flexdesk 0.17.16 → 0.17.18

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 (222) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/LICENSE +0 -0
  3. package/README.md +0 -0
  4. package/THIRD_PARTY_LICENSES.md +0 -0
  5. package/canvas.js +0 -0
  6. package/charts.js +0 -0
  7. package/core.js +0 -0
  8. package/css/base.css +32 -1
  9. package/css/flexdesk.css +32 -1
  10. package/css/reset.css +0 -0
  11. package/css/tokens.css +3 -0
  12. package/dist/canvas.js +0 -0
  13. package/dist/canvas.js.map +0 -0
  14. package/dist/chunk-3PHCPZHT.js +0 -0
  15. package/dist/chunk-3PHCPZHT.js.map +0 -0
  16. package/dist/chunk-FOOS3T5L.js +0 -0
  17. package/dist/chunk-FOOS3T5L.js.map +0 -0
  18. package/dist/chunk-GOQLR5WR.js +0 -0
  19. package/dist/chunk-GOQLR5WR.js.map +0 -0
  20. package/dist/{chunk-JKZLNKG6.js → chunk-HKPB6OFA.js} +90 -25
  21. package/dist/{chunk-JKZLNKG6.js.map → chunk-HKPB6OFA.js.map} +2 -2
  22. package/dist/chunk-HNU6MM2B.js +0 -0
  23. package/dist/chunk-HNU6MM2B.js.map +0 -0
  24. package/dist/chunk-JYWURG5T.js +0 -0
  25. package/dist/chunk-JYWURG5T.js.map +0 -0
  26. package/dist/chunk-N7HC3ADV.js +0 -0
  27. package/dist/chunk-N7HC3ADV.js.map +0 -0
  28. package/dist/editor.js.map +0 -0
  29. package/dist/flexdesk.css +32 -1
  30. package/dist/flow.js.map +0 -0
  31. package/dist/host.js +0 -0
  32. package/dist/host.js.map +0 -0
  33. package/dist/reset.css +0 -0
  34. package/dist/tile_registry-6WZPTUZV.js +0 -0
  35. package/dist/tile_registry-6WZPTUZV.js.map +0 -0
  36. package/dist/tokens.css +3 -0
  37. package/dist/widgets.js +4115 -4089
  38. package/dist/widgets.js.map +4 -4
  39. package/dist/wm.js +115 -31
  40. package/dist/wm.js.map +2 -2
  41. package/docs/components/command-palette.md +0 -0
  42. package/docs/components/conversation.md +0 -0
  43. package/docs/components/data-table-grid.md +0 -0
  44. package/docs/components/data-table.md +0 -0
  45. package/docs/components/editor.md +0 -0
  46. package/docs/components/flow-canvas-editor.md +0 -0
  47. package/docs/components/flow-editors.md +0 -0
  48. package/docs/components/flow-lane-editor.md +0 -0
  49. package/docs/components/flow-outline-editor.md +0 -0
  50. package/docs/components/input-fields.md +0 -0
  51. package/docs/components/markdown-and-outline.md +0 -0
  52. package/docs/components/menus.md +38 -1
  53. package/docs/components/notifications.md +23 -1
  54. package/docs/components/panels-and-desktops.md +22 -0
  55. package/docs/components/record-list-and-fact-table.md +0 -0
  56. package/docs/components/settings-view.md +0 -0
  57. package/docs/components/shell.md +54 -0
  58. package/docs/components/wizard.md +0 -0
  59. package/docs/concepts/building-and-packaging.md +0 -0
  60. package/docs/concepts/domain-neutrality.md +0 -0
  61. package/docs/concepts/host-port.md +0 -0
  62. package/docs/concepts/packages-and-entry-points.md +0 -0
  63. package/docs/concepts/peer-dependencies.md +0 -0
  64. package/docs/quickstart.md +0 -0
  65. package/editor.js +0 -0
  66. package/flow.js +0 -0
  67. package/host.js +0 -0
  68. package/package.json +1 -1
  69. package/src/canvas/canvas_adapter.js +0 -0
  70. package/src/canvas/edge_router.js +0 -0
  71. package/src/canvas/graph/connector_router.js +0 -0
  72. package/src/canvas/graph/history_service.js +0 -0
  73. package/src/canvas/graph/node_platform.js +0 -0
  74. package/src/canvas/index.js +0 -0
  75. package/src/canvas/layered_layout.js +0 -0
  76. package/src/charts/data_series_plot_window.js +0 -0
  77. package/src/charts/downsample.js +0 -0
  78. package/src/charts/plot_config.js +0 -0
  79. package/src/charts/plotly_wrapper.js +0 -0
  80. package/src/core/event_bus.js +0 -0
  81. package/src/core/logging.js +0 -0
  82. package/src/core/settings.js +0 -0
  83. package/src/core/state_guard.js +0 -0
  84. package/src/core/state_machine.js +0 -0
  85. package/src/editor/editor_pane.js +0 -0
  86. package/src/editor/monaco_editor_factory.js +0 -0
  87. package/src/editor/monaco_loader.js +0 -0
  88. package/src/editor/notebook_search.js +0 -0
  89. package/src/editor/notebook_undo_manager.js +0 -0
  90. package/src/editor/split_pane_container.js +0 -0
  91. package/src/flow/canvas_editor/connections.js +0 -0
  92. package/src/flow/canvas_editor/editor.js +0 -0
  93. package/src/flow/canvas_editor/index.js +0 -0
  94. package/src/flow/canvas_editor/layout.js +0 -0
  95. package/src/flow/canvas_editor/strings.js +0 -0
  96. package/src/flow/kit/always_before.js +0 -0
  97. package/src/flow/kit/catalogue.js +0 -0
  98. package/src/flow/kit/chip_input.js +0 -0
  99. package/src/flow/kit/dom.js +0 -0
  100. package/src/flow/kit/findings.js +0 -0
  101. package/src/flow/kit/history.js +0 -0
  102. package/src/flow/kit/index.js +0 -0
  103. package/src/flow/kit/keys.js +0 -0
  104. package/src/flow/kit/logic_graph.js +0 -0
  105. package/src/flow/kit/references.js +0 -0
  106. package/src/flow/kit/settings_panel.js +0 -0
  107. package/src/flow/kit/settings_schema.js +0 -0
  108. package/src/flow/kit/step_picker.js +0 -0
  109. package/src/flow/kit/strings.js +0 -0
  110. package/src/flow/kit/value_picker.js +0 -0
  111. package/src/flow/kit/widgets.js +0 -0
  112. package/src/flow/lanes/editor.js +0 -0
  113. package/src/flow/lanes/index.js +0 -0
  114. package/src/flow/lanes/layout.js +0 -0
  115. package/src/flow/lanes/pipeline.js +0 -0
  116. package/src/flow/lanes/preview.js +0 -0
  117. package/src/flow/lanes/strings.js +0 -0
  118. package/src/flow/outline/editor.js +0 -0
  119. package/src/flow/outline/index.js +0 -0
  120. package/src/flow/outline/mapping.js +0 -0
  121. package/src/flow/outline/menu.js +0 -0
  122. package/src/flow/outline/operations.js +0 -0
  123. package/src/flow/outline/recognise.js +0 -0
  124. package/src/flow/outline/strings.js +0 -0
  125. package/src/help/help_modal.js +0 -0
  126. package/src/help/help_registry.js +0 -0
  127. package/src/host/external_links.js +0 -0
  128. package/src/host/file_picker.js +0 -0
  129. package/src/host/host.js +0 -0
  130. package/src/host/photino_host.js +0 -0
  131. package/src/host/photino_host.md +0 -0
  132. package/src/host/pywebview_host.js +0 -0
  133. package/src/tiles/chart_tiles.js +0 -0
  134. package/src/tiles/config_schema.js +0 -0
  135. package/src/tiles/figure_tile.js +0 -0
  136. package/src/tiles/layout_persistence.js +0 -0
  137. package/src/tiles/list_tiles.js +0 -0
  138. package/src/tiles/scope_picker.js +0 -0
  139. package/src/tiles/tile_config_form.js +0 -0
  140. package/src/tiles/tile_registry.js +0 -0
  141. package/src/tiling/bottom_panel_tabs.js +0 -0
  142. package/src/tiling/command_palette.js +0 -0
  143. package/src/tiling/content_registry.js +0 -0
  144. package/src/tiling/entity_sources.js +0 -0
  145. package/src/tiling/kind_taxonomy.js +21 -13
  146. package/src/tiling/landing_table.js +0 -0
  147. package/src/tiling/loading_overlay.js +0 -0
  148. package/src/tiling/nav_panel.js +0 -0
  149. package/src/tiling/page_factory.js +0 -0
  150. package/src/tiling/panel_availability.js +0 -0
  151. package/src/tiling/panel_keys.js +0 -0
  152. package/src/tiling/shell.js +4 -1
  153. package/src/tiling/tab_strip.js +0 -0
  154. package/src/tiling/tile_renderer.js +0 -0
  155. package/src/tiling/tile_tab_menu.js +0 -0
  156. package/src/tiling/wm.js +123 -28
  157. package/src/tiling/zoom.js +0 -0
  158. package/src/ui/base/component_base.js +0 -0
  159. package/src/ui/base/controller_base.js +0 -0
  160. package/src/ui/base/page_base.js +0 -0
  161. package/src/ui/components/about_dialog.js +0 -0
  162. package/src/ui/components/alert_log.js +0 -0
  163. package/src/ui/components/alerts_pane.js +0 -0
  164. package/src/ui/components/attribute_list_editor.js +0 -0
  165. package/src/ui/components/autocomplete_field.js +0 -0
  166. package/src/ui/components/cell_editor_host.js +0 -0
  167. package/src/ui/components/cell_editors.js +0 -0
  168. package/src/ui/components/cell_grid.js +0 -0
  169. package/src/ui/components/cell_paste.js +0 -0
  170. package/src/ui/components/cell_selection.js +0 -0
  171. package/src/ui/components/choice_controls.js +0 -0
  172. package/src/ui/components/color_field.js +0 -0
  173. package/src/ui/components/color_list_field.js +0 -0
  174. package/src/ui/components/column_fit.js +0 -0
  175. package/src/ui/components/column_groups.js +0 -0
  176. package/src/ui/components/column_order.js +0 -0
  177. package/src/ui/components/conversation_view.js +0 -0
  178. package/src/ui/components/crosstab.js +0 -0
  179. package/src/ui/components/detail_header.js +0 -0
  180. package/src/ui/components/document_outline.js +0 -0
  181. package/src/ui/components/drag_reorder.js +0 -0
  182. package/src/ui/components/fact_table.js +0 -0
  183. package/src/ui/components/fill_handle.js +0 -0
  184. package/src/ui/components/gallery_picker.js +0 -0
  185. package/src/ui/components/header_actions.js +0 -0
  186. package/src/ui/components/inline_renamer.js +0 -0
  187. package/src/ui/components/job_progress.js +0 -0
  188. package/src/ui/components/menu_bar.js +101 -24
  189. package/src/ui/components/notification_history.js +11 -1
  190. package/src/ui/components/progress.js +0 -0
  191. package/src/ui/components/record_list.js +0 -0
  192. package/src/ui/components/row_activation.js +0 -0
  193. package/src/ui/components/section_stack.js +0 -0
  194. package/src/ui/components/series.js +0 -0
  195. package/src/ui/components/settings_view.js +0 -0
  196. package/src/ui/components/slide_out_panel.js +0 -0
  197. package/src/ui/components/slider_field.js +0 -0
  198. package/src/ui/components/sortable_list.js +0 -0
  199. package/src/ui/components/staged_edits.js +0 -0
  200. package/src/ui/components/staged_history.js +0 -0
  201. package/src/ui/components/table_filter_chips.js +0 -0
  202. package/src/ui/components/table_state_store.js +0 -0
  203. package/src/ui/components/table_structure.js +0 -0
  204. package/src/ui/components/table_values.js +0 -0
  205. package/src/ui/components/tabs.js +0 -0
  206. package/src/ui/components/tag_input.js +0 -0
  207. package/src/ui/components/toast.js +0 -0
  208. package/src/ui/components/toggle_field.js +0 -0
  209. package/src/ui/components/tsv.js +0 -0
  210. package/src/ui/components/window_taskbar.js +0 -0
  211. package/src/ui/controllers/window_chrome_controller.js +4 -1
  212. package/src/ui/notification_center.js +47 -1
  213. package/src/ui/utils/auto_scrollbars.js +0 -0
  214. package/src/ui/utils/markdown.js +0 -0
  215. package/src/ui/utils/native_context_menu.js +0 -0
  216. package/src/ui/utils/overlay_scrollbar.js +0 -0
  217. package/src/ui/utils/raf_resize_observer.js +0 -0
  218. package/src/ui/utils/scroll_lock.js +0 -0
  219. package/src/ui/utils/strip_scroll.js +0 -0
  220. package/src/ui/utils/tooltip_service.js +0 -0
  221. package/tiles.js +0 -0
  222. package/wm.js +0 -0
