diff --git a/src/features/Electron/titlebar/TabBar/TabItem.tsx b/src/features/Electron/titlebar/TabBar/TabItem.tsx index 154a709db8..f8f655abb1 100644 --- a/src/features/Electron/titlebar/TabBar/TabItem.tsx +++ b/src/features/Electron/titlebar/TabBar/TabItem.tsx @@ -1,5 +1,7 @@ 'use client'; +import { useSortable } from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; import { ActionIcon, Avatar, @@ -52,6 +54,10 @@ const TabItem = memo( const isUnread = useTabUnread(tab); const showUnreadDot = !isRunning && isUnread; + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ + id, + }); + const handleClick = useCallback(() => { if (!isActive) { onActivate(id, tab.url); @@ -102,10 +108,23 @@ const TabItem = memo( {meta.avatar ? ( diff --git a/src/features/Electron/titlebar/TabBar/index.tsx b/src/features/Electron/titlebar/TabBar/index.tsx index 3953e22d11..7e265ce347 100644 --- a/src/features/Electron/titlebar/TabBar/index.tsx +++ b/src/features/Electron/titlebar/TabBar/index.tsx @@ -1,5 +1,16 @@ 'use client'; +import { + closestCenter, + DndContext, + type DragEndEvent, + KeyboardSensor, + type Modifier, + PointerSensor, + useSensor, + useSensors, +} from '@dnd-kit/core'; +import { horizontalListSortingStrategy, SortableContext } from '@dnd-kit/sortable'; import { useWatchBroadcast } from '@lobechat/electron-client-ipc'; import { ActionIcon, ScrollArea } from '@lobehub/ui'; import { cx } from 'antd-style'; @@ -23,6 +34,9 @@ import TabItem from './TabItem'; const TAB_WIDTH = 180; const TAB_GAP = 0; +// Tabs only reorder along the horizontal axis, so lock the drag transform to X. +const restrictToHorizontalAxis: Modifier = ({ transform }) => ({ ...transform, y: 0 }); + // Fallback when the active route doesn't define createNewTab: open the home page, // so the "+" button stays available on every page. const DEFAULT_NEW_TAB_ACTION: NewTabAction = { @@ -35,6 +49,7 @@ const TabBar = () => { const { t } = useTranslation('electron'); const { allowed: canCreate, reason } = usePermission('create_content'); const viewportRef = useRef(null); + const scrolledActiveTabIdRef = useRef(null); const { tabs, activeTabId } = useResolvedTabs(); const activateTab = useElectronStore((s) => s.activateTab); const addTab = useElectronStore((s) => s.addTab); @@ -42,6 +57,29 @@ const TabBar = () => { const closeOtherTabs = useElectronStore((s) => s.closeOtherTabs); const closeLeftTabs = useElectronStore((s) => s.closeLeftTabs); const closeRightTabs = useElectronStore((s) => s.closeRightTabs); + const reorderTabs = useElectronStore((s) => s.reorderTabs); + + const sensors = useSensors( + // Require a small drag distance so a plain click still activates the tab. + useSensor(PointerSensor, { activationConstraint: { distance: 4 } }), + useSensor(KeyboardSensor), + ); + + const tabIds = useMemo(() => tabs.map((tab) => tab.tab.id), [tabs]); + + const handleDragEnd = useCallback( + (event: DragEndEvent) => { + const { active, over } = event; + if (!over || active.id === over.id) return; + + const fromIndex = tabIds.indexOf(active.id as string); + const toIndex = tabIds.indexOf(over.id as string); + if (fromIndex < 0 || toIndex < 0) return; + + reorderTabs(fromIndex, toIndex); + }, + [tabIds, reorderTabs], + ); const handleActivate = useCallback( (id: string, url: string) => { @@ -114,6 +152,13 @@ const TabBar = () => { const activeIndex = tabs.findIndex((tab) => tab.tab.id === activeTabId); if (activeIndex < 0) return; + // Only scroll into view when the active tab itself changes. Reordering + // background tabs keeps the same active tab, so skip it — otherwise every + // drop would yank the viewport back to the active tab and lose the user's + // scroll position. + if (scrolledActiveTabIdRef.current === activeTabId) return; + scrolledActiveTabIdRef.current = activeTabId; + const tabLeft = activeIndex * (TAB_WIDTH + TAB_GAP); const tabRight = tabLeft + TAB_WIDTH; const { scrollLeft, clientWidth } = viewport; @@ -173,20 +218,29 @@ const TabBar = () => { style: { alignItems: 'center', flexDirection: 'row', gap: TAB_GAP }, }} > - {tabs.map((tab, index) => ( - - ))} + + + {tabs.map((tab, index) => ( + + ))} + + {(newTabAction || !canCreate) && ( ({ opacity: 0; } `, + tabDragging: css` + cursor: grabbing; + z-index: 1; + background-color: ${cssVar.colorBgElevated}; + box-shadow: ${cssVar.boxShadowSecondary}; + + &::before, + & + &::before { + opacity: 0; + } + `, tabActive: css` background-color: ${cssVar.colorBgElevated};