Archived
feat: add file browser image previews
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@jmfederico/pi-web": patch
|
||||
---
|
||||
|
||||
Add cached image previews up to 10 MB to the workspace file browser for common image file types.
|
||||
@@ -1,3 +1,3 @@
|
||||
export { activityApi, api, filesApi, gitApi, piWebApi, projectsApi, sessionsApi, terminalsApi, workspacesApi } from "./api/clients";
|
||||
export { globalSessionEvents, realtimeEvents, sessionEvents, terminalSocket } from "./api/sockets";
|
||||
export type { AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, MessagePage, ModelSelectionResponse, OAuthFlowState, PiWebComponentStatus, PiWebInstallationInfo, PiWebReleaseStatus, PiWebStatusMessage, PiWebStatusResponse, Project, QueuedSessionMessage, RealtimeEvent, SessionActivity, SessionInfo, SessionModel, SessionStatus, SlashCommand, SessionUiEvent, TerminalInfo, TerminalUiEvent, ThinkingLevel, ThinkingLevelsResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse, WorkspaceActivityUiEvent } from "../../shared/apiTypes";
|
||||
export type { AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, FileContentMediaType, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, MessagePage, ModelSelectionResponse, OAuthFlowState, PiWebComponentStatus, PiWebInstallationInfo, PiWebReleaseStatus, PiWebStatusMessage, PiWebStatusResponse, Project, QueuedSessionMessage, RealtimeEvent, SessionActivity, SessionInfo, SessionModel, SessionStatus, SlashCommand, SessionUiEvent, TerminalInfo, TerminalUiEvent, ThinkingLevel, ThinkingLevelsResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse, WorkspaceActivityUiEvent } from "../../shared/apiTypes";
|
||||
|
||||
@@ -52,7 +52,7 @@ describe("API parsers", () => {
|
||||
});
|
||||
|
||||
it("validates file content responses", () => {
|
||||
expect(parseFileContentResponse({
|
||||
const textFile = {
|
||||
path: "README.md",
|
||||
language: "markdown",
|
||||
encoding: "utf8",
|
||||
@@ -61,9 +61,13 @@ describe("API parsers", () => {
|
||||
content: "text",
|
||||
truncated: false,
|
||||
binary: false,
|
||||
})).toMatchObject({ path: "README.md", language: "markdown", content: "text" });
|
||||
};
|
||||
|
||||
expect(parseFileContentResponse(textFile)).toMatchObject({ path: "README.md", language: "markdown", content: "text" });
|
||||
expect(parseFileContentResponse({ ...textFile, path: "logo.png", mediaType: "image", mimeType: "image/png", content: "", binary: true })).toMatchObject({ path: "logo.png", mediaType: "image", mimeType: "image/png" });
|
||||
|
||||
expect(() => parseFileContentResponse({ encoding: "base64" })).toThrow("Invalid file encoding");
|
||||
expect(() => parseFileContentResponse({ ...textFile, mediaType: "video" })).toThrow("Invalid file media type");
|
||||
});
|
||||
|
||||
it("parses command result variants", () => {
|
||||
|
||||
@@ -258,7 +258,13 @@ export function parseFileContentResponse(value: unknown): FileContentResponse {
|
||||
const record = requireRecord(value);
|
||||
const encoding = requireString(record, "encoding");
|
||||
if (encoding !== "utf8") throw new Error("Invalid file encoding");
|
||||
return { path: requireString(record, "path"), ...optionalField("language", optionalString(record, "language")), encoding, size: requireNumber(record, "size"), modifiedAt: requireString(record, "modifiedAt"), content: requireString(record, "content"), truncated: requireBoolean(record, "truncated"), binary: requireBoolean(record, "binary") };
|
||||
return { path: requireString(record, "path"), ...optionalField("language", optionalString(record, "language")), ...optionalField("mediaType", optionalFileMediaType(record["mediaType"])), ...optionalField("mimeType", optionalString(record, "mimeType")), encoding, size: requireNumber(record, "size"), modifiedAt: requireString(record, "modifiedAt"), content: requireString(record, "content"), truncated: requireBoolean(record, "truncated"), binary: requireBoolean(record, "binary") };
|
||||
}
|
||||
|
||||
function optionalFileMediaType(value: unknown): FileContentResponse["mediaType"] | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
if (value !== "image") throw new Error("Invalid file media type");
|
||||
return value;
|
||||
}
|
||||
|
||||
export function parseGitStatusResponse(value: unknown): GitStatusResponse {
|
||||
|
||||
@@ -13,3 +13,10 @@ export function messageUrl(sessionId: string, options?: { limit?: number; before
|
||||
const query = params.toString();
|
||||
return `/api/sessions/${sessionId}/messages${query ? `?${query}` : ""}`;
|
||||
}
|
||||
|
||||
export function workspaceImagePreviewUrl(projectId: string, workspaceId: string, path: string, options?: { modifiedAt?: string }): string {
|
||||
const params = new URLSearchParams();
|
||||
params.set("path", path);
|
||||
if (options?.modifiedAt !== undefined) params.set("v", options.modifiedAt);
|
||||
return `/api/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/file/preview?${params.toString()}`;
|
||||
}
|
||||
|
||||
@@ -160,6 +160,8 @@ export const workspacePanelStyles = css`
|
||||
.viewer-header { position: sticky; top: 0; display: flex; justify-content: space-between; gap: 8px; padding: 8px; border-bottom: 1px solid var(--pi-border-muted); background: var(--pi-bg); }
|
||||
.viewer-header strong { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
code-viewer { flex: 1 1 auto; min-height: 0; }
|
||||
.image-preview { flex: 1 1 auto; min-height: 0; box-sizing: border-box; display: flex; align-items: center; justify-content: center; overflow: auto; padding: 16px; }
|
||||
.image-preview img { display: block; max-width: 100%; max-height: 100%; object-fit: contain; border: 1px solid var(--pi-border-muted); border-radius: 8px; background-color: var(--pi-surface); background-image: linear-gradient(45deg, color-mix(in srgb, var(--pi-border-muted) 45%, transparent) 25%, transparent 25%), linear-gradient(-45deg, color-mix(in srgb, var(--pi-border-muted) 45%, transparent) 25%, transparent 25%), linear-gradient(45deg, transparent 75%, color-mix(in srgb, var(--pi-border-muted) 45%, transparent) 75%), linear-gradient(-45deg, transparent 75%, color-mix(in srgb, var(--pi-border-muted) 45%, transparent) 75%); background-position: 0 0, 0 8px, 8px -8px, -8px 0; background-size: 16px 16px; box-shadow: 0 8px 24px var(--pi-shadow-soft); }
|
||||
pre { margin: 0; padding: 10px; overflow: auto; font: 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; line-height: 1.45; white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
p { margin: 10px; }
|
||||
`;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { html, type TemplateResult } from "lit";
|
||||
import type { FileTreeEntry, GitDiffResponse, GitStatusResponse } from "../../api";
|
||||
import type { FileContentResponse, FileTreeEntry, GitDiffResponse, GitStatusResponse } from "../../api";
|
||||
import { workspaceImagePreviewUrl } from "../../api/urls";
|
||||
import { MAX_IMAGE_PREVIEW_BYTES, MAX_IMAGE_PREVIEW_LABEL } from "../../../../shared/workspaceFiles";
|
||||
import type { WorkspacePanelContribution, WorkspacePanelContext } from "../types";
|
||||
|
||||
export function createCoreWorkspacePanels(): WorkspacePanelContribution[] {
|
||||
@@ -48,8 +50,9 @@ function renderFiles(context: WorkspacePanelContext): TemplateResult {
|
||||
function renderTreeEntry(context: WorkspacePanelContext, entry: FileTreeEntry, depth: number): TemplateResult {
|
||||
const children = context.expandedDirs[entry.path];
|
||||
const hasChildren = children !== undefined;
|
||||
const selected = entry.type !== "directory" && context.selectedFilePath === entry.path;
|
||||
return html`
|
||||
<button class="row" style=${`--depth:${String(depth)}`} @click=${() => { selectTreeEntry(context, entry); }}>
|
||||
<button class=${selected ? "row selected" : "row"} style=${`--depth:${String(depth)}`} @click=${() => { selectTreeEntry(context, entry); }}>
|
||||
<span>${entry.type === "directory" ? (hasChildren ? "▾" : "▸") : "·"}</span>
|
||||
<span>${entry.name}</span>
|
||||
</button>
|
||||
@@ -66,7 +69,8 @@ function renderFileViewer(context: WorkspacePanelContext): TemplateResult {
|
||||
const file = context.selectedFileContent;
|
||||
if (context.selectedFilePath === undefined || context.selectedFilePath === "") return html`<p class="muted">Select a file.</p>`;
|
||||
if (file === undefined) return html`<p class="muted">Loading ${context.selectedFilePath}…</p>`;
|
||||
if (file.binary) return html`<p class="muted">Binary file: ${file.path}</p>`;
|
||||
if (file.mediaType === "image") return renderImageViewer(context, file);
|
||||
if (file.binary) return html`<p class="muted">Binary file: ${file.path} · ${formatFileSize(file.size)}</p>`;
|
||||
loadCodeViewer();
|
||||
return html`
|
||||
<div class="viewer-header"><strong>${file.path}</strong><small>${file.language ?? "text"}${file.truncated ? " · truncated" : ""}</small></div>
|
||||
@@ -74,6 +78,23 @@ function renderFileViewer(context: WorkspacePanelContext): TemplateResult {
|
||||
`;
|
||||
}
|
||||
|
||||
function renderImageViewer(context: WorkspacePanelContext, file: FileContentResponse): TemplateResult {
|
||||
const metadata = `${file.mimeType ?? "image"} · ${formatFileSize(file.size)}`;
|
||||
if (file.size > MAX_IMAGE_PREVIEW_BYTES) {
|
||||
return html`
|
||||
<div class="viewer-header"><strong>${file.path}</strong><small>${metadata}</small></div>
|
||||
<p class="muted">Image too large to preview: ${formatFileSize(file.size)} · limit ${MAX_IMAGE_PREVIEW_LABEL}</p>
|
||||
`;
|
||||
}
|
||||
const src = workspaceImagePreviewUrl(context.workspace.projectId, context.workspace.id, file.path, { modifiedAt: file.modifiedAt });
|
||||
return html`
|
||||
<div class="viewer-header"><strong>${file.path}</strong><small>${metadata}</small></div>
|
||||
<div class="image-preview">
|
||||
<img src=${src} alt=${file.path} decoding="async" />
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderTerminal(context: WorkspacePanelContext): TemplateResult {
|
||||
loadTerminalPanel();
|
||||
return html`<terminal-panel .workspace=${context.workspace} .selectedTerminalId=${context.selectedTerminalId} .autoStart=${context.terminalAutoStart} .onSelectTerminal=${context.onSelectTerminal}></terminal-panel>`;
|
||||
@@ -149,3 +170,17 @@ function stateLabel(index: string, workingTree: string): string {
|
||||
const label = workingTree !== "unmodified" ? workingTree : index;
|
||||
return label.slice(0, 1).toUpperCase();
|
||||
}
|
||||
|
||||
function formatFileSize(size: number): string {
|
||||
if (!Number.isFinite(size) || size < 0) return "0 B";
|
||||
if (size < 1024) return `${String(size)} B`;
|
||||
const kib = size / 1024;
|
||||
if (kib < 1024) return `${formatScaledFileSize(kib)} KB`;
|
||||
const mib = kib / 1024;
|
||||
if (mib < 1024) return `${formatScaledFileSize(mib)} MB`;
|
||||
return `${formatScaledFileSize(mib / 1024)} GB`;
|
||||
}
|
||||
|
||||
function formatScaledFileSize(value: number): string {
|
||||
return value >= 10 ? String(Math.round(value)) : value.toFixed(1);
|
||||
}
|
||||
|
||||
+37
-1
@@ -1,4 +1,4 @@
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { mkdtemp, rm, truncate, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
@@ -7,6 +7,7 @@ import { buildApp } from "./app.js";
|
||||
import { ProjectService } from "./projects/projectService.js";
|
||||
import { ProjectStore } from "./storage/projectStore.js";
|
||||
import { WorkspaceService } from "./workspaces/workspaceService.js";
|
||||
import { MAX_IMAGE_PREVIEW_BYTES } from "../shared/workspaceFiles.js";
|
||||
import type { Project, Workspace } from "./types.js";
|
||||
|
||||
let app: FastifyInstance;
|
||||
@@ -109,4 +110,39 @@ describe("buildApp", () => {
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("serves supported workspace images as previews", async () => {
|
||||
const addResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/projects",
|
||||
payload: { name: "Images", path: projectDir, create: true },
|
||||
});
|
||||
const project = addResponse.json<Project>();
|
||||
const svg = "<svg xmlns=\"http://www.w3.org/2000/svg\"><rect width=\"1\" height=\"1\" /></svg>";
|
||||
await writeFile(join(projectDir, "diagram.svg"), svg);
|
||||
await writeFile(join(projectDir, "note.txt"), "hello");
|
||||
await writeFile(join(projectDir, "huge.png"), "");
|
||||
await truncate(join(projectDir, "huge.png"), MAX_IMAGE_PREVIEW_BYTES + 1);
|
||||
|
||||
const workspacesResponse = await app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces` });
|
||||
const workspace = workspacesResponse.json<Workspace[]>()[0];
|
||||
if (workspace === undefined) throw new Error("Expected workspace");
|
||||
|
||||
const previewResponse = await app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces/${workspace.id}/file/preview?path=${encodeURIComponent("diagram.svg")}` });
|
||||
|
||||
expect(previewResponse.statusCode).toBe(200);
|
||||
expect(previewResponse.headers["content-type"]).toContain("image/svg+xml");
|
||||
expect(previewResponse.headers["cache-control"]).toBe("private, max-age=3600");
|
||||
expect(previewResponse.headers["content-security-policy"]).toContain("sandbox");
|
||||
expect(previewResponse.headers["x-content-type-options"]).toBe("nosniff");
|
||||
expect(previewResponse.body).toBe(svg);
|
||||
|
||||
const rejectedResponse = await app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces/${workspace.id}/file/preview?path=${encodeURIComponent("note.txt")}` });
|
||||
expect(rejectedResponse.statusCode).toBe(400);
|
||||
expect(rejectedResponse.json()).toEqual({ error: "Image preview is not supported for this file type" });
|
||||
|
||||
const tooLargeResponse = await app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces/${workspace.id}/file/preview?path=${encodeURIComponent("huge.png")}` });
|
||||
expect(tooLargeResponse.statusCode).toBe(400);
|
||||
expect(tooLargeResponse.json()).toEqual({ error: "Image is too large to preview (limit 10 MB)" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { WorkspaceService } from "./workspaces/workspaceService.js";
|
||||
import { resolveWorkspaceContext } from "./workspaces/workspaceContext.js";
|
||||
import { listWorkspaceTree } from "./workspaces/fileTreeService.js";
|
||||
import { readWorkspaceFile } from "./workspaces/fileContentService.js";
|
||||
import { readWorkspaceImagePreview } from "./workspaces/imagePreviewService.js";
|
||||
|
||||
export function registerWorkspaceExplorerRoutes(app: FastifyInstance, projects: ProjectService, workspaces: WorkspaceService): void {
|
||||
app.get<{ Params: { projectId: string; workspaceId: string }; Querystring: { path?: string } }>("/api/projects/:projectId/workspaces/:workspaceId/tree", async (request, reply) => {
|
||||
@@ -23,4 +24,21 @@ export function registerWorkspaceExplorerRoutes(app: FastifyInstance, projects:
|
||||
return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
});
|
||||
|
||||
app.get<{ Params: { projectId: string; workspaceId: string }; Querystring: { path?: string } }>("/api/projects/:projectId/workspaces/:workspaceId/file/preview", async (request, reply) => {
|
||||
try {
|
||||
const context = await resolveWorkspaceContext(projects, workspaces, request.params.projectId, request.params.workspaceId);
|
||||
const preview = await readWorkspaceImagePreview(context.root, request.query.path);
|
||||
return await reply
|
||||
.type(preview.mimeType)
|
||||
.header("Cache-Control", "private, max-age=3600")
|
||||
.header("Content-Length", String(preview.size))
|
||||
.header("Content-Security-Policy", "sandbox; default-src 'none'; img-src 'self' data: blob:; style-src 'unsafe-inline'")
|
||||
.header("Last-Modified", new Date(preview.modifiedAt).toUTCString())
|
||||
.header("X-Content-Type-Options", "nosniff")
|
||||
.send(preview.stream);
|
||||
} catch (error) {
|
||||
return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -145,9 +145,13 @@ export interface FileTreeResponse {
|
||||
truncated: boolean;
|
||||
}
|
||||
|
||||
export type FileContentMediaType = "image";
|
||||
|
||||
export interface FileContentResponse {
|
||||
path: string;
|
||||
language?: string;
|
||||
mediaType?: FileContentMediaType;
|
||||
mimeType?: string;
|
||||
encoding: "utf8";
|
||||
size: number;
|
||||
modifiedAt: string;
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
export const MAX_IMAGE_PREVIEW_BYTES = 10 * 1024 * 1024;
|
||||
export const MAX_IMAGE_PREVIEW_LABEL = "10 MB";
|
||||
Reference in New Issue
Block a user