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
package/dist/wm.js ADDED
@@ -0,0 +1,4830 @@
1
+ import {
2
+ HelpModal,
3
+ openForm,
4
+ showContextMenu
5
+ } from "./chunk-DVU44T77.js";
6
+ import {
7
+ ManagedWindow
8
+ } from "./chunk-UCJ2WD4D.js";
9
+ import "./chunk-FL5KFNQH.js";
10
+ import "./chunk-JYWURG5T.js";
11
+
12
+ // src/tiling/command_palette.js
13
+ var ROOT_ID = "twm-cmdpal";
14
+ var STATIC_COMMANDS = [
15
+ {
16
+ kind: "command",
17
+ id: "shortcuts",
18
+ label: "Keyboard shortcuts",
19
+ hint: "Show all key bindings (?)",
20
+ icon: "keyboard",
21
+ action: () => HelpModal.open("keyboard-shortcuts")
22
+ }
23
+ ];
24
+ function createCommandPalette({ wm, api, taxonomy, catalog, placeholder = "Search\u2026" }) {
25
+ if (!taxonomy) throw new Error("createCommandPalette: a taxonomy is required");
26
+ if (!catalog) throw new Error("createCommandPalette: an entity catalog is required");
27
+ let overlay = null;
28
+ let entities = [];
29
+ const isOpen = () => !!overlay;
30
+ const close = () => {
31
+ if (!overlay) return;
32
+ overlay.remove();
33
+ overlay = null;
34
+ };
35
+ const toggle = () => {
36
+ isOpen() ? close() : open();
37
+ };
38
+ const open = async () => {
39
+ if (overlay) return;
40
+ overlay = document.createElement("div");
41
+ overlay.id = ROOT_ID;
42
+ overlay.className = "twm-cmdpal-overlay";
43
+ overlay.innerHTML = _markup(taxonomy, placeholder);
44
+ document.body.appendChild(overlay);
45
+ overlay.addEventListener("click", (e) => {
46
+ if (e.target === overlay) close();
47
+ });
48
+ const input = overlay.querySelector(".twm-cmdpal__input");
49
+ const list = overlay.querySelector('[data-role="list"]');
50
+ const chipSlot = overlay.querySelector('[data-role="type-chip"]');
51
+ const chips = overlay.querySelectorAll("[data-shortcut]");
52
+ const toggles = overlay.querySelectorAll("[data-toggle]");
53
+ for (const btn of toggles) {
54
+ const which = btn.dataset.toggle;
55
+ btn.classList.toggle("twm-chip--on", wm.isPanelOpen(which));
56
+ }
57
+ chips.forEach((btn) => btn.addEventListener("click", () => {
58
+ const kind = btn.dataset.shortcut;
59
+ wm.openInPrimary(kind);
60
+ close();
61
+ }));
62
+ toggles.forEach((btn) => btn.addEventListener("click", () => {
63
+ const which = btn.dataset.toggle;
64
+ wm.togglePanel(which);
65
+ btn.classList.toggle("twm-chip--on", wm.isPanelOpen(which));
66
+ }));
67
+ let filtered = [];
68
+ let active = 0;
69
+ let typeFilter = null;
70
+ let typeFilterLabel = null;
71
+ const renderChip = () => {
72
+ if (!typeFilter) {
73
+ chipSlot.innerHTML = "";
74
+ return;
75
+ }
76
+ const head = typeFilter[0];
77
+ const meta = taxonomy.meta(head);
78
+ const label = typeFilterLabel || meta?.label || head;
79
+ chipSlot.innerHTML = `
80
+ <span class="twm-cmdpal__type-chip" title="Filtering by ${_esc(label)} (${typeFilter.length} kind${typeFilter.length === 1 ? "" : "s"})">
81
+ <span class="material-symbols-outlined twm-cmdpal__type-chip-icon">${meta?.icon || "arrow_right"}</span>
82
+ <span class="twm-cmdpal__type-chip-label">${_esc(label)}</span>
83
+ <button type="button" class="twm-cmdpal__type-chip-x"
84
+ data-action="clear-type"
85
+ aria-label="Clear type filter">\xD7</button>
86
+ </span>
87
+ `;
88
+ chipSlot.querySelector('[data-action="clear-type"]')?.addEventListener("click", (ev) => {
89
+ ev.preventDefault();
90
+ typeFilter = null;
91
+ typeFilterLabel = null;
92
+ renderChip();
93
+ input.focus();
94
+ refilter();
95
+ });
96
+ };
97
+ const render = () => {
98
+ list.innerHTML = filtered.slice(0, 200).map((e, i) => `
99
+ <div class="twm-cmdpal__item${i === active ? " twm-cmdpal__item--active" : ""}"
100
+ data-idx="${i}">
101
+ <span class="material-symbols-outlined twm-cmdpal__icon">${e.icon || "arrow_right"}</span>
102
+ <span class="twm-cmdpal__label">${_esc(e.label || e.id)}</span>
103
+ <span class="twm-cmdpal__kind">${e.kind}</span>
104
+ <span class="twm-cmdpal__hint">${_esc(e.hint || "")}</span>
105
+ </div>
106
+ `).join("") || "";
107
+ list.querySelectorAll("[data-idx]").forEach((el) => {
108
+ el.addEventListener("click", () => commit(Number(el.dataset.idx)));
109
+ });
110
+ };
111
+ const commit = (i) => {
112
+ const pick = filtered[i];
113
+ if (!pick) return;
114
+ close();
115
+ if (pick.action) {
116
+ try {
117
+ pick.action();
118
+ } catch (err) {
119
+ console.error(err);
120
+ }
121
+ return;
122
+ }
123
+ wm.openInPrimary(pick.kind, pick.props || { id: pick.id, label: pick.label });
124
+ };
125
+ const tryConsumePrefix = () => {
126
+ if (typeFilter) return false;
127
+ const m = input.value.match(/^([a-zA-Z][a-zA-Z0-9_-]*):/);
128
+ if (!m) return false;
129
+ const kinds = catalog.resolveTypePrefix(m[1]);
130
+ if (!kinds || kinds.length === 0) return false;
131
+ typeFilter = kinds;
132
+ typeFilterLabel = _titleCase(m[1]);
133
+ input.value = input.value.slice(m[0].length);
134
+ renderChip();
135
+ return true;
136
+ };
137
+ const refilter = () => {
138
+ tryConsumePrefix();
139
+ const q = input.value.trim().toLowerCase();
140
+ const filterSet = typeFilter ? new Set(typeFilter) : null;
141
+ const pool = filterSet ? entities.filter((e) => filterSet.has(e.kind)) : entities;
142
+ if (!q) {
143
+ filtered = typeFilter ? pool.slice().sort((a, b) => String(a.label || a.id).localeCompare(b.label || b.id)) : [];
144
+ } else {
145
+ filtered = _search(pool, q);
146
+ }
147
+ active = 0;
148
+ render();
149
+ };
150
+ input.addEventListener("input", refilter);
151
+ input.addEventListener("keydown", (e) => {
152
+ if (e.key === "ArrowDown") {
153
+ e.preventDefault();
154
+ active = Math.min(filtered.length - 1, active + 1);
155
+ render();
156
+ } else if (e.key === "ArrowUp") {
157
+ e.preventDefault();
158
+ active = Math.max(0, active - 1);
159
+ render();
160
+ } else if (e.key === "Enter") {
161
+ e.preventDefault();
162
+ commit(active);
163
+ } else if (e.key === "Escape") {
164
+ close();
165
+ } else if (e.key === "Backspace" && input.value === "" && typeFilter) {
166
+ e.preventDefault();
167
+ typeFilter = null;
168
+ typeFilterLabel = null;
169
+ renderChip();
170
+ refilter();
171
+ }
172
+ });
173
+ input.focus();
174
+ entities = _decorateIcons(STATIC_COMMANDS.slice(), taxonomy);
175
+ if (api) {
176
+ try {
177
+ entities = _decorateIcons(
178
+ [...STATIC_COMMANDS, ...await catalog.loadAll(api)],
179
+ taxonomy
180
+ );
181
+ if (input.value) refilter();
182
+ } catch (err) {
183
+ console.warn("[cmdpal] entity load failed", err);
184
+ }
185
+ }
186
+ };
187
+ return { open, close, toggle, isOpen };
188
+ }
189
+ function _markup(taxonomy, placeholder) {
190
+ const chips = taxonomy.topNavEntries().map((k) => `
191
+ <button class="twm-chip" data-shortcut="${k.kind}">
192
+ <span class="material-symbols-outlined">${k.icon}</span>
193
+ ${k.label}
194
+ </button>
195
+ `).join("");
196
+ return `
197
+ <div class="twm-cmdpal" role="dialog" aria-label="Command palette">
198
+ <div class="twm-cmdpal__inputrow">
199
+ <span class="twm-cmdpal__type-chip-slot" data-role="type-chip"></span>
200
+ <input type="text" class="twm-cmdpal__input"
201
+ placeholder="${_esc(placeholder)}"
202
+ autocomplete="off" />
203
+ </div>
204
+ <div class="twm-cmdpal__chips">${chips}</div>
205
+ <div class="twm-cmdpal__toggles">
206
+ <button class="twm-chip" data-toggle="left">
207
+ <span class="material-symbols-outlined">menu</span>
208
+ Left nav
209
+ </button>
210
+ <button class="twm-chip" data-toggle="right">
211
+ <span class="material-symbols-outlined">dock_to_left</span>
212
+ Right panel
213
+ </button>
214
+ <button class="twm-chip" data-toggle="bottom">
215
+ <span class="material-symbols-outlined">dock_to_bottom</span>
216
+ Bottom panel
217
+ </button>
218
+ </div>
219
+ <div class="twm-cmdpal__list" data-role="list"></div>
220
+ <div class="twm-cmdpal__footer">
221
+ <span><kbd>\u2191</kbd><kbd>\u2193</kbd> navigate</span>
222
+ <span><kbd>Enter</kbd> open</span>
223
+ <span><kbd>Esc</kbd> close</span>
224
+ </div>
225
+ </div>
226
+ `;
227
+ }
228
+ function _decorateIcons(rows, taxonomy) {
229
+ return rows.map((r) => {
230
+ if (r.icon) return r;
231
+ const meta = taxonomy.meta(r.kind);
232
+ return { ...r, icon: meta?.icon || "arrow_right" };
233
+ });
234
+ }
235
+ function _search(entities, q) {
236
+ const scored = [];
237
+ for (const e of entities) {
238
+ const id = String(e.id || "").toLowerCase();
239
+ const label = String(e.label || "").toLowerCase();
240
+ const hint = String(e.hint || "").toLowerCase();
241
+ let r;
242
+ if (id === q || label === q) r = 0;
243
+ else if (id.startsWith(q)) r = 1;
244
+ else if (label.startsWith(q)) r = 2;
245
+ else if (id.includes(q)) r = 3;
246
+ else if (label.includes(q)) r = 4;
247
+ else if (hint.includes(q)) r = 5;
248
+ else continue;
249
+ scored.push({ e, r });
250
+ }
251
+ scored.sort((a, b) => a.r - b.r || a.e.label.localeCompare(b.e.label));
252
+ return scored.map((x) => x.e);
253
+ }
254
+ function _esc(s) {
255
+ return String(s ?? "").replace(/[&<>"']/g, (c) => ({
256
+ "&": "&amp;",
257
+ "<": "&lt;",
258
+ ">": "&gt;",
259
+ '"': "&quot;",
260
+ "'": "&#39;"
261
+ })[c]);
262
+ }
263
+ function _titleCase(s) {
264
+ const str = String(s || "").replace(/[_-]+/g, " ").trim();
265
+ if (!str) return "";
266
+ return str.charAt(0).toUpperCase() + str.slice(1).toLowerCase();
267
+ }
268
+
269
+ // src/tiling/content_registry.js
270
+ var PLACEHOLDER_KIND = "window-placeholder";
271
+ function windowPlaceholderFactory(hostEl, props, ctx) {
272
+ hostEl.innerHTML = `
273
+ <div class="twm-window-placeholder">
274
+ <span class="material-symbols-outlined twm-window-placeholder__icon">open_in_new</span>
275
+ <div class="twm-window-placeholder__title">${_esc2(props.originalTitle || props.originalKind || "content")}</div>
276
+ <div class="twm-window-placeholder__hint">is open in a managed window</div>
277
+ <button class="twm-window-placeholder__btn" data-action="bring-back">
278
+ Bring back to this tile
279
+ </button>
280
+ </div>
281
+ `;
282
+ hostEl.querySelector('[data-action="bring-back"]')?.addEventListener("click", () => {
283
+ ctx.wm?.bringBackWindow?.(props.windowId);
284
+ });
285
+ return { title: `${props.originalTitle || props.originalKind} (window)` };
286
+ }
287
+ function createContentRegistry(map = {}) {
288
+ if (!map || typeof map !== "object") {
289
+ throw new TypeError("createContentRegistry: `content` must be an object of kind -> factory");
290
+ }
291
+ for (const [kind, factory] of Object.entries(map)) {
292
+ if (typeof factory !== "function") {
293
+ throw new TypeError(
294
+ `createContentRegistry: factory for "${kind}" must be a function`
295
+ );
296
+ }
297
+ }
298
+ const factories = new Map(Object.entries(map));
299
+ if (!factories.has(PLACEHOLDER_KIND)) {
300
+ factories.set(PLACEHOLDER_KIND, windowPlaceholderFactory);
301
+ }
302
+ return Object.freeze({
303
+ has: (kind) => factories.has(kind),
304
+ mount: (kind, hostEl, props, ctx) => {
305
+ const factory = factories.get(kind) || _placeholder(kind);
306
+ try {
307
+ return factory(hostEl, props || {}, ctx || {}) || {};
308
+ } catch (err) {
309
+ console.error("[content_registry] mount failed for", kind, err);
310
+ hostEl.innerHTML = `<div class="twm-tile-error">Mount failed: ${err && err.message || err}</div>`;
311
+ return {};
312
+ }
313
+ }
314
+ });
315
+ }
316
+ function _placeholder(kind) {
317
+ return (hostEl) => {
318
+ hostEl.innerHTML = `
319
+ <div class="tile-placeholder">
320
+ <div class="tile-placeholder__title">${kind}</div>
321
+ <div class="tile-placeholder__hint">no factory registered yet</div>
322
+ </div>
323
+ `;
324
+ return { title: kind };
325
+ };
326
+ }
327
+ function _esc2(s) {
328
+ return String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
329
+ }
330
+
331
+ // src/tiling/tile_tree.js
332
+ var _idCounter = 1;
333
+ function _newId(prefix) {
334
+ return `${prefix}-${_idCounter++}`;
335
+ }
336
+ function _isPanel(leaf) {
337
+ return String(leaf?.content?.kind || "").startsWith("panel:");
338
+ }
339
+ function _syncActiveTab(leaf) {
340
+ if (!leaf || leaf.kind !== "leaf") return;
341
+ const tabs = leaf.tabs || [];
342
+ if (tabs.length === 0) {
343
+ leaf.content = null;
344
+ leaf.title = leaf.title || "";
345
+ leaf.activeTabIdx = 0;
346
+ return;
347
+ }
348
+ if (leaf.activeTabIdx == null || leaf.activeTabIdx < 0 || leaf.activeTabIdx >= tabs.length) {
349
+ leaf.activeTabIdx = Math.max(0, Math.min(tabs.length - 1, leaf.activeTabIdx || 0));
350
+ }
351
+ const t = tabs[leaf.activeTabIdx];
352
+ leaf.content = { kind: t.kind, props: t.props || {} };
353
+ leaf.title = t.title || t.kind || "";
354
+ }
355
+ function makeLeaf({ content = null, title = "" } = {}) {
356
+ const leaf = {
357
+ id: _newId("leaf"),
358
+ kind: "leaf",
359
+ parentId: null,
360
+ // Tabs are the source of truth; content/title mirror tabs[active].
361
+ tabs: content ? [{ kind: content.kind, props: content.props || {}, title: title || content.kind || "" }] : [],
362
+ activeTabIdx: content ? 0 : 0,
363
+ content,
364
+ title,
365
+ // Per-top-nav-page tab archive. When a top-nav button switches
366
+ // this leaf to another page, the current `tabs` + `activeTabIdx`
367
+ // get stashed here under the LEAVING page's key, and the target
368
+ // page's archived list (if any) gets restored. This is what
369
+ // makes a tile remember its tab set when the user clicks away to
370
+ // another top-nav page and back. Keyed by top-nav kind — see the
371
+ // taxonomy's `topNavFor`.
372
+ pageTabs: {}
373
+ };
374
+ _syncActiveTab(leaf);
375
+ return leaf;
376
+ }
377
+ function makeSplit({ dir = "h", children = [], sizes = null } = {}) {
378
+ return {
379
+ id: _newId("split"),
380
+ kind: "split",
381
+ parentId: null,
382
+ dir,
383
+ children: children.slice(),
384
+ sizes: sizes ? sizes.slice() : children.map(() => 1)
385
+ };
386
+ }
387
+ var TileTree = class _TileTree {
388
+ constructor() {
389
+ this.nodes = /* @__PURE__ */ new Map();
390
+ this.rootId = null;
391
+ this.focusedLeafId = null;
392
+ this.focusStack = [];
393
+ }
394
+ // ── Read helpers ────────────────────────────────────────────────
395
+ get(id) {
396
+ return this.nodes.get(id) || null;
397
+ }
398
+ root() {
399
+ return this.rootId ? this.get(this.rootId) : null;
400
+ }
401
+ focused() {
402
+ return this.focusedLeafId ? this.get(this.focusedLeafId) : null;
403
+ }
404
+ leaves() {
405
+ const out = [];
406
+ const walk = (id) => {
407
+ const n = this.get(id);
408
+ if (!n) return;
409
+ if (n.kind === "leaf") out.push(n);
410
+ else n.children.forEach(walk);
411
+ };
412
+ if (this.rootId) walk(this.rootId);
413
+ return out;
414
+ }
415
+ // ── Construction ───────────────────────────────────────────────
416
+ setRoot(node) {
417
+ this._register(node);
418
+ this.rootId = node.id;
419
+ if (node.kind === "leaf") {
420
+ this.focus(node.id);
421
+ }
422
+ return node.id;
423
+ }
424
+ _register(node) {
425
+ this.nodes.set(node.id, node);
426
+ if (node.kind === "split") {
427
+ for (const cid of node.children) {
428
+ const c = this.get(cid);
429
+ if (c) c.parentId = node.id;
430
+ }
431
+ }
432
+ }
433
+ // ── Focus ───────────────────────────────────────────────────────
434
+ focus(leafId) {
435
+ if (!this.nodes.has(leafId)) return;
436
+ const node = this.get(leafId);
437
+ if (node.kind !== "leaf") return;
438
+ this.focusedLeafId = leafId;
439
+ this.focusStack = this.focusStack.filter((id) => id !== leafId);
440
+ this.focusStack.unshift(leafId);
441
+ }
442
+ /** The leaf where "open content" should land — top of focus stack,
443
+ * skipping panel:* tiles which are chrome, not content. */
444
+ primaryLeafId() {
445
+ for (const id of this.focusStack) {
446
+ const n = this.nodes.get(id);
447
+ if (n && n.kind === "leaf" && !_isPanel(n)) return id;
448
+ }
449
+ for (const leaf of this.leaves()) {
450
+ if (!_isPanel(leaf)) return leaf.id;
451
+ }
452
+ return null;
453
+ }
454
+ // ── Mutations ───────────────────────────────────────────────────
455
+ /** Replace the leaf's tab list with a single tab carrying `content`.
456
+ * Closes any existing tabs in that leaf — this is the "open in
457
+ * place" / "replace tile" path that the WM has always used. */
458
+ setLeafContent(leafId, content, title = "") {
459
+ const n = this.get(leafId);
460
+ if (!n || n.kind !== "leaf") return false;
461
+ if (content) {
462
+ n.tabs = [{
463
+ kind: content.kind,
464
+ props: content.props || {},
465
+ title: title || content.kind || ""
466
+ }];
467
+ n.activeTabIdx = 0;
468
+ } else {
469
+ n.tabs = [];
470
+ n.activeTabIdx = 0;
471
+ }
472
+ _syncActiveTab(n);
473
+ if (title) n.title = title;
474
+ return true;
475
+ }
476
+ /** Page-aware swap: archive the leaf's current tab list under its
477
+ * current top-nav key, then restore the target top-nav's archived
478
+ * list (or initialize one with the requested kind/props). Used
479
+ * by top-nav / palette navigation so each top-nav page keeps its
480
+ * own tab set across page switches.
481
+ *
482
+ * Parameters:
483
+ * leafId The primary leaf.
484
+ * target { kind, props, title } — what the user clicked.
485
+ * currentTopNav Top-nav of the leaf's current page (may be null).
486
+ * targetTopNav Top-nav of the target kind (must be set).
487
+ *
488
+ * When `currentTopNav === targetTopNav` (already on the right
489
+ * page), the swap is skipped and we either focus an existing
490
+ * matching tab or replace the active one. This is what makes
491
+ * clicking the top-nav button for the page you're already on
492
+ * feel like "Home for this page" without resetting tabs. */
493
+ swapToPage(leafId, target, currentTopNav, targetTopNav, opts = {}) {
494
+ const n = this.get(leafId);
495
+ if (!n || n.kind !== "leaf") return false;
496
+ if (!target || !target.kind) return false;
497
+ n.pageTabs = n.pageTabs || {};
498
+ const recordHistory = opts.recordHistory !== false;
499
+ const _pushPrev = (history, prev, next, extra) => {
500
+ if (!recordHistory || !prev || !prev.kind) return history;
501
+ const last = history[history.length - 1];
502
+ const sameAsPrev = last && last.kind === prev.kind && String(last.props?.id ?? "") === String(prev.props?.id ?? "");
503
+ const sameAsTarget = next && prev.kind === next.kind && String(prev.props?.id ?? "") === String(next.props?.id ?? "");
504
+ if (!sameAsPrev && !sameAsTarget) {
505
+ history.push({
506
+ kind: prev.kind,
507
+ props: prev.props || {},
508
+ title: prev.title || "",
509
+ ...extra || {}
510
+ });
511
+ }
512
+ return history;
513
+ };
514
+ const same = currentTopNav && currentTopNav === targetTopNav;
515
+ if (same) {
516
+ const tabs = n.tabs || [];
517
+ const matchIdx = tabs.findIndex((t) => t.kind === target.kind && String(t.props?.id ?? "") === String(target.props?.id ?? ""));
518
+ if (matchIdx >= 0) {
519
+ n.activeTabIdx = matchIdx;
520
+ } else if (tabs.length === 0) {
521
+ n.tabs = [{
522
+ kind: target.kind,
523
+ props: target.props || {},
524
+ title: target.title || target.kind,
525
+ history: []
526
+ }];
527
+ n.activeTabIdx = 0;
528
+ } else {
529
+ const prev = tabs[n.activeTabIdx];
530
+ const history = Array.isArray(prev?.history) ? prev.history : [];
531
+ _pushPrev(history, prev, target, null);
532
+ tabs[n.activeTabIdx] = {
533
+ kind: target.kind,
534
+ props: target.props || {},
535
+ title: target.title || target.kind,
536
+ history
537
+ };
538
+ }
539
+ _syncActiveTab(n);
540
+ return true;
541
+ }
542
+ const srcTabs = Array.isArray(n.tabs) ? n.tabs : [];
543
+ const srcActive = srcTabs[n.activeTabIdx];
544
+ const sourceEntry = currentTopNav && srcActive?.kind ? {
545
+ kind: srcActive.kind,
546
+ props: srcActive.props || {},
547
+ title: srcActive.title || "",
548
+ topNav: currentTopNav
549
+ } : null;
550
+ if (currentTopNav && srcTabs.length > 0) {
551
+ const archived = srcTabs.filter((t) => !t.transient).map((t) => ({
552
+ kind: t.kind,
553
+ props: { ...t.props || {} },
554
+ title: t.title,
555
+ history: Array.isArray(t.history) ? t.history.slice() : []
556
+ }));
557
+ n.pageTabs[currentTopNav] = {
558
+ tabs: archived,
559
+ activeTabIdx: Math.max(0, Math.min(archived.length - 1, n.activeTabIdx || 0))
560
+ };
561
+ }
562
+ const saved = n.pageTabs[targetTopNav];
563
+ if (saved && Array.isArray(saved.tabs) && saved.tabs.length > 0) {
564
+ n.tabs = saved.tabs.map((t) => ({
565
+ kind: t.kind,
566
+ props: { ...t.props || {} },
567
+ title: t.title,
568
+ history: Array.isArray(t.history) ? t.history.slice() : []
569
+ }));
570
+ n.activeTabIdx = Math.max(
571
+ 0,
572
+ Math.min(n.tabs.length - 1, saved.activeTabIdx || 0)
573
+ );
574
+ const wantsTarget = target.props?.id != null || target.kind !== targetTopNav;
575
+ if (wantsTarget) {
576
+ const wantId = target.props?.id != null;
577
+ const matchIdx = n.tabs.findIndex((t) => t.kind === target.kind && (!wantId || String(t.props?.id ?? "") === String(target.props.id)));
578
+ if (matchIdx >= 0) {
579
+ n.activeTabIdx = matchIdx;
580
+ } else {
581
+ const history = [];
582
+ if (sourceEntry) _pushPrev(history, sourceEntry, target, { topNav: currentTopNav });
583
+ n.tabs.push({
584
+ kind: target.kind,
585
+ props: target.props || {},
586
+ title: target.title || target.kind,
587
+ history
588
+ });
589
+ n.activeTabIdx = n.tabs.length - 1;
590
+ }
591
+ }
592
+ } else {
593
+ const history = [];
594
+ if (sourceEntry) _pushPrev(history, sourceEntry, target, { topNav: currentTopNav });
595
+ n.tabs = [{
596
+ kind: target.kind,
597
+ props: target.props || {},
598
+ title: target.title || target.kind,
599
+ history
600
+ }];
601
+ n.activeTabIdx = 0;
602
+ }
603
+ _syncActiveTab(n);
604
+ return true;
605
+ }
606
+ /** Update any tab whose (kind, props.id) matches the given pair to
607
+ * reflect a new label. Called from the WM in response to the
608
+ * embedder's entity-renamed bus event, so tab titles stay live as
609
+ * the underlying entity is renamed. */
610
+ renameMatchingTabs(kind, id, newLabel) {
611
+ if (!kind || !id || !newLabel) return false;
612
+ let touched = false;
613
+ const visit = (tabs) => {
614
+ if (!Array.isArray(tabs)) return;
615
+ for (const t of tabs) {
616
+ if (t.kind === kind && String(t.props?.id ?? "") === String(id)) {
617
+ t.title = String(newLabel);
618
+ if (t.props) t.props.label = String(newLabel);
619
+ touched = true;
620
+ }
621
+ }
622
+ };
623
+ for (const n of this.nodes.values()) {
624
+ if (n.kind !== "leaf") continue;
625
+ visit(n.tabs);
626
+ if (n.pageTabs) {
627
+ for (const arc of Object.values(n.pageTabs)) visit(arc?.tabs);
628
+ }
629
+ _syncActiveTab(n);
630
+ }
631
+ return touched;
632
+ }
633
+ /** Replace the active tab's content in place, preserving every
634
+ * other tab in the leaf. This is the "in-tile navigation" path:
635
+ * clicking a row on a landing page, walking back via Backspace,
636
+ * or clicking a breadcrumb segment should swap THIS tab's
637
+ * content without disturbing the user's other open tabs.
638
+ *
639
+ * By default the previous content is pushed onto the active
640
+ * tab's `history` stack so the user can Backspace through their
641
+ * own click-path (per-tab "browser back"). Callers that are
642
+ * themselves popping the history pass `{ recordHistory: false }`
643
+ * so we don't double-record. Same applies to programmatic loads
644
+ * that aren't user navigations.
645
+ *
646
+ * Falls back to `setLeafContent` for empty leaves so the WM
647
+ * never has to special-case the initial load. Returns true on
648
+ * success. */
649
+ replaceActiveTabContent(leafId, content, title = "", opts = {}) {
650
+ const n = this.get(leafId);
651
+ if (!n || n.kind !== "leaf") return false;
652
+ const tabs = Array.isArray(n.tabs) ? n.tabs : [];
653
+ if (tabs.length === 0) {
654
+ return this.setLeafContent(leafId, content, title);
655
+ }
656
+ if (!content) return false;
657
+ const idx = Math.max(0, Math.min(tabs.length - 1, n.activeTabIdx || 0));
658
+ const prev = tabs[idx];
659
+ const recordHistory = opts.recordHistory !== false;
660
+ const history = Array.isArray(prev?.history) ? prev.history : [];
661
+ if (recordHistory && prev && prev.kind) {
662
+ const last = history[history.length - 1];
663
+ const sameAsPrev = last && last.kind === prev.kind && String(last.props?.id ?? "") === String(prev.props?.id ?? "");
664
+ const sameAsTarget = prev.kind === content.kind && String(prev.props?.id ?? "") === String(content.props?.id ?? "");
665
+ if (!sameAsPrev && !sameAsTarget) {
666
+ history.push({
667
+ kind: prev.kind,
668
+ props: prev.props || {},
669
+ title: prev.title || ""
670
+ });
671
+ }
672
+ }
673
+ tabs[idx] = {
674
+ kind: content.kind,
675
+ props: content.props || {},
676
+ title: title || content.kind || "",
677
+ history
678
+ };
679
+ _syncActiveTab(n);
680
+ return true;
681
+ }
682
+ /** Merge `patch` into the active tab's `props`. Used when an
683
+ * in-tile editor surfaces metadata that should be captured in
684
+ * history snapshots (e.g. which sub-tab is open) so Backspace
685
+ * restores the user's view, not the default landing. Does not
686
+ * re-render or push history — pure metadata write. */
687
+ updateActiveTabProps(leafId, patch) {
688
+ const n = this.get(leafId);
689
+ if (!n || n.kind !== "leaf") return false;
690
+ const tabs = Array.isArray(n.tabs) ? n.tabs : [];
691
+ if (tabs.length === 0) return false;
692
+ const idx = Math.max(0, Math.min(tabs.length - 1, n.activeTabIdx || 0));
693
+ const cur = tabs[idx];
694
+ if (!cur) return false;
695
+ cur.props = { ...cur.props || {}, ...patch || {} };
696
+ return true;
697
+ }
698
+ /** Pop one entry off the active tab's history stack and return it,
699
+ * or null when empty. Doesn't mutate the tab beyond the stack —
700
+ * the caller is expected to apply the returned content via
701
+ * `replaceActiveTabContent(..., { recordHistory: false })`. */
702
+ popActiveTabHistory(leafId) {
703
+ const n = this.get(leafId);
704
+ if (!n || n.kind !== "leaf") return null;
705
+ const tabs = Array.isArray(n.tabs) ? n.tabs : [];
706
+ if (tabs.length === 0) return null;
707
+ const idx = Math.max(0, Math.min(tabs.length - 1, n.activeTabIdx || 0));
708
+ const cur = tabs[idx];
709
+ const history = Array.isArray(cur?.history) ? cur.history : [];
710
+ if (history.length === 0) return null;
711
+ return history.pop();
712
+ }
713
+ /** Inspect the active tab's history stack without popping. Used
714
+ * by the keymap to show / hide the back affordance and for
715
+ * diagnostics. */
716
+ activeTabHistory(leafId) {
717
+ const n = this.get(leafId);
718
+ if (!n || n.kind !== "leaf") return [];
719
+ const tabs = Array.isArray(n.tabs) ? n.tabs : [];
720
+ if (tabs.length === 0) return [];
721
+ const idx = Math.max(0, Math.min(tabs.length - 1, n.activeTabIdx || 0));
722
+ const cur = tabs[idx];
723
+ return Array.isArray(cur?.history) ? cur.history : [];
724
+ }
725
+ /** Append a new tab to a leaf and focus it. Returns the new tab's
726
+ * index, or -1 on failure. Panel tiles never grow tabs (they're
727
+ * chrome). */
728
+ appendLeafTab(leafId, content, title = "", opts = {}) {
729
+ const n = this.get(leafId);
730
+ if (!n || n.kind !== "leaf") return -1;
731
+ if (_isPanel(n)) return -1;
732
+ if (!content) return -1;
733
+ const tab = {
734
+ kind: content.kind,
735
+ props: content.props || {},
736
+ title: title || content.kind || "",
737
+ // Transient tabs (e.g. an add-row form) are not persisted /
738
+ // restored — see serialize() + the pageTabs archive.
739
+ ...opts.transient ? { transient: true } : {}
740
+ };
741
+ n.tabs = Array.isArray(n.tabs) ? n.tabs : [];
742
+ n.tabs.push(tab);
743
+ n.activeTabIdx = n.tabs.length - 1;
744
+ _syncActiveTab(n);
745
+ return n.activeTabIdx;
746
+ }
747
+ /** Switch the active tab on a leaf. No-op if `idx` is out of range. */
748
+ setActiveLeafTab(leafId, idx) {
749
+ const n = this.get(leafId);
750
+ if (!n || n.kind !== "leaf") return false;
751
+ if (!Array.isArray(n.tabs) || idx < 0 || idx >= n.tabs.length) return false;
752
+ n.activeTabIdx = idx;
753
+ _syncActiveTab(n);
754
+ return true;
755
+ }
756
+ /** Remove a tab. If the active tab was removed, the previous one
757
+ * becomes active. If the last tab was removed, the leaf's content
758
+ * drops to null (and the WM should treat that as an empty tile —
759
+ * same as before tabs existed). Returns the (possibly clamped)
760
+ * active index after the operation, or -1 on failure. */
761
+ removeLeafTab(leafId, idx) {
762
+ const n = this.get(leafId);
763
+ if (!n || n.kind !== "leaf") return -1;
764
+ if (!Array.isArray(n.tabs) || idx < 0 || idx >= n.tabs.length) return -1;
765
+ n.tabs.splice(idx, 1);
766
+ if (n.tabs.length === 0) {
767
+ n.activeTabIdx = 0;
768
+ _syncActiveTab(n);
769
+ return 0;
770
+ }
771
+ if (idx < n.activeTabIdx) n.activeTabIdx -= 1;
772
+ else if (idx === n.activeTabIdx) n.activeTabIdx = Math.max(0, idx - 1);
773
+ _syncActiveTab(n);
774
+ return n.activeTabIdx;
775
+ }
776
+ /** Bulk-close every tab in a leaf except the one at `keepIdx`.
777
+ * Used by the tab right-click "Close others" action. */
778
+ closeOtherTabs(leafId, keepIdx) {
779
+ const n = this.get(leafId);
780
+ if (!n || n.kind !== "leaf") return false;
781
+ const tabs = n.tabs || [];
782
+ if (keepIdx < 0 || keepIdx >= tabs.length) return false;
783
+ n.tabs = [tabs[keepIdx]];
784
+ n.activeTabIdx = 0;
785
+ _syncActiveTab(n);
786
+ return true;
787
+ }
788
+ /** Close every tab after `idx`. */
789
+ closeTabsAfter(leafId, idx) {
790
+ const n = this.get(leafId);
791
+ if (!n || n.kind !== "leaf") return false;
792
+ const tabs = n.tabs || [];
793
+ if (idx < 0 || idx >= tabs.length - 1) return false;
794
+ n.tabs = tabs.slice(0, idx + 1);
795
+ if (n.activeTabIdx > idx) n.activeTabIdx = idx;
796
+ _syncActiveTab(n);
797
+ return true;
798
+ }
799
+ /** Close every tab before `idx`. */
800
+ closeTabsBefore(leafId, idx) {
801
+ const n = this.get(leafId);
802
+ if (!n || n.kind !== "leaf") return false;
803
+ const tabs = n.tabs || [];
804
+ if (idx <= 0 || idx >= tabs.length) return false;
805
+ n.tabs = tabs.slice(idx);
806
+ n.activeTabIdx = Math.max(0, n.activeTabIdx - idx);
807
+ _syncActiveTab(n);
808
+ return true;
809
+ }
810
+ /** Reorder a tab from `from` to `to` within the same leaf. */
811
+ moveLeafTab(leafId, from, to) {
812
+ const n = this.get(leafId);
813
+ if (!n || n.kind !== "leaf") return false;
814
+ const tabs = n.tabs || [];
815
+ if (from < 0 || from >= tabs.length || to < 0 || to >= tabs.length) return false;
816
+ const moved = tabs.splice(from, 1)[0];
817
+ tabs.splice(to, 0, moved);
818
+ if (n.activeTabIdx === from) n.activeTabIdx = to;
819
+ else if (from < n.activeTabIdx && to >= n.activeTabIdx) n.activeTabIdx -= 1;
820
+ else if (from > n.activeTabIdx && to <= n.activeTabIdx) n.activeTabIdx += 1;
821
+ _syncActiveTab(n);
822
+ return true;
823
+ }
824
+ /**
825
+ * Split a leaf in the given direction; existing content stays in the
826
+ * original leaf, a new empty leaf is added next to it. Returns the
827
+ * new leaf id, or null on failure.
828
+ */
829
+ split(leafId, dir) {
830
+ const leaf = this.get(leafId);
831
+ if (!leaf || leaf.kind !== "leaf") return null;
832
+ const newLeaf = makeLeaf();
833
+ if (!leaf.parentId) {
834
+ const wrap2 = makeSplit({ dir, children: [leaf.id, newLeaf.id] });
835
+ this._register(newLeaf);
836
+ this._register(wrap2);
837
+ this.rootId = wrap2.id;
838
+ leaf.parentId = wrap2.id;
839
+ newLeaf.parentId = wrap2.id;
840
+ this.focus(newLeaf.id);
841
+ return newLeaf.id;
842
+ }
843
+ const parent = this.get(leaf.parentId);
844
+ const idx = parent.children.indexOf(leaf.id);
845
+ if (idx < 0) return null;
846
+ if (parent.dir === dir) {
847
+ parent.children.splice(idx + 1, 0, newLeaf.id);
848
+ const avg = parent.sizes.reduce((a, b) => a + b, 0) / parent.children.length;
849
+ parent.sizes.splice(idx + 1, 0, avg);
850
+ newLeaf.parentId = parent.id;
851
+ this._register(newLeaf);
852
+ this.focus(newLeaf.id);
853
+ return newLeaf.id;
854
+ }
855
+ const wrap = makeSplit({ dir, children: [leaf.id, newLeaf.id] });
856
+ wrap.parentId = parent.id;
857
+ parent.children[idx] = wrap.id;
858
+ leaf.parentId = wrap.id;
859
+ newLeaf.parentId = wrap.id;
860
+ this._register(newLeaf);
861
+ this._register(wrap);
862
+ this.focus(newLeaf.id);
863
+ return newLeaf.id;
864
+ }
865
+ /**
866
+ * Close a leaf, collapsing any single-child splits that result. If
867
+ * the last leaf is closed, leaves an empty tree (no root) — caller
868
+ * is expected to repopulate.
869
+ */
870
+ close(leafId) {
871
+ const leaf = this.get(leafId);
872
+ if (!leaf || leaf.kind !== "leaf") return false;
873
+ if (!leaf.parentId) {
874
+ this.nodes.clear();
875
+ this.rootId = null;
876
+ this.focusedLeafId = null;
877
+ this.focusStack = [];
878
+ return true;
879
+ }
880
+ const parent = this.get(leaf.parentId);
881
+ const idx = parent.children.indexOf(leaf.id);
882
+ parent.children.splice(idx, 1);
883
+ parent.sizes.splice(idx, 1);
884
+ this.nodes.delete(leaf.id);
885
+ this.focusStack = this.focusStack.filter((id) => id !== leaf.id);
886
+ this._collapse(parent);
887
+ if (this.focusedLeafId === leaf.id) {
888
+ const remaining = this.leaves();
889
+ this.focusedLeafId = remaining.length ? remaining[0].id : null;
890
+ if (this.focusedLeafId) this.focus(this.focusedLeafId);
891
+ }
892
+ return true;
893
+ }
894
+ _collapse(node) {
895
+ if (!node || node.kind !== "split") return;
896
+ if (node.children.length > 1) return;
897
+ if (node.children.length === 0) {
898
+ if (!node.parentId) {
899
+ this.nodes.delete(node.id);
900
+ this.rootId = null;
901
+ return;
902
+ }
903
+ const gp = this.get(node.parentId);
904
+ const idx = gp.children.indexOf(node.id);
905
+ gp.children.splice(idx, 1);
906
+ gp.sizes.splice(idx, 1);
907
+ this.nodes.delete(node.id);
908
+ this._collapse(gp);
909
+ return;
910
+ }
911
+ const only = this.get(node.children[0]);
912
+ if (!node.parentId) {
913
+ this.rootId = only.id;
914
+ only.parentId = null;
915
+ } else {
916
+ const gp = this.get(node.parentId);
917
+ const idx = gp.children.indexOf(node.id);
918
+ gp.children[idx] = only.id;
919
+ only.parentId = gp.id;
920
+ }
921
+ this.nodes.delete(node.id);
922
+ }
923
+ // ── Directional focus ──────────────────────────────────────────
924
+ /**
925
+ * Move focus from current leaf in the given direction. Walks up the
926
+ * tree looking for an ancestor split whose direction matches the
927
+ * move (h for left/right, v for up/down) and which has a sibling on
928
+ * the requested side; then descends along the matching edge.
929
+ */
930
+ focusDir(dir) {
931
+ const cur = this.focused();
932
+ if (!cur) return false;
933
+ const wantDir = dir === "left" || dir === "right" ? "h" : "v";
934
+ const forward = dir === "right" || dir === "down";
935
+ let child = cur;
936
+ let parent = child.parentId ? this.get(child.parentId) : null;
937
+ while (parent) {
938
+ if (parent.dir === wantDir) {
939
+ const idx = parent.children.indexOf(child.id);
940
+ const sibIdx = forward ? idx + 1 : idx - 1;
941
+ if (sibIdx >= 0 && sibIdx < parent.children.length) {
942
+ const target = this._descendEdge(
943
+ parent.children[sibIdx],
944
+ wantDir,
945
+ !forward
946
+ );
947
+ if (target) {
948
+ this.focus(target);
949
+ return true;
950
+ }
951
+ }
952
+ }
953
+ child = parent;
954
+ parent = child.parentId ? this.get(child.parentId) : null;
955
+ }
956
+ return false;
957
+ }
958
+ /**
959
+ * Walk down to a leaf. For the matching direction we take the
960
+ * `edge` end (first/last). For the orthogonal direction we just
961
+ * take the first child.
962
+ */
963
+ _descendEdge(nodeId, matchDir, takeFirst) {
964
+ let n = this.get(nodeId);
965
+ while (n && n.kind !== "leaf") {
966
+ const idx = n.dir === matchDir ? takeFirst ? 0 : n.children.length - 1 : 0;
967
+ n = this.get(n.children[idx]);
968
+ }
969
+ return n ? n.id : null;
970
+ }
971
+ // ── Move tile ──────────────────────────────────────────────────
972
+ /**
973
+ * Move the focused leaf one position in the given direction. Simple
974
+ * version: swap with the immediate sibling in the matching parent;
975
+ * if no such sibling, no-op (deep moves come later if needed).
976
+ */
977
+ moveDir(dir) {
978
+ const cur = this.focused();
979
+ if (!cur || !cur.parentId) return false;
980
+ const wantDir = dir === "left" || dir === "right" ? "h" : "v";
981
+ const forward = dir === "right" || dir === "down";
982
+ let child = cur;
983
+ let parent = this.get(child.parentId);
984
+ while (parent && parent.dir !== wantDir) {
985
+ child = parent;
986
+ parent = child.parentId ? this.get(child.parentId) : null;
987
+ }
988
+ if (!parent) return false;
989
+ const idx = parent.children.indexOf(child.id);
990
+ const swap = forward ? idx + 1 : idx - 1;
991
+ if (swap < 0 || swap >= parent.children.length) return false;
992
+ [parent.children[idx], parent.children[swap]] = [parent.children[swap], parent.children[idx]];
993
+ [parent.sizes[idx], parent.sizes[swap]] = [parent.sizes[swap], parent.sizes[idx]];
994
+ return true;
995
+ }
996
+ // ── Resize ──────────────────────────────────────────────────────
997
+ setSplitSizes(splitId, sizes) {
998
+ const n = this.get(splitId);
999
+ if (!n || n.kind !== "split") return false;
1000
+ if (sizes.length !== n.children.length) return false;
1001
+ n.sizes = sizes.slice();
1002
+ return true;
1003
+ }
1004
+ // ── Serialization (for desktops persistence) ──────────────────
1005
+ serialize() {
1006
+ if (!this.rootId) return null;
1007
+ const dump = (id) => {
1008
+ const n = this.get(id);
1009
+ if (n.kind === "leaf") {
1010
+ const liveTabs = (Array.isArray(n.tabs) ? n.tabs : []).filter((t) => !t.transient).map((t) => ({ kind: t.kind, props: t.props, title: t.title }));
1011
+ return {
1012
+ kind: "leaf",
1013
+ title: n.title,
1014
+ content: n.content,
1015
+ tabs: liveTabs,
1016
+ activeTabIdx: Math.max(0, Math.min(liveTabs.length - 1, n.activeTabIdx || 0)),
1017
+ // Page-archive: per-top-nav-kind saved tab lists so
1018
+ // switching to a previously-visited page restores
1019
+ // its tabs across reloads.
1020
+ pageTabs: n.pageTabs ? Object.fromEntries(
1021
+ Object.entries(n.pageTabs).map(([k, v]) => {
1022
+ const pt = (v.tabs || []).filter((t) => !t.transient).map((t) => ({ kind: t.kind, props: t.props, title: t.title }));
1023
+ return [k, {
1024
+ tabs: pt,
1025
+ activeTabIdx: Math.max(0, Math.min(pt.length - 1, v.activeTabIdx || 0))
1026
+ }];
1027
+ })
1028
+ ) : {}
1029
+ };
1030
+ }
1031
+ return {
1032
+ kind: "split",
1033
+ dir: n.dir,
1034
+ sizes: n.sizes.slice(),
1035
+ children: n.children.map(dump)
1036
+ };
1037
+ };
1038
+ return {
1039
+ root: dump(this.rootId),
1040
+ focusedTitle: this.focused()?.title || null
1041
+ };
1042
+ }
1043
+ static deserialize(blob) {
1044
+ const t = new _TileTree();
1045
+ if (!blob || !blob.root) return t;
1046
+ const build = (raw) => {
1047
+ if (raw.kind === "leaf") {
1048
+ const leaf = makeLeaf({ content: raw.content, title: raw.title });
1049
+ if (Array.isArray(raw.tabs) && raw.tabs.length > 0) {
1050
+ leaf.tabs = raw.tabs.map((tab) => ({
1051
+ kind: tab.kind,
1052
+ props: tab.props || {},
1053
+ title: tab.title || tab.kind || ""
1054
+ }));
1055
+ leaf.activeTabIdx = Math.max(
1056
+ 0,
1057
+ Math.min(leaf.tabs.length - 1, raw.activeTabIdx || 0)
1058
+ );
1059
+ _syncActiveTab(leaf);
1060
+ }
1061
+ if (raw.pageTabs && typeof raw.pageTabs === "object") {
1062
+ leaf.pageTabs = {};
1063
+ for (const [k, v] of Object.entries(raw.pageTabs)) {
1064
+ if (!v || !Array.isArray(v.tabs)) continue;
1065
+ leaf.pageTabs[k] = {
1066
+ tabs: v.tabs.map((t2) => ({
1067
+ kind: t2.kind,
1068
+ props: t2.props || {},
1069
+ title: t2.title || t2.kind || ""
1070
+ })),
1071
+ activeTabIdx: v.activeTabIdx || 0
1072
+ };
1073
+ }
1074
+ }
1075
+ return leaf;
1076
+ }
1077
+ const children = raw.children.map(build);
1078
+ const split = makeSplit({
1079
+ dir: raw.dir,
1080
+ children: children.map((c) => c.id),
1081
+ sizes: raw.sizes
1082
+ });
1083
+ children.forEach((c) => t._register(c));
1084
+ return split;
1085
+ };
1086
+ const root = build(blob.root);
1087
+ t.setRoot(root);
1088
+ return t;
1089
+ }
1090
+ };
1091
+
1092
+ // src/tiling/desktops.js
1093
+ var DEFAULT_PANEL_STATE = {
1094
+ left: true,
1095
+ right: true,
1096
+ bottom: true
1097
+ };
1098
+ function _makeDesktop(label, seed) {
1099
+ const tree = new TileTree();
1100
+ tree.setRoot(makeLeaf(seed()));
1101
+ return {
1102
+ id: `desk-${Math.random().toString(36).slice(2, 8)}`,
1103
+ label,
1104
+ tree,
1105
+ windows: [],
1106
+ // boot default: left + right + bottom all open. Names are
1107
+ // assigned by wm._canonicalize via PANEL_TITLES.
1108
+ panels: { ...DEFAULT_PANEL_STATE }
1109
+ };
1110
+ }
1111
+ var DesktopManager = class _DesktopManager {
1112
+ /** @param {{seed: () => object}} opts `seed` builds the root leaf. Required. */
1113
+ constructor({ seed } = {}) {
1114
+ if (typeof seed !== "function") {
1115
+ throw new Error("DesktopManager: a `seed` function is required (the taxonomy root leaf)");
1116
+ }
1117
+ this.seed = seed;
1118
+ this.desktops = [_makeDesktop("1", seed)];
1119
+ this.activeIdx = 0;
1120
+ }
1121
+ active() {
1122
+ return this.desktops[this.activeIdx];
1123
+ }
1124
+ switchTo(idx) {
1125
+ if (idx < 0 || idx >= this.desktops.length) return false;
1126
+ if (idx === this.activeIdx) return false;
1127
+ this.activeIdx = idx;
1128
+ return true;
1129
+ }
1130
+ ensureCount(n) {
1131
+ while (this.desktops.length < n) {
1132
+ this.desktops.push(_makeDesktop(String(this.desktops.length + 1), this.seed));
1133
+ }
1134
+ }
1135
+ addDesktop(label = null) {
1136
+ const d = _makeDesktop(label || String(this.desktops.length + 1), this.seed);
1137
+ this.desktops.push(d);
1138
+ return d;
1139
+ }
1140
+ serialize() {
1141
+ return {
1142
+ activeIdx: this.activeIdx,
1143
+ desktops: this.desktops.map((d) => ({
1144
+ id: d.id,
1145
+ label: d.label,
1146
+ tree: d.tree.serialize(),
1147
+ panels: { ...d.panels }
1148
+ // windows serialized at WM level since we don't own
1149
+ // managed-window state in this module.
1150
+ }))
1151
+ };
1152
+ }
1153
+ static deserialize(blob, { seed } = {}) {
1154
+ const m = new _DesktopManager({ seed });
1155
+ if (!blob || !Array.isArray(blob.desktops) || blob.desktops.length === 0) return m;
1156
+ m.desktops = blob.desktops.map((raw) => ({
1157
+ id: raw.id || `desk-${Math.random().toString(36).slice(2, 8)}`,
1158
+ label: raw.label || "?",
1159
+ tree: raw.tree ? TileTree.deserialize(raw.tree) : new TileTree(),
1160
+ windows: [],
1161
+ panels: { ...DEFAULT_PANEL_STATE, ...raw.panels || {} }
1162
+ }));
1163
+ for (const d of m.desktops) {
1164
+ if (!d.tree.rootId) d.tree.setRoot(makeLeaf(seed()));
1165
+ }
1166
+ m.activeIdx = Math.min(Math.max(0, blob.activeIdx | 0), m.desktops.length - 1);
1167
+ return m;
1168
+ }
1169
+ };
1170
+ var DESKTOPS_KEY = "desktops";
1171
+ async function loadDesktops(state) {
1172
+ if (!state) return null;
1173
+ try {
1174
+ return await state.read(DESKTOPS_KEY);
1175
+ } catch (err) {
1176
+ console.warn("[desktops] load failed", err);
1177
+ return null;
1178
+ }
1179
+ }
1180
+ async function saveDesktops(state, blob) {
1181
+ if (!state) return false;
1182
+ try {
1183
+ await state.write(DESKTOPS_KEY, blob);
1184
+ return true;
1185
+ } catch (err) {
1186
+ console.warn("[desktops] save failed", err);
1187
+ return false;
1188
+ }
1189
+ }
1190
+
1191
+ // src/tiling/entity_sources.js
1192
+ function createEntityCatalog({ sources, aliases = {}, aggregate = null } = {}) {
1193
+ if (!Array.isArray(sources)) {
1194
+ throw new TypeError("createEntityCatalog: `sources` must be an array");
1195
+ }
1196
+ for (const s of sources) {
1197
+ if (!s?.navKind || typeof s.list !== "function" || typeof s.shape !== "function") {
1198
+ throw new TypeError(
1199
+ `createEntityCatalog: source '${s?.navKind ?? "?"}' needs navKind + list() + shape()`
1200
+ );
1201
+ }
1202
+ }
1203
+ if (aggregate != null && typeof aggregate !== "function") {
1204
+ throw new TypeError("createEntityCatalog: `aggregate` must be a function when present");
1205
+ }
1206
+ const _sources = Object.freeze(sources.map((s) => Object.freeze({ ...s })));
1207
+ const _byKind = new Map(_sources.map((s) => [s.navKind, s]));
1208
+ const _aliases = Object.freeze({ ...aliases });
1209
+ const get = (navKind) => _byKind.get(navKind) || null;
1210
+ const _extract = (src, raw) => {
1211
+ const list = src.pluck ? src.pluck(raw) : Array.isArray(raw) ? raw : Array.isArray(raw?.items) ? raw.items : [];
1212
+ return Array.isArray(list) ? list : [];
1213
+ };
1214
+ const shapeRows = (navKind, rows) => {
1215
+ const src = _byKind.get(navKind);
1216
+ if (!src || !Array.isArray(rows)) return [];
1217
+ const out = [];
1218
+ for (const row of rows) {
1219
+ let shaped;
1220
+ try {
1221
+ shaped = src.shape(row);
1222
+ } catch (err) {
1223
+ console.warn("[entity-catalog]", navKind, "shape failed", err, row);
1224
+ continue;
1225
+ }
1226
+ if (!shaped || !shaped.id) continue;
1227
+ out.push(shaped);
1228
+ }
1229
+ return out;
1230
+ };
1231
+ const loadOne = async (navKind, api) => {
1232
+ const src = _byKind.get(navKind);
1233
+ if (!src || !api) return [];
1234
+ try {
1235
+ return _extract(src, await src.list(api));
1236
+ } catch (err) {
1237
+ console.warn("[entity-catalog]", navKind, "fetch failed", err);
1238
+ return [];
1239
+ }
1240
+ };
1241
+ const loadGrouped = async (api) => {
1242
+ const out = {};
1243
+ for (const s of _sources) out[s.navKind] = [];
1244
+ if (!api) return out;
1245
+ let bulk = null;
1246
+ if (aggregate) {
1247
+ try {
1248
+ bulk = await aggregate(api);
1249
+ } catch (err) {
1250
+ console.warn("[entity-catalog] aggregate failed, falling back to fan-out", err);
1251
+ }
1252
+ if (bulk && typeof bulk !== "object") bulk = null;
1253
+ }
1254
+ const missing = [];
1255
+ for (const src of _sources) {
1256
+ const raw = bulk ? bulk[src.navKind] : void 0;
1257
+ if (raw == null) {
1258
+ missing.push(src);
1259
+ continue;
1260
+ }
1261
+ out[src.navKind] = _extract(src, raw);
1262
+ }
1263
+ if (missing.length === 0) return out;
1264
+ await Promise.all(missing.map(async (src) => {
1265
+ out[src.navKind] = await loadOne(src.navKind, api);
1266
+ }));
1267
+ return out;
1268
+ };
1269
+ const loadAll = async (api) => {
1270
+ const grouped = await loadGrouped(api);
1271
+ const out = [];
1272
+ for (const src of _sources) {
1273
+ for (const shaped of shapeRows(src.navKind, grouped[src.navKind] || [])) {
1274
+ out.push({ kind: src.navKind, ...shaped });
1275
+ }
1276
+ }
1277
+ return out;
1278
+ };
1279
+ const resolveTypePrefix = (prefix) => {
1280
+ const p = String(prefix || "").toLowerCase().trim();
1281
+ if (!p) return null;
1282
+ if (_aliases[p]) return _aliases[p];
1283
+ for (const src of _sources) {
1284
+ if (src.navKind === p) return [src.navKind];
1285
+ if (src.navKind.replace(/-/g, "_") === p) return [src.navKind];
1286
+ }
1287
+ return null;
1288
+ };
1289
+ return Object.freeze({
1290
+ sources: _sources,
1291
+ get,
1292
+ loadOne,
1293
+ loadGrouped,
1294
+ loadAll,
1295
+ shapeRows,
1296
+ resolveTypePrefix
1297
+ });
1298
+ }
1299
+
1300
+ // src/tiling/keymap.js
1301
+ function installKeymap({ wm, palette }) {
1302
+ document.addEventListener("keydown", (e) => {
1303
+ const inField = e.target?.closest?.(
1304
+ 'input, textarea, select, [contenteditable="true"]'
1305
+ );
1306
+ if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k" && !e.shiftKey) {
1307
+ e.preventDefault();
1308
+ palette.toggle();
1309
+ return;
1310
+ }
1311
+ if ((e.ctrlKey || e.metaKey) && !e.altKey && e.key === "Tab") {
1312
+ e.preventDefault();
1313
+ wm.cycleFocusedTab(e.shiftKey ? -1 : 1);
1314
+ return;
1315
+ }
1316
+ if (e.key === "Escape") {
1317
+ if (palette.isOpen()) {
1318
+ palette.close();
1319
+ return;
1320
+ }
1321
+ }
1322
+ if (e.key === "Backspace" && !inField && !e.metaKey && !e.ctrlKey) {
1323
+ e.preventDefault();
1324
+ wm.navigateBack?.();
1325
+ return;
1326
+ }
1327
+ const fMatch = /^F([1-9]|1[0-2])$/.exec(e.key);
1328
+ if (fMatch && !inField && !e.altKey && !e.ctrlKey && !e.metaKey && !e.shiftKey) {
1329
+ const btns = document.querySelectorAll(
1330
+ ".twm-global-top-bar .twm-bar-center.twm-top-nav .twm-top-nav__btn"
1331
+ );
1332
+ const idx = Number(fMatch[1]) - 1;
1333
+ if (idx < btns.length) {
1334
+ e.preventDefault();
1335
+ btns[idx].click();
1336
+ return;
1337
+ }
1338
+ }
1339
+ if (e.shiftKey && !e.altKey && !e.ctrlKey && !e.metaKey && !inField && (e.key === "ArrowLeft" || e.key === "ArrowRight")) {
1340
+ const tabs = [...document.querySelectorAll(".twm-bp__tabs .twm-bp__tab")];
1341
+ if (tabs.length) {
1342
+ const cur = tabs.findIndex((t) => t.classList.contains("twm-bp__tab--on"));
1343
+ const dir = e.key === "ArrowRight" ? 1 : -1;
1344
+ const next = ((cur < 0 ? 0 : cur) + dir + tabs.length) % tabs.length;
1345
+ e.preventDefault();
1346
+ tabs[next].click();
1347
+ return;
1348
+ }
1349
+ }
1350
+ if (e.ctrlKey && !e.altKey && !e.shiftKey && !e.metaKey && !inField && (e.key === "ArrowLeft" || e.key === "ArrowRight")) {
1351
+ e.preventDefault();
1352
+ wm.cycleDesktop(e.key === "ArrowRight" ? 1 : -1);
1353
+ return;
1354
+ }
1355
+ if (!e.altKey) return;
1356
+ const key = e.key.toLowerCase();
1357
+ if (/^[1-9]$/.test(e.key)) {
1358
+ e.preventDefault();
1359
+ const idx = Number(e.key) - 1;
1360
+ if (e.shiftKey) wm.moveFocusedToDesktop(idx);
1361
+ else wm.switchDesktop(idx);
1362
+ return;
1363
+ }
1364
+ switch (key) {
1365
+ // Plain Alt+H/V split into an empty pane; with Shift they open
1366
+ // the focused tile's own content in the new split.
1367
+ case "h":
1368
+ e.preventDefault();
1369
+ e.shiftKey ? wm.splitFocusedWith("h") : wm.split("h");
1370
+ return;
1371
+ case "v":
1372
+ e.preventDefault();
1373
+ e.shiftKey ? wm.splitFocusedWith("v") : wm.split("v");
1374
+ return;
1375
+ case "t":
1376
+ e.preventDefault();
1377
+ wm.openFocusedInTab();
1378
+ return;
1379
+ case "n":
1380
+ e.preventDefault();
1381
+ wm.openFocusedInWindow();
1382
+ return;
1383
+ case "w":
1384
+ e.preventDefault();
1385
+ wm.closeFocused();
1386
+ return;
1387
+ case "f":
1388
+ e.preventDefault();
1389
+ wm.toggleManagedFocused();
1390
+ return;
1391
+ case "arrowleft":
1392
+ e.preventDefault();
1393
+ e.shiftKey ? wm.moveFocused("left") : wm.focusDir("left");
1394
+ return;
1395
+ case "arrowright":
1396
+ e.preventDefault();
1397
+ e.shiftKey ? wm.moveFocused("right") : wm.focusDir("right");
1398
+ return;
1399
+ case "arrowup":
1400
+ e.preventDefault();
1401
+ e.shiftKey ? wm.moveFocused("up") : wm.focusDir("up");
1402
+ return;
1403
+ case "arrowdown":
1404
+ e.preventDefault();
1405
+ e.shiftKey ? wm.moveFocused("down") : wm.focusDir("down");
1406
+ return;
1407
+ }
1408
+ if (key.length === 1 && /[a-z]/.test(key) && !inField) {
1409
+ e.preventDefault();
1410
+ }
1411
+ });
1412
+ }
1413
+
1414
+ // src/tiling/kind_taxonomy.js
1415
+ function createTaxonomy({ kinds, root } = {}) {
1416
+ if (!kinds || typeof kinds !== "object") {
1417
+ throw new TypeError("createTaxonomy: `kinds` must be an object of KindDefs");
1418
+ }
1419
+ if (!root || !kinds[root]) {
1420
+ throw new Error(`createTaxonomy: root kind '${root}' is not in the taxonomy`);
1421
+ }
1422
+ for (const [kind, def] of Object.entries(kinds)) {
1423
+ if (!def || typeof def !== "object") {
1424
+ throw new TypeError(`createTaxonomy: '${kind}' is not a KindDef`);
1425
+ }
1426
+ if (def.topNav && !kinds[def.topNav]) {
1427
+ throw new Error(
1428
+ `createTaxonomy: '${kind}' hangs off unknown topNav '${def.topNav}'`
1429
+ );
1430
+ }
1431
+ if (def.isTopNav && def.topNav) {
1432
+ throw new Error(
1433
+ `createTaxonomy: '${kind}' is both a top-nav kind and owned by one`
1434
+ );
1435
+ }
1436
+ }
1437
+ const _kinds = Object.freeze({ ...kinds });
1438
+ const meta = (kind) => _kinds[kind];
1439
+ const topNavFor = (kind) => {
1440
+ const m = _kinds[kind];
1441
+ if (!m) return void 0;
1442
+ return m.isTopNav ? kind : m.topNav;
1443
+ };
1444
+ const parentKindFor = (kind) => {
1445
+ if (kind === root) return null;
1446
+ const m = _kinds[kind];
1447
+ if (!m) return null;
1448
+ if (m.isTopNav) return root;
1449
+ return m.topNav || root;
1450
+ };
1451
+ const ancestors = (kind, props) => {
1452
+ const fn = _kinds[kind]?.ancestors;
1453
+ if (typeof fn !== "function") return [];
1454
+ let out;
1455
+ try {
1456
+ out = fn(props || {});
1457
+ } catch (err) {
1458
+ console.warn("[taxonomy] ancestors() threw for", kind, err);
1459
+ return [];
1460
+ }
1461
+ if (!Array.isArray(out)) return [];
1462
+ return out.filter((a) => a && a.kind).map((a) => ({ kind: a.kind, props: { id: a.id, label: a.label ?? a.id } }));
1463
+ };
1464
+ const parentOf = (kind, props) => {
1465
+ const chain = ancestors(kind, props);
1466
+ return chain.length ? chain[chain.length - 1] : null;
1467
+ };
1468
+ const labelOf = (kind, props) => {
1469
+ const fn = _kinds[kind]?.labelOf;
1470
+ if (typeof fn === "function") {
1471
+ try {
1472
+ const s = fn(props || {});
1473
+ if (s) return String(s);
1474
+ } catch (err) {
1475
+ console.warn("[taxonomy] labelOf() threw for", kind, err);
1476
+ }
1477
+ }
1478
+ if (props?.label) return String(props.label);
1479
+ return props?.id != null ? String(props.id) : "";
1480
+ };
1481
+ const sourcesFor = (kind) => {
1482
+ const s = _kinds[kind]?.sources ?? _kinds[root]?.sources;
1483
+ return Array.isArray(s) ? s : [];
1484
+ };
1485
+ const topNavEntries = () => Object.entries(_kinds).filter(([, m]) => m.isTopNav).sort((a, b) => (a[1].order ?? 1e3) - (b[1].order ?? 1e3)).map(([kind, m]) => ({
1486
+ kind,
1487
+ label: m.shortLabel || m.label,
1488
+ longLabel: m.label,
1489
+ icon: m.icon
1490
+ }));
1491
+ return Object.freeze({
1492
+ root,
1493
+ kinds: _kinds,
1494
+ meta,
1495
+ topNavFor,
1496
+ parentKindFor,
1497
+ ancestors,
1498
+ parentOf,
1499
+ labelOf,
1500
+ sourcesFor,
1501
+ topNavEntries
1502
+ });
1503
+ }
1504
+
1505
+ // src/tiling/panel_keys.js
1506
+ var _router = null;
1507
+ function installPanelKeyRouter(wm) {
1508
+ if (_router) _router.destroy();
1509
+ _router = new PanelKeyRouter(wm);
1510
+ return _router;
1511
+ }
1512
+ function getPanelKeyRouter() {
1513
+ return _router;
1514
+ }
1515
+ function uninstallPanelKeyRouter() {
1516
+ _router?.destroy();
1517
+ _router = null;
1518
+ }
1519
+ function registerPanelKeys(hostEl, handler) {
1520
+ if (!hostEl || typeof handler !== "function") return () => {
1521
+ };
1522
+ if (_router) return _router.register(hostEl, handler);
1523
+ return _legacyRegister(hostEl, handler);
1524
+ }
1525
+ var PanelKeyRouter = class {
1526
+ constructor(wm) {
1527
+ this._wm = wm;
1528
+ this._entries = [];
1529
+ this._onKey = this._dispatch.bind(this);
1530
+ document.addEventListener("keydown", this._onKey);
1531
+ }
1532
+ register(hostEl, handler) {
1533
+ const entry = { hostEl, handler };
1534
+ this._entries.push(entry);
1535
+ return () => {
1536
+ const i = this._entries.indexOf(entry);
1537
+ if (i >= 0) this._entries.splice(i, 1);
1538
+ };
1539
+ }
1540
+ destroy() {
1541
+ document.removeEventListener("keydown", this._onKey);
1542
+ this._entries.length = 0;
1543
+ }
1544
+ /** Is `hostEl` the panel that should receive keys right now? */
1545
+ _isActiveTarget(hostEl) {
1546
+ if (!hostEl?.isConnected) return false;
1547
+ const leaf = hostEl.closest?.(".twm-leaf");
1548
+ if (leaf) {
1549
+ return leaf === this._wm?.focusedLeafEl?.();
1550
+ }
1551
+ return hostEl.contains(document.activeElement) || document.activeElement === document.body;
1552
+ }
1553
+ _dispatch(ev) {
1554
+ for (const entry of [...this._entries]) {
1555
+ if (!this._isActiveTarget(entry.hostEl)) continue;
1556
+ try {
1557
+ entry.handler(ev);
1558
+ } catch (err) {
1559
+ console.warn("[panel-keys] handler threw", err);
1560
+ }
1561
+ if (ev.defaultPrevented) return;
1562
+ }
1563
+ }
1564
+ };
1565
+ function _legacyRegister(hostEl, handler) {
1566
+ const wrapped = (ev) => {
1567
+ if (!hostEl.isConnected) return;
1568
+ if (!hostEl.contains(document.activeElement) && document.activeElement !== document.body) return;
1569
+ handler(ev);
1570
+ };
1571
+ document.addEventListener("keydown", wrapped);
1572
+ return () => document.removeEventListener("keydown", wrapped);
1573
+ }
1574
+
1575
+ // src/tiling/landing_table.js
1576
+ function attachLandingTableBehavior(host, getRow, entityActions, options = {}) {
1577
+ if (!host) return () => {
1578
+ };
1579
+ const dispatch = (action, row) => {
1580
+ if (!row) return;
1581
+ const fn = action === "open-tab" ? entityActions.openInTab : action === "open-window" ? entityActions.openInWindow : entityActions[action];
1582
+ try {
1583
+ fn?.(row);
1584
+ } catch (e) {
1585
+ console.warn(`[landing] action "${action}" failed`, e);
1586
+ }
1587
+ };
1588
+ const bodyRowFor = (ev) => {
1589
+ const tr = ev.target.closest("tr");
1590
+ if (!tr || !tr.closest("tbody")) return null;
1591
+ const wrap = tr.closest(".twm-preview-table-wrap");
1592
+ if (!wrap || !host.contains(wrap)) return null;
1593
+ return tr;
1594
+ };
1595
+ const onClick = (ev) => {
1596
+ const actionBtn = ev.target.closest("[data-twm-action]");
1597
+ if (actionBtn && host.contains(actionBtn)) {
1598
+ ev.preventDefault();
1599
+ ev.stopPropagation();
1600
+ ev.stopImmediatePropagation();
1601
+ const tr2 = actionBtn.closest("tr");
1602
+ dispatch(actionBtn.dataset.twmAction, tr2 ? getRow(tr2.__rowIndex) : null);
1603
+ return;
1604
+ }
1605
+ if (ev.target.closest("button, a, input, select, textarea")) return;
1606
+ const tr = bodyRowFor(ev);
1607
+ if (tr) dispatch("open", getRow(tr.__rowIndex));
1608
+ };
1609
+ const defaultMenu = (row) => {
1610
+ const items = [];
1611
+ if (entityActions.open) {
1612
+ items.push({ label: "Open", icon: "open_in_new", action: "open" });
1613
+ if (entityActions.openInTab) {
1614
+ items.push({ label: "Open in new tab", icon: "tab", action: "open-tab" });
1615
+ }
1616
+ if (entityActions.openInWindow) {
1617
+ items.push({
1618
+ label: "Open in new window",
1619
+ icon: "open_in_full",
1620
+ action: "open-window"
1621
+ });
1622
+ }
1623
+ }
1624
+ if (entityActions.edit) items.push({ label: "Rename / edit", icon: "edit", action: "edit" });
1625
+ if (entityActions.delete) {
1626
+ if (items.length) items.push({ separator: true });
1627
+ items.push({ label: "Delete", icon: "delete", action: "delete", danger: true });
1628
+ }
1629
+ return items;
1630
+ };
1631
+ const buildMenu = typeof options.buildMenu === "function" ? options.buildMenu : defaultMenu;
1632
+ const onContextMenu = (ev) => {
1633
+ const tr = bodyRowFor(ev);
1634
+ if (!tr) return;
1635
+ ev.preventDefault();
1636
+ ev.stopPropagation();
1637
+ const row = getRow(tr.__rowIndex);
1638
+ if (!row) return;
1639
+ const items = buildMenu(row) || [];
1640
+ if (items.length === 0) return;
1641
+ showContextMenu(ev.clientX, ev.clientY, items, (action) => dispatch(action, row));
1642
+ };
1643
+ host.addEventListener("click", onClick);
1644
+ host.addEventListener("contextmenu", onContextMenu, true);
1645
+ return () => {
1646
+ host.removeEventListener("click", onClick);
1647
+ host.removeEventListener("contextmenu", onContextMenu, true);
1648
+ };
1649
+ }
1650
+ function wireLandingPaneFocus(hostEl) {
1651
+ const panes = Array.from(hostEl.querySelectorAll(".twm-landing__pane"));
1652
+ if (panes.length === 0) return { focus: () => {
1653
+ }, panes };
1654
+ let focused = panes[0];
1655
+ const reflect = () => {
1656
+ for (const p of panes) {
1657
+ p.classList.toggle("twm-landing__pane--focused", p === focused);
1658
+ }
1659
+ };
1660
+ reflect();
1661
+ for (const p of panes) {
1662
+ p.addEventListener("mousedown", () => {
1663
+ if (focused === p) return;
1664
+ focused = p;
1665
+ reflect();
1666
+ });
1667
+ }
1668
+ return {
1669
+ focus: (pane) => {
1670
+ if (pane && panes.includes(pane)) {
1671
+ focused = pane;
1672
+ reflect();
1673
+ }
1674
+ },
1675
+ panes
1676
+ };
1677
+ }
1678
+ function attachLandingKeyboardNav(host, getRows, onActivate, options = {}) {
1679
+ if (!host) return { teardown: () => {
1680
+ }, refresh: () => {
1681
+ }, focusFirst: () => {
1682
+ } };
1683
+ let cursor = 0;
1684
+ const tbody = () => host.querySelector("tbody");
1685
+ const repaintCursor = () => {
1686
+ const tb = tbody();
1687
+ if (!tb) return;
1688
+ const rows = getRows() || [];
1689
+ if (rows.length === 0) {
1690
+ cursor = 0;
1691
+ return;
1692
+ }
1693
+ if (cursor >= rows.length) cursor = rows.length - 1;
1694
+ if (cursor < 0) cursor = 0;
1695
+ let active = null;
1696
+ tb.querySelectorAll("tr").forEach((tr) => {
1697
+ const on = tr.__rowIndex === cursor;
1698
+ tr.classList.toggle("twm-row-cursor", on);
1699
+ if (on) active = tr;
1700
+ });
1701
+ if (active) active.scrollIntoView({ block: "nearest" });
1702
+ };
1703
+ const move = (delta) => {
1704
+ const rows = getRows() || [];
1705
+ if (rows.length === 0) return;
1706
+ cursor = Math.max(0, Math.min(rows.length - 1, cursor + delta));
1707
+ repaintCursor();
1708
+ };
1709
+ const open = () => {
1710
+ const rows = getRows() || [];
1711
+ const row = rows[cursor];
1712
+ if (row) onActivate(row);
1713
+ };
1714
+ const onKey = (ev) => {
1715
+ if (!host.isConnected) return;
1716
+ const t = ev.target;
1717
+ const editable = t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.tagName === "SELECT" || t.isContentEditable);
1718
+ if (editable) return;
1719
+ if (ev.key === "ArrowDown") {
1720
+ ev.preventDefault();
1721
+ move(1);
1722
+ } else if (ev.key === "ArrowUp") {
1723
+ ev.preventDefault();
1724
+ move(-1);
1725
+ } else if (ev.key === "Home") {
1726
+ ev.preventDefault();
1727
+ cursor = 0;
1728
+ repaintCursor();
1729
+ } else if (ev.key === "End") {
1730
+ ev.preventDefault();
1731
+ const rows = getRows() || [];
1732
+ cursor = Math.max(0, rows.length - 1);
1733
+ repaintCursor();
1734
+ } else if (ev.key === "Enter") {
1735
+ ev.preventDefault();
1736
+ open();
1737
+ } else if (options.activateKey && ev.key === options.activateKey && !ev.metaKey && !ev.ctrlKey && !ev.altKey) {
1738
+ ev.preventDefault();
1739
+ open();
1740
+ }
1741
+ };
1742
+ const offKey = registerPanelKeys(host, onKey);
1743
+ setTimeout(repaintCursor, 0);
1744
+ return {
1745
+ teardown: () => offKey(),
1746
+ refresh: () => repaintCursor(),
1747
+ focusFirst: () => {
1748
+ cursor = 0;
1749
+ repaintCursor();
1750
+ }
1751
+ };
1752
+ }
1753
+ function attachLandingShell(hostEl, { panes, initialPane = 0 } = {}) {
1754
+ if (!hostEl || !Array.isArray(panes) || panes.length === 0) {
1755
+ return { teardown: () => {
1756
+ }, refresh: () => {
1757
+ }, focusPane: () => {
1758
+ } };
1759
+ }
1760
+ const state = panes.map((spec) => ({
1761
+ spec,
1762
+ paneEl: spec.paneEl,
1763
+ tableHost: spec.tableHost || spec.paneEl?.querySelector(".twm-landing__table") || spec.paneEl,
1764
+ cursor: 0
1765
+ }));
1766
+ let focusedIdx = Math.max(0, Math.min(panes.length - 1, initialPane));
1767
+ const reflectPaneFocus = () => {
1768
+ state.forEach((s, i) => {
1769
+ s.paneEl?.classList.toggle(
1770
+ "twm-landing__pane--focused",
1771
+ i === focusedIdx
1772
+ );
1773
+ });
1774
+ };
1775
+ const repaintCursor = (idx = focusedIdx) => {
1776
+ const s = state[idx];
1777
+ if (!s) return;
1778
+ const tbody = s.tableHost?.querySelector("tbody");
1779
+ if (!tbody) return;
1780
+ const rows = s.spec.getRows?.() || [];
1781
+ if (rows.length === 0) {
1782
+ s.cursor = 0;
1783
+ return;
1784
+ }
1785
+ if (s.cursor >= rows.length) s.cursor = rows.length - 1;
1786
+ if (s.cursor < 0) s.cursor = 0;
1787
+ let active = null;
1788
+ tbody.querySelectorAll("tr").forEach((tr) => {
1789
+ const on = tr.__rowIndex === s.cursor;
1790
+ tr.classList.toggle("twm-row-cursor", on);
1791
+ if (on) active = tr;
1792
+ });
1793
+ if (active && idx === focusedIdx) {
1794
+ active.scrollIntoView({ block: "nearest" });
1795
+ }
1796
+ };
1797
+ const move = (delta) => {
1798
+ const s = state[focusedIdx];
1799
+ if (!s) return;
1800
+ const rows = s.spec.getRows?.() || [];
1801
+ if (rows.length === 0) return;
1802
+ s.cursor = Math.max(0, Math.min(rows.length - 1, s.cursor + delta));
1803
+ repaintCursor();
1804
+ };
1805
+ const jump = (toEnd) => {
1806
+ const s = state[focusedIdx];
1807
+ if (!s) return;
1808
+ const rows = s.spec.getRows?.() || [];
1809
+ s.cursor = toEnd ? Math.max(0, rows.length - 1) : 0;
1810
+ repaintCursor();
1811
+ };
1812
+ const currentRow = () => {
1813
+ const s = state[focusedIdx];
1814
+ if (!s) return null;
1815
+ const rows = s.spec.getRows?.() || [];
1816
+ return rows[s.cursor] ?? null;
1817
+ };
1818
+ const activate = () => {
1819
+ const row = currentRow();
1820
+ if (!row) return;
1821
+ try {
1822
+ state[focusedIdx].spec.onActivate?.(row);
1823
+ } catch (err) {
1824
+ console.warn("[landing-shell] activate failed", err);
1825
+ }
1826
+ };
1827
+ const activateInTab = () => {
1828
+ const row = currentRow();
1829
+ if (!row) return;
1830
+ const spec = state[focusedIdx].spec;
1831
+ const fn = spec.onActivateInTab || spec.onActivate;
1832
+ try {
1833
+ fn?.(row);
1834
+ } catch (err) {
1835
+ console.warn("[landing-shell] activate-tab failed", err);
1836
+ }
1837
+ };
1838
+ const activateInWindow = () => {
1839
+ const row = currentRow();
1840
+ if (!row) return;
1841
+ const spec = state[focusedIdx].spec;
1842
+ const fn = spec.onActivateInWindow || spec.onActivate;
1843
+ try {
1844
+ fn?.(row);
1845
+ } catch (err) {
1846
+ console.warn("[landing-shell] activate-window failed", err);
1847
+ }
1848
+ };
1849
+ const expandToggle = (expand) => {
1850
+ const s = state[focusedIdx];
1851
+ if (!s?.spec.onExpandToggle) return false;
1852
+ const row = currentRow();
1853
+ if (!row) return false;
1854
+ if (s.spec.isExpandable && !s.spec.isExpandable(row)) return false;
1855
+ const currentlyExpanded = s.spec.isExpanded ? !!s.spec.isExpanded(row) : false;
1856
+ if (expand === currentlyExpanded) return false;
1857
+ try {
1858
+ s.spec.onExpandToggle(row, expand);
1859
+ } catch (err) {
1860
+ console.warn("[landing-shell] expand failed", err);
1861
+ }
1862
+ return true;
1863
+ };
1864
+ const focusPane = (idx) => {
1865
+ if (idx < 0 || idx >= state.length) return;
1866
+ if (idx === focusedIdx) return;
1867
+ focusedIdx = idx;
1868
+ reflectPaneFocus();
1869
+ repaintCursor();
1870
+ };
1871
+ const cyclePane = (dir) => {
1872
+ if (state.length < 2) return;
1873
+ focusPane((focusedIdx + dir + state.length) % state.length);
1874
+ };
1875
+ const onMouseDown = (ev) => {
1876
+ const paneEl = ev.target.closest?.(".twm-landing__pane");
1877
+ if (!paneEl) return;
1878
+ const idx = state.findIndex((s) => s.paneEl === paneEl);
1879
+ if (idx < 0 || idx === focusedIdx) return;
1880
+ focusedIdx = idx;
1881
+ reflectPaneFocus();
1882
+ repaintCursor();
1883
+ };
1884
+ const onKey = (ev) => {
1885
+ if (!hostEl.isConnected) return;
1886
+ const t = ev.target;
1887
+ const editable = t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.tagName === "SELECT" || t.isContentEditable);
1888
+ if (editable) return;
1889
+ if (ev.key === "Enter" && (ev.metaKey || ev.ctrlKey) && !ev.altKey) {
1890
+ ev.preventDefault();
1891
+ activateInTab();
1892
+ return;
1893
+ }
1894
+ if (ev.key === "Enter" && ev.shiftKey && !ev.altKey && !ev.metaKey && !ev.ctrlKey) {
1895
+ ev.preventDefault();
1896
+ activateInWindow();
1897
+ return;
1898
+ }
1899
+ if (ev.metaKey || ev.ctrlKey || ev.altKey || ev.shiftKey) return;
1900
+ switch (ev.key) {
1901
+ case "ArrowDown":
1902
+ ev.preventDefault();
1903
+ move(1);
1904
+ return;
1905
+ case "ArrowUp":
1906
+ ev.preventDefault();
1907
+ move(-1);
1908
+ return;
1909
+ case "Home":
1910
+ ev.preventDefault();
1911
+ jump(false);
1912
+ return;
1913
+ case "End":
1914
+ ev.preventDefault();
1915
+ jump(true);
1916
+ return;
1917
+ case "Enter":
1918
+ ev.preventDefault();
1919
+ activate();
1920
+ return;
1921
+ case "Tab":
1922
+ ev.preventDefault();
1923
+ cyclePane(ev.shiftKey ? -1 : 1);
1924
+ return;
1925
+ case "ArrowRight":
1926
+ if (expandToggle(true)) ev.preventDefault();
1927
+ return;
1928
+ case "ArrowLeft":
1929
+ if (expandToggle(false)) ev.preventDefault();
1930
+ return;
1931
+ }
1932
+ };
1933
+ hostEl.addEventListener("mousedown", onMouseDown);
1934
+ const offKey = registerPanelKeys(hostEl, onKey);
1935
+ reflectPaneFocus();
1936
+ setTimeout(() => state.forEach((_, i) => repaintCursor(i)), 0);
1937
+ const parkFocusIfActive = () => {
1938
+ const leaf = hostEl.closest?.(".twm-leaf");
1939
+ const inTile = !!leaf;
1940
+ if (inTile && !leaf.classList.contains("twm-leaf--focused")) return;
1941
+ if (hostEl.contains(document.activeElement)) return;
1942
+ if (hostEl.tabIndex == null || hostEl.tabIndex < -1) hostEl.tabIndex = -1;
1943
+ try {
1944
+ hostEl.focus({ preventScroll: true });
1945
+ } catch {
1946
+ }
1947
+ };
1948
+ parkFocusIfActive();
1949
+ requestAnimationFrame(parkFocusIfActive);
1950
+ return {
1951
+ teardown: () => {
1952
+ hostEl.removeEventListener("mousedown", onMouseDown);
1953
+ offKey();
1954
+ },
1955
+ refresh: () => state.forEach((_, i) => repaintCursor(i)),
1956
+ focusPane,
1957
+ currentRow
1958
+ };
1959
+ }
1960
+ function mountLandingShell(hostEl, opts) {
1961
+ const {
1962
+ title = "",
1963
+ stats = [],
1964
+ pane = { key: "rows", title: "", hint: "" }
1965
+ } = opts || {};
1966
+ const paneKey = String(pane.key || "rows");
1967
+ const _esc8 = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({
1968
+ "&": "&amp;",
1969
+ "<": "&lt;",
1970
+ ">": "&gt;",
1971
+ '"': "&quot;",
1972
+ "'": "&#39;"
1973
+ })[c]);
1974
+ const statsHtml = (stats || []).map((s) => `
1975
+ <span class="twm-landing__stat twm-entity-header__field">
1976
+ <span>${_esc8(s.label || "")}</span>
1977
+ <strong data-role="${_esc8(s.key)}">${_esc8(s.initial != null ? s.initial : 0)}</strong>
1978
+ </span>
1979
+ `).join("");
1980
+ hostEl.classList.add("twm-landing");
1981
+ hostEl.innerHTML = `
1982
+ <header class="twm-entity-header">
1983
+ <h2>${_esc8(title)}</h2>
1984
+ ${statsHtml}
1985
+ <span class="twm-entity-header__spacer"></span>
1986
+ </header>
1987
+ <div class="twm-landing__split twm-landing__split--single">
1988
+ <section class="twm-landing__pane" data-pane="${_esc8(paneKey)}">
1989
+ <div class="twm-landing__pane-head">
1990
+ <span class="twm-landing__pane-title">${_esc8(pane.title || "")}</span>
1991
+ <span class="twm-landing__pane-hint">${_esc8(pane.hint || "")}</span>
1992
+ </div>
1993
+ <div class="twm-landing__table" data-role="${_esc8(paneKey)}-host"></div>
1994
+ </section>
1995
+ </div>
1996
+ <footer class="twm-landing__actions" data-role="actions"></footer>
1997
+ `;
1998
+ const headerEl = hostEl.querySelector(".twm-entity-header");
1999
+ const paneEl = hostEl.querySelector(`[data-pane="${paneKey}"]`);
2000
+ const tableHost = hostEl.querySelector(`[data-role="${paneKey}-host"]`);
2001
+ const footerEl = hostEl.querySelector('[data-role="actions"]');
2002
+ const setStat = (key, value) => {
2003
+ const el = hostEl.querySelector(`[data-role="${key}"]`);
2004
+ if (el) el.textContent = String(value);
2005
+ };
2006
+ return { headerEl, paneEl, tableHost, footerEl, setStat };
2007
+ }
2008
+ function mountLandingActions(footerEl, actions, hostEl) {
2009
+ if (!footerEl) return { teardown: () => {
2010
+ }, refresh: () => {
2011
+ } };
2012
+ let current = [];
2013
+ const renderInto = (list) => {
2014
+ current = (list || []).filter(Boolean);
2015
+ footerEl.innerHTML = "";
2016
+ for (const a of current) {
2017
+ const btn = document.createElement("button");
2018
+ btn.type = "button";
2019
+ btn.className = "twm-action-btn";
2020
+ if (a.id) btn.dataset.action = a.id;
2021
+ if (a.title) btn.title = a.title;
2022
+ const icon = a.icon ? `<span class="material-symbols-outlined">${_esc3(a.icon)}</span>` : "";
2023
+ const sc = a.shortcut ? ` <span class="twm-action-btn__kbd">[${_esc3(a.shortcut.toUpperCase())}]</span>` : "";
2024
+ btn.innerHTML = `${icon}<span>${_esc3(a.label || "")}</span>${sc}`;
2025
+ btn.addEventListener("click", (ev) => {
2026
+ ev.preventDefault();
2027
+ try {
2028
+ a.onClick?.(ev);
2029
+ } catch (err) {
2030
+ console.warn("[landing-actions] click failed", err);
2031
+ }
2032
+ });
2033
+ footerEl.appendChild(btn);
2034
+ }
2035
+ };
2036
+ const onKey = (ev) => {
2037
+ if (!footerEl.isConnected) return;
2038
+ if (ev.metaKey || ev.ctrlKey || ev.altKey) return;
2039
+ const t = ev.target;
2040
+ const editable = t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.tagName === "SELECT" || t.isContentEditable);
2041
+ if (editable) return;
2042
+ const k = ev.key.length === 1 ? ev.key.toLowerCase() : ev.key;
2043
+ const hit = current.find((a) => a.shortcut && String(a.shortcut).toLowerCase() === k);
2044
+ if (!hit) return;
2045
+ ev.preventDefault();
2046
+ try {
2047
+ hit.onClick?.(ev);
2048
+ } catch (err) {
2049
+ console.warn("[landing-actions] shortcut failed", err);
2050
+ }
2051
+ };
2052
+ renderInto(actions);
2053
+ const scopeEl = hostEl || footerEl.closest(".twm-landing") || footerEl;
2054
+ const offKey = registerPanelKeys(scopeEl, onKey);
2055
+ return {
2056
+ teardown: () => offKey(),
2057
+ refresh: renderInto
2058
+ };
2059
+ }
2060
+ function actionsCellRenderer(actionsColIdx, options = { edit: true, delete: true }) {
2061
+ return (td, _value, colIdx) => {
2062
+ if (colIdx !== actionsColIdx) return false;
2063
+ const parts = [];
2064
+ if (options.edit) {
2065
+ parts.push(`<button type="button" class="twm-row-action"
2066
+ data-twm-action="edit"
2067
+ title="Edit"
2068
+ aria-label="Edit">
2069
+ <span class="material-symbols-outlined">edit</span>
2070
+ </button>`);
2071
+ }
2072
+ if (options.delete) {
2073
+ parts.push(`<button type="button" class="twm-row-action twm-row-action--danger"
2074
+ data-twm-action="delete"
2075
+ title="Delete"
2076
+ aria-label="Delete">
2077
+ <span class="material-symbols-outlined">delete</span>
2078
+ </button>`);
2079
+ }
2080
+ td.classList.add("twm-row-actions-cell");
2081
+ td.innerHTML = parts.join("");
2082
+ return true;
2083
+ };
2084
+ }
2085
+ function _esc3(s) {
2086
+ return String(s ?? "").replace(/[&<>"']/g, (c) => ({
2087
+ "&": "&amp;",
2088
+ "<": "&lt;",
2089
+ ">": "&gt;",
2090
+ '"': "&quot;",
2091
+ "'": "&#39;"
2092
+ })[c]);
2093
+ }
2094
+
2095
+ // src/tiling/loading_overlay.js
2096
+ function makeLoadingOverlay(slot, { delay = 80 } = {}) {
2097
+ const overlay = document.createElement("div");
2098
+ overlay.className = "twm-tile-loading twm-tile-loading--pending";
2099
+ overlay.innerHTML = '<div class="twm-tile-loading__spinner" aria-label="Loading"></div>';
2100
+ slot.appendChild(overlay);
2101
+ const showTimer = setTimeout(() => {
2102
+ overlay.classList.remove("twm-tile-loading--pending");
2103
+ }, delay);
2104
+ return () => {
2105
+ clearTimeout(showTimer);
2106
+ overlay.remove();
2107
+ };
2108
+ }
2109
+
2110
+ // src/tiling/nav_panel.js
2111
+ function mountNavPanel(hostEl, {
2112
+ modes = [],
2113
+ wm = null,
2114
+ eventBus = null,
2115
+ filterPlaceholder = "Filter\u2026"
2116
+ } = {}) {
2117
+ if (!Array.isArray(modes) || modes.length === 0) {
2118
+ throw new Error("mountNavPanel: at least one mode is required");
2119
+ }
2120
+ hostEl.classList.add("twm-nav");
2121
+ hostEl.innerHTML = `
2122
+ <div class="twm-nav__modes" data-role="modes"></div>
2123
+ <div class="twm-sidebar-controls twm-nav__filter">
2124
+ <span class="material-symbols-outlined twm-sidebar-search-icon">search</span>
2125
+ <input class="twm-data-page__search twm-nav__filter-input"
2126
+ type="search" placeholder="${_esc4(filterPlaceholder)}"
2127
+ autocomplete="off" />
2128
+ </div>
2129
+ <div class="twm-nav__body" data-role="body"></div>
2130
+ `;
2131
+ const modesEl = hostEl.querySelector('[data-role="modes"]');
2132
+ const bodyEl = hostEl.querySelector('[data-role="body"]');
2133
+ const filterEl = hostEl.querySelector(".twm-nav__filter-input");
2134
+ modesEl.innerHTML = modes.map((m) => `
2135
+ <button class="twm-nav__mode twm-has-tooltip" data-mode="${_esc4(m.id)}"
2136
+ data-tooltip="${_esc4(m.label)}">
2137
+ <span class="material-symbols-outlined">${_esc4(m.icon)}</span>
2138
+ <span>${_esc4(m.label)}</span>
2139
+ </button>
2140
+ `).join("");
2141
+ const byId = new Map(modes.map((m) => [m.id, m]));
2142
+ const hosts = {};
2143
+ const panes = {};
2144
+ const filterByMode = Object.fromEntries(modes.map((m) => [m.id, ""]));
2145
+ let mode = modes[0].id;
2146
+ const ensureHost = (id) => {
2147
+ if (hosts[id]) return hosts[id];
2148
+ const el = document.createElement("div");
2149
+ el.className = `twm-nav__pane twm-nav__pane--${id}`;
2150
+ el.style.display = "none";
2151
+ bodyEl.appendChild(el);
2152
+ hosts[id] = el;
2153
+ return el;
2154
+ };
2155
+ const activate = async (next) => {
2156
+ const def = byId.get(next);
2157
+ if (!def) return;
2158
+ mode = next;
2159
+ for (const m of modes) {
2160
+ ensureHost(m.id).style.display = m.id === next ? "flex" : "none";
2161
+ }
2162
+ modesEl.querySelectorAll("[data-mode]").forEach((b) => b.classList.toggle("twm-nav__mode--on", b.dataset.mode === next));
2163
+ filterEl.value = filterByMode[next] || "";
2164
+ const paneEl = ensureHost(next);
2165
+ if (!panes[next]) {
2166
+ try {
2167
+ panes[next] = def.mount(paneEl, { wm, eventBus }) || {};
2168
+ } catch (err) {
2169
+ console.error("[nav] mode mount failed", next, err);
2170
+ panes[next] = {};
2171
+ }
2172
+ }
2173
+ try {
2174
+ await panes[next].activate?.();
2175
+ } catch (err) {
2176
+ console.error("[nav] mode activate failed", next, err);
2177
+ }
2178
+ _applyFilter();
2179
+ };
2180
+ const _applyFilter = () => {
2181
+ const q = filterByMode[mode] || "";
2182
+ try {
2183
+ panes[mode]?.filter?.(q);
2184
+ } catch (err) {
2185
+ console.warn("[nav] filter failed", mode, err);
2186
+ }
2187
+ };
2188
+ filterEl.addEventListener("input", () => {
2189
+ filterByMode[mode] = filterEl.value;
2190
+ _applyFilter();
2191
+ });
2192
+ modesEl.addEventListener("click", (e) => {
2193
+ const btn = e.target.closest("[data-mode]");
2194
+ if (btn) activate(btn.dataset.mode);
2195
+ });
2196
+ activate(mode);
2197
+ return {
2198
+ refresh: () => activate(mode),
2199
+ destroy: () => {
2200
+ for (const p of Object.values(panes)) {
2201
+ try {
2202
+ p?.destroy?.();
2203
+ } catch {
2204
+ }
2205
+ }
2206
+ }
2207
+ };
2208
+ }
2209
+ function _esc4(s) {
2210
+ return String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
2211
+ }
2212
+
2213
+ // src/tiling/tile_breadcrumb.js
2214
+ function mountTileBreadcrumb(kind, props, ctx) {
2215
+ const eventBus = ctx?.eventBus;
2216
+ const taxonomy = ctx?.taxonomy;
2217
+ const rootCrumb = ctx?.rootCrumb || null;
2218
+ if (!taxonomy) {
2219
+ console.error("[breadcrumb] no taxonomy in ctx \u2014 cannot render");
2220
+ return { el: document.createElement("nav"), destroy: () => {
2221
+ } };
2222
+ }
2223
+ const root = document.createElement("nav");
2224
+ root.className = "twm-topbar-breadcrumb twm-tile-breadcrumb";
2225
+ root.setAttribute("aria-label", "Tile breadcrumb");
2226
+ const getWm = () => ctx?.wm || window.__twm?.wm || null;
2227
+ const navigate = (k, p = {}) => {
2228
+ const wm = getWm();
2229
+ if (!wm) {
2230
+ console.error("[breadcrumb] no WM available \u2014 click ignored", k);
2231
+ return;
2232
+ }
2233
+ if (typeof wm.navigate === "function") {
2234
+ wm.navigate(k, p, { ctx, dest: "origin" });
2235
+ } else if (typeof wm.openInPrimary === "function") {
2236
+ wm.openInPrimary(k, p);
2237
+ } else {
2238
+ console.error("[breadcrumb] WM has no navigate/openInPrimary");
2239
+ }
2240
+ };
2241
+ let rootLabel = rootCrumb?.label || "";
2242
+ const render = () => {
2243
+ _renderInto(root, _segments(kind, props, taxonomy, rootCrumb, rootLabel, navigate));
2244
+ };
2245
+ render();
2246
+ let unsubscribe = null;
2247
+ if (typeof rootCrumb?.subscribe === "function") {
2248
+ try {
2249
+ unsubscribe = rootCrumb.subscribe((next) => {
2250
+ if (next?.label) rootLabel = next.label;
2251
+ render();
2252
+ }, { api: ctx?.api, host: ctx?.host, eventBus });
2253
+ } catch (err) {
2254
+ console.warn("[breadcrumb] rootCrumb.subscribe threw", err);
2255
+ }
2256
+ }
2257
+ return {
2258
+ el: root,
2259
+ destroy: () => {
2260
+ try {
2261
+ unsubscribe?.();
2262
+ } catch (err) {
2263
+ console.warn("[breadcrumb] rootCrumb teardown threw", err);
2264
+ }
2265
+ }
2266
+ };
2267
+ }
2268
+ function _segments(kind, props, taxonomy, rootCrumb, rootLabel, navigate) {
2269
+ const segs = [];
2270
+ const meta = taxonomy.meta(kind);
2271
+ if (meta?.appGlobal) {
2272
+ return [{ icon: meta.icon, label: meta.label, onClick: null }];
2273
+ }
2274
+ if (rootCrumb) {
2275
+ segs.push({
2276
+ icon: rootCrumb.icon,
2277
+ label: rootLabel || rootCrumb.label,
2278
+ onClick: () => navigate(rootCrumb.navKind ?? taxonomy.root)
2279
+ });
2280
+ }
2281
+ const topNav = taxonomy.topNavFor(kind);
2282
+ const topNavMeta = topNav ? taxonomy.meta(topNav) : null;
2283
+ if (topNav && topNav !== taxonomy.root && topNavMeta) {
2284
+ segs.push({
2285
+ icon: topNavMeta.icon,
2286
+ label: topNavMeta.label,
2287
+ onClick: () => navigate(topNav)
2288
+ });
2289
+ }
2290
+ for (const anc of taxonomy.ancestors(kind, props)) {
2291
+ segs.push({
2292
+ icon: taxonomy.meta(anc.kind)?.icon || "description",
2293
+ label: anc.props.label ?? anc.props.id,
2294
+ onClick: () => navigate(anc.kind, anc.props)
2295
+ });
2296
+ }
2297
+ const hasEntity = !!props?.id;
2298
+ if (meta) {
2299
+ if (hasEntity) {
2300
+ segs.push({
2301
+ icon: meta.icon,
2302
+ label: taxonomy.labelOf(kind, props),
2303
+ onClick: null
2304
+ });
2305
+ } else if (kind !== topNav) {
2306
+ segs.push({
2307
+ icon: meta.icon,
2308
+ label: meta.label,
2309
+ onClick: null
2310
+ });
2311
+ }
2312
+ } else if (hasEntity) {
2313
+ segs.push({
2314
+ icon: "description",
2315
+ label: props.label || props.id,
2316
+ onClick: null
2317
+ });
2318
+ }
2319
+ return segs;
2320
+ }
2321
+ function _renderInto(container, segments) {
2322
+ container.innerHTML = "";
2323
+ if (!segments?.length) return;
2324
+ const ol = document.createElement("ol");
2325
+ ol.className = "twm-topbar-breadcrumb__list";
2326
+ segments.forEach((seg, i) => {
2327
+ const li = document.createElement("li");
2328
+ li.className = "twm-topbar-breadcrumb__item";
2329
+ const isLast = i === segments.length - 1;
2330
+ if (isLast || !seg.onClick) {
2331
+ li.classList.add("twm-topbar-breadcrumb__item--current");
2332
+ li.innerHTML = `
2333
+ <span class="material-symbols-outlined twm-topbar-breadcrumb__icon">${_esc5(seg.icon)}</span>
2334
+ <span class="twm-topbar-breadcrumb__current">${_esc5(seg.label)}</span>
2335
+ `;
2336
+ } else {
2337
+ const btn = document.createElement("button");
2338
+ btn.className = "twm-topbar-breadcrumb__link";
2339
+ btn.type = "button";
2340
+ btn.innerHTML = `
2341
+ <span class="material-symbols-outlined twm-topbar-breadcrumb__icon">${_esc5(seg.icon)}</span>
2342
+ <span>${_esc5(seg.label)}</span>
2343
+ `;
2344
+ btn.addEventListener("click", () => {
2345
+ btn.classList.add("twm-tile-breadcrumb__link--flash");
2346
+ setTimeout(() => {
2347
+ btn.classList.remove("twm-tile-breadcrumb__link--flash");
2348
+ }, 180);
2349
+ try {
2350
+ seg.onClick();
2351
+ } catch (e) {
2352
+ console.error("[breadcrumb] segment click threw", e);
2353
+ }
2354
+ });
2355
+ li.appendChild(btn);
2356
+ const sep = document.createElement("span");
2357
+ sep.className = "twm-topbar-breadcrumb__separator";
2358
+ sep.textContent = "\u203A";
2359
+ li.appendChild(sep);
2360
+ }
2361
+ ol.appendChild(li);
2362
+ });
2363
+ container.appendChild(ol);
2364
+ }
2365
+ function _esc5(s) {
2366
+ return String(s ?? "").replace(/[&<>"']/g, (c) => ({
2367
+ "&": "&amp;",
2368
+ "<": "&lt;",
2369
+ ">": "&gt;",
2370
+ '"': "&quot;",
2371
+ "'": "&#39;"
2372
+ })[c]);
2373
+ }
2374
+
2375
+ // src/tiling/page_factory.js
2376
+ function createPageFactories({ eventBus = null, events = {}, refreshOn = {} } = {}) {
2377
+ const projectChanged = events?.projectChanged || null;
2378
+ const makeShim = (ctx) => ({
2379
+ // Route through openFromContext so navigation triggered from a
2380
+ // split tile / window stays in that container.
2381
+ openTab: ({ kind: k, entityId, label, icon, subTab }) => ctx.wm?.openFromContext(ctx, k, { id: entityId, label, icon, subTab }),
2382
+ // Open the same content as a NEW TAB in the current tile.
2383
+ openInTab: ({ kind: k, entityId, label, icon, subTab }) => ctx.wm?.openInTabFromContext(ctx, k, { id: entityId, label, icon, subTab }),
2384
+ // Open the same content in a fresh managed window.
2385
+ openInWindow: ({ kind: k, entityId, label, icon, subTab }) => ctx.wm?.navigate(k, { id: entityId, label, icon, subTab }, { ctx, dest: "window" }),
2386
+ // Persist editor sub-state into this tile's WM tab props.
2387
+ updateProps: (patch) => {
2388
+ try {
2389
+ ctx.wm?.updateActiveTabProps?.(ctx.leafId, patch);
2390
+ } catch (err) {
2391
+ console.warn("[shim] updateProps failed", err);
2392
+ }
2393
+ },
2394
+ // Close just THIS tab (not the whole tile); falls back to closing
2395
+ // the tile when it's the last tab. Also broadcasts so sidebars /
2396
+ // landings / nav refresh against the mutated project state.
2397
+ closeTab: (_id) => {
2398
+ if (projectChanged) {
2399
+ try {
2400
+ eventBus?.emit?.(projectChanged, { source: "twm-tab-close" });
2401
+ } catch {
2402
+ }
2403
+ }
2404
+ try {
2405
+ ctx.wm?.closeActiveTab?.(ctx.leafId);
2406
+ } catch (err) {
2407
+ console.warn("[shim] closeTab failed", err);
2408
+ }
2409
+ },
2410
+ // Non-closing mutations (Save/New/Rename) broadcast so listeners refresh.
2411
+ notifyChanged: (domain) => {
2412
+ if (!projectChanged) return;
2413
+ try {
2414
+ eventBus?.emit?.(
2415
+ projectChanged,
2416
+ { source: "tab-mutation", domain: domain || null }
2417
+ );
2418
+ } catch {
2419
+ }
2420
+ },
2421
+ registerProvider: () => {
2422
+ },
2423
+ getActiveTab: () => null
2424
+ });
2425
+ const mountTabBody = (factory, contentSlot, props, ctx) => {
2426
+ const shim = makeShim(ctx);
2427
+ const tab = factory(contentSlot, props?.id ?? props?.entityId ?? null, {
2428
+ logger: null,
2429
+ eventBus,
2430
+ workspaceTabs: shim,
2431
+ wm: ctx.wm,
2432
+ leafId: ctx.leafId,
2433
+ windowId: ctx.windowId
2434
+ });
2435
+ const hideLoading = makeLoadingOverlay(contentSlot);
2436
+ try {
2437
+ const ret = tab?.mount?.(props);
2438
+ if (ret && typeof ret.then === "function") {
2439
+ ret.then(hideLoading, (err) => {
2440
+ console.error("[page] mount failed", err);
2441
+ hideLoading();
2442
+ });
2443
+ } else {
2444
+ requestAnimationFrame(hideLoading);
2445
+ }
2446
+ } catch (err) {
2447
+ console.error("[page] mount failed", err);
2448
+ hideLoading();
2449
+ }
2450
+ const listeners = {};
2451
+ if (typeof tab?.refresh === "function" && eventBus?.on) {
2452
+ for (const [busEvent, reason] of Object.entries(refreshOn || {})) {
2453
+ const fn = () => {
2454
+ try {
2455
+ tab.refresh(reason);
2456
+ } catch (err) {
2457
+ console.warn("[page] refresh failed", busEvent, err);
2458
+ }
2459
+ };
2460
+ listeners[busEvent] = fn;
2461
+ eventBus.on(busEvent, fn);
2462
+ }
2463
+ }
2464
+ return () => {
2465
+ for (const [n, fn] of Object.entries(listeners)) {
2466
+ try {
2467
+ eventBus?.off?.(n, fn);
2468
+ } catch {
2469
+ }
2470
+ }
2471
+ try {
2472
+ tab?.dispose?.();
2473
+ tab?.unmount?.();
2474
+ } catch {
2475
+ }
2476
+ };
2477
+ };
2478
+ const tabFactory = (kind, factory) => (host, props, ctx) => {
2479
+ host.classList.add("twm-page-shell");
2480
+ host.innerHTML = "";
2481
+ const contentSlot = document.createElement("div");
2482
+ contentSlot.className = "twm-page-shell__content";
2483
+ contentSlot.tabIndex = -1;
2484
+ let crumb = null;
2485
+ if (!ctx?.windowId) {
2486
+ const breadcrumbSlot = document.createElement("div");
2487
+ breadcrumbSlot.className = "twm-page-shell__breadcrumb";
2488
+ host.appendChild(breadcrumbSlot);
2489
+ crumb = mountTileBreadcrumb(kind, props, { ...ctx, eventBus });
2490
+ breadcrumbSlot.appendChild(crumb.el);
2491
+ }
2492
+ host.appendChild(contentSlot);
2493
+ const teardown = mountTabBody(factory, contentSlot, props, ctx);
2494
+ return {
2495
+ destroy: () => {
2496
+ teardown();
2497
+ try {
2498
+ crumb?.destroy();
2499
+ } catch {
2500
+ }
2501
+ }
2502
+ };
2503
+ };
2504
+ const bareTabFactory = (kind, factory) => (host, props, ctx) => {
2505
+ host.classList.add("twm-page-shell");
2506
+ host.innerHTML = "";
2507
+ const contentSlot = document.createElement("div");
2508
+ contentSlot.className = "twm-page-shell__content";
2509
+ contentSlot.tabIndex = -1;
2510
+ host.appendChild(contentSlot);
2511
+ return { destroy: mountTabBody(factory, contentSlot, props, ctx) };
2512
+ };
2513
+ const stubPageFactory = (kind, render) => (host, props, ctx) => {
2514
+ host.classList.add("twm-page-shell");
2515
+ host.innerHTML = "";
2516
+ const breadcrumbSlot = document.createElement("div");
2517
+ breadcrumbSlot.className = "twm-page-shell__breadcrumb";
2518
+ const contentSlot = document.createElement("div");
2519
+ contentSlot.className = "twm-page-shell__content";
2520
+ contentSlot.tabIndex = -1;
2521
+ host.appendChild(breadcrumbSlot);
2522
+ host.appendChild(contentSlot);
2523
+ const crumb = mountTileBreadcrumb(kind, props, { ...ctx, eventBus });
2524
+ breadcrumbSlot.appendChild(crumb.el);
2525
+ const hideLoading = makeLoadingOverlay(contentSlot);
2526
+ const ret = render(contentSlot, props, ctx) || {};
2527
+ if (ret.ready && typeof ret.ready.then === "function") {
2528
+ ret.ready.then(hideLoading, hideLoading);
2529
+ } else {
2530
+ requestAnimationFrame(hideLoading);
2531
+ }
2532
+ return {
2533
+ title: ret.title,
2534
+ destroy: () => {
2535
+ try {
2536
+ crumb.destroy();
2537
+ } catch {
2538
+ }
2539
+ ret.destroy?.();
2540
+ }
2541
+ };
2542
+ };
2543
+ return Object.freeze({
2544
+ makeShim,
2545
+ mountTabBody,
2546
+ tabFactory,
2547
+ bareTabFactory,
2548
+ stubPageFactory
2549
+ });
2550
+ }
2551
+
2552
+ // src/tiling/tile_renderer.js
2553
+ var SPLITTER_PX = 4;
2554
+ var TileRenderer = class {
2555
+ constructor({ root, tree, content, ctx, onFocusChange }) {
2556
+ if (!content || typeof content.mount !== "function") {
2557
+ throw new Error("TileRenderer: a content registry is required");
2558
+ }
2559
+ this.root = root;
2560
+ this.tree = tree;
2561
+ this.content = content;
2562
+ this.ctx = ctx || {};
2563
+ this.onFocusChange = onFocusChange || (() => {
2564
+ });
2565
+ this._leafCache = /* @__PURE__ */ new Map();
2566
+ this._drag = null;
2567
+ this.root.classList.add("twm-root");
2568
+ this.root.addEventListener("mousedown", this._onMouseDown.bind(this));
2569
+ }
2570
+ render() {
2571
+ const savedScrolls = /* @__PURE__ */ new Map();
2572
+ for (const [leafId, entry] of this._leafCache.entries()) {
2573
+ const snap = [];
2574
+ entry.wrapEl.querySelectorAll("*").forEach((el) => {
2575
+ if (el.scrollTop > 0 || el.scrollLeft > 0) {
2576
+ snap.push({ el, top: el.scrollTop, left: el.scrollLeft });
2577
+ }
2578
+ });
2579
+ savedScrolls.set(leafId, snap);
2580
+ entry.wrapEl.remove();
2581
+ }
2582
+ this.root.innerHTML = "";
2583
+ const tree = this.tree;
2584
+ if (!tree.rootId) {
2585
+ const empty = document.createElement("div");
2586
+ empty.className = "twm-empty";
2587
+ empty.textContent = "Empty desktop \u2014 Ctrl+K to open something.";
2588
+ this.root.appendChild(empty);
2589
+ this._cleanCache(/* @__PURE__ */ new Set());
2590
+ return;
2591
+ }
2592
+ const liveLeafIds = /* @__PURE__ */ new Set();
2593
+ this._mount(tree.rootId, this.root, liveLeafIds);
2594
+ this._cleanCache(liveLeafIds);
2595
+ this._updateFocusClasses();
2596
+ const restore = () => {
2597
+ for (const [leafId, snap] of savedScrolls) {
2598
+ if (!this._leafCache.has(leafId)) continue;
2599
+ for (const { el, top, left } of snap) {
2600
+ if (el.isConnected) {
2601
+ el.scrollTop = top;
2602
+ el.scrollLeft = left;
2603
+ }
2604
+ }
2605
+ }
2606
+ };
2607
+ restore();
2608
+ requestAnimationFrame(restore);
2609
+ }
2610
+ _cleanCache(liveSet) {
2611
+ for (const [leafId, entry] of [...this._leafCache.entries()]) {
2612
+ if (!liveSet.has(leafId)) {
2613
+ try {
2614
+ entry.content?.destroy?.();
2615
+ } catch (_) {
2616
+ }
2617
+ entry.wrapEl.remove();
2618
+ this._leafCache.delete(leafId);
2619
+ }
2620
+ }
2621
+ }
2622
+ _mount(nodeId, parentEl, liveSet) {
2623
+ const node = this.tree.get(nodeId);
2624
+ if (!node) return;
2625
+ if (node.kind === "leaf") {
2626
+ liveSet.add(node.id);
2627
+ const el = this._leafEl(node);
2628
+ parentEl.appendChild(el);
2629
+ return;
2630
+ }
2631
+ const split = document.createElement("div");
2632
+ split.className = `twm-split twm-split--${node.dir}`;
2633
+ split.dataset.splitId = node.id;
2634
+ parentEl.appendChild(split);
2635
+ const total = node.sizes.reduce((a, b) => a + b, 0) || node.children.length;
2636
+ node.children.forEach((cid, i) => {
2637
+ const slot = document.createElement("div");
2638
+ slot.className = "twm-slot";
2639
+ const frac = (node.sizes[i] || 1) / total;
2640
+ slot.style.flex = `${frac} ${frac} 0`;
2641
+ split.appendChild(slot);
2642
+ this._mount(cid, slot, liveSet);
2643
+ if (i < node.children.length - 1) {
2644
+ const splitter = document.createElement("div");
2645
+ splitter.className = `twm-splitter twm-splitter--${node.dir}`;
2646
+ splitter.dataset.splitId = node.id;
2647
+ splitter.dataset.slotIdx = String(i);
2648
+ split.appendChild(splitter);
2649
+ }
2650
+ });
2651
+ }
2652
+ _leafEl(leaf) {
2653
+ const activeTab = Array.isArray(leaf.tabs) && leaf.tabs.length > 0 ? leaf.tabs[Math.max(0, Math.min(leaf.tabs.length - 1, leaf.activeTabIdx || 0))] : null;
2654
+ const tabFingerprint = (leaf.tabs || []).map((t) => `${t.kind}::${JSON.stringify(t.props || {})}`).join("|") + `#${leaf.activeTabIdx || 0}`;
2655
+ const kindKey = leaf.content ? `${leaf.content.kind}::${JSON.stringify(leaf.content.props || {})}::tabs:${tabFingerprint}` : "__empty__";
2656
+ let entry = this._leafCache.get(leaf.id);
2657
+ if (entry && entry.kindKey === kindKey) {
2658
+ entry.titleEl.textContent = leaf.title || (leaf.content ? leaf.content.kind : "empty");
2659
+ return entry.wrapEl;
2660
+ }
2661
+ if (entry) {
2662
+ try {
2663
+ entry.content?.destroy?.();
2664
+ } catch (_) {
2665
+ }
2666
+ entry.wrapEl.remove();
2667
+ }
2668
+ const wrap = document.createElement("div");
2669
+ wrap.className = "twm-leaf";
2670
+ wrap.dataset.leafId = leaf.id;
2671
+ const chrome = document.createElement("div");
2672
+ chrome.className = "twm-leaf__chrome";
2673
+ const title = document.createElement("span");
2674
+ title.className = "twm-leaf__title";
2675
+ title.textContent = leaf.title || (leaf.content ? leaf.content.kind : "empty");
2676
+ const actions = document.createElement("span");
2677
+ actions.className = "twm-leaf__actions";
2678
+ const isPanel = String(leaf.content?.kind || "").startsWith("panel:");
2679
+ actions.innerHTML = `
2680
+ ${isPanel ? "" : `
2681
+ <button class="twm-leaf__btn" data-action="split-h" title="Split horizontally (Alt+H)">
2682
+ <span class="material-symbols-outlined">splitscreen_vertical_add</span>
2683
+ </button>
2684
+ <button class="twm-leaf__btn" data-action="split-v" title="Split vertically (Alt+V)">
2685
+ <span class="material-symbols-outlined">splitscreen_add</span>
2686
+ </button>
2687
+ <button class="twm-leaf__btn" data-action="promote" title="Promote to window (Alt+F)">
2688
+ <span class="material-symbols-outlined">open_in_new</span>
2689
+ </button>`}
2690
+ <button class="twm-leaf__btn" data-action="close" title="Close (Alt+W)">
2691
+ <span class="material-symbols-outlined">close</span>
2692
+ </button>
2693
+ `;
2694
+ chrome.append(title, actions);
2695
+ const body = document.createElement("div");
2696
+ body.className = "twm-leaf__body";
2697
+ body.tabIndex = -1;
2698
+ const tabBar = document.createElement("div");
2699
+ tabBar.className = "twm-leaf__tabbar";
2700
+ if (!Array.isArray(leaf.tabs) || leaf.tabs.length <= 1) {
2701
+ tabBar.classList.add("twm-leaf__tabbar--hidden");
2702
+ }
2703
+ wrap.append(chrome, body, tabBar);
2704
+ wrap.addEventListener("mousedown", (e) => {
2705
+ if (e.target.closest(".twm-splitter")) return;
2706
+ this.tree.focus(leaf.id);
2707
+ this._updateFocusClasses();
2708
+ this.onFocusChange(leaf.id);
2709
+ const tgt = e.target;
2710
+ const focusable = tgt.closest?.(
2711
+ 'input, textarea, select, button, a, [contenteditable="true"], [tabindex]'
2712
+ );
2713
+ if (!focusable || !body.contains(focusable)) {
2714
+ setTimeout(() => {
2715
+ const ps = body.querySelector(".twm-page-shell__content") ?? body;
2716
+ if (ps.tabIndex == null || ps.tabIndex < -1) ps.tabIndex = -1;
2717
+ ps.focus({ preventScroll: true });
2718
+ }, 0);
2719
+ }
2720
+ });
2721
+ chrome.addEventListener("contextmenu", (e) => {
2722
+ e.preventDefault();
2723
+ this.tree.focus(leaf.id);
2724
+ this._updateFocusClasses();
2725
+ this.onFocusChange(leaf.id);
2726
+ this.ctx.onTileContextMenu?.(leaf.id, e.clientX, e.clientY);
2727
+ });
2728
+ actions.addEventListener("click", (e) => {
2729
+ const btn = e.target.closest("button");
2730
+ if (!btn) return;
2731
+ e.stopPropagation();
2732
+ this.ctx.onLeafAction?.(leaf.id, btn.dataset.action);
2733
+ });
2734
+ let content = {};
2735
+ if (leaf.content) {
2736
+ const leafCtx = { ...this.ctx, leafId: leaf.id };
2737
+ const activeProps = activeTab?.props ?? leaf.content.props;
2738
+ content = this.content.mount(leaf.content.kind, body, activeProps, leafCtx);
2739
+ if (content.title) title.textContent = content.title;
2740
+ } else {
2741
+ body.innerHTML = `<div class="tile-placeholder"><div class="tile-placeholder__hint">empty tile</div></div>`;
2742
+ }
2743
+ entry = {
2744
+ wrapEl: wrap,
2745
+ bodyEl: body,
2746
+ chromeEl: chrome,
2747
+ titleEl: title,
2748
+ content,
2749
+ kindKey,
2750
+ tabBarEl: tabBar
2751
+ };
2752
+ this._leafCache.set(leaf.id, entry);
2753
+ this._renderTabBar(leaf, entry);
2754
+ return wrap;
2755
+ }
2756
+ /** Paint a leaf's bottom tab strip. The strip is hidden for
2757
+ * single-tab leaves (the common case) so existing layouts read as
2758
+ * identical to today. Hamburger button at the start, then one
2759
+ * trapezoid-shaped tab per stored tab spec. */
2760
+ _renderTabBar(leaf, entry) {
2761
+ const bar = entry.tabBarEl;
2762
+ if (!bar) return;
2763
+ const tabs = Array.isArray(leaf.tabs) ? leaf.tabs : [];
2764
+ if (tabs.length <= 1) {
2765
+ bar.classList.add("twm-leaf__tabbar--hidden");
2766
+ bar.innerHTML = "";
2767
+ return;
2768
+ }
2769
+ bar.classList.remove("twm-leaf__tabbar--hidden");
2770
+ const activeIdx = Math.max(0, Math.min(tabs.length - 1, leaf.activeTabIdx || 0));
2771
+ bar.innerHTML = `
2772
+ <button type="button" class="twm-leaf__tab-hamburger"
2773
+ data-action="tab-menu"
2774
+ title="Open in new tab from this page's content">
2775
+ <span class="material-symbols-outlined">menu</span>
2776
+ </button>
2777
+ <ol class="twm-leaf__tabs" role="tablist">
2778
+ ${tabs.map((t, i) => `
2779
+ <li class="twm-leaf__tab${i === activeIdx ? " twm-leaf__tab--on" : ""}"
2780
+ role="tab" data-tab-idx="${i}"
2781
+ title="${_esc6(t.title || t.kind)}"
2782
+ draggable="true">
2783
+ <span class="twm-leaf__tab-label">${_esc6(t.title || t.kind)}</span>
2784
+ <button type="button" class="twm-leaf__tab-close"
2785
+ data-action="tab-close" data-tab-idx="${i}"
2786
+ title="Close this tab"
2787
+ aria-label="Close tab">
2788
+ <span class="material-symbols-outlined">close</span>
2789
+ </button>
2790
+ </li>
2791
+ `).join("")}
2792
+ </ol>
2793
+ `;
2794
+ bar.querySelectorAll(".twm-leaf__tab").forEach((li) => {
2795
+ li.addEventListener("click", (ev) => {
2796
+ if (ev.target.closest('[data-action="tab-close"]')) return;
2797
+ const idx = Number(li.dataset.tabIdx);
2798
+ this.ctx.onLeafTabAction?.(leaf.id, "switch", { idx });
2799
+ });
2800
+ li.addEventListener("contextmenu", (ev) => {
2801
+ ev.preventDefault();
2802
+ ev.stopPropagation();
2803
+ const idx = Number(li.dataset.tabIdx);
2804
+ this.ctx.onLeafTabAction?.(
2805
+ leaf.id,
2806
+ "menu",
2807
+ { idx, x: ev.clientX, y: ev.clientY }
2808
+ );
2809
+ });
2810
+ });
2811
+ bar.querySelectorAll('[data-action="tab-close"]').forEach((btn) => {
2812
+ btn.addEventListener("click", (ev) => {
2813
+ ev.preventDefault();
2814
+ ev.stopPropagation();
2815
+ const idx = Number(btn.dataset.tabIdx);
2816
+ this.ctx.onLeafTabAction?.(leaf.id, "close", { idx });
2817
+ });
2818
+ });
2819
+ bar.querySelector('[data-action="tab-menu"]')?.addEventListener("click", (ev) => {
2820
+ ev.preventDefault();
2821
+ const r = ev.currentTarget.getBoundingClientRect();
2822
+ this.ctx.onLeafTabAction?.(
2823
+ leaf.id,
2824
+ "open-menu",
2825
+ { x: r.left, y: r.top }
2826
+ );
2827
+ });
2828
+ let dragFromIdx = null;
2829
+ bar.querySelectorAll(".twm-leaf__tab").forEach((li) => {
2830
+ li.addEventListener("dragstart", (ev) => {
2831
+ dragFromIdx = Number(li.dataset.tabIdx);
2832
+ ev.dataTransfer.effectAllowed = "move";
2833
+ try {
2834
+ ev.dataTransfer.setData("text/plain", String(dragFromIdx));
2835
+ } catch {
2836
+ }
2837
+ });
2838
+ li.addEventListener("dragover", (ev) => {
2839
+ if (dragFromIdx == null) return;
2840
+ ev.preventDefault();
2841
+ ev.dataTransfer.dropEffect = "move";
2842
+ });
2843
+ li.addEventListener("drop", (ev) => {
2844
+ if (dragFromIdx == null) return;
2845
+ ev.preventDefault();
2846
+ const toIdx = Number(li.dataset.tabIdx);
2847
+ if (toIdx !== dragFromIdx) {
2848
+ this.ctx.onLeafTabAction?.(
2849
+ leaf.id,
2850
+ "move",
2851
+ { from: dragFromIdx, to: toIdx }
2852
+ );
2853
+ }
2854
+ dragFromIdx = null;
2855
+ });
2856
+ });
2857
+ }
2858
+ _updateFocusClasses() {
2859
+ const focused = this.tree.focusedLeafId;
2860
+ for (const [leafId, entry] of this._leafCache) {
2861
+ entry.wrapEl.classList.toggle("twm-leaf--focused", leafId === focused);
2862
+ }
2863
+ }
2864
+ /** The `.twm-leaf` wrap element for a leaf id, or null if it isn't
2865
+ * currently rendered. Used by the panel-key router to resolve which
2866
+ * DOM subtree owns keyboard input. */
2867
+ leafEl(leafId) {
2868
+ return this._leafCache.get(leafId)?.wrapEl || null;
2869
+ }
2870
+ // ── Drag-resize ────────────────────────────────────────────────
2871
+ _onMouseDown(e) {
2872
+ const splitter = e.target.closest(".twm-splitter");
2873
+ if (!splitter) return;
2874
+ e.preventDefault();
2875
+ const splitId = splitter.dataset.splitId;
2876
+ const slotIdx = Number(splitter.dataset.slotIdx);
2877
+ const split = this.tree.get(splitId);
2878
+ if (!split) return;
2879
+ const containerEl = splitter.parentElement;
2880
+ const horizontal = split.dir === "h";
2881
+ const rect = containerEl.getBoundingClientRect();
2882
+ const totalPx = horizontal ? rect.width : rect.height;
2883
+ this._drag = {
2884
+ splitId,
2885
+ slotIdx,
2886
+ horizontal,
2887
+ totalPx,
2888
+ startSizes: split.sizes.slice(),
2889
+ startPos: horizontal ? e.clientX : e.clientY,
2890
+ split
2891
+ };
2892
+ document.body.classList.add("twm-dragging");
2893
+ const move = (ev) => this._onMouseMove(ev);
2894
+ const up = (ev) => {
2895
+ this._onMouseUp(ev);
2896
+ document.removeEventListener("mousemove", move);
2897
+ document.removeEventListener("mouseup", up);
2898
+ };
2899
+ document.addEventListener("mousemove", move);
2900
+ document.addEventListener("mouseup", up);
2901
+ }
2902
+ _onMouseMove(e) {
2903
+ if (!this._drag) return;
2904
+ const { horizontal, totalPx, startSizes, startPos, slotIdx, split } = this._drag;
2905
+ const delta = (horizontal ? e.clientX : e.clientY) - startPos;
2906
+ const totalSize = startSizes.reduce((a, b) => a + b, 0);
2907
+ const px = totalPx - SPLITTER_PX * (split.children.length - 1);
2908
+ if (px <= 0) return;
2909
+ const deltaFrac = delta / px * totalSize;
2910
+ const minFrac = totalSize * 0.05;
2911
+ const newSizes = startSizes.slice();
2912
+ newSizes[slotIdx] = Math.max(minFrac, startSizes[slotIdx] + deltaFrac);
2913
+ newSizes[slotIdx + 1] = Math.max(minFrac, startSizes[slotIdx + 1] - deltaFrac);
2914
+ const sum = newSizes.reduce((a, b) => a + b, 0);
2915
+ const scale = totalSize / sum;
2916
+ for (let i = 0; i < newSizes.length; i++) newSizes[i] *= scale;
2917
+ this.tree.setSplitSizes(split.id, newSizes);
2918
+ const containerEl = document.querySelector(`.twm-split[data-split-id="${split.id}"]`);
2919
+ if (!containerEl) return;
2920
+ const slots = containerEl.querySelectorAll(":scope > .twm-slot");
2921
+ slots.forEach((slot, i) => {
2922
+ const frac = newSizes[i] / totalSize;
2923
+ slot.style.flex = `${frac} ${frac} 0`;
2924
+ });
2925
+ }
2926
+ _onMouseUp() {
2927
+ this._drag = null;
2928
+ document.body.classList.remove("twm-dragging");
2929
+ }
2930
+ };
2931
+ function _esc6(s) {
2932
+ return String(s ?? "").replace(/[&<>"']/g, (c) => ({
2933
+ "&": "&amp;",
2934
+ "<": "&lt;",
2935
+ ">": "&gt;",
2936
+ '"': "&quot;",
2937
+ "'": "&#39;"
2938
+ })[c]);
2939
+ }
2940
+
2941
+ // src/tiling/wm.js
2942
+ var PANEL_KINDS = /* @__PURE__ */ new Set(["panel:left", "panel:right", "panel:bottom"]);
2943
+ var PANEL_TITLES = {
2944
+ left: "Navigator",
2945
+ right: "Inspector",
2946
+ bottom: "Console"
2947
+ };
2948
+ var WindowManager = class {
2949
+ constructor({ rootEl, api, ctx, onChange, eventBus, host, taxonomy, events, content }) {
2950
+ if (!taxonomy) throw new Error("WindowManager: a taxonomy is required");
2951
+ if (!content || typeof content.mount !== "function") {
2952
+ throw new Error("WindowManager: a content registry is required (createContentRegistry / createShell owns it)");
2953
+ }
2954
+ this.rootEl = rootEl;
2955
+ this.content = content;
2956
+ this.api = api || null;
2957
+ this.host = host || null;
2958
+ this.taxonomy = taxonomy;
2959
+ this.events = events || {};
2960
+ this.ctx = ctx || {};
2961
+ this.eventBus = eventBus || null;
2962
+ this.onChange = onChange || (() => {
2963
+ });
2964
+ this._rootLeaf = () => {
2965
+ const kind = this.taxonomy.root;
2966
+ return {
2967
+ content: { kind, props: {} },
2968
+ title: this.taxonomy.meta(kind)?.label || kind
2969
+ };
2970
+ };
2971
+ this.desktops = new DesktopManager({ seed: this._rootLeaf });
2972
+ this.renderer = new TileRenderer({
2973
+ root: rootEl,
2974
+ tree: this.desktops.active().tree,
2975
+ content,
2976
+ ctx: {
2977
+ ...this.ctx,
2978
+ wm: this,
2979
+ onLeafAction: (leafId, action) => this._leafAction(leafId, action),
2980
+ onLeafTabAction: (leafId, action, data) => this._leafTabAction(leafId, action, data)
2981
+ },
2982
+ onFocusChange: () => this._notifyChange()
2983
+ });
2984
+ this._persistTimer = null;
2985
+ this._windowToLeaf = /* @__PURE__ */ new Map();
2986
+ this.panelKeys = installPanelKeyRouter(this);
2987
+ const renamedEvent = this.events.entityRenamed;
2988
+ if (renamedEvent) {
2989
+ this.eventBus?.on?.(renamedEvent, ({ kind, entityId, label } = {}) => {
2990
+ if (!kind || !entityId || !label) return;
2991
+ let touched = false;
2992
+ for (const d of this.desktops.desktops) {
2993
+ if (d.tree.renameMatchingTabs?.(kind, entityId, label)) {
2994
+ touched = true;
2995
+ }
2996
+ }
2997
+ if (touched) {
2998
+ this.renderer.render();
2999
+ this._persist();
3000
+ }
3001
+ });
3002
+ }
3003
+ }
3004
+ /** Emit on bus + call onChange. Use this instead of the bare callback
3005
+ * so other surfaces (palette, top-bar toggles, page shortcuts) can
3006
+ * subscribe through the existing event system. */
3007
+ _notifyChange(reason = null) {
3008
+ try {
3009
+ this.onChange(reason);
3010
+ } catch (err) {
3011
+ console.error("[wm] onChange threw", err);
3012
+ }
3013
+ try {
3014
+ this.eventBus?.emit?.("wm:changed", { reason, wm: this });
3015
+ } catch (err) {
3016
+ console.warn("[wm] event emit failed", err);
3017
+ }
3018
+ try {
3019
+ const tree = this._tree();
3020
+ const id = tree.primaryLeafId();
3021
+ const leaf = id ? tree.get(id) : null;
3022
+ const kind = leaf?.content?.kind;
3023
+ if (kind && kind !== "window-placeholder") {
3024
+ this.eventBus?.emit?.("workspace:tabs:activated", {
3025
+ kind,
3026
+ entityId: leaf.content.props?.id ?? null,
3027
+ label: leaf.title,
3028
+ from: reason || "wm"
3029
+ });
3030
+ }
3031
+ } catch (_) {
3032
+ }
3033
+ }
3034
+ // ── Persistence ─────────────────────────────────────────────────
3035
+ async load() {
3036
+ const blob = await loadDesktops(this.host?.state);
3037
+ if (blob) {
3038
+ this.desktops = DesktopManager.deserialize(blob, { seed: this._rootLeaf });
3039
+ this.renderer.tree = this.desktops.active().tree;
3040
+ }
3041
+ for (const d of this.desktops.desktops) {
3042
+ for (const leaf of d.tree.leaves()) {
3043
+ if (leaf.content?.kind === PLACEHOLDER_KIND) {
3044
+ const p = leaf.content.props || {};
3045
+ if (p.originalKind) {
3046
+ d.tree.setLeafContent(
3047
+ leaf.id,
3048
+ { kind: p.originalKind, props: p.originalProps || {} },
3049
+ p.originalTitle || p.originalKind
3050
+ );
3051
+ }
3052
+ }
3053
+ }
3054
+ this._canonicalize(d.tree, d);
3055
+ }
3056
+ this.renderer.tree = this.desktops.active().tree;
3057
+ this.renderer.render();
3058
+ this._notifyChange();
3059
+ }
3060
+ _persist() {
3061
+ if (this._persistTimer) clearTimeout(this._persistTimer);
3062
+ this._persistTimer = setTimeout(async () => {
3063
+ this._persistTimer = null;
3064
+ await saveDesktops(this.host?.state, this.desktops.serialize());
3065
+ }, 500);
3066
+ }
3067
+ // ── Content loading ─────────────────────────────────────────────
3068
+ /** Load content into the primary tile (last-active non-panel leaf).
3069
+ /** Walk back one step. Bound to Backspace.
3070
+ *
3071
+ * Priority order:
3072
+ * 1. Active tab's per-tab history stack (browser-style "back"):
3073
+ * any in-tile navigation (relationships click-through,
3074
+ * landing-row open, "Open" buttons inside tables) pushed
3075
+ * the previous state, and Backspace pops it. This is the
3076
+ * canonical user-facing back affordance.
3077
+ * 2. When the history is empty (the user landed on this page
3078
+ * via top-nav / palette / direct link), fall back to the
3079
+ * canonical taxonomy walk: a sub-page goes to its
3080
+ * top-nav, a top-nav page goes to Home.
3081
+ *
3082
+ * Adding a new kind to the injected taxonomy wires the fallback path
3083
+ * for Backspace + breadcrumb + top-nav highlight in one shot.
3084
+ * Adding a new "Open from X" click that should be a back-able
3085
+ * navigation just needs to use one of the in-tile routing
3086
+ * methods (openFromContext / openInLeaf / navigateActiveTab) —
3087
+ * they all record history by default. */
3088
+ navigateBack() {
3089
+ const tree = this._tree();
3090
+ const focusedId = tree.focusedLeafId;
3091
+ const primaryId = tree.primaryLeafId();
3092
+ const focusedLeaf = focusedId ? tree.get(focusedId) : null;
3093
+ const focusedKind = focusedLeaf?.content?.kind || "";
3094
+ const id = focusedId && focusedKind && !focusedKind.startsWith("panel:") && focusedKind !== "window-placeholder" ? focusedId : primaryId;
3095
+ if (!id) return;
3096
+ const prior = tree.popActiveTabHistory?.(id);
3097
+ if (prior && prior.kind) {
3098
+ const leafNow = tree.get(id);
3099
+ const curKind = leafNow?.content?.kind;
3100
+ const curTopNav = curKind ? this.taxonomy.topNavFor(curKind) : null;
3101
+ if (prior.topNav && curTopNav && prior.topNav !== curTopNav) {
3102
+ tree.swapToPage?.(
3103
+ id,
3104
+ {
3105
+ kind: prior.kind,
3106
+ props: prior.props || {},
3107
+ title: prior.title || _tabTitle(prior.kind, prior.props)
3108
+ },
3109
+ curTopNav,
3110
+ prior.topNav,
3111
+ { recordHistory: false }
3112
+ );
3113
+ } else {
3114
+ tree.replaceActiveTabContent(
3115
+ id,
3116
+ { kind: prior.kind, props: prior.props || {} },
3117
+ prior.title || _tabTitle(prior.kind, prior.props),
3118
+ { recordHistory: false }
3119
+ );
3120
+ }
3121
+ tree.focus(id);
3122
+ this.renderer.render();
3123
+ this._persist();
3124
+ this._notifyChange("navigate-back");
3125
+ return;
3126
+ }
3127
+ const leaf = tree.get(id);
3128
+ if (!leaf?.content) return;
3129
+ const { kind, props } = leaf.content;
3130
+ const up = this.taxonomy.parentOf(kind, props);
3131
+ if (up) {
3132
+ this.navigateActiveTab(id, up.kind, up.props);
3133
+ return;
3134
+ }
3135
+ const parent = this.taxonomy.parentKindFor(kind);
3136
+ if (parent) this.navigateActiveTab(id, parent);
3137
+ }
3138
+ /** True when `navigateBack` would do something user-visible — i.e.
3139
+ * the active tab has per-tab history, OR the current kind has a
3140
+ * taxonomy parent. Used by the breadcrumb to grey out / hide the
3141
+ * Back button when there's nowhere to go. */
3142
+ canNavigateBack() {
3143
+ const tree = this._tree();
3144
+ const focusedId = tree.focusedLeafId;
3145
+ const primaryId = tree.primaryLeafId();
3146
+ const focusedLeaf = focusedId ? tree.get(focusedId) : null;
3147
+ const focusedKind = focusedLeaf?.content?.kind || "";
3148
+ const id = focusedId && focusedKind && !focusedKind.startsWith("panel:") && focusedKind !== "window-placeholder" ? focusedId : primaryId;
3149
+ if (!id) return false;
3150
+ const history = tree.activeTabHistory?.(id) || [];
3151
+ if (history.length > 0) return true;
3152
+ const leaf = tree.get(id);
3153
+ const kind = leaf?.content?.kind;
3154
+ return !!(kind && this.taxonomy.parentKindFor(kind));
3155
+ }
3156
+ /** Persist editor sub-state (e.g. active sub-tab) into the active
3157
+ * tab's WM props so future history snapshots restore the user's
3158
+ * view rather than the default landing. Pure metadata write — no
3159
+ * re-render, no history push. */
3160
+ updateActiveTabProps(leafId, patch) {
3161
+ const tree = this._tree();
3162
+ if (!tree?.updateActiveTabProps) return;
3163
+ tree.updateActiveTabProps(leafId, patch);
3164
+ this._persist();
3165
+ }
3166
+ /** Navigate inside the current tab — preserves every other tab in
3167
+ * the leaf. Used by Backspace + breadcrumb segments + anything
3168
+ * else that should walk WITHIN the tile rather than reset it. */
3169
+ navigateActiveTab(leafId, kind, props = {}) {
3170
+ const tree = this._tree();
3171
+ const leaf = tree.get(leafId);
3172
+ if (!leaf || leaf.kind !== "leaf") {
3173
+ this.openInPrimary(kind, props);
3174
+ return;
3175
+ }
3176
+ tree.replaceActiveTabContent(leafId, { kind, props }, _tabTitle(kind, props));
3177
+ tree.focus(leafId);
3178
+ this.renderer.render();
3179
+ this._persist();
3180
+ this._notifyChange("navigate-active-tab");
3181
+ }
3182
+ /** Route a navigation request to the right container based on
3183
+ * where it came from:
3184
+ *
3185
+ * - `ctx.windowId` set → replace the managed-window content (the
3186
+ * user is navigating inside a windowed tile; the window stays).
3187
+ * - `ctx.leafId` set AND that leaf is NOT `panel:left` → replace
3188
+ * the leaf's content in place (the user is in a split tile;
3189
+ * the click stays in that tile).
3190
+ * - Otherwise (palette, top-nav shortcuts, click in the
3191
+ * navigator panel, backspace, plain bus event) → primary tile.
3192
+ *
3193
+ * The left nav (`panel:left`) is the only tile whose clicks
3194
+ * intentionally jump out to the primary tile — every other
3195
+ * container is self-contained. */
3196
+ openFromContext(ctx, kind, props = {}) {
3197
+ if (ctx?.windowId && this._windowToLeaf.has(ctx.windowId)) {
3198
+ this.openInWindow(ctx.windowId, kind, props);
3199
+ return;
3200
+ }
3201
+ if (ctx?.leafId) {
3202
+ const tree = this._tree();
3203
+ const leaf = tree.get(ctx.leafId);
3204
+ const k = leaf?.content?.kind;
3205
+ if (leaf && k && k !== "panel:left" && !k.startsWith("panel:")) {
3206
+ this.openInLeaf(ctx.leafId, kind, props);
3207
+ return;
3208
+ }
3209
+ }
3210
+ this.openInPrimary(kind, props);
3211
+ }
3212
+ /** Replace the leaf's **active tab** in place — preserves every
3213
+ * other tab. This is the routing for navigation that originated
3214
+ * INSIDE the tile: clicking a row on a landing page, walking a
3215
+ * breadcrumb segment, Backspace. Outside-the-tile navigation
3216
+ * (top-nav, palette, side-nav) uses `openInPrimary` instead, which
3217
+ * resets the leaf to a single fresh tab.
3218
+ *
3219
+ * For a leaf that has only one tab (the common case before the
3220
+ * tab feature shipped), the behavior is identical to the old
3221
+ * setLeafContent: one tab in, one tab out. */
3222
+ openInLeaf(leafId, kind, props = {}) {
3223
+ const tree = this._tree();
3224
+ const leaf = tree.get(leafId);
3225
+ if (!leaf || leaf.kind !== "leaf") {
3226
+ this.openInPrimary(kind, props);
3227
+ return;
3228
+ }
3229
+ tree.replaceActiveTabContent(leafId, { kind, props }, _tabTitle(kind, props));
3230
+ tree.focus(leafId);
3231
+ this.renderer.render();
3232
+ this._persist();
3233
+ this._notifyChange();
3234
+ }
3235
+ /** Replace a managed window's content in place. Tears down the
3236
+ * previous mount, mounts the new kind into the same contentEl,
3237
+ * and updates the window's title. */
3238
+ openInWindow(winId, kind, props = {}) {
3239
+ const rec = this._windowToLeaf.get(winId);
3240
+ if (!rec) {
3241
+ this.openInPrimary(kind, props);
3242
+ return;
3243
+ }
3244
+ try {
3245
+ rec.mountInfo?.destroy?.();
3246
+ } catch {
3247
+ }
3248
+ rec.contentEl.innerHTML = "";
3249
+ const mountInfo = this.content.mount(
3250
+ kind,
3251
+ rec.contentEl,
3252
+ props,
3253
+ { ...this.ctx, wm: this, windowId: winId }
3254
+ );
3255
+ rec.mountInfo = mountInfo;
3256
+ rec.original = {
3257
+ kind,
3258
+ props: { ...props || {} },
3259
+ title: mountInfo?.title || kind
3260
+ };
3261
+ try {
3262
+ const titleEl = rec.window.element?.querySelector(".twm-managed-window__title");
3263
+ if (titleEl) titleEl.textContent = rec.original.title;
3264
+ if (rec.window) rec.window.title = rec.original.title;
3265
+ } catch {
3266
+ }
3267
+ this._persist();
3268
+ this._notifyChange("window-content-changed");
3269
+ }
3270
+ /** Load content into the primary tile (last-active non-panel leaf).
3271
+ * If only panel tiles exist (or the tree is empty), spawn a new
3272
+ * content leaf and canonicalize so the panels wrap it. */
3273
+ openInPrimary(kind, props = {}) {
3274
+ const tree = this._tree();
3275
+ let leafId = tree.primaryLeafId();
3276
+ let spawned = false;
3277
+ if (!leafId) {
3278
+ leafId = this._spawnContentLeaf(tree);
3279
+ spawned = true;
3280
+ }
3281
+ if (!leafId) return;
3282
+ const leaf = tree.get(leafId);
3283
+ const currentTopNav = leaf?.content?.kind ? this.taxonomy.topNavFor(leaf.content.kind) || null : null;
3284
+ const targetTopNav = this.taxonomy.topNavFor(kind) || kind;
3285
+ const title = _tabTitle(kind, props);
3286
+ tree.swapToPage(
3287
+ leafId,
3288
+ { kind, props, title },
3289
+ currentTopNav,
3290
+ targetTopNav
3291
+ );
3292
+ tree.focus(leafId);
3293
+ if (spawned) this._canonicalize(tree, this.desktops.active());
3294
+ this.renderer.render();
3295
+ this._persist();
3296
+ this._notifyChange();
3297
+ }
3298
+ // ── Split / close / focus / move ────────────────────────────────
3299
+ split(dir) {
3300
+ const tree = this._tree();
3301
+ const focused = tree.focusedLeafId;
3302
+ if (!focused) return;
3303
+ tree.split(focused, dir);
3304
+ this.renderer.render();
3305
+ this._persist();
3306
+ this._notifyChange();
3307
+ }
3308
+ /** Split `leafId` along `dir` and mount `kind`/`props` in the freshly
3309
+ * created sibling — the "open this content in a new split" primitive
3310
+ * behind the code-pane split buttons, the per-pane context menu and
3311
+ * Alt+Shift+H / Alt+Shift+V. The source leaf keeps its content
3312
+ * untouched; the new pane gets a FRESH mount (so e.g. opening a Code
3313
+ * pane in a split never dismounts the original). Returns the new
3314
+ * leaf id, or null if the leaf can't be split. */
3315
+ splitLeafWith(leafId, dir, kind, props = {}, title = "") {
3316
+ const tree = this._tree();
3317
+ const src = leafId ? tree.get(leafId) : null;
3318
+ if (!src || src.kind !== "leaf") return null;
3319
+ const newId = tree.split(leafId, dir);
3320
+ if (!newId) return null;
3321
+ tree.setLeafContent(newId, { kind, props }, title || _tabTitle(kind, props));
3322
+ tree.focus(newId);
3323
+ this.renderer.render();
3324
+ this._persist();
3325
+ this._notifyChange("split-with");
3326
+ return newId;
3327
+ }
3328
+ /** Resolve the focused tile's active content for the keyboard-driven
3329
+ * "open focused tile elsewhere" chords (Alt+T / Alt+N / Alt+Shift+H /
3330
+ * Alt+Shift+V). Returns null for panels, window placeholders and
3331
+ * empty tiles — none of which can be meaningfully duplicated. */
3332
+ _focusedContent() {
3333
+ const tree = this._tree();
3334
+ const id = tree.focusedLeafId;
3335
+ const leaf = id ? tree.get(id) : null;
3336
+ if (!leaf || leaf.kind !== "leaf" || !leaf.content) return null;
3337
+ const kind = leaf.content.kind;
3338
+ if (!kind || kind === PLACEHOLDER_KIND || kind.startsWith("panel:")) return null;
3339
+ return { leafId: id, kind, props: leaf.content.props || {}, title: leaf.title };
3340
+ }
3341
+ /** Alt+T — open the focused tile's content in a new tab on that tile. */
3342
+ openFocusedInTab() {
3343
+ const c = this._focusedContent();
3344
+ if (!c) return;
3345
+ this.openInTabFromContext({ leafId: c.leafId }, c.kind, c.props);
3346
+ }
3347
+ /** Alt+N — open the focused tile's content in a fresh managed window.
3348
+ * Unlike Alt+F (promote) this DUPLICATES: the source tile stays put. */
3349
+ openFocusedInWindow() {
3350
+ const c = this._focusedContent();
3351
+ if (!c) return;
3352
+ this._navigateWindow(c.kind, c.props);
3353
+ }
3354
+ /** Alt+Shift+H / Alt+Shift+V — split the focused tile and open a fresh
3355
+ * copy of its content in the new pane. Falls back to an empty split
3356
+ * when the focused tile has no duplicable content (e.g. a panel). */
3357
+ splitFocusedWith(dir) {
3358
+ const c = this._focusedContent();
3359
+ if (!c) {
3360
+ this.split(dir);
3361
+ return;
3362
+ }
3363
+ this.splitLeafWith(c.leafId, dir, c.kind, c.props, c.title);
3364
+ }
3365
+ closeFocused() {
3366
+ const tree = this._tree();
3367
+ const focusedId = tree.focusedLeafId;
3368
+ if (!focusedId) return;
3369
+ const leaf = tree.get(focusedId);
3370
+ const kind = leaf?.content?.kind;
3371
+ if (kind === PLACEHOLDER_KIND) {
3372
+ const winId = leaf.content?.props?.windowId;
3373
+ const rec = winId ? this._windowToLeaf.get(winId) : null;
3374
+ if (rec) {
3375
+ this._windowToLeaf.delete(winId);
3376
+ try {
3377
+ rec.window.close({ force: true });
3378
+ } catch {
3379
+ }
3380
+ }
3381
+ tree.close(focusedId);
3382
+ } else if (PANEL_KINDS.has(kind)) {
3383
+ const d = this.desktops.active();
3384
+ const side = kind.split(":")[1];
3385
+ d.panels[side] = false;
3386
+ this._canonicalize(tree, d);
3387
+ } else {
3388
+ tree.close(focusedId);
3389
+ }
3390
+ this._canonicalize(tree, this.desktops.active());
3391
+ this.renderer.render();
3392
+ this._persist();
3393
+ this._notifyChange("tile-closed");
3394
+ }
3395
+ /** Close the ACTIVE tab of a leaf (e.g. a Cancel button inside the
3396
+ * tab's content). Mirrors the tab-strip × handler: remove just that
3397
+ * tab, or close the whole tile if it was the last one. Lets content
3398
+ * self-close without nuking sibling tabs. */
3399
+ closeActiveTab(leafId) {
3400
+ const tree = this._tree();
3401
+ const leaf = leafId ? tree.get(leafId) : null;
3402
+ if (!leaf || leaf.kind !== "leaf") {
3403
+ this.closeFocused();
3404
+ return;
3405
+ }
3406
+ const tabs = leaf.tabs || [];
3407
+ if (tabs.length <= 1) {
3408
+ tree.focus(leafId);
3409
+ this.closeFocused();
3410
+ return;
3411
+ }
3412
+ const idx = Math.max(0, Math.min(tabs.length - 1, leaf.activeTabIdx || 0));
3413
+ tree.removeLeafTab(leafId, idx);
3414
+ tree.focus(leafId);
3415
+ this.renderer.render();
3416
+ this._persist();
3417
+ this._notifyChange("twm-tab-close");
3418
+ }
3419
+ /** Insert a new empty content leaf at the centre of the layout
3420
+ * (wraps the current root in an h-split with the content on the
3421
+ * left, 4:1 ratio). If the tree is empty, becomes the root. */
3422
+ _spawnContentLeaf(tree) {
3423
+ const newLeaf = makeLeaf({ content: null, title: "" });
3424
+ if (!tree.rootId) {
3425
+ tree.setRoot(newLeaf);
3426
+ return newLeaf.id;
3427
+ }
3428
+ tree._register(newLeaf);
3429
+ const split = {
3430
+ id: `split-spawn-${Date.now().toString(36)}`,
3431
+ kind: "split",
3432
+ parentId: null,
3433
+ dir: "h",
3434
+ children: [newLeaf.id, tree.rootId],
3435
+ sizes: [4, 1]
3436
+ };
3437
+ tree.nodes.set(split.id, split);
3438
+ const oldRoot = tree.get(tree.rootId);
3439
+ if (oldRoot) oldRoot.parentId = split.id;
3440
+ newLeaf.parentId = split.id;
3441
+ tree.rootId = split.id;
3442
+ tree.focus(newLeaf.id);
3443
+ return newLeaf.id;
3444
+ }
3445
+ focusDir(dir) {
3446
+ if (this._tree().focusDir(dir)) {
3447
+ this.renderer._updateFocusClasses();
3448
+ this._notifyChange();
3449
+ }
3450
+ }
3451
+ /** The `.twm-leaf` DOM element of the currently focused leaf on the
3452
+ * active desktop, or null. The panel-key router consults this to
3453
+ * decide which panel owns the keyboard — it is the single source of
3454
+ * truth for "the selected tile". */
3455
+ focusedLeafEl() {
3456
+ const id = this._tree().focusedLeafId;
3457
+ return id ? this.renderer.leafEl(id) : null;
3458
+ }
3459
+ moveFocused(dir) {
3460
+ if (this._tree().moveDir(dir)) {
3461
+ this.renderer.render();
3462
+ this._persist();
3463
+ this._notifyChange();
3464
+ }
3465
+ }
3466
+ // ── Tile <-> Managed window ─────────────────────────────────────
3467
+ /** Promote the focused tile into a managed window and CLOSE the
3468
+ * source tile — promoting means the content leaves the grid, so the
3469
+ * origin slot is removed rather than left as an empty placeholder.
3470
+ * "Back to tile" re-docks the content into the primary tile. */
3471
+ toggleManagedFocused() {
3472
+ const tree = this._tree();
3473
+ const focused = tree.focused();
3474
+ if (!focused || !focused.content) return;
3475
+ if (PANEL_KINDS.has(focused.content.kind)) return;
3476
+ if (focused.content.kind === PLACEHOLDER_KIND) return;
3477
+ const leafId = focused.id;
3478
+ const desktopIdx = this.desktops.activeIdx;
3479
+ const original = {
3480
+ kind: focused.content.kind,
3481
+ props: { ...focused.content.props || {} },
3482
+ title: focused.title
3483
+ };
3484
+ const contentEl = document.createElement("div");
3485
+ contentEl.className = "twm-window-content";
3486
+ contentEl.style.cssText = "display:flex; flex-direction:column; flex:1; min-width:0; min-height:0; height:100%;";
3487
+ const winId = `twm-mw-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 5)}`;
3488
+ const mountInfo = this.content.mount(
3489
+ original.kind,
3490
+ contentEl,
3491
+ original.props,
3492
+ { ...this.ctx, wm: this, windowId: winId }
3493
+ );
3494
+ const win = new ManagedWindow({
3495
+ id: winId,
3496
+ title: mountInfo?.title || original.title,
3497
+ icon: "web_asset",
3498
+ content: contentEl,
3499
+ canMinimize: true,
3500
+ canMaximize: true,
3501
+ canResize: true,
3502
+ modal: false,
3503
+ onClose: () => this._onManagedWindowClosed(winId, mountInfo)
3504
+ });
3505
+ this._windowToLeaf.set(winId, {
3506
+ // Promoting CLOSES the source tile — the content lives in the
3507
+ // window now, not the tree. leafId is null so "back to tile"
3508
+ // re-docks into the primary tile (see _onManagedWindowClosed).
3509
+ leafId: null,
3510
+ desktopIdx,
3511
+ original,
3512
+ mountInfo,
3513
+ window: win,
3514
+ contentEl,
3515
+ // Set to true by bringBackWindow so the close path knows to
3516
+ // restore the content instead of destroying it.
3517
+ _demoting: false
3518
+ });
3519
+ tree.close(leafId);
3520
+ this._canonicalize(tree, this.desktops.active());
3521
+ this.renderer.render();
3522
+ win.show();
3523
+ this._decorateManagedWindow(win, winId);
3524
+ this._persist();
3525
+ this._notifyChange("window-promoted");
3526
+ }
3527
+ /** Dock the window's content back into the desktop's primary tile
3528
+ * (the source tile was closed on promote), then close the window. */
3529
+ bringBackWindow(windowId) {
3530
+ const rec = this._windowToLeaf.get(windowId);
3531
+ if (!rec) return;
3532
+ rec._demoting = true;
3533
+ try {
3534
+ rec.window.close({ force: true });
3535
+ } catch (err) {
3536
+ console.warn("[wm] bringBack: close failed", err);
3537
+ }
3538
+ }
3539
+ /** Re-home a managed window to another desktop. The window itself
3540
+ * stays on screen (managed windows are global) and leaves no tile
3541
+ * behind on either desktop; only its "home" changes, so bringing it
3542
+ * back will land on the new desktop's primary tile. */
3543
+ moveWindowToDesktop(windowId, targetIdx) {
3544
+ const rec = this._windowToLeaf.get(windowId);
3545
+ if (!rec) return;
3546
+ if (rec.desktopIdx === targetIdx) return;
3547
+ this.desktops.ensureCount(targetIdx + 1);
3548
+ rec.desktopIdx = targetIdx;
3549
+ this._persist();
3550
+ this._notifyChange("window-moved");
3551
+ }
3552
+ _onManagedWindowClosed(winId, mountInfo) {
3553
+ const rec = this._windowToLeaf.get(winId);
3554
+ this._windowToLeaf.delete(winId);
3555
+ if (!rec) return;
3556
+ try {
3557
+ mountInfo?.destroy?.();
3558
+ } catch {
3559
+ }
3560
+ const tree = this.desktops.desktops[rec.desktopIdx]?.tree;
3561
+ if (!tree) return;
3562
+ if (rec._demoting) {
3563
+ let pid = tree.primaryLeafId();
3564
+ let spawned = false;
3565
+ if (!pid) {
3566
+ pid = this._spawnContentLeaf(tree);
3567
+ spawned = true;
3568
+ }
3569
+ if (pid) {
3570
+ tree.appendLeafTab(
3571
+ pid,
3572
+ { kind: rec.original.kind, props: rec.original.props },
3573
+ rec.original.title
3574
+ );
3575
+ tree.focus(pid);
3576
+ if (spawned) this._canonicalize(tree, this.desktops.desktops[rec.desktopIdx]);
3577
+ }
3578
+ }
3579
+ if (this.desktops.active().tree === tree) this.renderer.render();
3580
+ this._persist();
3581
+ this._notifyChange(rec._demoting ? "window-demoted" : "window-closed");
3582
+ }
3583
+ /** Post-show DOM hook: inject a "back to tile" button into the
3584
+ * window chrome and wire a right-click context menu on the topbar. */
3585
+ _decorateManagedWindow(win, winId) {
3586
+ const topbar = win.element?.querySelector?.(".twm-managed-window__topbar");
3587
+ const buttons = topbar?.querySelector?.(".twm-managed-window__buttons");
3588
+ if (!buttons) return;
3589
+ const backBtn = document.createElement("button");
3590
+ backBtn.type = "button";
3591
+ backBtn.className = "twm-managed-window__btn managed-window__btn--demote";
3592
+ backBtn.title = "Back to tile";
3593
+ backBtn.innerHTML = `<span class="material-symbols-outlined" style="font-size:14px">close_fullscreen</span>`;
3594
+ backBtn.addEventListener("click", (e) => {
3595
+ e.stopPropagation();
3596
+ this.bringBackWindow(winId);
3597
+ });
3598
+ const closeBtn = buttons.querySelector(".twm-managed-window__btn--close");
3599
+ if (closeBtn) buttons.insertBefore(backBtn, closeBtn);
3600
+ else buttons.appendChild(backBtn);
3601
+ topbar.addEventListener("contextmenu", (e) => {
3602
+ e.preventDefault();
3603
+ const rec = this._windowToLeaf.get(winId);
3604
+ if (!rec) return;
3605
+ const items = [
3606
+ { label: "Back to tile", icon: "close_fullscreen", action: "back" }
3607
+ ];
3608
+ if (this.desktops.desktops.length > 1) {
3609
+ items.push({ separator: true });
3610
+ for (const [i, d] of this.desktops.desktops.entries()) {
3611
+ if (i === rec.desktopIdx) continue;
3612
+ items.push({
3613
+ label: `Move to desktop ${d.label}`,
3614
+ icon: "sweep",
3615
+ action: `move:${i}`
3616
+ });
3617
+ }
3618
+ }
3619
+ items.push({ separator: true });
3620
+ items.push({
3621
+ label: "Close window",
3622
+ icon: "close",
3623
+ action: "close",
3624
+ danger: true
3625
+ });
3626
+ showContextMenu(e.clientX, e.clientY, items, (action) => {
3627
+ if (action === "back") this.bringBackWindow(winId);
3628
+ else if (action === "close") {
3629
+ try {
3630
+ win.close({ force: true });
3631
+ } catch {
3632
+ }
3633
+ } else if (action?.startsWith?.("move:")) {
3634
+ this.moveWindowToDesktop(winId, Number(action.slice(5)));
3635
+ }
3636
+ });
3637
+ });
3638
+ }
3639
+ // ── Panel-tiles (left nav / right / bottom) ─────────────────────
3640
+ /** Panels are virtual: they live in the active desktop's tree as
3641
+ * leaves with content kinds 'panel:left', 'panel:right',
3642
+ * 'panel:bottom'. Toggling either inserts them at the appropriate
3643
+ * edge (a recursive split) or closes that leaf. */
3644
+ isPanelOpen(side) {
3645
+ const d = this.desktops.active();
3646
+ return !!d.panels[side];
3647
+ }
3648
+ togglePanel(side) {
3649
+ const d = this.desktops.active();
3650
+ d.panels[side] = !d.panels[side];
3651
+ this._canonicalize(this._tree(), d);
3652
+ this.renderer.render();
3653
+ this._persist();
3654
+ this._notifyChange();
3655
+ }
3656
+ /** Rebuild the tree so panel:* leaves sit in the canonical positions:
3657
+ *
3658
+ * v-split
3659
+ * ├ h-split ← the "content row"
3660
+ * │ ├ panel:left (if open)
3661
+ * │ ├ content (whatever non-panel subtree)
3662
+ * │ └ panel:right (if open)
3663
+ * └ panel:bottom (if open, full width below the content row)
3664
+ *
3665
+ * Existing panel leaves are detached + re-attached (same id), so the
3666
+ * renderer cache keeps the mounted content (nav filter state, etc.). */
3667
+ _canonicalize(tree, desktop) {
3668
+ const panel = {};
3669
+ for (const leaf of tree.leaves()) {
3670
+ const kind = leaf.content?.kind;
3671
+ if (kind === "panel:left") panel.left = leaf;
3672
+ else if (kind === "panel:right") panel.right = leaf;
3673
+ else if (kind === "panel:bottom") panel.bottom = leaf;
3674
+ }
3675
+ const detach = (leaf) => {
3676
+ if (!leaf) return;
3677
+ const pid = leaf.parentId;
3678
+ if (!pid) {
3679
+ tree.rootId = null;
3680
+ leaf.parentId = null;
3681
+ return;
3682
+ }
3683
+ const parent = tree.get(pid);
3684
+ if (!parent) return;
3685
+ const idx = parent.children.indexOf(leaf.id);
3686
+ if (idx >= 0) {
3687
+ parent.children.splice(idx, 1);
3688
+ parent.sizes.splice(idx, 1);
3689
+ }
3690
+ leaf.parentId = null;
3691
+ tree._collapse(parent);
3692
+ };
3693
+ detach(panel.left);
3694
+ detach(panel.right);
3695
+ detach(panel.bottom);
3696
+ let seededSide = null;
3697
+ if (!tree.rootId) {
3698
+ seededSide = desktop.panels.left ? "left" : desktop.panels.bottom ? "bottom" : desktop.panels.right ? "right" : null;
3699
+ if (seededSide) {
3700
+ const seed = panel[seededSide] || makeLeaf({
3701
+ content: { kind: `panel:${seededSide}`, props: {} },
3702
+ title: PANEL_TITLES[seededSide] || seededSide
3703
+ });
3704
+ if (panel[seededSide] && PANEL_TITLES[seededSide]) {
3705
+ panel[seededSide].title = PANEL_TITLES[seededSide];
3706
+ }
3707
+ if (!panel[seededSide]) tree._register(seed);
3708
+ if (!tree.nodes.has(seed.id)) tree.nodes.set(seed.id, seed);
3709
+ seed.parentId = null;
3710
+ tree.rootId = seed.id;
3711
+ }
3712
+ }
3713
+ const wrap = (panelLeaf, side) => {
3714
+ if (panelLeaf && !tree.nodes.has(panelLeaf.id)) {
3715
+ tree.nodes.set(panelLeaf.id, panelLeaf);
3716
+ }
3717
+ const leaf = panelLeaf || makeLeaf({
3718
+ content: { kind: `panel:${side}`, props: {} },
3719
+ title: PANEL_TITLES[side] || side
3720
+ });
3721
+ if (panelLeaf && PANEL_TITLES[side] && panelLeaf.title !== PANEL_TITLES[side]) {
3722
+ panelLeaf.title = PANEL_TITLES[side];
3723
+ }
3724
+ if (!panelLeaf) tree._register(leaf);
3725
+ const before = side === "left";
3726
+ const dir = side === "bottom" ? "v" : "h";
3727
+ const children = before ? [leaf.id, tree.rootId] : [tree.rootId, leaf.id];
3728
+ const sizes = side === "bottom" ? [4, 1] : side === "left" ? [1, 4] : [4, 1];
3729
+ const split = {
3730
+ id: `split-panel-${side}-${Math.random().toString(36).slice(2, 7)}`,
3731
+ kind: "split",
3732
+ parentId: null,
3733
+ dir,
3734
+ children,
3735
+ sizes
3736
+ };
3737
+ tree.nodes.set(split.id, split);
3738
+ const oldRoot = tree.get(tree.rootId);
3739
+ if (oldRoot) oldRoot.parentId = split.id;
3740
+ leaf.parentId = split.id;
3741
+ tree.rootId = split.id;
3742
+ };
3743
+ if (desktop.panels.left && seededSide !== "left" && tree.rootId) wrap(panel.left, "left");
3744
+ if (desktop.panels.right && seededSide !== "right" && tree.rootId) wrap(panel.right, "right");
3745
+ if (desktop.panels.bottom && seededSide !== "bottom" && tree.rootId) wrap(panel.bottom, "bottom");
3746
+ const reachable = /* @__PURE__ */ new Set();
3747
+ const walk = (id) => {
3748
+ if (!id || reachable.has(id)) return;
3749
+ reachable.add(id);
3750
+ const n = tree.get(id);
3751
+ if (n?.kind === "split") n.children.forEach(walk);
3752
+ };
3753
+ walk(tree.rootId);
3754
+ for (const id of [...tree.nodes.keys()]) {
3755
+ if (!reachable.has(id)) tree.nodes.delete(id);
3756
+ }
3757
+ }
3758
+ _findPanelLeaf(side) {
3759
+ const kind = `panel:${side}`;
3760
+ for (const leaf of this._tree().leaves()) {
3761
+ if (leaf.content?.kind === kind) return leaf;
3762
+ }
3763
+ return null;
3764
+ }
3765
+ // ── Desktops ────────────────────────────────────────────────────
3766
+ switchDesktop(idx) {
3767
+ this.desktops.ensureCount(idx + 1);
3768
+ if (!this.desktops.switchTo(idx)) return;
3769
+ const d = this.desktops.active();
3770
+ this._canonicalize(d.tree, d);
3771
+ this.renderer.tree = d.tree;
3772
+ this.renderer.render();
3773
+ this._persist();
3774
+ this._notifyChange();
3775
+ }
3776
+ addDesktop() {
3777
+ this.desktops.addDesktop();
3778
+ this._notifyChange();
3779
+ this._persist();
3780
+ }
3781
+ /** Step to the previous (-1) or next (+1) existing desktop, wrapping
3782
+ * around. No-op with a single desktop. Iterates only desktops that
3783
+ * already exist — use switchDesktop(idx) to create-on-demand. */
3784
+ cycleDesktop(dir) {
3785
+ const m = this.desktops;
3786
+ const n = m.desktops.length;
3787
+ if (n <= 1) return;
3788
+ const next = (m.activeIdx + (dir < 0 ? -1 : 1) + n) % n;
3789
+ this.switchDesktop(next);
3790
+ }
3791
+ /** Cycle the focused tile's main-panel tabs (Ctrl+Tab / Ctrl+Shift+Tab).
3792
+ * Wraps; no-op when the focused leaf has 0 or 1 tabs. */
3793
+ cycleFocusedTab(dir) {
3794
+ const tree = this._tree();
3795
+ const leafId = tree.focusedLeafId;
3796
+ const leaf = leafId ? tree.get(leafId) : null;
3797
+ const tabs = leaf && Array.isArray(leaf.tabs) ? leaf.tabs : [];
3798
+ if (tabs.length <= 1) return;
3799
+ const cur = Math.max(0, Math.min(tabs.length - 1, leaf.activeTabIdx || 0));
3800
+ const next = (cur + (dir < 0 ? -1 : 1) + tabs.length) % tabs.length;
3801
+ this._leafTabAction(leafId, "switch", { idx: next });
3802
+ }
3803
+ removeDesktop(idx) {
3804
+ const m = this.desktops;
3805
+ if (m.desktops.length <= 1) return false;
3806
+ if (idx < 0 || idx >= m.desktops.length) return false;
3807
+ m.desktops.splice(idx, 1);
3808
+ if (m.activeIdx >= m.desktops.length) m.activeIdx = m.desktops.length - 1;
3809
+ this.renderer.tree = m.active().tree;
3810
+ this.renderer.render();
3811
+ this._persist();
3812
+ this._notifyChange();
3813
+ return true;
3814
+ }
3815
+ moveFocusedToDesktop(idx) {
3816
+ const tree = this._tree();
3817
+ const focused = tree.focused();
3818
+ if (!focused || !focused.content) return;
3819
+ const payload = { kind: focused.content.kind, props: focused.content.props, title: focused.title };
3820
+ this.desktops.ensureCount(idx + 1);
3821
+ const target = this.desktops.desktops[idx];
3822
+ const targetTree = target.tree;
3823
+ const primary = targetTree.primaryLeafId();
3824
+ if (primary) targetTree.setLeafContent(primary, { kind: payload.kind, props: payload.props }, payload.title);
3825
+ tree.close(focused.id);
3826
+ if (!tree.rootId) tree.setRoot(makeLeaf(this._rootLeaf()));
3827
+ this.renderer.render();
3828
+ this._persist();
3829
+ this._notifyChange();
3830
+ }
3831
+ // ── Internals ───────────────────────────────────────────────────
3832
+ _tree() {
3833
+ return this.desktops.active().tree;
3834
+ }
3835
+ _leafAction(leafId, action) {
3836
+ const tree = this._tree();
3837
+ tree.focus(leafId);
3838
+ if (action === "close") this.closeFocused();
3839
+ else if (action === "promote") this.toggleManagedFocused();
3840
+ else if (action === "split-h") this.split("h");
3841
+ else if (action === "split-v") this.split("v");
3842
+ }
3843
+ /** Tab-strip event dispatcher. The renderer fires actions
3844
+ * (`switch` / `close` / `move` / `open-menu`) and the WM
3845
+ * translates them into tile-tree mutations + a re-render. */
3846
+ _leafTabAction(leafId, action, data = {}) {
3847
+ const tree = this._tree();
3848
+ const leaf = tree.get(leafId);
3849
+ if (!leaf || leaf.kind !== "leaf") return;
3850
+ if (action === "switch") {
3851
+ tree.setActiveLeafTab(leafId, data.idx);
3852
+ tree.focus(leafId);
3853
+ this.renderer.render();
3854
+ this._persist();
3855
+ this._notifyChange("tab-switch");
3856
+ return;
3857
+ }
3858
+ if (action === "close") {
3859
+ const tabs = leaf.tabs || [];
3860
+ if (tabs.length <= 1) {
3861
+ tree.focus(leafId);
3862
+ this.closeFocused();
3863
+ return;
3864
+ }
3865
+ tree.removeLeafTab(leafId, data.idx);
3866
+ tree.focus(leafId);
3867
+ this.renderer.render();
3868
+ this._persist();
3869
+ this._notifyChange("twm-tab-close");
3870
+ return;
3871
+ }
3872
+ if (action === "move") {
3873
+ tree.moveLeafTab(leafId, data.from, data.to);
3874
+ this.renderer.render();
3875
+ this._persist();
3876
+ this._notifyChange("tab-move");
3877
+ return;
3878
+ }
3879
+ if (action === "menu") {
3880
+ this._showTabContextMenu(leafId, data.idx, data.x, data.y);
3881
+ return;
3882
+ }
3883
+ if (action === "close-others") {
3884
+ tree.closeOtherTabs(leafId, data.idx);
3885
+ this.renderer.render();
3886
+ this._persist();
3887
+ this._notifyChange("tab-close-others");
3888
+ return;
3889
+ }
3890
+ if (action === "close-right") {
3891
+ tree.closeTabsAfter(leafId, data.idx);
3892
+ this.renderer.render();
3893
+ this._persist();
3894
+ this._notifyChange("tab-close-right");
3895
+ return;
3896
+ }
3897
+ if (action === "close-left") {
3898
+ tree.closeTabsBefore(leafId, data.idx);
3899
+ this.renderer.render();
3900
+ this._persist();
3901
+ this._notifyChange("tab-close-left");
3902
+ return;
3903
+ }
3904
+ if (action === "open-menu") {
3905
+ try {
3906
+ this.ctx?.onTileTabMenu?.(leafId, data.x, data.y);
3907
+ } catch (err) {
3908
+ console.warn("[wm] tab menu hook failed", err);
3909
+ }
3910
+ }
3911
+ }
3912
+ /** Central navigation entry point. Every nav action — left menu,
3913
+ * breadcrumb, landing-row click, in-tile reference, panel
3914
+ * reference, entity-to-entity — should route through this so the
3915
+ * one holistic routing concept lives in one place.
3916
+ *
3917
+ * A link declares its target as TWO axes:
3918
+ *
3919
+ * `opts.dest` — WHERE the content lands:
3920
+ * 'main' → the primary content tile (default).
3921
+ * The nav panel always uses this.
3922
+ * 'origin' → the tile/window the click came from
3923
+ * (needs `ctx`). Breadcrumbs use this so
3924
+ * a segment click stays in the current
3925
+ * tile (and inside a floating window when
3926
+ * windowed).
3927
+ * 'window' → a fresh managed window.
3928
+ * `opts.newTab` — HOW it lands in that destination:
3929
+ * false → replace the destination's active tab
3930
+ * (default). true → append a NEW tab.
3931
+ * Ignored for 'window' (one window = one
3932
+ * content).
3933
+ *
3934
+ * `opts.ctx` — the caller's mount context (`leafId`,
3935
+ * `windowId`); required for `dest:'origin'`.
3936
+ * `opts.transient` — the appended tab is not persisted/restored
3937
+ * (e.g. an add-row form). Only meaningful with
3938
+ * `newTab:true`.
3939
+ *
3940
+ * Back-compat: the legacy `opts.target` enum still works and maps
3941
+ * onto the axes — 'auto'→origin, 'tab'→origin+newTab,
3942
+ * 'primary'→main, 'window'→window. Prefer the two-axis form.
3943
+ *
3944
+ * The lower-level primitives (`openFromContext`,
3945
+ * `openInTabFromContext`, `openInTabInPrimary`, `openInPrimary`,
3946
+ * `openInWindow`) stay internal; callers prefer `wm.navigate(...)`. */
3947
+ navigate(kind, props = {}, opts = {}) {
3948
+ const { ctx = null, transient = false } = opts;
3949
+ let { dest = "main", newTab = false } = opts;
3950
+ if (opts.target != null) {
3951
+ switch (opts.target) {
3952
+ case "window":
3953
+ dest = "window";
3954
+ newTab = false;
3955
+ break;
3956
+ case "primary":
3957
+ dest = "main";
3958
+ newTab = false;
3959
+ break;
3960
+ case "tab":
3961
+ dest = "origin";
3962
+ newTab = true;
3963
+ break;
3964
+ case "split-h":
3965
+ dest = "split-h";
3966
+ break;
3967
+ case "split-v":
3968
+ dest = "split-v";
3969
+ break;
3970
+ case "auto":
3971
+ default:
3972
+ dest = "origin";
3973
+ newTab = false;
3974
+ break;
3975
+ }
3976
+ }
3977
+ if (dest === "split-h" || dest === "split-v") {
3978
+ const dir = dest === "split-h" ? "h" : "v";
3979
+ const leafId = ctx?.leafId || this._tree().focusedLeafId;
3980
+ return this.splitLeafWith(leafId, dir, kind, props, _tabTitle(kind, props));
3981
+ }
3982
+ if (dest === "window") return this._navigateWindow(kind, props);
3983
+ if (dest === "main") {
3984
+ return newTab ? this.openInTabInPrimary(kind, props, transient) : this.openInPrimary(kind, props);
3985
+ }
3986
+ return newTab ? this._navigateTab(ctx, kind, props, transient) : this._navigateAuto(ctx, kind, props);
3987
+ }
3988
+ _navigateAuto(ctx, kind, props) {
3989
+ if (ctx?.windowId && this._windowToLeaf.has(ctx.windowId)) {
3990
+ this.openInWindow(ctx.windowId, kind, props);
3991
+ return;
3992
+ }
3993
+ if (ctx?.leafId) {
3994
+ const tree = this._tree();
3995
+ const leaf = tree.get(ctx.leafId);
3996
+ const k = leaf?.content?.kind;
3997
+ if (leaf && k && !k.startsWith("panel:")) {
3998
+ this.openInLeaf(ctx.leafId, kind, props);
3999
+ return;
4000
+ }
4001
+ }
4002
+ this.openInPrimary(kind, props);
4003
+ }
4004
+ _navigateTab(ctx, kind, props, transient = false) {
4005
+ if (ctx?.windowId && this._windowToLeaf.has(ctx.windowId)) {
4006
+ this.openInWindow(ctx.windowId, kind, props);
4007
+ return;
4008
+ }
4009
+ this.openInTabFromContext(ctx || {}, kind, props, transient);
4010
+ }
4011
+ /** Spawn a fresh ManagedWindow with the requested content. No
4012
+ * source leaf — closing the window just disposes the content. */
4013
+ _navigateWindow(kind, props) {
4014
+ const winId = `twm-mw-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 5)}`;
4015
+ const contentEl = document.createElement("div");
4016
+ contentEl.className = "twm-window-content";
4017
+ contentEl.style.cssText = "display:flex; flex-direction:column; flex:1; min-width:0; min-height:0; height:100%;";
4018
+ const title = _tabTitle(kind, props);
4019
+ const mountInfo = this.content.mount(
4020
+ kind,
4021
+ contentEl,
4022
+ props,
4023
+ { ...this.ctx, wm: this, windowId: winId }
4024
+ );
4025
+ const win = new ManagedWindow({
4026
+ id: winId,
4027
+ title: mountInfo?.title || title,
4028
+ icon: "web_asset",
4029
+ content: contentEl,
4030
+ canMinimize: true,
4031
+ canMaximize: true,
4032
+ canResize: true,
4033
+ modal: false,
4034
+ onClose: () => this._onManagedWindowClosed(winId, mountInfo)
4035
+ });
4036
+ this._windowToLeaf.set(winId, {
4037
+ leafId: null,
4038
+ desktopIdx: this.desktops.activeIdx,
4039
+ original: {
4040
+ kind,
4041
+ props: { ...props || {} },
4042
+ title: mountInfo?.title || title
4043
+ },
4044
+ mountInfo,
4045
+ window: win,
4046
+ contentEl,
4047
+ _demoting: false
4048
+ });
4049
+ win.show();
4050
+ const _n = this._windowToLeaf.size;
4051
+ if (_n > 1 && typeof win._applyPosition === "function") {
4052
+ const step = (_n - 1) % 6 * 28;
4053
+ win.x = (win.x || 0) + step;
4054
+ win.y = (win.y || 0) + step;
4055
+ win._applyPosition();
4056
+ }
4057
+ this._decorateManagedWindow(win, winId);
4058
+ this._notifyChange("window-spawned");
4059
+ }
4060
+ /** Browser-style tab context menu. Items reflect the leaf's current
4061
+ * tab list — "Close others" is hidden when only one tab is open,
4062
+ * "Close to the right / left" are hidden at the edges. The actual
4063
+ * mutations route back through `_leafTabAction` so persistence +
4064
+ * notify stay in one place. */
4065
+ _showTabContextMenu(leafId, idx, x, y) {
4066
+ const tree = this._tree();
4067
+ const leaf = tree.get(leafId);
4068
+ if (!leaf || leaf.kind !== "leaf") return;
4069
+ const tabs = leaf.tabs || [];
4070
+ if (tabs.length === 0) return;
4071
+ const items = [
4072
+ { label: "Close tab", icon: "close", action: "close" }
4073
+ ];
4074
+ if (tabs.length > 1) {
4075
+ items.push({ label: "Close other tabs", icon: "tab_close", action: "close-others" });
4076
+ }
4077
+ if (idx < tabs.length - 1) {
4078
+ items.push({
4079
+ label: "Close tabs to the right",
4080
+ icon: "chevron_right",
4081
+ action: "close-right"
4082
+ });
4083
+ }
4084
+ if (idx > 0) {
4085
+ items.push({
4086
+ label: "Close tabs to the left",
4087
+ icon: "chevron_left",
4088
+ action: "close-left"
4089
+ });
4090
+ }
4091
+ showContextMenu(x, y, items, (action) => {
4092
+ this._leafTabAction(leafId, action, { idx });
4093
+ });
4094
+ }
4095
+ /** Open content in a new tab on the leaf where the call originated.
4096
+ * Mirrors `openFromContext` (windowed / split-leaf / primary
4097
+ * routing) but uses `appendLeafTab` so the existing content
4098
+ * stays in place as a tab. */
4099
+ openInTabFromContext(ctx, kind, props = {}, transient = false) {
4100
+ if (ctx?.windowId && this._windowToLeaf.has(ctx.windowId)) {
4101
+ this.openInWindow(ctx.windowId, kind, props);
4102
+ return;
4103
+ }
4104
+ const tree = this._tree();
4105
+ let leafId = ctx?.leafId;
4106
+ if (!leafId) leafId = tree.primaryLeafId();
4107
+ const leaf = leafId ? tree.get(leafId) : null;
4108
+ const k = leaf?.content?.kind;
4109
+ if (!leaf || !leafId || k && k.startsWith("panel:")) {
4110
+ this.openInPrimary(kind, props);
4111
+ return;
4112
+ }
4113
+ tree.appendLeafTab(leafId, { kind, props }, _tabTitle(kind, props), { transient });
4114
+ tree.focus(leafId);
4115
+ this.renderer.render();
4116
+ this._persist();
4117
+ this._notifyChange("tab-open");
4118
+ }
4119
+ /** Open content as a NEW tab in the primary content tile, regardless
4120
+ * of where the call came from (the `dest:'main', newTab:true` path).
4121
+ * Unlike `openInTabFromContext`, this never falls back to a replace:
4122
+ * it targets the primary leaf directly and always appends, so a
4123
+ * click from outside the tile system (e.g. the bottom-panel
4124
+ * "Add row" button, which passes no ctx) reliably lands as a sibling
4125
+ * tab in the main tile rather than swapping its content. */
4126
+ openInTabInPrimary(kind, props = {}, transient = false) {
4127
+ const tree = this._tree();
4128
+ const leafId = tree.primaryLeafId();
4129
+ if (!leafId) {
4130
+ this._navigateWindow(kind, props);
4131
+ return;
4132
+ }
4133
+ tree.appendLeafTab(leafId, { kind, props }, _tabTitle(kind, props), { transient });
4134
+ tree.focus(leafId);
4135
+ this.renderer.render();
4136
+ this._persist();
4137
+ this._notifyChange("tab-open");
4138
+ }
4139
+ };
4140
+ function _tabTitle(kind, props) {
4141
+ if (props && typeof props.label === "string" && props.label) return props.label;
4142
+ if (props && (typeof props.id === "string" || typeof props.id === "number") && String(props.id)) return String(props.id);
4143
+ return kind || "";
4144
+ }
4145
+
4146
+ // src/tiling/tile_tab_menu.js
4147
+ var PAGE_SIZE = 10;
4148
+ function openTileTabMenu({
4149
+ x,
4150
+ y,
4151
+ leafKind,
4152
+ onPick,
4153
+ api,
4154
+ taxonomy,
4155
+ entities
4156
+ }) {
4157
+ if (!taxonomy || !entities) {
4158
+ console.error("[tile-tab-menu] taxonomy + entity catalog are required");
4159
+ return () => {
4160
+ };
4161
+ }
4162
+ const topNav = taxonomy.topNavFor(leafKind) || leafKind;
4163
+ const sources = taxonomy.sourcesFor(topNav).map((nav) => entities.get(nav)).filter(Boolean);
4164
+ const overlay = document.createElement("div");
4165
+ overlay.className = "twm-tile-tabmenu-overlay";
4166
+ overlay.innerHTML = `
4167
+ <div class="twm-tile-tabmenu" role="dialog" aria-label="Open in new tab">
4168
+ <header class="twm-tile-tabmenu__head">
4169
+ <span class="material-symbols-outlined twm-tile-tabmenu__head-icon">tab</span>
4170
+ <span class="twm-tile-tabmenu__head-label">Open in new tab</span>
4171
+ <button type="button" class="twm-tile-tabmenu__close"
4172
+ data-action="close"
4173
+ aria-label="Close">\xD7</button>
4174
+ </header>
4175
+ <div class="twm-tile-tabmenu__search">
4176
+ <span class="material-symbols-outlined">search</span>
4177
+ <input type="search"
4178
+ data-role="search"
4179
+ placeholder="Filter rows across all sections\u2026"
4180
+ autocomplete="off" />
4181
+ </div>
4182
+ <div class="twm-tile-tabmenu__body" data-role="body">
4183
+ <div class="twm-tile-tabmenu__loading">Loading\u2026</div>
4184
+ </div>
4185
+ </div>
4186
+ `;
4187
+ document.body.appendChild(overlay);
4188
+ const panel = overlay.querySelector(".twm-tile-tabmenu");
4189
+ panel.style.position = "fixed";
4190
+ panel.style.left = `${Math.max(8, Math.min(window.innerWidth - 360, x))}px`;
4191
+ panel.style.top = `${Math.max(8, Math.min(window.innerHeight - 480, y - 480 + 22))}px`;
4192
+ let alive = true;
4193
+ let _query = "";
4194
+ let _cursor = 0;
4195
+ let _visibleItems = [];
4196
+ const sectionState = new Map(sources.map((s) => [s.navKind, { rows: [], page: 0, shaped: [] }]));
4197
+ const close = () => {
4198
+ if (!alive) return;
4199
+ alive = false;
4200
+ document.removeEventListener("mousedown", onOutside, true);
4201
+ document.removeEventListener("keydown", onKey, true);
4202
+ overlay.remove();
4203
+ };
4204
+ const onOutside = (ev) => {
4205
+ if (!overlay.contains(ev.target)) close();
4206
+ };
4207
+ const moveCursor = (delta) => {
4208
+ if (_visibleItems.length === 0) return;
4209
+ _cursor = Math.max(0, Math.min(_visibleItems.length - 1, _cursor + delta));
4210
+ _paintCursor();
4211
+ };
4212
+ const jumpCursor = (toEnd) => {
4213
+ if (_visibleItems.length === 0) return;
4214
+ _cursor = toEnd ? _visibleItems.length - 1 : 0;
4215
+ _paintCursor();
4216
+ };
4217
+ const pickCursor = () => {
4218
+ const cur = _visibleItems[_cursor];
4219
+ if (!cur) return;
4220
+ close();
4221
+ try {
4222
+ onPick?.(cur.navKind, cur.shaped);
4223
+ } catch (err) {
4224
+ console.warn("[tile-tab-menu] pick failed", err);
4225
+ }
4226
+ };
4227
+ const onKey = (ev) => {
4228
+ if (!alive) return;
4229
+ if (ev.isComposing) return;
4230
+ switch (ev.key) {
4231
+ case "Escape":
4232
+ ev.preventDefault();
4233
+ close();
4234
+ return;
4235
+ case "ArrowDown":
4236
+ ev.preventDefault();
4237
+ moveCursor(1);
4238
+ return;
4239
+ case "ArrowUp":
4240
+ ev.preventDefault();
4241
+ moveCursor(-1);
4242
+ return;
4243
+ case "Home":
4244
+ ev.preventDefault();
4245
+ jumpCursor(false);
4246
+ return;
4247
+ case "End":
4248
+ ev.preventDefault();
4249
+ jumpCursor(true);
4250
+ return;
4251
+ case "Enter":
4252
+ ev.preventDefault();
4253
+ pickCursor();
4254
+ return;
4255
+ case "PageDown": {
4256
+ if (_query) return;
4257
+ ev.preventDefault();
4258
+ const cur = _visibleItems[_cursor];
4259
+ const st = cur ? sectionState.get(cur.navKind) : null;
4260
+ if (st) {
4261
+ const pages = _pageCountFor(cur.navKind);
4262
+ if (st.page < pages - 1) {
4263
+ st.page += 1;
4264
+ _render();
4265
+ }
4266
+ }
4267
+ return;
4268
+ }
4269
+ case "PageUp": {
4270
+ if (_query) return;
4271
+ ev.preventDefault();
4272
+ const cur = _visibleItems[_cursor];
4273
+ const st = cur ? sectionState.get(cur.navKind) : null;
4274
+ if (st && st.page > 0) {
4275
+ st.page -= 1;
4276
+ _render();
4277
+ }
4278
+ return;
4279
+ }
4280
+ }
4281
+ };
4282
+ document.addEventListener("mousedown", onOutside, true);
4283
+ document.addEventListener("keydown", onKey, true);
4284
+ overlay.querySelector('[data-action="close"]').addEventListener("click", close);
4285
+ const searchInput = overlay.querySelector('[data-role="search"]');
4286
+ searchInput.addEventListener("input", () => {
4287
+ _query = searchInput.value.trim().toLowerCase();
4288
+ for (const st of sectionState.values()) st.page = 0;
4289
+ _cursor = 0;
4290
+ _render();
4291
+ });
4292
+ const bodyEl = overlay.querySelector('[data-role="body"]');
4293
+ const _pageCountFor = (navKind) => {
4294
+ const st = sectionState.get(navKind);
4295
+ if (!st) return 1;
4296
+ const total = (_query ? st.shaped.filter((s) => _matchesShaped(s, _query)) : st.shaped).length;
4297
+ return Math.max(1, Math.ceil(total / PAGE_SIZE));
4298
+ };
4299
+ const _render = () => {
4300
+ if (!alive) return;
4301
+ _visibleItems = [];
4302
+ const sections = [];
4303
+ for (const src of sources) {
4304
+ const st = sectionState.get(src.navKind);
4305
+ const meta = taxonomy.meta(src.navKind);
4306
+ const all = st.shaped;
4307
+ const filtered = _query ? all.filter((s) => _matchesShaped(s, _query)) : all;
4308
+ const total = filtered.length;
4309
+ let view;
4310
+ let page = 0;
4311
+ let pages = 1;
4312
+ if (_query) {
4313
+ view = filtered;
4314
+ } else {
4315
+ pages = Math.max(1, Math.ceil(total / PAGE_SIZE));
4316
+ page = Math.min(st.page, pages - 1);
4317
+ const start = page * PAGE_SIZE;
4318
+ view = filtered.slice(start, start + PAGE_SIZE);
4319
+ }
4320
+ for (const shaped of view) {
4321
+ _visibleItems.push({ navKind: src.navKind, shaped });
4322
+ }
4323
+ sections.push({ src, meta, view, total, page, pages });
4324
+ }
4325
+ if (_cursor >= _visibleItems.length) {
4326
+ _cursor = Math.max(0, _visibleItems.length - 1);
4327
+ }
4328
+ let flatIdx = 0;
4329
+ bodyEl.innerHTML = sections.map((s) => {
4330
+ const items = s.view.map((shaped) => {
4331
+ const idx = flatIdx++;
4332
+ const label = shaped.label || shaped.id || "";
4333
+ const hint = shaped.hint || "";
4334
+ return `
4335
+ <li class="twm-tile-tabmenu__item${idx === _cursor ? " twm-tile-tabmenu__item--cursor" : ""}"
4336
+ data-flat-idx="${idx}">
4337
+ <span class="twm-tile-tabmenu__item-label">${_esc7(label)}</span>
4338
+ ${hint ? `<span class="twm-tile-tabmenu__item-hint">${_esc7(hint)}</span>` : ""}
4339
+ </li>
4340
+ `;
4341
+ }).join("");
4342
+ return `
4343
+ <section class="twm-tile-tabmenu__section"
4344
+ data-section="${_esc7(s.src.navKind)}">
4345
+ <header class="twm-tile-tabmenu__section-head">
4346
+ <span class="material-symbols-outlined">${_esc7(s.meta?.icon || "arrow_right")}</span>
4347
+ <span class="twm-tile-tabmenu__section-label">${_esc7(s.meta?.label || s.src.navKind)}</span>
4348
+ <span class="twm-tile-tabmenu__section-count">${s.total}</span>
4349
+ </header>
4350
+ ${s.view.length === 0 ? `
4351
+ <div class="twm-tile-tabmenu__empty">${_query ? "No matches." : "\u2014 none \u2014"}</div>
4352
+ ` : `<ul class="twm-tile-tabmenu__list">${items}</ul>`}
4353
+ ${!_query && s.pages > 1 ? `
4354
+ <div class="twm-tile-tabmenu__pager">
4355
+ <button type="button" data-action="prev"
4356
+ data-section="${_esc7(s.src.navKind)}"
4357
+ ${s.page === 0 ? "disabled" : ""}>&lt;</button>
4358
+ <span>${s.page + 1} / ${s.pages}</span>
4359
+ <button type="button" data-action="next"
4360
+ data-section="${_esc7(s.src.navKind)}"
4361
+ ${s.page >= s.pages - 1 ? "disabled" : ""}>&gt;</button>
4362
+ </div>
4363
+ ` : ""}
4364
+ </section>
4365
+ `;
4366
+ }).join("");
4367
+ bodyEl.querySelectorAll(".twm-tile-tabmenu__item").forEach((li) => {
4368
+ const idx = Number(li.dataset.flatIdx);
4369
+ li.addEventListener("mousemove", () => {
4370
+ if (_cursor !== idx) {
4371
+ _cursor = idx;
4372
+ _paintCursor();
4373
+ }
4374
+ });
4375
+ li.addEventListener("click", () => {
4376
+ _cursor = idx;
4377
+ pickCursor();
4378
+ });
4379
+ });
4380
+ bodyEl.querySelectorAll('[data-action="prev"], [data-action="next"]').forEach((btn) => {
4381
+ btn.addEventListener("click", () => {
4382
+ const navKind = btn.dataset.section;
4383
+ const st = sectionState.get(navKind);
4384
+ if (!st) return;
4385
+ st.page += btn.dataset.action === "next" ? 1 : -1;
4386
+ _render();
4387
+ });
4388
+ });
4389
+ };
4390
+ const _paintCursor = () => {
4391
+ bodyEl.querySelectorAll(".twm-tile-tabmenu__item").forEach((li) => {
4392
+ const idx = Number(li.dataset.flatIdx);
4393
+ const on = idx === _cursor;
4394
+ li.classList.toggle("twm-tile-tabmenu__item--cursor", on);
4395
+ if (on) li.scrollIntoView({ block: "nearest" });
4396
+ });
4397
+ };
4398
+ (async () => {
4399
+ await Promise.all(sources.map(async (src) => {
4400
+ const st = sectionState.get(src.navKind);
4401
+ if (!st) return;
4402
+ st.rows = await entities.loadOne(src.navKind, api);
4403
+ st.shaped = entities.shapeRows(src.navKind, st.rows);
4404
+ }));
4405
+ _render();
4406
+ if (_visibleItems.length > 0) {
4407
+ _cursor = 0;
4408
+ _paintCursor();
4409
+ }
4410
+ })();
4411
+ searchInput.focus();
4412
+ return close;
4413
+ }
4414
+ function _matchesShaped(shaped, query) {
4415
+ if (!shaped) return false;
4416
+ const id = String(shaped.id ?? "").toLowerCase();
4417
+ const label = String(shaped.label ?? "").toLowerCase();
4418
+ const hint = String(shaped.hint ?? "").toLowerCase();
4419
+ return id.includes(query) || label.includes(query) || hint.includes(query);
4420
+ }
4421
+ function _esc7(s) {
4422
+ return String(s ?? "").replace(/[&<>"']/g, (c) => ({
4423
+ "&": "&amp;",
4424
+ "<": "&lt;",
4425
+ ">": "&gt;",
4426
+ '"': "&quot;",
4427
+ "'": "&#39;"
4428
+ })[c]);
4429
+ }
4430
+
4431
+ // src/tiling/shell.js
4432
+ async function createShell({
4433
+ root,
4434
+ taxonomy,
4435
+ entities,
4436
+ content,
4437
+ host = null,
4438
+ api = null,
4439
+ eventBus = null,
4440
+ logger = null,
4441
+ tableStore = null,
4442
+ events = {},
4443
+ rootCrumb = null,
4444
+ palette: paletteCfg = {},
4445
+ chrome = {}
4446
+ } = {}) {
4447
+ if (!root || typeof root.appendChild !== "function") {
4448
+ throw new TypeError("createShell: `root` must be an element");
4449
+ }
4450
+ if (!taxonomy) throw new Error("createShell: a taxonomy is required");
4451
+ if (!entities) throw new Error("createShell: an entity catalog is required");
4452
+ const log = logger ?? { info() {
4453
+ }, warn() {
4454
+ }, error() {
4455
+ }, debug() {
4456
+ } };
4457
+ const registry = createContentRegistry(content);
4458
+ let wm = null;
4459
+ let topNavEl = null;
4460
+ let desktopsEl = null;
4461
+ let toggles = null;
4462
+ const syncChrome = () => {
4463
+ if (!wm) return;
4464
+ syncPanelToggles(toggles, wm);
4465
+ syncDesktopBar(desktopsEl, wm);
4466
+ syncTopNav(topNavEl, wm);
4467
+ };
4468
+ wm = new WindowManager({
4469
+ rootEl: root,
4470
+ content: registry,
4471
+ api,
4472
+ eventBus,
4473
+ host,
4474
+ taxonomy,
4475
+ events,
4476
+ // `ctx` is the delivery vehicle for leaf-mounted chrome: tile_renderer
4477
+ // spreads it into every content factory, which is how the breadcrumb
4478
+ // gets `taxonomy` + `rootCrumb` without a content factory knowing they
4479
+ // exist. The two `onTile*Menu` keys are read with OPTIONAL CHAINING in
4480
+ // tile_renderer — drop one and right-click silently does nothing.
4481
+ ctx: {
4482
+ api,
4483
+ eventBus,
4484
+ host,
4485
+ tableStore,
4486
+ taxonomy,
4487
+ entities,
4488
+ rootCrumb,
4489
+ events,
4490
+ onTileContextMenu: (leafId, x, y) => _tileContextMenu(wm, leafId, x, y),
4491
+ onTileTabMenu: (leafId, x, y) => _tileTabMenu(wm, leafId, x, y)
4492
+ },
4493
+ onChange: () => syncChrome()
4494
+ });
4495
+ const palette = createCommandPalette({
4496
+ wm,
4497
+ api,
4498
+ taxonomy,
4499
+ catalog: entities,
4500
+ ...paletteCfg
4501
+ });
4502
+ installKeymap({ wm, palette });
4503
+ const paletteBtn = mountPaletteButton(chrome.paletteButton, palette);
4504
+ topNavEl = mountTopNav(chrome.topNav, taxonomy, wm);
4505
+ desktopsEl = mountDesktopBar(chrome.desktops, wm);
4506
+ toggles = bindPanelToggles(chrome.panelToggles, wm);
4507
+ eventBus?.on?.("wm:changed", syncChrome);
4508
+ await wm.load();
4509
+ syncChrome();
4510
+ log.info?.("shell mounted");
4511
+ return Object.freeze({
4512
+ wm,
4513
+ palette,
4514
+ taxonomy,
4515
+ entities,
4516
+ root,
4517
+ content: registry,
4518
+ chrome: Object.freeze({
4519
+ paletteButtonEl: paletteBtn,
4520
+ topNavEl,
4521
+ desktopsEl,
4522
+ panelToggles: toggles
4523
+ }),
4524
+ dispose: () => {
4525
+ try {
4526
+ eventBus?.off?.("wm:changed", syncChrome);
4527
+ } catch {
4528
+ }
4529
+ }
4530
+ });
4531
+ }
4532
+ function mountTopNav(hostEl, taxonomy, wm) {
4533
+ if (!hostEl) return null;
4534
+ hostEl.innerHTML = "";
4535
+ hostEl.classList.add("twm-top-nav");
4536
+ for (const k of taxonomy.topNavEntries()) {
4537
+ const btn = document.createElement("button");
4538
+ btn.className = "twm-top-nav__btn twm-has-tooltip";
4539
+ btn.dataset.kind = k.kind;
4540
+ btn.dataset.tooltip = k.label;
4541
+ btn.dataset.tooltipPlacement = "bottom";
4542
+ btn.setAttribute("aria-label", k.label);
4543
+ btn.innerHTML = `
4544
+ <span class="material-symbols-outlined twm-top-nav__icon">${k.icon}</span>
4545
+ <span class="twm-top-nav__label">${k.label}</span>
4546
+ `;
4547
+ hostEl.appendChild(btn);
4548
+ }
4549
+ hostEl.addEventListener("click", (e) => {
4550
+ const btn = e.target.closest("[data-kind]");
4551
+ if (!btn) return;
4552
+ wm.openInPrimary(btn.dataset.kind);
4553
+ });
4554
+ return hostEl;
4555
+ }
4556
+ function syncTopNav(hostEl, wm) {
4557
+ if (!hostEl) return;
4558
+ const tree = wm.desktops.active().tree;
4559
+ const primaryId = tree.primaryLeafId();
4560
+ const primaryKind = primaryId ? tree.get(primaryId)?.content?.kind : null;
4561
+ const topNavKind = wm.taxonomy.topNavFor(primaryKind) || primaryKind;
4562
+ hostEl.querySelectorAll("[data-kind]").forEach((b) => {
4563
+ b.classList.toggle("twm-top-nav__btn--on", b.dataset.kind === topNavKind);
4564
+ });
4565
+ }
4566
+ function mountPaletteButton(hostEl, palette) {
4567
+ if (!hostEl) return null;
4568
+ const existing = hostEl.querySelector("#twm-palette-btn");
4569
+ if (existing) return existing;
4570
+ const btn = document.createElement("button");
4571
+ btn.id = "twm-palette-btn";
4572
+ btn.className = "twm-panel-toggle-btn twm-has-tooltip";
4573
+ btn.dataset.tooltip = "Command palette (Ctrl+K)";
4574
+ btn.dataset.tooltipPlacement = "bottom";
4575
+ btn.setAttribute("aria-label", "Open command palette");
4576
+ btn.innerHTML = '<span class="material-symbols-outlined">menu_open</span>';
4577
+ btn.addEventListener("click", () => palette.toggle());
4578
+ hostEl.insertBefore(btn, hostEl.firstChild);
4579
+ return btn;
4580
+ }
4581
+ function bindPanelToggles(map, wm) {
4582
+ const fresh = {};
4583
+ for (const side of ["left", "right", "bottom"]) {
4584
+ const btn = map?.[side];
4585
+ if (!btn) continue;
4586
+ const el = btn.cloneNode(true);
4587
+ el.classList.remove("active", "pinned", "disabled");
4588
+ el.disabled = false;
4589
+ el.removeAttribute("aria-disabled");
4590
+ el.removeAttribute("aria-pressed");
4591
+ btn.replaceWith(el);
4592
+ el.addEventListener("click", () => wm.togglePanel(side));
4593
+ fresh[side] = el;
4594
+ }
4595
+ return fresh;
4596
+ }
4597
+ function syncPanelToggles(map, wm) {
4598
+ for (const side of ["left", "right", "bottom"]) {
4599
+ const btn = map?.[side];
4600
+ if (!btn) continue;
4601
+ btn.classList.toggle("twm-panel-toggle-btn--on", wm.isPanelOpen(side));
4602
+ }
4603
+ }
4604
+ function mountDesktopBar(hostEl, wm) {
4605
+ if (!hostEl) return null;
4606
+ let el = hostEl.querySelector("#twm-desktops");
4607
+ if (!el) {
4608
+ el = document.createElement("div");
4609
+ el.id = "twm-desktops";
4610
+ el.className = "twm-desktops";
4611
+ hostEl.appendChild(el);
4612
+ }
4613
+ el.addEventListener("click", (e) => {
4614
+ const btn = e.target.closest("[data-desktop]");
4615
+ if (!btn) return;
4616
+ const v = btn.dataset.desktop;
4617
+ if (v === "+") {
4618
+ wm.addDesktop();
4619
+ syncDesktopBar(el, wm);
4620
+ return;
4621
+ }
4622
+ wm.switchDesktop(Number(v));
4623
+ });
4624
+ el.addEventListener("dblclick", async (e) => {
4625
+ const btn = e.target.closest("[data-desktop]");
4626
+ if (!btn || btn.dataset.desktop === "+") return;
4627
+ e.preventDefault();
4628
+ const idx = Number(btn.dataset.desktop);
4629
+ const d = wm.desktops.desktops[idx];
4630
+ if (!d) return;
4631
+ const result = await openForm({
4632
+ title: "Rename desktop",
4633
+ fields: [{
4634
+ name: "label",
4635
+ label: "Name",
4636
+ type: "text",
4637
+ required: true,
4638
+ hint: "Up to 24 characters. Shown in the bottom-bar chip and the Alt+N tooltip."
4639
+ }],
4640
+ defaults: { label: d.label },
4641
+ submitLabel: "Rename"
4642
+ });
4643
+ if (!result || !result.label) return;
4644
+ d.label = String(result.label).slice(0, 24);
4645
+ syncDesktopBar(el, wm);
4646
+ wm._persist();
4647
+ });
4648
+ el.addEventListener("contextmenu", (e) => {
4649
+ const btn = e.target.closest("[data-desktop]");
4650
+ if (!btn || btn.dataset.desktop === "+") return;
4651
+ e.preventDefault();
4652
+ const idx = Number(btn.dataset.desktop);
4653
+ const m = wm.desktops;
4654
+ const d = m.desktops[idx];
4655
+ const canDelete = m.desktops.length > 1;
4656
+ showContextMenu(e.clientX, e.clientY, [
4657
+ {
4658
+ label: `Switch to ${d.label}`,
4659
+ icon: "desktop_windows",
4660
+ action: "switch",
4661
+ disabled: idx === m.activeIdx
4662
+ },
4663
+ { label: "Rename\u2026", icon: "edit", action: "rename" },
4664
+ { separator: true },
4665
+ { label: "New desktop", icon: "add", action: "add" },
4666
+ {
4667
+ label: "Delete desktop",
4668
+ icon: "delete",
4669
+ action: "delete",
4670
+ danger: true,
4671
+ disabled: !canDelete
4672
+ }
4673
+ ], async (action) => {
4674
+ if (action === "switch") wm.switchDesktop(idx);
4675
+ else if (action === "rename") {
4676
+ const result = await openForm({
4677
+ title: "Rename desktop",
4678
+ fields: [{ name: "label", label: "Name", type: "text", required: true }],
4679
+ defaults: { label: d.label },
4680
+ submitLabel: "Rename"
4681
+ });
4682
+ if (!result || !result.label) return;
4683
+ d.label = String(result.label).slice(0, 24);
4684
+ syncDesktopBar(el, wm);
4685
+ wm._persist();
4686
+ } else if (action === "add") {
4687
+ wm.addDesktop();
4688
+ syncDesktopBar(el, wm);
4689
+ } else if (action === "delete") {
4690
+ wm.removeDesktop(idx);
4691
+ syncDesktopBar(el, wm);
4692
+ }
4693
+ });
4694
+ });
4695
+ return el;
4696
+ }
4697
+ function syncDesktopBar(el, wm) {
4698
+ if (!el) return;
4699
+ const m = wm.desktops;
4700
+ el.innerHTML = m.desktops.map((d, i) => `
4701
+ <button class="twm-desk__btn${i === m.activeIdx ? " twm-desk__btn--on" : ""}"
4702
+ data-desktop="${i}"
4703
+ title="Desktop ${d.label} (Alt+${i + 1}) \xB7 right-click to delete">
4704
+ ${d.label}
4705
+ </button>`).join("") + `<button class="twm-desk__btn twm-desk__btn--add"
4706
+ data-desktop="+" title="Add desktop">+</button>`;
4707
+ }
4708
+ function _tileTabMenu(wm, leafId, x, y) {
4709
+ const tree = wm.desktops.active().tree;
4710
+ const leaf = tree.get(leafId);
4711
+ if (!leaf) return;
4712
+ const kind = leaf.content?.kind || wm.taxonomy.root;
4713
+ openTileTabMenu({
4714
+ x,
4715
+ y,
4716
+ leafKind: kind,
4717
+ api: wm.api,
4718
+ taxonomy: wm.taxonomy,
4719
+ entities: wm.ctx.entities,
4720
+ onPick: (navKind, shaped) => {
4721
+ tree.appendLeafTab(leafId, {
4722
+ kind: navKind,
4723
+ props: { id: shaped.id, label: shaped.label }
4724
+ }, shaped.label || shaped.id);
4725
+ tree.focus(leafId);
4726
+ wm.renderer.render();
4727
+ wm._persist?.();
4728
+ wm._notifyChange?.("tab-open-from-menu");
4729
+ }
4730
+ });
4731
+ }
4732
+ function _tileContextMenu(wm, leafId, x, y) {
4733
+ const tree = wm.desktops.active().tree;
4734
+ const leaf = tree.get(leafId);
4735
+ if (!leaf) return;
4736
+ const isPanel = String(leaf.content?.kind || "").startsWith("panel:");
4737
+ tree.focus(leafId);
4738
+ wm.renderer._updateFocusClasses();
4739
+ const items = [
4740
+ { label: "Split horizontally", icon: "splitscreen_vertical_add", action: "split-h" },
4741
+ { label: "Split vertically", icon: "splitscreen_add", action: "split-v" },
4742
+ { separator: true },
4743
+ {
4744
+ label: "Open in new tab",
4745
+ icon: "tab",
4746
+ action: "open-tab",
4747
+ disabled: isPanel || !leaf.content
4748
+ },
4749
+ {
4750
+ label: "Open in new window",
4751
+ icon: "open_in_full",
4752
+ action: "open-window",
4753
+ disabled: isPanel || !leaf.content
4754
+ },
4755
+ {
4756
+ label: "Promote to window",
4757
+ icon: "open_in_new",
4758
+ action: "promote",
4759
+ disabled: isPanel || !leaf.content
4760
+ }
4761
+ ];
4762
+ if (wm.desktops.desktops.length > 1 && !isPanel) {
4763
+ for (const [i, d] of wm.desktops.desktops.entries()) {
4764
+ if (i === wm.desktops.activeIdx) continue;
4765
+ items.push({
4766
+ label: `Move to desktop ${d.label}`,
4767
+ icon: "sweep",
4768
+ action: `move:${i}`
4769
+ });
4770
+ }
4771
+ }
4772
+ items.push({ separator: true });
4773
+ items.push({
4774
+ label: "Close tile",
4775
+ icon: "close",
4776
+ action: "close",
4777
+ danger: true,
4778
+ disabled: isPanel
4779
+ });
4780
+ showContextMenu(x, y, items, (action) => {
4781
+ if (action === "split-h") wm.split("h");
4782
+ else if (action === "split-v") wm.split("v");
4783
+ else if (action === "open-tab") {
4784
+ const c = leaf.content;
4785
+ if (c) wm.openInTabFromContext({ leafId }, c.kind, c.props || {});
4786
+ } else if (action === "open-window") {
4787
+ const c = leaf.content;
4788
+ if (c) wm.navigate(c.kind, c.props || {}, { target: "window" });
4789
+ } else if (action === "promote") wm.toggleManagedFocused();
4790
+ else if (action === "close") wm.closeFocused();
4791
+ else if (action?.startsWith?.("move:")) {
4792
+ wm.moveFocusedToDesktop(Number(action.slice(5)));
4793
+ }
4794
+ });
4795
+ }
4796
+ export {
4797
+ DesktopManager,
4798
+ PLACEHOLDER_KIND,
4799
+ PanelKeyRouter,
4800
+ TileRenderer,
4801
+ TileTree,
4802
+ WindowManager,
4803
+ actionsCellRenderer,
4804
+ attachLandingKeyboardNav,
4805
+ attachLandingShell,
4806
+ attachLandingTableBehavior,
4807
+ createCommandPalette,
4808
+ createContentRegistry,
4809
+ createEntityCatalog,
4810
+ createPageFactories,
4811
+ createShell,
4812
+ createTaxonomy,
4813
+ getPanelKeyRouter,
4814
+ installKeymap,
4815
+ installPanelKeyRouter,
4816
+ loadDesktops,
4817
+ makeLeaf,
4818
+ makeLoadingOverlay,
4819
+ makeSplit,
4820
+ mountLandingActions,
4821
+ mountLandingShell,
4822
+ mountNavPanel,
4823
+ mountTileBreadcrumb,
4824
+ openTileTabMenu,
4825
+ registerPanelKeys,
4826
+ saveDesktops,
4827
+ uninstallPanelKeyRouter,
4828
+ wireLandingPaneFocus
4829
+ };
4830
+ //# sourceMappingURL=wm.js.map