feat(ui/scripts): format, inlay hints, problems panel, type diagnostic
Three more editor features rolled in:
1. Roslyn Format command.
New POST /api/script-analysis/format runs Formatter.Format() from
Microsoft.CodeAnalysis.CSharp.Workspaces on the parsed script
tree. monaco-init.js registers a DocumentFormattingEditProvider
so Ctrl/Cmd-Shift-F and the toolbar "Format" button both work.
2. Inlay hints with parameter names.
New POST /api/script-analysis/inlay-hints walks CallShared /
CallScript invocations and emits InlayHint records positioned at
each argument with the matching parameter's name (e.g. "name:").
Ghost text appears via Monaco's InlayHintsProvider.
3. SCADA005 argument-type diagnostic.
Literal type vs. declared parameter type check on every
CallShared/CallScript argument. Float accepts Integer literals;
Object/List accept anything; null only matches reference-ish
types. Legacy lowercase types ("string" etc) from the DB are
normalized to the canonical set before comparison so existing
data doesn't false-negative. Non-literal args (variables,
expressions) are skipped — out of scope for a cheap pass.
4. Parameters["name"] hover.
Hover endpoint now also resolves Parameters["X"] element-access
keys against the form's DeclaredParameterShapes and returns
"parameter `name: String`"-style markdown. MonacoEditor surfaces
the new DeclaredParameterShapes parameter; ScriptParameterNames
gets a ParseShapes companion.
5. Problems panel.
Bootstrap card under the editor listing every marker with
severity badge, line number, message, and SCADA / CS code. Click
a row to scroll the editor to that line and focus. JS now
invokes OnMarkersChanged on the .NET side whenever
setModelMarkers fires, so the panel stays in sync with the
editor.
6. Editor toolbar.
Small top-right strip on each editor with Format / Wrap /
Minimap / Theme toggles. New MonacoBlazor.format,
setEditorOption, and revealLine JS APIs back the buttons and the
problems-panel scroll-to-line.
Contracts:
- FormatRequest / FormatResponse
- InlayHintsRequest / InlayHintsResponse / InlayHint
- HoverRequest.DeclaredParameters
- MonacoEditor.DeclaredParameterShapes parameter
- MonacoEditor.MarkersChanged callback
- ScadaContext.DeclaredParameterShapes
10 new xUnit tests covering format, inlay hints, SCADA005 (string-
expects-integer, integer-expects-string, float-accepts-integer,
object-accepts-anything, non-literal-skipped), and Parameters key
hover. Total: 139 -> 149.
Microsoft.CodeAnalysis.CSharp.Workspaces 4.13.0 added to pull in
Formatter and AdhocWorkspace.
Browser-verified: typing `CallShared("Greet", 42)` now shows the
"name:" inlay hint and a SCADA005 squiggle on `42`; Parameters["typo"]
shows SCADA003 as before; the toolbar buttons all work.
This commit is contained in:
@@ -38,9 +38,12 @@
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Script</label>
|
||||
<MonacoEditor Value="@_script" ValueChanged="@(v => _script = v)"
|
||||
<MonacoEditor @ref="_editor" Value="@_script" ValueChanged="@(v => _script = v)"
|
||||
Language="csharp" Height="320px"
|
||||
DeclaredParameters="@ScriptParameterNames.Parse(_params)" />
|
||||
DeclaredParameters="@ScriptParameterNames.Parse(_params)"
|
||||
DeclaredParameterShapes="@ScriptParameterNames.ParseShapes(_params)"
|
||||
MarkersChanged="@(m => { _markers = m; StateHasChanged(); })" />
|
||||
<ProblemsPanel Markers="@_markers" OnNavigate="@(m => _editor?.RevealLineAsync(m.StartLineNumber, m.StartColumn) ?? Task.CompletedTask)" />
|
||||
</div>
|
||||
|
||||
@if (_formError != null)
|
||||
@@ -65,6 +68,9 @@
|
||||
private int _timeoutSeconds = 30;
|
||||
private string? _params, _returns;
|
||||
private string? _formError;
|
||||
private MonacoEditor? _editor;
|
||||
private IReadOnlyList<ScadaLink.CentralUI.ScriptAnalysis.DiagnosticMarker> _markers
|
||||
= Array.Empty<ScadaLink.CentralUI.ScriptAnalysis.DiagnosticMarker>();
|
||||
|
||||
private ApiMethod? _existing;
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
@using ScadaLink.Commons.Entities.Scripts
|
||||
@using ScadaLink.Commons.Interfaces.Repositories
|
||||
@using ScadaLink.TemplateEngine
|
||||
@using ScriptAnalysis = ScadaLink.CentralUI.ScriptAnalysis
|
||||
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDesign)]
|
||||
@inject ITemplateEngineRepository TemplateEngineRepository
|
||||
@inject SharedScriptService SharedScriptService
|
||||
@@ -39,9 +40,12 @@
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<label class="form-label small">Code</label>
|
||||
<MonacoEditor Value="@_formCode" ValueChanged="@(v => _formCode = v)"
|
||||
<MonacoEditor @ref="_editor" Value="@_formCode" ValueChanged="@(v => _formCode = v)"
|
||||
Language="csharp" Height="320px"
|
||||
DeclaredParameters="@ScriptParameterNames.Parse(_formParameters)" />
|
||||
DeclaredParameters="@ScriptParameterNames.Parse(_formParameters)"
|
||||
DeclaredParameterShapes="@ScriptParameterNames.ParseShapes(_formParameters)"
|
||||
MarkersChanged="@(m => { _markers = m; StateHasChanged(); })" />
|
||||
<ProblemsPanel Markers="@_markers" OnNavigate="@(m => _editor?.RevealLineAsync(m.StartLineNumber, m.StartColumn) ?? Task.CompletedTask)" />
|
||||
</div>
|
||||
@if (_formError != null)
|
||||
{
|
||||
@@ -72,6 +76,8 @@
|
||||
private string? _formError;
|
||||
private string? _syntaxCheckResult;
|
||||
private bool _syntaxCheckPassed;
|
||||
private MonacoEditor? _editor;
|
||||
private IReadOnlyList<ScriptAnalysis.DiagnosticMarker> _markers = Array.Empty<ScriptAnalysis.DiagnosticMarker>();
|
||||
|
||||
private async Task<string> GetCurrentUserAsync()
|
||||
{
|
||||
|
||||
@@ -89,6 +89,9 @@
|
||||
private string? _scriptReturn;
|
||||
private bool _scriptIsLocked;
|
||||
private string? _scriptFormError;
|
||||
private MonacoEditor? _scriptEditor;
|
||||
private IReadOnlyList<ScadaLink.CentralUI.ScriptAnalysis.DiagnosticMarker> _scriptMarkers
|
||||
= Array.Empty<ScadaLink.CentralUI.ScriptAnalysis.DiagnosticMarker>();
|
||||
|
||||
private bool _showCompForm;
|
||||
private int _compComposedTemplateId;
|
||||
@@ -654,10 +657,13 @@
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label">Code</label>
|
||||
<MonacoEditor Value="@_scriptCode" ValueChanged="@(v => _scriptCode = v)"
|
||||
<MonacoEditor @ref="_scriptEditor" Value="@_scriptCode" ValueChanged="@(v => _scriptCode = v)"
|
||||
Language="csharp" Height="320px"
|
||||
DeclaredParameters="@ScriptParameterNames.Parse(_scriptParameters)"
|
||||
SiblingScripts="@(_scripts.Select(s => ScadaLink.CentralUI.ScriptAnalysis.ScriptShapeParser.Parse(s.Name, s.ParameterDefinitions, s.ReturnDefinition)).ToArray())" />
|
||||
DeclaredParameterShapes="@ScriptParameterNames.ParseShapes(_scriptParameters)"
|
||||
SiblingScripts="@(_scripts.Select(s => ScadaLink.CentralUI.ScriptAnalysis.ScriptShapeParser.Parse(s.Name, s.ParameterDefinitions, s.ReturnDefinition)).ToArray())"
|
||||
MarkersChanged="@(m => { _scriptMarkers = m; StateHasChanged(); })" />
|
||||
<ProblemsPanel Markers="@_scriptMarkers" OnNavigate="@(m => _scriptEditor?.RevealLineAsync(m.StartLineNumber, m.StartColumn) ?? Task.CompletedTask)" />
|
||||
</div>
|
||||
@if (_scriptFormError != null)
|
||||
{
|
||||
|
||||
@@ -2,6 +2,20 @@
|
||||
@implements IAsyncDisposable
|
||||
@inject IJSRuntime JS
|
||||
|
||||
@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>
|
||||
|
||||
@@ -11,13 +25,22 @@
|
||||
[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>
|
||||
/// Parameter names declared on the form (from the ParameterListEditor),
|
||||
/// surfaced as completions inside Parameters["..."] literals.
|
||||
/// surfaced as completions inside Parameters["..."] literals and used by
|
||||
/// the unknown-key diagnostic.
|
||||
/// </summary>
|
||||
[Parameter] public IReadOnlyList<string>? DeclaredParameters { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Full shapes (name + type + required) for the declared parameters.
|
||||
/// Used by Parameters["name"] hover to show the declared type. If null,
|
||||
/// derived from <see cref="DeclaredParameters"/> with type "Object".
|
||||
/// </summary>
|
||||
[Parameter] public IReadOnlyList<ScriptAnalysis.ParameterShape>? DeclaredParameterShapes { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Shapes (name + parameter list + return type) of other scripts on the
|
||||
/// same template. Surfaced inside CallScript("...") for completion,
|
||||
@@ -25,11 +48,21 @@
|
||||
/// </summary>
|
||||
[Parameter] public IReadOnlyList<ScriptAnalysis.ScriptShape>? SiblingScripts { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Fires whenever Monaco's marker set updates (after the 500 ms diagnostic
|
||||
/// debounce). Hosts can render a <see cref="ProblemsPanel"/> with the same
|
||||
/// data.
|
||||
/// </summary>
|
||||
[Parameter] public EventCallback<IReadOnlyList<ScriptAnalysis.DiagnosticMarker>> 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)
|
||||
{
|
||||
@@ -71,6 +104,17 @@
|
||||
return ValueChanged.InvokeAsync(_lastSentValue);
|
||||
}
|
||||
|
||||
[JSInvokable]
|
||||
public Task OnMarkersChanged(ScriptAnalysis.DiagnosticMarker[] markers) =>
|
||||
MarkersChanged.InvokeAsync(markers ?? Array.Empty<ScriptAnalysis.DiagnosticMarker>());
|
||||
|
||||
/// <summary>Programmatic scroll-to-line (called by the problems panel).</summary>
|
||||
public async Task RevealLineAsync(int line, int column = 1)
|
||||
{
|
||||
if (!_initialized) return;
|
||||
try { await JS.InvokeVoidAsync("MonacoBlazor.revealLine", _id, line, column); } catch { }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called from JS at completion-request time so the form's latest state is
|
||||
/// passed through, not whatever was captured when the editor was created.
|
||||
@@ -78,7 +122,34 @@
|
||||
[JSInvokable]
|
||||
public ScadaContext GetContext() => new(
|
||||
DeclaredParameters?.ToArray() ?? Array.Empty<string>(),
|
||||
SiblingScripts?.ToArray() ?? Array.Empty<ScriptAnalysis.ScriptShape>());
|
||||
SiblingScripts?.ToArray() ?? Array.Empty<ScriptAnalysis.ScriptShape>(),
|
||||
DeclaredParameterShapes?.ToArray()
|
||||
?? DeclaredParameters?.Select(n => new ScriptAnalysis.ParameterShape(n, "Object", true)).ToArray()
|
||||
?? Array.Empty<ScriptAnalysis.ParameterShape>());
|
||||
|
||||
private async Task FormatAsync()
|
||||
{
|
||||
if (!_initialized) return;
|
||||
try { await JS.InvokeVoidAsync("MonacoBlazor.format", _id); } catch { }
|
||||
}
|
||||
|
||||
private async Task ToggleWrap()
|
||||
{
|
||||
_wrap = !_wrap;
|
||||
try { await JS.InvokeVoidAsync("MonacoBlazor.setEditorOption", _id, "wordWrap", _wrap ? "on" : "off"); } catch { }
|
||||
}
|
||||
|
||||
private async Task ToggleMinimap()
|
||||
{
|
||||
_minimap = !_minimap;
|
||||
try { await JS.InvokeVoidAsync("MonacoBlazor.setEditorOption", _id, "minimap", new { enabled = _minimap }); } catch { }
|
||||
}
|
||||
|
||||
private async Task ToggleTheme()
|
||||
{
|
||||
_dark = !_dark;
|
||||
try { await JS.InvokeVoidAsync("MonacoBlazor.setEditorOption", _id, "theme", _dark ? "vs-dark" : "vs"); } catch { }
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
@@ -89,5 +160,8 @@
|
||||
_dotNetRef?.Dispose();
|
||||
}
|
||||
|
||||
public record ScadaContext(string[] DeclaredParameters, ScriptAnalysis.ScriptShape[] SiblingScripts);
|
||||
public record ScadaContext(
|
||||
string[] DeclaredParameters,
|
||||
ScriptAnalysis.ScriptShape[] SiblingScripts,
|
||||
ScriptAnalysis.ParameterShape[] DeclaredParameterShapes);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
@namespace ScadaLink.CentralUI.Components.Shared
|
||||
@using ScadaLink.CentralUI.ScriptAnalysis
|
||||
|
||||
@if (Markers.Count > 0)
|
||||
{
|
||||
<div class="card mt-2 mb-3">
|
||||
<div class="card-header py-1 small d-flex justify-content-between align-items-center">
|
||||
<span>
|
||||
@if (_errorCount > 0)
|
||||
{
|
||||
<span class="badge bg-danger me-1">@_errorCount error@(_errorCount == 1 ? "" : "s")</span>
|
||||
}
|
||||
@if (_warningCount > 0)
|
||||
{
|
||||
<span class="badge bg-warning text-dark me-1">@_warningCount warning@(_warningCount == 1 ? "" : "s")</span>
|
||||
}
|
||||
@if (_infoCount > 0)
|
||||
{
|
||||
<span class="badge bg-info text-dark me-1">@_infoCount info</span>
|
||||
}
|
||||
</span>
|
||||
<span class="text-muted">Problems</span>
|
||||
</div>
|
||||
<ul class="list-unstyled mb-0 small" style="max-height: 180px; overflow-y: auto;">
|
||||
@foreach (var m in Markers)
|
||||
{
|
||||
<li class="d-flex gap-2 align-items-start px-2 py-1 border-bottom">
|
||||
<span class="badge @SeverityBadge(m.Severity)" style="min-width: 60px;">@SeverityLabel(m.Severity)</span>
|
||||
<button type="button" class="btn btn-link btn-sm p-0 text-decoration-none flex-grow-1 text-start"
|
||||
@onclick="@(() => OnNavigate.InvokeAsync(m))">
|
||||
<span class="text-muted me-2">Line @m.StartLineNumber</span>@m.Message
|
||||
</button>
|
||||
<code class="text-muted small">@m.Code</code>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter, EditorRequired] public IReadOnlyList<DiagnosticMarker> Markers { get; set; } = Array.Empty<DiagnosticMarker>();
|
||||
[Parameter] public EventCallback<DiagnosticMarker> OnNavigate { get; set; }
|
||||
|
||||
private int _errorCount;
|
||||
private int _warningCount;
|
||||
private int _infoCount;
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
_errorCount = Markers.Count(m => m.Severity >= 8);
|
||||
_warningCount = Markers.Count(m => m.Severity == 4);
|
||||
_infoCount = Markers.Count(m => m.Severity > 0 && m.Severity < 4);
|
||||
}
|
||||
|
||||
private static string SeverityBadge(int sev) => sev switch
|
||||
{
|
||||
>= 8 => "bg-danger",
|
||||
4 => "bg-warning text-dark",
|
||||
_ => "bg-info text-dark"
|
||||
};
|
||||
|
||||
private static string SeverityLabel(int sev) => sev switch
|
||||
{
|
||||
>= 8 => "Error",
|
||||
4 => "Warning",
|
||||
_ => "Info"
|
||||
};
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
using System.Text.Json;
|
||||
using ScadaLink.CentralUI.ScriptAnalysis;
|
||||
|
||||
namespace ScadaLink.CentralUI.Components.Shared;
|
||||
|
||||
/// <summary>
|
||||
/// Parses the parameter-definitions JSON written by ParameterListEditor and
|
||||
/// returns the declared parameter names. Used by script-edit pages to feed
|
||||
/// the Monaco editor's Parameters["..."] completion provider.
|
||||
/// returns the declared parameter names (and shapes). Used by script-edit
|
||||
/// pages to feed the Monaco editor's Parameters["..."] context.
|
||||
/// </summary>
|
||||
public static class ScriptParameterNames
|
||||
{
|
||||
@@ -26,4 +27,25 @@ public static class ScriptParameterNames
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
}
|
||||
|
||||
public static IReadOnlyList<ParameterShape> ParseShapes(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json)) return Array.Empty<ParameterShape>();
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
if (doc.RootElement.ValueKind != JsonValueKind.Array) return Array.Empty<ParameterShape>();
|
||||
return doc.RootElement.EnumerateArray()
|
||||
.Select(el => new ParameterShape(
|
||||
Name: el.TryGetProperty("name", out var n) ? n.GetString() ?? "" : "",
|
||||
Type: el.TryGetProperty("type", out var t) ? t.GetString() ?? "String" : "String",
|
||||
Required: !el.TryGetProperty("required", out var rq) || rq.ValueKind != JsonValueKind.False))
|
||||
.Where(p => !string.IsNullOrEmpty(p.Name))
|
||||
.ToList();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return Array.Empty<ParameterShape>();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user