flexdesk 0.4.0 → 0.4.2

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
@@ -106,13 +106,15 @@ lives in `demo/`.
106
106
 
107
107
  ### Opt-in shell features
108
108
 
109
- Two features change what the user sees, so both are off until you ask:
109
+ These change what the user sees, so all of them are off until you ask:
110
110
 
111
111
  ```js
112
112
  await createShell({
113
113
  // …everything above…
114
114
  snapPromotion: true, // drag a floating window onto a tile: it snaps, and docks back in
115
115
  promoteInPlace: true, // a window floated out of a tile stays inside that tile's pane
116
+ backToOpenList: true, // Back in a record opened beside its list closes onto that list
117
+ floatActiveTab: true, // floating a tile takes the tab on screen, not the whole pane
116
118
  chrome: {
117
119
  topNav: document.getElementById('nav'),
118
120
  zoom: document.getElementById('status-bar'), // content zoom, 50–200%
@@ -130,6 +132,25 @@ for a settings pane. An embedder that builds its own `WindowManager` instead of
130
132
  calling `createShell` mounts the same control with `mountZoomControl(el, { root,
131
133
  host })`.
132
134
 
135
+ **`backToOpenList`** is for apps that open a record from a list in a new tab.
136
+ Without it, Back in that record has no history to pop, so it walks the taxonomy
137
+ up and rewrites the record into its parent, and the tile now shows the list
138
+ twice. With it, Back closes the record tab and activates the list it came from:
139
+ the nearest tab to the left, else to the right, that is not a record (no
140
+ `props.id`) and belongs to the same top-nav section. Per-tab history still comes
141
+ first, and a record with no such list beside it still walks up in place.
142
+
143
+ **`floatActiveTab`** is for apps whose tabs are separate records rather than
144
+ views of one pane. The float button, Alt+F and the tile menu take only the tab
145
+ on screen and leave its siblings in the tile, so the window holds one tab and
146
+ draws no strip. Without it the whole pane floats, strip and all.
147
+
148
+ **Back in a floating window** always acts on that window, whichever option is
149
+ set. The last click decides: inside a window, Backspace walks that window up;
150
+ anywhere else, it walks the focused tile as before. With `backToOpenList`, a
151
+ record in a window closes onto an open list of its section, first among the
152
+ window's own tabs and then in any tile of its desktop.
153
+
133
154
  ## The host port
134
155
 
135
156
  FlexDesk never touches `pywebview`, `fetch`, `localStorage` or the filesystem. It
package/dist/wm.js CHANGED
@@ -2902,7 +2902,7 @@ var TileRenderer = class {
2902
2902
  <button class="twm-leaf__btn" data-action="split-v" title="Split vertically (Alt+V)">
2903
2903
  <span class="material-symbols-outlined">splitscreen_add</span>
2904
2904
  </button>
2905
- <button class="twm-leaf__btn" data-action="promote" title="Float this pane as a window (Alt+F)">
2905
+ <button class="twm-leaf__btn" data-action="promote" title="Float this ${this.ctx.wm?.floatActiveTab ? "tab" : "pane"} as a window (Alt+F)">
2906
2906
  <span class="material-symbols-outlined">web_asset</span>
2907
2907
  </button>`}
2908
2908
  <button class="twm-leaf__btn" data-action="close" title="Close (Alt+W)">
