flexdesk 0.16.1 → 0.17.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 (252) hide show
  1. package/CHANGELOG.md +93 -0
  2. package/LICENSE +0 -0
  3. package/README.md +2217 -42
  4. package/THIRD_PARTY_LICENSES.md +0 -0
  5. package/css/base.css +1834 -75
  6. package/css/flexdesk.css +3146 -164
  7. package/css/overrides.css +16 -0
  8. package/css/reset.css +0 -0
  9. package/css/tokens.css +0 -0
  10. package/dist/canvas.js +6 -239
  11. package/dist/canvas.js.map +3 -3
  12. package/dist/charts.js +709 -18
  13. package/dist/charts.js.map +4 -4
  14. package/dist/chunk-3PHCPZHT.js +0 -0
  15. package/dist/chunk-3PHCPZHT.js.map +0 -0
  16. package/dist/chunk-4A7SJC3T.js +503 -0
  17. package/dist/chunk-4A7SJC3T.js.map +7 -0
  18. package/dist/{chunk-6JSOVNID.js → chunk-CVMCZZX2.js} +635 -10
  19. package/dist/chunk-CVMCZZX2.js.map +7 -0
  20. package/dist/chunk-D3COLZZ2.js +1311 -0
  21. package/dist/chunk-D3COLZZ2.js.map +7 -0
  22. package/dist/chunk-FOOS3T5L.js +0 -0
  23. package/dist/chunk-FOOS3T5L.js.map +0 -0
  24. package/dist/{chunk-SN4LSOAR.js → chunk-GOQLR5WR.js} +452 -2
  25. package/dist/chunk-GOQLR5WR.js.map +7 -0
  26. package/dist/{chunk-FL5KFNQH.js → chunk-HNU6MM2B.js} +52 -17
  27. package/dist/chunk-HNU6MM2B.js.map +7 -0
  28. package/dist/chunk-HYJYYEPK.js +375 -0
  29. package/dist/chunk-HYJYYEPK.js.map +7 -0
  30. package/dist/chunk-JKZLNKG6.js +2041 -0
  31. package/dist/chunk-JKZLNKG6.js.map +7 -0
  32. package/dist/chunk-JYWURG5T.js +0 -0
  33. package/dist/chunk-JYWURG5T.js.map +0 -0
  34. package/dist/{chunk-RQM5Z2MU.js → chunk-MCIXFNVF.js} +633 -311
  35. package/dist/chunk-MCIXFNVF.js.map +7 -0
  36. package/dist/chunk-N7HC3ADV.js +30 -0
  37. package/dist/chunk-N7HC3ADV.js.map +7 -0
  38. package/dist/{chunk-FBGP5KN5.js → chunk-Q36EPT2R.js} +3268 -203
  39. package/dist/chunk-Q36EPT2R.js.map +7 -0
  40. package/dist/chunk-QWQJUDPD.js +610 -0
  41. package/dist/chunk-QWQJUDPD.js.map +7 -0
  42. package/dist/{chunk-NLTOY57M.js → chunk-UURZ37SD.js} +28 -48
  43. package/dist/chunk-UURZ37SD.js.map +7 -0
  44. package/dist/{chunk-B5Q53LAJ.js → chunk-UWO43BZU.js} +2 -2
  45. package/dist/chunk-UWO43BZU.js.map +7 -0
  46. package/dist/chunk-WVFGV5FT.js +0 -0
  47. package/dist/chunk-WVFGV5FT.js.map +0 -0
  48. package/dist/core.js +170 -118
  49. package/dist/core.js.map +2 -2
  50. package/dist/editor.js +156 -70
  51. package/dist/editor.js.map +2 -2
  52. package/dist/flexdesk.css +3146 -164
  53. package/dist/flow.js +2789 -306
  54. package/dist/flow.js.map +4 -4
  55. package/dist/host.js +109 -3
  56. package/dist/host.js.map +3 -3
  57. package/dist/reset.css +0 -0
  58. package/dist/tile_registry-6WZPTUZV.js +0 -0
  59. package/dist/tile_registry-6WZPTUZV.js.map +0 -0
  60. package/dist/tiles.js +657 -234
  61. package/dist/tiles.js.map +4 -4
  62. package/dist/tokens.css +0 -0
  63. package/dist/widgets.js +3252 -2124
  64. package/dist/widgets.js.map +4 -4
  65. package/dist/wm.js +1227 -347
  66. package/dist/wm.js.map +4 -4
  67. package/package.json +3 -1
  68. package/src/canvas/canvas_adapter.js +5 -0
  69. package/src/canvas/edge_router.js +0 -0
  70. package/src/canvas/graph/connector_router.js +0 -0
  71. package/src/canvas/graph/history_service.js +0 -0
  72. package/src/canvas/graph/node_platform.js +0 -0
  73. package/src/canvas/index.js +1 -0
  74. package/src/canvas/layered_layout.js +244 -0
  75. package/src/charts/chart_types.js +0 -0
  76. package/src/charts/data_series_plot_window.js +0 -0
  77. package/src/charts/downsample.js +2 -2
  78. package/src/charts/plot_config.js +53 -29
  79. package/src/charts/plot_config_editor.js +627 -0
  80. package/src/charts/plot_grid.js +449 -0
  81. package/src/charts/plot_popout_window.js +103 -317
  82. package/src/charts/plotly_wrapper.js +22 -2
  83. package/src/charts/series_transforms.js +206 -0
  84. package/src/core/event_bus.js +0 -0
  85. package/src/core/logging.js +1 -1
  86. package/src/core/settings.js +105 -19
  87. package/src/core/state_guard.js +93 -51
  88. package/src/core/state_guard_config.js +85 -74
  89. package/src/core/state_machine.js +0 -0
  90. package/src/editor/editor_pane.js +3 -3
  91. package/src/editor/monaco_editor_factory.js +124 -60
  92. package/src/editor/monaco_loader.js +103 -28
  93. package/src/editor/notebook_search.js +0 -0
  94. package/src/editor/notebook_tab_bar.js +38 -52
  95. package/src/editor/notebook_undo_manager.js +0 -0
  96. package/src/editor/split_pane_container.js +6 -3
  97. package/src/flow/canvas_editor/connections.js +90 -0
  98. package/src/flow/canvas_editor/editor.js +2351 -153
  99. package/src/flow/canvas_editor/index.js +2 -1
  100. package/src/flow/canvas_editor/layout.js +119 -12
  101. package/src/flow/canvas_editor/strings.js +80 -0
  102. package/src/flow/kit/always_before.js +0 -0
  103. package/src/flow/kit/catalogue.js +97 -3
  104. package/src/flow/kit/chip_input.js +0 -0
  105. package/src/flow/kit/dom.js +0 -0
  106. package/src/flow/kit/findings.js +0 -0
  107. package/src/flow/kit/history.js +0 -0
  108. package/src/flow/kit/index.js +3 -3
  109. package/src/flow/kit/keys.js +7 -1
  110. package/src/flow/kit/logic_graph.js +97 -15
  111. package/src/flow/kit/references.js +0 -0
  112. package/src/flow/kit/settings_panel.js +0 -0
  113. package/src/flow/kit/settings_schema.js +2 -1
  114. package/src/flow/kit/step_picker.js +0 -0
  115. package/src/flow/kit/strings.js +3 -1
  116. package/src/flow/kit/value_picker.js +0 -0
  117. package/src/flow/kit/widgets.js +86 -109
  118. package/src/flow/lanes/editor.js +136 -14
  119. package/src/flow/lanes/index.js +3 -1
  120. package/src/flow/lanes/layout.js +0 -0
  121. package/src/flow/lanes/pipeline.js +44 -2
  122. package/src/flow/lanes/preview.js +0 -0
  123. package/src/flow/lanes/strings.js +1 -0
  124. package/src/flow/outline/editor.js +0 -0
  125. package/src/flow/outline/index.js +0 -0
  126. package/src/flow/outline/mapping.js +0 -0
  127. package/src/flow/outline/menu.js +0 -0
  128. package/src/flow/outline/operations.js +0 -0
  129. package/src/flow/outline/recognise.js +0 -0
  130. package/src/flow/outline/strings.js +0 -0
  131. package/src/help/help_modal.js +168 -42
  132. package/src/help/help_registry.js +12 -1
  133. package/src/host/external_links.js +0 -0
  134. package/src/host/file_picker.js +117 -0
  135. package/src/host/host.js +10 -2
  136. package/src/host/photino_host.js +9 -0
  137. package/src/host/photino_host.md +21 -3
  138. package/src/host/pywebview_host.js +10 -0
  139. package/src/tiles/chart_tiles.js +53 -245
  140. package/src/tiles/config_schema.js +8 -0
  141. package/src/tiles/figure_tile.js +322 -0
  142. package/src/tiles/layout_persistence.js +3 -3
  143. package/src/tiles/list_tiles.js +7 -7
  144. package/src/tiles/scope_picker.js +0 -0
  145. package/src/tiles/tile_base.js +1 -1
  146. package/src/tiles/tile_config_form.js +442 -0
  147. package/src/tiles/tile_grid.js +2 -0
  148. package/src/tiles/tile_registry.js +0 -0
  149. package/src/tiling/bottom_panel_tabs.js +67 -99
  150. package/src/tiling/command_palette.js +64 -19
  151. package/src/tiling/content_registry.js +4 -1
  152. package/src/tiling/desktops.js +11 -1
  153. package/src/tiling/entity_sources.js +0 -0
  154. package/src/tiling/keymap.js +403 -128
  155. package/src/tiling/kind_taxonomy.js +78 -0
  156. package/src/tiling/landing_table.js +0 -0
  157. package/src/tiling/loading_overlay.js +0 -0
  158. package/src/tiling/nav_panel.js +59 -10
  159. package/src/tiling/page_factory.js +5 -5
  160. package/src/tiling/panel_availability.js +49 -0
  161. package/src/tiling/panel_keys.js +0 -0
  162. package/src/tiling/shell.js +114 -12
  163. package/src/tiling/tab_strip.js +0 -0
  164. package/src/tiling/tile_breadcrumb.js +0 -0
  165. package/src/tiling/tile_renderer.js +105 -9
  166. package/src/tiling/tile_tab_menu.js +0 -0
  167. package/src/tiling/tile_tree.js +0 -0
  168. package/src/tiling/wm.js +562 -30
  169. package/src/tiling/zoom.js +0 -0
  170. package/src/ui/base/component_base.js +0 -0
  171. package/src/ui/base/controller_base.js +0 -0
  172. package/src/ui/base/page_base.js +0 -0
  173. package/src/ui/components/about_dialog.js +0 -0
  174. package/src/ui/components/action_dropdown.js +30 -2
  175. package/src/ui/components/alert_log.js +0 -0
  176. package/src/ui/components/alerts_pane.js +0 -0
  177. package/src/ui/components/attribute_list_editor.js +0 -0
  178. package/src/ui/components/autocomplete_field.js +0 -0
  179. package/src/ui/components/cell_editor_host.js +211 -0
  180. package/src/ui/components/cell_grid.js +648 -0
  181. package/src/ui/components/cell_selection.js +220 -0
  182. package/src/ui/components/choice_controls.js +351 -0
  183. package/src/ui/components/color_field.js +211 -0
  184. package/src/ui/components/color_list_field.js +210 -0
  185. package/src/ui/components/column_fit.js +0 -0
  186. package/src/ui/components/column_groups.js +314 -0
  187. package/src/ui/components/column_order.js +142 -0
  188. package/src/ui/components/computing_status_window.js +98 -25
  189. package/src/ui/components/confirm_dialog.js +81 -38
  190. package/src/ui/components/context_menu.js +2 -2
  191. package/src/ui/components/conversation_view.js +440 -0
  192. package/src/ui/components/crosstab.js +289 -0
  193. package/src/ui/components/data_table.js +601 -62
  194. package/src/ui/components/detail_header.js +22 -11
  195. package/src/ui/components/document_outline.js +397 -0
  196. package/src/ui/components/drag_reorder.js +0 -0
  197. package/src/ui/components/fact_table.js +0 -0
  198. package/src/ui/components/fill_handle.js +322 -0
  199. package/src/ui/components/gallery_picker.js +19 -8
  200. package/src/ui/components/header_actions.js +55 -0
  201. package/src/ui/components/inline_renamer.js +0 -0
  202. package/src/ui/components/job_progress.js +51 -0
  203. package/src/ui/components/managed_window.js +27 -4
  204. package/src/ui/components/menu_bar.js +745 -0
  205. package/src/ui/components/modal.js +449 -75
  206. package/src/ui/components/notification_history.js +3 -3
  207. package/src/ui/components/progress.js +483 -0
  208. package/src/ui/components/record_list.js +0 -0
  209. package/src/ui/components/row_activation.js +0 -0
  210. package/src/ui/components/section_stack.js +298 -0
  211. package/src/ui/components/series.js +173 -0
  212. package/src/ui/components/settings_view.js +634 -0
  213. package/src/ui/components/slide_out_panel.js +43 -6
  214. package/src/ui/components/slider_field.js +275 -18
  215. package/src/ui/components/sortable_list.js +0 -0
  216. package/src/ui/components/staged_edits.js +343 -0
  217. package/src/ui/components/staged_history.js +172 -0
  218. package/src/ui/components/table_filter_chips.js +0 -0
  219. package/src/ui/components/table_state_store.js +0 -0
  220. package/src/ui/components/table_structure.js +182 -0
  221. package/src/ui/components/table_values.js +0 -0
  222. package/src/ui/components/tabs.js +497 -0
  223. package/src/ui/components/tag_input.js +215 -0
  224. package/src/ui/components/toast.js +25 -8
  225. package/src/ui/components/toggle_field.js +175 -0
  226. package/src/ui/components/tree_view.js +55 -34
  227. package/src/ui/components/window_taskbar.js +0 -0
  228. package/src/ui/components/wizard.js +563 -0
  229. package/src/ui/controllers/window_chrome_controller.js +2 -2
  230. package/src/ui/notification_center.js +375 -119
  231. package/src/ui/utils/auto_scrollbars.js +0 -0
  232. package/src/ui/utils/markdown.js +709 -0
  233. package/src/ui/utils/native_context_menu.js +0 -0
  234. package/src/ui/utils/overlay_scrollbar.js +16 -12
  235. package/src/ui/utils/raf_resize_observer.js +0 -0
  236. package/src/ui/utils/scroll_lock.js +0 -0
  237. package/src/ui/utils/strip_scroll.js +55 -0
  238. package/src/ui/utils/tooltip_service.js +0 -8
  239. package/dist/chunk-6JSOVNID.js.map +0 -7
  240. package/dist/chunk-B5Q53LAJ.js.map +0 -7
  241. package/dist/chunk-FBGP5KN5.js.map +0 -7
  242. package/dist/chunk-FL5KFNQH.js.map +0 -7
  243. package/dist/chunk-NLTOY57M.js.map +0 -7
  244. package/dist/chunk-NNFTETK3.js +0 -633
  245. package/dist/chunk-NNFTETK3.js.map +0 -7
  246. package/dist/chunk-O5OHMWBB.js +0 -284
  247. package/dist/chunk-O5OHMWBB.js.map +0 -7
  248. package/dist/chunk-RQM5Z2MU.js.map +0 -7
  249. package/dist/chunk-SN4LSOAR.js.map +0 -7
  250. package/dist/chunk-W6FBJLPW.js +0 -367
  251. package/dist/chunk-W6FBJLPW.js.map +0 -7
  252. package/src/ui/controllers/panel_state_machine.js +0 -634
