fix(file-tree): keep aux-panel tree in sync with filesystem changes
Deep directories (beyond the workspace snapshot's depth limit) relied on a lazy-loaded override cache that was never invalidated, so agent-created files, in-app deletes / renames / rollbacks, and external changes inside expanded deep folders stayed invisible until the folder was reopened. Propagate watcher `changed_paths` through the delta envelope and fire a Meta event whenever FS activity doesn't alter the tree/git snapshots, so the frontend can surgically invalidate affected cache entries and re-fetch. Manual refresh (Reload from disk) clears the cache and re-hydrates still-expanded deep dirs through the same path. Replayed deltas after reconnect are forwarded to the same listeners. Also split the combined workspace-state effect into tree / git / status slices so unrelated state transitions (e.g. the 'resyncing' flip during a refresh) no longer rebuild the entire node tree and cause a flash.
This commit is contained in:
@@ -888,6 +888,11 @@ export function FileTreeTab() {
|
||||
new Map()
|
||||
)
|
||||
const lazyLoadingDirPathsRef = useRef<Set<string>>(new Set())
|
||||
const loadDirectoryChildrenRef = useRef<
|
||||
((dirPath: string) => Promise<void>) | null
|
||||
>(null)
|
||||
const expandedPathsRef = useRef<Set<string>>(new Set([FILE_TREE_ROOT_PATH]))
|
||||
const workspaceTreeRef = useRef<FileTreeNode[]>([])
|
||||
const externalConflictSignatureByPathRef = useRef<Map<string, string>>(
|
||||
new Map()
|
||||
)
|
||||
@@ -967,36 +972,58 @@ export function FileTreeTab() {
|
||||
return
|
||||
}
|
||||
|
||||
// Drop the lazy-load override cache so the fresh snapshot is not
|
||||
// masked by stale children (e.g. after deletes / renames / rollbacks
|
||||
// or files the agent just created). Reading expanded paths via a ref
|
||||
// keeps fetchTree's identity stable across expand/collapse so
|
||||
// downstream memoization is not invalidated on every tree interaction.
|
||||
const pathsToReload = Array.from(expandedPathsRef.current).filter(
|
||||
(path) => path !== FILE_TREE_ROOT_PATH
|
||||
)
|
||||
lazyLoadedChildrenByPathRef.current.clear()
|
||||
await workspaceState.requestResync("manual_refresh")
|
||||
// Re-hydrate children for directories beyond WORKSPACE_TREE_MAX_DEPTH
|
||||
// that are still expanded — the backend snapshot does not include them.
|
||||
const loader = loadDirectoryChildrenRef.current
|
||||
if (loader) {
|
||||
for (const path of pathsToReload) {
|
||||
void loader(path)
|
||||
}
|
||||
}
|
||||
},
|
||||
[folder?.path, workspaceState]
|
||||
)
|
||||
|
||||
// Tree updates are the only source that should cause a full setNodes.
|
||||
// applyLazyTreeOverrides rebuilds every directory node object, which forces
|
||||
// React to re-render the entire tree. Keeping this effect narrow avoids
|
||||
// wasted work on health / seq / error / git transitions that don't touch
|
||||
// the tree shape (e.g. the intermediate "resyncing" patch during a refresh).
|
||||
useEffect(() => {
|
||||
workspaceTreeRef.current = workspaceState.tree
|
||||
setNodes(
|
||||
applyLazyTreeOverrides(
|
||||
workspaceState.tree,
|
||||
lazyLoadedChildrenByPathRef.current
|
||||
)
|
||||
)
|
||||
}, [folder?.path, workspaceState.tree])
|
||||
|
||||
useEffect(() => {
|
||||
const nextStatusByPath = new Map<string, string>()
|
||||
for (const entry of workspaceState.git) {
|
||||
nextStatusByPath.set(entry.path, entry.status)
|
||||
}
|
||||
setGitStatusByPath(nextStatusByPath)
|
||||
setGitEnabled(true)
|
||||
}, [workspaceState.git])
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(
|
||||
workspaceState.health === "resyncing" && workspaceState.seq === 0
|
||||
)
|
||||
setError(workspaceState.health === "degraded" ? workspaceState.error : null)
|
||||
}, [
|
||||
folder?.path,
|
||||
workspaceState.error,
|
||||
workspaceState.git,
|
||||
workspaceState.health,
|
||||
workspaceState.seq,
|
||||
workspaceState.tree,
|
||||
])
|
||||
}, [workspaceState.error, workspaceState.health, workspaceState.seq])
|
||||
|
||||
const loadDirectoryChildren = useCallback(
|
||||
async (dirPath: string) => {
|
||||
@@ -1007,12 +1034,19 @@ export function FileTreeTab() {
|
||||
if (lazyLoadedChildrenByPathRef.current.has(normalizedDirPath)) return
|
||||
if (lazyLoadingDirPathsRef.current.has(normalizedDirPath)) return
|
||||
|
||||
const existingChildren = findDirectoryChildren(nodes, normalizedDirPath)
|
||||
// Check the backend tree (source of truth), not the rendered `nodes`.
|
||||
// `nodes` carries stale lazy-cache overrides that don't invalidate
|
||||
// until a tree_replace delta arrives — but for directories beyond
|
||||
// WORKSPACE_TREE_MAX_DEPTH the backend never emits tree_replace for
|
||||
// changes inside them (their children are not in tree_snapshot, so
|
||||
// the refreshed tree compares equal to the old one). Checking
|
||||
// `nodes` would cause fetchTree's forced reload to short-circuit on
|
||||
// the stale override and miss deletions / creations in deep dirs.
|
||||
const existingChildren = findDirectoryChildren(
|
||||
workspaceTreeRef.current,
|
||||
normalizedDirPath
|
||||
)
|
||||
if (existingChildren && existingChildren.length > 0) {
|
||||
lazyLoadedChildrenByPathRef.current.set(
|
||||
normalizedDirPath,
|
||||
existingChildren
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1033,9 +1067,90 @@ export function FileTreeTab() {
|
||||
lazyLoadingDirPathsRef.current.delete(normalizedDirPath)
|
||||
}
|
||||
},
|
||||
[folder?.path, nodes]
|
||||
[folder?.path]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
loadDirectoryChildrenRef.current = loadDirectoryChildren
|
||||
}, [loadDirectoryChildren])
|
||||
|
||||
useEffect(() => {
|
||||
expandedPathsRef.current = expandedPaths
|
||||
}, [expandedPaths])
|
||||
|
||||
// Subscribe to workspace envelopes to invalidate lazy-loaded overrides for
|
||||
// directories beyond WORKSPACE_TREE_MAX_DEPTH. Those directories are never
|
||||
// reflected in the backend's depth-2 tree_snapshot, so changes inside them
|
||||
// don't emit a tree_replace delta — the frontend has to target invalidation
|
||||
// by matching each `changed_paths` entry against its cached ancestors.
|
||||
// The backend already debounces raw FS events (1s / 3s max), so we only
|
||||
// need a microtask hop here to merge paths that hit the same cached
|
||||
// ancestor within one envelope (or any synchronous burst of envelopes).
|
||||
const subscribeWorkspaceEnvelopes = workspaceState.subscribeEnvelopes
|
||||
useEffect(() => {
|
||||
if (!subscribeWorkspaceEnvelopes) return
|
||||
|
||||
const pendingPaths = new Set<string>()
|
||||
let flushScheduled = false
|
||||
let disposed = false
|
||||
|
||||
const flushPending = () => {
|
||||
flushScheduled = false
|
||||
if (disposed || pendingPaths.size === 0) return
|
||||
const paths = Array.from(pendingPaths)
|
||||
pendingPaths.clear()
|
||||
|
||||
const loader = loadDirectoryChildrenRef.current
|
||||
const cache = lazyLoadedChildrenByPathRef.current
|
||||
const invalidated = new Set<string>()
|
||||
|
||||
for (const changed of paths) {
|
||||
const normalized = normalizeComparePath(changed)
|
||||
if (!normalized) continue
|
||||
// Walk from the changed path up to the root, invalidating the
|
||||
// nearest cached ancestor (or the path itself if a directory of
|
||||
// that name is in the cache). Checking the path itself covers
|
||||
// FS events that report the directory path directly.
|
||||
let cursor = normalized
|
||||
while (cursor.length > 0) {
|
||||
if (cache.has(cursor)) {
|
||||
invalidated.add(cursor)
|
||||
break
|
||||
}
|
||||
const slash = cursor.lastIndexOf("/")
|
||||
const parent = slash === -1 ? "" : cursor.slice(0, slash)
|
||||
if (parent.length === 0) break
|
||||
cursor = parent
|
||||
}
|
||||
}
|
||||
|
||||
if (invalidated.size === 0) return
|
||||
for (const path of invalidated) {
|
||||
cache.delete(path)
|
||||
}
|
||||
if (!loader) return
|
||||
for (const path of invalidated) {
|
||||
void loader(path)
|
||||
}
|
||||
}
|
||||
|
||||
const unsubscribe = subscribeWorkspaceEnvelopes(({ changed_paths }) => {
|
||||
if (!changed_paths || changed_paths.length === 0) return
|
||||
for (const path of changed_paths) {
|
||||
pendingPaths.add(path)
|
||||
}
|
||||
if (flushScheduled) return
|
||||
flushScheduled = true
|
||||
queueMicrotask(flushPending)
|
||||
})
|
||||
|
||||
return () => {
|
||||
disposed = true
|
||||
unsubscribe()
|
||||
pendingPaths.clear()
|
||||
}
|
||||
}, [subscribeWorkspaceEnvelopes])
|
||||
|
||||
useEffect(() => {
|
||||
const previousExpanded = previousExpandedPathsRef.current
|
||||
for (const path of expandedPaths) {
|
||||
|
||||
Reference in New Issue
Block a user