@@ -3978,7 +3978,9 @@ var WindowManager = class _WindowManager {
3978
3978
  panelDefaults = null,
3979
3979
  snapPromotion = false,
3980
3980
  promoteInPlace = false,
3981
- tabLayout = null
3981
+ tabLayout = null,
3982
+ backToOpenList = false,
3983
+ floatActiveTab = false
3982
3984
  }) {
3983
3985
  if (!taxonomy) throw new Error("WindowManager: a taxonomy is required");
3984
3986
  if (!content || typeof content.mount !== "function") {
@@ -3994,6 +3996,7 @@ var WindowManager = class _WindowManager {
3994
3996
  this.eventBus = eventBus || null;
3995
3997
  this.onChange = onChange || (() => {
3996
3998
  });
3999
+ this.floatActiveTab = !!floatActiveTab;
3997
4000
  this._rootLeaf = () => {
3998
4001
  const kind = this.taxonomy.root;
3999
4002
  return {
@@ -4004,6 +4007,13 @@ var WindowManager = class _WindowManager {
4004
4007
  this.panelDefaults = panelDefaults || null;
4005
4008
  this.snapPromotion = !!snapPromotion;
4006
4009
  this.promoteInPlace = !!promoteInPlace;
4010
+ this.backToOpenList = !!backToOpenList;
4011
+ this._backWindowId = null;
4012
+ const doc = rootEl?.ownerDocument || globalThis.document;
4013
+ doc?.addEventListener?.("pointerdown", (e) => {
4014
+ const el = e.target?.closest?.("[data-window-id]");
4015
+ this._backWindowId = el && this._windowToLeaf.has(el.getAttribute("data-window-id")) ? el.getAttribute("data-window-id") : null;
4016
+ }, true);
4007
4017
  this._snapCtl = null;
4008
4018
  this.desktops = new DesktopManager({
4009
4019
  seed: this._rootLeaf,
@@ -4148,6 +4158,7 @@ var WindowManager = class _WindowManager {
4148
4158
  * methods (openFromContext / openInLeaf / navigateActiveTab) —
4149
4159
  * they all record history by default. */
4150
4160
  navigateBack() {
4161
+ if (this._navigateBackInWindow()) return;
4151
4162
  const tree = this._tree();
4152
4163
  const focusedId = tree.focusedLeafId;
4153
4164
  const primaryId = tree.primaryLeafId();
@@ -4186,6 +4197,7 @@ var WindowManager = class _WindowManager {
4186
4197
  this._notifyChange("navigate-back");
4187
4198
  return;
4188
4199
  }
4200
+ if (this.backToOpenList && this._backToOpenList(id)) return;
4189
4201
  const leaf = tree.get(id);
4190
4202
  if (!leaf?.content) return;
4191
4203
  const { kind, props } = leaf.content;
@@ -4230,6 +4242,132 @@ var WindowManager = class _WindowManager {
4230
4242
  /** Navigate inside the current tab — preserves every other tab in
4231
4243
  * the leaf. Used by Backspace + breadcrumb segments + anything
4232
4244
  * else that should walk WITHIN the tile rather than reset it. */
4245
+ /**
4246
+ * C32. BACK FROM AN OPENED RECORD RETURNS TO THE LIST, IT DOES NOT MAKE ONE.
4247
+ *
4248
+ * The gesture this is for: a list tab, a record opened from it into a NEW
4249
+ * tab, and Backspace in that record. With nothing left in the record tab's
4250
+ * own history, step (3) of `navigateBack` walks the taxonomy up and REWRITES
4251
+ * the record tab into its parent — so the tile ends up showing the list
4252
+ * twice, once in the tab you started from and once in the tab you just left.
4253
+ * The user's model is a browser's: you came from the list, the list is still
4254
+ * open, so going back means closing this and being on the list again.
4255
+ *
4256
+ * So: when the active tab is an ENTITY — its props carry an `id`, the same
4257
+ * test `swapToPage` uses for "the caller asked for a specific entity" — look
4258
+ * for an open tab of the SAME section that is NOT one. That is the list or
4259
+ * landing page the record belongs to. Close the record and activate it.
4260
+ *
4261
+ * NEAREST TO THE LEFT FIRST. New tabs are appended at the end, so the tab a
4262
+ * record was opened from sits to its left; when several lists of the section
4263
+ * are open, the nearest one on that side is the one it most plausibly came
4264
+ * from. The right side is searched only after the left has nothing.
4265
+ *
4266
+ * DECLINES, and leaves step (3) to walk up in place, whenever there is no
4267
+ * such tab: a record that is the tile's only tab, or one whose section has no
4268
+ * list open. That keeps the one guarantee the whole rule exists for — Back
4269
+ * never leaves two copies of a list — without ever closing a record into a
4270
+ * tile that has nothing of its own to show.
4271
+ *
4272
+ * Matching is by SECTION (`topNavFor`) and not by kind, because the list a
4273
+ * record was opened from is often not the record's taxonomy parent: a root
4274
+ * landing page that renders the section, or a list that a flattened taxonomy
4275
+ * files beside the record rather than above it.
4276
+ *
4277
+ * @returns {boolean} whether it closed a tab
4278
+ */
4279
+ _backToOpenList(leafId) {
4280
+ const tree = this._tree();
4281
+ const leaf = tree.get(leafId);
4282
+ if (!leaf || leaf.kind !== "leaf") return false;
4283
+ const tabs = Array.isArray(leaf.tabs) ? leaf.tabs : [];
4284
+ if (tabs.length < 2) return false;
4285
+ const activeIdx = Math.max(0, Math.min(tabs.length - 1, leaf.activeTabIdx || 0));
4286
+ const active = tabs[activeIdx];
4287
+ if (!_isEntityTab(active)) return false;
4288
+ const section = this.taxonomy.topNavFor(active.kind);
4289
+ if (!section) return false;
4290
+ const isList = (t) => t && !_isEntityTab(t) && this.taxonomy.topNavFor(t.kind) === section;
4291
+ let target = -1;
4292
+ for (let i = activeIdx - 1; i >= 0 && target < 0; i--) if (isList(tabs[i])) target = i;
4293
+ for (let i = activeIdx + 1; i < tabs.length && target < 0; i++) if (isList(tabs[i])) target = i;
4294
+ if (target < 0) return false;
4295
+ tree.removeLeafTab(leafId, activeIdx);
4296
+ tree.setActiveLeafTab(leafId, target > activeIdx ? target - 1 : target);
4297
+ tree.focus(leafId);
4298
+ this.renderer.render();
4299
+ this._persist();
4300
+ this._notifyChange("navigate-back-close");
4301
+ return true;
4302
+ }
4303
+ /**
4304
+ * C34. Back while a floating window is the thing being read. Returns true
4305
+ * when it handled the press, false to let the tile path run.
4306
+ *
4307
+ * Only the window's ACTIVE tab is considered, in this order:
4308
+ * (1) with `backToOpenList`, a record closes onto an open list of its
4309
+ * section: first among the window's own tabs, then any tile of the
4310
+ * window's desktop, primary tile first. Closing the last tab closes
4311
+ * the window, which discards it (a plain close never docks back).
4312
+ * (2) otherwise it walks up IN the window, exactly as a tile does.
4313
+ * A window whose content has nowhere to go still counts as handled: the
4314
+ * press was aimed at it, and moving the tile behind it would be the bug.
4315
+ */
4316
+ _navigateBackInWindow() {
4317
+ const winId = this._backWindowId;
4318
+ const rec = winId ? this._windowToLeaf.get(winId) : null;
4319
+ if (!rec) {
4320
+ this._backWindowId = null;
4321
+ return false;
4322
+ }
4323
+ const tabs = Array.isArray(rec.tabs) && rec.tabs.length ? rec.tabs : [rec.original].filter(Boolean);
4324
+ const activeIdx = Math.max(0, Math.min(tabs.length - 1, rec.activeTabIdx || 0));
4325
+ const active = tabs[activeIdx];
4326
+ if (!active) return true;
4327
+ const section = this.backToOpenList && _isEntityTab(active) ? this.taxonomy.topNavFor(active.kind) : null;
4328
+ const isList = (t) => !!t && !_isEntityTab(t) && this.taxonomy.topNavFor(t.kind) === section;
4329
+ if (section) {
4330
+ let own = -1;
4331
+ for (let i = activeIdx - 1; i >= 0 && own < 0; i--) if (isList(tabs[i])) own = i;
4332
+ for (let i = activeIdx + 1; i < tabs.length && own < 0; i++) if (isList(tabs[i])) own = i;
4333
+ if (own >= 0 && Array.isArray(rec.tabs) && rec.tabs.length > 1) {
4334
+ this._windowTabAction(winId, "close", { idx: activeIdx });
4335
+ this.showWindowTab(winId, own > activeIdx ? own - 1 : own);
4336
+ return true;
4337
+ }
4338
+ const desk = this.desktops.desktops[rec.desktopIdx] || this.desktops.active();
4339
+ const tree = desk.tree;
4340
+ const leaves = tree.leaves?.() || [];
4341
+ const primaryId = tree.primaryLeafId();
4342
+ const ordered = [...leaves.filter((l) => l.id === primaryId), ...leaves.filter((l) => l.id !== primaryId)];
4343
+ for (const leaf of ordered) {
4344
+ const ltabs = Array.isArray(leaf.tabs) ? leaf.tabs : [];
4345
+ const cur = Math.max(0, Math.min(ltabs.length - 1, leaf.activeTabIdx || 0));
4346
+ const hit = isList(ltabs[cur]) ? cur : ltabs.findIndex(isList);
4347
+ if (hit < 0) continue;
4348
+ this._backWindowId = null;
4349
+ if (Array.isArray(rec.tabs) && rec.tabs.length > 1) this._windowTabAction(winId, "close", { idx: activeIdx });
4350
+ else {
4351
+ try {
4352
+ rec.window.close({ force: true });
4353
+ } catch {
4354
+ }
4355
+ }
4356
+ if (this.desktops.active().tree === tree) {
4357
+ tree.setActiveLeafTab(leaf.id, hit);
4358
+ tree.focus(leaf.id);
4359
+ this.renderer.render();
4360
+ }
4361
+ this._persist();
4362
+ this._notifyChange("navigate-back-close");
4363
+ return true;
4364
+ }
4365
+ }
4366
+ const up = this.taxonomy.parentOf?.(active.kind, active.props || {});
4367
+ const parentKind = up ? up.kind : this.taxonomy.parentKindFor(active.kind);
4368
+ if (parentKind) this.openInWindow(winId, parentKind, up ? up.props || {} : {});
4369
+ return true;
4370
+ }
4233
4371
  navigateActiveTab(leafId, kind, props = {}) {
4234
4372
  const tree = this._tree();
4235
4373
  const leaf = tree.get(leafId);
@@ -4571,6 +4709,11 @@ var WindowManager = class _WindowManager {
4571
4709
  const tree = this._tree();
4572
4710
  const focused = tree.focused();
4573
4711
  if (!focused) return null;
4712
+ const tabs = Array.isArray(focused.tabs) ? focused.tabs : [];
4713
+ if (this.floatActiveTab && tabs.length > 1) {
4714
+ const idx = Math.max(0, Math.min(tabs.length - 1, focused.activeTabIdx || 0));
4715
+ return this.floatTabAsWindow(focused.id, idx);
4716
+ }
4574
4717
  return this.floatPane(focused.id);
4575
4718
  }
4576
4719
  /** R8. Float a pane — every tab, with the strip — into a managed window.
@@ -6843,6 +6986,9 @@ function _swapSiblings(tree, aId, bId) {
6843
6986
  parent.sizes[j] = size;
6844
6987
  return true;
6845
6988
  }
6989
+ function _isEntityTab(tab) {
6990
+ return !!tab && tab.props != null && tab.props.id != null && tab.props.id !== "";
6991
+ }
6846
6992
 
6847
6993
  // src/tiling/tile_tab_menu.js
6848
6994
  var PAGE_SIZE = 10;
@@ -7356,6 +7502,8 @@ async function createShell({
7356
7502
  panels = null,
7357
7503
  snapPromotion = false,
7358
7504
  promoteInPlace = false,
7505
+ backToOpenList = false,
7506
+ floatActiveTab = false,
7359
7507
  tabLayout = null,
7360
7508
  chrome = {},
7361
7509
  // An embedder that moved its sections out of the top bar — into an icon
@@ -7395,6 +7543,8 @@ async function createShell({
7395
7543
  panelDefaults: panels,
7396
7544
  snapPromotion,
7397
7545
  promoteInPlace,
7546
+ backToOpenList,
7547
+ floatActiveTab,
7398
7548
  tabLayout,
7399
7549
  // `ctx` is the delivery vehicle for leaf-mounted chrome: tile_renderer
7400
7550
  // spreads it into every content factory, which is how the breadcrumb
@@ -7722,7 +7872,7 @@ function _tileContextMenu(wm, leafId, x, y) {
7722
7872
  // direction is a dead control; in the mean direction it is a missing
7723
7873
  // feature, and this file has shipped one of each.
7724
7874
  {
7725
- label: "Float this pane as a window",
7875
+ label: wm.floatActiveTab ? "Float this tab as a window" : "Float this pane as a window",
7726
7876
  icon: "web_asset",
7727
7877
  action: "promote",
7728
7878
  disabled: isPanel || !leaf.content || wm.renderer?.leafChrome?.(leafId)?.promote === false