flexdesk 0.16.0 → 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 (251) hide show
  1. package/CHANGELOG.md +93 -0
  2. package/LICENSE +0 -0
  3. package/README.md +2261 -42
  4. package/THIRD_PARTY_LICENSES.md +0 -0
  5. package/css/base.css +1834 -75
  6. package/css/flexdesk.css +3373 -238
  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-UWO43BZU.js +111 -0
  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 +3373 -238
  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 +1131 -253
  61. package/dist/tiles.js.map +4 -4
  62. package/dist/tokens.css +0 -0
  63. package/dist/widgets.js +3254 -2112
  64. package/dist/widgets.js.map +4 -4
  65. package/dist/wm.js +1227 -345
  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 +422 -0
  144. package/src/tiles/scope_picker.js +96 -0
  145. package/src/tiles/tile_base.js +12 -1
  146. package/src/tiles/tile_config_form.js +442 -0
  147. package/src/tiles/tile_grid.js +38 -1
  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 +26 -4
  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-CWPMLX67.js +0 -475
  241. package/dist/chunk-CWPMLX67.js.map +0 -7
  242. package/dist/chunk-FBGP5KN5.js.map +0 -7
  243. package/dist/chunk-FL5KFNQH.js.map +0 -7
  244. package/dist/chunk-NLTOY57M.js.map +0 -7
  245. package/dist/chunk-NNFTETK3.js +0 -633
  246. package/dist/chunk-NNFTETK3.js.map +0 -7
  247. package/dist/chunk-O5OHMWBB.js +0 -284
  248. package/dist/chunk-O5OHMWBB.js.map +0 -7
  249. package/dist/chunk-RQM5Z2MU.js.map +0 -7
  250. package/dist/chunk-SN4LSOAR.js.map +0 -7
  251. package/src/ui/controllers/panel_state_machine.js +0 -634
package/dist/wm.js CHANGED
@@ -1,23 +1,31 @@
1
1
  import {
2
2
  HelpModal,
3
+ createMenuBar
4
+ } from "./chunk-JKZLNKG6.js";
5
+ import {
6
+ TAB_LEVELS,
7
+ createTabs
8
+ } from "./chunk-HYJYYEPK.js";
9
+ import {
3
10
  showContextMenu
4
- } from "./chunk-CWPMLX67.js";
11
+ } from "./chunk-UWO43BZU.js";
5
12
  import {
6
13
  openForm
7
- } from "./chunk-NNFTETK3.js";
14
+ } from "./chunk-D3COLZZ2.js";
8
15
  import {
9
16
  ManagedWindow,
10
17
  isRowActivation
11
- } from "./chunk-6JSOVNID.js";
12
- import "./chunk-FL5KFNQH.js";
18
+ } from "./chunk-CVMCZZX2.js";
19
+ import "./chunk-HNU6MM2B.js";
13
20
  import {
14
21
  NotebookTabBar
15
- } from "./chunk-NLTOY57M.js";
22
+ } from "./chunk-UURZ37SD.js";
23
+ import "./chunk-N7HC3ADV.js";
16
24
  import "./chunk-WVFGV5FT.js";
17
25
  import "./chunk-JYWURG5T.js";
18
26
 
19
27
  // src/tiling/bottom_panel_tabs.js