package/src/tiling/wm.js CHANGED
@@ -9,8 +9,12 @@
9
9
  * wm.closeFocused() close the focused tile
10
10
  * wm.focusDir(dir) / wm.moveFocused(dir)
11
11
  * wm.toggleManagedFocused() tile <-> managed window
12
- * wm.togglePanel(side) left | right | bottom
13
- * wm.isPanelOpen(side) boolean
12
+ * wm.togglePanel(side) left | right | bottom; false if withheld
13
+ * wm.isPanelOpen(side) boolean (open AND available)
14
+ * wm.isPanelAvailable(side) boolean
15
+ * wm.getPanelAvailability(side) { available, reason }
16
+ * wm.setPanelAvailability(side, o) runtime override; null clears it
17
+ * wm.onPanelAvailabilityChange(fn) subscribe; returns an unsubscribe
14
18
  * wm.switchDesktop(idx) / wm.addDesktop() / wm.moveFocusedToDesktop(idx)
15
19
  */
16
20
 
@@ -22,6 +26,7 @@ import { installPanelKeyRouter } from './panel_keys.js';
22
26
  import { ManagedWindow } from '../ui/components/managed_window.js';
23
27
  import { showContextMenu } from '../ui/components/context_menu.js';
24
28
  import { createTabStrip } from './tab_strip.js';
