Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 62 additions & 22 deletions lib/features/connections/connections_panel.dart
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import 'dart:async' show Timer;

import 'package:flutter/material.dart' as material
show
AlertDialog,
TextEditingValue,
ValueListenableBuilder,
BuildContext,
Column,
Container,
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -534,6 +560,7 @@ class ConnectionsPanelState extends State<ConnectionsPanel> {
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;
Expand Down Expand Up @@ -561,6 +588,7 @@ class ConnectionsPanelState extends State<ConnectionsPanel> {
@override
void dispose() {
_treeSelection.dispose();
_searchDebouncer.cancel();
_searchController.dispose();
_searchFocusNode.dispose();
super.dispose();
Expand All @@ -580,6 +608,7 @@ class ConnectionsPanelState extends State<ConnectionsPanel> {

/// For testing global search filtering.
void setSearchQueryForTest(String query) {
_searchDebouncer.cancel();
_searchController.text = query;
setState(() => _searchQuery = query);
}
Expand Down Expand Up @@ -976,20 +1005,26 @@ class ConnectionsPanelState extends State<ConnectionsPanel> {
),
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<material.TextEditingValue>(
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,
Expand All @@ -1007,20 +1042,25 @@ class ConnectionsPanelState extends State<ConnectionsPanel> {
),
),
),
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<material.TextEditingValue>(
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,
),
),
),
],
),
),
Expand Down
15 changes: 11 additions & 4 deletions lib/features/connections/connections_panel_mysql.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> _pinnedItems = {};

@override
void dispose() {
_filterDebouncer.cancel();
_filterController.dispose();
super.dispose();
}
Expand Down Expand Up @@ -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,
),
Expand Down
15 changes: 11 additions & 4 deletions lib/features/connections/connections_panel_pg_tree.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> _pinnedItems = {};

@override
void dispose() {
_filterDebouncer.cancel();
_filterController.dispose();
super.dispose();
}
Expand Down Expand Up @@ -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,
),
Expand Down
15 changes: 11 additions & 4 deletions lib/features/connections/connections_panel_sqlite.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> _pinnedItems = {};

@override
void dispose() {
_filterDebouncer.cancel();
_filterController.dispose();
super.dispose();
}
Expand Down Expand Up @@ -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,
),
Expand Down
107 changes: 107 additions & 0 deletions test/features/connections/connection_tree_quick_search_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,32 @@ void main() {
});
});

group('FilterDebouncer', () {
testWidgets('runs only the last action after the pause', (tester) async {
final debouncer = FilterDebouncer();
final calls = <String>[];

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;

Expand Down Expand Up @@ -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<ConnectionsPanelState>();

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<ConnectionsPanelState>();

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<ConnectionsPanelState>();

Expand Down
Loading