diff --git a/src/hooks/useFileExplorer.test.tsx b/src/hooks/useFileExplorer.test.tsx index a0121291..90724f7b 100644 --- a/src/hooks/useFileExplorer.test.tsx +++ b/src/hooks/useFileExplorer.test.tsx @@ -76,4 +76,60 @@ describe('useFileExplorer change scope', () => { expect(result.current.fileStatus.get('src/session.ts')).toBeUndefined() expect(getLastTurnDiff).toHaveBeenCalledWith('session-1', '/repo') }) + + it('restores expanded folders per directory when switching projects', async () => { + listDirectory.mockImplementation(async (parentPath: string, directory: string) => { + if (parentPath === '') { + return [{ name: 'src', path: 'src', absolute: `${directory}/src`, type: 'directory', ignored: false }] + } + + if (parentPath === 'src') { + return [ + { + name: directory === '/repo-a' ? 'a.ts' : 'b.ts', + path: `src/${directory === '/repo-a' ? 'a.ts' : 'b.ts'}`, + absolute: `${directory}/src/${directory === '/repo-a' ? 'a.ts' : 'b.ts'}`, + type: 'file', + ignored: false, + }, + ] + } + + return [] + }) + + const { result, rerender } = renderHook( + ({ directory }) => useFileExplorer({ directory, autoLoad: true }), + { initialProps: { directory: '/repo-a' } }, + ) + + await waitFor(() => { + expect(result.current.tree).toHaveLength(1) + }) + + act(() => { + result.current.toggleExpand('src') + }) + + await waitFor(() => { + expect(result.current.expandedPaths.has('src')).toBe(true) + expect(result.current.tree[0]?.children?.[0]?.path).toBe('src/a.ts') + }) + + rerender({ directory: '/repo-b' }) + + await waitFor(() => { + expect(result.current.tree[0]?.absolute).toBe('/repo-b/src') + expect(result.current.tree[0]?.children?.[0]?.path).toBeUndefined() + expect(result.current.expandedPaths.has('src')).toBe(false) + }) + + rerender({ directory: '/repo-a' }) + + await waitFor(() => { + expect(result.current.tree[0]?.absolute).toBe('/repo-a/src') + expect(result.current.expandedPaths.has('src')).toBe(true) + expect(result.current.tree[0]?.children?.[0]?.path).toBe('src/a.ts') + }) + }) }) diff --git a/src/hooks/useFileExplorer.ts b/src/hooks/useFileExplorer.ts index 50c97d69..7b6d7da6 100644 --- a/src/hooks/useFileExplorer.ts +++ b/src/hooks/useFileExplorer.ts @@ -60,6 +60,7 @@ export function useFileExplorer(options: UseFileExplorerOptions = {}): UseFileEx // 展开状态 const [expandedPaths, setExpandedPaths] = useState>(new Set()) + const expandedPathsByDirectoryRef = useRef>>(new Map()) // 预览状态 const [previewContent, setPreviewContent] = useState(null) @@ -190,10 +191,23 @@ export function useFileExplorer(options: UseFileExplorerOptions = {}): UseFileEx [directory], ) + const updateExpandedPaths = useCallback( + (updater: (prev: Set) => Set) => { + setExpandedPaths(prev => { + const next = updater(prev) + if (directory) { + expandedPathsByDirectoryRef.current.set(directory, new Set(next)) + } + return next + }) + }, + [directory], + ) + // 切换展开/折叠 const toggleExpand = useCallback( (path: string) => { - setExpandedPaths(prev => { + updateExpandedPaths(prev => { const next = new Set(prev) if (next.has(path)) { next.delete(path) @@ -208,12 +222,12 @@ export function useFileExplorer(options: UseFileExplorerOptions = {}): UseFileEx return next }) }, - [tree, loadChildren], + [tree, loadChildren, updateExpandedPaths], ) const expandPath = useCallback( (path: string) => { - setExpandedPaths(prev => { + updateExpandedPaths(prev => { const next = new Set(prev) next.add(path) return next @@ -223,16 +237,16 @@ export function useFileExplorer(options: UseFileExplorerOptions = {}): UseFileEx loadChildren(path) } }, - [tree, loadChildren], + [tree, loadChildren, updateExpandedPaths], ) const collapsePath = useCallback((path: string) => { - setExpandedPaths(prev => { + updateExpandedPaths(prev => { const next = new Set(prev) next.delete(path) return next }) - }, []) + }, [updateExpandedPaths]) // 加载文件预览 const loadPreview = useCallback( @@ -280,11 +294,14 @@ export function useFileExplorer(options: UseFileExplorerOptions = {}): UseFileEx // 刷新 const refresh = useCallback(async () => { + if (directory) { + expandedPathsByDirectoryRef.current.delete(directory) + } setExpandedPaths(new Set()) previewCacheRef.current.clear() setPreviewContent(null) await Promise.all([loadRoot(), loadStatuses()]) - }, [loadRoot, loadStatuses]) + }, [directory, loadRoot, loadStatuses]) // 初始加载 useEffect(() => { @@ -299,6 +316,27 @@ export function useFileExplorer(options: UseFileExplorerOptions = {}): UseFileEx } }, [autoLoad, directory, loadStatuses]) + useEffect(() => { + if (!directory) { + setExpandedPaths(new Set()) + return + } + + const storedPaths = expandedPathsByDirectoryRef.current.get(directory) + setExpandedPaths(storedPaths ? new Set(storedPaths) : new Set()) + }, [directory]) + + useEffect(() => { + if (!directory || tree.length === 0 || expandedPaths.size === 0) return + + const pendingPaths = collectPendingExpandedDirectoryPaths(tree, expandedPaths) + if (pendingPaths.length === 0) return + + pendingPaths.forEach(path => { + void loadChildren(path) + }) + }, [directory, expandedPaths, loadChildren, tree]) + useEffect(() => { previewCacheRef.current.clear() previewLoadIdRef.current += 1 @@ -371,6 +409,30 @@ function updateTreeNode( }) } +function collectPendingExpandedDirectoryPaths(tree: FileTreeNode[], expandedPaths: Set): string[] { + const pending: string[] = [] + + const visit = (nodes: FileTreeNode[]) => { + for (const node of nodes) { + if (node.type !== 'directory') continue + + if (expandedPaths.has(node.path)) { + if (!node.isLoaded && !node.isLoading) { + pending.push(node.path) + continue + } + } + + if (node.children) { + visit(node.children) + } + } + } + + visit(tree) + return pending +} + // Helper: 规范化路径 — 统一分隔符为 /,去掉前导 ./ function normalizePath(p: string): string { let result = p.replace(/\\/g, '/') diff --git a/src/hooks/useGlobalEvents.ts b/src/hooks/useGlobalEvents.ts index 4bc05a64..aa83cdb0 100644 --- a/src/hooks/useGlobalEvents.ts +++ b/src/hooks/useGlobalEvents.ts @@ -262,7 +262,7 @@ function isSessionDirectlyOpen(sessionId: string): boolean { export function useGlobalEvents(directories?: string[]) { const directoriesRef = useRef(directories) - const refreshRef = useRef<(() => void) | null>(null) + const refreshRef = useRef<((mode?: 'replace' | 'merge') => void) | null>(null) const initializedDirectoriesRef = useRef(false) useEffect(() => { @@ -303,14 +303,14 @@ export function useGlobalEvents(directories?: string[]) { // 拉取 session 状态 + pending requests(初始化 & 重连共用) // ============================================ - const fetchAndInitialize = () => { + const fetchAndInitialize = (mode: 'replace' | 'merge' = 'replace') => { const currentVersion = ++fetchVersion activeFetchVersion = currentVersion void fetchActiveScopeData(directoriesRef.current) .then(({ statusMap, permissions, questions, sessionMetaEntries }) => { if (disposed || currentVersion !== fetchVersion) return - activeSessionStore.initialize(statusMap) - activeSessionStore.initializePendingRequests(permissions, questions) + activeSessionStore.initialize(statusMap, { mode }) + activeSessionStore.initializePendingRequests(permissions, questions, { mode }) const currentDirectories = directoriesRef.current const currentScopeKey = getScopeKey(directoriesRef.current) for (const pending of latePendingRequests.values()) { @@ -605,7 +605,7 @@ export function useGlobalEvents(directories?: string[]) { useLayoutEffect(() => { directoriesRef.current = directories if (initializedDirectoriesRef.current) { - refreshRef.current?.() + refreshRef.current?.('merge') return } initializedDirectoriesRef.current = true diff --git a/src/store/activeSessionStore.test.ts b/src/store/activeSessionStore.test.ts new file mode 100644 index 00000000..995b246f --- /dev/null +++ b/src/store/activeSessionStore.test.ts @@ -0,0 +1,50 @@ +import { beforeEach, describe, expect, it } from 'vitest' +import { activeSessionStore } from './activeSessionStore' + +describe('activeSessionStore scoped refresh handling', () => { + beforeEach(() => { + activeSessionStore.initialize({}) + activeSessionStore.initializePendingRequests([], []) + }) + + it('preserves existing busy child sessions when merging scoped status refreshes', () => { + activeSessionStore.initialize({ + root: { type: 'busy' }, + child: { type: 'busy' }, + }) + + activeSessionStore.initialize( + { + root: { type: 'busy' }, + }, + { mode: 'merge' }, + ) + + expect(activeSessionStore.getBusySessions().map(entry => entry.sessionId)).toEqual(['root', 'child']) + }) + + it('drops missing sessions on full status replacement refreshes', () => { + activeSessionStore.initialize({ + root: { type: 'busy' }, + child: { type: 'busy' }, + }) + + activeSessionStore.initialize({ + root: { type: 'busy' }, + }) + + expect(activeSessionStore.getBusySessions().map(entry => entry.sessionId)).toEqual(['root']) + }) + + it('keeps existing pending child requests during scoped pending refresh merges', () => { + activeSessionStore.addPendingRequest('req-child', 'child', 'question', 'Need approval') + + activeSessionStore.initializePendingRequests([], [], { mode: 'merge' }) + + expect(activeSessionStore.getBusySessions().map(entry => entry.sessionId)).toEqual(['child']) + expect(activeSessionStore.getBusySessions()[0]?.pendingAction).toEqual({ + type: 'question', + description: 'Need approval', + }) + }) +}) diff --git a/src/store/activeSessionStore.ts b/src/store/activeSessionStore.ts index ba4edcdc..0da0a15f 100644 --- a/src/store/activeSessionStore.ts +++ b/src/store/activeSessionStore.ts @@ -48,6 +48,10 @@ interface ActiveSessionState { initialized: boolean } +interface InitializeOptions { + mode?: 'replace' | 'merge' +} + type Subscriber = () => void // ============================================ @@ -81,7 +85,9 @@ class ActiveSessionStore { private notify() { this.recomputeDerived() - this.subscribers.forEach(cb => cb()) + this.subscribers.forEach(cb => { + cb() + }) } private recomputeDerived() { @@ -121,12 +127,31 @@ class ActiveSessionStore { getBusySessionsSnapshot = (): ActiveSessionEntry[] => this.cachedBusySessions getBusyCountSnapshot = (): number => this.cachedBusyCount + private mergeStatusMap(statusMap: SessionStatusMap, mode: 'replace' | 'merge'): SessionStatusMap { + const nextMap = mode === 'merge' ? { ...this.state.statusMap } : {} + + for (const [sessionId, status] of Object.entries(statusMap)) { + if (status.type === 'idle') { + delete nextMap[sessionId] + continue + } + + nextMap[sessionId] = status.type === 'retry' ? { ...status } : { type: 'busy' } + } + + return nextMap + } + // ============================================ // 初始化:从 API 拉取全量状态 // ============================================ - initialize(statusMap: SessionStatusMap) { - this.state = { statusMap: { ...statusMap }, initialized: true } + initialize(statusMap: SessionStatusMap, options: InitializeOptions = {}) { + const mode = options.mode ?? 'replace' + this.state = { + statusMap: this.mergeStatusMap(statusMap, mode), + initialized: true, + } this.notify() } @@ -137,16 +162,18 @@ class ActiveSessionStore { initializePendingRequests( permissions: Array<{ id: string; sessionID: string; permission: string; patterns?: string[] }>, questions: Array<{ id: string; sessionID: string; questions?: Array<{ header?: string }> }>, + options: InitializeOptions = {}, ) { - this.pendingRequests.clear() - this.deferredIdleSessions.clear() + const mode = options.mode ?? 'replace' + const pendingRequests = mode === 'merge' ? new Map(this.pendingRequests) : new Map() + const deferredIdleSessions = mode === 'merge' ? new Set(this.deferredIdleSessions) : new Set() let changed = false const newMap = { ...this.state.statusMap } for (const p of permissions) { const desc = p.patterns?.length ? `${p.permission}: ${p.patterns[0]}` : p.permission - this.pendingRequests.set(p.id, { + pendingRequests.set(p.id, { requestId: p.id, sessionId: p.sessionID, type: 'permission', @@ -154,14 +181,14 @@ class ActiveSessionStore { }) if (!newMap[p.sessionID] || newMap[p.sessionID].type === 'idle') { newMap[p.sessionID] = { type: 'busy' } - this.deferredIdleSessions.add(p.sessionID) + deferredIdleSessions.add(p.sessionID) changed = true } } for (const q of questions) { const desc = q.questions?.[0]?.header || 'Waiting for input' - this.pendingRequests.set(q.id, { + pendingRequests.set(q.id, { requestId: q.id, sessionId: q.sessionID, type: 'question', @@ -169,11 +196,14 @@ class ActiveSessionStore { }) if (!newMap[q.sessionID] || newMap[q.sessionID].type === 'idle') { newMap[q.sessionID] = { type: 'busy' } - this.deferredIdleSessions.add(q.sessionID) + deferredIdleSessions.add(q.sessionID) changed = true } } + this.pendingRequests = pendingRequests + this.deferredIdleSessions = deferredIdleSessions + if (changed) { this.state = { ...this.state, statusMap: newMap } }