From ce2bf62d428e631cad0bb586f6fa366c04666f7d Mon Sep 17 00:00:00 2001 From: Amit Haridas Date: Fri, 5 Jun 2026 15:34:25 +0530 Subject: [PATCH] feat(renderer): drag-to-reorder tabs (dnd-kit) --- src/renderer/components/layout/TabBar.tsx | 141 ++++++++++++++++------ tests/component/layout/TabBar.test.tsx | 23 ++++ 2 files changed, 128 insertions(+), 36 deletions(-) diff --git a/src/renderer/components/layout/TabBar.tsx b/src/renderer/components/layout/TabBar.tsx index 29f00e9..989414f 100644 --- a/src/renderer/components/layout/TabBar.tsx +++ b/src/renderer/components/layout/TabBar.tsx @@ -1,12 +1,92 @@ import { X } from 'lucide-react'; -import { useFileStore } from '@/stores/file-store'; +import { + DndContext, + PointerSensor, + useSensor, + useSensors, + closestCenter, + type DragEndEvent, +} from '@dnd-kit/core'; +import { + SortableContext, + horizontalListSortingStrategy, + useSortable, +} from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; +import { useFileStore, type OpenTab } from '@/stores/file-store'; import { cn } from '@/lib/utils'; +function SortableTab({ + tab, + isActive, + onSelect, + onClose, +}: { + tab: OpenTab; + isActive: boolean; + onSelect: (id: string) => void; + onClose: (id: string) => void; +}) { + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ + id: tab.id, + }); + + const style = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.5 : 1, + }; + + return ( +
onSelect(tab.id)} + > + {tab.dirty && ( + + )} + {tab.title} + +
+ ); +} + export function TabBar() { const openTabs = useFileStore((s) => s.openTabs); const activeTabId = useFileStore((s) => s.activeTabId); const setActiveTab = useFileStore((s) => s.setActiveTab); const closeTab = useFileStore((s) => s.closeTab); + const reorderTabs = useFileStore((s) => s.reorderTabs); + + // Require a small drag distance before activating — so a click on the tab + // body still triggers `onClick` (dnd-kit's PointerSensor default is 0). + const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 6 } })); if (openTabs.length === 0) { return ( @@ -16,43 +96,32 @@ export function TabBar() { ); } + function handleDragEnd(e: DragEndEvent) { + const { active, over } = e; + if (!over || active.id === over.id) return; + const fromIndex = openTabs.findIndex((t) => t.id === active.id); + const toIndex = openTabs.findIndex((t) => t.id === over.id); + if (fromIndex === -1 || toIndex === -1) return; + reorderTabs(fromIndex, toIndex); + } + return (
-
- {openTabs.map((tab) => { - const isActive = tab.id === activeTabId; - return ( -
setActiveTab(tab.id)} - > - {tab.dirty && ( - - )} - {tab.title} - -
- ); - })} -
+ + t.id)} strategy={horizontalListSortingStrategy}> +
+ {openTabs.map((tab) => ( + + ))} +
+
+
); } diff --git a/tests/component/layout/TabBar.test.tsx b/tests/component/layout/TabBar.test.tsx index 94ef08b..7748b14 100644 --- a/tests/component/layout/TabBar.test.tsx +++ b/tests/component/layout/TabBar.test.tsx @@ -90,4 +90,27 @@ describe('TabBar', () => { const dirtyDot = screen.getByLabelText('Unsaved changes'); expect(dirtyDot).toBeInTheDocument(); }); + + it('reorders tabs when reorderTabs is called (drag end handler integration)', () => { + useFileStore.setState({ + openTabs: [ + { id: '/tmp/a.md', path: '/tmp/a.md', title: 'a.md', dirty: false }, + { id: '/tmp/b.md', path: '/tmp/b.md', title: 'b.md', dirty: false }, + { id: '/tmp/c.md', path: '/tmp/c.md', title: 'c.md', dirty: false }, + ], + activeTabId: '/tmp/a.md', + }); + render(); + // Initial DOM order + const initialOrder = screen.getAllByRole('tab').map((el) => el.getAttribute('data-testid')); + expect(initialOrder).toEqual(['tab-/tmp/a.md', 'tab-/tmp/b.md', 'tab-/tmp/c.md']); + + // Simulate what dnd-kit's onDragEnd would do: move a.md (idx 0) to idx 2 + act(() => { + useFileStore.getState().reorderTabs(0, 2); + }); + + const newOrder = screen.getAllByRole('tab').map((el) => el.getAttribute('data-testid')); + expect(newOrder).toEqual(['tab-/tmp/b.md', 'tab-/tmp/c.md', 'tab-/tmp/a.md']); + }); });