Fix Windows image previews (#1811)

* fix(images): preserve Windows preview paths

Provider image markdown used escaped backslash paths on Windows, which markdown parsing could turn into encoded drive paths that the app treated as workspace-relative. Emit drive-letter paths as file URIs and normalize existing encoded local paths before file preview.

* test(server): accept file URI image sources

Windows provider image output now renders drive-letter paths as file URIs. Decode those markdown sources before filesystem assertions so the image rendering tests keep checking the materialized file.

* fix(images): keep plain local paths literal

Limit legacy URI decoding to markdown-encoded Windows drive paths so ordinary local paths with percent sequences remain unchanged. Document why provider image markdown matching still accepts old doubled-backslash history.
This commit is contained in:
Mohamed Boudra
2026-06-29 20:49:13 +00:00
committed by GitHub
parent df36a958f8
commit 811f52395e
8 changed files with 113 additions and 18 deletions
+26 -1
View File
@@ -1,5 +1,12 @@
import { describe, expect, it } from "vitest";
import { createImageSourceCacheKey, parseDataUrl, parseImageDataUrl, pathToFileUri } from "./utils";
import {
createImageSourceCacheKey,
fileUriToPath,
localFileSourceToPath,
parseDataUrl,
parseImageDataUrl,
pathToFileUri,
} from "./utils";
describe("pathToFileUri", () => {
it("converts POSIX absolute paths to file URIs", () => {
@@ -23,6 +30,24 @@ describe("pathToFileUri", () => {
});
});
describe("fileUriToPath", () => {
it("converts Windows drive-letter file URIs back to paths", () => {
expect(fileUriToPath("file:///C:/Users/file.txt")).toBe("C:/Users/file.txt");
});
});
describe("localFileSourceToPath", () => {
it("decodes markdown-encoded Windows drive-letter paths", () => {
expect(localFileSourceToPath("C:%5CUsers%5Cfile.txt")).toBe("C:/Users/file.txt");
});
it("preserves literal percent sequences in plain local paths", () => {
expect(localFileSourceToPath("/tmp/image%20with%20literal%20percent.png")).toBe(
"/tmp/image%20with%20literal%20percent.png",
);
});
});
describe("parseDataUrl", () => {
it("accepts base64 data URLs with media-type parameters", () => {
expect(parseDataUrl("data:image/png;charset=utf-8;name=preview;base64,AAECAw==")).toEqual({
+31 -1
View File
@@ -139,11 +139,41 @@ export function pathToFileUri(path: string): string {
return `file:///${path.replace(/\\/g, "/")}`;
}
function decodeFilePathSource(source: string): string {
try {
return decodeURIComponent(source);
} catch {
return source;
}
}
function normalizeWindowsDrivePath(path: string): string {
if (!/^[A-Za-z]:[\\/]/.test(path)) {
return path;
}
return path.replace(/\\/g, "/");
}
function isMarkdownEncodedWindowsDrivePath(source: string): boolean {
return /^[A-Za-z]:(?:%5[Cc]|%2[Ff])/.test(source);
}
export function fileUriToPath(uri: string): string {
if (!uri.startsWith("file://")) {
return uri;
}
return decodeURIComponent(uri.replace(/^file:\/\//, ""));
const decodedPath = decodeFilePathSource(uri.replace(/^file:\/\//, ""));
return normalizeWindowsDrivePath(decodedPath.replace(/^\/([A-Za-z]:[\\/])/, "$1"));
}
export function localFileSourceToPath(source: string): string {
let path = source;
if (source.startsWith("file://")) {
path = fileUriToPath(source);
} else if (isMarkdownEncodedWindowsDrivePath(source)) {
path = decodeFilePathSource(source);
}
return normalizeWindowsDrivePath(path);
}
export function getFileExtensionFromName(fileName: string | null | undefined): string {
@@ -79,6 +79,19 @@ describe("resolveAssistantImageSource", () => {
});
});
it("normalizes markdown-encoded Windows paths into file RPC requests", () => {
expect(
resolveAssistantImageSource({
source: "C:%5CUsers%5Chanse%5CAppData%5CLocal%5CTemp%5Cpaseo-attachments%5Cimage.png",
workspaceRoot: "C:/Users/hanse/eatingkat",
}),
).toEqual({
kind: "file_rpc",
cwd: "C:/",
path: "C:/Users/hanse/AppData/Local/Temp/paseo-attachments/image.png",
});
});
it("falls back to the drive root for Windows absolute paths", () => {
expect(
resolveAssistantImageSource({
@@ -1,4 +1,4 @@
import { fileUriToPath } from "@/attachments/utils";
import { localFileSourceToPath } from "@/attachments/utils";
import { resolveFilePreviewReadTarget } from "@/file-explorer/preview-target";
export type AssistantImageSourceResolution =
@@ -18,13 +18,8 @@ export function resolveAssistantImageSource(input: {
return { kind: "direct", uri: source };
}
const sourcePath = source.startsWith("file://") ? fileUriToPath(source) : source;
if (!sourcePath) {
return null;
}
const readTarget = resolveFilePreviewReadTarget({
path: sourcePath,
path: localFileSourceToPath(source),
workspaceRoot: input.workspaceRoot,
});
if (!readTarget) {