@jxsuite/studio 0.37.1 → 1.1.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 (113) hide show
  1. package/dist/iframe-entry.js +622 -49
  2. package/dist/iframe-entry.js.map +15 -12
  3. package/dist/studio.css +1333 -0
  4. package/dist/studio.js +103709 -81001
  5. package/dist/studio.js.map +201 -157
  6. package/package.json +47 -45
  7. package/src/account-status.ts +39 -0
  8. package/src/browse/browse.ts +10 -14
  9. package/src/canvas/canvas-live-render.ts +21 -2
  10. package/src/canvas/canvas-render.ts +35 -16
  11. package/src/canvas/canvas-utils.ts +118 -72
  12. package/src/canvas/iframe-entry.ts +20 -0
  13. package/src/canvas/iframe-eval.ts +155 -0
  14. package/src/canvas/iframe-host.ts +132 -6
  15. package/src/canvas/iframe-inline-edit.ts +107 -1
  16. package/src/canvas/iframe-protocol.ts +43 -3
  17. package/src/canvas/iframe-render.ts +18 -0
  18. package/src/collab/collab-session.ts +23 -8
  19. package/src/collab/collab-state.ts +6 -3
  20. package/src/component-props.ts +104 -0
  21. package/src/editor/context-menu.ts +1 -1
  22. package/src/editor/inline-edit-apply.ts +36 -1
  23. package/src/editor/inline-edit.ts +58 -1
  24. package/src/editor/repeater-scope.ts +8 -13
  25. package/src/editor/shortcuts.ts +41 -12
  26. package/src/files/file-ops.ts +14 -0
  27. package/src/files/files.ts +56 -1
  28. package/src/format/format-host.ts +63 -1
  29. package/src/grid/cell-editors.ts +288 -0
  30. package/src/grid/cell-popovers.ts +108 -0
  31. package/src/grid/csv-codec.ts +122 -0
  32. package/src/grid/edit-buffer.ts +490 -0
  33. package/src/grid/grid-controller.ts +417 -0
  34. package/src/grid/grid-layout.ts +83 -0
  35. package/src/grid/grid-open.ts +167 -0
  36. package/src/grid/grid-panel.ts +302 -0
  37. package/src/grid/grid-source.ts +210 -0
  38. package/src/grid/grid-view.ts +367 -0
  39. package/src/grid/schema-columns.ts +261 -0
  40. package/src/grid/sources/connector-source.ts +217 -0
  41. package/src/grid/sources/content-source.ts +542 -0
  42. package/src/grid/sources/csv-file-source.ts +247 -0
  43. package/src/grid/tabulator-tables.d.ts +120 -0
  44. package/src/new-project/add-repo-modal.ts +183 -0
  45. package/src/new-project/new-project-modal.ts +22 -3
  46. package/src/page-params.ts +34 -8
  47. package/src/panels/ai-chat/chat-markdown.ts +2 -2
  48. package/src/panels/ai-panel.ts +61 -4
  49. package/src/panels/block-action-bar.ts +8 -3
  50. package/src/panels/chat-panel.ts +98 -0
  51. package/src/panels/data-grid.ts +241 -0
  52. package/src/panels/editors.ts +43 -17
  53. package/src/panels/events-panel.ts +137 -44
  54. package/src/panels/formula-workspace.ts +379 -0
  55. package/src/panels/frontmatter-fields.ts +231 -0
  56. package/src/panels/frontmatter-panel.ts +132 -0
  57. package/src/panels/git-panel.ts +1 -1
  58. package/src/panels/head-panel.ts +11 -175
  59. package/src/panels/properties-panel.ts +154 -144
  60. package/src/panels/right-panel.ts +9 -47
  61. package/src/panels/signals-panel.ts +217 -460
  62. package/src/panels/statement-editor.ts +710 -0
  63. package/src/panels/statusbar.ts +1 -1
  64. package/src/panels/style-panel.ts +25 -1
  65. package/src/panels/stylebook-panel.ts +0 -2
  66. package/src/panels/tab-bar.ts +175 -6
  67. package/src/panels/tab-strip.ts +62 -6
  68. package/src/panels/toolbar.ts +37 -3
  69. package/src/panels/welcome-screen.ts +50 -0
  70. package/src/platform-errors.ts +30 -0
  71. package/src/platforms/cloud.ts +172 -89
  72. package/src/platforms/devserver.ts +172 -0
  73. package/src/services/ai-project-tools.ts +541 -0
  74. package/src/services/ai-session-store.ts +28 -0
  75. package/src/services/ai-system-prompt.ts +194 -24
  76. package/src/services/ai-tools.ts +88 -21
  77. package/src/services/automation.ts +16 -0
  78. package/src/services/context-resolver.ts +73 -0
  79. package/src/services/data-service.ts +155 -0
  80. package/src/services/document-assistant.ts +81 -11
  81. package/src/services/gated-registry.ts +72 -0
  82. package/src/services/live-preview.ts +0 -0
  83. package/src/services/monaco-setup.ts +75 -26
  84. package/src/services/preview-eval.ts +68 -0
  85. package/src/services/project-adoption.ts +31 -0
  86. package/src/settings/contributed-section.ts +406 -0
  87. package/src/settings/extension-sections.ts +145 -0
  88. package/src/settings/schema-field-ui.ts +4 -2
  89. package/src/settings/settings-modal.ts +101 -42
  90. package/src/site-context.ts +12 -1
  91. package/src/store.ts +4 -0
  92. package/src/studio.ts +107 -52
  93. package/src/tabs/patch-ops.ts +25 -0
  94. package/src/tabs/tab.ts +39 -1
  95. package/src/tabs/transact.ts +61 -14
  96. package/src/types.ts +132 -1
  97. package/src/ui/dynamic-slot.ts +272 -0
  98. package/src/ui/expression-editor.ts +423 -125
  99. package/src/ui/field-row.ts +5 -0
  100. package/src/ui/form-controls.ts +322 -0
  101. package/src/ui/formula-catalog.ts +557 -0
  102. package/src/ui/formula-chips.ts +216 -0
  103. package/src/ui/formula-palette.ts +211 -0
  104. package/src/ui/layers.ts +40 -0
  105. package/src/ui/media-picker.ts +1 -1
  106. package/src/ui/panel-resize.ts +15 -1
  107. package/src/ui/progress-modal.ts +2 -2
  108. package/src/ui/schema-form.ts +524 -0
  109. package/src/utils/preview-format.ts +26 -0
  110. package/src/utils/studio-utils.ts +4 -3
  111. package/src/view.ts +4 -4
  112. package/src/workspace/workspace.ts +14 -0
  113. package/src/settings/content-types-editor.ts +0 -599
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Chat-markdown.js — memoized markdown rendering for assistant chat messages.
3
3
  *
