Files
clide/lib/builtin/search/src/find_in_files_controller.dart
T
jpmschweitzerandClaude Opus 4.8 6d0ebab721 chore: adopt Dart 3.9 toolchain — honest floor + tall-style reformat (T-353)
Raise the declared minimums in pubspec.yaml to what our deps already
require: Flutter >=3.35.0 / Dart >=3.9.0 (was 3.19.0 / 3.5.0). alchemist
0.12 needs Flutter 3.32; Dart 3.9 first ships in Flutter 3.35, so 3.35 is
the binding floor. Pin the exact build toolchain in .fvmrc (Flutter
3.44.1).

Moving to the Dart 3.9 language level switches `dart format` to the new
"tall" style and enables two new lints. This commit is the resulting
mechanical churn, isolated from any behaviour change:
  - whole-tree `dart format` reformat (tall style)
  - `dart fix` for unnecessary_underscores + use_null_aware_elements

No runtime behaviour change; `make test` green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 12:11:53 +02:00

190 lines
5.8 KiB
Dart

/// 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 = '';
String replacement = '';
// -- 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;
void setReplacement(String v) {
if (replacement == v) return;
replacement = v;
notifyListeners();
}
/// True when the git working tree has no changes — the safety gate for
/// applying a destructive multi-file replace (the user's chosen model:
/// git is the lossless undo layer).
Future<bool> isWorkingTreeClean() async {
final r = await ipc.request('git.status');
return r.ok && r.data['clean'] == true;
}
/// Apply the current replacement across all matches, then refresh the
/// results. Returns the number of files changed and matches replaced.
/// Callers must gate on [isWorkingTreeClean] + user confirmation first.
Future<({int files, int count})> applyReplace() async {
final r = await ipc.request(
'search.replace',
args: {
'pattern': pattern,
'regex': regex,
'ignoreCase': ignoreCase,
'include': _split(includeGlobs),
'exclude': _split(excludeGlobs),
'replacement': replacement,
'apply': true,
},
);
final files = (r.data['filesChanged'] as num?)?.toInt() ?? 0;
final count = (r.data['totalCount'] as num?)?.toInt() ?? 0;
await run(pattern); // refresh the match list against the new content
return (files: files, count: count);
}
/// The query the panel is currently running (for preview rendering).
SearchQuery get query => SearchQuery(pattern: pattern, regex: regex, ignoreCase: ignoreCase);
/// 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();
}
}