Allow previews to open readable files (#1214)
* Allow previews to open readable files * Fix Windows file preview path test * Address file preview review comments
This commit is contained in:
@@ -336,12 +336,50 @@ describe("parseAssistantFileLink", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects absolute hrefs outside the workspace root", () => {
|
||||
it("allows absolute hrefs outside the workspace root", () => {
|
||||
expect(
|
||||
parseAssistantFileLink("/tmp/outside.txt", {
|
||||
workspaceRoot: "/Users/test/project",
|
||||
}),
|
||||
).toBeNull();
|
||||
).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", () => {
|
||||
@@ -408,6 +446,15 @@ describe("normalizeInlinePathTarget", () => {
|
||||
});
|
||||
});
|
||||
|
||||
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: ".",
|
||||
|
||||
@@ -233,10 +233,7 @@ export function parseFileProtocolUrl(value: string): InlinePathTarget | null {
|
||||
};
|
||||
}
|
||||
|
||||
function parseAssistantInlinePathLink(
|
||||
value: string,
|
||||
options: AssistantHrefParseOptions,
|
||||
): InlinePathTarget | null {
|
||||
function parseAssistantInlinePathLink(value: string): InlinePathTarget | null {
|
||||
const inlinePathTarget = parseInlinePathToken(value);
|
||||
if (!inlinePathTarget) {
|
||||
return null;
|
||||
@@ -247,10 +244,6 @@ function parseAssistantInlinePathLink(
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!isAllowedAbsolutePath(normalizedPath, options.workspaceRoot)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
...inlinePathTarget,
|
||||
path: normalizedPath,
|
||||
@@ -314,9 +307,7 @@ export function parseAssistantFileLink(
|
||||
return null;
|
||||
}
|
||||
|
||||
const inlinePathTarget = parseAssistantInlinePathLink(trimmed, {
|
||||
workspaceRoot: options.workspaceRoot,
|
||||
});
|
||||
const inlinePathTarget = parseAssistantInlinePathLink(trimmed);
|
||||
if (inlinePathTarget) {
|
||||
return inlinePathTarget;
|
||||
}
|
||||
@@ -324,7 +315,7 @@ export function parseAssistantFileLink(
|
||||
const windowsPathMatch = trimmed.match(/^([A-Za-z]:[\\/][^?#]*)(#[^?]+)?$/);
|
||||
if (windowsPathMatch) {
|
||||
const normalizedPath = normalizePathToken(windowsPathMatch[1] ?? "");
|
||||
if (!normalizedPath || !isAllowedAbsolutePath(normalizedPath, options.workspaceRoot)) {
|
||||
if (!normalizedPath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -363,10 +354,6 @@ export function parseAssistantFileLink(
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!isAllowedAbsolutePath(normalizedPath, options.workspaceRoot)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const lines = parseLineFragment(parsedUrl.hash);
|
||||
if (!lines) {
|
||||
return null;
|
||||
@@ -402,13 +389,21 @@ function parseWorkspaceRelativeFileLink(
|
||||
value: string,
|
||||
options: AssistantHrefParseOptions,
|
||||
): InlinePathTarget | null {
|
||||
const workspaceRoot = normalizePathInput(options.workspaceRoot);
|
||||
if (!workspaceRoot) {
|
||||
const parsed = parseLocalPathParts(value);
|
||||
if (!parsed || isAbsolutePath(parsed.path)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = parseLocalPathParts(value);
|
||||
if (!parsed || isAbsolutePath(parsed.path)) {
|
||||
if (isHomeRelativePath(parsed.path)) {
|
||||
return {
|
||||
raw: value,
|
||||
path: parsed.path,
|
||||
...parsed.lines,
|
||||
};
|
||||
}
|
||||
|
||||
const workspaceRoot = normalizePathInput(options.workspaceRoot);
|
||||
if (!workspaceRoot) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -529,6 +524,10 @@ function isAllowedAbsolutePath(pathValue: string, workspaceRoot?: string): boole
|
||||
return comparePath === compareWorkspaceRoot || comparePath.startsWith(comparePrefix);
|
||||
}
|
||||
|
||||
function isHomeRelativePath(pathValue: string): boolean {
|
||||
return pathValue === "~" || pathValue.startsWith("~/") || pathValue.startsWith("~\\");
|
||||
}
|
||||
|
||||
function isExternalHref(value: string): boolean {
|
||||
if (value.includes("://")) {
|
||||
return !value.toLowerCase().startsWith(`${FILE_PROTOCOL}//`);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
classifyForResolution,
|
||||
fetchDaemonResolution,
|
||||
@@ -35,27 +35,22 @@ function suggestionsFromMap(entriesByQuery: Record<string, DirectorySuggestionEn
|
||||
matchMode: "suffix";
|
||||
limit: number;
|
||||
}> = [];
|
||||
const getDirectorySuggestions = vi.fn<GetDirectorySuggestions>(
|
||||
async (input: {
|
||||
query: string;
|
||||
cwd: string;
|
||||
includeFiles: true;
|
||||
includeDirectories: false;
|
||||
matchMode: "suffix";
|
||||
limit: number;
|
||||
}) => {
|
||||
searches.push({
|
||||
query: input.query,
|
||||
cwd: input.cwd,
|
||||
matchMode: input.matchMode,
|
||||
limit: input.limit,
|
||||
});
|
||||
return resolvedSuggestions(entriesByQuery[input.query] ?? []);
|
||||
},
|
||||
);
|
||||
const getDirectorySuggestions: GetDirectorySuggestions = async (input) => {
|
||||
searches.push({
|
||||
query: input.query,
|
||||
cwd: input.cwd,
|
||||
matchMode: input.matchMode,
|
||||
limit: input.limit,
|
||||
});
|
||||
return resolvedSuggestions(entriesByQuery[input.query] ?? []);
|
||||
};
|
||||
return { getDirectorySuggestions, searches };
|
||||
}
|
||||
|
||||
const unavailableSuggestions: GetDirectorySuggestions = async () => {
|
||||
throw new Error("daemon unavailable");
|
||||
};
|
||||
|
||||
describe("classifyForResolution", () => {
|
||||
it("returns the directFile target synchronously", () => {
|
||||
const result = classifyForResolution({ href: "src/components/message.tsx#L33" }, CONTEXT);
|
||||
@@ -119,6 +114,40 @@ describe("classifyForResolution", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps absolute paths outside the workspace as direct file targets", () => {
|
||||
const result = classifyForResolution({ href: "/tmp/outside.txt" }, CONTEXT);
|
||||
|
||||
expect(result).toEqual({
|
||||
kind: "resolved",
|
||||
value: {
|
||||
kind: "file",
|
||||
target: {
|
||||
raw: "/tmp/outside.txt",
|
||||
path: "/tmp/outside.txt",
|
||||
lineStart: undefined,
|
||||
lineEnd: undefined,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps tilde paths as direct file targets", () => {
|
||||
const result = classifyForResolution({ href: "~/.paseo/plans/file-preview.md" }, CONTEXT);
|
||||
|
||||
expect(result).toEqual({
|
||||
kind: "resolved",
|
||||
value: {
|
||||
kind: "file",
|
||||
target: {
|
||||
raw: "~/.paseo/plans/file-preview.md",
|
||||
path: "~/.paseo/plans/file-preview.md",
|
||||
lineStart: undefined,
|
||||
lineEnd: undefined,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps auto-linkified normal domains external", () => {
|
||||
const result = classifyForResolution(
|
||||
{ href: "http://google.com", text: "google.com", markup: "linkify" },
|
||||
@@ -193,10 +222,6 @@ describe("fetchDaemonResolution", () => {
|
||||
});
|
||||
|
||||
it("throws a typed unresolved error when the daemon throws", async () => {
|
||||
const getDirectorySuggestions = vi.fn<GetDirectorySuggestions>(async () => {
|
||||
throw new Error("daemon unavailable");
|
||||
});
|
||||
|
||||
await expect(
|
||||
fetchDaemonResolution({
|
||||
ambiguousQuery: "dumm.md",
|
||||
@@ -208,7 +233,7 @@ describe("fetchDaemonResolution", () => {
|
||||
lineEnd: undefined,
|
||||
},
|
||||
workspaceRoot: "/Users/test/project",
|
||||
getDirectorySuggestions,
|
||||
getDirectorySuggestions: unavailableSuggestions,
|
||||
}),
|
||||
).rejects.toEqual(new UnresolvedFileLinkError("dumm.md"));
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user