flexdesk 0.1.0

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.
Files changed (124) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +195 -0
  3. package/THIRD_PARTY_LICENSES.md +53 -0
  4. package/css/base.css +3439 -0
  5. package/css/flexdesk.css +5797 -0
  6. package/css/overrides.css +2349 -0
  7. package/css/reset.css +46 -0
  8. package/css/tokens.css +137 -0
  9. package/dist/charts.js +780 -0
  10. package/dist/charts.js.map +7 -0
  11. package/dist/chunk-3PHCPZHT.js +222 -0
  12. package/dist/chunk-3PHCPZHT.js.map +7 -0
  13. package/dist/chunk-CT4YXXLP.js +1770 -0
  14. package/dist/chunk-CT4YXXLP.js.map +7 -0
  15. package/dist/chunk-DRYCDMEG.js +2217 -0
  16. package/dist/chunk-DRYCDMEG.js.map +7 -0
  17. package/dist/chunk-DVU44T77.js +855 -0
  18. package/dist/chunk-DVU44T77.js.map +7 -0
  19. package/dist/chunk-FL5KFNQH.js +640 -0
  20. package/dist/chunk-FL5KFNQH.js.map +7 -0
  21. package/dist/chunk-FOOS3T5L.js +117 -0
  22. package/dist/chunk-FOOS3T5L.js.map +7 -0
  23. package/dist/chunk-JYWURG5T.js +32 -0
  24. package/dist/chunk-JYWURG5T.js.map +7 -0
  25. package/dist/chunk-TLZUUFOE.js +276 -0
  26. package/dist/chunk-TLZUUFOE.js.map +7 -0
  27. package/dist/chunk-UCJ2WD4D.js +625 -0
  28. package/dist/chunk-UCJ2WD4D.js.map +7 -0
  29. package/dist/chunk-WVFGV5FT.js +205 -0
  30. package/dist/chunk-WVFGV5FT.js.map +7 -0
  31. package/dist/core.js +1098 -0
  32. package/dist/core.js.map +7 -0
  33. package/dist/editor.js +1661 -0
  34. package/dist/editor.js.map +7 -0
  35. package/dist/flexdesk.css +5797 -0
  36. package/dist/host.js +132 -0
  37. package/dist/host.js.map +7 -0
  38. package/dist/reset.css +46 -0
  39. package/dist/tile_registry-6WZPTUZV.js +28 -0
  40. package/dist/tile_registry-6WZPTUZV.js.map +7 -0
  41. package/dist/tiles.js +2068 -0
  42. package/dist/tiles.js.map +7 -0
  43. package/dist/tokens.css +137 -0
  44. package/dist/widgets.js +5438 -0
  45. package/dist/widgets.js.map +7 -0
  46. package/dist/wm.js +4830 -0
  47. package/dist/wm.js.map +7 -0
  48. package/package.json +80 -0
  49. package/src/charts/chart_types.js +699 -0
  50. package/src/charts/data_series_plot_window.js +761 -0
  51. package/src/charts/downsample.js +119 -0
  52. package/src/charts/plot_config.js +176 -0
  53. package/src/charts/plot_popout_window.js +1560 -0
  54. package/src/charts/plotly_wrapper.js +432 -0
  55. package/src/core/event_bus.js +230 -0
  56. package/src/core/logging.js +300 -0
  57. package/src/core/settings.js +813 -0
  58. package/src/core/state_guard.js +382 -0
  59. package/src/core/state_guard_config.js +309 -0
  60. package/src/core/state_machine.js +258 -0
  61. package/src/editor/editor_pane.js +264 -0
  62. package/src/editor/monaco_editor_factory.js +449 -0
  63. package/src/editor/monaco_loader.js +74 -0
  64. package/src/editor/notebook_search.js +460 -0
  65. package/src/editor/notebook_tab_bar.js +456 -0
  66. package/src/editor/notebook_undo_manager.js +108 -0
  67. package/src/editor/split_pane_container.js +197 -0
  68. package/src/help/help_modal.js +409 -0
  69. package/src/help/help_registry.js +65 -0
  70. package/src/host/host.js +69 -0
  71. package/src/host/pywebview_host.js +133 -0
  72. package/src/tiles/config_schema.js +256 -0
  73. package/src/tiles/layout_persistence.js +662 -0
  74. package/src/tiles/tile_base.js +785 -0
  75. package/src/tiles/tile_grid.js +905 -0
  76. package/src/tiles/tile_registry.js +136 -0
  77. package/src/tiling/command_palette.js +328 -0
  78. package/src/tiling/content_registry.js +110 -0
  79. package/src/tiling/desktops.js +133 -0
  80. package/src/tiling/entity_sources.js +179 -0
  81. package/src/tiling/keymap.js +156 -0
  82. package/src/tiling/kind_taxonomy.js +172 -0
  83. package/src/tiling/landing_table.js +717 -0
  84. package/src/tiling/loading_overlay.js +33 -0
  85. package/src/tiling/nav_panel.js +142 -0
  86. package/src/tiling/page_factory.js +215 -0
  87. package/src/tiling/panel_keys.js +145 -0
  88. package/src/tiling/shell.js +437 -0
  89. package/src/tiling/tile_breadcrumb.js +222 -0
  90. package/src/tiling/tile_renderer.js +459 -0
  91. package/src/tiling/tile_tab_menu.js +340 -0
  92. package/src/tiling/tile_tree.js +880 -0
  93. package/src/tiling/wm.js +1352 -0
  94. package/src/ui/base/component_base.js +30 -0
  95. package/src/ui/base/controller_base.js +48 -0
  96. package/src/ui/base/page_base.js +102 -0
  97. package/src/ui/components/about_dialog.js +224 -0
  98. package/src/ui/components/action_dropdown.js +389 -0
  99. package/src/ui/components/attribute_list_editor.js +652 -0
  100. package/src/ui/components/autocomplete_field.js +732 -0
  101. package/src/ui/components/computing_status_window.js +81 -0
  102. package/src/ui/components/confirm_dialog.js +307 -0
  103. package/src/ui/components/context_menu.js +87 -0
  104. package/src/ui/components/data_table.js +2228 -0
  105. package/src/ui/components/detail_header.js +177 -0
  106. package/src/ui/components/drag_reorder.js +0 -0
  107. package/src/ui/components/gallery_picker.js +147 -0
  108. package/src/ui/components/inline_renamer.js +129 -0
  109. package/src/ui/components/managed_window.js +804 -0
  110. package/src/ui/components/modal.js +512 -0
  111. package/src/ui/components/notification_history.js +264 -0
  112. package/src/ui/components/slide_out_panel.js +185 -0
  113. package/src/ui/components/slider_field.js +122 -0
  114. package/src/ui/components/sortable_list.js +309 -0
  115. package/src/ui/components/table_state_store.js +81 -0
  116. package/src/ui/components/toast.js +59 -0
  117. package/src/ui/components/tree_view.js +460 -0
  118. package/src/ui/components/window_taskbar.js +143 -0
  119. package/src/ui/controllers/panel_state_machine.js +634 -0
  120. package/src/ui/controllers/window_chrome_controller.js +659 -0
  121. package/src/ui/notification_center.js +576 -0
  122. package/src/ui/utils/overlay_scrollbar.js +492 -0
  123. package/src/ui/utils/raf_resize_observer.js +31 -0
  124. package/src/ui/utils/tooltip_service.js +406 -0
@@ -0,0 +1,880 @@
1
+ /**
2
+ * tile_tree.js — i3/sway-style binary tree for the tiling window manager.
3
+ *
4
+ * A workspace is one tree of Node objects:
5
+ *
6
+ * Node = { id, kind: 'split' | 'leaf', parentId, ... }
7
+ * Split: { dir: 'h' | 'v', children: [childId, ...], sizes: [number, ...] }
8
+ * Leaf: { tabs: [{kind, props, title}], activeTabIdx, content, title }
9
+ *
10
+ * **Tabs**. Each leaf carries an ordered list of tab specs and an active
11
+ * index. The active tab is mirrored onto the legacy `content` /
12
+ * `title` fields so the rest of the codebase (renderer, WM,
13
+ * breadcrumb, navigateBack, …) keeps reading `leaf.content.kind`
14
+ * unchanged. Single-tab leaves behave identically to today; multi-tab
15
+ * leaves expose a small TabBar at the bottom of the tile.
16
+ *
17
+ * The tree carries no DOM. A separate renderer maps it to flex containers.
18
+ * Mutations return new node-id arrays; consumers re-render after each call.
19
+ */
20
+
21
+ let _idCounter = 1;
22
+ function _newId(prefix) { return `${prefix}-${_idCounter++}`; }
23
+
24
+ function _isPanel(leaf) {
25
+ return String(leaf?.content?.kind || '').startsWith('panel:');
26
+ }
27
+
28
+ /** Mirror the active tab back onto `leaf.content` / `leaf.title` so the
29
+ * rest of the system sees no diff. Called after every tab mutation. */
30
+ function _syncActiveTab(leaf) {
31
+ if (!leaf || leaf.kind !== 'leaf') return;
32
+ const tabs = leaf.tabs || [];
33
+ if (tabs.length === 0) {
34
+ leaf.content = null;
35
+ leaf.title = leaf.title || '';
36
+ leaf.activeTabIdx = 0;
37
+ return;
38
+ }
39
+ if (leaf.activeTabIdx == null
40
+ || leaf.activeTabIdx < 0
41
+ || leaf.activeTabIdx >= tabs.length) {
42
+ leaf.activeTabIdx = Math.max(0, Math.min(tabs.length - 1, leaf.activeTabIdx || 0));
43
+ }
44
+ const t = tabs[leaf.activeTabIdx];
45
+ leaf.content = { kind: t.kind, props: t.props || {} };
46
+ leaf.title = t.title || t.kind || '';
47
+ }
48
+
49
+ export function makeLeaf({ content = null, title = '' } = {}) {
50
+ const leaf = {
51
+ id: _newId('leaf'),
52
+ kind: 'leaf',
53
+ parentId: null,
54
+ // Tabs are the source of truth; content/title mirror tabs[active].
55
+ tabs: content
56
+ ? [{ kind: content.kind, props: content.props || {}, title: title || content.kind || '' }]
57
+ : [],
58
+ activeTabIdx: content ? 0 : 0,
59
+ content,
60
+ title,
61
+ // Per-top-nav-page tab archive. When a top-nav button switches
62
+ // this leaf to another page, the current `tabs` + `activeTabIdx`
63
+ // get stashed here under the LEAVING page's key, and the target
64
+ // page's archived list (if any) gets restored. This is what
65
+ // makes a tile remember its tab set when the user clicks away to
66
+ // another top-nav page and back. Keyed by top-nav kind — see the
67
+ // taxonomy's `topNavFor`.
68
+ pageTabs: {},
69
+ };
70
+ _syncActiveTab(leaf);
71
+ return leaf;
72
+ }
73
+
74
+ export function makeSplit({ dir = 'h', children = [], sizes = null } = {}) {
75
+ return {
76
+ id: _newId('split'),
77
+ kind: 'split',
78
+ parentId: null,
79
+ dir,
80
+ children: children.slice(),
81
+ sizes: sizes ? sizes.slice() : children.map(() => 1),
82
+ };
83
+ }
84
+
85
+ /**
86
+ * TileTree — holds the node table and root, exposes mutation operations.
87
+ * Every mutation operates by node id; the tree never hands out raw nodes
88
+ * to callers (defensive copies via .get()).
89
+ */
90
+ export class TileTree {
91
+ constructor() {
92
+ this.nodes = new Map();
93
+ this.rootId = null;
94
+ this.focusedLeafId = null;
95
+ // Stack of recently-focused leaves. New content loads target the
96
+ // top of this stack — i.e. the last leaf the user focused.
97
+ this.focusStack = [];
98
+ }
99
+
100
+ // ── Read helpers ────────────────────────────────────────────────
101
+ get(id) { return this.nodes.get(id) || null; }
102
+ root() { return this.rootId ? this.get(this.rootId) : null; }
103
+ focused() { return this.focusedLeafId ? this.get(this.focusedLeafId) : null; }
104
+
105
+ leaves() {
106
+ const out = [];
107
+ const walk = (id) => {
108
+ const n = this.get(id);
109
+ if (!n) return;
110
+ if (n.kind === 'leaf') out.push(n);
111
+ else n.children.forEach(walk);
112
+ };
113
+ if (this.rootId) walk(this.rootId);
114
+ return out;
115
+ }
116
+
117
+ // ── Construction ───────────────────────────────────────────────
118
+ setRoot(node) {
119
+ this._register(node);
120
+ this.rootId = node.id;
121
+ if (node.kind === 'leaf') {
122
+ this.focus(node.id);
123
+ }
124
+ return node.id;
125
+ }
126
+
127
+ _register(node) {
128
+ this.nodes.set(node.id, node);
129
+ if (node.kind === 'split') {
130
+ for (const cid of node.children) {
131
+ const c = this.get(cid);
132
+ if (c) c.parentId = node.id;
133
+ }
134
+ }
135
+ }
136
+
137
+ // ── Focus ───────────────────────────────────────────────────────
138
+ focus(leafId) {
139
+ if (!this.nodes.has(leafId)) return;
140
+ const node = this.get(leafId);
141
+ if (node.kind !== 'leaf') return;
142
+ this.focusedLeafId = leafId;
143
+ this.focusStack = this.focusStack.filter((id) => id !== leafId);
144
+ this.focusStack.unshift(leafId);
145
+ }
146
+
147
+ /** The leaf where "open content" should land — top of focus stack,
148
+ * skipping panel:* tiles which are chrome, not content. */
149
+ primaryLeafId() {
150
+ for (const id of this.focusStack) {
151
+ const n = this.nodes.get(id);
152
+ if (n && n.kind === 'leaf' && !_isPanel(n)) return id;
153
+ }
154
+ for (const leaf of this.leaves()) {
155
+ if (!_isPanel(leaf)) return leaf.id;
156
+ }
157
+ return null;
158
+ }
159
+
160
+ // ── Mutations ───────────────────────────────────────────────────
161
+ /** Replace the leaf's tab list with a single tab carrying `content`.
162
+ * Closes any existing tabs in that leaf — this is the "open in
163
+ * place" / "replace tile" path that the WM has always used. */
164
+ setLeafContent(leafId, content, title = '') {
165
+ const n = this.get(leafId);
166
+ if (!n || n.kind !== 'leaf') return false;
167
+ if (content) {
168
+ n.tabs = [{
169
+ kind: content.kind,
170
+ props: content.props || {},
171
+ title: title || content.kind || '',
172
+ }];
173
+ n.activeTabIdx = 0;
174
+ } else {
175
+ n.tabs = [];
176
+ n.activeTabIdx = 0;
177
+ }
178
+ _syncActiveTab(n);
179
+ if (title) n.title = title;
180
+ return true;
181
+ }
182
+
183
+ /** Page-aware swap: archive the leaf's current tab list under its
184
+ * current top-nav key, then restore the target top-nav's archived
185
+ * list (or initialize one with the requested kind/props). Used
186
+ * by top-nav / palette navigation so each top-nav page keeps its
187
+ * own tab set across page switches.
188
+ *
189
+ * Parameters:
190
+ * leafId The primary leaf.
191
+ * target { kind, props, title } — what the user clicked.
192
+ * currentTopNav Top-nav of the leaf's current page (may be null).
193
+ * targetTopNav Top-nav of the target kind (must be set).
194
+ *
195
+ * When `currentTopNav === targetTopNav` (already on the right
196
+ * page), the swap is skipped and we either focus an existing
197
+ * matching tab or replace the active one. This is what makes
198
+ * clicking the top-nav button for the page you're already on
199
+ * feel like "Home for this page" without resetting tabs. */
200
+ swapToPage(leafId, target, currentTopNav, targetTopNav, opts = {}) {
201
+ const n = this.get(leafId);
202
+ if (!n || n.kind !== 'leaf') return false;
203
+ if (!target || !target.kind) return false;
204
+ n.pageTabs = n.pageTabs || {};
205
+ const recordHistory = opts.recordHistory !== false;
206
+
207
+ // Helper: mirror what replaceActiveTabContent does — push the
208
+ // outgoing tab onto the (incoming) tab's history stack so
209
+ // Backspace can walk back. `extra` is an optional annotation
210
+ // (e.g. `{ topNav: currentTopNav }`) for cross-page entries so
211
+ // navigateBack can re-route via swapToPage when popping.
212
+ const _pushPrev = (history, prev, next, extra) => {
213
+ if (!recordHistory || !prev || !prev.kind) return history;
214
+ const last = history[history.length - 1];
215
+ const sameAsPrev = last
216
+ && last.kind === prev.kind
217
+ && String(last.props?.id ?? '') === String(prev.props?.id ?? '');
218
+ const sameAsTarget = next
219
+ && prev.kind === next.kind
220
+ && String(prev.props?.id ?? '') === String(next.props?.id ?? '');
221
+ if (!sameAsPrev && !sameAsTarget) {
222
+ history.push({
223
+ kind: prev.kind,
224
+ props: prev.props || {},
225
+ title: prev.title || '',
226
+ ...(extra || {}),
227
+ });
228
+ }
229
+ return history;
230
+ };
231
+
232
+ const same = currentTopNav && currentTopNav === targetTopNav;
233
+ if (same) {
234
+ const tabs = n.tabs || [];
235
+ const matchIdx = tabs.findIndex((t) =>
236
+ t.kind === target.kind
237
+ && String(t.props?.id ?? '') === String(target.props?.id ?? ''));
238
+ if (matchIdx >= 0) {
239
+ n.activeTabIdx = matchIdx;
240
+ } else if (tabs.length === 0) {
241
+ n.tabs = [{
242
+ kind: target.kind,
243
+ props: target.props || {},
244
+ title: target.title || target.kind,
245
+ history: [],
246
+ }];
247
+ n.activeTabIdx = 0;
248
+ } else {
249
+ const prev = tabs[n.activeTabIdx];
250
+ const history = Array.isArray(prev?.history) ? prev.history : [];
251
+ _pushPrev(history, prev, target, null);
252
+ tabs[n.activeTabIdx] = {
253
+ kind: target.kind,
254
+ props: target.props || {},
255
+ title: target.title || target.kind,
256
+ history,
257
+ };
258
+ }
259
+ _syncActiveTab(n);
260
+ return true;
261
+ }
262
+
263
+ // Snapshot the source tab BEFORE archiving — used to seed the
264
+ // target tab's history with a cross-page back entry.
265
+ const srcTabs = Array.isArray(n.tabs) ? n.tabs : [];
266
+ const srcActive = srcTabs[n.activeTabIdx];
267
+ const sourceEntry = (currentTopNav && srcActive?.kind) ? {
268
+ kind: srcActive.kind,
269
+ props: srcActive.props || {},
270
+ title: srcActive.title || '',
271
+ topNav: currentTopNav,
272
+ } : null;
273
+
274
+ // Archive the current page — preserve `history` so the user
275
+ // doesn't lose in-tile back depth when they walk away and
276
+ // return to this page.
277
+ if (currentTopNav && srcTabs.length > 0) {
278
+ // Don't archive transient tabs (add-row forms etc.) — they
279
+ // shouldn't reappear when the user returns to this page.
280
+ const archived = srcTabs
281
+ .filter((t) => !t.transient)
282
+ .map((t) => ({
283
+ kind: t.kind, props: { ...(t.props || {}) }, title: t.title,
284
+ history: Array.isArray(t.history) ? t.history.slice() : [],
285
+ }));
286
+ n.pageTabs[currentTopNav] = {
287
+ tabs: archived,
288
+ activeTabIdx: Math.max(0, Math.min(archived.length - 1, n.activeTabIdx || 0)),
289
+ };
290
+ }
291
+
292
+ // Restore the target page, or initialize fresh.
293
+ const saved = n.pageTabs[targetTopNav];
294
+ if (saved && Array.isArray(saved.tabs) && saved.tabs.length > 0) {
295
+ n.tabs = saved.tabs.map((t) => ({
296
+ kind: t.kind, props: { ...(t.props || {}) }, title: t.title,
297
+ history: Array.isArray(t.history) ? t.history.slice() : [],
298
+ }));
299
+ n.activeTabIdx = Math.max(0,
300
+ Math.min(n.tabs.length - 1, saved.activeTabIdx || 0));
301
+ // The caller asked for a specific entity (props.id) OR for a
302
+ // page kind that ISN'T the nav category itself (e.g. `settings`
303
+ // lives under the `home` topNav). In both cases we must surface
304
+ // the requested target rather than silently showing whatever
305
+ // the restored page happened to hold. A bare top-nav click
306
+ // (target.kind === targetTopNav, no id) falls through and just
307
+ // restores the saved tabs as-is.
308
+ const wantsTarget = target.props?.id != null
309
+ || target.kind !== targetTopNav;
310
+ if (wantsTarget) {
311
+ const wantId = target.props?.id != null;
312
+ const matchIdx = n.tabs.findIndex((t) =>
313
+ t.kind === target.kind
314
+ && (!wantId
315
+ || String(t.props?.id ?? '') === String(target.props.id)));
316
+ if (matchIdx >= 0) {
317
+ n.activeTabIdx = matchIdx;
318
+ } else {
319
+ const history = [];
320
+ if (sourceEntry) _pushPrev(history, sourceEntry, target, { topNav: currentTopNav });
321
+ n.tabs.push({
322
+ kind: target.kind,
323
+ props: target.props || {},
324
+ title: target.title || target.kind,
325
+ history,
326
+ });
327
+ n.activeTabIdx = n.tabs.length - 1;
328
+ }
329
+ }
330
+ } else {
331
+ const history = [];
332
+ if (sourceEntry) _pushPrev(history, sourceEntry, target, { topNav: currentTopNav });
333
+ n.tabs = [{
334
+ kind: target.kind,
335
+ props: target.props || {},
336
+ title: target.title || target.kind,
337
+ history,
338
+ }];
339
+ n.activeTabIdx = 0;
340
+ }
341
+ _syncActiveTab(n);
342
+ return true;
343
+ }
344
+
345
+ /** Update any tab whose (kind, props.id) matches the given pair to
346
+ * reflect a new label. Called from the WM in response to the
347
+ * embedder's entity-renamed bus event, so tab titles stay live as
348
+ * the underlying entity is renamed. */
349
+ renameMatchingTabs(kind, id, newLabel) {
350
+ if (!kind || !id || !newLabel) return false;
351
+ let touched = false;
352
+ const visit = (tabs) => {
353
+ if (!Array.isArray(tabs)) return;
354
+ for (const t of tabs) {
355
+ if (t.kind === kind && String(t.props?.id ?? '') === String(id)) {
356
+ t.title = String(newLabel);
357
+ if (t.props) t.props.label = String(newLabel);
358
+ touched = true;
359
+ }
360
+ }
361
+ };
362
+ for (const n of this.nodes.values()) {
363
+ if (n.kind !== 'leaf') continue;
364
+ visit(n.tabs);
365
+ if (n.pageTabs) {
366
+ for (const arc of Object.values(n.pageTabs)) visit(arc?.tabs);
367
+ }
368
+ _syncActiveTab(n);
369
+ }
370
+ return touched;
371
+ }
372
+
373
+ /** Replace the active tab's content in place, preserving every
374
+ * other tab in the leaf. This is the "in-tile navigation" path:
375
+ * clicking a row on a landing page, walking back via Backspace,
376
+ * or clicking a breadcrumb segment should swap THIS tab's
377
+ * content without disturbing the user's other open tabs.
378
+ *
379
+ * By default the previous content is pushed onto the active
380
+ * tab's `history` stack so the user can Backspace through their
381
+ * own click-path (per-tab "browser back"). Callers that are
382
+ * themselves popping the history pass `{ recordHistory: false }`
383
+ * so we don't double-record. Same applies to programmatic loads
384
+ * that aren't user navigations.
385
+ *
386
+ * Falls back to `setLeafContent` for empty leaves so the WM
387
+ * never has to special-case the initial load. Returns true on
388
+ * success. */
389
+ replaceActiveTabContent(leafId, content, title = '', opts = {}) {
390
+ const n = this.get(leafId);
391
+ if (!n || n.kind !== 'leaf') return false;
392
+ const tabs = Array.isArray(n.tabs) ? n.tabs : [];
393
+ if (tabs.length === 0) {
394
+ return this.setLeafContent(leafId, content, title);
395
+ }
396
+ if (!content) return false;
397
+ const idx = Math.max(0, Math.min(tabs.length - 1, n.activeTabIdx || 0));
398
+ const prev = tabs[idx];
399
+ const recordHistory = opts.recordHistory !== false;
400
+ const history = Array.isArray(prev?.history) ? prev.history : [];
401
+ if (recordHistory && prev && prev.kind) {
402
+ // Avoid pushing duplicates when the user re-clicks the
403
+ // same row — same kind + same primary id → no-op.
404
+ const last = history[history.length - 1];
405
+ const sameAsPrev = last
406
+ && last.kind === prev.kind
407
+ && String(last.props?.id ?? '') === String(prev.props?.id ?? '');
408
+ const sameAsTarget = prev.kind === content.kind
409
+ && String(prev.props?.id ?? '') === String(content.props?.id ?? '');
410
+ if (!sameAsPrev && !sameAsTarget) {
411
+ history.push({
412
+ kind: prev.kind,
413
+ props: prev.props || {},
414
+ title: prev.title || '',
415
+ });
416
+ }
417
+ }
418
+ tabs[idx] = {
419
+ kind: content.kind,
420
+ props: content.props || {},
421
+ title: title || content.kind || '',
422
+ history,
423
+ };
424
+ _syncActiveTab(n);
425
+ return true;
426
+ }
427
+
428
+ /** Merge `patch` into the active tab's `props`. Used when an
429
+ * in-tile editor surfaces metadata that should be captured in
430
+ * history snapshots (e.g. which sub-tab is open) so Backspace
431
+ * restores the user's view, not the default landing. Does not
432
+ * re-render or push history — pure metadata write. */
433
+ updateActiveTabProps(leafId, patch) {
434
+ const n = this.get(leafId);
435
+ if (!n || n.kind !== 'leaf') return false;
436
+ const tabs = Array.isArray(n.tabs) ? n.tabs : [];
437
+ if (tabs.length === 0) return false;
438
+ const idx = Math.max(0, Math.min(tabs.length - 1, n.activeTabIdx || 0));
439
+ const cur = tabs[idx];
440
+ if (!cur) return false;
441
+ cur.props = { ...(cur.props || {}), ...(patch || {}) };
442
+ return true;
443
+ }
444
+
445
+ /** Pop one entry off the active tab's history stack and return it,
446
+ * or null when empty. Doesn't mutate the tab beyond the stack —
447
+ * the caller is expected to apply the returned content via
448
+ * `replaceActiveTabContent(..., { recordHistory: false })`. */
449
+ popActiveTabHistory(leafId) {
450
+ const n = this.get(leafId);
451
+ if (!n || n.kind !== 'leaf') return null;
452
+ const tabs = Array.isArray(n.tabs) ? n.tabs : [];
453
+ if (tabs.length === 0) return null;
454
+ const idx = Math.max(0, Math.min(tabs.length - 1, n.activeTabIdx || 0));
455
+ const cur = tabs[idx];
456
+ const history = Array.isArray(cur?.history) ? cur.history : [];
457
+ if (history.length === 0) return null;
458
+ return history.pop();
459
+ }
460
+
461
+ /** Inspect the active tab's history stack without popping. Used
462
+ * by the keymap to show / hide the back affordance and for
463
+ * diagnostics. */
464
+ activeTabHistory(leafId) {
465
+ const n = this.get(leafId);
466
+ if (!n || n.kind !== 'leaf') return [];
467
+ const tabs = Array.isArray(n.tabs) ? n.tabs : [];
468
+ if (tabs.length === 0) return [];
469
+ const idx = Math.max(0, Math.min(tabs.length - 1, n.activeTabIdx || 0));
470
+ const cur = tabs[idx];
471
+ return Array.isArray(cur?.history) ? cur.history : [];
472
+ }
473
+
474
+ /** Append a new tab to a leaf and focus it. Returns the new tab's
475
+ * index, or -1 on failure. Panel tiles never grow tabs (they're
476
+ * chrome). */
477
+ appendLeafTab(leafId, content, title = '', opts = {}) {
478
+ const n = this.get(leafId);
479
+ if (!n || n.kind !== 'leaf') return -1;
480
+ if (_isPanel(n)) return -1;
481
+ if (!content) return -1;
482
+ const tab = {
483
+ kind: content.kind,
484
+ props: content.props || {},
485
+ title: title || content.kind || '',
486
+ // Transient tabs (e.g. an add-row form) are not persisted /
487
+ // restored — see serialize() + the pageTabs archive.
488
+ ...(opts.transient ? { transient: true } : {}),
489
+ };
490
+ n.tabs = Array.isArray(n.tabs) ? n.tabs : [];
491
+ n.tabs.push(tab);
492
+ n.activeTabIdx = n.tabs.length - 1;
493
+ _syncActiveTab(n);
494
+ return n.activeTabIdx;
495
+ }
496
+
497
+ /** Switch the active tab on a leaf. No-op if `idx` is out of range. */
498
+ setActiveLeafTab(leafId, idx) {
499
+ const n = this.get(leafId);
500
+ if (!n || n.kind !== 'leaf') return false;
501
+ if (!Array.isArray(n.tabs) || idx < 0 || idx >= n.tabs.length) return false;
502
+ n.activeTabIdx = idx;
503
+ _syncActiveTab(n);
504
+ return true;
505
+ }
506
+
507
+ /** Remove a tab. If the active tab was removed, the previous one
508
+ * becomes active. If the last tab was removed, the leaf's content
509
+ * drops to null (and the WM should treat that as an empty tile —
510
+ * same as before tabs existed). Returns the (possibly clamped)
511
+ * active index after the operation, or -1 on failure. */
512
+ removeLeafTab(leafId, idx) {
513
+ const n = this.get(leafId);
514
+ if (!n || n.kind !== 'leaf') return -1;
515
+ if (!Array.isArray(n.tabs) || idx < 0 || idx >= n.tabs.length) return -1;
516
+ n.tabs.splice(idx, 1);
517
+ if (n.tabs.length === 0) {
518
+ n.activeTabIdx = 0;
519
+ _syncActiveTab(n);
520
+ return 0;
521
+ }
522
+ if (idx < n.activeTabIdx) n.activeTabIdx -= 1;
523
+ else if (idx === n.activeTabIdx) n.activeTabIdx = Math.max(0, idx - 1);
524
+ _syncActiveTab(n);
525
+ return n.activeTabIdx;
526
+ }
527
+
528
+ /** Bulk-close every tab in a leaf except the one at `keepIdx`.
529
+ * Used by the tab right-click "Close others" action. */
530
+ closeOtherTabs(leafId, keepIdx) {
531
+ const n = this.get(leafId);
532
+ if (!n || n.kind !== 'leaf') return false;
533
+ const tabs = n.tabs || [];
534
+ if (keepIdx < 0 || keepIdx >= tabs.length) return false;
535
+ n.tabs = [tabs[keepIdx]];
536
+ n.activeTabIdx = 0;
537
+ _syncActiveTab(n);
538
+ return true;
539
+ }
540
+
541
+ /** Close every tab after `idx`. */
542
+ closeTabsAfter(leafId, idx) {
543
+ const n = this.get(leafId);
544
+ if (!n || n.kind !== 'leaf') return false;
545
+ const tabs = n.tabs || [];
546
+ if (idx < 0 || idx >= tabs.length - 1) return false;
547
+ n.tabs = tabs.slice(0, idx + 1);
548
+ if (n.activeTabIdx > idx) n.activeTabIdx = idx;
549
+ _syncActiveTab(n);
550
+ return true;
551
+ }
552
+
553
+ /** Close every tab before `idx`. */
554
+ closeTabsBefore(leafId, idx) {
555
+ const n = this.get(leafId);
556
+ if (!n || n.kind !== 'leaf') return false;
557
+ const tabs = n.tabs || [];
558
+ if (idx <= 0 || idx >= tabs.length) return false;
559
+ n.tabs = tabs.slice(idx);
560
+ n.activeTabIdx = Math.max(0, n.activeTabIdx - idx);
561
+ _syncActiveTab(n);
562
+ return true;
563
+ }
564
+
565
+ /** Reorder a tab from `from` to `to` within the same leaf. */
566
+ moveLeafTab(leafId, from, to) {
567
+ const n = this.get(leafId);
568
+ if (!n || n.kind !== 'leaf') return false;
569
+ const tabs = n.tabs || [];
570
+ if (from < 0 || from >= tabs.length || to < 0 || to >= tabs.length) return false;
571
+ const moved = tabs.splice(from, 1)[0];
572
+ tabs.splice(to, 0, moved);
573
+ // Keep the moved tab focused if it was the active one.
574
+ if (n.activeTabIdx === from) n.activeTabIdx = to;
575
+ else if (from < n.activeTabIdx && to >= n.activeTabIdx) n.activeTabIdx -= 1;
576
+ else if (from > n.activeTabIdx && to <= n.activeTabIdx) n.activeTabIdx += 1;
577
+ _syncActiveTab(n);
578
+ return true;
579
+ }
580
+
581
+ /**
582
+ * Split a leaf in the given direction; existing content stays in the
583
+ * original leaf, a new empty leaf is added next to it. Returns the
584
+ * new leaf id, or null on failure.
585
+ */
586
+ split(leafId, dir /* 'h' | 'v' */) {
587
+ const leaf = this.get(leafId);
588
+ if (!leaf || leaf.kind !== 'leaf') return null;
589
+
590
+ const newLeaf = makeLeaf();
591
+ // Case 1: root split. Wrap root in a new Split.
592
+ if (!leaf.parentId) {
593
+ const wrap = makeSplit({ dir, children: [leaf.id, newLeaf.id] });
594
+ this._register(newLeaf);
595
+ this._register(wrap);
596
+ this.rootId = wrap.id;
597
+ leaf.parentId = wrap.id;
598
+ newLeaf.parentId = wrap.id;
599
+ this.focus(newLeaf.id);
600
+ return newLeaf.id;
601
+ }
602
+
603
+ const parent = this.get(leaf.parentId);
604
+ const idx = parent.children.indexOf(leaf.id);
605
+ if (idx < 0) return null;
606
+
607
+ // Case 2: parent already matches the split direction — insert
608
+ // the new leaf as a sibling.
609
+ if (parent.dir === dir) {
610
+ parent.children.splice(idx + 1, 0, newLeaf.id);
611
+ const avg = parent.sizes.reduce((a, b) => a + b, 0) / parent.children.length;
612
+ parent.sizes.splice(idx + 1, 0, avg);
613
+ newLeaf.parentId = parent.id;
614
+ this._register(newLeaf);
615
+ this.focus(newLeaf.id);
616
+ return newLeaf.id;
617
+ }
618
+
619
+ // Case 3: cross-direction — wrap the original leaf in a new
620
+ // Split that contains [leaf, newLeaf]. Replace leaf in parent.
621
+ const wrap = makeSplit({ dir, children: [leaf.id, newLeaf.id] });
622
+ wrap.parentId = parent.id;
623
+ parent.children[idx] = wrap.id;
624
+ leaf.parentId = wrap.id;
625
+ newLeaf.parentId = wrap.id;
626
+ this._register(newLeaf);
627
+ this._register(wrap);
628
+ this.focus(newLeaf.id);
629
+ return newLeaf.id;
630
+ }
631
+
632
+ /**
633
+ * Close a leaf, collapsing any single-child splits that result. If
634
+ * the last leaf is closed, leaves an empty tree (no root) — caller
635
+ * is expected to repopulate.
636
+ */
637
+ close(leafId) {
638
+ const leaf = this.get(leafId);
639
+ if (!leaf || leaf.kind !== 'leaf') return false;
640
+
641
+ // Root leaf — clear the tree.
642
+ if (!leaf.parentId) {
643
+ this.nodes.clear();
644
+ this.rootId = null;
645
+ this.focusedLeafId = null;
646
+ this.focusStack = [];
647
+ return true;
648
+ }
649
+
650
+ const parent = this.get(leaf.parentId);
651
+ const idx = parent.children.indexOf(leaf.id);
652
+ parent.children.splice(idx, 1);
653
+ parent.sizes.splice(idx, 1);
654
+ this.nodes.delete(leaf.id);
655
+ this.focusStack = this.focusStack.filter((id) => id !== leaf.id);
656
+
657
+ // Parent collapse — if a Split has only one child left, replace
658
+ // it with that child in *its* parent (or as root).
659
+ this._collapse(parent);
660
+
661
+ if (this.focusedLeafId === leaf.id) {
662
+ const remaining = this.leaves();
663
+ this.focusedLeafId = remaining.length ? remaining[0].id : null;
664
+ if (this.focusedLeafId) this.focus(this.focusedLeafId);
665
+ }
666
+ return true;
667
+ }
668
+
669
+ _collapse(node) {
670
+ if (!node || node.kind !== 'split') return;
671
+ if (node.children.length > 1) return;
672
+ if (node.children.length === 0) {
673
+ // Empty split — remove from its parent (or clear root).
674
+ if (!node.parentId) {
675
+ this.nodes.delete(node.id);
676
+ this.rootId = null;
677
+ return;
678
+ }
679
+ const gp = this.get(node.parentId);
680
+ const idx = gp.children.indexOf(node.id);
681
+ gp.children.splice(idx, 1);
682
+ gp.sizes.splice(idx, 1);
683
+ this.nodes.delete(node.id);
684
+ this._collapse(gp);
685
+ return;
686
+ }
687
+ // Exactly one child — promote it.
688
+ const only = this.get(node.children[0]);
689
+ if (!node.parentId) {
690
+ this.rootId = only.id;
691
+ only.parentId = null;
692
+ } else {
693
+ const gp = this.get(node.parentId);
694
+ const idx = gp.children.indexOf(node.id);
695
+ gp.children[idx] = only.id;
696
+ only.parentId = gp.id;
697
+ }
698
+ this.nodes.delete(node.id);
699
+ }
700
+
701
+ // ── Directional focus ──────────────────────────────────────────
702
+ /**
703
+ * Move focus from current leaf in the given direction. Walks up the
704
+ * tree looking for an ancestor split whose direction matches the
705
+ * move (h for left/right, v for up/down) and which has a sibling on
706
+ * the requested side; then descends along the matching edge.
707
+ */
708
+ focusDir(dir /* 'left' | 'right' | 'up' | 'down' */) {
709
+ const cur = this.focused();
710
+ if (!cur) return false;
711
+ const wantDir = (dir === 'left' || dir === 'right') ? 'h' : 'v';
712
+ const forward = (dir === 'right' || dir === 'down');
713
+
714
+ let child = cur;
715
+ let parent = child.parentId ? this.get(child.parentId) : null;
716
+ while (parent) {
717
+ if (parent.dir === wantDir) {
718
+ const idx = parent.children.indexOf(child.id);
719
+ const sibIdx = forward ? idx + 1 : idx - 1;
720
+ if (sibIdx >= 0 && sibIdx < parent.children.length) {
721
+ const target = this._descendEdge(parent.children[sibIdx],
722
+ wantDir, !forward);
723
+ if (target) {
724
+ this.focus(target);
725
+ return true;
726
+ }
727
+ }
728
+ }
729
+ child = parent;
730
+ parent = child.parentId ? this.get(child.parentId) : null;
731
+ }
732
+ return false;
733
+ }
734
+
735
+ /**
736
+ * Walk down to a leaf. For the matching direction we take the
737
+ * `edge` end (first/last). For the orthogonal direction we just
738
+ * take the first child.
739
+ */
740
+ _descendEdge(nodeId, matchDir, takeFirst) {
741
+ let n = this.get(nodeId);
742
+ while (n && n.kind !== 'leaf') {
743
+ const idx = n.dir === matchDir
744
+ ? (takeFirst ? 0 : n.children.length - 1)
745
+ : 0;
746
+ n = this.get(n.children[idx]);
747
+ }
748
+ return n ? n.id : null;
749
+ }
750
+
751
+ // ── Move tile ──────────────────────────────────────────────────
752
+ /**
753
+ * Move the focused leaf one position in the given direction. Simple
754
+ * version: swap with the immediate sibling in the matching parent;
755
+ * if no such sibling, no-op (deep moves come later if needed).
756
+ */
757
+ moveDir(dir) {
758
+ const cur = this.focused();
759
+ if (!cur || !cur.parentId) return false;
760
+ const wantDir = (dir === 'left' || dir === 'right') ? 'h' : 'v';
761
+ const forward = (dir === 'right' || dir === 'down');
762
+
763
+ let child = cur;
764
+ let parent = this.get(child.parentId);
765
+ while (parent && parent.dir !== wantDir) {
766
+ child = parent;
767
+ parent = child.parentId ? this.get(child.parentId) : null;
768
+ }
769
+ if (!parent) return false;
770
+ const idx = parent.children.indexOf(child.id);
771
+ const swap = forward ? idx + 1 : idx - 1;
772
+ if (swap < 0 || swap >= parent.children.length) return false;
773
+ [parent.children[idx], parent.children[swap]] =
774
+ [parent.children[swap], parent.children[idx]];
775
+ [parent.sizes[idx], parent.sizes[swap]] =
776
+ [parent.sizes[swap], parent.sizes[idx]];
777
+ return true;
778
+ }
779
+
780
+ // ── Resize ──────────────────────────────────────────────────────
781
+ setSplitSizes(splitId, sizes) {
782
+ const n = this.get(splitId);
783
+ if (!n || n.kind !== 'split') return false;
784
+ if (sizes.length !== n.children.length) return false;
785
+ n.sizes = sizes.slice();
786
+ return true;
787
+ }
788
+
789
+ // ── Serialization (for desktops persistence) ──────────────────
790
+ serialize() {
791
+ if (!this.rootId) return null;
792
+ const dump = (id) => {
793
+ const n = this.get(id);
794
+ if (n.kind === 'leaf') {
795
+ // Transient tabs (add-row forms etc.) are not persisted.
796
+ const liveTabs = (Array.isArray(n.tabs) ? n.tabs : [])
797
+ .filter((t) => !t.transient)
798
+ .map((t) => ({ kind: t.kind, props: t.props, title: t.title }));
799
+ return {
800
+ kind: 'leaf',
801
+ title: n.title,
802
+ content: n.content,
803
+ tabs: liveTabs,
804
+ activeTabIdx: Math.max(0, Math.min(liveTabs.length - 1, n.activeTabIdx || 0)),
805
+ // Page-archive: per-top-nav-kind saved tab lists so
806
+ // switching to a previously-visited page restores
807
+ // its tabs across reloads.
808
+ pageTabs: n.pageTabs
809
+ ? Object.fromEntries(
810
+ Object.entries(n.pageTabs).map(([k, v]) => {
811
+ const pt = (v.tabs || [])
812
+ .filter((t) => !t.transient)
813
+ .map((t) => ({ kind: t.kind, props: t.props, title: t.title }));
814
+ return [k, {
815
+ tabs: pt,
816
+ activeTabIdx: Math.max(0, Math.min(pt.length - 1, v.activeTabIdx || 0)),
817
+ }];
818
+ }))
819
+ : {},
820
+ };
821
+ }
822
+ return {
823
+ kind: 'split', dir: n.dir, sizes: n.sizes.slice(),
824
+ children: n.children.map(dump),
825
+ };
826
+ };
827
+ return {
828
+ root: dump(this.rootId),
829
+ focusedTitle: this.focused()?.title || null,
830
+ };
831
+ }
832
+
833
+ static deserialize(blob) {
834
+ const t = new TileTree();
835
+ if (!blob || !blob.root) return t;
836
+ const build = (raw) => {
837
+ if (raw.kind === 'leaf') {
838
+ // Forward-compat with the pre-tabs layout: an old
839
+ // serialized leaf carries `content` but no `tabs`.
840
+ // makeLeaf will derive a single-tab list from `content`.
841
+ const leaf = makeLeaf({ content: raw.content, title: raw.title });
842
+ if (Array.isArray(raw.tabs) && raw.tabs.length > 0) {
843
+ leaf.tabs = raw.tabs.map((tab) => ({
844
+ kind: tab.kind,
845
+ props: tab.props || {},
846
+ title: tab.title || tab.kind || '',
847
+ }));
848
+ leaf.activeTabIdx = Math.max(0,
849
+ Math.min(leaf.tabs.length - 1, raw.activeTabIdx || 0));
850
+ _syncActiveTab(leaf);
851
+ }
852
+ if (raw.pageTabs && typeof raw.pageTabs === 'object') {
853
+ leaf.pageTabs = {};
854
+ for (const [k, v] of Object.entries(raw.pageTabs)) {
855
+ if (!v || !Array.isArray(v.tabs)) continue;
856
+ leaf.pageTabs[k] = {
857
+ tabs: v.tabs.map((t) => ({
858
+ kind: t.kind,
859
+ props: t.props || {},
860
+ title: t.title || t.kind || '',
861
+ })),
862
+ activeTabIdx: v.activeTabIdx || 0,
863
+ };
864
+ }
865
+ }
866
+ return leaf;
867
+ }
868
+ const children = raw.children.map(build);
869
+ const split = makeSplit({
870
+ dir: raw.dir, children: children.map((c) => c.id),
871
+ sizes: raw.sizes,
872
+ });
873
+ children.forEach((c) => t._register(c));
874
+ return split;
875
+ };
876
+ const root = build(blob.root);
877
+ t.setRoot(root);
878
+ return t;
879
+ }
880
+ }