Spaces:
Sleeping
Sleeping
| /** | |
| * RecentFileTabs.tsx — S613: tab strip file recenti sopra il code editor | |
| * | |
| * Mostra gli ultimi MAX_RECENT file aperti come tab cliccabili (pattern VS Code). | |
| * Integrato nella riga sopra FileEditor nel top Panel del PanelGroup S612. | |
| * | |
| * - Click tab → apre il file (setOpenFile + setRequestOpenPath) | |
| * - × → chiude il tab (closeRecentFile; se era il file attivo, apre il precedente) | |
| * - Tab attivo evidenziato in blu | |
| * - Overflow orizzontale (no wrap) con scroll invisibile | |
| * | |
| * S614: import type { CSSProperties, RefObject } — import diretto da "react", non prefissato | |
| * data-path su FileTab per useActiveTabScroll | |
| * auto-scroll del tab attivo nella viewport | |
| * | |
| * S616: badge arancione "●" sui tab modificati dall'agente mentre erano in background. | |
| * onVfsChanged → markDirty(path) per file in recentFiles che non sono aperti. | |
| * Apertura tab → clearDirty(path) automatica via setOpenFile. | |
| */ | |
| import { useRef, useEffect, memo } from "react"; | |
| import type { CSSProperties, RefObject } from "react"; | |
| import { X } from "lucide-react"; | |
| import { useWorkspaceStore } from "@/store/workspaceStore"; | |
| import { onVfsFileChanged } from "@/lib/vfsDb"; // S619: usa onVfsFileChanged per dirty badge preciso | |
| import type { VFSFile } from "@/lib/vfsDb"; | |
| // ─── Icon helpers ───────────────────────────────────────────────────────────── | |
| function fileIcon(name: string): string { | |
| if (name.endsWith(".py")) return "🐍"; | |
| if (name.endsWith(".ts") || name.endsWith(".tsx")) return "📘"; | |
| if (name.endsWith(".js") || name.endsWith(".jsx")) return "📜"; | |
| if (name.endsWith(".json")) return "📋"; | |
| if (name.endsWith(".html")) return "🌐"; | |
| if (name.endsWith(".css") || name.endsWith(".scss")) return "🎨"; | |
| if (name.endsWith(".md")) return "📝"; | |
| if (name.endsWith(".sh")) return "⚙️"; | |
| return "📄"; | |
| } | |
| // ─── Single tab ─────────────────────────────────────────────────────────────── | |
| function FileTab({ | |
| file, | |
| isActive, | |
| isDirty, | |
| onOpen, | |
| onClose, | |
| }: { | |
| file: VFSFile; | |
| isActive: boolean; | |
| isDirty: boolean; | |
| onOpen: (f: VFSFile) => void; | |
| onClose: (path: string) => void; | |
| }) { | |
| return ( | |
| <div | |
| role="tab" | |
| aria-selected={isActive} | |
| data-path={file.path} | |
| onClick={() => onOpen(file)} | |
| title={isDirty ? `${file.path} — modificato dall'agente` : file.path} | |
| style={{ | |
| display: "flex", alignItems: "center", gap: 4, | |
| padding: "0 10px 0 8px", | |
| height: "100%", | |
| flexShrink: 0, | |
| cursor: "pointer", | |
| background: isActive ? "rgba(99,102,241,0.12)" : "transparent", | |
| borderRight: "1px solid rgba(99,102,241,0.08)", | |
| borderBottom: isActive ? "2px solid #818cf8" : "2px solid transparent", | |
| color: isActive ? "#c7d2fe" : "#4a4a78", | |
| fontSize: "0.68rem", | |
| transition: "color 0.15s, background 0.15s, border-color 0.15s", | |
| userSelect: "none", | |
| maxWidth: 160, | |
| minWidth: 60, | |
| position: "relative", | |
| }} | |
| onMouseEnter={e => { | |
| if (!isActive) { | |
| e.currentTarget.style.color = "#818cf8"; | |
| e.currentTarget.style.background = "rgba(99,102,241,0.06)"; | |
| } | |
| }} | |
| onMouseLeave={e => { | |
| if (!isActive) { | |
| e.currentTarget.style.color = "#4a4a78"; | |
| e.currentTarget.style.background = "transparent"; | |
| } | |
| }} | |
| > | |
| {/* S616: dirty dot — arancione, pulsa quando il file è stato modificato */} | |
| {isDirty && !isActive && ( | |
| <span | |
| aria-label="File modificato dall'agente" | |
| style={{ | |
| display: "inline-block", | |
| width: 6, height: 6, | |
| borderRadius: "50%", | |
| background: "#f97316", | |
| flexShrink: 0, | |
| animation: "mem-tab-pulse 1.5s ease-in-out infinite", | |
| }} | |
| /> | |
| )} | |
| <span style={{ fontSize: "0.72rem", flexShrink: 0 }}>{fileIcon(file.name)}</span> | |
| <span style={{ | |
| overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", | |
| flex: 1, minWidth: 0, | |
| color: isDirty && !isActive ? "#fb923c" : undefined, | |
| }}> | |
| {file.name} | |
| </span> | |
| <button | |
| onClick={e => { e.stopPropagation(); onClose(file.path); }} | |
| aria-label={`Chiudi ${file.name}`} | |
| style={{ | |
| display: "flex", alignItems: "center", justifyContent: "center", | |
| width: 14, height: 14, flexShrink: 0, | |
| background: "none", border: "none", cursor: "pointer", | |
| color: "inherit", borderRadius: 4, padding: 0, | |
| opacity: 0.6, | |
| transition: "opacity 0.15s, background 0.15s", | |
| }} | |
| onMouseEnter={e => { e.currentTarget.style.opacity = "1"; e.currentTarget.style.background = "rgba(239,68,68,0.20)"; }} | |
| onMouseLeave={e => { e.currentTarget.style.opacity = "0.6"; e.currentTarget.style.background = "none"; }} | |
| > | |
| <X size={10} strokeWidth={2.5} /> | |
| </button> | |
| </div> | |
| ); | |
| } | |
| // ─── Tab strip ──────────────────────────────────────────────────────────────── | |
| // ─── Active tab auto-scroll ────────────────────────────────────────────────── S614 | |
| function useActiveTabScroll( | |
| containerRef: RefObject<HTMLDivElement | null>, | |
| activePath: string | null, | |
| ) { | |
| useEffect(() => { | |
| if (!containerRef.current || !activePath) return; | |
| const el = containerRef.current.querySelector( | |
| `[data-path="${CSS.escape(activePath)}"]`, | |
| ) as HTMLElement | null; | |
| el?.scrollIntoView({ behavior: "smooth", block: "nearest", inline: "nearest" }); | |
| }, [activePath, containerRef]); | |
| } | |
| export const RecentFileTabs = memo(function RecentFileTabs() { | |
| const recentFiles = useWorkspaceStore(s => s.recentFiles); | |
| const openFile = useWorkspaceStore(s => s.openFile); | |
| const dirtyPaths = useWorkspaceStore(s => s.dirtyPaths); // S616 | |
| const setOpenFile = useWorkspaceStore(s => s.setOpenFile); | |
| const setRequestOpenPath = useWorkspaceStore(s => s.setRequestOpenPath); | |
| const closeRecentFile = useWorkspaceStore(s => s.closeRecentFile); | |
| const containerRef = useRef<HTMLDivElement | null>(null); | |
| // S614: scroll il tab attivo nella viewport quando cambia | |
| useActiveTabScroll(containerRef, openFile?.path ?? null); | |
| // S619: usa onVfsFileChanged per dirty badge preciso — segna solo il file effettivamente modificato | |
| // (fix S616: era onVfsChanged che marcava TUTTI i tab non-attivi ad ogni write) | |
| useEffect(() => { | |
| return onVfsFileChanged((changedPath) => { | |
| const { recentFiles: rf, openFile: of, markDirty: md } = | |
| useWorkspaceStore.getState(); | |
| // Marca dirty solo se il file è nei tab recenti E non è quello attivo | |
| const isRecent = rf.some(f => f.path === changedPath); | |
| if (isRecent && of?.path !== changedPath) { | |
| md(changedPath); | |
| } | |
| }); | |
| }, []); | |
| // Non renderizzare se non ci sono file recenti | |
| if (recentFiles.length === 0) return null; | |
| const handleOpen = (file: VFSFile) => { | |
| setOpenFile(file); // S616: clearDirty avviene dentro setOpenFile | |
| setRequestOpenPath(file.path); | |
| }; | |
| const handleClose = (path: string) => { | |
| closeRecentFile(path); // S616: clearDirty avviene dentro closeRecentFile | |
| }; | |
| return ( | |
| <div | |
| ref={containerRef} | |
| role="tablist" | |
| aria-label="File recenti" | |
| style={{ | |
| display: "flex", | |
| alignItems: "stretch", | |
| height: 32, | |
| background: "rgba(4,5,16,0.98)", | |
| borderBottom: "1px solid rgba(99,102,241,0.12)", | |
| overflowX: "auto", | |
| overflowY: "hidden", | |
| flexShrink: 0, | |
| // Nascondi scrollbar ma permetti scroll | |
| scrollbarWidth: "none", | |
| msOverflowStyle: "none", | |
| } as CSSProperties} | |
| > | |
| {recentFiles.map(file => ( | |
| <FileTab | |
| key={file.path} | |
| file={file} | |
| isActive={openFile?.path === file.path} | |
| isDirty={dirtyPaths.has(file.path)} | |
| onOpen={handleOpen} | |
| onClose={handleClose} | |
| /> | |
| ))} | |
| </div> | |
| ); | |
| }); | |