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
1 change: 1 addition & 0 deletions apps/pi-extension/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
"dependencies": {
"@joplin/turndown-plugin-gfm": "^1.0.64",
"@pierre/diffs": "1.2.8",
"chokidar": "^5.0.0",
"parse5": "^7.3.0",
"turndown": "^7.2.4"
},
Expand Down
182 changes: 182 additions & 0 deletions apps/pi-extension/server/file-browser-watch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
import chokidar, { type FSWatcher } from "chokidar";
import { existsSync, statSync } from "node:fs";
import type { IncomingMessage, ServerResponse } from "node:http";
import { isAbsolute, relative } from "node:path";

import { isFileBrowserExcludedPath } from "../generated/reference-common.js";
import { resolveUserPath } from "../generated/resolve-file.js";
import { getGitMetadataWatchPaths } from "../generated/workspace-status.js";
import { json } from "./helpers.js";

interface FileBrowserChangeEvent {
type: "ready" | "changed";
dirPath: string;
reason: "files" | "git" | "initial";
timestamp: number;
}

interface WatchEntry {
dirPath: string;
subscribers: Map<ServerResponse, string>;
contentWatcher: FSWatcher | null;
gitWatcher: FSWatcher | null;
debounceTimer: ReturnType<typeof setTimeout> | null;
}

const HEARTBEAT_MS = 30_000;
const DEBOUNCE_MS = 180;
const watchers = new Map<string, WatchEntry>();

function serialize(event: FileBrowserChangeEvent): string {
return `data: ${JSON.stringify(event)}\n\n`;
}

function isExcludedPath(path: string, root: string): boolean {
const rel = relative(root, path).replace(/\\/g, "/");
if (!rel || rel.startsWith("..") || isAbsolute(rel)) return false;
return isFileBrowserExcludedPath(rel);
}

function isValidDirectory(dirPath: string): boolean {
try {
return existsSync(dirPath) && statSync(dirPath).isDirectory();
} catch {
return false;
}
}

function broadcast(entry: WatchEntry, reason: FileBrowserChangeEvent["reason"]): void {
for (const [res, clientDirPath] of entry.subscribers) {
const payload = serialize({
type: "changed",
dirPath: clientDirPath,
reason,
timestamp: Date.now(),
});
try {
res.write(payload);
} catch {
entry.subscribers.delete(res);
}
}
}

function scheduleBroadcast(entry: WatchEntry, reason: "files" | "git"): void {
if (entry.debounceTimer) clearTimeout(entry.debounceTimer);
entry.debounceTimer = setTimeout(() => {
entry.debounceTimer = null;
broadcast(entry, reason);
}, DEBOUNCE_MS);
}

function closeWatcher(entry: WatchEntry): void {
if (entry.debounceTimer) clearTimeout(entry.debounceTimer);
void entry.contentWatcher?.close();
void entry.gitWatcher?.close();
watchers.delete(entry.dirPath);
}

function releaseSubscriber(entry: WatchEntry, res: ServerResponse): void {
entry.subscribers.delete(res);
if (entry.subscribers.size === 0) closeWatcher(entry);
}

function ensureWatcher(dirPath: string): WatchEntry {
const existing = watchers.get(dirPath);
if (existing) return existing;

const entry: WatchEntry = {
dirPath,
subscribers: new Map(),
contentWatcher: null,
gitWatcher: null,
debounceTimer: null,
};

entry.contentWatcher = chokidar.watch(dirPath, {
ignoreInitial: true,
persistent: true,
ignored: (path) => isExcludedPath(path, dirPath),
awaitWriteFinish: {
stabilityThreshold: 120,
pollInterval: 30,
},
});
entry.contentWatcher.on("all", () => scheduleBroadcast(entry, "files"));
entry.contentWatcher.on("error", () => scheduleBroadcast(entry, "files"));

const gitWatchPaths = getGitMetadataWatchPaths(dirPath);
if (gitWatchPaths.length > 0) {
entry.gitWatcher = chokidar.watch(gitWatchPaths, {
ignoreInitial: true,
persistent: true,
awaitWriteFinish: {
stabilityThreshold: 80,
pollInterval: 30,
},
});
entry.gitWatcher.on("all", () => scheduleBroadcast(entry, "git"));
entry.gitWatcher.on("error", () => scheduleBroadcast(entry, "git"));
}

watchers.set(dirPath, entry);
return entry;
}

export function handleFileBrowserStreamRequest(req: IncomingMessage, res: ServerResponse, url: URL): boolean {
if (url.pathname !== "/api/reference/files/stream" || req.method !== "GET") return false;

const rawDirPaths = url.searchParams.getAll("dirPath");
if (rawDirPaths.length === 0) {
json(res, { error: "Missing dirPath parameter" }, 400);
return true;
}

const dirPaths: string[] = [];
const clientDirPaths: string[] = [];
for (const rawDirPath of rawDirPaths) {
const dirPath = resolveUserPath(rawDirPath);
if (!isValidDirectory(dirPath)) {
json(res, { error: "Invalid directory path" }, 400);
return true;
}
if (!dirPaths.includes(dirPath)) {
dirPaths.push(dirPath);
clientDirPaths.push(rawDirPath);
}
}

const entries = dirPaths.map((dirPath) => ensureWatcher(dirPath));
res.writeHead(200, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
});
res.setTimeout(0);
for (let i = 0; i < entries.length; i++) {
const entry = entries[i]!;
const clientDirPath = clientDirPaths[i] ?? entry.dirPath;
res.write(serialize({
type: "ready",
dirPath: clientDirPath,
reason: "initial",
timestamp: Date.now(),
}));
entry.subscribers.set(res, clientDirPath);
}

