diff --git a/lib/features/command_palette/command_palette_dialog.dart b/lib/features/command_palette/command_palette_dialog.dart index 99ea11ab..c0263c59 100644 --- a/lib/features/command_palette/command_palette_dialog.dart +++ b/lib/features/command_palette/command_palette_dialog.dart @@ -39,6 +39,8 @@ class _CommandPaletteDialogState extends State { final _focusNode = FocusNode(); final _scroll = material.ScrollController(); var _selected = 0; + List _cachedHits = const []; + String _cachedQuery = ''; /// Set by keyboard navigation: the list moves under a still pointer, and the /// hover that causes must not take the selection back. Cleared as soon as @@ -51,20 +53,32 @@ class _CommandPaletteDialogState extends State { @override void initState() { super.initState(); - _controller.addListener(() { - setState(() => _selected = 0); - if (_scroll.hasClients) _scroll.jumpTo(0); - }); + _updateHits(); + _controller.addListener(_onQueryChanged); + QueryaCommandRegistry.instance.addListener(_onRegistryChanged); } @override void dispose() { + QueryaCommandRegistry.instance.removeListener(_onRegistryChanged); + _controller.removeListener(_onQueryChanged); _scroll.dispose(); _controller.dispose(); _focusNode.dispose(); super.dispose(); } + void _onQueryChanged() { + _updateHits(); + setState(() => _selected = 0); + if (_scroll.hasClients) _scroll.jumpTo(0); + } + + void _onRegistryChanged() { + if (!mounted) return; + setState(_updateHits); + } + bool get _objectPrefix { final t = _controller.text; return t.startsWith('#') || t.startsWith('@'); @@ -73,10 +87,23 @@ class _CommandPaletteDialogState extends State { String get _objectQuery => _objectPrefix ? _controller.text.substring(1) : ''; - List get _hits { - if (_objectPrefix) return const []; + void _updateHits() { + if (_objectPrefix) { + _cachedHits = const []; + _cachedQuery = _controller.text; + return; + } + final q = _controller.text; final ctx = widget.hostContext.mounted ? widget.hostContext : context; - return QueryaCommandRegistry.instance.search(_controller.text, context: ctx); + _cachedHits = QueryaCommandRegistry.instance.search(q, context: ctx); + _cachedQuery = q; + } + + List get _hits { + if (_cachedQuery != _controller.text) { + _updateHits(); + } + return _cachedHits; } void _openQuickSwitcher() { @@ -201,9 +228,11 @@ class _CommandPaletteDialogState extends State { child: material.InkWell( key: ValueKey(command.id), onTap: () => _run(command), - onHover: (_) { + onHover: (hovered) { if (_ignoreHover) return; - setState(() => _selected = index); + if (hovered && _selected != index) { + setState(() => _selected = index); + } }, borderRadius: BorderRadius.circular(6), child: DecoratedBox( diff --git a/lib/features/command_palette/quick_switcher_dialog.dart b/lib/features/command_palette/quick_switcher_dialog.dart index 6704d1a9..7f869888 100644 --- a/lib/features/command_palette/quick_switcher_dialog.dart +++ b/lib/features/command_palette/quick_switcher_dialog.dart @@ -266,9 +266,11 @@ class _QuickSwitcherDialogState extends State { child: material.InkWell( key: ValueKey(object.id), onTap: () => _open(object), - onHover: (_) { + onHover: (hovered) { if (_ignoreHover) return; - setState(() => _selected = index); + if (hovered && _selected != index) { + setState(() => _selected = index); + } }, borderRadius: BorderRadius.circular(6), child: DecoratedBox( diff --git a/test/features/command_palette/command_palette_dialog_test.dart b/test/features/command_palette/command_palette_dialog_test.dart index 49c88803..fbb97afb 100644 --- a/test/features/command_palette/command_palette_dialog_test.dart +++ b/test/features/command_palette/command_palette_dialog_test.dart @@ -1,3 +1,4 @@ +import 'dart:ui'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:querya_desktop/core/actions/querya_command.dart'; @@ -53,6 +54,49 @@ void main() { expect(find.byKey(const ValueKey('querya.connection.new')), findsNothing); }); + testWidgets('hovering over an item updates selection without redundant search', (tester) async { + registry.register( + QueryaCommand( + id: 'test.cmd.1', + title: 'Command 1', + category: 'Test', + execute: (_) {}, + ), + ); + registry.register( + QueryaCommand( + id: 'test.cmd.2', + title: 'Command 2', + category: 'Test', + execute: (_) {}, + ), + ); + await pumpPalette(tester); + + final firstItem = find.byKey(const ValueKey('test.cmd.1')); + final secondItem = find.byKey(const ValueKey('test.cmd.2')); + + expect(firstItem, findsOneWidget); + expect(secondItem, findsOneWidget); + + final gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(location: tester.getCenter(firstItem)); + await tester.pump(); + + // Hover over second item + await gesture.moveTo(tester.getCenter(secondItem)); + await tester.pump(); + + final inkWellFinder = find.byKey(const ValueKey('test.cmd.2')); + final decoratedBox = tester.widget( + find.descendant(of: inkWellFinder, matching: find.byType(DecoratedBox)), + ); + final boxDecoration = decoratedBox.decoration as BoxDecoration; + expect(boxDecoration.color, isNot(equals(Colors.transparent))); + + await gesture.removePointer(); + }); + testWidgets('Escape closes the palette', (tester) async { registry.ensureCoreDefaults(); await pumpPalette(tester);