Move file autolinks out of MarkdownBody

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-06-09 21:35:48 +00:00
1 parent e8b709e744
commit 5b6fc549a9
5 files changed
+87 -46

No files matched your search

+3 -3
View File
@@ -89,6 +89,7 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { MarkdownBody } from "./MarkdownBody";
import { WorkspaceFileMarkdownBody } from "./WorkspaceFileMarkdownBody";
import { MarkdownEditor, type MentionOption, type MarkdownEditorRef } from "./MarkdownEditor";
import { Identity } from "./Identity";
import { InlineEntitySelector, type InlineEntityOption } from "./InlineEntitySelector";
@@ -669,15 +670,14 @@ const IssueChatTextPart = memo(function IssueChatTextPart({ text, recessed }: {
return <SuccessfulRunHandoffCommentCallout text={text} recessed={recessed} onImageClick={onImageClick} />;
}
return (
<MarkdownBody
<WorkspaceFileMarkdownBody
className="text-sm leading-6"
style={recessed ? { opacity: 0.55 } : undefined}
softBreaks
linkWorkspaceFileReferences
onImageClick={onImageClick}
>
{text}
</MarkdownBody>
</WorkspaceFileMarkdownBody>
);
});
-19
View File
@@ -28,7 +28,6 @@ vi.mock("@/lib/router", () => ({
}: { children: ReactNode; to: string } & React.ComponentProps<"a">) => (
<a href={to} {...props}>{children}</a>
),
useLocation: () => ({ pathname: "/", search: "", hash: "" }),
}));
vi.mock("../api/issues", () => ({
@@ -534,22 +533,4 @@ describe("MarkdownBody", () => {
expect(html).toContain('href="/issues/ACME-1"');
});
it("renders workspace file refs in inline code as workspace file links when enabled", () => {
const html = renderToStaticMarkup(
<QueryClientProvider client={new QueryClient()}>
<ThemeProvider>
<MarkdownBody linkWorkspaceFileReferences>
{"Check `ui/src/pages/IssueDetail.tsx:42` please."}
</MarkdownBody>
</ThemeProvider>
</QueryClientProvider>,
);
expect(html).toContain('data-workspace-file-link="true"');
expect(html).toContain("ui/src/pages/IssueDetail.tsx:42");
});
it("does not create workspace file links when the flag is off", () => {
const html = renderMarkdown("See `ui/src/pages/IssueDetail.tsx:42` for details.");
expect(html).not.toContain("data-workspace-file-link");
});
});
-24
View File
@@ -12,13 +12,7 @@ import { issuesApi } from "../api/issues";
import { queryKeys } from "../lib/queryKeys";
import { parseIssueReferenceFromHref, remarkLinkIssueReferences } from "../lib/issue-reference";
import { remarkSoftBreaks } from "../lib/remark-soft-breaks";
import {
parseWorkspaceFileHref,
remarkWorkspaceFileRefs,
WORKSPACE_FILE_HREF_PREFIX,
} from "../lib/remark-workspace-file-refs";
import { StatusIcon } from "./StatusIcon";
import { WorkspaceFileLink } from "./WorkspaceFileLink";
interface MarkdownBodyProps {
children: string;
@@ -32,12 +26,6 @@ interface MarkdownBodyProps {
wikiLinkRoot?: string;
/** Optional href resolver for wikilinks. Return null to leave a token as plain text. */
resolveWikiLinkHref?: (target: string, label: string) => string | null | undefined;
/**
* When true, auto-link workspace file references inside inline code spans
* (e.g. `ui/src/pages/IssueDetail.tsx:42`). Requires a FileViewerProvider
* ancestor so that clicking a link opens the viewer.
*/
linkWorkspaceFileReferences?: boolean;
/** Optional resolver for relative image paths (e.g. within export packages) */
resolveImageSrc?: (src: string) => string | null;
/** Called when a user clicks an inline image */
@@ -172,7 +160,6 @@ function extractMermaidSource(children: ReactNode): string | null {
}
function safeMarkdownUrlTransform(url: string): string {
if (url.startsWith(WORKSPACE_FILE_HREF_PREFIX)) return url;
return parseMentionChipHref(url) ? url : defaultUrlTransform(url);
}
@@ -580,7 +567,6 @@ export function MarkdownBody({
enableWikiLinks = false,
wikiLinkRoot,
resolveWikiLinkHref,
linkWorkspaceFileReferences = false,
resolveImageSrc,
onImageClick,
}: MarkdownBodyProps) {
@@ -599,9 +585,6 @@ export function MarkdownBody({
if (linkIssueReferences) {
remarkPlugins.push([remarkLinkIssueReferences, { knownPrefixes }]);
}
if (linkWorkspaceFileReferences) {
remarkPlugins.push(remarkWorkspaceFileRefs);
}
if (softBreaks) {
remarkPlugins.push(remarkSoftBreaks);
}
@@ -653,13 +636,6 @@ export function MarkdownBody({
a: ({ node: _node, href, style: linkStyle, children: linkChildren, ...anchorProps }) => {
const dataProps = anchorProps as Record<string, unknown>;
const isWikiLink = dataProps["data-paperclip-wiki-link"] === "true";
if (linkWorkspaceFileReferences) {
const workspaceFileRef = parseWorkspaceFileHref(href);
if (workspaceFileRef) {
return <WorkspaceFileLink workspaceFileRef={workspaceFileRef} />;
}
}
if (isWikiLink && href && !/^[a-z][a-z\d+.-]*:/i.test(href) && !href.startsWith("//")) {
return (
<Link
@@ -0,0 +1,22 @@
// @vitest-environment node
import { describe, expect, it } from "vitest";
import { linkWorkspaceFileInlineCode } from "./WorkspaceFileMarkdownBody";
describe("linkWorkspaceFileInlineCode", () => {
it("links workspace file refs in inline code to the current issue file viewer", () => {
const markdown = linkWorkspaceFileInlineCode(
"Check `ui/src/pages/IssueDetail.tsx:42` please.",
"/issues/PAP-1",
"?tab=chat",
"#comment-1",
);
expect(markdown).toContain("[`ui/src/pages/IssueDetail.tsx:42`](");
expect(markdown).toContain("/issues/PAP-1?tab=chat&file=ui%2Fsrc%2Fpages%2FIssueDetail.tsx&line=42#comment-1");
});
it("leaves non-file inline code unchanged", () => {
expect(linkWorkspaceFileInlineCode("Run `pnpm test`.", "/issues/PAP-1", "", "")).toBe("Run `pnpm test`.");
});
});
@@ -0,0 +1,62 @@
import type { MouseEvent } from "react";
import { useMemo } from "react";
import { useLocation } from "@/lib/router";
import { readFileViewerStateFromSearch, useFileViewer, writeFileViewerStateToSearch } from "@/context/FileViewerContext";
import { parseWorkspaceFileRef } from "@/lib/workspace-file-parser";
import { MarkdownBody } from "./MarkdownBody";
type MarkdownBodyProps = Parameters<typeof MarkdownBody>[0];
const INLINE_CODE_RE = /`([^`\r\n]+)`/g;
function escapeMarkdownLinkLabel(value: string) {
return value.replace(/([\\\]])/g, "\\$1");
}
export function linkWorkspaceFileInlineCode(markdown: string, currentPathname: string, currentSearch: string, currentHash: string) {
return markdown.replace(INLINE_CODE_RE, (token, rawCode: string) => {
const ref = parseWorkspaceFileRef(rawCode);
if (!ref) return token;
const nextSearch = writeFileViewerStateToSearch(currentSearch, {
path: ref.path,
line: ref.line,
column: ref.column,
workspace: "auto",
});
const href = `${currentPathname}${nextSearch}${currentHash}`;
return `[\`${escapeMarkdownLinkLabel(ref.raw)}\`](${href})`;
});
}
export function WorkspaceFileMarkdownBody({
children,
...props
}: MarkdownBodyProps) {
const location = useLocation();
const viewer = useFileViewer();
const linkedMarkdown = useMemo(
() => linkWorkspaceFileInlineCode(children, location.pathname, location.search, location.hash),
[children, location.hash, location.pathname, location.search],
);
const handleClick = (event: MouseEvent<HTMLDivElement>) => {
if (!viewer) return;
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
const anchor = (event.target as HTMLElement | null)?.closest("a");
if (!anchor) return;
const url = new URL(anchor.href, window.location.href);
if (url.origin !== window.location.origin || url.pathname !== location.pathname) return;
const next = readFileViewerStateFromSearch(url.search);
if (!next) return;
event.preventDefault();
viewer.open(next);
};
return (
<div onClick={handleClick}>
<MarkdownBody {...props}>{linkedMarkdown}</MarkdownBody>
</div>
);
}