326dfa9d19
The console splits into up to four panes: a binary split tree of tmux slots (client/src/lib/paneLayout.ts) drawn by PaneGrid.tsx, every pane an absolutely placed sibling keyed by slot so splitting, swapping, resizing or closing never remounts a live terminal. Ctrl/Cmd+click on a tab, a + item or a sidebar session row opens it beside the focused pane (router state, never the URL); a pane header drags onto another pane's edge to split there or onto its middle to swap, picks another live session, splits right/down (a not-shown session or a new terminal), or closes the pane only. Dividers drag or take arrow keys. ?run= is the focused pane: every layout change goes through change(), which pushes the URL with focusOnly instead of an effect watching the focus (that ping-ponged with open requests). The layout is kept per workspace in localStorage. Only the focused pane's terminal takes the keyboard when it appears, and the keyboard follows a focus change. A pane closes when its terminal reports the exit; the run list prunes only a remembered layout on a workspace's first read and a just-opened slot that never got listed, so one empty read cannot wipe a split. A run ending on the home or a replay closes its pane without leaving the page. The session view now fills the page edge to edge: no page padding, the tab bar and the right panel's activity bar one h-10 strip, and the tab bar, pane headers and right panel on the sidebar/status bar colour (surface-1) around terminals on the page colour.
224 lines
8.4 KiB
TypeScript
224 lines
8.4 KiB
TypeScript
/**
|
|
* @file paneLayout.test.ts
|
|
* @description Tests for the session view's split-pane tree: opening a run
|
|
* (replace the focused pane, or split it), closing a pane and handing focus to
|
|
* the pane that takes its place, swapping/moving panes, resizing a split,
|
|
* pruning panes whose run is gone, the rectangles the grid draws, and the
|
|
* per-workspace storage that must survive bad or stale data.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
import { describe, it, expect, beforeEach } from "vitest";
|
|
import {
|
|
type PaneLayout,
|
|
type PaneNode,
|
|
MAX_PANES,
|
|
closeInLayout,
|
|
layoutRects,
|
|
leaves,
|
|
loadLayout,
|
|
moveInLayout,
|
|
openInLayout,
|
|
pane,
|
|
parseLayout,
|
|
pruneLayout,
|
|
saveLayout,
|
|
setRatio,
|
|
split,
|
|
swapInLayout,
|
|
} from "../paneLayout";
|
|
|
|
const one: PaneLayout = { root: pane(0), focus: 0 };
|
|
|
|
describe("openInLayout", () => {
|
|
it("starts a single pane on an empty layout", () => {
|
|
const l = openInLayout({ root: null, focus: null }, 2, "replace");
|
|
expect(l).toEqual({ root: pane(2), focus: 2 });
|
|
});
|
|
|
|
it("only focuses a slot that is already shown", () => {
|
|
const l: PaneLayout = { root: split("row", pane(0), pane(1)), focus: 0 };
|
|
expect(openInLayout(l, 1, "split")).toEqual({ ...l, focus: 1 });
|
|
});
|
|
|
|
it("replaces the focused pane on a plain open", () => {
|
|
const l: PaneLayout = { root: split("row", pane(0), pane(1)), focus: 1 };
|
|
const out = openInLayout(l, 3, "replace");
|
|
expect(out.root).toEqual(split("row", pane(0), pane(3)));
|
|
expect(out.focus).toBe(3);
|
|
});
|
|
|
|
it("splits the focused pane to the right on a split open", () => {
|
|
const l: PaneLayout = { root: split("row", pane(0), pane(1)), focus: 0 };
|
|
const out = openInLayout(l, 2, "split");
|
|
expect(out.root).toEqual(split("row", split("row", pane(0), pane(2)), pane(1)));
|
|
expect(out.focus).toBe(2);
|
|
});
|
|
|
|
it("replaces instead of splitting once MAX_PANES are shown", () => {
|
|
let l: PaneLayout = one;
|
|
for (const s of [1, 2, 3]) l = openInLayout(l, s, "split");
|
|
expect(leaves(l.root)).toHaveLength(MAX_PANES);
|
|
// A fifth run cannot exist (4 slots), but a stale focus must still be safe.
|
|
const out = openInLayout({ ...l, focus: 9 }, 3, "split");
|
|
expect(leaves(out.root)).toHaveLength(MAX_PANES);
|
|
});
|
|
|
|
it("falls back to the first pane when the stored focus is not in the tree", () => {
|
|
const l: PaneLayout = { root: split("row", pane(0), pane(1)), focus: 7 };
|
|
expect(openInLayout(l, 2, "replace").root).toEqual(split("row", pane(2), pane(1)));
|
|
});
|
|
});
|
|
|
|
describe("closeInLayout", () => {
|
|
it("hands focus to the pane that takes the closed one's place", () => {
|
|
const l: PaneLayout = {
|
|
root: split("row", pane(0), split("col", pane(1), pane(2))),
|
|
focus: 0,
|
|
};
|
|
const out = closeInLayout(l, 0);
|
|
expect(out.root).toEqual(split("col", pane(1), pane(2)));
|
|
expect(out.focus).toBe(1);
|
|
});
|
|
|
|
it("keeps focus when another pane closes", () => {
|
|
const l: PaneLayout = { root: split("row", pane(0), pane(1)), focus: 0 };
|
|
expect(closeInLayout(l, 1)).toEqual({ root: pane(0), focus: 0 });
|
|
});
|
|
|
|
it("empties the layout when the last pane closes", () => {
|
|
expect(closeInLayout(one, 0)).toEqual({ root: null, focus: null });
|
|
});
|
|
|
|
it("ignores a slot that is not shown", () => {
|
|
expect(closeInLayout(one, 3)).toBe(one);
|
|
});
|
|
});
|
|
|
|
describe("swapInLayout / moveInLayout", () => {
|
|
it("swaps two shown panes", () => {
|
|
const l: PaneLayout = { root: split("row", pane(0), pane(1)), focus: 0 };
|
|
expect(swapInLayout(l, 0, 1).root).toEqual(split("row", pane(1), pane(0)));
|
|
});
|
|
|
|
it("puts a hidden run into the target pane when swapping", () => {
|
|
const l: PaneLayout = { root: split("row", pane(0), pane(1)), focus: 0 };
|
|
expect(swapInLayout(l, 3, 1).root).toEqual(split("row", pane(0), pane(3)));
|
|
});
|
|
|
|
it("moves a pane to another pane's edge", () => {
|
|
const l: PaneLayout = { root: split("row", pane(0), split("row", pane(1), pane(2))), focus: 2 };
|
|
const out = moveInLayout(l, 0, 2, "bottom");
|
|
expect(out.root).toEqual(split("row", pane(1), split("col", pane(2), pane(0))));
|
|
expect(out.focus).toBe(0);
|
|
});
|
|
|
|
it("puts the moved pane first on a left or top edge", () => {
|
|
const l: PaneLayout = { root: split("row", pane(0), pane(1)), focus: 0 };
|
|
expect(moveInLayout(l, 1, 0, "top").root).toEqual(split("col", pane(1), pane(0)));
|
|
});
|
|
|
|
it("does nothing when a pane is dropped on itself", () => {
|
|
const l: PaneLayout = { root: split("row", pane(0), pane(1)), focus: 0 };
|
|
expect(moveInLayout(l, 1, 1, "left")).toBe(l);
|
|
});
|
|
|
|
it("refuses to add a fifth pane", () => {
|
|
let l: PaneLayout = one;
|
|
for (const s of [1, 2, 3]) l = openInLayout(l, s, "split");
|
|
const four = { ...l, root: closeInLayout(l, 3).root };
|
|
// 3 shown + slot 3 hidden: moving the hidden one in is allowed …
|
|
expect(leaves(moveInLayout({ ...four, focus: 0 }, 3, 0, "right").root)).toHaveLength(4);
|
|
// … a full layout keeps its shape.
|
|
expect(moveInLayout(l, 9, 0, "right")).toBe(l);
|
|
});
|
|
});
|
|
|
|
describe("setRatio / layoutRects", () => {
|
|
it("clamps a dragged ratio so no pane can vanish", () => {
|
|
const root = split("row", pane(0), pane(1));
|
|
expect((setRatio(root, "", 0.01) as Extract<PaneNode, { type: "split" }>).ratio).toBe(0.1);
|
|
expect((setRatio(root, "", 0.99) as Extract<PaneNode, { type: "split" }>).ratio).toBe(0.9);
|
|
});
|
|
|
|
it("resizes the split a path names", () => {
|
|
const root = split("row", pane(0), split("col", pane(1), pane(2)));
|
|
const out = setRatio(root, "b", 0.25) as Extract<PaneNode, { type: "split" }>;
|
|
expect((out.b as Extract<PaneNode, { type: "split" }>).ratio).toBe(0.25);
|
|
expect(out.ratio).toBe(0.5);
|
|
});
|
|
|
|
it("turns the tree into pane rectangles and divider lines", () => {
|
|
const root = split("row", pane(0), split("col", pane(1), pane(2), 0.25), 0.6);
|
|
const { panes, dividers } = layoutRects(root);
|
|
expect(panes).toEqual([
|
|
{ slot: 0, x: 0, y: 0, w: 0.6, h: 1 },
|
|
{ slot: 1, x: 0.6, y: 0, w: 0.4, h: 0.25 },
|
|
{ slot: 2, x: 0.6, y: 0.25, w: 0.4, h: 0.75 },
|
|
]);
|
|
expect(dividers).toEqual([
|
|
{ path: "", dir: "row", at: 0.6, box: { x: 0, y: 0, w: 1, h: 1 } },
|
|
{ path: "b", dir: "col", at: 0.25, box: { x: 0.6, y: 0, w: 0.4, h: 1 } },
|
|
]);
|
|
});
|
|
|
|
it("has nothing to draw for an empty layout", () => {
|
|
expect(layoutRects(null)).toEqual({ panes: [], dividers: [] });
|
|
});
|
|
});
|
|
|
|
describe("pruneLayout", () => {
|
|
it("drops panes whose run is gone, keeping the pending ones", () => {
|
|
const l: PaneLayout = {
|
|
root: split("row", pane(0), split("row", pane(1), pane(2))),
|
|
focus: 1,
|
|
};
|
|
const out = pruneLayout(l, new Set([0]), new Set([2]));
|
|
expect(out.root).toEqual(split("row", pane(0), pane(2)));
|
|
expect(out.focus).toBe(2);
|
|
});
|
|
|
|
it("returns the same object when nothing is gone", () => {
|
|
expect(pruneLayout(one, new Set([0]), new Set())).toBe(one);
|
|
});
|
|
});
|
|
|
|
describe("parseLayout / storage", () => {
|
|
beforeEach(() => window.localStorage.clear());
|
|
|
|
it("accepts a valid stored layout", () => {
|
|
const l: PaneLayout = { root: split("col", pane(1), pane(3), 0.3), focus: 3 };
|
|
expect(parseLayout(JSON.parse(JSON.stringify(l)))).toEqual(l);
|
|
});
|
|
|
|
it("rejects junk, out-of-range slots, duplicates and oversized trees", () => {
|
|
expect(parseLayout(null)).toEqual({ root: null, focus: null });
|
|
expect(parseLayout({ root: { type: "pane", slot: 7 }, focus: 7 }).root).toBeNull();
|
|
expect(parseLayout({ root: split("row", pane(1), pane(1)), focus: 1 }).root).toBeNull();
|
|
const five = split(
|
|
"row",
|
|
split("row", pane(0), pane(1)),
|
|
split("row", pane(2), split("row", pane(3), pane(0)))
|
|
);
|
|
expect(parseLayout({ root: five, focus: 0 }).root).toBeNull();
|
|
});
|
|
|
|
it("clamps a stored ratio and repairs a focus that is not shown", () => {
|
|
const out = parseLayout({ root: { ...split("row", pane(0), pane(1)), ratio: 5 }, focus: 3 });
|
|
expect(out.root).toEqual(split("row", pane(0), pane(1), 0.9));
|
|
expect(out.focus).toBe(0);
|
|
});
|
|
|
|
it("keeps one layout per workspace", () => {
|
|
saveLayout(1, { root: split("row", pane(0), pane(1)), focus: 1 });
|
|
saveLayout(2, one);
|
|
expect(loadLayout(1).focus).toBe(1);
|
|
expect(loadLayout(2)).toEqual(one);
|
|
expect(loadLayout(3)).toEqual({ root: null, focus: null });
|
|
});
|
|
|
|
it("survives unreadable storage", () => {
|
|
window.localStorage.setItem("agenthub.workspace.paneLayout", "{not json");
|
|
expect(loadLayout(1)).toEqual({ root: null, focus: null });
|
|
});
|
|
});
|