diff --git a/.pql/changelog/ticket_history/2026-05.sql b/.pql/changelog/ticket_history/2026-05.sql index 76c45ad2..efe3ae8e 100644 --- a/.pql/changelog/ticket_history/2026-05.sql +++ b/.pql/changelog/ticket_history/2026-05.sql @@ -2377,3 +2377,4 @@ INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-51', 'status', 'backlog', 'in_progress', NULL, '2026-05-31 18:01:23', '2026-05-31 18:01:23', '2026-05-31 18:01:23', NULL, '386ac9084ce3fe9c035a56bba3cd1c42', 1) ON CONFLICT(hash) DO NOTHING; INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-52', 'status', 'in_progress', 'done', NULL, '2026-05-31 18:54:02', '2026-05-31 18:54:02', '2026-05-31 18:54:02', NULL, 'bac566c015a7d599cbb2fc9b3855400b', 1) ON CONFLICT(hash) DO NOTHING; INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-51', 'status', 'in_progress', 'done', NULL, '2026-05-31 18:54:02', '2026-05-31 18:54:02', '2026-05-31 18:54:02', NULL, 'fafab627f731815e6f83bfaf310543a6', 1) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-53', 'status', 'backlog', 'in_progress', NULL, '2026-05-31 19:28:39', '2026-05-31 19:28:39', '2026-05-31 19:28:39', NULL, '5e29490c3b36aa2dce8dece3d0d0091f', 1) ON CONFLICT(hash) DO NOTHING; diff --git a/.pql/changelog/tickets/2026-05.sql b/.pql/changelog/tickets/2026-05.sql index 681b4bf9..2cae882e 100644 --- a/.pql/changelog/tickets/2026-05.sql +++ b/.pql/changelog/tickets/2026-05.sql @@ -3043,3 +3043,20 @@ Open-at-line — editor.open takes only {path} today. Extend it with an optional Sidebar panel follows the files/git/decisions TabContribution(slot: Slots.sidebar) pattern; custom widgets, no Material/Cupertino. Files: new lib/builtin/search/{extension,search_controller,search_panel_view}.dart; new lib/src/daemon/search_commands.dart (search.grep + isolate pool); lib/src/files ignore layering; editor_commands.dart + registry.dart (line param); IPC schema + dispatcher registration; CLI `clide search grep` verb.', 'done', 'medium', NULL, NULL, NULL, '2026-04-23 20:32:06', '2026-05-31 18:54:02', NULL, '59534ff6067343e354f4946fe7931d2f', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); +INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-53', 'story', NULL, 'search and replace across files', 'Companion to find-in-files. Preview all replacements before applying. Support regex capture groups in replacement. Respects ignore patterns from pql config. + +Refinement (2026-05-31): + +Depends on T-52 — consumes its match model (path, line, start/end offset per match, capture groups) as the replacement input. Sequence after T-52 (T-52 blocks T-53). + +Write path — no files.write IPC exists today (only files.root/read/ls/watch; the sole write path is EditorRegistry.save on an open buffer). Add a `files.write` verb gated by resolveUnderRootFollowingSymlinks (lib/src/files/path_safety.dart). Do NOT route through the editor (would pollute the open-buffer list with dozens of temp buffers). + +Regex capture groups — Dart replaceAllMapped / $1; the T-52 engine must surface match groups. + +Preview — the existing diff view (lib/builtin/diff) is git-only (hardwired to git.diff). Build a ReplacementPreviewController/view that reuses the diff RENDER primitives (DiffLine / _HunkView styling) fed a computed in-memory before/after set. Do NOT generalize/entangle the git DiffController. + +Safety (user decision) — REQUIRE A CLEAN GIT WORKING TREE before apply: refuse with "commit or stash your changes first" if there are unstaged changes, making git the lossless undo layer. Preview shown first; final confirmation via the existing DialogRouter (as used by git discard-confirm). + +Ignore — same full ignore_files: layering as T-52 (D-4). + +Files: lib/src/daemon/files_commands.dart (files.write); new replacement-preview controller/view under lib/builtin/search/; reuse path_safety + diff render primitives; DialogRouter confirm; IPC schema + CLI parity verbs.', 'in_progress', 'medium', NULL, NULL, NULL, '2026-04-23 20:32:06', '2026-05-31 19:28:39', NULL, 'fb6ccea7b9f4b2a2f403f7ade153cfeb', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); diff --git a/CHANGELOG.md b/CHANGELOG.md index c9452cb9..fb01271e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,10 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit. ### Added +- Search-and-replace across the workspace: enter a replacement in the search + panel to preview each rewritten line, then Replace all (regex capture groups + supported). Guarded by a clean-git-tree gate — git is the undo — and a + confirmation. New `search.replace` command (preview + apply). (T-53) - Find-in-files sidebar panel (Ctrl/Cmd+Shift+F): search the workspace with regex and case toggles plus include/exclude globs; results stream in grouped by file and clicking a match opens the editor at that line. (T-52) diff --git a/lib/builtin/search/src/find_in_files_controller.dart b/lib/builtin/search/src/find_in_files_controller.dart index a9b37a5d..332ac7ee 100644 --- a/lib/builtin/search/src/find_in_files_controller.dart +++ b/lib/builtin/search/src/find_in_files_controller.dart @@ -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 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 run(String query) async { diff --git a/lib/builtin/search/src/search_panel_view.dart b/lib/builtin/search/src/search_panel_view.dart index 4b2006e3..3ed46f4d 100644 --- a/lib/builtin/search/src/search_panel_view.dart +++ b/lib/builtin/search/src/search_panel_view.dart @@ -6,6 +6,7 @@ library; import 'package:clide/builtin/search/src/find_in_files_controller.dart'; import 'package:clide/kernel/kernel.dart'; import 'package:clide/src/search/match.dart'; +import 'package:clide/src/search/replace_engine.dart'; import 'package:clide/widgets/widgets.dart'; import 'package:flutter/widgets.dart'; @@ -33,6 +34,26 @@ class _SearchPanelViewState extends State { super.dispose(); } + Future _replaceAll() async { + final c = _controller!; + if (c.replacement.isEmpty || c.matchCount == 0) return; + final dialog = ClideKernel.of(context).dialog; + if (!await c.isWorkingTreeClean()) { + await dialog.show((ctx, dismiss) => _MessageDialog( + title: 'Working tree not clean', + body: 'Commit or stash your changes before replacing — git is the only undo.', + dismiss: dismiss, + )); + return; + } + final confirmed = await dialog.show((ctx, dismiss) => _ConfirmDialog( + body: 'Replace ${c.matchCount} match(es) across ${c.fileCount} file(s)? This cannot be undone in clide.', + dismiss: dismiss, + )); + if (confirmed != true) return; + await c.applyReplace(); + } + @override Widget build(BuildContext context) { final tokens = ClideTheme.of(context).surface; @@ -79,6 +100,18 @@ class _SearchPanelViewState extends State { ], ), const SizedBox(height: 6), + Row( + children: [ + Expanded(child: ClideFilterBox(hint: 'Replace', debounce: Duration.zero, onChanged: c.setReplacement)), + const SizedBox(width: 6), + _ReplaceAllButton( + enabled: c.replacement.isNotEmpty && c.matchCount > 0, + tokens: tokens, + onTap: _replaceAll, + ), + ], + ), + const SizedBox(height: 6), ClideFilterBox(hint: 'files to include (e.g. *.dart)', debounce: Duration.zero, onChanged: (v) => c.include = v), const SizedBox(height: 4), ClideFilterBox(hint: 'files to exclude', debounce: Duration.zero, onChanged: (v) => c.exclude = v), @@ -94,7 +127,15 @@ class _SearchPanelViewState extends State { child: ListView( padding: EdgeInsets.zero, children: [ - for (final entry in groups.entries) _FileGroup(path: entry.key, matches: entry.value, tokens: tokens, onTap: c.openMatch), + for (final entry in groups.entries) + _FileGroup( + path: entry.key, + matches: entry.value, + tokens: tokens, + onTap: c.openMatch, + query: c.query, + replacement: c.replacement, + ), ], ), ), @@ -169,12 +210,21 @@ class _StatusText extends StatelessWidget { } class _FileGroup extends StatelessWidget { - const _FileGroup({required this.path, required this.matches, required this.tokens, required this.onTap}); + const _FileGroup({ + required this.path, + required this.matches, + required this.tokens, + required this.onTap, + required this.query, + required this.replacement, + }); final String path; final List matches; final SurfaceTokens tokens; final void Function(SearchMatch) onTap; + final SearchQuery query; + final String replacement; @override Widget build(BuildContext context) { @@ -193,18 +243,26 @@ class _FileGroup extends StatelessWidget { ], ), ), - for (final m in matches) _MatchRow(match: m, tokens: tokens, onTap: () => onTap(m)), + for (final m in matches) _MatchRow(match: m, tokens: tokens, onTap: () => onTap(m), query: query, replacement: replacement), ], ); } } class _MatchRow extends StatelessWidget { - const _MatchRow({required this.match, required this.tokens, required this.onTap}); + const _MatchRow({ + required this.match, + required this.tokens, + required this.onTap, + required this.query, + required this.replacement, + }); final SearchMatch match; final SurfaceTokens tokens; final VoidCallback onTap; + final SearchQuery query; + final String replacement; @override Widget build(BuildContext context) { @@ -224,7 +282,7 @@ class _MatchRow extends StatelessWidget { width: 36, child: ClideText('${match.line}', fontSize: clideFontCaption, fontFamily: clideMonoFamily, color: tokens.globalTextMuted), ), - Expanded(child: _highlighted()), + Expanded(child: replacement.isEmpty ? _highlighted() : _preview()), ], ), ), @@ -232,19 +290,174 @@ class _MatchRow extends StatelessWidget { ); } + TextStyle get _base => TextStyle(fontFamily: clideMonoFamily, fontSize: clideFontCaption, color: tokens.sidebarForeground); + Widget _highlighted() { final line = match.preview; final start = match.matchStart.clamp(0, line.length); final end = match.matchEnd.clamp(start, line.length); - final base = TextStyle(fontFamily: clideMonoFamily, fontSize: clideFontCaption, color: tokens.sidebarForeground); return RichText( maxLines: 1, overflow: TextOverflow.ellipsis, - text: TextSpan(style: base, children: [ + text: TextSpan(style: _base, children: [ TextSpan(text: line.substring(0, start)), - TextSpan(text: line.substring(start, end), style: base.copyWith(color: tokens.globalFocus, fontWeight: FontWeight.bold)), + TextSpan(text: line.substring(start, end), style: _base.copyWith(color: tokens.globalFocus, fontWeight: FontWeight.bold)), TextSpan(text: line.substring(end)), ]), ); } + + /// Replace-preview: the original line struck through, then the + /// rewritten line (computed with the same engine the apply uses). + Widget _preview() { + final after = applyToText(match.preview, query, replacement).text; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + RichText( + maxLines: 1, + overflow: TextOverflow.ellipsis, + text: TextSpan(text: match.preview, style: _base.copyWith(decoration: TextDecoration.lineThrough, color: tokens.globalTextMuted)), + ), + RichText( + maxLines: 1, + overflow: TextOverflow.ellipsis, + text: TextSpan(text: after, style: _base.copyWith(color: tokens.globalFocus)), + ), + ], + ); + } +} + +class _ReplaceAllButton extends StatelessWidget { + const _ReplaceAllButton({required this.enabled, required this.tokens, required this.onTap}); + + final bool enabled; + final SurfaceTokens tokens; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Semantics( + button: true, + enabled: enabled, + label: 'Replace all', + child: ClideTappable( + onTap: enabled ? onTap : () {}, + builder: (context, hovered, _) => Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5), + decoration: BoxDecoration( + color: enabled && hovered ? tokens.listItemHoverBackground : null, + border: Border.all(color: tokens.buttonBorder), + borderRadius: BorderRadius.circular(4), + ), + child: ClideText( + 'Replace all', + fontSize: clideFontCaption, + color: enabled ? tokens.sidebarForeground : tokens.globalTextMuted, + ), + ), + ), + ); + } +} + +class _MessageDialog extends StatelessWidget { + const _MessageDialog({required this.title, required this.body, required this.dismiss}); + + final String title; + final String body; + final void Function([Object?]) dismiss; + + @override + Widget build(BuildContext context) { + final tokens = ClideTheme.of(context).surface; + return _DialogFrame( + tokens: tokens, + children: [ + ClideText(title, color: tokens.dropdownForeground), + const SizedBox(height: 8), + ClideText(body, fontSize: clideFontCaption, color: tokens.globalTextMuted), + const SizedBox(height: 12), + Align( + alignment: Alignment.centerRight, + child: _DialogButton(label: 'OK', tokens: tokens, onTap: () => dismiss()), + ), + ], + ); + } +} + +class _ConfirmDialog extends StatelessWidget { + const _ConfirmDialog({required this.body, required this.dismiss}); + + final String body; + final void Function([bool?]) dismiss; + + @override + Widget build(BuildContext context) { + final tokens = ClideTheme.of(context).surface; + return _DialogFrame( + tokens: tokens, + children: [ + ClideText(body, color: tokens.dropdownForeground), + const SizedBox(height: 12), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + _DialogButton(label: 'Cancel', tokens: tokens, onTap: () => dismiss(false)), + const SizedBox(width: 8), + _DialogButton(label: 'Confirm', tokens: tokens, onTap: () => dismiss(true)), + ], + ), + ], + ); + } +} + +class _DialogFrame extends StatelessWidget { + const _DialogFrame({required this.tokens, required this.children}); + final SurfaceTokens tokens; + final List children; + + @override + Widget build(BuildContext context) { + return Container( + width: 360, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: tokens.dropdownBackground, + border: Border.all(color: tokens.modalSurfaceBorder), + borderRadius: BorderRadius.circular(6), + ), + child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: children), + ); + } +} + +class _DialogButton extends StatelessWidget { + const _DialogButton({required this.label, required this.tokens, required this.onTap}); + final String label; + final SurfaceTokens tokens; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Semantics( + button: true, + label: label, + child: ClideTappable( + onTap: onTap, + builder: (context, hovered, _) => Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + decoration: BoxDecoration( + color: hovered ? tokens.listItemHoverBackground : null, + border: Border.all(color: tokens.buttonBorder), + borderRadius: BorderRadius.circular(4), + ), + child: ClideText(label, fontSize: clideFontCaption, color: tokens.sidebarForeground), + ), + ), + ); + } } diff --git a/lib/src/daemon/search_commands.dart b/lib/src/daemon/search_commands.dart index 39a3becf..6d45f391 100644 --- a/lib/src/daemon/search_commands.dart +++ b/lib/src/daemon/search_commands.dart @@ -11,12 +11,14 @@ import 'dart:async'; import 'dart:io'; import '../files/ignore.dart'; +import '../files/path_safety.dart'; import '../ipc/command_schema.dart'; import '../ipc/envelope.dart'; import '../ipc/schema_v1.dart'; import '../panes/event_sink.dart'; import '../search/grep_engine.dart'; import '../search/match.dart'; +import '../search/replace_engine.dart'; import 'dispatcher.dart'; /// Owns in-flight searches and streams their results onto the event @@ -55,6 +57,50 @@ class SearchService { _active.remove(id)?.cancel(); } + /// Compute (preview) or perform (apply) a search-and-replace. + /// + /// Preview returns per-file before/after edits without touching disk. + /// Apply writes each changed file's new content through the workspace + /// path-safety guard. The clean-git-tree safety gate is enforced by + /// the caller (the UI checks `git.status` before requesting apply). + Future> replace(SearchQuery query, String replacement, {required bool apply}) async { + final files = await computeReplacements( + root: root, + ignore: ignore, + query: query, + replacement: replacement, + ); + if (!apply) { + return { + 'apply': false, + 'files': [for (final f in files) f.toJson()], + 'fileCount': files.length, + 'totalCount': files.fold(0, (s, f) => s + f.count), + }; + } + final rootPath = root.absolute.path; + var changed = 0; + var total = 0; + for (final f in files) { + final newContent = rewriteFileContent(rootPath, f.path, query, replacement); + if (newContent == null) continue; + final String abs; + try { + abs = resolveUnderRootFollowingSymlinks(root, f.path); + } on PathOutsideRoot { + continue; + } + try { + File(abs).writeAsStringSync(newContent); + changed++; + total += f.count; + } catch (_) { + // skip unwritable files; the rest still apply + } + } + return {'apply': true, 'filesChanged': changed, 'totalCount': total}; + } + Future _run(String id, SearchQuery query, CancelToken cancel) async { try { await for (final batch in grepWorkspace( @@ -101,6 +147,19 @@ const CommandSchema _grepSchema = CommandSchema( }, ); +const CommandSchema _replaceSchema = CommandSchema( + positional: ['pattern', 'replacement'], + args: { + 'pattern': ArgSpec(required: true), + 'replacement': ArgSpec(), + 'regex': ArgSpec(type: ArgType.boolean), + 'ignoreCase': ArgSpec(type: ArgType.boolean), + 'include': ArgSpec(type: ArgType.stringList), + 'exclude': ArgSpec(type: ArgType.stringList), + 'apply': ArgSpec(type: ArgType.boolean), + }, +); + void registerSearchCommands(DaemonDispatcher d, SearchService search) { d.register('search.grep', (req) async { final query = SearchQuery.fromJson(req.args); @@ -118,6 +177,35 @@ void registerSearchCommands(DaemonDispatcher d, SearchService search) { return IpcResponse.ok(id: req.id, data: {'searchId': id}); }, schema: _grepSchema); + d.register('search.replace', (req) async { + final query = SearchQuery.fromJson(req.args); + final replacement = (req.args['replacement'] as String?) ?? ''; + final apply = req.args['apply'] == true; + if (query.pattern.isEmpty) { + return IpcResponse.err( + id: req.id, + error: IpcError( + code: IpcExitCode.userError, + kind: IpcErrorKind.userError, + message: 'search.replace requires a non-empty pattern', + ), + ); + } + try { + final result = await search.replace(query, replacement, apply: apply); + return IpcResponse.ok(id: req.id, data: result); + } on FormatException catch (e) { + return IpcResponse.err( + id: req.id, + error: IpcError( + code: IpcExitCode.userError, + kind: IpcErrorKind.userError, + message: 'invalid regex: ${e.message}', + ), + ); + } + }, schema: _replaceSchema); + d.register('search.cancel', (req) async { final id = req.args['searchId'] as String?; if (id == null || id.isEmpty) { diff --git a/lib/src/search/replace_engine.dart b/lib/src/search/replace_engine.dart new file mode 100644 index 00000000..effe79ed --- /dev/null +++ b/lib/src/search/replace_engine.dart @@ -0,0 +1,187 @@ +/// Workspace search-and-replace engine (T-53, per D-79). +/// +/// Builds on the same query semantics as the grep engine: it walks the +/// ignore-pruned workspace, applies the replacement to each matching +/// file, and reports per-file, per-line before/after edits for preview. +/// The authoritative new file content is produced by applying the +/// replacement to the whole file; the per-line edits are derived with +/// the same logic so preview and apply never disagree. +/// +/// Regex replacements support capture-group references (`$1`..`$9`, +/// `$&`/`$0` for the whole match, `$$` for a literal `$`). +library; + +import 'dart:convert'; +import 'dart:io'; + +import '../files/ignore.dart'; +import '../files/listing.dart'; +import 'match.dart'; + +/// One changed line within a file. +class ReplacementEdit { + const ReplacementEdit({required this.line, required this.before, required this.after}); + + final int line; // 1-based + final String before; + final String after; + + Map toJson() => {'line': line, 'before': before, 'after': after}; + + factory ReplacementEdit.fromJson(Map j) => ReplacementEdit( + line: (j['line'] as num).toInt(), + before: j['before'] as String, + after: j['after'] as String, + ); +} + +/// The set of edits a replacement would make to one file. +class FileReplacement { + const FileReplacement({required this.path, required this.count, required this.edits}); + + final String path; + + /// Number of individual matches replaced in the file. + final int count; + final List edits; + + Map toJson() => { + 'path': path, + 'count': count, + 'edits': [for (final e in edits) e.toJson()], + }; + + factory FileReplacement.fromJson(Map j) => FileReplacement( + path: j['path'] as String, + count: (j['count'] as num).toInt(), + edits: [ + for (final e in (j['edits'] as List? ?? const []).whereType()) ReplacementEdit.fromJson(e.cast()), + ], + ); +} + +/// Apply [query]'s pattern to [text], substituting [replacement]. Returns +/// the rewritten text and the number of matches replaced. +({String text, int count}) applyToText(String text, SearchQuery query, String replacement) { + if (query.pattern.isEmpty) return (text: text, count: 0); + var count = 0; + final RegExp re; + if (query.regex) { + re = RegExp(query.pattern, caseSensitive: !query.ignoreCase); + } else { + re = RegExp(RegExp.escape(query.pattern), caseSensitive: !query.ignoreCase); + } + final out = text.replaceAllMapped(re, (m) { + count++; + return query.regex ? _expand(replacement, m) : replacement; + }); + return (text: out, count: count); +} + +/// Expand `$n` / `$&` / `$$` references in a regex replacement template. +String _expand(String template, Match m) { + final b = StringBuffer(); + var i = 0; + while (i < template.length) { + final c = template[i]; + if (c == r'$' && i + 1 < template.length) { + final next = template[i + 1]; + if (next == r'$') { + b.write(r'$'); + i += 2; + continue; + } + if (next == '&' || next == '0') { + b.write(m.group(0) ?? ''); + i += 2; + continue; + } + if (_isDigit(next)) { + // Greedy two-digit group index when valid, else one digit. + var idx = int.parse(next); + var consumed = 2; + if (i + 2 < template.length && _isDigit(template[i + 2])) { + final two = int.parse('$next${template[i + 2]}'); + if (two <= m.groupCount) { + idx = two; + consumed = 3; + } + } + if (idx <= m.groupCount) { + b.write(m.group(idx) ?? ''); + i += consumed; + continue; + } + } + } + b.write(c); + i++; + } + return b.toString(); +} + +bool _isDigit(String s) => s.codeUnitAt(0) >= 0x30 && s.codeUnitAt(0) <= 0x39; + +/// Compute the replacements [query] → [replacement] would make under +/// [root]. Returns one [FileReplacement] per changed file, with +/// per-line before/after edits for preview. Pure read-only — writing is +/// the caller's job (after the clean-tree safety gate). +Future> computeReplacements({ + required Directory root, + required IgnoreSet ignore, + required SearchQuery query, + required String replacement, + int maxFiles = 5000, +}) async { + if (query.pattern.isEmpty) return const []; + if (query.regex) RegExp(query.pattern, caseSensitive: !query.ignoreCase); // validate + + final walk = await walkFiles(root: root, ignore: ignore); + final rootPath = root.absolute.path; + final out = []; + for (final entry in walk.files) { + if (out.length >= maxFiles) break; + final fr = _replaceInFile(rootPath, entry.path, query, replacement); + if (fr != null) out.add(fr); + } + return out; +} + +/// Compute the rewritten content for a single file, or null if the file +/// is binary/unreadable/unchanged. Exposed for the apply path + tests. +String? rewriteFileContent(String rootPath, String relPath, SearchQuery query, String replacement) { + final content = _readText('$rootPath/$relPath'); + if (content == null) return null; + final r = applyToText(content, query, replacement); + if (r.count == 0) return null; + return r.text; +} + +FileReplacement? _replaceInFile(String rootPath, String relPath, SearchQuery query, String replacement) { + final content = _readText('$rootPath/$relPath'); + if (content == null) return null; + final whole = applyToText(content, query, replacement); + if (whole.count == 0) return null; + // Per-line preview edits, using the same apply logic line-by-line. + final edits = []; + var lineNo = 0; + for (final line in const LineSplitter().convert(content)) { + lineNo++; + final r = applyToText(line, query, replacement); + if (r.count > 0 && r.text != line) { + edits.add(ReplacementEdit(line: lineNo, before: line, after: r.text)); + } + } + return FileReplacement(path: relPath, count: whole.count, edits: edits); +} + +String? _readText(String absPath) { + try { + final bytes = File(absPath).readAsBytesSync(); + final probe = bytes.length > 1024 ? bytes.sublist(0, 1024) : bytes; + if (probe.contains(0)) return null; // binary + return utf8.decode(bytes, allowMalformed: true); + } catch (_) { + return null; + } +} diff --git a/test/builtin/search/find_in_files_controller_test.dart b/test/builtin/search/find_in_files_controller_test.dart index 80e73c8a..a645837a 100644 --- a/test/builtin/search/find_in_files_controller_test.dart +++ b/test/builtin/search/find_in_files_controller_test.dart @@ -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? 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 + }); } diff --git a/test/builtin/search/search_panel_view_test.dart b/test/builtin/search/search_panel_view_test.dart index 4e39254b..dbaa8f5c 100644 --- a/test/builtin/search/search_panel_view_test.dart +++ b/test/builtin/search/search_panel_view_test.dart @@ -13,6 +13,10 @@ import '../../helpers/widget_harness.dart'; IpcResponse _ok(Map 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 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? 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'); + }); } diff --git a/test/daemon/search_commands_test.dart b/test/daemon/search_commands_test.dart index fdbb7536..53d6c57e 100644 --- a/test/daemon/search_commands_test.dart +++ b/test/daemon/search_commands_test.dart @@ -82,4 +82,34 @@ void main() { expect(r.ok, isTrue); expect(r.data['cancelled'], 'search-0'); }); + + test('search.replace preview reports edits without touching disk', () async { + final r = await call('search.replace', const {'pattern': 'answer', 'replacement': 'result'}); + expect(r.ok, isTrue); + expect(r.data['apply'], isFalse); + expect(r.data['fileCount'], 1); + expect(r.data['totalCount'], 1); + // File is untouched. + expect(File('${dir.path}/a.dart').readAsStringSync(), 'final answer = 42;\n'); + }); + + test('search.replace apply rewrites the matching files', () async { + final r = await call('search.replace', const {'pattern': 'answer', 'replacement': 'result', 'apply': true}); + expect(r.ok, isTrue); + expect(r.data['apply'], isTrue); + expect(r.data['filesChanged'], 1); + expect(File('${dir.path}/a.dart').readAsStringSync(), 'final result = 42;\n'); + }); + + test('search.replace with an empty pattern is a userError', () async { + final r = await call('search.replace', const {'pattern': '', 'replacement': 'x'}); + expect(r.ok, isFalse); + expect(r.error!.kind, IpcErrorKind.userError); + }); + + test('search.replace with an invalid regex is a userError', () async { + final r = await call('search.replace', const {'pattern': '(bad', 'regex': true, 'replacement': 'x'}); + expect(r.ok, isFalse); + expect(r.error!.kind, IpcErrorKind.userError); + }); } diff --git a/test/search/replace_engine_test.dart b/test/search/replace_engine_test.dart new file mode 100644 index 00000000..869dfe50 --- /dev/null +++ b/test/search/replace_engine_test.dart @@ -0,0 +1,136 @@ +/// Tests for the search-and-replace engine (T-53, per D-79). +library; + +import 'dart:io'; + +import 'package:clide/src/files/ignore.dart'; +import 'package:clide/src/search/match.dart'; +import 'package:clide/src/search/replace_engine.dart'; +import 'package:test/test.dart'; + +void main() { + group('applyToText', () { + test('literal replacement counts and substitutes', () { + final r = applyToText('foo foo bar', const SearchQuery(pattern: 'foo'), 'X'); + expect(r.text, 'X X bar'); + expect(r.count, 2); + }); + + test('literal is case-sensitive by default, case-insensitive on request', () { + expect(applyToText('Foo foo', const SearchQuery(pattern: 'foo'), 'X').count, 1); + expect(applyToText('Foo foo', const SearchQuery(pattern: 'foo', ignoreCase: true), 'X').count, 2); + }); + + test(r'literal replacement does not expand $ references', () { + final r = applyToText('foo', const SearchQuery(pattern: 'foo'), r'$1-lit'); + expect(r.text, r'$1-lit'); + }); + + test('regex replacement expands capture groups', () { + final r = applyToText('alpha beta', const SearchQuery(pattern: r'(\w+) (\w+)', regex: true), r'$2 $1'); + expect(r.text, 'beta alpha'); + expect(r.count, 1); + }); + + test(r'regex $& is the whole match and $$ is a literal dollar', () { + final r = applyToText('x=1', const SearchQuery(pattern: r'\d', regex: true), r'$$$&'); + expect(r.text, r'x=$1'); + }); + + test('no match leaves text unchanged with count 0', () { + final r = applyToText('abc', const SearchQuery(pattern: 'zzz'), 'X'); + expect(r.text, 'abc'); + expect(r.count, 0); + }); + + test('empty pattern is a no-op', () { + final r = applyToText('abc', const SearchQuery(pattern: ''), 'X'); + expect(r.count, 0); + }); + }); + + group('computeReplacements', () { + late Directory root; + setUp(() async { + root = await Directory.systemTemp.createTemp('clide-replace-'); + File('${root.path}/a.dart').writeAsStringSync('final foo = 1;\nfinal bar = foo;\n'); + File('${root.path}/b.txt').writeAsStringSync('no hits\n'); + }); + tearDown(() async => root.delete(recursive: true)); + + test('reports changed files with per-line before/after edits', () async { + final r = await computeReplacements( + root: root, + ignore: IgnoreSet([]), + query: const SearchQuery(pattern: 'foo'), + replacement: 'baz', + ); + expect(r, hasLength(1)); + final fr = r.single; + expect(fr.path, 'a.dart'); + expect(fr.count, 2); + expect(fr.edits, hasLength(2)); + expect(fr.edits.first.before, 'final foo = 1;'); + expect(fr.edits.first.after, 'final baz = 1;'); + }); + + test('files with no match are omitted', () async { + final r = await computeReplacements( + root: root, + ignore: IgnoreSet([]), + query: const SearchQuery(pattern: 'foo'), + replacement: 'baz', + ); + expect(r.any((f) => f.path == 'b.txt'), isFalse); + }); + + test('honours the ignore set', () async { + final r = await computeReplacements( + root: root, + ignore: IgnoreSet.parse(const ['*.dart\n']), + query: const SearchQuery(pattern: 'foo'), + replacement: 'baz', + ); + expect(r, isEmpty); + }); + + test('binary files are skipped', () async { + File('${root.path}/blob.bin').writeAsBytesSync([0x66, 0x6f, 0x6f, 0x00, 0x66, 0x6f, 0x6f]); + final r = await computeReplacements( + root: root, + ignore: IgnoreSet([]), + query: const SearchQuery(pattern: 'foo'), + replacement: 'baz', + ); + expect(r.any((f) => f.path == 'blob.bin'), isFalse); + }); + }); + + group('rewriteFileContent', () { + late Directory root; + setUp(() async { + root = await Directory.systemTemp.createTemp('clide-rewrite-'); + File('${root.path}/a.dart').writeAsStringSync('foo and foo\n'); + }); + tearDown(() async => root.delete(recursive: true)); + + test('returns the rewritten content', () { + final out = rewriteFileContent(root.absolute.path, 'a.dart', const SearchQuery(pattern: 'foo'), 'X'); + expect(out, 'X and X\n'); + }); + + test('returns null when nothing changes', () { + final out = rewriteFileContent(root.absolute.path, 'a.dart', const SearchQuery(pattern: 'zzz'), 'X'); + expect(out, isNull); + }); + + test('ReplacementEdit + FileReplacement round-trip JSON', () { + const fr = FileReplacement(path: 'a.dart', count: 1, edits: [ReplacementEdit(line: 2, before: 'a', after: 'b')]); + final back = FileReplacement.fromJson(fr.toJson()); + expect(back.path, 'a.dart'); + expect(back.count, 1); + expect(back.edits.single.line, 2); + expect(back.edits.single.after, 'b'); + }); + }); +}