Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
7 changes: 4 additions & 3 deletions apps/mobile/modules/t3-markdown-text/src/markdownLinks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,13 @@ import {
inlineCodeFilePathCandidate,
normalizeMarkdownLinkDestination,
parseMarkdownFileLink,
splitFilePathPosition,
} from "@t3tools/client-runtime/markdown-links";
import { parseThreadLinkHref } from "@t3tools/shared/threadLinks";
import { videoMimeType } from "@t3tools/shared/video";

import type { MARKDOWN_FILE_ICON_SOURCES } from "./markdownFileIcons.generated";

const POSITION_SUFFIX_PATTERN = /:\d+(?::\d+)?$/;

export type MarkdownLinkPresentation =
| {
readonly kind: "external";
Expand All @@ -26,6 +25,7 @@ export type MarkdownLinkPresentation =
readonly path: string;
readonly line?: number;
readonly column?: number;
readonly endLine?: number;
}
| {
readonly kind: "link";
Expand Down Expand Up @@ -249,7 +249,7 @@ export function normalizeNativeMarkdownUrl(value: string): string {
}

export function resolveMarkdownFileIcon(value: string): MarkdownFileIcon {
const basename = fileBasename(value).replace(POSITION_SUFFIX_PATTERN, "").toLowerCase();
const basename = fileBasename(splitFilePathPosition(value).path).toLowerCase();
if (videoMimeType({ name: basename, mimeType: "" }) !== null) return "video";
const exactIcon = FILE_ICON_BY_NAME[basename];
if (exactIcon) return exactIcon;
Expand Down Expand Up @@ -289,6 +289,7 @@ export function resolveMarkdownLinkPresentation(href: string): MarkdownLinkPrese
path: target.path,
...(target.line ? { line: target.line } : {}),
...(target.column ? { column: target.column } : {}),
...(target.endLine ? { endLine: target.endLine } : {}),
};
}

Expand Down
9 changes: 9 additions & 0 deletions apps/mobile/src/lib/markdownLinks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,15 @@ describe("resolveMarkdownLinkPresentation", () => {
});

