@jxsuite/studio 5.0.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 (132) hide show
  1. package/dist/chunks/{cf-account-picker-fyf63pmy.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-5y78e05d.js +20 -0
  4. package/dist/chunks/content-source-sgg1sv6n.js +46 -0
  5. package/dist/chunks/{convert-to-repeater-d0cgnb6d.js → convert-to-repeater-mf1cxgfn.js} +25 -25
  6. package/dist/chunks/entry-commands-w9qsxa6q.js +42 -0
  7. package/dist/chunks/{extension-sections-fy7q4fe9.js → extension-sections-c6zwzbre.js} +27 -27
  8. package/dist/chunks/file-ops-vrpq1jjk.js +47 -0
  9. package/dist/chunks/{files-ef8fgs3w.js → files-msaf95y6.js} +33 -32
  10. package/dist/chunks/{format-host-3c33nwr8.js → format-host-f64fnesp.js} +2 -2
  11. package/dist/chunks/{fs-events-13tj6sex.js → fs-events-3y6y0nrm.js} +6 -6
  12. package/dist/chunks/git-panel-bv5e22j0.js +50 -0
  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-jgesd924.js → iframe-host-dh5np8vk.js} +14 -14
  16. package/dist/chunks/{layers-1nq5evsh.js → layers-1vcfdbet.js} +7 -6
  17. package/dist/chunks/layers-panel-fksbdg4q.js +40 -0
  18. package/dist/chunks/{media-picker-2qbetq73.js → media-picker-p49nhqhs.js} +14 -14
  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-n553f0e1.js → references-h69xp9zz.js} +4 -4
  26. package/dist/chunks/right-panel-j97t2yvy.js +45 -0
  27. package/dist/chunks/{studio-18avnhjp.js → studio-01jeck2y.js} +36 -30
  28. package/dist/chunks/{studio-9xxz1q69.js → studio-14d9tq4d.js} +9 -9
  29. package/dist/chunks/{studio-xsykpvsh.js → studio-15f3kxqe.js} +19 -19
  30. package/dist/chunks/studio-1tbjfqv4.js +146 -0
  31. package/dist/chunks/{studio-79y5sz51.js → studio-28vs5k2y.js} +2 -2
  32. package/dist/chunks/{studio-3hxkd92t.js → studio-2mdbbc76.js} +10 -10
  33. package/dist/chunks/{studio-7r42c6ca.js → studio-3ntvq6cg.js} +5 -5
  34. package/dist/chunks/{studio-8jn5yhyn.js → studio-4c041r8y.js} +3 -3
  35. package/dist/chunks/{studio-qahxvnan.js → studio-4r14yzyy.js} +4 -4
  36. package/dist/chunks/{studio-arfpe389.js → studio-4xmjcrg3.js} +6 -6
  37. package/dist/chunks/{studio-5523bpzt.js → studio-5nz00bhh.js} +3 -3
  38. package/dist/chunks/{studio-x3rty6tr.js → studio-5t7ena5z.js} +8 -8
  39. package/dist/chunks/{studio-5rt7sqa0.js → studio-66arbpc2.js} +13 -19
  40. package/dist/chunks/{studio-fabxk6r9.js → studio-68fydsrj.js} +15 -15
  41. package/dist/chunks/{studio-tb3ev01j.js → studio-9j6sfeba.js} +6 -6
  42. package/dist/chunks/{studio-akbww0j5.js → studio-a1pswps3.js} +38 -38
  43. package/dist/chunks/{studio-6xa21ej9.js → studio-c04nzbby.js} +2 -2
  44. package/dist/chunks/{studio-4m0haseg.js → studio-c07s5ckd.js} +4 -4
  45. package/dist/chunks/{studio-57mf66w2.js → studio-ctm7bhx5.js} +14 -14
  46. package/dist/chunks/{studio-p1dw8wfw.js → studio-d8z5bajd.js} +14 -10
  47. package/dist/chunks/{studio-vz68yn9e.js → studio-en82c639.js} +6 -6
  48. package/dist/chunks/{studio-rkstz940.js → studio-etvx9s2g.js} +21 -12
  49. package/dist/chunks/{studio-az3zd49t.js → studio-ez11dmdh.js} +127 -37
  50. package/dist/chunks/{studio-sgdc5pfc.js → studio-hdc2jt3z.js} +4 -4
  51. package/dist/chunks/{studio-s6z853aq.js → studio-hhskq2mr.js} +24 -23
  52. package/dist/chunks/{studio-6ewqv390.js → studio-m1p9b6c3.js} +5 -5
  53. package/dist/chunks/{studio-25hkdrkw.js → studio-mpzwze9x.js} +12 -12
  54. package/dist/chunks/{studio-8qtrpzr3.js → studio-ncn9e189.js} +7 -7
  55. package/dist/chunks/{studio-81390n6f.js → studio-nq5yf5mp.js} +1 -1
  56. package/dist/chunks/{studio-5jy81t3s.js → studio-p6pmwkvh.js} +229 -24
  57. package/dist/chunks/{studio-3gxv60tn.js → studio-qa7fzxan.js} +4 -4
  58. package/dist/chunks/{studio-7xptrg0j.js → studio-sar8g54p.js} +8 -8
  59. package/dist/chunks/studio-t171hxhe.js +23 -0
  60. package/dist/chunks/{studio-em9nw8eh.js → studio-t3adrr56.js} +20 -17
  61. package/dist/chunks/{studio-hhvs4sdg.js → studio-vn5hske5.js} +10 -4
  62. package/dist/chunks/{studio-pm6hgy7r.js → studio-vt0tj32j.js} +29 -42
  63. package/dist/chunks/{studio-d1ja2x9r.js → studio-wjpgxywa.js} +4 -4
  64. package/dist/chunks/{studio-q3x06vmj.js → studio-wnsyffg1.js} +2 -2
  65. package/dist/chunks/{studio-rpyah2ew.js → studio-x10fp4tp.js} +64 -24
  66. package/dist/chunks/{studio-26d7myd1.js → studio-x72j58vw.js} +4 -4
  67. package/dist/chunks/{studio-cpvb2ed1.js → studio-y4f9fdge.js} +8 -8
  68. package/dist/chunks/{studio-erdatzvp.js → studio-ympe0n7a.js} +35 -6
  69. package/dist/chunks/{studio-y9zedyjn.js → studio-z2smx5ce.js} +2 -2
  70. package/dist/chunks/studio-zjjde71a.js +148 -0
  71. package/dist/chunks/{workspace-wrew8zk4.js → workspace-bnw54cha.js} +18 -10
  72. package/dist/iframe-entry.js +102 -11
  73. package/dist/studio.css +1 -2
  74. package/dist/studio.js +1113 -16062
  75. package/package.json +11 -11
  76. package/src/canvas/canvas-utils.ts +29 -2
  77. package/src/commands/command-args.ts +37 -2
  78. package/src/commands/registry.ts +18 -8
  79. package/src/commands/run-reported.ts +120 -0
  80. package/src/editor/shortcuts.ts +11 -23
  81. package/src/files/components.ts +39 -0
  82. package/src/files/files.ts +41 -17
  83. package/src/grid/grid-lazy.ts +59 -0
  84. package/src/grid/grid-open.ts +20 -3
  85. package/src/grid/grid-panel.ts +43 -9
  86. package/src/i18n/i18n-commands.ts +10 -0
  87. package/src/media/media-open.ts +14 -2
  88. package/src/packages/pull-package-sync.ts +5 -1
  89. package/src/panels/ai-panel.ts +5 -2
  90. package/src/panels/block-action-bar.ts +7 -2
  91. package/src/panels/i18n-panel.ts +8 -15
  92. package/src/panels/jump-bar.ts +3 -2
  93. package/src/panels/pane-context.ts +5 -4
  94. package/src/panels/pane-grid.ts +100 -10
  95. package/src/panels/problems-panel.ts +6 -2
  96. package/src/panels/quick-search.ts +6 -12
  97. package/src/panels/settings-menu.ts +5 -15
  98. package/src/panels/tab-drop.ts +220 -0
  99. package/src/panels/tab-strip.ts +238 -33
  100. package/src/services/ai-command-tools.ts +78 -8
  101. package/src/services/ai-models.ts +13 -0
  102. package/src/services/ai-project-tools.ts +8 -2
  103. package/src/services/ai-system-prompt.ts +4 -8
  104. package/src/services/ai-tools.ts +8 -69
  105. package/src/services/document-assistant.ts +9 -6
  106. package/src/studio.ts +24 -8
  107. package/src/surfaces/commandbar.json +35 -24
  108. package/src/surfaces/commandbar.ts +6 -5
  109. package/src/surfaces/pane-grid.json +35 -3
  110. package/src/surfaces/pane-grid.ts +26 -4
  111. package/src/surfaces/statusbar.ts +2 -1
  112. package/src/surfaces/tab-strip.json +16 -39
  113. package/src/surfaces/tab-strip.ts +76 -12
  114. package/src/tabs/project-config.ts +45 -11
  115. package/src/ui/ai-credentials-form.ts +12 -1
  116. package/src/ui/layers.ts +4 -1
  117. package/src/workspace/pane-derive.ts +7 -5
  118. package/src/workspace/session.ts +3 -3
  119. package/src/workspace/workspace.ts +220 -43
  120. package/styles/shell-frame.css +8 -0
  121. package/styles/shell-frame.json +4 -0
  122. package/dist/chunks/components-2w1f35qs.js +0 -18
  123. package/dist/chunks/content-source-3wqeas45.js +0 -46
  124. package/dist/chunks/entry-commands-d6g50j7s.js +0 -42
  125. package/dist/chunks/file-ops-3btj6st0.js +0 -47
  126. package/dist/chunks/git-panel-4wp2rn9b.js +0 -49
  127. package/dist/chunks/layers-panel-ar2gh90g.js +0 -40
  128. package/dist/chunks/menu-rf7n72mf.js +0 -14
  129. package/dist/chunks/publish-panel-98f7sr3g.js +0 -27
  130. package/dist/chunks/quick-search-srzz83k2.js +0 -45
  131. package/dist/chunks/right-panel-rrgx2dn3.js +0 -45
  132. package/dist/chunks/studio-d055c37e.js +0 -62
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jxsuite/studio",
3
- "version": "5.0.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
- "@jxsuite/create": "^1.3.10",
66
- "@jxsuite/formulas": "^0.0.17",
65
+ "@jxsuite/create": "^1.3.11",
66
+ "@jxsuite/formulas": "^0.0.18",
67
67
  "@jxsuite/markup": "^0.4.10",
