mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 09:41:04 +01:00
Improve workspace file viewer previews
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user