// ======================================== // TabBar Component // ======================================== // Tab management for CLI viewer panes with drag-and-drop support import { useCallback, useMemo, useState } from 'react'; import { useIntl } from 'react-intl'; import { X, Pin, PinOff, MoreHorizontal, SplitSquareHorizontal, SplitSquareVertical } from 'lucide-react'; import { cn } from '@/lib/utils'; import { Button } from '@/components/ui/Button'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/Dropdown'; import { useViewerStore, useViewerPanes, type PaneId, type TabState, } from '@/stores/viewerStore'; import { ExecutionPicker } from './ExecutionPicker'; // ========== Types ========== export interface TabBarProps { paneId: PaneId; className?: string; } interface TabItemProps { tab: TabState; paneId: PaneId; isActive: boolean; onSelect: () => void; onClose: (e: React.MouseEvent) => void; onTogglePin: (e: React.MouseEvent) => void; } // ========== Constants ========== const STATUS_COLORS = { running: 'bg-indigo-500 shadow-[0_0_6px_rgba(99,102,241,0.4)] animate-pulse', completed: 'bg-emerald-500', error: 'bg-rose-500', idle: 'bg-slate-400 dark:bg-slate-500', }; // ========== Helper Components ========== // Data transfer key for tab drag-and-drop const TAB_DRAG_DATA_TYPE = 'application/x-cli-viewer-tab'; interface TabDragData { tabId: string; sourcePaneId: string; } /** * Individual tab item with drag-and-drop support */ function TabItem({ tab, paneId, isActive, onSelect, onClose, onTogglePin }: TabItemProps) { const [isDragging, setIsDragging] = useState(false); const [isDragOver, setIsDragOver] = useState(false); const moveTab = useViewerStore((state) => state.moveTab); const panes = useViewerPanes(); // Simplify title for display const displayTitle = useMemo(() => { // If title contains tool name pattern, extract it const parts = tab.title.split('-'); return parts[0] || tab.title; }, [tab.title]); // Drag start handler const handleDragStart = useCallback((e: React.DragEvent) => { const dragData: TabDragData = { tabId: tab.id, sourcePaneId: paneId, }; e.dataTransfer.setData(TAB_DRAG_DATA_TYPE, JSON.stringify(dragData)); e.dataTransfer.effectAllowed = 'move'; setIsDragging(true); }, [tab.id, paneId]); // Drag end handler const handleDragEnd = useCallback(() => { setIsDragging(false); }, []); // Drag over handler const handleDragOver = useCallback((e: React.DragEvent) => { if (e.dataTransfer.types.includes(TAB_DRAG_DATA_TYPE)) { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; setIsDragOver(true); } }, []); // Drag leave handler const handleDragLeave = useCallback(() => { setIsDragOver(false); }, []); // Drop handler const handleDrop = useCallback((e: React.DragEvent) => { e.preventDefault(); setIsDragOver(false); const rawData = e.dataTransfer.getData(TAB_DRAG_DATA_TYPE); if (!rawData) return; try { const dragData: TabDragData = JSON.parse(rawData); const { tabId: sourceTabId, sourcePaneId } = dragData; // Don't do anything if dropping on the same tab if (sourceTabId === tab.id) return; // Find the target index const targetPane = panes[paneId]; if (!targetPane) return; const targetIndex = targetPane.tabs.findIndex((t) => t.id === tab.id); if (targetIndex === -1) return; // Move the tab moveTab(sourcePaneId, sourceTabId, paneId, targetIndex); } catch (err) { console.error('[TabBar] Failed to parse drag data:', err); } }, [tab.id, paneId, panes, moveTab]); return (
{ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onSelect(); } }} className={cn( 'group relative flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs', 'border border-border/50 shrink-0 min-w-0 max-w-[160px]', 'transition-all duration-150 select-none', isActive ? 'bg-slate-100 dark:bg-slate-800 border-slate-300 dark:border-slate-600 shadow-sm' : 'bg-muted/30 hover:bg-muted/50 border-border/30', tab.isPinned && 'border-amber-500/50', isDragging && 'opacity-50 cursor-grabbing', isDragOver && 'border-primary border-dashed bg-primary/10', !tab.isPinned && 'cursor-grab' )} title={tab.title} > {/* Status indicator dot */} {/* Tool name */} {displayTitle} {/* Pin indicator (always visible if pinned) */} {tab.isPinned && ( )} {/* Action buttons (visible on hover) */}
{/* Pin/Unpin button */} {/* Close button (hidden if pinned) */} {!tab.isPinned && ( )}
); } // ========== Main Component ========== /** * TabBar - Manages tabs within a pane * * Features: * - Tab display with status indicators * - Active tab highlighting * - Close button on hover * - Pin/unpin functionality * - Drag-and-drop tab reordering and moving between panes * - Pane actions dropdown */ export function TabBar({ paneId, className }: TabBarProps) { const { formatMessage } = useIntl(); const [isDragOver, setIsDragOver] = useState(false); const panes = useViewerPanes(); const pane = panes[paneId]; const setActiveTab = useViewerStore((state) => state.setActiveTab); const removeTab = useViewerStore((state) => state.removeTab); const togglePinTab = useViewerStore((state) => state.togglePinTab); const addPane = useViewerStore((state) => state.addPane); const removePane = useViewerStore((state) => state.removePane); const moveTab = useViewerStore((state) => state.moveTab); const handleTabSelect = useCallback( (tabId: string) => { setActiveTab(paneId, tabId); }, [paneId, setActiveTab] ); const handleTabClose = useCallback( (e: React.MouseEvent, tabId: string) => { e.stopPropagation(); removeTab(paneId, tabId); }, [paneId, removeTab] ); const handleTogglePin = useCallback( (e: React.MouseEvent, tabId: string) => { e.stopPropagation(); togglePinTab(tabId); }, [togglePinTab] ); const handleSplitHorizontal = useCallback(() => { addPane(paneId, 'horizontal'); }, [paneId, addPane]); const handleSplitVertical = useCallback(() => { addPane(paneId, 'vertical'); }, [paneId, addPane]); const handleClosePane = useCallback(() => { removePane(paneId); }, [paneId, removePane]); // Drag over handler for tab bar container (allows dropping to end of list) const handleContainerDragOver = useCallback((e: React.DragEvent) => { if (e.dataTransfer.types.includes(TAB_DRAG_DATA_TYPE)) { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; setIsDragOver(true); } }, []); // Drag leave handler for container const handleContainerDragLeave = useCallback((e: React.DragEvent) => { // Only set false if leaving the container entirely, not just moving to a child if (!e.currentTarget.contains(e.relatedTarget as Node)) { setIsDragOver(false); } }, []); // Drop handler for tab bar container (drops to end of list) const handleContainerDrop = useCallback((e: React.DragEvent) => { e.preventDefault(); setIsDragOver(false); const rawData = e.dataTransfer.getData(TAB_DRAG_DATA_TYPE); if (!rawData) return; try { const dragData: TabDragData = JSON.parse(rawData); const { tabId: sourceTabId, sourcePaneId } = dragData; // Move the tab to the end of this pane const targetIndex = pane?.tabs.length || 0; moveTab(sourcePaneId, sourceTabId, paneId, targetIndex); } catch (err) { console.error('[TabBar] Failed to parse drag data:', err); } }, [paneId, pane, moveTab]); // Sort tabs: pinned first, then by order const sortedTabs = useMemo(() => { if (!pane) return []; return [...pane.tabs].sort((a, b) => { if (a.isPinned !== b.isPinned) { return a.isPinned ? -1 : 1; } return a.order - b.order; }); }, [pane]); if (!pane) { return null; } return (
{/* Tabs */}
{sortedTabs.length === 0 ? ( {formatMessage({ id: 'cliViewer.tabs.noTabs', defaultMessage: 'No tabs open' })} ) : ( sortedTabs.map((tab) => ( handleTabSelect(tab.id)} onClose={(e) => handleTabClose(e, tab.id)} onTogglePin={(e) => handleTogglePin(e, tab.id)} /> )) )}
{/* Add tab button */} {/* Pane actions dropdown */} {formatMessage({ id: 'cliViewer.paneActions.splitHorizontal', defaultMessage: 'Split Horizontal' })} {formatMessage({ id: 'cliViewer.paneActions.splitVertical', defaultMessage: 'Split Vertical' })} {formatMessage({ id: 'cliViewer.paneActions.closePane', defaultMessage: 'Close Pane' })}
); } export default TabBar;