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