@jxsuite/studio 5.1.0 → 5.2.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 (103) hide show
  1. package/dist/chunks/{cf-account-picker-qwkm9zrf.js → cf-account-picker-h4dab9bq.js} +10 -10
  2. package/dist/chunks/{cf-settings-71bv5zn9.js → cf-settings-adg74qsk.js} +4 -4
  3. package/dist/chunks/{components-vj72gqtq.js → components-5y78e05d.js} +4 -4
  4. package/dist/chunks/content-source-sgg1sv6n.js +46 -0
  5. package/dist/chunks/{convert-to-repeater-n64h8znc.js → convert-to-repeater-mf1cxgfn.js} +22 -22
  6. package/dist/chunks/entry-commands-w9qsxa6q.js +42 -0
  7. package/dist/chunks/{extension-sections-3hpcambp.js → extension-sections-c6zwzbre.js} +26 -26
  8. package/dist/chunks/{file-ops-8yhd83xr.js → file-ops-vrpq1jjk.js} +22 -22
  9. package/dist/chunks/{files-hnxqxtsg.js → files-msaf95y6.js} +31 -30
  10. package/dist/chunks/{format-host-779sfp2n.js → format-host-f64fnesp.js} +2 -2
  11. package/dist/chunks/{fs-events-b88z7g4h.js → fs-events-3y6y0nrm.js} +4 -4
  12. package/dist/chunks/{git-panel-xwj8fpab.js → git-panel-bv5e22j0.js} +23 -22
  13. package/dist/chunks/grid-view-1p6hsyjx.css +8406 -0
  14. package/dist/chunks/grid-view-paj67nr5.js +15322 -0
  15. package/dist/chunks/{iframe-host-syr0f9sb.js → iframe-host-dh5np8vk.js} +13 -13
  16. package/dist/chunks/{layers-ff81wthn.js → layers-1vcfdbet.js} +5 -5
  17. package/dist/chunks/layers-panel-fksbdg4q.js +40 -0
  18. package/dist/chunks/{media-picker-cqhhxhhe.js → media-picker-p49nhqhs.js} +13 -13
  19. package/dist/chunks/menu-qht48vnc.js +15 -0
  20. package/dist/chunks/{monaco-buffer-bhy3hb6m.js → monaco-buffer-pykwbm92.js} +3 -3
  21. package/dist/chunks/{notify-628t0anh.js → notify-42gk8ha1.js} +2 -2
  22. package/dist/chunks/{parse-0zx14y15.js → parse-reg9eevt.js} +2 -2
  23. package/dist/chunks/publish-panel-vz23wwmg.js +27 -0
  24. package/dist/chunks/quick-search-e9rp74m8.js +46 -0
  25. package/dist/chunks/{references-qvvxej7h.js → references-h69xp9zz.js} +2 -2
  26. package/dist/chunks/right-panel-j97t2yvy.js +45 -0
  27. package/dist/chunks/{studio-b0k5tqgp.js → studio-01jeck2y.js} +31 -27
  28. package/dist/chunks/{studio-wqz6dggm.js → studio-14d9tq4d.js} +9 -9
  29. package/dist/chunks/{studio-try13h8c.js → studio-15f3kxqe.js} +18 -18
  30. package/dist/chunks/studio-1tbjfqv4.js +146 -0
  31. package/dist/chunks/{studio-vw7mzp6z.js → studio-28vs5k2y.js} +2 -2
  32. package/dist/chunks/{studio-93zafjcm.js → studio-2mdbbc76.js} +10 -10
  33. package/dist/chunks/{studio-7r42c6ca.js → studio-3ntvq6cg.js} +5 -5
  34. package/dist/chunks/{studio-g3jevyjt.js → studio-4c041r8y.js} +2 -2
  35. package/dist/chunks/{studio-3773086z.js → studio-4r14yzyy.js} +4 -4
  36. package/dist/chunks/{studio-3rz5md06.js → studio-4xmjcrg3.js} +6 -6
  37. package/dist/chunks/{studio-syg2tadj.js → studio-5nz00bhh.js} +3 -3
  38. package/dist/chunks/{studio-1knfqgy7.js → studio-5t7ena5z.js} +8 -8
  39. package/dist/chunks/{studio-a79k90n9.js → studio-66arbpc2.js} +8 -8
  40. package/dist/chunks/{studio-b4s9eykd.js → studio-68fydsrj.js} +15 -15
  41. package/dist/chunks/{studio-mhhyamxf.js → studio-9j6sfeba.js} +6 -6
  42. package/dist/chunks/{studio-7xnmgv66.js → studio-a1pswps3.js} +37 -37
  43. package/dist/chunks/{studio-6xa21ej9.js → studio-c04nzbby.js} +2 -2
  44. package/dist/chunks/{studio-nzzpvzx9.js → studio-c07s5ckd.js} +4 -4
  45. package/dist/chunks/{studio-js8nv53n.js → studio-ctm7bhx5.js} +14 -14
  46. package/dist/chunks/{studio-p1dw8wfw.js → studio-d8z5bajd.js} +14 -10
  47. package/dist/chunks/{studio-jxp12rnj.js → studio-en82c639.js} +6 -6
  48. package/dist/chunks/{studio-1z1kcjv4.js → studio-etvx9s2g.js} +11 -11
  49. package/dist/chunks/{studio-k75n7vhp.js → studio-ez11dmdh.js} +74 -36
  50. package/dist/chunks/{studio-qhhm6hbt.js → studio-hdc2jt3z.js} +4 -4
  51. package/dist/chunks/{studio-r6nvacqs.js → studio-hhskq2mr.js} +19 -19
  52. package/dist/chunks/{studio-6ewqv390.js → studio-m1p9b6c3.js} +5 -5
  53. package/dist/chunks/{studio-qk90fgqm.js → studio-mpzwze9x.js} +7 -7
  54. package/dist/chunks/{studio-e1pgga3k.js → studio-ncn9e189.js} +7 -7
  55. package/dist/chunks/{studio-kkqkcp3d.js → studio-p6pmwkvh.js} +10 -10
  56. package/dist/chunks/{studio-aj37eer3.js → studio-qa7fzxan.js} +4 -4
  57. package/dist/chunks/{studio-sbjyc8bt.js → studio-sar8g54p.js} +8 -8
  58. package/dist/chunks/studio-t171hxhe.js +23 -0
  59. package/dist/chunks/{studio-pxbgypmg.js → studio-t3adrr56.js} +17 -14
  60. package/dist/chunks/{studio-hhvs4sdg.js → studio-vn5hske5.js} +10 -4
  61. package/dist/chunks/{studio-5wt9nvhd.js → studio-vt0tj32j.js} +29 -42
  62. package/dist/chunks/{studio-qk4bfsq2.js → studio-wjpgxywa.js} +4 -4
  63. package/dist/chunks/{studio-q3x06vmj.js → studio-wnsyffg1.js} +2 -2
  64. package/dist/chunks/{studio-gb13095a.js → studio-x10fp4tp.js} +59 -20
  65. package/dist/chunks/{studio-8scdew0e.js → studio-x72j58vw.js} +4 -4
  66. package/dist/chunks/{studio-tqgryxwg.js → studio-y4f9fdge.js} +7 -7
  67. package/dist/chunks/{studio-vfq8qpjt.js → studio-ympe0n7a.js} +3 -3
  68. package/dist/chunks/{studio-y9zedyjn.js → studio-z2smx5ce.js} +2 -2
  69. package/dist/chunks/{studio-zdn2g6dt.js → studio-zjjde71a.js} +2 -2
  70. package/dist/chunks/{workspace-t55jdjbx.js → workspace-bnw54cha.js} +18 -10
  71. package/dist/iframe-entry.js +7 -3
  72. package/dist/studio.css +1 -2
  73. package/dist/studio.js +866 -15698
  74. package/package.json +6 -6
  75. package/src/files/files.ts +38 -15
  76. package/src/grid/grid-lazy.ts +59 -0
  77. package/src/grid/grid-open.ts +20 -3
  78. package/src/grid/grid-panel.ts +43 -9
  79. package/src/media/media-open.ts +14 -2
  80. package/src/panels/pane-context.ts +5 -4
  81. package/src/panels/pane-grid.ts +100 -10
  82. package/src/panels/tab-drop.ts +220 -0
  83. package/src/panels/tab-strip.ts +235 -30
  84. package/src/services/ai-models.ts +13 -0
  85. package/src/studio.ts +10 -6
  86. package/src/surfaces/commandbar.json +26 -16
  87. package/src/surfaces/pane-grid.json +35 -3
  88. package/src/surfaces/pane-grid.ts +26 -4
  89. package/src/surfaces/tab-strip.json +16 -39
  90. package/src/surfaces/tab-strip.ts +76 -12
  91. package/src/ui/ai-credentials-form.ts +12 -1
  92. package/src/workspace/pane-derive.ts +7 -5
  93. package/src/workspace/session.ts +3 -3
  94. package/src/workspace/workspace.ts +160 -43
  95. package/styles/shell-frame.css +8 -0
  96. package/styles/shell-frame.json +4 -0
  97. package/dist/chunks/content-source-5mex4x1h.js +0 -46
  98. package/dist/chunks/entry-commands-234hjnqw.js +0 -42
  99. package/dist/chunks/layers-panel-pnnghw12.js +0 -40
  100. package/dist/chunks/menu-9p2w46gj.js +0 -15
  101. package/dist/chunks/publish-panel-5zt840sy.js +0 -27
  102. package/dist/chunks/quick-search-3748564r.js +0 -45
  103. package/dist/chunks/right-panel-eccz92qt.js +0 -45
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jxsuite/studio",
3
- "version": "5.1.0",
3
+ "version": "5.2.0",
4
4
  "description": "Jx Studio — visual builder for Jx documents",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -59,23 +59,23 @@
