fix(explorer): refresh tree on create/rename with case-tolerant cache keys (#10392)

* fix(explorer): refresh tree on create/rename with case-tolerant cache keys

Windows watchers can emit paths whose casing differs from the worktree
dirCache key, so create events never refreshed. Also apply rename events
immediately by refreshing the parent listing instead of ignoring them.

* fix(explorer): reconcile Windows update-only creates

* fix(explorer): bound watcher update reconciliation

* fix(explorer): index watcher cache paths

* fix(explorer): avoid expanded directory rescan

* fix(explorer): batch watcher subtree purges

* fix(explorer): preserve Windows drive roots

---------

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
This commit is contained in:
Wooseong Kim 2026-07-29 12:52:09 +09:00 committed by GitHub
parent afbd98d8a4
commit adc10cd21a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 967 additions and 194 deletions

View File

@ -0,0 +1,70 @@
import { describe, expect, it, vi } from 'vitest'
import type { FsChangeEvent } from '../../../../shared/types'
import type { DirCache } from './file-explorer-types'
import { processFileExplorerFsPayload } from './file-explorer-watch-reconcile'
const DRIVE_ROOTS = [
{ label: 'backslash', root: 'C:\\', child: (name: string) => `c:\\${name}` },
{ label: 'slash', root: 'C:/', child: (name: string) => `c:/${name}` }
]
function processRootEvent(root: string, event: FsChangeEvent): ReturnType<typeof vi.fn> {
const refreshDir = vi.fn()
const rootCache: DirCache = {
children: [],
loading: false,
operationOwner: { kind: 'local' }
}
processFileExplorerFsPayload({
payload: { worktreePath: root, events: [event] },
currentWorktreePath: root,
worktreeId: 'folder::drive-root',
cache: { [root]: rootCache },
expanded: new Set(),
setDirCache: vi.fn(),
setSelectedPath: vi.fn(),
refreshDir,
refreshTree: vi.fn()
})
return refreshDir
}
describe('Windows drive-root watcher reconciliation', () => {
it.each(DRIVE_ROOTS)(
'refreshes a $label drive root for an update-only create',
({ root, child }) => {
const refreshDir = processRootEvent(root, {
kind: 'update',
absolutePath: child('new-file.txt'),
isDirectory: false
})
expect(refreshDir).toHaveBeenCalledOnce()
expect(refreshDir).toHaveBeenCalledWith(root)
}
)
it.each(DRIVE_ROOTS)('refreshes a $label drive root after a delete', ({ root, child }) => {
const refreshDir = processRootEvent(root, {
kind: 'delete',
absolutePath: child('removed-file.txt')
})
expect(refreshDir).toHaveBeenCalledOnce()
expect(refreshDir).toHaveBeenCalledWith(root)
})
it.each(DRIVE_ROOTS)('refreshes a $label drive root once after a rename', ({ root, child }) => {
const refreshDir = processRootEvent(root, {
kind: 'rename',
oldAbsolutePath: child('old-file.txt'),
absolutePath: child('new-file.txt'),
isDirectory: false
})
expect(refreshDir).toHaveBeenCalledOnce()
expect(refreshDir).toHaveBeenCalledWith(root)
})
})

View File

@ -0,0 +1,92 @@
import { joinPath, dirname, normalizeRelativePath } from '@/lib/path'
import {
normalizeRuntimePathForComparison,
relativePathInsideRoot
} from '../../../../shared/cross-platform-path'
export function normalizeExplorerAbsolutePath(path: string): string {
return path === '/' || /^[A-Za-z]:[\\/]$/.test(path) ? path : path.replace(/[\\/]+$/, '')
}
export function getExternalFileChangeRelativePath(
worktreePath: string,
absolutePath: string,
isDirectory: boolean | undefined
): string | null {
if (isDirectory === true) {
return null
}
const relativePath = relativePathInsideRoot(worktreePath, absolutePath)
if (relativePath === null || relativePath === '') {
return null
}
// Why: EditorPanel reloads tabs only from a worktree-relative path, not the watcher's absolute one; normalize or contents go stale.
return normalizeRelativePath(relativePath)
}
export function canonicalizeFileExplorerWatchPath(
worktreePath: string,
absolutePath: string
): string | null {
const relativePath = relativePathInsideRoot(worktreePath, absolutePath)
if (relativePath === null) {
return null
}
const rootPath = normalizeExplorerAbsolutePath(worktreePath)
return relativePath === '' ? rootPath : joinPath(rootPath, relativePath)
}
export function createCachedDirPathIndex(
cache: Record<string, { children: unknown }>
): ReadonlyMap<string, string> {
const index = new Map<string, string>()
for (const key of Object.keys(cache)) {
const normalizedKey = normalizeRuntimePathForComparison(key)
if (!index.has(normalizedKey)) {
index.set(normalizedKey, key)
}
}
return index
}
/**
* Map an event path to the dirCache key that should be refreshed.
* Windows watchers often differ in drive-letter casing from the worktree key.
*/
export function resolveCachedDirPath(
cache: Record<string, { children: unknown }>,
dirPath: string,
worktreePath?: string,
cachePathIndex?: ReadonlyMap<string, string>
): string | null {
if (dirPath in cache) {
return dirPath
}
const target = normalizeRuntimePathForComparison(dirPath)
const indexedPath = cachePathIndex?.get(target)
if (indexedPath) {
return indexedPath
}
if (!cachePathIndex) {
for (const key of Object.keys(cache)) {
if (normalizeRuntimePathForComparison(key) === target) {
return key
}
}
}
if (worktreePath && normalizeRuntimePathForComparison(worktreePath) === target) {
return normalizeExplorerAbsolutePath(worktreePath)
}
return null
}
export function parentDirForWatchPath(normalizedPath: string): string {
const parentPath = dirname(normalizedPath)
if (/^[A-Za-z]:$/.test(parentPath)) {
return `${parentPath}${normalizedPath.includes('\\') ? '\\' : '/'}`
}
return normalizeExplorerAbsolutePath(parentPath)
}

View File

@ -0,0 +1,441 @@
import { describe, expect, it, vi } from 'vitest'
import type { DirCache, TreeNode } from './file-explorer-types'
import { processFileExplorerFsPayload } from './file-explorer-watch-reconcile'
import { purgeDirCacheSubtrees } from './file-explorer-watcher-reconcile'
import { useAppStore } from '@/store'
function cacheWithChildren(paths: string[]): DirCache {
return {
children: paths.map(
(path): TreeNode => ({
name: path.split(/[\\/]/).at(-1) ?? path,
path,
relativePath: path,
isDirectory: false,
depth: 0,
operationOwner: { kind: 'local' }
})
),
loading: false,
operationOwner: { kind: 'local' }
}
}
function cacheWithMeasuredChildren(paths: string[], onPathRead: () => void): DirCache {
const cache = cacheWithChildren(paths)
for (let index = 0; index < paths.length; index++) {
Object.defineProperty(cache.children[index]!, 'path', {
get: () => {
onPathRead()
return paths[index]
}
})
}
return cache
}
function processUpdate(args: {
root: string
absolutePath: string
isDirectory?: boolean
cache: Record<string, DirCache>
}): ReturnType<typeof vi.fn> {
const refreshDir = vi.fn()
processFileExplorerFsPayload({
payload: {
worktreePath: args.root,
events: [{ kind: 'update', absolutePath: args.absolutePath, isDirectory: args.isDirectory }]
},
currentWorktreePath: args.root,
worktreeId: 'wt-1',
cache: args.cache,
expanded: new Set(),
setDirCache: vi.fn(),
setSelectedPath: vi.fn(),
refreshDir,
refreshTree: vi.fn()
})
return refreshDir
}
describe('processFileExplorerFsPayload update reconciliation', () => {
it('purges Windows descendants case-insensitively without folding remote POSIX paths', () => {
let cache: Record<string, DirCache> = {
'C:\\Repo\\Old': cacheWithChildren([]),
'c:\\repo\\OLD\\child': cacheWithChildren([]),
'C:\\Repo\\Keep': cacheWithChildren([]),
'/srv/repo/Old': cacheWithChildren([]),
'/srv/repo/Old/child': cacheWithChildren([]),
'/srv/repo/old/keep': cacheWithChildren([])
}
type DirCacheUpdate = Parameters<Parameters<typeof purgeDirCacheSubtrees>[0]>[0]
const setDirCache = (update: DirCacheUpdate): void => {
cache = typeof update === 'function' ? update(cache) : update
}
purgeDirCacheSubtrees(setDirCache, new Set(['C:\\repo\\old', '/srv/repo/Old']))
expect(Object.keys(cache)).toEqual(['C:\\Repo\\Keep', '/srv/repo/old/keep'])
})
it('refreshes a cached parent when Windows reports a new file as update', () => {
const root = 'C:\\Repo'
const refreshDir = processUpdate({
root,
absolutePath: 'c:\\repo\\new-file.txt',
isDirectory: false,
cache: { [root]: cacheWithChildren([`${root}\\existing.txt`]) }
})
expect(refreshDir).toHaveBeenCalledOnce()
expect(refreshDir).toHaveBeenCalledWith(root)
})
it('does not reread a directory for an existing file content update', () => {
const root = 'C:\\Repo'
const refreshDir = processUpdate({
root,
absolutePath: 'c:\\repo\\EXISTING.txt',
isDirectory: false,
cache: { [root]: cacheWithChildren([`${root}\\existing.txt`]) }
})
expect(refreshDir).not.toHaveBeenCalled()
})
it('indexes cached children once for a large update batch', () => {
const root = 'C:\\Repo'
const paths = Array.from({ length: 1_000 }, (_, index) => `${root}\\file-${index}.txt`)
let pathReads = 0
const refreshDir = vi.fn()
processFileExplorerFsPayload({
payload: {
worktreePath: root,
events: paths.map((absolutePath) => ({
kind: 'update' as const,
absolutePath: absolutePath.toLowerCase(),
isDirectory: false
}))
},
currentWorktreePath: root,
worktreeId: 'wt-1',
cache: { [root]: cacheWithMeasuredChildren(paths, () => pathReads++) },
expanded: new Set(),
setDirCache: vi.fn(),
setSelectedPath: vi.fn(),
refreshDir,
refreshTree: vi.fn()
})
expect(pathReads).toBe(paths.length)
expect(refreshDir).not.toHaveBeenCalled()
})
it('indexes cached directory keys once for a maximum update batch', () => {
const root = 'C:\\Repo'
const entries: Record<string, DirCache> = {
[root]: cacheWithChildren([])
}
for (let index = 0; index < 2_000; index++) {
entries[`${root}\\dir-${index}`] = cacheWithChildren([])
}
let cacheKeyReads = 0
const cache = new Proxy(entries, {
ownKeys(target) {
cacheKeyReads++
return Reflect.ownKeys(target)
}
})
const refreshDir = vi.fn()
processFileExplorerFsPayload({
payload: {
worktreePath: root,
events: Array.from({ length: 5_000 }, (_, index) => ({
kind: 'update' as const,
absolutePath: `c:\\repo\\new-${index}.txt`,
isDirectory: false
}))
},
currentWorktreePath: root,
worktreeId: 'wt-1',
cache,
expanded: new Set(),
setDirCache: vi.fn(),
setSelectedPath: vi.fn(),
refreshDir,
refreshTree: vi.fn()
})
expect(cacheKeyReads).toBe(1)
expect(refreshDir).toHaveBeenCalledOnce()
expect(refreshDir).toHaveBeenCalledWith(root)
})
it('does not rescan expanded directories for each refreshed cached parent', () => {
const root = 'C:\\Repo'
const cache: Record<string, DirCache> = { [root]: cacheWithChildren([]) }
const expandedPaths: string[] = []
for (let index = 0; index < 2_000; index++) {
cache[`${root}\\dir-${index}`] = cacheWithChildren([])
expandedPaths.push(`c:\\repo\\DIR-${index}`)
}
let expandedPathReads = 0
const expanded = new Set(expandedPaths)
const expandedIterator = expanded[Symbol.iterator].bind(expanded)
expanded[Symbol.iterator] = function* measuredExpandedIterator() {
for (const path of expandedIterator()) {
expandedPathReads++
yield path
}
return undefined
}
const refreshDir = vi.fn()
processFileExplorerFsPayload({
payload: {
worktreePath: root,
events: Array.from({ length: 5_000 }, (_, index) => ({
kind: 'create' as const,
absolutePath: `c:\\repo\\dir-${index % 2_000}\\new-${index}.txt`,
isDirectory: false
}))
},
currentWorktreePath: root,
worktreeId: 'wt-1',
cache,
expanded,
setDirCache: vi.fn(),
setSelectedPath: vi.fn(),
refreshDir,
refreshTree: vi.fn()
})
expect(expandedPathReads).toBe(0)
expect(refreshDir).toHaveBeenCalledTimes(2_000)
expect(new Set(refreshDir.mock.calls.map(([dirPath]) => dirPath)).size).toBe(2_000)
})
it('keeps POSIX child matching case-sensitive', () => {
const root = '/repo'
const refreshDir = processUpdate({
root,
absolutePath: '/repo/EXISTING.txt',
isDirectory: false,
cache: { [root]: cacheWithChildren(['/repo/existing.txt']) }
})
expect(refreshDir).toHaveBeenCalledWith(root)
})
it('refreshes an existing directory when the update identifies it as a directory', () => {
const root = '/repo'
const child = '/repo/src'
const refreshDir = processUpdate({
root,
absolutePath: child,
isDirectory: true,
cache: { [root]: cacheWithChildren([child]), [child]: cacheWithChildren([]) }
})
expect(refreshDir).toHaveBeenCalledOnce()
expect(refreshDir).toHaveBeenCalledWith(child)
})
it('deduplicates repeated remote update events for an absent child', () => {
const root = '/srv/workspace'
const refreshDir = vi.fn()
processFileExplorerFsPayload({
payload: {
worktreePath: root,
events: [
{ kind: 'update', absolutePath: `${root}/new.txt`, isDirectory: false },
{ kind: 'update', absolutePath: `${root}/new.txt`, isDirectory: false }
]
},
currentWorktreePath: root,
worktreeId: 'folder::remote-1',
cache: { [root]: cacheWithChildren([]) },
expanded: new Set(),
setDirCache: vi.fn(),
setSelectedPath: vi.fn(),
refreshDir,
refreshTree: vi.fn()
})
expect(refreshDir).toHaveBeenCalledOnce()
expect(refreshDir).toHaveBeenCalledWith(root)
})
it('ignores an update payload from another workspace', () => {
const root = '/srv/workspace'
const refreshDir = vi.fn()
processFileExplorerFsPayload({
payload: {
worktreePath: '/srv/other',
events: [{ kind: 'update', absolutePath: '/srv/other/new.txt', isDirectory: false }]
},
currentWorktreePath: root,
worktreeId: 'folder::remote-1',
cache: { [root]: cacheWithChildren([]) },
expanded: new Set(),
setDirCache: vi.fn(),
setSelectedPath: vi.fn(),
refreshDir,
refreshTree: vi.fn()
})
expect(refreshDir).not.toHaveBeenCalled()
})
it('refreshes both parents for a synthetic cross-directory rename', () => {
const root = '/repo'
const sourceDir = `${root}/source`
const targetDir = `${root}/target`
const refreshDir = vi.fn()
const refreshTree = vi.fn()
const setSelectedPath = vi.fn()
processFileExplorerFsPayload({
payload: {
worktreePath: root,
events: [
{
kind: 'rename',
oldAbsolutePath: `${sourceDir}/old.txt`,
absolutePath: `${targetDir}/new.txt`,
isDirectory: false
}
]
},
currentWorktreePath: root,
worktreeId: 'folder::remote-1',
cache: {
[root]: cacheWithChildren([sourceDir, targetDir]),
[sourceDir]: cacheWithChildren([`${sourceDir}/old.txt`]),
[targetDir]: cacheWithChildren([])
},
expanded: new Set([sourceDir, targetDir]),
setDirCache: vi.fn(),
setSelectedPath,
refreshDir,
refreshTree
})
expect(refreshDir).toHaveBeenCalledTimes(2)
expect(refreshDir).toHaveBeenCalledWith(sourceDir)
expect(refreshDir).toHaveBeenCalledWith(targetDir)
expect(refreshTree).not.toHaveBeenCalled()
expect(setSelectedPath.mock.calls[0]?.[0](`${sourceDir}/old.txt`)).toBeNull()
})
it('deduplicates subtree and selection state work for repeated renames', () => {
const root = '/repo'
const sourceDir = `${root}/source`
const targetDir = `${root}/target`
const oldDir = `${sourceDir}/old`
const newDir = `${targetDir}/new`
const rename = {
kind: 'rename' as const,
oldAbsolutePath: oldDir,
absolutePath: newDir,
isDirectory: true
}
const setDirCache = vi.fn()
const setSelectedPath = vi.fn()
const refreshDir = vi.fn()
processFileExplorerFsPayload({
payload: { worktreePath: root, events: [rename, rename] },
currentWorktreePath: root,
worktreeId: 'wt-1',
cache: {
[sourceDir]: cacheWithChildren([oldDir]),
[targetDir]: cacheWithChildren([newDir]),
[oldDir]: cacheWithChildren([]),
[newDir]: cacheWithChildren([])
},
expanded: new Set([sourceDir, targetDir]),
setDirCache,
setSelectedPath,
refreshDir,
refreshTree: vi.fn()
})
expect(setDirCache).toHaveBeenCalledOnce()
expect(setSelectedPath).toHaveBeenCalledOnce()
expect(refreshDir).toHaveBeenCalledTimes(2)
})
it('purges distinct cached directory renames with one bounded cache scan', () => {
const root = '/repo'
const worktreeId = 'watch-reconcile-perf'
const entries: Record<string, DirCache> = { [root]: cacheWithChildren([]) }
const expandedPaths: string[] = []
const events = Array.from({ length: 1_000 }, (_, index) => {
entries[`${root}/old-${index}`] = cacheWithChildren([])
entries[`${root}/new-${index}`] = cacheWithChildren([])
expandedPaths.push(`${root}/old-${index}`, `${root}/new-${index}`)
return {
kind: 'rename' as const,
oldAbsolutePath: `${root}/old-${index}`,
absolutePath: `${root}/new-${index}`,
isDirectory: true
}
})
let keyVisits = 0
const entryCount = Object.keys(entries).length
const measured = (value: Record<string, DirCache>): Record<string, DirCache> =>
new Proxy(value, {
getOwnPropertyDescriptor(target, property) {
keyVisits++
return Reflect.getOwnPropertyDescriptor(target, property)
}
})
let current = measured(entries)
type DirCacheUpdate = Parameters<
Parameters<typeof processFileExplorerFsPayload>[0]['setDirCache']
>[0]
const setDirCache = vi.fn((update: DirCacheUpdate) => {
current = measured(typeof update === 'function' ? update(current) : update)
})
let expandedPathReads = 0
const expanded = new Set(expandedPaths)
const expandedIterator = expanded[Symbol.iterator].bind(expanded)
expanded[Symbol.iterator] = function* measuredExpandedIterator() {
for (const path of expandedIterator()) {
expandedPathReads++
yield path
}
return undefined
}
const previousExpandedDirs = useAppStore.getState().expandedDirs
let remainingExpanded: Set<string> | undefined
try {
useAppStore.setState({
expandedDirs: { ...previousExpandedDirs, [worktreeId]: expanded }
})
processFileExplorerFsPayload({
payload: { worktreePath: root, events },
currentWorktreePath: root,
worktreeId,
cache: current,
expanded,
setDirCache,
setSelectedPath: vi.fn(),
refreshDir: vi.fn(),
refreshTree: vi.fn()
})
remainingExpanded = useAppStore.getState().expandedDirs[worktreeId]
} finally {
useAppStore.setState({ expandedDirs: previousExpandedDirs })
}
expect(setDirCache).toHaveBeenCalledOnce()
expect(keyVisits).toBe(entryCount * 2)
expect(expandedPathReads).toBe(expandedPaths.length)
expect(remainingExpanded).toEqual(new Set())
})
})

View File

@ -0,0 +1,224 @@
import type { Dispatch, SetStateAction } from 'react'
import type { FsChangedPayload } from '../../../../shared/types'
import type { DirCache } from './file-explorer-types'
import {
isPathInsideOrEqual,
normalizeRuntimePathForComparison
} from '../../../../shared/cross-platform-path'
import {
purgeDirCacheSubtrees,
purgeExpandedDirsSubtrees,
clearStalePendingReveal
} from './file-explorer-watcher-reconcile'
import {
canonicalizeFileExplorerWatchPath,
createCachedDirPathIndex,
normalizeExplorerAbsolutePath,
parentDirForWatchPath,
resolveCachedDirPath
} from './file-explorer-watch-path'
export type ProcessFileExplorerFsPayloadArgs = {
payload: FsChangedPayload
currentWorktreePath: string
worktreeId: string
cache: Record<string, DirCache>
expanded: Set<string>
setDirCache: Dispatch<SetStateAction<Record<string, DirCache>>>
setSelectedPath: Dispatch<SetStateAction<string | null>>
refreshDir: (dirPath: string) => void
refreshTree: () => void
}
function cachedDirectoryContainsPath(
cache: Record<string, DirCache>,
cachedDirPath: string,
childPath: string,
childPathIndexes: Map<string, Set<string>>
): boolean {
let childPaths = childPathIndexes.get(cachedDirPath)
if (!childPaths) {
childPaths = new Set(
cache[cachedDirPath]?.children.map((child) =>
normalizeRuntimePathForComparison(child.path)
) ?? []
)
childPathIndexes.set(cachedDirPath, childPaths)
}
return childPaths.has(normalizeRuntimePathForComparison(childPath))
}
export function processFileExplorerFsPayload(args: ProcessFileExplorerFsPayloadArgs): void {
const {
payload,
currentWorktreePath,
worktreeId,
cache,
setDirCache,
setSelectedPath,
refreshDir,
refreshTree
} = args
if (
normalizeRuntimePathForComparison(payload.worktreePath) !==
normalizeRuntimePathForComparison(currentWorktreePath)
) {
return
}
const dirsToRefresh = new Set<string>()
const childPathIndexes = new Map<string, Set<string>>()
const cachePathIndex = createCachedDirPathIndex(cache)
const cachedDirsToPurge = new Set<string>()
const reconciledRenameSources = new Set<string>()
let needsFullRefresh = false
const queueCachedDirPurge = (cachedDir: string | null): void => {
if (cachedDir) {
cachedDirsToPurge.add(cachedDir)
}
}
for (const evt of payload.events) {
if (evt.kind === 'overflow') {
needsFullRefresh = true
break
}
const normalizedPath = canonicalizeFileExplorerWatchPath(currentWorktreePath, evt.absolutePath)
if (!normalizedPath) {
continue
}
if (evt.kind === 'delete') {
// Why: watcher can't report isDirectory for deletes; a dirCache key means it was an expanded dir (design §4.4).
const cachedDir = resolveCachedDirPath(
cache,
normalizedPath,
currentWorktreePath,
cachePathIndex
)
const wasDirectory = cachedDir !== null
if (wasDirectory && cachedDir) {
queueCachedDirPurge(cachedDir)
}
clearStalePendingReveal(normalizedPath)
setSelectedPath((prev) => {
if (
prev &&
normalizeRuntimePathForComparison(prev) ===
normalizeRuntimePathForComparison(normalizedPath)
) {
return null
}
if (prev && wasDirectory && isPathInsideOrEqual(normalizedPath, prev)) {
return null
}
return prev
})
const parent = parentDirForWatchPath(normalizedPath)
const cachedParent = resolveCachedDirPath(cache, parent, currentWorktreePath, cachePathIndex)
if (cachedParent) {
dirsToRefresh.add(cachedParent)
}
} else if (evt.kind === 'create' || evt.kind === 'rename') {
// Why: create and rename both change a parent's listing. Rename was
// previously deferred (#10264) so Explorer stayed stale until focus
// remounted the tree. Case-insensitive cache lookup covers Windows
// drive-letter / path casing drift between watcher and worktree path.
const parent = parentDirForWatchPath(normalizedPath)
const cachedParent = resolveCachedDirPath(cache, parent, currentWorktreePath, cachePathIndex)
if (cachedParent) {
dirsToRefresh.add(cachedParent)
}
if (evt.kind === 'rename') {
const oldPath = evt.oldAbsolutePath
? canonicalizeFileExplorerWatchPath(currentWorktreePath, evt.oldAbsolutePath)
: null
const cachedOldDir = oldPath
? resolveCachedDirPath(cache, oldPath, currentWorktreePath, cachePathIndex)
: null
if (oldPath) {
const oldParent = parentDirForWatchPath(oldPath)
const cachedOldParent = resolveCachedDirPath(
cache,
oldParent,
currentWorktreePath,
cachePathIndex
)
if (cachedOldParent) {
dirsToRefresh.add(cachedOldParent)
}
const sourceKey = normalizeRuntimePathForComparison(oldPath)
if (!reconciledRenameSources.has(sourceKey)) {
reconciledRenameSources.add(sourceKey)
clearStalePendingReveal(oldPath)
setSelectedPath((prev) => {
if (!prev) {
return prev
}
const selectedSource = normalizeRuntimePathForComparison(prev)
if (selectedSource === sourceKey) {
return null
}
return cachedOldDir && isPathInsideOrEqual(oldPath, prev) ? null : prev
})
}
}
const cachedNewDir = resolveCachedDirPath(
cache,
normalizedPath,
currentWorktreePath,
cachePathIndex
)
queueCachedDirPurge(cachedOldDir)
queueCachedDirPurge(cachedNewDir)
}
} else if (evt.kind === 'update') {
const cachedDir = resolveCachedDirPath(
cache,
normalizedPath,
currentWorktreePath,
cachePathIndex
)
if (evt.isDirectory === true && cachedDir) {
dirsToRefresh.add(cachedDir)
continue
}
const parent = parentDirForWatchPath(normalizedPath)
const cachedParent = resolveCachedDirPath(cache, parent, currentWorktreePath, cachePathIndex)
// Windows can classify a new file as update; existing file updates do not invalidate the tree.
if (
cachedParent &&
!dirsToRefresh.has(cachedParent) &&
!cachedDirectoryContainsPath(cache, cachedParent, normalizedPath, childPathIndexes)
) {
dirsToRefresh.add(cachedParent)
}
}
}
purgeDirCacheSubtrees(setDirCache, cachedDirsToPurge)
purgeExpandedDirsSubtrees(worktreeId, cachedDirsToPurge)
if (needsFullRefresh) {
refreshTree()
return
}
const rootPath = normalizeExplorerAbsolutePath(currentWorktreePath)
for (const dirPath of dirsToRefresh) {
const isRoot =
normalizeRuntimePathForComparison(dirPath) === normalizeRuntimePathForComparison(rootPath)
if (isRoot || dirPath in cache) {
refreshDir(dirPath)
}
}
}

View File

@ -2,22 +2,56 @@ import type { Dispatch, SetStateAction } from 'react'
import type { DirCache } from './file-explorer-types'
import { normalizeAbsolutePath, isPathEqualOrDescendant } from './file-explorer-paths'
import { useAppStore } from '@/store'
import { normalizeRuntimePathForComparison } from '../../../../shared/cross-platform-path'
// ── dirCache subtree purge ───────────────────────────────────────────
// Why: dirCache is component-local useState in useFileExplorerTree, not
// Zustand. This helper accepts the setter so it can be called from the
// watch effect without Zustand coupling. See design §5.2.
export function purgeDirCacheSubtree(
function createSubtreeMatcher(paths: ReadonlySet<string>): (candidatePath: string) => boolean {
const normalizedRoots = new Set([...paths].map(normalizeRuntimePathForComparison))
return (candidatePath) => {
const candidate = normalizeRuntimePathForComparison(candidatePath)
if (normalizedRoots.has(candidate)) {
return true
}
if (candidate.startsWith('/') && normalizedRoots.has('/')) {
return true
}
for (
let index = candidate.indexOf('/');
index >= 0;
index = candidate.indexOf('/', index + 1)
) {
if (
index === 2 &&
/^[a-z]:\//.test(candidate) &&
normalizedRoots.has(candidate.slice(0, 3))
) {
return true
}
if (index > 0 && normalizedRoots.has(candidate.slice(0, index))) {
return true
}
}
return false
}
}
export function purgeDirCacheSubtrees(
setDirCache: Dispatch<SetStateAction<Record<string, DirCache>>>,
deletedPath: string
deletedPaths: ReadonlySet<string>
): void {
const normalized = normalizeAbsolutePath(deletedPath)
if (deletedPaths.size === 0) {
return
}
const shouldPurge = createSubtreeMatcher(deletedPaths)
setDirCache((prev) => {
let changed = false
const next: Record<string, DirCache> = {}
for (const key of Object.keys(prev)) {
if (isPathEqualOrDescendant(key, normalized)) {
if (shouldPurge(key)) {
changed = true
} else {
next[key] = prev[key]
@ -32,8 +66,14 @@ export function purgeDirCacheSubtree(
// external directory delete, all expanded descendants of the deleted
// path must be removed so the tree doesn't show phantom folders.
export function purgeExpandedDirsSubtree(worktreeId: string, deletedPath: string): void {
const normalized = normalizeAbsolutePath(deletedPath)
export function purgeExpandedDirsSubtrees(
worktreeId: string,
deletedPaths: ReadonlySet<string>
): void {
if (deletedPaths.size === 0) {
return
}
const shouldPurge = createSubtreeMatcher(deletedPaths)
useAppStore.setState((state) => {
const current = state.expandedDirs[worktreeId]
if (!current) {
@ -43,7 +83,7 @@ export function purgeExpandedDirsSubtree(worktreeId: string, deletedPath: string
const next = new Set<string>()
let changed = false
for (const dirPath of current) {
if (isPathEqualOrDescendant(dirPath, normalized)) {
if (shouldPurge(dirPath)) {
changed = true
} else {
next.add(dirPath)

View File

@ -1,10 +1,9 @@
import { describe, expect, it } from 'vitest'
import type { FsChangedPayload } from '../../../../shared/types'
import {
canonicalizeFileExplorerWatchPath,
getFileExplorerWatchRuntimeEnvironmentId,
getExternalFileChangeRelativePath,
payloadRequiresDeferredTreeRefresh
resolveCachedDirPath
} from './useFileExplorerWatch'
import type { AppState } from '@/store/types'
@ -104,36 +103,23 @@ describe('canonicalizeFileExplorerWatchPath', () => {
})
})
describe('payloadRequiresDeferredTreeRefresh', () => {
function payload(events: FsChangedPayload['events'], worktreePath = '/repo'): FsChangedPayload {
return { worktreePath, events }
}
it('does not require a full tree refresh for replayable deferred changes', () => {
const changes = payload([
{ kind: 'create', absolutePath: '/repo/src/new.ts', isDirectory: false },
{ kind: 'update', absolutePath: '/repo/src', isDirectory: true },
{ kind: 'delete', absolutePath: '/repo/src/old.ts' }
])
expect(payloadRequiresDeferredTreeRefresh(changes, '/repo')).toBe(false)
describe('resolveCachedDirPath', () => {
it('returns the exact cache key when present', () => {
const cache = { '/repo/src': { children: [] } }
expect(resolveCachedDirPath(cache, '/repo/src')).toBe('/repo/src')
})
it('requires a full tree refresh for unreplayable rename payloads in the current worktree', () => {
const changes = payload([
{ kind: 'rename', absolutePath: '/repo/src/old.ts', isDirectory: false }
])
expect(payloadRequiresDeferredTreeRefresh(changes, '/repo')).toBe(true)
it('matches Windows cache keys case-insensitively (#10264)', () => {
const cache = { 'C:\\Repo\\src': { children: [] } }
expect(resolveCachedDirPath(cache, 'c:\\repo\\src')).toBe('C:\\Repo\\src')
})
it('ignores stale deferred rename payloads from a previous worktree', () => {
const changes = payload(
[{ kind: 'rename', absolutePath: '/other/src/old.ts', isDirectory: false }],
'/other'
)
it('falls back to the worktree root path when the root is not yet cached', () => {
expect(resolveCachedDirPath({}, 'C:\\Repo', 'C:\\Repo')).toBe('C:\\Repo')
})
expect(payloadRequiresDeferredTreeRefresh(changes, '/repo')).toBe(false)
it('returns null when the directory is not cached and is not the worktree root', () => {
expect(resolveCachedDirPath({ '/repo': { children: [] } }, '/repo/src', '/repo')).toBeNull()
})
})

View File

@ -2,23 +2,19 @@ import { useEffect, useRef, type Dispatch, type SetStateAction } from 'react'
import type { FsChangedPayload } from '../../../../shared/types'
import type { DirCache, FileExplorerOperationOwner } from './file-explorer-types'
import type { InlineInput } from './FileExplorerRow'
import { joinPath, normalizeRelativePath, dirname } from '@/lib/path'
import {
isPathInsideOrEqual,
normalizeRuntimePathForComparison,
relativePathInsideRoot
} from '../../../../shared/cross-platform-path'
import {
purgeDirCacheSubtree,
purgeExpandedDirsSubtree,
clearStalePendingReveal
} from './file-explorer-watcher-reconcile'
import { useAppStore } from '@/store'
import { subscribeRuntimeFileChanges } from '@/runtime/runtime-file-client'
import {
getFileExplorerOperationOwnerFromState,
type FileExplorerOwnerState
} from './file-explorer-operation-owner'
import { processFileExplorerFsPayload } from './file-explorer-watch-reconcile'
export {
canonicalizeFileExplorerWatchPath,
getExternalFileChangeRelativePath,
resolveCachedDirPath
} from './file-explorer-watch-path'
type FileExplorerWatchOwnerState = Pick<
FileExplorerOwnerState,
@ -41,55 +37,6 @@ type UseFileExplorerWatchParams = {
operationOwner?: FileExplorerOperationOwner
}
export function getExternalFileChangeRelativePath(
worktreePath: string,
absolutePath: string,
isDirectory: boolean | undefined
): string | null {
if (isDirectory === true) {
return null
}
const relativePath = relativePathInsideRoot(worktreePath, absolutePath)
if (relativePath === null || relativePath === '') {
return null
}
// Why: EditorPanel reloads tabs only from a worktree-relative path, not the watcher's absolute one; normalize or contents go stale.
return normalizeRelativePath(relativePath)
}
export function canonicalizeFileExplorerWatchPath(
worktreePath: string,
absolutePath: string
): string | null {
const relativePath = relativePathInsideRoot(worktreePath, absolutePath)
if (relativePath === null) {
return null
}
const rootPath = normalizeExplorerAbsolutePath(worktreePath)
return relativePath === '' ? rootPath : joinPath(rootPath, relativePath)
}
function normalizeExplorerAbsolutePath(path: string): string {
return path === '/' || /^[A-Za-z]:[\\/]$/.test(path) ? path : path.replace(/[\\/]+$/, '')
}
export function payloadRequiresDeferredTreeRefresh(
payload: FsChangedPayload,
currentWorktreePath: string
): boolean {
if (
normalizeRuntimePathForComparison(payload.worktreePath) !==
normalizeRuntimePathForComparison(currentWorktreePath)
) {
return false
}
return payload.events.some((evt) => evt.kind === 'rename')
}
export function getFileExplorerWatchRuntimeEnvironmentId(
state: FileExplorerWatchOwnerState,
activeWorktreeId: string | null,
@ -181,104 +128,25 @@ export function useFileExplorerWatch({
const currentWorktreePath = worktreePath
function processPayload(payload: FsChangedPayload): void {
// Why: stale batched events from the old worktree can arrive after a switch and corrupt dirCache (design §3).
if (
normalizeRuntimePathForComparison(payload.worktreePath) !==
normalizeRuntimePathForComparison(currentWorktreePath)
) {
return
}
const wtId = worktreeIdRef.current
if (!wtId) {
return
}
const cache = dirCacheRef.current
const exp = expandedRef.current
// Collect directories that need refreshing
const dirsToRefresh = new Set<string>()
let needsFullRefresh = false
for (const evt of payload.events) {
if (evt.kind === 'overflow') {
needsFullRefresh = true
break
}
const normalizedPath = canonicalizeFileExplorerWatchPath(
currentWorktreePath,
evt.absolutePath
)
if (!normalizedPath) {
continue
}
if (evt.kind === 'delete') {
// Why: watcher can't report isDirectory for deletes; a dirCache key means it was an expanded dir (design §4.4).
const wasDirectory = normalizedPath in cache
if (wasDirectory) {
purgeDirCacheSubtree(setDirCache, normalizedPath)
purgeExpandedDirsSubtree(wtId, normalizedPath)
}
// Clear pendingExplorerReveal if it targets the deleted path or a descendant.
clearStalePendingReveal(normalizedPath)
// Clear selectedPath if it points into the deleted subtree
setSelectedPath((prev) => {
if (
prev &&
normalizeRuntimePathForComparison(prev) ===
normalizeRuntimePathForComparison(normalizedPath)
) {
return null
}
if (prev && wasDirectory && isPathInsideOrEqual(normalizedPath, prev)) {
return null
}
return prev
})
// Invalidate the parent directory
const parent = normalizeExplorerAbsolutePath(dirname(normalizedPath))
if (parent in cache) {
dirsToRefresh.add(parent)
}
} else if (evt.kind === 'create') {
// Invalidate the parent directory
const parent = normalizeExplorerAbsolutePath(dirname(normalizedPath))
if (parent in cache) {
dirsToRefresh.add(parent)
}
} else if (evt.kind === 'update') {
// Why: only directory updates invalidate; file-content updates are ignored in v1 (design §6.1).
if (evt.isDirectory === true) {
if (normalizedPath in cache) {
dirsToRefresh.add(normalizedPath)
}
}
}
// 'rename' is deferred to v2 (design §5.3)
}
if (needsFullRefresh) {
void refreshTreeRef.current()
return
}
// Only refresh dirs already loaded and reachable (root, expanded, or cached).
for (const dirPath of dirsToRefresh) {
if (
dirPath === normalizeExplorerAbsolutePath(currentWorktreePath) ||
exp.has(dirPath) ||
dirPath in dirCacheRef.current
) {
processFileExplorerFsPayload({
payload,
currentWorktreePath,
worktreeId: wtId,
cache: dirCacheRef.current,
expanded: expandedRef.current,
setDirCache,
setSelectedPath,
refreshDir: (dirPath) => {
void refreshDirRef.current(dirPath)
},
refreshTree: () => {
void refreshTreeRef.current()
}
}
})
}
// Why: expose processPayload to the flush effect so it can replay deferred payloads without re-subscribing.
@ -353,19 +221,12 @@ export function useFileExplorerWatch({
deferredRef.current.length > 0
) {
const deferred = deferredRef.current.splice(0)
const requiresFullRefresh = worktreePath
? deferred.some((payload) => payloadRequiresDeferredTreeRefresh(payload, worktreePath))
: false
// Why: replay deferred payloads so the tree cache reconciles to disk after inline input or drag ends (design §6.2).
if (processPayloadRef.current) {
for (const payload of deferred) {
processPayloadRef.current(payload)
}
}
// Why: create/delete/update already replayed above; only kinds this reconciler can't apply (rename) pay the full-tree refresh.
if (requiresFullRefresh) {
void refreshTreeRef.current()
}
}
}, [inlineInput, dragSourcePath, isNativeDragOver, worktreePath])
}, [inlineInput, dragSourcePath, isNativeDragOver])
}

View File

@ -0,0 +1,59 @@
import { renameSync, rmSync, writeFileSync } from 'node:fs'
import path from 'node:path'
import { test, expect } from './helpers/orca-app'
import { openFileExplorer } from './helpers/file-explorer'
import { waitForActiveWorktree, waitForSessionReady } from './helpers/store'
test('refreshes the visible tree after external Windows file changes', async ({ orcaPage }) => {
await waitForSessionReady(orcaPage)
await waitForActiveWorktree(orcaPage)
await orcaPage.evaluate(() => window.__store?.getState().setRightSidebarOpen(false))
await expect
.poll(() => orcaPage.evaluate(() => window.__store?.getState().rightSidebarOpen))
.toBe(false)
await openFileExplorer(orcaPage)
const worktreePath = await orcaPage.evaluate(() => {
const state = window.__store?.getState()
const worktreeId = state?.activeWorktreeId
if (!state || !worktreeId) {
throw new Error('active worktree unavailable')
}
const worktree = Object.values(state.worktreesByRepo)
.flat()
.find((candidate) => candidate.id === worktreeId)
if (!worktree) {
throw new Error('active worktree path unavailable')
}
return worktree.path
})
const originalName = 'watch-refresh-case.txt'
const renamedName = 'WATCH-REFRESH-CASE.txt'
const originalPath = path.join(worktreePath, originalName)
const renamedPath = path.join(worktreePath, renamedName)
const row = (name: string) =>
orcaPage
.locator('[data-file-explorer-row]')
.filter({ hasText: new RegExp(`^${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`) })
rmSync(originalPath, { force: true })
rmSync(renamedPath, { force: true })
try {
await expect(row('README.md')).toBeVisible({ timeout: 10_000 })
await orcaPage.waitForTimeout(2_000)
writeFileSync(originalPath, 'created outside Orca\n')
await expect(row(originalName)).toBeVisible({ timeout: 10_000 })
renameSync(originalPath, renamedPath)
await expect(row(renamedName)).toBeVisible({ timeout: 10_000 })
await expect(row(originalName)).toHaveCount(0, { timeout: 10_000 })
rmSync(renamedPath)
await expect(row(renamedName)).toHaveCount(0, { timeout: 10_000 })
} finally {
rmSync(originalPath, { force: true })
rmSync(renamedPath, { force: true })
}
})