ADR 0131: Replacing across files¶
Status¶
Accepted
Context¶
Project search has been read-only since ADR 0066: find every match, jump to one. Editing them was deliberately left out, because "change every file at once" needs an answer to two questions that finding does not.
LSP rename is waiting on the same two answers, which is why both have sat on the roadmap as one item.
Decision¶
Undo is per file, and the preview is the safety mechanism¶
ase_undo_undo(stack, buffer, ...) — one stack, one buffer. The undo
stack has no notion of an operation spanning files, and giving it one
means a window-level history that owns every buffer's, with genuinely
ambiguous semantics: undo in a file you have since edited would silently
change others.
So a multi-file edit is N per-file edits applied together. Each file
gets exactly one undo group, so u takes back that file's whole share.
Reversing the entire operation means closing the buffers without saving.
That makes the preview the thing that keeps you safe, not undo. You
decide before, not after — every change is listed, space keeps or drops
one, ctrl+enter applies what is left.
Applied into buffers, never onto disk¶
Every affected file is opened as a real buffer and edited through the ordinary undo path. Nothing is written until you save.
This costs a tab per file, which is the honest price: the editor shows you what it changed. In exchange it reuses the atomic save (ADR 0109), crash snapshots (ADR 0127) and the dirty marks, and "close without saving" is a complete reversal.
Writing straight to disk for files that are not open would be faster and was rejected: no preview of the result, no undo at all, and it steps around the save path that exists precisely to not lose files.
Where the code lives¶
applyLineEdits() on the viewport applies one file's share as a single
undo group. It sorts and applies last-first, because every
replacement shifts the offsets of the ones after it, and it does not
trust the caller to have sorted. An edit naming a place the buffer does
not have is skipped rather than fatal — a hit can outlive the file it was
found in, and half a rename is worse than none.
project::editsByFile() groups accepted hits into per-file shares. It is
separate from MainWindow because MainWindow lives in main.cpp
beside main() and cannot be linked into a test; what is left there is
opening a buffer and calling the above, which has nothing to get wrong.
Consequences¶
Alt+R, mirroring Alt+F. The find bar already had a replace row and a
project mode; this is both at once.
Three defects, each found by a different method, which is the part worth recording:
The first run of the unit tests caught offsetForLineColumn()
counting from zero on both axes while every caller in this feature counts
from one. It clamps rather than refusing, so the error was silent — every
edit resolved to a place just inside the previous line and was rejected
by the range check.
The first run in the actual editor showed an empty panel.
setMode() read mode == Mode::SearchResults to decide whether the list
was visible, so adding a third mode hid the rows and showed an empty text
view — which looks exactly like a search that found nothing.
Reading the preview it then drew caught two more. hit.text is
trimmed but hit.column indexes the untrimmed line, so the preview drew
the replacement one indent late: return widggadgetal;. SearchHit
gained textColumn for indexing what is displayed.
And the last of them was the real one. project::search() returns one
hit per line — correct for a results list, where six near-identical
rows push real hits off the screen, and silently wrong for a replace,
where five of six surviving is the worst thing it could do. search()
now takes everyOccurrence, false for finding and true for replacing.
A search that returns a truncated result refuses to become a replace at all. Replacing a prefix of the matches while implying it was all of them is the one outcome worth refusing outright.
Rename is the next producer of the same edit set: a WorkspaceEdit is a
list of per-file ranges and replacements, which is what editsByFile()
already returns.