From efdfa7dd795a246f28fa6524d1bce67126d9042a Mon Sep 17 00:00:00 2001 From: flemming-it Date: Tue, 9 Jun 2026 00:21:48 +0200 Subject: [PATCH] feat(editor): graph-pulse on errors + store-aware install / add-source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- lib/fai_studio_flow_editor.dart | 2 + lib/src/editor_controller.dart | 7 + lib/src/flow_analyzer.dart | 205 +++++++++++++++++++++++++++--- lib/src/flow_editor_page.dart | 39 +++++- lib/src/flow_yaml_controller.dart | 44 +++++-- lib/src/quick_fix.dart | 31 ++++- lib/src/widgets/flow_canvas.dart | 37 ++++-- lib/src/widgets/flow_node.dart | 109 +++++++++++++++- pubspec.yaml | 2 +- test/flow_analyzer_test.dart | 66 +++++++++- 10 files changed, 497 insertions(+), 45 deletions(-) diff --git a/lib/fai_studio_flow_editor.dart b/lib/fai_studio_flow_editor.dart index 21d99e7..e824140 100644 --- a/lib/fai_studio_flow_editor.dart +++ b/lib/fai_studio_flow_editor.dart @@ -29,8 +29,10 @@ export 'src/l10n.dart' show FlowEditorLocale; export 'src/quick_fix.dart' show InstallCapabilityCallback, + AddModuleSourceCallback, QuickFix, InstallCapabilityFix, + AddModuleSourceFix, ReplaceLineValueFix, IssueHoverRequest, IssueHoverSeverity; diff --git a/lib/src/editor_controller.dart b/lib/src/editor_controller.dart index fb80dfc..8bcb11d 100644 --- a/lib/src/editor_controller.dart +++ b/lib/src/editor_controller.dart @@ -17,6 +17,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:flutter_code_editor/flutter_code_editor.dart' show IssueType; import 'flow_yaml_controller.dart'; import 'model/auto_layout.dart'; @@ -88,6 +89,12 @@ class FlowEditorController extends ChangeNotifier { codeController.addListener(_onCodeChanged); } + /// Worst analyzer severity per step / pseudo-node, populated + /// during the last analyzer pass. The canvas reads this to + /// decide which step nodes pulse red / amber. + Map get stepIssueSeverity => + codeController.stepSeverity; + @override void dispose() { _reparseTimer?.cancel(); diff --git a/lib/src/flow_analyzer.dart b/lib/src/flow_analyzer.dart index c0bb81c..c7932e3 100644 --- a/lib/src/flow_analyzer.dart +++ b/lib/src/flow_analyzer.dart @@ -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 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 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> _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 _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 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 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 analyze(Code code) async { _fixesByIssue.clear(); + _stepSeverity.clear(); final issues = []; 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 `@` + // 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 []; + 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 ` 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 = []; + 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 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 + /// `@` 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 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 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 diff --git a/lib/src/flow_editor_page.dart b/lib/src/flow_editor_page.dart index bc59eb3..40e1672 100644 --- a/lib/src/flow_editor_page.dart +++ b/lib/src/flow_editor_page.dart @@ -85,6 +85,21 @@ class FlowEditorPage extends StatefulWidget { /// action (the diagnostic remains visible without it). final InstallCapabilityCallback? onInstallCapability; + /// Host-side "register a new module source" handler. Invoked + /// when the operator clicks "Add source for …" on + /// an unknown-capability issue that isn't in the public store. + /// Same callback contract as [onInstallCapability]; the host + /// is expected to prompt the operator for a path / URL and + /// then call the Hub install API. + final AddModuleSourceCallback? onAddModuleSource; + + /// Capabilities the public store knows how to install. The + /// analyzer uses this to decide whether to show "Install …" + /// (in store) or "Add source for …" (not in store) as the + /// quick-fix on an unknown `use:` line. Empty list = store + /// silent — no install button offered. + final List storeCapabilities; + const FlowEditorPage({ super.key, this.initialFlowName, @@ -93,6 +108,8 @@ class FlowEditorPage extends StatefulWidget { this.availableCapabilities = const [], this.style, this.onInstallCapability, + this.onAddModuleSource, + this.storeCapabilities = const [], }); @override @@ -117,8 +134,9 @@ class _FlowEditorPageState extends State super.initState(); _l = FlowEditorStrings(widget.locale); _controller = FlowEditorController(); - _controller.codeController.setAvailableCapabilities( - () => widget.availableCapabilities, + _controller.codeController.setCapabilityProviders( + available: () => widget.availableCapabilities, + store: () => widget.storeCapabilities, ); _controller.addListener(_onCtrlChanged); _controller.codeController.hoverRequest.addListener(_onHoverChanged); @@ -184,7 +202,22 @@ class _FlowEditorPageState extends State if (handler == null) return; final newCaps = await handler(capability); if (newCaps != null && mounted) { - _controller.codeController.setAvailableCapabilities(() => newCaps); + _controller.codeController.setCapabilityProviders( + available: () => newCaps, + store: () => widget.storeCapabilities, + ); + await _controller.codeController.reanalyze(); + } + break; + case AddModuleSourceFix(:final capability): + final handler = widget.onAddModuleSource; + if (handler == null) return; + final newCaps = await handler(capability); + if (newCaps != null && mounted) { + _controller.codeController.setCapabilityProviders( + available: () => newCaps, + store: () => widget.storeCapabilities, + ); await _controller.codeController.reanalyze(); } break; diff --git a/lib/src/flow_yaml_controller.dart b/lib/src/flow_yaml_controller.dart index 37e1253..734ac2d 100644 --- a/lib/src/flow_yaml_controller.dart +++ b/lib/src/flow_yaml_controller.dart @@ -27,15 +27,41 @@ import 'quick_fix.dart'; import 'wire_colors.dart'; class FlowYamlCodeController extends CodeController { - FlowYamlCodeController({List Function()? availableCapabilities}) - : super( - text: '', - language: yaml, - analyzer: FlowAnalyzer( - availableCapabilities: - availableCapabilities ?? (() => const []), - ), - ); + FlowYamlCodeController({ + List Function()? availableCapabilities, + List Function()? storeCapabilities, + }) : super( + text: '', + language: yaml, + analyzer: FlowAnalyzer( + availableCapabilities: + availableCapabilities ?? (() => const []), + storeCapabilities: storeCapabilities, + ), + ); + + /// Set both capability providers in one go. Re-creating the + /// analyzer is the simplest way to flip both closures without + /// risking a stale storeCapabilities reference inside an old + /// FlowAnalyzer instance. + void setCapabilityProviders({ + required List Function() available, + List Function()? store, + }) { + analyzer = FlowAnalyzer( + availableCapabilities: available, + storeCapabilities: store, + ); + } + + /// Worst-severity per step / pseudo-node from the analyzer's + /// last pass. Graph canvas reads this to decide which step + /// nodes pulse red / amber. Returns an empty map when the + /// active analyzer isn't a [FlowAnalyzer]. + Map get stepSeverity { + final a = analyzer; + return a is FlowAnalyzer ? a.stepSeverity : const {}; + } /// Published when the pointer enters a wavy-underlined range. /// The editor host (FlowEditorPage) listens and renders an diff --git a/lib/src/quick_fix.dart b/lib/src/quick_fix.dart index c33a21c..c23bc91 100644 --- a/lib/src/quick_fix.dart +++ b/lib/src/quick_fix.dart @@ -50,7 +50,7 @@ class ReplaceLineValueFix extends QuickFix { } /// Ask the host (Studio) to install the named capability via -/// the Hub. The editor delegates to the +/// the Hub's store-backed install. The editor delegates to the /// [InstallCapabilityCallback] passed into `FlowEditorPage` /// and reanalyzes the document once the host returns. @immutable @@ -66,6 +66,28 @@ class InstallCapabilityFix extends QuickFix { }) : super(label); } +/// Ask the host to register a new module source for an unknown +/// capability — used when the capability isn't in the public +/// store. The host's handler typically prompts the operator +/// for a local path (`fai install --link`) or a URL +/// (`fai install `), then installs and reanalyzes. +/// +/// This is the recovery path for private modules: the public +/// store doesn't know about `htw.digiscout/onet-lookup`, but +/// the operator can point the hub at the local clone. +@immutable +class AddModuleSourceFix extends QuickFix { + /// The capability the operator wrote — the host uses it to + /// pre-fill its prompt ("Where can `` be + /// installed from?"). + final String capability; + + const AddModuleSourceFix({ + required this.capability, + required String label, + }) : super(label); +} + /// Host-side install handler signature. Returns the new /// installed-capability list after the install completes (used /// by the editor to refresh its analyzer without round-tripping @@ -75,6 +97,13 @@ class InstallCapabilityFix extends QuickFix { typedef InstallCapabilityCallback = Future?> Function(String capability); +/// Host-side "add module source" handler. Receives the bare +/// capability spec the operator typed; expected to prompt the +/// operator for a local path / URL and then install it. +/// Same return contract as [InstallCapabilityCallback]. +typedef AddModuleSourceCallback = + Future?> Function(String capability); + /// Hover-tooltip request — the controller emits one of these /// via a ValueNotifier when the pointer enters an issue range, /// and clears it when the pointer leaves both the range and the diff --git a/lib/src/widgets/flow_canvas.dart b/lib/src/widgets/flow_canvas.dart index 9303a7e..a75122e 100644 --- a/lib/src/widgets/flow_canvas.dart +++ b/lib/src/widgets/flow_canvas.dart @@ -33,6 +33,7 @@ // InteractiveViewer. import 'package:flutter/material.dart'; +import 'package:flutter_code_editor/flutter_code_editor.dart' show IssueType; import '../editor_controller.dart'; import '../editor_style.dart'; @@ -875,6 +876,13 @@ class _FlowCanvasState extends State outputLabels.length, ); final cardWidth = _stepWidth(step); + // Analyzer severity for this step — if its YAML carries an + // unknown-capability error or a typed-input warning, we + // tag the node so FlowNode renders a pulsing halo. The + // running pulse takes priority when both are active; the + // node never combines run-glow + error-halo on the same + // surface (would mush the colour). + final issueSeverity = _issueSeverityFor(step.id); return Positioned( left: pos.x, top: pos.y, @@ -891,14 +899,12 @@ class _FlowCanvasState extends State selected: selected, status: status, elevated: _style.nodeShadows, - // Breathing-pulse on running steps when the active - // style allows flow animation. The canvas's existing - // _flowAnim ticks 0..1 during runs and is gated on - // the same accessibility / style preferences, so we - // can route it straight through. pulse: _style.flowAnimation && status == FlowNodeStatus.running ? _flowAnim : null, + issueSeverity: status == FlowNodeStatus.running + ? null + : issueSeverity, onTap: () => widget.controller.selectStep(step.id), onDrag: (delta) => _applyDrag(step.id, pos, delta, cardHeight), onContextMenu: (globalPos) => _showStepContextMenu(step, globalPos), @@ -930,10 +936,11 @@ class _FlowCanvasState extends State portSide: portSide, inputPortLabels: labels, selected: selected, - // Endpoints are selectable too — selecting opens - // the inputs / outputs editor in the properties - // panel so the operator can rename, retype, or add - // entries graphically instead of editing YAML. + // Endpoints carry the same issue-halo treatment as + // step nodes — the analyzer uses `__inputs__` / + // `__outputs__` sentinel ids so an `Unknown input type` + // warning lights up the matching endpoint card. + issueSeverity: _issueSeverityFor(nodeId), onTap: () => widget.controller.selectStep(nodeId), onDrag: (delta) => _applyDrag( nodeId, @@ -945,6 +952,18 @@ class _FlowCanvasState extends State ); } + /// Map the controller's per-step issue severity (from the + /// YAML analyzer) onto the FlowNode's enum. Returns null + /// when the step has no issues so the node renders normally. + FlowIssueSeverity? _issueSeverityFor(String stepId) { + final raw = widget.controller.stepIssueSeverity[stepId]; + return switch (raw) { + IssueType.error => FlowIssueSeverity.error, + IssueType.warning => FlowIssueSeverity.warning, + _ => null, + }; + } + /// Single drag entry point used by every node on the /// canvas. Converts a screen-space delta to canvas-space /// (via the current InteractiveViewer scale), clamps the diff --git a/lib/src/widgets/flow_node.dart b/lib/src/widgets/flow_node.dart index 3bb15be..ef2d42e 100644 --- a/lib/src/widgets/flow_node.dart +++ b/lib/src/widgets/flow_node.dart @@ -154,6 +154,12 @@ enum NodeVisualKind { module, approval, inputs, outputs } /// endpoint OUT to downstream steps). enum NodePortSide { left, right } +/// Severity tag passed to [FlowNode.issueSeverity]. Mirrors +/// flutter_code_editor's `IssueType` but lives in the editor +/// package so flow_node.dart doesn't pull the code-editor +/// transitively into widget tests that don't need it. +enum FlowIssueSeverity { error, warning } + class FlowNode extends StatelessWidget { final String id; final String title; @@ -224,6 +230,14 @@ class FlowNode extends StatelessWidget { /// no pulse is rendered. final Listenable? pulse; + /// When non-null, the node renders a permanently pulsing + /// halo in the appropriate severity colour — red for an + /// analyzer-flagged error, amber for a warning — so the + /// operator scanning the canvas immediately sees which step + /// the YAML linter is unhappy about, without switching to + /// the text tab. + final FlowIssueSeverity? issueSeverity; + const FlowNode({ super.key, required this.id, @@ -244,6 +258,7 @@ class FlowNode extends StatelessWidget { this.onContextMenu, this.elevated = true, this.pulse, + this.issueSeverity, this.width = NodeGeometry.width, }); @@ -268,10 +283,6 @@ class FlowNode extends StatelessWidget { child: AnimatedBuilder( animation: pulse!, builder: (context, child) { - // Derive a 0..1 breath value from the controller. - // Use a sine wave so the glow eases in + out - // rather than ramping linearly — reads as - // "breathing" instead of "flickering". final t = (pulse! is Animation) ? (pulse! as Animation).value : 0.0; @@ -294,6 +305,19 @@ class FlowNode extends StatelessWidget { ), ); } + if (issueSeverity != null) { + // Always-on breathing halo in the severity colour. Owns + // its own AnimationController so it ticks even when no + // step is running (no shared pulse listenable upstream). + return SizedBox( + width: width, + height: height, + child: _IssueHalo( + severity: issueSeverity!, + child: cardWidget, + ), + ); + } return SizedBox(width: width, height: height, child: cardWidget); } @@ -592,3 +616,80 @@ NodeVisualKind kindForStep(FlowStep step) { if (step.isApproval) return NodeVisualKind.approval; return NodeVisualKind.module; } + +/// Permanently breathing halo painted around a node carrying +/// an analyzer issue. Owns its own AnimationController so it +/// ticks regardless of the canvas's run-state. Halo colour +/// matches severity — error red or warning amber. Honours +/// `MediaQuery.disableAnimations` so reduce-motion operators +/// still get a static halo without the breathing oscillation. +class _IssueHalo extends StatefulWidget { + final FlowIssueSeverity severity; + final Widget child; + const _IssueHalo({required this.severity, required this.child}); + + @override + State<_IssueHalo> createState() => _IssueHaloState(); +} + +class _IssueHaloState extends State<_IssueHalo> + with SingleTickerProviderStateMixin { + late final AnimationController _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1600), + )..repeat(); + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + Color _tone(BuildContext context) { + final theme = Theme.of(context); + return widget.severity == FlowIssueSeverity.error + ? theme.colorScheme.error + : const Color(0xFFEF6C00); + } + + @override + Widget build(BuildContext context) { + final tone = _tone(context); + final reduceMotion = MediaQuery.disableAnimationsOf(context); + if (reduceMotion) { + return DecoratedBox( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(FaiRadius.md), + boxShadow: [ + BoxShadow( + color: tone.withValues(alpha: 0.35), + blurRadius: 18, + spreadRadius: 1.5, + ), + ], + ), + child: widget.child, + ); + } + return AnimatedBuilder( + animation: _controller, + builder: (context, child) { + final breath = 0.5 + 0.5 * math.sin(_controller.value * 2 * math.pi); + return DecoratedBox( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(FaiRadius.md), + boxShadow: [ + BoxShadow( + color: tone.withValues(alpha: 0.22 + 0.30 * breath), + blurRadius: 16 + 8 * breath, + spreadRadius: 0.5 + 2.0 * breath, + ), + ], + ), + child: child, + ); + }, + child: widget.child, + ); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index 341a75e..dc5e6b2 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: fai_studio_flow_editor description: Swappable inline YAML editor for F∆I Studio flows. -version: 0.17.0 +version: 0.18.0 publish_to: 'none' repository: https://git.flemming.ai/fai/studio-flow-editor diff --git a/test/flow_analyzer_test.dart b/test/flow_analyzer_test.dart index a9c1c13..8732e55 100644 --- a/test/flow_analyzer_test.dart +++ b/test/flow_analyzer_test.dart @@ -105,10 +105,11 @@ steps: expect(r.issues, isEmpty); }); - test('attaches InstallCapabilityFix to unknown-capability issue', + test('attaches InstallCapabilityFix when the capability is in the store', () async { final a = FlowAnalyzer( availableCapabilities: () => const ['debug.echo'], + storeCapabilities: () => const ['text.classify'], ); final r = await a.analyze(_wrap(''' name: x @@ -124,6 +125,69 @@ steps: expect((fix as InstallCapabilityFix).capability, 'text.classify@^1'); }); + test('falls back to AddModuleSourceFix when unknown and not in store', + () async { + final a = FlowAnalyzer( + availableCapabilities: () => const ['debug.echo'], + storeCapabilities: () => const ['text.classify'], + ); + final r = await a.analyze(_wrap(''' +name: x +steps: + - id: c + use: htw.private/secret@^0.1 +''')); + expect(r.issues, hasLength(1)); + final fixes = a.fixesFor(r.issues.first); + expect(fixes, hasLength(1)); + expect(fixes.first, isA()); + expect(r.issues.first.message, contains('Not in the store')); + }); + + test('did-you-mean wins over install/add-source when a near miss exists', + () async { + // Installed bare `text.echo`; user typed `text.echi` (edit-distance 1). + // The fix list should carry the Replace-as-primary; no install or + // add-source needed because the analyzer already found the answer. + final a = FlowAnalyzer( + availableCapabilities: () => const ['text.echo@^0.1'], + storeCapabilities: () => const [], + ); + final r = await a.analyze(_wrap(''' +name: x +steps: + - id: t + use: text.echi@^0.1 +''')); + expect(r.issues, hasLength(1)); + final fixes = a.fixesFor(r.issues.first); + expect(fixes.first, isA()); + // Replacement preserves the version constraint by reusing the + // installed entry's `@^0.1`. + expect( + (fixes.first as ReplaceLineValueFix).replacement, + 'text.echo@^0.1', + ); + }); + + test('records worst severity per step for canvas highlighting', + () async { + final a = FlowAnalyzer( + availableCapabilities: () => const ['debug.echo'], + storeCapabilities: () => const [], + ); + await a.analyze(_wrap(''' +name: x +inputs: + pdf: byes +steps: + - id: bad + use: nope@^1 +''')); + expect(a.stepSeverity['bad'], IssueType.error); + expect(a.stepSeverity[kInputsNodeId], IssueType.warning); + }); + test('suggests closest valid type for a misspelled value', () async { final a = FlowAnalyzer(availableCapabilities: () => const []); final r = await a.analyze(_wrap('''