From c3616b379ff05edd1974ce3bb6a96235530b041c Mon Sep 17 00:00:00 2001 From: Reekin Date: Fri, 20 Mar 2026 01:25:21 +0800 Subject: [PATCH 01/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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; +} From 50d48e3a64fba61f1802e782289abe7beca180b0 Mon Sep 17 00:00:00 2001 From: Thomas Ricouard Date: Sun, 22 Mar 2026 10:46:03 +0100 Subject: [PATCH 12/15] refactor: centralize message file link handling --- .../messages/components/Markdown.test.tsx | 323 +---------- src/features/messages/components/Markdown.tsx | 416 ++------------ .../messages/components/MessageRows.tsx | 6 +- .../messages/components/Messages.test.tsx | 28 +- .../messages/hooks/useFileLinkOpener.test.tsx | 96 +--- .../messages/hooks/useFileLinkOpener.ts | 65 ++- .../messages/test/fileLinkAssertions.ts | 15 + .../messages/utils/messageFileLinks.test.ts | 39 ++ .../messages/utils/messageFileLinks.ts | 520 ++++++++++++++++++ .../messages/utils/mountedWorkspacePaths.ts | 31 +- .../messages/utils/remarkFileLinks.test.ts | 69 +++ .../utils/workspaceRoutePaths.test.ts | 40 ++ .../messages/utils/workspaceRoutePaths.ts | 98 ++++ .../settings/components/settingsTypes.ts | 41 +- src/utils/fileLinks.test.ts | 78 +-- src/utils/fileLinks.ts | 94 ---- src/utils/remarkFileLinks.ts | 189 +------ 17 files changed, 955 insertions(+), 1193 deletions(-) create mode 100644 src/features/messages/test/fileLinkAssertions.ts create mode 100644 src/features/messages/utils/messageFileLinks.test.ts create mode 100644 src/features/messages/utils/messageFileLinks.ts create mode 100644 src/features/messages/utils/remarkFileLinks.test.ts create mode 100644 src/features/messages/utils/workspaceRoutePaths.test.ts create mode 100644 src/features/messages/utils/workspaceRoutePaths.ts diff --git a/src/features/messages/components/Markdown.test.tsx b/src/features/messages/components/Markdown.test.tsx index 05f18eef7..c2a469f05 100644 --- a/src/features/messages/components/Markdown.test.tsx +++ b/src/features/messages/components/Markdown.test.tsx @@ -1,17 +1,9 @@ // @vitest-environment jsdom import { cleanup, createEvent, fireEvent, render, screen } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { expectOpenedFileTarget } from "../test/fileLinkAssertions"; 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(); @@ -469,298 +461,6 @@ describe("Markdown file-like href behavior", () => { expect(container.querySelector("code")?.textContent).toBe("/workspace/reviews#L9"); }); - it("does not turn natural-language slash phrases into file links", () => { - const { container } = render( - , - ); - - expect(container.querySelector(".message-file-link")).toBeNull(); - expect(container.textContent).toContain("app/daemon"); - expect(container.textContent).toContain("Git/Plan"); - }); - - it("does not turn longer slash phrases into file links", () => { - const { container } = render( - , - ); - - expect(container.querySelector(".message-file-link")).toBeNull(); - expect(container.textContent).toContain("Spec/Verification/Evidence"); - }); - - it("still turns clear file paths in plain text into file links", () => { - const { container } = render( - , - ); - - const fileLinks = [...container.querySelectorAll(".message-file-link")]; - expect(fileLinks).toHaveLength(2); - expect(fileLinks[0]?.textContent).toContain("setup.md"); - expect(fileLinks[1]?.textContent).toContain("index.ts"); - }); - - it("linkifies extensionless mounted file paths under reserved workspace names", () => { - const onOpenFileLink = vi.fn(); - const { container } = render( - , - ); - - const link = container.querySelector('a[href^="codex-file:"]'); - expect(link).not.toBeNull(); - - const clickEvent = createEvent.click(link as Element, { - bubbles: true, - cancelable: true, - }); - fireEvent(link as Element, clickEvent); - expect(clickEvent.defaultPrevented).toBe(true); - expect(onOpenFileLink).toHaveBeenCalledWith("/workspaces/team/reviews/bin/tool"); - }); - - it("turns Windows absolute paths in plain text into file links", () => { - const { container } = render( - , - ); - - const fileLinks = [...container.querySelectorAll(".message-file-link")]; - expect(fileLinks).toHaveLength(1); - expect(fileLinks[0]?.textContent).toContain("App.tsx"); - expect(fileLinks[0]?.getAttribute("title")).toBe( - "I:\\gpt-projects\\CodexMonitor\\src\\App.tsx:12", - ); - }); - - it("normalizes plain-text Windows #L anchors before opening file links", () => { - const onOpenFileLink = vi.fn(); - const { container } = render( - , - ); - - const fileLinks = [...container.querySelectorAll(".message-file-link")]; - expect(fileLinks).toHaveLength(1); - expect(fileLinks[0]?.getAttribute("title")).toBe( - "I:\\gpt-projects\\CodexMonitor\\src\\App.tsx:12", - ); - - const clickEvent = createEvent.click(fileLinks[0] as Element, { - bubbles: true, - cancelable: true, - }); - fireEvent(fileLinks[0] as Element, clickEvent); - expect(clickEvent.defaultPrevented).toBe(true); - expect(onOpenFileLink).toHaveBeenCalledWith( - "I:\\gpt-projects\\CodexMonitor\\src\\App.tsx:12", - ); - }); - - it("does not linkify Windows paths embedded inside file URLs", () => { - const { container } = render( - , - ); - - expect(container.querySelector(".message-file-link")).toBeNull(); - expect(container.textContent).toContain("file:///C:/repo/src/App.tsx"); - }); - - it("ignores non-line file URL fragments when opening file hrefs", () => { - const onOpenFileLink = vi.fn(); - render( - , - ); - - const link = screen.getByText("report").closest("a"); - expect(link?.getAttribute("href")).toBe("file:///tmp/report.md#overview"); - - const clickEvent = createEvent.click(link as Element, { - bubbles: true, - cancelable: true, - }); - fireEvent(link as Element, clickEvent); - expect(clickEvent.defaultPrevented).toBe(true); - expectOpenedFileTarget(onOpenFileLink, "/tmp/report.md"); - }); - - it("keeps line anchors when opening file URLs", () => { - const onOpenFileLink = vi.fn(); - render( - , - ); - - const link = screen.getByText("report").closest("a"); - expect(link?.getAttribute("href")).toBe("file:///tmp/report.md#L12"); - - const clickEvent = createEvent.click(link as Element, { - bubbles: true, - cancelable: true, - }); - fireEvent(link as Element, clickEvent); - expect(clickEvent.defaultPrevented).toBe(true); - expectOpenedFileTarget(onOpenFileLink, "/tmp/report.md", 12); - }); - - it("preserves Windows drive paths when file URL decoding encounters an unescaped percent", () => { - const onOpenFileLink = vi.fn(); - render( - , - ); - - const link = screen.getByText("report").closest("a"); - expect(link?.getAttribute("href")).toBe("file:///C:/repo/100%25.tsx#L12"); - - const clickEvent = createEvent.click(link as Element, { - bubbles: true, - cancelable: true, - }); - fireEvent(link as Element, clickEvent); - expect(clickEvent.defaultPrevented).toBe(true); - expectOpenedFileTarget(onOpenFileLink, "C:/repo/100%.tsx", 12); - }); - - it("preserves UNC host paths when file URL decoding encounters an unescaped percent", () => { - const onOpenFileLink = vi.fn(); - render( - , - ); - - const link = screen.getByText("report").closest("a"); - expect(link?.getAttribute("href")).toBe("file://server/share/100%25.tsx#L12"); - - const clickEvent = createEvent.click(link as Element, { - bubbles: true, - cancelable: true, - }); - fireEvent(link as Element, clickEvent); - expect(clickEvent.defaultPrevented).toBe(true); - expectOpenedFileTarget(onOpenFileLink, "//server/share/100%.tsx", 12); - }); - - it("keeps encoded #L-like filenames intact when opening file URLs", () => { - const onOpenFileLink = vi.fn(); - render( - , - ); - - const link = screen.getByText("report").closest("a"); - expect(link?.getAttribute("href")).toBe("file:///tmp/report%23L12.md"); - - const clickEvent = createEvent.click(link as Element, { - bubbles: true, - cancelable: true, - }); - fireEvent(link as Element, clickEvent); - expect(clickEvent.defaultPrevented).toBe(true); - expectOpenedFileTarget(onOpenFileLink, "/tmp/report#L12.md"); - }); - - it("keeps encoded bare #L-like filenames intact when opening file URLs", () => { - const onOpenFileLink = vi.fn(); - render( - , - ); - - const link = screen.getByText("report").closest("a"); - expect(link?.getAttribute("href")).toBe("file:///tmp/%23L12"); - - const clickEvent = createEvent.click(link as Element, { - bubbles: true, - cancelable: true, - }); - fireEvent(link as Element, clickEvent); - expect(clickEvent.defaultPrevented).toBe(true); - expectOpenedFileTarget(onOpenFileLink, "/tmp/#L12"); - }); - - it("keeps encoded #L-like filename endings intact when opening markdown hrefs", () => { - const onOpenFileLink = vi.fn(); - render( - , - ); - - const link = screen.getByText("report").closest("a"); - expect(link?.getAttribute("href")).toBe("./report.md%23L12"); - - const clickEvent = createEvent.click(link as Element, { - bubbles: true, - cancelable: true, - }); - fireEvent(link as Element, clickEvent); - expect(clickEvent.defaultPrevented).toBe(true); - expectOpenedFileTarget(onOpenFileLink, "./report.md#L12"); - }); - - it("keeps encoded #L-like filename column endings intact when opening markdown hrefs", () => { - const onOpenFileLink = vi.fn(); - render( - , - ); - - const link = screen.getByText("report").closest("a"); - expect(link?.getAttribute("href")).toBe("./report.md%23L12C3"); - - const clickEvent = createEvent.click(link as Element, { - bubbles: true, - cancelable: true, - }); - fireEvent(link as Element, clickEvent); - expect(clickEvent.defaultPrevented).toBe(true); - expectOpenedFileTarget(onOpenFileLink, "./report.md#L12C3"); - }); - it("still opens mounted file links when the workspace basename is settings", () => { const onOpenFileLink = vi.fn(); render( @@ -806,25 +506,4 @@ describe("Markdown file-like href behavior", () => { expect(onOpenFileLink).not.toHaveBeenCalled(); }); - it("linkifies mounted file paths when the nested workspace basename is reviews", () => { - const onOpenFileLink = vi.fn(); - const { container } = render( - , - ); - - const link = container.querySelector('a[href^="codex-file:"]'); - expect(link).not.toBeNull(); - - const clickEvent = createEvent.click(link as Element, { - bubbles: true, - cancelable: true, - }); - fireEvent(link as Element, clickEvent); - expect(clickEvent.defaultPrevented).toBe(true); - expect(onOpenFileLink).toHaveBeenCalledWith("/workspaces/team/reviews/src/App.tsx"); - }); }); diff --git a/src/features/messages/components/Markdown.tsx b/src/features/messages/components/Markdown.tsx index 2829243bd..b87d0122b 100644 --- a/src/features/messages/components/Markdown.tsx +++ b/src/features/messages/components/Markdown.tsx @@ -3,22 +3,16 @@ import ReactMarkdown, { type Components } from "react-markdown"; import remarkGfm from "remark-gfm"; import { openUrl } from "@tauri-apps/plugin-opener"; import { - type FileLinkTarget, - type ParsedFileLocation, - formatFileLocation, - isKnownLocalWorkspaceRoutePath as isKnownLocalWorkspaceRouteFilePath, - normalizeFileLinkPath, - parseFileLocation, - parseFileUrlLocation, -} from "../../../utils/fileLinks"; -import { - decodeFileLink, + describeFileTarget, + formatParsedFileLocation, isFileLinkUrl, - isLinkableFilePath, + parseFileLinkUrl, + parseInlineFileTarget, remarkFileLinks, + resolveMessageFileHref, toFileLink, -} from "../../../utils/remarkFileLinks"; -import { resolveMountedWorkspacePath } from "../utils/mountedWorkspacePaths"; +} from "../utils/messageFileLinks"; +import type { ParsedFileLocation } from "../../../utils/fileLinks"; type MarkdownProps = { value: string; @@ -28,8 +22,8 @@ type MarkdownProps = { codeBlockCopyUseModifier?: boolean; showFilePath?: boolean; workspacePath?: string | null; - onOpenFileLink?: (path: FileLinkTarget) => void; - onOpenFileLinkMenu?: (event: React.MouseEvent, path: FileLinkTarget) => void; + onOpenFileLink?: (path: ParsedFileLocation) => void; + onOpenFileLinkMenu?: (event: React.MouseEvent, path: ParsedFileLocation) => void; onOpenThreadLink?: (threadId: string) => void; }; @@ -56,112 +50,6 @@ type LinkBlockProps = { urls: string[]; }; -type ParsedFileReference = { - fullPath: string; - fileName: string; - lineLabel: string | null; - 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, "/"); -} - -function trimTrailingPathSeparators(path: string) { - return path.replace(/\/+$/, ""); -} - -function isWindowsAbsolutePath(path: string) { - return /^[A-Za-z]:\//.test(path); -} - -function isAbsolutePath(path: string) { - return path.startsWith("/") || isWindowsAbsolutePath(path); -} - -function extractPathRoot(path: string) { - if (isWindowsAbsolutePath(path)) { - return path.slice(0, 2).toLowerCase(); - } - if (path.startsWith("/")) { - return "/"; - } - return ""; -} - -function splitAbsolutePath(path: string) { - const root = extractPathRoot(path); - if (!root) { - return null; - } - const withoutRoot = - root === "/" ? path.slice(1) : path.slice(2).replace(/^\/+/, ""); - return { - root, - segments: withoutRoot.split("/").filter(Boolean), - }; -} - -function toRelativePath(fromPath: string, toPath: string) { - const fromAbsolute = splitAbsolutePath(fromPath); - const toAbsolute = splitAbsolutePath(toPath); - if (!fromAbsolute || !toAbsolute) { - return null; - } - if (fromAbsolute.root !== toAbsolute.root) { - return null; - } - const caseInsensitive = fromAbsolute.root !== "/"; - let commonLength = 0; - while ( - commonLength < fromAbsolute.segments.length && - commonLength < toAbsolute.segments.length && - (caseInsensitive - ? fromAbsolute.segments[commonLength].toLowerCase() === - toAbsolute.segments[commonLength].toLowerCase() - : fromAbsolute.segments[commonLength] === toAbsolute.segments[commonLength]) - ) { - commonLength += 1; - } - const backtrack = new Array(fromAbsolute.segments.length - commonLength).fill(".."); - const forward = toAbsolute.segments.slice(commonLength); - return [...backtrack, ...forward].join("/"); -} - -function relativeDisplayPath(path: string, workspacePath?: string | null) { - const normalizedPath = trimTrailingPathSeparators(normalizePathSeparators(path.trim())); - if (!workspacePath) { - return normalizedPath; - } - const normalizedWorkspace = trimTrailingPathSeparators( - normalizePathSeparators(workspacePath.trim()), - ); - if (!normalizedWorkspace) { - return normalizedPath; - } - if (!isAbsolutePath(normalizedPath) || !isAbsolutePath(normalizedWorkspace)) { - return normalizedPath; - } - const relative = toRelativePath(normalizedWorkspace, normalizedPath); - if (relative === null) { - return normalizedPath; - } - if (relative.length === 0) { - const segments = normalizedPath.split("/").filter(Boolean); - return segments.length > 0 ? segments[segments.length - 1] : normalizedPath; - } - return relative; -} - function extractLanguageTag(className?: string) { if (!className) { return null; @@ -199,182 +87,6 @@ function normalizeUrlLine(line: string) { return withoutBullet; } -function safeDecodeURIComponent(value: string) { - try { - return decodeURIComponent(value); - } catch { - return null; - } -} - -function safeDecodeFileLink(url: string) { - try { - return decodeFileLink(url); - } catch { - return null; - } -} -const LIKELY_LOCAL_ABSOLUTE_PATH_PREFIXES = [ - "/Users/", - "/home/", - "/tmp/", - "/var/", - "/opt/", - "/etc/", - "/private/", - "/Volumes/", - "/mnt/", - "/usr/", - "/workspace/", - "/workspaces/", - "/root/", - "/srv/", - "/data/", -]; -const WORKSPACE_ROUTE_PREFIXES = ["/workspace/", "/workspaces/"]; - -function stripPathLineSuffix(value: string) { - return parseFileLocation(value).path; -} - -function hasLikelyFileName(path: string) { - const normalizedPath = stripPathLineSuffix(path).replace(/[\\/]+$/, ""); - const lastSegment = normalizedPath.split(/[\\/]/).pop() ?? ""; - if (!lastSegment || lastSegment === "." || lastSegment === "..") { - return false; - } - if (lastSegment.startsWith(".") && lastSegment.length > 1) { - return true; - } - return lastSegment.includes("."); -} - -function hasLikelyLocalAbsolutePrefix(path: string) { - const normalizedPath = path.replace(/\\/g, "/"); - return LIKELY_LOCAL_ABSOLUTE_PATH_PREFIXES.some((prefix) => - normalizedPath.startsWith(prefix), - ); -} - -function splitWorkspaceRoutePath(path: string) { - const normalizedPath = path.replace(/\\/g, "/"); - if (normalizedPath.startsWith("/workspace/")) { - return { - segments: normalizedPath.slice("/workspace/".length).split("/").filter(Boolean), - prefix: "/workspace/", - }; - } - if (normalizedPath.startsWith("/workspaces/")) { - return { - segments: normalizedPath.slice("/workspaces/".length).split("/").filter(Boolean), - prefix: "/workspaces/", - }; - } - return null; -} - -function hasLikelyWorkspaceNameSegment(segment: string) { - return /[A-Z]/.test(segment) || /[._-]/.test(segment); -} - -function isLikelyMountedWorkspaceFilePath( - path: string, - workspacePath?: string | null, -) { - if (isKnownLocalWorkspaceRouteFilePath(path)) { - return false; - } - if (resolveMountedWorkspacePath(path, workspacePath) !== null) { - return true; - } - - const mountedPath = splitWorkspaceRoutePath(path); - return Boolean( - mountedPath?.prefix === "/workspace/" && - mountedPath.segments.length >= 2 && - hasLikelyWorkspaceNameSegment(mountedPath.segments[0]), - ); -} - -function usesAbsolutePathDepthFallback( - path: string, - workspacePath?: string | null, -) { - const normalizedPath = path.replace(/\\/g, "/"); - if ( - WORKSPACE_ROUTE_PREFIXES.some((prefix) => normalizedPath.startsWith(prefix)) && - !isLikelyMountedWorkspaceFilePath(normalizedPath, workspacePath) - ) { - return false; - } - return hasLikelyLocalAbsolutePrefix(normalizedPath) && pathSegmentCount(normalizedPath) >= 3; -} - -function pathSegmentCount(path: string) { - return path.split("/").filter(Boolean).length; -} - -function isLikelyFileHref( - url: string, - workspacePath?: string | null, -) { - const trimmed = url.trim(); - if (!trimmed) { - return false; - } - if (trimmed.startsWith("file://")) { - return true; - } - if ( - trimmed.startsWith("http://") || - trimmed.startsWith("https://") || - trimmed.startsWith("mailto:") - ) { - return false; - } - if (trimmed.startsWith("thread://") || trimmed.startsWith("/thread/")) { - return false; - } - if (trimmed.startsWith("#")) { - return false; - } - const parsedLocation = parseFileLocation(trimmed); - const pathOnly = parsedLocation.path.trim(); - if (/[?#]/.test(pathOnly)) { - return false; - } - if (/^[A-Za-z]:[\\/]/.test(pathOnly) || pathOnly.startsWith("\\\\")) { - return true; - } - if (pathOnly.startsWith("/")) { - if (parsedLocation.line !== null) { - const normalizedPath = pathOnly.replace(/\\/g, "/"); - if ( - WORKSPACE_ROUTE_PREFIXES.some((prefix) => normalizedPath.startsWith(prefix)) - ) { - return isLikelyMountedWorkspaceFilePath(normalizedPath, workspacePath); - } - return true; - } - if (hasLikelyFileName(pathOnly)) { - return true; - } - return usesAbsolutePathDepthFallback(pathOnly, workspacePath); - } - if (parsedLocation.line !== null) { - return true; - } - if (pathOnly.startsWith("~/")) { - return true; - } - if (pathOnly.startsWith("./") || pathOnly.startsWith("../")) { - return parsedLocation.line !== null || hasLikelyFileName(pathOnly); - } - if (hasLikelyFileName(pathOnly)) { - return pathSegmentCount(pathOnly) >= 3; - } - return false; -} function extractUrlLines(value: string) { const lines = value.split(/\r?\n/); @@ -487,39 +199,6 @@ function LinkBlock({ urls }: LinkBlockProps) { ); } -function parseFileReference( - rawPath: FileLinkTarget, - workspacePath?: string | null, -): ParsedFileReference { - const parsedLocation = toParsedFileTarget(rawPath); - const fullPath = formatFileLocation( - parsedLocation.path, - parsedLocation.line, - parsedLocation.column, - ); - const pathWithoutLine = parsedLocation.path.trim(); - const lineLabel = - parsedLocation.line === null - ? null - : `${parsedLocation.line}${parsedLocation.column !== null ? `:${parsedLocation.column}` : ""}`; - const displayPath = relativeDisplayPath(pathWithoutLine, workspacePath); - const normalizedPath = trimTrailingPathSeparators(displayPath) || displayPath; - const lastSlashIndex = normalizedPath.lastIndexOf("/"); - const fallbackFile = normalizedPath || fullPath; - const fileName = - lastSlashIndex >= 0 ? normalizedPath.slice(lastSlashIndex + 1) : fallbackFile; - const rawParentPath = - lastSlashIndex >= 0 ? normalizedPath.slice(0, lastSlashIndex) : ""; - const parentPath = rawParentPath || (normalizedPath.startsWith("/") ? "/" : null); - - return { - fullPath, - fileName, - lineLabel, - parentPath, - }; -} - function FileReferenceLink({ href, rawPath, @@ -529,16 +208,13 @@ function FileReferenceLink({ onContextMenu, }: { href: string; - rawPath: FileLinkTarget; + rawPath: ParsedFileLocation; showFilePath: boolean; workspacePath?: string | null; - onClick: (event: React.MouseEvent, path: FileLinkTarget) => void; - onContextMenu: (event: React.MouseEvent, path: FileLinkTarget) => void; + onClick: (event: React.MouseEvent, path: ParsedFileLocation) => void; + onContextMenu: (event: React.MouseEvent, path: ParsedFileLocation) => void; }) { - const { fullPath, fileName, lineLabel, parentPath } = parseFileReference( - rawPath, - workspacePath, - ); + const { fullPath, fileName, lineLabel, parentPath } = describeFileTarget(rawPath, workspacePath); return ( { + const handleFileLinkClick = (event: React.MouseEvent, path: ParsedFileLocation) => { event.preventDefault(); event.stopPropagation(); onOpenFileLink?.(path); @@ -662,54 +338,24 @@ export function Markdown({ }; const handleFileLinkContextMenu = ( event: React.MouseEvent, - path: FileLinkTarget, + path: ParsedFileLocation, ) => { event.preventDefault(); event.stopPropagation(); onOpenFileLinkMenu?.(event, path); }; - const getLinkablePath = (rawValue: string) => { - const normalizedPath = normalizeFileLinkPath(rawValue).trim(); - if (!normalizedPath) { - return null; - } - if (isKnownLocalWorkspaceRouteFilePath(normalizedPath)) { - return null; - } - if (!isLinkableFilePath(normalizedPath)) { - return null; - } - return normalizedPath; - }; + const resolvedHrefFilePathCache = new Map(); const resolveHrefFilePath = (url: string) => { - const fileUrlPath = parseFileUrlLocation(url); - if (fileUrlPath) { - return fileUrlPath; + if (resolvedHrefFilePathCache.has(url)) { + return resolvedHrefFilePathCache.get(url) ?? null; } - 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; - } - seenCandidates.add(candidate); - const linkableCandidate = getLinkablePath(candidate); - if (!linkableCandidate) { - continue; - } - if (isLikelyFileHref(linkableCandidate, workspacePath)) { - const parsedCandidate = parseFileLocation(linkableCandidate); - const decodedPath = safeDecodeURIComponent(parsedCandidate.path); - return { - path: decodedPath ?? parsedCandidate.path, - line: parsedCandidate.line, - column: parsedCandidate.column, - }; - } + const resolvedPath = resolveMessageFileHref(url, workspacePath); + if (!resolvedPath) { + resolvedHrefFilePathCache.set(url, null); + return null; } - return null; + resolvedHrefFilePathCache.set(url, resolvedPath); + return resolvedPath; }; const components: Components = { a: ({ href, children }) => { @@ -734,7 +380,7 @@ export function Markdown({ ); } if (isFileLinkUrl(url)) { - const path = safeDecodeFileLink(url); + const path = parseFileLinkUrl(url); if (!path) { return ( handleFileLinkClick(event, hrefFilePath); const contextMenuHandler = onOpenFileLinkMenu @@ -769,7 +415,7 @@ export function Markdown({ : undefined; return ( {children}; } const text = String(children ?? "").trim(); - const linkablePath = getLinkablePath(text); - if (!linkablePath) { + const fileTarget = parseInlineFileTarget(text); + if (!fileTarget) { return {children}; } - const href = toFileLink(linkablePath); + const href = toFileLink(fileTarget); return ( void; - onOpenFileLinkMenu?: (event: MouseEvent, path: FileLinkTarget) => void; + onOpenFileLink?: (path: ParsedFileLocation) => void; + onOpenFileLinkMenu?: (event: MouseEvent, path: ParsedFileLocation) => void; onOpenThreadLink?: (threadId: string) => void; }; diff --git a/src/features/messages/components/Messages.test.tsx b/src/features/messages/components/Messages.test.tsx index 7c850ff34..e4bd76f1a 100644 --- a/src/features/messages/components/Messages.test.tsx +++ b/src/features/messages/components/Messages.test.tsx @@ -3,6 +3,7 @@ import { useCallback, useState } from "react"; import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import type { ConversationItem } from "../../../types"; +import { expectOpenedFileTarget } from "../test/fileLinkAssertions"; import { Messages } from "./Messages"; const useFileLinkOpenerMock = vi.fn( @@ -17,15 +18,6 @@ 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, @@ -280,8 +272,10 @@ describe("Messages", () => { expect(fileLink).toBeTruthy(); fireEvent.click(fileLink as Element); - expect(openFileLinkMock).toHaveBeenCalledWith( - "iosApp/src/views/DocumentsList/DocumentListView.swift:111", + expectOpenedFileTarget( + openFileLinkMock, + "iosApp/src/views/DocumentsList/DocumentListView.swift", + 111, ); }); @@ -649,7 +643,11 @@ describe("Messages", () => { const fileLink = container.querySelector(".message-file-link"); expect(fileLink).toBeTruthy(); fireEvent.click(fileLink as Element); - expect(openFileLinkMock).toHaveBeenCalledWith(absolutePath); + expectOpenedFileTarget( + openFileLinkMock, + "/Users/dimillian/Documents/Dev/CodexMonitor/src/features/messages/components/Markdown.tsx", + 244, + ); }); it("renders absolute file references outside workspace using dotdot-relative paths", () => { @@ -683,7 +681,11 @@ describe("Messages", () => { const fileLink = container.querySelector(".message-file-link"); expect(fileLink).toBeTruthy(); fireEvent.click(fileLink as Element); - expect(openFileLinkMock).toHaveBeenCalledWith(absolutePath); + expectOpenedFileTarget( + openFileLinkMock, + "/Users/dimillian/Documents/Other/IceCubesApp/file.rs", + 123, + ); }); it("does not re-render messages while typing when message props stay stable", () => { diff --git a/src/features/messages/hooks/useFileLinkOpener.test.tsx b/src/features/messages/hooks/useFileLinkOpener.test.tsx index 61813a1b4..5f76a6b75 100644 --- a/src/features/messages/hooks/useFileLinkOpener.test.tsx +++ b/src/features/messages/hooks/useFileLinkOpener.test.tsx @@ -2,6 +2,7 @@ import { act, renderHook } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; import { openWorkspaceIn } from "../../../services/tauri"; +import { fileTarget } from "../test/fileLinkAssertions"; import { useFileLinkOpener } from "./useFileLinkOpener"; const { @@ -53,7 +54,7 @@ describe("useFileLinkOpener", () => { vi.clearAllMocks(); }); - it("copies namespace-prefixed Windows drive paths as round-trippable file URLs", async () => { + async function copyLinkFor(rawPath: string) { const clipboardWriteTextMock = vi.fn(); Object.defineProperty(navigator, "clipboard", { value: { writeText: clipboardWriteTextMock }, @@ -76,7 +77,7 @@ describe("useFileLinkOpener", () => { clientX: 12, clientY: 24, } as never, - "\\\\?\\C:\\repo\\src\\App.tsx:42", + fileTarget(rawPath), ); }); @@ -86,86 +87,23 @@ describe("useFileLinkOpener", () => { ); await copyLinkItem?.action?.(); + return clipboardWriteTextMock.mock.calls[0]?.[0]; + } - expect(clipboardWriteTextMock).toHaveBeenCalledWith( + it("copies namespace-prefixed Windows drive paths as round-trippable file URLs", async () => { + expect(await copyLinkFor("\\\\?\\C:\\repo\\src\\App.tsx:42")).toBe( "file:///%5C%5C%3F%5CC%3A%5Crepo%5Csrc%5CApp.tsx#L42", ); }); it("copies namespace-prefixed Windows UNC paths as round-trippable file URLs", async () => { - const clipboardWriteTextMock = vi.fn(); - Object.defineProperty(navigator, "clipboard", { - value: { writeText: clipboardWriteTextMock }, - configurable: true, - }); - menuItemNewMock.mockImplementation(async (options) => options); - predefinedMenuItemNewMock.mockImplementation(async (options) => options); - menuNewMock.mockImplementation(async ({ items }) => ({ - items, - popup: vi.fn(), - })); - - const { result } = renderHook(() => useFileLinkOpener(null, [], "")); - - await act(async () => { - await result.current.showFileLinkMenu( - { - preventDefault: vi.fn(), - stopPropagation: vi.fn(), - clientX: 12, - clientY: 24, - } as never, - "\\\\?\\UNC\\server\\share\\repo\\App.tsx:42", - ); - }); - - const items = menuNewMock.mock.calls[0]?.[0]?.items ?? []; - const copyLinkItem = items.find( - (item: { text?: string; action?: () => Promise }) => item.text === "Copy Link", - ); - - await copyLinkItem?.action?.(); - - expect(clipboardWriteTextMock).toHaveBeenCalledWith( + expect(await copyLinkFor("\\\\?\\UNC\\server\\share\\repo\\App.tsx:42")).toBe( "file:///%5C%5C%3F%5CUNC%5Cserver%5Cshare%5Crepo%5CApp.tsx#L42", ); }); it("percent-encodes copied file URLs for Windows paths with reserved characters", async () => { - const clipboardWriteTextMock = vi.fn(); - Object.defineProperty(navigator, "clipboard", { - value: { writeText: clipboardWriteTextMock }, - configurable: true, - }); - menuItemNewMock.mockImplementation(async (options) => options); - predefinedMenuItemNewMock.mockImplementation(async (options) => options); - menuNewMock.mockImplementation(async ({ items }) => ({ - items, - popup: vi.fn(), - })); - - const { result } = renderHook(() => useFileLinkOpener(null, [], "")); - - await act(async () => { - await result.current.showFileLinkMenu( - { - preventDefault: vi.fn(), - stopPropagation: vi.fn(), - clientX: 12, - clientY: 24, - } as never, - "C:\\repo\\My File #100%.tsx:42", - ); - }); - - const items = menuNewMock.mock.calls[0]?.[0]?.items ?? []; - const copyLinkItem = items.find( - (item: { text?: string; action?: () => Promise }) => item.text === "Copy Link", - ); - - await copyLinkItem?.action?.(); - - expect(clipboardWriteTextMock).toHaveBeenCalledWith( + expect(await copyLinkFor("C:\\repo\\My File #100%.tsx:42")).toBe( "file:///C:/repo/My%20File%20%23100%25.tsx#L42", ); }); @@ -176,7 +114,9 @@ describe("useFileLinkOpener", () => { const { result } = renderHook(() => useFileLinkOpener(workspacePath, [], "")); await act(async () => { - await result.current.openFileLink("/workspace/src/features/messages/components/Markdown.tsx"); + await result.current.openFileLink( + fileTarget("/workspace/src/features/messages/components/Markdown.tsx"), + ); }); expect(openWorkspaceInMock).toHaveBeenCalledWith( @@ -191,7 +131,7 @@ describe("useFileLinkOpener", () => { const { result } = renderHook(() => useFileLinkOpener(workspacePath, [], "")); await act(async () => { - await result.current.openFileLink("/workspace/CodexMonitor/LICENSE"); + await result.current.openFileLink(fileTarget("/workspace/CodexMonitor/LICENSE")); }); expect(openWorkspaceInMock).toHaveBeenCalledWith( @@ -206,7 +146,7 @@ describe("useFileLinkOpener", () => { const { result } = renderHook(() => useFileLinkOpener(workspacePath, [], "")); await act(async () => { - await result.current.openFileLink("/workspace/settings/LICENSE"); + await result.current.openFileLink(fileTarget("/workspace/settings/LICENSE")); }); expect(openWorkspaceInMock).toHaveBeenCalledWith( @@ -221,7 +161,7 @@ describe("useFileLinkOpener", () => { const { result } = renderHook(() => useFileLinkOpener(workspacePath, [], "")); await act(async () => { - await result.current.openFileLink("/workspaces/team/CodexMonitor/src"); + await result.current.openFileLink(fileTarget("/workspaces/team/CodexMonitor/src")); }); expect(openWorkspaceInMock).toHaveBeenCalledWith( @@ -237,7 +177,7 @@ describe("useFileLinkOpener", () => { await act(async () => { await result.current.openFileLink( - "/workspace/src/features/messages/components/Markdown.tsx:33:7", + fileTarget("/workspace/src/features/messages/components/Markdown.tsx:33:7"), ); }); @@ -258,7 +198,7 @@ describe("useFileLinkOpener", () => { const { result } = renderHook(() => useFileLinkOpener(workspacePath, [], "")); await act(async () => { - await result.current.openFileLink("/workspace/src/App.tsx#L33"); + await result.current.openFileLink(fileTarget("/workspace/src/App.tsx#L33")); }); expect(openWorkspaceInMock).toHaveBeenCalledWith( @@ -296,7 +236,7 @@ describe("useFileLinkOpener", () => { await act(async () => { await result.current.openFileLink( - "/workspace/src/features/messages/components/Markdown.tsx:366-369", + fileTarget("/workspace/src/features/messages/components/Markdown.tsx:366-369"), ); }); diff --git a/src/features/messages/hooks/useFileLinkOpener.ts b/src/features/messages/hooks/useFileLinkOpener.ts index f82bb8b24..e18bab494 100644 --- a/src/features/messages/hooks/useFileLinkOpener.ts +++ b/src/features/messages/hooks/useFileLinkOpener.ts @@ -9,9 +9,8 @@ import { openWorkspaceIn } from "../../../services/tauri"; import { pushErrorToast } from "../../../services/toasts"; import type { OpenAppTarget } from "../../../types"; import { - type FileLinkTarget, + type ParsedFileLocation, formatFileLocation, - parseFileLocation, toFileUrl, } from "../../../utils/fileLinks"; import { @@ -52,8 +51,15 @@ const canOpenTarget = (target: OpenTarget) => { return Boolean(resolveAppName(target)); }; -function toParsedFileTarget(target: FileLinkTarget) { - return typeof target === "string" ? parseFileLocation(target) : target; +function resolveOpenTarget( + openTargets: OpenAppTarget[], + selectedOpenAppId: string, +): OpenTarget { + return { + ...DEFAULT_OPEN_TARGET, + ...(openTargets.find((entry) => entry.id === selectedOpenAppId) ?? + openTargets[0]), + }; } function resolveFilePath(path: string, workspacePath?: string | null) { @@ -71,6 +77,21 @@ function resolveFilePath(path: string, workspacePath?: string | null) { return joinWorkspacePath(workspacePath, trimmed); } +function resolveFileLinkContext( + fileLocation: ParsedFileLocation, + workspacePath?: string | null, +) { + return { + fileLocation, + rawPathLabel: formatFileLocation( + fileLocation.path, + fileLocation.line, + fileLocation.column, + ), + resolvedPath: resolveFilePath(fileLocation.path, workspacePath), + }; +} + export function useFileLinkOpener( workspacePath: string | null, openTargets: OpenAppTarget[], @@ -98,19 +119,12 @@ export function useFileLinkOpener( ); const openFileLink = useCallback( - async (rawPath: FileLinkTarget) => { - const target = { - ...DEFAULT_OPEN_TARGET, - ...(openTargets.find((entry) => entry.id === selectedOpenAppId) ?? - openTargets[0]), - }; - const fileLocation = toParsedFileTarget(rawPath); - const rawPathLabel = formatFileLocation( - fileLocation.path, - fileLocation.line, - fileLocation.column, + async (targetLocation: ParsedFileLocation) => { + const target = resolveOpenTarget(openTargets, selectedOpenAppId); + const { fileLocation, rawPathLabel, resolvedPath } = resolveFileLinkContext( + targetLocation, + workspacePath, ); - const resolvedPath = resolveFilePath(fileLocation.path, workspacePath); const openLocation = { ...(fileLocation.line !== null ? { line: fileLocation.line } : {}), ...(fileLocation.column !== null ? { column: fileLocation.column } : {}), @@ -163,21 +177,14 @@ export function useFileLinkOpener( ); const showFileLinkMenu = useCallback( - async (event: MouseEvent, rawPath: FileLinkTarget) => { + async (event: MouseEvent, targetLocation: ParsedFileLocation) => { event.preventDefault(); event.stopPropagation(); - const target = { - ...DEFAULT_OPEN_TARGET, - ...(openTargets.find((entry) => entry.id === selectedOpenAppId) ?? - openTargets[0]), - }; - const fileLocation = toParsedFileTarget(rawPath); - const rawPathLabel = formatFileLocation( - fileLocation.path, - fileLocation.line, - fileLocation.column, + const target = resolveOpenTarget(openTargets, selectedOpenAppId); + const { fileLocation, rawPathLabel, resolvedPath } = resolveFileLinkContext( + targetLocation, + workspacePath, ); - const resolvedPath = resolveFilePath(fileLocation.path, workspacePath); const appName = resolveAppName(target); const command = resolveCommand(target); const canOpen = canOpenTarget(target); @@ -196,7 +203,7 @@ export function useFileLinkOpener( text: openLabel, enabled: canOpen, action: async () => { - await openFileLink(rawPath); + await openFileLink(fileLocation); }, }), ...(target.kind === "finder" diff --git a/src/features/messages/test/fileLinkAssertions.ts b/src/features/messages/test/fileLinkAssertions.ts new file mode 100644 index 000000000..b0b37e720 --- /dev/null +++ b/src/features/messages/test/fileLinkAssertions.ts @@ -0,0 +1,15 @@ +import { expect, vi } from "vitest"; +import { parseFileLocation, type ParsedFileLocation } from "../../../utils/fileLinks"; + +export function expectOpenedFileTarget( + mock: ReturnType, + path: string, + line: number | null = null, + column: number | null = null, +) { + expect(mock).toHaveBeenCalledWith({ path, line, column }); +} + +export function fileTarget(rawPath: string): ParsedFileLocation { + return parseFileLocation(rawPath); +} diff --git a/src/features/messages/utils/messageFileLinks.test.ts b/src/features/messages/utils/messageFileLinks.test.ts new file mode 100644 index 000000000..4ddbfe622 --- /dev/null +++ b/src/features/messages/utils/messageFileLinks.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; +import { formatFileLocation } from "../../../utils/fileLinks"; +import { resolveMessageFileHref } from "./messageFileLinks"; + +function expectResolvedHref(url: string, expected: string | null) { + const resolved = resolveMessageFileHref(url); + const formatted = resolved + ? formatFileLocation(resolved.path, resolved.line, resolved.column) + : null; + expect(formatted).toBe(expected); +} + +describe("resolveMessageFileHref", () => { + it("ignores non-line file URL fragments", () => { + expectResolvedHref("file:///tmp/report.md#overview", "/tmp/report.md"); + }); + + it("preserves line anchors for file URLs", () => { + expectResolvedHref("file:///tmp/report.md#L12", "/tmp/report.md:12"); + }); + + it("preserves Windows drive paths with unescaped percent characters", () => { + expectResolvedHref("file:///C:/repo/100%.tsx#L12", "C:/repo/100%.tsx:12"); + }); + + it("preserves UNC host paths with unescaped percent characters", () => { + expectResolvedHref("file://server/share/100%.tsx#L12", "//server/share/100%.tsx:12"); + }); + + it("keeps encoded #L-like filenames intact for file URLs", () => { + expectResolvedHref("file:///tmp/report%23L12.md", "/tmp/report#L12.md"); + expectResolvedHref("file:///tmp/%23L12", "/tmp/#L12"); + }); + + it("keeps encoded #L-like filename endings intact for markdown hrefs", () => { + expectResolvedHref("./report.md%23L12", "./report.md#L12"); + expectResolvedHref("./report.md%23L12C3", "./report.md#L12C3"); + }); +}); diff --git a/src/features/messages/utils/messageFileLinks.ts b/src/features/messages/utils/messageFileLinks.ts new file mode 100644 index 000000000..80ec31755 --- /dev/null +++ b/src/features/messages/utils/messageFileLinks.ts @@ -0,0 +1,520 @@ +import { + FILE_LINK_SUFFIX_SOURCE, + type ParsedFileLocation, + formatFileLocation, + normalizeFileLinkPath, + parseFileLocation, + parseFileUrlLocation, +} from "../../../utils/fileLinks"; +import { resolveMountedWorkspacePath } from "./mountedWorkspacePaths"; +import { + isKnownLocalWorkspaceRoutePath, + splitWorkspaceRoutePath, + WORKSPACE_MOUNT_PREFIX, + WORKSPACE_ROUTE_PREFIXES, +} from "./workspaceRoutePaths"; + +export type ParsedFileReference = { + fullPath: string; + fileName: string; + lineLabel: string | null; + parentPath: string | null; +}; + +type MarkdownNode = { + type: string; + value?: string; + url?: string; + children?: MarkdownNode[]; +}; + +const FILE_LINK_PROTOCOL = "codex-file:"; +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( + `(${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+.-]*:\/\/\/?$/; +const PATH_CANDIDATE_PREFIX_BOUNDARY_PATTERN = /[\s<>"'`()\[\]{}]/u; +const LIKELY_LOCAL_ABSOLUTE_PATH_PREFIXES = [ + "/Users/", + "/home/", + "/tmp/", + "/var/", + "/opt/", + "/etc/", + "/private/", + "/Volumes/", + "/mnt/", + "/usr/", + "/workspace/", + "/workspaces/", + "/root/", + "/srv/", + "/data/", +]; + +function normalizePathSeparators(path: string) { + return path.replace(/\\/g, "/"); +} + +function trimTrailingPathSeparators(path: string) { + return path.replace(/\/+$/, ""); +} + +function isWindowsAbsolutePath(path: string) { + return /^[A-Za-z]:\//.test(path); +} + +function isAbsolutePath(path: string) { + return path.startsWith("/") || isWindowsAbsolutePath(path); +} + +function extractPathRoot(path: string) { + if (isWindowsAbsolutePath(path)) { + return path.slice(0, 2).toLowerCase(); + } + if (path.startsWith("/")) { + return "/"; + } + return ""; +} + +function splitAbsolutePath(path: string) { + const root = extractPathRoot(path); + if (!root) { + return null; + } + const withoutRoot = + root === "/" ? path.slice(1) : path.slice(2).replace(/^\/+/, ""); + return { + root, + segments: withoutRoot.split("/").filter(Boolean), + }; +} + +function toRelativePath(fromPath: string, toPath: string) { + const fromAbsolute = splitAbsolutePath(fromPath); + const toAbsolute = splitAbsolutePath(toPath); + if (!fromAbsolute || !toAbsolute || fromAbsolute.root !== toAbsolute.root) { + return null; + } + + const caseInsensitive = fromAbsolute.root !== "/"; + let commonLength = 0; + while ( + commonLength < fromAbsolute.segments.length && + commonLength < toAbsolute.segments.length && + (caseInsensitive + ? fromAbsolute.segments[commonLength].toLowerCase() === + toAbsolute.segments[commonLength].toLowerCase() + : fromAbsolute.segments[commonLength] === toAbsolute.segments[commonLength]) + ) { + commonLength += 1; + } + + const backtrack = new Array(fromAbsolute.segments.length - commonLength).fill(".."); + const forward = toAbsolute.segments.slice(commonLength); + return [...backtrack, ...forward].join("/"); +} + +export function relativeDisplayPath(path: string, workspacePath?: string | null) { + const normalizedPath = trimTrailingPathSeparators(normalizePathSeparators(path.trim())); + if (!workspacePath) { + return normalizedPath; + } + const normalizedWorkspace = trimTrailingPathSeparators( + normalizePathSeparators(workspacePath.trim()), + ); + if (!normalizedWorkspace) { + return normalizedPath; + } + if (!isAbsolutePath(normalizedPath) || !isAbsolutePath(normalizedWorkspace)) { + return normalizedPath; + } + + const relative = toRelativePath(normalizedWorkspace, normalizedPath); + if (relative === null) { + return normalizedPath; + } + if (relative.length === 0) { + const segments = normalizedPath.split("/").filter(Boolean); + return segments.length > 0 ? segments[segments.length - 1] : normalizedPath; + } + return relative; +} + +function safeDecodeURIComponent(value: string) { + try { + return decodeURIComponent(value); + } catch { + return null; + } +} + +function stripPathLineSuffix(value: string) { + return parseFileLocation(value).path; +} + +function hasLikelyFileName(path: string) { + const normalizedPath = stripPathLineSuffix(path).replace(/[\\/]+$/, ""); + const lastSegment = normalizedPath.split(/[\\/]/).pop() ?? ""; + if (!lastSegment || lastSegment === "." || lastSegment === "..") { + return false; + } + if (lastSegment.startsWith(".") && lastSegment.length > 1) { + return true; + } + return lastSegment.includes("."); +} + +function hasLikelyLocalAbsolutePrefix(path: string) { + const normalizedPath = path.replace(/\\/g, "/"); + return LIKELY_LOCAL_ABSOLUTE_PATH_PREFIXES.some((prefix) => + normalizedPath.startsWith(prefix), + ); +} + +function hasLikelyWorkspaceNameSegment(segment: string) { + return /[A-Z]/.test(segment) || /[._-]/.test(segment); +} + +function pathSegmentCount(path: string) { + return path.split("/").filter(Boolean).length; +} + +function isPathCandidate( + value: string, + leadingText: string, + previousChar: string, +) { + if (URL_SCHEME_PREFIX_PATTERN.test(leadingText)) { + return false; + } + if (/^[A-Za-z]:[\\/]/.test(value) || value.startsWith("\\\\")) { + return !previousChar || !LETTER_OR_NUMBER_PATTERN.test(previousChar); + } + if (!value.includes("/")) { + return false; + } + if (value.startsWith("//")) { + return false; + } + if (value.startsWith("/") || value.startsWith("./") || value.startsWith("../")) { + if ( + value.startsWith("/") && + previousChar && + LETTER_OR_NUMBER_PATTERN.test(previousChar) + ) { + return false; + } + return true; + } + if (value.startsWith("~/")) { + return true; + } + const lastSegment = value.split("/").pop() ?? ""; + return lastSegment.includes("."); +} + +function splitTrailingPunctuation(value: string) { + let end = value.length; + while (end > 0 && TRAILING_PUNCTUATION.has(value[end - 1])) { + end -= 1; + } + return { + path: value.slice(0, end), + trailing: value.slice(end), + }; +} + +function getLeadingPathCandidateContext(value: string, matchIndex: number) { + let startIndex = matchIndex; + while (startIndex > 0) { + const previousChar = value[startIndex - 1]; + if (PATH_CANDIDATE_PREFIX_BOUNDARY_PATTERN.test(previousChar)) { + break; + } + startIndex -= 1; + } + return value.slice(startIndex, matchIndex); +} + +function isSkippableParent(parentType?: string) { + return parentType === "link" || parentType === "inlineCode" || parentType === "code"; +} + +function isLikelyMountedWorkspaceFilePath( + path: string, + workspacePath?: string | null, +) { + if (isKnownLocalWorkspaceRoutePath(path)) { + return false; + } + if (resolveMountedWorkspacePath(path, workspacePath) !== null) { + return true; + } + + const mountedPath = splitWorkspaceRoutePath(path); + return Boolean( + mountedPath?.prefix === WORKSPACE_MOUNT_PREFIX && + mountedPath.segments.length >= 2 && + hasLikelyWorkspaceNameSegment(mountedPath.segments[0]), + ); +} + +function usesAbsolutePathDepthFallback( + path: string, + workspacePath?: string | null, +) { + const normalizedPath = path.replace(/\\/g, "/"); + if ( + WORKSPACE_ROUTE_PREFIXES.some((prefix) => normalizedPath.startsWith(prefix)) && + !isLikelyMountedWorkspaceFilePath(normalizedPath, workspacePath) + ) { + return false; + } + return hasLikelyLocalAbsolutePrefix(normalizedPath) && pathSegmentCount(normalizedPath) >= 3; +} + +function isLikelyFileHref( + url: string, + workspacePath?: string | null, +) { + const trimmed = url.trim(); + if (!trimmed) { + return false; + } + if (trimmed.startsWith("file://")) { + return true; + } + if ( + trimmed.startsWith("http://") || + trimmed.startsWith("https://") || + trimmed.startsWith("mailto:") + ) { + return false; + } + if (trimmed.startsWith("thread://") || trimmed.startsWith("/thread/")) { + return false; + } + if (trimmed.startsWith("#")) { + return false; + } + + const parsedLocation = parseFileLocation(trimmed); + const pathOnly = parsedLocation.path.trim(); + if (/[?#]/.test(pathOnly)) { + return false; + } + if (/^[A-Za-z]:[\\/]/.test(pathOnly) || pathOnly.startsWith("\\\\")) { + return true; + } + if (pathOnly.startsWith("/")) { + if (parsedLocation.line !== null) { + const normalizedPath = pathOnly.replace(/\\/g, "/"); + if ( + WORKSPACE_ROUTE_PREFIXES.some((prefix) => normalizedPath.startsWith(prefix)) + ) { + return isLikelyMountedWorkspaceFilePath(normalizedPath, workspacePath); + } + return true; + } + if (hasLikelyFileName(pathOnly)) { + return true; + } + return usesAbsolutePathDepthFallback(pathOnly, workspacePath); + } + if (parsedLocation.line !== null) { + return true; + } + if (pathOnly.startsWith("~/")) { + return true; + } + if (pathOnly.startsWith("./") || pathOnly.startsWith("../")) { + return parsedLocation.line !== null || hasLikelyFileName(pathOnly); + } + if (hasLikelyFileName(pathOnly)) { + return pathSegmentCount(pathOnly) >= 3; + } + return false; +} + +export function parseInlineFileTarget(value: string): ParsedFileLocation | null { + const normalizedPath = normalizeFileLinkPath(value).trim(); + if (!normalizedPath || isKnownLocalWorkspaceRoutePath(normalizedPath)) { + return null; + } + if (!FILE_PATH_MATCH.test(normalizedPath)) { + return null; + } + if (!isPathCandidate(normalizedPath, "", "")) { + return null; + } + return parseFileLocation(normalizedPath); +} + +export function formatParsedFileLocation(target: ParsedFileLocation) { + return formatFileLocation(target.path, target.line, target.column); +} + +export function parseFileLinkUrl(url: string): ParsedFileLocation | null { + if (!url.startsWith(FILE_LINK_PROTOCOL)) { + return null; + } + const decoded = safeDecodeURIComponent(url.slice(FILE_LINK_PROTOCOL.length)); + return decoded ? parseFileLocation(decoded) : null; +} + +export function toFileLink(target: ParsedFileLocation | string) { + const value = + typeof target === "string" ? normalizeFileLinkPath(target) : formatParsedFileLocation(target); + return `${FILE_LINK_PROTOCOL}${encodeURIComponent(value)}`; +} + +function linkifyText(value: string) { + FILE_PATH_PATTERN.lastIndex = 0; + const nodes: MarkdownNode[] = []; + let lastIndex = 0; + let hasLink = false; + + for (const match of value.matchAll(FILE_PATH_PATTERN)) { + const matchIndex = match.index ?? 0; + const raw = match[0]; + if (matchIndex > lastIndex) { + nodes.push({ type: "text", value: value.slice(lastIndex, matchIndex) }); + } + + const leadingText = getLeadingPathCandidateContext(value, matchIndex); + const previousChar = matchIndex > 0 ? value[matchIndex - 1] : ""; + const { path, trailing } = splitTrailingPunctuation(raw); + if (path && isPathCandidate(path, leadingText, previousChar)) { + const parsedTarget = parseInlineFileTarget(path); + if (parsedTarget) { + nodes.push({ + type: "link", + url: toFileLink(parsedTarget), + children: [{ type: "text", value: path }], + }); + if (trailing) { + nodes.push({ type: "text", value: trailing }); + } + hasLink = true; + } else { + nodes.push({ type: "text", value: raw }); + } + } else { + nodes.push({ type: "text", value: raw }); + } + + lastIndex = matchIndex + raw.length; + } + + if (lastIndex < value.length) { + nodes.push({ type: "text", value: value.slice(lastIndex) }); + } + + return hasLink ? nodes : null; +} + +function walk(node: MarkdownNode, parentType?: string) { + if (!node.children) { + return; + } + + for (let index = 0; index < node.children.length; index += 1) { + const child = node.children[index]; + if ( + child.type === "text" && + typeof child.value === "string" && + !isSkippableParent(parentType) + ) { + const nextNodes = linkifyText(child.value); + if (nextNodes) { + node.children.splice(index, 1, ...nextNodes); + index += nextNodes.length - 1; + continue; + } + } + walk(child, child.type); + } +} + +export function remarkFileLinks() { + return (tree: MarkdownNode) => { + walk(tree); + }; +} + +export function isFileLinkUrl(url: string) { + return url.startsWith(FILE_LINK_PROTOCOL); +} + +export function resolveMessageFileHref( + url: string, + workspacePath?: string | null, +): ParsedFileLocation | null { + const fileUrlTarget = parseFileUrlLocation(url); + if (fileUrlTarget) { + return fileUrlTarget; + } + + 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) || !isLikelyFileHref(candidate, workspacePath)) { + continue; + } + seenCandidates.add(candidate); + + const parsedTarget = parseInlineFileTarget(candidate); + if (!parsedTarget) { + continue; + } + + const decodedPath = safeDecodeURIComponent(parsedTarget.path); + return { + path: decodedPath ?? parsedTarget.path, + line: parsedTarget.line, + column: parsedTarget.column, + }; + } + + return null; +} + +export function describeFileTarget( + target: ParsedFileLocation, + workspacePath?: string | null, +): ParsedFileReference { + const fullPath = formatParsedFileLocation(target); + const displayPath = relativeDisplayPath(target.path, workspacePath); + const normalizedPath = trimTrailingPathSeparators(displayPath) || displayPath; + const lastSlashIndex = normalizedPath.lastIndexOf("/"); + const fallbackFile = normalizedPath || fullPath; + const fileName = + lastSlashIndex >= 0 ? normalizedPath.slice(lastSlashIndex + 1) : fallbackFile; + const rawParentPath = + lastSlashIndex >= 0 ? normalizedPath.slice(0, lastSlashIndex) : ""; + return { + fullPath, + fileName, + lineLabel: + target.line === null + ? null + : `${target.line}${target.column !== null ? `:${target.column}` : ""}`, + parentPath: rawParentPath || (normalizedPath.startsWith("/") ? "/" : null), + }; +} diff --git a/src/features/messages/utils/mountedWorkspacePaths.ts b/src/features/messages/utils/mountedWorkspacePaths.ts index 45fca8c82..156499330 100644 --- a/src/features/messages/utils/mountedWorkspacePaths.ts +++ b/src/features/messages/utils/mountedWorkspacePaths.ts @@ -1,8 +1,9 @@ import { joinWorkspacePath } from "../../../utils/platformPaths"; -import { isKnownLocalWorkspaceRoutePath } from "../../../utils/fileLinks"; - -const WORKSPACE_MOUNT_PREFIX = "/workspace/"; -const WORKSPACES_MOUNT_PREFIX = "/workspaces/"; +import { + isKnownLocalWorkspaceRoutePath, + splitWorkspaceRoutePath, + WORKSPACE_MOUNT_PREFIX, +} from "./workspaceRoutePaths"; function normalizePathSeparators(path: string) { return path.replace(/\\/g, "/"); @@ -55,20 +56,12 @@ export function resolveMountedWorkspacePath( return null; }; - if (normalizedPath.startsWith(WORKSPACE_MOUNT_PREFIX)) { - return resolveFromSegments( - normalizedPath.slice(WORKSPACE_MOUNT_PREFIX.length).split("/").filter(Boolean), - true, - ); - } - if (normalizedPath.startsWith(WORKSPACES_MOUNT_PREFIX)) { - return resolveFromSegments( - normalizedPath - .slice(WORKSPACES_MOUNT_PREFIX.length) - .split("/") - .filter(Boolean), - false, - ); + const routeMatch = splitWorkspaceRoutePath(normalizedPath); + if (!routeMatch) { + return null; } - return null; + return resolveFromSegments( + routeMatch.segments, + routeMatch.prefix === WORKSPACE_MOUNT_PREFIX, + ); } diff --git a/src/features/messages/utils/remarkFileLinks.test.ts b/src/features/messages/utils/remarkFileLinks.test.ts new file mode 100644 index 000000000..fc13ec1f4 --- /dev/null +++ b/src/features/messages/utils/remarkFileLinks.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from "vitest"; +import { remarkFileLinks } from "./messageFileLinks"; + +type TestNode = { + type: string; + value?: string; + url?: string; + children?: TestNode[]; +}; + +function runRemarkFileLinks(tree: TestNode) { + remarkFileLinks()(tree); + return tree; +} + +function textParagraph(value: string): TestNode { + return { + type: "root", + children: [ + { + type: "paragraph", + children: [{ type: "text", value }], + }, + ], + }; +} + +describe("remarkFileLinks", () => { + it("does not turn natural-language slash phrases into file links", () => { + const tree = runRemarkFileLinks( + textParagraph("Keep the current app/daemon behavior and the existing Git/Plan experience."), + ); + expect(tree.children?.[0]?.children?.map((child) => child.type)).toEqual(["text"]); + }); + + it("turns clear file paths into links", () => { + const tree = runRemarkFileLinks( + textParagraph("See docs/setup.md and /Users/example/project/src/index.ts for details."), + ); + expect(tree.children?.[0]?.children?.filter((child) => child.type === "link")).toHaveLength(2); + }); + + it("keeps workspace route anchors out of linkification", () => { + const tree = runRemarkFileLinks( + textParagraph("See /workspace/settings#L12 for app settings."), + ); + expect(tree.children?.[0]?.children?.map((child) => child.type)).toEqual(["text"]); + }); + + it("leaves inline code untouched", () => { + const tree = runRemarkFileLinks({ + type: "root", + children: [ + { + type: "paragraph", + children: [{ type: "inlineCode", value: "/workspace/reviews#L9" }], + }, + ], + }); + expect(tree.children?.[0]?.children?.[0]?.type).toBe("inlineCode"); + }); + + it("does not turn file URLs into local file links", () => { + const tree = runRemarkFileLinks( + textParagraph("Download file:///C:/repo/src/App.tsx instead of opening a local file link."), + ); + expect(tree.children?.[0]?.children?.map((child) => child.type)).toEqual(["text"]); + }); +}); diff --git a/src/features/messages/utils/workspaceRoutePaths.test.ts b/src/features/messages/utils/workspaceRoutePaths.test.ts new file mode 100644 index 000000000..a1f57b522 --- /dev/null +++ b/src/features/messages/utils/workspaceRoutePaths.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; +import { isKnownLocalWorkspaceRoutePath } from "./workspaceRoutePaths"; + +describe("isKnownLocalWorkspaceRoutePath", () => { + it("matches exact mounted settings and reviews routes", () => { + expect(isKnownLocalWorkspaceRoutePath("/workspace/settings")).toBe(true); + expect(isKnownLocalWorkspaceRoutePath("/workspace/reviews")).toBe(true); + expect(isKnownLocalWorkspaceRoutePath("/workspaces/team/settings")).toBe(true); + expect(isKnownLocalWorkspaceRoutePath("/workspaces/team/reviews")).toBe(true); + }); + + it("keeps explicit nested settings and reviews app routes out of file resolution", () => { + expect(isKnownLocalWorkspaceRoutePath("/workspace/settings/profile")).toBe(true); + expect(isKnownLocalWorkspaceRoutePath("/workspace/reviews/overview")).toBe(true); + expect(isKnownLocalWorkspaceRoutePath("/workspaces/team/settings/profile")).toBe(true); + expect(isKnownLocalWorkspaceRoutePath("/workspaces/team/reviews/overview")).toBe(true); + }); + + it("still allows file-like descendants under reserved workspace names", () => { + expect(isKnownLocalWorkspaceRoutePath("/workspace/settings/src/App.tsx")).toBe(false); + expect(isKnownLocalWorkspaceRoutePath("/workspace/reviews/src/App.tsx")).toBe(false); + expect(isKnownLocalWorkspaceRoutePath("/workspaces/team/settings/src/App.tsx")).toBe( + false, + ); + expect(isKnownLocalWorkspaceRoutePath("/workspaces/team/reviews/src/App.tsx")).toBe( + false, + ); + }); + + it("treats extensionless descendants under reserved workspace names as mounted files", () => { + expect(isKnownLocalWorkspaceRoutePath("/workspace/settings/LICENSE")).toBe(false); + expect(isKnownLocalWorkspaceRoutePath("/workspace/reviews/bin/tool")).toBe(false); + expect(isKnownLocalWorkspaceRoutePath("/workspaces/team/settings/Makefile")).toBe( + false, + ); + expect(isKnownLocalWorkspaceRoutePath("/workspaces/team/reviews/bin/tool")).toBe( + false, + ); + }); +}); diff --git a/src/features/messages/utils/workspaceRoutePaths.ts b/src/features/messages/utils/workspaceRoutePaths.ts new file mode 100644 index 000000000..b6b3a3b4b --- /dev/null +++ b/src/features/messages/utils/workspaceRoutePaths.ts @@ -0,0 +1,98 @@ +import { SETTINGS_ROUTE_SECTION_IDS } from "@settings/components/settingsTypes"; +import { parseFileLocation } from "../../../utils/fileLinks"; + +export const WORKSPACE_MOUNT_PREFIX = "/workspace/"; +export const WORKSPACES_MOUNT_PREFIX = "/workspaces/"; +export const WORKSPACE_ROUTE_PREFIXES = [ + WORKSPACE_MOUNT_PREFIX, + WORKSPACES_MOUNT_PREFIX, +] as const; + +const LOCAL_WORKSPACE_ROUTE_TAIL_SEGMENTS = { + reviews: new Set(["overview"]), + settings: new Set(SETTINGS_ROUTE_SECTION_IDS), +} as const; + +export type WorkspaceRouteMatch = { + prefix: (typeof WORKSPACE_ROUTE_PREFIXES)[number]; + segments: string[]; +}; + +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 normalizeWorkspaceRoutePath(rawPath: string) { + return stripNonLineUrlSuffix(parseFileLocation(rawPath).path.trim().replace(/\\/g, "/")); +} + +export function splitWorkspaceRoutePath(path: string): WorkspaceRouteMatch | null { + const normalizedPath = path.replace(/\\/g, "/"); + if (normalizedPath.startsWith(WORKSPACE_MOUNT_PREFIX)) { + return { + prefix: WORKSPACE_MOUNT_PREFIX, + segments: normalizedPath.slice(WORKSPACE_MOUNT_PREFIX.length).split("/").filter(Boolean), + }; + } + if (normalizedPath.startsWith(WORKSPACES_MOUNT_PREFIX)) { + return { + prefix: WORKSPACES_MOUNT_PREFIX, + segments: normalizedPath + .slice(WORKSPACES_MOUNT_PREFIX.length) + .split("/") + .filter(Boolean), + }; + } + return null; +} + +function getLocalWorkspaceRouteInfo(rawPath: string) { + const match = splitWorkspaceRoutePath(normalizeWorkspaceRoutePath(rawPath)); + if (!match) { + return null; + } + return { + routeSegment: + match.prefix === WORKSPACE_MOUNT_PREFIX + ? match.segments[0] ?? null + : match.segments[1] ?? null, + tailSegments: + match.prefix === WORKSPACE_MOUNT_PREFIX + ? match.segments.slice(1) + : match.segments.slice(2), + }; +} + +export function isKnownLocalWorkspaceRoutePath(rawPath: string) { + const routeInfo = getLocalWorkspaceRouteInfo(rawPath); + if (!routeInfo?.routeSegment) { + return false; + } + if ( + !Object.prototype.hasOwnProperty.call( + LOCAL_WORKSPACE_ROUTE_TAIL_SEGMENTS, + routeInfo.routeSegment, + ) + ) { + return false; + } + if (routeInfo.tailSegments.length === 0) { + return true; + } + if (routeInfo.tailSegments.length !== 1) { + return false; + } + const allowedTailSegments = + LOCAL_WORKSPACE_ROUTE_TAIL_SEGMENTS[ + routeInfo.routeSegment as keyof typeof LOCAL_WORKSPACE_ROUTE_TAIL_SEGMENTS + ]; + return (allowedTailSegments as ReadonlySet).has(routeInfo.tailSegments[0]); +} diff --git a/src/features/settings/components/settingsTypes.ts b/src/features/settings/components/settingsTypes.ts index 091e89910..166f79148 100644 --- a/src/features/settings/components/settingsTypes.ts +++ b/src/features/settings/components/settingsTypes.ts @@ -1,19 +1,32 @@ import type { OpenAppTarget } from "@/types"; -type SettingsSection = - | "projects" - | "environments" - | "display" - | "about" - | "composer" - | "dictation" - | "shortcuts" - | "open-apps" - | "git" - | "server" - | "agents"; - -export type CodexSection = SettingsSection | "codex" | "features"; +export const SETTINGS_SECTION_IDS = [ + "projects", + "environments", + "display", + "about", + "composer", + "dictation", + "shortcuts", + "open-apps", + "git", + "server", + "agents", +] as const; + +export const SETTINGS_EXTRA_SECTION_IDS = ["codex", "features"] as const; + +export const SETTINGS_ROUTE_SECTION_IDS = [ + ...SETTINGS_SECTION_IDS, + ...SETTINGS_EXTRA_SECTION_IDS, + "profile", +] as const; + +type SettingsSection = (typeof SETTINGS_SECTION_IDS)[number]; + +export type CodexSection = + | SettingsSection + | (typeof SETTINGS_EXTRA_SECTION_IDS)[number]; export type ShortcutSettingKey = | "composerModelShortcut" diff --git a/src/utils/fileLinks.test.ts b/src/utils/fileLinks.test.ts index 6fd884a20..117e3e7d4 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, toFileUrl } from "./fileLinks"; +import { formatFileLocation, parseFileUrlLocation, toFileUrl } from "./fileLinks"; function withThrowingUrlConstructor(run: () => void) { const originalUrl = globalThis.URL; @@ -24,33 +24,39 @@ function withThrowingUrlConstructor(run: () => void) { } } -describe("fromFileUrl", () => { +function expectFileUrlLocation(url: string, expected: string | null) { + const parsed = parseFileUrlLocation(url); + const formatted = parsed + ? formatFileLocation(parsed.path, parsed.line, parsed.column) + : null; + expect(formatted).toBe(expected); +} + +describe("parseFileUrlLocation", () => { 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"); + expectFileUrlLocation("file:///tmp/%23L12", "/tmp/#L12"); + expectFileUrlLocation("file:///tmp/report%23L12C3.md", "/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( + expectFileUrlLocation("file:///tmp/report%23L12.md#L34", "/tmp/report#L12.md:34"); + expectFileUrlLocation("file:///tmp/report%23L12C3.md#L34C2", "/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"); + expectFileUrlLocation("file:///C:/repo/100%.tsx#L12", "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", - ); + expectFileUrlLocation("file://server/share/100%.tsx#L12", "//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( + expectFileUrlLocation("file:///C:/repo/100%.tsx#L12", "C:/repo/100%.tsx:12"); + expectFileUrlLocation("file://localhost/C:/repo/100%.tsx#L12", "C:/repo/100%.tsx:12", ); }); @@ -58,7 +64,7 @@ describe("fromFileUrl", () => { it("preserves UNC host info when the URL constructor fallback is used", () => { withThrowingUrlConstructor(() => { - expect(fromFileUrl("file://server/share/100%.tsx#L12")).toBe( + expectFileUrlLocation("file://server/share/100%.tsx#L12", "//server/share/100%.tsx:12", ); }); @@ -66,15 +72,15 @@ 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"); + expectFileUrlLocation("file:///tmp/%23L12", "/tmp/#L12"); + expectFileUrlLocation("file:///tmp/report%23L12.md#L34", "/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"); + expectFileUrlLocation(fileUrl, "\\\\?\\C:\\repo\\src\\App.tsx:12"); }); it("round-trips Windows namespace UNC paths through file URLs", () => { @@ -82,44 +88,6 @@ describe("fromFileUrl", () => { expect(fileUrl).toBe( "file:///%5C%5C%3F%5CUNC%5Cserver%5Cshare%5Crepo%5CApp.tsx#L12", ); - expect(fromFileUrl(fileUrl)).toBe("\\\\?\\UNC\\server\\share\\repo\\App.tsx:12"); - }); -}); - -describe("isKnownLocalWorkspaceRoutePath", () => { - it("matches exact mounted settings and reviews routes", () => { - expect(isKnownLocalWorkspaceRoutePath("/workspace/settings")).toBe(true); - expect(isKnownLocalWorkspaceRoutePath("/workspace/reviews")).toBe(true); - expect(isKnownLocalWorkspaceRoutePath("/workspaces/team/settings")).toBe(true); - expect(isKnownLocalWorkspaceRoutePath("/workspaces/team/reviews")).toBe(true); - }); - - it("keeps explicit nested settings and reviews app routes out of file resolution", () => { - expect(isKnownLocalWorkspaceRoutePath("/workspace/settings/profile")).toBe(true); - expect(isKnownLocalWorkspaceRoutePath("/workspace/reviews/overview")).toBe(true); - expect(isKnownLocalWorkspaceRoutePath("/workspaces/team/settings/profile")).toBe(true); - expect(isKnownLocalWorkspaceRoutePath("/workspaces/team/reviews/overview")).toBe(true); - }); - - it("still allows file-like descendants under reserved workspace names", () => { - expect(isKnownLocalWorkspaceRoutePath("/workspace/settings/src/App.tsx")).toBe(false); - expect(isKnownLocalWorkspaceRoutePath("/workspace/reviews/src/App.tsx")).toBe(false); - expect(isKnownLocalWorkspaceRoutePath("/workspaces/team/settings/src/App.tsx")).toBe( - false, - ); - expect(isKnownLocalWorkspaceRoutePath("/workspaces/team/reviews/src/App.tsx")).toBe( - false, - ); - }); - - it("treats extensionless descendants under reserved workspace names as mounted files", () => { - expect(isKnownLocalWorkspaceRoutePath("/workspace/settings/LICENSE")).toBe(false); - expect(isKnownLocalWorkspaceRoutePath("/workspace/reviews/bin/tool")).toBe(false); - expect(isKnownLocalWorkspaceRoutePath("/workspaces/team/settings/Makefile")).toBe( - false, - ); - expect(isKnownLocalWorkspaceRoutePath("/workspaces/team/reviews/bin/tool")).toBe( - false, - ); + expectFileUrlLocation(fileUrl, "\\\\?\\UNC\\server\\share\\repo\\App.tsx:12"); }); }); diff --git a/src/utils/fileLinks.ts b/src/utils/fileLinks.ts index f6b960017..bf539a3f1 100644 --- a/src/utils/fileLinks.ts +++ b/src/utils/fileLinks.ts @@ -4,31 +4,10 @@ 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; const FILE_URL_LOCATION_HASH_PATTERN = /^#L(\d+)(?:C(\d+))?$/i; -const LOCAL_WORKSPACE_ROUTE_TAIL_SEGMENTS = { - reviews: new Set(["overview"]), - settings: new Set([ - "about", - "agents", - "codex", - "composer", - "dictation", - "display", - "environments", - "features", - "git", - "open-apps", - "profile", - "projects", - "server", - "shortcuts", - ]), -} as const; export const FILE_LINK_SUFFIX_SOURCE = "(?:(?::\\d+(?::\\d+)?|:\\d+-\\d+)|(?:#L\\d+(?:C\\d+)?))?"; @@ -200,74 +179,6 @@ export function normalizeFileLinkPath(rawPath: string) { return formatFileLocation(parsed.path, parsed.line, parsed.column); } -function stripNonLineUrlSuffix(path: string) { - const queryIndex = path.indexOf("?"); - const hashIndex = path.indexOf("#"); - const boundaryIndex = - queryIndex === -1 - ? hashIndex - : hashIndex === -1 - ? queryIndex - : Math.min(queryIndex, hashIndex); - return boundaryIndex === -1 ? path : path.slice(0, boundaryIndex); -} - -function getLocalWorkspaceRouteInfo(rawPath: string) { - const parsed = parseFileLocation(rawPath); - const normalizedPath = stripNonLineUrlSuffix(parsed.path.trim().replace(/\\/g, "/")); - if (normalizedPath.startsWith("/workspace/")) { - const mountedSegments = normalizedPath - .slice("/workspace/".length) - .split("/") - .filter(Boolean); - return { - line: parsed.line, - mountedSegments, - routeSegment: mountedSegments[0] ?? null, - tailSegments: mountedSegments.slice(1), - }; - } - if (normalizedPath.startsWith("/workspaces/")) { - const mountedSegments = normalizedPath - .slice("/workspaces/".length) - .split("/") - .filter(Boolean); - return { - line: parsed.line, - mountedSegments, - routeSegment: mountedSegments[1] ?? null, - tailSegments: mountedSegments.slice(2), - }; - } - return null; -} - -export function isKnownLocalWorkspaceRoutePath(rawPath: string) { - const routeInfo = getLocalWorkspaceRouteInfo(rawPath); - if (!routeInfo?.routeSegment) { - return false; - } - if ( - !Object.prototype.hasOwnProperty.call( - LOCAL_WORKSPACE_ROUTE_TAIL_SEGMENTS, - routeInfo.routeSegment, - ) - ) { - return false; - } - if (routeInfo.tailSegments.length === 0) { - return true; - } - if (routeInfo.tailSegments.length !== 1) { - return false; - } - const allowedTailSegments = - LOCAL_WORKSPACE_ROUTE_TAIL_SEGMENTS[ - routeInfo.routeSegment as keyof typeof LOCAL_WORKSPACE_ROUTE_TAIL_SEGMENTS - ]; - return allowedTailSegments.has(routeInfo.tailSegments[0]); -} - type FileUrlParts = { host: string; pathname: string; @@ -380,8 +291,3 @@ export function parseFileUrlLocation(url: string): ParsedFileLocation | null { return { path, line, column }; } } - -export function fromFileUrl(url: string) { - const parsed = parseFileUrlLocation(url); - return parsed ? formatFileLocation(parsed.path, parsed.line, parsed.column) : null; -} diff --git a/src/utils/remarkFileLinks.ts b/src/utils/remarkFileLinks.ts index 278d86492..9b25c3257 100644 --- a/src/utils/remarkFileLinks.ts +++ b/src/utils/remarkFileLinks.ts @@ -1,181 +1,8 @@ -import { - FILE_LINK_SUFFIX_SOURCE, - isKnownLocalWorkspaceRoutePath, - normalizeFileLinkPath, -} from "./fileLinks"; - -const FILE_LINK_PROTOCOL = "codex-file:"; -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( - `(${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; - value?: string; - url?: string; - children?: MarkdownNode[]; -}; - -function isPathCandidate( - value: string, - leadingText: string, - previousChar: string, -) { - if (URL_SCHEME_PREFIX_PATTERN.test(leadingText)) { - return false; - } - if (/^[A-Za-z]:[\\/]/.test(value) || value.startsWith("\\\\")) { - return !previousChar || !LETTER_OR_NUMBER_PATTERN.test(previousChar); - } - if (!value.includes("/")) { - return false; - } - if (value.startsWith("//")) { - return false; - } - if (value.startsWith("/") || value.startsWith("./") || value.startsWith("../")) { - if ( - value.startsWith("/") && - previousChar && - LETTER_OR_NUMBER_PATTERN.test(previousChar) - ) { - return false; - } - return true; - } - if (value.startsWith("~/")) { - return true; - } - const lastSegment = value.split("/").pop() ?? ""; - return lastSegment.includes("."); -} - -function splitTrailingPunctuation(value: string) { - let end = value.length; - while (end > 0 && TRAILING_PUNCTUATION.has(value[end - 1])) { - end -= 1; - } - return { - path: value.slice(0, end), - trailing: value.slice(end), - }; -} - -export function toFileLink(path: string) { - return `${FILE_LINK_PROTOCOL}${encodeURIComponent(path)}`; -} - -function linkifyText(value: string) { - FILE_PATH_PATTERN.lastIndex = 0; - const nodes: MarkdownNode[] = []; - let lastIndex = 0; - let hasLink = false; - - for (const match of value.matchAll(FILE_PATH_PATTERN)) { - const matchIndex = match.index ?? 0; - const raw = match[0]; - if (matchIndex > lastIndex) { - nodes.push({ type: "text", value: value.slice(lastIndex, matchIndex) }); - } - - const leadingText = value.slice(0, matchIndex); - const previousChar = matchIndex > 0 ? value[matchIndex - 1] : ""; - 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), - children: [{ type: "text", value: path }], - }); - if (trailing) { - nodes.push({ type: "text", value: trailing }); - } - hasLink = true; - } else { - nodes.push({ type: "text", value: raw }); - } - - lastIndex = matchIndex + raw.length; - } - - if (lastIndex < value.length) { - nodes.push({ type: "text", value: value.slice(lastIndex) }); - } - - return hasLink ? nodes : null; -} - -function isSkippableParent(parentType?: string) { - return parentType === "link" || parentType === "inlineCode" || parentType === "code"; -} - -function walk(node: MarkdownNode, parentType?: string) { - if (!node.children) { - return; - } - - for (let index = 0; index < node.children.length; index += 1) { - const child = node.children[index]; - if ( - child.type === "text" && - typeof child.value === "string" && - !isSkippableParent(parentType) - ) { - const nextNodes = linkifyText(child.value); - if (nextNodes) { - node.children.splice(index, 1, ...nextNodes); - index += nextNodes.length - 1; - continue; - } - } - walk(child, child.type); - } -} - -export function remarkFileLinks() { - return (tree: MarkdownNode) => { - walk(tree); - }; -} - -export function isLinkableFilePath(value: string) { - const trimmed = value.trim(); - if (!trimmed) { - return false; - } - if (isKnownLocalWorkspaceRoutePath(trimmed)) { - return false; - } - if (!FILE_PATH_MATCH.test(trimmed)) { - return false; - } - return isPathCandidate(trimmed, "", ""); -} - -export function isFileLinkUrl(url: string) { - return url.startsWith(FILE_LINK_PROTOCOL); -} - -export function decodeFileLink(url: string) { - return decodeURIComponent(url.slice(FILE_LINK_PROTOCOL.length)); -} +export { + isFileLinkUrl, + parseFileLinkUrl, + parseInlineFileTarget, + remarkFileLinks, + resolveMessageFileHref, + toFileLink, +} from "../features/messages/utils/messageFileLinks"; From 313eb80d975824e768756f7176487307cb92226c Mon Sep 17 00:00:00 2001 From: Thomas Ricouard Date: Sun, 22 Mar 2026 10:48:49 +0100 Subject: [PATCH 13/15] fix: satisfy PR lint checks --- src/features/app/hooks/useTrayRecentThreads.ts | 5 ++--- src/features/messages/utils/messageFileLinks.ts | 2 +- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/features/app/hooks/useTrayRecentThreads.ts b/src/features/app/hooks/useTrayRecentThreads.ts index 6275948b8..226dd2824 100644 --- a/src/features/app/hooks/useTrayRecentThreads.ts +++ b/src/features/app/hooks/useTrayRecentThreads.ts @@ -85,7 +85,6 @@ export function useTrayRecentThreads({ [isSubagentThread, threadsByWorkspace, workspaces], ); const serializedEntries = useMemo(() => JSON.stringify(entries), [entries]); - const syncEntries = useMemo(() => entries, [serializedEntries]); const lastSyncedEntriesRef = useRef(null); useEffect(() => { @@ -103,7 +102,7 @@ export function useTrayRecentThreads({ const scheduleSync = () => { timeoutId = window.setTimeout(() => { timeoutId = null; - void setTrayRecentThreads(syncEntries) + void setTrayRecentThreads(entries) .then(() => { if (cancelled) { return; @@ -128,5 +127,5 @@ export function useTrayRecentThreads({ window.clearTimeout(timeoutId); } }; - }, [serializedEntries, syncEntries]); + }, [entries, serializedEntries]); } diff --git a/src/features/messages/utils/messageFileLinks.ts b/src/features/messages/utils/messageFileLinks.ts index 80ec31755..00b7e655d 100644 --- a/src/features/messages/utils/messageFileLinks.ts +++ b/src/features/messages/utils/messageFileLinks.ts @@ -45,7 +45,7 @@ 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+.-]*:\/\/\/?$/; -const PATH_CANDIDATE_PREFIX_BOUNDARY_PATTERN = /[\s<>"'`()\[\]{}]/u; +const PATH_CANDIDATE_PREFIX_BOUNDARY_PATTERN = /[\s<>"'()`[\]{}]/u; const LIKELY_LOCAL_ABSOLUTE_PATH_PREFIXES = [ "/Users/", "/home/", From c7dde07f2ed346a3bb22080a3e65784b0ef3675b Mon Sep 17 00:00:00 2001 From: Thomas Ricouard Date: Sun, 22 Mar 2026 10:54:30 +0100 Subject: [PATCH 14/15] Update useTrayRecentThreads.ts --- src/features/app/hooks/useTrayRecentThreads.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/features/app/hooks/useTrayRecentThreads.ts b/src/features/app/hooks/useTrayRecentThreads.ts index 226dd2824..6275948b8 100644 --- a/src/features/app/hooks/useTrayRecentThreads.ts +++ b/src/features/app/hooks/useTrayRecentThreads.ts @@ -85,6 +85,7 @@ export function useTrayRecentThreads({ [isSubagentThread, threadsByWorkspace, workspaces], ); const serializedEntries = useMemo(() => JSON.stringify(entries), [entries]); + const syncEntries = useMemo(() => entries, [serializedEntries]); const lastSyncedEntriesRef = useRef(null); useEffect(() => { @@ -102,7 +103,7 @@ export function useTrayRecentThreads({ const scheduleSync = () => { timeoutId = window.setTimeout(() => { timeoutId = null; - void setTrayRecentThreads(entries) + void setTrayRecentThreads(syncEntries) .then(() => { if (cancelled) { return; @@ -127,5 +128,5 @@ export function useTrayRecentThreads({ window.clearTimeout(timeoutId); } }; - }, [entries, serializedEntries]); + }, [serializedEntries, syncEntries]); } From ead51f76677a8bf1d417ae6b5270d61e0ed8d183 Mon Sep 17 00:00:00 2001 From: Thomas Ricouard Date: Sun, 22 Mar 2026 10:56:47 +0100 Subject: [PATCH 15/15] fix: avoid linkifying custom URI Windows paths --- src/features/messages/components/Markdown.test.tsx | 12 ++++++++++++ src/features/messages/utils/messageFileLinks.ts | 6 +++++- src/features/messages/utils/remarkFileLinks.test.ts | 7 +++++++ 3 files changed, 24 insertions(+), 1 deletion(-) diff --git a/src/features/messages/components/Markdown.test.tsx b/src/features/messages/components/Markdown.test.tsx index c2a469f05..37f252224 100644 --- a/src/features/messages/components/Markdown.test.tsx +++ b/src/features/messages/components/Markdown.test.tsx @@ -448,6 +448,18 @@ describe("Markdown file-like href behavior", () => { expect(container.textContent).toContain("/workspace/settings#L12"); }); + it("does not linkify Windows file paths embedded in custom URIs", () => { + const { container } = render( + , + ); + + expect(container.querySelector(".message-file-link")).toBeNull(); + expect(container.textContent).toContain("vscode://file/C:/repo/src/App.tsx:12"); + }); + it("does not turn workspace review #L anchors in inline code into file links", () => { const { container } = render( "'()`[\]{}]/u; const LIKELY_LOCAL_ABSOLUTE_PATH_PREFIXES = [ "/Users/", @@ -198,7 +199,10 @@ function isPathCandidate( leadingText: string, previousChar: string, ) { - if (URL_SCHEME_PREFIX_PATTERN.test(leadingText)) { + if ( + URL_SCHEME_PREFIX_PATTERN.test(leadingText) || + EMBEDDED_URL_SCHEME_PATTERN.test(leadingText) + ) { return false; } if (/^[A-Za-z]:[\\/]/.test(value) || value.startsWith("\\\\")) { diff --git a/src/features/messages/utils/remarkFileLinks.test.ts b/src/features/messages/utils/remarkFileLinks.test.ts index fc13ec1f4..55ab18b60 100644 --- a/src/features/messages/utils/remarkFileLinks.test.ts +++ b/src/features/messages/utils/remarkFileLinks.test.ts @@ -66,4 +66,11 @@ describe("remarkFileLinks", () => { ); expect(tree.children?.[0]?.children?.map((child) => child.type)).toEqual(["text"]); }); + + it("does not split custom URIs that embed Windows file paths", () => { + const tree = runRemarkFileLinks( + textParagraph("Open vscode://file/C:/repo/src/App.tsx:12 in VS Code."), + ); + expect(tree.children?.[0]?.children?.map((child) => child.type)).toEqual(["text"]); + }); });