Archived
feat: add file browser image previews
This commit is contained in:
@@ -1,8 +1,10 @@
|
||||
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
|
||||
import { mkdtemp, mkdir, rm, truncate, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { MAX_IMAGE_PREVIEW_BYTES } from "../../shared/workspaceFiles.js";
|
||||
import { readWorkspaceFile } from "./fileContentService.js";
|
||||
import { readWorkspaceImagePreview } from "./imagePreviewService.js";
|
||||
|
||||
const roots: string[] = [];
|
||||
|
||||
@@ -57,6 +59,31 @@ describe("readWorkspaceFile", () => {
|
||||
expect(file.size).toBe(4);
|
||||
});
|
||||
|
||||
it("marks supported images as previewable", async () => {
|
||||
const root = await tempWorkspace();
|
||||
await writeFile(join(root, "logo.PNG"), Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00]));
|
||||
|
||||
const file = await readWorkspaceFile(root, "logo.PNG");
|
||||
|
||||
expect(file).toMatchObject({ mediaType: "image", mimeType: "image/png", content: "", binary: true, truncated: false });
|
||||
expect(file.size).toBe(9);
|
||||
});
|
||||
|
||||
it("opens image preview streams only for supported images within the preview size limit", async () => {
|
||||
const root = await tempWorkspace();
|
||||
await writeFile(join(root, "diagram.svg"), "<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>");
|
||||
await writeFile(join(root, "note.txt"), "hello");
|
||||
await writeFile(join(root, "huge.png"), "");
|
||||
await truncate(join(root, "huge.png"), MAX_IMAGE_PREVIEW_BYTES + 1);
|
||||
|
||||
const preview = await readWorkspaceImagePreview(root, "diagram.svg");
|
||||
preview.stream.destroy();
|
||||
|
||||
expect(preview).toMatchObject({ path: "diagram.svg", mimeType: "image/svg+xml", size: 46 });
|
||||
await expect(readWorkspaceImagePreview(root, "note.txt")).rejects.toThrow("Image preview is not supported");
|
||||
await expect(readWorkspaceImagePreview(root, "huge.png")).rejects.toThrow("Image is too large to preview");
|
||||
});
|
||||
|
||||
it("truncates large text files", async () => {
|
||||
const root = await tempWorkspace();
|
||||
await writeFile(join(root, "large.md"), "a".repeat(512 * 1024 + 7));
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { readFile, stat } from "node:fs/promises";
|
||||
import { open, stat } from "node:fs/promises";
|
||||
import type { FileContentResponse } from "../../shared/apiTypes.js";
|
||||
import { imageMimeTypeForPath } from "./imagePreviewService.js";
|
||||
import { resolveInsideWorkspace } from "./pathSafety.js";
|
||||
|
||||
const MAX_BYTES = 512 * 1024;
|
||||
@@ -10,11 +11,13 @@ export async function readWorkspaceFile(rootPath: string, path: string | undefin
|
||||
const s = await stat(target);
|
||||
if (!s.isFile()) throw new Error("Path is not a file");
|
||||
const bytesToRead = Math.min(s.size, MAX_BYTES);
|
||||
const buffer = (await readFile(target)).subarray(0, bytesToRead);
|
||||
const binary = isProbablyBinary(buffer);
|
||||
const buffer = await readFilePrefix(target, bytesToRead);
|
||||
const media = mediaForPath(relativePath);
|
||||
const binary = media.mediaType === "image" || isProbablyBinary(buffer);
|
||||
return {
|
||||
path: relativePath,
|
||||
...languageForPath(relativePath),
|
||||
...media,
|
||||
encoding: "utf8",
|
||||
size: s.size,
|
||||
modifiedAt: s.mtime.toISOString(),
|
||||
@@ -24,6 +27,18 @@ export async function readWorkspaceFile(rootPath: string, path: string | undefin
|
||||
};
|
||||
}
|
||||
|
||||
async function readFilePrefix(target: string, bytesToRead: number): Promise<Buffer> {
|
||||
if (bytesToRead === 0) return Buffer.alloc(0);
|
||||
const buffer = Buffer.alloc(bytesToRead);
|
||||
const handle = await open(target, "r");
|
||||
try {
|
||||
const result = await handle.read(buffer, 0, bytesToRead, 0);
|
||||
return buffer.subarray(0, result.bytesRead);
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
}
|
||||
|
||||
function isProbablyBinary(buffer: Buffer): boolean {
|
||||
const sample = buffer.subarray(0, Math.min(buffer.length, 8192));
|
||||
return sample.includes(0);
|
||||
@@ -50,3 +65,8 @@ function languageForPath(path: string): { language?: string } {
|
||||
const language = ext === undefined ? undefined : languages[ext];
|
||||
return language === undefined ? {} : { language };
|
||||
}
|
||||
|
||||
function mediaForPath(path: string): { mediaType?: "image"; mimeType?: string } {
|
||||
const mimeType = imageMimeTypeForPath(path);
|
||||
return mimeType === undefined ? {} : { mediaType: "image", mimeType };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { createReadStream, type ReadStream } from "node:fs";
|
||||
import { stat } from "node:fs/promises";
|
||||
import { extname } from "node:path";
|
||||
import { MAX_IMAGE_PREVIEW_BYTES, MAX_IMAGE_PREVIEW_LABEL } from "../../shared/workspaceFiles.js";
|
||||
import { resolveInsideWorkspace } from "./pathSafety.js";
|
||||
|
||||
const IMAGE_MIME_TYPES: Record<string, string | undefined> = {
|
||||
".avif": "image/avif",
|
||||
".bmp": "image/bmp",
|
||||
".gif": "image/gif",
|
||||
".ico": "image/x-icon",
|
||||
".jpeg": "image/jpeg",
|
||||
".jpg": "image/jpeg",
|
||||
".png": "image/png",
|
||||
".svg": "image/svg+xml",
|
||||
".webp": "image/webp",
|
||||
};
|
||||
|
||||
export interface WorkspaceImagePreview {
|
||||
path: string;
|
||||
mimeType: string;
|
||||
size: number;
|
||||
modifiedAt: string;
|
||||
stream: ReadStream;
|
||||
}
|
||||
|
||||
export function imageMimeTypeForPath(path: string): string | undefined {
|
||||
return IMAGE_MIME_TYPES[extname(path).toLowerCase()];
|
||||
}
|
||||
|
||||
export async function readWorkspaceImagePreview(rootPath: string, path: string | undefined): Promise<WorkspaceImagePreview> {
|
||||
if (path === undefined || path === "") throw new Error("path query parameter is required");
|
||||
const { target, relativePath } = await resolveInsideWorkspace(rootPath, path);
|
||||
const s = await stat(target);
|
||||
if (!s.isFile()) throw new Error("Path is not a file");
|
||||
const mimeType = imageMimeTypeForPath(relativePath);
|
||||
if (mimeType === undefined) throw new Error("Image preview is not supported for this file type");
|
||||
if (s.size > MAX_IMAGE_PREVIEW_BYTES) throw new Error(`Image is too large to preview (limit ${MAX_IMAGE_PREVIEW_LABEL})`);
|
||||
return {
|
||||
path: relativePath,
|
||||
mimeType,
|
||||
size: s.size,
|
||||
modifiedAt: s.mtime.toISOString(),
|
||||
stream: createReadStream(target),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user