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'
|
export 'src/quick_fix.dart'
|
||||||
show
|
show
|
||||||
InstallCapabilityCallback,
|
InstallCapabilityCallback,
|
||||||
|
AddModuleSourceCallback,
|
||||||
QuickFix,
|
QuickFix,
|
||||||
InstallCapabilityFix,
|
InstallCapabilityFix,
|
||||||
|
AddModuleSourceFix,
|
||||||
ReplaceLineValueFix,
|
ReplaceLineValueFix,
|
||||||
IssueHoverRequest,
|
IssueHoverRequest,
|
||||||
IssueHoverSeverity;
|
IssueHoverSeverity;
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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('''
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue