diff --git a/src-tauri/src/workspace_state/mod.rs b/src-tauri/src/workspace_state/mod.rs index b6ce74b..815cbc2 100644 --- a/src-tauri/src/workspace_state/mod.rs +++ b/src-tauri/src/workspace_state/mod.rs @@ -48,6 +48,8 @@ pub struct WorkspaceDeltaEnvelope { pub kind: String, pub payload: Vec, pub requires_resync: bool, + #[serde(default)] + pub changed_paths: Vec, } #[derive(Debug, Clone, Serialize)] @@ -58,6 +60,8 @@ pub struct WorkspaceStateEvent { pub kind: String, pub payload: Vec, pub requires_resync: bool, + #[serde(default)] + pub changed_paths: Vec, } #[derive(Debug, Clone, Serialize)] @@ -108,6 +112,7 @@ impl WorkspaceStateCore { kind: String, payload: Vec, requires_resync: bool, + changed_paths: Vec, ) -> 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)); diff --git a/src/components/layout/aux-panel-file-tree-tab.tsx b/src/components/layout/aux-panel-file-tree-tab.tsx index a35ed1e..beb5b1f 100644 --- a/src/components/layout/aux-panel-file-tree-tab.tsx +++ b/src/components/layout/aux-panel-file-tree-tab.tsx @@ -888,6 +888,11 @@ export function FileTreeTab() { new Map() ) const lazyLoadingDirPathsRef = useRef>(new Set()) + const loadDirectoryChildrenRef = useRef< + ((dirPath: string) => Promise) | null + >(null) + const expandedPathsRef = useRef>(new Set([FILE_TREE_ROOT_PATH])) + const workspaceTreeRef = useRef([]) const externalConflictSignatureByPathRef = useRef>( 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() 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() + 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() + + 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) { diff --git a/src/hooks/use-workspace-state-store.ts b/src/hooks/use-workspace-state-store.ts index 718293a..156e096 100644 --- a/src/hooks/use-workspace-state-store.ts +++ b/src/hooks/use-workspace-state-store.ts @@ -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 restart: () => Promise + 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() 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, } } diff --git a/src/lib/types.ts b/src/lib/types.ts index c127533..4f78fc0 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -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 {