59
59
  },
60
60
  "dependencies": {
61
61
  "@atlaskit/pragmatic-drag-and-drop": "^3.1.0",
62
- "@atlaskit/pragmatic-drag-and-drop-hitbox": "^2.2.0",
63
- "@jxsuite/ai": "^0.37.1",
62
+ "@atlaskit/pragmatic-drag-and-drop-hitbox": "^2.2.2",
63
+ "@jxsuite/ai": "^0.37.2",
64
64
  "@jxsuite/collab": "^0.9.0",
65
65
  "@jxsuite/create": "^1.3.11",
66
66
  "@jxsuite/formulas": "^0.0.18",
67
67
  "@jxsuite/markup": "^0.4.10",
68
- "@jxsuite/protocol": "^2.3.0",
68
+ "@jxsuite/protocol": "^2.3.1",
69
69
  "@jxsuite/runtime": "^4.0.1",
70
70
  "@jxsuite/schema": "^2.2.0",
71
71
  "@jxsuite/site": "^2.0.1",
72
72
  "@jxsuite/ui": "^0.2.0",
73
- "@vue/reactivity": "3.5.42",
73
+ "@vue/reactivity": "3.5.43",
74
74
  "ajv": "^8.20.0",
75
75
  "ajv-formats": "^3.0.1",
76
76
  "lit-html": "^3.3.3",
77
77
  "monaco-editor": "^0.56.0",
78
- "tabulator-tables": "^6.5.2",
78
+ "tabulator-tables": "^6.5.3",
79
79
  "yaml": "^2.9.1"
80
80
  },
