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 {...@@ -74,10 +74,12 @@ import {
74} from "../markdown-clipboard";74} from "../markdown-clipboard";
75import { remarkNormalizeListItemIndentation } from "../markdown-list-indentation";75import { remarkNormalizeListItemIndentation } from "../markdown-list-indentation";
76import {76import {
77 extractMarkdownLinkHrefs,
77 normalizeMarkdownLinkDestination,78 normalizeMarkdownLinkDestination,
78 resolveInlineCodeFileLinkMeta,79 resolveInlineCodeFileLinkMeta,
79 resolveMarkdownFileLinkMeta,80 resolveMarkdownFileLinkMeta,
80 rewriteMarkdownFileUriHref,81 rewriteMarkdownFileUriHref,
82 shouldOpenMarkdownFileLinkInEditor,
81 type MarkdownFileLinkMeta,83 type MarkdownFileLinkMeta,
82} from "../markdown-links";84} from "../markdown-links";
83import { readLocalApi } from "../localApi";85import { readLocalApi } from "../localApi";
...@@ -825,7 +827,6 @@ interface MarkdownFileLinkProps {...@@ -825,7 +827,6 @@ interface MarkdownFileLinkProps {
825 className?: string | undefined;827 className?: string | undefined;
826}828}
827829
828const MARKDOWN_LINK_HREF_PATTERN = /\[[^\]]*]\(([^)\s]+)(?:\s+["'][^"']*["'])?\)/g;
829const MARKDOWN_FILE_LINK_CLASS_NAME =830const MARKDOWN_FILE_LINK_CLASS_NAME =
830 "chat-markdown-file-link cursor-pointer transition-colors hover:bg-accent/70";831 "chat-markdown-file-link cursor-pointer transition-colors hover:bg-accent/70";
831832
...@@ -904,16 +905,6 @@ function extractInlineCodeSpans(text: string): string[] {...@@ -904,16 +905,6 @@ function extractInlineCodeSpans(text: string): string[] {
904 return spans;905 return spans;
905}906}
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
917function normalizeMarkdownLinkHrefKey(href: string): string {908function normalizeMarkdownLinkHrefKey(href: string): string {
918 const normalizedHref = normalizeMarkdownLinkDestination(href);909 const normalizedHref = normalizeMarkdownLinkDestination(href);
919 return rewriteMarkdownFileUriHref(normalizedHref) ?? normalizedHref;910 return rewriteMarkdownFileUriHref(normalizedHref) ?? normalizedHref;
...@@ -1307,6 +1298,10 @@ const MarkdownFileLink = memo(function MarkdownFileLink({...@@ -1307,6 +1298,10 @@ const MarkdownFileLink = memo(function MarkdownFileLink({
1307 onClick={(event) => {1298 onClick={(event) => {
1308 event.preventDefault();1299 event.preventDefault();
1309 event.stopPropagation();1300 event.stopPropagation();
1301 if (shouldOpenMarkdownFileLinkInEditor(event)) {
1302 handleOpenInEditor();
1303 return;
1304 }
1310 if (onOpenInBrowser) {1305 if (onOpenInBrowser) {
1311 handleOpenInBrowser();1306 handleOpenInBrowser();
1312 return;1307 return;
...@@ -1629,7 +1624,10 @@ function ChatMarkdown({...@@ -1629,7 +1624,10 @@ function ChatMarkdown({
1629 },1624 },
1630 a({ node, href, children, title: _title, ...props }) {1625 a({ node, href, children, title: _title, ...props }) {
1631 const normalizedHref = href ? normalizeMarkdownLinkHrefKey(href) : "";1626 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;
1633 if (!fileLinkMeta) {1631 if (!fileLinkMeta) {
1634 const faviconHost = resolveExternalWebLinkHost(href);1632 const faviconHost = resolveExternalWebLinkHost(href);
1635 const isSameDocumentLink = href?.startsWith("#") ?? false;1633 const isSameDocumentLink = href?.startsWith("#") ?? false;
apps/web/src/markdown-links.test.ts+86
...@@ -1,12 +1,74 @@...@@ -1,12 +1,74 @@
1import { describe, expect, it } from "vite-plus/test";1import { describe, expect, it } from "vite-plus/test";
2import { createElement } from "react";
3import { renderToStaticMarkup } from "react-dom/server";
4import ReactMarkdown from "react-markdown";
25
3import {6import {
7 extractMarkdownLinkHrefs,
4 resolveInlineCodeFileLinkMeta,8 resolveInlineCodeFileLinkMeta,
5 resolveMarkdownFileLinkMeta,9 resolveMarkdownFileLinkMeta,
6 resolveMarkdownFileLinkTarget,10 resolveMarkdownFileLinkTarget,
7 rewriteMarkdownFileUriHref,11 rewriteMarkdownFileUriHref,
12 shouldOpenMarkdownFileLinkInEditor,
8} from "./markdown-links";13} 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
10describe("rewriteMarkdownFileUriHref", () => {72describe("rewriteMarkdownFileUriHref", () => {
11 it("rewrites file uri hrefs into direct path hrefs", () => {73 it("rewrites file uri hrefs into direct path hrefs", () => {
12 expect(rewriteMarkdownFileUriHref("file:///Users/julius/project/src/main.ts#L42")).toBe(74 expect(rewriteMarkdownFileUriHref("file:///Users/julius/project/src/main.ts#L42")).toBe(
...@@ -88,6 +150,30 @@ describe("resolveMarkdownFileLinkTarget", () => {...@@ -88,6 +150,30 @@ describe("resolveMarkdownFileLinkTarget", () => {
88 });150 });
89 });151 });
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
91 it("formats tooltip display paths relative to the cwd for slash-prefixed windows paths", () => {177 it("formats tooltip display paths relative to the cwd for slash-prefixed windows paths", () => {
92 expect(178 expect(
93 resolveMarkdownFileLinkMeta(179 resolveMarkdownFileLinkMeta(
apps/web/src/markdown-links.ts+25-2
...@@ -1,11 +1,16 @@...@@ -1,11 +1,16 @@
1import { formatWorkspaceRelativePath } from "./filePathDisplay";1import { formatWorkspaceRelativePath } from "./filePathDisplay";
2import { resolvePathLinkTarget, splitPathAndPosition } from "./terminal-links";2import {
3 isTerminalLinkActivation,
4 resolvePathLinkTarget,
5 splitPathAndPosition,
6} from "./terminal-links";
37
4const WINDOWS_DRIVE_PATH_PATTERN = /^[A-Za-z]:[\\/]/;8const WINDOWS_DRIVE_PATH_PATTERN = /^[A-Za-z]:[\\/]/;
5const WINDOWS_UNC_PATH_PATTERN = /^\\\\/;9const WINDOWS_UNC_PATH_PATTERN = /^\\\\/;
6const EXTERNAL_SCHEME_PATTERN = /^([A-Za-z][A-Za-z0-9+.-]*):(.*)$/;10const EXTERNAL_SCHEME_PATTERN = /^([A-Za-z][A-Za-z0-9+.-]*):(.*)$/;
7const RELATIVE_PATH_PREFIX_PATTERN = /^(~\/|\.{1,2}\/)/;11const 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}$/;
9const RELATIVE_FILE_NAME_PATTERN = /^[A-Za-z0-9._-]+\.[A-Za-z0-9_-]+(?::\d+){0,2}$/;14const RELATIVE_FILE_NAME_PATTERN = /^[A-Za-z0-9._-]+\.[A-Za-z0-9_-]+(?::\d+){0,2}$/;
10const POSITION_SUFFIX_PATTERN = /:\d+(?::\d+)?$/;15const POSITION_SUFFIX_PATTERN = /:\d+(?::\d+)?$/;
11const POSITION_ONLY_PATTERN = /^\d+(?::\d+)?$/;16const POSITION_ONLY_PATTERN = /^\d+(?::\d+)?$/;
...@@ -37,6 +42,8 @@ const POSIX_FILE_ROOT_PREFIXES = [...@@ -37,6 +42,8 @@ const POSIX_FILE_ROOT_PREFIXES = [
37 "/workspace/",42 "/workspace/",
38 "/workspaces/",43 "/workspaces/",
39] as const;44] as const;
45const MARKDOWN_LINK_HREF_PATTERN =
46 /\[[^\]]*]\(\s*(?:<([^>\n]+)>|([^\s)]+))(?:\s+["'][^"']*["'])?\s*\)/g;
4047
41export interface MarkdownFileLinkMeta {48export interface MarkdownFileLinkMeta {
42 filePath: string;49 filePath: string;
...@@ -48,6 +55,22 @@ export interface MarkdownFileLinkMeta {...@@ -48,6 +55,22 @@ export interface MarkdownFileLinkMeta {
48 column?: number;55 column?: number;
49}56}
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
51function safeDecode(value: string): string {74function safeDecode(value: string): string {
52 try {75 try {
53 return decodeURIComponent(value);76 return decodeURIComponent(value);