it("recognizes relative source paths and bare filenames", () => {
expect(resolveMarkdownLinkPresentation("apps/mobile/src/index.ts:49-74")).toEqual({
kind: "file",
href: "apps/mobile/src/index.ts:49-74",
icon: "typescript",
label: "index.ts:49-74",
path: "apps/mobile/src/index.ts",
line: 49,
endLine: 74,
});
expect(resolveMarkdownLinkPresentation("apps/mobile/src/index.ts:10")).toEqual({
kind: "file",
href: "apps/mobile/src/index.ts:10",
Expand Down
32 changes: 20 additions & 12 deletions apps/mobile/src/lib/nativeMarkdownText.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,19 +69,27 @@ describe("nativeMarkdownTextRuns", () => {
{ start: 9, end: 10, text: "![Screenshot](t3-context://v1/image/screenshot)" },
]);
});
it("links a path-shaped code span without changing the same path in prose", () => {
it.each([
{ path: "/tmp/frame.png", label: "frame.png", icon: "image" },
{ path: "src/main.ts:49-74", label: "main.ts:49-74", icon: "typescript" },
])("links $path in code without changing the same path in prose", ({ path, label, icon }) => {
const runs = nativeMarkdownTextRuns({
type: "paragraph",
children: [
{ type: "text", content: `${path} ` },
{ type: "code_inline", content: path },
],
});
expect(runs).toEqual([{ text: `${path} ` }, { text: label, href: path, fileIcon: icon }]);
expect(
nativeMarkdownTextRuns({
type: "paragraph",
children: [
{ type: "text", content: "/tmp/frame.png " },
{ type: "code_inline", content: "/tmp/frame.png" },
],
}),
).toEqual([
{ text: "/tmp/frame.png " },
{ text: "frame.png", href: "/tmp/frame.png", fileIcon: "image" },
]);
nativeMarkdownContextCopyRanges(
runs.map((run) => ({
run,
text: run.fileIcon ? "\uFFFC" : run.text,
inlineImageLength: 0,
})),
),
).toEqual([{ start: path.length + 1, end: path.length + 2, text: `[${label}](<${path}>)` }]);
});

it("keeps a thread link's href so a press can open the thread", () => {
Expand Down
57 changes: 41 additions & 16 deletions apps/web/src/components/ChatMarkdown.permissions.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -188,22 +188,47 @@ function offeredActions() {
return state.choose.mock.lastCall![0].map((item: { id: string }) => item.id);
}

it("removes host actions after revocation while keeping file preview and copying", async () => {
await renderMarkdown("[Readme](docs/readme.md)");
await openContextMenu();
expect(offeredActions()).toEqual(["open", "reveal", "copy-relative", "copy-full"]);
await act(async () => {
state.allowed = false;
for (const listener of state.listeners) listener();
});
state.choose.mockResolvedValue("copy-full");
await openContextMenu();
expect(offeredActions()).toEqual(["copy-relative", "copy-full"]);
expect(state.copy).toHaveBeenCalledWith("/work/docs/readme.md");
await act(async () => anchor().props.onClick(menuEvent()));
expect(state.openFile).toHaveBeenCalledWith(threadRef, "docs/readme.md", undefined);
expect(state.openEditor).not.toHaveBeenCalled();
});
it.each([
{
text: "[Readme](docs/readme.md)",
path: "docs/readme.md",
position: "",
editorPosition: "",
line: undefined,
},
{
text: "`docs/readme.md:49-74`",
path: "docs/readme.md",
position: ":49-74",
editorPosition: ":49",
line: 49,
},
])(
"keeps $path preview and copying after host actions are revoked",
async ({ text, path, position, editorPosition, line }) => {
await renderMarkdown(text);
await openContextMenu();
expect(offeredActions()).toEqual(["open", "reveal", "copy-relative", "copy-full"]);
state.choose.mockResolvedValue("open");
await openContextMenu();
expect(state.openEditor).toHaveBeenCalledExactlyOnceWith({
environmentId: threadRef.environmentId,
input: { cwd: `/work/${path}${editorPosition}`, editor: "vscode" },
});
state.openEditor.mockClear();
await act(async () => {
state.allowed = false;
for (const listener of state.listeners) listener();
});
state.choose.mockResolvedValue("copy-full");
await openContextMenu();
expect(offeredActions()).toEqual(["copy-relative", "copy-full"]);
expect(state.copy).toHaveBeenCalledWith(`/work/${path}${position}`);
await act(async () => anchor().props.onClick(menuEvent()));
expect(state.openFile).toHaveBeenCalledWith(threadRef, path, line);
expect(state.openEditor).not.toHaveBeenCalled();
},
);

it("does not launch an editor after revocation during a native context menu", async () => {
let choose: (action: string) => void = () => {
Expand Down
14 changes: 11 additions & 3 deletions apps/web/src/components/ChatMarkdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,11 @@ import {
classifyMarkdownImageSource,
markdownImageSourceFragment,
} from "@t3tools/client-runtime/markdown-images";
import { inlineCodeFilePathCandidate } from "@t3tools/client-runtime/markdown-links";
import {
formatFilePathPosition,
inlineCodeFilePathCandidate,
splitFilePathPosition,
} from "@t3tools/client-runtime/markdown-links";
import { mediaFileReference, mediaUrlReference } from "@t3tools/client-runtime/media-reference";
import { mediaKindFromPath, mediaMimeTypeFromExtension } from "@t3tools/shared/filePreview";
import * as Cause from "effect/Cause";
Expand Down Expand Up @@ -2150,7 +2154,11 @@ const MarkdownFileLink = memo(function MarkdownFileLink({
}
void (async () => {
try {
const result = await onOpen(targetPath);
// Editors accept a single line/column; keep ranges for display and copying.
const { endLine, ...position } = splitFilePathPosition(targetPath);
const result = await onOpen(
endLine === undefined ? targetPath : formatFilePathPosition(position),
);
if (result._tag === "Success" || isAtomCommandInterrupted(result)) {
return;
}
Expand Down Expand Up @@ -2835,7 +2843,7 @@ function useChatMarkdownState({
}
if (fileLinkMeta.line) {
labelParts.push(
`L${fileLinkMeta.line}${fileLinkMeta.column ? `:C${fileLinkMeta.column}` : ""}`,
`L${fileLinkMeta.line}${fileLinkMeta.endLine ? `-${fileLinkMeta.endLine}` : fileLinkMeta.column ? `:C${fileLinkMeta.column}` : ""}`,
);
}
const mediaPath = mediaSource ?? fileLinkMeta.filePath;
Expand Down
69 changes: 60 additions & 9 deletions apps/web/src/markdown-links.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -344,8 +344,13 @@ describe("relative links inside a rendered host file", () => {
filePath: "/tmp/report/appendix.md",
workspaceRelativePath: null,
});
const inline = resolveInlineCodeFileLinkMeta("Makefile:12", "/repo", "/tmp/report");
expect(inline).toMatchObject({ filePath: "/tmp/report/Makefile", line: 12 });
const inline = resolveInlineCodeFileLinkMeta("Makefile:12-18", "/repo", "/tmp/report");
expect(inline).toMatchObject({
filePath: "/tmp/report/Makefile",
targetPath: "/tmp/report/Makefile:12-18",
line: 12,
endLine: 18,
});
expect(resolveMarkdownFileLinkMeta("src/main.ts", "/repo", "/repo/docs")).toMatchObject({
filePath: "/repo/docs/src/main.ts",
workspaceRelativePath: "docs/src/main.ts",
Expand Down Expand Up @@ -382,13 +387,59 @@ describe("resolveInlineCodeFileLinkMeta", () => {
});
});

it("links relative paths with line positions", () => {
expect(
resolveInlineCodeFileLinkMeta("src/processRunner.ts:71", "/Users/julius/project"),
).toMatchObject({
targetPath: "/Users/julius/project/src/processRunner.ts:71",
line: 71,
});
it.each([
{
source: "src/processRunner.ts:71",
cwd: "/Users/julius/project",
expected: { targetPath: "/Users/julius/project/src/processRunner.ts:71", line: 71 },
},
{
source: "src/processRunner.ts:49-74",
cwd: "/Users/julius/project",
expected: {
filePath: "/Users/julius/project/src/processRunner.ts",
targetPath: "/Users/julius/project/src/processRunner.ts:49-74",
displayPath: "project/src/processRunner.ts:49-74",
workspaceRelativePath: "src/processRunner.ts",
basename: "processRunner.ts",
line: 49,
endLine: 74,
},
},
{
source: "script.ts:49-74",
cwd: "/Users/julius/project",
expected: {
filePath: "/Users/julius/project/script.ts",
targetPath: "/Users/julius/project/script.ts:49-74",
workspaceRelativePath: "script.ts",
line: 49,
endLine: 74,
},
},
{
source: "/Users/julius/project/src/processRunner.ts:49-74",
cwd: undefined,
expected: {
filePath: "/Users/julius/project/src/processRunner.ts",
targetPath: "/Users/julius/project/src/processRunner.ts:49-74",
line: 49,
endLine: 74,
},
},
{
source: "src\\main.ts:49-74",
cwd: "C:\\Users\\mike\\project",
expected: {
filePath: "C:\\Users\\mike\\project\\src\\main.ts",
targetPath: "C:\\Users\\mike\\project\\src\\main.ts:49-74",
workspaceRelativePath: "src/main.ts",
line: 49,
endLine: 74,
},
},
])("links $source with line positions", ({ source, cwd, expected }) => {
expect(resolveInlineCodeFileLinkMeta(source, cwd)).toMatchObject(expected);
});

it("links bare filenames only when a line suffix marks them as file references", () => {
Expand Down
4 changes: 3 additions & 1 deletion apps/web/src/markdown-links.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export interface MarkdownFileLinkMeta {
basename: string;
line?: number;
column?: number;
endLine?: number;
}

export function extractMarkdownLinkHrefs(markdown: string): string[] {
Expand Down Expand Up @@ -107,7 +108,7 @@ export function resolveMarkdownFileLinkMeta(
}

function buildFileLinkMetaFromTarget(targetPath: string, cwd?: string): MarkdownFileLinkMeta {
const { path, line, column } = splitFilePathPosition(targetPath);
const { path, line, column, endLine } = splitFilePathPosition(targetPath);
return {
filePath: path,
targetPath,
Expand All @@ -116,5 +117,6 @@ function buildFileLinkMetaFromTarget(targetPath: string, cwd?: string): Markdown
basename: fileBasename(path),
...(line !== undefined ? { line } : {}),
...(column !== undefined ? { column } : {}),
...(endLine !== undefined ? { endLine } : {}),
};
}
27 changes: 27 additions & 0 deletions packages/client-runtime/src/markdownLinks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { describe, expect, it } from "vite-plus/test";

import {
fileBasename,
formatFilePathPosition,
inlineCodeFilePathCandidate,
isMarkdownFileLinkLabel,
parseFileUrlHref,
Expand All @@ -20,6 +21,9 @@ describe("isMarkdownFileLinkLabel", () => {
["example.ts:99", "/repo/src/example.ts:12", false],
["example.ts:12:2", "/repo/src/example.ts:12:2", true],
["example.ts:12:3", "/repo/src/example.ts:12:2", false],
["example.ts:49-74", "/repo/src/example.ts:49-74", true],
["example.ts:49-75", "/repo/src/example.ts:49-74", false],
["example.ts:49-74", "/repo/src/example.ts:49", false],
["example.ts:12", "/repo/src/example.ts", false],
["src/example.ts:12", "/repo/src/example.ts:12", true],
["./src/example.ts", "/repo/src/example.ts", true],
Expand All @@ -40,14 +44,21 @@ describe("inlineCodeFilePathCandidate", () => {
["\\\\server\\share\\image.png", "\\\\server\\share\\image.png"],
["conf.d/nginx.conf", "conf.d/nginx.conf"],
["script.pl:10", "script.pl:10"],
["main.ts:49-74", "main.ts:49-74"],
["src\\main.ts:49-74", "src/main.ts:49-74"],
["Makefile:49-74", "Makefile:49-74"],
["script.pl:49-74", "script.pl:49-74"],
["node.meta", null],
["Recorded evidence here: /tmp/image.png", null],
["origin/main", null],
["127.0.0.1:3000", null],
["localhost:49-74", null],
["example.com:49-74", null],
["example.com/index.html", null],
["example.pl/index.html", null],
["z-ai/glm-5.3", null],
["z-ai/glm-5.3:12", null],
["z-ai/glm-5.3:49-74", null],
["python/3.12", null],
["Qwen/Qwen2.5-Coder", null],
["meta-llama/Llama-3.1-8B", null],
Expand Down Expand Up @@ -91,6 +102,12 @@ describe("splitFilePathPosition", () => {
["src/main.ts", "", { path: "src/main.ts" }],
["src/main.ts:12", "", { path: "src/main.ts", line: 12 }],
["src/main.ts:12:5", "", { path: "src/main.ts", line: 12, column: 5 }],
["src/main.ts:49-74", "", { path: "src/main.ts", line: 49, endLine: 74 }],
["src/main.ts:49-49", "", { path: "src/main.ts", line: 49, endLine: 49 }],
["src/main.ts:49-0", "", { path: "src/main.ts", line: 49 }],
["src/main.ts:74-49", "", { path: "src/main.ts", line: 74 }],
["src/main.ts:0-74", "", { path: "src/main.ts" }],
["src/main.ts:49-", "", { path: "src/main.ts:49-" }],
["src/main.ts", "#L18C2", { path: "src/main.ts", line: 18, column: 2 }],
["src/main.ts:3", "#L18C2", { path: "src/main.ts", line: 3 }],
["src/main.ts:0", "", { path: "src/main.ts" }],
Expand Down Expand Up @@ -124,6 +141,10 @@ describe("parseMarkdownFileLink", () => {
["~/notes/today.md", "~/notes/today.md"],
["AGENTS.md", "AGENTS.md"],
["script.ts:10", "script.ts"],
["script.ts:49-74", "script.ts"],
["apps/mobile/src/index.ts:49-74", "apps/mobile/src/index.ts"],
["/Users/julius/project/src/main.ts:49-74", "/Users/julius/project/src/main.ts"],
["C:\\Users\\mike\\project\\src\\main.ts:49-74", "C:\\Users\\mike\\project\\src\\main.ts"],
["/tmp/clip%23one.mp4#t=2", "/tmp/clip#one.mp4"],
])("recognizes %s as a file", (href, path) => {
expect(parseMarkdownFileLink(href)?.path).toBe(path);
Expand All @@ -141,6 +162,9 @@ describe("parseMarkdownFileLink", () => {
"/app#L1",
"readme",
"TODO:12",
"TODO:49-74",
"script.ts:49-",
"script.ts:49-nope",
])("does not treat %s as a file", (href) => {
expect(parseMarkdownFileLink(href)).toBeNull();
});
Expand All @@ -152,6 +176,9 @@ describe("parseMarkdownFileLink", () => {
});

it("reads positions from suffixes and line anchors", () => {
const range = parseMarkdownFileLink("script.ts:49-74");
expect(range).toEqual({ path: "script.ts", line: 49, endLine: 74 });
expect(formatFilePathPosition(range!)).toBe("script.ts:49-74");
expect(parseMarkdownFileLink("/Users/julius/project/src/main.ts#L42C7")).toEqual({
path: "/Users/julius/project/src/main.ts",
line: 42,
Expand Down
Loading
Loading