* Allow previews to open readable files * Fix Windows file preview path test * Address file preview review comments
472 lines
14 KiB
TypeScript
472 lines
14 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import {
|
|
classifyAssistantFileLink,
|
|
normalizeInlinePathTarget,
|
|
parseAssistantFileLink,
|
|
parseFileProtocolUrl,
|
|
parseInlinePathToken,
|
|
} from "./parse";
|
|
|
|
describe("parseInlinePathToken", () => {
|
|
it("returns null for plain paths without a line number", () => {
|
|
expect(parseInlinePathToken("src/app.ts")).toBeNull();
|
|
expect(parseInlinePathToken("README.md")).toBeNull();
|
|
});
|
|
|
|
it("parses filename:line", () => {
|
|
expect(parseInlinePathToken("src/app.ts:12")).toEqual({
|
|
raw: "src/app.ts:12",
|
|
path: "src/app.ts",
|
|
lineStart: 12,
|
|
lineEnd: undefined,
|
|
});
|
|
});
|
|
|
|
it("parses filename:lineStart-lineEnd", () => {
|
|
expect(parseInlinePathToken("src/app.ts:12-20")).toEqual({
|
|
raw: "src/app.ts:12-20",
|
|
path: "src/app.ts",
|
|
lineStart: 12,
|
|
lineEnd: 20,
|
|
});
|
|
});
|
|
|
|
it("parses filename:line:column as a line target", () => {
|
|
expect(parseInlinePathToken("src/app.ts:12:4")).toEqual({
|
|
raw: "src/app.ts:12:4",
|
|
path: "src/app.ts",
|
|
lineStart: 12,
|
|
lineEnd: undefined,
|
|
});
|
|
});
|
|
|
|
it("parses filename(line,column) as a line target", () => {
|
|
expect(parseInlinePathToken("src/app.ts(12,4)")).toEqual({
|
|
raw: "src/app.ts(12,4)",
|
|
path: "src/app.ts",
|
|
lineStart: 12,
|
|
lineEnd: undefined,
|
|
});
|
|
});
|
|
|
|
it("parses filename lines lineStart-lineEnd", () => {
|
|
expect(parseInlinePathToken("src/app.ts lines 12-20")).toEqual({
|
|
raw: "src/app.ts lines 12-20",
|
|
path: "src/app.ts",
|
|
lineStart: 12,
|
|
lineEnd: 20,
|
|
});
|
|
});
|
|
|
|
it("rejects range-only :line tokens", () => {
|
|
expect(parseInlinePathToken(":12")).toBeNull();
|
|
expect(parseInlinePathToken(":12-20")).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("parseFileProtocolUrl", () => {
|
|
it("parses file URLs with line fragments", () => {
|
|
expect(parseFileProtocolUrl("file:///Users/test/project/src/app.tsx#L81")).toEqual({
|
|
raw: "file:///Users/test/project/src/app.tsx#L81",
|
|
path: "/Users/test/project/src/app.tsx",
|
|
lineStart: 81,
|
|
lineEnd: undefined,
|
|
});
|
|
});
|
|
|
|
it("parses file URLs with line-column fragments", () => {
|
|
expect(parseFileProtocolUrl("file:///Users/test/project/src/app.tsx#L81C5-L83C2")).toEqual({
|
|
raw: "file:///Users/test/project/src/app.tsx#L81C5-L83C2",
|
|
path: "/Users/test/project/src/app.tsx",
|
|
lineStart: 81,
|
|
lineEnd: 83,
|
|
});
|
|
});
|
|
|
|
it("parses file URLs without line fragments", () => {
|
|
expect(parseFileProtocolUrl("file:///Users/test/project/src/app.tsx")).toEqual({
|
|
raw: "file:///Users/test/project/src/app.tsx",
|
|
path: "/Users/test/project/src/app.tsx",
|
|
lineStart: undefined,
|
|
lineEnd: undefined,
|
|
});
|
|
});
|
|
|
|
it("parses windows file URLs and line ranges", () => {
|
|
expect(parseFileProtocolUrl("file:///C:/Users/test/project/src/app.tsx#L12-L20")).toEqual({
|
|
raw: "file:///C:/Users/test/project/src/app.tsx#L12-L20",
|
|
path: "C:/Users/test/project/src/app.tsx",
|
|
lineStart: 12,
|
|
lineEnd: 20,
|
|
});
|
|
});
|
|
|
|
it("rejects non-file URLs and invalid ranges", () => {
|
|
expect(parseFileProtocolUrl("https://example.com/test.ts#L10")).toBeNull();
|
|
expect(parseFileProtocolUrl("file:///Users/test/project/src/app.tsx#L20-L12")).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("classifyAssistantFileLink", () => {
|
|
it("keeps explicit external URLs out of file parsing", () => {
|
|
expect(
|
|
classifyAssistantFileLink("http://dumm.md", {
|
|
workspaceRoot: "/Users/test/project",
|
|
}),
|
|
).toEqual({
|
|
kind: "external",
|
|
raw: "http://dumm.md",
|
|
});
|
|
expect(classifyAssistantFileLink("mailto:test@example.com")).toEqual({
|
|
kind: "external",
|
|
raw: "mailto:test@example.com",
|
|
});
|
|
});
|
|
|
|
it("classifies bare workspace candidates separately from direct relative files", () => {
|
|
expect(
|
|
classifyAssistantFileLink("dumm.md", {
|
|
workspaceRoot: "/Users/test/project",
|
|
}),
|
|
).toEqual({
|
|
kind: "ambiguousFileCandidate",
|
|
target: {
|
|
raw: "dumm.md",
|
|
path: "/Users/test/project/dumm.md",
|
|
lineStart: undefined,
|
|
lineEnd: undefined,
|
|
},
|
|
});
|
|
|
|
expect(
|
|
classifyAssistantFileLink("message-renderer.tsx", {
|
|
workspaceRoot: "/Users/test/project",
|
|
}),
|
|
).toEqual({
|
|
kind: "ambiguousFileCandidate",
|
|
target: {
|
|
raw: "message-renderer.tsx",
|
|
path: "/Users/test/project/message-renderer.tsx",
|
|
lineStart: undefined,
|
|
lineEnd: undefined,
|
|
},
|
|
});
|
|
|
|
expect(
|
|
classifyAssistantFileLink("src/components/message.tsx#L33", {
|
|
workspaceRoot: "/Users/test/project",
|
|
}),
|
|
).toEqual({
|
|
kind: "directFile",
|
|
target: {
|
|
raw: "src/components/message.tsx#L33",
|
|
path: "/Users/test/project/src/components/message.tsx",
|
|
lineStart: 33,
|
|
lineEnd: undefined,
|
|
},
|
|
});
|
|
});
|
|
|
|
it("does not classify normal bare domains as file candidates", () => {
|
|
expect(
|
|
classifyAssistantFileLink("google.com", {
|
|
workspaceRoot: "/Users/test/project",
|
|
}),
|
|
).toBeNull();
|
|
expect(
|
|
classifyAssistantFileLink("example.com", {
|
|
workspaceRoot: "/Users/test/project",
|
|
}),
|
|
).toBeNull();
|
|
expect(
|
|
classifyAssistantFileLink("openai.com/path", {
|
|
workspaceRoot: "/Users/test/project",
|
|
}),
|
|
).toBeNull();
|
|
});
|
|
|
|
it("does not classify plain inline code words or identifiers as file candidates", () => {
|
|
for (const value of [
|
|
"main",
|
|
"origin/main",
|
|
"1f7fc232b",
|
|
"25994904967",
|
|
"babysit main 1f7fc232b",
|
|
]) {
|
|
expect(
|
|
classifyAssistantFileLink(value, {
|
|
workspaceRoot: "/Users/test/project",
|
|
}),
|
|
).toBeNull();
|
|
}
|
|
});
|
|
|
|
it("does not classify shell commands containing path arguments as file candidates", () => {
|
|
expect(
|
|
classifyAssistantFileLink(
|
|
"npm run lint -- packages/app/src/stores/workspace-layout-actions.ts packages/app/src/stores/workspace-layout-store.ts packages/app/src/screens/workspace/workspace-screen.tsx",
|
|
{
|
|
workspaceRoot: "/Users/test/project",
|
|
},
|
|
),
|
|
).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("parseAssistantFileLink", () => {
|
|
it("resolves bare markdown filenames against the active workspace", () => {
|
|
expect(
|
|
parseAssistantFileLink("dumm.md", {
|
|
workspaceRoot: "/Users/test/project",
|
|
}),
|
|
).toEqual({
|
|
raw: "dumm.md",
|
|
path: "/Users/test/project/dumm.md",
|
|
lineStart: undefined,
|
|
lineEnd: undefined,
|
|
});
|
|
});
|
|
|
|
it("resolves bare source filenames with line suffixes against the active workspace", () => {
|
|
expect(
|
|
parseAssistantFileLink("file.ts:12", {
|
|
workspaceRoot: "/Users/test/project",
|
|
}),
|
|
).toEqual({
|
|
raw: "file.ts:12",
|
|
path: "/Users/test/project/file.ts",
|
|
lineStart: 12,
|
|
lineEnd: undefined,
|
|
});
|
|
});
|
|
|
|
it("rejects bare domains and domain-like paths", () => {
|
|
expect(
|
|
parseAssistantFileLink("google.com", {
|
|
workspaceRoot: "/Users/test/project",
|
|
}),
|
|
).toBeNull();
|
|
expect(
|
|
parseAssistantFileLink("google.com:80", {
|
|
workspaceRoot: "/Users/test/project",
|
|
}),
|
|
).toBeNull();
|
|
expect(
|
|
parseAssistantFileLink("openai.com/path", {
|
|
workspaceRoot: "/Users/test/project",
|
|
}),
|
|
).toBeNull();
|
|
});
|
|
|
|
it("resolves relative paths against the active workspace", () => {
|
|
expect(
|
|
parseAssistantFileLink("src/components/message.tsx#L33", {
|
|
workspaceRoot: "/Users/test/project",
|
|
}),
|
|
).toEqual({
|
|
raw: "src/components/message.tsx#L33",
|
|
path: "/Users/test/project/src/components/message.tsx",
|
|
lineStart: 33,
|
|
lineEnd: undefined,
|
|
});
|
|
});
|
|
|
|
it("parses absolute POSIX hrefs inside the active workspace", () => {
|
|
expect(
|
|
parseAssistantFileLink("/Users/test/project/src/app.tsx#L33", {
|
|
workspaceRoot: "/Users/test/project",
|
|
}),
|
|
).toEqual({
|
|
raw: "/Users/test/project/src/app.tsx#L33",
|
|
path: "/Users/test/project/src/app.tsx",
|
|
lineStart: 33,
|
|
lineEnd: undefined,
|
|
});
|
|
});
|
|
|
|
it("parses absolute POSIX hrefs with VS Code-style line suffixes inside the active workspace", () => {
|
|
expect(
|
|
parseAssistantFileLink("/Users/test/project/src/app.tsx:33", {
|
|
workspaceRoot: "/Users/test/project",
|
|
}),
|
|
).toEqual({
|
|
raw: "/Users/test/project/src/app.tsx:33",
|
|
path: "/Users/test/project/src/app.tsx",
|
|
lineStart: 33,
|
|
lineEnd: undefined,
|
|
});
|
|
});
|
|
|
|
it("parses absolute Windows hrefs inside the active workspace", () => {
|
|
expect(
|
|
parseAssistantFileLink("C:/repo/src/app.tsx#L12-L20", {
|
|
workspaceRoot: "C:/repo",
|
|
}),
|
|
).toEqual({
|
|
raw: "C:/repo/src/app.tsx#L12-L20",
|
|
path: "C:/repo/src/app.tsx",
|
|
lineStart: 12,
|
|
lineEnd: 20,
|
|
});
|
|
});
|
|
|
|
it("parses absolute Windows hrefs with VS Code-style line suffixes inside the active workspace", () => {
|
|
expect(
|
|
parseAssistantFileLink("C:/repo/src/app.tsx:12-20", {
|
|
workspaceRoot: "C:/repo",
|
|
}),
|
|
).toEqual({
|
|
raw: "C:/repo/src/app.tsx:12-20",
|
|
path: "C:/repo/src/app.tsx",
|
|
lineStart: 12,
|
|
lineEnd: 20,
|
|
});
|
|
});
|
|
|
|
it("allows file URLs even when they are outside the workspace root", () => {
|
|
expect(
|
|
parseAssistantFileLink("file:///tmp/outside.txt", {
|
|
workspaceRoot: "/Users/test/project",
|
|
}),
|
|
).toEqual({
|
|
raw: "file:///tmp/outside.txt",
|
|
path: "/tmp/outside.txt",
|
|
lineStart: undefined,
|
|
lineEnd: undefined,
|
|
});
|
|
});
|
|
|
|
it("allows absolute hrefs outside the workspace root", () => {
|
|
expect(
|
|
parseAssistantFileLink("/tmp/outside.txt", {
|
|
workspaceRoot: "/Users/test/project",
|
|
}),
|
|
).toEqual({
|
|
raw: "/tmp/outside.txt",
|
|
path: "/tmp/outside.txt",
|
|
lineStart: undefined,
|
|
lineEnd: undefined,
|
|
});
|
|
});
|
|
|
|
it("keeps tilde hrefs as direct home-relative file targets", () => {
|
|
expect(
|
|
parseAssistantFileLink("~/.paseo/plans/file-preview.md", {
|
|
workspaceRoot: "/Users/test/project",
|
|
}),
|
|
).toEqual({
|
|
raw: "~/.paseo/plans/file-preview.md",
|
|
path: "~/.paseo/plans/file-preview.md",
|
|
lineStart: undefined,
|
|
lineEnd: undefined,
|
|
});
|
|
expect(
|
|
parseAssistantFileLink("~/.paseo/plans/file-preview.md:12", {
|
|
workspaceRoot: "/Users/test/project",
|
|
}),
|
|
).toEqual({
|
|
raw: "~/.paseo/plans/file-preview.md:12",
|
|
path: "~/.paseo/plans/file-preview.md",
|
|
lineStart: 12,
|
|
lineEnd: undefined,
|
|
});
|
|
expect(
|
|
parseAssistantFileLink("~\\.paseo\\plans\\file-preview.md", {
|
|
workspaceRoot: "/Users/test/project",
|
|
}),
|
|
).toEqual({
|
|
raw: "~\\.paseo\\plans\\file-preview.md",
|
|
path: "~/.paseo/plans/file-preview.md",
|
|
lineStart: undefined,
|
|
lineEnd: undefined,
|
|
});
|
|
});
|
|
|
|
it("rejects external URLs", () => {
|
|
expect(parseAssistantFileLink("https://example.com/Users/test/project/src/app.tsx")).toBeNull();
|
|
expect(
|
|
parseAssistantFileLink("http://dumm.md", {
|
|
workspaceRoot: "/Users/test/project",
|
|
}),
|
|
).toBeNull();
|
|
});
|
|
|
|
it("rejects invalid line fragments", () => {
|
|
expect(
|
|
parseAssistantFileLink("/Users/test/project/src/app.tsx#L20-L12", {
|
|
workspaceRoot: "/Users/test/project",
|
|
}),
|
|
).toBeNull();
|
|
});
|
|
|
|
it("does not throw when the input contains a literal '%' that is not a valid percent-escape", () => {
|
|
// Regressions for tool output strings like ping's "100% packet loss",
|
|
// Windows "%PATH%" references, and percentages such as "0% off".
|
|
// decodeURIComponent throws URIError on these; the parser must swallow
|
|
// it and return null rather than crash the renderer.
|
|
const cases = [
|
|
"/tmp/100% packet loss",
|
|
"/Users/test/project/0% off",
|
|
"/var/log/%PATH%/x.log",
|
|
"file:///tmp/100% packet loss",
|
|
];
|
|
for (const value of cases) {
|
|
expect(() =>
|
|
parseAssistantFileLink(value, { workspaceRoot: "/Users/test/project" }),
|
|
).not.toThrow();
|
|
}
|
|
expect(() => parseFileProtocolUrl("file:///tmp/100% packet loss")).not.toThrow();
|
|
});
|
|
});
|
|
|
|
describe("normalizeInlinePathTarget", () => {
|
|
it("keeps relative file paths as file targets", () => {
|
|
expect(normalizeInlinePathTarget("packages/app/src/components/message.tsx")).toEqual({
|
|
directory: "packages/app/src/components",
|
|
file: "packages/app/src/components/message.tsx",
|
|
});
|
|
});
|
|
|
|
it("resolves absolute paths under cwd back to workspace-relative paths", () => {
|
|
expect(
|
|
normalizeInlinePathTarget(
|
|
"/Users/test/project/packages/app/src/components/message.tsx",
|
|
"/Users/test/project",
|
|
),
|
|
).toEqual({
|
|
directory: "packages/app/src/components",
|
|
file: "packages/app/src/components/message.tsx",
|
|
});
|
|
});
|
|
|
|
it("keeps absolute paths outside cwd as absolute file targets", () => {
|
|
expect(normalizeInlinePathTarget("/tmp/message.tsx", "/Users/test/project")).toEqual({
|
|
directory: "/tmp",
|
|
file: "/tmp/message.tsx",
|
|
});
|
|
});
|
|
|
|
it("keeps tilde paths as home-relative file targets", () => {
|
|
expect(
|
|
normalizeInlinePathTarget("~/.paseo/plans/file-preview.md", "/Users/test/project"),
|
|
).toEqual({
|
|
directory: "~/.paseo/plans",
|
|
file: "~/.paseo/plans/file-preview.md",
|
|
});
|
|
});
|
|
|
|
it("treats cwd itself as the workspace root directory", () => {
|
|
expect(normalizeInlinePathTarget("/Users/test/project", "/Users/test/project")).toEqual({
|
|
directory: ".",
|
|
});
|
|
});
|
|
|
|
it("keeps trailing-slash paths as directories", () => {
|
|
expect(
|
|
normalizeInlinePathTarget("/Users/test/project/packages/app/", "/Users/test/project"),
|
|
).toEqual({
|
|
directory: "packages/app",
|
|
});
|
|
});
|
|
});
|