authorgravatar for 76637177+SunkenInTime@users.noreply.github.comDara Adedeji <76637177+SunkenInTime@users.noreply.github.com> 2026-08-22 05:15:11-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-22 17:45:11+05:30
logf34b9d31b3ddbd53a553141fd769721fca4b8eb5
tree4759138508dac7fb5637e30336f89b6575bc9631
parentce91284f832fc71bf46ff2a8ffc290e20db4e83d
signature Signed by PGP key B5690EEEBB952194

fix(web): command-click spaced folder links (#6439)

Co-authored-by: shivam <91240327+shivamhwp@users.noreply.github.com>

3 files changed, 121 insertions(+), 14 deletions(-)

apps/web/src/components/ChatMarkdown.tsx+10-12
......@@ -74,10 +74,12 @@ import {
7474} from "../markdown-clipboard";
7575import { remarkNormalizeListItemIndentation } from "../markdown-list-indentation";
7676import {
77 extractMarkdownLinkHrefs,
7778 normalizeMarkdownLinkDestination,
7879 resolveInlineCodeFileLinkMeta,
7980 resolveMarkdownFileLinkMeta,
8081 rewriteMarkdownFileUriHref,
82 shouldOpenMarkdownFileLinkInEditor,
8183 type MarkdownFileLinkMeta,
8284} from "../markdown-links";
8385import { readLocalApi } from "../localApi";
......@@ -825,7 +827,6 @@ interface MarkdownFileLinkProps {
825827 className?: string | undefined;
826828}
827829
828const MARKDOWN_LINK_HREF_PATTERN = /\[[^\]]*]\(([^)\s]+)(?:\s+["'][^"']*["'])?\)/g;
829830const MARKDOWN_FILE_LINK_CLASS_NAME =
830831 "chat-markdown-file-link cursor-pointer transition-colors hover:bg-accent/70";
831832
......@@ -904,16 +905,6 @@ function extractInlineCodeSpans(text: string): string[] {
904905 return spans;
905906}
906907
907function extractMarkdownLinkHrefs(text: string): string[] {
908 const hrefs: string[] = [];
909 for (const match of text.matchAll(MARKDOWN_LINK_HREF_PATTERN)) {
910 const href = match[1]?.trim();
911 if (!href) continue;
912 hrefs.push(href);
913 }
914 return hrefs;
915}
916
917908function normalizeMarkdownLinkHrefKey(href: string): string {
918909 const normalizedHref = normalizeMarkdownLinkDestination(href);
919910 return rewriteMarkdownFileUriHref(normalizedHref) ?? normalizedHref;
......@@ -1307,6 +1298,10 @@ const MarkdownFileLink = memo(function MarkdownFileLink({
13071298 onClick={(event) => {
13081299 event.preventDefault();
13091300 event.stopPropagation();
1301 if (shouldOpenMarkdownFileLinkInEditor(event)) {
1302 handleOpenInEditor();
1303 return;
1304 }
13101305 if (onOpenInBrowser) {
13111306 handleOpenInBrowser();
13121307 return;
......@@ -1629,7 +1624,10 @@ function ChatMarkdown({
16291624 },
16301625 a({ node, href, children, title: _title, ...props }) {
16311626 const normalizedHref = href ? normalizeMarkdownLinkHrefKey(href) : "";
1632 const fileLinkMeta = normalizedHref ? markdownFileLinkMetaByHref.get(normalizedHref) : null;
1627 const fileLinkMeta = normalizedHref
1628 ? (markdownFileLinkMetaByHref.get(normalizedHref) ??
1629 resolveMarkdownFileLinkMeta(normalizedHref, cwd))
1630 : null;
16331631 if (!fileLinkMeta) {
16341632 const faviconHost = resolveExternalWebLinkHost(href);
16351633 const isSameDocumentLink = href?.startsWith("#") ?? false;
apps/web/src/markdown-links.test.ts+86
......@@ -1,12 +1,74 @@
11import { describe, expect, it } from "vite-plus/test";
2import { createElement } from "react";
3import { renderToStaticMarkup } from "react-dom/server";
4import ReactMarkdown from "react-markdown";
25
36import {
7 extractMarkdownLinkHrefs,
48 resolveInlineCodeFileLinkMeta,
59 resolveMarkdownFileLinkMeta,
610 resolveMarkdownFileLinkTarget,
711 rewriteMarkdownFileUriHref,
12 shouldOpenMarkdownFileLinkInEditor,
813} from "./markdown-links";
914
15function renderMarkdownLinkHref(markdown: string): string | undefined {
16 let renderedHref: string | undefined;
17 renderToStaticMarkup(
18 createElement(
19 ReactMarkdown,
20 {
21 components: {
22 a({ href }) {
23 renderedHref = href;
24 return createElement("a", { href });
25 },
26 },
27 },
28 markdown,
29 ),
30 );
31 return renderedHref;
32}
33
34describe("extractMarkdownLinkHrefs", () => {
35 it("extracts angle-bracketed paths containing spaces", () => {
36 expect(
37 extractMarkdownLinkHrefs(
38 "[Open the Bike Receipts folder](</Users/dara/Downloads/Lime Ride Artifacts/Bike Receipts>)",
39 ),
40 ).toEqual(["/Users/dara/Downloads/Lime Ride Artifacts/Bike Receipts"]);
41 });
42
43 it("preserves ordinary destinations and ignores link titles", () => {
44 expect(
45 extractMarkdownLinkHrefs(
46 '[source](apps/web/src/markdown-links.ts "implementation") and [docs](https://example.com)',
47 ),
48 ).toEqual(["apps/web/src/markdown-links.ts", "https://example.com"]);
49 });
50});
51
52describe("shouldOpenMarkdownFileLinkInEditor", () => {
53 it("uses command-click on macOS", () => {
54 expect(shouldOpenMarkdownFileLinkInEditor({ metaKey: true, ctrlKey: false }, "MacIntel")).toBe(
55 true,
56 );
57 expect(shouldOpenMarkdownFileLinkInEditor({ metaKey: false, ctrlKey: true }, "MacIntel")).toBe(
58 false,
59 );
60 });
61
62 it("uses control-click on other platforms", () => {
63 expect(
64 shouldOpenMarkdownFileLinkInEditor({ metaKey: false, ctrlKey: true }, "Linux x86_64"),
65 ).toBe(true);
66 expect(
67 shouldOpenMarkdownFileLinkInEditor({ metaKey: true, ctrlKey: false }, "Linux x86_64"),
68 ).toBe(false);
69 });
70});
71
1072describe("rewriteMarkdownFileUriHref", () => {
1173 it("rewrites file uri hrefs into direct path hrefs", () => {
1274 expect(rewriteMarkdownFileUriHref("file:///Users/julius/project/src/main.ts#L42")).toBe(
......@@ -88,6 +150,30 @@ describe("resolveMarkdownFileLinkTarget", () => {
88150 });
89151 });
90152
153 it("resolves the encoded spaces emitted by the markdown renderer", () => {
154 expect(
155 resolveMarkdownFileLinkMeta(
156 "/Users/dara/Downloads/Lime%20Ride%20Artifacts/Bike%20Receipts",
157 "/Users/dara/Downloads/Lime Ride Artifacts",
158 ),
159 ).toMatchObject({
160 targetPath: "/Users/dara/Downloads/Lime Ride Artifacts/Bike Receipts",
161 workspaceRelativePath: "Bike Receipts",
162 basename: "Bike Receipts",
163 });
164 });
165
166 it("resolves relative spaced folders from the markdown renderer", () => {
167 const href = renderMarkdownLinkHref("[folder](<docs/My Folder>)");
168
169 expect(href).toBe("docs/My%20Folder");
170 expect(resolveMarkdownFileLinkMeta(href, "/repo/project")).toMatchObject({
171 targetPath: "/repo/project/docs/My Folder",
172 workspaceRelativePath: "docs/My Folder",
173 basename: "My Folder",
174 });
175 });
176
91177 it("formats tooltip display paths relative to the cwd for slash-prefixed windows paths", () => {
92178 expect(
93179 resolveMarkdownFileLinkMeta(
apps/web/src/markdown-links.ts+25-2
......@@ -1,11 +1,16 @@
11import { formatWorkspaceRelativePath } from "./filePathDisplay";
2import { resolvePathLinkTarget, splitPathAndPosition } from "./terminal-links";
2import {
3 isTerminalLinkActivation,
4 resolvePathLinkTarget,
5 splitPathAndPosition,
6} from "./terminal-links";
37
48const WINDOWS_DRIVE_PATH_PATTERN = /^[A-Za-z]:[\\/]/;
59const WINDOWS_UNC_PATH_PATTERN = /^\\\\/;
610const EXTERNAL_SCHEME_PATTERN = /^([A-Za-z][A-Za-z0-9+.-]*):(.*)$/;
711const RELATIVE_PATH_PREFIX_PATTERN = /^(~\/|\.{1,2}\/)/;
8const RELATIVE_FILE_PATH_PATTERN = /^[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)+(?::\d+){0,2}$/;
12const RELATIVE_FILE_PATH_PATTERN =
13 /^(?:[A-Za-z0-9._-]+(?: +[A-Za-z0-9._-]+)*\/)+[A-Za-z0-9._-]+(?: +[A-Za-z0-9._-]+)*(?::\d+){0,2}$/;
914const RELATIVE_FILE_NAME_PATTERN = /^[A-Za-z0-9._-]+\.[A-Za-z0-9_-]+(?::\d+){0,2}$/;
1015const POSITION_SUFFIX_PATTERN = /:\d+(?::\d+)?$/;
1116const POSITION_ONLY_PATTERN = /^\d+(?::\d+)?$/;
......@@ -37,6 +42,8 @@ const POSIX_FILE_ROOT_PREFIXES = [
3742 "/workspace/",
3843 "/workspaces/",
3944] as const;
45const MARKDOWN_LINK_HREF_PATTERN =
46 /\[[^\]]*]\(\s*(?:<([^>\n]+)>|([^\s)]+))(?:\s+["'][^"']*["'])?\s*\)/g;
4047
4148export interface MarkdownFileLinkMeta {
4249 filePath: string;
......@@ -48,6 +55,22 @@ export interface MarkdownFileLinkMeta {
4855 column?: number;
4956}
5057
58export function extractMarkdownLinkHrefs(markdown: string): string[] {
59 const hrefs: string[] = [];
60 for (const match of markdown.matchAll(MARKDOWN_LINK_HREF_PATTERN)) {
61 const href = (match[1] ?? match[2])?.trim();
62 if (href) hrefs.push(href);
63 }
64 return hrefs;
65}
66
67export function shouldOpenMarkdownFileLinkInEditor(
68 event: Pick<MouseEvent, "metaKey" | "ctrlKey">,
69 platform?: string,
70): boolean {
71 return isTerminalLinkActivation(event, platform);
72}
73
5174function safeDecode(value: string): string {
5275 try {
5376 return decodeURIComponent(value);