flexdesk 0.9.1 → 0.10.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.
package/README.md CHANGED
@@ -130,6 +130,35 @@ await createShell({
130
130
  });
131
131
  ```
132
132
 
133
+ ### Your desktops beside the app's (0.10.0)
134
+
135
+ Pass `chrome.desktops` and the shell paints a desktop bar: a chip per desktop
136
+ and a `+`. An app that keeps desktops of its own (one per page, say) marks them
137
+ on the desktop objects themselves:
138
+
139
+ ```js
140
+ const shell = await createShell({ /* … */ eventBus,
141
+ chrome: { desktops: barEl }, desktopBar: { switchOnAdd: true } });
142
+ shell.wm.desktops.ensureCount(pages.length);
143
+ shell.wm.desktops.desktops.slice(0, pages.length)
144
+ .forEach((d) => { d.group = 'Workbench'; d.fixed = true; });
145
+ eventBus.emit('wm:changed', {}); // repaint the bar
146
+ ```
147
+
148
+ Desktops that share a **`group`** are one chip, labelled by the group, and one
149
+ stop for Ctrl+Left/Right; the chip goes back to the member that was on screen
150
+ last (`wm.desktops.chips()` says what the bar draws). A **`fixed`** desktop is
151
+ the app's: the bar offers no rename and no delete, no menu offers it as a
152
+ *Move to desktop* target, and `removeDesktop`, `moveFocusedToDesktop` and
153
+ `moveWindowToDesktop` refuse it. Both are persisted. A desktop the user adds is
154
+ numbered among the ungrouped ones, and `desktopBar.switchOnAdd` puts it on
155
+ screen. With neither set anywhere, the bar is what it always was.
156
+
157
+ *Move to desktop* now carries every tab of the pane and adds them to the target's
158
+ main pane; it used to replace that pane with the active tab alone. A target still
159
+ holding only its seed is replaced, as before. `dispose()` removes the bar, so a
160
+ rebuilt shell paints a fresh one.
161
+
133
162
  ### The palette that finds anything (0.9.0; matching tightened in 0.9.1)
134
163
 
135
164
  Ctrl+K's grouped layout. Every option is off unless you pass it, so a shell that
package/dist/wm.js CHANGED
@@ -1659,7 +1659,10 @@ function _makeDesktop(label, seed, panelDefaults = DEFAULT_PANEL_STATE) {
1659
1659
  // Boot default: whatever the embedder asked for, left + right + bottom
1660
1660
  // when it asked for nothing. Names are assigned by wm._canonicalize via
1661
1661
  // PANEL_TITLES.
1662
- panels: { ...panelDefaults }
1662
+ panels: { ...panelDefaults },
1663
+ // C40. Both unset is a desktop exactly as it always was.
1664
+ group: null,
1665
+ fixed: false
1663
1666
  };
1664
1667
  }
1665
1668
  var DesktopManager = class _DesktopManager {
@@ -1678,6 +1681,7 @@ var DesktopManager = class _DesktopManager {
1678
1681
  this.panelDefaults = { ...DEFAULT_PANEL_STATE, ...panelDefaults || {} };
1679
1682
  this.desktops = [_makeDesktop("1", seed, this.panelDefaults)];
1680
1683
  this.activeIdx = 0;
1684
+ this.lastInGroup = /* @__PURE__ */ new Map();
1681
1685
  }
1682
1686
  active() {
1683
1687
  return this.desktops[this.activeIdx];
@@ -1686,8 +1690,76 @@ var DesktopManager = class _DesktopManager {
1686
1690
  if (idx < 0 || idx >= this.desktops.length) return false;
1687
1691
  if (idx === this.activeIdx) return false;
1688
1692
  this.activeIdx = idx;
1693
+ const d = this.desktops[idx];
1694
+ if (d.group) this.lastInGroup.set(d.group, d.id);
1689
1695
  return true;
1690
1696
  }
1697
+ /**
1698
+ * ══ C40. WHAT THE DESKTOP BAR DRAWS — ONE CHIP PER GROUP ═══════════════
1699
+ *
1700
+ * An embedder may give desktops a `group` and mark them `fixed`. Desktops
1701
+ * that share a group are ONE stop for the user — one chip in the bar, one
1702
+ * step of Ctrl+Left/Right — and the chip goes back to whichever member was
1703
+ * last on screen; which member that is, is the embedder's business (Tables
1704
+ * keeps one desktop per page under a "Workbench" group, and the rail picks
1705
+ * among them). A `fixed` desktop is the embedder's own: the bar offers no
1706
+ * rename and no delete for it, and no menu offers it as a "Move to"
1707
+ * destination.
1708
+ *
1709
+ * With no group set anywhere this is one chip per desktop, in order — the
1710
+ * bar every existing embedder has.
1711
+ *
1712
+ * @returns {Array<{ idx: number, label: string, group: string|null,
1713
+ * fixed: boolean, active: boolean, members: number[] }>} `idx` is the
1714
+ * desktop the chip switches to.
1715
+ */
1716
+ chips() {
1717
+ const out = [];
1718
+ const byGroup = /* @__PURE__ */ new Map();
1719
+ this.desktops.forEach((d, i) => {
1720
+ if (!d.group) {
1721
+ out.push({
1722
+ idx: i,
1723
+ label: d.label,
1724
+ group: null,
1725
+ fixed: !!d.fixed,
1726
+ active: i === this.activeIdx,
1727
+ members: [i]
1728
+ });
1729
+ return;
1730
+ }
1731
+ let chip = byGroup.get(d.group);
1732
+ if (!chip) {
1733
+ chip = {
1734
+ idx: i,
1735
+ label: d.group,
1736
+ group: d.group,
1737
+ fixed: true,
1738
+ active: false,
1739
+ members: []
1740
+ };
1741
+ byGroup.set(d.group, chip);
1742
+ out.push(chip);
1743
+ }
1744
+ chip.members.push(i);
1745
+ if (i === this.activeIdx) chip.active = true;
1746
+ });
1747
+ for (const chip of byGroup.values()) {
1748
+ if (chip.active) {
1749
+ chip.idx = this.activeIdx;
1750
+ continue;
1751
+ }
1752
+ const last = this.lastInGroup.get(chip.group);
1753
+ const at = last ? this.desktops.findIndex((d) => d.id === last) : -1;
1754
+ if (at >= 0 && this.desktops[at].group === chip.group) chip.idx = at;
1755
+ }
1756
+ return out;
1757
+ }
1758
+ /** C40. Whether any desktop belongs to a group — the bar's cue to stop
1759
+ * promising "Alt+N" on its chips, which then no longer count desktops. */
1760
+ hasGroups() {
1761
+ return this.desktops.some((d) => !!d.group);
1762
+ }
1691
1763
  ensureCount(n) {
1692
1764
  while (this.desktops.length < n) {
1693
1765
  this.desktops.push(_makeDesktop(
@@ -1698,11 +1770,8 @@ var DesktopManager = class _DesktopManager {
1698
1770
  }
1699
1771
  }
1700
1772
  addDesktop(label = null) {
1701
- const d = _makeDesktop(
1702
- label || String(this.desktops.length + 1),
1703
- this.seed,
1704
- this.panelDefaults
1705
- );
1773
+ const n = this.desktops.filter((d2) => !d2.group).length + 1;
1774
+ const d = _makeDesktop(label || String(n), this.seed, this.panelDefaults);
1706
1775
  this.desktops.push(d);
1707
1776
  return d;
1708
1777
  }
@@ -1713,7 +1782,11 @@ var DesktopManager = class _DesktopManager {
1713
1782
  id: d.id,
1714
1783
  label: d.label,
1715
1784
  tree: d.tree.serialize(),
1716
- panels: { ...d.panels }
1785
+ panels: { ...d.panels },
1786
+ // C40. Written only when set, so a blob without them is
1787
+ // byte-for-byte the blob it always was.
1788
+ ...d.group ? { group: d.group } : {},
1789
+ ...d.fixed ? { fixed: true } : {}
1717
1790
  // windows serialized at WM level since we don't own
1718
1791
  // managed-window state in this module.
1719
1792
  }))
@@ -1731,12 +1804,16 @@ var DesktopManager = class _DesktopManager {
1731
1804
  // closed that panel, and re-opening it on every reload is the bug
1732
1805
  // this merge order avoids. The default only fills a key the stored
1733
1806
  // blob predates.
1734
- panels: { ...m.panelDefaults, ...raw.panels || {} }
1807
+ panels: { ...m.panelDefaults, ...raw.panels || {} },
1808
+ group: raw.group || null,
1809
+ fixed: !!raw.fixed
1735
1810
  }));
1736
1811
  for (const d of m.desktops) {
1737
1812
  if (!d.tree.rootId) d.tree.setRoot(makeLeaf(seed()));
1738
1813
  }
1739
1814
  m.activeIdx = Math.min(Math.max(0, blob.activeIdx | 0), m.desktops.length - 1);
1815
+ const active = m.desktops[m.activeIdx];
1816
+ if (active.group) m.lastInGroup.set(active.group, active.id);
1740
1817
  return m;
1741
1818
  }
1742
1819
  };
@@ -6686,6 +6763,7 @@ var WindowManager = class _WindowManager {
6686
6763
  const rec = this._windowToLeaf.get(windowId);
6687
6764
  if (!rec) return;
6688
6765
  if (rec.desktopIdx === targetIdx) return;
6766
+ if (this.desktops.desktops[targetIdx]?.fixed) return;
6689
6767
  this.desktops.ensureCount(targetIdx + 1);
6690
6768
  rec.desktopIdx = targetIdx;
6691
6769
  if (!rec.homeContainer && rec.homeLeafId) {
@@ -6816,10 +6894,10 @@ var WindowManager = class _WindowManager {
6816
6894
  const items = [
6817
6895
  { label: "Back to tile", icon: "close_fullscreen", action: "back" }
6818
6896
  ];
6819
- if (this.desktops.desktops.length > 1) {
6897
+ const targets = [...this.desktops.desktops.entries()].filter(([i, d]) => i !== rec.desktopIdx && !d.fixed);
6898
+ if (targets.length) {
6820
6899
  items.push({ separator: true });
6821
- for (const [i, d] of this.desktops.desktops.entries()) {
6822
- if (i === rec.desktopIdx) continue;
6900
+ for (const [i, d] of targets) {
6823
6901
  items.push({
6824
6902
  label: `Move to desktop ${d.label}`,
6825
6903
  icon: "sweep",
@@ -7113,20 +7191,34 @@ var WindowManager = class _WindowManager {
7113
7191
  this._persist();
7114
7192
  this._notifyChange();
7115
7193
  }
7116
- addDesktop() {
7194
+ /** Add a desktop. C40: `{ switchTo: true }` also puts it on screen, which
7195
+ * is what the bar's `+` does under `createShell({ desktopBar: {
7196
+ * switchOnAdd: true } })`. Answers the new desktop's index. */
7197
+ addDesktop({ switchTo = false } = {}) {
7117
7198
  this.desktops.addDesktop();
7199
+ const idx = this.desktops.desktops.length - 1;
7200
+ if (switchTo) {
7201
+ this.switchDesktop(idx);
7202
+ return idx;
7203
+ }
7118
7204
  this._notifyChange();
7119
7205
  this._persist();
7206
+ return idx;
7120
7207
  }
7121
7208
  /** Step to the previous (-1) or next (+1) existing desktop, wrapping
7122
7209
  * around. No-op with a single desktop. Iterates only desktops that
7123
- * already exist — use switchDesktop(idx) to create-on-demand. */
7210
+ * already exist — use switchDesktop(idx) to create-on-demand.
7211
+ *
7212
+ * C40. It steps between the bar's CHIPS, so a group is one stop and
7213
+ * lands on its member last on screen. Ungrouped, a chip is a desktop
7214
+ * and this is the step it always was. */
7124
7215
  cycleDesktop(dir) {
7125
- const m = this.desktops;
7126
- const n = m.desktops.length;
7216
+ const chips = this.desktops.chips();
7217
+ const n = chips.length;
7127
7218
  if (n <= 1) return;
7128
- const next = (m.activeIdx + (dir < 0 ? -1 : 1) + n) % n;
7129
- this.switchDesktop(next);
7219
+ const at = chips.findIndex((c) => c.active);
7220
+ const next = chips[(at + (dir < 0 ? -1 : 1) + n) % n];
7221
+ this.switchDesktop(next.idx);
7130
7222
  }
7131
7223
  /** Cycle the focused tile's main-panel tabs (Ctrl+Tab / Ctrl+Shift+Tab).
7132
7224
  * Wraps; no-op when the focused leaf has 0 or 1 tabs. */
@@ -7163,6 +7255,7 @@ var WindowManager = class _WindowManager {
7163
7255
  const m = this.desktops;
7164
7256
  if (m.desktops.length <= 1) return false;
7165
7257
  if (idx < 0 || idx >= m.desktops.length) return false;
7258
+ if (m.desktops[idx].fixed) return false;
7166
7259
  m.desktops.splice(idx, 1);
7167
7260
  if (idx < m.activeIdx) m.activeIdx -= 1;
7168
7261
  if (m.activeIdx >= m.desktops.length) m.activeIdx = m.desktops.length - 1;
@@ -7188,12 +7281,31 @@ var WindowManager = class _WindowManager {
7188
7281
  if (!focused || !focused.content) return;
7189
7282
  const closeChrome = this.renderer?.leafChrome?.(focused.id)?.close;
7190
7283
  if (closeChrome === false || closeChrome?.disabled === true) return;
7191
- const payload = { kind: focused.content.kind, props: focused.content.props, title: focused.title };
7284
+ if (this.desktops.desktops[idx]?.fixed) return;
7192
7285
  this.desktops.ensureCount(idx + 1);
7193
7286
  const target = this.desktops.desktops[idx];
7287
+ const tabs = Array.isArray(focused.tabs) && focused.tabs.length ? focused.tabs : [{ kind: focused.content.kind, props: focused.content.props, title: focused.title }];
7288
+ const activeTab = Math.max(0, Math.min(tabs.length - 1, focused.activeTabIdx || 0));
7194
7289
  const targetTree = target.tree;
7195
7290
  const primary = targetTree.primaryLeafId();
7196
- if (primary) targetTree.setLeafContent(primary, { kind: payload.kind, props: payload.props }, payload.title);
7291
+ if (primary) {
7292
+ const dest = targetTree.get(primary);
7293
+ const seedKind = this._rootLeaf()?.content?.kind;
7294
+ const blank = (dest.tabs || []).length <= 1 && dest.content?.kind === seedKind;
7295
+ let first = -1;
7296
+ tabs.forEach((tab, i) => {
7297
+ const content = { kind: tab.kind, props: tab.props || {} };
7298
+ const title = tab.title || tab.kind || "";
7299
+ if (i === 0 && blank) {
7300
+ targetTree.setLeafContent(primary, content, title);
7301
+ first = 0;
7302
+ return;
7303
+ }
7304
+ const at = targetTree.appendLeafTab(primary, content, title);
7305
+ if (first < 0) first = at;
7306
+ });
7307
+ if (first >= 0) targetTree.setActiveLeafTab(primary, first + activeTab);
7308
+ }
7197
7309
  tree.close(focused.id);
7198
7310
  if (!tree.rootId) tree.setRoot(makeLeaf(this._rootLeaf()));
7199
7311
  if (!tree.leaves().some((l) => !String(l.content?.kind || "").startsWith("panel:"))) {
@@ -8163,6 +8275,7 @@ async function createShell({
8163
8275
  floatActiveTab = false,
8164
8276
  tabLayout = null,
8165
8277
  newTabButton = true,
8278
+ desktopBar = {},
8166
8279
  chrome = {},
8167
8280
  // An embedder that moved its sections out of the top bar — into an icon
8168
8281
  // rail, say — passes the selector its own buttons match, and F1..F8 keep
@@ -8234,7 +8347,7 @@ async function createShell({
8234
8347
  const disposeKeymap = installKeymap({ wm, palette, navSelector });
8235
8348
  const paletteBtn = mountPaletteButton(chrome.paletteButton, palette);
8236
8349
  topNavEl = mountTopNav(chrome.topNav, taxonomy, wm);
8237
- desktopsEl = mountDesktopBar(chrome.desktops, wm);
8350
+ desktopsEl = mountDesktopBar(chrome.desktops, wm, desktopBar || {});
8238
8351
  const zoom = mountZoomControl(chrome.zoom, { root, host });
8239
8352
  if (zoom) await zoom.ready;
8240
8353
  toggles = bindPanelToggles(chrome.panelToggles, wm);
@@ -8285,6 +8398,10 @@ async function createShell({
8285
8398
  zoom?.dispose();
8286
8399
  } catch {
8287
8400
  }
8401
+ try {
8402
+ desktopsEl?.remove();
8403
+ } catch {
8404
+ }
8288
8405
  }
8289
8406
  });
8290
8407
  }
@@ -8359,7 +8476,7 @@ function syncPanelToggles(map, wm) {
8359
8476
  btn.classList.toggle("twm-panel-toggle-btn--on", wm.isPanelOpen(side));
8360
8477
  }
8361
8478
  }
8362
- function mountDesktopBar(hostEl, wm) {
8479
+ function mountDesktopBar(hostEl, wm, { switchOnAdd = false } = {}) {
8363
8480
  if (!hostEl) return null;
8364
8481
  let el = hostEl.querySelector("#twm-desktops");
8365
8482
  if (!el) {
@@ -8373,15 +8490,16 @@ function mountDesktopBar(hostEl, wm) {
8373
8490
  if (!btn) return;
8374
8491
  const v = btn.dataset.desktop;
8375
8492
  if (v === "+") {
8376
- wm.addDesktop();
8493
+ wm.addDesktop({ switchTo: switchOnAdd });
8377
8494
  syncDesktopBar(el, wm);
8378
8495
  return;
8379
8496
  }
8380
8497
  wm.switchDesktop(Number(v));
8381
8498
  });
8499
+ const isOwn = (btn) => btn.dataset.desktopFixed !== "1";
8382
8500
  el.addEventListener("dblclick", async (e) => {
8383
8501
  const btn = e.target.closest("[data-desktop]");
8384
- if (!btn || btn.dataset.desktop === "+") return;
8502
+ if (!btn || btn.dataset.desktop === "+" || !isOwn(btn)) return;
8385
8503
  e.preventDefault();
8386
8504
  const idx = Number(btn.dataset.desktop);
8387
8505
  const d = wm.desktops.desktops[idx];
@@ -8410,25 +8528,28 @@ function mountDesktopBar(hostEl, wm) {
8410
8528
  const idx = Number(btn.dataset.desktop);
8411
8529
  const m = wm.desktops;
8412
8530
  const d = m.desktops[idx];
8413
- const canDelete = m.desktops.length > 1;
8414
- showContextMenu(e.clientX, e.clientY, [
8531
+ const own = isOwn(btn);
8532
+ const canDelete = own && m.desktops.length > 1;
8533
+ const name = btn.dataset.desktopLabel || d.label;
8534
+ const items = [
8415
8535
  {
8416
- label: `Switch to ${d.label}`,
8536
+ label: `Switch to ${name}`,
8417
8537
  icon: "desktop_windows",
8418
8538
  action: "switch",
8419
8539
  disabled: idx === m.activeIdx
8420
8540
  },
8421
- { label: "Rename\u2026", icon: "edit", action: "rename" },
8541
+ ...own ? [{ label: "Rename\u2026", icon: "edit", action: "rename" }] : [],
8422
8542
  { separator: true },
8423
8543
  { label: "New desktop", icon: "add", action: "add" },
8424
- {
8544
+ ...own ? [{
8425
8545
  label: "Delete desktop",
8426
8546
  icon: "delete",
8427
8547
  action: "delete",
8428
8548
  danger: true,
8429
8549
  disabled: !canDelete
8430
- }
8431
- ], async (action) => {
8550
+ }] : []
8551
+ ];
8552
+ showContextMenu(e.clientX, e.clientY, items, async (action) => {
8432
8553
  if (action === "switch") wm.switchDesktop(idx);
8433
8554
  else if (action === "rename") {
8434
8555
  const result = await openForm({
@@ -8442,7 +8563,7 @@ function mountDesktopBar(hostEl, wm) {
8442
8563
  syncDesktopBar(el, wm);
8443
8564
  wm._persist();
8444
8565
  } else if (action === "add") {
8445
- wm.addDesktop();
8566
+ wm.addDesktop({ switchTo: switchOnAdd });
8446
8567
  syncDesktopBar(el, wm);
8447
8568
  } else if (action === "delete") {
8448
8569
  wm.removeDesktop(idx);
@@ -8455,13 +8576,31 @@ function mountDesktopBar(hostEl, wm) {
8455
8576
  function syncDesktopBar(el, wm) {
8456
8577
  if (!el) return;
8457
8578
  const m = wm.desktops;
8458
- el.innerHTML = m.desktops.map((d, i) => `
8579
+ if (!m.hasGroups()) {
8580
+ el.innerHTML = m.desktops.map((d, i) => `
8459
8581
  <button class="twm-desk__btn${i === m.activeIdx ? " twm-desk__btn--on" : ""}"
8460
8582
  data-desktop="${i}"
8461
8583
  title="Desktop ${d.label} (Alt+${i + 1}) \xB7 right-click to delete">
8462
8584
  ${d.label}
8463
8585
  </button>`).join("") + `<button class="twm-desk__btn twm-desk__btn--add"
8464
8586
  data-desktop="+" title="Add desktop">+</button>`;
8587
+ return;
8588
+ }
8589
+ el.innerHTML = m.chips().map((c) => {
8590
+ const label = _escapeHtml(c.label);
8591
+ const title = c.fixed ? label : `${label} \xB7 double-click to rename \xB7 right-click for more`;
8592
+ return `
8593
+ <button class="twm-desk__btn${c.active ? " twm-desk__btn--on" : ""}"
8594
+ data-desktop="${c.idx}"
8595
+ data-desktop-label="${label}"${c.fixed ? ' data-desktop-fixed="1"' : ""}
8596
+ title="${title}">
8597
+ ${label}
8598
+ </button>`;
8599
+ }).join("") + `<button class="twm-desk__btn twm-desk__btn--add"
8600
+ data-desktop="+" title="New desktop">+</button>`;
8601
+ }
8602
+ function _escapeHtml(s) {
8603
+ return String(s ?? "").replace(/[&<>"']/g, (ch) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[ch]);
8465
8604
  }
8466
8605
  function _tileTabMenu(wm, leafId, x, y) {
8467
8606
  const tree = wm.desktops.active().tree;
@@ -8539,7 +8678,7 @@ function _tileContextMenu(wm, leafId, x, y) {
8539
8678
  ];
8540
8679
  if (wm.desktops.desktops.length > 1 && !isPanel) {
8541
8680
  for (const [i, d] of wm.desktops.desktops.entries()) {
8542
- if (i === wm.desktops.activeIdx) continue;
8681
+ if (i === wm.desktops.activeIdx || d.fixed) continue;
8543
8682
  items.push({
8544
8683
  label: `Move to desktop ${d.label}`,
8545
8684
  icon: "sweep",