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

@ -29,8 +29,10 @@ export 'src/l10n.dart' show FlowEditorLocale;
export 'src/quick_fix.dart' export 'src/quick_fix.dart'
show show
InstallCapabilityCallback, InstallCapabilityCallback,
AddModuleSourceCallback,
QuickFix, QuickFix,
InstallCapabilityFix, InstallCapabilityFix,
AddModuleSourceFix,
ReplaceLineValueFix, ReplaceLineValueFix,
IssueHoverRequest, IssueHoverRequest,
IssueHoverSeverity; IssueHoverSeverity;

View file

@ -17,6 +17,7 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_code_editor/flutter_code_editor.dart' show IssueType;
import 'flow_yaml_controller.dart'; import 'flow_yaml_controller.dart';
import 'model/auto_layout.dart'; import 'model/auto_layout.dart';
@ -88,6 +89,12 @@ class FlowEditorController extends ChangeNotifier {
codeController.addListener(_onCodeChanged); 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<String, IssueType> get stepIssueSeverity =>
codeController.stepSeverity;
@override @override
void dispose() { void dispose() {
_reparseTimer?.cancel(); _reparseTimer?.cancel();

View file

@ -23,12 +23,28 @@ import 'package:yaml/yaml.dart';
import 'quick_fix.dart'; import 'quick_fix.dart';
import 'wire_colors.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 { class FlowAnalyzer extends AbstractAnalyzer {
/// Returns the names of capabilities the operator has installed. /// Returns the names of capabilities the operator has
/// A closure so the analyzer always sees the current list /// installed. A closure so the analyzer always sees the
/// without needing a re-create on every Studio rebuild. /// current list without needing a re-create on every Studio
/// rebuild.
final List<String> Function() availableCapabilities; 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. /// Quick fixes attached to the most-recent analyze() pass.
/// Keyed by the same `Issue` instances that landed in /// Keyed by the same `Issue` instances that landed in
/// `analysisResult.issues`; consumers look up fixes per issue /// `analysisResult.issues`; consumers look up fixes per issue
@ -36,16 +52,46 @@ class FlowAnalyzer extends AbstractAnalyzer {
/// disambiguated. /// disambiguated.
final Map<Issue, List<QuickFix>> _fixesByIssue = {}; 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 /// Read-only view of the fixes computed during the last
/// analyze(). Returns an empty list when no fix is known. /// analyze(). Returns an empty list when no fix is known.
List<QuickFix> fixesFor(Issue issue) => List<QuickFix> fixesFor(Issue issue) =>
List.unmodifiable(_fixesByIssue[issue] ?? const []); 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 @override
Future<AnalysisResult> analyze(Code code) async { Future<AnalysisResult> analyze(Code code) async {
_fixesByIssue.clear(); _fixesByIssue.clear();
_stepSeverity.clear();
final issues = <Issue>[]; final issues = <Issue>[];
final text = code.text; final text = code.text;
if (text.trim().isEmpty) { if (text.trim().isEmpty) {
@ -54,6 +100,13 @@ class FlowAnalyzer extends AbstractAnalyzer {
final caps = availableCapabilities(); final caps = availableCapabilities();
final knownBareCaps = caps.map(_bareCap).toSet(); 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; YamlNode? doc;
try { try {
@ -85,32 +138,132 @@ class FlowAnalyzer extends AbstractAnalyzer {
if (useValue is! String) continue; if (useValue is! String) continue;
if (knownBareCaps.isEmpty) continue; if (knownBareCaps.isEmpty) continue;
if (!knownBareCaps.contains(_bareCap(useValue))) { 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( final issue = Issue(
line: useNode.span.start.line, line: issueLine,
message: message: message,
'Unknown capability "$useValue". '
'Install the module that provides it, or check the spelling.',
type: IssueType.error, type: IssueType.error,
); );
issues.add(issue); issues.add(issue);
_fixesByIssue[issue] = [ 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( InstallCapabilityFix(
capability: useValue, capability: useValue,
label: 'Install $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(
_checkFieldTypes(doc['outputs'], 'output', issues); doc['inputs'],
'input',
issues,
nodeId: kInputsNodeId,
);
_checkFieldTypes(
doc['outputs'],
'output',
issues,
nodeId: kOutputsNodeId,
);
} }
return AnalysisResult(issues: issues); 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; if (block is! YamlMap) return;
for (final entry in block.nodes.entries) { for (final entry in block.nodes.entries) {
final value = entry.value.value; final value = entry.value.value;
@ -131,6 +284,7 @@ class FlowAnalyzer extends AbstractAnalyzer {
type: IssueType.warning, type: IssueType.warning,
); );
out.add(issue); out.add(issue);
_bumpSeverity(nodeId, IssueType.warning);
// If the typo is within edit-distance two of a known // If the typo is within edit-distance two of a known
// type token, offer a one-click replace as the primary // type token, offer a one-click replace as the primary
// fix. The strip and tooltip render this as a button. // 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 /// Find the closest type in [kKnownTypes] within Levenshtein
/// distance 2. Returns `null` when nothing is close enough /// distance 2. Returns `null` when nothing is close enough
/// "totally made up word" should leave the operator picking /// "totally made up word" should leave the operator picking

View file

@ -85,6 +85,21 @@ class FlowEditorPage extends StatefulWidget {
/// action (the diagnostic remains visible without it). /// action (the diagnostic remains visible without it).
final InstallCapabilityCallback? onInstallCapability; final InstallCapabilityCallback? onInstallCapability;
/// Host-side "register a new module source" handler. Invoked
/// when the operator clicks "Add source for <capability>…" 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<String> storeCapabilities;
const FlowEditorPage({ const FlowEditorPage({
super.key, super.key,
this.initialFlowName, this.initialFlowName,
@ -93,6 +108,8 @@ class FlowEditorPage extends StatefulWidget {
this.availableCapabilities = const [], this.availableCapabilities = const [],
this.style, this.style,
this.onInstallCapability, this.onInstallCapability,
this.onAddModuleSource,
this.storeCapabilities = const [],
}); });
@override @override
@ -117,8 +134,9 @@ class _FlowEditorPageState extends State<FlowEditorPage>
super.initState(); super.initState();
_l = FlowEditorStrings(widget.locale); _l = FlowEditorStrings(widget.locale);
_controller = FlowEditorController(); _controller = FlowEditorController();
_controller.codeController.setAvailableCapabilities( _controller.codeController.setCapabilityProviders(
() => widget.availableCapabilities, available: () => widget.availableCapabilities,
store: () => widget.storeCapabilities,
); );
_controller.addListener(_onCtrlChanged); _controller.addListener(_onCtrlChanged);
_controller.codeController.hoverRequest.addListener(_onHoverChanged); _controller.codeController.hoverRequest.addListener(_onHoverChanged);
@ -184,7 +202,22 @@ class _FlowEditorPageState extends State<FlowEditorPage>
if (handler == null) return; if (handler == null) return;
final newCaps = await handler(capability); final newCaps = await handler(capability);
if (newCaps != null && mounted) { 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(); await _controller.codeController.reanalyze();
} }
break; break;

View file

@ -27,16 +27,42 @@ import 'quick_fix.dart';
import 'wire_colors.dart'; import 'wire_colors.dart';
class FlowYamlCodeController extends CodeController { class FlowYamlCodeController extends CodeController {
FlowYamlCodeController({List<String> Function()? availableCapabilities}) FlowYamlCodeController({
: super( List<String> Function()? availableCapabilities,
List<String> Function()? storeCapabilities,
}) : super(
text: '', text: '',
language: yaml, language: yaml,
analyzer: FlowAnalyzer( analyzer: FlowAnalyzer(
availableCapabilities: availableCapabilities:
availableCapabilities ?? (() => const <String>[]), availableCapabilities ?? (() => const <String>[]),
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<String> Function() available,
List<String> 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<String, IssueType> get stepSeverity {
final a = analyzer;
return a is FlowAnalyzer ? a.stepSeverity : const {};
}
/// Published when the pointer enters a wavy-underlined range. /// Published when the pointer enters a wavy-underlined range.
/// The editor host (FlowEditorPage) listens and renders an /// The editor host (FlowEditorPage) listens and renders an
/// overlay tooltip near the cursor. Reset to `null` once the /// overlay tooltip near the cursor. Reset to `null` once the

View file

@ -50,7 +50,7 @@ class ReplaceLineValueFix extends QuickFix {
} }
/// Ask the host (Studio) to install the named capability via /// 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` /// [InstallCapabilityCallback] passed into `FlowEditorPage`
/// and reanalyzes the document once the host returns. /// and reanalyzes the document once the host returns.
@immutable @immutable
@ -66,6 +66,28 @@ class InstallCapabilityFix extends QuickFix {
}) : super(label); }) : 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 <url>`), 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 `<capability>` be
/// installed from?").
final String capability;
const AddModuleSourceFix({
required this.capability,
required String label,
}) : super(label);
}
/// Host-side install handler signature. Returns the new /// Host-side install handler signature. Returns the new
/// installed-capability list after the install completes (used /// installed-capability list after the install completes (used
/// by the editor to refresh its analyzer without round-tripping /// by the editor to refresh its analyzer without round-tripping
@ -75,6 +97,13 @@ class InstallCapabilityFix extends QuickFix {
typedef InstallCapabilityCallback = typedef InstallCapabilityCallback =
Future<List<String>?> Function(String capability); Future<List<String>?> 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<List<String>?> Function(String capability);
/// Hover-tooltip request the controller emits one of these /// Hover-tooltip request the controller emits one of these
/// via a ValueNotifier when the pointer enters an issue range, /// via a ValueNotifier when the pointer enters an issue range,
/// and clears it when the pointer leaves both the range and the /// and clears it when the pointer leaves both the range and the

View file

@ -33,6 +33,7 @@
// InteractiveViewer. // InteractiveViewer.
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_code_editor/flutter_code_editor.dart' show IssueType;
import '../editor_controller.dart'; import '../editor_controller.dart';
import '../editor_style.dart'; import '../editor_style.dart';
@ -875,6 +876,13 @@ class _FlowCanvasState extends State<FlowCanvas>
outputLabels.length, outputLabels.length,
); );
final cardWidth = _stepWidth(step); 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( return Positioned(
left: pos.x, left: pos.x,
top: pos.y, top: pos.y,
@ -891,14 +899,12 @@ class _FlowCanvasState extends State<FlowCanvas>
selected: selected, selected: selected,
status: status, status: status,
elevated: _style.nodeShadows, 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 pulse: _style.flowAnimation && status == FlowNodeStatus.running
? _flowAnim ? _flowAnim
: null, : null,
issueSeverity: status == FlowNodeStatus.running
? null
: issueSeverity,
onTap: () => widget.controller.selectStep(step.id), onTap: () => widget.controller.selectStep(step.id),
onDrag: (delta) => _applyDrag(step.id, pos, delta, cardHeight), onDrag: (delta) => _applyDrag(step.id, pos, delta, cardHeight),
onContextMenu: (globalPos) => _showStepContextMenu(step, globalPos), onContextMenu: (globalPos) => _showStepContextMenu(step, globalPos),
@ -930,10 +936,11 @@ class _FlowCanvasState extends State<FlowCanvas>
portSide: portSide, portSide: portSide,
inputPortLabels: labels, inputPortLabels: labels,
selected: selected, selected: selected,
// Endpoints are selectable too selecting opens // Endpoints carry the same issue-halo treatment as
// the inputs / outputs editor in the properties // step nodes the analyzer uses `__inputs__` /
// panel so the operator can rename, retype, or add // `__outputs__` sentinel ids so an `Unknown input type`
// entries graphically instead of editing YAML. // warning lights up the matching endpoint card.
issueSeverity: _issueSeverityFor(nodeId),
onTap: () => widget.controller.selectStep(nodeId), onTap: () => widget.controller.selectStep(nodeId),
onDrag: (delta) => _applyDrag( onDrag: (delta) => _applyDrag(
nodeId, 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 /// Single drag entry point used by every node on the
/// canvas. Converts a screen-space delta to canvas-space /// canvas. Converts a screen-space delta to canvas-space
/// (via the current InteractiveViewer scale), clamps the /// (via the current InteractiveViewer scale), clamps the

View file

@ -154,6 +154,12 @@ enum NodeVisualKind { module, approval, inputs, outputs }
/// endpoint OUT to downstream steps). /// endpoint OUT to downstream steps).
enum NodePortSide { left, right } 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 { class FlowNode extends StatelessWidget {
final String id; final String id;
final String title; final String title;
@ -224,6 +230,14 @@ class FlowNode extends StatelessWidget {
/// no pulse is rendered. /// no pulse is rendered.
final Listenable? pulse; 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({ const FlowNode({
super.key, super.key,
required this.id, required this.id,
@ -244,6 +258,7 @@ class FlowNode extends StatelessWidget {
this.onContextMenu, this.onContextMenu,
this.elevated = true, this.elevated = true,
this.pulse, this.pulse,
this.issueSeverity,
this.width = NodeGeometry.width, this.width = NodeGeometry.width,
}); });
@ -268,10 +283,6 @@ class FlowNode extends StatelessWidget {
child: AnimatedBuilder( child: AnimatedBuilder(
animation: pulse!, animation: pulse!,
builder: (context, child) { 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>) final t = (pulse! is Animation<double>)
? (pulse! as Animation<double>).value ? (pulse! as Animation<double>).value
: 0.0; : 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); return SizedBox(width: width, height: height, child: cardWidget);
} }
@ -592,3 +616,80 @@ NodeVisualKind kindForStep(FlowStep step) {
if (step.isApproval) return NodeVisualKind.approval; if (step.isApproval) return NodeVisualKind.approval;
return NodeVisualKind.module; 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,
);
}
}

View file

@ -1,6 +1,6 @@
name: fai_studio_flow_editor name: fai_studio_flow_editor
description: Swappable inline YAML editor for F∆I Studio flows. description: Swappable inline YAML editor for F∆I Studio flows.
version: 0.17.0 version: 0.18.0
publish_to: 'none' publish_to: 'none'
repository: https://git.flemming.ai/fai/studio-flow-editor repository: https://git.flemming.ai/fai/studio-flow-editor

View file

@ -105,10 +105,11 @@ steps:
expect(r.issues, isEmpty); expect(r.issues, isEmpty);
}); });
test('attaches InstallCapabilityFix to unknown-capability issue', test('attaches InstallCapabilityFix when the capability is in the store',
() async { () async {
final a = FlowAnalyzer( final a = FlowAnalyzer(
availableCapabilities: () => const ['debug.echo'], availableCapabilities: () => const ['debug.echo'],
storeCapabilities: () => const ['text.classify'],
); );
final r = await a.analyze(_wrap(''' final r = await a.analyze(_wrap('''
name: x name: x
@ -124,6 +125,69 @@ steps:
expect((fix as InstallCapabilityFix).capability, 'text.classify@^1'); 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<AddModuleSourceFix>());
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<ReplaceLineValueFix>());
// 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 { test('suggests closest valid type for a misspelled value', () async {
final a = FlowAnalyzer(availableCapabilities: () => const []); final a = FlowAnalyzer(availableCapabilities: () => const []);
final r = await a.analyze(_wrap(''' final r = await a.analyze(_wrap('''