From 878b954dad63520b0ffe1cd9b8a460e5fe2720ca Mon Sep 17 00:00:00 2001 From: Bilal Irfan Date: Tue, 3 Nov 2026 03:23:07 +0500 Subject: [PATCH 1/4] fix(web,mobile): inline file paths keep their full written label Co-Authored-By: Claude Opus 5.5 --- .../src/native/useEnrichedLinkVariants.ts | 11 +++++++--- apps/web/src/components/ChatMarkdown.tsx | 21 ++++++++++++------- packages/shared/src/threadFindText.test.ts | 6 ++++-- packages/shared/src/threadFindText.ts | 5 ++++- 4 files changed, 29 insertions(+), 14 deletions(-) diff --git a/apps/mobile/src/native/useEnrichedLinkVariants.ts b/apps/mobile/src/native/useEnrichedLinkVariants.ts index 789b3a0baeb9..f412bac63fbf 100644 --- a/apps/mobile/src/native/useEnrichedLinkVariants.ts +++ b/apps/mobile/src/native/useEnrichedLinkVariants.ts @@ -60,6 +60,7 @@ export function withChipIcon( function presentation( url: string, props: Pick, + inlineCode: boolean, ): MarkdownLinkCustomization | undefined { const custom = props.linkCustomization?.(url); if (custom) return custom; @@ -75,7 +76,8 @@ function presentation( const link = resolveMarkdownLinkPresentation(url); if (link.kind === "file") return { - label: link.label, + // Inline code becomes `[path]()`, so omitting the label keeps the written path. + ...(inlineCode ? {} : { label: link.label }), icon: markdownFileIconSource(link.icon), chip: { accent: contextChipPresentation("mention").accent, tintsIcon: false }, }; @@ -96,8 +98,11 @@ export function useEnrichedLinkVariants( ) { const { linkCustomization, skills, textStyle, fileEmbedLabel } = props; const theme = useUniwindTheme(); + // Joined so a streaming tick keeps `links` stable. + const inlineCodeUrlKey = urls.filter((url) => props.markdown.includes(`\`${url}\``)).join("\n"); const links = useMemo(() => { const byUrl = new Map(); + const inlineCodeUrls = new Set(inlineCodeUrlKey.split("\n")); for (const url of urls) { if (byUrl.has(url)) continue; const label = fileHrefs.has(url) ? fileEmbedLabel?.(url, false) : null; @@ -107,11 +112,11 @@ export function useEnrichedLinkVariants( color: textStyle.fileTextColor, icon: markdownFileIconSource(resolveMarkdownFileIcon(label)), } - : presentation(url, { linkCustomization, skills, textStyle }); + : presentation(url, { linkCustomization, skills, textStyle }, inlineCodeUrls.has(url)); if (value) byUrl.set(url, value); } return byUrl; - }, [urls, linkCustomization, skills, textStyle, fileHrefs, fileEmbedLabel]); + }, [urls, inlineCodeUrlKey, linkCustomization, skills, textStyle, fileHrefs, fileEmbedLabel]); const chipForegrounds = useMemo( () => new Map( diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index dd37be3b4b6a..ab416ad60e88 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -820,6 +820,7 @@ function useChatMarkdownState({ fileLinkMeta: MarkdownFileLinkMeta, copyMarkdown: string, mediaSource?: string, + label?: string, ) => { const resolveHostPath = () => resolveMarkdownFileHostPath({ @@ -845,14 +846,17 @@ function useChatMarkdownState({ workspaceRelativePath={fileLinkMeta.workspaceRelativePath} panelPath={panelPath} line={fileLinkMeta.line} - label={fileLinkLabel( - { - path: fileLinkMeta.filePath, - ...(fileLinkMeta.line !== undefined ? { line: fileLinkMeta.line } : {}), - ...(fileLinkMeta.column !== undefined ? { column: fileLinkMeta.column } : {}), - }, - fileLinkParentSuffixByPath, - )} + label={ + label ?? + fileLinkLabel( + { + path: fileLinkMeta.filePath, + ...(fileLinkMeta.line !== undefined ? { line: fileLinkMeta.line } : {}), + ...(fileLinkMeta.column !== undefined ? { column: fileLinkMeta.column } : {}), + }, + fileLinkParentSuffixByPath, + ) + } copyMarkdown={copyMarkdown} theme={resolvedTheme} threadRef={threadRef} @@ -1355,6 +1359,7 @@ const CHAT_MARKDOWN_COMPONENTS = { fileLinkMeta, `\`${codeText}\``, inlineCodeFilePathCandidate(codeText) ?? codeText.trim(), + codeText.trim(), ); } return ( diff --git a/packages/shared/src/threadFindText.test.ts b/packages/shared/src/threadFindText.test.ts index 77a0874c2133..fdc9e0758b41 100644 --- a/packages/shared/src/threadFindText.test.ts +++ b/packages/shared/src/threadFindText.test.ts @@ -73,8 +73,10 @@ const assistantSegments = (text: string, cwd?: string) => it("searches displayed prose and file-chip labels without hidden paths", () => { expect( - assistantSegments("[important description](/tmp/actual.ts). `/tmp/inline-example.ts:42`"), - ).toEqual(["important description actual.ts. inline-example.ts · L42"]); + assistantSegments( + "[important description](/tmp/actual.ts). `avatars/u1.webp` `/tmp/inline-example.ts:42`", + ), + ).toEqual(["important description actual.ts. avatars/u1.webp /tmp/inline-example.ts:42"]); expect(assistantSegments("[label](src/main.ts#L3C2)", "/workspace/repo")).toEqual([ "label main.ts · L3:C2", ]); diff --git a/packages/shared/src/threadFindText.ts b/packages/shared/src/threadFindText.ts index 3afec6e04729..9fbbe385b08f 100644 --- a/packages/shared/src/threadFindText.ts +++ b/packages/shared/src/threadFindText.ts @@ -164,7 +164,10 @@ function markdownThreadFindText( const label = labelText(node); if (node.tagName === "a" && candidate && !isMarkdownFileLinkLabel(label, candidate)) text += `${label} `; - text += fileLinkLabel(splitFilePathPosition(target), parentSuffixes); + text += + node.tagName === "code" + ? label.trim() + : fileLinkLabel(splitFilePathPosition(target), parentSuffixes); return; } } From 5ab2fdade0b84d6e65b69a60a750ebe73a62de34 Mon Sep 17 00:00:00 2001 From: Bilal Irfan Date: Tue, 3 Nov 2026 03:51:44 +0500 Subject: [PATCH 2/4] fix(web,mobile): example file paths in replies stay plain code Co-Authored-By: Claude Opus 5.5 --- .../features/files/FileMarkdownPreview.tsx | 5 + .../src/features/threads/ThreadFeed.tsx | 11 ++ apps/mobile/src/lib/markdownDocument.ts | 95 +++++++++----- apps/mobile/src/lib/markdownHtml.test.ts | 5 +- .../src/lib/markdownInlineFileLinks.test.ts | 27 +++- .../src/native/EnrichedMarkdownText.tsx | 31 +++-- .../src/native/SelectableMarkdownText.tsx | 1 + .../native/SelectableMarkdownText.types.ts | 10 ++ .../src/native/useEnrichedLinkVariants.ts | 12 +- .../src/native/useExistingInlineFiles.ts | 72 +++++++++++ apps/web/src/components/ChatMarkdown.tsx | 35 ++++- .../src/state/projectCommands.ts | 23 ++-- .../src/state/workspaceFileExistence.test.ts | 75 +++++++++++ .../src/state/workspaceFileExistence.ts | 121 ++++++++++++++++++ 14 files changed, 455 insertions(+), 68 deletions(-) create mode 100644 apps/mobile/src/native/useExistingInlineFiles.ts create mode 100644 packages/client-runtime/src/state/workspaceFileExistence.test.ts create mode 100644 packages/client-runtime/src/state/workspaceFileExistence.ts diff --git a/apps/mobile/src/features/files/FileMarkdownPreview.tsx b/apps/mobile/src/features/files/FileMarkdownPreview.tsx index f9f81459bbf0..366033144ed2 100644 --- a/apps/mobile/src/features/files/FileMarkdownPreview.tsx +++ b/apps/mobile/src/features/files/FileMarkdownPreview.tsx @@ -249,6 +249,11 @@ export function FileMarkdownPreview(props: { false, ) } + inlineFileScope={{ + environmentId: props.environmentId, + workspaceRoot: markdownDirectory, + searchBasenames: false, + }} onLinkPress={onLinkPress} renderImage={renderImage} resolveImageSource={resolveImageSource} diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index e411b6a2b63f..a4629b77931c 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -159,6 +159,7 @@ import { splitMarkdownDetails } from "../../lib/markdownDetails"; import { hasWideMarkdownBlock } from "../../lib/wideMarkdownBlocks"; import { SelectableMarkdownText, + type InlineFileScope, type MarkdownFileContextMenu, type MarkdownImageRenderer, type MarkdownImageSourceResolver, @@ -1113,6 +1114,7 @@ interface MarkdownLinkHandlers { readonly onImagePress?: (href: string) => void; readonly resolveImageSource?: MarkdownImageSourceResolver; readonly fileEmbedLabel?: (href: string, standalone: boolean) => string | null; + readonly inlineFileScope?: InlineFileScope; readonly linkCustomization?: (href: string) => MarkdownLinkCustomization | undefined; } @@ -3018,6 +3020,15 @@ export const ThreadFeed = memo(function ThreadFeed({ standalone, ), onLinkPress: onMarkdownLinkPress, + ...(props.workspaceRoot + ? { + inlineFileScope: { + environmentId: props.environmentId, + workspaceRoot: props.workspaceRoot, + searchBasenames: true, + }, + } + : {}), linkCustomization: enrichedThreadLinkCustomization, onImagePress: (href) => onMarkdownLinkPress(href, true), resolveImageSource, diff --git a/apps/mobile/src/lib/markdownDocument.ts b/apps/mobile/src/lib/markdownDocument.ts index 6e5bc86cf771..43701801f579 100644 --- a/apps/mobile/src/lib/markdownDocument.ts +++ b/apps/mobile/src/lib/markdownDocument.ts @@ -287,29 +287,48 @@ export function markdownWithHtml(markdown: string): string { // Start only at the beginning of a backtick run to avoid retrying every suffix. const INLINE_CODE_SPAN = /(? boolean; +} + +export interface NativeMarkdown { + readonly markdown: string; + /** Link URLs that came from inline code, never shared with an authored link. */ + readonly inlineFileHrefs: ReadonlySet; +} + +const NO_INLINE_FILE_HREFS: ReadonlySet = new Set(); + +/** Inline code that may name a file. May include spans the rewrite skips, such as fenced code. */ +export function inlineCodeFileCandidates(markdown: string, pattern: RegExp): ReadonlyArray { + const candidates = new Set(); + for (const [, code] of markdown.matchAll(INLINE_CODE_SPAN)) { + const value = code!.trim(); + if (pattern.test(value)) candidates.add(value); + } + return [...candidates]; +} + +export function markdownWithInlineFileLinks( + markdown: string, + fileLinks: InlineFileLinks, +): NativeMarkdown { + return transformNativeMarkdown(markdown, fileLinks, false); } function transformNativeMarkdown( markdown: string, - fileLink: RegExp | undefined, + fileLinks: InlineFileLinks | undefined, tableBreaks: boolean, -): string { +): NativeMarkdown { const breakTables = tableBreaks && mayHaveTableUnderText(markdown); - let linkFiles = false; - if (fileLink) { - for (const [, code] of markdown.matchAll(INLINE_CODE_SPAN)) { - if (fileLink.test(code!.trim())) { - linkFiles = true; - break; - } - } - } - if (!linkFiles && !breakTables) return markdown; - const edits: MarkdownEdit[] = []; - const fileEdits: MarkdownEdit[] = []; - let rewritesPipe = false; + const linkFiles = + fileLinks !== undefined && inlineCodeFileCandidates(markdown, fileLinks.pattern).length > 0; + if (!linkFiles && !breakTables) return { markdown, inlineFileHrefs: NO_INLINE_FILE_HREFS }; + const edits: Array = []; + const authoredUrls = new Set(); const visit = (node: MarkdownNode) => { if (linkFiles && node.type === "inlineCode") { const start = node.position?.start.offset; @@ -318,19 +337,16 @@ function transformNativeMarkdown( if ( start !== undefined && end !== undefined && - fileLink?.test(node.value) && - !/[<>]/.test(node.value) + fileLinks !== undefined && + fileLinks.pattern.test(node.value) && + !/[<>]/.test(node.value) && + fileLinks.exists(node.value) ) { - const edit = { - start, - end, - content: `[${escaped}](<${escaped}>)`, - }; - edits.push(edit); - fileEdits.push(edit); - if (node.value.includes("|")) rewritesPipe = true; + edits.push({ start, end, content: `[${escaped}](<${escaped}>)`, href: node.value }); } - } else if (node.type !== "link" && node.type !== "linkReference" && "children" in node) + } else if (node.type === "link" || node.type === "image" || node.type === "definition") { + authoredUrls.add(node.url); + } else if (node.type !== "linkReference" && "children" in node) node.children.forEach((child, index) => { const previous = node.children[index - 1]; const start = child.position?.start; @@ -356,10 +372,18 @@ function transformNativeMarkdown( }); }; visit(parser.parse(markdown)); + // Native pill content is keyed by URL, so inline code naming an authored link's or image's + // URL stays code rather than relabeling that link. + const kept = edits.filter((edit) => edit.href === undefined || !authoredUrls.has(edit.href)); + const fileEdits = kept.filter((edit) => edit.href !== undefined); + const inlineFileHrefs = new Set(fileEdits.map((edit) => edit.href!)); // Rewritten pipes can change table structure, so preserve the original pass ordering. - if (breakTables && rewritesPipe) - return markdownWithTableBreaks(applyMarkdownEdits(markdown, fileEdits)); - return applyMarkdownEdits(markdown, edits); + if (breakTables && fileEdits.some((edit) => edit.href!.includes("|"))) + return { + markdown: markdownWithTableBreaks(applyMarkdownEdits(markdown, fileEdits)), + inlineFileHrefs, + }; + return { markdown: applyMarkdownEdits(markdown, kept), inlineFileHrefs }; } // A delimiter row such as `|---|:--:|`, possibly inside a blockquote or list item. @@ -383,7 +407,7 @@ function mayHaveTableUnderText(markdown: string): boolean { /** md4c starts tables only at paragraph starts; insert breaks to match GFM. */ export function markdownWithTableBreaks(markdown: string): string { - return transformNativeMarkdown(markdown, undefined, true); + return transformNativeMarkdown(markdown, undefined, true).markdown; } // `>` covers a destination continued on a blockquote line. @@ -440,8 +464,11 @@ export function parseMarkdownDocument(markdown: string): Root { return parser.parse(markdownForNativeParsing(markdown)); } -export function markdownForNativeRendering(markdown: string, fileLink: RegExp): string { - return transformNativeMarkdown(markdownForNativeParsing(markdown), fileLink, true); +export function markdownForNativeRendering( + markdown: string, + fileLinks: InlineFileLinks, +): NativeMarkdown { + return transformNativeMarkdown(markdownForNativeParsing(markdown), fileLinks, true); } /** Native media slots only cover standalone images; nested file embeds must become links. */ diff --git a/apps/mobile/src/lib/markdownHtml.test.ts b/apps/mobile/src/lib/markdownHtml.test.ts index cd845f314978..3984767156b3 100644 --- a/apps/mobile/src/lib/markdownHtml.test.ts +++ b/apps/mobile/src/lib/markdownHtml.test.ts @@ -325,7 +325,10 @@ describe("Windows paths in native Markdown", () => { ].join("\n\n"); const native = await parseMarkdown( - markdownForNativeRendering(markdown, ENRICHED_INLINE_FILE_LINK_REGEX), + markdownForNativeRendering(markdown, { + pattern: ENRICHED_INLINE_FILE_LINK_REGEX, + exists: () => true, + }).markdown, ); const urls: string[] = []; const collect = (node: { attributes?: { url?: string }; children?: unknown[] }) => { diff --git a/apps/mobile/src/lib/markdownInlineFileLinks.test.ts b/apps/mobile/src/lib/markdownInlineFileLinks.test.ts index a790fea7efc0..3e21b98caf86 100644 --- a/apps/mobile/src/lib/markdownInlineFileLinks.test.ts +++ b/apps/mobile/src/lib/markdownInlineFileLinks.test.ts @@ -10,8 +10,9 @@ import { markdownWithHtml, } from "./markdownDocument"; +const EVERY_FILE_EXISTS = { pattern: ENRICHED_INLINE_FILE_LINK_REGEX, exists: () => true }; const rewrite = (markdown: string) => - markdownWithInlineFileLinks(markdown, ENRICHED_INLINE_FILE_LINK_REGEX); + markdownWithInlineFileLinks(markdown, EVERY_FILE_EXISTS).markdown; describe("inline code that names a file", () => { it.each([ @@ -61,7 +62,7 @@ describe("inline code that names a file", () => { previousFileLinks(markdownWithHtml(prefix), ENRICHED_INLINE_FILE_LINK_REGEX), ); expect( - markdownForNativeRendering(prefix, ENRICHED_INLINE_FILE_LINK_REGEX), + markdownForNativeRendering(prefix, EVERY_FILE_EXISTS).markdown, JSON.stringify(prefix), ).toBe(expected); } @@ -74,6 +75,28 @@ describe("inline code that names a file", () => { ); }); + it("stays code unless the file exists", () => { + const exists = (path: string) => path === "src/a.ts"; + expect( + markdownWithInlineFileLinks("See `avatars/u1.webp` and `src/a.ts`.", { + pattern: ENRICHED_INLINE_FILE_LINK_REGEX, + exists, + }).markdown, + ).toBe("See `avatars/u1.webp` and [src/a.ts]()."); + }); + + it("reports only inline code hrefs, leaving an authored link's URL to that link", () => { + expect( + markdownWithInlineFileLinks( + "[the file](src/a.ts) `src/a.ts` `` src/b.ts ``\n\n```\n`src/c.ts`\n```", + EVERY_FILE_EXISTS, + ), + ).toEqual({ + markdown: "[the file](src/a.ts) `src/a.ts` [src/b.ts]()\n\n```\n`src/c.ts`\n```", + inlineFileHrefs: new Set(["src/b.ts"]), + }); + }); + it("keeps commands, identifiers and URLs as code", () => { const markdown = "Run `vp test run`, call `useMemo`, open `https://example.com/a.ts`."; expect(rewrite(markdown)).toBe(markdown); diff --git a/apps/mobile/src/native/EnrichedMarkdownText.tsx b/apps/mobile/src/native/EnrichedMarkdownText.tsx index 6fa8c229a953..7611c7e7205a 100644 --- a/apps/mobile/src/native/EnrichedMarkdownText.tsx +++ b/apps/mobile/src/native/EnrichedMarkdownText.tsx @@ -24,6 +24,7 @@ import type { SelectableMarkdownTextProps } from "./SelectableMarkdownText.types import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; import { Menu } from "../components/ui/Menu"; import { useEnrichedLinkVariants } from "./useEnrichedLinkVariants"; +import { useExistingInlineFiles } from "./useExistingInlineFiles"; import { useRetryingImageSource } from "./useRetryingImageSource"; import { copyTextWithHaptic } from "../lib/copyTextWithHaptic"; import { @@ -164,20 +165,25 @@ function enrichedStyle( export function MobileEnrichedMarkdownText(props: SelectableMarkdownTextProps) { const { renderImage, resolveImageSource, fileEmbedLabel } = props; const { themeAppearance, mathRenderingEnabled } = useAppearancePreferences(); - const { markdown, fileHrefs } = useMemo(() => { + const inlineFileExists = useExistingInlineFiles(props.markdown, props.inlineFileScope); + const { markdown, fileHrefs, inlineFileHrefKey } = useMemo(() => { + const fileLinks = { pattern: ENRICHED_INLINE_FILE_LINK_REGEX, exists: inlineFileExists }; if (!fileEmbedLabel) { + const native = markdownForNativeRendering(props.markdown, fileLinks); return { - markdown: markdownForNativeRendering(props.markdown, ENRICHED_INLINE_FILE_LINK_REGEX), + markdown: native.markdown, fileHrefs: new Set(), + inlineFileHrefKey: [...native.inlineFileHrefs].join("\n"), }; } - const content = markdownWithInlineFileLinks( - markdownForNativeParsing(props.markdown), - ENRICHED_INLINE_FILE_LINK_REGEX, - ); - const files = markdownWithFileEmbeds(content, fileEmbedLabel); - return { ...files, markdown: markdownWithTableBreaks(files.markdown) }; - }, [props.markdown, fileEmbedLabel]); + const content = markdownWithInlineFileLinks(markdownForNativeParsing(props.markdown), fileLinks); + const files = markdownWithFileEmbeds(content.markdown, fileEmbedLabel); + return { + ...files, + markdown: markdownWithTableBreaks(files.markdown), + inlineFileHrefKey: [...content.inlineFileHrefs].join("\n"), + }; + }, [props.markdown, fileEmbedLabel, inlineFileExists]); const [parsedLinkUrls, setParsedLinkUrls] = useState>([]); const onLinksChange = (links: MarkdownLinkOccurrence[]) => { const urls = [...new Set(links.map((link) => link.url))]; @@ -200,7 +206,12 @@ export function MobileEnrichedMarkdownText(props: SelectableMarkdownTextProps) { const linkUrls = skillLinkKey ? [...new Set([...parsedLinkUrls, ...skillLinkKey.split("\n")])] : parsedLinkUrls; - const { linkVariants, linkPillContent } = useEnrichedLinkVariants(linkUrls, props, fileHrefs); + const { linkVariants, linkPillContent } = useEnrichedLinkVariants( + linkUrls, + props, + fileHrefs, + inlineFileHrefKey, + ); const linkRecognition = useMemo(() => ({ text: skillRegex ?? undefined }), [skillRegex]); const [activeMenu, setActiveMenu] = useState<{ url: string; diff --git a/apps/mobile/src/native/SelectableMarkdownText.tsx b/apps/mobile/src/native/SelectableMarkdownText.tsx index e49405af4dd4..31df1c354a58 100644 --- a/apps/mobile/src/native/SelectableMarkdownText.tsx +++ b/apps/mobile/src/native/SelectableMarkdownText.tsx @@ -6,6 +6,7 @@ import { themeColorWithAlpha } from "../lib/mobileTheme"; import { useUniwindTheme } from "../lib/useUniwindTheme"; export type { + InlineFileScope, MarkdownFileContextMenu, MarkdownFileContextMenuAction, MarkdownImageRenderer, diff --git a/apps/mobile/src/native/SelectableMarkdownText.types.ts b/apps/mobile/src/native/SelectableMarkdownText.types.ts index 686007015268..6a4704dcc3ca 100644 --- a/apps/mobile/src/native/SelectableMarkdownText.types.ts +++ b/apps/mobile/src/native/SelectableMarkdownText.types.ts @@ -62,6 +62,14 @@ export interface MarkdownLinkCustomization { readonly chip?: { readonly accent?: string; readonly tintsIcon: boolean }; } +/** Where inline code paths are checked before they become file chips. */ +export interface InlineFileScope { + readonly environmentId: import("@t3tools/contracts").EnvironmentId; + readonly workspaceRoot: string; + /** Whether a bare file name may match a file elsewhere in the workspace. */ + readonly searchBasenames: boolean; +} + export interface SelectableMarkdownTextProps { readonly markdown: string; readonly textStyle: NativeMarkdownTextStyle; @@ -74,6 +82,8 @@ export interface SelectableMarkdownTextProps { readonly contextClipboardFragment?: string; readonly renderImage?: MarkdownImageRenderer; readonly fileEmbedLabel?: (href: string, standalone: boolean) => string | null; + /** Without a scope, inline code paths stay inline code. */ + readonly inlineFileScope?: InlineFileScope; readonly resolveImageSource?: MarkdownImageSourceResolver; readonly onImagePress?: (href: string) => void; readonly marginTop?: number; diff --git a/apps/mobile/src/native/useEnrichedLinkVariants.ts b/apps/mobile/src/native/useEnrichedLinkVariants.ts index f412bac63fbf..5b5ecd39cc87 100644 --- a/apps/mobile/src/native/useEnrichedLinkVariants.ts +++ b/apps/mobile/src/native/useEnrichedLinkVariants.ts @@ -76,7 +76,7 @@ function presentation( const link = resolveMarkdownLinkPresentation(url); if (link.kind === "file") return { - // Inline code becomes `[path]()`, so omitting the label keeps the written path. + // Inline code becomes `[path]()`, so no label keeps the written path. ...(inlineCode ? {} : { label: link.label }), icon: markdownFileIconSource(link.icon), chip: { accent: contextChipPresentation("mention").accent, tintsIcon: false }, @@ -95,14 +95,14 @@ export function useEnrichedLinkVariants( urls: ReadonlyArray, props: SelectableMarkdownTextProps, fileHrefs: ReadonlySet, + // Newline-joined so a streaming tick keeps `links` stable. + inlineFileHrefKey: string, ) { const { linkCustomization, skills, textStyle, fileEmbedLabel } = props; const theme = useUniwindTheme(); - // Joined so a streaming tick keeps `links` stable. - const inlineCodeUrlKey = urls.filter((url) => props.markdown.includes(`\`${url}\``)).join("\n"); const links = useMemo(() => { const byUrl = new Map(); - const inlineCodeUrls = new Set(inlineCodeUrlKey.split("\n")); + const inlineFileHrefs = new Set(inlineFileHrefKey.split("\n")); for (const url of urls) { if (byUrl.has(url)) continue; const label = fileHrefs.has(url) ? fileEmbedLabel?.(url, false) : null; @@ -112,11 +112,11 @@ export function useEnrichedLinkVariants( color: textStyle.fileTextColor, icon: markdownFileIconSource(resolveMarkdownFileIcon(label)), } - : presentation(url, { linkCustomization, skills, textStyle }, inlineCodeUrls.has(url)); + : presentation(url, { linkCustomization, skills, textStyle }, inlineFileHrefs.has(url)); if (value) byUrl.set(url, value); } return byUrl; - }, [urls, inlineCodeUrlKey, linkCustomization, skills, textStyle, fileHrefs, fileEmbedLabel]); + }, [urls, inlineFileHrefKey, linkCustomization, skills, textStyle, fileHrefs, fileEmbedLabel]); const chipForegrounds = useMemo( () => new Map( diff --git a/apps/mobile/src/native/useExistingInlineFiles.ts b/apps/mobile/src/native/useExistingInlineFiles.ts new file mode 100644 index 000000000000..cf4e154c9d82 --- /dev/null +++ b/apps/mobile/src/native/useExistingInlineFiles.ts @@ -0,0 +1,72 @@ +import { useAtomValue } from "@effect/atom-react"; +import { AuthFilesystemReadScope } from "@t3tools/contracts"; +import { needsWorkspaceBasenameLookup } from "@t3tools/client-runtime/workspace-basename-lookup"; +import { useMemo } from "react"; + +import { + isAbsolutePath, + resolveFileLinkPath, + resolveWorkspaceFilePath, + resolveWorkspaceRelativeFilePath, +} from "../features/files/filePath"; +import { ENRICHED_INLINE_FILE_LINK_REGEX } from "../lib/enrichedLinkPresentation"; +import { inlineCodeFileCandidates } from "../lib/markdownDocument"; +import { resolveMarkdownLinkPresentation } from "../lib/markdownLinks"; +import { projectEnvironment } from "../state/projects"; +import { useEnvironmentScope } from "../state/session"; +import type { InlineFileScope } from "./SelectableMarkdownText.types"; + +/** Whether an inline code path in `markdown` names a file that exists in the scope. */ +export function useExistingInlineFiles( + markdown: string, + scope: InlineFileScope | undefined, +): (path: string) => boolean { + const canRead = useEnvironmentScope(scope?.environmentId ?? null, AuthFilesystemReadScope); + // Joined so streaming ticks that add no candidates keep the lookups and render memo unchanged. + const candidateKey = scope + ? inlineCodeFileCandidates(markdown, ENRICHED_INLINE_FILE_LINK_REGEX).join("\n") + : ""; + const workspaceRoot = scope?.workspaceRoot; + const searchBasenames = scope?.searchBasenames ?? false; + const candidates = useMemo(() => { + const byValue = new Map(); + if (!workspaceRoot || candidateKey === "") return byValue; + for (const value of candidateKey.split("\n")) { + const presentation = resolveMarkdownLinkPresentation(value); + if (presentation.kind !== "file") continue; + const resolved = resolveFileLinkPath(workspaceRoot, presentation.path); + if (resolved === null) continue; + const relative = resolveWorkspaceRelativeFilePath(workspaceRoot, resolved); + byValue.set(value, { + path: isAbsolutePath(resolved) ? resolved : resolveWorkspaceFilePath(workspaceRoot, resolved), + // Mirrors the link press handler, which searches only for bare file names. + lookup: + searchBasenames && + relative !== null && + !/[\\/]$/.test(presentation.path) && + needsWorkspaceBasenameLookup(relative) + ? relative + : null, + }); + } + return byValue; + }, [candidateKey, workspaceRoot, searchBasenames]); + const existing = useAtomValue( + projectEnvironment.existingFiles( + scope && canRead && candidates.size > 0 + ? { + environmentId: scope.environmentId, + cwd: scope.workspaceRoot, + candidates: [...candidates.values()], + } + : null, + ), + ); + return useMemo( + () => (value: string) => { + const candidate = candidates.get(value); + return candidate !== undefined && existing.has(candidate.path); + }, + [candidates, existing], + ); +} diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index ab416ad60e88..208a2e117202 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -164,6 +164,7 @@ import { previewEnvironment } from "../state/preview"; import { useAtomCommand } from "../state/use-atom-command"; import { useAtomQueryRunner } from "../state/use-atom-query-runner"; import { projectEnvironment } from "../state/projects"; +import { FileMetadataThreadContext } from "../hooks/FileMetadataThreadProvider"; import { filesystemEnvironment } from "../state/filesystem"; import { claimWorkspaceBasenameLookup, @@ -683,6 +684,32 @@ function useChatMarkdownState({ const fileLinksKey = `${fileLinkSourcesKey(text, cwd, imageBaseDir)}`; const { markdownFileLinkMetaByHref, inlineCodeFileLinkMetaByText, fileLinkParentSuffixByPath } = resolveFileLinkSources(fileLinksKey, cwd, imageBaseDir); + const canReadFiles = useEnvironmentScope(environmentId, AuthFilesystemReadScope); + const metadataThreadRef = use(FileMetadataThreadContext); + // Inline code becomes a file chip only once the file is known to exist, so examples stay code. + const existingInlineCodeFiles = useAtomValue( + projectEnvironment.existingFiles( + environmentId !== null && cwd && canReadFiles + ? { + environmentId, + threadId: + metadataThreadRef?.environmentId === environmentId + ? metadataThreadRef.threadId + : undefined, + cwd, + candidates: [...inlineCodeFileLinkMetaByText.values()].map((meta) => ({ + path: meta.filePath, + lookup: meta.workspaceLookupPath, + })), + } + : null, + ), + ); + const existingInlineCodeFileLinkMetaByText = new Map( + [...inlineCodeFileLinkMetaByText].filter(([, meta]) => + existingInlineCodeFiles.has(meta.filePath), + ), + ); const resolveThreadPullRequest = ( href: string, ): (ThreadPullRequestKey & { readonly url: string }) | null => { @@ -909,7 +936,7 @@ function useChatMarkdownState({ headingLevelOffset, imageBaseDir, workspaceMutationId, - inlineCodeFileLinkMetaByText, + inlineCodeFileLinkMetaByText: existingInlineCodeFileLinkMetaByText, inline, mathEnabled, isStreaming, @@ -1337,7 +1364,7 @@ const CHAT_MARKDOWN_COMPONENTS = { ); }, code: function MarkdownCode({ node, children, className, ...props }) { - const { cwd, imageBaseDir, inlineCodeFileLinkMetaByText, fileLinkChip, mathEnabled, inline } = + const { inlineCodeFileLinkMetaByText, fileLinkChip, mathEnabled, inline } = use(ChatMarkdownRendererContext); const math = mathEnabled ? mathKind(className) : null; if (math) { @@ -1351,9 +1378,7 @@ const CHAT_MARKDOWN_COMPONENTS = { } if (node?.properties?.dataInlineCode != null) { const codeText = nodeToPlainText(children); - const fileLinkMeta = - inlineCodeFileLinkMetaByText.get(codeText.trim()) ?? - resolveInlineCodeFileLinkMeta(codeText, cwd, imageBaseDir ?? cwd); + const fileLinkMeta = inlineCodeFileLinkMetaByText.get(codeText.trim()); if (fileLinkMeta) { return fileLinkChip( fileLinkMeta, diff --git a/packages/client-runtime/src/state/projectCommands.ts b/packages/client-runtime/src/state/projectCommands.ts index caa706d86d46..3e553a4cdfcf 100644 --- a/packages/client-runtime/src/state/projectCommands.ts +++ b/packages/client-runtime/src/state/projectCommands.ts @@ -29,6 +29,7 @@ import { import { createProject, deleteProject, updateProject } from "../operations/commands.ts"; import type { EnvironmentRegistry } from "../connection/registry.ts"; import { createFileMetadataAtoms } from "./fileMetadata.ts"; +import { createWorkspaceFileExistenceAtom } from "./workspaceFileExistence.ts"; import { resolveWorkspaceFilePath } from "@t3tools/shared/path"; export type { @@ -79,6 +80,16 @@ export function createProjectEnvironmentAtoms( }, ) { const fileMetadata = createFileMetadataAtoms(runtime); + const searchEntries = createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:projects:search-entries", + tag: WS_METHODS.projectsSearchEntries, + staleTimeMs: 15_000, + execute: (input) => + fileMetadata.seedAfterRead( + request(WS_METHODS.projectsSearchEntries, input), + (result, isCurrent) => fileMetadata.rememberEntries(input.cwd, result.entries, isCurrent), + ), + }); const projectScheduler = createAtomCommandScheduler(); const fileScheduler = createAtomCommandScheduler(); const optimisticFileFamily = Atom.family((key: string) => @@ -107,16 +118,8 @@ export function createProjectEnvironmentAtoms( ), ), }), - searchEntries: createEnvironmentRpcQueryAtomFamily(runtime, { - label: "environment-data:projects:search-entries", - tag: WS_METHODS.projectsSearchEntries, - staleTimeMs: 15_000, - execute: (input) => - fileMetadata.seedAfterRead( - request(WS_METHODS.projectsSearchEntries, input), - (result, isCurrent) => fileMetadata.rememberEntries(input.cwd, result.entries, isCurrent), - ), - }), + searchEntries, + existingFiles: createWorkspaceFileExistenceAtom(fileMetadata.metadata, searchEntries), listEntries: createEnvironmentRpcQueryAtomFamily(runtime, { label: "environment-data:projects:list-entries", tag: WS_METHODS.projectsListEntries, diff --git a/packages/client-runtime/src/state/workspaceFileExistence.test.ts b/packages/client-runtime/src/state/workspaceFileExistence.test.ts new file mode 100644 index 000000000000..353795aba6db --- /dev/null +++ b/packages/client-runtime/src/state/workspaceFileExistence.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from "@effect/vitest"; +import { + EnvironmentId, + type FilesystemEntryMetadata, + type ProjectSearchEntriesInput, + type ProjectSearchEntriesResult, +} from "@t3tools/contracts"; +import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity"; + +import { createWorkspaceFileExistenceAtom } from "./workspaceFileExistence.ts"; + +const environmentId = EnvironmentId.make("existence-environment"); + +function setup(input: { + readonly metadata: Record; + readonly search?: Record>; +}) { + const searched: string[] = []; + const statted: string[] = []; + const metadata = Atom.family((path: string) => { + statted.push(path); + const value = input.metadata[path] ?? null; + return Atom.make>( + value === "pending" ? AsyncResult.initial(true) : AsyncResult.success(value), + ); + }); + const search = Atom.family((query: string) => { + searched.push(query); + return Atom.make>( + AsyncResult.success({ + entries: (input.search?.[query] ?? []).map((path) => ({ path, kind: "file" as const })), + truncated: false, + }), + ); + }); + const existingFiles = createWorkspaceFileExistenceAtom( + (target) => metadata(target.input.path), + (target: { readonly input: ProjectSearchEntriesInput }) => search(target.input.query), + ); + return { registry: AtomRegistry.make(), existingFiles, searched, statted }; +} + +describe("createWorkspaceFileExistenceAtom", () => { + it("confirms only paths the environment knows exist", () => { + const { registry, existingFiles, searched, statted } = setup({ + metadata: { + "/repo/src/existing.ts": { kind: "file" }, + "/repo/avatars/u1.webp": null, + "/repo/pending.ts": "pending", + "/repo/Button.tsx": null, + "/repo/Missing.tsx": null, + }, + search: { "Button.tsx": ["src/ui/Button.tsx"], "Missing.tsx": ["src/MissingThing.tsx"] }, + }); + const found = registry.get( + existingFiles({ + environmentId, + cwd: "/repo", + candidates: [ + { path: "/repo/src/existing.ts", lookup: null }, + { path: "/repo/avatars/u1.webp", lookup: null }, + { path: "/repo/pending.ts", lookup: "pending.ts" }, + { path: "/repo/Button.tsx", lookup: "Button.tsx" }, + { path: "/repo/Missing.tsx", lookup: "Missing.tsx" }, + { path: "\\\\server\\share\\a.ts", lookup: null }, + ], + }), + ); + + expect([...found]).toEqual(["/repo/src/existing.ts", "/repo/Button.tsx"]); + // Search runs only once the literal path is known to be missing. + expect(searched).toEqual(["Button.tsx", "Missing.tsx"]); + expect(statted).not.toContain("\\\\server\\share\\a.ts"); + }); +}); diff --git a/packages/client-runtime/src/state/workspaceFileExistence.ts b/packages/client-runtime/src/state/workspaceFileExistence.ts new file mode 100644 index 000000000000..1df905dbb48e --- /dev/null +++ b/packages/client-runtime/src/state/workspaceFileExistence.ts @@ -0,0 +1,121 @@ +import type { + EnvironmentId, + FilesystemEntryMetadata, + ProjectSearchEntriesInput, + ProjectSearchEntriesResult, + ThreadId, +} from "@t3tools/contracts"; +import { isAbsolutePath, isNetworkPath } from "@t3tools/shared/path"; +import * as Option from "effect/Option"; +import { AsyncResult, Atom } from "effect/reactivity"; + +import { + needsWorkspaceBasenameLookup, + pickStrippedWorkspaceMatch, + pickWorkspaceBasenameMatch, + stripRepeatedWorkspacePrefix, + WORKSPACE_BASENAME_LOOKUP_LIMIT, +} from "../workspaceBasenameLookup.ts"; + +export interface WorkspaceFileCandidate { + /** Host path the file would have if written literally. */ + readonly path: string; + /** Workspace-relative path to search for when `path` is missing, as file links do on click. */ + readonly lookup: string | null; +} + +export interface WorkspaceFileExistenceTarget { + readonly environmentId: EnvironmentId; + readonly threadId?: ThreadId | undefined; + readonly cwd: string; + readonly candidates: ReadonlyArray; +} + +const NO_FILES = Atom.make>(new Set()).pipe( + Atom.withLabel("environment-data:projects:existing-files:none"), +); + +function sameEntries(a: ReadonlySet, b: ReadonlySet): boolean { + return a.size === b.size && [...a].every((value) => b.has(value)); +} + +/** + * Candidate paths confirmed to exist in the environment, read through the shared metadata + * and search caches. Unknown, unreadable, and disconnected lookups count as missing. + */ +export function createWorkspaceFileExistenceAtom( + fileMetadata: (target: { + readonly environmentId: EnvironmentId; + readonly input: { readonly path: string; readonly threadId?: ThreadId }; + }) => Atom.Atom>, + searchEntries: (target: { + readonly environmentId: EnvironmentId; + readonly input: ProjectSearchEntriesInput; + }) => Atom.Atom>, +) { + const family = Atom.family((key: string) => { + const target = JSON.parse(key) as WorkspaceFileExistenceTarget; + return Atom.make((get): ReadonlySet => { + const found = new Set(); + for (const { path, lookup } of target.candidates) { + const metadata = AsyncResult.getOrElse( + get( + fileMetadata({ + environmentId: target.environmentId, + input: { path, ...(target.threadId ? { threadId: target.threadId } : {}) }, + }), + ), + () => undefined, + ); + if (metadata) found.add(path); + if (metadata !== null || lookup === null) continue; + const basename = needsWorkspaceBasenameLookup(lookup); + const stripped = basename ? null : stripRepeatedWorkspacePrefix(lookup, target.cwd); + if (!basename && stripped === null) continue; + const result = AsyncResult.getOrElse( + get( + searchEntries({ + environmentId: target.environmentId, + input: { + cwd: target.cwd, + query: stripped ?? lookup, + limit: WORKSPACE_BASENAME_LOOKUP_LIMIT, + kind: "file", + }, + }), + ), + () => null, + ); + const match = + result === null + ? null + : stripped === null + ? pickWorkspaceBasenameMatch(lookup, result.entries) + : pickStrippedWorkspaceMatch(stripped, target.cwd, result.entries); + if (match !== null) found.add(path); + } + const previous = Option.getOrNull(get.self>()); + return previous !== null && sameEntries(previous, found) ? previous : found; + }).pipe(Atom.withLabel("environment-data:projects:existing-files")); + }); + return (target: WorkspaceFileExistenceTarget | null): Atom.Atom> => { + if (target === null) return NO_FILES; + // Servers stat what they are asked about, and a network path leaks the + // Windows user's NTLM credentials to its host before anything is clicked. + const candidates = target.candidates.filter( + ({ path }) => + (isAbsolutePath(path) || path.startsWith("~/")) && + !isNetworkPath(path) && + path.length <= 512, + ); + if (candidates.length === 0) return NO_FILES; + return family( + JSON.stringify({ + environmentId: target.environmentId, + ...(target.threadId ? { threadId: target.threadId } : {}), + cwd: target.cwd, + candidates, + }), + ); + }; +} From 768a8708ee4057495a7ea12a35d1358719178d5a Mon Sep 17 00:00:00 2001 From: Bilal Irfan Date: Tue, 3 Nov 2026 13:14:18 +0500 Subject: [PATCH 3/4] fix(client): let web clients typecheck inline file lookups --- apps/mobile/src/native/EnrichedMarkdownText.tsx | 5 ++++- apps/mobile/src/native/useExistingInlineFiles.ts | 4 +++- apps/web/src/components/ChatMarkdown.tsx | 5 +++-- packages/client-runtime/src/state/projectCommands.ts | 5 +++++ packages/client-runtime/src/state/workspaceFileExistence.ts | 2 +- packages/shared/src/threadFindText.test.ts | 4 ++-- 6 files changed, 18 insertions(+), 7 deletions(-) diff --git a/apps/mobile/src/native/EnrichedMarkdownText.tsx b/apps/mobile/src/native/EnrichedMarkdownText.tsx index 7611c7e7205a..bb58e3346095 100644 --- a/apps/mobile/src/native/EnrichedMarkdownText.tsx +++ b/apps/mobile/src/native/EnrichedMarkdownText.tsx @@ -176,7 +176,10 @@ export function MobileEnrichedMarkdownText(props: SelectableMarkdownTextProps) { inlineFileHrefKey: [...native.inlineFileHrefs].join("\n"), }; } - const content = markdownWithInlineFileLinks(markdownForNativeParsing(props.markdown), fileLinks); + const content = markdownWithInlineFileLinks( + markdownForNativeParsing(props.markdown), + fileLinks, + ); const files = markdownWithFileEmbeds(content.markdown, fileEmbedLabel); return { ...files, diff --git a/apps/mobile/src/native/useExistingInlineFiles.ts b/apps/mobile/src/native/useExistingInlineFiles.ts index cf4e154c9d82..87e0ee07f4ae 100644 --- a/apps/mobile/src/native/useExistingInlineFiles.ts +++ b/apps/mobile/src/native/useExistingInlineFiles.ts @@ -38,7 +38,9 @@ export function useExistingInlineFiles( if (resolved === null) continue; const relative = resolveWorkspaceRelativeFilePath(workspaceRoot, resolved); byValue.set(value, { - path: isAbsolutePath(resolved) ? resolved : resolveWorkspaceFilePath(workspaceRoot, resolved), + path: isAbsolutePath(resolved) + ? resolved + : resolveWorkspaceFilePath(workspaceRoot, resolved), // Mirrors the link press handler, which searches only for bare file names. lookup: searchBasenames && diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 208a2e117202..f9b54f5a868b 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -1364,8 +1364,9 @@ const CHAT_MARKDOWN_COMPONENTS = { ); }, code: function MarkdownCode({ node, children, className, ...props }) { - const { inlineCodeFileLinkMetaByText, fileLinkChip, mathEnabled, inline } = - use(ChatMarkdownRendererContext); + const { inlineCodeFileLinkMetaByText, fileLinkChip, mathEnabled, inline } = use( + ChatMarkdownRendererContext, + ); const math = mathEnabled ? mathKind(className) : null; if (math) { return ( diff --git a/packages/client-runtime/src/state/projectCommands.ts b/packages/client-runtime/src/state/projectCommands.ts index 3e553a4cdfcf..84d3f6ee76ef 100644 --- a/packages/client-runtime/src/state/projectCommands.ts +++ b/packages/client-runtime/src/state/projectCommands.ts @@ -32,6 +32,11 @@ import { createFileMetadataAtoms } from "./fileMetadata.ts"; import { createWorkspaceFileExistenceAtom } from "./workspaceFileExistence.ts"; import { resolveWorkspaceFilePath } from "@t3tools/shared/path"; +export type { + WorkspaceFileCandidate, + WorkspaceFileExistenceTarget, +} from "./workspaceFileExistence.ts"; + export type { CreateProjectInput, DeleteProjectInput, diff --git a/packages/client-runtime/src/state/workspaceFileExistence.ts b/packages/client-runtime/src/state/workspaceFileExistence.ts index 1df905dbb48e..81aedddf261a 100644 --- a/packages/client-runtime/src/state/workspaceFileExistence.ts +++ b/packages/client-runtime/src/state/workspaceFileExistence.ts @@ -31,7 +31,7 @@ export interface WorkspaceFileExistenceTarget { readonly candidates: ReadonlyArray; } -const NO_FILES = Atom.make>(new Set()).pipe( +const NO_FILES = Atom.make>(new Set()).pipe( Atom.withLabel("environment-data:projects:existing-files:none"), ); diff --git a/packages/shared/src/threadFindText.test.ts b/packages/shared/src/threadFindText.test.ts index fdc9e0758b41..3c943114d6d8 100644 --- a/packages/shared/src/threadFindText.test.ts +++ b/packages/shared/src/threadFindText.test.ts @@ -83,11 +83,11 @@ it("searches displayed prose and file-chip labels without hidden paths", () => { expect(assistantSegments("[label](src/main.ts#L3C2)")).toEqual(["label"]); }); -it("keeps fence paths literal and indexes file-chip labels in user messages", () => { +it("keeps written paths in fences and user messages", () => { expect(assistantSegments("```text\n/tmp/file.ts:42\n```")).toEqual(["/tmp/file.ts:42\n"]); expect( searchableMessageSegments({ role: "user", text: "`/tmp/file.ts:42`", streaming: false }), - ).toEqual(["file.ts · L42"]); + ).toEqual(["/tmp/file.ts:42"]); }); it("indexes nested disclosure summaries and bodies in rendered order", () => { From bbf6d850401c80adf312bec5fd5546417089e7e9 Mon Sep 17 00:00:00 2001 From: Bilal Irfan Date: Tue, 3 Nov 2026 13:24:06 +0500 Subject: [PATCH 4/4] fix(mobile): keep Markdown characters in inline file labels --- apps/mobile/src/lib/markdownDocument.ts | 3 ++- apps/mobile/src/lib/markdownInlineFileLinks.test.ts | 10 ++++++++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/apps/mobile/src/lib/markdownDocument.ts b/apps/mobile/src/lib/markdownDocument.ts index 43701801f579..ea2de16c9297 100644 --- a/apps/mobile/src/lib/markdownDocument.ts +++ b/apps/mobile/src/lib/markdownDocument.ts @@ -334,6 +334,7 @@ function transformNativeMarkdown( const start = node.position?.start.offset; const end = node.position?.end.offset; const escaped = node.value.replace(/[\\[\]&]/g, "\\$&"); + const label = escaped.replace(/[*_~]/g, "\\$&"); if ( start !== undefined && end !== undefined && @@ -342,7 +343,7 @@ function transformNativeMarkdown( !/[<>]/.test(node.value) && fileLinks.exists(node.value) ) { - edits.push({ start, end, content: `[${escaped}](<${escaped}>)`, href: node.value }); + edits.push({ start, end, content: `[${label}](<${escaped}>)`, href: node.value }); } } else if (node.type === "link" || node.type === "image" || node.type === "definition") { authoredUrls.add(node.url); diff --git a/apps/mobile/src/lib/markdownInlineFileLinks.test.ts b/apps/mobile/src/lib/markdownInlineFileLinks.test.ts index 3e21b98caf86..c911c17d2dfe 100644 --- a/apps/mobile/src/lib/markdownInlineFileLinks.test.ts +++ b/apps/mobile/src/lib/markdownInlineFileLinks.test.ts @@ -107,8 +107,14 @@ describe("inline code that names a file", () => { expect(rewrite(markdown)).toBe(markdown); }); - it("links to the exact path when it holds backslashes or ampersands", () => { - for (const path of ["C:\\repo\\_private\\file.ts", "./src/a©.ts", "./src/[id].ts"]) { + it("keeps the exact path as link text and destination", () => { + for (const path of [ + "C:\\repo\\_private\\file.ts", + "./src/a©.ts", + "./src/[id].ts", + "./src/__tests__/main.ts", + "./src/**name**_~~test~~.ts", + ]) { const paragraph = parser.parse(rewrite(`\`${path}\``)).children[0]; const link = paragraph?.type === "paragraph" ? paragraph.children[0] : undefined; expect(link?.type === "link" && link.url).toBe(path);