feat(editor): graph-pulse on errors + store-aware install / add-source
Three connected improvements to the analyzer-driven diagnostics:
- **Pulsing halo on broken graph nodes**. Every step / pseudo-
node (inputs / outputs) carrying an analyzer issue now
breathes a red (error) or amber (warning) halo on the graph
tab — operator sees the problem on the canvas without
flipping to text. Honours prefers-reduced-motion: reduce-
motion users get a static halo at the same intensity. The
canvas reads severity via a new `stepIssueSeverity` map on
FlowEditorController; pseudo-nodes use `__inputs__` /
`__outputs__` sentinel ids.
- **Store-aware install button**. The analyzer now takes a
second closure, `storeCapabilities`, listing what the public
store can install. Unknown-capability issues only carry the
"Install …" quick-fix when the bare cap is in that list;
otherwise the issue carries an "Add source for …" fix
instead. Resolves the asymmetry the operator reported: the
Store didn't show `htw.digiscout/onet.lookup` but the editor
happily offered to install it (and would have failed). The
install path no longer lies about itself.
- **Did-you-mean suggestion**. When the unknown cap is within
edit-distance two of an installed or store cap (different
spelling — distance-0 stays hidden because that's an install
case, not a typo), the analyzer emits a `ReplaceLineValueFix`
suggesting the closest match. Preserves the version
constraint by reusing the installed spec when present
(e.g. `text.echi@^0.1` → `text.echo@^0.1`).
New public surface:
- `AddModuleSourceFix` + `AddModuleSourceCallback`
- `FlowEditorPage.storeCapabilities` + `onAddModuleSource`
- `FlowAnalyzer.stepSeverity` + the `kInputsNodeId` /
`kOutputsNodeId` sentinels
- `FlowIssueSeverity` enum + `FlowNode.issueSeverity`
Tests:
- Install fix only when in store
- AddModuleSourceFix as fallback when not in store
- Did-you-mean replaces install when a near miss exists
- stepSeverity populated for both step ids and pseudo-nodes
All 36 editor tests green. Bumped to 0.18.0.
Signed-off-by: flemming-it <sf@flemming.it>
This commit is contained in:
parent
f43c1ac6cf
commit
efdfa7dd79
10 changed files with 497 additions and 45 deletions
|
|
@ -23,12 +23,28 @@ import 'package:yaml/yaml.dart';
|
|||
import 'quick_fix.dart';
|
||||
import 'wire_colors.dart';
|
||||
|
||||
/// Reserved step-id sentinels the analyzer uses for issues that
|
||||
/// live in the YAML's pseudo-nodes (`inputs:` and `outputs:`
|
||||
/// blocks), so the graph canvas can highlight those nodes the
|
||||
/// same way it highlights a real step.
|
||||
const String kInputsNodeId = '__inputs__';
|
||||
const String kOutputsNodeId = '__outputs__';
|
||||
|
||||
class FlowAnalyzer extends AbstractAnalyzer {
|
||||
/// Returns the names of capabilities the operator has installed.
|
||||
/// A closure so the analyzer always sees the current list
|
||||
/// without needing a re-create on every Studio rebuild.
|
||||
/// Returns the names of capabilities the operator has
|
||||
/// installed. A closure so the analyzer always sees the
|
||||
/// current list without needing a re-create on every Studio
|
||||
/// rebuild.
|
||||
final List<String> Function() availableCapabilities;
|
||||
|
||||
/// Returns the names of capabilities the public store knows
|
||||
/// how to install. Used to decide whether an unknown-cap
|
||||
/// issue should carry an Install button — clicking the
|
||||
/// button on a capability the hub can't actually fetch would
|
||||
/// just fail. Null = "no store available" → install offered
|
||||
/// for every unknown cap (legacy behaviour).
|
||||
final List<String> Function()? storeCapabilities;
|
||||
|
||||
/// Quick fixes attached to the most-recent analyze() pass.
|
||||
/// Keyed by the same `Issue` instances that landed in
|
||||
/// `analysisResult.issues`; consumers look up fixes per issue
|
||||
|
|
@ -36,16 +52,46 @@ class FlowAnalyzer extends AbstractAnalyzer {
|
|||
/// disambiguated.
|
||||
final Map<Issue, List<QuickFix>> _fixesByIssue = {};
|
||||
|
||||
FlowAnalyzer({required this.availableCapabilities});
|
||||
/// Worst-severity per step / pseudo-node from the most-recent
|
||||
/// pass. The graph canvas reads this to decide which nodes
|
||||
/// pulse red (error) / amber (warning). Pseudo-nodes for
|
||||
/// `inputs:` and `outputs:` use [kInputsNodeId] /
|
||||
/// [kOutputsNodeId] sentinels.
|
||||
final Map<String, IssueType> _stepSeverity = {};
|
||||
|
||||
FlowAnalyzer({
|
||||
required this.availableCapabilities,
|
||||
this.storeCapabilities,
|
||||
});
|
||||
|
||||
/// Read-only view of the fixes computed during the last
|
||||
/// analyze(). Returns an empty list when no fix is known.
|
||||
List<QuickFix> fixesFor(Issue issue) =>
|
||||
List.unmodifiable(_fixesByIssue[issue] ?? const []);
|
||||
|
||||
/// Worst-severity (`error` beats `warning` beats `info`)
|
||||
/// recorded per step / pseudo-node during the last analyze().
|
||||
/// Caller treats absence as "no issue".
|
||||
Map<String, IssueType> get stepSeverity =>
|
||||
Map.unmodifiable(_stepSeverity);
|
||||
|
||||
void _bumpSeverity(String stepId, IssueType type) {
|
||||
final current = _stepSeverity[stepId];
|
||||
if (current == null || _severityRank(type) > _severityRank(current)) {
|
||||
_stepSeverity[stepId] = type;
|
||||
}
|
||||
}
|
||||
|
||||
static int _severityRank(IssueType t) => switch (t) {
|
||||
IssueType.error => 2,
|
||||
IssueType.warning => 1,
|
||||
IssueType.info => 0,
|
||||
};
|
||||
|
||||
@override
|
||||
Future<AnalysisResult> analyze(Code code) async {
|
||||
_fixesByIssue.clear();
|
||||
_stepSeverity.clear();
|
||||
final issues = <Issue>[];
|
||||
final text = code.text;
|
||||
if (text.trim().isEmpty) {
|
||||
|
|
@ -54,6 +100,13 @@ class FlowAnalyzer extends AbstractAnalyzer {
|
|||
|
||||
final caps = availableCapabilities();
|
||||
final knownBareCaps = caps.map(_bareCap).toSet();
|
||||
// Use both bare names AND fully-qualified `<bare>@<version>`
|
||||
// as Did-you-mean candidates so the suggestion can preserve
|
||||
// the version constraint when the user already typed one.
|
||||
final installedFull = caps.toSet();
|
||||
final storeCaps =
|
||||
storeCapabilities?.call() ?? const <String>[];
|
||||
final storeBare = storeCaps.map(_bareCap).toSet();
|
||||
|
||||
YamlNode? doc;
|
||||
try {
|
||||
|
|
@ -85,32 +138,132 @@ class FlowAnalyzer extends AbstractAnalyzer {
|
|||
if (useValue is! String) continue;
|
||||
if (knownBareCaps.isEmpty) continue;
|
||||
if (!knownBareCaps.contains(_bareCap(useValue))) {
|
||||
final issueLine = useNode.span.start.line;
|
||||
final stepId = _stepIdFor(step);
|
||||
final inStore = storeBare.contains(_bareCap(useValue));
|
||||
// Did-you-mean candidate: scan installed + store bare
|
||||
// caps for the closest spelling within edit-distance 2.
|
||||
// Wins over the install button when present — typo
|
||||
// fixes are cheaper than network round-trips.
|
||||
// Distance-0 (same word) is never a suggestion —
|
||||
// that case means the bare cap IS in the store
|
||||
// (just not installed yet), so the Install button
|
||||
// is the right call and the Did-you-mean would
|
||||
// confusingly say "use the thing you typed".
|
||||
final didYouMean = _closestCapability(
|
||||
_bareCap(useValue),
|
||||
{...knownBareCaps, ...storeBare}
|
||||
.where((c) => c != _bareCap(useValue))
|
||||
.toSet(),
|
||||
);
|
||||
|
||||
// Tailor the message: "unknown" reads identical in
|
||||
// both cases, but the recovery line nudges toward
|
||||
// the right action.
|
||||
final message = inStore
|
||||
? 'Unknown capability "$useValue". '
|
||||
'Install via the Fix button or check the spelling.'
|
||||
: didYouMean != null
|
||||
? 'Unknown capability "$useValue". '
|
||||
'Did you mean "$didYouMean"?'
|
||||
: 'Unknown capability "$useValue". '
|
||||
'Not in the store — install locally with '
|
||||
'`fai install --link <path>` or check the spelling.';
|
||||
final issue = Issue(
|
||||
line: useNode.span.start.line,
|
||||
message:
|
||||
'Unknown capability "$useValue". '
|
||||
'Install the module that provides it, or check the spelling.',
|
||||
line: issueLine,
|
||||
message: message,
|
||||
type: IssueType.error,
|
||||
);
|
||||
issues.add(issue);
|
||||
_fixesByIssue[issue] = [
|
||||
InstallCapabilityFix(
|
||||
capability: useValue,
|
||||
label: 'Install $useValue',
|
||||
),
|
||||
];
|
||||
if (stepId != null) {
|
||||
_bumpSeverity(stepId, IssueType.error);
|
||||
}
|
||||
|
||||
final fixes = <QuickFix>[];
|
||||
if (didYouMean != null) {
|
||||
// Try to preserve the version constraint the user
|
||||
// already typed.
|
||||
final versionTail = _versionTail(useValue);
|
||||
final fullReplacement = installedFull.firstWhere(
|
||||
(c) => _bareCap(c) == didYouMean,
|
||||
orElse: () => '$didYouMean$versionTail',
|
||||
);
|
||||
fixes.add(
|
||||
ReplaceLineValueFix(
|
||||
line: issueLine,
|
||||
replacement: fullReplacement,
|
||||
label: 'Use "$fullReplacement"',
|
||||
),
|
||||
);
|
||||
}
|
||||
if (inStore) {
|
||||
fixes.add(
|
||||
InstallCapabilityFix(
|
||||
capability: useValue,
|
||||
label: 'Install $useValue',
|
||||
),
|
||||
);
|
||||
} else if (didYouMean == null) {
|
||||
// Not in store + no near-miss spelling — give the
|
||||
// operator a path to register the module they
|
||||
// actually have (local clone, internal URL, …).
|
||||
// Hidden behind the "Did you mean" suggestion when
|
||||
// present so the suggested fix stays the primary
|
||||
// action.
|
||||
fixes.add(
|
||||
AddModuleSourceFix(
|
||||
capability: useValue,
|
||||
label: 'Add source for $useValue…',
|
||||
),
|
||||
);
|
||||
}
|
||||
if (fixes.isNotEmpty) {
|
||||
_fixesByIssue[issue] = fixes;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_checkFieldTypes(doc['inputs'], 'input', issues);
|
||||
_checkFieldTypes(doc['outputs'], 'output', issues);
|
||||
_checkFieldTypes(
|
||||
doc['inputs'],
|
||||
'input',
|
||||
issues,
|
||||
nodeId: kInputsNodeId,
|
||||
);
|
||||
_checkFieldTypes(
|
||||
doc['outputs'],
|
||||
'output',
|
||||
issues,
|
||||
nodeId: kOutputsNodeId,
|
||||
);
|
||||
}
|
||||
|
||||
return AnalysisResult(issues: issues);
|
||||
}
|
||||
|
||||
void _checkFieldTypes(YamlNode? block, String kind, List<Issue> out) {
|
||||
/// Resolve the step's `id` field as a string, or null when
|
||||
/// the step has none (the analyzer doesn't generate
|
||||
/// synthetic ids — a step without an id is itself a
|
||||
/// validation issue handled elsewhere).
|
||||
String? _stepIdFor(YamlMap step) {
|
||||
final id = step.nodes['id']?.value;
|
||||
return id is String ? id : null;
|
||||
}
|
||||
|
||||
/// Strip the leading `bareCap` so we keep just the
|
||||
/// `@<version>` part. Caller stitches it back onto the
|
||||
/// suggested replacement.
|
||||
String _versionTail(String full) {
|
||||
final at = full.indexOf('@');
|
||||
return at < 0 ? '' : full.substring(at);
|
||||
}
|
||||
|
||||
void _checkFieldTypes(
|
||||
YamlNode? block,
|
||||
String kind,
|
||||
List<Issue> out, {
|
||||
required String nodeId,
|
||||
}) {
|
||||
if (block is! YamlMap) return;
|
||||
for (final entry in block.nodes.entries) {
|
||||
final value = entry.value.value;
|
||||
|
|
@ -131,6 +284,7 @@ class FlowAnalyzer extends AbstractAnalyzer {
|
|||
type: IssueType.warning,
|
||||
);
|
||||
out.add(issue);
|
||||
_bumpSeverity(nodeId, IssueType.warning);
|
||||
// If the typo is within edit-distance two of a known
|
||||
// type token, offer a one-click replace as the primary
|
||||
// fix. The strip and tooltip render this as a button.
|
||||
|
|
@ -149,6 +303,23 @@ class FlowAnalyzer extends AbstractAnalyzer {
|
|||
}
|
||||
}
|
||||
|
||||
/// Find the closest capability in [candidates] within
|
||||
/// Levenshtein distance 2. Returns null when nothing is close
|
||||
/// enough — matches the same threshold the type-token nudge
|
||||
/// uses.
|
||||
String? _closestCapability(String input, Set<String> candidates) {
|
||||
String? best;
|
||||
int bestDistance = 3;
|
||||
for (final c in candidates) {
|
||||
final d = _levenshtein(input, c);
|
||||
if (d < bestDistance) {
|
||||
bestDistance = d;
|
||||
best = c;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/// Find the closest type in [kKnownTypes] within Levenshtein
|
||||
/// distance 2. Returns `null` when nothing is close enough —
|
||||
/// "totally made up word" should leave the operator picking
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue