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
8 changes: 4 additions & 4 deletions components/FileViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,9 @@ import {
isVideoPath,
} from "@/lib/file-types";
import { encodeFilePathForApi, getFileDirectory, getFileName, getRelativeFilePath } from "@/lib/file-paths";
import { resolveLocalFileHref } from "@/lib/file-links";
import { resolveLocalFileHref, shouldOpenLocalFileInApp } from "@/lib/file-links";
import { parseFrontmatter } from "@/lib/frontmatter";
import { markdownPreviewRehypePlugins, markdownPreviewRemarkPlugins, normalizeDisplayMath } from "@/lib/markdown";
import { markdownPreviewRehypePlugins, markdownPreviewRemarkPlugins, markdownUrlTransform, normalizeDisplayMath } from "@/lib/markdown";
import { CodeBlock, MermaidBlock } from "./MermaidBlock";
import { FrontmatterCard } from "./FrontmatterCard";
import { parseUnifiedPatch } from "@/lib/patch";
Expand Down Expand Up @@ -1681,6 +1681,7 @@ function TextFileViewer({
<ReactMarkdown
remarkPlugins={markdownPreviewRemarkPlugins}
rehypePlugins={markdownPreviewRehypePlugins}
urlTransform={onOpenFile ? markdownUrlTransform : undefined}
components={{
code({ className, children, ...props }) {
const lang = className?.replace("language-", "").toLowerCase() ?? "";
Expand Down Expand Up @@ -1713,8 +1714,7 @@ function TextFileViewer({
}

const handleClick = (event: MouseEvent<HTMLAnchorElement>) => {
if (event.defaultPrevented || event.button !== 0) return;
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
if (!shouldOpenLocalFileInApp(event)) return;
event.preventDefault();
onOpenFile(linkedFile);
};
Expand Down
15 changes: 12 additions & 3 deletions components/MarkdownBody.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,19 @@ test("opens non-file markdown links in a safe new tab", () => {
});

test("keeps local file markdown links in the app", () => {
const html = renderMarkdown("[file](components/MarkdownBody.tsx)");
const relativeHtml = renderMarkdown("[file](components/MarkdownBody.tsx)");
const fileUrlHtml = renderMarkdown("[report](file:///home/me/project/report.html)");

assert.match(html, /<a href="components\/MarkdownBody\.tsx">file<\/a>/);
assert.doesNotMatch(html, /target=|rel=|\snode=/);
assert.match(relativeHtml, /<a href="components\/MarkdownBody\.tsx">file<\/a>/);
assert.doesNotMatch(relativeHtml, /target=|rel=|\snode=/);
assert.match(fileUrlHtml, /<a href="file:\/\/\/home\/me\/project\/report\.html">report<\/a>/);
assert.doesNotMatch(fileUrlHtml, /target=|rel=|\snode=/);
});

test("keeps file URLs inert without an in-app file handler", () => {
const html = renderMarkdown("[report](file:///home/me/project/report.html)", { onOpenFile: undefined });

assert.match(html, /<a href="" target="_blank" rel="noopener noreferrer">report<\/a>/);
});

test("keeps single-tilde CJK numeric ranges literal instead of striking them", () => {
Expand Down
8 changes: 4 additions & 4 deletions components/MarkdownBody.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@

import { useMemo, type MouseEvent } from "react";
import ReactMarkdown, { type Components } from "react-markdown";
import { resolveLocalFileHref } from "@/lib/file-links";
import { resolveLocalFileHref, shouldOpenLocalFileInApp } from "@/lib/file-links";
import { encodeFilePathForApi } from "@/lib/file-paths";
import { markdownRehypePlugins, markdownRemarkPlugins, normalizeDisplayMath } from "@/lib/markdown";
import { markdownRehypePlugins, markdownRemarkPlugins, markdownUrlTransform, normalizeDisplayMath } from "@/lib/markdown";
import { MermaidBlock, CodeBlock } from "./MermaidBlock";

interface MarkdownBodyProps {
Expand Down Expand Up @@ -61,8 +61,7 @@ export function MarkdownBody({ children, className, isStreaming, cwd, onOpenFile
}

const handleClick = (event: MouseEvent<HTMLAnchorElement>) => {
if (event.defaultPrevented || event.button !== 0) return;
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
if (!shouldOpenLocalFileInApp(event)) return;
const target = event.currentTarget.getAttribute("target");
if (target && target !== "_self") return;
event.preventDefault();
Expand Down Expand Up @@ -99,6 +98,7 @@ export function MarkdownBody({ children, className, isStreaming, cwd, onOpenFile
<ReactMarkdown
remarkPlugins={markdownRemarkPlugins}
rehypePlugins={markdownRehypePlugins}
urlTransform={onOpenFile ? markdownUrlTransform : undefined}
components={components}
>
{normalizedMarkdown}
Expand Down
48 changes: 48 additions & 0 deletions lib/file-links.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,39 @@ async function loadSubject() {
return import("./file-links.ts");
}

test("opens local files for plain, Command, and Ctrl left clicks", async () => {
const { shouldOpenLocalFileInApp } = await loadSubject();
const click = {
defaultPrevented: false,
button: 0,
metaKey: false,
ctrlKey: false,
shiftKey: false,
altKey: false,
};

assert.equal(shouldOpenLocalFileInApp(click), true);
assert.equal(shouldOpenLocalFileInApp({ ...click, metaKey: true }), true);
assert.equal(shouldOpenLocalFileInApp({ ...click, ctrlKey: true }), true);
});

test("leaves secondary modified and non-left clicks to the browser", async () => {
const { shouldOpenLocalFileInApp } = await loadSubject();
const click = {
defaultPrevented: false,
button: 0,
metaKey: false,
ctrlKey: false,
shiftKey: false,
altKey: false,
};

assert.equal(shouldOpenLocalFileInApp({ ...click, shiftKey: true }), false);
assert.equal(shouldOpenLocalFileInApp({ ...click, altKey: true }), false);
assert.equal(shouldOpenLocalFileInApp({ ...click, button: 1 }), false);
assert.equal(shouldOpenLocalFileInApp({ ...click, defaultPrevented: true }), false);
});

test("resolves absolute markdown file links and strips line suffixes", async () => {
const { resolveLocalFileHref } = await loadSubject();

Expand Down Expand Up @@ -69,6 +102,21 @@ test("does not treat app or external URLs as file links", async () => {
assert.equal(resolveLocalFileHref("//example.com/a.ts", "/home/me/project"), null);
});

test("decodes file URL paths once after parsing URL delimiters", async () => {
const { resolveLocalFileHref } = await loadSubject();

for (const [href, expected] of [
["file:///home/me/project/C%23/report.html", "/home/me/project/C#/report.html"],
["file:///home/me/project/report%3F.html", "/home/me/project/report?.html"],
["file:///home/me/project/report%2520.html", "/home/me/project/report%20.html"],
["file:///home/me/project/report%20one.html?raw=1#L10", "/home/me/project/report one.html"],
["file:///C:/Users/me/C%23/report%2520.html", "C:/Users/me/C#/report%20.html"],
["file://server/share/C%23/report%2520.html", "//server/share/C#/report%20.html"],
]) {
assert.equal(resolveLocalFileHref(href, "/home/me/project"), expected, href);
}
});

test("resolves Windows file URLs without a synthetic leading slash", async () => {
const { resolveLocalFileHref } = await loadSubject();

Expand Down
21 changes: 20 additions & 1 deletion lib/file-links.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,21 @@
interface LocalFileClickEvent {
defaultPrevented: boolean;
button: number;
metaKey: boolean;
ctrlKey: boolean;
shiftKey: boolean;
altKey: boolean;
}

export function shouldOpenLocalFileInApp(event: LocalFileClickEvent): boolean {
// Browsers block file:// navigation from Pi Web's HTTP origin, so the
// platform primary modifier must use the same in-app preview as a plain click.
return !event.defaultPrevented
&& event.button === 0
&& !event.shiftKey
&& !event.altKey;
}

function safeDecode(value: string): string {
try {
return decodeURIComponent(value);
Expand Down Expand Up @@ -98,7 +116,8 @@ export function resolveLocalFileHref(
}

if (lowerHref.startsWith("file:")) {
candidate = fileUrlToPath(normalizedHref);
// Decode only the parsed pathname so encoded delimiters stay in the filename.
candidate = fileUrlToPath(cleanHref);
candidateKind = candidate ? "absolute" : null;
} else if (/^[a-zA-Z]:\//.test(normalizedHref)) {
candidate = normalizedHref;
Expand Down
10 changes: 9 additions & 1 deletion lib/markdown.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { Options as ReactMarkdownOptions } from "react-markdown";
import { defaultUrlTransform, type Options as ReactMarkdownOptions } from "react-markdown";
import rehypeKatex from "rehype-katex";
import rehypeRaw from "rehype-raw";
import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
Expand All @@ -12,9 +12,17 @@ const markdownSanitizeSchema = {
...defaultSchema.attributes,
code: [["className", /^language-./, "math-inline", "math-display"]],
},
protocols: {
...defaultSchema.protocols,
href: [...(defaultSchema.protocols?.href ?? []), "file"],
},
strip: [...(defaultSchema.strip || []), "iframe", "object", "style", "form"],
};

export function markdownUrlTransform(value: string): string {
return /^file:/i.test(value) ? value : defaultUrlTransform(value);
}

export function normalizeDisplayMath(markdown: string): string {
const lineBreak = markdown.includes("\r\n") ? "\r\n" : "\n";
const lines = markdown.split(/\r?\n/);
Expand Down