Files
Genarrative/apps/ai-game-creator-shell/src/features/app-shell/useRecentProjects.ts
T
kdletters 1e4ff4da6b
Project CI / Repository checks (pull_request) Successful in 2m40s
Project CI / Frontend tests (pull_request) Successful in 3m12s
Project CI / Backend tests (pull_request) Successful in 6m1s
Project CI / Native shell tests (pull_request) Successful in 20m9s
修复 AGC 异步操作恢复闭环
认证响应体读取加入可取消超时并释放 refresh singleflight

将 Runner 会话安装与清除移到 blocking worker 并增加 UI fence

让最近项目逐项检查且单目录超时不阻塞其它项目

将首页自动创建锁提升到 WorkspaceLauncher 生命周期

补充定向测试、跨页创建测试和异步闭环规范
2026-09-14 12:31:40 +08:00

195 lines
5.8 KiB
TypeScript

import {
type Dispatch,
type SetStateAction,
useEffect,
useMemo,
useState,
} from 'react';
import { resolveTauriInvoke } from '../../app/tauri';
import type { LocalProjectDirectoryStatus } from '../../app/types';
import {
isAbsoluteProjectPath,
projectPathHasControlCharacter,
} from '../project-summary/projectSummary';
import {
buildRecentProjectRows,
readRecentWorkspaces,
removeRecentWorkspace,
writeRecentWorkspace,
} from './model';
const RECENT_WORKSPACE_CHECK_TIMEOUT_MS = 5_000;
export function useRecentProjects(setStatus: Dispatch<SetStateAction<string>>) {
const [recentWorkspaces, setRecentWorkspaces] =
useState<string[]>(readRecentWorkspaces);
const [recentWorkspaceStatuses, setRecentWorkspaceStatuses] = useState<
Record<string, LocalProjectDirectoryStatus | null>
>({});
const [recentWorkspaceRefreshKey, setRecentWorkspaceRefreshKey] = useState(0);
const [recentWorkspaceRefreshing, setRecentWorkspaceRefreshing] =
useState(false);
const [projectSearchQuery, setProjectSearchQuery] = useState('');
async function inspectRecentWorkspace(
invoke: NonNullable<ReturnType<typeof resolveTauriInvoke>>,
workspace: string,
): Promise<[string, LocalProjectDirectoryStatus | null]> {
let timeoutHandle: number | undefined;
try {
const result = 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 [workspace, result];
} catch {
return [workspace, null];
} finally {
if (timeoutHandle !== undefined) {
window.clearTimeout(timeoutHandle);
}
}
}
useEffect(() => {
const invoke = resolveTauriInvoke();
if (!invoke || recentWorkspaces.length === 0) {
setRecentWorkspaceStatuses({});
setRecentWorkspaceRefreshing(false);
return;
}
let disposed = false;
let pendingCount = recentWorkspaces.length;
setRecentWorkspaceStatuses({});
setRecentWorkspaceRefreshing(true);
for (const workspace of recentWorkspaces) {
void inspectRecentWorkspace(invoke, workspace).then(
([projectPath, status]) => {
if (disposed) {
return;
}
setRecentWorkspaceStatuses((current) => ({
...current,
[projectPath]: status,
}));
pendingCount -= 1;
if (pendingCount === 0) {
setRecentWorkspaceRefreshing(false);
}
},
);
}
return () => {
disposed = true;
};
}, [recentWorkspaces, recentWorkspaceRefreshKey]);
function rememberRecentWorkspace(projectPath: string) {
setRecentWorkspaces(writeRecentWorkspace(projectPath));
setRecentWorkspaceRefreshKey((current) => current + 1);
}
async function refreshRecentWorkspace(projectPath: string) {
const invoke = resolveTauriInvoke();
if (!invoke) {
return;
}
const [, status] = await inspectRecentWorkspace(invoke, projectPath);
setRecentWorkspaceStatuses((current) => ({
...current,
[projectPath]: status,
}));
}
function handleRecentWorkspaceRemove(projectPath: string) {
setRecentWorkspaces(removeRecentWorkspace(projectPath));
setRecentWorkspaceStatuses((current) => {
const { [projectPath]: _removed, ...rest } = current;
return rest;
});
}
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>;