Files
PaperClipAI/ui/src/components/FileViewerSheet.tsx
T
DottaandPaperclip 9b58daba6e Polish file viewer UX review punchlist (PAP-1962)
Addresses UX review feedback on PAP-1963:

- Fix denial-body leak for too_large/size codes (describeDenial no longer
  echoes fallback for known codes; the call site passes empty fallback so
  unknown codes show a generic message).
- Sticky line-number gutter with solid code-bg fill; highlight border
  moves from absolute-positioned sibling onto the sticky gutter so the
  amber marker stays pinned when users scroll long lines horizontally.
- Swap FileSearch copy-link icon for Link2.
- WorkspaceFileLink now sets a real deep-link href so modifier-click
  opens the file viewer in a new tab instead of scrolling to the top.
- Collapse duplicate title/description in the sheet header when the
  basename equals the full path (e.g. .env.production).
- Drop the duplicate title/helper block from OpenFilePrompt (sheet
  header already carries the title + description).
- Replace ArrowLeft with Cloud for the remote-workspace state.
- Retire arbitrary text-[12.5px]/[11px]/[0.78em] sizes in favor of text-xs.
- Unify 404 copy on "active workspace" everywhere.
- Raise chip-row contrast by using bg-muted instead of bg-background.

Also commits previously untracked visual-regression infra:
- Storybook stories for 12 file viewer states
- screenshot-file-viewer.mjs / screenshot-one.mjs
- describeDenial unit tests

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-06-09 21:35:48 +00:00

776 lines
26 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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: <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) => 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;
onSubmit(trimmed, workspace);
};
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 px-3 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 [loadStart, setLoadStart] = useState<number>(() => Date.now());
const [elapsedMs, setElapsedMs] = useState(0);
const [copiedField, setCopiedField] = useState<"path" | "link" | null>(null);
const [announcement, setAnnouncement] = useState<string>("");
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 (
<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-1 text-xs text-muted-foreground">
<span
className="inline-flex items-center rounded-full border border-border bg-muted px-2 py-0.5"
title={resolvedResource.workspaceLabel}
>
From {resolvedResource.workspaceLabel}
</span>
{resolvedResource.previewKind ? (
<span className="inline-flex items-center rounded-full border border-border bg-muted px-2 py-0.5 capitalize">
{resolvedResource.previewKind}
</span>
) : null}
{formatBytes(resolvedResource.byteSize) ? (
<span className="inline-flex items-center rounded-full border border-border bg-muted px-2 py-0.5">
{formatBytes(resolvedResource.byteSize)}
</span>
) : null}
{state?.line ? (
<span className="inline-flex items-center rounded-full border border-border bg-muted px-2 py-0.5">
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}
/>
);
}