| author | |
| committer | |
| log | f34b9d31b3ddbd53a553141fd769721fca4b8eb5 |
| tree | 4759138508dac7fb5637e30336f89b6575bc9631 |
| parent | ce91284f832fc71bf46ff2a8ffc290e20db4e83d |
| signature | Signed by PGP key B5690EEEBB952194 |
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"; |
| 75 | import { remarkNormalizeListItemIndentation } from "../markdown-list-indentation"; | 75 | import { remarkNormalizeListItemIndentation } from "../markdown-list-indentation"; |
| 76 | import { | 76 | import { |
| 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"; |
| 83 | import { readLocalApi } from "../localApi"; | 85 | import { 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 | } |
| 827 | 829 | ||
| 828 | const MARKDOWN_LINK_HREF_PATTERN = /\[[^\]]*]\(([^)\s]+)(?:\s+["'][^"']*["'])?\)/g; | ||
| 829 | const MARKDOWN_FILE_LINK_CLASS_NAME = | 830 | const 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"; |
| 831 | 832 | ||
| ... | @@ -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 | } |
| 906 | 907 | ||
| 907 | function 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 | |||
| 917 | function normalizeMarkdownLinkHrefKey(href: string): string { | 908 | function 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 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | 1 | import { describe, expect, it } from "vite-plus/test"; |
| 2 | import { createElement } from "react"; | ||
| 3 | import { renderToStaticMarkup } from "react-dom/server"; | ||
| 4 | import ReactMarkdown from "react-markdown"; | ||
| 2 | 5 | ||
| 3 | import { | 6 | import { |
| 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"; |
| 9 | 14 | ||
| 15 | function 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 | |||
| 34 | describe("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 | |||
| 52 | describe("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 | |||
| 10 | describe("rewriteMarkdownFileUriHref", () => { | 72 | describe("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 | }); |
| 90 | 152 | ||
| 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 @@ |
| 1 | import { formatWorkspaceRelativePath } from "./filePathDisplay"; | 1 | import { formatWorkspaceRelativePath } from "./filePathDisplay"; |
| 2 | import { resolvePathLinkTarget, splitPathAndPosition } from "./terminal-links"; | 2 | import { |
| 3 | isTerminalLinkActivation, | ||
| 4 | resolvePathLinkTarget, | ||
| 5 | splitPathAndPosition, | ||
| 6 | } from "./terminal-links"; | ||
| 3 | 7 | ||
| 4 | const WINDOWS_DRIVE_PATH_PATTERN = /^[A-Za-z]:[\\/]/; | 8 | const WINDOWS_DRIVE_PATH_PATTERN = /^[A-Za-z]:[\\/]/; |
| 5 | const WINDOWS_UNC_PATH_PATTERN = /^\\\\/; | 9 | const WINDOWS_UNC_PATH_PATTERN = /^\\\\/; |
| 6 | const EXTERNAL_SCHEME_PATTERN = /^([A-Za-z][A-Za-z0-9+.-]*):(.*)$/; | 10 | const EXTERNAL_SCHEME_PATTERN = /^([A-Za-z][A-Za-z0-9+.-]*):(.*)$/; |
| 7 | const RELATIVE_PATH_PREFIX_PATTERN = /^(~\/|\.{1,2}\/)/; | 11 | const RELATIVE_PATH_PREFIX_PATTERN = /^(~\/|\.{1,2}\/)/; |
| 8 | const RELATIVE_FILE_PATH_PATTERN = /^[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)+(?::\d+){0,2}$/; | 12 | const RELATIVE_FILE_PATH_PATTERN = |
| 13 | /^(?:[A-Za-z0-9._-]+(?: +[A-Za-z0-9._-]+)*\/)+[A-Za-z0-9._-]+(?: +[A-Za-z0-9._-]+)*(?::\d+){0,2}$/; | ||
| 9 | const RELATIVE_FILE_NAME_PATTERN = /^[A-Za-z0-9._-]+\.[A-Za-z0-9_-]+(?::\d+){0,2}$/; | 14 | const RELATIVE_FILE_NAME_PATTERN = /^[A-Za-z0-9._-]+\.[A-Za-z0-9_-]+(?::\d+){0,2}$/; |
| 10 | const POSITION_SUFFIX_PATTERN = /:\d+(?::\d+)?$/; | 15 | const POSITION_SUFFIX_PATTERN = /:\d+(?::\d+)?$/; |
| 11 | const POSITION_ONLY_PATTERN = /^\d+(?::\d+)?$/; | 16 | const 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; |
| 45 | const MARKDOWN_LINK_HREF_PATTERN = | ||
| 46 | /\[[^\]]*]\(\s*(?:<([^>\n]+)>|([^\s)]+))(?:\s+["'][^"']*["'])?\s*\)/g; | ||
| 40 | 47 | ||
| 41 | export interface MarkdownFileLinkMeta { | 48 | export 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 | } |
| 50 | 57 | ||
| 58 | export 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 | |||
| 67 | export function shouldOpenMarkdownFileLinkInEditor( | ||
| 68 | event: Pick<MouseEvent, "metaKey" | "ctrlKey">, | ||
| 69 | platform?: string, | ||
| 70 | ): boolean { | ||
| 71 | return isTerminalLinkActivation(event, platform); | ||
| 72 | } | ||
| 73 | |||
| 51 | function safeDecode(value: string): string { | 74 | function safeDecode(value: string): string { |
| 52 | try { | 75 | try { |
| 53 | return decodeURIComponent(value); | 76 | return decodeURIComponent(value); |