From fe1e95818c38426a675cef26479e89598e3b5f4e Mon Sep 17 00:00:00 2001 From: Samigos Date: Wed, 25 Feb 2026 15:19:07 +0200 Subject: [PATCH 1/7] fix(messages): harden file link routing in markdown --- src/features/messages/components/Markdown.tsx | 179 +++++++++++++++++- .../messages/components/Messages.test.tsx | 79 ++++++++ 2 files changed, 256 insertions(+), 2 deletions(-) diff --git a/src/features/messages/components/Markdown.tsx b/src/features/messages/components/Markdown.tsx index ec450531d..37e74ebbd 100644 --- a/src/features/messages/components/Markdown.tsx +++ b/src/features/messages/components/Markdown.tsx @@ -180,6 +180,136 @@ 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 LIKELY_LOCAL_ABSOLUTE_PATH_PREFIXES = [ + "/Users/", + "/home/", + "/tmp/", + "/var/", + "/opt/", + "/etc/", + "/private/", + "/Volumes/", + "/mnt/", +]; + +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 isLikelyFileHref(url: string) { + 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("/")) { + return hasLikelyLocalAbsolutePrefix(trimmed); + } + if (FILE_LINE_SUFFIX_PATTERN.test(trimmed)) { + return true; + } + if (hasLikelyFileName(trimmed)) { + return true; + } + if ( + trimmed.startsWith("~/") || + trimmed.startsWith("./") || + trimmed.startsWith("../") + ) { + return true; + } + return false; +} + +function toPathFromFileUrl(url: string) { + if (!url.toLowerCase().startsWith("file://")) { + return null; + } + + try { + const parsed = new URL(url); + if (parsed.protocol !== "file:") { + return null; + } + + const decodedPath = safeDecodeURIComponent(parsed.pathname) ?? parsed.pathname; + let path = decodedPath; + if (parsed.host && parsed.host !== "localhost") { + const normalizedPath = decodedPath.startsWith("/") + ? decodedPath + : `/${decodedPath}`; + path = `//${parsed.host}${normalizedPath}`; + } + if (/^\/[A-Za-z]:\//.test(path)) { + path = path.slice(1); + } + return path; + } catch { + const manualPath = url.slice("file://".length).trim(); + if (!manualPath) { + return null; + } + return safeDecodeURIComponent(manualPath) ?? manualPath; + } +} + function extractUrlLines(value: string) { const lines = value.split(/\r?\n/); const urls = lines @@ -474,9 +604,29 @@ export function Markdown({ } return trimmed; }; + const resolveHrefFilePath = (url: string) => { + if (isLikelyFileHref(url)) { + const directPath = getLinkablePath(url); + if (directPath) { + return directPath; + } + } + const decodedUrl = safeDecodeURIComponent(url); + if (decodedUrl && isLikelyFileHref(decodedUrl)) { + const decodedPath = getLinkablePath(decodedUrl); + if (decodedPath) { + return decodedPath; + } + } + const fileUrlPath = toPathFromFileUrl(url); + if (!fileUrlPath) { + return null; + } + return getLinkablePath(fileUrlPath); + }; const components: Components = { a: ({ href, children }) => { - const url = href ?? ""; + const url = (href ?? "").trim(); const threadId = url.startsWith("thread://") ? url.slice("thread://".length).trim() : url.startsWith("/thread/") @@ -497,7 +647,20 @@ export function Markdown({ ); } if (isFileLinkUrl(url)) { - const path = decodeFileLink(url); + const path = safeDecodeFileLink(url); + if (!path) { + return ( + { + event.preventDefault(); + event.stopPropagation(); + }} + > + {children} + + ); + } return ( ); } + const hrefFilePath = resolveHrefFilePath(url); + if (hrefFilePath) { + return ( + handleFileLinkClick(event, hrefFilePath)} + onContextMenu={(event) => handleFileLinkContextMenu(event, hrefFilePath)} + > + {children} + + ); + } const isExternal = url.startsWith("http://") || url.startsWith("https://") || diff --git a/src/features/messages/components/Messages.test.tsx b/src/features/messages/components/Messages.test.tsx index efd3990d2..5ab716547 100644 --- a/src/features/messages/components/Messages.test.tsx +++ b/src/features/messages/components/Messages.test.tsx @@ -234,6 +234,85 @@ describe("Messages", () => { ); }); + it("routes markdown href file paths through the file opener", () => { + const linkedPath = + "/Users/dimillian/Documents/Dev/CodexMonitor/src/features/messages/components/Markdown.tsx:244"; + const items: ConversationItem[] = [ + { + id: "msg-file-href-link", + kind: "message", + role: "assistant", + text: `Open [this file](${linkedPath})`, + }, + ]; + + render( + , + ); + + fireEvent.click(screen.getByText("this file")); + expect(openFileLinkMock).toHaveBeenCalledWith(linkedPath); + }); + + it("keeps non-file relative links as normal markdown links", () => { + const items: ConversationItem[] = [ + { + id: "msg-help-href-link", + kind: "message", + role: "assistant", + text: "See [Help](/help/getting-started)", + }, + ]; + + render( + , + ); + + const helpLink = screen.getByText("Help").closest("a"); + expect(helpLink?.getAttribute("href")).toBe("/help/getting-started"); + fireEvent.click(screen.getByText("Help")); + expect(openFileLinkMock).not.toHaveBeenCalled(); + }); + + it("does not crash or navigate on malformed codex-file links", () => { + const items: ConversationItem[] = [ + { + id: "msg-malformed-file-link", + kind: "message", + role: "assistant", + text: "Bad [path](codex-file:%E0%A4%A)", + }, + ]; + + render( + , + ); + + fireEvent.click(screen.getByText("path")); + expect(openFileLinkMock).not.toHaveBeenCalled(); + }); + it("hides file parent paths when message file path display is disabled", () => { const items: ConversationItem[] = [ { From e3f23ed161032d925b9aa7023dbedc14979dc933 Mon Sep 17 00:00:00 2001 From: Samigos Date: Wed, 25 Feb 2026 15:49:34 +0200 Subject: [PATCH 2/7] fix(messages): handle encoded href paths and tighten relative link detection --- src/features/messages/components/Markdown.tsx | 11 ++-- .../messages/components/Messages.test.tsx | 52 +++++++++++++++++++ 2 files changed, 57 insertions(+), 6 deletions(-) diff --git a/src/features/messages/components/Markdown.tsx b/src/features/messages/components/Markdown.tsx index 37e74ebbd..9b183d27f 100644 --- a/src/features/messages/components/Markdown.tsx +++ b/src/features/messages/components/Markdown.tsx @@ -268,13 +268,12 @@ function isLikelyFileHref(url: string) { if (hasLikelyFileName(trimmed)) { return true; } - if ( - trimmed.startsWith("~/") || - trimmed.startsWith("./") || - trimmed.startsWith("../") - ) { + if (trimmed.startsWith("~/")) { return true; } + if (trimmed.startsWith("./") || trimmed.startsWith("../")) { + return FILE_LINE_SUFFIX_PATTERN.test(trimmed) || hasLikelyFileName(trimmed); + } return false; } @@ -608,7 +607,7 @@ export function Markdown({ if (isLikelyFileHref(url)) { const directPath = getLinkablePath(url); if (directPath) { - return directPath; + return safeDecodeURIComponent(directPath) ?? directPath; } } const decodedUrl = safeDecodeURIComponent(url); diff --git a/src/features/messages/components/Messages.test.tsx b/src/features/messages/components/Messages.test.tsx index 5ab716547..ca19e6ed8 100644 --- a/src/features/messages/components/Messages.test.tsx +++ b/src/features/messages/components/Messages.test.tsx @@ -261,6 +261,31 @@ describe("Messages", () => { expect(openFileLinkMock).toHaveBeenCalledWith(linkedPath); }); + it("decodes percent-encoded href file paths before opening", () => { + const items: ConversationItem[] = [ + { + id: "msg-file-href-encoded-link", + kind: "message", + role: "assistant", + text: "Open [guide](./docs/My%20Guide.md)", + }, + ]; + + render( + , + ); + + fireEvent.click(screen.getByText("guide")); + expect(openFileLinkMock).toHaveBeenCalledWith("./docs/My Guide.md"); + }); + it("keeps non-file relative links as normal markdown links", () => { const items: ConversationItem[] = [ { @@ -288,6 +313,33 @@ describe("Messages", () => { expect(openFileLinkMock).not.toHaveBeenCalled(); }); + it("keeps dot-relative non-file links as normal markdown links", () => { + const items: ConversationItem[] = [ + { + id: "msg-help-dot-relative-href-link", + kind: "message", + role: "assistant", + text: "See [Help](./help/getting-started)", + }, + ]; + + render( + , + ); + + const helpLink = screen.getByText("Help").closest("a"); + expect(helpLink?.getAttribute("href")).toBe("./help/getting-started"); + fireEvent.click(screen.getByText("Help")); + expect(openFileLinkMock).not.toHaveBeenCalled(); + }); + it("does not crash or navigate on malformed codex-file links", () => { const items: ConversationItem[] = [ { From c216c249ab58d939e7ad0197975d71b0d692e862 Mon Sep 17 00:00:00 2001 From: Samigos Date: Wed, 25 Feb 2026 20:16:18 +0200 Subject: [PATCH 3/7] fix(messages): tighten markdown file-href routing and fallback behavior --- .../messages/components/Markdown.test.tsx | 72 +++++++++++++++++++ src/features/messages/components/Markdown.tsx | 34 +++++++-- .../messages/components/Messages.test.tsx | 53 ++++++++++++++ 3 files changed, 153 insertions(+), 6 deletions(-) create mode 100644 src/features/messages/components/Markdown.test.tsx diff --git a/src/features/messages/components/Markdown.test.tsx b/src/features/messages/components/Markdown.test.tsx new file mode 100644 index 000000000..b96f29419 --- /dev/null +++ b/src/features/messages/components/Markdown.test.tsx @@ -0,0 +1,72 @@ +// @vitest-environment jsdom +import { cleanup, createEvent, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { Markdown } from "./Markdown"; + +describe("Markdown file-like href behavior", () => { + afterEach(() => { + cleanup(); + }); + + it("preserves default anchor navigation when no file opener is provided", () => { + render( + , + ); + + const link = screen.getByText("setup").closest("a"); + expect(link?.getAttribute("href")).toBe("./docs/setup.md"); + + const clickEvent = createEvent.click(link as Element, { + bubbles: true, + cancelable: true, + }); + fireEvent(link as Element, clickEvent); + expect(clickEvent.defaultPrevented).toBe(false); + }); + + it("intercepts file-like href clicks when a file opener is provided", () => { + const onOpenFileLink = vi.fn(); + render( + , + ); + + const link = screen.getByText("setup").closest("a"); + expect(link?.getAttribute("href")).toBe("./docs/setup.md"); + + const clickEvent = createEvent.click(link as Element, { + bubbles: true, + cancelable: true, + }); + fireEvent(link as Element, clickEvent); + expect(clickEvent.defaultPrevented).toBe(true); + expect(onOpenFileLink).toHaveBeenCalledWith("./docs/setup.md"); + }); + + it("does not intercept bare relative links even when a file opener is provided", () => { + const onOpenFileLink = vi.fn(); + render( + , + ); + + const link = screen.getByText("setup").closest("a"); + expect(link?.getAttribute("href")).toBe("docs/setup.md"); + const clickEvent = createEvent.click(link as Element, { + bubbles: true, + cancelable: true, + }); + fireEvent(link as Element, clickEvent); + expect(clickEvent.defaultPrevented).toBe(false); + expect(onOpenFileLink).not.toHaveBeenCalled(); + }); +}); diff --git a/src/features/messages/components/Markdown.tsx b/src/features/messages/components/Markdown.tsx index 9b183d27f..91bb49e15 100644 --- a/src/features/messages/components/Markdown.tsx +++ b/src/features/messages/components/Markdown.tsx @@ -207,6 +207,12 @@ const LIKELY_LOCAL_ABSOLUTE_PATH_PREFIXES = [ "/private/", "/Volumes/", "/mnt/", + "/usr/", + "/workspace/", + "/workspaces/", + "/root/", + "/srv/", + "/data/", ]; function stripPathLineSuffix(value: string) { @@ -232,6 +238,10 @@ function hasLikelyLocalAbsolutePrefix(path: string) { ); } +function pathSegmentCount(path: string) { + return path.split("/").filter(Boolean).length; +} + function isLikelyFileHref(url: string) { const trimmed = url.trim(); if (!trimmed) { @@ -260,20 +270,26 @@ function isLikelyFileHref(url: string) { return true; } if (trimmed.startsWith("/")) { - return hasLikelyLocalAbsolutePrefix(trimmed); + if (FILE_LINE_SUFFIX_PATTERN.test(trimmed)) { + return true; + } + if (hasLikelyFileName(trimmed)) { + return true; + } + return hasLikelyLocalAbsolutePrefix(trimmed) && pathSegmentCount(trimmed) >= 3; } if (FILE_LINE_SUFFIX_PATTERN.test(trimmed)) { return true; } - if (hasLikelyFileName(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; } @@ -673,11 +689,17 @@ export function Markdown({ } const hrefFilePath = resolveHrefFilePath(url); if (hrefFilePath) { + const clickHandler = onOpenFileLink + ? (event: React.MouseEvent) => handleFileLinkClick(event, hrefFilePath) + : undefined; + const contextMenuHandler = onOpenFileLinkMenu + ? (event: React.MouseEvent) => handleFileLinkContextMenu(event, hrefFilePath) + : undefined; return ( handleFileLinkClick(event, hrefFilePath)} - onContextMenu={(event) => handleFileLinkContextMenu(event, hrefFilePath)} + onClick={clickHandler} + onContextMenu={contextMenuHandler} > {children} diff --git a/src/features/messages/components/Messages.test.tsx b/src/features/messages/components/Messages.test.tsx index ca19e6ed8..14cd95398 100644 --- a/src/features/messages/components/Messages.test.tsx +++ b/src/features/messages/components/Messages.test.tsx @@ -261,6 +261,32 @@ describe("Messages", () => { expect(openFileLinkMock).toHaveBeenCalledWith(linkedPath); }); + it("routes absolute non-whitelisted file href paths through the file opener", () => { + const linkedPath = "/custom/project/src/App.tsx:12"; + const items: ConversationItem[] = [ + { + id: "msg-file-href-absolute-non-whitelisted-link", + kind: "message", + role: "assistant", + text: `Open [app file](${linkedPath})`, + }, + ]; + + render( + , + ); + + fireEvent.click(screen.getByText("app file")); + expect(openFileLinkMock).toHaveBeenCalledWith(linkedPath); + }); + it("decodes percent-encoded href file paths before opening", () => { const items: ConversationItem[] = [ { @@ -313,6 +339,33 @@ describe("Messages", () => { expect(openFileLinkMock).not.toHaveBeenCalled(); }); + it("keeps route-like absolute links as normal markdown links", () => { + const items: ConversationItem[] = [ + { + id: "msg-help-workspace-route-link", + kind: "message", + role: "assistant", + text: "See [Workspace Home](/workspace/settings)", + }, + ]; + + render( + , + ); + + const link = screen.getByText("Workspace Home").closest("a"); + expect(link?.getAttribute("href")).toBe("/workspace/settings"); + fireEvent.click(screen.getByText("Workspace Home")); + expect(openFileLinkMock).not.toHaveBeenCalled(); + }); + it("keeps dot-relative non-file links as normal markdown links", () => { const items: ConversationItem[] = [ { From 241161ebd5d36be7cfa4df139a4fe13da63c7b2e Mon Sep 17 00:00:00 2001 From: Samigos Date: Sat, 28 Feb 2026 23:46:52 +0200 Subject: [PATCH 4/7] fix: normalize markdown local and file link handling --- .../messages/components/Markdown.test.tsx | 74 ++++++++++++++++++- src/features/messages/components/Markdown.tsx | 65 ++++++++++++++-- .../messages/components/Messages.test.tsx | 56 ++++++++++++++ 3 files changed, 186 insertions(+), 9 deletions(-) diff --git a/src/features/messages/components/Markdown.test.tsx b/src/features/messages/components/Markdown.test.tsx index b96f29419..eb0e76818 100644 --- a/src/features/messages/components/Markdown.test.tsx +++ b/src/features/messages/components/Markdown.test.tsx @@ -8,7 +8,7 @@ describe("Markdown file-like href behavior", () => { cleanup(); }); - it("preserves default anchor navigation when no file opener is provided", () => { + it("prevents file-like href navigation when no file opener is provided", () => { render( { cancelable: true, }); fireEvent(link as Element, clickEvent); - expect(clickEvent.defaultPrevented).toBe(false); + expect(clickEvent.defaultPrevented).toBe(true); }); it("intercepts file-like href clicks when a file opener is provided", () => { @@ -49,7 +49,7 @@ describe("Markdown file-like href behavior", () => { expect(onOpenFileLink).toHaveBeenCalledWith("./docs/setup.md"); }); - it("does not intercept bare relative links even when a file opener is provided", () => { + it("prevents bare relative link navigation without treating it as a file", () => { const onOpenFileLink = vi.fn(); render( { cancelable: true, }); fireEvent(link as Element, clickEvent); - expect(clickEvent.defaultPrevented).toBe(false); + expect(clickEvent.defaultPrevented).toBe(true); + expect(onOpenFileLink).not.toHaveBeenCalled(); + }); + + it("still intercepts explicit workspace file hrefs when a file opener is provided", () => { + const onOpenFileLink = vi.fn(); + render( + , + ); + + const link = screen.getByText("example").closest("a"); + expect(link?.getAttribute("href")).toBe("/workspace/src/example.ts"); + + const clickEvent = createEvent.click(link as Element, { + bubbles: true, + cancelable: true, + }); + fireEvent(link as Element, clickEvent); + expect(clickEvent.defaultPrevented).toBe(true); + expect(onOpenFileLink).toHaveBeenCalledWith("/workspace/src/example.ts"); + }); + + it("intercepts file hrefs that use #L line anchors", () => { + const onOpenFileLink = vi.fn(); + render( + , + ); + + const link = screen.getByText("markdown").closest("a"); + expect(link?.getAttribute("href")).toBe("./docs/setup.md#L12"); + + const clickEvent = createEvent.click(link as Element, { + bubbles: true, + cancelable: true, + }); + fireEvent(link as Element, clickEvent); + expect(clickEvent.defaultPrevented).toBe(true); + expect(onOpenFileLink).toHaveBeenCalledWith("./docs/setup.md:12"); + }); + + it("prevents unsupported route fragments without treating them as file links", () => { + const onOpenFileLink = vi.fn(); + render( + , + ); + + const link = screen.getByText("profile").closest("a"); + expect(link?.getAttribute("href")).toBe("/workspace/settings/profile#details"); + + 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 91bb49e15..b36d4b248 100644 --- a/src/features/messages/components/Markdown.tsx +++ b/src/features/messages/components/Markdown.tsx @@ -197,6 +197,7 @@ function safeDecodeFileLink(url: string) { } 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/", @@ -214,6 +215,7 @@ const LIKELY_LOCAL_ABSOLUTE_PATH_PREFIXES = [ "/srv/", "/data/", ]; +const ROUTE_LIKE_ABSOLUTE_PATH_PREFIXES = ["/workspace/", "/workspaces/"]; function stripPathLineSuffix(value: string) { return value.replace(FILE_LINE_SUFFIX_PATTERN, ""); @@ -238,10 +240,37 @@ function hasLikelyLocalAbsolutePrefix(path: string) { ); } +function usesAbsolutePathDepthFallback(path: string) { + const normalizedPath = path.replace(/\\/g, "/"); + if ( + ROUTE_LIKE_ABSOLUTE_PATH_PREFIXES.some((prefix) => + normalizedPath.startsWith(prefix), + ) + ) { + return false; + } + return hasLikelyLocalAbsolutePrefix(normalizedPath) && pathSegmentCount(normalizedPath) >= 3; +} + function pathSegmentCount(path: string) { return path.split("/").filter(Boolean).length; } +function toPathFromFileHashAnchor(url: string) { + 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)) { + return null; + } + const [, line, column] = match; + return `${basePath}:${line}${column ? `:${column}` : ""}`; +} + function isLikelyFileHref(url: string) { const trimmed = url.trim(); if (!trimmed) { @@ -276,7 +305,7 @@ function isLikelyFileHref(url: string) { if (hasLikelyFileName(trimmed)) { return true; } - return hasLikelyLocalAbsolutePrefix(trimmed) && pathSegmentCount(trimmed) >= 3; + return usesAbsolutePathDepthFallback(trimmed); } if (FILE_LINE_SUFFIX_PATTERN.test(trimmed)) { return true; @@ -598,6 +627,10 @@ export function Markdown({ event.stopPropagation(); onOpenFileLink?.(path); }; + const handleLocalLinkClick = (event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + }; const handleFileLinkContextMenu = ( event: React.MouseEvent, path: string, @@ -620,6 +653,13 @@ export function Markdown({ return trimmed; }; const resolveHrefFilePath = (url: string) => { + const hashAnchorPath = toPathFromFileHashAnchor(url); + if (hashAnchorPath) { + const anchoredPath = getLinkablePath(hashAnchorPath); + if (anchoredPath) { + return safeDecodeURIComponent(anchoredPath) ?? anchoredPath; + } + } if (isLikelyFileHref(url)) { const directPath = getLinkablePath(url); if (directPath) { @@ -627,6 +667,15 @@ export function Markdown({ } } const decodedUrl = safeDecodeURIComponent(url); + if (decodedUrl) { + const decodedHashAnchorPath = toPathFromFileHashAnchor(decodedUrl); + if (decodedHashAnchorPath) { + const anchoredPath = getLinkablePath(decodedHashAnchorPath); + if (anchoredPath) { + return anchoredPath; + } + } + } if (decodedUrl && isLikelyFileHref(decodedUrl)) { const decodedPath = getLinkablePath(decodedUrl); if (decodedPath) { @@ -689,9 +738,8 @@ export function Markdown({ } const hrefFilePath = resolveHrefFilePath(url); if (hrefFilePath) { - const clickHandler = onOpenFileLink - ? (event: React.MouseEvent) => handleFileLinkClick(event, hrefFilePath) - : undefined; + const clickHandler = (event: React.MouseEvent) => + handleFileLinkClick(event, hrefFilePath); const contextMenuHandler = onOpenFileLinkMenu ? (event: React.MouseEvent) => handleFileLinkContextMenu(event, hrefFilePath) : undefined; @@ -711,7 +759,14 @@ export function Markdown({ url.startsWith("mailto:"); if (!isExternal) { - return {children}; + if (url.startsWith("#")) { + return {children}; + } + return ( + + {children} + + ); } return ( diff --git a/src/features/messages/components/Messages.test.tsx b/src/features/messages/components/Messages.test.tsx index 14cd95398..f3afe0298 100644 --- a/src/features/messages/components/Messages.test.tsx +++ b/src/features/messages/components/Messages.test.tsx @@ -312,6 +312,35 @@ describe("Messages", () => { expect(openFileLinkMock).toHaveBeenCalledWith("./docs/My Guide.md"); }); + it("routes absolute href file paths with #L anchors through the file opener", () => { + const linkedPath = + "/Users/dimillian/Documents/Dev/CodexMonitor/src/features/messages/components/Markdown.tsx#L244"; + const items: ConversationItem[] = [ + { + id: "msg-file-href-anchor-link", + kind: "message", + role: "assistant", + text: `Open [this file](${linkedPath})`, + }, + ]; + + render( + , + ); + + fireEvent.click(screen.getByText("this file")); + expect(openFileLinkMock).toHaveBeenCalledWith( + "/Users/dimillian/Documents/Dev/CodexMonitor/src/features/messages/components/Markdown.tsx:244", + ); + }); + it("keeps non-file relative links as normal markdown links", () => { const items: ConversationItem[] = [ { @@ -366,6 +395,33 @@ describe("Messages", () => { expect(openFileLinkMock).not.toHaveBeenCalled(); }); + it("keeps deep workspace route links as normal markdown links", () => { + const items: ConversationItem[] = [ + { + id: "msg-help-workspace-route-link-deep", + kind: "message", + role: "assistant", + text: "See [Profile](/workspace/settings/profile)", + }, + ]; + + render( + , + ); + + const link = screen.getByText("Profile").closest("a"); + expect(link?.getAttribute("href")).toBe("/workspace/settings/profile"); + fireEvent.click(screen.getByText("Profile")); + expect(openFileLinkMock).not.toHaveBeenCalled(); + }); + it("keeps dot-relative non-file links as normal markdown links", () => { const items: ConversationItem[] = [ { From 43e60da1b85fb9e22739f4737b9680cfbf28bcc9 Mon Sep 17 00:00:00 2001 From: Samigos Date: Sun, 1 Mar 2026 21:34:56 +0200 Subject: [PATCH 5/7] feat: support line-aware workspace file link opens --- src-tauri/src/bin/codex_monitor_daemon.rs | 4 +- .../bin/codex_monitor_daemon/rpc/workspace.rs | 2 + src-tauri/src/shared/workspace_rpc.rs | 2 + src-tauri/src/shared/workspaces_core/io.rs | 299 ++++++++++++++++-- src-tauri/src/workspaces/commands.rs | 4 +- .../messages/components/Markdown.test.tsx | 22 ++ src/features/messages/components/Markdown.tsx | 18 +- .../messages/components/Messages.test.tsx | 26 ++ .../messages/hooks/useFileLinkOpener.test.tsx | 127 ++++++++ .../messages/hooks/useFileLinkOpener.ts | 155 ++++++++- src/services/tauri.test.ts | 23 ++ src/services/tauri.ts | 4 + 12 files changed, 648 insertions(+), 38 deletions(-) create mode 100644 src/features/messages/hooks/useFileLinkOpener.test.tsx diff --git a/src-tauri/src/bin/codex_monitor_daemon.rs b/src-tauri/src/bin/codex_monitor_daemon.rs index 3a2cd0ce3..a306f1ceb 100644 --- a/src-tauri/src/bin/codex_monitor_daemon.rs +++ b/src-tauri/src/bin/codex_monitor_daemon.rs @@ -980,8 +980,10 @@ impl DaemonState { app: Option, args: Vec, command: Option, + line: Option, + column: Option, ) -> Result<(), String> { - workspaces_core::open_workspace_in_core(path, app, args, command).await + workspaces_core::open_workspace_in_core(path, app, args, command, line, column).await } async fn get_open_app_icon(&self, app_name: String) -> Result, String> { diff --git a/src-tauri/src/bin/codex_monitor_daemon/rpc/workspace.rs b/src-tauri/src/bin/codex_monitor_daemon/rpc/workspace.rs index 093a6baf7..3f18527f7 100644 --- a/src-tauri/src/bin/codex_monitor_daemon/rpc/workspace.rs +++ b/src-tauri/src/bin/codex_monitor_daemon/rpc/workspace.rs @@ -253,6 +253,8 @@ pub(super) async fn try_handle( request.app, request.args, request.command, + request.line, + request.column, )) .await, ) diff --git a/src-tauri/src/shared/workspace_rpc.rs b/src-tauri/src/shared/workspace_rpc.rs index e1e9b5af1..85acbf46e 100644 --- a/src-tauri/src/shared/workspace_rpc.rs +++ b/src-tauri/src/shared/workspace_rpc.rs @@ -99,6 +99,8 @@ pub(crate) struct OpenWorkspaceInRequest { pub(crate) app: Option, pub(crate) args: Vec, pub(crate) command: Option, + pub(crate) line: Option, + pub(crate) column: Option, } #[derive(Debug, Serialize, Deserialize)] diff --git a/src-tauri/src/shared/workspaces_core/io.rs b/src-tauri/src/shared/workspaces_core/io.rs index 4445007c8..972bfb292 100644 --- a/src-tauri/src/shared/workspaces_core/io.rs +++ b/src-tauri/src/shared/workspaces_core/io.rs @@ -1,7 +1,6 @@ use std::collections::HashMap; -#[cfg(target_os = "windows")] -use std::path::Path; -use std::path::PathBuf; +use std::env; +use std::path::{Path, PathBuf}; use tokio::sync::Mutex; @@ -12,11 +11,138 @@ use crate::types::WorkspaceEntry; use super::helpers::resolve_workspace_root; +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum LineAwareLaunchStrategy { + GotoFlag, + PathWithLineColumn, +} + +fn normalize_open_location(line: Option, column: Option) -> Option<(u32, Option)> { + let line = line.filter(|value| *value > 0)?; + let column = column.filter(|value| *value > 0); + Some((line, column)) +} + +fn format_path_with_location(path: &str, line: u32, column: Option) -> String { + match column { + Some(column) => format!("{path}:{line}:{column}"), + None => format!("{path}:{line}"), + } +} + +fn command_identifier(command: &str) -> String { + let trimmed = command.trim(); + let file_name = Path::new(trimmed) + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or(trimmed); + let stem = Path::new(file_name) + .file_stem() + .and_then(|value| value.to_str()) + .unwrap_or(file_name); + stem.trim().to_ascii_lowercase() +} + +fn command_launch_strategy(command: &str) -> Option { + let identifier = command_identifier(command); + if identifier == "code" + || identifier == "code-insiders" + || identifier == "cursor" + || identifier == "cursor-insiders" + { + return Some(LineAwareLaunchStrategy::GotoFlag); + } + if identifier == "zed" || identifier == "zed-preview" { + return Some(LineAwareLaunchStrategy::PathWithLineColumn); + } + None +} + +fn app_launch_strategy(app: &str) -> Option { + let normalized = app.trim().to_ascii_lowercase(); + if normalized.contains("visual studio code") || normalized.starts_with("cursor") { + return Some(LineAwareLaunchStrategy::GotoFlag); + } + if normalized == "zed" || normalized.starts_with("zed ") { + return Some(LineAwareLaunchStrategy::PathWithLineColumn); + } + None +} + +fn app_cli_command(app: &str) -> Option<&'static str> { + let normalized = app.trim().to_ascii_lowercase(); + if normalized.contains("visual studio code insiders") { + return Some("code-insiders"); + } + if normalized.contains("visual studio code") { + return Some("code"); + } + if normalized.starts_with("cursor") { + return Some("cursor"); + } + if normalized == "zed" || normalized.starts_with("zed ") { + return Some("zed"); + } + None +} + +fn find_executable_in_path(program: &str) -> Option { + let trimmed = program.trim(); + if trimmed.is_empty() { + return None; + } + + let path = PathBuf::from(trimmed); + if path.is_file() { + return Some(path); + } + + let path_var = env::var_os("PATH")?; + for dir in env::split_paths(&path_var) { + let candidate = dir.join(trimmed); + if candidate.is_file() { + return Some(candidate); + } + } + + None +} + +fn build_launch_args( + path: &str, + args: &[String], + line: Option, + column: Option, + strategy: Option, +) -> Vec { + let mut launch_args = args.to_vec(); + if let Some((line, column)) = normalize_open_location(line, column) { + let located_path = format_path_with_location(path, line, column); + match strategy { + Some(LineAwareLaunchStrategy::GotoFlag) => { + launch_args.push("--goto".to_string()); + launch_args.push(located_path); + } + Some(LineAwareLaunchStrategy::PathWithLineColumn) => { + launch_args.push(located_path); + } + None => { + launch_args.push(path.to_string()); + } + } + return launch_args; + } + launch_args.push(path.to_string()); + launch_args +} + pub(crate) async fn open_workspace_in_core( path: String, app: Option, args: Vec, command: Option, + line: Option, + column: Option, ) -> Result<(), String> { fn output_snippet(bytes: &[u8]) -> Option { const MAX_CHARS: usize = 240; @@ -44,6 +170,13 @@ pub(crate) async fn open_workspace_in_core( if trimmed.is_empty() { return Err("Missing app or command".to_string()); } + let launch_args = build_launch_args( + &path, + &args, + line, + column, + command_launch_strategy(trimmed), + ); #[cfg(target_os = "windows")] let mut cmd = { @@ -56,9 +189,7 @@ pub(crate) async fn open_workspace_in_core( if matches!(ext.as_deref(), Some("cmd") | Some("bat")) { let mut cmd = tokio_command("cmd"); - let mut command_args = args.clone(); - command_args.push(path.clone()); - let command_line = build_cmd_c_command(resolved_path, &command_args)?; + let command_line = build_cmd_c_command(resolved_path, &launch_args)?; cmd.arg("/D"); cmd.arg("/S"); cmd.arg("/C"); @@ -66,7 +197,7 @@ pub(crate) async fn open_workspace_in_core( cmd } else { let mut cmd = tokio_command(resolved_path); - cmd.args(&args).arg(&path); + cmd.args(&launch_args); cmd } }; @@ -74,7 +205,7 @@ pub(crate) async fn open_workspace_in_core( #[cfg(not(target_os = "windows"))] let mut cmd = { let mut cmd = tokio_command(trimmed); - cmd.args(&args).arg(&path); + cmd.args(&launch_args); cmd }; @@ -86,27 +217,43 @@ pub(crate) async fn open_workspace_in_core( if trimmed.is_empty() { return Err("Missing app or command".to_string()); } + let app_strategy = app_launch_strategy(trimmed); #[cfg(target_os = "macos")] - let mut cmd = { - let mut cmd = tokio_command("open"); - cmd.arg("-a").arg(trimmed).arg(&path); - if !args.is_empty() { - cmd.arg("--args").args(&args); + { + if let (Some(strategy), Some(cli_program)) = ( + app_strategy, + normalize_open_location(line, column) + .and_then(|_| app_cli_command(trimmed)) + .and_then(find_executable_in_path), + ) { + let launch_args = build_launch_args(&path, &args, line, column, Some(strategy)); + let mut cmd = tokio_command(cli_program); + cmd.args(&launch_args); + cmd.output() + .await + .map_err(|error| format!("Failed to open app ({target_label}): {error}"))? + } else { + let mut cmd = tokio_command("open"); + cmd.arg("-a").arg(trimmed).arg(&path); + if !args.is_empty() { + cmd.arg("--args").args(&args); + } + cmd.output() + .await + .map_err(|error| format!("Failed to open app ({target_label}): {error}"))? } - cmd - }; + } #[cfg(not(target_os = "macos"))] - let mut cmd = { + { + let launch_args = build_launch_args(&path, &args, line, column, app_strategy); let mut cmd = tokio_command(trimmed); - cmd.args(&args).arg(&path); - cmd - }; - - cmd.output() - .await - .map_err(|error| format!("Failed to open app ({target_label}): {error}"))? + cmd.args(&launch_args); + cmd.output() + .await + .map_err(|error| format!("Failed to open app ({target_label}): {error}"))? + } } else { return Err("Missing app or command".to_string()); }; @@ -140,6 +287,112 @@ pub(crate) async fn open_workspace_in_core( } } +#[cfg(test)] +mod tests { + use super::{ + app_cli_command, app_launch_strategy, build_launch_args, command_launch_strategy, + LineAwareLaunchStrategy, + }; + + #[test] + fn matches_line_aware_command_targets() { + assert_eq!( + command_launch_strategy("/usr/local/bin/code"), + Some(LineAwareLaunchStrategy::GotoFlag) + ); + assert_eq!( + command_launch_strategy("cursor.cmd"), + Some(LineAwareLaunchStrategy::GotoFlag) + ); + assert_eq!( + command_launch_strategy("zed"), + Some(LineAwareLaunchStrategy::PathWithLineColumn) + ); + assert_eq!(command_launch_strategy("vim"), None); + } + + #[test] + fn matches_line_aware_app_targets() { + assert_eq!( + app_launch_strategy("Visual Studio Code"), + Some(LineAwareLaunchStrategy::GotoFlag) + ); + assert_eq!( + app_launch_strategy("Cursor"), + Some(LineAwareLaunchStrategy::GotoFlag) + ); + assert_eq!( + app_launch_strategy("Zed Preview"), + Some(LineAwareLaunchStrategy::PathWithLineColumn) + ); + assert_eq!(app_launch_strategy("Ghostty"), None); + } + + #[test] + fn maps_known_apps_to_cli_commands() { + assert_eq!(app_cli_command("Visual Studio Code"), Some("code")); + assert_eq!( + app_cli_command("Visual Studio Code Insiders"), + Some("code-insiders") + ); + assert_eq!(app_cli_command("Cursor"), Some("cursor")); + assert_eq!(app_cli_command("Zed Preview"), Some("zed")); + assert_eq!(app_cli_command("Ghostty"), None); + } + + #[test] + fn builds_goto_args_for_code_family_targets() { + let args = build_launch_args( + "/tmp/project/src/App.tsx", + &["--reuse-window".to_string()], + Some(33), + Some(7), + Some(LineAwareLaunchStrategy::GotoFlag), + ); + + assert_eq!( + args, + vec![ + "--reuse-window".to_string(), + "--goto".to_string(), + "/tmp/project/src/App.tsx:33:7".to_string(), + ] + ); + } + + #[test] + fn builds_line_suffixed_path_for_zed_targets() { + let args = build_launch_args( + "/tmp/project/src/App.tsx", + &[], + Some(33), + None, + Some(LineAwareLaunchStrategy::PathWithLineColumn), + ); + + assert_eq!(args, vec!["/tmp/project/src/App.tsx:33".to_string()]); + } + + #[test] + fn falls_back_to_plain_path_for_unknown_targets() { + let args = build_launch_args( + "/tmp/project/src/App.tsx", + &["--foreground".to_string()], + Some(33), + Some(7), + None, + ); + + assert_eq!( + args, + vec![ + "--foreground".to_string(), + "/tmp/project/src/App.tsx".to_string(), + ] + ); + } +} + #[cfg(target_os = "macos")] pub(crate) async fn get_open_app_icon_core( app_name: String, diff --git a/src-tauri/src/workspaces/commands.rs b/src-tauri/src/workspaces/commands.rs index 7a54ae0aa..4f8166e75 100644 --- a/src-tauri/src/workspaces/commands.rs +++ b/src-tauri/src/workspaces/commands.rs @@ -645,8 +645,10 @@ pub(crate) async fn open_workspace_in( app: Option, args: Vec, command: Option, + line: Option, + column: Option, ) -> Result<(), String> { - workspaces_core::open_workspace_in_core(path, app, args, command).await + workspaces_core::open_workspace_in_core(path, app, args, command, line, column).await } #[tauri::command] diff --git a/src/features/messages/components/Markdown.test.tsx b/src/features/messages/components/Markdown.test.tsx index eb0e76818..b088c2f57 100644 --- a/src/features/messages/components/Markdown.test.tsx +++ b/src/features/messages/components/Markdown.test.tsx @@ -92,6 +92,28 @@ describe("Markdown file-like href behavior", () => { expect(onOpenFileLink).toHaveBeenCalledWith("/workspace/src/example.ts"); }); + it("still intercepts dotless workspace file hrefs when a file opener is provided", () => { + const onOpenFileLink = vi.fn(); + render( + , + ); + + const link = screen.getByText("license").closest("a"); + expect(link?.getAttribute("href")).toBe("/workspace/CodexMonitor/LICENSE"); + + const clickEvent = createEvent.click(link as Element, { + bubbles: true, + cancelable: true, + }); + fireEvent(link as Element, clickEvent); + expect(clickEvent.defaultPrevented).toBe(true); + expect(onOpenFileLink).toHaveBeenCalledWith("/workspace/CodexMonitor/LICENSE"); + }); + it("intercepts file hrefs that use #L line anchors", () => { const onOpenFileLink = vi.fn(); render( diff --git a/src/features/messages/components/Markdown.tsx b/src/features/messages/components/Markdown.tsx index b36d4b248..9ea3bb74f 100644 --- a/src/features/messages/components/Markdown.tsx +++ b/src/features/messages/components/Markdown.tsx @@ -198,6 +198,10 @@ function safeDecodeFileLink(url: string) { const FILE_LINE_SUFFIX_PATTERN = /:\d+(?::\d+)?$/; const FILE_HASH_LINE_SUFFIX_PATTERN = /^#L(\d+)(?:C(\d+))?$/i; +const KNOWN_LOCAL_WORKSPACE_ROUTE_PREFIXES = [ + "/workspace/settings", + "/workspaces/settings", +]; const LIKELY_LOCAL_ABSOLUTE_PATH_PREFIXES = [ "/Users/", "/home/", @@ -215,7 +219,6 @@ const LIKELY_LOCAL_ABSOLUTE_PATH_PREFIXES = [ "/srv/", "/data/", ]; -const ROUTE_LIKE_ABSOLUTE_PATH_PREFIXES = ["/workspace/", "/workspaces/"]; function stripPathLineSuffix(value: string) { return value.replace(FILE_LINE_SUFFIX_PATTERN, ""); @@ -240,13 +243,16 @@ function hasLikelyLocalAbsolutePrefix(path: string) { ); } +function isKnownLocalWorkspaceRoute(path: string) { + const normalizedPath = path.replace(/\\/g, "/"); + return KNOWN_LOCAL_WORKSPACE_ROUTE_PREFIXES.some((prefix) => + normalizedPath === prefix || normalizedPath.startsWith(`${prefix}/`), + ); +} + function usesAbsolutePathDepthFallback(path: string) { const normalizedPath = path.replace(/\\/g, "/"); - if ( - ROUTE_LIKE_ABSOLUTE_PATH_PREFIXES.some((prefix) => - normalizedPath.startsWith(prefix), - ) - ) { + if (isKnownLocalWorkspaceRoute(normalizedPath)) { return false; } return hasLikelyLocalAbsolutePrefix(normalizedPath) && pathSegmentCount(normalizedPath) >= 3; diff --git a/src/features/messages/components/Messages.test.tsx b/src/features/messages/components/Messages.test.tsx index f3afe0298..0718e08c5 100644 --- a/src/features/messages/components/Messages.test.tsx +++ b/src/features/messages/components/Messages.test.tsx @@ -341,6 +341,32 @@ describe("Messages", () => { ); }); + it("routes dotless workspace href file paths through the file opener", () => { + const linkedPath = "/workspace/CodexMonitor/LICENSE"; + const items: ConversationItem[] = [ + { + id: "msg-file-href-workspace-dotless-link", + kind: "message", + role: "assistant", + text: `Open [license](${linkedPath})`, + }, + ]; + + render( + , + ); + + fireEvent.click(screen.getByText("license")); + expect(openFileLinkMock).toHaveBeenCalledWith(linkedPath); + }); + it("keeps non-file relative links as normal markdown links", () => { const items: ConversationItem[] = [ { diff --git a/src/features/messages/hooks/useFileLinkOpener.test.tsx b/src/features/messages/hooks/useFileLinkOpener.test.tsx new file mode 100644 index 000000000..8fc5d200b --- /dev/null +++ b/src/features/messages/hooks/useFileLinkOpener.test.tsx @@ -0,0 +1,127 @@ +// @vitest-environment jsdom +import { act, renderHook } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { openWorkspaceIn } from "../../../services/tauri"; +import { useFileLinkOpener } from "./useFileLinkOpener"; + +vi.mock("../../../services/tauri", () => ({ + openWorkspaceIn: vi.fn(), +})); + +vi.mock("@tauri-apps/plugin-opener", () => ({ + revealItemInDir: vi.fn(), +})); + +vi.mock("@tauri-apps/api/menu", () => ({ + Menu: { new: vi.fn() }, + MenuItem: { new: vi.fn() }, + PredefinedMenuItem: { new: vi.fn() }, +})); + +vi.mock("@tauri-apps/api/dpi", () => ({ + LogicalPosition: vi.fn(), +})); + +vi.mock("@tauri-apps/api/window", () => ({ + getCurrentWindow: vi.fn(), +})); + +vi.mock("@sentry/react", () => ({ + captureException: vi.fn(), +})); + +vi.mock("../../../services/toasts", () => ({ + pushErrorToast: vi.fn(), +})); + +describe("useFileLinkOpener", () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + 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"); + }); + + expect(openWorkspaceInMock).toHaveBeenCalledWith( + "/Users/sotiriskaniras/Documents/Development/Forks/CodexMonitor/src/features/messages/components/Markdown.tsx", + expect.objectContaining({ appName: "Visual Studio Code", args: [] }), + ); + }); + + it("maps /workspace//... 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/CodexMonitor/LICENSE"); + }); + + expect(openWorkspaceInMock).toHaveBeenCalledWith( + "/Users/sotiriskaniras/Documents/Development/Forks/CodexMonitor/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"); + }); + + expect(openWorkspaceInMock).toHaveBeenCalledWith( + "/Users/sotiriskaniras/Documents/Development/Forks/CodexMonitor/src", + expect.objectContaining({ appName: "Visual Studio Code", args: [] }), + ); + }); + + it("preserves file link line and column metadata for editor opens", 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:33:7", + ); + }); + + expect(openWorkspaceInMock).toHaveBeenCalledWith( + "/Users/sotiriskaniras/Documents/Development/Forks/CodexMonitor/src/features/messages/components/Markdown.tsx", + expect.objectContaining({ + appName: "Visual Studio Code", + args: [], + line: 33, + column: 7, + }), + ); + }); + + it("parses #L line anchors before opening the editor", 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/App.tsx#L33"); + }); + + expect(openWorkspaceInMock).toHaveBeenCalledWith( + "/Users/sotiriskaniras/Documents/Development/Forks/CodexMonitor/src/App.tsx", + expect.objectContaining({ + appName: "Visual Studio Code", + args: [], + line: 33, + }), + ); + }); +}); diff --git a/src/features/messages/hooks/useFileLinkOpener.ts b/src/features/messages/hooks/useFileLinkOpener.ts index 879d5259b..8843063af 100644 --- a/src/features/messages/hooks/useFileLinkOpener.ts +++ b/src/features/messages/hooks/useFileLinkOpener.ts @@ -34,6 +34,9 @@ const DEFAULT_OPEN_TARGET: OpenTarget = { const resolveAppName = (target: OpenTarget) => (target.appName ?? "").trim(); const resolveCommand = (target: OpenTarget) => (target.command ?? "").trim(); +const WORKSPACE_MOUNT_PREFIX = "/workspace/"; +const WORKSPACES_MOUNT_PREFIX = "/workspaces/"; + const canOpenTarget = (target: OpenTarget) => { if (target.kind === "finder") { return true; @@ -44,20 +47,151 @@ const canOpenTarget = (target: OpenTarget) => { return Boolean(resolveAppName(target)); }; +function normalizePathSeparators(path: string) { + return path.replace(/\\/g, "/"); +} + +function trimTrailingSeparators(path: string) { + return path.replace(/[\\/]+$/, ""); +} + +function pathBaseName(path: string) { + return trimTrailingSeparators(normalizePathSeparators(path.trim())) + .split("/") + .filter(Boolean) + .pop() ?? ""; +} + +function resolveMountedWorkspacePath( + path: string, + workspacePath?: string | null, +) { + const trimmed = path.trim(); + const trimmedWorkspace = workspacePath?.trim() ?? ""; + if (!trimmedWorkspace) { + return null; + } + + const normalizedPath = normalizePathSeparators(trimmed); + const workspaceName = pathBaseName(trimmedWorkspace); + if (!workspaceName) { + return null; + } + + const resolveFromSegments = (segments: string[], allowDirectRelative: boolean) => { + if (segments.length === 0) { + return trimTrailingSeparators(trimmedWorkspace); + } + const workspaceIndex = segments.findIndex((segment) => segment === workspaceName); + if (workspaceIndex >= 0) { + const relativePath = segments.slice(workspaceIndex + 1).join("/"); + return relativePath + ? joinWorkspacePath(trimmedWorkspace, relativePath) + : trimTrailingSeparators(trimmedWorkspace); + } + if (allowDirectRelative) { + return joinWorkspacePath(trimmedWorkspace, segments.join("/")); + } + 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, + ); + } + return null; +} + function resolveFilePath(path: string, workspacePath?: string | null) { const trimmed = path.trim(); if (!workspacePath) { return trimmed; } + const mountedWorkspacePath = resolveMountedWorkspacePath(trimmed, workspacePath); + if (mountedWorkspacePath) { + return mountedWorkspacePath; + } if (isAbsolutePath(trimmed)) { return trimmed; } return joinWorkspacePath(workspacePath, trimmed); } -function stripLineSuffix(path: string) { - const match = path.match(/^(.*?)(?::\d+(?::\d+)?)?$/); - return match ? match[1] : path; +type ParsedFileLocation = { + path: string; + line: number | null; + column: number | null; +}; + +const FILE_LOCATION_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) { + return { + path: trimmed, + line: null, + column: null, + }; + } + + 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), + }; +} + +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( @@ -93,7 +227,12 @@ export function useFileLinkOpener( ...(openTargets.find((entry) => entry.id === selectedOpenAppId) ?? openTargets[0]), }; - const resolvedPath = resolveFilePath(stripLineSuffix(rawPath), workspacePath); + const fileLocation = parseFileLocation(rawPath); + const resolvedPath = resolveFilePath(fileLocation.path, workspacePath); + const openLocation = { + ...(fileLocation.line !== null ? { line: fileLocation.line } : {}), + ...(fileLocation.column !== null ? { column: fileLocation.column } : {}), + }; try { if (!canOpenTarget(target)) { @@ -112,6 +251,7 @@ export function useFileLinkOpener( await openWorkspaceIn(resolvedPath, { command, args: target.args, + ...openLocation, }); return; } @@ -123,6 +263,7 @@ export function useFileLinkOpener( await openWorkspaceIn(resolvedPath, { appName, args: target.args, + ...openLocation, }); } catch (error) { reportOpenError(error, { @@ -148,7 +289,8 @@ export function useFileLinkOpener( ...(openTargets.find((entry) => entry.id === selectedOpenAppId) ?? openTargets[0]), }; - const resolvedPath = resolveFilePath(stripLineSuffix(rawPath), workspacePath); + const fileLocation = parseFileLocation(rawPath); + const resolvedPath = resolveFilePath(fileLocation.path, workspacePath); const appName = resolveAppName(target); const command = resolveCommand(target); const canOpen = canOpenTarget(target); @@ -199,8 +341,7 @@ export function useFileLinkOpener( await MenuItem.new({ text: "Copy Link", action: async () => { - const link = - resolvedPath.startsWith("/") ? `file://${resolvedPath}` : resolvedPath; + const link = toFileUrl(resolvedPath, fileLocation.line, fileLocation.column); try { await navigator.clipboard.writeText(link); } catch { diff --git a/src/services/tauri.test.ts b/src/services/tauri.test.ts index 808cfd213..06fb4e08f 100644 --- a/src/services/tauri.test.ts +++ b/src/services/tauri.test.ts @@ -358,6 +358,29 @@ describe("tauri invoke wrappers", () => { app: "Xcode", command: null, args: ["--reuse-window"], + line: null, + column: null, + }); + }); + + it("passes line-aware openWorkspaceIn options", async () => { + const invokeMock = vi.mocked(invoke); + invokeMock.mockResolvedValueOnce({}); + + await openWorkspaceIn("/tmp/project/src/App.tsx", { + command: "code", + args: ["--reuse-window"], + line: 33, + column: 7, + }); + + expect(invokeMock).toHaveBeenCalledWith("open_workspace_in", { + path: "/tmp/project/src/App.tsx", + app: null, + command: "code", + args: ["--reuse-window"], + line: 33, + column: 7, }); }); diff --git a/src/services/tauri.ts b/src/services/tauri.ts index 07919a72f..afc920574 100644 --- a/src/services/tauri.ts +++ b/src/services/tauri.ts @@ -342,6 +342,8 @@ export async function openWorkspaceIn( appName?: string | null; command?: string | null; args?: string[]; + line?: number | null; + column?: number | null; }, ): Promise { return invoke("open_workspace_in", { @@ -349,6 +351,8 @@ export async function openWorkspaceIn( app: options.appName ?? null, command: options.command ?? null, args: options.args ?? [], + line: options.line ?? null, + column: options.column ?? null, }); } From 96bfb5c3e4014743d7c26100c6c24e5afd43c19b Mon Sep 17 00:00:00 2001 From: Samigos Date: Sun, 1 Mar 2026 23:36:36 +0200 Subject: [PATCH 6/7] fix: improve workspace file link and app name detection --- src-tauri/src/shared/workspaces_core/io.rs | 24 ++- .../messages/components/Markdown.test.tsx | 70 ++++++++ src/features/messages/components/Markdown.tsx | 157 ++++++++++++++++-- 3 files changed, 232 insertions(+), 19 deletions(-) diff --git a/src-tauri/src/shared/workspaces_core/io.rs b/src-tauri/src/shared/workspaces_core/io.rs index 972bfb292..28c6778e4 100644 --- a/src-tauri/src/shared/workspaces_core/io.rs +++ b/src-tauri/src/shared/workspaces_core/io.rs @@ -59,7 +59,7 @@ fn command_launch_strategy(command: &str) -> Option { } fn app_launch_strategy(app: &str) -> Option { - let normalized = app.trim().to_ascii_lowercase(); + let normalized = normalize_app_identifier(app); if normalized.contains("visual studio code") || normalized.starts_with("cursor") { return Some(LineAwareLaunchStrategy::GotoFlag); } @@ -70,7 +70,7 @@ fn app_launch_strategy(app: &str) -> Option { } fn app_cli_command(app: &str) -> Option<&'static str> { - let normalized = app.trim().to_ascii_lowercase(); + let normalized = normalize_app_identifier(app); if normalized.contains("visual studio code insiders") { return Some("code-insiders"); } @@ -86,6 +86,22 @@ fn app_cli_command(app: &str) -> Option<&'static str> { None } +fn normalize_app_identifier(app: &str) -> String { + app.trim() + .chars() + .map(|value| { + if value.is_ascii_alphanumeric() { + value.to_ascii_lowercase() + } else { + ' ' + } + }) + .collect::() + .split_whitespace() + .collect::>() + .join(" ") +} + fn find_executable_in_path(program: &str) -> Option { let trimmed = program.trim(); if trimmed.is_empty() { @@ -335,6 +351,10 @@ mod tests { app_cli_command("Visual Studio Code Insiders"), Some("code-insiders") ); + assert_eq!( + app_cli_command("Visual Studio Code - Insiders"), + Some("code-insiders") + ); assert_eq!(app_cli_command("Cursor"), Some("cursor")); assert_eq!(app_cli_command("Zed Preview"), Some("zed")); assert_eq!(app_cli_command("Ghostty"), None); diff --git a/src/features/messages/components/Markdown.test.tsx b/src/features/messages/components/Markdown.test.tsx index b088c2f57..15802bc07 100644 --- a/src/features/messages/components/Markdown.test.tsx +++ b/src/features/messages/components/Markdown.test.tsx @@ -98,6 +98,7 @@ describe("Markdown file-like href behavior", () => { , ); @@ -114,6 +115,75 @@ describe("Markdown file-like href behavior", () => { expect(onOpenFileLink).toHaveBeenCalledWith("/workspace/CodexMonitor/LICENSE"); }); + it("keeps generic workspace routes as normal markdown links", () => { + const onOpenFileLink = vi.fn(); + render( + , + ); + + const link = screen.getByText("overview").closest("a"); + expect(link?.getAttribute("href")).toBe("/workspace/reviews/overview"); + + const clickEvent = createEvent.click(link as Element, { + bubbles: true, + cancelable: true, + }); + fireEvent(link as Element, clickEvent); + expect(clickEvent.defaultPrevented).toBe(true); + expect(onOpenFileLink).not.toHaveBeenCalled(); + }); + + it("keeps nested workspaces routes as normal markdown links", () => { + const onOpenFileLink = vi.fn(); + render( + , + ); + + 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( + , + ); + + const link = screen.getByText("src").closest("a"); + expect(link?.getAttribute("href")).toBe("/workspaces/team/CodexMonitor/src"); + + const clickEvent = createEvent.click(link as Element, { + bubbles: true, + cancelable: true, + }); + fireEvent(link as Element, clickEvent); + expect(clickEvent.defaultPrevented).toBe(true); + expect(onOpenFileLink).toHaveBeenCalledWith("/workspaces/team/CodexMonitor/src"); + }); + it("intercepts file hrefs that use #L line anchors", () => { const onOpenFileLink = vi.fn(); render( diff --git a/src/features/messages/components/Markdown.tsx b/src/features/messages/components/Markdown.tsx index 9ea3bb74f..c314d60f2 100644 --- a/src/features/messages/components/Markdown.tsx +++ b/src/features/messages/components/Markdown.tsx @@ -198,10 +198,6 @@ function safeDecodeFileLink(url: string) { const FILE_LINE_SUFFIX_PATTERN = /:\d+(?::\d+)?$/; const FILE_HASH_LINE_SUFFIX_PATTERN = /^#L(\d+)(?:C(\d+))?$/i; -const KNOWN_LOCAL_WORKSPACE_ROUTE_PREFIXES = [ - "/workspace/settings", - "/workspaces/settings", -]; const LIKELY_LOCAL_ABSOLUTE_PATH_PREFIXES = [ "/Users/", "/home/", @@ -219,6 +215,31 @@ const LIKELY_LOCAL_ABSOLUTE_PATH_PREFIXES = [ "/srv/", "/data/", ]; +const WORKSPACE_ROUTE_PREFIXES = ["/workspace/", "/workspaces/"]; +const LIKELY_WORKSPACE_ROOT_SEGMENTS = new Set([ + "src", + "app", + "lib", + "docs", + "scripts", + "test", + "tests", + "packages", + "apps", + "bin", + "public", +]); +const DOTLESS_WORKSPACE_FILE_NAMES = new Set([ + "LICENSE", + "README", + "CHANGELOG", + "NOTICE", + "COPYING", + "Makefile", + "Dockerfile", + "Procfile", + "Gemfile", +]); function stripPathLineSuffix(value: string) { return value.replace(FILE_LINE_SUFFIX_PATTERN, ""); @@ -243,16 +264,109 @@ function hasLikelyLocalAbsolutePrefix(path: string) { ); } -function isKnownLocalWorkspaceRoute(path: string) { +function workspaceBaseName(workspacePath?: string | null) { + const normalizedWorkspace = trimTrailingPathSeparators( + normalizePathSeparators(workspacePath?.trim() ?? ""), + ); + return normalizedWorkspace.split("/").filter(Boolean).pop() ?? ""; +} + +function splitWorkspaceRoutePath(path: string) { const normalizedPath = path.replace(/\\/g, "/"); - return KNOWN_LOCAL_WORKSPACE_ROUTE_PREFIXES.some((prefix) => - normalizedPath === prefix || normalizedPath.startsWith(`${prefix}/`), + 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 hasLikelyDotlessWorkspaceFileLeaf(segment: string) { + if (!segment || segment === "." || segment === "..") { + return false; + } + if (segment.startsWith(".") && segment.length > 1) { + return true; + } + return ( + DOTLESS_WORKSPACE_FILE_NAMES.has(segment) || /^[A-Z0-9_-]+$/.test(segment) ); } -function usesAbsolutePathDepthFallback(path: string) { +function isLikelyMountedWorkspaceFilePath( + path: string, + workspacePath?: string | null, +) { + const mountedPath = splitWorkspaceRoutePath(path); + if (!mountedPath || mountedPath.segments.length === 0) { + return false; + } + + const workspaceName = workspaceBaseName(workspacePath); + if ( + workspaceName && + mountedPath.prefix === "/workspace/" && + mountedPath.segments[0] === workspaceName && + mountedPath.segments.length >= 2 + ) { + return true; + } + + if ( + mountedPath.prefix === "/workspace/" && + mountedPath.segments.length >= 2 && + hasLikelyWorkspaceNameSegment(mountedPath.segments[0]) && + hasLikelyDotlessWorkspaceFileLeaf( + mountedPath.segments[mountedPath.segments.length - 1], + ) + ) { + return true; + } + + if ( + mountedPath.prefix === "/workspace/" && + mountedPath.segments.length >= 2 && + LIKELY_WORKSPACE_ROOT_SEGMENTS.has(mountedPath.segments[0]) + ) { + return true; + } + + if ( + workspaceName && + mountedPath.prefix === "/workspaces/" && + mountedPath.segments.length >= 3 + ) { + const workspaceIndex = mountedPath.segments.findIndex( + (segment) => segment === workspaceName, + ); + if (workspaceIndex >= 0 && workspaceIndex < mountedPath.segments.length - 1) { + return true; + } + } + + return false; +} + +function usesAbsolutePathDepthFallback( + path: string, + workspacePath?: string | null, +) { const normalizedPath = path.replace(/\\/g, "/"); - if (isKnownLocalWorkspaceRoute(normalizedPath)) { + if ( + WORKSPACE_ROUTE_PREFIXES.some((prefix) => normalizedPath.startsWith(prefix)) && + !isLikelyMountedWorkspaceFilePath(normalizedPath, workspacePath) + ) { return false; } return hasLikelyLocalAbsolutePrefix(normalizedPath) && pathSegmentCount(normalizedPath) >= 3; @@ -262,7 +376,10 @@ function pathSegmentCount(path: string) { return path.split("/").filter(Boolean).length; } -function toPathFromFileHashAnchor(url: string) { +function toPathFromFileHashAnchor( + url: string, + workspacePath?: string | null, +) { const hashIndex = url.indexOf("#"); if (hashIndex <= 0) { return null; @@ -270,14 +387,17 @@ function toPathFromFileHashAnchor(url: string) { 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)) { + if (!basePath || !match || !isLikelyFileHref(basePath, workspacePath)) { return null; } const [, line, column] = match; return `${basePath}:${line}${column ? `:${column}` : ""}`; } -function isLikelyFileHref(url: string) { +function isLikelyFileHref( + url: string, + workspacePath?: string | null, +) { const trimmed = url.trim(); if (!trimmed) { return false; @@ -311,7 +431,7 @@ function isLikelyFileHref(url: string) { if (hasLikelyFileName(trimmed)) { return true; } - return usesAbsolutePathDepthFallback(trimmed); + return usesAbsolutePathDepthFallback(trimmed, workspacePath); } if (FILE_LINE_SUFFIX_PATTERN.test(trimmed)) { return true; @@ -659,14 +779,14 @@ export function Markdown({ return trimmed; }; const resolveHrefFilePath = (url: string) => { - const hashAnchorPath = toPathFromFileHashAnchor(url); + const hashAnchorPath = toPathFromFileHashAnchor(url, workspacePath); if (hashAnchorPath) { const anchoredPath = getLinkablePath(hashAnchorPath); if (anchoredPath) { return safeDecodeURIComponent(anchoredPath) ?? anchoredPath; } } - if (isLikelyFileHref(url)) { + if (isLikelyFileHref(url, workspacePath)) { const directPath = getLinkablePath(url); if (directPath) { return safeDecodeURIComponent(directPath) ?? directPath; @@ -674,7 +794,10 @@ export function Markdown({ } const decodedUrl = safeDecodeURIComponent(url); if (decodedUrl) { - const decodedHashAnchorPath = toPathFromFileHashAnchor(decodedUrl); + const decodedHashAnchorPath = toPathFromFileHashAnchor( + decodedUrl, + workspacePath, + ); if (decodedHashAnchorPath) { const anchoredPath = getLinkablePath(decodedHashAnchorPath); if (anchoredPath) { @@ -682,7 +805,7 @@ export function Markdown({ } } } - if (decodedUrl && isLikelyFileHref(decodedUrl)) { + if (decodedUrl && isLikelyFileHref(decodedUrl, workspacePath)) { const decodedPath = getLinkablePath(decodedUrl); if (decodedPath) { return decodedPath; From dc416b6d4d6f2a0b537ec1096012631b2a30df44 Mon Sep 17 00:00:00 2001 From: Samigos Date: Sun, 1 Mar 2026 23:56:14 +0200 Subject: [PATCH 7/7] fix(messages): resolve mounted workspace links and line ranges --- .../messages/components/Markdown.test.tsx | 46 +++++++ src/features/messages/components/Markdown.tsx | 101 +++------------- .../messages/hooks/useFileLinkOpener.test.tsx | 21 ++++ .../messages/hooks/useFileLinkOpener.ts | 113 +++++------------- .../messages/utils/mountedWorkspacePaths.ts | 70 +++++++++++ 5 files changed, 186 insertions(+), 165 deletions(-) create mode 100644 src/features/messages/utils/mountedWorkspacePaths.ts diff --git a/src/features/messages/components/Markdown.test.tsx b/src/features/messages/components/Markdown.test.tsx index 15802bc07..5a76f69a7 100644 --- a/src/features/messages/components/Markdown.test.tsx +++ b/src/features/messages/components/Markdown.test.tsx @@ -115,6 +115,52 @@ describe("Markdown file-like href behavior", () => { expect(onOpenFileLink).toHaveBeenCalledWith("/workspace/CodexMonitor/LICENSE"); }); + it("intercepts mounted workspace links outside the old root allowlist", () => { + const onOpenFileLink = vi.fn(); + render( + , + ); + + const link = screen.getByText("workflows").closest("a"); + expect(link?.getAttribute("href")).toBe("/workspace/.github/workflows"); + + const clickEvent = createEvent.click(link as Element, { + bubbles: true, + cancelable: true, + }); + fireEvent(link as Element, clickEvent); + expect(clickEvent.defaultPrevented).toBe(true); + expect(onOpenFileLink).toHaveBeenCalledWith("/workspace/.github/workflows"); + }); + + it("intercepts mounted workspace directory links that resolve relative to the workspace", () => { + const onOpenFileLink = vi.fn(); + render( + , + ); + + const link = screen.getByText("assets").closest("a"); + expect(link?.getAttribute("href")).toBe("/workspace/dist/assets"); + + const clickEvent = createEvent.click(link as Element, { + bubbles: true, + cancelable: true, + }); + fireEvent(link as Element, clickEvent); + expect(clickEvent.defaultPrevented).toBe(true); + expect(onOpenFileLink).toHaveBeenCalledWith("/workspace/dist/assets"); + }); + it("keeps generic workspace routes as normal markdown links", () => { const onOpenFileLink = vi.fn(); render( diff --git a/src/features/messages/components/Markdown.tsx b/src/features/messages/components/Markdown.tsx index c314d60f2..503aef0b0 100644 --- a/src/features/messages/components/Markdown.tsx +++ b/src/features/messages/components/Markdown.tsx @@ -9,6 +9,7 @@ import { remarkFileLinks, toFileLink, } from "../../../utils/remarkFileLinks"; +import { resolveMountedWorkspacePath } from "../utils/mountedWorkspacePaths"; type MarkdownProps = { value: string; @@ -216,30 +217,7 @@ const LIKELY_LOCAL_ABSOLUTE_PATH_PREFIXES = [ "/data/", ]; const WORKSPACE_ROUTE_PREFIXES = ["/workspace/", "/workspaces/"]; -const LIKELY_WORKSPACE_ROOT_SEGMENTS = new Set([ - "src", - "app", - "lib", - "docs", - "scripts", - "test", - "tests", - "packages", - "apps", - "bin", - "public", -]); -const DOTLESS_WORKSPACE_FILE_NAMES = new Set([ - "LICENSE", - "README", - "CHANGELOG", - "NOTICE", - "COPYING", - "Makefile", - "Dockerfile", - "Procfile", - "Gemfile", -]); +const LOCAL_WORKSPACE_ROUTE_SEGMENTS = new Set(["reviews", "settings"]); function stripPathLineSuffix(value: string) { return value.replace(FILE_LINE_SUFFIX_PATTERN, ""); @@ -264,13 +242,6 @@ function hasLikelyLocalAbsolutePrefix(path: string) { ); } -function workspaceBaseName(workspacePath?: string | null) { - const normalizedWorkspace = trimTrailingPathSeparators( - normalizePathSeparators(workspacePath?.trim() ?? ""), - ); - return normalizedWorkspace.split("/").filter(Boolean).pop() ?? ""; -} - function splitWorkspaceRoutePath(path: string) { const normalizedPath = path.replace(/\\/g, "/"); if (normalizedPath.startsWith("/workspace/")) { @@ -292,70 +263,36 @@ function hasLikelyWorkspaceNameSegment(segment: string) { return /[A-Z]/.test(segment) || /[._-]/.test(segment); } -function hasLikelyDotlessWorkspaceFileLeaf(segment: string) { - if (!segment || segment === "." || segment === "..") { +function isKnownLocalWorkspaceRoutePath(path: string) { + const mountedPath = splitWorkspaceRoutePath(path); + if (!mountedPath || mountedPath.segments.length === 0) { return false; } - if (segment.startsWith(".") && segment.length > 1) { - return true; - } - return ( - DOTLESS_WORKSPACE_FILE_NAMES.has(segment) || /^[A-Z0-9_-]+$/.test(segment) - ); + + 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, ) { - const mountedPath = splitWorkspaceRoutePath(path); - if (!mountedPath || mountedPath.segments.length === 0) { + if (isKnownLocalWorkspaceRoutePath(path)) { return false; } - - const workspaceName = workspaceBaseName(workspacePath); - if ( - workspaceName && - mountedPath.prefix === "/workspace/" && - mountedPath.segments[0] === workspaceName && - mountedPath.segments.length >= 2 - ) { - return true; - } - - if ( - mountedPath.prefix === "/workspace/" && - mountedPath.segments.length >= 2 && - hasLikelyWorkspaceNameSegment(mountedPath.segments[0]) && - hasLikelyDotlessWorkspaceFileLeaf( - mountedPath.segments[mountedPath.segments.length - 1], - ) - ) { + if (resolveMountedWorkspacePath(path, workspacePath) !== null) { return true; } - if ( - mountedPath.prefix === "/workspace/" && - mountedPath.segments.length >= 2 && - LIKELY_WORKSPACE_ROOT_SEGMENTS.has(mountedPath.segments[0]) - ) { - return true; - } - - if ( - workspaceName && - mountedPath.prefix === "/workspaces/" && - mountedPath.segments.length >= 3 - ) { - const workspaceIndex = mountedPath.segments.findIndex( - (segment) => segment === workspaceName, - ); - if (workspaceIndex >= 0 && workspaceIndex < mountedPath.segments.length - 1) { - return true; - } - } - - return false; + const mountedPath = splitWorkspaceRoutePath(path); + return Boolean( + mountedPath?.prefix === "/workspace/" && + mountedPath.segments.length >= 2 && + hasLikelyWorkspaceNameSegment(mountedPath.segments[0]), + ); } function usesAbsolutePathDepthFallback( diff --git a/src/features/messages/hooks/useFileLinkOpener.test.tsx b/src/features/messages/hooks/useFileLinkOpener.test.tsx index 8fc5d200b..42b6816a9 100644 --- a/src/features/messages/hooks/useFileLinkOpener.test.tsx +++ b/src/features/messages/hooks/useFileLinkOpener.test.tsx @@ -124,4 +124,25 @@ describe("useFileLinkOpener", () => { }), ); }); + + 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); + const { result } = renderHook(() => useFileLinkOpener(workspacePath, [], "")); + + await act(async () => { + await result.current.openFileLink( + "/workspace/src/features/messages/components/Markdown.tsx:366-369", + ); + }); + + expect(openWorkspaceInMock).toHaveBeenCalledWith( + "/Users/sotiriskaniras/Documents/Development/Forks/CodexMonitor/src/features/messages/components/Markdown.tsx", + expect.objectContaining({ + appName: "Visual Studio Code", + args: [], + line: 366, + }), + ); + }); }); diff --git a/src/features/messages/hooks/useFileLinkOpener.ts b/src/features/messages/hooks/useFileLinkOpener.ts index 8843063af..65ea91bdd 100644 --- a/src/features/messages/hooks/useFileLinkOpener.ts +++ b/src/features/messages/hooks/useFileLinkOpener.ts @@ -13,6 +13,7 @@ import { joinWorkspacePath, revealInFileManagerLabel, } from "../../../utils/platformPaths"; +import { resolveMountedWorkspacePath } from "../utils/mountedWorkspacePaths"; type OpenTarget = { id: string; @@ -34,8 +35,6 @@ const DEFAULT_OPEN_TARGET: OpenTarget = { const resolveAppName = (target: OpenTarget) => (target.appName ?? "").trim(); const resolveCommand = (target: OpenTarget) => (target.command ?? "").trim(); -const WORKSPACE_MOUNT_PREFIX = "/workspace/"; -const WORKSPACES_MOUNT_PREFIX = "/workspaces/"; const canOpenTarget = (target: OpenTarget) => { if (target.kind === "finder") { @@ -47,72 +46,6 @@ const canOpenTarget = (target: OpenTarget) => { return Boolean(resolveAppName(target)); }; -function normalizePathSeparators(path: string) { - return path.replace(/\\/g, "/"); -} - -function trimTrailingSeparators(path: string) { - return path.replace(/[\\/]+$/, ""); -} - -function pathBaseName(path: string) { - return trimTrailingSeparators(normalizePathSeparators(path.trim())) - .split("/") - .filter(Boolean) - .pop() ?? ""; -} - -function resolveMountedWorkspacePath( - path: string, - workspacePath?: string | null, -) { - const trimmed = path.trim(); - const trimmedWorkspace = workspacePath?.trim() ?? ""; - if (!trimmedWorkspace) { - return null; - } - - const normalizedPath = normalizePathSeparators(trimmed); - const workspaceName = pathBaseName(trimmedWorkspace); - if (!workspaceName) { - return null; - } - - const resolveFromSegments = (segments: string[], allowDirectRelative: boolean) => { - if (segments.length === 0) { - return trimTrailingSeparators(trimmedWorkspace); - } - const workspaceIndex = segments.findIndex((segment) => segment === workspaceName); - if (workspaceIndex >= 0) { - const relativePath = segments.slice(workspaceIndex + 1).join("/"); - return relativePath - ? joinWorkspacePath(trimmedWorkspace, relativePath) - : trimTrailingSeparators(trimmedWorkspace); - } - if (allowDirectRelative) { - return joinWorkspacePath(trimmedWorkspace, segments.join("/")); - } - 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, - ); - } - return null; -} - function resolveFilePath(path: string, workspacePath?: string | null) { const trimmed = path.trim(); if (!workspacePath) { @@ -135,6 +68,7 @@ type ParsedFileLocation = { }; const FILE_LOCATION_SUFFIX_PATTERN = /^(.*?):(\d+)(?::(\d+))?$/; +const FILE_LOCATION_RANGE_SUFFIX_PATTERN = /^(.*?):(\d+)-(\d+)$/; const FILE_LOCATION_HASH_PATTERN = /^(.*?)#L(\d+)(?:C(\d+))?$/i; function parsePositiveInteger(value?: string) { @@ -161,28 +95,41 @@ function parseFileLocation(rawPath: string): ParsedFileLocation { } const match = trimmed.match(FILE_LOCATION_SUFFIX_PATTERN); - if (!match) { + if (match) { + const [, path, lineValue, columnValue] = match; + const line = parsePositiveInteger(lineValue); + if (line === null) { + return { + path: trimmed, + line: null, + column: null, + }; + } + return { - path: trimmed, - line: null, - column: null, + path, + line, + column: parsePositiveInteger(columnValue), }; } - const [, path, lineValue, columnValue] = match; - const line = parsePositiveInteger(lineValue); - if (line === null) { - return { - path: trimmed, - line: null, - column: null, - }; + 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, - line, - column: parsePositiveInteger(columnValue), + path: trimmed, + line: null, + column: null, }; } diff --git a/src/features/messages/utils/mountedWorkspacePaths.ts b/src/features/messages/utils/mountedWorkspacePaths.ts new file mode 100644 index 000000000..15963fe8d --- /dev/null +++ b/src/features/messages/utils/mountedWorkspacePaths.ts @@ -0,0 +1,70 @@ +import { joinWorkspacePath } from "../../../utils/platformPaths"; + +const WORKSPACE_MOUNT_PREFIX = "/workspace/"; +const WORKSPACES_MOUNT_PREFIX = "/workspaces/"; + +function normalizePathSeparators(path: string) { + return path.replace(/\\/g, "/"); +} + +function trimTrailingSeparators(path: string) { + return path.replace(/[\\/]+$/, ""); +} + +function pathBaseName(path: string) { + return trimTrailingSeparators(normalizePathSeparators(path.trim())) + .split("/") + .filter(Boolean) + .pop() ?? ""; +} + +export function resolveMountedWorkspacePath( + path: string, + workspacePath?: string | null, +) { + const trimmed = path.trim(); + const trimmedWorkspace = workspacePath?.trim() ?? ""; + if (!trimmedWorkspace) { + return null; + } + + const normalizedPath = normalizePathSeparators(trimmed); + const workspaceName = pathBaseName(trimmedWorkspace); + if (!workspaceName) { + return null; + } + + const resolveFromSegments = (segments: string[], allowDirectRelative: boolean) => { + if (segments.length === 0) { + return trimTrailingSeparators(trimmedWorkspace); + } + const workspaceIndex = segments.findIndex((segment) => segment === workspaceName); + if (workspaceIndex >= 0) { + const relativePath = segments.slice(workspaceIndex + 1).join("/"); + return relativePath + ? joinWorkspacePath(trimmedWorkspace, relativePath) + : trimTrailingSeparators(trimmedWorkspace); + } + if (allowDirectRelative) { + return joinWorkspacePath(trimmedWorkspace, segments.join("/")); + } + 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, + ); + } + return null; +}