mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-21 14:03:35 +08:00
perf: optimize recent projects loading with parallel git queries and caching
目录选择器每次打开都串行执行大量 git 子进程导致卡顿,现改为并行查询 + 前后端双层 30s 缓存,选择新目录时自动失效缓存。 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
8888e1c3fb
commit
9eb3be4b11
@ -11,6 +11,11 @@ type Props = {
|
|||||||
|
|
||||||
type DirEntry = { name: string; path: string; isDirectory: boolean }
|
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() {
|
function isTauriRuntime() {
|
||||||
return typeof window !== 'undefined' && ('__TAURI_INTERNALS__' in window || '__TAURI__' in window)
|
return typeof window !== 'undefined' && ('__TAURI_INTERNALS__' in window || '__TAURI__' in window)
|
||||||
}
|
}
|
||||||
@ -69,12 +74,21 @@ export function DirectoryPicker({ value, onChange }: Props) {
|
|||||||
}
|
}
|
||||||
}, [isOpen, updateDropdownPos])
|
}, [isOpen, updateDropdownPos])
|
||||||
|
|
||||||
// Load recent projects when opened
|
// Load recent projects when opened (with client-side cache)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen || mode !== 'recent') return
|
if (!isOpen || mode !== 'recent') return
|
||||||
|
// Use cache if fresh
|
||||||
|
if (cachedProjects && Date.now() - cacheTimestamp < CACHE_TTL) {
|
||||||
|
setProjects(cachedProjects)
|
||||||
|
return
|
||||||
|
}
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
sessionsApi.getRecentProjects()
|
sessionsApi.getRecentProjects()
|
||||||
.then(({ projects }) => setProjects(projects))
|
.then(({ projects: p }) => {
|
||||||
|
cachedProjects = p
|
||||||
|
cacheTimestamp = Date.now()
|
||||||
|
setProjects(p)
|
||||||
|
})
|
||||||
.catch(() => setProjects([]))
|
.catch(() => setProjects([]))
|
||||||
.finally(() => setLoading(false))
|
.finally(() => setLoading(false))
|
||||||
}, [isOpen, mode])
|
}, [isOpen, mode])
|
||||||
@ -94,6 +108,8 @@ export function DirectoryPicker({ value, onChange }: Props) {
|
|||||||
onChange(path)
|
onChange(path)
|
||||||
setIsOpen(false)
|
setIsOpen(false)
|
||||||
setMode('recent')
|
setMode('recent')
|
||||||
|
// Invalidate cache so next open reflects the new selection
|
||||||
|
cachedProjects = null
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleChooseFolder = async () => {
|
const handleChooseFolder = async () => {
|
||||||
|
|||||||
@ -244,14 +244,22 @@ async function patchSession(req: Request, sessionId: string): Promise<Response>
|
|||||||
return Response.json({ ok: true })
|
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<Response> {
|
async function getRecentProjects(): Promise<Response> {
|
||||||
|
// 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 { sessions } = await sessionService.listSessions({ limit: 200 })
|
||||||
const validSessions = sessions.filter((session) => session.workDirExists && session.workDir)
|
const validSessions = sessions.filter((session) => session.workDirExists && session.workDir)
|
||||||
|
|
||||||
// First pass: resolve realPath for each session and group by realPath to dedup
|
// First pass: resolve realPath for each session and group by realPath to dedup
|
||||||
const realPathMap = new Map<string, { projectPath: string; modifiedAt: string; sessionCount: number; sessionId: string }>()
|
const realPathMap = new Map<string, { projectPath: string; modifiedAt: string; sessionCount: number; sessionId: string }>()
|
||||||
for (const s of validSessions) {
|
for (const s of validSessions) {
|
||||||
// Resolve the real path for dedup
|
|
||||||
let realPath: string
|
let realPath: string
|
||||||
try {
|
try {
|
||||||
const workDir = await sessionService.getSessionWorkDir(s.id)
|
const workDir = await sessionService.getSessionWorkDir(s.id)
|
||||||
@ -273,57 +281,58 @@ async function getRecentProjects(): Promise<Response> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build project list with git info
|
// Build project list with git info — parallelize git operations
|
||||||
const projects: Array<{
|
const entries = Array.from(realPathMap.entries())
|
||||||
projectPath: string
|
const projects = await Promise.all(
|
||||||
realPath: string
|
entries.map(async ([realPath, info]) => {
|
||||||
projectName: string
|
const projectName = realPath.split('/').filter(Boolean).pop() || info.projectPath
|
||||||
isGit: boolean
|
|
||||||
repoName: string | null
|
|
||||||
branch: string | null
|
|
||||||
modifiedAt: string
|
|
||||||
sessionCount: number
|
|
||||||
}> = []
|
|
||||||
|
|
||||||
for (const [realPath, info] of realPathMap) {
|
let isGit = false
|
||||||
const projectName = realPath.split('/').filter(Boolean).pop() || info.projectPath
|
let repoName: string | null = null
|
||||||
|
let branch: string | null = null
|
||||||
// Check if it's a git repo
|
try {
|
||||||
let isGit = false
|
const proc = Bun.spawn(['git', 'rev-parse', '--is-inside-work-tree'], {
|
||||||
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'], {
|
|
||||||
cwd: realPath, stdout: 'pipe', stderr: 'pipe',
|
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 {
|
if (isGit) {
|
||||||
const remoteProc = Bun.spawn(['git', 'remote', 'get-url', 'origin'], {
|
// Run branch + remote in parallel
|
||||||
cwd: realPath, stdout: 'pipe', stderr: 'pipe',
|
const [branchResult, remoteResult] = await Promise.all([
|
||||||
})
|
(async () => {
|
||||||
const remote = (await new Response(remoteProc.stdout).text()).trim()
|
const branchProc = Bun.spawn(['git', 'rev-parse', '--abbrev-ref', 'HEAD'], {
|
||||||
const match = remote.match(/:([^/]+\/[^/]+?)(?:\.git)?$/) || remote.match(/\/([^/]+\/[^/]+?)(?:\.git)?$/)
|
cwd: realPath, stdout: 'pipe', stderr: 'pipe',
|
||||||
repoName = match ? match[1]! : null
|
})
|
||||||
} catch { /* no remote */ }
|
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
|
// Sort by most recent
|
||||||
projects.sort((a, b) => b.modifiedAt.localeCompare(a.modifiedAt))
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user