Skip to content

Commit 8cea6cd

Browse files
authored
Add pure client-local native pane layout model (#1129)
1 parent 923df41 commit 8cea6cd

4 files changed

Lines changed: 538 additions & 0 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Added a standalone pure state model (src/shared/native-terminal-client-layout/) for a future native multi-pane terminal: it holds each client's view of the shared, ordered pane set plus its own focus and optional zoom, reconciles those overlays deterministically when panes are added, removed, or reordered, and proves that focus/zoom mutations never touch the shared pane list or introduce any PTY dimension. No runtime or UI behavior changes.
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
# 164 — Client-local focus/zoom is a pure overlay on the shared pane set
2+
3+
## Context
4+
5+
The tmux-removal roadmap replaces one shared tmux layout with a native host
6+
that fans one PTY to several local clients (decisions 158, 160). SplitTree
7+
(`src/shared/split-tree.ts`) still bakes `activePaneId`/`zoomedPaneId` into the
8+
one shared tree, which cannot express per-client focus. LAY-005 needs the state
9+
law for per-client focus/zoom proven before any renderer, host, or adapter uses
10+
it.
11+
12+
## Decision
13+
14+
`src/shared/native-terminal-client-layout/index.ts` models each client's view
15+
as `{ paneIds, focusedPaneId, zoomedPaneId }` — the shared ordered pane set plus
16+
two client-local overlays. Focus and zoom are **orthogonal**: zoom does not move
17+
focus and focus does not clear zoom, so a client can zoom a pane other than the
18+
focused one. Every function is pure, immutable, and returns the same reference
19+
on a no-op; the module is import-free with no PTY dimension anywhere.
20+
21+
Reconciliation is deterministic: a still-present focus/zoom is kept; a removed
22+
zoom target clears zoom; a removed/invalid focus falls back to the nearest
23+
surviving pane **after** the old focus in the previous order, else the nearest
24+
before it, else the first pane; an empty set has null focus. This keeps the
25+
invariant `focus === null ⇔ paneIds is empty`, verified by
26+
`validateClientPaneLayout`.
27+
28+
## Risks
29+
30+
Orthogonal focus/zoom diverges from SplitTree, where zoom follows the active
31+
pane. A future adapter (Seq 1254) must map deliberately between the two rather
32+
than assume zoom implies focus. The overlay stores its own last-seen pane order
33+
to compute neighbor fallback; a caller that skips `reconcile` on a shared-set
34+
change can leave a stale focus until the next reconcile.
35+
36+
## Alternatives considered
37+
38+
Storing focus/zoom only (no pane-order snapshot) was rejected because neighbor
39+
fallback then loses adjacency and can only jump to the first pane. Coupling zoom
40+
to focus (SplitTree's rule) was rejected as it hides two independent client
41+
choices behind one field and complicates observer-isolation proofs. Extending
42+
SplitTree with per-client fields was rejected: it would mutate the frozen shared
43+
model this ticket must not touch.
Lines changed: 300 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,300 @@
1+
import { readFileSync } from "node:fs";
2+
import { join, resolve } from "node:path";
3+
import { describe, expect, it } from "vitest";
4+
import {
5+
createClientPaneLayout,
6+
focusPane,
7+
isPaneZoomed,
8+
reconcileClientPaneLayout,
9+
toggleZoom,
10+
unzoomPane,
11+
validateClientPaneLayout,
12+
zoomPane,
13+
type ClientPaneLayout,
14+
} from "../../shared/native-terminal-client-layout";
15+
16+
const cwd = process.cwd();
17+
const mainviewRoot = cwd.endsWith(join("src", "mainview")) ? cwd : resolve(cwd, "src/mainview");
18+
const MODULE_SOURCE = resolve(mainviewRoot, "../shared/native-terminal-client-layout/index.ts");
19+
20+
const STATE_KEYS = ["paneIds", "focusedPaneId", "zoomedPaneId"] as const;
21+
22+
function panes(count: number): string[] {
23+
return Array.from({ length: count }, (_, index) => `pane-${index + 1}`);
24+
}
25+
26+
describe("createClientPaneLayout", () => {
27+
it("focuses the first pane and starts unzoomed", () => {
28+
const layout = createClientPaneLayout(panes(3));
29+
expect(layout.paneIds).toEqual(["pane-1", "pane-2", "pane-3"]);
30+
expect(layout.focusedPaneId).toBe("pane-1");
31+
expect(layout.zoomedPaneId).toBeNull();
32+
});
33+
34+
it("has no focus for an empty pane set", () => {
35+
const layout = createClientPaneLayout([]);
36+
expect(layout.paneIds).toEqual([]);
37+
expect(layout.focusedPaneId).toBeNull();
38+
expect(layout.zoomedPaneId).toBeNull();
39+
});
40+
41+
it("drops duplicate and empty ids while preserving first-occurrence order", () => {
42+
const layout = createClientPaneLayout(["pane-2", "", "pane-1", "pane-2", "pane-1"]);
43+
expect(layout.paneIds).toEqual(["pane-2", "pane-1"]);
44+
expect(layout.focusedPaneId).toBe("pane-2");
45+
});
46+
47+
it("only exposes the three client-local fields (no PTY dimensions)", () => {
48+
const layout = createClientPaneLayout(panes(2));
49+
expect(Object.keys(layout).sort()).toEqual([...STATE_KEYS].sort());
50+
});
51+
});
52+
53+
describe.each([0, 1, 2, 6])("invariants hold for %i panes", (count) => {
54+
it("produces a valid layout from create and reconcile", () => {
55+
const created = createClientPaneLayout(panes(count));
56+
expect(validateClientPaneLayout(created).valid).toBe(true);
57+
expect(created.focusedPaneId === null).toBe(count === 0);
58+
59+
const reconciled = reconcileClientPaneLayout(createClientPaneLayout([]), panes(count));
60+
expect(validateClientPaneLayout(reconciled).valid).toBe(true);
61+
expect(reconciled.focusedPaneId).toBe(count === 0 ? null : "pane-1");
62+
});
63+
});
64+
65+
describe("reconcileClientPaneLayout — focus", () => {
66+
it("keeps a still-present focus", () => {
67+
const layout = focusPane(createClientPaneLayout(panes(3)), "pane-2");
68+
const next = reconcileClientPaneLayout(layout, ["pane-1", "pane-2", "pane-3", "pane-4"]);
69+
expect(next.focusedPaneId).toBe("pane-2");
70+
});
71+
72+
it("moves focus to the next surviving pane when the focused pane is removed", () => {
73+
const layout = focusPane(createClientPaneLayout(panes(6)), "pane-3");
74+
const next = reconcileClientPaneLayout(layout, ["pane-1", "pane-2", "pane-4", "pane-5", "pane-6"]);
75+
expect(next.focusedPaneId).toBe("pane-4");
76+
});
77+
78+
it("falls back to the previous pane when no later pane survives", () => {
79+
const layout = focusPane(createClientPaneLayout(panes(6)), "pane-6");
80+
const next = reconcileClientPaneLayout(layout, ["pane-1", "pane-2", "pane-3", "pane-4", "pane-5"]);
81+
expect(next.focusedPaneId).toBe("pane-5");
82+
});
83+
84+
it("skips other removed panes to reach the nearest survivor", () => {
85+
const layout = focusPane(createClientPaneLayout(panes(6)), "pane-3");
86+
const next = reconcileClientPaneLayout(layout, ["pane-1", "pane-2", "pane-5", "pane-6"]);
87+
expect(next.focusedPaneId).toBe("pane-5");
88+
});
89+
90+
it("selects the first pane when focus was null but panes now exist", () => {
91+
const empty = createClientPaneLayout([]);
92+
const next = reconcileClientPaneLayout(empty, panes(2));
93+
expect(next.focusedPaneId).toBe("pane-1");
94+
});
95+
96+
it("clears focus for an empty pane set", () => {
97+
const layout = createClientPaneLayout(panes(2));
98+
const next = reconcileClientPaneLayout(layout, []);
99+
expect(next.focusedPaneId).toBeNull();
100+
expect(next.paneIds).toEqual([]);
101+
});
102+
});
103+
104+
describe("reconcileClientPaneLayout — zoom", () => {
105+
it("keeps a still-present zoom target", () => {
106+
const layout = zoomPane(createClientPaneLayout(panes(3)), "pane-2");
107+
const next = reconcileClientPaneLayout(layout, ["pane-1", "pane-2", "pane-3", "pane-4"]);
108+
expect(next.zoomedPaneId).toBe("pane-2");
109+
});
110+
111+
it("clears zoom when the zoomed pane is removed but keeps a surviving focus", () => {
112+
const focused = focusPane(createClientPaneLayout(panes(3)), "pane-1");
113+
const zoomed = zoomPane(focused, "pane-3");
114+
const next = reconcileClientPaneLayout(zoomed, ["pane-1", "pane-2"]);
115+
expect(next.zoomedPaneId).toBeNull();
116+
expect(next.focusedPaneId).toBe("pane-1");
117+
});
118+
119+
it("clears zoom for an empty pane set", () => {
120+
const zoomed = zoomPane(createClientPaneLayout(panes(2)), "pane-1");
121+
const next = reconcileClientPaneLayout(zoomed, []);
122+
expect(next.zoomedPaneId).toBeNull();
123+
});
124+
});
125+
126+
describe("reconcileClientPaneLayout — reorder & no-op", () => {
127+
it("preserves focus and zoom across a pure reorder", () => {
128+
const layout = zoomPane(focusPane(createClientPaneLayout(panes(3)), "pane-2"), "pane-2");
129+
const next = reconcileClientPaneLayout(layout, ["pane-3", "pane-1", "pane-2"]);
130+
expect(next.paneIds).toEqual(["pane-3", "pane-1", "pane-2"]);
131+
expect(next.focusedPaneId).toBe("pane-2");
132+
expect(next.zoomedPaneId).toBe("pane-2");
133+
});
134+
135+
it("returns the same reference when nothing changed", () => {
136+
const layout = focusPane(createClientPaneLayout(panes(3)), "pane-2");
137+
expect(reconcileClientPaneLayout(layout, panes(3))).toBe(layout);
138+
});
139+
140+
it("normalizes duplicates from the shared set", () => {
141+
const layout = createClientPaneLayout(panes(2));
142+
const next = reconcileClientPaneLayout(layout, ["pane-1", "pane-1", "pane-2", "pane-2"]);
143+
expect(next.paneIds).toEqual(["pane-1", "pane-2"]);
144+
});
145+
});
146+
147+
describe("local mutations never alter the shared pane list or PTY dimensions", () => {
148+
it("keeps the shared pane array reference identical across focus/zoom mutations", () => {
149+
const base = createClientPaneLayout(panes(4));
150+
const focused = focusPane(base, "pane-3");
151+
const zoomed = zoomPane(focused, "pane-3");
152+
const toggled = toggleZoom(zoomed, "pane-3");
153+
const unzoomed = unzoomPane(zoomed);
154+
155+
for (const result of [focused, zoomed, toggled, unzoomed]) {
156+
expect(result.paneIds).toBe(base.paneIds);
157+
expect(result.paneIds).toEqual(["pane-1", "pane-2", "pane-3", "pane-4"]);
158+
}
159+
});
160+
161+
it("does not mutate the caller's shared array or the input layout", () => {
162+
const shared = panes(3);
163+
const sharedSnapshot = [...shared];
164+
const layout = createClientPaneLayout(shared);
165+
const layoutSnapshot: ClientPaneLayout = {
166+
paneIds: [...layout.paneIds],
167+
focusedPaneId: layout.focusedPaneId,
168+
zoomedPaneId: layout.zoomedPaneId,
169+
};
170+
171+
focusPane(layout, "pane-2");
172+
zoomPane(layout, "pane-3");
173+
reconcileClientPaneLayout(layout, ["pane-1"]);
174+
175+
expect(shared).toEqual(sharedSnapshot);
176+
expect(layout).toEqual(layoutSnapshot);
177+
});
178+
179+
it("carries no PTY dimension fields on any produced state", () => {
180+
const layout = zoomPane(focusPane(createClientPaneLayout(panes(2)), "pane-2"), "pane-2");
181+
expect(Object.keys(layout).sort()).toEqual([...STATE_KEYS].sort());
182+
});
183+
184+
it("is a pure, import-free source with no runtime or PTY dependencies", () => {
185+
const source = readFileSync(MODULE_SOURCE, "utf8");
186+
expect(source).not.toMatch(/^\s*import\s/m);
187+
expect(source).not.toMatch(/\brequire\s*\(/);
188+
// Strip comments so the dependency scan sees code, not the prose that
189+
// documents the very tokens it forbids (e.g. "no PTY dimension").
190+
const code = source.replace(/\/\*[\s\S]*?\*\//g, "").replace(/\/\/.*$/gm, "");
191+
expect(code).not.toMatch(/\b(?:React|tmux|Bun\.Terminal|WebSocket|api\.request|node:)\b/i);
192+
expect(code).not.toMatch(/\b(?:pty|cols|rows|resize|dimensions?|viewport)\b/i);
193+
});
194+
});
195+
196+
describe("focus & zoom mutations", () => {
197+
it("no-ops focusing an absent pane", () => {
198+
const layout = createClientPaneLayout(panes(2));
199+
expect(focusPane(layout, "pane-9")).toBe(layout);
200+
});
201+
202+
it("no-ops focusing the already-focused pane", () => {
203+
const layout = createClientPaneLayout(panes(2));
204+
expect(focusPane(layout, "pane-1")).toBe(layout);
205+
});
206+
207+
it("zooms the focused pane by default and independent of focus", () => {
208+
const layout = focusPane(createClientPaneLayout(panes(3)), "pane-2");
209+
const zoomed = zoomPane(layout);
210+
expect(zoomed.zoomedPaneId).toBe("pane-2");
211+
expect(zoomed.focusedPaneId).toBe("pane-2");
212+
213+
const otherZoom = zoomPane(layout, "pane-3");
214+
expect(otherZoom.zoomedPaneId).toBe("pane-3");
215+
expect(otherZoom.focusedPaneId).toBe("pane-2");
216+
});
217+
218+
it("no-ops zooming an absent pane and never zooms without a target", () => {
219+
const layout = createClientPaneLayout(panes(2));
220+
expect(zoomPane(layout, "pane-9")).toBe(layout);
221+
const empty = createClientPaneLayout([]);
222+
expect(zoomPane(empty)).toBe(empty);
223+
});
224+
225+
it("toggles zoom on and off", () => {
226+
const layout = focusPane(createClientPaneLayout(panes(2)), "pane-1");
227+
const on = toggleZoom(layout);
228+
expect(isPaneZoomed(on)).toBe(true);
229+
const off = toggleZoom(on);
230+
expect(isPaneZoomed(off)).toBe(false);
231+
expect(off.focusedPaneId).toBe("pane-1");
232+
});
233+
234+
it("no-ops unzoom when nothing is zoomed", () => {
235+
const layout = createClientPaneLayout(panes(2));
236+
expect(unzoomPane(layout)).toBe(layout);
237+
});
238+
});
239+
240+
describe("two clients reconcile independently over the same shared pane set", () => {
241+
it("evolves each client's focus and zoom from its own prior state", () => {
242+
const shared = panes(4);
243+
const clientA = zoomPane(focusPane(createClientPaneLayout(shared), "pane-2"), "pane-2");
244+
const clientB = focusPane(createClientPaneLayout(shared), "pane-4");
245+
246+
const nextShared = ["pane-1", "pane-3", "pane-4"]; // pane-2 removed + reordered
247+
const nextA = reconcileClientPaneLayout(clientA, nextShared);
248+
const nextB = reconcileClientPaneLayout(clientB, nextShared);
249+
250+
expect(nextA.paneIds).toEqual(nextShared);
251+
expect(nextB.paneIds).toEqual(nextShared);
252+
253+
// A lost its focused+zoomed pane-2 → focus falls back, zoom clears.
254+
expect(nextA.focusedPaneId).toBe("pane-3");
255+
expect(nextA.zoomedPaneId).toBeNull();
256+
257+
// B kept its independent focus, untouched by A's reconciliation.
258+
expect(nextB.focusedPaneId).toBe("pane-4");
259+
expect(nextB.zoomedPaneId).toBeNull();
260+
});
261+
262+
it("isolates observers: mutating one client does not touch another", () => {
263+
const shared = panes(3);
264+
const clientA = createClientPaneLayout(shared);
265+
const clientB = createClientPaneLayout(shared);
266+
267+
const movedA = zoomPane(focusPane(clientA, "pane-3"), "pane-3");
268+
269+
expect(clientB.focusedPaneId).toBe("pane-1");
270+
expect(clientB.zoomedPaneId).toBeNull();
271+
expect(movedA.focusedPaneId).toBe("pane-3");
272+
expect(clientA.focusedPaneId).toBe("pane-1"); // original A is immutable
273+
});
274+
});
275+
276+
describe("validateClientPaneLayout", () => {
277+
it("accepts every state produced by reconciliation across a churn sequence", () => {
278+
let layout = createClientPaneLayout(panes(6));
279+
const churn: string[][] = [
280+
["pane-1", "pane-2", "pane-3", "pane-4", "pane-5", "pane-6"],
281+
["pane-2", "pane-4", "pane-6"],
282+
[],
283+
["pane-7", "pane-8"],
284+
["pane-8", "pane-7"],
285+
];
286+
for (const shared of churn) {
287+
layout = reconcileClientPaneLayout(layout, shared);
288+
layout = toggleZoom(layout);
289+
expect(validateClientPaneLayout(layout).valid).toBe(true);
290+
}
291+
});
292+
293+
it("rejects a dangling focus, dangling zoom, duplicates, and focus on empty", () => {
294+
expect(validateClientPaneLayout({ paneIds: ["a"], focusedPaneId: "b", zoomedPaneId: null }).valid).toBe(false);
295+
expect(validateClientPaneLayout({ paneIds: ["a"], focusedPaneId: "a", zoomedPaneId: "z" }).valid).toBe(false);
296+
expect(validateClientPaneLayout({ paneIds: ["a", "a"], focusedPaneId: "a", zoomedPaneId: null }).valid).toBe(false);
297+
expect(validateClientPaneLayout({ paneIds: [], focusedPaneId: "a", zoomedPaneId: null }).valid).toBe(false);
298+
expect(validateClientPaneLayout("nope").valid).toBe(false);
299+
});
300+
});

0 commit comments

Comments
 (0)