diff --git a/src/features/messages/components/Markdown.test.tsx b/src/features/messages/components/Markdown.test.tsx new file mode 100644 index 000000000..b96f29419 --- /dev/null +++ b/src/features/messages/components/Markdown.test.tsx @@ -0,0 +1,72 @@ +// @vitest-environment jsdom +import { cleanup, createEvent, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { Markdown } from "./Markdown"; + +describe("Markdown file-like href behavior", () => { + afterEach(() => { + cleanup(); + }); + + it("preserves default anchor navigation when no file opener is provided", () => { + render( + , + ); + + const link = screen.getByText("setup").closest("a"); + expect(link?.getAttribute("href")).toBe("./docs/setup.md"); + + const clickEvent = createEvent.click(link as Element, { + bubbles: true, + cancelable: true, + }); + fireEvent(link as Element, clickEvent); + expect(clickEvent.defaultPrevented).toBe(false); + }); + + it("intercepts file-like href clicks when a file opener is provided", () => { + const onOpenFileLink = vi.fn(); + render( + , + ); + + const link = screen.getByText("setup").closest("a"); + expect(link?.getAttribute("href")).toBe("./docs/setup.md"); + + const clickEvent = createEvent.click(link as Element, { + bubbles: true, + cancelable: true, + }); + fireEvent(link as Element, clickEvent); + expect(clickEvent.defaultPrevented).toBe(true); + expect(onOpenFileLink).toHaveBeenCalledWith("./docs/setup.md"); + }); + + it("does not intercept bare relative links even when a file opener is provided", () => { + const onOpenFileLink = vi.fn(); + render( + , + ); + + const link = screen.getByText("setup").closest("a"); + expect(link?.getAttribute("href")).toBe("docs/setup.md"); + const clickEvent = createEvent.click(link as Element, { + bubbles: true, + cancelable: true, + }); + fireEvent(link as Element, clickEvent); + expect(clickEvent.defaultPrevented).toBe(false); + expect(onOpenFileLink).not.toHaveBeenCalled(); + }); +}); diff --git a/src/features/messages/components/Markdown.tsx b/src/features/messages/components/Markdown.tsx index ec450531d..91bb49e15 100644 --- a/src/features/messages/components/Markdown.tsx +++ b/src/features/messages/components/Markdown.tsx @@ -180,6 +180,151 @@ function normalizeUrlLine(line: string) { return withoutBullet; } +function safeDecodeURIComponent(value: string) { + try { + return decodeURIComponent(value); + } catch { + return null; + } +} + +function safeDecodeFileLink(url: string) { + try { + return decodeFileLink(url); + } catch { + return null; + } +} + +const FILE_LINE_SUFFIX_PATTERN = /:\d+(?::\d+)?$/; +const LIKELY_LOCAL_ABSOLUTE_PATH_PREFIXES = [ + "/Users/", + "/home/", + "/tmp/", + "/var/", + "/opt/", + "/etc/", + "/private/", + "/Volumes/", + "/mnt/", + "/usr/", + "/workspace/", + "/workspaces/", + "/root/", + "/srv/", + "/data/", +]; + +function stripPathLineSuffix(value: string) { + return value.replace(FILE_LINE_SUFFIX_PATTERN, ""); +} + +function hasLikelyFileName(path: string) { + const normalizedPath = stripPathLineSuffix(path).replace(/[\\/]+$/, ""); + const lastSegment = normalizedPath.split(/[\\/]/).pop() ?? ""; + if (!lastSegment || lastSegment === "." || lastSegment === "..") { + return false; + } + if (lastSegment.startsWith(".") && lastSegment.length > 1) { + return true; + } + return lastSegment.includes("."); +} + +function hasLikelyLocalAbsolutePrefix(path: string) { + const normalizedPath = path.replace(/\\/g, "/"); + return LIKELY_LOCAL_ABSOLUTE_PATH_PREFIXES.some((prefix) => + normalizedPath.startsWith(prefix), + ); +} + +function pathSegmentCount(path: string) { + return path.split("/").filter(Boolean).length; +} + +function isLikelyFileHref(url: string) { + const trimmed = url.trim(); + if (!trimmed) { + return false; + } + if (trimmed.startsWith("file://")) { + return true; + } + if ( + trimmed.startsWith("http://") || + trimmed.startsWith("https://") || + trimmed.startsWith("mailto:") + ) { + return false; + } + if (trimmed.startsWith("thread://") || trimmed.startsWith("/thread/")) { + return false; + } + if (trimmed.startsWith("#")) { + return false; + } + if (/[?#]/.test(trimmed)) { + return false; + } + if (/^[A-Za-z]:[\\/]/.test(trimmed) || trimmed.startsWith("\\\\")) { + return true; + } + if (trimmed.startsWith("/")) { + if (FILE_LINE_SUFFIX_PATTERN.test(trimmed)) { + return true; + } + if (hasLikelyFileName(trimmed)) { + return true; + } + return hasLikelyLocalAbsolutePrefix(trimmed) && pathSegmentCount(trimmed) >= 3; + } + if (FILE_LINE_SUFFIX_PATTERN.test(trimmed)) { + return true; + } + if (trimmed.startsWith("~/")) { + return true; + } + if (trimmed.startsWith("./") || trimmed.startsWith("../")) { + return FILE_LINE_SUFFIX_PATTERN.test(trimmed) || hasLikelyFileName(trimmed); + } + if (hasLikelyFileName(trimmed)) { + return pathSegmentCount(trimmed) >= 3; + } + return false; +} + +function toPathFromFileUrl(url: string) { + if (!url.toLowerCase().startsWith("file://")) { + return null; + } + + try { + const parsed = new URL(url); + if (parsed.protocol !== "file:") { + return null; + } + + const decodedPath = safeDecodeURIComponent(parsed.pathname) ?? parsed.pathname; + let path = decodedPath; + if (parsed.host && parsed.host !== "localhost") { + const normalizedPath = decodedPath.startsWith("/") + ? decodedPath + : `/${decodedPath}`; + path = `//${parsed.host}${normalizedPath}`; + } + if (/^\/[A-Za-z]:\//.test(path)) { + path = path.slice(1); + } + return path; + } catch { + const manualPath = url.slice("file://".length).trim(); + if (!manualPath) { + return null; + } + return safeDecodeURIComponent(manualPath) ?? manualPath; + } +} + function extractUrlLines(value: string) { const lines = value.split(/\r?\n/); const urls = lines @@ -474,9 +619,29 @@ export function Markdown({ } return trimmed; }; + const resolveHrefFilePath = (url: string) => { + if (isLikelyFileHref(url)) { + const directPath = getLinkablePath(url); + if (directPath) { + return safeDecodeURIComponent(directPath) ?? directPath; + } + } + const decodedUrl = safeDecodeURIComponent(url); + if (decodedUrl && isLikelyFileHref(decodedUrl)) { + const decodedPath = getLinkablePath(decodedUrl); + if (decodedPath) { + return decodedPath; + } + } + const fileUrlPath = toPathFromFileUrl(url); + if (!fileUrlPath) { + return null; + } + return getLinkablePath(fileUrlPath); + }; const components: Components = { a: ({ href, children }) => { - const url = href ?? ""; + const url = (href ?? "").trim(); const threadId = url.startsWith("thread://") ? url.slice("thread://".length).trim() : url.startsWith("/thread/") @@ -497,7 +662,20 @@ export function Markdown({ ); } if (isFileLinkUrl(url)) { - const path = decodeFileLink(url); + const path = safeDecodeFileLink(url); + if (!path) { + return ( + { + event.preventDefault(); + event.stopPropagation(); + }} + > + {children} + + ); + } return ( ); } + const hrefFilePath = resolveHrefFilePath(url); + if (hrefFilePath) { + const clickHandler = onOpenFileLink + ? (event: React.MouseEvent) => handleFileLinkClick(event, hrefFilePath) + : undefined; + const contextMenuHandler = onOpenFileLinkMenu + ? (event: React.MouseEvent) => handleFileLinkContextMenu(event, hrefFilePath) + : undefined; + return ( + + {children} + + ); + } const isExternal = url.startsWith("http://") || url.startsWith("https://") || diff --git a/src/features/messages/components/Messages.test.tsx b/src/features/messages/components/Messages.test.tsx index efd3990d2..14cd95398 100644 --- a/src/features/messages/components/Messages.test.tsx +++ b/src/features/messages/components/Messages.test.tsx @@ -234,6 +234,190 @@ describe("Messages", () => { ); }); + it("routes markdown href file paths through the file opener", () => { + const linkedPath = + "/Users/dimillian/Documents/Dev/CodexMonitor/src/features/messages/components/Markdown.tsx:244"; + const items: ConversationItem[] = [ + { + id: "msg-file-href-link", + kind: "message", + role: "assistant", + text: `Open [this file](${linkedPath})`, + }, + ]; + + render( + , + ); + + fireEvent.click(screen.getByText("this file")); + expect(openFileLinkMock).toHaveBeenCalledWith(linkedPath); + }); + + it("routes absolute non-whitelisted file href paths through the file opener", () => { + const linkedPath = "/custom/project/src/App.tsx:12"; + const items: ConversationItem[] = [ + { + id: "msg-file-href-absolute-non-whitelisted-link", + kind: "message", + role: "assistant", + text: `Open [app file](${linkedPath})`, + }, + ]; + + render( + , + ); + + fireEvent.click(screen.getByText("app file")); + expect(openFileLinkMock).toHaveBeenCalledWith(linkedPath); + }); + + it("decodes percent-encoded href file paths before opening", () => { + const items: ConversationItem[] = [ + { + id: "msg-file-href-encoded-link", + kind: "message", + role: "assistant", + text: "Open [guide](./docs/My%20Guide.md)", + }, + ]; + + render( + , + ); + + fireEvent.click(screen.getByText("guide")); + expect(openFileLinkMock).toHaveBeenCalledWith("./docs/My Guide.md"); + }); + + it("keeps non-file relative links as normal markdown links", () => { + const items: ConversationItem[] = [ + { + id: "msg-help-href-link", + kind: "message", + role: "assistant", + text: "See [Help](/help/getting-started)", + }, + ]; + + render( + , + ); + + const helpLink = screen.getByText("Help").closest("a"); + expect(helpLink?.getAttribute("href")).toBe("/help/getting-started"); + fireEvent.click(screen.getByText("Help")); + expect(openFileLinkMock).not.toHaveBeenCalled(); + }); + + it("keeps route-like absolute links as normal markdown links", () => { + const items: ConversationItem[] = [ + { + id: "msg-help-workspace-route-link", + kind: "message", + role: "assistant", + text: "See [Workspace Home](/workspace/settings)", + }, + ]; + + render( + , + ); + + const link = screen.getByText("Workspace Home").closest("a"); + expect(link?.getAttribute("href")).toBe("/workspace/settings"); + fireEvent.click(screen.getByText("Workspace Home")); + expect(openFileLinkMock).not.toHaveBeenCalled(); + }); + + it("keeps dot-relative non-file links as normal markdown links", () => { + const items: ConversationItem[] = [ + { + id: "msg-help-dot-relative-href-link", + kind: "message", + role: "assistant", + text: "See [Help](./help/getting-started)", + }, + ]; + + render( + , + ); + + const helpLink = screen.getByText("Help").closest("a"); + expect(helpLink?.getAttribute("href")).toBe("./help/getting-started"); + fireEvent.click(screen.getByText("Help")); + expect(openFileLinkMock).not.toHaveBeenCalled(); + }); + + it("does not crash or navigate on malformed codex-file links", () => { + const items: ConversationItem[] = [ + { + id: "msg-malformed-file-link", + kind: "message", + role: "assistant", + text: "Bad [path](codex-file:%E0%A4%A)", + }, + ]; + + render( + , + ); + + fireEvent.click(screen.getByText("path")); + expect(openFileLinkMock).not.toHaveBeenCalled(); + }); + it("hides file parent paths when message file path display is disabled", () => { const items: ConversationItem[] = [ {