Undo/redo: - cmd_newline recorded the undo node's position after the cursor moved past the split point, so undoing Enter joined the wrong pair of lines. - Backspace/Delete line-joins recorded UndoType::Newline, whose apply() semantics are inverted for a join (forward=split); add UndoType::JoinLines with correct forward/backward behavior. - Regex replace-all, indent/unindent region, kill-to-eol, kill-line, kill-region, and delete-word-prev/next mutated the buffer via the no-undo-recording raw APIs; they now record undo, grouped atomically via a shared UndoGroupGuard. - Plain-text replace-all with an empty replacement advanced the scan position one character too far, skipping adjacent/overlapping matches. Crash recovery / memory safety: - Buffer's move ctor/assignment never carried over swap_rec_, on_disk_identity_, or the visual-line-mode fields, so the crash-recovery journal silently stopped tracking a buffer whenever Editor's std::vector<Buffer> reallocated or shifted (e.g. opening/closing files). Added SwapManager::Rehome() plus Flush()-before-mutate in Editor::AddBuffer/CloseBuffer so the journal's Buffer* key and the background writer thread never reference a stale address. - UndoTree leaked its entire node graph (including all edit text) on every buffer close/reload; added ~UndoTree() to free it. - main.cc didn't restore the terminal if an exception escaped the run loop; added an RAII guard around Frontend::Shutdown(). Input handling: - Terminal frontend used getch() instead of get_wch(), silently dropping non-ASCII keyboard input despite linking wide-char ncurses. - KKeymap's C-k lookup switches on an already-lowercased key, making `case 'E'` unreachable dead code; C-k Shift-E silently aliased to C-k e. - ImGui file picker wasn't modal: keystrokes typed while it was open still reached the buffer underneath as edit commands, and Escape didn't close it. - Gated ImGuiInputHandler's unconditional fprintf/fflush diagnostics behind an IMGUI_IH_DEBUG macro, matching QtInputHandler's existing convention. Syntax highlighting: - Go/Rust/SQL highlighters weren't stateful, so multi-line /* */ comments mis-highlighted as code starting on the second line. - Python's triple-quoted-string handler didn't re-scan the remainder of a line after a string closed mid-line, missing a same-line reopen. - HighlighterEngine's state_last_contig_ field was declared but unused, forcing an O(n) scan of state_cache_ on every stateful lookup; wired it up as a real fast path and fixed InvalidateFrom() to keep it in sync. - kge's :syntax off was silently undone the next frame because apply_syntax_to_buffer() re-applied the config default unconditionally every frame; added a user-override flag mirroring the existing edit-mode-detection guard. Added regression tests for all of the above (test_undo.cc, test_kkeymap.cc, test_command_semantics.cc, test_search_replace_flow.cc, and a new test_syntax_highlighting.cc). Full suite (163 tests) passes, including under AddressSanitizer. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
481 lines
11 KiB
C++
481 lines
11 KiB
C++
#include "UndoSystem.h"
|
|
#include "Buffer.h"
|
|
#include <cassert>
|
|
#include <cstdio>
|
|
|
|
|
|
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_++;
|
|
return active_group_id_;
|
|
}
|
|
|
|
|
|
void
|
|
UndoSystem::EndGroup()
|
|
{
|
|
commit();
|
|
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;
|
|
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();
|
|
free_node(tree_.root);
|
|
tree_.root = nullptr;
|
|
tree_.current = nullptr;
|
|
tree_.saved = nullptr;
|
|
active_group_id_ = 0;
|
|
next_group_id_ = 1;
|
|
update_dirty_flag();
|
|
}
|
|
|
|
|
|
void
|
|
UndoSystem::apply(const UndoNode *node, int direction)
|
|
{
|
|
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);
|
|
buf_->SetCursor(static_cast<std::size_t>(node->col + node->text.size()),
|
|
static_cast<std::size_t>(node->row));
|
|
} 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);
|
|
buf_->SetCursor(static_cast<std::size_t>(node->col + node->text.size()),
|
|
static_cast<std::size_t>(node->row));
|
|
}
|
|
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)
|
|
{
|
|
if (!node)
|
|
return;
|
|
// Free child subtree(s) and sibling branches
|
|
if (node->child) {
|
|
// Free entire redo list starting at child, including each subtree
|
|
UndoNode *branch = node->child;
|
|
while (branch) {
|
|
UndoNode *next = branch->next;
|
|
free_node(branch);
|
|
branch = next;
|
|
}
|
|
node->child = nullptr;
|
|
}
|
|
delete node;
|
|
}
|
|
|
|
|
|
void
|
|
UndoSystem::free_branch(UndoNode *node)
|
|
{
|
|
// Free a branch list (node and its next siblings) including their subtrees
|
|
while (node) {
|
|
UndoNode *next = node->next;
|
|
free_node(node);
|
|
node = next;
|
|
}
|
|
}
|
|
|
|
|
|
static bool
|
|
dfs_find_parent(UndoNode *cur, UndoNode *target, UndoNode *&out_parent)
|
|
{
|
|
if (!cur)
|
|
return false;
|
|
for (UndoNode *child = cur->child; child != nullptr; child = child->next) {
|
|
if (child == target) {
|
|
out_parent = cur;
|
|
return true;
|
|
}
|
|
if (dfs_find_parent(child, target, out_parent))
|
|
return true;
|
|
}
|
|
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 = (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;
|
|
if (root == target)
|
|
return true;
|
|
for (UndoNode *child = root->child; child != nullptr; child = child->next) {
|
|
if (is_descendant(child, target))
|
|
return true;
|
|
}
|
|
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
|
|
} |