Files
lmxopcua/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/MonacoEditor.razor
T
Joseph Doherty dc0d7653b9 feat(v3-batch3): B3-WP4 — {{equip}}/<RefName> reference-relative resolution
Replace the deleted equipment-base-prefix {{equip}} mechanism (impossible now
that equipment is reference-only) with per-reference resolution:
{{equip}}/<RefName> resolves through the owning equipment's UnsTagReference rows
(by effective name) to the backing raw tag's RawPath. Every unresolved <RefName>
is a clear deploy error + authoring rejection + Monaco diagnostic — preserving
"editor accepts <=> publish accepts".

Commons:
- EquipmentScriptPaths: delete DeriveEquipmentBase; SubstituteEquipmentToken now
  takes the equipment's reference map (effectiveName -> RawPath) and substitutes
  {{equip}}/<RefName> inside path literals (unresolved left intact, never throws);
  add ExtractEquipReferenceNames (path-literal scoped) +
  ExtractEquipReferenceNamesFromText (message templates). Slash syntax replaces
  the v2 dot joint.
- New EquipmentReferenceMap: shared, input-shape-agnostic builder (entity + JSON
  sides) over RawPathResolver — the single authority both compose seams + the
  validator use, so resolved RawPaths agree byte-for-byte.

Compose seams (byte-parity kept):
- AddressSpaceComposer.Compose + DeploymentArtifact.ParseComposition both build
  the per-equipment reference map from UnsTagReferences + Tags + raw topology and
  substitute VirtualTag AND ScriptedAlarm-predicate sources before dependency
  extraction (runtime dep refs = resolved RawPaths).

Deploy gate + authoring + editor:
- DraftValidator.ValidateEquipReferenceResolution: EquipReferenceUnresolved error
  for unresolved {{equip}}/<RefName> in VirtualTag/ScriptedAlarm scripts + alarm
  message-template tokens (naming script, equipment, missing ref).
- UnsTreeService.ValidateEquipTokenAsync (Wave-A M1): reference-relative authoring
  rejection, replacing the stale DeriveEquipmentBase(empty)->reject-all logic.
- ScriptAnalysisService: {{equip}}/ completion offers the equipment's reference
  effective names; DiagnoseAsync flags unresolved refs (OTSCRIPT_EQUIPREF) same as
  the deploy gate. Requests carry optional EquipmentId (threaded MonacoEditor ->
  monaco-init.js -> fetch bodies). IScriptTagCatalog.GetEquipmentRelativeLeavesAsync
  repurposed to GetEquipmentReferenceNamesAsync(equipmentId, filter).

Tests: EquipmentScriptPaths substitution/extraction (slash pinned); composer<->
artifact reference-resolution byte-parity; DraftValidator unresolved-ref;
ScriptAnalysis completion+diagnostic; M1 authoring gate. Build clean (0/0); all
five affected suites green.

Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
2026-07-16 06:58:17 -04:00

175 lines
6.6 KiB
Plaintext

