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 <[email protected]>
Цей коміт міститься в:
ntbang0901 authored and GitHub committed 2026-11-04 23:10:28 +08:00
1 джерело a363544625
коміт bdc108a61b
5 files changed
+131 -15

No files matched your search

+2 -2
Переглянути файл
@@ -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) {
+30
Переглянути файл
@@ -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"]);
});
});
+22 -7
Переглянути файл
@@ -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 -----------------------------------------------------------------
+5 -6
Переглянути файл
@@ -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.
<div className={clsx(styles.tabRows, hidden && styles.tabsHidden)}>
{strip(items.slice(0, pinnedCount), dnd.all, vertical ? actions : undefined)}
{strip(items.slice(0, pinnedCount), dnd.pinned, vertical ? actions : undefined)}
{strip(items.slice(pinnedCount), dnd.rest, vertical ? undefined : actions)}
</div>
) : (
+72
Переглянути файл
@@ -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<object>()),
editorController: () => ({ publishContext: vi.fn(), tabMenu: () => [] }),
}));
vi.mock("./TextEditorHost", () => ({ TextEditorHost: () => <input aria-label="Source" /> }));
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<string, unknown>) {
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(<EditorAreaView />);
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(<EditorAreaView />);
expect(screen.getAllByRole("tablist", { name: "Editor tabs" })).toHaveLength(1);
});
});