add search-and-replace across files

Builds on the find-in-files engine. A replace engine applies the
query's replacement to each matching file — literal or regex with
capture-group expansion ($1, $&, $$) — and reports per-file, per-line
before/after edits computed with the same logic the apply uses, so
preview and apply never disagree.

The search.replace command previews (no disk writes) or applies
(writing each changed file through the workspace path-safety guard).
The panel gains a Replace field: each match row previews its rewritten
line, and Replace all is gated on a clean git working tree (git is the
undo) plus a confirmation before it writes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-31 21:41:09 +02:00
co-authored by Claude Opus 4.8
parent d26a1f895c
commit 4aed6c12a5
11 changed files with 821 additions and 8 deletions
@@ -27,6 +27,7 @@ class FindInFilesController extends ChangeNotifier {
bool ignoreCase = false;
String includeGlobs = '';
String excludeGlobs = '';
String replacement = '';
// -- Result state ---------------------------------------------------------
String? _activeSearchId;
@@ -67,6 +68,42 @@ class FindInFilesController extends ChangeNotifier {
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 {