@checkstack/ui 1.11.0 → 1.13.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 (71) hide show
  1. package/.storybook/main.ts +43 -0
  2. package/CHANGELOG.md +326 -0
  3. package/package.json +23 -18
  4. package/scripts/generate-stdlib-types.ts +23 -0
  5. package/src/components/Accordion.tsx +17 -9
  6. package/src/components/ActionCard.tsx +99 -11
  7. package/src/components/BrandIcon.tsx +57 -0
  8. package/src/components/CodeEditor/CodeEditor.tsx +159 -14
  9. package/src/components/CodeEditor/TypefoxEditor.tsx +537 -168
  10. package/src/components/CodeEditor/editorTheme.test.ts +41 -0
  11. package/src/components/CodeEditor/editorTheme.ts +26 -0
  12. package/src/components/CodeEditor/generated/builtin-modules.json +1 -0
  13. package/src/components/CodeEditor/importSpecifiers.test.ts +286 -0
  14. package/src/components/CodeEditor/importSpecifiers.ts +267 -0
  15. package/src/components/CodeEditor/index.ts +26 -0
  16. package/src/components/CodeEditor/monacoGuard.ts +76 -0
  17. package/src/components/CodeEditor/monacoTsService.ts +185 -0
  18. package/src/components/CodeEditor/popoutTitle.test.ts +37 -0
  19. package/src/components/CodeEditor/popoutTitle.ts +31 -0
  20. package/src/components/CodeEditor/scriptContext.test.ts +15 -7
  21. package/src/components/CodeEditor/scriptContext.ts +12 -18
  22. package/src/components/CodeEditor/scriptDiagnostics.test.ts +135 -0
  23. package/src/components/CodeEditor/scriptDiagnostics.ts +172 -0
  24. package/src/components/CodeEditor/types.ts +79 -0
  25. package/src/components/CodeEditor/validateScripts.ts +172 -0
  26. package/src/components/CodeEditor/vscodeServicesSignal.ts +72 -0
  27. package/src/components/ConfirmationModal.tsx +7 -1
  28. package/src/components/Dialog.tsx +32 -11
  29. package/src/components/DurationInput.tsx +121 -0
  30. package/src/components/DynamicForm/DynamicForm.tsx +119 -47
  31. package/src/components/DynamicForm/DynamicOptionsField.tsx +19 -14
  32. package/src/components/DynamicForm/FormField.tsx +183 -15
  33. package/src/components/DynamicForm/MultiTypeEditorField.tsx +78 -2
  34. package/src/components/DynamicForm/SecretEnvEditor.tsx +315 -0
  35. package/src/components/DynamicForm/index.ts +20 -0
  36. package/src/components/DynamicForm/secretEnv.logic.test.ts +126 -0
  37. package/src/components/DynamicForm/secretEnv.logic.ts +87 -0
  38. package/src/components/DynamicForm/types.ts +134 -1
  39. package/src/components/DynamicForm/utils.test.ts +38 -0
  40. package/src/components/DynamicForm/utils.ts +54 -0
  41. package/src/components/DynamicForm/validation.logic.test.ts +255 -0
  42. package/src/components/DynamicForm/validation.logic.ts +210 -0
  43. package/src/components/DynamicIcon.tsx +39 -17
  44. package/src/components/Markdown.tsx +68 -2
  45. package/src/components/Popover.tsx +6 -1
  46. package/src/components/ScriptTestPanel.logic.test.ts +139 -0
  47. package/src/components/ScriptTestPanel.logic.ts +137 -0
  48. package/src/components/ScriptTestPanel.tsx +394 -0
  49. package/src/components/Sheet.tsx +21 -6
  50. package/src/components/Spinner.tsx +56 -0
  51. package/src/components/StatusBadge.tsx +78 -0
  52. package/src/components/StrategyConfigCard.tsx +3 -3
  53. package/src/components/Tabs.tsx +7 -1
  54. package/src/components/TimeOfDayInput.tsx +116 -0
  55. package/src/components/UserMenu.logic.test.ts +37 -0
  56. package/src/components/UserMenu.logic.ts +30 -0
  57. package/src/components/UserMenu.tsx +40 -12
  58. package/src/components/comboboxInteraction.ts +39 -0
  59. package/src/components/iconRegistry.tsx +27 -0
  60. package/src/components/portalContainer.ts +24 -0
  61. package/src/index.ts +7 -0
  62. package/stories/ActionCard.stories.tsx +60 -0
  63. package/stories/CodeEditor.stories.tsx +47 -2
  64. package/stories/DurationInput.stories.tsx +59 -0
  65. package/stories/Introduction.mdx +1 -1
  66. package/stories/Markdown.stories.tsx +56 -0
  67. package/stories/ScriptTestPanel.stories.tsx +106 -0
  68. package/stories/SecretEnvEditor.stories.tsx +80 -0
  69. package/stories/Spinner.stories.tsx +90 -0
  70. package/stories/TimeOfDayInput.stories.tsx +34 -0
  71. package/tsconfig.json +4 -0
@@ -19,16 +19,21 @@
19
19
  // (Monarch grammars for ~80 languages) without pulling in the extension host
20
20
  // (no SharedArrayBuffer / COOP / COEP needed).
21
21
  import "@codingame/monaco-vscode-standalone-languages";
22
- // The named imports below ALSO trigger this package's side-effect registration
23
- // of the standalone TypeScript language features (defaults + ts.worker). We use
24
- // them to configure the TS/JS language services + inject ambient context types.
22
+ // TS/JS language-service setup (compiler options, ambient stdlib types, worker
23
+ // factory) lives in the shared `monacoTsService` module so the headless
24
+ // `validateScripts` validator can reuse the exact same configured singletons.
25
25
  import {
26
26
  typescriptDefaults,
27
27
  javascriptDefaults,
28
- ScriptTarget,
29
- ModuleKind,
30
- ModuleResolutionKind,
31
- } from "@codingame/monaco-vscode-standalone-typescript-language-features";
28
+ ensureStandaloneWorkerFactory,
29
+ } from "./monacoTsService";
30
+ import {
31
+ areVscodeServicesReady,
32
+ claimColdInit,
33
+ markVscodeServicesReady,
34
+ onVscodeServicesReady,
35
+ releaseColdInit,
36
+ } from "./vscodeServicesSignal";
32
37
  // Named import also triggers the side-effect registration of the REAL VS Code
33
38
  // JSON language service (proper highlighting + completion + folding), replacing
34
39
  // the hand-rolled `json-template` Monarch grammar. We turn its built-in
@@ -41,21 +46,25 @@ import { jsonDefaults } from "@codingame/monaco-vscode-standalone-json-language-
41
46
  // `languageStatusServiceOverride` below).
42
47
  import getLanguagesServiceOverride from "@codingame/monaco-vscode-languages-service-override";
43
48
 
