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, {
1515} from "react";
1616import type { Components } from "react-markdown";
1717import ReactMarkdown from "react-markdown";
18import { defaultUrlTransform } from "react-markdown";
1819import remarkGfm from "remark-gfm";
1920import { openInPreferredEditor } from "../editorPreferences";
2021import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffRendering";
2122import { fnv1a32 } from "../lib/diffRendering";
2223import { LRUCache } from "../lib/lruCache";
2324import { useTheme } from "../hooks/useTheme";
24import { resolveMarkdownFileLinkTarget } from "../markdown-links";
25import { resolveMarkdownFileLinkTarget, rewriteMarkdownFileUriHref } from "../markdown-links";
2526import { readLocalApi } from "../localApi";
2627
2728class CodeHighlightErrorBoundary extends React.Component<
......@@ -238,6 +239,9 @@ function SuspenseShikiCodeBlock({
238239function ChatMarkdown({ text, cwd, isStreaming = false }: ChatMarkdownProps) {
239240 const { resolvedTheme } = useTheme();
240241 const diffThemeName = resolveDiffThemeName(resolvedTheme);
242 const markdownUrlTransform = useCallback((href: string) => {
243 return rewriteMarkdownFileUriHref(href) ?? defaultUrlTransform(href);
244 }, []);
241245 const markdownComponents = useMemo<Components>(
242246 () => ({
243247 a({ node: _node, href, ...props }) {
......@@ -290,7 +294,11 @@ function ChatMarkdown({ text, cwd, isStreaming = false }: ChatMarkdownProps) {
290294
291295 return (
292296 <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 >
294302 {text}
295303 </ReactMarkdown>
296304 </div>
apps/web/src/markdown-links.test.ts+15-1
......@@ -1,6 +1,20 @@
11import { 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
519describe("resolveMarkdownFileLinkTarget", () => {
620 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 } {
3838 return { path, hash: rawHash };
3939}
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 {
4245 try {
4346 const parsed = new URL(href);
4447 if (parsed.protocol.toLowerCase() !== "file:") return null;
4548
46 const decodedPath = safeDecode(parsed.pathname);
47 if (decodedPath.length === 0) return null;
49 const rawPath = parsed.pathname;
50 if (rawPath.length === 0) return null;
4851
4952 // Browser URL parser encodes "C:/foo" as "/C:/foo" for file URLs.
50 const normalizedPath = /^\/[A-Za-z]:[\\/]/.test(decodedPath)
51 ? decodedPath.slice(1)
52 : decodedPath;
53 const normalizedPath = /^\/[A-Za-z]:[\\/]/.test(rawPath) ? rawPath.slice(1) : rawPath;
5354
54 return { path: normalizedPath, hash: parsed.hash };
55 return {
56 path: options?.decodePath === false ? normalizedPath : safeDecode(normalizedPath),
57 hash: parsed.hash,
58 };
5559 } catch {
5660 return null;
5761 }
5862}
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
6071function looksLikePosixFilesystemPath(path: string): boolean {
6172 if (!path.startsWith("/")) return false;
6273 if (POSIX_FILE_ROOT_PREFIXES.some((prefix) => path.startsWith(prefix))) return true;