Review High (merge blocker): query-replace searched the origin buffer's
bytes but applied edits and moved the cursor through apply_active_edit /
search_place_cursor, which target whatever is ACTIVE. Focus can drift
mid-session — a click into another split, a key from another frontend,
both changing the active buffer outside the shadow — so a match found in
the origin buffer could be applied to an unrelated one. Buffer
corruption.
Fix: query_replace_on_origin() verifies the active buffer still equals
the session's origin buffer before every edit; on mismatch it ABORTS
without editing (clears the highlight, drops the session, status
'query-replace aborted: active buffer changed'), so an origin match can
never land in a foreign buffer. Guards replace/skip/all/replace-and-quit.
The dispatcher's after-edit revision compare now targets the ORIGIN
buffer (query_replace_origin_buffer + buffer_revision) not the active
one, so a drift-abort — which edits nothing — never spuriously fires
the hook. The forward-search clamp uses the origin bytes' length, not
active_buffer_len.
Also (review Low/med): query_replace_active() added to the
completion-popup modal-close guard, so a popup opened via the direct
Lua start (ed.query_replace_start) can't linger rendered-but-unreachable
while QR swallows keys.
Tests: core drift-abort (both buffers untouched) + end-to-end
focus-drift regression; ! fires after-edit exactly once for the batch;
RET/Esc/C-g quit paths (keep replacements); DEL skips. Acceptance
header corrected to match actual coverage.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>