import { useState, useEffect, useRef, useCallback, memo, lazy, Suspense } from "react"; import { Z_INDEX } from "@/lib/zindex"; import { Save, FileText, Folder, FolderOpen, Plus, Upload, Download, Trash2, Play, Loader, RefreshCw, X, ChevronRight, ChevronDown, } from "lucide-react"; import { vfsAsync, onVfsChanged } from "@/lib/vfsDb"; import { useLiveDiagnostics } from "@/hooks/useLiveDiagnostics"; // GAP-3 import { updateFileState } from "@/lib/worldModel"; // X8 import type { VFSFile } from "@/lib/vfsDb"; // import type { VfsTsError } from "@/lib/vfsTsChecker"; // GAP-3 // removed: unused import { canRun } from "@/lib/codeRunner"; import DiffBlock from "./DiffBlock"; import CodeMirrorEditor from "./CodeMirrorEditor"; import { useWorkspaceStore } from "@/store/workspaceStore"; // S611 import { useUIStore } from "@/store/uiStore"; // S623: nasconde sidebar interna se showFileTree è aperta import { useIsMobile } from "@/hooks/use-mobile"; // S800: mobile-specific UX import { useMobileKeyboardAware } from "@/hooks/useMobileKeyboardAware"; // S800: keyboard height import { useAutoSave } from "@/hooks/useAutoSave"; // S800: auto-save su iOS // S800: componenti mobile — lazy-loaded per non impattare bundle desktop const MobileEditorFindBar = lazy(() => import("@/components/workspace/MobileEditorFindBar")); const MobileEditorStatusBar = lazy(() => import("@/components/workspace/MobileEditorStatusBar")); // ─── Types ──────────────────────────────────────────────────────────────────── interface FileEditorProps { onRunRequest?: (code: string, filename: string) => void; onFileOpen?: (file: VFSFile) => void; } interface TreeNode { name: string; path: string; isDir: boolean; children: TreeNode[]; file?: VFSFile; } // ─── Extension colors ───────────────────────────────────────────────────────── const EXT_COLORS: Record = { ts: "#3178c6", tsx: "#3178c6", js: "#f7df1e", jsx: "#61dafb", py: "#3572a5", go: "#00add8", rs: "#dea584", json: "#ffa500", md: "#83a598", css: "#563d7c", html: "#e34c26", sh: "#89d051", txt: "#888", toml: "#9c4221", yaml: "#cc3e44", yml: "#cc3e44", csv: "#3b82f6", xml: "#84cc16", sql: "#f472b6", }; function FileIcon({ name, size = 13 }: { name: string; size?: number }) { const ext = name.split(".").pop()?.toLowerCase() ?? ""; const color = EXT_COLORS[ext] || "var(--text-muted)"; return ; } // ─── Build tree from flat VFS list ──────────────────────────────────────────── function buildTree(files: VFSFile[]): TreeNode[] { const root: TreeNode[] = []; const dirMap = new Map(); const getOrCreateDir = (dirPath: string): TreeNode => { if (dirMap.has(dirPath)) return dirMap.get(dirPath)!; const parts = dirPath.split("/"); const name = parts.pop()!; const parentPath = parts.join("/"); const node: TreeNode = { name, path: dirPath, isDir: true, children: [] }; dirMap.set(dirPath, node); if (!parentPath) { root.push(node); } else { getOrCreateDir(parentPath).children.push(node); } return node; }; const sorted = [...files].sort((a, b) => a.path.localeCompare(b.path)); for (const file of sorted) { const parts = file.path.split("/"); if (parts.length === 1) { root.push({ name: file.name, path: file.path, isDir: false, children: [], file }); } else { parts.pop(); const dirPath = parts.join("/"); const dir = getOrCreateDir(dirPath); dir.children.push({ name: file.name, path: file.path, isDir: false, children: [], file }); } } const sortNodes = (nodes: TreeNode[]) => { nodes.sort((a, b) => { if (a.isDir !== b.isDir) return a.isDir ? -1 : 1; return a.name.localeCompare(b.name); }); nodes.forEach(n => n.children.length && sortNodes(n.children)); }; sortNodes(root); return root; } // ─── Tree node component ───────────────────────────────────────────────────── function TreeNodeRow({ node, depth, activeFile, onOpen, onDelete, }: { node: TreeNode; depth: number; activeFile: string | null; onOpen: (file: VFSFile) => void; onDelete: (path: string) => void; }) { const [expanded, setExpanded] = useState(true); const isActive = activeFile === node.path; if (node.isDir) { return ( <> {expanded && node.children.map(child => ( ))} ); } return (
); } // ─── Main component ─────────────────────────────────────────────────────────── const FileEditor = memo(function FileEditor({ onRunRequest, onFileOpen }: FileEditorProps) { const [files, setFiles] = useState([]); const [tree, setTree] = useState([]); const [openFile, setOpenFile] = useState(null); const [editorContent, setEditorContent] = useState(""); const [saving, setSaving] = useState(false); const [saveMsg, setSaveMsg] = useState(""); const [showDiff, setShowDiff] = useState(false); const [isDragOver, setIsDragOver] = useState(false); const [newFileName, setNewFileName] = useState(""); const [showNewFile, setShowNewFile] = useState(false); const fileInputRef = useRef(null); const emptyInputRef = useRef(null); const isText = (f: VFSFile) => !f.opfs && !f.type.startsWith("image/") && !f.type.startsWith("video/") && !f.type.startsWith("audio/") && f.type !== "application/pdf"; const reload = useCallback(async () => { const all = await vfsAsync.list(); const text = all.filter(isText); setFiles(text); setTree(buildTree(text)); }, []); useEffect(() => { reload(); return onVfsChanged(reload); }, [reload]); // S623: nasconde sidebar interna quando la sidebar globale FileTreeSidebar è aperta (evita doppio albero) const showFileTree = useUIStore(s => s.showFileTree); // S611: apre un file richiesto da FileTreeSidebar (via workspaceStore.requestOpenPath) const requestOpenPath = useWorkspaceStore(s => s.requestOpenPath); const setRequestOpenPath = useWorkspaceStore(s => s.setRequestOpenPath); useEffect(() => { if (!requestOpenPath) return; const target = files.find(f => f.path === requestOpenPath); if (target) { void openVfsFile(target); } setRequestOpenPath(null); // eslint-disable-next-line react-hooks/exhaustive-deps }, [requestOpenPath, files]); const openVfsFile = async (file: VFSFile) => { const content = await vfsAsync.read(file.path) ?? file.content ?? ""; const loaded: VFSFile = { ...file, content }; setOpenFile(loaded); setEditorContent(content); setShowDiff(false); onFileOpen?.(loaded); }; const save = async () => { if (!openFile) return; setSaving(true); setSaveMsg(""); try { await vfsAsync.write(openFile.path, editorContent, openFile.type || "text/plain"); setSaveMsg("Salvato!"); setOpenFile(f => f ? { ...f, content: editorContent } : f); updateFileState(openFile.path, { lastModified: Date.now(), health: "unknown" }); // X8: sync WorldModel } catch (e) { setSaveMsg(`Errore: ${(e as Error).message}`); } finally { setSaving(false); setTimeout(() => setSaveMsg(""), 2500); } }; const deleteFile = async (path: string) => { if (!confirm(`Eliminare "${path}"?`)) return; await vfsAsync.delete(path); if (openFile?.path === path) { setOpenFile(null); setEditorContent(""); } }; const downloadFile = () => { if (!openFile) return; const blob = new Blob([editorContent], { type: openFile.type || "text/plain" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = openFile.name; a.click(); URL.revokeObjectURL(url); }; const uploadFiles = async (incoming: FileList | File[]) => { const list = Array.from(incoming); for (const f of list) { const textLike = f.type.startsWith("text/") || f.type === "application/json" || /\.(ts|tsx|js|jsx|py|go|rs|md|json|yaml|yml|toml|csv|sql|sh|txt|html|css|xml|env|ini|log|conf|cfg|gitignore)$/i.test(f.name); if (!textLike) { setSaveMsg(`"${f.name}" non è un file di testo`); setTimeout(() => setSaveMsg(""), 2500); continue; } const text = await f.text(); await vfsAsync.write(f.name, text, f.type || "text/plain"); } }; const createNewFile = async () => { const name = newFileName.trim(); if (!name) return; await vfsAsync.write(name, "", "text/plain"); setNewFileName(""); setShowNewFile(false); await reload(); const all = await vfsAsync.list(); const created = all.find(f => f.path === name || f.name === name); if (created) openVfsFile(created); }; const handleDrop = async (e: React.DragEvent) => { e.preventDefault(); setIsDragOver(false); if (e.dataTransfer.files.length > 0) await uploadFiles(e.dataTransfer.files); }; const isDirty = openFile && editorContent !== openFile.content; // S800: mobile-specific state const isMobile = useIsMobile(); const { keyboardHeight, isKeyboardOpen } = useMobileKeyboardAware(); const [fontSize, setFontSize] = useState(13); // font size controllato da status bar const [cursorLine, setCursorLine] = useState(1); // posizione cursore per status bar const [cursorCol, setCursorCol] = useState(1); const [showFindBar, setShowFindBar] = useState(false); // find/replace bar mobile const [isFullscreen, setIsFullscreen] = useState(false); // full-screen mode mobile const scrollToOffsetRef = useRef<((offset: number) => void) | null>(null); // S800: auto-save — critico su iOS (app può essere killata in background) // _autoSaveRef mantiene sempre l'ultimo save() (che chiude su openFile/editorContent aggiornati) const _autoSaveRef = useRef<() => Promise>(async () => {}); _autoSaveRef.current = save; // assegnazione sincrona nel render — sempre aggiornata const stableSave = useCallback(async () => { await _autoSaveRef.current(); }, []); useAutoSave({ isDirty: !!isDirty, onSave: stableSave, enabled: isMobile }); const handleFontSize = useCallback((delta: number) => { setFontSize(prev => Math.max(10, Math.min(24, prev + delta))); }, []); const handleFindNavigate = useCallback((offset: number) => { scrollToOffsetRef.current?.(offset); }, []); const handleReplace = useCallback((offset: number, matchLen: number, replacement: string) => { const before = editorContent.slice(0, offset); const after = editorContent.slice(offset + matchLen); setEditorContent(before + replacement + after); }, [editorContent]); const handleReplaceAll = useCallback((query: string, replacement: string) => { setEditorContent(editorContent.split(query).join(replacement)); }, [editorContent]); const canRunFile = openFile ? canRun(openFile.path) : false; // GAP-3: live TS diagnostics — debounced 800ms, solo file .ts/.tsx const { errors: tsErrors, isChecking: isTsChecking } = useLiveDiagnostics(openFile?.path ?? null); const tsErrCount = tsErrors.filter(e => e.sev === "error").length; const tsWarnCount = tsErrors.filter(e => e.sev === "warn").length; function buildSimpleDiff(): string { if (!openFile || editorContent === openFile.content) return ""; const aLines = openFile.content.split("\n").slice(0, 300); const bLines = editorContent.split("\n").slice(0, 300); return [ `--- a/${openFile.path}`, `+++ b/${openFile.path}`, `@@ -1,${aLines.length} +1,${bLines.length} @@`, ...aLines.map(l => `-${l}`), ...bLines.map(l => `+${l}`), ].join("\n"); } return (
{ e.preventDefault(); setIsDragOver(true); }} onDragLeave={e => { if (!e.currentTarget.contains(e.relatedTarget as Node)) setIsDragOver(false); }} onDrop={handleDrop} > {/* Drag overlay */} {isDragOver && (
Rilascia i file qui per caricarli
)} {/* ─── Sidebar interna — S623: nascosta quando showFileTree (global) è aperta ─── */} {!showFileTree &&
{/* Header */}
File ({files.length})
{/* New file input */} {showNewFile && (
setNewFileName(e.target.value)} onKeyDown={e => { if (e.key === "Enter") createNewFile(); if (e.key === "Escape") { setShowNewFile(false); setNewFileName(""); } }} placeholder="es. script.py" style={{ width: "100%", padding: "4px 6px", fontSize: "0.72rem", background: "var(--bg3)", border: "1px solid var(--border)", borderRadius: 5, color: "var(--text)", outline: "none", boxSizing: "border-box", }} />
)} {/* Hidden file inputs */} { if (e.target.files) await uploadFiles(e.target.files); e.target.value = ""; }} /> {/* File tree */}
{tree.length === 0 ? (
Nessun file
Carica o crea un file per iniziare
) : ( tree.map(node => ( )) )}
{/* Drop hint */}
Trascina file per caricarli
} {/* S623: fine sidebar interna condizionale (!showFileTree) */} {/* ─── Editor area ─────────────────────────────────────────────── */}
{openFile ? ( <> {/* Toolbar */}
{openFile.path} {isDirty && } {/* GAP-3: TS badge */} {isTsChecking && ( TS… )} {!isTsChecking && tsErrCount > 0 && ( {tsErrCount}⚠ )} {!isTsChecking && tsErrCount === 0 && tsWarnCount > 0 && ( {tsWarnCount}△ )}
{canRunFile && ( )} {isDirty && ( <> )} {/* S800: Cerca — visibile solo su mobile */} {isMobile && ( )} {/* S800: Full-screen — visibile solo su mobile */} {isMobile && ( )} {saveMsg && ( {saveMsg} )}
{/* CodeMirror editor — lazy-loaded da esm.sh (zero bundle impact) */} {/* S800: padding-bottom su mobile per la status bar (28px) + keyboard safety */}
{ setCursorLine(l); setCursorCol(c); }} scrollToOffsetRef={scrollToOffsetRef} /> {/* S800: Status bar — solo mobile */} {isMobile && openFile && ( )}
{/* S800: Find bar — solo mobile, si posiziona sopra la tastiera */} {isMobile && showFindBar && openFile && ( setShowFindBar(false)} onNavigate={handleFindNavigate} onReplace={handleReplace} onReplaceAll={handleReplaceAll} /> )} {/* Diff panel */} {showDiff && isDirty && (
)} ) : ( /* Empty state */

Nessun file aperto

Seleziona un file dalla sidebar,
caricane uno o creane uno nuovo

{ if (e.target.files) await uploadFiles(e.target.files); e.target.value = ""; }} />
)}
); }); export default FileEditor;