gui: decode percent-encoded non-ASCII artifact paths

micromark percent-encodes non-ASCII characters in link hrefs, so an
artifact link to a Chinese/Japanese/Korean filename dispatched the encoded
path and the backend 404ed on the literal %E6… name. Decode the path (and
strip a leading slash so it stays workspace-relative) before opening.
This commit is contained in:
malin1997
2026-07-25 22:20:56 +08:00
parent 6ff0f0fac9
commit 03c145cf3c
2 changed files with 50 additions and 4 deletions
+27 -1
View File
@@ -1,6 +1,6 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { Markdown, OPEN_ARTIFACT_EVENT } from "./Markdown";
import { Markdown, OPEN_ARTIFACT_EVENT, normalizeArtifactPath } from "./Markdown";
afterEach(cleanup);
@@ -35,4 +35,30 @@ describe("Markdown artifact links", () => {
render(<Markdown text="[](artifact:out/report.pdf)" />);
expect(screen.getByTestId("artifact-chip").textContent).toContain("report.pdf");
});
// Chinese (and other non-ASCII) filenames: micromark percent-encodes the href; without
// decode the backend looks up the literal %E6… path and returns "not found".
it("decodes percent-encoded non-ASCII artifact paths before opening", () => {
const seen: string[] = [];
const listener = (e: Event) => seen.push((e as CustomEvent).detail.path);
window.addEventListener(OPEN_ARTIFACT_EVENT, listener);
render(<Markdown text="📄 [Monthly report](artifact:reports/2026-04_月度报告.md)" />);
const chip = screen.getByTestId("artifact-chip");
expect(chip.getAttribute("title")).toBe("reports/2026-04_月度报告.md");
fireEvent.click(chip);
expect(seen).toEqual(["reports/2026-04_月度报告.md"]);
window.removeEventListener(OPEN_ARTIFACT_EVENT, listener);
});
});
describe("normalizeArtifactPath", () => {
it("decodes percent-encoded segments and strips a leading slash", () => {
expect(normalizeArtifactPath("reports/2026-04_%E6%9C%88%E5%BA%A6%E6%8A%A5%E5%91%8A.md")).toBe(
"reports/2026-04_月度报告.md",
);
expect(normalizeArtifactPath("/reports/foo.md")).toBe("reports/foo.md");
expect(normalizeArtifactPath("reports/ascii.md")).toBe("reports/ascii.md");
});
});
+23 -3
View File
@@ -10,16 +10,36 @@ import { Icon } from "./Icon";
// the session's artifact list, App un-hides the rail.
export const OPEN_ARTIFACT_EVENT = "ocw-open-artifact";
/** Normalize an artifact: href path for the session workspace.
*
* react-markdown / micromark percent-encodes non-ASCII URL characters, so
* `artifact:reports/报告.md` becomes `artifact:reports/%E6%8A%A5%E5%91%8A.md`.
* The backend looks up the literal filesystem path — encoded names 404 as "not found".
* Also strip a single leading `/` so `artifact:/reports/x.md` stays workspace-relative
* (Path(workspace) / "/abs" would otherwise escape the workspace root).
*/
export function normalizeArtifactPath(raw: string): string {
let path = raw;
try {
path = decodeURIComponent(raw);
} catch {
// malformed % sequences — keep raw
}
if (path.startsWith("/")) path = path.replace(/^\/+/, "");
return path;
}
function ArtifactChip({ path, title }: { path: string; title: string }) {
const { t } = useTranslation();
const file = path.split("/").pop() || path;
const resolved = normalizeArtifactPath(path);
const file = resolved.split("/").pop() || resolved;
return (
<button
className="art-chip"
data-testid="artifact-chip"
title={path}
title={resolved}
onClick={() =>
window.dispatchEvent(new CustomEvent(OPEN_ARTIFACT_EVENT, { detail: { path } }))
window.dispatchEvent(new CustomEvent(OPEN_ARTIFACT_EVENT, { detail: { path: resolved } }))
}
>
<span className="art-chip-ico">