From b743db35afb7b9bd1414432335af8fd70b948fd9 Mon Sep 17 00:00:00 2001 From: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com> Date: Fri, 10 Apr 2026 16:38:27 +0800 Subject: [PATCH] feat(desktop): drag-to-reorder tabs via dnd-kit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds horizontal drag-and-drop reordering for the desktop tab bar using @dnd-kit/sortable, with axis + parent constraints so tabs only slide horizontally within the bar. Order is persisted automatically through the existing tab-store partialize. Also brings tab-store into the standardized storage pipeline introduced in 85cff154 — it was the last persist store still using vanilla zustand persist instead of createPersistStorage(defaultStorage). Storage key multica_tabs is unchanged so existing user data is preserved. - apps/desktop: add @dnd-kit/{core,sortable,modifiers,utilities} - tab-store: moveTab(from, to) action via arrayMove (preserves router refs) - tab-store: persist storage → createJSONStorage(createPersistStorage(defaultStorage)) - tab-bar: DndContext + SortableContext(horizontalListSortingStrategy) - tab-bar: restrictToHorizontalAxis + restrictToParentElement modifiers - tab-bar: PointerSensor distance:5 to disambiguate click vs drag - tab-bar: stopPropagation on close-button pointerdown to avoid drag start Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/desktop/package.json | 6 +- .../src/renderer/src/components/tab-bar.tsx | 86 +++++++++++++++++-- .../src/renderer/src/stores/tab-store.ts | 12 ++- pnpm-lock.yaml | 25 ++++++ 4 files changed, 122 insertions(+), 7 deletions(-) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index ff1ba87e5..45a2a2617 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -15,6 +15,10 @@ "postinstall": "electron-builder install-app-deps" }, "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/modifiers": "^9.0.0", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@electron-toolkit/preload": "^3.0.2", "@electron-toolkit/utils": "^4.0.0", "@multica/core": "workspace:*", @@ -26,8 +30,8 @@ "tw-animate-css": "^1.4.0" }, "devDependencies": { - "@multica/tsconfig": "workspace:*", "@electron-toolkit/tsconfig": "^2.0.0", + "@multica/tsconfig": "workspace:*", "@tailwindcss/vite": "^4", "@types/node": "catalog:", "@types/react": "catalog:", diff --git a/apps/desktop/src/renderer/src/components/tab-bar.tsx b/apps/desktop/src/renderer/src/components/tab-bar.tsx index a42b97951..0bd4f388b 100644 --- a/apps/desktop/src/renderer/src/components/tab-bar.tsx +++ b/apps/desktop/src/renderer/src/components/tab-bar.tsx @@ -10,6 +10,24 @@ import { Plus, type LucideIcon, } from "lucide-react"; +import { + DndContext, + PointerSensor, + useSensor, + useSensors, + closestCenter, + type DragEndEvent, +} from "@dnd-kit/core"; +import { + SortableContext, + horizontalListSortingStrategy, + useSortable, +} from "@dnd-kit/sortable"; +import { + restrictToHorizontalAxis, + restrictToParentElement, +} from "@dnd-kit/modifiers"; +import { CSS } from "@dnd-kit/utilities"; import { cn } from "@multica/ui/lib/utils"; import { useTabStore, resolveRouteIcon, type Tab } from "@/stores/tab-store"; @@ -23,12 +41,28 @@ const TAB_ICONS: Record = { Settings, }; -function TabItem({ tab, isActive, isOnly }: { tab: Tab; isActive: boolean; isOnly: boolean }) { +function SortableTabItem({ tab, isActive, isOnly }: { tab: Tab; isActive: boolean; isOnly: boolean }) { const setActiveTab = useTabStore((s) => s.setActiveTab); const closeTab = useTabStore((s) => s.closeTab); + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id: tab.id }); + const Icon = TAB_ICONS[tab.icon]; + const style = { + transform: CSS.Transform.toString(transform), + transition, + WebkitAppRegion: "no-drag", + zIndex: isDragging ? 10 : undefined, + } as React.CSSProperties; + const handleClick = () => { if (isActive) return; setActiveTab(tab.id); @@ -41,16 +75,25 @@ function TabItem({ tab, isActive, isOnly }: { tab: Tab; isActive: boolean; isOnl // No navigate() — store handles activeTabId switch }; + // Stop pointer down on close so it doesn't start a drag on the parent button. + const stopDragOnClose = (e: React.PointerEvent) => { + e.stopPropagation(); + }; + return (