package/src/tiling/wm.js CHANGED
@@ -344,6 +344,29 @@ export class WindowManager {
344
344
  //
345
345
  // MOVE verbs (float, dock, drag a tab, move to desktop) carry the one
346
346
  // instance somewhere else and are untouched.
347
+ //
348
+ // `single: 'desktop'` keeps the same promises per virtual desktop: a
349
+ // desktop is the tiles and panels the user arranged on it, so opening the
350
+ // kind looks only at the ACTIVE desktop — its tiles, their archived pages
351
+ // and the windows on it — and opens it there when that desktop has none.
352
+ // A window is on the desktop it was opened on or moved to (`desktopIdx`):
353
+ // a free-floating one is drawn over every desktop, but it is that one
354
+ // desktop's instance, so it never stands in for another desktop's. The
355
+ // move verbs refuse to carry the kind onto a desktop that already holds
356
+ // it (`desktopMoveRefusal`). Navigating inside a window looks on the
357
+ // window's own desktop (`openInWindow`). Removing a desktop brings its
358
+ // windows across, a window never being destroyed by a tile operation, so
359
+ // the active desktop can then hold the kind twice until the next reload
360
+ // keeps the first.
361
+
362
+ /** The key a tab's instance is unique under on desktop `desktopIdx`: its
363
+ * instance key, scoped to that desktop for a `single: 'desktop'` kind;
364
+ * null for a tab that is not single. */
365
+ _scopedKey(t, desktopIdx) {
366
+ const k = t ? this._instanceKey(t.kind, t.props) : null;
367
+ if (k == null || !this.taxonomy.isSinglePerDesktop(t.kind)) return k;
368
+ return `${desktopIdx}\u0000${k}`;
369
+ }
347
370
 
348
371
  /** The instance key of a tab spec, or null when its kind is not single. */
349
372
  _instanceKey(kind, props) {
@@ -356,27 +379,38 @@ export class WindowManager {
356
379
  }
357
380
 
358
381
  /** Where the one instance of a single kind is open, or null — also null
359
- * for a kind that is not single. One of:
382
+ * for a kind that is not single. A `single: 'desktop'` kind is looked for
383
+ * on the active desktop only. One of:
360
384
  * `{ where: 'window', windowId, idx, desktopIdx }`
361
385
  * `{ where: 'tab', desktopIdx, leafId, idx }`
362
386
  * `{ where: 'archive', desktopIdx, leafId, page, idx }` — a tab of a
363
387
  * top-nav page the tile has switched away from (`pageTabs`). */
364
388
  findInstance(kind, props = {}) {
389
+ const on = this.taxonomy.isSinglePerDesktop(kind) ? this.desktops.activeIdx : null;
390
+ return this._findInstance(kind, props, on);
391
+ }
392
+
393
+ /** `findInstance`, looking only at desktop `on` when it is a number, and
394
+ * past the window `exceptWindowId`. */
395
+ _findInstance(kind, props, on, exceptWindowId = null) {
365
396
  const key = this._instanceKey(kind, props);
366
397
  if (key == null) return null;
367
398
  const same = (t) => !!t && this._instanceKey(t.kind, t.props) === key;
399
+ const desktops = [...this.desktops.desktops.entries()]
400
+ .filter(([desktopIdx]) => on == null || desktopIdx === on);
368
401
  for (const [windowId, rec] of this._windowToLeaf) {
402
+ if (windowId === exceptWindowId) continue;
403
+ if (on != null && rec.desktopIdx !== on) continue;
369
404
  const idx = _windowTabSpecs(rec).findIndex(same);
370
405
  if (idx >= 0) return { where: 'window', windowId, idx, desktopIdx: rec.desktopIdx };
371
406
  }
372
- const desktops = this.desktops.desktops;
373
- for (const [desktopIdx, d] of desktops.entries()) {
407
+ for (const [desktopIdx, d] of desktops) {
374
408
  for (const leaf of d.tree.leaves()) {
375
409
  const idx = (leaf.tabs || []).findIndex(same);
376
410
  if (idx >= 0) return { where: 'tab', desktopIdx, leafId: leaf.id, idx };
377
411
  }
378
412
  }
379
- for (const [desktopIdx, d] of desktops.entries()) {
413
+ for (const [desktopIdx, d] of desktops) {
380
414
  for (const leaf of d.tree.leaves()) {
381
415
  for (const [page, arc] of this._archivedPages(leaf)) {
382
416
  const idx = arc.tabs.findIndex(same);
@@ -387,6 +421,31 @@ export class WindowManager {
387
421
  return null;
388
422
  }
389
423
 
424
+ /** Why moving a pane's or a window's tabs onto desktop `idx` is refused,
425
+ * or null: that desktop already holds one of their `single: 'desktop'`
426
+ * kinds (in a tile, an archived page or a window), and a desktop holds
427
+ * such a kind once. `source` is `{ leafId }` (a tile of the active
428
+ * desktop) or `{ windowId }`. The move verbs ask it, and their menu rows
429
+ * are greyed with its sentence. */
430
+ desktopMoveRefusal(source, idx) {
431
+ let specs = [];
432
+ if (source?.windowId) {
433
+ const rec = this._windowToLeaf.get(source.windowId);
434
+ specs = rec ? _windowTabSpecs(rec) : [];
435
+ } else {
436
+ const leaf = this._tree().get(source?.leafId);
437
+ specs = leaf?.tabs?.length ? leaf.tabs : (leaf?.content ? [leaf.content] : []);
438
+ }
439
+ const d = this.desktops.desktops[idx];
440
+ if (!d) return null;
441
+ for (const t of specs) {
442
+ if (!t || !this.taxonomy.isSinglePerDesktop(t.kind)) continue;
443
+ if (!this._findInstance(t.kind, t.props, idx, source.windowId ?? null)) continue;
444
+ return `${this.taxonomy.kinds[t.kind]?.label || t.kind} is open on desktop ${d.label} already`;
445
+ }
446
+ return null;
447
+ }
448
+
390
449
  /** Reveal the instance of a single kind that is open: answers whether
391
450
  * there was one. Every open path asks this first.
392
451
  *
@@ -400,7 +459,12 @@ export class WindowManager {
400
459
  * open names no props at all — a top-nav button, `openInPrimary(kind)` —
401
460
  * which asks to SEE the instance, not to empty it. */
402
461
  revealInstance(kind, props = {}) {
403
- const loc = this.findInstance(kind, props);
462
+ return this._revealFound(this.findInstance(kind, props), kind, props);
463
+ }
464
+
465
+ /** Reveal the instance at `loc` (a `findInstance` result, or null) and
466
+ * hand it `props`. Answers whether there was one to reveal. */
467
+ _revealFound(loc, kind, props) {
404
468
  if (!loc || !this._revealAt(loc)) return false;
405
469
  if (props && Object.keys(props).length) this._deliverProps(kind, props);
406
470
  return true;
@@ -516,7 +580,8 @@ export class WindowManager {
516
580
  * soon as the tile leaves the page by a path other than `swapToPage`
517
581
  * (`navigateActiveTab`). So a restored tab whose instance is live
518
582
  * anywhere else is dropped from the tile and from the restored archive;
519
- * the instance stays where it is. */
583
+ * the instance stays where it is. For a `single: 'desktop'` kind,
584
+ * anywhere else on the tile's desktop. */
520
585
  _swapToPage(tree, leafId, target, currentTopNav, targetTopNav, opts) {
521
586
  const done = tree.swapToPage(leafId, target, currentTopNav, targetTopNav, opts);
522
587
  if (done) this._dropRestoredCopies(tree, leafId, targetTopNav, target);
@@ -527,11 +592,12 @@ export class WindowManager {
527
592
  const leaf = tree.get(leafId);
528
593
  const tabs = Array.isArray(leaf?.tabs) ? leaf.tabs : null;
529
594
  if (!tabs) return;
595
+ const desktopIdx = this.desktops.desktops.findIndex((d) => d.tree === tree);
530
596
  const elsewhere = this._liveInstanceKeys(tree, leafId);
531
597
  const keeper = () => {
532
598
  const seen = new Set();
533
599
  return (t) => {
534
- const k = t ? this._instanceKey(t.kind, t.props) : null;
600
+ const k = this._scopedKey(t, desktopIdx);
535
601
  if (k == null) return true;
536
602
  if (elsewhere.has(k) || seen.has(k)) return false;
537
603
  seen.add(k);
@@ -569,16 +635,23 @@ export class WindowManager {
569
635
  tree.setActiveLeafTab(leafId, Math.max(0, Math.min(tabs.length - 1, active)));
570
636
  }
571
637
 
572
- /** The instance keys of every LIVE tab — windows, and every leaf of every
573
- * desktop but `leafId` of `tree`. Archived pages are not live. */
638
+ /** The scoped keys (`_scopedKey`, for `tree`'s desktop) of every LIVE
639
+ * tab — windows, and every leaf of every desktop but `leafId` of `tree`;
640
+ * a `single: 'desktop'` tab counts only when it is on `tree`'s desktop.
641
+ * Archived pages are not live. */
574
642
  _liveInstanceKeys(tree, leafId) {
643
+ const here = this.desktops.desktops.findIndex((d) => d.tree === tree);
575
644
  const keys = new Set();
576
- const add = (t) => {
577
- const k = t ? this._instanceKey(t.kind, t.props) : null;
645
+ const adder = (onHere) => (t) => {
646
+ if (!onHere && t && this.taxonomy.isSinglePerDesktop(t.kind)) return;
647
+ const k = this._scopedKey(t, here);
578
648
  if (k != null) keys.add(k);
579
649
  };
580
- for (const [, rec] of this._windowToLeaf) _windowTabSpecs(rec).forEach(add);
650
+ for (const [, rec] of this._windowToLeaf) {
651
+ _windowTabSpecs(rec).forEach(adder(rec.desktopIdx === here));
652
+ }
581
653
  for (const d of this.desktops.desktops) {
654
+ const add = adder(d.tree === tree);
582
655
  for (const leaf of d.tree.leaves()) {
583
656
  if (d.tree === tree && leaf.id === leafId) continue;
584
657
  (leaf.tabs || []).forEach(add);
@@ -618,14 +691,15 @@ export class WindowManager {
618
691
  return dropped;
619
692
  }
620
693
 
621
- /** A restored layout keeps the FIRST instance of each single kind: later
622
- * live tabs are dropped (the tile shows the tab that took the dropped
623
- * one's place, or closes when it held nothing else), and so are later
624
- * archived ones. Answers whether anything was dropped. */
694
+ /** A restored layout keeps the FIRST instance of each single kind (of a
695
+ * `single: 'desktop'` kind, on each desktop): later live tabs are
696
+ * dropped (the tile shows the tab that took the dropped one's place, or
697
+ * closes when it held nothing else), and so are later archived ones.
698
+ * Answers whether anything was dropped. */
625
699
  _dropDuplicateSingles() {
626
700
  const seen = new Set();
627
- return this._pruneRestoredTabs((t) => {
628
- const k = t ? this._instanceKey(t.kind, t.props) : null;
701
+ return this._pruneRestoredTabs((t, desktopIdx) => {
702
+ const k = this._scopedKey(t, desktopIdx);
629
703
  if (k == null) return true;
630
704
  if (seen.has(k)) return false;
631
705
  seen.add(k);
@@ -633,19 +707,20 @@ export class WindowManager {
633
707
  });
634
708
  }
635
709
 
636
- /** Drop every tab of the restored layout `keep(tab)` refuses, in the
637
- * tiles and in their archived pages; a tile left empty closes (seeding a
638
- * content tile if none is left). Returns whether anything went. */
710
+ /** Drop every tab of the restored layout `keep(tab, desktopIdx)` refuses,
711
+ * in the tiles and in their archived pages; a tile left empty closes
712
+ * (seeding a content tile if none is left). Returns whether anything
713
+ * went. */
639
714
  _pruneRestoredTabs(keep) {
640
715
  let dropped = false;
641
- for (const d of this.desktops.desktops) {
716
+ for (const [desktopIdx, d] of this.desktops.desktops.entries()) {
642
717
  const tree = d.tree;
643
718
  for (const leaf of tree.leaves()) {
644
719
  const tabs = Array.isArray(leaf.tabs) ? leaf.tabs : [];
645
720
  let active = leaf.activeTabIdx || 0;
646
721
  let changed = false;
647
722
  for (let i = 0; i < tabs.length;) {
648
- if (keep(tabs[i])) { i++; continue; }
723
+ if (keep(tabs[i], desktopIdx)) { i++; continue; }
649
724
  tabs.splice(i, 1);
650
725
  if (i < active) active -= 1;
651
726
  changed = true;
@@ -664,10 +739,10 @@ export class WindowManager {
664
739
  }
665
740
  }
666
741
  }
667
- for (const d of this.desktops.desktops) {
742
+ for (const [desktopIdx, d] of this.desktops.desktops.entries()) {
668
743
  for (const leaf of d.tree.leaves()) {
669
744
  for (const [page, arc] of this._archivedPages(leaf)) {
670
- const kept = arc.tabs.filter(keep);
745
+ const kept = arc.tabs.filter((t) => keep(t, desktopIdx));
671
746
  if (kept.length === arc.tabs.length) continue;
672
747
  dropped = true;
673
748
  if (!kept.length) { delete leaf.pageTabs[page]; continue; }
@@ -1096,9 +1171,15 @@ export class WindowManager {
1096
1171
  * and updates the window's title. */
1097
1172
  openInWindow(winId, kind, props = {}) {
1098
1173
  if (this._openWindowOnly(kind, props)) return;
1099
- if (this.revealInstance(kind, props)) return;
1100
1174
  const rec = this._windowToLeaf.get(winId);
1101
1175
  if (!rec) { this.openInPrimary(kind, props); return; }
1176
+ // A window is on its own desktop, even a free-floating one drawn over
1177
+ // every desktop: a `single: 'desktop'` kind navigated to in it is
1178
+ // looked for on the window's desktop, not the one on screen.
1179
+ const loc = this.taxonomy.isSinglePerDesktop(kind)
1180
+ ? this._findInstance(kind, props, rec.desktopIdx)
1181
+ : this.findInstance(kind, props);
1182
+ if (this._revealFound(loc, kind, props)) return;
1102
1183
  try { rec.mountInfo?.destroy?.(); } catch {}
1103
1184
  // R8. THE BODY, NOT THE CONTENT ELEMENT. A floated pane's window holds
1104
1185
  // a tab strip above its body, and both are children of `contentEl` —
@@ -3149,6 +3230,11 @@ export class WindowManager {
3149
3230
  // The window's home follows the drop. Without this the dock is applied
3150
3231
  // to the tree of the desktop the window was PROMOTED from, and the tile
3151
3232
  // the user aimed at is on the one they are looking at.
3233
+ // A free-floating window is drawn over every desktop, so a drop can
3234
+ // carry it to another one: refused, as the move verbs are, when that
3235
+ // desktop holds its `single: 'desktop'` kind already.
3236
+ if (typeof desktopIdx === 'number' && desktopIdx !== record.desktopIdx
3237
+ && this.desktopMoveRefusal({ windowId: winId }, desktopIdx)) return false;
3152
3238
  if (typeof desktopIdx === 'number') record.desktopIdx = desktopIdx;
3153
3239
 
3154
3240
  // THE DRAG ALREADY ANSWERED, so there is nothing to ask — the probe
@@ -3236,6 +3322,8 @@ export class WindowManager {
3236
3322
  if (rec.desktopIdx === targetIdx) return;
3237
3323
  // C40. A fixed desktop is the embedder's, and nothing is moved onto it.
3238
3324
  if (this.desktops.desktops[targetIdx]?.fixed) return;
3325
+ // A desktop holds a `single: 'desktop'` kind once.
3326
+ if (this.desktopMoveRefusal({ windowId }, targetIdx)) return;
3239
3327
  this.desktops.ensureCount(targetIdx + 1);
3240
3328
  rec.desktopIdx = targetIdx;
3241
3329
  if (!rec.homeContainer && rec.homeLeafId) {
@@ -3411,9 +3499,11 @@ export class WindowManager {
3411
3499
  if (targets.length) {
3412
3500
  items.push({ separator: true });
3413
3501
  for (const [i, d] of targets) {
3502
+ const refusal = this.desktopMoveRefusal({ windowId: winId }, i);
3414
3503
  items.push({
3415
3504
  label: `Move to desktop ${d.label}`, icon: 'sweep',
3416
- action: `move:${i}`,
3505
+ action: `move:${i}`, disabled: !!refusal,
3506
+ title: refusal || undefined,
3417
3507
  });
3418
3508
  }
3419
3509
  }
@@ -3988,7 +4078,9 @@ export class WindowManager {
3988
4078
  * ruling is that a tile operation may move a window and never destroy it,
3989
4079
  * and removing a desktop is the largest tile operation there is: they come
3990
4080
  * across to the desktop that ends up active, re-homed onto its ground by
3991
- * `_rehomeContainedWindows` on the render below.
4081
+ * `_rehomeContainedWindows` on the render below. One holding a
4082
+ * `single: 'desktop'` kind the active desktop already has makes it hold
4083
+ * the kind twice, until a reload keeps the first.
3992
4084
  */
3993
4085
  removeDesktop(idx) {
3994
4086
  const m = this.desktops;
@@ -4066,6 +4158,9 @@ export class WindowManager {
4066
4158
  // Already there. Without this, `tree.close` below removes the tile from
4067
4159
  // the same tree its tabs were just appended to, and it is gone.
4068
4160
  if (idx === this.desktops.activeIdx) return;
4161
+ // A desktop holds a `single: 'desktop'` kind once: a pane carrying one
4162
+ // the target already has stays where it is.
4163
+ if (this.desktopMoveRefusal({ leafId: focused.id }, idx)) return;
4069
4164
  this.desktops.ensureCount(idx + 1);
4070
4165
  const target = this.desktops.desktops[idx];
4071
4166
  // C40. THE PANE'S TABS JOIN THE TARGET'S PRIMARY LEAF, ALL OF THEM. It
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
@@ -62,6 +62,15 @@
62
62
  * shortcut, context, action }`), so the palette and the bar list identical
63
63
  * verbs.
64
64
  *
65
+ * ── Two styles: a bar, or one hamburger button (STORY-0133) ───────────
66
+ * `style: 'bar'` (the default) draws a title per menu: File Edit Help.
67
+ * `style: 'hamburger'` draws ONE icon button (`icon`, 'menu' by default,
68
+ * named by `label`) whose menu lists the menus, each flying out its items:
69
+ * ☰ › File › Open…. It is the same model, so `commands()`, `setEnabled`,
70
+ * `setChecked` and `onAction` do not change; `open(menuId)` opens the
71
+ * hamburger with that menu flown out. `setStyle(style)` switches at run
72
+ * time (a setting, a narrow window), closing whatever is open.
73
+ *
65
74
  * ── Roles ─────────────────────────────────────────────────────────────
66
75
  * The WAI-ARIA menubar pattern: `role="menubar"` on the element you pass,
67
76
  * a `role="menuitem"` title per menu inside a `role="none"` wrapper, each
@@ -77,6 +86,9 @@
77
86
  const SEL_TITLE = '.twm-menubar__title';
78
87
  const SEL_ITEM = '.twm-menubar__item';
79
88
  const SEL_MENU = '.twm-menubar__menu';
89
+ const STYLES = ['bar', 'hamburger'];
90
+ /** The hamburger's own model ids. A menu's row in it is `${HAMBURGER}:${menu.id}`. */
91
+ const HAMBURGER = 'twm-menubar:hamburger';
80
92
 
81
93
  /** @returns {MenuBar} */
82
94
  export function createMenuBar(el, opts) {
@@ -91,16 +103,22 @@ export class MenuBar {
91
103
  * @param {Function} [opts.onAction] `(id, { item, checked }) => void`
92
104
  * @param {string} [opts.label] the bar's accessible name ('Menu')
93
105
  * @param {string} [opts.emptyLabel] the disabled row an empty submenu shows ('Empty')
106
+ * @param {string} [opts.style] 'bar' (a title per menu, the default) or 'hamburger' (one button)
107
+ * @param {string} [opts.icon] the hamburger's Material Symbols icon ('menu')
94
108
  */
95
- constructor(el, { menus, onAction = null, label = 'Menu', emptyLabel = 'Empty' } = {}) {
109
+ constructor(el, { menus, onAction = null, label = 'Menu', emptyLabel = 'Empty', style = 'bar', icon = 'menu' } = {}) {
96
110
  if (!el || typeof el.appendChild !== 'function') {
97
111
  throw new TypeError('createMenuBar: `el` must be an element');
98
112
  }
99
113
  _validateMenus(menus);
114
+ _validateStyle(style);
100
115
  this.el = el;
101
116
  this.menus = menus;
102
117
  this._onAction = typeof onAction === 'function' ? onAction : null;
103
118
  this._emptyLabel = emptyLabel;
119
+ this._label = label;
120
+ this._icon = icon;
121
+ this._style = style;
104
122
  this._doc = el.ownerDocument;
105
123
  this._disabled = new Map(); // id -> boolean (override)
106
124
  this._checked = new Map(); // id -> boolean (override, check items)
@@ -112,26 +130,10 @@ export class MenuBar {
112
130
  this._returnTo = null; // what had focus before the bar did
113
131
  this._altArmed = false;
114
132
 
115
- el.innerHTML = '';
116
133
  el.classList.add('twm-menubar');
117
134
  el.setAttribute('role', 'menubar');
118
135
  el.setAttribute('aria-label', label);
119
- this._titles = menus.map((m, i) => {
120
- const entry = this._doc.createElement('div');
121
- entry.className = 'twm-menubar__entry';
122
- entry.setAttribute('role', 'none');
123
- const btn = this._doc.createElement('button');
124
- btn.type = 'button';
125
- btn.className = 'twm-menubar__title';
126
- btn.setAttribute('role', 'menuitem');
127
- btn.setAttribute('aria-haspopup', 'menu');
128
- btn.setAttribute('aria-expanded', 'false');
129
- btn.tabIndex = i === 0 ? 0 : -1;
130
- btn.textContent = m.label;
131
- entry.appendChild(btn);
132
- el.appendChild(entry);
133
- return btn;
134
- });
136
+ this._renderTitles();
135
137
 
136
138
  this._h = {
137
139
  mousedown: (e) => this._onMouseDown(e),
@@ -163,12 +165,35 @@ export class MenuBar {
163
165
  /** Whether a menu is open. */
164
166
  isOpen() { return this._openIdx >= 0; }
165
167
 
166
- /** Open the menu with this id (focus on its title, or on its first item). */
168
+ /** Open the menu with this id (focus on its title, or on its first item).
169
+ * A hamburger opens with that menu flown out (focus on its row, or on
170
+ * the menu's first item). */
167
171
  open(menuId, { focusItem = false } = {}) {
168
172
  const i = this.menus.findIndex((m) => m.id === menuId);
169
173
  if (i < 0) return;
170
174
  this._enter();
171
- this._open(i, focusItem ? 'first' : null);
175
+ if (this._style !== 'hamburger') { this._open(i, focusItem ? 'first' : null); return; }
176
+ this._open(0, null);
177
+ const row = this._itemsIn(this._stack[0].menu)
178
+ .find((b) => this._models.get(b)?.id === `${HAMBURGER}:${menuId}`);
179
+ if (!row) return;
180
+ row.focus({ preventScroll: true });
181
+ this._openSub(row, focusItem ? 'first' : null);
182
+ }
183
+
184
+ /** 'bar' or 'hamburger'. */
185
+ get style() { return this._style; }
186
+
187
+ /** Draw the menus as a bar or as one hamburger button. Whatever is open
188
+ * closes; the model and every setEnabled / setChecked stay. */
189
+ setStyle(style) {
190
+ _validateStyle(style);
191
+ if (style === this._style) return;
192
+ const hadFocus = this.el.contains(this._doc.activeElement);
193
+ this._deactivate();
194
+ this._style = style;
195
+ this._renderTitles();
196
+ if (hadFocus) this._focusTitle(0);
172
197
  }
173
198
 
174
199
  /** Close every open menu. Focus stays in the bar if it was there. */
@@ -239,7 +264,7 @@ export class MenuBar {
239
264
  this._doc.removeEventListener('mousedown', this._h.docMousedown, true);
240
265
  this._doc.defaultView?.removeEventListener('blur', this._h.winBlur);
241
266
  this.el.innerHTML = '';
242
- this.el.classList.remove('twm-menubar');
267
+ this.el.classList.remove('twm-menubar', 'twm-menubar--hamburger');
243
268
  this.el.removeAttribute('role');
244
269
  this.el.removeAttribute('aria-label');
245
270
  }
@@ -317,6 +342,50 @@ export class MenuBar {
317
342
  }
318
343
  }
319
344
 
345
+ // ── titles ────────────────────────────────────────────────────────
346
+
347
+ /** The titles for the current style. `_top` is what they open: the
348
+ * menus themselves, or the hamburger's one menu that lists them. */
349
+ _renderTitles() {
350
+ const d = this._doc;
351
+ const burger = this._style === 'hamburger';
352
+ this.el.innerHTML = '';
353
+ this.el.classList.toggle('twm-menubar--hamburger', burger);
354
+ this._top = burger
355
+ ? [{ id: HAMBURGER, label: this._label, items: this.menus.map((m) => ({
356
+ id: `${HAMBURGER}:${m.id}`, label: m.label, submenu: m.items,
357
+ })) }]
358
+ : this.menus;
359
+ this._titles = this._top.map((m, i) => {
360
+ const entry = d.createElement('div');
361
+ entry.className = 'twm-menubar__entry';
362
+ entry.setAttribute('role', 'none');
363
+ const btn = d.createElement('button');
364
+ btn.type = 'button';
365
+ btn.className = 'twm-menubar__title';
366
+ btn.setAttribute('role', 'menuitem');
367
+ btn.setAttribute('aria-haspopup', 'menu');
368
+ btn.setAttribute('aria-expanded', 'false');
369
+ btn.tabIndex = i === 0 ? 0 : -1;
370
+ if (burger) {
371
+ btn.classList.add('twm-menubar__title--icon', 'twm-has-tooltip');
372
+ btn.setAttribute('aria-label', m.label);
373
+ btn.dataset.tooltip = m.label;
374
+ btn.dataset.tooltipPlacement = 'bottom';
375
+ const icon = d.createElement('span');
376
+ icon.className = 'material-symbols-outlined';
377
+ icon.setAttribute('aria-hidden', 'true');
378
+ icon.textContent = this._icon;
379
+ btn.appendChild(icon);
380
+ } else {
381
+ btn.textContent = m.label;
382
+ }
383
+ entry.appendChild(btn);
384
+ this.el.appendChild(entry);
385
+ return btn;
386
+ });
387
+ }
388
+
320
389
  // ── open / close ──────────────────────────────────────────────────
321
390
 
322
391
  _setRoving(i) {
@@ -359,7 +428,7 @@ export class MenuBar {
359
428
  // being removed never holds focus.
360
429
  this._focusTitle(i);
361
430
  this._closeAll();
362
- const m = this.menus[i];
431
+ const m = this._top[i];
363
432
  const title = this._titles[i];
364
433
  const menu = this._buildMenu(m.items, m.label);
365
434
  title.parentElement.appendChild(menu);
@@ -596,6 +665,8 @@ export class MenuBar {
596
665
  _titleKey(key, i) {
597
666
  const n = this._titles.length;
598
667
  const open = this._openIdx >= 0;
668
+ // One title (a hamburger): there is no next or previous menu.
669
+ if (n === 1 && (key === 'ArrowRight' || key === 'ArrowLeft' || key === 'Home' || key === 'End')) return true;
599
670
  switch (key) {
600
671
  case 'ArrowRight': open ? this._open(i + 1, null) : this._focusTitle((i + 1) % n); return true;
601
672
  case 'ArrowLeft': open ? this._open(i - 1, null) : this._focusTitle((i - 1 + n) % n); return true;
@@ -642,11 +713,11 @@ export class MenuBar {
642
713
  case 'End': this._focusIn(menu, 'last'); return true;
643
714
  case 'ArrowRight':
644
715
  if (item?.submenu != null && !this._isDisabled(item)) this._openSub(btn, 'first');
645
- else this._open(this._openIdx + 1, 'first');
716
+ else if (this._titles.length > 1) this._open(this._openIdx + 1, 'first');
646
717
  return true;
647
718
  case 'ArrowLeft':
648
719
  if (level > 0) this._closeFrom(level);
649
- else this._open(this._openIdx - 1, 'first');
720
+ else if (this._titles.length > 1) this._open(this._openIdx - 1, 'first');
650
721
  return true;
651
722
  case 'Enter': case ' ':
652
723
  this._activate(btn);
@@ -706,6 +777,12 @@ function _kind(item) {
706
777
  return 'menuitem';
707
778
  }
708
779
 
780
+ function _validateStyle(style) {
781
+ if (!STYLES.includes(style)) {
782
+ throw new TypeError(`createMenuBar: \`style\` must be ${STYLES.map((s) => `'${s}'`).join(' or ')}`);
783
+ }
784
+ }
785
+
709
786
  /** A bad model is a boot error, not a menu that silently lacks a row. */
710
787
  function _validateMenus(menus) {
711
788
  if (!Array.isArray(menus) || !menus.length) {
@@ -21,7 +21,11 @@
21
21
  * alertsTab the tab a click asks for, `bottompanel:show { tab }`; 'alerts'.
22
22
  * onOpen a click, instead of the bus event — for an embedder with no bus,
23
23
  * or whose panel must be opened before a tab can be chosen.
24
+ *
25
+ * A right-click on an entry opens the context menu with "Copy message"
26
+ * (STORY-0131), the same as on its toast; the popover stays open meanwhile.
24
27
  */
28
+ import { showNotificationMenu } from '../notification_center.js';
25
29
 
26
30
  export class NotificationHistory {
27
31
  constructor({ notificationCenter, eventBus, logger, mountPoint = null, alertLog = null,
@@ -209,10 +213,15 @@ export class NotificationHistory {
209
213
  };
210
214
  this._popoverCleanup = cleanup;
211
215
 
216
+ // The entries' context menu is outside the panel, and using it is not leaving it.
212
217
  const onDocDown = (ev) => {
218
+ if (ev.target.closest?.('.twm-context-menu')) return;
213
219
  if (!panel.contains(ev.target) && !this.triggerEl.contains(ev.target)) cleanup();
214
220
  };
215
- const onKey = (ev) => { if (ev.key === 'Escape') cleanup(); };
221
+ // Escape closes the innermost layer: an open entry menu closes itself.
222
+ const onKey = (ev) => {
223
+ if (ev.key === 'Escape' && !document.querySelector('.twm-context-menu')) cleanup();
224
+ };
216
225
  const onResize = () => cleanup();
217
226
 
218
227
  document.addEventListener('mousedown', onDocDown, true);
@@ -261,6 +270,7 @@ export class NotificationHistory {
261
270
  body.className = 'twm-notification-history__item-body';
262
271
  body.textContent = entry.message || '';
263
272
  item.appendChild(body);
273
+ item.addEventListener('contextmenu', (event) => showNotificationMenu(event, entry, this.logger));
264
274
 
265
275
  fragment.appendChild(item);
266
276
  }
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes