PageSourceSearch

https://www.meshweaver.cloud/_content/MeshWeaver.Blazor/Components/Monaco/MonacoEditorView.puw22al161.razor.js

js meshweaver.cloud collected 2026-09-25 22:09:08 UTC 72,064 bytes, 1,454 lines download raw bytes

1// Monaco Editor View JavaScript module
2const editorState = new Map();
3
4// =============================================================================
5// Monaco Theme Synchronization
6// =============================================================================
7
8let themeCallbackRegistered = false;
9
10// Update all Monaco editors to match the app theme
11function syncMonacoTheme(effectiveTheme) {
12    const monacoTheme = effectiveTheme === 'dark' ? 'vs-dark' : 'vs';
13
14    // Check if Monaco is available
15    if (typeof monaco !== 'undefined' && monaco.editor) {
16        // Set the global Monaco theme - this affects all editors
17        monaco.editor.setTheme(monacoTheme);
18    }
19}
20
21// The OS preference, the ultimate arbiter when the app theme is "System".
22function osPrefersDark() {
23    return !!(window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches);
24}
25
26// Resolve the CURRENTLY-RENDERED effective theme ('dark' | 'light'), correct even when the app theme
27// mode is "System" (follow the OS). Order of authority:
28//   1. document.documentElement[data-theme] — what themeHandler.applyTheme() ACTUALLY wrote (already
29//      resolved System→OS). This reflects what's on screen right now; it's the most reliable signal and
30//      is re-written whenever the app mode OR the OS preference changes.
31//   2. themeHandler.getEffectiveTheme() — recomputes from the current mode (also resolves System→OS).
32//   3. body[data-theme] / dark classes — legacy FluentDesignTheme signals.
33//   4. the OS preference directly.
34// A literal 'system' value anywhere is resolved via the OS — never treated as a theme name (that was
35// the "black font": 'system' !== 'dark' → the light 'vs' theme with black glyphs on the dark composer).
36function detectThemeFromDOM() {
37    const resolve = (v) => {
38        if (v === 'dark' || v === 'light') return v;
39        if (v === 'system' || v === 'auto') return osPrefersDark() ? 'dark' : 'light';
40        return null;
41    };
42    const htmlTheme = resolve(document.documentElement.getAttribute('data-theme'));
43    if (htmlTheme) return htmlTheme;
44    if (typeof window.themeHandler !== 'undefined'
45        && typeof window.themeHandler.getEffectiveTheme === 'function') {
46        const handlerTheme = resolve(window.themeHandler.getEffectiveTheme());
47        if (handlerTheme) return handlerTheme;
48    }
49    const bodyTheme = resolve(document.body?.getAttribute('data-theme'));
50    if (bodyTheme) return bodyTheme;
51    if (document.documentElement.classList.contains('dark-theme') ||
52        document.body?.classList.contains('dark') ||
53        document.body?.classList.contains('fluent-dark') ||
54        document.documentElement.classList.contains('dark') ||
55        document.documentElement.classList.contains('fluent-dark')) {
56        return 'dark';
57    }
58    return osPrefersDark() ? 'dark' : 'light';
59}
60
61// Register theme change callback (called from initEditor when Monaco is ready)
62function ensureThemeCallbackRegistered() {
63    if (themeCallbackRegistered) return;
64
65    if (typeof window.themeHandler !== 'undefined' && window.themeHandler.registerThemeChangeCallback) {
66        window.themeHandler.registerThemeChangeCallback((effectiveTheme, isDark) => {
67            syncMonacoTheme(effectiveTheme);
68        });
69        themeCallbackRegistered = true;
70
71        // Apply current theme immediately
72        const currentTheme = window.themeHandler.getEffectiveTheme();
73        syncMonacoTheme(currentTheme);
74    } else {
75        // Fallback: detect theme from DOM and apply
76        const currentTheme = detectThemeFromDOM();
77        syncMonacoTheme(currentTheme);
78
79        // Also listen for system theme changes
80        if (window.matchMedia) {
81            window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
82                // Only use system preference if no explicit theme is set
83                if (!document.documentElement.getAttribute('data-theme')) {
84                    syncMonacoTheme(e.matches ? 'dark' : 'light');
85                }
86            });
87        }
88
89        // Set up MutationObservers to watch for theme attribute changes on both body and html
90        const observer = new MutationObserver((mutations) => {
91            for (const mutation of mutations) {
92                if (mutation.type === 'attributes' &&
93                    (mutation.attributeName === 'data-theme' || mutation.attributeName === 'class')) {
94                    const theme = detectThemeFromDOM();
95                    syncMonacoTheme(theme);
96                }
97            }
98        });
99
100        // Observe both body and documentElement for theme changes
101        if (document.body) {
102            observer.observe(document.body, { attributes: true, attributeFilter: ['data-theme', 'class'] });
103        }
104        observer.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme', 'class'] });
105
106        themeCallbackRegistered = true;
107    }
108}
109
110// =============================================================================
111
112// Debounce utility function
113function debounce(fn, delay) {
114    let timeoutId = null;
115    return function (...args) {
116        if (timeoutId) {
117            clearTimeout(timeoutId);
118        }
119        return new Promise((resolve) => {
120            timeoutId = setTimeout(async () => {
121                timeoutId = null;
122                const result = await fn.apply(this, args);
123                resolve(result);
124            }, delay);
125        });
126    };
127}
128
129// Add global styles for suggest widget (needed because FixedOverflowWidgets renders outside component)
130(function addSuggestWidgetStyles() {
131    if (document.getElementById('monaco-suggest-styles')) return;
132
133    const style = document.createElement('style');
134    style.id = 'monaco-suggest-styles';
135    style.textContent = `
136        /* Target suggest widget in overflow widgets container. Responsive width:
137           never wider than the viewport (minus a gutter) so a narrow side-panel
138           composer doesn't get a fixed 550px popup spilling off-screen. The exact
139           per-editor clamp to the editor's own bounds is done in JS (clampSuggestWidget). */
140        .overflowingContentWidgets .suggest-widget,
141        .monaco-editor .suggest-widget {
142            width: min(550px, calc(100vw - 24px)) !important;
143            min-width: 240px !important;
144            max-width: min(550px, calc(100vw - 24px)) !important;
145        }
146
147        /* Force the list to use full width */
148        .overflowingContentWidgets .suggest-widget .monaco-list,
149        .monaco-editor .suggest-widget .monaco-list {
150            width: 100% !important;
151        }
152
153        .overflowingContentWidgets .suggest-widget .monaco-list-rows,
154        .monaco-editor .suggest-widget .monaco-list-rows {
155            width: 100% !important;
156        }
157
158        /* Each row - let Monaco calculate height, just ensure proper layout */
159        .overflowingContentWidgets .suggest-widget .monaco-list-row,
160        .monaco-editor .suggest-widget .monaco-list-row {
161            width: 100% !important;
162            box-sizing: border-box !important;
163        }
164
165        /* Primary label (node name) - bold */
166        .overflowingContentWidgets .suggest-widget .monaco-icon-name-container,
167        .monaco-editor .suggest-widget .monaco-icon-name-container {
168            font-weight: 600 !important;
169        }
170
171        /* Secondary line (path) - muted, smaller */
172        .overflowingContentWidgets .suggest-widget .monaco-icon-description-container,
173        .monaco-editor .suggest-widget .monaco-icon-description-container {
174            margin-left: 8px !important;
175            opacity: 0.7 !important;
176            white-space: nowrap !important;
177            overflow: hidden !important;
178            text-overflow: ellipsis !important;
179        }
180
181        /* Dark mode support for suggest widget - high specificity to override Monaco defaults */
182        html[data-theme="dark"] .monaco-editor .suggest-widget,
183        html[data-theme="dark"] .overflowingContentWidgets .suggest-widget,
184        html[data-theme="dark"] .suggest-widget.monaco-editor-overlaymessage,
185        :root[data-theme="dark"] .suggest-widget {
186            background-color: #252526 !important;
187            border: 1px solid #454545 !important;
188            color: #cccccc !important;
189        }
190
191        html[data-theme="dark"] .monaco-editor .suggest-widget .monaco-list,
192        html[data-theme="dark"] .overflowingContentWidgets .suggest-widget .monaco-list,
193        :root[data-theme="dark"] .suggest-widget .monaco-list {
194            background-color: #252526 !important;
195        }
196
197        html[data-theme="dark"] .monaco-editor .suggest-widget .monaco-list-row,
198        html[data-theme="dark"] .overflowingContentWidgets .suggest-widget .monaco-list-row,
199        :root[data-theme="dark"] .suggest-widget .monaco-list-row {
200            color: #cccccc !important;
201            background-color: transparent !important;
202        }
203
204        html[data-theme="dark"] .monaco-editor .suggest-widget .monaco-list-row.focused,
205        html[data-theme="dark"] .monaco-editor .suggest-widget .monaco-list-row.selected,
206        html[data-theme="dark"] .overflowingContentWidgets .suggest-widget .monaco-list-row.focused,
207        html[data-theme="dark"] .overflowingContentWidgets .suggest-widget .monaco-list-row.selected,
208        :root[data-theme="dark"] .suggest-widget .monaco-list-row.focused,
209        :root[data-theme="dark"] .suggest-widget .monaco-list-row.selected {
210            background-color: #094771 !important;
211            color: #ffffff !important;
212        }
213
214        html[data-theme="dark"] .monaco-editor .suggest-widget .details-label,
215        html[data-theme="dark"] .overflowingContentWidgets .suggest-widget .details-label,
216        :root[data-theme="dark"] .suggest-widget .details-label {
217            color: #8a8a8a !important;
218        }
219
220        /* NOTE: no @media (prefers-color-scheme: dark) block. The popup must follow the
221           APP theme (:root[data-theme=...]) — see the data-theme rules above — NOT the OS
222           setting. The old prefers-color-scheme override forced a dark popup whenever macOS
223           was in dark mode even though the app was in LIGHT theme (dark box in a white panel). */
224    `;
225    document.head.appendChild(style);
226})();
227
228// Resolves once Monaco's async AMD bundle has loaded, so the component can defer
229// rendering <StandaloneCodeEditor> (BlazorMonaco's create() touches the global
230// `monaco`) until it's safe. The host (App.razor) exposes window.monacoReady — a
231// GETTER: reading it starts the on-demand download of the bundle + stylesheet (once, shared)
232// and returns the Promise that settles when both have loaded. Pages without an editor never
233// read it, so they never download Monaco. We treat
234// BOTH success and failure as "done": a failed/stalled Monaco load must NOT block the
235// editor forever (BlazorMonaco surfaces its own error if monaco is genuinely missing),
236// and it must never block the rest of the now-decoupled Blazor circuit. Hosts that
237// preload Monaco synchronously (no window.monacoReady) resolve immediately.
238export function waitForMonaco() {
239    const ready = window.monacoReady;
240    if (ready && typeof ready.then === 'function') {
241        return ready.then(() => true, () => true);
242    }
243    return true;
244}
245
246export function initEditor(editorId, placeholder, dotNetRef, codeEditMode = false, showLineNumbers = false, autoGrow = false) {
247    const container = document.getElementById(editorId);
248    if (!container) {
249        // Component may have been disposed before JS init completed — not an error
250        console.debug('Monaco container not yet available:', editorId);
251        return;
252    }
253
254    // Register theme sync callback (only once, when first editor initializes)
255    ensureThemeCallbackRegistered();
256
257    // Get the monaco editor instance via BlazorMonaco's registry (reliable lookup)
258    const editorInstance = window.blazorMonaco?.editor?.getEditor(editorId);
259
260    // Store state for this editor
261    editorState.set(editorId, {
262        dotNetRef: dotNetRef,
263        editorInstance: editorInstance,
264        annotationDecorationIds: [],
265        completionConfig: null,
266        completionDisposable: null,
267        codeEditMode: codeEditMode,
268        showLineNumbers: showLineNumbers,
269        autoGrow: autoGrow,
270        resizeTimeout: null
271    });
272
273    // Add placeholder styling
274    updatePlaceholder(editorId, placeholder, showLineNumbers);
275
276    // Apply the CURRENT app theme immediately and UNCONDITIONALLY. monaco.editor.setTheme is
277    // GLOBAL (not per-instance), so it must run even when this editor isn't in BlazorMonaco's
278    // registry yet (getEditor returned null). Gating it on `editorInstance` (the content-change
279    // rewiring) meant a composer mounted AFTER the first editor — e.g. the chat composer inside
280    // the user-activity area — kept Monaco's default 'vs' (light) theme, so its glyphs rendered
281    // BLACK on the transparent-over-dark composer background (unreadable in dark mode). Re-syncing
282    // the global theme on every mount also heals a stale theme set before the app flipped to dark.
283    if (typeof monaco !== 'undefined' && monaco.editor) {
284        // detectThemeFromDOM now resolves the EFFECTIVE theme even under the "System" mode (documentElement
285        // [data-theme] that applyTheme wrote → themeHandler.getEffectiveTheme → OS preference), so a composer
286        // mounted after the first editor (e.g. inside the user-activity area) no longer resets Monaco to the
287        // light 'vs' theme and render black glyphs on the dark composer background.
288        monaco.editor.setTheme(detectThemeFromDOM() === 'dark' ? 'vs-dark' : 'vs');
289    }
290    if (editorInstance) {
291        // Handle content changes for placeholder AND push the value back to C#.
292        // 🚨 This is the SYNC content-change wiring that replaces BlazorMonaco's
293        // OnDidChangeModelContent Razor callback: that callback is registered via
294        // BlazorMonaco's async SetEventListeners in OnAfterRenderAsync, which throws
295        // "Couldn't find the editor with id …" (tearing down the circuit) when the
296        // editor is mid-teardown. Wiring the listener directly on the editor instance
297        // here avoids that race. The invoke is best-effort — a disposed dotNetRef
298        // rejects, which we swallow so a teardown can never surface an unhandled error.
299        editorInstance.onDidChangeModelContent(() => {
300            const value = editorInstance.getValue();
301            updatePlaceholderVisibility(editorId, !value);
302            const st = editorState.get(editorId);
303            if (st?.dotNetRef) {
304                st.dotNetRef.invokeMethodAsync('HandleContentChanged', value)
305                    .catch(err => console.debug('HandleContentChanged failed (editor disposed?):', err));
306            }
307        });
308
309        // Auto-grow (opt-in): follow the content height so the composer expands with the
310        // text instead of scrolling invisibly inside a fixed box (issue #178). The container's
311        // CSS min-height/max-height clamp the range; automaticLayout's ResizeObserver re-lays
312        // the editor out when the container height changes. Debounced so rapid typing doesn't
313        // thrash the DOM; shrink-back on delete works the same way (smaller contentHeight).
314        if (autoGrow) {
315            // 🚨 Grow the OUTER container (.monaco-editor-container), NOT the inner
316            // #editorId element. #editorId IS the .monaco-editor-view div, which
317            // MonacoEditorView.razor.css pins to `height: 100% !important` — and a CSS
318            // `!important` rule overrides an inline style, so setting `.style.height`
319            // on it was SILENTLY IGNORED. The composer therefore stayed stuck at the
320            // 80px min-height and long input scrolled invisibly (lines "moved up").
321            // The outer container carries the min-height/max-height clamps and has no
322            // !important height rule, so sizing IT lets the inner 100% fill it: the box
323            // expands with the content and only scrolls once max-height is reached.
324            editorInstance.onDidContentSizeChange((e) => {
325                const st = editorState.get(editorId);
326                if (!st) return;
327                if (st.resizeTimeout) clearTimeout(st.resizeTimeout);
328                st.resizeTimeout = setTimeout(() => {
329                    const inner = document.getElementById(editorId);
330                    // 🚨 #editorId is BlazorMonaco's OWN root div, which carries BlazorMonaco's
331                    // default class `monaco-editor-container` — the SAME name as our wrapper.
332                    // A plain `inner.closest(...)` therefore self-matches the inner element,
333                    // and the height write lands on the `!important`-pinned .monaco-editor-view
334                    // (silently ignored) instead of the wrapper — auto-grow dead on every
335                    // surface (issue #458). Start the search at the PARENT to reach the wrapper.
336                    const outer = inner?.parentElement?.closest('.monaco-editor-container');
337                    // Guard against a dispose race: the editor can be torn down during the 50ms
338                    // debounce window, after which layout() throws "Couldn't find the editor…".
339                    // getDomNode() returns null once the instance is disposed — bail if so.
340                    if (outer && editorInstance.getDomNode()) {
341                        outer.style.height = e.contentHeight + 'px';
342                        // Re-lay out to the new height now; automaticLayout's ResizeObserver
343                        // would otherwise catch up a frame later.
344                        editorInstance.layout();
345                    }
346                }, 50);
347            });
348        }
349
350        // Keep the suggest (autocomplete) widget inside THIS editor's own horizontal bounds.
351        // Monaco renders it at document level (FixedOverflowWidgets), up to the CSS width, so in a
352        // narrow side-panel composer it otherwise spilled LEFT into the page. On each content /
353        // cursor change (which is also when the widget first appears + repositions) we cap the
354        // widget's width to min(550, editorWidth) — it may only SHRINK for a narrow editor, never
355        // grow past the 550px design cap in a wide one — and re-anchor it if it reaches past the
356        // editor's edges. Uses setProperty(..,'important') so it beats the stylesheet's `!important`
357        // width. Writing the widget's inline style does NOT fire onDidChange* — no feedback loop —
358        // and a rAF debounce coalesces bursts. Wrapped so a stray DOM state can never throw into Monaco.
359        {
360            let clampPending = false;
361            const clampSuggestWidget = () => {
362                clampPending = false;
363                try {
364                    if (!editorInstance.hasTextFocus || !editorInstance.hasTextFocus()) return;
365                    const node = editorInstance.getDomNode && editorInstance.getDomNode();
366                    if (!node) return;
367                    const widget = document.querySelector('.overflowingContentWidgets .suggest-widget.visible')
368                        || node.querySelector('.suggest-widget.visible');
369                    if (!widget) return;
370                    const rect = node.getBoundingClientRect();
371                    if (rect.width < 40) return;
372                    // Only cap — never expand beyond the 550px CSS design width.
373                    const maxW = Math.max(220, Math.min(550, Math.round(rect.width)));
374                    widget.style.setProperty('width', maxW + 'px', 'important');
375                    widget.style.setProperty('max-width', maxW + 'px', 'important');
376                    widget.style.setProperty('min-width', Math.min(220, maxW) + 'px', 'important');
377                    const wr = widget.getBoundingClientRect();
378                    if (wr.left < rect.left - 1)
379                        widget.style.left = Math.round(rect.left) + 'px';
380                    else if (wr.right > rect.right + 1)
381                        widget.style.left = Math.round(Math.max(rect.left, rect.right - wr.width)) + 'px';
382                } catch { /* never break Monaco's render loop */ }
383            };
384            const scheduleClamp = () => {
385                if (clampPending) return;
386                clampPending = true;
387                requestAnimationFrame(clampSuggestWidget);
388            };
389            editorInstance.onDidChangeModelContent(scheduleClamp);
390            editorInstance.onDidChangeCursorSelection(scheduleClamp);
391        }
392
393        // Handle Enter key - in code edit mode, Enter inserts newline; in chat mode, Enter submits
394        const state = editorState.get(editorId);
395        if (!state?.codeEditMode) {
396            // Chat input mode: Enter submits, Shift+Enter and Alt+Enter insert a newline.
397            //
398            // 🚨 The WHOLE Enter family is handled in this ONE onKeyDown listener, and that is
399            // issue #2081. Shift+Enter and Alt+Enter used to be editorInstance.addCommand(...)
400            // registrations instead, and `addCommand` does not register anything ON THE EDITOR.
401            // Measured in monaco-editor 0.56.0, the build the portal vendors and serves:
402            //
403            //   • standalone/browser/standaloneCodeEditor.js — addCommand(keybinding, handler,
404            //     context) forwards to the STANDALONE KEYBINDING SERVICE, which is one page-wide
405            //     singleton, with `when = ContextKeyExpr.deserialize(context)`. We passed no
406            //     context, so `when` is undefined: the binding is scoped to no editor at all.
407            //   • standalone/browser/standaloneServices.js — addDynamicKeybindings APPENDS to one
408            //     page-wide array and returns a disposable that would remove the entry, which
409            //     addCommand discards (it returns only a string id). Nothing can unregister one,
410            //     so every chat editor ever mounted left two entries behind for the page's life.
411            //   • platform/keybinding/common/keybindingResolver.js — _findCommand scans the
412            //     matching entries BACKWARDS, so the LAST registration wins.
413            //   • platform/keybinding/common/abstractKeybindingService.js — a resolved keybinding
414            //     sets shouldPreventDefault, so the intercepted chord could not fall through to
415            //     Monaco's own newline either.
416            //
417            // So Shift+Enter ran the handler belonging to whichever chat-mode editor was created
418            // LAST on the page — not the focused one — and typed the newline into that instance,
419            // or nowhere at all once it had been disposed. CodeEditMode defaults to false, so the
420            // chat composer, SearchBoxView and MeshSearchView all registered one: opening the
421            // search box once was enough to take Shift+Enter away from the composer for the rest
422            // of the session. That is the whole of "intermittently inserts no newline" — it turned
423            // on what had been mounted last, never on what was typed, which is why the reporter
424            // saw no correlation with message length.
425            //
426            // This is NOT the #2217 focus defect and is not a regression of it: #2252's fix (every
427            // picker close path returns focus to the composer, ThreadChatView's
428            // _focusComposerOnRender) is present and correct. It was a second, independent way to
429            // lose the same keystroke. See Chat/ShiftEnterNewline.md.
430            //
431            // The fix is symmetry with plain Enter, which never had the problem because it is
432            // handled here — a listener on the editor INSTANCE, which only ever fires for the
433            // editor that received the keystroke. The newline action itself is unchanged
434            // (trigger 'type' '\n'); all that moves is WHERE it is bound from, so it can no
435            // longer be won by a different editor.
436            //
437            // 🚨 It deliberately does NOT just fall through to "Monaco's default newline". That
438            // reading is wrong, and the vendored bundle says so — measured against
439            // wwwroot/lib/monaco-editor/monaco.js, which binds BOTH chords:
440            //   • Shift+Enter (primary:1027) → `acceptAlternativeSelectedSuggestion`, live
441            //     whenever `suggestWidgetVisible && textInputFocus && HasFocusedSuggestion`.
442            //   • Alt+Enter  (primary:515)  → the find widget's `SelectAllMatchesAction`.
443            // So an unprevented Shift+Enter while the suggest list is up ACCEPTS THE SUGGESTION
444            // instead of breaking the line — precisely the slash-command case #2081 was reported
445            // from, where the list is open most of the time the user is typing. preventDefault +
446            // stopPropagation keep both away: the editor's own keydown listener fires on the
447            // textarea, inside the container the keybinding service listens on, so stopping
448            // propagation there means the chord never reaches it.
449            //
450            // Pinned by clients/react/src/controls/composerEnterKeys.test.ts, which drives THIS
451            // file and fails on a page-global registration.
452            editorInstance.onKeyDown(async (e) => {
453                if (e.keyCode !== monaco.KeyCode.Enter) return;
454
455                // Ctrl/Cmd+Enter belongs to Monaco, not to the composer.
456                if (e.ctrlKey || e.metaKey) return;
457
458                // Shift+Enter / Alt+Enter → a newline in THIS editor, whatever else is on screen.
459                if (e.shiftKey || e.altKey) {
460                    e.preventDefault();
461                    e.stopPropagation();
462                    editorInstance.trigger('keyboard', 'type', { text: '\n' });
463                    return;
464                }
465
466                // Plain Enter submits — but block that ONLY when the suggest widget is ACTUALLY on
467                // screen (it owns Enter then: accept the selected suggestion). The previous check
468                // read suggestController.model.state > 0, which is truthy for the whole suggest
469                // SESSION — including the async-completion loading window with nothing
470                // rendered yet. Typing "/model" and pressing Enter inside the completion
471                // debounce therefore swallowed the first Enter with no visible widget
472                // (issue #174: commands need multiple attempts). The rendered widget adds
473                // .suggest-widget.visible — but with FixedOverflowWidgets (our config) the
474                // widget is hosted OUTSIDE the editor node in the document-level
475                // .overflowingContentWidgets container (same reason isAutocompleteVisible
476                // below searches document-wide), so check the editor DOM first and fall
477                // back to the overflow container. A visible suggest widget can only belong
478                // to the focused editor — the one receiving this keydown — so the
479                // document-wide fallback cannot misattribute across editors.
480                const suggestWidget = editorInstance.getDomNode()?.querySelector('.suggest-widget')
481                    ?? document.querySelector('.overflowingContentWidgets .suggest-widget');
482                const isSuggestVisible = !!suggestWidget && suggestWidget.classList.contains('visible');
483
484                if (!isSuggestVisible) {
485                    e.preventDefault();
486                    e.stopPropagation();
487                    const currentState = editorState.get(editorId);
488                    if (currentState?.dotNetRef) {
489                        try {
490                            await currentState.dotNetRef.invokeMethodAsync('HandleSubmit');
491                        } catch (err) {
492                            console.error('Error calling HandleSubmit:', err);
493                        }
494                    }
495                }
496            });
497        }
498        // In code edit mode, Enter naturally inserts newlines (default Monaco behavior)
499
500        // Handle blur event - delay to check if focus moved to autocomplete
501        editorInstance.onDidBlurEditorWidget(async () => {
502            // Small delay to allow focus to settle (autocomplete popup steals focus)
503            await new Promise(resolve => setTimeout(resolve, 100));
504
505            // Check if autocomplete is visible - don't fire blur if it is
506            const suggestController = editorInstance.getContribution('editor.contrib.suggestController');
507            const suggestState = suggestController?.model?.state;
508            const isSuggestVisible = typeof suggestState === 'number' && suggestState > 0;
509
510            if (isSuggestVisible) {
511                return; // Don't fire blur while autocomplete is open
512            }
513
514            // Also check if editor regained focus
515            if (editorInstance.hasTextFocus()) {
516                return; // Focus returned to editor
517            }
518
519            const currentState = editorState.get(editorId);
520            if (currentState?.dotNetRef) {
521                try {
522                    await currentState.dotNetRef.invokeMethodAsync('HandleBlur');
523                } catch (err) {
524                    // Ignore errors - component may have been disposed
525                }
526            }
527        });
528
529        // Track text focus so the C# side can suppress external Value reconciles while the user
530        // owns the buffer. Without this, a data-bound echo (a live node-stream emission behind the
531        // Markdown editor, or a lagging MessageText round-trip behind the chat composer) reconciles
532        // via SetValue mid-edit and WIPES the in-progress keystrokes / resets the cursor — the
533        // "typing / backspace / arrows do nothing" report. onDidFocus/BlurEditorText fire for the
534        // text input specifically (not the surrounding widget), which is exactly what "the user is
535        // editing" means. Best-effort invoke: a disposed dotNetRef rejects and we swallow it.
536        editorInstance.onDidFocusEditorText(() => {
537            const st = editorState.get(editorId);
538            st?.dotNetRef?.invokeMethodAsync('HandleFocusChanged', true)
539                .catch(err => console.debug('HandleFocusChanged(true) failed (editor disposed?):', err));
540        });
541        editorInstance.onDidBlurEditorText(() => {
542            const st = editorState.get(editorId);
543            st?.dotNetRef?.invokeMethodAsync('HandleFocusChanged', false)
544                .catch(err => console.debug('HandleFocusChanged(false) failed (editor disposed?):', err));
545        });
546
547        // Force layout after initialization
548        setTimeout(() => {
549            editorInstance.layout();
550        }, 100);
551    } else {
552        console.error('Editor instance not found for', editorId);
553    }
554}
555
556function updatePlaceholder(editorId, placeholder, showLineNumbers = false) {
557    const container = document.getElementById(editorId);
558    if (!container) return;
559
560    // Calculate left offset based on line numbers
561    // When line numbers are shown, we need to account for the gutter width
562    // Monaco uses ~40px for 3-char line numbers + some padding
563    const leftOffset = showLineNumbers ? 35 : 10;
564
565    // Create or update placeholder element
566    let placeholderEl = container.querySelector('.monaco-placeholder');
567    if (!placeholderEl) {
568        placeholderEl = document.createElement('div');
569        placeholderEl.className = 'monaco-placeholder';
570        placeholderEl.style.cssText = `
571            position: absolute;
572            top: 8px;
573            left: ${leftOffset}px;
574            color: var(--neutral-foreground-hint, #605e5c);
575            pointer-events: none;
576            font-size: 14px;
577            font-family: var(--body-font, "Segoe UI", -apple-system, BlinkMacSystemFont, Roboto, "Helvetica Neue", sans-serif);
578            z-index: 1;
579        `;
580        container.style.position = 'relative';
581        container.appendChild(placeholderEl);
582    } else {
583        // Update left position if element already exists
584        placeholderEl.style.left = `${leftOffset}px`;
585    }
586    placeholderEl.textContent = placeholder;
587
588    // Check initial visibility
589    const state = editorState.get(editorId);
590    if (state?.editorInstance) {
591        const value = state.editorInstance.getValue();
592        updatePlaceholderVisibility(editorId, !value);
593    }
594}
595
596function updatePlaceholderVisibility(editorId, show) {
597    const container = document.getElementById(editorId);
598    if (!container) return;
599
600    const placeholderEl = container.querySelector('.monaco-placeholder');
601    if (placeholderEl) {
602        placeholderEl.style.display = show ? 'block' : 'none';
603    }
604}
605
606export function registerCompletionProvider(editorId, config) {
607    const state = editorState.get(editorId);
608    if (!state) {
609        // Editor may have been disposed before completion provider was registered
610        console.debug('Monaco editor state not available:', editorId);
611        return;
612    }
613
614    // Parse config
615    const triggerCharacters = config?.triggerCharacters || [];
616    const useAsync = config?.useAsync || false;
617    const language = config?.language || 'plaintext';
618    let items = [];
619    if (Array.isArray(config?.items)) {
620        items = config.items;
621    } else if (config?.items && typeof config.items === 'object') {
622        items = Object.values(config.items);
623    }
624
625    state.completionConfig = { triggerCharacters, items, useAsync, language };
626    state.isCompletionPending = false;
627
628    // Dispose previous provider if exists
629    if (state.completionDisposable) {
630        state.completionDisposable.dispose();
631        state.completionDisposable = null;
632    }
633
634    // Only register if we have items or async mode, and trigger characters
635    if (!useAsync && items.length === 0) {
636        return;
637    }
638    if (triggerCharacters.length === 0) {
639        return;
640    }
641
642    // Register a command that fires when a completion item is accepted.
643    // keybinding=0 means no keyboard shortcut — invoked only via CompletionItem.command.
644    if (!state.completionCommandId && state.editorInstance) {
645        state.completionCommandId = state.editorInstance.addCommand(0, (_, path) => {
646            const currentState = editorState.get(editorId);
647            if (currentState?.dotNetRef && path) {
648                currentState.dotNetRef.invokeMethodAsync('HandleCompletionAccepted', path);
649            }
650            // Re-trigger suggest if accepted item was a directory/collection (ends with / or :)
651            if (currentState?.editorInstance) {
652                setTimeout(() => {
653                    const editor = currentState.editorInstance;
654                    const model = editor.getModel();
655                    const pos = editor.getPosition();
656                    if (pos && model) {
657                        const lineContent = model.getLineContent(pos.lineNumber);
658                        const charBefore = lineContent.charAt(pos.column - 2); // 1-based
659                        if (charBefore === '/' || charBefore === ':') {
660                            editor.trigger('keyboard', 'editor.action.triggerSuggest', {});
661                        }
662                    }
663                }, 100);
664            }
665        });
666    }
667
668    // Build trigger character set for regex (not used directly anymore, but kept for reference)
669    const escapedTriggers = triggerCharacters.map(c => c.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('');
670
671    // Create debounced async fetch function (50ms delay)
672    const debouncedFetch = debounce(async (query) => {
673        if (!state.dotNetRef) {
674            return [];
675        }
676        try {
677            state.isCompletionPending = true;
678            return await state.dotNetRef.invokeMethodAsync('GetAsyncCompletions', query);
679        } catch (e) {
680            console.error('Error fetching async completions:', e);
681            return [];
682        } finally {
683            state.isCompletionPending = false;
684        }
685    }, 50);
686
687    // Register new completion provider for the specified language
688    // Note: Monaco registers providers globally per language, so we need to check
689    // if this request is for our specific editor instance
690    state.completionDisposable = monaco.languages.registerCompletionItemProvider(language, {
691        triggerCharacters: triggerCharacters,
692        provideCompletionItems: async (model, position) => {
693            // Check if this model belongs to our editor
694            const editorInstance = editorState.get(editorId)?.editorInstance;
695            if (!editorInstance || editorInstance.getModel() !== model) {
696                // This completion request is not for our editor, skip it
697                return null;
698            }
699
700            const currentState = editorState.get(editorId);
701            const isAsync = currentState?.completionConfig?.useAsync || false;
702
703            const textUntilPosition = model.getValueInRange({
704                startLineNumber: position.lineNumber,
705                startColumn: 1,
706                endLineNumber: position.lineNumber,
707                endColumn: position.column
708            });
709
710            let fullQuery;
711            let matchLength;
712
713            // Get the configured trigger characters
714            const configuredTriggers = currentState?.completionConfig?.triggerCharacters || ['@'];
715
716            // Check if we're after a configured trigger character
717            let triggerMatch = null;
718
719            for (const trigger of configuredTriggers) {
720                // Escape the trigger character for regex
721                const escapedTrigger = trigger.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
722
723                // @ can be followed by paths with slashes: @agent/Name, @content/path/file
724                // / is only a trigger at word boundary (for commands like /agent)
725                if (trigger === '/') {
726                    // A slash-command is only valid at the very START of the composer (line 1, only
727                    // optional whitespace before it). Triggering on a '/' mid-code — paths (`cd /etc`),
728                    // "//" comments, `a / b`, `http://` — popped the command list, which is now heavy
729                    // (every CLI command + skill) and churned Monaco → "stuck when entering code".
730                    if (position.lineNumber !== 1) continue;
731                    const regex = new RegExp(`^\\s*${escapedTrigger}([\\w\\-\\.]+)?$`);
732                    const match = textUntilPosition.match(regex);
733                    if (match) {
734                        // Adjust match to not include the leading space
735                        const fullMatch = match[0];
736                        const slashIndex = fullMatch.indexOf('/');
737                        match[0] = fullMatch.substring(slashIndex);
738                        triggerMatch = match;
739                        break;
740                    }
741                } else {
742                    // @-reference trigger. Fire whenever the cursor is INSIDE an @-token being typed:
743                    // the most recent '@' before the cursor must (a) sit on a word boundary — i.e. NOT
744                    // immediately follow an alphanumeric/underscore, which would make it an email like
745                    // "foo@bar" — and (b) be followed only by valid path characters up to the cursor
746                    // (no whitespace / break). This replaces the old positive lookbehind (?:^|\s|")
747                    // that wrongly suppressed the trigger after legitimate word boundaries that are not
748                    // whitespace — punctuation like ',', '.', ':', '!', '(' or a non-ASCII letter — so
749                    // "Check @AgenticPension," followed by "@" (or "see.@Fund", "(@Fund") now triggers.
750                    // The boundary excludes a preceding '\w' (email "foo@bar") AND a preceding '/' — the
751                    // latter keeps the old intent of NOT re-opening a new @-reference INSIDE an existing
752                    // path token (e.g. "@/path/@content", where the second '@' follows a '/').
753                    const atIndex = textUntilPosition.lastIndexOf(trigger);
754                    if (atIndex >= 0) {
755                        const afterTrigger = textUntilPosition.substring(atIndex + trigger.length);
756                        const charBefore = atIndex > 0 ? textUntilPosition[atIndex - 1] : '';
757                        const onWordBoundary = atIndex === 0 || !/[\w/]/.test(charBefore);
758                        const withinToken = /^[\w\-\.\/:]*$/.test(afterTrigger);
759                        if (onWordBoundary && withinToken) {
760                            triggerMatch = [trigger + afterTrigger, afterTrigger];
761                            break;
762                        }
763                    }
764                }
765            }
766
767            if (!triggerMatch) {
768                return { suggestions: [] };
769            }
770
771            const triggerChar = triggerMatch[0].charAt(0);
772            const afterTrigger = triggerMatch[1] || '';
773
774            // Include trigger char in query for server to determine context
775            fullQuery = triggerChar + afterTrigger;
776            matchLength = triggerMatch[0].length;
777
778            // Calculate range to replace (from trigger/prefix to current position)
779            const range = new monaco.Range(
780                position.lineNumber,
781                position.column - matchLength,
782                position.lineNumber,
783                position.column
784            );
785
786            let currentItems;
787
788            const pending = isAsync ? currentState._pendingCompletionItems : null;
789            if (pending && pending.query === fullQuery) {
790                // Progressive update: use pre-fetched items pushed via pushCompletionUpdate —
791                // but ONLY when they answer the query currently being completed. The buffer is
792                // keyed by the query the .NET subscription computed the snapshot for.
793                currentItems = pending.items;
794                currentState._pendingCompletionItems = null;
795            } else if (isAsync) {
796                // Any pending buffer here answers a DIFFERENT (stale) query: a push for an
797                // earlier trigger token landing after the user typed on, or a leftover from a
798                // previous suggest session (a push that arrived while no @-token was active is
799                // never consumed — the early `!triggerMatch` return above skips the buffer).
800                // Consuming it unkeyed served the stale items as the FIRST result for the new
801                // trigger AND skipped the fetch for the real query — issue #542 ("first @
802                // autocomplete shows wrong results; only re-triggering shows the right list").
803                // Discard it and fetch for the current query; the resulting .NET subscription
804                // re-pushes snapshots keyed to this query, which the branch above then serves.
805                currentState._pendingCompletionItems = null;
806                // Async mode: fetch from server with debounce (send full query including trigger char)
807                currentItems = await debouncedFetch(fullQuery);
808            } else {
809                // Sync mode: filter locally
810                const allItems = currentState?.completionConfig?.items || [];
811                const searchTermLower = afterTrigger.toLowerCase();
812                currentItems = allItems.filter(item =>
813                    item && item.label &&
814                    (item.label.toLowerCase().includes(searchTermLower) ||
815                    (item.description && item.description.toLowerCase().includes(searchTermLower)))
816                );
817            }
818
819            if (!Array.isArray(currentItems)) {
820                return { suggestions: [] };
821            }
822
823            const suggestions = currentItems.map((item, index) => {
824                // filterText must match what the user typed (fullQuery includes the trigger char)
825                // Use insertText as filterText since that's what matches the typed pattern
826                const filterText = item.insertText || item.label;
827
828                // Simple single-line display: Path as label, category as detail
829                // This avoids row height calculation issues with multi-line labels
830                const displayLabel = item.path || item.label;
831
832                const suggestion = {
833                    label: displayLabel,
834                    kind: typeof item.kind === 'number' ? item.kind : monaco.languages.CompletionItemKind.Text,
835                    insertText: item.insertText || item.label,
836                    range: range,
837                    detail: item.category || '',          // Category shown on the right
838                    documentation: item.description ? {   // Full description on hover
839                        value: item.description
840                    } : undefined,
841                    filterText: filterText,
842                    // 🚨 The server's ORDER is the ranking — never re-alphabetise it here. Monaco
843                    // sorts by (fuzzy score, sortText, label), so a lowercased label as sortText
844                    // discards whatever relevance the provider computed the moment the list opens
845                    // with nothing typed. An index-derived key preserves that order and still lets
846                    // Monaco's score lead once the user types. (Same fix as the C# completions'
847                    // RankKey — see MeshNodeLanguageService.)
848                    sortText: item.sortKey || String(index).padStart(6, '0')   // width matches RankKey
849                };
850
851                // Attach command to notify C# when a suggestion is accepted
852                if (currentState.completionCommandId && item.path) {
853                    suggestion.command = {
854                        id: currentState.completionCommandId,
855                        title: '',
856                        arguments: [item.path]
857                    };
858                }
859
860                return suggestion;
861            });
862
863            // Set incomplete: true for async mode to allow re-fetching as user types
864            return { suggestions, incomplete: isAsync };
865        }
866    });
867}
868
869
870// ————————————————————————————————————————————————————————————— 
870code completions
871// LSP CompletionKind (wire values, see IMeshLanguageService.CompletionKind) → Monaco
872// languages.CompletionItemKind. Distinct enums with different numbering — mapped here at the
873// boundary so the .NET side stays LSP-pure.
874const LSP_TO_MONACO_KIND = {
875    1: 18,  // Text
876    2: 0,   // Method
877    3: 1,   // Function
878    4: 2,   // Constructor
879    5: 3,   // Field
880    6: 4,   // Variable
881    7: 5,   // Class
882    8: 7,   // Interface
883    9: 8,   // Module
884    10: 9,  // Property
885    11: 12, // Unit
886    12: 13, // Value
887    13: 15, // Enum
888    14: 17, // Keyword
889    15: 27, // Snippet
890    16: 19, // Color
891    17: 20, // File
892    18: 21, // Reference
893    19: 23, // Folder
894    20: 16, // EnumMember
895    21: 14, // Constant
896    22: 6,  // Struct
897    23: 10, // Event
898    24: 11, // Operator
899    25: 24, // TypeParameter
900};
901
902/**
903 * Registers the POSITION-BASED code-completion provider (Roslyn over the mesh language
904 * service) — distinct from registerCompletionProvider, whose trigger-token model matches a
905 * typed @-reference query and cannot express "complete C# at line/column". Each suggest
906 * request round-trips the full buffer + caret to .NET (GetCodeCompletions), which answers
907 * with LSP-shaped entries; Monaco filters and ranks client-side against the current word.
908 */
909export function registerCodeCompletionProvider(editorId, config) {
910    const state = editorState.get(editorId);
911    if (!state) {
912        console.debug('Monaco editor state not available:', editorId);
913        return;
914    }
915    if (state.codeCompletionDisposable) {
916        state.codeCompletionDisposable.dispose();
917        state.codeCompletionDisposable = null;
918    }
919    const language = config?.language || 'csharp';
920    // Command invoked by Monaco AFTER an item is inserted — the acceptance signal the per-user
921    // suggest memory is built from (same mechanism the @-reference provider uses).
922    if (!state.codeCompletionCommandId && state.editorInstance) {
923        state.codeCompletionCommandId = state.editorInstance.addCommand(0, (_, label, kind, typed) => {
924            const currentState = editorState.get(editorId);
925            if (currentState?.dotNetRef && label) {
926                currentState.dotNetRef
927                    .invokeMethodAsync('HandleCodeCompletionAccepted', label, kind ?? 0, typed ?? '')
928                    .catch(err => console.debug('HandleCodeCompletionAccepted failed (editor disposed?):', err));
929            }
930        });
931    }
932    state.codeCompletionDisposable = monaco.languages.registerCompletionItemProvider(language, {
933        triggerCharacters: ['.'],
934        provideCompletionItems: async (model, position) => {
935            // Providers register globally per language — serve only our own editor's model.
936            const current = editorState.get(editorId);
937            const instance = current?.editorInstance;
938            if (!instance || instance.getModel() !== model || !current.dotNetRef) return null;
939            let items;
940            try {
941                items = await current.dotNetRef.invokeMethodAsync(
942                    'GetCodeCompletions', model.getValue(), position.lineNumber, position.column);
943            } catch (err) {
944                console.debug('GetCodeCompletions failed (editor disposed?):', err);
945                return null;
946            }
947            if (!items || items.length === 0) return { suggestions: [] };
948            const word = model.getWordUntilPosition(position);
949            const range = {
950                startLineNumber: position.lineNumber,
951                endLineNumber: position.lineNumber,
952                startColumn: word.startColumn,
953                endColumn: word.endColumn,
954            };
955            // The word being completed — recorded with the acceptance so the memory can key on
956            // "what I had typed", exactly like VS Code's recentlyUsedByPrefix.
957            const typed = word.word || '';
958            return {
959                suggestions: items.map(i => ({
960                    label: i.label,
961                    insertText: i.insertText || i.label,
962                    kind: LSP_TO_MONACO_KIND[i.kind] ?? 18,
963                    detail: i.detail || undefined,
964                    documentation: i.documentation || undefined,
965                    sortText: i.sortText || undefined,
966                    // The one item this user accepted last time in this situation. Monaco
967                    // highlights it without disturbing the order we returned.
968                    preselect: i.preselect === true,
969                    range,
970                    command: current.codeCompletionCommandId
971                        ? { id: current.codeCompletionCommandId, title: '', arguments: [i.label, i.kind, typed] }
972                        : undefined,
973                })),
974            };
975        },
976    });
977}
978
979export function isAutocompleteVisible(editorId) {
980    // Check if async completion is pending
981    const state = editorState.get(editorId);
982    if (state?.isCompletionPending) {
983        return true;
984    }
985
986    // Check DOM for visible suggest widgets (works with FixedOverflowWidgets)
987    const suggestWidgets = document.querySelectorAll('.monaco-editor .suggest-widget, .overflowingContentWidgets .suggest-widget');
988    for (const widget of suggestWidgets) {
989        const style = window.getComputedStyle(widget);
990        if (style.display !== 'none' && style.visibility !== 'hidden' && widget.offsetParent !== null) {
991            return true;
992        }
993    }
994
995    // Fallback: check editor contribution state
996    const editorInstance = editorState.get(editorId)?.editorInstance;
997    if (editorInstance) {
998        try {
999            const contribution = editorInstance.getContribution('editor.contrib.suggestController');
1000            if (contribution && contribution.widget && contribution.widget.value) {
1001                const widgetState = contribution.widget.value.state;
1002                // States: 0=Hidden, 1=Loading, 2=Empty, 3=Open, 4=Frozen, 5=Details
1003                if (widgetState >= 1 && widgetState <= 5) {
1004                    return true;
1005                }
1006            }
1007        } catch (e) {
1008            // Ignore errors
1009        }
1010    }
1011
1012    return false;
1013}
1014
1015// Trigger the suggestion/autocomplete popup programmatically
1016export function triggerSuggest(editorId) {
1017    const editorInstance = editorState.get(editorId)?.editorInstance;
1018    if (editorInstance) {
1019        // Trigger the suggest action
1020        editorInstance.trigger('keyboard', 'editor.action.triggerSuggest', {});
1021        return true;
1022    }
1023    return false;
1024}
1025
1026// Push updated completion items into the editor's pending state and re-trigger suggestions.
1027// Used for progressive streaming: fast local results arrive first, remote results merge in later.
1028// `query` is the query these items answer — the completion provider consumes the buffer ONLY
1029// when it still matches the trigger token being completed, and discards-and-fetches otherwise
1030// (issue #542: an unkeyed buffer served a previous query's items as the first result while
1031// suppressing the fetch for the current one).
1032export function pushCompletionUpdate(editorId, query, items) {
1033    const state = editorState.get(editorId);
1034    if (state) {
1035        // Store updated items keyed by query — consumed by the provider on the re-trigger below
1036        state._pendingCompletionItems = { query, items };
1037        const editorInstance = state.editorInstance;
1038        if (editorInstance) {
1039            editorInstance.trigger('', 'editor.action.triggerSuggest', {});
1040        }
1041    }
1042}
1043
1044// =============================================================================
1045// LSP-style live diagnostics (Stage-3)
1046// =============================================================================
1047// Subscribes to debounced onDidChangeModelContent and calls back to .NET with the
1048// current model text; the .NET side (RequestDiagnostics) invokes the consumer's
1049// IObservable<DiagnosticInfo[]>, then pushes results here via pushDiagnostics.
1050
1051const DIAGNOSTICS_DEBOUNCE_MS = 300;
1052const DIAGNOSTICS_MARKER_OWNER = 'meshweaver-lsp';
1053
1054export function enableDiagnostics(editorId, dotNetRef) {
1055    const state = editorState.get(editorId);
1056    if (!state || !state.editorInstance) return;
1057    if (state._diagnosticsEnabled) return; // idempotent
1058    state._diagnosticsEnabled = true;
1059    state._diagnosticsDotNetRef = dotNetRef;
1060
1061    const requestNow = () => {
1062        const model = state.editorInstance?.getModel();
1063        if (!model) return;
1064        dotNetRef.invokeMethodAsync('RequestDiagnostics', model.getValue()).catch(err => {
1065            console.warn('LSP RequestDiagnostics failed:', err);
1066        });
1067    };
1068
1069    const scheduleRequest = () => {
1070        if (state._diagnosticsTimer) clearTimeout(state._diagnosticsTimer);
1071        state._diagnosticsTimer = setTimeout(requestNow, DIAGNOSTICS_DEBOUNCE_MS);
1072    };
1073
1074    // Initial fetch — current saved text gets a diagnostics pass before the user types.
1075    requestNow();
1076
1077    // Subsequent edits — debounced so we don't fire on every keystroke.
1078    state._diagnosticsChangeDisposable = state.editorInstance.onDidChangeModelContent(() => {
1079        scheduleRequest();
1080    });
1081}
1082
1083// Maps an LSP DiagnosticSeverity (0..3) to a Monaco MarkerSeverity (Hint=1, Info=2, Warning=4, Error=8).
1084function lspSeverityToMonaco(lspSeverity) {
1085    switch (lspSeverity) {
1086        case 3: return 8;  // Error
1087        case 2: return 4;  // Warning
1088        case 1: return 2;  // Info
1089        case 0: return 1;  // Hidden / Hint
1090        default: return 2;
1091    }
1092}
1093
1094// Push a fresh diagnostics snapshot from .NET into Monaco's marker layer for this editor.
1095// Items shape (matches RequestDiagnostics's anonymous payload):
1096//   { severity, message, id, startLine, startCharacter, endLine, endCharacter }  (LSP 0-based)
1097export function pushDiagnostics(editorId, items) {
1098    const state = editorState.get(editorId);
1099    if (!state) return;
1100    const editorInstance = state.editorInstance;
1101    if (!editorInstance) return;
1102    const model = editorInstance.getModel();
1103    if (!model) return;
1104
1105    const markers = (items || []).map(d => ({
1106        // Monaco is 1-based; LSP is 0-based — add 1 to each coordinate.
1107        startLineNumber: (d.startLine | 0) + 1,
1108        startColumn: (d.startCharacter | 0) + 1,
1109        endLineNumber: (d.endLine | 0) + 1,
1110        endColumn: (d.endCharacter | 0) + 1,
1111        message: d.message || '',
1112        code: d.id || '',
1113        severity: lspSeverityToMonaco(d.severity | 0),
1114        source: 'Roslyn',
1115    }));
1116    monaco.editor.setModelMarkers(model, DIAGNOSTICS_MARKER_OWNER, markers);
1117}
1118
1119// Authoritatively give the editor keyboard focus. `editorInstance.focus()` alone is NOT enough on
1120// Safari: Monaco keeps drawing its caret from an INTERNAL focus tracker, and when the real
1121// <textarea.inputarea> silently loses DOM focus during a Blazor re-render (the message-stream storm),
1122// that tracker can stay "focused" — so a later focus() short-circuits and never re-focuses the DOM
1123// textarea. The result is the reported "blinking caret, but typing does nothing until I reload":
1124// keystrokes go to whatever element actually holds DOM focus, not the editor. Forcing the real
1125// inputarea to take focus restores keyboard input without a reload. Idempotent when already focused.
1126export function focusEditor(editorId) {
1127    const editorInstance = editorState.get(editorId)?.editorInstance;
1128    if (!editorInstance) return false;
1129    try {
1130        editorInstance.focus();
1131        const ta = editorInstance.getDomNode()?.querySelector('textarea.inputarea');
1132        if (ta && document.activeElement !== ta) ta.focus();
1133        return true;
1134    } catch {
1135        // editor mid-teardown — harmless
1136        return false;
1137    }
1138}
1139
1140// Reconcile an external / data-bound value into the editor WITHOUT ever clobbering an in-progress
1141// edit. The focus check is done HERE, atomically with setValue, so it is the GROUND TRUTH at the
1142// moment of the write — unlike the C# `editorHasFocus` flag, which is updated through async JS→.NET
1143// focus events that can lag or be dropped under a re-render storm (Safari). A stale-false flag on the
1144// C# side would let SetValue wipe the user's live keystrokes and reset the cursor; re-checking
1145// hasTextFocus() right before the write closes that race. Returns true when the editor is reconciled
1146// (it now holds `value` — whether written just now or already equal), false only when it REFUSED
1147// because the editor is focused (or isn't ready) — so the caller advances lastSetValue only on true.
1148export function reconcileValue(editorId, value) {
1149    const editorInstance = editorState.get(editorId)?.editorInstance;
1150    if (!editorInstance) return false;                    // not ready — retry on a later render
1151    if (editorInstance.hasTextFocus()) return false;      // user owns the buffer — never clobber
1152    const next = value ?? '';
1153    if (editorInstance.getValue() !== next) editorInstance.setValue(next);
1154    // Unfocused → the editor now holds `next` (set just now, or already equal). Report reconciled so
1155    // the caller advances lastSetValue; false is reserved for "refused because focused", so a stale
1156    // lastSetValue only persists while the user owns the buffer and clears on the next unfocused render.
1157    return true;
1158}
1159
1160// Set cursor position to end of content
1161export function setCursorToEnd(editorId) {
1162    const editorInstance = editorState.get(editorId)?.editorInstance;
1163    if (editorInstance) {
1164        const model = editorInstance.getModel();
1165        if (model) {
1166            const lastLine = model.getLineCount();
1167            const lastColumn = model.getLineMaxColumn(lastLine);
1168            editorInstance.setPosition({ lineNumber: lastLine, column: lastColumn });
1169            return true;
1170        }
1171    }
1172    return false;
1173}
1174
1175// =============================================================================
1176// Annotation Decorations for Track Changes
1177// =============================================================================
1178
1179// Generate a short unique marker ID
1180function generateMarkerId() {
1181    return Date.now().toString(36) + Math.random().toString(36).substring(2, 6);
1182}
1183
1184/**
1185 * Updates Monaco editor decorations to visually highlight annotation ranges.
1186 * Accepts pre-computed ranges from C# (offsets in clean content).
1187 * Each range: { type: 'insert'|'delete'|'comment', start: number, end: number }
1188 */
1189export function updateAnnotationDecorations(editorId, ranges) {
1190    const state = editorState.get(editorId);
1191    if (!state?.editorInstance) return;
1192
1193    // If no ranges provided, keep existing decorations (no-op)
1194    if (!Array.isArray(ranges)) return;
1195
1196    const editorInstance = state.editorInstance;
1197    const model = editorInstance.getModel();
1198    if (!model) return;
1199
1200    const decorations = [];
1201
1202    for (const range of ranges) {
1203            const startPos = model.getPositionAt(range.start);
1204            const endPos = model.getPositionAt(range.end);
1205
1206            let className;
1207            switch (range.type) {
1208                case 'insert':
1209                    className = 'monaco-insert-decoration';
1210                    break;
1211                case 'delete':
1212                    className = 'monaco-delete-decoration';
1213                    break;
1214                case 'comment':
1215                    className = 'monaco-comment-decoration';
1216                    break;
1217                default:
1218                    continue;
1219            }
1220
1221            decorations.push({
1222                range: new monaco.Range(startPos.lineNumber, startPos.column, endPos.lineNumber, endPos.column),
1223                options: {
1224                    inlineClassName: className,
1225                    stickiness: monaco.editor.TrackedRangeStickiness.NeverGrowsWhenTypingAtEdges
1226                }
1227            });
1228    }
1229
1230    // Store decoration IDs on state for future delta updates
1231    state.annotationDecorationIds = editorInstance.deltaDecorations(
1232        state.annotationDecorationIds || [],
1233        decorations
1234    );
1235}
1236
1237/**
1238 * Enables or disables track changes mode for the editor.
1239 * When enabled, new edits are wrapped in <!--insert:id--> markers
1240 * and deletions of non-marker text are wrapped in <!--delete:id--> markers.
1241 */
1242/**
1243 * Enables or disables track changes mode.
1244 * @param {string} editorId - The editor instance ID
1245 * @param {boolean} enabled - Whether to enable track changes
1246 * @param {string} [author] - Author name to embed in markers
1247 */
1248export function setTrackChangesMode(editorId, enabled, author) {
1249    const state = editorState.get(editorId);
1250    if (!state?.editorInstance) return;
1251    const editorInstance = state.editorInstance;
1252
1253    state.trackChangesEnabled = enabled;
1254    state.trackChangesAuthor = author || '';
1255
1256    if (enabled && !state.trackChangesDisposable) {
1257        state.trackChangesProcessing = false;
1258
1259        // Listen to content changes and wrap them in annotation markers
1260        state.trackChangesDisposable = editorInstance.onDidChangeModelContent((e) => {
1261            if (state.trackChangesProcessing || !state.trackChangesEnabled) return;
1262
1263            const model = editorInstance.getModel();
1264            if (!model) return;
1265
1266            const authorStr = state.trackChangesAuthor;
1267            const dateStr = formatShortDate();
1268            const metaSuffix = authorStr ? `:${authorStr}:${dateStr}` : '';
1269
1270            // Process each change in the event
1271            const edits = [];
1272            let needsUpdate = false;
1273
1274            // Process changes in reverse order to maintain position accuracy
1275            const changes = [...e.changes].sort((a, b) => b.rangeOffset - a.rangeOffset);
1276
1277            for (const change of changes) {
1278                const isInsertion = change.text.length > 0 && change.rangeLength === 0;
1279                const isDeletion = change.text.length === 0 && change.rangeLength > 0;
1280                const isReplacement = change.text.length > 0 && change.rangeLength > 0;
1281
1282                // Check if we're editing inside an existing annotation marker tag
1283                const content = model.getValue();
1284                const beforeChange = content.substring(Math.max(0, change.rangeOffset - 200), change.rangeOffset);
1285                const isInsideTag = /<!--(?:insert|delete|comment):[^>]*$/.test(beforeChange) ||
1286                                     /<!--\/(?:insert|delete|comment):[^>]*$/.test(beforeChange);
1287                if (isInsideTag) continue;
1288
1289                const markerId = generateMarkerId();
1290
1291                if (isInsertion) {
1292                    const insertedText = change.text;
1293                    if (insertedText.trim().length === 0) continue;
1294
1295                    const pos = model.getPositionAt(change.rangeOffset);
1296                    const endPos = model.getPositionAt(change.rangeOffset + insertedText.length);
1297                    edits.push({
1298                        range: new monaco.Range(pos.lineNumber, pos.column, endPos.lineNumber, endPos.column),
1299                        text: `<!--insert:${markerId}${metaSuffix}-->${insertedText}<!--/insert:${markerId}-->`
1300                    });
1301                    needsUpdate = true;
1302                } else if (isDeletion) {
1303                    const deletedText = content.substring(change.rangeOffset, change.rangeOffset + change.rangeLength);
1304                    if (/^<!--\/?(?:insert|delete|comment):/.test(deletedText)) continue;
1305                    if (deletedText.trim().length === 0) continue;
1306
1307                    const pos = model.getPositionAt(change.rangeOffset);
1308                    edits.push({
1309                        range: new monaco.Range(pos.lineNumber, pos.column, pos.lineNumber, pos.column),
1310                        text: `<!--delete:${markerId}${metaSuffix}-->${deletedText}<!--/delete:${markerId}-->`
1311                    });
1312                    needsUpdate = true;
1313                } else if (isReplacement) {
1314                    const deletedText = content.substring(change.rangeOffset, change.rangeOffset + change.rangeLength);
1315                    if (/^<!--\/?(?:insert|delete|comment):/.test(deletedText)) continue;
1316
1317                    const delMarkerId = generateMarkerId();
1318                    const insMarkerId = generateMarkerId();
1319                    const pos = model.getPositionAt(change.rangeOffset);
1320                    const endPos = model.getPositionAt(change.rangeOffset + change.text.length);
1321                    edits.push({
1322                        range: new monaco.Range(pos.lineNumber, pos.column, endPos.lineNumber, endPos.column),
1323                        text: `<!--delete:${delMarkerId}${metaSuffix}-->${deletedText}<!--/delete:${delMarkerId}--><!--insert:${insMarkerId}${metaSuffix}-->${change.text}<!--/insert:${insMarkerId}-->`
1324                    });
1325                    needsUpdate = true;
1326                }
1327            }
1328
1329            if (needsUpdate && edits.length > 0) {
1330                state.trackChangesProcessing = true;
1331                try {
1332                    editorInstance.executeEdits('track-changes', edits);
1333                } finally {
1334                    state.trackChangesProcessing = false;
1335                }
1336            }
1337        });
1338    } else if (!enabled && state.trackChangesDisposable) {
1339        state.trackChangesDisposable.dispose();
1340        state.trackChangesDisposable = null;
1341    }
1342}
1343
1344/**
1345 * Registers a "Add Comment" action in the Monaco editor context menu.
1346 * When triggered, calls the Blazor callback with the selection offset range.
1347 * @param {string} editorId - The editor instance ID
1348 * @param {object} dotNetRef - DotNet object reference for callback
1349 * @param {string} callbackMethod - Name of the [JSInvokable] method to call
1350 */
1351export function registerCommentAction(editorId, dotNetRef, callbackMethod) {
1352    const state = editorState.get(editorId);
1353    if (!state?.editorInstance) return;
1354    const editorInstance = state.editorInstance;
1355
1356    // Enable context menu
1357    editorInstance.updateOptions({ contextmenu: true });
1358
1359    state.commentActionDisposable = editorInstance.addAction({
1360        id: 'add-comment',
1361        label: 'Add Comment',
1362        contextMenuGroupId: '9_cutcopypaste',
1363        contextMenuOrder: 100,
1364        precondition: 'editorHasSelection',
1365        run: (ed) => {
1366            const selection = ed.getSelection();
1367            if (!selection || selection.isEmpty()) return;
1368            const model = ed.getModel();
1369            if (!model) return;
1370            const startOffset = model.getOffsetAt(selection.getStartPosition());
1371            const endOffset = model.getOffsetAt(selection.getEndPosition());
1372            dotNetRef.invokeMethodAsync(callbackMethod, startOffset, endOffset);
1373        }
1374    });
1375}
1376
1377/**
1378 * Sets the editor value while suppressing the track changes handler.
1379 * Use this for programmatic content updates (e.g. accept/reject annotations)
1380 * to prevent the track changes handler from re-wrapping the change in markers.
1381 */
1382export function setValueSuppressTracking(editorId, value) {
1383    const state = editorState.get(editorId);
1384    if (!state?.editorInstance) return;
1385
1386    state.trackChangesProcessing = true;
1387    try {
1388        state.editorInstance.setValue(value);
1389    } finally {
1390        // Use setTimeout to ensure the synchronous onDidChangeModelContent handler
1391        // has already fired before we re-enable tracking
1392        setTimeout(() => {
1393            if (editorState.has(editorId)) {
1394                editorState.get(editorId).trackChangesProcessing = false;
1395            }
1396        }, 0);
1397    }
1398}
1399
1400function formatShortDate() {
1401    const months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
1402    const d = new Date();
1403    return `${months[d.getMonth()]} ${d.getDate()}`;
1404}
1405
1406/**
1407 * Navigates the editor cursor to a specific annotation marker by ID.
1408 * Returns the line number if found, or 0 if not found.
1409 */
1410export function navigateToAnnotation(editorId, markerId) {
1411    const state = editorState.get(editorId);
1412    if (!state?.editorInstance) return 0;
1413    const editorInstance = state.editorInstance;
1414
1415    const model = editorInstance.getModel();
1416    if (!model) return 0;
1417
1418    const content = model.getValue();
1419    const markerRegex = new RegExp(`<!--(?:insert|delete|comment):${markerId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}[^>]*-->`, 'g');
1420    const match = markerRegex.exec(content);
1421    if (!match) return 0;
1422
1423    const pos = model.getPositionAt(match.index);
1424    editorInstance.setPosition(pos);
1425    editorInstance.revealLineInCenter(pos.lineNumber);
1426    editorInstance.focus();
1427    return pos.lineNumber;
1428}
1429
1430export function dispose(editorId) {
1431    const state = editorState.get(editorId);
1432    if (state) {
1433        if (state.completionDisposable) {
1434            state.completionDisposable.dispose();
1435        }
1436        if (state.codeCompletionDisposable) {
1437            state.codeCompletionDisposable.dispose();
1438        }
1439        if (state._diagnosticsChangeDisposable) {
1440            state._diagnosticsChangeDisposable.dispose();
1441        }
1442        if (state._diagnosticsTimer) {
1443            clearTimeout(state._diagnosticsTimer);
1444        }
1445        const editorInstance = state.editorInstance;
1446        if (editorInstance) {
1447            const model = editorInstance.getModel();
1448            if (model) {
1449                monaco.editor.setModelMarkers(model, DIAGNOSTICS_MARKER_OWNER, []);
1450            }
1451        }
1452        editorState.delete(editorId);
1453    }
1454}

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.