add find-in-files sidebar panel + Ctrl/Cmd+Shift+F
The find-in-files UI on top of the search.grep engine. A FindInFilesController drives search.grep, accumulates streamed search.match events (scoped to the active searchId, stale ids ignored) grouped by file, and opens a match in the editor at its line. The SearchPanelView contributes a sidebar tab: a debounced query box, regex + case toggles, include/exclude glob fields, and a grouped results list with the matched span highlighted. findInFiles.open (Ctrl/Cmd+Shift+F) reveals and activates the search tab. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,151 @@
|
||||
/// Unit tests for [FindInFilesController] — query state, streamed match
|
||||
/// accumulation, grouping, stale-id filtering, cancel, and open (T-52).
|
||||
library;
|
||||
|
||||
import 'package:clide/builtin/search/src/find_in_files_controller.dart';
|
||||
import 'package:clide/clide.dart';
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/src/search/match.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import '../../helpers/kernel_fixture.dart';
|
||||
|
||||
IpcResponse _ok(Map<String, Object?> data) => IpcResponse.ok(id: '', data: data);
|
||||
|
||||
void main() {
|
||||
late KernelFixture f;
|
||||
FindInFilesController? ctrl;
|
||||
|
||||
setUp(() async {
|
||||
f = await KernelFixture.create();
|
||||
f.ipc.stub('search.grep', (_) async => _ok({'searchId': 's1'}));
|
||||
f.ipc.stub('search.cancel', (_) async => _ok(const {}));
|
||||
f.ipc.stub('editor.open', (_) async => _ok(const {}));
|
||||
});
|
||||
tearDown(() async {
|
||||
ctrl?.dispose();
|
||||
ctrl = null;
|
||||
await f.dispose();
|
||||
});
|
||||
|
||||
FindInFilesController make() => ctrl = FindInFilesController(ipc: f.ipc, events: f.services.events);
|
||||
|
||||
void emitMatch(String id, List<Map<String, Object?>> matches) {
|
||||
f.services.events.emit(DaemonEvent(
|
||||
subsystem: 'search',
|
||||
kind: 'search.match',
|
||||
data: {'searchId': id, 'matches': matches},
|
||||
ts: DateTime.now().toUtc(),
|
||||
));
|
||||
}
|
||||
|
||||
Map<String, Object?> m(String path, int line) => {'path': path, 'line': line, 'matchStart': 0, 'matchEnd': 3, 'preview': 'foo bar'};
|
||||
|
||||
test('run sends search.grep with the current options and sets running', () async {
|
||||
Map<String, Object?>? sent;
|
||||
f.ipc.stub('search.grep', (args) async {
|
||||
sent = args;
|
||||
return _ok({'searchId': 's1'});
|
||||
});
|
||||
final c = make()
|
||||
..setRegex(true)
|
||||
..setIgnoreCase(true);
|
||||
c.include = '*.dart';
|
||||
await c.run('foo');
|
||||
expect(sent!['pattern'], 'foo');
|
||||
expect(sent!['regex'], isTrue);
|
||||
expect(sent!['ignoreCase'], isTrue);
|
||||
expect(sent!['include'], ['*.dart']);
|
||||
});
|
||||
|
||||
test('empty pattern clears without dispatching a search', () async {
|
||||
var called = false;
|
||||
f.ipc.stub('search.grep', (_) async {
|
||||
called = true;
|
||||
return _ok({'searchId': 's1'});
|
||||
});
|
||||
final c = make();
|
||||
await c.run(' ');
|
||||
expect(called, isFalse);
|
||||
expect(c.running, isFalse);
|
||||
});
|
||||
|
||||
test('streamed matches accumulate and group by file', () async {
|
||||
final c = make();
|
||||
await c.run('foo');
|
||||
emitMatch('s1', [m('a.dart', 1), m('a.dart', 5), m('b.dart', 2)]);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(c.matchCount, 3);
|
||||
final g = c.grouped();
|
||||
expect(g.keys, containsAll(['a.dart', 'b.dart']));
|
||||
expect(g['a.dart'], hasLength(2));
|
||||
expect(c.fileCount, 2);
|
||||
});
|
||||
|
||||
test('matches from a stale search id are ignored', () async {
|
||||
final c = make();
|
||||
await c.run('foo'); // activeSearchId == s1
|
||||
emitMatch('OLD', [m('z.dart', 9)]);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(c.matchCount, 0);
|
||||
});
|
||||
|
||||
test('search.done clears running', () async {
|
||||
final c = make();
|
||||
await c.run('foo');
|
||||
f.services.events.emit(DaemonEvent(
|
||||
subsystem: 'search',
|
||||
kind: 'search.done',
|
||||
data: const {'searchId': 's1', 'cancelled': false},
|
||||
ts: DateTime.now().toUtc(),
|
||||
));
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(c.running, isFalse);
|
||||
expect(c.done, isTrue);
|
||||
});
|
||||
|
||||
test('search.error surfaces the message', () async {
|
||||
final c = make();
|
||||
await c.run('(bad');
|
||||
f.services.events.emit(DaemonEvent(
|
||||
subsystem: 'search',
|
||||
kind: 'search.error',
|
||||
data: const {'searchId': 's1', 'message': 'invalid regex: x'},
|
||||
ts: DateTime.now().toUtc(),
|
||||
));
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(c.error, contains('invalid regex'));
|
||||
expect(c.running, isFalse);
|
||||
});
|
||||
|
||||
test('re-running clears prior results', () async {
|
||||
final c = make();
|
||||
await c.run('foo');
|
||||
emitMatch('s1', [m('a.dart', 1)]);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(c.matchCount, 1);
|
||||
await c.run('bar');
|
||||
expect(c.matchCount, 0); // cleared on new run
|
||||
});
|
||||
|
||||
test('openMatch issues editor.open with the line', () async {
|
||||
Map<String, Object?>? sent;
|
||||
f.ipc.stub('editor.open', (args) async {
|
||||
sent = args;
|
||||
return _ok(const {});
|
||||
});
|
||||
final c = make();
|
||||
c.openMatch(const SearchMatch(path: 'a.dart', line: 7, matchStart: 0, matchEnd: 3, preview: 'foo'));
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(sent!['path'], 'a.dart');
|
||||
expect(sent!['line'], 7);
|
||||
});
|
||||
|
||||
test('cancel stops running', () async {
|
||||
final c = make();
|
||||
await c.run('foo');
|
||||
expect(c.running, isTrue);
|
||||
c.cancel();
|
||||
expect(c.running, isFalse);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
/// Widget test for [SearchPanelView] — searching renders streamed
|
||||
/// matches grouped by file and clicking a match opens it (T-52).
|
||||
library;
|
||||
|
||||
import 'package:clide/builtin/search/src/search_panel_view.dart';
|
||||
import 'package:clide/clide.dart';
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import '../../helpers/kernel_fixture.dart';
|
||||
import '../../helpers/widget_harness.dart';
|
||||
|
||||
IpcResponse _ok(Map<String, Object?> data) => IpcResponse.ok(id: '', data: data);
|
||||
|
||||
void main() {
|
||||
late KernelFixture f;
|
||||
|
||||
setUp(() async {
|
||||
f = await KernelFixture.create();
|
||||
f.ipc.stub('search.grep', (_) async => _ok({'searchId': 's1'}));
|
||||
f.ipc.stub('search.cancel', (_) async => _ok(const {}));
|
||||
});
|
||||
tearDown(() => f.dispose());
|
||||
|
||||
void emitMatches() {
|
||||
f.services.events.emit(DaemonEvent(
|
||||
subsystem: 'search',
|
||||
kind: 'search.match',
|
||||
data: const {
|
||||
'searchId': 's1',
|
||||
'matches': [
|
||||
{'path': 'lib/a.dart', 'line': 12, 'matchStart': 6, 'matchEnd': 9, 'preview': 'final foo = 1;'},
|
||||
],
|
||||
},
|
||||
ts: DateTime.now().toUtc(),
|
||||
));
|
||||
}
|
||||
|
||||
testWidgets('search renders matches grouped by file', (tester) async {
|
||||
await tester.pumpWidget(harness(f, const SearchPanelView()));
|
||||
await tester.enterText(find.byType(EditableText).first, 'foo');
|
||||
await tester.pump(const Duration(milliseconds: 250)); // debounce → run()
|
||||
await pumpAsync(tester); // search.grep resolves; activeSearchId set
|
||||
emitMatches();
|
||||
await pumpAsync(tester);
|
||||
|
||||
expect(find.text('lib/a.dart'), findsOneWidget); // file group header
|
||||
expect(find.text('12'), findsOneWidget); // line number
|
||||
});
|
||||
|
||||
testWidgets('tapping a match opens the editor at its line', (tester) async {
|
||||
Map<String, Object?>? opened;
|
||||
f.ipc.stub('editor.open', (args) async {
|
||||
opened = args;
|
||||
return _ok(const {});
|
||||
});
|
||||
await tester.pumpWidget(harness(f, const SearchPanelView()));
|
||||
await tester.enterText(find.byType(EditableText).first, 'foo');
|
||||
await tester.pump(const Duration(milliseconds: 250));
|
||||
await pumpAsync(tester);
|
||||
emitMatches();
|
||||
await pumpAsync(tester);
|
||||
|
||||
await tester.tap(find.text('12')); // the match row's line number
|
||||
await pumpAsync(tester);
|
||||
|
||||
expect(opened, isNotNull);
|
||||
expect(opened!['path'], 'lib/a.dart');
|
||||
expect(opened!['line'], 12);
|
||||
});
|
||||
}
|
||||
@@ -35,6 +35,10 @@ void main() {
|
||||
expect(parseIntentId('quickOpen.accept'), isA<QuickOpenAcceptIntent>());
|
||||
});
|
||||
|
||||
test('returns the findInFiles.open intent', () {
|
||||
expect(parseIntentId('findInFiles.open'), isA<FindInFilesIntent>());
|
||||
});
|
||||
|
||||
test('returns the text.scale* intents', () {
|
||||
expect(parseIntentId('text.scaleIncrease'), isA<TextScaleIncreaseIntent>());
|
||||
expect(parseIntentId('text.scaleDecrease'), isA<TextScaleDecreaseIntent>());
|
||||
|
||||
@@ -18,10 +18,12 @@ void main() {
|
||||
|
||||
setUp(() async {
|
||||
f = await KernelFixture.create();
|
||||
f.ipc.stub('files.walk', (_) async => IpcResponse.ok(id: '1', data: const {
|
||||
'files': ['lib/main.dart', 'lib/app.dart', 'README.md'],
|
||||
'truncated': false,
|
||||
}));
|
||||
f.ipc.stub(
|
||||
'files.walk',
|
||||
(_) async => IpcResponse.ok(id: '1', data: const {
|
||||
'files': ['lib/main.dart', 'lib/app.dart', 'README.md'],
|
||||
'truncated': false,
|
||||
}));
|
||||
f.ipc.stub('editor.open', (args) async => IpcResponse.ok(id: '1', data: {'path': args['path']}));
|
||||
});
|
||||
tearDown(() => f.dispose());
|
||||
@@ -63,9 +65,7 @@ void main() {
|
||||
|
||||
testWidgets('tapping an md result publishes to the markdown reader bus', (tester) async {
|
||||
final published = <Message>[];
|
||||
final sub = f.services.messages
|
||||
.subscribe(publisher: 'builtin.markdown', channel: 'selection')
|
||||
.listen(published.add);
|
||||
final sub = f.services.messages.subscribe(publisher: 'builtin.markdown', channel: 'selection').listen(published.add);
|
||||
addTearDown(sub.cancel);
|
||||
|
||||
await tester.pumpWidget(harness(f, const QuickOpenOverlay()));
|
||||
|
||||
Reference in New Issue
Block a user