Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 39 additions & 2 deletions src/MeshWeaver.Blazor/Components/Monaco/MonacoEditorView.razor
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,22 @@
private int lastCompletionItemCount = 0;
private string? lastSetValue; // Track last value set to editor to detect external changes

// 🚨 External-echo clobber guard. A data-bound Value can arrive on a re-render WHILE the user is
// mid-edit — a live node-stream echo behind MarkdownEditorView, or a lagging MessageText round-trip
// behind the chat composer. Reconciling it into the editor via SetValue then WIPES the in-progress
// keystrokes and resets the cursor: the "typing / backspace / arrows do nothing" report. Two
// complementary suppressors (the single userIsTyping flag was removed in 9084b7ad5 and this
// regressed):
// • userIsTyping — set on every content change; suppresses the ONE reconcile that the keystroke
// itself triggers (synchronous echo on the same render), then re-arms.
// • editorHasFocus — the user owns the buffer whenever the editor is focused, so suppress EVERY
// external reconcile then. Covers ASYNC stream echoes that land on their own
// render cycle (the Markdown case). Legitimate programmatic pushes never use
// this reconcile path — they go through SetValueAsync / SetValueSuppressTracking
// (which set lastSetValue) — so suppressing while focused blocks nothing real.
private bool userIsTyping;
private bool editorHasFocus;

[CascadingParameter(Name = "ThemeMode")]
public DesignThemeModes Mode { get; set; }

Expand Down Expand Up @@ -343,6 +359,11 @@
public async Task HandleContentChanged(string? value)
{
value ??= string.Empty;
// The user just edited: mark active typing so the reconcile in OnAfterRenderAsync
// (triggered by the ValueChanged round-trip below) can't overwrite this keystroke
// with a lagging data-bound echo. Set synchronously here — this JS invoke carries
// the edit, so it beats the async focus event on the very first keystroke.
userIsTyping = true;
if (value != Value)
{
Value = value;
Expand All @@ -363,6 +384,15 @@
await OnBlur.InvokeAsync();
}

/// <summary>
/// Tracks Monaco text focus (from the JS module's onDidFocusEditorText / onDidBlurEditorText
/// listeners). While the editor is focused the user owns the buffer, so
/// <see cref="OnAfterRenderAsync"/> must not reconcile an external/echoed <see cref="Value"/>
/// into it — that would wipe the in-progress edit and reset the cursor.
/// </summary>
[JSInvokable]
public void HandleFocusChanged(bool focused) => editorHasFocus = focused;

/// <summary>
/// Fires when the user accepts a completion suggestion.
/// The parameter is the path of the accepted item.
Expand Down Expand Up @@ -790,16 +820,23 @@
Theme = themeChanged ? GetMonacoTheme() : null
});

// Check if Value changed from an external source (e.g., data binding).
// Reconcile a genuine EXTERNAL Value change into the editor — but NEVER while the user
// owns the buffer (actively typing, or simply focused). Reconciling mid-edit wiped the
// in-progress keystrokes and reset the cursor (the "typing / backspace / arrows do
// nothing" report). Legitimate programmatic pushes use SetValueAsync, not this path.
// See the userIsTyping / editorHasFocus fields.
var currentValue = Value ?? "";
if (currentValue != lastSetValue)
if (currentValue != lastSetValue && !userIsTyping && !editorHasFocus)
{
Logger.LogDebug("Value changed externally from '{OldValue}' to '{NewValue}'",
lastSetValue?.Substring(0, Math.Min(50, lastSetValue?.Length ?? 0)),
currentValue.Substring(0, Math.Min(50, currentValue.Length)));
lastSetValue = currentValue;
await editor.SetValue(currentValue);
}
// Re-arm: the keystroke-triggered render has passed, so a later genuine external change
// can reconcile again (while unfocused). editorHasFocus stays authoritative until blur.
userIsTyping = false;

// Try to register completion provider (will skip if already registered with same items)
await TryRegisterCompletionProvider();
Expand Down
18 changes: 18 additions & 0 deletions src/MeshWeaver.Blazor/Components/Monaco/MonacoEditorView.razor.js
Original file line number Diff line number Diff line change
Expand Up @@ -440,6 +440,24 @@ export function initEditor(editorId, placeholder, dotNetRef, codeEditMode = fals
}
});

// Track text focus so the C# side can suppress external Value reconciles while the user
// owns the buffer. Without this, a data-bound echo (a live node-stream emission behind the
// Markdown editor, or a lagging MessageText round-trip behind the chat composer) reconciles
// via SetValue mid-edit and WIPES the in-progress keystrokes / resets the cursor — the
// "typing / backspace / arrows do nothing" report. onDidFocus/BlurEditorText fire for the
// text input specifically (not the surrounding widget), which is exactly what "the user is
// editing" means. Best-effort invoke: a disposed dotNetRef rejects and we swallow it.
editorInstance.onDidFocusEditorText(() => {
const st = editorState.get(editorId);
st?.dotNetRef?.invokeMethodAsync('HandleFocusChanged', true)
.catch(err => console.debug('HandleFocusChanged(true) failed (editor disposed?):', err));
});
editorInstance.onDidBlurEditorText(() => {
const st = editorState.get(editorId);
st?.dotNetRef?.invokeMethodAsync('HandleFocusChanged', false)
.catch(err => console.debug('HandleFocusChanged(false) failed (editor disposed?):', err));
});

// Force layout after initialization
setTimeout(() => {
editorInstance.layout();
Expand Down
Loading