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:
2026-05-31 20:53:50 +02:00
co-authored by Claude Opus 4.8
parent 399a4d3a3f
commit f96c565acd
13 changed files with 695 additions and 7 deletions
@@ -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<DaemonEvent>().listen(_onEvent);
}
final DaemonClient ipc;
final DaemonBus events;
StreamSubscription<DaemonEvent>? _sub;
// -- Query state ----------------------------------------------------------
String pattern = '';
bool regex = false;
bool ignoreCase = false;
String includeGlobs = '';
String excludeGlobs = '';
// -- Result state ---------------------------------------------------------
String? _activeSearchId;
final List<SearchMatch> _matches = [];
bool _running = false;
bool _done = false;
String? _error;
List<SearchMatch> 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<String, List<SearchMatch>> grouped() {
final out = <String, List<SearchMatch>>{};
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<void> 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<Map>()) {
_matches.add(SearchMatch.fromJson(m.cast<String, Object?>()));
}
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<String> _split(String s) => s.split(RegExp(r'[,\s]+')).where((x) => x.isNotEmpty).toList();
@override
void dispose() {
_sub?.cancel();
_sub = null;
super.dispose();
}
}