修复项目列表状态互相污染

保留已确认项目的独立状态

隔离项目检查代次和迟到结果

增加坏项目刷新回归测试并同步项目文档
This commit is contained in:
kdletters
2026-09-21 01:06:41 +08:00
parent 12c1e5f3f8
commit 914791d63c
4 changed files with 126 additions and 3 deletions
@@ -3,6 +3,7 @@ import {
type SetStateAction,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
@@ -31,6 +32,9 @@ export function useRecentProjects(setStatus: Dispatch<SetStateAction<string>>) {
const [recentWorkspaceRefreshing, setRecentWorkspaceRefreshing] =
useState(false);
const [projectSearchQuery, setProjectSearchQuery] = useState('');
const recentWorkspacesRef = useRef(recentWorkspaces);
recentWorkspacesRef.current = recentWorkspaces;
const inspectionGenerationRef = useRef(0);
async function inspectRecentWorkspace(
invoke: NonNullable<ReturnType<typeof resolveTauriInvoke>>,
@@ -62,19 +66,36 @@ export function useRecentProjects(setStatus: Dispatch<SetStateAction<string>>) {
useEffect(() => {
const invoke = resolveTauriInvoke();
if (!invoke || recentWorkspaces.length === 0) {
inspectionGenerationRef.current += 1;
setRecentWorkspaceStatuses({});
setRecentWorkspaceRefreshing(false);
return;
}
const inspectionGeneration = ++inspectionGenerationRef.current;
let disposed = false;
let pendingCount = recentWorkspaces.length;
setRecentWorkspaceStatuses({});
// 保留已完成项目的最后一个独立结果。刷新是增量投影:只有新项目或
// 尚未完成检查的项目显示“检查中”,不能因为另一个坏目录而把整张列表
// 清空成同一个异常状态。
setRecentWorkspaceStatuses((current) => {
const next: Record<string, LocalProjectDirectoryStatus | null> = {};
for (const workspace of recentWorkspaces) {
if (Object.prototype.hasOwnProperty.call(current, workspace)) {
next[workspace] = current[workspace] ?? null;
}
}
return next;
});
setRecentWorkspaceRefreshing(true);
for (const workspace of recentWorkspaces) {
void inspectRecentWorkspace(invoke, workspace).then(
([projectPath, status]) => {
if (disposed) {
if (
disposed ||
inspectionGeneration !== inspectionGenerationRef.current ||
!recentWorkspacesRef.current.includes(projectPath)
) {
return;
}
setRecentWorkspaceStatuses((current) => ({
@@ -104,6 +125,9 @@ export function useRecentProjects(setStatus: Dispatch<SetStateAction<string>>) {
return;
}
const [, status] = await inspectRecentWorkspace(invoke, projectPath);
if (!recentWorkspacesRef.current.includes(projectPath)) {
return;
}
setRecentWorkspaceStatuses((current) => ({
...current,
[projectPath]: status,
@@ -0,0 +1,93 @@
// @vitest-environment jsdom
import { act, renderHook, waitFor } from '@testing-library/react';
import { afterEach, expect, test, vi } from 'vitest';
import { useRecentProjects } from '../src/features/app-shell/useRecentProjects';
const READY_PROJECT = {
projectPath: '/tmp/ready-project',
exists: true,
isDirectory: true,
isGameCreatorProject: true,
isGodotProject: false,
godotProjectRoot: null,
isCocosProject: false,
cocosProjectRoot: null,
isUnityProject: false,
unityProjectRoot: null,
projectName: '正常项目',
recentRunStatus: null,
recentRunStopReason: null,
};
afterEach(() => {
delete window.__TAURI__;
window.localStorage.clear();
});
test('刷新坏项目时保留其它项目已确认的正常状态', async () => {
let finishPendingInspection: (() => void) | null = null;
const pendingInspection = new Promise<typeof READY_PROJECT>((resolve) => {
finishPendingInspection = () =>
resolve({
...READY_PROJECT,
projectPath: '/tmp/slow-broken-project',
projectName: '慢速坏项目',
});
});
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command !== 'inspect_local_project_directory') {
throw new Error(`unexpected invoke ${command}`);
}
const projectPath = String(args?.projectPath ?? '');
if (projectPath === '/tmp/slow-broken-project') {
return pendingInspection;
}
return READY_PROJECT;
},
);
window.__TAURI__ = { core: { invoke } };
window.localStorage.setItem(
'genarrative-ai-game-creator.recent-workspaces.v1',
JSON.stringify(['/tmp/ready-project']),
);
const { result } = renderHook(() => useRecentProjects(vi.fn()));
await waitFor(() => {
expect(result.current.projectRows[0]).toMatchObject({
name: '正常项目',
status: '本地项目',
canOpen: true,
});
});
act(() => {
result.current.rememberRecentWorkspace('/tmp/slow-broken-project');
});
await waitFor(() => {
expect(result.current.projectRows).toHaveLength(2);
const readyRow = result.current.projectRows.find(
(row) => row.path === '/tmp/ready-project',
);
const slowRow = result.current.projectRows.find(
(row) => row.path === '/tmp/slow-broken-project',
);
expect(readyRow).toMatchObject({
name: '正常项目',
status: '本地项目',
canOpen: true,
});
expect(slowRow).toMatchObject({
name: 'slow-broken-project',
status: '检查中',
canOpen: false,
});
});
await act(async () => {
finishPendingInspection?.();
await pendingInspection;
});
});
@@ -15,6 +15,12 @@ Godot 编辑器操控复用既有 AGC 插件宿主、EditorAdapter、Runner 和
> 用途:记录已经确认、会影响后续开发的长期技术/产品/协作决策。短期讨论不要写在这里。
> 当前口径(2026-09-18):历史条目的旧路径、旧版本和已退役对象只用于追溯,不构成现行实现依据。策划 Agent V1/V2 的 Runtime、专用命令、审批卡、展示适配和旧测试已删除;当前策划入口统一使用 Design Agent。如与当前代码或 `docs/README.md` 冲突,以当前代码和最新专题文档为准。
## 2026-09-20 最近项目检查保持项目级隔离
- 背景:最近项目刷新会重新检查所有路径。若其中一个目录损坏、超时或不可读,清空整张状态表会让已确认正常的项目暂时全部显示“检查中”,用户只能移除坏项目后看到列表恢复。
- 决策:最近项目状态按路径独立投影;刷新时保留仍在列表中的最后一次结果,只有新增或尚未检查的项目进入“检查中”。检查代次或列表成员变化后,迟到结果不得写回,单个项目的失败不能改变其它项目的可打开状态。
- 验证:`recentProjectsHook.test.tsx` 覆盖“新增慢/坏项目刷新时保留正常项目”;`recentProjectsModel.test.ts``unityProjectOpen.test.tsx` 与前端类型检查一并执行。
## 2026-09-17 GameCreationApp 资源 kind 只保留一份词汇表:严格解析 + `app_log!` 留痕
- 背景:kind 曾经有三份实现——Rust 手写 `GAME_CREATION_APP_CANONICAL_ASSET_KINDS` + `canonical_game_creation_app_asset_kind()`(带 legacy 别名表与 `font → document` 特例)、TS 手写 `GAME_CREATION_APP_CANONICAL_ASSET_KINDS` + `GAME_CREATION_APP_LEGACY_ASSET_KINDS` + `canonicalGameCreationAppAssetKind()`、以及 ts-rs 生成的 TS union。两份手写表互相引用又各自收口,判据直接分叉(同一个 `"UI"` 一边归一成 `ui-design`、一边收口成 `unknown`),跨语言一致性只能靠正则解析源码的测试来钉。
@@ -29,7 +29,7 @@
### RecentProjectInspection
每个最近项目拥有独立 operation 和结果。检查中、可打开、失败、超时、不存在、非目录、未初始化和可导入不能通过一个全局刷新 gate 互相覆盖。
每个最近项目拥有独立 operation 和结果。检查中、可打开、失败、超时、不存在、非目录、未初始化和可导入不能通过一个全局刷新 gate 互相覆盖。刷新采用增量投影:已确认的项目结果继续保留,只有新增或尚未完成检查的项目显示“检查中”;项目被移除或检查代次变化后,迟到结果不得写回列表。
### DevStackIdentity