Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions src/features/messages/components/Markdown.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<Markdown
value="See [setup](./docs/setup.md)"
className="markdown"
/>,
);

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(
<Markdown
value="See [setup](./docs/setup.md)"
className="markdown"
onOpenFileLink={onOpenFileLink}
/>,
);

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(
<Markdown
value="See [setup](docs/setup.md)"
className="markdown"
onOpenFileLink={onOpenFileLink}
/>,
);

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();
});
});
200 changes: 198 additions & 2 deletions src/features/messages/components/Markdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,151 @@ 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/",
"/usr/",
"/workspace/",
"/workspaces/",
"/root/",
"/srv/",
"/data/",
];

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 pathSegmentCount(path: string) {
return path.split("/").filter(Boolean).length;
}

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("/")) {
if (FILE_LINE_SUFFIX_PATTERN.test(trimmed)) {
return true;
}
if (hasLikelyFileName(trimmed)) {
return true;
}
return hasLikelyLocalAbsolutePrefix(trimmed) && pathSegmentCount(trimmed) >= 3;
Comment on lines +276 to +279

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid classifying deep /workspace routes as file links

In isLikelyFileHref, absolute links under /workspace/ are treated as file paths once they have 3+ path segments, so a markdown link like /workspace/settings/profile is routed through onOpenFileLink instead of normal anchor navigation. In Messages, this means route-style links can be intercepted and fail with the file opener path, even though this change is intended to preserve non-file relative links as normal anchors.

Useful? React with 👍 / 👎.

}
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/);
const urls = lines
Expand Down Expand Up @@ -474,9 +619,29 @@ export function Markdown({
}
return trimmed;
};
const resolveHrefFilePath = (url: string) => {
if (isLikelyFileHref(url)) {
const directPath = getLinkablePath(url);
if (directPath) {
return safeDecodeURIComponent(directPath) ?? 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/")
Expand All @@ -497,7 +662,20 @@ export function Markdown({
);
}
if (isFileLinkUrl(url)) {
const path = decodeFileLink(url);
const path = safeDecodeFileLink(url);
if (!path) {
return (
<a
href={href}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
}}
>
{children}
</a>
);
}
return (
<FileReferenceLink
href={href ?? toFileLink(path)}
Expand All @@ -509,6 +687,24 @@ 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 (
<a
href={href ?? toFileLink(hrefFilePath)}
onClick={clickHandler}
onContextMenu={contextMenuHandler}
>
{children}
</a>
);
}
const isExternal =
url.startsWith("http://") ||
url.startsWith("https://") ||
Expand Down
Loading
Loading