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";