flexdesk 0.2.0 → 0.4.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.
Files changed (58) hide show
  1. package/README.md +26 -0
  2. package/css/base.css +2243 -872
  3. package/css/flexdesk.css +1375 -14
  4. package/css/overrides.css +44 -0
  5. package/css/tokens.css +45 -0
  6. package/dist/charts.js +5 -3
  7. package/dist/charts.js.map +1 -1
  8. package/dist/{chunk-DVU44T77.js → chunk-ELXVW542.js} +196 -75
  9. package/dist/chunk-ELXVW542.js.map +7 -0
  10. package/dist/chunk-LH5TSOZW.js +1237 -0
  11. package/dist/chunk-LH5TSOZW.js.map +7 -0
  12. package/dist/{chunk-TLZUUFOE.js → chunk-O5OHMWBB.js} +10 -2
  13. package/dist/chunk-O5OHMWBB.js.map +7 -0
  14. package/dist/{chunk-CT4YXXLP.js → chunk-QIU5S2RU.js} +371 -73
  15. package/dist/chunk-QIU5S2RU.js.map +7 -0
  16. package/dist/chunk-QNQHQ24V.js +408 -0
  17. package/dist/chunk-QNQHQ24V.js.map +7 -0
  18. package/dist/{chunk-DRYCDMEG.js → chunk-XKDTIT4Q.js} +168 -12
  19. package/dist/chunk-XKDTIT4Q.js.map +7 -0
  20. package/dist/editor.js +3 -380
  21. package/dist/editor.js.map +3 -3
  22. package/dist/flexdesk.css +1375 -14
  23. package/dist/tiles.js +168 -41
  24. package/dist/tiles.js.map +2 -2
  25. package/dist/tokens.css +45 -0
  26. package/dist/widgets.js +44 -14
  27. package/dist/widgets.js.map +2 -2
  28. package/dist/wm.js +3140 -157
  29. package/dist/wm.js.map +4 -4
  30. package/package.json +3 -2
  31. package/src/charts/chart_types.js +167 -0
  32. package/src/charts/plotly_wrapper.js +178 -10
  33. package/src/editor/notebook_tab_bar.js +39 -3
  34. package/src/tiles/tile_base.js +143 -35
  35. package/src/tiles/tile_grid.js +52 -1
  36. package/src/tiling/command_palette.js +71 -18
  37. package/src/tiling/desktops.js +36 -12
  38. package/src/tiling/keymap.js +24 -4
  39. package/src/tiling/shell.js +156 -25
  40. package/src/tiling/tab_strip.js +184 -0
  41. package/src/tiling/tile_breadcrumb.js +34 -2
  42. package/src/tiling/tile_renderer.js +1386 -21
  43. package/src/tiling/tile_tab_menu.js +101 -0
  44. package/src/tiling/tile_tree.js +115 -11
  45. package/src/tiling/wm.js +2375 -84
  46. package/src/tiling/zoom.js +248 -0
  47. package/src/ui/components/action_dropdown.js +34 -3
  48. package/src/ui/components/autocomplete_field.js +65 -13
  49. package/src/ui/components/context_menu.js +79 -8
  50. package/src/ui/components/data_table.js +508 -84
  51. package/src/ui/components/managed_window.js +928 -36
  52. package/src/ui/components/modal.js +214 -8
  53. package/dist/chunk-CT4YXXLP.js.map +0 -7
  54. package/dist/chunk-DRYCDMEG.js.map +0 -7
  55. package/dist/chunk-DVU44T77.js.map +0 -7
  56. package/dist/chunk-TLZUUFOE.js.map +0 -7
  57. package/dist/chunk-UCJ2WD4D.js +0 -625
  58. package/dist/chunk-UCJ2WD4D.js.map +0 -7
@@ -320,6 +320,107 @@ export function openTileTabMenu({
320
320
  }
321
321
 
322
322
 
323
+ /** Open a compact menu anchored to the leaf's hamburger button that
324
+ * lists the leaf's OPEN TABS for quick switching (a browser-style tab
325
+ * overflow list). Clicking a row switches to that tab. Closes on
326
+ * Escape, click-outside, or selection.
327
+ *
328
+ * Anchored to the button: the tab strip sits at the bottom of the tile,
329
+ * so the menu opens UPWARD, its bottom edge pinned just above the
330
+ * hamburger at (x, y) = the button's top-left corner.
331
+ *
332
+ * This is deliberately NOT the same surface as `openTileTabMenu` above
333
+ * (which browses a page's content sources to open NEW tabs). The
334
+ * hamburger's job is "show me the tabs I already have open here".
335
+ *
336
+ * @param {{x:number, y:number,
337
+ * tabs: Array<{kind:string, title?:string}>,
338
+ * activeIdx?:number,
339
+ * onPick:(idx:number)=>void}} opts
340
+ * @returns {() => void} teardown closure (the menu also self-disposes).
341
+ */
342
+ export function openTileTabSwitcher({ x, y, tabs, activeIdx = 0, onPick }) {
343
+ const list = Array.isArray(tabs) ? tabs : [];
344
+ if (list.length === 0) return () => {};
345
+
346
+ const overlay = document.createElement('div');
347
+ overlay.className = 'twm-tile-tabswitch-overlay';
348
+ const rows = list.map((t, i) => `
349
+ <li class="twm-tile-tabswitch__item${i === activeIdx ? ' twm-tile-tabswitch__item--on' : ''}"
350
+ data-idx="${i}" role="option" aria-selected="${i === activeIdx}">
351
+ <span class="material-symbols-outlined twm-tile-tabswitch__check">${i === activeIdx ? 'check' : ''}</span>
352
+ <span class="twm-tile-tabswitch__label">${_esc(t.title || t.kind || 'Tab')}</span>
353
+ </li>
354
+ `).join('');
355
+ overlay.innerHTML = `
356
+ <div class="twm-tile-tabswitch" role="dialog" aria-label="Open tabs">
357
+ <header class="twm-tile-tabswitch__head">
358
+ <span class="material-symbols-outlined">tab</span>
359
+ <span class="twm-tile-tabswitch__head-label">Open tabs</span>
360
+ </header>
361
+ <ul class="twm-tile-tabswitch__list" role="listbox">${rows}</ul>
362
+ </div>
363
+ `;
364
+ document.body.appendChild(overlay);
365
+
366
+ const panel = overlay.querySelector('.twm-tile-tabswitch');
367
+ const W = 260;
368
+ panel.style.position = 'fixed';
369
+ panel.style.left = `${Math.max(8, Math.min(window.innerWidth - W - 8, x))}px`;
370
+ // Open upward: pin the panel's bottom just above the hamburger.
371
+ panel.style.bottom = `${Math.max(8, window.innerHeight - y + 4)}px`;
372
+ panel.style.maxHeight = `${Math.max(120, y - 16)}px`;
373
+
374
+ let alive = true;
375
+ let cursor = Math.max(0, Math.min(list.length - 1, activeIdx));
376
+
377
+ const close = () => {
378
+ if (!alive) return;
379
+ alive = false;
380
+ document.removeEventListener('mousedown', onOutside, true);
381
+ document.removeEventListener('keydown', onKey, true);
382
+ overlay.remove();
383
+ };
384
+ const onOutside = (ev) => { if (!overlay.contains(ev.target)) close(); };
385
+ const pick = (idx) => {
386
+ close();
387
+ try { onPick?.(idx); }
388
+ catch (err) { console.warn('[tile-tab-switch] pick failed', err); }
389
+ };
390
+ const paint = () => {
391
+ overlay.querySelectorAll('.twm-tile-tabswitch__item').forEach((li) => {
392
+ const on = Number(li.dataset.idx) === cursor;
393
+ li.classList.toggle('twm-tile-tabswitch__item--cursor', on);
394
+ if (on) li.scrollIntoView({ block: 'nearest' });
395
+ });
396
+ };
397
+ const onKey = (ev) => {
398
+ if (!alive || ev.isComposing) return;
399
+ switch (ev.key) {
400
+ case 'Escape': ev.preventDefault(); close(); return;
401
+ case 'ArrowDown': ev.preventDefault();
402
+ cursor = Math.min(list.length - 1, cursor + 1); paint(); return;
403
+ case 'ArrowUp': ev.preventDefault();
404
+ cursor = Math.max(0, cursor - 1); paint(); return;
405
+ case 'Home': ev.preventDefault(); cursor = 0; paint(); return;
406
+ case 'End': ev.preventDefault(); cursor = list.length - 1; paint(); return;
407
+ case 'Enter': ev.preventDefault(); pick(cursor); return;
408
+ }
409
+ };
410
+ overlay.querySelectorAll('.twm-tile-tabswitch__item').forEach((li) => {
411
+ const idx = Number(li.dataset.idx);
412
+ li.addEventListener('mousemove', () => {
413
+ if (cursor !== idx) { cursor = idx; paint(); }
414
+ });
415
+ li.addEventListener('click', () => pick(idx));
416
+ });
417
+ document.addEventListener('mousedown', onOutside, true);
418
+ document.addEventListener('keydown', onKey, true);
419
+ paint();
420
+ return close;
421
+ }
422
+
423
+
323
424
  /** Match a shaped record (`{id, label, hint}`) against `query`.
324
425
  * Case-insensitive substring against any field. Mirrors the command
325
426
  * palette's matching so search semantics are consistent across both
@@ -298,15 +298,27 @@ export class TileTree {
298
298
  }));
299
299
  n.activeTabIdx = Math.max(0,
300
300
  Math.min(n.tabs.length - 1, saved.activeTabIdx || 0));
301
- // The caller asked for a specific entity (props.id) OR for a
302
- // page kind that ISN'T the nav category itself (e.g. `settings`
303
- // lives under the `home` topNav). In both cases we must surface
304
- // the requested target rather than silently showing whatever
305
- // the restored page happened to hold. A bare top-nav click
306
- // (target.kind === targetTopNav, no id) falls through and just
307
- // restores the saved tabs as-is.
301
+ // The caller asked for a specific entity (props.id), for a page
302
+ // kind that ISN'T the nav category itself (e.g. `settings` lives
303
+ // under the `home` topNav), or for this page WITH PARTICULAR PROPS
304
+ // (a filter, an ad-hoc expression). In all three we must surface the
305
+ // requested target rather than silently showing whatever the
306
+ // restored page happened to hold.
307
+ //
308
+ // THE THIRD CASE WAS MISSING, and it is not a corner: "show me
309
+ // everything open on bo" is `kind: 'backlog'` with an `expr` and no
310
+ // id — no entity, and `backlog` IS its own nav category — so it fell
311
+ // through to the bare-restore branch and put back whatever the page
312
+ // last held. If that was a record you had been reading, the tile did
313
+ // not visibly change at all, and the click looked broken. (Found and
314
+ // fixed in BugDesk's fork first; this is that fix, upstream.)
315
+ //
316
+ // A BARE page switch still restores as-is, which is the whole point
317
+ // of archiving tabs: clicking a top-nav chip carries no props and
318
+ // must put back the tab you left open.
308
319
  const wantsTarget = target.props?.id != null
309
- || target.kind !== targetTopNav;
320
+ || target.kind !== targetTopNav
321
+ || Object.keys(target.props || {}).length > 0;
310
322
  if (wantsTarget) {
311
323
  const wantId = target.props?.id != null;
312
324
  const matchIdx = n.tabs.findIndex((t) =>
@@ -489,9 +501,19 @@ export class TileTree {
489
501
  };
490
502
  n.tabs = Array.isArray(n.tabs) ? n.tabs : [];
491
503
  n.tabs.push(tab);
492
- n.activeTabIdx = n.tabs.length - 1;
493
- _syncActiveTab(n);
494
- return n.activeTabIdx;
504
+ // `background` appends WITHOUT switching to it — the caller asked for a
505
+ // tab to come back to, not for the page to change under them. Without
506
+ // this, "open in a background tab" was indistinguishable from an
507
+ // ordinary click: the tab arrived and took the screen with it.
508
+ //
509
+ // Returns the new tab's index either way, NOT `activeTabIdx`: for a
510
+ // background tab those differ, and a caller that wants to address the
511
+ // tab it just made needs the index of that tab.
512
+ if (!opts.background) {
513
+ n.activeTabIdx = n.tabs.length - 1;
514
+ _syncActiveTab(n);
515
+ }
516
+ return n.tabs.length - 1;
495
517
  }
496
518
 
497
519
  /** Switch the active tab on a leaf. No-op if `idx` is out of range. */
@@ -578,6 +600,88 @@ export class TileTree {
578
600
  return true;
579
601
  }
580
602
 
603
+ /**
604
+ * C33. MOVE ONE TAB FROM ONE LEAF TO ANOTHER, AS ONE MUTATION.
605
+ *
606
+ * Every other tab operation on this class takes ONE `leafId`, and that was
607
+ * a complete description of the model until a tab could be dragged into a
608
+ * different tile: `appendLeafTab`, `removeLeafTab`, `moveLeafTab` and the
609
+ * three bulk closes all begin and end inside a single leaf. The renderer's
610
+ * refusal to wire `onDropFromOtherPane` named this absence as one of its
611
+ * two reasons (`tile_renderer.js`, `_topTabCallbacks`); this is that half.
612
+ *
613
+ * ONE FUNCTION RATHER THAN A COMPOSE, and that is why it lives here rather
614
+ * than in the WM. `removeLeafTab` then `appendLeafTab` is two mutations
615
+ * with a moment between them in which the tab exists nowhere — and the
616
+ * second can FAIL (a panel destination refuses tabs), which leaves the tree
617
+ * short one tab and nothing on screen saying where it went. Every guard is
618
+ * therefore taken before the first splice.
619
+ *
620
+ * TWO NODES ARE RE-SYNCED, WHICH IS WHAT MAKES THIS DIFFERENT. `tabs` is
621
+ * the source of truth and `content`/`title` mirror `tabs[active]`
622
+ * (`_syncActiveTab`). Every existing mutation touches one leaf, so one
623
+ * re-sync is right; this one touches two, and skipping the SOURCE's leaves
624
+ * a pane whose chrome still names — and whose body still mounts — a tab it
625
+ * no longer holds. That is the mistake this operation invites, and it is
626
+ * what `web/js/shell/tab_drop.test.mjs` asserts against in the consumer.
627
+ *
628
+ * A same-leaf call is a REORDER and delegates, so there is one
629
+ * implementation of "a tab changed position within its strip" rather than
630
+ * two that will disagree about the active-index clamp.
631
+ *
632
+ * @param {string} fromLeafId
633
+ * @param {number} fromIdx
634
+ * @param {string} toLeafId
635
+ * @param {number} [toIdx=-1] where to insert; -1 (or past the end) appends
636
+ * @returns {{ok: boolean, toIdx: number, emptied: boolean}|null} null when
637
+ * the move was refused. `emptied` tells the caller the source pane now
638
+ * holds nothing, which is its cue to re-seed rather than leave a blank
639
+ * tile — the never-empty-tile invariant is the WM's to keep, not this
640
+ * class's.
641
+ */
642
+ moveTabToLeaf(fromLeafId, fromIdx, toLeafId, toIdx = -1) {
643
+ const from = this.get(fromLeafId);
644
+ const to = this.get(toLeafId);
645
+ if (!from || from.kind !== 'leaf') return null;
646
+ if (!to || to.kind !== 'leaf') return null;
647
+ const tabs = Array.isArray(from.tabs) ? from.tabs : [];
648
+ if (!Number.isInteger(fromIdx) || fromIdx < 0 || fromIdx >= tabs.length) return null;
649
+ // Panel tiles are chrome, not content, and never grow tabs — the same
650
+ // refusal `appendLeafTab` makes, taken here BEFORE anything is spliced
651
+ // so a refused destination cannot cost the source its tab.
652
+ if (_isPanel(to)) return null;
653
+ if (fromLeafId === toLeafId) {
654
+ const dest = (!Number.isInteger(toIdx) || toIdx < 0 || toIdx >= tabs.length)
655
+ ? tabs.length - 1 : toIdx;
656
+ if (!this.moveLeafTab(fromLeafId, fromIdx, dest)) return null;
657
+ return { ok: true, toIdx: dest, emptied: false };
658
+ }
659
+
660
+ const [moved] = from.tabs.splice(fromIdx, 1);
661
+ // THE SOURCE'S ACTIVE INDEX, WITH `removeLeafTab`'S OWN THREE BRANCHES.
662
+ // Spelled out rather than delegated because `removeLeafTab` re-syncs
663
+ // and returns, and the splice above has already happened; the
664
+ // arithmetic is the contract, so it is written where it can be compared
665
+ // against the original line for line.
666
+ if (from.tabs.length === 0) from.activeTabIdx = 0;
667
+ else if (fromIdx < from.activeTabIdx) from.activeTabIdx -= 1;
668
+ else if (fromIdx === from.activeTabIdx) from.activeTabIdx = Math.max(0, fromIdx - 1);
669
+
670
+ to.tabs = Array.isArray(to.tabs) ? to.tabs : [];
671
+ const at = (!Number.isInteger(toIdx) || toIdx < 0 || toIdx > to.tabs.length)
672
+ ? to.tabs.length : toIdx;
673
+ to.tabs.splice(at, 0, moved);
674
+ // THE ARRIVING TAB IS THE ONE YOU WANTED TO SEE. A drop is a deliberate
675
+ // act naming one tab and one destination; leaving the destination on
676
+ // whatever it was already showing would make the gesture look like it
677
+ // did nothing, which is the failure mode of every silent move.
678
+ to.activeTabIdx = at;
679
+
680
+ _syncActiveTab(from);
681
+ _syncActiveTab(to);
682
+ return { ok: true, toIdx: at, emptied: from.tabs.length === 0 };
683
+ }
684
+
581
685
  /**
582
686
  * Split a leaf in the given direction; existing content stays in the
583
687
  * original leaf, a new empty leaf is added next to it. Returns the