Files
Genarrative/apps/ai-game-creator-shell/src/features/app-shell/useRecentProjects.ts
T
kdletters 8701f43f75 AGC 渲染层网络与状态下沉 Rust,并修复画布交互与失败文案
- 删除渲染层传输层: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 中无调用方的残留。
2026-09-28 14:15:02 +08:00

434 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>;