authorgravatar for saiansh2525@gmail.comYash Singh <saiansh2525@gmail.com> 2026-04-09 19:49:35-05:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-04-09 17:49:35-07:00
log592c234fd5d1ba8eb59197a15affbbda1150341f
tree3d47cd3ce6923c64e9bcb523338383b8655fef99
parent66d76b5d02308dc7a8e3ff218bb0301bc20c0c2a
signature Signed by PGP key B5690EEEBB952194

fix: make file uri links clickable (#1867)

Co-authored-by: Julius Marminge <julius0216@outlook.com>

4 files changed, 119 insertions(+), 10 deletions(-)

apps/web/src/components/ChatMarkdown.browser.tsx created+76
...@@ -0,0 +1,76 @@
1import "../index.css";
2
3import { page } from "vitest/browser";
4import { afterEach, describe, expect, it, vi } from "vitest";
5import { render } from "vitest-browser-react";
6
7const { openInPreferredEditorMock, readLocalApiMock } = vi.hoisted(() => ({
8 openInPreferredEditorMock: vi.fn(async () => "vscode"),
9 readLocalApiMock: vi.fn(() => ({
10 server: { getConfig: vi.fn(async () => ({ availableEditors: ["vscode"] })) },
11 shell: { openInEditor: vi.fn(async () => undefined) },
12 })),
13}));
14
15vi.mock("../editorPreferences", () => ({
16 openInPreferredEditor: openInPreferredEditorMock,
17}));
18
19vi.mock("../localApi", () => ({
20 readLocalApi: readLocalApiMock,
21}));
22
23import ChatMarkdown from "./ChatMarkdown";
24
25describe("ChatMarkdown", () => {
26 afterEach(() => {
27 openInPreferredEditorMock.mockClear();
28 readLocalApiMock.mockClear();
29 localStorage.clear();
30 document.body.innerHTML = "";
31 });
32
33 it("rewrites file uri hrefs into direct paths before rendering", async () => {
34 const filePath =
35 "/Users/yashsingh/p/sco/claude-code-extract/src/utils/permissions/PermissionRule.ts";
36 const screen = await render(
37 <ChatMarkdown text={`[PermissionRule.ts](file://${filePath})`} cwd="/repo/project" />,
38 );
39
40 try {
41 const link = page.getByRole("link", { name: "PermissionRule.ts" });
42 await expect.element(link).toBeInTheDocument();
43 await expect.element(link).toHaveAttribute("href", filePath);
44
45 await link.click();
46
47 await vi.waitFor(() => {
48 expect(openInPreferredEditorMock).toHaveBeenCalledWith(expect.anything(), filePath);
49 });
50 } finally {
51 await screen.unmount();
52 }
53 });
54
55 it("keeps line anchors working after rewriting file uri hrefs", async () => {
56 const filePath =
57 "/Users/yashsingh/p/sco/claude-code-extract/src/utils/permissions/PermissionRule.ts";
58 const screen = await render(
59 <ChatMarkdown text={`[PermissionRule.ts:1](file://${filePath}#L1)`} cwd="/repo/project" />,
60 );
61
62 try {
63 const link = page.getByRole("link", { name: "PermissionRule.ts:1" });
64 await expect.element(link).toBeInTheDocument();
65 await expect.element(link).toHaveAttribute("href", `${filePath}#L1`);
66
67 await link.click();
68
69 await vi.waitFor(() => {
70 expect(openInPreferredEditorMock).toHaveBeenCalledWith(expect.anything(), `${filePath}:1`);
71 });
72 } finally {
73 await screen.unmount();
74 }
75 });
76});
apps/web/src/components/ChatMarkdown.tsx+10-2
...@@ -15,13 +15,14 @@ import React, {...@@ -15,13 +15,14 @@ import React, {
15} from "react";15} from "react";
16import type { Components } from "react-markdown";16import type { Components } from "react-markdown";
17import ReactMarkdown from "react-markdown";17import ReactMarkdown from "react-markdown";
18import { defaultUrlTransform } from "react-markdown";
18import remarkGfm from "remark-gfm";19import remarkGfm from "remark-gfm";
19import { openInPreferredEditor } from "../editorPreferences";20import { openInPreferredEditor } from "../editorPreferences";
20import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffRendering";21import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffRendering";
21import { fnv1a32 } from "../lib/diffRendering";22import { fnv1a32 } from "../lib/diffRendering";
22import { LRUCache } from "../lib/lruCache";23import { LRUCache } from "../lib/lruCache";
23import { useTheme } from "../hooks/useTheme";24import { useTheme } from "../hooks/useTheme";
24import { resolveMarkdownFileLinkTarget } from "../markdown-links";25import { resolveMarkdownFileLinkTarget, rewriteMarkdownFileUriHref } from "../markdown-links";
25import { readLocalApi } from "../localApi";26import { readLocalApi } from "../localApi";
2627
27class CodeHighlightErrorBoundary extends React.Component<28class CodeHighlightErrorBoundary extends React.Component<
...@@ -238,6 +239,9 @@ function SuspenseShikiCodeBlock({...@@ -238,6 +239,9 @@ function SuspenseShikiCodeBlock({
238function ChatMarkdown({ text, cwd, isStreaming = false }: ChatMarkdownProps) {239function ChatMarkdown({ text, cwd, isStreaming = false }: ChatMarkdownProps) {
239 const { resolvedTheme } = useTheme();240 const { resolvedTheme } = useTheme();
240 const diffThemeName = resolveDiffThemeName(resolvedTheme);241 const diffThemeName = resolveDiffThemeName(resolvedTheme);
242 const markdownUrlTransform = useCallback((href: string) => {
243 return rewriteMarkdownFileUriHref(href) ?? defaultUrlTransform(href);
244 }, []);
241 const markdownComponents = useMemo<Components>(245 const markdownComponents = useMemo<Components>(
242 () => ({246 () => ({
243 a({ node: _node, href, ...props }) {247 a({ node: _node, href, ...props }) {
...@@ -290,7 +294,11 @@ function ChatMarkdown({ text, cwd, isStreaming = false }: ChatMarkdownProps) {...@@ -290,7 +294,11 @@ function ChatMarkdown({ text, cwd, isStreaming = false }: ChatMarkdownProps) {
290294
291 return (295 return (
292 <div className="chat-markdown w-full min-w-0 text-sm leading-relaxed text-foreground/80">296 <div className="chat-markdown w-full min-w-0 text-sm leading-relaxed text-foreground/80">
293 <ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>297 <ReactMarkdown
298 remarkPlugins={[remarkGfm]}
299 components={markdownComponents}
300 urlTransform={markdownUrlTransform}
301 >
294 {text}302 {text}
295 </ReactMarkdown>303 </ReactMarkdown>
296 </div>304 </div>
apps/web/src/markdown-links.test.ts+15-1
...@@ -1,6 +1,20 @@...@@ -1,6 +1,20 @@
1import { describe, expect, it } from "vitest";1import { describe, expect, it } from "vitest";
22
3import { resolveMarkdownFileLinkTarget } from "./markdown-links";3import { resolveMarkdownFileLinkTarget, rewriteMarkdownFileUriHref } from "./markdown-links";
4
5describe("rewriteMarkdownFileUriHref", () => {
6 it("rewrites file uri hrefs into direct path hrefs", () => {
7 expect(rewriteMarkdownFileUriHref("file:///Users/julius/project/src/main.ts#L42")).toBe(
8 "/Users/julius/project/src/main.ts#L42",
9 );
10 });
11
12 it("preserves encoded octets so file paths are decoded only once later", () => {
13 expect(rewriteMarkdownFileUriHref("file:///Users/julius/project/file%2520name.md")).toBe(
14 "/Users/julius/project/file%2520name.md",
15 );
16 });
17});
418
5describe("resolveMarkdownFileLinkTarget", () => {19describe("resolveMarkdownFileLinkTarget", () => {
6 it("resolves absolute posix file paths", () => {20 it("resolves absolute posix file paths", () => {
apps/web/src/markdown-links.ts+18-7
...@@ -38,25 +38,36 @@ function stripSearchAndHash(value: string): { path: string; hash: string } {...@@ -38,25 +38,36 @@ function stripSearchAndHash(value: string): { path: string; hash: string } {
38 return { path, hash: rawHash };38 return { path, hash: rawHash };
39}39}
4040
41function parseFileUrlHref(href: string): { path: string; hash: string } | null {41function parseFileUrlHref(
42 href: string,
43 options?: { readonly decodePath?: boolean },
44): { path: string; hash: string } | null {
42 try {45 try {
43 const parsed = new URL(href);46 const parsed = new URL(href);
44 if (parsed.protocol.toLowerCase() !== "file:") return null;47 if (parsed.protocol.toLowerCase() !== "file:") return null;
4548
46 const decodedPath = safeDecode(parsed.pathname);49 const rawPath = parsed.pathname;
47 if (decodedPath.length === 0) return null;50 if (rawPath.length === 0) return null;
4851
49 // Browser URL parser encodes "C:/foo" as "/C:/foo" for file URLs.52 // Browser URL parser encodes "C:/foo" as "/C:/foo" for file URLs.
50 const normalizedPath = /^\/[A-Za-z]:[\\/]/.test(decodedPath)53 const normalizedPath = /^\/[A-Za-z]:[\\/]/.test(rawPath) ? rawPath.slice(1) : rawPath;
51 ? decodedPath.slice(1)
52 : decodedPath;
5354
54 return { path: normalizedPath, hash: parsed.hash };55 return {
56 path: options?.decodePath === false ? normalizedPath : safeDecode(normalizedPath),
57 hash: parsed.hash,
58 };
55 } catch {59 } catch {
56 return null;60 return null;
57 }61 }
58}62}
5963
64export function rewriteMarkdownFileUriHref(href: string | undefined): string | null {
65 if (!href) return null;
66 const target = parseFileUrlHref(href.trim(), { decodePath: false });
67 if (!target) return null;
68 return `${target.path}${target.hash}`;
69}
70
60function looksLikePosixFilesystemPath(path: string): boolean {71function looksLikePosixFilesystemPath(path: string): boolean {
61 if (!path.startsWith("/")) return false;72 if (!path.startsWith("/")) return false;
62 if (POSIX_FILE_ROOT_PREFIXES.some((prefix) => path.startsWith(prefix))) return true;73 if (POSIX_FILE_ROOT_PREFIXES.some((prefix) => path.startsWith(prefix))) return true;