mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-31 07:35:24 +00:00
feat(split-screen): redesign split screen with free-form panes and pane picker
This commit is contained in:
1 parent
5f3372f9f6
commit
81e7aa6274
46 files changed
+5027
-2537
No files matched your search
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -27,7 +27,6 @@ function renderRail(onRailClick = vi.fn()) {
|
||||
<AppRail
|
||||
railView="hosts"
|
||||
sidebarOpen={false}
|
||||
splitMode="none"
|
||||
username="alice"
|
||||
isAdmin={false}
|
||||
onRailClick={onRailClick}
|
||||
|
||||
@@ -60,7 +60,6 @@ describe("RAIL_ITEMS", () => {
|
||||
"quick-connect",
|
||||
"ssh-tools",
|
||||
"macros",
|
||||
"split-screen",
|
||||
"sync",
|
||||
]);
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user