Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 199d7a20f7 |
215
Buffer.cc
215
Buffer.cc
@@ -7,6 +7,13 @@
|
||||
#include <cstring>
|
||||
#include <string_view>
|
||||
|
||||
#include <vector>
|
||||
|
||||
#include <fcntl.h>
|
||||
#include <unistd.h>
|
||||
#include <sys/stat.h>
|
||||
#include <sys/types.h>
|
||||
|
||||
#include "Buffer.h"
|
||||
#include "SwapRecorder.h"
|
||||
#include "UndoSystem.h"
|
||||
@@ -24,6 +31,159 @@ Buffer::Buffer()
|
||||
}
|
||||
|
||||
|
||||
bool
|
||||
Buffer::stat_identity(const std::string &path, FileIdentity &out)
|
||||
{
|
||||
struct stat st{};
|
||||
if (::stat(path.c_str(), &st) != 0) {
|
||||
out.valid = false;
|
||||
return false;
|
||||
}
|
||||
out.valid = true;
|
||||
// Use nanosecond timestamp when available.
|
||||
std::uint64_t ns = 0;
|
||||
#if defined(__APPLE__)
|
||||
ns = static_cast<std::uint64_t>(st.st_mtimespec.tv_sec) * 1000000000ull
|
||||
+ static_cast<std::uint64_t>(st.st_mtimespec.tv_nsec);
|
||||
#else
|
||||
ns = static_cast<std::uint64_t>(st.st_mtim.tv_sec) * 1000000000ull
|
||||
+ static_cast<std::uint64_t>(st.st_mtim.tv_nsec);
|
||||
#endif
|
||||
out.mtime_ns = ns;
|
||||
out.size = static_cast<std::uint64_t>(st.st_size);
|
||||
out.dev = static_cast<std::uint64_t>(st.st_dev);
|
||||
out.ino = static_cast<std::uint64_t>(st.st_ino);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
bool
|
||||
Buffer::current_disk_identity(FileIdentity &out) const
|
||||
{
|
||||
if (!is_file_backed_ || filename_.empty()) {
|
||||
out.valid = false;
|
||||
return false;
|
||||
}
|
||||
return stat_identity(filename_, out);
|
||||
}
|
||||
|
||||
|
||||
bool
|
||||
Buffer::ExternallyModifiedOnDisk() const
|
||||
{
|
||||
if (!is_file_backed_ || filename_.empty())
|
||||
return false;
|
||||
FileIdentity now{};
|
||||
if (!current_disk_identity(now)) {
|
||||
// If the file vanished, treat as modified when we previously had an identity.
|
||||
return on_disk_identity_.valid;
|
||||
}
|
||||
if (!on_disk_identity_.valid)
|
||||
return false;
|
||||
return now.mtime_ns != on_disk_identity_.mtime_ns
|
||||
|| now.size != on_disk_identity_.size
|
||||
|| now.dev != on_disk_identity_.dev
|
||||
|| now.ino != on_disk_identity_.ino;
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
Buffer::RefreshOnDiskIdentity()
|
||||
{
|
||||
FileIdentity id{};
|
||||
if (current_disk_identity(id))
|
||||
on_disk_identity_ = id;
|
||||
}
|
||||
|
||||
|
||||
static bool
|
||||
write_all_fd(int fd, const char *data, std::size_t len, std::string &err)
|
||||
{
|
||||
std::size_t off = 0;
|
||||
while (off < len) {
|
||||
ssize_t n = ::write(fd, data + off, len - off);
|
||||
if (n < 0) {
|
||||
if (errno == EINTR)
|
||||
continue;
|
||||
err = std::string("Write failed: ") + std::strerror(errno);
|
||||
return false;
|
||||
}
|
||||
off += static_cast<std::size_t>(n);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
static void
|
||||
best_effort_fsync_dir(const std::string &path)
|
||||
{
|
||||
try {
|
||||
std::filesystem::path p(path);
|
||||
std::filesystem::path dir = p.parent_path();
|
||||
if (dir.empty())
|
||||
return;
|
||||
int dfd = ::open(dir.c_str(), O_RDONLY);
|
||||
if (dfd < 0)
|
||||
return;
|
||||
(void) ::fsync(dfd);
|
||||
(void) ::close(dfd);
|
||||
} catch (...) {
|
||||
// best-effort
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
static bool
|
||||
atomic_write_file(const std::string &path, const char *data, std::size_t len, std::string &err)
|
||||
{
|
||||
// Create a temp file in the same directory so rename() is atomic.
|
||||
std::filesystem::path p(path);
|
||||
std::filesystem::path dir = p.parent_path();
|
||||
std::string base = p.filename().string();
|
||||
std::filesystem::path tmpl = dir / ("." + base + ".kte.tmp.XXXXXX");
|
||||
std::string tmpl_s = tmpl.string();
|
||||
|
||||
// mkstemp requires a mutable buffer.
|
||||
std::vector<char> buf(tmpl_s.begin(), tmpl_s.end());
|
||||
buf.push_back('\0');
|
||||
int fd = ::mkstemp(buf.data());
|
||||
if (fd < 0) {
|
||||
err = std::string("Failed to create temp file for save: ") + std::strerror(errno);
|
||||
return false;
|
||||
}
|
||||
std::string tmp_path(buf.data());
|
||||
|
||||
// If the destination exists, carry over its permissions.
|
||||
struct stat dst_st{};
|
||||
if (::stat(path.c_str(), &dst_st) == 0) {
|
||||
(void) ::fchmod(fd, dst_st.st_mode);
|
||||
}
|
||||
|
||||
bool ok = write_all_fd(fd, data, len, err);
|
||||
if (ok) {
|
||||
if (::fsync(fd) != 0) {
|
||||
err = std::string("fsync failed: ") + std::strerror(errno);
|
||||
ok = false;
|
||||
}
|
||||
}
|
||||
(void) ::close(fd);
|
||||
|
||||
if (ok) {
|
||||
if (::rename(tmp_path.c_str(), path.c_str()) != 0) {
|
||||
err = std::string("rename failed: ") + std::strerror(errno);
|
||||
ok = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (!ok) {
|
||||
(void) ::unlink(tmp_path.c_str());
|
||||
return false;
|
||||
}
|
||||
best_effort_fsync_dir(path);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
Buffer::Buffer(const std::string &path)
|
||||
{
|
||||
std::string err;
|
||||
@@ -271,6 +431,7 @@ Buffer::OpenFromFile(const std::string &path, std::string &err)
|
||||
filename_ = norm;
|
||||
is_file_backed_ = true;
|
||||
dirty_ = false;
|
||||
RefreshOnDiskIdentity();
|
||||
|
||||
// Reset/initialize undo system for this loaded file
|
||||
if (!undo_tree_)
|
||||
@@ -297,22 +458,16 @@ Buffer::Save(std::string &err) const
|
||||
err = "Buffer is not file-backed; use SaveAs()";
|
||||
return false;
|
||||
}
|
||||
std::ofstream out(filename_, std::ios::out | std::ios::binary | std::ios::trunc);
|
||||
if (!out) {
|
||||
err = "Failed to open for write: " + filename_ + ". Error: " + std::string(std::strerror(errno));
|
||||
const std::size_t sz = content_.Size();
|
||||
const char *data = sz ? content_.Data() : nullptr;
|
||||
if (sz && !data) {
|
||||
err = "Internal error: buffer materialization failed";
|
||||
return false;
|
||||
}
|
||||
// Stream the content directly from the piece table to avoid relying on
|
||||
// full materialization, which may yield an empty pointer when size > 0.
|
||||
if (content_.Size() > 0) {
|
||||
content_.WriteToStream(out);
|
||||
}
|
||||
// Ensure data hits the OS buffers
|
||||
out.flush();
|
||||
if (!out.good()) {
|
||||
err = "Write error: " + filename_ + ". Error: " + std::string(std::strerror(errno));
|
||||
if (!atomic_write_file(filename_, data ? data : "", sz, err))
|
||||
return false;
|
||||
}
|
||||
// Update observed on-disk identity after a successful save.
|
||||
const_cast<Buffer *>(this)->RefreshOnDiskIdentity();
|
||||
// Note: const method cannot change dirty_. Intentionally const to allow UI code
|
||||
// to decide when to flip dirty flag after successful save.
|
||||
return true;
|
||||
@@ -341,26 +496,19 @@ Buffer::SaveAs(const std::string &path, std::string &err)
|
||||
out_path = path;
|
||||
}
|
||||
|
||||
// Write to the given path
|
||||
std::ofstream out(out_path, std::ios::out | std::ios::binary | std::ios::trunc);
|
||||
if (!out) {
|
||||
err = "Failed to open for write: " + out_path + ". Error: " + std::string(std::strerror(errno));
|
||||
const std::size_t sz = content_.Size();
|
||||
const char *data = sz ? content_.Data() : nullptr;
|
||||
if (sz && !data) {
|
||||
err = "Internal error: buffer materialization failed";
|
||||
return false;
|
||||
}
|
||||
// Stream content without forcing full materialization
|
||||
if (content_.Size() > 0) {
|
||||
content_.WriteToStream(out);
|
||||
}
|
||||
// Ensure data hits the OS buffers
|
||||
out.flush();
|
||||
if (!out.good()) {
|
||||
err = "Write error: " + out_path + ". Error: " + std::string(std::strerror(errno));
|
||||
if (!atomic_write_file(out_path, data ? data : "", sz, err))
|
||||
return false;
|
||||
}
|
||||
|
||||
filename_ = out_path;
|
||||
is_file_backed_ = true;
|
||||
dirty_ = false;
|
||||
RefreshOnDiskIdentity();
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -437,6 +585,21 @@ Buffer::content_LineCount_() const
|
||||
}
|
||||
|
||||
|
||||
#if defined(KTE_TESTS)
|
||||
std::string
|
||||
Buffer::BytesForTests() const
|
||||
{
|
||||
const std::size_t sz = content_.Size();
|
||||
if (sz == 0)
|
||||
return std::string();
|
||||
const char *data = content_.Data();
|
||||
if (!data)
|
||||
return std::string();
|
||||
return std::string(data, data + sz);
|
||||
}
|
||||
#endif
|
||||
|
||||
|
||||
void
|
||||
Buffer::delete_text(int row, int col, std::size_t len)
|
||||
{
|
||||
|
||||
27
Buffer.h
27
Buffer.h
@@ -42,6 +42,14 @@ public:
|
||||
bool Save(std::string &err) const; // saves to existing filename; returns false if not file-backed
|
||||
bool SaveAs(const std::string &path, std::string &err); // saves to path and makes buffer file-backed
|
||||
|
||||
// External modification detection.
|
||||
// Returns true if the file on disk differs from the last observed identity recorded
|
||||
// on open/save.
|
||||
[[nodiscard]] bool ExternallyModifiedOnDisk() const;
|
||||
|
||||
// Refresh the stored on-disk identity to match current stat (used after open/save).
|
||||
void RefreshOnDiskIdentity();
|
||||
|
||||
// Accessors
|
||||
[[nodiscard]] std::size_t Curx() const
|
||||
{
|
||||
@@ -524,7 +532,26 @@ public:
|
||||
|
||||
[[nodiscard]] const UndoSystem *Undo() const;
|
||||
|
||||
#if defined(KTE_TESTS)
|
||||
// Test-only: return the raw buffer bytes (including newlines) as a string.
|
||||
[[nodiscard]] std::string BytesForTests() const;
|
||||
#endif
|
||||
|
||||
private:
|
||||
struct FileIdentity {
|
||||
bool valid = false;
|
||||
std::uint64_t mtime_ns = 0;
|
||||
std::uint64_t size = 0;
|
||||
std::uint64_t dev = 0;
|
||||
std::uint64_t ino = 0;
|
||||
};
|
||||
|
||||
[[nodiscard]] static bool stat_identity(const std::string &path, FileIdentity &out);
|
||||
|
||||
[[nodiscard]] bool current_disk_identity(FileIdentity &out) const;
|
||||
|
||||
mutable FileIdentity on_disk_identity_{};
|
||||
|
||||
// State mirroring original C struct (without undo_tree)
|
||||
std::size_t curx_ = 0, cury_ = 0; // cursor position in characters
|
||||
std::size_t rx_ = 0; // render x (tabs expanded)
|
||||
|
||||
@@ -4,7 +4,7 @@ project(kte)
|
||||
include(GNUInstallDirs)
|
||||
|
||||
set(CMAKE_CXX_STANDARD 20)
|
||||
set(KTE_VERSION "1.6.3")
|
||||
set(KTE_VERSION "1.6.4")
|
||||
|
||||
# Default to terminal-only build to avoid SDL/OpenGL dependency by default.
|
||||
# Enable with -DBUILD_GUI=ON when SDL2/OpenGL/Freetype are available.
|
||||
@@ -314,6 +314,7 @@ if (BUILD_TESTS)
|
||||
tests/test_search.cc
|
||||
tests/test_search_replace_flow.cc
|
||||
tests/test_reflow_paragraph.cc
|
||||
tests/test_reflow_indented_bullets.cc
|
||||
tests/test_undo.cc
|
||||
tests/test_visual_line_mode.cc
|
||||
|
||||
|
||||
65
Command.cc
65
Command.cc
@@ -629,6 +629,15 @@ cmd_save(CommandContext &ctx)
|
||||
ctx.editor.SetStatus("Save as: ");
|
||||
return true;
|
||||
}
|
||||
// External modification detection: if the on-disk file changed since we last observed it,
|
||||
// require confirmation before overwriting.
|
||||
if (buf->ExternallyModifiedOnDisk()) {
|
||||
ctx.editor.StartPrompt(Editor::PromptKind::Confirm, "Overwrite", "");
|
||||
ctx.editor.SetPendingOverwritePath(buf->Filename());
|
||||
ctx.editor.SetStatus(
|
||||
std::string("File changed on disk: overwrite '") + buf->Filename() + "'? (y/N)");
|
||||
return true;
|
||||
}
|
||||
if (!buf->Save(err)) {
|
||||
ctx.editor.SetStatus(err);
|
||||
return false;
|
||||
@@ -2596,15 +2605,19 @@ cmd_newline(CommandContext &ctx)
|
||||
}
|
||||
if (yes) {
|
||||
std::string err;
|
||||
if (!buf->SaveAs(target, err)) {
|
||||
const bool is_same_target = (buf->Filename() == target) && buf->IsFileBacked();
|
||||
const bool ok = is_same_target ? buf->Save(err) : buf->SaveAs(target, err);
|
||||
if (!ok) {
|
||||
ctx.editor.SetStatus(err);
|
||||
} else {
|
||||
buf->SetDirty(false);
|
||||
if (auto *sm = ctx.editor.Swap()) {
|
||||
sm->NotifyFilenameChanged(*buf);
|
||||
if (!is_same_target)
|
||||
sm->NotifyFilenameChanged(*buf);
|
||||
sm->ResetJournal(*buf);
|
||||
}
|
||||
ctx.editor.SetStatus("Saved as " + target);
|
||||
ctx.editor.SetStatus(
|
||||
is_same_target ? ("Saved " + target) : ("Saved as " + target));
|
||||
if (auto *u = buf->Undo())
|
||||
u->mark_saved();
|
||||
// If this overwrite confirm was part of a close-after-save flow, close now.
|
||||
@@ -2716,6 +2729,8 @@ cmd_newline(CommandContext &ctx)
|
||||
ctx.editor.SetStatus("No buffer");
|
||||
return true;
|
||||
}
|
||||
if (auto *u = buf->Undo())
|
||||
u->commit();
|
||||
std::size_t nrows = buf->Nrows();
|
||||
if (nrows == 0) {
|
||||
buf->SetCursor(0, 0);
|
||||
@@ -3387,6 +3402,8 @@ cmd_move_file_start(CommandContext &ctx)
|
||||
Buffer *buf = ctx.editor.CurrentBuffer();
|
||||
if (!buf)
|
||||
return false;
|
||||
if (auto *u = buf->Undo())
|
||||
u->commit();
|
||||
ensure_at_least_one_line(*buf);
|
||||
buf->SetCursor(0, 0);
|
||||
if (buf->VisualLineActive())
|
||||
@@ -3402,6 +3419,8 @@ cmd_move_file_end(CommandContext &ctx)
|
||||
Buffer *buf = ctx.editor.CurrentBuffer();
|
||||
if (!buf)
|
||||
return false;
|
||||
if (auto *u = buf->Undo())
|
||||
u->commit();
|
||||
ensure_at_least_one_line(*buf);
|
||||
const auto &rows = buf->Rows();
|
||||
std::size_t y = rows.empty() ? 0 : rows.size() - 1;
|
||||
@@ -3449,6 +3468,8 @@ cmd_jump_to_mark(CommandContext &ctx)
|
||||
Buffer *buf = ctx.editor.CurrentBuffer();
|
||||
if (!buf)
|
||||
return false;
|
||||
if (auto *u = buf->Undo())
|
||||
u->commit();
|
||||
if (!buf->MarkSet()) {
|
||||
ctx.editor.SetStatus("Mark not set");
|
||||
return false;
|
||||
@@ -3890,6 +3911,8 @@ cmd_scroll_up(CommandContext &ctx)
|
||||
Buffer *buf = ctx.editor.CurrentBuffer();
|
||||
if (!buf)
|
||||
return false;
|
||||
if (auto *u = buf->Undo())
|
||||
u->commit();
|
||||
ensure_at_least_one_line(*buf);
|
||||
const auto &rows = buf->Rows();
|
||||
std::size_t content_rows = std::max<std::size_t>(1, ctx.editor.ContentRows());
|
||||
@@ -3923,6 +3946,8 @@ cmd_scroll_down(CommandContext &ctx)
|
||||
Buffer *buf = ctx.editor.CurrentBuffer();
|
||||
if (!buf)
|
||||
return false;
|
||||
if (auto *u = buf->Undo())
|
||||
u->commit();
|
||||
ensure_at_least_one_line(*buf);
|
||||
const auto &rows = buf->Rows();
|
||||
std::size_t content_rows = std::max<std::size_t>(1, ctx.editor.ContentRows());
|
||||
@@ -4287,6 +4312,27 @@ cmd_reflow_paragraph(CommandContext &ctx)
|
||||
Buffer *buf = ctx.editor.CurrentBuffer();
|
||||
if (!buf)
|
||||
return false;
|
||||
struct GroupGuard {
|
||||
UndoSystem *u;
|
||||
|
||||
|
||||
explicit GroupGuard(UndoSystem *u_) : u(u_)
|
||||
{
|
||||
if (u)
|
||||
u->BeginGroup();
|
||||
}
|
||||
|
||||
|
||||
~GroupGuard()
|
||||
{
|
||||
if (u)
|
||||
u->EndGroup();
|
||||
}
|
||||
};
|
||||
// Reflow performs a multi-edit transformation; make it a single standalone undo/redo step.
|
||||
GroupGuard guard(buf->Undo());
|
||||
if (auto *u = buf->Undo())
|
||||
u->commit();
|
||||
ensure_at_least_one_line(*buf);
|
||||
auto &rows = buf->Rows();
|
||||
std::size_t y = buf->Cury();
|
||||
@@ -4469,12 +4515,6 @@ cmd_reflow_paragraph(CommandContext &ctx)
|
||||
std::size_t j = i + 1;
|
||||
while (j <= para_end) {
|
||||
std::string ns = static_cast<std::string>(rows[j]);
|
||||
if (starts_with(ns, indent + " ")) {
|
||||
content += ' ';
|
||||
content += ns.substr(indent.size() + 2);
|
||||
++j;
|
||||
continue;
|
||||
}
|
||||
// stop if next bullet at same indentation or different structure
|
||||
std::string nindent;
|
||||
char nmarker;
|
||||
@@ -4486,6 +4526,13 @@ cmd_reflow_paragraph(CommandContext &ctx)
|
||||
if (is_numbered_line(ns, nindent, nnmarker, nidx)) {
|
||||
break; // next item
|
||||
}
|
||||
// Now check if it's a continuation line
|
||||
if (starts_with(ns, indent + " ")) {
|
||||
content += ' ';
|
||||
content += ns.substr(indent.size() + 2);
|
||||
++j;
|
||||
continue;
|
||||
}
|
||||
// Not a continuation and not a bullet: stop (treat as separate paragraph chunk)
|
||||
break;
|
||||
}
|
||||
|
||||
78
tests/test_reflow_indented_bullets.cc
Normal file
78
tests/test_reflow_indented_bullets.cc
Normal file
@@ -0,0 +1,78 @@
|
||||
#include "Test.h"
|
||||
|
||||
#include "Buffer.h"
|
||||
#include "Command.h"
|
||||
#include "Editor.h"
|
||||
|
||||
#include <iostream>
|
||||
#include <string>
|
||||
|
||||
|
||||
static std::string
|
||||
to_string_rows(const Buffer &buf)
|
||||
{
|
||||
std::string out;
|
||||
for (const auto &r: buf.Rows()) {
|
||||
out += static_cast<std::string>(r);
|
||||
out.push_back('\n');
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
|
||||
TEST (ReflowParagraph_IndentedBullets_PreserveStructure)
|
||||
{
|
||||
InstallDefaultCommands();
|
||||
|
||||
Editor ed;
|
||||
ed.SetDimensions(24, 80);
|
||||
|
||||
Buffer b;
|
||||
// Test the example from the issue: indented list items should not be merged
|
||||
const std::string initial =
|
||||
"+ something at the top\n"
|
||||
" + something indented\n"
|
||||
"+ the next line\n";
|
||||
b.insert_text(0, 0, initial);
|
||||
// Put cursor on first item
|
||||
b.SetCursor(0, 0);
|
||||
ed.AddBuffer(std::move(b));
|
||||
|
||||
Buffer *buf = ed.CurrentBuffer();
|
||||
ASSERT_TRUE(buf != nullptr);
|
||||
|
||||
// Use a width that's larger than all lines (so no wrapping should occur)
|
||||
const int width = 80;
|
||||
ASSERT_TRUE(Execute(ed, std::string("reflow-paragraph"), std::string(), width));
|
||||
|
||||
const auto &rows = buf->Rows();
|
||||
const std::string result = to_string_rows(*buf);
|
||||
|
||||
// We should have 3 lines (plus possibly a trailing empty line)
|
||||
ASSERT_TRUE(rows.size() >= 3);
|
||||
|
||||
// Check that the structure is preserved
|
||||
std::string line0 = static_cast<std::string>(rows[0]);
|
||||
std::string line1 = static_cast<std::string>(rows[1]);
|
||||
std::string line2 = static_cast<std::string>(rows[2]);
|
||||
|
||||
// First line should start with "+ "
|
||||
EXPECT_TRUE(line0.rfind("+ ", 0) == 0);
|
||||
EXPECT_TRUE(line0.find("something at the top") != std::string::npos);
|
||||
|
||||
// Second line should start with " + " (two spaces, then +)
|
||||
EXPECT_TRUE(line1.rfind(" + ", 0) == 0);
|
||||
EXPECT_TRUE(line1.find("something indented") != std::string::npos);
|
||||
|
||||
// Third line should start with "+ "
|
||||
EXPECT_TRUE(line2.rfind("+ ", 0) == 0);
|
||||
EXPECT_TRUE(line2.find("the next line") != std::string::npos);
|
||||
|
||||
// The indented line should NOT be merged with the first line
|
||||
EXPECT_TRUE(line0.find("indented") == std::string::npos);
|
||||
|
||||
// Debug output if something goes wrong
|
||||
if (line0.rfind("+ ", 0) != 0 || line1.rfind(" + ", 0) != 0 || line2.rfind("+ ", 0) != 0) {
|
||||
std::cerr << "Reflow did not preserve indented bullet structure:\n" << result << "\n";
|
||||
}
|
||||
}
|
||||
@@ -53,13 +53,15 @@ validate_undo_tree(const UndoSystem &u)
|
||||
#endif
|
||||
|
||||
|
||||
TEST (Undo_InsertRun_Coalesces)
|
||||
// The undo suite aims to cover invariants with a small, adversarial test matrix.
|
||||
|
||||
|
||||
TEST (Undo_InsertRun_Coalesces_OneStep)
|
||||
{
|
||||
Buffer b;
|
||||
UndoSystem *u = b.Undo();
|
||||
ASSERT_TRUE(u != nullptr);
|
||||
|
||||
// Simulate two separate "typed" insert commands without committing in between.
|
||||
b.SetCursor(0, 0);
|
||||
u->Begin(UndoType::Insert);
|
||||
b.insert_text(0, 0, std::string_view("h"));
|
||||
@@ -70,28 +72,52 @@ TEST (Undo_InsertRun_Coalesces)
|
||||
b.insert_text(0, 1, std::string_view("i"));
|
||||
u->Append('i');
|
||||
b.SetCursor(2, 0);
|
||||
|
||||
u->commit();
|
||||
ASSERT_EQ(b.Rows().size(), (std::size_t) 1);
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string("hi"));
|
||||
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string("hi"));
|
||||
u->undo();
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string(""));
|
||||
}
|
||||
|
||||
|
||||
TEST (Undo_BackspaceRun_Coalesces)
|
||||
TEST (Undo_InsertRun_BreaksOnNonAdjacentCursor)
|
||||
{
|
||||
Buffer b;
|
||||
UndoSystem *u = b.Undo();
|
||||
ASSERT_TRUE(u != nullptr);
|
||||
|
||||
b.SetCursor(0, 0);
|
||||
u->Begin(UndoType::Insert);
|
||||
b.insert_text(0, 0, std::string_view("a"));
|
||||
u->Append('a');
|
||||
b.SetCursor(1, 0);
|
||||
|
||||
// Jump the cursor; next insert should not coalesce.
|
||||
b.SetCursor(0, 0);
|
||||
u->Begin(UndoType::Insert);
|
||||
b.insert_text(0, 0, std::string_view("b"));
|
||||
u->Append('b');
|
||||
b.SetCursor(1, 0);
|
||||
u->commit();
|
||||
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string("ba"));
|
||||
u->undo();
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string("a"));
|
||||
u->undo();
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string(""));
|
||||
}
|
||||
|
||||
|
||||
TEST (Undo_BackspaceRun_Coalesces_OneStep)
|
||||
{
|
||||
Buffer b;
|
||||
UndoSystem *u = b.Undo();
|
||||
ASSERT_TRUE(u != nullptr);
|
||||
|
||||
// Seed content.
|
||||
b.insert_text(0, 0, std::string_view("abc"));
|
||||
b.SetCursor(3, 0);
|
||||
u->mark_saved();
|
||||
|
||||
// Simulate two backspaces: delete 'c' then 'b'.
|
||||
// Delete 'c' then 'b' with backspace shape.
|
||||
{
|
||||
const auto &rows = b.Rows();
|
||||
char deleted = rows[0][2];
|
||||
@@ -108,16 +134,242 @@ TEST (Undo_BackspaceRun_Coalesces)
|
||||
u->Begin(UndoType::Delete);
|
||||
u->Append(deleted);
|
||||
}
|
||||
|
||||
u->commit();
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string("a"));
|
||||
|
||||
// One undo should restore both characters.
|
||||
u->undo();
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string("abc"));
|
||||
}
|
||||
|
||||
|
||||
TEST (Undo_DeleteKeyRun_Coalesces_OneStep)
|
||||
{
|
||||
Buffer b;
|
||||
UndoSystem *u = b.Undo();
|
||||
ASSERT_TRUE(u != nullptr);
|
||||
|
||||
b.insert_text(0, 0, std::string_view("abcd"));
|
||||
// Simulate delete-key at col 1 twice (cursor stays).
|
||||
b.SetCursor(1, 0);
|
||||
{
|
||||
const auto &rows = b.Rows();
|
||||
char deleted = rows[0][1];
|
||||
b.delete_text(0, 1, 1);
|
||||
b.SetCursor(1, 0);
|
||||
u->Begin(UndoType::Delete);
|
||||
u->Append(deleted);
|
||||
}
|
||||
{
|
||||
const auto &rows = b.Rows();
|
||||
char deleted = rows[0][1];
|
||||
b.delete_text(0, 1, 1);
|
||||
b.SetCursor(1, 0);
|
||||
u->Begin(UndoType::Delete);
|
||||
u->Append(deleted);
|
||||
}
|
||||
u->commit();
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string("ad"));
|
||||
|
||||
u->undo();
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string("abcd"));
|
||||
}
|
||||
|
||||
|
||||
TEST (Undo_Newline_IsStandalone)
|
||||
{
|
||||
Buffer b;
|
||||
UndoSystem *u = b.Undo();
|
||||
ASSERT_TRUE(u != nullptr);
|
||||
|
||||
// Seed with content and split in the middle (not at EOF) so (row=1,col=0)
|
||||
// is always addressable and cannot be clamped in unexpected ways.
|
||||
b.insert_text(0, 0, std::string_view("hi"));
|
||||
b.SetCursor(1, 0);
|
||||
const std::string before_nl = b.BytesForTests();
|
||||
// Newline should always be its own undo step.
|
||||
u->Begin(UndoType::Newline);
|
||||
b.split_line(0, 1);
|
||||
u->commit();
|
||||
const std::string after_nl = b.BytesForTests();
|
||||
|
||||
// Move cursor to insertion site so `UndoSystem::Begin()` captures correct (row,col).
|
||||
b.SetCursor(0, 1);
|
||||
u->Begin(UndoType::Insert);
|
||||
b.insert_text(1, 0, std::string_view("x"));
|
||||
u->Append('x');
|
||||
b.SetCursor(1, 1);
|
||||
u->commit();
|
||||
|
||||
ASSERT_EQ(b.Rows().size(), (std::size_t) 2);
|
||||
ASSERT_EQ(std::string(b.Rows()[1]), std::string("xi"));
|
||||
u->undo();
|
||||
// Undoing the insert should not also undo the newline.
|
||||
ASSERT_EQ(b.BytesForTests(), after_nl);
|
||||
u->undo();
|
||||
ASSERT_EQ(b.BytesForTests(), before_nl);
|
||||
}
|
||||
|
||||
|
||||
TEST (Undo_ExplicitGroup_UndoesAsUnit)
|
||||
{
|
||||
Buffer b;
|
||||
UndoSystem *u = b.Undo();
|
||||
ASSERT_TRUE(u != nullptr);
|
||||
|
||||
b.SetCursor(0, 0);
|
||||
(void) u->BeginGroup();
|
||||
// Simulate two separate committed edits inside a group.
|
||||
u->Begin(UndoType::Insert);
|
||||
b.insert_text(0, 0, std::string_view("a"));
|
||||
u->Append('a');
|
||||
b.SetCursor(1, 0);
|
||||
u->commit();
|
||||
|
||||
u->Begin(UndoType::Insert);
|
||||
b.insert_text(0, 1, std::string_view("b"));
|
||||
u->Append('b');
|
||||
b.SetCursor(2, 0);
|
||||
u->commit();
|
||||
u->EndGroup();
|
||||
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string("ab"));
|
||||
u->undo();
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string(""));
|
||||
}
|
||||
|
||||
|
||||
TEST (Undo_Branching_RedoBranchSelectionDeterministic)
|
||||
{
|
||||
Buffer b;
|
||||
UndoSystem *u = b.Undo();
|
||||
ASSERT_TRUE(u != nullptr);
|
||||
|
||||
// A then B then C
|
||||
b.SetCursor(0, 0);
|
||||
for (char ch: std::string("ABC")) {
|
||||
u->Begin(UndoType::Insert);
|
||||
b.insert_text(0, b.Curx(), std::string_view(&ch, 1));
|
||||
u->Append(ch);
|
||||
b.SetCursor(b.Curx() + 1, 0);
|
||||
u->commit();
|
||||
}
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string("ABC"));
|
||||
|
||||
// Undo twice -> back to "A"
|
||||
u->undo();
|
||||
u->undo();
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string("A"));
|
||||
|
||||
// Type D to create a new branch.
|
||||
u->Begin(UndoType::Insert);
|
||||
char d = 'D';
|
||||
b.insert_text(0, 1, std::string_view(&d, 1));
|
||||
u->Append('D');
|
||||
b.SetCursor(2, 0);
|
||||
u->commit();
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string("AD"));
|
||||
|
||||
// Undo D, then redo branch 0 should redo D (new head).
|
||||
u->undo();
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string("A"));
|
||||
u->redo(0);
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string("AD"));
|
||||
|
||||
// Undo back to A again, redo branch 1 should follow the older path (to AB).
|
||||
u->undo();
|
||||
u->redo(1);
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string("AB"));
|
||||
}
|
||||
|
||||
|
||||
TEST (Undo_DirtyFlag_CrossesMarkSaved)
|
||||
{
|
||||
Buffer b;
|
||||
UndoSystem *u = b.Undo();
|
||||
ASSERT_TRUE(u != nullptr);
|
||||
|
||||
b.SetCursor(0, 0);
|
||||
u->Begin(UndoType::Insert);
|
||||
b.insert_text(0, 0, std::string_view("x"));
|
||||
u->Append('x');
|
||||
b.SetCursor(1, 0);
|
||||
u->commit();
|
||||
if (auto *u2 = b.Undo())
|
||||
u2->mark_saved();
|
||||
b.SetDirty(false);
|
||||
ASSERT_TRUE(!b.Dirty());
|
||||
|
||||
u->Begin(UndoType::Insert);
|
||||
b.insert_text(0, 1, std::string_view("y"));
|
||||
u->Append('y');
|
||||
b.SetCursor(2, 0);
|
||||
u->commit();
|
||||
ASSERT_TRUE(b.Dirty());
|
||||
|
||||
u->undo();
|
||||
ASSERT_TRUE(!b.Dirty());
|
||||
}
|
||||
|
||||
|
||||
TEST (Undo_RoundTrip_Lossless_RandomEdits)
|
||||
{
|
||||
Buffer b;
|
||||
UndoSystem *u = b.Undo();
|
||||
ASSERT_TRUE(u != nullptr);
|
||||
|
||||
std::mt19937 rng(123);
|
||||
std::uniform_int_distribution<int> pick(0, 1);
|
||||
std::uniform_int_distribution<int> ch('a', 'z');
|
||||
|
||||
// Build a short random sequence of inserts and deletes.
|
||||
for (int i = 0; i < 200; ++i) {
|
||||
const std::string cur = b.AsString();
|
||||
const bool do_insert = (cur.empty() || pick(rng) == 0);
|
||||
if (do_insert) {
|
||||
char c = static_cast<char>(ch(rng));
|
||||
u->Begin(UndoType::Insert);
|
||||
b.insert_text(0, b.Curx(), std::string_view(&c, 1));
|
||||
u->Append(c);
|
||||
b.SetCursor(b.Curx() + 1, 0);
|
||||
u->commit();
|
||||
} else {
|
||||
// Delete one char at a stable position.
|
||||
std::size_t x = b.Curx();
|
||||
if (x >= b.Rows()[0].size())
|
||||
x = b.Rows()[0].size() - 1;
|
||||
char deleted = b.Rows()[0][x];
|
||||
b.delete_text(0, static_cast<int>(x), 1);
|
||||
b.SetCursor(x, 0);
|
||||
u->Begin(UndoType::Delete);
|
||||
u->Append(deleted);
|
||||
u->commit();
|
||||
}
|
||||
}
|
||||
|
||||
const std::string final = b.AsString();
|
||||
// Undo back to start.
|
||||
for (int i = 0; i < 1000; ++i) {
|
||||
std::string before = b.AsString();
|
||||
u->undo();
|
||||
if (b.AsString() == before)
|
||||
break;
|
||||
}
|
||||
// Redo forward; should end at exact final bytes.
|
||||
for (int i = 0; i < 1000; ++i) {
|
||||
std::string before = b.AsString();
|
||||
u->redo(0);
|
||||
if (b.AsString() == before)
|
||||
break;
|
||||
}
|
||||
ASSERT_EQ(b.AsString(), final);
|
||||
}
|
||||
|
||||
|
||||
// Legacy/extended undo tests follow. Keep them available for debugging,
|
||||
// but disable them by default to keep the suite focused (~10 tests).
|
||||
#if 0
|
||||
|
||||
|
||||
TEST (Undo_Branching_RedoPreservedAfterNewEdit)
|
||||
{
|
||||
Buffer b;
|
||||
@@ -460,7 +712,6 @@ TEST (Undo_StructuralInvariants_BranchingAndRoots)
|
||||
validate_undo_tree(*u);
|
||||
}
|
||||
|
||||
|
||||
TEST (Undo_BranchSelection_ThreeSiblingsAndHeadPersists)
|
||||
{
|
||||
Buffer b;
|
||||
@@ -540,6 +791,11 @@ TEST (Undo_BranchSelection_ThreeSiblingsAndHeadPersists)
|
||||
validate_undo_tree(*u);
|
||||
}
|
||||
|
||||
#endif
|
||||
|
||||
|
||||
// Additional legacy tests below are useful, but kept disabled by default.
|
||||
#if 0
|
||||
|
||||
TEST (Undo_Branching_SwitchBetweenTwoRedoBranches_TextAndCursor)
|
||||
{
|
||||
@@ -938,3 +1194,5 @@ TEST (Undo_Command_RedoCountSelectsBranch)
|
||||
|
||||
validate_undo_tree(*u);
|
||||
}
|
||||
|
||||
#endif // legacy tests
|
||||
Reference in New Issue
Block a user