Spaces:
Sleeping
Sleeping
| 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<string, string> = { | |
| 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 <FileText size={size} color={color} />; | |
| } | |
| // ─── Build tree from flat VFS list ──────────────────────────────────────────── | |
| function buildTree(files: VFSFile[]): TreeNode[] { | |
| const root: TreeNode[] = []; | |
| const dirMap = new Map<string, TreeNode>(); | |
| 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 ( | |
| <> | |
| <button | |
| onClick={() => setExpanded(e => !e)} | |
| style={{ | |
| display: "flex", alignItems: "center", gap: 5, width: "100%", | |
| padding: `3px 8px 3px ${8 + depth * 14}px`, | |
| background: "none", border: "none", cursor: "pointer", | |
| color: "var(--text-muted)", fontSize: "0.74rem", textAlign: "left", | |
| }} | |
| > | |
| {expanded | |
| ? <ChevronDown size={10} color="#f59e0b" /> | |
| : <ChevronRight size={10} color="#f59e0b" />} | |
| {expanded | |
| ? <FolderOpen size={13} color="#f59e0b" /> | |
| : <Folder size={13} color="#f59e0b" />} | |
| <span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", flex: 1 }}> | |
| {node.name} | |
| </span> | |
| </button> | |
| {expanded && node.children.map(child => ( | |
| <TreeNodeRow | |
| key={child.path} node={child} depth={depth + 1} | |
| activeFile={activeFile} onOpen={onOpen} onDelete={onDelete} | |
| /> | |
| ))} | |
| </> | |
| ); | |
| } | |
| return ( | |
| <div | |
| style={{ | |
| display: "flex", alignItems: "center", | |
| background: isActive ? "rgba(59,130,246,0.12)" : "none", | |
| borderRadius: 4, margin: "0 4px", | |
| }} | |
| > | |
| <button | |
| onClick={() => node.file && onOpen(node.file)} | |
| style={{ | |
| display: "flex", alignItems: "center", gap: 5, flex: 1, | |
| padding: `3px 4px 3px ${8 + depth * 14}px`, | |
| background: "none", border: "none", cursor: "pointer", | |
| color: isActive ? "var(--text)" : "var(--text-muted)", | |
| fontSize: "0.74rem", textAlign: "left", | |
| }} | |
| > | |
| <FileIcon name={node.name} /> | |
| <span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", flex: 1 }}> | |
| {node.name} | |
| </span> | |
| </button> | |
| <button | |
| onClick={() => onDelete(node.path)} | |
| title="Elimina file" | |
| style={{ | |
| background: "none", border: "none", cursor: "pointer", | |
| color: "var(--text-muted)", padding: "2px 6px 2px 2px", | |
| opacity: 0.4, flexShrink: 0, transition: "opacity 0.15s", | |
| }} | |
| onMouseEnter={e => { e.currentTarget.style.opacity = "1"; e.currentTarget.style.color = "#f87171"; }} | |
| onMouseLeave={e => { e.currentTarget.style.opacity = "0.4"; e.currentTarget.style.color = "var(--text-muted)"; }} | |
| > | |
| <Trash2 size={10} /> | |
| </button> | |
| </div> | |
| ); | |
| } | |
| // ─── Main component ─────────────────────────────────────────────────────────── | |
| const FileEditor = memo(function FileEditor({ onRunRequest, onFileOpen }: FileEditorProps) { | |
| const [files, setFiles] = useState<VFSFile[]>([]); | |
| const [tree, setTree] = useState<TreeNode[]>([]); | |
| const [openFile, setOpenFile] = useState<VFSFile | null>(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<HTMLInputElement>(null); | |
| const emptyInputRef = useRef<HTMLInputElement>(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<void>>(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 ( | |
| <div | |
| style={{ display: "flex", height: "100%", overflow: "hidden", position: "relative" }} | |
| onDragOver={e => { e.preventDefault(); setIsDragOver(true); }} | |
| onDragLeave={e => { if (!e.currentTarget.contains(e.relatedTarget as Node)) setIsDragOver(false); }} | |
| onDrop={handleDrop} | |
| > | |
| {/* Drag overlay */} | |
| {isDragOver && ( | |
| <div style={{ | |
| position: "absolute", inset: 0, zIndex: Z_INDEX.SIDEBAR, | |
| background: "rgba(59,130,246,0.1)", | |
| border: "2px dashed rgba(59,130,246,0.5)", | |
| display: "flex", alignItems: "center", justifyContent: "center", | |
| borderRadius: 8, pointerEvents: "none", | |
| }}> | |
| <div style={{ color: "#60a5fa", fontSize: "1rem", fontWeight: 600 }}> | |
| Rilascia i file qui per caricarli | |
| </div> | |
| </div> | |
| )} | |
| {/* ─── Sidebar interna — S623: nascosta quando showFileTree (global) è aperta ─── */} | |
| {!showFileTree && <div style={{ | |
| width: 200, flexShrink: 0, borderRight: "1px solid var(--border)", | |
| overflowY: "auto", background: "var(--bg2)", | |
| display: "flex", flexDirection: "column", | |
| }}> | |
| {/* Header */} | |
| <div style={{ | |
| display: "flex", alignItems: "center", gap: 4, | |
| padding: "8px 8px 6px", flexShrink: 0, | |
| borderBottom: "1px solid var(--border)", | |
| }}> | |
| <span style={{ | |
| fontSize: "0.66rem", fontWeight: 700, | |
| color: "var(--text-muted)", textTransform: "uppercase", | |
| letterSpacing: "0.07em", flex: 1, | |
| }}> | |
| File ({files.length}) | |
| </span> | |
| <button | |
| onClick={() => setShowNewFile(s => !s)} | |
| title="Nuovo file" | |
| style={{ background: "none", border: "none", cursor: "pointer", color: "var(--text-muted)", padding: 2 }} | |
| > | |
| <Plus size={13} /> | |
| </button> | |
| <button | |
| onClick={() => fileInputRef.current?.click()} | |
| title="Carica file dal computer" | |
| style={{ background: "none", border: "none", cursor: "pointer", color: "var(--text-muted)", padding: 2 }} | |
| > | |
| <Upload size={13} /> | |
| </button> | |
| <button | |
| onClick={reload} | |
| title="Aggiorna lista" | |
| style={{ background: "none", border: "none", cursor: "pointer", color: "var(--text-muted)", padding: 2 }} | |
| > | |
| <RefreshCw size={11} /> | |
| </button> | |
| </div> | |
| {/* New file input */} | |
| {showNewFile && ( | |
| <div style={{ padding: "6px 8px", borderBottom: "1px solid var(--border)", flexShrink: 0 }}> | |
| <input | |
| autoFocus | |
| value={newFileName} | |
| onChange={e => 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", | |
| }} | |
| /> | |
| </div> | |
| )} | |
| {/* Hidden file inputs */} | |
| <input | |
| ref={fileInputRef} | |
| type="file" | |
| multiple | |
| style={{ display: "none" }} | |
| onChange={async e => { | |
| if (e.target.files) await uploadFiles(e.target.files); | |
| e.target.value = ""; | |
| }} | |
| /> | |
| {/* File tree */} | |
| <div style={{ flex: 1, overflowY: "auto", paddingTop: 4 }}> | |
| {tree.length === 0 ? ( | |
| <div style={{ | |
| padding: "1.5rem 0.75rem", textAlign: "center", | |
| color: "var(--text-muted)", fontSize: "0.72rem", | |
| }}> | |
| <Upload size={20} style={{ opacity: 0.25, marginBottom: 6 }} /> | |
| <div style={{ fontWeight: 600 }}>Nessun file</div> | |
| <div style={{ opacity: 0.6, marginTop: 3 }}> | |
| Carica o crea un file per iniziare | |
| </div> | |
| </div> | |
| ) : ( | |
| tree.map(node => ( | |
| <TreeNodeRow | |
| key={node.path} | |
| node={node} | |
| depth={0} | |
| activeFile={openFile?.path ?? null} | |
| onOpen={openVfsFile} | |
| onDelete={deleteFile} | |
| /> | |
| )) | |
| )} | |
| </div> | |
| {/* Drop hint */} | |
| <div style={{ | |
| padding: "6px 10px", fontSize: "0.61rem", | |
| color: "var(--text-muted)", opacity: 0.45, | |
| textAlign: "center", flexShrink: 0, | |
| borderTop: "1px solid var(--border)", | |
| }}> | |
| Trascina file per caricarli | |
| </div> | |
| </div>} | |
| {/* S623: fine sidebar interna condizionale (!showFileTree) */} | |
| {/* ─── Editor area ─────────────────────────────────────────────── */} | |
| <div style={{ flex: 1, display: "flex", flexDirection: "column", overflow: "hidden" }}> | |
| {openFile ? ( | |
| <> | |
| {/* Toolbar */} | |
| <div style={{ | |
| display: "flex", alignItems: "center", gap: 6, | |
| padding: "6px 10px", borderBottom: "1px solid var(--border)", | |
| background: "var(--bg2)", flexShrink: 0, flexWrap: "wrap", | |
| }}> | |
| <div style={{ display: "flex", alignItems: "center", gap: 5, flex: 1, minWidth: 0 }}> | |
| <FileIcon name={openFile.name} size={12} /> | |
| <span style={{ | |
| fontSize: "0.77rem", color: "var(--text)", | |
| fontFamily: "ui-monospace, monospace", | |
| overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", | |
| }}> | |
| {openFile.path} | |
| </span> | |
| {isDirty && <span style={{ color: "#fbbf24", fontSize: "0.85rem" }}>●</span>} | |
| {/* GAP-3: TS badge */} | |
| {isTsChecking && ( | |
| <span style={{ fontSize: "0.60rem", color: "#6366f1", fontFamily: "ui-monospace,monospace", opacity: 0.8 }}>TS…</span> | |
| )} | |
| {!isTsChecking && tsErrCount > 0 && ( | |
| <span style={{ | |
| fontSize: "0.60rem", fontWeight: 700, color: "#f87171", | |
| fontFamily: "ui-monospace,monospace", | |
| background: "rgba(248,113,113,0.10)", padding: "1px 5px", | |
| borderRadius: 4, border: "1px solid rgba(248,113,113,0.25)", | |
| }}>{tsErrCount}⚠</span> | |
| )} | |
| {!isTsChecking && tsErrCount === 0 && tsWarnCount > 0 && ( | |
| <span style={{ | |
| fontSize: "0.60rem", fontWeight: 700, color: "#fbbf24", | |
| fontFamily: "ui-monospace,monospace", | |
| background: "rgba(251,191,36,0.10)", padding: "1px 5px", | |
| borderRadius: 4, border: "1px solid rgba(251,191,36,0.20)", | |
| }}>{tsWarnCount}△</span> | |
| )} | |
| </div> | |
| {canRunFile && ( | |
| <button | |
| onClick={() => onRunRequest?.(editorContent, openFile.path)} | |
| style={{ | |
| display: "flex", alignItems: "center", gap: 4, | |
| padding: "3px 9px", borderRadius: 6, cursor: "pointer", | |
| background: "rgba(59,130,246,0.15)", | |
| border: "1px solid rgba(59,130,246,0.25)", | |
| color: "#4ade80", fontSize: "0.72rem", fontWeight: 600, | |
| }} | |
| > | |
| <Play size={11} fill="currentColor" /> Esegui | |
| </button> | |
| )} | |
| <button | |
| onClick={downloadFile} | |
| title="Scarica file" | |
| style={{ | |
| display: "flex", alignItems: "center", gap: 4, | |
| padding: "3px 9px", borderRadius: 6, cursor: "pointer", | |
| background: "rgba(96,165,250,0.1)", | |
| border: "1px solid rgba(96,165,250,0.2)", | |
| color: "#93c5fd", fontSize: "0.72rem", fontWeight: 600, | |
| }} | |
| > | |
| <Download size={11} /> Scarica | |
| </button> | |
| <button | |
| onClick={save} | |
| disabled={!isDirty || saving} | |
| style={{ | |
| display: "flex", alignItems: "center", gap: 4, | |
| padding: "3px 9px", borderRadius: 6, | |
| cursor: isDirty ? "pointer" : "default", | |
| background: isDirty ? "rgba(59,130,246,0.18)" : "var(--bg4)", | |
| border: isDirty ? "1px solid rgba(59,130,246,0.3)" : "1px solid transparent", | |
| color: isDirty ? "var(--primary-light)" : "var(--text-muted)", | |
| fontSize: "0.72rem", fontWeight: 600, opacity: isDirty ? 1 : 0.45, | |
| }} | |
| > | |
| {saving | |
| ? <Loader size={11} style={{ animation: "spin-slow 1s linear infinite" }} /> | |
| : <Save size={11} />} | |
| Salva | |
| </button> | |
| {isDirty && ( | |
| <> | |
| <button | |
| onClick={() => setShowDiff(d => !d)} | |
| title="Mostra diff" | |
| style={{ | |
| display: "flex", alignItems: "center", gap: 3, | |
| padding: "3px 9px", borderRadius: 6, cursor: "pointer", | |
| background: showDiff ? "rgba(96,165,250,0.15)" : "rgba(255,255,255,0.04)", | |
| border: `1px solid ${showDiff ? "rgba(96,165,250,0.3)" : "rgba(255,255,255,0.08)"}`, | |
| color: showDiff ? "#93c5fd" : "var(--text-muted)", | |
| fontSize: "0.72rem", fontWeight: 600, | |
| }} | |
| > | |
| ↔ Diff | |
| </button> | |
| <button | |
| onClick={() => { setEditorContent(openFile.content); setShowDiff(false); }} | |
| title="Ripristina versione salvata" | |
| style={{ | |
| display: "flex", alignItems: "center", gap: 3, | |
| padding: "3px 8px", borderRadius: 6, cursor: "pointer", | |
| background: "rgba(239,68,68,0.08)", | |
| border: "1px solid rgba(239,68,68,0.18)", | |
| color: "#fca5a5", fontSize: "0.72rem", fontWeight: 600, | |
| }} | |
| > | |
| <X size={10} /> Ripristina | |
| </button> | |
| </> | |
| )} | |
| {/* S800: Cerca — visibile solo su mobile */} | |
| {isMobile && ( | |
| <button | |
| onClick={() => { setShowFindBar(v => !v); }} | |
| title="Cerca nel file" | |
| style={{ | |
| display: "flex", alignItems: "center", gap: 3, | |
| padding: "3px 9px", borderRadius: 6, cursor: "pointer", | |
| background: showFindBar ? "rgba(99,102,241,0.18)" : "rgba(255,255,255,0.04)", | |
| border: `1px solid ${showFindBar ? "rgba(99,102,241,0.3)" : "rgba(255,255,255,0.08)"}`, | |
| color: showFindBar ? "#818cf8" : "var(--text-muted)", | |
| fontSize: "0.72rem", fontWeight: 600, touchAction: "manipulation", | |
| WebkitTapHighlightColor: "transparent", | |
| } as React.CSSProperties} | |
| > | |
| 🔍 | |
| </button> | |
| )} | |
| {/* S800: Full-screen — visibile solo su mobile */} | |
| {isMobile && ( | |
| <button | |
| onClick={() => setIsFullscreen(v => !v)} | |
| title={isFullscreen ? "Esci dal full-screen" : "Full-screen"} | |
| style={{ | |
| display: "flex", alignItems: "center", | |
| padding: "3px 9px", borderRadius: 6, cursor: "pointer", | |
| background: isFullscreen ? "rgba(99,102,241,0.18)" : "rgba(255,255,255,0.04)", | |
| border: `1px solid ${isFullscreen ? "rgba(99,102,241,0.3)" : "rgba(255,255,255,0.08)"}`, | |
| color: isFullscreen ? "#818cf8" : "var(--text-muted)", | |
| fontSize: "0.72rem", touchAction: "manipulation", | |
| WebkitTapHighlightColor: "transparent", | |
| } as React.CSSProperties} | |
| > | |
| {isFullscreen ? "⊡" : "⊞"} | |
| </button> | |
| )} | |
| {saveMsg && ( | |
| <span style={{ | |
| fontSize: "0.72rem", | |
| color: saveMsg.startsWith("Errore") ? "#f87171" : "#4ade80", | |
| }}> | |
| {saveMsg} | |
| </span> | |
| )} | |
| </div> | |
| {/* CodeMirror editor — lazy-loaded da esm.sh (zero bundle impact) */} | |
| {/* S800: padding-bottom su mobile per la status bar (28px) + keyboard safety */} | |
| <div style={{ | |
| flex: 1, display: "flex", flexDirection: "column", overflow: "hidden", | |
| ...(isMobile && isFullscreen ? { | |
| position: "fixed", inset: 0, zIndex: 9999, | |
| background: "hsl(222 22% 7%)", flexDirection: "column", | |
| } : {}), | |
| }}> | |
| <CodeMirrorEditor | |
| value={editorContent} | |
| onChange={setEditorContent} | |
| filename={openFile.path} | |
| onSave={save} | |
| tsErrors={tsErrors} | |
| fontSize={fontSize} | |
| onCursorChange={(l, c) => { setCursorLine(l); setCursorCol(c); }} | |
| scrollToOffsetRef={scrollToOffsetRef} | |
| /> | |
| {/* S800: Status bar — solo mobile */} | |
| {isMobile && openFile && ( | |
| <Suspense fallback={null}> | |
| <MobileEditorStatusBar | |
| filename={openFile.path} | |
| fileSize={editorContent.length} | |
| isDirty={!!isDirty} | |
| line={cursorLine} | |
| col={cursorCol} | |
| fontSize={fontSize} | |
| onFontSize={handleFontSize} | |
| /> | |
| </Suspense> | |
| )} | |
| </div> | |
| {/* S800: Find bar — solo mobile, si posiziona sopra la tastiera */} | |
| {isMobile && showFindBar && openFile && ( | |
| <Suspense fallback={null}> | |
| <MobileEditorFindBar | |
| content={editorContent} | |
| bottomOffset={isKeyboardOpen ? keyboardHeight : 0} | |
| onClose={() => setShowFindBar(false)} | |
| onNavigate={handleFindNavigate} | |
| onReplace={handleReplace} | |
| onReplaceAll={handleReplaceAll} | |
| /> | |
| </Suspense> | |
| )} | |
| {/* Diff panel */} | |
| {showDiff && isDirty && ( | |
| <div style={{ | |
| flexShrink: 0, maxHeight: "38vh", overflowY: "auto", | |
| borderTop: "1px solid rgba(96,165,250,0.12)", | |
| background: "rgba(5,5,16,0.96)", | |
| }}> | |
| <DiffBlock content={buildSimpleDiff()} /> | |
| </div> | |
| )} | |
| </> | |
| ) : ( | |
| /* Empty state */ | |
| <div style={{ | |
| flex: 1, display: "flex", alignItems: "center", justifyContent: "center", | |
| color: "var(--text-muted)", flexDirection: "column", gap: 10, | |
| }}> | |
| <FileText size={38} style={{ opacity: 0.13 }} /> | |
| <p style={{ margin: 0, fontSize: "0.85rem", fontWeight: 600 }}> | |
| Nessun file aperto | |
| </p> | |
| <p style={{ margin: 0, fontSize: "0.75rem", opacity: 0.55, textAlign: "center" }}> | |
| Seleziona un file dalla sidebar,<br />caricane uno o creane uno nuovo | |
| </p> | |
| <div style={{ display: "flex", gap: 8, marginTop: 4 }}> | |
| <button | |
| onClick={() => fileInputRef.current?.click()} | |
| style={{ | |
| display: "flex", alignItems: "center", gap: 6, | |
| padding: "7px 14px", borderRadius: 8, cursor: "pointer", | |
| background: "rgba(59,130,246,0.12)", | |
| border: "1px solid rgba(59,130,246,0.25)", | |
| color: "#4ade80", fontSize: "0.78rem", fontWeight: 600, | |
| }} | |
| > | |
| <Upload size={13} /> Carica file | |
| </button> | |
| <button | |
| onClick={() => setShowNewFile(true)} | |
| style={{ | |
| display: "flex", alignItems: "center", gap: 6, | |
| padding: "7px 14px", borderRadius: 8, cursor: "pointer", | |
| background: "rgba(96,165,250,0.1)", | |
| border: "1px solid rgba(96,165,250,0.2)", | |
| color: "#93c5fd", fontSize: "0.78rem", fontWeight: 600, | |
| }} | |
| > | |
| <Plus size={13} /> Nuovo file | |
| </button> | |
| </div> | |
| <input | |
| ref={emptyInputRef} | |
| type="file" | |
| multiple | |
| style={{ display: "none" }} | |
| onChange={async e => { | |
| if (e.target.files) await uploadFiles(e.target.files); | |
| e.target.value = ""; | |
| }} | |
| /> | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| ); | |
| }); | |
| export default FileEditor; | |