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

95
Swap.h
View File

@@ -7,11 +7,14 @@
#include <string_view>
#include <vector>
#include <unordered_map>
#include <memory>
#include <mutex>
#include <condition_variable>
#include <thread>
#include <atomic>
#include "SwapRecorder.h"
class Buffer;
namespace kte {
@@ -31,30 +34,12 @@ struct SwapConfig {
unsigned fsync_interval_ms{1000}; // at most once per second
};
// Lightweight interface that Buffer can call without depending on full manager impl
class SwapRecorder {
public:
virtual ~SwapRecorder() = default;
virtual void RecordInsert(Buffer &buf, int row, int col, std::string_view text) = 0;
virtual void RecordDelete(Buffer &buf, int row, int col, std::size_t len) = 0;
virtual void RecordSplit(Buffer &buf, int row, int col) = 0;
virtual void RecordJoin(Buffer &buf, int row) = 0;
virtual void NotifyFilenameChanged(Buffer &buf) = 0;
virtual void SetSuspended(Buffer &buf, bool on) = 0;
};
// SwapManager manages sidecar swap files and a single background writer thread.
class SwapManager final : public SwapRecorder {
class SwapManager final {
public:
SwapManager();
~SwapManager() override;
~SwapManager();
// Attach a buffer to begin journaling. Safe to call multiple times; idempotent.
void Attach(Buffer *buf);
@@ -62,17 +47,34 @@ public:
// Detach and close journal.
void Detach(Buffer *buf);
// SwapRecorder: Notify that the buffer's filename changed (e.g., SaveAs)
void NotifyFilenameChanged(Buffer &buf) override;
// Block until all currently queued records have been written.
// If buf is non-null, flushes all records (stage 1) but is primarily intended
// for tests and shutdown.
void Flush(Buffer *buf = nullptr);
// SwapRecorder
void RecordInsert(Buffer &buf, int row, int col, std::string_view text) override;
// Obtain a per-buffer recorder adapter that emits records for that buffer.
// The returned pointer is owned by the SwapManager and remains valid until
// Detach(buf) or SwapManager destruction.
SwapRecorder *RecorderFor(Buffer *buf);
void RecordDelete(Buffer &buf, int row, int col, std::size_t len) override;
// Notify that the buffer's filename changed (e.g., SaveAs)
void NotifyFilenameChanged(Buffer &buf);
void RecordSplit(Buffer &buf, int row, int col) override;
// Replay a swap journal into an already-open buffer.
// On success, the buffer content reflects all valid journal records.
// On failure (corrupt/truncated/invalid), the buffer is left in whatever
// state results from applying records up to the failure point; callers should
// treat this as a recovery failure and surface `err`.
static bool ReplayFile(Buffer &buf, const std::string &swap_path, std::string &err);
void RecordJoin(Buffer &buf, int row) override;
// Test-only hook to keep swap path logic centralized.
// (Avoid duplicating naming rules in unit tests.)
#ifdef KTE_TESTS
static std::string ComputeSwapPathForTests(const Buffer &buf)
{
return ComputeSidecarPath(buf);
}
#endif
// RAII guard to suspend recording for internal operations
class SuspendGuard {
@@ -88,12 +90,32 @@ public:
};
// Per-buffer toggle
void SetSuspended(Buffer &buf, bool on) override;
void SetSuspended(Buffer &buf, bool on);
private:
class BufferRecorder final : public SwapRecorder {
public:
BufferRecorder(SwapManager &m, Buffer &b) : m_(m), buf_(b) {}
void OnInsert(int row, int col, std::string_view bytes) override;
void OnDelete(int row, int col, std::size_t len) override;
private:
SwapManager &m_;
Buffer &buf_;
};
void RecordInsert(Buffer &buf, int row, int col, std::string_view text);
void RecordDelete(Buffer &buf, int row, int col, std::size_t len);
void RecordSplit(Buffer &buf, int row, int col);
void RecordJoin(Buffer &buf, int row);
struct JournalCtx {
std::string path;
void *file{nullptr}; // FILE*
int fd{-1};
bool header_ok{false};
bool suspended{false};
@@ -106,6 +128,7 @@ private:
SwapRecType type{SwapRecType::INS};
std::vector<std::uint8_t> payload; // framed payload only
bool urgent_flush{false};
std::uint64_t seq{0};
};
// Helpers
@@ -115,17 +138,19 @@ private:
static bool ensure_parent_dir(const std::string &path);
static bool write_header(JournalCtx &ctx);
static bool write_header(int fd);
static bool open_ctx(JournalCtx &ctx);
static bool open_ctx(JournalCtx &ctx, const std::string &path);
static void close_ctx(JournalCtx &ctx);
static std::uint32_t crc32(const std::uint8_t *data, std::size_t len, std::uint32_t seed = 0);
static void put_varu64(std::vector<std::uint8_t> &out, std::uint64_t v);
static void put_le32(std::vector<std::uint8_t> &out, std::uint32_t v);
static void put_u24(std::uint8_t dst[3], std::uint32_t v);
static void put_le64(std::uint8_t dst[8], std::uint64_t v);
static void put_u24_le(std::uint8_t dst[3], std::uint32_t v);
void enqueue(Pending &&p);
@@ -136,9 +161,13 @@ private:
// State
SwapConfig cfg_{};
std::unordered_map<Buffer *, JournalCtx> journals_;
std::unordered_map<Buffer *, std::unique_ptr<BufferRecorder> > recorders_;
std::mutex mtx_;
std::condition_variable cv_;
std::vector<Pending> queue_;
std::uint64_t next_seq_{0};
std::uint64_t last_processed_{0};
std::uint64_t inflight_{0};
std::atomic<bool> running_{false};
std::thread worker_;
};