flexdesk 0.17.16 → 0.17.17

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.
@@ -120,7 +120,8 @@ background, a left border in the severity's colour (`--color-success`,
120
120
  `--color-warning`, `--color-danger`; info keeps the blue) and the entry
121
121
  animation `twm-notification-in`, which the sheet now defines. The card sets
122
122
  `pointer-events: auto` over the root's `none`, so its buttons and its
123
- click-to-close work.
123
+ click-to-close work. Since [Copy message](#copy-message), click-to-close takes
124
+ only a click on the card outside its text.
124
125
 
125
126
  Until 0.17.2 the card carried the bare `notification` / `notification--<severity>`
126
127
  classes, which no FlexDesk sheet styled: the card had no background and no
@@ -156,6 +157,27 @@ Until 0.17.3 the bell counted only on bus events, so a toast raised through
156
157
  was pushed as a second entry: a toast that timed out was in `history` twice
157
158
  and counted twice. `tests/notification_history_follows_center.test.mjs` holds it.
158
159
 
160
+ ### Copy message
161
+
162
+ Right-clicking a toast, or its entry in the `NotificationHistory` popover, opens
163
+ the context menu (`showContextMenu`) with **Copy message**. It copies the
164
+ notification's title, message and description, one per line and skipping any
165
+ that is empty, as plain text through `navigator.clipboard.writeText`. The menu
166
+ replaces the browser's native one there. The history popover stays open while
167
+ its menu is used. Every context menu draws on `--z-context-menu` (tokens.css),
168
+ over toasts (`--z-toast`) and managed windows, so the menu opens over the toast
169
+ that was right-clicked.
170
+
171
+ The text of a toast and of a history entry (title, message, description,
172
+ status) is selectable: the sheets give it `user-select: text` over the body's
173
+ `none`. A click or double-click on a toast's text (title, message,
174
+ description, status) selects it and does not close the toast, nor does a click
175
+ that ends a selection inside it. Only a click on the card outside the text (the
176
+ header's empty space, the padding, the timestamp) closes it, besides the ×
177
+ button. `notificationText(notification)` returns the copied
178
+ text, and `showNotificationMenu(event, notification, logger)` opens the menu.
179
+ Both are exported from `widgets`. `tests/notification_copy.test.mjs` covers it.
180
+
159
181
  ### Alerts with history: a log, a pane, a tab host and a bell (0.14.0)
160
182
 
161
183
  A toast is the moment. The **alert log** keeps what came in until it is
@@ -10,6 +10,7 @@ The shell's left, right and bottom panels, and its virtual desktops. A kind says
10
10
  - [Panels keep the size you dragged them to (0.17.0)](#panels-keep-the-size-you-dragged-them-to-0170)
11
11
  - [A navigation panel you drive yourself: `mountNavPanel` (0.17.0)](#a-navigation-panel-you-drive-yourself-mountnavpanel-0170)
12
12
  - [Your desktops beside the app's (0.10.0)](#your-desktops-beside-the-apps-0100)
13
+ - [A page that opens on the desktop you are on (0.17.17)](#a-page-that-opens-on-the-desktop-you-are-on-01717)
13
14
  - [A saved layout that cannot be read (0.17.14)](#a-saved-layout-that-cannot-be-read-01714)
14
15
 
15
16
  ## Panels a page does not offer (0.17.0)
@@ -127,6 +128,27 @@ main pane; it used to replace that pane with the active tab alone. A target stil
127
128
  holding only its seed is replaced, as before. `dispose()` removes the bar, so a
128
129
  rebuilt shell paints a fresh one.
129
130
 
131
+ ## A page that opens on the desktop you are on (0.17.17)
132
+
133
+ A desktop is the tiles and panels arranged on it. A kind marked `single: true`
134
+ exists once in the app, so opening it from another desktop switches to the
135
+ desktop that has it. A kind marked `single: 'desktop'` exists once per desktop
136
+ instead: the top-nav button, `navigate` and `openInPrimary` reveal the
137
+ instance on the active desktop, or open one there when that desktop has none,
138
+ and never switch desktop.
139
+
140
+ ```js
141
+ kinds: {
142
+ model: { label: 'Model', isTopNav: true, single: 'desktop' },
143
+ }
144
+ ```
145
+
146
+ On desktop 2, clicking *Model* opens the model on desktop 2 even while
147
+ desktop 1 shows its own; clicking it again reveals desktop 2's. Each desktop's
148
+ instance is saved with its layout and restored there. The details, and what
149
+ counts as a window on a desktop, are in
150
+ [Shell and navigation](shell.md#a-kind-that-exists-once-per-desktop-single-desktop-01717).
151
+
130
152
  ## A saved layout that cannot be read (0.17.14)
131
153
 
132
154
  The shell saves its layout through the host's `state` under the key `desktops`
@@ -10,6 +10,7 @@ A complete first boot is in the [quickstart](../quickstart.md#hello-shell). This
10
10
 
11
11
  - [Opt-in shell features](#opt-in-shell-features)
12
12
  - [A kind that exists once: `single` (0.17.0)](#a-kind-that-exists-once-single-0170)
13
+ - [A kind that exists once per desktop: `single: 'desktop'` (0.17.17)](#a-kind-that-exists-once-per-desktop-single-desktop-01717)
13
14
  - [An open single instance takes new props: `navigate(props)` (0.17.2)](#an-open-single-instance-takes-new-props-navigateprops-0172)
14
15
  - [A link to a kind nobody registered (0.17.2)](#a-link-to-a-kind-nobody-registered-0172)
15
16
  - [A pane that names what it shows: `ctx.setTitle` (0.12.1)](#a-pane-that-names-what-it-shows-ctxsettitle-0121)
@@ -74,6 +75,59 @@ with it. The taxonomy answers `isSingle(kind)`, `instanceKey(kind, props)` and
74
75
  `singleReason(kind)`; the window manager answers `isSingleKind(kind)`,
75
76
  `findInstance(kind, props)` and `revealInstance(kind, props)`.
76
77
 
78
+ ## A kind that exists once per desktop: `single: 'desktop'` (0.17.17)
79
+
80
+ A virtual desktop is the tiles and panels the user arranged on it, so a page
81
+ the user opens from the top nav belongs on the desktop they are looking at,
82
+ not on another desktop that already shows it. Mark such a kind
83
+ `single: 'desktop'`: at most one instance per desktop.
84
+
85
+ ```js
86
+ createTaxonomy({
87
+ root: 'cosmos',
88
+ kinds: {
89
+ cosmos: { label: 'Cosmos', isTopNav: true },
90
+ chart: { label: 'Star chart', isTopNav: true, single: 'desktop' }, // one per desktop
91
+ },
92
+ });
93
+ ```
94
+
95
+ - **Every open path looks only at the active desktop** — `navigate`, the top
96
+ nav, `openInPrimary`, `openInTab*`, `splitLeafWith`, Back. When the desktop
97
+ has the instance (in a tile, in a top-nav page a tile switched away from, or
98
+ in a window on it) it is revealed there, as for `single: true`; when it has
99
+ none, one is opened there. The open never switches desktop, and the
100
+ instances on other desktops are left as they are.
101
+ - **A window is on the desktop it was opened on** or moved to. A window
102
+ contained to a pane (`promoteInPlace`) is shown on that desktop only; a
103
+ free-floating one (`navigate(kind, props, { dest: 'window' })`) is drawn over
104
+ every desktop but is still the instance of the one it was opened on, never
105
+ of another: on desktop 1, *Model* reveals desktop 1's own model even while
106
+ desktop 2's floats in front of it.
107
+ - **The copy verbs refuse** within the desktop, as for `single: true`.
108
+ - **The move verbs refuse** to carry the kind onto a desktop that already holds
109
+ it: *Move to desktop N* (Alt+Shift+N and the tile and window menus, whose row
110
+ is greyed with the reason), and dropping a floating window onto another
111
+ desktop's tile. `wm.desktopMoveRefusal({ leafId } | { windowId }, idx)`
112
+ answers that reason, or null. Removing a desktop brings its windows across
113
+ to the active desktop and never closes one, so that desktop can then hold
114
+ the kind twice, until a reload keeps the first.
115
+ - **Navigating inside a window looks on the window's own desktop**
116
+ (`openInWindow`, and the in-window navigation that goes through it): a
117
+ floating window of desktop 1 navigated to the kind while desktop 2 is on
118
+ screen reveals desktop 1's instance, and shows the kind itself when desktop
119
+ 1 has none. The window stays on its desktop.
120
+ - **A page restore and a restored layout dedupe per desktop**: a tile's archived
121
+ page brings its instance back unless that desktop has it live elsewhere, and
122
+ a reload keeps the first instance on each desktop. Each desktop's instance
123
+ survives, live or archived.
124
+
125
+ `single: true` keeps its meaning: one instance in the app, and opening it
126
+ switches to the desktop it is on. Because `'desktop'` names this mode, it is
127
+ not read as a prop name. `findInstance(kind)` answers the active desktop's
128
+ instance; the taxonomy answers `isSinglePerDesktop(kind)`, and
129
+ `instanceKey(kind, props)` is the same for every props.
130
+
77
131
  ## An open single instance takes new props: `navigate(props)` (0.17.2)
78
132
 
79
133
  Revealing a `single` instance used to drop the props the open asked for: the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flexdesk",
3
- "version": "0.17.16",
3
+ "version": "0.17.17",
4
4
  "description": "A tiling window manager and widget set for desktop-class web applications. Zero runtime dependencies.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -53,15 +53,17 @@
53
53
  * not inserted and their toggles are disabled, showing
54
54
  * `reason`.
55
55
  *
56
- * single? `true`, or the name of a prop. The kind exists ONCE: content
57
- * that holds heavy app-wide state and must not be mounted
58
- * twice. With `true` there is one instance of the kind; with a
59
- * prop name there is one per value of that prop (`'id'`: one
60
- * tile per record). The window manager makes every open path
61
- * reveal the instance that exists, refuses the copy verbs with
62
- * a reason, and keeps the first of any duplicates a restored
63
- * layout holds. The root kind cannot be single: every new
64
- * pane, tab and desktop is seeded with it.
56
+ * single? `true`, `'desktop'`, or the name of a prop. The kind exists
57
+ * ONCE: content that holds heavy app-wide state and must not be
58
+ * mounted twice. With `true` there is one instance of the kind;
59
+ * with `'desktop'` there is one per virtual desktop, found and
60
+ * opened on the active one (so `'desktop'` is not a prop name
61
+ * here); with a prop name there is one per value of that prop
62
+ * (`'id'`: one tile per record). The window manager makes every
63
+ * open path reveal the instance that exists, refuses the copy
64
+ * verbs with a reason, and keeps the first of any duplicates a
65
+ * restored layout holds. The root kind cannot be single: every
66
+ * new pane, tab and desktop is seeded with it.
65
67
  *
66
68
  * window? `{ width?, height? }` — the initial size, in px, of a managed
67
69
  * window this kind opens in (`navigate(…, { dest: 'window' })`,
@@ -249,14 +251,19 @@ export function createTaxonomy({ kinds, root } = {}) {
249
251
  return s === true || (typeof s === 'string' && s !== '');
250
252
  };
251
253
 
254
+ /** Whether `kind` exists once per virtual desktop (`single: 'desktop'`)
255
+ * rather than once in the app. */
256
+ const isSinglePerDesktop = (kind) => _kinds[kind]?.single === 'desktop';
257
+
252
258
  /** The identity of one instance of a single kind: two tab specs with the
253
- * same key are the same instance. `null` for a kind that is not single,
254
- * which is how callers ask both questions at once. Keyed by the kind and,
255
- * for `single: '<prop>'`, that prop's value as a string. */
259
+ * same key are the same instance (on one desktop, for
260
+ * `single: 'desktop'`). `null` for a kind that is not single, which is
261
+ * how callers ask both questions at once. Keyed by the kind and, for
262
+ * `single: '<prop>'`, that prop's value as a string. */
256
263
  const instanceKey = (kind, props) => {
257
264
  if (!isSingle(kind)) return null;
258
265
  const by = _kinds[kind].single;
259
- if (by === true) return String(kind);
266
+ if (by === true || by === 'desktop') return String(kind);
260
267
  const v = props?.[by];
261
268
  return `${kind}\u0000${v == null ? '' : String(v)}`;
262
269
  };
@@ -287,6 +294,7 @@ export function createTaxonomy({ kinds, root } = {}) {
287
294
  panelOffer,
288
295
  topNavEntries,
289
296
  isSingle,
297
+ isSinglePerDesktop,
290
298
  instanceKey,
291
299
  singleReason,
292
300
  isWindowOnly,
@@ -712,8 +712,11 @@ function _tileContextMenu(wm, leafId, x, y) {
712
712
  // `isPanel` above) never a panel: `moveFocusedToDesktop` refuses all
713
713
  // three too, because the keyboard reaches it without this menu.
714
714
  if (i === wm.desktops.activeIdx || d.fixed) continue;
715
+ // A desktop holds a `single: 'desktop'` kind once: greyed, saying so.
716
+ const refusal = wm.desktopMoveRefusal({ leafId }, i);
715
717
  items.push({ label: `Move to desktop ${d.label}`, icon: 'sweep',
716
- action: `move:${i}` });
718
+ action: `move:${i}`, disabled: !!refusal,
719
+ title: refusal || undefined });
717
720
  }
718
721
  }
719
722
  items.push({ separator: true });
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
@@ -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
  }
@@ -35,11 +35,20 @@
35
35
  * `log:entry`). A toast that leaves marks its own entry `dismissed` with a
36
36
  * `reason`; it adds none. `subscribe(fn)` calls `fn(entry, center)` for each
37
37
  * entry added, so a history view follows the centre rather than the bus.
38
+ *
39
+ * Copy message (STORY-0131)
40
+ * -------------------------
41
+ * A right-click on a toast, or on its entry in the history, opens the context
42
+ * menu with "Copy message": the title, the message and the description, one
43
+ * per line, go to the clipboard as plain text. The text is also selectable:
44
+ * a click on the title, message, description or status, or one that ends a
45
+ * selection, does not close the toast; only a click on its chrome does.
38
46
  */
39
47
  import { ComponentBase } from './base/component_base.js';
40
48
  import { getSetting } from '../core/settings.js';
41
49
  import { setToastBus, getToastBus } from './components/toast.js';
42
50
  import { ProgressBar } from './components/progress.js';
51
+ import { showContextMenu } from './components/context_menu.js';
43
52
 
44
53
  const SEVERITIES = ['info', 'success', 'warn', 'error', 'fatal'];
45
54
 
@@ -60,6 +69,39 @@ const DEFAULT_LOG_LEVELS = new Set(['warn', 'error', 'fatal']);
60
69
  // Messages from these namespaces are self-descriptive and need no category header.
61
70
  const SUPPRESSED_NAMESPACE_TITLES = new Set(['bootstrap', 'app']);
62
71
 
72
+ /** A notification as plain text: its title, message and description, one per line. */
73
+ export function notificationText(notification) {
74
+ return [notification.title, notification.message, notification.description]
75
+ .filter((part) => part != null && String(part) !== '')
76
+ .join('\n');
77
+ }
78
+
79
+ /**
80
+ * The right-click menu of a toast or a history entry: "Copy message". It takes
81
+ * the right-click, so the browser's own menu does not open.
82
+ */
83
+ export function showNotificationMenu(event, notification, logger) {
84
+ event.preventDefault();
85
+ event.stopPropagation();
86
+ showContextMenu(event.clientX, event.clientY,
87
+ [{ label: 'Copy message', icon: 'content_copy', action: 'copy' }],
88
+ () => {
89
+ Promise.resolve()
90
+ .then(() => navigator.clipboard.writeText(notificationText(notification)))
91
+ .catch((err) => logger?.warn?.('twm-notification-center', 'Copy message failed', { err }));
92
+ });
93
+ }
94
+
95
+ /** The toast's text: clicking it selects, it does not dismiss. */
96
+ const TOAST_TEXT = '.twm-notification__title, .twm-notification__body, '
97
+ + '.twm-notification__description, .twm-notification__status';
98
+
99
+ /** A non-empty text selection inside `el`: a click that ends one is not a click on it. */
100
+ function selectsTextIn(el) {
101
+ const sel = el.ownerDocument.getSelection?.();
102
+ return Boolean(sel && !sel.isCollapsed && sel.rangeCount && el.contains(sel.getRangeAt(0).commonAncestorContainer));
103
+ }
104
+
63
105
  export class NotificationCenter extends ComponentBase {
64
106
  #items;
65
107
  #summaryEl;
@@ -752,12 +794,16 @@ export class NotificationCenter extends ComponentBase {
752
794
  item.appendChild(actionsEl);
753
795
  }
754
796
 
797
+ // A click on the text is the start of selecting it (a double-click's
798
+ // first click leaves only a caret), so only the toast's chrome closes it.
755
799
  item.addEventListener('click', (event) => {
756
- if (event.target.closest('button') || NotificationCenter.#isRunning(notification)) {
800
+ if (event.target.closest(`button, ${TOAST_TEXT}`) || NotificationCenter.#isRunning(notification)
801
+ || selectsTextIn(item)) {
757
802
  return;
758
803
  }
759
804
  this.dismiss(notification.id, 'click');
760
805
  });
806
+ item.addEventListener('contextmenu', (event) => showNotificationMenu(event, notification, this.logger));
761
807
 
762
808
  this.#patchItem(entry, notification);
763
809
  return entry;