flexdesk 0.4.2 → 0.4.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flexdesk",
3
- "version": "0.4.2",
3
+ "version": "0.4.4",
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": {
@@ -44,20 +44,21 @@ export function mountTileBreadcrumb(kind, props, ctx) {
44
44
  // before the renderer threaded wm through — still navigates.
45
45
  const getWm = () => ctx?.wm || window.__twm?.wm || null;
46
46
 
47
- // Breadcrumb segments route through the canonical `wm.navigate`
48
- // helper so the same three-axis routing (window → replace window,
49
- // tile → replace active tab, panel/none → primary) lives in one
50
- // place. Critically, this is what makes the breadcrumb work in
51
- // WINDOWED mode — passing `ctx` keeps windowId in scope so the
52
- // segment click stays inside the floating window instead of
53
- // jumping back to the primary tile.
47
+ // Every crumb CLIMBS: `wm.navigateUp` (C35), not `wm.navigate`. A crumb
48
+ // means "up to this level of where I am", and from a record whose list is
49
+ // already open that is what Backspace means too, so the WM answers both
50
+ // with one rule and the breadcrumb never works out which crumb is the
51
+ // parent. Passing `ctx` keeps the click inside its own tile or floating
52
+ // window.
54
53
  const navigate = (k, p = {}) => {
55
54
  const wm = getWm();
56
55
  if (!wm) {
57
56
  console.error('[breadcrumb] no WM available — click ignored', k);
58
57
  return;
59
58
  }
60
- if (typeof wm.navigate === 'function') {
59
+ if (typeof wm.navigateUp === 'function') {
60
+ wm.navigateUp(k, p, { ctx });
61
+ } else if (typeof wm.navigate === 'function') {
61
62
  wm.navigate(k, p, { ctx, dest: 'origin' });
62
63
  } else if (typeof wm.openInPrimary === 'function') {
63
64
  // Fallback for older WM revisions.
package/src/tiling/wm.js CHANGED
@@ -88,7 +88,7 @@ export class WindowManager {
88
88
  this.promoteInPlace = !!promoteInPlace;
89
89
  /** C32. Whether Backspace on an ENTITY tab closes it and returns to a list
90
90
  * that is already open, instead of turning the tab into a second copy of
91
- * that list. See `_backToOpenList`. Default off: it changes what the key
91
+ * that list. See `_returnToOpenList`. Default off: it changes what the key
92
92
  * does, and an existing embedder upgrading must not find tabs closing
93
93
  * where they used to navigate. */
94
94
  this.backToOpenList = !!backToOpenList;
@@ -261,69 +261,50 @@ export class WindowManager {
261
261
  * methods (openFromContext / openInLeaf / navigateActiveTab) —
262
262
  * they all record history by default. */
263
263
  navigateBack() {
264
- // C34. A window the user is reading gets the Back, not the tile under it.
265
- if (this._navigateBackInWindow()) return;
266
- const tree = this._tree();
267
- // Prefer the FOCUSED leaf — that's where the user was just
268
- // interacting (Open click, breadcrumb step, etc.). Falls back
269
- // to the primary leaf for keyboard-driven Backspace when no
270
- // tile is explicitly focused (panels excluded). If the focused
271
- // leaf is a panel, walk back on the primary content leaf
272
- // instead (Backspace from the nav panel should still navigate
273
- // the user's content area).
274
- const focusedId = tree.focusedLeafId;
275
- const primaryId = tree.primaryLeafId();
276
- const focusedLeaf = focusedId ? tree.get(focusedId) : null;
277
- const focusedKind = focusedLeaf?.content?.kind || '';
278
- const id = (focusedId && focusedKind
279
- && !focusedKind.startsWith('panel:')
280
- && focusedKind !== 'window-placeholder')
281
- ? focusedId : primaryId;
282
- if (!id) return;
283
- // (1) Per-tab history pop, if anything was recorded.
284
- const prior = tree.popActiveTabHistory?.(id);
285
- if (prior && prior.kind) {
286
- // Cross-page back entries carry a `topNav` marker — pop
287
- // routes via swapToPage so we land on the correct page.
288
- const leafNow = tree.get(id);
289
- const curKind = leafNow?.content?.kind;
290
- const curTopNav = curKind ? this.taxonomy.topNavFor(curKind) : null;
291
- if (prior.topNav && curTopNav && prior.topNav !== curTopNav) {
292
- tree.swapToPage?.(
293
- id,
294
- { kind: prior.kind, props: prior.props || {},
295
- title: prior.title || _tabTitle(prior.kind, prior.props) },
296
- curTopNav, prior.topNav,
297
- { recordHistory: false },
298
- );
299
- } else {
300
- tree.replaceActiveTabContent(
301
- id,
302
- { kind: prior.kind, props: prior.props || {} },
303
- prior.title || _tabTitle(prior.kind, prior.props),
304
- { recordHistory: false },
305
- );
306
- }
307
- tree.focus(id);
308
- this.renderer.render();
309
- this._persist();
310
- this._notifyChange('navigate-back');
311
- return;
312
- }
313
- // (2) C32. An entity tab with nowhere left to go back to, beside a list
314
- // of its own section that is already open: close it and land there.
315
- if (this.backToOpenList && this._backToOpenList(id)) return;
264
+ const scope = this._backScope();
265
+ if (!scope) return;
266
+ // (1) Per-tab history, browser-style. Tiles only: a window keeps none.
267
+ if (scope.leafId && this._popTabHistory(scope.leafId)) return;
268
+ // (2) C32. A record beside an open list of its section closes onto it.
269
+ if (this._returnToOpenList(scope)) return;
316
270
  // (3) Taxonomy fallback — the user's path is gone, so walk the
317
- // canonical parent instead. An ENTITY-level parent wins (the
318
- // instance one level up, e.g. the parent an id names); failing
319
- // that, the KIND-level parent (this page's section).
320
- const leaf = tree.get(id);
321
- if (!leaf?.content) return;
322
- const { kind, props } = leaf.content;
323
- const up = this.taxonomy.parentOf(kind, props);
324
- if (up) { this.navigateActiveTab(id, up.kind, up.props); return; }
325
- const parent = this.taxonomy.parentKindFor(kind);
326
- if (parent) this.navigateActiveTab(id, parent);
271
+ // canonical parent instead, in place. An ENTITY-level parent wins (the
272
+ // instance one level up); failing that, the KIND-level parent.
273
+ const active = this._scopeActiveTab(scope);
274
+ if (!active) return;
275
+ const up = this.taxonomy.parentOf(active.kind, active.props || {});
276
+ const parentKind = up ? up.kind : this.taxonomy.parentKindFor(active.kind);
277
+ if (parentKind) this._navigateInScope(scope, parentKind, up ? (up.props || {}) : {});
278
+ }
279
+
280
+ /**
281
+ * C35. CLIMB TO A LEVEL: what a breadcrumb crumb means.
282
+ *
283
+ * `navigate` goes to exactly the target. A crumb says something different:
284
+ * "take me up to this level of where I am". From a record, the level it
285
+ * names is usually a list of the record's own section, and when such a list
286
+ * is already open that is the SAME request Backspace makes, so it goes
287
+ * through the same rule, `_returnToOpenList`, and closes the record onto the
288
+ * open list instead of turning the record into a second copy of it.
289
+ *
290
+ * Every crumb calls this, so no crumb needs to know whether it is the
291
+ * immediate parent. A crumb that names an entity (an epic above a story), a
292
+ * record with no list of its section open, or content that is not a record
293
+ * navigates in place, exactly as `navigate(…, { dest: 'origin' })` would.
294
+ *
295
+ * @param {string} kind
296
+ * @param {object} [props]
297
+ * @param {{ctx?: object}} [opts] the crumb's tile or window context
298
+ */
299
+ navigateUp(kind, props = {}, { ctx = null } = {}) {
300
+ const scope = this._scopeFromCtx(ctx);
301
+ if (!scope) return this.navigate(kind, props, { ctx, dest: 'origin' });
302
+ const active = this._scopeActiveTab(scope);
303
+ const aimsAtOwnList = !_isEntityTab({ props })
304
+ && _isEntityTab(active)
305
+ && this.taxonomy.topNavFor(kind) === this.taxonomy.topNavFor(active.kind);
306
+ if (aimsAtOwnList && this._returnToOpenList(scope)) return;
307
+ this._navigateInScope(scope, kind, props);
327
308
  }
328
309
 
329
310
  /** True when `navigateBack` would do something user-visible — i.e.
@@ -372,137 +353,181 @@ export class WindowManager {
372
353
  this._persist();
373
354
  }
374
355
 
375
- /** Navigate inside the current tab — preserves every other tab in
376
- * the leaf. Used by Backspace + breadcrumb segments + anything
377
- * else that should walk WITHIN the tile rather than reset it. */
378
- /**
379
- * C32. BACK FROM AN OPENED RECORD RETURNS TO THE LIST, IT DOES NOT MAKE ONE.
380
- *
381
- * The gesture this is for: a list tab, a record opened from it into a NEW
382
- * tab, and Backspace in that record. With nothing left in the record tab's
383
- * own history, step (3) of `navigateBack` walks the taxonomy up and REWRITES
384
- * the record tab into its parent — so the tile ends up showing the list
385
- * twice, once in the tab you started from and once in the tab you just left.
386
- * The user's model is a browser's: you came from the list, the list is still
387
- * open, so going back means closing this and being on the list again.
388
- *
389
- * So: when the active tab is an ENTITY — its props carry an `id`, the same
390
- * test `swapToPage` uses for "the caller asked for a specific entity" — look
391
- * for an open tab of the SAME section that is NOT one. That is the list or
392
- * landing page the record belongs to. Close the record and activate it.
393
- *
394
- * NEAREST TO THE LEFT FIRST. New tabs are appended at the end, so the tab a
395
- * record was opened from sits to its left; when several lists of the section
396
- * are open, the nearest one on that side is the one it most plausibly came
397
- * from. The right side is searched only after the left has nothing.
398
- *
399
- * DECLINES, and leaves step (3) to walk up in place, whenever there is no
400
- * such tab: a record that is the tile's only tab, or one whose section has no
401
- * list open. That keeps the one guarantee the whole rule exists for — Back
402
- * never leaves two copies of a list — without ever closing a record into a
403
- * tile that has nothing of its own to show.
404
- *
405
- * Matching is by SECTION (`topNavFor`) and not by kind, because the list a
406
- * record was opened from is often not the record's taxonomy parent: a root
407
- * landing page that renders the section, or a list that a flattened taxonomy
408
- * files beside the record rather than above it.
409
- *
410
- * @returns {boolean} whether it closed a tab
411
- */
412
- _backToOpenList(leafId) {
356
+ // ── Back and up: one scope model, one rule ─────────────────────
357
+ //
358
+ // A SCOPE is where a navigation happens: `{ leafId }` for a tile or
359
+ // `{ windowId }` for a floating window. Backspace and `navigateUp` both
360
+ // resolve one, and everything below works on a scope, so tiles and windows
361
+ // cannot drift into two versions of the same behaviour.
362
+
363
+ /** The scope Backspace acts on. C34: a floating window the user last
364
+ * clicked in, because the tree's focus never moves to a window; else the
365
+ * focused content tile; else the primary tile. Panels are never a scope:
366
+ * Backspace from the nav panel walks the content area. */
367
+ _backScope() {
368
+ if (this._backWindowId && this._windowToLeaf.has(this._backWindowId)) {
369
+ return { windowId: this._backWindowId };
370
+ }
371
+ this._backWindowId = null;
413
372
  const tree = this._tree();
414
- const leaf = tree.get(leafId);
415
- if (!leaf || leaf.kind !== 'leaf') return false;
416
- const tabs = Array.isArray(leaf.tabs) ? leaf.tabs : [];
417
- if (tabs.length < 2) return false;
418
- const activeIdx = Math.max(0, Math.min(tabs.length - 1, leaf.activeTabIdx || 0));
419
- const active = tabs[activeIdx];
420
- if (!_isEntityTab(active)) return false;
421
- const section = this.taxonomy.topNavFor(active.kind);
422
- if (!section) return false;
373
+ const focusedId = tree.focusedLeafId;
374
+ const focusedKind = focusedId ? tree.get(focusedId)?.content?.kind || '' : '';
375
+ const id = (focusedId && focusedKind
376
+ && !focusedKind.startsWith('panel:')
377
+ && focusedKind !== 'window-placeholder')
378
+ ? focusedId : tree.primaryLeafId();
379
+ return id ? { leafId: id } : null;
380
+ }
423
381
 
424
- const isList = (t) => t && !_isEntityTab(t) && this.taxonomy.topNavFor(t.kind) === section;
425
- let target = -1;
426
- for (let i = activeIdx - 1; i >= 0 && target < 0; i--) if (isList(tabs[i])) target = i;
427
- for (let i = activeIdx + 1; i < tabs.length && target < 0; i++) if (isList(tabs[i])) target = i;
428
- if (target < 0) return false;
382
+ /** The scope a content context names: its window, or its tile. Null for a
383
+ * panel or a context the WM does not know. */
384
+ _scopeFromCtx(ctx) {
385
+ if (ctx?.windowId && this._windowToLeaf.has(ctx.windowId)) return { windowId: ctx.windowId };
386
+ const leaf = ctx?.leafId ? this._tree().get(ctx.leafId) : null;
387
+ const k = leaf?.content?.kind || '';
388
+ if (leaf && leaf.kind === 'leaf' && k && !k.startsWith('panel:')) return { leafId: leaf.id };
389
+ return null;
390
+ }
429
391
 
430
- tree.removeLeafTab(leafId, activeIdx);
431
- // Removing the active tab shifts everything after it one to the left.
432
- tree.setActiveLeafTab(leafId, target > activeIdx ? target - 1 : target);
433
- tree.focus(leafId);
392
+ /** A scope's tab list and active index. A window promoted with no strip, or
393
+ * a tile with no tab array, reads as a single tab. */
394
+ _scopeTabs(scope) {
395
+ if (scope?.windowId) {
396
+ const rec = this._windowToLeaf.get(scope.windowId);
397
+ if (!rec) return null;
398
+ const tabs = Array.isArray(rec.tabs) && rec.tabs.length ? rec.tabs : [rec.original].filter(Boolean);
399
+ return { tabs, activeIdx: Math.max(0, Math.min(tabs.length - 1, rec.activeTabIdx || 0)) };
400
+ }
401
+ const leaf = scope?.leafId ? this._tree().get(scope.leafId) : null;
402
+ if (!leaf || leaf.kind !== 'leaf') return null;
403
+ const tabs = Array.isArray(leaf.tabs) && leaf.tabs.length ? leaf.tabs : [leaf.content].filter(Boolean);
404
+ return { tabs, activeIdx: Math.max(0, Math.min(tabs.length - 1, leaf.activeTabIdx || 0)) };
405
+ }
406
+
407
+ _scopeActiveTab(scope) {
408
+ const src = this._scopeTabs(scope);
409
+ return src ? src.tabs[src.activeIdx] || null : null;
410
+ }
411
+
412
+ /** Replace what a scope shows, in place: the tile's active tab (recording
413
+ * history) or the window's content. */
414
+ _navigateInScope(scope, kind, props = {}) {
415
+ if (scope.windowId) return this.openInWindow(scope.windowId, kind, props);
416
+ return this.navigateActiveTab(scope.leafId, kind, props);
417
+ }
418
+
419
+ /** Pop the active tab's history, if it has any. */
420
+ _popTabHistory(id) {
421
+ const tree = this._tree();
422
+ const prior = tree.popActiveTabHistory?.(id);
423
+ if (!prior || !prior.kind) return false;
424
+ // Cross-page back entries carry a `topNav` marker — pop
425
+ // routes via swapToPage so we land on the correct page.
426
+ const curKind = tree.get(id)?.content?.kind;
427
+ const curTopNav = curKind ? this.taxonomy.topNavFor(curKind) : null;
428
+ const title = prior.title || _tabTitle(prior.kind, prior.props);
429
+ if (prior.topNav && curTopNav && prior.topNav !== curTopNav) {
430
+ tree.swapToPage?.(id, { kind: prior.kind, props: prior.props || {}, title },
431
+ curTopNav, prior.topNav, { recordHistory: false });
432
+ } else {
433
+ tree.replaceActiveTabContent(id, { kind: prior.kind, props: prior.props || {} },
434
+ title, { recordHistory: false });
435
+ }
436
+ tree.focus(id);
434
437
  this.renderer.render();
435
438
  this._persist();
436
- this._notifyChange('navigate-back-close');
439
+ this._notifyChange('navigate-back');
437
440
  return true;
438
441
  }
439
442
 
440
443
  /**
441
- * C34. Back while a floating window is the thing being read. Returns true
442
- * when it handled the press, false to let the tile path run.
443
- *
444
- * Only the window's ACTIVE tab is considered, in this order:
445
- * (1) with `backToOpenList`, a record closes onto an open list of its
446
- * section: first among the window's own tabs, then any tile of the
447
- * window's desktop, primary tile first. Closing the last tab closes
448
- * the window, which discards it (a plain close never docks back).
449
- * (2) otherwise it walks up IN the window, exactly as a tile does.
450
- * A window whose content has nowhere to go still counts as handled: the
451
- * press was aimed at it, and moving the tile behind it would be the bug.
444
+ * C32 / C34 / C35. BACK FROM A RECORD RETURNS TO THE LIST; IT DOES NOT MAKE ONE.
445
+ *
446
+ * The gesture this is for: a list, a record opened from it into a new tab,
447
+ * then Backspace or the list's crumb in that record. Rewriting the record
448
+ * into its parent would leave the list open twice. The user's model is a
449
+ * browser's: the list is still open, so going back closes the record and
450
+ * shows the list again. This function is the ONLY place that rule lives.
451
+ *
452
+ * Applies when `backToOpenList` is on and the scope's active tab is a RECORD
453
+ * (its props carry an `id`). The list is an open tab that is not a record
454
+ * and belongs to the same SECTION (`topNavFor`), matched by section and not
455
+ * by kind because a record's list is often not its taxonomy parent: a root
456
+ * landing that renders the section, or a list filed beside the record.
457
+ *
458
+ * Where it looks, in order:
459
+ * - the scope's own tabs, nearest to the LEFT first (new tabs are appended,
460
+ * so the tab a record came from sits to its left), then to the right;
461
+ * - for a WINDOW only, then any tile of the window's desktop, primary tile
462
+ * first, activating a list tab there. Closing a window's last tab closes
463
+ * the window, which discards it; a plain close never docks back.
464
+ *
465
+ * Declines, leaving the caller to navigate in place, when there is no such
466
+ * list, so Back never closes a record into a tile with nothing to show.
467
+ *
468
+ * @returns {boolean} whether it closed the record
452
469
  */
453
- _navigateBackInWindow() {
454
- const winId = this._backWindowId;
455
- const rec = winId ? this._windowToLeaf.get(winId) : null;
456
- if (!rec) { this._backWindowId = null; return false; }
457
- const tabs = Array.isArray(rec.tabs) && rec.tabs.length ? rec.tabs : [rec.original].filter(Boolean);
458
- const activeIdx = Math.max(0, Math.min(tabs.length - 1, rec.activeTabIdx || 0));
470
+ _returnToOpenList(scope) {
471
+ if (!this.backToOpenList) return false;
472
+ const src = this._scopeTabs(scope);
473
+ if (!src) return false;
474
+ const { tabs, activeIdx } = src;
459
475
  const active = tabs[activeIdx];
460
- if (!active) return true;
461
- const section = this.backToOpenList && _isEntityTab(active)
462
- ? this.taxonomy.topNavFor(active.kind) : null;
476
+ if (!_isEntityTab(active)) return false;
477
+ const section = this.taxonomy.topNavFor(active.kind);
478
+ if (!section) return false;
463
479
  const isList = (t) => !!t && !_isEntityTab(t) && this.taxonomy.topNavFor(t.kind) === section;
464
480
 
465
- if (section) {
466
- // (1a) a list among the window's own tabs, nearest to the left first.
467
- let own = -1;
468
- for (let i = activeIdx - 1; i >= 0 && own < 0; i--) if (isList(tabs[i])) own = i;
469
- for (let i = activeIdx + 1; i < tabs.length && own < 0; i++) if (isList(tabs[i])) own = i;
470
- if (own >= 0 && Array.isArray(rec.tabs) && rec.tabs.length > 1) {
471
- this._windowTabAction(winId, 'close', { idx: activeIdx });
472
- this.showWindowTab(winId, own > activeIdx ? own - 1 : own);
481
+ const own = _nearestIndex(tabs, activeIdx, isList);
482
+ if (own >= 0 && tabs.length > 1) {
483
+ const landing = own > activeIdx ? own - 1 : own;
484
+ if (scope.windowId) {
485
+ this._windowTabAction(scope.windowId, 'close', { idx: activeIdx });
486
+ this.showWindowTab(scope.windowId, landing);
473
487
  return true;
474
488
  }
475
- // (1b) a list in a tile of the window's desktop.
476
- const desk = this.desktops.desktops[rec.desktopIdx] || this.desktops.active();
477
- const tree = desk.tree;
478
- const leaves = tree.leaves?.() || [];
479
- const primaryId = tree.primaryLeafId();
480
- const ordered = [...leaves.filter((l) => l.id === primaryId), ...leaves.filter((l) => l.id !== primaryId)];
481
- for (const leaf of ordered) {
482
- const ltabs = Array.isArray(leaf.tabs) ? leaf.tabs : [];
483
- const cur = Math.max(0, Math.min(ltabs.length - 1, leaf.activeTabIdx || 0));
484
- const hit = isList(ltabs[cur]) ? cur : ltabs.findIndex(isList);
485
- if (hit < 0) continue;
486
- this._backWindowId = null;
487
- if (Array.isArray(rec.tabs) && rec.tabs.length > 1) this._windowTabAction(winId, 'close', { idx: activeIdx });
488
- else { try { rec.window.close({ force: true }); } catch { /* already gone */ } }
489
- if (this.desktops.active().tree === tree) {
490
- tree.setActiveLeafTab(leaf.id, hit);
491
- tree.focus(leaf.id);
492
- this.renderer.render();
493
- }
494
- this._persist();
495
- this._notifyChange('navigate-back-close');
496
- return true;
489
+ const tree = this._tree();
490
+ tree.removeLeafTab(scope.leafId, activeIdx);
491
+ tree.setActiveLeafTab(scope.leafId, landing);
492
+ tree.focus(scope.leafId);
493
+ this.renderer.render();
494
+ this._persist();
495
+ this._notifyChange('navigate-back-close');
496
+ return true;
497
+ }
498
+ if (!scope.windowId) return false;
499
+
500
+ const rec = this._windowToLeaf.get(scope.windowId);
501
+ const tree = (this.desktops.desktops[rec.desktopIdx] || this.desktops.active()).tree;
502
+ const primaryId = tree.primaryLeafId();
503
+ const leaves = tree.leaves?.() || [];
504
+ const ordered = [...leaves.filter((l) => l.id === primaryId), ...leaves.filter((l) => l.id !== primaryId)];
505
+ for (const leaf of ordered) {
506
+ const ltabs = Array.isArray(leaf.tabs) ? leaf.tabs : [];
507
+ const cur = Math.max(0, Math.min(ltabs.length - 1, leaf.activeTabIdx || 0));
508
+ const hit = isList(ltabs[cur]) ? cur : ltabs.findIndex(isList);
509
+ if (hit < 0) continue;
510
+ if (this._backWindowId === scope.windowId) this._backWindowId = null;
511
+ if (Array.isArray(rec.tabs) && rec.tabs.length > 1) {
512
+ this._windowTabAction(scope.windowId, 'close', { idx: activeIdx });
513
+ } else {
514
+ try { rec.window.close({ force: true }); } catch { /* already gone */ }
515
+ }
516
+ if (this.desktops.active().tree === tree) {
517
+ tree.setActiveLeafTab(leaf.id, hit);
518
+ tree.focus(leaf.id);
519
+ this.renderer.render();
497
520
  }
521
+ this._persist();
522
+ this._notifyChange('navigate-back-close');
523
+ return true;
498
524
  }
499
- // (2) walk up inside the window.
500
- const up = this.taxonomy.parentOf?.(active.kind, active.props || {});
501
- const parentKind = up ? up.kind : this.taxonomy.parentKindFor(active.kind);
502
- if (parentKind) this.openInWindow(winId, parentKind, up ? (up.props || {}) : {});
503
- return true;
525
+ return false;
504
526
  }
505
527
 
528
+ /** Navigate inside the current tab — preserves every other tab in
529
+ * the leaf. Used by Backspace + breadcrumb segments + anything
530
+ * else that should walk WITHIN the tile rather than reset it. */
506
531
  navigateActiveTab(leafId, kind, props = {}) {
507
532
  const tree = this._tree();
508
533
  const leaf = tree.get(leafId);
@@ -640,6 +665,20 @@ export class WindowManager {
640
665
  tree.swapToPage(leafId,
641
666
  { kind, props, title },
642
667
  currentTopNav, targetTopNav);
668
+ // C35. THE SAME PAGE WITH DIFFERENT PROPS IS A DIFFERENT VIEW. swapToPage
669
+ // finds an existing tab by kind + props.id, and a list has no id, so a
670
+ // filter clicked while that list is showing matched the open list tab
671
+ // and re-activated it unchanged: the click did nothing. The active tab
672
+ // must carry the requested props. A BARE open (no props, a top-nav
673
+ // click) is left alone, because putting back the tab you left, filter
674
+ // and all, is the point of archiving page tabs. BugDesk's own tiling
675
+ // fork carried this correction; it is upstream now.
676
+ const _leaf = tree.get(leafId);
677
+ const _active = _leaf?.tabs?.[_leaf.activeTabIdx];
678
+ if (_active && _active.kind === kind && Object.keys(props || {}).length > 0
679
+ && JSON.stringify(_active.props || {}) !== JSON.stringify(props || {})) {
680
+ tree.replaceActiveTabContent(leafId, { kind, props }, title, { recordHistory: false });
681
+ }
643
682
  tree.focus(leafId);
644
683
  if (spawned) this._canonicalize(tree, this.desktops.active());
645
684
  this.renderer.render();
@@ -3807,6 +3846,14 @@ function _swapSiblings(tree, aId, bId) {
3807
3846
 
3808
3847
  /** A tab showing ONE record rather than a list: its props name an `id`. The same
3809
3848
  * test `swapToPage` applies to "the caller asked for a specific entity". */
3849
+ /** The index nearest to `from` whose tab passes `test`: leftward first, then
3850
+ * rightward. -1 when none does. */
3851
+ function _nearestIndex(tabs, from, test) {
3852
+ for (let i = from - 1; i >= 0; i--) if (test(tabs[i])) return i;
3853
+ for (let i = from + 1; i < tabs.length; i++) if (test(tabs[i])) return i;
3854
+ return -1;
3855
+ }
3856
+
3810
3857
  function _isEntityTab(tab) {
3811
3858
  return !!tab && tab.props != null && tab.props.id != null && tab.props.id !== '';
3812
3859
  }