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 = { 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 = { 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 (
{label} {value}
); } // ─── 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 ( ); } // ─── Main component ─────────────────────────────────────────────────────────── interface FileExplorerProps { onClose: () => void; } const FileExplorer = memo(function FileExplorer({ onClose }: FileExplorerProps) { const [files, setFiles] = useState([]); const [selected, setSelected] = useState(null); const [content, setContent] = useState(""); 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(null); const [activeAction, setActiveAction] = useState(null); const [actionDone, setActionDone] = useState(null); const [actionError, setActionError] = useState(null); const [opfsLoading, setOpfsLoading] = useState(false); const [opfsError, setOpfsError] = useState(null); const [opfsRetry, setOpfsRetry] = useState(0); const [opfsRetryTrig,setOpfsRetryTrig]= useState(0); const [converting, setConverting] = useState(false); const [convOpts, setConvOpts] = useState({ quality: 85, maxW: 1280, maxH: 1280 }); const [tab, setTab] = useState<"files" | "snapshots">("files"); // S194 const [snapshots, setSnapshots] = useState([]); // S194 const [snapLoading, setSnapLoading] = useState(false); // S194 const [snapMsg, setSnapMsg] = useState(null); // S194 const uploadRef = useRef(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(r => setTimeout(r, ms)); const withTimeout = (p: Promise, ms: number): Promise => Promise.race([ p, new Promise((_, 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 (
e.stopPropagation()} > {/* ── Header ── */}
File Manager VFS
{files.length} file · {fmt.size(totalBytes)}
handleUpload(e.target.files)} /> uploadRef.current?.click()} disabled={uploading}>{uploading ? "Carico…" : "Carica"} {files.length > 0 && {exporting ? "ZIP…" : "Esporta ZIP"}} {files.length > 0 && { vfsAsync.clear().catch(() => {}); setSelected(null); reload(); }}>Svuota}
{/* ── Tab bar ── */}
{(["files", "snapshots"] as const).map(t => ( ))}
{/* ── Body ── */}
{/* ── File list ── */}
{ e.preventDefault(); setDragOver(true); }} onDragLeave={() => setDragOver(false)} onDrop={e => { e.preventDefault(); setDragOver(false); handleUpload(e.dataTransfer.files); }} >
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" }} />
{filtered.length === 0 ? (
{search ? "Nessun risultato" : "VFS vuoto"}
{!search &&
Trascina file qui o usa Carica
}
) : 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 (
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" }} > {TYPE_EMOJI[cat]}
{f.name}
{matchesContent ? Match nel contenuto : f.path}
{fmt.size(f.size)} {cat}
); })}
{dragOver && (
Rilascia per caricare
)}
{/* ── Detail panel ── */} {showPanel && selected && (
{/* Detail header */}
{analysis ? TYPE_EMOJI[analysis.category] : "FILE"}
{selected.name}
{selected.path} · {fmt.date(selected.updatedAt)}
{analysis && ( {analysis.language ?? analysis.category.toUpperCase()} )}
{/* Scrollable detail body */}
{/* ── Preview ── */}
{analysis?.category === "image" && (
{selected.name}
)} {analysis?.category === "audio" && (
)} {analysis?.category === "video" && (
)} {analysis?.category === "csv" && analysis.csvHeaders && (
Anteprima tabella (prime 5 righe)
{analysis.csvHeaders.map(h => ( ))} {(analysis.csvPreview ?? []).map((row, i) => ( {row.map((cell, j) => ( ))} ))}
{h}
{cell}
)} {analysis?.category === "html" && content && (
Anteprima HTML