diff --git a/Buffer.cc b/Buffer.cc index fb60ba6..b68814f 100644 --- a/Buffer.cc +++ b/Buffer.cc @@ -972,10 +972,54 @@ Buffer::replace_all_bytes(const std::string_view bytes) if (!bytes.empty()) content_.Append(bytes.data(), bytes.size()); rows_cache_dirty_ = true; + if (undo_sys_) + undo_sys_->clear(); MarkContentChanged(); } +void +Buffer::insert_spans(int row, int col, const std::vector &spans) +{ + if (row < 0) + row = 0; + if (col < 0) + col = 0; + const std::size_t off = content_.LineColToByteOffset(static_cast(row), + static_cast(col)); + content_.InsertSpans(off, spans); + rows_cache_dirty_ = true; + edited_at_(row); + if (swap_rec_) { + // The journal needs the bytes as one record (see insert_row). + std::string text; + content_.VisitSpans(spans, [&](const char *d, std::size_t n) { + text.append(d, n); + }); + if (!text.empty()) + swap_rec_->OnInsert(row, col, text); + } +} + + +std::vector +Buffer::SpansAt(int row, int col, std::size_t len) const +{ + const std::size_t off = content_.LineColToByteOffset(static_cast(std::max(row, 0)), + static_cast(std::max(col, 0))); + return content_.SpansInRange(off, len); +} + + +std::vector +Buffer::TakeDeletedSpans(int row, int col, std::size_t len) +{ + const std::size_t off = content_.LineColToByteOffset(static_cast(std::max(row, 0)), + static_cast(std::max(col, 0))); + return content_.TakeDeletedSpans(off, len); +} + + // Undo system accessors UndoSystem * Buffer::Undo() diff --git a/Buffer.h b/Buffer.h index a5b2fe1..36b3ade 100644 --- a/Buffer.h +++ b/Buffer.h @@ -664,11 +664,42 @@ public: void delete_row(int row); + // Insert the bytes of storage spans (see TextSpan.h) at (row, col) + // without copying them into storage again. Used by undo for large texts. + void insert_spans(int row, int col, const std::vector &spans); + // Replace the entire buffer content with raw bytes. // Intended for crash recovery (swap replay) and test harnesses. - // This does not trigger swap or undo recording. + // This does not trigger swap or undo recording, and clears undo history + // (recorded against the previous content, which it also references). void replace_all_bytes(std::string_view bytes); + // ===== Span access for undo (see TextSpan.h) ===== + [[nodiscard]] std::vector SpansAt(int row, int col, std::size_t len) const; + + // Spans removed by the most recent deletion, if it was exactly `len` + // bytes at (row, col) (see PieceTable::TakeDeletedSpans). + [[nodiscard]] std::vector TakeDeletedSpans(int row, int col, std::size_t len); + + [[nodiscard]] bool SpansEqual(const std::vector &spans, std::string_view text) const + { + return content_.SpansEqual(spans, text); + } + + + template + void VisitSpans(const std::vector &spans, Fn &&fn) const + { + content_.VisitSpans(spans, std::forward(fn)); + } + + + // Total size of the content in bytes. + [[nodiscard]] std::size_t ContentBytes() const + { + return content_.Size(); + } + // Undo system accessors (created per-buffer) [[nodiscard]] UndoSystem *Undo(); diff --git a/CMakeLists.txt b/CMakeLists.txt index 9010a8f..36267d2 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -272,6 +272,7 @@ set(COMMON_HEADERS TestInputHandler.h TestRenderer.h TestFrontend.h + TextSpan.h UndoNode.h UndoTree.h UndoSystem.h diff --git a/PieceTable.cc b/PieceTable.cc index 1e923cf..87cf81a 100644 --- a/PieceTable.cc +++ b/PieceTable.cc @@ -59,6 +59,7 @@ PieceTable::operator=(const PieceTable &other) version_ = other.version_; range_cache_ = {}; find_cache_ = {}; + last_deleted_ = {}; InvalidateLineIndex(); return *this; } @@ -98,6 +99,8 @@ PieceTable::operator=(PieceTable &&other) noexcept version_ = other.version_; range_cache_ = {}; find_cache_ = {}; + last_deleted_ = {}; + other.last_deleted_ = {}; InvalidateLineIndex(); return *this; } @@ -204,8 +207,9 @@ PieceTable::Clear() line_index_.clear(); line_index_dirty_ = true; version_++; - range_cache_ = {}; - find_cache_ = {}; + range_cache_ = {}; + find_cache_ = {}; + last_deleted_ = {}; } @@ -437,6 +441,7 @@ PieceTable::Insert(std::size_t byte_offset, const char *text, std::size_t len) byte_offset = total_size_; } + last_deleted_ = {}; const std::size_t add_start = add_.size(); add_.append(text, len); @@ -510,6 +515,16 @@ PieceTable::Delete(std::size_t byte_offset, std::size_t len) len = total_size_ - byte_offset; } + // Remember what a large deletion removed, so undo can refer to the + // stored bytes instead of keeping its own copy (see TakeDeletedSpans). + last_deleted_ = {}; + if (len >= kCaptureDeletedMin) { + last_deleted_.spans = SpansInRange(byte_offset, len); + last_deleted_.offset = byte_offset; + last_deleted_.len = len; + last_deleted_.valid = true; + } + auto [idx, inner] = locate(byte_offset); std::size_t remaining = len; @@ -568,6 +583,104 @@ PieceTable::Delete(std::size_t byte_offset, std::size_t len) } +// ===== Span access ===== + +std::vector +PieceTable::SpansInRange(std::size_t byte_offset, std::size_t len) const +{ + std::vector out; + if (byte_offset >= total_size_ || len == 0) + return out; + len = std::min(len, total_size_ - byte_offset); + auto [idx, inner] = locate(byte_offset); + while (len > 0 && idx < pieces_.size()) { + const Piece &p = pieces_[idx]; + const std::size_t take = std::min(p.len - inner, len); + out.push_back(TextSpan{p.src == Source::Add, p.start + inner, take}); + len -= take; + inner = 0; + ++idx; + } + return out; +} + + +bool +PieceTable::SpansEqual(const std::vector &spans, std::string_view text) const +{ + std::size_t pos = 0; + bool equal = true; + VisitSpans(spans, [&](const char *data, std::size_t n) { + if (!equal || pos + n > text.size() || std::memcmp(data, text.data() + pos, n) != 0) + equal = false; + pos += n; + }); + return equal && pos == text.size(); +} + + +void +PieceTable::InsertSpans(std::size_t byte_offset, const std::vector &spans) +{ + if (byte_offset > total_size_) + byte_offset = total_size_; + std::vector ins; + std::size_t len = 0; + for (const TextSpan &sp: spans) { + const std::string &src = sp.add ? add_ : original_; + if (sp.len == 0 || sp.start + sp.len > src.size()) + continue; + ins.push_back(Piece{sp.add ? Source::Add : Source::Original, sp.start, sp.len}); + len += sp.len; + } + if (len == 0) + return; + + std::size_t at = pieces_.size(); + if (byte_offset < total_size_) { + auto [idx, inner] = locate(byte_offset); + if (inner > 0) { + // Split the piece containing the insertion point. + const Piece target = pieces_[idx]; + pieces_[idx].len = inner; + pieces_.insert(pieces_.begin() + static_cast(idx + 1), + Piece{target.src, target.start + inner, target.len - inner}); + at = idx + 1; + } else { + at = idx; + } + } + pieces_.insert(pieces_.begin() + static_cast(at), ins.begin(), ins.end()); + total_size_ += len; + dirty_ = true; + std::size_t off = byte_offset; + for (const Piece &p: ins) { + const std::string &src = p.src == Source::Add ? add_ : original_; + lineIndexOnInsert(off, src.data() + p.start, p.len); + off += p.len; + } + coalesceNeighbors(std::min(at + ins.size(), pieces_.size() - 1)); + if (at > 0) + coalesceNeighbors(at - 1); + maybeConsolidate(); + version_++; + range_cache_ = {}; + find_cache_ = {}; + last_deleted_ = {}; +} + + +std::vector +PieceTable::TakeDeletedSpans(std::size_t byte_offset, std::size_t len) +{ + std::vector out; + if (last_deleted_.valid && last_deleted_.offset == byte_offset && last_deleted_.len == len) + out = std::move(last_deleted_.spans); + last_deleted_ = {}; + return out; +} + + // ===== Consolidation implementation ===== void diff --git a/PieceTable.h b/PieceTable.h index a77b688..4224f33 100644 --- a/PieceTable.h +++ b/PieceTable.h @@ -43,6 +43,9 @@ #include #include #include +#include + +#include "TextSpan.h" class PieceTable { @@ -139,6 +142,36 @@ public: // Stream out content without materializing the entire buffer void WriteToStream(std::ostream &out) const; + // ===== Span access (see TextSpan.h) ===== + // The storage spans backing [byte_offset, byte_offset + len). + [[nodiscard]] std::vector SpansInRange(std::size_t byte_offset, std::size_t len) const; + + // Call fn(const char *data, std::size_t len) for each span's bytes, in order. + template + void VisitSpans(const std::vector &spans, Fn &&fn) const + { + for (const TextSpan &sp: spans) { + const std::string &src = sp.add ? add_ : original_; + if (sp.len > 0 && sp.start + sp.len <= src.size()) + fn(src.data() + sp.start, sp.len); + } + } + + + // Whether the spans hold exactly `text`. + [[nodiscard]] bool SpansEqual(const std::vector &spans, std::string_view text) const; + + // Insert the spans' bytes at byte_offset without copying them into + // storage again (the new pieces reference the existing bytes). + void InsertSpans(std::size_t byte_offset, const std::vector &spans); + + // Deletions of at least kCaptureDeletedMin bytes remember the spans they + // removed; this returns them if the most recent deletion was exactly + // [byte_offset, byte_offset + len), else nothing. Consumes the capture. + static constexpr std::size_t kCaptureDeletedMin = 4096; + + [[nodiscard]] std::vector TakeDeletedSpans(std::size_t byte_offset, std::size_t len); + // Heuristic configuration void SetConsolidationParams(std::size_t piece_limit, std::size_t small_piece_threshold, @@ -220,6 +253,13 @@ private: std::size_t result = std::numeric_limits::max(); }; + struct DeletedCapture { + bool valid = false; + std::size_t offset = 0; + std::size_t len = 0; + std::vector spans; + } last_deleted_; + mutable RangeCache range_cache_; mutable FindCache find_cache_; diff --git a/TextSpan.h b/TextSpan.h new file mode 100644 index 0000000..cd6e824 --- /dev/null +++ b/TextSpan.h @@ -0,0 +1,16 @@ +/* + * TextSpan.h - a reference to bytes held in a PieceTable's storage. + * + * PieceTable storage is append-only (edits add pieces; nothing rewrites + * stored bytes), so a span stays valid for the life of the table's storage. + * Undo records large texts as spans instead of copies of the bytes. + */ +#pragma once +#include + + +struct TextSpan { + bool add{false}; // false: original storage, true: add storage + std::size_t start{0}; + std::size_t len{0}; +}; diff --git a/UndoNode.h b/UndoNode.h index c0adc2d..1aa18eb 100644 --- a/UndoNode.h +++ b/UndoNode.h @@ -1,6 +1,9 @@ #pragma once #include #include +#include + +#include "TextSpan.h" enum class UndoType : std::uint8_t { @@ -24,7 +27,32 @@ struct UndoNode { int col{}; std::uint64_t group_id = 0; // 0 means ungrouped; non-zero means undo/redo as an atomic group std::string text; + // A large Insert/Paste/Delete text is held as references to the buffer's + // storage instead (text is then empty and span_len is its length): undo + // of a replace-all over a 100 MB file otherwise kept 200 MB of copies. + std::vector spans; + std::size_t span_len = 0; UndoNode *parent = nullptr; // previous state; null means pre-first-edit UndoNode *child = nullptr; // next in current timeline UndoNode *next = nullptr; // redo branch + + + [[nodiscard]] bool HasSpans() const + { + return !spans.empty(); + } + + + // Length of the node's text, however it is held. + [[nodiscard]] std::size_t TextSize() const + { + return HasSpans() ? span_len : text.size(); + } + + + // Approximate heap and object bytes held by this node. + [[nodiscard]] std::size_t CostBytes() const + { + return sizeof(UndoNode) + text.capacity() + spans.capacity() * sizeof(TextSpan); + } }; \ No newline at end of file diff --git a/UndoSystem.cc b/UndoSystem.cc index 389cbd0..2c8eae4 100644 --- a/UndoSystem.cc +++ b/UndoSystem.cc @@ -1,5 +1,6 @@ #include "UndoSystem.h" #include "Buffer.h" +#include #include #include #include @@ -29,8 +30,10 @@ UndoSystem::EndGroup() commit(); if (group_depth_ > 0) --group_depth_; - if (group_depth_ == 0) + if (group_depth_ == 0) { active_group_id_ = 0; + maybe_prune(); + } } @@ -150,6 +153,9 @@ UndoSystem::commit() return; } + try_convert_to_spans(tree_.pending); + bytes_ += tree_.pending->CostBytes(); + if (!tree_.root) { tree_.root = tree_.pending; tree_.pending->parent = nullptr; @@ -177,6 +183,8 @@ UndoSystem::commit() tree_.pending = nullptr; pending_mode_ = PendingAppendMode::Append; update_dirty_flag(); + if (group_depth_ == 0) + maybe_prune(); } @@ -291,6 +299,7 @@ UndoSystem::clear() tree_.current = nullptr; tree_.saved = nullptr; base_unsaved_ = false; + bytes_ = 0; active_group_id_ = 0; group_depth_ = 0; next_group_id_ = 1; @@ -298,24 +307,40 @@ UndoSystem::clear() } -// Position just after `node->text` inserted at (node->col, node->row). The text -// may span lines: the column is then the length after its last newline, not -// col + size (which left the cursor past the end of the line, so later edits -// and their undo records went to the wrong place). +// Position just after the node's text inserted at (node->col, node->row). +// The text may span lines: the column is then the length after its last +// newline, not col + size (which left the cursor past the end of the line, +// so later edits and their undo records went to the wrong place). static void -end_of_text(const UndoNode *node, std::size_t &x, std::size_t &y) +end_of_text(const Buffer &buf, const UndoNode *node, std::size_t &x, std::size_t &y) { - y = static_cast(node->row); - x = static_cast(node->col); - const auto nl = node->text.rfind('\n'); - if (nl == std::string::npos) { - x += node->text.size(); - return; - } - for (char c: node->text) - if (c == '\n') - ++y; - x = node->text.size() - nl - 1; + y = static_cast(node->row); + x = static_cast(node->col); + auto scan = [&](const char *d, std::size_t n) { + for (std::size_t i = 0; i < n; ++i) { + if (d[i] == '\n') { + ++y; + x = 0; + } else { + ++x; + } + } + }; + if (node->HasSpans()) + buf.VisitSpans(node->spans, scan); + else + scan(node->text.data(), node->text.size()); +} + + +// Insert the node's text at its position (from spans without copying). +static void +insert_node_text(Buffer &buf, const UndoNode *node) +{ + if (node->HasSpans()) + buf.insert_spans(node->row, node->col, node->spans); + else + buf.insert_text(node->row, node->col, node->text); } @@ -331,21 +356,21 @@ UndoSystem::apply(const UndoNode *node, int direction) case UndoType::Insert: case UndoType::Paste: if (direction > 0) { - buf_->insert_text(node->row, node->col, node->text); - end_of_text(node, ex, ey); + insert_node_text(*buf_, node); + end_of_text(*buf_, node, ex, ey); buf_->SetCursor(ex, ey); } else { - buf_->delete_text(node->row, node->col, node->text.size()); + buf_->delete_text(node->row, node->col, node->TextSize()); buf_->SetCursor(static_cast(node->col), static_cast(node->row)); } break; case UndoType::Delete: if (direction > 0) { - buf_->delete_text(node->row, node->col, node->text.size()); + buf_->delete_text(node->row, node->col, node->TextSize()); buf_->SetCursor(static_cast(node->col), static_cast(node->row)); } else { - buf_->insert_text(node->row, node->col, node->text); - end_of_text(node, ex, ey); + insert_node_text(*buf_, node); + end_of_text(*buf_, node, ex, ey); buf_->SetCursor(ex, ey); } break; @@ -446,6 +471,185 @@ UndoSystem::find_parent(UndoNode *from, UndoNode *target) } +// Texts at least this long are held as storage spans when possible. +static constexpr std::size_t kSpanMinBytes = PieceTable::kCaptureDeletedMin; + + +void +UndoSystem::try_convert_to_spans(UndoNode *node) +{ + if (!buf_ || !node || node->text.size() < kSpanMinBytes) + return; + std::vector spans; + switch (node->type) { + case UndoType::Insert: + case UndoType::Paste: + // The inserted text is in the buffer at the node's position. + spans = buf_->SpansAt(node->row, node->col, node->text.size()); + break; + case UndoType::Delete: + // The deleted text is still in storage; the deletion remembered where. + spans = buf_->TakeDeletedSpans(node->row, node->col, node->text.size()); + break; + default: + return; + } + // Only when the spans hold exactly the recorded text; otherwise (the + // text is not where the node says) keep the copy. + if (spans.empty() || !buf_->SpansEqual(spans, node->text)) + return; + node->span_len = node->text.size(); + node->spans = std::move(spans); + node->spans.shrink_to_fit(); + std::string().swap(node->text); +} + + +void +UndoSystem::maybe_prune() +{ + std::size_t budget = budget_; + if (budget == 0) { + const std::size_t content = buf_ ? buf_->ContentBytes() : 0; + budget = std::max(std::size_t{64} << 20, content * 2); + } + if (bytes_ <= budget) + return; + // Prune well below the budget so this does not run on every commit. + prune_to(budget - budget / 4); +} + + +void +UndoSystem::forget_saved_state() +{ + tree_.saved = nullptr; + base_unsaved_ = true; +} + + +// Total bytes held by a node, its children and its siblings. +static std::size_t +forest_bytes(const UndoNode *first) +{ + std::size_t total = 0; + std::vector stack; + if (first) + stack.push_back(first); + while (!stack.empty()) { + const UndoNode *n = stack.back(); + stack.pop_back(); + total += n->CostBytes(); + if (n->child) + stack.push_back(n->child); + if (n->next) + stack.push_back(n->next); + } + return total; +} + + +static bool +forest_contains(const UndoNode *first, const UndoNode *target) +{ + if (!target) + return false; + std::vector stack; + if (first) + stack.push_back(first); + while (!stack.empty()) { + const UndoNode *n = stack.back(); + stack.pop_back(); + if (n == target) + return true; + if (n->child) + stack.push_back(n->child); + if (n->next) + stack.push_back(n->next); + } + return false; +} + + +void +UndoSystem::prune_to(const std::size_t target) +{ + // The line kept: root..current, then current's active redo line. + std::vector line; + for (UndoNode *n = tree_.current; n; n = n->parent) + line.push_back(n); + std::reverse(line.begin(), line.end()); + { + UndoNode *n = tree_.current ? tree_.current->child : tree_.root; + for (; n; n = n->child) + line.push_back(n); + } + if (line.empty()) + return; + + // Detach and free the siblings of `keep` in the list starting at *head. + auto drop_siblings = [&](UndoNode **head, UndoNode *keep) { + UndoNode *n = *head; + *head = keep; + while (n) { + UndoNode *nx = n->next; + if (n != keep) { + n->next = nullptr; + if (forest_contains(n, tree_.saved)) + forget_saved_state(); + FreeUndoForest(n); + } + n = nx; + } + keep->next = nullptr; + }; + + // 1. Branches off the kept line: reachable only by choosing a redo branch. + drop_siblings(&tree_.root, line.front()); + for (std::size_t i = 0; i + 1 < line.size(); ++i) + drop_siblings(&line[i]->child, line[i + 1]); + bytes_ = forest_bytes(tree_.root); + + // 2. The oldest edits, while they are older than the current state. An + // undo group goes whole: undoing what remained of it would stop at a + // state inside the group, which the user never saw. + std::size_t i = 0; + bool mid_group = false; + while ((bytes_ > target || mid_group) && i < line.size() && line[i] != tree_.current && tree_.current) { + UndoNode *old = line[i]; + UndoNode *nx = old->child; + if (!nx) + break; + if (old->group_id != 0 && old->group_id == tree_.current->group_id) + break; // the current state's own group stays whole + // Dropping `old` loses the state before it. `old`'s own state + // becomes the first state in history. + if (tree_.saved == nullptr) + forget_saved_state(); + else if (tree_.saved == old) + tree_.saved = nullptr; + mid_group = old->group_id != 0 && nx->group_id == old->group_id; + bytes_ -= old->CostBytes(); + old->child = nullptr; + delete old; + nx->parent = nullptr; + tree_.root = nx; + ++i; + } + + // 3. The redo line beyond the current state, newest first. + if (bytes_ > target && tree_.current && tree_.current->child) { + UndoNode *future = tree_.current->child; + if (forest_contains(future, tree_.saved)) + forget_saved_state(); + tree_.current->child = nullptr; + bytes_ -= forest_bytes(future); + FreeUndoForest(future); + } + update_dirty_flag(); +} + + void UndoSystem::update_dirty_flag() { diff --git a/UndoSystem.h b/UndoSystem.h index b4d827d..b6d6efb 100644 --- a/UndoSystem.h +++ b/UndoSystem.h @@ -90,6 +90,21 @@ public: void UpdateBufferReference(Buffer &new_buf); + // History memory budget in bytes (node texts, spans and nodes). When + // committed history exceeds it, branches off the current path and then + // the oldest edits are dropped. 0 (the default) means max(64 MiB, twice + // the buffer's size). + void SetByteBudget(std::size_t bytes) + { + budget_ = bytes; + } + + + [[nodiscard]] std::size_t HistoryBytes() const + { + return bytes_; + } + #if defined(KTE_TESTS) // Test-only introspection hook. const UndoTree &TreeForTests() const @@ -119,6 +134,21 @@ private: void update_dirty_flag(); + // Hold a large committed text as storage spans (see UndoNode::spans). + void try_convert_to_spans(UndoNode *node); + + // Enforce the byte budget (outside groups). + void maybe_prune(); + + void prune_to(std::size_t target); + + // The saved state's node is being dropped: no state in history matches + // the file any more. + void forget_saved_state(); + + std::size_t bytes_ = 0; // approximate bytes held by committed history + std::size_t budget_ = 0; + PendingAppendMode pending_mode_ = PendingAppendMode::Append; std::uint64_t active_group_id_ = 0; diff --git a/docs/audits/claude-findings-20260923.md b/docs/audits/claude-findings-20260923.md index 8161e31..aadbcdf 100644 --- a/docs/audits/claude-findings-20260923.md +++ b/docs/audits/claude-findings-20260923.md @@ -499,6 +499,20 @@ before the fix (unless noted). agreed. Redo-all after heavy branching can end on an older branch of the undo tree (a state that did exist), as before. +**Follow-up: undo memory** +- Undo texts of 4 KiB or more are held as references to the piece + table's (append-only) storage instead of copies, verified byte for + byte when converted; undo/redo re-splice the stored bytes instead of + copying them into storage again. A replace-all session on a 26 MB file + ends at 181 MB instead of 332 MB; undo/redo cycles of a 6.6 MB kill + stay at 75 MB instead of growing by 6.6 MB each. +- History has a byte budget (default max(64 MiB, twice the buffer)): + over it, branches off the current path, then the oldest edits (whole + undo groups), then the redo line are dropped. If the saved state goes, + the buffer stays modified until the next save. +- replace_all_bytes clears undo history (it was recorded against, and + may reference, the previous content). + **Known limitations (not fixed)** - Catastrophic regex backtracking (e.g. `(a*)*b`) can still take very long; std::regex has no time limit. @@ -511,9 +525,7 @@ before the fix (unless noted). the file before the first one edits it is not warned at open; the session that is locked out is told at its first edit instead. - Regex search runs std::regex over the whole buffer per keystroke - (about 0.4 s on 26 MB); replace-all on a file with matches spread - throughout records nearly the whole file in the undo tree, which has - no size limit. + (about 0.4 s on 26 MB). - A journal whose buffer exceeds 16 MiB cannot be checkpointed; after a lost record such a journal stays incomplete until the file is saved (reported to the user). diff --git a/tests/test_audit_regressions.cc b/tests/test_audit_regressions.cc index 80f95c1..fc89610 100644 --- a/tests/test_audit_regressions.cc +++ b/tests/test_audit_regressions.cc @@ -21,6 +21,7 @@ #include #include #include +#include #include #include #include @@ -1730,3 +1731,141 @@ TEST(Audit_RowRangeEdits_Scale) ASSERT_EQ(h.Text(), text); ASSERT_TRUE(secs < 20.0); } + + +// Large undo texts are held as references to the buffer's storage: a +// replace-all over a large buffer keeps almost nothing in history, and undo, +// redo and the journal still reproduce the text. +TEST(Undo_LargeTextsHeldAsSpans) +{ + TempDir d("undo_spans"); + const std::string path = (d.path / "big.txt").string(); + std::string text; + for (int i = 0; i < 20000; ++i) + text += "alpha " + std::to_string(i) + " beta\n"; + { + std::ofstream o(path, std::ios::binary); + o << text; + } + std::remove(kte::SwapManager::ComputeSwapPathForFilename(path).c_str()); + TestHarness h; + std::string err; + ASSERT_TRUE(h.EditorRef().OpenFile(path, err)); + Buffer &b = h.Buf(); + regex_replace_all(h, "alpha", "gamma"); + std::string after = text; + for (std::size_t p = after.find("alpha"); p != std::string::npos; p = after.find("alpha", p + 5)) + after.replace(p, 5, "gamma"); + ASSERT_EQ(h.Text(), after); + // Two nodes (delete + insert) of ~400 KB each, held as spans. + ASSERT_TRUE(b.Undo()->HistoryBytes() < 16384); + const UndoNode *ins = b.Undo()->TreeForTests().current; + ASSERT_TRUE(ins && ins->HasSpans()); + ASSERT_TRUE(ins->parent && ins->parent->HasSpans()); + + // Kill a large region (a Delete node held as spans) and yank it back. + b.SetCursor(0, 100); + ASSERT_TRUE(h.Exec(CommandId::ToggleMark)); + b.SetCursor(0, 5000); + ASSERT_TRUE(h.Exec(CommandId::KillRegion)); + const std::string killed = h.Text(); + ASSERT_TRUE(b.Undo()->HistoryBytes() < 32768); + + for (int i = 0; i < 3; ++i) { + ASSERT_TRUE(h.Undo()); + ASSERT_TRUE(h.Undo()); + ASSERT_EQ(h.Text(), text); + ASSERT_TRUE(h.Redo()); + ASSERT_EQ(h.Text(), after); + ASSERT_TRUE(h.Redo()); + ASSERT_EQ(h.Text(), killed); + } + // The journal (fed from spans on undo/redo) replays to the same text. + h.EditorRef().Swap()->Flush(&b); + const std::string copy = (d.path / "crash.swp").string(); + { + std::ofstream o(copy, std::ios::binary | std::ios::trunc); + o << slurp(kte::SwapManager::ComputeSwapPathForTests(b)); + } + Buffer b2; + ASSERT_TRUE(b2.OpenFromFile(path, err)); + ASSERT_TRUE(kte::SwapManager::ReplayFile(b2, copy, err)); + ASSERT_EQ(b2.BytesForTests(), killed); + b.SetDirty(false); +} + + +// Undo history has a byte budget: over it, branches off the current path and +// then the oldest edits go. History never lands on a state that did not +// exist, and once the saved state is gone the buffer stays modified. +TEST(Undo_ByteBudgetPrunesOldestAndBranches) +{ + TestHarness h; + Buffer &b = h.Buf(); + UndoSystem *u = b.Undo(); + u->SetByteBudget(64 * 1024); + b.insert_text(0, 0, "base\n"); + u->mark_saved(); + b.SetDirty(false); + + // A branch: an edit, undone, then a different edit. + ASSERT_TRUE(h.Exec(CommandId::InsertText, std::string(2000, 'b'))); + ASSERT_TRUE(h.Exec(CommandId::MoveLeft)); + ASSERT_TRUE(h.Undo()); + std::set seen{h.Text()}; + // Many distinct edits, each its own node with ~1 KB of text. + for (int i = 0; i < 400; ++i) { + ASSERT_TRUE(h.Exec(CommandId::InsertText, std::string(1000, static_cast('a' + i % 26)))); + seen.insert(h.Text()); + ASSERT_TRUE(h.Exec(CommandId::Newline)); + seen.insert(h.Text()); + ASSERT_TRUE(u->HistoryBytes() <= 64 * 1024); + } + const std::string tip = h.Text(); + // Undo everything that is left: the oldest edits (and the branch) are + // gone, so this does not reach the saved text, and the buffer knows it. + for (int i = 0; i < 2000; ++i) + (void) Execute(h.EditorRef(), CommandId::Undo); + ASSERT_TRUE(h.Text() != std::string("base\n")); + ASSERT_TRUE(seen.count(h.Text()) == 1); + ASSERT_TRUE(b.Dirty()); + for (int i = 0; i < 2000; ++i) + (void) Execute(h.EditorRef(), CommandId::Redo); + ASSERT_EQ(h.Text(), tip); + ASSERT_TRUE(b.Dirty()); + u->mark_saved(); + ASSERT_TRUE(!b.Dirty()); +} + + +// Pruning drops an undo group whole. +TEST(Undo_ByteBudgetKeepsGroupsWhole) +{ + // The replace's delete node (~1.3 KB) is large next to the gap between + // budget and prune target, its insert node small: across filler sizes, + // pruning would stop between the two unless groups are kept whole. + std::string base; + for (int i = 0; i < 300; ++i) + base += "one "; + base += "\n"; + for (int fill = 40; fill < 400; fill += 9) { + TestHarness h; + Buffer &b = h.Buf(); + b.Undo()->SetByteBudget(4096); + b.insert_text(0, 0, base); + std::set seen{h.Text()}; + regex_replace_all(h, "one ", "x"); // a group: delete + insert + seen.insert(h.Text()); + for (int i = 0; i < 12; ++i) { + ASSERT_TRUE(h.Exec(CommandId::InsertText, std::string(static_cast(fill), 'z'))); + seen.insert(h.Text()); + ASSERT_TRUE(h.Exec(CommandId::Newline)); + seen.insert(h.Text()); + } + // Undo everything left, checking every state on the way. + for (int i = 0; i < 100; ++i) { + (void) Execute(h.EditorRef(), CommandId::Undo); + ASSERT_TRUE(seen.count(h.Text()) == 1); + } + } +}