81
81
  "devDependencies": {
@@ -60,6 +60,7 @@ import {
60
60
  } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
61
61
  import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
62
62
  import {
63
+ PRIMARY_PANE,
63
64
  activateTab,
64
65
  moveTabToPane,
65
66
  openTab,
@@ -405,7 +406,7 @@ export async function openLastSessionOrHome(): Promise<boolean> {
405
406
  if (session) {
406
407
  const opened = await restoreSession(session, {
407
408
  ensureSecondPane: () => {
408
- receivingPane();
409
+ receivingPane(PRIMARY_PANE);
409
410
  },
410
411
  openFile: (path, paneId) => openFileInTab(path, { focus: false, paneId }),
411
412
  });
@@ -1525,6 +1526,11 @@ function fileRowFacts(entry: { path: string; type: string }): Record<string, unk
1525
1526
  }
1526
1527
  return facts;
1527
1528
  }
1529
+ /* Every file row states `file`, because `document.openToSide` takes one and the row is the only
1530
+ thing that knows it — the same reason `content.openEntry`'s `path` is stated here. A command
1531
+ whose required arguments this row cannot answer is skipped, so without this fact the
1532
+ "Open to the Side" row would never render on any file. */
1533
+ facts.file = entry.path;
1528
1534
  if (collectionOfPath(entry.path)) {
1529
1535
  facts.path = entry.path;
1530
1536
  }
@@ -2238,23 +2244,36 @@ export interface OpenFileOpts {
2238
2244
  /**
2239
2245
  * Bring an ALREADY-OPEN tab to where the caller asked for it.
2240
2246
  *
2241
- * Three cases, and the third is the one a following pane depends on:
2247
+ * Three cases, and the third is the one a cross-pane open depends on:
2242
2248
  *
2243
- * | the tab is… | behaviour |
2244
- * | ------------------------------------------------ | ---------------------------------------------- |
2245
- * | in the requested pane (or no pane was requested) | activate it there, honouring `focus` |
2246
- * | elsewhere, and **not** its pane's active tab | move it — one tab is one document in one strip |
2247
- * | elsewhere, and **is** its pane's active tab | **nothing.** You are already looking at it |
2249
+ * | the tab is… | behaviour |
2250
+ * | ---------------------------------------------------------------------------- | ---------------------------------------------- |
2251
+ * | elsewhere, and **not** its pane's active tab | move it — one tab is one document in one strip |
2252
+ * | elsewhere, and **is** its pane's active tab, browsing (`focus: false`) | **nothing.** You are already looking at it |
2253
+ * | already in the requested pane, or committing (moving/staying, focus follows) | activate it there, honouring `focus` |
2248
2254
  *
2249
- * The third exists because moving it would oscillate: a derivation that re-resolves to the document
2255
+ * The first exists because moving it would oscillate: a derivation that re-resolves to the document
2250
2256
  * the author is editing would yank it out of their pane and into the assistant one, and the follow
2251
2257
  * would then re-resolve against whatever landed in its place.
2258
+ *
2259
+ * The third is the first's other half, and it is what "Open to the Side" needs: the tab is
2260
+ * elsewhere and IS its pane's active tab, but the caller asked for it in a DIFFERENT pane with the
2261
+ * focus following — the author asked to go there, so "you are already looking at it" is the wrong
2262
+ * answer. The move is a `moveTabToPane`, which activates nothing and moves no focus; the
2263
+ * `activateTab` below then points the keyboard at the pane it landed in.
2264
+ *
2265
+ * **The guard reads `wanted !== undefined && holder` — not also `holder.id !== wanted`.** A tab
2266
+ * already in the requested pane still passes through: `moveTabToPane` is idempotent for a tab
2267
+ * already in `paneId` (its own doc, next to this call), so entering the block and calling it anyway
2268
+ * costs nothing and reaches the identical final `activateTab`. Naming the extra condition would ask
2269
+ * a reader to verify a distinction that changes nothing — the redundant branch this replaced is
2270
+ * `check-lens-mutants.ts`'s own "delete rather than assert" rule, applied to itself.
2252
2271
  */
2253
2272
  function revealOpenTab(tabId: string, opts: OpenFileOpts): void {
2254
2273
  const wanted = opts.paneId;
2255
2274
  const holder = paneOfTab(tabId);
2256
- if (wanted !== undefined && holder && holder.id !== wanted) {
2257
- if (holder.activeTabId === tabId) {
2275
+ if (wanted !== undefined && holder) {
2276
+ if (holder.activeTabId === tabId && opts.focus === false) {
2258
2277
  return;
2259
2278
  }
2260
2279
  moveTabToPane(tabId, wanted);
@@ -2293,7 +2312,7 @@ export async function openFileInTab(path: string, opts: OpenFileOpts = {}) {
2293
2312
  * why a branch anywhere else would send a restored media tab straight back down the error path.
2294
2313
  */
2295
2314
  if (isViewableMedia(path)) {
2296
- openMediaTab(path);
2315
+ openMediaTab(path, opts);
2297
2316
  if (follows) {
2298
2317
  requireProjectState().selectedPath = path;
2299
2318
  }
@@ -2308,8 +2327,10 @@ export async function openFileInTab(path: string, opts: OpenFileOpts = {}) {
2308
2327
  // CSV files open in the grid editor (source mode remains as the raw-text alternate).
2309
2328
  if (path.toLowerCase().endsWith(".csv")) {
2310
2329
  try {
2311
- await openCsvGridTab(path);
2312
- requireProjectState().selectedPath = path;
2330
+ await openCsvGridTab(path, opts);
2331
+ if (follows) {
2332
+ requireProjectState().selectedPath = path;
2333
+ }
2313
2334
  trackRecentFile({
2314
2335
  name: path.split("/").pop() || path,
2315
2336
  path,
@@ -2395,8 +2416,10 @@ export async function openFileInTab(path: string, opts: OpenFileOpts = {}) {
2395
2416
  * Open a file into a NAMED pane, browsing rather than committing, leaving the keyboard behind.
2396
2417
  *
2397
2418
  * The same body as {@link openFileInTab} with the three options a side-open always wants, given a
2398
- * signature that says which pane in the first parameter. It is what "open it beside this" means
2399
- * everywhere it is asked for — drilling into a component, following a layout, `pane.compareWith`.
2419
+ * signature that says which pane in the first parameter. It is what the READS mean by "open it
2420
+ * beside this" — following a layout, `pane.compareWith`. The gestures that open something
2421
+ * (drill-in, Open to the Side) call {@link openFileInTab} directly, because the pane they open into
2422
+ * takes the keyboard.
2400
2423
  *
2401
2424
  * @param {string} paneId
2402
2425
  * @param {string} path
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Lazy grid engine.
3
+ *
4
+ * `grid-view.ts` is the only module that imports `tabulator-tables`, and as of 6.5.3 Tabulator's
5
+ * `Row` class runs `document.createElement` in a static field, so the CLASS DEFINITION — not the
6
+ * table's construction — needs a DOM to exist. That makes `grid-view.ts` unsafe to reach with a
7
+ * static value-import from any module that must load with no DOM: `app-commands.ts` pulls
8
+ * `gridViewCommands` from `grid-panel.ts` for the command set `scripts/check-command-levels.ts` and
9
+ * its siblings read in a bare Bun process (see `commands/defaults.ts`'s note on
10
+ * `selection.convertToComponent` for the sibling convention this follows).
11
+ *
12
+ * So `grid-panel.ts` never has a static value-import of `./grid-view`; it loads the engine through
13
+ * here instead, exactly as `services/monaco-lazy.ts` defers Monaco. Real usage pays no cost for it
14
+ * — a grid tab cannot exist before `document` does — and the two accessors mirror Monaco's: one for
15
+ * code that MOUNTS a grid (async is free there), one for code that can only run with a grid engine
16
+ * already loaded.
17
+ */
18
+ import type * as gridViewModule from "./grid-view";
19
+
20
+ export type GridEngine = typeof gridViewModule;
21
+
22
+ let _engine: GridEngine | null = null;
23
+ let _loading: Promise<GridEngine> | null = null;
24
+
25
+ /**
26
+ * Load the Tabulator-backed grid engine. Memoized — concurrent callers share one import.
27
+ *
28
+ * @returns {Promise<GridEngine>}
29
+ */
30
+ export function loadGridEngine(): Promise<GridEngine> {
31
+ if (_engine) {
32
+ return Promise.resolve(_engine);
33
+ }
34
+ // Not `async`: an async function wraps the memo in a fresh promise per call, so concurrent
35
+ // Callers could not observe that they share one in-flight load.
36
+ _loading ??= import("./grid-view").then((engine) => {
37
+ _engine = engine;
38
+ return engine;
39
+ });
40
+ return _loading;
41
+ }
42
+
43
+ /**
44
+ * The loaded engine, or null before the first grid mount.
45
+ *
46
+ * For call sites that can run synchronously once a grid already exists in the session — they must
47
+ * not force the module to load, only read it if it is already there.
48
+ *
49
+ * @returns {GridEngine | null}
50
+ */
51
+ export function loadedGridEngine(): GridEngine | null {
52
+ return _engine;
53
+ }
54
+
55
+ /** Reset the memo (tests only). */
56
+ export function resetGridLazy(): void {
57
+ _engine = null;
58
+ _loading = null;
59
+ }
@@ -30,11 +30,25 @@ import type { AnyCommand, CommandRegistry } from "../commands/registry";
30
30
  /** Placeholder document for grid tabs — the grid never reads it; save routes to the controller. */
31
31
  const GRID_STUB_DOCUMENT = { children: [], tagName: "div" };
32
32
 
33
- /** Open (or activate) a `.csv` file as a grid tab. */
34
- export async function openCsvGridTab(path: string): Promise<Tab> {
33
+ /**
34
+ * Open (or activate) a `.csv` file as a grid tab.
35
+ *
36
+ * The options are `openFileInTab`'s own, passed straight through: `paneId` says which pane the tab
37
+ * lands in (defaulting to the focused one), `focus: false` browses without moving the keyboard, and
38
+ * `preview` opens a disposable tab. An existing tab is activated with the caller's `focus` — the
39
+ * same rule the document path follows.
40
+ *
41
+ * @param {string} path - Project-relative path of the `.csv`
42
+ * @param {{ paneId?: string; focus?: boolean; preview?: boolean }} [opts]
43
+ * @returns {Promise<Tab>}
44
+ */
45
+ export async function openCsvGridTab(
46
+ path: string,
47
+ opts: { paneId?: string; focus?: boolean; preview?: boolean } = {},
48
+ ): Promise<Tab> {
35
49
  const existing = workspace.tabs.get(path);
36
50
  if (existing) {
37
- activateTab(path);
51
+ activateTab(path, { focus: opts.focus !== false });
38
52
  return existing;
39
53
  }
40
54
 
@@ -52,6 +66,9 @@ export async function openCsvGridTab(path: string): Promise<Tab> {
52
66
  documentPath: path,
53
67
  id: path,
54
68
  sourceFormat: formatForPath(path)?.name ?? null,
69
+ ...(opts.paneId !== undefined && { paneId: opts.paneId }),
70
+ ...(opts.preview === true && { preview: true }),
71
+ ...(opts.focus === false && { focus: false }),
55
72
  });
56
73
  const controller = createGridController(tab, source);
57
74
  void controller.load();
@@ -39,7 +39,7 @@ import { rectOf } from "../utils/geometry";
39
39
  import { activeTab, workspace } from "../workspace/workspace";
40
40
  import { createGridController, getGridController } from "./grid-controller";
41
41
  import { createCsvFileSource } from "./sources/csv-file-source";
42
- import { createGridView } from "./grid-view";
42
+ import { loadedGridEngine, loadGridEngine } from "./grid-lazy";
43
43
  import { parseGridTabId } from "./grid-source";
44
44
  import { mountGridPanelSurface } from "../surfaces/grid-panel";
45
45
  import { openGridViewsSurface } from "../surfaces/grid-views";
@@ -473,12 +473,45 @@ export function renderGridMode(surface: CanvasSurface, tab: Tab) {
473
473
  const engine = controller;
474
474
  let hostEl: HTMLElement | null = null;
475
475
 
476
+ /**
477
+ * (Re)build the Tabulator engine over `host`, destroying whatever view is live first.
478
+ *
479
+ * Synchronous when `tabulator-tables` is already loaded — true for every mount after the
480
+ * session's first, so every existing caller keeps seeing `panel.view` set before this returns. On
481
+ * a cold first load it goes through `loadGridEngine()` instead (see `grid-lazy.ts`) and `onReady`
482
+ * — and, symmetrically with the `ready` handling below, `panel.bump()` — run once the import
483
+ * resolves, guarded against the pane having moved on in the meantime.
484
+ */
485
+ function rebuildView(onReady?: (view: GridView) => void) {
486
+ panel.view?.destroy();
487
+ panel.view = null;
488
+ if (!hostEl) {
489
+ return;
490
+ }
491
+ const requestedHost = hostEl;
492
+ const loaded = loadedGridEngine();
493
+ if (loaded) {
494
+ const view = loaded.createGridView(requestedHost, engine);
495
+ panel.view = view;
496
+ onReady?.(view);
497
+ return;
498
+ }
499
+ void loadGridEngine().then(({ createGridView }) => {
500
+ if (activeIn(paneId) !== panel || hostEl !== requestedHost || panel.view) {
501
+ return;
502
+ }
503
+ const view = createGridView(requestedHost, engine);
504
+ panel.view = view;
505
+ onReady?.(view);
506
+ panel.bump();
507
+ });
508
+ }
509
+
476
510
  const panel: ActiveGridPanel = {
477
511
  applyLayout(layout) {
478
512
  void engine.setSort(layout?.sort ?? null);
479
513
  engine.setGrouping(layout?.groupBy ?? null);
480
- panel.remount();
481
- panel.view?.setSearch(layout?.filter ?? "");
514
+ rebuildView((view) => view.setSearch(layout?.filter ?? ""));
482
515
  panel.bump();
483
516
  },
484
517
  bump() {
@@ -486,8 +519,7 @@ export function renderGridMode(surface: CanvasSurface, tab: Tab) {
486
519
  },
487
520
  paneId,
488
521
  remount() {
489
- panel.view?.destroy();
490
- panel.view = hostEl ? createGridView(hostEl, engine) : null;
522
+ rebuildView();
491
523
  },
492
524
  replace: null,
493
525
  scope,
@@ -576,14 +608,16 @@ export function renderGridMode(surface: CanvasSurface, tab: Tab) {
576
608
 
577
609
  // Create the engine once the columns exist and the host div is in the DOM.
578
610
  if (!panel.view && !engine.state.loading && engine.state.columns.length > 0 && hostEl) {
579
- panel.view = createGridView(hostEl, engine);
580
- if (!restored) {
611
+ rebuildView((view) => {
612
+ if (restored) {
613
+ return;
614
+ }
581
615
  restored = true;
582
616
  const layout = loadGridLayout(engine.source.id);
583
617
  void engine.setSort(layout?.sort ?? null);
584
618
  engine.setGrouping(layout?.groupBy ?? null);
585
- panel.view.setSearch(layout?.filter ?? "");
586
- }
619
+ view.setSearch(layout?.filter ?? "");
620
+ });
587
621
  }
588
622
  });
589
623
  });
@@ -39,13 +39,22 @@ export function mediaTabModes(path: string): string[] {
39
39
  /**
40
40
  * Open (or activate) a media file's viewer tab.
41
41
  *
42
+ * The options are `openFileInTab`'s own, passed straight through: `paneId` says which pane the tab
43
+ * lands in (defaulting to the focused one), `focus: false` browses without moving the keyboard, and
44
+ * `preview` opens a disposable tab. An existing tab is activated with the caller's `focus` — the
45
+ * same rule the document path follows.
46
+ *
42
47
  * @param {string} path - Project-relative path of the file
48
+ * @param {{ paneId?: string; focus?: boolean; preview?: boolean }} [opts]
43
49
  * @returns {Tab}
44
50
  */
45
- export function openMediaTab(path: string): Tab {
51
+ export function openMediaTab(
52
+ path: string,
53
+ opts: { paneId?: string; focus?: boolean; preview?: boolean } = {},
54
+ ): Tab {
46
55
  const existing = workspace.tabs.get(path);
47
56
  if (existing) {
48
- activateTab(path);
57
+ activateTab(path, { focus: opts.focus !== false });
49
58
  return existing;
50
59
  }
51
60
  return openTab({
@@ -54,5 +63,8 @@ export function openMediaTab(path: string): Tab {
54
63
  documentPath: path,
55
64
  id: path,
56
65
  sourceFormat: null,
66
+ ...(opts.paneId !== undefined && { paneId: opts.paneId }),
67
+ ...(opts.preview === true && { preview: true }),
68
+ ...(opts.focus === false && { focus: false }),
57
69
  });
58
70
  }
@@ -500,10 +500,11 @@ export interface PresetRow {
500
500
  *
501
501
  * The previous round made the row's ANSWER a pure function of the pane and left its ACTION
502
502
  * resolving the focus: every one of these three commands takes the focused pane as its subject
503
- * (`pane.derive`'s `activePane()`/`sidePane()`, `pane.unsplit`'s `workspace.activePaneId`), so a
504
- * row read off the secondary pane's menu derived from the primary. Reachable by keyboard and only
505
- * by keyboard, which is why it survived a browser pass: `panels/pane-grid.ts` focuses a pane on
506
- * POINTERDOWN, and a keyboard activation of a menu item fires `click` alone.
503
+ * (`pane.derive`'s `activePane()`/`paneBeside(source.id)`, `pane.unsplit`'s
504
+ * `workspace.activePaneId`), so a row read off the secondary pane's menu derived from the
505
+ * primary. Reachable by keyboard and only by keyboard, which is why it survived a browser pass:
506
+ * `panels/pane-grid.ts` focuses a pane on POINTERDOWN, and a keyboard activation of a menu item
507
+ * fires `click` alone.
507
508
  *
508
509
  * Carried on the row rather than passed as a command argument. A pane id in `args` would make
509
510
  * "which pane" a public input of `pane.derive` — a third property on a record the palette already
@@ -11,20 +11,22 @@
11
11
  * that scaffolding was standing in for, and the handover is deleted with it.
12
12
  *
13
13
  * **The document owns the frame; this module owns the decisions.** `surfaces/pane-grid.json` is the
14
- * cells, the four boxes inside each one, the splitter between two of them and every rule that lays
14
+ * cells, the five boxes inside each one, the splitter between two of them and every rule that lays
15
15
  * them out; `surfaces/pane-grid.ts` is the mount. What is left here is the flow: which panes there
16
16
  * are, what a cell's stage is furnished with, in what ORDER a departing cell is taken apart, and
17
17
  * what the grid's own tracks are. Everything a cell CONTAINS still arrives through the module that
18
18
  * owns it — `canvas/canvas-render.ts` is the render root of the stage and this document puts
19
19
  * nothing inside it, `panels/jump-bar.ts` and `panels/pane-context.ts` are handed the jump and
20
- * chrome boxes, and `panels/tab-strip.ts` is handed the strip.
20
+ * chrome boxes, `panels/tab-strip.ts` is handed the strip, and this module makes the fifth — the
21
+ * right-edge drop zone — a pragmatic target itself.
21
22
  *
22
23
  * **Three properties survived the conversion, and each is structural rather than remembered:**
23
24
  *
24
25
  * 1. _A pane is complete before it is published._ The runtime builds a `$map` row inside its own
25
- * effect scope and inserts it afterwards, so the four `cellPart` calls below — the surface
26
- * record, the gestures, the two bars' mounts, the pane-focus listener — all happen while the
27
- * cell is still detached. There is no frame in which a cell exists with no stage inside it.
26
+ * effect scope and inserts it afterwards, so the five `cellPart` calls below — the surface
27
+ * record, the gestures, the two bars' mounts, the pane-focus listener, the drop zone's own
28
+ * registration — all happen while the cell is still detached. There is no frame in which a cell
29
+ * exists with no stage inside it.
28
30
  * 2. _A pane's node is never re-parented._ The repeater is keyed on the pane id and the runtime's
29
31
  * forward-cursor pass only moves rows that are out of place. `workspace.panes[0]` is always the
30
32
  * primary and `MAX_PANES` is 2, so no row ever moves — which matters because re-parenting is not
@@ -54,6 +56,12 @@
54
56
  */
55
57
 
56
58
  import { effect, effectScope } from "../reactivity";
59
+ import {
60
+ dropTargetForElements,
61
+ monitorForElements,
62
+ } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
63
+ import { isTabDropSource } from "./tab-drop";
64
+ import { rectOf } from "../utils/geometry";
57
65
  import {
58
66
  DEFAULT_PANE_SPLIT,
59
67
  PANE_SPLIT_MAX,
@@ -74,13 +82,13 @@ import { scheduleCanvasRender } from "../canvas/canvas-render";
74
82
  import { paneRegion, paneStripRegion } from "../ui/regions";
75
83
  import { attachJumpBarHost } from "./jump-bar";
76
84
  import { attachPaneChromeHost } from "./pane-context";
77
- import { focusPane, workspace } from "../workspace/workspace";
85
+ import { MAX_PANES, focusPane, workspace } from "../workspace/workspace";
78
86
  import { mountPaneGridSurface } from "../surfaces/pane-grid";
79
87
  import type { EffectScope } from "@vue/reactivity";
80
88
  import type { CanvasSurface } from "../canvas/canvas-surface";
81
89
  import type { PaneCellPart, PaneGridRow, PaneGridSurface } from "../surfaces/pane-grid";
82
90
 
83
- /** One drawn pane: its root and the four surfaces inside it. */
91
+ /** One drawn pane: its root and the five surfaces inside it. */
84
92
  export interface PaneCell {
85
93
  paneId: string;
86
94
  root: HTMLElement;
@@ -88,13 +96,16 @@ export interface PaneCell {
88
96
  jump: HTMLElement;
89
97
  chrome: HTMLElement;
90
98
  stage: HTMLElement;
99
+ dropZone: HTMLElement;
91
100
  surface: CanvasSurface;
92
101
  }
93
102
 
94
- /** A cell's record, plus the disposer its stage gestures live behind. */
103
+ /** A cell's record, plus the disposers its stage gestures and its drop zone live behind. */
95
104
  interface CellState extends PaneCell {
96
105
  /** Stage-gesture disposer, live between the stage's creation and the cell's disposal. */
97
106
  releaseGestures: (() => void) | null;
107
+ /** The drop zone's pragmatic registration, live between its creation and the cell's disposal. */
108
+ releaseDropZone: (() => void) | null;
98
109
  }
99
110
 
100
111
  const _cells = new Map<string, CellState>();
@@ -110,6 +121,13 @@ let _drawn: string[] = [];
110
121
 
111
122
  let _scope: EffectScope | null = null;
112
123
 
124
+ /**
125
+ * The monitor that ARMS the right-edge zones — `_surface.drag`'s one writer. It never resolves a
126
+ * drop; `panels/tab-strip.ts`'s own monitor does that, from the same drop targets this one only
127
+ * watches.
128
+ */
129
+ let _dragMonitor: (() => void) | null = null;
130
+
113
131
  /** The cell a pane is drawn in, or null. Test-visible, and the bootstrap's handle on the primary. */
114
132
  export function cellForPane(paneId: string): PaneCell | null {
115
133
  return _cells.get(paneId) ?? null;
@@ -120,7 +138,7 @@ export function cellForPane(paneId: string): PaneCell | null {
120
138
  *
121
139
  * The mount is asynchronous — the runtime waits for the kit to be defined and renders one microtask
122
140
  * after that — so `mountShell()` starting the grid is not the same event as the grid existing. The
123
- * bootstrap reads the primary cell's four boxes on the line after it, exactly as `mountShellTree()`
141
+ * bootstrap reads the primary cell's five boxes on the line after it, exactly as `mountShellTree()`
124
142
  * made every caller await the frame for the same reason.
125
143
  */
126
144
  export function paneGridReady(): Promise<void> {
@@ -142,8 +160,10 @@ function cellState(paneId: string): CellState {
142
160
  }
143
161
  const cell: CellState = {
144
162
  chrome: null as unknown as HTMLElement,
163
+ dropZone: null as unknown as HTMLElement,
145
164
  jump: null as unknown as HTMLElement,
146
165
  paneId,
166
+ releaseDropZone: null,
147
167
  releaseGestures: null,
148
168
  root: null as unknown as HTMLElement,
149
169
  stage: null as unknown as HTMLElement,
@@ -217,7 +237,52 @@ function attachStage(cell: CellState, stage: HTMLElement): void {
217
237
  }
218
238
 
219
239
  /**
220
- * One of a cell's five boxes has been created. Fill the record in, and wire what belongs to it.
240
+ * The RIGHT BAND of `paneId`'s cell, and only while it is legal to drop there: the grid is still
241
+ * under {@link MAX_PANES}, and `paneId` names the LAST pane — the one a second pane would sit
242
+ * beside. A grid of two panes has no legal edge at all, so the band answers refused everywhere on
243
+ * it.
244
+ *
245
+ * The band itself is `min(120px, half the cell's own width)`: wide enough to aim at, narrow enough
246
+ * that the rest of the stage keeps ordinary canvas gestures — a drag never reaches this at all
247
+ * unless `canDrop` already accepted it, so the band only ever competes with itself.
248
+ */
249
+ function edgeData(
250
+ paneId: string,
251
+ input: { clientX: number },
252
+ element: HTMLElement,
253
+ ): Record<string, unknown> {
254
+ if (workspace.panes.length >= MAX_PANES || workspace.panes.at(-1)?.id !== paneId) {
255
+ return { type: "pane-edge-refused" };
256
+ }
257
+ const rect = rectOf(element);
258
+ const band = Math.min(120, rect.width / 2);
259
+ if (input.clientX < rect.right - band) {
260
+ return { type: "pane-edge-refused" };
261
+ }
262
+ return { paneId, type: "pane-edge" };
263
+ }
264
+
265
+ /**
266
+ * Make a cell's drop zone a pragmatic target. `canDrop` alone is not enough to keep the zone out of
267
+ * a two-pane grid's way — {@link edgeData} refuses in the DATA, the same "never fall through" rule
268
+ * `files.ts`'s row targets use, because the zone sits ABOVE the stage (`z-index: 4`) and a refusal
269
+ * that let the drop bubble past it would still have kept the pointer from ever reaching the iframe
270
+ * underneath.
271
+ */
272
+ function attachDropZone(paneId: string, element: HTMLElement): () => void {
273
+ return dropTargetForElements({
274
+ canDrop({ source }) {
275
+ return isTabDropSource(source.data);
276
+ },
277
+ element,
278
+ getData({ input }) {
279
+ return edgeData(paneId, input, element);
280
+ },
281
+ });
282
+ }
283
+
284
+ /**
285
+ * One of a cell's six boxes has been created. Fill the record in, and wire what belongs to it.
221
286
  *
222
287
  * The two bars are HANDED their host rather than resolving a region, the same way
223
288
  * `panels/frontmatter-panel.ts` is handed the stage. They cannot resolve one the way the tab strip
@@ -248,6 +313,11 @@ function cellPart(paneId: string, part: PaneCellPart, element: HTMLElement): voi
248
313
  attachPaneChromeHost(paneId, element);
249
314
  return;
250
315
  }
316
+ if (part === "dropZone") {
317
+ cell.dropZone = element;
318
+ cell.releaseDropZone = attachDropZone(paneId, element);
319
+ return;
320
+ }
251
321
  attachStage(cell, element);
252
322
  }
253
323
 
@@ -272,6 +342,8 @@ function cellPart(paneId: string, part: PaneCellPart, element: HTMLElement): voi
272
342
  * the record the hosts resolve through.
273
343
  */
274
344
  function disposeCell(cell: CellState): void {
345
+ cell.releaseDropZone?.();
346
+ cell.releaseDropZone = null;
275
347
  attachJumpBarHost(cell.paneId, null);
276
348
  attachPaneChromeHost(cell.paneId, null);
277
349
  cell.releaseGestures?.();
@@ -396,6 +468,22 @@ export function mount(): void {
396
468
  { cellPart, move: setPaneSplit, settle: persistDocks },
397
469
  );
398
470
  _ready = _surface.ready;
471
+ _dragMonitor = monitorForElements({
472
+ canMonitor({ source }) {
473
+ return isTabDropSource(source.data);
474
+ },
475
+ onDrag({ location }) {
476
+ const [target] = location.current.dropTargets;
477
+ const edgePane = target?.data.type === "pane-edge" ? (target.data.paneId as string) : "";
478
+ _surface?.drag({ armed: true, edgePane });
479
+ },
480
+ onDragStart() {
481
+ _surface?.drag({ armed: true, edgePane: "" });
482
+ },
483
+ onDrop() {
484
+ _surface?.drag({ armed: false, edgePane: "" });
485
+ },
486
+ });
399
487
  _scope = effectScope();
400
488
  _scope.run(() => {
401
489
  effect(() => {
@@ -412,6 +500,8 @@ export function mount(): void {
412
500
  export function unmount(): void {
413
501
  _scope?.stop();
414
502
  _scope = null;
503
+ _dragMonitor?.();
504
+ _dragMonitor = null;
415
505
  /* Every cell is taken apart by the same ordered teardown a departing one gets, and BEFORE the
416
506
  document goes: doing it afterwards would hand `releaseCanvasHosts` a stage whose frames the
417
507
  runtime had already dropped. */