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
Original file line number Diff line number Diff line change
@@ -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<Record<string, DirCache>>

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<string, DirCache> = {
'/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<string, DirCache>[] = []
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<string, DirEntry[]> = {
'/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)
})
})
168 changes: 146 additions & 22 deletions src/renderer/src/components/right-sidebar/useFileExplorerTree.ts
Original file line number Diff line number Diff line change
@@ -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<string, DirCache>
setDirCache: React.Dispatch<React.SetStateAction<Record<string, DirCache>>>
setDirCache: Dispatch<SetStateAction<Record<string, DirCache>>>
rootCache: DirCache | undefined
rootError: string | null
loadDir: (
Expand All @@ -26,6 +30,117 @@ type UseFileExplorerTreeResult = {
resetAndLoad: () => void
}

type RefreshFileExplorerTreeDir = {
dirPath: string
depth: number
}

type RefreshFileExplorerExpandedDirsParams = {
dirs: RefreshFileExplorerTreeDir[]
worktreePath: string
dirLoadTracker: FileExplorerDirLoadTracker
setDirCache: Dispatch<SetStateAction<Record<string, DirCache>>>
readDirectory: (dirPath: string) => Promise<DirEntry[]>
}

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<boolean> {
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<typeof result, { current: true }> => 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<string>,
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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) => {
Expand Down