From f96c565acddcbe7ace18dff920a44157f1ddce72 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Sun, 31 May 2026 20:53:50 +0200 Subject: [PATCH] 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) --- CHANGELOG.md | 3 + assets/keymaps/default.yaml | 4 + lib/app.dart | 8 + lib/builtin/search/search.dart | 3 + lib/builtin/search/src/extension.dart | 31 +++ .../search/src/find_in_files_controller.dart | 152 +++++++++++ lib/builtin/search/src/search_panel_view.dart | 250 ++++++++++++++++++ lib/kernel/src/keymap/intents.dart | 8 + lib/main.dart | 2 + .../search/find_in_files_controller_test.dart | 151 +++++++++++ .../search/search_panel_view_test.dart | 72 +++++ test/kernel/src/keymap/intents_test.dart | 4 + test/widgets/quick_open_overlay_test.dart | 14 +- 13 files changed, 695 insertions(+), 7 deletions(-) create mode 100644 lib/builtin/search/search.dart create mode 100644 lib/builtin/search/src/extension.dart create mode 100644 lib/builtin/search/src/find_in_files_controller.dart create mode 100644 lib/builtin/search/src/search_panel_view.dart create mode 100644 test/builtin/search/find_in_files_controller_test.dart create mode 100644 test/builtin/search/search_panel_view_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index ddbfb4e2..c9452cb9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,9 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit. ### Added +- Find-in-files sidebar panel (Ctrl/Cmd+Shift+F): search the workspace with + regex and case toggles plus include/exclude globs; results stream in grouped + by file and clicking a match opens the editor at that line. (T-52) - Workspace content-search engine with `search.grep` / `search.cancel` commands: a pure-Dart, isolate-parallel grep (literal or regex, case + include/exclude glob filters) that streams matches and honours the `ignore_files:` chain. The diff --git a/assets/keymaps/default.yaml b/assets/keymaps/default.yaml index 22915bc9..768f8c1f 100644 --- a/assets/keymaps/default.yaml +++ b/assets/keymaps/default.yaml @@ -76,6 +76,10 @@ bindings: keys: escape when: quickOpen.open + # -- Find in files ---------------------------------------------------- + - intent: findInFiles.open + keys: [ctrl+shift+f, meta+shift+f] + # -- Text scale ------------------------------------------------------- # On most layouts `+` is `shift+equal`; we bind both so users who # think of it as Ctrl+Plus and users who hit Ctrl+= both work. diff --git a/lib/app.dart b/lib/app.dart index 6b151b1c..e4747aff 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -125,6 +125,14 @@ class _RootShellState extends State<_RootShell> { return null; }, ), + FindInFilesIntent: CallbackAction( + onInvoke: (_) { + widget.services.arrangement.setVisible(Slots.sidebar, true); + widget.services.arrangement.setCollapsed(Slots.sidebar, false); + widget.services.panels.activateTab(Slots.sidebar, 'search.findInFiles'); + return null; + }, + ), FocusNextPanelIntent: CallbackAction( onInvoke: (_) { widget.services.focus.focusNextSlot(); diff --git a/lib/builtin/search/search.dart b/lib/builtin/search/search.dart new file mode 100644 index 00000000..be1d6b7b --- /dev/null +++ b/lib/builtin/search/search.dart @@ -0,0 +1,3 @@ +export 'src/extension.dart'; +export 'src/find_in_files_controller.dart'; +export 'src/search_panel_view.dart'; diff --git a/lib/builtin/search/src/extension.dart b/lib/builtin/search/src/extension.dart new file mode 100644 index 00000000..1268fc14 --- /dev/null +++ b/lib/builtin/search/src/extension.dart @@ -0,0 +1,31 @@ +import 'package:clide/builtin/search/src/search_panel_view.dart'; +import 'package:clide/extension/extension.dart'; +import 'package:clide/kernel/kernel.dart'; +import 'package:clide/widgets/widgets.dart'; + +/// Find-in-files panel. Contributes a sidebar tab that runs workspace +/// content searches through the daemon's `search.*` subsystem (the +/// pure-Dart isolate-pool grep, D-79) and lists matches grouped by +/// file, click-to-open at the line. +class SearchExtension extends ClideExtension { + @override + String get id => 'builtin.search'; + @override + String get title => 'Search'; + @override + String get version => '0.1.0'; + @override + List get dependsOn => const []; + + @override + List get contributions => [ + TabContribution( + id: 'search.findInFiles', + slot: Slots.sidebar, + title: 'Search', + icon: PhosphorIcons.magnifyingGlass, + priority: -90, + build: (_) => const SearchPanelView(), + ), + ]; +} diff --git a/lib/builtin/search/src/find_in_files_controller.dart b/lib/builtin/search/src/find_in_files_controller.dart new file mode 100644 index 00000000..a9b37a5d --- /dev/null +++ b/lib/builtin/search/src/find_in_files_controller.dart @@ -0,0 +1,152 @@ +/// State model for the find-in-files panel (T-52, per D-79). +/// +/// Holds the query + option state, drives the `search.grep` IPC verb, +/// and accumulates streamed `search.match` events (scoped to the active +/// searchId) into a per-file grouping. Re-running cancels the prior +/// search; results from a stale search id are ignored. +library; + +import 'dart:async'; + +import 'package:clide/kernel/kernel.dart'; +import 'package:clide/src/search/match.dart'; +import 'package:flutter/foundation.dart'; + +class FindInFilesController extends ChangeNotifier { + FindInFilesController({required this.ipc, required this.events}) { + _sub = events.on().listen(_onEvent); + } + + final DaemonClient ipc; + final DaemonBus events; + StreamSubscription? _sub; + + // -- Query state ---------------------------------------------------------- + String pattern = ''; + bool regex = false; + bool ignoreCase = false; + String includeGlobs = ''; + String excludeGlobs = ''; + + // -- Result state --------------------------------------------------------- + String? _activeSearchId; + final List _matches = []; + bool _running = false; + bool _done = false; + String? _error; + + List get matches => List.unmodifiable(_matches); + bool get running => _running; + bool get done => _done; + String? get error => _error; + int get matchCount => _matches.length; + + /// Matches grouped by file path, preserving first-seen file order. + Map> grouped() { + final out = >{}; + for (final m in _matches) { + (out[m.path] ??= []).add(m); + } + return out; + } + + int get fileCount => grouped().length; + + void setRegex(bool v) { + if (regex == v) return; + regex = v; + notifyListeners(); + } + + void setIgnoreCase(bool v) { + if (ignoreCase == v) return; + ignoreCase = v; + notifyListeners(); + } + + set include(String v) => includeGlobs = v; + set exclude(String v) => excludeGlobs = v; + + /// Start a search with the current query/options. Cancels any + /// in-flight search first and clears prior results. + Future run(String query) async { + pattern = query; + if (_activeSearchId != null) { + unawaited(ipc.request('search.cancel', args: {'searchId': _activeSearchId})); + _activeSearchId = null; + } + _matches.clear(); + _error = null; + _done = false; + if (pattern.trim().isEmpty) { + _running = false; + notifyListeners(); + return; + } + _running = true; + notifyListeners(); + + final resp = await ipc.request('search.grep', args: { + 'pattern': pattern, + 'regex': regex, + 'ignoreCase': ignoreCase, + 'include': _split(includeGlobs), + 'exclude': _split(excludeGlobs), + }); + if (!resp.ok) { + _error = resp.error?.message ?? 'search failed'; + _running = false; + notifyListeners(); + return; + } + _activeSearchId = resp.data['searchId'] as String?; + } + + /// Stop the in-flight search, if any. + void cancel() { + if (_activeSearchId != null) { + unawaited(ipc.request('search.cancel', args: {'searchId': _activeSearchId})); + _activeSearchId = null; + } + _running = false; + notifyListeners(); + } + + /// Open a match in the editor at its line (search always lands on the + /// source line, even for `.md`, which the reader can't position). + void openMatch(SearchMatch m) { + unawaited(ipc.request('editor.open', args: {'path': m.path, 'line': m.line})); + } + + void _onEvent(DaemonEvent e) { + if (e.subsystem != 'search') return; + if (e.data['searchId'] != _activeSearchId) return; // stale / cancelled + switch (e.kind) { + case 'search.match': + final raw = (e.data['matches'] as List?) ?? const []; + for (final m in raw.whereType()) { + _matches.add(SearchMatch.fromJson(m.cast())); + } + notifyListeners(); + case 'search.done': + _running = false; + _done = true; + _activeSearchId = null; + notifyListeners(); + case 'search.error': + _error = e.data['message'] as String? ?? 'search error'; + _running = false; + _activeSearchId = null; + notifyListeners(); + } + } + + static List _split(String s) => s.split(RegExp(r'[,\s]+')).where((x) => x.isNotEmpty).toList(); + + @override + void dispose() { + _sub?.cancel(); + _sub = null; + super.dispose(); + } +} diff --git a/lib/builtin/search/src/search_panel_view.dart b/lib/builtin/search/src/search_panel_view.dart new file mode 100644 index 00000000..4b2006e3 --- /dev/null +++ b/lib/builtin/search/src/search_panel_view.dart @@ -0,0 +1,250 @@ +/// The find-in-files sidebar panel (T-52, per D-79). A search input +/// with regex/case toggles + include/exclude glob fields, and a results +/// list grouped by file. Clicking a match opens the editor at its line. +library; + +import 'package:clide/builtin/search/src/find_in_files_controller.dart'; +import 'package:clide/kernel/kernel.dart'; +import 'package:clide/src/search/match.dart'; +import 'package:clide/widgets/widgets.dart'; +import 'package:flutter/widgets.dart'; + +class SearchPanelView extends StatefulWidget { + const SearchPanelView({super.key}); + + @override + State createState() => _SearchPanelViewState(); +} + +class _SearchPanelViewState extends State { + FindInFilesController? _controller; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (_controller != null) return; + final kernel = ClideKernel.of(context); + _controller = FindInFilesController(ipc: kernel.ipc, events: kernel.events); + } + + @override + void dispose() { + _controller?.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final tokens = ClideTheme.of(context).surface; + final c = _controller!; + return ListenableBuilder( + listenable: c, + builder: (context, _) { + final groups = c.grouped(); + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.all(8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + ClideFilterBox(hint: 'Search', onChanged: c.run, onSubmitted: c.run), + const SizedBox(height: 6), + Row( + children: [ + _Toggle( + label: '.*', + tooltip: 'Regular expression', + active: c.regex, + tokens: tokens, + onTap: () { + c.setRegex(!c.regex); + c.run(c.pattern); + }, + ), + const SizedBox(width: 6), + _Toggle( + label: 'Aa', + tooltip: 'Case insensitive', + active: c.ignoreCase, + tokens: tokens, + onTap: () { + c.setIgnoreCase(!c.ignoreCase); + c.run(c.pattern); + }, + ), + const Spacer(), + _StatusText(c, tokens), + ], + ), + const SizedBox(height: 6), + ClideFilterBox(hint: 'files to include (e.g. *.dart)', debounce: Duration.zero, onChanged: (v) => c.include = v), + const SizedBox(height: 4), + ClideFilterBox(hint: 'files to exclude', debounce: Duration.zero, onChanged: (v) => c.exclude = v), + ], + ), + ), + if (c.error != null) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + child: ClideText(c.error!, color: tokens.globalTextMuted, fontSize: clideFontCaption), + ), + Expanded( + child: ListView( + padding: EdgeInsets.zero, + children: [ + for (final entry in groups.entries) _FileGroup(path: entry.key, matches: entry.value, tokens: tokens, onTap: c.openMatch), + ], + ), + ), + ], + ); + }, + ); + } +} + +class _Toggle extends StatelessWidget { + const _Toggle({ + required this.label, + required this.tooltip, + required this.active, + required this.tokens, + required this.onTap, + }); + + final String label; + final String tooltip; + final bool active; + final SurfaceTokens tokens; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Semantics( + button: true, + label: tooltip, + toggled: active, + child: ClideTappable( + onTap: onTap, + builder: (context, hovered, _) => Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: active ? tokens.listItemSelectedBackground : (hovered ? tokens.sidebarItemHover : null), + border: Border.all(color: active ? tokens.globalFocus : tokens.buttonBorder), + borderRadius: BorderRadius.circular(3), + ), + child: ClideText( + label, + fontFamily: clideMonoFamily, + fontSize: clideFontCaption, + color: active ? tokens.listItemSelectedForeground : tokens.sidebarForeground, + ), + ), + ), + ); + } +} + +class _StatusText extends StatelessWidget { + const _StatusText(this.c, this.tokens); + final FindInFilesController c; + final SurfaceTokens tokens; + + @override + Widget build(BuildContext context) { + final String text; + if (c.running) { + text = 'Searching…'; + } else if (c.matchCount == 0 && c.done) { + text = 'No results'; + } else if (c.matchCount > 0) { + text = '${c.matchCount} in ${c.fileCount}'; + } else { + text = ''; + } + return ClideText(text, color: tokens.globalTextMuted, fontSize: clideFontCaption); + } +} + +class _FileGroup extends StatelessWidget { + const _FileGroup({required this.path, required this.matches, required this.tokens, required this.onTap}); + + final String path; + final List matches; + final SurfaceTokens tokens; + final void Function(SearchMatch) onTap; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Container( + color: tokens.panelHeader, + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 3), + child: Row( + children: [ + Expanded( + child: ClideText(path, maxLines: 1, overflow: TextOverflow.ellipsis, color: tokens.panelHeaderForeground), + ), + ClideText('${matches.length}', fontSize: clideFontCaption, color: tokens.globalTextMuted), + ], + ), + ), + for (final m in matches) _MatchRow(match: m, tokens: tokens, onTap: () => onTap(m)), + ], + ); + } +} + +class _MatchRow extends StatelessWidget { + const _MatchRow({required this.match, required this.tokens, required this.onTap}); + + final SearchMatch match; + final SurfaceTokens tokens; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Semantics( + button: true, + label: 'Open ${match.path} line ${match.line}', + onTap: onTap, + child: ClideTappable( + onTap: onTap, + builder: (context, hovered, _) => Container( + color: hovered ? tokens.listItemHoverBackground : null, + padding: const EdgeInsets.only(left: 18, right: 8, top: 2, bottom: 2), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 36, + child: ClideText('${match.line}', fontSize: clideFontCaption, fontFamily: clideMonoFamily, color: tokens.globalTextMuted), + ), + Expanded(child: _highlighted()), + ], + ), + ), + ), + ); + } + + Widget _highlighted() { + final line = match.preview; + final start = match.matchStart.clamp(0, line.length); + final end = match.matchEnd.clamp(start, line.length); + final base = TextStyle(fontFamily: clideMonoFamily, fontSize: clideFontCaption, color: tokens.sidebarForeground); + return RichText( + maxLines: 1, + overflow: TextOverflow.ellipsis, + text: TextSpan(style: base, children: [ + TextSpan(text: line.substring(0, start)), + TextSpan(text: line.substring(start, end), style: base.copyWith(color: tokens.globalFocus, fontWeight: FontWeight.bold)), + TextSpan(text: line.substring(end)), + ]), + ); + } +} diff --git a/lib/kernel/src/keymap/intents.dart b/lib/kernel/src/keymap/intents.dart index ec2d5362..2f5f43c7 100644 --- a/lib/kernel/src/keymap/intents.dart +++ b/lib/kernel/src/keymap/intents.dart @@ -77,6 +77,13 @@ class QuickOpenAcceptIntent extends Intent { const QuickOpenAcceptIntent(); } +// -- Find in files ---------------------------------------------------------- + +/// Reveal the find-in-files search panel in the sidebar. +class FindInFilesIntent extends Intent { + const FindInFilesIntent(); +} + // -- Text scale ------------------------------------------------------------- class TextScaleIncreaseIntent extends Intent { @@ -122,6 +129,7 @@ final Map builtinIntents = { 'quickOpen.selectNext': () => const QuickOpenSelectNextIntent(), 'quickOpen.selectPrevious': () => const QuickOpenSelectPreviousIntent(), 'quickOpen.accept': () => const QuickOpenAcceptIntent(), + 'findInFiles.open': () => const FindInFilesIntent(), 'text.scaleIncrease': () => const TextScaleIncreaseIntent(), 'text.scaleDecrease': () => const TextScaleDecreaseIntent(), 'text.scaleReset': () => const TextScaleResetIntent(), diff --git a/lib/main.dart b/lib/main.dart index 40bce37b..c93d1bf6 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -12,6 +12,7 @@ import 'package:clide/builtin/editor/editor.dart'; import 'package:clide/builtin/extensions_ui/extensions_ui.dart'; import 'package:clide/builtin/files/files.dart'; import 'package:clide/builtin/git/git.dart'; +import 'package:clide/builtin/search/search.dart'; import 'package:clide/builtin/grammars_core/grammars_core.dart'; import 'package:clide/builtin/graph/graph.dart'; import 'package:clide/builtin/ipc_status/ipc_status.dart'; @@ -246,6 +247,7 @@ Future main() async { ..register(TicketsExtension()) ..register(DecisionsExtension()) ..register(FilesExtension()) + ..register(SearchExtension()) ..register(GitExtension()) ..register(PqlExtension()) ..register(ProblemsExtension()) diff --git a/test/builtin/search/find_in_files_controller_test.dart b/test/builtin/search/find_in_files_controller_test.dart new file mode 100644 index 00000000..6c393fed --- /dev/null +++ b/test/builtin/search/find_in_files_controller_test.dart @@ -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 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> matches) { + f.services.events.emit(DaemonEvent( + subsystem: 'search', + kind: 'search.match', + data: {'searchId': id, 'matches': matches}, + ts: DateTime.now().toUtc(), + )); + } + + Map 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? 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.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.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.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.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.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? 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.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); + }); +} diff --git a/test/builtin/search/search_panel_view_test.dart b/test/builtin/search/search_panel_view_test.dart new file mode 100644 index 00000000..349383f1 --- /dev/null +++ b/test/builtin/search/search_panel_view_test.dart @@ -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 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? 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); + }); +} diff --git a/test/kernel/src/keymap/intents_test.dart b/test/kernel/src/keymap/intents_test.dart index a01eee09..6a3ae427 100644 --- a/test/kernel/src/keymap/intents_test.dart +++ b/test/kernel/src/keymap/intents_test.dart @@ -35,6 +35,10 @@ void main() { expect(parseIntentId('quickOpen.accept'), isA()); }); + test('returns the findInFiles.open intent', () { + expect(parseIntentId('findInFiles.open'), isA()); + }); + test('returns the text.scale* intents', () { expect(parseIntentId('text.scaleIncrease'), isA()); expect(parseIntentId('text.scaleDecrease'), isA()); diff --git a/test/widgets/quick_open_overlay_test.dart b/test/widgets/quick_open_overlay_test.dart index 3d5e6657..3884b90c 100644 --- a/test/widgets/quick_open_overlay_test.dart +++ b/test/widgets/quick_open_overlay_test.dart @@ -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 = []; - 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()));