From bdc108a61b61a06b087489bfdbb0cbe1b3858553 Mon Sep 17 00:00:00 2001 From: ntbang0901 <121003585+ntbang0901@users.noreply.github.com> Date: Wed, 4 Nov 2026 22:10:28 +0700 Subject: [PATCH] fix(editor): show pinned tabs in their own row when Multiple rows is on (#1198) "Show pinned tabs in a separate row" was ignored while "Multiple rows" was on, because the pinned row explicitly excluded horizontal layouts with multiple rows. The two options are independent, so the pinned row now shows whenever some but not all tabs are pinned, and each row wraps on its own. With the rows split, dropping a tab on the pinned row pins it and keeps it in the pinned group, and dropping it on the other row unpins it. Co-authored-by: ntbang0901 <121003585+ntbang0901@users.noreply.github.com> --- src/editor/core/controller.ts | 4 +- src/editor/core/model/model.test.ts | 30 +++++++++ src/editor/core/model/tabs.ts | 29 +++++++-- src/editor/core/view/EditorArea.tsx | 11 ++-- src/editor/core/view/pinnedTabsRow.test.tsx | 72 +++++++++++++++++++++ 5 files changed, 131 insertions(+), 15 deletions(-) create mode 100644 src/editor/core/view/pinnedTabsRow.test.tsx 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.