fix(adminui): Monaco text syncs on demand, diagnostics are sequenced, Problems list (D1)

R04:P1 - the editor no longer ships its full text JS->.NET on every keystroke. It reports
at blur, and ScriptEdit / the VirtualTag modal read the authoritative value with
MonacoEditor.GetValueAsync at Save (validation runs after the pull). The Blazor hub's
MaximumReceiveMessageSize is raised from 32 KB to 1 MB. The JS refuses a value that
would not fit instead of sending an oversize frame, and Save surfaces that refusal
rather than persisting stale source.

R04:P9 - diagnostics carry a generation counter and an AbortController. Every edit aborts
the in-flight request, and only the response for the current text is applied.

R04:S2 - the always-empty inlay-hints provider, endpoint, service method and contracts
are removed.

R04:S3 - MarkersChanged is typed (DiagnosticMarker) and feeds a new ScriptProblemsList.
Clicking a problem calls RevealLineAsync. Markers cross to .NET only when a list
subscribes.

Refs #745

Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
Joseph Doherty
2026-10-22 17:38:56 -04:00
parent 8e3bbf91fb
commit da2578833d
14 changed files with 514 additions and 61 deletions
@@ -1,5 +1,6 @@
@namespace ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared
@using Microsoft.Extensions.Logging
@using ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis
@implements IAsyncDisposable
@inject IJSRuntime JS
@inject Microsoft.Extensions.Logging.ILogger<MonacoEditor> Logger
@@ -22,7 +23,13 @@
style="height: @Height; border: 1px solid var(--bs-border-color); border-radius: 0.25rem; overflow: hidden;"></div>
@code {
/// <summary>
/// The editor text. Two-way bound, but the editor reports edits back only when it loses focus — never per
/// keystroke (each report is one circuit message carrying the whole script). A page that persists the text
/// must read the authoritative current value with <see cref="GetValueAsync"/> at save time.
/// </summary>
[Parameter] public string Value { get; set; } = "";
/// <summary>Raised with the editor text when the editor loses focus after an edit (and from <see cref="GetValueAsync"/>).</summary>
[Parameter] public EventCallback<string> ValueChanged { get; set; }
[Parameter] public string Language { get; set; } = "csharp";
[Parameter] public string Height { get; set; } = "320px";
@@ -37,11 +44,11 @@
[Parameter] public string? EquipmentId { get; set; }
/// <summary>
/// Fires whenever Monaco's marker set updates (after the 500 ms diagnostic
/// debounce). The marker DTO is not modelled yet — typed as object[] until a
/// later task wires the Roslyn-backed diagnostics.
/// Fires whenever Monaco's diagnostic marker set updates (after the 500 ms debounce, and only for the response
/// to the CURRENT text — superseded responses are dropped in the JS). Feeds <c>ScriptProblemsList</c>; the
/// markers are sent from JS only when this callback has a subscriber.
/// </summary>
[Parameter] public EventCallback<object[]> MarkersChanged { get; set; }
[Parameter] public EventCallback<IReadOnlyList<DiagnosticMarker>> MarkersChanged { get; set; }
private ElementReference _hostRef;
private DotNetObjectReference<MonacoEditor>? _dotNetRef;
@@ -69,7 +76,9 @@
value = Value ?? "",
language = Language,
readOnly = ReadOnly,
equipmentId = EquipmentId
equipmentId = EquipmentId,
maxSyncBytes = ScriptEditorLimits.MaxEditorSyncBytes,
reportMarkers = MarkersChanged.HasDelegate
},
_dotNetRef);
_initialized = true;
@@ -126,9 +135,47 @@
return ValueChanged.InvokeAsync(_lastSentValue);
}
/// <summary>
/// Reads the editor's current text on demand (call it at Save), raises <see cref="ValueChanged"/> when it
/// differs from the last value reported, and returns it. Before the editor is live (prerender, failed init)
/// the bound <see cref="Value"/> is authoritative and is returned as-is.
/// </summary>
/// <returns>The current text, or an error (e.g. the script is too large to send) — never a stale value.</returns>
public async Task<EditorReadResult> GetValueAsync()
{
if (!_initialized) return new EditorReadResult(Value ?? "", null);
string? current;
try
{
current = await JS.InvokeAsync<string?>("MonacoBlazor.getValue", _id);
}
catch (JSDisconnectedException)
{
return new EditorReadResult(null, "The connection to the editor was lost; reload the page and try again.");
}
catch (JSException ex)
{
Logger.LogWarning(ex, "Monaco editor {EditorId}: failed to read the editor text.", _id);
// JSException.Message carries the JS stack after the first line; the author needs only the reason.
return new EditorReadResult(null, ex.Message.Split('\n', 2)[0].Trim());
}
if (current is null) return new EditorReadResult(Value ?? "", null); // editor already disposed
await OnValueChanged(current);
return new EditorReadResult(current, null);
}
/// <summary>The outcome of <see cref="GetValueAsync"/>: the text, or why it could not be read.</summary>
/// <param name="Value">The editor text; null when <paramref name="Error"/> is set.</param>
/// <param name="Error">Why the text could not be read; null on success.</param>
public sealed record EditorReadResult(string? Value, string? Error)
{
/// <summary>True when <see cref="Value"/> holds the editor text.</summary>
public bool Ok => Error is null;
}
[JSInvokable]
public Task OnMarkersChanged(object[] markers) =>
MarkersChanged.InvokeAsync(markers ?? Array.Empty<object>());
public Task OnMarkersChanged(DiagnosticMarker[]? markers) =>
MarkersChanged.InvokeAsync(markers ?? Array.Empty<DiagnosticMarker>());
/// <summary>Programmatic scroll-to-line (called by the problems panel).</summary>
public async Task RevealLineAsync(int line, int column = 1)