/** * Tab bar for the project editor. * Shows open files as tabs with dirty indicator (dot), close button, * right-click context menu (close others, close to left/right, close all), * and drag-and-drop reordering via @dnd-kit. */ import { useCallback, useMemo, useState } from 'react'; import { closestCenter, DndContext, DragOverlay, PointerSensor, useSensor, useSensors, } from '@dnd-kit/core'; import { horizontalListSortingStrategy, SortableContext, useSortable } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { useStore } from '@renderer/store'; import { X } from 'lucide-react'; import { useShallow } from 'zustand/react/shallow'; import { EditorTabContextMenu } from './EditorTabContextMenu'; import { FileIcon } from './FileIcon'; import type { DragEndEvent, DragStartEvent } from '@dnd-kit/core'; import type { EditorFileTab } from '@shared/types/editor'; // ============================================================================= // Types // ============================================================================= interface EditorTabBarProps { /** Called instead of direct closeTab — allows parent to intercept dirty tabs */ onRequestCloseTab: (tabId: string) => void; } // ============================================================================= // Component // ============================================================================= export const EditorTabBar = ({ onRequestCloseTab, }: EditorTabBarProps): React.ReactElement | null => { const { tabs, activeTabId, modifiedFiles } = useStore( useShallow((s) => ({ tabs: s.editorOpenTabs, activeTabId: s.editorActiveTabId, modifiedFiles: s.editorModifiedFiles, })) ); const setActiveEditorTab = useStore((s) => s.setActiveEditorTab); const reorderEditorTabs = useStore((s) => s.reorderEditorTabs); const closeOtherEditorTabs = useStore((s) => s.closeOtherEditorTabs); const closeEditorTabsToLeft = useStore((s) => s.closeEditorTabsToLeft); const closeEditorTabsToRight = useStore((s) => s.closeEditorTabsToRight); const closeAllEditorTabs = useStore((s) => s.closeAllEditorTabs); const [draggedTab, setDraggedTab] = useState(null); const tabIds = useMemo(() => tabs.map((t) => t.id), [tabs]); const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 5 }, }) ); const handleDragStart = useCallback( (event: DragStartEvent) => { const tab = tabs.find((t) => t.id === event.active.id); setDraggedTab(tab ?? null); }, [tabs] ); const handleDragEnd = useCallback( (event: DragEndEvent) => { setDraggedTab(null); const { active, over } = event; if (over && active.id !== over.id) { reorderEditorTabs(String(active.id), String(over.id)); } }, [reorderEditorTabs] ); const handleDragCancel = useCallback(() => { setDraggedTab(null); }, []); if (tabs.length === 0) return null; return (
{tabs.map((tab, index) => ( setActiveEditorTab(tab.id)} onRequestClose={onRequestCloseTab} onCloseOthers={closeOtherEditorTabs} onCloseToLeft={closeEditorTabsToLeft} onCloseToRight={closeEditorTabsToRight} onCloseAll={closeAllEditorTabs} /> ))}
{draggedTab && }
); }; // ============================================================================= // Sortable tab item // ============================================================================= interface SortableEditorTabProps { tab: EditorFileTab; tabIndex: number; totalTabs: number; isActive: boolean; isModified: boolean; onActivate: () => void; onRequestClose: (tabId: string) => void; onCloseOthers: (tabId: string) => void; onCloseToLeft: (tabId: string) => void; onCloseToRight: (tabId: string) => void; onCloseAll: () => void; } const SortableEditorTab = ({ tab, tabIndex, totalTabs, isActive, isModified, onActivate, onRequestClose, onCloseOthers, onCloseToLeft, onCloseToRight, onCloseAll, }: SortableEditorTabProps): React.ReactElement => { const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: tab.id, }); const style: React.CSSProperties = { transform: CSS.Transform.toString(transform), transition: isDragging ? 'none' : transition, opacity: isDragging ? 0.3 : 1, }; const handleClose = (e: React.MouseEvent) => { e.stopPropagation(); onRequestClose(tab.id); }; const handleAuxClick = (e: React.MouseEvent) => { if (e.button === 1) { e.preventDefault(); onRequestClose(tab.id); } }; return ( // Sortable wrapper — must be the outermost element so @dnd-kit controls its position. // ContextMenu.Trigger inside EditorTabContextMenu adds an extra
, // so the useSortable ref/transform CANNOT live on the inner {tab.filePath}
); }; // ============================================================================= // Drag overlay (ghost shown while dragging) // ============================================================================= const EditorTabOverlay = ({ tab }: { tab: EditorFileTab }): React.ReactElement => (
{tab.fileName}
);