feat: add file browser image previews

This commit is contained in:
Federico Jaramillo Martinez
2026-05-22 15:36:24 +02:00
parent 5bc25424d3
commit a1e903f8f9
14 changed files with 224 additions and 12 deletions
+1 -1
View File
@@ -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";
+6 -2
View File
@@ -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", () => {
+7 -1
View File
@@ -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 {
+7
View File
@@ -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()}`;
}
+2
View File
@@ -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; }
`;
+38 -3
View File
@@ -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);
}