perf(explorer): avoid full refresh after replayed events (#1827)

Skip the expensive expanded-tree refresh after deferred file explorer watcher payloads when the replayed events can be reconciled with targeted directory refreshes. Keep the fallback for unreplayable rename payloads.
This commit is contained in:
Neil 2026-05-14 01:22:38 -07:00 committed by GitHub
parent 105d0a50f8
commit ee2b8b861c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 56 additions and 6 deletions

View File

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

View File

@ -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()
}
}