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
@@ -11,6 +11,7 @@
@using System.Text
@using ZB.MOM.WW.OtOpcUa.Configuration
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
@using ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
@inject NavigationManager Nav
@@ -30,7 +31,9 @@ else if (!IsNew && _existing is null)
}
else
{
<EditForm Model="_form" OnValidSubmit="SubmitAsync" FormName="scriptEdit">
@* OnSubmit (not OnValidSubmit): the editor text is pulled from Monaco FIRST, then validated — the bound
SourceCode is only refreshed at blur, so validating before the pull could judge stale text. *@
<EditForm Model="_form" OnSubmit="SubmitAsync" FormName="scriptEdit">
<DataAnnotationsValidator />
<ValidationSummary class="alert alert-danger py-2 px-3" />
<section class="panel rise">
@@ -57,7 +60,9 @@ else
<section class="panel rise mt-3">
<div class="panel-head">Source</div>
<div style="padding:1rem">
<MonacoEditor @bind-Value="_form.SourceCode" Height="420px" />
<MonacoEditor @ref="_editor" @bind-Value="_form.SourceCode" Height="420px"
MarkersChanged="OnMarkersChanged" />
<ScriptProblemsList Markers="_markers" OnSelect="RevealProblemAsync" />
<div class="form-text">SHA-256 hash is computed automatically on save.</div>
</div>
</section>
@@ -84,6 +89,13 @@ else
private bool _loaded;
private bool _busy;
private string? _error;
private MonacoEditor? _editor;
private IReadOnlyList<DiagnosticMarker> _markers = Array.Empty<DiagnosticMarker>();
private void OnMarkersChanged(IReadOnlyList<DiagnosticMarker> markers) => _markers = markers;
private Task RevealProblemAsync(DiagnosticMarker m)
=> _editor is null ? Task.CompletedTask : _editor.RevealLineAsync(m.StartLineNumber, m.StartColumn);
protected override async Task OnInitializedAsync()
{
@@ -105,11 +117,21 @@ else
_loaded = true;
}
private async Task SubmitAsync()
private async Task SubmitAsync(EditContext editContext)
{
_busy = true; _error = null;
try
{
// The editor reports its text only at blur, so read the authoritative current value now. A read
// failure (e.g. the script is too large to send) aborts the save rather than persisting stale source.
if (_editor is not null)
{
var read = await _editor.GetValueAsync();
if (!read.Ok) { _error = $"Not saved: {read.Error}"; return; }
_form.SourceCode = read.Value!;
}
if (!editContext.Validate()) return;
var sourceHash = HashSource(_form.SourceCode);
await using var db = await DbFactory.CreateDbContextAsync();
if (IsNew)
@@ -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)
@@ -0,0 +1,57 @@
@namespace ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared
@using ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis
@* The script editor's Problems list. Fed by MonacoEditor.MarkersChanged; clicking a row calls OnSelect, which the
host wires to MonacoEditor.RevealLineAsync so the caret jumps to the problem. Ordering and wording live in
ScriptProblems (unit-tested). *@
@{
var ordered = ScriptProblems.Order(Markers);
}
<div class="script-problems mt-2 small" role="region" aria-label="Problems">
<div class="d-flex align-items-center gap-2 text-muted mb-1">
<span class="fw-semibold">Problems</span>
<span>@ScriptProblems.Summary(ordered)</span>
</div>
@if (ordered.Count > 0)
{
<ul class="list-group list-group-flush border rounded" style="max-height: 10rem; overflow-y: auto;">
@foreach (var m in ordered.Take(MaxRows))
{
<li class="list-group-item list-group-item-action py-1 px-2 d-flex gap-2 align-items-baseline"
role="button" tabindex="0" style="cursor: pointer;"
title="Go to line @m.StartLineNumber, column @m.StartColumn"
@onclick="() => OnSelect.InvokeAsync(m)"
@onkeydown="e => OnKeyAsync(e, m)">
<span class="badge @BadgeClass(m.Severity)">@ScriptProblems.SeverityLabel(m.Severity)</span>
<span class="mono text-muted">@m.StartLineNumber:@m.StartColumn</span>
<span class="flex-grow-1">@m.Message</span>
<span class="mono text-muted">@m.Code</span>
</li>
}
</ul>
@if (ordered.Count > MaxRows)
{
<div class="text-muted mt-1">… and @(ordered.Count - MaxRows) more</div>
}
}
</div>
@code {
private const int MaxRows = 200;
/// <summary>The markers the editor last reported (null before the first diagnostics pass).</summary>
[Parameter] public IReadOnlyList<DiagnosticMarker>? Markers { get; set; }
/// <summary>Raised when the author picks a problem; wire it to <c>MonacoEditor.RevealLineAsync</c>.</summary>
[Parameter] public EventCallback<DiagnosticMarker> OnSelect { get; set; }
private Task OnKeyAsync(KeyboardEventArgs e, DiagnosticMarker m)
=> e.Key is "Enter" or " " ? OnSelect.InvokeAsync(m) : Task.CompletedTask;
private static string BadgeClass(int severity) => severity switch
{
ScriptProblems.Error => "text-bg-danger",
ScriptProblems.Warning => "text-bg-warning",
_ => "text-bg-secondary",
};
}
@@ -7,6 +7,7 @@
@using System.ComponentModel.DataAnnotations
@using Microsoft.AspNetCore.Components.Forms
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns
@using ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis
@inject IUnsTreeService Svc
@if (Visible)
@@ -99,7 +100,9 @@
Editing shared script "<span class="mono">@_scriptName</span>" — used by
@_scriptUsageCount virtual tag(s). Changes affect all of them.
</div>
<MonacoEditor @bind-Value="_scriptSource" Height="300px" EquipmentId="@EquipmentId" />
<MonacoEditor @ref="_scriptEditor" @bind-Value="_scriptSource" Height="300px" EquipmentId="@EquipmentId"
MarkersChanged="OnScriptMarkersChanged" />
<ScriptProblemsList Markers="_scriptMarkers" OnSelect="RevealScriptProblemAsync" />
@if (!string.IsNullOrWhiteSpace(_scriptError))
{
<div class="text-danger small mt-2">@_scriptError</div>
@@ -217,6 +220,24 @@
private bool _scriptSaving;
private bool _scriptSaved;
private string? _scriptError;
private MonacoEditor? _scriptEditor;
private IReadOnlyList<DiagnosticMarker> _scriptMarkers = Array.Empty<DiagnosticMarker>();
private void OnScriptMarkersChanged(IReadOnlyList<DiagnosticMarker> markers) => _scriptMarkers = markers;
private Task RevealScriptProblemAsync(DiagnosticMarker m)
=> _scriptEditor is null ? Task.CompletedTask : _scriptEditor.RevealLineAsync(m.StartLineNumber, m.StartColumn);
// The inline editor reports its text only at blur; pull the authoritative current value before acting on it.
// Returns false (with _scriptError set) when the text could not be read, so nothing stale is saved.
private async Task<bool> PullScriptSourceAsync()
{
if (_scriptEditor is null) return true;
var read = await _scriptEditor.GetValueAsync();
if (!read.Ok) { _scriptError = $"Not saved: {read.Error}"; return false; }
_scriptSource = read.Value!;
return true;
}
// "New script" action state (shown only when no script is bound yet).
private bool _scriptCreating;
@@ -358,8 +379,16 @@
/// Toggles the inline script-source panel. Clears the stale "Script saved." banner when
/// (re)expanding so a collapse/expand cycle doesn't re-show a confirmation from an earlier save.
/// </summary>
private void ToggleScriptPanel()
private async Task ToggleScriptPanel()
{
if (_scriptExpanded)
{
// Collapsing disposes the editor: keep its unsaved text in _scriptSource so re-opening shows it.
// A failed read keeps the panel open (with the error) rather than discarding the edits.
if (!await PullScriptSourceAsync()) return;
_scriptEditor = null;
_scriptMarkers = Array.Empty<DiagnosticMarker>();
}
_scriptExpanded = !_scriptExpanded;
if (_scriptExpanded)
{
@@ -384,6 +413,7 @@
_scriptError = null;
try
{
if (!await PullScriptSourceAsync()) return;
var result = await Svc.UpdateScriptSourceAsync(_form.ScriptId, _scriptSource, _scriptRowVersion);
if (result.Ok)
{