- Redoing an insert and undoing a delete put the cursor at col + size on the start row even when the text spans lines, i.e. past the end of the line. Later edits and their undo records then went to the wrong place (a join recorded at column 2 of 'a' re-split the text wrongly) and delete-word-prev read past the line (heap-buffer-overflow under ASan). The cursor now goes after the text's last newline, and undo and redo clamp it to the buffer. - A recovered buffer stays modified in every undo state (mark_base_unsaved): one edit and its undo made it look clean, and closing it then discarded the recovered text and its journal. - Save-and-quit records the saved undo state. - Visual-line selections are clamped to the buffer when read; the visual newline counts the cursor's own line only if it was selected. A stale selection put the cursor rows past the end, where typed text could not be undone. - The yank size cap accounts for visual-line yanks (once per line). - After an exception escapes a command, open undo groups are closed so later edits do not join them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015rcVQiiWyTzoNrsvLSaAfB
528 lines
12 KiB
C++
528 lines
12 KiB
C++
#include "UndoSystem.h"
|
|
#include "Buffer.h"
|
|
#include <cassert>
|
|
#include <cstdio>
|
|
#include <vector>
|
|
|
|
|
|
UndoSystem::UndoSystem(Buffer &owner, UndoTree &tree)
|
|
: buf_(&owner), tree_(tree) {}
|
|
|
|
|
|
std::uint64_t
|
|
UndoSystem::BeginGroup()
|
|
{
|
|
// Ensure any pending typed run is sealed so the group is a distinct undo step.
|
|
commit();
|
|
if (active_group_id_ == 0)
|
|
active_group_id_ = next_group_id_++;
|
|
// Groups nest (e.g. SmartNewline wraps Newline, which groups its own
|
|
// visual-line splits); only the outermost EndGroup closes the group.
|
|
++group_depth_;
|
|
return active_group_id_;
|
|
}
|
|
|
|
|
|
void
|
|
UndoSystem::EndGroup()
|
|
{
|
|
commit();
|
|
if (group_depth_ > 0)
|
|
--group_depth_;
|
|
if (group_depth_ == 0)
|
|
active_group_id_ = 0;
|
|
}
|
|
|
|
|
|
void
|
|
UndoSystem::Begin(UndoType type)
|
|
{
|
|
if (!buf_)
|
|
return;
|
|
const int row = static_cast<int>(buf_->Cury());
|
|
const int col = static_cast<int>(buf_->Curx());
|
|
|
|
// Some operations should always be standalone undo steps.
|
|
const bool always_standalone = (type == UndoType::Newline || type == UndoType::DeleteRow || type ==
|
|
UndoType::InsertRow || type == UndoType::JoinLines);
|
|
if (always_standalone) {
|
|
commit();
|
|
}
|
|
|
|
if (tree_.pending) {
|
|
if (tree_.pending->type == type) {
|
|
// Typed-run coalescing rules.
|
|
switch (type) {
|
|
case UndoType::Insert:
|
|
case UndoType::Paste: {
|
|
// Cursor must be at the end of the pending insert.
|
|
if (tree_.pending->row == row
|
|
&& col == tree_.pending->col + static_cast<int>(tree_.pending->text.size())) {
|
|
pending_mode_ = PendingAppendMode::Append;
|
|
return;
|
|
}
|
|
break;
|
|
}
|
|
case UndoType::Delete: {
|
|
if (tree_.pending->row == row) {
|
|
// Two common delete shapes:
|
|
// 1) backspace-run: cursor moves left each time (so new col is pending.col - 1)
|
|
// 2) delete-run: cursor stays, always deleting at the same col
|
|
if (col == tree_.pending->col) {
|
|
pending_mode_ = PendingAppendMode::Append;
|
|
return;
|
|
}
|
|
if (col + 1 == tree_.pending->col) {
|
|
// Extend a backspace run to the left; update the start column now.
|
|
tree_.pending->col = col;
|
|
pending_mode_ = PendingAppendMode::Prepend;
|
|
return;
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
case UndoType::Newline:
|
|
case UndoType::DeleteRow:
|
|
case UndoType::InsertRow:
|
|
case UndoType::JoinLines:
|
|
break;
|
|
}
|
|
}
|
|
// Can't coalesce: seal the previous pending step.
|
|
commit();
|
|
}
|
|
|
|
// Start a new pending node.
|
|
tree_.pending = new UndoNode{};
|
|
tree_.pending->type = type;
|
|
tree_.pending->row = row;
|
|
tree_.pending->col = col;
|
|
tree_.pending->group_id = active_group_id_;
|
|
tree_.pending->text.clear();
|
|
tree_.pending->parent = nullptr;
|
|
tree_.pending->child = nullptr;
|
|
tree_.pending->next = nullptr;
|
|
pending_mode_ = PendingAppendMode::Append;
|
|
}
|
|
|
|
|
|
void
|
|
UndoSystem::Append(char ch)
|
|
{
|
|
if (!tree_.pending)
|
|
return;
|
|
if (pending_mode_ == PendingAppendMode::Prepend) {
|
|
tree_.pending->text.insert(tree_.pending->text.begin(), ch);
|
|
} else {
|
|
tree_.pending->text.push_back(ch);
|
|
}
|
|
}
|
|
|
|
|
|
void
|
|
UndoSystem::Append(std::string_view text)
|
|
{
|
|
if (!tree_.pending)
|
|
return;
|
|
if (text.empty())
|
|
return;
|
|
if (pending_mode_ == PendingAppendMode::Prepend) {
|
|
tree_.pending->text.insert(0, text.data(), text.size());
|
|
} else {
|
|
tree_.pending->text.append(text.data(), text.size());
|
|
}
|
|
}
|
|
|
|
|
|
void
|
|
UndoSystem::commit()
|
|
{
|
|
if (!tree_.pending)
|
|
return;
|
|
|
|
// Drop empty text batches for text-based operations.
|
|
if ((tree_.pending->type == UndoType::Insert || tree_.pending->type == UndoType::Delete
|
|
|| tree_.pending->type == UndoType::Paste)
|
|
&& tree_.pending->text.empty()) {
|
|
delete tree_.pending;
|
|
tree_.pending = nullptr;
|
|
pending_mode_ = PendingAppendMode::Append;
|
|
return;
|
|
}
|
|
|
|
if (!tree_.root) {
|
|
tree_.root = tree_.pending;
|
|
tree_.pending->parent = nullptr;
|
|
tree_.current = tree_.pending;
|
|
} else if (!tree_.current) {
|
|
// We are at the "pre-first-edit" state (undo past the first node).
|
|
// In branching history, preserve the existing root chain as an alternate branch.
|
|
tree_.pending->parent = nullptr;
|
|
tree_.pending->next = tree_.root;
|
|
tree_.root = tree_.pending;
|
|
tree_.current = tree_.pending;
|
|
} else {
|
|
// Branching semantics: attach as a new redo branch under current.
|
|
// Make the new edit the active child by inserting it at the head.
|
|
tree_.pending->parent = tree_.current;
|
|
if (!tree_.current->child) {
|
|
tree_.current->child = tree_.pending;
|
|
} else {
|
|
tree_.pending->next = tree_.current->child;
|
|
tree_.current->child = tree_.pending;
|
|
}
|
|
tree_.current = tree_.pending;
|
|
}
|
|
|
|
tree_.pending = nullptr;
|
|
pending_mode_ = PendingAppendMode::Append;
|
|
update_dirty_flag();
|
|
}
|
|
|
|
|
|
void
|
|
UndoSystem::undo()
|
|
{
|
|
// Seal any in-progress typed run before undo.
|
|
commit();
|
|
if (!tree_.current)
|
|
return;
|
|
debug_log("undo");
|
|
const std::uint64_t gid = tree_.current->group_id;
|
|
do {
|
|
UndoNode *node = tree_.current;
|
|
apply(node, -1);
|
|
tree_.current = node->parent;
|
|
} while (gid != 0 && tree_.current && tree_.current->group_id == gid);
|
|
update_dirty_flag();
|
|
}
|
|
|
|
|
|
void
|
|
UndoSystem::redo(int branch_index)
|
|
{
|
|
commit();
|
|
UndoNode **head = nullptr;
|
|
if (!tree_.current) {
|
|
head = &tree_.root;
|
|
} else {
|
|
head = &tree_.current->child;
|
|
}
|
|
if (!head || !*head)
|
|
return;
|
|
if (branch_index < 0)
|
|
branch_index = 0;
|
|
|
|
// Select the Nth sibling from the branch list and make it the active head.
|
|
UndoNode *prev = nullptr;
|
|
UndoNode *sel = *head;
|
|
for (int i = 0; i < branch_index && sel; ++i) {
|
|
prev = sel;
|
|
sel = sel->next;
|
|
}
|
|
if (!sel)
|
|
return;
|
|
if (prev) {
|
|
prev->next = sel->next;
|
|
sel->next = *head;
|
|
*head = sel;
|
|
}
|
|
|
|
debug_log("redo");
|
|
UndoNode *node = *head;
|
|
const std::uint64_t gid = node->group_id;
|
|
apply(node, +1);
|
|
tree_.current = node;
|
|
while (gid != 0 && tree_.current && tree_.current->child
|
|
&& tree_.current->child->group_id == gid) {
|
|
UndoNode *child = tree_.current->child;
|
|
apply(child, +1);
|
|
tree_.current = child;
|
|
}
|
|
update_dirty_flag();
|
|
}
|
|
|
|
|
|
void
|
|
UndoSystem::mark_saved()
|
|
{
|
|
commit();
|
|
tree_.saved = tree_.current;
|
|
base_unsaved_ = false;
|
|
update_dirty_flag();
|
|
}
|
|
|
|
|
|
void
|
|
UndoSystem::AbortGroups()
|
|
{
|
|
commit();
|
|
group_depth_ = 0;
|
|
active_group_id_ = 0;
|
|
}
|
|
|
|
|
|
void
|
|
UndoSystem::mark_base_unsaved()
|
|
{
|
|
base_unsaved_ = true;
|
|
update_dirty_flag();
|
|
}
|
|
|
|
|
|
void
|
|
UndoSystem::discard_pending()
|
|
{
|
|
if (tree_.pending) {
|
|
delete tree_.pending;
|
|
tree_.pending = nullptr;
|
|
}
|
|
pending_mode_ = PendingAppendMode::Append;
|
|
}
|
|
|
|
|
|
void
|
|
UndoSystem::clear()
|
|
{
|
|
discard_pending();
|
|
// The root may have siblings (edits made after undoing to the start).
|
|
FreeUndoForest(tree_.root);
|
|
tree_.root = nullptr;
|
|
tree_.current = nullptr;
|
|
tree_.saved = nullptr;
|
|
base_unsaved_ = false;
|
|
active_group_id_ = 0;
|
|
group_depth_ = 0;
|
|
next_group_id_ = 1;
|
|
update_dirty_flag();
|
|
}
|
|
|
|
|
|
// 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).
|
|
static void
|
|
end_of_text(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;
|
|
}
|
|
|
|
|
|
void
|
|
UndoSystem::apply(const UndoNode *node, int direction)
|
|
{
|
|
std::size_t ex = 0, ey = 0;
|
|
if (!node)
|
|
return;
|
|
// Cursor positioning: keep the point at a sensible location after undo/redo.
|
|
// Low-level Buffer edit primitives do not move the cursor.
|
|
switch (node->type) {
|
|
case UndoType::Insert:
|
|
case UndoType::Paste:
|
|
if (direction > 0) {
|
|
buf_->insert_text(node->row, node->col, node->text);
|
|
end_of_text(node, ex, ey);
|
|
buf_->SetCursor(ex, ey);
|
|
} else {
|
|
buf_->delete_text(node->row, node->col, node->text.size());
|
|
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_->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);
|
|
buf_->SetCursor(ex, ey);
|
|
}
|
|
break;
|
|
case UndoType::Newline:
|
|
if (direction > 0) {
|
|
buf_->split_line(node->row, node->col);
|
|
buf_->SetCursor(0, static_cast<std::size_t>(node->row + 1));
|
|
} else {
|
|
buf_->join_lines(node->row);
|
|
buf_->SetCursor(static_cast<std::size_t>(node->col), static_cast<std::size_t>(node->row));
|
|
}
|
|
break;
|
|
case UndoType::DeleteRow:
|
|
if (direction > 0) {
|
|
buf_->delete_row(node->row);
|
|
buf_->SetCursor(0, static_cast<std::size_t>(node->row));
|
|
} else {
|
|
buf_->insert_row(node->row, node->text);
|
|
buf_->SetCursor(0, static_cast<std::size_t>(node->row));
|
|
}
|
|
break;
|
|
case UndoType::InsertRow:
|
|
if (direction > 0) {
|
|
buf_->insert_row(node->row, node->text);
|
|
buf_->SetCursor(0, static_cast<std::size_t>(node->row));
|
|
} else {
|
|
buf_->delete_row(node->row);
|
|
buf_->SetCursor(0, static_cast<std::size_t>(node->row));
|
|
}
|
|
break;
|
|
case UndoType::JoinLines:
|
|
// Mirror image of Newline: forward removes a newline, backward restores it.
|
|
if (direction > 0) {
|
|
buf_->join_lines(node->row);
|
|
buf_->SetCursor(static_cast<std::size_t>(node->col), static_cast<std::size_t>(node->row));
|
|
} else {
|
|
buf_->split_line(node->row, node->col);
|
|
buf_->SetCursor(0, static_cast<std::size_t>(node->row + 1));
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
|
|
void
|
|
UndoSystem::free_node(UndoNode *node)
|
|
{
|
|
// Free this node and its child subtree, but not its siblings.
|
|
if (!node)
|
|
return;
|
|
UndoNode *children = node->child;
|
|
node->child = nullptr;
|
|
delete node;
|
|
FreeUndoForest(children);
|
|
}
|
|
|
|
|
|
void
|
|
UndoSystem::free_branch(UndoNode *node)
|
|
{
|
|
// Free a branch list (node and its next siblings) including their subtrees
|
|
FreeUndoForest(node);
|
|
}
|
|
|
|
|
|
// Iterative search for `target` below `cur`; sets out_parent to its parent.
|
|
static bool
|
|
dfs_find_parent(UndoNode *cur, UndoNode *target, UndoNode *&out_parent)
|
|
{
|
|
if (!cur)
|
|
return false;
|
|
std::vector<UndoNode *> stack{cur};
|
|
while (!stack.empty()) {
|
|
UndoNode *node = stack.back();
|
|
stack.pop_back();
|
|
for (UndoNode *child = node->child; child != nullptr; child = child->next) {
|
|
if (child == target) {
|
|
out_parent = node;
|
|
return true;
|
|
}
|
|
stack.push_back(child);
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
|
|
UndoNode *
|
|
UndoSystem::find_parent(UndoNode *from, UndoNode *target)
|
|
{
|
|
if (!from || !target)
|
|
return nullptr;
|
|
if (from == target)
|
|
return nullptr;
|
|
UndoNode *parent = nullptr;
|
|
dfs_find_parent(from, target, parent);
|
|
return parent;
|
|
}
|
|
|
|
|
|
void
|
|
UndoSystem::update_dirty_flag()
|
|
{
|
|
// dirty if current != saved
|
|
bool dirty = base_unsaved_ || (tree_.current != tree_.saved);
|
|
buf_->SetDirty(dirty);
|
|
}
|
|
|
|
|
|
void
|
|
UndoSystem::UpdateBufferReference(Buffer &new_buf)
|
|
{
|
|
buf_ = &new_buf;
|
|
}
|
|
|
|
|
|
// ---- Debug helpers ----
|
|
const char *
|
|
UndoSystem::type_str(UndoType t)
|
|
{
|
|
switch (t) {
|
|
case UndoType::Insert:
|
|
return "Insert";
|
|
case UndoType::Delete:
|
|
return "Delete";
|
|
case UndoType::Paste:
|
|
return "Paste";
|
|
case UndoType::Newline:
|
|
return "Newline";
|
|
case UndoType::DeleteRow:
|
|
return "DeleteRow";
|
|
case UndoType::InsertRow:
|
|
return "InsertRow";
|
|
case UndoType::JoinLines:
|
|
return "JoinLines";
|
|
}
|
|
return "?";
|
|
}
|
|
|
|
|
|
bool
|
|
UndoSystem::is_descendant(UndoNode *root, const UndoNode *target)
|
|
{
|
|
if (!root || !target)
|
|
return false;
|
|
std::vector<UndoNode *> stack{root};
|
|
while (!stack.empty()) {
|
|
UndoNode *node = stack.back();
|
|
stack.pop_back();
|
|
if (node == target)
|
|
return true;
|
|
for (UndoNode *child = node->child; child != nullptr; child = child->next)
|
|
stack.push_back(child);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
|
|
void
|
|
UndoSystem::debug_log(const char *op) const
|
|
{
|
|
#ifdef KTE_UNDO_DEBUG
|
|
int row = static_cast<int>(buf_->Cury());
|
|
int col = static_cast<int>(buf_->Curx());
|
|
const UndoNode *p = tree_.pending;
|
|
std::fprintf(stderr,
|
|
"[UNDO] %s cur=(%d,%d) pending=%p t=%s r=%d c=%d nlen=%zu current=%p saved=%p\n",
|
|
op,
|
|
row, col,
|
|
(const void *) p,
|
|
p ? type_str(p->type) : "-",
|
|
p ? p->row : -1,
|
|
p ? p->col : -1,
|
|
p ? p->text.size() : 0,
|
|
(void *) tree_.current,
|
|
(void *) tree_.saved);
|
|
#else
|
|
(void) op;
|
|
#endif
|
|
} |