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
@@ -173,4 +173,44 @@ void main() {
await c.run('foo');
expect(sent!['exclude'], ['build/**', '*.g.dart']);
});
test('setReplacement updates the field and notifies', () {
final c = make();
var n = 0;
c.addListener(() => n++);
c.setReplacement('baz');
expect(c.replacement, 'baz');
expect(n, 1);
c.setReplacement('baz'); // no change
expect(n, 1);
});
test('isWorkingTreeClean reflects git.status clean flag', () async {
f.ipc.stub('git.status', (_) async => _ok(const {'clean': true}));
expect(await make().isWorkingTreeClean(), isTrue);
f.ipc.stub('git.status', (_) async => _ok(const {'clean': false}));
expect(await make().isWorkingTreeClean(), isFalse);
});
test('applyReplace sends apply, returns the summary, and refreshes', () async {
Map<String, Object?>? sent;
var grepCalls = 0;
f.ipc.stub('search.replace', (args) async {
sent = args;
return _ok(const {'apply': true, 'filesChanged': 3, 'totalCount': 7});
});
f.ipc.stub('search.grep', (_) async {
grepCalls++;
return _ok({'searchId': 's1'});
});
final c = make();
c.setReplacement('baz');
await c.run('foo'); // grepCalls == 1
final res = await c.applyReplace();
expect(sent!['apply'], isTrue);
expect(sent!['replacement'], 'baz');
expect(res.files, 3);
expect(res.count, 7);
expect(grepCalls, 2); // applyReplace re-runs the search
});
}
@@ -13,6 +13,10 @@ import '../../helpers/widget_harness.dart';
IpcResponse _ok(Map<String, Object?> data) => IpcResponse.ok(id: '', data: data);
/// SearchPanelView wrapped in a DialogHost so the replace confirm /
/// not-clean dialogs render and can be driven.
Widget _withDialogs(KernelFixture f) => DialogHost(router: f.services.dialog, child: const SearchPanelView());
void main() {
late KernelFixture f;
@@ -115,4 +119,60 @@ void main() {
await pumpAsync(tester);
expect(grepCalls, greaterThan(before));
});
// Drive a search so there are matches + set a replacement string.
Future<void> seedReplace(WidgetTester tester) async {
await tester.enterText(find.byType(EditableText).first, 'foo');
await tester.pump(const Duration(milliseconds: 250));
await pumpAsync(tester);
emitMatches();
await pumpAsync(tester);
await tester.enterText(find.byType(EditableText).at(1), 'bar'); // replace field
await pumpAsync(tester);
}
testWidgets('replace preview renders the rewritten line', (tester) async {
await tester.pumpWidget(harness(f, _withDialogs(f)));
await seedReplace(tester);
// The emitted match line is 'final foo = 1;' → preview shows the after
// (rendered as a RichText span, so match on the plain text).
expect(
find.byWidgetPredicate((w) => w is RichText && w.text.toPlainText() == 'final bar = 1;'),
findsOneWidget,
);
});
testWidgets('Replace all on a dirty tree shows a guard dialog, no apply', (tester) async {
var applyCalled = false;
f.ipc.stub('git.status', (_) async => _ok(const {'clean': false}));
f.ipc.stub('search.replace', (_) async {
applyCalled = true;
return _ok(const {'apply': true, 'filesChanged': 0, 'totalCount': 0});
});
await tester.pumpWidget(harness(f, _withDialogs(f)));
await seedReplace(tester);
await tester.tap(find.text('Replace all'));
await pumpAsync(tester);
expect(find.text('Working tree not clean'), findsOneWidget);
expect(applyCalled, isFalse);
});
testWidgets('Replace all on a clean tree confirms then applies', (tester) async {
Map<String, Object?>? applyArgs;
f.ipc.stub('git.status', (_) async => _ok(const {'clean': true}));
f.ipc.stub('search.replace', (args) async {
applyArgs = args;
return _ok(const {'apply': true, 'filesChanged': 1, 'totalCount': 1});
});
await tester.pumpWidget(harness(f, _withDialogs(f)));
await seedReplace(tester);
await tester.tap(find.text('Replace all'));
await pumpAsync(tester);
// Confirm dialog up; confirm it.
await tester.tap(find.text('Confirm'));
await pumpAsync(tester);
expect(applyArgs, isNotNull);
expect(applyArgs!['apply'], isTrue);
expect(applyArgs!['replacement'], 'bar');
});
}