feat(watcher): forward FS events as workspace/didChangeWatchedFiles
LSP servers maintain their own workspace index built at initialize time and rely on the client to push file-system events. Previously the daemon only synced the single file being queried, so externally created/changed files (codegen, build scripts, git checkout, the agent's own writes from the perspective of other open files) left the server's index stale until manual /lsp-destroy. Each ClientEntry now lazily owns a WorkspaceWatcher (chokidar + picomatch) that translates FS events into workspace/didChangeWatchedFiles batches. Patterns come from the server via client/registerCapability (no speculative watching). Ignores layer a tiny baseline (.git, .DS_Store) over the repo's root .gitignore, with a fallback list for non-git workspaces. Events debounce 50ms quiet / 500ms max wait. Notable: gopls registers absolute-path globs (/abs/root/**/*.go) rather than relative ones, so compileWatchers() matches each event against both relative and absolute path forms. Caught by the integration test; unit regression test added. Rollback: PI_LSP_DISABLE_WATCHERS=1 disables all watcher creation. - src/client.ts: honor register/unregisterCapability for workspace/didChangeWatchedFiles; advertise dynamicRegistration; expose getFileWatchers/onWatchersChanged/sendNotification - src/watcher.ts: new WorkspaceWatcher with layered ignores, debounce+batch, Created+Deleted coalescing, dual-form glob matching - src/daemon.ts: per-entry watcher lifecycle, PI_LSP_DISABLE_WATCHERS, LSP_DEBUG-gated pattern/event logging - test/unit/watcher.test.ts: 11 tests against real chokidar + temp dir - test/integration/watcher-gopls.test.ts: end-to-end against gopls - AGENTS.md: new "Workspace File Watching" section - flake.nix: add go (required by gopls integration test)
This commit is contained in:
@@ -8,6 +8,7 @@ import * as path from "node:path";
|
||||
import { LspClient } from "./client.ts";
|
||||
import { findRoot, findServerById, pathToUri } from "./root.ts";
|
||||
import type { ServerConfig } from "./types.ts";
|
||||
import { WorkspaceWatcher, type FileEvent } from "./watcher.ts";
|
||||
import {
|
||||
logPath,
|
||||
socketPath,
|
||||
@@ -37,6 +38,11 @@ interface ClientEntry {
|
||||
idleTimer: NodeJS.Timeout | null;
|
||||
ttlMs: number;
|
||||
lastUsed: number;
|
||||
// watcher: Lazy - created on first registerCapability for watched files,
|
||||
// disposed on eviction. Null if the server never registered any watchers
|
||||
// (or if PI_LSP_DISABLE_WATCHERS is set).
|
||||
watcher: WorkspaceWatcher | null;
|
||||
unsubscribeWatchers: (() => void) | null;
|
||||
}
|
||||
|
||||
const entries = new Map<string, ClientEntry>();
|
||||
@@ -88,6 +94,8 @@ async function getOrCreateEntry(
|
||||
idleTimer: null,
|
||||
ttlMs,
|
||||
lastUsed: Date.now(),
|
||||
watcher: null,
|
||||
unsubscribeWatchers: null,
|
||||
};
|
||||
entries.set(key, entry);
|
||||
try {
|
||||
@@ -96,10 +104,52 @@ async function getOrCreateEntry(
|
||||
entries.delete(key);
|
||||
throw err;
|
||||
}
|
||||
attachWatcher(entry);
|
||||
bumpIdle(entry);
|
||||
return entry;
|
||||
}
|
||||
|
||||
// Attach Watcher - Subscribes to the client's watcher-registration events.
|
||||
// The first non-empty registration lazily creates the WorkspaceWatcher;
|
||||
// subsequent register/unregister calls update its pattern set in place.
|
||||
// Honors PI_LSP_DISABLE_WATCHERS for emergency rollback.
|
||||
function attachWatcher(entry: ClientEntry): void {
|
||||
if (process.env.PI_LSP_DISABLE_WATCHERS) return;
|
||||
const sync = () => {
|
||||
const patterns = entry.client.getFileWatchers();
|
||||
if (patterns.length === 0 && !entry.watcher) return;
|
||||
if (!entry.watcher) {
|
||||
entry.watcher = new WorkspaceWatcher(entry.rootDir, (events) =>
|
||||
forwardEvents(entry, events),
|
||||
);
|
||||
log(`watcher`, entry.server.id, entry.rootDir, `patterns=${patterns.length}`);
|
||||
}
|
||||
if (process.env.LSP_DEBUG) {
|
||||
log(`watcher patterns`, entry.server.id, JSON.stringify(patterns));
|
||||
}
|
||||
entry.watcher.setPatterns(patterns);
|
||||
};
|
||||
entry.unsubscribeWatchers = entry.client.onWatchersChanged(sync);
|
||||
// Initial Sync - Server may have already sent registerCapability during
|
||||
// initialize before we subscribed.
|
||||
sync();
|
||||
}
|
||||
|
||||
// Forward Events - Sends a batched workspace/didChangeWatchedFiles to the
|
||||
// server. We catch errors so a downed transport doesn't crash the daemon.
|
||||
function forwardEvents(entry: ClientEntry, events: FileEvent[]): void {
|
||||
try {
|
||||
if (process.env.LSP_DEBUG) {
|
||||
log(`watcher fire`, entry.server.id, JSON.stringify(events));
|
||||
}
|
||||
entry.client.sendNotification("workspace/didChangeWatchedFiles", {
|
||||
changes: events,
|
||||
});
|
||||
} catch (err) {
|
||||
log(`watcher send failed`, entry.server.id, (err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
// Bump Idle - Resets the idle eviction timer. Called on every request that
|
||||
// touches the entry. We log evictions so the daemon's behavior is visible.
|
||||
function bumpIdle(entry: ClientEntry) {
|
||||
@@ -113,6 +163,8 @@ function evict(entry: ClientEntry, reason: string) {
|
||||
log(`evict`, entry.key, reason);
|
||||
entries.delete(entry.key);
|
||||
if (entry.idleTimer) clearTimeout(entry.idleTimer);
|
||||
if (entry.unsubscribeWatchers) entry.unsubscribeWatchers();
|
||||
void entry.watcher?.dispose();
|
||||
void entry.client.dispose();
|
||||
// Auto Shutdown - If this was the last entry, there's nothing left to
|
||||
// manage. Tear down the daemon so it doesn't sit idle forever.
|
||||
@@ -292,6 +344,8 @@ function shutdownDaemon(reason: string) {
|
||||
if (server) server.close();
|
||||
for (const entry of entries.values()) {
|
||||
if (entry.idleTimer) clearTimeout(entry.idleTimer);
|
||||
if (entry.unsubscribeWatchers) entry.unsubscribeWatchers();
|
||||
void entry.watcher?.dispose();
|
||||
void entry.client.dispose();
|
||||
}
|
||||
entries.clear();
|
||||
|
||||
Reference in New Issue
Block a user