| author | |
| committer | |
| log | 592c234fd5d1ba8eb59197a15affbbda1150341f |
| tree | 3d47cd3ce6923c64e9bcb523338383b8655fef99 |
| parent | 66d76b5d02308dc7a8e3ff218bb0301bc20c0c2a |
| signature | Signed by PGP key B5690EEEBB952194 |
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 @@ | ||
| 1 | import "../index.css"; | ||
| 2 | |||
| 3 | import { page } from "vitest/browser"; | ||
| 4 | import { afterEach, describe, expect, it, vi } from "vitest"; | ||
| 5 | import { render } from "vitest-browser-react"; | ||
| 6 | |||
| 7 | const { 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 | |||
| 15 | vi.mock("../editorPreferences", () => ({ | ||
| 16 | openInPreferredEditor: openInPreferredEditorMock, | ||
| 17 | })); | ||
| 18 | |||
| 19 | vi.mock("../localApi", () => ({ | ||
| 20 | readLocalApi: readLocalApiMock, | ||
| 21 | })); | ||
| 22 | |||
| 23 | import ChatMarkdown from "./ChatMarkdown"; | ||
| 24 | |||
| 25 | describe("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"; |
| 16 | import type { Components } from "react-markdown"; | 16 | import type { Components } from "react-markdown"; |
| 17 | import ReactMarkdown from "react-markdown"; | 17 | import ReactMarkdown from "react-markdown"; |
| 18 | import { defaultUrlTransform } from "react-markdown"; | ||
| 18 | import remarkGfm from "remark-gfm"; | 19 | import remarkGfm from "remark-gfm"; |
| 19 | import { openInPreferredEditor } from "../editorPreferences"; | 20 | import { openInPreferredEditor } from "../editorPreferences"; |
| 20 | import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffRendering"; | 21 | import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffRendering"; |
| 21 | import { fnv1a32 } from "../lib/diffRendering"; | 22 | import { fnv1a32 } from "../lib/diffRendering"; |
| 22 | import { LRUCache } from "../lib/lruCache"; | 23 | import { LRUCache } from "../lib/lruCache"; |
| 23 | import { useTheme } from "../hooks/useTheme"; | 24 | import { useTheme } from "../hooks/useTheme"; |
| 24 | import { resolveMarkdownFileLinkTarget } from "../markdown-links"; | 25 | import { resolveMarkdownFileLinkTarget, rewriteMarkdownFileUriHref } from "../markdown-links"; |
| 25 | import { readLocalApi } from "../localApi"; | 26 | import { readLocalApi } from "../localApi"; |
| 26 | 27 | ||
| 27 | class CodeHighlightErrorBoundary extends React.Component< | 28 | class CodeHighlightErrorBoundary extends React.Component< |
| ... | @@ -238,6 +239,9 @@ function SuspenseShikiCodeBlock({ | ... | @@ -238,6 +239,9 @@ function SuspenseShikiCodeBlock({ |
| 238 | function ChatMarkdown({ text, cwd, isStreaming = false }: ChatMarkdownProps) { | 239 | function 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) { |
| 290 | 294 | ||
| 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 @@ |
| 1 | import { describe, expect, it } from "vitest"; | 1 | import { describe, expect, it } from "vitest"; |
| 2 | 2 | ||
| 3 | import { resolveMarkdownFileLinkTarget } from "./markdown-links"; | 3 | import { resolveMarkdownFileLinkTarget, rewriteMarkdownFileUriHref } from "./markdown-links"; |
| 4 | |||
| 5 | describe("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 | }); | ||
| 4 | 18 | ||
| 5 | describe("resolveMarkdownFileLinkTarget", () => { | 19 | describe("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 | } |
| 40 | 40 | ||
| 41 | function parseFileUrlHref(href: string): { path: string; hash: string } | null { | 41 | function 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; |
| 45 | 48 | ||
| 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; |
| 48 | 51 | ||
| 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; | ||
| 53 | 54 | ||
| 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 | } |
| 59 | 63 | ||
| 64 | export 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 | |||
| 60 | function looksLikePosixFilesystemPath(path: string): boolean { | 71 | function 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; |