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:
parent
1b50924e16
commit
f43c1ac6cf
7 changed files with 884 additions and 198 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
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,18 +123,75 @@ class FlowAnalyzer extends AbstractAnalyzer {
|
|||
// declaring a typo.
|
||||
if (value.trim().isEmpty) continue;
|
||||
if (!kKnownTypes.contains(value)) {
|
||||
out.add(
|
||||
Issue(
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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 <capability>" 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<FlowEditorPage>
|
|||
late Future<List<_FlowFile>> _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<FlowEditorPage>
|
|||
() => 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<FlowEditorPage>
|
|||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.codeController.hoverRequest.removeListener(_onHoverChanged);
|
||||
_removeHoverEntry();
|
||||
_controller.removeListener(_onCtrlChanged);
|
||||
_controller.dispose();
|
||||
_tabs.dispose();
|
||||
|
|
@ -126,6 +146,77 @@ class _FlowEditorPageState extends State<FlowEditorPage>
|
|||
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<void> _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<List<_FlowFile>> _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 <cap>" or "Change to \"bytes\"").
|
||||
class _DiagnosticStrip extends StatefulWidget {
|
||||
final CodeController controller;
|
||||
const _DiagnosticStrip({required this.controller});
|
||||
final FlowYamlCodeController controller;
|
||||
final Future<void> 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<QuickFix, bool> _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<Issue> issues) {
|
||||
final buf = StringBuffer();
|
||||
for (final i in issues) {
|
||||
buf.writeln('L${i.line + 1}: ${i.message}');
|
||||
}
|
||||
return buf.toString().trimRight();
|
||||
}
|
||||
|
||||
Future<void> _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,24 +1322,27 @@ 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),
|
||||
),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
// --- 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,
|
||||
|
|
@ -1229,10 +1364,9 @@ class _DiagnosticStripState extends State<_DiagnosticStrip> {
|
|||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
child: SelectableText(
|
||||
'L${issues.first.line + 1}: ${issues.first.message}',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontFamily: 'JetBrains Mono',
|
||||
fontSize: 11,
|
||||
|
|
@ -1240,6 +1374,12 @@ class _DiagnosticStripState extends State<_DiagnosticStrip> {
|
|||
),
|
||||
),
|
||||
),
|
||||
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,
|
||||
|
|
@ -1247,62 +1387,29 @@ class _DiagnosticStripState extends State<_DiagnosticStrip> {
|
|||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_expanded) ...[
|
||||
const SizedBox(height: 6),
|
||||
Divider(height: 1, color: theme.colorScheme.outlineVariant),
|
||||
const SizedBox(height: 6),
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxHeight: 140),
|
||||
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)
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
_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}'),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
@ -1312,7 +1419,6 @@ class _DiagnosticStripState extends State<_DiagnosticStrip> {
|
|||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -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<QuickFix> fixes;
|
||||
final Color tone;
|
||||
final Map<QuickFix, bool> busy;
|
||||
final Future<void> 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<void> 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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<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
|
||||
// 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 {
|
||||
|
|
|
|||
112
lib/src/quick_fix.dart
Normal file
112
lib/src/quick_fix.dart
Normal file
|
|
@ -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<List<String>?> 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<QuickFix> 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 }
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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<InstallCapabilityFix>());
|
||||
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<ReplaceLineValueFix>());
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue