@checkstack/ui 1.9.0 → 1.11.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 (52) hide show
  1. package/CHANGELOG.md +417 -0
  2. package/package.json +15 -7
  3. package/scripts/generate-stdlib-types.ts +2 -2
  4. package/src/components/ActionCard.tsx +221 -0
  5. package/src/components/CodeEditor/CodeEditor.tsx +51 -9
  6. package/src/components/CodeEditor/TypefoxEditor.tsx +868 -0
  7. package/src/components/CodeEditor/bracketKeyGroups.test.ts +120 -0
  8. package/src/components/CodeEditor/bracketKeyGroups.ts +205 -0
  9. package/src/components/CodeEditor/generateTypeDefinitions.ts +4 -4
  10. package/src/components/CodeEditor/index.ts +2 -0
  11. package/src/components/CodeEditor/scriptContext.test.ts +41 -0
  12. package/src/components/CodeEditor/scriptContext.ts +76 -1
  13. package/src/components/CodeEditor/templateValidation.ts +51 -0
  14. package/src/components/CodeEditor/types.ts +109 -0
  15. package/src/components/CodeEditor/validateJsonTemplate.test.ts +61 -0
  16. package/src/components/CodeEditor/validateJsonTemplate.ts +26 -0
  17. package/src/components/CodeEditor/validateXmlTemplate.test.ts +34 -0
  18. package/src/components/CodeEditor/validateXmlTemplate.ts +35 -0
  19. package/src/components/CodeEditor/validateYamlTemplate.test.ts +39 -0
  20. package/src/components/CodeEditor/validateYamlTemplate.ts +28 -0
  21. package/src/components/DynamicForm/DynamicForm.tsx +2 -0
  22. package/src/components/DynamicForm/FormField.tsx +29 -9
  23. package/src/components/DynamicForm/KeyValueEditor.tsx +2 -169
  24. package/src/components/DynamicForm/MultiTypeEditorField.tsx +16 -7
  25. package/src/components/DynamicForm/types.ts +11 -0
  26. package/src/components/ListEmptyState.tsx +51 -0
  27. package/src/components/QueryErrorState.tsx +64 -0
  28. package/src/components/ResponsiveTable.tsx +92 -0
  29. package/src/components/Skeleton.tsx +39 -0
  30. package/src/components/TemplateInput.tsx +104 -0
  31. package/src/components/TemplateInputToggle.tsx +111 -0
  32. package/src/components/TemplateValueInput.test.ts +98 -0
  33. package/src/components/TemplateValueInput.tsx +470 -0
  34. package/src/components/VariablePicker.tsx +271 -0
  35. package/src/hooks/useInitOnceForKey.test.ts +27 -0
  36. package/src/hooks/useInitOnceForKey.ts +21 -18
  37. package/src/index.ts +10 -0
  38. package/src/utils/toastTemplates.test.ts +82 -0
  39. package/src/utils/toastTemplates.ts +47 -0
  40. package/stories/ActionCard.stories.tsx +62 -0
  41. package/stories/Alert.stories.tsx +5 -5
  42. package/stories/ListEmptyState.stories.tsx +48 -0
  43. package/stories/QueryErrorState.stories.tsx +40 -0
  44. package/stories/ResponsiveTable.stories.tsx +93 -0
  45. package/stories/Skeleton.stories.tsx +53 -0
  46. package/stories/TemplateInputToggle.stories.tsx +77 -0
  47. package/stories/TemplateValueInput.stories.tsx +65 -0
  48. package/stories/VariablePicker.stories.tsx +109 -0
  49. package/stories/toastTemplates.stories.tsx +60 -0
  50. package/src/components/CodeEditor/MonacoEditor.tsx +0 -616
  51. package/src/components/CodeEditor/monacoStdlib.ts +0 -62
  52. package/src/components/CodeEditor/monacoWorkers.ts +0 -118
