From 165116bd6f48ea2dbeaa7a8cfbbfa411c46e07cc Mon Sep 17 00:00:00 2001 From: Glow <139084205+glowdotjpeg@users.noreply.github.com> Date: Sat, 25 Jul 2026 17:32:45 -0400 Subject: [PATCH] fix(web): open Windows markdown file links --- apps/web/src/components/ChatMarkdown.tsx | 42 +++++++++++----- apps/web/src/markdown-links.test.ts | 64 ++++++++++++++++++++++++ apps/web/src/markdown-links.ts | 61 ++++++++++++++++++++++ 3 files changed, 154 insertions(+), 13 deletions(-) diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index fac3bf7d245..c03b5fb92db 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -66,7 +66,9 @@ import { } from "../markdown-clipboard"; import { remarkNormalizeListItemIndentation } from "../markdown-list-indentation"; import { - normalizeMarkdownLinkDestination, + extractMarkdownLinkDestinationAtOffsets, + normalizeMarkdownFileLinkHref, + preserveMarkdownFileLinkHref, resolveMarkdownFileLinkMeta, rewriteMarkdownFileUriHref, } from "../markdown-links"; @@ -826,11 +828,6 @@ function extractMarkdownLinkHrefs(text: string): string[] { return hrefs; } -function normalizeMarkdownLinkHrefKey(href: string): string { - const normalizedHref = normalizeMarkdownLinkDestination(href); - return rewriteMarkdownFileUriHref(normalizedHref) ?? normalizedHref; -} - const MARKDOWN_LINK_FAVICON_CLASS_NAME = "block size-full shrink-0 select-none"; /** Hosts whose favicon request already failed this session — skip straight to the globe. */ @@ -1272,7 +1269,7 @@ function ChatMarkdown({ NonNullable> >(); for (const href of extractMarkdownLinkHrefs(text)) { - const normalizedHref = normalizeMarkdownLinkHrefKey(href); + const normalizedHref = normalizeMarkdownFileLinkHref(href); if (metaByHref.has(normalizedHref)) continue; const meta = resolveMarkdownFileLinkMeta(normalizedHref, cwd); if (meta) { @@ -1285,9 +1282,13 @@ function ChatMarkdown({ const filePaths = [...markdownFileLinkMetaByHref.values()].map((meta) => meta.filePath); return buildFileLinkParentSuffixByPath(filePaths); }, [markdownFileLinkMetaByHref]); - const markdownUrlTransform = useCallback((href: string) => { - return rewriteMarkdownFileUriHref(href) ?? defaultUrlTransform(href); - }, []); + const markdownUrlTransform = useCallback( + (href: string) => + rewriteMarkdownFileUriHref(href) ?? + preserveMarkdownFileLinkHref(href, cwd) ?? + defaultUrlTransform(href), + [cwd], + ); // Re-emit highlighted content as markdown so copying out of the rendered // view keeps links, emphasis, lists, and code fences intact. const handleCopy = useCallback((event: ReactClipboardEvent) => { @@ -1384,8 +1385,22 @@ function ChatMarkdown({ ); }, a({ node, href, children, ...props }) { - const normalizedHref = href ? normalizeMarkdownLinkHrefKey(href) : ""; - const fileLinkMeta = normalizedHref ? markdownFileLinkMetaByHref.get(normalizedHref) : null; + const normalizedHref = href ? normalizeMarkdownFileLinkHref(href) : ""; + const sourceHref = extractMarkdownLinkDestinationAtOffsets( + text, + node?.position?.start.offset, + node?.position?.end.offset, + ); + const normalizedSourceHref = sourceHref ? normalizeMarkdownFileLinkHref(sourceHref) : ""; + const fileLinkMeta = + (normalizedHref + ? (markdownFileLinkMetaByHref.get(normalizedHref) ?? + resolveMarkdownFileLinkMeta(normalizedHref, cwd)) + : null) ?? + (normalizedSourceHref + ? (markdownFileLinkMetaByHref.get(normalizedSourceHref) ?? + resolveMarkdownFileLinkMeta(normalizedSourceHref, cwd)) + : null); if (!fileLinkMeta) { const faviconHost = resolveExternalWebLinkHost(href); const isSameDocumentLink = href?.startsWith("#") ?? false; @@ -1475,7 +1490,7 @@ function ChatMarkdown({ workspaceRelativePath={fileLinkMeta.workspaceRelativePath} line={fileLinkMeta.line} label={labelParts.join(" · ")} - copyMarkdown={`[${fileLinkMeta.basename}](${normalizedHref})`} + copyMarkdown={`[${fileLinkMeta.basename}](${normalizedSourceHref || normalizedHref})`} theme={resolvedTheme} threadRef={threadRef} onOpen={openInPreferredEditor} @@ -1527,6 +1542,7 @@ function ChatMarkdown({ }), [ diffThemeName, + cwd, fileLinkParentSuffixByPath, isStreaming, markdownFileLinkMetaByHref, diff --git a/apps/web/src/markdown-links.test.ts b/apps/web/src/markdown-links.test.ts index 5691ffa8895..d4ea3aa6103 100644 --- a/apps/web/src/markdown-links.test.ts +++ b/apps/web/src/markdown-links.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from "vite-plus/test"; import { + extractMarkdownLinkDestinationAtOffsets, + normalizeMarkdownFileLinkHref, + preserveMarkdownFileLinkHref, resolveMarkdownFileLinkMeta, resolveMarkdownFileLinkTarget, rewriteMarkdownFileUriHref, @@ -127,3 +130,64 @@ describe("resolveMarkdownFileLinkTarget", () => { expect(resolveMarkdownFileLinkTarget("/chat/settings")).toBeNull(); }); }); + +describe("markdown file href compatibility", () => { + it("recovers a raw Windows destination from a parsed link node range", () => { + const markdown = "Created [markdown-links.ts](C:\\Users\\aydri\\repo\\src\\markdown-links.ts)."; + const startOffset = markdown.indexOf("["); + const endOffset = markdown.indexOf(").") + 1; + + expect(extractMarkdownLinkDestinationAtOffsets(markdown, startOffset, endOffset)).toBe( + "C:\\Users\\aydri\\repo\\src\\markdown-links.ts", + ); + }); + + it("recovers an angle-wrapped destination containing spaces", () => { + const markdown = 'See [report]( "Report").'; + const startOffset = markdown.indexOf("["); + const endOffset = markdown.indexOf(").") + 1; + + expect(extractMarkdownLinkDestinationAtOffsets(markdown, startOffset, endOffset)).toBe( + "", + ); + }); + + it("preserves windows file destinations that React Markdown would otherwise sanitize", () => { + const href = String.raw`C:\Users\aydri\Documents\GitHub\t3code\AVA_HANDOFF.md`; + + expect(preserveMarkdownFileLinkHref(href)).toBe(href); + }); + + it("preserves workspace file destinations containing spaces", () => { + expect( + preserveMarkdownFileLinkHref( + "C:/Users/Test User/repo/AVA_HANDOFF.md", + "C:/Users/Test User/repo", + ), + ).toBe("C:/Users/Test User/repo/AVA_HANDOFF.md"); + }); + + it("does not bypass normal URL sanitization for external schemes", () => { + expect(preserveMarkdownFileLinkHref("javascript:report.md", "/repo")).toBeNull(); + }); + + it("resolves the encoded href received by the rendered anchor", () => { + expect( + resolveMarkdownFileLinkMeta( + "C:%5CUsers%5Caydri%5CDocuments%5CGitHub%5Ct3code%5CAVA_HANDOFF.md", + String.raw`C:\Users\aydri\Documents\GitHub\t3code`, + ), + ).toMatchObject({ + basename: "AVA_HANDOFF.md", + workspaceRelativePath: "AVA_HANDOFF.md", + }); + }); + + it("normalizes file URI aliases before matching known file metadata", () => { + expect( + normalizeMarkdownFileLinkHref( + "file:///C:/Users/aydri/Documents/GitHub/t3code/AVA_HANDOFF.md#L12", + ), + ).toBe("C:/Users/aydri/Documents/GitHub/t3code/AVA_HANDOFF.md#L12"); + }); +}); diff --git a/apps/web/src/markdown-links.ts b/apps/web/src/markdown-links.ts index 1e24de8bb1d..87b2483096d 100644 --- a/apps/web/src/markdown-links.ts +++ b/apps/web/src/markdown-links.ts @@ -92,6 +92,62 @@ export function rewriteMarkdownFileUriHref(href: string | undefined): string | n return `${target.path}${target.hash}`; } +export function normalizeMarkdownFileLinkHref(href: string): string { + const normalizedHref = normalizeMarkdownLinkDestination(href); + return rewriteMarkdownFileUriHref(normalizedHref) ?? normalizedHref; +} + +export function extractMarkdownLinkDestinationAtOffsets( + markdown: string, + startOffset: number | undefined, + endOffset: number | undefined, +): string | null { + if ( + !Number.isSafeInteger(startOffset) || + !Number.isSafeInteger(endOffset) || + startOffset === undefined || + endOffset === undefined || + startOffset < 0 || + endOffset <= startOffset || + endOffset > markdown.length + ) { + return null; + } + + const source = markdown.slice(startOffset, endOffset); + const destinationMarker = source.lastIndexOf("]("); + if (destinationMarker < 0 || !source.endsWith(")")) return null; + + const destinationSource = source.slice(destinationMarker + 2, -1).trimStart(); + if (destinationSource.startsWith("<")) { + const closingBracket = destinationSource.indexOf(">"); + return closingBracket > 0 ? destinationSource.slice(0, closingBracket + 1) : null; + } + + let nestedParentheses = 0; + for (let index = 0; index < destinationSource.length; index += 1) { + const character = destinationSource[index]; + if (character === "\\") { + index += 1; + continue; + } + if (character === "(") { + nestedParentheses += 1; + continue; + } + if (character === ")") { + if (nestedParentheses === 0) return destinationSource.slice(0, index); + nestedParentheses -= 1; + continue; + } + if (/\s/.test(character ?? "") && nestedParentheses === 0) { + return destinationSource.slice(0, index); + } + } + + return destinationSource || null; +} + function looksLikePosixFilesystemPath(path: string): boolean { if (!path.startsWith("/")) return false; if (POSIX_FILE_ROOT_PREFIXES.some((prefix) => path.startsWith(prefix))) return true; @@ -170,6 +226,11 @@ export function resolveMarkdownFileLinkTarget( return resolvePathLinkTarget(pathWithPosition, cwd); } +export function preserveMarkdownFileLinkHref(href: string, cwd?: string): string | null { + const normalizedHref = normalizeMarkdownLinkDestination(href); + return resolveMarkdownFileLinkTarget(normalizedHref, cwd) ? normalizedHref : null; +} + function basenameOfPath(path: string): string { const separatorIndex = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\")); return separatorIndex >= 0 ? path.slice(separatorIndex + 1) : path;