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
|
|
@ -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,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<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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue