@skyhook-io/k8s-ui 1.8.13 → 1.8.15

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.
@@ -1,34 +1,87 @@
1
- import { useRef, useCallback, useEffect } from 'react'
2
- import Editor, { DiffEditor, OnMount, OnChange, type Monaco } from '@monaco-editor/react'
3
- import { PaneLoader } from './PaneLoader'
1
+ import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'
2
+ import Editor, {
3
+ DiffEditor,
4
+ type DiffOnMount,
5
+ type Monaco,
6
+ type OnChange,
7
+ type OnMount,
8
+ } from '@monaco-editor/react'
4
9
  import type { editor } from 'monaco-editor'
10
+ import { AlertCircle, AlertTriangle, ChevronDown, ChevronRight, Info } from 'lucide-react'
11
+ import { parseAllDocuments, parseDocument } from 'yaml'
12
+ import { isMac } from '../../utils/platform'
13
+ import { splitYamlDocuments } from '../../utils/yaml'
14
+ import { PaneLoader } from './PaneLoader'
15
+
16
+ export interface YamlDocumentIdentity {
17
+ index: number
18
+ schemaIndex: number
19
+ apiVersion: string
20
+ kind: string
21
+ startLine: number
22
+ }
23
+
24
+ export interface YamlSchemaLoadResult {
25
+ schemas: Array<Record<string, unknown> | null>
26
+ unavailable?: Array<{ index: number; reason: string }>
27
+ }
28
+
29
+ export type YamlSchemaLoader = (documents: YamlDocumentIdentity[]) => Promise<YamlSchemaLoadResult>
5
30
 
6
- interface YamlEditorProps {
31
+ export interface YamlDiagnostic {
32
+ severity: 'error' | 'warning' | 'info' | 'hint'
33
+ message: string
34
+ line: number
35
+ column: number
36
+ documentIndex: number
37
+ blocking: boolean
38
+ }
39
+
40
+ export interface YamlEditorProps {
7
41
  value: string
8
42
  onChange?: (value: string) => void
9
43
  readOnly?: boolean
10
44
  height?: string | number
11
45
  onValidate?: (isValid: boolean, errors: string[]) => void
12
- /** Resource kind - used to highlight editable fields for restricted resources like Pods */
46
+ onDiagnostics?: (diagnostics: YamlDiagnostic[]) => void
47
+ schemaLoader?: YamlSchemaLoader
13
48
  kind?: string
49
+ showProblems?: boolean
50
+ }
51
+
52
+ type SchemaStatus = 'idle' | 'loading' | 'ready' | 'partial' | 'unavailable'
53
+
54
+ export function parseYamlDocumentIdentities(value: string): YamlDocumentIdentity[] {
55
+ return splitYamlDocuments(value).map(({ content, startLine, schemaIndex }, index) => {
56
+ let parsed: unknown
57
+ try {
58
+ parsed = parseDocument(content).toJS({ maxAliasCount: 100 })
59
+ } catch {
60
+ parsed = undefined
61
+ }
62
+ const object = parsed && typeof parsed === 'object' ? (parsed as Record<string, unknown>) : {}
63
+ return {
64
+ index,
65
+ schemaIndex,
66
+ apiVersion: typeof object.apiVersion === 'string' ? object.apiVersion : '',
67
+ kind: typeof object.kind === 'string' ? object.kind : '',
68
+ startLine,
69
+ }
70
+ })
14
71
  }
15
72
 
16
- // Find line numbers for editable fields in a Pod YAML
17
73
  function findPodEditableLines(yaml: string): number[] {
18
74
  const lines = yaml.split('\n')
19
75
  const editableLines: number[] = []
20
-
21
76
  let inContainers = false
22
77
  let inInitContainers = false
23
78
  let inTolerations = false
24
79
  let containerIndent = 0
25
80
 
26
- for (let i = 0; i < lines.length; i++) {
81
+ for (let i = 0; i < lines.length; i += 1) {
27
82
  const line = lines[i]
28
83
  const trimmed = line.trimStart()
29
84
  const indent = line.length - trimmed.length
30
-
31
- // Track when we enter/exit containers section
32
85
  if (trimmed.startsWith('containers:')) {
33
86
  inContainers = true
34
87
  containerIndent = indent
@@ -41,44 +94,108 @@ function findPodEditableLines(yaml: string): number[] {
41
94
  }
42
95
  if (trimmed.startsWith('tolerations:')) {
43
96
  inTolerations = true
44
- editableLines.push(i + 1) // 1-indexed
97
+ editableLines.push(i + 1)
45
98
  containerIndent = indent
46
99
  continue
47
100
  }
48
-
49
- // Exit section when we hit same or lower indent level with a new key
50
- if ((inContainers || inInitContainers || inTolerations) &&
51
- indent <= containerIndent &&
52
- trimmed.length > 0 &&
53
- !trimmed.startsWith('-') &&
54
- !trimmed.startsWith('#')) {
101
+ if (
102
+ (inContainers || inInitContainers || inTolerations) &&
103
+ indent <= containerIndent &&
104
+ trimmed.length > 0 &&
105
+ !trimmed.startsWith('-') &&
106
+ !trimmed.startsWith('#')
107
+ ) {
55
108
  inContainers = false
56
109
  inInitContainers = false
57
110
  inTolerations = false
58
111
  }
59
-
60
- // Mark image lines as editable
61
112
  if ((inContainers || inInitContainers) && trimmed.startsWith('image:')) {
62
- editableLines.push(i + 1) // 1-indexed
63
- }
64
-
65
- // Mark toleration lines as editable (whole section)
66
- if (inTolerations && trimmed.length > 0) {
67
- editableLines.push(i + 1)
68
- }
69
-
70
- // activeDeadlineSeconds is editable
71
- if (trimmed.startsWith('activeDeadlineSeconds:')) {
72
113
  editableLines.push(i + 1)
73
114
  }
115
+ if (inTolerations && trimmed.length > 0) editableLines.push(i + 1)
116
+ if (trimmed.startsWith('activeDeadlineSeconds:')) editableLines.push(i + 1)
117
+ if (trimmed.startsWith('terminationGracePeriodSeconds:')) editableLines.push(i + 1)
118
+ }
119
+ return editableLines
120
+ }
74
121
 
75
- // terminationGracePeriodSeconds is editable (with restrictions)
76
- if (trimmed.startsWith('terminationGracePeriodSeconds:')) {
77
- editableLines.push(i + 1)
78
- }
122
+ function documentIndexForLine(documents: YamlDocumentIdentity[], line: number) {
123
+ let index = 0
124
+ for (const document of documents) {
125
+ if (document.startLine > line) break
126
+ index = document.index
79
127
  }
128
+ return index
129
+ }
80
130
 
81
- return editableLines
131
+ export function parseFallbackYamlDiagnostics(value: string): YamlDiagnostic[] {
132
+ const documents = parseYamlDocumentIdentities(value)
133
+ return parseAllDocuments(value).flatMap((document) =>
134
+ document.errors.map((error) => {
135
+ const start = error.linePos?.[0]
136
+ const line = start?.line ?? 1
137
+ return {
138
+ severity: 'error',
139
+ message: error.message,
140
+ line,
141
+ column: start?.col ?? 1,
142
+ documentIndex: documentIndexForLine(documents, line),
143
+ blocking: true,
144
+ }
145
+ }),
146
+ )
147
+ }
148
+
149
+ function useDocumentMonacoTheme() {
150
+ const readTheme = () =>
151
+ typeof document !== 'undefined' && document.documentElement.classList.contains('dark')
152
+ const [dark, setDark] = useState(readTheme)
153
+ useEffect(() => {
154
+ if (typeof document === 'undefined') return
155
+ const observer = new MutationObserver(() => setDark(readTheme()))
156
+ observer.observe(document.documentElement, {
157
+ attributes: true,
158
+ attributeFilter: ['class'],
159
+ })
160
+ return () => observer.disconnect()
161
+ }, [])
162
+ return dark ? ('vs-dark' as const) : ('vs' as const)
163
+ }
164
+
165
+ function markerSeverity(value: number, monaco: Monaco): YamlDiagnostic['severity'] {
166
+ if (value === monaco.MarkerSeverity.Error) return 'error'
167
+ if (value === monaco.MarkerSeverity.Warning) return 'warning'
168
+ if (value === monaco.MarkerSeverity.Info) return 'info'
169
+ return 'hint'
170
+ }
171
+
172
+ export function isBlockingYamlDiagnostic(
173
+ severity: YamlDiagnostic['severity'],
174
+ code?: string,
175
+ message?: string,
176
+ source?: string,
177
+ ) {
178
+ if (source?.startsWith('yaml-schema:')) return false
179
+ if (severity === 'error') return true
180
+ if (severity !== 'warning') return false
181
+ return code !== '2' && !message?.startsWith('[radar-advisory:deprecated]')
182
+ }
183
+
184
+ export function shouldAutoTriggerYamlSuggestions(
185
+ insertedTexts: readonly string[],
186
+ lineContent: string,
187
+ cursorColumn: number,
188
+ ) {
189
+ const insertedBlankLine = insertedTexts.some(
190
+ (text) => /\r?\n/.test(text) && text.trim().length === 0,
191
+ )
192
+ return (
193
+ insertedBlankLine && lineContent.trim().length === 0 && cursorColumn === lineContent.length + 1
194
+ )
195
+ }
196
+
197
+ function displayDiagnosticMessage(message: string) {
198
+ return message.replace(/^\[radar-advisory:deprecated\]\s*/, '')
82
199
  }
83
200
 
84
201
  export function YamlEditor({
@@ -87,172 +204,517 @@ export function YamlEditor({
87
204
  readOnly = false,
88
205
  height = '100%',
89
206
  onValidate,
207
+ onDiagnostics,
208
+ schemaLoader,
90
209
  kind,
210
+ showProblems = true,
91
211
  }: YamlEditorProps) {
92
212
  const editorRef = useRef<editor.IStandaloneCodeEditor | null>(null)
93
213
  const decorationsRef = useRef<string[]>([])
214
+ const markerSubscriptionRef = useRef<{ dispose(): void } | null>(null)
215
+ const suggestionSubscriptionRef = useRef<{ dispose(): void } | null>(null)
216
+ const suggestionTimerRef = useRef<number | null>(null)
217
+ const schemaDisposerRef = useRef<(() => void) | null>(null)
94
218
  const monacoRef = useRef<Monaco | null>(null)
219
+ const schemaRequestRef = useRef(0)
220
+ const schemaLoadedOnceRef = useRef(false)
221
+ const schemaStatusRef = useRef<SchemaStatus>(schemaLoader ? 'loading' : 'idle')
222
+ const schemaLoaderRef = useRef(schemaLoader)
223
+ const documentsRef = useRef<YamlDocumentIdentity[]>([])
224
+ const onDiagnosticsRef = useRef(onDiagnostics)
225
+ const onValidateRef = useRef(onValidate)
226
+ const [runtimeReady, setRuntimeReady] = useState(false)
227
+ const [runtimeError, setRuntimeError] = useState(false)
228
+ const [runtimeAttempt, setRuntimeAttempt] = useState(0)
229
+ const [diagnostics, setDiagnostics] = useState<YamlDiagnostic[]>([])
230
+ const [problemsOpen, setProblemsOpen] = useState(false)
231
+ const [schemaStatus, setSchemaStatus] = useState<SchemaStatus>(schemaLoader ? 'loading' : 'idle')
232
+ const [schemaMessage, setSchemaMessage] = useState('')
233
+ const [schemaUnavailable, setSchemaUnavailable] = useState<
234
+ Array<{ index: number; reason: string }>
235
+ >([])
236
+ const editorId = useId()
237
+ const modelPath = useMemo(
238
+ () => `radar://yaml/${editorId.replace(/[^a-zA-Z0-9_-]/g, '') || 'editor'}.yaml`,
239
+ [editorId],
240
+ )
241
+ const theme = useDocumentMonacoTheme()
242
+ const documents = useMemo(() => parseYamlDocumentIdentities(value), [value])
243
+ const identitySignature = documents
244
+ .map(
245
+ ({ schemaIndex, apiVersion, kind: documentKind }) =>
246
+ `${schemaIndex}:${apiVersion}|${documentKind}`,
247
+ )
248
+ .join('\n')
249
+ documentsRef.current = documents
250
+ schemaStatusRef.current = schemaStatus
251
+ schemaLoaderRef.current = schemaLoader
252
+ onDiagnosticsRef.current = onDiagnostics
253
+ onValidateRef.current = onValidate
254
+ const suggestionShortcut = isMac() ? '⌃Space' : 'Ctrl+Space'
255
+
256
+ useEffect(() => {
257
+ let active = true
258
+ setRuntimeReady(false)
259
+ setRuntimeError(false)
260
+ import('./yamlMonacoRuntime')
261
+ .then(({ ensureYamlMonaco }) => ensureYamlMonaco())
262
+ .then(() => {
263
+ if (active) setRuntimeReady(true)
264
+ })
265
+ .catch(() => {
266
+ if (active) setRuntimeError(true)
267
+ })
268
+ return () => {
269
+ active = false
270
+ }
271
+ }, [runtimeAttempt])
272
+
273
+ useEffect(() => {
274
+ if (!runtimeError) return
275
+ const next = parseFallbackYamlDiagnostics(value)
276
+ setDiagnostics(next)
277
+ if (next.length > 0) setProblemsOpen(true)
278
+ onDiagnosticsRef.current?.(next)
279
+ onValidateRef.current?.(
280
+ next.length === 0,
281
+ next.map((diagnostic) => `Line ${diagnostic.line}: ${diagnostic.message}`),
282
+ )
283
+ }, [runtimeError, value])
284
+
285
+ useEffect(() => {
286
+ return () => {
287
+ markerSubscriptionRef.current?.dispose()
288
+ suggestionSubscriptionRef.current?.dispose()
289
+ if (suggestionTimerRef.current !== null) {
290
+ window.clearTimeout(suggestionTimerRef.current)
291
+ }
292
+ schemaDisposerRef.current?.()
293
+ const editorWindow = window as typeof window & {
294
+ __radarMonacoEditor?: unknown
295
+ }
296
+ if (editorWindow.__radarMonacoEditor === editorRef.current) {
297
+ delete editorWindow.__radarMonacoEditor
298
+ }
299
+ }
300
+ }, [])
301
+
302
+ useEffect(() => {
303
+ schemaDisposerRef.current?.()
304
+ schemaDisposerRef.current = null
305
+ if (!schemaLoaderRef.current) {
306
+ setSchemaStatus('idle')
307
+ setSchemaMessage('')
308
+ setSchemaUnavailable([])
309
+ return
310
+ }
311
+ const completeDocuments = documents.filter((document) => document.apiVersion && document.kind)
312
+ if (completeDocuments.length !== documents.length || documents.length === 0) {
313
+ setSchemaStatus('unavailable')
314
+ setSchemaMessage('Add apiVersion and kind to enable cluster-aware guidance.')
315
+ setSchemaUnavailable(
316
+ documents
317
+ .filter((document) => !document.apiVersion || !document.kind)
318
+ .map((document) => ({
319
+ index: document.index,
320
+ reason: 'Add apiVersion and kind to enable cluster-aware guidance.',
321
+ })),
322
+ )
323
+ return
324
+ }
325
+
326
+ const request = ++schemaRequestRef.current
327
+ setSchemaStatus('loading')
328
+ setSchemaMessage('Loading schemas from this cluster…')
329
+ setSchemaUnavailable([])
330
+ const delay = schemaLoadedOnceRef.current ? 250 : 0
331
+ schemaLoadedOnceRef.current = true
332
+ const timer = window.setTimeout(() => {
333
+ const loadSchemas = schemaLoaderRef.current
334
+ if (!loadSchemas) return
335
+ loadSchemas(completeDocuments)
336
+ .then(async (result) => {
337
+ if (request !== schemaRequestRef.current) return
338
+ const { registerYamlSchema } = await import('./yamlMonacoRuntime')
339
+ if (request !== schemaRequestRef.current) return
340
+ const schemaSequence = Array.from(
341
+ {
342
+ length: Math.max(...documents.map((document) => document.schemaIndex)) + 1,
343
+ },
344
+ () => null as Record<string, unknown> | null,
345
+ )
346
+ documents.forEach((document, index) => {
347
+ schemaSequence[document.schemaIndex] = result.schemas[index] ?? null
348
+ })
349
+ const disposeSchema = await registerYamlSchema(modelPath, schemaSequence)
350
+ if (request !== schemaRequestRef.current) {
351
+ disposeSchema()
352
+ return
353
+ }
354
+ schemaDisposerRef.current = disposeSchema
355
+ const unavailable = result.unavailable ?? []
356
+ setSchemaUnavailable(unavailable)
357
+ setSchemaStatus(
358
+ unavailable.length === 0
359
+ ? 'ready'
360
+ : unavailable.length < documents.length
361
+ ? 'partial'
362
+ : 'unavailable',
363
+ )
364
+ setSchemaMessage(
365
+ unavailable.length === 0
366
+ ? `${documents.length} cluster schema${documents.length === 1 ? '' : 's'} active`
367
+ : unavailable.length === documents.length
368
+ ? unavailable[0]?.reason || 'Cluster schemas are unavailable.'
369
+ : `${documents.length - unavailable.length} of ${documents.length} cluster schemas active`,
370
+ )
371
+ })
372
+ .catch((error: unknown) => {
373
+ if (request !== schemaRequestRef.current) return
374
+ const reason = error instanceof Error ? error.message : 'Cluster schemas are unavailable.'
375
+ setSchemaStatus('unavailable')
376
+ setSchemaMessage(reason)
377
+ setSchemaUnavailable(documents.map((document) => ({ index: document.index, reason })))
378
+ })
379
+ }, delay)
380
+ return () => {
381
+ window.clearTimeout(timer)
382
+ schemaRequestRef.current += 1
383
+ }
384
+ }, [Boolean(schemaLoader), identitySignature, modelPath])
95
385
 
96
- // Apply decorations for editable fields
97
386
  const applyDecorations = useCallback(() => {
98
- const editor = editorRef.current
387
+ const mountedEditor = editorRef.current
99
388
  const monaco = monacoRef.current
100
- if (!editor || !monaco || !kind) return
101
-
389
+ if (!mountedEditor || !monaco || !kind) return
102
390
  const isPod = kind.toLowerCase() === 'pods' || kind.toLowerCase() === 'pod'
103
391
  if (!isPod) {
104
- // Clear decorations for non-pods
105
392
  if (decorationsRef.current.length > 0) {
106
- decorationsRef.current = editor.deltaDecorations(decorationsRef.current, [])
393
+ decorationsRef.current = mountedEditor.deltaDecorations(decorationsRef.current, [])
107
394
  }
108
395
  return
109
396
  }
110
-
111
- const editableLines = findPodEditableLines(value)
112
-
113
- const decorations: editor.IModelDeltaDecoration[] = editableLines.map(lineNumber => ({
114
- range: {
115
- startLineNumber: lineNumber,
116
- startColumn: 1,
117
- endLineNumber: lineNumber,
118
- endColumn: 1,
119
- },
120
- options: {
121
- isWholeLine: true,
122
- className: 'editable-line-highlight',
123
- glyphMarginClassName: 'editable-line-glyph',
124
- overviewRuler: {
125
- color: 'rgba(34, 197, 94, 0.5)',
126
- position: monaco.editor.OverviewRulerLane.Left,
397
+ const decorations: editor.IModelDeltaDecoration[] = findPodEditableLines(value).map(
398
+ (lineNumber) => ({
399
+ range: {
400
+ startLineNumber: lineNumber,
401
+ startColumn: 1,
402
+ endLineNumber: lineNumber,
403
+ endColumn: 1,
127
404
  },
128
- },
129
- }))
130
-
131
- decorationsRef.current = editor.deltaDecorations(decorationsRef.current, decorations)
405
+ options: {
406
+ isWholeLine: true,
407
+ className: 'editable-line-highlight',
408
+ glyphMarginClassName: 'editable-line-glyph',
409
+ overviewRuler: {
410
+ color: 'rgba(34, 197, 94, 0.5)',
411
+ position: monaco.editor.OverviewRulerLane.Left,
412
+ },
413
+ },
414
+ }),
415
+ )
416
+ decorationsRef.current = mountedEditor.deltaDecorations(decorationsRef.current, decorations)
132
417
  }, [value, kind])
133
418
 
134
- // Re-apply decorations when value changes
135
- useEffect(() => {
136
- applyDecorations()
137
- }, [applyDecorations])
419
+ useEffect(() => applyDecorations(), [applyDecorations])
138
420
 
139
- // Expose editor globally for desktop clipboard interception (see main.tsx).
140
- useEffect(() => {
141
- return () => { delete (window as any).__radarMonacoEditor }
142
- }, [])
421
+ const publishDiagnostics = useCallback(
422
+ (mountedEditor: editor.IStandaloneCodeEditor, monaco: Monaco) => {
423
+ const model = mountedEditor.getModel()
424
+ if (!model) return
425
+ const next = monaco.editor.getModelMarkers({ resource: model.uri }).map((marker) => {
426
+ const severity = markerSeverity(marker.severity, monaco)
427
+ return {
428
+ severity,
429
+ message: displayDiagnosticMessage(marker.message),
430
+ line: marker.startLineNumber,
431
+ column: marker.startColumn,
432
+ documentIndex: documentIndexForLine(documentsRef.current, marker.startLineNumber),
433
+ blocking: isBlockingYamlDiagnostic(
434
+ severity,
435
+ typeof marker.code === 'string' ? marker.code : undefined,
436
+ marker.message,
437
+ marker.source,
438
+ ),
439
+ } satisfies YamlDiagnostic
440
+ })
441
+ setDiagnostics(next)
442
+ if (next.some((diagnostic) => diagnostic.blocking)) setProblemsOpen(true)
443
+ const blocking = next.filter((diagnostic) => diagnostic.blocking)
444
+ onDiagnosticsRef.current?.(next)
445
+ onValidateRef.current?.(
446
+ blocking.length === 0,
447
+ blocking.map((diagnostic) => `Line ${diagnostic.line}: ${diagnostic.message}`),
448
+ )
449
+ },
450
+ [],
451
+ )
143
452
 
144
- const handleEditorMount: OnMount = useCallback((editor, monaco) => {
145
- editorRef.current = editor
146
- monacoRef.current = monaco
147
- ;(window as any).__radarMonacoEditor = editor
148
-
149
- // Add CSS for editable line highlighting
150
- const styleId = 'yaml-editor-styles'
151
- if (!document.getElementById(styleId)) {
152
- const style = document.createElement('style')
153
- style.id = styleId
154
- style.textContent = `
155
- .editable-line-highlight {
156
- background-color: rgba(34, 197, 94, 0.1) !important;
157
- border-left: 3px solid rgba(34, 197, 94, 0.6) !important;
453
+ const handleEditorMount: OnMount = useCallback(
454
+ (mountedEditor, monaco) => {
455
+ editorRef.current = mountedEditor
456
+ monacoRef.current = monaco
457
+ ;(window as typeof window & { __radarMonacoEditor?: unknown }).__radarMonacoEditor =
458
+ mountedEditor
459
+ ensureEditableLineStyles()
460
+ mountedEditor.updateOptions({
461
+ minimap: { enabled: false },
462
+ lineNumbers: 'on',
463
+ scrollBeyondLastLine: false,
464
+ wordWrap: 'on',
465
+ wrappingStrategy: 'advanced',
466
+ folding: true,
467
+ foldingStrategy: 'indentation',
468
+ renderLineHighlight: 'line',
469
+ selectOnLineNumbers: true,
470
+ roundedSelection: true,
471
+ cursorStyle: 'line',
472
+ automaticLayout: true,
473
+ tabSize: 2,
474
+ insertSpaces: true,
475
+ fontSize: 13,
476
+ fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace',
477
+ padding: { top: 12, bottom: 12 },
478
+ glyphMargin: true,
479
+ ariaLabel: 'YAML editor',
480
+ })
481
+ markerSubscriptionRef.current?.dispose()
482
+ markerSubscriptionRef.current = monaco.editor.onDidChangeMarkers((uris) => {
483
+ if (uris.some((uri) => uri.toString() === mountedEditor.getModel()?.uri.toString())) {
484
+ publishDiagnostics(mountedEditor, monaco)
158
485
  }
159
- .editable-line-glyph {
160
- background-color: rgba(34, 197, 94, 0.6);
161
- width: 4px !important;
162
- margin-left: 3px;
163
- border-radius: 2px;
486
+ })
487
+ suggestionSubscriptionRef.current?.dispose()
488
+ suggestionSubscriptionRef.current = mountedEditor.onDidChangeModelContent((event) => {
489
+ if (
490
+ !schemaLoaderRef.current ||
491
+ !event.changes.some(({ text }) => /\r?\n/.test(text) && text.trim().length === 0)
492
+ ) {
493
+ return
164
494
  }
165
- `
166
- document.head.appendChild(style)
167
- }
168
-
169
- // Configure YAML diagnostics (yaml property added by monaco-yaml plugin when available)
170
- ;(monaco.languages as any).yaml?.yamlDefaults?.setDiagnosticsOptions({
171
- enableSchemaRequest: false,
172
- validate: true,
173
- format: true,
174
- })
175
-
176
- // Set editor options
177
- editor.updateOptions({
178
- minimap: { enabled: false },
179
- lineNumbers: 'on',
180
- scrollBeyondLastLine: false,
181
- wordWrap: 'on',
182
- wrappingStrategy: 'advanced',
183
- folding: true,
184
- foldingStrategy: 'indentation',
185
- renderLineHighlight: 'line',
186
- selectOnLineNumbers: true,
187
- roundedSelection: true,
188
- cursorStyle: 'line',
189
- automaticLayout: true,
190
- tabSize: 2,
191
- insertSpaces: true,
192
- fontSize: 13,
193
- fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace',
194
- padding: { top: 12, bottom: 12 },
195
- glyphMargin: true,
196
- })
197
-
198
- // Listen for validation markers
199
- if (onValidate) {
200
- monaco.editor.onDidChangeMarkers((uris: readonly { toString(): string }[]) => {
201
- const uri = uris[0]
202
- if (uri && uri.toString() === editor.getModel()?.uri.toString()) {
203
- const markers = monaco.editor.getModelMarkers({ resource: uri as Parameters<typeof monaco.editor.getModelMarkers>[0]['resource'] })
204
- const errors = markers
205
- .filter((m: { severity: number }) => m.severity === monaco.MarkerSeverity.Error)
206
- .map((m: { startLineNumber: number; message: string }) => `Line ${m.startLineNumber}: ${m.message}`)
207
- onValidate(errors.length === 0, errors)
495
+ if (suggestionTimerRef.current !== null) {
496
+ window.clearTimeout(suggestionTimerRef.current)
208
497
  }
498
+ const insertedTexts = event.changes.map(({ text }) => text)
499
+ suggestionTimerRef.current = window.setTimeout(() => {
500
+ suggestionTimerRef.current = null
501
+ if (
502
+ !mountedEditor.hasTextFocus() ||
503
+ mountedEditor.getOption(monaco.editor.EditorOption.readOnly) ||
504
+ !['ready', 'partial'].includes(schemaStatusRef.current)
505
+ ) {
506
+ return
507
+ }
508
+ const model = mountedEditor.getModel()
509
+ const position = mountedEditor.getPosition()
510
+ if (
511
+ !model ||
512
+ !position ||
513
+ !shouldAutoTriggerYamlSuggestions(
514
+ insertedTexts,
515
+ model.getLineContent(position.lineNumber),
516
+ position.column,
517
+ )
518
+ ) {
519
+ return
520
+ }
521
+ mountedEditor.trigger('radar-yaml', 'editor.action.triggerSuggest', { auto: true })
522
+ }, 120)
209
523
  })
210
- }
524
+ publishDiagnostics(mountedEditor, monaco)
525
+ window.setTimeout(applyDecorations, 100)
526
+ },
527
+ [applyDecorations, publishDiagnostics],
528
+ )
211
529
 
212
- // Apply initial decorations
213
- setTimeout(applyDecorations, 100)
214
- }, [onValidate, applyDecorations])
530
+ const handleChange: OnChange = useCallback(
531
+ (nextValue) => {
532
+ if (onChange && nextValue !== undefined) onChange(nextValue)
533
+ },
534
+ [onChange],
535
+ )
215
536
 
216
- const handleChange: OnChange = useCallback((newValue) => {
217
- if (onChange && newValue !== undefined) {
218
- onChange(newValue)
219
- }
220
- }, [onChange])
537
+ const focusDiagnostic = useCallback((diagnostic: YamlDiagnostic) => {
538
+ const mountedEditor = editorRef.current
539
+ if (!mountedEditor) return
540
+ mountedEditor.setPosition({
541
+ lineNumber: diagnostic.line,
542
+ column: diagnostic.column,
543
+ })
544
+ mountedEditor.revealLineInCenter(diagnostic.line)
545
+ mountedEditor.focus()
546
+ }, [])
547
+
548
+ const blockingCount = diagnostics.filter((diagnostic) => diagnostic.blocking).length
549
+ const problemSummary =
550
+ blockingCount > 0
551
+ ? `${blockingCount} blocking`
552
+ : diagnostics.length > 0
553
+ ? `${diagnostics.length} advisory`
554
+ : schemaUnavailable.length > 0
555
+ ? `${schemaUnavailable.length} schema unavailable`
556
+ : 'None'
221
557
 
222
558
  return (
223
- <div className="rounded-lg overflow-hidden border border-theme-border" style={{ height }}>
224
- <Editor
225
- defaultLanguage="yaml"
226
- value={value}
227
- onChange={handleChange}
228
- onMount={handleEditorMount}
229
- theme="vs-dark"
230
- options={{
231
- readOnly,
232
- domReadOnly: readOnly,
233
- }}
234
- loading={
235
- <div className="flex items-center justify-center h-full bg-theme-surface text-theme-text-secondary">
236
- Loading editor…
559
+ <div
560
+ className="flex flex-col rounded-lg overflow-hidden border border-theme-border bg-theme-surface"
561
+ style={{ height }}
562
+ >
563
+ <div className="flex-1 min-h-0">
564
+ {runtimeReady ? (
565
+ <Editor
566
+ path={modelPath}
567
+ defaultLanguage="yaml"
568
+ value={value}
569
+ onChange={handleChange}
570
+ onMount={handleEditorMount}
571
+ theme={theme}
572
+ options={{
573
+ readOnly,
574
+ domReadOnly: readOnly,
575
+ suggest: {
576
+ selectionMode: 'never',
577
+ },
578
+ }}
579
+ loading={<PaneLoader label="Loading YAML editor…" className="h-full" />}
580
+ />
581
+ ) : runtimeError ? (
582
+ <div className="flex h-full min-h-0 flex-col bg-theme-base">
583
+ <div
584
+ role="alert"
585
+ className="flex shrink-0 items-center gap-2 border-b border-theme-border bg-theme-elevated px-3 py-2 text-xs text-theme-text-secondary"
586
+ >
587
+ <AlertTriangle className="h-4 w-4 shrink-0 text-warning-text" />
588
+ <span>
589
+ Rich YAML editing is unavailable. Basic editing and syntax validation remain
590
+ available without completion or cluster schema guidance.
591
+ </span>
592
+ <button
593
+ type="button"
594
+ onClick={() => setRuntimeAttempt((attempt) => attempt + 1)}
595
+ className="ml-auto rounded border border-theme-border bg-theme-surface px-2 py-1 text-theme-text-primary hover:bg-theme-hover"
596
+ >
597
+ Retry rich editor
598
+ </button>
599
+ </div>
600
+ <textarea
601
+ aria-label="YAML editor fallback"
602
+ value={value}
603
+ onChange={(event) => onChange?.(event.target.value)}
604
+ readOnly={readOnly}
605
+ spellCheck={false}
606
+ className="min-h-0 flex-1 resize-none bg-theme-base p-3 font-mono text-xs leading-5 text-theme-text-primary outline-none"
607
+ />
237
608
  </div>
238
- }
239
- />
609
+ ) : (
610
+ <PaneLoader label="Loading YAML editor…" className="h-full" />
611
+ )}
612
+ </div>
613
+ {showProblems && (
614
+ <div className="shrink-0 border-t border-theme-border bg-theme-elevated/60">
615
+ <button
616
+ type="button"
617
+ onClick={() => setProblemsOpen((open) => !open)}
618
+ className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs text-theme-text-secondary hover:bg-theme-hover"
619
+ aria-expanded={problemsOpen}
620
+ >
621
+ {problemsOpen ? (
622
+ <ChevronDown className="h-3.5 w-3.5" />
623
+ ) : (
624
+ <ChevronRight className="h-3.5 w-3.5" />
625
+ )}
626
+ <span className="font-medium text-theme-text-primary">Problems</span>
627
+ <span>{problemSummary}</span>
628
+ <span className="ml-auto flex min-w-0 items-center gap-3 text-theme-text-tertiary">
629
+ <span className="truncate" aria-live="polite">
630
+ {schemaStatus === 'loading' ? 'Loading cluster schemas…' : schemaMessage}
631
+ </span>
632
+ {!readOnly && (schemaStatus === 'ready' || schemaStatus === 'partial') && (
633
+ <span
634
+ className="flex shrink-0 items-center gap-1.5"
635
+ aria-label={`Select a suggestion with arrow keys, accept with Enter or Tab; show suggestions with ${suggestionShortcut}`}
636
+ >
637
+ <span>Accept selected</span>
638
+ <kbd className="rounded border border-theme-border bg-theme-surface px-1 font-mono text-theme-text-secondary">
639
+ Enter / Tab
640
+ </kbd>
641
+ <span>Suggestions</span>
642
+ <kbd className="rounded border border-theme-border bg-theme-surface px-1 font-mono text-theme-text-secondary">
643
+ {suggestionShortcut}
644
+ </kbd>
645
+ </span>
646
+ )}
647
+ </span>
648
+ </button>
649
+ {problemsOpen && (
650
+ <div className="max-h-36 overflow-auto border-t border-theme-border py-1">
651
+ {diagnostics.length === 0 && schemaUnavailable.length === 0 ? (
652
+ <div className="px-3 py-2 text-xs text-theme-text-tertiary">
653
+ {schemaStatus === 'ready'
654
+ ? 'No syntax or schema problems found.'
655
+ : schemaMessage || 'No syntax problems found.'}
656
+ </div>
657
+ ) : (
658
+ diagnostics.map((diagnostic, index) => {
659
+ const document = documents[diagnostic.documentIndex]
660
+ const Icon =
661
+ diagnostic.severity === 'error'
662
+ ? AlertCircle
663
+ : diagnostic.severity === 'warning'
664
+ ? AlertTriangle
665
+ : Info
666
+ return (
667
+ <button
668
+ type="button"
669
+ key={`${diagnostic.line}:${diagnostic.column}:${index}`}
670
+ onClick={() => focusDiagnostic(diagnostic)}
671
+ className="flex w-full items-start gap-2 px-3 py-1.5 text-left text-xs hover:bg-theme-hover"
672
+ >
673
+ <Icon
674
+ className={`mt-0.5 h-3.5 w-3.5 shrink-0 ${diagnostic.blocking ? 'text-red-500' : 'text-theme-text-tertiary'}`}
675
+ />
676
+ <span className="min-w-0 flex-1 text-theme-text-secondary">
677
+ {diagnostic.message}
678
+ </span>
679
+ <span className="shrink-0 text-theme-text-tertiary">
680
+ {document?.kind ? `${document.kind} · ` : ''}Ln {diagnostic.line}
681
+ </span>
682
+ </button>
683
+ )
684
+ })
685
+ )}
686
+ {schemaUnavailable.map(({ index, reason }) => {
687
+ const document = documents[index]
688
+ return (
689
+ <div
690
+ key={`schema:${index}`}
691
+ className="flex items-start gap-2 px-3 py-1.5 text-xs text-theme-text-secondary"
692
+ >
693
+ <AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0 text-warning-text" />
694
+ <span className="min-w-0 flex-1">{reason}</span>
695
+ <span className="shrink-0 text-theme-text-tertiary">
696
+ {document?.kind || `Document ${index + 1}`}
697
+ </span>
698
+ </div>
699
+ )
700
+ })}
701
+ </div>
702
+ )}
703
+ </div>
704
+ )}
240
705
  </div>
241
706
  )
242
707
  }
243
708
 
244
- interface YamlDiffEditorProps {
709
+ export interface YamlDiffEditorProps {
245
710
  original: string
246
711
  modified: string
247
712
  height?: string | number
248
- /** When true, render unified (single column) instead of side-by-side. */
249
713
  unified?: boolean
250
- /** When true, only diff regions stay rendered — unchanged sections collapse. */
251
714
  hideUnchanged?: boolean
252
- /** Caller-controlled theme. Defaults to dark to match YamlEditor. */
253
715
  theme?: 'vs-dark' | 'vs'
254
- /** When true, drops the rounded border so the editor reaches its container's edges. */
255
716
  bleed?: boolean
717
+ onReadyChange?: (ready: boolean) => void
256
718
  }
257
719
 
258
720
  export function YamlDiffEditor({
@@ -261,33 +723,144 @@ export function YamlDiffEditor({
261
723
  height = '100%',
262
724
  unified = false,
263
725
  hideUnchanged = false,
264
- theme = 'vs-dark',
726
+ theme,
265
727
  bleed = false,
728
+ onReadyChange,
266
729
  }: YamlDiffEditorProps) {
730
+ const [runtimeReady, setRuntimeReady] = useState(false)
731
+ const [runtimeError, setRuntimeError] = useState(false)
732
+ const [runtimeAttempt, setRuntimeAttempt] = useState(0)
733
+ const documentTheme = useDocumentMonacoTheme()
734
+ const diffModelsRef = useRef<editor.IDiffEditorModel | null>(null)
735
+ const onReadyChangeRef = useRef(onReadyChange)
736
+ onReadyChangeRef.current = onReadyChange
737
+ const handleDiffMount = useCallback<DiffOnMount>((diffEditor) => {
738
+ diffModelsRef.current = diffEditor.getModel()
739
+ onReadyChangeRef.current?.(true)
740
+ }, [])
741
+ useEffect(() => {
742
+ let active = true
743
+ setRuntimeReady(false)
744
+ setRuntimeError(false)
745
+ onReadyChangeRef.current?.(false)
746
+ import('./monacoRuntime')
747
+ .then(({ ensureMonaco }) => ensureMonaco())
748
+ .then(() => {
749
+ if (active) setRuntimeReady(true)
750
+ })
751
+ .catch(() => {
752
+ if (active) setRuntimeError(true)
753
+ })
754
+ return () => {
755
+ active = false
756
+ }
757
+ }, [runtimeAttempt])
758
+ useEffect(
759
+ () => () => {
760
+ const models = diffModelsRef.current
761
+ diffModelsRef.current = null
762
+ window.setTimeout(() => {
763
+ models?.original.dispose()
764
+ models?.modified.dispose()
765
+ })
766
+ },
767
+ [],
768
+ )
769
+ useEffect(() => {
770
+ if (runtimeError) onReadyChangeRef.current?.(true)
771
+ }, [runtimeError])
267
772
  return (
268
- <div className={bleed ? 'overflow-hidden' : 'rounded-lg overflow-hidden border border-theme-border'} style={{ height }}>
269
- <DiffEditor
270
- original={original}
271
- modified={modified}
272
- language="yaml"
273
- theme={theme}
274
- options={{
275
- readOnly: true,
276
- renderSideBySide: !unified,
277
- hideUnchangedRegions: { enabled: hideUnchanged },
278
- minimap: { enabled: false },
279
- lineNumbers: 'on',
280
- scrollBeyondLastLine: false,
281
- wordWrap: 'on',
282
- fontSize: 13,
283
- fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace',
284
- padding: { top: 12, bottom: 12 },
285
- renderOverviewRuler: true,
286
- ignoreTrimWhitespace: false,
287
- automaticLayout: true,
288
- }}
289
- loading={<PaneLoader label="Loading resources…" className="h-full" />}
290
- />
773
+ <div
774
+ className={
775
+ bleed ? 'overflow-hidden' : 'rounded-lg overflow-hidden border border-theme-border'
776
+ }
777
+ style={{ height }}
778
+ >
779
+ {runtimeReady ? (
780
+ <DiffEditor
781
+ original={original}
782
+ modified={modified}
783
+ language="yaml"
784
+ theme={theme ?? documentTheme}
785
+ keepCurrentOriginalModel
786
+ keepCurrentModifiedModel
787
+ onMount={handleDiffMount}
788
+ options={{
789
+ readOnly: true,
790
+ renderSideBySide: !unified,
791
+ useInlineViewWhenSpaceIsLimited: false,
792
+ hideUnchangedRegions: { enabled: hideUnchanged },
793
+ minimap: { enabled: false },
794
+ lineNumbers: 'on',
795
+ scrollBeyondLastLine: false,
796
+ wordWrap: 'on',
797
+ fontSize: 13,
798
+ fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace',
799
+ padding: { top: 12, bottom: 12 },
800
+ renderOverviewRuler: true,
801
+ ignoreTrimWhitespace: false,
802
+ automaticLayout: true,
803
+ }}
804
+ loading={<PaneLoader label="Loading diff…" className="h-full" />}
805
+ />
806
+ ) : runtimeError ? (
807
+ <div className="flex h-full min-h-0 flex-col bg-theme-base">
808
+ <div
809
+ role="alert"
810
+ className="flex shrink-0 items-center gap-2 border-b border-theme-border bg-theme-elevated px-3 py-2 text-xs text-theme-text-secondary"
811
+ >
812
+ <AlertTriangle className="h-4 w-4 shrink-0 text-warning-text" />
813
+ <span>Rich diff unavailable. Review the plain YAML below.</span>
814
+ <button
815
+ type="button"
816
+ onClick={() => setRuntimeAttempt((attempt) => attempt + 1)}
817
+ className="ml-auto rounded border border-theme-border bg-theme-surface px-2 py-1 text-theme-text-primary hover:bg-theme-hover"
818
+ >
819
+ Retry rich diff
820
+ </button>
821
+ </div>
822
+ <div className="grid min-h-0 flex-1 grid-cols-1 divide-y divide-theme-border overflow-hidden md:grid-cols-2 md:divide-x md:divide-y-0">
823
+ <section className="flex min-h-0 flex-col">
824
+ <div className="shrink-0 border-b border-theme-border px-3 py-1.5 text-[11px] font-medium uppercase tracking-wide text-theme-text-tertiary">
825
+ Before
826
+ </div>
827
+ <pre className="min-h-0 flex-1 overflow-auto whitespace-pre p-3 font-mono text-xs text-theme-text-primary">
828
+ {original}
829
+ </pre>
830
+ </section>
831
+ <section className="flex min-h-0 flex-col">
832
+ <div className="shrink-0 border-b border-theme-border px-3 py-1.5 text-[11px] font-medium uppercase tracking-wide text-theme-text-tertiary">
833
+ After
834
+ </div>
835
+ <pre className="min-h-0 flex-1 overflow-auto whitespace-pre p-3 font-mono text-xs text-theme-text-primary">
836
+ {modified}
837
+ </pre>
838
+ </section>
839
+ </div>
840
+ </div>
841
+ ) : (
842
+ <PaneLoader label="Loading diff…" className="h-full" />
843
+ )}
291
844
  </div>
292
845
  )
293
846
  }
847
+
848
+ function ensureEditableLineStyles() {
849
+ const styleId = 'yaml-editor-styles'
850
+ if (document.getElementById(styleId)) return
851
+ const style = document.createElement('style')
852
+ style.id = styleId
853
+ style.textContent = `
854
+ .editable-line-highlight {
855
+ background-color: rgba(34, 197, 94, 0.1) !important;
856
+ border-left: 3px solid rgba(34, 197, 94, 0.6) !important;
857
+ }
858
+ .editable-line-glyph {
859
+ background-color: rgba(34, 197, 94, 0.6);
860
+ width: 4px !important;
861
+ margin-left: 3px;
862
+ border-radius: 2px;
863
+ }
864
+ `
865
+ document.head.appendChild(style)
866
+ }