flexdesk 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (124) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +195 -0
  3. package/THIRD_PARTY_LICENSES.md +53 -0
  4. package/css/base.css +3439 -0
  5. package/css/flexdesk.css +5797 -0
  6. package/css/overrides.css +2349 -0
  7. package/css/reset.css +46 -0
  8. package/css/tokens.css +137 -0
  9. package/dist/charts.js +780 -0
  10. package/dist/charts.js.map +7 -0
  11. package/dist/chunk-3PHCPZHT.js +222 -0
  12. package/dist/chunk-3PHCPZHT.js.map +7 -0
  13. package/dist/chunk-CT4YXXLP.js +1770 -0
  14. package/dist/chunk-CT4YXXLP.js.map +7 -0
  15. package/dist/chunk-DRYCDMEG.js +2217 -0
  16. package/dist/chunk-DRYCDMEG.js.map +7 -0
  17. package/dist/chunk-DVU44T77.js +855 -0
  18. package/dist/chunk-DVU44T77.js.map +7 -0
  19. package/dist/chunk-FL5KFNQH.js +640 -0
  20. package/dist/chunk-FL5KFNQH.js.map +7 -0
  21. package/dist/chunk-FOOS3T5L.js +117 -0
  22. package/dist/chunk-FOOS3T5L.js.map +7 -0
  23. package/dist/chunk-JYWURG5T.js +32 -0
  24. package/dist/chunk-JYWURG5T.js.map +7 -0
  25. package/dist/chunk-TLZUUFOE.js +276 -0
  26. package/dist/chunk-TLZUUFOE.js.map +7 -0
  27. package/dist/chunk-UCJ2WD4D.js +625 -0
  28. package/dist/chunk-UCJ2WD4D.js.map +7 -0
  29. package/dist/chunk-WVFGV5FT.js +205 -0
  30. package/dist/chunk-WVFGV5FT.js.map +7 -0
  31. package/dist/core.js +1098 -0
  32. package/dist/core.js.map +7 -0
  33. package/dist/editor.js +1661 -0
  34. package/dist/editor.js.map +7 -0
  35. package/dist/flexdesk.css +5797 -0
  36. package/dist/host.js +132 -0
  37. package/dist/host.js.map +7 -0
  38. package/dist/reset.css +46 -0
  39. package/dist/tile_registry-6WZPTUZV.js +28 -0
  40. package/dist/tile_registry-6WZPTUZV.js.map +7 -0
  41. package/dist/tiles.js +2068 -0
  42. package/dist/tiles.js.map +7 -0
  43. package/dist/tokens.css +137 -0
  44. package/dist/widgets.js +5438 -0
  45. package/dist/widgets.js.map +7 -0
  46. package/dist/wm.js +4830 -0
  47. package/dist/wm.js.map +7 -0
  48. package/package.json +80 -0
  49. package/src/charts/chart_types.js +699 -0
  50. package/src/charts/data_series_plot_window.js +761 -0
  51. package/src/charts/downsample.js +119 -0
  52. package/src/charts/plot_config.js +176 -0
  53. package/src/charts/plot_popout_window.js +1560 -0
  54. package/src/charts/plotly_wrapper.js +432 -0
  55. package/src/core/event_bus.js +230 -0
  56. package/src/core/logging.js +300 -0
  57. package/src/core/settings.js +813 -0
  58. package/src/core/state_guard.js +382 -0
  59. package/src/core/state_guard_config.js +309 -0
  60. package/src/core/state_machine.js +258 -0
  61. package/src/editor/editor_pane.js +264 -0
  62. package/src/editor/monaco_editor_factory.js +449 -0
  63. package/src/editor/monaco_loader.js +74 -0
  64. package/src/editor/notebook_search.js +460 -0
  65. package/src/editor/notebook_tab_bar.js +456 -0
  66. package/src/editor/notebook_undo_manager.js +108 -0
  67. package/src/editor/split_pane_container.js +197 -0
  68. package/src/help/help_modal.js +409 -0
  69. package/src/help/help_registry.js +65 -0
  70. package/src/host/host.js +69 -0
  71. package/src/host/pywebview_host.js +133 -0
  72. package/src/tiles/config_schema.js +256 -0
  73. package/src/tiles/layout_persistence.js +662 -0
  74. package/src/tiles/tile_base.js +785 -0
  75. package/src/tiles/tile_grid.js +905 -0
  76. package/src/tiles/tile_registry.js +136 -0
  77. package/src/tiling/command_palette.js +328 -0
  78. package/src/tiling/content_registry.js +110 -0
  79. package/src/tiling/desktops.js +133 -0
  80. package/src/tiling/entity_sources.js +179 -0
  81. package/src/tiling/keymap.js +156 -0
  82. package/src/tiling/kind_taxonomy.js +172 -0
  83. package/src/tiling/landing_table.js +717 -0
  84. package/src/tiling/loading_overlay.js +33 -0
  85. package/src/tiling/nav_panel.js +142 -0
  86. package/src/tiling/page_factory.js +215 -0
  87. package/src/tiling/panel_keys.js +145 -0
  88. package/src/tiling/shell.js +437 -0
  89. package/src/tiling/tile_breadcrumb.js +222 -0
  90. package/src/tiling/tile_renderer.js +459 -0
  91. package/src/tiling/tile_tab_menu.js +340 -0
  92. package/src/tiling/tile_tree.js +880 -0
  93. package/src/tiling/wm.js +1352 -0
  94. package/src/ui/base/component_base.js +30 -0
  95. package/src/ui/base/controller_base.js +48 -0
  96. package/src/ui/base/page_base.js +102 -0
  97. package/src/ui/components/about_dialog.js +224 -0
  98. package/src/ui/components/action_dropdown.js +389 -0
  99. package/src/ui/components/attribute_list_editor.js +652 -0
  100. package/src/ui/components/autocomplete_field.js +732 -0
  101. package/src/ui/components/computing_status_window.js +81 -0
  102. package/src/ui/components/confirm_dialog.js +307 -0
  103. package/src/ui/components/context_menu.js +87 -0
  104. package/src/ui/components/data_table.js +2228 -0
  105. package/src/ui/components/detail_header.js +177 -0
  106. package/src/ui/components/drag_reorder.js +0 -0
  107. package/src/ui/components/gallery_picker.js +147 -0
  108. package/src/ui/components/inline_renamer.js +129 -0
  109. package/src/ui/components/managed_window.js +804 -0
  110. package/src/ui/components/modal.js +512 -0
  111. package/src/ui/components/notification_history.js +264 -0
  112. package/src/ui/components/slide_out_panel.js +185 -0
  113. package/src/ui/components/slider_field.js +122 -0
  114. package/src/ui/components/sortable_list.js +309 -0
  115. package/src/ui/components/table_state_store.js +81 -0
  116. package/src/ui/components/toast.js +59 -0
  117. package/src/ui/components/tree_view.js +460 -0
  118. package/src/ui/components/window_taskbar.js +143 -0
  119. package/src/ui/controllers/panel_state_machine.js +634 -0
  120. package/src/ui/controllers/window_chrome_controller.js +659 -0
  121. package/src/ui/notification_center.js +576 -0
  122. package/src/ui/utils/overlay_scrollbar.js +492 -0
  123. package/src/ui/utils/raf_resize_observer.js +31 -0
  124. package/src/ui/utils/tooltip_service.js +406 -0
