8701f43f75
- 删除渲染层传输层:services/clientApi.ts、clientHttp 的 fetchClientHttp 与超时封装整体退场,账户、认证、素材直传、错误上报与游戏分发发布改走 Rust typed command。 - 新增宿主封装:accountHost、editorAssetHost、platformSessionRetry、agentRuntimeErrorDetail 与 features/app-shell/workspacePreferences;渲染层不再读写 access token / refresh cookie,localStorage 只剩测试态旧键迁移 shim。 - 修复 DirectProject 失败文案 v2 映射与「查看详情」按需读取,画布标签面板高度夹紧、框选每帧重算、文档预览入口与场景标题栏 key、资源筛选面板样式。 - 补齐与调整回归:accountHost、agentRuntimeErrorDetail、clientAuthHost、clientHttp、platformSession、platformSessionRetry、resourceFilterPanelStyle、directThreadChatSubscription 等定向用例,并清理测试 harness 中无调用方的残留。
434 lines
14 KiB
TypeScript
434 lines
14 KiB
TypeScript
import {
|
||
type Dispatch,
|
||
type SetStateAction,
|
||
useEffect,
|
||
useMemo,
|
||
useRef,
|
||
useState,
|
||
} from 'react';
|
||
|
||
import { resolveTauriInvoke } from '../../app/tauri';
|
||
import type { LocalProjectDirectoryStatus, TauriInvoke } from '../../app/types';
|
||
import {
|
||
isAbsoluteProjectPath,
|
||
projectPathHasControlCharacter,
|
||
} from '../project-summary/projectSummary';
|
||
import { buildRecentProjectRows } from './model';
|
||
import {
|
||
readWorkspacePreferences,
|
||
rememberRecentWorkspace as rememberRecentWorkspacePreference,
|
||
removeRecentWorkspace as removeRecentWorkspacePreference,
|
||
subscribeWorkspacePreferences,
|
||
} from './workspacePreferences';
|
||
|
||
const RECENT_WORKSPACE_CHECK_TIMEOUT_MS = 5_000;
|
||
/** 单次检查失败后的就地重试退避,数组长度即重试次数。 */
|
||
const RECENT_WORKSPACE_CHECK_RETRY_DELAYS_MS = [300];
|
||
/** 一轮结束仍有可重试失败时重跑整张列表的退避,数组长度即重跑次数上限。 */
|
||
const RECENT_WORKSPACE_FAILURE_RECHECK_DELAYS_MS = [15_000, 45_000, 120_000];
|
||
/**
|
||
* 提权/权限类失败不重试:Rust 侧会重新走 `Start-Process -Verb RunAs -Wait`,
|
||
* 而提权闸门只存在于单次 invoke 内,重试等于在用户刚点「否」后再弹一次 UAC。
|
||
* 判据与 config.rs 的 `windows_acl_error_may_need_elevation` 同口径。
|
||
*/
|
||
const RECENT_WORKSPACE_ELEVATION_ERROR_MARKERS = [
|
||
'DACL',
|
||
'权限',
|
||
'error 5',
|
||
'安全对象不属于当前用户',
|
||
'启用 Windows',
|
||
'特权',
|
||
'1300',
|
||
'AGC ACL 提权修复未成功',
|
||
];
|
||
|
||
type RecentWorkspaceInspection = {
|
||
path: string;
|
||
status: LocalProjectDirectoryStatus | null;
|
||
/** false 表示提权/权限类失败:既不重试,也不驱动整表重查。 */
|
||
retryable: boolean;
|
||
};
|
||
|
||
function recentWorkspaceFailureIsRetryable(message: string): boolean {
|
||
return !RECENT_WORKSPACE_ELEVATION_ERROR_MARKERS.some((marker) =>
|
||
message.includes(marker),
|
||
);
|
||
}
|
||
|
||
async function inspectRecentWorkspace(
|
||
invoke: TauriInvoke,
|
||
workspace: string,
|
||
): Promise<RecentWorkspaceInspection> {
|
||
let timeoutHandle: number | undefined;
|
||
try {
|
||
const status = await Promise.race([
|
||
invoke<LocalProjectDirectoryStatus>('inspect_local_project_directory', {
|
||
projectPath: workspace,
|
||
}),
|
||
new Promise<never>((_, reject) => {
|
||
timeoutHandle = window.setTimeout(
|
||
() => reject(new Error('项目目录检查超时')),
|
||
RECENT_WORKSPACE_CHECK_TIMEOUT_MS,
|
||
);
|
||
}),
|
||
]);
|
||
return { path: workspace, status, retryable: true };
|
||
} catch (error) {
|
||
const message = error instanceof Error ? error.message : String(error);
|
||
return {
|
||
path: workspace,
|
||
status: null,
|
||
retryable: recentWorkspaceFailureIsRetryable(message),
|
||
};
|
||
} finally {
|
||
if (timeoutHandle !== undefined) {
|
||
window.clearTimeout(timeoutHandle);
|
||
}
|
||
}
|
||
}
|
||
|
||
async function inspectRecentWorkspaceWithRetry(
|
||
invoke: TauriInvoke,
|
||
workspace: string,
|
||
): Promise<RecentWorkspaceInspection> {
|
||
for (let attempt = 0; ; attempt += 1) {
|
||
const inspection = await inspectRecentWorkspace(invoke, workspace);
|
||
const retryDelayMs = RECENT_WORKSPACE_CHECK_RETRY_DELAYS_MS[attempt];
|
||
if (
|
||
inspection.status ||
|
||
!inspection.retryable ||
|
||
retryDelayMs === undefined
|
||
) {
|
||
return inspection;
|
||
}
|
||
await new Promise<void>((resolve) => {
|
||
window.setTimeout(resolve, retryDelayMs);
|
||
});
|
||
}
|
||
}
|
||
|
||
export function useRecentProjects(setStatus: Dispatch<SetStateAction<string>>) {
|
||
const setStatusRef = useRef(setStatus);
|
||
setStatusRef.current = setStatus;
|
||
const [recentWorkspaces, setRecentWorkspaces] = useState<string[]>([]);
|
||
const [recentWorkspaceStatuses, setRecentWorkspaceStatuses] = useState<
|
||
Record<string, LocalProjectDirectoryStatus | null>
|
||
>({});
|
||
const [recentWorkspaceRefreshKey, setRecentWorkspaceRefreshKey] = useState(0);
|
||
const [recentWorkspaceRefreshing, setRecentWorkspaceRefreshing] =
|
||
useState(false);
|
||
const [projectSearchQuery, setProjectSearchQuery] = useState('');
|
||
const recentWorkspacesRef = useRef(recentWorkspaces);
|
||
recentWorkspacesRef.current = recentWorkspaces;
|
||
const inspectionGenerationRef = useRef(0);
|
||
const failureRecheckTimerRef = useRef<number | undefined>(undefined);
|
||
const failureStreakRef = useRef(0);
|
||
const lastRetryableFailureKeyRef = useRef('');
|
||
// 提权类失败在用户再次主动操作前不再自动重试。
|
||
const nonRetryablePathsRef = useRef<Set<string>>(new Set());
|
||
const recentWorkspaceSignatureRef = useRef('');
|
||
const workspacePreferenceEventGenerationRef = useRef(0);
|
||
|
||
function applyWorkspacePreferencesSnapshot(
|
||
recent: readonly string[],
|
||
): boolean {
|
||
const signature = recent.join('\u0000');
|
||
if (recentWorkspaceSignatureRef.current === signature) {
|
||
return false;
|
||
}
|
||
recentWorkspaceSignatureRef.current = signature;
|
||
setRecentWorkspaces([...recent]);
|
||
setRecentWorkspaceRefreshKey((current) => current + 1);
|
||
return true;
|
||
}
|
||
|
||
useEffect(() => {
|
||
let disposed = false;
|
||
let unsubscribe: (() => void) | null = null;
|
||
const setup = async () => {
|
||
try {
|
||
unsubscribe = await subscribeWorkspacePreferences((snapshot) => {
|
||
if (!disposed) {
|
||
workspacePreferenceEventGenerationRef.current += 1;
|
||
applyWorkspacePreferencesSnapshot(snapshot.recentWorkspaces);
|
||
}
|
||
});
|
||
} catch (error) {
|
||
if (!disposed) {
|
||
setStatusRef.current(
|
||
error instanceof Error ? error.message : String(error),
|
||
);
|
||
}
|
||
}
|
||
if (disposed) {
|
||
return;
|
||
}
|
||
const eventGenerationAtRead =
|
||
workspacePreferenceEventGenerationRef.current;
|
||
try {
|
||
const snapshot = await readWorkspacePreferences();
|
||
if (
|
||
!disposed &&
|
||
workspacePreferenceEventGenerationRef.current ===
|
||
eventGenerationAtRead
|
||
) {
|
||
applyWorkspacePreferencesSnapshot(snapshot.recentWorkspaces);
|
||
}
|
||
} catch (error) {
|
||
if (!disposed) {
|
||
setStatusRef.current(
|
||
error instanceof Error ? error.message : String(error),
|
||
);
|
||
}
|
||
}
|
||
};
|
||
void setup();
|
||
return () => {
|
||
disposed = true;
|
||
unsubscribe?.();
|
||
};
|
||
}, []);
|
||
|
||
function scheduleFailureRecheck(retryableFailureCount: number) {
|
||
window.clearTimeout(failureRecheckTimerRef.current);
|
||
if (retryableFailureCount === 0) {
|
||
failureStreakRef.current = 0;
|
||
return;
|
||
}
|
||
const recheckDelayMs =
|
||
RECENT_WORKSPACE_FAILURE_RECHECK_DELAYS_MS[failureStreakRef.current];
|
||
if (recheckDelayMs === undefined) {
|
||
return;
|
||
}
|
||
failureStreakRef.current += 1;
|
||
failureRecheckTimerRef.current = window.setTimeout(() => {
|
||
failureRecheckTimerRef.current = undefined;
|
||
setRecentWorkspaceRefreshKey((current) => current + 1);
|
||
}, recheckDelayMs);
|
||
}
|
||
|
||
useEffect(() => {
|
||
const invoke = resolveTauriInvoke();
|
||
if (!invoke || recentWorkspaces.length === 0) {
|
||
inspectionGenerationRef.current += 1;
|
||
setRecentWorkspaceStatuses({});
|
||
setRecentWorkspaceRefreshing(false);
|
||
return;
|
||
}
|
||
const inspectionGeneration = ++inspectionGenerationRef.current;
|
||
let disposed = false;
|
||
const pendingWorkspaces = recentWorkspaces.filter(
|
||
(workspace) => !nonRetryablePathsRef.current.has(workspace),
|
||
);
|
||
let pendingCount = pendingWorkspaces.length;
|
||
const retryableFailures: string[] = [];
|
||
const finishRound = () => {
|
||
setRecentWorkspaceRefreshing(false);
|
||
// 失败集合变化即重置退避预算,避免长期坏目录吃满新瞬时失败的额度。
|
||
const failureKey = [...retryableFailures].sort().join('|');
|
||
if (failureKey !== lastRetryableFailureKeyRef.current) {
|
||
lastRetryableFailureKeyRef.current = failureKey;
|
||
failureStreakRef.current = 0;
|
||
}
|
||
scheduleFailureRecheck(retryableFailures.length);
|
||
};
|
||
// 刷新是增量投影:成功结果保留,但上一轮的失败结果不进新投影,
|
||
// 失败项回到「检查中」并在本轮重新检查。
|
||
setRecentWorkspaceStatuses((current) => {
|
||
const next: Record<string, LocalProjectDirectoryStatus | null> = {};
|
||
for (const workspace of recentWorkspaces) {
|
||
const status = current[workspace];
|
||
if (status) {
|
||
next[workspace] = status;
|
||
}
|
||
}
|
||
return next;
|
||
});
|
||
setRecentWorkspaceRefreshing(true);
|
||
|
||
if (pendingCount === 0) {
|
||
finishRound();
|
||
return;
|
||
}
|
||
|
||
for (const workspace of pendingWorkspaces) {
|
||
void inspectRecentWorkspaceWithRetry(invoke, workspace).then(
|
||
({ path: projectPath, status, retryable }) => {
|
||
if (
|
||
disposed ||
|
||
inspectionGeneration !== inspectionGenerationRef.current ||
|
||
!recentWorkspacesRef.current.includes(projectPath)
|
||
) {
|
||
return;
|
||
}
|
||
if (status) {
|
||
nonRetryablePathsRef.current.delete(projectPath);
|
||
} else if (retryable) {
|
||
retryableFailures.push(projectPath);
|
||
} else {
|
||
nonRetryablePathsRef.current.add(projectPath);
|
||
}
|
||
setRecentWorkspaceStatuses((current) => ({
|
||
...current,
|
||
[projectPath]: status,
|
||
}));
|
||
pendingCount -= 1;
|
||
if (pendingCount === 0) {
|
||
finishRound();
|
||
}
|
||
},
|
||
);
|
||
}
|
||
return () => {
|
||
disposed = true;
|
||
window.clearTimeout(failureRecheckTimerRef.current);
|
||
failureRecheckTimerRef.current = undefined;
|
||
};
|
||
}, [recentWorkspaces, recentWorkspaceRefreshKey]);
|
||
|
||
function rememberRecentWorkspace(projectPath: string) {
|
||
// 用户主动打开或新建项目:解除提权类失败的跳过标记。
|
||
nonRetryablePathsRef.current.clear();
|
||
const eventGenerationAtCommand =
|
||
workspacePreferenceEventGenerationRef.current;
|
||
void rememberRecentWorkspacePreference(projectPath)
|
||
.then((snapshot) => {
|
||
if (
|
||
workspacePreferenceEventGenerationRef.current ===
|
||
eventGenerationAtCommand
|
||
) {
|
||
applyWorkspacePreferencesSnapshot(snapshot.recentWorkspaces);
|
||
}
|
||
})
|
||
.catch((error) => {
|
||
setStatusRef.current(
|
||
error instanceof Error ? error.message : String(error),
|
||
);
|
||
});
|
||
}
|
||
|
||
async function refreshRecentWorkspace(projectPath: string) {
|
||
const invoke = resolveTauriInvoke();
|
||
if (!invoke) {
|
||
return;
|
||
}
|
||
nonRetryablePathsRef.current.delete(projectPath);
|
||
const inspection = await inspectRecentWorkspaceWithRetry(
|
||
invoke,
|
||
projectPath,
|
||
);
|
||
if (!recentWorkspacesRef.current.includes(projectPath)) {
|
||
return;
|
||
}
|
||
if (inspection.status) {
|
||
nonRetryablePathsRef.current.delete(projectPath);
|
||
} else if (inspection.retryable) {
|
||
scheduleFailureRecheck(1);
|
||
} else {
|
||
nonRetryablePathsRef.current.add(projectPath);
|
||
}
|
||
setRecentWorkspaceStatuses((current) => ({
|
||
...current,
|
||
[projectPath]: inspection.status,
|
||
}));
|
||
}
|
||
|
||
function handleRecentWorkspaceRemove(projectPath: string) {
|
||
nonRetryablePathsRef.current.delete(projectPath);
|
||
const eventGenerationAtCommand =
|
||
workspacePreferenceEventGenerationRef.current;
|
||
void removeRecentWorkspacePreference(projectPath)
|
||
.then((snapshot) => {
|
||
if (
|
||
workspacePreferenceEventGenerationRef.current ===
|
||
eventGenerationAtCommand
|
||
) {
|
||
applyWorkspacePreferencesSnapshot(snapshot.recentWorkspaces);
|
||
}
|
||
setRecentWorkspaceStatuses((current) => {
|
||
const { [projectPath]: _removed, ...rest } = current;
|
||
return rest;
|
||
});
|
||
})
|
||
.catch((error) => {
|
||
setStatusRef.current(
|
||
error instanceof Error ? error.message : String(error),
|
||
);
|
||
});
|
||
}
|
||
|
||
async function handleRevealProjectDirectory(projectPath: string) {
|
||
const trimmedProjectPath = projectPath.trim();
|
||
if (!trimmedProjectPath || !isAbsoluteProjectPath(trimmedProjectPath)) {
|
||
setStatus('请提供项目绝对路径');
|
||
return;
|
||
}
|
||
if (projectPathHasControlCharacter(trimmedProjectPath)) {
|
||
setStatus('项目目录不能包含控制字符');
|
||
return;
|
||
}
|
||
const invoke = resolveTauriInvoke();
|
||
if (!invoke) {
|
||
setStatus('需要在 Tauri App 内运行');
|
||
return;
|
||
}
|
||
try {
|
||
await invoke('open_local_project_directory', {
|
||
projectPath: trimmedProjectPath,
|
||
});
|
||
setStatus('已打开项目目录');
|
||
} catch (error) {
|
||
setStatus(error instanceof Error ? error.message : String(error));
|
||
}
|
||
}
|
||
|
||
const projectRows = buildRecentProjectRows(
|
||
recentWorkspaces,
|
||
recentWorkspaceStatuses,
|
||
recentWorkspaceRefreshing,
|
||
);
|
||
const filteredProjectRows = useMemo(() => {
|
||
const normalizedQuery = projectSearchQuery.trim().toLocaleLowerCase();
|
||
if (!normalizedQuery) {
|
||
return projectRows;
|
||
}
|
||
return projectRows.filter((project) =>
|
||
[
|
||
project.name,
|
||
project.path,
|
||
project.status,
|
||
project.godotProjectRoot ?? '',
|
||
project.projectKind === 'cocos'
|
||
? 'cocos creator'
|
||
: project.projectKind === 'unity'
|
||
? 'unity'
|
||
: project.projectKind === 'ue'
|
||
? 'ue unreal engine'
|
||
: project.projectKind === 'godot'
|
||
? 'godot'
|
||
: project.projectKind === 'web'
|
||
? 'phaser gameagent web'
|
||
: 'unknown',
|
||
].some((value) => value.toLocaleLowerCase().includes(normalizedQuery)),
|
||
);
|
||
}, [projectRows, projectSearchQuery]);
|
||
|
||
return {
|
||
recentWorkspaces,
|
||
recentWorkspaceRefreshing,
|
||
projectRows,
|
||
filteredProjectRows,
|
||
projectSearchQuery,
|
||
setProjectSearchQuery,
|
||
recentProjectRows: projectRows
|
||
.filter((project) => project.canOpen)
|
||
.slice(0, 3),
|
||
rememberRecentWorkspace,
|
||
refreshRecentWorkspace,
|
||
handleRecentWorkspaceRemove,
|
||
handleRevealProjectDirectory,
|
||
};
|
||
}
|
||
|
||
export type RecentProjectsController = ReturnType<typeof useRecentProjects>;
|