29
+ import { PANEL_OFFERED, PANEL_SIDES, assertPanelSide, readPanelOffer } from './panel_availability.js';
25
30
 
26
31
  const PANEL_KINDS = new Set(['panel:left', 'panel:right', 'panel:bottom']);
27
32
 
@@ -58,6 +63,17 @@ export class WindowManager {
58
63
  this.ctx = ctx || {};
59
64
  this.eventBus = eventBus || null;
60
65
  this.onChange = onChange || (() => {});
66
+ // Panel availability. Runtime overrides (`setPanelAvailability`) win
67
+ // over the primary kind's declaration (`KindDef.panels`); the last
68
+ // availability announced, so a change is announced once; and who
69
+ // wants to hear about it. Before anything is mounted every side is
70
+ // offered, so a first primary that withholds one is announced.
71
+ this._panelOverrides = {};
72
+ this._panelAvailability = Object.fromEntries(PANEL_SIDES.map((s) => [s, PANEL_OFFERED]));
73
+ this._panelAvailabilityListeners = new Set();
74
+ this._reconcilingPanels = false;
75
+ /** >0 while a composite verb runs: see `_revealAt`. */
76
+ this._quietDepth = 0;
61
77
  // C34. Whether the tile's float gesture takes only the tab on screen.
62
78
  this.floatActiveTab = !!floatActiveTab;
63
79
 
@@ -132,6 +148,7 @@ export class WindowManager {
132
148
  },
133
149
  onFocusChange: () => this._notifyChange(),
134
150
  onAfterRender: () => this._rehomeContainedWindows(),
151
+ onSplitResized: (splitId) => this._onSplitResized(splitId),
135
152
  });
136
153
  this._persistTimer = null;
137
154
  // Window-id → leaf-id mapping so a managed-window demote can
@@ -201,6 +218,13 @@ export class WindowManager {
201
218
  * so other surfaces (palette, top-bar toggles, page shortcuts) can
202
219
  * subscribe through the existing event system. */
203
220
  _notifyChange(reason = null) {
221
+ // A composite verb (a single-kind reveal) is ONE change: the verbs it
222
+ // is built from stay quiet while it runs, and it notifies once.
223
+ if (this._quietDepth > 0) return;
224
+ // Whatever changed may have changed the primary kind, and with it which
225
+ // panels are offered: put the tree and the announcement right FIRST,
226
+ // so every listener below sees the panels as they now are.
227
+ this._reconcilePanels();
204
228
  try { this.onChange(reason); } catch (err) { console.error('[wm] onChange threw', err); }
205
229
  try { this.eventBus?.emit?.('wm:changed', { reason, wm: this }); }
206
230
  catch (err) { console.warn('[wm] event emit failed', err); }
@@ -244,10 +268,13 @@ export class WindowManager {
244
268
  }
245
269
  }
246
270
  }
247
- this._canonicalize(d.tree, d);
248
271
  }
272
+ // A single kind is open once, whatever the saved layout says.
273
+ const deduped = this._dropDuplicateSingles();
274
+ for (const d of this.desktops.desktops) this._canonicalize(d.tree, d);
249
275
  this.renderer.tree = this.desktops.active().tree;
250
276
  this.renderer.render();
277
+ if (deduped) this._persist();
251
278
  this._notifyChange();
252
279
  }
253
280
 
@@ -259,6 +286,275 @@ export class WindowManager {
259
286
  }, 500);
260
287
  }
261
288
 