@@ -0,0 +1,1352 @@
1
+ /**
2
+ * wm.js — WindowManager facade. Owns the desktop manager, the active
3
+ * tree's renderer, the managed-window stack, and the panel-tile state.
4
+ * Exposes a small imperative API consumed by the palette, keymap, top
5
+ * bar, bottom bar, and nav panel.
6
+ *
7
+ * wm.openInPrimary(kind, props) load a content into the primary tile
8
+ * wm.split(dir) split the focused tile
9
+ * wm.closeFocused() close the focused tile
10
+ * wm.focusDir(dir) / wm.moveFocused(dir)
11
+ * wm.toggleManagedFocused() tile <-> managed window
12
+ * wm.togglePanel(side) left | right | bottom
13
+ * wm.isPanelOpen(side) boolean
14
+ * wm.switchDesktop(idx) / wm.addDesktop() / wm.moveFocusedToDesktop(idx)
15
+ */
16
+
17
+ import { DesktopManager, saveDesktops, loadDesktops } from './desktops.js';
18
+ import { TileRenderer } from './tile_renderer.js';
19
+ import { makeLeaf } from './tile_tree.js';
20
+ import { PLACEHOLDER_KIND } from './content_registry.js';
21
+ import { installPanelKeyRouter } from './panel_keys.js';
22
+ import { ManagedWindow } from '../ui/components/managed_window.js';
23
+ import { showContextMenu } from '../ui/components/context_menu.js';
24
+
25
+ const PANEL_KINDS = new Set(['panel:left', 'panel:right', 'panel:bottom']);
26
+
27
+ const PANEL_TITLES = {
28
+ left: 'Navigator',
29
+ right: 'Inspector',
30
+ bottom: 'Console',
31
+ };
32
+
33
+ export class WindowManager {
34
+ constructor({ rootEl, api, ctx, onChange, eventBus, host, taxonomy, events, content }) {
35
+ if (!taxonomy) throw new Error('WindowManager: a taxonomy is required');
36
+ if (!content || typeof content.mount !== 'function') {
37
+ throw new Error('WindowManager: a content registry is required '
38
+ + '(createContentRegistry / createShell owns it)');
39
+ }
40
+ this.rootEl = rootEl;
41
+ // The shell-scoped content registry (kind -> factory). NOT a module
42
+ // singleton: two shells on one page must not share a kind table, and
43
+ // nothing may register a kind after the first mount.
44
+ this.content = content;
45
+ this.api = api || null;
46
+ // The host port. `this.api` stays: 20 domain page factories still read
47
+ // `ctx.api` — that coupling is P3's problem, not the host port's.
48
+ this.host = host || null;
49
+ // The injected ontology (see tiling/kind_taxonomy.js). The WM asks it
50
+ // for Backspace parents and top-nav sections; it names no kind itself.
51
+ this.taxonomy = taxonomy;
52
+ // Bus events the shell reacts to. Absent key ⇒ no subscription.
53
+ this.events = events || {};
54
+ this.ctx = ctx || {};
55
+ this.eventBus = eventBus || null;
56
+ this.onChange = onChange || (() => {});
57
+
58
+ // The leaf a fresh or emptied desktop starts with. Comes from the
59
+ // taxonomy's root kind — NOT a hardcoded 'home'. An embedder with a
60
+ // different ontology used to get an EcoAgent Home tile it had never
61
+ // registered, and therefore the content_registry placeholder.
62
+ this._rootLeaf = () => {
63
+ const kind = this.taxonomy.root;
64
+ return {
65
+ content: { kind, props: {} },
66
+ title: this.taxonomy.meta(kind)?.label || kind,
67
+ };
68
+ };
69
+ this.desktops = new DesktopManager({ seed: this._rootLeaf });
70
+ this.renderer = new TileRenderer({
71
+ root: rootEl,
72
+ tree: this.desktops.active().tree,
73
+ content,
74
+ ctx: {
75
+ ...this.ctx,
76
+ wm: this,
77
+ onLeafAction: (leafId, action) => this._leafAction(leafId, action),
78
+ onLeafTabAction: (leafId, action, data) =>
79
+ this._leafTabAction(leafId, action, data),
80
+ },
81
+ onFocusChange: () => this._notifyChange(),
82
+ });
83
+ this._persistTimer = null;
84
+ // Window-id → leaf-id mapping so a managed-window demote can
85
+ // restore its original tile slot.
86
+ this._windowToLeaf = new Map();
87
+
88
+ // Central keyboard router: panels register key handlers and the
89
+ // router dispatches each keydown only to the panel inside the
90
+ // currently focused leaf (see panel_keys.js). One authority for
91
+ // "which panel owns the keyboard" instead of every panel guessing
92
+ // from document.activeElement.
93
+ this.panelKeys = installPanelKeyRouter(this);
94
+
95
+ // Live-update tab titles when entities are renamed. The embedder
96
+ // names the event (`events.entityRenamed`); its per-entity editors
97
+ // fire it on Save. Walks every leaf's `tabs` and `pageTabs` archive
98
+ // so an archived page's tab labels stay accurate too. No event
99
+ // configured ⇒ no subscription, and tab titles are simply static.
100
+ const renamedEvent = this.events.entityRenamed;
101
+ if (renamedEvent) {
102
+ this.eventBus?.on?.(renamedEvent, ({ kind, entityId, label } = {}) => {
103
+ if (!kind || !entityId || !label) return;
104
+ let touched = false;
105
+ for (const d of this.desktops.desktops) {
106
+ if (d.tree.renameMatchingTabs?.(kind, entityId, label)) {
107
+ touched = true;
108
+ }
109
+ }
110
+ if (touched) {
111
+ this.renderer.render();
112
+ this._persist();
113
+ }
114
+ });
115
+ }
116
+ }
117
+
118
+ /** Emit on bus + call onChange. Use this instead of the bare callback
119
+ * so other surfaces (palette, top-bar toggles, page shortcuts) can
120
+ * subscribe through the existing event system. */
121
+ _notifyChange(reason = null) {
122
+ try { this.onChange(reason); } catch (err) { console.error('[wm] onChange threw', err); }
123
+ try { this.eventBus?.emit?.('wm:changed', { reason, wm: this }); }
124
+ catch (err) { console.warn('[wm] event emit failed', err); }
125
+ // Emit the canonical `workspace:tabs:activated` event for the
126
+ // current primary leaf — the existing sidebars (ProjectSidebar
127
+ // etc.) already listen for this to highlight the active row.
128
+ try {
129
+ const tree = this._tree();
130
+ const id = tree.primaryLeafId();
131
+ const leaf = id ? tree.get(id) : null;
132
+ const kind = leaf?.content?.kind;
133
+ if (kind && kind !== 'window-placeholder') {
134
+ this.eventBus?.emit?.('workspace:tabs:activated', {
135
+ kind,
136
+ entityId: leaf.content.props?.id ?? null,
137
+ label: leaf.title,
138
+ from: reason || 'wm',
139
+ });
140
+ }
141
+ } catch (_) {}
142
+ }
143
+
144
+ // ── Persistence ─────────────────────────────────────────────────
145
+ async load() {
146
+ const blob = await loadDesktops(this.host?.state);
147
+ if (blob) {
148
+ this.desktops = DesktopManager.deserialize(blob, { seed: this._rootLeaf });
149
+ this.renderer.tree = this.desktops.active().tree;
150
+ }
151
+ // Normalize: managed windows don't survive a reload, so any
152
+ // window-placeholder leaves restore their original content.
153
+ for (const d of this.desktops.desktops) {
154
+ for (const leaf of d.tree.leaves()) {
155
+ if (leaf.content?.kind === PLACEHOLDER_KIND) {
156
+ const p = leaf.content.props || {};
157
+ if (p.originalKind) {
158
+ d.tree.setLeafContent(leaf.id,
159
+ { kind: p.originalKind, props: p.originalProps || {} },
160
+ p.originalTitle || p.originalKind);
161
+ }
162
+ }
163
+ }
164
+ this._canonicalize(d.tree, d);
165
+ }
166
+ this.renderer.tree = this.desktops.active().tree;
167
+ this.renderer.render();
168
+ this._notifyChange();
169
+ }
170
+
171
+ _persist() {
172
+ if (this._persistTimer) clearTimeout(this._persistTimer);
173
+ this._persistTimer = setTimeout(async () => {
174
+ this._persistTimer = null;
175
+ await saveDesktops(this.host?.state, this.desktops.serialize());
176
+ }, 500);
177
+ }
178
+
179
+ // ── Content loading ─────────────────────────────────────────────
180
+ /** Load content into the primary tile (last-active non-panel leaf).
181
+ /** Walk back one step. Bound to Backspace.
182
+ *
183
+ * Priority order:
184
+ * 1. Active tab's per-tab history stack (browser-style "back"):
185
+ * any in-tile navigation (relationships click-through,
186
+ * landing-row open, "Open" buttons inside tables) pushed
187
+ * the previous state, and Backspace pops it. This is the
188
+ * canonical user-facing back affordance.
189
+ * 2. When the history is empty (the user landed on this page
190
+ * via top-nav / palette / direct link), fall back to the
191
+ * canonical taxonomy walk: a sub-page goes to its
192
+ * top-nav, a top-nav page goes to Home.
193
+ *
194
+ * Adding a new kind to the injected taxonomy wires the fallback path
195
+ * for Backspace + breadcrumb + top-nav highlight in one shot.
196
+ * Adding a new "Open from X" click that should be a back-able
197
+ * navigation just needs to use one of the in-tile routing
198
+ * methods (openFromContext / openInLeaf / navigateActiveTab) —
199
+ * they all record history by default. */
200
+ navigateBack() {
201
+ const tree = this._tree();
202
+ // Prefer the FOCUSED leaf — that's where the user was just
203
+ // interacting (Open click, breadcrumb step, etc.). Falls back
204
+ // to the primary leaf for keyboard-driven Backspace when no
205
+ // tile is explicitly focused (panels excluded). If the focused
206
+ // leaf is a panel, walk back on the primary content leaf
207
+ // instead (Backspace from the nav panel should still navigate
208
+ // the user's content area).
209
+ const focusedId = tree.focusedLeafId;
210
+ const primaryId = tree.primaryLeafId();
211
+ const focusedLeaf = focusedId ? tree.get(focusedId) : null;
212
+ const focusedKind = focusedLeaf?.content?.kind || '';
213
+ const id = (focusedId && focusedKind
214
+ && !focusedKind.startsWith('panel:')
215
+ && focusedKind !== 'window-placeholder')
216
+ ? focusedId : primaryId;
217
+ if (!id) return;
218
+ // (1) Per-tab history pop, if anything was recorded.
219
+ const prior = tree.popActiveTabHistory?.(id);
220
+ if (prior && prior.kind) {
221
+ // Cross-page back entries carry a `topNav` marker — pop
222
+ // routes via swapToPage so we land on the correct page.
223
+ const leafNow = tree.get(id);
224
+ const curKind = leafNow?.content?.kind;
225
+ const curTopNav = curKind ? this.taxonomy.topNavFor(curKind) : null;
226
+ if (prior.topNav && curTopNav && prior.topNav !== curTopNav) {
227
+ tree.swapToPage?.(
228
+ id,
229
+ { kind: prior.kind, props: prior.props || {},
230
+ title: prior.title || _tabTitle(prior.kind, prior.props) },
231
+ curTopNav, prior.topNav,
232
+ { recordHistory: false },
233
+ );
234
+ } else {
235
+ tree.replaceActiveTabContent(
236
+ id,
237
+ { kind: prior.kind, props: prior.props || {} },
238
+ prior.title || _tabTitle(prior.kind, prior.props),
239
+ { recordHistory: false },
240
+ );
241
+ }
242
+ tree.focus(id);
243
+ this.renderer.render();
244
+ this._persist();
245
+ this._notifyChange('navigate-back');
246
+ return;
247
+ }
248
+ // (2) Taxonomy fallback — the user's path is gone, so walk the
249
+ // canonical parent instead. An ENTITY-level parent wins (the
250
+ // instance one level up, e.g. the parent an id names); failing
251
+ // that, the KIND-level parent (this page's section).
252
+ const leaf = tree.get(id);
253
+ if (!leaf?.content) return;
254
+ const { kind, props } = leaf.content;
255
+ const up = this.taxonomy.parentOf(kind, props);
256
+ if (up) { this.navigateActiveTab(id, up.kind, up.props); return; }
257
+ const parent = this.taxonomy.parentKindFor(kind);
258
+ if (parent) this.navigateActiveTab(id, parent);
259
+ }
260
+
261
+ /** True when `navigateBack` would do something user-visible — i.e.
262
+ * the active tab has per-tab history, OR the current kind has a
263
+ * taxonomy parent. Used by the breadcrumb to grey out / hide the
264
+ * Back button when there's nowhere to go. */
265
+ canNavigateBack() {
266
+ const tree = this._tree();
267
+ const focusedId = tree.focusedLeafId;
268
+ const primaryId = tree.primaryLeafId();
269
+ const focusedLeaf = focusedId ? tree.get(focusedId) : null;
270
+ const focusedKind = focusedLeaf?.content?.kind || '';
271
+ const id = (focusedId && focusedKind
272
+ && !focusedKind.startsWith('panel:')
273
+ && focusedKind !== 'window-placeholder')
274
+ ? focusedId : primaryId;
275
+ if (!id) return false;
276
+ const history = tree.activeTabHistory?.(id) || [];
277
+ if (history.length > 0) return true;
278
+ const leaf = tree.get(id);
279
+ const kind = leaf?.content?.kind;
280
+ return !!(kind && this.taxonomy.parentKindFor(kind));
281
+ }
282
+
283
+ /** Persist editor sub-state (e.g. active sub-tab) into the active
284
+ * tab's WM props so future history snapshots restore the user's
285
+ * view rather than the default landing. Pure metadata write — no
286
+ * re-render, no history push. */
287
+ updateActiveTabProps(leafId, patch) {
288
+ const tree = this._tree();
289
+ if (!tree?.updateActiveTabProps) return;
290
+ tree.updateActiveTabProps(leafId, patch);
291
+ this._persist();
292
+ }
293
+
294
+ /** Navigate inside the current tab — preserves every other tab in
295
+ * the leaf. Used by Backspace + breadcrumb segments + anything
296
+ * else that should walk WITHIN the tile rather than reset it. */
297
+ navigateActiveTab(leafId, kind, props = {}) {
298
+ const tree = this._tree();
299
+ const leaf = tree.get(leafId);
300
+ if (!leaf || leaf.kind !== 'leaf') {
301
+ this.openInPrimary(kind, props);
302
+ return;
303
+ }
304
+ tree.replaceActiveTabContent(leafId, { kind, props }, _tabTitle(kind, props));
305
+ tree.focus(leafId);
306
+ this.renderer.render();
307
+ this._persist();
308
+ this._notifyChange('navigate-active-tab');
309
+ }
310
+
311
+ /** Route a navigation request to the right container based on
312
+ * where it came from:
313
+ *
314
+ * - `ctx.windowId` set → replace the managed-window content (the
315
+ * user is navigating inside a windowed tile; the window stays).
316
+ * - `ctx.leafId` set AND that leaf is NOT `panel:left` → replace
317
+ * the leaf's content in place (the user is in a split tile;
318
+ * the click stays in that tile).
319
+ * - Otherwise (palette, top-nav shortcuts, click in the
320
+ * navigator panel, backspace, plain bus event) → primary tile.
321
+ *
322
+ * The left nav (`panel:left`) is the only tile whose clicks
323
+ * intentionally jump out to the primary tile — every other
324
+ * container is self-contained. */
325
+ openFromContext(ctx, kind, props = {}) {
326
+ if (ctx?.windowId && this._windowToLeaf.has(ctx.windowId)) {
327
+ this.openInWindow(ctx.windowId, kind, props);
328
+ return;
329
+ }
330
+ if (ctx?.leafId) {
331
+ const tree = this._tree();
332
+ const leaf = tree.get(ctx.leafId);
333
+ const k = leaf?.content?.kind;
334
+ if (leaf && k && k !== 'panel:left' && !k.startsWith('panel:')) {
335
+ this.openInLeaf(ctx.leafId, kind, props);
336
+ return;
337
+ }
338
+ }
339
+ this.openInPrimary(kind, props);
340
+ }
341
+
342
+ /** Replace the leaf's **active tab** in place — preserves every
343
+ * other tab. This is the routing for navigation that originated
344
+ * INSIDE the tile: clicking a row on a landing page, walking a
345
+ * breadcrumb segment, Backspace. Outside-the-tile navigation
346
+ * (top-nav, palette, side-nav) uses `openInPrimary` instead, which
347
+ * resets the leaf to a single fresh tab.
348
+ *
349
+ * For a leaf that has only one tab (the common case before the
350
+ * tab feature shipped), the behavior is identical to the old
351
+ * setLeafContent: one tab in, one tab out. */
352
+ openInLeaf(leafId, kind, props = {}) {
353
+ const tree = this._tree();
354
+ const leaf = tree.get(leafId);
355
+ if (!leaf || leaf.kind !== 'leaf') {
356
+ this.openInPrimary(kind, props);
357
+ return;
358
+ }
359
+ tree.replaceActiveTabContent(leafId, { kind, props }, _tabTitle(kind, props));
360
+ tree.focus(leafId);
361
+ this.renderer.render();
362
+ this._persist();
363
+ this._notifyChange();
364
+ }
365
+
366
+ /** Replace a managed window's content in place. Tears down the
367
+ * previous mount, mounts the new kind into the same contentEl,
368
+ * and updates the window's title. */
369
+ openInWindow(winId, kind, props = {}) {
370
+ const rec = this._windowToLeaf.get(winId);
371
+ if (!rec) { this.openInPrimary(kind, props); return; }
372
+ try { rec.mountInfo?.destroy?.(); } catch {}
373
+ rec.contentEl.innerHTML = '';
374
+ const mountInfo = this.content.mount(kind, rec.contentEl, props,
375
+ { ...this.ctx, wm: this, windowId: winId });
376
+ rec.mountInfo = mountInfo;
377
+ rec.original = {
378
+ kind, props: { ...(props || {}) },
379
+ title: mountInfo?.title || kind,
380
+ };
381
+ // Update window title (DOM + the ManagedWindow instance).
382
+ try {
383
+ const titleEl = rec.window.element?.querySelector('.twm-managed-window__title');
384
+ if (titleEl) titleEl.textContent = rec.original.title;
385
+ if (rec.window) rec.window.title = rec.original.title;
386
+ } catch {}
387
+ // `rec.original` now holds the latest content, so a later "back to
388
+ // tile" docks the current view (not the kind first promoted). The
389
+ // window owns no tile in the tree, so there is no leaf to update.
390
+ this._persist();
391
+ this._notifyChange('window-content-changed');
392
+ }
393
+
394
+ /** Load content into the primary tile (last-active non-panel leaf).
395
+ * If only panel tiles exist (or the tree is empty), spawn a new
396
+ * content leaf and canonicalize so the panels wrap it. */
397
+ openInPrimary(kind, props = {}) {
398
+ const tree = this._tree();
399
+ let leafId = tree.primaryLeafId();
400
+ let spawned = false;
401
+ if (!leafId) {
402
+ leafId = this._spawnContentLeaf(tree);
403
+ spawned = true;
404
+ }
405
+ if (!leafId) return;
406
+ // Page-aware swap: archive the leaf's current page tabs and
407
+ // restore (or initialize) the target page's. This is what makes
408
+ // a tile remember its tab set when the user clicks away to
409
+ // another top-nav page and back. `swapToPage` is a no-op when
410
+ // the leaf has no current page (just-spawned content leaf) —
411
+ // the default branch initializes a fresh single tab.
412
+ const leaf = tree.get(leafId);
413
+ const currentTopNav = leaf?.content?.kind
414
+ ? this.taxonomy.topNavFor(leaf.content.kind) || null
415
+ : null;
416
+ const targetTopNav = this.taxonomy.topNavFor(kind) || kind;
417
+ const title = _tabTitle(kind, props);
418
+ tree.swapToPage(leafId,
419
+ { kind, props, title },
420
+ currentTopNav, targetTopNav);
421
+ tree.focus(leafId);
422
+ if (spawned) this._canonicalize(tree, this.desktops.active());
423
+ this.renderer.render();
424
+ this._persist();
425
+ this._notifyChange();
426
+ }
427
+
428
+ // ── Split / close / focus / move ────────────────────────────────
429
+ split(dir) {
430
+ const tree = this._tree();
431
+ const focused = tree.focusedLeafId;
432
+ if (!focused) return;
433
+ tree.split(focused, dir);
434
+ this.renderer.render();
435
+ this._persist();
436
+ this._notifyChange();
437
+ }
438
+
439
+ /** Split `leafId` along `dir` and mount `kind`/`props` in the freshly
440
+ * created sibling — the "open this content in a new split" primitive
441
+ * behind the code-pane split buttons, the per-pane context menu and
442
+ * Alt+Shift+H / Alt+Shift+V. The source leaf keeps its content
443
+ * untouched; the new pane gets a FRESH mount (so e.g. opening a Code
444
+ * pane in a split never dismounts the original). Returns the new
445
+ * leaf id, or null if the leaf can't be split. */
446
+ splitLeafWith(leafId, dir, kind, props = {}, title = '') {
447
+ const tree = this._tree();
448
+ const src = leafId ? tree.get(leafId) : null;
449
+ if (!src || src.kind !== 'leaf') return null;
450
+ const newId = tree.split(leafId, dir);
451
+ if (!newId) return null;
452
+ tree.setLeafContent(newId, { kind, props }, title || _tabTitle(kind, props));
453
+ tree.focus(newId);
454
+ this.renderer.render();
455
+ this._persist();
456
+ this._notifyChange('split-with');
457
+ return newId;
458
+ }
459
+
460
+ /** Resolve the focused tile's active content for the keyboard-driven
461
+ * "open focused tile elsewhere" chords (Alt+T / Alt+N / Alt+Shift+H /
462
+ * Alt+Shift+V). Returns null for panels, window placeholders and
463
+ * empty tiles — none of which can be meaningfully duplicated. */
464
+ _focusedContent() {
465
+ const tree = this._tree();
466
+ const id = tree.focusedLeafId;
467
+ const leaf = id ? tree.get(id) : null;
468
+ if (!leaf || leaf.kind !== 'leaf' || !leaf.content) return null;
469
+ const kind = leaf.content.kind;
470
+ if (!kind || kind === PLACEHOLDER_KIND || kind.startsWith('panel:')) return null;
471
+ return { leafId: id, kind, props: leaf.content.props || {}, title: leaf.title };
472
+ }
473
+
474
+ /** Alt+T — open the focused tile's content in a new tab on that tile. */
475
+ openFocusedInTab() {
476
+ const c = this._focusedContent();
477
+ if (!c) return;
478
+ this.openInTabFromContext({ leafId: c.leafId }, c.kind, c.props);
479
+ }
480
+
481
+ /** Alt+N — open the focused tile's content in a fresh managed window.
482
+ * Unlike Alt+F (promote) this DUPLICATES: the source tile stays put. */
483
+ openFocusedInWindow() {
484
+ const c = this._focusedContent();
485
+ if (!c) return;
486
+ this._navigateWindow(c.kind, c.props);
487
+ }
488
+
489
+ /** Alt+Shift+H / Alt+Shift+V — split the focused tile and open a fresh
490
+ * copy of its content in the new pane. Falls back to an empty split
491
+ * when the focused tile has no duplicable content (e.g. a panel). */
492
+ splitFocusedWith(dir) {
493
+ const c = this._focusedContent();
494
+ if (!c) { this.split(dir); return; }
495
+ this.splitLeafWith(c.leafId, dir, c.kind, c.props, c.title);
496
+ }
497
+
498
+ closeFocused() {
499
+ const tree = this._tree();
500
+ const focusedId = tree.focusedLeafId;
501
+ if (!focusedId) return;
502
+ const leaf = tree.get(focusedId);
503
+ const kind = leaf?.content?.kind;
504
+
505
+ // Closing a placeholder closes its window — the window's onClose
506
+ // handler restores the leaf, then we close that leaf too.
507
+ if (kind === PLACEHOLDER_KIND) {
508
+ const winId = leaf.content?.props?.windowId;
509
+ const rec = winId ? this._windowToLeaf.get(winId) : null;
510
+ if (rec) {
511
+ // Drop the mapping so the onClose handler doesn't restore.
512
+ this._windowToLeaf.delete(winId);
513
+ try { rec.window.close({ force: true }); } catch {}
514
+ }
515
+ tree.close(focusedId);
516
+ }
517
+ // Closing a panel: also flip the desktop's panel state off so
518
+ // the top-bar toggle button reflects the closure.
519
+ else if (PANEL_KINDS.has(kind)) {
520
+ const d = this.desktops.active();
521
+ const side = kind.split(':')[1];
522
+ d.panels[side] = false;
523
+ this._canonicalize(tree, d);
524
+ }
525
+ else {
526
+ tree.close(focusedId);
527
+ }
528
+
529
+ // Re-canonicalize so the remaining panels fill the area
530
+ // properly (no leftover wrap from the content leaf). The user
531
+ // is allowed to close the last content tile — the panels can
532
+ // fully occupy the WM. A new content leaf is spawned later by
533
+ // openInPrimary when something needs it.
534
+ this._canonicalize(tree, this.desktops.active());
535
+
536
+ this.renderer.render();
537
+ this._persist();
538
+ this._notifyChange('tile-closed');
539
+ }
540
+
541
+ /** Close the ACTIVE tab of a leaf (e.g. a Cancel button inside the
542
+ * tab's content). Mirrors the tab-strip × handler: remove just that
543
+ * tab, or close the whole tile if it was the last one. Lets content
544
+ * self-close without nuking sibling tabs. */
545
+ closeActiveTab(leafId) {
546
+ const tree = this._tree();
547
+ const leaf = leafId ? tree.get(leafId) : null;
548
+ if (!leaf || leaf.kind !== 'leaf') {
549
+ // No tabbed leaf (e.g. content in a managed window) — fall
550
+ // back to closing the focused container.
551
+ this.closeFocused();
552
+ return;
553
+ }
554
+ const tabs = leaf.tabs || [];
555
+ if (tabs.length <= 1) {
556
+ tree.focus(leafId);
557
+ this.closeFocused();
558
+ return;
559
+ }
560
+ const idx = Math.max(0, Math.min(tabs.length - 1, leaf.activeTabIdx || 0));
561
+ tree.removeLeafTab(leafId, idx);
562
+ tree.focus(leafId);
563
+ this.renderer.render();
564
+ this._persist();
565
+ this._notifyChange('twm-tab-close');
566
+ }
567
+
568
+ /** Insert a new empty content leaf at the centre of the layout
569
+ * (wraps the current root in an h-split with the content on the
570
+ * left, 4:1 ratio). If the tree is empty, becomes the root. */
571
+ _spawnContentLeaf(tree) {
572
+ const newLeaf = makeLeaf({ content: null, title: '' });
573
+ if (!tree.rootId) { tree.setRoot(newLeaf); return newLeaf.id; }
574
+ tree._register(newLeaf);
575
+ const split = {
576
+ id: `split-spawn-${Date.now().toString(36)}`,
577
+ kind: 'split', parentId: null, dir: 'h',
578
+ children: [newLeaf.id, tree.rootId], sizes: [4, 1],
579
+ };
580
+ tree.nodes.set(split.id, split);
581
+ const oldRoot = tree.get(tree.rootId);
582
+ if (oldRoot) oldRoot.parentId = split.id;
583
+ newLeaf.parentId = split.id;
584
+ tree.rootId = split.id;
585
+ tree.focus(newLeaf.id);
586
+ return newLeaf.id;
587
+ }
588
+
589
+ focusDir(dir) {
590
+ if (this._tree().focusDir(dir)) {
591
+ this.renderer._updateFocusClasses();
592
+ this._notifyChange();
593
+ }
594
+ }
595
+
596
+ /** The `.twm-leaf` DOM element of the currently focused leaf on the
597
+ * active desktop, or null. The panel-key router consults this to
598
+ * decide which panel owns the keyboard — it is the single source of
599
+ * truth for "the selected tile". */
600
+ focusedLeafEl() {
601
+ const id = this._tree().focusedLeafId;
602
+ return id ? this.renderer.leafEl(id) : null;
603
+ }
604
+
605
+ moveFocused(dir) {
606
+ if (this._tree().moveDir(dir)) {
607
+ this.renderer.render();
608
+ this._persist();
609
+ this._notifyChange();
610
+ }
611
+ }
612
+
613
+ // ── Tile <-> Managed window ─────────────────────────────────────
614
+ /** Promote the focused tile into a managed window and CLOSE the
615
+ * source tile — promoting means the content leaves the grid, so the
616
+ * origin slot is removed rather than left as an empty placeholder.
617
+ * "Back to tile" re-docks the content into the primary tile. */
618
+ toggleManagedFocused() {
619
+ const tree = this._tree();
620
+ const focused = tree.focused();
621
+ if (!focused || !focused.content) return;
622
+ if (PANEL_KINDS.has(focused.content.kind)) return;
623
+ if (focused.content.kind === PLACEHOLDER_KIND) return;
624
+
625
+ const leafId = focused.id;
626
+ const desktopIdx = this.desktops.activeIdx;
627
+ const original = {
628
+ kind: focused.content.kind,
629
+ props: { ...(focused.content.props || {}) },
630
+ title: focused.title,
631
+ };
632
+
633
+ const contentEl = document.createElement('div');
634
+ contentEl.className = 'twm-window-content';
635
+ contentEl.style.cssText = 'display:flex; flex-direction:column; flex:1; min-width:0; min-height:0; height:100%;';
636
+
637
+ const winId = `twm-mw-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 5)}`;
638
+ const mountInfo = this.content.mount(original.kind, contentEl, original.props,
639
+ { ...this.ctx, wm: this, windowId: winId });
640
+ const win = new ManagedWindow({
641
+ id: winId,
642
+ title: mountInfo?.title || original.title,
643
+ icon: 'web_asset',
644
+ content: contentEl,
645
+ canMinimize: true,
646
+ canMaximize: true,
647
+ canResize: true,
648
+ modal: false,
649
+ onClose: () => this._onManagedWindowClosed(winId, mountInfo),
650
+ });
651
+
652
+ this._windowToLeaf.set(winId, {
653
+ // Promoting CLOSES the source tile — the content lives in the
654
+ // window now, not the tree. leafId is null so "back to tile"
655
+ // re-docks into the primary tile (see _onManagedWindowClosed).
656
+ leafId: null, desktopIdx, original, mountInfo, window: win,
657
+ contentEl,
658
+ // Set to true by bringBackWindow so the close path knows to
659
+ // restore the content instead of destroying it.
660
+ _demoting: false,
661
+ });
662
+
663
+ // Close the origin tile so it doesn't linger as an empty
664
+ // "(window)" placeholder. Re-canonicalize so panels reflow; the
665
+ // desktop is allowed to end up with no content tile (panels can
666
+ // fill it, and openInPrimary re-spawns one on demand) — same as
667
+ // closeFocused on the last tile.
668
+ tree.close(leafId);
669
+ this._canonicalize(tree, this.desktops.active());
670
+
671
+ this.renderer.render();
672
+ win.show();
673
+ this._decorateManagedWindow(win, winId);
674
+ this._persist();
675
+ this._notifyChange('window-promoted');
676
+ }
677
+
678
+ /** Dock the window's content back into the desktop's primary tile
679
+ * (the source tile was closed on promote), then close the window. */
680
+ bringBackWindow(windowId) {
681
+ const rec = this._windowToLeaf.get(windowId);
682
+ if (!rec) return;
683
+ rec._demoting = true;
684
+ try { rec.window.close({ force: true }); }
685
+ catch (err) { console.warn('[wm] bringBack: close failed', err); }
686
+ }
687
+
688
+ /** Re-home a managed window to another desktop. The window itself
689
+ * stays on screen (managed windows are global) and leaves no tile
690
+ * behind on either desktop; only its "home" changes, so bringing it
691
+ * back will land on the new desktop's primary tile. */
692
+ moveWindowToDesktop(windowId, targetIdx) {
693
+ const rec = this._windowToLeaf.get(windowId);
694
+ if (!rec) return;
695
+ if (rec.desktopIdx === targetIdx) return;
696
+ this.desktops.ensureCount(targetIdx + 1);
697
+ rec.desktopIdx = targetIdx;
698
+ this._persist();
699
+ this._notifyChange('window-moved');
700
+ }
701
+
702
+ _onManagedWindowClosed(winId, mountInfo) {
703
+ const rec = this._windowToLeaf.get(winId);
704
+ this._windowToLeaf.delete(winId);
705
+ if (!rec) return;
706
+ try { mountInfo?.destroy?.(); } catch {}
707
+ const tree = this.desktops.desktops[rec.desktopIdx]?.tree;
708
+ if (!tree) return;
709
+
710
+ if (rec._demoting) {
711
+ // "Back to tile": the source tile was closed when the window was
712
+ // promoted, so dock the (latest) content into the desktop's
713
+ // primary tile as a new tab, spawning a tile when it has none.
714
+ let pid = tree.primaryLeafId();
715
+ let spawned = false;
716
+ if (!pid) { pid = this._spawnContentLeaf(tree); spawned = true; }
717
+ if (pid) {
718
+ tree.appendLeafTab(pid,
719
+ { kind: rec.original.kind, props: rec.original.props },
720
+ rec.original.title);
721
+ tree.focus(pid);
722
+ if (spawned) this._canonicalize(tree, this.desktops.desktops[rec.desktopIdx]);
723
+ }
724
+ }
725
+ // Plain close (rec._demoting === false): the window and its content
726
+ // are discarded. The source tile was already closed on promote, so
727
+ // there is nothing left in the tree to clean up.
728
+ if (this.desktops.active().tree === tree) this.renderer.render();
729
+ this._persist();
730
+ this._notifyChange(rec._demoting ? 'window-demoted' : 'window-closed');
731
+ }
732
+
733
+ /** Post-show DOM hook: inject a "back to tile" button into the
734
+ * window chrome and wire a right-click context menu on the topbar. */
735
+ _decorateManagedWindow(win, winId) {
736
+ const topbar = win.element?.querySelector?.('.twm-managed-window__topbar');
737
+ const buttons = topbar?.querySelector?.('.twm-managed-window__buttons');
738
+ if (!buttons) return;
739
+ const backBtn = document.createElement('button');
740
+ backBtn.type = 'button';
741
+ backBtn.className = 'twm-managed-window__btn managed-window__btn--demote';
742
+ backBtn.title = 'Back to tile';
743
+ backBtn.innerHTML = `<span class="material-symbols-outlined" style="font-size:14px">close_fullscreen</span>`;
744
+ backBtn.addEventListener('click', (e) => {
745
+ e.stopPropagation();
746
+ this.bringBackWindow(winId);
747
+ });
748
+ const closeBtn = buttons.querySelector('.twm-managed-window__btn--close');
749
+ if (closeBtn) buttons.insertBefore(backBtn, closeBtn);
750
+ else buttons.appendChild(backBtn);
751
+
752
+ topbar.addEventListener('contextmenu', (e) => {
753
+ e.preventDefault();
754
+ const rec = this._windowToLeaf.get(winId);
755
+ if (!rec) return;
756
+ const items = [
757
+ { label: 'Back to tile', icon: 'close_fullscreen', action: 'back' },
758
+ ];
759
+ if (this.desktops.desktops.length > 1) {
760
+ items.push({ separator: true });
761
+ for (const [i, d] of this.desktops.desktops.entries()) {
762
+ if (i === rec.desktopIdx) continue;
763
+ items.push({
764
+ label: `Move to desktop ${d.label}`, icon: 'sweep',
765
+ action: `move:${i}`,
766
+ });
767
+ }
768
+ }
769
+ items.push({ separator: true });
770
+ items.push({ label: 'Close window', icon: 'close',
771
+ action: 'close', danger: true });
772
+ showContextMenu(e.clientX, e.clientY, items, (action) => {
773
+ if (action === 'back') this.bringBackWindow(winId);
774
+ else if (action === 'close') {
775
+ try { win.close({ force: true }); } catch {}
776
+ }
777
+ else if (action?.startsWith?.('move:')) {
778
+ this.moveWindowToDesktop(winId, Number(action.slice(5)));
779
+ }
780
+ });
781
+ });
782
+ }
783
+
784
+ // ── Panel-tiles (left nav / right / bottom) ─────────────────────
785
+ /** Panels are virtual: they live in the active desktop's tree as
786
+ * leaves with content kinds 'panel:left', 'panel:right',
787
+ * 'panel:bottom'. Toggling either inserts them at the appropriate
788
+ * edge (a recursive split) or closes that leaf. */
789
+ isPanelOpen(side) {
790
+ const d = this.desktops.active();
791
+ return !!d.panels[side];
792
+ }
793
+
794
+ togglePanel(side) {
795
+ const d = this.desktops.active();
796
+ d.panels[side] = !d.panels[side];
797
+ this._canonicalize(this._tree(), d);
798
+ this.renderer.render();
799
+ this._persist();
800
+ this._notifyChange();
801
+ }
802
+
803
+ /** Rebuild the tree so panel:* leaves sit in the canonical positions:
804
+ *
805
+ * v-split
806
+ * ├ h-split ← the "content row"
807
+ * │ ├ panel:left (if open)
808
+ * │ ├ content (whatever non-panel subtree)
809
+ * │ └ panel:right (if open)
810
+ * └ panel:bottom (if open, full width below the content row)
811
+ *
812
+ * Existing panel leaves are detached + re-attached (same id), so the
813
+ * renderer cache keeps the mounted content (nav filter state, etc.). */
814
+ _canonicalize(tree, desktop) {
815
+ // 1. Find existing panel leaves.
816
+ const panel = {};
817
+ for (const leaf of tree.leaves()) {
818
+ const kind = leaf.content?.kind;
819
+ if (kind === 'panel:left') panel.left = leaf;
820
+ else if (kind === 'panel:right') panel.right = leaf;
821
+ else if (kind === 'panel:bottom') panel.bottom = leaf;
822
+ }
823
+
824
+ // 2. Detach each panel leaf from its current parent. (We don't
825
+ // use tree.close because that deletes the node; we want to
826
+ // preserve identity so the renderer keeps the mounted body.)
827
+ const detach = (leaf) => {
828
+ if (!leaf) return;
829
+ const pid = leaf.parentId;
830
+ if (!pid) {
831
+ tree.rootId = null;
832
+ leaf.parentId = null;
833
+ return;
834
+ }
835
+ const parent = tree.get(pid);
836
+ if (!parent) return;
837
+ const idx = parent.children.indexOf(leaf.id);
838
+ if (idx >= 0) {
839
+ parent.children.splice(idx, 1);
840
+ parent.sizes.splice(idx, 1);
841
+ }
842
+ leaf.parentId = null;
843
+ tree._collapse(parent);
844
+ };
845
+ detach(panel.left); detach(panel.right); detach(panel.bottom);
846
+
847
+ // 3. The content subtree may legitimately be empty — the user
848
+ // is allowed to close the last main tile and let panels
849
+ // take over the entire WM area. If so, seed root from the
850
+ // first requested panel; otherwise content stays as root.
851
+ let seededSide = null;
852
+ if (!tree.rootId) {
853
+ seededSide = desktop.panels.left ? 'left'
854
+ : desktop.panels.bottom ? 'bottom'
855
+ : desktop.panels.right ? 'right'
856
+ : null;
857
+ if (seededSide) {
858
+ const seed = panel[seededSide] || makeLeaf({
859
+ content: { kind: `panel:${seededSide}`, props: {} },
860
+ title: PANEL_TITLES[seededSide] || seededSide,
861
+ });
862
+ if (panel[seededSide] && PANEL_TITLES[seededSide]) {
863
+ panel[seededSide].title = PANEL_TITLES[seededSide];
864
+ }
865
+ if (!panel[seededSide]) tree._register(seed);
866
+ if (!tree.nodes.has(seed.id)) tree.nodes.set(seed.id, seed);
867
+ seed.parentId = null;
868
+ tree.rootId = seed.id;
869
+ }
870
+ // else: nothing requested; tree stays empty (renderer shows
871
+ // the "Empty desktop" placeholder).
872
+ }
873
+
874
+ // 4. Wrap content row with left + right (h-split), then wrap
875
+ // that with bottom (v-split) so bottom spans full width.
876
+ const wrap = (panelLeaf, side) => {
877
+ // Re-register the leaf if it was orphaned by detach.
878
+ if (panelLeaf && !tree.nodes.has(panelLeaf.id)) {
879
+ tree.nodes.set(panelLeaf.id, panelLeaf);
880
+ }
881
+ const leaf = panelLeaf || makeLeaf({
882
+ content: { kind: `panel:${side}`, props: {} },
883
+ title: PANEL_TITLES[side] || side,
884
+ });
885
+ // Heal old leaves whose stored title is still 'left'/etc.
886
+ if (panelLeaf && PANEL_TITLES[side] && panelLeaf.title !== PANEL_TITLES[side]) {
887
+ panelLeaf.title = PANEL_TITLES[side];
888
+ }
889
+ if (!panelLeaf) tree._register(leaf);
890
+ const before = (side === 'left');
891
+ const dir = (side === 'bottom') ? 'v' : 'h';
892
+ const children = before ? [leaf.id, tree.rootId] : [tree.rootId, leaf.id];
893
+ const sizes = (side === 'bottom') ? [4, 1]
894
+ : (side === 'left') ? [1, 4]
895
+ : [4, 1];
896
+ const split = {
897
+ id: `split-panel-${side}-${Math.random().toString(36).slice(2, 7)}`,
898
+ kind: 'split', parentId: null, dir, children, sizes,
899
+ };
900
+ tree.nodes.set(split.id, split);
901
+ const oldRoot = tree.get(tree.rootId);
902
+ if (oldRoot) oldRoot.parentId = split.id;
903
+ leaf.parentId = split.id;
904
+ tree.rootId = split.id;
905
+ };
906
+ // Skip the side already consumed as the seed root (would
907
+ // otherwise wrap a fresh duplicate leaf around the existing
908
+ // panel-as-root).
909
+ if (desktop.panels.left && seededSide !== 'left' && tree.rootId) wrap(panel.left, 'left');
910
+ if (desktop.panels.right && seededSide !== 'right' && tree.rootId) wrap(panel.right, 'right');
911
+ if (desktop.panels.bottom && seededSide !== 'bottom' && tree.rootId) wrap(panel.bottom, 'bottom');
912
+
913
+ // GC orphaned nodes (panel leaves whose `panels.X` flipped to
914
+ // false stayed in tree.nodes after detach).
915
+ const reachable = new Set();
916
+ const walk = (id) => {
917
+ if (!id || reachable.has(id)) return;
918
+ reachable.add(id);
919
+ const n = tree.get(id);
920
+ if (n?.kind === 'split') n.children.forEach(walk);
921
+ };
922
+ walk(tree.rootId);
923
+ for (const id of [...tree.nodes.keys()]) {
924
+ if (!reachable.has(id)) tree.nodes.delete(id);
925
+ }
926
+ }
927
+
928
+ _findPanelLeaf(side) {
929
+ const kind = `panel:${side}`;
930
+ for (const leaf of this._tree().leaves()) {
931
+ if (leaf.content?.kind === kind) return leaf;
932
+ }
933
+ return null;
934
+ }
935
+
936
+ // ── Desktops ────────────────────────────────────────────────────
937
+ switchDesktop(idx) {
938
+ this.desktops.ensureCount(idx + 1);
939
+ if (!this.desktops.switchTo(idx)) return;
940
+ const d = this.desktops.active();
941
+ // Runtime-created desktops (ensureCount / addDesktop) are bare
942
+ // home-leaf trees whose panels haven't been materialized yet —
943
+ // only desktops present at load() were canonicalized. Without
944
+ // this, the new desktop shows no panels while the toggle buttons
945
+ // (which read `d.panels`) report them open. Canonicalize on every
946
+ // switch: it's idempotent for already-canonical trees.
947
+ this._canonicalize(d.tree, d);
948
+ this.renderer.tree = d.tree;
949
+ this.renderer.render();
950
+ this._persist();
951
+ this._notifyChange();
952
+ }
953
+
954
+ addDesktop() {
955
+ this.desktops.addDesktop();
956
+ this._notifyChange();
957
+ this._persist();
958
+ }
959
+
960
+ /** Step to the previous (-1) or next (+1) existing desktop, wrapping
961
+ * around. No-op with a single desktop. Iterates only desktops that
962
+ * already exist — use switchDesktop(idx) to create-on-demand. */
963
+ cycleDesktop(dir) {
964
+ const m = this.desktops;
965
+ const n = m.desktops.length;
966
+ if (n <= 1) return;
967
+ const next = (m.activeIdx + (dir < 0 ? -1 : 1) + n) % n;
968
+ this.switchDesktop(next);
969
+ }
970
+
971
+ /** Cycle the focused tile's main-panel tabs (Ctrl+Tab / Ctrl+Shift+Tab).
972
+ * Wraps; no-op when the focused leaf has 0 or 1 tabs. */
973
+ cycleFocusedTab(dir) {
974
+ const tree = this._tree();
975
+ const leafId = tree.focusedLeafId;
976
+ const leaf = leafId ? tree.get(leafId) : null;
977
+ const tabs = (leaf && Array.isArray(leaf.tabs)) ? leaf.tabs : [];
978
+ if (tabs.length <= 1) return;
979
+ const cur = Math.max(0, Math.min(tabs.length - 1, leaf.activeTabIdx || 0));
980
+ const next = (cur + (dir < 0 ? -1 : 1) + tabs.length) % tabs.length;
981
+ this._leafTabAction(leafId, 'switch', { idx: next });
982
+ }
983
+
984
+ removeDesktop(idx) {
985
+ const m = this.desktops;
986
+ if (m.desktops.length <= 1) return false;
987
+ if (idx < 0 || idx >= m.desktops.length) return false;
988
+ m.desktops.splice(idx, 1);
989
+ if (m.activeIdx >= m.desktops.length) m.activeIdx = m.desktops.length - 1;
990
+ this.renderer.tree = m.active().tree;
991
+ this.renderer.render();
992
+ this._persist();
993
+ this._notifyChange();
994
+ return true;
995
+ }
996
+
997
+ moveFocusedToDesktop(idx) {
998
+ const tree = this._tree();
999
+ const focused = tree.focused();
1000
+ if (!focused || !focused.content) return;
1001
+ const payload = { kind: focused.content.kind, props: focused.content.props, title: focused.title };
1002
+ this.desktops.ensureCount(idx + 1);
1003
+ const target = this.desktops.desktops[idx];
1004
+ // Place into the target tree's primary leaf.
1005
+ const targetTree = target.tree;
1006
+ const primary = targetTree.primaryLeafId();
1007
+ if (primary) targetTree.setLeafContent(primary, { kind: payload.kind, props: payload.props }, payload.title);
1008
+ // Remove from current desktop.
1009
+ tree.close(focused.id);
1010
+ if (!tree.rootId) tree.setRoot(makeLeaf(this._rootLeaf()));
1011
+ this.renderer.render();
1012
+ this._persist();
1013
+ this._notifyChange();
1014
+ }
1015
+
1016
+ // ── Internals ───────────────────────────────────────────────────
1017
+ _tree() { return this.desktops.active().tree; }
1018
+
1019
+ _leafAction(leafId, action) {
1020
+ const tree = this._tree();
1021
+ tree.focus(leafId);
1022
+ if (action === 'close') this.closeFocused();
1023
+ else if (action === 'promote') this.toggleManagedFocused();
1024
+ // Chrome split buttons mirror Alt+H / Alt+V: structural split of
1025
+ // this tile into a fresh empty pane (the user then fills it).
1026
+ else if (action === 'split-h') this.split('h');
1027
+ else if (action === 'split-v') this.split('v');
1028
+ }
1029
+
1030
+ /** Tab-strip event dispatcher. The renderer fires actions
1031
+ * (`switch` / `close` / `move` / `open-menu`) and the WM
1032
+ * translates them into tile-tree mutations + a re-render. */
1033
+ _leafTabAction(leafId, action, data = {}) {
1034
+ const tree = this._tree();
1035
+ const leaf = tree.get(leafId);
1036
+ if (!leaf || leaf.kind !== 'leaf') return;
1037
+ if (action === 'switch') {
1038
+ tree.setActiveLeafTab(leafId, data.idx);
1039
+ tree.focus(leafId);
1040
+ this.renderer.render();
1041
+ this._persist();
1042
+ this._notifyChange('tab-switch');
1043
+ return;
1044
+ }
1045
+ if (action === 'close') {
1046
+ const tabs = leaf.tabs || [];
1047
+ // Closing the last remaining tab closes the tile — same
1048
+ // behavior as Alt+W on a single-tab leaf today.
1049
+ if (tabs.length <= 1) {
1050
+ tree.focus(leafId);
1051
+ this.closeFocused();
1052
+ return;
1053
+ }
1054
+ tree.removeLeafTab(leafId, data.idx);
1055
+ tree.focus(leafId);
1056
+ this.renderer.render();
1057
+ this._persist();
1058
+ this._notifyChange('twm-tab-close');
1059
+ return;
1060
+ }
1061
+ if (action === 'move') {
1062
+ tree.moveLeafTab(leafId, data.from, data.to);
1063
+ this.renderer.render();
1064
+ this._persist();
1065
+ this._notifyChange('tab-move');
1066
+ return;
1067
+ }
1068
+ if (action === 'menu') {
1069
+ this._showTabContextMenu(leafId, data.idx, data.x, data.y);
1070
+ return;
1071
+ }
1072
+ if (action === 'close-others') {
1073
+ tree.closeOtherTabs(leafId, data.idx);
1074
+ this.renderer.render();
1075
+ this._persist();
1076
+ this._notifyChange('tab-close-others');
1077
+ return;
1078
+ }
1079
+ if (action === 'close-right') {
1080
+ tree.closeTabsAfter(leafId, data.idx);
1081
+ this.renderer.render();
1082
+ this._persist();
1083
+ this._notifyChange('tab-close-right');
1084
+ return;
1085
+ }
1086
+ if (action === 'close-left') {
1087
+ tree.closeTabsBefore(leafId, data.idx);
1088
+ this.renderer.render();
1089
+ this._persist();
1090
+ this._notifyChange('tab-close-left');
1091
+ return;
1092
+ }
1093
+ if (action === 'open-menu') {
1094
+ // Defer to a per-WM callback if anyone wired one (the
1095
+ // hamburger menu mounts via this hook). The renderer hands
1096
+ // us screen coordinates so the menu can anchor below the
1097
+ // hamburger button.
1098
+ try {
1099
+ this.ctx?.onTileTabMenu?.(leafId, data.x, data.y);
1100
+ } catch (err) {
1101
+ console.warn('[wm] tab menu hook failed', err);
1102
+ }
1103
+ }
1104
+ }
1105
+
1106
+ /** Central navigation entry point. Every nav action — left menu,
1107
+ * breadcrumb, landing-row click, in-tile reference, panel
1108
+ * reference, entity-to-entity — should route through this so the
1109
+ * one holistic routing concept lives in one place.
1110
+ *
1111
+ * A link declares its target as TWO axes:
1112
+ *
1113
+ * `opts.dest` — WHERE the content lands:
1114
+ * 'main' → the primary content tile (default).
1115
+ * The nav panel always uses this.
1116
+ * 'origin' → the tile/window the click came from
1117
+ * (needs `ctx`). Breadcrumbs use this so
1118
+ * a segment click stays in the current
1119
+ * tile (and inside a floating window when
1120
+ * windowed).
1121
+ * 'window' → a fresh managed window.
1122
+ * `opts.newTab` — HOW it lands in that destination:
1123
+ * false → replace the destination's active tab
1124
+ * (default). true → append a NEW tab.
1125
+ * Ignored for 'window' (one window = one
1126
+ * content).
1127
+ *
1128
+ * `opts.ctx` — the caller's mount context (`leafId`,
1129
+ * `windowId`); required for `dest:'origin'`.
1130
+ * `opts.transient` — the appended tab is not persisted/restored
1131
+ * (e.g. an add-row form). Only meaningful with
1132
+ * `newTab:true`.
1133
+ *
1134
+ * Back-compat: the legacy `opts.target` enum still works and maps
1135
+ * onto the axes — 'auto'→origin, 'tab'→origin+newTab,
1136
+ * 'primary'→main, 'window'→window. Prefer the two-axis form.
1137
+ *
1138
+ * The lower-level primitives (`openFromContext`,
1139
+ * `openInTabFromContext`, `openInTabInPrimary`, `openInPrimary`,
1140
+ * `openInWindow`) stay internal; callers prefer `wm.navigate(...)`. */
1141
+ navigate(kind, props = {}, opts = {}) {
1142
+ const { ctx = null, transient = false } = opts;
1143
+ // Resolve the two axes, honoring the legacy `target` alias.
1144
+ let { dest = 'main', newTab = false } = opts;
1145
+ if (opts.target != null) {
1146
+ switch (opts.target) {
1147
+ case 'window': dest = 'window'; newTab = false; break;
1148
+ case 'primary': dest = 'main'; newTab = false; break;
1149
+ case 'tab': dest = 'origin'; newTab = true; break;
1150
+ case 'split-h': dest = 'split-h'; break;
1151
+ case 'split-v': dest = 'split-v'; break;
1152
+ case 'auto':
1153
+ default: dest = 'origin'; newTab = false; break;
1154
+ }
1155
+ }
1156
+ // Split destinations: split the originating tile (or, lacking a
1157
+ // tile context, the focused tile) and mount a fresh instance in
1158
+ // the new pane. This is the routing for the code-pane "open in
1159
+ // horizontal/vertical split" buttons.
1160
+ if (dest === 'split-h' || dest === 'split-v') {
1161
+ const dir = dest === 'split-h' ? 'h' : 'v';
1162
+ const leafId = ctx?.leafId || this._tree().focusedLeafId;
1163
+ return this.splitLeafWith(leafId, dir, kind, props, _tabTitle(kind, props));
1164
+ }
1165
+ if (dest === 'window') return this._navigateWindow(kind, props);
1166
+ if (dest === 'main') {
1167
+ return newTab
1168
+ ? this.openInTabInPrimary(kind, props, transient)
1169
+ : this.openInPrimary(kind, props);
1170
+ }
1171
+ // dest === 'origin'
1172
+ return newTab
1173
+ ? this._navigateTab(ctx, kind, props, transient)
1174
+ : this._navigateAuto(ctx, kind, props);
1175
+ }
1176
+
1177
+ _navigateAuto(ctx, kind, props) {
1178
+ // Windowed content → replace window in place.
1179
+ if (ctx?.windowId && this._windowToLeaf.has(ctx.windowId)) {
1180
+ this.openInWindow(ctx.windowId, kind, props);
1181
+ return;
1182
+ }
1183
+ // In-tile content → replace ACTIVE TAB only (preserves siblings).
1184
+ // Panel tiles (left/right/bottom) fall through — their clicks
1185
+ // target the primary content tile, not the panel itself.
1186
+ if (ctx?.leafId) {
1187
+ const tree = this._tree();
1188
+ const leaf = tree.get(ctx.leafId);
1189
+ const k = leaf?.content?.kind;
1190
+ if (leaf && k && !k.startsWith('panel:')) {
1191
+ this.openInLeaf(ctx.leafId, kind, props);
1192
+ return;
1193
+ }
1194
+ }
1195
+ // Outside the tile (top-nav, panel, palette) → primary tile,
1196
+ // page-aware swap that preserves per-page tab archives.
1197
+ this.openInPrimary(kind, props);
1198
+ }
1199
+
1200
+ _navigateTab(ctx, kind, props, transient = false) {
1201
+ // Windows aren't tabbed — "open in tab" inside a window just
1202
+ // replaces the window's content.
1203
+ if (ctx?.windowId && this._windowToLeaf.has(ctx.windowId)) {
1204
+ this.openInWindow(ctx.windowId, kind, props);
1205
+ return;
1206
+ }
1207
+ this.openInTabFromContext(ctx || {}, kind, props, transient);
1208
+ }
1209
+
1210
+ /** Spawn a fresh ManagedWindow with the requested content. No
1211
+ * source leaf — closing the window just disposes the content. */
1212
+ _navigateWindow(kind, props) {
1213
+ const winId = `twm-mw-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 5)}`;
1214
+ const contentEl = document.createElement('div');
1215
+ contentEl.className = 'twm-window-content';
1216
+ contentEl.style.cssText = 'display:flex; flex-direction:column; flex:1; min-width:0; min-height:0; height:100%;';
1217
+ const title = _tabTitle(kind, props);
1218
+ const mountInfo = this.content.mount(kind, contentEl, props,
1219
+ { ...this.ctx, wm: this, windowId: winId });
1220
+ const win = new ManagedWindow({
1221
+ id: winId,
1222
+ title: mountInfo?.title || title,
1223
+ icon: 'web_asset',
1224
+ content: contentEl,
1225
+ canMinimize: true,
1226
+ canMaximize: true,
1227
+ canResize: true,
1228
+ modal: false,
1229
+ onClose: () => this._onManagedWindowClosed(winId, mountInfo),
1230
+ });
1231
+ // leafId is null — `_onManagedWindowClosed` already short-circuits
1232
+ // both branches when there's no source leaf, so the close path
1233
+ // just disposes the content and drops the map entry.
1234
+ this._windowToLeaf.set(winId, {
1235
+ leafId: null,
1236
+ desktopIdx: this.desktops.activeIdx,
1237
+ original: { kind, props: { ...(props || {}) },
1238
+ title: mountInfo?.title || title },
1239
+ mountInfo, window: win, contentEl,
1240
+ _demoting: false,
1241
+ });
1242
+ win.show();
1243
+ // Cascade: a fresh window centers by default, so opening one from
1244
+ // another window would land exactly on top — reading as the
1245
+ // calling window being replaced. Offset by the current window
1246
+ // count (cycling every 6) so each new window is slightly inset
1247
+ // from the last.
1248
+ const _n = this._windowToLeaf.size; // includes this new window
1249
+ if (_n > 1 && typeof win._applyPosition === 'function') {
1250
+ const step = ((_n - 1) % 6) * 28;
1251
+ win.x = (win.x || 0) + step;
1252
+ win.y = (win.y || 0) + step;
1253
+ win._applyPosition();
1254
+ }
1255
+ this._decorateManagedWindow(win, winId);
1256
+ this._notifyChange('window-spawned');
1257
+ }
1258
+
1259
+ /** Browser-style tab context menu. Items reflect the leaf's current
1260
+ * tab list — "Close others" is hidden when only one tab is open,
1261
+ * "Close to the right / left" are hidden at the edges. The actual
1262
+ * mutations route back through `_leafTabAction` so persistence +
1263
+ * notify stay in one place. */
1264
+ _showTabContextMenu(leafId, idx, x, y) {
1265
+ const tree = this._tree();
1266
+ const leaf = tree.get(leafId);
1267
+ if (!leaf || leaf.kind !== 'leaf') return;
1268
+ const tabs = leaf.tabs || [];
1269
+ if (tabs.length === 0) return;
1270
+ const items = [
1271
+ { label: 'Close tab', icon: 'close', action: 'close' },
1272
+ ];
1273
+ if (tabs.length > 1) {
1274
+ items.push({ label: 'Close other tabs', icon: 'tab_close', action: 'close-others' });
1275
+ }
1276
+ if (idx < tabs.length - 1) {
1277
+ items.push({ label: 'Close tabs to the right', icon: 'chevron_right',
1278
+ action: 'close-right' });
1279
+ }
1280
+ if (idx > 0) {
1281
+ items.push({ label: 'Close tabs to the left', icon: 'chevron_left',
1282
+ action: 'close-left' });
1283
+ }
1284
+ showContextMenu(x, y, items, (action) => {
1285
+ this._leafTabAction(leafId, action, { idx });
1286
+ });
1287
+ }
1288
+
1289
+ /** Open content in a new tab on the leaf where the call originated.
1290
+ * Mirrors `openFromContext` (windowed / split-leaf / primary
1291
+ * routing) but uses `appendLeafTab` so the existing content
1292
+ * stays in place as a tab. */
1293
+ openInTabFromContext(ctx, kind, props = {}, transient = false) {
1294
+ // Managed-window content: just open in the window — managed
1295
+ // windows aren't tabbed (one window = one content).
1296
+ if (ctx?.windowId && this._windowToLeaf.has(ctx.windowId)) {
1297
+ this.openInWindow(ctx.windowId, kind, props);
1298
+ return;
1299
+ }
1300
+ const tree = this._tree();
1301
+ let leafId = ctx?.leafId;
1302
+ if (!leafId) leafId = tree.primaryLeafId();
1303
+ const leaf = leafId ? tree.get(leafId) : null;
1304
+ const k = leaf?.content?.kind;
1305
+ if (!leaf || !leafId || (k && k.startsWith('panel:'))) {
1306
+ // Panel tiles can't host content tabs — fall back to the
1307
+ // primary leaf (matches openFromContext fall-through).
1308
+ this.openInPrimary(kind, props);
1309
+ return;
1310
+ }
1311
+ tree.appendLeafTab(leafId, { kind, props }, _tabTitle(kind, props), { transient });
1312
+ tree.focus(leafId);
1313
+ this.renderer.render();
1314
+ this._persist();
1315
+ this._notifyChange('tab-open');
1316
+ }
1317
+
1318
+ /** Open content as a NEW tab in the primary content tile, regardless
1319
+ * of where the call came from (the `dest:'main', newTab:true` path).
1320
+ * Unlike `openInTabFromContext`, this never falls back to a replace:
1321
+ * it targets the primary leaf directly and always appends, so a
1322
+ * click from outside the tile system (e.g. the bottom-panel
1323
+ * "Add row" button, which passes no ctx) reliably lands as a sibling
1324
+ * tab in the main tile rather than swapping its content. */
1325
+ openInTabInPrimary(kind, props = {}, transient = false) {
1326
+ const tree = this._tree();
1327
+ const leafId = tree.primaryLeafId();
1328
+ // No content tile on this desktop (e.g. a panels-only layout) —
1329
+ // open in a managed window rather than spawning a bare tile. The
1330
+ // caller asked for "a tab in the main tile"; with no main tile to
1331
+ // tab into, a floating window is the least-surprising fallback.
1332
+ if (!leafId) { this._navigateWindow(kind, props); return; }
1333
+ tree.appendLeafTab(leafId, { kind, props }, _tabTitle(kind, props), { transient });
1334
+ tree.focus(leafId);
1335
+ this.renderer.render();
1336
+ this._persist();
1337
+ this._notifyChange('tab-open');
1338
+ }
1339
+ }
1340
+
1341
+
1342
+ /** Derive a tab's display title from the content it carries — entity
1343
+ * label first, then id, then the bare kind. Used everywhere a tab is
1344
+ * created, so the bar names what the user opened rather than the kind
1345
+ * of thing it is. The rename-bus listener in the WM keeps these in
1346
+ * sync when the underlying entity is renamed. */
1347
+ function _tabTitle(kind, props) {
1348
+ if (props && typeof props.label === 'string' && props.label) return props.label;
1349
+ if (props && (typeof props.id === 'string' || typeof props.id === 'number')
1350
+ && String(props.id)) return String(props.id);
1351
+ return kind || '';
1352
+ }