diff --git a/lib/fai_studio_flow_editor.dart b/lib/fai_studio_flow_editor.dart index 65002c6..21d99e7 100644 --- a/lib/fai_studio_flow_editor.dart +++ b/lib/fai_studio_flow_editor.dart @@ -26,6 +26,14 @@ export 'src/editor_style.dart' show FaiEditorStyle, EditorCanvasBackdrop, EditorPanelStyle; export 'src/flow_editor_page.dart' show FlowEditorPage; export 'src/l10n.dart' show FlowEditorLocale; +export 'src/quick_fix.dart' + show + InstallCapabilityCallback, + QuickFix, + InstallCapabilityFix, + ReplaceLineValueFix, + IssueHoverRequest, + IssueHoverSeverity; export 'src/run_driver.dart' show FlowRunDriver, diff --git a/lib/src/flow_analyzer.dart b/lib/src/flow_analyzer.dart index 4fa5c9f..c0bb81c 100644 --- a/lib/src/flow_analyzer.dart +++ b/lib/src/flow_analyzer.dart @@ -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>` 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 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> _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 fixesFor(Issue issue) => + List.unmodifiable(_fixesByIssue[issue] ?? const []); @override Future analyze(Code code) async { + _fixesByIssue.clear(); final issues = []; 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 - // `: ` 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.generate(m + 1, (i) => i); + var curr = List.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 `@` tail from a capability spec so /// `text.echo@^1` and `text.echo` compare equal. String _bareCap(String full) { diff --git a/lib/src/flow_editor_page.dart b/lib/src/flow_editor_page.dart index 3b51a1d..bc59eb3 100644 --- a/lib/src/flow_editor_page.dart +++ b/lib/src/flow_editor_page.dart @@ -31,8 +31,10 @@ import 'package:flutter_code_editor/flutter_code_editor.dart'; import 'editor_controller.dart'; import 'editor_style.dart'; +import 'flow_yaml_controller.dart'; import 'l10n.dart'; import 'model/flow_graph.dart'; +import 'quick_fix.dart'; import 'run_driver.dart'; import 'tokens.dart'; import 'widgets.dart'; @@ -75,6 +77,14 @@ class FlowEditorPage extends StatefulWidget { /// the editor's source. final FaiEditorStyle? style; + /// Host-side install handler invoked when the operator clicks + /// the "Install " quick-fix on an unknown-capability + /// issue. Studio's implementation calls the Hub and returns + /// the post-install capability list; the editor refreshes its + /// analyzer and dismisses the issue. `null` hides the install + /// action (the diagnostic remains visible without it). + final InstallCapabilityCallback? onInstallCapability; + const FlowEditorPage({ super.key, this.initialFlowName, @@ -82,6 +92,7 @@ class FlowEditorPage extends StatefulWidget { this.runDriver, this.availableCapabilities = const [], this.style, + this.onInstallCapability, }); @override @@ -95,6 +106,12 @@ class _FlowEditorPageState extends State late Future> _files; late final TabController _tabs; + /// Live hover overlay — inserted when the pointer enters an + /// underlined issue range, removed when it leaves both the + /// range and the tooltip card. Kept on State so we can clear + /// it on dispose without leaking. + OverlayEntry? _hoverEntry; + @override void initState() { super.initState(); @@ -104,6 +121,7 @@ class _FlowEditorPageState extends State () => widget.availableCapabilities, ); _controller.addListener(_onCtrlChanged); + _controller.codeController.hoverRequest.addListener(_onHoverChanged); _tabs = TabController(length: 3, vsync: this); _files = _listFiles(); final initial = widget.initialFlowName; @@ -116,6 +134,8 @@ class _FlowEditorPageState extends State @override void dispose() { + _controller.codeController.hoverRequest.removeListener(_onHoverChanged); + _removeHoverEntry(); _controller.removeListener(_onCtrlChanged); _controller.dispose(); _tabs.dispose(); @@ -126,6 +146,77 @@ class _FlowEditorPageState extends State if (mounted) setState(() {}); } + void _onHoverChanged() { + final req = _controller.codeController.hoverRequest.value; + if (req == null) { + _removeHoverEntry(); + return; + } + _removeHoverEntry(); + final overlay = Overlay.of(context, rootOverlay: true); + _hoverEntry = OverlayEntry( + builder: (ctx) => _IssueHoverCard( + request: req, + onApplyFix: _applyQuickFix, + onEnter: _controller.codeController.cancelHoverDismiss, + onExit: _controller.codeController.scheduleHoverDismiss, + ), + ); + overlay.insert(_hoverEntry!); + } + + void _removeHoverEntry() { + _hoverEntry?.remove(); + _hoverEntry = null; + } + + /// Apply a quick fix returned by the analyzer. Returns once + /// the fix has been applied; the controller reanalyzes on its + /// own. + Future _applyQuickFix(QuickFix fix) async { + switch (fix) { + case ReplaceLineValueFix(): + _applyReplaceLineValue(fix); + await _controller.codeController.reanalyze(); + break; + case InstallCapabilityFix(:final capability): + final handler = widget.onInstallCapability; + if (handler == null) return; + final newCaps = await handler(capability); + if (newCaps != null && mounted) { + _controller.codeController.setAvailableCapabilities(() => newCaps); + await _controller.codeController.reanalyze(); + } + break; + } + _controller.codeController.clearHover(); + } + + /// Substitute the colon-value of [fix.line] with + /// [fix.replacement]. Preserves indentation + the key, only + /// touches what's right of the first ":". Idempotent — if the + /// value already matches, the call is a no-op. + void _applyReplaceLineValue(ReplaceLineValueFix fix) { + final controller = _controller.codeController; + final fullText = controller.fullText; + final lines = fullText.split('\n'); + if (fix.line < 0 || fix.line >= lines.length) return; + final line = lines[fix.line]; + final colonIdx = line.indexOf(':'); + if (colonIdx < 0) return; + // Find first non-whitespace after the colon — preserve any + // single leading space, drop everything past it up to the + // end of the line (modulo a trailing comment). + final commentIdx = line.indexOf('#', colonIdx + 1); + final rhsEnd = commentIdx < 0 ? line.length : commentIdx; + final tail = commentIdx < 0 ? '' : line.substring(rhsEnd); + final newLine = '${line.substring(0, colonIdx + 1)} ${fix.replacement}' + '${tail.isEmpty ? '' : ' $tail'}'; + if (newLine == line) return; + lines[fix.line] = newLine; + controller.fullText = lines.join('\n'); + } + // --- file ops --- Future> _listFiles() async { @@ -620,7 +711,10 @@ outputs: background: theme.colorScheme.surface, ), ), - _DiagnosticStrip(controller: _controller.codeController), + _DiagnosticStrip( + controller: _controller.codeController, + onApplyFix: _applyQuickFix, + ), ], ), ); @@ -1124,17 +1218,25 @@ class _NewFlowDialogState extends State<_NewFlowDialog> { } } -/// Bottom diagnostic strip — single-line "X issues · {first}" -/// summary that opens into a per-line list on tap. Replaces the -/// gutter's hard-positioned error pin so the popup never paints -/// over the code area, and gives the operator a stable surface -/// to scan even when the wavy underline sits off-screen. +/// Bottom diagnostic strip — single-line summary that opens +/// into a per-issue list on tap. Replaces the gutter's hard- +/// positioned error pin so the popup never paints over the +/// code area, and gives the operator a stable surface to scan +/// + copy from. /// -/// The strip listens to the CodeController; it rebuilds on every -/// analyzer pass without polling. +/// Each row's message is rendered as `SelectableText` so the +/// operator can copy with the system shortcut. A dedicated +/// "Copy" button per row + a "Copy all" header button cover +/// the trackpad-only operator. When the analyzer attaches a +/// `QuickFix` to an issue, the row also renders an action +/// button (e.g. "Install " or "Change to \"bytes\""). class _DiagnosticStrip extends StatefulWidget { - final CodeController controller; - const _DiagnosticStrip({required this.controller}); + final FlowYamlCodeController controller; + final Future Function(QuickFix) onApplyFix; + const _DiagnosticStrip({ + required this.controller, + required this.onApplyFix, + }); @override State<_DiagnosticStrip> createState() => _DiagnosticStripState(); @@ -1142,6 +1244,7 @@ class _DiagnosticStrip extends StatefulWidget { class _DiagnosticStripState extends State<_DiagnosticStrip> { bool _expanded = false; + final Map _busy = {}; @override void initState() { @@ -1159,6 +1262,35 @@ class _DiagnosticStripState extends State<_DiagnosticStrip> { if (mounted) setState(() {}); } + Color _toneForIssue(IssueType type, ThemeData theme) { + return switch (type) { + IssueType.error => theme.colorScheme.error, + IssueType.warning => const Color(0xFFEF6C00), + IssueType.info => theme.colorScheme.primary, + }; + } + + void _copy(String text) { + Clipboard.setData(ClipboardData(text: text)); + } + + String _formatAll(List issues) { + final buf = StringBuffer(); + for (final i in issues) { + buf.writeln('L${i.line + 1}: ${i.message}'); + } + return buf.toString().trimRight(); + } + + Future _runFix(QuickFix fix) async { + setState(() => _busy[fix] = true); + try { + await widget.onApplyFix(fix); + } finally { + if (mounted) setState(() => _busy.remove(fix)); + } + } + @override Widget build(BuildContext context) { final theme = Theme.of(context); @@ -1190,127 +1322,101 @@ class _DiagnosticStripState extends State<_DiagnosticStrip> { final warnCount = issues.where((i) => i.type == IssueType.warning).length; final tone = errorCount > 0 ? theme.colorScheme.error - : const Color(0xFFEF6C00); // amber/orange for warnings + : const Color(0xFFEF6C00); return Material( color: theme.colorScheme.surfaceContainer, - child: InkWell( - onTap: () => setState(() => _expanded = !_expanded), - child: Container( - decoration: BoxDecoration( - border: Border( - top: BorderSide(color: theme.colorScheme.outlineVariant), - ), + child: Container( + decoration: BoxDecoration( + border: Border( + top: BorderSide(color: theme.colorScheme.outlineVariant), ), - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Row( - children: [ - Container( - width: 8, - height: 8, - decoration: BoxDecoration( - color: tone, - shape: BoxShape.circle, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + // --- Header row (clickable to expand/collapse) --- + InkWell( + onTap: () => setState(() => _expanded = !_expanded), + child: Padding( + padding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + child: Row( + children: [ + Container( + width: 8, + height: 8, + decoration: BoxDecoration( + color: tone, + shape: BoxShape.circle, + ), ), - ), - const SizedBox(width: 8), - Text( - _summary(errorCount, warnCount), - style: TextStyle( - fontFamily: 'JetBrains Mono', - fontSize: 11, - fontWeight: FontWeight.w600, - color: tone, - ), - ), - const SizedBox(width: 8), - Expanded( - child: Text( - 'L${issues.first.line + 1}: ${issues.first.message}', - maxLines: 1, - overflow: TextOverflow.ellipsis, + const SizedBox(width: 8), + Text( + _summary(errorCount, warnCount), style: TextStyle( fontFamily: 'JetBrains Mono', fontSize: 11, - color: theme.colorScheme.onSurface, + fontWeight: FontWeight.w600, + color: tone, ), ), - ), - Icon( - _expanded ? Icons.expand_more : Icons.expand_less, - size: 16, - color: theme.colorScheme.onSurfaceVariant, - ), - ], - ), - if (_expanded) ...[ - const SizedBox(height: 6), - Divider(height: 1, color: theme.colorScheme.outlineVariant), - const SizedBox(height: 6), - ConstrainedBox( - constraints: const BoxConstraints(maxHeight: 140), - child: SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - for (final issue in issues) - Padding( - padding: const EdgeInsets.symmetric(vertical: 2), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - width: 6, - height: 6, - margin: const EdgeInsets.only(top: 5, right: 8), - decoration: BoxDecoration( - color: switch (issue.type) { - IssueType.error => - theme.colorScheme.error, - IssueType.warning => - const Color(0xFFEF6C00), - IssueType.info => - theme.colorScheme.primary, - }, - shape: BoxShape.circle, - ), - ), - SizedBox( - width: 42, - child: Text( - 'L${issue.line + 1}', - style: TextStyle( - fontFamily: 'JetBrains Mono', - fontSize: 11, - color: - theme.colorScheme.onSurfaceVariant, - ), - ), - ), - Expanded( - child: Text( - issue.message, - style: TextStyle( - fontFamily: 'JetBrains Mono', - fontSize: 11, - color: theme.colorScheme.onSurface, - ), - ), - ), - ], - ), - ), - ], + const SizedBox(width: 8), + Expanded( + child: SelectableText( + 'L${issues.first.line + 1}: ${issues.first.message}', + maxLines: 1, + style: TextStyle( + fontFamily: 'JetBrains Mono', + fontSize: 11, + color: theme.colorScheme.onSurface, + ), + ), ), + IconButton( + tooltip: 'Copy all', + icon: const Icon(Icons.content_copy, size: 14), + visualDensity: VisualDensity.compact, + onPressed: () => _copy(_formatAll(issues)), + ), + Icon( + _expanded ? Icons.expand_more : Icons.expand_less, + size: 16, + color: theme.colorScheme.onSurfaceVariant, + ), + ], + ), + ), + ), + if (_expanded) ...[ + Divider(height: 1, color: theme.colorScheme.outlineVariant), + ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 220), + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (final issue in issues) + _IssueRow( + issue: issue, + fixes: widget.controller.fixesFor(issue), + tone: _toneForIssue(issue.type, theme), + busy: _busy, + onApplyFix: _runFix, + onCopy: () => + _copy('L${issue.line + 1}: ${issue.message}'), + ), + ], ), ), - ], + ), ], - ), + ], ), ), ); @@ -1325,3 +1431,261 @@ class _DiagnosticStripState extends State<_DiagnosticStrip> { return parts.join(' · '); } } + +/// One row in the expanded diagnostic list. Lays out as +/// `[dot] [L7] [message] [Copy] [Fix...]` — keeping the +/// quick-fix action buttons aligned right so the operator's +/// eye finds them consistently across rows. +class _IssueRow extends StatelessWidget { + final Issue issue; + final List fixes; + final Color tone; + final Map busy; + final Future Function(QuickFix) onApplyFix; + final VoidCallback onCopy; + + const _IssueRow({ + required this.issue, + required this.fixes, + required this.tone, + required this.busy, + required this.onApplyFix, + required this.onCopy, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 6, + height: 6, + margin: const EdgeInsets.only(top: 6, right: 8), + decoration: BoxDecoration(color: tone, shape: BoxShape.circle), + ), + SizedBox( + width: 42, + child: Text( + 'L${issue.line + 1}', + style: TextStyle( + fontFamily: 'JetBrains Mono', + fontSize: 11, + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ), + Expanded( + child: SelectableText( + issue.message, + style: TextStyle( + fontFamily: 'JetBrains Mono', + fontSize: 11, + color: theme.colorScheme.onSurface, + ), + ), + ), + const SizedBox(width: 8), + IconButton( + tooltip: 'Copy', + icon: const Icon(Icons.content_copy, size: 13), + visualDensity: VisualDensity.compact, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(minWidth: 24, minHeight: 24), + onPressed: onCopy, + ), + for (final fix in fixes) ...[ + const SizedBox(width: 6), + FilledButton.tonalIcon( + onPressed: busy[fix] == true ? null : () => onApplyFix(fix), + icon: busy[fix] == true + ? const SizedBox( + width: 12, + height: 12, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.auto_fix_high, size: 13), + label: Text( + fix.label, + style: const TextStyle( + fontFamily: 'JetBrains Mono', + fontSize: 11, + ), + ), + style: FilledButton.styleFrom( + visualDensity: VisualDensity.compact, + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + minimumSize: const Size(0, 24), + ), + ), + ], + ], + ), + ); + } +} + +/// Floating tooltip rendered next to the cursor when it enters +/// a wavy-underlined issue range. Carries the same message + +/// quick-fix actions as the strip, so the operator can fix +/// without leaving the underline. Self-contained MouseRegion +/// cancels the dismiss timer when the pointer slides INTO the +/// tooltip so action buttons stay clickable. +class _IssueHoverCard extends StatelessWidget { + final IssueHoverRequest request; + final Future Function(QuickFix) onApplyFix; + final VoidCallback onEnter; + final VoidCallback onExit; + + const _IssueHoverCard({ + required this.request, + required this.onApplyFix, + required this.onEnter, + required this.onExit, + }); + + Color _tone(BuildContext context) { + final theme = Theme.of(context); + return switch (request.severity) { + IssueHoverSeverity.error => theme.colorScheme.error, + IssueHoverSeverity.warning => const Color(0xFFEF6C00), + IssueHoverSeverity.info => theme.colorScheme.primary, + }; + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final media = MediaQuery.of(context); + // Anchor below + slightly right of the cursor, but clamp + // to viewport so the card never spills off-screen on a + // narrow window. + const cardWidth = 380.0; + final maxLeft = (media.size.width - cardWidth - 12).clamp(8.0, double.infinity); + final dx = (request.globalPosition.dx + 12).clamp(8.0, maxLeft); + final dy = (request.globalPosition.dy + 18).clamp( + 8.0, + media.size.height - 200, + ); + final tone = _tone(context); + return Positioned( + left: dx.toDouble(), + top: dy.toDouble(), + child: MouseRegion( + onEnter: (_) => onEnter(), + onExit: (_) => onExit(), + child: Material( + color: Colors.transparent, + child: Container( + width: cardWidth, + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: theme.colorScheme.outlineVariant, + ), + boxShadow: const [ + BoxShadow( + color: Color(0x33000000), + blurRadius: 18, + offset: Offset(0, 6), + ), + ], + ), + padding: const EdgeInsets.fromLTRB(12, 10, 8, 10), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(top: 5), + child: Container( + width: 8, + height: 8, + decoration: BoxDecoration( + color: tone, + shape: BoxShape.circle, + ), + ), + ), + const SizedBox(width: 8), + Text( + 'L${request.line + 1}', + style: TextStyle( + fontFamily: 'JetBrains Mono', + fontSize: 11, + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(width: 8), + Expanded( + child: SelectableText( + request.message, + style: TextStyle( + fontFamily: 'JetBrains Mono', + fontSize: 12, + color: theme.colorScheme.onSurface, + ), + ), + ), + IconButton( + tooltip: 'Copy', + icon: const Icon(Icons.content_copy, size: 14), + visualDensity: VisualDensity.compact, + onPressed: () { + Clipboard.setData( + ClipboardData( + text: + 'L${request.line + 1}: ${request.message}', + ), + ); + }, + ), + ], + ), + if (request.fixes.isNotEmpty) ...[ + const SizedBox(height: 8), + Wrap( + spacing: 6, + runSpacing: 6, + children: [ + for (final fix in request.fixes) + FilledButton.tonalIcon( + onPressed: () => onApplyFix(fix), + icon: const Icon(Icons.auto_fix_high, size: 13), + label: Text( + fix.label, + style: const TextStyle( + fontFamily: 'JetBrains Mono', + fontSize: 11, + ), + ), + style: FilledButton.styleFrom( + visualDensity: VisualDensity.compact, + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 6, + ), + minimumSize: const Size(0, 28), + ), + ), + ], + ), + ], + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/src/flow_yaml_controller.dart b/lib/src/flow_yaml_controller.dart index 7cec615..37e1253 100644 --- a/lib/src/flow_yaml_controller.dart +++ b/lib/src/flow_yaml_controller.dart @@ -1,24 +1,29 @@ // FlowYamlCodeController — CodeController subclass that adds -// F∆I-specific colouring on top of the stock YAML highlighter. +// 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 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 hoverRequest = + ValueNotifier(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 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 fixesFor(Issue issue) { + final a = analyzer; + return a is FlowAnalyzer ? a.fixesFor(issue) : const []; + } + + @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 - // F∆I 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 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, @@ -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 { diff --git a/lib/src/quick_fix.dart b/lib/src/quick_fix.dart new file mode 100644 index 0000000..c33a21c --- /dev/null +++ b/lib/src/quick_fix.dart @@ -0,0 +1,112 @@ +// Quick-fix model for the flow editor's diagnostics. +// +// Each analyzer issue can carry one or more `QuickFix` records +// describing a one-click remediation. The editor renders these +// as action buttons on the hover tooltip and inside the bottom +// diagnostic strip. Two categories exist today: +// +// * `ReplaceLineValueFix` — purely textual; the editor +// applies it directly by mutating the controller's +// buffer. +// * `InstallCapabilityFix` — needs the host (Studio) to talk +// to the Hub. The editor invokes the host-provided +// callback and reanalyzes once it returns. +// +// New fix kinds can be added by extending the sealed base; both +// surfaces (tooltip + strip) switch on the runtime type, so +// adding a kind also means adding a case there. + +import 'dart:ui' show Offset; + +import 'package:flutter/foundation.dart'; + +@immutable +sealed class QuickFix { + /// One-line button label rendered in the UI (verb-first, + /// imperative). Kept short so the strip layout doesn't reflow + /// when multiple fixes are present. + final String label; + const QuickFix(this.label); +} + +/// Replace the trailing value of `[line]` with [replacement]. +/// Used by the unknown-type warning to offer the closest +/// matching valid type token (e.g. `byes` → `bytes`). +@immutable +class ReplaceLineValueFix extends QuickFix { + /// Zero-based line index inside the document. + final int line; + + /// The token to substitute for the existing value (the + /// substring after the colon, with surrounding whitespace + /// preserved by the applier). + final String replacement; + + const ReplaceLineValueFix({ + required this.line, + required this.replacement, + required String label, + }) : super(label); +} + +/// Ask the host (Studio) to install the named capability via +/// the Hub. The editor delegates to the +/// [InstallCapabilityCallback] passed into `FlowEditorPage` +/// and reanalyzes the document once the host returns. +@immutable +class InstallCapabilityFix extends QuickFix { + /// The capability spec the operator wrote (with or without an + /// `@version` tail). The host is responsible for parsing the + /// version part if it matters; the editor never strips it. + final String capability; + + const InstallCapabilityFix({ + required this.capability, + required String label, + }) : super(label); +} + +/// Host-side install handler signature. Returns the new +/// installed-capability list after the install completes (used +/// by the editor to refresh its analyzer without round-tripping +/// through the parent widget tree). Returns `null` on failure; +/// in that case the editor keeps the prior list and the issue +/// stays visible. +typedef InstallCapabilityCallback = + Future?> Function(String capability); + +/// Hover-tooltip request — the controller emits one of these +/// via a ValueNotifier when the pointer enters an issue range, +/// and clears it when the pointer leaves both the range and the +/// tooltip. The editor host listens and renders an overlay. +@immutable +class IssueHoverRequest { + /// Zero-based line of the issue the pointer is over. + final int line; + + /// Human-readable issue message (lifted from + /// `Issue.message`). + final String message; + + /// Issue severity. Drives the dot colour in the tooltip. + final IssueHoverSeverity severity; + + /// Pre-computed quick fixes the tooltip should render as + /// action buttons. Empty when the editor has nothing + /// actionable to offer. + final List fixes; + + /// Global cursor position at hover time — the host overlay + /// uses it to anchor the tooltip near the pointer. + final Offset globalPosition; + + const IssueHoverRequest({ + required this.line, + required this.message, + required this.severity, + required this.fixes, + required this.globalPosition, + }); +} + +enum IssueHoverSeverity { error, warning, info } diff --git a/pubspec.yaml b/pubspec.yaml index 1af70d8..341a75e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: fai_studio_flow_editor description: Swappable inline YAML editor for F∆I Studio flows. -version: 0.16.0 +version: 0.17.0 publish_to: 'none' repository: https://git.flemming.ai/fai/studio-flow-editor diff --git a/test/flow_analyzer_test.dart b/test/flow_analyzer_test.dart index 1fb58b2..a9c1c13 100644 --- a/test/flow_analyzer_test.dart +++ b/test/flow_analyzer_test.dart @@ -3,6 +3,7 @@ // custom CodeController's wavy-underline overrides. import 'package:fai_studio_flow_editor/src/flow_analyzer.dart'; +import 'package:fai_studio_flow_editor/src/quick_fix.dart'; import 'package:flutter_code_editor/flutter_code_editor.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:highlight/languages/yaml.dart'; @@ -103,5 +104,55 @@ steps: ''')); expect(r.issues, isEmpty); }); + + test('attaches InstallCapabilityFix to unknown-capability issue', + () async { + final a = FlowAnalyzer( + availableCapabilities: () => const ['debug.echo'], + ); + final r = await a.analyze(_wrap(''' +name: x +steps: + - id: c + use: text.classify@^1 +''')); + expect(r.issues, hasLength(1)); + final fixes = a.fixesFor(r.issues.first); + expect(fixes, hasLength(1)); + final fix = fixes.first; + expect(fix, isA()); + expect((fix as InstallCapabilityFix).capability, 'text.classify@^1'); + }); + + test('suggests closest valid type for a misspelled value', () async { + final a = FlowAnalyzer(availableCapabilities: () => const []); + final r = await a.analyze(_wrap(''' +name: x +inputs: + pdf: byes +steps: [] +''')); + expect(r.issues, hasLength(1)); + final fixes = a.fixesFor(r.issues.first); + expect(fixes, hasLength(1)); + final fix = fixes.first; + expect(fix, isA()); + expect((fix as ReplaceLineValueFix).replacement, 'bytes'); + expect(fix.line, r.issues.first.line); + }); + + test('emits no fix when the typo is far from every known type', + () async { + final a = FlowAnalyzer(availableCapabilities: () => const []); + final r = await a.analyze(_wrap(''' +name: x +inputs: + pdf: zonglefax +steps: [] +''')); + expect(r.issues, hasLength(1)); + final fixes = a.fixesFor(r.issues.first); + expect(fixes, isEmpty); + }); }); }