fix(edit): PR #109 round 1 — shared search invalidation, daemon anchor clear, bounded indent scan
Finding 1: the empty-anchor optimistic residual was never GPU-only (the TUI mirror tracks no selection state; its gate checks cursor freshness/EOL only). The fix moves daemon-side: handle_remote_crdt_op clears a selection whose anchor equals the pre-edit cursor (= empty) before applying the source cursor update; nonempty selections stand. Covers both frontends. The TUI gate's missing type-over check (nonempty selection at EOL) is a named deferral. Finding 2: Q#AI8 invalidation is one helper (search_invalidate_for_edit) invoked from all four edit paths -- apply_active_edit, notify_buffer_edit, and now undo/redo, which received precise Edits but invalidated nothing. rebuild_views_for is named as a lower-frequency bypass (deferral). Finding 3: acceptance matrix trued up -- added active-search fail-closed + retype recovery, delete translation on both paths, undo/redo staleness + origin tests; modal contexts narrowed to what this suite pins (query-replace/menu/completion ride their own suites). Finding 4: indent extraction is a forward-chunked scan stopping at the first non-whitespace byte -- Enter at the end of a giant minified line no longer materializes the line. Functional pin at 64 KiB. Both medium fixes are bite-verified (tests fail with the fix disabled). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ATiKMwJ4864d82D39EvsU6
This commit is contained in:
parent
7b5365cfbf
commit
180343e6e3
|
|
@ -33,14 +33,25 @@ local function line_start_before(buf, pos)
|
|||
end
|
||||
|
||||
-- The indent to carry over a split at `split` (Q#AI3):
|
||||
-- bytes[line_start .. min(first_non_ws, split)]. Slicing the line head
|
||||
-- up to the split point and taking its leading `[ \t]*` run IS that
|
||||
-- clip — the match cannot run past the slice's end. `[ \t]` rather
|
||||
-- bytes[line_start .. min(first_non_ws, split)]. Forward chunked scan
|
||||
-- from the line start, stopping at the first non-whitespace byte —
|
||||
-- never materializing more of the line than the indent itself plus
|
||||
-- one chunk (Enter at the end of a giant minified line must not copy
|
||||
-- the whole line just to produce an empty indent). `[ \t]` rather
|
||||
-- than `%s` so a CR on a CRLF line never counts as indent.
|
||||
local function indent_before(buf, split)
|
||||
local start = line_start_before(buf, split)
|
||||
if split <= start then return "" end
|
||||
return buf:slice(start, split):match("^[ \t]*")
|
||||
local parts = {}
|
||||
local p = start
|
||||
while p < split do
|
||||
local chunk_to = math.min(p + 4096, split)
|
||||
local chunk = buf:slice(p, chunk_to)
|
||||
local ws = chunk:match("^[ \t]*")
|
||||
table.insert(parts, ws)
|
||||
if #ws < #chunk then break end
|
||||
p = chunk_to
|
||||
end
|
||||
return table.concat(parts)
|
||||
end
|
||||
|
||||
-- Right-gravity translation of `pos` through the effective edit
|
||||
|
|
|
|||
|
|
@ -7,12 +7,14 @@ language-agnostic, as one undoable edit. Second-to-last Arc 2 item;
|
|||
auto-pairing follows as its own framing + PR.
|
||||
|
||||
Roadmap: `docs/roadmap-2026-07.md` Arc 2 ("auto-indent on newline").
|
||||
Revision 5: Q#AI9 is success-gated (`insert_char` reports success;
|
||||
clear only on Ok — a rejected edit must not mutate selection state),
|
||||
and Q#AI2 names the one deliberate behavior change `buffer.newline`
|
||||
inherits through the shared primitive. Earlier: live-origin
|
||||
translation (Q#AI8), M6.4 same-kind intercept contract (Q#AI5),
|
||||
empty-selection core fix with the GPU residual named (Q#AI9).
|
||||
Revision 6 (PR #109 round 1): Q#AI8's invalidation is one helper
|
||||
invoked from all four edit paths — dispatch, direct notification,
|
||||
undo, redo — and the empty-anchor clear moved daemon-side, closing
|
||||
the optimistic case for BOTH frontends (the residual was never
|
||||
GPU-only: the TUI mirror tracks no selection state). The acceptance
|
||||
matrix now matches what the suites actually pin. Earlier revisions:
|
||||
success-gated Q#AI9, live-origin translation, M6.4 same-kind
|
||||
intercept contract, modal-context corrections.
|
||||
|
||||
## Ground truth (as of `efa41cb`)
|
||||
|
||||
|
|
@ -310,14 +312,17 @@ anyway.
|
|||
|
||||
Three parts, all required:
|
||||
|
||||
1. **Mark**: `notify_buffer_edit` gains the same
|
||||
`SearchStore::mark_stale` call `apply_active_edit` already makes.
|
||||
One place, all three callers fixed: applied CRDT ops
|
||||
(`src/daemon.rs:2133`), general Lua mutator edits
|
||||
(`src/lua_bindings/mod.rs:1390-1395`), and the errors-buffer
|
||||
append (`src/lua.rs:442` — normally a no-op, unless the *errors*
|
||||
buffer itself carries accepted search state, in which case
|
||||
marking it stale is exactly right).
|
||||
1. **Mark**: one helper, `search_invalidate_for_edit` (mark stale +
|
||||
translate the origin), invoked from **all four edit paths**:
|
||||
`apply_active_edit` (dispatch), `notify_buffer_edit` (applied CRDT
|
||||
ops `src/daemon.rs:2133`, general Lua mutator edits
|
||||
`src/lua_bindings/mod.rs:1390-1395`, and the errors-buffer append
|
||||
`src/lua.rs:442` — normally a no-op, unless the *errors* buffer
|
||||
itself carries accepted search state, in which case marking it
|
||||
stale is exactly right), and — round-1 finding — `undo` / `redo`,
|
||||
which receive precise `Edit` values but previously invalidated
|
||||
nothing. Generated-buffer rebuilds via `rebuild_views_for` remain
|
||||
a named lower-frequency bypass (deferral).
|
||||
2. **Honor (fail closed)**: `SearchStore::step` returns `None` while
|
||||
stale — C-s / `search.next` stops navigating byte ranges that no
|
||||
longer exist instead of teleporting the cursor to them — and
|
||||
|
|
@ -363,14 +368,22 @@ input, on both frontends' round-trip paths, and makes
|
|||
`insert_char_over_region`'s existing "any selection is cleared" doc
|
||||
claim true.
|
||||
|
||||
**Out of scope, named**: the GPU optimistic path inherits the bug
|
||||
independently — its eligibility gate reads Selection *decorations*,
|
||||
which an empty selection never paints (ground truth), and the
|
||||
daemon's CRDT-apply arm clears no anchors. Optimistic typing over an
|
||||
empty anchor therefore still arms a surprise selection that the next
|
||||
round-tripped key can consume. Fixing that means teaching the CRDT
|
||||
arm about anchors — deferred alongside the substrate window-state
|
||||
reconciliation work, explicitly, not silently.
|
||||
**Also in scope (round 1: the residual was never GPU-only)**: the
|
||||
TUI attach mirror tracks buffers and cursors but no selection state
|
||||
(`src/buffer_mirror.rs:113`), and its optimistic gate checks cursor
|
||||
freshness and EOL only (`src/optimistic.rs:250`) — so both
|
||||
frontends' optimistic paths could re-arm the type-over. The fix
|
||||
lives in the daemon's CRDT source arm (`handle_remote_crdt_op`):
|
||||
before applying the source cursor update, a selection whose anchor
|
||||
equals the pre-edit cursor — i.e. one that was EMPTY — is cleared.
|
||||
Nonempty selections stand untouched.
|
||||
|
||||
**Out of scope, named**: the TUI optimistic gate performs no
|
||||
type-over check at all — a NONEMPTY selection ending at EOL
|
||||
optimistically inserts instead of consuming the region (the GPU
|
||||
gates on Selection decorations, which nonempty selections do paint,
|
||||
so it round-trips correctly there). That is a pre-existing TUI gate
|
||||
gap, deferred with the substrate reconciliation work.
|
||||
|
||||
## Bets
|
||||
|
||||
|
|
@ -409,10 +422,12 @@ reconciliation work, explicitly, not silently.
|
|||
per-command — including windows other than the acting one), and
|
||||
aligning comment.lua's transformed-intercept fix-up with Q#AI5's
|
||||
translate-and-clamp discipline.
|
||||
- **GPU-optimistic empty-anchor residual** (Q#AI9): the daemon CRDT
|
||||
arm clears no selection anchors, and an empty anchor paints no
|
||||
Selection decoration to gate on — optimistic typing can still arm
|
||||
a type-over one keystroke later.
|
||||
- **TUI optimistic type-over gate gap** (Q#AI9 round 1): the TUI
|
||||
attach gate consults no selection state, so a NONEMPTY selection
|
||||
ending at EOL optimistically inserts instead of type-over. (The
|
||||
empty-anchor half was fixed daemon-side in round 1.)
|
||||
- **Generated-buffer rebuilds** (`rebuild_views_for`) bypass search
|
||||
invalidation — a lower-frequency Q#AI8 gap, named not handled.
|
||||
- Auto-recompute of a stale search from the stored query on step
|
||||
(Q#AI8 fails closed instead).
|
||||
- Unifying the five hardcoded tab-width sites (4× core `TAB_WIDTH=8`,
|
||||
|
|
@ -459,26 +474,30 @@ reconciliation work, explicitly, not silently.
|
|||
untouched — the original window's validity after such an intercept
|
||||
belongs to the substrate-reconciliation deferral, and the test does
|
||||
not claim it.
|
||||
- **Search staleness** (Q#AI8):
|
||||
- isearch, accept, RET → accepted highlights marked stale; a
|
||||
direct `buf:insert` case pins the notify-path level.
|
||||
- Direct/remote edit during **active** isearch → `C-s` is a no-op
|
||||
(cursor unmoved, no jump to obsolete offsets) and the n/m summary
|
||||
reads `(None, 0)`; typing the next pattern char refreshes and
|
||||
stepping resumes.
|
||||
- **Origin translation**: during a live search, an external insert
|
||||
and a delete strictly before the origin → the next pattern
|
||||
character focuses relative to the translated origin, and cancel
|
||||
restores the cursor to the translated position — exercised via
|
||||
both the notify path (Lua mutator/CRDT) and the other-frontend
|
||||
dispatch path.
|
||||
- Post-accept edit → `search.next` no-op instead of stale
|
||||
navigation.
|
||||
- **Search staleness** (Q#AI8; acceptance + lib split, per what each
|
||||
seam can reach):
|
||||
- Acceptance: isearch, accept, RET → post-accept `search_step`
|
||||
no-op; the same via a direct `buf:insert` (notify path).
|
||||
- Lib (editor_core): edit during **active** isearch → step no-op
|
||||
and summary `(None, 0)`, then the next pattern keystroke
|
||||
refreshes and stepping resumes; origin translation through
|
||||
**inserts and deletes on both edit paths** (dispatch and
|
||||
notify), pinning recompute focus and cancel restore; **undo and
|
||||
redo** stale accepted matches and translate the live origin.
|
||||
- Lib (search store): stale `step` fails closed; a fresh `set`
|
||||
un-sticks it.
|
||||
- Lib (daemon, `--features crdt`): the optimistic source arm
|
||||
clears an EMPTY anchor and leaves a NONEMPTY selection alone.
|
||||
- `after-edit` fires exactly once per RET (keybound and `M-x`).
|
||||
- Kill-chain break: `C-k`, RET, `C-k` → two ring entries.
|
||||
- Modal contexts unaffected: isearch accept, query-replace prompt,
|
||||
context-menu invoke, minibuffer accept (the `m_x` helper exercises
|
||||
it), completion-popup accept.
|
||||
- Modal contexts: isearch accept and minibuffer accept pinned here
|
||||
(plus buffer-list RET below). Query-replace, context-menu, and
|
||||
completion-popup RET are pinned by their own suites
|
||||
(`tests/query_replace_acceptance.rs`, the menu/completion
|
||||
acceptance suites), which run in the gates — not re-pinned here.
|
||||
- Giant minified line (64 KiB, unindented and indented): splits
|
||||
correctly — pins the forward-chunked indent scan's behavior
|
||||
(boundedness is by construction).
|
||||
- Buffer-list RET still visits via dispatch (now also reachable from
|
||||
the GPU frontend).
|
||||
- `this_command()` after RET is `edit.newline-and-indent`.
|
||||
|
|
|
|||
121
src/daemon.rs
121
src/daemon.rs
|
|
@ -2111,6 +2111,19 @@ fn handle_remote_crdt_op(
|
|||
continue;
|
||||
}
|
||||
if Some(*wid) == source_active_window_id {
|
||||
// Q#AI9 (PR #109 round 1): an empty anchor armed at
|
||||
// the pre-edit cursor must not survive the cursor
|
||||
// moving off it — otherwise the optimistic paths
|
||||
// (GPU always; TUI mirror, which tracks no selection
|
||||
// state) re-arm the type-over that
|
||||
// `insert_char_over_region`'s no-region clear fixed
|
||||
// on the dispatch path. Nonempty selections stand:
|
||||
// the TUI gate's missing type-over check is a named
|
||||
// deferral, and guessing here would destroy a real
|
||||
// selection.
|
||||
if win.selection.map(|sel| sel.anchor) == Some(win.cursor) {
|
||||
win.selection = None;
|
||||
}
|
||||
// Source window: set directly to optimistic post-edit
|
||||
// position (matches the source frontend's mirror
|
||||
// cursor after advance/retreat).
|
||||
|
|
@ -2551,6 +2564,114 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
/// Q#AI9 (PR #109 round 1): the optimistic-apply arm clears an
|
||||
/// EMPTY anchor on the source window — the GPU always takes this
|
||||
/// path, and the TUI attach mirror tracks no selection state, so
|
||||
/// neither frontend's gate stops an armed-empty-anchor sequence
|
||||
/// from re-creating the type-over that
|
||||
/// `insert_char_over_region`'s no-region clear fixed on the
|
||||
/// dispatch path. A NONEMPTY selection must survive untouched
|
||||
/// (the TUI gate's missing type-over check is a named deferral).
|
||||
#[cfg(feature = "crdt")]
|
||||
#[test]
|
||||
fn handle_remote_crdt_op_clears_only_an_empty_source_anchor() {
|
||||
use crate::editor::EditorState;
|
||||
use crate::protocol::FrontendId;
|
||||
use crate::window::Selection;
|
||||
|
||||
// Shared fixture: CRDT-backed active buffer + a peer doc that
|
||||
// produces the optimistic op, sourced from LOCAL (which has a
|
||||
// registered view, so the source-window arm runs).
|
||||
fn apply_peer_insert(editor: &mut EditorState, buffer_id: crate::buffer::BufferId) {
|
||||
let snapshot_bytes = {
|
||||
let core = editor.core.borrow();
|
||||
let reg = core.registry.borrow();
|
||||
let buf = reg.get(buffer_id).expect("buffer");
|
||||
buf.crdt_state()
|
||||
.expect("crdt-backed")
|
||||
.export_snapshot()
|
||||
.expect("export snapshot")
|
||||
};
|
||||
let peer = loro::LoroDoc::new();
|
||||
peer.set_peer_id(u64::from(FrontendId::LOCAL.0))
|
||||
.expect("set peer id");
|
||||
peer.import(&snapshot_bytes).expect("import snapshot");
|
||||
let v_before = peer.oplog_vv();
|
||||
peer.get_text("body").insert(0, "x").expect("peer insert");
|
||||
let op_bytes = peer
|
||||
.export(loro::ExportMode::updates(&v_before))
|
||||
.expect("export op");
|
||||
super::handle_remote_crdt_op(
|
||||
editor,
|
||||
FrontendId::LOCAL,
|
||||
buffer_id,
|
||||
crate::rope::CrdtOp {
|
||||
peer_id: FrontendId::LOCAL.0,
|
||||
bytes: op_bytes,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Case 1: empty anchor at the cursor (S-Left-at-BOF shape) —
|
||||
// cleared by the optimistic apply.
|
||||
let mut editor = EditorState::new();
|
||||
let buffer_id = editor.core.borrow().active_window().buffer_id;
|
||||
{
|
||||
let core = editor.core.borrow();
|
||||
let mut reg = core.registry.borrow_mut();
|
||||
reg.get_mut(buffer_id)
|
||||
.expect("active buffer")
|
||||
.upgrade_to_crdt(2)
|
||||
.expect("upgrade to crdt");
|
||||
}
|
||||
{
|
||||
let mut core = editor.core.borrow_mut();
|
||||
let at = core.active_window().cursor;
|
||||
core.active_window_mut().selection = Some(Selection { anchor: at });
|
||||
}
|
||||
apply_peer_insert(&mut editor, buffer_id);
|
||||
{
|
||||
let core = editor.core.borrow();
|
||||
assert!(
|
||||
core.active_window().selection.is_none(),
|
||||
"an empty anchor must not survive an optimistic source edit"
|
||||
);
|
||||
assert_eq!(
|
||||
core.active_window().cursor,
|
||||
1,
|
||||
"cursor at post-edit position"
|
||||
);
|
||||
}
|
||||
|
||||
// Case 2: nonempty selection — the arm must not touch it.
|
||||
let mut editor = EditorState::new();
|
||||
let buffer_id = editor.core.borrow().active_window().buffer_id;
|
||||
editor.core.borrow_mut().insert_char('a');
|
||||
editor.core.borrow_mut().insert_char('b');
|
||||
{
|
||||
let core = editor.core.borrow();
|
||||
let mut reg = core.registry.borrow_mut();
|
||||
reg.get_mut(buffer_id)
|
||||
.expect("active buffer")
|
||||
.upgrade_to_crdt(2)
|
||||
.expect("upgrade to crdt");
|
||||
}
|
||||
{
|
||||
let mut core = editor.core.borrow_mut();
|
||||
core.active_window_mut().selection = Some(Selection { anchor: 0 });
|
||||
// cursor is at 2 after the two inserts: nonempty region.
|
||||
}
|
||||
apply_peer_insert(&mut editor, buffer_id);
|
||||
{
|
||||
let core = editor.core.borrow();
|
||||
assert_eq!(
|
||||
core.active_window().selection,
|
||||
Some(Selection { anchor: 0 }),
|
||||
"a nonempty selection survives the optimistic source edit"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Kill ring Q#KR2 — GPU typing arrives here without touching
|
||||
/// dispatch_key, so it must update the source frontend's command
|
||||
/// boundary or `C-k x C-k` on the GPU would append across the typed
|
||||
|
|
|
|||
|
|
@ -1192,17 +1192,28 @@ impl EditorCore {
|
|||
.push((CrdtOpOrigin::DaemonKey, buffer_id, (**crdt_op).clone()));
|
||||
}
|
||||
// Search matches were computed against the pre-edit text, so
|
||||
// their byte positions are now wrong. Mark the buffer's matches
|
||||
// stale (M11.8): the producer / TUI overlay suppress them until
|
||||
// a fresh search re-runs against the current content. No-op for
|
||||
// a buffer with no search state. The headline isearch bet —
|
||||
// "stale-after-edit linger" — is closed here.
|
||||
// their byte positions are now wrong (M11.8): the producer /
|
||||
// TUI overlay suppress them until a fresh search re-runs. The
|
||||
// headline isearch bet — "stale-after-edit linger" — is
|
||||
// closed here.
|
||||
self.search_invalidate_for_edit(buffer_id, &edit);
|
||||
Ok(edit.new_rope.len())
|
||||
}
|
||||
|
||||
/// Q#AI8 search invalidation for a landed edit: mark the buffer's
|
||||
/// matches stale (no-op without search state) and right-gravity-
|
||||
/// translate the live session origin. ONE helper so every edit
|
||||
/// path — dispatch ([`Self::apply_active_edit`]), direct
|
||||
/// notification ([`Self::notify_buffer_edit`]), and history
|
||||
/// ([`Self::undo`] / [`Self::redo`]) — invalidates identically;
|
||||
/// a path that skips this leaves highlights, step targets, and
|
||||
/// the n/m count pointing at pre-edit offsets.
|
||||
fn search_invalidate_for_edit(&mut self, buffer_id: BufferId, edit: &Edit) {
|
||||
self.search_store
|
||||
.lock()
|
||||
.expect("search store mutex poisoned")
|
||||
.mark_stale(buffer_id);
|
||||
self.translate_search_origin(buffer_id, &edit);
|
||||
Ok(edit.new_rope.len())
|
||||
self.translate_search_origin(buffer_id, edit);
|
||||
}
|
||||
|
||||
/// Right-gravity-translate the live search origin through an edit
|
||||
|
|
@ -1247,11 +1258,7 @@ impl EditorCore {
|
|||
/// highlights and the session origin survive at pre-edit offsets
|
||||
/// for every Lua mutator edit and applied CRDT op.
|
||||
pub fn notify_buffer_edit(&mut self, buffer_id: BufferId, edit: &Edit) {
|
||||
self.search_store
|
||||
.lock()
|
||||
.expect("search store mutex poisoned")
|
||||
.mark_stale(buffer_id);
|
||||
self.translate_search_origin(buffer_id, edit);
|
||||
self.search_invalidate_for_edit(buffer_id, edit);
|
||||
let reg = self.registry.borrow();
|
||||
let Ok(buffer) = reg.get(buffer_id) else {
|
||||
return;
|
||||
|
|
@ -1897,6 +1904,9 @@ impl EditorCore {
|
|||
}
|
||||
}
|
||||
drop(reg);
|
||||
// Q#AI8 (PR #109 round 1): history edits move bytes
|
||||
// like any other edit — invalidate search state.
|
||||
self.search_invalidate_for_edit(buffer_id, &edit);
|
||||
// Post-audit-round-5 F27: undo on a CRDT-backed
|
||||
// buffer produces a crdt_op that must broadcast to
|
||||
// every replica frontend (including the one whose
|
||||
|
|
@ -1935,6 +1945,8 @@ impl EditorCore {
|
|||
}
|
||||
}
|
||||
drop(reg);
|
||||
// Q#AI8 — same as undo above.
|
||||
self.search_invalidate_for_edit(buffer_id, &edit);
|
||||
// Post-audit-round-5 F27 — same as undo above.
|
||||
self.queue_daemon_origin_crdt_op(buffer_id, &edit);
|
||||
}
|
||||
|
|
@ -3826,6 +3838,138 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undo_and_redo_stale_accepted_search_navigation() {
|
||||
// Q#AI8 (PR #109 round 1): history edits move bytes like any
|
||||
// other edit — undo/redo must invalidate search state.
|
||||
let mut s = from_bytes(b"foo bar foo");
|
||||
s.active_window_mut().cursor = 0;
|
||||
assert!(s.insert_char('x')); // the history entry: "xfoo bar foo"
|
||||
s.search_begin(true, false);
|
||||
type_query(&mut s, "foo");
|
||||
s.search_finish(true); // accept
|
||||
assert_eq!(s.search_match_summary(), (Some(0), 2));
|
||||
s.undo(); // back to "foo bar foo": offsets moved
|
||||
assert_eq!(
|
||||
s.search_match_summary(),
|
||||
(None, 0),
|
||||
"undo stales the accepted matches"
|
||||
);
|
||||
let before = s.cursor();
|
||||
s.search_step(true);
|
||||
assert_eq!(s.cursor(), before, "stale step is a no-op after undo");
|
||||
|
||||
// Redo the same way: refresh the matches first (a fresh set
|
||||
// clears staleness), then redo must stale them again.
|
||||
s.search_begin(true, false);
|
||||
type_query(&mut s, "foo");
|
||||
s.search_finish(true);
|
||||
assert_eq!(s.search_match_summary().1, 2);
|
||||
s.redo(); // forward to "xfoo bar foo" again
|
||||
assert_eq!(
|
||||
s.search_match_summary(),
|
||||
(None, 0),
|
||||
"redo stales the accepted matches"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undo_translates_the_live_search_origin() {
|
||||
let mut s = from_bytes(b"foo bar foo");
|
||||
s.active_window_mut().cursor = 0;
|
||||
assert!(s.insert_char('x')); // "xfoo bar foo"
|
||||
s.active_window_mut().cursor = 5;
|
||||
s.search_begin(true, false); // origin 5 ('b' of "bar")
|
||||
type_query(&mut s, "foo");
|
||||
s.undo(); // removes the 'x' at 0: origin must shift to 4
|
||||
s.search_finish(false); // cancel
|
||||
assert_eq!(
|
||||
s.cursor(),
|
||||
4,
|
||||
"cancel lands on the origin translated through the undo"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn origin_translates_through_deletes_on_both_paths() {
|
||||
// Dispatch path (apply_active_edit): backspace before the
|
||||
// origin.
|
||||
let mut s = from_bytes(b"foo bar foo");
|
||||
s.active_window_mut().cursor = 5;
|
||||
s.search_begin(true, false); // origin 5
|
||||
type_query(&mut s, "foo");
|
||||
s.active_window_mut().cursor = 2;
|
||||
s.backspace(); // deletes byte 1: origin 5 -> 4
|
||||
s.search_finish(false);
|
||||
assert_eq!(s.cursor(), 4, "origin shifted left by the deleted byte");
|
||||
|
||||
// Direct-notification path: a delete edit through
|
||||
// notify_buffer_edit.
|
||||
let mut s = from_bytes(b"foo bar foo");
|
||||
let bid = s.active_buffer_id();
|
||||
s.active_window_mut().cursor = 5;
|
||||
s.search_begin(true, false); // origin 5
|
||||
type_query(&mut s, "foo");
|
||||
let edit = {
|
||||
let mut reg = s.registry.borrow_mut();
|
||||
let buffer = reg.get_mut(bid).expect("buffer");
|
||||
buffer
|
||||
.apply_edit(crate::buffer::EditOp::Delete {
|
||||
range: Range::new(0, 2),
|
||||
})
|
||||
.expect("direct delete")
|
||||
};
|
||||
s.notify_buffer_edit(bid, &edit);
|
||||
s.search_finish(false);
|
||||
assert_eq!(
|
||||
s.cursor(),
|
||||
3,
|
||||
"origin shifted left by the two directly deleted bytes"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn active_search_fails_closed_while_stale_and_recovers_on_retype() {
|
||||
// Q#AI8 during a LIVE session: an external edit mid-search
|
||||
// makes step and summary fail closed; the next pattern
|
||||
// keystroke recomputes (set clears staleness) and resumes.
|
||||
let mut s = from_bytes(b"foo bar foo");
|
||||
let bid = s.active_buffer_id();
|
||||
s.active_window_mut().cursor = 0;
|
||||
s.search_begin(true, false);
|
||||
type_query(&mut s, "fo");
|
||||
assert_eq!(s.search_match_summary().1, 2);
|
||||
let edit = {
|
||||
let mut reg = s.registry.borrow_mut();
|
||||
let buffer = reg.get_mut(bid).expect("buffer");
|
||||
buffer
|
||||
.apply_edit(crate::buffer::EditOp::Insert {
|
||||
pos: 0,
|
||||
bytes: b"zz",
|
||||
})
|
||||
.expect("direct insert")
|
||||
};
|
||||
s.notify_buffer_edit(bid, &edit);
|
||||
assert_eq!(
|
||||
s.search_match_summary(),
|
||||
(None, 0),
|
||||
"summary fails closed mid-search"
|
||||
);
|
||||
let before = s.cursor();
|
||||
s.search_step(true);
|
||||
assert_eq!(s.cursor(), before, "step fails closed mid-search");
|
||||
// Growing the query recomputes against the current text.
|
||||
type_query(&mut s, "o");
|
||||
assert_eq!(
|
||||
s.search_match_summary().1,
|
||||
2,
|
||||
"the next pattern keystroke refreshes the match set"
|
||||
);
|
||||
assert_eq!(s.cursor(), 2, "focus lands from the translated origin");
|
||||
s.search_step(true);
|
||||
assert_eq!(s.cursor(), 10, "stepping resumes after the refresh");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_backspace_widens_the_match_set() {
|
||||
let mut s = from_bytes(b"fo foo food");
|
||||
|
|
|
|||
|
|
@ -149,6 +149,27 @@ fn zero_indent_and_empty_buffer_match_plain_newline() {
|
|||
assert_eq!(cursor(&s), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn giant_minified_line_splits_correctly() {
|
||||
// PR #109 round 1 finding 4: the indent scan is forward-chunked
|
||||
// and stops at the first non-whitespace byte, so Enter at the end
|
||||
// of a huge unindented line never materializes the line. This
|
||||
// pins the behavior; boundedness is by construction.
|
||||
let long = "x".repeat(64 * 1024);
|
||||
let mut s = editor_with(&long);
|
||||
exec(&s, &format!("pmacs.editor.goto_byte({})", long.len()));
|
||||
press(&mut s, KeyCode::Enter);
|
||||
assert_eq!(buffer_text(&s), format!("{long}\n"));
|
||||
assert_eq!(cursor(&s) as usize, long.len() + 1);
|
||||
|
||||
// And an indented giant line still carries exactly its indent.
|
||||
let body = format!(" {long}");
|
||||
let mut s = editor_with(&body);
|
||||
exec(&s, &format!("pmacs.editor.goto_byte({})", body.len()));
|
||||
press(&mut s, KeyCode::Enter);
|
||||
assert_eq!(buffer_text(&s), format!("{body}\n "));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whitespace_only_line_copies_and_the_abandoned_line_keeps_its_whitespace() {
|
||||
// Named non-goal (Q#AI3): no trailing-whitespace cleanup on the
|
||||
|
|
|
|||
Loading…
Reference in New Issue