289
+ // ── Single-instance kinds (KindDef `single`) ────────────────────
290
+ //
291
+ // Some content holds heavy app-wide state and must exist once. The
292
+ // taxonomy says which kinds (`single: true`, or `single: '<prop>'` for one
293
+ // per value of that prop); the WM keeps three promises about them:
294
+ //
295
+ // - every OPEN path reveals the instance that exists — it switches to
296
+ // its desktop, activates its tab (restoring an archived page if that is
297
+ // where it is) and focuses its tile, or raises its window — instead of
298
+ // mounting a second one;
299
+ // - the COPY verbs (Alt+T / Alt+N / Alt+Shift+H / Alt+Shift+V, the tile
300
+ // menu's "Open in new tab" and "Open a copy in a window", the palette's
301
+ // split and window modes) are refused, the menus greyed with a reason;
302
+ // - a restored layout holding duplicates keeps the FIRST (desktop order,
303
+ // then tile order; live tabs before archived pages).
304
+ //
305
+ // MOVE verbs (float, dock, drag a tab, move to desktop) carry the one
306
+ // instance somewhere else and are untouched.
307
+
308
+ /** The instance key of a tab spec, or null when its kind is not single. */
309
+ _instanceKey(kind, props) {
310
+ return this.taxonomy.instanceKey(kind, props);
311
+ }
312
+
313
+ /** Whether `kind` exists once, and so refuses the copy verbs. */
314
+ isSingleKind(kind) {
315
+ return this.taxonomy.isSingle(kind);
316
+ }
317
+
318
+ /** Where the one instance of a single kind is open, or null — also null
319
+ * for a kind that is not single. One of:
320
+ * `{ where: 'window', windowId, idx, desktopIdx }`
321
+ * `{ where: 'tab', desktopIdx, leafId, idx }`
322
+ * `{ where: 'archive', desktopIdx, leafId, page, idx }` — a tab of a
323
+ * top-nav page the tile has switched away from (`pageTabs`). */
324
+ findInstance(kind, props = {}) {
325
+ const key = this._instanceKey(kind, props);
326
+ if (key == null) return null;
327
+ const same = (t) => !!t && this._instanceKey(t.kind, t.props) === key;
328
+ for (const [windowId, rec] of this._windowToLeaf) {
329
+ const idx = _windowTabSpecs(rec).findIndex(same);
330
+ if (idx >= 0) return { where: 'window', windowId, idx, desktopIdx: rec.desktopIdx };
331
+ }
332
+ const desktops = this.desktops.desktops;
333
+ for (const [desktopIdx, d] of desktops.entries()) {
334
+ for (const leaf of d.tree.leaves()) {
335
+ const idx = (leaf.tabs || []).findIndex(same);
336
+ if (idx >= 0) return { where: 'tab', desktopIdx, leafId: leaf.id, idx };
337
+ }
338
+ }
339
+ for (const [desktopIdx, d] of desktops.entries()) {
340
+ for (const leaf of d.tree.leaves()) {
341
+ for (const [page, arc] of this._archivedPages(leaf)) {
342
+ const idx = arc.tabs.findIndex(same);
343
+ if (idx >= 0) return { where: 'archive', desktopIdx, leafId: leaf.id, page, idx };
344
+ }
345
+ }
346
+ }
347
+ return null;
348
+ }
349
+
350
+ /** Reveal the instance of a single kind that is open: answers whether
351
+ * there was one. Every open path asks this first. */
352
+ revealInstance(kind, props = {}) {
353
+ const loc = this.findInstance(kind, props);
354
+ return loc ? this._revealAt(loc) : false;
355
+ }
356
+
357
+ /** A leaf's archived pages that can come back — every `pageTabs` entry
358
+ * but the CURRENT page's. `swapToPage` restores an archive without
359
+ * deleting it, so the current page's entry is a stale copy of the live
360
+ * tabs, overwritten the next time the tile leaves the page; counting it
361
+ * would find every live instance twice. */
362
+ _archivedPages(leaf) {
363
+ const kind = leaf?.content?.kind;
364
+ const current = kind ? this.taxonomy.topNavFor(kind) || null : null;
365
+ return Object.entries(leaf?.pageTabs || {})
366
+ .filter(([page, arc]) => page !== current && Array.isArray(arc?.tabs));
367
+ }
368
+
369
+ /** Reveal `loc` with ONE change notification. The verbs a reveal is made
370
+ * of — `showWindowTab`, `switchDesktop` (directly or via `revealWindow`)
371
+ * — each notify on their own; inside a reveal they are quiet, and the
372
+ * reveal notifies once, as every other verb does. */
373
+ _revealAt(loc) {
374
+ this._quietDepth += 1;
375
+ let shown = false;
376
+ try { shown = this._revealAtQuietly(loc); }
377
+ finally { this._quietDepth -= 1; }
378
+ if (shown) this._notifyChange('single-reveal');
379
+ return shown;
380
+ }
381
+
382
+ _revealAtQuietly(loc) {
383
+ if (loc.where === 'window') {
384
+ const rec = this._windowToLeaf.get(loc.windowId);
385
+ if (!rec) return false;
386
+ if (Array.isArray(rec.tabs) && rec.tabs.length) this.showWindowTab(loc.windowId, loc.idx);
387
+ // A window contained to a pane lives on that pane's desktop;
388
+ // `revealWindow` switches there. An uncontained one floats over
389
+ // every desktop and is already on screen.
390
+ this.revealWindow(rec.window);
391
+ // `show()` restores a minimised window and raises a visible one.
392
+ try { rec.window.show(); } catch (err) { console.warn('[wm] reveal: show failed', err); }
393
+ return true;
394
+ }
395
+ const tree = this.desktops.desktops[loc.desktopIdx]?.tree;
396
+ const leaf = tree?.get(loc.leafId);
397
+ if (!leaf || leaf.kind !== 'leaf') return false;
398
+ if (loc.where === 'archive') {
399
+ const tab = leaf.pageTabs[loc.page].tabs[loc.idx];
400
+ const key = this._instanceKey(tab.kind, tab.props);
401
+ const curKind = leaf.content?.kind;
402
+ const current = curKind ? this.taxonomy.topNavFor(curKind) || null : null;
403
+ this._swapToPage(tree, loc.leafId,
404
+ { kind: tab.kind, props: tab.props || {}, title: tab.title },
405
+ current, loc.page, { recordHistory: false });
406
+ const at = (leaf.tabs || []).findIndex((t) => this._instanceKey(t.kind, t.props) === key);
407
+ if (at >= 0) tree.setActiveLeafTab(loc.leafId, at);
408
+ } else {
409
+ tree.setActiveLeafTab(loc.leafId, loc.idx);
410
+ }
411
+ tree.focus(loc.leafId);
412
+ // The tree is settled BEFORE the switch, so the switch's one repaint
413
+ // mounts the instance and nothing else first.
414
+ if (loc.desktopIdx !== this.desktops.activeIdx) {
415
+ this.switchDesktop(loc.desktopIdx);
416
+ } else {
417
+ this.renderer.render();
418
+ this._persist();
419
+ }
420
+ return true;
421
+ }
422
+
423
+ /** `TileTree.swapToPage`, and then no restored tab is a second copy of a
424
+ * single kind. Every page restore in the WM goes through here.
425
+ *
426
+ * A page archive (`pageTabs`) is a snapshot: the instance it lists can
427
+ * have moved to another tile or a window since — and the current page's
428
+ * own entry is a stale copy of the live tabs that comes back into play as
429
+ * soon as the tile leaves the page by a path other than `swapToPage`
430
+ * (`navigateActiveTab`). So a restored tab whose instance is live
431
+ * anywhere else is dropped from the tile and from the restored archive;
432
+ * the instance stays where it is. */
433
+ _swapToPage(tree, leafId, target, currentTopNav, targetTopNav, opts) {
434
+ const done = tree.swapToPage(leafId, target, currentTopNav, targetTopNav, opts);
435
+ if (done) this._dropRestoredCopies(tree, leafId, targetTopNav, target);
436
+ return done;
437
+ }
438
+
439
+ _dropRestoredCopies(tree, leafId, page, target) {
440
+ const leaf = tree.get(leafId);
441
+ const tabs = Array.isArray(leaf?.tabs) ? leaf.tabs : null;
442
+ if (!tabs) return;
443
+ const elsewhere = this._liveInstanceKeys(tree, leafId);
444
+ const keeper = () => {
445
+ const seen = new Set();
446
+ return (t) => {
447
+ const k = t ? this._instanceKey(t.kind, t.props) : null;
448
+ if (k == null) return true;
449
+ if (elsewhere.has(k) || seen.has(k)) return false;
450
+ seen.add(k);
451
+ return true;
452
+ };
453
+ };
454
+ const arc = leaf.pageTabs?.[page];
455
+ if (arc && Array.isArray(arc.tabs)) {
456
+ const kept = arc.tabs.filter(keeper());
457
+ if (kept.length !== arc.tabs.length) {
458
+ arc.tabs = kept;
459
+ arc.activeTabIdx = Math.max(0, Math.min(kept.length - 1, arc.activeTabIdx || 0));
460
+ }
461
+ }
462
+ const keep = keeper();
463
+ let active = leaf.activeTabIdx || 0;
464
+ let changed = false;
465
+ for (let i = 0; i < tabs.length;) {
466
+ if (keep(tabs[i])) { i++; continue; }
467
+ tabs.splice(i, 1);
468
+ if (i < active) active -= 1;
469
+ changed = true;
470
+ }
471
+ if (!changed) return;
472
+ if (!tabs.length) {
473
+ // Everything the archive held lives elsewhere: the tile shows
474
+ // what was asked for. (A single target that is live elsewhere was
475
+ // revealed before any restore, so this tab is never a copy.)
476
+ tabs.push({
477
+ kind: target.kind, props: target.props || {},
478
+ title: target.title || _tabTitle(target.kind, target.props), history: [],
479
+ });
480
+ active = 0;
481
+ }
482
+ tree.setActiveLeafTab(leafId, Math.max(0, Math.min(tabs.length - 1, active)));
483
+ }
484
+
485
+ /** The instance keys of every LIVE tab — windows, and every leaf of every
486
+ * desktop but `leafId` of `tree`. Archived pages are not live. */
487
+ _liveInstanceKeys(tree, leafId) {
488
+ const keys = new Set();
489
+ const add = (t) => {
490
+ const k = t ? this._instanceKey(t.kind, t.props) : null;
491
+ if (k != null) keys.add(k);
492
+ };
493
+ for (const [, rec] of this._windowToLeaf) _windowTabSpecs(rec).forEach(add);
494
+ for (const d of this.desktops.desktops) {
495
+ for (const leaf of d.tree.leaves()) {
496
+ if (d.tree === tree && leaf.id === leafId) continue;
497
+ (leaf.tabs || []).forEach(add);
498
+ }
499
+ }
500
+ return keys;
501
+ }
502
+
503
+ /** A restored layout keeps the FIRST instance of each single kind: later
504
+ * live tabs are dropped (the tile shows the tab that took the dropped
505
+ * one's place, or closes when it held nothing else), and so are later
506
+ * archived ones. Answers whether anything was dropped. */
507
+ _dropDuplicateSingles() {
508
+ const seen = new Set();
509
+ let dropped = false;
510
+ const keep = (t) => {
511
+ const k = t ? this._instanceKey(t.kind, t.props) : null;
512
+ if (k == null) return true;
513
+ if (seen.has(k)) return false;
514
+ seen.add(k);
515
+ return true;
516
+ };
517
+ for (const d of this.desktops.desktops) {
518
+ const tree = d.tree;
519
+ for (const leaf of tree.leaves()) {
520
+ const tabs = Array.isArray(leaf.tabs) ? leaf.tabs : [];
521
+ let active = leaf.activeTabIdx || 0;
522
+ let changed = false;
523
+ for (let i = 0; i < tabs.length;) {
524
+ if (keep(tabs[i])) { i++; continue; }
525
+ tabs.splice(i, 1);
526
+ if (i < active) active -= 1;
527
+ changed = true;
528
+ }
529
+ if (!changed) continue;
530
+ dropped = true;
531
+ if (tabs.length) {
532
+ tree.setActiveLeafTab(leaf.id, Math.max(0, Math.min(tabs.length - 1, active)));
533
+ continue;
534
+ }
535
+ tree.close(leaf.id);
536
+ if (!tree.rootId) tree.setRoot(makeLeaf(this._rootLeaf()));
537
+ if (!tree.leaves().some((l) => !String(l.content?.kind || '').startsWith('panel:'))) {
538
+ const spawned = this._spawnContentLeaf(tree);
539
+ if (spawned) this._seedHome(tree, spawned);
540
+ }
541
+ }
542
+ }
543
+ for (const d of this.desktops.desktops) {
544
+ for (const leaf of d.tree.leaves()) {
545
+ for (const [page, arc] of this._archivedPages(leaf)) {
546
+ const kept = arc.tabs.filter(keep);
547
+ if (kept.length === arc.tabs.length) continue;
548
+ dropped = true;
549
+ if (!kept.length) { delete leaf.pageTabs[page]; continue; }
550
+ arc.tabs = kept;
551
+ arc.activeTabIdx = Math.max(0, Math.min(kept.length - 1, arc.activeTabIdx || 0));
552
+ }
553
+ }
554
+ }
555
+ return dropped;
556
+ }
557
+
262
558
  // ── Content loading ─────────────────────────────────────────────
