дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 03:05:40 +02:00
Adds three split actions to the editor tab menu, the Window › Editor Tabs menu and Find Action: Open in Opposite Group keeps the tab and opens a copy in the neighbouring group (splitting when there is none), Change Splitter Orientation flips the split holding the current group, and Unsplit All closes every other group.
324 рядки
12 KiB
TypeScript
324 рядки
12 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]);
|
|
});
|
|
});
|