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:
@@ -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');
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user