4
- * Wraps @jxsuite/parser/md-html (sanitized markdown → HTML) with a per-message cache
4
+ * Wraps @jxsuite/markup/md-html (sanitized markdown → HTML) with a per-message cache
5
5
  * keyed by message id + content length, so re-renders during streaming only re-parse
6
6
  * the message that actually grew. The HTML goes through unsafeHTML, which is safe here
7
7
  * because md-html sanitizes (raw HTML dropped, javascript: URLs stripped).
@@ -12,7 +12,7 @@
12
12
  import { html } from "lit-html";
13
13
  import type { TemplateResult } from "lit-html";
14
14
  import { unsafeHTML } from "lit-html/directives/unsafe-html.js";
15
- import { markdownToHtml } from "@jxsuite/parser/md-html";
15
+ import { markdownToHtml } from "@jxsuite/markup/md-html";
16
16
 
17
17
  const cache = new Map<string, { len: number; html: string }>();
18
18
 
@@ -16,12 +16,13 @@
16
16
  * @license MIT
17
17
  */
18
18
 
19
- import { html, render as litRender } from "lit-html";
19
+ import { html, render as litRender, nothing } from "lit-html";
20
20
  import type { TemplateResult } from "lit-html";
21
+ import { getPlatform } from "../platform";
21
22
  import { effect, effectScope } from "../reactivity";
