Repository Public MIT
1"use client";2import { FileCode2, FileText, Lock } from "lucide-react";3import { useState } from "react";4import { bytes } from "@/lib/format";56interface F {7 path: string;8 sizeBytes: number;9 sha256: string;10 executable: boolean;11 previewable: boolean;12}1314/** Read-only browser for release files. Content is fetched as text and rendered as text — never as HTML. */15export function FileExplorer({ namespace, slug, version, files }: { namespace: string; slug: string; version: string; files: F[] }) {16 const [open, setOpen] = useState<string | null>(null);17 const [content, setContent] = useState<string>("");18 const [state, setState] = useState<"idle" | "loading" | "error">("idle");1920 async function show(f: F) {21 setOpen(f.path);22 // Binary or large files are never fetched or rendered: only their size and checksum are shown.23 if (!f.previewable) {24 setContent("");25 setState("idle");26 return;27 }28 setState("loading");29 try {30 const res = await fetch(`/api/v1/artifacts/${namespace}/${slug}/versions/${encodeURIComponent(version)}/files?path=${encodeURIComponent(f.path)}`);31 if (!res.ok) throw new Error(String(res.status));32 setContent(((await res.json()) as { content: string }).content);33 setState("idle");34 } catch {35 setState("error");36 }37 }38 const current = files.find((f) => f.path === open);3940 return (41 <div className="grid gap-4 lg:grid-cols-[minmax(0,18rem)_minmax(0,1fr)]">42 <ul className="card max-h-[28rem] overflow-y-auto p-1.5" aria-label="Files in this release">43 {files.map((f) => (44 <li key={f.path}>45 <button46 type="button"47 onClick={() => show(f)}48 aria-current={open === f.path}49 title={f.previewable ? f.path : `${f.path} — binary or large file, not previewed`}50 className={`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left font-mono text-[12px] ${open === f.path ? "bg-raised text-text" : "text-muted hover:bg-raised hover:text-text"}`}51 >52 {f.previewable ? f.executable ? <FileCode2 size={13} aria-hidden className="shrink-0 text-warn" /> : <FileText size={13} aria-hidden className="shrink-0" /> : <Lock size={13} aria-hidden className="shrink-0" />}53 <span className="min-w-0 flex-1 truncate">{f.path}</span>54 <span className="shrink-0 text-[10px] text-faint">{bytes(f.sizeBytes)}</span>55 </button>56 </li>57 ))}58 </ul>59 <div className="card min-h-40 overflow-hidden">60 {!current ? (61 <p className="p-5 text-sm text-muted">Select a file to preview it. Files are shown as plain text and never executed.</p>62 ) : (63 <>64 <div className="flex flex-wrap items-center gap-x-4 gap-y-1 border-b border-line px-4 py-2 font-mono text-[11px] text-faint">65 <span className="text-text">{current.path}</span>66 <span>{bytes(current.sizeBytes)}</span>67 {current.executable && <span className="text-warn">executable</span>}68 <span className="truncate" title={current.sha256}>sha256 {current.sha256.slice(0, 16)}…</span>69 </div>70 {state === "loading" ? (71 <div className="space-y-2 p-4" aria-busy="true" aria-label="Loading file">72 <div className="skeleton h-3 w-2/3" />73 <div className="skeleton h-3 w-1/2" />74 <div className="skeleton h-3 w-3/5" />75 </div>76 ) : state === "error" ? (77 <p className="p-5 text-sm text-danger" role="alert">This file could not be loaded.</p>78 ) : !current.previewable ? (79 <p className="p-5 text-sm text-muted" data-testid="binary-file">Binary file, {bytes(current.sizeBytes)}. It is not previewed here; download the archive and check its SHA-256 to inspect it.</p>80 ) : (81 <pre className="max-h-[32rem] overflow-auto bg-code p-4 font-mono text-[12.5px] leading-relaxed text-text" tabIndex={0}>82 <code>{content}</code>83 </pre>84 )}85 </>86 )}87 </div>88 </div>89 );90}91