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:
xintaofei
2026-04-20 22:57:24 +08:00
parent c825291b1e
commit baf3b6e89f
4 changed files with 220 additions and 15 deletions

View File

@@ -48,6 +48,8 @@ pub struct WorkspaceDeltaEnvelope {
pub kind: String, pub kind: String,
pub payload: Vec<WorkspaceDelta>, pub payload: Vec<WorkspaceDelta>,
pub requires_resync: bool, pub requires_resync: bool,
#[serde(default)]
pub changed_paths: Vec<String>,
} }
#[derive(Debug, Clone, Serialize)] #[derive(Debug, Clone, Serialize)]
@@ -58,6 +60,8 @@ pub struct WorkspaceStateEvent {
pub kind: String, pub kind: String,
pub payload: Vec<WorkspaceDelta>, pub payload: Vec<WorkspaceDelta>,
pub requires_resync: bool, pub requires_resync: bool,
#[serde(default)]
pub changed_paths: Vec<String>,
} }
#[derive(Debug, Clone, Serialize)] #[derive(Debug, Clone, Serialize)]
@@ -108,6 +112,7 @@ impl WorkspaceStateCore {
kind: String, kind: String,
payload: Vec<WorkspaceDelta>, payload: Vec<WorkspaceDelta>,
requires_resync: bool, requires_resync: bool,
changed_paths: Vec<String>,
) -> WorkspaceStateEvent { ) -> WorkspaceStateEvent {
self.seq += 1; self.seq += 1;
@@ -120,6 +125,7 @@ impl WorkspaceStateCore {
kind: kind.clone(), kind: kind.clone(),
payload: payload.clone(), payload: payload.clone(),
requires_resync, requires_resync,
changed_paths: changed_paths.clone(),
}; };
self.push_recent_event(envelope); self.push_recent_event(envelope);
@@ -130,6 +136,7 @@ impl WorkspaceStateCore {
kind, kind,
payload, payload,
requires_resync, requires_resync,
changed_paths,
} }
} }
@@ -713,6 +720,17 @@ async fn flush_watch_batch(
}); });
} }
// Surface FS activity that doesn't otherwise change tree/git snapshots
// (e.g. files added/removed in a directory beyond WORKSPACE_TREE_MAX_DEPTH,
// or gitignored / non-git-repo changes). The envelope's `changed_paths`
// lets the frontend invalidate its lazy-loaded overrides for deep
// directories without waiting for a manual reload.
if payload.is_empty() && !changed_paths.is_empty() {
payload.push(WorkspaceDelta::Meta {
reason: "fs_events".to_string(),
});
}
if payload.is_empty() { if payload.is_empty() {
return; return;
} }
@@ -731,7 +749,7 @@ async fn flush_watch_batch(
"meta".to_string() "meta".to_string()
}; };
guard.append_event(kind, payload, git_presence_changed) guard.append_event(kind, payload, git_presence_changed, changed_paths)
}; };
emit_event(emitter, "folder://workspace-state-event", event); emit_event(emitter, "folder://workspace-state-event", event);
@@ -1072,6 +1090,7 @@ mod tests {
reason: "boot".to_string(), reason: "boot".to_string(),
}], }],
false, false,
Vec::new(),
); );
let e2 = core.append_event( let e2 = core.append_event(
@@ -1080,6 +1099,7 @@ mod tests {
reason: "tick".to_string(), reason: "tick".to_string(),
}], }],
false, false,
Vec::new(),
); );
assert!(e2.seq > e1.seq); assert!(e2.seq > e1.seq);
@@ -1095,6 +1115,7 @@ mod tests {
reason: "a".to_string(), reason: "a".to_string(),
}], }],
false, false,
Vec::new(),
); );
core.append_event( core.append_event(
@@ -1103,6 +1124,7 @@ mod tests {
reason: "b".to_string(), reason: "b".to_string(),
}], }],
false, false,
Vec::new(),
); );
let snapshot = core.snapshot(Some(e1.seq)); let snapshot = core.snapshot(Some(e1.seq));
@@ -1123,6 +1145,7 @@ mod tests {
reason: "a".to_string(), reason: "a".to_string(),
}], }],
false, false,
Vec::new(),
); );
core.append_event( core.append_event(
"meta".to_string(), "meta".to_string(),
@@ -1130,6 +1153,7 @@ mod tests {
reason: "b".to_string(), reason: "b".to_string(),
}], }],
false, false,
Vec::new(),
); );
let snapshot = core.snapshot(Some(0)); let snapshot = core.snapshot(Some(0));

