From c3616b379ff05edd1974ce3bb6a96235530b041c Mon Sep 17 00:00:00 2001 From: Reekin Date: Fri, 20 Mar 2026 01:25:21 +0800 Subject: [PATCH 01/11] fix: support windows file links in messages --- .../messages/components/Markdown.test.tsx | 94 +++++++++ src/features/messages/components/Markdown.tsx | 162 +++++--------- .../messages/components/Messages.test.tsx | 29 +++ .../messages/hooks/useFileLinkOpener.test.tsx | 139 +++++++++++- .../messages/hooks/useFileLinkOpener.ts | 81 +------ src/utils/fileLinks.ts | 199 ++++++++++++++++++ src/utils/remarkFileLinks.ts | 30 ++- 7 files changed, 531 insertions(+), 203 deletions(-) create mode 100644 src/utils/fileLinks.ts diff --git a/src/features/messages/components/Markdown.test.tsx b/src/features/messages/components/Markdown.test.tsx index dd75ce237..3682b7bd7 100644 --- a/src/features/messages/components/Markdown.test.tsx +++ b/src/features/messages/components/Markdown.test.tsx @@ -252,6 +252,45 @@ describe("Markdown file-like href behavior", () => { expect(onOpenFileLink).toHaveBeenCalledWith("./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); + expect(onOpenFileLink).toHaveBeenCalledWith( + "I:\\gpt-projects\\CodexMonitor\\src\\features\\settings\\components\\sections\\SettingsDisplaySection.tsx:422", + ); + + fireEvent.contextMenu(link as Element); + expect(onOpenFileLinkMenu).toHaveBeenCalledWith( + expect.anything(), + "I:\\gpt-projects\\CodexMonitor\\src\\features\\settings\\components\\sections\\SettingsDisplaySection.tsx:422", + ); + }); + it("prevents unsupported route fragments without treating them as file links", () => { const onOpenFileLink = vi.fn(); render( @@ -312,4 +351,59 @@ describe("Markdown file-like href behavior", () => { expect(fileLinks[0]?.textContent).toContain("setup.md"); expect(fileLinks[1]?.textContent).toContain("index.ts"); }); + + 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"); + }); }); diff --git a/src/features/messages/components/Markdown.tsx b/src/features/messages/components/Markdown.tsx index 503aef0b0..bbca0f924 100644 --- a/src/features/messages/components/Markdown.tsx +++ b/src/features/messages/components/Markdown.tsx @@ -2,6 +2,11 @@ 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 { + fromFileUrl, + normalizeFileLinkPath, + parseFileLocation, +} from "../../../utils/fileLinks"; import { decodeFileLink, isFileLinkUrl, @@ -196,9 +201,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/", @@ -220,7 +222,7 @@ 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) { @@ -313,24 +315,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 +339,38 @@ 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) { 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 @@ -532,10 +486,13 @@ function parseFileReference( rawPath: string, 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 trimmed = normalizeFileLinkPath(rawPath); + const parsedLocation = parseFileLocation(trimmed); + 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("/"); @@ -702,57 +659,40 @@ export function Markdown({ 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; } - 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 = fromFileUrl(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 decodedPath = safeDecodeURIComponent(linkableCandidate); + return normalizeFileLinkPath(decodedPath ?? linkableCandidate); } } - const fileUrlPath = toPathFromFileUrl(url); - if (!fileUrlPath) { - return null; - } - return getLinkablePath(fileUrlPath); + return null; }; const components: Components = { a: ({ href, children }) => { @@ -812,6 +752,7 @@ export function Markdown({ return ( @@ -885,6 +826,9 @@ export function Markdown({ remarkPlugins={[remarkGfm, remarkFileLinks]} urlTransform={(url) => { const hasScheme = /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(url); + if (resolveHrefFilePath(url)) { + return url; + } if ( isFileLinkUrl(url) || url.startsWith("http://") || diff --git a/src/features/messages/components/Messages.test.tsx b/src/features/messages/components/Messages.test.tsx index ea2f606a9..f99455a3c 100644 --- a/src/features/messages/components/Messages.test.tsx +++ b/src/features/messages/components/Messages.test.tsx @@ -383,6 +383,35 @@ describe("Messages", () => { ); }); + 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")); + expect(openFileLinkMock).toHaveBeenCalledWith( + "I:\\gpt-projects\\CodexMonitor\\src\\features\\settings\\components\\sections\\SettingsDisplaySection.tsx:422", + ); + }); + it("routes dotless workspace href file paths through the file opener", () => { const linkedPath = "/workspace/CodexMonitor/LICENSE"; const items: ConversationItem[] = [ diff --git a/src/features/messages/hooks/useFileLinkOpener.test.tsx b/src/features/messages/hooks/useFileLinkOpener.test.tsx index 42b6816a9..706a78fea 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,121 @@ describe("useFileLinkOpener", () => { vi.clearAllMocks(); }); + it("copies namespace-prefixed Windows drive paths as valid 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:///C:/repo/src/App.tsx#L42"); + }); + + it("copies namespace-prefixed Windows UNC paths as valid 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://server/share/repo/App.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); diff --git a/src/features/messages/hooks/useFileLinkOpener.ts b/src/features/messages/hooks/useFileLinkOpener.ts index 65ea91bdd..0b3a8ab7b 100644 --- a/src/features/messages/hooks/useFileLinkOpener.ts +++ b/src/features/messages/hooks/useFileLinkOpener.ts @@ -8,6 +8,7 @@ import * as Sentry from "@sentry/react"; import { openWorkspaceIn } from "../../../services/tauri"; import { pushErrorToast } from "../../../services/toasts"; import type { OpenAppTarget } from "../../../types"; +import { parseFileLocation, toFileUrl } from "../../../utils/fileLinks"; import { isAbsolutePath, joinWorkspacePath, @@ -61,86 +62,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[], diff --git a/src/utils/fileLinks.ts b/src/utils/fileLinks.ts new file mode 100644 index 000000000..17fdc80e4 --- /dev/null +++ b/src/utils/fileLinks.ts @@ -0,0 +1,199 @@ +export 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; + +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; +} + +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); +} + +type FileUrlParts = { + host: string; + pathname: string; +}; + +function encodeFileUrlPathname(pathname: string) { + 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) { + const [, server, share, rest = ""] = namespaceUncMatch; + const normalizedRest = rest.replace(/\\/g, "/").replace(/^\/+/, ""); + return { + host: server, + pathname: `/${share}${normalizedRest ? `/${normalizedRest}` : ""}`, + }; + } + + const namespaceDriveMatch = normalizedWindowsPath.match(/^\\\\\?\\([A-Za-z]:)(.*)$/); + if (namespaceDriveMatch) { + const [, driveRoot, rest = ""] = namespaceDriveMatch; + return { + host: "", + pathname: `/${driveRoot}${rest.replace(/\\/g, "/")}`, + }; + } + + 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)}`; + } + if (line === null) { + return base; + } + return `${base}#L${line}${column !== null ? `C${column}` : ""}`; +} + +export function fromFileUrl(url: string) { + if (!url.toLowerCase().startsWith("file://")) { + return null; + } + + try { + const parsed = new URL(url); + if (parsed.protocol !== "file:") { + return null; + } + + const decodedPath = decodeURIComponent(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 normalizeFileLinkPath(`${path}${parsed.hash}`); + } catch { + const manualPath = url.slice("file://".length).trim(); + if (!manualPath) { + return null; + } + try { + return normalizeFileLinkPath(decodeURIComponent(manualPath)); + } catch { + return normalizeFileLinkPath(manualPath); + } + } +} diff --git a/src/utils/remarkFileLinks.ts b/src/utils/remarkFileLinks.ts index 4824452fa..fb85ae73d 100644 --- a/src/utils/remarkFileLinks.ts +++ b/src/utils/remarkFileLinks.ts @@ -1,15 +1,23 @@ +import { FILE_LINK_SUFFIX_SOURCE, 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 +28,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 +88,14 @@ 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); nodes.push({ type: "link", - url: toFileLink(path), + url: toFileLink(normalizedPath), children: [{ type: "text", value: path }], }); if (trailing) { From ac232a32054a45761850524f75d3222568b14ec3 Mon Sep 17 00:00:00 2001 From: Reekin Date: Fri, 20 Mar 2026 01:41:30 +0800 Subject: [PATCH 02/11] fix: ignore non-line file url fragments --- .../messages/components/Markdown.test.tsx | 44 +++++++++++++++++++ src/utils/fileLinks.ts | 5 ++- 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/src/features/messages/components/Markdown.test.tsx b/src/features/messages/components/Markdown.test.tsx index 3682b7bd7..1d647d1bf 100644 --- a/src/features/messages/components/Markdown.test.tsx +++ b/src/features/messages/components/Markdown.test.tsx @@ -406,4 +406,48 @@ describe("Markdown file-like href behavior", () => { 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); + expect(onOpenFileLink).toHaveBeenCalledWith("/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); + expect(onOpenFileLink).toHaveBeenCalledWith("/tmp/report.md:12"); + }); }); diff --git a/src/utils/fileLinks.ts b/src/utils/fileLinks.ts index 17fdc80e4..9825c5789 100644 --- a/src/utils/fileLinks.ts +++ b/src/utils/fileLinks.ts @@ -184,7 +184,10 @@ export function fromFileUrl(url: string) { if (/^\/[A-Za-z]:\//.test(path)) { path = path.slice(1); } - return normalizeFileLinkPath(`${path}${parsed.hash}`); + const normalizedHash = FILE_LOCATION_HASH_PATTERN.test(parsed.hash) + ? parsed.hash + : ""; + return normalizeFileLinkPath(`${path}${normalizedHash}`); } catch { const manualPath = url.slice("file://".length).trim(); if (!manualPath) { From b10b4c77efe4b4765ec039369512d1f3c83ef3cd Mon Sep 17 00:00:00 2001 From: Reekin Date: Fri, 20 Mar 2026 01:59:10 +0800 Subject: [PATCH 03/11] fix: keep workspace routes out of file links --- .../messages/components/Markdown.test.tsx | 72 +++++++++++++++++++ src/features/messages/components/Markdown.tsx | 10 +++ src/utils/fileLinks.ts | 19 +++++ src/utils/remarkFileLinks.ts | 14 +++- 4 files changed, 114 insertions(+), 1 deletion(-) diff --git a/src/features/messages/components/Markdown.test.tsx b/src/features/messages/components/Markdown.test.tsx index 1d647d1bf..bb6d41c8b 100644 --- a/src/features/messages/components/Markdown.test.tsx +++ b/src/features/messages/components/Markdown.test.tsx @@ -313,6 +313,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( normalizedPath.startsWith(prefix)) + ) { + return isLikelyMountedWorkspaceFilePath(normalizedPath, workspacePath); + } return true; } if (hasLikelyFileName(pathOnly)) { @@ -664,6 +671,9 @@ export function Markdown({ if (!normalizedPath) { return null; } + if (isKnownLocalWorkspaceRouteFilePath(normalizedPath)) { + return null; + } if (!isLinkableFilePath(normalizedPath)) { return null; } diff --git a/src/utils/fileLinks.ts b/src/utils/fileLinks.ts index 9825c5789..21a7d8477 100644 --- a/src/utils/fileLinks.ts +++ b/src/utils/fileLinks.ts @@ -83,6 +83,25 @@ export function normalizeFileLinkPath(rawPath: string) { return formatFileLocation(parsed.path, parsed.line, parsed.column); } +export function isKnownLocalWorkspaceRoutePath(rawPath: string) { + const normalizedPath = parseFileLocation(rawPath).path.trim().replace(/\\/g, "/"); + if (normalizedPath.startsWith("/workspace/")) { + const routeSegment = normalizedPath + .slice("/workspace/".length) + .split("/") + .filter(Boolean)[0]; + return routeSegment === "reviews" || routeSegment === "settings"; + } + if (normalizedPath.startsWith("/workspaces/")) { + const routeSegment = normalizedPath + .slice("/workspaces/".length) + .split("/") + .filter(Boolean)[1]; + return routeSegment === "reviews" || routeSegment === "settings"; + } + return false; +} + type FileUrlParts = { host: string; pathname: string; diff --git a/src/utils/remarkFileLinks.ts b/src/utils/remarkFileLinks.ts index fb85ae73d..278d86492 100644 --- a/src/utils/remarkFileLinks.ts +++ b/src/utils/remarkFileLinks.ts @@ -1,4 +1,8 @@ -import { FILE_LINK_SUFFIX_SOURCE, normalizeFileLinkPath } from "./fileLinks"; +import { + FILE_LINK_SUFFIX_SOURCE, + isKnownLocalWorkspaceRoutePath, + normalizeFileLinkPath, +} from "./fileLinks"; const FILE_LINK_PROTOCOL = "codex-file:"; const POSIX_OR_RELATIVE_FILE_PATH_PATTERN = @@ -93,6 +97,11 @@ function linkifyText(value: string) { const { path, trailing } = splitTrailingPunctuation(raw); 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(normalizedPath), @@ -154,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; } From 158bba560267c6bc97c088411ea81636a58bbcc9 Mon Sep 17 00:00:00 2001 From: Reekin Date: Fri, 20 Mar 2026 02:14:00 +0800 Subject: [PATCH 04/11] fix: preserve file url path info on decode fallback --- .../messages/components/Markdown.test.tsx | 44 +++++++ src/utils/fileLinks.test.ts | 54 +++++++++ src/utils/fileLinks.ts | 107 ++++++++++++++---- 3 files changed, 184 insertions(+), 21 deletions(-) create mode 100644 src/utils/fileLinks.test.ts diff --git a/src/features/messages/components/Markdown.test.tsx b/src/features/messages/components/Markdown.test.tsx index bb6d41c8b..93f05a9fe 100644 --- a/src/features/messages/components/Markdown.test.tsx +++ b/src/features/messages/components/Markdown.test.tsx @@ -522,4 +522,48 @@ describe("Markdown file-like href behavior", () => { expect(clickEvent.defaultPrevented).toBe(true); expect(onOpenFileLink).toHaveBeenCalledWith("/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); + expect(onOpenFileLink).toHaveBeenCalledWith("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); + expect(onOpenFileLink).toHaveBeenCalledWith("//server/share/100%.tsx:12"); + }); }); diff --git a/src/utils/fileLinks.test.ts b/src/utils/fileLinks.test.ts new file mode 100644 index 000000000..91e92bb69 --- /dev/null +++ b/src/utils/fileLinks.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; +import { fromFileUrl } 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 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", + ); + }); + }); +}); diff --git a/src/utils/fileLinks.ts b/src/utils/fileLinks.ts index 21a7d8477..b9536a67f 100644 --- a/src/utils/fileLinks.ts +++ b/src/utils/fileLinks.ts @@ -19,6 +19,85 @@ function parsePositiveInteger(value?: string) { return Number.isFinite(parsed) && parsed > 0 ? parsed : null; } +function decodeURIComponentSafely(value: string) { + try { + return decodeURIComponent(value); + } catch { + return value; + } +} + +function normalizeRecognizedFileUrlHash(hash: string) { + return FILE_LOCATION_HASH_PATTERN.test(hash) ? hash : ""; +} + +function buildLocalPathFromFileUrl(host: string, pathname: string) { + const decodedPath = decodeURIComponentSafely(pathname); + 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); @@ -192,30 +271,16 @@ export function fromFileUrl(url: string) { return null; } - const decodedPath = decodeURIComponent(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); - } - const normalizedHash = FILE_LOCATION_HASH_PATTERN.test(parsed.hash) - ? parsed.hash - : ""; + const path = buildLocalPathFromFileUrl(parsed.host, parsed.pathname); + const normalizedHash = normalizeRecognizedFileUrlHash(parsed.hash); return normalizeFileLinkPath(`${path}${normalizedHash}`); } catch { - const manualPath = url.slice("file://".length).trim(); - if (!manualPath) { + const manualParts = parseManualFileUrl(url); + if (!manualParts) { return null; } - try { - return normalizeFileLinkPath(decodeURIComponent(manualPath)); - } catch { - return normalizeFileLinkPath(manualPath); - } + const path = buildLocalPathFromFileUrl(manualParts.host, manualParts.pathname); + const normalizedHash = normalizeRecognizedFileUrlHash(manualParts.hash); + return normalizeFileLinkPath(`${path}${normalizedHash}`); } } From 08f6339091430c32de5cd5c91290d82ebc937c02 Mon Sep 17 00:00:00 2001 From: Reekin Date: Fri, 20 Mar 2026 02:29:19 +0800 Subject: [PATCH 05/11] fix: parse file url anchors without re-parsing paths --- .../messages/components/Markdown.test.tsx | 22 ++++++++++++++++ src/features/messages/components/Markdown.tsx | 2 ++ src/utils/fileLinks.test.ts | 19 ++++++++++++++ src/utils/fileLinks.ts | 26 ++++++++++++++----- 4 files changed, 63 insertions(+), 6 deletions(-) diff --git a/src/features/messages/components/Markdown.test.tsx b/src/features/messages/components/Markdown.test.tsx index 93f05a9fe..c7324ec38 100644 --- a/src/features/messages/components/Markdown.test.tsx +++ b/src/features/messages/components/Markdown.test.tsx @@ -566,4 +566,26 @@ describe("Markdown file-like href behavior", () => { expect(clickEvent.defaultPrevented).toBe(true); expect(onOpenFileLink).toHaveBeenCalledWith("//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); + expect(onOpenFileLink).toHaveBeenCalledWith("/tmp/report#L12.md"); + }); }); diff --git a/src/features/messages/components/Markdown.tsx b/src/features/messages/components/Markdown.tsx index 6697526ef..7b7a22c1b 100644 --- a/src/features/messages/components/Markdown.tsx +++ b/src/features/messages/components/Markdown.tsx @@ -836,6 +836,8 @@ 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; } diff --git a/src/utils/fileLinks.test.ts b/src/utils/fileLinks.test.ts index 91e92bb69..4284ac489 100644 --- a/src/utils/fileLinks.test.ts +++ b/src/utils/fileLinks.test.ts @@ -25,6 +25,18 @@ function withThrowingUrlConstructor(run: () => void) { } 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"); }); @@ -51,4 +63,11 @@ describe("fromFileUrl", () => { ); }); }); + + 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"); + }); + }); }); diff --git a/src/utils/fileLinks.ts b/src/utils/fileLinks.ts index b9536a67f..4bddb05af 100644 --- a/src/utils/fileLinks.ts +++ b/src/utils/fileLinks.ts @@ -7,6 +7,7 @@ export type 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; export const FILE_LINK_SUFFIX_SOURCE = "(?:(?::\\d+(?::\\d+)?|:\\d+-\\d+)|(?:#L\\d+(?:C\\d+)?))?"; @@ -27,8 +28,21 @@ function decodeURIComponentSafely(value: string) { } } -function normalizeRecognizedFileUrlHash(hash: string) { - return FILE_LOCATION_HASH_PATTERN.test(hash) ? hash : ""; +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) { @@ -272,15 +286,15 @@ export function fromFileUrl(url: string) { } const path = buildLocalPathFromFileUrl(parsed.host, parsed.pathname); - const normalizedHash = normalizeRecognizedFileUrlHash(parsed.hash); - return normalizeFileLinkPath(`${path}${normalizedHash}`); + const { line, column } = parseRecognizedFileUrlHash(parsed.hash); + return formatFileLocation(path, line, column); } catch { const manualParts = parseManualFileUrl(url); if (!manualParts) { return null; } const path = buildLocalPathFromFileUrl(manualParts.host, manualParts.pathname); - const normalizedHash = normalizeRecognizedFileUrlHash(manualParts.hash); - return normalizeFileLinkPath(`${path}${normalizedHash}`); + const { line, column } = parseRecognizedFileUrlHash(manualParts.hash); + return formatFileLocation(path, line, column); } } From eecd1a387a6d20176c4726dbea61ead4f84e7909 Mon Sep 17 00:00:00 2001 From: Reekin Date: Fri, 20 Mar 2026 02:47:24 +0800 Subject: [PATCH 06/11] fix: narrow mounted route guards for file links --- .../messages/components/Markdown.test.tsx | 52 +++++++++++++++++-- src/features/messages/components/Markdown.tsx | 10 ++-- src/utils/fileLinks.test.ts | 22 +++++++- src/utils/fileLinks.ts | 16 +++--- 4 files changed, 86 insertions(+), 14 deletions(-) diff --git a/src/features/messages/components/Markdown.test.tsx b/src/features/messages/components/Markdown.test.tsx index c7324ec38..5f25ca83a 100644 --- a/src/features/messages/components/Markdown.test.tsx +++ b/src/features/messages/components/Markdown.test.tsx @@ -161,19 +161,19 @@ describe("Markdown file-like href behavior", () => { expect(onOpenFileLink).toHaveBeenCalledWith("/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("overview").closest("a"); - expect(link?.getAttribute("href")).toBe("/workspace/reviews/overview"); + const link = screen.getByText("reviews").closest("a"); + expect(link?.getAttribute("href")).toBe("/workspace/reviews"); const clickEvent = createEvent.click(link as Element, { bubbles: true, @@ -588,4 +588,48 @@ describe("Markdown file-like href behavior", () => { expect(clickEvent.defaultPrevented).toBe(true); expect(onOpenFileLink).toHaveBeenCalledWith("/tmp/report#L12.md"); }); + + 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); + expect(onOpenFileLink).toHaveBeenCalledWith("/workspace/settings/src/App.tsx"); + }); + + 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 7b7a22c1b..9468d11ac 100644 --- a/src/features/messages/components/Markdown.tsx +++ b/src/features/messages/components/Markdown.tsx @@ -274,9 +274,13 @@ function isKnownLocalWorkspaceRoutePath(path: string) { const routeSegment = mountedPath.prefix === "/workspace/" - ? mountedPath.segments[0] - : mountedPath.segments[1]; - return Boolean(routeSegment) && LOCAL_WORKSPACE_ROUTE_SEGMENTS.has(routeSegment); + ? mountedPath.segments.length === 1 + ? mountedPath.segments[0] + : null + : mountedPath.segments.length === 2 + ? mountedPath.segments[1] + : null; + return routeSegment !== null && LOCAL_WORKSPACE_ROUTE_SEGMENTS.has(routeSegment); } function isLikelyMountedWorkspaceFilePath( diff --git a/src/utils/fileLinks.test.ts b/src/utils/fileLinks.test.ts index 4284ac489..b57595660 100644 --- a/src/utils/fileLinks.test.ts +++ b/src/utils/fileLinks.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { fromFileUrl } from "./fileLinks"; +import { fromFileUrl, isKnownLocalWorkspaceRoutePath } from "./fileLinks"; function withThrowingUrlConstructor(run: () => void) { const originalUrl = globalThis.URL; @@ -71,3 +71,23 @@ describe("fromFileUrl", () => { }); }); }); + +describe("isKnownLocalWorkspaceRoutePath", () => { + it("matches only 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("does not treat deeper mounted paths as reserved routes", () => { + 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, + ); + }); +}); diff --git a/src/utils/fileLinks.ts b/src/utils/fileLinks.ts index 4bddb05af..c95ad2f25 100644 --- a/src/utils/fileLinks.ts +++ b/src/utils/fileLinks.ts @@ -179,18 +179,22 @@ export function normalizeFileLinkPath(rawPath: string) { export function isKnownLocalWorkspaceRoutePath(rawPath: string) { const normalizedPath = parseFileLocation(rawPath).path.trim().replace(/\\/g, "/"); if (normalizedPath.startsWith("/workspace/")) { - const routeSegment = normalizedPath + const mountedSegments = normalizedPath .slice("/workspace/".length) .split("/") - .filter(Boolean)[0]; - return routeSegment === "reviews" || routeSegment === "settings"; + .filter(Boolean); + return mountedSegments.length === 1 + ? mountedSegments[0] === "reviews" || mountedSegments[0] === "settings" + : false; } if (normalizedPath.startsWith("/workspaces/")) { - const routeSegment = normalizedPath + const mountedSegments = normalizedPath .slice("/workspaces/".length) .split("/") - .filter(Boolean)[1]; - return routeSegment === "reviews" || routeSegment === "settings"; + .filter(Boolean); + return mountedSegments.length === 2 + ? mountedSegments[1] === "reviews" || mountedSegments[1] === "settings" + : false; } return false; } From 920636224843be05685a59b108cc7477b2b35787 Mon Sep 17 00:00:00 2001 From: Reekin Date: Sat, 21 Mar 2026 15:52:37 +0800 Subject: [PATCH 07/11] fix: preserve file link routes and namespace paths --- .../messages/components/Markdown.test.tsx | 46 ++++++++ src/features/messages/components/Markdown.tsx | 20 +--- .../messages/hooks/useFileLinkOpener.test.tsx | 10 +- .../messages/utils/mountedWorkspacePaths.ts | 4 + src/utils/fileLinks.test.ts | 27 ++++- src/utils/fileLinks.ts | 105 +++++++++++++++--- 6 files changed, 169 insertions(+), 43 deletions(-) diff --git a/src/features/messages/components/Markdown.test.tsx b/src/features/messages/components/Markdown.test.tsx index 5f25ca83a..115b8ffb1 100644 --- a/src/features/messages/components/Markdown.test.tsx +++ b/src/features/messages/components/Markdown.test.tsx @@ -207,6 +207,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( @@ -611,6 +634,29 @@ describe("Markdown file-like href behavior", () => { expect(onOpenFileLink).toHaveBeenCalledWith("/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( diff --git a/src/features/messages/components/Markdown.tsx b/src/features/messages/components/Markdown.tsx index 9468d11ac..aaf9ebb7d 100644 --- a/src/features/messages/components/Markdown.tsx +++ b/src/features/messages/components/Markdown.tsx @@ -220,7 +220,6 @@ 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 parseFileLocation(value).path; @@ -266,28 +265,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.length === 1 - ? mountedPath.segments[0] - : null - : mountedPath.segments.length === 2 - ? mountedPath.segments[1] - : null; - return routeSegment !== null && 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) { diff --git a/src/features/messages/hooks/useFileLinkOpener.test.tsx b/src/features/messages/hooks/useFileLinkOpener.test.tsx index 706a78fea..dcafd71a2 100644 --- a/src/features/messages/hooks/useFileLinkOpener.test.tsx +++ b/src/features/messages/hooks/useFileLinkOpener.test.tsx @@ -53,7 +53,7 @@ describe("useFileLinkOpener", () => { vi.clearAllMocks(); }); - it("copies namespace-prefixed Windows drive paths as valid file URLs", async () => { + it("copies namespace-prefixed Windows drive paths as round-trippable file URLs", async () => { const clipboardWriteTextMock = vi.fn(); Object.defineProperty(navigator, "clipboard", { value: { writeText: clipboardWriteTextMock }, @@ -87,10 +87,12 @@ describe("useFileLinkOpener", () => { await copyLinkItem?.action?.(); - expect(clipboardWriteTextMock).toHaveBeenCalledWith("file:///C:/repo/src/App.tsx#L42"); + expect(clipboardWriteTextMock).toHaveBeenCalledWith( + "file:///%5C%5C%3F%5CC%3A%5Crepo%5Csrc%5CApp.tsx#L42", + ); }); - it("copies namespace-prefixed Windows UNC paths as valid file URLs", async () => { + it("copies namespace-prefixed Windows UNC paths as round-trippable file URLs", async () => { const clipboardWriteTextMock = vi.fn(); Object.defineProperty(navigator, "clipboard", { value: { writeText: clipboardWriteTextMock }, @@ -125,7 +127,7 @@ describe("useFileLinkOpener", () => { await copyLinkItem?.action?.(); expect(clipboardWriteTextMock).toHaveBeenCalledWith( - "file://server/share/repo/App.tsx#L42", + "file:///%5C%5C%3F%5CUNC%5Cserver%5Cshare%5Crepo%5CApp.tsx#L42", ); }); 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 index b57595660..8ca5bf88e 100644 --- a/src/utils/fileLinks.test.ts +++ b/src/utils/fileLinks.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { fromFileUrl, isKnownLocalWorkspaceRoutePath } from "./fileLinks"; +import { fromFileUrl, isKnownLocalWorkspaceRoutePath, toFileUrl } from "./fileLinks"; function withThrowingUrlConstructor(run: () => void) { const originalUrl = globalThis.URL; @@ -70,17 +70,38 @@ describe("fromFileUrl", () => { 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 only exact mounted settings and reviews routes", () => { + 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("does not treat deeper mounted paths as reserved routes", () => { + it("keeps 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( diff --git a/src/utils/fileLinks.ts b/src/utils/fileLinks.ts index c95ad2f25..a31510fbd 100644 --- a/src/utils/fileLinks.ts +++ b/src/utils/fileLinks.ts @@ -8,6 +8,24 @@ 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 RESERVED_LOCAL_WORKSPACE_ROUTE_SEGMENTS = new Set(["reviews", "settings"]); +const LIKELY_WORKSPACE_FILE_HEAD_SEGMENTS = new Set([ + ".github", + ".vscode", + "app", + "assets", + "components", + "dist", + "docs", + "hooks", + "lib", + "public", + "scripts", + "src", + "test", + "tests", + "utils", +]); export const FILE_LINK_SUFFIX_SOURCE = "(?:(?::\\d+(?::\\d+)?|:\\d+-\\d+)|(?:#L\\d+(?:C\\d+)?))?"; @@ -47,6 +65,9 @@ function parseRecognizedFileUrlHash(hash: string) { 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}`; @@ -176,35 +197,83 @@ export function normalizeFileLinkPath(rawPath: string) { return formatFileLocation(parsed.path, parsed.line, parsed.column); } -export function isKnownLocalWorkspaceRoutePath(rawPath: string) { - const normalizedPath = parseFileLocation(rawPath).path.trim().replace(/\\/g, "/"); +function hasLikelyFileNameSegment(segment: string) { + return segment.startsWith(".") ? segment.length > 1 : segment.includes("."); +} + +function hasLikelyMountedWorkspaceFileTail( + segments: string[], + line: number | null, +) { + if (segments.length === 0) { + return false; + } + if (line !== null) { + return true; + } + + const [firstSegment] = segments; + const lastSegment = segments[segments.length - 1]; + return ( + hasLikelyFileNameSegment(lastSegment) || + LIKELY_WORKSPACE_FILE_HEAD_SEGMENTS.has(firstSegment) + ); +} + +function getLocalWorkspaceRouteInfo(rawPath: string) { + const parsed = parseFileLocation(rawPath); + const normalizedPath = parsed.path.trim().replace(/\\/g, "/"); if (normalizedPath.startsWith("/workspace/")) { const mountedSegments = normalizedPath .slice("/workspace/".length) .split("/") .filter(Boolean); - return mountedSegments.length === 1 - ? mountedSegments[0] === "reviews" || mountedSegments[0] === "settings" - : false; + 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 mountedSegments.length === 2 - ? mountedSegments[1] === "reviews" || mountedSegments[1] === "settings" - : false; + 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; } - return false; + if (!RESERVED_LOCAL_WORKSPACE_ROUTE_SEGMENTS.has(routeInfo.routeSegment)) { + return false; + } + return !hasLikelyMountedWorkspaceFileTail(routeInfo.tailSegments, routeInfo.line); } type FileUrlParts = { host: string; pathname: string; + treatPathnameAsOpaque?: boolean; }; -function encodeFileUrlPathname(pathname: string) { +function encodeFileUrlPathname(pathname: string, treatPathnameAsOpaque = false) { + if (treatPathnameAsOpaque) { + return pathname + .split("/") + .map((segment) => encodeURIComponent(segment)) + .join("/"); + } return pathname .split("/") .map((segment, index) => { @@ -222,20 +291,19 @@ function toFileUrlParts(path: string): FileUrlParts | null { /^\\\\\?\\UNC\\([^\\]+)\\([^\\]+)(.*)$/i, ); if (namespaceUncMatch) { - const [, server, share, rest = ""] = namespaceUncMatch; - const normalizedRest = rest.replace(/\\/g, "/").replace(/^\/+/, ""); return { - host: server, - pathname: `/${share}${normalizedRest ? `/${normalizedRest}` : ""}`, + host: "", + pathname: `/${normalizedWindowsPath}`, + treatPathnameAsOpaque: true, }; } const namespaceDriveMatch = normalizedWindowsPath.match(/^\\\\\?\\([A-Za-z]:)(.*)$/); if (namespaceDriveMatch) { - const [, driveRoot, rest = ""] = namespaceDriveMatch; return { host: "", - pathname: `/${driveRoot}${rest.replace(/\\/g, "/")}`, + pathname: `/${normalizedWindowsPath}`, + treatPathnameAsOpaque: true, }; } @@ -270,7 +338,10 @@ export function toFileUrl(path: string, line: number | null, column: number | nu const parts = toFileUrlParts(path); let base = path; if (parts) { - base = `file://${parts.host}${encodeFileUrlPathname(parts.pathname)}`; + base = `file://${parts.host}${encodeFileUrlPathname( + parts.pathname, + parts.treatPathnameAsOpaque, + )}`; } if (line === null) { return base; From b071892d61f538f7b693cd29473bb22a17815c44 Mon Sep 17 00:00:00 2001 From: Reekin Date: Sat, 21 Mar 2026 16:09:29 +0800 Subject: [PATCH 08/11] fix: restore extensionless mounted file links --- .../messages/components/Markdown.test.tsx | 45 ++++++++++ .../messages/hooks/useFileLinkOpener.test.tsx | 15 ++++ src/utils/fileLinks.test.ts | 13 ++- src/utils/fileLinks.ts | 89 ++++++++++--------- 4 files changed, 119 insertions(+), 43 deletions(-) diff --git a/src/features/messages/components/Markdown.test.tsx b/src/features/messages/components/Markdown.test.tsx index 115b8ffb1..138ec1fe1 100644 --- a/src/features/messages/components/Markdown.test.tsx +++ b/src/features/messages/components/Markdown.test.tsx @@ -253,6 +253,29 @@ describe("Markdown file-like href behavior", () => { expect(onOpenFileLink).toHaveBeenCalledWith("/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); + expect(onOpenFileLink).toHaveBeenCalledWith("/workspace/settings/LICENSE"); + }); + it("intercepts file hrefs that use #L line anchors", () => { const onOpenFileLink = vi.fn(); render( @@ -447,6 +470,28 @@ describe("Markdown file-like href behavior", () => { 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( { ); }); + 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); diff --git a/src/utils/fileLinks.test.ts b/src/utils/fileLinks.test.ts index 8ca5bf88e..6fd884a20 100644 --- a/src/utils/fileLinks.test.ts +++ b/src/utils/fileLinks.test.ts @@ -94,7 +94,7 @@ describe("isKnownLocalWorkspaceRoutePath", () => { expect(isKnownLocalWorkspaceRoutePath("/workspaces/team/reviews")).toBe(true); }); - it("keeps nested settings and reviews app routes out of file resolution", () => { + 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); @@ -111,4 +111,15 @@ describe("isKnownLocalWorkspaceRoutePath", () => { 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 index a31510fbd..7eb6fe30b 100644 --- a/src/utils/fileLinks.ts +++ b/src/utils/fileLinks.ts @@ -8,24 +8,25 @@ 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 RESERVED_LOCAL_WORKSPACE_ROUTE_SEGMENTS = new Set(["reviews", "settings"]); -const LIKELY_WORKSPACE_FILE_HEAD_SEGMENTS = new Set([ - ".github", - ".vscode", - "app", - "assets", - "components", - "dist", - "docs", - "hooks", - "lib", - "public", - "scripts", - "src", - "test", - "tests", - "utils", -]); +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+)?))?"; @@ -197,32 +198,21 @@ export function normalizeFileLinkPath(rawPath: string) { return formatFileLocation(parsed.path, parsed.line, parsed.column); } -function hasLikelyFileNameSegment(segment: string) { - return segment.startsWith(".") ? segment.length > 1 : segment.includes("."); -} - -function hasLikelyMountedWorkspaceFileTail( - segments: string[], - line: number | null, -) { - if (segments.length === 0) { - return false; - } - if (line !== null) { - return true; - } - - const [firstSegment] = segments; - const lastSegment = segments[segments.length - 1]; - return ( - hasLikelyFileNameSegment(lastSegment) || - LIKELY_WORKSPACE_FILE_HEAD_SEGMENTS.has(firstSegment) - ); +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 = parsed.path.trim().replace(/\\/g, "/"); + const normalizedPath = stripNonLineUrlSuffix(parsed.path.trim().replace(/\\/g, "/")); if (normalizedPath.startsWith("/workspace/")) { const mountedSegments = normalizedPath .slice("/workspace/".length) @@ -255,10 +245,25 @@ export function isKnownLocalWorkspaceRoutePath(rawPath: string) { if (!routeInfo?.routeSegment) { return false; } - if (!RESERVED_LOCAL_WORKSPACE_ROUTE_SEGMENTS.has(routeInfo.routeSegment)) { + 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; } - return !hasLikelyMountedWorkspaceFileTail(routeInfo.tailSegments, routeInfo.line); + 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 = { From eb3f373f1c225df7f0fd87237421e4aea2719760 Mon Sep 17 00:00:00 2001 From: Reekin Date: Sat, 21 Mar 2026 16:29:21 +0800 Subject: [PATCH 09/11] test: cover nested singular workspace review routes --- .../messages/components/Markdown.test.tsx | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src/features/messages/components/Markdown.test.tsx b/src/features/messages/components/Markdown.test.tsx index 138ec1fe1..81784c356 100644 --- a/src/features/messages/components/Markdown.test.tsx +++ b/src/features/messages/components/Markdown.test.tsx @@ -184,6 +184,29 @@ describe("Markdown file-like href behavior", () => { 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"); + + 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 workspaces routes as normal markdown links", () => { const onOpenFileLink = vi.fn(); render( From 136b706c38008b74879883aa7d844769e08253e5 Mon Sep 17 00:00:00 2001 From: Reekin Date: Sat, 21 Mar 2026 16:42:37 +0800 Subject: [PATCH 10/11] fix: preserve encoded href line markers in filenames --- .../messages/components/Markdown.test.tsx | 44 +++++++++++++++++++ src/features/messages/components/Markdown.tsx | 10 ++++- 2 files changed, 52 insertions(+), 2 deletions(-) diff --git a/src/features/messages/components/Markdown.test.tsx b/src/features/messages/components/Markdown.test.tsx index 81784c356..3be7d23b4 100644 --- a/src/features/messages/components/Markdown.test.tsx +++ b/src/features/messages/components/Markdown.test.tsx @@ -680,6 +680,50 @@ describe("Markdown file-like href behavior", () => { expect(onOpenFileLink).toHaveBeenCalledWith("/tmp/report#L12.md"); }); + 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); + expect(onOpenFileLink).toHaveBeenCalledWith("./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); + expect(onOpenFileLink).toHaveBeenCalledWith("./report.md#L12C3"); + }); + it("still opens mounted file links when the workspace basename is settings", () => { const onOpenFileLink = vi.fn(); render( diff --git a/src/features/messages/components/Markdown.tsx b/src/features/messages/components/Markdown.tsx index aaf9ebb7d..eccdf25d6 100644 --- a/src/features/messages/components/Markdown.tsx +++ b/src/features/messages/components/Markdown.tsx @@ -3,6 +3,7 @@ import ReactMarkdown, { type Components } from "react-markdown"; import remarkGfm from "remark-gfm"; import { openUrl } from "@tauri-apps/plugin-opener"; import { + formatFileLocation, fromFileUrl, isKnownLocalWorkspaceRoutePath as isKnownLocalWorkspaceRouteFilePath, normalizeFileLinkPath, @@ -684,8 +685,13 @@ export function Markdown({ continue; } if (isLikelyFileHref(linkableCandidate, workspacePath)) { - const decodedPath = safeDecodeURIComponent(linkableCandidate); - return normalizeFileLinkPath(decodedPath ?? linkableCandidate); + const parsedCandidate = parseFileLocation(linkableCandidate); + const decodedPath = safeDecodeURIComponent(parsedCandidate.path); + return formatFileLocation( + decodedPath ?? parsedCandidate.path, + parsedCandidate.line, + parsedCandidate.column, + ); } } return null; From 02b32cb372754750994f6ef7dc17661cb5520073 Mon Sep 17 00:00:00 2001 From: Reekin Date: Sat, 21 Mar 2026 17:03:05 +0800 Subject: [PATCH 11/11] fix: avoid reparsing structured file link targets --- .../messages/components/Markdown.test.tsx | 75 ++++++++++++++----- src/features/messages/components/Markdown.tsx | 58 ++++++++------ .../messages/components/MessageRows.tsx | 5 +- .../messages/components/Messages.test.tsx | 33 ++++++-- .../messages/hooks/useFileLinkOpener.test.tsx | 18 +++++ .../messages/hooks/useFileLinkOpener.ts | 33 ++++++-- src/utils/fileLinks.ts | 13 +++- 7 files changed, 175 insertions(+), 60 deletions(-) diff --git a/src/features/messages/components/Markdown.test.tsx b/src/features/messages/components/Markdown.test.tsx index 3be7d23b4..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,7 +167,7 @@ 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 exact workspace routes as normal markdown links", () => { @@ -273,7 +282,7 @@ 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", () => { @@ -296,7 +305,7 @@ describe("Markdown file-like href behavior", () => { }); fireEvent(link as Element, clickEvent); expect(clickEvent.defaultPrevented).toBe(true); - expect(onOpenFileLink).toHaveBeenCalledWith("/workspace/settings/LICENSE"); + expectOpenedFileTarget(onOpenFileLink, "/workspace/settings/LICENSE"); }); it("intercepts file hrefs that use #L line anchors", () => { @@ -318,7 +327,7 @@ 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", () => { @@ -349,14 +358,20 @@ describe("Markdown file-like href behavior", () => { }); fireEvent(link as Element, clickEvent); expect(clickEvent.defaultPrevented).toBe(true); - expect(onOpenFileLink).toHaveBeenCalledWith( - "I:\\gpt-projects\\CodexMonitor\\src\\features\\settings\\components\\sections\\SettingsDisplaySection.tsx:422", + expectOpenedFileTarget( + onOpenFileLink, + "I:\\gpt-projects\\CodexMonitor\\src\\features\\settings\\components\\sections\\SettingsDisplaySection.tsx", + 422, ); fireEvent.contextMenu(link as Element); expect(onOpenFileLinkMenu).toHaveBeenCalledWith( expect.anything(), - "I:\\gpt-projects\\CodexMonitor\\src\\features\\settings\\components\\sections\\SettingsDisplaySection.tsx:422", + { + path: "I:\\gpt-projects\\CodexMonitor\\src\\features\\settings\\components\\sections\\SettingsDisplaySection.tsx", + line: 422, + column: null, + }, ); }); @@ -589,7 +604,7 @@ describe("Markdown file-like href behavior", () => { }); fireEvent(link as Element, clickEvent); expect(clickEvent.defaultPrevented).toBe(true); - expect(onOpenFileLink).toHaveBeenCalledWith("/tmp/report.md"); + expectOpenedFileTarget(onOpenFileLink, "/tmp/report.md"); }); it("keeps line anchors when opening file URLs", () => { @@ -611,7 +626,7 @@ describe("Markdown file-like href behavior", () => { }); fireEvent(link as Element, clickEvent); expect(clickEvent.defaultPrevented).toBe(true); - expect(onOpenFileLink).toHaveBeenCalledWith("/tmp/report.md:12"); + expectOpenedFileTarget(onOpenFileLink, "/tmp/report.md", 12); }); it("preserves Windows drive paths when file URL decoding encounters an unescaped percent", () => { @@ -633,7 +648,7 @@ describe("Markdown file-like href behavior", () => { }); fireEvent(link as Element, clickEvent); expect(clickEvent.defaultPrevented).toBe(true); - expect(onOpenFileLink).toHaveBeenCalledWith("C:/repo/100%.tsx:12"); + expectOpenedFileTarget(onOpenFileLink, "C:/repo/100%.tsx", 12); }); it("preserves UNC host paths when file URL decoding encounters an unescaped percent", () => { @@ -655,7 +670,7 @@ describe("Markdown file-like href behavior", () => { }); fireEvent(link as Element, clickEvent); expect(clickEvent.defaultPrevented).toBe(true); - expect(onOpenFileLink).toHaveBeenCalledWith("//server/share/100%.tsx:12"); + expectOpenedFileTarget(onOpenFileLink, "//server/share/100%.tsx", 12); }); it("keeps encoded #L-like filenames intact when opening file URLs", () => { @@ -677,7 +692,29 @@ describe("Markdown file-like href behavior", () => { }); fireEvent(link as Element, clickEvent); expect(clickEvent.defaultPrevented).toBe(true); - expect(onOpenFileLink).toHaveBeenCalledWith("/tmp/report#L12.md"); + 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", () => { @@ -699,7 +736,7 @@ describe("Markdown file-like href behavior", () => { }); fireEvent(link as Element, clickEvent); expect(clickEvent.defaultPrevented).toBe(true); - expect(onOpenFileLink).toHaveBeenCalledWith("./report.md#L12"); + expectOpenedFileTarget(onOpenFileLink, "./report.md#L12"); }); it("keeps encoded #L-like filename column endings intact when opening markdown hrefs", () => { @@ -721,7 +758,7 @@ describe("Markdown file-like href behavior", () => { }); fireEvent(link as Element, clickEvent); expect(clickEvent.defaultPrevented).toBe(true); - expect(onOpenFileLink).toHaveBeenCalledWith("./report.md#L12C3"); + expectOpenedFileTarget(onOpenFileLink, "./report.md#L12C3"); }); it("still opens mounted file links when the workspace basename is settings", () => { @@ -743,7 +780,7 @@ describe("Markdown file-like href behavior", () => { }); fireEvent(link as Element, clickEvent); expect(clickEvent.defaultPrevented).toBe(true); - expect(onOpenFileLink).toHaveBeenCalledWith("/workspace/settings/src/App.tsx"); + expectOpenedFileTarget(onOpenFileLink, "/workspace/settings/src/App.tsx"); }); it("keeps nested settings routes local when the workspace basename is settings", () => { diff --git a/src/features/messages/components/Markdown.tsx b/src/features/messages/components/Markdown.tsx index eccdf25d6..2829243bd 100644 --- a/src/features/messages/components/Markdown.tsx +++ b/src/features/messages/components/Markdown.tsx @@ -3,11 +3,13 @@ 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, - fromFileUrl, isKnownLocalWorkspaceRoutePath as isKnownLocalWorkspaceRouteFilePath, normalizeFileLinkPath, parseFileLocation, + parseFileUrlLocation, } from "../../../utils/fileLinks"; import { decodeFileLink, @@ -26,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; }; @@ -61,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, "/"); } @@ -477,11 +488,15 @@ function LinkBlock({ urls }: LinkBlockProps) { } function parseFileReference( - rawPath: string, + rawPath: FileLinkTarget, workspacePath?: string | null, ): ParsedFileReference { - const trimmed = normalizeFileLinkPath(rawPath); - const parsedLocation = parseFileLocation(trimmed); + const parsedLocation = toParsedFileTarget(rawPath); + const fullPath = formatFileLocation( + parsedLocation.path, + parsedLocation.line, + parsedLocation.column, + ); const pathWithoutLine = parsedLocation.path.trim(); const lineLabel = parsedLocation.line === null @@ -490,7 +505,7 @@ function parseFileReference( 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 = @@ -498,7 +513,7 @@ function parseFileReference( const parentPath = rawParentPath || (normalizedPath.startsWith("/") ? "/" : null); return { - fullPath: trimmed, + fullPath, fileName, lineLabel, parentPath, @@ -514,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, @@ -636,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); @@ -647,7 +662,7 @@ export function Markdown({ }; const handleFileLinkContextMenu = ( event: React.MouseEvent, - path: string, + path: FileLinkTarget, ) => { event.preventDefault(); event.stopPropagation(); @@ -667,7 +682,7 @@ export function Markdown({ return normalizedPath; }; const resolveHrefFilePath = (url: string) => { - const fileUrlPath = fromFileUrl(url); + const fileUrlPath = parseFileUrlLocation(url); if (fileUrlPath) { return fileUrlPath; } @@ -687,11 +702,11 @@ export function Markdown({ if (isLikelyFileHref(linkableCandidate, workspacePath)) { const parsedCandidate = parseFileLocation(linkableCandidate); const decodedPath = safeDecodeURIComponent(parsedCandidate.path); - return formatFileLocation( - decodedPath ?? parsedCandidate.path, - parsedCandidate.line, - parsedCandidate.column, - ); + return { + path: decodedPath ?? parsedCandidate.path, + line: parsedCandidate.line, + column: parsedCandidate.column, + }; } } return null; @@ -746,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 @@ -753,8 +769,8 @@ export function Markdown({ : undefined; return ( 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 f99455a3c..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,10 @@ 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, ); }); @@ -407,8 +422,10 @@ describe("Messages", () => { ); fireEvent.click(screen.getByText("settings display")); - expect(openFileLinkMock).toHaveBeenCalledWith( - "I:\\gpt-projects\\CodexMonitor\\src\\features\\settings\\components\\sections\\SettingsDisplaySection.tsx:422", + expectOpenedFileTarget( + openFileLinkMock, + "I:\\gpt-projects\\CodexMonitor\\src\\features\\settings\\components\\sections\\SettingsDisplaySection.tsx", + 422, ); }); @@ -435,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 093c03b71..61813a1b4 100644 --- a/src/features/messages/hooks/useFileLinkOpener.test.tsx +++ b/src/features/messages/hooks/useFileLinkOpener.test.tsx @@ -271,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 0b3a8ab7b..f82bb8b24 100644 --- a/src/features/messages/hooks/useFileLinkOpener.ts +++ b/src/features/messages/hooks/useFileLinkOpener.ts @@ -8,7 +8,12 @@ import * as Sentry from "@sentry/react"; import { openWorkspaceIn } from "../../../services/tauri"; import { pushErrorToast } from "../../../services/toasts"; import type { OpenAppTarget } from "../../../types"; -import { parseFileLocation, toFileUrl } from "../../../utils/fileLinks"; +import { + type FileLinkTarget, + formatFileLocation, + parseFileLocation, + toFileUrl, +} from "../../../utils/fileLinks"; import { isAbsolutePath, joinWorkspacePath, @@ -47,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) { @@ -89,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 } : {}), @@ -135,7 +149,7 @@ export function useFileLinkOpener( }); } catch (error) { reportOpenError(error, { - rawPath, + rawPath: rawPathLabel, resolvedPath, workspacePath, targetId: target.id, @@ -149,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 = { @@ -157,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); @@ -190,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/utils/fileLinks.ts b/src/utils/fileLinks.ts index 7eb6fe30b..f6b960017 100644 --- a/src/utils/fileLinks.ts +++ b/src/utils/fileLinks.ts @@ -4,6 +4,8 @@ export type ParsedFileLocation = { 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; @@ -354,7 +356,7 @@ export function toFileUrl(path: string, line: number | null, column: number | nu return `${base}#L${line}${column !== null ? `C${column}` : ""}`; } -export function fromFileUrl(url: string) { +export function parseFileUrlLocation(url: string): ParsedFileLocation | null { if (!url.toLowerCase().startsWith("file://")) { return null; } @@ -367,7 +369,7 @@ export function fromFileUrl(url: string) { const path = buildLocalPathFromFileUrl(parsed.host, parsed.pathname); const { line, column } = parseRecognizedFileUrlHash(parsed.hash); - return formatFileLocation(path, line, column); + return { path, line, column }; } catch { const manualParts = parseManualFileUrl(url); if (!manualParts) { @@ -375,6 +377,11 @@ export function fromFileUrl(url: string) { } const path = buildLocalPathFromFileUrl(manualParts.host, manualParts.pathname); const { line, column } = parseRecognizedFileUrlHash(manualParts.hash); - return formatFileLocation(path, line, column); + return { path, line, column }; } } + +export function fromFileUrl(url: string) { + const parsed = parseFileUrlLocation(url); + return parsed ? formatFileLocation(parsed.path, parsed.line, parsed.column) : null; +}