Skip to content

Commit 5f8056d

Browse files
Zhangs-11agegr
andauthored
fix: open local file links with Ctrl/Cmd-click (#708)
* fix: open local file links with primary modifiers * fix: preserve encoded characters in local file URLs --------- Co-authored-by: Alex Yang <agegcn@gmail.com>
1 parent 9290c27 commit 5f8056d

6 files changed

Lines changed: 97 additions & 13 deletions

File tree

‎components/FileViewer.tsx‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,9 @@ import {
1919
isVideoPath,
2020
} from "@/lib/file-types";
2121
import { encodeFilePathForApi, getFileDirectory, getFileName, getRelativeFilePath } from "@/lib/file-paths";
22-
import { resolveLocalFileHref } from "@/lib/file-links";
22+
import { resolveLocalFileHref, shouldOpenLocalFileInApp } from "@/lib/file-links";
2323
import { parseFrontmatter } from "@/lib/frontmatter";
24-
import { markdownPreviewRehypePlugins, markdownPreviewRemarkPlugins, normalizeDisplayMath } from "@/lib/markdown";
24+
import { markdownPreviewRehypePlugins, markdownPreviewRemarkPlugins, markdownUrlTransform, normalizeDisplayMath } from "@/lib/markdown";
2525
import { CodeBlock, MermaidBlock } from "./MermaidBlock";
2626
import { FrontmatterCard } from "./FrontmatterCard";
2727
import { parseUnifiedPatch } from "@/lib/patch";
@@ -1681,6 +1681,7 @@ function TextFileViewer({
16811681
<ReactMarkdown
16821682
remarkPlugins={markdownPreviewRemarkPlugins}
16831683
rehypePlugins={markdownPreviewRehypePlugins}
1684+
urlTransform={onOpenFile ? markdownUrlTransform : undefined}
16841685
components={{
16851686
code({ className, children, ...props }) {
16861687
const lang = className?.replace("language-", "").toLowerCase() ?? "";
@@ -1713,8 +1714,7 @@ function TextFileViewer({
17131714
}
17141715

17151716
const handleClick = (event: MouseEvent<HTMLAnchorElement>) => {
1716-
if (event.defaultPrevented || event.button !== 0) return;
1717-
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
1717+
if (!shouldOpenLocalFileInApp(event)) return;
17181718
event.preventDefault();
17191719
onOpenFile(linkedFile);
17201720
};

‎components/MarkdownBody.test.mjs‎

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,10 +37,19 @@ test("opens non-file markdown links in a safe new tab", () => {
3737
});
3838

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

42-
assert.match(html, /<a href="components\/MarkdownBody\.tsx">file<\/a>/);
43-
assert.doesNotMatch(html, /target=|rel=|\snode=/);
43+
assert.match(relativeHtml, /<a href="components\/MarkdownBody\.tsx">file<\/a>/);
44+
assert.doesNotMatch(relativeHtml, /target=|rel=|\snode=/);
45+
assert.match(fileUrlHtml, /<a href="file:\/\/\/home\/me\/project\/report\.html">report<\/a>/);
46+
assert.doesNotMatch(fileUrlHtml, /target=|rel=|\snode=/);
47+
});
48+
49+
test("keeps file URLs inert without an in-app file handler", () => {
50+
const html = renderMarkdown("[report](file:///home/me/project/report.html)", { onOpenFile: undefined });
51+
52+
assert.match(html, /<a href="" target="_blank" rel="noopener noreferrer">report<\/a>/);
4453
});
4554

4655
test("keeps single-tilde CJK numeric ranges literal instead of striking them", () => {

‎components/MarkdownBody.tsx‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,9 @@
22

33
import { useMemo, type MouseEvent } from "react";
44
import ReactMarkdown, { type Components } from "react-markdown";
5-
import { resolveLocalFileHref } from "@/lib/file-links";
5+
import { resolveLocalFileHref, shouldOpenLocalFileInApp } from "@/lib/file-links";
66
import { encodeFilePathForApi } from "@/lib/file-paths";
7-
import { markdownRehypePlugins, markdownRemarkPlugins, normalizeDisplayMath } from "@/lib/markdown";
7+
import { markdownRehypePlugins, markdownRemarkPlugins, markdownUrlTransform, normalizeDisplayMath } from "@/lib/markdown";
88
import { MermaidBlock, CodeBlock } from "./MermaidBlock";
99

1010
interface MarkdownBodyProps {
@@ -61,8 +61,7 @@ export function MarkdownBody({ children, className, isStreaming, cwd, onOpenFile
6161
}
6262

6363
const handleClick = (event: MouseEvent<HTMLAnchorElement>) => {
64-
if (event.defaultPrevented || event.button !== 0) return;
65-
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
64+
if (!shouldOpenLocalFileInApp(event)) return;
6665
const target = event.currentTarget.getAttribute("target");
6766
if (target && target !== "_self") return;
6867
event.preventDefault();
@@ -99,6 +98,7 @@ export function MarkdownBody({ children, className, isStreaming, cwd, onOpenFile
9998
<ReactMarkdown
10099
remarkPlugins={markdownRemarkPlugins}
101100
rehypePlugins={markdownRehypePlugins}
101+
urlTransform={onOpenFile ? markdownUrlTransform : undefined}
102102
components={components}
103103
>
104104
{normalizedMarkdown}

‎lib/file-links.test.mjs‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,39 @@ async function loadSubject() {
55
return import("./file-links.ts");
66
}
77

8+
test("opens local files for plain, Command, and Ctrl left clicks", async () => {
9+
const { shouldOpenLocalFileInApp } = await loadSubject();
10+
const click = {
11+
defaultPrevented: false,
12+
button: 0,
13+
metaKey: false,
14+
ctrlKey: false,
15+
shiftKey: false,
16+
altKey: false,
17+
};
18+
19+
assert.equal(shouldOpenLocalFileInApp(click), true);
20+
assert.equal(shouldOpenLocalFileInApp({ ...click, metaKey: true }), true);
21+
assert.equal(shouldOpenLocalFileInApp({ ...click, ctrlKey: true }), true);
22+
});
23+
24+
test("leaves secondary modified and non-left clicks to the browser", async () => {
25+
const { shouldOpenLocalFileInApp } = await loadSubject();
26+
const click = {
27+
defaultPrevented: false,
28+
button: 0,
29+
metaKey: false,
30+
ctrlKey: false,
31+
shiftKey: false,
32+
altKey: false,
33+
};
34+
35+
assert.equal(shouldOpenLocalFileInApp({ ...click, shiftKey: true }), false);
36+
assert.equal(shouldOpenLocalFileInApp({ ...click, altKey: true }), false);
37+
assert.equal(shouldOpenLocalFileInApp({ ...click, button: 1 }), false);
38+
assert.equal(shouldOpenLocalFileInApp({ ...click, defaultPrevented: true }), false);
39+
});
40+
841
test("resolves absolute markdown file links and strips line suffixes", async () => {
942
const { resolveLocalFileHref } = await loadSubject();
1043

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

105+
test("decodes file URL paths once after parsing URL delimiters", async () => {
106+
const { resolveLocalFileHref } = await loadSubject();
107+
108+
for (const [href, expected] of [
109+
["file:///home/me/project/C%23/report.html", "/home/me/project/C#/report.html"],
110+
["file:///home/me/project/report%3F.html", "/home/me/project/report?.html"],
111+
["file:///home/me/project/report%2520.html", "/home/me/project/report%20.html"],
112+
["file:///home/me/project/report%20one.html?raw=1#L10", "/home/me/project/report one.html"],
113+
["file:///C:/Users/me/C%23/report%2520.html", "C:/Users/me/C#/report%20.html"],
114+
["file://server/share/C%23/report%2520.html", "//server/share/C#/report%20.html"],
115+
]) {
116+
assert.equal(resolveLocalFileHref(href, "/home/me/project"), expected, href);
117+
}
118+
});
119+
72120
test("resolves Windows file URLs without a synthetic leading slash", async () => {
73121
const { resolveLocalFileHref } = await loadSubject();
74122

‎lib/file-links.ts‎

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,21 @@
1+
interface LocalFileClickEvent {
2+
defaultPrevented: boolean;
3+
button: number;
4+
metaKey: boolean;
5+
ctrlKey: boolean;
6+
shiftKey: boolean;
7+
altKey: boolean;
8+
}
9+
10+
export function shouldOpenLocalFileInApp(event: LocalFileClickEvent): boolean {
11+
// Browsers block file:// navigation from Pi Web's HTTP origin, so the
12+
// platform primary modifier must use the same in-app preview as a plain click.
13+
return !event.defaultPrevented
14+
&& event.button === 0
15+
&& !event.shiftKey
16+
&& !event.altKey;
17+
}
18+
119
function safeDecode(value: string): string {
220
try {
321
return decodeURIComponent(value);
@@ -98,7 +116,8 @@ export function resolveLocalFileHref(
98116
}
99117

100118
if (lowerHref.startsWith("file:")) {
101-
candidate = fileUrlToPath(normalizedHref);
119+
// Decode only the parsed pathname so encoded delimiters stay in the filename.
120+
candidate = fileUrlToPath(cleanHref);
102121
candidateKind = candidate ? "absolute" : null;
103122
} else if (/^[a-zA-Z]:\//.test(normalizedHref)) {
104123
candidate = normalizedHref;

‎lib/markdown.ts‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { Options as ReactMarkdownOptions } from "react-markdown";
1+
import { defaultUrlTransform, type Options as ReactMarkdownOptions } from "react-markdown";
22
import rehypeKatex from "rehype-katex";
33
import rehypeRaw from "rehype-raw";
44
import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
@@ -12,9 +12,17 @@ const markdownSanitizeSchema = {
1212
...defaultSchema.attributes,
1313
code: [["className", /^language-./, "math-inline", "math-display"]],
1414
},
15+
protocols: {
16+
...defaultSchema.protocols,
17+
href: [...(defaultSchema.protocols?.href ?? []), "file"],
18+
},
1519
strip: [...(defaultSchema.strip || []), "iframe", "object", "style", "form"],
1620
};
1721

22+
export function markdownUrlTransform(value: string): string {
23+
return /^file:/i.test(value) ? value : defaultUrlTransform(value);
24+
}
25+
1826
export function normalizeDisplayMath(markdown: string): string {
1927
const lineBreak = markdown.includes("\r\n") ? "\r\n" : "\n";
2028
const lines = markdown.split(/\r?\n/);

0 commit comments

Comments
 (0)