diff --git a/ui/src/markdownTarget.ts b/ui/src/markdownTarget.ts index e1c061fc8..938d11c76 100644 --- a/ui/src/markdownTarget.ts +++ b/ui/src/markdownTarget.ts @@ -72,7 +72,7 @@ export function citedFilePath(path: string, resolveFilePath?: (path: string) => if (native || /^file:/i.test(path)) return native; if (resolveFilePath) return resolveFilePath(path); try { - const decoded = decodeURI(path); + const decoded = decodeURIComponent(path); return decoded.includes("\0") ? null : decoded; } catch { return null; diff --git a/ui/tests/markdownTarget.test.mjs b/ui/tests/markdownTarget.test.mjs index 395d293a0..5a3befdd9 100644 --- a/ui/tests/markdownTarget.test.mjs +++ b/ui/tests/markdownTarget.test.mjs @@ -70,6 +70,20 @@ test("re-resolved markdown paths are decoded only once", () => { assert.equal(docLink(encodeMarkdownPath(figure.path)), "docs/figures/chart#1.png"); }); +test("chat file links decode reserved filename characters without a resolver", () => { + for (const [target, filename] of [ + ["chart%231.png", "chart#1.png"], + ["chart%3F1.png", "chart?1.png"], + ["chart%26draft.png", "chart&draft.png"], + ["chart%3A42.py", "chart:42.py"], + ["chart%25231.png", "chart%231.png"], + ]) { + assert.equal(citedFilePath(target), filename); + } + assert.equal(citedFilePath("bad%escape.png"), null); + assert.equal(citedFilePath("%00.png"), null); +}); + test("document assets resolve relative to the decoded document path once", () => { const asset = (path, src) => documentAssetTarget(path, src, true); assert.deepEqual(asset("my figs/plot.html", "chart%231.png#x"), { path: "my figs/chart#1.png", query: "", hash: "#x" });