Add swap journaling and group undo/redo with extensive tests.

- Introduced SwapManager for sidecar journaling of buffer mutations, with a safe recovery mechanism.
- Added group undo/redo functionality, allowing atomic grouping of related edits.
- Implemented `SwapRecorder` and integrated it as a callback interface for mutations.
- Added unit tests for swap journaling (save/load/replay) and undo grouping.
- Refactored undo to support group tracking and ID management.
- Updated CMake to include the new tests and swap journaling logic.
This commit is contained in:
2026-02-11 20:47:18 -08:00
parent 15b350bfaa
commit 895e4ccb1e
27 changed files with 2419 additions and 290 deletions

142
tests/test_buffer_rows.cc Normal file
View File

@@ -0,0 +1,142 @@
#include "Test.h"
#include "Buffer.h"
#include <algorithm>
#include <limits>
#include <string>
#include <vector>
static std::vector<std::string>
split_lines_preserve_trailing_empty(const std::string &s)
{
std::vector<std::string> out;
std::size_t start = 0;
for (std::size_t i = 0; i <= s.size(); i++) {
if (i == s.size() || s[i] == '\n') {
out.push_back(s.substr(start, i - start));
start = i + 1;
}
}
if (out.empty())
out.push_back(std::string());
return out;
}
static std::vector<std::size_t>
line_starts_for(const std::string &s)
{
std::vector<std::size_t> starts;
starts.push_back(0);
for (std::size_t i = 0; i < s.size(); i++) {
if (s[i] == '\n')
starts.push_back(i + 1);
}
return starts;
}
static std::size_t
ref_linecol_to_offset(const std::string &s, std::size_t row, std::size_t col)
{
auto starts = line_starts_for(s);
if (starts.empty())
return 0;
if (row >= starts.size())
return s.size();
std::size_t start = starts[row];
std::size_t end = (row + 1 < starts.size()) ? starts[row + 1] : s.size();
if (end > start && s[end - 1] == '\n')
end -= 1; // clamp before trailing newline
return start + std::min(col, end - start);
}
static void
check_buffer_matches_model(const Buffer &b, const std::string &model)
{
auto expected_lines = split_lines_preserve_trailing_empty(model);
const auto &rows = b.Rows();
ASSERT_EQ(rows.size(), expected_lines.size());
ASSERT_EQ(b.Nrows(), rows.size());
auto starts = line_starts_for(model);
ASSERT_EQ(starts.size(), expected_lines.size());
std::string via_views;
for (std::size_t i = 0; i < rows.size(); i++) {
ASSERT_EQ(std::string(rows[i]), expected_lines[i]);
ASSERT_EQ(b.GetLineString(i), expected_lines[i]);
std::size_t exp_start = starts[i];
std::size_t exp_end = (i + 1 < starts.size()) ? starts[i + 1] : model.size();
auto r = b.GetLineRange(i);
ASSERT_EQ(r.first, exp_start);
ASSERT_EQ(r.second, exp_end);
auto v = b.GetLineView(i);
ASSERT_EQ(std::string(v), model.substr(exp_start, exp_end - exp_start));
via_views.append(v.data(), v.size());
}
ASSERT_EQ(via_views, model);
}
TEST (Buffer_RowsCache_MultiLineEdits_StayConsistent)
{
Buffer b;
std::string model;
check_buffer_matches_model(b, model);
// Insert text and newlines in a few different ways.
b.insert_text(0, 0, std::string("abc"));
model.insert(0, "abc");
check_buffer_matches_model(b, model);
b.split_line(0, 1); // a\nbc
model.insert(ref_linecol_to_offset(model, 0, 1), "\n");
check_buffer_matches_model(b, model);
b.insert_text(1, 2, std::string("X")); // a\nbcX
model.insert(ref_linecol_to_offset(model, 1, 2), "X");
check_buffer_matches_model(b, model);
b.join_lines(0); // abcX
{
std::size_t off = ref_linecol_to_offset(model, 0, std::numeric_limits<std::size_t>::max());
if (off < model.size() && model[off] == '\n')
model.erase(off, 1);
}
check_buffer_matches_model(b, model);
// Insert a multi-line segment in one shot.
b.insert_text(0, 2, std::string("\n123\nxyz"));
model.insert(ref_linecol_to_offset(model, 0, 2), "\n123\nxyz");
check_buffer_matches_model(b, model);
// Delete spanning across a newline.
b.delete_text(0, 1, 5);
{
std::size_t start = ref_linecol_to_offset(model, 0, 1);
std::size_t actual = std::min<std::size_t>(5, model.size() - start);
model.erase(start, actual);
}
check_buffer_matches_model(b, model);
// Insert/delete whole rows.
b.insert_row(1, std::string_view("ROW"));
model.insert(ref_linecol_to_offset(model, 1, 0), "ROW\n");
check_buffer_matches_model(b, model);
b.delete_row(1);
{
auto starts = line_starts_for(model);
if (1 < (int) starts.size()) {
std::size_t start = starts[1];
std::size_t end = (2 < starts.size()) ? starts[2] : model.size();
model.erase(start, end - start);
}
}
check_buffer_matches_model(b, model);
}