20
- var BADGE_LEVELS = Object.freeze(["info", "warning", "alert"]);
28
+ var BADGE_LEVELS = TAB_LEVELS;
21
29
  function createBottomPanelTabs(container, {
22
30
  panes = [],
23
31
  remember = null,
@@ -26,103 +34,38 @@ function createBottomPanelTabs(container, {
26
34
  onSelect = null,
27
35
  initial = null
28
36
  } = {}) {
29
- const doc = container.ownerDocument;
30
- const root = doc.createElement("div");
31
- root.className = "twm-bp";
32
- const strip = doc.createElement("div");
33
- strip.className = "twm-bp__tabs";
34
- strip.setAttribute("role", "tablist");
35
- const body = doc.createElement("div");
36
- body.className = "twm-bp__body";
37
- root.append(strip, body);
38
- container.appendChild(root);
39
37
  const byId = new Map(panes.map((pane) => [pane.id, pane]));
40
- const tabs = /* @__PURE__ */ new Map();
41
- const mounted = /* @__PURE__ */ new Map();
42
- let active = null;
38
+ let restoring = false;
43
39
  let chosen = false;
44
40
  let destroyed = false;
45
- for (const pane of panes) {
46
- const tab = doc.createElement("button");
47
- tab.type = "button";
48
- tab.className = "twm-bp__tab";
49
- tab.dataset.pane = pane.id;
50
- tab.setAttribute("role", "tab");
51
- tab.setAttribute("aria-selected", "false");
52
- const label = doc.createElement("span");
53
- label.className = "twm-bp__label";
54
- label.textContent = pane.label;
55
- const badge = doc.createElement("span");
56
- badge.className = "twm-bp__badge";
57
- badge.hidden = true;
58
- tab.append(label, badge);
59
- tab.addEventListener("click", () => api.select(pane.id));
60
- strip.appendChild(tab);
61
- tabs.set(pane.id, tab);
62
- }
63
- function mount(id) {
64
- if (mounted.has(id)) return mounted.get(id);
65
- const pane = byId.get(id);
66
- const el = doc.createElement("div");
67
- el.className = "twm-bp-pane";
68
- el.dataset.pane = id;
69
- el.setAttribute("role", "tabpanel");
70
- el.hidden = id !== active;
71
- body.appendChild(el);
72
- const entry = { el, instance: null };
73
- mounted.set(id, entry);
74
- try {
75
- entry.instance = pane.mount?.(el, store ? store(pane) : null, api) ?? null;
76
- } catch (err) {
77
- console.error(`[bottom-panel] pane "${id}" failed to mount`, err);
78
- }
79
- return entry;
80
- }
81
- function show(id) {
82
- if (destroyed || !byId.has(id)) return false;
83
- active = id;
84
- for (const [key, tab] of tabs) {
85
- const on = key === id;
86
- tab.classList.toggle("twm-bp__tab--on", on);
87
- tab.setAttribute("aria-selected", String(on));
88
- }
89
- for (const [key, entry] of mounted) entry.el.hidden = key !== id;
90
- mount(id).instance?.onShow?.();
91
- onSelect?.(id, byId.get(id));
92
- return true;
93
- }
41
+ let strip = null;
94
42
  const api = {
95
- root,
43
+ root: null,
96
44
  /** Bring a tab to the front, and remember it. */
97
45
  select(id) {
98
- if (!show(id)) return;
46
+ if (destroyed || !byId.has(id)) return;
99
47
  chosen = true;
100
- try {
101
- remember?.write?.(id);
102
- } catch {
48
+ if (strip.active() !== id) {
49
+ strip.select(id);
50
+ return;
103
51
  }
52
+ strip.instance(id)?.onShow?.();
53
+ remembered(id);
104
54
  },
105
55
  /** The tab in front, or null. */
106
- active: () => active,
56
+ active: () => strip.active(),
107
57
  /** `count` on a tab, coloured by `level`; nothing at zero. */
108
- setBadge(id, count, level = null) {
109
- const badge = tabs.get(id)?.querySelector(".twm-bp__badge");
110
- if (!badge) return;
111
- const n = Number(count) || 0;
112
- badge.hidden = n <= 0;
113
- badge.textContent = n > 99 ? "99+" : String(Math.max(n, 0));
114
- badge.className = "twm-bp__badge" + (BADGE_LEVELS.includes(level) ? ` twm-bp__badge--${level}` : "");
115
- },
58
+ setBadge: (id, count, level = null) => strip.setBadge(id, count, level),
116
59
  /** A mounted pane's live instance, or null — how one tab hands work to
117
60
  * another. `select` it first, which mounts it. */
118
- instance: (id) => mounted.get(id)?.instance ?? null,
61
+ instance: (id) => strip.instance(id),
119
62
  /** The panel is on screen again (its tile re-mounted): tell the pane in front. */
120
63
  reshow() {
121
- if (active) mounted.get(active)?.instance?.onShow?.();
64
+ strip.instance(strip.active())?.onShow?.();
122
65
  },
123
66
  /** Before the panel is torn down: every mounted pane saves. */
124
67
  async persist() {
125
- for (const entry of mounted.values()) await entry.instance?.persist?.();
68
+ for (const instance of strip.instances()) await instance.persist?.();
126
69
  },
127
70
  /** Gone for good: panes save, then are destroyed; the bus is let go. */
128
71
  async destroy() {
@@ -133,47 +76,410 @@ function createBottomPanelTabs(container, {
133
76
  subscription?.dispose?.();
134
77
  } catch {
135
78
  }
136
- for (const entry of mounted.values()) {
137
- try {
138
- entry.instance?.destroy?.();
139
- } catch (err) {
140
- console.warn("[bottom-panel] pane destroy failed", err);
141
- }
142
- }
143
- mounted.clear();
144
- root.remove();
79
+ strip.destroy();
145
80
  }
146
81
  };
147
- for (const pane of panes) if (pane.eager) mount(pane.id);
82
+ function remembered(id) {
83
+ onSelect?.(id, byId.get(id));
84
+ try {
85
+ remember?.write?.(id);
86
+ } catch {
87
+ }
88
+ }
89
+ strip = createTabs(container, {
90
+ block: "twm-bp",
91
+ dataKey: "pane",
92
+ items: panes.map(({ id, label, eager }) => ({ id, label, eager: !!eager })),
93
+ active: byId.has(initial) ? initial : panes[0]?.id,
94
+ mountPane: (item, el, tabs) => {
95
+ strip ??= tabs;
96
+ api.root ??= tabs.root;
97
+ const pane = byId.get(item.id);
98
+ return pane.mount?.(el, store ? store(pane) : null, api) ?? null;
99
+ },
100
+ onSelect: (id) => {
101
+ if (restoring) {
102
+ onSelect?.(id, byId.get(id));
103
+ return;
104
+ }
105
+ chosen = true;
106
+ remembered(id);
107
+ },
108
+ // A click on the tab already in front is a select here, as it always
109
+ // was: the pane hears onShow again and the choice is remembered.
110
+ onReselect: (id) => api.select(id)
111
+ });
112
+ api.root ??= strip.root;
113
+ if (strip.active() != null) onSelect?.(strip.active(), byId.get(strip.active()));
148
114
  const subscription = eventBus?.on?.(
149
115
  "bottompanel:show",
150
116
  (payload) => api.select(payload?.tab ?? panes[0]?.id)
151
117
  ) ?? null;
152
- show(byId.has(initial) ? initial : panes[0]?.id);
153
118
  if (remember?.read) {
154
119
  Promise.resolve().then(() => remember.read()).then((saved) => {
155
- if (chosen || destroyed) return;
156
- if (byId.has(saved)) show(saved);
120
+ if (chosen || destroyed || !byId.has(saved)) return;
121
+ if (strip.active() === saved) {
122
+ strip.instance(saved)?.onShow?.();
123
+ onSelect?.(saved, byId.get(saved));
124
+ return;
125
+ }
126
+ restoring = true;
127
+ try {
128
+ strip.select(saved);
129
+ } finally {
130
+ restoring = false;
131
+ }
157
132
  }).catch(() => {
158
133
  });
159
134
  }
160
135
  return api;
161
136
  }
162
137
 
163
- // src/tiling/command_palette.js
164
- var ROOT_ID = "twm-cmdpal";
165
- var STATIC_COMMANDS = [
166
- {
167
- kind: "command",
168
- id: "shortcuts",
169
- label: "Keyboard shortcuts",
170
- hint: "Show all key bindings (?)",
171
- icon: "keyboard",
172
- shortcut: "?",
173
- verb: "run",
174
- action: () => HelpModal.open("keyboard-shortcuts")
138
+ // src/tiling/keymap.js
139
+ var KEYMAP_SHORTCUTS = Object.freeze([
140
+ { keys: "Ctrl+K", action: "Open the command palette" },
141
+ { keys: "?", action: "Open help" },
142
+ { keys: "Esc", action: "Close the palette or a modal" },
143
+ { keys: "F1..F8", action: "Jump to the Nth top-nav page" },
144
+ { keys: "Backspace", action: "Go back one step up the breadcrumb" },
145
+ { keys: "Alt+H / Alt+V", action: "Split the focused tile horizontally / vertically (empty pane)" },
146
+ { keys: "Alt+Shift+H / Alt+Shift+V", action: "Open the focused tile's content in a horizontal / vertical split" },
147
+ { keys: "Alt+T", action: "Open the focused tile's content in a new tab" },
148
+ { keys: "Alt+N", action: "Open the focused tile's content in a new window" },
149
+ { keys: "Alt+W", action: "Close the focused tile" },
150
+ { keys: "Alt+F", action: "Float the focused tile into a window, or dock it back" },
151
+ { keys: "Alt+Arrow", action: "Move focus to the neighbouring tile" },
152
+ { keys: "Alt+Shift+Arrow", action: "Swap the focused tile with its neighbour" },
153
+ { keys: "Alt+1..9", action: "Switch to desktop N" },
154
+ { keys: "Alt+Shift+1..9", action: "Move the focused tile to desktop N" },
155
+ { keys: "Ctrl+Left / Ctrl+Right", action: "Previous / next desktop" },
156
+ { keys: "Shift+Left / Shift+Right", action: "Previous / next bottom-panel tab" },
157
+ { keys: "Ctrl+Tab / Ctrl+Shift+Tab", action: "Next / previous tab in the focused tile" }
158
+ ].map((r) => Object.freeze(r)));
159
+ var KEYBOARD_SHORTCUTS_TOPIC_ID = "keyboard-shortcuts";
160
+ var _esc = (s) => String(s).replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
161
+ function keyboardShortcutsTopic(shortcuts = KEYMAP_SHORTCUTS) {
162
+ const rows = shortcuts.map((r) => `<tr><td><kbd>${_esc(r.keys)}</kbd></td><td>${_esc(r.action)}</td></tr>`).join("");
163
+ return {
164
+ id: KEYBOARD_SHORTCUTS_TOPIC_ID,
165
+ title: "Keyboard shortcuts",
166
+ html: `<table class="help-shortcuts"><thead><tr><th>Keys</th><th>Action</th></tr></thead><tbody>${rows}</tbody></table>`
167
+ };
168
+ }
169
+ var DEFAULT_NAV_SELECTOR = ".twm-global-top-bar .twm-bar-center.twm-top-nav .twm-top-nav__btn";
170
+ var DIRS = ["left", "right", "up", "down"];
171
+ var _where = (d) => d === "up" || d === "down" ? d : `on the ${d}`;
172
+ var ACTIONS = {
173
+ "palette": { label: "Open the command palette", run: ({ palette }) => {
174
+ palette.toggle();
175
+ } },
176
+ "palette:close": { label: "Close the palette", run: ({ palette }) => {
177
+ if (palette.isOpen()) palette.close();
178
+ return false;
179
+ } },
180
+ "help": { label: "Open help", run: () => {
181
+ HelpModal.toggle();
182
+ } },
183
+ "back": { label: "Go back one step up the breadcrumb", run: ({ wm }) => {
184
+ wm.navigateBack?.();
185
+ } },
186
+ "tab:next": { label: "Next tab in the focused tile", run: ({ wm }) => {
187
+ wm.cycleFocusedTab(1);
188
+ } },
189
+ "tab:prev": { label: "Previous tab in the focused tile", run: ({ wm }) => {
190
+ wm.cycleFocusedTab(-1);
191
+ } },
192
+ "desktop:next": { label: "Next desktop", run: ({ wm }) => {
193
+ wm.cycleDesktop(1);
194
+ } },
195
+ "desktop:prev": { label: "Previous desktop", run: ({ wm }) => {
196
+ wm.cycleDesktop(-1);
197
+ } },
198
+ "panelTab:next": { label: "Next bottom-panel tab", run: () => _cyclePanelTab(1) },
199
+ "panelTab:prev": { label: "Previous bottom-panel tab", run: () => _cyclePanelTab(-1) },
200
+ "split:h": { label: "Split the focused tile horizontally (empty pane)", run: ({ wm }) => {
201
+ wm.split("h");
202
+ } },
203
+ "split:v": { label: "Split the focused tile vertically (empty pane)", run: ({ wm }) => {
204
+ wm.split("v");
205
+ } },
206
+ "splitWith:h": { label: "Open the focused tile's content in a horizontal split", run: ({ wm }) => {
207
+ wm.splitFocusedWith("h");
208
+ } },
209
+ "splitWith:v": { label: "Open the focused tile's content in a vertical split", run: ({ wm }) => {
210
+ wm.splitFocusedWith("v");
211
+ } },
212
+ "openInTab": { label: "Open the focused tile's content in a new tab", run: ({ wm }) => {
213
+ wm.openFocusedInTab();
214
+ } },
215
+ "openInWindow": { label: "Open the focused tile's content in a new window", run: ({ wm }) => {
216
+ wm.openFocusedInWindow();
217
+ } },
218
+ "close": { label: "Close the focused tile", run: ({ wm }) => {
219
+ wm.closeFocused();
220
+ } },
221
+ "float": { label: "Float the focused tile into a window, or dock it back", run: ({ wm }) => {
222
+ wm.toggleManagedFocused();
223
+ } },
224
+ // Bare Alt+letter: browsers open their menu bar on it, mid-flow.
225
+ "suppress": { label: null, run: () => {
226
+ } },
227
+ ...Object.fromEntries(DIRS.flatMap((d) => [
228
+ [`focus:${d}`, { label: `Move focus to the tile ${_where(d)}`, run: ({ wm }) => {
229
+ wm.focusDir(d);
230
+ } }],
231
+ [`move:${d}`, { label: `Swap the focused tile with its neighbour ${_where(d)}`, run: ({ wm }) => {
232
+ wm.moveFocused(d);
233
+ } }]
234
+ ]))
235
+ };
236
+ function _action(name) {
237
+ if (typeof name !== "string") return null;
238
+ if (Object.hasOwn(ACTIONS, name)) return ACTIONS[name];
239
+ const m = /^(nav|desktop|toDesktop):(\d+)$/.exec(name);
240
+ if (!m) return null;
241
+ const n = Number(m[2]);
242
+ if (m[1] === "nav") {
243
+ return {
244
+ label: `Jump to top-nav page ${n + 1}`,
245
+ // The selector is a CONFIG option, not a constant. An embedder that
246
+ // moves its sections out of the top bar — into an icon rail, say —
247
+ // would otherwise find F1..F8 silently doing nothing. Passing
248
+ // `navSelector` is how such an embedder keeps the keys.
249
+ run: ({ navSelector }) => {
250
+ const btns = document.querySelectorAll(navSelector || DEFAULT_NAV_SELECTOR);
251
+ if (n >= btns.length) return false;
252
+ btns[n].click();
253
+ return void 0;
254
+ }
255
+ };
256
+ }
257
+ if (m[1] === "desktop") {
258
+ return { label: `Switch to desktop ${n + 1}`, run: ({ wm }) => {
259
+ wm.switchDesktop(n);
260
+ } };
175
261
  }
262
+ return { label: `Move the focused tile to desktop ${n + 1}`, run: ({ wm }) => {
263
+ wm.moveFocusedToDesktop(n);
264
+ } };
265
+ }
266
+ function _cyclePanelTab(dir) {
267
+ const tabs = [...document.querySelectorAll(".twm-bp__tabs .twm-bp__tab")];
268
+ if (!tabs.length) return false;
269
+ const cur = tabs.findIndex((t) => t.classList.contains("twm-bp__tab--on"));
270
+ tabs[((cur < 0 ? 0 : cur) + dir + tabs.length) % tabs.length].click();
271
+ return void 0;
272
+ }
273
+ var ARROWS = ["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"];
274
+ var DIGITS = [1, 2, 3, 4, 5, 6, 7, 8, 9];
275
+ var F_KEYS = Array.from({ length: 12 }, (_, i) => `F${i + 1}`);
276
+ var DEFAULT_CHORDS = [
277
+ ["Ctrl+K", "palette", true],
278
+ ["Meta+K", "palette", true],
279
+ ["?", "help", false],
280
+ ["Escape", "palette:close", true],
281
+ ...F_KEYS.map((f, i) => [f, `nav:${i}`, false]),
282
+ ["Backspace", "back", false],
283
+ ["Alt+H", "split:h", true],
284
+ ["Alt+V", "split:v", true],
285
+ ["Alt+Shift+H", "splitWith:h", true],
286
+ ["Alt+Shift+V", "splitWith:v", true],
287
+ ["Alt+T", "openInTab", true],
288
+ ["Alt+N", "openInWindow", true],
289
+ ["Alt+W", "close", true],
290
+ ["Alt+F", "float", true],
291
+ ...ARROWS.map((k, i) => [`Alt+${k}`, `focus:${DIRS[i]}`, true]),
292
+ ...ARROWS.map((k, i) => [`Alt+Shift+${k}`, `move:${DIRS[i]}`, true]),
293
+ ...DIGITS.map((d) => [`Alt+${d}`, `desktop:${d - 1}`, true]),
294
+ ...DIGITS.map((d) => [`Alt+Shift+${d}`, `toDesktop:${d - 1}`, true]),
295
+ ["Ctrl+ArrowLeft", "desktop:prev", false],
296
+ ["Ctrl+ArrowRight", "desktop:next", false],
297
+ ["Shift+ArrowLeft", "panelTab:prev", false],
298
+ ["Shift+ArrowRight", "panelTab:next", false],
299
+ ["Ctrl+Tab", "tab:next", true],
300
+ ["Meta+Tab", "tab:next", true],
301
+ ["Ctrl+Shift+Tab", "tab:prev", true],
302
+ ["Meta+Shift+Tab", "tab:prev", true],
303
+ ["Alt+Letter", "suppress", false]
176
304
  ];
305
+ var SHORTCUT_CHORDS = [
306
+ ["Ctrl+K", "Meta+K"],
307
+ ["?"],
308
+ ["Escape"],
309
+ F_KEYS,
310
+ ["Backspace"],
311
+ ["Alt+H", "Alt+V"],
312
+ ["Alt+Shift+H", "Alt+Shift+V"],
313
+ ["Alt+T"],
314
+ ["Alt+N"],
315
+ ["Alt+W"],
316
+ ["Alt+F"],
317
+ ARROWS.map((k) => `Alt+${k}`),
318
+ ARROWS.map((k) => `Alt+Shift+${k}`),
319
+ DIGITS.map((d) => `Alt+${d}`),
320
+ DIGITS.map((d) => `Alt+Shift+${d}`),
321
+ ["Ctrl+ArrowLeft", "Ctrl+ArrowRight"],
322
+ ["Shift+ArrowLeft", "Shift+ArrowRight"],
323
+ ["Ctrl+Tab", "Meta+Tab", "Ctrl+Shift+Tab", "Meta+Shift+Tab"]
324
+ ];
325
+ var DEFAULTS = new Map(DEFAULT_CHORDS.map(([id, action, inField]) => [id, { action, inField }]));
326
+ var BY_LOWER = new Map([...DEFAULTS.keys()].map((id) => [id.toLowerCase(), id]));
327
+ var MODIFIER_ORDER = ["Ctrl", "Meta", "Alt", "Shift"];
328
+ var MODIFIER_NAMES = {
329
+ ctrl: "Ctrl",
330
+ control: "Ctrl",
331
+ meta: "Meta",
332
+ cmd: "Meta",
333
+ command: "Meta",
334
+ alt: "Alt",
335
+ option: "Alt",
336
+ shift: "Shift"
337
+ };
338
+ var KEY_ALIASES = { esc: "Escape", left: "ArrowLeft", right: "ArrowRight", up: "ArrowUp", down: "ArrowDown" };
339
+ var _shiftCounts = (key) => key.length !== 1 || /^[\p{L}\p{N}]$/u.test(key);
340
+ function _compose(mods, key) {
341
+ const k = key.length === 1 ? key.toUpperCase() : key;
342
+ const used = MODIFIER_ORDER.filter((m) => mods.has(m) && (m !== "Shift" || _shiftCounts(key)));
343
+ return [...used, k].join("+");
344
+ }
345
+ function _chordOfEvent(e) {
346
+ const key = e.key;
347
+ if (!key || key === "Control" || key === "Shift" || key === "Alt" || key === "Meta") return null;
348
+ const { ctrlKey: ctrl, metaKey: meta, altKey: alt, shiftKey: shift } = e;
349
+ const lower = key.toLowerCase();
350
+ const cm = ctrl ? "Ctrl" : "Meta";
351
+ if ((ctrl || meta) && lower === "k" && !shift) return `${cm}+K`;
352
+ if ((ctrl || meta) && !alt && key === "Tab") return `${cm}+${shift ? "Shift+" : ""}Tab`;
353
+ if (key === "Escape") return "Escape";
354
+ if (key === "?") return "?";
355
+ if (key === "Backspace") return ctrl || meta ? null : "Backspace";
356
+ if (!alt) {
357
+ const only = (m) => ctrl === (m === "Ctrl") && shift === (m === "Shift") && !meta;
358
+ if (/^F([1-9]|1[0-2])$/.test(key)) return !ctrl && !meta && !shift ? key : null;
359
+ if (key === "ArrowLeft" || key === "ArrowRight") {
360
+ if (only("Shift")) return `Shift+${key}`;
361
+ if (only("Ctrl")) return `Ctrl+${key}`;
362
+ }
363
+ return null;
364
+ }
365
+ const sh = shift ? "Shift+" : "";
366
+ if (/^[1-9]$/.test(key)) return `Alt+${sh}${key}`;
367
+ if (lower === "h" || lower === "v") return `Alt+${sh}${lower.toUpperCase()}`;
368
+ if (lower === "t" || lower === "n" || lower === "w" || lower === "f") return `Alt+${lower.toUpperCase()}`;
369
+ if (ARROWS.includes(key)) return `Alt+${sh}${key}`;
370
+ if (/^[a-z]$/.test(lower)) return "Alt+Letter";
371
+ return null;
372
+ }
373
+ function _canonicalChord(id) {
374
+ if (typeof id !== "string" || !id) return null;
375
+ const parts = id.split("+").map((p) => p.trim());
376
+ const keyRaw = parts.pop();
377
+ if (!keyRaw) return null;
378
+ const mods = /* @__PURE__ */ new Set();
379
+ for (const p of parts) {
380
+ const m = MODIFIER_NAMES[p.toLowerCase()];
381
+ if (!m) return null;
382
+ mods.add(m);
383
+ }
384
+ const key = KEY_ALIASES[keyRaw.toLowerCase()] ?? keyRaw;
385
+ return BY_LOWER.get(_compose(mods, key).toLowerCase()) ?? null;
386
+ }
387
+ function _resolve(bindings) {
388
+ const table = /* @__PURE__ */ new Map();
389
+ for (const [id, { action, inField }] of DEFAULTS) {
390
+ const a = _action(action);
391
+ table.set(id, { run: a.run, label: a.label, inField, isDefault: true });
392
+ }
393
+ const problems = [];
394
+ if (bindings == null) return { table, problems };
395
+ if (typeof bindings !== "object") {
396
+ problems.push("`bindings` must be an object of { chord: action | false | function } \u2014 ignored");
397
+ return { table, problems };
398
+ }
399
+ for (const [rawId, value] of Object.entries(bindings)) {
400
+ const id = _canonicalChord(rawId);
401
+ if (!id) {
402
+ problems.push(`unknown chord "${rawId}" in bindings \u2014 ignored`);
403
+ continue;
404
+ }
405
+ const { action: defaultAction, inField } = DEFAULTS.get(id);
406
+ if (value === false) {
407
+ table.set(id, { disabled: true });
408
+ } else if (typeof value === "function") {
409
+ table.set(id, {
410
+ run: (ctx, e) => value(e, { wm: ctx.wm, palette: ctx.palette }),
411
+ label: "Defined by the application",
412
+ inField,
413
+ isDefault: false
414
+ });
415
+ } else {
416
+ const a = _action(value);
417
+ if (!a) {
418
+ problems.push(`unknown action ${JSON.stringify(value)} for chord "${rawId}" \u2014 ignored`);
419
+ continue;
420
+ }
421
+ table.set(id, { run: a.run, label: a.label, inField, isDefault: value === defaultAction });
422
+ }
423
+ }
424
+ return { table, problems };
425
+ }
426
+ function keymapShortcuts(bindings = null) {
427
+ const { table } = _resolve(bindings);
428
+ const rows = [];
429
+ SHORTCUT_CHORDS.forEach((chords, i) => {
430
+ if (chords.every((id) => table.get(id).isDefault)) {
431
+ rows.push(KEYMAP_SHORTCUTS[i]);
432
+ return;
433
+ }
434
+ for (const id of chords) {
435
+ const b = table.get(id);
436
+ if (b.disabled || !b.label) continue;
437
+ rows.push(Object.freeze({ keys: id, action: b.label }));
438
+ }
439
+ });
440
+ return Object.freeze(rows);
441
+ }
442
+ function installKeymap({ wm, palette, navSelector = null, bindings = null, logger = null } = {}) {
443
+ const { table, problems } = _resolve(bindings);
444
+ const log = logger ?? console;
445
+ for (const p of problems) log.warn?.(`[keymap] ${p}`);
446
+ const ctx = { wm, palette, navSelector };
447
+ const onKeyDown = (e) => {
448
+ if (e.defaultPrevented) return;
449
+ const chord = _chordOfEvent(e);
450
+ if (!chord) return;
451
+ const b = table.get(chord);
452
+ if (!b || b.disabled) return;
453
+ if (!b.inField && e.target?.closest?.('input, textarea, select, [contenteditable="true"]')) return;
454
+ if (b.run(ctx, e) !== false) e.preventDefault();
455
+ };
456
+ document.addEventListener("keydown", onKeyDown);
457
+ return () => document.removeEventListener("keydown", onKeyDown);
458
+ }
459
+
460
+ // src/tiling/command_palette.js
461
+ var ROOT_ID = "twm-cmdpal";
462
+ var HELP_ACTION = KEYMAP_SHORTCUTS.find((r) => r.keys === "?").action;
463
+ function _staticCommands(shortcuts) {
464
+ const helpKeys = shortcuts.filter((r) => r.action === HELP_ACTION).map((r) => r.keys).join(" / ");
465
+ return [
466
+ {
467
+ kind: "command",
468
+ id: "shortcuts",
469
+ label: "Keyboard shortcuts",
470
+ hint: helpKeys ? `Show all key bindings (${helpKeys})` : "Show all key bindings",
471
+ icon: "keyboard",
472
+ ...helpKeys ? { shortcut: helpKeys } : {},
473
+ verb: "run",
474
+ // The application's help may define the topic; when it does not, the
475
+ // window manager's own bindings are shown rather than an error.
476
+ action: () => HelpModal.open(
477
+ KEYBOARD_SHORTCUTS_TOPIC_ID,
478
+ { fallback: keyboardShortcutsTopic(shortcuts) }
479
+ )
480
+ }
481
+ ];
482
+ }
177
483
  var DEFAULT_GROUP_CAP = 4;
178
484
  var DEFAULT_COMMAND_MIN_CHARS = 2;
179
485
  var REMOTE_DEBOUNCE_MS = 150;
@@ -195,7 +501,8 @@ function createCommandPalette({
195
501
  drill = null,
196
502
  cache = false,
197
503
  tips = null,
198
- footerNote = ""
504
+ footerNote = "",
505
+ shortcuts = KEYMAP_SHORTCUTS
199
506
  }) {
200
507
  if (!taxonomy) throw new Error("createCommandPalette: a taxonomy is required");
201
508
  if (!catalog) throw new Error("createCommandPalette: an entity catalog is required");
@@ -204,6 +511,7 @@ function createCommandPalette({
204
511
  let entities = [];
205
512
  let cached = null;
206
513
  let onClose = null;
514
+ const isSingle = (row) => !!row && taxonomy.isSingle(row.kind);
207
515
  const isOpen = () => !!overlay;
208
516
  const close = () => {
209
517
  if (!overlay) return;
@@ -221,6 +529,7 @@ function createCommandPalette({
221
529
  const invalidate = () => {
222
530
  cached = null;
223
531
  };
532
+ const staticCommands = _staticCommands(shortcuts);
224
533
  const open = async () => {
225
534
  if (overlay) return;
226
535
  overlay = document.createElement("div");
@@ -238,19 +547,28 @@ function createCommandPalette({
238
547
  const chipSlot = overlay.querySelector('[data-role="type-chip"]');
239
548
  const chips = overlay.querySelectorAll("[data-shortcut]");
240
549
  const toggles = overlay.querySelectorAll("[data-toggle]");
241
- for (const btn of toggles) {
550
+ const syncToggle = (btn) => {
242
551
  const which = btn.dataset.toggle;
552
+ const { available, reason } = wm.getPanelAvailability(which);
243
553
  btn.classList.toggle("twm-chip--on", wm.isPanelOpen(which));
244
- }
554
+ if (available) {
555
+ btn.removeAttribute("aria-disabled");
556
+ btn.removeAttribute("title");
557
+ } else {
558
+ btn.setAttribute("aria-disabled", "true");
559
+ if (reason) btn.setAttribute("title", reason);
560
+ else btn.removeAttribute("title");
561
+ }
562
+ };
563
+ toggles.forEach(syncToggle);
245
564
  chips.forEach((btn) => btn.addEventListener("click", () => {
246
565
  const kind = btn.dataset.shortcut;
247
566
  wm.openInPrimary(kind);
248
567
  close();
249
568
  }));
250
569
  toggles.forEach((btn) => btn.addEventListener("click", () => {
251
- const which = btn.dataset.toggle;
252
- wm.togglePanel(which);
253
- btn.classList.toggle("twm-chip--on", wm.isPanelOpen(which));
570
+ wm.togglePanel(btn.dataset.toggle);
571
+ syncToggle(btn);
254
572
  }));
255
573
  let filtered = [];
256
574
  let active = 0;
@@ -271,12 +589,12 @@ function createCommandPalette({
271
589
  const renderChip = () => {
272
590
  if (scope) {
273
591
  chipSlot.innerHTML = `
274
- <span class="twm-cmdpal__type-chip" title="${_esc(scope.label)}">
275
- <span class="material-symbols-outlined twm-cmdpal__type-chip-icon">${_esc(scope.icon || "arrow_right")}</span>
276
- <span class="twm-cmdpal__type-chip-label twm-cmdpal__type-chip-label--scope">${_esc(scope.label)}</span>
592
+ <span class="twm-cmdpal__type-chip" title="${_esc2(scope.label)}">
593
+ <span class="material-symbols-outlined twm-cmdpal__type-chip-icon">${_esc2(scope.icon || "arrow_right")}</span>
594
+ <span class="twm-cmdpal__type-chip-label twm-cmdpal__type-chip-label--scope">${_esc2(scope.label)}</span>
277
595
  <button type="button" class="twm-cmdpal__type-chip-x"
278
596
  data-action="clear-type"
279
- aria-label="Leave ${_esc(scope.label)}">\xD7</button>
597
+ aria-label="Leave ${_esc2(scope.label)}">\xD7</button>
280
598
  </span>`;
281
599
  chipSlot.querySelector('[data-action="clear-type"]')?.addEventListener("click", (ev) => {
282
600
  ev.preventDefault();
@@ -293,9 +611,9 @@ function createCommandPalette({
293
611
  const meta = taxonomy.meta(head);
294
612
  const label = typeFilterLabel || meta?.label || head;
295
613
  chipSlot.innerHTML = `
296
- <span class="twm-cmdpal__type-chip" title="Filtering by ${_esc(label)} (${typeFilter.length} kind${typeFilter.length === 1 ? "" : "s"})">
614
+ <span class="twm-cmdpal__type-chip" title="Filtering by ${_esc2(label)} (${typeFilter.length} kind${typeFilter.length === 1 ? "" : "s"})">
297
615
  <span class="material-symbols-outlined twm-cmdpal__type-chip-icon">${meta?.icon || "arrow_right"}</span>
298
- <span class="twm-cmdpal__type-chip-label">${_esc(label)}</span>
616
+ <span class="twm-cmdpal__type-chip-label">${_esc2(label)}</span>
299
617
  <button type="button" class="twm-cmdpal__type-chip-x"
300
618
  data-action="clear-type"
301
619
  aria-label="Clear type filter">\xD7</button>
@@ -319,9 +637,9 @@ function createCommandPalette({
319
637
  <div class="twm-cmdpal__item${i === active ? " twm-cmdpal__item--active" : ""}"
320
638
  data-idx="${i}">
321
639
  <span class="material-symbols-outlined twm-cmdpal__icon">${e.icon || "arrow_right"}</span>
322
- <span class="twm-cmdpal__label">${_esc(e.label || e.id)}</span>
640
+ <span class="twm-cmdpal__label">${_esc2(e.label || e.id)}</span>
323
641
  <span class="twm-cmdpal__kind">${e.kind}</span>
324
- <span class="twm-cmdpal__hint">${_esc(e.hint || "")}</span>
642
+ <span class="twm-cmdpal__hint">${_esc2(e.hint || "")}</span>
325
643
  </div>
326
644
  `).join("") || "";
327
645
  list.querySelectorAll("[data-idx]").forEach((el) => {
@@ -346,21 +664,22 @@ function createCommandPalette({
346
664
  if (r.more) {
347
665
  return `<div class="twm-cmdpal__item twm-cmdpal__item--more${on}" data-idx="${i}" role="option">
348
666
  <span class="material-symbols-outlined twm-cmdpal__icon">expand_more</span>
349
- <span class="twm-cmdpal__main"><span class="twm-cmdpal__label">${_esc(r.label)}</span></span>
667
+ <span class="twm-cmdpal__main"><span class="twm-cmdpal__label">${_esc2(r.label)}</span></span>
350
668
  <span class="twm-cmdpal__side"><span class="twm-cmdpal__acts"><span><kbd>Enter</kbd> show all</span></span></span>
351
669
  </div>`;
352
670
  }
353
671
  const drawn = _drawn(r);
354
- const ctx = r.cells?.length ? `<span class="twm-cmdpal__cells">${r.cells.map(_esc).join(" \xB7 ")}</span>` : drawn ? `<span class="twm-cmdpal__context">${_esc(drawn)}</span>` : "";
672
+ const ctx = r.cells?.length ? `<span class="twm-cmdpal__cells">${r.cells.map(_esc2).join(" \xB7 ")}</span>` : drawn ? `<span class="twm-cmdpal__context">${_esc2(drawn)}</span>` : "";
355
673
  const verb = r.verb || (r.action || r.kind === "command" ? "run" : "open");
356
674
  const modes = Array.isArray(r.modes) ? r.modes : [];
357
- const acts = [`<span><kbd>Enter</kbd> ${_esc(verb)}</span>`];
358
- if (r.drillLabel && drill) acts.push(`<span><kbd>Tab</kbd> ${_esc(r.drillLabel)}</span>`);
359
- if (modes.includes("split")) acts.push("<span><kbd>Ctrl Enter</kbd> split</span>");
360
- if (modes.includes("window")) acts.push("<span><kbd>Shift Enter</kbd> window</span>");
361
- const side = r.shortcut ? `<kbd class="twm-cmdpal__shortcut">${_esc(r.shortcut)}</kbd>` : `<span class="twm-cmdpal__kind">${_esc(kindLabel(r))}</span>`;
675
+ const acts = [`<span><kbd>Enter</kbd> ${_esc2(verb)}</span>`];
676
+ if (r.drillLabel && drill) acts.push(`<span><kbd>Tab</kbd> ${_esc2(r.drillLabel)}</span>`);
677
+ const refused = isSingle(r) ? ` class="twm-cmdpal__act--disabled" aria-disabled="true" title="${_esc2(taxonomy.singleReason(r.kind))}"` : "";
678
+ if (modes.includes("split")) acts.push(`<span${refused}><kbd>Ctrl Enter</kbd> split</span>`);
679
+ if (modes.includes("window")) acts.push(`<span${refused}><kbd>Shift Enter</kbd> window</span>`);
680
+ const side = r.shortcut ? `<kbd class="twm-cmdpal__shortcut">${_esc2(r.shortcut)}</kbd>` : `<span class="twm-cmdpal__kind">${_esc2(kindLabel(r))}</span>`;
362
681
  return `<div class="twm-cmdpal__item${on}" data-idx="${i}" role="option">
363
- <span class="material-symbols-outlined twm-cmdpal__icon">${_esc(r.icon || "arrow_right")}</span>
682
+ <span class="material-symbols-outlined twm-cmdpal__icon">${_esc2(r.icon || "arrow_right")}</span>
364
683
  <span class="twm-cmdpal__main"><span class="twm-cmdpal__label">${_mark(r.label || r.id, r._hits)}</span>${ctx}</span>
365
684
  <span class="twm-cmdpal__side">${side}<span class="twm-cmdpal__acts">${acts.join("")}</span></span>
366
685
  </div>`;
@@ -369,22 +688,22 @@ function createCommandPalette({
369
688
  let html = "";
370
689
  for (const s of sections) {
371
690
  if (!s.count) continue;
372
- if (s.title) html += `<div class="twm-cmdpal__group" role="presentation">${_esc(s.title)}</div>`;
691
+ if (s.title) html += `<div class="twm-cmdpal__group" role="presentation">${_esc2(s.title)}</div>`;
373
692
  for (let i = s.start; i < s.start + s.count; i++) html += rowHtml(filtered[i], i);
374
693
  }
375
694
  const q = input.value.trim();
376
695
  if (!filtered.length) {
377
696
  if (scope) {
378
697
  const msg = remote.error ? remote.error : remote.pending ? "Searching\u2026" : q ? `Nothing in ${scope.label} matches \u201C${q}\u201D.` : scope.emptyText || `Type to search ${scope.label}.`;
379
- html += `<div class="twm-cmdpal__empty">${_esc(msg)}</div>`;
698
+ html += `<div class="twm-cmdpal__empty">${_esc2(msg)}</div>`;
380
699
  } else if (q && q !== ">") {
381
- html += `<div class="twm-cmdpal__empty">Nothing matches \u201C${_esc(q)}\u201D.</div>`;
700
+ html += `<div class="twm-cmdpal__empty">Nothing matches \u201C${_esc2(q)}\u201D.</div>`;
382
701
  }
383
702
  } else if (scope && remote.pending) {
384
703
  html += '<div class="twm-cmdpal__empty twm-cmdpal__empty--quiet">Searching\u2026</div>';
385
704
  }
386
705
  if (!q && !scope && !typeFilter && Array.isArray(tips) && tips.length) {
387
- html += `<div class="twm-cmdpal__tips">${tips.map((t) => `<span>${_esc(t)}</span>`).join("")}</div>`;
706
+ html += `<div class="twm-cmdpal__tips">${tips.map((t) => `<span>${_esc2(t)}</span>`).join("")}</div>`;
388
707
  }
389
708
  list.innerHTML = html;
390
709
  renderFooter();
@@ -403,7 +722,7 @@ function createCommandPalette({
403
722
  if (scope || typeFilter) parts.push("<span><kbd>Backspace</kbd> leave</span>");
404
723
  parts.push("<span><kbd>Esc</kbd> close</span>");
405
724
  const note = scope?.note || footerNote;
406
- foot.innerHTML = parts.join("") + (note ? `<span class="twm-cmdpal__footnote">${_esc(note)}</span>` : "");
725
+ foot.innerHTML = parts.join("") + (note ? `<span class="twm-cmdpal__footnote">${_esc2(note)}</span>` : "");
407
726
  };
408
727
  if (grouped) {
409
728
  list.addEventListener("mousemove", (e) => {
@@ -428,6 +747,7 @@ function createCommandPalette({
428
747
  showAll(picked.more);
429
748
  return;
430
749
  }
750
+ if (mode !== "open" && isSingle(picked)) return;
431
751
  const pick = _public(picked);
432
752
  const how = { mode, scope: scope?.id ?? null };
433
753
  close();
@@ -673,7 +993,7 @@ function createCommandPalette({
673
993
  console.warn("[cmdpal] commands() threw", err);
674
994
  }
675
995
  }
676
- return [...STATIC_COMMANDS, ...extra];
996
+ return [...staticCommands, ...extra];
677
997
  };
678
998
  const withCommands = (rows) => grouped ? [...rows, ...commandRows()] : [...commandRows(), ...rows];
679
999
  entities = _decorateIcons(withCommands(cache && cached ? cached : []), taxonomy);
@@ -728,7 +1048,7 @@ function _markup(taxonomy, placeholder, wm, { chrome = {}, grouped = false } = {
728
1048
  <span class="material-symbols-outlined twm-cmdpal__search-icon">search</span>
729
1049
  <span class="twm-cmdpal__type-chip-slot" data-role="type-chip"></span>
730
1050
  <input type="text" class="twm-cmdpal__input"
731
- placeholder="${_esc(placeholder)}"
1051
+ placeholder="${_esc2(placeholder)}"
732
1052
  autocomplete="off" spellcheck="false" role="combobox"
733
1053
  aria-expanded="true" aria-controls="twm-cmdpal-list" />
734
1054
  <kbd class="twm-cmdpal__esc">Esc</kbd>
@@ -744,7 +1064,7 @@ function _markup(taxonomy, placeholder, wm, { chrome = {}, grouped = false } = {
744
1064
  <div class="twm-cmdpal__inputrow">
745
1065
  <span class="twm-cmdpal__type-chip-slot" data-role="type-chip"></span>
746
1066
  <input type="text" class="twm-cmdpal__input"
747
- placeholder="${_esc(placeholder)}"
1067
+ placeholder="${_esc2(placeholder)}"
748
1068
  autocomplete="off" />
749
1069
  </div>
750
1070
  <div class="twm-cmdpal__chips">${chips}</div>
@@ -862,7 +1182,7 @@ function _match(text, query) {
862
1182
  }
863
1183
  function _mark(text, hits) {
864
1184
  const s = String(text ?? "");
865
- if (!hits?.length) return _esc(s);
1185
+ if (!hits?.length) return _esc2(s);
866
1186
  const set = new Set(hits);
867
1187
  let out = "";
868
1188
  let open = false;
@@ -876,12 +1196,12 @@ function _mark(text, hits) {
876
1196
  out += "</mark>";
877
1197
  open = false;
878
1198
  }
879
- out += _esc(s[i]);
1199
+ out += _esc2(s[i]);
880
1200
  }
881
1201
  if (open) out += "</mark>";
882
1202
  return out;
883
1203
  }
884
- function _esc(s) {
1204
+ function _esc2(s) {
885
1205
  return String(s ?? "").replace(/[&<>"']/g, (c) => ({
886
1206
  "&": "&amp;",
887
1207
  "<": "&lt;",
@@ -902,7 +1222,7 @@ function windowPlaceholderFactory(hostEl, props, ctx) {
902
1222
  hostEl.innerHTML = `
903
1223
  <div class="twm-window-placeholder">
904
1224
  <span class="material-symbols-outlined twm-window-placeholder__icon">open_in_new</span>
905
- <div class="twm-window-placeholder__title">${_esc2(props.originalTitle || props.originalKind || "content")}</div>
1225
+ <div class="twm-window-placeholder__title">${_esc3(props.originalTitle || props.originalKind || "content")}</div>
906
1226
  <div class="twm-window-placeholder__hint">is open in a managed window</div>
907
1227
  <button class="twm-window-placeholder__btn" data-action="bring-back">
908
1228
  Bring back to this tile
@@ -954,7 +1274,7 @@ function _placeholder(kind) {
954
1274
  return { title: kind };
955
1275
  };
956
1276
  }
957
- function _esc2(s) {
1277
+ function _esc3(s) {
958
1278
  return String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
959
1279
  }
960
1280
 
@@ -1804,6 +2124,11 @@ function _makeDesktop(label, seed, panelDefaults = DEFAULT_PANEL_STATE) {
1804
2124
  // when it asked for nothing. Names are assigned by wm._canonicalize via
1805
2125
  // PANEL_TITLES.
1806
2126
  panels: { ...panelDefaults },
2127
+ // Each panel's last share of the WM area along its axis, recorded by
2128
+ // the WM when a splitter drag ends (and, for a panel not yet
2129
+ // measured, when it rebuilds the panels). A side with none opens at
2130
+ // the default ratio.
2131
+ panelShares: {},
1807
2132
  // C40. Both unset is a desktop exactly as it always was.
1808
2133
  group: null,
1809
2134
  fixed: false
@@ -1927,6 +2252,9 @@ var DesktopManager = class _DesktopManager {
1927
2252
  label: d.label,
1928
2253
  tree: d.tree.serialize(),
1929
2254
  panels: { ...d.panels },
2255
+ // Written only when a panel has been measured, like the C40
2256
+ // keys below.
2257
+ ...Object.keys(d.panelShares).length ? { panelShares: { ...d.panelShares } } : {},
1930
2258
  // C40. Written only when set, so a blob without them is
1931
2259
  // byte-for-byte the blob it always was.
1932
2260
  ...d.group ? { group: d.group } : {},
@@ -1949,6 +2277,7 @@ var DesktopManager = class _DesktopManager {
1949
2277
  // this merge order avoids. The default only fills a key the stored
1950
2278
  // blob predates.
1951
2279
  panels: { ...m.panelDefaults, ...raw.panels || {} },
2280
+ panelShares: { ...raw.panelShares || {} },
1952
2281
  group: raw.group || null,
1953
2282
  fixed: !!raw.fixed
1954
2283
  }));
@@ -2091,120 +2420,25 @@ function createEntityCatalog({ sources, aliases = {}, aggregate = null } = {}) {
2091
2420
  });
2092
2421
  }
2093
2422
 
2094
- // src/tiling/keymap.js
2095
- function installKeymap({ wm, palette, ...opts } = {}) {
2096
- const onKeyDown = (e) => {
2097
- const inField = e.target?.closest?.(
2098
- 'input, textarea, select, [contenteditable="true"]'
2099
- );
2100
- if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k" && !e.shiftKey) {
2101
- e.preventDefault();
2102
- palette.toggle();
2103
- return;
2104
- }
2105
- if ((e.ctrlKey || e.metaKey) && !e.altKey && e.key === "Tab") {
2106
- e.preventDefault();
2107
- wm.cycleFocusedTab(e.shiftKey ? -1 : 1);
2108
- return;
2109
- }
2110
- if (e.key === "Escape") {
2111
- if (palette.isOpen()) {
2112
- palette.close();
2113
- return;
2114
- }
2115
- }
2116
- if (e.key === "Backspace" && !inField && !e.metaKey && !e.ctrlKey) {
2117
- e.preventDefault();
2118
- wm.navigateBack?.();
2119
- return;
2120
- }
2121
- const fMatch = /^F([1-9]|1[0-2])$/.exec(e.key);
2122
- if (fMatch && !inField && !e.altKey && !e.ctrlKey && !e.metaKey && !e.shiftKey) {
2123
- const btns = document.querySelectorAll(
2124
- opts.navSelector || ".twm-global-top-bar .twm-bar-center.twm-top-nav .twm-top-nav__btn"
2125
- );
2126
- const idx = Number(fMatch[1]) - 1;
2127
- if (idx < btns.length) {
2128
- e.preventDefault();
2129
- btns[idx].click();
2130
- return;
2131
- }
2132
- }
2133
- if (e.shiftKey && !e.altKey && !e.ctrlKey && !e.metaKey && !inField && (e.key === "ArrowLeft" || e.key === "ArrowRight")) {
2134
- const tabs = [...document.querySelectorAll(".twm-bp__tabs .twm-bp__tab")];
2135
- if (tabs.length) {
2136
- const cur = tabs.findIndex((t) => t.classList.contains("twm-bp__tab--on"));
2137
- const dir = e.key === "ArrowRight" ? 1 : -1;
2138
- const next = ((cur < 0 ? 0 : cur) + dir + tabs.length) % tabs.length;
2139
- e.preventDefault();
2140
- tabs[next].click();
2141
- return;
2142
- }
2143
- }
2144
- if (e.ctrlKey && !e.altKey && !e.shiftKey && !e.metaKey && !inField && (e.key === "ArrowLeft" || e.key === "ArrowRight")) {
2145
- e.preventDefault();
2146
- wm.cycleDesktop(e.key === "ArrowRight" ? 1 : -1);
2147
- return;
2148
- }
2149
- if (!e.altKey) return;
2150
- const key = e.key.toLowerCase();
2151
- if (/^[1-9]$/.test(e.key)) {
2152
- e.preventDefault();
2153
- const idx = Number(e.key) - 1;
2154
- if (e.shiftKey) wm.moveFocusedToDesktop(idx);
2155
- else wm.switchDesktop(idx);
2156
- return;
2157
- }
2158
- switch (key) {
2159
- // Plain Alt+H/V split into an empty pane; with Shift they open
2160
- // the focused tile's own content in the new split.
2161
- case "h":
2162
- e.preventDefault();
2163
- e.shiftKey ? wm.splitFocusedWith("h") : wm.split("h");
2164
- return;
2165
- case "v":
2166
- e.preventDefault();
2167
- e.shiftKey ? wm.splitFocusedWith("v") : wm.split("v");
2168
- return;
2169
- case "t":
2170
- e.preventDefault();
2171
- wm.openFocusedInTab();
2172
- return;
2173
- case "n":
2174
- e.preventDefault();
2175
- wm.openFocusedInWindow();
2176
- return;
2177
- case "w":
2178
- e.preventDefault();
2179
- wm.closeFocused();
2180
- return;
2181
- case "f":
2182
- e.preventDefault();
2183
- wm.toggleManagedFocused();
2184
- return;
2185
- case "arrowleft":
2186
- e.preventDefault();
2187
- e.shiftKey ? wm.moveFocused("left") : wm.focusDir("left");
2188
- return;
2189
- case "arrowright":
2190
- e.preventDefault();
2191
- e.shiftKey ? wm.moveFocused("right") : wm.focusDir("right");
2192
- return;
2193
- case "arrowup":
2194
- e.preventDefault();
2195
- e.shiftKey ? wm.moveFocused("up") : wm.focusDir("up");
2196
- return;
2197
- case "arrowdown":
2198
- e.preventDefault();
2199
- e.shiftKey ? wm.moveFocused("down") : wm.focusDir("down");
2200
- return;
2201
- }
2202
- if (key.length === 1 && /[a-z]/.test(key) && !inField) {
2203
- e.preventDefault();
2204
- }
2205
- };
2206
- document.addEventListener("keydown", onKeyDown);
2207
- return () => document.removeEventListener("keydown", onKeyDown);
2423
+ // src/tiling/panel_availability.js
2424
+ var PANEL_SIDES = Object.freeze(["left", "right", "bottom"]);
2425
+ var PANEL_OFFERED = Object.freeze({ available: true, reason: null });
2426
+ function readPanelOffer(offer, where) {
2427
+ if (offer === true) return PANEL_OFFERED;
2428
+ if (offer === false) return Object.freeze({ available: false, reason: null });
2429
+ if (offer && typeof offer === "object" && !Array.isArray(offer)) {
2430
+ const { reason = null } = offer;
2431
+ if (reason !== null && typeof reason !== "string") {
2432
+ throw new TypeError(`${where}: a panel's \`reason\` must be a string`);
2433
+ }
2434
+ return Object.freeze({ available: false, reason: reason || null });
2435
+ }
2436
+ throw new TypeError(`${where}: a panel offer is true, false or { reason }`);
2437
+ }
2438
+ function assertPanelSide(side, where) {
2439
+ if (!PANEL_SIDES.includes(side)) {
2440
+ throw new TypeError(`${where}: unknown panel side '${side}' (expected ${PANEL_SIDES.join(", ")})`);
2441
+ }
2208
2442
  }
2209
2443
 
2210
2444
  // src/tiling/kind_taxonomy.js
@@ -2229,6 +2463,27 @@ function createTaxonomy({ kinds, root } = {}) {
2229
2463
  `createTaxonomy: '${kind}' is both a top-nav kind and owned by one`
2230
2464
  );
2231
2465
  }
2466
+ if (def.panels !== void 0) {
2467
+ if (!def.panels || typeof def.panels !== "object" || Array.isArray(def.panels)) {
2468
+ throw new TypeError(`createTaxonomy: '${kind}'.panels must be an object`);
2469
+ }
2470
+ for (const [side, offer] of Object.entries(def.panels)) {
2471
+ if (!PANEL_SIDES.includes(side)) {
2472
+ throw new Error(`createTaxonomy: '${kind}'.panels names unknown side '${side}'`);
2473
+ }
2474
+ readPanelOffer(offer, `createTaxonomy: '${kind}'.panels.${side}`);
2475
+ }
2476
+ }
2477
+ if (def.single != null && def.single !== false && def.single !== true && !(typeof def.single === "string" && def.single)) {
2478
+ throw new TypeError(
2479
+ `createTaxonomy: '${kind}'.single must be true or a prop name`
2480
+ );
2481
+ }
2482
+ }
2483
+ if (kinds[root].single) {
2484
+ throw new Error(
2485
+ `createTaxonomy: the root kind '${root}' cannot be single \u2014 every new pane, tab and desktop is seeded with it`
2486
+ );
2232
2487
  }
2233
2488
  const _kinds = Object.freeze({ ...kinds });
2234
2489
  const meta = (kind) => _kinds[kind];
@@ -2278,12 +2533,31 @@ function createTaxonomy({ kinds, root } = {}) {
2278
2533
  const s = _kinds[kind]?.sources ?? _kinds[root]?.sources;
2279
2534
  return Array.isArray(s) ? s : [];
2280
2535
  };
2536
+ const panelOffer = (kind, side) => {
2537
+ for (const k of [kind, _kinds[kind]?.topNav]) {
2538
+ const offer = k ? _kinds[k]?.panels?.[side] : void 0;
2539
+ if (offer !== void 0) return readPanelOffer(offer, `taxonomy '${k}'.panels.${side}`);
2540
+ }
2541
+ return PANEL_OFFERED;
2542
+ };
2281
2543
  const topNavEntries = () => Object.entries(_kinds).filter(([, m]) => m.isTopNav).sort((a, b) => (a[1].order ?? 1e3) - (b[1].order ?? 1e3)).map(([kind, m]) => ({
2282
2544
  kind,
2283
2545
  label: m.shortLabel || m.label,
2284
2546
  longLabel: m.label,
2285
2547
  icon: m.icon
2286
2548
  }));
2549
+ const isSingle = (kind) => {
2550
+ const s = _kinds[kind]?.single;
2551
+ return s === true || typeof s === "string" && s !== "";
2552
+ };
2553
+ const instanceKey = (kind, props) => {
2554
+ if (!isSingle(kind)) return null;
2555
+ const by = _kinds[kind].single;
2556
+ if (by === true) return String(kind);
2557
+ const v = props?.[by];
2558
+ return `${kind}\0${v == null ? "" : String(v)}`;
2559
+ };
2560
+ const singleReason = (kind) => `${_kinds[kind]?.label || kind} is open once: use the one that is open`;
2287
2561
  return Object.freeze({
2288
2562
  root,
2289
2563
  kinds: _kinds,
@@ -2294,7 +2568,11 @@ function createTaxonomy({ kinds, root } = {}) {
2294
2568
  parentOf,
2295
2569
  labelOf,
2296
2570
  sourcesFor,
2297
- topNavEntries
2571
+ panelOffer,
2572
+ topNavEntries,
2573
+ isSingle,
2574
+ instanceKey,
2575
+ singleReason
2298
2576
  });
2299
2577
  }
2300
2578
 
@@ -2760,7 +3038,7 @@ function mountLandingShell(hostEl, opts) {
2760
3038
  pane = { key: "rows", title: "", hint: "" }
2761
3039
  } = opts || {};
2762
3040
  const paneKey = String(pane.key || "rows");
2763
- const _esc8 = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({
3041
+ const _esc9 = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({
2764
3042
  "&": "&amp;",
2765
3043
  "<": "&lt;",
2766
3044
  ">": "&gt;",
@@ -2769,24 +3047,24 @@ function mountLandingShell(hostEl, opts) {
2769
3047
  })[c]);
2770
3048
  const statsHtml = (stats || []).map((s) => `
2771
3049
  <span class="twm-landing__stat twm-entity-header__field">
2772
- <span>${_esc8(s.label || "")}</span>
2773
- <strong data-role="${_esc8(s.key)}">${_esc8(s.initial != null ? s.initial : 0)}</strong>
3050
+ <span>${_esc9(s.label || "")}</span>
3051
+ <strong data-role="${_esc9(s.key)}">${_esc9(s.initial != null ? s.initial : 0)}</strong>
2774
3052
  </span>
2775
3053
  `).join("");
2776
3054
  hostEl.classList.add("twm-landing");
2777
3055
  hostEl.innerHTML = `
2778
3056
  <header class="twm-entity-header">
2779
- <h2>${_esc8(title)}</h2>
3057
+ <h2>${_esc9(title)}</h2>
2780
3058
  ${statsHtml}
2781
3059
  <span class="twm-entity-header__spacer"></span>
2782
3060
  </header>
2783
3061
  <div class="twm-landing__split twm-landing__split--single">
2784
- <section class="twm-landing__pane" data-pane="${_esc8(paneKey)}">
3062
+ <section class="twm-landing__pane" data-pane="${_esc9(paneKey)}">
2785
3063
  <div class="twm-landing__pane-head">
2786
- <span class="twm-landing__pane-title">${_esc8(pane.title || "")}</span>
2787
- <span class="twm-landing__pane-hint">${_esc8(pane.hint || "")}</span>
3064
+ <span class="twm-landing__pane-title">${_esc9(pane.title || "")}</span>
3065
+ <span class="twm-landing__pane-hint">${_esc9(pane.hint || "")}</span>
2788
3066
  </div>
2789
- <div class="twm-landing__table" data-role="${_esc8(paneKey)}-host"></div>
3067
+ <div class="twm-landing__table" data-role="${_esc9(paneKey)}-host"></div>
2790
3068
  </section>
2791
3069
  </div>
2792
3070
  <footer class="twm-landing__actions" data-role="actions"></footer>
@@ -2815,9 +3093,9 @@ function mountLandingActions(footerEl, actions, hostEl) {
2815
3093
  btn.className = "twm-action-btn";
2816
3094
  if (a.id) btn.dataset.action = a.id;
2817
3095
  if (a.title) btn.title = a.title;
2818
- const icon = a.icon ? `<span class="material-symbols-outlined">${_esc3(a.icon)}</span>` : "";
2819
- const sc = a.shortcut ? ` <span class="twm-action-btn__kbd">[${_esc3(a.shortcut.toUpperCase())}]</span>` : "";
2820
- btn.innerHTML = `${icon}<span>${_esc3(a.label || "")}</span>${sc}`;
3096
+ const icon = a.icon ? `<span class="material-symbols-outlined">${_esc4(a.icon)}</span>` : "";
3097
+ const sc = a.shortcut ? ` <span class="twm-action-btn__kbd">[${_esc4(a.shortcut.toUpperCase())}]</span>` : "";
3098
+ btn.innerHTML = `${icon}<span>${_esc4(a.label || "")}</span>${sc}`;
2821
3099
  btn.addEventListener("click", (ev) => {
2822
3100
  ev.preventDefault();
2823
3101
  try {
@@ -2878,7 +3156,7 @@ function actionsCellRenderer(actionsColIdx, options = { edit: true, delete: true
2878
3156
  return true;
2879
3157
  };
2880
3158
  }
2881
- function _esc3(s) {
3159
+ function _esc4(s) {
2882
3160
  return String(s ?? "").replace(/[&<>"']/g, (c) => ({
2883
3161
  "&": "&amp;",
2884
3162
  "<": "&lt;",
@@ -2908,37 +3186,46 @@ function mountNavPanel(hostEl, {
2908
3186
  modes = [],
2909
3187
  wm = null,
2910
3188
  eventBus = null,
2911
- filterPlaceholder = "Filter\u2026"
3189
+ filterPlaceholder = "Filter\u2026",
3190
+ showModeStrip = true,
3191
+ showFilter = true,
3192
+ initialMode = null
2912
3193
  } = {}) {
2913
3194
  if (!Array.isArray(modes) || modes.length === 0) {
2914
3195
  throw new Error("mountNavPanel: at least one mode is required");
2915
3196
  }
2916
- hostEl.classList.add("twm-nav");
2917
- hostEl.innerHTML = `
2918
- <div class="twm-nav__modes" data-role="modes"></div>
3197
+ const stripHtml = showModeStrip ? `
3198
+ <div class="twm-nav__modes" data-role="modes"></div>` : "";
3199
+ const filterHtml = showFilter ? `
2919
3200
  <div class="twm-sidebar-controls twm-nav__filter">
2920
3201
  <span class="material-symbols-outlined twm-sidebar-search-icon">search</span>
2921
3202
  <input class="twm-data-page__search twm-nav__filter-input"
2922
- type="search" placeholder="${_esc4(filterPlaceholder)}"
3203
+ type="search" placeholder="${_esc5(filterPlaceholder)}"
2923
3204
  autocomplete="off" />
2924
- </div>
3205
+ </div>` : "";
3206
+ hostEl.classList.add("twm-nav");
3207
+ hostEl.innerHTML = `${stripHtml}${filterHtml}
2925
3208
  <div class="twm-nav__body" data-role="body"></div>
2926
3209
  `;
2927
3210
  const modesEl = hostEl.querySelector('[data-role="modes"]');
2928
3211
  const bodyEl = hostEl.querySelector('[data-role="body"]');
3212
+ const filterBoxEl = hostEl.querySelector(".twm-nav__filter");
2929
3213
  const filterEl = hostEl.querySelector(".twm-nav__filter-input");
2930
- modesEl.innerHTML = modes.map((m) => `
2931
- <button class="twm-nav__mode twm-has-tooltip" data-mode="${_esc4(m.id)}"
2932
- data-tooltip="${_esc4(m.label)}">
2933
- <span class="material-symbols-outlined">${_esc4(m.icon)}</span>
2934
- <span>${_esc4(m.label)}</span>
3214
+ if (modesEl) {
3215
+ modesEl.innerHTML = modes.map((m) => `
3216
+ <button class="twm-nav__mode twm-has-tooltip" data-mode="${_esc5(m.id)}"
3217
+ data-tooltip="${_esc5(m.label)}">
3218
+ <span class="material-symbols-outlined">${_esc5(m.icon)}</span>
3219
+ <span>${_esc5(m.label)}</span>
2935
3220
  </button>
2936
3221
  `).join("");
3222
+ }
2937
3223
  const byId = new Map(modes.map((m) => [m.id, m]));
2938
3224
  const hosts = {};
2939
3225
  const panes = {};
2940
3226
  const filterByMode = Object.fromEntries(modes.map((m) => [m.id, ""]));
2941
- let mode = modes[0].id;
3227
+ let mode = byId.has(initialMode) ? initialMode : modes[0].id;
3228
+ const hasFilter = (id) => !!filterEl && byId.get(id)?.filter !== false;
2942
3229
  const ensureHost = (id) => {
2943
3230
  if (hosts[id]) return hosts[id];
2944
3231
  const el = document.createElement("div");
@@ -2955,8 +3242,12 @@ function mountNavPanel(hostEl, {
2955
3242
  for (const m of modes) {
2956
3243
  ensureHost(m.id).style.display = m.id === next ? "flex" : "none";
2957
3244
  }
2958
- modesEl.querySelectorAll("[data-mode]").forEach((b) => b.classList.toggle("twm-nav__mode--on", b.dataset.mode === next));
2959
- filterEl.value = filterByMode[next] || "";
3245
+ modesEl?.querySelectorAll("[data-mode]").forEach((b) => b.classList.toggle("twm-nav__mode--on", b.dataset.mode === next));
3246
+ if (filterEl) {
3247
+ if (!hasFilter(next)) filterBoxEl.style.display = "none";
3248
+ else if (filterBoxEl.style.display) filterBoxEl.style.removeProperty("display");
3249
+ filterEl.value = hasFilter(next) ? filterByMode[next] || "" : "";
3250
+ }
2960
3251
  const paneEl = ensureHost(next);
2961
3252
  if (!panes[next]) {
2962
3253
  try {
@@ -2974,6 +3265,7 @@ function mountNavPanel(hostEl, {
2974
3265
  _applyFilter();
2975
3266
  };
2976
3267
  const _applyFilter = () => {
3268
+ if (!hasFilter(mode)) return;
2977
3269
  const q = filterByMode[mode] || "";
2978
3270
  try {
2979
3271
  panes[mode]?.filter?.(q);
@@ -2981,16 +3273,19 @@ function mountNavPanel(hostEl, {
2981
3273
  console.warn("[nav] filter failed", mode, err);
2982
3274
  }
2983
3275
  };
2984
- filterEl.addEventListener("input", () => {
3276
+ filterEl?.addEventListener("input", () => {
3277
+ if (!hasFilter(mode)) return;
2985
3278
  filterByMode[mode] = filterEl.value;
2986
3279
  _applyFilter();
2987
3280
  });
2988
- modesEl.addEventListener("click", (e) => {
3281
+ modesEl?.addEventListener("click", (e) => {
2989
3282
  const btn = e.target.closest("[data-mode]");
2990
3283
  if (btn) activate(btn.dataset.mode);
2991
3284
  });
2992
3285
  activate(mode);
2993
3286
  return {
3287
+ select: (modeId) => activate(modeId),
3288
+ active: () => mode,
2994
3289
  refresh: () => activate(mode),
2995
3290
  destroy: () => {
2996
3291
  for (const p of Object.values(panes)) {
@@ -3002,7 +3297,7 @@ function mountNavPanel(hostEl, {
3002
3297
  }
3003
3298
  };
3004
3299
  }
3005
- function _esc4(s) {
3300
+ function _esc5(s) {
3006
3301
  return String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
3007
3302
  }
3008
3303
 
@@ -3156,8 +3451,8 @@ function _renderInto(container, segments) {
3156
3451
  li.classList.add("twm-topbar-breadcrumb__item--current");
3157
3452
  li.setAttribute("aria-current", "page");
3158
3453
  li.innerHTML = `
3159
- <span class="material-symbols-outlined twm-topbar-breadcrumb__icon">${_esc5(seg.icon)}</span>
3160
- <span class="twm-topbar-breadcrumb__current" title="${_esc5(seg.label)}">${_esc5(seg.label)}</span>
3454
+ <span class="material-symbols-outlined twm-topbar-breadcrumb__icon">${_esc6(seg.icon)}</span>
3455
+ <span class="twm-topbar-breadcrumb__current" title="${_esc6(seg.label)}">${_esc6(seg.label)}</span>
3161
3456
  `;
3162
3457
  } else {
3163
3458
  const btn = document.createElement("button");
@@ -3165,8 +3460,8 @@ function _renderInto(container, segments) {
3165
3460
  btn.type = "button";
3166
3461
  btn.title = String(seg.label ?? "");
3167
3462
  btn.innerHTML = `
3168
- <span class="material-symbols-outlined twm-topbar-breadcrumb__icon">${_esc5(seg.icon)}</span>
3169
- <span>${_esc5(seg.label)}</span>
3463
+ <span class="material-symbols-outlined twm-topbar-breadcrumb__icon">${_esc6(seg.icon)}</span>
3464
+ <span>${_esc6(seg.label)}</span>
3170
3465
  `;
3171
3466
  btn.addEventListener("click", () => {
3172
3467
  btn.classList.add("twm-tile-breadcrumb__link--flash");
@@ -3189,7 +3484,7 @@ function _renderInto(container, segments) {
3189
3484
  });
3190
3485
  container.appendChild(ol);
3191
3486
  }
3192
- function _esc5(s) {
3487
+ function _esc6(s) {
3193
3488
  return String(s ?? "").replace(/[&<>"']/g, (c) => ({
3194
3489
  "&": "&amp;",
3195
3490
  "<": "&lt;",
@@ -3274,7 +3569,7 @@ function createPageFactories({ eventBus = null, events = {}, refreshOn = {} } =
3274
3569
  console.error("[page] mount failed", err);
3275
3570
  hideLoading();
3276
3571
  }
3277
- const listeners = {};
3572
+ const subscriptions = [];
3278
3573
  if (typeof tab?.refresh === "function" && eventBus?.on) {
3279
3574
  for (const [busEvent, reason] of Object.entries(refreshOn || {})) {
3280
3575
  const fn = () => {
@@ -3284,14 +3579,13 @@ function createPageFactories({ eventBus = null, events = {}, refreshOn = {} } =
3284
3579
  console.warn("[page] refresh failed", busEvent, err);
3285
3580
  }
3286
3581
  };
3287
- listeners[busEvent] = fn;
3288
- eventBus.on(busEvent, fn);
3582
+ subscriptions.push(eventBus.on(busEvent, fn));
3289
3583
  }
3290
3584
  }
3291
3585
  return () => {
3292
- for (const [n, fn] of Object.entries(listeners)) {
3586
+ for (const handle of subscriptions) {
3293
3587
  try {
3294
- eventBus?.off?.(n, fn);
3588
+ handle?.dispose?.();
3295
3589
  } catch {
3296
3590
  }
3297
3591
  }
@@ -3378,6 +3672,7 @@ function createPageFactories({ eventBus = null, events = {}, refreshOn = {} } =
3378
3672
 
3379
3673
  // src/tiling/tile_renderer.js
3380
3674
  var SPLITTER_PX = 4;
3675
+ var MIN_SLOT_FRACTION = 0.05;
3381
3676
  var TAB_LAYOUTS = ["bottom", "top", "header"];
3382
3677
  var DEFAULT_TAB_LAYOUT = "bottom";
3383
3678
  function _normalizeTabLayout(value) {
@@ -3410,6 +3705,12 @@ var TileRenderer = class {
3410
3705
  static get TAB_MIME() {
3411
3706
  return TILE_TAB_MIME;
3412
3707
  }
3708
+ /** The smallest share of its split a splitter drag leaves a slot. Readable
3709
+ * so a caller that sets split sizes itself (the WM rebuilding its panel
3710
+ * splits) never builds one a drag could not have produced. */
3711
+ static get MIN_SLOT_FRACTION() {
3712
+ return MIN_SLOT_FRACTION;
3713
+ }
3413
3714
  constructor({
3414
3715
  root,
3415
3716
  tree,
@@ -3417,6 +3718,7 @@ var TileRenderer = class {
3417
3718
  ctx,
3418
3719
  onFocusChange,
3419
3720
  onAfterRender,
3721
+ onSplitResized,
3420
3722
  tabLayout = null,
3421
3723
  newTabButton = true
3422
3724
  }) {
@@ -3431,6 +3733,8 @@ var TileRenderer = class {
3431
3733
  });
3432
3734
  this.onAfterRender = onAfterRender || (() => {
3433
3735
  });
3736
+ this.onSplitResized = onSplitResized || (() => {
3737
+ });
3434
3738
  this.newTabButton = newTabButton !== false;
3435
3739
  this._leafCache = /* @__PURE__ */ new Map();
3436
3740
  this._drag = null;
@@ -3693,6 +3997,7 @@ var TileRenderer = class {
3693
3997
  let content = {};
3694
3998
  let liveTitle = null;
3695
3999
  let liveEntry = null;
4000
+ let liveActionsSet = false;
3696
4001
  if (leaf.content) {
3697
4002
  const setTitle = (text) => {
3698
4003
  const next = text == null || text === "" ? null : String(text);
@@ -3706,13 +4011,22 @@ var TileRenderer = class {
3706
4011
  const now = this.tree?.get?.(leaf.id) || leaf;
3707
4012
  liveEntry.titleEl.textContent = next || now.title || (now.content ? now.content.kind : "empty");
3708
4013
  };
3709
- const leafCtx = { ...this.ctx, leafId: leaf.id, setTitle };
4014
+ const setChromeActions = (specs) => {
4015
+ if (!liveEntry) {
4016
+ liveActionsSet = true;
4017
+ _paintContentActions(contentActions, specs);
4018
+ return;
4019
+ }
4020
+ if (this._leafCache.get(leaf.id) !== liveEntry) return;
4021
+ _paintContentActions(liveEntry.contentActionsEl, specs);
4022
+ };
4023
+ const leafCtx = { ...this.ctx, leafId: leaf.id, setTitle, setChromeActions };
3710
4024
  const activeProps = activeTab?.props ?? leaf.content.props;
3711
4025
  content = this.content.mount(leaf.content.kind, body, activeProps, leafCtx);
3712
4026
  if (content.title) title.textContent = content.title;
3713
4027
  if (liveTitle) title.textContent = liveTitle;
3714
4028
  _paintLeafIcon(icon, leaf, content, this.ctx);
3715
- _paintContentActions(contentActions, content.chromeActions);
4029
+ if (!liveActionsSet) _paintContentActions(contentActions, content.chromeActions);
3716
4030
  _vetoStructuralActions(actions, content.chrome);
3717
4031
  } else {
3718
4032
  body.innerHTML = `<div class="tile-placeholder"><div class="tile-placeholder__hint">empty tile</div></div>`;
@@ -3724,6 +4038,10 @@ var TileRenderer = class {
3724
4038
  titleEl: title,
3725
4039
  iconEl: icon,
3726
4040
  content,
4041
+ // C42. The strip `ctx.setChromeActions` repaints after the
4042
+ // mount. A cached repaint never rebuilds it, which is what
4043
+ // makes a list set later survive one.
4044
+ contentActionsEl: contentActions,
3727
4045
  // DERIVED HERE, NOT REUSED FROM ABOVE. `kindKey` was computed
3728
4046
  // before the mount; content that records its sub-tab or its
3729
4047
  // scroll offset WHILE mounting has already written to the
@@ -4126,7 +4444,7 @@ var TileRenderer = class {
4126
4444
  _topTabCallbacks(leafId) {
4127
4445
  return {
4128
4446
  // C33. `DragReorder` calls this through `NotebookTabBar`, which
4129
- // sets its own `application/x-ecosim-tab` first and unchanged — so
4447
+ // sets its own tab MIME (getTabDragMime()) first and unchanged — so
4130
4448
  // an editor pane sharing the page cannot notice that tiles are
4131
4449
  // dragging tabs too.
4132
4450
  onDragStart: (ev, key, item) => {
@@ -4197,9 +4515,9 @@ var TileRenderer = class {
4197
4515
  ${tabs.map((t, i) => `
4198
4516
  <li class="twm-leaf__tab${i === activeIdx ? " twm-leaf__tab--on" : ""}"
4199
4517
  role="tab" data-tab-idx="${i}"
4200
- title="${_esc6(t.title || t.kind)}"
4518
+ title="${_esc7(t.title || t.kind)}"
4201
4519
  draggable="true">
4202
- <span class="twm-leaf__tab-label">${_esc6(t.title || t.kind)}</span>
4520
+ <span class="twm-leaf__tab-label">${_esc7(t.title || t.kind)}</span>
4203
4521
  <button type="button" class="twm-leaf__tab-close"
4204
4522
  data-action="tab-close" data-tab-idx="${i}"
4205
4523
  title="Close this tab"
@@ -4550,7 +4868,7 @@ var TileRenderer = class {
4550
4868
  const px = totalPx - SPLITTER_PX * (split.children.length - 1);
4551
4869
  if (px <= 0) return;
4552
4870
  const deltaFrac = delta / px * totalSize;
4553
- const minFrac = totalSize * 0.05;
4871
+ const minFrac = totalSize * MIN_SLOT_FRACTION;
4554
4872
  const newSizes = startSizes.slice();
4555
4873
  newSizes[slotIdx] = Math.max(minFrac, startSizes[slotIdx] + deltaFrac);
4556
4874
  newSizes[slotIdx + 1] = Math.max(minFrac, startSizes[slotIdx + 1] - deltaFrac);
@@ -4567,11 +4885,20 @@ var TileRenderer = class {
4567
4885
  });
4568
4886
  }
4569
4887
  _onMouseUp() {
4888
+ const drag = this._drag;
4570
4889
  this._drag = null;
4571
4890
  document.body.classList.remove("twm-dragging");
4891
+ if (!drag) return;
4892
+ const sizes = this.tree.get(drag.splitId)?.sizes;
4893
+ if (!sizes || sizes.every((v, i) => v === drag.startSizes[i])) return;
4894
+ try {
4895
+ this.onSplitResized(drag.splitId);
4896
+ } catch (err) {
4897
+ console.error("[tile-renderer] onSplitResized threw", err);
4898
+ }
4572
4899
  }
4573
4900
  };
4574
- function _esc6(s) {
4901
+ function _esc7(s) {
4575
4902
  return String(s ?? "").replace(/[&<>"']/g, (c) => ({
4576
4903
  "&": "&amp;",
4577
4904
  "<": "&lt;",
@@ -4595,15 +4922,41 @@ function _paintContentActions(hostEl, specs) {
4595
4922
  }
4596
4923
  hostEl.hidden = false;
4597
4924
  for (const spec of specs) {
4598
- if (!spec || !spec.icon) continue;
4925
+ if (!spec) continue;
4926
+ if (spec.separator) {
4927
+ const sep = document.createElement("span");
4928
+ sep.className = "twm-leaf__sep";
4929
+ sep.setAttribute("role", "separator");
4930
+ hostEl.appendChild(sep);
4931
+ continue;
4932
+ }
4933
+ const label = spec.label == null || spec.label === "" ? "" : String(spec.label);
4934
+ if (!spec.icon && !label) continue;
4599
4935
  const btn = document.createElement("button");
4600
4936
  btn.type = "button";
4601
4937
  btn.className = "twm-leaf__btn";
4602
4938
  btn.title = spec.title || "";
4603
- btn.setAttribute("aria-label", spec.title || "");
4604
- btn.innerHTML = `<span class="material-symbols-outlined">${spec.icon}</span>`;
4939
+ btn.setAttribute("aria-label", spec.title || label);
4940
+ btn.innerHTML = spec.icon ? `<span class="material-symbols-outlined">${spec.icon}</span>` : "";
4941
+ if (label) {
4942
+ const text = document.createElement("span");
4943
+ text.className = "twm-leaf__btn-label";
4944
+ text.textContent = label;
4945
+ btn.appendChild(text);
4946
+ btn.classList.add("twm-leaf__btn--labelled");
4947
+ }
4948
+ if (spec.pressed != null) {
4949
+ btn.setAttribute("aria-pressed", spec.pressed ? "true" : "false");
4950
+ if (spec.pressed) btn.classList.add("twm-leaf__btn--pressed");
4951
+ }
4952
+ const disabled = !!spec.disabled;
4953
+ if (disabled) {
4954
+ btn.setAttribute("aria-disabled", "true");
4955
+ btn.classList.add("twm-leaf__btn--disabled");
4956
+ }
4605
4957
  btn.addEventListener("click", (e) => {
4606
4958
  e.stopPropagation();
4959
+ if (disabled) return;
4607
4960
  try {
4608
4961
  spec.onClick?.();
4609
4962
  } catch (err) {
@@ -4777,6 +5130,11 @@ var WindowManager = class _WindowManager {
4777
5130
  this.eventBus = eventBus || null;
4778
5131
  this.onChange = onChange || (() => {
4779
5132
  });
5133
+ this._panelOverrides = {};
5134
+ this._panelAvailability = Object.fromEntries(PANEL_SIDES.map((s) => [s, PANEL_OFFERED]));
5135
+ this._panelAvailabilityListeners = /* @__PURE__ */ new Set();
5136
+ this._reconcilingPanels = false;
5137
+ this._quietDepth = 0;
4780
5138
  this.floatActiveTab = !!floatActiveTab;
4781
5139
  this._rootLeaf = () => {
4782
5140
  const kind = this.taxonomy.root;
@@ -4821,7 +5179,8 @@ var WindowManager = class _WindowManager {
4821
5179
  onLeafTabAction: (leafId, action, data) => this._leafTabAction(leafId, action, data)
4822
5180
  },
4823
5181
  onFocusChange: () => this._notifyChange(),
4824
- onAfterRender: () => this._rehomeContainedWindows()
5182
+ onAfterRender: () => this._rehomeContainedWindows(),
5183
+ onSplitResized: (splitId) => this._onSplitResized(splitId)
4825
5184
  });
4826
5185
  this._persistTimer = null;
4827
5186
  this._windowToLeaf = /* @__PURE__ */ new Map();
@@ -4874,6 +5233,8 @@ var WindowManager = class _WindowManager {
4874
5233
  * so other surfaces (palette, top-bar toggles, page shortcuts) can
4875
5234
  * subscribe through the existing event system. */
4876
5235
  _notifyChange(reason = null) {
5236
+ if (this._quietDepth > 0) return;
5237
+ this._reconcilePanels();
4877
5238
  try {
4878
5239
  this.onChange(reason);
4879
5240
  } catch (err) {
@@ -4923,10 +5284,12 @@ var WindowManager = class _WindowManager {
4923
5284
  }
4924
5285
  }
4925
5286
  }
4926
- this._canonicalize(d.tree, d);
4927
5287
  }
5288
+ const deduped = this._dropDuplicateSingles();
5289
+ for (const d of this.desktops.desktops) this._canonicalize(d.tree, d);
4928
5290
  this.renderer.tree = this.desktops.active().tree;
4929
5291
  this.renderer.render();
5292
+ if (deduped) this._persist();
4930
5293
  this._notifyChange();
4931
5294
  }
4932
5295
  _persist() {
@@ -4936,6 +5299,276 @@ var WindowManager = class _WindowManager {
4936
5299
  await saveDesktops(this.host?.state, this.desktops.serialize());
4937
5300
  }, 500);
4938
5301
  }
5302
+ // ── Single-instance kinds (KindDef `single`) ────────────────────
5303
+ //
5304
+ // Some content holds heavy app-wide state and must exist once. The
5305
+ // taxonomy says which kinds (`single: true`, or `single: '<prop>'` for one
5306
+ // per value of that prop); the WM keeps three promises about them:
5307
+ //
5308
+ // - every OPEN path reveals the instance that exists — it switches to
5309
+ // its desktop, activates its tab (restoring an archived page if that is
5310
+ // where it is) and focuses its tile, or raises its window — instead of
5311
+ // mounting a second one;
5312
+ // - the COPY verbs (Alt+T / Alt+N / Alt+Shift+H / Alt+Shift+V, the tile
5313
+ // menu's "Open in new tab" and "Open a copy in a window", the palette's
5314
+ // split and window modes) are refused, the menus greyed with a reason;
5315
+ // - a restored layout holding duplicates keeps the FIRST (desktop order,
5316
+ // then tile order; live tabs before archived pages).
5317
+ //
5318
+ // MOVE verbs (float, dock, drag a tab, move to desktop) carry the one
5319
+ // instance somewhere else and are untouched.
5320
+ /** The instance key of a tab spec, or null when its kind is not single. */
5321
+ _instanceKey(kind, props) {
5322
+ return this.taxonomy.instanceKey(kind, props);
5323
+ }
5324
+ /** Whether `kind` exists once, and so refuses the copy verbs. */
5325
+ isSingleKind(kind) {
5326
+ return this.taxonomy.isSingle(kind);
5327
+ }
5328
+ /** Where the one instance of a single kind is open, or null — also null
5329
+ * for a kind that is not single. One of:
5330
+ * `{ where: 'window', windowId, idx, desktopIdx }`
5331
+ * `{ where: 'tab', desktopIdx, leafId, idx }`
5332
+ * `{ where: 'archive', desktopIdx, leafId, page, idx }` — a tab of a
5333
+ * top-nav page the tile has switched away from (`pageTabs`). */
5334
+ findInstance(kind, props = {}) {
5335
+ const key = this._instanceKey(kind, props);
5336
+ if (key == null) return null;
5337
+ const same = (t) => !!t && this._instanceKey(t.kind, t.props) === key;
5338
+ for (const [windowId, rec] of this._windowToLeaf) {
5339
+ const idx = _windowTabSpecs(rec).findIndex(same);
5340
+ if (idx >= 0) return { where: "window", windowId, idx, desktopIdx: rec.desktopIdx };
5341
+ }
5342
+ const desktops = this.desktops.desktops;
5343
+ for (const [desktopIdx, d] of desktops.entries()) {
5344
+ for (const leaf of d.tree.leaves()) {
5345
+ const idx = (leaf.tabs || []).findIndex(same);
5346
+ if (idx >= 0) return { where: "tab", desktopIdx, leafId: leaf.id, idx };
5347
+ }
5348
+ }
5349
+ for (const [desktopIdx, d] of desktops.entries()) {
5350
+ for (const leaf of d.tree.leaves()) {
5351
+ for (const [page, arc] of this._archivedPages(leaf)) {
5352
+ const idx = arc.tabs.findIndex(same);
5353
+ if (idx >= 0) return { where: "archive", desktopIdx, leafId: leaf.id, page, idx };
5354
+ }
5355
+ }
5356
+ }
5357
+ return null;
5358
+ }
5359
+ /** Reveal the instance of a single kind that is open: answers whether
5360
+ * there was one. Every open path asks this first. */
5361
+ revealInstance(kind, props = {}) {
5362
+ const loc = this.findInstance(kind, props);
5363
+ return loc ? this._revealAt(loc) : false;
5364
+ }
5365
+ /** A leaf's archived pages that can come back — every `pageTabs` entry
5366
+ * but the CURRENT page's. `swapToPage` restores an archive without
5367
+ * deleting it, so the current page's entry is a stale copy of the live
5368
+ * tabs, overwritten the next time the tile leaves the page; counting it
5369
+ * would find every live instance twice. */
5370
+ _archivedPages(leaf) {
5371
+ const kind = leaf?.content?.kind;
5372
+ const current = kind ? this.taxonomy.topNavFor(kind) || null : null;
5373
+ return Object.entries(leaf?.pageTabs || {}).filter(([page, arc]) => page !== current && Array.isArray(arc?.tabs));
5374
+ }
5375
+ /** Reveal `loc` with ONE change notification. The verbs a reveal is made
5376
+ * of — `showWindowTab`, `switchDesktop` (directly or via `revealWindow`)
5377
+ * — each notify on their own; inside a reveal they are quiet, and the
5378
+ * reveal notifies once, as every other verb does. */
5379
+ _revealAt(loc) {
5380
+ this._quietDepth += 1;
5381
+ let shown = false;
5382
+ try {
5383
+ shown = this._revealAtQuietly(loc);
5384
+ } finally {
5385
+ this._quietDepth -= 1;
5386
+ }
5387
+ if (shown) this._notifyChange("single-reveal");
5388
+ return shown;
5389
+ }
5390
+ _revealAtQuietly(loc) {
5391
+ if (loc.where === "window") {
5392
+ const rec = this._windowToLeaf.get(loc.windowId);
5393
+ if (!rec) return false;
5394
+ if (Array.isArray(rec.tabs) && rec.tabs.length) this.showWindowTab(loc.windowId, loc.idx);
5395
+ this.revealWindow(rec.window);
5396
+ try {
5397
+ rec.window.show();
5398
+ } catch (err) {
5399
+ console.warn("[wm] reveal: show failed", err);
5400
+ }
5401
+ return true;
5402
+ }
5403
+ const tree = this.desktops.desktops[loc.desktopIdx]?.tree;
5404
+ const leaf = tree?.get(loc.leafId);
5405
+ if (!leaf || leaf.kind !== "leaf") return false;
5406
+ if (loc.where === "archive") {
5407
+ const tab = leaf.pageTabs[loc.page].tabs[loc.idx];
5408
+ const key = this._instanceKey(tab.kind, tab.props);
5409
+ const curKind = leaf.content?.kind;
5410
+ const current = curKind ? this.taxonomy.topNavFor(curKind) || null : null;
5411
+ this._swapToPage(
5412
+ tree,
5413
+ loc.leafId,
5414
+ { kind: tab.kind, props: tab.props || {}, title: tab.title },
5415
+ current,
5416
+ loc.page,
5417
+ { recordHistory: false }
5418
+ );
5419
+ const at = (leaf.tabs || []).findIndex((t) => this._instanceKey(t.kind, t.props) === key);
5420
+ if (at >= 0) tree.setActiveLeafTab(loc.leafId, at);
5421
+ } else {
5422
+ tree.setActiveLeafTab(loc.leafId, loc.idx);
5423
+ }
5424
+ tree.focus(loc.leafId);
5425
+ if (loc.desktopIdx !== this.desktops.activeIdx) {
5426
+ this.switchDesktop(loc.desktopIdx);
5427
+ } else {
5428
+ this.renderer.render();
5429
+ this._persist();
5430
+ }
5431
+ return true;
5432
+ }
5433
+ /** `TileTree.swapToPage`, and then no restored tab is a second copy of a
5434
+ * single kind. Every page restore in the WM goes through here.
5435
+ *
5436
+ * A page archive (`pageTabs`) is a snapshot: the instance it lists can
5437
+ * have moved to another tile or a window since — and the current page's
5438
+ * own entry is a stale copy of the live tabs that comes back into play as
5439
+ * soon as the tile leaves the page by a path other than `swapToPage`
5440
+ * (`navigateActiveTab`). So a restored tab whose instance is live
5441
+ * anywhere else is dropped from the tile and from the restored archive;
5442
+ * the instance stays where it is. */
5443
+ _swapToPage(tree, leafId, target, currentTopNav, targetTopNav, opts) {
5444
+ const done = tree.swapToPage(leafId, target, currentTopNav, targetTopNav, opts);
5445
+ if (done) this._dropRestoredCopies(tree, leafId, targetTopNav, target);
5446
+ return done;
5447
+ }
5448
+ _dropRestoredCopies(tree, leafId, page, target) {
5449
+ const leaf = tree.get(leafId);
5450
+ const tabs = Array.isArray(leaf?.tabs) ? leaf.tabs : null;
5451
+ if (!tabs) return;
5452
+ const elsewhere = this._liveInstanceKeys(tree, leafId);
5453
+ const keeper = () => {
5454
+ const seen = /* @__PURE__ */ new Set();
5455
+ return (t) => {
5456
+ const k = t ? this._instanceKey(t.kind, t.props) : null;
5457
+ if (k == null) return true;
5458
+ if (elsewhere.has(k) || seen.has(k)) return false;
5459
+ seen.add(k);
5460
+ return true;
5461
+ };
5462
+ };
5463
+ const arc = leaf.pageTabs?.[page];
5464
+ if (arc && Array.isArray(arc.tabs)) {
5465
+ const kept = arc.tabs.filter(keeper());
5466
+ if (kept.length !== arc.tabs.length) {
5467
+ arc.tabs = kept;
5468
+ arc.activeTabIdx = Math.max(0, Math.min(kept.length - 1, arc.activeTabIdx || 0));
5469
+ }
5470
+ }
5471
+ const keep = keeper();
5472
+ let active = leaf.activeTabIdx || 0;
5473
+ let changed = false;
5474
+ for (let i = 0; i < tabs.length; ) {
5475
+ if (keep(tabs[i])) {
5476
+ i++;
5477
+ continue;
5478
+ }
5479
+ tabs.splice(i, 1);
5480
+ if (i < active) active -= 1;
5481
+ changed = true;
5482
+ }
5483
+ if (!changed) return;
5484
+ if (!tabs.length) {
5485
+ tabs.push({
5486
+ kind: target.kind,
5487
+ props: target.props || {},
5488
+ title: target.title || _tabTitle(target.kind, target.props),
5489
+ history: []
5490
+ });
5491
+ active = 0;
5492
+ }
5493
+ tree.setActiveLeafTab(leafId, Math.max(0, Math.min(tabs.length - 1, active)));
5494
+ }
5495
+ /** The instance keys of every LIVE tab — windows, and every leaf of every
5496
+ * desktop but `leafId` of `tree`. Archived pages are not live. */
5497
+ _liveInstanceKeys(tree, leafId) {
5498
+ const keys = /* @__PURE__ */ new Set();
5499
+ const add = (t) => {
5500
+ const k = t ? this._instanceKey(t.kind, t.props) : null;
5501
+ if (k != null) keys.add(k);
5502
+ };
5503
+ for (const [, rec] of this._windowToLeaf) _windowTabSpecs(rec).forEach(add);
5504
+ for (const d of this.desktops.desktops) {
5505
+ for (const leaf of d.tree.leaves()) {
5506
+ if (d.tree === tree && leaf.id === leafId) continue;
5507
+ (leaf.tabs || []).forEach(add);
5508
+ }
5509
+ }
5510
+ return keys;
5511
+ }
5512
+ /** A restored layout keeps the FIRST instance of each single kind: later
5513
+ * live tabs are dropped (the tile shows the tab that took the dropped
5514
+ * one's place, or closes when it held nothing else), and so are later
5515
+ * archived ones. Answers whether anything was dropped. */
5516
+ _dropDuplicateSingles() {
5517
+ const seen = /* @__PURE__ */ new Set();
5518
+ let dropped = false;
5519
+ const keep = (t) => {
5520
+ const k = t ? this._instanceKey(t.kind, t.props) : null;
5521
+ if (k == null) return true;
5522
+ if (seen.has(k)) return false;
5523
+ seen.add(k);
5524
+ return true;
5525
+ };
5526
+ for (const d of this.desktops.desktops) {
5527
+ const tree = d.tree;
5528
+ for (const leaf of tree.leaves()) {
5529
+ const tabs = Array.isArray(leaf.tabs) ? leaf.tabs : [];
5530
+ let active = leaf.activeTabIdx || 0;
5531
+ let changed = false;
5532
+ for (let i = 0; i < tabs.length; ) {
5533
+ if (keep(tabs[i])) {
5534
+ i++;
5535
+ continue;
5536
+ }
5537
+ tabs.splice(i, 1);
5538
+ if (i < active) active -= 1;
5539
+ changed = true;
5540
+ }
5541
+ if (!changed) continue;
5542
+ dropped = true;
5543
+ if (tabs.length) {
5544
+ tree.setActiveLeafTab(leaf.id, Math.max(0, Math.min(tabs.length - 1, active)));
5545
+ continue;
5546
+ }
5547
+ tree.close(leaf.id);
5548
+ if (!tree.rootId) tree.setRoot(makeLeaf(this._rootLeaf()));
5549
+ if (!tree.leaves().some((l) => !String(l.content?.kind || "").startsWith("panel:"))) {
5550
+ const spawned = this._spawnContentLeaf(tree);
5551
+ if (spawned) this._seedHome(tree, spawned);
5552
+ }
5553
+ }
5554
+ }
5555
+ for (const d of this.desktops.desktops) {
5556
+ for (const leaf of d.tree.leaves()) {
5557
+ for (const [page, arc] of this._archivedPages(leaf)) {
5558
+ const kept = arc.tabs.filter(keep);
5559
+ if (kept.length === arc.tabs.length) continue;
5560
+ dropped = true;
5561
+ if (!kept.length) {
5562
+ delete leaf.pageTabs[page];
5563
+ continue;
5564
+ }
5565
+ arc.tabs = kept;
5566
+ arc.activeTabIdx = Math.max(0, Math.min(kept.length - 1, arc.activeTabIdx || 0));
5567
+ }
5568
+ }
5569
+ }
5570
+ return dropped;
5571
+ }
4939
5572
  // ── Content loading ─────────────────────────────────────────────
4940
5573
  /** Load content into the primary tile (last-active non-panel leaf).
4941
5574
  /** Walk back one step. Bound to Backspace.
@@ -5129,11 +5762,13 @@ var WindowManager = class _WindowManager {
5129
5762
  const tree = this._tree();
5130
5763
  const prior = tree.popActiveTabHistory?.(id);
5131
5764
  if (!prior || !prior.kind) return false;
5765
+ if (this.revealInstance(prior.kind, prior.props || {})) return true;
5132
5766
  const curKind = tree.get(id)?.content?.kind;
5133
5767
  const curTopNav = curKind ? this.taxonomy.topNavFor(curKind) : null;
5134
5768
  const title = prior.title || _tabTitle(prior.kind, prior.props);
5135
5769
  if (prior.topNav && curTopNav && prior.topNav !== curTopNav) {
5136
- tree.swapToPage?.(
5770
+ this._swapToPage(
5771
+ tree,
5137
5772
  id,
5138
5773
  { kind: prior.kind, props: prior.props || {}, title },
5139
5774
  curTopNav,
@@ -5243,6 +5878,7 @@ var WindowManager = class _WindowManager {
5243
5878
  * the leaf. Used by Backspace + breadcrumb segments + anything
5244
5879
  * else that should walk WITHIN the tile rather than reset it. */
5245
5880
  navigateActiveTab(leafId, kind, props = {}) {
5881
+ if (this.revealInstance(kind, props)) return;
5246
5882
  const tree = this._tree();
5247
5883
  const leaf = tree.get(leafId);
5248
5884
  if (!leaf || leaf.kind !== "leaf") {
@@ -5296,6 +5932,7 @@ var WindowManager = class _WindowManager {
5296
5932
  * tab feature shipped), the behavior is identical to the old
5297
5933
  * setLeafContent: one tab in, one tab out. */
5298
5934
  openInLeaf(leafId, kind, props = {}) {
5935
+ if (this.revealInstance(kind, props)) return;
5299
5936
  const tree = this._tree();
5300
5937
  const leaf = tree.get(leafId);
5301
5938
  if (!leaf || leaf.kind !== "leaf") {
@@ -5312,6 +5949,7 @@ var WindowManager = class _WindowManager {
5312
5949
  * previous mount, mounts the new kind into the same body element,
5313
5950
  * and updates the window's title. */
5314
5951
  openInWindow(winId, kind, props = {}) {
5952
+ if (this.revealInstance(kind, props)) return;
5315
5953
  const rec = this._windowToLeaf.get(winId);
5316
5954
  if (!rec) {
5317
5955
  this.openInPrimary(kind, props);
@@ -5350,6 +5988,7 @@ var WindowManager = class _WindowManager {
5350
5988
  * If only panel tiles exist (or the tree is empty), spawn a new
5351
5989
  * content leaf and canonicalize so the panels wrap it. */
5352
5990
  openInPrimary(kind, props = {}) {
5991
+ if (this.revealInstance(kind, props)) return;
5353
5992
  const tree = this._tree();
5354
5993
  let leafId = tree.primaryLeafId();
5355
5994
  let spawned = false;
@@ -5362,7 +6001,8 @@ var WindowManager = class _WindowManager {
5362
6001
  const currentTopNav = leaf?.content?.kind ? this.taxonomy.topNavFor(leaf.content.kind) || null : null;
5363
6002
  const targetTopNav = this.taxonomy.topNavFor(kind) || kind;
5364
6003
  const title = _tabTitle(kind, props);
5365
- tree.swapToPage(
6004
+ this._swapToPage(
6005
+ tree,
5366
6006
  leafId,
5367
6007
  { kind, props, title },
5368
6008
  currentTopNav,
@@ -5408,6 +6048,7 @@ var WindowManager = class _WindowManager {
5408
6048
  * pane in a split never dismounts the original). Returns the new
5409
6049
  * leaf id, or null if the leaf can't be split. */
5410
6050
  splitLeafWith(leafId, dir, kind, props = {}, title = "") {
6051
+ if (this.revealInstance(kind, props)) return null;
5411
6052
  const tree = this._tree();
5412
6053
  leafId = this._contentLeafId(leafId);
5413
6054
  const src = leafId ? tree.get(leafId) : null;
@@ -5443,17 +6084,18 @@ var WindowManager = class _WindowManager {
5443
6084
  if (!kind || kind === PLACEHOLDER_KIND || kind.startsWith("panel:")) return null;
5444
6085
  return { leafId: id, kind, props: leaf.content.props || {}, title: leaf.title };
5445
6086
  }
5446
- /** Alt+T — open the focused tile's content in a new tab on that tile. */
6087
+ /** Alt+T — open the focused tile's content in a new tab on that tile.
6088
+ * A copy verb, so a single kind refuses it (and Alt+N, Alt+Shift+H/V). */
5447
6089
  openFocusedInTab() {
5448
6090
  const c = this._focusedContent();
5449
- if (!c) return;
6091
+ if (!c || this.isSingleKind(c.kind)) return;
5450
6092
  this.openInTabFromContext({ leafId: c.leafId }, c.kind, c.props);
5451
6093
  }
5452
6094
  /** Alt+N — open the focused tile's content in a fresh managed window.
5453
6095
  * Unlike Alt+F (promote) this DUPLICATES: the source tile stays put. */
5454
6096
  openFocusedInWindow() {
5455
6097
  const c = this._focusedContent();
5456
- if (!c) return;
6098
+ if (!c || this.isSingleKind(c.kind)) return;
5457
6099
  this._navigateWindow(c.kind, c.props);
5458
6100
  }
5459
6101
  /** Alt+Shift+H / Alt+Shift+V — split the focused tile and open a fresh
@@ -5465,6 +6107,7 @@ var WindowManager = class _WindowManager {
5465
6107
  this.split(dir);
5466
6108
  return;
5467
6109
  }
6110
+ if (this.isSingleKind(c.kind)) return;
5468
6111
  this.splitLeafWith(c.leafId, dir, c.kind, c.props, c.title);
5469
6112
  }
5470
6113
  closeFocused() {
@@ -7225,15 +7868,94 @@ var WindowManager = class _WindowManager {
7225
7868
  * edge (a recursive split) or closes that leaf. */
7226
7869
  isPanelOpen(side) {
7227
7870
  const d = this.desktops.active();
7228
- return !!d.panels[side];
7871
+ return !!d.panels[side] && this.isPanelAvailable(side);
7229
7872
  }
7873
+ /** Flip a panel. A withheld panel is not flipped: answers `false` and
7874
+ * touches nothing — not the remembered open state, not the layout, not
7875
+ * what is persisted. Answers `true` when it flipped. */
7230
7876
  togglePanel(side) {
7877
+ if (!this.isPanelAvailable(side)) return false;
7231
7878
  const d = this.desktops.active();
7232
7879
  d.panels[side] = !d.panels[side];
7233
7880
  this._canonicalize(this._tree(), d);
7234
7881
  this.renderer.render();
7235
7882
  this._persist();
7236
7883
  this._notifyChange();
7884
+ return true;
7885
+ }
7886
+ // ── Panel availability ──────────────────────────────────────────
7887
+ /** Whether the panel on `side` is offered on the active desktop right now:
7888
+ * `{ available, reason }`. A runtime override wins; else the primary
7889
+ * tile's kind says (`KindDef.panels`, see kind_taxonomy.js); else it is
7890
+ * offered. `desktops.panels` is untouched by any of this — it remembers
7891
+ * what the user OPENED, so a panel withheld while it was open comes back
7892
+ * open once it is offered again. */
7893
+ getPanelAvailability(side, tree = this._tree()) {
7894
+ assertPanelSide(side, "getPanelAvailability");
7895
+ const override = this._panelOverrides[side];
7896
+ if (override) return override;
7897
+ const id = tree.primaryLeafId();
7898
+ const content = id ? tree.get(id)?.content : null;
7899
+ const kind = content?.kind === PLACEHOLDER_KIND ? content.props?.originalKind : content?.kind;
7900
+ return this.taxonomy.panelOffer(kind, side);
7901
+ }
7902
+ isPanelAvailable(side) {
7903
+ return this.getPanelAvailability(side).available;
7904
+ }
7905
+ /** Content's say, at runtime: `offer` is `true`, `false` or `{ reason }`
7906
+ * and beats the kind's declaration until it is cleared with `null`. The
7907
+ * override belongs to the shell, not to a tile — content that sets one
7908
+ * clears it when it goes (its `destroy`). */
7909
+ setPanelAvailability(side, offer) {
7910
+ assertPanelSide(side, "setPanelAvailability");
7911
+ if (offer === null || offer === void 0) delete this._panelOverrides[side];
7912
+ else this._panelOverrides[side] = readPanelOffer(offer, "setPanelAvailability");
7913
+ this._notifyChange("panel-availability");
7914
+ }
7915
+ /** Subscribe to availability changes. `fn({ panels, changed })` runs ONCE
7916
+ * per change, however many sides changed in it: `panels` is every side's
7917
+ * `{ available, reason }`, `changed` the sides that differ from the last
7918
+ * call. The same payload goes out on the bus as `wm:panels:availability`.
7919
+ * Returns the unsubscribe. */
7920
+ onPanelAvailabilityChange(fn) {
7921
+ this._panelAvailabilityListeners.add(fn);
7922
+ return () => this._panelAvailabilityListeners.delete(fn);
7923
+ }
7924
+ /** Bring the active desktop's panel leaves in line with what is offered,
7925
+ * and announce a change of availability. Called from `_notifyChange`, so
7926
+ * every path that can change the primary kind passes through it. */
7927
+ _reconcilePanels() {
7928
+ if (this._reconcilingPanels || !this.desktops) return;
7929
+ this._reconcilingPanels = true;
7930
+ try {
7931
+ const d = this.desktops.active();
7932
+ const panels = {};
7933
+ for (const side of PANEL_SIDES) panels[side] = this.getPanelAvailability(side, d.tree);
7934
+ const shown = (side) => !!d.panels[side] && panels[side].available;
7935
+ if (d.tree.rootId && PANEL_SIDES.some((s) => shown(s) !== !!this._findPanelLeaf(s))) {
7936
+ this._canonicalize(d.tree, d);
7937
+ this.renderer.render();
7938
+ }
7939
+ const last = this._panelAvailability;
7940
+ const changed = PANEL_SIDES.filter((s) => last[s].available !== panels[s].available || last[s].reason !== panels[s].reason);
7941
+ if (!changed.length) return;
7942
+ this._panelAvailability = panels;
7943
+ const payload = Object.freeze({ panels: Object.freeze(panels), changed, wm: this });
7944
+ for (const fn of [...this._panelAvailabilityListeners]) {
7945
+ try {
7946
+ fn(payload);
7947
+ } catch (err) {
7948
+ console.error("[wm] panel availability listener threw", err);
7949
+ }
7950
+ }
7951
+ try {
7952
+ this.eventBus?.emit?.("wm:panels:availability", payload);
7953
+ } catch (err) {
7954
+ console.warn("[wm] event emit failed", err);
7955
+ }
7956
+ } finally {
7957
+ this._reconcilingPanels = false;
7958
+ }
7237
7959
  }
7238
7960
  /** Rebuild the tree so panel:* leaves sit in the canonical positions:
7239
7961
  *
@@ -7247,13 +7969,13 @@ var WindowManager = class _WindowManager {
7247
7969
  * Existing panel leaves are detached + re-attached (same id), so the
7248
7970
  * renderer cache keeps the mounted content (nav filter state, etc.). */
7249
7971
  _canonicalize(tree, desktop) {
7250
- const panel = {};
7251
- for (const leaf of tree.leaves()) {
7252
- const kind = leaf.content?.kind;
7253
- if (kind === "panel:left") panel.left = leaf;
7254
- else if (kind === "panel:right") panel.right = leaf;
7255
- else if (kind === "panel:bottom") panel.bottom = leaf;
7972
+ const open = {};
7973
+ for (const side of PANEL_SIDES) {
7974
+ open[side] = !!desktop.panels[side] && this.getPanelAvailability(side, tree).available;
7256
7975
  }
7976
+ const panel = _panelLeaves(tree);
7977
+ this._recordPanelShares(tree, desktop);
7978
+ const share = desktop.panelShares;
7257
7979
  const detach = (leaf) => {
7258
7980
  if (!leaf) return;
7259
7981
  const pid = leaf.parentId;
@@ -7277,7 +7999,7 @@ var WindowManager = class _WindowManager {
7277
7999
  detach(panel.bottom);
7278
8000
  let seededSide = null;
7279
8001
  if (!tree.rootId) {
7280
- seededSide = desktop.panels.left ? "left" : desktop.panels.bottom ? "bottom" : desktop.panels.right ? "right" : null;
8002
+ seededSide = open.left ? "left" : open.bottom ? "bottom" : open.right ? "right" : null;
7281
8003
  if (seededSide) {
7282
8004
  const seed = panel[seededSide] || makeLeaf({
7283
8005
  content: { kind: `panel:${seededSide}`, props: {} },
@@ -7292,6 +8014,15 @@ var WindowManager = class _WindowManager {
7292
8014
  tree.rootId = seed.id;
7293
8015
  }
7294
8016
  }
8017
+ const willWrap = (side) => open[side] && seededSide !== side && !!tree.rootId;
8018
+ const DEFAULT_SHARE = 1 / 5;
8019
+ const slotShare = (side) => {
8020
+ const own = share[side];
8021
+ if (own == null) return null;
8022
+ if (side !== "left" || !willWrap("right")) return _clampSlotShare(own);
8023
+ const right = _clampSlotShare(share.right ?? DEFAULT_SHARE);
8024
+ return _clampSlotShare(own / (1 - right));
8025
+ };
7295
8026
  const wrap = (panelLeaf, side) => {
7296
8027
  if (panelLeaf && !tree.nodes.has(panelLeaf.id)) {
7297
8028
  tree.nodes.set(panelLeaf.id, panelLeaf);
@@ -7307,7 +8038,8 @@ var WindowManager = class _WindowManager {
7307
8038
  const before = side === "left";
7308
8039
  const dir = side === "bottom" ? "v" : "h";
7309
8040
  const children = before ? [leaf.id, tree.rootId] : [tree.rootId, leaf.id];
7310
- const sizes = side === "bottom" ? [4, 1] : side === "left" ? [1, 4] : [4, 1];
8041
+ const kept = slotShare(side);
8042
+ const sizes = kept != null ? before ? [kept, 1 - kept] : [1 - kept, kept] : before ? [1, 4] : [4, 1];
7311
8043
  const split = {
7312
8044
  id: `split-panel-${side}-${Math.random().toString(36).slice(2, 7)}`,
7313
8045
  kind: "split",
@@ -7322,9 +8054,9 @@ var WindowManager = class _WindowManager {
7322
8054
  leaf.parentId = split.id;
7323
8055
  tree.rootId = split.id;
7324
8056
  };
7325
- if (desktop.panels.left && seededSide !== "left" && tree.rootId) wrap(panel.left, "left");
7326
- if (desktop.panels.right && seededSide !== "right" && tree.rootId) wrap(panel.right, "right");
7327
- if (desktop.panels.bottom && seededSide !== "bottom" && tree.rootId) wrap(panel.bottom, "bottom");
8057
+ if (willWrap("left")) wrap(panel.left, "left");
8058
+ if (willWrap("right")) wrap(panel.right, "right");
8059
+ if (willWrap("bottom")) wrap(panel.bottom, "bottom");
7328
8060
  const reachable = /* @__PURE__ */ new Set();
7329
8061
  const walk = (id) => {
7330
8062
  if (!id || reachable.has(id)) return;
@@ -7337,6 +8069,68 @@ var WindowManager = class _WindowManager {
7337
8069
  if (!reachable.has(id)) tree.nodes.delete(id);
7338
8070
  }
7339
8071
  }
8072
+ /** A finished splitter drag of split `splitId` is a layout change like
8073
+ * any other: record the shares of the panels that split sizes (the ones
8074
+ * inside it; the Navigator sits inside the right panel's split) while
8075
+ * the tree is still canonical (a drag never changes its structure), then
8076
+ * save, so the dragged size is what the next load, or the next
8077
+ * structural change, gives back. */
8078
+ _onSplitResized(splitId) {
8079
+ this._recordPanelShares(this._tree(), this.desktops.active(), splitId);
8080
+ this._persist();
8081
+ this._notifyChange("split-resized");
8082
+ }
8083
+ /** Keep `desktop.panelShares` current: each panel's share of the WM area
8084
+ * along its own axis (width for left/right, height for bottom), kept on
8085
+ * the desktop record and serialized with it, so a panel the user closes
8086
+ * comes back at the size it had, even across a reload while it was
8087
+ * closed. With `resizedSplitId`, the panels inside that split are
8088
+ * measured (a drag); without, the panels that have no share yet are.
8089
+ * A panel that measures nothing (not on the tree, the root, or under a
8090
+ * split that is not one of its canonical panel splits) keeps the share
8091
+ * it last had. A share is recorded as measured, never clamped: the drag
8092
+ * range belongs to the slot of the split being built (step 5 of
8093
+ * _canonicalize), not to an area share. A stored value that is not a
8094
+ * share at all (not a finite number strictly between 0 and 1) is
8095
+ * dropped, so that panel opens at the default again. */
8096
+ _recordPanelShares(tree, desktop, resizedSplitId = null) {
8097
+ const panel = _panelLeaves(tree);
8098
+ for (const side of PANEL_SIDES) {
8099
+ const kept = _validShare(desktop.panelShares[side]);
8100
+ const measure = resizedSplitId == null ? kept == null : _isInside(tree, panel[side], resizedSplitId);
8101
+ const v = (measure ? this._panelShare(tree, panel[side], side) : null) ?? kept;
8102
+ if (v == null) delete desktop.panelShares[side];
8103
+ else desktop.panelShares[side] = v;
8104
+ }
8105
+ }
8106
+ /** The share (0..1) of the WM area the `side` panel leaf takes along its
8107
+ * axis: the product of its shares in every ancestor split running that
8108
+ * way (splits across the axis do not change it). Only a CANONICAL
8109
+ * placement measures: the leaf's own parent is its `split-panel-<side>`
8110
+ * split and every ancestor is a panel split. Any other tree is one a
8111
+ * structural change has left in passing (the last content tile closed,
8112
+ * or a spawned content leaf wrapping the whole root), whose sizes are
8113
+ * not the user's; that, a missing or root leaf, or a degenerate split
8114
+ * gives null. */
8115
+ _panelShare(tree, leaf, side) {
8116
+ if (!leaf?.parentId) return null;
8117
+ if (!String(leaf.parentId).startsWith(`split-panel-${side}-`)) return null;
8118
+ const axis = side === "bottom" ? "v" : "h";
8119
+ let frac = 1;
8120
+ let node = leaf;
8121
+ while (node.parentId) {
8122
+ const parent = tree.get(node.parentId);
8123
+ if (!parent || !String(parent.id).startsWith("split-panel-")) return null;
8124
+ if (parent.dir === axis) {
8125
+ const idx = parent.children.indexOf(node.id);
8126
+ const total = parent.sizes.reduce((a, b) => a + b, 0);
8127
+ if (idx < 0 || !(total > 0) || !(parent.sizes[idx] > 0)) return null;
8128
+ frac *= parent.sizes[idx] / total;
8129
+ }
8130
+ node = parent;
8131
+ }
8132
+ return frac > 0 && frac < 1 ? frac : null;
8133
+ }
7340
8134
  _findPanelLeaf(side) {
7341
8135
  const kind = `panel:${side}`;
7342
8136
  for (const leaf of this._tree().leaves()) {
@@ -7682,6 +8476,7 @@ var WindowManager = class _WindowManager {
7682
8476
  /** Spawn a fresh ManagedWindow with the requested content. No
7683
8477
  * source leaf — closing the window just disposes the content. */
7684
8478
  _navigateWindow(kind, props) {
8479
+ if (this.revealInstance(kind, props)) return;
7685
8480
  const winId = `twm-mw-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 5)}`;
7686
8481
  const contentEl = document.createElement("div");
7687
8482
  contentEl.className = "twm-window-content";
@@ -7799,6 +8594,7 @@ var WindowManager = class _WindowManager {
7799
8594
  * routing) but uses `appendLeafTab` so the existing content
7800
8595
  * stays in place as a tab. */
7801
8596
  openInTabFromContext(ctx, kind, props = {}, transient = false, background = false) {
8597
+ if (this.revealInstance(kind, props)) return;
7802
8598
  if (ctx?.windowId && this._windowToLeaf.has(ctx.windowId)) {
7803
8599
  this.openInWindow(ctx.windowId, kind, props);
7804
8600
  return;
@@ -7831,6 +8627,7 @@ var WindowManager = class _WindowManager {
7831
8627
  * "Add row" button, which passes no ctx) reliably lands as a sibling
7832
8628
  * tab in the main tile rather than swapping its content. */
7833
8629
  openInTabInPrimary(kind, props = {}, transient = false, background = false) {
8630
+ if (this.revealInstance(kind, props)) return;
7834
8631
  const tree = this._tree();
7835
8632
  const leafId = tree.primaryLeafId();
7836
8633
  if (!leafId) {
@@ -7849,11 +8646,38 @@ var WindowManager = class _WindowManager {
7849
8646
  this._notifyChange("tab-open");
7850
8647
  }
7851
8648
  };
8649
+ function _panelLeaves(tree) {
8650
+ const panel = {};
8651
+ for (const leaf of tree.leaves()) {
8652
+ const kind = leaf.content?.kind;
8653
+ if (kind === "panel:left") panel.left = leaf;
8654
+ else if (kind === "panel:right") panel.right = leaf;
8655
+ else if (kind === "panel:bottom") panel.bottom = leaf;
8656
+ }
8657
+ return panel;
8658
+ }
8659
+ function _isInside(tree, node, splitId) {
8660
+ for (let id = node?.parentId; id; id = tree.get(id)?.parentId) {
8661
+ if (id === splitId) return true;
8662
+ }
8663
+ return false;
8664
+ }
8665
+ function _validShare(v) {
8666
+ return typeof v === "number" && Number.isFinite(v) && v > 0 && v < 1 ? v : null;
8667
+ }
8668
+ function _clampSlotShare(v) {
8669
+ const min = TileRenderer.MIN_SLOT_FRACTION;
8670
+ return Math.min(Math.max(v, min), 1 - min);
8671
+ }
7852
8672
  function _tabTitle(kind, props) {
7853
8673
  if (props && typeof props.label === "string" && props.label) return props.label;
7854
8674
  if (props && (typeof props.id === "string" || typeof props.id === "number") && String(props.id)) return String(props.id);
7855
8675
  return kind || "";
7856
8676
  }
8677
+ function _windowTabSpecs(rec) {
8678
+ if (Array.isArray(rec?.tabs) && rec.tabs.length) return rec.tabs;
8679
+ return rec?.original ? [rec.original] : [];
8680
+ }
7857
8681
  function _leafTabSpecs(leaf) {
7858
8682
  const tabs = Array.isArray(leaf.tabs) ? leaf.tabs : [];
7859
8683
  if (tabs.length) {
@@ -8114,17 +8938,17 @@ function openTileTabMenu({
8114
8938
  return `
8115
8939
  <li class="twm-tile-tabmenu__item${idx === _cursor ? " twm-tile-tabmenu__item--cursor" : ""}"
8116
8940
  data-flat-idx="${idx}">
8117
- <span class="twm-tile-tabmenu__item-label">${_esc7(label)}</span>
8118
- ${hint ? `<span class="twm-tile-tabmenu__item-hint">${_esc7(hint)}</span>` : ""}
8941
+ <span class="twm-tile-tabmenu__item-label">${_esc8(label)}</span>
8942
+ ${hint ? `<span class="twm-tile-tabmenu__item-hint">${_esc8(hint)}</span>` : ""}
8119
8943
  </li>
8120
8944
  `;
8121
8945
  }).join("");
8122
8946
  return `
8123
8947
  <section class="twm-tile-tabmenu__section"
8124
- data-section="${_esc7(s.src.navKind)}">
8948
+ data-section="${_esc8(s.src.navKind)}">
8125
8949
  <header class="twm-tile-tabmenu__section-head">
8126
- <span class="material-symbols-outlined">${_esc7(s.meta?.icon || "arrow_right")}</span>
8127
- <span class="twm-tile-tabmenu__section-label">${_esc7(s.meta?.label || s.src.navKind)}</span>
8950
+ <span class="material-symbols-outlined">${_esc8(s.meta?.icon || "arrow_right")}</span>
8951
+ <span class="twm-tile-tabmenu__section-label">${_esc8(s.meta?.label || s.src.navKind)}</span>
8128
8952
  <span class="twm-tile-tabmenu__section-count">${s.total}</span>
8129
8953
  </header>
8130
8954
  ${s.view.length === 0 ? `
@@ -8133,11 +8957,11 @@ function openTileTabMenu({
8133
8957
  ${!_query && s.pages > 1 ? `
8134
8958
  <div class="twm-tile-tabmenu__pager">
8135
8959
  <button type="button" data-action="prev"
8136
- data-section="${_esc7(s.src.navKind)}"
8960
+ data-section="${_esc8(s.src.navKind)}"
8137
8961
  ${s.page === 0 ? "disabled" : ""}>&lt;</button>
8138
8962
  <span>${s.page + 1} / ${s.pages}</span>
8139
8963
  <button type="button" data-action="next"
8140
- data-section="${_esc7(s.src.navKind)}"
8964
+ data-section="${_esc8(s.src.navKind)}"
8141
8965
  ${s.page >= s.pages - 1 ? "disabled" : ""}>&gt;</button>
8142
8966
  </div>
8143
8967
  ` : ""}
@@ -8201,7 +9025,7 @@ function openTileTabSwitcher({ x, y, tabs, activeIdx = 0, onPick }) {
8201
9025
  <li class="twm-tile-tabswitch__item${i === activeIdx ? " twm-tile-tabswitch__item--on" : ""}"
8202
9026
  data-idx="${i}" role="option" aria-selected="${i === activeIdx}">
8203
9027
  <span class="material-symbols-outlined twm-tile-tabswitch__check">${i === activeIdx ? "check" : ""}</span>
8204
- <span class="twm-tile-tabswitch__label">${_esc7(t.title || t.kind || "Tab")}</span>
9028
+ <span class="twm-tile-tabswitch__label">${_esc8(t.title || t.kind || "Tab")}</span>
8205
9029
  </li>
8206
9030
  `).join("");
8207
9031
  overlay.innerHTML = `
@@ -8302,7 +9126,7 @@ function _matchesShaped(shaped, query) {
8302
9126
  const hint = String(shaped.hint ?? "").toLowerCase();
8303
9127
  return id.includes(query) || label.includes(query) || hint.includes(query);
8304
9128
  }
8305
- function _esc7(s) {
9129
+ function _esc8(s) {
8306
9130
  return String(s ?? "").replace(/[&<>"']/g, (c) => ({
8307
9131
  "&": "&amp;",
8308
9132
  "<": "&lt;",
@@ -8440,17 +9264,34 @@ async function createShell({
8440
9264
  tabLayout = null,
8441
9265
  newTabButton = true,
8442
9266
  desktopBar = {},
9267
+ menuBar: menuBarCfg = null,
8443
9268
  chrome = {},
8444
9269
  // An embedder that moved its sections out of the top bar — into an icon
8445
9270
  // rail, say — passes the selector its own buttons match, and F1..F8 keep
8446
9271
  // working. Omitted, the default top-bar selector applies.
8447
- navSelector = null
9272
+ navSelector = null,
9273
+ keymap = null
8448
9274
  } = {}) {
8449
9275
  if (!root || typeof root.appendChild !== "function") {
8450
9276
  throw new TypeError("createShell: `root` must be an element");
8451
9277
  }
8452
9278
  if (!taxonomy) throw new Error("createShell: a taxonomy is required");
8453
9279
  if (!entities) throw new Error("createShell: an entity catalog is required");
9280
+ if (chrome.menuBar && !menuBarCfg?.menus) {
9281
+ throw new TypeError("createShell: `chrome.menuBar` needs `menuBar: { menus }`");
9282
+ }
9283
+ if (menuBarCfg && !chrome.menuBar) {
9284
+ throw new TypeError("createShell: `menuBar` needs `chrome.menuBar`, the element it is painted into");
9285
+ }
9286
+ if (keymap != null) {
9287
+ if (typeof keymap !== "object" || Array.isArray(keymap)) {
9288
+ throw new TypeError("createShell: `keymap` must be an object of { bindings }");
9289
+ }
9290
+ const extra = Object.keys(keymap).filter((k) => k !== "bindings");
9291
+ if (extra.length) {
9292
+ throw new TypeError(`createShell: unknown \`keymap\` key(s) ${extra.map((k) => `"${k}"`).join(", ")} \u2014 the only key is \`bindings\``);
9293
+ }
9294
+ }
8454
9295
  const log = logger ?? { info() {
8455
9296
  }, warn() {
8456
9297
  }, error() {
@@ -8501,21 +9342,28 @@ async function createShell({
8501
9342
  },
8502
9343
  onChange: () => syncChrome()
8503
9344
  });
9345
+ const menuBar = chrome.menuBar ? createMenuBar(chrome.menuBar, menuBarCfg) : null;
9346
+ const ownCommands = paletteCfg.commands;
9347
+ const commands = menuBar && menuBarCfg.palette !== false ? () => [...typeof ownCommands === "function" ? ownCommands() || [] : [], ...menuBar.commands()] : ownCommands;
9348
+ const bindings = keymap?.bindings ?? null;
8504
9349
  const palette = createCommandPalette({
8505
9350
  wm,
8506
9351
  api,
8507
9352
  taxonomy,
8508
9353
  catalog: entities,
8509
- ...paletteCfg
9354
+ // What *Keyboard shortcuts* lists is what the keys do in THIS shell.
9355
+ shortcuts: keymapShortcuts(bindings),
9356
+ ...paletteCfg,
9357
+ commands
8510
9358
  });
8511
- const disposeKeymap = installKeymap({ wm, palette, navSelector });
9359
+ const disposeKeymap = installKeymap({ wm, palette, navSelector, bindings, logger });
8512
9360
  const paletteBtn = mountPaletteButton(chrome.paletteButton, palette);
8513
9361
  topNavEl = mountTopNav(chrome.topNav, taxonomy, wm);
8514
9362
  desktopsEl = mountDesktopBar(chrome.desktops, wm, desktopBar || {});
8515
9363
  const zoom = mountZoomControl(chrome.zoom, { root, host });
8516
9364
  if (zoom) await zoom.ready;
8517
9365
  toggles = bindPanelToggles(chrome.panelToggles, wm);
8518
- eventBus?.on?.("wm:changed", syncChrome);
9366
+ const chromeSubscription = eventBus?.on?.("wm:changed", syncChrome);
8519
9367
  await wm.load();
8520
9368
  syncChrome();
8521
9369
  log.info?.("shell mounted");
@@ -8533,7 +9381,9 @@ async function createShell({
8533
9381
  panelToggles: toggles,
8534
9382
  // `get`/`set` so an embedder can drive the zoom from its own settings
8535
9383
  // pane, or read it, without reaching into the control's DOM.
8536
- zoom
9384
+ zoom,
9385
+ // STORY-0030. `setEnabled` / `setChecked` / `open` / `commands`.
9386
+ menuBar
8537
9387
  }),
8538
9388
  // A shell that can be built can be built TWICE — an embedder that
8539
9389
  // rebuilds on a context change (a different project, a different
@@ -8542,7 +9392,7 @@ async function createShell({
8542
9392
  // with the first one's keyboard.
8543
9393
  dispose: () => {
8544
9394
  try {
8545
- eventBus?.off?.("wm:changed", syncChrome);
9395
+ chromeSubscription?.dispose?.();
8546
9396
  } catch {
8547
9397
  }
8548
9398
  try {
@@ -8566,6 +9416,10 @@ async function createShell({
8566
9416
  desktopsEl?.remove();
8567
9417
  } catch {
8568
9418
  }
9419
+ try {
9420
+ menuBar?.dispose();
9421
+ } catch {
9422
+ }
8569
9423
  }
8570
9424
  });
8571
9425
  }
@@ -8617,6 +9471,7 @@ function mountPaletteButton(hostEl, palette) {
8617
9471
  hostEl.insertBefore(btn, hostEl.firstChild);
8618
9472
  return btn;
8619
9473
  }
9474
+ var OWN_TOOLTIPS = /* @__PURE__ */ new WeakMap();
8620
9475
  function bindPanelToggles(map, wm) {
8621
9476
  const fresh = {};
8622
9477
  for (const side of ["left", "right", "bottom"]) {
@@ -8629,6 +9484,10 @@ function bindPanelToggles(map, wm) {
8629
9484
  el.removeAttribute("aria-pressed");
8630
9485
  btn.replaceWith(el);
8631
9486
  el.addEventListener("click", () => wm.togglePanel(side));
9487
+ OWN_TOOLTIPS.set(el, {
9488
+ tooltip: el.getAttribute("data-tooltip"),
9489
+ title: el.getAttribute("title")
9490
+ });
8632
9491
  fresh[side] = el;
8633
9492
  }
8634
9493
  return fresh;
@@ -8638,6 +9497,21 @@ function syncPanelToggles(map, wm) {
8638
9497
  const btn = map?.[side];
8639
9498
  if (!btn) continue;
8640
9499
  btn.classList.toggle("twm-panel-toggle-btn--on", wm.isPanelOpen(side));
9500
+ const { available, reason } = wm.getPanelAvailability(side);
9501
+ const own = OWN_TOOLTIPS.get(btn) || {};
9502
+ const restore = (attr, value) => value == null ? btn.removeAttribute(attr) : btn.setAttribute(attr, value);
9503
+ if (available) {
9504
+ btn.removeAttribute("aria-disabled");
9505
+ restore("data-tooltip", own.tooltip);
9506
+ restore("title", own.title);
9507
+ continue;
9508
+ }
9509
+ btn.setAttribute("aria-disabled", "true");
9510
+ if (btn.classList.contains("twm-has-tooltip")) {
9511
+ restore("data-tooltip", reason ?? own.tooltip);
9512
+ } else {
9513
+ restore("title", reason ?? own.title);
9514
+ }
8641
9515
  }
8642
9516
  }
8643
9517
  function mountDesktopBar(hostEl, wm, { switchOnAdd = false } = {}) {
@@ -8793,6 +9667,8 @@ function _tileContextMenu(wm, leafId, x, y) {
8793
9667
  const isPanel = String(leaf.content?.kind || "").startsWith("panel:");
8794
9668
  tree.focus(leafId);
8795
9669
  wm.renderer._updateFocusClasses();
9670
+ const single = !!leaf.content && wm.isSingleKind(leaf.content.kind);
9671
+ const singleTitle = single ? wm.taxonomy.singleReason(leaf.content.kind) : void 0;
8796
9672
  const items = [
8797
9673
  { label: "Split horizontally", icon: "splitscreen_vertical_add", action: "split-h" },
8798
9674
  { label: "Split vertically", icon: "splitscreen_add", action: "split-v" },
@@ -8801,7 +9677,8 @@ function _tileContextMenu(wm, leafId, x, y) {
8801
9677
  label: "Open in new tab",
8802
9678
  icon: "tab",
8803
9679
  action: "open-tab",
8804
- disabled: isPanel || !leaf.content
9680
+ disabled: isPanel || !leaf.content || single,
9681
+ title: singleTitle
8805
9682
  },
8806
9683
  // TWO DIFFERENT GLYPHS FOR TWO DIFFERENT DESTINATIONS. `web_asset` is a
8807
9684
  // window INSIDE the application — the same glyph `ManagedWindow` uses
@@ -8814,7 +9691,8 @@ function _tileContextMenu(wm, leafId, x, y) {
8814
9691
  label: "Open a copy in a window",
8815
9692
  icon: "web_asset",
8816
9693
  action: "open-window",
8817
- disabled: isPanel || !leaf.content
9694
+ disabled: isPanel || !leaf.content || single,
9695
+ title: singleTitle
8818
9696
  },
8819
9697
  // C20, THE OTHER HALF — and it was missing while the `close` half
8820
9698
  // below carried a paragraph explaining why it could not be.
@@ -8880,6 +9758,8 @@ function _tileContextMenu(wm, leafId, x, y) {
8880
9758
  export {
8881
9759
  BADGE_LEVELS,
8882
9760
  DesktopManager,
9761
+ KEYBOARD_SHORTCUTS_TOPIC_ID,
9762
+ KEYMAP_SHORTCUTS,
8883
9763
  PLACEHOLDER_KIND,
8884
9764
  PanelKeyRouter,
8885
9765
  TILE_TAB_MIME,
@@ -8908,6 +9788,8 @@ export {
8908
9788
  getPanelKeyRouter,
8909
9789
  installKeymap,
8910
9790
  installPanelKeyRouter,
9791
+ keyboardShortcutsTopic,
9792
+ keymapShortcuts,
8911
9793
  loadDesktops,
8912
9794
  makeLeaf,
8913
9795
  makeLoadingOverlay,