44
- // Worker entry URLs, bundled and resolved by Vite via the `?worker&url`
45
- // suffix. We import them as URL STRINGS (not Worker constructors) because
46
- // monaco-languageclient's worker factory consumes `loader().url.toString()`.
47
- // Vite's STATIC `?worker&url` resolution is required here: a runtime
48
- // `new URL(specifier, import.meta.url)` would resolve the bare specifier
49
- // relative to THIS source file (e.g. core/ui/src/components/CodeEditor/...)
50
- // and 404. The editor worker comes from the monaco-editor drop-in
51
- // (@codingame/monaco-vscode-editor-api); the TypeScript worker (which also
52
- // serves JavaScript) from the standalone language-features package.
53
- import editorWorkerUrl from "@codingame/monaco-vscode-editor-api/esm/vs/editor/editor.worker.js?worker&url";
54
- import tsWorkerUrl from "@codingame/monaco-vscode-standalone-typescript-language-features/worker?worker&url";
55
- import jsonWorkerUrl from "@codingame/monaco-vscode-standalone-json-language-features/worker?worker&url";
56
-
57
- import { useEffect, useId, useRef, useState } from "react";
58
- import * as monaco from "@codingame/monaco-vscode-editor-api";
49
+
50
+ import {
51
+ type CSSProperties,
52
+ useEffect,
53
+ useId,
54
+ useLayoutEffect,
55
+ useRef,
56
+ useState,
57
+ } from "react";
58
+ // Types come from the package directly (type-only, so it pulls no runtime code
59
+ // and the lint rule allows it). RUNTIME monaco access goes through the guarded
60
+ // accessor (`monacoRuntime`, see monacoGuard.ts): in dev it throws if a
61
+ // `monaco.editor.*` / `monaco.languages.*` function runs before the services
62
+ // are initialized, preventing the "Services are already initialized" regression
63
+ // class. `no-restricted-imports` forbids importing the raw editor-api value.
64
+ import type * as monaco from "@codingame/monaco-vscode-editor-api";
65
+ import { monaco as monacoRuntime } from "./monacoGuard";
66
+ import { useTheme } from "../ThemeProvider";
67
+ import { MONACO_THEME_MAP, VARIABLE_TOKEN_COLOR } from "./editorTheme";
59
68
  import { MonacoEditorReactComp } from "@typefox/monaco-editor-react";
60
69
  import { extractBracketKeyGroups } from "./bracketKeyGroups";
61
70
  import { validateJsonTemplate } from "./validateJsonTemplate";
@@ -68,131 +77,152 @@ import {
68
77
  matchShellEnvVarTrigger,
69
78
  } from "./shellEnvVarMatcher";
70
79
  import type {
80
+ AcquireTypes,
71
81
  CodeEditorLanguage,
72
82
  EditorMarker,
73
83
  ShellEnvVar,
74
84
  TemplateProperty,
75
85
  } from "./types";
86
+ import {
87
+ importSpecifierCompletionContext,
88
+ mergeImportCompletionEntries,
89
+ parseBareImportSpecifiers,
90
+ planAcquisitions,
91
+ } from "./importSpecifiers";
92
+ // Authoritative, build-time-derived list of importable runtime built-in
93
+ // specifiers (`node:fs`, bare `fs`, `bun`, `bun:test`, ...). Generated from the
94
+ // SAME bundled `@types/node` + `bun-types` declarations the editor injects (see
95
+ // scripts/generate-stdlib-types.ts -> extractBuiltinModuleSpecifiers), so the
96
+ // import-name completions never drift from the runtime stdlib. Imported as a
97
+ // plain JSON module (tiny: ~115 names); the bulky type bodies stay in the
98
+ // separately code-split stdlib-types.json.
99
+ import builtinModulesJson from "./generated/builtin-modules.json";
76
100
  import {
77
101
  type EditorAppConfig,
78
102
  type TextContents,
79
103
  } from "monaco-languageclient/editorApp";
80
104
  import { type MonacoVscodeApiConfig } from "monaco-languageclient/vscodeApiWrapper";
81
- import {
82
- // `useWorkerFactory` is a plain library registration function, not a React
83
- // hook. We alias away the `use` prefix so the `react-hooks/rules-of-hooks`
84
- // lint rule (which keys purely off the identifier name) does not misfire.
85
- useWorkerFactory as registerWorkerFactory,
86
- Worker,
87
- type WorkerFactoryConfig,
88
- type WorkerLoader,
89
- } from "monaco-languageclient/workerFactory";
90
-
91
- // The logger type originates from `@codingame/monaco-vscode-log-service-override`,
92
- // which is not a direct dependency of this package. We derive it from the
93
- // `WorkerFactoryConfig` we already import so we never reach for a transitive
94
- // specifier (and never need an `any`).
95
- type WorkerFactoryLogger = WorkerFactoryConfig["logger"];
96
-
97
- const editorWorkerLoader: WorkerLoader = () =>
98
- new Worker(editorWorkerUrl, { type: "module" });
99
-
100
- const tsWorkerLoader: WorkerLoader = () =>
101
- new Worker(tsWorkerUrl, { type: "module" });
102
-
103
- const jsonWorkerLoader: WorkerLoader = () =>
104
- new Worker(jsonWorkerUrl, { type: "module" });
105
+ // ─── Lazy Automatic Type Acquisition (ATA) registry ─────────────────────────
106
+ //
107
+ // The TS/JS language services are singletons, so acquired package types are
108
+ // registered ONCE and shared across every editor instance (a package imported
109
+ // in one script editor is then typed in all of them — harmless, since the
110
+ // declarations are the same install). State is module-scoped:
111
+ //
112
+ // - `acquiredFilePaths`: virtual paths already passed to addExtraLib (dedupe
113
+ // so two editors importing the same package don't double-register a file).
114
+ // - `acquiredSpecifiers`: package names already acquired (skip the fetch).
115
+ // - `acquireResetKey`: the install identity (lockfile hash) the current
116
+ // acquired-set belongs to; when it changes, the set is reset so types
117
+ // refresh against the new install.
118
+ const acquiredFilePaths = new Set<string>();
119
+ const acquiredSpecifiers = new Set<string>();
120
+ let currentAcquireResetKey: string | undefined;
105
121
 
106
122
  /**
107
- * Registers the worker loaders required for the standalone (classic) Monaco
108
- * setup. We only need the generic editor worker plus the TypeScript worker
109
- * (which also serves JavaScript). Mirrors the upstream `defineClassicWorkers`
110
- * helper referenced above. The underlying `useWorkerFactory` export is a
111
- * plain library registration function (not a React hook) despite its name;
112
- * it is called from module scope here, never from a component render.
123
+ * Reset the acquired-set when the install identity changes. The already-
124
+ * registered extra-libs are left in place (disposing them is unnecessary
125
+ * the new install re-registers the same virtual paths, and addExtraLib
126
+ * overwrites by path), but the specifier set clears so each package is
127
+ * re-fetched against the new hash.
113
128
  */
