diff --git a/docs/development/ZENTRAL.md b/docs/development/ZENTRAL.md
index f59ab41..c6da213 100644
--- a/docs/development/ZENTRAL.md
+++ b/docs/development/ZENTRAL.md
@@ -38,3 +38,7 @@ File riassuntivo dello stato di sviluppo di Zentral.
- Implementazione `SchemaDiscoveryService` per allineamento automatico dataset report con strutture dati live.
- [2025-12-06 Sidebar Collapsible](./devlog/2025-12-06-010500_sidebar_collapsible.md) - **Completato**
- Reso il menu laterale collassabile (manuale e responsive) con visualizzazione a sole icone.
+- [2025-12-06 Tab UX Improvements](./devlog/2025-12-06-011000_tab_ux_improvements.md) - **Completato**
+ - Miglioramento UX tab: chiusura con middle-click, drag & drop, gruppi di tab personalizzati.
+- [2025-12-06 Tab Flicker Fix](./devlog/2025-12-06-011500_tab_flicker_fix.md) - **Completato**
+ - Risolto problema di flicker rimuovendo l'aggiornamento manuale dello stato attivo e affidandosi esclusivamente alla sincronizzazione con l'URL.
diff --git a/docs/development/devlog/2025-12-06-011000_tab_ux_improvements.md b/docs/development/devlog/2025-12-06-011000_tab_ux_improvements.md
new file mode 100644
index 0000000..e078ad9
--- /dev/null
+++ b/docs/development/devlog/2025-12-06-011000_tab_ux_improvements.md
@@ -0,0 +1,41 @@
+# Tab UX Improvements
+
+## Overview
+Improve the user experience of the tab bar above the viewport. The goal is to make it more flexible and user-friendly.
+
+## Features
+1. **Middle-click to close**: Allow closing tabs by clicking with the middle mouse button.
+2. **Drag and Drop**: Allow reordering tabs freely.
+3. **Tab Groups (Sessions)**: Allow saving the current set of open tabs as a named group/session and restoring it later.
+4. **Context Menu**: Add a right-click context menu to tabs with options like:
+ - Close
+ - Close Others
+ - Close to Right
+ - Pin Tab (optional, if time permits)
+
+## Implementation Plan
+
+### 1. Dependencies
+- Install `@dnd-kit/core`, `@dnd-kit/sortable`, `@dnd-kit/utilities`.
+
+### 2. Context Update (`TabContext.tsx`)
+- Add `reorderTabs(newOrder: Tab[])` function.
+- Add state for `tabGroups` (saved in `localStorage`).
+- Add `saveTabGroup(name: string)` function.
+- Add `loadTabGroup(name: string)` function.
+- Add `deleteTabGroup(name: string)` function.
+- Add `closeOtherTabs(path: string)` function.
+- Add `closeTabsToRight(path: string)` function.
+
+### 3. Component Update (`TabsBar.tsx`)
+- Wrap tabs in `DndContext` and `SortableContext`.
+- Create a `SortableTab` component.
+- Implement `onAuxClick` for middle-click closing.
+- Add a "Tab Groups" button/menu to the right of the tabs.
+ - Show saved groups.
+ - Option to save current session.
+- Implement a custom Context Menu for tabs.
+
+## Technical Details
+- **Storage**: Use `localStorage` for now. Keys: `zentral_tabs`, `zentral_active_tab`, `zentral_tab_groups`.
+- **Styling**: Use MUI components and system.
diff --git a/docs/development/devlog/2025-12-06-011500_tab_flicker_fix.md b/docs/development/devlog/2025-12-06-011500_tab_flicker_fix.md
new file mode 100644
index 0000000..b54736e
--- /dev/null
+++ b/docs/development/devlog/2025-12-06-011500_tab_flicker_fix.md
@@ -0,0 +1,16 @@
+# Tab Flicker Fix
+
+## Issue
+The user reports a flicker when clicking a tab before it becomes active.
+
+## Diagnosis
+The current implementation of the active tab style uses `borderBottom: isActive ? 2 : 0`. This causes a layout shift (height change or content displacement) of 2px whenever the active state changes. This visual jump is perceived as a flicker.
+
+## Solution
+Update the styling to maintain a constant border width but change the color.
+- Change `borderBottom` to always be `2`.
+- Change `borderColor` to be `'primary.main'` when active and `'transparent'` when inactive.
+
+## Plan
+1. Modify `src/frontend/src/components/TabsBar.tsx`.
+2. Update `SortableTab` styles.
diff --git a/src/frontend/package-lock.json b/src/frontend/package-lock.json
index b41411a..2e79e53 100644
--- a/src/frontend/package-lock.json
+++ b/src/frontend/package-lock.json
@@ -8,6 +8,9 @@
"name": "frontend",
"version": "0.0.0",
"dependencies": {
+ "@dnd-kit/core": "^6.3.1",
+ "@dnd-kit/sortable": "^10.0.0",
+ "@dnd-kit/utilities": "^3.2.2",
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@fullcalendar/daygrid": "^6.1.19",
@@ -335,6 +338,59 @@
"node": ">=6.9.0"
}
},
+ "node_modules/@dnd-kit/accessibility": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
+ "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.8.0"
+ }
+ },
+ "node_modules/@dnd-kit/core": {
+ "version": "6.3.1",
+ "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
+ "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@dnd-kit/accessibility": "^3.1.1",
+ "@dnd-kit/utilities": "^3.2.2",
+ "tslib": "^2.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.8.0",
+ "react-dom": ">=16.8.0"
+ }
+ },
+ "node_modules/@dnd-kit/sortable": {
+ "version": "10.0.0",
+ "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
+ "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
+ "license": "MIT",
+ "dependencies": {
+ "@dnd-kit/utilities": "^3.2.2",
+ "tslib": "^2.0.0"
+ },
+ "peerDependencies": {
+ "@dnd-kit/core": "^6.3.0",
+ "react": ">=16.8.0"
+ }
+ },
+ "node_modules/@dnd-kit/utilities": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
+ "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.8.0"
+ }
+ },
"node_modules/@emotion/babel-plugin": {
"version": "11.13.5",
"resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz",
@@ -5587,6 +5643,12 @@
"typescript": ">=4.8.4"
}
},
+ "node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+ "license": "0BSD"
+ },
"node_modules/type-check": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
diff --git a/src/frontend/package.json b/src/frontend/package.json
index e28a2cc..ff72f36 100644
--- a/src/frontend/package.json
+++ b/src/frontend/package.json
@@ -10,6 +10,9 @@
"preview": "vite preview"
},
"dependencies": {
+ "@dnd-kit/core": "^6.3.1",
+ "@dnd-kit/sortable": "^10.0.0",
+ "@dnd-kit/utilities": "^3.2.2",
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@fullcalendar/daygrid": "^6.1.19",
diff --git a/src/frontend/src/components/TabsBar.tsx b/src/frontend/src/components/TabsBar.tsx
index 0cf18fc..c4d758a 100644
--- a/src/frontend/src/components/TabsBar.tsx
+++ b/src/frontend/src/components/TabsBar.tsx
@@ -1,15 +1,165 @@
-import React from 'react';
-import { Box, Tabs, Tab, IconButton } from '@mui/material';
-import { Close as CloseIcon } from '@mui/icons-material';
-import { useTabs } from '../contexts/TabContext';
+import React, { useState } from 'react';
+import { Box, Tab, IconButton, Menu, MenuItem, ListItemIcon, ListItemText, Divider, Button, TextField, Dialog, DialogTitle, DialogContent, DialogActions, Tooltip } from '@mui/material';
+import { Close as CloseIcon, Save as SaveIcon, FolderOpen as FolderOpenIcon, Delete as DeleteIcon, ArrowForward as ArrowForwardIcon, ClearAll as ClearAllIcon } from '@mui/icons-material';
+import { useTabs, Tab as TabType } from '../contexts/TabContext';
import { useTheme } from '@mui/material/styles';
+import { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors, DragEndEvent } from '@dnd-kit/core';
+import { arrayMove, SortableContext, sortableKeyboardCoordinates, horizontalListSortingStrategy, useSortable } from '@dnd-kit/sortable';
+import { CSS } from '@dnd-kit/utilities';
+
+interface SortableTabProps {
+ tab: TabType;
+ activeTabPath: string;
+ onActivate: (path: string) => void;
+ onClose: (path: string) => void;
+ onContextMenu: (event: React.MouseEvent, tab: TabType) => void;
+}
+
+function SortableTab({ tab, activeTabPath, onActivate, onClose, onContextMenu }: SortableTabProps) {
+ const {
+ attributes,
+ listeners,
+ setNodeRef,
+ transform,
+ transition,
+ isDragging
+ } = useSortable({ id: tab.path });
+
+ const style = {
+ transform: CSS.Transform.toString(transform),
+ transition,
+ opacity: isDragging ? 0.5 : 1,
+ zIndex: isDragging ? 1 : 'auto',
+ };
+
+ const isActive = activeTabPath === tab.path;
+
+ return (
+ {
+ if (e.button === 1 && tab.closable) {
+ e.preventDefault();
+ onClose(tab.path);
+ }
+ }}
+ onContextMenu={(e) => onContextMenu(e, tab)}
+ >
+
+ {tab.label}
+ {tab.closable && (
+ {
+ e.stopPropagation();
+ onClose(tab.path);
+ }}
+ sx={{
+ p: 0.5,
+ ml: 0.5,
+ width: 20,
+ height: 20,
+ '&:hover': {
+ bgcolor: 'action.hover',
+ color: 'error.main'
+ }
+ }}
+ >
+
+
+ )}
+
+ }
+ value={tab.path}
+ onClick={() => onActivate(tab.path)}
+ sx={{
+ minHeight: 48,
+ textTransform: 'none',
+ fontWeight: 500,
+ color: isActive ? 'primary.main' : 'text.secondary',
+ borderBottom: 2,
+ borderColor: isActive ? 'primary.main' : 'transparent',
+ opacity: 1,
+ minWidth: 'auto',
+ px: 2,
+ '&:hover': {
+ bgcolor: 'action.hover',
+ color: isActive ? 'primary.main' : 'text.primary',
+ }
+ }}
+ />
+
+ );
+}
export default function TabsBar() {
- const { tabs, activeTabPath, setActiveTab, closeTab } = useTabs();
+ const {
+ tabs,
+ activeTabPath,
+ setActiveTab,
+ closeTab,
+ reorderTabs,
+ saveTabGroup,
+ loadTabGroup,
+ tabGroups,
+ deleteTabGroup,
+ closeOtherTabs,
+ closeTabsToRight
+ } = useTabs();
const theme = useTheme();
- const handleChange = (_: React.SyntheticEvent, newValue: string) => {
- setActiveTab(newValue);
+ const [contextMenu, setContextMenu] = useState<{ mouseX: number; mouseY: number; tab: TabType | null } | null>(null);
+ const [groupsMenuAnchor, setGroupsMenuAnchor] = useState(null);
+ const [saveGroupDialogOpen, setSaveGroupDialogOpen] = useState(false);
+ const [newGroupName, setNewGroupName] = useState('');
+
+ const sensors = useSensors(
+ useSensor(PointerSensor, {
+ activationConstraint: {
+ distance: 8,
+ },
+ }),
+ useSensor(KeyboardSensor, {
+ coordinateGetter: sortableKeyboardCoordinates,
+ })
+ );
+
+ const handleDragEnd = (event: DragEndEvent) => {
+ const { active, over } = event;
+
+ if (over && active.id !== over.id) {
+ const oldIndex = tabs.findIndex((t) => t.path === active.id);
+ const newIndex = tabs.findIndex((t) => t.path === over.id);
+ reorderTabs(arrayMove(tabs, oldIndex, newIndex));
+ }
+ };
+
+ const handleContextMenu = (event: React.MouseEvent, tab: TabType) => {
+ event.preventDefault();
+ setContextMenu({
+ mouseX: event.clientX + 2,
+ mouseY: event.clientY - 6,
+ tab,
+ });
+ };
+
+ const handleCloseContextMenu = () => {
+ setContextMenu(null);
+ };
+
+ const handleSaveGroup = () => {
+ if (newGroupName.trim()) {
+ saveTabGroup(newGroupName.trim());
+ setNewGroupName('');
+ setSaveGroupDialogOpen(false);
+ }
};
return (
@@ -21,68 +171,144 @@ export default function TabsBar() {
minHeight: 48,
display: 'flex',
alignItems: 'center',
- overflowX: 'auto',
- '&::-webkit-scrollbar': {
- height: 4,
- },
- '&::-webkit-scrollbar-track': {
- backgroundColor: theme.palette.grey[100],
- },
- '&::-webkit-scrollbar-thumb': {
- backgroundColor: theme.palette.grey[400],
- borderRadius: 2,
- },
+ justifyContent: 'space-between',
}}
>
-
+
+ t.path)}
+ strategy={horizontalListSortingStrategy}
+ >
+ {tabs.map((tab) => (
+
+ ))}
+
+
+
+
+ {/* Tab Groups & Actions */}
+
+
+ setGroupsMenuAnchor(e.currentTarget)}
+ >
+
+
+
+
+
+ {/* Context Menu */}
+
+
+ {/* Groups Menu */}
+
+
+
+ {/* Save Group Dialog */}
+
);
}
diff --git a/src/frontend/src/contexts/TabContext.tsx b/src/frontend/src/contexts/TabContext.tsx
index e157cc0..9fe8054 100644
--- a/src/frontend/src/contexts/TabContext.tsx
+++ b/src/frontend/src/contexts/TabContext.tsx
@@ -7,12 +7,24 @@ export interface Tab {
closable?: boolean;
}
+export interface TabGroup {
+ name: string;
+ tabs: Tab[];
+}
+
interface TabContextType {
tabs: Tab[];
activeTabPath: string;
+ tabGroups: TabGroup[];
openTab: (path: string, label: string, closable?: boolean) => void;
closeTab: (path: string) => void;
setActiveTab: (path: string) => void;
+ reorderTabs: (newTabs: Tab[]) => void;
+ saveTabGroup: (name: string) => void;
+ loadTabGroup: (name: string) => void;
+ deleteTabGroup: (name: string) => void;
+ closeOtherTabs: (path: string) => void;
+ closeTabsToRight: (path: string) => void;
}
const TabContext = createContext(undefined);
@@ -20,13 +32,15 @@ const TabContext = createContext(undefined);
export function TabProvider({ children }: { children: ReactNode }) {
const [tabs, setTabs] = useState([]);
const [activeTabPath, setActiveTabPath] = useState('/');
+ const [tabGroups, setTabGroups] = useState([]);
const navigate = useNavigate();
const location = useLocation();
- // Load tabs from local storage on mount
+ // Load tabs and groups from local storage on mount
useEffect(() => {
const savedTabs = localStorage.getItem('zentral_tabs');
const savedActiveTab = localStorage.getItem('zentral_active_tab');
+ const savedTabGroups = localStorage.getItem('zentral_tab_groups');
if (savedTabs) {
try {
@@ -34,7 +48,6 @@ export function TabProvider({ children }: { children: ReactNode }) {
if (Array.isArray(parsedTabs) && parsedTabs.length > 0) {
setTabs(parsedTabs);
} else {
- // Default tab
setTabs([{ path: '/', label: 'Dashboard', closable: false }]);
}
} catch (e) {
@@ -48,6 +61,17 @@ export function TabProvider({ children }: { children: ReactNode }) {
if (savedActiveTab) {
setActiveTabPath(savedActiveTab);
}
+
+ if (savedTabGroups) {
+ try {
+ const parsedGroups = JSON.parse(savedTabGroups);
+ if (Array.isArray(parsedGroups)) {
+ setTabGroups(parsedGroups);
+ }
+ } catch (e) {
+ console.error("Failed to parse tab groups", e);
+ }
+ }
}, []);
// Save tabs to local storage whenever they change
@@ -58,22 +82,24 @@ export function TabProvider({ children }: { children: ReactNode }) {
localStorage.setItem('zentral_active_tab', activeTabPath);
}, [tabs, activeTabPath]);
+ // Save groups to local storage
+ useEffect(() => {
+ localStorage.setItem('zentral_tab_groups', JSON.stringify(tabGroups));
+ }, [tabGroups]);
+
// Sync active tab with location
useEffect(() => {
- // If the current location is not the active tab, update active tab if it exists in tabs
- // This handles browser back/forward buttons
const currentPath = location.pathname;
const tabExists = tabs.find(t => t.path === currentPath);
if (tabExists && activeTabPath !== currentPath) {
setActiveTabPath(currentPath);
}
- }, [location.pathname, tabs, activeTabPath]);
+ }, [location.pathname, tabs]);
const openTab = (path: string, label: string, closable: boolean = true) => {
if (!tabs.find((t) => t.path === path)) {
setTabs((prev) => [...prev, { path, label, closable }]);
}
- setActiveTabPath(path);
navigate(path);
};
@@ -81,14 +107,15 @@ export function TabProvider({ children }: { children: ReactNode }) {
const tabIndex = tabs.findIndex((t) => t.path === path);
if (tabIndex === -1) return;
+ // Prevent closing the last tab if it's the dashboard (or make sure dashboard is always there)
+ // But logic here says if we close a tab, we switch to another.
+
const newTabs = tabs.filter((t) => t.path !== path);
setTabs(newTabs);
if (activeTabPath === path) {
- // Switch to the nearest tab
const newActiveTab = newTabs[tabIndex] || newTabs[tabIndex - 1] || newTabs[0];
if (newActiveTab) {
- setActiveTabPath(newActiveTab.path);
navigate(newActiveTab.path);
} else {
navigate('/');
@@ -97,12 +124,83 @@ export function TabProvider({ children }: { children: ReactNode }) {
};
const setActiveTab = (path: string) => {
- setActiveTabPath(path);
navigate(path);
};
+ const reorderTabs = (newTabs: Tab[]) => {
+ setTabs(newTabs);
+ };
+
+ const saveTabGroup = (name: string) => {
+ const newGroup: TabGroup = { name, tabs: [...tabs] };
+ setTabGroups(prev => {
+ const existingIndex = prev.findIndex(g => g.name === name);
+ if (existingIndex >= 0) {
+ const updated = [...prev];
+ updated[existingIndex] = newGroup;
+ return updated;
+ }
+ return [...prev, newGroup];
+ });
+ };
+
+ const loadTabGroup = (name: string) => {
+ const group = tabGroups.find(g => g.name === name);
+ if (group) {
+ setTabs(group.tabs);
+ if (group.tabs.length > 0) {
+ setActiveTabPath(group.tabs[0].path);
+ navigate(group.tabs[0].path);
+ }
+ }
+ };
+
+ const deleteTabGroup = (name: string) => {
+ setTabGroups(prev => prev.filter(g => g.name !== name));
+ };
+
+ const closeOtherTabs = (path: string) => {
+ const tabToKeep = tabs.find(t => t.path === path);
+ if (tabToKeep) {
+ // Keep dashboard if it exists and is not the current tab?
+ // Usually "Close others" means close everything else that is closable.
+ // Assuming Dashboard is not closable, it should be kept?
+ // The current logic has 'closable' property.
+
+ const newTabs = tabs.filter(t => t.path === path || !t.closable);
+ setTabs(newTabs);
+ navigate(path);
+ }
+ };
+
+ const closeTabsToRight = (path: string) => {
+ const index = tabs.findIndex(t => t.path === path);
+ if (index !== -1) {
+ const tabsToKeep = tabs.slice(0, index + 1);
+ // Also keep non-closable tabs that might be to the right (unlikely but good to be safe)
+ const tabsToRight = tabs.slice(index + 1);
+ const nonClosableToRight = tabsToRight.filter(t => !t.closable);
+
+ setTabs([...tabsToKeep, ...nonClosableToRight]);
+ navigate(path);
+ }
+ };
+
return (
-
+
{children}
);