68
- "@jxsuite/protocol": "^2.3.0",
69
- "@jxsuite/runtime": "^4.0.0",
68
+ "@jxsuite/protocol": "^2.3.1",
69
+ "@jxsuite/runtime": "^4.0.1",
70
70
  "@jxsuite/schema": "^2.2.0",
71
- "@jxsuite/site": "^2.0.0",
72
- "@jxsuite/ui": "^0.1.0",
73
- "@vue/reactivity": "3.5.42",
71
+ "@jxsuite/site": "^2.0.1",
72
+ "@jxsuite/ui": "^0.2.0",
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": {
@@ -1024,8 +1024,35 @@ export function canvasViewCommands(deps: CanvasCommandDeps): AnyCommand[] {
1024
1024
  group: "3_canvas",
1025
1025
  requires: "an open document",
1026
1026
  when: documentOpen,
1027
- /* No `aiTool`, by §12.4's first deletion rule: chrome. `open_document` lands on the canvas,
1028
- and the tree tools key on `editor.kind`, not the mode. */
1027
+ /*
1028
+ * Projected, and it was deleted once under §12.4's first rule (chrome). The rule is about a
1029
+ * verb whose WHOLE effect is what the person is looking at, and this one's is not: the tree
1030
+ * tools are gated on `editor.kind`, which is derived from the mode (`editorKindForMode`), so
1031
+ * a document the person left in Code — or the model itself moved — refuses every tree write
1032
+ * with "requires an open document whose element tree the canvas is editing", and without this
1033
+ * verb the model had no way back to a state it can read. `run` acts on the active tab and
1034
+ * the model reads the result: `after.editor.kind` is the same fact the gate reads.
1035
+ */
1036
+ aiTool: {
1037
+ description:
1038
+ "Switch the active document's editor mode. edit and design put its element tree on " +
1039
+ "the canvas, where the document tools work; preview renders it as a visitor sees it; " +
1040
+ "source shows the JSON; stylebook, grid and git-diff open other editors. Use it to " +
1041
+ "return a document to edit or design when the tree tools are refused.",
1042
+ name: "set_canvas_mode",
1043
+ /* `after.editor.kind` rather than the tab's own field: it is the gate's read (`treeEditable`
1044
+ in `document-assistant.ts` is `editor.kind === "canvas"`), so the sentence and the gate
1045
+ cannot disagree. The mode named is the one requested — `run` resolved, so it is the mode
1046
+ the tab is in. */
1047
+ report: ({ after, args }) => {
1048
+ const mode = enumArg("canvas.setMode", args, "mode", CANVAS_MODES);
1049
+ const reach =
1050
+ after.editor.kind === "canvas"
1051
+ ? "the document tools address its element tree"
1052
+ : "the tree-editing tools are unavailable until it returns to edit or design";
1053
+ return `The active document is in ${mode} mode; ${reach}.`;
1054
+ },
1055
+ },
1029
1056
  run: (_commandCtx, args) => {
1030
1057
  const mode = enumArg("canvas.setMode", args, "mode", CANVAS_MODES);
1031
1058
  const tab = requireTab("canvas.setMode");
@@ -72,6 +72,23 @@ export function numberArg(commandId: string, args: CommandArgValues, key: string
72
72
  return value;
73
73
  }
74
74
 
75
+ /**
76
+ * A required integer argument. `1.5` is a refusal, not a value to round: a caller naming an index
77
+ * or a count with a fraction in it has said something the app cannot mean.
78
+ *
79
+ * Its own reader rather than `numberArg` plus a check in `run`, because `coerceArgs` routes a bare
80
+ * `type: "integer"` here — without it the row read as a plain number and `1.5` passed the schema
81
+ * pass to fail, or not, in whatever `run` did with it. `typedArg` already held a type LIST to
82
+ * integrality; this is the same rule for the bare form.
83
+ */
84
+ export function integerArg(commandId: string, args: CommandArgValues, key: string): number {
85
+ const value = args[key];
86
+ if (typeof value !== "number" || !Number.isInteger(value)) {
87
+ throw refuse(commandId, key, `expected an integer, got ${describe(value)}`);
88
+ }
89
+ return value;
90
+ }
91
+
75
92
  /**
76
93
  * A required number argument inside a closed interval.
77
94
  *
@@ -330,6 +347,7 @@ export type CoercionRow =
330
347
  | "boolean"
331
348
  | "bounded-number"
332
349
  | "number"
350
+ | "integer"
333
351
  | "string"
334
352
  | "path"
335
353
  | "path-list"
@@ -373,8 +391,13 @@ export function describeShape(property: object): CoercionRow {
373
391
  case "number":
374
392
  case "integer": {
375
393
  // Both bounds, as `numberProperty` writes them. A half-open interval is not a shape any
376
- // Record declares, so it reads as a plain number and `run` owns the bound.
377
- return p.minimum !== undefined && p.maximum !== undefined ? "bounded-number" : "number";
394
+ // Record declares, so it reads as a plain number and `run` owns the bound. A bounded integer
395
+ // Is the `bounded-number` row too, which holds it to integrality before the interval; only
396
+ // The BARE integer goes to `integerArg`, whose whole rule is integrality.
397
+ if (p.minimum !== undefined && p.maximum !== undefined) {
398
+ return "bounded-number";
399
+ }
400
+ return p.type === "integer" ? "integer" : "number";
378
401
  }
379
402
  case "string": {
380
403
  return "string";
@@ -486,6 +509,9 @@ function shapeOf(property: PropertySchema): string {
486
509
  case "number": {
487
510
  return "a finite number";
488
511
  }
512
+ case "integer": {
513
+ return "an integer";
514
+ }
489
515
  case "string": {
490
516
  return "a non-empty string";
491
517
  }
@@ -577,11 +603,20 @@ function coerceProperty(
577
603
  return booleanArg(commandId, args, key);
578
604
  }
579
605
  case "bounded-number": {
606
+ // Integrality first, so `{ type: "integer", minimum: 1, maximum: 9 }` refuses `1.5` by name
607
+ // Rather than admitting it as a number inside the interval — the static check's `type` row
608
+ // Already refused it, and the two passes must agree.
609
+ if (property.type === "integer") {
610
+ integerArg(commandId, args, key);
611
+ }
580
612
  return boundedNumberArg(commandId, args, key, property.minimum!, property.maximum!);
581
613
  }
582
614
  case "number": {
583
615
  return numberArg(commandId, args, key);
584
616
  }
617
+ case "integer": {
618
+ return integerArg(commandId, args, key);
619
+ }
585
620
  case "string": {
586
621
  return stringArg(commandId, args, key);
587
622
  }
@@ -27,7 +27,9 @@
27
27
  *
28
28
  * The registry takes its context by injection (`getContext`), so nothing here imports a state
29
29
  * module. That is what lets the CI checks import the command set in a bare Bun process and lets
30
- * every test build the exact state it wants to assert against.
30
+ * every test build the exact state it wants to assert against. The one store it reaches is the
31
+ * notification store, through `run-reported.ts`, and only from `handleKeyEvent`: a chord's refusal
32
+ * has no caller to hand it to, so the registry files it where every other surface's refusal goes.
31
33
  */
32
34
 
33
35
  import { createKeymap } from "./keymap";
@@ -35,6 +37,7 @@ import type { KeyChordEvent, Keymap, KeymapMatch } from "./keymap";
35
37
  import { checkRecordPlacements } from "./levels";
36
38
  import type { Category, KeyScope, Level, Placement } from "./levels";
37
39
  import { coerceArgs } from "./command-args";
40
+ import { runReported } from "./run-reported";
38
41
  import type { CommandContext } from "./context";
39
42
 
40
43
  /** Arguments as they arrive from a palette prompt, an automation manifest step or an AI tool call. */
@@ -435,13 +438,20 @@ export function createCommandRegistry(options: CommandRegistryOptions): CommandR
435
438
  if (!registry.isEnabled(hit.commandId)) {
436
439
  return hit.commandId;
437
440
  }
438
- /* An ARGUMENT refusal is the one throw that still leaves here, on purpose. A chord runs its
439
- record with `{}`, and `run` coerces that against the schema first, so a record whose
440
- schema requires a key cannot be a chord — every shipped keybinding is held to
441
- `required: []` by the sweep in `tests/command-args.test.ts`, and a user's own keymap
442
- override that binds one anyway hears about it as a RangeError naming the command and the
443
- key, rather than as a chord that silently does nothing. */
444
- void registry.run(hit.commandId);
441
+ /* An ARGUMENT refusal is the one refusal that can still happen here, and it is REPORTED
442
+ rather than thrown. A chord runs its record with `{}`, and `run` coerces that against the
443
+ schema first, so a record whose schema requires a key cannot be a chord — every shipped
444
+ keybinding is held to `required: []` by the sweep in `tests/command-args.test.ts`, and a
445
+ user's own keymap override that binds one anyway hears about it as a Problems row naming
446
+ the command and the key, rather than as a chord that silently does nothing. It used to
447
+ leave as a `RangeError` out of the keydown listener, which named the same things to the
448
+ console and to nobody; `run-reported.ts` is what catches that shape, and the rejection a
449
+ `run` body may produce later, for every surface alike. A gate that closes BETWEEN the
450
+ `isEnabled` above and `run`'s own re-read is reported the same way, and that is a choice:
451
+ the silent claim above is for a chord the person can see is disabled, while a gate that
452
+ moved under the chord is state the person cannot see, and the `requires` sentence in
453
+ Problems is the one account of why nothing happened. */
454
+ void runReported(registry, hit.commandId, undefined, "Keyboard");
445
455
  return hit.commandId;
446
456
  },
447
457
  context: options.getContext,
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Run-reported.ts — run a command from a surface, and let a refusal land in Problems.
3
+ *
4
+ * `registry.run` refuses in two shapes, and a caller that catches one and not the other still lets
5
+ * a refusal out. It throws SYNCHRONOUSLY — `CommandUnavailableError` when the gate refuses, a
6
+ * `RangeError` when `coerceArgs` refuses the received record BEFORE `run` is entered, and whatever
7
+ * a synchronous `run` body throws — and it REJECTS when an async `run` body does. A bare `void
8
+ * registry.run(id, args)` lets the first shape out of the click, keydown or notice action it came
9
+ * from, and a `.catch` on the promise alone catches nothing when `run` has already thrown; a
10
+ * `Promise.resolve(registry.run(...))` around it is the same mistake with more characters, because
11
+ * the argument is evaluated first. Three surfaces had grown the same six-line try/catch in reply
12
+ * (the palette's `runCommand`, the Settings menu's `runRow` and the Languages panel's `runByName`)
13
+ * while ten others still ran bare, which is the drift issue 333 names: a refusal reached Problems
14
+ * or the console depending on which surface the person happened to be on.
15
+ *
16
+ * This is the one spelling. It catches both shapes, files the sentence in Problems — the tier
17
+ * `notify.error` chooses, and the right one: a refusal names something the person has to fix or a
18
+ * schema that drifted from its caller, and neither is a toast that may be taken away — and returns
19
+ * a promise that never rejects, so a caller may `void` it or `await` it without a `.catch`.
20
+ *
21
+ * A refusal is the whole message; a CRASH is not. `CommandUnavailableError` and `RangeError` are
22
+ * the two shapes the registry and `coerceArgs` refuse with, and each is a sentence written to be
23
+ * read, so the Problems row is complete. Anything else out of a `run` body — a `TypeError` on a
24
+ * node that was not there — is a bug, and its stack is the part that matters; the row still names
25
+ * it, and the error object also goes to the console, where the palette used to send everything and
26
+ * where a trace can be opened. Without that the helper would have made every surface quieter than
27
+ * the keydown listener it replaced, which reached `window.onerror`.
28
+ *
29
+ * Two spellings, one rule. {@link runReported} takes the registry as a PARAMETER: `editor/
30
+ * shortcuts.ts`, the block action bar and the registry's own `handleKeyEvent` are handed theirs,
31
+ * and a helper that resolved the active registry for them would silently do nothing wherever a
32
+ * test, or a second window kind, ran a registry that is not the published one.
33
+ * {@link runActiveReported} is the same call for a surface that was spelled `void
34
+ * activeRegistry()?.run(id, args)` — a footer row, a palette hit, a tab-strip chip — and it keeps
35
+ * that spelling's one property: before the bootstrap has published a registry there is nothing to
36
+ * run and nothing to report, exactly as the `?.` was silent.
37
+ *
38
+ * The sweep in `tests/run-reported.test.ts` walks `packages/studio/src` for any other `.run(` on a
39
+ * registry. Two callers own their refusal and are named there as such — `services/automation.ts`
40
+ * and `services/ai-command-tools.ts`, where the script or the model is the one who reads it — and
41
+ * the `?.run(` spelling issue 333 did not list survives in a RATCHET of files the sweep names one
42
+ * by one, each still holding a bare call; the list can only shrink, so a new surface cannot spell
43
+ * it bare and an existing one converted must drop its entry.
44
+ *
45
+ * `runReported` does not pre-check `isEnabled`. A surface that renders a disabled control for a
46
+ * refused record has already shown the `requires` sentence, and a click that arrives on one anyway
47
+ * (state moved between the render and the click) is a refusal the person should read, not a
48
+ * swallowed no-op. The callers that DO pre-check keep doing so for their own reasons — a chord on a
49
+ * disabled command is claimed silently by `handleKeyEvent`, by design — and route what remains
50
+ * through here.
51
+ *
52
+ * @docs studio/interface/problems-and-progress
53
+ */
54
+
55
+ import { errorMessage } from "@jxsuite/schema/parse";
56
+ import { notify } from "../services/notify";
57
+ import { activeRegistry } from "./active-registry";
58
+ import type { CommandArgs, CommandRegistry } from "./registry";
59
+
60
+ /**
61
+ * Whether an error is one of the two REFUSAL shapes, whose message is written to be read whole.
62
+ *
63
+ * By name rather than `instanceof CommandUnavailableError`: `registry.ts` imports this module for
64
+ * `handleKeyEvent`, and a value import back would close a cycle for one class whose constructor
65
+ * sets exactly this name. `RangeError` is the shape `coerceArgs` and a `run` body refuse with.
66
+ */
67
+ function isRefusal(error: unknown): boolean {
68
+ return (
69
+ error instanceof RangeError ||
70
+ (error instanceof Error && error.name === "CommandUnavailableError")
71
+ );
72
+ }
73
+
74
+ /**
75
+ * Run `id` with `args` on `registry`, and file whatever refuses it in Problems under `source`.
76
+ *
77
+ * `source` is the SURFACE the run came from ("Palette", "Keyboard", "Languages"), the column the
78
+ * Problems panel groups by; it defaults to the command id, which is what the Settings menu filed
79
+ * under before this helper existed and is still a true answer to "who reported it". The Problems
80
+ * row is keyed by the command id, so a chord held down on a refusing record is one problem rather
81
+ * than sixty — the file-watcher rule `services/notify.ts` writes down for `key`.
82
+ *
83
+ * The returned promise settles when the command does, and never rejects: an `await` on it is a
84
+ * wait, not a second place the refusal could escape from.
85
+ */
86
+ export function runReported(
87
+ registry: CommandRegistry,
88
+ id: string,
89
+ args?: CommandArgs,
90
+ source: string = id,
91
+ ): Promise<void> {
92
+ const report = (error: unknown) => {
93
+ if (!isRefusal(error)) {
94
+ // A crash, not a refusal: the row below carries the sentence, the console keeps the stack.
95
+ console.error(`${source}: command "${id}" failed`, error);
96
+ }
97
+ notify.error(errorMessage(error), { key: `command.run:${id}`, source });
98
+ };
99
+ let result: void | Promise<void>;
100
+ try {
101
+ result = registry.run(id, args);
102
+ } catch (error) {
103
+ report(error);
104
+ return Promise.resolve();
105
+ }
106
+ return Promise.resolve(result).catch(report);
107
+ }
108
+
109
+ /**
110
+ * {@link runReported} on this window's registry, or nothing before the bootstrap has published one.
111
+ *
112
+ * The `?.` this replaces was the surfaces' answer to `active-registry.ts` returning `null` for the
113
+ * frame before the bootstrap composes the registry; a click that lands in that frame has no
114
+ * registry to refuse it, so there is no refusal to file. The resolved promise keeps the caller's
115
+ * `void` honest either way.
116
+ */
117
+ export function runActiveReported(id: string, args?: CommandArgs, source?: string): Promise<void> {
118
+ const registry = activeRegistry();
119
+ return registry ? runReported(registry, id, args, source) : Promise.resolve();
120
+ }
@@ -77,7 +77,7 @@ import { clipboardCommands, pasteNode } from "./context-menu";
77
77
  import { hasElementSelection, hasSelection, inCanvas, keyScopeStack } from "../commands/context";
78
78
  import { defaultCommands } from "../commands/defaults";
79
79
  import { setActiveRegistry } from "../commands/active-registry";
80
- import { CommandUnavailableError } from "../commands/registry";
80
+ import { runReported } from "../commands/run-reported";
81
81
  import type { CommandContext } from "../commands/context";
82
82
  import type { DockId as CommandDockId } from "../commands/defaults";
83
83
  import type { AnyCommand, CommandRegistry } from "../commands/registry";
@@ -388,11 +388,12 @@ function toggleShellDock(registry: CommandRegistry, dock: CommandDockId): void {
388
388
  *
389
389
  * `view.setAssistant` and `view.setRightTab` are `shell.ts`'s, composed into the app's registry by
390
390
  * the bootstrap — a reduced registry (a test, a future second window kind) may not carry them, and
391
- * a missing id must be inert rather than an exception thrown out of a keydown handler.
391
+ * a missing id must be inert rather than an exception thrown out of a keydown handler. A present
392
+ * one that refuses its arguments is not inert: `commands/run-reported.ts` files it in Problems.
392
393
  */
393
394
  function runIfPresent(registry: CommandRegistry, id: string, args: Record<string, unknown>): void {
394
395
  if (registry.get(id) && registry.isEnabled(id)) {
395
- void registry.run(id, args);
396
+ void runReported(registry, id, args, "Keyboard");
396
397
  }
397
398
  }
398
399
 
@@ -901,34 +902,21 @@ export function registerStudioCommands(
901
902
  *
902
903
  * `preventDefault()` iff a command claimed the chord. A command that is VISIBLE but disabled — ⌘Z
903
904
  * with nothing to undo, Delete on the document element — still counts as claiming it: the registry
904
- * throws {@link CommandUnavailableError} from `run`, and swallowing the key is the honest outcome,
905
- * because the chord is spoken for and letting the browser act on it instead would be a surprise.
906
- *
907
- * HANDOFF: `registry.handleKeyEvent` reports the hit and runs it in one step, so a refusal can only
908
- * be observed by catching. It would be better for it to consult `isEnabled` itself and return the
909
- * id without running; `commands/registry.ts` is another workstream's file this wave.
905
+ * consults `isEnabled` itself and returns the id without running, and swallowing the key is the
906
+ * honest outcome, because the chord is spoken for and letting the browser act on it instead would
907
+ * be a surprise. Nothing is caught here, because nothing leaves: `handleKeyEvent` runs the hit
908
+ * through `commands/run-reported.ts`, so a record that refuses its arguments, a gate that closed
909
+ * between the check and the run, or a `run` body that throws is a Problems row under "Keyboard"
910
+ * rather than an exception out of the keydown listener.
910
911
  */
911
912
  function dispatchKey(registry: CommandRegistry, event: KeyboardEvent): string | undefined {
912
- const commandId = claimChord(registry, event);
913
+ const commandId = registry.handleKeyEvent(event, keyScopeStack(registry.context()));
913
914
  if (commandId) {
914
915
  event.preventDefault();
915
916
  }
916
917
  return commandId;
917
918
  }
918
919
 
919
- /** The id of the command that claimed the chord, whether it ran or refused. */
920
- function claimChord(registry: CommandRegistry, event: KeyboardEvent): string | undefined {
921
- const stack = keyScopeStack(registry.context());
922
- try {
923
- return registry.handleKeyEvent(event, stack);
924
- } catch (error) {
925
- if (error instanceof CommandUnavailableError) {
926
- return error.commandId;
927
- }
928
- throw error;
929
- }
930
- }
931
-
932
920
  // ─── Pointer and wheel gestures ───────────────────────────────────────────────
933
921
 
934
922
  /**
@@ -3,6 +3,7 @@
3
3
  import { getPlatform } from "../platform";
4
4
  import { projectState } from "../store";
5
5
  import type { ComponentMeta } from "../types";
6
+ import { componentMetaFrom } from "@jxsuite/schema/component-meta";
6
7
  import type { JxMutableNode } from "@jxsuite/schema/types";
7
8
 
8
9
  /** A discovered component: project components carry a file path; package components may not. */
@@ -26,6 +27,44 @@ export async function loadComponentRegistry() {
26
27
  }
27
28
  }
28
29
 
30
+ /**
31
+ * Bring the registry up to date with a document that was just saved.
32
+ *
33
+ * The registry is the snapshot the platform's scan took when the project opened, and a saved
34
+ * component definition used to leave it behind: give a prop `format: "image"` in the definition and
35
+ * every instance's Component Settings kept drawing the text box the snapshot knew, until the
36
+ * project was reopened. The saved document is the same input the scan reads, through the same
37
+ * extractor (`componentMetaFrom`, shared by all three backends), so the entry is rebuilt from it
38
+ * here rather than by a rescan of every JSON file in the project. A definition that stopped being a
39
+ * component (its `tagName` lost its hyphen) leaves the registry by the same rule the scan applies.
40
+ *
41
+ * @param {string | null} path - The saved document's project-relative path; `null` for an unsaved
42
+ * draft, which no scan would have seen either.
43
+ * @param {unknown} doc - The saved document.
44
+ * @returns {boolean} Whether the registry changed, so a caller knows to repaint what reads it.
45
+ */
46
+ export function noteComponentSaved(path: string | null, doc: unknown): boolean {
47
+ if (!path) {
48
+ return false;
49
+ }
50
+ const index = componentRegistry.findIndex((c) => c.path === path);
51
+ const meta = componentMetaFrom(doc, path);
52
+ if (!meta) {
53
+ if (index === -1) {
54
+ return false;
55
+ }
56
+ componentRegistry.splice(index, 1);
57
+ return true;
58
+ }
59
+ const entry = meta as ComponentEntry;
60
+ if (index === -1) {
61
+ componentRegistry.push(entry);
62
+ } else {
63
+ componentRegistry[index] = entry;
64
+ }
65
+ return true;
66
+ }
67
+
29
68
  /**
30
69
  * Build a fresh instance definition for a component: $props from prop defaults, plus the
31
70
  * component's slot fallback children cloned in as starting content so editors can edit from the
@@ -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,
@@ -73,6 +74,7 @@ import {
73
74
  import { openCsvGridTab, openPagesGrid } from "../grid/grid-open";
74
75
  import { isViewableMedia, openMediaTab } from "../media/media-open";
75
76
  import { activeRegistry } from "../commands/active-registry";
77
+ import { runActiveReported, runReported } from "../commands/run-reported";
76
78
  import { collectionOfPath } from "../content/entry-model";
77
79
  import { confirmFileDelete, parseSourceForPath, renamePromptMessage } from "./file-ops";
78
80
  import { parseJsonDocument } from "./json-layout";
@@ -404,7 +406,7 @@ export async function openLastSessionOrHome(): Promise<boolean> {
404
406
  if (session) {
405
407
  const opened = await restoreSession(session, {
406
408
  ensureSecondPane: () => {
407
- receivingPane();
409
+ receivingPane(PRIMARY_PANE);
408
410
  },
409
411
  openFile: (path, paneId) => openFileInTab(path, { focus: false, paneId }),
410
412
  });
@@ -1524,6 +1526,11 @@ function fileRowFacts(entry: { path: string; type: string }): Record<string, unk
1524
1526
  }
1525
1527
  return facts;
1526
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;
1527
1534
  if (collectionOfPath(entry.path)) {
1528
1535
  facts.path = entry.path;
1529
1536
  }
@@ -1582,7 +1589,7 @@ function placedFileRows(entry: { path: string; type: string }): MenuRowProjectio
1582
1589
  rows.push(
1583
1590
  reason === undefined
1584
1591
  ? treeMenuRow(command.id, command.title, () => {
1585
- void registry.run(command.id, args);
1592
+ void runReported(registry, command.id, args, "Files");
1586
1593
  })
1587
1594
  : {
1588
1595
  destructive: false,
@@ -2237,23 +2244,36 @@ export interface OpenFileOpts {
2237
2244
  /**
2238
2245
  * Bring an ALREADY-OPEN tab to where the caller asked for it.
2239
2246
  *
2240
- * 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:
2241
2248
  *
2242
- * | the tab is… | behaviour |
2243
- * | ------------------------------------------------ | ---------------------------------------------- |
2244
- * | in the requested pane (or no pane was requested) | activate it there, honouring `focus` |
2245
- * | elsewhere, and **not** its pane's active tab | move it — one tab is one document in one strip |
2246
- * | 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` |
2247
2254
  *
2248
- * 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
2249
2256
  * the author is editing would yank it out of their pane and into the assistant one, and the follow
2250
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.
2251
2271
  */
2252
2272
  function revealOpenTab(tabId: string, opts: OpenFileOpts): void {
2253
2273
  const wanted = opts.paneId;
2254
2274
  const holder = paneOfTab(tabId);
2255
- if (wanted !== undefined && holder && holder.id !== wanted) {
2256
- if (holder.activeTabId === tabId) {
2275
+ if (wanted !== undefined && holder) {
2276
+ if (holder.activeTabId === tabId && opts.focus === false) {
2257
2277
  return;
2258
2278
  }
2259
2279
  moveTabToPane(tabId, wanted);
@@ -2292,7 +2312,7 @@ export async function openFileInTab(path: string, opts: OpenFileOpts = {}) {
2292
2312
  * why a branch anywhere else would send a restored media tab straight back down the error path.
2293
2313
  */
2294
2314
  if (isViewableMedia(path)) {
2295
- openMediaTab(path);
2315
+ openMediaTab(path, opts);
2296
2316
  if (follows) {
2297
2317
  requireProjectState().selectedPath = path;
2298
2318
  }
@@ -2307,8 +2327,10 @@ export async function openFileInTab(path: string, opts: OpenFileOpts = {}) {
2307
2327
  // CSV files open in the grid editor (source mode remains as the raw-text alternate).
2308
2328
  if (path.toLowerCase().endsWith(".csv")) {
2309
2329
  try {
2310
- await openCsvGridTab(path);
2311
- requireProjectState().selectedPath = path;
2330
+ await openCsvGridTab(path, opts);
2331
+ if (follows) {
2332
+ requireProjectState().selectedPath = path;
2333
+ }
2312
2334
  trackRecentFile({
2313
2335
  name: path.split("/").pop() || path,
2314
2336
  path,
@@ -2394,8 +2416,10 @@ export async function openFileInTab(path: string, opts: OpenFileOpts = {}) {
2394
2416
  * Open a file into a NAMED pane, browsing rather than committing, leaving the keyboard behind.
2395
2417
  *
2396
2418
  * The same body as {@link openFileInTab} with the three options a side-open always wants, given a
2397
- * signature that says which pane in the first parameter. It is what "open it beside this" means
2398
- * 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.
2399
2423
  *
2400
2424
  * @param {string} paneId
2401
2425
  * @param {string} path
@@ -2498,7 +2522,7 @@ const FILE_ACTIONS: FilesPanelActions = {
2498
2522
  /* The DECLARED command, run through the registry — never a second opener of this surface's own
2499
2523
  (§12.5). It is the one the ⌘O chord, the palette and the status bar's PROJECT field already
2500
2524
  run, and it is the only one that knows about the picker modal and the "new window" target. */
2501
- void activeRegistry()?.run("project.open");
2525
+ void runActiveReported("project.open", undefined, "Files");
2502
2526
  },
2503
2527
  refresh: () => {
2504
2528
  void refreshFileTree();
@@ -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
+ }