@namespace ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared
@using Microsoft.Extensions.Logging
@implements IAsyncDisposable
@inject IJSRuntime JS
@inject Microsoft.Extensions.Logging.ILogger<MonacoEditor> Logger
@if (ShowToolbar)
{
<div class="d-flex justify-content-end align-items-center gap-3 mb-1 small text-muted">
<button type="button" class="btn btn-link btn-sm p-0 text-decoration-none" @onclick="FormatAsync"
title="Format document (Ctrl/Cmd+Shift+F)">Format</button>
<button type="button" class="btn btn-link btn-sm p-0 text-decoration-none" @onclick="ToggleWrap"
title="Word wrap">@(_wrap ? "Wrap on" : "Wrap off")</button>
<button type="button" class="btn btn-link btn-sm p-0 text-decoration-none" @onclick="ToggleMinimap"
title="Toggle minimap">@(_minimap ? "Minimap on" : "Minimap off")</button>
<button type="button" class="btn btn-link btn-sm p-0 text-decoration-none" @onclick="ToggleTheme"
title="Toggle theme">@(_dark ? "Dark" : "Light")</button>
</div>
}
<div @ref="_hostRef" class="monaco-editor-host"
style="height: @Height; border: 1px solid var(--bs-border-color); border-radius: 0.25rem; overflow: hidden;"></div>
@code {
[Parameter] public string Value { get; set; } = "";
[Parameter] public EventCallback<string> ValueChanged { get; set; }
[Parameter] public string Language { get; set; } = "csharp";
[Parameter] public string Height { get; set; } = "320px";
[Parameter] public bool ReadOnly { get; set; } = false;
[Parameter] public bool ShowToolbar { get; set; } = true;
/// <summary>
/// Owning-equipment context for equipment-relative <c>{{equip}}/&lt;RefName&gt;</c> completion +
/// diagnostics. Set on the per-equipment VirtualTag / ScriptedAlarm modals; left null on the shared
/// ScriptEdit page (where the token cannot resolve to a single equipment).
/// </summary>
[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.
/// </summary>
[Parameter] public EventCallback<object[]> 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 bool _wrap;
private bool _minimap;
private bool _dark;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
_dotNetRef = DotNetObjectReference.Create(this);
_lastSentValue = Value ?? "";
try
{
await JS.InvokeVoidAsync(
"MonacoBlazor.createEditor",
_id,
_hostRef,
new
{
value = Value ?? "",
language = Language,
readOnly = ReadOnly,
equipmentId = EquipmentId
},
_dotNetRef);
_initialized = true;
}
catch (InvalidOperationException)
{
// Prerendering: JS interop is not available yet — the next
// (interactive) render retries. Expected, not logged.
}
catch (JSDisconnectedException)
{
// Circuit disconnected before init completed — nothing to do.
}
catch (JSException ex)
{
// A genuine Monaco init failure — surface it instead of hiding it.
Logger.LogError(ex, "Monaco editor {EditorId} failed to initialize.", _id);
}
}
else if (_initialized && (Value ?? "") != _lastSentValue)
{
_lastSentValue = Value ?? "";
await SafeInvokeAsync("MonacoBlazor.setValue", "set editor value", _id, _lastSentValue);
}
}
/// <summary>
/// Invokes a Monaco JS function, swallowing the expected disconnect case but
/// logging any genuine JS error so failures are not silent.
/// </summary>
private async ValueTask SafeInvokeAsync(string fn, string action, params object?[] args)
{
try
{
await JS.InvokeVoidAsync(fn, args);
}
catch (JSDisconnectedException)
{
// Circuit gone — the editor no longer exists; nothing to log.
}
catch (JSException ex)
{
Logger.LogWarning(ex, "Monaco editor {EditorId}: failed to {Action}.", _id, action);
}
}
[JSInvokable]
public Task OnValueChanged(string newValue)
{
var normalized = newValue ?? "";
if (normalized == _lastSentValue)
return Task.CompletedTask;
_lastSentValue = normalized;
return ValueChanged.InvokeAsync(_lastSentValue);
}
[JSInvokable]
public Task OnMarkersChanged(object[] markers) =>
MarkersChanged.InvokeAsync(markers ?? Array.Empty<object>());
/// <summary>Programmatic scroll-to-line (called by the problems panel).</summary>
public async Task RevealLineAsync(int line, int column = 1)
{
if (!_initialized) return;
await SafeInvokeAsync("MonacoBlazor.revealLine", "reveal line", _id, line, column);
}
private async Task FormatAsync()
{
if (!_initialized) return;
await SafeInvokeAsync("MonacoBlazor.format", "format document", _id);
}
private async Task ToggleWrap()
{
_wrap = !_wrap;
await SafeInvokeAsync("MonacoBlazor.setEditorOption", "toggle word wrap", _id, "wordWrap", _wrap ? "on" : "off");
}
private async Task ToggleMinimap()
{
_minimap = !_minimap;
await SafeInvokeAsync("MonacoBlazor.setEditorOption", "toggle minimap", _id, "minimap", new { enabled = _minimap });
}
private async Task ToggleTheme()
{
_dark = !_dark;
await SafeInvokeAsync("MonacoBlazor.setEditorOption", "toggle theme", _id, "theme", _dark ? "vs-dark" : "vs");
}
public async ValueTask DisposeAsync()
{
if (_initialized)
{
// Disposal commonly races a circuit disconnect — JSDisconnectedException
// here is expected and silent; a real JSException is still logged.
await SafeInvokeAsync("MonacoBlazor.dispose", "dispose editor", _id);
}
_dotNetRef?.Dispose();
}
}