@@ -0,0 +1,868 @@
1
+ /// <reference types="vite/client" />
2
+ // Stage 1 foundation for the monaco-editor -> @typefox/monaco-editor-react
3
+ // migration. This is an ISOLATED test vehicle: it renders a single plain
4
+ // TypeScript editor with completions + diagnostics powered by the STANDALONE
5
+ // (classic) TypeScript language features. It is intentionally NOT wired into
6
+ // the existing `CodeEditor` / `MonacoEditor.tsx` and must NOT be re-exported
7
+ // from the package barrel (`src/index.ts`) because the underlying
8
+ // `@codingame/monaco-vscode-*` stack is browser-only and would break any
9
+ // SSR/test path that imports the barrel.
10
+ //
11
+ // API + config shape verified against the INSTALLED package types
12
+ // (@typefox/monaco-editor-react@7.7.0, monaco-languageclient@10.7.0, all
13
+ // @codingame/monaco-vscode-*@25.1.2) and the canonical standalone/classic
14
+ // worker registration in the upstream repo:
15
+ // https://github.com/TypeFox/monaco-languageclient/blob/main/packages/client/test/support/helper-classic.ts
16
+ // https://github.com/TypeFox/monaco-languageclient/blob/main/packages/client/test/worker/workerLoaders.test.ts
17
+
18
+ // Side-effect import registers the classic Monaco language contributions
19
+ // (Monarch grammars for ~80 languages) without pulling in the extension host
20
+ // (no SharedArrayBuffer / COOP / COEP needed).
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.
25
+ import {
26
+ typescriptDefaults,
27
+ javascriptDefaults,
28
+ ScriptTarget,
29
+ ModuleKind,
30
+ ModuleResolutionKind,
31
+ } from "@codingame/monaco-vscode-standalone-typescript-language-features";
32
+ // Named import also triggers the side-effect registration of the REAL VS Code
33
+ // JSON language service (proper highlighting + completion + folding), replacing
34
+ // the hand-rolled `json-template` Monarch grammar. We turn its built-in
35
+ // (raw-text) validation OFF and validate the template-substituted form instead
36
+ // (see validateJsonTemplate), so templates work in any position - including
37
+ // unquoted ones like a numeric `"timeout": {{x}}`.
38
+ import { jsonDefaults } from "@codingame/monaco-vscode-standalone-json-language-features";
39
+ // Default export is `getServiceOverride()`, returning a service-id -> descriptor
40
+ // map. We register ONLY its `ILanguageStatusService` entry (see
41
+ // `languageStatusServiceOverride` below).
42
+ import getLanguagesServiceOverride from "@codingame/monaco-vscode-languages-service-override";
43
+
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";
59
+ import { MonacoEditorReactComp } from "@typefox/monaco-editor-react";
60
+ import { extractBracketKeyGroups } from "./bracketKeyGroups";
61
+ import { validateJsonTemplate } from "./validateJsonTemplate";
62
+ import { validateYamlTemplate } from "./validateYamlTemplate";
63
+ import { validateXmlTemplate } from "./validateXmlTemplate";
64
+ import type { TemplateDiagnostic } from "./templateValidation";
65
+ import { detectAutoClosedBraces, detectOpenTemplate } from "./templateUtils";
66
+ import {
67
+ buildShellEnvVarInsertText,
68
+ matchShellEnvVarTrigger,
69
+ } from "./shellEnvVarMatcher";
70
+ import type {
71
+ CodeEditorLanguage,
72
+ EditorMarker,
73
+ ShellEnvVar,
74
+ TemplateProperty,
75
+ } from "./types";
76
+ import {
77
+ type EditorAppConfig,
78
+ type TextContents,
79
+ } from "monaco-languageclient/editorApp";
80
+ 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
+
106
+ /**
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.
113
+ */
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,
143
+ };
144
+
145
+ /**
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.
151
+ */
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);
161
+ }
162
+ };
163
+
164
+ configureTypeScriptDefaults();
165
+
166
+ /**
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.
173
+ */
174
+ let stdlibLoadStarted = false;
175
+ const ensureStandaloneStdlib = async (): Promise<void> => {
176
+ if (stdlibLoadStarted) {
177
+ return;
178
+ }
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}`);
185
+ }
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
+ }
193
+ };
194
+
195
+ void ensureStandaloneStdlib();
196
+
197
+ // Turn OFF the JSON service's built-in validation. The editor content is a
198
+ // template that renders to JSON, so we validate the template-substituted form
199
+ // ourselves (see the json validation effect + validateJsonTemplate) to tolerate
200
+ // `{{ }}` in any position. Highlighting + completion from the service stay on.
201
+ jsonDefaults.setDiagnosticsOptions({ validate: false });
202
+
203
+ // Monaco language id per editor language. Matches the ids registered by
204
+ // @codingame/monaco-vscode-standalone-languages (verified: shell is "shell").
205
+ // JSON editors with templates use the custom `json-template` language (below).
206
+ const MONACO_LANGUAGE_ID: Record<CodeEditorLanguage, string> = {
207
+ typescript: "typescript",
208
+ javascript: "javascript",
209
+ json: "json",
210
+ yaml: "yaml",
211
+ xml: "xml",
212
+ markdown: "markdown",
213
+ shell: "shell",
214
+ };
215
+
216
+ // File extension for the model uri, so the language service / grammar keys off
217
+ // a sensible filename.
218
+ const LANGUAGE_FILE_EXT: Record<CodeEditorLanguage, string> = {
219
+ typescript: "ts",
220
+ javascript: "js",
221
+ json: "json",
222
+ yaml: "yaml",
223
+ xml: "xml",
224
+ markdown: "md",
225
+ shell: "sh",
226
+ };
227
+
228
+ const isTsLikeLanguage = (language: CodeEditorLanguage): boolean =>
229
+ language === "typescript" || language === "javascript";
230
+
231
+ // Per-language template-aware validators (markdown has none - no structure to
232
+ // validate). Each validates the template-substituted form so `{{ }}` is
233
+ // tolerated anywhere; see templateValidation.ts.
234
+ const TEMPLATE_VALIDATORS: Partial<
235
+ Record<CodeEditorLanguage, (text: string) => TemplateDiagnostic[]>
236
+ > = {
237
+ json: validateJsonTemplate,
238
+ yaml: validateYamlTemplate,
239
+ xml: validateXmlTemplate,
240
+ };
241
+
242
+ // Variable-like tokens (`{{ template }}` expressions, shell `$env` refs) are
243
+ // highlighted via inline decorations rather than per-language grammars: this
244
+ // 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.
248
+ const VARIABLE_TOKEN_CLASS = "checkstack-editor-variable";
249
+ let variableTokenStyleInjected = false;
250
+ const ensureVariableTokenStyle = (): void => {
251
+ if (variableTokenStyleInjected) {
252
+ return;
253
+ }
254
+ variableTokenStyleInjected = true;
255
+ const style = document.createElement("style");
256
+ // `!important` so the decoration color always wins over the underlying token
257
+ // color (.mtkN), making `{{ }}` look identical inside and outside strings.
258
+ style.textContent = `.${VARIABLE_TOKEN_CLASS}{color:#9cdcfe !important;}`;
259
+ document.head.append(style);
260
+ };
261
+
262
+ // Apply (and keep in sync) inline decorations for every match of `pattern` in
263
+ // the model. Returns a disposable that removes the decorations + listener.
264
+ // `pattern` must be a global (/g) regex; its lastIndex is reset per pass.
265
+ const installRegexDecorations = ({
266
+ model,
267
+ pattern,
268
+ className,
269
+ }: {
270
+ model: monaco.editor.ITextModel;
271
+ pattern: RegExp;
272
+ className: string;
273
+ }): monaco.IDisposable => {
274
+ ensureVariableTokenStyle();
275
+ const compute = (): monaco.editor.IModelDeltaDecoration[] => {
276
+ const text = model.getValue();
277
+ const decorations: monaco.editor.IModelDeltaDecoration[] = [];
278
+ pattern.lastIndex = 0;
279
+ let match = pattern.exec(text);
280
+ while (match !== null) {
281
+ const start = model.getPositionAt(match.index);
282
+ const end = model.getPositionAt(match.index + match[0].length);
283
+ decorations.push({
284
+ range: new monaco.Range(
285
+ start.lineNumber,
286
+ start.column,
287
+ end.lineNumber,
288
+ end.column,
289
+ ),
290
+ options: { inlineClassName: className },
291
+ });
292
+ // Guard against a zero-length match looping forever.
293
+ if (pattern.lastIndex === match.index) {
294
+ pattern.lastIndex += 1;
295
+ }
296
+ match = pattern.exec(text);
297
+ }
298
+ return decorations;
299
+ };
300
+ let decorationIds = model.deltaDecorations([], compute());
301
+ const subscription = model.onDidChangeContent(() => {
302
+ decorationIds = model.deltaDecorations(decorationIds, compute());
303
+ });
304
+ return {
305
+ dispose: () => {
306
+ subscription.dispose();
307
+ model.deltaDecorations(decorationIds, []);
308
+ },
309
+ };
310
+ };
311
+
312
+ const findModelById = (
313
+ modelId: string,
314
+ ): monaco.editor.ITextModel | undefined =>
315
+ monaco.editor
316
+ .getModels()
317
+ .find((candidate) => candidate.uri.toString().includes(modelId));
318
+
319
+ export type TypefoxEditorProps = {
320
+ /** Stable identity for the underlying editor app + model uri. */
321
+ id: string;
322
+ /** Initial source rendered in the editor. */
323
+ value: string;
324
+ /** Notified with the latest editor text whenever it changes. */
325
+ onChange?: (value: string) => void;
326
+ /** Editor language. Defaults to `typescript`. */
327
+ language?: CodeEditorLanguage;
328
+ /** Minimum editor height in pixels. Defaults to 240. */
329
+ minHeight?: number;
330
+ /**
331
+ * Generated ambient type definitions (the `context.d.ts`) injected as a TS
332
+ * extra-lib so `context.*` resolves with real fields. Wired up once per
333
+ * editor at mount, keyed by a unique path - no addExtraLib race.
334
+ * Only used for `typescript` / `javascript` editors.
335
+ */
336
+ typeDefinitions?: string;
337
+ /**
338
+ * Template properties for non-script editors. When provided, typing `{{`
339
+ * autocompletes the available `{{ path }}` references. Only used for
340
+ * markup/text editors (json / yaml / xml / markdown).
341
+ */
342
+ templateProperties?: TemplateProperty[];
343
+ /**
344
+ * Environment-variable hints for `shell` editors. When provided, typing `$`
345
+ * or `${` autocompletes the variable names. Only used when `language` is
346
+ * `shell`.
347
+ */
348
+ shellEnvVars?: ShellEnvVar[];
349
+ /**
350
+ * Externally-computed diagnostics rendered as inline squiggles under a
351
+ * dedicated marker owner (so they coexist with monaco's own markers).
352
+ * Positions are 1-based (monaco convention) - e.g. YAML definition validation.
353
+ */
354
+ markers?: EditorMarker[];
355
+ /** Render the editor read-only. */
356
+ readOnly?: boolean;
357
+ /** Accessible label / hint for the editor (surfaced via aria-label). */
358
+ placeholder?: string;
359
+ };
360
+
361
+ /**
362
+ * Isolated editor used to validate the Typefox/monaco-vscode stack in the
363
+ * browser. Dark theme, no minimap, automatic layout, word-based suggestions
364
+ * disabled. For `typescript`/`javascript` it injects the `context` types +
365
+ * bracket completions; for markup/text languages it offers `{{ }}` template
366
+ * completions.
367
+ */
368
+ // The standalone ("classic") service set omits `ILanguageStatusService`, but
369
+ // the JSON language features register a language-status indicator (the active
370
+ // formatter) on editor focus and throw "LanguageStatusService.addStatus is not
371
+ // supported" without it. We register ONLY that service: the full languages
372
+ // override would also swap `ILanguageService` for the workbench impl and pull
373
+ // in the files-service override, both of which conflict with the standalone
374
+ // language setup. The override map is keyed by the service-decorator id
375
+ // (`createDecorator('ILanguageStatusService')`), so we pick that one entry.
376
+ const LANGUAGE_STATUS_SERVICE_ID = "ILanguageStatusService";
377
+ const languageStatusServiceOverride: monaco.editor.IEditorOverrideServices =
378
+ (() => {
379
+ const all = getLanguagesServiceOverride();
380
+ return LANGUAGE_STATUS_SERVICE_ID in all
381
+ ? { [LANGUAGE_STATUS_SERVICE_ID]: all[LANGUAGE_STATUS_SERVICE_ID] }
382
+ : {};
383
+ })();
384
+
385
+ export const TypefoxEditor = ({
386
+ id,
387
+ value,
388
+ onChange,
389
+ language = "typescript",
390
+ minHeight = 240,
391
+ typeDefinitions,
392
+ templateProperties,
393
+ shellEnvVars,
394
+ markers,
395
+ readOnly = false,
396
+ placeholder,
397
+ }: TypefoxEditorProps) => {
398
+ // `MonacoEditorReactComp` captures `onTextChanged` once at editor-start, so
399
+ // the handler it calls would otherwise close over a stale `onChange` (bound
400
+ // to the value/sibling-config at mount time). Routing through a ref that we
401
+ // keep current on every render means content changes always invoke the
402
+ // latest `onChange` — without this, editing one DynamicForm field reverts
403
+ // sibling fields (e.g. a shell action's `env`) to their mount-time values.
404
+ const onChangeRef = useRef(onChange);
405
+ onChangeRef.current = onChange;
406
+
407
+ // Unique-per-instance id so multiple editors never share a model or clobber
408
+ // each other's extra-lib.
409
+ const reactId = useId();
410
+ const modelId = `${id}-${reactId.replaceAll(":", "")}`;
411
+ const modelUri = `/workspace/${modelId}.${LANGUAGE_FILE_EXT[language]}`;
412
+
413
+ const isTsLike = isTsLikeLanguage(language);
414
+ const hasTemplates =
415
+ templateProperties !== undefined && templateProperties.length > 0;
416
+ const languageId = MONACO_LANGUAGE_ID[language];
417
+
418
+ // Set once the wrapper has initialised the VS Code services, so the
419
+ // completion providers below register against a ready languages registry.
420
+ const [apiReady, setApiReady] = useState(false);
421
+
422
+ useEffect(() => {
423
+ if (!isTsLike || typeDefinitions === undefined) {
424
+ return;
425
+ }
426
+ // Inject this editor's ambient `context` types. addExtraLib keys by path
427
+ // and re-syncs the worker on change, so the types are reliably picked up
428
+ // (no race with model load). NOTE: extra-libs are ambient/global to the TS
429
+ // service, so two TS editors with *different* `context` shapes mounted at
430
+ // once would collide on the `context` identifier - per-editor isolation is
431
+ // a Stage 6 concern, irrelevant to the single-editor test vehicle here.
432
+ const lib = typescriptDefaults.addExtraLib(
433
+ typeDefinitions,
434
+ `file:///context-${modelId}.d.ts`,
435
+ );
436
+ return () => {
437
+ lib.dispose();
438
+ };
439
+ }, [isTsLike, typeDefinitions, modelId]);
440
+
441
+ // Type-driven bracket-notation completions. The standalone TS worker omits
442
+ // object members whose keys aren't valid identifiers (artifact ids like
443
+ // `integration-jira.issue`), and the built-in SuggestAdapter can't be
444
+ // overridden to insert them, so we register our own provider: typing
445
+ // `<objectExpression>.` lists the keys and accepting one rewrites the dot to
446
+ // `["key"]` (mirrors VS Code's `obj."a-b"` -> `obj["a-b"]`). The groups are
447
+ // derived from the injected `context.d.ts` itself, so no separate prop is
448
+ // threaded. Scoped to THIS editor's model so multiple editors don't cross-feed.
449
+ useEffect(() => {
450
+ if (!apiReady || !isTsLike || typeDefinitions === undefined) {
451
+ return;
452
+ }
453
+ const groups = extractBracketKeyGroups({ typeDefinitions });
454
+ if (groups.length === 0) {
455
+ return;
456
+ }
457
+
458
+ const escapeRegExp = (input: string): string =>
459
+ input.replaceAll(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`);
460
+
461
+ const provideCompletionItems = (
462
+ model: monaco.editor.ITextModel,
463
+ position: monaco.Position,
464
+ ): monaco.languages.CompletionList => {
465
+ if (!model.uri.toString().includes(modelId)) {
466
+ return { suggestions: [] };
467
+ }
468
+ const textBefore = model
469
+ .getLineContent(position.lineNumber)
470
+ .slice(0, position.column - 1);
471
+
472
+ for (const { objectExpression, keys } of groups) {
473
+ // Match `<objectExpression>.<query>` at the cursor, ensuring the
474
+ // expression isn't the tail of a longer identifier.
475
+ const match = new RegExp(
476
+ String.raw`(?:^|[^\w$.])${escapeRegExp(objectExpression)}\.([\w$]*)$`,
477
+ ).exec(textBefore);
478
+ if (!match) {
479
+ continue;
480
+ }
481
+ const query = match[1] ?? "";
482
+ const queryStartColumn = position.column - query.length;
483
+ const dotColumn = queryStartColumn - 1;
484
+
485
+ return {
486
+ suggestions: keys.map((key) => ({
487
+ label: `["${key}"]`,
488
+ kind: monaco.languages.CompletionItemKind.Property,
489
+ detail: objectExpression,
490
+ insertText: `["${key}"]`,
491
+ filterText: key,
492
+ range: {
493
+ startLineNumber: position.lineNumber,
494
+ startColumn: queryStartColumn,
495
+ endLineNumber: position.lineNumber,
496
+ endColumn: position.column,
497
+ },
498
+ // Delete the triggering `.` so `obj.` becomes `obj["key"]`.
499
+ additionalTextEdits: [
500
+ {
501
+ range: {
502
+ startLineNumber: position.lineNumber,
503
+ startColumn: dotColumn,
504
+ endLineNumber: position.lineNumber,
505
+ endColumn: dotColumn + 1,
506
+ },
507
+ text: "",
508
+ },
509
+ ],
510
+ })),
511
+ };
512
+ }
513
+ return { suggestions: [] };
514
+ };
515
+
516
+ const disposables = (["typescript", "javascript"] as const).map((lang) =>
517
+ monaco.languages.registerCompletionItemProvider(lang, {
518
+ triggerCharacters: ["."],
519
+ provideCompletionItems,
520
+ }),
521
+ );
522
+ return () => {
523
+ for (const disposable of disposables) {
524
+ disposable.dispose();
525
+ }
526
+ };
527
+ }, [apiReady, isTsLike, typeDefinitions, modelId]);
528
+
529
+ // Template `{{ }}` completion for markup/text editors (json / yaml / xml /
530
+ // markdown). Typing `{{` lists the available `{{ path }}` references; ported
531
+ // from the legacy MonacoEditor template provider (uses the same tested
532
+ // `detectOpenTemplate` / `detectAutoClosedBraces` helpers). Registered for
533
+ // THIS editor's resolved language id and scoped to its model.
534
+ useEffect(() => {
535
+ if (!apiReady || isTsLike || !hasTemplates) {
536
+ return;
537
+ }
538
+ const properties = templateProperties ?? [];
539
+
540
+ const provideCompletionItems = (
541
+ model: monaco.editor.ITextModel,
542
+ position: monaco.Position,
543
+ ): monaco.languages.CompletionList => {
544
+ if (!model.uri.toString().includes(modelId)) {
545
+ return { suggestions: [] };
546
+ }
547
+ const content = model.getValue();
548
+ const cursorOffset = model.getOffsetAt(position);
549
+
550
+ const openTemplate = detectOpenTemplate({ content, cursorOffset });
551
+ if (!openTemplate.isInTemplate) {
552
+ return { suggestions: [] };
553
+ }
554
+
555
+ const query = openTemplate.query.toLowerCase();
556
+ const startColumn = openTemplate.startColumn;
557
+ // Monaco may have auto-closed with `}}` after the cursor; extend the
558
+ // replaced range over it so we don't leave dangling braces.
559
+ const endColumn =
560
+ position.column + detectAutoClosedBraces({ content, cursorOffset });
561
+
562
+ const suggestions = properties
563
+ .filter(
564
+ (prop) => query === "" || prop.path.toLowerCase().includes(query),
565
+ )
566
+ .map((prop, index) => ({
567
+ label: `{{${prop.path}}}`,
568
+ kind: monaco.languages.CompletionItemKind.Variable,
569
+ detail: prop.type,
570
+ documentation: prop.description,
571
+ insertText: `{{${prop.path}}}`,
572
+ // Leading space sorts these above the editor's own suggestions.
573
+ sortText: ` ${String(index).padStart(4, "0")}`,
574
+ filterText: `{{${query}${prop.path}`,
575
+ preselect: index === 0,
576
+ range: {
577
+ startLineNumber: position.lineNumber,
578
+ startColumn,
579
+ endLineNumber: position.lineNumber,
580
+ endColumn,
581
+ },
582
+ }));
583
+
584
+ return { suggestions, incomplete: false };
585
+ };
586
+
587
+ const provider = monaco.languages.registerCompletionItemProvider(
588
+ languageId,
589
+ { triggerCharacters: ["{"], provideCompletionItems },
590
+ );
591
+ return () => {
592
+ provider.dispose();
593
+ };
594
+ }, [
595
+ apiReady,
596
+ isTsLike,
597
+ hasTemplates,
598
+ templateProperties,
599
+ languageId,
600
+ modelId,
601
+ ]);
602
+
603
+ // Highlight `{{ ... }}` template expressions (template editors). The
604
+ // `[^{}]*` body (not `[^}]*`) stops an unclosed `{{` from swallowing text up
605
+ // to a later `}}`.
606
+ useEffect(() => {
607
+ if (!apiReady || isTsLike || !hasTemplates) {
608
+ return;
609
+ }
610
+ const model = findModelById(modelId);
611
+ if (!model) {
612
+ return;
613
+ }
614
+ const handle = installRegexDecorations({
615
+ model,
616
+ pattern: /\{\{[^{}]*\}\}/g,
617
+ className: VARIABLE_TOKEN_CLASS,
618
+ });
619
+ return () => {
620
+ handle.dispose();
621
+ };
622
+ }, [apiReady, isTsLike, hasTemplates, modelId]);
623
+
624
+ // Highlight shell variable references (`$NAME` / `${NAME}`) - the shell
625
+ // grammar doesn't color these inside double-quoted strings, where ours live.
626
+ useEffect(() => {
627
+ if (!apiReady || language !== "shell") {
628
+ return;
629
+ }
630
+ const model = findModelById(modelId);
631
+ if (!model) {
632
+ return;
633
+ }
634
+ const handle = installRegexDecorations({
635
+ model,
636
+ pattern: /\$\{[A-Za-z_]\w*\}|\$[A-Za-z_]\w*/g,
637
+ className: VARIABLE_TOKEN_CLASS,
638
+ });
639
+ return () => {
640
+ handle.dispose();
641
+ };
642
+ }, [apiReady, language, modelId]);
643
+
644
+ // Shell `$env` completion. For `shell` editors, typing `$` or `${` suggests
645
+ // the provided variable names (and brace-closes `${name}` correctly). Ported
646
+ // from the legacy MonacoEditor shell provider; uses the tested
647
+ // `matchShellEnvVarTrigger` / `buildShellEnvVarInsertText` helpers.
648
+ useEffect(() => {
649
+ if (!apiReady || language !== "shell") {
650
+ return;
651
+ }
652
+ if (shellEnvVars === undefined || shellEnvVars.length === 0) {
653
+ return;
654
+ }
655
+ const envVars = shellEnvVars;
656
+
657
+ const provideCompletionItems = (
658
+ model: monaco.editor.ITextModel,
659
+ position: monaco.Position,
660
+ ): monaco.languages.CompletionList => {
661
+ if (!model.uri.toString().includes(modelId)) {
662
+ return { suggestions: [] };
663
+ }
664
+ const lineText = model.getLineContent(position.lineNumber);
665
+ const textBefore = lineText.slice(0, position.column - 1);
666
+ const match = matchShellEnvVarTrigger(textBefore);
667
+ if (!match) {
668
+ return { suggestions: [] };
669
+ }
670
+ const startColumn = position.column - match.prefixLength;
671
+ // `{` auto-closes to `}` in shell, so a braced `${` leaves a `}` right
672
+ // after the cursor. Extend the replace range over it so an accepted
673
+ // `${NAME}` doesn't leave a stray brace (`${NAME}}`).
674
+ const hasAutoClosedBrace =
675
+ match.form === "braced" && lineText[position.column - 1] === "}";
676
+ const endColumn = hasAutoClosedBrace
677
+ ? position.column + 1
678
+ : position.column;
679
+ // filterText must match the text already in the replace range (`${` for
680
+ // braced, `$` for bare) or monaco fuzzy-filters every item out.
681
+ const filterPrefix = match.form === "braced" ? "${" : "$";
682
+
683
+ const suggestions = envVars
684
+ .filter(
685
+ (v) => match.query === "" || v.name.toUpperCase().includes(match.query),
686
+ )
687
+ .map((v, index) => ({
688
+ label: `$${v.name}`,
689
+ kind: monaco.languages.CompletionItemKind.Variable,
690
+ detail: v.example ? `e.g. ${v.example}` : "shell env var",
691
+ // Full name in the (wrapping) docs panel so long CHECKSTACK_* names
692
+ // stay legible even when the suggest-list label truncates.
693
+ documentation: {
694
+ value: [`\`$${v.name}\``, v.description].filter(Boolean).join("\n\n"),
695
+ },
696
+ insertText: buildShellEnvVarInsertText(match, v.name),
697
+ sortText: ` ${String(index).padStart(4, "0")}`,
698
+ filterText: `${filterPrefix}${v.name}`,
699
+ range: {
700
+ startLineNumber: position.lineNumber,
701
+ startColumn,
702
+ endLineNumber: position.lineNumber,
703
+ endColumn,
704
+ },
705
+ }));
706
+
707
+ return { suggestions, incomplete: false };
708
+ };
709
+
710
+ const provider = monaco.languages.registerCompletionItemProvider("shell", {
711
+ triggerCharacters: ["$", "{"],
712
+ provideCompletionItems,
713
+ });
714
+ return () => {
715
+ provider.dispose();
716
+ };
717
+ }, [apiReady, language, shellEnvVars, modelId]);
718
+
719
+ // External validation markers (inline squiggles). Applied under a dedicated
720
+ // owner so they coexist with monaco's own language markers. Ported from the
721
+ // legacy editor; used e.g. for YAML definition validation in AutomationEditPage.
722
+ useEffect(() => {
723
+ if (!apiReady) {
724
+ return;
725
+ }
726
+ const model = monaco.editor
727
+ .getModels()
728
+ .find((candidate) => candidate.uri.toString().includes(modelId));
729
+ if (!model) {
730
+ return;
731
+ }
732
+ const toSeverity = (
733
+ severity: EditorMarker["severity"],
734
+ ): monaco.MarkerSeverity => {
735
+ if (severity === "warning") {
736
+ return monaco.MarkerSeverity.Warning;
737
+ }
738
+ if (severity === "info") {
739
+ return monaco.MarkerSeverity.Info;
740
+ }
741
+ return monaco.MarkerSeverity.Error;
742
+ };
743
+ monaco.editor.setModelMarkers(
744
+ model,
745
+ "external-validation",
746
+ (markers ?? []).map((marker) => ({
747
+ startLineNumber: marker.startLineNumber,
748
+ startColumn: marker.startColumn,
749
+ endLineNumber: marker.endLineNumber,
750
+ endColumn: marker.endColumn,
751
+ message: marker.message,
752
+ severity: toSeverity(marker.severity),
753
+ })),
754
+ );
755
+ return () => {
756
+ monaco.editor.setModelMarkers(model, "external-validation", []);
757
+ };
758
+ }, [apiReady, markers, modelId]);
759
+
760
+ // Template-aware validation for markup languages (json / yaml / xml). The
761
+ // language services' own validation is off (json) or absent (yaml/xml);
762
+ // instead we validate the template-substituted form so `{{ }}` is allowed in
763
+ // any position while real structural errors are still flagged. Recomputed on
764
+ // every edit. Under a dedicated owner so it coexists with the external
765
+ // `markers`.
766
+ useEffect(() => {
767
+ const validate = TEMPLATE_VALIDATORS[language];
768
+ if (!apiReady || !validate) {
769
+ return;
770
+ }
771
+ const model = findModelById(modelId);
772
+ if (!model) {
773
+ return;
774
+ }
775
+ const owner = "template-validation";
776
+ const runValidation = (): void => {
777
+ const diagnostics = validate(model.getValue());
778
+ monaco.editor.setModelMarkers(
779
+ model,
780
+ owner,
781
+ diagnostics.map((diagnostic) => {
782
+ const start = model.getPositionAt(diagnostic.offset);
783
+ const end = model.getPositionAt(
784
+ diagnostic.offset + diagnostic.length,
785
+ );
786
+ return {
787
+ startLineNumber: start.lineNumber,
788
+ startColumn: start.column,
789
+ endLineNumber: end.lineNumber,
790
+ endColumn: end.column,
791
+ message: diagnostic.message,
792
+ severity: monaco.MarkerSeverity.Error,
793
+ };
794
+ }),
795
+ );
796
+ };
797
+ runValidation();
798
+ const subscription = model.onDidChangeContent(() => {
799
+ runValidation();
800
+ });
801
+ return () => {
802
+ subscription.dispose();
803
+ monaco.editor.setModelMarkers(model, owner, []);
804
+ };
805
+ }, [apiReady, language, modelId]);
806
+
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
+ const editorAppConfig: EditorAppConfig = {
823
+ // Unique per instance (modelId includes a useId suffix) so multiple editors
824
+ // sharing the same `id` prop don't collide in the wrapper's app registry.
825
+ id: modelId,
826
+ codeResources: {
827
+ modified: {
828
+ text: value,
829
+ uri: modelUri,
830
+ enforceLanguageId: languageId,
831
+ },
832
+ },
833
+ 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",
838
+ minimap: { enabled: false },
839
+ automaticLayout: true,
840
+ // Force completions to come from the TS language service rather than
841
+ // naive word matching.
842
+ wordBasedSuggestions: "off",
843
+ scrollBeyondLastLine: false,
844
+ readOnly,
845
+ ariaLabel: placeholder ?? "Code editor",
846
+ },
847
+ };
848
+
849
+ const handleTextChanged = (textChanges: TextContents): void => {
850
+ onChangeRef.current?.(textChanges.modified ?? "");
851
+ };
852
+
853
+ return (
854
+ <MonacoEditorReactComp
855
+ style={{ minHeight: `${minHeight}px`, height: `${minHeight}px` }}
856
+ vscodeApiConfig={vscodeApiConfig}
857
+ editorAppConfig={editorAppConfig}
858
+ onTextChanged={handleTextChanged}
859
+ onEditorStartDone={() => {
860
+ // Per-editor ready signal for the completion providers. We use this
861
+ // (not onVscodeApiInitDone) because the vscode API initialises globally
862
+ // once, so a second editor never gets its own onVscodeApiInitDone - but
863
+ // onEditorStartDone fires for each editor instance.
864
+ setApiReady(true);
865
+ }}
866
+ />
867
+ );
868
+ };