Spaces:
Sleeping
Sleeping
| /** | |
| * FileTreeSidebar.tsx — S611: file tree persistente nell'editor tab | |
| * | |
| * Sostituisce il VFS "cieco" (accessibile solo da More > File System VFS) con | |
| * una sidebar sempre visibile che mostra la struttura del progetto in real-time. | |
| * | |
| * Desktop: sidebar collassabile a sinistra dell'editor (~180px). | |
| * Mobile (iPhone): drawer a scomparsa dal basso, attivato da un FAB floating. | |
| * | |
| * Real-time: si aggiorna automaticamente via onVfsChanged ogni volta che | |
| * l'agente scrive un file (write_file tool). | |
| * | |
| * S615: search input con debounce 250ms — quando attivo mostra lista flat | |
| * dei file che corrispondono (path match), con highlight del termine trovato. | |
| */ | |
| import { useState, useEffect, useCallback, useRef, memo } from "react"; | |
| import { Z_INDEX } from "@/lib/zindex"; | |
| import { | |
| Folder, FolderOpen, FileText, ChevronRight, ChevronDown, | |
| PanelLeftClose, PanelLeftOpen, X, Search, FilePlus, | |
| } from "lucide-react"; | |
| import { vfsAsync, onVfsChanged } from "@/lib/vfsDb"; | |
| import type { VFSFile } from "@/lib/vfsDb"; | |
| import { useWorkspaceStore } from "@/store/workspaceStore"; | |
| import { useUIStore } from "@/store/uiStore"; | |
| import { useLongPress } from "@/hooks/useLongPress"; // S800 | |
| import { MobileFileContextSheet } from "@/components/workspace/MobileFileContextSheet"; // S800 | |
| // ─── Extension → colour map (same as FileEditor) ───────────────────────────── | |
| 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 fileColor(name: string): string { | |
| const ext = name.split(".").pop()?.toLowerCase() ?? ""; | |
| return EXT_COLORS[ext] ?? "#9ca3af"; | |
| } | |
| // ─── Tree node types ────────────────────────────────────────────────────────── | |
| interface TreeNode { | |
| name: string; | |
| path: string; | |
| isDir: boolean; | |
| children: TreeNode[]; | |
| file?: VFSFile; | |
| } | |
| 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 f of sorted) { | |
| const parts = f.path.split("/"); | |
| if (parts.length === 1) { | |
| root.push({ name: f.name, path: f.path, isDir: false, children: [], file: f }); | |
| } else { | |
| parts.pop(); | |
| getOrCreateDir(parts.join("/")).children.push({ | |
| name: f.name, path: f.path, isDir: false, children: [], file: f, | |
| }); | |
| } | |
| } | |
| const sort = (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 && sort(n.children)); | |
| }; | |
| sort(root); | |
| return root; | |
| } | |
| // ─── S615: debounce hook ────────────────────────────────────────────────────── | |
| function useDebounce<T>(value: T, ms: number): T { | |
| const [debounced, setDebounced] = useState(value); | |
| useEffect(() => { | |
| const t = setTimeout(() => setDebounced(value), ms); | |
| return () => clearTimeout(t); | |
| }, [value, ms]); | |
| return debounced; | |
| } | |
| // ─── S615: highlight helper ─────────────────────────────────────────────────── | |
| function HighlightMatch({ text, query }: { text: string; query: string }) { | |
| if (!query) return <span>{text}</span>; | |
| const lc = text.toLowerCase(); | |
| const qi = lc.indexOf(query.toLowerCase()); | |
| if (qi === -1) return <span>{text}</span>; | |
| return ( | |
| <span> | |
| {text.slice(0, qi)} | |
| <mark style={{ background: "rgba(251,191,36,0.30)", color: "#fbbf24", borderRadius: 3, padding: "0 1px" }}> | |
| {text.slice(qi, qi + query.length)} | |
| </mark> | |
| {text.slice(qi + query.length)} | |
| </span> | |
| ); | |
| } | |
| // ─── S615: flat search results ──────────────────────────────────────────────── | |
| function SearchResults({ | |
| files, query, activePath, onSelect, onLongPress, | |
| }: { | |
| files: VFSFile[]; | |
| query: string; | |
| activePath: string | null; | |
| onSelect: (f: VFSFile) => void; | |
| onLongPress?: (f: VFSFile) => void; | |
| }) { | |
| const lq = query.toLowerCase(); | |
| const matches = files.filter(f => | |
| f.path.toLowerCase().includes(lq) || f.name.toLowerCase().includes(lq), | |
| ); | |
| if (matches.length === 0) { | |
| return ( | |
| <p style={{ fontSize: "0.66rem", color: "#3a3a6a", padding: "8px 10px", lineHeight: 1.4 }}> | |
| Nessun file trovato per "{query}" | |
| </p> | |
| ); | |
| } | |
| return ( | |
| <> | |
| <p style={{ fontSize: "0.58rem", color: "#3a3a6a", padding: "4px 10px 2px", letterSpacing: "0.05em" }}> | |
| {matches.length} risultat{matches.length === 1 ? "o" : "i"} | |
| </p> | |
| {matches.map(f => { | |
| const isActive = activePath === f.path; | |
| return ( | |
| <button | |
| key={f.path} | |
| onClick={() => onSelect(f)} | |
| title={f.path} | |
| style={{ | |
| display: "flex", alignItems: "center", gap: 4, width: "100%", | |
| padding: "3px 8px", | |
| background: isActive ? "rgba(59,130,246,0.15)" : "none", | |
| border: "none", cursor: "pointer", | |
| borderRadius: 6, margin: "0 2px", | |
| color: isActive ? "#93c5fd" : "#8892b0", | |
| fontSize: "0.70rem", textAlign: "left", | |
| transition: "background 0.12s", | |
| }} | |
| onMouseEnter={e => { if (!isActive) e.currentTarget.style.background = "rgba(255,255,255,0.04)"; }} | |
| onMouseLeave={e => { if (!isActive) e.currentTarget.style.background = "none"; }} | |
| > | |
| <FileText size={11} color={fileColor(f.name)} style={{ flexShrink: 0 }} /> | |
| <span style={{ flex: 1, minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}> | |
| <HighlightMatch text={f.name} query={query} /> | |
| </span> | |
| </button> | |
| ); | |
| })} | |
| </> | |
| ); | |
| } | |
| // ─── TreeRow ───────────────────────────────────────────────────────────────── | |
| function TreeRow({ | |
| node, depth, activePath, onSelect, onLongPress, | |
| }: { | |
| node: TreeNode; | |
| depth: number; | |
| activePath: string | null; | |
| onSelect: (f: VFSFile) => void; | |
| /** S800: long-press su file leaf → context sheet mobile */ | |
| onLongPress?: (f: VFSFile) => void; | |
| }) { | |
| const [expanded, setExpanded] = useState(true); | |
| const isActive = activePath === node.path; | |
| const indent = 8 + depth * 12; | |
| // S800: long-press detection per file (non cartelle) | |
| const lp = useLongPress({ | |
| onLongPress: () => { if (node.file) onLongPress?.(node.file); }, | |
| enabled: !!onLongPress && !node.isDir, | |
| }); | |
| if (node.isDir) { | |
| return ( | |
| <> | |
| <button | |
| onClick={() => setExpanded(e => !e)} | |
| style={{ | |
| display: "flex", alignItems: "center", gap: 4, width: "100%", | |
| padding: `4px 6px 4px ${indent}px`, | |
| background: "none", border: "none", cursor: "pointer", | |
| color: "#8892b0", fontSize: "0.72rem", textAlign: "left", | |
| }} | |
| > | |
| {expanded | |
| ? <ChevronDown size={10} color="#f59e0b" /> | |
| : <ChevronRight size={10} color="#f59e0b" />} | |
| {expanded | |
| ? <FolderOpen size={12} color="#f59e0b" /> | |
| : <Folder size={12} color="#f59e0b" />} | |
| <span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", flex: 1, fontWeight: 600 }}> | |
| {node.name} | |
| </span> | |
| </button> | |
| {expanded && node.children.map(child => ( | |
| <TreeRow key={child.path} node={child} depth={depth + 1} | |
| activePath={activePath} onSelect={onSelect} onLongPress={onLongPress} /> | |
| ))} | |
| </> | |
| ); | |
| } | |
| return ( | |
| <button | |
| onClick={() => node.file && onSelect(node.file)} | |
| title={node.path} | |
| {...(onLongPress ? lp : {})} | |
| style={{ | |
| display: "flex", alignItems: "center", gap: 4, width: "100%", | |
| padding: `3px 6px 3px ${indent}px`, | |
| background: isActive ? "rgba(59,130,246,0.15)" : "none", | |
| border: "none", cursor: "pointer", | |
| borderRadius: 6, margin: "0 2px", | |
| color: isActive ? "#93c5fd" : "#8892b0", | |
| fontSize: "0.72rem", textAlign: "left", | |
| transition: "background 0.12s", | |
| touchAction: "manipulation", | |
| WebkitTapHighlightColor: "transparent", | |
| } as React.CSSProperties} | |
| onMouseEnter={e => { if (!isActive) e.currentTarget.style.background = "rgba(255,255,255,0.04)"; }} | |
| onMouseLeave={e => { if (!isActive) e.currentTarget.style.background = "none"; }} | |
| > | |
| <FileText size={11} color={fileColor(node.name)} style={{ flexShrink: 0 }} /> | |
| <span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", flex: 1 }}> | |
| {node.name} | |
| </span> | |
| </button> | |
| ); | |
| } | |
| // ─── Props ──────────────────────────────────────────────────────────────────── | |
| interface FileTreeSidebarProps { | |
| /** Called when user clicks a file — parent switches tab to editor */ | |
| onRequestOpen: (file: VFSFile) => void; | |
| } | |
| // ─── Desktop sidebar ────────────────────────────────────────────────────────── | |
| export const FileTreeSidebar = memo(function FileTreeSidebar({ onRequestOpen }: FileTreeSidebarProps) { | |
| const [files, setFiles] = useState<VFSFile[]>([]); | |
| const [tree, setTree] = useState<TreeNode[]>([]); | |
| // S615: search state | |
| const [rawQuery, setRawQuery] = useState(""); | |
| const debouncedQuery = useDebounce(rawQuery, 250); | |
| const searchInputRef = useRef<HTMLInputElement | null>(null); | |
| // S620: nuovo file inline | |
| const [newFileMode, setNewFileMode] = useState(false); | |
| const [newFilePath, setNewFilePath] = useState(""); | |
| const newFileInputRef = useRef<HTMLInputElement | null>(null); | |
| const openFile = useWorkspaceStore(s => s.openFile); | |
| const showFileTree = useUIStore(s => s.showFileTree); | |
| const toggleFileTree = useUIStore(s => s.toggleFileTree); | |
| const reload = useCallback(async () => { | |
| const all = await vfsAsync.list(); | |
| setFiles(all); | |
| setTree(buildTree(all)); | |
| }, []); | |
| useEffect(() => { | |
| reload(); | |
| return onVfsChanged(reload); | |
| }, [reload]); | |
| // S723-GAP3.2: ascolta agent:vfs-update emesso da agentSSE quando il backend scrive file. | |
| // reload() rilegge vfsAsync.list() — il workspace mostra file creati dall'agente in real-time. | |
| useEffect(() => { | |
| const _h = () => { void reload(); }; | |
| window.addEventListener("agent:vfs-update", _h); | |
| return () => window.removeEventListener("agent:vfs-update", _h); | |
| }, [reload]); | |
| // S615: Ctrl+F / Cmd+F focalizza la search input quando la sidebar è aperta | |
| // S619: Ctrl+B / Cmd+B toggle FileTreeSidebar (VS Code standard) | |
| useEffect(() => { | |
| const handler = (e: KeyboardEvent) => { | |
| if (e.ctrlKey || e.metaKey) { | |
| if (e.key === "b") { | |
| e.preventDefault(); | |
| toggleFileTree(); | |
| return; | |
| } | |
| if (e.key === "f" && showFileTree && searchInputRef.current) { | |
| const active = document.activeElement; | |
| if (active?.tagName === "TEXTAREA" || (active as HTMLInputElement)?.dataset?.codemirror) return; | |
| e.preventDefault(); | |
| searchInputRef.current.focus(); | |
| } | |
| } | |
| }; | |
| window.addEventListener("keydown", handler); | |
| return () => window.removeEventListener("keydown", handler); | |
| }, [showFileTree, toggleFileTree]); | |
| const clearSearch = () => { setRawQuery(""); searchInputRef.current?.focus(); }; | |
| const isSearching = debouncedQuery.trim().length > 0; | |
| // S620: auto-focus sull'input "nuovo file" quando si entra in modalità di creazione | |
| useEffect(() => { | |
| if (newFileMode) { | |
| setTimeout(() => newFileInputRef.current?.focus(), 60); | |
| } | |
| }, [newFileMode]); | |
| // S620: crea il file nel VFS e aprilo nell'editor | |
| const handleCreateFile = useCallback(async () => { | |
| const p = newFilePath.trim(); | |
| if (!p) { setNewFileMode(false); setNewFilePath(""); return; } | |
| const normPath = p.startsWith("/") ? p : "/" + p; | |
| const exists = await vfsAsync.exists(normPath); | |
| if (!exists) { | |
| await vfsAsync.write(normPath, "", "text/plain"); | |
| } | |
| setNewFileMode(false); | |
| setNewFilePath(""); | |
| const _vfsFile = files.find(f => f.path === normPath) ?? { | |
| name: normPath.split('/').pop() ?? normPath, | |
| path: normPath, content: '', size: 0, | |
| type: 'text/plain', createdAt: Date.now(), updatedAt: Date.now(), | |
| }; | |
| onRequestOpen(_vfsFile); | |
| }, [newFilePath, onRequestOpen]); | |
| return ( | |
| <div style={{ | |
| width: showFileTree ? 180 : 28, | |
| minWidth: showFileTree ? 180 : 28, | |
| borderRight: "1px solid rgba(99,102,241,0.14)", | |
| background: "rgba(4,5,16,0.96)", | |
| display: "flex", flexDirection: "column", | |
| overflow: "hidden", | |
| transition: "width 0.22s cubic-bezier(0.4,0,0.2,1), min-width 0.22s cubic-bezier(0.4,0,0.2,1)", | |
| flexShrink: 0, | |
| }}> | |
| {/* Header */} | |
| <div style={{ | |
| display: "flex", alignItems: "center", justifyContent: "space-between", | |
| padding: "6px 6px 6px 8px", borderBottom: "1px solid rgba(99,102,241,0.10)", | |
| flexShrink: 0, minHeight: 32, | |
| }}> | |
| {showFileTree && ( | |
| <span style={{ fontSize: "0.62rem", fontWeight: 700, color: "#4a4a78", | |
| textTransform: "uppercase", letterSpacing: "0.08em", whiteSpace: "nowrap", overflow: "hidden" }}> | |
| FILE | |
| {files.length > 0 && ( | |
| <span style={{ marginLeft: 5, color: "#3b82f6", fontWeight: 600 }}> | |
| {files.length} | |
| </span> | |
| )} | |
| </span> | |
| )} | |
| {/* S620: pulsanti header (FilePlus + toggle) */} | |
| <div style={{ display: "flex", alignItems: "center", gap: 1, marginLeft: "auto" }}> | |
| {showFileTree && ( | |
| <button | |
| onClick={() => { setNewFilePath(""); setNewFileMode(true); }} | |
| title="Nuovo file (S620)" | |
| aria-label="Crea nuovo file" | |
| style={{ | |
| background: "none", border: "none", cursor: "pointer", | |
| color: "#4a4a78", padding: 3, borderRadius: 6, display: "flex", | |
| transition: "color 0.15s", | |
| }} | |
| onMouseEnter={e => { e.currentTarget.style.color = "#34d399"; }} | |
| onMouseLeave={e => { e.currentTarget.style.color = "#4a4a78"; }} | |
| > | |
| <FilePlus size={13} /> | |
| </button> | |
| )} | |
| <button | |
| onClick={toggleFileTree} | |
| title={showFileTree ? "Chiudi file tree (Ctrl+B)" : "Apri file tree (Ctrl+B)"} | |
| style={{ | |
| background: "none", border: "none", cursor: "pointer", | |
| color: "#4a4a78", padding: 3, borderRadius: 6, display: "flex", | |
| transition: "color 0.15s", | |
| }} | |
| onMouseEnter={e => { e.currentTarget.style.color = "#818cf8"; }} | |
| onMouseLeave={e => { e.currentTarget.style.color = "#4a4a78"; }} | |
| > | |
| {showFileTree | |
| ? <PanelLeftClose size={14} /> | |
| : <PanelLeftOpen size={14} />} | |
| </button> | |
| </div> | |
| </div> | |
| {/* S615: search input — solo visibile quando la sidebar è espansa */} | |
| {showFileTree && ( | |
| <div style={{ | |
| padding: "5px 6px", | |
| borderBottom: "1px solid rgba(99,102,241,0.08)", | |
| flexShrink: 0, | |
| }}> | |
| <div style={{ | |
| display: "flex", alignItems: "center", gap: 4, | |
| background: "rgba(255,255,255,0.04)", | |
| border: `1px solid ${isSearching ? "rgba(129,140,248,0.40)" : "rgba(99,102,241,0.15)"}`, | |
| borderRadius: 8, padding: "3px 6px", | |
| transition: "border-color 0.15s", | |
| }}> | |
| <Search size={11} color={isSearching ? "#818cf8" : "#3a3a6a"} style={{ flexShrink: 0 }} /> | |
| <input | |
| ref={searchInputRef} | |
| value={rawQuery} | |
| onChange={e => setRawQuery(e.target.value)} | |
| onKeyDown={e => { if (e.key === "Escape") clearSearch(); }} | |
| placeholder="Cerca file…" | |
| aria-label="Cerca file nel VFS" | |
| style={{ | |
| flex: 1, background: "none", border: "none", outline: "none", | |
| color: "#c7d2fe", fontSize: "0.68rem", | |
| caretColor: "#818cf8", | |
| }} | |
| /> | |
| {rawQuery && ( | |
| <button | |
| onClick={clearSearch} | |
| aria-label="Cancella ricerca" | |
| style={{ | |
| background: "none", border: "none", cursor: "pointer", | |
| color: "#4a4a78", padding: 0, display: "flex", | |
| transition: "color 0.15s", | |
| }} | |
| onMouseEnter={e => { e.currentTarget.style.color = "#ef4444"; }} | |
| onMouseLeave={e => { e.currentTarget.style.color = "#4a4a78"; }} | |
| > | |
| <X size={10} /> | |
| </button> | |
| )} | |
| </div> | |
| </div> | |
| )} | |
| {/* S620: input inline "nuovo file" — visibile solo in newFileMode */} | |
| {showFileTree && newFileMode && ( | |
| <div style={{ | |
| padding: "5px 6px", | |
| borderBottom: "1px solid rgba(52,211,153,0.20)", | |
| flexShrink: 0, | |
| background: "rgba(52,211,153,0.04)", | |
| }}> | |
| <div style={{ | |
| display: "flex", alignItems: "center", gap: 4, | |
| border: "1px solid rgba(52,211,153,0.50)", | |
| borderRadius: 8, padding: "3px 6px", | |
| }}> | |
| <FilePlus size={11} color="#34d399" style={{ flexShrink: 0 }} /> | |
| <input | |
| ref={newFileInputRef} | |
| value={newFilePath} | |
| onChange={e => setNewFilePath(e.target.value)} | |
| onKeyDown={e => { | |
| if (e.key === "Enter") { e.preventDefault(); handleCreateFile(); } | |
| if (e.key === "Escape") { setNewFileMode(false); setNewFilePath(""); } | |
| }} | |
| placeholder="src/nuovo.ts…" | |
| aria-label="Path del nuovo file" | |
| style={{ | |
| flex: 1, background: "none", border: "none", outline: "none", | |
| color: "#6ee7b7", fontSize: "0.68rem", | |
| caretColor: "#34d399", | |
| }} | |
| /> | |
| <button | |
| onClick={() => { setNewFileMode(false); setNewFilePath(""); }} | |
| aria-label="Annulla nuovo file" | |
| style={{ | |
| background: "none", border: "none", cursor: "pointer", | |
| color: "#4a4a78", padding: 0, display: "flex", | |
| transition: "color 0.15s", | |
| }} | |
| onMouseEnter={e => { e.currentTarget.style.color = "#ef4444"; }} | |
| onMouseLeave={e => { e.currentTarget.style.color = "#4a4a78"; }} | |
| > | |
| <X size={10} /> | |
| </button> | |
| </div> | |
| </div> | |
| )} | |
| {/* Tree / Search results */} | |
| {showFileTree && ( | |
| <div style={{ flex: 1, overflow: "auto", padding: "4px 0" }}> | |
| {isSearching ? ( | |
| /* S615: risultati flat con highlight */ | |
| <SearchResults | |
| files={files} | |
| query={debouncedQuery.trim()} | |
| activePath={openFile?.path ?? null} | |
| onSelect={onRequestOpen} | |
| /> | |
| ) : tree.length === 0 ? ( | |
| <p style={{ fontSize: "0.66rem", color: "#3a3a6a", padding: "8px 12px", lineHeight: 1.4 }}> | |
| Nessun file nel VFS.{"\n"}L'agente creerà i file qui. | |
| </p> | |
| ) : ( | |
| tree.map(node => ( | |
| <TreeRow | |
| key={node.path} | |
| node={node} | |
| depth={0} | |
| activePath={openFile?.path ?? null} | |
| onSelect={onRequestOpen} | |
| /> | |
| )) | |
| )} | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| }); | |
| // ─── Mobile drawer (bottom sheet) ──────────────────────────────────────────── | |
| export function MobileFileTreeDrawer({ onRequestOpen }: FileTreeSidebarProps) { | |
| const [open, setOpen] = useState(false); | |
| const [files, setFiles] = useState<VFSFile[]>([]); | |
| const [tree, setTree] = useState<TreeNode[]>([]); | |
| const [contextFile, setContextFile] = useState<VFSFile | null>(null); // S800: long-press context | |
| // S615: search nel drawer mobile | |
| const [rawQuery, setRawQuery] = useState(""); | |
| const debouncedQuery = useDebounce(rawQuery, 250); | |
| const openFile = useWorkspaceStore(s => s.openFile); | |
| const reload = useCallback(async () => { | |
| const all = await vfsAsync.list(); | |
| setFiles(all); | |
| setTree(buildTree(all)); | |
| }, []); | |
| useEffect(() => { | |
| reload(); | |
| return onVfsChanged(reload); | |
| }, [reload]); | |
| const isSearching = debouncedQuery.trim().length > 0; | |
| return ( | |
| <> | |
| {/* FAB button */} | |
| <button | |
| onClick={() => setOpen(v => !v)} | |
| title="File tree" | |
| style={{ | |
| position: "fixed", bottom: 72, right: 14, | |
| zIndex: Z_INDEX.MODAL_BACKDROP, | |
| width: 38, height: 38, borderRadius: "50%", | |
| background: "rgba(99,102,241,0.85)", | |
| border: "1px solid rgba(129,140,248,0.4)", | |
| boxShadow: "0 4px 16px rgba(99,102,241,0.35)", | |
| cursor: "pointer", color: "#fff", | |
| display: "flex", alignItems: "center", justifyContent: "center", | |
| transition: "transform 0.18s, background 0.18s", | |
| }} | |
| onMouseEnter={e => { e.currentTarget.style.transform = "scale(1.08)"; }} | |
| onMouseLeave={e => { e.currentTarget.style.transform = "scale(1)"; }} | |
| > | |
| <Folder size={16} /> | |
| {files.length > 0 && ( | |
| <span style={{ | |
| position: "absolute", top: -4, right: -4, | |
| background: "#3b82f6", color: "#fff", | |
| fontSize: "0.55rem", fontWeight: 700, | |
| borderRadius: "50%", width: 16, height: 16, | |
| display: "flex", alignItems: "center", justifyContent: "center", | |
| }}> | |
| {files.length > 9 ? "9+" : files.length} | |
| </span> | |
| )} | |
| </button> | |
| {/* Backdrop */} | |
| {open && ( | |
| <div | |
| onClick={() => setOpen(false)} | |
| style={{ | |
| position: "fixed", inset: 0, zIndex: Z_INDEX.MODAL, | |
| background: "rgba(0,0,0,0.45)", | |
| }} | |
| /> | |
| )} | |
| {/* Bottom sheet */} | |
| <div style={{ | |
| position: "fixed", left: 0, right: 0, bottom: 0, | |
| zIndex: Z_INDEX.DRAWER_CONTENT, | |
| height: "65vh", | |
| background: "rgba(8,9,24,0.98)", | |
| borderTop: "1px solid rgba(99,102,241,0.3)", | |
| borderRadius: "18px 18px 0 0", | |
| display: "flex", flexDirection: "column", | |
| overflow: "hidden", | |
| transform: open ? "translateY(0)" : "translateY(100%)", | |
| transition: "transform 0.28s cubic-bezier(0.4,0,0.2,1)", | |
| boxShadow: "0 -12px 40px rgba(0,0,0,0.5)", | |
| paddingBottom: "max(8px, env(safe-area-inset-bottom, 0px))", | |
| }}> | |
| {/* Handle + header */} | |
| <div style={{ | |
| display: "flex", alignItems: "center", justifyContent: "space-between", | |
| padding: "10px 16px 8px", | |
| borderBottom: "1px solid rgba(99,102,241,0.12)", | |
| flexShrink: 0, | |
| }}> | |
| <div style={{ display: "flex", alignItems: "center", gap: 8 }}> | |
| <div style={{ | |
| width: 36, height: 4, borderRadius: 3, | |
| background: "rgba(255,255,255,0.15)", | |
| position: "absolute", top: 6, left: "50%", transform: "translateX(-50%)", | |
| }} /> | |
| <Folder size={14} color="#818cf8" /> | |
| <span style={{ fontSize: "0.78rem", fontWeight: 600, color: "#c7d2fe" }}> | |
| File VFS | |
| </span> | |
| <span style={{ fontSize: "0.68rem", color: "#4a4a78" }}> | |
| ({files.length} file) | |
| </span> | |
| </div> | |
| <button | |
| onClick={() => setOpen(false)} | |
| style={{ | |
| background: "none", border: "none", cursor: "pointer", | |
| color: "#4a4a78", padding: 4, borderRadius: 4, display: "flex", | |
| }} | |
| > | |
| <X size={16} /> | |
| </button> | |
| </div> | |
| {/* S615: search input mobile */} | |
| <div style={{ padding: "8px 12px 6px", flexShrink: 0 }}> | |
| <div style={{ | |
| display: "flex", alignItems: "center", gap: 6, | |
| background: "rgba(255,255,255,0.05)", | |
| border: `1px solid ${isSearching ? "rgba(129,140,248,0.4)" : "rgba(99,102,241,0.18)"}`, | |
| borderRadius: 8, padding: "6px 10px", | |
| transition: "border-color 0.15s", | |
| }}> | |
| <Search size={14} color={isSearching ? "#818cf8" : "#3a3a6a"} /> | |
| <input | |
| value={rawQuery} | |
| onChange={e => setRawQuery(e.target.value)} | |
| onKeyDown={e => { if (e.key === "Escape") setRawQuery(""); }} | |
| placeholder="Cerca file…" | |
| autoComplete="off" | |
| style={{ | |
| flex: 1, background: "none", border: "none", outline: "none", | |
| color: "#c7d2fe", fontSize: "0.82rem", | |
| caretColor: "#818cf8", | |
| }} | |
| /> | |
| {rawQuery && ( | |
| <button | |
| onClick={() => setRawQuery("")} | |
| style={{ background: "none", border: "none", cursor: "pointer", color: "#4a4a78", padding: 0 }} | |
| > | |
| <X size={13} /> | |
| </button> | |
| )} | |
| </div> | |
| </div> | |
| {/* Tree / Search results */} | |
| <div style={{ flex: 1, overflow: "auto", padding: "4px 0" }}> | |
| {isSearching ? ( | |
| <SearchResults | |
| files={files} | |
| query={debouncedQuery.trim()} | |
| activePath={openFile?.path ?? null} | |
| onSelect={(f) => { onRequestOpen(f); setOpen(false); setRawQuery(""); }} | |
| onLongPress={(f) => { setContextFile(f); }} | |
| /> | |
| ) : tree.length === 0 ? ( | |
| <p style={{ fontSize: "0.72rem", color: "#3a3a6a", padding: "12px 16px", lineHeight: 1.5 }}> | |
| Nessun file nel VFS.{"\n"}L'agente creerà i file qui. | |
| </p> | |
| ) : ( | |
| tree.map(node => ( | |
| <TreeRow | |
| key={node.path} | |
| node={node} | |
| depth={0} | |
| activePath={openFile?.path ?? null} | |
| onSelect={(f) => { onRequestOpen(f); setOpen(false); }} | |
| onLongPress={(f) => { setContextFile(f); }} | |
| /> | |
| )) | |
| )} | |
| </div> | |
| </div> | |
| {/* S800: context sheet long-press */} | |
| <MobileFileContextSheet | |
| file={contextFile} | |
| onClose={() => setContextFile(null)} | |
| onFileChanged={(oldPath, newPath) => { | |
| // Se il file aperto era quello rinominato/eliminato, gestiscilo | |
| if (openFile?.path === oldPath) { | |
| onRequestOpen({ ...(openFile!), path: newPath ?? "", name: newPath?.split("/").pop() ?? "" } as VFSFile); | |
| } | |
| setContextFile(null); | |
| }} | |
| /> | |
| </> | |
| ); | |
| } | |