Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 49 additions & 1 deletion apps/server/src/wsServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
*
* @module Server
*/
import fs from "node:fs";
import http, { type IncomingMessage } from "node:http";
import type { Duplex } from "node:stream";

Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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<typeof setTimeout> | 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 })),
);
Expand Down
17 changes: 16 additions & 1 deletion apps/web/src/components/WorkspaceFileTree.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 (
<div className={cn("space-y-2", props.className)}>
<div className="space-y-1.5 px-2">
Expand Down
20 changes: 20 additions & 0 deletions apps/web/src/wsNativeApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
ORCHESTRATION_WS_METHODS,
type ContextMenuItem,
type NativeApi,
type ProjectFileTreeChangedPayload,
type PrReviewRepoConfigUpdatedPayload,
type PrReviewSyncUpdatedPayload,
ServerConfigUpdatedPayload,
Expand All @@ -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>();

/**
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -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) =>
Expand Down
4 changes: 4 additions & 0 deletions packages/contracts/src/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import type {
} from "./git";
import type {
ProjectDeleteEntryInput,
ProjectFileTreeChangedPayload,
ProjectListDirectoryInput,
ProjectListDirectoryResult,
ProjectReadFileInput,
Expand Down Expand Up @@ -315,6 +316,9 @@ export interface NativeApi {
writeFile: (input: ProjectWriteFileInput) => Promise<ProjectWriteFileResult>;
readFile: (input: ProjectReadFileInput) => Promise<ProjectReadFileResult>;
deleteEntry: (input: ProjectDeleteEntryInput) => Promise<void>;
onFileTreeChanged: (
callback: (payload: ProjectFileTreeChangedPayload) => void,
) => () => void;
};
shell: {
openInEditor: (cwd: string, editor: EditorId) => Promise<void>;
Expand Down
5 changes: 5 additions & 0 deletions packages/contracts/src/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
9 changes: 9 additions & 0 deletions packages/contracts/src/ws.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ import {
ProjectSearchEntriesInput,
ProjectWriteFileInput,
} from "./project";
import { ProjectFileTreeChangedPayload } from "./project";
import { OpenInEditorInput, OpenPathInput } from "./editor";
import {
GeneratePairingLinkInput,
Expand Down Expand Up @@ -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 ─────────────────────────
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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,
Expand All @@ -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;
Expand All @@ -381,6 +389,7 @@ export const WsPush = Schema.Union([
WsPushPrReviewSyncUpdated,
WsPushPrReviewRepoConfigUpdated,
WsPushTerminalEvent,
WsPushProjectFileTreeChanged,
WsPushOrchestrationDomainEvent,
]);
export type WsPush = typeof WsPush.Type;
Expand Down
Loading