View File

@@ -888,6 +888,11 @@ export function FileTreeTab() {
new Map() new Map()
) )
const lazyLoadingDirPathsRef = useRef<Set<string>>(new Set()) 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>>( const externalConflictSignatureByPathRef = useRef<Map<string, string>>(
new Map() new Map()
) )
@@ -967,36 +972,58 @@ export function FileTreeTab() {
return 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") 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] [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(() => { useEffect(() => {
workspaceTreeRef.current = workspaceState.tree
setNodes( setNodes(
applyLazyTreeOverrides( applyLazyTreeOverrides(
workspaceState.tree, workspaceState.tree,
lazyLoadedChildrenByPathRef.current lazyLoadedChildrenByPathRef.current
) )
) )
}, [folder?.path, workspaceState.tree])
useEffect(() => {
const nextStatusByPath = new Map<string, string>() const nextStatusByPath = new Map<string, string>()
for (const entry of workspaceState.git) { for (const entry of workspaceState.git) {
nextStatusByPath.set(entry.path, entry.status) nextStatusByPath.set(entry.path, entry.status)
} }
setGitStatusByPath(nextStatusByPath) setGitStatusByPath(nextStatusByPath)
setGitEnabled(true) setGitEnabled(true)
}, [workspaceState.git])
useEffect(() => {
setLoading( setLoading(
workspaceState.health === "resyncing" && workspaceState.seq === 0 workspaceState.health === "resyncing" && workspaceState.seq === 0
) )
setError(workspaceState.health === "degraded" ? workspaceState.error : null) setError(workspaceState.health === "degraded" ? workspaceState.error : null)
}, [ }, [workspaceState.error, workspaceState.health, workspaceState.seq])
folder?.path,
workspaceState.error,
workspaceState.git,
workspaceState.health,
workspaceState.seq,
workspaceState.tree,
])
const loadDirectoryChildren = useCallback( const loadDirectoryChildren = useCallback(
async (dirPath: string) => { async (dirPath: string) => {
@@ -1007,12 +1034,19 @@ export function FileTreeTab() {
if (lazyLoadedChildrenByPathRef.current.has(normalizedDirPath)) return if (lazyLoadedChildrenByPathRef.current.has(normalizedDirPath)) return
if (lazyLoadingDirPathsRef.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) { if (existingChildren && existingChildren.length > 0) {
lazyLoadedChildrenByPathRef.current.set(
normalizedDirPath,
existingChildren
)
return return
} }
@@ -1033,9 +1067,90 @@ export function FileTreeTab() {
lazyLoadingDirPathsRef.current.delete(normalizedDirPath) 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(() => { useEffect(() => {
const previousExpanded = previousExpandedPathsRef.current const previousExpanded = previousExpandedPathsRef.current
for (const path of expandedPaths) { for (const path of expandedPaths) {

View File

@@ -30,9 +30,16 @@ export interface WorkspaceStateView {
isGitRepo: boolean isGitRepo: boolean
} }
export type WorkspaceEnvelopeListener = (envelope: {
seq: number
kind: string
changed_paths: string[]
}) => void
export interface WorkspaceStateResult extends WorkspaceStateView { export interface WorkspaceStateResult extends WorkspaceStateView {
requestResync: (reason?: string) => Promise<void> requestResync: (reason?: string) => Promise<void>
restart: () => Promise<void> restart: () => Promise<void>
subscribeEnvelopes: (listener: WorkspaceEnvelopeListener) => () => void
} }
const WORKSPACE_PROTOCOL_VERSION = 1 const WORKSPACE_PROTOCOL_VERSION = 1
@@ -140,6 +147,7 @@ class WorkspaceStateStore {
private readonly rootPath: string private readonly rootPath: string
private readonly normalizedRootPath: string private readonly normalizedRootPath: string
private listeners = new Set<() => void>() private listeners = new Set<() => void>()
private envelopeListeners = new Set<WorkspaceEnvelopeListener>()
private state: WorkspaceStateView private state: WorkspaceStateView
private refCount = 0 private refCount = 0
private started = false private started = false
@@ -171,6 +179,13 @@ class WorkspaceStateStore {
} }
} }
subscribeEnvelopes = (listener: WorkspaceEnvelopeListener): (() => void) => {
this.envelopeListeners.add(listener)
return () => {
this.envelopeListeners.delete(listener)
}
}
acquire = () => { acquire = () => {
this.cancelPendingShutdown() this.cancelPendingShutdown()
this.cancelEviction() this.cancelEviction()
@@ -212,6 +227,16 @@ class WorkspaceStateStore {
this.patchState((prev) => applySnapshot(prev, snapshot)) this.patchState((prev) => applySnapshot(prev, snapshot))
if (snapshot.full) { if (snapshot.full) {
this.hasBaselineSnapshot = true this.hasBaselineSnapshot = true
} else {
// Forward replayed envelopes so downstream cache-invalidation
// hooks catch up on FS activity that happened while disconnected.
for (const envelope of snapshot.deltas) {
this.notifyEnvelope({
seq: envelope.seq,
kind: envelope.kind,
changed_paths: envelope.changed_paths ?? [],
})
}
} }
} catch (error) { } catch (error) {
this.patchState((prev) => ({ this.patchState((prev) => ({
@@ -315,6 +340,15 @@ class WorkspaceStateStore {
) )
if (!this.isLifecycleActive(lifecycleId)) return if (!this.isLifecycleActive(lifecycleId)) return
this.patchState((prev) => applySnapshot(prev, catchUpSnapshot)) this.patchState((prev) => applySnapshot(prev, catchUpSnapshot))
if (!catchUpSnapshot.full) {
for (const envelope of catchUpSnapshot.deltas) {
this.notifyEnvelope({
seq: envelope.seq,
kind: envelope.kind,
changed_paths: envelope.changed_paths ?? [],
})
}
}
} catch (error) { } catch (error) {
this.patchState((prev) => ({ this.patchState((prev) => ({
...prev, ...prev,
@@ -418,6 +452,26 @@ class WorkspaceStateStore {
health: "healthy", health: "healthy",
error: null, error: null,
})) }))
this.notifyEnvelope({
seq: event.seq,
kind: event.kind,
changed_paths: event.changed_paths ?? [],
})
}
private notifyEnvelope = (envelope: {
seq: number
kind: string
changed_paths: string[]
}) => {
for (const listener of this.envelopeListeners) {
try {
listener(envelope)
} catch (error) {
console.error("[workspace-state] envelope listener failed", error)
}
}
} }
private patchState = ( private patchState = (
@@ -509,11 +563,20 @@ export function useWorkspaceStateStore(
await store.restart() await store.restart()
}, [store]) }, [store])
const subscribeEnvelopes = useCallback(
(listener: WorkspaceEnvelopeListener) => {
if (!store) return () => {}
return store.subscribeEnvelopes(listener)
},
[store]
)
if (!rootPath) { if (!rootPath) {
return { return {
...EMPTY_STATE, ...EMPTY_STATE,
requestResync, requestResync,
restart, restart,
subscribeEnvelopes,
} }
} }
@@ -521,5 +584,6 @@ export function useWorkspaceStateStore(
...snapshot, ...snapshot,
requestResync, requestResync,
restart, restart,
subscribeEnvelopes,
} }
} }

View File

@@ -879,6 +879,7 @@ export interface WorkspaceDeltaEnvelope {
kind: "fs_delta" | "git_delta" | "meta" | "resync_hint" | string kind: "fs_delta" | "git_delta" | "meta" | "resync_hint" | string
payload: WorkspaceDelta[] payload: WorkspaceDelta[]
requires_resync: boolean requires_resync: boolean
changed_paths?: string[]
} }
export interface WorkspaceStateEvent { export interface WorkspaceStateEvent {
@@ -888,6 +889,7 @@ export interface WorkspaceStateEvent {
kind: "fs_delta" | "git_delta" | "meta" | "resync_hint" | string kind: "fs_delta" | "git_delta" | "meta" | "resync_hint" | string
payload: WorkspaceDelta[] payload: WorkspaceDelta[]
requires_resync: boolean requires_resync: boolean
changed_paths?: string[]
} }
export interface WorkspaceSnapshotResponse { export interface WorkspaceSnapshotResponse {