/** * 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 = { 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(); 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(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 {text}; const lc = text.toLowerCase(); const qi = lc.indexOf(query.toLowerCase()); if (qi === -1) return {text}; return ( {text.slice(0, qi)} {text.slice(qi, qi + query.length)} {text.slice(qi + query.length)} ); } // ─── 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 (

Nessun file trovato per "{query}"

); } return ( <>

{matches.length} risultat{matches.length === 1 ? "o" : "i"}

{matches.map(f => { const isActive = activePath === f.path; return ( ); })} ); } // ─── 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 ( <> {expanded && node.children.map(child => ( ))} ); } return ( ); } // ─── 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([]); const [tree, setTree] = useState([]); // S615: search state const [rawQuery, setRawQuery] = useState(""); const debouncedQuery = useDebounce(rawQuery, 250); const searchInputRef = useRef(null); // S620: nuovo file inline const [newFileMode, setNewFileMode] = useState(false); const [newFilePath, setNewFilePath] = useState(""); const newFileInputRef = useRef(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 (
{/* Header */}
{showFileTree && ( FILE {files.length > 0 && ( {files.length} )} )} {/* S620: pulsanti header (FilePlus + toggle) */}
{showFileTree && ( )}
{/* S615: search input — solo visibile quando la sidebar è espansa */} {showFileTree && (
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 && ( )}
)} {/* S620: input inline "nuovo file" — visibile solo in newFileMode */} {showFileTree && newFileMode && (
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", }} />
)} {/* Tree / Search results */} {showFileTree && (
{isSearching ? ( /* S615: risultati flat con highlight */ ) : tree.length === 0 ? (

Nessun file nel VFS.{"\n"}L'agente creerà i file qui.

) : ( tree.map(node => ( )) )}
)}
); }); // ─── Mobile drawer (bottom sheet) ──────────────────────────────────────────── export function MobileFileTreeDrawer({ onRequestOpen }: FileTreeSidebarProps) { const [open, setOpen] = useState(false); const [files, setFiles] = useState([]); const [tree, setTree] = useState([]); const [contextFile, setContextFile] = useState(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 */} {/* Backdrop */} {open && (
setOpen(false)} style={{ position: "fixed", inset: 0, zIndex: Z_INDEX.MODAL, background: "rgba(0,0,0,0.45)", }} /> )} {/* Bottom sheet */}
{/* Handle + header */}
File VFS ({files.length} file)
{/* S615: search input mobile */}
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 && ( )}
{/* Tree / Search results */}
{isSearching ? ( { onRequestOpen(f); setOpen(false); setRawQuery(""); }} onLongPress={(f) => { setContextFile(f); }} /> ) : tree.length === 0 ? (

Nessun file nel VFS.{"\n"}L'agente creerà i file qui.

) : ( tree.map(node => ( { onRequestOpen(f); setOpen(false); }} onLongPress={(f) => { setContextFile(f); }} /> )) )}
{/* S800: context sheet long-press */} 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); }} /> ); }