Skip to content

Commit 1fcfad8

Browse files
authored
Polish live file tree refresh
1 parent 40210fd commit 1fcfad8

14 files changed

Lines changed: 507 additions & 88 deletions

apps/pi-extension/server.test.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
runGitDiff,
1414
runVcsDiff,
1515
stageFile,
16+
startPlanReviewServer,
1617
startReviewServer,
1718
unstageFile,
1819
} from "./server";
@@ -32,6 +33,28 @@ function makeTempDir(prefix: string): string {
3233
return dir;
3334
}
3435

36+
function writeTempFile(root: string, relativePath: string, content = "x"): string {
37+
const full = join(root, relativePath);
38+
mkdirSync(join(full, ".."), { recursive: true });
39+
writeFileSync(full, content, "utf-8");
40+
return full;
41+
}
42+
43+
interface PiTreeNode {
44+
path: string;
45+
type: "file" | "folder";
46+
children?: PiTreeNode[];
47+
}
48+
49+
function flattenTree(nodes: PiTreeNode[]): string[] {
50+
const paths: string[] = [];
51+
for (const node of nodes) {
52+
if (node.type === "file") paths.push(node.path);
53+
else paths.push(...flattenTree(node.children ?? []));
54+
}
55+
return paths;
56+
}
57+
3558
function childEnv(): NodeJS.ProcessEnv {
3659
return { ...process.env };
3760
}
@@ -964,3 +987,48 @@ describe("pi review server", () => {
964987
}
965988
}, 20_000);
966989
});
990+
991+
describe("pi plan server file browser", () => {
992+
test("filters excluded folders from tree and workspace status", async () => {
993+
const repo = makeTempDir("plannotator-pi-files-");
994+
const dataDir = makeTempDir("plannotator-pi-files-data-");
995+
process.env.PLANNOTATOR_DATA_DIR = dataDir;
996+
process.env.PLANNOTATOR_PORT = String(await reservePort());
997+
process.chdir(repo);
998+
999+
git(repo, ["init"]);
1000+
git(repo, ["branch", "-M", "main"]);
1001+
git(repo, ["config", "user.email", "pi-files@example.com"]);
1002+
git(repo, ["config", "user.name", "Pi Files"]);
1003+
writeTempFile(repo, "docs/visible.md", "visible\n");
1004+
writeTempFile(repo, "dist/generated.md", "before\n");
1005+
git(repo, ["add", "-A"]);
1006+
git(repo, ["commit", "-m", "initial"]);
1007+
1008+
writeTempFile(repo, "dist/generated.md", "after\n");
1009+
writeTempFile(repo, "packages/app/node_modules/pkg/readme.md", "hidden\n");
1010+
1011+
const server = await startPlanReviewServer({
1012+
plan: "# Plan",
1013+
origin: "pi",
1014+
htmlContent: "<!doctype html><html><body>plan</body></html>",
1015+
});
1016+
1017+
try {
1018+
const url = new URL(`${server.url}/api/reference/files`);
1019+
url.searchParams.set("dirPath", repo);
1020+
const response = await fetch(url);
1021+
const payload = await response.json() as {
1022+
tree: PiTreeNode[];
1023+
workspaceStatus: { totals: { files: number }; files: Record<string, unknown> };
1024+
};
1025+
1026+
expect(response.status).toBe(200);
1027+
expect(flattenTree(payload.tree)).toEqual(["docs/visible.md"]);
1028+
expect(payload.workspaceStatus.totals.files).toBe(0);
1029+
expect(payload.workspaceStatus.files).toEqual({});
1030+
} finally {
1031+
server.stop();
1032+
}
1033+
});
1034+
});
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { describe, expect, test } from "bun:test";
2+
import { tmpdir } from "node:os";
3+
import { dirname, join } from "node:path";
4+
import { isFileBrowserWatchIgnoredPath } from "./file-browser-watch";
5+
6+
describe("Pi file browser watcher", () => {
7+
test("ignores nested excluded folders for watcher paths", () => {
8+
const root = join(tmpdir(), "plannotator-pi-watch-root");
9+
10+
expect(isFileBrowserWatchIgnoredPath(join(root, "packages", "app", "node_modules"), root)).toBe(true);
11+
expect(isFileBrowserWatchIgnoredPath(join(root, "packages", "app", "node_modules", "pkg", "readme.md"), root)).toBe(true);
12+
expect(isFileBrowserWatchIgnoredPath(join(root, "docs", "dist", "generated.md"), root)).toBe(true);
13+
expect(isFileBrowserWatchIgnoredPath(join(root, "docs", "plan.md"), root)).toBe(false);
14+
expect(isFileBrowserWatchIgnoredPath(root, root)).toBe(false);
15+
expect(isFileBrowserWatchIgnoredPath(join(dirname(root), "outside", "node_modules"), root)).toBe(false);
16+
});
17+
});

