AUDIT / src /components /workspace /FileTreeSidebar.tsx
Arypulka98's picture
feat(audit): deploy full backend cluster node (part 2)
cc11e77 verified
Raw
History Blame Contribute Delete
28.7 kB
/**
* 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);
}}
/>
</>
);
}