From b7708d140a4826d7bd7c20c08e2730b029dd5dd8 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Fri, 25 Sep 2026 14:56:49 +0300 Subject: [PATCH] perf(connections): debounce tree object filters and the sidebar connection search (Closes #878) The PostgreSQL / MySQL / SQLite object-group filters and the sidebar 'Filter connections...' field called setState on every keystroke, re-filtering and rebuilding the whole subtree each time. Add FilterDebouncer (150 ms, the same as the data grid filter bar) and apply the query only after the user pauses. Clearing cancels a pending update and takes effect immediately. In the sidebar, the search icon and clear button are driven by the text controller through ValueListenableBuilders, so they still react to every keystroke without rebuilding the panel. --- .../connections/connections_panel.dart | 84 ++++++++++---- .../connections/connections_panel_mysql.dart | 15 ++- .../connections_panel_pg_tree.dart | 15 ++- .../connections/connections_panel_sqlite.dart | 15 ++- .../connection_tree_quick_search_test.dart | 107 ++++++++++++++++++ 5 files changed, 202 insertions(+), 34 deletions(-) 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();