From 7a1e9b7465113a5b9cb300f09ce25d8be5c774b7 Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Sat, 4 Apr 2026 13:39:28 -0500 Subject: [PATCH] feat: auto-refresh file tree on filesystem changes Add a server-side fs.watch watcher that monitors the workspace directory and pushes debounced change events via WebSocket, so the file tree automatically reflects new, deleted, or modified files without manual refresh. Co-Authored-By: Claude Opus 4.6 --- apps/server/src/wsServer.ts | 50 ++++++++++++++++++- apps/web/src/components/WorkspaceFileTree.tsx | 17 ++++++- apps/web/src/wsNativeApi.ts | 20 ++++++++ packages/contracts/src/ipc.ts | 4 ++ packages/contracts/src/project.ts | 5 ++ packages/contracts/src/ws.ts | 9 ++++ 6 files changed, 103 insertions(+), 2 deletions(-) diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index 367fc5d53..5e045f572 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -6,6 +6,7 @@ * * @module Server */ +import fs from "node:fs"; import http, { type IncomingMessage } from "node:http"; import type { Duplex } from "node:stream"; @@ -53,7 +54,7 @@ import { pickFolderNative } from "./nativeFolderPicker.ts"; import { GitManager } from "./git/Services/GitManager.ts"; import { TerminalManager } from "./terminal/Services/Manager.ts"; import { Keybindings } from "./keybindings"; -import { listWorkspaceDirectory, searchWorkspaceEntries } from "./workspaceEntries"; +import { clearWorkspaceIndexCache, listWorkspaceDirectory, searchWorkspaceEntries } from "./workspaceEntries"; import { OrchestrationEngineService } from "./orchestration/Services/OrchestrationEngine"; import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery"; import { OrchestrationReactor } from "./orchestration/Services/OrchestrationReactor"; @@ -835,6 +836,53 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< yield* Effect.addFinalizer(() => Effect.sync(() => unsubscribeTerminalEvents())); yield* readiness.markTerminalSubscriptionsReady; + // ── File tree watcher ────────────────────────────────────────────── + // Watch the workspace directory for file system changes and push + // notifications so the client can refresh the file tree automatically. + const FILE_TREE_DEBOUNCE_MS = 300; + const IGNORED_WATCHER_DIRS = new Set([ + ".git", + "node_modules", + ".next", + ".turbo", + "dist", + "build", + "out", + ".cache", + ]); + + let fileTreeDebounceTimer: ReturnType | null = null; + + const fileTreeWatcher = fs.watch( + cwd, + { recursive: true }, + (_eventType, filename) => { + if (!filename) return; + + // Ignore changes inside noisy directories + const normalized = String(filename).replaceAll("\\", "/"); + const firstSegment = normalized.split("/")[0]; + if (firstSegment && IGNORED_WATCHER_DIRS.has(firstSegment)) return; + + // Debounce rapid consecutive changes into a single push + if (fileTreeDebounceTimer) clearTimeout(fileTreeDebounceTimer); + fileTreeDebounceTimer = setTimeout(() => { + fileTreeDebounceTimer = null; + clearWorkspaceIndexCache(cwd); + void Effect.runPromise( + pushBus.publishAll(WS_CHANNELS.projectFileTreeChanged, { cwd }), + ); + }, FILE_TREE_DEBOUNCE_MS); + }, + ); + + yield* Effect.addFinalizer(() => + Effect.sync(() => { + fileTreeWatcher.close(); + if (fileTreeDebounceTimer) clearTimeout(fileTreeDebounceTimer); + }), + ); + yield* NodeHttpServer.make(() => httpServer, listenOptions).pipe( Effect.mapError((cause) => new ServerLifecycleError({ operation: "httpServerListen", cause })), ); diff --git a/apps/web/src/components/WorkspaceFileTree.tsx b/apps/web/src/components/WorkspaceFileTree.tsx index b464be047..7bd28839f 100644 --- a/apps/web/src/components/WorkspaceFileTree.tsx +++ b/apps/web/src/components/WorkspaceFileTree.tsx @@ -8,12 +8,13 @@ import { SlidersHorizontalIcon, TriangleAlertIcon, } from "lucide-react"; -import { type MouseEvent, memo, useCallback, useDeferredValue, useState } from "react"; +import { type MouseEvent, memo, useCallback, useDeferredValue, useEffect, useState } from "react"; import { openInPreferredEditor } from "~/editorPreferences"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; import { useFileViewNavigation } from "~/hooks/useFileViewNavigation"; import { projectListDirectoryQueryOptions, + projectQueryKeys, projectSearchEntriesQueryOptions, } from "~/lib/projectReactQuery"; import { cn, isMacPlatform } from "~/lib/utils"; @@ -229,6 +230,20 @@ export const WorkspaceFileTree = memo(function WorkspaceFileTree(props: { [props.cwd, queryClient], ); + // Subscribe to server-pushed file tree change events so the tree auto-refreshes + // when files are created, deleted, or modified outside of the app (e.g. by git, + // terminal commands, or external editors). + useEffect(() => { + const api = readNativeApi(); + if (!api?.projects.onFileTreeChanged) return; + + const unsubscribe = api.projects.onFileTreeChanged(() => { + void queryClient.invalidateQueries({ queryKey: projectQueryKeys.all }); + }); + + return unsubscribe; + }, [queryClient]); + return (
diff --git a/apps/web/src/wsNativeApi.ts b/apps/web/src/wsNativeApi.ts index 9015e40b2..b9edb2e5b 100644 --- a/apps/web/src/wsNativeApi.ts +++ b/apps/web/src/wsNativeApi.ts @@ -4,6 +4,7 @@ import { ORCHESTRATION_WS_METHODS, type ContextMenuItem, type NativeApi, + type ProjectFileTreeChangedPayload, type PrReviewRepoConfigUpdatedPayload, type PrReviewSyncUpdatedPayload, ServerConfigUpdatedPayload, @@ -24,6 +25,9 @@ const prReviewSyncUpdatedListeners = new Set<(payload: PrReviewSyncUpdatedPayloa const prReviewRepoConfigUpdatedListeners = new Set< (payload: PrReviewRepoConfigUpdatedPayload) => void >(); +const projectFileTreeChangedListeners = new Set< + (payload: ProjectFileTreeChangedPayload) => void +>(); const transportStateListeners = new Set<(state: TransportState) => void>(); /** @@ -157,6 +161,16 @@ export function createWsNativeApi(): NativeApi { } } }); + transport.subscribe(WS_CHANNELS.projectFileTreeChanged, (message) => { + const payload = message.data; + for (const listener of projectFileTreeChangedListeners) { + try { + listener(payload); + } catch { + // Swallow listener errors + } + } + }); const api: NativeApi = { dialogs: { @@ -194,6 +208,12 @@ export function createWsNativeApi(): NativeApi { writeFile: (input) => transport.request(WS_METHODS.projectsWriteFile, input), readFile: (input) => transport.request(WS_METHODS.projectsReadFile, input), deleteEntry: (input) => transport.request(WS_METHODS.projectsDeleteEntry, input), + onFileTreeChanged: (callback) => { + projectFileTreeChangedListeners.add(callback); + return () => { + projectFileTreeChangedListeners.delete(callback); + }; + }, }, shell: { openInEditor: (cwd, editor) => diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 93f3fcff1..95af215e4 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -25,6 +25,7 @@ import type { } from "./git"; import type { ProjectDeleteEntryInput, + ProjectFileTreeChangedPayload, ProjectListDirectoryInput, ProjectListDirectoryResult, ProjectReadFileInput, @@ -315,6 +316,9 @@ export interface NativeApi { writeFile: (input: ProjectWriteFileInput) => Promise; readFile: (input: ProjectReadFileInput) => Promise; deleteEntry: (input: ProjectDeleteEntryInput) => Promise; + onFileTreeChanged: ( + callback: (payload: ProjectFileTreeChangedPayload) => void, + ) => () => void; }; shell: { openInEditor: (cwd: string, editor: EditorId) => Promise; diff --git a/packages/contracts/src/project.ts b/packages/contracts/src/project.ts index 99f7c0f07..a1544b4a7 100644 --- a/packages/contracts/src/project.ts +++ b/packages/contracts/src/project.ts @@ -89,3 +89,8 @@ export const ProjectDeleteEntryInput = Schema.Struct({ relativePath: TrimmedNonEmptyString.check(Schema.isMaxLength(PROJECT_DIRECTORY_PATH_MAX_LENGTH)), }); export type ProjectDeleteEntryInput = typeof ProjectDeleteEntryInput.Type; + +export const ProjectFileTreeChangedPayload = Schema.Struct({ + cwd: TrimmedNonEmptyString, +}); +export type ProjectFileTreeChangedPayload = typeof ProjectFileTreeChangedPayload.Type; diff --git a/packages/contracts/src/ws.ts b/packages/contracts/src/ws.ts index 3017f1c8c..10039aeb4 100644 --- a/packages/contracts/src/ws.ts +++ b/packages/contracts/src/ws.ts @@ -64,6 +64,7 @@ import { ProjectSearchEntriesInput, ProjectWriteFileInput, } from "./project"; +import { ProjectFileTreeChangedPayload } from "./project"; import { OpenInEditorInput, OpenPathInput } from "./editor"; import { GeneratePairingLinkInput, @@ -176,6 +177,7 @@ export const WS_CHANNELS = { terminalEvent: "terminal.event", serverWelcome: "server.welcome", serverConfigUpdated: "server.configUpdated", + projectFileTreeChanged: "project.fileTreeChanged", } as const; // -- Tagged Union of all request body schemas ───────────────────────── @@ -323,6 +325,7 @@ export interface WsPushPayloadByChannel { readonly [WS_CHANNELS.prReviewSyncUpdated]: typeof PrReviewSyncUpdatedPayload.Type; readonly [WS_CHANNELS.prReviewRepoConfigUpdated]: typeof PrReviewRepoConfigUpdatedPayload.Type; readonly [WS_CHANNELS.terminalEvent]: typeof TerminalEvent.Type; + readonly [WS_CHANNELS.projectFileTreeChanged]: typeof ProjectFileTreeChangedPayload.Type; readonly [ORCHESTRATION_WS_CHANNELS.domainEvent]: OrchestrationEvent; } @@ -358,6 +361,10 @@ export const WsPushPrReviewRepoConfigUpdated = makeWsPushSchema( PrReviewRepoConfigUpdatedPayload, ); export const WsPushTerminalEvent = makeWsPushSchema(WS_CHANNELS.terminalEvent, TerminalEvent); +export const WsPushProjectFileTreeChanged = makeWsPushSchema( + WS_CHANNELS.projectFileTreeChanged, + ProjectFileTreeChangedPayload, +); export const WsPushOrchestrationDomainEvent = makeWsPushSchema( ORCHESTRATION_WS_CHANNELS.domainEvent, OrchestrationEvent, @@ -370,6 +377,7 @@ export const WsPushChannelSchema = Schema.Literals([ WS_CHANNELS.serverWelcome, WS_CHANNELS.serverConfigUpdated, WS_CHANNELS.terminalEvent, + WS_CHANNELS.projectFileTreeChanged, ORCHESTRATION_WS_CHANNELS.domainEvent, ]); export type WsPushChannelSchema = typeof WsPushChannelSchema.Type; @@ -381,6 +389,7 @@ export const WsPush = Schema.Union([ WsPushPrReviewSyncUpdated, WsPushPrReviewRepoConfigUpdated, WsPushTerminalEvent, + WsPushProjectFileTreeChanged, WsPushOrchestrationDomainEvent, ]); export type WsPush = typeof WsPush.Type;