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
@@ -22,7 +22,3 @@ public record SignatureHelpParameter(string Label, string? Documentation);
public record FormatRequest(string Code);
public record FormatResponse(string Code);
public record InlayHintsRequest(string Code);
public record InlayHintsResponse(IReadOnlyList<InlayHint> Hints);
public record InlayHint(int Line, int Column, string Label);
@@ -27,7 +27,6 @@ public static class ScriptAnalysisEndpoints
group.MapPost("/hover", (HoverRequest r, ScriptAnalysisService s, CancellationToken ct) => Run(() => s.Hover(r, ct), ct));
group.MapPost("/signature-help", (SignatureHelpRequest r, ScriptAnalysisService s, CancellationToken ct) => Run(() => Task.FromResult(s.SignatureHelp(r, ct)), ct));
group.MapPost("/format", (FormatRequest r, ScriptAnalysisService s) => Results.Ok(s.Format(r)));
group.MapPost("/inlay-hints", (InlayHintsRequest r, ScriptAnalysisService s) => Results.Ok(s.InlayHints(r)));
return endpoints;
}
@@ -551,9 +551,4 @@ public sealed class ScriptAnalysisService
return new FormatResponse(req.Code);
}
}
/// <summary>Returns editor inlay hints for the script. Currently a no-op (always empty) — the endpoint
/// exists for future use but the feature is not implemented.</summary>
/// <param name="req">The inlay-hints request (unused).</param>
/// <returns>An always-empty inlay-hints response.</returns>
public InlayHintsResponse InlayHints(InlayHintsRequest req) => new(Array.Empty<InlayHint>());
}
@@ -0,0 +1,19 @@
namespace ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis;
/// <summary>Size limits for moving Monaco editor text across the Blazor Server circuit.</summary>
/// <remarks>
/// The editor text crosses JS → .NET only on demand (at blur and when a page saves), never per keystroke. Each
/// crossing is ONE SignalR message, bounded by the Blazor hub's <c>MaximumReceiveMessageSize</c>. The framework
/// default is 32 KB, which silently lost any larger script (the oversize invoke failed and Save persisted the
/// stale source), so <c>AddAdminUI</c> raises it to <see cref="HubMaximumReceiveMessageSize"/>. The editor JS
/// refuses to send a value whose serialized form exceeds <see cref="MaxEditorSyncBytes"/> — an oversize frame
/// would tear the circuit down — and the save surfaces that refusal to the author instead.
/// </remarks>
public static class ScriptEditorLimits
{
/// <summary>The Blazor hub's receive limit, in bytes (1 MB).</summary>
public const long HubMaximumReceiveMessageSize = 1024 * 1024;
/// <summary>The largest serialized editor value the JS will send, leaving 64 KB of frame headroom.</summary>
public const int MaxEditorSyncBytes = (int)HubMaximumReceiveMessageSize - 64 * 1024;
}
@@ -0,0 +1,54 @@
namespace ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis;
/// <summary>
/// Presentation rules for the script editor's Problems list — the markers Monaco reports through
/// <c>MonacoEditor.MarkersChanged</c> (the same <see cref="DiagnosticMarker"/> shape the diagnostics endpoint
/// returns), rendered by <c>ScriptProblemsList</c>.
/// </summary>
public static class ScriptProblems
{
/// <summary>Monaco's error severity.</summary>
public const int Error = 8;
/// <summary>Monaco's warning severity.</summary>
public const int Warning = 4;
/// <summary>Errors first, then warnings, then the rest; within a severity, by position.</summary>
/// <param name="markers">The reported markers (null treated as none).</param>
/// <returns>The markers in display order.</returns>
public static IReadOnlyList<DiagnosticMarker> Order(IEnumerable<DiagnosticMarker>? markers)
=> (markers ?? Array.Empty<DiagnosticMarker>())
.OrderByDescending(m => m.Severity)
.ThenBy(m => m.StartLineNumber)
.ThenBy(m => m.StartColumn)
.ToList();
/// <summary>The label for a Monaco severity value.</summary>
/// <param name="severity">8 = error, 4 = warning, 2 = info, otherwise hint.</param>
/// <returns>The display label.</returns>
public static string SeverityLabel(int severity) => severity switch
{
Error => "Error",
Warning => "Warning",
2 => "Info",
_ => "Hint",
};
/// <summary>A one-line count, e.g. <c>"2 errors, 1 warning"</c>.</summary>
/// <param name="markers">The reported markers.</param>
/// <returns>The summary, or <c>"No problems"</c>.</returns>
public static string Summary(IReadOnlyCollection<DiagnosticMarker> markers)
{
if (markers.Count == 0) return "No problems";
var errors = markers.Count(m => m.Severity == Error);
var warnings = markers.Count(m => m.Severity == Warning);
var other = markers.Count - errors - warnings;
var parts = new List<string>(3);
if (errors > 0) parts.Add(Plural(errors, "error"));
if (warnings > 0) parts.Add(Plural(warnings, "warning"));
if (other > 0) parts.Add($"{other} other");
return string.Join(", ", parts);
}
private static string Plural(int n, string noun) => n == 1 ? $"1 {noun}" : $"{n} {noun}s";
}