flexdesk 0.9.0 → 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,7 +130,36 @@ await createShell({
130
130
  });
131
131
  ```
132
132
 
133
- ### The palette that finds anything (0.9.0)
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
+
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
136
165
  passes none of them keeps the chip strip, the panel toggles and the flat list:
@@ -158,8 +187,10 @@ A row may carry `context` (drawn, not searched), `keywords` (searched, not
158
187
  drawn), `modes` (which of `split`/`window` its keys offer), `drillLabel` (the Tab
159
188
  hint), `boost`, `group`/`groupLabel`, `kindLabel`, `shortcut` and `verb`. A group's
160
189
  header is the taxonomy's `plural` for the kind, else its `label`. Matching ranks
161
- exact, prefix, word starts (`ord li` finds *Order lines*), substring, then a tight
162
- subsequence. A drill's `search` is debounced, and a stale answer is aborted and
190
+ exact, prefix, word starts (`ord li` finds *Order lines*), substring, then an
191
+ abbreviation: it starts at a word, and the letters it skips outside word starts are
192
+ at most half the query (`cstmr` finds *customer*; `cour` does not find *Scroll up
193
+ to*). A drill's `search` is debounced, and a stale answer is aborted and
163
194
  never drawn. `palette.invalidate()` drops the cache.
164
195
 
165
196
  **`chrome.zoom`** paints a − / track / + / readout control into the element you
package/dist/wm.js CHANGED
@@ -698,6 +698,7 @@ function _match(text, query) {
698
698
  const sub = t.indexOf(q);
699
699
  if (sub >= 0) return { s: 60, hits: span(sub, q.length) };
700
700
  const flat = q.replace(/\s+/g, "");
701
+ const startsWord = (i) => i === 0 || _WORD_BREAK.test(t[i - 1]);
701
702
  const hits = [];
702
703
  let j = 0;
703
704
  for (let i = 0; i < t.length && j < flat.length; i++) {
@@ -706,9 +707,13 @@ function _match(text, query) {
706
707
  j++;
707
708
  }
708
709
  }
709
- if (j < flat.length) return null;
710
- const spread = hits[hits.length - 1] - hits[0] + 1 - flat.length;
711
- if (spread > 3 * flat.length + 6) return null;
710
+ if (j < flat.length || !startsWord(hits[0])) return null;
711
+ let spread = 0;
712
+ for (let k = 1; k < hits.length; k++) {
713
+ const gap = hits[k] - hits[k - 1] - 1;
714
+ if (gap > 0 && !startsWord(hits[k])) spread += gap;
715
+ }
716
+ if (spread > Math.floor(flat.length / 2)) return null;
712
717
  return { s: Math.max(1, 30 - spread), hits };
713
718
  }
714
719
  function _mark(text, hits) {
@@ -1654,7 +1659,10 @@ function _makeDesktop(label, seed, panelDefaults = DEFAULT_PANEL_STATE) {
1654
1659
  // Boot default: whatever the embedder asked for, left + right + bottom
1655
1660
  // when it asked for nothing. Names are assigned by wm._canonicalize via
1656
1661
  // PANEL_TITLES.
1657
- panels: { ...panelDefaults }
1662
+ panels: { ...panelDefaults },
1663
+ // C40. Both unset is a desktop exactly as it always was.
1664
+ group: null,
1665
+ fixed: false
1658
1666
  };
1659
1667
  }
1660
1668
  var DesktopManager = class _DesktopManager {
@@ -1673,6 +1681,7 @@ var DesktopManager = class _DesktopManager {
1673
1681
  this.panelDefaults = { ...DEFAULT_PANEL_STATE, ...panelDefaults || {} };
1674
1682
  this.desktops = [_makeDesktop("1", seed, this.panelDefaults)];
1675
1683
  this.activeIdx = 0;
1684
+ this.lastInGroup = /* @__PURE__ */ new Map();
1676
1685
  }
1677
1686
  active() {
1678
1687
  return this.desktops[this.activeIdx];
@@ -1681,8 +1690,76 @@ var DesktopManager = class _DesktopManager {
1681
1690
  if (idx < 0 || idx >= this.desktops.length) return false;
1682
1691
  if (idx === this.activeIdx) return false;
1683
1692
  this.activeIdx = idx;
1693
+ const d = this.desktops[idx];
1694
+ if (d.group) this.lastInGroup.set(d.group, d.id);
1684
1695
  return true;
1685
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
+ }
1686
1763
  ensureCount(n) {
1687
1764
  while (this.desktops.length < n) {
1688
1765
  this.desktops.push(_makeDesktop(
@@ -1693,11 +1770,8 @@ var DesktopManager = class _DesktopManager {
1693
1770
  }
1694
1771
  }
1695
1772
  addDesktop(label = null) {
1696
- const d = _makeDesktop(
1697
- label || String(this.desktops.length + 1),
1698
- this.seed,
1699
- this.panelDefaults
1700
- );
1773
+ const n = this.desktops.filter((d2) => !d2.group).length + 1;
1774
+ const d = _makeDesktop(label || String(n), this.seed, this.panelDefaults);
1701
1775
  this.desktops.push(d);
1702
1776
  return d;
1703
1777
  }
@@ -1708,7 +1782,11 @@ var DesktopManager = class _DesktopManager {
1708
1782
  id: d.id,
1709
1783
  label: d.label,
1710
1784
  tree: d.tree.serialize(),
1711
- 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 } : {}
1712
1790
  // windows serialized at WM level since we don't own
1713
1791
  // managed-window state in this module.
1714
1792
  }))
@@ -1726,12 +1804,16 @@ var DesktopManager = class _DesktopManager {
1726
1804
  // closed that panel, and re-opening it on every reload is the bug
1727
1805
  // this merge order avoids. The default only fills a key the stored
1728
1806
  // blob predates.
1729
- panels: { ...m.panelDefaults, ...raw.panels || {} }
1807
+ panels: { ...m.panelDefaults, ...raw.panels || {} },
1808
+ group: raw.group || null,
1809
+ fixed: !!raw.fixed
1730
1810
  }));
1731
1811
  for (const d of m.desktops) {
1732
1812
  if (!d.tree.rootId) d.tree.setRoot(makeLeaf(seed()));
1733
1813
  }
1734
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);
1735
1817
  return m;
1736
1818
  }
1737
1819
  };
@@ -6681,6 +6763,7 @@ var WindowManager = class _WindowManager {
6681
6763
  const rec = this._windowToLeaf.get(windowId);
6682
6764
  if (!rec) return;
6683
6765
  if (rec.desktopIdx === targetIdx) return;
6766
+ if (this.desktops.desktops[targetIdx]?.fixed) return;
6684
6767
  this.desktops.ensureCount(targetIdx + 1);
6685
6768
  rec.desktopIdx = targetIdx;
6686
6769
  if (!rec.homeContainer && rec.homeLeafId) {
@@ -6811,10 +6894,10 @@ var WindowManager = class _WindowManager {
6811
6894
  const items = [
6812
6895
  { label: "Back to tile", icon: "close_fullscreen", action: "back" }
6813
6896
  ];
6814
- 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) {
6815
6899
  items.push({ separator: true });
6816
- for (const [i, d] of this.desktops.desktops.entries()) {
6817
- if (i === rec.desktopIdx) continue;
6900
+ for (const [i, d] of targets) {
6818
6901
  items.push({
6819
6902
  label: `Move to desktop ${d.label}`,
6820
6903
  icon: "sweep",
@@ -7108,20 +7191,34 @@ var WindowManager = class _WindowManager {
7108
7191
  this._persist();
7109
7192
  this._notifyChange();
7110
7193
  }
7111
- 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 } = {}) {
7112
7198
  this.desktops.addDesktop();
7199
+ const idx = this.desktops.desktops.length - 1;
7200
+ if (switchTo) {
7201
+ this.switchDesktop(idx);
7202
+ return idx;
7203
+ }
7113
7204
  this._notifyChange();
7114
7205
  this._persist();
7206
+ return idx;
7115
7207
  }
7116
7208
  /** Step to the previous (-1) or next (+1) existing desktop, wrapping
7117
7209
  * around. No-op with a single desktop. Iterates only desktops that
7118
- * 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. */
7119
7215
  cycleDesktop(dir) {
7120
- const m = this.desktops;
7121
- const n = m.desktops.length;
7216
+ const chips = this.desktops.chips();
7217
+ const n = chips.length;
7122
7218
  if (n <= 1) return;
7123
- const next = (m.activeIdx + (dir < 0 ? -1 : 1) + n) % n;
7124
- 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);
7125
7222
  }
7126
7223
  /** Cycle the focused tile's main-panel tabs (Ctrl+Tab / Ctrl+Shift+Tab).
7127
7224
  * Wraps; no-op when the focused leaf has 0 or 1 tabs. */
@@ -7158,6 +7255,7 @@ var WindowManager = class _WindowManager {
7158
7255
  const m = this.desktops;
7159
7256
  if (m.desktops.length <= 1) return false;
7160
7257
  if (idx < 0 || idx >= m.desktops.length) return false;
7258
+ if (m.desktops[idx].fixed) return false;
7161
7259
  m.desktops.splice(idx, 1);
7162
7260
  if (idx < m.activeIdx) m.activeIdx -= 1;
7163
7261
  if (m.activeIdx >= m.desktops.length) m.activeIdx = m.desktops.length - 1;
@@ -7183,12 +7281,31 @@ var WindowManager = class _WindowManager {
7183
7281
  if (!focused || !focused.content) return;
7184
7282
  const closeChrome = this.renderer?.leafChrome?.(focused.id)?.close;
7185
7283
  if (closeChrome === false || closeChrome?.disabled === true) return;
7186
- const payload = { kind: focused.content.kind, props: focused.content.props, title: focused.title };
7284
+ if (this.desktops.desktops[idx]?.fixed) return;
7187
7285
  this.desktops.ensureCount(idx + 1);
7188
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));
7189
7289
  const targetTree = target.tree;
7190
7290
  const primary = targetTree.primaryLeafId();
7191
- 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
+ }
7192
7309
  tree.close(focused.id);
7193
7310
  if (!tree.rootId) tree.setRoot(makeLeaf(this._rootLeaf()));
7194
7311
  if (!tree.leaves().some((l) => !String(l.content?.kind || "").startsWith("panel:"))) {
@@ -8158,6 +8275,7 @@ async function createShell({
8158
8275
  floatActiveTab = false,
8159
8276
  tabLayout = null,
8160
8277
  newTabButton = true,
8278
+ desktopBar = {},
8161
8279
  chrome = {},
8162
8280
  // An embedder that moved its sections out of the top bar — into an icon
8163
8281
  // rail, say — passes the selector its own buttons match, and F1..F8 keep
@@ -8229,7 +8347,7 @@ async function createShell({
8229
8347
  const disposeKeymap = installKeymap({ wm, palette, navSelector });
8230
8348
  const paletteBtn = mountPaletteButton(chrome.paletteButton, palette);
8231
8349
  topNavEl = mountTopNav(chrome.topNav, taxonomy, wm);
8232
- desktopsEl = mountDesktopBar(chrome.desktops, wm);
8350
+ desktopsEl = mountDesktopBar(chrome.desktops, wm, desktopBar || {});
8233
8351
  const zoom = mountZoomControl(chrome.zoom, { root, host });
8234
8352
  if (zoom) await zoom.ready;
8235
8353
  toggles = bindPanelToggles(chrome.panelToggles, wm);
@@ -8280,6 +8398,10 @@ async function createShell({
8280
8398
  zoom?.dispose();
8281
8399
  } catch {
8282
8400
  }
8401
+ try {
8402
+ desktopsEl?.remove();
8403
+ } catch {
8404
+ }
8283
8405
  }
8284
8406
  });
8285
8407
  }
@@ -8354,7 +8476,7 @@ function syncPanelToggles(map, wm) {
8354
8476
  btn.classList.toggle("twm-panel-toggle-btn--on", wm.isPanelOpen(side));
8355
8477
  }
8356
8478
  }
8357
- function mountDesktopBar(hostEl, wm) {
8479
+ function mountDesktopBar(hostEl, wm, { switchOnAdd = false } = {}) {
8358
8480
  if (!hostEl) return null;
8359
8481
  let el = hostEl.querySelector("#twm-desktops");
8360
8482
  if (!el) {
@@ -8368,15 +8490,16 @@ function mountDesktopBar(hostEl, wm) {
8368
8490
  if (!btn) return;
8369
8491
  const v = btn.dataset.desktop;
8370
8492
  if (v === "+") {
8371
- wm.addDesktop();
8493
+ wm.addDesktop({ switchTo: switchOnAdd });
8372
8494
  syncDesktopBar(el, wm);
8373
8495
  return;
8374
8496
  }
8375
8497
  wm.switchDesktop(Number(v));
8376
8498
  });
8499
+ const isOwn = (btn) => btn.dataset.desktopFixed !== "1";
8377
8500
  el.addEventListener("dblclick", async (e) => {
8378
8501
  const btn = e.target.closest("[data-desktop]");
8379
- if (!btn || btn.dataset.desktop === "+") return;
8502
+ if (!btn || btn.dataset.desktop === "+" || !isOwn(btn)) return;
8380
8503
  e.preventDefault();
8381
8504
  const idx = Number(btn.dataset.desktop);
8382
8505
  const d = wm.desktops.desktops[idx];
@@ -8405,25 +8528,28 @@ function mountDesktopBar(hostEl, wm) {
8405
8528
  const idx = Number(btn.dataset.desktop);
8406
8529
  const m = wm.desktops;
8407
8530
  const d = m.desktops[idx];
8408
- const canDelete = m.desktops.length > 1;
8409
- 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 = [
8410
8535
  {
8411
- label: `Switch to ${d.label}`,
8536
+ label: `Switch to ${name}`,
8412
8537
  icon: "desktop_windows",
8413
8538
  action: "switch",
8414
8539
  disabled: idx === m.activeIdx
8415
8540
  },
8416
- { label: "Rename\u2026", icon: "edit", action: "rename" },
8541
+ ...own ? [{ label: "Rename\u2026", icon: "edit", action: "rename" }] : [],
8417
8542
  { separator: true },
8418
8543
  { label: "New desktop", icon: "add", action: "add" },
8419
- {
8544
+ ...own ? [{
8420
8545
  label: "Delete desktop",
8421
8546
  icon: "delete",
8422
8547
  action: "delete",
8423
8548
  danger: true,
8424
8549
  disabled: !canDelete
8425
- }
8426
- ], async (action) => {
8550
+ }] : []
8551
+ ];
8552
+ showContextMenu(e.clientX, e.clientY, items, async (action) => {
8427
8553
  if (action === "switch") wm.switchDesktop(idx);
8428
8554
  else if (action === "rename") {
8429
8555
  const result = await openForm({
@@ -8437,7 +8563,7 @@ function mountDesktopBar(hostEl, wm) {
8437
8563
  syncDesktopBar(el, wm);
8438
8564
  wm._persist();
8439
8565
  } else if (action === "add") {
8440
- wm.addDesktop();
8566
+ wm.addDesktop({ switchTo: switchOnAdd });
8441
8567
  syncDesktopBar(el, wm);
8442
8568
  } else if (action === "delete") {
8443
8569
  wm.removeDesktop(idx);
@@ -8450,13 +8576,31 @@ function mountDesktopBar(hostEl, wm) {
8450
8576
  function syncDesktopBar(el, wm) {
8451
8577
  if (!el) return;
8452
8578
  const m = wm.desktops;
8453
- el.innerHTML = m.desktops.map((d, i) => `
8579
+ if (!m.hasGroups()) {
8580
+ el.innerHTML = m.desktops.map((d, i) => `
8454
8581
  <button class="twm-desk__btn${i === m.activeIdx ? " twm-desk__btn--on" : ""}"
8455
8582
  data-desktop="${i}"
8456
8583
  title="Desktop ${d.label} (Alt+${i + 1}) \xB7 right-click to delete">
8457
8584
  ${d.label}
8458
8585
  </button>`).join("") + `<button class="twm-desk__btn twm-desk__btn--add"
8459
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]);
8460
8604
  }
8461
8605
  function _tileTabMenu(wm, leafId, x, y) {
8462
8606
  const tree = wm.desktops.active().tree;
@@ -8534,7 +8678,7 @@ function _tileContextMenu(wm, leafId, x, y) {
8534
8678
  ];
8535
8679
  if (wm.desktops.desktops.length > 1 && !isPanel) {
8536
8680
  for (const [i, d] of wm.desktops.desktops.entries()) {
8537
- if (i === wm.desktops.activeIdx) continue;
8681
+ if (i === wm.desktops.activeIdx || d.fixed) continue;
8538
8682
  items.push({
8539
8683
  label: `Move to desktop ${d.label}`,
8540
8684
  icon: "sweep",