1e4ff4da6b
认证响应体读取加入可取消超时并释放 refresh singleflight 将 Runner 会话安装与清除移到 blocking worker 并增加 UI fence 让最近项目逐项检查且单目录超时不阻塞其它项目 将首页自动创建锁提升到 WorkspaceLauncher 生命周期 补充定向测试、跨页创建测试和异步闭环规范
195 lines
5.8 KiB
TypeScript
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>;
|