Merge master 4fb04a9f (lane D-a) into remed/lane-D-b
ScriptEdit.razor conflict resolved keeping both D-a's on-demand Monaco sync + Problems list and D-b's delete confirm. Refs #745 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
@@ -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
|
||||
@@ -20,9 +21,23 @@
|
||||
|
||||
<div @ref="_hostRef" class="monaco-editor-host"
|
||||
style="height: @Height; border: 1px solid var(--bs-border-color); border-radius: 0.25rem; overflow: hidden;"></div>
|
||||
@if (_tooLargeBytes > 0)
|
||||
{
|
||||
@* Non-blocking: the author keeps editing, but learns now (not at Save) that this text cannot be sent. *@
|
||||
<div class="text-warning small mt-1" role="status">
|
||||
This script is too large to send to the server (@_tooLargeBytes bytes; the limit is
|
||||
@ScriptEditorLimits.MaxEditorSyncBytes bytes). It cannot be saved until it is shortened.
|
||||
</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,17 +52,20 @@
|
||||
[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, and at most
|
||||
/// <see cref="ScriptEditorLimits.MaxReportedMarkers"/> of them (plus the total) so the report always fits in one
|
||||
/// circuit message.
|
||||
/// </summary>
|
||||
[Parameter] public EventCallback<object[]> MarkersChanged { get; set; }
|
||||
[Parameter] public EventCallback<ScriptProblemReport> MarkersChanged { get; set; }
|
||||
|
||||
private ElementReference _hostRef;
|
||||
private DotNetObjectReference<MonacoEditor>? _dotNetRef;
|
||||
private readonly string _id = Guid.NewGuid().ToString("N");
|
||||
private string _lastSentValue = "";
|
||||
private bool _initialized;
|
||||
private int _tooLargeBytes;
|
||||
private bool _wrap;
|
||||
private bool _minimap;
|
||||
private bool _dark;
|
||||
@@ -69,7 +87,11 @@
|
||||
value = Value ?? "",
|
||||
language = Language,
|
||||
readOnly = ReadOnly,
|
||||
equipmentId = EquipmentId
|
||||
equipmentId = EquipmentId,
|
||||
maxSyncBytes = ScriptEditorLimits.MaxEditorSyncBytes,
|
||||
reportMarkers = MarkersChanged.HasDelegate,
|
||||
maxReportedMarkers = ScriptEditorLimits.MaxReportedMarkers,
|
||||
maxReportedMessageChars = ScriptEditorLimits.MaxReportedMessageChars
|
||||
},
|
||||
_dotNetRef);
|
||||
_initialized = true;
|
||||
@@ -126,9 +148,57 @@
|
||||
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
|
||||
_tooLargeBytes = 0; // it was read, so it fits
|
||||
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, int total) =>
|
||||
MarkersChanged.InvokeAsync(ScriptProblemReport.FromEditor(markers, total));
|
||||
|
||||
/// <summary>The JS reports, at blur, that the text has grown past (bytes > 0) or back under (0) the sync limit.</summary>
|
||||
[JSInvokable]
|
||||
public void OnSyncRefused(int bytes)
|
||||
{
|
||||
if (_tooLargeBytes == bytes) return;
|
||||
_tooLargeBytes = bytes;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
/// <summary>Programmatic scroll-to-line (called by the problems panel).</summary>
|
||||
public async Task RevealLineAsync(int line, int column = 1)
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
@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 report = Report ?? ScriptProblemReport.Empty;
|
||||
var ordered = ScriptProblems.Order(report.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(report with { Markers = 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 (report.Total > Math.Min(ordered.Count, MaxRows))
|
||||
{
|
||||
@* The editor reports at most ScriptEditorLimits.MaxReportedMarkers problems (one circuit message); the
|
||||
rest are still squiggled in the editor. *@
|
||||
<div class="text-muted mt-1">+@(report.Total - Math.Min(ordered.Count, MaxRows)) more — fix the problems above, or scroll the editor to see them all.</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private const int MaxRows = ScriptEditorLimits.MaxReportedMarkers;
|
||||
|
||||
/// <summary>The report the editor last sent (null before the first diagnostics pass).</summary>
|
||||
[Parameter] public ScriptProblemReport? Report { 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 Report="_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 ScriptProblemReport _scriptMarkers = ScriptProblemReport.Empty;
|
||||
|
||||
private void OnScriptMarkersChanged(ScriptProblemReport report) => _scriptMarkers = report;
|
||||
|
||||
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;
|
||||
@@ -227,6 +248,9 @@
|
||||
if (!Visible)
|
||||
{
|
||||
_loadedKey = null; // closed → next open reloads fresh
|
||||
// Closing removes the editor component: drop its reference and its problems.
|
||||
_scriptEditor = null;
|
||||
_scriptMarkers = ScriptProblemReport.Empty;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -278,6 +302,9 @@
|
||||
{
|
||||
_scriptSaved = false;
|
||||
_scriptError = null;
|
||||
// A different script (or a reload) — drop the previous script's problems. The editor instance itself stays:
|
||||
// while the panel is open the same MonacoEditor component receives the new source via its Value.
|
||||
_scriptMarkers = ScriptProblemReport.Empty;
|
||||
|
||||
if (string.IsNullOrEmpty(_form.ScriptId))
|
||||
{
|
||||
@@ -358,8 +385,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 = ScriptProblemReport.Empty;
|
||||
}
|
||||
_scriptExpanded = !_scriptExpanded;
|
||||
if (_scriptExpanded)
|
||||
{
|
||||
@@ -384,6 +419,7 @@
|
||||
_scriptError = null;
|
||||
try
|
||||
{
|
||||
if (!await PullScriptSourceAsync()) return;
|
||||
var result = await Svc.UpdateScriptSourceAsync(_form.ScriptId, _scriptSource, _scriptRowVersion);
|
||||
if (result.Ok)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user