22
23
  import { createDocumentAssistant } from "../services/document-assistant";
23
24
  import { hasOpenAiKey } from "../services/ai-settings";
24
- import { fetchAvailableModels, isProxyConfigured } from "../services/ai-models";
25
+ import { fetchAvailableModels, isManagedProxy, isProxyConfigured } from "../services/ai-models";
25
26
  import { createAiCredentialsForm } from "../ui/ai-credentials-form";
26
27
  import { clearMarkdownCache } from "./ai-chat/chat-markdown";
27
28
  import { renderChatHeader, renderMessageList } from "./ai-chat/chat-view";
@@ -168,11 +169,67 @@ function startEditApiKey() {
168
169
  credsForm.startEdit();
169
170
  }
170
171
 
171
- /** The OpenAI (or compatible) key + endpoint settings form, shown as a gate when no key is set. */
172
+ // ─── Managed Cloudflare connect (Workers AI) ─────────────────────────────────
173
+
174
+ let cfConnectBusy = false;
175
+ let cfConnectError = "";
176
+
177
+ /**
178
+ * Managed platforms broker Workers AI on the user's own Cloudflare account: offer connecting it as
179
+ * the keyless alternative whenever the proxy says managed-but-unconfigured and the platform can run
180
+ * the hosted OAuth flow (the PAL seam — desktop shells can implement cfConnect later).
181
+ */
182
+ function canOfferManagedConnect(): boolean {
183
+ return isManagedProxy() && !isProxyConfigured() && Boolean(getPlatform().cfConnect);
184
+ }
185
+
186
+ async function connectCloudflareForAi() {
187
+ if (cfConnectBusy) {
188
+ return;
189
+ }
190
+ cfConnectBusy = true;
191
+ cfConnectError = "";
192
+ scheduleAiRender();
193
+ try {
194
+ const connection = await getPlatform().cfConnect?.();
195
+ if (connection) {
196
+ // Re-probe: /models flips to configured once the connection lands, opening the gate.
197
+ await fetchAvailableModels({ force: true });
198
+ } else {
199
+ cfConnectError = "Cloudflare connection was not completed.";
200
+ }
201
+ } catch (error) {
202
+ cfConnectError = error instanceof Error ? error.message : String(error);
203
+ }
204
+ cfConnectBusy = false;
205
+ scheduleAiRender();
206
+ }
207
+
208
+ function renderManagedConnect() {
209
+ return html`
210
+ <div class="ai-managed-connect">
211
+ <div>Use Workers AI on your own Cloudflare account — no API key needed.</div>
212
+ <sp-button size="s" ?disabled=${cfConnectBusy} @click=${() => void connectCloudflareForAi()}>
213
+ ${cfConnectBusy ? "Connecting…" : "Connect Cloudflare"}
214
+ </sp-button>
215
+ ${cfConnectError
216
+ ? html`<div class="ai-managed-connect-error">${cfConnectError}</div>`
217
+ : nothing}
218
+ <div class="ai-managed-connect-divider">— or bring your own key —</div>
219
+ </div>
220
+ `;
221
+ }
222
+
223
+ /**
224
+ * The credentials gate: on managed platforms a keyless "Connect Cloudflare" (Workers AI) option
225
+ * sits above the OpenAI-compatible key form — both are real, working paths.
226
+ */
172
227
  function renderKeyGate() {
173
228
  return html`
174
229
  <div class="ai-tab-body">
175
- <div class="ai-status-center">${credsForm.render()}</div>
230
+ <div class="ai-status-center">
231
+ ${canOfferManagedConnect() ? renderManagedConnect() : nothing} ${credsForm.render()}
232
+ </div>
176
233
  </div>
177
234
  `;
178
235
  }
