Skip to content

Commit 40210fd

Browse files
authored
Add live annotate file tree workspace status (#931)
* feat(annotate): add live file tree workspace status * fix(annotate): guard dirty feedback and rename stats * fix(annotate): tighten live file tree status * fix(annotate): surface deleted file browser roots * fix(annotate): normalize file tree status paths * fix(annotate): avoid optional git locks for workspace status * fix(annotate): tighten workspace status git metadata * fix(annotate): refresh file tree after reconnect * fix(pi): expose file browser stream route
1 parent b67cb09 commit 40210fd

28 files changed

Lines changed: 1946 additions & 275 deletions

apps/pi-extension/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
"dependencies": {
4242
"@joplin/turndown-plugin-gfm": "^1.0.64",
4343
"@pierre/diffs": "1.2.8",
44+
"chokidar": "^5.0.0",
4445
"parse5": "^7.3.0",
4546
"turndown": "^7.2.4"
4647
},
Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
import chokidar, { type FSWatcher } from "chokidar";
2+
import { existsSync, statSync } from "node:fs";
3+
import type { IncomingMessage, ServerResponse } from "node:http";
4+
import { isAbsolute, relative } from "node:path";
5+
6+
import { isFileBrowserExcludedPath } from "../generated/reference-common.js";
7+
import { resolveUserPath } from "../generated/resolve-file.js";
8+
import { getGitMetadataWatchPaths } from "../generated/workspace-status.js";
9+
import { json } from "./helpers.js";
10+
11+
interface FileBrowserChangeEvent {
12+
type: "ready" | "changed";
13+
dirPath: string;
14+
reason: "files" | "git" | "initial";
15+
timestamp: number;
16+
}
17+
18+
interface WatchEntry {
19+
dirPath: string;
20+
subscribers: Map<ServerResponse, string>;
21+
contentWatcher: FSWatcher | null;
22+
gitWatcher: FSWatcher | null;
23+
debounceTimer: ReturnType<typeof setTimeout> | null;
24+
}
25+
26+
const HEARTBEAT_MS = 30_000;
27+
const DEBOUNCE_MS = 180;
28+
const watchers = new Map<string, WatchEntry>();
29+
30+
function serialize(event: FileBrowserChangeEvent): string {
31+
return `data: ${JSON.stringify(event)}\n\n`;
32+
}
33+
34+
function isExcludedPath(path: string, root: string): boolean {
35+
const rel = relative(root, path).replace(/\\/g, "/");
36+
if (!rel || rel.startsWith("..") || isAbsolute(rel)) return false;
37+
return isFileBrowserExcludedPath(rel);
38+
}
39+
40+
function isValidDirectory(dirPath: string): boolean {
41+
try {
42+
return existsSync(dirPath) && statSync(dirPath).isDirectory();
43+
} catch {
44+
return false;
45+
}
46+
}
47+
48+
function broadcast(entry: WatchEntry, reason: FileBrowserChangeEvent["reason"]): void {
49+
for (const [res, clientDirPath] of entry.subscribers) {
50+
const payload = serialize({
51+
type: "changed",
52+
dirPath: clientDirPath,
53+
reason,
54+
timestamp: Date.now(),
55+
});
56+
try {
57+
res.write(payload);
58+
} catch {
59+
entry.subscribers.delete(res);
60+
}
61+
}
62+
}
63+
64+
function scheduleBroadcast(entry: WatchEntry, reason: "files" | "git"): void {
65+
if (entry.debounceTimer) clearTimeout(entry.debounceTimer);
66+
entry.debounceTimer = setTimeout(() => {
67+
entry.debounceTimer = null;
68+
broadcast(entry, reason);
69+
}, DEBOUNCE_MS);
70+
}
71+
72+
function closeWatcher(entry: WatchEntry): void {
73+
if (entry.debounceTimer) clearTimeout(entry.debounceTimer);
74+
void entry.contentWatcher?.close();
75+
void entry.gitWatcher?.close();
76+
watchers.delete(entry.dirPath);
77+
}
78+
79+
function releaseSubscriber(entry: WatchEntry, res: ServerResponse): void {
80+
entry.subscribers.delete(res);
81+
if (entry.subscribers.size === 0) closeWatcher(entry);
82+
}
83+
84+
function ensureWatcher(dirPath: string): WatchEntry {
85+
const existing = watchers.get(dirPath);
86+
if (existing) return existing;
87+
88+
const entry: WatchEntry = {
89+
dirPath,
90+
subscribers: new Map(),
91+
contentWatcher: null,
92+
gitWatcher: null,
93+
debounceTimer: null,
94+
};
95+
96+
entry.contentWatcher = chokidar.watch(dirPath, {
97+
ignoreInitial: true,
98+
persistent: true,
99+
ignored: (path) => isExcludedPath(path, dirPath),
100+
awaitWriteFinish: {
101+
stabilityThreshold: 120,
102+
pollInterval: 30,
103+
},
104+
});
105+
entry.contentWatcher.on("all", () => scheduleBroadcast(entry, "files"));
106+
entry.contentWatcher.on("error", () => scheduleBroadcast(entry, "files"));
107+
108+
const gitWatchPaths = getGitMetadataWatchPaths(dirPath);
109+
if (gitWatchPaths.length > 0) {
110+
entry.gitWatcher = chokidar.watch(gitWatchPaths, {
111+
ignoreInitial: true,
112+
persistent: true,
113+
awaitWriteFinish: {
114+
stabilityThreshold: 80,
115+
pollInterval: 30,
116+
},
117+
});
118+
entry.gitWatcher.on("all", () => scheduleBroadcast(entry, "git"));
119+
entry.gitWatcher.on("error", () => scheduleBroadcast(entry, "git"));
120+
}
121+
122+
watchers.set(dirPath, entry);
123+
return entry;
124+
}
125+
126+
export function handleFileBrowserStreamRequest(req: IncomingMessage, res: ServerResponse, url: URL): boolean {
127+
if (url.pathname !== "/api/reference/files/stream" || req.method !== "GET") return false;
128+
129+
const rawDirPaths = url.searchParams.getAll("dirPath");
130+
if (rawDirPaths.length === 0) {
131+
json(res, { error: "Missing dirPath parameter" }, 400);
132+
return true;
133+
}
134+
135+
const dirPaths: string[] = [];
136+
const clientDirPaths: string[] = [];
137+
for (const rawDirPath of rawDirPaths) {
138+
const dirPath = resolveUserPath(rawDirPath);
139+
if (!isValidDirectory(dirPath)) {
140+
json(res, { error: "Invalid directory path" }, 400);
141+
return true;
142+
}
143+
if (!dirPaths.includes(dirPath)) {
144+
dirPaths.push(dirPath);
145+
clientDirPaths.push(rawDirPath);
146+
}
147+
}
148+
149+
const entries = dirPaths.map((dirPath) => ensureWatcher(dirPath));
150+
res.writeHead(200, {
151+
"Content-Type": "text/event-stream",
152+
"Cache-Control": "no-cache",
153+
Connection: "keep-alive",
154+
});
155+
res.setTimeout(0);
156+
for (let i = 0; i < entries.length; i++) {
157+
const entry = entries[i]!;
158+
const clientDirPath = clientDirPaths[i] ?? entry.dirPath;
159+
res.write(serialize({
160+
type: "ready",
161+
dirPath: clientDirPath,
162+
reason: "initial",
163+
timestamp: Date.now(),
164+
}));
165+
entry.subscribers.set(res, clientDirPath);
166+
}
167+
168+
const heartbeat = setInterval(() => {
169+
try {
170+
res.write(": heartbeat\n\n");
171+
} catch {
172+
for (const entry of entries) releaseSubscriber(entry, res);
173+
clearInterval(heartbeat);
174+
}
175+
}, HEARTBEAT_MS);
176+
177+
res.on("close", () => {
178+
clearInterval(heartbeat);
179+
for (const entry of entries) releaseSubscriber(entry, res);
180+
});
181+
return true;
182+
}

apps/pi-extension/server/reference.ts

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,14 @@ import {
2121
type VaultNode,
2222
buildFileTree,
2323
FILE_BROWSER_EXCLUDED,
24+
isFileBrowserExcludedPath,
2425
} from "../generated/reference-common.js";
26+
import {
27+
filterWorkspaceStatusForDirectory,
28+
getWorkspaceStatusForDirectory,
29+
getWorkspaceStatusRelativePaths,
30+
type WorkspaceFileChange,
31+
} from "../generated/workspace-status.js";
2532
import { detectObsidianVaults } from "../generated/integrations-common.js";
2633
import {
2734
isAbsoluteUserPath,
@@ -177,7 +184,9 @@ function jsonDoc(res: Res, data: Record<string, unknown>, options?: HandleDocOpt
177184
}
178185

179186
/** Recursively walk a directory collecting files by extension, skipping ignored dirs. */
180-
function walkMarkdownFiles(dir: string, root: string, results: string[], extensions: RegExp = /\.(mdx?|txt|html?)$/i): void {
187+
const FILE_BROWSER_EXTENSIONS = /\.(mdx?|txt|html?)$/i;
188+
189+
function walkMarkdownFiles(dir: string, root: string, results: string[], extensions: RegExp = FILE_BROWSER_EXTENSIONS): void {
181190
let entries: Dirent[];
182191
try {
183192
entries = readdirSync(dir, { withFileTypes: true }) as Dirent[];
@@ -192,11 +201,16 @@ function walkMarkdownFiles(dir: string, root: string, results: string[], extensi
192201
const relative = join(dir, entry.name)
193202
.slice(root.length + 1)
194203
.replace(/\\/g, "/");
204+
if (isFileBrowserExcludedPath(relative)) continue;
195205
results.push(relative);
196206
}
197207
}
198208
}
199209

210+
function includeWorkspaceFile(relativePath: string, _change: WorkspaceFileChange): boolean {
211+
return FILE_BROWSER_EXTENSIONS.test(relativePath) && !isFileBrowserExcludedPath(relativePath);
212+
}
213+
200214
/** Serve a linked markdown document. Uses shared resolveMarkdownFile for parity with Bun server. */
201215
export async function handleDocRequest(res: Res, url: URL, options: HandleDocOptions = {}): Promise<void> {
202216
const requestedPath = url.searchParams.get("path");
@@ -506,10 +520,15 @@ export function handleFileBrowserRequest(res: Res, url: URL): void {
506520
return;
507521
}
508522
try {
509-
const files: string[] = [];
510-
walkMarkdownFiles(resolvedDir, resolvedDir, files);
511-
files.sort();
512-
json(res, { tree: buildFileTree(files) });
523+
const files = new Set<string>();
524+
const diskFiles: string[] = [];
525+
walkMarkdownFiles(resolvedDir, resolvedDir, diskFiles);
526+
for (const file of diskFiles) files.add(file);
527+
const workspaceStatus = filterWorkspaceStatusForDirectory(getWorkspaceStatusForDirectory(resolvedDir), resolvedDir, includeWorkspaceFile);
528+
for (const file of getWorkspaceStatusRelativePaths(workspaceStatus, resolvedDir, includeWorkspaceFile)) {
529+
files.add(file);
530+
}
531+
json(res, { tree: buildFileTree([...files].sort()), workspaceStatus });
513532
} catch {
514533
json(res, { error: "Failed to list directory files" }, 500);
515534
}

apps/pi-extension/server/serverAnnotate.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import {
3434
handleObsidianFilesRequest,
3535
handleObsidianDocRequest,
3636
} from "./reference.js";
37+
import { handleFileBrowserStreamRequest } from "./file-browser-watch.js";
3738
import { warmFileListCache } from "../generated/resolve-file.js";
3839
import { createExternalAnnotationHandler } from "./external-annotations.js";
3940
import {
@@ -411,6 +412,9 @@ export async function startAnnotateServer(options: {
411412
handleObsidianDocRequest(res, url);
412413
} else if (url.pathname === "/api/reference/files" && req.method === "GET") {
413414
handleFileBrowserRequest(res, url);
415+
} else if (url.pathname === "/api/reference/files/stream" && req.method === "GET") {
416+
handleFileBrowserStreamRequest(req, res, url);
417+
return;
414418
} else if (url.pathname === "/favicon.svg") {
415419
handleFavicon(res);
416420
} else if (url.pathname === "/api/exit" && req.method === "POST") {

apps/pi-extension/server/serverPlan.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ import {
5050
handleObsidianFilesRequest,
5151
handleObsidianVaultsRequest,
5252
} from "./reference.js";
53+
import { handleFileBrowserStreamRequest } from "./file-browser-watch.js";
5354
import { warmFileListCache } from "../generated/resolve-file.js";
5455

5556
export interface PlanReviewDecision {
@@ -284,6 +285,9 @@ export async function startPlanReviewServer(options: {
284285
handleObsidianDocRequest(res, url);
285286
} else if (url.pathname === "/api/reference/files" && req.method === "GET") {
286287
handleFileBrowserRequest(res, url);
288+
} else if (url.pathname === "/api/reference/files/stream" && req.method === "GET") {
289+
handleFileBrowserStreamRequest(req, res, url);
290+
return;
287291
} else if (
288292
url.pathname === "/api/plan/vscode-diff" &&
289293
req.method === "POST"

apps/pi-extension/vendor.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ cd "$(dirname "$0")"
77
rm -rf generated
88
mkdir -p generated generated/ai/providers
99

10-
for f in feedback-templates prompts review-core diff-paths cli-pagination jj-core vcs-core review-args storage draft project pr-types pr-provider pr-stack pr-github pr-gitlab checklist integrations-common repo reference-common favicon code-file resolve-file config external-annotation agent-jobs worktree worktree-pool html-to-markdown html-assets html-assets-node url-to-markdown tour annotate-args at-reference review-workspace-node review-workspace pfm-reminder improvement-hooks code-nav data-dir semantic-diff-types semantic-diff source-save source-save-node; do
10+
for f in feedback-templates prompts review-core diff-paths cli-pagination jj-core vcs-core review-args storage draft project pr-types pr-provider pr-stack pr-github pr-gitlab checklist integrations-common repo reference-common favicon code-file resolve-file config external-annotation agent-jobs worktree worktree-pool html-to-markdown html-assets html-assets-node url-to-markdown tour annotate-args at-reference review-workspace-node review-workspace pfm-reminder improvement-hooks code-nav data-dir semantic-diff-types semantic-diff source-save source-save-node workspace-status; do
1111
src="../../packages/shared/$f.ts"
1212
printf '// @generated — DO NOT EDIT. Source: packages/shared/%s.ts\n' "$f" | cat - "$src" > "generated/$f.ts"
1313
done

bun.lock

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)