Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions apps/mobile/src/features/files/FileMarkdownPreview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,11 @@ export function FileMarkdownPreview(props: {
false,
)
}
inlineFileScope={{
environmentId: props.environmentId,
workspaceRoot: markdownDirectory,
searchBasenames: false,
}}
onLinkPress={onLinkPress}
renderImage={renderImage}
resolveImageSource={resolveImageSource}
Expand Down
11 changes: 11 additions & 0 deletions apps/mobile/src/features/threads/ThreadFeed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,7 @@ import { splitMarkdownDetails } from "../../lib/markdownDetails";
import { hasWideMarkdownBlock } from "../../lib/wideMarkdownBlocks";
import {
SelectableMarkdownText,
type InlineFileScope,
type MarkdownFileContextMenu,
type MarkdownImageRenderer,
type MarkdownImageSourceResolver,
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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,
Expand Down
96 changes: 62 additions & 34 deletions apps/mobile/src/lib/markdownDocument.ts
Original file line number Diff line number Diff line change
Expand Up @@ -287,50 +287,67 @@ export function markdownWithHtml(markdown: string): string {
// Start only at the beginning of a backtick run to avoid retrying every suffix.
const INLINE_CODE_SPAN = /(?<!`)`+([^`\r\n]+)`+/g;

export function markdownWithInlineFileLinks(markdown: string, fileLink: RegExp): string {
return transformNativeMarkdown(markdown, fileLink, false);
export interface InlineFileLinks {
readonly pattern: RegExp;
/** Whether the written path names a file known to exist; others stay inline code. */
readonly exists: (path: string) => boolean;
}

export interface NativeMarkdown {
readonly markdown: string;
/** Link URLs that came from inline code, never shared with an authored link. */
readonly inlineFileHrefs: ReadonlySet<string>;
}

const NO_INLINE_FILE_HREFS: ReadonlySet<string> = 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<string> {
const candidates = new Set<string>();
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<MarkdownEdit & { readonly href?: string }> = [];
const authoredUrls = new Set<string>();
const visit = (node: MarkdownNode) => {
if (linkFiles && node.type === "inlineCode") {
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 &&
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: `[${label}](<${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;
Expand All @@ -356,10 +373,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.
Expand All @@ -383,7 +408,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.
Expand Down Expand Up @@ -440,8 +465,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. */
Expand Down
5 changes: 4 additions & 1 deletion apps/mobile/src/lib/markdownHtml.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] }) => {
Expand Down
37 changes: 33 additions & 4 deletions apps/mobile/src/lib/markdownInlineFileLinks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([
Expand Down Expand Up @@ -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);
}
Expand All @@ -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](<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](<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);
Expand All @@ -84,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&copy;.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&copy;.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);
Expand Down
28 changes: 21 additions & 7 deletions apps/mobile/src/native/EnrichedMarkdownText.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -164,20 +165,28 @@ 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<string>(),
inlineFileHrefKey: [...native.inlineFileHrefs].join("\n"),
};
}
const content = markdownWithInlineFileLinks(
markdownForNativeParsing(props.markdown),
ENRICHED_INLINE_FILE_LINK_REGEX,
fileLinks,
);
const files = markdownWithFileEmbeds(content, fileEmbedLabel);
return { ...files, markdown: markdownWithTableBreaks(files.markdown) };
}, [props.markdown, fileEmbedLabel]);
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<ReadonlyArray<string>>([]);
const onLinksChange = (links: MarkdownLinkOccurrence[]) => {
const urls = [...new Set(links.map((link) => link.url))];
Expand All @@ -200,7 +209,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;
Expand Down
1 change: 1 addition & 0 deletions apps/mobile/src/native/SelectableMarkdownText.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { themeColorWithAlpha } from "../lib/mobileTheme";
import { useUniwindTheme } from "../lib/useUniwindTheme";

export type {
InlineFileScope,
MarkdownFileContextMenu,
MarkdownFileContextMenuAction,
MarkdownImageRenderer,
Expand Down
10 changes: 10 additions & 0 deletions apps/mobile/src/native/SelectableMarkdownText.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down
11 changes: 8 additions & 3 deletions apps/mobile/src/native/useEnrichedLinkVariants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ export function withChipIcon(
function presentation(
url: string,
props: Pick<SelectableMarkdownTextProps, "linkCustomization" | "skills" | "textStyle">,
inlineCode: boolean,
): MarkdownLinkCustomization | undefined {
const custom = props.linkCustomization?.(url);
if (custom) return custom;
Expand All @@ -75,7 +76,8 @@ function presentation(
const link = resolveMarkdownLinkPresentation(url);
if (link.kind === "file")
return {
label: link.label,
// Inline code becomes `[path](<path>)`, so no label keeps the written path.
...(inlineCode ? {} : { label: link.label }),
icon: markdownFileIconSource(link.icon),
chip: { accent: contextChipPresentation("mention").accent, tintsIcon: false },
};
Expand All @@ -93,11 +95,14 @@ export function useEnrichedLinkVariants(
urls: ReadonlyArray<string>,
props: SelectableMarkdownTextProps,
fileHrefs: ReadonlySet<string>,
// Newline-joined so a streaming tick keeps `links` stable.
inlineFileHrefKey: string,
) {
const { linkCustomization, skills, textStyle, fileEmbedLabel } = props;
const theme = useUniwindTheme();
const links = useMemo(() => {
const byUrl = new Map<string, MarkdownLinkCustomization>();
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;
Expand All @@ -107,11 +112,11 @@ export function useEnrichedLinkVariants(
color: textStyle.fileTextColor,
icon: markdownFileIconSource(resolveMarkdownFileIcon(label)),
}
: presentation(url, { linkCustomization, skills, textStyle });
: presentation(url, { linkCustomization, skills, textStyle }, inlineFileHrefs.has(url));
if (value) byUrl.set(url, value);
}
return byUrl;
}, [urls, linkCustomization, skills, textStyle, fileHrefs, fileEmbedLabel]);
}, [urls, inlineFileHrefKey, linkCustomization, skills, textStyle, fileHrefs, fileEmbedLabel]);
const chipForegrounds = useMemo(
() =>
new Map(
Expand Down
Loading
Loading