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 }) + } +})