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
+227 -23
View File
@@ -1,5 +1,6 @@
#include "UndoSystem.h"
#include "Buffer.h"
#include <algorithm>
#include <cassert>
#include <cstdio>
#include <vector>
@@ -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<std::size_t>(node->row);
x = static_cast<std::size_t>(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<std::size_t>(node->row);
x = static_cast<std::size_t>(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<std::size_t>(node->col), static_cast<std::size_t>(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<std::size_t>(node->col), static_cast<std::size_t>(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<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
UndoSystem::update_dirty_flag()
{