@checkstack/ui 1.10.0 → 1.12.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.
- package/.storybook/main.ts +43 -0
- package/CHANGELOG.md +565 -0
- package/package.json +15 -7
- package/scripts/generate-stdlib-types.ts +25 -2
- package/src/components/ActionCard.tsx +309 -0
- package/src/components/CodeEditor/CodeEditor.tsx +132 -9
- package/src/components/CodeEditor/TypefoxEditor.tsx +1024 -0
- package/src/components/CodeEditor/bracketKeyGroups.test.ts +120 -0
- package/src/components/CodeEditor/bracketKeyGroups.ts +205 -0
- package/src/components/CodeEditor/generateTypeDefinitions.ts +4 -4
- package/src/components/CodeEditor/generated/builtin-modules.json +1 -0
- package/src/components/CodeEditor/importSpecifiers.test.ts +286 -0
- package/src/components/CodeEditor/importSpecifiers.ts +267 -0
- package/src/components/CodeEditor/index.ts +26 -0
- package/src/components/CodeEditor/monacoTsService.ts +217 -0
- package/src/components/CodeEditor/popoutTitle.test.ts +37 -0
- package/src/components/CodeEditor/popoutTitle.ts +31 -0
- package/src/components/CodeEditor/scriptContext.test.ts +41 -0
- package/src/components/CodeEditor/scriptContext.ts +76 -1
- package/src/components/CodeEditor/scriptDiagnostics.test.ts +135 -0
- package/src/components/CodeEditor/scriptDiagnostics.ts +172 -0
- package/src/components/CodeEditor/templateValidation.ts +51 -0
- package/src/components/CodeEditor/types.ts +168 -0
- package/src/components/CodeEditor/validateJsonTemplate.test.ts +61 -0
- package/src/components/CodeEditor/validateJsonTemplate.ts +26 -0
- package/src/components/CodeEditor/validateScripts.ts +132 -0
- package/src/components/CodeEditor/validateXmlTemplate.test.ts +34 -0
- package/src/components/CodeEditor/validateXmlTemplate.ts +35 -0
- package/src/components/CodeEditor/validateYamlTemplate.test.ts +39 -0
- package/src/components/CodeEditor/validateYamlTemplate.ts +28 -0
- package/src/components/Dialog.tsx +32 -11
- package/src/components/DurationInput.tsx +121 -0
- package/src/components/DynamicForm/DynamicForm.tsx +27 -1
- package/src/components/DynamicForm/FormField.tsx +138 -10
- package/src/components/DynamicForm/KeyValueEditor.tsx +2 -169
- package/src/components/DynamicForm/MultiTypeEditorField.tsx +83 -9
- package/src/components/DynamicForm/SecretEnvEditor.tsx +315 -0
- package/src/components/DynamicForm/index.ts +6 -0
- package/src/components/DynamicForm/secretEnv.logic.test.ts +126 -0
- package/src/components/DynamicForm/secretEnv.logic.ts +87 -0
- package/src/components/DynamicForm/types.ts +83 -1
- package/src/components/DynamicForm/utils.ts +32 -0
- package/src/components/Popover.tsx +6 -1
- package/src/components/ScriptTestPanel.logic.test.ts +139 -0
- package/src/components/ScriptTestPanel.logic.ts +137 -0
- package/src/components/ScriptTestPanel.tsx +394 -0
- package/src/components/Sheet.tsx +21 -6
- package/src/components/TemplateInput.tsx +104 -0
- package/src/components/TemplateInputToggle.tsx +111 -0
- package/src/components/TemplateValueInput.test.ts +98 -0
- package/src/components/TemplateValueInput.tsx +470 -0
- package/src/components/TimeOfDayInput.tsx +116 -0
- package/src/components/VariablePicker.tsx +271 -0
- package/src/components/comboboxInteraction.ts +39 -0
- package/src/components/portalContainer.ts +24 -0
- package/src/hooks/useInitOnceForKey.test.ts +27 -0
- package/src/hooks/useInitOnceForKey.ts +21 -18
- package/src/index.ts +9 -0
- package/stories/ActionCard.stories.tsx +122 -0
- package/stories/Alert.stories.tsx +5 -5
- package/stories/CodeEditor.stories.tsx +47 -2
- package/stories/DurationInput.stories.tsx +59 -0
- package/stories/ScriptTestPanel.stories.tsx +106 -0
- package/stories/SecretEnvEditor.stories.tsx +80 -0
- package/stories/TemplateInputToggle.stories.tsx +77 -0
- package/stories/TemplateValueInput.stories.tsx +65 -0
- package/stories/TimeOfDayInput.stories.tsx +34 -0
- package/stories/VariablePicker.stories.tsx +109 -0
- package/tsconfig.json +1 -0
- package/src/components/CodeEditor/MonacoEditor.tsx +0 -616
- package/src/components/CodeEditor/monacoStdlib.ts +0 -62
- package/src/components/CodeEditor/monacoWorkers.ts +0 -118
|
@@ -0,0 +1,1024 @@
|
|
|
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
|
+
// 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
|
+
import {
|
|
26
|
+
typescriptDefaults,
|
|
27
|
+
javascriptDefaults,
|
|
28
|
+
ensureStandaloneWorkerFactory,
|
|
29
|
+
markVscodeServicesReady,
|
|
30
|
+
} from "./monacoTsService";
|
|
31
|
+
// Named import also triggers the side-effect registration of the REAL VS Code
|
|
32
|
+
// JSON language service (proper highlighting + completion + folding), replacing
|
|
33
|
+
// the hand-rolled `json-template` Monarch grammar. We turn its built-in
|
|
34
|
+
// (raw-text) validation OFF and validate the template-substituted form instead
|
|
35
|
+
// (see validateJsonTemplate), so templates work in any position - including
|
|
36
|
+
// unquoted ones like a numeric `"timeout": {{x}}`.
|
|
37
|
+
import { jsonDefaults } from "@codingame/monaco-vscode-standalone-json-language-features";
|
|
38
|
+
// Default export is `getServiceOverride()`, returning a service-id -> descriptor
|
|
39
|
+
// map. We register ONLY its `ILanguageStatusService` entry (see
|
|
40
|
+
// `languageStatusServiceOverride` below).
|
|
41
|
+
import getLanguagesServiceOverride from "@codingame/monaco-vscode-languages-service-override";
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
import { type CSSProperties, useEffect, useId, useRef, useState } from "react";
|
|
45
|
+
import * as monaco from "@codingame/monaco-vscode-editor-api";
|
|
46
|
+
import { MonacoEditorReactComp } from "@typefox/monaco-editor-react";
|
|
47
|
+
import { extractBracketKeyGroups } from "./bracketKeyGroups";
|
|
48
|
+
import { validateJsonTemplate } from "./validateJsonTemplate";
|
|
49
|
+
import { validateYamlTemplate } from "./validateYamlTemplate";
|
|
50
|
+
import { validateXmlTemplate } from "./validateXmlTemplate";
|
|
51
|
+
import type { TemplateDiagnostic } from "./templateValidation";
|
|
52
|
+
import { detectAutoClosedBraces, detectOpenTemplate } from "./templateUtils";
|
|
53
|
+
import {
|
|
54
|
+
buildShellEnvVarInsertText,
|
|
55
|
+
matchShellEnvVarTrigger,
|
|
56
|
+
} from "./shellEnvVarMatcher";
|
|
57
|
+
import type {
|
|
58
|
+
AcquireTypes,
|
|
59
|
+
CodeEditorLanguage,
|
|
60
|
+
EditorMarker,
|
|
61
|
+
ShellEnvVar,
|
|
62
|
+
TemplateProperty,
|
|
63
|
+
} from "./types";
|
|
64
|
+
import {
|
|
65
|
+
importSpecifierCompletionContext,
|
|
66
|
+
mergeImportCompletionEntries,
|
|
67
|
+
parseBareImportSpecifiers,
|
|
68
|
+
planAcquisitions,
|
|
69
|
+
} from "./importSpecifiers";
|
|
70
|
+
// Authoritative, build-time-derived list of importable runtime built-in
|
|
71
|
+
// specifiers (`node:fs`, bare `fs`, `bun`, `bun:test`, ...). Generated from the
|
|
72
|
+
// SAME bundled `@types/node` + `bun-types` declarations the editor injects (see
|
|
73
|
+
// scripts/generate-stdlib-types.ts -> extractBuiltinModuleSpecifiers), so the
|
|
74
|
+
// import-name completions never drift from the runtime stdlib. Imported as a
|
|
75
|
+
// plain JSON module (tiny: ~115 names); the bulky type bodies stay in the
|
|
76
|
+
// separately code-split stdlib-types.json.
|
|
77
|
+
import builtinModulesJson from "./generated/builtin-modules.json";
|
|
78
|
+
import {
|
|
79
|
+
type EditorAppConfig,
|
|
80
|
+
type TextContents,
|
|
81
|
+
} from "monaco-languageclient/editorApp";
|
|
82
|
+
import { type MonacoVscodeApiConfig } from "monaco-languageclient/vscodeApiWrapper";
|
|
83
|
+
// ─── Lazy Automatic Type Acquisition (ATA) registry ─────────────────────────
|
|
84
|
+
//
|
|
85
|
+
// The TS/JS language services are singletons, so acquired package types are
|
|
86
|
+
// registered ONCE and shared across every editor instance (a package imported
|
|
87
|
+
// in one script editor is then typed in all of them — harmless, since the
|
|
88
|
+
// declarations are the same install). State is module-scoped:
|
|
89
|
+
//
|
|
90
|
+
// - `acquiredFilePaths`: virtual paths already passed to addExtraLib (dedupe
|
|
91
|
+
// so two editors importing the same package don't double-register a file).
|
|
92
|
+
// - `acquiredSpecifiers`: package names already acquired (skip the fetch).
|
|
93
|
+
// - `acquireResetKey`: the install identity (lockfile hash) the current
|
|
94
|
+
// acquired-set belongs to; when it changes, the set is reset so types
|
|
95
|
+
// refresh against the new install.
|
|
96
|
+
const acquiredFilePaths = new Set<string>();
|
|
97
|
+
const acquiredSpecifiers = new Set<string>();
|
|
98
|
+
let currentAcquireResetKey: string | undefined;
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Reset the acquired-set when the install identity changes. The already-
|
|
102
|
+
* registered extra-libs are left in place (disposing them is unnecessary —
|
|
103
|
+
* the new install re-registers the same virtual paths, and addExtraLib
|
|
104
|
+
* overwrites by path), but the specifier set clears so each package is
|
|
105
|
+
* re-fetched against the new hash.
|
|
106
|
+
*/
|
|
107
|
+
const syncAcquireResetKey = (resetKey: string | undefined): void => {
|
|
108
|
+
if (resetKey === currentAcquireResetKey) return;
|
|
109
|
+
currentAcquireResetKey = resetKey;
|
|
110
|
+
acquiredSpecifiers.clear();
|
|
111
|
+
acquiredFilePaths.clear();
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Register one acquired package's declaration files with both the TS and JS
|
|
116
|
+
* services, deduped by virtual path. Paths are `node_modules/...`-relative;
|
|
117
|
+
* we mount each at `file:///<path>` so NodeJs + `@types` resolution finds it.
|
|
118
|
+
*/
|
|
119
|
+
const registerAcquiredFiles = (
|
|
120
|
+
files: ReadonlyArray<{ path: string; content: string }>,
|
|
121
|
+
): void => {
|
|
122
|
+
for (const file of files) {
|
|
123
|
+
const uri = `file:///${file.path}`;
|
|
124
|
+
if (acquiredFilePaths.has(uri)) continue;
|
|
125
|
+
acquiredFilePaths.add(uri);
|
|
126
|
+
for (const defaults of [typescriptDefaults, javascriptDefaults]) {
|
|
127
|
+
defaults.addExtraLib(file.content, uri);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Acquire types for every NEW bare specifier in `source`, against the given
|
|
134
|
+
* resolver. Pure planning (`parseBareImportSpecifiers` / `planAcquisitions`)
|
|
135
|
+
* is unit-tested; this thin async glue is intentionally untested (no DOM /
|
|
136
|
+
* network in unit tests). A specifier is marked acquired even when it returns
|
|
137
|
+
* no files, so a typeless package isn't re-fetched on every keystroke.
|
|
138
|
+
*/
|
|
139
|
+
const runTypeAcquisition = async ({
|
|
140
|
+
source,
|
|
141
|
+
acquireTypes,
|
|
142
|
+
resetKey,
|
|
143
|
+
}: {
|
|
144
|
+
source: string;
|
|
145
|
+
acquireTypes: AcquireTypes;
|
|
146
|
+
resetKey: string | undefined;
|
|
147
|
+
}): Promise<void> => {
|
|
148
|
+
syncAcquireResetKey(resetKey);
|
|
149
|
+
const specifiers = parseBareImportSpecifiers(source);
|
|
150
|
+
const toAcquire = planAcquisitions({
|
|
151
|
+
specifiers,
|
|
152
|
+
acquired: acquiredSpecifiers,
|
|
153
|
+
});
|
|
154
|
+
for (const specifier of toAcquire) {
|
|
155
|
+
// Mark first so concurrent/keystroke re-runs don't double-fetch.
|
|
156
|
+
acquiredSpecifiers.add(specifier);
|
|
157
|
+
try {
|
|
158
|
+
const files = await acquireTypes(specifier);
|
|
159
|
+
registerAcquiredFiles(files);
|
|
160
|
+
} catch {
|
|
161
|
+
// A failed fetch un-marks so a later edit can retry.
|
|
162
|
+
acquiredSpecifiers.delete(specifier);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
// Turn OFF the JSON service's built-in validation. The editor content is a
|
|
168
|
+
// template that renders to JSON, so we validate the template-substituted form
|
|
169
|
+
// ourselves (see the json validation effect + validateJsonTemplate) to tolerate
|
|
170
|
+
// `{{ }}` in any position. Highlighting + completion from the service stay on.
|
|
171
|
+
jsonDefaults.setDiagnosticsOptions({ validate: false });
|
|
172
|
+
|
|
173
|
+
// Monaco language id per editor language. Matches the ids registered by
|
|
174
|
+
// @codingame/monaco-vscode-standalone-languages (verified: shell is "shell").
|
|
175
|
+
// JSON editors with templates use the custom `json-template` language (below).
|
|
176
|
+
const MONACO_LANGUAGE_ID: Record<CodeEditorLanguage, string> = {
|
|
177
|
+
typescript: "typescript",
|
|
178
|
+
javascript: "javascript",
|
|
179
|
+
json: "json",
|
|
180
|
+
yaml: "yaml",
|
|
181
|
+
xml: "xml",
|
|
182
|
+
markdown: "markdown",
|
|
183
|
+
shell: "shell",
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
// File extension for the model uri, so the language service / grammar keys off
|
|
187
|
+
// a sensible filename.
|
|
188
|
+
const LANGUAGE_FILE_EXT: Record<CodeEditorLanguage, string> = {
|
|
189
|
+
typescript: "ts",
|
|
190
|
+
javascript: "js",
|
|
191
|
+
json: "json",
|
|
192
|
+
yaml: "yaml",
|
|
193
|
+
xml: "xml",
|
|
194
|
+
markdown: "md",
|
|
195
|
+
shell: "sh",
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
const isTsLikeLanguage = (language: CodeEditorLanguage): boolean =>
|
|
199
|
+
language === "typescript" || language === "javascript";
|
|
200
|
+
|
|
201
|
+
// Per-language template-aware validators (markdown has none - no structure to
|
|
202
|
+
// validate). Each validates the template-substituted form so `{{ }}` is
|
|
203
|
+
// tolerated anywhere; see templateValidation.ts.
|
|
204
|
+
const TEMPLATE_VALIDATORS: Partial<
|
|
205
|
+
Record<CodeEditorLanguage, (text: string) => TemplateDiagnostic[]>
|
|
206
|
+
> = {
|
|
207
|
+
json: validateJsonTemplate,
|
|
208
|
+
yaml: validateYamlTemplate,
|
|
209
|
+
xml: validateXmlTemplate,
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
// Variable-like tokens (`{{ template }}` expressions, shell `$env` refs) are
|
|
213
|
+
// highlighted via inline decorations rather than per-language grammars: this
|
|
214
|
+
// works for any language (yaml / xml / markdown have no template grammar; shell
|
|
215
|
+
// doesn't color `$VAR` inside strings) and keeps the color consistent. #9cdcfe
|
|
216
|
+
// is the vs-dark `variable` token color, matching json-template's grammar. The
|
|
217
|
+
// CSS class is injected once.
|
|
218
|
+
const VARIABLE_TOKEN_CLASS = "checkstack-editor-variable";
|
|
219
|
+
let variableTokenStyleInjected = false;
|
|
220
|
+
const ensureVariableTokenStyle = (): void => {
|
|
221
|
+
if (variableTokenStyleInjected) {
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
variableTokenStyleInjected = true;
|
|
225
|
+
const style = document.createElement("style");
|
|
226
|
+
// `!important` so the decoration color always wins over the underlying token
|
|
227
|
+
// color (.mtkN), making `{{ }}` look identical inside and outside strings.
|
|
228
|
+
style.textContent = `.${VARIABLE_TOKEN_CLASS}{color:#9cdcfe !important;}`;
|
|
229
|
+
document.head.append(style);
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
// Apply (and keep in sync) inline decorations for every match of `pattern` in
|
|
233
|
+
// the model. Returns a disposable that removes the decorations + listener.
|
|
234
|
+
// `pattern` must be a global (/g) regex; its lastIndex is reset per pass.
|
|
235
|
+
const installRegexDecorations = ({
|
|
236
|
+
model,
|
|
237
|
+
pattern,
|
|
238
|
+
className,
|
|
239
|
+
}: {
|
|
240
|
+
model: monaco.editor.ITextModel;
|
|
241
|
+
pattern: RegExp;
|
|
242
|
+
className: string;
|
|
243
|
+
}): monaco.IDisposable => {
|
|
244
|
+
ensureVariableTokenStyle();
|
|
245
|
+
const compute = (): monaco.editor.IModelDeltaDecoration[] => {
|
|
246
|
+
const text = model.getValue();
|
|
247
|
+
const decorations: monaco.editor.IModelDeltaDecoration[] = [];
|
|
248
|
+
pattern.lastIndex = 0;
|
|
249
|
+
let match = pattern.exec(text);
|
|
250
|
+
while (match !== null) {
|
|
251
|
+
const start = model.getPositionAt(match.index);
|
|
252
|
+
const end = model.getPositionAt(match.index + match[0].length);
|
|
253
|
+
decorations.push({
|
|
254
|
+
range: new monaco.Range(
|
|
255
|
+
start.lineNumber,
|
|
256
|
+
start.column,
|
|
257
|
+
end.lineNumber,
|
|
258
|
+
end.column,
|
|
259
|
+
),
|
|
260
|
+
options: { inlineClassName: className },
|
|
261
|
+
});
|
|
262
|
+
// Guard against a zero-length match looping forever.
|
|
263
|
+
if (pattern.lastIndex === match.index) {
|
|
264
|
+
pattern.lastIndex += 1;
|
|
265
|
+
}
|
|
266
|
+
match = pattern.exec(text);
|
|
267
|
+
}
|
|
268
|
+
return decorations;
|
|
269
|
+
};
|
|
270
|
+
let decorationIds = model.deltaDecorations([], compute());
|
|
271
|
+
const subscription = model.onDidChangeContent(() => {
|
|
272
|
+
decorationIds = model.deltaDecorations(decorationIds, compute());
|
|
273
|
+
});
|
|
274
|
+
return {
|
|
275
|
+
dispose: () => {
|
|
276
|
+
subscription.dispose();
|
|
277
|
+
model.deltaDecorations(decorationIds, []);
|
|
278
|
+
},
|
|
279
|
+
};
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
const findModelById = (
|
|
283
|
+
modelId: string,
|
|
284
|
+
): monaco.editor.ITextModel | undefined =>
|
|
285
|
+
monaco.editor
|
|
286
|
+
.getModels()
|
|
287
|
+
.find((candidate) => candidate.uri.toString().includes(modelId));
|
|
288
|
+
|
|
289
|
+
export type TypefoxEditorProps = {
|
|
290
|
+
/** Stable identity for the underlying editor app + model uri. */
|
|
291
|
+
id: string;
|
|
292
|
+
/** Initial source rendered in the editor. */
|
|
293
|
+
value: string;
|
|
294
|
+
/** Notified with the latest editor text whenever it changes. */
|
|
295
|
+
onChange?: (value: string) => void;
|
|
296
|
+
/** Editor language. Defaults to `typescript`. */
|
|
297
|
+
language?: CodeEditorLanguage;
|
|
298
|
+
/** Minimum editor height in pixels. Defaults to 240. */
|
|
299
|
+
minHeight?: number;
|
|
300
|
+
/**
|
|
301
|
+
* When true, the editor container fills its flex parent (`height: 100%`)
|
|
302
|
+
* instead of using a fixed `minHeight` px height, so it grows to fit a tall
|
|
303
|
+
* flex column (e.g. the popout dialog body). `minHeight` is still applied as
|
|
304
|
+
* a floor. Defaults to false, preserving the inline fixed-height behaviour.
|
|
305
|
+
*/
|
|
306
|
+
fillHeight?: boolean;
|
|
307
|
+
/**
|
|
308
|
+
* Generated ambient type definitions (the `context.d.ts`) injected as a TS
|
|
309
|
+
* extra-lib so `context.*` resolves with real fields. Wired up once per
|
|
310
|
+
* editor at mount, keyed by a unique path - no addExtraLib race.
|
|
311
|
+
* Only used for `typescript` / `javascript` editors.
|
|
312
|
+
*/
|
|
313
|
+
typeDefinitions?: string;
|
|
314
|
+
/**
|
|
315
|
+
* Template properties for non-script editors. When provided, typing `{{`
|
|
316
|
+
* autocompletes the available `{{ path }}` references. Only used for
|
|
317
|
+
* markup/text editors (json / yaml / xml / markdown).
|
|
318
|
+
*/
|
|
319
|
+
templateProperties?: TemplateProperty[];
|
|
320
|
+
/**
|
|
321
|
+
* Environment-variable hints for `shell` editors. When provided, typing `$`
|
|
322
|
+
* or `${` autocompletes the variable names. Only used when `language` is
|
|
323
|
+
* `shell`.
|
|
324
|
+
*/
|
|
325
|
+
shellEnvVars?: ShellEnvVar[];
|
|
326
|
+
/**
|
|
327
|
+
* Externally-computed diagnostics rendered as inline squiggles under a
|
|
328
|
+
* dedicated marker owner (so they coexist with monaco's own markers).
|
|
329
|
+
* Positions are 1-based (monaco convention) - e.g. YAML definition validation.
|
|
330
|
+
*/
|
|
331
|
+
markers?: EditorMarker[];
|
|
332
|
+
/** Render the editor read-only. */
|
|
333
|
+
readOnly?: boolean;
|
|
334
|
+
/** Accessible label / hint for the editor (surfaced via aria-label). */
|
|
335
|
+
placeholder?: string;
|
|
336
|
+
/**
|
|
337
|
+
* Lazy Automatic Type Acquisition resolver. When provided (TS/JS editors),
|
|
338
|
+
* bare `import`/`require` specifiers in the buffer are parsed (debounced)
|
|
339
|
+
* and each NEW package's `.d.ts` closure is fetched + registered so e.g.
|
|
340
|
+
* `import { debounce } from "lodash"` autocompletes. Injected by the
|
|
341
|
+
* consumer so this component stays plugin-agnostic.
|
|
342
|
+
*/
|
|
343
|
+
acquireTypes?: AcquireTypes;
|
|
344
|
+
/**
|
|
345
|
+
* Install identity (lockfile hash). When it changes, the shared acquired-set
|
|
346
|
+
* resets so types refresh against the new install.
|
|
347
|
+
*/
|
|
348
|
+
acquireResetKey?: string;
|
|
349
|
+
/**
|
|
350
|
+
* Importable installed package NAMES (TS/JS editors). When provided, the
|
|
351
|
+
* editor suggests these as completions while the cursor is inside an import
|
|
352
|
+
* specifier string (`import {} from "lod"` -> `lodash`) - solving the
|
|
353
|
+
* lazy-ATA catch-22 where no module is registered yet. Must already exclude
|
|
354
|
+
* `@types/*` companions (you import `lodash`, never `@types/lodash`).
|
|
355
|
+
* Injected by the consumer so this component stays plugin-agnostic.
|
|
356
|
+
*/
|
|
357
|
+
importablePackages?: string[];
|
|
358
|
+
};
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Isolated editor used to validate the Typefox/monaco-vscode stack in the
|
|
362
|
+
* browser. Dark theme, no minimap, automatic layout, word-based suggestions
|
|
363
|
+
* disabled. For `typescript`/`javascript` it injects the `context` types +
|
|
364
|
+
* bracket completions; for markup/text languages it offers `{{ }}` template
|
|
365
|
+
* completions.
|
|
366
|
+
*/
|
|
367
|
+
// The standalone ("classic") service set omits `ILanguageStatusService`, but
|
|
368
|
+
// the JSON language features register a language-status indicator (the active
|
|
369
|
+
// formatter) on editor focus and throw "LanguageStatusService.addStatus is not
|
|
370
|
+
// supported" without it. We register ONLY that service: the full languages
|
|
371
|
+
// override would also swap `ILanguageService` for the workbench impl and pull
|
|
372
|
+
// in the files-service override, both of which conflict with the standalone
|
|
373
|
+
// language setup. The override map is keyed by the service-decorator id
|
|
374
|
+
// (`createDecorator('ILanguageStatusService')`), so we pick that one entry.
|
|
375
|
+
const LANGUAGE_STATUS_SERVICE_ID = "ILanguageStatusService";
|
|
376
|
+
const languageStatusServiceOverride: monaco.editor.IEditorOverrideServices =
|
|
377
|
+
(() => {
|
|
378
|
+
const all = getLanguagesServiceOverride();
|
|
379
|
+
return LANGUAGE_STATUS_SERVICE_ID in all
|
|
380
|
+
? { [LANGUAGE_STATUS_SERVICE_ID]: all[LANGUAGE_STATUS_SERVICE_ID] }
|
|
381
|
+
: {};
|
|
382
|
+
})();
|
|
383
|
+
|
|
384
|
+
// Always-available runtime built-in import specifiers (Node + Bun), derived at
|
|
385
|
+
// build time from the bundled stdlib types. These are importable in the script
|
|
386
|
+
// sandbox regardless of the installed-package allowlist (the sandbox is a Bun
|
|
387
|
+
// subprocess; Bun provides Node's builtins + its own `bun:` modules), and their
|
|
388
|
+
// types are already loaded ambiently via the stdlib bundle - so completing one
|
|
389
|
+
// needs no lazy acquisition. The JSON is a plain `string[]`.
|
|
390
|
+
const BUILTIN_MODULE_SPECIFIERS: readonly string[] = builtinModulesJson;
|
|
391
|
+
|
|
392
|
+
export const TypefoxEditor = ({
|
|
393
|
+
id,
|
|
394
|
+
value,
|
|
395
|
+
onChange,
|
|
396
|
+
language = "typescript",
|
|
397
|
+
minHeight = 240,
|
|
398
|
+
fillHeight = false,
|
|
399
|
+
typeDefinitions,
|
|
400
|
+
templateProperties,
|
|
401
|
+
shellEnvVars,
|
|
402
|
+
markers,
|
|
403
|
+
readOnly = false,
|
|
404
|
+
placeholder,
|
|
405
|
+
acquireTypes,
|
|
406
|
+
acquireResetKey,
|
|
407
|
+
importablePackages,
|
|
408
|
+
}: TypefoxEditorProps) => {
|
|
409
|
+
// `MonacoEditorReactComp` captures `onTextChanged` once at editor-start, so
|
|
410
|
+
// the handler it calls would otherwise close over a stale `onChange` (bound
|
|
411
|
+
// to the value/sibling-config at mount time). Routing through a ref that we
|
|
412
|
+
// keep current on every render means content changes always invoke the
|
|
413
|
+
// latest `onChange` — without this, editing one DynamicForm field reverts
|
|
414
|
+
// sibling fields (e.g. a shell action's `env`) to their mount-time values.
|
|
415
|
+
const onChangeRef = useRef(onChange);
|
|
416
|
+
onChangeRef.current = onChange;
|
|
417
|
+
|
|
418
|
+
// Unique-per-instance id so multiple editors never share a model or clobber
|
|
419
|
+
// each other's extra-lib.
|
|
420
|
+
const reactId = useId();
|
|
421
|
+
const modelId = `${id}-${reactId.replaceAll(":", "")}`;
|
|
422
|
+
const modelUri = `/workspace/${modelId}.${LANGUAGE_FILE_EXT[language]}`;
|
|
423
|
+
|
|
424
|
+
const isTsLike = isTsLikeLanguage(language);
|
|
425
|
+
const hasTemplates =
|
|
426
|
+
templateProperties !== undefined && templateProperties.length > 0;
|
|
427
|
+
const languageId = MONACO_LANGUAGE_ID[language];
|
|
428
|
+
|
|
429
|
+
// Set once the wrapper has initialised the VS Code services, so the
|
|
430
|
+
// completion providers below register against a ready languages registry.
|
|
431
|
+
const [apiReady, setApiReady] = useState(false);
|
|
432
|
+
|
|
433
|
+
useEffect(() => {
|
|
434
|
+
if (!isTsLike || typeDefinitions === undefined) {
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
// Inject this editor's ambient `context` types. addExtraLib keys by path
|
|
438
|
+
// and re-syncs the worker on change, so the types are reliably picked up
|
|
439
|
+
// (no race with model load). NOTE: extra-libs are ambient/global to the TS
|
|
440
|
+
// service, so two TS editors with *different* `context` shapes mounted at
|
|
441
|
+
// once would collide on the `context` identifier - per-editor isolation is
|
|
442
|
+
// a Stage 6 concern, irrelevant to the single-editor test vehicle here.
|
|
443
|
+
const lib = typescriptDefaults.addExtraLib(
|
|
444
|
+
typeDefinitions,
|
|
445
|
+
`file:///context-${modelId}.d.ts`,
|
|
446
|
+
);
|
|
447
|
+
return () => {
|
|
448
|
+
lib.dispose();
|
|
449
|
+
};
|
|
450
|
+
}, [isTsLike, typeDefinitions, modelId]);
|
|
451
|
+
|
|
452
|
+
// Lazy Automatic Type Acquisition (ATA). For TS/JS editors with an injected
|
|
453
|
+
// `acquireTypes` resolver, parse the buffer's bare import/require specifiers
|
|
454
|
+
// (debounced) and fetch + register each NEW package's `.d.ts` closure, so
|
|
455
|
+
// `import { x } from "pkg"` autocompletes. The acquired-set is module-scoped
|
|
456
|
+
// and shared across editors (the declarations are install-global); the pure
|
|
457
|
+
// parse/plan steps are unit-tested in importSpecifiers.test.ts. Re-running
|
|
458
|
+
// on a new `acquireTypes`/`acquireResetKey` identity is cheap and safe — the
|
|
459
|
+
// module-scoped acquired-set dedupes, so it never double-fetches.
|
|
460
|
+
useEffect(() => {
|
|
461
|
+
if (!apiReady || !isTsLike || acquireTypes === undefined) {
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
const model = findModelById(modelId);
|
|
465
|
+
if (!model) {
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
const acquire = (source: string): void => {
|
|
470
|
+
void runTypeAcquisition({
|
|
471
|
+
source,
|
|
472
|
+
acquireTypes,
|
|
473
|
+
resetKey: acquireResetKey,
|
|
474
|
+
});
|
|
475
|
+
};
|
|
476
|
+
|
|
477
|
+
// Run once for the initial content so existing imports resolve on open.
|
|
478
|
+
acquire(model.getValue());
|
|
479
|
+
|
|
480
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
481
|
+
const subscription = model.onDidChangeContent(() => {
|
|
482
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
483
|
+
timer = setTimeout(() => {
|
|
484
|
+
acquire(model.getValue());
|
|
485
|
+
}, 400);
|
|
486
|
+
});
|
|
487
|
+
return () => {
|
|
488
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
489
|
+
subscription.dispose();
|
|
490
|
+
};
|
|
491
|
+
}, [apiReady, isTsLike, acquireTypes, acquireResetKey, modelId]);
|
|
492
|
+
|
|
493
|
+
// Controlled-value sync. `value` is only seeded into the model at mount
|
|
494
|
+
// (codeResources), so the editor is otherwise uncontrolled. Apply external
|
|
495
|
+
// `value` changes — a sibling editor's edits (the inline ↔ popout pair share
|
|
496
|
+
// one controlled `value`), a YAML→Visual reset, a loaded definition — to this
|
|
497
|
+
// model. Guarded by an equality check: the user's own edit round-trips
|
|
498
|
+
// `value === model.getValue()`, so the actively-edited editor is a no-op and
|
|
499
|
+
// there is no feedback loop; only a background editor whose `value` prop
|
|
500
|
+
// diverged gets updated.
|
|
501
|
+
useEffect(() => {
|
|
502
|
+
if (!apiReady) return;
|
|
503
|
+
const model = findModelById(modelId);
|
|
504
|
+
if (!model || model.getValue() === value) return;
|
|
505
|
+
model.setValue(value);
|
|
506
|
+
}, [apiReady, modelId, value]);
|
|
507
|
+
|
|
508
|
+
// Import-specifier name completions. Lazy ATA only registers a package's
|
|
509
|
+
// types AFTER its name is in the buffer, so while the user is still TYPING
|
|
510
|
+
// the specifier (`import {} from "lod"`) no module exists yet and the TS
|
|
511
|
+
// worker offers nothing. This provider fills that gap: when the cursor is
|
|
512
|
+
// inside an import/require string (detected by the unit-tested
|
|
513
|
+
// `importSpecifierCompletionContext`), it suggests:
|
|
514
|
+
// - the always-available runtime built-ins (`node:fs`, `bun`, ...), so
|
|
515
|
+
// they appear even with an empty allowlist; AND
|
|
516
|
+
// - the injected installed-package names (already `@types/*`-free).
|
|
517
|
+
// Selecting a built-in inserts an already-typed module; selecting an
|
|
518
|
+
// installed package triggers the ATA loop to load its closure. The list is
|
|
519
|
+
// merged + deduped + sorted by `mergeImportCompletionEntries` (a unit-tested
|
|
520
|
+
// pure helper). Built-ins read as "Node.js" / "Bun built-in" via `detail`.
|
|
521
|
+
// Scoped to THIS model; only the import-string position triggers it, so it
|
|
522
|
+
// never pollutes normal completions. Always registered (built-ins are
|
|
523
|
+
// always available), independent of the allowlist.
|
|
524
|
+
useEffect(() => {
|
|
525
|
+
if (!apiReady || !isTsLike) {
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
const entries = mergeImportCompletionEntries({
|
|
529
|
+
builtins: BUILTIN_MODULE_SPECIFIERS,
|
|
530
|
+
installedPackages: importablePackages ?? [],
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
const provideCompletionItems = (
|
|
534
|
+
model: monaco.editor.ITextModel,
|
|
535
|
+
position: monaco.Position,
|
|
536
|
+
): monaco.languages.CompletionList => {
|
|
537
|
+
if (!model.uri.toString().includes(modelId)) {
|
|
538
|
+
return { suggestions: [] };
|
|
539
|
+
}
|
|
540
|
+
const lineUpToCursor = model
|
|
541
|
+
.getLineContent(position.lineNumber)
|
|
542
|
+
.slice(0, position.column - 1);
|
|
543
|
+
const ctx = importSpecifierCompletionContext(lineUpToCursor);
|
|
544
|
+
if (!ctx) {
|
|
545
|
+
return { suggestions: [] };
|
|
546
|
+
}
|
|
547
|
+
// Replace the whole partial specifier (between the quotes) without
|
|
548
|
+
// touching the quotes themselves.
|
|
549
|
+
const range = {
|
|
550
|
+
startLineNumber: position.lineNumber,
|
|
551
|
+
startColumn: ctx.replaceFromColumn,
|
|
552
|
+
endLineNumber: position.lineNumber,
|
|
553
|
+
endColumn: position.column,
|
|
554
|
+
};
|
|
555
|
+
return {
|
|
556
|
+
suggestions: entries.map((entry) => ({
|
|
557
|
+
label: entry.name,
|
|
558
|
+
kind: monaco.languages.CompletionItemKind.Module,
|
|
559
|
+
detail: entry.detail,
|
|
560
|
+
insertText: entry.name,
|
|
561
|
+
filterText: entry.name,
|
|
562
|
+
range,
|
|
563
|
+
})),
|
|
564
|
+
// The list is the full known set; let monaco filter by `partial`.
|
|
565
|
+
incomplete: false,
|
|
566
|
+
};
|
|
567
|
+
};
|
|
568
|
+
|
|
569
|
+
const disposables = (["typescript", "javascript"] as const).map((lang) =>
|
|
570
|
+
monaco.languages.registerCompletionItemProvider(lang, {
|
|
571
|
+
// Opening quotes start a specifier; `:` advances into a `node:`/`bun:`
|
|
572
|
+
// builtin; `/` advances into a scoped name or subpath.
|
|
573
|
+
triggerCharacters: ['"', "'", "/", ":"],
|
|
574
|
+
provideCompletionItems,
|
|
575
|
+
}),
|
|
576
|
+
);
|
|
577
|
+
return () => {
|
|
578
|
+
for (const disposable of disposables) {
|
|
579
|
+
disposable.dispose();
|
|
580
|
+
}
|
|
581
|
+
};
|
|
582
|
+
}, [apiReady, isTsLike, importablePackages, modelId]);
|
|
583
|
+
|
|
584
|
+
// Type-driven bracket-notation completions. The standalone TS worker omits
|
|
585
|
+
// object members whose keys aren't valid identifiers (artifact ids like
|
|
586
|
+
// `integration-jira.issue`), and the built-in SuggestAdapter can't be
|
|
587
|
+
// overridden to insert them, so we register our own provider: typing
|
|
588
|
+
// `<objectExpression>.` lists the keys and accepting one rewrites the dot to
|
|
589
|
+
// `["key"]` (mirrors VS Code's `obj."a-b"` -> `obj["a-b"]`). The groups are
|
|
590
|
+
// derived from the injected `context.d.ts` itself, so no separate prop is
|
|
591
|
+
// threaded. Scoped to THIS editor's model so multiple editors don't cross-feed.
|
|
592
|
+
useEffect(() => {
|
|
593
|
+
if (!apiReady || !isTsLike || typeDefinitions === undefined) {
|
|
594
|
+
return;
|
|
595
|
+
}
|
|
596
|
+
const groups = extractBracketKeyGroups({ typeDefinitions });
|
|
597
|
+
if (groups.length === 0) {
|
|
598
|
+
return;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
const escapeRegExp = (input: string): string =>
|
|
602
|
+
input.replaceAll(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`);
|
|
603
|
+
|
|
604
|
+
const provideCompletionItems = (
|
|
605
|
+
model: monaco.editor.ITextModel,
|
|
606
|
+
position: monaco.Position,
|
|
607
|
+
): monaco.languages.CompletionList => {
|
|
608
|
+
if (!model.uri.toString().includes(modelId)) {
|
|
609
|
+
return { suggestions: [] };
|
|
610
|
+
}
|
|
611
|
+
const textBefore = model
|
|
612
|
+
.getLineContent(position.lineNumber)
|
|
613
|
+
.slice(0, position.column - 1);
|
|
614
|
+
|
|
615
|
+
for (const { objectExpression, keys } of groups) {
|
|
616
|
+
// Match `<objectExpression>.<query>` at the cursor, ensuring the
|
|
617
|
+
// expression isn't the tail of a longer identifier.
|
|
618
|
+
const match = new RegExp(
|
|
619
|
+
String.raw`(?:^|[^\w$.])${escapeRegExp(objectExpression)}\.([\w$]*)$`,
|
|
620
|
+
).exec(textBefore);
|
|
621
|
+
if (!match) {
|
|
622
|
+
continue;
|
|
623
|
+
}
|
|
624
|
+
const query = match[1] ?? "";
|
|
625
|
+
const queryStartColumn = position.column - query.length;
|
|
626
|
+
const dotColumn = queryStartColumn - 1;
|
|
627
|
+
|
|
628
|
+
return {
|
|
629
|
+
suggestions: keys.map((key) => ({
|
|
630
|
+
label: `["${key}"]`,
|
|
631
|
+
kind: monaco.languages.CompletionItemKind.Property,
|
|
632
|
+
detail: objectExpression,
|
|
633
|
+
insertText: `["${key}"]`,
|
|
634
|
+
filterText: key,
|
|
635
|
+
range: {
|
|
636
|
+
startLineNumber: position.lineNumber,
|
|
637
|
+
startColumn: queryStartColumn,
|
|
638
|
+
endLineNumber: position.lineNumber,
|
|
639
|
+
endColumn: position.column,
|
|
640
|
+
},
|
|
641
|
+
// Delete the triggering `.` so `obj.` becomes `obj["key"]`.
|
|
642
|
+
additionalTextEdits: [
|
|
643
|
+
{
|
|
644
|
+
range: {
|
|
645
|
+
startLineNumber: position.lineNumber,
|
|
646
|
+
startColumn: dotColumn,
|
|
647
|
+
endLineNumber: position.lineNumber,
|
|
648
|
+
endColumn: dotColumn + 1,
|
|
649
|
+
},
|
|
650
|
+
text: "",
|
|
651
|
+
},
|
|
652
|
+
],
|
|
653
|
+
})),
|
|
654
|
+
};
|
|
655
|
+
}
|
|
656
|
+
return { suggestions: [] };
|
|
657
|
+
};
|
|
658
|
+
|
|
659
|
+
const disposables = (["typescript", "javascript"] as const).map((lang) =>
|
|
660
|
+
monaco.languages.registerCompletionItemProvider(lang, {
|
|
661
|
+
triggerCharacters: ["."],
|
|
662
|
+
provideCompletionItems,
|
|
663
|
+
}),
|
|
664
|
+
);
|
|
665
|
+
return () => {
|
|
666
|
+
for (const disposable of disposables) {
|
|
667
|
+
disposable.dispose();
|
|
668
|
+
}
|
|
669
|
+
};
|
|
670
|
+
}, [apiReady, isTsLike, typeDefinitions, modelId]);
|
|
671
|
+
|
|
672
|
+
// Template `{{ }}` completion for markup/text editors (json / yaml / xml /
|
|
673
|
+
// markdown). Typing `{{` lists the available `{{ path }}` references; ported
|
|
674
|
+
// from the legacy MonacoEditor template provider (uses the same tested
|
|
675
|
+
// `detectOpenTemplate` / `detectAutoClosedBraces` helpers). Registered for
|
|
676
|
+
// THIS editor's resolved language id and scoped to its model.
|
|
677
|
+
useEffect(() => {
|
|
678
|
+
if (!apiReady || isTsLike || !hasTemplates) {
|
|
679
|
+
return;
|
|
680
|
+
}
|
|
681
|
+
const properties = templateProperties ?? [];
|
|
682
|
+
|
|
683
|
+
const provideCompletionItems = (
|
|
684
|
+
model: monaco.editor.ITextModel,
|
|
685
|
+
position: monaco.Position,
|
|
686
|
+
): monaco.languages.CompletionList => {
|
|
687
|
+
if (!model.uri.toString().includes(modelId)) {
|
|
688
|
+
return { suggestions: [] };
|
|
689
|
+
}
|
|
690
|
+
const content = model.getValue();
|
|
691
|
+
const cursorOffset = model.getOffsetAt(position);
|
|
692
|
+
|
|
693
|
+
const openTemplate = detectOpenTemplate({ content, cursorOffset });
|
|
694
|
+
if (!openTemplate.isInTemplate) {
|
|
695
|
+
return { suggestions: [] };
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
const query = openTemplate.query.toLowerCase();
|
|
699
|
+
const startColumn = openTemplate.startColumn;
|
|
700
|
+
// Monaco may have auto-closed with `}}` after the cursor; extend the
|
|
701
|
+
// replaced range over it so we don't leave dangling braces.
|
|
702
|
+
const endColumn =
|
|
703
|
+
position.column + detectAutoClosedBraces({ content, cursorOffset });
|
|
704
|
+
|
|
705
|
+
const suggestions = properties
|
|
706
|
+
.filter(
|
|
707
|
+
(prop) => query === "" || prop.path.toLowerCase().includes(query),
|
|
708
|
+
)
|
|
709
|
+
.map((prop, index) => ({
|
|
710
|
+
label: `{{${prop.path}}}`,
|
|
711
|
+
kind: monaco.languages.CompletionItemKind.Variable,
|
|
712
|
+
detail: prop.type,
|
|
713
|
+
documentation: prop.description,
|
|
714
|
+
insertText: `{{${prop.path}}}`,
|
|
715
|
+
// Leading space sorts these above the editor's own suggestions.
|
|
716
|
+
sortText: ` ${String(index).padStart(4, "0")}`,
|
|
717
|
+
filterText: `{{${query}${prop.path}`,
|
|
718
|
+
preselect: index === 0,
|
|
719
|
+
range: {
|
|
720
|
+
startLineNumber: position.lineNumber,
|
|
721
|
+
startColumn,
|
|
722
|
+
endLineNumber: position.lineNumber,
|
|
723
|
+
endColumn,
|
|
724
|
+
},
|
|
725
|
+
}));
|
|
726
|
+
|
|
727
|
+
return { suggestions, incomplete: false };
|
|
728
|
+
};
|
|
729
|
+
|
|
730
|
+
const provider = monaco.languages.registerCompletionItemProvider(
|
|
731
|
+
languageId,
|
|
732
|
+
{ triggerCharacters: ["{"], provideCompletionItems },
|
|
733
|
+
);
|
|
734
|
+
return () => {
|
|
735
|
+
provider.dispose();
|
|
736
|
+
};
|
|
737
|
+
}, [
|
|
738
|
+
apiReady,
|
|
739
|
+
isTsLike,
|
|
740
|
+
hasTemplates,
|
|
741
|
+
templateProperties,
|
|
742
|
+
languageId,
|
|
743
|
+
modelId,
|
|
744
|
+
]);
|
|
745
|
+
|
|
746
|
+
// Highlight `{{ ... }}` template expressions (template editors). The
|
|
747
|
+
// `[^{}]*` body (not `[^}]*`) stops an unclosed `{{` from swallowing text up
|
|
748
|
+
// to a later `}}`.
|
|
749
|
+
useEffect(() => {
|
|
750
|
+
if (!apiReady || isTsLike || !hasTemplates) {
|
|
751
|
+
return;
|
|
752
|
+
}
|
|
753
|
+
const model = findModelById(modelId);
|
|
754
|
+
if (!model) {
|
|
755
|
+
return;
|
|
756
|
+
}
|
|
757
|
+
const handle = installRegexDecorations({
|
|
758
|
+
model,
|
|
759
|
+
pattern: /\{\{[^{}]*\}\}/g,
|
|
760
|
+
className: VARIABLE_TOKEN_CLASS,
|
|
761
|
+
});
|
|
762
|
+
return () => {
|
|
763
|
+
handle.dispose();
|
|
764
|
+
};
|
|
765
|
+
}, [apiReady, isTsLike, hasTemplates, modelId]);
|
|
766
|
+
|
|
767
|
+
// Highlight shell variable references (`$NAME` / `${NAME}`) - the shell
|
|
768
|
+
// grammar doesn't color these inside double-quoted strings, where ours live.
|
|
769
|
+
useEffect(() => {
|
|
770
|
+
if (!apiReady || language !== "shell") {
|
|
771
|
+
return;
|
|
772
|
+
}
|
|
773
|
+
const model = findModelById(modelId);
|
|
774
|
+
if (!model) {
|
|
775
|
+
return;
|
|
776
|
+
}
|
|
777
|
+
const handle = installRegexDecorations({
|
|
778
|
+
model,
|
|
779
|
+
pattern: /\$\{[A-Za-z_]\w*\}|\$[A-Za-z_]\w*/g,
|
|
780
|
+
className: VARIABLE_TOKEN_CLASS,
|
|
781
|
+
});
|
|
782
|
+
return () => {
|
|
783
|
+
handle.dispose();
|
|
784
|
+
};
|
|
785
|
+
}, [apiReady, language, modelId]);
|
|
786
|
+
|
|
787
|
+
// Shell `$env` completion. For `shell` editors, typing `$` or `${` suggests
|
|
788
|
+
// the provided variable names (and brace-closes `${name}` correctly). Ported
|
|
789
|
+
// from the legacy MonacoEditor shell provider; uses the tested
|
|
790
|
+
// `matchShellEnvVarTrigger` / `buildShellEnvVarInsertText` helpers.
|
|
791
|
+
useEffect(() => {
|
|
792
|
+
if (!apiReady || language !== "shell") {
|
|
793
|
+
return;
|
|
794
|
+
}
|
|
795
|
+
if (shellEnvVars === undefined || shellEnvVars.length === 0) {
|
|
796
|
+
return;
|
|
797
|
+
}
|
|
798
|
+
const envVars = shellEnvVars;
|
|
799
|
+
|
|
800
|
+
const provideCompletionItems = (
|
|
801
|
+
model: monaco.editor.ITextModel,
|
|
802
|
+
position: monaco.Position,
|
|
803
|
+
): monaco.languages.CompletionList => {
|
|
804
|
+
if (!model.uri.toString().includes(modelId)) {
|
|
805
|
+
return { suggestions: [] };
|
|
806
|
+
}
|
|
807
|
+
const lineText = model.getLineContent(position.lineNumber);
|
|
808
|
+
const textBefore = lineText.slice(0, position.column - 1);
|
|
809
|
+
const match = matchShellEnvVarTrigger(textBefore);
|
|
810
|
+
if (!match) {
|
|
811
|
+
return { suggestions: [] };
|
|
812
|
+
}
|
|
813
|
+
const startColumn = position.column - match.prefixLength;
|
|
814
|
+
// `{` auto-closes to `}` in shell, so a braced `${` leaves a `}` right
|
|
815
|
+
// after the cursor. Extend the replace range over it so an accepted
|
|
816
|
+
// `${NAME}` doesn't leave a stray brace (`${NAME}}`).
|
|
817
|
+
const hasAutoClosedBrace =
|
|
818
|
+
match.form === "braced" && lineText[position.column - 1] === "}";
|
|
819
|
+
const endColumn = hasAutoClosedBrace
|
|
820
|
+
? position.column + 1
|
|
821
|
+
: position.column;
|
|
822
|
+
// filterText must match the text already in the replace range (`${` for
|
|
823
|
+
// braced, `$` for bare) or monaco fuzzy-filters every item out.
|
|
824
|
+
const filterPrefix = match.form === "braced" ? "${" : "$";
|
|
825
|
+
|
|
826
|
+
const suggestions = envVars
|
|
827
|
+
.filter(
|
|
828
|
+
(v) => match.query === "" || v.name.toUpperCase().includes(match.query),
|
|
829
|
+
)
|
|
830
|
+
.map((v, index) => ({
|
|
831
|
+
label: `$${v.name}`,
|
|
832
|
+
kind: monaco.languages.CompletionItemKind.Variable,
|
|
833
|
+
detail: v.example ? `e.g. ${v.example}` : "shell env var",
|
|
834
|
+
// Full name in the (wrapping) docs panel so long CHECKSTACK_* names
|
|
835
|
+
// stay legible even when the suggest-list label truncates.
|
|
836
|
+
documentation: {
|
|
837
|
+
value: [`\`$${v.name}\``, v.description].filter(Boolean).join("\n\n"),
|
|
838
|
+
},
|
|
839
|
+
insertText: buildShellEnvVarInsertText(match, v.name),
|
|
840
|
+
sortText: ` ${String(index).padStart(4, "0")}`,
|
|
841
|
+
filterText: `${filterPrefix}${v.name}`,
|
|
842
|
+
range: {
|
|
843
|
+
startLineNumber: position.lineNumber,
|
|
844
|
+
startColumn,
|
|
845
|
+
endLineNumber: position.lineNumber,
|
|
846
|
+
endColumn,
|
|
847
|
+
},
|
|
848
|
+
}));
|
|
849
|
+
|
|
850
|
+
return { suggestions, incomplete: false };
|
|
851
|
+
};
|
|
852
|
+
|
|
853
|
+
const provider = monaco.languages.registerCompletionItemProvider("shell", {
|
|
854
|
+
triggerCharacters: ["$", "{"],
|
|
855
|
+
provideCompletionItems,
|
|
856
|
+
});
|
|
857
|
+
return () => {
|
|
858
|
+
provider.dispose();
|
|
859
|
+
};
|
|
860
|
+
}, [apiReady, language, shellEnvVars, modelId]);
|
|
861
|
+
|
|
862
|
+
// External validation markers (inline squiggles). Applied under a dedicated
|
|
863
|
+
// owner so they coexist with monaco's own language markers. Ported from the
|
|
864
|
+
// legacy editor; used e.g. for YAML definition validation in AutomationEditPage.
|
|
865
|
+
useEffect(() => {
|
|
866
|
+
if (!apiReady) {
|
|
867
|
+
return;
|
|
868
|
+
}
|
|
869
|
+
const model = monaco.editor
|
|
870
|
+
.getModels()
|
|
871
|
+
.find((candidate) => candidate.uri.toString().includes(modelId));
|
|
872
|
+
if (!model) {
|
|
873
|
+
return;
|
|
874
|
+
}
|
|
875
|
+
const toSeverity = (
|
|
876
|
+
severity: EditorMarker["severity"],
|
|
877
|
+
): monaco.MarkerSeverity => {
|
|
878
|
+
if (severity === "warning") {
|
|
879
|
+
return monaco.MarkerSeverity.Warning;
|
|
880
|
+
}
|
|
881
|
+
if (severity === "info") {
|
|
882
|
+
return monaco.MarkerSeverity.Info;
|
|
883
|
+
}
|
|
884
|
+
return monaco.MarkerSeverity.Error;
|
|
885
|
+
};
|
|
886
|
+
monaco.editor.setModelMarkers(
|
|
887
|
+
model,
|
|
888
|
+
"external-validation",
|
|
889
|
+
(markers ?? []).map((marker) => ({
|
|
890
|
+
startLineNumber: marker.startLineNumber,
|
|
891
|
+
startColumn: marker.startColumn,
|
|
892
|
+
endLineNumber: marker.endLineNumber,
|
|
893
|
+
endColumn: marker.endColumn,
|
|
894
|
+
message: marker.message,
|
|
895
|
+
severity: toSeverity(marker.severity),
|
|
896
|
+
})),
|
|
897
|
+
);
|
|
898
|
+
return () => {
|
|
899
|
+
monaco.editor.setModelMarkers(model, "external-validation", []);
|
|
900
|
+
};
|
|
901
|
+
}, [apiReady, markers, modelId]);
|
|
902
|
+
|
|
903
|
+
// Template-aware validation for markup languages (json / yaml / xml). The
|
|
904
|
+
// language services' own validation is off (json) or absent (yaml/xml);
|
|
905
|
+
// instead we validate the template-substituted form so `{{ }}` is allowed in
|
|
906
|
+
// any position while real structural errors are still flagged. Recomputed on
|
|
907
|
+
// every edit. Under a dedicated owner so it coexists with the external
|
|
908
|
+
// `markers`.
|
|
909
|
+
useEffect(() => {
|
|
910
|
+
const validate = TEMPLATE_VALIDATORS[language];
|
|
911
|
+
if (!apiReady || !validate) {
|
|
912
|
+
return;
|
|
913
|
+
}
|
|
914
|
+
const model = findModelById(modelId);
|
|
915
|
+
if (!model) {
|
|
916
|
+
return;
|
|
917
|
+
}
|
|
918
|
+
const owner = "template-validation";
|
|
919
|
+
const runValidation = (): void => {
|
|
920
|
+
const diagnostics = validate(model.getValue());
|
|
921
|
+
monaco.editor.setModelMarkers(
|
|
922
|
+
model,
|
|
923
|
+
owner,
|
|
924
|
+
diagnostics.map((diagnostic) => {
|
|
925
|
+
const start = model.getPositionAt(diagnostic.offset);
|
|
926
|
+
const end = model.getPositionAt(
|
|
927
|
+
diagnostic.offset + diagnostic.length,
|
|
928
|
+
);
|
|
929
|
+
return {
|
|
930
|
+
startLineNumber: start.lineNumber,
|
|
931
|
+
startColumn: start.column,
|
|
932
|
+
endLineNumber: end.lineNumber,
|
|
933
|
+
endColumn: end.column,
|
|
934
|
+
message: diagnostic.message,
|
|
935
|
+
severity: monaco.MarkerSeverity.Error,
|
|
936
|
+
};
|
|
937
|
+
}),
|
|
938
|
+
);
|
|
939
|
+
};
|
|
940
|
+
runValidation();
|
|
941
|
+
const subscription = model.onDidChangeContent(() => {
|
|
942
|
+
runValidation();
|
|
943
|
+
});
|
|
944
|
+
return () => {
|
|
945
|
+
subscription.dispose();
|
|
946
|
+
monaco.editor.setModelMarkers(model, owner, []);
|
|
947
|
+
};
|
|
948
|
+
}, [apiReady, language, modelId]);
|
|
949
|
+
|
|
950
|
+
const vscodeApiConfig: MonacoVscodeApiConfig = {
|
|
951
|
+
// 'classic' is the standalone axis (no extension host); 'extended' is the
|
|
952
|
+
// extension-host axis we deliberately avoid in this migration.
|
|
953
|
+
$type: "classic",
|
|
954
|
+
// Register the missing ILanguageStatusService (see the override comment
|
|
955
|
+
// above) so focusing a JSON editor doesn't throw "addStatus is not
|
|
956
|
+
// supported".
|
|
957
|
+
serviceOverrides: { ...languageStatusServiceOverride },
|
|
958
|
+
viewsConfig: {
|
|
959
|
+
// Plain editor, no workbench views.
|
|
960
|
+
$type: "EditorService",
|
|
961
|
+
},
|
|
962
|
+
monacoWorkerFactory: ensureStandaloneWorkerFactory,
|
|
963
|
+
};
|
|
964
|
+
|
|
965
|
+
const editorAppConfig: EditorAppConfig = {
|
|
966
|
+
// Unique per instance (modelId includes a useId suffix) so multiple editors
|
|
967
|
+
// sharing the same `id` prop don't collide in the wrapper's app registry.
|
|
968
|
+
id: modelId,
|
|
969
|
+
codeResources: {
|
|
970
|
+
modified: {
|
|
971
|
+
text: value,
|
|
972
|
+
uri: modelUri,
|
|
973
|
+
enforceLanguageId: languageId,
|
|
974
|
+
},
|
|
975
|
+
},
|
|
976
|
+
editorOptions: {
|
|
977
|
+
// 'vs-dark' is the builtin classic dark theme. (The VS Code
|
|
978
|
+
// 'Default Dark Modern' theme would require the extension-host
|
|
979
|
+
// theme-defaults extension, which the standalone setup omits.)
|
|
980
|
+
theme: "vs-dark",
|
|
981
|
+
minimap: { enabled: false },
|
|
982
|
+
automaticLayout: true,
|
|
983
|
+
// Force completions to come from the TS language service rather than
|
|
984
|
+
// naive word matching.
|
|
985
|
+
wordBasedSuggestions: "off",
|
|
986
|
+
scrollBeyondLastLine: false,
|
|
987
|
+
readOnly,
|
|
988
|
+
ariaLabel: placeholder ?? "Code editor",
|
|
989
|
+
},
|
|
990
|
+
};
|
|
991
|
+
|
|
992
|
+
const handleTextChanged = (textChanges: TextContents): void => {
|
|
993
|
+
onChangeRef.current?.(textChanges.modified ?? "");
|
|
994
|
+
};
|
|
995
|
+
|
|
996
|
+
// In `fillHeight` mode the container takes its parent's height so Monaco's
|
|
997
|
+
// `automaticLayout` resizes to fill a tall flex column (the popout body);
|
|
998
|
+
// `minHeight` stays as a floor. Otherwise the inline fixed-px behaviour is
|
|
999
|
+
// preserved exactly.
|
|
1000
|
+
const containerStyle: CSSProperties = fillHeight
|
|
1001
|
+
? { minHeight: `${minHeight}px`, height: "100%" }
|
|
1002
|
+
: { minHeight: `${minHeight}px`, height: `${minHeight}px` };
|
|
1003
|
+
|
|
1004
|
+
return (
|
|
1005
|
+
<MonacoEditorReactComp
|
|
1006
|
+
style={containerStyle}
|
|
1007
|
+
vscodeApiConfig={vscodeApiConfig}
|
|
1008
|
+
editorAppConfig={editorAppConfig}
|
|
1009
|
+
onTextChanged={handleTextChanged}
|
|
1010
|
+
onEditorStartDone={() => {
|
|
1011
|
+
// Per-editor ready signal for the completion providers. We use this
|
|
1012
|
+
// (not onVscodeApiInitDone) because the vscode API initialises globally
|
|
1013
|
+
// once, so a second editor never gets its own onVscodeApiInitDone - but
|
|
1014
|
+
// onEditorStartDone fires for each editor instance.
|
|
1015
|
+
setApiReady(true);
|
|
1016
|
+
// The monaco-vscode services are now up. Let the headless script
|
|
1017
|
+
// validator know it may safely use the worker (it must never init the
|
|
1018
|
+
// services itself - that would collide with this wrapper's one-time
|
|
1019
|
+
// init and throw "Services are already initialized").
|
|
1020
|
+
markVscodeServicesReady();
|
|
1021
|
+
}}
|
|
1022
|
+
/>
|
|
1023
|
+
);
|
|
1024
|
+
};
|