feat(adminui): {{equip}}-aware hover + {{equip}}. leaf completion in the script editor

This commit is contained in:
Joseph Doherty
2026-06-10 08:04:51 -04:00
parent cadd6c60b7
commit c7041a24e7
5 changed files with 121 additions and 2 deletions
@@ -18,6 +18,14 @@ public interface IScriptTagCatalog
/// <summary>Exact (case-sensitive, Ordinal) lookup of a resolvable tag path; null when not a known configured path.</summary>
Task<ScriptTagInfo?> GetTagInfoAsync(string path, CancellationToken ct);
/// <summary>Distinct attribute leaf names — the substring after the first dot of each
/// configured tag FullName — optionally prefix-filtered. Powers {{equip}}. completion,
/// which needs the per-equipment object prefix stripped.</summary>
/// <param name="filter">Case-insensitive StartsWith prefix over the leaf; null/empty = all (bounded).</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Distinct leaf names.</returns>
Task<IReadOnlyList<string>> GetEquipmentRelativeLeavesAsync(string? filter, CancellationToken ct);
}
/// <summary>Resolved info for one configured tag/virtual-tag path (for hover).</summary>
@@ -33,7 +41,7 @@ public sealed record ScriptTagInfo(string Path, string Kind, string DataType, st
/// <para>
/// <b>Fidelity over breadth.</b> Verified: the live runtime resolves a <c>ctx.GetTag("X")</c>
/// literal against the driver <c>FullName</c> — the resolution chain is
/// <c>Phase7Composer.ExtractDependencyRefs</c> harvesting the <c>ctx.GetTag("…")</c> literals
/// <c>Phase7Composer</c> (via <c>EquipmentScriptPaths.ExtractDependencyRefs</c>) harvesting the <c>ctx.GetTag("…")</c> literals
/// into <c>EquipmentVirtualTagPlan.DependencyRefs</c>
/// (<c>src/Server/…OpcUaServer/Phase7Composer.cs</c>); those become
/// <c>VirtualTagActor._dependencyRefs</c>, registered with the
@@ -102,6 +110,23 @@ public sealed class ScriptTagCatalog(IDbContextFactory<OtOpcUaConfigDbContext> d
return entries.FirstOrDefault(e => string.Equals(e.Path, path, StringComparison.Ordinal));
}
/// <inheritdoc />
public async Task<IReadOnlyList<string>> GetEquipmentRelativeLeavesAsync(string? filter, CancellationToken ct)
{
var entries = await BuildEntriesAsync(ct);
var leaves = new HashSet<string>(StringComparer.Ordinal);
foreach (var e in entries)
{
var dot = e.Path.IndexOf('.');
if (dot < 0 || dot + 1 >= e.Path.Length) continue;
leaves.Add(e.Path.Substring(dot + 1));
}
IEnumerable<string> q = leaves;
if (!string.IsNullOrWhiteSpace(filter))
q = q.Where(n => n.StartsWith(filter, StringComparison.OrdinalIgnoreCase));
return q.OrderBy(n => n, StringComparer.OrdinalIgnoreCase).Take(MaxResults).ToList();
}
/// <summary>
/// Loads Tags + VirtualTags (untracked) and projects one <see cref="ScriptTagInfo"/> per row —
/// the SINGLE source of the per-category resolvable-key projection shared by
@@ -2,6 +2,7 @@ using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.OtOpcUa.Commons.Types;
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
using ZB.MOM.WW.OtOpcUa.Core.VirtualTags;
@@ -161,6 +162,16 @@ public sealed class ScriptAnalysisService
if (_catalog != null && TryGetTagPathLiteral(token, out var pathPrefix))
{
const string equipDot = EquipmentScriptPaths.EquipToken + "."; // "{{equip}}."
if (pathPrefix.StartsWith(equipDot, StringComparison.Ordinal))
{
var leaves = await _catalog.GetEquipmentRelativeLeavesAsync(
pathPrefix.Substring(equipDot.Length), CancellationToken.None);
return new CompletionsResponse(leaves
.Select(n => new CompletionItem(equipDot + n, equipDot + n, "tag path", "Field"))
.ToList());
}
var paths = await _catalog.GetPathsAsync(pathPrefix, CancellationToken.None);
return new CompletionsResponse(
paths.Select(p => new CompletionItem(p, p, "tag path", "Field")).ToList());
@@ -242,8 +253,18 @@ public sealed class ScriptAnalysisService
// (or note it's not a known configured tag path) instead of the string-literal symbol.
if (_catalog is not null && TryGetTagPathLiteral(token, out var tagPath) && !string.IsNullOrEmpty(tagPath))
{
var info = await _catalog.GetTagInfoAsync(tagPath, CancellationToken.None);
static string Code(string s) => s.Replace("`", "\\`");
// Equipment-relative literal (contains the {{equip}} token): it cannot resolve to a
// configured path as-typed (the token is only substituted at deploy), so short-circuit
// before GetTagInfoAsync and explain the token instead of warning "not a known path".
if (tagPath.Contains(EquipmentScriptPaths.EquipToken, StringComparison.Ordinal))
{
return new HoverResponse(
$"**Equipment-relative path** `{Code(tagPath)}`\n\n" +
"The {{equip}} token is replaced with the owning equipment's tag base when the VirtualTag is deployed.");
}
var info = await _catalog.GetTagInfoAsync(tagPath, CancellationToken.None);
var tagMd = info is null
? $"**Tag path** `{Code(tagPath)}`\n\n⚠ Not a known configured tag path."
: $"**Tag path** `{Code(info.Path)}`\n\n{info.Kind} · Type **{info.DataType}**"