Файли
openidea/src/editor/core/model/model.test.ts
T
ntbang0901 bdc108a61b 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]>
2026-11-04 23:10:28 +08:00

354 рядки
13 KiB
TypeScript

import { describe, expect, it } from "vitest";
import {
changeSplitOrientation,
closeAll,
closeKey,
closeOthers,
closeTab,
closeToLeft,
closeToRight,
closeUnmodified,
cycleTab,
findTab,
initialTabsState,
moveTab,
openTab,
paneIds,
promoteTab,
reopenClosed,
sortTabs,
splitPane,
tabOptions,
togglePin,
unsplitAll,
type TabsState,
} from "./tabs";
import { NavigationHistory } from "./navHistory";
import { emptyStack, extendSelection, shrinkSelection } from "./selectionStack";
import { humpLeft, humpRight } from "./camelHumps";
const file = (path: string) => ({ kind: "file", key: `file:${path}`, path });
const labels = (s: TabsState, pane = s.activePane) =>
s.panes[pane]!.tabs.map((t) => `${t.input.path}${t.preview ? "*" : ""}${t.pinned ? "^" : ""}`);
const active = (s: TabsState) => {
const p = s.panes[s.activePane]!;
return p.tabs.find((t) => t.id === p.active)?.input.path;
};
function open(s: TabsState, path: string, opts = {}) {
return openTab(s, s.activePane, file(path), opts).state;
}
describe("tab model", () => {
it("opens new tabs right of the active one and reuses existing tabs", () => {
let s = initialTabsState();
s = open(s, "a");
s = open(s, "b");
s = open(s, "c");
s = openTab(s, s.activePane, file("a")).state;
s = open(s, "d");
expect(labels(s)).toEqual(["a", "d", "b", "c"]);
expect(active(s)).toBe("d");
});
it("keeps a single preview tab that the next preview replaces", () => {
let s = initialTabsState();
s = open(s, "a");
s = open(s, "p1", { preview: true });
s = open(s, "p2", { preview: true });
expect(labels(s)).toEqual(["a", "p2*"]);
// Opening explicitly (or editing) promotes the preview.
s = open(s, "p2");
expect(labels(s)).toEqual(["a", "p2"]);
s = open(s, "p3", { preview: true });
const tab = s.panes[s.activePane]!.tabs.find((t) => t.preview)!;
s = promoteTab(s, s.activePane, tab.id);
expect(labels(s)).toEqual(["a", "p2", "p3"]);
});
it("pins tabs to the front and protects them from bulk close", () => {
let s = initialTabsState();
for (const p of ["a", "b", "c", "d"]) s = open(s, p);
const pane = s.panes[s.activePane]!;
const c = pane.tabs[2]!;
s = togglePin(s, s.activePane, c.id);
expect(labels(s)).toEqual(["c^", "a", "b", "d"]);
const b = s.panes[s.activePane]!.tabs[2]!;
s = closeOthers(s, s.activePane, b.id);
expect(labels(s)).toEqual(["c^", "b"]);
expect(active(s)).toBe("b");
s = open(s, "e");
s = closeToRight(s, s.activePane, s.panes[s.activePane]!.tabs[0]!.id);
expect(labels(s)).toEqual(["c^"]);
s = open(s, "f");
s = closeAll(s, s.activePane, true);
expect(labels(s)).toEqual(["c^"]);
s = closeAll(s, s.activePane);
expect(labels(s)).toEqual([]);
});
it("closes tabs to the left and unmodified tabs, keeping pinned ones", () => {
let s = initialTabsState();
for (const p of ["a", "b", "c", "d"]) s = open(s, p);
s = togglePin(s, s.activePane, s.panes[s.activePane]!.tabs[0]!.id);
s = closeToLeft(s, s.activePane, s.panes[s.activePane]!.tabs[2]!.id);
expect(labels(s)).toEqual(["a^", "c", "d"]);
s = open(s, "e");
s = closeUnmodified(s, s.activePane, (t) => t.input.path === "d");
expect(labels(s)).toEqual(["a^", "d"]);
});
it("activates the left neighbour when closing the active tab (IDEA default)", () => {
let s = initialTabsState();
for (const p of ["a", "b", "c"]) s = open(s, p);
s = openTab(s, s.activePane, file("a")).state;
s = openTab(s, s.activePane, file("c")).state;
s = closeTab(s, s.activePane, findTab(s, "file:c")!.tab.id);
expect(active(s)).toBe("b");
// The first tab has no left neighbour: the right one.
s = openTab(s, s.activePane, file("a")).state;
s = closeTab(s, s.activePane, findTab(s, "file:a")!.tab.id);
expect(active(s)).toBe("b");
});
it("activates the most recently used tab when closing the active one if set", () => {
tabOptions.activateOnClose = "mru";
try {
let s = initialTabsState();
for (const p of ["a", "b", "c"]) s = open(s, p);
s = openTab(s, s.activePane, file("a")).state;
s = openTab(s, s.activePane, file("c")).state;
const c = findTab(s, "file:c")!.tab;
s = closeTab(s, s.activePane, c.id);
expect(active(s)).toBe("a");
} finally {
tabOptions.activateOnClose = "left";
}
});
it("closes the preview tab when a regular tab opens", () => {
let s = initialTabsState();
s = open(s, "a");
s = open(s, "p", { preview: true });
s = open(s, "b");
expect(labels(s)).toEqual(["a", "b"]);
expect(active(s)).toBe("b");
expect(s.panes[s.activePane]!.mru).toHaveLength(2);
// Reopening an existing regular tab keeps the preview.
s = open(s, "q", { preview: true });
s = open(s, "a");
expect(labels(s)).toEqual(["a", "b", "q*"]);
});
it("reopens closed tabs at their position, most recent first", () => {
let s = initialTabsState();
for (const p of ["a", "b", "c"]) s = open(s, p);
s = closeTab(s, s.activePane, findTab(s, "file:b")!.tab.id);
s = closeTab(s, s.activePane, findTab(s, "file:c")!.tab.id);
s = reopenClosed(s)!.state;
expect(labels(s)).toEqual(["a", "c"]);
s = reopenClosed(s)!.state;
expect(labels(s)).toEqual(["a", "b", "c"]);
expect(reopenClosed(s)).toBeNull();
});
it("splits, moves tabs across panes and collapses empty panes", () => {
let s = initialTabsState();
s = open(s, "a");
s = open(s, "b");
const first = s.activePane;
const split = splitPane(s, first, "row");
s = split.state;
expect(paneIds(s)).toEqual([first, split.paneId]);
expect(labels(s, split.paneId)).toEqual(["b"]);
expect(s.activePane).toBe(split.paneId);
s = moveTab(s, first, findTab(s, "file:a", first)!.tab.id, split.paneId, 0);
expect(labels(s, split.paneId)).toEqual(["a", "b"]);
expect(labels(s, first)).toEqual(["b"]);
s = closeTab(s, first, s.panes[first]!.tabs[0]!.id);
expect(paneIds(s)).toEqual([split.paneId]);
expect(s.layout).toEqual({ kind: "pane", paneId: split.paneId });
});
it("flips the orientation of a split and unsplits everything", () => {
let s = open(initialTabsState(), "a");
const first = s.activePane;
const right = splitPane(s, first, "row");
const below = splitPane(right.state, right.paneId, "column");
s = below.state;
const dirs = (st: TabsState) =>
st.layout.kind === "split"
? [st.layout.dir, ...st.layout.children.map((c) => (c.kind === "split" ? c.dir : "pane"))]
: [];
expect(dirs(s)).toEqual(["row", "pane", "column"]);
// The nested split turns side by side and merges into the outer row.
s = changeSplitOrientation(s, below.paneId);
expect(dirs(s)).toEqual(["row", "pane", "pane", "pane"]);
s = changeSplitOrientation(s, first);
expect(dirs(s)).toEqual(["column", "pane", "pane", "pane"]);
expect(changeSplitOrientation(open(initialTabsState(), "x"), "nope").layout.kind).toBe("pane");
s = unsplitAll(s, right.paneId);
expect(paneIds(s)).toEqual([right.paneId]);
expect(s.activePane).toBe(right.paneId);
expect(labels(s)).toEqual(["a"]);
});
it("closes a key everywhere and cycles tabs", () => {
let s = initialTabsState();
s = open(s, "a");
s = open(s, "b");
s = splitPane(s, s.activePane, "column").state;
s = closeKey(s, "file:b");
expect(paneIds(s)).toHaveLength(1);
expect(labels(s)).toEqual(["a"]);
s = open(s, "c");
s = cycleTab(s, 1);
expect(active(s)).toBe("a");
s = cycleTab(s, -1);
expect(active(s)).toBe("c");
});
it("sorts tabs alphabetically, pinned ones first", () => {
let s = initialTabsState();
for (const p of ["c", "B", "a", "d"]) s = open(s, p);
s = togglePin(s, s.activePane, s.panes[s.activePane]!.tabs.find((t) => t.input.path === "d")!.id);
s = sortTabs(s, s.activePane, (t) => t.input.path!);
expect(labels(s)).toEqual(["d^", "a", "B", "c"]);
expect(sortTabs(s, s.activePane, (t) => t.input.path!)).toBe(s);
});
});
describe("navigation history", () => {
it("records file switches and navigations, not plain caret moves", () => {
let t = 0;
const h = new NavigationHistory(() => ++t);
h.update({ path: "a", line: 1, col: 0 });
h.update({ path: "a", line: 5, col: 2 });
// Arrows / paging / typing move the current place, however far.
h.update({ path: "a", line: 300, col: 0 });
expect(h.entries).toHaveLength(1);
h.update({ path: "a", line: 100, col: 0 }, true);
h.update({ path: "b", line: 0, col: 0 });
expect(h.entries.map((e) => `${e.path}:${e.line}`)).toEqual(["a:300", "a:100", "b:0"]);
expect(h.back()).toMatchObject({ path: "a", line: 100 });
expect(h.back()).toMatchObject({ path: "a", line: 300 });
expect(h.back()).toBeNull();
expect(h.forward()).toMatchObject({ path: "a", line: 100 });
// Navigating somewhere new from the middle drops the forward stack.
h.update({ path: "c", line: 3, col: 0 });
expect(h.entries.map((e) => e.path)).toEqual(["a", "a", "c"]);
expect(h.canGoForward()).toBe(false);
h.removePath("a");
expect(h.snapshot()).toMatchObject({ index: 0, entries: [{ path: "c" }] });
});
it("a navigation on the same line records nothing", () => {
const h = new NavigationHistory();
h.update({ path: "a", line: 7, col: 0 });
h.update({ path: "a", line: 7, col: 9 }, true);
expect(h.entries).toHaveLength(1);
});
it("merges back places less than four lines apart", () => {
const h = new NavigationHistory();
h.update({ path: "a", line: 10, col: 0 });
h.update({ path: "a", line: 50, col: 0 }, true);
h.update({ path: "a", line: 12, col: 0 });
h.update({ path: "a", line: 80, col: 0 }, true);
// a:10 and a:12 are one place: the newer one stays.
expect(h.entries.map((e) => e.line)).toEqual([12, 80]);
h.update({ path: "a", line: 20, col: 0 }, true);
expect(h.entries.map((e) => e.line)).toEqual([12, 80, 20]);
});
it("arriving at a back target updates it in place", () => {
const h = new NavigationHistory();
h.update({ path: "a", line: 0, col: 0 });
h.update({ path: "b", line: 0, col: 0 });
const target = h.back()!;
h.update({ path: target.path, line: target.line, col: 4 });
expect(h.entries).toHaveLength(2);
expect(h.index).toBe(0);
});
});
describe("selection stack", () => {
const cands = [
{ from: 4, to: 7 },
{ from: 0, to: 20 },
{ from: 3, to: 9 },
];
it("extends to the innermost containing range and shrinks back", () => {
let st = emptyStack();
let cur = { from: 5, to: 5 };
let r = extendSelection(st, cur, cands);
expect(r.next).toEqual({ from: 4, to: 7 });
st = r.state;
cur = r.next!;
r = extendSelection(st, cur, cands);
expect(r.next).toEqual({ from: 3, to: 9 });
st = r.state;
cur = r.next!;
r = shrinkSelection(st, cur);
expect(r.next).toEqual({ from: 4, to: 7 });
r = shrinkSelection(r.state, r.next!);
expect(r.next).toEqual({ from: 5, to: 5 });
expect(shrinkSelection(r.state, r.next!).next).toBeNull();
});
it("resets when the selection changed in between", () => {
const r = extendSelection(emptyStack(), { from: 5, to: 5 }, cands);
expect(shrinkSelection(r.state, { from: 0, to: 1 }).next).toBeNull();
});
});
describe("camel humps", () => {
const stops = (text: string, dir: "left" | "right") => {
const out: number[] = [];
let pos = dir === "right" ? 0 : text.length;
for (let i = 0; i < 20; i++) {
const next = dir === "right" ? humpRight(text, pos) : humpLeft(text, pos);
if (next === pos) break;
out.push((pos = next));
}
return out;
};
it("stops at camel-case, acronym and underscore boundaries", () => {
expect(stops("parseHTMLDocument", "right")).toEqual([5, 9, 17]);
expect(stops("parseHTMLDocument", "left")).toEqual([9, 5, 0]);
expect(stops("snake_case_name", "right")).toEqual([6, 11, 15]);
expect(stops("foo.barBaz(x)", "right")).toEqual([3, 4, 7, 10, 11, 12, 13]);
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"]);
});
});