diff --git a/src/features/messages/components/Markdown.test.tsx b/src/features/messages/components/Markdown.test.tsx index dd75ce237..05f18eef7 100644 --- a/src/features/messages/components/Markdown.test.tsx +++ b/src/features/messages/components/Markdown.test.tsx @@ -3,6 +3,15 @@ import { cleanup, createEvent, fireEvent, render, screen } from "@testing-librar import { afterEach, describe, expect, it, vi } from "vitest"; import { Markdown } from "./Markdown"; +function expectOpenedFileTarget( + mock: ReturnType, + path: string, + line: number | null = null, + column: number | null = null, +) { + expect(mock).toHaveBeenCalledWith({ path, line, column }); +} + describe("Markdown file-like href behavior", () => { afterEach(() => { cleanup(); @@ -46,7 +55,7 @@ describe("Markdown file-like href behavior", () => { }); fireEvent(link as Element, clickEvent); expect(clickEvent.defaultPrevented).toBe(true); - expect(onOpenFileLink).toHaveBeenCalledWith("./docs/setup.md"); + expectOpenedFileTarget(onOpenFileLink, "./docs/setup.md"); }); it("prevents bare relative link navigation without treating it as a file", () => { @@ -89,7 +98,7 @@ describe("Markdown file-like href behavior", () => { }); fireEvent(link as Element, clickEvent); expect(clickEvent.defaultPrevented).toBe(true); - expect(onOpenFileLink).toHaveBeenCalledWith("/workspace/src/example.ts"); + expectOpenedFileTarget(onOpenFileLink, "/workspace/src/example.ts"); }); it("still intercepts dotless workspace file hrefs when a file opener is provided", () => { @@ -112,7 +121,7 @@ describe("Markdown file-like href behavior", () => { }); fireEvent(link as Element, clickEvent); expect(clickEvent.defaultPrevented).toBe(true); - expect(onOpenFileLink).toHaveBeenCalledWith("/workspace/CodexMonitor/LICENSE"); + expectOpenedFileTarget(onOpenFileLink, "/workspace/CodexMonitor/LICENSE"); }); it("intercepts mounted workspace links outside the old root allowlist", () => { @@ -135,7 +144,7 @@ describe("Markdown file-like href behavior", () => { }); fireEvent(link as Element, clickEvent); expect(clickEvent.defaultPrevented).toBe(true); - expect(onOpenFileLink).toHaveBeenCalledWith("/workspace/.github/workflows"); + expectOpenedFileTarget(onOpenFileLink, "/workspace/.github/workflows"); }); it("intercepts mounted workspace directory links that resolve relative to the workspace", () => { @@ -158,20 +167,43 @@ describe("Markdown file-like href behavior", () => { }); fireEvent(link as Element, clickEvent); expect(clickEvent.defaultPrevented).toBe(true); - expect(onOpenFileLink).toHaveBeenCalledWith("/workspace/dist/assets"); + expectOpenedFileTarget(onOpenFileLink, "/workspace/dist/assets"); }); - it("keeps generic workspace routes as normal markdown links", () => { + it("keeps exact workspace routes as normal markdown links", () => { const onOpenFileLink = vi.fn(); render( , ); + const link = screen.getByText("reviews").closest("a"); + expect(link?.getAttribute("href")).toBe("/workspace/reviews"); + + const clickEvent = createEvent.click(link as Element, { + bubbles: true, + cancelable: true, + }); + fireEvent(link as Element, clickEvent); + expect(clickEvent.defaultPrevented).toBe(true); + expect(onOpenFileLink).not.toHaveBeenCalled(); + }); + + it("keeps nested workspace reviews routes local even when the workspace basename matches", () => { + const onOpenFileLink = vi.fn(); + render( + , + ); + const link = screen.getByText("overview").closest("a"); expect(link?.getAttribute("href")).toBe("/workspace/reviews/overview"); @@ -207,6 +239,29 @@ describe("Markdown file-like href behavior", () => { expect(onOpenFileLink).not.toHaveBeenCalled(); }); + it("keeps nested reviews routes local even when the workspace basename matches the route segment", () => { + const onOpenFileLink = vi.fn(); + render( + , + ); + + const link = screen.getByText("overview").closest("a"); + expect(link?.getAttribute("href")).toBe("/workspaces/team/reviews/overview"); + + const clickEvent = createEvent.click(link as Element, { + bubbles: true, + cancelable: true, + }); + fireEvent(link as Element, clickEvent); + expect(clickEvent.defaultPrevented).toBe(true); + expect(onOpenFileLink).not.toHaveBeenCalled(); + }); + it("still intercepts nested workspace file hrefs when a file opener is provided", () => { const onOpenFileLink = vi.fn(); render( @@ -227,7 +282,30 @@ describe("Markdown file-like href behavior", () => { }); fireEvent(link as Element, clickEvent); expect(clickEvent.defaultPrevented).toBe(true); - expect(onOpenFileLink).toHaveBeenCalledWith("/workspaces/team/CodexMonitor/src"); + expectOpenedFileTarget(onOpenFileLink, "/workspaces/team/CodexMonitor/src"); + }); + + it("treats extensionless paths under /workspace/settings as files", () => { + const onOpenFileLink = vi.fn(); + render( + , + ); + + const link = screen.getByText("license").closest("a"); + expect(link?.getAttribute("href")).toBe("/workspace/settings/LICENSE"); + + const clickEvent = createEvent.click(link as Element, { + bubbles: true, + cancelable: true, + }); + fireEvent(link as Element, clickEvent); + expect(clickEvent.defaultPrevented).toBe(true); + expectOpenedFileTarget(onOpenFileLink, "/workspace/settings/LICENSE"); }); it("intercepts file hrefs that use #L line anchors", () => { @@ -249,7 +327,52 @@ describe("Markdown file-like href behavior", () => { }); fireEvent(link as Element, clickEvent); expect(clickEvent.defaultPrevented).toBe(true); - expect(onOpenFileLink).toHaveBeenCalledWith("./docs/setup.md:12"); + expectOpenedFileTarget(onOpenFileLink, "./docs/setup.md", 12); + }); + + it("intercepts Windows absolute file hrefs with #L anchors and preserves the tooltip", () => { + const onOpenFileLink = vi.fn(); + const onOpenFileLinkMenu = vi.fn(); + const linkedPath = + "I:\\gpt-projects\\CodexMonitor\\src\\features\\settings\\components\\sections\\SettingsDisplaySection.tsx#L422"; + render( + , + ); + + const link = screen.getByText("SettingsDisplaySection.tsx").closest("a"); + expect(link?.getAttribute("href")).toBe( + "I:%5Cgpt-projects%5CCodexMonitor%5Csrc%5Cfeatures%5Csettings%5Ccomponents%5Csections%5CSettingsDisplaySection.tsx#L422", + ); + expect(link?.getAttribute("title")).toBe( + "I:\\gpt-projects\\CodexMonitor\\src\\features\\settings\\components\\sections\\SettingsDisplaySection.tsx:422", + ); + + const clickEvent = createEvent.click(link as Element, { + bubbles: true, + cancelable: true, + }); + fireEvent(link as Element, clickEvent); + expect(clickEvent.defaultPrevented).toBe(true); + expectOpenedFileTarget( + onOpenFileLink, + "I:\\gpt-projects\\CodexMonitor\\src\\features\\settings\\components\\sections\\SettingsDisplaySection.tsx", + 422, + ); + + fireEvent.contextMenu(link as Element); + expect(onOpenFileLinkMenu).toHaveBeenCalledWith( + expect.anything(), + { + path: "I:\\gpt-projects\\CodexMonitor\\src\\features\\settings\\components\\sections\\SettingsDisplaySection.tsx", + line: 422, + column: null, + }, + ); }); it("prevents unsupported route fragments without treating them as file links", () => { @@ -274,6 +397,78 @@ describe("Markdown file-like href behavior", () => { expect(onOpenFileLink).not.toHaveBeenCalled(); }); + it("keeps workspace settings #L anchors as local routes", () => { + const onOpenFileLink = vi.fn(); + render( + , + ); + + const link = screen.getByText("settings").closest("a"); + expect(link?.getAttribute("href")).toBe("/workspace/settings#L12"); + + const clickEvent = createEvent.click(link as Element, { + bubbles: true, + cancelable: true, + }); + fireEvent(link as Element, clickEvent); + expect(clickEvent.defaultPrevented).toBe(true); + expect(onOpenFileLink).not.toHaveBeenCalled(); + }); + + it("keeps workspace reviews #L anchors as local routes", () => { + const onOpenFileLink = vi.fn(); + render( + , + ); + + const link = screen.getByText("reviews").closest("a"); + expect(link?.getAttribute("href")).toBe("/workspace/reviews#L9"); + + const clickEvent = createEvent.click(link as Element, { + bubbles: true, + cancelable: true, + }); + fireEvent(link as Element, clickEvent); + expect(clickEvent.defaultPrevented).toBe(true); + expect(onOpenFileLink).not.toHaveBeenCalled(); + }); + + it("does not linkify workspace settings #L anchors in plain text", () => { + const { container } = render( + , + ); + + expect(container.querySelector(".message-file-link")).toBeNull(); + expect(container.textContent).toContain("/workspace/settings#L12"); + }); + + it("does not turn workspace review #L anchors in inline code into file links", () => { + const { container } = render( + , + ); + + expect(container.querySelector(".message-file-link")).toBeNull(); + expect(container.querySelector("code")?.textContent).toBe("/workspace/reviews#L9"); + }); + it("does not turn natural-language slash phrases into file links", () => { const { container } = render( { expect(fileLinks[0]?.textContent).toContain("setup.md"); expect(fileLinks[1]?.textContent).toContain("index.ts"); }); + + it("linkifies extensionless mounted file paths under reserved workspace names", () => { + const onOpenFileLink = vi.fn(); + const { container } = render( + , + ); + + const link = container.querySelector('a[href^="codex-file:"]'); + expect(link).not.toBeNull(); + + const clickEvent = createEvent.click(link as Element, { + bubbles: true, + cancelable: true, + }); + fireEvent(link as Element, clickEvent); + expect(clickEvent.defaultPrevented).toBe(true); + expect(onOpenFileLink).toHaveBeenCalledWith("/workspaces/team/reviews/bin/tool"); + }); + + it("turns Windows absolute paths in plain text into file links", () => { + const { container } = render( + , + ); + + const fileLinks = [...container.querySelectorAll(".message-file-link")]; + expect(fileLinks).toHaveLength(1); + expect(fileLinks[0]?.textContent).toContain("App.tsx"); + expect(fileLinks[0]?.getAttribute("title")).toBe( + "I:\\gpt-projects\\CodexMonitor\\src\\App.tsx:12", + ); + }); + + it("normalizes plain-text Windows #L anchors before opening file links", () => { + const onOpenFileLink = vi.fn(); + const { container } = render( + , + ); + + const fileLinks = [...container.querySelectorAll(".message-file-link")]; + expect(fileLinks).toHaveLength(1); + expect(fileLinks[0]?.getAttribute("title")).toBe( + "I:\\gpt-projects\\CodexMonitor\\src\\App.tsx:12", + ); + + const clickEvent = createEvent.click(fileLinks[0] as Element, { + bubbles: true, + cancelable: true, + }); + fireEvent(fileLinks[0] as Element, clickEvent); + expect(clickEvent.defaultPrevented).toBe(true); + expect(onOpenFileLink).toHaveBeenCalledWith( + "I:\\gpt-projects\\CodexMonitor\\src\\App.tsx:12", + ); + }); + + it("does not linkify Windows paths embedded inside file URLs", () => { + const { container } = render( + , + ); + + expect(container.querySelector(".message-file-link")).toBeNull(); + expect(container.textContent).toContain("file:///C:/repo/src/App.tsx"); + }); + + it("ignores non-line file URL fragments when opening file hrefs", () => { + const onOpenFileLink = vi.fn(); + render( + , + ); + + const link = screen.getByText("report").closest("a"); + expect(link?.getAttribute("href")).toBe("file:///tmp/report.md#overview"); + + const clickEvent = createEvent.click(link as Element, { + bubbles: true, + cancelable: true, + }); + fireEvent(link as Element, clickEvent); + expect(clickEvent.defaultPrevented).toBe(true); + expectOpenedFileTarget(onOpenFileLink, "/tmp/report.md"); + }); + + it("keeps line anchors when opening file URLs", () => { + const onOpenFileLink = vi.fn(); + render( + , + ); + + const link = screen.getByText("report").closest("a"); + expect(link?.getAttribute("href")).toBe("file:///tmp/report.md#L12"); + + const clickEvent = createEvent.click(link as Element, { + bubbles: true, + cancelable: true, + }); + fireEvent(link as Element, clickEvent); + expect(clickEvent.defaultPrevented).toBe(true); + expectOpenedFileTarget(onOpenFileLink, "/tmp/report.md", 12); + }); + + it("preserves Windows drive paths when file URL decoding encounters an unescaped percent", () => { + const onOpenFileLink = vi.fn(); + render( + , + ); + + const link = screen.getByText("report").closest("a"); + expect(link?.getAttribute("href")).toBe("file:///C:/repo/100%25.tsx#L12"); + + const clickEvent = createEvent.click(link as Element, { + bubbles: true, + cancelable: true, + }); + fireEvent(link as Element, clickEvent); + expect(clickEvent.defaultPrevented).toBe(true); + expectOpenedFileTarget(onOpenFileLink, "C:/repo/100%.tsx", 12); + }); + + it("preserves UNC host paths when file URL decoding encounters an unescaped percent", () => { + const onOpenFileLink = vi.fn(); + render( + , + ); + + const link = screen.getByText("report").closest("a"); + expect(link?.getAttribute("href")).toBe("file://server/share/100%25.tsx#L12"); + + const clickEvent = createEvent.click(link as Element, { + bubbles: true, + cancelable: true, + }); + fireEvent(link as Element, clickEvent); + expect(clickEvent.defaultPrevented).toBe(true); + expectOpenedFileTarget(onOpenFileLink, "//server/share/100%.tsx", 12); + }); + + it("keeps encoded #L-like filenames intact when opening file URLs", () => { + const onOpenFileLink = vi.fn(); + render( + , + ); + + const link = screen.getByText("report").closest("a"); + expect(link?.getAttribute("href")).toBe("file:///tmp/report%23L12.md"); + + const clickEvent = createEvent.click(link as Element, { + bubbles: true, + cancelable: true, + }); + fireEvent(link as Element, clickEvent); + expect(clickEvent.defaultPrevented).toBe(true); + expectOpenedFileTarget(onOpenFileLink, "/tmp/report#L12.md"); + }); + + it("keeps encoded bare #L-like filenames intact when opening file URLs", () => { + const onOpenFileLink = vi.fn(); + render( + , + ); + + const link = screen.getByText("report").closest("a"); + expect(link?.getAttribute("href")).toBe("file:///tmp/%23L12"); + + const clickEvent = createEvent.click(link as Element, { + bubbles: true, + cancelable: true, + }); + fireEvent(link as Element, clickEvent); + expect(clickEvent.defaultPrevented).toBe(true); + expectOpenedFileTarget(onOpenFileLink, "/tmp/#L12"); + }); + + it("keeps encoded #L-like filename endings intact when opening markdown hrefs", () => { + const onOpenFileLink = vi.fn(); + render( + , + ); + + const link = screen.getByText("report").closest("a"); + expect(link?.getAttribute("href")).toBe("./report.md%23L12"); + + const clickEvent = createEvent.click(link as Element, { + bubbles: true, + cancelable: true, + }); + fireEvent(link as Element, clickEvent); + expect(clickEvent.defaultPrevented).toBe(true); + expectOpenedFileTarget(onOpenFileLink, "./report.md#L12"); + }); + + it("keeps encoded #L-like filename column endings intact when opening markdown hrefs", () => { + const onOpenFileLink = vi.fn(); + render( + , + ); + + const link = screen.getByText("report").closest("a"); + expect(link?.getAttribute("href")).toBe("./report.md%23L12C3"); + + const clickEvent = createEvent.click(link as Element, { + bubbles: true, + cancelable: true, + }); + fireEvent(link as Element, clickEvent); + expect(clickEvent.defaultPrevented).toBe(true); + expectOpenedFileTarget(onOpenFileLink, "./report.md#L12C3"); + }); + + it("still opens mounted file links when the workspace basename is settings", () => { + const onOpenFileLink = vi.fn(); + render( + , + ); + + const link = screen.getByText("app").closest("a"); + expect(link?.getAttribute("href")).toBe("/workspace/settings/src/App.tsx"); + + const clickEvent = createEvent.click(link as Element, { + bubbles: true, + cancelable: true, + }); + fireEvent(link as Element, clickEvent); + expect(clickEvent.defaultPrevented).toBe(true); + expectOpenedFileTarget(onOpenFileLink, "/workspace/settings/src/App.tsx"); + }); + + it("keeps nested settings routes local when the workspace basename is settings", () => { + const onOpenFileLink = vi.fn(); + render( + , + ); + + const link = screen.getByText("profile").closest("a"); + expect(link?.getAttribute("href")).toBe("/workspace/settings/profile"); + + const clickEvent = createEvent.click(link as Element, { + bubbles: true, + cancelable: true, + }); + fireEvent(link as Element, clickEvent); + expect(clickEvent.defaultPrevented).toBe(true); + expect(onOpenFileLink).not.toHaveBeenCalled(); + }); + + it("linkifies mounted file paths when the nested workspace basename is reviews", () => { + const onOpenFileLink = vi.fn(); + const { container } = render( + , + ); + + const link = container.querySelector('a[href^="codex-file:"]'); + expect(link).not.toBeNull(); + + const clickEvent = createEvent.click(link as Element, { + bubbles: true, + cancelable: true, + }); + fireEvent(link as Element, clickEvent); + expect(clickEvent.defaultPrevented).toBe(true); + expect(onOpenFileLink).toHaveBeenCalledWith("/workspaces/team/reviews/src/App.tsx"); + }); }); diff --git a/src/features/messages/components/Markdown.tsx b/src/features/messages/components/Markdown.tsx index 503aef0b0..2829243bd 100644 --- a/src/features/messages/components/Markdown.tsx +++ b/src/features/messages/components/Markdown.tsx @@ -2,6 +2,15 @@ import { useEffect, useRef, useState, type ReactNode, type MouseEvent } from "re import ReactMarkdown, { type Components } from "react-markdown"; import remarkGfm from "remark-gfm"; import { openUrl } from "@tauri-apps/plugin-opener"; +import { + type FileLinkTarget, + type ParsedFileLocation, + formatFileLocation, + isKnownLocalWorkspaceRoutePath as isKnownLocalWorkspaceRouteFilePath, + normalizeFileLinkPath, + parseFileLocation, + parseFileUrlLocation, +} from "../../../utils/fileLinks"; import { decodeFileLink, isFileLinkUrl, @@ -19,8 +28,8 @@ type MarkdownProps = { codeBlockCopyUseModifier?: boolean; showFilePath?: boolean; workspacePath?: string | null; - onOpenFileLink?: (path: string) => void; - onOpenFileLinkMenu?: (event: React.MouseEvent, path: string) => void; + onOpenFileLink?: (path: FileLinkTarget) => void; + onOpenFileLinkMenu?: (event: React.MouseEvent, path: FileLinkTarget) => void; onOpenThreadLink?: (threadId: string) => void; }; @@ -54,6 +63,15 @@ type ParsedFileReference = { parentPath: string | null; }; +function toParsedFileTarget(target: FileLinkTarget): ParsedFileLocation { + return typeof target === "string" ? parseFileLocation(target) : target; +} + +function formatFileTarget(target: FileLinkTarget) { + const parsed = toParsedFileTarget(target); + return formatFileLocation(parsed.path, parsed.line, parsed.column); +} + function normalizePathSeparators(path: string) { return path.replace(/\\/g, "/"); } @@ -196,9 +214,6 @@ function safeDecodeFileLink(url: string) { return null; } } - -const FILE_LINE_SUFFIX_PATTERN = /:\d+(?::\d+)?$/; -const FILE_HASH_LINE_SUFFIX_PATTERN = /^#L(\d+)(?:C(\d+))?$/i; const LIKELY_LOCAL_ABSOLUTE_PATH_PREFIXES = [ "/Users/", "/home/", @@ -217,10 +232,9 @@ const LIKELY_LOCAL_ABSOLUTE_PATH_PREFIXES = [ "/data/", ]; const WORKSPACE_ROUTE_PREFIXES = ["/workspace/", "/workspaces/"]; -const LOCAL_WORKSPACE_ROUTE_SEGMENTS = new Set(["reviews", "settings"]); function stripPathLineSuffix(value: string) { - return value.replace(FILE_LINE_SUFFIX_PATTERN, ""); + return parseFileLocation(value).path; } function hasLikelyFileName(path: string) { @@ -263,24 +277,11 @@ function hasLikelyWorkspaceNameSegment(segment: string) { return /[A-Z]/.test(segment) || /[._-]/.test(segment); } -function isKnownLocalWorkspaceRoutePath(path: string) { - const mountedPath = splitWorkspaceRoutePath(path); - if (!mountedPath || mountedPath.segments.length === 0) { - return false; - } - - const routeSegment = - mountedPath.prefix === "/workspace/" - ? mountedPath.segments[0] - : mountedPath.segments[1]; - return Boolean(routeSegment) && LOCAL_WORKSPACE_ROUTE_SEGMENTS.has(routeSegment); -} - function isLikelyMountedWorkspaceFilePath( path: string, workspacePath?: string | null, ) { - if (isKnownLocalWorkspaceRoutePath(path)) { + if (isKnownLocalWorkspaceRouteFilePath(path)) { return false; } if (resolveMountedWorkspacePath(path, workspacePath) !== null) { @@ -313,24 +314,6 @@ function pathSegmentCount(path: string) { return path.split("/").filter(Boolean).length; } -function toPathFromFileHashAnchor( - url: string, - workspacePath?: string | null, -) { - const hashIndex = url.indexOf("#"); - if (hashIndex <= 0) { - return null; - } - const basePath = url.slice(0, hashIndex).trim(); - const hash = url.slice(hashIndex).trim(); - const match = hash.match(FILE_HASH_LINE_SUFFIX_PATTERN); - if (!basePath || !match || !isLikelyFileHref(basePath, workspacePath)) { - return null; - } - const [, line, column] = match; - return `${basePath}:${line}${column ? `:${column}` : ""}`; -} - function isLikelyFileHref( url: string, workspacePath?: string | null, @@ -355,68 +338,44 @@ function isLikelyFileHref( if (trimmed.startsWith("#")) { return false; } - if (/[?#]/.test(trimmed)) { + const parsedLocation = parseFileLocation(trimmed); + const pathOnly = parsedLocation.path.trim(); + if (/[?#]/.test(pathOnly)) { return false; } - if (/^[A-Za-z]:[\\/]/.test(trimmed) || trimmed.startsWith("\\\\")) { + if (/^[A-Za-z]:[\\/]/.test(pathOnly) || pathOnly.startsWith("\\\\")) { return true; } - if (trimmed.startsWith("/")) { - if (FILE_LINE_SUFFIX_PATTERN.test(trimmed)) { + if (pathOnly.startsWith("/")) { + if (parsedLocation.line !== null) { + const normalizedPath = pathOnly.replace(/\\/g, "/"); + if ( + WORKSPACE_ROUTE_PREFIXES.some((prefix) => normalizedPath.startsWith(prefix)) + ) { + return isLikelyMountedWorkspaceFilePath(normalizedPath, workspacePath); + } return true; } - if (hasLikelyFileName(trimmed)) { + if (hasLikelyFileName(pathOnly)) { return true; } - return usesAbsolutePathDepthFallback(trimmed, workspacePath); + return usesAbsolutePathDepthFallback(pathOnly, workspacePath); } - if (FILE_LINE_SUFFIX_PATTERN.test(trimmed)) { + if (parsedLocation.line !== null) { return true; } - if (trimmed.startsWith("~/")) { + if (pathOnly.startsWith("~/")) { return true; } - if (trimmed.startsWith("./") || trimmed.startsWith("../")) { - return FILE_LINE_SUFFIX_PATTERN.test(trimmed) || hasLikelyFileName(trimmed); + if (pathOnly.startsWith("./") || pathOnly.startsWith("../")) { + return parsedLocation.line !== null || hasLikelyFileName(pathOnly); } - if (hasLikelyFileName(trimmed)) { - return pathSegmentCount(trimmed) >= 3; + if (hasLikelyFileName(pathOnly)) { + return pathSegmentCount(pathOnly) >= 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 @@ -529,17 +488,24 @@ function LinkBlock({ urls }: LinkBlockProps) { } function parseFileReference( - rawPath: string, + rawPath: FileLinkTarget, workspacePath?: string | null, ): ParsedFileReference { - const trimmed = rawPath.trim(); - const lineMatch = trimmed.match(/^(.*?):(\d+(?::\d+)?)$/); - const pathWithoutLine = (lineMatch?.[1] ?? trimmed).trim(); - const lineLabel = lineMatch?.[2] ?? null; + const parsedLocation = toParsedFileTarget(rawPath); + const fullPath = formatFileLocation( + parsedLocation.path, + parsedLocation.line, + parsedLocation.column, + ); + const pathWithoutLine = parsedLocation.path.trim(); + const lineLabel = + parsedLocation.line === null + ? null + : `${parsedLocation.line}${parsedLocation.column !== null ? `:${parsedLocation.column}` : ""}`; const displayPath = relativeDisplayPath(pathWithoutLine, workspacePath); const normalizedPath = trimTrailingPathSeparators(displayPath) || displayPath; const lastSlashIndex = normalizedPath.lastIndexOf("/"); - const fallbackFile = normalizedPath || trimmed; + const fallbackFile = normalizedPath || fullPath; const fileName = lastSlashIndex >= 0 ? normalizedPath.slice(lastSlashIndex + 1) : fallbackFile; const rawParentPath = @@ -547,7 +513,7 @@ function parseFileReference( const parentPath = rawParentPath || (normalizedPath.startsWith("/") ? "/" : null); return { - fullPath: trimmed, + fullPath, fileName, lineLabel, parentPath, @@ -563,11 +529,11 @@ function FileReferenceLink({ onContextMenu, }: { href: string; - rawPath: string; + rawPath: FileLinkTarget; showFilePath: boolean; workspacePath?: string | null; - onClick: (event: React.MouseEvent, path: string) => void; - onContextMenu: (event: React.MouseEvent, path: string) => void; + onClick: (event: React.MouseEvent, path: FileLinkTarget) => void; + onContextMenu: (event: React.MouseEvent, path: FileLinkTarget) => void; }) { const { fullPath, fileName, lineLabel, parentPath } = parseFileReference( rawPath, @@ -685,7 +651,7 @@ export function Markdown({ const content = codeBlock ? `\`\`\`\n${normalizedValue}\n\`\`\`` : normalizedValue; - const handleFileLinkClick = (event: React.MouseEvent, path: string) => { + const handleFileLinkClick = (event: React.MouseEvent, path: FileLinkTarget) => { event.preventDefault(); event.stopPropagation(); onOpenFileLink?.(path); @@ -696,63 +662,54 @@ export function Markdown({ }; const handleFileLinkContextMenu = ( event: React.MouseEvent, - path: string, + path: FileLinkTarget, ) => { event.preventDefault(); event.stopPropagation(); onOpenFileLinkMenu?.(event, path); }; - const filePathWithOptionalLineMatch = /^(.+?)(:\d+(?::\d+)?)?$/; const getLinkablePath = (rawValue: string) => { - const trimmed = rawValue.trim(); - if (!trimmed) { + const normalizedPath = normalizeFileLinkPath(rawValue).trim(); + if (!normalizedPath) { + return null; + } + if (isKnownLocalWorkspaceRouteFilePath(normalizedPath)) { return null; } - const match = trimmed.match(filePathWithOptionalLineMatch); - const pathOnly = match?.[1]?.trim() ?? trimmed; - if (!pathOnly || !isLinkableFilePath(pathOnly)) { + if (!isLinkableFilePath(normalizedPath)) { return null; } - return trimmed; + return normalizedPath; }; const resolveHrefFilePath = (url: string) => { - const hashAnchorPath = toPathFromFileHashAnchor(url, workspacePath); - if (hashAnchorPath) { - const anchoredPath = getLinkablePath(hashAnchorPath); - if (anchoredPath) { - return safeDecodeURIComponent(anchoredPath) ?? anchoredPath; - } + const fileUrlPath = parseFileUrlLocation(url); + if (fileUrlPath) { + return fileUrlPath; } - if (isLikelyFileHref(url, workspacePath)) { - const directPath = getLinkablePath(url); - if (directPath) { - return safeDecodeURIComponent(directPath) ?? directPath; + const rawCandidates = [url, safeDecodeURIComponent(url)].filter( + (candidate): candidate is string => Boolean(candidate), + ); + const seenCandidates = new Set(); + for (const candidate of rawCandidates) { + if (seenCandidates.has(candidate)) { + continue; } - } - const decodedUrl = safeDecodeURIComponent(url); - if (decodedUrl) { - const decodedHashAnchorPath = toPathFromFileHashAnchor( - decodedUrl, - workspacePath, - ); - if (decodedHashAnchorPath) { - const anchoredPath = getLinkablePath(decodedHashAnchorPath); - if (anchoredPath) { - return anchoredPath; - } + seenCandidates.add(candidate); + const linkableCandidate = getLinkablePath(candidate); + if (!linkableCandidate) { + continue; } - } - if (decodedUrl && isLikelyFileHref(decodedUrl, workspacePath)) { - const decodedPath = getLinkablePath(decodedUrl); - if (decodedPath) { - return decodedPath; + if (isLikelyFileHref(linkableCandidate, workspacePath)) { + const parsedCandidate = parseFileLocation(linkableCandidate); + const decodedPath = safeDecodeURIComponent(parsedCandidate.path); + return { + path: decodedPath ?? parsedCandidate.path, + line: parsedCandidate.line, + column: parsedCandidate.column, + }; } } - const fileUrlPath = toPathFromFileUrl(url); - if (!fileUrlPath) { - return null; - } - return getLinkablePath(fileUrlPath); + return null; }; const components: Components = { a: ({ href, children }) => { @@ -804,6 +761,7 @@ export function Markdown({ } const hrefFilePath = resolveHrefFilePath(url); if (hrefFilePath) { + const formattedHrefFilePath = formatFileTarget(hrefFilePath); const clickHandler = (event: React.MouseEvent) => handleFileLinkClick(event, hrefFilePath); const contextMenuHandler = onOpenFileLinkMenu @@ -811,7 +769,8 @@ export function Markdown({ : undefined; return ( @@ -885,6 +844,11 @@ export function Markdown({ remarkPlugins={[remarkGfm, remarkFileLinks]} urlTransform={(url) => { const hasScheme = /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(url); + // Keep file-like hrefs intact before scheme sanitization runs, otherwise + // Windows absolute paths such as C:/repo/file.ts look like unknown schemes. + if (resolveHrefFilePath(url)) { + return url; + } if ( isFileLinkUrl(url) || url.startsWith("http://") || diff --git a/src/features/messages/components/MessageRows.tsx b/src/features/messages/components/MessageRows.tsx index 2bb0713aa..bbd6651ef 100644 --- a/src/features/messages/components/MessageRows.tsx +++ b/src/features/messages/components/MessageRows.tsx @@ -17,6 +17,7 @@ import X from "lucide-react/dist/esm/icons/x"; import { exportMarkdownFile } from "@services/tauri"; import { pushErrorToast } from "@services/toasts"; import type { ConversationItem } from "../../../types"; +import type { FileLinkTarget } from "../../../utils/fileLinks"; import { PierreDiffBlock } from "../../git/components/PierreDiffBlock"; import { MAX_COMMAND_OUTPUT_LINES, @@ -38,8 +39,8 @@ import { Markdown } from "./Markdown"; type MarkdownFileLinkProps = { showMessageFilePath?: boolean; workspacePath?: string | null; - onOpenFileLink?: (path: string) => void; - onOpenFileLinkMenu?: (event: MouseEvent, path: string) => void; + onOpenFileLink?: (path: FileLinkTarget) => void; + onOpenFileLinkMenu?: (event: MouseEvent, path: FileLinkTarget) => void; onOpenThreadLink?: (threadId: string) => void; }; diff --git a/src/features/messages/components/Messages.test.tsx b/src/features/messages/components/Messages.test.tsx index ea2f606a9..7c850ff34 100644 --- a/src/features/messages/components/Messages.test.tsx +++ b/src/features/messages/components/Messages.test.tsx @@ -17,6 +17,15 @@ const { exportMarkdownFileMock } = vi.hoisted(() => ({ exportMarkdownFileMock: vi.fn(), })); +function expectOpenedFileTarget( + mock: ReturnType, + path: string, + line: number | null = null, + column: number | null = null, +) { + expect(mock).toHaveBeenCalledWith({ path, line, column }); +} + vi.mock("../hooks/useFileLinkOpener", () => ({ useFileLinkOpener: ( workspacePath: string | null, @@ -300,7 +309,11 @@ describe("Messages", () => { ); fireEvent.click(screen.getByText("this file")); - expect(openFileLinkMock).toHaveBeenCalledWith(linkedPath); + expectOpenedFileTarget( + openFileLinkMock, + "/Users/dimillian/Documents/Dev/CodexMonitor/src/features/messages/components/Markdown.tsx", + 244, + ); }); it("routes absolute non-whitelisted file href paths through the file opener", () => { @@ -326,7 +339,7 @@ describe("Messages", () => { ); fireEvent.click(screen.getByText("app file")); - expect(openFileLinkMock).toHaveBeenCalledWith(linkedPath); + expectOpenedFileTarget(openFileLinkMock, "/custom/project/src/App.tsx", 12); }); it("decodes percent-encoded href file paths before opening", () => { @@ -351,7 +364,7 @@ describe("Messages", () => { ); fireEvent.click(screen.getByText("guide")); - expect(openFileLinkMock).toHaveBeenCalledWith("./docs/My Guide.md"); + expectOpenedFileTarget(openFileLinkMock, "./docs/My Guide.md"); }); it("routes absolute href file paths with #L anchors through the file opener", () => { @@ -378,8 +391,41 @@ describe("Messages", () => { ); fireEvent.click(screen.getByText("this file")); - expect(openFileLinkMock).toHaveBeenCalledWith( - "/Users/dimillian/Documents/Dev/CodexMonitor/src/features/messages/components/Markdown.tsx:244", + expectOpenedFileTarget( + openFileLinkMock, + "/Users/dimillian/Documents/Dev/CodexMonitor/src/features/messages/components/Markdown.tsx", + 244, + ); + }); + + it("routes Windows absolute href file paths with #L anchors through the file opener", () => { + const linkedPath = + "I:\\gpt-projects\\CodexMonitor\\src\\features\\settings\\components\\sections\\SettingsDisplaySection.tsx#L422"; + const items: ConversationItem[] = [ + { + id: "msg-file-href-windows-anchor-link", + kind: "message", + role: "assistant", + text: `Open [settings display](${linkedPath})`, + }, + ]; + + render( + , + ); + + fireEvent.click(screen.getByText("settings display")); + expectOpenedFileTarget( + openFileLinkMock, + "I:\\gpt-projects\\CodexMonitor\\src\\features\\settings\\components\\sections\\SettingsDisplaySection.tsx", + 422, ); }); @@ -406,7 +452,7 @@ describe("Messages", () => { ); fireEvent.click(screen.getByText("license")); - expect(openFileLinkMock).toHaveBeenCalledWith(linkedPath); + expectOpenedFileTarget(openFileLinkMock, linkedPath); }); it("keeps non-file relative links as normal markdown links", () => { diff --git a/src/features/messages/hooks/useFileLinkOpener.test.tsx b/src/features/messages/hooks/useFileLinkOpener.test.tsx index 42b6816a9..61813a1b4 100644 --- a/src/features/messages/hooks/useFileLinkOpener.test.tsx +++ b/src/features/messages/hooks/useFileLinkOpener.test.tsx @@ -4,6 +4,20 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { openWorkspaceIn } from "../../../services/tauri"; import { useFileLinkOpener } from "./useFileLinkOpener"; +const { + menuNewMock, + menuItemNewMock, + predefinedMenuItemNewMock, + logicalPositionMock, + getCurrentWindowMock, +} = vi.hoisted(() => ({ + menuNewMock: vi.fn(), + menuItemNewMock: vi.fn(), + predefinedMenuItemNewMock: vi.fn(), + logicalPositionMock: vi.fn(), + getCurrentWindowMock: vi.fn(), +})); + vi.mock("../../../services/tauri", () => ({ openWorkspaceIn: vi.fn(), })); @@ -13,17 +27,17 @@ vi.mock("@tauri-apps/plugin-opener", () => ({ })); vi.mock("@tauri-apps/api/menu", () => ({ - Menu: { new: vi.fn() }, - MenuItem: { new: vi.fn() }, - PredefinedMenuItem: { new: vi.fn() }, + Menu: { new: menuNewMock }, + MenuItem: { new: menuItemNewMock }, + PredefinedMenuItem: { new: predefinedMenuItemNewMock }, })); vi.mock("@tauri-apps/api/dpi", () => ({ - LogicalPosition: vi.fn(), + LogicalPosition: logicalPositionMock, })); vi.mock("@tauri-apps/api/window", () => ({ - getCurrentWindow: vi.fn(), + getCurrentWindow: getCurrentWindowMock, })); vi.mock("@sentry/react", () => ({ @@ -39,6 +53,123 @@ describe("useFileLinkOpener", () => { vi.clearAllMocks(); }); + it("copies namespace-prefixed Windows drive paths as round-trippable file URLs", async () => { + const clipboardWriteTextMock = vi.fn(); + Object.defineProperty(navigator, "clipboard", { + value: { writeText: clipboardWriteTextMock }, + configurable: true, + }); + menuItemNewMock.mockImplementation(async (options) => options); + predefinedMenuItemNewMock.mockImplementation(async (options) => options); + menuNewMock.mockImplementation(async ({ items }) => ({ + items, + popup: vi.fn(), + })); + + const { result } = renderHook(() => useFileLinkOpener(null, [], "")); + + await act(async () => { + await result.current.showFileLinkMenu( + { + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + clientX: 12, + clientY: 24, + } as never, + "\\\\?\\C:\\repo\\src\\App.tsx:42", + ); + }); + + const items = menuNewMock.mock.calls[0]?.[0]?.items ?? []; + const copyLinkItem = items.find( + (item: { text?: string; action?: () => Promise }) => item.text === "Copy Link", + ); + + await copyLinkItem?.action?.(); + + expect(clipboardWriteTextMock).toHaveBeenCalledWith( + "file:///%5C%5C%3F%5CC%3A%5Crepo%5Csrc%5CApp.tsx#L42", + ); + }); + + it("copies namespace-prefixed Windows UNC paths as round-trippable file URLs", async () => { + const clipboardWriteTextMock = vi.fn(); + Object.defineProperty(navigator, "clipboard", { + value: { writeText: clipboardWriteTextMock }, + configurable: true, + }); + menuItemNewMock.mockImplementation(async (options) => options); + predefinedMenuItemNewMock.mockImplementation(async (options) => options); + menuNewMock.mockImplementation(async ({ items }) => ({ + items, + popup: vi.fn(), + })); + + const { result } = renderHook(() => useFileLinkOpener(null, [], "")); + + await act(async () => { + await result.current.showFileLinkMenu( + { + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + clientX: 12, + clientY: 24, + } as never, + "\\\\?\\UNC\\server\\share\\repo\\App.tsx:42", + ); + }); + + const items = menuNewMock.mock.calls[0]?.[0]?.items ?? []; + const copyLinkItem = items.find( + (item: { text?: string; action?: () => Promise }) => item.text === "Copy Link", + ); + + await copyLinkItem?.action?.(); + + expect(clipboardWriteTextMock).toHaveBeenCalledWith( + "file:///%5C%5C%3F%5CUNC%5Cserver%5Cshare%5Crepo%5CApp.tsx#L42", + ); + }); + + it("percent-encodes copied file URLs for Windows paths with reserved characters", async () => { + const clipboardWriteTextMock = vi.fn(); + Object.defineProperty(navigator, "clipboard", { + value: { writeText: clipboardWriteTextMock }, + configurable: true, + }); + menuItemNewMock.mockImplementation(async (options) => options); + predefinedMenuItemNewMock.mockImplementation(async (options) => options); + menuNewMock.mockImplementation(async ({ items }) => ({ + items, + popup: vi.fn(), + })); + + const { result } = renderHook(() => useFileLinkOpener(null, [], "")); + + await act(async () => { + await result.current.showFileLinkMenu( + { + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + clientX: 12, + clientY: 24, + } as never, + "C:\\repo\\My File #100%.tsx:42", + ); + }); + + const items = menuNewMock.mock.calls[0]?.[0]?.items ?? []; + const copyLinkItem = items.find( + (item: { text?: string; action?: () => Promise }) => item.text === "Copy Link", + ); + + await copyLinkItem?.action?.(); + + expect(clipboardWriteTextMock).toHaveBeenCalledWith( + "file:///C:/repo/My%20File%20%23100%25.tsx#L42", + ); + }); + it("maps /workspace root-relative paths to the active workspace path", async () => { const workspacePath = "/Users/sotiriskaniras/Documents/Development/Forks/CodexMonitor"; const openWorkspaceInMock = vi.mocked(openWorkspaceIn); @@ -69,6 +200,21 @@ describe("useFileLinkOpener", () => { ); }); + it("maps extensionless files under /workspace/settings to the active workspace path", async () => { + const workspacePath = "/Users/sotiriskaniras/Documents/Development/Forks/settings"; + const openWorkspaceInMock = vi.mocked(openWorkspaceIn); + const { result } = renderHook(() => useFileLinkOpener(workspacePath, [], "")); + + await act(async () => { + await result.current.openFileLink("/workspace/settings/LICENSE"); + }); + + expect(openWorkspaceInMock).toHaveBeenCalledWith( + "/Users/sotiriskaniras/Documents/Development/Forks/settings/LICENSE", + expect.objectContaining({ appName: "Visual Studio Code", args: [] }), + ); + }); + it("maps nested /workspaces/...//... paths to the active workspace path", async () => { const workspacePath = "/Users/sotiriskaniras/Documents/Development/Forks/CodexMonitor"; const openWorkspaceInMock = vi.mocked(openWorkspaceIn); @@ -125,6 +271,24 @@ describe("useFileLinkOpener", () => { ); }); + it("opens structured file targets without re-parsing #L-like filename endings", async () => { + const openWorkspaceInMock = vi.mocked(openWorkspaceIn); + const { result } = renderHook(() => useFileLinkOpener(null, [], "")); + + await act(async () => { + await result.current.openFileLink({ + path: "/tmp/#L12", + line: null, + column: null, + }); + }); + + expect(openWorkspaceInMock).toHaveBeenCalledWith( + "/tmp/#L12", + expect.objectContaining({ appName: "Visual Studio Code", args: [] }), + ); + }); + it("normalizes line ranges to the starting line before opening the editor", async () => { const workspacePath = "/Users/sotiriskaniras/Documents/Development/Forks/CodexMonitor"; const openWorkspaceInMock = vi.mocked(openWorkspaceIn); diff --git a/src/features/messages/hooks/useFileLinkOpener.ts b/src/features/messages/hooks/useFileLinkOpener.ts index 65ea91bdd..f82bb8b24 100644 --- a/src/features/messages/hooks/useFileLinkOpener.ts +++ b/src/features/messages/hooks/useFileLinkOpener.ts @@ -8,6 +8,12 @@ import * as Sentry from "@sentry/react"; import { openWorkspaceIn } from "../../../services/tauri"; import { pushErrorToast } from "../../../services/toasts"; import type { OpenAppTarget } from "../../../types"; +import { + type FileLinkTarget, + formatFileLocation, + parseFileLocation, + toFileUrl, +} from "../../../utils/fileLinks"; import { isAbsolutePath, joinWorkspacePath, @@ -46,6 +52,10 @@ const canOpenTarget = (target: OpenTarget) => { return Boolean(resolveAppName(target)); }; +function toParsedFileTarget(target: FileLinkTarget) { + return typeof target === "string" ? parseFileLocation(target) : target; +} + function resolveFilePath(path: string, workspacePath?: string | null) { const trimmed = path.trim(); if (!workspacePath) { @@ -61,86 +71,6 @@ function resolveFilePath(path: string, workspacePath?: string | null) { return joinWorkspacePath(workspacePath, trimmed); } -type ParsedFileLocation = { - path: string; - line: number | null; - column: number | null; -}; - -const FILE_LOCATION_SUFFIX_PATTERN = /^(.*?):(\d+)(?::(\d+))?$/; -const FILE_LOCATION_RANGE_SUFFIX_PATTERN = /^(.*?):(\d+)-(\d+)$/; -const FILE_LOCATION_HASH_PATTERN = /^(.*?)#L(\d+)(?:C(\d+))?$/i; - -function parsePositiveInteger(value?: string) { - if (!value) { - return null; - } - const parsed = Number.parseInt(value, 10); - return Number.isFinite(parsed) && parsed > 0 ? parsed : null; -} - -function parseFileLocation(rawPath: string): ParsedFileLocation { - const trimmed = rawPath.trim(); - const hashMatch = trimmed.match(FILE_LOCATION_HASH_PATTERN); - if (hashMatch) { - const [, path, lineValue, columnValue] = hashMatch; - const line = parsePositiveInteger(lineValue); - if (line !== null) { - return { - path, - line, - column: parsePositiveInteger(columnValue), - }; - } - } - - const match = trimmed.match(FILE_LOCATION_SUFFIX_PATTERN); - if (match) { - const [, path, lineValue, columnValue] = match; - const line = parsePositiveInteger(lineValue); - if (line === null) { - return { - path: trimmed, - line: null, - column: null, - }; - } - - return { - path, - line, - column: parsePositiveInteger(columnValue), - }; - } - - const rangeMatch = trimmed.match(FILE_LOCATION_RANGE_SUFFIX_PATTERN); - if (rangeMatch) { - const [, path, startLineValue] = rangeMatch; - const startLine = parsePositiveInteger(startLineValue); - if (startLine !== null) { - return { - path, - line: startLine, - column: null, - }; - } - } - - return { - path: trimmed, - line: null, - column: null, - }; -} - -function toFileUrl(path: string, line: number | null, column: number | null) { - const base = path.startsWith("/") ? `file://${path}` : path; - if (line === null) { - return base; - } - return `${base}#L${line}${column !== null ? `C${column}` : ""}`; -} - export function useFileLinkOpener( workspacePath: string | null, openTargets: OpenAppTarget[], @@ -168,13 +98,18 @@ export function useFileLinkOpener( ); const openFileLink = useCallback( - async (rawPath: string) => { + async (rawPath: FileLinkTarget) => { const target = { ...DEFAULT_OPEN_TARGET, ...(openTargets.find((entry) => entry.id === selectedOpenAppId) ?? openTargets[0]), }; - const fileLocation = parseFileLocation(rawPath); + const fileLocation = toParsedFileTarget(rawPath); + const rawPathLabel = formatFileLocation( + fileLocation.path, + fileLocation.line, + fileLocation.column, + ); const resolvedPath = resolveFilePath(fileLocation.path, workspacePath); const openLocation = { ...(fileLocation.line !== null ? { line: fileLocation.line } : {}), @@ -214,7 +149,7 @@ export function useFileLinkOpener( }); } catch (error) { reportOpenError(error, { - rawPath, + rawPath: rawPathLabel, resolvedPath, workspacePath, targetId: target.id, @@ -228,7 +163,7 @@ export function useFileLinkOpener( ); const showFileLinkMenu = useCallback( - async (event: MouseEvent, rawPath: string) => { + async (event: MouseEvent, rawPath: FileLinkTarget) => { event.preventDefault(); event.stopPropagation(); const target = { @@ -236,7 +171,12 @@ export function useFileLinkOpener( ...(openTargets.find((entry) => entry.id === selectedOpenAppId) ?? openTargets[0]), }; - const fileLocation = parseFileLocation(rawPath); + const fileLocation = toParsedFileTarget(rawPath); + const rawPathLabel = formatFileLocation( + fileLocation.path, + fileLocation.line, + fileLocation.column, + ); const resolvedPath = resolveFilePath(fileLocation.path, workspacePath); const appName = resolveAppName(target); const command = resolveCommand(target); @@ -269,7 +209,7 @@ export function useFileLinkOpener( await revealItemInDir(resolvedPath); } catch (error) { reportOpenError(error, { - rawPath, + rawPath: rawPathLabel, resolvedPath, workspacePath, targetId: target.id, diff --git a/src/features/messages/utils/mountedWorkspacePaths.ts b/src/features/messages/utils/mountedWorkspacePaths.ts index 15963fe8d..45fca8c82 100644 --- a/src/features/messages/utils/mountedWorkspacePaths.ts +++ b/src/features/messages/utils/mountedWorkspacePaths.ts @@ -1,4 +1,5 @@ import { joinWorkspacePath } from "../../../utils/platformPaths"; +import { isKnownLocalWorkspaceRoutePath } from "../../../utils/fileLinks"; const WORKSPACE_MOUNT_PREFIX = "/workspace/"; const WORKSPACES_MOUNT_PREFIX = "/workspaces/"; @@ -23,6 +24,9 @@ export function resolveMountedWorkspacePath( workspacePath?: string | null, ) { const trimmed = path.trim(); + if (isKnownLocalWorkspaceRoutePath(trimmed)) { + return null; + } const trimmedWorkspace = workspacePath?.trim() ?? ""; if (!trimmedWorkspace) { return null; diff --git a/src/utils/fileLinks.test.ts b/src/utils/fileLinks.test.ts new file mode 100644 index 000000000..6fd884a20 --- /dev/null +++ b/src/utils/fileLinks.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it } from "vitest"; +import { fromFileUrl, isKnownLocalWorkspaceRoutePath, toFileUrl } from "./fileLinks"; + +function withThrowingUrlConstructor(run: () => void) { + const originalUrl = globalThis.URL; + const throwingUrl = class { + constructor() { + throw new TypeError("Simulated URL constructor failure"); + } + } as unknown as typeof URL; + + Object.defineProperty(globalThis, "URL", { + configurable: true, + value: throwingUrl, + }); + + try { + run(); + } finally { + Object.defineProperty(globalThis, "URL", { + configurable: true, + value: originalUrl, + }); + } +} + +describe("fromFileUrl", () => { + it("keeps encoded #L-like path segments as part of the decoded filename", () => { + expect(fromFileUrl("file:///tmp/%23L12")).toBe("/tmp/#L12"); + expect(fromFileUrl("file:///tmp/report%23L12C3.md")).toBe("/tmp/report#L12C3.md"); + }); + + it("uses only the real URL fragment as a line anchor", () => { + expect(fromFileUrl("file:///tmp/report%23L12.md#L34")).toBe("/tmp/report#L12.md:34"); + expect(fromFileUrl("file:///tmp/report%23L12C3.md#L34C2")).toBe( + "/tmp/report#L12C3.md:34:2", + ); + }); + + it("keeps Windows drive paths when decoding a file URL with an unescaped percent", () => { + expect(fromFileUrl("file:///C:/repo/100%.tsx#L12")).toBe("C:/repo/100%.tsx:12"); + }); + + it("keeps UNC host paths when decoding a file URL with an unescaped percent", () => { + expect(fromFileUrl("file://server/share/100%.tsx#L12")).toBe( + "//server/share/100%.tsx:12", + ); + }); + + it("preserves Windows drive info when the URL constructor fallback is used", () => { + withThrowingUrlConstructor(() => { + expect(fromFileUrl("file:///C:/repo/100%.tsx#L12")).toBe("C:/repo/100%.tsx:12"); + expect(fromFileUrl("file://localhost/C:/repo/100%.tsx#L12")).toBe( + "C:/repo/100%.tsx:12", + ); + }); + }); + + it("preserves UNC host info when the URL constructor fallback is used", () => { + withThrowingUrlConstructor(() => { + expect(fromFileUrl("file://server/share/100%.tsx#L12")).toBe( + "//server/share/100%.tsx:12", + ); + }); + }); + + it("keeps encoded #L-like path segments when the URL constructor fallback is used", () => { + withThrowingUrlConstructor(() => { + expect(fromFileUrl("file:///tmp/%23L12")).toBe("/tmp/#L12"); + expect(fromFileUrl("file:///tmp/report%23L12.md#L34")).toBe("/tmp/report#L12.md:34"); + }); + }); + + it("round-trips Windows namespace drive paths through file URLs", () => { + const fileUrl = toFileUrl("\\\\?\\C:\\repo\\src\\App.tsx", 12, null); + expect(fileUrl).toBe("file:///%5C%5C%3F%5CC%3A%5Crepo%5Csrc%5CApp.tsx#L12"); + expect(fromFileUrl(fileUrl)).toBe("\\\\?\\C:\\repo\\src\\App.tsx:12"); + }); + + it("round-trips Windows namespace UNC paths through file URLs", () => { + const fileUrl = toFileUrl("\\\\?\\UNC\\server\\share\\repo\\App.tsx", 12, null); + expect(fileUrl).toBe( + "file:///%5C%5C%3F%5CUNC%5Cserver%5Cshare%5Crepo%5CApp.tsx#L12", + ); + expect(fromFileUrl(fileUrl)).toBe("\\\\?\\UNC\\server\\share\\repo\\App.tsx:12"); + }); +}); + +describe("isKnownLocalWorkspaceRoutePath", () => { + it("matches exact mounted settings and reviews routes", () => { + expect(isKnownLocalWorkspaceRoutePath("/workspace/settings")).toBe(true); + expect(isKnownLocalWorkspaceRoutePath("/workspace/reviews")).toBe(true); + expect(isKnownLocalWorkspaceRoutePath("/workspaces/team/settings")).toBe(true); + expect(isKnownLocalWorkspaceRoutePath("/workspaces/team/reviews")).toBe(true); + }); + + it("keeps explicit nested settings and reviews app routes out of file resolution", () => { + expect(isKnownLocalWorkspaceRoutePath("/workspace/settings/profile")).toBe(true); + expect(isKnownLocalWorkspaceRoutePath("/workspace/reviews/overview")).toBe(true); + expect(isKnownLocalWorkspaceRoutePath("/workspaces/team/settings/profile")).toBe(true); + expect(isKnownLocalWorkspaceRoutePath("/workspaces/team/reviews/overview")).toBe(true); + }); + + it("still allows file-like descendants under reserved workspace names", () => { + expect(isKnownLocalWorkspaceRoutePath("/workspace/settings/src/App.tsx")).toBe(false); + expect(isKnownLocalWorkspaceRoutePath("/workspace/reviews/src/App.tsx")).toBe(false); + expect(isKnownLocalWorkspaceRoutePath("/workspaces/team/settings/src/App.tsx")).toBe( + false, + ); + expect(isKnownLocalWorkspaceRoutePath("/workspaces/team/reviews/src/App.tsx")).toBe( + false, + ); + }); + + it("treats extensionless descendants under reserved workspace names as mounted files", () => { + expect(isKnownLocalWorkspaceRoutePath("/workspace/settings/LICENSE")).toBe(false); + expect(isKnownLocalWorkspaceRoutePath("/workspace/reviews/bin/tool")).toBe(false); + expect(isKnownLocalWorkspaceRoutePath("/workspaces/team/settings/Makefile")).toBe( + false, + ); + expect(isKnownLocalWorkspaceRoutePath("/workspaces/team/reviews/bin/tool")).toBe( + false, + ); + }); +}); diff --git a/src/utils/fileLinks.ts b/src/utils/fileLinks.ts new file mode 100644 index 000000000..f6b960017 --- /dev/null +++ b/src/utils/fileLinks.ts @@ -0,0 +1,387 @@ +export type ParsedFileLocation = { + path: string; + line: number | null; + column: number | null; +}; + +export type FileLinkTarget = string | ParsedFileLocation; + +const FILE_LOCATION_SUFFIX_PATTERN = /^(.*?):(\d+)(?::(\d+))?$/; +const FILE_LOCATION_RANGE_SUFFIX_PATTERN = /^(.*?):(\d+)-(\d+)$/; +const FILE_LOCATION_HASH_PATTERN = /^(.*?)#L(\d+)(?:C(\d+))?$/i; +const FILE_URL_LOCATION_HASH_PATTERN = /^#L(\d+)(?:C(\d+))?$/i; +const LOCAL_WORKSPACE_ROUTE_TAIL_SEGMENTS = { + reviews: new Set(["overview"]), + settings: new Set([ + "about", + "agents", + "codex", + "composer", + "dictation", + "display", + "environments", + "features", + "git", + "open-apps", + "profile", + "projects", + "server", + "shortcuts", + ]), +} as const; + +export const FILE_LINK_SUFFIX_SOURCE = + "(?:(?::\\d+(?::\\d+)?|:\\d+-\\d+)|(?:#L\\d+(?:C\\d+)?))?"; + +function parsePositiveInteger(value?: string) { + if (!value) { + return null; + } + const parsed = Number.parseInt(value, 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : null; +} + +function decodeURIComponentSafely(value: string) { + try { + return decodeURIComponent(value); + } catch { + return value; + } +} + +function parseRecognizedFileUrlHash(hash: string) { + const match = hash.match(FILE_URL_LOCATION_HASH_PATTERN); + if (!match) { + return { + line: null, + column: null, + }; + } + + const [, lineValue, columnValue] = match; + const line = parsePositiveInteger(lineValue); + return { + line, + column: line === null ? null : parsePositiveInteger(columnValue), + }; +} + +function buildLocalPathFromFileUrl(host: string, pathname: string) { + const decodedPath = decodeURIComponentSafely(pathname); + if (/^\/(?:\\\\|\/\/)[?.][\\/]/.test(decodedPath)) { + return decodedPath.slice(1); + } + let path = decodedPath; + if (host && host !== "localhost") { + const normalizedPath = decodedPath.startsWith("/") ? decodedPath : `/${decodedPath}`; + path = `//${host}${normalizedPath}`; + } + if (/^\/[A-Za-z]:\//.test(path)) { + path = path.slice(1); + } + return path; +} + +function parseManualFileUrl(url: string) { + const manualPath = url.slice("file://".length).trim(); + if (!manualPath) { + return null; + } + + const hashIndex = manualPath.indexOf("#"); + const hash = hashIndex === -1 ? "" : manualPath.slice(hashIndex); + const pathWithHost = hashIndex === -1 ? manualPath : manualPath.slice(0, hashIndex); + if (!pathWithHost) { + return null; + } + + if (pathWithHost.startsWith("/")) { + return { + host: "", + pathname: pathWithHost, + hash, + }; + } + + const slashIndex = pathWithHost.indexOf("/"); + if (slashIndex === -1) { + if (/^[A-Za-z]:$/.test(pathWithHost)) { + return { + host: "", + pathname: `/${pathWithHost}`, + hash, + }; + } + return { + host: pathWithHost, + pathname: "", + hash, + }; + } + + const host = pathWithHost.slice(0, slashIndex); + const pathname = pathWithHost.slice(slashIndex); + if (/^[A-Za-z]:$/.test(host)) { + return { + host: "", + pathname: `/${host}${pathname}`, + hash, + }; + } + + return { + host, + pathname, + hash, + }; +} + +export function parseFileLocation(rawPath: string): ParsedFileLocation { + const trimmed = rawPath.trim(); + const hashMatch = trimmed.match(FILE_LOCATION_HASH_PATTERN); + if (hashMatch) { + const [, path, lineValue, columnValue] = hashMatch; + const line = parsePositiveInteger(lineValue); + if (line !== null) { + return { + path, + line, + column: parsePositiveInteger(columnValue), + }; + } + } + + const match = trimmed.match(FILE_LOCATION_SUFFIX_PATTERN); + if (match) { + const [, path, lineValue, columnValue] = match; + const line = parsePositiveInteger(lineValue); + if (line !== null) { + return { + path, + line, + column: parsePositiveInteger(columnValue), + }; + } + } + + const rangeMatch = trimmed.match(FILE_LOCATION_RANGE_SUFFIX_PATTERN); + if (rangeMatch) { + const [, path, startLineValue] = rangeMatch; + const startLine = parsePositiveInteger(startLineValue); + if (startLine !== null) { + return { + path, + line: startLine, + column: null, + }; + } + } + + return { + path: trimmed, + line: null, + column: null, + }; +} + +export function formatFileLocation( + path: string, + line: number | null, + column: number | null, +) { + if (line === null) { + return path.trim(); + } + return `${path.trim()}:${line}${column !== null ? `:${column}` : ""}`; +} + +export function normalizeFileLinkPath(rawPath: string) { + const parsed = parseFileLocation(rawPath); + return formatFileLocation(parsed.path, parsed.line, parsed.column); +} + +function stripNonLineUrlSuffix(path: string) { + const queryIndex = path.indexOf("?"); + const hashIndex = path.indexOf("#"); + const boundaryIndex = + queryIndex === -1 + ? hashIndex + : hashIndex === -1 + ? queryIndex + : Math.min(queryIndex, hashIndex); + return boundaryIndex === -1 ? path : path.slice(0, boundaryIndex); +} + +function getLocalWorkspaceRouteInfo(rawPath: string) { + const parsed = parseFileLocation(rawPath); + const normalizedPath = stripNonLineUrlSuffix(parsed.path.trim().replace(/\\/g, "/")); + if (normalizedPath.startsWith("/workspace/")) { + const mountedSegments = normalizedPath + .slice("/workspace/".length) + .split("/") + .filter(Boolean); + return { + line: parsed.line, + mountedSegments, + routeSegment: mountedSegments[0] ?? null, + tailSegments: mountedSegments.slice(1), + }; + } + if (normalizedPath.startsWith("/workspaces/")) { + const mountedSegments = normalizedPath + .slice("/workspaces/".length) + .split("/") + .filter(Boolean); + return { + line: parsed.line, + mountedSegments, + routeSegment: mountedSegments[1] ?? null, + tailSegments: mountedSegments.slice(2), + }; + } + return null; +} + +export function isKnownLocalWorkspaceRoutePath(rawPath: string) { + const routeInfo = getLocalWorkspaceRouteInfo(rawPath); + if (!routeInfo?.routeSegment) { + return false; + } + if ( + !Object.prototype.hasOwnProperty.call( + LOCAL_WORKSPACE_ROUTE_TAIL_SEGMENTS, + routeInfo.routeSegment, + ) + ) { + return false; + } + if (routeInfo.tailSegments.length === 0) { + return true; + } + if (routeInfo.tailSegments.length !== 1) { + return false; + } + const allowedTailSegments = + LOCAL_WORKSPACE_ROUTE_TAIL_SEGMENTS[ + routeInfo.routeSegment as keyof typeof LOCAL_WORKSPACE_ROUTE_TAIL_SEGMENTS + ]; + return allowedTailSegments.has(routeInfo.tailSegments[0]); +} + +type FileUrlParts = { + host: string; + pathname: string; + treatPathnameAsOpaque?: boolean; +}; + +function encodeFileUrlPathname(pathname: string, treatPathnameAsOpaque = false) { + if (treatPathnameAsOpaque) { + return pathname + .split("/") + .map((segment) => encodeURIComponent(segment)) + .join("/"); + } + return pathname + .split("/") + .map((segment, index) => { + if (index === 1 && /^[A-Za-z]:$/.test(segment)) { + return segment; + } + return encodeURIComponent(segment); + }) + .join("/"); +} + +function toFileUrlParts(path: string): FileUrlParts | null { + const normalizedWindowsPath = path.replace(/\//g, "\\"); + const namespaceUncMatch = normalizedWindowsPath.match( + /^\\\\\?\\UNC\\([^\\]+)\\([^\\]+)(.*)$/i, + ); + if (namespaceUncMatch) { + return { + host: "", + pathname: `/${normalizedWindowsPath}`, + treatPathnameAsOpaque: true, + }; + } + + const namespaceDriveMatch = normalizedWindowsPath.match(/^\\\\\?\\([A-Za-z]:)(.*)$/); + if (namespaceDriveMatch) { + return { + host: "", + pathname: `/${normalizedWindowsPath}`, + treatPathnameAsOpaque: true, + }; + } + + const uncMatch = normalizedWindowsPath.match(/^\\\\([^\\]+)\\([^\\]+)(.*)$/); + if (uncMatch) { + const [, server, share, rest = ""] = uncMatch; + const normalizedRest = rest.replace(/\\/g, "/").replace(/^\/+/, ""); + return { + host: server, + pathname: `/${share}${normalizedRest ? `/${normalizedRest}` : ""}`, + }; + } + + if (/^[A-Za-z]:[\\/]/.test(path)) { + return { + host: "", + pathname: `/${path.replace(/\\/g, "/")}`, + }; + } + + if (path.startsWith("/")) { + return { + host: "", + pathname: path, + }; + } + + return null; +} + +export function toFileUrl(path: string, line: number | null, column: number | null) { + const parts = toFileUrlParts(path); + let base = path; + if (parts) { + base = `file://${parts.host}${encodeFileUrlPathname( + parts.pathname, + parts.treatPathnameAsOpaque, + )}`; + } + if (line === null) { + return base; + } + return `${base}#L${line}${column !== null ? `C${column}` : ""}`; +} + +export function parseFileUrlLocation(url: string): ParsedFileLocation | null { + if (!url.toLowerCase().startsWith("file://")) { + return null; + } + + try { + const parsed = new URL(url); + if (parsed.protocol !== "file:") { + return null; + } + + const path = buildLocalPathFromFileUrl(parsed.host, parsed.pathname); + const { line, column } = parseRecognizedFileUrlHash(parsed.hash); + return { path, line, column }; + } catch { + const manualParts = parseManualFileUrl(url); + if (!manualParts) { + return null; + } + const path = buildLocalPathFromFileUrl(manualParts.host, manualParts.pathname); + const { line, column } = parseRecognizedFileUrlHash(manualParts.hash); + return { path, line, column }; + } +} + +export function fromFileUrl(url: string) { + const parsed = parseFileUrlLocation(url); + return parsed ? formatFileLocation(parsed.path, parsed.line, parsed.column) : null; +} diff --git a/src/utils/remarkFileLinks.ts b/src/utils/remarkFileLinks.ts index 4824452fa..278d86492 100644 --- a/src/utils/remarkFileLinks.ts +++ b/src/utils/remarkFileLinks.ts @@ -1,15 +1,27 @@ +import { + FILE_LINK_SUFFIX_SOURCE, + isKnownLocalWorkspaceRoutePath, + normalizeFileLinkPath, +} from "./fileLinks"; + const FILE_LINK_PROTOCOL = "codex-file:"; -const FILE_LINE_SUFFIX_PATTERN = "(?::\\d+(?::\\d+)?)?"; +const POSIX_OR_RELATIVE_FILE_PATH_PATTERN = + "(?:\\/[^\\s\\`\"'<>]+|~\\/[^\\s\\`\"'<>]+|\\.{1,2}\\/[^\\s\\`\"'<>]+|[A-Za-z0-9._-]+(?:\\/[A-Za-z0-9._-]+)+)"; +const WINDOWS_ABSOLUTE_FILE_PATH_PATTERN = + "(?:[A-Za-z]:[\\\\/][^\\s\\`\"'<>]+(?:[\\\\/][^\\s\\`\"'<>]+)*)"; +const WINDOWS_UNC_FILE_PATH_PATTERN = + "(?:\\\\\\\\[^\\s\\`\"'<>]+(?:\\\\[^\\s\\`\"'<>]+)+)"; const FILE_PATH_PATTERN = new RegExp( - `(\\/[^\\s\\\`"'<>]+|~\\/[^\\s\\\`"'<>]+|\\.{1,2}\\/[^\\s\\\`"'<>]+|[A-Za-z0-9._-]+(?:\\/[A-Za-z0-9._-]+)+)${FILE_LINE_SUFFIX_PATTERN}`, + `(${POSIX_OR_RELATIVE_FILE_PATH_PATTERN}|${WINDOWS_ABSOLUTE_FILE_PATH_PATTERN}|${WINDOWS_UNC_FILE_PATH_PATTERN})${FILE_LINK_SUFFIX_SOURCE}`, "g", ); const FILE_PATH_MATCH = new RegExp(`^${FILE_PATH_PATTERN.source}$`); const TRAILING_PUNCTUATION = new Set([".", ",", ";", ":", "!", "?", ")", "]", "}"]); const LETTER_OR_NUMBER_PATTERN = /[\p{L}\p{N}.]/u; +const URL_SCHEME_PREFIX_PATTERN = /[a-zA-Z][a-zA-Z0-9+.-]*:\/\/\/?$/; type MarkdownNode = { type: string; @@ -20,16 +32,19 @@ type MarkdownNode = { function isPathCandidate( value: string, - leadingContext: string, + leadingText: string, previousChar: string, ) { - if (!value.includes("/")) { + if (URL_SCHEME_PREFIX_PATTERN.test(leadingText)) { return false; } - if (value.startsWith("//")) { + if (/^[A-Za-z]:[\\/]/.test(value) || value.startsWith("\\\\")) { + return !previousChar || !LETTER_OR_NUMBER_PATTERN.test(previousChar); + } + if (!value.includes("/")) { return false; } - if (leadingContext.endsWith("://")) { + if (value.startsWith("//")) { return false; } if (value.startsWith("/") || value.startsWith("./") || value.startsWith("../")) { @@ -77,13 +92,19 @@ function linkifyText(value: string) { nodes.push({ type: "text", value: value.slice(lastIndex, matchIndex) }); } - const leadingContext = value.slice(Math.max(0, matchIndex - 3), matchIndex); + const leadingText = value.slice(0, matchIndex); const previousChar = matchIndex > 0 ? value[matchIndex - 1] : ""; const { path, trailing } = splitTrailingPunctuation(raw); - if (path && isPathCandidate(path, leadingContext, previousChar)) { + if (path && isPathCandidate(path, leadingText, previousChar)) { + const normalizedPath = normalizeFileLinkPath(path); + if (isKnownLocalWorkspaceRoutePath(normalizedPath)) { + nodes.push({ type: "text", value: raw }); + lastIndex = matchIndex + raw.length; + continue; + } nodes.push({ type: "link", - url: toFileLink(path), + url: toFileLink(normalizedPath), children: [{ type: "text", value: path }], }); if (trailing) { @@ -142,6 +163,9 @@ export function isLinkableFilePath(value: string) { if (!trimmed) { return false; } + if (isKnownLocalWorkspaceRoutePath(trimmed)) { + return false; + } if (!FILE_PATH_MATCH.test(trimmed)) { return false; }