From 9eb3be4b11154892fbb5901df78595efaa9aa125 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Tue, 14 Apr 2026 16:02:35 +0800 Subject: [PATCH] perf: optimize recent projects loading with parallel git queries and caching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 目录选择器每次打开都串行执行大量 git 子进程导致卡顿,现改为并行查询 + 前后端双层 30s 缓存,选择新目录时自动失效缓存。 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/components/shared/DirectoryPicker.tsx | 20 +++- src/server/api/sessions.ts | 97 ++++++++++--------- 2 files changed, 71 insertions(+), 46 deletions(-) diff --git a/desktop/src/components/shared/DirectoryPicker.tsx b/desktop/src/components/shared/DirectoryPicker.tsx index 1e05a016..5cc87d35 100644 --- a/desktop/src/components/shared/DirectoryPicker.tsx +++ b/desktop/src/components/shared/DirectoryPicker.tsx @@ -11,6 +11,11 @@ type Props = { type DirEntry = { name: string; path: string; isDirectory: boolean } +// Module-level cache for recent projects (shared across instances, survives re-renders) +let cachedProjects: RecentProject[] | null = null +let cacheTimestamp = 0 +const CACHE_TTL = 30_000 // 30s + function isTauriRuntime() { return typeof window !== 'undefined' && ('__TAURI_INTERNALS__' in window || '__TAURI__' in window) } @@ -69,12 +74,21 @@ export function DirectoryPicker({ value, onChange }: Props) { } }, [isOpen, updateDropdownPos]) - // Load recent projects when opened + // Load recent projects when opened (with client-side cache) useEffect(() => { if (!isOpen || mode !== 'recent') return + // Use cache if fresh + if (cachedProjects && Date.now() - cacheTimestamp < CACHE_TTL) { + setProjects(cachedProjects) + return + } setLoading(true) sessionsApi.getRecentProjects() - .then(({ projects }) => setProjects(projects)) + .then(({ projects: p }) => { + cachedProjects = p + cacheTimestamp = Date.now() + setProjects(p) + }) .catch(() => setProjects([])) .finally(() => setLoading(false)) }, [isOpen, mode]) @@ -94,6 +108,8 @@ export function DirectoryPicker({ value, onChange }: Props) { onChange(path) setIsOpen(false) setMode('recent') + // Invalidate cache so next open reflects the new selection + cachedProjects = null } const handleChooseFolder = async () => { diff --git a/src/server/api/sessions.ts b/src/server/api/sessions.ts index dad71ff5..0fdf3f19 100644 --- a/src/server/api/sessions.ts +++ b/src/server/api/sessions.ts @@ -244,14 +244,22 @@ async function patchSession(req: Request, sessionId: string): Promise return Response.json({ ok: true }) } +// In-memory cache for recent projects (TTL: 30s) +let recentProjectsCache: { data: Response; timestamp: number } | null = null +const RECENT_PROJECTS_CACHE_TTL = 30_000 + async function getRecentProjects(): Promise { + // Return cached response if fresh + if (recentProjectsCache && Date.now() - recentProjectsCache.timestamp < RECENT_PROJECTS_CACHE_TTL) { + return recentProjectsCache.data.clone() + } + const { sessions } = await sessionService.listSessions({ limit: 200 }) const validSessions = sessions.filter((session) => session.workDirExists && session.workDir) // First pass: resolve realPath for each session and group by realPath to dedup const realPathMap = new Map() for (const s of validSessions) { - // Resolve the real path for dedup let realPath: string try { const workDir = await sessionService.getSessionWorkDir(s.id) @@ -273,57 +281,58 @@ async function getRecentProjects(): Promise { } } - // Build project list with git info - const projects: Array<{ - projectPath: string - realPath: string - projectName: string - isGit: boolean - repoName: string | null - branch: string | null - modifiedAt: string - sessionCount: number - }> = [] + // Build project list with git info — parallelize git operations + const entries = Array.from(realPathMap.entries()) + const projects = await Promise.all( + entries.map(async ([realPath, info]) => { + const projectName = realPath.split('/').filter(Boolean).pop() || info.projectPath - for (const [realPath, info] of realPathMap) { - const projectName = realPath.split('/').filter(Boolean).pop() || info.projectPath - - // Check if it's a git repo - let isGit = false - let repoName: string | null = null - let branch: string | null = null - try { - const proc = Bun.spawn(['git', 'rev-parse', '--is-inside-work-tree'], { - cwd: realPath, stdout: 'pipe', stderr: 'pipe', - }) - const out = await new Response(proc.stdout).text() - isGit = out.trim() === 'true' - - if (isGit) { - const branchProc = Bun.spawn(['git', 'rev-parse', '--abbrev-ref', 'HEAD'], { + let isGit = false + let repoName: string | null = null + let branch: string | null = null + try { + const proc = Bun.spawn(['git', 'rev-parse', '--is-inside-work-tree'], { cwd: realPath, stdout: 'pipe', stderr: 'pipe', }) - branch = (await new Response(branchProc.stdout).text()).trim() || null + const out = await new Response(proc.stdout).text() + isGit = out.trim() === 'true' - try { - const remoteProc = Bun.spawn(['git', 'remote', 'get-url', 'origin'], { - cwd: realPath, stdout: 'pipe', stderr: 'pipe', - }) - const remote = (await new Response(remoteProc.stdout).text()).trim() - const match = remote.match(/:([^/]+\/[^/]+?)(?:\.git)?$/) || remote.match(/\/([^/]+\/[^/]+?)(?:\.git)?$/) - repoName = match ? match[1]! : null - } catch { /* no remote */ } + if (isGit) { + // Run branch + remote in parallel + const [branchResult, remoteResult] = await Promise.all([ + (async () => { + const branchProc = Bun.spawn(['git', 'rev-parse', '--abbrev-ref', 'HEAD'], { + cwd: realPath, stdout: 'pipe', stderr: 'pipe', + }) + return (await new Response(branchProc.stdout).text()).trim() || null + })(), + (async () => { + try { + const remoteProc = Bun.spawn(['git', 'remote', 'get-url', 'origin'], { + cwd: realPath, stdout: 'pipe', stderr: 'pipe', + }) + const remote = (await new Response(remoteProc.stdout).text()).trim() + const match = remote.match(/:([^/]+\/[^/]+?)(?:\.git)?$/) || remote.match(/\/([^/]+\/[^/]+?)(?:\.git)?$/) + return match ? match[1]! : null + } catch { return null } + })(), + ]) + branch = branchResult + repoName = remoteResult + } + } catch { /* not a git repo or dir doesn't exist */ } + + return { + projectPath: info.projectPath, realPath, projectName, isGit, repoName, branch, + modifiedAt: info.modifiedAt, sessionCount: info.sessionCount, } - } catch { /* not a git repo or dir doesn't exist */ } - - projects.push({ - projectPath: info.projectPath, realPath, projectName, isGit, repoName, branch, - modifiedAt: info.modifiedAt, sessionCount: info.sessionCount, }) - } + ) // Sort by most recent projects.sort((a, b) => b.modifiedAt.localeCompare(a.modifiedAt)) - return Response.json({ projects: projects.slice(0, 10) }) + const response = Response.json({ projects: projects.slice(0, 10) }) + recentProjectsCache = { data: response.clone(), timestamp: Date.now() } + return response }