feat(split-screen): redesign split screen with free-form panes and pane picker

This commit is contained in:
LukeGus committed 2026-09-28 19:49:04 -05:00
1 parent 5f3372f9f6
commit 81e7aa6274
46 files changed
+5027 -2537

No files matched your search

+19
View File
@@ -0,0 +1,19 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { createId } from "@/lib/create-id";
afterEach(() => vi.unstubAllGlobals());
describe("createId", () => {
it("uses randomUUID when available", () => {
vi.stubGlobal("crypto", { randomUUID: () => "uuid-1" });
expect(createId()).toBe("uuid-1");
});
it("falls back without randomUUID, e.g. over plain http", () => {
vi.stubGlobal("crypto", {});
const first = createId();
const second = createId();
expect(first).toBeTruthy();
expect(second).not.toBe(first);
});
});
-148
View File
@@ -1,148 +0,0 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import { render, screen, cleanup, fireEvent } from "@testing-library/react";
import type { Tab } from "@/types/ui-types";
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string, opts?: Record<string, unknown>) =>
opts ? `${key}:${JSON.stringify(opts)}` : key,
}),
}));
import { SplitView, defaultSizes } from "../../shell/SplitView";
function makeTab(overrides: Partial<Tab> = {}): Tab {
return {
id: "tab-1",
instanceId: "instance-1",
type: "terminal",
label: "web-01",
openedAt: 0,
...overrides,
} as Tab;
}
afterEach(() => {
cleanup();
});
describe("defaultSizes", () => {
it("returns the expected default split for 2-way", () => {
expect(defaultSizes("2-way")).toEqual({
rowSizes: [100],
rowColSizes: [[50, 50]],
});
});
it("returns stacked defaults for a horizontal 2-way layout", () => {
expect(defaultSizes("2-way-horizontal")).toEqual({
rowSizes: [50, 50],
rowColSizes: [[100], [100]],
});
});
it("returns a single full pane for 'none'", () => {
expect(defaultSizes("none")).toEqual({
rowSizes: [100],
rowColSizes: [[100]],
});
});
});
describe("SplitView - controlled rowSizes/rowColSizes", () => {
it("renders panes using externally-supplied non-default sizes, not the built-in default", () => {
const tabs = [makeTab({ id: "t1" }), makeTab({ id: "t2", label: "t2" })];
const { container } = render(
<SplitView
tabs={tabs}
paneTabIds={["t1", "t2", null, null, null, null]}
splitMode="2-way"
rowSizes={[100]}
rowColSizes={[[30, 70]]}
onRowSizesChange={() => {}}
onRowColSizesChange={() => {}}
/>,
);
// The first pane's flex column width should reflect the supplied 30%,
// not the 50/50 default - this is the regression guard for the
// rowSizes/rowColSizes lift-up out of SplitView's old internal state.
const widthStyled = container.querySelectorAll('[style*="width: 30%"]');
expect(widthStyled.length).toBeGreaterThan(0);
});
it("calls onReset (not an internal reset) when the Reset button is clicked", () => {
const onReset = vi.fn();
const tabs = [makeTab({ id: "t1" })];
render(
<SplitView
tabs={tabs}
paneTabIds={["t1", null, null, null, null, null]}
splitMode="2-way"
rowSizes={[100]}
rowColSizes={[[30, 70]]}
onRowSizesChange={() => {}}
onRowColSizesChange={() => {}}
onReset={onReset}
/>,
);
fireEvent.click(screen.getByTitle("Reset to equal split"));
expect(onReset).toHaveBeenCalledTimes(1);
});
it("releases a session from its pane from the pane header", () => {
const onAssignPane = vi.fn();
render(
<SplitView
tabs={[makeTab({ id: "t1" })]}
paneTabIds={["t1", null, null, null, null, null]}
splitMode="2-way"
rowSizes={[100]}
rowColSizes={[[50, 50]]}
onRowSizesChange={() => {}}
onRowColSizesChange={() => {}}
onAssignPane={onAssignPane}
/>,
);
fireEvent.click(screen.getByLabelText("terminal.split.removeFromSplit"));
expect(onAssignPane).toHaveBeenCalledWith(0, "");
});
it("does not mutate rowSizes/rowColSizes props locally - reflects prop changes on rerender", () => {
const tabs = [makeTab({ id: "t1" }), makeTab({ id: "t2", label: "t2" })];
const { container, rerender } = render(
<SplitView
tabs={tabs}
paneTabIds={["t1", "t2", null, null, null, null]}
splitMode="2-way"
rowSizes={[100]}
rowColSizes={[[50, 50]]}
onRowSizesChange={() => {}}
onRowColSizesChange={() => {}}
/>,
);
expect(
container.querySelectorAll('[style*="width: 50%"]').length,
).toBeGreaterThan(0);
rerender(
<SplitView
tabs={tabs}
paneTabIds={["t1", "t2", null, null, null, null]}
splitMode="2-way"
rowSizes={[100]}
rowColSizes={[[20, 80]]}
onRowSizesChange={() => {}}
onRowColSizesChange={() => {}}
/>,
);
expect(
container.querySelectorAll('[style*="width: 20%"]').length,
).toBeGreaterThan(0);
});
});
+120 -40
View File
@@ -1,9 +1,14 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render } from "@testing-library/react";
import type { ComponentProps } from "react";
import type { Tab } from "@/types/ui-types";
import type { SplitSummary } from "@/shell/split/split-tabs";
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (key: string) => key }),
useTranslation: () => ({
t: (key: string, opts?: Record<string, unknown>) =>
opts ? `${key}:${JSON.stringify(opts)}` : key,
}),
}));
vi.mock("@/lib/electron", () => ({ isElectron: () => true }));
@@ -17,52 +22,33 @@ const tabs = [
{ id: "terminal-1", type: "terminal", label: "web-01" },
] as Tab[];
function renderTabBar(activeTabId: string, onSetActiveTab = vi.fn()) {
return render(
<TabBar
tabs={tabs}
activeTabId={activeTabId}
splitMode="none"
paneTabIds={[]}
focusedPaneIndex={null}
onSetActiveTab={onSetActiveTab}
onCloseTab={() => {}}
onRefreshTab={() => {}}
onReorderTabs={() => {}}
onSplitTab={() => {}}
onAddToSplit={() => {}}
onRemoveFromSplit={() => {}}
isAppFullscreen={false}
onToggleAppFullscreen={() => {}}
/>,
);
function props(
overrides: Partial<ComponentProps<typeof TabBar>> = {},
): ComponentProps<typeof TabBar> {
return {
tabs,
activeTabId: "dashboard",
splits: [],
activeSplitFull: false,
onSetActiveTab: () => {},
onCloseTab: () => {},
onRefreshTab: () => {},
onReorderTabs: () => {},
onSplitAction: () => {},
isAppFullscreen: false,
onToggleAppFullscreen: () => {},
...overrides,
};
}
describe("TabBar workspace continuity", () => {
it("keeps one shared indicator on the active workspace", () => {
const { container, rerender } = renderTabBar("dashboard");
const { container, rerender } = render(<TabBar {...props()} />);
expect(
container.querySelector('[data-tab-indicator="dashboard"]'),
).toBeTruthy();
rerender(
<TabBar
tabs={tabs}
activeTabId="terminal-1"
splitMode="none"
paneTabIds={[]}
focusedPaneIndex={null}
onSetActiveTab={() => {}}
onCloseTab={() => {}}
onRefreshTab={() => {}}
onReorderTabs={() => {}}
onSplitTab={() => {}}
onAddToSplit={() => {}}
onRemoveFromSplit={() => {}}
isAppFullscreen={false}
onToggleAppFullscreen={() => {}}
/>,
);
rerender(<TabBar {...props({ activeTabId: "terminal-1" })} />);
expect(container.querySelectorAll("[data-tab-indicator]")).toHaveLength(1);
expect(
@@ -72,10 +58,104 @@ describe("TabBar workspace continuity", () => {
it("activates a workspace immediately when its tab is clicked", () => {
const onSetActiveTab = vi.fn();
const { getByText } = renderTabBar("dashboard", onSetActiveTab);
const { getByText } = render(<TabBar {...props({ onSetActiveTab })} />);
fireEvent.click(getByText("web-01"));
expect(onSetActiveTab).toHaveBeenCalledWith("terminal-1");
});
});
describe("TabBar split menu", () => {
const split: SplitSummary = {
id: "split-1",
label: "Split 1",
full: false,
panes: [
{ id: "p1", index: 1, tabId: "terminal-9", label: "db-01" },
{ id: "p2", index: 2, tabId: null, label: null },
],
};
function openMenu(overrides: Partial<ComponentProps<typeof TabBar>> = {}) {
const onSplitAction = vi.fn();
const view = render(<TabBar {...props({ onSplitAction, ...overrides })} />);
fireEvent.contextMenu(view.getByText("web-01"));
return { ...view, onSplitAction };
}
it("splits the clicked tab right or down", () => {
const { getByText, onSplitAction } = openMenu();
fireEvent.click(getByText("splitScreen.splitDown"));
expect(onSplitAction).toHaveBeenCalledWith({
kind: "split",
tabId: "terminal-1",
edge: "bottom",
});
});
it("lists every pane of every split, so the target is always clear", () => {
const { getByText, onSplitAction } = openMenu({ splits: [split] });
expect(
getByText('splitScreen.addToSplit:{"split":"Split 1"}'),
).toBeTruthy();
expect(
getByText('splitScreen.paneItem:{"index":1,"label":"db-01"}'),
).toBeTruthy();
fireEvent.click(getByText('splitScreen.paneEmptyItem:{"index":2}'));
expect(onSplitAction).toHaveBeenCalledWith({
kind: "addToPane",
tabId: "terminal-1",
splitTabId: "split-1",
paneId: "p2",
});
});
it("adds a new pane to an existing split", () => {
const { getByText, onSplitAction } = openMenu({ splits: [split] });
fireEvent.click(getByText("splitScreen.newPaneBelow"));
expect(onSplitAction).toHaveBeenCalledWith({
kind: "addPane",
tabId: "terminal-1",
splitTabId: "split-1",
edge: "bottom",
});
});
it("disables new panes on a full split", () => {
const { getByText } = openMenu({ splits: [{ ...split, full: true }] });
expect(
(
getByText("splitScreen.newPaneRight").closest(
"button",
) as HTMLButtonElement
).disabled,
).toBe(true);
});
it("offers unsplit on a split tab instead of splitting it", () => {
const splitTab = {
id: "split-1",
type: "split-screen",
label: "Split 1",
split: {
root: { kind: "pane", id: "p1", tabId: null },
focusedPaneId: "p1",
},
} as Tab;
const onSplitAction = vi.fn();
const { getByText, queryByText } = render(
<TabBar {...props({ tabs: [...tabs, splitTab], onSplitAction })} />,
);
fireEvent.contextMenu(getByText("Split 1"));
expect(queryByText("splitScreen.splitRight")).toBeNull();
fireEvent.click(getByText("splitScreen.unsplit"));
expect(onSplitAction).toHaveBeenCalledWith({
kind: "unsplit",
splitTabId: "split-1",
});
});
});
+142 -49
View File
@@ -2,12 +2,24 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
buildLayoutPayload as buildWorkspacePayload,
buildLayoutTabSnapshots as buildWorkspaceTabSnapshots,
remapSlotIds,
resolveLayoutSplits,
resolveLayoutTabTarget as resolveWorkspaceTabTarget,
snapshotData,
} from "@/shell/shell-layout";
import { registerTabType, resetTabTypes } from "@/shell/tab-registry";
import type { Host, Tab, WorkspaceTabSnapshot } from "@/types/ui-types";
import { makeSplitTab } from "@/shell/split/split-tabs";
import {
createPane,
createSplitNode,
createSplitState,
listPanes,
} from "@/shell/split/split-tree";
import type {
Host,
Tab,
WorkspacePayload,
WorkspaceTabSnapshot,
} from "@/types/ui-types";
// Plugin tab types take part in layouts only while registered, so the ones
// these cases use are registered the way their plugins would.
@@ -130,20 +142,6 @@ describe("buildWorkspaceTabSnapshots", () => {
});
});
describe("remapSlotIds", () => {
it("maps slot ids to live tab ids, preserving nulls and unresolved slots", () => {
const map = new Map([
["slot-a", "new-tab-1"],
["slot-b", "new-tab-2"],
]);
const result = remapSlotIds(
["slot-a", null, "slot-b", "slot-missing"],
map,
);
expect(result).toEqual(["new-tab-1", null, "new-tab-2", null]);
});
});
describe("resolveWorkspaceTabTarget", () => {
const hosts: Host[] = [makeHost({ id: "1", syncId: "sync-web-01" })];
@@ -221,50 +219,71 @@ describe("resolveWorkspaceTabTarget", () => {
});
});
function splitOf(id: string, tabIds: (string | null)[]): Tab {
const root = createSplitNode(
"row",
tabIds.map((tabId) => createPane(tabId)),
);
const split = makeSplitTab(id, `Split ${id}`, createSplitState(root), 0);
return split;
}
describe("buildWorkspacePayload", () => {
it("builds a full payload including pane assignment remapped to slot ids", () => {
it("saves every split with its pane tabs swapped for slot ids", () => {
let counter = 0;
const genSlotId = () => `slot-${counter++}`;
const host = makeHost();
const tabs: Tab[] = [
makeTab({ id: "t1", type: "terminal", host }),
makeTab({ id: "t2", type: "files", host }),
makeTab({ id: "t1", type: "terminal", host, parentSplitTabId: "a" }),
makeTab({ id: "t2", type: "files", host, parentSplitTabId: "a" }),
makeTab({ id: "t3", type: "terminal", host, parentSplitTabId: "b" }),
splitOf("a", ["t1", "t2", null]),
splitOf("b", ["t3"]),
];
const payload = buildWorkspacePayload({
tabs,
activeTabId: "t1",
splitMode: "2-way",
paneTabIds: ["t1", "t2", null, null, null, null],
rowSizes: [100],
rowColSizes: [[50, 50]],
activeTabId: "split-b",
genSlotId,
});
expect(payload.version).toBe(1);
expect(payload.activeSlotId).toBe("slot-0");
expect(payload.paneTabIds).toEqual([
expect(payload.version).toBe(2);
expect(payload.tabs.map((tab) => tab.slotId)).toEqual([
"slot-0",
"slot-1",
"slot-2",
]);
expect(payload.splits).toHaveLength(2);
const [first, second] = payload.splits!;
expect(first.label).toBe("Split a");
expect(listPanes(first.root).map((pane) => pane.tabId)).toEqual([
"slot-0",
"slot-1",
null,
null,
null,
null,
]);
expect(payload.splitMode).toBe("2-way");
expect(payload.rowSizes).toEqual([100]);
expect(payload.rowColSizes).toEqual([[50, 50]]);
expect(listPanes(second.root).map((pane) => pane.tabId)).toEqual([
"slot-2",
]);
expect(payload.activeSlotId).toBe(second.slotId);
expect(payload.splitMode).toBeUndefined();
});
it("leaves out a split none of whose tabs are saved", () => {
const payload = buildWorkspacePayload({
tabs: [
makeTab({ id: "w", type: "web-endpoint", parentSplitTabId: "a" }),
splitOf("a", ["w", null]),
],
activeTabId: "split-a",
});
expect(payload.splits).toEqual([]);
expect(payload.activeSlotId).toBeNull();
});
it("sets activeSlotId to null when the active tab is not capturable", () => {
const payload = buildWorkspacePayload({
tabs: [makeTab({ id: "t1", type: "host-manager" })],
activeTabId: "t1",
splitMode: "none",
paneTabIds: [null, null, null, null, null, null],
rowSizes: [100],
rowColSizes: [[100]],
});
expect(payload.activeSlotId).toBeNull();
expect(payload.tabs).toHaveLength(0);
@@ -274,10 +293,6 @@ describe("buildWorkspacePayload", () => {
const payload = buildWorkspacePayload({
tabs: [],
activeTabId: "dashboard",
splitMode: "none",
paneTabIds: [null, null, null, null, null, null],
rowSizes: [100],
rowColSizes: [[100]],
sidebar: {
left: { view: "hosts", open: true, width: 320 },
right: { view: "history", open: true, width: 240 },
@@ -290,18 +305,96 @@ describe("buildWorkspacePayload", () => {
});
});
it("omits sidebar when not supplied so older payloads stay unchanged", () => {
it("omits sidebar when not supplied", () => {
const payload = buildWorkspacePayload({
tabs: [],
activeTabId: "dashboard",
splitMode: "none",
paneTabIds: [null, null, null, null, null, null],
rowSizes: [100],
rowColSizes: [[100]],
});
expect(payload.sidebar).toBeUndefined();
});
});
describe("resolveLayoutSplits", () => {
it("rebuilds each saved split with live tab ids", () => {
const tabs: Tab[] = [
makeTab({ id: "t1", type: "terminal", parentSplitTabId: "a" }),
makeTab({ id: "t2", type: "terminal", parentSplitTabId: "a" }),
splitOf("a", ["t1", "t2"]),
];
const payload = buildWorkspacePayload({
tabs,
activeTabId: "split-a",
genSlotId: (() => {
let n = 0;
return () => `slot-${n++}`;
})(),
});
expect(payload.sidebar).toBeUndefined();
expect(payload.version).toBe(1);
const live = new Map([
["slot-0", "new-1"],
["slot-1", "new-2"],
]);
const [restored] = resolveLayoutSplits(payload, live);
expect(restored.label).toBe("Split a");
expect(restored.slotId).toBe(payload.splits![0].slotId);
expect(listPanes(restored.split.root).map((pane) => pane.tabId)).toEqual([
"new-1",
"new-2",
]);
});
it("drops a saved split none of whose tabs came back", () => {
const payload = buildWorkspacePayload({
tabs: [
makeTab({ id: "t1", type: "terminal", parentSplitTabId: "a" }),
splitOf("a", ["t1"]),
],
activeTabId: "split-a",
});
expect(resolveLayoutSplits(payload, new Map())).toEqual([]);
});
it("reads the single fixed-mode split of a version 1 payload", () => {
const payload: WorkspacePayload = {
version: 1,
tabs: [],
activeSlotId: "slot-a",
splitMode: "3-way",
paneTabIds: ["slot-a", "slot-b", "slot-c", null, null, null],
rowSizes: [30, 70],
rowColSizes: [[40, 60], [100]],
};
const live = new Map([
["slot-a", "a"],
["slot-b", "b"],
["slot-c", "c"],
]);
const [restored] = resolveLayoutSplits(payload, live);
expect(restored.label).toBeNull();
expect(restored.slotId).toBeNull();
const root = restored.split.root;
expect(root.kind).toBe("split");
if (root.kind !== "split") return;
expect(root.direction).toBe("row");
expect(root.sizes[0]).toBeCloseTo(40);
expect(listPanes(root).map((pane) => pane.tabId)).toEqual(["a", "b", "c"]);
});
it("finds no splits in a version 1 payload without one", () => {
expect(
resolveLayoutSplits(
{
version: 1,
tabs: [],
activeSlotId: null,
splitMode: "none",
paneTabIds: [],
rowSizes: [100],
rowColSizes: [[100]],
},
new Map(),
),
).toEqual([]);
});
});
+227
View File
@@ -0,0 +1,227 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { Tab } from "@/types/ui-types";
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string, opts?: Record<string, unknown>) =>
opts ? `${key}:${JSON.stringify(opts)}` : key,
}),
}));
import { SplitView, type SplitViewActions } from "@/shell/split/SplitView";
import { makeSplitTab } from "@/shell/split/split-tabs";
import {
createPane,
createSplitNode,
createSplitState,
listPanes,
toggleZoom,
} from "@/shell/split/split-tree";
afterEach(cleanup);
function tab(id: string, label: string): Tab {
return {
id,
instanceId: id,
type: "terminal",
label,
openedAt: 0,
parentSplitTabId: "split-s",
} as Tab;
}
function actions(): SplitViewActions {
return {
focusPane: vi.fn(),
resize: vi.fn(),
equalize: vi.fn(),
splitPane: vi.fn(),
closePane: vi.fn(),
closeSession: vi.fn(),
moveToTab: vi.fn(),
showInPane: vi.fn(),
swapPanes: vi.fn(),
toggleZoom: vi.fn(),
resizeEnd: vi.fn(),
};
}
function setup({
mobile = false,
zoom = false,
}: { mobile?: boolean; zoom?: boolean } = {}) {
const root = createSplitNode("row", [
createPane("t1"),
createSplitNode("column", [createPane("t2"), createPane(null)]),
]);
let state = createSplitState(root, listPanes(root)[0].id);
if (zoom) state = toggleZoom(state, listPanes(root)[1].id);
const splitTab = makeSplitTab("s", "Split 1", state, 0);
const tabs = [tab("t1", "web-01"), tab("t2", "db-01"), splitTab];
const act = actions();
const onPaneContentRef = vi.fn();
const renderEmptyPane = vi.fn((paneId: string, index: number) => (
<div data-testid={`empty-${index}`}>{paneId}</div>
));
const view = render(
<SplitView
splitTab={splitTab}
tabs={tabs}
isMobile={mobile}
actions={act}
onPaneContentRef={onPaneContentRef}
renderEmptyPane={renderEmptyPane}
/>,
);
return {
...view,
act,
onPaneContentRef,
panes: listPanes(state.root),
};
}
describe("SplitView", () => {
it("renders every pane of the tree with a numbered header", () => {
const { container, panes } = setup();
const rendered = Array.from(
container.querySelectorAll("[data-split-pane-id]"),
).map((el) => el.getAttribute("data-split-pane-id"));
expect(rendered).toEqual(panes.map((pane) => pane.id));
expect(screen.getByText("web-01")).toBeTruthy();
expect(screen.getByText("db-01")).toBeTruthy();
});
it("hands each filled pane's content element to the shell", () => {
const { onPaneContentRef, panes } = setup();
const registered = onPaneContentRef.mock.calls
.filter(([, el]) => el)
.map(([paneId]) => paneId);
expect(registered).toEqual([panes[0].id, panes[1].id]);
});
it("shows the picker in an empty pane, with its number", () => {
const { panes } = setup();
expect(screen.getByTestId("empty-3").textContent).toBe(panes[2].id);
});
it("focuses a pane when it is pressed", () => {
const { container, act, panes } = setup();
const pane = container.querySelector(
`[data-split-pane-id="${panes[1].id}"]`,
)!;
fireEvent.pointerDown(pane);
expect(act.focusPane).toHaveBeenCalledWith(panes[1].id);
});
it("focuses a pane from a click inside content the shell moved in", () => {
const { act, onPaneContentRef, panes } = setup();
const contentEl = onPaneContentRef.mock.calls.find(
([paneId, el]) => paneId === panes[1].id && el,
)![1] as HTMLDivElement;
// What the shell does with a tab's portal node.
const tabNode = document.createElement("div");
contentEl.appendChild(tabNode);
fireEvent.pointerDown(tabNode);
expect(act.focusPane).toHaveBeenCalledWith(panes[1].id);
});
it("focuses a pane when keyboard focus moves into it", () => {
const { act, onPaneContentRef, panes } = setup();
const contentEl = onPaneContentRef.mock.calls.find(
([paneId, el]) => paneId === panes[0].id && el,
)![1] as HTMLDivElement;
const input = document.createElement("textarea");
contentEl.appendChild(input);
input.focus();
expect(act.focusPane).toHaveBeenCalledWith(panes[0].id);
});
it("outlines only the focused pane, above its content", () => {
const { container, panes } = setup();
const outlined = Array.from(
container.querySelectorAll("[data-split-pane-id]"),
).filter((el) => el.querySelector(".border-accent-brand\\/70"));
expect(outlined.map((el) => el.getAttribute("data-split-pane-id"))).toEqual(
[panes[0].id],
);
});
it("runs the pane menu's actions", async () => {
const user = userEvent.setup();
const { container, act, panes } = setup();
const pane = container.querySelector(
`[data-split-pane-id="${panes[0].id}"]`,
)!;
await user.click(
pane.querySelector('[aria-label="splitScreen.paneActions"]')!,
);
await user.click(await screen.findByText("splitScreen.closePane"));
expect(act.closePane).toHaveBeenCalledWith(panes[0].id);
await user.click(
pane.querySelector('[aria-label="splitScreen.paneActions"]')!,
);
await user.click(await screen.findByText("splitScreen.moveToTab"));
expect(act.moveToTab).toHaveBeenCalledWith(panes[0].id);
});
it("switches a pane's tab from the header dropdown", async () => {
const user = userEvent.setup();
const { container, act, panes } = setup();
const pane = container.querySelector(
`[data-split-pane-id="${panes[0].id}"]`,
)!;
await user.click(pane.querySelector('[title="splitScreen.showHere"]')!);
await user.click(await screen.findByText("splitScreen.clearPane"));
expect(act.showInPane).toHaveBeenCalledWith(panes[0].id, null);
});
it("splits a pane from its header", () => {
const { container, act, panes } = setup();
const pane = container.querySelector(
`[data-split-pane-id="${panes[0].id}"]`,
)!;
fireEvent.click(
pane.querySelector('[aria-label="splitScreen.splitDown"]')!,
);
expect(act.splitPane).toHaveBeenCalledWith(panes[0].id, "bottom");
});
it("toggles zoom from the header", () => {
const { container, act, panes } = setup();
const pane = container.querySelector(
`[data-split-pane-id="${panes[0].id}"]`,
)!;
fireEvent.click(pane.querySelector('[aria-label="splitScreen.zoom"]')!);
expect(act.toggleZoom).toHaveBeenCalledWith(panes[0].id);
});
it("evens out a group on a divider double-click", () => {
const { container, act } = setup();
const dividers = container.querySelectorAll('[role="separator"]');
expect(dividers).toHaveLength(2);
fireEvent.doubleClick(dividers[0]);
expect(act.equalize).toHaveBeenCalled();
expect(act.resizeEnd).toHaveBeenCalled();
});
it("shows only the zoomed pane", () => {
const { container, panes } = setup({ zoom: true });
const rendered = Array.from(
container.querySelectorAll("[data-split-pane-id]"),
).map((el) => el.getAttribute("data-split-pane-id"));
expect(rendered).toEqual([panes[1].id]);
});
it("on mobile shows one pane and a strip to switch panes", () => {
const { container, act, panes } = setup({ mobile: true });
expect(container.querySelectorAll("[data-split-pane-id]")).toHaveLength(1);
fireEvent.click(screen.getAllByText("db-01")[0]);
expect(act.focusPane).toHaveBeenCalledWith(panes[1].id);
});
});
@@ -0,0 +1,74 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
beginSplitDrag,
dropTargetAt,
dropTargetRect,
endSplitDrag,
isSplitDragging,
moveSplitDrag,
setSplitDropHandler,
setSplitDropHitTest,
} from "@/shell/split/split-drag";
const rect = { left: 0, top: 0, width: 200, height: 100 };
afterEach(() => endSplitDrag(false));
describe("dropTargetAt", () => {
it("picks the nearest edge band, else the center", () => {
expect(dropTargetAt(rect, 10, 50)).toBe("left");
expect(dropTargetAt(rect, 190, 50)).toBe("right");
expect(dropTargetAt(rect, 100, 5)).toBe("top");
expect(dropTargetAt(rect, 100, 95)).toBe("bottom");
expect(dropTargetAt(rect, 100, 50)).toBe("center");
});
});
describe("dropTargetRect", () => {
it("covers the half the new pane would take", () => {
expect(dropTargetRect(rect, "right")).toEqual({
left: 100,
top: 0,
width: 100,
height: 100,
});
expect(dropTargetRect(rect, "top")).toEqual({
left: 0,
top: 0,
width: 200,
height: 50,
});
expect(dropTargetRect(rect, "center")).toEqual(rect);
});
});
describe("split drag", () => {
it("drops on the hovered target when committed", () => {
const hover = { paneId: "p1", target: "left" as const, rect };
const disposeHit = setSplitDropHitTest(() => hover);
const onDrop = vi.fn();
const disposeDrop = setSplitDropHandler(onDrop);
const source = { kind: "tab" as const, tabId: "t1", label: "web-01" };
beginSplitDrag(source, 1, 1);
moveSplitDrag(2, 2);
expect(isSplitDragging()).toBe(true);
endSplitDrag(true);
expect(isSplitDragging()).toBe(false);
expect(onDrop).toHaveBeenCalledWith(source, hover);
disposeHit();
disposeDrop();
});
it("does nothing when cancelled or over no target", () => {
const disposeHit = setSplitDropHitTest(() => null);
const onDrop = vi.fn();
const disposeDrop = setSplitDropHandler(onDrop);
beginSplitDrag({ kind: "tab", tabId: "t1", label: "x" }, 0, 0);
endSplitDrag(true);
expect(onDrop).not.toHaveBeenCalled();
disposeHit();
disposeDrop();
});
});
+268
View File
@@ -0,0 +1,268 @@
import { describe, expect, it } from "vitest";
import type { Tab } from "@/types/ui-types";
import {
addSplitTab,
closeSplitTab,
isSplitTab,
makeSplitTab,
nextSplitNumber,
placeTabInPane,
removeTab,
restoreSplitTabs,
serializeSplitTabs,
splitTabOf,
summarizeSplits,
syncSplitChildren,
updateSplit,
type SplitTab,
} from "@/shell/split/split-tabs";
import {
createPane,
createSplitNode,
createSplitState,
listPanes,
removePane,
} from "@/shell/split/split-tree";
function tab(id: string, overrides: Partial<Tab> = {}): Tab {
return {
id,
instanceId: `inst-${id}`,
type: "terminal",
label: id,
openedAt: 0,
...overrides,
} as Tab;
}
function split(id: string, tabIds: (string | null)[]): SplitTab {
return makeSplitTab(
id,
`Split ${id}`,
createSplitState(
createSplitNode(
"row",
tabIds.map((tabId) => createPane(tabId)),
),
),
0,
);
}
function paneTabs(tabs: Tab[], splitId: string) {
const found = tabs.find((t) => t.id === splitId);
if (!isSplitTab(found)) return null;
return listPanes(found.split.root).map((pane) => pane.tabId);
}
const dashboard = tab("dashboard", { type: "dashboard" });
describe("syncSplitChildren", () => {
it("marks tabs in panes and releases the rest", () => {
const tabs = syncSplitChildren([
tab("a"),
tab("b", { parentSplitTabId: "split-x" }),
split("x", ["a", null]),
]);
expect(tabs.find((t) => t.id === "a")?.parentSplitTabId).toBe("split-x");
expect(tabs.find((t) => t.id === "b")?.parentSplitTabId).toBeUndefined();
});
it("keeps a tab claimed by two splits with the first", () => {
const tabs = syncSplitChildren([
tab("a"),
split("x", ["a"]),
split("y", ["a", null]),
]);
expect(paneTabs(tabs, "split-x")).toEqual(["a"]);
expect(paneTabs(tabs, "split-y")).toEqual([null, null]);
});
it("never holds the dashboard or another split in a pane", () => {
const tabs = syncSplitChildren([
dashboard,
split("inner", [null]),
split("x", ["dashboard", "split-inner"]),
]);
expect(paneTabs(tabs, "split-x")).toEqual([null, null]);
});
it("returns the same array when nothing changes", () => {
const tabs = syncSplitChildren([tab("a"), split("x", ["a"])]);
expect(syncSplitChildren(tabs)).toBe(tabs);
});
});
describe("adding and closing", () => {
it("adds a split and takes its tabs out of other splits", () => {
let tabs = addSplitTab([tab("a"), tab("b")], split("x", ["a", "b"]));
tabs = addSplitTab(tabs, split("y", ["b", null]));
expect(paneTabs(tabs, "split-x")).toEqual(["a", null]);
expect(paneTabs(tabs, "split-y")).toEqual(["b", null]);
expect(splitTabOf(tabs, "b")?.id).toBe("split-y");
});
it("releases every tab when a split closes", () => {
const tabs = closeSplitTab(
addSplitTab([tab("a"), tab("b")], split("x", ["a", "b"])),
"split-x",
);
expect(tabs.map((t) => t.id)).toEqual(["a", "b"]);
expect(tabs.every((t) => !t.parentSplitTabId)).toBe(true);
});
it("leaves an empty, focused pane when a tab in it closes", () => {
const tabs = removeTab(
addSplitTab([tab("a"), tab("b")], split("x", ["a", "b"])),
"b",
);
const s = tabs.find((t) => t.id === "split-x") as SplitTab;
const panes = listPanes(s.split.root);
expect(panes.map((pane) => pane.tabId)).toEqual(["a", null]);
expect(s.split.focusedPaneId).toBe(panes[1].id);
expect(tabs.some((t) => t.id === "b")).toBe(false);
});
it("closes the split when an update removes its last pane", () => {
const tabs = addSplitTab([tab("a")], split("x", ["a"]));
const next = updateSplit(tabs, "split-x", (state) =>
removePane(state, state.focusedPaneId),
);
expect(next.some((t) => t.id === "split-x")).toBe(false);
expect(next.find((t) => t.id === "a")?.parentSplitTabId).toBeUndefined();
});
});
describe("placeTabInPane", () => {
it("fills the pane and focuses it", () => {
const tabs = addSplitTab([tab("a"), tab("b")], split("x", ["a", null]));
const s = tabs.find((t) => t.id === "split-x") as SplitTab;
const empty = listPanes(s.split.root)[1];
const next = placeTabInPane(tabs, "split-x", empty.id, "b");
const updated = next.find((t) => t.id === "split-x") as SplitTab;
expect(paneTabs(next, "split-x")).toEqual(["a", "b"]);
expect(updated.split.focusedPaneId).toBe(empty.id);
expect(next.find((t) => t.id === "b")?.parentSplitTabId).toBe("split-x");
});
it("sends a replaced tab back to the tab bar", () => {
const tabs = addSplitTab([tab("a"), tab("b")], split("x", ["a"]));
const s = tabs.find((t) => t.id === "split-x") as SplitTab;
const next = placeTabInPane(
tabs,
"split-x",
listPanes(s.split.root)[0].id,
"b",
);
expect(next.find((t) => t.id === "a")?.parentSplitTabId).toBeUndefined();
});
it("refuses the dashboard", () => {
const tabs = addSplitTab([dashboard], split("x", [null]));
const s = tabs.find((t) => t.id === "split-x") as SplitTab;
const pane = listPanes(s.split.root)[0];
expect(placeTabInPane(tabs, "split-x", pane.id, "dashboard")).toBe(tabs);
});
});
describe("labels and summaries", () => {
it("picks the lowest unused split number", () => {
const labelFor = (n: number) => `Split ${n}`;
const tabs = [
{ ...split("a", [null]), label: "Split 1" },
{ ...split("b", [null]), label: "Split 3" },
];
expect(nextSplitNumber(tabs, labelFor)).toBe(2);
});
it("summarizes panes with their tab labels", () => {
const tabs = addSplitTab(
[tab("a", { label: "web-01", customLabel: "prod" })],
split("x", ["a", null]),
);
const [summary] = summarizeSplits(tabs, 8);
expect(summary.panes.map((pane) => [pane.index, pane.label])).toEqual([
[1, "prod"],
[2, null],
]);
expect(summary.full).toBe(false);
expect(summarizeSplits(tabs, 2)[0].full).toBe(true);
});
});
describe("persistence", () => {
it("round-trips a split through instance ids", () => {
const tabs = addSplitTab([tab("a"), tab("b")], split("x", ["a", null]));
const saved = serializeSplitTabs(tabs);
expect(saved[0].version).toBe(2);
expect(listPanes(saved[0].root).map((pane) => pane.tabId)).toEqual([
"inst-a",
null,
]);
// After a reload, tabs come back with new live ids but the same instanceIds.
const reloaded = [
tab("a2", { instanceId: "inst-a" }),
tab("b2", { instanceId: "inst-b" }),
];
const restored = restoreSplitTabs(
JSON.parse(JSON.stringify(saved)),
reloaded,
);
const restoredSplit = restored.find(isSplitTab)!;
expect(restoredSplit.label).toBe("Split x");
expect(listPanes(restoredSplit.split.root).map((p) => p.tabId)).toEqual([
"a2",
null,
]);
expect(restored.find((t) => t.id === "a2")?.parentSplitTabId).toBe(
restoredSplit.id,
);
});
it("restores the fixed-mode shape saved before the layout tree", () => {
const restored = restoreSplitTabs(
[
{
instanceId: "old",
label: "Split 1",
mode: "3-way-horizontal",
paneInstanceIds: ["inst-a", "inst-b", "inst-c", null, null, null],
rowSizes: [50, 50],
rowColSizes: [[50, 50], [100]],
},
],
[tab("a"), tab("b"), tab("c")],
);
const restoredSplit = restored.find(isSplitTab)!;
expect(restoredSplit.id).toBe("split-old");
expect(listPanes(restoredSplit.split.root).map((p) => p.tabId)).toEqual([
"a",
"b",
"c",
]);
});
it("skips entries whose tabs are gone or that are unreadable", () => {
const restored = restoreSplitTabs(
[
{
instanceId: "x",
label: "Gone",
mode: "2-way",
paneInstanceIds: ["nope"],
},
{ instanceId: "y", label: "Junk", root: { kind: "weird" } },
"not an object",
],
[tab("a")],
);
expect(restored.some(isSplitTab)).toBe(false);
});
it("does not restore a split that is already open", () => {
const tabs = addSplitTab([tab("a")], split("x", ["a"]));
const saved = serializeSplitTabs(tabs);
expect(restoreSplitTabs(saved, tabs)).toBe(tabs);
});
});
+373
View File
@@ -0,0 +1,373 @@
import { describe, expect, it } from "vitest";
import {
addPaneAtEdge,
assignTab,
canAddPane,
clearTab,
createPane,
createSplitNode,
createSplitState,
equalizeSplit,
findPaneByTab,
focusPane,
listPanes,
MAX_PANES,
MIN_PANE_SIZE,
moveDivider,
movePane,
neighborPane,
normalizeNode,
normalizeState,
removePane,
resizeSplit,
shownPanes,
splitPane,
swapPanes,
toggleZoom,
type LayoutNode,
type SplitState,
} from "@/shell/split/split-tree";
import {
applyPreset,
buildPreset,
fromLegacyConfig,
SPLIT_PRESETS,
} from "@/shell/split/split-presets";
function tabsOf(node: LayoutNode) {
return listPanes(node).map((pane) => pane.tabId);
}
function shape(node: LayoutNode): unknown {
if (node.kind === "pane") return node.tabId ?? "_";
return { [node.direction]: node.children.map(shape) };
}
function twoColumns(): SplitState {
return createSplitState(
createSplitNode("row", [createPane("a"), createPane("b")]),
);
}
describe("createSplitState", () => {
it("focuses the first empty pane, so the next pick fills it", () => {
const state = createSplitState(
createSplitNode("row", [createPane("a"), createPane(null)]),
);
expect(listPanes(state.root)[1].id).toBe(state.focusedPaneId);
});
});
describe("splitPane", () => {
it("wraps a lone pane in a new group", () => {
const state = createSplitState(createPane("a"));
const result = splitPane(state, state.focusedPaneId, "right", "b");
expect(shape(result!.state.root)).toEqual({ row: ["a", "b"] });
expect(result!.state.focusedPaneId).toBe(result!.paneId);
});
it("adds a sibling when splitting along the group's own direction", () => {
const state = twoColumns();
const [a] = listPanes(state.root);
const result = splitPane(state, a.id, "left", "c")!;
expect(shape(result.state.root)).toEqual({ row: ["c", "a", "b"] });
const root = result.state.root;
if (root.kind !== "split") throw new Error("expected a group");
expect(root.sizes).toEqual([25, 25, 50]);
});
it("nests a group when splitting across the direction", () => {
const state = twoColumns();
const [, b] = listPanes(state.root);
const result = splitPane(state, b.id, "bottom")!;
expect(shape(result.state.root)).toEqual({
row: ["a", { column: ["b", "_"] }],
});
});
it("moves a tab out of its old pane when it is split into a new one", () => {
const state = twoColumns();
const [a] = listPanes(state.root);
const result = splitPane(state, a.id, "top", "b")!;
expect(tabsOf(result.state.root)).toEqual(["b", "a", null]);
});
it("refuses to go past the pane limit", () => {
let state = createSplitState(createPane("t0"));
for (let i = 1; i < MAX_PANES; i++) {
state = splitPane(state, state.focusedPaneId, "right", `t${i}`)!.state;
}
expect(canAddPane(state)).toBe(false);
expect(splitPane(state, state.focusedPaneId, "right")).toBeNull();
expect(addPaneAtEdge(state, "bottom")).toBeNull();
});
it("clears zoom so the new pane is visible", () => {
const state = toggleZoom(twoColumns(), listPanes(twoColumns().root)[0].id);
const zoomed = { ...state, zoomedPaneId: state.focusedPaneId };
const result = splitPane(zoomed, zoomed.focusedPaneId, "right")!;
expect(result.state.zoomedPaneId).toBeNull();
});
});
describe("addPaneAtEdge", () => {
it("appends to a root running the same way, sharing space evenly", () => {
const result = addPaneAtEdge(twoColumns(), "right", "c")!;
const root = result.state.root;
expect(shape(root)).toEqual({ row: ["a", "b", "c"] });
if (root.kind !== "split") throw new Error("expected a group");
root.sizes.forEach((size) => expect(size).toBeCloseTo(100 / 3));
});
it("wraps the whole layout when adding across it", () => {
const result = addPaneAtEdge(twoColumns(), "bottom")!;
expect(shape(result.state.root)).toEqual({
column: [{ row: ["a", "b"] }, "_"],
});
});
});
describe("removePane", () => {
it("collapses a group left with one child", () => {
const state = twoColumns();
const [a] = listPanes(state.root);
const next = removePane(state, a.id)!;
expect(next.root.kind).toBe("pane");
expect(tabsOf(next.root)).toEqual(["b"]);
});
it("flattens a nested group into a parent running the same way", () => {
const root = createSplitNode("row", [
createPane("a"),
createSplitNode("column", [
createPane("b"),
createSplitNode("row", [createPane("c"), createPane("d")]),
]),
]);
const state = createSplitState(root);
const b = findPaneByTab(state, "b")!;
const next = removePane(state, b.id)!;
expect(shape(next.root)).toEqual({ row: ["a", "c", "d"] });
});
it("returns null for the last pane, so the split closes", () => {
const state = createSplitState(createPane("a"));
expect(removePane(state, state.focusedPaneId)).toBeNull();
});
it("moves focus to a neighbour when the focused pane goes", () => {
const state = twoColumns();
const [a, b] = listPanes(state.root);
const next = removePane(focusPane(state, b.id), b.id)!;
expect(next.focusedPaneId).toBe(a.id);
});
});
describe("tab placement", () => {
it("assigns a tab to one pane only", () => {
const state = twoColumns();
const [, b] = listPanes(state.root);
const next = assignTab(state, b.id, "a");
expect(tabsOf(next.root)).toEqual([null, "a"]);
});
it("empties a pane without removing it", () => {
const next = clearTab(twoColumns(), "a");
expect(tabsOf(next.root)).toEqual([null, "b"]);
});
it("swaps two panes' tabs", () => {
const state = twoColumns();
const [a, b] = listPanes(state.root);
expect(tabsOf(swapPanes(state, a.id, b.id).root)).toEqual(["b", "a"]);
});
it("moves a pane to another pane's edge", () => {
const state = createSplitState(
createSplitNode("row", [
createPane("a"),
createPane("b"),
createPane("c"),
]),
);
const a = findPaneByTab(state, "a")!;
const c = findPaneByTab(state, "c")!;
const next = movePane(state, a.id, c.id, "bottom");
expect(shape(next.root)).toEqual({ row: ["b", { column: ["c", "a"] }] });
});
it("swaps on a center drop", () => {
const state = twoColumns();
const [a, b] = listPanes(state.root);
expect(tabsOf(movePane(state, a.id, b.id, "center").root)).toEqual([
"b",
"a",
]);
});
});
describe("sizes", () => {
it("keeps both sides of a divider above the minimum", () => {
expect(moveDivider([50, 50], 0, 80)).toEqual([
100 - MIN_PANE_SIZE,
MIN_PANE_SIZE,
]);
expect(moveDivider([50, 50], 0, -80)).toEqual([
MIN_PANE_SIZE,
100 - MIN_PANE_SIZE,
]);
});
it("resizes and evens out one group", () => {
const state = twoColumns();
const id = state.root.id;
const resized = resizeSplit(state, id, [30, 70]);
const root = resized.root;
if (root.kind !== "split") throw new Error("expected a group");
expect(root.sizes).toEqual([30, 70]);
const even = equalizeSplit(resized, id).root;
if (even.kind !== "split") throw new Error("expected a group");
expect(even.sizes).toEqual([50, 50]);
});
it("ignores sizes that do not match the children", () => {
const state = twoColumns();
expect(resizeSplit(state, state.root.id, [100])).toBe(state);
});
});
describe("normalize", () => {
it("keeps the same object when nothing needs fixing", () => {
const state = twoColumns();
expect(normalizeState(state)).toBe(state);
expect(normalizeNode(state.root)).toBe(state.root);
});
it("empties panes whose tabs are gone and drops duplicates", () => {
const state = createSplitState(
createSplitNode("row", [
createPane("a"),
createPane("a"),
createPane("gone"),
]),
);
const next = normalizeState(state, (id) => id !== "gone");
expect(tabsOf(next.root)).toEqual(["a", null, null]);
});
it("repairs a focus that points nowhere", () => {
const state = { ...twoColumns(), focusedPaneId: "missing" };
const next = normalizeState(state);
expect(next.focusedPaneId).toBe(listPanes(next.root)[0].id);
});
});
describe("zoom and shown panes", () => {
it("shows only the zoomed pane", () => {
const state = twoColumns();
const [a] = listPanes(state.root);
const zoomed = toggleZoom(state, a.id);
expect(shownPanes(zoomed).map((pane) => pane.id)).toEqual([a.id]);
expect(toggleZoom(zoomed, a.id).zoomedPaneId).toBeNull();
});
it("shows only the focused pane when one pane fits", () => {
const state = twoColumns();
const [, b] = listPanes(state.root);
const focused = focusPane(state, b.id);
expect(shownPanes(focused, true).map((pane) => pane.id)).toEqual([b.id]);
expect(shownPanes(focused, false)).toHaveLength(2);
});
it("follows focus while zoomed", () => {
const state = twoColumns();
const [a, b] = listPanes(state.root);
const moved = focusPane(toggleZoom(state, a.id), b.id);
expect(moved.zoomedPaneId).toBe(b.id);
});
});
describe("neighborPane", () => {
// +---+---+
// | a | b |
// | +---+
// | | c |
// +---+---+
const rects = {
a: { left: 0, top: 0, right: 100, bottom: 200 },
b: { left: 101, top: 0, right: 200, bottom: 100 },
c: { left: 101, top: 101, right: 200, bottom: 200 },
};
it("finds panes by position in any tree shape", () => {
expect(neighborPane("a", "right", rects)).toBe("b");
expect(neighborPane("b", "down", rects)).toBe("c");
expect(neighborPane("c", "up", rects)).toBe("b");
expect(neighborPane("c", "left", rects)).toBe("a");
});
it("returns null at the edge", () => {
expect(neighborPane("a", "left", rects)).toBeNull();
expect(neighborPane("b", "up", rects)).toBeNull();
});
});
describe("presets", () => {
it("builds each preset with its pane count", () => {
for (const preset of SPLIT_PRESETS) {
expect(listPanes(buildPreset(preset.id))).toHaveLength(preset.panes);
}
});
it("re-lays a split out, keeping its tabs in order", () => {
const next = applyPreset(twoColumns(), "grid-4");
expect(tabsOf(next.root)).toEqual(["a", "b", null, null]);
});
it("drops tabs past the preset's pane count", () => {
const state = createSplitState(buildPreset("grid-4", ["a", "b", "c", "d"]));
expect(tabsOf(applyPreset(state, "columns-2").root)).toEqual(["a", "b"]);
});
});
describe("fromLegacyConfig", () => {
it.each([
["2-way", { row: ["a", "b"] }],
["2-way-horizontal", { column: ["a", "b"] }],
["3-way", { row: ["a", { column: ["b", "c"] }] }],
["3-way-horizontal", { column: [{ row: ["a", "b"] }, "c"] }],
["4-way", { column: [{ row: ["a", "b"] }, { row: ["c", "d"] }] }],
["5-way", { column: [{ row: ["a", "b", "c"] }, { row: ["d", "e"] }] }],
["6-way", { column: [{ row: ["a", "b", "c"] }, { row: ["d", "e", "f"] }] }],
])("maps %s onto the tree", (mode, expected) => {
const root = fromLegacyConfig(mode, ["a", "b", "c", "d", "e", "f"]);
expect(shape(root!)).toEqual(expected);
});
it("keeps saved divider positions", () => {
const root = fromLegacyConfig(
"4-way",
["a", "b", "c", "d"],
[30, 70],
[
[20, 80],
[50, 50],
],
)!;
if (root.kind !== "split") throw new Error("expected a group");
expect(root.sizes).toEqual([30, 70]);
const top = root.children[0];
if (top.kind !== "split") throw new Error("expected a group");
expect(top.sizes).toEqual([20, 80]);
});
it("falls back to even sizes when the saved ones do not fit", () => {
const root = fromLegacyConfig("2-way", ["a", "b"], "junk", [[1, 2, 3]])!;
if (root.kind !== "split") throw new Error("expected a group");
expect(root.sizes).toEqual([50, 50]);
});
it("returns null for an unknown mode", () => {
expect(fromLegacyConfig("none", [])).toBeNull();
});
});
-103
View File
@@ -1,103 +0,0 @@
import { describe, expect, it } from "vitest";
import type { Tab } from "@/types/ui-types";
import {
assignTabsToSplit,
createSplitConfig,
releaseSplitTabs,
restoreSplitTabs,
serializeSplitTabs,
} from "@/shell/splitTabUtils";
const session = (id: string): Tab => ({
id,
instanceId: `instance-${id}`,
type: "terminal",
label: id,
openedAt: 1,
});
describe("split tab state", () => {
it("moves assigned sessions under a split tab and releases removed panes", () => {
const split: Tab = {
id: "split-1",
instanceId: "split-instance",
type: "split-screen",
label: "Split #1",
openedAt: 1,
splitConfig: createSplitConfig("2-way", ["a", "b"], {
rowSizes: [100],
rowColSizes: [[50, 50]],
}),
};
const assigned = assignTabsToSplit(
[session("a"), session("b"), split],
split.id,
["a", "b"],
);
expect(assigned.slice(0, 2).map((tab) => tab.parentSplitTabId)).toEqual([
split.id,
split.id,
]);
const updated = assignTabsToSplit(assigned, split.id, ["a", null]);
expect(updated.find((tab) => tab.id === "a")?.parentSplitTabId).toBe(
split.id,
);
expect(updated.find((tab) => tab.id === "b")?.parentSplitTabId).toBe(
undefined,
);
});
it("releases child sessions when the split tab closes", () => {
const tabs = assignTabsToSplit(
[
session("a"),
{
id: "split-1",
instanceId: "split-instance",
type: "split-screen",
label: "Split #1",
openedAt: 1,
},
],
"split-1",
["a"],
);
expect(releaseSplitTabs(tabs, "split-1")).toEqual([session("a")]);
});
it("persists pane membership by stable instance id", () => {
const members = [session("a"), session("b")];
const split: Tab = {
id: "split-1",
instanceId: "workspace-1",
type: "split-screen",
label: "Production",
openedAt: 1,
splitConfig: createSplitConfig("2-way", ["a", "b"], {
rowSizes: [100],
rowColSizes: [[40, 60]],
}),
};
const saved = serializeSplitTabs([...members, split]);
const restoredMembers = [
{ ...session("new-a"), instanceId: "instance-a" },
{ ...session("new-b"), instanceId: "instance-b" },
];
const restored = restoreSplitTabs(saved, restoredMembers, 2);
const restoredSplit = restored.find((tab) => tab.type === "split-screen");
expect(restoredSplit?.label).toBe("Production");
expect(restoredSplit?.splitConfig?.paneTabIds.slice(0, 2)).toEqual([
"new-a",
"new-b",
]);
expect(restoredMembers.map((tab) => tab.id)).toEqual(["new-a", "new-b"]);
expect(
restored
.filter((tab) => tab.type === "terminal")
.map((tab) => tab.parentSplitTabId),
).toEqual(["split-workspace-1", "split-workspace-1"]);
});
});
-1
View File
@@ -27,7 +27,6 @@ function renderRail(onRailClick = vi.fn()) {
<AppRail
railView="hosts"
sidebarOpen={false}
splitMode="none"
username="alice"
isAdmin={false}
onRailClick={onRailClick}
-1
View File
@@ -60,7 +60,6 @@ describe("RAIL_ITEMS", () => {
"quick-connect",
"ssh-tools",
"macros",
"split-screen",
"sync",
]);
});