apps/pi-extension/server/file-browser-watch.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ function serialize(event: FileBrowserChangeEvent): string {
3131
return `data: ${JSON.stringify(event)}\n\n`;
3232
}
3333

34-
function isExcludedPath(path: string, root: string): boolean {
34+
export function isFileBrowserWatchIgnoredPath(path: string, root: string): boolean {
3535
const rel = relative(root, path).replace(/\\/g, "/");
3636
if (!rel || rel.startsWith("..") || isAbsolute(rel)) return false;
3737
return isFileBrowserExcludedPath(rel);
@@ -73,7 +73,9 @@ function closeWatcher(entry: WatchEntry): void {
7373
if (entry.debounceTimer) clearTimeout(entry.debounceTimer);
7474
void entry.contentWatcher?.close();
7575
void entry.gitWatcher?.close();
76-
watchers.delete(entry.dirPath);
76+
if (watchers.get(entry.dirPath) === entry) {
77+
watchers.delete(entry.dirPath);
78+
}
7779
}
7880

7981
function releaseSubscriber(entry: WatchEntry, res: ServerResponse): void {
@@ -96,7 +98,7 @@ function ensureWatcher(dirPath: string): WatchEntry {
9698
entry.contentWatcher = chokidar.watch(dirPath, {
9799
ignoreInitial: true,
98100
persistent: true,
99-
ignored: (path) => isExcludedPath(path, dirPath),
101+
ignored: (path) => isFileBrowserWatchIgnoredPath(path, dirPath),
100102
awaitWriteFinish: {
101103
stabilityThreshold: 120,
102104
pollInterval: 30,

apps/pi-extension/server/reference.ts

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@ import type { IncomingMessage } from "node:http";
2020
import {
2121
type VaultNode,
2222
buildFileTree,
23-
FILE_BROWSER_EXCLUDED,
2423
isFileBrowserExcludedPath,
2524
} from "../generated/reference-common.js";
2625
import {
@@ -194,13 +193,13 @@ function walkMarkdownFiles(dir: string, root: string, results: string[], extensi
194193
return;
195194
}
196195
for (const entry of entries) {
196+
const relative = join(dir, entry.name)
197+
.slice(root.length + 1)
198+
.replace(/\\/g, "/");
197199
if (entry.isDirectory()) {
198-
if (FILE_BROWSER_EXCLUDED.includes(entry.name + "/")) continue;
200+
if (isFileBrowserExcludedPath(relative)) continue;
199201
walkMarkdownFiles(join(dir, entry.name), root, results, extensions);
200202
} else if (entry.isFile() && extensions.test(entry.name)) {
201-
const relative = join(dir, entry.name)
202-
.slice(root.length + 1)
203-
.replace(/\\/g, "/");
204203
if (isFileBrowserExcludedPath(relative)) continue;
205204
results.push(relative);
206205
}
@@ -508,7 +507,7 @@ export function handleObsidianDocRequest(res: Res, url: URL): void {
508507
}
509508
}
510509

511-
export function handleFileBrowserRequest(res: Res, url: URL): void {
510+
export async function handleFileBrowserRequest(res: Res, url: URL): Promise<void> {
512511
const dirPath = url.searchParams.get("dirPath");
513512
if (!dirPath) {
514513
json(res, { error: "Missing dirPath parameter" }, 400);
@@ -524,7 +523,7 @@ export function handleFileBrowserRequest(res: Res, url: URL): void {
524523
const diskFiles: string[] = [];
525524
walkMarkdownFiles(resolvedDir, resolvedDir, diskFiles);
526525
for (const file of diskFiles) files.add(file);
527-
const workspaceStatus = filterWorkspaceStatusForDirectory(getWorkspaceStatusForDirectory(resolvedDir), resolvedDir, includeWorkspaceFile);
526+
const workspaceStatus = filterWorkspaceStatusForDirectory(await getWorkspaceStatusForDirectory(resolvedDir), resolvedDir, includeWorkspaceFile);
528527
for (const file of getWorkspaceStatusRelativePaths(workspaceStatus, resolvedDir, includeWorkspaceFile)) {
529528
files.add(file);
530529
}

apps/pi-extension/server/serverAnnotate.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -411,7 +411,7 @@ export async function startAnnotateServer(options: {
411411
} else if (url.pathname === "/api/reference/obsidian/doc" && req.method === "GET") {
412412
handleObsidianDocRequest(res, url);
413413
} else if (url.pathname === "/api/reference/files" && req.method === "GET") {
414-
handleFileBrowserRequest(res, url);
414+
await handleFileBrowserRequest(res, url);
415415
} else if (url.pathname === "/api/reference/files/stream" && req.method === "GET") {
416416
handleFileBrowserStreamRequest(req, res, url);
417417
return;

apps/pi-extension/server/serverPlan.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -284,7 +284,7 @@ export async function startPlanReviewServer(options: {
284284
} else if (url.pathname === "/api/reference/obsidian/doc" && req.method === "GET") {
285285
handleObsidianDocRequest(res, url);
286286
} else if (url.pathname === "/api/reference/files" && req.method === "GET") {
287-
handleFileBrowserRequest(res, url);
287+
await handleFileBrowserRequest(res, url);
288288
} else if (url.pathname === "/api/reference/files/stream" && req.method === "GET") {
289289
handleFileBrowserStreamRequest(req, res, url);
290290
return;

packages/server/reference-handlers.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -172,7 +172,7 @@ describe("handleFileBrowserFiles", () => {
172172
git(root, "commit", "-m", "init");
173173

174174
writeTempFile(root, "dist/generated.md", "after\n");
175-
writeTempFile(root, "node_modules/pkg/readme.md", "hidden\n");
175+
writeTempFile(root, "packages/app/node_modules/pkg/readme.md", "hidden\n");
176176

177177
const url = new URL("http://localhost/api/reference/files");
178178
url.searchParams.set("dirPath", root);

packages/server/reference-handlers.ts

Lines changed: 25 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@
66
*/
77

88
import { existsSync, statSync } from "fs";
9-
import { resolve } from "path";
9+
import { readdir } from "fs/promises";
10+
import { join, relative, resolve } from "path";
1011
import { buildFileTree, isFileBrowserExcludedPath } from "@plannotator/shared/reference-common";
1112
import {
1213
filterWorkspaceStatusForDirectory,
@@ -511,6 +512,27 @@ function includeWorkspaceFile(relativePath: string, _change: WorkspaceFileChange
511512
return FILE_BROWSER_EXTENSIONS.test(relativePath) && !isFileBrowserExcludedPath(relativePath);
512513
}
513514

515+
async function walkFileBrowserFiles(dir: string, root: string, files: Set<string>): Promise<void> {
516+
let entries;
517+
try {
518+
entries = await readdir(dir, { withFileTypes: true });
519+
} catch {
520+
return;
521+
}
522+
523+
for (const entry of entries) {
524+
const fullPath = join(dir, entry.name);
525+
const relativePath = relative(root, fullPath).replace(/\\/g, "/");
526+
if (entry.isDirectory()) {
527+
if (isFileBrowserExcludedPath(relativePath)) continue;
528+
await walkFileBrowserFiles(fullPath, root, files);
529+
} else if (entry.isFile() && FILE_BROWSER_EXTENSIONS.test(entry.name)) {
530+
if (isFileBrowserExcludedPath(relativePath)) continue;
531+
files.add(relativePath);
532+
}
533+
}
534+
}
535+
514536
/** List markdown files in a directory as a nested tree. */
515537
export async function handleFileBrowserFiles(req: Request): Promise<Response> {
516538
const url = new URL(req.url);
@@ -528,16 +550,9 @@ export async function handleFileBrowserFiles(req: Request): Promise<Response> {
528550
}
529551

530552
try {
531-
const glob = new Bun.Glob("**/*.{md,mdx,txt,html,htm}");
532553
const files = new Set<string>();
533-
for await (const match of glob.scan({
534-
cwd: resolvedDir,
535-
onlyFiles: true,
536-
})) {
537-
if (isFileBrowserExcludedPath(match)) continue;
538-
files.add(match);
539-
}
540-
const workspaceStatus = filterWorkspaceStatusForDirectory(getWorkspaceStatusForDirectory(resolvedDir), resolvedDir, includeWorkspaceFile);
554+
await walkFileBrowserFiles(resolvedDir, resolvedDir, files);
555+
const workspaceStatus = filterWorkspaceStatusForDirectory(await getWorkspaceStatusForDirectory(resolvedDir), resolvedDir, includeWorkspaceFile);
541556
for (const match of getWorkspaceStatusRelativePaths(workspaceStatus, resolvedDir, includeWorkspaceFile)) {
542557
files.add(match);
543558
}

packages/server/reference-watch.test.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { afterEach, describe, expect, test } from "bun:test";
22
import { mkdtempSync, rmSync } from "node:fs";
33
import { tmpdir } from "node:os";
44
import { basename, dirname, join } from "node:path";
5-
import { handleFileBrowserFilesStream } from "./reference-watch";
5+
import { handleFileBrowserFilesStream, isFileBrowserWatchIgnoredPath } from "./reference-watch";
66

77
const tempDirs: string[] = [];
88

@@ -52,6 +52,17 @@ afterEach(() => {
5252
});
5353

5454
describe("handleFileBrowserFilesStream", () => {
55+
test("ignores nested excluded folders for watcher paths", () => {
56+
const root = join(tmpdir(), "plannotator-watch-root");
57+
58+
expect(isFileBrowserWatchIgnoredPath(join(root, "packages", "app", "node_modules"), root)).toBe(true);
59+
expect(isFileBrowserWatchIgnoredPath(join(root, "packages", "app", "node_modules", "pkg", "readme.md"), root)).toBe(true);
60+
expect(isFileBrowserWatchIgnoredPath(join(root, "docs", "dist", "generated.md"), root)).toBe(true);
61+
expect(isFileBrowserWatchIgnoredPath(join(root, "docs", "plan.md"), root)).toBe(false);
62+
expect(isFileBrowserWatchIgnoredPath(root, root)).toBe(false);
63+
expect(isFileBrowserWatchIgnoredPath(join(dirname(root), "outside", "node_modules"), root)).toBe(false);
64+
});
65+
5566
test("opens one SSE stream for multiple roots", async () => {
5667
const first = makeTempDir("plannotator-watch-a-");
5768
const second = makeTempDir("plannotator-watch-b-");

packages/server/reference-watch.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ function serialize(event: FileBrowserChangeEvent): Uint8Array {
2929
return encoder.encode(`data: ${JSON.stringify(event)}\n\n`);
3030
}
3131

32-
function isExcludedPath(path: string, root: string): boolean {
32+
export function isFileBrowserWatchIgnoredPath(path: string, root: string): boolean {
3333
const rel = relative(root, path).replace(/\\/g, "/");
3434
if (!rel || rel.startsWith("..") || isAbsolute(rel)) return false;
3535
return isFileBrowserExcludedPath(rel);
@@ -71,7 +71,9 @@ function closeWatcher(entry: WatchEntry): void {
7171
if (entry.debounceTimer) clearTimeout(entry.debounceTimer);
7272
void entry.contentWatcher?.close();
7373
void entry.gitWatcher?.close();
74-
watchers.delete(entry.dirPath);
74+
if (watchers.get(entry.dirPath) === entry) {
75+
watchers.delete(entry.dirPath);
76+
}
7577
}
7678

7779
function releaseSubscriber(entry: WatchEntry, controller: ReadableStreamDefaultController): void {
@@ -94,7 +96,7 @@ function ensureWatcher(dirPath: string): WatchEntry {
9496
entry.contentWatcher = chokidar.watch(dirPath, {
9597
ignoreInitial: true,
9698
persistent: true,
97-
ignored: (path) => isExcludedPath(path, dirPath),
99+
ignored: (path) => isFileBrowserWatchIgnoredPath(path, dirPath),
98100
awaitWriteFinish: {
99101
stabilityThreshold: 120,
100102
pollInterval: 30,

0 commit comments

Comments
 (0)