263
559
  /** Load content into the primary tile (last-active non-panel leaf).
264
560
  /** Walk back one step. Bound to Backspace.
@@ -485,13 +781,16 @@ export class WindowManager {
485
781
  const tree = this._tree();
486
782
  const prior = tree.popActiveTabHistory?.(id);
487
783
  if (!prior || !prior.kind) return false;
784
+ // A single kind opened elsewhere since this entry was pushed is
785
+ // revealed there, not put back here as a second copy.
786
+ if (this.revealInstance(prior.kind, prior.props || {})) return true;
488
787
  // Cross-page back entries carry a `topNav` marker — pop
489
788
  // routes via swapToPage so we land on the correct page.
490
789
  const curKind = tree.get(id)?.content?.kind;
491
790
  const curTopNav = curKind ? this.taxonomy.topNavFor(curKind) : null;
492
791
  const title = prior.title || _tabTitle(prior.kind, prior.props);
493
792
  if (prior.topNav && curTopNav && prior.topNav !== curTopNav) {
494
- tree.swapToPage?.(id, { kind: prior.kind, props: prior.props || {}, title },
793
+ this._swapToPage(tree, id, { kind: prior.kind, props: prior.props || {}, title },
495
794
  curTopNav, prior.topNav, { recordHistory: false });
496
795
  } else {
497
796
  tree.replaceActiveTabContent(id, { kind: prior.kind, props: prior.props || {} },
@@ -593,6 +892,7 @@ export class WindowManager {
593
892
  * the leaf. Used by Backspace + breadcrumb segments + anything
594
893
  * else that should walk WITHIN the tile rather than reset it. */
