diff --git a/src/renderer/src/components/right-sidebar/useFileExplorerWatch.test.ts b/src/renderer/src/components/right-sidebar/useFileExplorerWatch.test.ts index 3ecd5c74bb2..2d0c9290074 100644 --- a/src/renderer/src/components/right-sidebar/useFileExplorerWatch.test.ts +++ b/src/renderer/src/components/right-sidebar/useFileExplorerWatch.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from 'vitest' -import { getExternalFileChangeRelativePath } from './useFileExplorerWatch' +import type { FsChangedPayload } from '../../../../shared/types' +import { + getExternalFileChangeRelativePath, + payloadRequiresDeferredTreeRefresh +} from './useFileExplorerWatch' describe('getExternalFileChangeRelativePath', () => { it('returns a worktree-relative file path for external file updates', () => { @@ -57,3 +61,36 @@ describe('getExternalFileChangeRelativePath', () => { ) }) }) + +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) + }) + + 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('ignores stale deferred rename payloads from a previous worktree', () => { + const changes = payload( + [{ kind: 'rename', absolutePath: '/other/src/old.ts', isDirectory: false }], + '/other' + ) + + expect(payloadRequiresDeferredTreeRefresh(changes, '/repo')).toBe(false) + }) +}) diff --git a/src/renderer/src/components/right-sidebar/useFileExplorerWatch.ts b/src/renderer/src/components/right-sidebar/useFileExplorerWatch.ts index 1823dae0280..22c9dbd0205 100644 --- a/src/renderer/src/components/right-sidebar/useFileExplorerWatch.ts +++ b/src/renderer/src/components/right-sidebar/useFileExplorerWatch.ts @@ -57,6 +57,17 @@ export function getExternalFileChangeRelativePath( return normalizeRelativePath(normalizedAbsolutePath.slice(worktreePrefix.length)) } +export function payloadRequiresDeferredTreeRefresh( + payload: FsChangedPayload, + currentWorktreePath: string +): boolean { + if (normalizeAbsolutePath(payload.worktreePath) !== normalizeAbsolutePath(currentWorktreePath)) { + return false + } + + return payload.events.some((evt) => evt.kind === 'rename') +} + /** * Reconciles File Explorer state on filesystem events for the active worktree. * @@ -278,6 +289,9 @@ 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 every deferred payload through `processPayload` so the // tree cache reconciles to disk state after inline input or drag ends // (design ยง6.2). Editor-tab reloads are handled independently by @@ -288,11 +302,10 @@ export function useFileExplorerWatch({ processPayloadRef.current(payload) } } - // Why: also trigger a tree refresh as a safety net. Deferred events may - // have been coalesced by the watcher or become stale during the defer - // window, and a full refresh guarantees the explorer state converges on - // disk reality even if individual event replay misses a subtree. - if (worktreePath) { + // Why: create/delete/update payloads replay into targeted refreshDir + // calls above. Only event kinds this reconciler cannot apply safely + // should pay the full expanded-tree refresh cost after a deferred flush. + if (requiresFullRefresh) { void refreshTreeRef.current() } }