AUDIT / src /components /FileEditor.tsx
Arypulka98's picture
feat(audit): deploy full backend cluster node
aea470f verified
Raw
History Blame Contribute Delete
32.7 kB
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;