From 113dad6f1be8babb12a1b3f0bddae283d3995843 Mon Sep 17 00:00:00 2001 From: zhangshuo Date: Fri, 4 Sep 2026 14:42:42 +0800 Subject: [PATCH 1/2] fix: open local file links with primary modifiers --- components/FileViewer.tsx | 8 ++++---- components/MarkdownBody.test.mjs | 19 +++++++++++++----- components/MarkdownBody.tsx | 8 ++++---- lib/file-links.test.mjs | 33 ++++++++++++++++++++++++++++++++ lib/file-links.ts | 18 +++++++++++++++++ lib/markdown.ts | 10 +++++++++- 6 files changed, 82 insertions(+), 14 deletions(-) diff --git a/components/FileViewer.tsx b/components/FileViewer.tsx index bce9c8a6f..d0e97cc0f 100644 --- a/components/FileViewer.tsx +++ b/components/FileViewer.tsx @@ -18,9 +18,9 @@ import { isImagePath, } 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"; @@ -1446,6 +1446,7 @@ function TextFileViewer({ ) => { - 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); }; diff --git a/components/MarkdownBody.test.mjs b/components/MarkdownBody.test.mjs index de538d07b..c02fc119b 100644 --- a/components/MarkdownBody.test.mjs +++ b/components/MarkdownBody.test.mjs @@ -11,11 +11,11 @@ const jiti = createJiti(import.meta.url, { const { MarkdownBody } = await jiti.import("./MarkdownBody.tsx"); const { normalizeDisplayMath } = await jiti.import("../lib/markdown.ts"); -function renderMarkdown(markdown) { +function renderMarkdown(markdown, withOpenFile = true) { return renderToStaticMarkup( React.createElement(MarkdownBody, { cwd: "/home/me/project", - onOpenFile() {}, + ...(withOpenFile ? { onOpenFile() {} } : {}), }, markdown), ); } @@ -31,10 +31,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, /file<\/a>/); - assert.doesNotMatch(html, /target=|rel=|\snode=/); + assert.match(relativeHtml, /file<\/a>/); + assert.doesNotMatch(relativeHtml, /target=|rel=|\snode=/); + assert.match(fileUrlHtml, /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)", false); + + assert.match(html, /report<\/a>/); }); test("keeps single-tilde CJK numeric ranges literal instead of striking them", () => { diff --git a/components/MarkdownBody.tsx b/components/MarkdownBody.tsx index 2547f3393..c225e412b 100644 --- a/components/MarkdownBody.tsx +++ b/components/MarkdownBody.tsx @@ -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 { @@ -55,8 +55,7 @@ export function MarkdownBody({ children, className, isStreaming, cwd, onOpenFile } const handleClick = (event: MouseEvent) => { - 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(); @@ -93,6 +92,7 @@ export function MarkdownBody({ children, className, isStreaming, cwd, onOpenFile {normalizedMarkdown} diff --git a/lib/file-links.test.mjs b/lib/file-links.test.mjs index 46cd40878..c355463bc 100644 --- a/lib/file-links.test.mjs +++ b/lib/file-links.test.mjs @@ -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(); diff --git a/lib/file-links.ts b/lib/file-links.ts index 19c20dd5e..11cdc18b6 100644 --- a/lib/file-links.ts +++ b/lib/file-links.ts @@ -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); diff --git a/lib/markdown.ts b/lib/markdown.ts index e31bf9e6d..e7ccfff0f 100644 --- a/lib/markdown.ts +++ b/lib/markdown.ts @@ -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"; @@ -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/); From 1cd2e591c952f8bb97f589f59ea14dbfa67ee8be Mon Sep 17 00:00:00 2001 From: Alex Yang Date: Sat, 5 Sep 2026 22:31:37 +0800 Subject: [PATCH 2/2] fix: preserve encoded characters in local file URLs --- lib/file-links.test.mjs | 15 +++++++++++++++ lib/file-links.ts | 3 ++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/lib/file-links.test.mjs b/lib/file-links.test.mjs index c355463bc..63405e4d4 100644 --- a/lib/file-links.test.mjs +++ b/lib/file-links.test.mjs @@ -102,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(); diff --git a/lib/file-links.ts b/lib/file-links.ts index 11cdc18b6..8a4f1a717 100644 --- a/lib/file-links.ts +++ b/lib/file-links.ts @@ -116,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;