mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 09:41:04 +01:00
793 lines
26 KiB
TypeScript
793 lines
26 KiB
TypeScript
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 { parseWorkspaceFileRef } from "@/lib/workspace-file-parser";
|
||
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: <Lock aria-hidden="true" className="h-6 w-6 text-amber-500" />,
|
||
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: <Ban aria-hidden="true" className="h-6 w-6 text-red-500" />,
|
||
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: <FolderOpen aria-hidden="true" className="h-6 w-6 text-muted-foreground" />,
|
||
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: <AlertTriangle aria-hidden="true" className="h-6 w-6 text-amber-500" />,
|
||
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: <AlertTriangle aria-hidden="true" className="h-6 w-6 text-amber-500" />,
|
||
title: "File is too large to preview",
|
||
body: "This file exceeds the supported preview size.",
|
||
};
|
||
}
|
||
if (lower.includes("binary") || lower.includes("unsupported")) {
|
||
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.",
|
||
};
|
||
}
|
||
return {
|
||
icon: <Ban aria-hidden="true" className="h-6 w-6 text-red-500" />,
|
||
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 (
|
||
<div className="flex flex-col items-start gap-3 p-6 text-sm">
|
||
<div className="flex items-start gap-3">
|
||
{icon}
|
||
<div className="flex-1 space-y-1">
|
||
<p className="font-medium text-foreground">{title}</p>
|
||
{body ? <p className="text-muted-foreground">{body}</p> : null}
|
||
{secondary}
|
||
</div>
|
||
</div>
|
||
{actions ? <div className="flex flex-wrap gap-2">{actions}</div> : null}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
interface OpenFilePromptProps {
|
||
onSubmit: (
|
||
path: string,
|
||
workspace: WorkspaceFileSelector,
|
||
line: number | null,
|
||
column: number | null,
|
||
) => void;
|
||
}
|
||
|
||
function OpenFilePrompt({ onSubmit }: OpenFilePromptProps) {
|
||
const [value, setValue] = useState("");
|
||
const [workspace, setWorkspace] = useState<WorkspaceFileSelector>("auto");
|
||
const inputRef = useRef<HTMLInputElement>(null);
|
||
|
||
useEffect(() => {
|
||
const frame = requestAnimationFrame(() => inputRef.current?.focus());
|
||
return () => cancelAnimationFrame(frame);
|
||
}, []);
|
||
|
||
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
|
||
event.preventDefault();
|
||
const trimmed = value.trim();
|
||
if (!trimmed) return;
|
||
const parsed = parseWorkspaceFileRef(trimmed);
|
||
if (parsed) {
|
||
onSubmit(parsed.path, workspace, parsed.line, parsed.column);
|
||
return;
|
||
}
|
||
onSubmit(trimmed, workspace, null, null);
|
||
};
|
||
|
||
return (
|
||
<form className="space-y-4 p-6" onSubmit={handleSubmit}>
|
||
<label htmlFor="paperclip-file-viewer-input" className="sr-only">
|
||
Workspace-relative file path
|
||
</label>
|
||
<input
|
||
ref={inputRef}
|
||
id="paperclip-file-viewer-input"
|
||
type="text"
|
||
className="w-full rounded-md border border-border bg-background px-3 py-2 font-mono text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||
placeholder="e.g. ui/src/pages/IssueDetail.tsx:42"
|
||
value={value}
|
||
onChange={(event) => setValue(event.target.value)}
|
||
autoComplete="off"
|
||
spellCheck={false}
|
||
/>
|
||
<fieldset className="space-y-2">
|
||
<legend className="text-xs text-muted-foreground">Workspace</legend>
|
||
<div className="flex flex-wrap gap-2">
|
||
{(["auto", "execution", "project"] as const).map((option) => (
|
||
<label
|
||
key={option}
|
||
className={cn(
|
||
"cursor-pointer rounded-md border px-3 py-1 text-xs capitalize",
|
||
workspace === option
|
||
? "border-primary bg-primary/10 text-primary"
|
||
: "border-border text-foreground/80 hover:bg-accent/40",
|
||
)}
|
||
>
|
||
<input
|
||
type="radio"
|
||
name="paperclip-file-viewer-workspace"
|
||
value={option}
|
||
checked={workspace === option}
|
||
onChange={() => setWorkspace(option)}
|
||
className="sr-only"
|
||
/>
|
||
{option === "auto" ? "Auto (issue default)" : option}
|
||
</label>
|
||
))}
|
||
</div>
|
||
</fieldset>
|
||
<div className="flex justify-end">
|
||
<Button type="submit" size="sm" disabled={!value.trim()}>
|
||
Open file
|
||
</Button>
|
||
</div>
|
||
</form>
|
||
);
|
||
}
|
||
|
||
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<HTMLDivElement>(null);
|
||
const highlightedLineRef = useRef<HTMLDivElement>(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 (
|
||
<FileViewerStateView
|
||
icon={<AlertTriangle aria-hidden="true" className="h-6 w-6 text-amber-500" />}
|
||
title="Image preview unavailable"
|
||
/>
|
||
);
|
||
}
|
||
return (
|
||
<div className="flex items-center justify-center overflow-auto bg-muted/40 p-4">
|
||
<img
|
||
src={dataUrl}
|
||
alt={resource.title}
|
||
className="max-h-full max-w-full rounded border border-border object-contain"
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (resource.previewKind === "unsupported" || !lines) {
|
||
return (
|
||
<FileViewerStateView
|
||
icon={<AlertTriangle aria-hidden="true" className="h-6 w-6 text-amber-500" />}
|
||
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 (
|
||
<div
|
||
ref={codeScrollRef}
|
||
role="region"
|
||
aria-label={`${resource.title} source`}
|
||
tabIndex={0}
|
||
className="paperclip-file-viewer-code flex-1 overflow-auto bg-[var(--paperclip-code-bg,theme(colors.muted.DEFAULT))] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset"
|
||
>
|
||
<pre className="m-0 font-mono text-xs leading-5">
|
||
{lines.map((lineText, index) => {
|
||
const lineNumber = index + 1;
|
||
const isHighlighted = lineNumber === highlightedLine;
|
||
return (
|
||
<div
|
||
key={lineNumber}
|
||
ref={isHighlighted ? highlightedLineRef : undefined}
|
||
data-line-number={lineNumber}
|
||
className={cn(
|
||
"flex whitespace-pre",
|
||
isHighlighted && "bg-[var(--paperclip-code-highlight-bg,rgba(250,204,21,0.12))]",
|
||
)}
|
||
>
|
||
<span
|
||
aria-hidden="true"
|
||
className={cn(
|
||
"sticky left-0 z-10 shrink-0 select-none pl-3 pr-4 text-right text-[var(--paperclip-code-gutter-fg,theme(colors.muted.foreground))] opacity-70",
|
||
"bg-[var(--paperclip-code-bg,theme(colors.muted.DEFAULT))]",
|
||
isHighlighted &&
|
||
"opacity-100 bg-[var(--paperclip-code-highlight-bg,rgba(250,204,21,0.12))] border-l-2 border-[var(--paperclip-code-highlight-border,rgb(234,179,8))]",
|
||
)}
|
||
style={{ width: gutterWidth }}
|
||
>
|
||
{lineNumber}
|
||
</span>
|
||
<code className="flex-1 pr-4">{lineText.length === 0 ? "" : lineText}</code>
|
||
</div>
|
||
);
|
||
})}
|
||
</pre>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function LoadingView({ elapsedMs }: { elapsedMs: number }) {
|
||
if (elapsedMs < 100) {
|
||
return <div className="flex-1" aria-hidden="true" />;
|
||
}
|
||
if (elapsedMs < 400) {
|
||
return (
|
||
<div className="flex-1 space-y-2 p-6" aria-busy="true" aria-live="polite">
|
||
<span className="sr-only">Loading file preview</span>
|
||
{Array.from({ length: 10 }).map((_, index) => (
|
||
<div key={index} className="h-3 rounded bg-muted animate-pulse" style={{ width: `${90 - index * 6}%` }} />
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
return (
|
||
<div
|
||
className="flex flex-1 flex-col items-start justify-start gap-3 p-6 text-sm"
|
||
role="status"
|
||
aria-live="polite"
|
||
>
|
||
<div className="flex items-center gap-2 text-muted-foreground">
|
||
<Loader2 aria-hidden="true" className="h-4 w-4 animate-spin" />
|
||
Loading file preview...
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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 [elapsedMs, setElapsedMs] = useState(0);
|
||
const [copiedField, setCopiedField] = useState<"path" | "link" | null>(null);
|
||
const [announcement, setAnnouncement] = useState<string>("");
|
||
|
||
useEffect(() => {
|
||
if (!state) {
|
||
setElapsedMs(0);
|
||
return;
|
||
}
|
||
const now = Date.now();
|
||
setElapsedMs(0);
|
||
const interval = window.setInterval(() => {
|
||
setElapsedMs(Date.now() - now);
|
||
}, 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,
|
||
line: number | null,
|
||
column: number | null,
|
||
) => {
|
||
viewer.open({ path, line, column, 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 (
|
||
<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)]"
|
||
aria-labelledby={FILE_VIEWER_LABELLED_BY_ID}
|
||
aria-describedby={FILE_VIEWER_DESCRIBED_BY_ID}
|
||
showCloseButton={false}
|
||
>
|
||
<SheetHeader className="border-b border-border gap-1 p-3">
|
||
<div className="flex items-start gap-2">
|
||
<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">
|
||
{title}
|
||
</SheetTitle>
|
||
<SheetDescription
|
||
id={FILE_VIEWER_DESCRIBED_BY_ID}
|
||
className={cn(
|
||
"truncate font-mono text-xs",
|
||
!showDescription && "sr-only",
|
||
)}
|
||
title={state?.path}
|
||
>
|
||
{description}
|
||
</SheetDescription>
|
||
</div>
|
||
<div className="flex shrink-0 items-center gap-1">
|
||
{state ? (
|
||
<Button
|
||
type="button"
|
||
variant="ghost"
|
||
size="icon-sm"
|
||
onClick={handleCopyPath}
|
||
aria-label="Copy path"
|
||
title="Copy path"
|
||
className="h-7 w-7"
|
||
>
|
||
{copiedField === "path" ? <Check className="h-4 w-4 text-green-500" /> : <Copy className="h-4 w-4" />}
|
||
</Button>
|
||
) : null}
|
||
{state ? (
|
||
<Button
|
||
type="button"
|
||
variant="ghost"
|
||
size="icon-sm"
|
||
onClick={handleCopyLink}
|
||
aria-label="Copy link to this file view"
|
||
title="Copy link"
|
||
className="h-7 w-7"
|
||
>
|
||
{copiedField === "link" ? <Check className="h-4 w-4 text-green-500" /> : <Link2 className="h-4 w-4" />}
|
||
</Button>
|
||
) : null}
|
||
<Button
|
||
type="button"
|
||
variant="ghost"
|
||
size="sm"
|
||
onClick={() => handleOpenChange(false)}
|
||
className="h-7 px-2 text-xs"
|
||
aria-label="Close file viewer"
|
||
>
|
||
Close
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
{resolvedResource ? (
|
||
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-xs text-muted-foreground">
|
||
<span title={resolvedResource.workspaceLabel}>
|
||
From {resolvedResource.workspaceLabel}
|
||
</span>
|
||
{resolvedResource.previewKind ? (
|
||
<>
|
||
<span aria-hidden="true" className="opacity-50">·</span>
|
||
<span className="capitalize">{resolvedResource.previewKind}</span>
|
||
</>
|
||
) : null}
|
||
{formatBytes(resolvedResource.byteSize) ? (
|
||
<>
|
||
<span aria-hidden="true" className="opacity-50">·</span>
|
||
<span>{formatBytes(resolvedResource.byteSize)}</span>
|
||
</>
|
||
) : null}
|
||
{state?.line ? (
|
||
<>
|
||
<span aria-hidden="true" className="opacity-50">·</span>
|
||
<span>
|
||
Line {state.line}
|
||
{state.column ? `, Col ${state.column}` : ""}
|
||
</span>
|
||
</>
|
||
) : null}
|
||
</div>
|
||
) : null}
|
||
</SheetHeader>
|
||
<div className="relative flex min-h-0 flex-1 flex-col">
|
||
<div aria-live="polite" className="sr-only">
|
||
{announcement}
|
||
</div>
|
||
{state ? (
|
||
<FileViewerBody
|
||
resolveQuery={resolveQuery}
|
||
contentQuery={contentQuery}
|
||
elapsedMs={elapsedMs}
|
||
canPreview={canPreview}
|
||
highlightedLine={state.line ?? null}
|
||
onRetry={handleRetry}
|
||
onSetAnnouncement={setAnnouncement}
|
||
onFallbackToProject={
|
||
state.workspace !== "project"
|
||
? () =>
|
||
viewer.open({
|
||
path: state.path,
|
||
line: state.line,
|
||
column: state.column,
|
||
workspace: "project",
|
||
})
|
||
: null
|
||
}
|
||
/>
|
||
) : showPromptWhenEmpty ? (
|
||
<OpenFilePrompt onSubmit={handlePromptSubmit} />
|
||
) : null}
|
||
</div>
|
||
</SheetContent>
|
||
</Sheet>
|
||
);
|
||
}
|
||
|
||
interface FileViewerBodyProps {
|
||
resolveQuery: UseQueryResult<ResolvedWorkspaceResource, unknown>;
|
||
contentQuery: UseQueryResult<WorkspaceFileContent, unknown>;
|
||
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 <LoadingView elapsedMs={elapsedMs} />;
|
||
}
|
||
|
||
if (resolveQuery.isError) {
|
||
const normalized = normalizeError(resolveQuery.error);
|
||
if (normalized.status === 404) {
|
||
return (
|
||
<FileViewerStateView
|
||
icon={<FileSearch aria-hidden="true" className="h-6 w-6 text-muted-foreground" />}
|
||
title="File not found"
|
||
body="That file was not found in the active workspace."
|
||
actions={
|
||
<>
|
||
{onFallbackToProject ? (
|
||
<Button type="button" variant="secondary" size="sm" onClick={onFallbackToProject}>
|
||
Try project workspace
|
||
</Button>
|
||
) : null}
|
||
<Button type="button" variant="ghost" size="sm" onClick={onRetry}>
|
||
<RefreshCcw aria-hidden="true" className="mr-1 h-3 w-3" /> Retry
|
||
</Button>
|
||
</>
|
||
}
|
||
/>
|
||
);
|
||
}
|
||
if (normalized.status === 422) {
|
||
return (
|
||
<FileViewerStateView
|
||
icon={<FolderOpen aria-hidden="true" className="h-6 w-6 text-muted-foreground" />}
|
||
title="No workspace available"
|
||
body="This issue does not have a workspace that supports preview yet."
|
||
/>
|
||
);
|
||
}
|
||
const denial = describeDenial(normalized.code, normalized.message);
|
||
return (
|
||
<FileViewerStateView
|
||
icon={denial.icon}
|
||
title={denial.title}
|
||
body={denial.body}
|
||
actions={
|
||
<Button type="button" variant="ghost" size="sm" onClick={onRetry}>
|
||
<RefreshCcw aria-hidden="true" className="mr-1 h-3 w-3" /> Retry
|
||
</Button>
|
||
}
|
||
/>
|
||
);
|
||
}
|
||
|
||
const resource = resolveQuery.data;
|
||
if (!resource) return null;
|
||
|
||
if (resource.kind === "remote_resource") {
|
||
return (
|
||
<FileViewerStateView
|
||
icon={<Cloud aria-hidden="true" className="h-6 w-6 text-muted-foreground" />}
|
||
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 <FileViewerStateView icon={denial.icon} title={denial.title} body={denial.body} />;
|
||
}
|
||
|
||
if (contentQuery.isFetching && !contentQuery.data) {
|
||
return <LoadingView elapsedMs={elapsedMs} />;
|
||
}
|
||
|
||
if (contentQuery.isError) {
|
||
const normalized = normalizeError(contentQuery.error);
|
||
const denial = describeDenial(normalized.code, normalized.message);
|
||
return (
|
||
<FileViewerStateView
|
||
icon={denial.icon}
|
||
title={denial.title}
|
||
body={denial.body}
|
||
actions={
|
||
<Button type="button" variant="ghost" size="sm" onClick={onRetry}>
|
||
<RefreshCcw aria-hidden="true" className="mr-1 h-3 w-3" /> Retry
|
||
</Button>
|
||
}
|
||
/>
|
||
);
|
||
}
|
||
|
||
if (!contentQuery.data) return null;
|
||
|
||
return (
|
||
<FileContentViewer
|
||
content={contentQuery.data}
|
||
highlightedLine={highlightedLine}
|
||
onLoaded={onSetAnnouncement}
|
||
/>
|
||
);
|
||
}
|