diff --git a/src/features/messages/components/Markdown.test.tsx b/src/features/messages/components/Markdown.test.tsx index dd75ce237..37f252224 100644 --- a/src/features/messages/components/Markdown.test.tsx +++ b/src/features/messages/components/Markdown.test.tsx @@ -1,6 +1,7 @@ // @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"; describe("Markdown file-like href behavior", () => { @@ -46,7 +47,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 +90,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 +113,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 +136,7 @@ describe("Markdown file-like href behavior", () => { }); fireEvent(link as Element, clickEvent); expect(clickEvent.defaultPrevented).toBe(true); - expect(onOpenFileLink).toHaveBeenCalledWith("/workspace/.github/workflows"); + expectOpenedFileTarget(onOpenFileLink, "/workspace/.github/workflows"); }); it("intercepts mounted workspace directory links that resolve relative to the workspace", () => { @@ -158,20 +159,43 @@ describe("Markdown file-like href behavior", () => { }); fireEvent(link as Element, clickEvent); expect(clickEvent.defaultPrevented).toBe(true); - expect(onOpenFileLink).toHaveBeenCalledWith("/workspace/dist/assets"); + expectOpenedFileTarget(onOpenFileLink, "/workspace/dist/assets"); }); - it("keeps generic workspace routes as normal markdown links", () => { + it("keeps exact workspace routes as normal markdown links", () => { const onOpenFileLink = vi.fn(); render( , ); + const link = screen.getByText("reviews").closest("a"); + expect(link?.getAttribute("href")).toBe("/workspace/reviews"); + + const clickEvent = createEvent.click(link as Element, { + bubbles: true, + cancelable: true, + }); + fireEvent(link as Element, clickEvent); + expect(clickEvent.defaultPrevented).toBe(true); + expect(onOpenFileLink).not.toHaveBeenCalled(); + }); + + it("keeps nested workspace reviews routes local even when the workspace basename matches", () => { + const onOpenFileLink = vi.fn(); + render( + , + ); + const link = screen.getByText("overview").closest("a"); expect(link?.getAttribute("href")).toBe("/workspace/reviews/overview"); @@ -207,6 +231,29 @@ describe("Markdown file-like href behavior", () => { expect(onOpenFileLink).not.toHaveBeenCalled(); }); + it("keeps nested reviews routes local even when the workspace basename matches the route segment", () => { + const onOpenFileLink = vi.fn(); + render( + , + ); + + const link = screen.getByText("overview").closest("a"); + expect(link?.getAttribute("href")).toBe("/workspaces/team/reviews/overview"); + + const clickEvent = createEvent.click(link as Element, { + bubbles: true, + cancelable: true, + }); + fireEvent(link as Element, clickEvent); + expect(clickEvent.defaultPrevented).toBe(true); + expect(onOpenFileLink).not.toHaveBeenCalled(); + }); + it("still intercepts nested workspace file hrefs when a file opener is provided", () => { const onOpenFileLink = vi.fn(); render( @@ -227,7 +274,30 @@ describe("Markdown file-like href behavior", () => { }); fireEvent(link as Element, clickEvent); expect(clickEvent.defaultPrevented).toBe(true); - expect(onOpenFileLink).toHaveBeenCalledWith("/workspaces/team/CodexMonitor/src"); + expectOpenedFileTarget(onOpenFileLink, "/workspaces/team/CodexMonitor/src"); + }); + + it("treats extensionless paths under /workspace/settings as files", () => { + const onOpenFileLink = vi.fn(); + render( + , + ); + + const link = screen.getByText("license").closest("a"); + expect(link?.getAttribute("href")).toBe("/workspace/settings/LICENSE"); + + const clickEvent = createEvent.click(link as Element, { + bubbles: true, + cancelable: true, + }); + fireEvent(link as Element, clickEvent); + expect(clickEvent.defaultPrevented).toBe(true); + expectOpenedFileTarget(onOpenFileLink, "/workspace/settings/LICENSE"); }); it("intercepts file hrefs that use #L line anchors", () => { @@ -249,7 +319,52 @@ describe("Markdown file-like href behavior", () => { }); fireEvent(link as Element, clickEvent); expect(clickEvent.defaultPrevented).toBe(true); - expect(onOpenFileLink).toHaveBeenCalledWith("./docs/setup.md:12"); + expectOpenedFileTarget(onOpenFileLink, "./docs/setup.md", 12); + }); + + it("intercepts Windows absolute file hrefs with #L anchors and preserves the tooltip", () => { + const onOpenFileLink = vi.fn(); + const onOpenFileLinkMenu = vi.fn(); + const linkedPath = + "I:\\gpt-projects\\CodexMonitor\\src\\features\\settings\\components\\sections\\SettingsDisplaySection.tsx#L422"; + render( + , + ); + + const link = screen.getByText("SettingsDisplaySection.tsx").closest("a"); + expect(link?.getAttribute("href")).toBe( + "I:%5Cgpt-projects%5CCodexMonitor%5Csrc%5Cfeatures%5Csettings%5Ccomponents%5Csections%5CSettingsDisplaySection.tsx#L422", + ); + expect(link?.getAttribute("title")).toBe( + "I:\\gpt-projects\\CodexMonitor\\src\\features\\settings\\components\\sections\\SettingsDisplaySection.tsx:422", + ); + + const clickEvent = createEvent.click(link as Element, { + bubbles: true, + cancelable: true, + }); + fireEvent(link as Element, clickEvent); + expect(clickEvent.defaultPrevented).toBe(true); + expectOpenedFileTarget( + onOpenFileLink, + "I:\\gpt-projects\\CodexMonitor\\src\\features\\settings\\components\\sections\\SettingsDisplaySection.tsx", + 422, + ); + + fireEvent.contextMenu(link as Element); + expect(onOpenFileLinkMenu).toHaveBeenCalledWith( + expect.anything(), + { + path: "I:\\gpt-projects\\CodexMonitor\\src\\features\\settings\\components\\sections\\SettingsDisplaySection.tsx", + line: 422, + column: null, + }, + ); }); it("prevents unsupported route fragments without treating them as file links", () => { @@ -274,42 +389,133 @@ describe("Markdown file-like href behavior", () => { expect(onOpenFileLink).not.toHaveBeenCalled(); }); - it("does not turn natural-language slash phrases into file links", () => { + 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("app/daemon"); - expect(container.textContent).toContain("Git/Plan"); + expect(container.textContent).toContain("/workspace/settings#L12"); }); - it("does not turn longer slash phrases into file links", () => { + 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("Spec/Verification/Evidence"); + expect(container.textContent).toContain("vscode://file/C:/repo/src/App.tsx:12"); }); - it("still turns clear file paths in plain text into file links", () => { + 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("still opens mounted file links when the workspace basename is settings", () => { + const onOpenFileLink = vi.fn(); + 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"); + const link = screen.getByText("app").closest("a"); + expect(link?.getAttribute("href")).toBe("/workspace/settings/src/App.tsx"); + + const clickEvent = createEvent.click(link as Element, { + bubbles: true, + cancelable: true, + }); + fireEvent(link as Element, clickEvent); + expect(clickEvent.defaultPrevented).toBe(true); + expectOpenedFileTarget(onOpenFileLink, "/workspace/settings/src/App.tsx"); }); + + it("keeps nested settings routes local when the workspace basename is settings", () => { + const onOpenFileLink = vi.fn(); + render( + , + ); + + const link = screen.getByText("profile").closest("a"); + expect(link?.getAttribute("href")).toBe("/workspace/settings/profile"); + + const clickEvent = createEvent.click(link as Element, { + bubbles: true, + cancelable: true, + }); + fireEvent(link as Element, clickEvent); + expect(clickEvent.defaultPrevented).toBe(true); + expect(onOpenFileLink).not.toHaveBeenCalled(); + }); + }); diff --git a/src/features/messages/components/Markdown.tsx b/src/features/messages/components/Markdown.tsx index 503aef0b0..b87d0122b 100644 --- a/src/features/messages/components/Markdown.tsx +++ b/src/features/messages/components/Markdown.tsx @@ -3,13 +3,16 @@ import ReactMarkdown, { type Components } from "react-markdown"; import remarkGfm from "remark-gfm"; import { openUrl } from "@tauri-apps/plugin-opener"; 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; @@ -19,8 +22,8 @@ type MarkdownProps = { codeBlockCopyUseModifier?: boolean; showFilePath?: boolean; workspacePath?: string | null; - onOpenFileLink?: (path: string) => void; - onOpenFileLinkMenu?: (event: React.MouseEvent, path: string) => void; + onOpenFileLink?: (path: ParsedFileLocation) => void; + onOpenFileLinkMenu?: (event: React.MouseEvent, path: ParsedFileLocation) => void; onOpenThreadLink?: (threadId: string) => void; }; @@ -47,103 +50,6 @@ type LinkBlockProps = { urls: string[]; }; -type ParsedFileReference = { - fullPath: string; - fileName: string; - lineLabel: string | null; - parentPath: string | null; -}; - -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; @@ -181,241 +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 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/", - "/tmp/", - "/var/", - "/opt/", - "/etc/", - "/private/", - "/Volumes/", - "/mnt/", - "/usr/", - "/workspace/", - "/workspaces/", - "/root/", - "/srv/", - "/data/", -]; -const WORKSPACE_ROUTE_PREFIXES = ["/workspace/", "/workspaces/"]; -const LOCAL_WORKSPACE_ROUTE_SEGMENTS = new Set(["reviews", "settings"]); - -function stripPathLineSuffix(value: string) { - return value.replace(FILE_LINE_SUFFIX_PATTERN, ""); -} - -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 isKnownLocalWorkspaceRoutePath(path: string) { - const mountedPath = splitWorkspaceRoutePath(path); - if (!mountedPath || mountedPath.segments.length === 0) { - return false; - } - - const routeSegment = - mountedPath.prefix === "/workspace/" - ? mountedPath.segments[0] - : mountedPath.segments[1]; - return Boolean(routeSegment) && LOCAL_WORKSPACE_ROUTE_SEGMENTS.has(routeSegment); -} - -function isLikelyMountedWorkspaceFilePath( - path: string, - workspacePath?: string | null, -) { - if (isKnownLocalWorkspaceRoutePath(path)) { - 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 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, -) { - const trimmed = url.trim(); - if (!trimmed) { - return false; - } - if (trimmed.startsWith("file://")) { - return true; - } - if ( - trimmed.startsWith("http://") || - trimmed.startsWith("https://") || - trimmed.startsWith("mailto:") - ) { - return false; - } - if (trimmed.startsWith("thread://") || trimmed.startsWith("/thread/")) { - return false; - } - if (trimmed.startsWith("#")) { - return false; - } - if (/[?#]/.test(trimmed)) { - return false; - } - if (/^[A-Za-z]:[\\/]/.test(trimmed) || trimmed.startsWith("\\\\")) { - return true; - } - if (trimmed.startsWith("/")) { - if (FILE_LINE_SUFFIX_PATTERN.test(trimmed)) { - return true; - } - if (hasLikelyFileName(trimmed)) { - return true; - } - return usesAbsolutePathDepthFallback(trimmed, workspacePath); - } - if (FILE_LINE_SUFFIX_PATTERN.test(trimmed)) { - return true; - } - if (trimmed.startsWith("~/")) { - return true; - } - if (trimmed.startsWith("./") || trimmed.startsWith("../")) { - return FILE_LINE_SUFFIX_PATTERN.test(trimmed) || hasLikelyFileName(trimmed); - } - if (hasLikelyFileName(trimmed)) { - return pathSegmentCount(trimmed) >= 3; - } - return false; -} - -function toPathFromFileUrl(url: string) { - if (!url.toLowerCase().startsWith("file://")) { - return null; - } - - try { - const parsed = new URL(url); - if (parsed.protocol !== "file:") { - return null; - } - - const decodedPath = safeDecodeURIComponent(parsed.pathname) ?? parsed.pathname; - let path = decodedPath; - if (parsed.host && parsed.host !== "localhost") { - const normalizedPath = decodedPath.startsWith("/") - ? decodedPath - : `/${decodedPath}`; - path = `//${parsed.host}${normalizedPath}`; - } - if (/^\/[A-Za-z]:\//.test(path)) { - path = path.slice(1); - } - return path; - } catch { - const manualPath = url.slice("file://".length).trim(); - if (!manualPath) { - return null; - } - return safeDecodeURIComponent(manualPath) ?? manualPath; - } -} function extractUrlLines(value: string) { const lines = value.split(/\r?\n/); @@ -528,32 +199,6 @@ function LinkBlock({ urls }: LinkBlockProps) { ); } -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 displayPath = relativeDisplayPath(pathWithoutLine, workspacePath); - const normalizedPath = trimTrailingPathSeparators(displayPath) || displayPath; - const lastSlashIndex = normalizedPath.lastIndexOf("/"); - const fallbackFile = normalizedPath || trimmed; - 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: trimmed, - fileName, - lineLabel, - parentPath, - }; -} - function FileReferenceLink({ href, rawPath, @@ -563,16 +208,13 @@ function FileReferenceLink({ onContextMenu, }: { href: string; - rawPath: string; + rawPath: ParsedFileLocation; showFilePath: boolean; workspacePath?: string | null; - onClick: (event: React.MouseEvent, path: string) => void; - onContextMenu: (event: React.MouseEvent, path: string) => 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); @@ -696,63 +338,24 @@ export function Markdown({ }; const handleFileLinkContextMenu = ( event: React.MouseEvent, - path: string, + path: ParsedFileLocation, ) => { event.preventDefault(); event.stopPropagation(); onOpenFileLinkMenu?.(event, path); }; - const filePathWithOptionalLineMatch = /^(.+?)(:\d+(?::\d+)?)?$/; - const getLinkablePath = (rawValue: string) => { - const trimmed = rawValue.trim(); - if (!trimmed) { - return null; - } - const match = trimmed.match(filePathWithOptionalLineMatch); - const pathOnly = match?.[1]?.trim() ?? trimmed; - if (!pathOnly || !isLinkableFilePath(pathOnly)) { - return null; - } - return trimmed; - }; + const resolvedHrefFilePathCache = new Map(); const resolveHrefFilePath = (url: string) => { - const hashAnchorPath = toPathFromFileHashAnchor(url, workspacePath); - if (hashAnchorPath) { - const anchoredPath = getLinkablePath(hashAnchorPath); - if (anchoredPath) { - return safeDecodeURIComponent(anchoredPath) ?? anchoredPath; - } - } - if (isLikelyFileHref(url, workspacePath)) { - const directPath = getLinkablePath(url); - if (directPath) { - return safeDecodeURIComponent(directPath) ?? directPath; - } + if (resolvedHrefFilePathCache.has(url)) { + return resolvedHrefFilePathCache.get(url) ?? null; } - const decodedUrl = safeDecodeURIComponent(url); - if (decodedUrl) { - const decodedHashAnchorPath = toPathFromFileHashAnchor( - decodedUrl, - workspacePath, - ); - if (decodedHashAnchorPath) { - const anchoredPath = getLinkablePath(decodedHashAnchorPath); - if (anchoredPath) { - return anchoredPath; - } - } - } - if (decodedUrl && isLikelyFileHref(decodedUrl, workspacePath)) { - const decodedPath = getLinkablePath(decodedUrl); - if (decodedPath) { - return decodedPath; - } - } - const fileUrlPath = toPathFromFileUrl(url); - if (!fileUrlPath) { + const resolvedPath = resolveMessageFileHref(url, workspacePath); + if (!resolvedPath) { + resolvedHrefFilePathCache.set(url, null); return null; } - return getLinkablePath(fileUrlPath); + resolvedHrefFilePathCache.set(url, resolvedPath); + return resolvedPath; }; const components: Components = { a: ({ href, children }) => { @@ -777,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 @@ -812,6 +416,7 @@ export function Markdown({ return ( @@ -853,15 +458,15 @@ export function Markdown({ 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 ( { const hasScheme = /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(url); + // Keep file-like hrefs intact before scheme sanitization runs, otherwise + // Windows absolute paths such as C:/repo/file.ts look like unknown schemes. + if (resolveHrefFilePath(url)) { + return url; + } if ( isFileLinkUrl(url) || url.startsWith("http://") || diff --git a/src/features/messages/components/MessageRows.tsx b/src/features/messages/components/MessageRows.tsx index 2bb0713aa..7692da916 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 { ParsedFileLocation } 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: 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 ea2f606a9..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( @@ -271,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, ); }); @@ -300,7 +303,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 +333,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 +358,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 +385,41 @@ describe("Messages", () => { ); fireEvent.click(screen.getByText("this file")); - expect(openFileLinkMock).toHaveBeenCalledWith( - "/Users/dimillian/Documents/Dev/CodexMonitor/src/features/messages/components/Markdown.tsx:244", + expectOpenedFileTarget( + openFileLinkMock, + "/Users/dimillian/Documents/Dev/CodexMonitor/src/features/messages/components/Markdown.tsx", + 244, + ); + }); + + it("routes Windows absolute href file paths with #L anchors through the file opener", () => { + const linkedPath = + "I:\\gpt-projects\\CodexMonitor\\src\\features\\settings\\components\\sections\\SettingsDisplaySection.tsx#L422"; + const items: ConversationItem[] = [ + { + id: "msg-file-href-windows-anchor-link", + kind: "message", + role: "assistant", + text: `Open [settings display](${linkedPath})`, + }, + ]; + + render( + , + ); + + fireEvent.click(screen.getByText("settings display")); + expectOpenedFileTarget( + openFileLinkMock, + "I:\\gpt-projects\\CodexMonitor\\src\\features\\settings\\components\\sections\\SettingsDisplaySection.tsx", + 422, ); }); @@ -406,7 +446,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", () => { @@ -603,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", () => { @@ -637,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 42b6816a9..5f76a6b75 100644 --- a/src/features/messages/hooks/useFileLinkOpener.test.tsx +++ b/src/features/messages/hooks/useFileLinkOpener.test.tsx @@ -2,8 +2,23 @@ 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 { + 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 +28,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,13 +54,69 @@ describe("useFileLinkOpener", () => { vi.clearAllMocks(); }); + async function copyLinkFor(rawPath: string) { + 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, + fileTarget(rawPath), + ); + }); + + const items = menuNewMock.mock.calls[0]?.[0]?.items ?? []; + const copyLinkItem = items.find( + (item: { text?: string; action?: () => Promise }) => item.text === "Copy Link", + ); + + await copyLinkItem?.action?.(); + return clipboardWriteTextMock.mock.calls[0]?.[0]; + } + + 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 () => { + 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 () => { + expect(await copyLinkFor("C:\\repo\\My File #100%.tsx:42")).toBe( + "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); 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( @@ -60,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( @@ -69,13 +140,28 @@ describe("useFileLinkOpener", () => { ); }); + it("maps extensionless files under /workspace/settings to the active workspace path", async () => { + const workspacePath = "/Users/sotiriskaniras/Documents/Development/Forks/settings"; + const openWorkspaceInMock = vi.mocked(openWorkspaceIn); + const { result } = renderHook(() => useFileLinkOpener(workspacePath, [], "")); + + await act(async () => { + await result.current.openFileLink(fileTarget("/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); 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( @@ -91,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"), ); }); @@ -112,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( @@ -125,6 +211,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); @@ -132,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 65ea91bdd..e18bab494 100644 --- a/src/features/messages/hooks/useFileLinkOpener.ts +++ b/src/features/messages/hooks/useFileLinkOpener.ts @@ -8,6 +8,11 @@ import * as Sentry from "@sentry/react"; import { openWorkspaceIn } from "../../../services/tauri"; import { pushErrorToast } from "../../../services/toasts"; import type { OpenAppTarget } from "../../../types"; +import { + type ParsedFileLocation, + formatFileLocation, + toFileUrl, +} from "../../../utils/fileLinks"; import { isAbsolutePath, joinWorkspacePath, @@ -46,6 +51,17 @@ const canOpenTarget = (target: OpenTarget) => { return Boolean(resolveAppName(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) { const trimmed = path.trim(); if (!workspacePath) { @@ -61,86 +77,21 @@ 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, - }; - } - } - +function resolveFileLinkContext( + fileLocation: ParsedFileLocation, + workspacePath?: string | null, +) { return { - path: trimmed, - line: null, - column: null, + fileLocation, + rawPathLabel: formatFileLocation( + fileLocation.path, + fileLocation.line, + fileLocation.column, + ), + resolvedPath: resolveFilePath(fileLocation.path, workspacePath), }; } -function toFileUrl(path: string, line: number | null, column: number | null) { - const base = path.startsWith("/") ? `file://${path}` : path; - if (line === null) { - return base; - } - return `${base}#L${line}${column !== null ? `C${column}` : ""}`; -} - export function useFileLinkOpener( workspacePath: string | null, openTargets: OpenAppTarget[], @@ -168,14 +119,12 @@ export function useFileLinkOpener( ); const openFileLink = useCallback( - async (rawPath: string) => { - const target = { - ...DEFAULT_OPEN_TARGET, - ...(openTargets.find((entry) => entry.id === selectedOpenAppId) ?? - openTargets[0]), - }; - const fileLocation = parseFileLocation(rawPath); - const resolvedPath = resolveFilePath(fileLocation.path, workspacePath); + async (targetLocation: ParsedFileLocation) => { + const target = resolveOpenTarget(openTargets, selectedOpenAppId); + const { fileLocation, rawPathLabel, resolvedPath } = resolveFileLinkContext( + targetLocation, + workspacePath, + ); const openLocation = { ...(fileLocation.line !== null ? { line: fileLocation.line } : {}), ...(fileLocation.column !== null ? { column: fileLocation.column } : {}), @@ -214,7 +163,7 @@ export function useFileLinkOpener( }); } catch (error) { reportOpenError(error, { - rawPath, + rawPath: rawPathLabel, resolvedPath, workspacePath, targetId: target.id, @@ -228,16 +177,14 @@ export function useFileLinkOpener( ); const showFileLinkMenu = useCallback( - async (event: MouseEvent, rawPath: string) => { + async (event: MouseEvent, targetLocation: ParsedFileLocation) => { event.preventDefault(); event.stopPropagation(); - const target = { - ...DEFAULT_OPEN_TARGET, - ...(openTargets.find((entry) => entry.id === selectedOpenAppId) ?? - openTargets[0]), - }; - const fileLocation = parseFileLocation(rawPath); - const resolvedPath = resolveFilePath(fileLocation.path, workspacePath); + const target = resolveOpenTarget(openTargets, selectedOpenAppId); + const { fileLocation, rawPathLabel, resolvedPath } = resolveFileLinkContext( + targetLocation, + workspacePath, + ); const appName = resolveAppName(target); const command = resolveCommand(target); const canOpen = canOpenTarget(target); @@ -256,7 +203,7 @@ export function useFileLinkOpener( text: openLabel, enabled: canOpen, action: async () => { - await openFileLink(rawPath); + await openFileLink(fileLocation); }, }), ...(target.kind === "finder" @@ -269,7 +216,7 @@ export function useFileLinkOpener( await revealItemInDir(resolvedPath); } catch (error) { reportOpenError(error, { - rawPath, + rawPath: rawPathLabel, resolvedPath, workspacePath, targetId: target.id, diff --git a/src/features/messages/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..fee560b26 --- /dev/null +++ b/src/features/messages/utils/messageFileLinks.ts @@ -0,0 +1,524 @@ +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 EMBEDDED_URL_SCHEME_PATTERN = /[a-zA-Z][a-zA-Z0-9+.-]*:\/\/\S*$/; +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) || + EMBEDDED_URL_SCHEME_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 15963fe8d..156499330 100644 --- a/src/features/messages/utils/mountedWorkspacePaths.ts +++ b/src/features/messages/utils/mountedWorkspacePaths.ts @@ -1,7 +1,9 @@ import { joinWorkspacePath } from "../../../utils/platformPaths"; - -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, "/"); @@ -23,6 +25,9 @@ export function resolveMountedWorkspacePath( workspacePath?: string | null, ) { const trimmed = path.trim(); + if (isKnownLocalWorkspaceRoutePath(trimmed)) { + return null; + } const trimmedWorkspace = workspacePath?.trim() ?? ""; if (!trimmedWorkspace) { return null; @@ -51,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..55ab18b60 --- /dev/null +++ b/src/features/messages/utils/remarkFileLinks.test.ts @@ -0,0 +1,76 @@ +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"]); + }); + + 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"]); + }); +}); 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 new file mode 100644 index 000000000..117e3e7d4 --- /dev/null +++ b/src/utils/fileLinks.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from "vitest"; +import { formatFileLocation, parseFileUrlLocation, toFileUrl } from "./fileLinks"; + +function withThrowingUrlConstructor(run: () => void) { + const originalUrl = globalThis.URL; + const throwingUrl = class { + constructor() { + throw new TypeError("Simulated URL constructor failure"); + } + } as unknown as typeof URL; + + Object.defineProperty(globalThis, "URL", { + configurable: true, + value: throwingUrl, + }); + + try { + run(); + } finally { + Object.defineProperty(globalThis, "URL", { + configurable: true, + value: originalUrl, + }); + } +} + +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", () => { + 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", () => { + 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", () => { + 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", () => { + 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(() => { + 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", + ); + }); + }); + + it("preserves UNC host info when the URL constructor fallback is used", () => { + withThrowingUrlConstructor(() => { + expectFileUrlLocation("file://server/share/100%.tsx#L12", + "//server/share/100%.tsx:12", + ); + }); + }); + + it("keeps encoded #L-like path segments when the URL constructor fallback is used", () => { + withThrowingUrlConstructor(() => { + 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"); + expectFileUrlLocation(fileUrl, "\\\\?\\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", + ); + expectFileUrlLocation(fileUrl, "\\\\?\\UNC\\server\\share\\repo\\App.tsx:12"); + }); +}); diff --git a/src/utils/fileLinks.ts b/src/utils/fileLinks.ts new file mode 100644 index 000000000..bf539a3f1 --- /dev/null +++ b/src/utils/fileLinks.ts @@ -0,0 +1,293 @@ +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; +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+)?))?"; + +function parsePositiveInteger(value?: string) { + if (!value) { + return null; + } + const parsed = Number.parseInt(value, 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : null; +} + +function decodeURIComponentSafely(value: string) { + try { + return decodeURIComponent(value); + } catch { + return value; + } +} + +function parseRecognizedFileUrlHash(hash: string) { + const match = hash.match(FILE_URL_LOCATION_HASH_PATTERN); + if (!match) { + return { + line: null, + column: null, + }; + } + + const [, lineValue, columnValue] = match; + const line = parsePositiveInteger(lineValue); + return { + line, + column: line === null ? null : parsePositiveInteger(columnValue), + }; +} + +function buildLocalPathFromFileUrl(host: string, pathname: string) { + const decodedPath = decodeURIComponentSafely(pathname); + if (/^\/(?:\\\\|\/\/)[?.][\\/]/.test(decodedPath)) { + return decodedPath.slice(1); + } + let path = decodedPath; + if (host && host !== "localhost") { + const normalizedPath = decodedPath.startsWith("/") ? decodedPath : `/${decodedPath}`; + path = `//${host}${normalizedPath}`; + } + if (/^\/[A-Za-z]:\//.test(path)) { + path = path.slice(1); + } + return path; +} + +function parseManualFileUrl(url: string) { + const manualPath = url.slice("file://".length).trim(); + if (!manualPath) { + return null; + } + + const hashIndex = manualPath.indexOf("#"); + const hash = hashIndex === -1 ? "" : manualPath.slice(hashIndex); + const pathWithHost = hashIndex === -1 ? manualPath : manualPath.slice(0, hashIndex); + if (!pathWithHost) { + return null; + } + + if (pathWithHost.startsWith("/")) { + return { + host: "", + pathname: pathWithHost, + hash, + }; + } + + const slashIndex = pathWithHost.indexOf("/"); + if (slashIndex === -1) { + if (/^[A-Za-z]:$/.test(pathWithHost)) { + return { + host: "", + pathname: `/${pathWithHost}`, + hash, + }; + } + return { + host: pathWithHost, + pathname: "", + hash, + }; + } + + const host = pathWithHost.slice(0, slashIndex); + const pathname = pathWithHost.slice(slashIndex); + if (/^[A-Za-z]:$/.test(host)) { + return { + host: "", + pathname: `/${host}${pathname}`, + hash, + }; + } + + return { + host, + pathname, + hash, + }; +} + +export function parseFileLocation(rawPath: string): ParsedFileLocation { + const trimmed = rawPath.trim(); + const hashMatch = trimmed.match(FILE_LOCATION_HASH_PATTERN); + if (hashMatch) { + const [, path, lineValue, columnValue] = hashMatch; + const line = parsePositiveInteger(lineValue); + if (line !== null) { + return { + path, + line, + column: parsePositiveInteger(columnValue), + }; + } + } + + const match = trimmed.match(FILE_LOCATION_SUFFIX_PATTERN); + if (match) { + const [, path, lineValue, columnValue] = match; + const line = parsePositiveInteger(lineValue); + if (line !== null) { + return { + path, + line, + column: parsePositiveInteger(columnValue), + }; + } + } + + const rangeMatch = trimmed.match(FILE_LOCATION_RANGE_SUFFIX_PATTERN); + if (rangeMatch) { + const [, path, startLineValue] = rangeMatch; + const startLine = parsePositiveInteger(startLineValue); + if (startLine !== null) { + return { + path, + line: startLine, + column: null, + }; + } + } + + return { + path: trimmed, + line: null, + column: null, + }; +} + +export function formatFileLocation( + path: string, + line: number | null, + column: number | null, +) { + if (line === null) { + return path.trim(); + } + return `${path.trim()}:${line}${column !== null ? `:${column}` : ""}`; +} + +export function normalizeFileLinkPath(rawPath: string) { + const parsed = parseFileLocation(rawPath); + return formatFileLocation(parsed.path, parsed.line, parsed.column); +} + +type FileUrlParts = { + host: string; + pathname: string; + treatPathnameAsOpaque?: boolean; +}; + +function encodeFileUrlPathname(pathname: string, treatPathnameAsOpaque = false) { + if (treatPathnameAsOpaque) { + return pathname + .split("/") + .map((segment) => encodeURIComponent(segment)) + .join("/"); + } + return pathname + .split("/") + .map((segment, index) => { + if (index === 1 && /^[A-Za-z]:$/.test(segment)) { + return segment; + } + return encodeURIComponent(segment); + }) + .join("/"); +} + +function toFileUrlParts(path: string): FileUrlParts | null { + const normalizedWindowsPath = path.replace(/\//g, "\\"); + const namespaceUncMatch = normalizedWindowsPath.match( + /^\\\\\?\\UNC\\([^\\]+)\\([^\\]+)(.*)$/i, + ); + if (namespaceUncMatch) { + return { + host: "", + pathname: `/${normalizedWindowsPath}`, + treatPathnameAsOpaque: true, + }; + } + + const namespaceDriveMatch = normalizedWindowsPath.match(/^\\\\\?\\([A-Za-z]:)(.*)$/); + if (namespaceDriveMatch) { + return { + host: "", + pathname: `/${normalizedWindowsPath}`, + treatPathnameAsOpaque: true, + }; + } + + const uncMatch = normalizedWindowsPath.match(/^\\\\([^\\]+)\\([^\\]+)(.*)$/); + if (uncMatch) { + const [, server, share, rest = ""] = uncMatch; + const normalizedRest = rest.replace(/\\/g, "/").replace(/^\/+/, ""); + return { + host: server, + pathname: `/${share}${normalizedRest ? `/${normalizedRest}` : ""}`, + }; + } + + if (/^[A-Za-z]:[\\/]/.test(path)) { + return { + host: "", + pathname: `/${path.replace(/\\/g, "/")}`, + }; + } + + if (path.startsWith("/")) { + return { + host: "", + pathname: path, + }; + } + + return null; +} + +export function toFileUrl(path: string, line: number | null, column: number | null) { + const parts = toFileUrlParts(path); + let base = path; + if (parts) { + base = `file://${parts.host}${encodeFileUrlPathname( + parts.pathname, + parts.treatPathnameAsOpaque, + )}`; + } + if (line === null) { + return base; + } + return `${base}#L${line}${column !== null ? `C${column}` : ""}`; +} + +export function parseFileUrlLocation(url: string): ParsedFileLocation | null { + if (!url.toLowerCase().startsWith("file://")) { + return null; + } + + try { + const parsed = new URL(url); + if (parsed.protocol !== "file:") { + return null; + } + + const path = buildLocalPathFromFileUrl(parsed.host, parsed.pathname); + const { line, column } = parseRecognizedFileUrlHash(parsed.hash); + return { path, line, column }; + } catch { + const manualParts = parseManualFileUrl(url); + if (!manualParts) { + return null; + } + const path = buildLocalPathFromFileUrl(manualParts.host, manualParts.pathname); + const { line, column } = parseRecognizedFileUrlHash(manualParts.hash); + return { path, line, column }; + } +} diff --git a/src/utils/remarkFileLinks.ts b/src/utils/remarkFileLinks.ts index 4824452fa..9b25c3257 100644 --- a/src/utils/remarkFileLinks.ts +++ b/src/utils/remarkFileLinks.ts @@ -1,157 +1,8 @@ -const FILE_LINK_PROTOCOL = "codex-file:"; -const FILE_LINE_SUFFIX_PATTERN = "(?::\\d+(?::\\d+)?)?"; - -const FILE_PATH_PATTERN = - new RegExp( - `(\\/[^\\s\\\`"'<>]+|~\\/[^\\s\\\`"'<>]+|\\.{1,2}\\/[^\\s\\\`"'<>]+|[A-Za-z0-9._-]+(?:\\/[A-Za-z0-9._-]+)+)${FILE_LINE_SUFFIX_PATTERN}`, - "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; - -type MarkdownNode = { - type: string; - value?: string; - url?: string; - children?: MarkdownNode[]; -}; - -function isPathCandidate( - value: string, - leadingContext: string, - previousChar: string, -) { - if (!value.includes("/")) { - return false; - } - if (value.startsWith("//")) { - return false; - } - if (leadingContext.endsWith("://")) { - 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 leadingContext = value.slice(Math.max(0, matchIndex - 3), matchIndex); - const previousChar = matchIndex > 0 ? value[matchIndex - 1] : ""; - const { path, trailing } = splitTrailingPunctuation(raw); - if (path && isPathCandidate(path, leadingContext, previousChar)) { - nodes.push({ - type: "link", - url: toFileLink(path), - 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 (!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";