fix(markdown): preserve file links and image revisions

This commit is contained in:
Mohamed Boudra
2026-10-15 15:32:19 +02:00
parent 35302f7251
commit 554530a667
17 changed files with 186 additions and 56 deletions
@@ -2,7 +2,7 @@ export {
classifyAssistantFileLink,
normalizeInlinePathTarget,
type InlinePathTarget,
} from "./parse";
} from "./paths";
export {
AssistantFileLinkResolverProvider,
useAssistantFileLinkEnvironment,
@@ -5,7 +5,7 @@ import {
parseAssistantFileLink,
parseFileProtocolUrl,
parseInlinePathToken,
} from "./parse";
} from "./index";
describe("parseInlinePathToken", () => {
it("returns null for plain paths without a line number", () => {
@@ -10,7 +10,7 @@ import {
import React from "react";
import type { ToastApi } from "@/components/toast-host";
import type { OpenFileDisposition } from "@/workspace/file-open";
import type { InlinePathTarget } from "./parse";
import type { InlinePathTarget } from "./paths";
import type { AssistantFileLinkContext, GetDirectorySuggestions } from "./resolver";
export interface AssistantFileLinkDaemonClient {
@@ -3,7 +3,7 @@ import {
isFileLookingAssistantToken,
type AssistantFileLinkClassification,
type InlinePathTarget,
} from "./parse";
} from "./paths";
import { i18n } from "@/i18n/i18next";
export interface AssistantFileLinkSource {
@@ -6,7 +6,7 @@ import { act, renderHook, waitFor } from "@testing-library/react";
import React, { useCallback, useMemo, useState, type ReactNode } from "react";
import { describe, expect, it, vi } from "vitest";
import type { ToastApi } from "@/components/toast-host";
import type { InlinePathTarget } from "./parse";
import type { InlinePathTarget } from "./paths";
import { AssistantFileLinkResolverProvider } from "./provider";
import type { DirectorySuggestionResult } from "./resolver";
import { useFileLink } from "./use-file-link";
@@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next";
import { useStableEvent } from "@/hooks/use-stable-event";
import type { OpenFileDisposition } from "@/workspace/file-open";
import { openExternalUrl } from "@/utils/open-external-url";
import type { InlinePathTarget } from "./parse";
import type { InlinePathTarget } from "./paths";
import {
useAssistantFileLinkResolverContext,
type AssistantFileLinkResolverContextValue,
+10 -3
View File
@@ -1,4 +1,4 @@
import { useMemo, useEffect } from "react";
import { useMemo, useEffect, useId } from "react";
import { useFetchQueries } from "@/data/query";
import { createAssistantImageFileAcquisition } from "@/assistant-image/file-acquisition";
import {
@@ -33,7 +33,11 @@ export interface MarkdownImageContext {
}
export function useMarkdownSource(text: string, context: MarkdownImageContext) {
const prepared = useMemo(() => prepareMarkdownSource(text), [text]);
const occurrenceKey = useId();
const prepared = useMemo(
() => prepareMarkdownSource(text, context.workspaceRoot),
[text, context.workspaceRoot],
);
const sources = [...new Set(prepared.images.map((image) => image.source))];
const acquisitions = sources.map((source) => {
const resolved = context.filePath
@@ -54,7 +58,7 @@ export function useMarkdownSource(text: string, context: MarkdownImageContext) {
? resolveAssistantImageSource({ source: resolved, workspaceRoot: context.workspaceRoot })
: null,
serverId: context.serverId,
occurrenceKey: context.filePath ?? context.workspaceRoot ?? "markdown",
occurrenceKey,
unavailableMessage: "Image unavailable",
});
});
@@ -90,6 +94,9 @@ export function useMarkdownSource(text: string, context: MarkdownImageContext) {
});
return {
markdown: prepared.render(resolved),
linkRecognition: prepared.linkRecognition,
linkPillContent: prepared.linkPillContent,
linkSource: prepared.linkSource,
imageSources: Object.fromEntries([...resolved].map(([source, url]) => [url, source])),
};
}
@@ -36,3 +36,30 @@ it("preserves named and numeric entities as literal rendered text", () => {
"AT\\&T 😀 \\# heading and `&`",
);
});
it("preserves empty table cells while escaping spoilers in cell text", () => {
const source = "| A || B |\n| --- | --- | --- |\n| x || z |";
expect(prepareMarkdownSource(source).render()).toBe(
"| A | | B |\n| --- | --- | --- |\n| x | | z |",
);
});
it.each([
"src/components/message.tsx",
"C:\\repo\\file.ts",
"docs/说明.md",
"src/file.ts:10-20",
"src/file.ts(10,2)",
])("recognizes inline paths through the file resolver: %s", (path) => {
const prepared = prepareMarkdownSource(`Open \`${path}\`.`, "/workspace");
expect(prepared.linkRecognition.inlineCode?.test(path)).toBe(true);
expect(prepared.linkSource(path)).toEqual({ href: path, text: path, sourceType: "inline-code" });
});
it("recognizes bare Unicode filenames and preserves inline-code URLs", () => {
const prepared = prepareMarkdownSource("Read 说明.md, or `https://example.com`.", "/workspace");
expect([..."说明.md,".matchAll(prepared.linkRecognition.text)].map(([text]) => text)).toEqual([
"说明.md",
]);
expect(prepared.linkRecognition.inlineCode?.test("https://example.com")).toBe(true);
});
@@ -1,6 +1,6 @@
import { View } from "react-native";
import { markdownCopyImageSourcesDataSet } from "@/assistant-selection-copy/markup";
import { memo, useMemo, useState } from "react";
import { memo, useState } from "react";
import {
EnrichedMarkdownText,
type MarkdownStyle,
@@ -20,12 +20,6 @@ import { useMarkdownSource, type MarkdownImageContext } from "./images";
const ThemedMarkdown = withUnistyles(EnrichedMarkdownText);
const MD4C_FLAGS = { latexMath: false, hardSoftBreaks: true, admonitions: true };
const FILE_PATTERN =
/(?:https?:\/\/[^\s<>]+|(?:\.{0,2}\/|~\/)?[\w@.-]+(?:\/[\w@.-]+)+(?::\d+(?::\d+)?)?|[\w@-]+\.[a-zA-Z][\w-]*(?::\d+(?::\d+)?)?)/g;
const LINK_RECOGNITION = {
text: FILE_PATTERN,
inlineCode: new RegExp(`^(?:${FILE_PATTERN.source})$`),
};
function markdownStyle(theme: Theme, compact = false): { markdownStyle: MarkdownStyle } {
const fontSize = compact ? theme.fontSize.base : theme.fontSize.content;
@@ -140,15 +134,16 @@ export const MarkdownRenderer = memo(function MarkdownRenderer({
(environment.client?.readFile
? { readFile: environment.client.readFile.bind(environment.client) }
: undefined);
const { markdown, imageSources } = useMarkdownSource(text, {
workspaceRoot: environment.workspaceRoot,
serverId: environment.serverId,
...imageContext,
client,
});
const { markdown, imageSources, linkRecognition, linkPillContent, linkSource } =
useMarkdownSource(text, {
workspaceRoot: environment.workspaceRoot,
serverId: environment.serverId,
...imageContext,
client,
});
const [image, setImage] = useState<ImageLightboxSource | null>(null);
const pressLink = useStableEvent(({ url }: { url: string }) => {
const source = { href: url, text: url };
const source = linkSource(url);
if (onLinkPress && !actions.canResolveFile(source)) onLinkPress(url);
else actions.open(source, "preferred");
});
@@ -156,15 +151,6 @@ export const MarkdownRenderer = memo(function MarkdownRenderer({
setImage({ type: "uri", uri: url }),
);
const closeImage = useStableEvent(() => setImage(null));
const pills = useMemo(
() =>
Object.fromEntries(
[...markdown.matchAll(FILE_PATTERN)]
.filter(([url]) => !/^https?:/i.test(url))
.map(([url]) => [url, { label: url }]),
),
[markdown],
);
const content = (
<>
<ThemedMarkdown
@@ -174,8 +160,8 @@ export const MarkdownRenderer = memo(function MarkdownRenderer({
flavor="github"
selectable
md4cFlags={MD4C_FLAGS}
linkRecognition={LINK_RECOGNITION}
linkPillContent={pills}
linkRecognition={linkRecognition}
linkPillContent={linkPillContent}
enableTaskListItemToggle={false}
enableLinkPreview={false}
enableBlockContextMenu={false}
+61 -6
View File
@@ -1,6 +1,46 @@
import { gfmTable } from "micromark-extension-gfm-table";
import { gfmTableFromMarkdown } from "mdast-util-gfm-table";
import { classifyAssistantFileLink } from "@/assistant-file-links/paths";
import type { AssistantFileLinkSource } from "@/assistant-file-links";
import { decodeString } from "micromark-util-decode-string";
import { fromMarkdown } from "mdast-util-from-markdown";
import type { Nodes } from "mdast";
import type { Nodes, Root } from "mdast";
// Keep this regex in the syntax shared by JavaScript, ICU and java.util.regex.
// The Unicode flag makes Metro expand it to escapes rejected by native engines.
const FILE_PATTERN =
/(?:https?:\/\/[^\s<>]+|(?:[A-Za-z]:[\\/]|\.{0,2}\/|~\/)?[a-zA-Z0-9\u0080-\uFFFF_@.%+-]+(?:[\\/][a-zA-Z0-9\u0080-\uFFFF_@.%+-]+)+(?::\d+(?::\d+)?(?:-\d+(?::\d+)?)?)?|[a-zA-Z0-9\u0080-\uFFFF_@-]+\.[a-zA-Z0-9\u0080-\uFFFF_-]+(?::\d+(?::\d+)?(?:-\d+(?::\d+)?)?)?)/g;
function linkMetadata(source: string, getTree: () => Root, workspaceRoot?: string) {
return {
linkPillContent: Object.fromEntries(
[...source.matchAll(FILE_PATTERN)]
.filter(([url]) => !/^https?:/i.test(url))
.map(([url]) => [url, { label: url }]),
),
linkRecognition: {
text: FILE_PATTERN,
inlineCode: new RegExp(
String.raw`^(?:${FILE_PATTERN.source})(?:\(\d+(?:,\d+)?(?:-\d+(?:,\d+)?)?\)| lines? \d+(?:-\d+)?)?$`,
),
},
linkSource(url: string): AssistantFileLinkSource {
function containsInlinePath(node: Nodes): boolean {
return (
(node.type === "inlineCode" && node.value === url) ||
("children" in node && node.children.some(containsInlinePath))
);
}
// The library emits only the URL. Recover raw inline-code semantics at tap time,
// without parsing every reveal frame merely to configure link recognition.
return source.includes("`") &&
classifyAssistantFileLink(url, { workspaceRoot }) &&
containsInlinePath(getTree())
? { href: url, text: url, sourceType: "inline-code" }
: { href: url, text: url };
},
};
}
export interface MarkdownImage {
source: string;
@@ -11,23 +51,37 @@ export interface MarkdownImage {
}
/** Preserve source spelling; adapt workspace images, spoiler delimiters, and entity text. */
export function prepareMarkdownSource(source: string) {
export function prepareMarkdownSource(source: string, workspaceRoot?: string) {
const images: MarkdownImage[] = [];
let tree: Root | undefined;
const getTree = () =>
(tree ??= fromMarkdown(source, {
extensions: [gfmTable()],
mdastExtensions: [gfmTableFromMarkdown()],
}));
const edits: { start: number; end: number; text: string }[] = [];
if (!source.includes("![") && !source.includes("||") && !source.includes("&")) {
return { images, render: () => source };
return { images, ...linkMetadata(source, getTree, workspaceRoot), render: () => source };
}
const tree = fromMarkdown(source);
const definitions = new Map<string, { url: string; title?: string | null }>();
function definitionsIn(node: Nodes) {
if (node.type === "definition") definitions.set(node.identifier, node);
if ("children" in node) node.children.forEach(definitionsIn);
}
definitionsIn(tree);
definitionsIn(getTree());
function visit(node: Nodes) {
const start = node.position?.start.offset;
const end = node.position?.end.offset;
if (start !== undefined && end !== undefined) {
if (node.type === "tableRow") {
// MD4C's always-enabled spoiler extension otherwise consumes adjacent cell
// delimiters. Spaces keep empty GFM cells empty without producing spoilers.
const row = source.slice(start, end);
for (const match of row.matchAll(/(?<!\\)\|(?=\|)/g)) {
edits.push({ start: start + match.index + 1, end: start + match.index + 1, text: " " });
}
}
if (node.type === "image" || node.type === "imageReference") {
const target = node.type === "image" ? node : definitions.get(node.identifier);
if (target)
@@ -52,9 +106,10 @@ export function prepareMarkdownSource(source: string) {
}
if ("children" in node) node.children.forEach(visit);
}
visit(tree);
visit(getTree());
return {
images,
...linkMetadata(source, getTree, workspaceRoot),
render(urls: ReadonlyMap<string, string> = new Map()) {
const replacements = [...edits];
for (const image of images) {