chain-studio-flow-editor/lib/src/flow_yaml_controller.dart
flemming-it efdfa7dd79 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>
2026-06-09 00:21:48 +02:00

363 lines
12 KiB
Dart

// FlowYamlCodeController — CodeController subclass that adds
// F∆I-specific colouring on top of the stock YAML highlighter
// and routes analyzer-issue hovers + reanalyze cycles for the
// editor host.
//
// The base highlighter colours generic YAML structure (keys,
// strings, numbers, comments, anchors). On top of that, this
// subclass walks the rendered span tree once per build to
// recolour wire-typed values, lay wavy underlines on broken
// lines, and attach pointer-hover handlers so the editor host
// can pop a tooltip with the message + quick fixes.
//
// The lookup runs every keystroke (because TextField rebuilds
// the span on each change), but each pass is O(N) over the
// visible text — fast enough for the flow files we expect
// (hundreds of lines, not megabytes).
import 'dart:async';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter_code_editor/flutter_code_editor.dart';
import 'package:highlight/languages/yaml.dart';
import 'flow_analyzer.dart';
import 'quick_fix.dart';
import 'wire_colors.dart';
class FlowYamlCodeController extends CodeController {
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
/// overlay tooltip near the cursor. Reset to `null` once the
/// pointer leaves both the range AND the tooltip.
final ValueNotifier<IssueHoverRequest?> hoverRequest =
ValueNotifier<IssueHoverRequest?>(null);
Timer? _hideTimer;
/// Cancel the pending hover-dismiss timer. The host calls
/// this when the pointer enters the floating tooltip card so
/// the operator can click an action button without the tip
/// vanishing under them.
void cancelHoverDismiss() {
_hideTimer?.cancel();
_hideTimer = null;
}
/// Schedule the hover tooltip to clear after a short grace
/// period. Lets the operator slide from the underline into
/// the tooltip without the latter snapping away.
void scheduleHoverDismiss() {
_hideTimer?.cancel();
_hideTimer = Timer(const Duration(milliseconds: 220), () {
hoverRequest.value = null;
_hideTimer = null;
});
}
/// Clear the hover tooltip immediately — used when the editor
/// is being disposed or the host wants to dismiss the tip
/// (e.g. after applying a quick fix).
void clearHover() {
_hideTimer?.cancel();
_hideTimer = null;
hoverRequest.value = null;
}
/// Replace the analyzer's capability provider. Called by the
/// editor host when the installed-capability list changes —
/// e.g. after `fai install` while the editor is open.
void setAvailableCapabilities(List<String> Function() provider) {
analyzer = FlowAnalyzer(availableCapabilities: provider);
}
/// Run the analyzer against the current buffer and publish
/// the new result. The host calls this after a quick fix that
/// changes external state (e.g. installing a module) so the
/// strip + underlines update without waiting for the next
/// keystroke.
Future<void> reanalyze() async {
final result = await analyzer.analyze(code);
analysisResult = result;
notifyListeners();
}
/// Read-only access to the quick fixes computed for an issue.
/// Returns an empty list when the analyzer is the default
/// `DefaultLocalAnalyzer` (i.e. no language host hooked up).
List<QuickFix> fixesFor(Issue issue) {
final a = analyzer;
return a is FlowAnalyzer ? a.fixesFor(issue) : const <QuickFix>[];
}
@override
void dispose() {
_hideTimer?.cancel();
hoverRequest.dispose();
super.dispose();
}
// Matches an indented `key: value` line where the value is
// one of our known type tokens. Covers both shapes the F∆I
// YAML uses (`pdf: bytes` shorthand and the explicit
// `type: bytes` row).
static final RegExp _typeAssignment = RegExp(
r'''^[ \t]+[A-Za-z_][\w-]*\s*:\s*(?:['"])?([A-Za-z_][\w-]*)(?:['"])?\s*(?:#.*)?$''',
multiLine: true,
);
@override
TextSpan buildTextSpan({
required BuildContext context,
TextStyle? style,
bool? withComposing,
}) {
final base = super.buildTextSpan(
context: context,
style: style,
withComposing: withComposing,
);
final theme = Theme.of(context);
final overrides = <_Override>[];
overrides.addAll(_collectOverrides(text, theme));
overrides.addAll(_collectIssueOverrides(theme));
if (overrides.isEmpty) return base;
overrides.sort((a, b) => a.start.compareTo(b.start));
return _applyOverrides(base, overrides);
}
/// Build wavy-underline overrides for every analyzer-reported
/// issue. The underline spans from the first non-whitespace
/// character to the end of the affected line, so a clean line
/// stays clean and the diagnostic visually attaches to the
/// content the operator wrote (not the indent).
List<_Override> _collectIssueOverrides(ThemeData theme) {
final issues = analysisResult.issues;
if (issues.isEmpty) return const <_Override>[];
final lines = code.lines.lines;
final out = <_Override>[];
for (final issue in issues) {
if (issue.line < 0 || issue.line >= lines.length) continue;
final line = lines[issue.line];
final text = line.text;
var endOffset = line.textRange.end;
if (text.endsWith('\n')) endOffset -= 1;
var startOffset = line.textRange.start;
for (var i = 0; i < text.length; i++) {
final c = text.codeUnitAt(i);
if (c == 0x20 || c == 0x09) {
startOffset++;
} else {
break;
}
}
if (startOffset >= endOffset) continue;
final color = switch (issue.type) {
IssueType.error => theme.colorScheme.error,
IssueType.warning => const Color(0xFFEF6C00),
IssueType.info => theme.colorScheme.primary,
};
out.add(
_Override(
startOffset,
endOffset,
TextStyle(
decoration: TextDecoration.underline,
decorationStyle: TextDecorationStyle.wavy,
decorationColor: color,
decorationThickness: 1.5,
),
issue: issue,
),
);
}
return out;
}
List<_Override> _collectOverrides(String source, ThemeData theme) {
final out = <_Override>[];
for (final m in _typeAssignment.allMatches(source)) {
final typeName = m.group(1);
if (typeName == null || !kKnownTypes.contains(typeName)) continue;
final whole = m.group(0)!;
final nameStart = m.start + whole.indexOf(typeName);
final nameEnd = nameStart + typeName.length;
out.add(
_Override(
nameStart,
nameEnd,
TextStyle(
color: wireColor(typeName, theme),
fontWeight: FontWeight.w600,
),
),
);
}
return out;
}
TextSpan _applyOverrides(InlineSpan span, List<_Override> overrides) {
final leaves = <_Leaf>[];
_flatten(span, null, leaves);
final children = <InlineSpan>[];
var globalCursor = 0;
var ovIdx = 0;
for (final leaf in leaves) {
final localText = leaf.text;
final leafStart = globalCursor;
final leafEnd = leafStart + localText.length;
var localCursor = 0;
while (ovIdx < overrides.length && overrides[ovIdx].end <= leafStart) {
ovIdx++;
}
var probe = ovIdx;
while (probe < overrides.length && overrides[probe].start < leafEnd) {
final ov = overrides[probe];
final ovStart = ov.start < leafStart ? leafStart : ov.start;
final ovEnd = ov.end > leafEnd ? leafEnd : ov.end;
final before = ovStart - leafStart;
if (before > localCursor) {
children.add(
TextSpan(
text: localText.substring(localCursor, before),
style: leaf.style,
),
);
}
final overlap = ovEnd - leafStart;
children.add(_buildOverrideSpan(localText, before, overlap, leaf, ov));
localCursor = overlap;
probe++;
}
if (localCursor < localText.length) {
children.add(
TextSpan(
text: localText.substring(localCursor),
style: leaf.style,
),
);
}
globalCursor = leafEnd;
}
final rootStyle = (span is TextSpan) ? span.style : null;
return TextSpan(style: rootStyle, children: children);
}
TextSpan _buildOverrideSpan(
String localText,
int before,
int overlap,
_Leaf leaf,
_Override ov,
) {
final mergedStyle = (leaf.style ?? const TextStyle()).merge(ov.style);
final issue = ov.issue;
if (issue == null) {
return TextSpan(
text: localText.substring(before, overlap),
style: mergedStyle,
);
}
// Capture the issue + analyzer-supplied fixes so hover
// handlers can publish a self-contained request without
// round-tripping through the host on every pointer event.
final fixes = fixesFor(issue);
final severity = switch (issue.type) {
IssueType.error => IssueHoverSeverity.error,
IssueType.warning => IssueHoverSeverity.warning,
IssueType.info => IssueHoverSeverity.info,
};
return TextSpan(
text: localText.substring(before, overlap),
style: mergedStyle,
mouseCursor: SystemMouseCursors.help,
onEnter: (PointerEnterEvent e) {
_hideTimer?.cancel();
_hideTimer = null;
hoverRequest.value = IssueHoverRequest(
line: issue.line,
message: issue.message,
severity: severity,
fixes: fixes,
globalPosition: e.position,
);
},
onExit: (PointerExitEvent _) {
scheduleHoverDismiss();
},
);
}
void _flatten(InlineSpan span, TextStyle? inherited, List<_Leaf> out) {
if (span is! TextSpan) return;
final merged = inherited == null
? span.style
: (span.style == null ? inherited : inherited.merge(span.style));
final t = span.text;
if (t != null && t.isNotEmpty) {
out.add(_Leaf(t, merged));
}
final children = span.children;
if (children != null) {
for (final c in children) {
_flatten(c, merged, out);
}
}
}
}
class _Override {
final int start;
final int end;
final TextStyle style;
final Issue? issue;
const _Override(this.start, this.end, this.style, {this.issue});
}
class _Leaf {
final String text;
final TextStyle? style;
const _Leaf(this.text, this.style);
}