From 2db1765994429fb3165f028b7d66bccb6274e697 Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Thu, 9 Apr 2026 19:06:18 -0500 Subject: [PATCH] fix: make file uri links clickable --- .../src/components/ChatMarkdown.browser.tsx | 76 +++++++++++++++++++ apps/web/src/components/ChatMarkdown.tsx | 12 ++- apps/web/src/markdown-links.test.ts | 16 +++- apps/web/src/markdown-links.ts | 25 ++++-- 4 files changed, 119 insertions(+), 10 deletions(-) create mode 100644 apps/web/src/components/ChatMarkdown.browser.tsx diff --git a/apps/web/src/components/ChatMarkdown.browser.tsx b/apps/web/src/components/ChatMarkdown.browser.tsx new file mode 100644 index 0000000000..28f8b5fb53 --- /dev/null +++ b/apps/web/src/components/ChatMarkdown.browser.tsx @@ -0,0 +1,76 @@ +import "../index.css"; + +import { page } from "vitest/browser"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { render } from "vitest-browser-react"; + +const { openInPreferredEditorMock, readLocalApiMock } = vi.hoisted(() => ({ + openInPreferredEditorMock: vi.fn(async () => "vscode"), + readLocalApiMock: vi.fn(() => ({ + server: { getConfig: vi.fn(async () => ({ availableEditors: ["vscode"] })) }, + shell: { openInEditor: vi.fn(async () => undefined) }, + })), +})); + +vi.mock("../editorPreferences", () => ({ + openInPreferredEditor: openInPreferredEditorMock, +})); + +vi.mock("../localApi", () => ({ + readLocalApi: readLocalApiMock, +})); + +import ChatMarkdown from "./ChatMarkdown"; + +describe("ChatMarkdown", () => { + afterEach(() => { + openInPreferredEditorMock.mockClear(); + readLocalApiMock.mockClear(); + localStorage.clear(); + document.body.innerHTML = ""; + }); + + it("rewrites file uri hrefs into direct paths before rendering", async () => { + const filePath = + "/Users/yashsingh/p/sco/claude-code-extract/src/utils/permissions/PermissionRule.ts"; + const screen = await render( + , + ); + + try { + const link = page.getByRole("link", { name: "PermissionRule.ts" }); + await expect.element(link).toBeInTheDocument(); + await expect.element(link).toHaveAttribute("href", filePath); + + await link.click(); + + await vi.waitFor(() => { + expect(openInPreferredEditorMock).toHaveBeenCalledWith(expect.anything(), filePath); + }); + } finally { + await screen.unmount(); + } + }); + + it("keeps line anchors working after rewriting file uri hrefs", async () => { + const filePath = + "/Users/yashsingh/p/sco/claude-code-extract/src/utils/permissions/PermissionRule.ts"; + const screen = await render( + , + ); + + try { + const link = page.getByRole("link", { name: "PermissionRule.ts:1" }); + await expect.element(link).toBeInTheDocument(); + await expect.element(link).toHaveAttribute("href", `${filePath}#L1`); + + await link.click(); + + await vi.waitFor(() => { + expect(openInPreferredEditorMock).toHaveBeenCalledWith(expect.anything(), `${filePath}:1`); + }); + } finally { + await screen.unmount(); + } + }); +}); diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 11926cd95c..327e77e57c 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -15,13 +15,14 @@ import React, { } from "react"; import type { Components } from "react-markdown"; import ReactMarkdown from "react-markdown"; +import { defaultUrlTransform } from "react-markdown"; import remarkGfm from "remark-gfm"; import { openInPreferredEditor } from "../editorPreferences"; import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffRendering"; import { fnv1a32 } from "../lib/diffRendering"; import { LRUCache } from "../lib/lruCache"; import { useTheme } from "../hooks/useTheme"; -import { resolveMarkdownFileLinkTarget } from "../markdown-links"; +import { resolveMarkdownFileLinkTarget, rewriteMarkdownFileUriHref } from "../markdown-links"; import { readLocalApi } from "../localApi"; class CodeHighlightErrorBoundary extends React.Component< @@ -238,6 +239,9 @@ function SuspenseShikiCodeBlock({ function ChatMarkdown({ text, cwd, isStreaming = false }: ChatMarkdownProps) { const { resolvedTheme } = useTheme(); const diffThemeName = resolveDiffThemeName(resolvedTheme); + const markdownUrlTransform = useCallback((href: string) => { + return rewriteMarkdownFileUriHref(href) ?? defaultUrlTransform(href); + }, []); const markdownComponents = useMemo( () => ({ a({ node: _node, href, ...props }) { @@ -290,7 +294,11 @@ function ChatMarkdown({ text, cwd, isStreaming = false }: ChatMarkdownProps) { return (
- + {text}
diff --git a/apps/web/src/markdown-links.test.ts b/apps/web/src/markdown-links.test.ts index 5f0a0cd711..d3ca8bc99a 100644 --- a/apps/web/src/markdown-links.test.ts +++ b/apps/web/src/markdown-links.test.ts @@ -1,6 +1,20 @@ import { describe, expect, it } from "vitest"; -import { resolveMarkdownFileLinkTarget } from "./markdown-links"; +import { resolveMarkdownFileLinkTarget, rewriteMarkdownFileUriHref } from "./markdown-links"; + +describe("rewriteMarkdownFileUriHref", () => { + it("rewrites file uri hrefs into direct path hrefs", () => { + expect(rewriteMarkdownFileUriHref("file:///Users/julius/project/src/main.ts#L42")).toBe( + "/Users/julius/project/src/main.ts#L42", + ); + }); + + it("preserves encoded octets so file paths are decoded only once later", () => { + expect(rewriteMarkdownFileUriHref("file:///Users/julius/project/file%2520name.md")).toBe( + "/Users/julius/project/file%2520name.md", + ); + }); +}); describe("resolveMarkdownFileLinkTarget", () => { it("resolves absolute posix file paths", () => { diff --git a/apps/web/src/markdown-links.ts b/apps/web/src/markdown-links.ts index aa5f0f1498..b5dcab0100 100644 --- a/apps/web/src/markdown-links.ts +++ b/apps/web/src/markdown-links.ts @@ -38,25 +38,36 @@ function stripSearchAndHash(value: string): { path: string; hash: string } { return { path, hash: rawHash }; } -function parseFileUrlHref(href: string): { path: string; hash: string } | null { +function parseFileUrlHref( + href: string, + options?: { readonly decodePath?: boolean }, +): { path: string; hash: string } | null { try { const parsed = new URL(href); if (parsed.protocol.toLowerCase() !== "file:") return null; - const decodedPath = safeDecode(parsed.pathname); - if (decodedPath.length === 0) return null; + const rawPath = parsed.pathname; + if (rawPath.length === 0) return null; // Browser URL parser encodes "C:/foo" as "/C:/foo" for file URLs. - const normalizedPath = /^\/[A-Za-z]:[\\/]/.test(decodedPath) - ? decodedPath.slice(1) - : decodedPath; + const normalizedPath = /^\/[A-Za-z]:[\\/]/.test(rawPath) ? rawPath.slice(1) : rawPath; - return { path: normalizedPath, hash: parsed.hash }; + return { + path: options?.decodePath === false ? normalizedPath : safeDecode(normalizedPath), + hash: parsed.hash, + }; } catch { return null; } } +export function rewriteMarkdownFileUriHref(href: string | undefined): string | null { + if (!href) return null; + const target = parseFileUrlHref(href.trim(), { decodePath: false }); + if (!target) return null; + return `${target.path}${target.hash}`; +} + function looksLikePosixFilesystemPath(path: string): boolean { if (!path.startsWith("/")) return false; if (POSIX_FILE_ROOT_PREFIXES.some((prefix) => path.startsWith(prefix))) return true;