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:
flemming-it 2026-06-09 00:21:48 +02:00
parent f43c1ac6cf
commit efdfa7dd79
10 changed files with 497 additions and 45 deletions

View file

@ -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<FlowCanvas>
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<FlowCanvas>
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<FlowCanvas>
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<FlowCanvas>
);
}
/// 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

View file

@ -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<double>)
? (pulse! as Animation<double>).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,
);
}
}