diff --git a/packages/panes/src/core/store/store.test.ts b/packages/panes/src/core/store/store.test.ts index 3f98e498f0d..e42a0392b1c 100644 --- a/packages/panes/src/core/store/store.test.ts +++ b/packages/panes/src/core/store/store.test.ts @@ -598,6 +598,48 @@ describe("movePaneToSplit", () => { }); }); +describe("movePaneToNewTab", () => { + it("moves a pane into a new tab at the requested index", () => { + const store = makeStore(); + store.getState().addTab({ + id: "t1", + panes: [tp("p1"), tp("p2")], + activePaneId: "p1", + }); + store.getState().addTab({ id: "t2", panes: [tp("p3")] }); + + store.getState().movePaneToNewTab({ paneId: "p2", toIndex: 1 }); + + const tabs = store.getState().tabs; + const newTab = tabs[1]; + if (!newTab) throw new Error("Expected new tab at index 1"); + + expect(tabs.map((t) => t.id)).toEqual(["t1", newTab.id, "t2"]); + expect(newTab.panes.p2).toBeDefined(); + expect(newTab.activePaneId).toBe("p2"); + expect(newTab.layout).toEqual({ type: "pane", paneId: "p2" }); + expect(tabs[0]?.panes.p2).toBeUndefined(); + expect(tabs[0]?.layout).toEqual({ type: "pane", paneId: "p1" }); + expect(store.getState().activeTabId).toBe(newTab.id); + }); + + it("keeps insertion position stable when the source tab is removed", () => { + const store = makeStore(); + store.getState().addTab({ id: "t1", panes: [tp("p1")] }); + store.getState().addTab({ id: "t2", panes: [tp("p2")] }); + + store.getState().movePaneToNewTab({ paneId: "p1", toIndex: 1 }); + + const tabs = store.getState().tabs; + const newTab = tabs[0]; + if (!newTab) throw new Error("Expected new tab at index 0"); + + expect(tabs.map((t) => t.id)).toEqual([newTab.id, "t2"]); + expect(newTab.panes.p1).toBeDefined(); + expect(store.getState().activeTabId).toBe(newTab.id); + }); +}); + describe("edge cases", () => { it("invalid IDs are no-ops", () => { const store = makeStore(); diff --git a/packages/panes/src/core/store/store.ts b/packages/panes/src/core/store/store.ts index 276e5d0d17f..eda98dc6657 100644 --- a/packages/panes/src/core/store/store.ts +++ b/packages/panes/src/core/store/store.ts @@ -167,7 +167,7 @@ export interface WorkspaceStore extends WorkspaceState { }) => void; movePaneToTab: (args: { paneId: string; targetTabId: string }) => void; - movePaneToNewTab: (args: { paneId: string }) => void; + movePaneToNewTab: (args: { paneId: string; toIndex?: number }) => void; reorderTab: (args: { tabId: string; toIndex: number }) => void; @@ -795,10 +795,12 @@ export function createWorkspaceStore( set((s) => { let sourceTab: Tab | undefined; let pane: Pane | undefined; - for (const t of s.tabs) { + let sourceTabIndex = -1; + for (const [index, t] of s.tabs.entries()) { if (t.panes[args.paneId]) { sourceTab = t; pane = t.panes[args.paneId]; + sourceTabIndex = index; break; } } @@ -835,7 +837,19 @@ export function createWorkspaceStore( }) .filter((t): t is Tab => t !== null); - nextTabs.push(newTab); + const requestedIndex = args.toIndex ?? nextTabs.length; + const adjustedIndex = + args.toIndex !== undefined && + !nextSourceLayout && + sourceTabIndex < args.toIndex + ? args.toIndex - 1 + : requestedIndex; + const insertIndex = Math.max( + 0, + Math.min(adjustedIndex, nextTabs.length), + ); + + nextTabs.splice(insertIndex, 0, newTab); return { tabs: nextTabs, activeTabId: newTab.id }; }); diff --git a/packages/panes/src/react/components/Workspace/Workspace.tsx b/packages/panes/src/react/components/Workspace/Workspace.tsx index b04d4d254e1..c7688cda016 100644 --- a/packages/panes/src/react/components/Workspace/Workspace.tsx +++ b/packages/panes/src/react/components/Workspace/Workspace.tsx @@ -83,6 +83,9 @@ export function Workspace({ onReorderTab={(tabId, toIndex) => store.getState().reorderTab({ tabId, toIndex }) } + onMovePaneToNewTab={(paneId, toIndex) => + store.getState().movePaneToNewTab({ paneId, toIndex }) + } getTabTitle={(tab) => resolveTabTitle(tab, tabs, registry)} renderTabIcon={renderTabIcon} renderAddTabMenu={renderAddTabMenu} diff --git a/packages/panes/src/react/components/Workspace/components/TabBar/TabBar.tsx b/packages/panes/src/react/components/Workspace/components/TabBar/TabBar.tsx index c59387f97d8..c6fb91f210f 100644 --- a/packages/panes/src/react/components/Workspace/components/TabBar/TabBar.tsx +++ b/packages/panes/src/react/components/Workspace/components/TabBar/TabBar.tsx @@ -15,6 +15,7 @@ import { } from "react"; import { useDrop } from "react-dnd"; import type { Tab } from "../../../../../types"; +import { PANE_DRAG_TYPE } from "../Tab/components/Pane/components/PaneHeader"; import { TAB_DRAG_TYPE, TabItem } from "./components/TabItem"; import { computeInsertIndex, TAB_WIDTH } from "./utils"; @@ -27,12 +28,16 @@ interface TabBarProps { onCloseAllTabs: () => void; onRenameTab: (tabId: string, title: string | undefined) => void; onReorderTab: (tabId: string, toIndex: number) => void; + onMovePaneToNewTab: (paneId: string, toIndex: number) => void; getTabTitle: (tab: Tab) => string; renderTabIcon?: (tab: Tab) => ReactNode; renderAddTabMenu?: () => ReactNode; renderTabAccessory?: (tab: Tab) => ReactNode; } +type TabDragItem = { tabId: string }; +type PaneDragItem = { paneId: string }; + function AddTabButton<_TData>({ renderAddTabMenu, }: { @@ -72,6 +77,7 @@ export function TabBar({ onCloseAllTabs, onRenameTab, onReorderTab, + onMovePaneToNewTab, getTabTitle, renderTabIcon, renderAddTabMenu, @@ -85,8 +91,8 @@ export function TabBar({ const [{ isOver }, connectDrop] = useDrop( () => ({ - accept: TAB_DRAG_TYPE, - hover: (_item, monitor) => { + accept: [TAB_DRAG_TYPE, PANE_DRAG_TYPE], + hover: (_item: TabDragItem | PaneDragItem, monitor) => { const track = tabsTrackRef.current; const offset = monitor.getClientOffset(); if (!track || !offset) return; @@ -101,10 +107,22 @@ export function TabBar({ setInsertIndex(idx); } }, - drop: (item: { tabId: string }) => { + drop: (item: TabDragItem | PaneDragItem, monitor) => { const idx = insertIndexRef.current; if (idx === null) return; + insertIndexRef.current = null; + setInsertIndex(null); + + if (monitor.getItemType() === PANE_DRAG_TYPE && "paneId" in item) { + onMovePaneToNewTab(item.paneId, idx); + return; + } + + if (monitor.getItemType() !== TAB_DRAG_TYPE || !("tabId" in item)) { + return; + } + const dragIndex = tabs.findIndex((t) => t.id === item.tabId); if (dragIndex === -1) return; @@ -112,15 +130,13 @@ export function TabBar({ let toIndex = idx; if (dragIndex < toIndex) toIndex--; - insertIndexRef.current = null; - setInsertIndex(null); onReorderTab(item.tabId, toIndex); }, collect: (monitor) => ({ isOver: monitor.isOver(), }), }), - [tabs, onReorderTab], + [tabs, onReorderTab, onMovePaneToNewTab], ); // Clear indicator when cursor leaves the tab bar @@ -129,7 +145,7 @@ export function TabBar({ if (insertIndex !== null) setInsertIndex(null); } - const setScrollContainerRef = useCallback( + const setRootRef = useCallback( (node: HTMLDivElement | null) => { connectDrop(node); }, @@ -148,7 +164,10 @@ export function TabBar({ if (tabs.length === 0) { return ( -
+
@@ -158,9 +177,11 @@ export function TabBar({ } return ( -
+