diff --git a/src/renderer/src/components/right-sidebar/useFileExplorerTree.test.ts b/src/renderer/src/components/right-sidebar/useFileExplorerTree.test.ts new file mode 100644 index 0000000000..baa959b272 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/useFileExplorerTree.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it, vi } from 'vitest' +import type { SetStateAction } from 'react' +import type { DirEntry } from '../../../../shared/types' +import type { DirCache } from './file-explorer-types' +import { createFileExplorerDirLoadTracker } from './file-explorer-dir-load-tracker' +import { refreshFileExplorerExpandedDirs } from './useFileExplorerTree' + +type CacheUpdate = SetStateAction> + +function entry(name: string, isDirectory = false): DirEntry { + return { name, isDirectory, isSymlink: false } +} + +describe('refreshFileExplorerExpandedDirs', () => { + it('reloads expanded directories with one loading cache commit and one result cache commit', async () => { + let cache: Record = { + '/repo': { + children: [ + { name: 'old', path: '/repo/old', relativePath: 'old', isDirectory: false, depth: 0 } + ], + loading: false + }, + '/repo/src': { children: [], loading: false }, + '/repo/docs': { children: [], loading: false } + } + const committedCaches: Record[] = [] + const setDirCache = vi.fn((update: CacheUpdate) => { + cache = typeof update === 'function' ? update(cache) : update + committedCaches.push(cache) + }) + const readDirectory = vi.fn(async (dirPath: string) => { + const entriesByPath: Record = { + '/repo/src': [entry('index.ts')], + '/repo/docs': [entry('guide.md')] + } + return entriesByPath[dirPath] ?? [] + }) + + const refreshed = await refreshFileExplorerExpandedDirs({ + dirs: [ + { dirPath: '/repo/src', depth: 0 }, + { dirPath: '/repo/docs', depth: 0 } + ], + worktreePath: '/repo', + dirLoadTracker: createFileExplorerDirLoadTracker(), + setDirCache, + readDirectory + }) + + expect(refreshed).toBe(true) + expect(setDirCache).toHaveBeenCalledTimes(2) + expect(committedCaches[0]).toMatchObject({ + '/repo': { loading: false, children: [{ name: 'old' }] }, + '/repo/src': { loading: true }, + '/repo/docs': { loading: true } + }) + expect(committedCaches[1]).toMatchObject({ + '/repo': { loading: false, children: [{ name: 'old' }] }, + '/repo/src': { + loading: false, + children: [ + { + name: 'index.ts', + path: '/repo/src/index.ts', + relativePath: 'src/index.ts', + isDirectory: false, + depth: 1 + } + ] + }, + '/repo/docs': { + loading: false, + children: [ + { + name: 'guide.md', + path: '/repo/docs/guide.md', + relativePath: 'docs/guide.md', + isDirectory: false, + depth: 1 + } + ] + } + }) + expect(readDirectory).toHaveBeenCalledTimes(2) + }) +}) diff --git a/src/renderer/src/components/right-sidebar/useFileExplorerTree.ts b/src/renderer/src/components/right-sidebar/useFileExplorerTree.ts index 7481bb4c87..5fc586a426 100644 --- a/src/renderer/src/components/right-sidebar/useFileExplorerTree.ts +++ b/src/renderer/src/components/right-sidebar/useFileExplorerTree.ts @@ -1,17 +1,21 @@ -import type React from 'react' +import type { Dispatch, SetStateAction } from 'react' import { useCallback, useRef, useState } from 'react' import { joinPath, normalizeRelativePath } from '@/lib/path' import { getConnectionId } from '@/lib/connection-context' +import type { DirEntry } from '../../../../shared/types' import type { DirCache, TreeNode } from './file-explorer-types' import { splitPathSegments } from './path-tree' import { shouldIncludeFileExplorerEntry } from './file-explorer-entries' import { readRuntimeDirectory, statRuntimePath } from '@/runtime/runtime-file-client' -import { createFileExplorerDirLoadTracker } from './file-explorer-dir-load-tracker' +import { + createFileExplorerDirLoadTracker, + type FileExplorerDirLoadTracker +} from './file-explorer-dir-load-tracker' import { getRightSidebarWorktreeRuntimeSettings } from './file-explorer-runtime-owner' type UseFileExplorerTreeResult = { dirCache: Record - setDirCache: React.Dispatch>> + setDirCache: Dispatch>> rootCache: DirCache | undefined rootError: string | null loadDir: ( @@ -26,6 +30,117 @@ type UseFileExplorerTreeResult = { resetAndLoad: () => void } +type RefreshFileExplorerTreeDir = { + dirPath: string + depth: number +} + +type RefreshFileExplorerExpandedDirsParams = { + dirs: RefreshFileExplorerTreeDir[] + worktreePath: string + dirLoadTracker: FileExplorerDirLoadTracker + setDirCache: Dispatch>> + readDirectory: (dirPath: string) => Promise +} + +function entriesToTreeNodes( + entries: DirEntry[], + dirPath: string, + depth: number, + worktreePath: string | null +): TreeNode[] { + return entries.filter(shouldIncludeFileExplorerEntry).map((entry) => { + const path = joinPath(dirPath, entry.name) + return { + name: entry.name, + path, + relativePath: worktreePath + ? normalizeRelativePath(path.slice(worktreePath.length + 1)) + : entry.name, + isDirectory: entry.isDirectory, + isSymlink: entry.isSymlink, + depth: depth + 1 + } + }) +} + +export async function refreshFileExplorerExpandedDirs({ + dirs, + worktreePath, + dirLoadTracker, + setDirCache, + readDirectory +}: RefreshFileExplorerExpandedDirsParams): Promise { + if (dirs.length === 0) { + return true + } + + const uniqueDirs = Array.from(new Map(dirs.map((dir) => [dir.dirPath, dir])).values()) + const loadTokens = new Map( + uniqueDirs.map((dir) => [dir.dirPath, dirLoadTracker.begin(dir.dirPath)]) + ) + + // Why: expanded refresh can touch many directories; commit the loading and + // result states in two batched setDirCache writes (rather than per-directory) + // so refreshing large worktrees stays O(N) instead of O(N²) cache spreads. + setDirCache((prev) => { + const next = { ...prev } + for (const { dirPath } of uniqueDirs) { + next[dirPath] = { + children: prev[dirPath]?.children ?? [], + loading: true + } + } + return next + }) + + const results = await Promise.all( + uniqueDirs.map(async ({ dirPath, depth }) => { + const loadToken = loadTokens.get(dirPath)! + try { + const entries = await readDirectory(dirPath) + if (!dirLoadTracker.isCurrent(loadToken)) { + return { current: false as const } + } + return { + current: true as const, + dirPath, + cache: { + children: entriesToTreeNodes(entries, dirPath, depth, worktreePath), + loading: false + } + } + } catch { + if (!dirLoadTracker.isCurrent(loadToken)) { + return { current: false as const } + } + return { + current: true as const, + dirPath, + cache: { children: [], loading: false } + } + } + }) + ) + + const currentResults = results.filter( + (result): result is Extract => result.current + ) + if (currentResults.length === 0) { + return false + } + + setDirCache((prev) => { + const next = { ...prev } + for (const result of currentResults) { + next[result.dirPath] = result.cache + } + return next + }) + + return currentResults.length === uniqueDirs.length +} + export function useFileExplorerTree( worktreePath: string | null, expanded: Set, @@ -76,18 +191,7 @@ export function useFileExplorerTree( if (depth === -1) { setRootError(null) } - const children: TreeNode[] = entries - .filter(shouldIncludeFileExplorerEntry) - .map((entry) => ({ - name: entry.name, - path: joinPath(dirPath, entry.name), - relativePath: worktreePath - ? normalizeRelativePath(joinPath(dirPath, entry.name).slice(worktreePath.length + 1)) - : entry.name, - isDirectory: entry.isDirectory, - isSymlink: entry.isSymlink, - depth: depth + 1 - })) + const children = entriesToTreeNodes(entries, dirPath, depth, worktreePath) setDirCache((prev) => ({ ...prev, [dirPath]: { children, loading: false } })) return true } catch (error) { @@ -156,13 +260,33 @@ export function useFileExplorerTree( if (!rootLoadCompleted || !dirLoadTrackerRef.current.isSessionCurrent(refreshSession)) { return } - await Promise.all( - Array.from(expanded).map(async (dirPath) => { - const depth = splitPathSegments(dirPath.slice(worktreePath.length + 1)).length - 1 - await loadDir(dirPath, depth, { force: true }) - }) - ) - }, [expanded, loadDir, worktreePath]) + // Why: root (worktreePath) was just force-loaded above; exclude it here so + // refreshFileExplorerExpandedDirs doesn't queue a duplicate read of root. + const expandedDirs = Array.from(expanded) + .filter((dirPath) => dirPath !== worktreePath) + .map((dirPath) => ({ + dirPath, + depth: splitPathSegments(dirPath.slice(worktreePath.length + 1)).length - 1 + })) + await refreshFileExplorerExpandedDirs({ + dirs: expandedDirs, + worktreePath, + dirLoadTracker: dirLoadTrackerRef.current, + setDirCache, + readDirectory: async (dirPath) => { + const connectionId = getConnectionId(activeWorktreeId ?? null) ?? undefined + return readRuntimeDirectory( + { + settings: getRightSidebarWorktreeRuntimeSettings(activeWorktreeId), + worktreeId: activeWorktreeId, + worktreePath, + connectionId + }, + dirPath + ) + } + }) + }, [activeWorktreeId, expanded, loadDir, worktreePath]) const refreshDir = useCallback( async (dirPath: string) => {