Undo: hold large texts as storage spans; byte budget with pruning

- Undo texts of 4 KiB or more (Insert/Paste from the buffer, Delete from
  the spans the piece table remembers for its last large deletion) are
  held as TextSpan references into the piece table's append-only
  storage, after checking they hold exactly the recorded bytes. Undo and
  redo insert them with Buffer::insert_spans, which adds pieces over the
  stored bytes instead of copying them into storage again (the journal
  still gets the bytes, as one record). A replace-all session on a 26 MB
  file peaks at 181 MB instead of 332 MB; undo/redo cycles of a 6.6 MB
  kill no longer grow memory by 6.6 MB each.
- UndoSystem keeps a byte budget (default max(64 MiB, twice the buffer
  size); SetByteBudget). Over it, history is pruned to 75% of the budget:
  branches off the current path, then the oldest edits (an undo group
  goes whole, and never the current state's group), then the redo line.
  When the saved state is dropped the buffer stays modified until saved.
- replace_all_bytes clears undo history, which was recorded against (and
  may reference the storage of) the previous content.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015rcVQiiWyTzoNrsvLSaAfB
This commit is contained in:
Claude
2026-09-24 09:16:04 -07:00
committed by Kyle Isom
parent 7165bb286b
commit 404b983009
11 changed files with 687 additions and 29 deletions
+44
View File
@@ -972,10 +972,54 @@ Buffer::replace_all_bytes(const std::string_view bytes)
if (!bytes.empty()) if (!bytes.empty())
content_.Append(bytes.data(), bytes.size()); content_.Append(bytes.data(), bytes.size());
rows_cache_dirty_ = true; rows_cache_dirty_ = true;
if (undo_sys_)
undo_sys_->clear();
MarkContentChanged(); MarkContentChanged();
} }
void
Buffer::insert_spans(int row, int col, const std::vector<TextSpan> &spans)
{
if (row < 0)
row = 0;
if (col < 0)
col = 0;
const std::size_t off = content_.LineColToByteOffset(static_cast<std::size_t>(row),
static_cast<std::size_t>(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<TextSpan>
Buffer::SpansAt(int row, int col, std::size_t len) const
{
const std::size_t off = content_.LineColToByteOffset(static_cast<std::size_t>(std::max(row, 0)),
static_cast<std::size_t>(std::max(col, 0)));
return content_.SpansInRange(off, len);
}
std::vector<TextSpan>
Buffer::TakeDeletedSpans(int row, int col, std::size_t len)
{
const std::size_t off = content_.LineColToByteOffset(static_cast<std::size_t>(std::max(row, 0)),
static_cast<std::size_t>(std::max(col, 0)));
return content_.TakeDeletedSpans(off, len);
}
// Undo system accessors // Undo system accessors
UndoSystem * UndoSystem *
Buffer::Undo() Buffer::Undo()
+32 -1
View File
@@ -664,11 +664,42 @@ public:
void delete_row(int row); 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<TextSpan> &spans);
// Replace the entire buffer content with raw bytes. // Replace the entire buffer content with raw bytes.
// Intended for crash recovery (swap replay) and test harnesses. // 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); void replace_all_bytes(std::string_view bytes);
// ===== Span access for undo (see TextSpan.h) =====
[[nodiscard]] std::vector<TextSpan> 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<TextSpan> TakeDeletedSpans(int row, int col, std::size_t len);
[[nodiscard]] bool SpansEqual(const std::vector<TextSpan> &spans, std::string_view text) const
{
return content_.SpansEqual(spans, text);
}
template<typename Fn>
void VisitSpans(const std::vector<TextSpan> &spans, Fn &&fn) const
{
content_.VisitSpans(spans, std::forward<Fn>(fn));
}
// Total size of the content in bytes.
[[nodiscard]] std::size_t ContentBytes() const
{
return content_.Size();
}
// Undo system accessors (created per-buffer) // Undo system accessors (created per-buffer)
[[nodiscard]] UndoSystem *Undo(); [[nodiscard]] UndoSystem *Undo();
+1
View File
@@ -272,6 +272,7 @@ set(COMMON_HEADERS
TestInputHandler.h TestInputHandler.h
TestRenderer.h TestRenderer.h
TestFrontend.h TestFrontend.h
TextSpan.h
UndoNode.h UndoNode.h
UndoTree.h UndoTree.h
UndoSystem.h UndoSystem.h
+113
View File
@@ -59,6 +59,7 @@ PieceTable::operator=(const PieceTable &other)
version_ = other.version_; version_ = other.version_;
range_cache_ = {}; range_cache_ = {};
find_cache_ = {}; find_cache_ = {};
last_deleted_ = {};
InvalidateLineIndex(); InvalidateLineIndex();
return *this; return *this;
} }
@@ -98,6 +99,8 @@ PieceTable::operator=(PieceTable &&other) noexcept
version_ = other.version_; version_ = other.version_;
range_cache_ = {}; range_cache_ = {};
find_cache_ = {}; find_cache_ = {};
last_deleted_ = {};
other.last_deleted_ = {};
InvalidateLineIndex(); InvalidateLineIndex();
return *this; return *this;
} }
@@ -206,6 +209,7 @@ PieceTable::Clear()
version_++; version_++;
range_cache_ = {}; range_cache_ = {};
find_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_; byte_offset = total_size_;
} }
last_deleted_ = {};
const std::size_t add_start = add_.size(); const std::size_t add_start = add_.size();
add_.append(text, len); add_.append(text, len);
@@ -510,6 +515,16 @@ PieceTable::Delete(std::size_t byte_offset, std::size_t len)
len = total_size_ - byte_offset; 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); auto [idx, inner] = locate(byte_offset);
std::size_t remaining = len; std::size_t remaining = len;
@@ -568,6 +583,104 @@ PieceTable::Delete(std::size_t byte_offset, std::size_t len)
} }
// ===== Span access =====
std::vector<TextSpan>
PieceTable::SpansInRange(std::size_t byte_offset, std::size_t len) const
{
std::vector<TextSpan> 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<TextSpan> &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<TextSpan> &spans)
{
if (byte_offset > total_size_)
byte_offset = total_size_;
std::vector<Piece> 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<std::ptrdiff_t>(idx + 1),
Piece{target.src, target.start + inner, target.len - inner});
at = idx + 1;
} else {
at = idx;
}
}
pieces_.insert(pieces_.begin() + static_cast<std::ptrdiff_t>(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<TextSpan>
PieceTable::TakeDeletedSpans(std::size_t byte_offset, std::size_t len)
{
std::vector<TextSpan> 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 ===== // ===== Consolidation implementation =====
void void
+40
View File
@@ -43,6 +43,9 @@
#include <vector> #include <vector>
#include <limits> #include <limits>
#include <mutex> #include <mutex>
#include <string_view>
#include "TextSpan.h"
class PieceTable { class PieceTable {
@@ -139,6 +142,36 @@ public:
// Stream out content without materializing the entire buffer // Stream out content without materializing the entire buffer
void WriteToStream(std::ostream &out) const; void WriteToStream(std::ostream &out) const;
// ===== Span access (see TextSpan.h) =====
// The storage spans backing [byte_offset, byte_offset + len).
[[nodiscard]] std::vector<TextSpan> 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<typename Fn>
void VisitSpans(const std::vector<TextSpan> &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<TextSpan> &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<TextSpan> &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<TextSpan> TakeDeletedSpans(std::size_t byte_offset, std::size_t len);
// Heuristic configuration // Heuristic configuration
void SetConsolidationParams(std::size_t piece_limit, void SetConsolidationParams(std::size_t piece_limit,
std::size_t small_piece_threshold, std::size_t small_piece_threshold,
@@ -220,6 +253,13 @@ private:
std::size_t result = std::numeric_limits<std::size_t>::max(); std::size_t result = std::numeric_limits<std::size_t>::max();
}; };
struct DeletedCapture {
bool valid = false;
std::size_t offset = 0;
std::size_t len = 0;
std::vector<TextSpan> spans;
} last_deleted_;
mutable RangeCache range_cache_; mutable RangeCache range_cache_;
mutable FindCache find_cache_; mutable FindCache find_cache_;
+16
View File
@@ -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 <cstddef>
struct TextSpan {
bool add{false}; // false: original storage, true: add storage
std::size_t start{0};
std::size_t len{0};
};
+28
View File
@@ -1,6 +1,9 @@
#pragma once #pragma once
#include <cstdint> #include <cstdint>
#include <string> #include <string>
#include <vector>
#include "TextSpan.h"
enum class UndoType : std::uint8_t { enum class UndoType : std::uint8_t {
@@ -24,7 +27,32 @@ struct UndoNode {
int col{}; int col{};
std::uint64_t group_id = 0; // 0 means ungrouped; non-zero means undo/redo as an atomic group std::uint64_t group_id = 0; // 0 means ungrouped; non-zero means undo/redo as an atomic group
std::string text; 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<TextSpan> spans;
std::size_t span_len = 0;
UndoNode *parent = nullptr; // previous state; null means pre-first-edit UndoNode *parent = nullptr; // previous state; null means pre-first-edit
UndoNode *child = nullptr; // next in current timeline UndoNode *child = nullptr; // next in current timeline
UndoNode *next = nullptr; // redo branch 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);
}
}; };
+224 -20
View File
@@ -1,5 +1,6 @@
#include "UndoSystem.h" #include "UndoSystem.h"
#include "Buffer.h" #include "Buffer.h"
#include <algorithm>
#include <cassert> #include <cassert>
#include <cstdio> #include <cstdio>
#include <vector> #include <vector>
@@ -29,8 +30,10 @@ UndoSystem::EndGroup()
commit(); commit();
if (group_depth_ > 0) if (group_depth_ > 0)
--group_depth_; --group_depth_;
if (group_depth_ == 0) if (group_depth_ == 0) {
active_group_id_ = 0; active_group_id_ = 0;
maybe_prune();
}
} }
@@ -150,6 +153,9 @@ UndoSystem::commit()
return; return;
} }
try_convert_to_spans(tree_.pending);
bytes_ += tree_.pending->CostBytes();
if (!tree_.root) { if (!tree_.root) {
tree_.root = tree_.pending; tree_.root = tree_.pending;
tree_.pending->parent = nullptr; tree_.pending->parent = nullptr;
@@ -177,6 +183,8 @@ UndoSystem::commit()
tree_.pending = nullptr; tree_.pending = nullptr;
pending_mode_ = PendingAppendMode::Append; pending_mode_ = PendingAppendMode::Append;
update_dirty_flag(); update_dirty_flag();
if (group_depth_ == 0)
maybe_prune();
} }
@@ -291,6 +299,7 @@ UndoSystem::clear()
tree_.current = nullptr; tree_.current = nullptr;
tree_.saved = nullptr; tree_.saved = nullptr;
base_unsaved_ = false; base_unsaved_ = false;
bytes_ = 0;
active_group_id_ = 0; active_group_id_ = 0;
group_depth_ = 0; group_depth_ = 0;
next_group_id_ = 1; next_group_id_ = 1;
@@ -298,24 +307,40 @@ UndoSystem::clear()
} }
// Position just after `node->text` inserted at (node->col, node->row). The text // Position just after the node's text inserted at (node->col, node->row).
// may span lines: the column is then the length after its last newline, not // The text may span lines: the column is then the length after its last
// col + size (which left the cursor past the end of the line, so later edits // newline, not col + size (which left the cursor past the end of the line,
// and their undo records went to the wrong place). // so later edits and their undo records went to the wrong place).
static void 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<std::size_t>(node->row); y = static_cast<std::size_t>(node->row);
x = static_cast<std::size_t>(node->col); x = static_cast<std::size_t>(node->col);
const auto nl = node->text.rfind('\n'); auto scan = [&](const char *d, std::size_t n) {
if (nl == std::string::npos) { for (std::size_t i = 0; i < n; ++i) {
x += node->text.size(); if (d[i] == '\n') {
return;
}
for (char c: node->text)
if (c == '\n')
++y; ++y;
x = node->text.size() - nl - 1; 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::Insert:
case UndoType::Paste: case UndoType::Paste:
if (direction > 0) { if (direction > 0) {
buf_->insert_text(node->row, node->col, node->text); insert_node_text(*buf_, node);
end_of_text(node, ex, ey); end_of_text(*buf_, node, ex, ey);
buf_->SetCursor(ex, ey); buf_->SetCursor(ex, ey);
} else { } else {
buf_->delete_text(node->row, node->col, node->text.size()); buf_->delete_text(node->row, node->col, node->TextSize());
buf_->SetCursor(static_cast<std::size_t>(node->col), static_cast<std::size_t>(node->row)); buf_->SetCursor(static_cast<std::size_t>(node->col), static_cast<std::size_t>(node->row));
} }
break; break;
case UndoType::Delete: case UndoType::Delete:
if (direction > 0) { 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<std::size_t>(node->col), static_cast<std::size_t>(node->row)); buf_->SetCursor(static_cast<std::size_t>(node->col), static_cast<std::size_t>(node->row));
} else { } else {
buf_->insert_text(node->row, node->col, node->text); insert_node_text(*buf_, node);
end_of_text(node, ex, ey); end_of_text(*buf_, node, ex, ey);
buf_->SetCursor(ex, ey); buf_->SetCursor(ex, ey);
} }
break; 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<TextSpan> 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>(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<const UndoNode *> 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<const UndoNode *> 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<UndoNode *> 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 void
UndoSystem::update_dirty_flag() UndoSystem::update_dirty_flag()
{ {
+30
View File
@@ -90,6 +90,21 @@ public:
void UpdateBufferReference(Buffer &new_buf); 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) #if defined(KTE_TESTS)
// Test-only introspection hook. // Test-only introspection hook.
const UndoTree &TreeForTests() const const UndoTree &TreeForTests() const
@@ -119,6 +134,21 @@ private:
void update_dirty_flag(); 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; PendingAppendMode pending_mode_ = PendingAppendMode::Append;
std::uint64_t active_group_id_ = 0; std::uint64_t active_group_id_ = 0;
+15 -3
View File
@@ -499,6 +499,20 @@ before the fix (unless noted).
agreed. Redo-all after heavy branching can end on an older branch of agreed. Redo-all after heavy branching can end on an older branch of
the undo tree (a state that did exist), as before. 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)** **Known limitations (not fixed)**
- Catastrophic regex backtracking (e.g. `(a*)*b`) can still take very - Catastrophic regex backtracking (e.g. `(a*)*b`) can still take very
long; std::regex has no time limit. 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 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. session that is locked out is told at its first edit instead.
- Regex search runs std::regex over the whole buffer per keystroke - 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 (about 0.4 s on 26 MB).
throughout records nearly the whole file in the undo tree, which has
no size limit.
- A journal whose buffer exceeds 16 MiB cannot be checkpointed; after a - A journal whose buffer exceeds 16 MiB cannot be checkpointed; after a
lost record such a journal stays incomplete until the file is saved lost record such a journal stays incomplete until the file is saved
(reported to the user). (reported to the user).
+139
View File
@@ -21,6 +21,7 @@
#include <sys/stat.h> #include <sys/stat.h>
#include <unistd.h> #include <unistd.h>
#include <random> #include <random>
#include <set>
#include <vector> #include <vector>
#include <clocale> #include <clocale>
#include <memory> #include <memory>
@@ -1730,3 +1731,141 @@ TEST(Audit_RowRangeEdits_Scale)
ASSERT_EQ(h.Text(), text); ASSERT_EQ(h.Text(), text);
ASSERT_TRUE(secs < 20.0); 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<std::string> 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<char>('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<std::string> 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<std::size_t>(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);
}
}
}