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,26 @@
// FlowAnalyzer feeds AbstractAnalyzer-shaped issues into the
// flow editor's CodeController so the gutter can show error
// pins and the text tab can underline broken lines.
// flow editor's CodeController so the bottom diagnostic strip
// and the hover tooltip can light up broken lines.
//
// Two kinds of issues are reported today:
// Issue kinds reported today:
//
// - YAML parse errors. The exception's source span pins the
// line; the message goes straight through.
// - `use:` lines pointing at a capability the operator hasn't
// installed. We compare the bare `provider/name` (no
// installed. Comparison is on the bare `provider/name` (no
// version) against the host-supplied capability list, so
// `text.echo@^1` matches an installed `text.echo` even when
// the constraints differ.
// `text.echo@^1` matches an installed `text.echo`.
// - inputs/outputs values that aren't known type tokens. Catches
// `pdf: byes` before it fails at run time.
//
// `$step.field` reference resolution and per-field type checks
// will land here next they need the module spec map and are
// staged in the editor controller plumbing in editor_controller.dart.
// On top of issues, the analyzer also publishes a parallel
// `Map<Issue, List<QuickFix>>` so the editor's tooltip and
// strip can render one-click remediations next to the message.
import 'package:flutter_code_editor/flutter_code_editor.dart';
import 'package:yaml/yaml.dart';
import 'quick_fix.dart';
import 'wire_colors.dart';
class FlowAnalyzer extends AbstractAnalyzer {
@ -27,10 +29,23 @@ class FlowAnalyzer extends AbstractAnalyzer {
/// without needing a re-create on every Studio rebuild.
final List<String> Function() availableCapabilities;
const FlowAnalyzer({required this.availableCapabilities});
/// 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
/// rather than per line so a line with multiple issues stays
/// disambiguated.
final Map<Issue, List<QuickFix>> _fixesByIssue = {};
FlowAnalyzer({required this.availableCapabilities});
/// 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 []);
@override
Future<AnalysisResult> analyze(Code code) async {
_fixesByIssue.clear();
final issues = <Issue>[];
final text = code.text;
if (text.trim().isEmpty) {
@ -54,11 +69,7 @@ class FlowAnalyzer extends AbstractAnalyzer {
return AnalysisResult(issues: issues);
} catch (e) {
issues.add(
Issue(
line: 0,
message: 'YAML: $e',
type: IssueType.error,
),
Issue(line: 0, message: 'YAML: $e', type: IssueType.error),
);
return AnalysisResult(issues: issues);
}
@ -74,24 +85,24 @@ class FlowAnalyzer extends AbstractAnalyzer {
if (useValue is! String) continue;
if (knownBareCaps.isEmpty) continue;
if (!knownBareCaps.contains(_bareCap(useValue))) {
issues.add(
Issue(
line: useNode.span.start.line,
message:
'Unknown capability "$useValue". '
'Install the module that provides it, or check the spelling.',
type: IssueType.error,
),
final issue = Issue(
line: useNode.span.start.line,
message:
'Unknown capability "$useValue". '
'Install the module that provides it, or check the spelling.',
type: IssueType.error,
);
issues.add(issue);
_fixesByIssue[issue] = [
InstallCapabilityFix(
capability: useValue,
label: 'Install $useValue',
),
];
}
}
}
// Flag any inputs/outputs declaration whose value isn't a
// recognised type token. Flow YAML keeps each field as a
// `<name>: <type>` pair under `inputs:` / `outputs:` a
// typo like `pdf: byes` should light up rather than fail
// silently at run time.
_checkFieldTypes(doc['inputs'], 'input', issues);
_checkFieldTypes(doc['outputs'], 'output', issues);
}
@ -112,20 +123,77 @@ class FlowAnalyzer extends AbstractAnalyzer {
// declaring a typo.
if (value.trim().isEmpty) continue;
if (!kKnownTypes.contains(value)) {
out.add(
Issue(
line: entry.value.span.start.line,
message:
'Unknown $kind type "$value". '
'Use one of: ${kKnownTypes.join(", ")}.',
type: IssueType.warning,
),
final issue = Issue(
line: entry.value.span.start.line,
message:
'Unknown $kind type "$value". '
'Use one of: ${kKnownTypes.join(", ")}.',
type: IssueType.warning,
);
out.add(issue);
// 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.
final closest = _closestKnownType(value);
if (closest != null) {
_fixesByIssue[issue] = [
ReplaceLineValueFix(
line: entry.value.span.start.line,
replacement: closest,
label: 'Change to "$closest"',
),
];
}
}
}
}
}
/// 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
/// from the full list rather than being nudged toward a poor
/// match.
String? _closestKnownType(String input) {
String? best;
int bestDistance = 3; // exclusive upper bound
for (final candidate in kKnownTypes) {
final d = _levenshtein(input, candidate);
if (d < bestDistance) {
bestDistance = d;
best = candidate;
}
}
return best;
}
/// Classic dynamic-programming Levenshtein distance small
/// enough to inline since the strings we hit are 4-8 chars.
int _levenshtein(String a, String b) {
if (a == b) return 0;
if (a.isEmpty) return b.length;
if (b.isEmpty) return a.length;
final n = a.length;
final m = b.length;
var prev = List<int>.generate(m + 1, (i) => i);
var curr = List<int>.filled(m + 1, 0);
for (var i = 1; i <= n; i++) {
curr[0] = i;
for (var j = 1; j <= m; j++) {
final cost = a.codeUnitAt(i - 1) == b.codeUnitAt(j - 1) ? 0 : 1;
curr[j] = [
curr[j - 1] + 1,
prev[j] + 1,
prev[j - 1] + cost,
].reduce((x, y) => x < y ? x : y);
}
final swap = prev;
prev = curr;
curr = swap;
}
return prev[m];
}
/// Strip the `@<version>` tail from a capability spec so
/// `text.echo@^1` and `text.echo` compare equal.
String _bareCap(String full) {