Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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
8 changes: 4 additions & 4 deletions apps/mobile/modules/t3-markdown-text/src/markdownLinks.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { fileBasename } from "@t3tools/shared/path";
import { formatFilePathPosition } from "@t3tools/shared/fileLinks";
import { formatFilePathPosition, splitFilePathPosition } from "@t3tools/shared/fileLinks";
import {
inlineCodeFilePathCandidate,
normalizeMarkdownLinkDestination,
Expand All @@ -10,8 +10,6 @@ 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 +24,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 +248,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 +288,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
9 changes: 8 additions & 1 deletion apps/web/src/components/ChatMarkdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ import { MarkdownFindContext, useFindRevealRef } from "./chat/markdownFindContex
import {
buildFileLinkParentSuffixByPath,
fileLinkLabel,
formatFilePathPosition,
resolvePathLinkTarget,
splitFilePathPosition,
} from "@t3tools/shared/fileLinks";
import {
isWindowsDrivePathHref,
Expand Down Expand Up @@ -1892,7 +1894,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 @@ -2592,6 +2598,7 @@ function useChatMarkdownState({
path: fileLinkMeta.filePath,
...(fileLinkMeta.line !== undefined ? { line: fileLinkMeta.line } : {}),
...(fileLinkMeta.column !== undefined ? { column: fileLinkMeta.column } : {}),
...(fileLinkMeta.endLine !== undefined ? { endLine: fileLinkMeta.endLine } : {}),
},
fileLinkParentSuffixByPath,
)}
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 @@ -84,8 +84,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 @@ -122,13 +127,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 @@ -17,6 +17,7 @@ export interface MarkdownFileLinkMeta {
basename: string;
line?: number;
column?: number;
endLine?: number;
}

export function shouldOpenMarkdownFileLinkInEditor(
Expand Down Expand Up @@ -64,7 +65,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 @@ -73,5 +74,6 @@ function buildFileLinkMetaFromTarget(targetPath: string, cwd?: string): Markdown
basename: fileBasename(path),
...(line !== undefined ? { line } : {}),
...(column !== undefined ? { column } : {}),
...(endLine !== undefined ? { endLine } : {}),
};
}
6 changes: 6 additions & 0 deletions packages/shared/src/fileLinks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,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
10 changes: 8 additions & 2 deletions packages/shared/src/fileLinks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {
stripSlashPrefixedWindowsDrive,
} from "./path.ts";

const POSITION_SUFFIX_CAPTURE_PATTERN = /:(\d+)(?::(\d+))?$/;
const POSITION_SUFFIX_CAPTURE_PATTERN = /:(\d+)(?::(\d+)|-(\d+))?$/;

const POSITION_HASH_PATTERN = /^#L(\d+)(?:C(\d+))?$/i;

Expand Down Expand Up @@ -36,6 +36,7 @@ export interface FilePathPosition {
readonly path: string;
readonly line?: number;
readonly column?: number;
readonly endLine?: number;
}

export function splitFilePathPosition(path: string, hash = ""): FilePathPosition {
Expand All @@ -45,15 +46,18 @@ export function splitFilePathPosition(path: string, hash = ""): FilePathPosition

const line = Number.parseInt(match[1], 10);
const column = match[2] === undefined ? undefined : Number.parseInt(match[2], 10);
const endLine = match[3] === undefined ? undefined : Number.parseInt(match[3], 10);
return {
path: suffixMatch ? path.slice(0, -suffixMatch[0].length) : path,
...(line > 0 ? { line } : {}),
...(column !== undefined && column > 0 ? { column } : {}),
...(line > 0 && endLine !== undefined && endLine >= line ? { endLine } : {}),
};
}

export function formatFilePathPosition(position: FilePathPosition): string {
if (!position.line) return position.path;
if (position.endLine) return `${position.path}:${position.line}-${position.endLine}`;
return `${position.path}:${position.line}${position.column ? `:${position.column}` : ""}`;
}

Expand Down Expand Up @@ -176,7 +180,9 @@ export function fileLinkLabel(
return [
fileBasename(file.path),
suffix,
file.line ? `L${file.line}${file.column ? `:C${file.column}` : ""}` : undefined,
file.line
? `L${file.line}${file.endLine ? `-${file.endLine}` : file.column ? `:C${file.column}` : ""}`
: undefined,
]
.filter(Boolean)
.join(" · ");
Expand Down
Loading
Loading