feat(editor): copyable diagnostics + hover tooltip + quick fixes

Three operator-UX gaps closed in one pass — the diagnostic
strip stayed plain Text (no copy), the wavy underline gave no
hover tooltip (had to read the strip), and there was no way to
act on an issue without leaving the editor.

  - **Selectable + copyable strip**. Both the header summary
    and the per-row issue message are now SelectableText. Per-
    row Copy button (compact icon) lives next to the message;
    header carries a 'Copy all' that copies every issue as
    'L7: <message>' lines. Trackpad-only operators no longer
    have to use the system selection gesture.

  - **Hover tooltip on the wavy underline**. The controller
    attaches TextSpan.onEnter / onExit handlers to every
    issue-overlapping leaf and publishes IssueHoverRequest via
    a ValueNotifier. FlowEditorPage listens and inserts an
    OverlayEntry tooltip card near the cursor. Card carries
    its own MouseRegion that cancels the dismiss timer so the
    operator can slide INTO it to click the action buttons.

  - **Quick fixes for the two most common issues**:
      · 'Unknown capability X' → InstallCapabilityFix (delegated
        to host via the new onInstallCapability callback). On
        success, controller.setAvailableCapabilities + reanalyze
        clear the issue automatically.
      · 'Unknown type Y' with a Levenshtein-distance-≤2 match
        → ReplaceLineValueFix. Applied by the editor itself:
        line is mutated, key + indent preserved, comment
        preserved, then reanalyze.
    Distance > 2 stays unfixed — pushing 'zonglefax' to 'bytes'
    would be worse than no suggestion.

New public surface (exported from the package):

  - QuickFix sealed base + InstallCapabilityFix + ReplaceLineValueFix
  - InstallCapabilityCallback typedef
  - IssueHoverRequest + IssueHoverSeverity
  - FlowEditorPage.onInstallCapability prop

Tests:
  - FlowAnalyzer attaches InstallCapabilityFix to capability
    issues
  - FlowAnalyzer suggests the closest valid type for typos
  - FlowAnalyzer emits no fix when the typo is too far

All 33 editor tests green. Bumped to 0.17.0.

Signed-off-by: flemming-it <sf@flemming.it>
This commit is contained in:
flemming-it 2026-06-09 00:01:51 +02:00
parent 1b50924e16
commit f43c1ac6cf
7 changed files with 884 additions and 198 deletions

View file

@ -1,24 +1,29 @@
// FlowYamlCodeController CodeController subclass that adds
// FI-specific colouring on top of the stock YAML highlighter.
// FI-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 find
// `type: text|json|bytes|file|number|integer` declarations and
// recolours just the type-value token with the same hue the
// graph canvas uses for that wire so a glance at the YAML
// confirms what a glance at the graph shows.
// 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 {
@ -32,6 +37,44 @@ class FlowYamlCodeController extends CodeController {
),
);
/// 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.
@ -39,22 +82,36 @@ class FlowYamlCodeController extends CodeController {
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. This covers both shapes the
// FI YAML uses:
//
// inputs:
// pdf: bytes # field-name type
// count: number
//
// inputs:
// - name: pdf
// type: bytes # explicit `type:` row
//
// We require at least one leading whitespace so top-level
// keys (`name: foo`, `version: 0.1.0`) can't false-match.
// The captured group is the value we resolve its offset
// inside the document and recolour only that token.
// one of our known type tokens. Covers both shapes the FI
// 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,
@ -94,14 +151,9 @@ class FlowYamlCodeController extends CodeController {
if (issue.line < 0 || issue.line >= lines.length) continue;
final line = lines[issue.line];
final text = line.text;
// Skip trailing newline if any so the underline doesn't
// extend past the visual line end.
var endOffset = line.textRange.end;
if (text.endsWith('\n')) endOffset -= 1;
var startOffset = line.textRange.start;
// Walk past leading whitespace so indentation isn't
// underlined looks cleaner and keeps focus on the
// actual broken token.
for (var i = 0; i < text.length; i++) {
final c = text.codeUnitAt(i);
if (c == 0x20 || c == 0x09) {
@ -126,15 +178,13 @@ class FlowYamlCodeController extends CodeController {
decorationColor: color,
decorationThickness: 1.5,
),
issue: issue,
),
);
}
return out;
}
/// Scan [source] and return ascending, non-overlapping ranges
/// whose [_Override.style] should override whatever the
/// highlighter chose.
List<_Override> _collectOverrides(String source, ThemeData theme) {
final out = <_Override>[];
for (final m in _typeAssignment.allMatches(source)) {
@ -157,9 +207,6 @@ class FlowYamlCodeController extends CodeController {
return out;
}
/// Flatten + rebuild [span] so every character in an
/// [_Override] range carries the override style. Outside
/// ranges, the original highlighter style is preserved.
TextSpan _applyOverrides(InlineSpan span, List<_Override> overrides) {
final leaves = <_Leaf>[];
_flatten(span, null, leaves);
@ -192,12 +239,7 @@ class FlowYamlCodeController extends CodeController {
);
}
final overlap = ovEnd - leafStart;
children.add(
TextSpan(
text: localText.substring(before, overlap),
style: (leaf.style ?? const TextStyle()).merge(ov.style),
),
);
children.add(_buildOverrideSpan(localText, before, overlap, leaf, ov));
localCursor = overlap;
probe++;
}
@ -217,11 +259,51 @@ class FlowYamlCodeController extends CodeController {
return TextSpan(style: rootStyle, children: children);
}
/// Depth-first walk of [span], emitting one [_Leaf] per text
/// chunk in document order. The highlighter's SpanBuilder
/// produces TextSpans that carry BOTH text AND children; we
/// emit the parent's text first, then descend into its
/// children, so concatenating all leaves reproduces [text].
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
@ -244,7 +326,8 @@ class _Override {
final int start;
final int end;
final TextStyle style;
const _Override(this.start, this.end, this.style);
final Issue? issue;
const _Override(this.start, this.end, this.style, {this.issue});
}
class _Leaf {