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:
@@ -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)
|
||||
{
|
||||
|
||||
@@ -53,7 +53,7 @@ public static class EndpointRouteBuilderExtensions
|
||||
/// <returns>The same service collection, for chaining.</returns>
|
||||
public static IServiceCollection AddAdminUI(this IServiceCollection services)
|
||||
{
|
||||
services.AddRazorComponents().AddInteractiveServerComponents();
|
||||
services.AddRazorComponents().AddInteractiveServerComponents().AddHubOptions(o => o.MaximumReceiveMessageSize = ScriptAnalysis.ScriptEditorLimits.HubMaximumReceiveMessageSize);
|
||||
services.AddOtOpcUaDriverStatusServices();
|
||||
|
||||
// Secrets-management UI (ZB.MOM.WW.Secrets.Ui RCL, mounted at /admin/secrets). Register its
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
@@ -112,25 +112,6 @@
|
||||
} catch (e) { return []; }
|
||||
}
|
||||
});
|
||||
monaco.languages.registerInlayHintsProvider("csharp", {
|
||||
provideInlayHints: async function (model) {
|
||||
try {
|
||||
const resp = await fetch("/api/script-analysis/inlay-hints", {
|
||||
method: "POST", credentials: "same-origin",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ code: model.getValue() })
|
||||
});
|
||||
if (!resp.ok) return { hints: [], dispose: function () {} };
|
||||
const data = await resp.json();
|
||||
return {
|
||||
hints: (data.hints || []).map(function (h) {
|
||||
return { position: { lineNumber: h.line, column: h.column }, label: h.label, kind: 2, paddingRight: true };
|
||||
}),
|
||||
dispose: function () {}
|
||||
};
|
||||
} catch (e) { return { hints: [], dispose: function () {} }; }
|
||||
}
|
||||
});
|
||||
monaco.languages.registerSignatureHelpProvider("csharp", {
|
||||
signatureHelpTriggerCharacters: ["(", ","],
|
||||
signatureHelpRetriggerCharacters: [","],
|
||||
@@ -156,17 +137,41 @@
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchDiagnostics(model) {
|
||||
// Resolves to the marker array, or null when the request was aborted (superseded by a newer edit) — the
|
||||
// caller must then leave the markers alone. A server/network failure still resolves to [] (clears markers).
|
||||
async function fetchDiagnostics(model, signal) {
|
||||
try {
|
||||
const resp = await fetch("/api/script-analysis/diagnostics", {
|
||||
method: "POST", credentials: "same-origin",
|
||||
method: "POST", credentials: "same-origin", signal: signal,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ code: model.getValue(), equipmentId: equipmentIdOf(model) })
|
||||
});
|
||||
if (!resp.ok) return [];
|
||||
const data = await resp.json();
|
||||
return data.markers || [];
|
||||
} catch (e) { return []; }
|
||||
} catch (e) { return (signal && signal.aborted) ? null : []; }
|
||||
}
|
||||
|
||||
// The editor text crosses the Blazor circuit (JS -> .NET) only on demand — at blur and when the page saves —
|
||||
// never per keystroke. Each crossing is one SignalR message, bounded by the hub's MaximumReceiveMessageSize;
|
||||
// an oversize message would tear the circuit down, so a value whose serialized form would not fit is refused
|
||||
// here instead (getValue throws a descriptive error that .NET surfaces to the author).
|
||||
// Blazor carries an interop value as a JSON string inside the hub frame; measuring it escaped twice is a
|
||||
// conservative upper bound whichever hub protocol (BlazorPack or JSON) the circuit negotiated.
|
||||
function serializedBytes(v) { return new TextEncoder().encode(JSON.stringify(JSON.stringify(v))).length; }
|
||||
function tooLargeMessage(e, v) {
|
||||
if (!e.maxSyncBytes) return null;
|
||||
const bytes = serializedBytes(v);
|
||||
return bytes > e.maxSyncBytes
|
||||
? "The script is too large for the editor to send to the server (" + bytes + " bytes; the limit is " + e.maxSyncBytes + " bytes)."
|
||||
: null;
|
||||
}
|
||||
function syncValue(e) {
|
||||
if (!e.dirty) return;
|
||||
const v = e.editor.getValue();
|
||||
if (tooLargeMessage(e, v)) return; // stays dirty; the explicit save read reports the error
|
||||
e.dirty = false;
|
||||
e.dotNetRef.invokeMethodAsync("OnValueChanged", v).catch(function () { e.dirty = true; });
|
||||
}
|
||||
|
||||
// A single body-level node for Monaco's overflow widgets (suggest / hover / parameter hints).
|
||||
@@ -207,22 +212,38 @@
|
||||
// Stamp the owning-equipment id on the model so the global csharp providers can thread it into the
|
||||
// {{equip}}/<RefName> completion + diagnostics (null on the shared ScriptEdit page).
|
||||
try { const m0 = editor.getModel(); if (m0) m0.__otEquipmentId = options.equipmentId || null; } catch (x) {}
|
||||
let diagTimer = null;
|
||||
const scheduleDiagnostics = function () {
|
||||
if (diagTimer) clearTimeout(diagTimer);
|
||||
diagTimer = setTimeout(async function () {
|
||||
const model = editor.getModel(); if (!model) return;
|
||||
const markers = await fetchDiagnostics(model);
|
||||
monaco.editor.setModelMarkers(model, "otopcua", markers);
|
||||
dotNetRef.invokeMethodAsync("OnMarkersChanged", markers).catch(function () {});
|
||||
}, 500);
|
||||
const e = {
|
||||
editor: editor, dotNetRef: dotNetRef, dirty: false,
|
||||
maxSyncBytes: options.maxSyncBytes || 0, reportMarkers: !!options.reportMarkers,
|
||||
diagTimer: null, diagGeneration: 0, diagAbort: null
|
||||
};
|
||||
// Diagnostics: the 500 ms debounce delays the START of a request; the generation counter + AbortController
|
||||
// make sure only the response for the CURRENT text is applied. Every edit bumps the generation and aborts
|
||||
// the in-flight request, so a slow older response can never overwrite newer markers.
|
||||
const runDiagnostics = async function () {
|
||||
const model = editor.getModel(); if (!model) return;
|
||||
const generation = ++e.diagGeneration;
|
||||
const ac = new AbortController();
|
||||
e.diagAbort = ac;
|
||||
const markers = await fetchDiagnostics(model, ac.signal);
|
||||
if (markers === null || generation !== e.diagGeneration || editors[id] !== e) return; // superseded
|
||||
e.diagAbort = null;
|
||||
monaco.editor.setModelMarkers(model, "otopcua", markers);
|
||||
if (e.reportMarkers) dotNetRef.invokeMethodAsync("OnMarkersChanged", markers).catch(function () {});
|
||||
};
|
||||
e.scheduleDiagnostics = function () {
|
||||
e.diagGeneration++;
|
||||
if (e.diagAbort) { e.diagAbort.abort(); e.diagAbort = null; }
|
||||
if (e.diagTimer) clearTimeout(e.diagTimer);
|
||||
e.diagTimer = setTimeout(runDiagnostics, 500);
|
||||
};
|
||||
editor.onDidChangeModelContent(function () {
|
||||
dotNetRef.invokeMethodAsync("OnValueChanged", editor.getValue()).catch(function () {});
|
||||
if (options.language === "csharp") scheduleDiagnostics();
|
||||
e.dirty = true;
|
||||
if (options.language === "csharp") e.scheduleDiagnostics();
|
||||
});
|
||||
editors[id] = { editor: editor, dotNetRef: dotNetRef };
|
||||
if (options.language === "csharp") scheduleDiagnostics();
|
||||
editor.onDidBlurEditorText(function () { syncValue(e); });
|
||||
editors[id] = e;
|
||||
if (options.language === "csharp") e.scheduleDiagnostics();
|
||||
}
|
||||
function setEditorOption(id, name, value) {
|
||||
const e = editors[id]; if (!e) return;
|
||||
@@ -235,10 +256,28 @@
|
||||
const e = editors[id]; if (!e) return;
|
||||
e.editor.revealLineInCenter(line); e.editor.setPosition({ lineNumber: line, column: col || 1 }); e.editor.focus();
|
||||
}
|
||||
function setValue(id, v) { const e = editors[id]; if (e && e.editor.getValue() !== v) e.editor.setValue(v || ""); }
|
||||
function getValue(id) { const e = editors[id]; return e ? e.editor.getValue() : null; }
|
||||
function setValue(id, v) {
|
||||
const e = editors[id]; if (!e) return;
|
||||
if (e.editor.getValue() !== v) e.editor.setValue(v || "");
|
||||
e.dirty = false; // the text now equals what .NET holds
|
||||
}
|
||||
// Reads the current text on demand (Save). Throws when it would not fit in one circuit message.
|
||||
function getValue(id) {
|
||||
const e = editors[id]; if (!e) return null;
|
||||
const v = e.editor.getValue();
|
||||
const err = tooLargeMessage(e, v);
|
||||
if (err) throw new Error(err);
|
||||
e.dirty = false;
|
||||
return v;
|
||||
}
|
||||
function setMarkers(id, m) { const e = editors[id]; const model = e && e.editor.getModel(); if (model) monaco.editor.setModelMarkers(model, "otopcua", m || []); }
|
||||
function dispose(id) { const e = editors[id]; if (!e) return; try { e.editor.dispose(); } catch (x) {} delete editors[id]; }
|
||||
function dispose(id) {
|
||||
const e = editors[id]; if (!e) return;
|
||||
if (e.diagTimer) clearTimeout(e.diagTimer);
|
||||
if (e.diagAbort) e.diagAbort.abort();
|
||||
try { e.editor.dispose(); } catch (x) {}
|
||||
delete editors[id];
|
||||
}
|
||||
|
||||
window.MonacoBlazor = { createEditor, setValue, getValue, setMarkers, setEditorOption, format, revealLine, dispose };
|
||||
})();
|
||||
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.ScriptAnalysis;
|
||||
|
||||
/// <summary>
|
||||
/// Source pins for the Monaco interop (AdminUI has no bUnit and no JS test runner). These do not prove the
|
||||
/// browser behaviour — the docker-dev live check does — they stop the specific regressions R04:P1/P9/S2/S3 fixed
|
||||
/// from quietly coming back: per-keystroke text sync, unsequenced diagnostics, the inlay-hints POST, and an
|
||||
/// unconsumed marker round-trip.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class MonacoInteropSourceTests
|
||||
{
|
||||
private static string Read(params string[] relative)
|
||||
{
|
||||
var dir = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "ZB.MOM.WW.OtOpcUa.slnx")))
|
||||
dir = dir.Parent;
|
||||
dir.ShouldNotBeNull("could not locate the repository root");
|
||||
return File.ReadAllText(Path.Combine(new[] { dir!.FullName, "src", "Server", "ZB.MOM.WW.OtOpcUa.AdminUI" }.Concat(relative).ToArray()));
|
||||
}
|
||||
|
||||
private static string Js() => Read("wwwroot", "js", "monaco-init.js");
|
||||
|
||||
[Fact]
|
||||
public void The_content_change_handler_does_not_ship_the_text_to_dotnet()
|
||||
{
|
||||
var js = Js();
|
||||
var handler = Regex.Match(js, @"onDidChangeModelContent\(function \(\) \{(?<body>.*?)\n \}\);", RegexOptions.Singleline);
|
||||
handler.Success.ShouldBeTrue("the content-change handler moved; update this pin");
|
||||
handler.Groups["body"].Value.ShouldNotContain("OnValueChanged");
|
||||
handler.Groups["body"].Value.ShouldNotContain("getValue");
|
||||
// Positive control: the text IS still reported — at blur.
|
||||
js.ShouldContain("onDidBlurEditorText");
|
||||
js.ShouldContain("\"OnValueChanged\"");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Diagnostics_are_generation_guarded_and_abortable()
|
||||
{
|
||||
var js = Js();
|
||||
js.ShouldContain("diagGeneration");
|
||||
js.ShouldContain("generation !== e.diagGeneration");
|
||||
js.ShouldContain("new AbortController()");
|
||||
js.ShouldContain("signal: signal");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Providers_honour_the_monaco_cancellation_token()
|
||||
{
|
||||
var js = Js();
|
||||
js.ShouldContain("token.onCancellationRequested");
|
||||
foreach (var path in new[] { "\"completions\"", "\"hover\"", "\"format\"", "\"signature-help\"" })
|
||||
js.ShouldContain("postAnalysis(" + path);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_inlay_hints_provider_is_not_registered() => Js().ShouldNotContain("registerInlayHintsProvider");
|
||||
|
||||
[Fact]
|
||||
public void Markers_cross_to_dotnet_only_for_a_subscribed_problems_list()
|
||||
{
|
||||
Js().ShouldContain("if (e.reportMarkers) dotNetRef.invokeMethodAsync(\"OnMarkersChanged\"");
|
||||
Read("Components", "Shared", "MonacoEditor.razor").ShouldContain("reportMarkers = MarkersChanged.HasDelegate");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Components", "Pages", "ScriptEdit.razor")]
|
||||
[InlineData("Components", "Shared", "Uns", "VirtualTagModal.razor")]
|
||||
public void Script_hosts_read_the_editor_on_save_and_render_the_problems_list(params string[] path)
|
||||
{
|
||||
var src = Read(path);
|
||||
src.ShouldContain("GetValueAsync()");
|
||||
src.ShouldContain("<ScriptProblemsList");
|
||||
src.ShouldContain("RevealLineAsync(");
|
||||
src.ShouldContain("MarkersChanged=");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.ScriptAnalysis;
|
||||
|
||||
/// <summary>
|
||||
/// R04:P1 / S2 — the Monaco editor no longer ships its text over the circuit per keystroke; the page reads it on
|
||||
/// demand at blur / Save. That single read is one hub message, so the Blazor hub's receive limit is raised from
|
||||
/// the 32 KB default to 1 MB, and the JS refuses (rather than sends) a value that would not fit.
|
||||
/// </summary>
|
||||
public sealed class MonacoSyncTests
|
||||
{
|
||||
private static HubOptions ComponentHubOptions(IServiceProvider sp)
|
||||
{
|
||||
// ComponentHub is internal to Microsoft.AspNetCore.Components.Server; resolve its options by type name.
|
||||
var hubType = Type.GetType("Microsoft.AspNetCore.Components.Server.ComponentHub, Microsoft.AspNetCore.Components.Server", throwOnError: true)!;
|
||||
var optionsType = typeof(IOptions<>).MakeGenericType(typeof(HubOptions<>).MakeGenericType(hubType));
|
||||
var options = sp.GetRequiredService(optionsType);
|
||||
return (HubOptions)optionsType.GetProperty("Value")!.GetValue(options)!;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_blazor_hub_accepts_a_1_MB_message()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
services.AddAdminUI();
|
||||
using var sp = services.BuildServiceProvider();
|
||||
|
||||
ComponentHubOptions(sp).MaximumReceiveMessageSize.ShouldBe(1024 * 1024);
|
||||
ScriptEditorLimits.HubMaximumReceiveMessageSize.ShouldBe(1024 * 1024);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Without_AddAdminUI_the_default_limit_is_the_32_KB_that_truncated_large_scripts()
|
||||
{
|
||||
// Positive control for the test above: the 1 MB comes from AddAdminUI, not from a framework default.
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
services.AddRazorComponents().AddInteractiveServerComponents();
|
||||
using var sp = services.BuildServiceProvider();
|
||||
|
||||
(ComponentHubOptions(sp).MaximumReceiveMessageSize ?? 32 * 1024).ShouldBe(32 * 1024);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_editor_sync_budget_leaves_headroom_inside_the_hub_limit_and_fits_a_100_KB_script()
|
||||
{
|
||||
ScriptEditorLimits.MaxEditorSyncBytes.ShouldBeLessThan((int)ScriptEditorLimits.HubMaximumReceiveMessageSize);
|
||||
ScriptEditorLimits.MaxEditorSyncBytes.ShouldBeGreaterThan(4 * 100 * 1024); // 100 KB even after escaping twice
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.ScriptAnalysis;
|
||||
|
||||
/// <summary>
|
||||
/// R04:S3 / S2 — the marker round-trip now feeds a real Problems list (MarkersChanged → list → RevealLineAsync),
|
||||
/// and the dead inlay-hints endpoint is gone. The Razor wiring is live-verified; the ordering and wording it
|
||||
/// renders are pinned here.
|
||||
/// </summary>
|
||||
public sealed class ProblemsListTests
|
||||
{
|
||||
private static DiagnosticMarker M(int sev, int line, int col, string code = "X") => new(sev, line, col, line, col + 1, "msg", code);
|
||||
|
||||
[Fact]
|
||||
public void Problems_are_ordered_errors_first_then_by_position()
|
||||
{
|
||||
var ordered = ScriptProblems.Order(new[]
|
||||
{
|
||||
M(4, 1, 1, "W1"), M(8, 9, 2, "E3"), M(8, 2, 5, "E2"), M(8, 2, 1, "E1"), M(2, 1, 1, "I1"),
|
||||
});
|
||||
|
||||
ordered.Select(m => m.Code).ShouldBe(new[] { "E1", "E2", "E3", "W1", "I1" });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Null_or_empty_markers_order_to_an_empty_list() => ScriptProblems.Order(null).ShouldBeEmpty();
|
||||
|
||||
[Theory]
|
||||
[InlineData(8, "Error")]
|
||||
[InlineData(4, "Warning")]
|
||||
[InlineData(2, "Info")]
|
||||
[InlineData(1, "Hint")]
|
||||
public void Severity_labels_follow_monaco_values(int severity, string label)
|
||||
=> ScriptProblems.SeverityLabel(severity).ShouldBe(label);
|
||||
|
||||
[Fact]
|
||||
public void Summary_counts_errors_and_warnings()
|
||||
{
|
||||
ScriptProblems.Summary(Array.Empty<DiagnosticMarker>()).ShouldBe("No problems");
|
||||
ScriptProblems.Summary(new[] { M(8, 1, 1) }).ShouldBe("1 error");
|
||||
ScriptProblems.Summary(new[] { M(8, 1, 1), M(8, 2, 1), M(4, 3, 1) }).ShouldBe("2 errors, 1 warning");
|
||||
ScriptProblems.Summary(new[] { M(4, 1, 1), M(4, 2, 1), M(2, 3, 1) }).ShouldBe("2 warnings, 1 other");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_inlay_hints_endpoint_is_gone()
|
||||
{
|
||||
// The always-empty inlay-hints endpoint (and the JS provider that POSTed the full source to it on every
|
||||
// edit) were removed. Positive control: the other five routes are still mapped.
|
||||
var (app, _) = AnalysisCancellationTests.BuildApp();
|
||||
var routes = AnalysisCancellationTests.Endpoints(app).Select(e => e.RoutePattern.RawText!).ToList();
|
||||
|
||||
routes.ShouldNotContain(r => r.Contains("inlay", StringComparison.OrdinalIgnoreCase));
|
||||
foreach (var r in new[] { "/diagnostics", "/completions", "/hover", "/signature-help", "/format" })
|
||||
routes.ShouldContain(x => x.EndsWith(r));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user