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:
@@ -48,6 +48,8 @@ pub struct WorkspaceDeltaEnvelope {
|
||||
pub kind: String,
|
||||
pub payload: Vec<WorkspaceDelta>,
|
||||
pub requires_resync: bool,
|
||||
#[serde(default)]
|
||||
pub changed_paths: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
@@ -58,6 +60,8 @@ pub struct WorkspaceStateEvent {
|
||||
pub kind: String,
|
||||
pub payload: Vec<WorkspaceDelta>,
|
||||
pub requires_resync: bool,
|
||||
#[serde(default)]
|
||||
pub changed_paths: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
@@ -108,6 +112,7 @@ impl WorkspaceStateCore {
|
||||
kind: String,
|
||||
payload: Vec<WorkspaceDelta>,
|
||||
requires_resync: bool,
|
||||
changed_paths: Vec<String>,
|
||||
) -> WorkspaceStateEvent {
|
||||
self.seq += 1;
|
||||
|
||||
@@ -120,6 +125,7 @@ impl WorkspaceStateCore {
|
||||
kind: kind.clone(),
|
||||
payload: payload.clone(),
|
||||
requires_resync,
|
||||
changed_paths: changed_paths.clone(),
|
||||
};
|
||||
self.push_recent_event(envelope);
|
||||
|
||||
@@ -130,6 +136,7 @@ impl WorkspaceStateCore {
|
||||
kind,
|
||||
payload,
|
||||
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() {
|
||||
return;
|
||||
}
|
||||
@@ -731,7 +749,7 @@ async fn flush_watch_batch(
|
||||
"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);
|
||||
@@ -1072,6 +1090,7 @@ mod tests {
|
||||
reason: "boot".to_string(),
|
||||
}],
|
||||
false,
|
||||
Vec::new(),
|
||||
);
|
||||
|
||||
let e2 = core.append_event(
|
||||
@@ -1080,6 +1099,7 @@ mod tests {
|
||||
reason: "tick".to_string(),
|
||||
}],
|
||||
false,
|
||||
Vec::new(),
|
||||
);
|
||||
|
||||
assert!(e2.seq > e1.seq);
|
||||
@@ -1095,6 +1115,7 @@ mod tests {
|
||||
reason: "a".to_string(),
|
||||
}],
|
||||
false,
|
||||
Vec::new(),
|
||||
);
|
||||
|
||||
core.append_event(
|
||||
@@ -1103,6 +1124,7 @@ mod tests {
|
||||
reason: "b".to_string(),
|
||||
}],
|
||||
false,
|
||||
Vec::new(),
|
||||
);
|
||||
|
||||
let snapshot = core.snapshot(Some(e1.seq));
|
||||
@@ -1123,6 +1145,7 @@ mod tests {
|
||||
reason: "a".to_string(),
|
||||
}],
|
||||
false,
|
||||
Vec::new(),
|
||||
);
|
||||
core.append_event(
|
||||
"meta".to_string(),
|
||||
@@ -1130,6 +1153,7 @@ mod tests {
|
||||
reason: "b".to_string(),
|
||||
}],
|
||||
false,
|
||||
Vec::new(),
|
||||
);
|
||||
|
||||
let snapshot = core.snapshot(Some(0));
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -30,9 +30,16 @@ export interface WorkspaceStateView {
|
||||
isGitRepo: boolean
|
||||
}
|
||||
|
||||
export type WorkspaceEnvelopeListener = (envelope: {
|
||||
seq: number
|
||||
kind: string
|
||||
changed_paths: string[]
|
||||
}) => void
|
||||
|
||||
export interface WorkspaceStateResult extends WorkspaceStateView {
|
||||
requestResync: (reason?: string) => Promise<void>
|
||||
restart: () => Promise<void>
|
||||
subscribeEnvelopes: (listener: WorkspaceEnvelopeListener) => () => void
|
||||
}
|
||||
|
||||
const WORKSPACE_PROTOCOL_VERSION = 1
|
||||
@@ -140,6 +147,7 @@ class WorkspaceStateStore {
|
||||
private readonly rootPath: string
|
||||
private readonly normalizedRootPath: string
|
||||
private listeners = new Set<() => void>()
|
||||
private envelopeListeners = new Set<WorkspaceEnvelopeListener>()
|
||||
private state: WorkspaceStateView
|
||||
private refCount = 0
|
||||
private started = false
|
||||
@@ -171,6 +179,13 @@ class WorkspaceStateStore {
|
||||
}
|
||||
}
|
||||
|
||||
subscribeEnvelopes = (listener: WorkspaceEnvelopeListener): (() => void) => {
|
||||
this.envelopeListeners.add(listener)
|
||||
return () => {
|
||||
this.envelopeListeners.delete(listener)
|
||||
}
|
||||
}
|
||||
|
||||
acquire = () => {
|
||||
this.cancelPendingShutdown()
|
||||
this.cancelEviction()
|
||||
@@ -212,6 +227,16 @@ class WorkspaceStateStore {
|
||||
this.patchState((prev) => applySnapshot(prev, snapshot))
|
||||
if (snapshot.full) {
|
||||
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) {
|
||||
this.patchState((prev) => ({
|
||||
@@ -315,6 +340,15 @@ class WorkspaceStateStore {
|
||||
)
|
||||
if (!this.isLifecycleActive(lifecycleId)) return
|
||||
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) {
|
||||
this.patchState((prev) => ({
|
||||
...prev,
|
||||
@@ -418,6 +452,26 @@ class WorkspaceStateStore {
|
||||
health: "healthy",
|
||||
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 = (
|
||||
@@ -509,11 +563,20 @@ export function useWorkspaceStateStore(
|
||||
await store.restart()
|
||||
}, [store])
|
||||
|
||||
const subscribeEnvelopes = useCallback(
|
||||
(listener: WorkspaceEnvelopeListener) => {
|
||||
if (!store) return () => {}
|
||||
return store.subscribeEnvelopes(listener)
|
||||
},
|
||||
[store]
|
||||
)
|
||||
|
||||
if (!rootPath) {
|
||||
return {
|
||||
...EMPTY_STATE,
|
||||
requestResync,
|
||||
restart,
|
||||
subscribeEnvelopes,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -521,5 +584,6 @@ export function useWorkspaceStateStore(
|
||||
...snapshot,
|
||||
requestResync,
|
||||
restart,
|
||||
subscribeEnvelopes,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -879,6 +879,7 @@ export interface WorkspaceDeltaEnvelope {
|
||||
kind: "fs_delta" | "git_delta" | "meta" | "resync_hint" | string
|
||||
payload: WorkspaceDelta[]
|
||||
requires_resync: boolean
|
||||
changed_paths?: string[]
|
||||
}
|
||||
|
||||
export interface WorkspaceStateEvent {
|
||||
@@ -888,6 +889,7 @@ export interface WorkspaceStateEvent {
|
||||
kind: "fs_delta" | "git_delta" | "meta" | "resync_hint" | string
|
||||
payload: WorkspaceDelta[]
|
||||
requires_resync: boolean
|
||||
changed_paths?: string[]
|
||||
}
|
||||
|
||||
export interface WorkspaceSnapshotResponse {
|
||||
|
||||
Reference in New Issue
Block a user