import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from "react";
import { useQuery, type UseQueryResult } from "@tanstack/react-query";
import {
AlertTriangle,
Ban,
Check,
Cloud,
Copy,
FileCode2,
FileSearch,
FolderOpen,
Link2,
Loader2,
Lock,
RefreshCcw,
} from "lucide-react";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { fileResourcesApi } from "@/api/file-resources";
import { ApiError } from "@/api/client";
import { queryKeys } from "@/lib/queryKeys";
import {
useRequiredFileViewer,
type FileViewerUrlState,
} from "@/context/FileViewerContext";
import type {
ResolvedWorkspaceResource,
WorkspaceFileContent,
WorkspaceFileSelector,
} from "@paperclipai/shared";
const FILE_VIEWER_LABELLED_BY_ID = "paperclip-file-viewer-title";
const FILE_VIEWER_DESCRIBED_BY_ID = "paperclip-file-viewer-description";
interface FileViewerErrorShape {
status: number;
code: string;
message: string;
}
function normalizeError(error: unknown): FileViewerErrorShape {
if (error instanceof ApiError) {
const body = (error.body ?? null) as { error?: string; code?: string } | null;
const code = typeof body?.code === "string" ? body.code : "";
return {
status: error.status,
code,
message: typeof body?.error === "string" ? body.error : error.message,
};
}
if (error instanceof Error) {
return { status: 0, code: "", message: error.message };
}
return { status: 0, code: "", message: "Something went wrong." };
}
function formatBytes(size: number | null | undefined): string | null {
if (typeof size !== "number" || !Number.isFinite(size) || size < 0) return null;
if (size < 1024) return `${size} B`;
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`;
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
}
function decodeBase64ToBytes(data: string): Uint8Array {
const raw = globalThis.atob ? globalThis.atob(data) : "";
const bytes = new Uint8Array(raw.length);
for (let index = 0; index < raw.length; index += 1) {
bytes[index] = raw.charCodeAt(index);
}
return bytes;
}
function bytesToDataUrl(bytes: Uint8Array, contentType: string | null | undefined): string {
let binary = "";
for (const byte of bytes) binary += String.fromCharCode(byte);
const encoded = globalThis.btoa ? globalThis.btoa(binary) : "";
return `data:${contentType ?? "application/octet-stream"};base64,${encoded}`;
}
function splitContentIntoLines(data: string): string[] {
if (data === "") return [""];
const normalized = data.replace(/\r\n?/g, "\n");
const lines = normalized.split("\n");
if (lines.length > 0 && lines[lines.length - 1] === "") {
lines.pop();
}
return lines.length > 0 ? lines : [""];
}
function basename(path: string): string {
const idx = path.lastIndexOf("/");
return idx < 0 ? path : path.slice(idx + 1);
}
function middleTruncatePath(path: string, maxLen = 80): string {
if (path.length <= maxLen) return path;
const head = path.slice(0, Math.floor(maxLen / 2) - 1);
const tail = path.slice(path.length - (maxLen - head.length - 1));
return `${head}…${tail}`;
}
export function describeDenial(code: string, fallback: string): { title: string; body: string; icon: ReactNode } {
const lower = code.toLowerCase();
if (lower.includes("policy") || lower.includes("denied") || lower.includes("sensitive")) {
return {
icon: ,
title: "Viewer blocked for this file",
body: "This file is not available through the viewer because it may contain sensitive data.",
};
}
if (lower.includes("outside") || lower.includes("traversal")) {
return {
icon: ,
title: "Path is outside the workspace",
body: "The viewer can only open files that live under the issue's workspace.",
};
}
if (lower.includes("archive") || lower.includes("cleaned")) {
return {
icon: ,
title: "Workspace is no longer available",
body: "The isolated worktree for this issue has been cleaned up, so files cannot be previewed.",
};
}
if (lower.includes("remote")) {
return {
icon: ,
title: "Remote workspace preview not supported",
body: "This workspace is hosted remotely and is not available for inline preview yet.",
};
}
if (lower.includes("too_large") || lower.includes("size")) {
return {
icon: ,
title: "File is too large to preview",
body: "This file exceeds the supported preview size.",
};
}
if (lower.includes("binary") || lower.includes("unsupported")) {
return {
icon: ,
title: "Preview not supported for this file type",
body: "This file does not have a text or image preview available.",
};
}
return {
icon: ,
title: "Can't preview this file",
body: fallback || "The viewer was unable to load this file.",
};
}
function FileViewerStateView({
icon,
title,
body,
secondary,
actions,
}: {
icon: ReactNode;
title: string;
body?: string;
secondary?: ReactNode;
actions?: ReactNode;
}) {
return (
{icon}
{title}
{body ?
{body}
: null}
{secondary}
{actions ?
{actions}
: null}
);
}
interface OpenFilePromptProps {
onSubmit: (path: string, workspace: WorkspaceFileSelector) => void;
}
function OpenFilePrompt({ onSubmit }: OpenFilePromptProps) {
const [value, setValue] = useState("");
const [workspace, setWorkspace] = useState("auto");
const inputRef = useRef(null);
useEffect(() => {
const frame = requestAnimationFrame(() => inputRef.current?.focus());
return () => cancelAnimationFrame(frame);
}, []);
const handleSubmit = (event: React.FormEvent) => {
event.preventDefault();
const trimmed = value.trim();
if (!trimmed) return;
onSubmit(trimmed, workspace);
};
return (
);
}
interface FileContentViewerProps {
content: WorkspaceFileContent;
highlightedLine: number | null;
onLoaded?: (summary: string) => void;
}
function FileContentViewer({ content, highlightedLine, onLoaded }: FileContentViewerProps) {
const { resource } = content;
const lines = useMemo(() => {
if (resource.previewKind === "text") {
return splitContentIntoLines(content.content.data);
}
return null;
}, [content.content.data, resource.previewKind]);
const codeScrollRef = useRef(null);
const highlightedLineRef = useRef(null);
useEffect(() => {
if (!lines) return;
onLoaded?.(`File loaded, ${lines.length} ${lines.length === 1 ? "line" : "lines"}.`);
}, [lines, onLoaded]);
useEffect(() => {
if (!highlightedLine || !highlightedLineRef.current) return;
highlightedLineRef.current.scrollIntoView({ block: "center", behavior: "auto" });
}, [highlightedLine]);
if (resource.previewKind === "image") {
const encoded = content.content.encoding === "base64" ? content.content.data : content.content.data;
const dataUrl = content.content.encoding === "base64"
? bytesToDataUrl(decodeBase64ToBytes(encoded), resource.contentType)
: null;
if (!dataUrl) {
return (
}
title="Image preview unavailable"
/>
);
}
return (
);
}
if (resource.previewKind === "unsupported" || !lines) {
return (
}
title="Preview not supported for this file type"
body={resource.contentType ? `Content type: ${resource.contentType}` : undefined}
/>
);
}
const gutterWidth = `${Math.max(2, String(lines.length).length)}ch`;
return (
{lines.map((lineText, index) => {
const lineNumber = index + 1;
const isHighlighted = lineNumber === highlightedLine;
return (
{lineNumber}
{lineText.length === 0 ? "" : lineText}
);
})}
);
}
function LoadingView({ elapsedMs }: { elapsedMs: number }) {
if (elapsedMs < 100) {
return ;
}
if (elapsedMs < 400) {
return (
Loading file preview
{Array.from({ length: 10 }).map((_, index) => (
))}
);
}
return (
);
}
interface FileViewerSheetProps {
issueId: string;
/** When not provided, the sheet defaults to the context state. */
state?: FileViewerUrlState | null;
/** When true, renders the "Open file" prompt when no file is selected but sheet is open. */
showPromptWhenEmpty?: boolean;
/** Whether the sheet is open. Defaults to `state !== null`. */
open?: boolean;
onOpenChange?: (open: boolean) => void;
}
export function FileViewerSheet({
issueId,
state: stateProp,
showPromptWhenEmpty = false,
open: openProp,
onOpenChange,
}: FileViewerSheetProps) {
const viewer = useRequiredFileViewer();
const state = typeof stateProp !== "undefined" ? stateProp : viewer.state;
const computedOpen = typeof openProp === "boolean" ? openProp : state !== null || showPromptWhenEmpty;
const [loadStart, setLoadStart] = useState(() => Date.now());
const [elapsedMs, setElapsedMs] = useState(0);
const [copiedField, setCopiedField] = useState<"path" | "link" | null>(null);
const [announcement, setAnnouncement] = useState("");
useEffect(() => {
if (!state) {
setElapsedMs(0);
return;
}
setLoadStart(Date.now());
setElapsedMs(0);
const interval = window.setInterval(() => {
setElapsedMs(Date.now() - loadStart);
}, 75);
return () => window.clearInterval(interval);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [state?.path, state?.workspace]);
const resolveQuery = useQuery({
queryKey: state
? queryKeys.issues.fileResource(issueId, state.path, state.workspace)
: ["issues", "file-resources", issueId, "resolve", "__closed__"],
queryFn: () => fileResourcesApi.resolve(issueId, { path: state!.path, workspace: state!.workspace }),
enabled: !!state && computedOpen,
retry: false,
staleTime: 30_000,
});
const resolvedResource: ResolvedWorkspaceResource | undefined = resolveQuery.data;
const canPreview = resolvedResource?.capabilities.preview ?? false;
const contentQuery = useQuery({
queryKey: state
? queryKeys.issues.fileResourceContent(issueId, state.path, state.workspace)
: ["issues", "file-resources", issueId, "content", "__closed__"],
queryFn: () => fileResourcesApi.content(issueId, { path: state!.path, workspace: state!.workspace }),
enabled: !!state && computedOpen && canPreview,
retry: false,
staleTime: 30_000,
});
useEffect(() => {
if (resolveQuery.isError) {
const normalized = normalizeError(resolveQuery.error);
setAnnouncement(normalized.message || "Unable to load file.");
}
}, [resolveQuery.isError, resolveQuery.error]);
useEffect(() => {
if (contentQuery.isError) {
const normalized = normalizeError(contentQuery.error);
setAnnouncement(normalized.message || "Unable to load file content.");
}
}, [contentQuery.isError, contentQuery.error]);
const handleOpenChange = useCallback(
(next: boolean) => {
if (onOpenChange) {
onOpenChange(next);
return;
}
if (!next) viewer.close();
},
[onOpenChange, viewer],
);
const handlePromptSubmit = useCallback(
(path: string, workspace: WorkspaceFileSelector) => {
viewer.open({ path, line: null, column: null, workspace });
},
[viewer],
);
const copyToClipboard = useCallback(async (value: string, field: "path" | "link") => {
try {
await navigator.clipboard.writeText(value);
setCopiedField(field);
window.setTimeout(() => setCopiedField((current) => (current === field ? null : current)), 1500);
} catch {
setAnnouncement("Unable to copy to clipboard.");
}
}, []);
const handleCopyPath = useCallback(() => {
if (state) void copyToClipboard(state.path, "path");
}, [copyToClipboard, state]);
const handleCopyLink = useCallback(() => {
if (typeof window === "undefined") return;
void copyToClipboard(window.location.href, "link");
}, [copyToClipboard]);
const handleRetry = useCallback(() => {
void resolveQuery.refetch();
if (canPreview) void contentQuery.refetch();
}, [canPreview, contentQuery, resolveQuery]);
const title = state ? basename(state.path) : "Open file";
const description = state
? middleTruncatePath(state.path)
: "Enter a workspace-relative path to preview.";
const showDescription = state ? description !== title : true;
return (
{title}
{description}
{state ? (
) : null}
{state ? (
) : null}
{resolvedResource ? (
From {resolvedResource.workspaceLabel}
{resolvedResource.previewKind ? (
{resolvedResource.previewKind}
) : null}
{formatBytes(resolvedResource.byteSize) ? (
{formatBytes(resolvedResource.byteSize)}
) : null}
{state?.line ? (
Line {state.line}
{state.column ? `, Col ${state.column}` : ""}
) : null}
) : null}
{announcement}
{state ? (
viewer.open({
path: state.path,
line: state.line,
column: state.column,
workspace: "project",
})
: null
}
/>
) : showPromptWhenEmpty ? (
) : null}
);
}
interface FileViewerBodyProps {
resolveQuery: UseQueryResult;
contentQuery: UseQueryResult;
elapsedMs: number;
canPreview: boolean;
highlightedLine: number | null;
onRetry: () => void;
onSetAnnouncement: (message: string) => void;
onFallbackToProject: null | (() => void);
}
function FileViewerBody({
resolveQuery,
contentQuery,
elapsedMs,
canPreview,
highlightedLine,
onRetry,
onSetAnnouncement,
onFallbackToProject,
}: FileViewerBodyProps) {
if (resolveQuery.isFetching && !resolveQuery.data) {
return ;
}
if (resolveQuery.isError) {
const normalized = normalizeError(resolveQuery.error);
if (normalized.status === 404) {
return (
}
title="File not found"
body="That file was not found in the active workspace."
actions={
<>
{onFallbackToProject ? (
) : null}
>
}
/>
);
}
if (normalized.status === 422) {
return (
}
title="No workspace available"
body="This issue does not have a workspace that supports preview yet."
/>
);
}
const denial = describeDenial(normalized.code, normalized.message);
return (
Retry
}
/>
);
}
const resource = resolveQuery.data;
if (!resource) return null;
if (resource.kind === "remote_resource") {
return (
}
title="Remote workspace preview coming soon"
body="This workspace is hosted remotely; inline previews are not supported yet."
/>
);
}
if (!canPreview) {
const denial = describeDenial(resource.denialReason ?? "", "");
return ;
}
if (contentQuery.isFetching && !contentQuery.data) {
return ;
}
if (contentQuery.isError) {
const normalized = normalizeError(contentQuery.error);
const denial = describeDenial(normalized.code, normalized.message);
return (
Retry
}
/>
);
}
if (!contentQuery.data) return null;
return (
);
}