595
894
  navigateActiveTab(leafId, kind, props = {}) {
895
+ if (this.revealInstance(kind, props)) return;
596
896
  const tree = this._tree();
597
897
  const leaf = tree.get(leafId);
598
898
  if (!leaf || leaf.kind !== 'leaf') {
@@ -648,6 +948,7 @@ export class WindowManager {
648
948
  * tab feature shipped), the behavior is identical to the old
649
949
  * setLeafContent: one tab in, one tab out. */
650
950
  openInLeaf(leafId, kind, props = {}) {
951
+ if (this.revealInstance(kind, props)) return;
651
952
  const tree = this._tree();
652
953
  const leaf = tree.get(leafId);
653
954
  if (!leaf || leaf.kind !== 'leaf') {
@@ -665,6 +966,7 @@ export class WindowManager {
665
966
  * previous mount, mounts the new kind into the same body element,
666
967
  * and updates the window's title. */
667
968
  openInWindow(winId, kind, props = {}) {
969
+ if (this.revealInstance(kind, props)) return;
668
970
  const rec = this._windowToLeaf.get(winId);
669
971
  if (!rec) { this.openInPrimary(kind, props); return; }
670
972
  try { rec.mountInfo?.destroy?.(); } catch {}
@@ -706,6 +1008,7 @@ export class WindowManager {
706
1008
  * If only panel tiles exist (or the tree is empty), spawn a new
707
1009
  * content leaf and canonicalize so the panels wrap it. */
708
1010
  openInPrimary(kind, props = {}) {
1011
+ if (this.revealInstance(kind, props)) return;
709
1012
  const tree = this._tree();
710
1013
  let leafId = tree.primaryLeafId();
711
1014
  let spawned = false;
@@ -726,7 +1029,7 @@ export class WindowManager {
726
1029
  : null;
727
1030
  const targetTopNav = this.taxonomy.topNavFor(kind) || kind;
728
1031
  const title = _tabTitle(kind, props);
729
- tree.swapToPage(leafId,
1032
+ this._swapToPage(tree, leafId,
730
1033
  { kind, props, title },
731
1034
  currentTopNav, targetTopNav);
732
1035
  // C35. THE SAME PAGE WITH DIFFERENT PROPS IS A DIFFERENT VIEW. swapToPage
@@ -788,6 +1091,7 @@ export class WindowManager {
788
1091
  * pane in a split never dismounts the original). Returns the new
789
1092
  * leaf id, or null if the leaf can't be split. */
790
1093
  splitLeafWith(leafId, dir, kind, props = {}, title = '') {
1094
+ if (this.revealInstance(kind, props)) return null;
791
1095
  const tree = this._tree();
792
1096
  leafId = this._contentLeafId(leafId);
793
1097
  const src = leafId ? tree.get(leafId) : null;
@@ -826,10 +1130,11 @@ export class WindowManager {
826
1130
  return { leafId: id, kind, props: leaf.content.props || {}, title: leaf.title };
827
1131
  }
828
1132
 
829
- /** Alt+T — open the focused tile's content in a new tab on that tile. */
1133
+ /** Alt+T — open the focused tile's content in a new tab on that tile.
1134
+ * A copy verb, so a single kind refuses it (and Alt+N, Alt+Shift+H/V). */
830
1135
  openFocusedInTab() {
831
1136
  const c = this._focusedContent();
832
- if (!c) return;
1137
+ if (!c || this.isSingleKind(c.kind)) return;
833
1138
  this.openInTabFromContext({ leafId: c.leafId }, c.kind, c.props);
834
1139
  }
835
1140
 
@@ -837,7 +1142,7 @@ export class WindowManager {
837
1142
  * Unlike Alt+F (promote) this DUPLICATES: the source tile stays put. */
838
1143
  openFocusedInWindow() {
839
1144
  const c = this._focusedContent();
840
- if (!c) return;
1145
+ if (!c || this.isSingleKind(c.kind)) return;
841
1146
  this._navigateWindow(c.kind, c.props);
842
1147
  }
843
1148
 
@@ -847,6 +1152,7 @@ export class WindowManager {
847
1152
  splitFocusedWith(dir) {
848
1153
  const c = this._focusedContent();
849
1154
  if (!c) { this.split(dir); return; }
1155
+ if (this.isSingleKind(c.kind)) return;
850
1156
  this.splitLeafWith(c.leafId, dir, c.kind, c.props, c.title);
851
1157
  }
852
1158
 
@@ -3138,16 +3444,97 @@ export class WindowManager {
3138
3444
  * edge (a recursive split) or closes that leaf. */
3139
3445
  isPanelOpen(side) {
3140
3446
  const d = this.desktops.active();
3141
- return !!d.panels[side];
3447
+ return !!d.panels[side] && this.isPanelAvailable(side);
3142
3448
  }
3143
3449
 
3450
+ /** Flip a panel. A withheld panel is not flipped: answers `false` and
3451
+ * touches nothing — not the remembered open state, not the layout, not
3452
+ * what is persisted. Answers `true` when it flipped. */
3144
3453
  togglePanel(side) {
3454
+ if (!this.isPanelAvailable(side)) return false;
3145
3455
  const d = this.desktops.active();
3146
3456
  d.panels[side] = !d.panels[side];
3147
3457
  this._canonicalize(this._tree(), d);
3148
3458
  this.renderer.render();
3149
3459
  this._persist();
3150
3460
  this._notifyChange();
3461
+ return true;
3462
+ }
3463
+
3464
+ // ── Panel availability ──────────────────────────────────────────
3465
+ /** Whether the panel on `side` is offered on the active desktop right now:
3466
+ * `{ available, reason }`. A runtime override wins; else the primary
3467
+ * tile's kind says (`KindDef.panels`, see kind_taxonomy.js); else it is
3468
+ * offered. `desktops.panels` is untouched by any of this — it remembers
3469
+ * what the user OPENED, so a panel withheld while it was open comes back
3470
+ * open once it is offered again. */
3471
+ getPanelAvailability(side, tree = this._tree()) {
3472
+ assertPanelSide(side, 'getPanelAvailability');
3473
+ const override = this._panelOverrides[side];
3474
+ if (override) return override;
3475
+ const id = tree.primaryLeafId();
3476
+ const content = id ? tree.get(id)?.content : null;
3477
+ // A tile holding a floated window's place still stands for that content.
3478
+ const kind = content?.kind === PLACEHOLDER_KIND
3479
+ ? content.props?.originalKind
3480
+ : content?.kind;
3481
+ return this.taxonomy.panelOffer(kind, side);
3482
+ }
3483
+
3484
+ isPanelAvailable(side) {
3485
+ return this.getPanelAvailability(side).available;
3486
+ }
3487
+
3488
+ /** Content's say, at runtime: `offer` is `true`, `false` or `{ reason }`
3489
+ * and beats the kind's declaration until it is cleared with `null`. The
3490
+ * override belongs to the shell, not to a tile — content that sets one
3491
+ * clears it when it goes (its `destroy`). */
3492
+ setPanelAvailability(side, offer) {
3493
+ assertPanelSide(side, 'setPanelAvailability');
3494
+ if (offer === null || offer === undefined) delete this._panelOverrides[side];
3495
+ else this._panelOverrides[side] = readPanelOffer(offer, 'setPanelAvailability');
3496
+ this._notifyChange('panel-availability');
3497
+ }
3498
+
3499
+ /** Subscribe to availability changes. `fn({ panels, changed })` runs ONCE
3500
+ * per change, however many sides changed in it: `panels` is every side's
3501
+ * `{ available, reason }`, `changed` the sides that differ from the last
3502
+ * call. The same payload goes out on the bus as `wm:panels:availability`.
3503
+ * Returns the unsubscribe. */
3504
+ onPanelAvailabilityChange(fn) {
3505
+ this._panelAvailabilityListeners.add(fn);
3506
+ return () => this._panelAvailabilityListeners.delete(fn);
3507
+ }
3508
+
3509
+ /** Bring the active desktop's panel leaves in line with what is offered,
3510
+ * and announce a change of availability. Called from `_notifyChange`, so
3511
+ * every path that can change the primary kind passes through it. */
3512
+ _reconcilePanels() {
3513
+ if (this._reconcilingPanels || !this.desktops) return;
3514
+ this._reconcilingPanels = true;
3515
+ try {
3516
+ const d = this.desktops.active();
3517
+ const panels = {};
3518
+ for (const side of PANEL_SIDES) panels[side] = this.getPanelAvailability(side, d.tree);
3519
+ const shown = (side) => !!d.panels[side] && panels[side].available;
3520
+ if (d.tree.rootId && PANEL_SIDES.some((s) => shown(s) !== !!this._findPanelLeaf(s))) {
3521
+ this._canonicalize(d.tree, d);
3522
+ this.renderer.render();
3523
+ }
3524
+ const last = this._panelAvailability;
3525
+ const changed = PANEL_SIDES.filter((s) => last[s].available !== panels[s].available
3526
+ || last[s].reason !== panels[s].reason);
3527
+ if (!changed.length) return;
3528
+ this._panelAvailability = panels;
3529
+ const payload = Object.freeze({ panels: Object.freeze(panels), changed, wm: this });
3530
+ for (const fn of [...this._panelAvailabilityListeners]) {
3531
+ try { fn(payload); } catch (err) { console.error('[wm] panel availability listener threw', err); }
3532
+ }
3533
+ try { this.eventBus?.emit?.('wm:panels:availability', payload); }
3534
+ catch (err) { console.warn('[wm] event emit failed', err); }
3535
+ } finally {
3536
+ this._reconcilingPanels = false;
3537
+ }
3151
3538
  }
3152
3539
 
3153
3540
  /** Rebuild the tree so panel:* leaves sit in the canonical positions:
@@ -3162,16 +3549,30 @@ export class WindowManager {
3162
3549
  * Existing panel leaves are detached + re-attached (same id), so the
3163
3550
  * renderer cache keeps the mounted content (nav filter state, etc.). */
3164
3551
  _canonicalize(tree, desktop) {
3165
- // 1. Find existing panel leaves.
3166
- const panel = {};
3167
- for (const leaf of tree.leaves()) {
3168
- const kind = leaf.content?.kind;
3169
- if (kind === 'panel:left') panel.left = leaf;
3170
- else if (kind === 'panel:right') panel.right = leaf;
3171
- else if (kind === 'panel:bottom') panel.bottom = leaf;
3552
+ // 0. Which panels this tree shows: the ones the user has open AND the
3553
+ // primary content offers. A withheld panel is left out without
3554
+ // forgetting it was open (`desktop.panels` is not written).
3555
+ const open = {};
3556
+ for (const side of PANEL_SIDES) {
3557
+ open[side] = !!desktop.panels[side] && this.getPanelAvailability(side, tree).available;
3172
3558
  }
3173
3559
 
3174
- // 2. Detach each panel leaf from its current parent. (We don't
3560
+ // 1. Find existing panel leaves.
3561
+ const panel = _panelLeaves(tree);
3562
+
3563
+ // 2. Measure each panel that has no share yet BEFORE anything is
3564
+ // detached. Detaching collapses the panel's split, so this is the
3565
+ // last moment a size the tree still holds can be read: a panel
3566
+ // still at its default, or a stored blob that predates
3567
+ // `panelShares`. A panel that has a share keeps it: the tree holds
3568
+ // what was BUILT from that share, which the drag range may have
3569
+ // held back (step 5), and only a drag (_onSplitResized) changes
3570
+ // what the user asked for. A tree a structural change has just
3571
+ // left non-canonical measures nothing; see _recordPanelShares.
3572
+ this._recordPanelShares(tree, desktop);
3573
+ const share = desktop.panelShares;
3574
+
3575
+ // 3. Detach each panel leaf from its current parent. (We don't
3175
3576
  // use tree.close because that deletes the node; we want to
3176
3577
  // preserve identity so the renderer keeps the mounted body.)
3177
3578
  const detach = (leaf) => {
@@ -3194,15 +3595,15 @@ export class WindowManager {
3194
3595
  };
3195
3596
  detach(panel.left); detach(panel.right); detach(panel.bottom);
3196
3597
 
3197
- // 3. The content subtree may legitimately be empty — the user
3598
+ // 4. The content subtree may legitimately be empty — the user
3198
3599
  // is allowed to close the last main tile and let panels
3199
3600
  // take over the entire WM area. If so, seed root from the
3200
3601
  // first requested panel; otherwise content stays as root.
3201
3602
  let seededSide = null;
3202
3603
  if (!tree.rootId) {
3203
- seededSide = desktop.panels.left ? 'left'
3204
- : desktop.panels.bottom ? 'bottom'
3205
- : desktop.panels.right ? 'right'
3604
+ seededSide = open.left ? 'left'
3605
+ : open.bottom ? 'bottom'
3606
+ : open.right ? 'right'
3206
3607
  : null;
3207
3608
  if (seededSide) {
3208
3609
  const seed = panel[seededSide] || makeLeaf({
@@ -3221,8 +3622,30 @@ export class WindowManager {
3221
3622
  // the "Empty desktop" placeholder).
3222
3623
  }
3223
3624
 
3224
- // 4. Wrap content row with left + right (h-split), then wrap
3625
+ // 5. Wrap content row with left + right (h-split), then wrap
3225
3626
  // that with bottom (v-split) so bottom spans full width.
3627
+ //
3628
+ // Sizes: a panel with a share from step 2 gets back the same
3629
+ // share of the WM area; only a panel that has never had one (opened
3630
+ // for the first time on this desktop) takes the default 1:4. Left
3631
+ // sits INSIDE the right panel's split, so its slot share of that
3632
+ // inner split is its area share divided by the slot the right panel
3633
+ // leaves over, which keeps the Navigator's width when the right
3634
+ // panel is toggled. The drag range is applied here, to the slot
3635
+ // share of the split being built, for every side: a share taken at
3636
+ // a different time (the right panel dragged wide while the left was
3637
+ // closed) can ask for a slot past it, and no split is built that a
3638
+ // splitter drag could not produce. A canonical tree built from its
3639
+ // own measured shares therefore comes back at the same sizes.
3640
+ const willWrap = (side) => open[side] && seededSide !== side && !!tree.rootId;
3641
+ const DEFAULT_SHARE = 1 / 5;
3642
+ const slotShare = (side) => {
3643
+ const own = share[side];
3644
+ if (own == null) return null;
3645
+ if (side !== 'left' || !willWrap('right')) return _clampSlotShare(own);
3646
+ const right = _clampSlotShare(share.right ?? DEFAULT_SHARE);
3647
+ return _clampSlotShare(own / (1 - right));
3648
+ };
3226
3649
  const wrap = (panelLeaf, side) => {
3227
3650
  // Re-register the leaf if it was orphaned by detach.
3228
3651
  if (panelLeaf && !tree.nodes.has(panelLeaf.id)) {
@@ -3240,9 +3663,9 @@ export class WindowManager {
3240
3663
  const before = (side === 'left');
3241
3664
  const dir = (side === 'bottom') ? 'v' : 'h';
3242
3665
  const children = before ? [leaf.id, tree.rootId] : [tree.rootId, leaf.id];
3243
- const sizes = (side === 'bottom') ? [4, 1]
3244
- : (side === 'left') ? [1, 4]
3245
- : [4, 1];
3666
+ const kept = slotShare(side);
3667
+ const sizes = (kept != null) ? (before ? [kept, 1 - kept] : [1 - kept, kept])
3668
+ : before ? [1, 4] : [4, 1];
3246
3669
  const split = {
3247
3670
  id: `split-panel-${side}-${Math.random().toString(36).slice(2, 7)}`,
3248
3671
  kind: 'split', parentId: null, dir, children, sizes,
@@ -3256,12 +3679,12 @@ export class WindowManager {
3256
3679
  // Skip the side already consumed as the seed root (would
3257
3680
  // otherwise wrap a fresh duplicate leaf around the existing
3258
3681
  // panel-as-root).
3259
- if (desktop.panels.left && seededSide !== 'left' && tree.rootId) wrap(panel.left, 'left');
3260
- if (desktop.panels.right && seededSide !== 'right' && tree.rootId) wrap(panel.right, 'right');
3261
- if (desktop.panels.bottom && seededSide !== 'bottom' && tree.rootId) wrap(panel.bottom, 'bottom');
3682
+ if (willWrap('left')) wrap(panel.left, 'left');
3683
+ if (willWrap('right')) wrap(panel.right, 'right');
3684
+ if (willWrap('bottom')) wrap(panel.bottom, 'bottom');
3262
3685
 
3263
- // GC orphaned nodes (panel leaves whose `panels.X` flipped to
3264
- // false stayed in tree.nodes after detach).
3686
+ // GC orphaned nodes (panel leaves whose side is no longer shown —
3687
+ // closed, or withheld — stayed in tree.nodes after detach).
3265
3688
  const reachable = new Set();
3266
3689
  const walk = (id) => {
3267
3690
  if (!id || reachable.has(id)) return;
@@ -3275,6 +3698,72 @@ export class WindowManager {
3275
3698
  }
3276
3699
  }
3277
3700
 
3701
+ /** A finished splitter drag of split `splitId` is a layout change like
3702
+ * any other: record the shares of the panels that split sizes (the ones
3703
+ * inside it; the Navigator sits inside the right panel's split) while
3704
+ * the tree is still canonical (a drag never changes its structure), then
3705
+ * save, so the dragged size is what the next load, or the next
3706
+ * structural change, gives back. */
3707
+ _onSplitResized(splitId) {
3708
+ this._recordPanelShares(this._tree(), this.desktops.active(), splitId);
3709
+ this._persist();
3710
+ this._notifyChange('split-resized');
3711
+ }
3712
+
3713
+ /** Keep `desktop.panelShares` current: each panel's share of the WM area
3714
+ * along its own axis (width for left/right, height for bottom), kept on
3715
+ * the desktop record and serialized with it, so a panel the user closes
3716
+ * comes back at the size it had, even across a reload while it was
3717
+ * closed. With `resizedSplitId`, the panels inside that split are
3718
+ * measured (a drag); without, the panels that have no share yet are.
3719
+ * A panel that measures nothing (not on the tree, the root, or under a
3720
+ * split that is not one of its canonical panel splits) keeps the share
3721
+ * it last had. A share is recorded as measured, never clamped: the drag
3722
+ * range belongs to the slot of the split being built (step 5 of
3723
+ * _canonicalize), not to an area share. A stored value that is not a
3724
+ * share at all (not a finite number strictly between 0 and 1) is
3725
+ * dropped, so that panel opens at the default again. */
3726
+ _recordPanelShares(tree, desktop, resizedSplitId = null) {
3727
+ const panel = _panelLeaves(tree);
3728
+ for (const side of PANEL_SIDES) {
3729
+ const kept = _validShare(desktop.panelShares[side]);
3730
+ const measure = resizedSplitId == null ? kept == null
3731
+ : _isInside(tree, panel[side], resizedSplitId);
3732
+ const v = (measure ? this._panelShare(tree, panel[side], side) : null) ?? kept;
3733
+ if (v == null) delete desktop.panelShares[side];
3734
+ else desktop.panelShares[side] = v;
3735
+ }
3736
+ }
3737
+
3738
+ /** The share (0..1) of the WM area the `side` panel leaf takes along its
3739
+ * axis: the product of its shares in every ancestor split running that
3740
+ * way (splits across the axis do not change it). Only a CANONICAL
3741
+ * placement measures: the leaf's own parent is its `split-panel-<side>`
3742
+ * split and every ancestor is a panel split. Any other tree is one a
3743
+ * structural change has left in passing (the last content tile closed,
3744
+ * or a spawned content leaf wrapping the whole root), whose sizes are
3745
+ * not the user's; that, a missing or root leaf, or a degenerate split
3746
+ * gives null. */
3747
+ _panelShare(tree, leaf, side) {
3748
+ if (!leaf?.parentId) return null;
3749
+ if (!String(leaf.parentId).startsWith(`split-panel-${side}-`)) return null;
3750
+ const axis = side === 'bottom' ? 'v' : 'h';
3751
+ let frac = 1;
3752
+ let node = leaf;
3753
+ while (node.parentId) {
3754
+ const parent = tree.get(node.parentId);
3755
+ if (!parent || !String(parent.id).startsWith('split-panel-')) return null;
3756
+ if (parent.dir === axis) {
3757
+ const idx = parent.children.indexOf(node.id);
3758
+ const total = parent.sizes.reduce((a, b) => a + b, 0);
3759
+ if (idx < 0 || !(total > 0) || !(parent.sizes[idx] > 0)) return null;
3760
+ frac *= parent.sizes[idx] / total;
3761
+ }
3762
+ node = parent;
3763
+ }
3764
+ return (frac > 0 && frac < 1) ? frac : null;
3765
+ }
3766
+
3278
3767
  _findPanelLeaf(side) {
3279
3768
  const kind = `panel:${side}`;
3280
3769
  for (const leaf of this._tree().leaves()) {
@@ -3692,6 +4181,7 @@ export class WindowManager {
3692
4181
  /** Spawn a fresh ManagedWindow with the requested content. No
3693
4182
  * source leaf — closing the window just disposes the content. */
3694
4183
  _navigateWindow(kind, props) {
4184
+ if (this.revealInstance(kind, props)) return;
3695
4185
  const winId = `twm-mw-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 5)}`;
3696
4186
  const contentEl = document.createElement('div');
3697
4187
  contentEl.className = 'twm-window-content';
@@ -3820,6 +4310,7 @@ export class WindowManager {
3820
4310
  * routing) but uses `appendLeafTab` so the existing content
3821
4311
  * stays in place as a tab. */
3822
4312
  openInTabFromContext(ctx, kind, props = {}, transient = false, background = false) {
4313
+ if (this.revealInstance(kind, props)) return;
3823
4314
  // Managed-window content: just open in the window — managed
3824
4315
  // windows aren't tabbed (one window = one content).
3825
4316
  if (ctx?.windowId && this._windowToLeaf.has(ctx.windowId)) {
@@ -3855,6 +4346,7 @@ export class WindowManager {
3855
4346
  * "Add row" button, which passes no ctx) reliably lands as a sibling
3856
4347
  * tab in the main tile rather than swapping its content. */
3857
4348
  openInTabInPrimary(kind, props = {}, transient = false, background = false) {
4349
+ if (this.revealInstance(kind, props)) return;
3858
4350
  const tree = this._tree();
3859
4351
  const leafId = tree.primaryLeafId();
3860
4352
  // No content tile on this desktop (e.g. a panels-only layout) —
@@ -3874,6 +4366,39 @@ export class WindowManager {
3874
4366
  }
3875
4367
 
3876
4368
 
4369
+ /** The panel leaves of `tree`, by side. */
4370
+ function _panelLeaves(tree) {
4371
+ const panel = {};
4372
+ for (const leaf of tree.leaves()) {
4373
+ const kind = leaf.content?.kind;
4374
+ if (kind === 'panel:left') panel.left = leaf;
4375
+ else if (kind === 'panel:right') panel.right = leaf;
4376
+ else if (kind === 'panel:bottom') panel.bottom = leaf;
4377
+ }
4378
+ return panel;
4379
+ }
4380
+
4381
+ /** Whether `node` sits (at any depth) inside the split `splitId`. */
4382
+ function _isInside(tree, node, splitId) {
4383
+ for (let id = node?.parentId; id; id = tree.get(id)?.parentId) {
4384
+ if (id === splitId) return true;
4385
+ }
4386
+ return false;
4387
+ }
4388
+
4389
+ /** `v` if it is a share (a finite number strictly between 0 and 1), else
4390
+ * null. */
4391
+ function _validShare(v) {
4392
+ return (typeof v === 'number' && Number.isFinite(v) && v > 0 && v < 1) ? v : null;
4393
+ }
4394
+
4395
+ /** A slot share of a split being built, held to the range a splitter drag
4396
+ * allows a slot: [MIN_SLOT_FRACTION, 1 - MIN_SLOT_FRACTION]. */
4397
+ function _clampSlotShare(v) {
4398
+ const min = TileRenderer.MIN_SLOT_FRACTION;
4399
+ return Math.min(Math.max(v, min), 1 - min);
4400
+ }
4401
+
3877
4402
  /** Derive a tab's display title from the content it carries — entity
3878
4403
  * label first, then id, then the bare kind. Used everywhere a tab is
3879
4404
  * created, so the bar names what the user opened rather than the kind
@@ -3886,6 +4411,13 @@ function _tabTitle(kind, props) {
3886
4411
  return kind || '';
3887
4412
  }
3888
4413
 
4414
+ /** The tab specs a window record holds: a floated pane's list, or the one
4415
+ * content an Alt+N / adopted window carries. */
4416
+ function _windowTabSpecs(rec) {
4417
+ if (Array.isArray(rec?.tabs) && rec.tabs.length) return rec.tabs;
4418
+ return rec?.original ? [rec.original] : [];
4419
+ }
4420
+
3889
4421
  /** R8. A leaf's tabs as the plain specs a window record holds — deep enough
3890
4422
  * that editing one afterwards cannot reach back into the tree, which matters
3891
4423
  * because a floated tab keeps being edited (`openInWindow` renames it) while