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. ) : ( diff --git a/src/editor/core/view/pinnedTabsRow.test.tsx b/src/editor/core/view/pinnedTabsRow.test.tsx new file mode 100644 index 000000000..55bb45c05 --- /dev/null +++ b/src/editor/core/view/pinnedTabsRow.test.tsx @@ -0,0 +1,72 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { cleanup, render, screen, within } from "@testing-library/react"; +import { useProject } from "@/platform/project"; +import { useTabs } from "../controller"; +import type { Tab } from "../model/tabs"; +import { EditorAreaView } from "./EditorArea"; + +vi.mock("../controller", async (original) => ({ + ...(await original()), + editorController: () => ({ publishContext: vi.fn(), tabMenu: () => [] }), +})); +vi.mock("./TextEditorHost", () => ({ TextEditorHost: () => })); +vi.mock("./FindBar", () => ({ FindBar: () => null })); +vi.mock("./Banner", () => ({ EditorBanners: () => null, VirtualBanner: () => null })); +vi.mock("./StatusItems", () => ({ CodeBreadcrumbs: () => null })); + +const tab = (n: number, pinned: boolean): Tab => ({ + id: `t${n}`, + input: { kind: "file", key: `/p/f${n}.txt`, path: `/p/f${n}.txt` }, + preview: false, + pinned, +}); +const originalTabs = useTabs.getState(); +const originalProject = useProject.getState(); + +function seed(pinned: boolean[], tabs: Record) { + const list = pinned.map((p, i) => tab(i + 1, p)); + useTabs.setState({ + layout: { kind: "pane", paneId: "p1" }, + panes: { p1: { id: "p1", tabs: list, active: list[0]!.id, mru: list.map((t) => t.id) } }, + activePane: "p1", + closed: [], + }); + useProject.setState({ settings: { editor: { tabs } } as never }); +} + +beforeEach(() => cleanup()); +afterEach(() => { + cleanup(); + useTabs.setState(originalTabs, true); + useProject.setState(originalProject, true); +}); + +describe("pinned tabs in a separate row", () => { + it.each([ + { placement: "top", multipleRows: true, pinnedSeparateRow: true, rows: 2 }, + { placement: "bottom", multipleRows: true, pinnedSeparateRow: true, rows: 2 }, + { placement: "top", multipleRows: false, pinnedSeparateRow: true, rows: 2 }, + { placement: "left", multipleRows: false, pinnedSeparateRow: true, rows: 2 }, + { placement: "top", multipleRows: true, pinnedSeparateRow: false, rows: 1 }, + { placement: "top", multipleRows: false, pinnedSeparateRow: false, rows: 1 }, + ])("$placement multipleRows=$multipleRows pinnedRow=$pinnedSeparateRow → $rows strip(s)", (c) => { + const { rows, ...tabs } = c; + seed([true, true, false, false, false], tabs); + render(); + const lists = screen.getAllByRole("tablist", { name: "Editor tabs" }); + expect(lists).toHaveLength(rows); + if (rows === 2) { + expect(within(lists[0]!).getAllByRole("tab")).toHaveLength(2); + expect(within(lists[1]!).getAllByRole("tab")).toHaveLength(3); + } + }); + + it.each([ + ["every tab pinned", [true, true, true]], + ["no tab pinned", [false, false, false]], + ])("%s → one strip", (_, pinned) => { + seed(pinned, { placement: "top", multipleRows: true, pinnedSeparateRow: true }); + render(); + expect(screen.getAllByRole("tablist", { name: "Editor tabs" })).toHaveLength(1); + }); +});