Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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
4 changes: 2 additions & 2 deletions apps/hook/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ import { parsePRUrl, checkPRAuth, fetchPR, getCliName, getCliInstallUrl, getMRLa
import { writeRemoteShareLink } from "@plannotator/server/share-url";
import { resolveMarkdownFile, resolveUserPath, hasMarkdownFiles } from "@plannotator/shared/resolve-file";
import { FILE_BROWSER_EXCLUDED } from "@plannotator/shared/reference-common";
import { statSync, rmSync, realpathSync, existsSync, appendFileSync } from "fs";
import { statSync, rmSync, realpathSync, existsSync } from "fs";
import { parseRemoteUrl } from "@plannotator/shared/repo";
import {
getReviewApprovedPrompt,
Expand Down Expand Up @@ -108,7 +108,7 @@ import {
isTopLevelHelpInvocation,
} from "./cli";
import path from "path";
import { tmpdir, homedir } from "os";
import { tmpdir } from "os";

// Embed the built HTML at compile time
// @ts-ignore - Bun import attribute for text
Expand Down
12 changes: 8 additions & 4 deletions apps/pi-extension/server/reference.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import {
isWithinProjectRoot,
warmFileListCache,
} from "../generated/resolve-file.js";
import { parseCodePath } from "../generated/code-file.js";
import { htmlToMarkdown } from "../generated/html-to-markdown.js";
import { preloadFile } from "@pierre/diffs/ssr";

Expand Down Expand Up @@ -116,7 +117,9 @@ export async function handleDocRequest(res: Res, url: URL): Promise<void> {

// Code files: try literal resolve first; on miss, fall back to smart resolver.
if (isCodeFilePath(requestedPath)) {
const literalPath = resolveUserPath(requestedPath, resolvedBase || projectRoot);
const parsed = parseCodePath(requestedPath);
const cleanPath = parsed.filePath;
const literalPath = resolveUserPath(cleanPath, resolvedBase || projectRoot);
const literalAllowed = resolvedBase || isWithinProjectRoot(literalPath, projectRoot);

let resolvedCode: string | null = null;
Expand All @@ -125,7 +128,7 @@ export async function handleDocRequest(res: Res, url: URL): Promise<void> {
}

if (!resolvedCode) {
const result = await resolveCodeFile(requestedPath, projectRoot);
const result = await resolveCodeFile(cleanPath, projectRoot);
if (result.kind === "found") {
resolvedCode = result.path;
} else if (result.kind === "ambiguous") {
Expand Down Expand Up @@ -163,7 +166,7 @@ export async function handleDocRequest(res: Res, url: URL): Promise<void> {
} catch {
// Fall back to client-side rendering
}
json(res, { codeFile: true, contents, filepath: resolvedCode, prerenderedHTML });
json(res, { codeFile: true, contents, filepath: resolvedCode, prerenderedHTML, line: parsed.line, lineEnd: parsed.lineEnd });
return;
} catch {
json(res, { error: `File not found: ${requestedPath}` }, 404);
Expand Down Expand Up @@ -234,7 +237,8 @@ export async function handleDocExistsRequest(res: Res, req: IncomingMessage): Pr

await Promise.all(
(paths as string[]).map(async (p) => {
const r = await resolveCodeFile(p, projectRoot, baseDir);
const cleanP = parseCodePath(p).filePath;
const r = await resolveCodeFile(cleanP, projectRoot, baseDir);
if (r.kind === "found") {
results[p] = { status: "found", resolved: r.path };
} else if (r.kind === "ambiguous") {
Expand Down
2 changes: 1 addition & 1 deletion packages/editor/demoPlan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ export const COLLAB_CONFIG = {
| \`package.json\` | Root config |

## Overview
Add real-time collaboration features to the editor using _**[WebSocket API](https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API)**_ and *[operational transforms](https://en.wikipedia.org/wiki/Operational_transformation)*.
Add real-time collaboration features to the editor using _**[WebSocket API](https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API)**_ and *[operational transforms](https://en.wikipedia.org/wiki/Operational_transformation)*. The core rendering logic is in \`packages/ui/components/Viewer.tsx:140-180\` and the block parser at \`packages/ui/utils/parser.ts:261-286\`.

## Phase 1: Infrastructure

Expand Down
12 changes: 8 additions & 4 deletions packages/server/reference-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import { existsSync, statSync } from "fs";
import { resolve } from "path";
import { buildFileTree, FILE_BROWSER_EXCLUDED } from "@plannotator/shared/reference-common";
import { parseCodePath } from "@plannotator/shared/code-file";
import { detectObsidianVaults } from "./integrations";
import {
isAbsoluteUserPath,
Expand Down Expand Up @@ -83,7 +84,9 @@ export async function handleDoc(req: Request): Promise<Response> {
// Code files: try literal resolve first; on miss, fall back to the smart
// resolver which walks the project for case-insensitive / suffix matches.
if (isCodeFilePath(requestedPath)) {
const literalPath = resolveUserPath(requestedPath, resolvedBase || projectRoot);
const parsed = parseCodePath(requestedPath);
const cleanPath = parsed.filePath;
const literalPath = resolveUserPath(cleanPath, resolvedBase || projectRoot);
const literalAllowed = resolvedBase || isWithinProjectRoot(literalPath, projectRoot);

let resolvedCode: string | null = null;
Expand All @@ -95,7 +98,7 @@ export async function handleDoc(req: Request): Promise<Response> {
}

if (!resolvedCode) {
const result = await resolveCodeFile(requestedPath, projectRoot);
const result = await resolveCodeFile(cleanPath, projectRoot);
if (result.kind === "found") {
resolvedCode = result.path;
} else if (result.kind === "ambiguous") {
Expand Down Expand Up @@ -134,7 +137,7 @@ export async function handleDoc(req: Request): Promise<Response> {
} catch {
// Fall back to client-side rendering
}
return Response.json({ codeFile: true, contents, filepath: resolvedCode, prerenderedHTML });
return Response.json({ codeFile: true, contents, filepath: resolvedCode, prerenderedHTML, line: parsed.line, lineEnd: parsed.lineEnd });
} catch {
return Response.json({ error: `File not found: ${requestedPath}` }, { status: 404 });
}
Expand Down Expand Up @@ -215,7 +218,8 @@ export async function handleDocExists(req: Request): Promise<Response> {

await Promise.all(
(paths as string[]).map(async (p) => {
const r = await resolveCodeFile(p, projectRoot, baseDir);
const cleanP = parseCodePath(p).filePath;
const r = await resolveCodeFile(cleanP, projectRoot, baseDir);
if (r.kind === "found") {
results[p] = { status: "found", resolved: r.path };
} else if (r.kind === "ambiguous") {
Expand Down
26 changes: 24 additions & 2 deletions packages/shared/code-file.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,38 @@
export const CODE_FILE_REGEX = /(?:\.(tsx?|jsx?|py|rb|go|rs|java|c|cpp|h|hpp|cs|swift|kt|scala|sh|bash|zsh|sql|graphql|json|ya?ml|toml|ini|css|scss|less|xml|tf|lua|r|dart|ex|exs|vue|svelte|astro|zig|proto)|(?:^|\/)(Dockerfile|Makefile|Rakefile|Gemfile|Procfile|Vagrantfile|Brewfile|Justfile))$/i;

export const CODE_PATH_BARE_REGEX = /(?:\.{0,2}\/)?(?:[a-zA-Z0-9_@.\-\[\]]+\/)+[a-zA-Z0-9_.\-\[\]]+\.[a-zA-Z0-9]+/g;
export const CODE_PATH_BARE_REGEX = /(?:\.{0,2}\/)?(?:[a-zA-Z0-9_@.\-\[\]]+\/)+[a-zA-Z0-9_.\-\[\]]+\.[a-zA-Z0-9]+(?::\d+(?:-\d+)?)?/g;

const IMPLAUSIBLE_CHARS = /[{},*?\s]/;

export function isPlausibleCodeFilePath(input: string): boolean {
return !IMPLAUSIBLE_CHARS.test(input);
}

export interface ParsedCodePath {
filePath: string;
line?: number;
lineEnd?: number;
}

const LINE_SUFFIX_RE = /:(\d+)(?:-(\d+))?$/;

export function parseCodePath(input: string): ParsedCodePath {
const clean = input.replace(/#.*$/, '');
const m = clean.match(LINE_SUFFIX_RE);
if (!m) return { filePath: clean };
let line = Number.parseInt(m[1], 10);
let lineEnd = m[2] ? Number.parseInt(m[2], 10) : undefined;
if (lineEnd != null && lineEnd < line) { const tmp = line; line = lineEnd; lineEnd = tmp; }
return { filePath: clean.replace(LINE_SUFFIX_RE, ''), line, lineEnd };
}

export function stripLineRef(input: string): string {
return input.replace(/#.*$/, '').replace(LINE_SUFFIX_RE, '');
}

export function isCodeFilePath(input: string): boolean {
if (!isPlausibleCodeFilePath(input)) return false;
return CODE_FILE_REGEX.test(input.replace(/#.*$/, ''))
return CODE_FILE_REGEX.test(stripLineRef(input))
&& !input.startsWith('http://') && !input.startsWith('https://');
}

Expand Down
1 change: 1 addition & 0 deletions packages/shared/pfm-reminder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ Code-file links (highest leverage)
Reference real source files inline. Plannotator validates the path and renders a clickable badge that opens the file in the reviewer's editor — prefer this over pasting code when you just need to point at something.
\`packages/server/index.ts\` backticked path
\`packages/server/index.ts:42\` path with line number
\`packages/server/index.ts:10-20\` line range — hover shows a code snippet preview
[the handler](packages/server/index.ts:42) markdown link form
Ambiguous paths (e.g. \`index.ts\`) still render and open a picker.

Expand Down
19 changes: 16 additions & 3 deletions packages/ui/components/GraphvizBlock.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,15 @@ export const GraphvizBlock: React.FC<{ block: Block }> = ({ block }) => {
const cleaned = renderedSvg
.replace(/ width="[^"]*"/, ' width="100%"')
.replace(/ height="[^"]*"/, ' height="100%"')
.replace(/ style="[^"]*"/, '');
.replace(/ style="[^"]*"/, '')
.replace(/<polygon[^>]*fill="white"[^>]*\/>/, '')
.replace(/fill="black"/g, 'fill="var(--foreground)"')
.replace(/fill="#000000"/g, 'fill="var(--foreground)"')
.replace(/stroke="black"/g, 'stroke="var(--muted-foreground)"')
.replace(/stroke="#000000"/g, 'stroke="var(--muted-foreground)"')
.replace(/fill="lightgrey"/g, 'fill="var(--muted)"')
.replace(/fill="lightgray"/g, 'fill="var(--muted)"');

if (!cancelled) {
naturalBoundsRef.current = parseViewBoxFromMarkup(cleaned);
setSvg(cleaned);
Expand Down Expand Up @@ -454,10 +462,15 @@ export const GraphvizBlock: React.FC<{ block: Block }> = ({ block }) => {
</pre>
);

const naturalHeight = naturalBoundsRef.current
? `min(65vh, ${Math.min(36 * 16, Math.max(4 * 16, Math.round(naturalBoundsRef.current.height * (800 / naturalBoundsRef.current.width))))}px)`
: 'min(65vh, 36rem)';

const diagramBody = (
<div
ref={containerRef}
className={`rounded-xl bg-muted/30 border border-border/30 overflow-hidden select-none cursor-grab ${isExpanded ? 'h-full min-h-0' : 'h-[min(65vh,36rem)] min-h-[20rem]'}`}
className={`rounded-xl bg-muted/30 border border-border/30 overflow-hidden select-none cursor-grab ${isExpanded ? 'h-full min-h-0' : ''}`}
style={isExpanded ? undefined : { height: naturalHeight }}
dangerouslySetInnerHTML={{ __html: svg }}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
Expand All @@ -470,7 +483,7 @@ export const GraphvizBlock: React.FC<{ block: Block }> = ({ block }) => {
<>
<div className="my-5 group relative" data-block-id={block.id}>
{!isExpanded && controls}
{showSource || !svg ? inlineSource : !isExpanded ? diagramBody : <div className="rounded-xl border border-border/30 bg-muted/10 h-[min(65vh,36rem)] min-h-[20rem]" />}
{showSource || !svg ? inlineSource : !isExpanded ? diagramBody : <div className="rounded-xl border border-border/30 bg-muted/10" style={{ height: naturalHeight }} />}
</div>

{!showSource && svg && isExpanded && typeof document !== 'undefined' && createPortal(
Expand Down
Loading
Loading