Improve workspace file viewer previews

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-06-09 21:35:48 +00:00
1 parent d503982075
commit fec3c19a0e
8 files changed
+185 -63

No files matched your search

@@ -1,7 +1,7 @@
export type WorkspaceFileWorkspaceKind = "execution_workspace" | "project_workspace";
export type WorkspaceFileSelector = "auto" | "execution" | "project";
export type WorkspaceFileListMode = "all" | "recent" | "changed";
export type WorkspaceFilePreviewKind = "text" | "image" | "pdf" | "unsupported";
export type WorkspaceFilePreviewKind = "text" | "image" | "video" | "pdf" | "unsupported";
export type WorkspaceFileResourceKind = "file" | "remote_resource";
export type WorkspaceFileContentEncoding = "utf8" | "base64";
@@ -9,7 +9,7 @@ function utf8ByteLength(value: string) {
export const workspaceFileWorkspaceKindSchema = z.enum(["execution_workspace", "project_workspace"]);
export const workspaceFileSelectorSchema = z.enum(["auto", "execution", "project"]).default("auto");
export const workspaceFileListModeSchema = z.enum(["all", "recent", "changed"]).default("all");
export const workspaceFilePreviewKindSchema = z.enum(["text", "image", "pdf", "unsupported"]);
export const workspaceFilePreviewKindSchema = z.enum(["text", "image", "video", "pdf", "unsupported"]);
export const workspaceFileResourceKindSchema = z.enum(["file", "remote_resource"]);
export const workspaceFileRefSchema = z.object({
@@ -215,6 +215,28 @@ describeEmbeddedPostgres("workspace file resources", () => {
expect(resolved.capabilities.preview).toBe(true);
});
it("resolves and reads video workspace files as base64 previews", async () => {
const { projectRoot, executionRoot } = await makeWorkspace();
const graph = await seedGraph(db, { projectRoot, executionRoot });
await fs.writeFile(path.join(projectRoot, "demo.mp4"), Buffer.from([0, 0, 0, 24, 102, 116, 121, 112]));
const resolved = await workspaceFileResourceService(db).resolve(graph.issueId, {
path: "demo.mp4",
workspace: "project",
});
expect(resolved.previewKind).toBe("video");
expect(resolved.contentType).toBe("video/mp4");
expect(resolved.capabilities.preview).toBe(true);
const content = await workspaceFileResourceService(db).readContent(graph.issueId, {
path: "demo.mp4",
workspace: "project",
});
expect(content.resource.previewKind).toBe("video");
expect(content.content.encoding).toBe("base64");
expect(content.content.data).toBe(Buffer.from([0, 0, 0, 24, 102, 116, 121, 112]).toString("base64"));
});
it("lists and searches safe file candidates from the preferred execution workspace", async () => {
const { root, projectRoot, executionRoot } = await makeWorkspace();
const graph = await seedGraph(db, { projectRoot, executionRoot });
@@ -87,6 +87,13 @@ const IMAGE_CONTENT_TYPES = new Map([
[".webp", "image/webp"],
]);
const VIDEO_CONTENT_TYPES = new Map([
[".m4v", "video/mp4"],
[".mov", "video/quicktime"],
[".mp4", "video/mp4"],
[".webm", "video/webm"],
]);
type IssueRow = typeof issues.$inferSelect;
type ExecutionWorkspaceRow = typeof executionWorkspaces.$inferSelect;
type ProjectWorkspaceRow = typeof projectWorkspaces.$inferSelect;
@@ -118,7 +125,9 @@ type WorkspaceFileListQueryInput = {
};
function previewCapForKind(kind: WorkspaceFilePreviewKind) {
return kind === "image" || kind === "pdf" ? WORKSPACE_FILE_MEDIA_MAX_BYTES : WORKSPACE_FILE_TEXT_MAX_BYTES;
return kind === "image" || kind === "video" || kind === "pdf"
? WORKSPACE_FILE_MEDIA_MAX_BYTES
: WORKSPACE_FILE_TEXT_MAX_BYTES;
}
function relativePathFromReal(rootReal: string, targetReal: string) {
@@ -184,6 +193,7 @@ function shouldPruneSegments(segments: string[]) {
function contentTypeForPath(filePath: string): string | null {
const ext = path.extname(filePath).toLowerCase();
if (IMAGE_CONTENT_TYPES.has(ext)) return IMAGE_CONTENT_TYPES.get(ext) ?? null;
if (VIDEO_CONTENT_TYPES.has(ext)) return VIDEO_CONTENT_TYPES.get(ext) ?? null;
if (ext === ".pdf") return "application/pdf";
if (ext === ".svg") return "image/svg+xml";
if (ext === ".html" || ext === ".htm") return "text/html";
@@ -194,6 +204,7 @@ function contentTypeForPath(filePath: string): string | null {
function previewKindForKnownContentType(contentType: string | null): WorkspaceFilePreviewKind | null {
if (!contentType) return null;
if (contentType.startsWith("image/") && contentType !== "image/svg+xml") return "image";
if (contentType.startsWith("video/")) return "video";
if (contentType === "application/pdf") return "pdf";
if (contentType === "text/html") return "unsupported";
if (contentType === "image/svg+xml" || contentType.startsWith("text/")) return "text";
+1
View File
@@ -1936,6 +1936,7 @@ async function isRuntimeServiceUrlHealthy(
) {
if (!url) return true;
const healthUrl = resolveRuntimeServiceHealthUrl(url, input);
if (!healthUrl) return true;
try {
const response = await fetch(healthUrl, { signal: AbortSignal.timeout(2_000) });
return response.ok;
-38
View File
@@ -1,38 +0,0 @@
import { describe, expect, it } from "vitest";
import { describeDenial } from "./FileViewerSheet";
describe("describeDenial", () => {
it("returns the curated body for too_large regardless of fallback", () => {
expect(describeDenial("too_large", "").body).toBe(
"This file exceeds the supported preview size.",
);
expect(describeDenial("too_large", "ignored fallback").body).toBe(
"This file exceeds the supported preview size.",
);
});
it("does not leak the raw denial code as body when fallback is empty", () => {
for (const code of [
"denied_by_policy_sensitive",
"outside_workspace_root",
"workspace_archived",
"binary_unsupported",
"remote_preview_unsupported",
]) {
const { body } = describeDenial(code, "");
expect(body).not.toBe(code);
expect(body).not.toMatch(/^[a-z_]+$/);
}
});
it("falls back to the generic message for unknown codes with empty fallback", () => {
const { body, title } = describeDenial("", "");
expect(title).toBe("Can't preview this file");
expect(body).toBe("The viewer was unable to load this file.");
});
it("prefers a human-readable server message for unknown codes", () => {
const { body } = describeDenial("unknown_code", "Server refused the request.");
expect(body).toBe("Server refused the request.");
});
});
+101
View File
@@ -0,0 +1,101 @@
import { describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import type { WorkspaceFileContent } from "@paperclipai/shared";
import { describeDenial, FileContentViewer } from "./FileViewerSheet";
describe("describeDenial", () => {
it("returns the curated body for too_large regardless of fallback", () => {
expect(describeDenial("too_large", "").body).toBe(
"This file exceeds the supported preview size.",
);
expect(describeDenial("too_large", "ignored fallback").body).toBe(
"This file exceeds the supported preview size.",
);
});
it("does not leak the raw denial code as body when fallback is empty", () => {
for (const code of [
"denied_by_policy_sensitive",
"outside_workspace_root",
"workspace_archived",
"binary_unsupported",
"remote_preview_unsupported",
]) {
const { body } = describeDenial(code, "");
expect(body).not.toBe(code);
expect(body).not.toMatch(/^[a-z_]+$/);
}
});
it("describes unsupported previews in terms of text, image, and video", () => {
expect(describeDenial("unsupported_content", "").body).toBe(
"This file does not have a text, image, or video preview available.",
);
});
it("falls back to the generic message for unknown codes with empty fallback", () => {
const { body, title } = describeDenial("", "");
expect(title).toBe("Can't preview this file");
expect(body).toBe("The viewer was unable to load this file.");
});
it("prefers a human-readable server message for unknown codes", () => {
const { body } = describeDenial("unknown_code", "Server refused the request.");
expect(body).toBe("Server refused the request.");
});
});
describe("FileContentViewer", () => {
function content(overrides: Partial<WorkspaceFileContent> = {}): WorkspaceFileContent {
return {
resource: {
kind: "file",
provider: "local_fs",
title: "notes.txt",
displayPath: "notes.txt",
workspaceLabel: "Workspace",
workspaceKind: "project_workspace",
workspaceId: "11111111-1111-4111-8111-111111111111",
contentType: "text/plain; charset=utf-8",
byteSize: 18,
previewKind: "text",
capabilities: { preview: true, download: false, listChildren: false },
},
content: {
encoding: "utf8",
data: "one very long line",
},
...overrides,
};
}
it("wraps text content instead of forcing horizontal preformatted scrolling", () => {
const markup = renderToStaticMarkup(<FileContentViewer content={content()} highlightedLine={null} />);
expect(markup).toContain("whitespace-pre-wrap");
expect(markup).toContain("break-words");
});
it("renders video previews with native controls", () => {
const markup = renderToStaticMarkup(
<FileContentViewer
content={content({
resource: {
...content().resource,
title: "demo.mp4",
displayPath: "demo.mp4",
contentType: "video/mp4",
previewKind: "video",
},
content: {
encoding: "base64",
data: "AAAA",
},
})}
highlightedLine={null}
/>,
);
expect(markup).toContain("<video");
expect(markup).toContain("controls");
expect(markup).toContain("data:video/mp4;base64,AAAA");
});
});
+47 -22
View File
@@ -25,12 +25,12 @@ import {
X,
} from "lucide-react";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { fileResourcesApi } from "@/api/file-resources";
@@ -142,7 +142,7 @@ export function describeDenial(code: string, fallback: string): { title: string;
return {
icon: <AlertTriangle aria-hidden="true" className="h-6 w-6 text-amber-500" />,
title: "Preview not supported for this file type",
body: "This file does not have a text or image preview available.",
body: "This file does not have a text, image, or video preview available.",
};
}
return {
@@ -186,7 +186,7 @@ interface FileContentViewerProps {
onLoaded?: (summary: string) => void;
}
function FileContentViewer({ content, highlightedLine, onLoaded }: FileContentViewerProps) {
export function FileContentViewer({ content, highlightedLine, onLoaded }: FileContentViewerProps) {
const { resource } = content;
const lines = useMemo(() => {
if (resource.previewKind === "text") {
@@ -231,6 +231,32 @@ function FileContentViewer({ content, highlightedLine, onLoaded }: FileContentVi
);
}
if (resource.previewKind === "video") {
const dataUrl = content.content.encoding === "base64"
? `data:${resource.contentType ?? "application/octet-stream"};base64,${content.content.data}`
: null;
if (!dataUrl) {
return (
<FileViewerStateView
icon={<AlertTriangle aria-hidden="true" className="h-6 w-6 text-amber-500" />}
title="Video preview unavailable"
/>
);
}
return (
<div className="flex flex-1 items-center justify-center overflow-auto bg-black p-4">
<video
src={dataUrl}
controls
preload="metadata"
playsInline
aria-label={`Video preview: ${resource.title}`}
className="max-h-full max-w-full rounded border border-white/10 bg-black"
/>
</div>
);
}
if (resource.previewKind === "unsupported" || !lines) {
return (
<FileViewerStateView
@@ -261,7 +287,7 @@ function FileContentViewer({ content, highlightedLine, onLoaded }: FileContentVi
ref={isHighlighted ? highlightedLineRef : undefined}
data-line-number={lineNumber}
className={cn(
"flex whitespace-pre",
"grid grid-cols-[auto_minmax(0,1fr)]",
isHighlighted && "bg-[var(--paperclip-code-highlight-bg,rgba(250,204,21,0.12))]",
)}
>
@@ -277,7 +303,7 @@ function FileContentViewer({ content, highlightedLine, onLoaded }: FileContentVi
>
{lineNumber}
</span>
<code className="flex-1 pr-4">{lineText.length === 0 ? "​" : lineText}</code>
<code className="min-w-0 whitespace-pre-wrap break-words pr-4">{lineText.length === 0 ? "​" : lineText}</code>
</div>
);
})}
@@ -448,10 +474,9 @@ export function FileViewerSheet({
const showDescription = state ? description !== title : true;
return (
<Sheet open={computedOpen} onOpenChange={handleOpenChange}>
<SheetContent
side="right"
className="flex h-full w-full flex-col gap-0 p-0 sm:max-w-[min(900px,90vw)]"
<Dialog open={computedOpen} onOpenChange={handleOpenChange}>
<DialogContent
className="flex h-[min(840px,calc(100dvh-2rem))] w-[calc(100vw-2rem)] max-w-[min(1120px,calc(100vw-2rem))] flex-col gap-0 overflow-hidden p-0 sm:w-[92vw] sm:max-w-[1120px]"
aria-labelledby={FILE_VIEWER_LABELLED_BY_ID}
aria-describedby={FILE_VIEWER_DESCRIBED_BY_ID}
showCloseButton={false}
@@ -463,7 +488,7 @@ export function FileViewerSheet({
}
}}
>
<SheetHeader className="border-b border-border gap-1 p-3">
<DialogHeader className="border-b border-border gap-1 p-3">
<div className="flex items-start gap-2">
{browseMode ? (
<FolderSearch aria-hidden="true" className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
@@ -471,10 +496,10 @@ export function FileViewerSheet({
<FileCode2 aria-hidden="true" className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
)}
<div className="min-w-0 flex-1">
<SheetTitle id={FILE_VIEWER_LABELLED_BY_ID} className="truncate text-sm">
<DialogTitle id={FILE_VIEWER_LABELLED_BY_ID} className="truncate text-sm">
{title}
</SheetTitle>
<SheetDescription
</DialogTitle>
<DialogDescription
id={FILE_VIEWER_DESCRIBED_BY_ID}
className={cn(
"truncate font-mono text-xs",
@@ -483,7 +508,7 @@ export function FileViewerSheet({
title={state?.path}
>
{description}
</SheetDescription>
</DialogDescription>
</div>
<div className="flex shrink-0 items-center gap-1">
{cameFromBrowse ? (
@@ -579,7 +604,7 @@ export function FileViewerSheet({
) : null}
</div>
) : null}
</SheetHeader>
</DialogHeader>
<div className="relative flex min-h-0 flex-1 flex-col">
<div aria-live="polite" className="sr-only">
{announcement}
@@ -614,8 +639,8 @@ export function FileViewerSheet({
/>
) : null}
</div>
</SheetContent>
</Sheet>
</DialogContent>
</Dialog>
);
}