mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-14 13:22:37 +00:00
Open assistant links to non-ASCII filenames (#6520)
* fix(app): decode percent-encoded assistant file links markdown-it percent-encodes non-ASCII and space characters in link hrefs, so a link like [x](docs/reports/报告.md) arrives as docs/reports/%E6%8A%A5%E5%91%8A.md. The file:// and bare absolute-path branches already run safeDecodeURIComponent, but the workspace-relative, path:line, Windows, and home-relative branches did not, so clicking the link tried to open the still-encoded name and failed with ENOENT. Decode the path portion before the relative-path heuristics and in the inline absolute/Windows branches so all href forms behave the same. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(app): decode hrefs only, never raw inline-code paths Raw inline-code and linkified tokens reach the same parser as markdown hrefs. Decoding unconditionally would send a real file named my%20file.md to my file.md. Gate every decode on AssistantHrefParseOptions.decodeHref — set by the resolver only when the token came from source.href — and extract parseWindowsFileLink to keep the entry point under the complexity limit. Adds parse-level and resolver-level regression tests for literal %20 filenames. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(app): keep raw absolute paths out of URL parsing new URL() percent-encodes non-ASCII, so a raw inline-code path like /tmp/开户.md became /tmp/%E5%BC%80%E6%88%B7.md when decodeHref was false. Hrefs still go through URL parsing (normalizes dot segments before decode); raw paths split the # fragment manually instead. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(app): keep encoded and literal file links distinct Preserve inline-code provenance for Windows paths and key cached file lookups by their resolved query, path, and line range. Reproduce the reported non-ASCII Markdown link through the renderer and filesystem, with browser coverage for clicking it. Co-authored-by: Ouyang Xingyuan <galaxyroaming@qq.com> Co-authored-by: dengjiawen <dengjiawen@hortorgames.com> * test(app): assert file content after opening the markdown link Co-authored-by: sunshinehomeyu-blip <267551119+sunshinehomeyu-blip@users.noreply.github.com> * fix(app): preserve encoded line markers in filenames --------- Co-authored-by: Ouyang Xingyuan <galaxyroaming@qq.com> Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> Co-authored-by: dengjiawen <dengjiawen@hortorgames.com> Co-authored-by: sunshinehomeyu-blip <267551119+sunshinehomeyu-blip@users.noreply.github.com>
This commit is contained in:
co-authored by
Devin
Ouyang Xingyuan
dengjiawen
sunshinehomeyu-blip
parent
01f9bc8291
commit
563c014dc1
@@ -96,6 +96,64 @@ async function seedAgentWithFileLink(input: LinkedFile) {
|
||||
}
|
||||
|
||||
test.describe("CodeMirror workspace file editing", () => {
|
||||
test("opens the non-ASCII file named by an assistant markdown link", async ({ page }) => {
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "encoded-markdown-link-",
|
||||
title: "Non-ASCII file link",
|
||||
initialPrompt: "Show the report link",
|
||||
featureValues: { mockAssistantResponse: "[报告](docs/reports/开户赠金.md)" },
|
||||
});
|
||||
try {
|
||||
await mkdir(path.join(session.cwd, "docs/reports"), { recursive: true });
|
||||
await writeFile(path.join(session.cwd, "docs/reports/开户赠金.md"), "# Report opened\n");
|
||||
await openAgentRoute(page, session);
|
||||
await openAssistantMarkdownLink(page, "报告");
|
||||
await expectFileTabOpen(page, "docs/reports/开户赠金.md");
|
||||
await expect(
|
||||
page
|
||||
.getByTestId("workspace-file-pane")
|
||||
.filter({ visible: true })
|
||||
.getByText("Report opened", { exact: true }),
|
||||
).toBeVisible();
|
||||
await page.screenshot({ path: test.info().outputPath("non-ascii-file-opened.png") });
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
for (const order of ["raw-first", "href-first"] as const) {
|
||||
test(`opens literal and encoded percent filenames independently ${order}`, async ({ page }) => {
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "percent-link-cache-",
|
||||
title: "Distinct file links",
|
||||
initialPrompt: "Show both filenames",
|
||||
featureValues: {
|
||||
mockAssistantResponse: "Raw `literal%20.md`. [Decoded](literal%20.md).",
|
||||
},
|
||||
});
|
||||
try {
|
||||
await writeFile(path.join(session.cwd, "literal%20.md"), "# Literal filename\n");
|
||||
await writeFile(path.join(session.cwd, "literal .md"), "# Decoded filename\n");
|
||||
await openAgentRoute(page, session);
|
||||
const links = order === "raw-first" ? ["raw", "href"] : ["href", "raw"];
|
||||
for (const link of links) {
|
||||
await returnToAssistant(page, session.agentId);
|
||||
if (link === "raw") {
|
||||
await openAssistantInlinePath(page, "literal%20.md");
|
||||
await expectFileTabOpen(page, "literal%20.md");
|
||||
await expectPreviewText(page, "Literal filename");
|
||||
} else {
|
||||
await openAssistantMarkdownLink(page, "Decoded");
|
||||
await expectFileTabOpen(page, "literal .md");
|
||||
await expectPreviewText(page, "Decoded filename");
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test("jumps to each relative reference to the same file", async ({ page }) => {
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "relative-reference-qa-",
|
||||
@@ -757,3 +815,24 @@ test.describe("CodeMirror workspace file editing", () => {
|
||||
await expect(page.getByLabel("Vim mode NORMAL")).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
async function openAssistantMarkdownLink(page: Page, name: string): Promise<void> {
|
||||
await page.getByRole("link", { name, exact: true }).first().click();
|
||||
}
|
||||
|
||||
async function returnToAssistant(page: Page, agentId: string): Promise<void> {
|
||||
await page.getByTestId(`workspace-tab-agent_${agentId}`).filter({ visible: true }).click();
|
||||
}
|
||||
|
||||
async function openAssistantInlinePath(page: Page, token: string): Promise<void> {
|
||||
await page.getByText(token, { exact: true }).click();
|
||||
}
|
||||
|
||||
async function expectPreviewText(page: Page, text: string): Promise<void> {
|
||||
await expect(
|
||||
page
|
||||
.getByTestId("workspace-file-pane")
|
||||
.filter({ visible: true })
|
||||
.getByText(text, { exact: true }),
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
@@ -417,6 +417,128 @@ describe("parseAssistantFileLink", () => {
|
||||
}
|
||||
expect(() => parseFileProtocolUrl("file:///tmp/100% packet loss")).not.toThrow();
|
||||
});
|
||||
|
||||
it("decodes percent-encoded relative markdown hrefs", () => {
|
||||
// markdown-it percent-encodes non-ASCII characters in hrefs, so a link
|
||||
// written as [x](docs/reports/开户赠金.md) arrives here percent-encoded.
|
||||
expect(
|
||||
parseAssistantFileLink("docs/reports/%E5%BC%80%E6%88%B7%E8%B5%A0%E9%87%91.md", {
|
||||
workspaceRoot: "/Users/test/project",
|
||||
decodeHref: true,
|
||||
}),
|
||||
).toEqual({
|
||||
raw: "docs/reports/%E5%BC%80%E6%88%B7%E8%B5%A0%E9%87%91.md",
|
||||
path: "/Users/test/project/docs/reports/开户赠金.md",
|
||||
lineStart: undefined,
|
||||
lineEnd: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("decodes percent-encoded spaces in relative markdown hrefs", () => {
|
||||
expect(
|
||||
parseAssistantFileLink("docs/my%20file.md#L5", {
|
||||
workspaceRoot: "/Users/test/project",
|
||||
decodeHref: true,
|
||||
}),
|
||||
).toEqual({
|
||||
raw: "docs/my%20file.md#L5",
|
||||
path: "/Users/test/project/docs/my file.md",
|
||||
lineStart: 5,
|
||||
lineEnd: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("decodes percent-encoded absolute hrefs with line suffixes", () => {
|
||||
expect(
|
||||
parseAssistantFileLink("/tmp/%E5%BC%80%E6%88%B7.md:3", {
|
||||
workspaceRoot: "/Users/test/project",
|
||||
decodeHref: true,
|
||||
}),
|
||||
).toEqual({
|
||||
raw: "/tmp/%E5%BC%80%E6%88%B7.md:3",
|
||||
path: "/tmp/开户.md",
|
||||
lineStart: 3,
|
||||
lineEnd: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("decodes percent-encoded home-relative hrefs", () => {
|
||||
expect(
|
||||
parseAssistantFileLink("~/notes/%E5%BC%80%E6%88%B7.md", {
|
||||
workspaceRoot: "/Users/test/project",
|
||||
decodeHref: true,
|
||||
}),
|
||||
).toEqual({
|
||||
raw: "~/notes/%E5%BC%80%E6%88%B7.md",
|
||||
path: "~/notes/开户.md",
|
||||
lineStart: undefined,
|
||||
lineEnd: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("decodes percent-encoded Windows hrefs", () => {
|
||||
expect(
|
||||
parseAssistantFileLink("C:/repo/my%20dir/app.tsx#L8", {
|
||||
workspaceRoot: "C:/repo",
|
||||
decodeHref: true,
|
||||
}),
|
||||
).toEqual({
|
||||
raw: "C:/repo/my%20dir/app.tsx#L8",
|
||||
path: "C:/repo/my dir/app.tsx",
|
||||
lineStart: 8,
|
||||
lineEnd: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps literal percent signs in raw paths that are not hrefs", () => {
|
||||
// Inline-code and linkified tokens are raw file paths: a real file named
|
||||
// "my%20file.md" must not be decoded into "my file.md".
|
||||
expect(
|
||||
parseAssistantFileLink("docs/my%20file.md", {
|
||||
workspaceRoot: "/Users/test/project",
|
||||
}),
|
||||
).toEqual({
|
||||
raw: "docs/my%20file.md",
|
||||
path: "/Users/test/project/docs/my%20file.md",
|
||||
lineStart: undefined,
|
||||
lineEnd: undefined,
|
||||
});
|
||||
expect(
|
||||
parseAssistantFileLink("/tmp/my%20file.md:3", {
|
||||
workspaceRoot: "/Users/test/project",
|
||||
}),
|
||||
).toEqual({
|
||||
raw: "/tmp/my%20file.md:3",
|
||||
path: "/tmp/my%20file.md",
|
||||
lineStart: 3,
|
||||
lineEnd: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps non-ASCII characters in raw absolute paths", () => {
|
||||
// `new URL` percent-encodes non-ASCII, so raw paths must not be routed
|
||||
// through URL parsing — `/tmp/开户.md` must round-trip unchanged.
|
||||
expect(
|
||||
parseAssistantFileLink("/tmp/开户.md", {
|
||||
workspaceRoot: "/Users/test/project",
|
||||
}),
|
||||
).toEqual({
|
||||
raw: "/tmp/开户.md",
|
||||
path: "/tmp/开户.md",
|
||||
lineStart: undefined,
|
||||
lineEnd: undefined,
|
||||
});
|
||||
expect(
|
||||
parseAssistantFileLink("/tmp/开户.md#L4", {
|
||||
workspaceRoot: "/Users/test/project",
|
||||
}),
|
||||
).toEqual({
|
||||
raw: "/tmp/开户.md#L4",
|
||||
path: "/tmp/开户.md",
|
||||
lineStart: 4,
|
||||
lineEnd: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeInlinePathTarget", () => {
|
||||
@@ -469,3 +591,23 @@ describe("normalizeInlinePathTarget", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("encoded filename line markers", () => {
|
||||
it.each(["docs/report.md%3A12", "docs/report.md%2812%2C4%29"])(
|
||||
"keeps encoded line syntax in the filename %s",
|
||||
(href) => {
|
||||
expect(
|
||||
parseAssistantFileLink(href, {
|
||||
workspaceRoot: "/workspace",
|
||||
decodeHref: true,
|
||||
}),
|
||||
).toMatchObject({ path: `/workspace/${decodeURIComponent(href)}` });
|
||||
expect(
|
||||
parseAssistantFileLink(href, {
|
||||
workspaceRoot: "/workspace",
|
||||
decodeHref: true,
|
||||
})?.lineStart,
|
||||
).toBeUndefined();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -80,6 +80,13 @@ const ASSISTANT_FILE_EXTENSIONS = new Set([
|
||||
|
||||
export interface AssistantHrefParseOptions {
|
||||
workspaceRoot?: string;
|
||||
/**
|
||||
* True only when `value` is a markdown href — the renderer percent-encodes
|
||||
* non-ASCII and space characters, so the path must be decoded. Raw path
|
||||
* text (inline code, linkified tokens) is not encoded; decoding it would
|
||||
* send a real file named `my%20file.md` to `my file.md`.
|
||||
*/
|
||||
decodeHref?: boolean;
|
||||
}
|
||||
|
||||
export type AssistantFileLinkClassification =
|
||||
@@ -234,13 +241,20 @@ export function parseFileProtocolUrl(value: string): InlinePathTarget | null {
|
||||
};
|
||||
}
|
||||
|
||||
function parseAssistantInlinePathLink(value: string): InlinePathTarget | null {
|
||||
function parseAssistantInlinePathLink(
|
||||
value: string,
|
||||
options: Pick<AssistantHrefParseOptions, "decodeHref"> = {},
|
||||
): InlinePathTarget | null {
|
||||
const inlinePathTarget = parseInlinePathToken(value);
|
||||
if (!inlinePathTarget) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedPath = normalizePathToken(inlinePathTarget.path);
|
||||
let pathPart = inlinePathTarget.path;
|
||||
if (options.decodeHref) {
|
||||
pathPart = safeDecodeURIComponent(pathPart);
|
||||
}
|
||||
const normalizedPath = normalizePathToken(pathPart);
|
||||
if (!normalizedPath || !isAbsolutePath(normalizedPath)) {
|
||||
return null;
|
||||
}
|
||||
@@ -277,7 +291,7 @@ export function classifyAssistantFileLink(
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isAmbiguousWorkspaceCandidate(trimmed, target, options.workspaceRoot)) {
|
||||
if (isAmbiguousWorkspaceCandidate(trimmed, target, options)) {
|
||||
return {
|
||||
kind: "ambiguousFileCandidate",
|
||||
target,
|
||||
@@ -308,32 +322,19 @@ export function parseAssistantFileLink(
|
||||
return null;
|
||||
}
|
||||
|
||||
const inlinePathTarget = parseAssistantInlinePathLink(trimmed);
|
||||
const inlinePathTarget = parseAssistantInlinePathLink(trimmed, options);
|
||||
if (inlinePathTarget) {
|
||||
return inlinePathTarget;
|
||||
}
|
||||
|
||||
const windowsPathMatch = trimmed.match(/^([A-Za-z]:[\\/][^?#]*)(#[^?]+)?$/);
|
||||
if (windowsPathMatch) {
|
||||
const normalizedPath = normalizePathToken(windowsPathMatch[1] ?? "");
|
||||
if (!normalizedPath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const lines = parseLineFragment(windowsPathMatch[2] ?? "");
|
||||
if (!lines) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
raw: value,
|
||||
path: normalizedPath,
|
||||
...lines,
|
||||
};
|
||||
const windowsTarget = parseWindowsFileLink(trimmed, value, options);
|
||||
if (windowsTarget) {
|
||||
return windowsTarget;
|
||||
}
|
||||
|
||||
const relativeTarget = parseWorkspaceRelativeFileLink(trimmed, {
|
||||
workspaceRoot: options.workspaceRoot,
|
||||
decodeHref: options.decodeHref,
|
||||
});
|
||||
if (relativeTarget) {
|
||||
return relativeTarget;
|
||||
@@ -343,19 +344,33 @@ export function parseAssistantFileLink(
|
||||
return null;
|
||||
}
|
||||
|
||||
// Hrefs go through URL parsing so `%`-escapes and `.`/`..` segments
|
||||
// normalize before decode. Raw paths must not: `new URL` percent-encodes
|
||||
// non-ASCII characters, which would corrupt `/tmp/开户.md` into the
|
||||
// still-encoded name.
|
||||
let pathPart: string;
|
||||
let hashPart: string;
|
||||
if (options.decodeHref) {
|
||||
let parsedUrl: URL;
|
||||
try {
|
||||
parsedUrl = new URL(trimmed, "http://paseo.invalid");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
pathPart = safeDecodeURIComponent(parsedUrl.pathname);
|
||||
hashPart = parsedUrl.hash;
|
||||
} else {
|
||||
const hashIndex = trimmed.indexOf("#");
|
||||
pathPart = hashIndex >= 0 ? trimmed.slice(0, hashIndex) : trimmed;
|
||||
hashPart = hashIndex >= 0 ? trimmed.slice(hashIndex) : "";
|
||||
}
|
||||
|
||||
const normalizedPath = normalizePathToken(safeDecodeURIComponent(parsedUrl.pathname));
|
||||
const normalizedPath = normalizePathToken(pathPart);
|
||||
if (!normalizedPath || !isAbsolutePath(normalizedPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const lines = parseLineFragment(parsedUrl.hash);
|
||||
const lines = parseLineFragment(hashPart);
|
||||
if (!lines) {
|
||||
return null;
|
||||
}
|
||||
@@ -386,11 +401,42 @@ export function isFileLookingAssistantToken(value: string): boolean {
|
||||
return isPlausibleAssistantLocalPath(path);
|
||||
}
|
||||
|
||||
function parseWindowsFileLink(
|
||||
trimmed: string,
|
||||
raw: string,
|
||||
options: Pick<AssistantHrefParseOptions, "decodeHref"> = {},
|
||||
): InlinePathTarget | null {
|
||||
const windowsPathMatch = trimmed.match(/^([A-Za-z]:[\\/][^?#]*)(#[^?]+)?$/);
|
||||
if (!windowsPathMatch) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let pathPart = windowsPathMatch[1] ?? "";
|
||||
if (options.decodeHref) {
|
||||
pathPart = safeDecodeURIComponent(pathPart);
|
||||
}
|
||||
const normalizedPath = normalizePathToken(pathPart);
|
||||
if (!normalizedPath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const lines = parseLineFragment(windowsPathMatch[2] ?? "");
|
||||
if (!lines) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
raw,
|
||||
path: normalizedPath,
|
||||
...lines,
|
||||
};
|
||||
}
|
||||
|
||||
function parseWorkspaceRelativeFileLink(
|
||||
value: string,
|
||||
options: AssistantHrefParseOptions,
|
||||
): InlinePathTarget | null {
|
||||
const parsed = parseLocalPathParts(value);
|
||||
const parsed = parseLocalPathParts(value, options);
|
||||
if (!parsed || isAbsolutePath(parsed.path)) {
|
||||
return null;
|
||||
}
|
||||
@@ -422,7 +468,11 @@ function parseWorkspaceRelativeFileLink(
|
||||
|
||||
function parseLocalPathParts(
|
||||
value: string,
|
||||
): { path: string; lines: Pick<InlinePathTarget, "lineStart" | "lineEnd"> } | null {
|
||||
options: Pick<AssistantHrefParseOptions, "decodeHref"> = {},
|
||||
): {
|
||||
path: string;
|
||||
lines: Pick<InlinePathTarget, "lineStart" | "lineEnd">;
|
||||
} | null {
|
||||
const normalized = normalizePathToken(value);
|
||||
if (!normalized || normalized.includes("?")) {
|
||||
return null;
|
||||
@@ -436,14 +486,19 @@ function parseLocalPathParts(
|
||||
return null;
|
||||
}
|
||||
|
||||
// Split literal line markers before decoding; encoded punctuation belongs
|
||||
// to the filename. Raw inline-code/path text stays untouched.
|
||||
const inlinePathTarget = parseInlinePathToken(beforeHash);
|
||||
if (inlinePathTarget) {
|
||||
if (!isPlausibleAssistantLocalPath(inlinePathTarget.path)) {
|
||||
let pathPart = inlinePathTarget.path;
|
||||
if (options.decodeHref) {
|
||||
pathPart = safeDecodeURIComponent(pathPart);
|
||||
}
|
||||
if (!isPlausibleAssistantLocalPath(pathPart)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
path: inlinePathTarget.path,
|
||||
path: pathPart,
|
||||
lines: {
|
||||
lineStart: inlinePathTarget.lineStart,
|
||||
lineEnd: inlinePathTarget.lineEnd,
|
||||
@@ -455,12 +510,17 @@ function parseLocalPathParts(
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!isPlausibleAssistantLocalPath(beforeHash)) {
|
||||
let decodedBeforeHash = beforeHash;
|
||||
if (options.decodeHref) {
|
||||
decodedBeforeHash = safeDecodeURIComponent(beforeHash);
|
||||
}
|
||||
const plausiblePath = parseInlinePathToken(decodedBeforeHash)?.path ?? decodedBeforeHash;
|
||||
if (!isPlausibleAssistantLocalPath(plausiblePath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
path: beforeHash,
|
||||
path: decodedBeforeHash,
|
||||
lines: fragmentLines,
|
||||
};
|
||||
}
|
||||
@@ -545,14 +605,14 @@ function isExternalHref(value: string): boolean {
|
||||
function isAmbiguousWorkspaceCandidate(
|
||||
value: string,
|
||||
target: InlinePathTarget,
|
||||
workspaceRoot?: string,
|
||||
options: AssistantHrefParseOptions = {},
|
||||
): boolean {
|
||||
const normalizedWorkspaceRoot = normalizePathInput(workspaceRoot);
|
||||
const normalizedWorkspaceRoot = normalizePathInput(options.workspaceRoot);
|
||||
if (!normalizedWorkspaceRoot || !isAllowedAbsolutePath(target.path, normalizedWorkspaceRoot)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const parsed = parseLocalPathParts(value);
|
||||
const parsed = parseLocalPathParts(value, options);
|
||||
if (!parsed || isAbsolutePath(parsed.path)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import MarkdownIt from "markdown-it";
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import {
|
||||
classifyForResolution,
|
||||
fetchDaemonResolution,
|
||||
@@ -14,6 +18,25 @@ const CONTEXT: AssistantFileLinkContext = {
|
||||
workspaceRoot: "/Users/test/project",
|
||||
};
|
||||
|
||||
it("opens the existing non-ASCII file named by a rendered markdown link", async () => {
|
||||
const workspaceRoot = await mkdtemp(path.join(tmpdir(), "assistant-file-link-"));
|
||||
try {
|
||||
await mkdir(path.join(workspaceRoot, "docs/reports"), { recursive: true });
|
||||
await writeFile(path.join(workspaceRoot, "docs/reports/开户赠金.md"), "report content");
|
||||
const tokens = new MarkdownIt().parseInline("[报告](docs/reports/开户赠金.md)", {});
|
||||
const href = tokens[0].children?.find((token) => token.type === "link_open")?.attrGet("href");
|
||||
expect(href).toBe("docs/reports/%E5%BC%80%E6%88%B7%E8%B5%A0%E9%87%91.md");
|
||||
const resolution = classifyForResolution({ href: href! }, { workspaceRoot });
|
||||
expect(resolution.kind).toBe("resolved");
|
||||
if (resolution.kind !== "resolved" || resolution.value.kind !== "file") {
|
||||
throw new Error("Expected a directly resolved file link");
|
||||
}
|
||||
expect(await readFile(resolution.value.target.path, "utf8")).toBe("report content");
|
||||
} finally {
|
||||
await rm(workspaceRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
function resolvedSuggestions(
|
||||
entries: DirectorySuggestionResult["entries"],
|
||||
): DirectorySuggestionResult {
|
||||
@@ -52,6 +75,27 @@ const unavailableSuggestions: GetDirectorySuggestions = async () => {
|
||||
};
|
||||
|
||||
describe("classifyForResolution", () => {
|
||||
it.each([
|
||||
["C:/repo/my%20file.md", undefined],
|
||||
["C:\\repo\\my%20file.md", undefined],
|
||||
["C:/repo/my%20file.md:12", 12],
|
||||
["C:\\repo\\my%20file.md:12", 12],
|
||||
])("keeps the literal filename in Windows inline code %s", (href, lineStart) => {
|
||||
for (const text of [href, undefined]) {
|
||||
const result = classifyForResolution(
|
||||
{ href, text, sourceType: "inline-code" },
|
||||
{ workspaceRoot: "C:/repo" },
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
kind: "resolved",
|
||||
value: { kind: "file", target: { path: "C:/repo/my%20file.md", lineStart } },
|
||||
});
|
||||
}
|
||||
expect(classifyForResolution({ href }, { workspaceRoot: "C:/repo" })).toMatchObject({
|
||||
kind: "resolved",
|
||||
value: { kind: "file", target: { path: "C:/repo/my file.md", lineStart } },
|
||||
});
|
||||
});
|
||||
it("returns the directFile target synchronously", () => {
|
||||
const result = classifyForResolution({ href: "src/components/message.tsx#L33" }, CONTEXT);
|
||||
|
||||
@@ -165,6 +209,46 @@ describe("classifyForResolution", () => {
|
||||
|
||||
expect(result).toEqual({ kind: "resolved", value: { kind: "ignored" } });
|
||||
});
|
||||
|
||||
it("decodes percent-encoded markdown hrefs", () => {
|
||||
const result = classifyForResolution({ href: "docs/my%20file.md" }, CONTEXT);
|
||||
|
||||
expect(result).toEqual({
|
||||
kind: "resolved",
|
||||
value: {
|
||||
kind: "file",
|
||||
target: {
|
||||
raw: "docs/my%20file.md",
|
||||
path: "/Users/test/project/docs/my file.md",
|
||||
lineStart: undefined,
|
||||
lineEnd: undefined,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps literal percent signs in inline-code paths", () => {
|
||||
const result = classifyForResolution(
|
||||
{
|
||||
href: "docs/my%20file.md",
|
||||
text: "docs/my%20file.md",
|
||||
sourceType: "inline-code",
|
||||
},
|
||||
CONTEXT,
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
kind: "needsLookup",
|
||||
ambiguousQuery: "docs/my%20file.md",
|
||||
token: "docs/my%20file.md",
|
||||
target: {
|
||||
raw: "docs/my%20file.md",
|
||||
path: "/Users/test/project/docs/my%20file.md",
|
||||
lineStart: undefined,
|
||||
lineEnd: undefined,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchDaemonResolution", () => {
|
||||
|
||||
@@ -108,13 +108,15 @@ export function classifyForResolution(
|
||||
source: AssistantFileLinkSource,
|
||||
context: AssistantFileLinkContext,
|
||||
): AssistantFileLinkResolution {
|
||||
const token = getAssistantFileLinkToken(source).trim();
|
||||
const tokenInfo = getAssistantFileLinkTokenInfo(source);
|
||||
const token = tokenInfo.token.trim();
|
||||
if (!token) {
|
||||
return { kind: "resolved", value: { kind: "ignored" } };
|
||||
}
|
||||
|
||||
const classification = classifyAssistantFileLink(token, {
|
||||
workspaceRoot: context.workspaceRoot,
|
||||
decodeHref: tokenInfo.fromHref,
|
||||
});
|
||||
if (!classification) {
|
||||
return { kind: "resolved", value: { kind: "ignored" } };
|
||||
@@ -148,14 +150,26 @@ export function classifyForResolution(
|
||||
}
|
||||
|
||||
export function getAssistantFileLinkToken(source: AssistantFileLinkSource): string {
|
||||
if (isLinkifiedSource(source) || source.sourceType === "inline-code") {
|
||||
return getAssistantFileLinkTokenInfo(source).token;
|
||||
}
|
||||
|
||||
// Markdown link hrefs are percent-encoded by the renderer; inline-code and
|
||||
// linkified text are raw file paths that must not be decoded.
|
||||
function getAssistantFileLinkTokenInfo(source: AssistantFileLinkSource): {
|
||||
token: string;
|
||||
fromHref: boolean;
|
||||
} {
|
||||
if (source.sourceType === "inline-code") {
|
||||
return { token: source.text?.trim() || source.href, fromHref: false };
|
||||
}
|
||||
if (isLinkifiedSource(source)) {
|
||||
const text = source.text?.trim();
|
||||
if (text && isFileLookingAssistantToken(text)) {
|
||||
return text;
|
||||
return { token: text, fromHref: false };
|
||||
}
|
||||
}
|
||||
|
||||
return source.href;
|
||||
return { token: source.href, fromHref: true };
|
||||
}
|
||||
|
||||
export function getAmbiguousSuggestionQuery(
|
||||
|
||||
@@ -29,14 +29,21 @@ export interface AssistantFileLinkActions {
|
||||
canResolveFile(source: AssistantFileLinkSource): boolean;
|
||||
}
|
||||
|
||||
interface AssistantFileLinkLookupKey {
|
||||
query: string;
|
||||
path: string;
|
||||
lineStart: number | null;
|
||||
lineEnd: number | null;
|
||||
}
|
||||
|
||||
type AssistantFileLinkQueryKey = readonly [
|
||||
"assistantFileLink",
|
||||
string | null,
|
||||
string | null,
|
||||
string,
|
||||
AssistantFileLinkLookupKey | null,
|
||||
];
|
||||
|
||||
const DISABLED_QUERY_KEY = ["assistantFileLink", null, null, ""] as const;
|
||||
const DISABLED_QUERY_KEY = ["assistantFileLink", null, null, null] as const;
|
||||
|
||||
export function useFileLink(source: AssistantFileLinkSource): UseFileLinkResult {
|
||||
const { t } = useTranslation();
|
||||
@@ -59,7 +66,7 @@ export function useFileLink(source: AssistantFileLinkSource): UseFileLinkResult
|
||||
? assistantFileLinkQueryKey({
|
||||
serverId,
|
||||
workspaceRoot,
|
||||
token: resolution.token,
|
||||
resolution,
|
||||
})
|
||||
: DISABLED_QUERY_KEY,
|
||||
[resolution, serverId, workspaceRoot],
|
||||
@@ -179,7 +186,7 @@ function openAssistantFileLink(input: {
|
||||
const capturedQueryKey = assistantFileLinkQueryKey({
|
||||
serverId: capturedConfig.serverId,
|
||||
workspaceRoot: capturedConfig.workspaceRoot,
|
||||
token: capturedResolution.token,
|
||||
resolution: capturedResolution,
|
||||
});
|
||||
|
||||
const run = async () => {
|
||||
@@ -245,9 +252,19 @@ function useStableSource(source: AssistantFileLinkSource): AssistantFileLinkSour
|
||||
function assistantFileLinkQueryKey(input: {
|
||||
serverId?: string;
|
||||
workspaceRoot?: string;
|
||||
token: string;
|
||||
resolution: Extract<AssistantFileLinkResolution, { kind: "needsLookup" }>;
|
||||
}): AssistantFileLinkQueryKey {
|
||||
return ["assistantFileLink", input.serverId ?? null, input.workspaceRoot ?? null, input.token];
|
||||
return [
|
||||
"assistantFileLink",
|
||||
input.serverId ?? null,
|
||||
input.workspaceRoot ?? null,
|
||||
{
|
||||
query: input.resolution.ambiguousQuery,
|
||||
path: input.resolution.target.path,
|
||||
lineStart: input.resolution.target.lineStart ?? null,
|
||||
lineEnd: input.resolution.target.lineEnd ?? null,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
async function dispatchResolvedLink(input: {
|
||||
|
||||
Reference in New Issue
Block a user