diff --git a/lib/features/connections/connections_panel.dart b/lib/features/connections/connections_panel.dart index 3e74b30..39add6f 100644 --- a/lib/features/connections/connections_panel.dart +++ b/lib/features/connections/connections_panel.dart @@ -1,6 +1,10 @@ +import 'dart:async' show Timer; + import 'package:flutter/material.dart' as material show AlertDialog, + TextEditingValue, + ValueListenableBuilder, BuildContext, Column, Container, @@ -166,6 +170,28 @@ material.Widget lazyConnectionTreeList({ ); } +/// Pause after the last keystroke before a connections / tree filter is applied +/// (same order as the data grid filter bar). +const kTreeFilterDebounce = Duration(milliseconds: 150); + +/// Applies a text filter only once the user pauses typing, so each keystroke +/// does not rebuild a whole tree subtree. +class FilterDebouncer { + FilterDebouncer({this.delay = kTreeFilterDebounce}); + + final Duration delay; + Timer? _timer; + + /// Runs [action] after [delay]; a newer call replaces a pending one. + void run(void Function() action) { + _timer?.cancel(); + _timer = Timer(delay, action); + } + + /// Drops a pending action (clear button, dispose). + void cancel() => _timer?.cancel(); +} + /// Compact inline search/filter input for an expanded tree object group (e.g. Tables, Views). class TreeObjectFilterBar extends material.StatelessWidget { const TreeObjectFilterBar({ @@ -534,6 +560,7 @@ class ConnectionsPanelState extends State { final _searchController = material.TextEditingController(); final _searchFocusNode = material.FocusNode(); String _searchQuery = ''; + final _searchDebouncer = FilterDebouncer(); /// Ignores stale [setState] when multiple [_loadData] runs overlap (e.g. tests). int _loadDataGeneration = 0; @@ -561,6 +588,7 @@ class ConnectionsPanelState extends State { @override void dispose() { _treeSelection.dispose(); + _searchDebouncer.cancel(); _searchController.dispose(); _searchFocusNode.dispose(); super.dispose(); @@ -580,6 +608,7 @@ class ConnectionsPanelState extends State { /// For testing global search filtering. void setSearchQueryForTest(String query) { + _searchDebouncer.cancel(); _searchController.text = query; setState(() => _searchQuery = query); } @@ -976,20 +1005,26 @@ class ConnectionsPanelState extends State { ), child: material.Row( children: [ - material.Icon( - material.Icons.search_rounded, - size: 14, - color: q.isNotEmpty - ? theme.colorScheme.primary - : theme.colorScheme.mutedForeground, + // Driven by the text field itself, so it reacts to every + // keystroke while the list filter below is debounced. + material.ValueListenableBuilder( + valueListenable: _searchController, + builder: (_, value, __) => material.Icon( + material.Icons.search_rounded, + size: 14, + color: value.text.trim().isNotEmpty + ? theme.colorScheme.primary + : theme.colorScheme.mutedForeground, + ), ), const Gap(6), material.Expanded( child: material.TextField( controller: _searchController, focusNode: _searchFocusNode, - onChanged: (val) => - setState(() => _searchQuery = val), + onChanged: (val) => _searchDebouncer.run(() { + if (mounted) setState(() => _searchQuery = val); + }), style: material.TextStyle( fontSize: 12, color: theme.colorScheme.foreground, @@ -1007,20 +1042,25 @@ class ConnectionsPanelState extends State { ), ), ), - if (q.isNotEmpty) ...[ - material.GestureDetector( - behavior: material.HitTestBehavior.opaque, - onTap: () { - _searchController.clear(); - setState(() => _searchQuery = ''); - }, - child: material.Icon( - material.Icons.close_rounded, - size: 14, - color: theme.colorScheme.mutedForeground, - ), - ), - ], + material.ValueListenableBuilder( + valueListenable: _searchController, + builder: (_, value, __) => + value.text.trim().isEmpty + ? const material.SizedBox.shrink() + : material.GestureDetector( + behavior: material.HitTestBehavior.opaque, + onTap: () { + _searchDebouncer.cancel(); + _searchController.clear(); + setState(() => _searchQuery = ''); + }, + child: material.Icon( + material.Icons.close_rounded, + size: 14, + color: theme.colorScheme.mutedForeground, + ), + ), + ), ], ), ), diff --git a/lib/features/connections/connections_panel_mysql.dart b/lib/features/connections/connections_panel_mysql.dart index 8d9cf50..b11a67f 100644 --- a/lib/features/connections/connections_panel_mysql.dart +++ b/lib/features/connections/connections_panel_mysql.dart @@ -613,11 +613,13 @@ class _MysqlObjectGroup extends StatefulWidget { class _MysqlObjectGroupState extends State<_MysqlObjectGroup> { bool _expanded = false; final _filterController = material.TextEditingController(); + final _filterDebouncer = FilterDebouncer(); String _filter = ''; final Set _pinnedItems = {}; @override void dispose() { + _filterDebouncer.cancel(); _filterController.dispose(); super.dispose(); } @@ -679,11 +681,16 @@ class _MysqlObjectGroupState extends State<_MysqlObjectGroup> { TreeObjectFilterBar( controller: _filterController, hintText: 'Filter ${widget.label.toLowerCase()}...', - onChanged: (val) => setState(() => _filter = val), - onClear: () => setState(() { - _filter = ''; - _filterController.clear(); + onChanged: (val) => _filterDebouncer.run(() { + if (mounted) setState(() => _filter = val); }), + onClear: () { + _filterDebouncer.cancel(); + setState(() { + _filter = ''; + _filterController.clear(); + }); + }, filteredCount: sorted.length, totalCount: widget.items.length, ), diff --git a/lib/features/connections/connections_panel_pg_tree.dart b/lib/features/connections/connections_panel_pg_tree.dart index 7133f6b..8fb15a0 100644 --- a/lib/features/connections/connections_panel_pg_tree.dart +++ b/lib/features/connections/connections_panel_pg_tree.dart @@ -794,11 +794,13 @@ class _PgObjectGroup extends StatefulWidget { class _PgObjectGroupState extends State<_PgObjectGroup> { bool _expanded = false; final _filterController = material.TextEditingController(); + final _filterDebouncer = FilterDebouncer(); String _filter = ''; final Set _pinnedItems = {}; @override void dispose() { + _filterDebouncer.cancel(); _filterController.dispose(); super.dispose(); } @@ -861,11 +863,16 @@ class _PgObjectGroupState extends State<_PgObjectGroup> { TreeObjectFilterBar( controller: _filterController, hintText: 'Filter ${widget.label.toLowerCase()}...', - onChanged: (val) => setState(() => _filter = val), - onClear: () => setState(() { - _filter = ''; - _filterController.clear(); + onChanged: (val) => _filterDebouncer.run(() { + if (mounted) setState(() => _filter = val); }), + onClear: () { + _filterDebouncer.cancel(); + setState(() { + _filter = ''; + _filterController.clear(); + }); + }, filteredCount: sorted.length, totalCount: widget.items.length, ), diff --git a/lib/features/connections/connections_panel_sqlite.dart b/lib/features/connections/connections_panel_sqlite.dart index 4f48ce0..584add6 100644 --- a/lib/features/connections/connections_panel_sqlite.dart +++ b/lib/features/connections/connections_panel_sqlite.dart @@ -377,11 +377,13 @@ class _SqliteObjectGroup extends StatefulWidget { class _SqliteObjectGroupState extends State<_SqliteObjectGroup> { bool _expanded = false; final _filterController = material.TextEditingController(); + final _filterDebouncer = FilterDebouncer(); String _filter = ''; final Set _pinnedItems = {}; @override void dispose() { + _filterDebouncer.cancel(); _filterController.dispose(); super.dispose(); } @@ -443,11 +445,16 @@ class _SqliteObjectGroupState extends State<_SqliteObjectGroup> { TreeObjectFilterBar( controller: _filterController, hintText: 'Filter ${widget.label.toLowerCase()}...', - onChanged: (val) => setState(() => _filter = val), - onClear: () => setState(() { - _filter = ''; - _filterController.clear(); + onChanged: (val) => _filterDebouncer.run(() { + if (mounted) setState(() => _filter = val); }), + onClear: () { + _filterDebouncer.cancel(); + setState(() { + _filter = ''; + _filterController.clear(); + }); + }, filteredCount: sorted.length, totalCount: widget.items.length, ), diff --git a/test/features/connections/connection_tree_quick_search_test.dart b/test/features/connections/connection_tree_quick_search_test.dart index 3d2bce0..7590319 100644 --- a/test/features/connections/connection_tree_quick_search_test.dart +++ b/test/features/connections/connection_tree_quick_search_test.dart @@ -87,6 +87,32 @@ void main() { }); }); + group('FilterDebouncer', () { + testWidgets('runs only the last action after the pause', (tester) async { + final debouncer = FilterDebouncer(); + final calls = []; + + debouncer.run(() => calls.add('a')); + await tester.pump(const Duration(milliseconds: 50)); + debouncer.run(() => calls.add('ab')); + await tester.pump(const Duration(milliseconds: 50)); + debouncer.run(() => calls.add('abc')); + expect(calls, isEmpty); + + await tester.pump(kTreeFilterDebounce); + expect(calls, ['abc']); + }); + + testWidgets('cancel drops a pending action', (tester) async { + final debouncer = FilterDebouncer(); + var ran = false; + debouncer.run(() => ran = true); + debouncer.cancel(); + await tester.pump(kTreeFilterDebounce * 2); + expect(ran, isFalse); + }); + }); + group('ConnectionsPanel global search and tree filtering', () { late Directory tempDir; @@ -156,6 +182,87 @@ void main() { await FoldersStorage.instance.reload(); }); + testWidgets('typing in the sidebar filter applies once after the debounce', + (tester) async { + final panelKey = GlobalKey(); + + await tester.binding.setSurfaceSize(const material.Size(400, 700)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + await tester.pumpWidget( + ShadcnApp( + theme: AppTheme.dark, + home: material.SizedBox( + width: 400, + height: 700, + child: ConnectionsPanel( + key: panelKey, + skipInitialDbLoadForTest: true, + ), + ), + ), + ); + await tester.runAsync(() async { + await panelKey.currentState!.reloadConnectionsFromDb(); + }); + await tester.pump(); + + final field = find.byType(material.TextField).first; + for (final q in ['a', 'an', 'ana', 'anal']) { + await tester.enterText(field, q); + await tester.pump(const Duration(milliseconds: 40)); + } + // Still inside the debounce window: nothing filtered yet. + expect(find.text('Prod Postgres'), findsOneWidget); + expect(find.text('1/3'), findsNothing); + + await tester.pump(kTreeFilterDebounce); + expect(find.text('Local Analytics'), findsOneWidget); + expect(find.text('Prod Postgres'), findsNothing); + expect(find.text('1/3'), findsOneWidget); + }); + + testWidgets('clear button resets the sidebar filter immediately', + (tester) async { + final panelKey = GlobalKey(); + + await tester.binding.setSurfaceSize(const material.Size(400, 700)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + await tester.pumpWidget( + ShadcnApp( + theme: AppTheme.dark, + home: material.SizedBox( + width: 400, + height: 700, + child: ConnectionsPanel( + key: panelKey, + skipInitialDbLoadForTest: true, + ), + ), + ), + ); + await tester.runAsync(() async { + await panelKey.currentState!.reloadConnectionsFromDb(); + }); + await tester.pump(); + + await tester.enterText(find.byType(material.TextField).first, 'analytics'); + await tester.pump(kTreeFilterDebounce); + expect(find.text('Prod MySQL'), findsNothing); + + await tester.tap(find.byIcon(material.Icons.close_rounded).first); + await tester.pump(); + expect(find.text('Prod MySQL'), findsOneWidget); + + // A keystroke typed just before clearing must not re-apply afterwards. + await tester.enterText(find.byType(material.TextField).first, 'x'); + await tester.pump(); // the clear button follows the text, not the debounce + await tester.tap(find.byIcon(material.Icons.close_rounded).first); + await tester.pump(kTreeFilterDebounce * 2); + expect(find.text('Prod MySQL'), findsOneWidget); + }); + testWidgets('global search filters connections and auto-expands folder', (tester) async { final panelKey = GlobalKey();