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.