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
61 changes: 56 additions & 5 deletions apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import type { MarkdownNode } from "react-native-nitro-markdown/headless";
import {
escapeMarkdownLinkLabel,
isMarkdownFileLinkLabel,
} from "@t3tools/client-runtime/markdown-links";
import { collectComposerInlineTokens } from "@t3tools/shared/composerInlineTokens";
import { imageMimeType } from "@t3tools/shared/image";
import { isWindowsAbsolutePath } from "@t3tools/shared/path";
Expand Down Expand Up @@ -94,7 +98,8 @@ import {
parseComposerContextHref,
} from "@t3tools/shared/composerContextReferences";

/** Native selections count UTF-16 display units, including each inline image placeholder. */
/** Reconstructs the canonical markdown link when copying a descriptive
file-link chip, so the prose label is not dropped from the clipboard. */
export function nativeMarkdownContextCopyRanges(
runs: ReadonlyArray<{
readonly run: {
Expand All @@ -108,10 +113,15 @@ export function nativeMarkdownContextCopyRanges(
readonly inlineImageLength: number;
}>,
) {
const starts: number[] = [];
let offset = 0;
return runs.flatMap(({ run, text, inlineImageLength }) => {
const start = offset;
for (const { text, inlineImageLength } of runs) {
starts.push(offset);
offset += text.length + inlineImageLength;
}
return runs.flatMap(({ run, text, inlineImageLength }, index) => {
const start = starts[index] ?? 0;
const end = start + text.length + inlineImageLength;
const reference = parseComposerContextHref(run.href ?? "");
const source = reference
? formatComposerContextReference({ ...reference, label: run.text })
Expand All @@ -120,7 +130,25 @@ export function nativeMarkdownContextCopyRanges(
: run.fileIcon && run.href
? (run.sourceText ?? `[${run.text}](<${run.href}>)`)
: null;
return source === null ? [] : [{ start, end: offset, text: source }];
if (source === null) return [];
// A descriptive file link renders its prose label as plain href runs ahead
// of the destination chip run, which carries the full canonical link as its
// source text. Extend that range over the label so copying keeps the prose.
// Runs that already carry their own chip (fileIcon) or skill end the label.
let rangeStart = start;
if (run.fileIcon && run.href && run.sourceText) {
let previous = index - 1;
while (
previous >= 0 &&
runs[previous]?.run.href === run.href &&
runs[previous]?.run.fileIcon == null &&
runs[previous]?.run.skillName == null
) {
rangeStart = starts[previous] ?? rangeStart;
previous -= 1;
}
}
return [{ start: rangeStart, end, text: source }];
});
}

Expand Down Expand Up @@ -181,6 +209,7 @@ interface RunContext {
readonly href?: string;
readonly externalHost?: string;
readonly fileIcon?: MarkdownFileIcon;
readonly sourceText?: string;
readonly role?: NativeMarkdownTextRun["role"];
readonly headingLevel?: number;
readonly depth?: number;
Expand Down Expand Up @@ -268,6 +297,7 @@ function sameRunStyle(left: NativeMarkdownTextRun, right: NativeMarkdownTextRun)
left.href === right.href &&
left.externalHost === right.externalHost &&
left.fileIcon === right.fileIcon &&
left.sourceText === right.sourceText &&
left.skillName === right.skillName &&
left.skillLabel === right.skillLabel &&
left.role === right.role &&
Expand Down Expand Up @@ -298,6 +328,7 @@ function appendRun(
...(context.href ? { href: context.href } : {}),
...(context.externalHost ? { externalHost: context.externalHost } : {}),
...(context.fileIcon ? { fileIcon: context.fileIcon } : {}),
...(context.sourceText ? { sourceText: context.sourceText } : {}),
...(context.role ? { role: context.role } : {}),
...(context.headingLevel ? { headingLevel: context.headingLevel } : {}),
...(context.depth ? { depth: context.depth } : {}),
Expand Down Expand Up @@ -425,7 +456,8 @@ function appendChildren(
return runs;
}

function nodeTextContent(node: MarkdownNode): string {
/** Reads the inline text of a markdown node for link presentation. */
export function nodeTextContent(node: MarkdownNode): string {
if (node.content !== undefined) {
return node.content;
}
Expand Down Expand Up @@ -482,6 +514,25 @@ function appendNode(
}
const presentation = resolveMarkdownLinkPresentation(node.href ?? "");
if (presentation.kind === "file") {
// A descriptive label stays visible ahead of the destination chip.
// The chip run carries the full canonical link so copying keeps the prose.
const label = nodeTextContent(node);
if (!isMarkdownFileLinkLabel(label, node.href ?? "")) {
const labelStart = runs.length;
appendChildren(runs, node, { ...context, href: presentation.href });
// Copy the label the runs display: breaks render as spaces, so the
// canonical link must spell them the same way.
const displayedLabel = runs
.slice(labelStart)
.map((run) => run.text)
.join("");
return appendRun(runs, ` (${presentation.label})`, {
...context,
href: presentation.href,
fileIcon: presentation.icon,
sourceText: `[${escapeMarkdownLinkLabel(displayedLabel)}](<${presentation.href}>)`,
});
}
return appendRun(runs, presentation.label, {
...context,
href: presentation.href,
Expand Down
32 changes: 20 additions & 12 deletions apps/mobile/src/features/threads/ThreadFeed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import * as Haptics from "expo-haptics";
import { KeyboardAwareLegendList } from "@legendapp/list/keyboard";
import { useViewabilityAmount, type LegendListRef } from "@legendapp/list/react-native";
import { scopeThreadRef } from "@t3tools/client-runtime/environment";
import { isMarkdownFileLinkLabel } from "@t3tools/client-runtime/markdown-links";
import { resolveUserMessagePresentation } from "@t3tools/client-runtime/user-message";
import { canForkProjectedAssistantItem } from "@t3tools/client-runtime/state/thread-workflows";
import {
Expand Down Expand Up @@ -154,6 +155,7 @@ import {
resolveMarkdownLinkIcon,
resolveMarkdownLinkPresentation,
} from "@t3tools/mobile-markdown-text/links";
import { nodeTextContent } from "@t3tools/mobile-markdown-text/markdown";
import {
failedFeedRunIds,
deriveThreadFeedPresentation,
Expand Down Expand Up @@ -1183,21 +1185,27 @@ function useMarkdownStyles(
preserveSoftBreaks: boolean,
highlightCode: boolean,
): CustomRenderers => ({
link: ({ children, href = "" }) => {
link: ({ node, children, href = "" }) => {
const presentation = resolveMarkdownLinkPresentation(href);
if (presentation.kind === "file") {
// A descriptive label stays visible ahead of the destination chip.
const descriptive = !isMarkdownFileLinkLabel(nodeTextContent(node), href);
return (
<NativeText
className="font-t3-bold"
onPress={() => onLinkPress(href)}
style={{ color: inlineTextColor }}
>
<Image
source={markdownFileIconSource(presentation.icon)}
style={markdownLinkStyles.inlineIcon}
/>
{presentation.label}
</NativeText>
<MarkdownLinkLabelContext.Provider value>
<NativeText
className="font-t3-bold"
onPress={() => onLinkPress(href)}
style={{ color: inlineTextColor }}
>
{descriptive ? <>{children} (</> : null}
<Image
source={markdownFileIconSource(presentation.icon)}
style={markdownLinkStyles.inlineIcon}
/>
{presentation.label}
{descriptive ? ")" : null}
</NativeText>
</MarkdownLinkLabelContext.Provider>
);
}
if (presentation.kind === "external") {
Expand Down
157 changes: 146 additions & 11 deletions apps/mobile/src/lib/nativeMarkdownText.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,16 +125,10 @@ describe("nativeMarkdownTextRuns", () => {
]);
});

it("normalizes external and file links for native presentation", () => {
it("preserves descriptive file-link labels beside the destination chip", () => {
const node: MarkdownNode = {
type: "paragraph",
children: [
{
type: "link",
href: "https://example.com/docs",
children: [{ type: "text", content: "Docs" }],
},
{ type: "text", content: " " },
{
type: "link",
href: "file:///repo/README.md#L12",
Expand All @@ -145,11 +139,31 @@ describe("nativeMarkdownTextRuns", () => {

expect(nativeMarkdownTextRuns(node)).toEqual([
{
text: "Docs",
href: "https://example.com/docs",
externalHost: "example.com",
text: "ignored label",
href: "file:///repo/README.md#L12",
},
{ text: " " },
{
text: " (README.md:12)",
href: "file:///repo/README.md#L12",
fileIcon: "markdown",
sourceText: "[ignored label](<file:///repo/README.md#L12>)",
},
]);
});

it("keeps filename file-link labels compact", () => {
const node: MarkdownNode = {
type: "paragraph",
children: [
{
type: "link",
href: "file:///repo/README.md#L12",
children: [{ type: "text", content: "README.md:12" }],
},
],
};

expect(nativeMarkdownTextRuns(node)).toEqual([
{
text: "README.md:12",
href: "file:///repo/README.md#L12",
Expand All @@ -158,6 +172,127 @@ describe("nativeMarkdownTextRuns", () => {
]);
});

it("preserves descriptive file-link formatting and keeps filename links compact", () => {
const href = "/repo/src/example.ts:12";
expect(
nativeMarkdownTextRuns({
type: "paragraph",
children: [
{
type: "link",
href,
children: [
{ type: "bold", children: [{ type: "text", content: "validates" }] },
{ type: "text", content: " the input" },
],
},
{ type: "link", href, children: [{ type: "code_inline", content: "src/example.ts:12" }] },
],
}),
).toEqual([
{ text: "validates", bold: true, href },
{ text: " the input", href },
{
text: " (example.ts:12)",
href,
fileIcon: "typescript",
sourceText: "[validates the input](</repo/src/example.ts:12>)",
},
{ text: "example.ts:12", href, fileIcon: "typescript" },
]);
});

it("copies a descriptive file link with its prose label intact", () => {
const runs = nativeMarkdownTextRuns({
type: "paragraph",
children: [
{
type: "link",
href: "file:///repo/README.md#L12",
children: [{ type: "text", content: "ignored label" }],
},
],
});

expect(
nativeMarkdownContextCopyRanges(
runs.map((run) => ({ run, text: run.text, inlineImageLength: run.fileIcon ? 1 : 0 })),
),
).toEqual([
{
start: 0,
end: "ignored label (README.md:12)".length + 1,
text: "[ignored label](<file:///repo/README.md#L12>)",
},
]);
});

it("copies soft breaks in descriptive file-link labels as displayed", () => {
const runs = nativeMarkdownTextRuns({
type: "paragraph",
children: [
{
type: "link",
href: "file:///repo/README.md#L12",
children: [
{ type: "text", content: "read" },
{ type: "soft_break" },
{ type: "text", content: "here" },
],
},
],
});

expect(runs.map((run) => run.text).join("")).toBe("read here (README.md:12)");
expect(
nativeMarkdownContextCopyRanges(
runs.map((run) => ({ run, text: run.text, inlineImageLength: run.fileIcon ? 1 : 0 })),
),
).toEqual([
{
start: 0,
end: "read here (README.md:12)".length + 1,
text: "[read here](<file:///repo/README.md#L12>)",
},
]);
});

it("keeps copy ranges for adjacent same-destination links separate", () => {
const runs = nativeMarkdownTextRuns({
type: "paragraph",
children: [
{
type: "link",
href: "/repo/src/example.ts:12",
children: [{ type: "text", content: "validates the input" }],
},
{
type: "link",
href: "/repo/src/example.ts:12",
children: [{ type: "text", content: "example.ts:12" }],
},
],
});

const firstEnd = "validates the input (example.ts:12)".length + 1;
expect(
nativeMarkdownContextCopyRanges(
runs.map((run) => ({ run, text: run.text, inlineImageLength: run.fileIcon ? 1 : 0 })),
),
).toEqual([
{
start: 0,
end: firstEnd,
text: "[validates the input](</repo/src/example.ts:12>)",
},
{
start: firstEnd,
end: firstEnd + "example.ts:12".length + 1,
text: "[example.ts:12](</repo/src/example.ts:12>)",
},
]);
});

it("keeps hard breaks and collapses soft breaks", () => {
const node: MarkdownNode = {
type: "paragraph",
Expand Down
Loading
Loading