From adc10cd21ab33812582c5dbd2fa07e9430bcd940 Mon Sep 17 00:00:00 2001 From: Wooseong Kim <2222333+innocarpe@users.noreply.github.com> Date: Wed, 29 Jul 2026 12:52:09 +0900 Subject: [PATCH] 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> --- .../file-explorer-watch-drive-root.test.ts | 70 +++ .../right-sidebar/file-explorer-watch-path.ts | 92 ++++ .../file-explorer-watch-reconcile.test.ts | 441 ++++++++++++++++++ .../file-explorer-watch-reconcile.ts | 224 +++++++++ .../file-explorer-watcher-reconcile.ts | 54 ++- .../useFileExplorerWatch.test.ts | 40 +- .../right-sidebar/useFileExplorerWatch.ts | 181 +------ tests/e2e/file-explorer-watch-refresh.spec.ts | 59 +++ 8 files changed, 967 insertions(+), 194 deletions(-) create mode 100644 src/renderer/src/components/right-sidebar/file-explorer-watch-drive-root.test.ts create mode 100644 src/renderer/src/components/right-sidebar/file-explorer-watch-path.ts create mode 100644 src/renderer/src/components/right-sidebar/file-explorer-watch-reconcile.test.ts create mode 100644 src/renderer/src/components/right-sidebar/file-explorer-watch-reconcile.ts create mode 100644 tests/e2e/file-explorer-watch-refresh.spec.ts diff --git a/src/renderer/src/components/right-sidebar/file-explorer-watch-drive-root.test.ts b/src/renderer/src/components/right-sidebar/file-explorer-watch-drive-root.test.ts new file mode 100644 index 000000000..259d3da31 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/file-explorer-watch-drive-root.test.ts @@ -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 { + 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) + }) +}) diff --git a/src/renderer/src/components/right-sidebar/file-explorer-watch-path.ts b/src/renderer/src/components/right-sidebar/file-explorer-watch-path.ts new file mode 100644 index 000000000..5273408fe --- /dev/null +++ b/src/renderer/src/components/right-sidebar/file-explorer-watch-path.ts @@ -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 +): ReadonlyMap { + const index = new Map() + 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, + dirPath: string, + worktreePath?: string, + cachePathIndex?: ReadonlyMap +): 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) +} diff --git a/src/renderer/src/components/right-sidebar/file-explorer-watch-reconcile.test.ts b/src/renderer/src/components/right-sidebar/file-explorer-watch-reconcile.test.ts new file mode 100644 index 000000000..d62420f79 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/file-explorer-watch-reconcile.test.ts @@ -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 +}): ReturnType { + 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 = { + '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[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 = { + [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 = { [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 = { [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): Record => + new Proxy(value, { + getOwnPropertyDescriptor(target, property) { + keyVisits++ + return Reflect.getOwnPropertyDescriptor(target, property) + } + }) + let current = measured(entries) + type DirCacheUpdate = Parameters< + Parameters[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 | 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()) + }) +}) diff --git a/src/renderer/src/components/right-sidebar/file-explorer-watch-reconcile.ts b/src/renderer/src/components/right-sidebar/file-explorer-watch-reconcile.ts new file mode 100644 index 000000000..538657edb --- /dev/null +++ b/src/renderer/src/components/right-sidebar/file-explorer-watch-reconcile.ts @@ -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 + expanded: Set + setDirCache: Dispatch>> + setSelectedPath: Dispatch> + refreshDir: (dirPath: string) => void + refreshTree: () => void +} + +function cachedDirectoryContainsPath( + cache: Record, + cachedDirPath: string, + childPath: string, + childPathIndexes: Map> +): 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() + const childPathIndexes = new Map>() + const cachePathIndex = createCachedDirPathIndex(cache) + const cachedDirsToPurge = new Set() + const reconciledRenameSources = new Set() + 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) + } + } +} diff --git a/src/renderer/src/components/right-sidebar/file-explorer-watcher-reconcile.ts b/src/renderer/src/components/right-sidebar/file-explorer-watcher-reconcile.ts index 517618f3f..0f151c542 100644 --- a/src/renderer/src/components/right-sidebar/file-explorer-watcher-reconcile.ts +++ b/src/renderer/src/components/right-sidebar/file-explorer-watcher-reconcile.ts @@ -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): (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>>, - deletedPath: string + deletedPaths: ReadonlySet ): void { - const normalized = normalizeAbsolutePath(deletedPath) + if (deletedPaths.size === 0) { + return + } + const shouldPurge = createSubtreeMatcher(deletedPaths) setDirCache((prev) => { let changed = false const next: Record = {} 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 +): 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() let changed = false for (const dirPath of current) { - if (isPathEqualOrDescendant(dirPath, normalized)) { + if (shouldPurge(dirPath)) { changed = true } else { next.add(dirPath) diff --git a/src/renderer/src/components/right-sidebar/useFileExplorerWatch.test.ts b/src/renderer/src/components/right-sidebar/useFileExplorerWatch.test.ts index bb08fbc99..a5ee3f264 100644 --- a/src/renderer/src/components/right-sidebar/useFileExplorerWatch.test.ts +++ b/src/renderer/src/components/right-sidebar/useFileExplorerWatch.test.ts @@ -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() }) }) diff --git a/src/renderer/src/components/right-sidebar/useFileExplorerWatch.ts b/src/renderer/src/components/right-sidebar/useFileExplorerWatch.ts index 0d2cb4ad4..be089be16 100644 --- a/src/renderer/src/components/right-sidebar/useFileExplorerWatch.ts +++ b/src/renderer/src/components/right-sidebar/useFileExplorerWatch.ts @@ -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() - 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]) } diff --git a/tests/e2e/file-explorer-watch-refresh.spec.ts b/tests/e2e/file-explorer-watch-refresh.spec.ts new file mode 100644 index 000000000..d8a1bc1e4 --- /dev/null +++ b/tests/e2e/file-explorer-watch-refresh.spec.ts @@ -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 }) + } +})