mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-10 10:25:03 +00:00
fix(markdown): preserve file links and image revisions
This commit is contained in:
@@ -2,7 +2,7 @@ export {
|
||||
classifyAssistantFileLink,
|
||||
normalizeInlinePathTarget,
|
||||
type InlinePathTarget,
|
||||
} from "./parse";
|
||||
} from "./paths";
|
||||
export {
|
||||
AssistantFileLinkResolverProvider,
|
||||
useAssistantFileLinkEnvironment,
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user