const heartbeat = setInterval(() => {
try {
res.write(": heartbeat\n\n");
} catch {
for (const entry of entries) releaseSubscriber(entry, res);
clearInterval(heartbeat);
}
}, HEARTBEAT_MS);

res.on("close", () => {
clearInterval(heartbeat);
for (const entry of entries) releaseSubscriber(entry, res);
});
return true;
}
29 changes: 24 additions & 5 deletions apps/pi-extension/server/reference.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,14 @@ import {
type VaultNode,
buildFileTree,
FILE_BROWSER_EXCLUDED,
isFileBrowserExcludedPath,
} from "../generated/reference-common.js";
import {
filterWorkspaceStatusForDirectory,
getWorkspaceStatusForDirectory,
getWorkspaceStatusRelativePaths,
type WorkspaceFileChange,
} from "../generated/workspace-status.js";
import { detectObsidianVaults } from "../generated/integrations-common.js";
import {
isAbsoluteUserPath,
Expand Down Expand Up @@ -177,7 +184,9 @@ function jsonDoc(res: Res, data: Record<string, unknown>, options?: HandleDocOpt
}

/** Recursively walk a directory collecting files by extension, skipping ignored dirs. */
function walkMarkdownFiles(dir: string, root: string, results: string[], extensions: RegExp = /\.(mdx?|txt|html?)$/i): void {
const FILE_BROWSER_EXTENSIONS = /\.(mdx?|txt|html?)$/i;

function walkMarkdownFiles(dir: string, root: string, results: string[], extensions: RegExp = FILE_BROWSER_EXTENSIONS): void {
let entries: Dirent[];
try {
entries = readdirSync(dir, { withFileTypes: true }) as Dirent[];
Expand All @@ -192,11 +201,16 @@ function walkMarkdownFiles(dir: string, root: string, results: string[], extensi
const relative = join(dir, entry.name)
.slice(root.length + 1)
.replace(/\\/g, "/");
if (isFileBrowserExcludedPath(relative)) continue;
results.push(relative);
}
}
}

function includeWorkspaceFile(relativePath: string, _change: WorkspaceFileChange): boolean {
return FILE_BROWSER_EXTENSIONS.test(relativePath) && !isFileBrowserExcludedPath(relativePath);
}

/** Serve a linked markdown document. Uses shared resolveMarkdownFile for parity with Bun server. */
export async function handleDocRequest(res: Res, url: URL, options: HandleDocOptions = {}): Promise<void> {
const requestedPath = url.searchParams.get("path");
Expand Down Expand Up @@ -506,10 +520,15 @@ export function handleFileBrowserRequest(res: Res, url: URL): void {
return;
}
try {
const files: string[] = [];
walkMarkdownFiles(resolvedDir, resolvedDir, files);
files.sort();
json(res, { tree: buildFileTree(files) });
const files = new Set<string>();
const diskFiles: string[] = [];
walkMarkdownFiles(resolvedDir, resolvedDir, diskFiles);
for (const file of diskFiles) files.add(file);
const workspaceStatus = filterWorkspaceStatusForDirectory(getWorkspaceStatusForDirectory(resolvedDir), resolvedDir, includeWorkspaceFile);
for (const file of getWorkspaceStatusRelativePaths(workspaceStatus, resolvedDir, includeWorkspaceFile)) {
files.add(file);
}
json(res, { tree: buildFileTree([...files].sort()), workspaceStatus });
} catch {
json(res, { error: "Failed to list directory files" }, 500);
}
Expand Down
4 changes: 4 additions & 0 deletions apps/pi-extension/server/serverAnnotate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import {
handleObsidianFilesRequest,
handleObsidianDocRequest,
} from "./reference.js";
import { handleFileBrowserStreamRequest } from "./file-browser-watch.js";
import { warmFileListCache } from "../generated/resolve-file.js";
import { createExternalAnnotationHandler } from "./external-annotations.js";
import {
Expand Down Expand Up @@ -431,6 +432,9 @@ export async function startAnnotateServer(options: {
handleObsidianDocRequest(res, url);
} else if (url.pathname === "/api/reference/files" && req.method === "GET") {
handleFileBrowserRequest(res, url);
} else if (url.pathname === "/api/reference/files/stream" && req.method === "GET") {
handleFileBrowserStreamRequest(req, res, url);
return;
} else if (url.pathname === "/favicon.svg") {
handleFavicon(res);
} else if (url.pathname === "/api/exit" && req.method === "POST") {
Expand Down
4 changes: 4 additions & 0 deletions apps/pi-extension/server/serverPlan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ import {
handleObsidianFilesRequest,
handleObsidianVaultsRequest,
} from "./reference.js";
import { handleFileBrowserStreamRequest } from "./file-browser-watch.js";
import { warmFileListCache } from "../generated/resolve-file.js";

export interface PlanReviewDecision {
Expand Down Expand Up @@ -284,6 +285,9 @@ export async function startPlanReviewServer(options: {
handleObsidianDocRequest(res, url);
} else if (url.pathname === "/api/reference/files" && req.method === "GET") {
handleFileBrowserRequest(res, url);
} else if (url.pathname === "/api/reference/files/stream" && req.method === "GET") {
handleFileBrowserStreamRequest(req, res, url);
return;
} else if (
url.pathname === "/api/plan/vscode-diff" &&
req.method === "POST"
Expand Down
2 changes: 1 addition & 1 deletion apps/pi-extension/vendor.sh
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ cd "$(dirname "$0")"
rm -rf generated
mkdir -p generated generated/ai/providers

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
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
src="../../packages/shared/$f.ts"
printf '// @generated — DO NOT EDIT. Source: packages/shared/%s.ts\n' "$f" | cat - "$src" > "generated/$f.ts"
done
Expand Down
2 changes: 2 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading