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
|
|
@ -29,8 +29,10 @@ export 'src/l10n.dart' show FlowEditorLocale;
|
|||
export 'src/quick_fix.dart'
|
||||
show
|
||||
InstallCapabilityCallback,
|
||||
AddModuleSourceCallback,
|
||||
QuickFix,
|
||||
InstallCapabilityFix,
|
||||
AddModuleSourceFix,
|
||||
ReplaceLineValueFix,
|
||||
IssueHoverRequest,
|
||||
IssueHoverSeverity;
|
||||
|
|
|
|||
|
|
@ -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<String, IssueType> get stepIssueSeverity =>
|
||||
codeController.stepSeverity;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_reparseTimer?.cancel();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 <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({
|
||||
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<FlowEditorPage>
|
|||
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<FlowEditorPage>
|
|||
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;
|
||||
|
|
|
|||
|
|
@ -27,15 +27,41 @@ import 'quick_fix.dart';
|
|||
import 'wire_colors.dart';
|
||||
|
||||
class FlowYamlCodeController extends CodeController {
|
||||
FlowYamlCodeController({List<String> Function()? availableCapabilities})
|
||||
: super(
|
||||
text: '',
|
||||
language: yaml,
|
||||
analyzer: FlowAnalyzer(
|
||||
availableCapabilities:
|
||||
availableCapabilities ?? (() => const <String>[]),
|
||||
),
|
||||
);
|
||||
FlowYamlCodeController({
|
||||
List<String> Function()? availableCapabilities,
|
||||
List<String> Function()? storeCapabilities,
|
||||
}) : super(
|
||||
text: '',
|
||||
language: yaml,
|
||||
analyzer: FlowAnalyzer(
|
||||
availableCapabilities:
|
||||
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.
|
||||
/// The editor host (FlowEditorPage) listens and renders an
|
||||
|
|
|
|||
|
|
@ -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 <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
|
||||
/// 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<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
|
||||
/// via a ValueNotifier when the pointer enters an issue range,
|
||||
/// and clears it when the pointer leaves both the range and the
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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<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 {
|
||||
final a = FlowAnalyzer(availableCapabilities: () => const []);
|
||||
final r = await a.analyze(_wrap('''
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue