pi-weave 0.1.6 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,139 @@
1
+ /**
2
+ * surface/focus.ts — the Focus surface as a Component (weave-view-tui-v2 §6).
3
+ *
4
+ * The 1-hop graph neighborhood (`focusModel`). Enter re-centers the focus on
5
+ * the selected neighbor, walking the graph. Each Focus pane owns its own
6
+ * center node + selection/scroll.
7
+ */
8
+
9
+ import { matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
10
+ import { focusModel, sanitizeTerminalText } from "../model";
11
+ import { kindStyle, provenanceStyle, type ThemeSlot } from "../theme";
12
+ import { windowLines, type Surface, type SurfaceEvent, type SurfaceInit } from "./base";
13
+ import type { NoteSource } from "../../../../core/types";
14
+ import type { GraphModel } from "../../../../core/graph/model";
15
+
16
+ export interface FocusSurfaceState {
17
+ focusId: string | null;
18
+ selectedId: string | null;
19
+ scrollOffset: number;
20
+ }
21
+
22
+ export class FocusSurface implements Surface {
23
+ readonly kind = "focus" as const;
24
+ private readonly ctx;
25
+ private readonly onEvent: ((e: SurfaceEvent) => void) | undefined;
26
+ state: FocusSurfaceState;
27
+ paneRows = 24;
28
+
29
+ constructor(init: SurfaceInit) {
30
+ this.ctx = init.context;
31
+ this.onEvent = init.onEvent;
32
+ this.state = { focusId: null, selectedId: null, scrollOffset: 0 };
33
+ }
34
+
35
+ /** Bind the focus center node (called when opening/`f`). */
36
+ setFocus(nodeId: string): void {
37
+ this.state = { focusId: nodeId, selectedId: nodeId, scrollOffset: 0 };
38
+ }
39
+
40
+ setFocused(_focused: boolean): void {}
41
+
42
+ title(): string {
43
+ return "Focus";
44
+ }
45
+
46
+ invalidate(): void {}
47
+
48
+ rebind(model: GraphModel): void {
49
+ // After a refresh, drop a focus node that no longer exists rather than
50
+ // rendering an empty "(no focus node)" pane.
51
+ if (this.state.focusId && !model.nodes.some((n) => n.id === this.state.focusId)) {
52
+ this.state = { focusId: null, selectedId: null, scrollOffset: 0 };
53
+ }
54
+ }
55
+
56
+ handleInput(data: string): void {
57
+ if (matchesKey(data, "up")) return this.move(-1);
58
+ if (matchesKey(data, "down")) return this.move(1);
59
+ if (matchesKey(data, "pageUp")) return this.movePage(-1);
60
+ if (matchesKey(data, "pageDown")) return this.movePage(1);
61
+ if (matchesKey(data, "home")) {
62
+ this.state = { ...this.state, selectedId: this.rows()[0]?.id ?? null, scrollOffset: 0 };
63
+ return;
64
+ }
65
+ if (matchesKey(data, "end")) {
66
+ const r = this.rows();
67
+ const last = r[r.length - 1];
68
+ this.state = { ...this.state, selectedId: last?.id ?? null, scrollOffset: Math.max(0, r.length - this.paneRows) };
69
+ return;
70
+ }
71
+ if (matchesKey(data, "enter")) {
72
+ const rows = this.rows();
73
+ const idx = rows.findIndex((r) => r.id === this.state.selectedId);
74
+ const row = idx >= 0 ? rows[idx] : undefined;
75
+ if (row && row.target && row.target !== this.state.focusId) {
76
+ this.state = { focusId: row.target, selectedId: row.target, scrollOffset: 0 };
77
+ }
78
+ return;
79
+ }
80
+ if (data === "g") {
81
+ this.onEvent?.({ type: "focusNode", id: this.state.focusId ?? "" });
82
+ return;
83
+ }
84
+ }
85
+
86
+ private move(delta: 1 | -1): void {
87
+ const rows = this.rows();
88
+ if (rows.length === 0) return;
89
+ const idx = rows.findIndex((r) => r.id === this.state.selectedId);
90
+ const nextIdx = Math.max(0, Math.min(rows.length - 1, (idx < 0 ? -1 : idx) + delta));
91
+ const row = rows[nextIdx]!;
92
+ this.state = { ...this.state, selectedId: row.id };
93
+ }
94
+
95
+ private movePage(dir: 1 | -1): void {
96
+ const rows = this.rows();
97
+ const idx = rows.findIndex((r) => r.id === this.state.selectedId);
98
+ const target = idx < 0 ? 0 : Math.max(0, Math.min(rows.length - 1, idx + dir * this.paneRows));
99
+ const row = rows[target];
100
+ if (row) this.state = { ...this.state, selectedId: row.id };
101
+ }
102
+
103
+ private rows(): { id: string; target?: string }[] {
104
+ if (!this.state.focusId) return [];
105
+ const f = focusModel(this.ctx.model, this.state.focusId);
106
+ const center: { id: string; target?: string } = f.center.target ? { id: f.center.id, target: f.center.target } : { id: f.center.id };
107
+ return [center, ...f.groups.flatMap((g) => g.rows.map((r): { id: string; target?: string } => ({ id: r.id, target: r.target! })))];
108
+ }
109
+
110
+ render(width: number): string[] {
111
+ const t = this.ctx.theme;
112
+ const id = this.state.focusId;
113
+ if (!id) return [t.fg("dim", "(no focus node)")];
114
+ const f = focusModel(this.ctx.model, id);
115
+ const lines: { text: string; id?: string }[] = [];
116
+ const cm = this.rowMarker(f.center.kind, f.center.provenance);
117
+ lines.push({ text: t.bold(`${cm}${sanitizeTerminalText(f.center.label)}`) + t.fg("dim", " (focus — g/esc to exit)"), id: f.center.id });
118
+ for (const g of f.groups) {
119
+ lines.push({ text: t.fg("accent", g.heading) });
120
+ for (const r of g.rows) {
121
+ lines.push({ text: ` ${this.rowMarker(r.kind, r.provenance)}${sanitizeTerminalText(r.label)}`, id: r.id });
122
+ }
123
+ }
124
+ const selId = this.state.selectedId;
125
+ const selLine = selId ? lines.findIndex((l) => l.id === selId) : -1;
126
+ const marker = (text: string) => `› ${this.ctx.theme.bg("selectedBg", text)}`;
127
+ const windowed = windowLines(lines.map((l) => l.text), selLine, this.state.scrollOffset, Math.max(5, this.paneRows), marker);
128
+ return windowed.map((l) => truncateToWidth(l, width));
129
+ }
130
+
131
+ private rowMarker(kind: string, prov: NoteSource | null): string {
132
+ const ks = kindStyle(kind as never);
133
+ if (kind === "note") {
134
+ const ps = provenanceStyle(prov);
135
+ return ps.glyph ? `${ps.glyph} ` : "";
136
+ }
137
+ return `${this.ctx.theme.fg(ks.slot as ThemeSlot, `${ks.glyph} `)}`;
138
+ }
139
+ }
@@ -0,0 +1,102 @@
1
+ /**
2
+ * surface/health.ts — the Health surface as a Component (weave-view-tui-v2 §6).
3
+ *
4
+ * Renders `healthModel` (staleness + link health). Enter on a health row with
5
+ * a target opens that node in detail (emitted as a SurfaceEvent).
6
+ */
7
+
8
+ import { matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
9
+ import { healthModel, sanitizeTerminalText } from "../model";
10
+ import { windowLines, type Surface, type SurfaceEvent, type SurfaceInit } from "./base";
11
+
12
+ export interface HealthSurfaceState {
13
+ selectedId: string | null;
14
+ scrollOffset: number;
15
+ }
16
+
17
+ export class HealthSurface implements Surface {
18
+ readonly kind = "health" as const;
19
+ private readonly ctx;
20
+ private readonly onEvent: ((e: SurfaceEvent) => void) | undefined;
21
+ state: HealthSurfaceState;
22
+ paneRows = 24;
23
+
24
+ constructor(init: SurfaceInit) {
25
+ this.ctx = init.context;
26
+ this.onEvent = init.onEvent;
27
+ this.state = { selectedId: null, scrollOffset: 0 };
28
+ }
29
+
30
+ title(): string {
31
+ return "Health";
32
+ }
33
+
34
+ setFocused(_focused: boolean): void {}
35
+
36
+ invalidate(): void {}
37
+
38
+ handleInput(data: string): void {
39
+ if (matchesKey(data, "up")) return this.move(-1);
40
+ if (matchesKey(data, "down")) return this.move(1);
41
+ if (matchesKey(data, "pageUp")) return this.movePage(-1);
42
+ if (matchesKey(data, "pageDown")) return this.movePage(1);
43
+ if (matchesKey(data, "home")) {
44
+ this.state = { ...this.state, selectedId: this.rows()[0]?.id ?? null, scrollOffset: 0 };
45
+ return;
46
+ }
47
+ if (matchesKey(data, "end")) {
48
+ const r = this.rows();
49
+ const last = r[r.length - 1];
50
+ this.state = { ...this.state, selectedId: last?.id ?? null, scrollOffset: Math.max(0, r.length - this.paneRows) };
51
+ return;
52
+ }
53
+ if (matchesKey(data, "enter")) {
54
+ const rows = this.rows();
55
+ const idx = rows.findIndex((r) => r.id === this.state.selectedId);
56
+ const row = idx >= 0 ? rows[idx] : undefined;
57
+ if (row && row.target) this.onEvent?.({ type: "openDetail", id: row.target });
58
+ return;
59
+ }
60
+ }
61
+
62
+ private move(delta: 1 | -1): void {
63
+ const rows = this.rows();
64
+ if (rows.length === 0) return;
65
+ const idx = rows.findIndex((r) => r.id === this.state.selectedId);
66
+ const nextIdx = Math.max(0, Math.min(rows.length - 1, (idx < 0 ? -1 : idx) + delta));
67
+ const row = rows[nextIdx]!;
68
+ this.state = { ...this.state, selectedId: row.id };
69
+ }
70
+
71
+ private movePage(dir: 1 | -1): void {
72
+ const rows = this.rows();
73
+ const idx = rows.findIndex((r) => r.id === this.state.selectedId);
74
+ const target = idx < 0 ? 0 : Math.max(0, Math.min(rows.length - 1, idx + dir * this.paneRows));
75
+ const row = rows[target];
76
+ if (row) this.state = { ...this.state, selectedId: row.id };
77
+ }
78
+
79
+ private rows(): { id: string; target?: string }[] {
80
+ const h = healthModel(this.ctx.model);
81
+ return h.sections.flatMap((s) =>
82
+ s.rows.filter((r) => r.target).map((r): { id: string; target?: string } => ({ id: r.id, target: r.target! })),
83
+ );
84
+ }
85
+
86
+ render(width: number): string[] {
87
+ const t = this.ctx.theme;
88
+ const h = healthModel(this.ctx.model);
89
+ const lines: { text: string; id?: string }[] = [];
90
+ for (const s of h.sections) {
91
+ lines.push({ text: t.bold(s.heading) });
92
+ for (const r of s.rows) {
93
+ lines.push({ text: sanitizeTerminalText(r.text), id: r.id });
94
+ }
95
+ }
96
+ const selId = this.state.selectedId;
97
+ const selLine = selId ? lines.findIndex((l) => l.id === selId) : -1;
98
+ const marker = (text: string) => `› ${this.ctx.theme.bg("selectedBg", text)}`;
99
+ const windowed = windowLines(lines.map((l) => l.text), selLine, this.state.scrollOffset, Math.max(5, this.paneRows), marker);
100
+ return windowed.map((l) => truncateToWidth(l, width));
101
+ }
102
+ }
@@ -0,0 +1,381 @@
1
+ /**
2
+ * workspace.ts — the pure split-tree workspace model (weave-view-tui-v2 §4).
3
+ *
4
+ * A workspace is a recursive tree of splits (HStack rows / VStack columns)
5
+ * and panes. Each pane is a surface instance with its own selection/scroll/
6
+ * filter state (held by the pane component, not here); this module models the
7
+ * *structure* only: the split tree, pane ids, per-pane surface + bound node,
8
+ * focus (active pane), resize weights, and responsive collapse.
9
+ *
10
+ * This layer is harness-free (imports only core types) so every operation is
11
+ * unit-tested without a terminal — mirroring v1's model.ts posture (§11).
12
+ */
13
+
14
+ import type { GraphModel } from "../../../core/graph/model";
15
+ import { graphRoots } from "./model";
16
+
17
+ /** The four surface kinds a pane can host. */
18
+ export type SurfaceKind = "explore" | "detail" | "focus" | "health";
19
+
20
+ export interface PaneNode {
21
+ type: "pane";
22
+ /** Stable pane id. */
23
+ id: string;
24
+ surface: SurfaceKind;
25
+ /** Node id bound for detail/focus panes; null until opened. */
26
+ nodeId: string | null;
27
+ }
28
+
29
+ /** Split direction. `row` → HStack (side-by-side); `column` → VStack (stacked). */
30
+ export type SplitDir = "row" | "column";
31
+
32
+ export interface SplitNode {
33
+ type: "split";
34
+ direction: SplitDir;
35
+ /** Flex weights per child (drives the pi-tui Stack grow values). */
36
+ sizes: number[];
37
+ children: WorkspaceNode[];
38
+ }
39
+
40
+ export type WorkspaceNode = PaneNode | SplitNode;
41
+
42
+ export interface Workspace {
43
+ name: string;
44
+ root: WorkspaceNode;
45
+ /** The single active (focus-owning) pane id. */
46
+ activePaneId: string;
47
+ }
48
+
49
+ /** Axis a resize/move direction acts on. */
50
+ export type Axis = "row" | "column";
51
+
52
+ /** Split direction a `\`/`|` command produces. */
53
+ export type SplitRequest = "vertical" | "horizontal";
54
+
55
+ let nextPaneId = 1;
56
+ /** Fresh, collision-free pane id (test seam resets via resetWorkspaceIds). */
57
+ export function newPaneId(): string {
58
+ return `pane${nextPaneId++}`;
59
+ }
60
+ export function resetWorkspaceIds(): void {
61
+ nextPaneId = 1;
62
+ }
63
+
64
+ export function paneNode(surface: SurfaceKind, nodeId: string | null = null): PaneNode {
65
+ return { type: "pane", id: newPaneId(), surface, nodeId };
66
+ }
67
+
68
+ export function splitNode(direction: SplitDir, children: WorkspaceNode[], sizes?: number[]): SplitNode {
69
+ const s = sizes ?? children.map(() => 1);
70
+ return { type: "split", direction, children, sizes: s.length === children.length ? s : children.map(() => 1) };
71
+ }
72
+
73
+ // ---------------------------------------------------------------------------
74
+ // Navigation / collection
75
+ // ---------------------------------------------------------------------------
76
+
77
+ /** All panes in layout (render) order — the focus-cycle order. */
78
+ export function collectPanes(node: WorkspaceNode): PaneNode[] {
79
+ if (node.type === "pane") return [node];
80
+ const out: PaneNode[] = [];
81
+ for (const c of node.children) out.push(...collectPanes(c));
82
+ return out;
83
+ }
84
+
85
+ export function workspacePanes(ws: Workspace): PaneNode[] {
86
+ return collectPanes(ws.root);
87
+ }
88
+
89
+ export function findPane(node: WorkspaceNode, id: string): PaneNode | null {
90
+ if (node.type === "pane") return node.id === id ? node : null;
91
+ for (const c of node.children) {
92
+ const found = findPane(c, id);
93
+ if (found) return found;
94
+ }
95
+ return null;
96
+ }
97
+
98
+ export function countPanes(ws: Workspace): number {
99
+ return workspacePanes(ws).length;
100
+ }
101
+
102
+ /** Cycle the active pane in layout order; `dir` is +1 (next) or -1 (prev). */
103
+ export function focusNext(ws: Workspace, dir: 1 | -1): Workspace {
104
+ const panes = workspacePanes(ws);
105
+ if (panes.length <= 1) return ws;
106
+ const idx = panes.findIndex((p) => p.id === ws.activePaneId);
107
+ const next = panes[(idx + dir + panes.length) % panes.length];
108
+ if (!next) return ws;
109
+ return { ...ws, activePaneId: next.id };
110
+ }
111
+
112
+ // ---------------------------------------------------------------------------
113
+ // Split / close / swap
114
+ // ---------------------------------------------------------------------------
115
+
116
+ /**
117
+ * Split a pane in place, producing `dir`:
118
+ * - "vertical" → a column split (new pane below the original).
119
+ * - "horizontal" → a row split (new pane to the right of the original).
120
+ * The new pane is created with the same surface and empty selection, and
121
+ * becomes active. Returns the new workspace.
122
+ */
123
+ export function split(ws: Workspace, id: string, dir: SplitRequest, surface: SurfaceKind = "explore"): Workspace {
124
+ const parent = findParent(ws.root, id);
125
+ const holder = parent?.children ?? [ws.root];
126
+ const idx = holder.findIndex((c) => c.type === "pane" && c.id === id);
127
+ if (idx < 0) return ws;
128
+ const target = holder[idx] as PaneNode;
129
+
130
+ const fresh = paneNode(surface);
131
+ const newSplit = splitNode(
132
+ dir === "vertical" ? "column" : "row",
133
+ [target, fresh],
134
+ [1, 1],
135
+ );
136
+
137
+ if (!parent) {
138
+ return { ...ws, root: newSplit, activePaneId: fresh.id };
139
+ }
140
+ const children = parent.children.slice();
141
+ children[idx] = newSplit;
142
+ const nextRoot = replaceChild(ws.root, parent, children);
143
+ return { ...ws, root: nextRoot, activePaneId: fresh.id };
144
+ }
145
+
146
+ /** Swap two panes' surface + bound node within a split (the `move` command). */
147
+ export function movePane(ws: Workspace, id: string, dir: Axis): Workspace {
148
+ const parent = findParent(ws.root, id);
149
+ if (!parent || parent.direction !== dir) return ws;
150
+ const idx = parent.children.findIndex((c) => c.type === "pane" && c.id === id);
151
+ if (idx < 0) return ws;
152
+ // swap with the adjacent pane (right sibling if present, else left)
153
+ const neighborIdx = parent.children[idx + 1] ? idx + 1 : idx - 1;
154
+ const neighbor = neighborIdx !== undefined ? parent.children[neighborIdx] : undefined;
155
+ if (neighbor === undefined || neighbor.type !== "pane") return ws;
156
+ const children = parent.children.slice();
157
+ const a = children[idx] as PaneNode;
158
+ const b = neighbor;
159
+ children[idx] = { ...a, surface: b.surface, nodeId: b.nodeId };
160
+ children[neighborIdx] = { ...b, surface: a.surface, nodeId: a.nodeId };
161
+ return { ...ws, root: replaceChild(ws.root, parent, children) };
162
+ }
163
+
164
+ /**
165
+ * Close the active pane. If it was the last pane, keep one empty pane (don't
166
+ * quit). The focus moves to the pane that replaced it (nearest neighbor).
167
+ */
168
+ export function close(ws: Workspace, id: string): Workspace {
169
+ const parent = findParent(ws.root, id);
170
+ if (!parent) {
171
+ // single-pane root or id not present: keep one empty pane (don't quit)
172
+ if (ws.root.type === "pane") {
173
+ return {
174
+ ...ws,
175
+ root: paneNode("explore"),
176
+ activePaneId: newPaneId(),
177
+ };
178
+ }
179
+ return ws; // id not found
180
+ }
181
+ const idx = parent.children.findIndex((c) => c.type === "pane" && c.id === id);
182
+ if (idx < 0) return ws;
183
+ const children = parent.children.slice();
184
+ children.splice(idx, 1);
185
+ const nextRoot = collapseEmptySplits(replaceChild(ws.root, parent, children));
186
+ const panes = collectPanes(nextRoot);
187
+ const nextActive = panes[Math.min(idx, panes.length - 1)]?.id ?? newPaneId();
188
+ return { ...ws, root: nextRoot, activePaneId: nextActive };
189
+ }
190
+
191
+ /** Remove a split node that has a single child, unwrapping it (fixes close). */
192
+ export function collapseEmptySplits(node: WorkspaceNode): WorkspaceNode {
193
+ if (node.type === "pane") return node;
194
+ const children = node.children.map(collapseEmptySplits);
195
+ if (children.length === 1) return children[0]!;
196
+ return { ...node, children };
197
+ }
198
+
199
+ /** Set a pane's surface in place (the e/d/f/h surface swap). */
200
+ export function setPaneSurface(ws: Workspace, id: string, surface: SurfaceKind): Workspace {
201
+ return { ...ws, root: setPaneNodeSurface(ws.root, id, surface) };
202
+ }
203
+
204
+ function setPaneNodeSurface(node: WorkspaceNode, id: string, surface: SurfaceKind): WorkspaceNode {
205
+ if (node.type === "pane") return node.id === id ? { ...node, surface } : node;
206
+ return { ...node, children: node.children.map((c) => setPaneNodeSurface(c, id, surface)) };
207
+ }
208
+
209
+ // ---------------------------------------------------------------------------
210
+ // Resize
211
+ // ---------------------------------------------------------------------------
212
+
213
+ /**
214
+ * Resize the active pane along `axis` by `delta` weight units, adjusting it
215
+ * against its sibling within the nearest split on that axis. Weights are
216
+ * clamped to [1, 100]. Returns the same workspace when there is no sibling on
217
+ * the axis.
218
+ */
219
+ export function resize(ws: Workspace, id: string, axis: Axis, delta: number): Workspace {
220
+ const parent = findParent(ws.root, id);
221
+ if (!parent || parent.direction !== axis) return ws;
222
+ const idx = parent.children.findIndex((c) => c.type === "pane" && c.id === id);
223
+ if (idx < 0) return ws;
224
+ // neighbor to adjust: shrink/grow against the next sibling if present, else prev
225
+ const neighborIdx: number | undefined = parent.children[idx + 1] ? idx + 1 : parent.children[idx - 1] ? idx - 1 : undefined;
226
+ if (neighborIdx === undefined) return ws;
227
+ const sizes = parent.sizes.slice();
228
+ const cur = sizes[idx] ?? 1;
229
+ const nbr = sizes[neighborIdx] ?? 1;
230
+ const d = Math.sign(delta) * Math.min(3, Math.abs(delta));
231
+ const nextCur = Math.max(1, Math.min(100, cur + d));
232
+ const nextNbr = Math.max(1, Math.min(100, nbr - d));
233
+ sizes[idx] = nextCur;
234
+ sizes[neighborIdx] = nextNbr;
235
+ const next: SplitNode = { ...parent, sizes };
236
+ return { ...ws, root: replaceNode(ws.root, parent, next) };
237
+ }
238
+
239
+ // ---------------------------------------------------------------------------
240
+ // Default workspaces (ship 3, decision 6: Explore is the first-run default)
241
+ // ---------------------------------------------------------------------------
242
+
243
+ /** The default first-run workspace: Explore 40/60. */
244
+ export function defaultWorkspace(model: GraphModel, name = "Explore"): Workspace {
245
+ const roots = graphRoots(model);
246
+ const explore = paneNode("explore", roots[0] ?? null);
247
+ const detail = paneNode("detail", null);
248
+ const root = splitNode("row", [explore, detail], [40, 60]);
249
+ return { name, root, activePaneId: explore.id };
250
+ }
251
+
252
+ /** Triple: Explore 30 / Detail 40 / Focus 30 — walk the graph while reading. */
253
+ export function tripleWorkspace(name = "Triple"): Workspace {
254
+ const explore = paneNode("explore");
255
+ const detail = paneNode("detail");
256
+ const focus = paneNode("focus");
257
+ const root = splitNode("row", [explore, detail, focus], [30, 40, 30]);
258
+ return { name, root, activePaneId: explore.id };
259
+ }
260
+
261
+ /** Wide: dashboard on top, reading below. */
262
+ export function wideWorkspace(name = "Wide"): Workspace {
263
+ const explore = paneNode("explore");
264
+ const health = paneNode("health");
265
+ const detail = paneNode("detail");
266
+ const top = splitNode("row", [explore, health], [50, 50]);
267
+ const root = splitNode("column", [top, detail], [40, 60]);
268
+ return { name, root, activePaneId: explore.id };
269
+ }
270
+
271
+ /** All three named defaults. */
272
+ export function defaultWorkspaces(model: GraphModel): Workspace[] {
273
+ return [defaultWorkspace(model), tripleWorkspace(), wideWorkspace()];
274
+ }
275
+
276
+ // ---------------------------------------------------------------------------
277
+ // Responsive collapse (design §9.2)
278
+ // ---------------------------------------------------------------------------
279
+
280
+ /**
281
+ * Collapse the workspace to fit a terminal width:
282
+ * - ≥ 110: unchanged.
283
+ * - 80–109: at most one column split — collapse any column split nested inside
284
+ * another split down to its first child pane.
285
+ * - < 80: single visible pane (the active one); the component shows the others
286
+ * as a one-line tab bar (decision 5).
287
+ */
288
+ export function collapseForWidth(ws: Workspace, width: number): Workspace {
289
+ if (width >= 110) return ws;
290
+ if (width < 80) {
291
+ const active = findPane(ws.root, ws.activePaneId);
292
+ const single = active ? { ...active, nodeId: active.nodeId } : paneNode("explore");
293
+ return { ...ws, root: single, activePaneId: single.id };
294
+ }
295
+ return { ...ws, root: collapseNestedColumns(ws.root) };
296
+ }
297
+
298
+ /** Flatten a column split nested directly under another column split to its first pane. */
299
+ export function collapseNestedColumns(node: WorkspaceNode, insideColumn = false): WorkspaceNode {
300
+ if (node.type === "pane") return node;
301
+ const wasColumn = node.direction === "column";
302
+ const children = node.children.map((c) => collapseNestedColumns(c, wasColumn));
303
+ if (node.direction === "column" && insideColumn && children.length > 1) {
304
+ return children[0] ?? node;
305
+ }
306
+ return { ...node, children };
307
+ }
308
+
309
+ // ---------------------------------------------------------------------------
310
+ // Serialize / deserialize (persistence shape; the component adds the vault IO)
311
+ // ---------------------------------------------------------------------------
312
+
313
+ export interface SerializedWorkspace {
314
+ name: string;
315
+ activePaneId: string;
316
+ root: SerializedNode;
317
+ }
318
+ export type SerializedNode = { t: "pane"; id: string; s: SurfaceKind; n: string | null } | { t: "split"; d: SplitDir; sizes: number[]; c: SerializedNode[] };
319
+
320
+ export function serialize(ws: Workspace): SerializedWorkspace {
321
+ return { name: ws.name, activePaneId: ws.activePaneId, root: serializeNode(ws.root) };
322
+ }
323
+
324
+ function serializeNode(node: WorkspaceNode): SerializedNode {
325
+ if (node.type === "pane") return { t: "pane", id: node.id, s: node.surface, n: node.nodeId };
326
+ return { t: "split", d: node.direction, sizes: node.sizes, c: node.children.map(serializeNode) };
327
+ }
328
+
329
+ /** Deserialize a serialized workspace; degrades to a fresh Explore on bad shape. */
330
+ export function deserialize(json: SerializedWorkspace | null, fallback: Workspace): Workspace {
331
+ if (!json || typeof json.name !== "string") return fallback;
332
+ const root = deserializeNode(json.root);
333
+ if (!root) return fallback;
334
+ const active = findPane(root, json.activePaneId);
335
+ return {
336
+ name: json.name,
337
+ root,
338
+ activePaneId: active ? active.id : workspacePanes({ name: json.name, root, activePaneId: json.activePaneId })[0]?.id ?? newPaneId(),
339
+ };
340
+ }
341
+
342
+ function deserializeNode(node: SerializedNode | undefined): WorkspaceNode | null {
343
+ if (!node) return null;
344
+ if (node.t === "pane") return { type: "pane", id: node.id || newPaneId(), surface: node.s, nodeId: node.n ?? null };
345
+ if (node.t === "split") {
346
+ const children = (node.c ?? []).map(deserializeNode).filter((c): c is WorkspaceNode => c !== null);
347
+ if (children.length === 0) return null;
348
+ return { type: "split", direction: node.d, sizes: node.sizes ?? children.map(() => 1), children };
349
+ }
350
+ return null;
351
+ }
352
+
353
+ // ---------------------------------------------------------------------------
354
+ // Helpers
355
+ // ---------------------------------------------------------------------------
356
+
357
+ /** Find the split node directly containing pane `id`, or null if root. */
358
+ function findParent(node: WorkspaceNode, id: string): SplitNode | null {
359
+ if (node.type === "pane") return null;
360
+ for (const c of node.children) {
361
+ if (c.type === "pane" && c.id === id) return node;
362
+ }
363
+ for (const c of node.children) {
364
+ const found = c.type === "split" ? findParent(c, id) : null;
365
+ if (found) return found;
366
+ }
367
+ return null;
368
+ }
369
+
370
+ /** Replace `parent` (by reference identity within a node tree) with a new split. */
371
+ function replaceChild(root: WorkspaceNode, parent: SplitNode, children: WorkspaceNode[]): WorkspaceNode {
372
+ const next: SplitNode = { ...parent, children };
373
+ return replaceNode(root, parent, next);
374
+ }
375
+
376
+ /** Swap one node reference for another within a tree (structural copy). */
377
+ function replaceNode(root: WorkspaceNode, target: SplitNode, replacement: SplitNode): WorkspaceNode {
378
+ if (root === target) return replacement;
379
+ if (root.type === "pane") return root;
380
+ return { ...root, children: root.children.map((c) => replaceNode(c, target, replacement)) };
381
+ }