Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions src/hooks/useFileExplorer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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')
})
})
})
76 changes: 69 additions & 7 deletions src/hooks/useFileExplorer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ export function useFileExplorer(options: UseFileExplorerOptions = {}): UseFileEx

// 展开状态
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(new Set())
const expandedPathsByDirectoryRef = useRef<Map<string, Set<string>>>(new Map())

// 预览状态
const [previewContent, setPreviewContent] = useState<FileContent | null>(null)
Expand Down Expand Up @@ -190,10 +191,23 @@ export function useFileExplorer(options: UseFileExplorerOptions = {}): UseFileEx
[directory],
)

const updateExpandedPaths = useCallback(
(updater: (prev: Set<string>) => Set<string>) => {
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)
Expand All @@ -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
Expand All @@ -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(
Expand Down Expand Up @@ -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(() => {
Expand All @@ -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
Expand Down Expand Up @@ -371,6 +409,30 @@ function updateTreeNode(
})
}

function collectPendingExpandedDirectoryPaths(tree: FileTreeNode[], expandedPaths: Set<string>): 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, '/')
Expand Down
10 changes: 5 additions & 5 deletions src/hooks/useGlobalEvents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -262,7 +262,7 @@ function isSessionDirectlyOpen(sessionId: string): boolean {

export function useGlobalEvents(directories?: string[]) {
const directoriesRef = useRef<string[] | undefined>(directories)
const refreshRef = useRef<(() => void) | null>(null)
const refreshRef = useRef<((mode?: 'replace' | 'merge') => void) | null>(null)
const initializedDirectoriesRef = useRef(false)

useEffect(() => {
Expand Down Expand Up @@ -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()) {
Expand Down Expand Up @@ -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
Expand Down
50 changes: 50 additions & 0 deletions src/store/activeSessionStore.test.ts
Original file line number Diff line number Diff line change
@@ -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',
})
})
})
Loading