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>
363 lines
12 KiB
Dart
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);
|
|
}
|