From a1e903f8f9ec35b99ba8efb3ffc2e39cf84277f4 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Fri, 22 May 2026 15:36:01 +0200 Subject: [PATCH] feat: add file browser image previews --- .changeset/image-preview-file-browser.md | 5 ++ src/client/src/api.ts | 2 +- src/client/src/api/parsers.test.ts | 8 +++- src/client/src/api/parsers.ts | 8 +++- src/client/src/api/urls.ts | 7 +++ src/client/src/components/shared.ts | 2 + src/client/src/plugins/core/panels.ts | 41 +++++++++++++++-- src/server/app.test.ts | 38 ++++++++++++++- src/server/workspaceExplorerRoutes.ts | 18 ++++++++ .../workspaces/fileContentService.test.ts | 29 +++++++++++- src/server/workspaces/fileContentService.ts | 26 +++++++++-- src/server/workspaces/imagePreviewService.ts | 46 +++++++++++++++++++ src/shared/apiTypes.ts | 4 ++ src/shared/workspaceFiles.ts | 2 + 14 files changed, 224 insertions(+), 12 deletions(-) create mode 100644 .changeset/image-preview-file-browser.md create mode 100644 src/server/workspaces/imagePreviewService.ts create mode 100644 src/shared/workspaceFiles.ts diff --git a/.changeset/image-preview-file-browser.md b/.changeset/image-preview-file-browser.md new file mode 100644 index 0000000..6d112e7 --- /dev/null +++ b/.changeset/image-preview-file-browser.md @@ -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. diff --git a/src/client/src/api.ts b/src/client/src/api.ts index 36961a3..30617bf 100644 --- a/src/client/src/api.ts +++ b/src/client/src/api.ts @@ -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"; diff --git a/src/client/src/api/parsers.test.ts b/src/client/src/api/parsers.test.ts index 5d26291..c8c9db6 100644 --- a/src/client/src/api/parsers.test.ts +++ b/src/client/src/api/parsers.test.ts @@ -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", () => { diff --git a/src/client/src/api/parsers.ts b/src/client/src/api/parsers.ts index c81ff5b..dda4575 100644 --- a/src/client/src/api/parsers.ts +++ b/src/client/src/api/parsers.ts @@ -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 { diff --git a/src/client/src/api/urls.ts b/src/client/src/api/urls.ts index a7ac7ed..721735d 100644 --- a/src/client/src/api/urls.ts +++ b/src/client/src/api/urls.ts @@ -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()}`; +} diff --git a/src/client/src/components/shared.ts b/src/client/src/components/shared.ts index 59578fb..368ba88 100644 --- a/src/client/src/components/shared.ts +++ b/src/client/src/components/shared.ts @@ -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; } `; diff --git a/src/client/src/plugins/core/panels.ts b/src/client/src/plugins/core/panels.ts index 91dab38..5ac2514 100644 --- a/src/client/src/plugins/core/panels.ts +++ b/src/client/src/plugins/core/panels.ts @@ -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` - @@ -66,7 +69,8 @@ function renderFileViewer(context: WorkspacePanelContext): TemplateResult { const file = context.selectedFileContent; if (context.selectedFilePath === undefined || context.selectedFilePath === "") return html`

Select a file.

`; if (file === undefined) return html`

Loading ${context.selectedFilePath}…

`; - if (file.binary) return html`

Binary file: ${file.path}

`; + if (file.mediaType === "image") return renderImageViewer(context, file); + if (file.binary) return html`

Binary file: ${file.path} · ${formatFileSize(file.size)}

`; loadCodeViewer(); return html`
${file.path}${file.language ?? "text"}${file.truncated ? " · truncated" : ""}
@@ -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` +
${file.path}${metadata}
+

Image too large to preview: ${formatFileSize(file.size)} · limit ${MAX_IMAGE_PREVIEW_LABEL}

+ `; + } + const src = workspaceImagePreviewUrl(context.workspace.projectId, context.workspace.id, file.path, { modifiedAt: file.modifiedAt }); + return html` +
${file.path}${metadata}
+
+ ${file.path} +
+ `; +} + function renderTerminal(context: WorkspacePanelContext): TemplateResult { loadTerminalPanel(); return html``; @@ -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); +} diff --git a/src/server/app.test.ts b/src/server/app.test.ts index 6f4a981..5339f85 100644 --- a/src/server/app.test.ts +++ b/src/server/app.test.ts @@ -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(); + const 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()[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)" }); + }); }); diff --git a/src/server/workspaceExplorerRoutes.ts b/src/server/workspaceExplorerRoutes.ts index ff62613..9a793cc 100644 --- a/src/server/workspaceExplorerRoutes.ts +++ b/src/server/workspaceExplorerRoutes.ts @@ -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) }); + } + }); } diff --git a/src/server/workspaces/fileContentService.test.ts b/src/server/workspaces/fileContentService.test.ts index 9aa236c..22705da 100644 --- a/src/server/workspaces/fileContentService.test.ts +++ b/src/server/workspaces/fileContentService.test.ts @@ -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"), ""); + 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)); diff --git a/src/server/workspaces/fileContentService.ts b/src/server/workspaces/fileContentService.ts index 96ff86f..1718141 100644 --- a/src/server/workspaces/fileContentService.ts +++ b/src/server/workspaces/fileContentService.ts @@ -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 { + 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 }; +} diff --git a/src/server/workspaces/imagePreviewService.ts b/src/server/workspaces/imagePreviewService.ts new file mode 100644 index 0000000..319e200 --- /dev/null +++ b/src/server/workspaces/imagePreviewService.ts @@ -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 = { + ".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 { + 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), + }; +} diff --git a/src/shared/apiTypes.ts b/src/shared/apiTypes.ts index 2e96af0..58394d4 100644 --- a/src/shared/apiTypes.ts +++ b/src/shared/apiTypes.ts @@ -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; diff --git a/src/shared/workspaceFiles.ts b/src/shared/workspaceFiles.ts new file mode 100644 index 0000000..6a95bfc --- /dev/null +++ b/src/shared/workspaceFiles.ts @@ -0,0 +1,2 @@ +export const MAX_IMAGE_PREVIEW_BYTES = 10 * 1024 * 1024; +export const MAX_IMAGE_PREVIEW_LABEL = "10 MB";