AUDIT / src /components /FileExplorer.tsx
Arypulka98's picture
feat(audit): deploy full backend cluster node
aea470f verified
Raw
History Blame Contribute Delete
49.8 kB
import { useState, useEffect, useRef, useCallback, memo, type ReactNode } from "react";
import { vfsAsync, onVfsChanged } from "@/lib/vfsDb";
import type { VFSFile } from "@/lib/vfsDb";
import { Z_INDEX } from "@/lib/zindex";
import { parseFile } from "@/lib/fileReader";
import {
analyzeSync, getImageDimensions, analyzeImageWithAI,
FileAnalysis, FileCategory,
} from "@/lib/fileAnalyzer";
import { convertFile, downloadBlob, ConvertOptions } from "@/lib/fileConverter";
import { listSnapshots, restoreSnapshot, deleteSnapshot } from "@/lib/workspaceSnapshot"; // S194
import type { StoredSnapshot } from "@/lib/vfsDb"; // S194 — StoredSnapshot is exported from vfsDb
// ─── Helpers ──────────────────────────────────────────────────────────────────
const fmt = {
size: (b: number) =>
b < 1024 ? `${b} B` : b < 1_048_576 ? `${(b / 1024).toFixed(1)} KB` : `${(b / 1_048_576).toFixed(2)} MB`,
date: (ts: number) =>
new Date(ts).toLocaleString("it-IT", { day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit" }),
num: (n: number) => n.toLocaleString("it-IT"),
};
const CAT_COLOR: Record<FileCategory, string> = {
image: "#a78bfa", video: "#f59e0b", audio: "#60a5fa", pdf: "#ef4444",
json: "#f97316", csv: "#3b82f6", markdown: "#60a5fa", html: "#facc15",
css: "#38bdf8", code: "#4f8ef7", text: "#9ca3af", archive: "#8b5cf6",
binary: "#6b7280",
};
const TYPE_EMOJI: Record<FileCategory, string> = {
image: "IMG", video: "VID", audio: "AUD", pdf: "PDF",
json: "JSON", csv: "CSV", markdown:"MD", html: "HTML",
css: "CSS", code: "CODE", text: "TXT", archive:"ZIP",
binary: "BIN",
};
function getCatFromFile(f: VFSFile): FileCategory {
const ext = f.name.split(".").pop()?.toLowerCase() ?? "";
const mime = f.type ?? "";
if (mime.startsWith("image/") || /^(png|jpe?g|gif|webp|svg|bmp|ico)$/.test(ext)) return "image";
if (mime.startsWith("video/") || /^(mp4|webm|mov)$/.test(ext)) return "video";
if (mime.startsWith("audio/") || /^(mp3|wav|ogg|m4a|aac)$/.test(ext)) return "audio";
if (ext === "pdf") return "pdf";
if (ext === "json") return "json";
if (ext === "csv") return "csv";
if (ext === "md") return "markdown";
if (/^(html?)$/.test(ext)) return "html";
if (/^(css|scss)$/.test(ext)) return "css";
if (/^(js|jsx|ts|tsx|py|java|c|cpp|go|rs|rb|php|swift|sh|sql)$/.test(ext)) return "code";
if (mime.startsWith("text/") || /^(txt|log|yaml|yml|toml|xml|ini|env)$/.test(ext)) return "text";
if (/^(zip|tar|gz|rar)$/.test(ext)) return "archive";
return "binary";
}
// ─── Stat pill ────────────────────────────────────────────────────────────────
function Pill({ label, value }: { label: string; value: string }) {
return (
<div style={{ display: "flex", flexDirection: "column", gap: 1, background: "rgba(255,255,255,0.04)", border: "1px solid rgba(255,255,255,0.07)", borderRadius: 8, padding: "5px 10px", minWidth: 70 }}>
<span style={{ fontSize: "0.58rem", color: "#6b6b8a", textTransform: "uppercase", letterSpacing: "0.06em", fontWeight: 700 }}>{label}</span>
<span style={{ fontSize: "0.78rem", color: "#d0d0f0", fontWeight: 600, fontFamily: "ui-monospace, monospace" }}>{value}</span>
</div>
);
}
// ─── Action card ──────────────────────────────────────────────────────────────
function ActionCard({
icon, label, desc, color, active, done, loading,
onClick,
}: {
icon: string; label: string; desc: string; color: string;
active?: boolean; done?: boolean; loading?: boolean;
onClick: () => void;
}) {
return (
<button
onClick={onClick}
disabled={loading}
style={{
all: "unset", cursor: loading ? "default" : "pointer",
display: "flex", alignItems: "center", gap: 10,
padding: "0.65rem 0.9rem", borderRadius: 10,
background: active || done
? `${color}18`
: "rgba(255,255,255,0.025)",
border: `1px solid ${active || done ? color + "44" : "rgba(255,255,255,0.07)"}`,
transition: "all 0.15s",
opacity: loading ? 0.6 : 1,
}}
onMouseEnter={e => { if (!loading) { e.currentTarget.style.background = `${color}12`; e.currentTarget.style.borderColor = `${color}33`; } }}
onMouseLeave={e => { if (!loading && !active && !done) { e.currentTarget.style.background = "rgba(255,255,255,0.025)"; e.currentTarget.style.borderColor = "rgba(255,255,255,0.07)"; } }}
>
<span style={{ width: 8, height: 8, borderRadius: "50%", background: done ? "#60a5fa" : loading ? "#fbbf24" : color, display: "inline-block", flexShrink: 0 }} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: "0.78rem", fontWeight: 600, color: done ? "#60a5fa" : color }}>{done ? "Completato!" : label}</div>
{desc && <div style={{ fontSize: "0.66rem", color: "#5a5a78", marginTop: 1 }}>{desc}</div>}
</div>
</button>
);
}
// ─── Main component ───────────────────────────────────────────────────────────
interface FileExplorerProps {
onClose: () => void;
}
const FileExplorer = memo(function FileExplorer({ onClose }: FileExplorerProps) {
const [files, setFiles] = useState<VFSFile[]>([]);
const [selected, setSelected] = useState<VFSFile | null>(null);
const [content, setContent] = useState<string>("");
const [search, setSearch] = useState("");
const [contentSearch, setContentSearch] = useState("");
const [exporting, setExporting] = useState(false);
const [uploading, setUploading] = useState(false);
const [dragOver, setDragOver] = useState(false);
const [analysis, setAnalysis] = useState<FileAnalysis | null>(null);
const [activeAction, setActiveAction] = useState<string | null>(null);
const [actionDone, setActionDone] = useState<string | null>(null);
const [actionError, setActionError] = useState<string | null>(null);
const [opfsLoading, setOpfsLoading] = useState(false);
const [opfsError, setOpfsError] = useState<string | null>(null);
const [opfsRetry, setOpfsRetry] = useState(0);
const [opfsRetryTrig,setOpfsRetryTrig]= useState(0);
const [converting, setConverting] = useState(false);
const [convOpts, setConvOpts] = useState<ConvertOptions>({ quality: 85, maxW: 1280, maxH: 1280 });
const [tab, setTab] = useState<"files" | "snapshots">("files"); // S194
const [snapshots, setSnapshots] = useState<StoredSnapshot[]>([]); // S194
const [snapLoading, setSnapLoading] = useState(false); // S194
const [snapMsg, setSnapMsg] = useState<string | null>(null); // S194
const uploadRef = useRef<HTMLInputElement>(null);
const opfsAbortRef = useRef<{ cancelled: boolean }>({ cancelled: false });
// ── File list ──────────────────────────────────────────────────────────────
const reload = useCallback(() => {
vfsAsync.list().then(setFiles).catch(() => setFiles([]));
}, []);
useEffect(() => { reload(); return onVfsChanged(reload); }, [reload]);
// S194: load snapshots when switching to snapshots tab
useEffect(() => {
if (tab !== "snapshots") return;
setSnapLoading(true);
listSnapshots().then(s => { setSnapshots(s); setSnapLoading(false); }).catch(() => setSnapLoading(false));
}, [tab]);
// ── Load content when file selected ───────────────────────────────────────
useEffect(() => {
if (!selected) { opfsAbortRef.current.cancelled = true; setContent(""); setAnalysis(null); setActiveAction(null); setActionDone(null); setOpfsLoading(false); setOpfsError(null); setOpfsRetry(0); return; }
setActiveAction(null); setActionDone(null); setActionError(null); setOpfsLoading(false); setOpfsError(null); setOpfsRetry(0);
if (selected.opfs) {
opfsAbortRef.current.cancelled = true; // cancel any in-flight read
const _opfsToken = { cancelled: false };
opfsAbortRef.current = _opfsToken;
setContent("");
setOpfsLoading(true);
setOpfsError(null);
setOpfsRetry(0);
const MAX_OPFS_RETRIES = 3;
const OPFS_TIMEOUT_MS = 8_000;
const opfsDelay = (ms: number) => new Promise<void>(r => setTimeout(r, ms));
const withTimeout = <T,>(p: Promise<T>, ms: number): Promise<T> =>
Promise.race([
p,
new Promise<T>((_, reject) =>
setTimeout(() => reject(new Error('opfs-timeout')), ms),
),
]);
(async () => {
for (let attempt = 0; attempt < MAX_OPFS_RETRIES; attempt++) {
if (_opfsToken.cancelled) return; // file changed — abort
setOpfsRetry(attempt + 1);
try {
const c = await withTimeout(vfsAsync.read(selected.path), OPFS_TIMEOUT_MS);
if (_opfsToken.cancelled) return; // file changed mid-read
const c2 = c ?? "";
setContent(c2);
setAnalysis(analyzeSync(selected, c2));
setOpfsLoading(false);
return;
} catch (err: unknown) {
const isTimeout = err instanceof Error && err.message === 'opfs-timeout';
if (attempt < MAX_OPFS_RETRIES - 1) {
await opfsDelay(isTimeout ? 200 : 400 * Math.pow(2, attempt));
}
}
}
if (!_opfsToken.cancelled) {
setOpfsError(`Impossibile leggere il file dopo ${MAX_OPFS_RETRIES} tentativi (timeout ${OPFS_TIMEOUT_MS / 1000}s/tentativo). Verifica l'archiviazione o ricarica.`);
setOpfsLoading(false);
}
})();
} else {
setContent(selected.content);
setAnalysis(analyzeSync(selected, selected.content));
}
}, [selected, opfsRetryTrig]);
// ── Image dimensions (async after analysis) ────────────────────────────────
useEffect(() => {
if (!selected || !analysis) return;
if (analysis.category !== "image") return;
const src = content || selected.content;
if (!src) return;
getImageDimensions(src).then(({ w, h }) => {
if (w > 0) setAnalysis(prev => prev ? { ...prev, imgWidth: w, imgHeight: h } : prev);
});
}, [selected, analysis?.category, content]); // eslint-disable-line react-hooks/exhaustive-deps
// ── AI analysis ────────────────────────────────────────────────────────────
const runAIAnalysis = useCallback(async () => {
if (!selected || !analysis) return;
setAnalysis(prev => prev ? { ...prev, aiAnalyzing: true, aiError: undefined } : prev);
const src = content || selected.content;
const result = await analyzeImageWithAI(src, selected.name);
setAnalysis(prev => prev ? { ...prev, aiAnalyzing: false, ...result } : prev);
}, [selected, analysis, content]);
// ── Manual OPFS retry ─────────────────────────────────────────────────────
const retryOpfsRead = useCallback(() => {
setOpfsRetryTrig(t => t + 1);
}, []);
// ── Conversion ─────────────────────────────────────────────────────────────
const runConvert = useCallback(async (action: string) => {
if (!selected) return;
if (opfsLoading) return; // guard: OPFS still loading
if (action === "ai-analyze") { void runAIAnalysis(); return; }
if (action === "copy") {
await navigator.clipboard.writeText(content || selected.content).catch(() => {});
setActionDone(action);
setTimeout(() => setActionDone(null), 2000);
return;
}
if (action === "download") {
const c2 = content || selected.content;
const blob = new Blob([c2], { type: selected.type || "text/plain" });
downloadBlob(blob, selected.name);
return;
}
if (action === "delete") {
await vfsAsync.delete(selected.path);
setSelected(null);
reload();
return;
}
setConverting(true);
setActionError(null);
try {
const c2 = content || selected.content;
const res = await convertFile(selected, c2, action, convOpts);
downloadBlob(res.blob, res.filename);
setActionDone(action);
setTimeout(() => setActionDone(null), 3000);
} catch (e: unknown) {
setActionError(e instanceof Error ? e.message : "Errore conversione");
} finally {
setConverting(false);
}
}, [selected, content, convOpts, reload, runAIAnalysis]);
// ── Upload ─────────────────────────────────────────────────────────────────
const handleUpload = useCallback(async (fileList: FileList | null) => {
if (!fileList || fileList.length === 0) return;
setUploading(true);
try {
for (const file of Array.from(fileList)) {
const parsed = await parseFile(file);
if (parsed.error) continue;
if (parsed.category === "image" && parsed.dataUrl) {
await vfsAsync.write("/uploads/" + file.name, parsed.dataUrl, file.type);
} else if (parsed.content) {
await vfsAsync.write("/uploads/" + file.name, parsed.content, file.type || "text/plain");
// S459: RAG indexing — popola ragPipeline sui file caricati nel VFS (fire-and-forget)
import("@/lib/agent/ragPipeline").then(m => m.indexParsedFile(parsed)).catch(() => {});
}
}
reload();
} finally {
setUploading(false);
if (uploadRef.current) uploadRef.current.value = "";
}
}, [reload]);
// ── Export ZIP ────────────────────────────────────────────────────────────
const exportAllZip = useCallback(async () => {
if (files.length === 0) return;
setExporting(true);
try {
const JSZip = (await import("jszip")).default;
const zip = new JSZip();
for (const f of files) {
const c = f.opfs ? (await vfsAsync.read(f.path) ?? "") : f.content;
zip.file(f.path.startsWith("/") ? f.path.slice(1) : f.path, c);
}
const blob = await zip.generateAsync({ type: "blob", compression: "DEFLATE", compressionOptions: { level: 6 } });
downloadBlob(blob, "vfs-export.zip");
} finally {
setExporting(false);
}
}, [files]);
// ── Download single file ──────────────────────────────────────────────────
const downloadFile = useCallback(async (f: VFSFile) => {
const c = f.opfs ? (await vfsAsync.read(f.path) ?? "") : f.content;
const blob = new Blob([c], { type: f.type || "text/plain" });
downloadBlob(blob, f.name);
}, []);
// ── Filtered file list ────────────────────────────────────────────────────
const filtered = search.trim()
? files.filter(f =>
f.path.toLowerCase().includes(search.toLowerCase()) ||
(!f.opfs && f.content.toLowerCase().includes(search.toLowerCase()))
)
: files;
const totalBytes = files.reduce((s, f) => s + f.size, 0);
// ── Select file ───────────────────────────────────────────────────────────
const selectFile = useCallback((f: VFSFile) => {
setSelected(prev => prev?.path === f.path ? null : f);
setContentSearch("");
}, []);
// ─────────────────────────────────────────────────────────────────────────
// Render
// ─────────────────────────────────────────────────────────────────────────
const showPanel = !!selected;
const listWidth = showPanel ? "min(300px, 42%)" : "100%";
return (
<div
style={{ position: "fixed", inset: 0, zIndex: Z_INDEX.MODAL_OVERLAY, display: "flex", background: "rgba(0,0,0,0.65)", alignItems: "center", justifyContent: "center" }}
onClick={onClose}
>
<div
style={{ background: "rgba(7,7,18,0.94)", border: "1px solid rgba(255,255,255,0.08)", borderRadius: 18, width: "min(920px, 97vw)", maxHeight: "88vh", display: "flex", flexDirection: "column", overflow: "hidden", boxShadow: "0 24px 80px rgba(0,0,0,0.75)" }}
onClick={e => e.stopPropagation()}
>
{/* ── Header ── */}
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", padding: "0.85rem 1.2rem", borderBottom: "1px solid rgba(255,255,255,0.07)", gap: 8, flexWrap: "wrap" }}>
<div>
<div style={{ fontWeight: 700, fontSize: "0.97rem", color: "#e2e2f0" }}>File Manager VFS</div>
<div style={{ fontSize: "0.68rem", color: "#6b6b7b", marginTop: 1 }}>{files.length} file · {fmt.size(totalBytes)}</div>
</div>
<div style={{ display: "flex", gap: 6, alignItems: "center", flexWrap: "wrap" }}>
<input ref={uploadRef} type="file" multiple style={{ display: "none" }} onChange={e => handleUpload(e.target.files)} />
<Btn color="#4f8ef7" onClick={() => uploadRef.current?.click()} disabled={uploading}>{uploading ? "Carico…" : "Carica"}</Btn>
{files.length > 0 && <Btn color="#a78bfa" onClick={exportAllZip} disabled={exporting}>{exporting ? "ZIP…" : "Esporta ZIP"}</Btn>}
{files.length > 0 && <Btn color="#ef4444" onClick={() => { vfsAsync.clear().catch(() => {}); setSelected(null); reload(); }}>Svuota</Btn>}
<button onClick={onClose} style={{ all: "unset", cursor: "pointer", color: "#6b6b7b", fontSize: "1.15rem", padding: "0 4px" }}></button>
</div>
</div>
{/* ── Tab bar ── */}
<div style={{ display: "flex", gap: 2, padding: "0.4rem 1.2rem 0", borderBottom: "1px solid rgba(255,255,255,0.07)" }}>
{(["files", "snapshots"] as const).map(t => (
<button key={t} onClick={() => setTab(t)} style={{
all: "unset", cursor: "pointer", padding: "0.3rem 0.8rem", borderRadius: "7px 7px 0 0",
fontSize: "0.72rem", fontWeight: tab === t ? 700 : 400,
color: tab === t ? "#3b82f6" : "#6b6b8a",
background: tab === t ? "rgba(59,130,246,0.1)" : "transparent",
transition: "all 0.15s",
}}>
{t === "files" ? "Files" : "Snapshots"}
</button>
))}
</div>
{/* ── Body ── */}
<div style={{ display: tab === "files" ? "flex" : "none", flex: 1, overflow: "hidden", minHeight: 0 }}>
{/* ── File list ── */}
<div
style={{ width: listWidth, flexShrink: 0, borderRight: showPanel ? "1px solid rgba(255,255,255,0.07)" : "none", display: "flex", flexDirection: "column", overflow: "hidden", transition: "width 0.2s", background: dragOver ? "rgba(79,142,247,0.04)" : "transparent" }}
onDragOver={e => { e.preventDefault(); setDragOver(true); }}
onDragLeave={() => setDragOver(false)}
onDrop={e => { e.preventDefault(); setDragOver(false); handleUpload(e.dataTransfer.files); }}
>
<div style={{ padding: "0.5rem 0.8rem", borderBottom: "1px solid rgba(255,255,255,0.05)" }}>
<input
value={search}
onChange={e => setSearch(e.target.value)}
placeholder="Cerca nome o contenuto…"
style={{ width: "100%", boxSizing: "border-box", background: "rgba(255,255,255,0.05)", border: "1px solid rgba(255,255,255,0.1)", borderRadius: 7, padding: "0.3rem 0.6rem", color: "#e2e2f0", fontSize: "0.76rem", outline: "none" }}
/>
</div>
<div style={{ flex: 1, overflowY: "auto" }}>
{filtered.length === 0 ? (
<div style={{ textAlign: "center", color: "#4a4a62", padding: "2.5rem 1rem" }}>
<div style={{ width: 40, height: 40, borderRadius: 8, background: "rgba(255,255,255,0.04)", border: "1px solid rgba(255,255,255,0.08)", marginBottom: 8, display: "flex", alignItems: "center", justifyContent: "center" }}>
<svg width={18} height={18} viewBox="0 0 24 24" fill="none" stroke="#4a4a72" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
</div>
<div style={{ fontSize: "0.82rem" }}>{search ? "Nessun risultato" : "VFS vuoto"}</div>
{!search && <div style={{ fontSize: "0.7rem", marginTop: 5, color: "#3a3a52" }}>Trascina file qui o usa Carica</div>}
</div>
) : filtered.map(f => {
const cat = getCatFromFile(f);
const isSel = selected?.path === f.path;
const matchesContent = search.trim() && !f.opfs && f.content.toLowerCase().includes(search.toLowerCase()) && !f.path.toLowerCase().includes(search.toLowerCase());
return (
<div
key={f.path}
onClick={() => selectFile(f)}
style={{ display: "flex", alignItems: "center", gap: 8, padding: "0.42rem 0.8rem", cursor: "pointer", background: isSel ? "rgba(79,142,247,0.09)" : "transparent", borderBottom: "1px solid rgba(255,255,255,0.025)", borderLeft: isSel ? "2px solid #4f8ef7" : "2px solid transparent", transition: "all 0.1s" }}
>
<span style={{ fontSize: "0.58rem", fontWeight: 700, color: CAT_COLOR[cat], background: `${CAT_COLOR[cat]}18`, border: `1px solid ${CAT_COLOR[cat]}33`, borderRadius: 3, padding: "0 3px", letterSpacing: "0.03em", flexShrink: 0 }}>{TYPE_EMOJI[cat]}</span>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: "0.78rem", color: "#c4c4d8", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{f.name}</div>
<div style={{ fontSize: "0.63rem", color: "#5a5a72", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{matchesContent ? <span style={{ color: "#f59e0b" }}>Match nel contenuto</span> : f.path}
</div>
</div>
<div style={{ display: "flex", flexDirection: "column", alignItems: "flex-end", gap: 1, flexShrink: 0 }}>
<span style={{ fontSize: "0.63rem", color: "#6b6b8a", fontFamily: "ui-monospace, monospace" }}>{fmt.size(f.size)}</span>
<span style={{ fontSize: "0.58rem", color: CAT_COLOR[cat], background: `${CAT_COLOR[cat]}18`, border: `1px solid ${CAT_COLOR[cat]}33`, borderRadius: 4, padding: "0 4px", lineHeight: 1.5 }}>{cat}</span>
</div>
<button onClick={e => { e.stopPropagation(); void downloadFile(f); }} style={{ all: "unset", cursor: "pointer", color: "#4a4a72", fontSize: "0.8rem", padding: "2px 3px" }}>⬇</button>
<button onClick={e => { e.stopPropagation(); void vfsAsync.delete(f.path).then(reload); if (selected?.path === f.path) setSelected(null); }} style={{ all: "unset", cursor: "pointer", color: "#4a4a72", fontSize: "0.72rem", padding: "2px 3px" }}>X</button>
</div>
);
})}
</div>
{dragOver && (
<div style={{ padding: "0.5rem", borderTop: "1px solid rgba(79,142,247,0.3)", background: "rgba(79,142,247,0.07)", textAlign: "center", fontSize: "0.73rem", color: "#4f8ef7" }}>
Rilascia per caricare
</div>
)}
</div>
{/* ── Detail panel ── */}
{showPanel && selected && (
<div style={{ flex: 1, display: "flex", flexDirection: "column", overflow: "hidden", minWidth: 0 }}>
{/* Detail header */}
<div style={{ display: "flex", alignItems: "center", gap: 8, padding: "0.6rem 1rem", borderBottom: "1px solid rgba(255,255,255,0.07)", flexShrink: 0 }}>
<span style={{ fontSize: "0.58rem", fontWeight: 700, color: analysis ? CAT_COLOR[analysis.category] : "#9090c0", background: analysis ? `${CAT_COLOR[analysis.category]}18` : "rgba(144,144,192,0.12)", border: `1px solid ${analysis ? CAT_COLOR[analysis.category] + "33" : "rgba(144,144,192,0.2)"}`, borderRadius: 3, padding: "0 3px", letterSpacing: "0.03em", flexShrink: 0 }}>{analysis ? TYPE_EMOJI[analysis.category] : "FILE"}</span>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: "0.84rem", fontWeight: 600, color: "#e0e0f0", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{selected.name}</div>
<div style={{ fontSize: "0.64rem", color: "#6b6b8a", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{selected.path} · {fmt.date(selected.updatedAt)}</div>
</div>
{analysis && (
<span style={{ fontSize: "0.65rem", color: CAT_COLOR[analysis.category], background: `${CAT_COLOR[analysis.category]}18`, border: `1px solid ${CAT_COLOR[analysis.category]}33`, borderRadius: 5, padding: "2px 7px", flexShrink: 0, fontWeight: 700 }}>
{analysis.language ?? analysis.category.toUpperCase()}
</span>
)}
<button onClick={() => setSelected(null)} style={{ all: "unset", cursor: "pointer", color: "#6b6b7b", fontSize: "0.95rem", padding: "2px 4px" }}>✕</button>
</div>
{/* Scrollable detail body */}
<div style={{ flex: 1, overflowY: "auto", display: "flex", flexDirection: "column", gap: 0 }}>
{/* ── Preview ── */}
<div style={{ flexShrink: 0 }}>
{analysis?.category === "image" && (
<div style={{ background: "rgba(255,255,255,0.02)", borderBottom: "1px solid rgba(255,255,255,0.05)", display: "flex", alignItems: "center", justifyContent: "center", padding: "0.8rem", minHeight: 140, maxHeight: 260 }}>
<img
src={content || selected.content}
alt={selected.name}
style={{ maxWidth: "100%", maxHeight: 220, objectFit: "contain", borderRadius: 8, boxShadow: "0 4px 20px rgba(0,0,0,0.4)" }}
/>
</div>
)}
{analysis?.category === "audio" && (
<div style={{ padding: "0.8rem 1rem", background: "rgba(96,165,250,0.04)", borderBottom: "1px solid rgba(255,255,255,0.05)" }}>
<audio controls style={{ width: "100%", height: 40 }} src={content || selected.content} />
</div>
)}
{analysis?.category === "video" && (
<div style={{ padding: "0.8rem 1rem", background: "rgba(245,158,11,0.04)", borderBottom: "1px solid rgba(255,255,255,0.05)" }}>
<video controls style={{ width: "100%", maxHeight: 200, borderRadius: 8 }} src={content || selected.content} />
</div>
)}
{analysis?.category === "csv" && analysis.csvHeaders && (
<div style={{ padding: "0.7rem 1rem", borderBottom: "1px solid rgba(255,255,255,0.05)", overflowX: "auto" }}>
<div style={{ fontSize: "0.62rem", color: "#6b6b8a", marginBottom: 4 }}>Anteprima tabella (prime 5 righe)</div>
<table style={{ borderCollapse: "collapse", fontSize: "0.71rem", color: "#c0c0d0", minWidth: "100%" }}>
<thead>
<tr>{analysis.csvHeaders.map(h => (
<th key={h} style={{ borderBottom: "1px solid rgba(255,255,255,0.12)", padding: "4px 10px", textAlign: "left", color: "#3b82f6", fontWeight: 700, whiteSpace: "nowrap" }}>{h}</th>
))}</tr>
</thead>
<tbody>
{(analysis.csvPreview ?? []).map((row, i) => (
<tr key={i} style={{ background: i % 2 === 0 ? "transparent" : "rgba(255,255,255,0.015)" }}>
{row.map((cell, j) => (
<td key={j} style={{ padding: "3px 10px", borderBottom: "1px solid rgba(255,255,255,0.04)", whiteSpace: "nowrap", maxWidth: 160, overflow: "hidden", textOverflow: "ellipsis" }}>{cell}</td>
))}
</tr>
))}
</tbody>
</table>
</div>
)}
{analysis?.category === "html" && content && (
<div style={{ padding: "0.7rem 1rem", borderBottom: "1px solid rgba(255,255,255,0.05)" }}>
<div style={{ fontSize: "0.62rem", color: "#6b6b8a", marginBottom: 4 }}>Anteprima HTML</div>
<iframe
srcDoc={content}
sandbox="allow-same-origin"
style={{ width: "100%", height: 180, border: "1px solid rgba(255,255,255,0.1)", borderRadius: 8, background: "#fff" }}
title={selected.name}
/>
</div>
)}
{(analysis?.category === "text" || analysis?.category === "code" || analysis?.category === "markdown" || analysis?.category === "css" || analysis?.category === "json") && content && (
<div style={{ padding: "0.7rem 1rem", borderBottom: "1px solid rgba(255,255,255,0.05)" }}>
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 4 }}>
<div style={{ fontSize: "0.62rem", color: "#6b6b8a" }}>Anteprima</div>
{analysis.category === "json" && (
<span style={{ fontSize: "0.6rem", color: analysis.jsonValid ? "#60a5fa" : "#ef4444", background: analysis.jsonValid ? "rgba(96,165,250,0.1)" : "rgba(239,68,68,0.1)", border: `1px solid ${analysis.jsonValid ? "rgba(96,165,250,0.3)" : "rgba(239,68,68,0.3)"}`, borderRadius: 4, padding: "1px 6px" }}>
{analysis.jsonValid ? "ok JSON valido" : "! JSON non valido"}
</span>
)}
</div>
<div style={{ position: "relative" }}>
{contentSearch && (
<div style={{ marginBottom: 4 }}>
<input
value={contentSearch}
onChange={e => setContentSearch(e.target.value)}
placeholder="Cerca nel contenuto…"
style={{ width: "100%", boxSizing: "border-box", background: "rgba(255,255,255,0.05)", border: "1px solid rgba(255,255,255,0.1)", borderRadius: 5, padding: "0.2rem 0.5rem", color: "#e2e2f0", fontSize: "0.72rem", outline: "none" }}
/>
</div>
)}
{opfsLoading ? (
<div style={{ padding: "1.2rem 0.7rem", background: "rgba(0,0,0,0.25)", borderRadius: 8, border: "1px solid rgba(255,255,255,0.06)", display: "flex", alignItems: "center", gap: 8, color: "#6b7280", fontSize: "0.74rem" }}>
<span style={{ width: 8, height: 8, borderRadius: "50%", background: "#4f8ef7", display: "inline-block", animation: "visionPulse 1.4s ease-in-out infinite" }} />
Lettura in corso…
</div>
) : opfsError ? (
<div style={{ padding: "1rem 0.7rem", background: "rgba(239,68,68,0.05)", borderRadius: 8, border: "1px solid rgba(239,68,68,0.12)", color: "#fca5a5", fontSize: "0.73rem" }}>
{opfsError}
</div>
) : (
<pre style={{ margin: 0, padding: "0.7rem", background: "rgba(0,0,0,0.25)", borderRadius: 8, fontSize: "0.71rem", lineHeight: 1.55, color: "#c8c8de", fontFamily: "ui-monospace, monospace", whiteSpace: "pre-wrap", wordBreak: "break-word", maxHeight: 200, overflow: "auto", border: "1px solid rgba(255,255,255,0.06)" }}>
{contentSearch.trim()
? content.split("\n").filter(l => l.toLowerCase().includes(contentSearch.toLowerCase())).slice(0, 30).join("\n") || "(Nessun match)"
: content.slice(0, 2000) + (content.length > 2000 ? `\n… (${fmt.num(content.length - 2000)} caratteri in più)` : "")
}
</pre>
)}
{!opfsLoading && !opfsError && !contentSearch && analysis.category !== "json" && (
<button onClick={() => setContentSearch(" ")} style={{ all: "unset", cursor: "pointer", position: "absolute", top: 6, right: 6, fontSize: "0.62rem", color: "#6b6b8a", background: "rgba(0,0,0,0.4)", border: "1px solid rgba(255,255,255,0.08)", borderRadius: 4, padding: "2px 6px" }}>Cerca</button>
)}
</div>
</div>
)}
</div>
{/* ── OPFS loading banner ── */}
{opfsLoading && (
<div style={{ padding: "0.75rem 1rem", borderBottom: "1px solid rgba(255,255,255,0.05)", display: "flex", alignItems: "center", gap: 9, background: "rgba(79,142,247,0.06)" }}>
<span style={{ width: 10, height: 10, borderRadius: "50%", background: "#4f8ef7", display: "inline-block", animation: "visionPulse 1.4s ease-in-out infinite", flexShrink: 0 }} />
<div>
<div style={{ fontSize: "0.75rem", color: "#93c5fd", fontWeight: 600 }}>Caricamento in corso… {opfsRetry > 1 ? `(tentativo ${opfsRetry}/3)` : ""}</div>
<div style={{ fontSize: "0.66rem", color: "#6b7280", marginTop: 1 }}>File OPFS in lettura — timeout 5s per tentativo. Attendi prima di operare sul file.</div>
</div>
</div>
)}
{/* ── OPFS error banner ── */}
{opfsError && !opfsLoading && (
<div style={{ padding: "0.75rem 1rem", borderBottom: "1px solid rgba(239,68,68,0.15)", display: "flex", alignItems: "flex-start", gap: 9, background: "rgba(239,68,68,0.06)" }}>
<span style={{ fontSize: "0.85rem", color: "#fbbf24", fontWeight: 700, flexShrink: 0 }}>!</span>
<div style={{ flex: 1 }}>
<div style={{ fontSize: "0.75rem", color: "#ef4444", fontWeight: 600 }}>Errore lettura file</div>
<div style={{ fontSize: "0.68rem", color: "#fca5a5", marginTop: 1 }}>{opfsError}</div>
<button
onClick={retryOpfsRead}
style={{ all: "unset", cursor: "pointer", marginTop: 6, display: "inline-block", fontSize: "0.7rem", color: "#93c5fd", background: "rgba(79,142,247,0.12)", border: "1px solid rgba(79,142,247,0.25)", borderRadius: 6, padding: "3px 10px", fontWeight: 600 }}
>
Riprova
</button>
</div>
</div>
)}
{/* ── Stats ── */}
{analysis && !opfsLoading && (
<div style={{ padding: "0.7rem 1rem", borderBottom: "1px solid rgba(255,255,255,0.05)", flexShrink: 0 }}>
<div style={{ display: "flex", flexWrap: "wrap", gap: 6 }}>
<Pill label="Formato" value={analysis.category.toUpperCase()} />
<Pill label="Dimensione" value={fmt.size(selected.size)} />
{analysis.imgWidth != null && analysis.imgWidth > 0 && <Pill label="Larghezza" value={`${analysis.imgWidth}px`} />}
{analysis.imgHeight != null && analysis.imgHeight > 0 && <Pill label="Altezza" value={`${analysis.imgHeight}px`} />}
{analysis.lineCount != null && <Pill label="Righe" value={fmt.num(analysis.lineCount)} />}
{analysis.wordCount != null && <Pill label="Parole" value={fmt.num(analysis.wordCount)} />}
{analysis.csvRows != null && <Pill label="Righe" value={fmt.num(analysis.csvRows)} />}
{analysis.csvCols != null && <Pill label="Colonne" value={fmt.num(analysis.csvCols)} />}
{analysis.jsonKeyCount != null && <Pill label={analysis.jsonIsArray ? "Elementi" : "Chiavi"} value={fmt.num(analysis.jsonKeyCount)} />}
{analysis.jsonDepth != null && <Pill label="Profondità" value={String(analysis.jsonDepth)} />}
</div>
</div>
)}
{/* ── AI analysis result ── */}
{(analysis?.aiAnalyzing || analysis?.aiDescription || analysis?.aiError) && (
<div style={{ padding: "0.8rem 1rem", borderBottom: "1px solid rgba(255,255,255,0.05)", flexShrink: 0 }}>
<div style={{ fontSize: "0.66rem", color: "#a78bfa", textTransform: "uppercase", letterSpacing: "0.06em", fontWeight: 700, marginBottom: 6 }}>Analisi AI</div>
{analysis.aiAnalyzing && (
<div style={{ fontSize: "0.76rem", color: "#a78bfa", display: "flex", alignItems: "center", gap: 6 }}>
<span style={{ width: 8, height: 8, borderRadius: "50%", background: "#a78bfa", display: "inline-block", animation: "visionPulse 1.4s ease-in-out infinite", flexShrink: 0 }} /> Analisi in corso…
</div>
)}
{analysis.aiError && <div style={{ fontSize: "0.74rem", color: "#ef4444" }}>! {analysis.aiError}</div>}
{analysis.aiDescription && (
<div style={{ fontSize: "0.76rem", color: "#d0d0f0", lineHeight: 1.5, marginBottom: 6 }}>{analysis.aiDescription}</div>
)}
{analysis.aiMood && (
<div style={{ fontSize: "0.7rem", color: "#a78bfa" }}>Mood: {analysis.aiMood}</div>
)}
{analysis.aiTextInImage && (
<div style={{ fontSize: "0.72rem", color: "#fbbf24", marginTop: 4 }}>Testo rilevato: "{analysis.aiTextInImage}"</div>
)}
{analysis.aiObjects && analysis.aiObjects.length > 0 && (
<div style={{ display: "flex", flexWrap: "wrap", gap: 4, marginTop: 5 }}>
{analysis.aiObjects.map(o => (
<span key={o} style={{ fontSize: "0.65rem", color: "#60a5fa", background: "rgba(96,165,250,0.08)", border: "1px solid rgba(96,165,250,0.2)", borderRadius: 5, padding: "2px 7px" }}>{o}</span>
))}
</div>
)}
{analysis.aiTags && analysis.aiTags.length > 0 && (
<div style={{ display: "flex", flexWrap: "wrap", gap: 4, marginTop: 5 }}>
{analysis.aiTags.map(t => (
<span key={t} style={{ fontSize: "0.62rem", color: "#9ca3af", background: "rgba(156,163,175,0.08)", border: "1px solid rgba(156,163,175,0.15)", borderRadius: 5, padding: "2px 6px" }}>#{t}</span>
))}
</div>
)}
</div>
)}
{/* ── Actions ── */}
{analysis && (
<div style={{ padding: "0.8rem 1rem", flexShrink: 0 }}>
<div style={{ fontSize: "0.62rem", color: "#6b6b8a", textTransform: "uppercase", letterSpacing: "0.06em", fontWeight: 700, marginBottom: 8 }}>Cosa vuoi fare?</div>
<div style={{ display: "flex", flexDirection: "column", gap: 5 }}>
{analysis.actions.map(act => (
<div key={act.id}>
<ActionCard
icon={act.icon} label={act.label} desc={act.desc} color={act.color}
active={activeAction === act.id}
done={actionDone === act.id}
loading={converting && activeAction === act.id}
onClick={() => {
if (opfsLoading) return;
if (act.hasOptions) {
setActiveAction(prev => prev === act.id ? null : act.id);
setActionDone(null);
} else {
setActiveAction(act.id);
void runConvert(act.id);
}
}}
/>
{/* Inline options for resize/compress */}
{activeAction === act.id && act.hasOptions && (
<div style={{ marginTop: 5, marginLeft: 12, padding: "0.7rem 0.9rem", background: "rgba(255,255,255,0.03)", border: `1px solid ${act.color}22`, borderRadius: 9, display: "flex", flexDirection: "column", gap: 8 }}>
{act.id === "resize" && (
<>
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
<label style={{ fontSize: "0.7rem", color: "#9ca3af", width: 70 }}>Max W (px)</label>
<input type="number" min={64} max={4096} value={convOpts.maxW} onChange={e => setConvOpts(p => ({ ...p, maxW: +e.target.value }))}
style={{ flex: 1, background: "rgba(255,255,255,0.07)", border: "1px solid rgba(255,255,255,0.12)", borderRadius: 5, padding: "3px 7px", color: "#e2e2f0", fontSize: "0.75rem", outline: "none" }} />
<label style={{ fontSize: "0.7rem", color: "#9ca3af", width: 70 }}>Max H (px)</label>
<input type="number" min={64} max={4096} value={convOpts.maxH} onChange={e => setConvOpts(p => ({ ...p, maxH: +e.target.value }))}
style={{ flex: 1, background: "rgba(255,255,255,0.07)", border: "1px solid rgba(255,255,255,0.12)", borderRadius: 5, padding: "3px 7px", color: "#e2e2f0", fontSize: "0.75rem", outline: "none" }} />
</div>
</>
)}
{act.id === "compress" && (
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
<label style={{ fontSize: "0.7rem", color: "#9ca3af", width: 80 }}>Qualità</label>
<input type="range" min={10} max={95} value={convOpts.quality} onChange={e => setConvOpts(p => ({ ...p, quality: +e.target.value }))} style={{ flex: 1 }} />
<span style={{ fontSize: "0.75rem", color: "#e2e2f0", width: 36, textAlign: "right", fontFamily: "ui-monospace, monospace" }}>{convOpts.quality}%</span>
</div>
)}
<button
onClick={() => void runConvert(act.id)}
disabled={converting}
style={{ all: "unset", cursor: converting ? "default" : "pointer", alignSelf: "flex-start", padding: "0.35rem 1rem", borderRadius: 7, background: act.color, color: "#fff", fontSize: "0.74rem", fontWeight: 700, opacity: converting ? 0.6 : 1, transition: "opacity 0.15s" }}
>
{converting ? "Conversione…" : "Converti e scarica"}
</button>
{actionError && <div style={{ fontSize: "0.7rem", color: "#ef4444" }}>! {actionError}</div>}
</div>
)}
</div>
))}
</div>
{actionError && !activeAction?.match(/resize|compress/) && (
<div style={{ marginTop: 6, fontSize: "0.72rem", color: "#ef4444" }}>! {actionError}</div>
)}
</div>
)}
</div>
</div>
)}
</div>
{/* ── Snapshots panel ── */}
{tab === "snapshots" && (
<div style={{ flex: 1, overflow: "auto", padding: "1rem 1.2rem" }}>
{snapMsg && (
<div style={{ padding: "0.5rem 0.85rem", marginBottom: "0.8rem", borderRadius: 8,
background: "rgba(59,130,246,0.12)", color: "#3b82f6", fontSize: "0.78rem" }}>
{snapMsg}
</div>
)}
{snapLoading ? (
<div style={{ textAlign: "center", color: "#6b6b8a", padding: "2rem", fontSize: "0.82rem" }}>Caricamento...</div>
) : snapshots.length === 0 ? (
<div style={{ textAlign: "center", color: "#4a4a62", padding: "2.5rem 1rem" }}>
<div style={{ fontSize: "2.2rem", marginBottom: 8 }}>...</div>
<div style={{ fontSize: "0.82rem" }}>Nessuno snapshot salvato</div>
<div style={{ fontSize: "0.7rem", marginTop: 5, color: "#3a3a52" }}>Creati automaticamente ogni 3 step</div>
</div>
) : snapshots.map(s => (
<div key={s.id} style={{
display: "flex", alignItems: "center", gap: 10, padding: "0.6rem 0.85rem",
borderRadius: 10, marginBottom: 6,
background: "rgba(255,255,255,0.03)", border: "1px solid rgba(255,255,255,0.06)",
}}>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: "0.8rem", color: "#c0c0d0", fontWeight: 600, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{s.label || "Snapshot"}
</div>
<div style={{ fontSize: "0.67rem", color: "#6b6b8a", marginTop: 2 }}>
{new Date(s.createdAt).toLocaleString("it-IT", { dateStyle: "short", timeStyle: "short" })} · {(s.files as unknown[])?.length ?? 0} file
</div>
</div>
<Btn color="#3b82f6" onClick={async () => {
const ok = await restoreSnapshot(s.id);
setSnapMsg(ok ? "Snapshot ripristinato" : "Errore ripristino");
setTimeout(() => setSnapMsg(null), 3000);
}}>Ripristina</Btn>
<Btn color="#ef4444" onClick={async () => {
await deleteSnapshot(s.id);
setSnapshots(prev => prev.filter(x => x.id !== s.id));
}}>X</Btn>
</div>
))}
</div>
)}
</div>
</div>
);
});
export default FileExplorer;
// ─── Mini button helper ───────────────────────────────────────────────────────
function Btn({ children, color, onClick, disabled }: {
children: ReactNode; color: string;
onClick: () => void; disabled?: boolean;
}) {
return (
<button
onClick={onClick}
disabled={disabled}
style={{ all: "unset", cursor: disabled ? "default" : "pointer", fontSize: "0.73rem", color, padding: "3px 9px", borderRadius: 6, background: `${color}12`, border: `1px solid ${color}28`, opacity: disabled ? 0.6 : 1, transition: "opacity 0.15s" }}
>
{children}
</button>
);
}