- Recovery works when another named buffer is current. On that path
OpenFile's NotifyFilenameChanged deleted the journal (old path ==
new path) that was about to be replayed; the swap was lost and the
file opened without the edits (pre-existing).
- After recovering from a journal with a torn final record, the torn
bytes are truncated before the session appends to it (ReplayFile now
reports the valid prefix length). A journal that recovers nothing is
removed instead of appended to.
- Reload restarts the journal; recovery used to replay the edits the
reload discarded.
- Opening a file that is already open switches to its buffer (in
OpenFile and before the recovery check). Two buffers for one file
interleaved records in one journal, and closing either unlinked it.
- Swap file names escape '!' and '%', so distinct paths no longer
share a journal ('/x/a!b' vs '/x/a/b'). Swap files for paths that
contain those characters get new names.
- The gap flag is cleared when the journal restarts, and a gap that
cannot be closed (buffer too large to checkpoint) is reported once.
- crc32's table is initialised thread-safely; writer-thread error
reports identify the buffer by journal path instead of racing on
Buffer::Filename(); Close() no longer retries on EINTR (the fd is
already released and may have been reused).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015rcVQiiWyTzoNrsvLSaAfB
libstdc++'s std::regex matcher recurses about once per matched
character, so '.*', 'a+' or '(a|b)*' on a line of a few tens of
thousands of characters (minified JS/JSON) overflowed the 8 MiB stack
and crashed the editor from the regex search prompt or regex replace.
Regex search and regex replace now run their matching on a worker
thread with a 1 GiB reserved stack (RunWithLargeStack; the caller
waits, so nothing is shared concurrently). Renderers skip regex match
highlighting on lines over 10,000 bytes. Catastrophic backtracking
(e.g. (a*)*b) is still possible: std::regex has no time limit.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015rcVQiiWyTzoNrsvLSaAfB
- Motion and delete commands read rows through RowsView (on-demand,
version-cached) instead of Buffer::Rows(), which rebuilt every line of
the file after each edit. On a 500k-line buffer after an edit:
backspace/delete/motions 80-90 ms -> ~0.1 ms; C-u 3000 C-d 273 s ->
0.25 s. Whole-buffer search scans keep Rows() (cached between edits).
- While a prompt is open only prompt-aware and view-only commands run.
Others edited the buffer behind the prompt (bypassing the read-only
check) or switched/closed buffers under a pending confirmation, which
then acted on the wrong buffer. The ':' command path now applies the
read-only check too.
- Pasting into a prompt (ImGui) flattens line breaks to spaces instead
of emitting Newline, which accepted the prompt and sent the rest of
the paste to the buffer (or ran replace-all).
- The mark region clamps both ends; a stale mark past the end of the
buffer is treated as end of buffer. This fixes kill-region deleting
the wrong text and leaving the cursor past end-of-line.
- Kill-line with a count stops at end of buffer instead of killing
lines above the cursor (regression from the D3 change).
- Undo groups nest, so SmartNewline in visual-line mode is one step.
- Word motions and word deletion treat non-ASCII bytes as word
characters and so never split a UTF-8 character.
- The universal-argument count saturates at 1,000,000 instead of
overflowing int.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015rcVQiiWyTzoNrsvLSaAfB
Consolidation rewrites pieces without changing content or offsets but
invalidated the line index, so once a buffer passed the piece limit
every edit paid a full rescan again. A moved-from table kept the index
of the text it gave away, and Insert now updates that index in place,
producing ranges past the end of its buffer.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015rcVQiiWyTzoNrsvLSaAfB
Every edit bumped the buffer version (through SetDirty) and called
InvalidateFrom(0), so all cached spans and line states were discarded
and stateful highlighters re-lexed from row 0 to the viewport on each
keystroke.
Buffer mutators now report the row they changed (edited_at_), which
bumps the version and tells the engine to drop only rows from there
down; a row's highlighting depends only on rows above it. A version
change that arrives without that notice still clears everything, so a
missed path cannot show stale spans. The engine keeps states as a
contiguous vector and caches spans for every row it lexes. SetDirty no
longer touches the version: the edits themselves do, including undo
and redo back to the saved state (D9 still holds; its test now checks
the highlighting itself).
Typing near the end of a 100k-line C++ file: 46.3 ms -> 0.14 ms of
highlighting work per keystroke.
Audit finding P3.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015rcVQiiWyTzoNrsvLSaAfB
- PieceTable::Insert/Delete adjust a valid line index directly (shift
later line starts, add or drop starts for inserted or deleted
newlines) instead of invalidating it, which forced a byte-by-byte
rescan of the whole buffer on the next line query. On a 38 MB,
1M-line buffer, insert + line query drops from 14.7 ms to 0.28 ms.
- The terminal and ImGui renderers fetch only the rows they draw
(Nrows + GetLineString) instead of Buffer::Rows(), which rebuilt a
string for every line of the file on the first frame after each edit.
Qt's line-count uses switch to Nrows().
ImGuiRenderer.cc was syntax-checked with g++ and clang++ against the
vendored ImGui headers; the GUI targets could not be built here (no
SDL2 or Qt6).
Audit findings P1, P2.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015rcVQiiWyTzoNrsvLSaAfB
- Backspace and delete-char remove a whole UTF-8 character (they
removed one byte, leaving an invalid lead byte in the file), and
record the full character for undo.
- Left/right step over whole characters; up/down snap the column to a
character boundary and clamp the row, which also removes an unsigned
underflow in move-down when the cursor was past the last row.
- Horizontal scroll offsets are computed in display cells, the way the
terminal renderer draws (tabs, wide characters), instead of bytes, so
long non-ASCII lines no longer scroll to blank with the cursor off
screen. The mouse column mapping uses the same measure.
Audit findings B5, B6.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015rcVQiiWyTzoNrsvLSaAfB
ESC sets a flag that makes the next key a Meta command, but arrows,
mouse events, Enter, Tab and control chords returned before the flag
was checked and left it set. A later plain key then ran as a Meta
command: ESC, Right, d deleted a word instead of inserting 'd'. The
flag is now consumed up front, and the Meta lookup runs before the
control-chord and Enter handling so ESC ^H (meta-backspace) and ESC
Enter (SmartNewline) reach their bindings.
Verified by driving kte in a pty: ESC, Right, d on 'hello world' now
gives 'hdello world' (was 'hworld').
Audit finding B3.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015rcVQiiWyTzoNrsvLSaAfB
- Regex replace-all, reflow and kill-region replaced or deleted whole
rows, which added or left a stray newline when the text ended on a
last line without one. They now edit the byte range in place.
- Regex replace-all no longer matches the empty position after a final
newline. Before, a zero-width pattern such as ^ matched it, added a
line, and matched the new empty last row again: the editor hung.
- Kill-line on the last row kills an unterminated line with the newline
before it, and does nothing on the empty row after a final newline
(it used to record an undo step that added a line).
- The visual-line newline records its splits as one undo step, and
places the cursor correctly for a repeat count.
- Buffer::delete_text computes its byte range directly instead of
materializing every line it spans.
Audit findings D3, D4, D5.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015rcVQiiWyTzoNrsvLSaAfB
- Replay recovers every complete record before a torn final record (the
normal result of a crash mid-append) and reports it through err,
instead of rejecting the whole journal. SwapReplay_TruncatedLog is
updated to the new behaviour.
- A record that cannot be written (open/write failure, breaker open)
marks the journal as having a gap. Later position-based records are
dropped until a checkpoint re-establishes the content; recorders
request one on the next edit (at most once per second).
- A failed append is truncated back off the file, and a failed header
write closes the file so the next attempt starts fresh.
- Inserts over the 24-bit record limit are split into consecutive
records; buffers too large to checkpoint are no longer snapshotted
and rejected on every checkpoint.
- Declining to delete an unreadable swap moves it to .corrupt so new
edits are not appended behind the bad data.
- NotifyFilenameChanged no longer writes a full checkpoint into the
journal it is about to delete.
Audit findings D7, P5.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015rcVQiiWyTzoNrsvLSaAfB
A secondary GUI window shared the primary's buffer vector but attached
buffers through its own SwapManager. Closing the window destroyed that
manager while buffers still held its recorders, so the next edit called
into freed memory; Rehome through the wrong manager also dropped
journaling for the primary's buffers.
Audit finding M3.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015rcVQiiWyTzoNrsvLSaAfB
The worker read the Buffer (PieceTable pieces and add buffer) while the
main thread edited it with no shared lock, and kept a raw Buffer* that
dangled once the buffer vector reallocated or shifted. PrefetchViewport
now only highlights the visible rows synchronously.
Audit finding M2.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015rcVQiiWyTzoNrsvLSaAfB
- SmartNewline recorded its indent insert after moving the cursor, so
undo deleted the wrong text; it is now recorded at the insertion
column and grouped with the newline. With a prompt open it now just
accepts the prompt (D1, B11).
- Scroll up/down, jump-to-mark and replace-all clamp the cursor to the
buffer, so edits are never made past end-of-line or end-of-buffer
(D2, M5).
- Replace-all is one undo step and no longer rebuilds Rows() per match
(D5, P4).
- Reflow on a blank line no longer merges adjacent paragraphs (D6).
- ensure_cursor_visible uses Nrows()/GetLineString instead of Rows() and
GetLineView, avoiding a whole-file rebuild per command (P1, P2).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015rcVQiiWyTzoNrsvLSaAfB
set_escdelay is an ncurses function, not a macro, so the #ifdef guard
compiled it out and ESC waited the 1 s default. KEY_RESIZE was mapped
to Refresh, the C-g cancel handler, which cleared the mark and
cancelled open prompts (including swap recovery) on every resize.
Audit findings B1, B2.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015rcVQiiWyTzoNrsvLSaAfB
SetDirty(false) did not bump the version, so undo back to the saved
state (and reload, and replace_all_bytes) left the highlighter serving
spans for the old text. Reloading a file deleted on disk now also
clears undo history recorded against the old content.
Audit finding D9.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015rcVQiiWyTzoNrsvLSaAfB
Recursion depth equalled history length, so destroying or clearing a
buffer with a few hundred thousand undo steps overflowed the stack.
UndoSystem::clear() also freed only the first root branch, leaking the
root's siblings; it now frees the whole forest.
Audit finding M4.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015rcVQiiWyTzoNrsvLSaAfB
Copy/move assignment kept the destination's line index, so closing a
buffer (vector erase move-assigns later buffers down) left the shifted
buffer with its predecessor's line offsets and out-of-bounds line views.
Appends that merged into an existing piece also skipped invalidation.
Audit findings M1, B12.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015rcVQiiWyTzoNrsvLSaAfB