@@ -606,10 +606,12 @@ export function renderBlockActionBar() {
606
606
  const tag = (node.tagName ?? "div").toLowerCase();
607
607
 
608
608
  // Inline format state, sourced from the iframe's selection snapshot.
609
- const { editing, snapshot } = getEditSnapshot();
609
+ const { editing, editingProp, snapshot } = getEditSnapshot();
610
610
  const inlineEditing = editing;
611
611
  const actions = getInlineActions(tag) || [];
612
- const showFormat = inlineEditing && actions.length > 0;
612
+ // A prop-bound plain session edits a single plain string — never show the format group (belt and
613
+ // Braces: component tags have no $inlineActions, so `actions` is empty there anyway).
614
+ const showFormat = inlineEditing && !editingProp && actions.length > 0;
613
615
  const activeValues =
614
616
  showFormat && snapshot
615
617
  ? actions.filter((a) => snapshot.activeTags.includes(a.tag)).map((a) => a.tag)
@@ -646,7 +648,10 @@ export function renderBlockActionBar() {
646
648
  @click=${badgeInteractive
647
649
  ? (e: MouseEvent) => onTagBadgeClick(e, convertTargets, selection)
648
650
  : nothing}
649
- >${isRepeater ? nodeLabel(node) : node.$id || (node.tagName ?? "div")}</span
651
+ >${isRepeater ? nodeLabel(node) : node.$id || (node.tagName ?? "div")}${inlineEditing &&
652
+ editingProp
653
+ ? ` · ${editingProp}`
654
+ : ""}</span
650
655
  >
651
656
 
652
657
  ${selection.length >= 2
@@ -0,0 +1,98 @@
1
+ /// <reference lib="dom" />
2
+ /**
3
+ * Chat-panel.ts — the persistent AI chat sidebar (`#chat-panel` shell region).
4
+ *
5
+ * Hosts the assistant UI from ai-panel.ts unconditionally: with no project (welcome screen), with a
6
+ * project but no open document, and with a document open. The panel is mounted once at studio boot
7
+ * and never tears down on tab switches — the assistant's module state and DOM (composer draft,
8
+ * scroll position) persist.
9
+ *
10
+ * Deliberately NOT built on createPanelScheduler: ai-panel owns a focus-guard-free rAF render loop
11
+ * (streaming must repaint while the composer is focused). This module only provides the host
12
+ * container, the initial paint, and the pending-agent-prompt handoff; ai-panel's watcher drives all
13
+ * chat-state repaints through the same lit part cache.
14
+ *
15
+ * @license MIT
16
+ */
17
+
18
+ import { render as litRender } from "lit-html";
19
+ import { effect, effectScope } from "../reactivity";
20
+ import { applyPanelCollapse, view } from "../view";
21
+ import { workspace } from "../workspace/workspace";
22
+ import { consumePendingAgentPrompt, hasPendingAgentPrompt } from "../services/agent-seed";
23
+ import {
24
+ bindAiPanelHost,
25
+ mountAiPanel,
26
+ renderAiPanelTemplate,
27
+ seedAssistantPrompt,
28
+ } from "./ai-panel";
29
+
30
+ import type { EffectScope } from "@vue/reactivity";
31
+
32
+ let _host: HTMLElement | null = null;
33
+ let _container: HTMLElement | null = null;
34
+ let _scope: EffectScope | null = null;
35
+
36
+ /**
37
+ * Mount the chat sidebar into its shell region. Idempotent per host: the persistent `.panel-body`
38
+ * container is created once and bound as the ai-panel render host (lit needs a single render target
39
+ * for its part cache). A missing host (shell without a #chat-panel region, e.g. reduced test
40
+ * fixtures) is a no-op.
41
+ *
42
+ * @param {HTMLElement | null} host
43
+ */
44
+ export function mount(host: HTMLElement | null) {
45
+ if (!host || (_host === host && _container)) {
46
+ return;
47
+ }
48
+ _host = host;
49
+ _container = document.createElement("div");
50
+ _container.className = "panel-body";
51
+ host.textContent = "";
52
+ host.append(_container);
53
+
54
+ mountAiPanel();
55
+ // The AI panel owns a focus-guard-free rAF render loop into this container so
56
+ // Streaming repaints while the composer is focused (see ai-panel.ts).
57
+ bindAiPanelHost(_container);
58
+ render();
59
+
60
+ _scope?.stop();
61
+ _scope = effectScope();
62
+ _scope.run(() => {
63
+ effect(() => {
64
+ // A pending agent prompt (stored by the New Project flow, possibly from another window) is
65
+ // Keyed by the absolute project root — consume it as soon as this window adopts that root.
66
+ const root = workspace.projectRoot;
67
+ if (!root || !hasPendingAgentPrompt(root)) {
68
+ return;
69
+ }
70
+ if (view.chatPanelCollapsed) {
71
+ view.chatPanelCollapsed = false;
72
+ applyPanelCollapse();
73
+ }
74
+ const prompt = consumePendingAgentPrompt(root);
75
+ if (prompt) {
76
+ // Defer past the current render so the assistant machinery is in place before the send.
77
+ requestAnimationFrame(() => void seedAssistantPrompt(prompt));
78
+ }
79
+ });
80
+ });
81
+ }
82
+
83
+ export function unmount() {
84
+ _scope?.stop();
85
+ _scope = null;
86
+ if (_host) {
87
+ _host.textContent = "";
88
+ }
89
+ _host = null;
90
+ _container = null;
91
+ }
92
+
93
+ /** Repaint the assistant template into the persistent container (no-op before mount). */
94
+ export function render() {
95
+ if (_container) {
96
+ litRender(renderAiPanelTemplate(), _container);
97
+ }
98
+ }
@@ -0,0 +1,241 @@
1
+ /// <reference lib="dom" />
2
+ /**
3
+ * Data section actions — the owner console entry points over the platform's data surface.
4
+ *
5
+ * Integration: the settings modal's contributed sections stay fully generic — extension-sections
6
+ * passes this module's {@link dataSectionActions} into the ContributedSectionOptions.actions slot
7
+ * for the data-domain sections ("connections"/"data") whenever the platform implements the
8
+ * protocol's data routes. Those actions surface Test Connection, Push Schema (dry-run plan
9
+ * confirmation before apply), and Open Data Grid — which opens the grid-tab source picker; table
10
+ * editing itself lives in the spreadsheet grid tabs (src/grid/), which replaced the old modal grid
11
+ * at feature parity (paging, cell edit, add/delete row) and added batch save with undo.
12
+ */
13
+
14
+ import { html, nothing } from "lit-html";
15
+ import { openModal } from "../ui/layers";
16
+ import { dataSurfaceAvailable, pushSchema, testConnection } from "../services/data-service";
17
+ import { openGridSourcePicker } from "../grid/grid-open";
18
+ import type { TemplateResult } from "lit-html";
19
+ import type { DataConnectionTestResult, DataPushResult } from "../types";
20
+ import type { SectionActionsContext } from "../settings/contributed-section";
21
+
22
+ // ─── Grid opening (delegates to the grid-tab source picker) ──────────────────
23
+
24
+ /** Reset all module UI state and close any open surfaces (test hook / project switch). */
25
+ export function resetDataGridState(): void {
26
+ actionsState = { pushing: false, testResult: null, testing: null };
27
+ closePushDialog();
28
+ }
29
+
30
+ /** True when the platform serves the data grid. */
31
+ export function isDataGridAvailable(): boolean {
32
+ return dataSurfaceAvailable();
33
+ }
34
+
35
+ // ─── Push dialog (dry-run plan confirmation before apply) ─────────────────────
36
+
37
+ interface PushDialogState {
38
+ handle: ReturnType<typeof openModal>;
39
+ connection: string | undefined;
40
+ phase: "loading" | "confirm" | "applying" | "done";
41
+ plan: DataPushResult | null;
42
+ result: DataPushResult | null;
43
+ onDone: () => void;
44
+ }
45
+
46
+ let pushDialog: PushDialogState | null = null;
47
+
48
+ /** Close the push dialog (also part of resetDataGridState). */
49
+ function closePushDialog(): void {
50
+ pushDialog?.handle.close();
51
+ pushDialog = null;
52
+ }
53
+
54
+ /** Start a push: dry-run first, then a confirmation dialog gates the apply. */
55
+ export async function startPush(connection: string | undefined, onDone: () => void) {
56
+ if (pushDialog) {
57
+ return;
58
+ }
59
+ const handle = openModal(html``);
60
+ pushDialog = { connection, handle, onDone, phase: "loading", plan: null, result: null };
61
+ renderPushDialog();
62
+ const plan = await pushSchema({
63
+ dryRun: true,
64
+ ...(connection === undefined ? {} : { connection }),
65
+ });
66
+ if (!pushDialog) {
67
+ return;
68
+ }
69
+ pushDialog.plan = plan;
70
+ pushDialog.phase = "confirm";
71
+ renderPushDialog();
72
+ }
73
+
74
+ async function applyPush(): Promise<void> {
75
+ if (!pushDialog) {
76
+ return;
77
+ }
78
+ pushDialog.phase = "applying";
79
+ renderPushDialog();
80
+ const result = await pushSchema(
81
+ pushDialog.connection === undefined ? {} : { connection: pushDialog.connection },
82
+ );
83
+ if (!pushDialog) {
84
+ return;
85
+ }
86
+ pushDialog.result = result;
87
+ pushDialog.phase = "done";
88
+ renderPushDialog();
89
+ pushDialog.onDone();
90
+ }
91
+
92
+ function pushDialogBody(state: PushDialogState): TemplateResult {
93
+ if (state.phase === "loading" || state.phase === "applying") {
94
+ return html`<div class="push-dialog-status">
95
+ ${state.phase === "loading" ? "Compiling plan…" : "Applying…"}
96
+ </div>`;
97
+ }
98
+ const shown = state.phase === "done" ? state.result : state.plan;
99
+ const steps = shown?.plan ?? [];
100
+ const warnings = shown?.warnings ?? [];
101
+ const errors = shown?.errors ?? [];
102
+ return html`
103
+ <div class="push-dialog-plan">
104
+ ${state.phase === "done"
105
+ ? html`<div class="push-dialog-status">
106
+ ${shown?.applied ? "Schema applied." : "Push failed."}
107
+ </div>`
108
+ : steps.length === 0 && errors.length === 0
109
+ ? html`<div class="push-dialog-status">Nothing to push — the schema is up to date.</div>`
110
+ : nothing}
111
+ <ul class="push-dialog-steps">
112
+ ${steps.map(
113
+ (step) => html`<li class="push-step push-step-${step.kind}">${step.summary}</li>`,
114
+ )}
115
+ </ul>
116
+ ${warnings.map((w) => html`<div class="push-dialog-warning">${w}</div>`)}
117
+ ${errors.map((e) => html`<div class="push-dialog-error">${e}</div>`)}
118
+ </div>
119
+ `;
120
+ }
121
+
122
+ function renderPushDialog(): void {
123
+ if (!pushDialog) {
124
+ return;
125
+ }
126
+ const state = pushDialog;
127
+ const confirmable = state.phase === "confirm" && (state.plan?.plan.length ?? 0) > 0;
128
+ const tpl = html`
129
+ <sp-underlay open @close=${closePushDialog}></sp-underlay>
130
+ <div class="settings-modal push-dialog">
131
+ <div class="settings-modal-header">
132
+ <h2 class="settings-modal-title">
133
+ Push Schema${state.connection ? html` — ${state.connection}` : nothing}
134
+ </h2>
135
+ </div>
136
+ <div class="settings-modal-body">${pushDialogBody(state)}</div>
137
+ <div class="push-dialog-actions">
138
+ <sp-action-button size="s" class="push-cancel" @click=${closePushDialog}>
139
+ ${state.phase === "done" ? "Close" : "Cancel"}
140
+ </sp-action-button>
141
+ ${confirmable
142
+ ? html`<sp-action-button
143
+ size="s"
144
+ emphasized
145
+ class="push-apply"
146
+ @click=${() => void applyPush()}
147
+ >Apply</sp-action-button
148
+ >`
149
+ : nothing}
150
+ </div>
151
+ </div>
152
+ `;
153
+ state.handle.update(tpl);
154
+ }
155
+
156
+ // ─── Contributed-section actions (Test / Push / Open grid) ────────────────────
157
+
158
+ interface ActionsState {
159
+ /** Connection currently being tested, when any. */
160
+ testing: string | null;
161
+ testResult: (DataConnectionTestResult & { connection: string }) | null;
162
+ pushing: boolean;
163
+ }
164
+
165
+ let actionsState: ActionsState = { pushing: false, testResult: null, testing: null };
166
+
167
+ /**
168
+ * The actions renderer for a data-domain contributed section, or null when the section is not
169
+ * data-domain or the platform lacks the data routes. Section keys "connections"/"data" are the
170
+ * connector's host wire contract (the same literals the backend's data routes serve) — the generic
171
+ * contributed-section renderer itself stays extension-agnostic.
172
+ *
173
+ * @param {string} sectionKey
174
+ * @returns {((ctx: SectionActionsContext) => TemplateResult) | null}
175
+ */
176
+ export function dataSectionActions(
177
+ sectionKey: string,
178
+ ): ((ctx: SectionActionsContext) => TemplateResult) | null {
179
+ if ((sectionKey !== "connections" && sectionKey !== "data") || !dataSurfaceAvailable()) {
180
+ return null;
181
+ }
182
+ return (ctx) => renderSectionActions(sectionKey, ctx);
183
+ }
184
+
185
+ async function runTest(connection: string, rerender: () => void): Promise<void> {
186
+ actionsState.testing = connection;
187
+ actionsState.testResult = null;
188
+ rerender();
189
+ const result = await testConnection(connection);
190
+ actionsState.testing = null;
191
+ actionsState.testResult = { ...result, connection };
192
+ rerender();
193
+ }
194
+
195
+ function renderSectionActions(sectionKey: string, ctx: SectionActionsContext): TemplateResult {
196
+ const { selected, rerender } = ctx;
197
+ const { testResult } = actionsState;
198
+ // On the connections section a selected entry scopes both Test and Push to that connection.
199
+ const pushTarget = sectionKey === "connections" && selected ? selected : undefined;
200
+ return html`
201
+ <div class="data-section-actions">
202
+ ${sectionKey === "connections"
203
+ ? html`<sp-action-button
204
+ size="s"
205
+ class="data-action-test"
206
+ ?disabled=${!selected || actionsState.testing !== null}
207
+ @click=${() => {
208
+ if (selected) {
209
+ void runTest(selected, rerender);
210
+ }
211
+ }}
212
+ >
213
+ ${actionsState.testing ? "Testing…" : "Test Connection"}
214
+ </sp-action-button>`
215
+ : nothing}
216
+ <sp-action-button
217
+ size="s"
218
+ class="data-action-push"
219
+ @click=${() => void startPush(pushTarget, rerender)}
220
+ >
221
+ Push Schema
222
+ </sp-action-button>
223
+ <sp-action-button
224
+ size="s"
225
+ class="data-action-grid"
226
+ @click=${() => void openGridSourcePicker()}
227
+ >
228
+ Open Data Grid
229
+ </sp-action-button>
230
+ ${testResult
231
+ ? html`<span
232
+ class="data-test-result ${testResult.ok ? "ok" : "failed"}"
233
+ title=${testResult.error ?? ""}
234
+ >
235
+ ${testResult.connection}:
236
+ ${testResult.ok ? "connected" : (testResult.error ?? "failed")}
237
+ </span>`
238
+ : nothing}
239
+ </div>
240
+ `;
241
+ }
@@ -14,6 +14,7 @@ import { activeTab } from "../workspace/workspace";
14
14
  import { mutateUpdateDef, mutateUpdateProperty, transactDoc } from "../tabs/transact";
15
15
  import { view } from "../view";
16
16
  import { codeService, getFunctionArgs, setLintMarkers } from "../services/code-services";
17
+ import { globalEntries, namedFormulaEntries } from "../ui/formula-catalog";
17
18
 
18
19
  import type { OxLintDiagnostic } from "../services/code-services";
19
20
  import type { JxMutableNode, JxPrototypeDef } from "@jxsuite/schema/types";
@@ -185,7 +186,10 @@ export function registerFunctionCompletions() {
185
186
  view._completionRegistered = true;
186
187
  monaco.languages.registerCompletionItemProvider("javascript", {
187
188
  provideCompletionItems(model, position) {
188
- const defs = activeTab.value?.doc.document?.state || {};
189
+ if (!activeTab.value) {
190
+ return { suggestions: [] };
191
+ }
192
+ const defs = activeTab.value.doc.document?.state || {};
189
193
  const word = model.getWordUntilPosition(position);
190
194
  const range = {
191
195
  endColumn: word.endColumn,
@@ -194,23 +198,45 @@ export function registerFunctionCompletions() {
194
198
  startLineNumber: position.lineNumber,
195
199
  };
196
200
 
197
- const suggestions = Object.entries(defs).map(([key, def]) => {
198
- let kind = monaco.languages.CompletionItemKind.Variable;
199
- if (
200
- (def as JxPrototypeDef)?.$prototype === "Function" ||
201
- (def as Record<string, unknown>)?.$handler
202
- ) {
203
- kind = monaco.languages.CompletionItemKind.Function;
204
- } else if ((def as JxPrototypeDef)?.$prototype) {
205
- kind = monaco.languages.CompletionItemKind.Property;
206
- }
207
- return {
208
- insertText: `state.${key}`,
209
- kind,
210
- label: `state.${key}`,
201
+ // Named-formula catalog metadata enriches their completions with documentation.
202
+ const formulaDocs = new Map(namedFormulaEntries(defs).map((e) => [e.name, e.description]));
203
+
204
+ const suggestions: monaco.languages.CompletionItem[] = Object.entries(defs).map(
205
+ ([key, def]) => {
206
+ let kind = monaco.languages.CompletionItemKind.Variable;
207
+ if (
208
+ (def as JxPrototypeDef)?.$prototype === "Function" ||
209
+ (def as Record<string, unknown>)?.$handler ||
210
+ formulaDocs.has(key)
211
+ ) {
212
+ kind = monaco.languages.CompletionItemKind.Function;
213
+ } else if ((def as JxPrototypeDef)?.$prototype) {
214
+ kind = monaco.languages.CompletionItemKind.Property;
215
+ }
216
+ const item: monaco.languages.CompletionItem = {
217
+ insertText: `state.${key}`,
218
+ kind,
219
+ label: `state.${key}`,
220
+ range,
221
+ };
222
+ const documentation = formulaDocs.get(key);
223
+ if (documentation) {
224
+ item.documentation = documentation;
225
+ }
226
+ return item;
227
+ },
228
+ );
229
+
230
+ // Blessed pure globals from the formula catalog (Math.*, JSON.*, Object.*, …).
231
+ for (const entry of globalEntries()) {
232
+ suggestions.push({
233
+ documentation: entry.description,
234
+ insertText: `window.${entry.label}`,
235
+ kind: monaco.languages.CompletionItemKind.Function,
236
+ label: entry.label,
211
237
  range,
212
- };
213
- });
238
+ });
239
+ }
214
240
  return { suggestions };
215
241
  },
216
242
  triggerCharacters: ["."],