114
- const configureStandaloneWorkerFactory = (
115
- logger?: WorkerFactoryLogger,
116
- ): void => {
117
- registerWorkerFactory({
118
- workerLoaders: {
119
- editorWorkerService: editorWorkerLoader,
120
- // Both must be defined or the worker factory errors (see upstream
121
- // helper-classic.ts).
122
- javascript: tsWorkerLoader,
123
- typescript: tsWorkerLoader,
124
- json: jsonWorkerLoader,
125
- },
126
- logger,
127
- });
128
- };
129
-
130
- // Base compiler options for the standalone TS + JS services. `types` (node +
131
- // bun-types) is added only once the stdlib bundle has loaded (see
132
- // ensureStandaloneStdlib), so the service doesn't transiently error on a
133
- // missing `node` type while the ~3 MB bundle is still fetching.
134
- const BASE_COMPILER_OPTIONS = {
135
- target: ScriptTarget.ESNext,
136
- module: ModuleKind.ESNext,
137
- moduleResolution: ModuleResolutionKind.NodeJs,
138
- lib: ["esnext"],
139
- allowNonTsExtensions: false,
140
- noEmit: true,
141
- strict: true,
142
- esModuleInterop: true,
129
+ const syncAcquireResetKey = (resetKey: string | undefined): void => {
130
+ if (resetKey === currentAcquireResetKey) return;
131
+ currentAcquireResetKey = resetKey;
132
+ acquiredSpecifiers.clear();
133
+ acquiredFilePaths.clear();
143
134
  };
144
135
 
145
136
  /**
146
- * Configure the standalone TS + JS language services ONCE at module load.
147
- * `typescriptDefaults` / `javascriptDefaults` are singletons, so doing this at
148
- * module scope (not per-mount) guarantees the first editor to mount cannot
149
- * start the service with stale defaults - the timing race the legacy monaco
150
- * editor hit.
137
+ * Register one acquired package's declaration files with both the TS and JS
138
+ * services, deduped by virtual path. Paths are `node_modules/...`-relative;
139
+ * we mount each at `file:///<path>` so NodeJs + `@types` resolution finds it.
151
140
  */
152
- const configureTypeScriptDefaults = (): void => {
153
- for (const defaults of [typescriptDefaults, javascriptDefaults]) {
154
- defaults.setCompilerOptions({ ...BASE_COMPILER_OPTIONS });
155
- // 1108: a top-level `return` is valid because the runtime wraps scripts in
156
- // an async IIFE (same suppression as the legacy editor).
157
- defaults.setDiagnosticsOptions({ diagnosticCodesToIgnore: [1108] });
158
- // Push models to the worker eagerly so diagnostics/completions are ready on
159
- // the first keystroke.
160
- defaults.setEagerModelSync(true);
141
+ const registerAcquiredFiles = (
142
+ files: ReadonlyArray<{ path: string; content: string }>,
143
+ ): void => {
144
+ for (const file of files) {
145
+ const uri = `file:///${file.path}`;
146
+ if (acquiredFilePaths.has(uri)) continue;
147
+ acquiredFilePaths.add(uri);
148
+ for (const defaults of [typescriptDefaults, javascriptDefaults]) {
149
+ defaults.addExtraLib(file.content, uri);
150
+ }
161
151
  }
162
152
  };
163
153
 
164
- configureTypeScriptDefaults();
154
+ // ─── @checkstack/sdk editor-type injection ──────────────────────────────────
155
+ //
156
+ // The running release's SDK editor bundle (ambient `.d.ts` for the script
157
+ // helpers + typed client) is mounted ONCE into the shared TS/JS services,
158
+ // keyed by release version. A deployment upgrade changes the key, so the libs
159
+ // reset and the editor never serves stale SDK types (plan §6.2).
160
+ const sdkMountedPaths = new Set<string>();
161
+ let currentSdkResetKey: string | undefined;
165
162
 
166
163
  /**
167
- * Lazy-load the bundled `@types/node` + `bun-types` declarations into the
168
- * standalone TS service so script editors have `console`, `fetch`, `process`,
169
- * `Bun`, etc. typed (parity with the legacy editor). The ~3 MB bundle is
170
- * code-split into its own chunk and fetched once. Ported from the legacy
171
- * `monacoStdlib.ts` (without its `@monaco-editor/react` dependency). Runs at
172
- * module load; this file is browser-only so the dynamic import is safe here.
164
+ * Mount the SDK bundle files, resetting on a release-version change. addExtraLib
165
+ * overwrites by path, so a version bump re-mounts the same virtual paths with
166
+ * fresh content; the mounted-path set just dedupes within a version so two
167
+ * editors don't double-register.
173
168
  */
174
- let stdlibLoadStarted = false;
175
- const ensureStandaloneStdlib = async (): Promise<void> => {
176
- if (stdlibLoadStarted) {
177
- return;
169
+ const mountSdkTypes = ({
170
+ files,
171
+ resetKey,
172
+ }: {
173
+ files: ReadonlyArray<{ path: string; content: string }>;
174
+ resetKey: string | undefined;
175
+ }): void => {
176
+ if (resetKey !== currentSdkResetKey) {
177
+ currentSdkResetKey = resetKey;
178
+ sdkMountedPaths.clear();
178
179
  }
179
- stdlibLoadStarted = true;
180
- const stdlibModule = await import("./generated/stdlib-types.json");
181
- const bundle = stdlibModule.default;
182
- for (const defaults of [typescriptDefaults, javascriptDefaults]) {
183
- for (const [path, content] of Object.entries(bundle)) {
184
- defaults.addExtraLib(content, `file:///${path}`);
180
+ for (const file of files) {
181
+ const uri = `file:///${file.path}`;
182
+ // On a fresh version we re-mount (overwrite) even if the path was seen
183
+ // under a prior key; within a version, skip an already-mounted path.
184
+ if (sdkMountedPaths.has(uri)) continue;
185
+ sdkMountedPaths.add(uri);
186
+ for (const defaults of [typescriptDefaults, javascriptDefaults]) {
187
+ defaults.addExtraLib(file.content, uri);
185
188
  }
186
- // The @types/node + bun-types declarations now exist at their node_modules
187
- // virtual paths, so include them ambiently.
188
- defaults.setCompilerOptions({
189
- ...BASE_COMPILER_OPTIONS,
190
- types: ["node", "bun-types"],
191
- });
192
189
  }
193
190
  };
194
191
 
195
- void ensureStandaloneStdlib();
192
+ /**
193
+ * Acquire types for every NEW bare specifier in `source`, against the given
194
+ * resolver. Pure planning (`parseBareImportSpecifiers` / `planAcquisitions`)
195
+ * is unit-tested; this thin async glue is intentionally untested (no DOM /
196
+ * network in unit tests). A specifier is marked acquired even when it returns
197
+ * no files, so a typeless package isn't re-fetched on every keystroke.
198
+ */
199
+ const runTypeAcquisition = async ({
200
+ source,
201
+ acquireTypes,
202
+ resetKey,
203
+ }: {
204
+ source: string;
205
+ acquireTypes: AcquireTypes;
206
+ resetKey: string | undefined;
207
+ }): Promise<void> => {
208
+ syncAcquireResetKey(resetKey);
209
+ const specifiers = parseBareImportSpecifiers(source);
210
+ const toAcquire = planAcquisitions({
211
+ specifiers,
212
+ acquired: acquiredSpecifiers,
213
+ });
214
+ for (const specifier of toAcquire) {
215
+ // Mark first so concurrent/keystroke re-runs don't double-fetch.
216
+ acquiredSpecifiers.add(specifier);
217
+ try {
218
+ const files = await acquireTypes(specifier);
219
+ registerAcquiredFiles(files);
220
+ } catch {
221
+ // A failed fetch un-marks so a later edit can retry.
222
+ acquiredSpecifiers.delete(specifier);
223
+ }
224
+ }
225
+ };
196
226
 
197
227
  // Turn OFF the JSON service's built-in validation. The editor content is a
198
228
  // template that renders to JSON, so we validate the template-substituted form
@@ -242,20 +272,31 @@ const TEMPLATE_VALIDATORS: Partial<
242
272
  // Variable-like tokens (`{{ template }}` expressions, shell `$env` refs) are
243
273
  // highlighted via inline decorations rather than per-language grammars: this
244
274
  // works for any language (yaml / xml / markdown have no template grammar; shell
245
- // doesn't color `$VAR` inside strings) and keeps the color consistent. #9cdcfe
246
- // is the vs-dark `variable` token color, matching json-template's grammar. The
247
- // CSS class is injected once.
275
+ // doesn't color `$VAR` inside strings) and keeps the color consistent.
276
+ // The style element is injected once and updated whenever the resolved theme
277
+ // changes so the decoration tracks the editor theme.
248
278
  const VARIABLE_TOKEN_CLASS = "checkstack-editor-variable";
249
- let variableTokenStyleInjected = false;
250
- const ensureVariableTokenStyle = (): void => {
251
- if (variableTokenStyleInjected) {
279
+ const VARIABLE_TOKEN_STYLE_ID = "checkstack-editor-variable-style";
280
+
281
+ const ensureVariableTokenStyle = ({
282
+ resolvedTheme,
283
+ }: {
284
+ resolvedTheme: "light" | "dark";
285
+ }): void => {
286
+ const color = VARIABLE_TOKEN_COLOR[resolvedTheme];
287
+ const existing = document.querySelector<HTMLStyleElement>(
288
+ `#${VARIABLE_TOKEN_STYLE_ID}`,
289
+ );
290
+ if (existing !== null) {
291
+ // Update in place so a theme toggle refreshes the color.
292
+ existing.textContent = `.${VARIABLE_TOKEN_CLASS}{color:${color} !important;}`;
252
293
  return;
253
294
  }
254
- variableTokenStyleInjected = true;
255
295
  const style = document.createElement("style");
296
+ style.id = VARIABLE_TOKEN_STYLE_ID;
256
297
  // `!important` so the decoration color always wins over the underlying token
257
298
  // color (.mtkN), making `{{ }}` look identical inside and outside strings.
258
- style.textContent = `.${VARIABLE_TOKEN_CLASS}{color:#9cdcfe !important;}`;
299
+ style.textContent = `.${VARIABLE_TOKEN_CLASS}{color:${color} !important;}`;
259
300
  document.head.append(style);
260
301
  };
261
302
 
@@ -271,7 +312,6 @@ const installRegexDecorations = ({
271
312
  pattern: RegExp;
272
313
  className: string;
273
314
  }): monaco.IDisposable => {
274
- ensureVariableTokenStyle();
275
315
  const compute = (): monaco.editor.IModelDeltaDecoration[] => {
276
316
  const text = model.getValue();
277
317
  const decorations: monaco.editor.IModelDeltaDecoration[] = [];
@@ -281,7 +321,7 @@ const installRegexDecorations = ({
281
321
  const start = model.getPositionAt(match.index);
282
322
  const end = model.getPositionAt(match.index + match[0].length);
283
323
  decorations.push({
284
- range: new monaco.Range(
324
+ range: new monacoRuntime.Range(
285
325
  start.lineNumber,
286
326
  start.column,
287
327
  end.lineNumber,
@@ -312,7 +352,7 @@ const installRegexDecorations = ({
312
352
  const findModelById = (
313
353
  modelId: string,
314
354
  ): monaco.editor.ITextModel | undefined =>
315
- monaco.editor
355
+ monacoRuntime.editor
316
356
  .getModels()
317
357
  .find((candidate) => candidate.uri.toString().includes(modelId));
318
358
 
@@ -327,6 +367,13 @@ export type TypefoxEditorProps = {
327
367
  language?: CodeEditorLanguage;
328
368
  /** Minimum editor height in pixels. Defaults to 240. */
329
369
  minHeight?: number;
370
+ /**
371
+ * When true, the editor container fills its flex parent (`height: 100%`)
372
+ * instead of using a fixed `minHeight` px height, so it grows to fit a tall
373
+ * flex column (e.g. the popout dialog body). `minHeight` is still applied as
374
+ * a floor. Defaults to false, preserving the inline fixed-height behaviour.
375
+ */
376
+ fillHeight?: boolean;
330
377
  /**
331
378
  * Generated ambient type definitions (the `context.d.ts`) injected as a TS
332
379
  * extra-lib so `context.*` resolves with real fields. Wired up once per
@@ -356,11 +403,56 @@ export type TypefoxEditorProps = {
356
403
  readOnly?: boolean;
357
404
  /** Accessible label / hint for the editor (surfaced via aria-label). */
358
405
  placeholder?: string;
406
+ /**
407
+ * Lazy Automatic Type Acquisition resolver. When provided (TS/JS editors),
408
+ * bare `import`/`require` specifiers in the buffer are parsed (debounced)
409
+ * and each NEW package's `.d.ts` closure is fetched + registered so e.g.
410
+ * `import { debounce } from "lodash"` autocompletes. Injected by the
411
+ * consumer so this component stays plugin-agnostic.
412
+ */
413
+ acquireTypes?: AcquireTypes;
414
+ /**
415
+ * Install identity (lockfile hash). When it changes, the shared acquired-set
416
+ * resets so types refresh against the new install.
417
+ */
418
+ acquireResetKey?: string;
419
+ /**
420
+ * The running release's `@checkstack/sdk` editor bundle, as virtual `.d.ts`
421
+ * files to mount (TS/JS editors). Makes `import { defineHealthCheck } from
422
+ * "@checkstack/sdk/healthcheck"` resolve with real, version-matched types.
423
+ * Each file mounts at `file:///<path>` via `addExtraLib`. Fetched live by the
424
+ * consumer (so this component stays network-agnostic + DOM-test-free).
425
+ */
426
+ sdkTypes?: ReadonlyArray<{ path: string; content: string }>;
427
+ /**
428
+ * Release-version reset key for `sdkTypes`. When it changes, the previously
429
+ * mounted SDK libs are reset so the editor never serves stale SDK types after
430
+ * a deployment upgrade.
431
+ */
432
+ sdkTypesResetKey?: string;
433
+ /**
434
+ * Importable installed package NAMES (TS/JS editors). When provided, the
435
+ * editor suggests these as completions while the cursor is inside an import
436
+ * specifier string (`import {} from "lod"` -> `lodash`) - solving the
437
+ * lazy-ATA catch-22 where no module is registered yet. Must already exclude
438
+ * `@types/*` companions (you import `lodash`, never `@types/lodash`).
439
+ * Injected by the consumer so this component stays plugin-agnostic.
440
+ */
441
+ importablePackages?: string[];
442
+ /**
443
+ * When `true`, this editor never CLAIMS the one-time global cold init - it
444
+ * always waits for another (visible) editor to bring the monaco-vscode
445
+ * services up, then mounts. Set this for OFFSCREEN/hidden editors (the
446
+ * automation `ScriptServicesBooter`): a hidden editor's init may never
447
+ * complete, so it must not be the sole initializer. Defaults to `false`.
448
+ */
449
+ deferInit?: boolean;
359
450
  };
360
451
 
361
452
  /**
362
453
  * Isolated editor used to validate the Typefox/monaco-vscode stack in the
363
- * browser. Dark theme, no minimap, automatic layout, word-based suggestions
454
+ * browser. Theme follows `useTheme().resolvedTheme` (`vs` in light mode,
455
+ * `vs-dark` in dark mode), no minimap, automatic layout, word-based suggestions
364
456
  * disabled. For `typescript`/`javascript` it injects the `context` types +
365
457
  * bracket completions; for markup/text languages it offers `{{ }}` template
366
458
  * completions.
@@ -382,19 +474,81 @@ const languageStatusServiceOverride: monaco.editor.IEditorOverrideServices =
382
474
  : {};
383
475
  })();
384
476
 
477
+ // The monaco-vscode global API config. Hoisted to module scope (it has no
478
+ // per-editor state) so it can drive a single, app-lifetime global init below.
479
+ const vscodeApiConfig: MonacoVscodeApiConfig = {
480
+ // 'classic' is the standalone axis (no extension host); 'extended' is the
481
+ // extension-host axis we deliberately avoid in this migration.
482
+ $type: "classic",
483
+ // Register the missing ILanguageStatusService (see the override above) so
484
+ // focusing a JSON editor doesn't throw "addStatus is not supported".
485
+ serviceOverrides: { ...languageStatusServiceOverride },
486
+ // Plain editor, no workbench views.
487
+ viewsConfig: { $type: "EditorService" },
488
+ monacoWorkerFactory: ensureStandaloneWorkerFactory,
489
+ };
490
+
491
+ // ─── How the global monaco-vscode init is serialized ────────────────────────
492
+ //
493
+ // `@codingame/monaco-vscode-api`'s global `initialize()` is ONE-SHOT and can
494
+ // never be torn down or re-run. `@typefox/monaco-editor-react` performs that
495
+ // init itself when a `MonacoEditorReactComp` is given a `vscodeApiConfig`, and
496
+ // that is StrictMode-safe FOR A SINGLE EDITOR (upstream tests cover exactly
497
+ // that). The breakage is two editors on one page (e.g. an open script-action
498
+ // editor PLUS the hidden `ScriptServicesBooter`): both wrappers race the init
499
+ // guard (only set inside an async `start()`), both call `initialize()`, the
500
+ // second throws "Services are already initialized", and the global state is
501
+ // corrupted so NO editor starts. StrictMode (dev only) makes the race
502
+ // deterministic via mount -> unmount -> remount; production works because
503
+ // StrictMode is a no-op there.
504
+ //
505
+ // Fix (per the maintainers' single-editor-init guidance): exactly ONE editor
506
+ // claims the cold init and mounts WITH `vscodeApiConfig` (the proven path);
507
+ // every other editor waits for `areVscodeServicesReady()` and only then mounts
508
+ // (it still passes `vscodeApiConfig`, but @typefox no-ops since the services
509
+ // are already initialized). The hidden booter sets `deferInit` so it never
510
+ // claims - the claimer must be a real, visible editor whose init can complete.
511
+
512
+ // Always-available runtime built-in import specifiers (Node + Bun), derived at
513
+ // build time from the bundled stdlib types. These are importable in the script
514
+ // sandbox regardless of the installed-package allowlist (the sandbox is a Bun
515
+ // subprocess; Bun provides Node's builtins + its own `bun:` modules), and their
516
+ // types are already loaded ambiently via the stdlib bundle - so completing one
517
+ // needs no lazy acquisition. The JSON is a plain `string[]`.
518
+ const BUILTIN_MODULE_SPECIFIERS: readonly string[] = builtinModulesJson;
519
+
520
+ // Passed as the wrapper's `onError` so a wrapper failure is surfaced here
521
+ // instead of becoming an uncaught promise rejection (and so @typefox doesn't
522
+ // reset its internal run-queue lock + re-throw).
523
+ const handleEditorError = (error: Error): void => {
524
+ console.error("[CodeEditor] monaco editor error:", error);
525
+ };
526
+
385
527
  export const TypefoxEditor = ({
386
528
  id,
387
529
  value,
388
530
  onChange,
389
531
  language = "typescript",
390
532
  minHeight = 240,
533
+ fillHeight = false,
391
534
  typeDefinitions,
392
535
  templateProperties,
393
536
  shellEnvVars,
394
537
  markers,
395
538
  readOnly = false,
396
539
  placeholder,
540
+ acquireTypes,
541
+ acquireResetKey,
542
+ sdkTypes,
543
+ sdkTypesResetKey,
544
+ importablePackages,
545
+ deferInit = false,
397
546
  }: TypefoxEditorProps) => {
547
+ // Follow the app's resolved theme so the editor uses `vs` (light) or
548
+ // `vs-dark` (dark) and updates live when the user toggles the theme.
549
+ const { resolvedTheme } = useTheme();
550
+ const monacoTheme = MONACO_THEME_MAP[resolvedTheme];
551
+
398
552
  // `MonacoEditorReactComp` captures `onTextChanged` once at editor-start, so
399
553
  // the handler it calls would otherwise close over a stale `onChange` (bound
400
554
  // to the value/sibling-config at mount time). Routing through a ref that we
@@ -419,8 +573,62 @@ export const TypefoxEditor = ({
419
573
  // completion providers below register against a ready languages registry.
420
574
  const [apiReady, setApiReady] = useState(false);
421
575
 
576
+ // Cold-init serialization (see the block comment above). Tracks whether the
577
+ // global services are up yet, and whether THIS editor is the designated
578
+ // initializer (the one that mounts first, with `vscodeApiConfig`).
579
+ const [servicesReady, setServicesReady] = useState(() =>
580
+ areVscodeServicesReady(),
581
+ );
582
+ const [isInitializer, setIsInitializer] = useState(false);
422
583
  useEffect(() => {
423
- if (!isTsLike || typeDefinitions === undefined) {
584
+ if (servicesReady) return;
585
+ return onVscodeServicesReady(() => setServicesReady(true));
586
+ }, [servicesReady]);
587
+ // Decide the initializer role in a layout effect (NOT during render) so it is
588
+ // StrictMode-safe: StrictMode runs setup -> cleanup -> setup, and the cleanup
589
+ // releases the claim, so exactly one editor ends up the stable holder. A
590
+ // `deferInit` editor (the hidden booter) never claims - the initializer must
591
+ // be a real, visible editor whose @typefox init can actually complete.
592
+ useLayoutEffect(() => {
593
+ if (areVscodeServicesReady() || deferInit) return;
594
+ const claimed = claimColdInit();
595
+ setIsInitializer(claimed);
596
+ return () => {
597
+ if (claimed) releaseColdInit();
598
+ };
599
+ }, [deferInit]);
600
+
601
+ // Mount the wrapper when the services are ready (we attach to them) OR when we
602
+ // are the initializer (we bring them up, passing `vscodeApiConfig`). Until
603
+ // then, a sized, non-animated placeholder so the layout doesn't jump.
604
+ const canMountWrapper = servicesReady || isInitializer;
605
+
606
+ // Keep the Monaco global theme and the variable-token decoration color in
607
+ // sync whenever the resolved app theme changes. Monaco's theme is a global
608
+ // imperative setting (not per-editor), so re-deriving `editorAppConfig`
609
+ // alone is not enough after the editor has started - this effect is what
610
+ // makes live toggling work.
611
+ //
612
+ // GATED on `apiReady`: `monaco.editor.setTheme()` resolves a service via
613
+ // `StandaloneServices.get()`, which AUTO-INITIALIZES the monaco-vscode
614
+ // services if they aren't up yet (CodinGame standaloneServices.js:963). If
615
+ // this ran before the wrapper's own init (e.g. on the deferred booter, which
616
+ // mounts but never gets `apiReady`), it would trip CodinGame's
617
+ // `servicesInitialized` flag and make the wrapper's later `initialize()` throw
618
+ // "Services are already initialized". The initial theme is already applied via
619
+ // `editorAppConfig.editorOptions.theme`; this effect only handles live
620
+ // toggling, which is always after the editor (and thus the API) has started.
621
+ useEffect(() => {
622
+ if (!apiReady) return;
623
+ monacoRuntime.editor.setTheme(monacoTheme);
624
+ ensureVariableTokenStyle({ resolvedTheme });
625
+ }, [apiReady, resolvedTheme, monacoTheme]);
626
+
627
+ useEffect(() => {
628
+ // GATED on `apiReady` for the same reason as the theme effect above:
629
+ // touching `typescriptDefaults` before the wrapper init can auto-initialize
630
+ // the services and collide with the wrapper's `initialize()`.
631
+ if (!apiReady || !isTsLike || typeDefinitions === undefined) {
424
632
  return;
425
633
  }
426
634
  // Inject this editor's ambient `context` types. addExtraLib keys by path
@@ -436,7 +644,150 @@ export const TypefoxEditor = ({
436
644
  return () => {
437
645
  lib.dispose();
438
646
  };
439
- }, [isTsLike, typeDefinitions, modelId]);
647
+ }, [apiReady, isTsLike, typeDefinitions, modelId]);
648
+
649
+ // Mount the @checkstack/sdk editor bundle (script helpers + typed client) so
650
+ // `import ... from "@checkstack/sdk/healthcheck"` resolves with real types.
651
+ // Shared/module-scoped + reset-on-version (mountSdkTypes); the fetch lives in
652
+ // the consumer so this component is network-agnostic + DOM-test-free.
653
+ useEffect(() => {
654
+ if (!apiReady || !isTsLike || sdkTypes === undefined) {
655
+ return;
656
+ }
657
+ mountSdkTypes({ files: sdkTypes, resetKey: sdkTypesResetKey });
658
+ }, [apiReady, isTsLike, sdkTypes, sdkTypesResetKey]);
659
+
660
+ // Lazy Automatic Type Acquisition (ATA). For TS/JS editors with an injected
661
+ // `acquireTypes` resolver, parse the buffer's bare import/require specifiers
662
+ // (debounced) and fetch + register each NEW package's `.d.ts` closure, so
663
+ // `import { x } from "pkg"` autocompletes. The acquired-set is module-scoped
664
+ // and shared across editors (the declarations are install-global); the pure
665
+ // parse/plan steps are unit-tested in importSpecifiers.test.ts. Re-running
666
+ // on a new `acquireTypes`/`acquireResetKey` identity is cheap and safe — the
667
+ // module-scoped acquired-set dedupes, so it never double-fetches.
668
+ useEffect(() => {
669
+ if (!apiReady || !isTsLike || acquireTypes === undefined) {
670
+ return;
671
+ }
672
+ const model = findModelById(modelId);
673
+ if (!model) {
674
+ return;
675
+ }
676
+
677
+ const acquire = (source: string): void => {
678
+ void runTypeAcquisition({
679
+ source,
680
+ acquireTypes,
681
+ resetKey: acquireResetKey,
682
+ });
683
+ };
684
+
685
+ // Run once for the initial content so existing imports resolve on open.
686
+ acquire(model.getValue());
687
+
688
+ let timer: ReturnType<typeof setTimeout> | undefined;
689
+ const subscription = model.onDidChangeContent(() => {
690
+ if (timer !== undefined) clearTimeout(timer);
691
+ timer = setTimeout(() => {
692
+ acquire(model.getValue());
693
+ }, 400);
694
+ });
695
+ return () => {
696
+ if (timer !== undefined) clearTimeout(timer);
697
+ subscription.dispose();
698
+ };
699
+ }, [apiReady, isTsLike, acquireTypes, acquireResetKey, modelId]);
700
+
701
+ // Controlled-value sync. `value` is only seeded into the model at mount
702
+ // (codeResources), so the editor is otherwise uncontrolled. Apply external
703
+ // `value` changes — a sibling editor's edits (the inline ↔ popout pair share
704
+ // one controlled `value`), a YAML→Visual reset, a loaded definition — to this
705
+ // model. Guarded by an equality check: the user's own edit round-trips
706
+ // `value === model.getValue()`, so the actively-edited editor is a no-op and
707
+ // there is no feedback loop; only a background editor whose `value` prop
708
+ // diverged gets updated.
709
+ useEffect(() => {
710
+ if (!apiReady) return;
711
+ const model = findModelById(modelId);
712
+ if (!model || model.getValue() === value) return;
713
+ model.setValue(value);
714
+ }, [apiReady, modelId, value]);
715
+
716
+ // Import-specifier name completions. Lazy ATA only registers a package's
717
+ // types AFTER its name is in the buffer, so while the user is still TYPING
718
+ // the specifier (`import {} from "lod"`) no module exists yet and the TS
719
+ // worker offers nothing. This provider fills that gap: when the cursor is
720
+ // inside an import/require string (detected by the unit-tested
721
+ // `importSpecifierCompletionContext`), it suggests:
722
+ // - the always-available runtime built-ins (`node:fs`, `bun`, ...), so
723
+ // they appear even with an empty allowlist; AND
724
+ // - the injected installed-package names (already `@types/*`-free).
725
+ // Selecting a built-in inserts an already-typed module; selecting an
726
+ // installed package triggers the ATA loop to load its closure. The list is
727
+ // merged + deduped + sorted by `mergeImportCompletionEntries` (a unit-tested
728
+ // pure helper). Built-ins read as "Node.js" / "Bun built-in" via `detail`.
729
+ // Scoped to THIS model; only the import-string position triggers it, so it
730
+ // never pollutes normal completions. Always registered (built-ins are
731
+ // always available), independent of the allowlist.
732
+ useEffect(() => {
733
+ if (!apiReady || !isTsLike) {
734
+ return;
735
+ }
736
+ const entries = mergeImportCompletionEntries({
737
+ builtins: BUILTIN_MODULE_SPECIFIERS,
738
+ installedPackages: importablePackages ?? [],
739
+ });
740
+
741
+ const provideCompletionItems = (
742
+ model: monaco.editor.ITextModel,
743
+ position: monaco.Position,
744
+ ): monaco.languages.CompletionList => {
745
+ if (!model.uri.toString().includes(modelId)) {
746
+ return { suggestions: [] };
747
+ }
748
+ const lineUpToCursor = model
749
+ .getLineContent(position.lineNumber)
750
+ .slice(0, position.column - 1);
751
+ const ctx = importSpecifierCompletionContext(lineUpToCursor);
752
+ if (!ctx) {
753
+ return { suggestions: [] };
754
+ }
755
+ // Replace the whole partial specifier (between the quotes) without
756
+ // touching the quotes themselves.
757
+ const range = {
758
+ startLineNumber: position.lineNumber,
759
+ startColumn: ctx.replaceFromColumn,
760
+ endLineNumber: position.lineNumber,
761
+ endColumn: position.column,
762
+ };
763
+ return {
764
+ suggestions: entries.map((entry) => ({
765
+ label: entry.name,
766
+ kind: monacoRuntime.languages.CompletionItemKind.Module,
767
+ detail: entry.detail,
768
+ insertText: entry.name,
769
+ filterText: entry.name,
770
+ range,
771
+ })),
772
+ // The list is the full known set; let monaco filter by `partial`.
773
+ incomplete: false,
774
+ };
775
+ };
776
+
777
+ const disposables = (["typescript", "javascript"] as const).map((lang) =>
778
+ monacoRuntime.languages.registerCompletionItemProvider(lang, {
779
+ // Opening quotes start a specifier; `:` advances into a `node:`/`bun:`
780
+ // builtin; `/` advances into a scoped name or subpath.
781
+ triggerCharacters: ['"', "'", "/", ":"],
782
+ provideCompletionItems,
783
+ }),
784
+ );
785
+ return () => {
786
+ for (const disposable of disposables) {
787
+ disposable.dispose();
788
+ }
789
+ };
790
+ }, [apiReady, isTsLike, importablePackages, modelId]);
440
791
 
441
792
  // Type-driven bracket-notation completions. The standalone TS worker omits
442
793
  // object members whose keys aren't valid identifiers (artifact ids like
@@ -485,7 +836,7 @@ export const TypefoxEditor = ({
485
836
  return {
486
837
  suggestions: keys.map((key) => ({
487
838
  label: `["${key}"]`,
488
- kind: monaco.languages.CompletionItemKind.Property,
839
+ kind: monacoRuntime.languages.CompletionItemKind.Property,
489
840
  detail: objectExpression,
490
841
  insertText: `["${key}"]`,
491
842
  filterText: key,
@@ -514,7 +865,7 @@ export const TypefoxEditor = ({
514
865
  };
515
866
 
516
867
  const disposables = (["typescript", "javascript"] as const).map((lang) =>
517
- monaco.languages.registerCompletionItemProvider(lang, {
868
+ monacoRuntime.languages.registerCompletionItemProvider(lang, {
518
869
  triggerCharacters: ["."],
519
870
  provideCompletionItems,
520
871
  }),
@@ -565,7 +916,7 @@ export const TypefoxEditor = ({
565
916
  )
566
917
  .map((prop, index) => ({
567
918
  label: `{{${prop.path}}}`,
568
- kind: monaco.languages.CompletionItemKind.Variable,
919
+ kind: monacoRuntime.languages.CompletionItemKind.Variable,
569
920
  detail: prop.type,
570
921
  documentation: prop.description,
571
922
  insertText: `{{${prop.path}}}`,
@@ -584,7 +935,7 @@ export const TypefoxEditor = ({
584
935
  return { suggestions, incomplete: false };
585
936
  };
586
937
 
587
- const provider = monaco.languages.registerCompletionItemProvider(
938
+ const provider = monacoRuntime.languages.registerCompletionItemProvider(
588
939
  languageId,
589
940
  { triggerCharacters: ["{"], provideCompletionItems },
590
941
  );
@@ -686,7 +1037,7 @@ export const TypefoxEditor = ({
686
1037
  )
687
1038
  .map((v, index) => ({
688
1039
  label: `$${v.name}`,
689
- kind: monaco.languages.CompletionItemKind.Variable,
1040
+ kind: monacoRuntime.languages.CompletionItemKind.Variable,
690
1041
  detail: v.example ? `e.g. ${v.example}` : "shell env var",
691
1042
  // Full name in the (wrapping) docs panel so long CHECKSTACK_* names
692
1043
  // stay legible even when the suggest-list label truncates.
@@ -707,7 +1058,7 @@ export const TypefoxEditor = ({
707
1058
  return { suggestions, incomplete: false };
708
1059
  };
709
1060
 
710
- const provider = monaco.languages.registerCompletionItemProvider("shell", {
1061
+ const provider = monacoRuntime.languages.registerCompletionItemProvider("shell", {
711
1062
  triggerCharacters: ["$", "{"],
712
1063
  provideCompletionItems,
713
1064
  });
@@ -723,7 +1074,7 @@ export const TypefoxEditor = ({
723
1074
  if (!apiReady) {
724
1075
  return;
725
1076
  }
726
- const model = monaco.editor
1077
+ const model = monacoRuntime.editor
727
1078
  .getModels()
728
1079
  .find((candidate) => candidate.uri.toString().includes(modelId));
729
1080
  if (!model) {
@@ -733,14 +1084,14 @@ export const TypefoxEditor = ({
733
1084
  severity: EditorMarker["severity"],
734
1085
  ): monaco.MarkerSeverity => {
735
1086
  if (severity === "warning") {
736
- return monaco.MarkerSeverity.Warning;
1087
+ return monacoRuntime.MarkerSeverity.Warning;
737
1088
  }
738
1089
  if (severity === "info") {
739
- return monaco.MarkerSeverity.Info;
1090
+ return monacoRuntime.MarkerSeverity.Info;
740
1091
  }
741
- return monaco.MarkerSeverity.Error;
1092
+ return monacoRuntime.MarkerSeverity.Error;
742
1093
  };
743
- monaco.editor.setModelMarkers(
1094
+ monacoRuntime.editor.setModelMarkers(
744
1095
  model,
745
1096
  "external-validation",
746
1097
  (markers ?? []).map((marker) => ({
@@ -753,7 +1104,7 @@ export const TypefoxEditor = ({
753
1104
  })),
754
1105
  );
755
1106
  return () => {
756
- monaco.editor.setModelMarkers(model, "external-validation", []);
1107
+ monacoRuntime.editor.setModelMarkers(model, "external-validation", []);
757
1108
  };
758
1109
  }, [apiReady, markers, modelId]);
759
1110
 
@@ -775,7 +1126,7 @@ export const TypefoxEditor = ({
775
1126
  const owner = "template-validation";
776
1127
  const runValidation = (): void => {
777
1128
  const diagnostics = validate(model.getValue());
778
- monaco.editor.setModelMarkers(
1129
+ monacoRuntime.editor.setModelMarkers(
779
1130
  model,
780
1131
  owner,
781
1132
  diagnostics.map((diagnostic) => {
@@ -789,7 +1140,7 @@ export const TypefoxEditor = ({
789
1140
  endLineNumber: end.lineNumber,
790
1141
  endColumn: end.column,
791
1142
  message: diagnostic.message,
792
- severity: monaco.MarkerSeverity.Error,
1143
+ severity: monacoRuntime.MarkerSeverity.Error,
793
1144
  };
794
1145
  }),
795
1146
  );
@@ -800,25 +1151,10 @@ export const TypefoxEditor = ({
800
1151
  });
801
1152
  return () => {
802
1153
  subscription.dispose();
803
- monaco.editor.setModelMarkers(model, owner, []);
1154
+ monacoRuntime.editor.setModelMarkers(model, owner, []);
804
1155
  };
805
1156
  }, [apiReady, language, modelId]);
806
1157
 
807
- const vscodeApiConfig: MonacoVscodeApiConfig = {
808
- // 'classic' is the standalone axis (no extension host); 'extended' is the
809
- // extension-host axis we deliberately avoid in this migration.
810
- $type: "classic",
811
- // Register the missing ILanguageStatusService (see the override comment
812
- // above) so focusing a JSON editor doesn't throw "addStatus is not
813
- // supported".
814
- serviceOverrides: { ...languageStatusServiceOverride },
815
- viewsConfig: {
816
- // Plain editor, no workbench views.
817
- $type: "EditorService",
818
- },
819
- monacoWorkerFactory: configureStandaloneWorkerFactory,
820
- };
821
-
822
1158
  const editorAppConfig: EditorAppConfig = {
823
1159
  // Unique per instance (modelId includes a useId suffix) so multiple editors
824
1160
  // sharing the same `id` prop don't collide in the wrapper's app registry.
@@ -831,10 +1167,12 @@ export const TypefoxEditor = ({
831
1167
  },
832
1168
  },
833
1169
  editorOptions: {
834
- // 'vs-dark' is the builtin classic dark theme. (The VS Code
835
- // 'Default Dark Modern' theme would require the extension-host
836
- // theme-defaults extension, which the standalone setup omits.)
837
- theme: "vs-dark",
1170
+ // Derived from useTheme().resolvedTheme: "vs" for light, "vs-dark" for
1171
+ // dark. These are the two built-in classic themes available in the
1172
+ // standalone setup (the VS Code 'Default Dark Modern' theme requires the
1173
+ // extension-host theme-defaults extension, which the standalone setup
1174
+ // omits).
1175
+ theme: monacoTheme,
838
1176
  minimap: { enabled: false },
839
1177
  automaticLayout: true,
840
1178
  // Force completions to come from the TS language service rather than
@@ -850,18 +1188,49 @@ export const TypefoxEditor = ({
850
1188
  onChangeRef.current?.(textChanges.modified ?? "");
851
1189
  };
852
1190
 
1191
+ // In `fillHeight` mode the container takes its parent's height so Monaco's
1192
+ // `automaticLayout` resizes to fill a tall flex column (the popout body);
1193
+ // `minHeight` stays as a floor. Otherwise the inline fixed-px behaviour is
1194
+ // preserved exactly.
1195
+ const containerStyle: CSSProperties = fillHeight
1196
+ ? { minHeight: `${minHeight}px`, height: "100%" }
1197
+ : { minHeight: `${minHeight}px`, height: `${minHeight}px` };
1198
+
1199
+ // Non-initializer editors wait for the services to be up before mounting (a
1200
+ // sized, non-animated placeholder until then, so the layout doesn't jump).
1201
+ // The initializer mounts immediately and brings the services up.
1202
+ if (!canMountWrapper) {
1203
+ return (
1204
+ <div
1205
+ style={containerStyle}
1206
+ className="w-full rounded-md bg-muted"
1207
+ aria-busy="true"
1208
+ aria-label={placeholder ?? "Loading editor"}
1209
+ />
1210
+ );
1211
+ }
1212
+
853
1213
  return (
854
1214
  <MonacoEditorReactComp
855
- style={{ minHeight: `${minHeight}px`, height: `${minHeight}px` }}
1215
+ style={containerStyle}
856
1216
  vscodeApiConfig={vscodeApiConfig}
857
1217
  editorAppConfig={editorAppConfig}
858
1218
  onTextChanged={handleTextChanged}
1219
+ // Route wrapper errors to our handler rather than letting @typefox reset
1220
+ // its run-queue lock and re-throw as an uncaught rejection (recommended by
1221
+ // the monaco-languageclient maintainers).
1222
+ onError={handleEditorError}
859
1223
  onEditorStartDone={() => {
860
1224
  // Per-editor ready signal for the completion providers. We use this
861
1225
  // (not onVscodeApiInitDone) because the vscode API initialises globally
862
1226
  // once, so a second editor never gets its own onVscodeApiInitDone - but
863
1227
  // onEditorStartDone fires for each editor instance.
864
1228
  setApiReady(true);
1229
+ // The monaco-vscode services are now up. Let the headless script
1230
+ // validator know it may safely use the worker (it must never init the
1231
+ // services itself - that would collide with this wrapper's one-time
1232
+ // init and throw "Services are already initialized").
1233
+ markVscodeServicesReady();
865
1234
  }}
866
1235
  />
867
1236
  );