Skip to content

Commit a11bf80

Browse files
authored
feat(ui): code file viewer with syntax highlighting and annotations (#634)
* feat(ui): extract reusable PopoutDialog, fix backdrop blur The table popout lost its backdrop blur when we switched to modal={false} to keep annotation toolbars interactive. Radix ignores Dialog.Overlay in non-modal mode, so replace it with a plain div backdrop that works regardless. Extract the dialog shell (backdrop, close button, portal, annotation-aware dismiss) into a reusable PopoutDialog component for upcoming use cases. Add a demo table to the dev plan content. * Add read-only code file popout * Add code file annotation support * Fix code selection popover position * fix(editor): restore global-attachment-only drafts The save condition was broadened to persist drafts with only global attachments, but the restore handler still skipped applying when both annotation arrays were empty — silently dropping the attachments. * fix(ui): import SelectedLineRange from @pierre/diffs base package SelectedLineRange is not re-exported from @pierre/diffs/react — import it from the base @pierre/diffs entry point instead. * chore: add TODO for bot callback + code annotation limitation
1 parent c007d1c commit a11bf80

33 files changed

Lines changed: 1480 additions & 222 deletions

apps/hook/dev-mock-api.ts

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,10 @@
2727
* a more structural diff (outline → full spec) within whichever mode is on.
2828
*/
2929
import type { Plugin } from 'vite';
30+
import { existsSync, readFileSync, statSync } from 'fs';
31+
import { resolve } from 'path';
32+
import { isCodeFilePath } from '../../packages/shared/code-file';
33+
import { preloadFile } from '@pierre/diffs/ssr';
3034

3135
// ─── Default plans (Real-time Collaboration) ─────────────────────────────
3236
// What every dev sees when running `bun run dev:hook` without any flag.
@@ -569,7 +573,7 @@ export function devMockApi(): Plugin {
569573
return {
570574
name: 'plannotator-dev-mock-api',
571575
configureServer(server) {
572-
server.middlewares.use((req, res, next) => {
576+
server.middlewares.use(async (req, res, next) => {
573577
if (req.url === '/api/plan') {
574578
res.setHeader('Content-Type', 'application/json');
575579
res.end(JSON.stringify({
@@ -606,6 +610,41 @@ export function devMockApi(): Plugin {
606610
return;
607611
}
608612

613+
if (req.url?.startsWith('/api/doc?')) {
614+
const url = new URL(req.url, 'http://localhost');
615+
const reqPath = url.searchParams.get('path');
616+
if (!reqPath) {
617+
res.statusCode = 400;
618+
res.end(JSON.stringify({ error: 'Missing path parameter' }));
619+
return;
620+
}
621+
const base = url.searchParams.get('base');
622+
const repoRoot = resolve(import.meta.dirname, '../..');
623+
const resolved = resolve(base || repoRoot, reqPath);
624+
if (!existsSync(resolved) || statSync(resolved).isDirectory()) {
625+
res.statusCode = 404;
626+
res.end(JSON.stringify({ error: `File not found: ${reqPath}` }));
627+
return;
628+
}
629+
const contents = readFileSync(resolved, 'utf-8');
630+
res.setHeader('Content-Type', 'application/json');
631+
if (isCodeFilePath(reqPath)) {
632+
const displayName = resolved.split('/').pop() || resolved;
633+
let prerenderedHTML: string | undefined;
634+
try {
635+
const result = await preloadFile({
636+
file: { name: displayName, contents },
637+
options: { disableFileHeader: true },
638+
});
639+
prerenderedHTML = result.prerenderedHTML;
640+
} catch { /* fall back to client-side rendering */ }
641+
res.end(JSON.stringify({ codeFile: true, contents, filepath: resolved, prerenderedHTML }));
642+
} else {
643+
res.end(JSON.stringify({ markdown: contents, filepath: resolved }));
644+
}
645+
return;
646+
}
647+
609648
next();
610649
});
611650
},

apps/pi-extension/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
"prepublishOnly": "cd ../.. && bun run build:pi"
4040
},
4141
"dependencies": {
42+
"@pierre/diffs": "^1.1.12",
4243
"turndown": "^7.2.4",
4344
"@joplin/turndown-plugin-gfm": "^1.0.64"
4445
},

apps/pi-extension/server/reference.ts

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,13 @@ import {
2424
import { detectObsidianVaults } from "../generated/integrations-common.js";
2525
import {
2626
isAbsoluteUserPath,
27+
isCodeFilePath,
2728
resolveMarkdownFile,
2829
resolveUserPath,
2930
isWithinProjectRoot,
3031
} from "../generated/resolve-file.js";
3132
import { htmlToMarkdown } from "../generated/html-to-markdown.js";
33+
import { preloadFile } from "@pierre/diffs/ssr";
3234

3335
type Res = ServerResponse;
3436

@@ -54,7 +56,7 @@ function walkMarkdownFiles(dir: string, root: string, results: string[], extensi
5456
}
5557

5658
/** Serve a linked markdown document. Uses shared resolveMarkdownFile for parity with Bun server. */
57-
export function handleDocRequest(res: Res, url: URL): void {
59+
export async function handleDocRequest(res: Res, url: URL): Promise<void> {
5860
const requestedPath = url.searchParams.get("path");
5961
if (!requestedPath) {
6062
json(res, { error: "Missing path parameter" }, 400);
@@ -106,6 +108,40 @@ export function handleDocRequest(res: Res, url: URL): void {
106108
return;
107109
}
108110

111+
// Code files: resolve directly, return raw contents (no markdown conversion)
112+
if (isCodeFilePath(requestedPath)) {
113+
const resolvedCode = resolveUserPath(requestedPath, resolvedBase || projectRoot);
114+
if (!resolvedBase && !isWithinProjectRoot(resolvedCode, projectRoot)) {
115+
json(res, { error: "Access denied: path is outside project root" }, 403);
116+
return;
117+
}
118+
try {
119+
if (existsSync(resolvedCode)) {
120+
const stat = statSync(resolvedCode);
121+
if (stat.size > 2 * 1024 * 1024) {
122+
json(res, { error: "File too large (max 2MB)" }, 413);
123+
return;
124+
}
125+
const contents = readFileSync(resolvedCode, "utf-8");
126+
const displayName = resolvedCode.split("/").pop() || resolvedCode;
127+
let prerenderedHTML: string | undefined;
128+
try {
129+
const result = await preloadFile({
130+
file: { name: displayName, contents },
131+
options: { disableFileHeader: true },
132+
});
133+
prerenderedHTML = result.prerenderedHTML;
134+
} catch {
135+
// Fall back to client-side rendering
136+
}
137+
json(res, { codeFile: true, contents, filepath: resolvedCode, prerenderedHTML });
138+
return;
139+
}
140+
} catch { /* fall through */ }
141+
json(res, { error: `File not found: ${requestedPath}` }, 404);
142+
return;
143+
}
144+
109145
const result = resolveMarkdownFile(requestedPath, projectRoot);
110146

111147
if (result.kind === "ambiguous") {

apps/pi-extension/server/serverAnnotate.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,7 @@ export async function startAnnotateServer(options: {
126126
if (!url.searchParams.has("base") && options.filePath && !/^https?:\/\//i.test(options.filePath)) {
127127
url.searchParams.set("base", dirname(resolvePath(options.filePath)));
128128
}
129-
handleDocRequest(res, url);
129+
await handleDocRequest(res, url);
130130
} else if (url.pathname === "/api/obsidian/vaults") {
131131
handleObsidianVaultsRequest(res);
132132
} else if (url.pathname === "/api/reference/obsidian/files" && req.method === "GET") {

apps/pi-extension/server/serverPlan.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -246,7 +246,7 @@ export async function startPlanReviewServer(options: {
246246
} else if (externalAnnotations && (await externalAnnotations.handle(req, res, url))) {
247247
return;
248248
} else if (url.pathname === "/api/doc" && req.method === "GET") {
249-
handleDocRequest(res, url);
249+
await handleDocRequest(res, url);
250250
} else if (url.pathname === "/api/obsidian/vaults") {
251251
handleObsidianVaultsRequest(res);
252252
} else if (url.pathname === "/api/reference/obsidian/files" && req.method === "GET") {

apps/pi-extension/vendor.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ cd "$(dirname "$0")"
66

77
mkdir -p generated generated/ai/providers
88

9-
for f in feedback-templates prompts review-core storage draft project pr-provider pr-stack pr-github pr-gitlab checklist integrations-common repo reference-common favicon resolve-file config external-annotation agent-jobs worktree worktree-pool html-to-markdown url-to-markdown tour annotate-args at-reference; do
9+
for f in feedback-templates prompts review-core storage draft project 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 url-to-markdown tour annotate-args at-reference; do
1010
src="../../packages/shared/$f.ts"
1111
printf '// @generated — DO NOT EDIT. Source: packages/shared/%s.ts\n' "$f" | cat - "$src" > "generated/$f.ts"
1212
done

bun.lock

Lines changed: 5 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)