diff --git a/src/editor/core/controller.ts b/src/editor/core/controller.ts index 20874e908..4291d070f 100644 --- a/src/editor/core/controller.ts +++ b/src/editor/core/controller.ts @@ -1342,8 +1342,8 @@ export class EditorController implements SlotHost { this.apply((s) => (s.panes[paneId] ? promoteTab(s, paneId, tabId) : s)); } /** Drag & drop; the tab keeps its id (and slot) when moved within a pane. */ - moveTab(fromPane: string, tabId: string, toPane: string, index: number) { - this.apply((s) => moveTab(s, fromPane, tabId, toPane, index)); + moveTab(fromPane: string, tabId: string, toPane: string, index: number, pin?: boolean) { + this.apply((s) => moveTab(s, fromPane, tabId, toPane, index, pin)); } /** Drag & drop onto an editor edge: the tab moves into a new split on that side. */ moveTabToSplit(fromPane: string, tabId: string, targetPane: string, edge: SplitEdge) { diff --git a/src/editor/core/model/model.test.ts b/src/editor/core/model/model.test.ts index 062afa2c7..0041fbed1 100644 --- a/src/editor/core/model/model.test.ts +++ b/src/editor/core/model/model.test.ts @@ -321,3 +321,33 @@ describe("camel humps", () => { expect(stops(" value2Go", "right")).toEqual([7, 8, 10]); }); }); + +describe("moveTab between the pinned row and the other row", () => { + // a^ b^ | c d e (^ = pinned) + const setup = () => { + let s = initialTabsState(); + for (const p of ["a", "b", "c", "d", "e"]) s = open(s, p); + const id = (p: string) => findTab(s, `file:${p}`, s.activePane)!.tab.id; + s = togglePin(s, s.activePane, id("a")); + s = togglePin(s, s.activePane, id("b")); + return { s, id }; + }; + + it.each([ + ["a plain tab onto the pinned row", "d", 1, true, ["a^", "d^", "b^", "c", "e"]], + ["a plain tab to the front of the pinned row", "e", 0, true, ["e^", "a^", "b^", "c", "d"]], + ["a plain tab to the end of the pinned row", "c", 2, true, ["a^", "b^", "c^", "d", "e"]], + ["a pinned tab onto the other row", "a", 4, false, ["b^", "c", "d", "a", "e"]], + ["a pinned tab to the start of the other row", "b", 2, false, ["a^", "b", "c", "d", "e"]], + ["a plain tab inside the other row keeps it plain", "c", 5, false, ["a^", "b^", "d", "e", "c"]], + ["an index past the pinned group stays in it", "d", 4, true, ["a^", "b^", "d^", "c", "e"]], + ])("%s", (_, path, index, pin, expected) => { + const { s, id } = setup(); + expect(labels(moveTab(s, s.activePane, id(path), s.activePane, index, pin))).toEqual(expected); + }); + + it("leaves the pinned state alone without a target row", () => { + const { s, id } = setup(); + expect(labels(moveTab(s, s.activePane, id("d"), s.activePane, 0))).toEqual(["d", "a^", "b^", "c", "e"]); + }); +}); diff --git a/src/editor/core/model/tabs.ts b/src/editor/core/model/tabs.ts index 56cf6a83a..b582a9285 100644 --- a/src/editor/core/model/tabs.ts +++ b/src/editor/core/model/tabs.ts @@ -363,15 +363,32 @@ export function cycleTab(s: TabsState, delta: number, paneId = s.activePane): Ta } /** Moves a tab (drag & drop) within or across panes. */ -export function moveTab(s: TabsState, fromPane: string, tabId: string, toPane: string, index: number): TabsState { +/** + * Moves a tab to `index` of `toPane`. `pin` set (a drop on the pinned or the other row of a + * split tab bar): the tab takes that state and lands inside the matching group. + */ +export function moveTab( + s: TabsState, + fromPane: string, + tabId: string, + toPane: string, + index: number, + pin?: boolean, +): TabsState { const src = paneOf(s, fromPane); const tab = src.tabs.find((t) => t.id === tabId); if (!tab) return s; + const place = (tabs: Tab[], at: number): Tab[] => { + const moved = pin === undefined ? tab : { ...tab, pinned: pin, preview: pin ? false : tab.preview }; + const pins = tabs.filter((t) => t.pinned).length; + const clamped = Math.max(0, Math.min(at, tabs.length)); + tabs.splice(pin === undefined ? clamped : pin ? Math.min(clamped, pins) : Math.max(clamped, pins), 0, moved); + return tabs; + }; if (fromPane === toPane) { const tabs = src.tabs.filter((t) => t.id !== tabId); - const at = Math.max(0, Math.min(index > src.tabs.indexOf(tab) ? index - 1 : index, tabs.length)); - tabs.splice(at, 0, tab); - return { ...withPane(s, touch({ ...src, tabs }, tabId)), activePane: toPane }; + const next = place(tabs, index > src.tabs.indexOf(tab) ? index - 1 : index); + return { ...withPane(s, touch({ ...src, tabs: next }, tabId)), activePane: toPane }; } const dst = paneOf(s, toPane); const dup = dst.tabs.find((t) => t.input.key === tab.input.key); @@ -379,9 +396,7 @@ export function moveTab(s: TabsState, fromPane: string, tabId: string, toPane: s next = { ...next, closed: s.closed }; if (dup) return { ...activateTab(next, toPane, dup.id), activePane: toPane }; const dstNow = paneOf(next, toPane); - const tabs = [...dstNow.tabs]; - tabs.splice(Math.max(0, Math.min(index, tabs.length)), 0, tab); - return { ...withPane(next, touch({ ...dstNow, tabs }, tab.id)), activePane: toPane }; + return { ...withPane(next, touch({ ...dstNow, tabs: place([...dstNow.tabs], index) }, tab.id)), activePane: toPane }; } // --- splits ----------------------------------------------------------------- diff --git a/src/editor/core/view/EditorArea.tsx b/src/editor/core/view/EditorArea.tsx index 68ca7cd89..2dccfd9e4 100644 --- a/src/editor/core/view/EditorArea.tsx +++ b/src/editor/core/view/EditorArea.tsx @@ -223,24 +223,23 @@ function PaneView({ paneId }: { paneId: string }) { const pinnedCount = tabs.filter((t) => t.pinned).length; // Settings › Editor Tabs › Tab placement Left / Right: a vertical, scrolling column. const vertical = tabPrefs.placement === "left" || tabPrefs.placement === "right"; - const pinnedRow = - tabPrefs.pinnedSeparateRow && (vertical || !tabPrefs.multipleRows) && pinnedCount > 0 && pinnedCount < tabs.length; + const pinnedRow = tabPrefs.pinnedSeparateRow && pinnedCount > 0 && pinnedCount < tabs.length; const dnd = useMemo(() => { // A tab released outside the window detaches into a forced new window. const dropOutside = (raw: string) => { const p = parseTabPayload(raw); if (p) void ctrl.detachTab(p.paneId, p.tabId); }; - const strip = (offset: number): TabsDnd => ({ + const strip = (offset: number, pin?: boolean): TabsDnd => ({ type: EDITOR_TAB_MIME, payload: (tabId) => JSON.stringify({ paneId, tabId }), onDrop: (raw, index) => { const p = parseTabPayload(raw); - if (p) ctrl.moveTab(p.paneId, p.tabId, paneId, index + offset); + if (p) ctrl.moveTab(p.paneId, p.tabId, paneId, index + offset, pin); }, onDropOutside: dropOutside, }); - return { all: strip(0), rest: strip(pinnedCount) }; + return { all: strip(0), pinned: strip(0, true), rest: strip(pinnedCount, false) }; }, [ctrl, paneId, pinnedCount]); // Dropping a tab or project files on the editor: an edge splits, the middle opens / moves here. @@ -343,7 +342,7 @@ function PaneView({ paneId }: { paneId: string }) { {pinnedRow ? ( // Settings › Editor Tabs › Show pinned tabs in a separate row.