Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 95a588b0df | |||
| 199d7a20f7 | |||
| 44827fe53f | |||
| 2a6ff2a862 | |||
| 895e4ccb1e | |||
| 15b350bfaa |
@@ -1,6 +1,6 @@
|
|||||||
# Project Guidelines
|
# Project Guidelines
|
||||||
|
|
||||||
kte is Kyle's Text Editor — a simple, fast text editor written in C++17.
|
kte is Kyle's Text Editor — a simple, fast text editor written in C++20.
|
||||||
It
|
It
|
||||||
replaces the earlier C implementation, ke (see the ke manual in
|
replaces the earlier C implementation, ke (see the ke manual in
|
||||||
`docs/ke.md`). The
|
`docs/ke.md`). The
|
||||||
@@ -43,7 +43,7 @@ The file `docs/ke.md` contains the canonical reference for keybindings.
|
|||||||
|
|
||||||
## Contributing/Development Notes
|
## Contributing/Development Notes
|
||||||
|
|
||||||
- C++ standard: C++17.
|
- C++ standard: C++20.
|
||||||
- Keep dependencies minimal.
|
- Keep dependencies minimal.
|
||||||
- Prefer small, focused changes that preserve ke’s UX unless explicitly
|
- Prefer small, focused changes that preserve ke’s UX unless explicitly
|
||||||
changing
|
changing
|
||||||
@@ -55,3 +55,4 @@ The file `docs/ke.md` contains the canonical reference for keybindings.
|
|||||||
for now).
|
for now).
|
||||||
- Inspiration: kilo, WordStar/VDE, emacs, `mg(1)`.
|
- Inspiration: kilo, WordStar/VDE, emacs, `mg(1)`.
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
280
Buffer.cc
280
Buffer.cc
@@ -7,7 +7,15 @@
|
|||||||
#include <cstring>
|
#include <cstring>
|
||||||
#include <string_view>
|
#include <string_view>
|
||||||
|
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include <fcntl.h>
|
||||||
|
#include <unistd.h>
|
||||||
|
#include <sys/stat.h>
|
||||||
|
#include <sys/types.h>
|
||||||
|
|
||||||
#include "Buffer.h"
|
#include "Buffer.h"
|
||||||
|
#include "SwapRecorder.h"
|
||||||
#include "UndoSystem.h"
|
#include "UndoSystem.h"
|
||||||
#include "UndoTree.h"
|
#include "UndoTree.h"
|
||||||
// For reconstructing highlighter state on copies
|
// For reconstructing highlighter state on copies
|
||||||
@@ -23,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)
|
Buffer::Buffer(const std::string &path)
|
||||||
{
|
{
|
||||||
std::string err;
|
std::string err;
|
||||||
@@ -270,6 +431,7 @@ Buffer::OpenFromFile(const std::string &path, std::string &err)
|
|||||||
filename_ = norm;
|
filename_ = norm;
|
||||||
is_file_backed_ = true;
|
is_file_backed_ = true;
|
||||||
dirty_ = false;
|
dirty_ = false;
|
||||||
|
RefreshOnDiskIdentity();
|
||||||
|
|
||||||
// Reset/initialize undo system for this loaded file
|
// Reset/initialize undo system for this loaded file
|
||||||
if (!undo_tree_)
|
if (!undo_tree_)
|
||||||
@@ -296,22 +458,16 @@ Buffer::Save(std::string &err) const
|
|||||||
err = "Buffer is not file-backed; use SaveAs()";
|
err = "Buffer is not file-backed; use SaveAs()";
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
std::ofstream out(filename_, std::ios::out | std::ios::binary | std::ios::trunc);
|
const std::size_t sz = content_.Size();
|
||||||
if (!out) {
|
const char *data = sz ? content_.Data() : nullptr;
|
||||||
err = "Failed to open for write: " + filename_ + ". Error: " + std::string(std::strerror(errno));
|
if (sz && !data) {
|
||||||
|
err = "Internal error: buffer materialization failed";
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
// Stream the content directly from the piece table to avoid relying on
|
if (!atomic_write_file(filename_, data ? data : "", sz, err))
|
||||||
// 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));
|
|
||||||
return false;
|
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
|
// Note: const method cannot change dirty_. Intentionally const to allow UI code
|
||||||
// to decide when to flip dirty flag after successful save.
|
// to decide when to flip dirty flag after successful save.
|
||||||
return true;
|
return true;
|
||||||
@@ -340,26 +496,19 @@ Buffer::SaveAs(const std::string &path, std::string &err)
|
|||||||
out_path = path;
|
out_path = path;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write to the given path
|
const std::size_t sz = content_.Size();
|
||||||
std::ofstream out(out_path, std::ios::out | std::ios::binary | std::ios::trunc);
|
const char *data = sz ? content_.Data() : nullptr;
|
||||||
if (!out) {
|
if (sz && !data) {
|
||||||
err = "Failed to open for write: " + out_path + ". Error: " + std::string(std::strerror(errno));
|
err = "Internal error: buffer materialization failed";
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
// Stream content without forcing full materialization
|
if (!atomic_write_file(out_path, data ? data : "", sz, err))
|
||||||
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));
|
|
||||||
return false;
|
return false;
|
||||||
}
|
|
||||||
|
|
||||||
filename_ = out_path;
|
filename_ = out_path;
|
||||||
is_file_backed_ = true;
|
is_file_backed_ = true;
|
||||||
dirty_ = false;
|
dirty_ = false;
|
||||||
|
RefreshOnDiskIdentity();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -390,6 +539,8 @@ Buffer::insert_text(int row, int col, std::string_view text)
|
|||||||
if (!text.empty()) {
|
if (!text.empty()) {
|
||||||
content_.Insert(off, text.data(), text.size());
|
content_.Insert(off, text.data(), text.size());
|
||||||
rows_cache_dirty_ = true;
|
rows_cache_dirty_ = true;
|
||||||
|
if (swap_rec_)
|
||||||
|
swap_rec_->OnInsert(row, col, text);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -434,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
|
void
|
||||||
Buffer::delete_text(int row, int col, std::size_t len)
|
Buffer::delete_text(int row, int col, std::size_t len)
|
||||||
{
|
{
|
||||||
@@ -443,6 +609,7 @@ Buffer::delete_text(int row, int col, std::size_t len)
|
|||||||
row = 0;
|
row = 0;
|
||||||
if (col < 0)
|
if (col < 0)
|
||||||
col = 0;
|
col = 0;
|
||||||
|
|
||||||
const std::size_t start = content_.LineColToByteOffset(static_cast<std::size_t>(row),
|
const std::size_t start = content_.LineColToByteOffset(static_cast<std::size_t>(row),
|
||||||
static_cast<std::size_t>(col));
|
static_cast<std::size_t>(col));
|
||||||
std::size_t r = static_cast<std::size_t>(row);
|
std::size_t r = static_cast<std::size_t>(row);
|
||||||
@@ -462,16 +629,19 @@ Buffer::delete_text(int row, int col, std::size_t len)
|
|||||||
break;
|
break;
|
||||||
// Consume newline between lines as one char, if there is a next line
|
// Consume newline between lines as one char, if there is a next line
|
||||||
if (r + 1 < lc) {
|
if (r + 1 < lc) {
|
||||||
if (remaining > 0) {
|
remaining -= 1; // the newline
|
||||||
remaining -= 1; // the newline
|
r += 1;
|
||||||
r += 1;
|
c = 0;
|
||||||
c = 0;
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// At last line and still remaining: delete to EOF
|
// At last line and still remaining: delete to EOF
|
||||||
std::size_t total = content_.Size();
|
const std::size_t total = content_.Size();
|
||||||
content_.Delete(start, total - start);
|
const std::size_t actual = (total > start) ? (total - start) : 0;
|
||||||
|
if (actual == 0)
|
||||||
|
return;
|
||||||
|
content_.Delete(start, actual);
|
||||||
rows_cache_dirty_ = true;
|
rows_cache_dirty_ = true;
|
||||||
|
if (swap_rec_)
|
||||||
|
swap_rec_->OnDelete(row, col, actual);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -479,8 +649,11 @@ Buffer::delete_text(int row, int col, std::size_t len)
|
|||||||
// Compute end offset at (r,c)
|
// Compute end offset at (r,c)
|
||||||
std::size_t end = content_.LineColToByteOffset(r, c);
|
std::size_t end = content_.LineColToByteOffset(r, c);
|
||||||
if (end > start) {
|
if (end > start) {
|
||||||
content_.Delete(start, end - start);
|
const std::size_t actual = end - start;
|
||||||
|
content_.Delete(start, actual);
|
||||||
rows_cache_dirty_ = true;
|
rows_cache_dirty_ = true;
|
||||||
|
if (swap_rec_)
|
||||||
|
swap_rec_->OnDelete(row, col, actual);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -488,15 +661,18 @@ Buffer::delete_text(int row, int col, std::size_t len)
|
|||||||
void
|
void
|
||||||
Buffer::split_line(int row, const int col)
|
Buffer::split_line(int row, const int col)
|
||||||
{
|
{
|
||||||
|
int c = col;
|
||||||
if (row < 0)
|
if (row < 0)
|
||||||
row = 0;
|
row = 0;
|
||||||
if (col < 0)
|
if (c < 0)
|
||||||
row = 0;
|
c = 0;
|
||||||
const std::size_t off = content_.LineColToByteOffset(static_cast<std::size_t>(row),
|
const std::size_t off = content_.LineColToByteOffset(static_cast<std::size_t>(row),
|
||||||
static_cast<std::size_t>(col));
|
static_cast<std::size_t>(c));
|
||||||
const char nl = '\n';
|
const char nl = '\n';
|
||||||
content_.Insert(off, &nl, 1);
|
content_.Insert(off, &nl, 1);
|
||||||
rows_cache_dirty_ = true;
|
rows_cache_dirty_ = true;
|
||||||
|
if (swap_rec_)
|
||||||
|
swap_rec_->OnInsert(row, c, std::string_view("\n", 1));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -508,11 +684,14 @@ Buffer::join_lines(int row)
|
|||||||
std::size_t r = static_cast<std::size_t>(row);
|
std::size_t r = static_cast<std::size_t>(row);
|
||||||
if (r + 1 >= content_.LineCount())
|
if (r + 1 >= content_.LineCount())
|
||||||
return;
|
return;
|
||||||
|
const int col = static_cast<int>(content_.GetLine(r).size());
|
||||||
// Delete the newline between line r and r+1
|
// Delete the newline between line r and r+1
|
||||||
std::size_t end_of_line = content_.LineColToByteOffset(r, std::numeric_limits<std::size_t>::max());
|
std::size_t end_of_line = content_.LineColToByteOffset(r, std::numeric_limits<std::size_t>::max());
|
||||||
// end_of_line now equals line end (clamped before newline). The newline should be exactly at this position.
|
// end_of_line now equals line end (clamped before newline). The newline should be exactly at this position.
|
||||||
content_.Delete(end_of_line, 1);
|
content_.Delete(end_of_line, 1);
|
||||||
rows_cache_dirty_ = true;
|
rows_cache_dirty_ = true;
|
||||||
|
if (swap_rec_)
|
||||||
|
swap_rec_->OnDelete(row, col, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -527,6 +706,12 @@ Buffer::insert_row(int row, const std::string_view text)
|
|||||||
const char nl = '\n';
|
const char nl = '\n';
|
||||||
content_.Insert(off + text.size(), &nl, 1);
|
content_.Insert(off + text.size(), &nl, 1);
|
||||||
rows_cache_dirty_ = true;
|
rows_cache_dirty_ = true;
|
||||||
|
if (swap_rec_) {
|
||||||
|
// Avoid allocation: emit the row text insertion (if any) and the newline insertion.
|
||||||
|
if (!text.empty())
|
||||||
|
swap_rec_->OnInsert(row, 0, text);
|
||||||
|
swap_rec_->OnInsert(row, static_cast<int>(text.size()), std::string_view("\n", 1));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -541,9 +726,24 @@ Buffer::delete_row(int row)
|
|||||||
auto range = content_.GetLineRange(r); // [start,end)
|
auto range = content_.GetLineRange(r); // [start,end)
|
||||||
// If not last line, ensure we include the separating newline by using end as-is (which points to next line start)
|
// If not last line, ensure we include the separating newline by using end as-is (which points to next line start)
|
||||||
// If last line, end may equal total_size_. We still delete [start,end) which removes the last line content.
|
// If last line, end may equal total_size_. We still delete [start,end) which removes the last line content.
|
||||||
std::size_t start = range.first;
|
const std::size_t start = range.first;
|
||||||
std::size_t end = range.second;
|
const std::size_t end = range.second;
|
||||||
content_.Delete(start, end - start);
|
const std::size_t actual = (end > start) ? (end - start) : 0;
|
||||||
|
if (actual == 0)
|
||||||
|
return;
|
||||||
|
content_.Delete(start, actual);
|
||||||
|
rows_cache_dirty_ = true;
|
||||||
|
if (swap_rec_)
|
||||||
|
swap_rec_->OnDelete(row, 0, actual);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
void
|
||||||
|
Buffer::replace_all_bytes(const std::string_view bytes)
|
||||||
|
{
|
||||||
|
content_.Clear();
|
||||||
|
if (!bytes.empty())
|
||||||
|
content_.Append(bytes.data(), bytes.size());
|
||||||
rows_cache_dirty_ = true;
|
rows_cache_dirty_ = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
55
Buffer.h
55
Buffer.h
@@ -42,6 +42,14 @@ public:
|
|||||||
bool Save(std::string &err) const; // saves to existing filename; returns false if not file-backed
|
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
|
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
|
// Accessors
|
||||||
[[nodiscard]] std::size_t Curx() const
|
[[nodiscard]] std::size_t Curx() const
|
||||||
{
|
{
|
||||||
@@ -418,6 +426,23 @@ public:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// In visual-line (multi-cursor) mode, the UI should highlight only the per-line
|
||||||
|
// cursor "spot" (Curx clamped to each line length), not the entire line.
|
||||||
|
[[nodiscard]] bool VisualLineSpotSelected(std::size_t y, std::size_t sx) const
|
||||||
|
{
|
||||||
|
if (!visual_line_active_)
|
||||||
|
return false;
|
||||||
|
if (y < VisualLineStartY() || y > VisualLineEndY())
|
||||||
|
return false;
|
||||||
|
std::string_view ln = GetLineView(y);
|
||||||
|
// `GetLineView()` returns the raw range, which may include a trailing '\n'.
|
||||||
|
if (!ln.empty() && ln.back() == '\n')
|
||||||
|
ln.remove_suffix(1);
|
||||||
|
const std::size_t spot = std::min(Curx(), ln.size());
|
||||||
|
return sx == spot;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
[[nodiscard]] std::string AsString() const;
|
[[nodiscard]] std::string AsString() const;
|
||||||
|
|
||||||
// Syntax highlighting integration (per-buffer)
|
// Syntax highlighting integration (per-buffer)
|
||||||
@@ -477,6 +502,12 @@ public:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
[[nodiscard]] kte::SwapRecorder *SwapRecorder() const
|
||||||
|
{
|
||||||
|
return swap_rec_;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// Raw, low-level editing APIs used by UndoSystem apply().
|
// Raw, low-level editing APIs used by UndoSystem apply().
|
||||||
// These must NOT trigger undo recording. They also do not move the cursor.
|
// These must NOT trigger undo recording. They also do not move the cursor.
|
||||||
void insert_text(int row, int col, std::string_view text);
|
void insert_text(int row, int col, std::string_view text);
|
||||||
@@ -491,12 +522,36 @@ public:
|
|||||||
|
|
||||||
void delete_row(int row);
|
void delete_row(int row);
|
||||||
|
|
||||||
|
// Replace the entire buffer content with raw bytes.
|
||||||
|
// Intended for crash recovery (swap replay) and test harnesses.
|
||||||
|
// This does not trigger swap or undo recording.
|
||||||
|
void replace_all_bytes(std::string_view bytes);
|
||||||
|
|
||||||
// Undo system accessors (created per-buffer)
|
// Undo system accessors (created per-buffer)
|
||||||
[[nodiscard]] UndoSystem *Undo();
|
[[nodiscard]] UndoSystem *Undo();
|
||||||
|
|
||||||
[[nodiscard]] const UndoSystem *Undo() const;
|
[[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:
|
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)
|
// State mirroring original C struct (without undo_tree)
|
||||||
std::size_t curx_ = 0, cury_ = 0; // cursor position in characters
|
std::size_t curx_ = 0, cury_ = 0; // cursor position in characters
|
||||||
std::size_t rx_ = 0; // render x (tabs expanded)
|
std::size_t rx_ = 0; // render x (tabs expanded)
|
||||||
|
|||||||
@@ -4,13 +4,13 @@ project(kte)
|
|||||||
include(GNUInstallDirs)
|
include(GNUInstallDirs)
|
||||||
|
|
||||||
set(CMAKE_CXX_STANDARD 20)
|
set(CMAKE_CXX_STANDARD 20)
|
||||||
set(KTE_VERSION "1.6.0")
|
set(KTE_VERSION "1.6.5")
|
||||||
|
|
||||||
# Default to terminal-only build to avoid SDL/OpenGL dependency by default.
|
# Default to terminal-only build to avoid SDL/OpenGL dependency by default.
|
||||||
# Enable with -DBUILD_GUI=ON when SDL2/OpenGL/Freetype are available.
|
# Enable with -DBUILD_GUI=ON when SDL2/OpenGL/Freetype are available.
|
||||||
set(BUILD_GUI ON CACHE BOOL "Enable building the graphical version.")
|
set(BUILD_GUI ON CACHE BOOL "Enable building the graphical version.")
|
||||||
set(KTE_USE_QT OFF CACHE BOOL "Build the QT frontend instead of ImGui.")
|
set(KTE_USE_QT OFF CACHE BOOL "Build the QT frontend instead of ImGui.")
|
||||||
set(BUILD_TESTS OFF CACHE BOOL "Enable building test programs.")
|
set(BUILD_TESTS ON CACHE BOOL "Enable building test programs.")
|
||||||
set(KTE_FONT_SIZE "18.0" CACHE STRING "Default font size for GUI")
|
set(KTE_FONT_SIZE "18.0" CACHE STRING "Default font size for GUI")
|
||||||
option(KTE_UNDO_DEBUG "Enable undo instrumentation logs" OFF)
|
option(KTE_UNDO_DEBUG "Enable undo instrumentation logs" OFF)
|
||||||
option(KTE_ENABLE_TREESITTER "Enable optional Tree-sitter highlighter adapter" OFF)
|
option(KTE_ENABLE_TREESITTER "Enable optional Tree-sitter highlighter adapter" OFF)
|
||||||
@@ -298,10 +298,24 @@ if (BUILD_TESTS)
|
|||||||
add_executable(kte_tests
|
add_executable(kte_tests
|
||||||
tests/TestRunner.cc
|
tests/TestRunner.cc
|
||||||
tests/Test.h
|
tests/Test.h
|
||||||
|
tests/TestHarness.h
|
||||||
|
tests/test_daily_driver_harness.cc
|
||||||
|
tests/test_daily_workflows.cc
|
||||||
tests/test_buffer_io.cc
|
tests/test_buffer_io.cc
|
||||||
|
tests/test_buffer_rows.cc
|
||||||
|
tests/test_command_semantics.cc
|
||||||
|
tests/test_kkeymap.cc
|
||||||
|
tests/test_swap_recorder.cc
|
||||||
|
tests/test_swap_writer.cc
|
||||||
|
tests/test_swap_replay.cc
|
||||||
|
tests/test_swap_recovery_prompt.cc
|
||||||
|
tests/test_swap_cleanup.cc
|
||||||
|
tests/test_swap_git_editor.cc
|
||||||
tests/test_piece_table.cc
|
tests/test_piece_table.cc
|
||||||
tests/test_search.cc
|
tests/test_search.cc
|
||||||
|
tests/test_search_replace_flow.cc
|
||||||
tests/test_reflow_paragraph.cc
|
tests/test_reflow_paragraph.cc
|
||||||
|
tests/test_reflow_indented_bullets.cc
|
||||||
tests/test_undo.cc
|
tests/test_undo.cc
|
||||||
tests/test_visual_line_mode.cc
|
tests/test_visual_line_mode.cc
|
||||||
|
|
||||||
@@ -312,6 +326,8 @@ if (BUILD_TESTS)
|
|||||||
Command.cc
|
Command.cc
|
||||||
HelpText.cc
|
HelpText.cc
|
||||||
Swap.cc
|
Swap.cc
|
||||||
|
KKeymap.cc
|
||||||
|
SwapRecorder.h
|
||||||
OptimizedSearch.cc
|
OptimizedSearch.cc
|
||||||
UndoNode.cc
|
UndoNode.cc
|
||||||
UndoTree.cc
|
UndoTree.cc
|
||||||
|
|||||||
245
Command.cc
245
Command.cc
@@ -618,6 +618,8 @@ cmd_save(CommandContext &ctx)
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
buf->SetDirty(false);
|
buf->SetDirty(false);
|
||||||
|
if (auto *sm = ctx.editor.Swap())
|
||||||
|
sm->ResetJournal(*buf);
|
||||||
ctx.editor.SetStatus("Saved " + buf->Filename());
|
ctx.editor.SetStatus("Saved " + buf->Filename());
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -627,11 +629,22 @@ cmd_save(CommandContext &ctx)
|
|||||||
ctx.editor.SetStatus("Save as: ");
|
ctx.editor.SetStatus("Save as: ");
|
||||||
return true;
|
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)) {
|
if (!buf->Save(err)) {
|
||||||
ctx.editor.SetStatus(err);
|
ctx.editor.SetStatus(err);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
buf->SetDirty(false);
|
buf->SetDirty(false);
|
||||||
|
if (auto *sm = ctx.editor.Swap())
|
||||||
|
sm->ResetJournal(*buf);
|
||||||
ctx.editor.SetStatus("Saved " + buf->Filename());
|
ctx.editor.SetStatus("Saved " + buf->Filename());
|
||||||
if (auto *u = buf->Undo())
|
if (auto *u = buf->Undo())
|
||||||
u->mark_saved();
|
u->mark_saved();
|
||||||
@@ -686,6 +699,10 @@ cmd_save_as(CommandContext &ctx)
|
|||||||
ctx.editor.SetStatus(err);
|
ctx.editor.SetStatus(err);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
if (auto *sm = ctx.editor.Swap()) {
|
||||||
|
sm->NotifyFilenameChanged(*buf);
|
||||||
|
sm->ResetJournal(*buf);
|
||||||
|
}
|
||||||
ctx.editor.SetStatus("Saved as " + ctx.arg);
|
ctx.editor.SetStatus("Saved as " + ctx.arg);
|
||||||
if (auto *u = buf->Undo())
|
if (auto *u = buf->Undo())
|
||||||
u->mark_saved();
|
u->mark_saved();
|
||||||
@@ -789,6 +806,7 @@ cmd_refresh(CommandContext &ctx)
|
|||||||
ctx.editor.SetCloseConfirmPending(false);
|
ctx.editor.SetCloseConfirmPending(false);
|
||||||
ctx.editor.SetCloseAfterSave(false);
|
ctx.editor.SetCloseAfterSave(false);
|
||||||
ctx.editor.ClearPendingOverwritePath();
|
ctx.editor.ClearPendingOverwritePath();
|
||||||
|
ctx.editor.CancelRecoveryPrompt();
|
||||||
ctx.editor.CancelPrompt();
|
ctx.editor.CancelPrompt();
|
||||||
ctx.editor.SetStatus("Canceled");
|
ctx.editor.SetStatus("Canceled");
|
||||||
return true;
|
return true;
|
||||||
@@ -808,6 +826,14 @@ cmd_refresh(CommandContext &ctx)
|
|||||||
ctx.editor.SetStatus("Find canceled");
|
ctx.editor.SetStatus("Find canceled");
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
// If nothing else to cancel, treat C-g/refresh as a mark clear (ke behavior).
|
||||||
|
if (Buffer *buf = ctx.editor.CurrentBuffer()) {
|
||||||
|
if (buf->MarkSet()) {
|
||||||
|
buf->ClearMark();
|
||||||
|
ctx.editor.SetStatus("Mark cleared");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
// Otherwise just a hint; renderer will redraw
|
// Otherwise just a hint; renderer will redraw
|
||||||
ctx.editor.SetStatus("");
|
ctx.editor.SetStatus("");
|
||||||
return true;
|
return true;
|
||||||
@@ -1988,21 +2014,44 @@ cmd_insert_text(CommandContext &ctx)
|
|||||||
const std::size_t sy = buf->VisualLineStartY();
|
const std::size_t sy = buf->VisualLineStartY();
|
||||||
const std::size_t ey = buf->VisualLineEndY();
|
const std::size_t ey = buf->VisualLineEndY();
|
||||||
const auto &rows = buf->Rows();
|
const auto &rows = buf->Rows();
|
||||||
|
UndoSystem *u = buf->Undo();
|
||||||
|
std::uint64_t gid = 0;
|
||||||
|
if (u)
|
||||||
|
gid = u->BeginGroup();
|
||||||
|
(void) gid;
|
||||||
|
|
||||||
|
std::string ins;
|
||||||
|
if (repeat == 1) {
|
||||||
|
ins = ctx.arg;
|
||||||
|
} else {
|
||||||
|
ins.reserve(ctx.arg.size() * static_cast<std::size_t>(repeat));
|
||||||
|
for (int i = 0; i < repeat; ++i)
|
||||||
|
ins += ctx.arg;
|
||||||
|
}
|
||||||
for (std::size_t yy = sy; yy <= ey; ++yy) {
|
for (std::size_t yy = sy; yy <= ey; ++yy) {
|
||||||
if (yy >= rows.size())
|
if (yy >= rows.size())
|
||||||
break;
|
break;
|
||||||
std::size_t xx = x;
|
std::size_t xx = x;
|
||||||
if (xx > rows[yy].size())
|
if (xx > rows[yy].size())
|
||||||
xx = rows[yy].size();
|
xx = rows[yy].size();
|
||||||
for (int i = 0; i < repeat; ++i) {
|
if (!ins.empty()) {
|
||||||
buf->insert_text(static_cast<int>(yy), static_cast<int>(xx), std::string_view(ctx.arg));
|
buf->SetCursor(xx, yy);
|
||||||
xx += ctx.arg.size();
|
if (u)
|
||||||
|
u->Begin(UndoType::Insert);
|
||||||
|
buf->insert_text(static_cast<int>(yy), static_cast<int>(xx), std::string_view(ins));
|
||||||
|
xx += ins.size();
|
||||||
|
if (u) {
|
||||||
|
u->Append(std::string_view(ins));
|
||||||
|
u->commit();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (yy == y) {
|
if (yy == y) {
|
||||||
cx = xx;
|
cx = xx;
|
||||||
cy = yy;
|
cy = yy;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (u)
|
||||||
|
u->EndGroup();
|
||||||
buf->SetDirty(true);
|
buf->SetDirty(true);
|
||||||
buf->SetCursor(cx, cy);
|
buf->SetCursor(cx, cy);
|
||||||
ensure_cursor_visible(ctx.editor, *buf);
|
ensure_cursor_visible(ctx.editor, *buf);
|
||||||
@@ -2418,7 +2467,6 @@ cmd_newline(CommandContext &ctx)
|
|||||||
ctx.editor.SetSearchIndex(-1);
|
ctx.editor.SetSearchIndex(-1);
|
||||||
return true;
|
return true;
|
||||||
} else if (kind == Editor::PromptKind::OpenFile) {
|
} else if (kind == Editor::PromptKind::OpenFile) {
|
||||||
std::string err;
|
|
||||||
// Expand "~" to the user's home directory
|
// Expand "~" to the user's home directory
|
||||||
auto expand_user_path = [](const std::string &in) -> std::string {
|
auto expand_user_path = [](const std::string &in) -> std::string {
|
||||||
if (!in.empty() && in[0] == '~') {
|
if (!in.empty() && in[0] == '~') {
|
||||||
@@ -2435,14 +2483,19 @@ cmd_newline(CommandContext &ctx)
|
|||||||
value = expand_user_path(value);
|
value = expand_user_path(value);
|
||||||
if (value.empty()) {
|
if (value.empty()) {
|
||||||
ctx.editor.SetStatus("Open canceled (empty)");
|
ctx.editor.SetStatus("Open canceled (empty)");
|
||||||
} else if (!ctx.editor.OpenFile(value, err)) {
|
|
||||||
ctx.editor.SetStatus(err.empty() ? std::string("Failed to open ") + value : err);
|
|
||||||
} else {
|
} else {
|
||||||
ctx.editor.SetStatus(std::string("Opened ") + value);
|
ctx.editor.RequestOpenFile(value);
|
||||||
// Center the view on the cursor (e.g. if the buffer restored a cursor position)
|
const bool opened = ctx.editor.ProcessPendingOpens();
|
||||||
cmd_center_on_cursor(ctx);
|
if (ctx.editor.PromptActive()) {
|
||||||
// Close the prompt so subsequent typing edits the buffer, not the prompt
|
// A recovery confirmation prompt was started.
|
||||||
ctx.editor.CancelPrompt();
|
return true;
|
||||||
|
}
|
||||||
|
if (opened) {
|
||||||
|
// Center the view on the cursor (e.g. if the buffer restored a cursor position)
|
||||||
|
cmd_center_on_cursor(ctx);
|
||||||
|
// Close the prompt so subsequent typing edits the buffer, not the prompt
|
||||||
|
ctx.editor.CancelPrompt();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else if (kind == Editor::PromptKind::BufferSwitch) {
|
} else if (kind == Editor::PromptKind::BufferSwitch) {
|
||||||
// Resolve to a buffer index by exact match against path or basename;
|
// Resolve to a buffer index by exact match against path or basename;
|
||||||
@@ -2552,11 +2605,19 @@ cmd_newline(CommandContext &ctx)
|
|||||||
}
|
}
|
||||||
if (yes) {
|
if (yes) {
|
||||||
std::string err;
|
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);
|
ctx.editor.SetStatus(err);
|
||||||
} else {
|
} else {
|
||||||
buf->SetDirty(false);
|
buf->SetDirty(false);
|
||||||
ctx.editor.SetStatus("Saved as " + target);
|
if (auto *sm = ctx.editor.Swap()) {
|
||||||
|
if (!is_same_target)
|
||||||
|
sm->NotifyFilenameChanged(*buf);
|
||||||
|
sm->ResetJournal(*buf);
|
||||||
|
}
|
||||||
|
ctx.editor.SetStatus(
|
||||||
|
is_same_target ? ("Saved " + target) : ("Saved as " + target));
|
||||||
if (auto *u = buf->Undo())
|
if (auto *u = buf->Undo())
|
||||||
u->mark_saved();
|
u->mark_saved();
|
||||||
// If this overwrite confirm was part of a close-after-save flow, close now.
|
// If this overwrite confirm was part of a close-after-save flow, close now.
|
||||||
@@ -2589,6 +2650,16 @@ cmd_newline(CommandContext &ctx)
|
|||||||
ctx.editor.ClearPendingOverwritePath();
|
ctx.editor.ClearPendingOverwritePath();
|
||||||
// Regardless of answer, end any close-after-save pending state for safety.
|
// Regardless of answer, end any close-after-save pending state for safety.
|
||||||
ctx.editor.SetCloseAfterSave(false);
|
ctx.editor.SetCloseAfterSave(false);
|
||||||
|
} else if (ctx.editor.PendingRecoveryPrompt() != Editor::RecoveryPromptKind::None) {
|
||||||
|
bool yes = false;
|
||||||
|
if (!value.empty()) {
|
||||||
|
char c = value[0];
|
||||||
|
yes = (c == 'y' || c == 'Y');
|
||||||
|
}
|
||||||
|
(void) ctx.editor.ResolveRecoveryPrompt(yes);
|
||||||
|
ctx.editor.CancelPrompt();
|
||||||
|
// Continue any queued opens (e.g., startup argv files).
|
||||||
|
ctx.editor.ProcessPendingOpens();
|
||||||
} else if (ctx.editor.CloseConfirmPending() && buf) {
|
} else if (ctx.editor.CloseConfirmPending() && buf) {
|
||||||
bool yes = false;
|
bool yes = false;
|
||||||
if (!value.empty()) {
|
if (!value.empty()) {
|
||||||
@@ -2607,6 +2678,8 @@ cmd_newline(CommandContext &ctx)
|
|||||||
proceed_to_close = false;
|
proceed_to_close = false;
|
||||||
} else {
|
} else {
|
||||||
buf->SetDirty(false);
|
buf->SetDirty(false);
|
||||||
|
if (auto *sm = ctx.editor.Swap())
|
||||||
|
sm->ResetJournal(*buf);
|
||||||
if (auto *u = buf->Undo())
|
if (auto *u = buf->Undo())
|
||||||
u->mark_saved();
|
u->mark_saved();
|
||||||
}
|
}
|
||||||
@@ -2616,6 +2689,10 @@ cmd_newline(CommandContext &ctx)
|
|||||||
proceed_to_close = false;
|
proceed_to_close = false;
|
||||||
} else {
|
} else {
|
||||||
buf->SetDirty(false);
|
buf->SetDirty(false);
|
||||||
|
if (auto *sm = ctx.editor.Swap()) {
|
||||||
|
sm->NotifyFilenameChanged(*buf);
|
||||||
|
sm->ResetJournal(*buf);
|
||||||
|
}
|
||||||
if (auto *u = buf->Undo())
|
if (auto *u = buf->Undo())
|
||||||
u->mark_saved();
|
u->mark_saved();
|
||||||
}
|
}
|
||||||
@@ -2652,6 +2729,8 @@ cmd_newline(CommandContext &ctx)
|
|||||||
ctx.editor.SetStatus("No buffer");
|
ctx.editor.SetStatus("No buffer");
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
if (auto *u = buf->Undo())
|
||||||
|
u->commit();
|
||||||
std::size_t nrows = buf->Nrows();
|
std::size_t nrows = buf->Nrows();
|
||||||
if (nrows == 0) {
|
if (nrows == 0) {
|
||||||
buf->SetCursor(0, 0);
|
buf->SetCursor(0, 0);
|
||||||
@@ -2933,26 +3012,41 @@ cmd_backspace(CommandContext &ctx)
|
|||||||
const std::size_t sy = buf->VisualLineStartY();
|
const std::size_t sy = buf->VisualLineStartY();
|
||||||
const std::size_t ey = buf->VisualLineEndY();
|
const std::size_t ey = buf->VisualLineEndY();
|
||||||
const auto &rows = buf->Rows();
|
const auto &rows = buf->Rows();
|
||||||
std::size_t cx = x;
|
std::uint64_t gid = 0;
|
||||||
|
if (u)
|
||||||
|
gid = u->BeginGroup();
|
||||||
|
(void) gid;
|
||||||
|
std::size_t cx = x;
|
||||||
for (std::size_t yy = sy; yy <= ey; ++yy) {
|
for (std::size_t yy = sy; yy <= ey; ++yy) {
|
||||||
if (yy >= rows.size())
|
if (yy >= rows.size())
|
||||||
break;
|
break;
|
||||||
std::size_t xx = x;
|
std::size_t xx = x;
|
||||||
if (xx > rows[yy].size())
|
if (xx > rows[yy].size())
|
||||||
xx = rows[yy].size();
|
xx = rows[yy].size();
|
||||||
|
std::string deleted;
|
||||||
for (int i = 0; i < repeat; ++i) {
|
for (int i = 0; i < repeat; ++i) {
|
||||||
if (xx == 0)
|
if (xx == 0)
|
||||||
break;
|
break;
|
||||||
|
const auto &rows_view = buf->Rows();
|
||||||
|
if (yy < rows_view.size() && (xx - 1) < rows_view[yy].size())
|
||||||
|
deleted.insert(deleted.begin(), rows_view[yy][xx - 1]);
|
||||||
buf->delete_text(static_cast<int>(yy), static_cast<int>(xx - 1), 1);
|
buf->delete_text(static_cast<int>(yy), static_cast<int>(xx - 1), 1);
|
||||||
--xx;
|
--xx;
|
||||||
}
|
}
|
||||||
|
if (u && !deleted.empty()) {
|
||||||
|
buf->SetCursor(xx, yy);
|
||||||
|
u->Begin(UndoType::Delete);
|
||||||
|
u->Append(std::string_view(deleted));
|
||||||
|
u->commit();
|
||||||
|
}
|
||||||
if (yy == y)
|
if (yy == y)
|
||||||
cx = xx;
|
cx = xx;
|
||||||
}
|
}
|
||||||
|
if (u)
|
||||||
|
u->EndGroup();
|
||||||
buf->SetDirty(true);
|
buf->SetDirty(true);
|
||||||
buf->SetCursor(cx, y);
|
buf->SetCursor(cx, y);
|
||||||
ensure_cursor_visible(ctx.editor, *buf);
|
ensure_cursor_visible(ctx.editor, *buf);
|
||||||
(void) u;
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
for (int i = 0; i < repeat; ++i) {
|
for (int i = 0; i < repeat; ++i) {
|
||||||
@@ -3014,21 +3108,35 @@ cmd_delete_char(CommandContext &ctx)
|
|||||||
const std::size_t sy = buf->VisualLineStartY();
|
const std::size_t sy = buf->VisualLineStartY();
|
||||||
const std::size_t ey = buf->VisualLineEndY();
|
const std::size_t ey = buf->VisualLineEndY();
|
||||||
const auto &rows = buf->Rows();
|
const auto &rows = buf->Rows();
|
||||||
|
std::uint64_t gid = 0;
|
||||||
|
if (u)
|
||||||
|
gid = u->BeginGroup();
|
||||||
|
(void) gid;
|
||||||
for (std::size_t yy = sy; yy <= ey; ++yy) {
|
for (std::size_t yy = sy; yy <= ey; ++yy) {
|
||||||
if (yy >= rows.size())
|
if (yy >= rows.size())
|
||||||
break;
|
break;
|
||||||
std::size_t xx = x;
|
std::size_t xx = x;
|
||||||
if (xx > rows[yy].size())
|
if (xx > rows[yy].size())
|
||||||
xx = rows[yy].size();
|
xx = rows[yy].size();
|
||||||
|
std::string deleted;
|
||||||
for (int i = 0; i < repeat; ++i) {
|
for (int i = 0; i < repeat; ++i) {
|
||||||
if (xx >= buf->Rows()[yy].size())
|
const auto &rows_view = buf->Rows();
|
||||||
|
if (yy >= rows_view.size() || xx >= rows_view[yy].size())
|
||||||
break;
|
break;
|
||||||
|
deleted.push_back(rows_view[yy][xx]);
|
||||||
buf->delete_text(static_cast<int>(yy), static_cast<int>(xx), 1);
|
buf->delete_text(static_cast<int>(yy), static_cast<int>(xx), 1);
|
||||||
}
|
}
|
||||||
|
if (u && !deleted.empty()) {
|
||||||
|
buf->SetCursor(xx, yy);
|
||||||
|
u->Begin(UndoType::Delete);
|
||||||
|
u->Append(std::string_view(deleted));
|
||||||
|
u->commit();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
if (u)
|
||||||
|
u->EndGroup();
|
||||||
buf->SetDirty(true);
|
buf->SetDirty(true);
|
||||||
ensure_cursor_visible(ctx.editor, *buf);
|
ensure_cursor_visible(ctx.editor, *buf);
|
||||||
(void) u;
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
for (int i = 0; i < repeat; ++i) {
|
for (int i = 0; i < repeat; ++i) {
|
||||||
@@ -3218,8 +3326,63 @@ cmd_yank(CommandContext &ctx)
|
|||||||
}
|
}
|
||||||
ensure_at_least_one_line(*buf);
|
ensure_at_least_one_line(*buf);
|
||||||
int repeat = ctx.count > 0 ? ctx.count : 1;
|
int repeat = ctx.count > 0 ? ctx.count : 1;
|
||||||
for (int i = 0; i < repeat; ++i) {
|
std::string ins;
|
||||||
insert_text_at_cursor(*buf, text);
|
if (repeat == 1) {
|
||||||
|
ins = text;
|
||||||
|
} else {
|
||||||
|
ins.reserve(text.size() * static_cast<std::size_t>(repeat));
|
||||||
|
for (int i = 0; i < repeat; ++i)
|
||||||
|
ins += text;
|
||||||
|
}
|
||||||
|
|
||||||
|
UndoSystem *u = buf->Undo();
|
||||||
|
// Visual-line mode: broadcast yank to beginning-of-line on every affected line.
|
||||||
|
if (buf->VisualLineActive()) {
|
||||||
|
const std::size_t sy = buf->VisualLineStartY();
|
||||||
|
const std::size_t ey = buf->VisualLineEndY();
|
||||||
|
const std::size_t y0 = buf->Cury();
|
||||||
|
|
||||||
|
std::uint64_t gid = 0;
|
||||||
|
if (u)
|
||||||
|
gid = u->BeginGroup();
|
||||||
|
(void) gid;
|
||||||
|
|
||||||
|
// Iterate from bottom to top so insertions don't invalidate remaining line indices.
|
||||||
|
for (std::size_t yy = ey + 1; yy-- > sy;) {
|
||||||
|
buf->SetCursor(0, yy);
|
||||||
|
if (u)
|
||||||
|
u->Begin(UndoType::Paste);
|
||||||
|
insert_text_at_cursor(*buf, ins);
|
||||||
|
if (u) {
|
||||||
|
u->Append(std::string_view(ins));
|
||||||
|
u->commit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (u)
|
||||||
|
u->EndGroup();
|
||||||
|
|
||||||
|
// Keep the point on the primary cursor line (as it was before yank), at the end of the
|
||||||
|
// inserted text for that line.
|
||||||
|
std::size_t nl_count = 0;
|
||||||
|
std::size_t last_nl = std::string::npos;
|
||||||
|
for (std::size_t i = 0; i < ins.size(); ++i) {
|
||||||
|
if (ins[i] == '\n') {
|
||||||
|
++nl_count;
|
||||||
|
last_nl = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const std::size_t delta_y = nl_count;
|
||||||
|
const std::size_t delta_x = (last_nl == std::string::npos) ? ins.size() : (ins.size() - last_nl - 1);
|
||||||
|
const std::size_t above = (y0 >= sy) ? (y0 - sy) : 0;
|
||||||
|
buf->SetCursor(delta_x, y0 + delta_y + above * nl_count);
|
||||||
|
} else {
|
||||||
|
if (u)
|
||||||
|
u->Begin(UndoType::Paste);
|
||||||
|
insert_text_at_cursor(*buf, ins);
|
||||||
|
if (u) {
|
||||||
|
u->Append(std::string_view(ins));
|
||||||
|
u->commit();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Yank is a paste operation; it should clear the mark/region and any selection highlighting.
|
// Yank is a paste operation; it should clear the mark/region and any selection highlighting.
|
||||||
buf->ClearMark();
|
buf->ClearMark();
|
||||||
@@ -3239,6 +3402,8 @@ cmd_move_file_start(CommandContext &ctx)
|
|||||||
Buffer *buf = ctx.editor.CurrentBuffer();
|
Buffer *buf = ctx.editor.CurrentBuffer();
|
||||||
if (!buf)
|
if (!buf)
|
||||||
return false;
|
return false;
|
||||||
|
if (auto *u = buf->Undo())
|
||||||
|
u->commit();
|
||||||
ensure_at_least_one_line(*buf);
|
ensure_at_least_one_line(*buf);
|
||||||
buf->SetCursor(0, 0);
|
buf->SetCursor(0, 0);
|
||||||
if (buf->VisualLineActive())
|
if (buf->VisualLineActive())
|
||||||
@@ -3254,6 +3419,8 @@ cmd_move_file_end(CommandContext &ctx)
|
|||||||
Buffer *buf = ctx.editor.CurrentBuffer();
|
Buffer *buf = ctx.editor.CurrentBuffer();
|
||||||
if (!buf)
|
if (!buf)
|
||||||
return false;
|
return false;
|
||||||
|
if (auto *u = buf->Undo())
|
||||||
|
u->commit();
|
||||||
ensure_at_least_one_line(*buf);
|
ensure_at_least_one_line(*buf);
|
||||||
const auto &rows = buf->Rows();
|
const auto &rows = buf->Rows();
|
||||||
std::size_t y = rows.empty() ? 0 : rows.size() - 1;
|
std::size_t y = rows.empty() ? 0 : rows.size() - 1;
|
||||||
@@ -3301,6 +3468,8 @@ cmd_jump_to_mark(CommandContext &ctx)
|
|||||||
Buffer *buf = ctx.editor.CurrentBuffer();
|
Buffer *buf = ctx.editor.CurrentBuffer();
|
||||||
if (!buf)
|
if (!buf)
|
||||||
return false;
|
return false;
|
||||||
|
if (auto *u = buf->Undo())
|
||||||
|
u->commit();
|
||||||
if (!buf->MarkSet()) {
|
if (!buf->MarkSet()) {
|
||||||
ctx.editor.SetStatus("Mark not set");
|
ctx.editor.SetStatus("Mark not set");
|
||||||
return false;
|
return false;
|
||||||
@@ -3742,6 +3911,8 @@ cmd_scroll_up(CommandContext &ctx)
|
|||||||
Buffer *buf = ctx.editor.CurrentBuffer();
|
Buffer *buf = ctx.editor.CurrentBuffer();
|
||||||
if (!buf)
|
if (!buf)
|
||||||
return false;
|
return false;
|
||||||
|
if (auto *u = buf->Undo())
|
||||||
|
u->commit();
|
||||||
ensure_at_least_one_line(*buf);
|
ensure_at_least_one_line(*buf);
|
||||||
const auto &rows = buf->Rows();
|
const auto &rows = buf->Rows();
|
||||||
std::size_t content_rows = std::max<std::size_t>(1, ctx.editor.ContentRows());
|
std::size_t content_rows = std::max<std::size_t>(1, ctx.editor.ContentRows());
|
||||||
@@ -3775,6 +3946,8 @@ cmd_scroll_down(CommandContext &ctx)
|
|||||||
Buffer *buf = ctx.editor.CurrentBuffer();
|
Buffer *buf = ctx.editor.CurrentBuffer();
|
||||||
if (!buf)
|
if (!buf)
|
||||||
return false;
|
return false;
|
||||||
|
if (auto *u = buf->Undo())
|
||||||
|
u->commit();
|
||||||
ensure_at_least_one_line(*buf);
|
ensure_at_least_one_line(*buf);
|
||||||
const auto &rows = buf->Rows();
|
const auto &rows = buf->Rows();
|
||||||
std::size_t content_rows = std::max<std::size_t>(1, ctx.editor.ContentRows());
|
std::size_t content_rows = std::max<std::size_t>(1, ctx.editor.ContentRows());
|
||||||
@@ -4139,6 +4312,27 @@ cmd_reflow_paragraph(CommandContext &ctx)
|
|||||||
Buffer *buf = ctx.editor.CurrentBuffer();
|
Buffer *buf = ctx.editor.CurrentBuffer();
|
||||||
if (!buf)
|
if (!buf)
|
||||||
return false;
|
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);
|
ensure_at_least_one_line(*buf);
|
||||||
auto &rows = buf->Rows();
|
auto &rows = buf->Rows();
|
||||||
std::size_t y = buf->Cury();
|
std::size_t y = buf->Cury();
|
||||||
@@ -4321,12 +4515,6 @@ cmd_reflow_paragraph(CommandContext &ctx)
|
|||||||
std::size_t j = i + 1;
|
std::size_t j = i + 1;
|
||||||
while (j <= para_end) {
|
while (j <= para_end) {
|
||||||
std::string ns = static_cast<std::string>(rows[j]);
|
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
|
// stop if next bullet at same indentation or different structure
|
||||||
std::string nindent;
|
std::string nindent;
|
||||||
char nmarker;
|
char nmarker;
|
||||||
@@ -4338,6 +4526,13 @@ cmd_reflow_paragraph(CommandContext &ctx)
|
|||||||
if (is_numbered_line(ns, nindent, nnmarker, nidx)) {
|
if (is_numbered_line(ns, nindent, nnmarker, nidx)) {
|
||||||
break; // next item
|
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)
|
// Not a continuation and not a bullet: stop (treat as separate paragraph chunk)
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
254
Editor.cc
254
Editor.cc
@@ -1,6 +1,7 @@
|
|||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <utility>
|
#include <cstdio>
|
||||||
#include <filesystem>
|
#include <filesystem>
|
||||||
|
#include <utility>
|
||||||
|
|
||||||
#include "Editor.h"
|
#include "Editor.h"
|
||||||
#include "syntax/HighlighterRegistry.h"
|
#include "syntax/HighlighterRegistry.h"
|
||||||
@@ -8,6 +9,41 @@
|
|||||||
#include "syntax/NullHighlighter.h"
|
#include "syntax/NullHighlighter.h"
|
||||||
|
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
static std::string
|
||||||
|
buffer_bytes_via_views(const Buffer &b)
|
||||||
|
{
|
||||||
|
const auto &rows = b.Rows();
|
||||||
|
std::string out;
|
||||||
|
for (std::size_t i = 0; i < rows.size(); i++) {
|
||||||
|
auto v = b.GetLineView(i);
|
||||||
|
out.append(v.data(), v.size());
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
static void
|
||||||
|
apply_pending_line(Editor &ed, const std::size_t line1)
|
||||||
|
{
|
||||||
|
if (line1 == 0)
|
||||||
|
return;
|
||||||
|
Buffer *b = ed.CurrentBuffer();
|
||||||
|
if (!b)
|
||||||
|
return;
|
||||||
|
const std::size_t nrows = b->Nrows();
|
||||||
|
std::size_t line = line1 > 0 ? line1 - 1 : 0; // 1-based to 0-based
|
||||||
|
if (nrows > 0) {
|
||||||
|
if (line >= nrows)
|
||||||
|
line = nrows - 1;
|
||||||
|
} else {
|
||||||
|
line = 0;
|
||||||
|
}
|
||||||
|
b->SetCursor(0, line);
|
||||||
|
}
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
|
||||||
Editor::Editor()
|
Editor::Editor()
|
||||||
{
|
{
|
||||||
swap_ = std::make_unique<kte::SwapManager>();
|
swap_ = std::make_unique<kte::SwapManager>();
|
||||||
@@ -128,8 +164,8 @@ Editor::AddBuffer(const Buffer &buf)
|
|||||||
buffers_.push_back(buf);
|
buffers_.push_back(buf);
|
||||||
// Attach swap recorder
|
// Attach swap recorder
|
||||||
if (swap_) {
|
if (swap_) {
|
||||||
buffers_.back().SetSwapRecorder(swap_.get());
|
|
||||||
swap_->Attach(&buffers_.back());
|
swap_->Attach(&buffers_.back());
|
||||||
|
buffers_.back().SetSwapRecorder(swap_->RecorderFor(&buffers_.back()));
|
||||||
}
|
}
|
||||||
if (buffers_.size() == 1) {
|
if (buffers_.size() == 1) {
|
||||||
curbuf_ = 0;
|
curbuf_ = 0;
|
||||||
@@ -143,8 +179,8 @@ Editor::AddBuffer(Buffer &&buf)
|
|||||||
{
|
{
|
||||||
buffers_.push_back(std::move(buf));
|
buffers_.push_back(std::move(buf));
|
||||||
if (swap_) {
|
if (swap_) {
|
||||||
buffers_.back().SetSwapRecorder(swap_.get());
|
|
||||||
swap_->Attach(&buffers_.back());
|
swap_->Attach(&buffers_.back());
|
||||||
|
buffers_.back().SetSwapRecorder(swap_->RecorderFor(&buffers_.back()));
|
||||||
}
|
}
|
||||||
if (buffers_.size() == 1) {
|
if (buffers_.size() == 1) {
|
||||||
curbuf_ = 0;
|
curbuf_ = 0;
|
||||||
@@ -171,16 +207,16 @@ Editor::OpenFile(const std::string &path, std::string &err)
|
|||||||
return false;
|
return false;
|
||||||
// Ensure swap recorder is attached for this buffer
|
// Ensure swap recorder is attached for this buffer
|
||||||
if (swap_) {
|
if (swap_) {
|
||||||
cur.SetSwapRecorder(swap_.get());
|
|
||||||
swap_->Attach(&cur);
|
swap_->Attach(&cur);
|
||||||
|
cur.SetSwapRecorder(swap_->RecorderFor(&cur));
|
||||||
swap_->NotifyFilenameChanged(cur);
|
swap_->NotifyFilenameChanged(cur);
|
||||||
}
|
}
|
||||||
// Setup highlighting using registry (extension + shebang)
|
// Setup highlighting using registry (extension + shebang)
|
||||||
cur.EnsureHighlighter();
|
cur.EnsureHighlighter();
|
||||||
std::string first = "";
|
std::string first = "";
|
||||||
const auto &rows = cur.Rows();
|
const auto &cur_rows = cur.Rows();
|
||||||
if (!rows.empty())
|
if (!cur_rows.empty())
|
||||||
first = static_cast<std::string>(rows[0]);
|
first = static_cast<std::string>(cur_rows[0]);
|
||||||
std::string ft = kte::HighlighterRegistry::DetectForPath(path, first);
|
std::string ft = kte::HighlighterRegistry::DetectForPath(path, first);
|
||||||
if (!ft.empty()) {
|
if (!ft.empty()) {
|
||||||
cur.SetFiletype(ft);
|
cur.SetFiletype(ft);
|
||||||
@@ -197,22 +233,18 @@ Editor::OpenFile(const std::string &path, std::string &err)
|
|||||||
eng->InvalidateFrom(0);
|
eng->InvalidateFrom(0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Defensive: ensure any active prompt is closed after a successful open
|
// Defensive: ensure any active prompt is closed after a successful open
|
||||||
CancelPrompt();
|
CancelPrompt();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Buffer b;
|
Buffer b;
|
||||||
if (!b.OpenFromFile(path, err)) {
|
if (!b.OpenFromFile(path, err)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (swap_) {
|
// NOTE: swap recorder/attach must happen after the buffer is stored in its
|
||||||
b.SetSwapRecorder(swap_.get());
|
// final location (vector) because swap manager keys off Buffer*.
|
||||||
// path is known, notify
|
|
||||||
swap_->Attach(&b);
|
|
||||||
swap_->NotifyFilenameChanged(b);
|
|
||||||
}
|
|
||||||
// Initialize syntax highlighting by extension + shebang via registry (v2)
|
// Initialize syntax highlighting by extension + shebang via registry (v2)
|
||||||
b.EnsureHighlighter();
|
b.EnsureHighlighter();
|
||||||
std::string first = "";
|
std::string first = "";
|
||||||
@@ -239,10 +271,179 @@ Editor::OpenFile(const std::string &path, std::string &err)
|
|||||||
}
|
}
|
||||||
// Add as a new buffer and switch to it
|
// Add as a new buffer and switch to it
|
||||||
std::size_t idx = AddBuffer(std::move(b));
|
std::size_t idx = AddBuffer(std::move(b));
|
||||||
SwitchTo(idx);
|
if (swap_) {
|
||||||
// Defensive: ensure any active prompt is closed after a successful open
|
swap_->NotifyFilenameChanged(buffers_[idx]);
|
||||||
CancelPrompt();
|
}
|
||||||
return true;
|
SwitchTo(idx);
|
||||||
|
// Defensive: ensure any active prompt is closed after a successful open
|
||||||
|
CancelPrompt();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
void
|
||||||
|
Editor::RequestOpenFile(const std::string &path, const std::size_t line1)
|
||||||
|
{
|
||||||
|
PendingOpen p;
|
||||||
|
p.path = path;
|
||||||
|
p.line1 = line1;
|
||||||
|
pending_open_.push_back(std::move(p));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
bool
|
||||||
|
Editor::HasPendingOpens() const
|
||||||
|
{
|
||||||
|
return !pending_open_.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
Editor::RecoveryPromptKind
|
||||||
|
Editor::PendingRecoveryPrompt() const
|
||||||
|
{
|
||||||
|
return pending_recovery_prompt_;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
void
|
||||||
|
Editor::CancelRecoveryPrompt()
|
||||||
|
{
|
||||||
|
pending_recovery_prompt_ = RecoveryPromptKind::None;
|
||||||
|
pending_recovery_open_ = PendingOpen{};
|
||||||
|
pending_recovery_swap_path_.clear();
|
||||||
|
pending_recovery_replay_err_.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
bool
|
||||||
|
Editor::ResolveRecoveryPrompt(const bool yes)
|
||||||
|
{
|
||||||
|
const RecoveryPromptKind kind = pending_recovery_prompt_;
|
||||||
|
if (kind == RecoveryPromptKind::None)
|
||||||
|
return false;
|
||||||
|
const PendingOpen req = pending_recovery_open_;
|
||||||
|
const std::string swp = pending_recovery_swap_path_;
|
||||||
|
const std::string rerr_s = pending_recovery_replay_err_;
|
||||||
|
CancelRecoveryPrompt();
|
||||||
|
|
||||||
|
std::string err;
|
||||||
|
if (kind == RecoveryPromptKind::RecoverOrDiscard) {
|
||||||
|
if (yes) {
|
||||||
|
if (!OpenFile(req.path, err)) {
|
||||||
|
SetStatus(err);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
Buffer *b = CurrentBuffer();
|
||||||
|
if (!b) {
|
||||||
|
SetStatus("Recovery failed: no buffer");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
std::string rerr;
|
||||||
|
if (!kte::SwapManager::ReplayFile(*b, swp, rerr)) {
|
||||||
|
SetStatus("Swap recovery failed: " + rerr);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
b->SetDirty(true);
|
||||||
|
apply_pending_line(*this, req.line1);
|
||||||
|
SetStatus("Recovered " + req.path);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// Discard: best-effort delete swap, then open clean.
|
||||||
|
(void) std::remove(swp.c_str());
|
||||||
|
if (!OpenFile(req.path, err)) {
|
||||||
|
SetStatus(err);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
apply_pending_line(*this, req.line1);
|
||||||
|
SetStatus("Opened " + req.path);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (kind == RecoveryPromptKind::DeleteCorruptSwap) {
|
||||||
|
if (yes) {
|
||||||
|
(void) std::remove(swp.c_str());
|
||||||
|
}
|
||||||
|
if (!OpenFile(req.path, err)) {
|
||||||
|
SetStatus(err);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
apply_pending_line(*this, req.line1);
|
||||||
|
// Include a short hint that the swap was corrupt.
|
||||||
|
if (!rerr_s.empty()) {
|
||||||
|
SetStatus("Opened " + req.path + " (swap unreadable)");
|
||||||
|
} else {
|
||||||
|
SetStatus("Opened " + req.path);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
bool
|
||||||
|
Editor::ProcessPendingOpens()
|
||||||
|
{
|
||||||
|
if (PromptActive())
|
||||||
|
return false;
|
||||||
|
if (pending_recovery_prompt_ != RecoveryPromptKind::None)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
bool opened_any = false;
|
||||||
|
while (!pending_open_.empty()) {
|
||||||
|
PendingOpen req = std::move(pending_open_.front());
|
||||||
|
pending_open_.pop_front();
|
||||||
|
if (req.path.empty())
|
||||||
|
continue;
|
||||||
|
|
||||||
|
std::string swp = kte::SwapManager::ComputeSwapPathForFilename(req.path);
|
||||||
|
bool swp_exists = false;
|
||||||
|
try {
|
||||||
|
swp_exists = !swp.empty() && std::filesystem::exists(std::filesystem::path(swp));
|
||||||
|
} catch (...) {
|
||||||
|
swp_exists = false;
|
||||||
|
}
|
||||||
|
if (swp_exists) {
|
||||||
|
Buffer tmp;
|
||||||
|
std::string oerr;
|
||||||
|
if (tmp.OpenFromFile(req.path, oerr)) {
|
||||||
|
const std::string orig = buffer_bytes_via_views(tmp);
|
||||||
|
std::string rerr;
|
||||||
|
if (kte::SwapManager::ReplayFile(tmp, swp, rerr)) {
|
||||||
|
const std::string rec = buffer_bytes_via_views(tmp);
|
||||||
|
if (rec != orig) {
|
||||||
|
pending_recovery_prompt_ = RecoveryPromptKind::RecoverOrDiscard;
|
||||||
|
pending_recovery_open_ = req;
|
||||||
|
pending_recovery_swap_path_ = swp;
|
||||||
|
StartPrompt(PromptKind::Confirm, "Recover", "");
|
||||||
|
SetStatus("Recover swap edits for " + req.path + "? (y/N, C-g cancel)");
|
||||||
|
return opened_any;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
pending_recovery_prompt_ = RecoveryPromptKind::DeleteCorruptSwap;
|
||||||
|
pending_recovery_open_ = req;
|
||||||
|
pending_recovery_swap_path_ = swp;
|
||||||
|
pending_recovery_replay_err_ = rerr;
|
||||||
|
StartPrompt(PromptKind::Confirm, "Swap", "");
|
||||||
|
SetStatus(
|
||||||
|
"Swap file unreadable for " + req.path +
|
||||||
|
". Delete it? (y/N, C-g cancel)");
|
||||||
|
return opened_any;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string err;
|
||||||
|
if (!OpenFile(req.path, err)) {
|
||||||
|
SetStatus(err);
|
||||||
|
opened_any = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
apply_pending_line(*this, req.line1);
|
||||||
|
SetStatus("Opened " + req.path);
|
||||||
|
opened_any = true;
|
||||||
|
// Open at most one per call; frontends can call us again next frame.
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return opened_any;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -284,6 +485,13 @@ Editor::CloseBuffer(std::size_t index)
|
|||||||
if (index >= buffers_.size()) {
|
if (index >= buffers_.size()) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
if (swap_) {
|
||||||
|
// Always remove swap file when closing a buffer on normal exit.
|
||||||
|
// Swap files are for crash recovery; on clean close, we don't need them.
|
||||||
|
// This prevents stale swap files from accumulating (e.g., when used as git editor).
|
||||||
|
swap_->Detach(&buffers_[index], true);
|
||||||
|
buffers_[index].SetSwapRecorder(nullptr);
|
||||||
|
}
|
||||||
buffers_.erase(buffers_.begin() + static_cast<std::ptrdiff_t>(index));
|
buffers_.erase(buffers_.begin() + static_cast<std::ptrdiff_t>(index));
|
||||||
if (buffers_.empty()) {
|
if (buffers_.empty()) {
|
||||||
curbuf_ = 0;
|
curbuf_ = 0;
|
||||||
|
|||||||
37
Editor.h
37
Editor.h
@@ -4,6 +4,7 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
#include <cstddef>
|
#include <cstddef>
|
||||||
#include <ctime>
|
#include <ctime>
|
||||||
|
#include <deque>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
@@ -497,6 +498,30 @@ public:
|
|||||||
|
|
||||||
bool OpenFile(const std::string &path, std::string &err);
|
bool OpenFile(const std::string &path, std::string &err);
|
||||||
|
|
||||||
|
// Request that a file be opened. The request is processed by calling
|
||||||
|
// ProcessPendingOpens() (typically once per frontend frame).
|
||||||
|
void RequestOpenFile(const std::string &path, std::size_t line1 = 0);
|
||||||
|
|
||||||
|
// If no modal prompt is active, process queued open requests.
|
||||||
|
// Returns true if a file was opened during this call.
|
||||||
|
bool ProcessPendingOpens();
|
||||||
|
|
||||||
|
[[nodiscard]] bool HasPendingOpens() const;
|
||||||
|
|
||||||
|
// Swap recovery confirmation state. When non-None, a `PromptKind::Confirm`
|
||||||
|
// prompt is active and the user's answer should be routed to ResolveRecoveryPrompt().
|
||||||
|
enum class RecoveryPromptKind {
|
||||||
|
None = 0,
|
||||||
|
RecoverOrDiscard, // y = recover swap, else discard swap and open clean
|
||||||
|
DeleteCorruptSwap // y = delete corrupt swap, else keep it
|
||||||
|
};
|
||||||
|
|
||||||
|
[[nodiscard]] RecoveryPromptKind PendingRecoveryPrompt() const;
|
||||||
|
|
||||||
|
bool ResolveRecoveryPrompt(bool yes);
|
||||||
|
|
||||||
|
void CancelRecoveryPrompt();
|
||||||
|
|
||||||
// Buffer switching/closing
|
// Buffer switching/closing
|
||||||
bool SwitchTo(std::size_t index);
|
bool SwitchTo(std::size_t index);
|
||||||
|
|
||||||
@@ -550,6 +575,11 @@ public:
|
|||||||
}
|
}
|
||||||
|
|
||||||
private:
|
private:
|
||||||
|
struct PendingOpen {
|
||||||
|
std::string path;
|
||||||
|
std::size_t line1{0}; // 1-based; 0 = none
|
||||||
|
};
|
||||||
|
|
||||||
std::size_t rows_ = 0, cols_ = 0;
|
std::size_t rows_ = 0, cols_ = 0;
|
||||||
int mode_ = 0;
|
int mode_ = 0;
|
||||||
int kill_ = 0; // KILL CHAIN
|
int kill_ = 0; // KILL CHAIN
|
||||||
@@ -593,6 +623,13 @@ private:
|
|||||||
std::string prompt_text_;
|
std::string prompt_text_;
|
||||||
std::string pending_overwrite_path_;
|
std::string pending_overwrite_path_;
|
||||||
|
|
||||||
|
// Deferred open + swap recovery prompt state
|
||||||
|
std::deque<PendingOpen> pending_open_;
|
||||||
|
RecoveryPromptKind pending_recovery_prompt_ = RecoveryPromptKind::None;
|
||||||
|
PendingOpen pending_recovery_open_{};
|
||||||
|
std::string pending_recovery_swap_path_;
|
||||||
|
std::string pending_recovery_replay_err_;
|
||||||
|
|
||||||
// GUI-only state (safe no-op in terminal builds)
|
// GUI-only state (safe no-op in terminal builds)
|
||||||
bool file_picker_visible_ = false;
|
bool file_picker_visible_ = false;
|
||||||
std::string file_picker_dir_;
|
std::string file_picker_dir_;
|
||||||
|
|||||||
@@ -298,6 +298,9 @@ GUIFrontend::Step(Editor &ed, bool &running)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Allow deferred opens (including swap recovery prompts) to run.
|
||||||
|
ed.ProcessPendingOpens();
|
||||||
|
|
||||||
// Execute pending mapped inputs (drain queue) AFTER dimensions are updated
|
// Execute pending mapped inputs (drain queue) AFTER dimensions are updated
|
||||||
for (;;) {
|
for (;;) {
|
||||||
MappedInput mi;
|
MappedInput mi;
|
||||||
|
|||||||
@@ -308,14 +308,10 @@ ImGuiRenderer::Draw(Editor &ed)
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Draw selection background (over search highlight; under text)
|
// Draw selection background (over search highlight; under text)
|
||||||
if (sel_active || vsel_active) {
|
if (sel_active) {
|
||||||
bool line_has = false;
|
bool line_has = false;
|
||||||
std::size_t sx = 0, ex = 0;
|
std::size_t sx = 0, ex = 0;
|
||||||
if (vsel_active && i >= vsel_sy && i <= vsel_ey) {
|
if (i < sel_sy || i > sel_ey) {
|
||||||
sx = 0;
|
|
||||||
ex = line.size();
|
|
||||||
line_has = ex > sx;
|
|
||||||
} else if (i < sel_sy || i > sel_ey) {
|
|
||||||
line_has = false;
|
line_has = false;
|
||||||
} else if (sel_sy == sel_ey) {
|
} else if (sel_sy == sel_ey) {
|
||||||
sx = sel_sx;
|
sx = sel_sx;
|
||||||
@@ -351,6 +347,30 @@ ImGuiRenderer::Draw(Editor &ed)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (vsel_active && i >= vsel_sy && i <= vsel_ey) {
|
||||||
|
// Visual-line (multi-cursor) mode: highlight only the per-line cursor spot.
|
||||||
|
const std::size_t spot_sx = std::min(buf->Curx(), line.size());
|
||||||
|
const std::size_t rx_start = src_to_rx(spot_sx);
|
||||||
|
std::size_t rx_end = rx_start;
|
||||||
|
if (spot_sx < line.size()) {
|
||||||
|
rx_end = src_to_rx(spot_sx + 1);
|
||||||
|
} else {
|
||||||
|
// EOL spot: draw a 1-cell highlight just past the last character.
|
||||||
|
rx_end = rx_start + 1;
|
||||||
|
}
|
||||||
|
if (rx_end > coloffs_now) {
|
||||||
|
std::size_t vx0 = (rx_start > coloffs_now)
|
||||||
|
? (rx_start - coloffs_now)
|
||||||
|
: 0;
|
||||||
|
std::size_t vx1 = rx_end - coloffs_now;
|
||||||
|
ImVec2 p0 = ImVec2(line_pos.x + static_cast<float>(vx0) * space_w,
|
||||||
|
line_pos.y);
|
||||||
|
ImVec2 p1 = ImVec2(line_pos.x + static_cast<float>(vx1) * space_w,
|
||||||
|
line_pos.y + line_h);
|
||||||
|
ImU32 col = ImGui::GetColorU32(ImGuiCol_TextSelectedBg);
|
||||||
|
ImGui::GetWindowDrawList()->AddRectFilled(p0, p1, col);
|
||||||
|
}
|
||||||
|
}
|
||||||
// Emit entire line to an expanded buffer (tabs -> spaces)
|
// Emit entire line to an expanded buffer (tabs -> spaces)
|
||||||
for (std::size_t src = 0; src < line.size(); ++src) {
|
for (std::size_t src = 0; src < line.size(); ++src) {
|
||||||
char c = line[src];
|
char c = line[src];
|
||||||
@@ -892,12 +912,8 @@ ImGuiRenderer::Draw(Editor &ed)
|
|||||||
ed.SetFilePickerDir(e.path.string());
|
ed.SetFilePickerDir(e.path.string());
|
||||||
} else if (!e.is_dir && ImGui::IsMouseClicked(ImGuiMouseButton_Left)) {
|
} else if (!e.is_dir && ImGui::IsMouseClicked(ImGuiMouseButton_Left)) {
|
||||||
// Open file on single click
|
// Open file on single click
|
||||||
std::string err;
|
ed.RequestOpenFile(e.path.string());
|
||||||
if (!ed.OpenFile(e.path.string(), err)) {
|
(void) ed.ProcessPendingOpens();
|
||||||
ed.SetStatus(std::string("open: ") + err);
|
|
||||||
} else {
|
|
||||||
ed.SetStatus(std::string("Opened: ") + e.name);
|
|
||||||
}
|
|
||||||
ed.SetFilePickerVisible(false);
|
ed.SetFilePickerVisible(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ KLookupKCommand(const int ascii_key, const bool ctrl, CommandId &out) -> bool
|
|||||||
case 'd':
|
case 'd':
|
||||||
out = CommandId::KillLine;
|
out = CommandId::KillLine;
|
||||||
return true;
|
return true;
|
||||||
|
case 's':
|
||||||
|
out = CommandId::Save;
|
||||||
|
return true;
|
||||||
case 'q':
|
case 'q':
|
||||||
out = CommandId::QuitNow;
|
out = CommandId::QuitNow;
|
||||||
return true;
|
return true;
|
||||||
@@ -42,6 +45,9 @@ KLookupKCommand(const int ascii_key, const bool ctrl, CommandId &out) -> bool
|
|||||||
case 'a':
|
case 'a':
|
||||||
out = CommandId::MarkAllAndJumpEnd;
|
out = CommandId::MarkAllAndJumpEnd;
|
||||||
return true;
|
return true;
|
||||||
|
case ' ': // C-k SPACE
|
||||||
|
out = CommandId::ToggleMark;
|
||||||
|
return true;
|
||||||
case 'i':
|
case 'i':
|
||||||
out = CommandId::BufferNew; // C-k i new empty buffer
|
out = CommandId::BufferNew; // C-k i new empty buffer
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -775,6 +775,9 @@ GUIFrontend::Step(Editor &ed, bool &running)
|
|||||||
if (app_)
|
if (app_)
|
||||||
app_->processEvents();
|
app_->processEvents();
|
||||||
|
|
||||||
|
// Allow deferred opens (including swap recovery prompts) to run.
|
||||||
|
ed.ProcessPendingOpens();
|
||||||
|
|
||||||
// Drain input queue
|
// Drain input queue
|
||||||
for (;;) {
|
for (;;) {
|
||||||
MappedInput mi;
|
MappedInput mi;
|
||||||
@@ -801,14 +804,8 @@ GUIFrontend::Step(Editor &ed, bool &running)
|
|||||||
const QStringList files = dlg.selectedFiles();
|
const QStringList files = dlg.selectedFiles();
|
||||||
if (!files.isEmpty()) {
|
if (!files.isEmpty()) {
|
||||||
const QString fp = files.front();
|
const QString fp = files.front();
|
||||||
std::string err;
|
ed.RequestOpenFile(fp.toStdString());
|
||||||
if (ed.OpenFile(fp.toStdString(), err)) {
|
(void) ed.ProcessPendingOpens();
|
||||||
ed.SetStatus(std::string("Opened: ") + fp.toStdString());
|
|
||||||
} else if (!err.empty()) {
|
|
||||||
ed.SetStatus(std::string("Open failed: ") + err);
|
|
||||||
} else {
|
|
||||||
ed.SetStatus("Open failed");
|
|
||||||
}
|
|
||||||
// Update picker dir for next time
|
// Update picker dir for next time
|
||||||
QFileInfo info(fp);
|
QFileInfo info(fp);
|
||||||
ed.SetFilePickerDir(info.dir().absolutePath().toStdString());
|
ed.SetFilePickerDir(info.dir().absolutePath().toStdString());
|
||||||
|
|||||||
33
README.md
33
README.md
@@ -32,7 +32,8 @@ Project Goals
|
|||||||
|
|
||||||
Keybindings
|
Keybindings
|
||||||
-----------
|
-----------
|
||||||
kte maintains ke’s command model while internals evolve. Highlights (subject to refinement):
|
kte maintains ke’s command model while internals evolve. Highlights (
|
||||||
|
subject to refinement):
|
||||||
|
|
||||||
- K‑command prefix: `C-k` enters k‑command mode; exit with `ESC` or
|
- K‑command prefix: `C-k` enters k‑command mode; exit with `ESC` or
|
||||||
`C-g`.
|
`C-g`.
|
||||||
@@ -52,7 +53,8 @@ See `ke.md` for the canonical ke reference retained for now.
|
|||||||
|
|
||||||
Build and Run
|
Build and Run
|
||||||
-------------
|
-------------
|
||||||
Prerequisites: C++17 compiler, CMake, and ncurses development headers/libs.
|
Prerequisites: C++20 compiler, CMake, and ncurses development
|
||||||
|
headers/libs.
|
||||||
|
|
||||||
Dependencies by platform
|
Dependencies by platform
|
||||||
------------------------
|
------------------------
|
||||||
@@ -62,30 +64,38 @@ Dependencies by platform
|
|||||||
- `brew install ncurses`
|
- `brew install ncurses`
|
||||||
- Optional GUI (enable with `-DBUILD_GUI=ON`):
|
- Optional GUI (enable with `-DBUILD_GUI=ON`):
|
||||||
- `brew install sdl2 freetype`
|
- `brew install sdl2 freetype`
|
||||||
- OpenGL is provided by the system framework on macOS; no package needed.
|
- OpenGL is provided by the system framework on macOS; no
|
||||||
|
package needed.
|
||||||
|
|
||||||
- Debian/Ubuntu
|
- Debian/Ubuntu
|
||||||
- Terminal (default):
|
- Terminal (default):
|
||||||
- `sudo apt-get install -y libncurses5-dev libncursesw5-dev`
|
- `sudo apt-get install -y libncurses5-dev libncursesw5-dev`
|
||||||
- Optional GUI (enable with `-DBUILD_GUI=ON`):
|
- Optional GUI (enable with `-DBUILD_GUI=ON`):
|
||||||
- `sudo apt-get install -y libsdl2-dev libfreetype6-dev mesa-common-dev`
|
-
|
||||||
- The `mesa-common-dev` package provides OpenGL headers/libs (`libGL`).
|
`sudo apt-get install -y libsdl2-dev libfreetype6-dev mesa-common-dev`
|
||||||
|
- The `mesa-common-dev` package provides OpenGL headers/libs (
|
||||||
|
`libGL`).
|
||||||
|
|
||||||
- NixOS/Nix
|
- NixOS/Nix
|
||||||
- Terminal (default):
|
- Terminal (default):
|
||||||
- Ad-hoc shell: `nix-shell -p cmake gcc ncurses`
|
- Ad-hoc shell: `nix-shell -p cmake gcc ncurses`
|
||||||
- Optional GUI (enable with `-DBUILD_GUI=ON`):
|
- Optional GUI (enable with `-DBUILD_GUI=ON`):
|
||||||
- Ad-hoc shell: `nix-shell -p cmake gcc ncurses SDL2 freetype libGL`
|
- Ad-hoc shell:
|
||||||
- With flakes/devshell (example `flake.nix` inputs not provided): include
|
`nix-shell -p cmake gcc ncurses SDL2 freetype libGL`
|
||||||
`ncurses` for TUI, and `SDL2`, `freetype`, `libGL` for GUI in your devShell.
|
- With flakes/devshell (example `flake.nix` inputs not provided):
|
||||||
|
include
|
||||||
|
`ncurses` for TUI, and `SDL2`, `freetype`, `libGL` for GUI in your
|
||||||
|
devShell.
|
||||||
|
|
||||||
Notes
|
Notes
|
||||||
-----
|
-----
|
||||||
|
|
||||||
- The GUI is OFF by default to keep SDL/OpenGL/Freetype optional. Enable it by
|
- The GUI is OFF by default to keep SDL/OpenGL/Freetype optional. Enable
|
||||||
|
it by
|
||||||
configuring with `-DBUILD_GUI=ON` and ensuring the GUI deps above are
|
configuring with `-DBUILD_GUI=ON` and ensuring the GUI deps above are
|
||||||
installed for your platform.
|
installed for your platform.
|
||||||
- If you previously configured with GUI ON and want to disable it, reconfigure
|
- If you previously configured with GUI ON and want to disable it,
|
||||||
|
reconfigure
|
||||||
the build directory with `-DBUILD_GUI=OFF`.
|
the build directory with `-DBUILD_GUI=OFF`.
|
||||||
|
|
||||||
Example build:
|
Example build:
|
||||||
@@ -113,7 +123,8 @@ built as `kge`) or request the GUI from `kte`:
|
|||||||
GUI build example
|
GUI build example
|
||||||
-----------------
|
-----------------
|
||||||
|
|
||||||
To build with the optional GUI (after installing the GUI dependencies listed above):
|
To build with the optional GUI (after installing the GUI dependencies
|
||||||
|
listed above):
|
||||||
|
|
||||||
```
|
```
|
||||||
cmake -S . -B cmake-build-debug -DCMAKE_BUILD_TYPE=Debug -DBUILD_GUI=ON
|
cmake -S . -B cmake-build-debug -DCMAKE_BUILD_TYPE=Debug -DBUILD_GUI=ON
|
||||||
|
|||||||
145
Swap.h
145
Swap.h
@@ -7,11 +7,14 @@
|
|||||||
#include <string_view>
|
#include <string_view>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
#include <unordered_map>
|
#include <unordered_map>
|
||||||
|
#include <memory>
|
||||||
#include <mutex>
|
#include <mutex>
|
||||||
#include <condition_variable>
|
#include <condition_variable>
|
||||||
#include <thread>
|
#include <thread>
|
||||||
#include <atomic>
|
#include <atomic>
|
||||||
|
|
||||||
|
#include "SwapRecorder.h"
|
||||||
|
|
||||||
class Buffer;
|
class Buffer;
|
||||||
|
|
||||||
namespace kte {
|
namespace kte {
|
||||||
@@ -29,50 +32,88 @@ struct SwapConfig {
|
|||||||
// Grouping and durability knobs (stage 1 defaults)
|
// Grouping and durability knobs (stage 1 defaults)
|
||||||
unsigned flush_interval_ms{200}; // group small writes
|
unsigned flush_interval_ms{200}; // group small writes
|
||||||
unsigned fsync_interval_ms{1000}; // at most once per second
|
unsigned fsync_interval_ms{1000}; // at most once per second
|
||||||
};
|
|
||||||
|
|
||||||
// Lightweight interface that Buffer can call without depending on full manager impl
|
// Checkpoint/compaction knobs (stage 2 defaults)
|
||||||
class SwapRecorder {
|
// A checkpoint is a full snapshot of the buffer content written as a CHKPT record.
|
||||||
public:
|
// Compaction rewrites the swap file to contain just the latest checkpoint.
|
||||||
virtual ~SwapRecorder() = default;
|
std::size_t checkpoint_bytes{1024 * 1024}; // request checkpoint after this many queued edit-bytes
|
||||||
|
unsigned checkpoint_interval_ms{60000}; // request checkpoint at least this often while editing
|
||||||
|
std::size_t compact_bytes{8 * 1024 * 1024}; // compact on checkpoint once journal grows beyond this
|
||||||
|
|
||||||
virtual void RecordInsert(Buffer &buf, int row, int col, std::string_view text) = 0;
|
// Cleanup / retention (best-effort)
|
||||||
|
bool prune_on_startup{true};
|
||||||
virtual void RecordDelete(Buffer &buf, int row, int col, std::size_t len) = 0;
|
unsigned prune_max_age_days{30};
|
||||||
|
std::size_t prune_max_files{2048};
|
||||||
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.
|
// SwapManager manages sidecar swap files and a single background writer thread.
|
||||||
class SwapManager final : public SwapRecorder {
|
class SwapManager final {
|
||||||
public:
|
public:
|
||||||
SwapManager();
|
SwapManager();
|
||||||
|
|
||||||
~SwapManager() override;
|
~SwapManager();
|
||||||
|
|
||||||
// Attach a buffer to begin journaling. Safe to call multiple times; idempotent.
|
// Attach a buffer to begin journaling. Safe to call multiple times; idempotent.
|
||||||
void Attach(Buffer *buf);
|
void Attach(Buffer *buf);
|
||||||
|
|
||||||
// Detach and close journal.
|
// Detach and close journal.
|
||||||
void Detach(Buffer *buf);
|
// If remove_file is true, the swap file is deleted after closing.
|
||||||
|
// Intended for clean shutdown/close flows.
|
||||||
|
void Detach(Buffer *buf, bool remove_file = false);
|
||||||
|
|
||||||
// SwapRecorder: Notify that the buffer's filename changed (e.g., SaveAs)
|
// Reset (truncate-by-delete) the journal for a buffer after a clean save.
|
||||||
void NotifyFilenameChanged(Buffer &buf) override;
|
// Best-effort: closes the current fd, deletes the swap file, and resumes recording.
|
||||||
|
void ResetJournal(Buffer &buf);
|
||||||
|
|
||||||
// SwapRecorder
|
// Best-effort pruning of old swap files under the swap directory.
|
||||||
void RecordInsert(Buffer &buf, int row, int col, std::string_view text) override;
|
// Never touches non-`.swp` files.
|
||||||
|
void PruneSwapDir();
|
||||||
|
|
||||||
void RecordDelete(Buffer &buf, int row, int col, std::size_t len) 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);
|
||||||
|
|
||||||
void RecordSplit(Buffer &buf, int row, int col) override;
|
// Request a full-content checkpoint record for one buffer (or all buffers if buf is null).
|
||||||
|
// This is best-effort and asynchronous; call Flush() if you need it written before continuing.
|
||||||
|
void Checkpoint(Buffer *buf = nullptr);
|
||||||
|
|
||||||
void RecordJoin(Buffer &buf, int row) override;
|
|
||||||
|
void SetConfig(const SwapConfig &cfg)
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lg(mtx_);
|
||||||
|
cfg_ = cfg;
|
||||||
|
cv_.notify_one();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
|
||||||
|
// Notify that the buffer's filename changed (e.g., SaveAs)
|
||||||
|
void NotifyFilenameChanged(Buffer &buf);
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
|
||||||
|
// Compute the swap path for a file-backed buffer by filename.
|
||||||
|
// Returns empty string if filename is empty.
|
||||||
|
static std::string ComputeSwapPathForFilename(const std::string &filename);
|
||||||
|
|
||||||
|
// 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
|
// RAII guard to suspend recording for internal operations
|
||||||
class SuspendGuard {
|
class SuspendGuard {
|
||||||
@@ -88,17 +129,44 @@ public:
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Per-buffer toggle
|
// Per-buffer toggle
|
||||||
void SetSuspended(Buffer &buf, bool on) override;
|
void SetSuspended(Buffer &buf, bool on);
|
||||||
|
|
||||||
private:
|
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);
|
||||||
|
|
||||||
|
void RecordCheckpoint(Buffer &buf, bool urgent_flush);
|
||||||
|
|
||||||
|
void maybe_request_checkpoint(Buffer &buf, std::size_t approx_edit_bytes);
|
||||||
|
|
||||||
struct JournalCtx {
|
struct JournalCtx {
|
||||||
std::string path;
|
std::string path;
|
||||||
void *file{nullptr}; // FILE*
|
|
||||||
int fd{-1};
|
int fd{-1};
|
||||||
bool header_ok{false};
|
bool header_ok{false};
|
||||||
bool suspended{false};
|
bool suspended{false};
|
||||||
std::uint64_t last_flush_ns{0};
|
std::uint64_t last_flush_ns{0};
|
||||||
std::uint64_t last_fsync_ns{0};
|
std::uint64_t last_fsync_ns{0};
|
||||||
|
std::uint64_t last_chkpt_ns{0};
|
||||||
|
std::uint64_t edit_bytes_since_chkpt{0};
|
||||||
|
std::uint64_t approx_size_bytes{0};
|
||||||
};
|
};
|
||||||
|
|
||||||
struct Pending {
|
struct Pending {
|
||||||
@@ -106,26 +174,35 @@ private:
|
|||||||
SwapRecType type{SwapRecType::INS};
|
SwapRecType type{SwapRecType::INS};
|
||||||
std::vector<std::uint8_t> payload; // framed payload only
|
std::vector<std::uint8_t> payload; // framed payload only
|
||||||
bool urgent_flush{false};
|
bool urgent_flush{false};
|
||||||
|
std::uint64_t seq{0};
|
||||||
};
|
};
|
||||||
|
|
||||||
// Helpers
|
// Helpers
|
||||||
static std::string ComputeSidecarPath(const Buffer &buf);
|
static std::string ComputeSidecarPath(const Buffer &buf);
|
||||||
|
|
||||||
|
static std::string ComputeSidecarPathForFilename(const std::string &filename);
|
||||||
|
|
||||||
static std::uint64_t now_ns();
|
static std::uint64_t now_ns();
|
||||||
|
|
||||||
static bool ensure_parent_dir(const std::string &path);
|
static bool ensure_parent_dir(const std::string &path);
|
||||||
|
|
||||||
static bool write_header(JournalCtx &ctx);
|
static std::string SwapDirRoot();
|
||||||
|
|
||||||
static bool open_ctx(JournalCtx &ctx);
|
static bool write_header(int fd);
|
||||||
|
|
||||||
|
static bool open_ctx(JournalCtx &ctx, const std::string &path);
|
||||||
|
|
||||||
static void close_ctx(JournalCtx &ctx);
|
static void close_ctx(JournalCtx &ctx);
|
||||||
|
|
||||||
|
static bool compact_to_checkpoint(JournalCtx &ctx, const std::vector<std::uint8_t> &chkpt_record);
|
||||||
|
|
||||||
static std::uint32_t crc32(const std::uint8_t *data, std::size_t len, std::uint32_t seed = 0);
|
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);
|
void enqueue(Pending &&p);
|
||||||
|
|
||||||
@@ -136,9 +213,13 @@ private:
|
|||||||
// State
|
// State
|
||||||
SwapConfig cfg_{};
|
SwapConfig cfg_{};
|
||||||
std::unordered_map<Buffer *, JournalCtx> journals_;
|
std::unordered_map<Buffer *, JournalCtx> journals_;
|
||||||
|
std::unordered_map<Buffer *, std::unique_ptr<BufferRecorder> > recorders_;
|
||||||
std::mutex mtx_;
|
std::mutex mtx_;
|
||||||
std::condition_variable cv_;
|
std::condition_variable cv_;
|
||||||
std::vector<Pending> queue_;
|
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::atomic<bool> running_{false};
|
||||||
std::thread worker_;
|
std::thread worker_;
|
||||||
};
|
};
|
||||||
|
|||||||
19
SwapRecorder.h
Normal file
19
SwapRecorder.h
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
// SwapRecorder.h - minimal swap journal recording interface for Buffer mutations
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <cstddef>
|
||||||
|
#include <string_view>
|
||||||
|
|
||||||
|
namespace kte {
|
||||||
|
// SwapRecorder is a tiny, non-blocking callback interface.
|
||||||
|
// Implementations must return quickly; Buffer calls these hooks after a
|
||||||
|
// mutation succeeds.
|
||||||
|
class SwapRecorder {
|
||||||
|
public:
|
||||||
|
virtual ~SwapRecorder() = default;
|
||||||
|
|
||||||
|
virtual void OnInsert(int row, int col, std::string_view bytes) = 0;
|
||||||
|
|
||||||
|
virtual void OnDelete(int row, int col, std::size_t len) = 0;
|
||||||
|
};
|
||||||
|
} // namespace kte
|
||||||
@@ -94,6 +94,9 @@ TerminalFrontend::Step(Editor &ed, bool &running)
|
|||||||
}
|
}
|
||||||
ed.SetDimensions(static_cast<std::size_t>(r), static_cast<std::size_t>(c));
|
ed.SetDimensions(static_cast<std::size_t>(r), static_cast<std::size_t>(c));
|
||||||
|
|
||||||
|
// Allow deferred opens (including swap recovery prompts) to run.
|
||||||
|
ed.ProcessPendingOpens();
|
||||||
|
|
||||||
MappedInput mi;
|
MappedInput mi;
|
||||||
if (input_.Poll(mi)) {
|
if (input_.Poll(mi)) {
|
||||||
if (mi.hasCommand) {
|
if (mi.hasCommand) {
|
||||||
|
|||||||
@@ -126,12 +126,7 @@ TerminalRenderer::Draw(Editor &ed)
|
|||||||
const bool vsel_active = buf->VisualLineActive();
|
const bool vsel_active = buf->VisualLineActive();
|
||||||
const std::size_t vsel_sy = vsel_active ? buf->VisualLineStartY() : 0;
|
const std::size_t vsel_sy = vsel_active ? buf->VisualLineStartY() : 0;
|
||||||
const std::size_t vsel_ey = vsel_active ? buf->VisualLineEndY() : 0;
|
const std::size_t vsel_ey = vsel_active ? buf->VisualLineEndY() : 0;
|
||||||
auto is_src_in_sel = [&](std::size_t y, std::size_t sx) -> bool {
|
auto is_src_in_mark_sel = [&](std::size_t y, std::size_t sx) -> bool {
|
||||||
(void) sx;
|
|
||||||
if (vsel_active) {
|
|
||||||
if (y >= vsel_sy && y <= vsel_ey)
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (!sel_active)
|
if (!sel_active)
|
||||||
return false;
|
return false;
|
||||||
if (y < sel_sy || y > sel_ey)
|
if (y < sel_sy || y > sel_ey)
|
||||||
@@ -146,9 +141,48 @@ TerminalRenderer::Draw(Editor &ed)
|
|||||||
};
|
};
|
||||||
int written = 0;
|
int written = 0;
|
||||||
if (li < lines.size()) {
|
if (li < lines.size()) {
|
||||||
std::string line = static_cast<std::string>(lines[li]);
|
std::string line = static_cast<std::string>(lines[li]);
|
||||||
src_i = 0;
|
const bool vsel_on_line = vsel_active && li >= vsel_sy && li <= vsel_ey;
|
||||||
render_col = 0;
|
const std::size_t vsel_spot_src = vsel_on_line
|
||||||
|
? std::min(buf->Curx(), line.size())
|
||||||
|
: 0;
|
||||||
|
const bool vsel_spot_is_eol = vsel_on_line && vsel_spot_src == line.size();
|
||||||
|
std::size_t vsel_line_rx = 0;
|
||||||
|
if (vsel_spot_is_eol) {
|
||||||
|
// Compute the rendered (column) width of the line so we can highlight a
|
||||||
|
// single cell at EOL when the spot falls beyond the last character.
|
||||||
|
std::size_t rc = 0;
|
||||||
|
std::size_t si = 0;
|
||||||
|
while (si < line.size()) {
|
||||||
|
wchar_t wch = L' ';
|
||||||
|
int wch_len = 1;
|
||||||
|
std::mbstate_t state = std::mbstate_t();
|
||||||
|
size_t res = std::mbrtowc(&wch, &line[si], line.size() - si, &state);
|
||||||
|
if (res == (size_t) -1 || res == (size_t) -2) {
|
||||||
|
wch = static_cast<unsigned char>(line[si]);
|
||||||
|
wch_len = 1;
|
||||||
|
} else if (res == 0) {
|
||||||
|
wch = L'\0';
|
||||||
|
wch_len = 1;
|
||||||
|
} else {
|
||||||
|
wch_len = static_cast<int>(res);
|
||||||
|
}
|
||||||
|
if (wch == L'\t') {
|
||||||
|
constexpr std::size_t tab_width = 8;
|
||||||
|
const std::size_t next_tab = tab_width - (rc % tab_width);
|
||||||
|
rc += next_tab;
|
||||||
|
} else {
|
||||||
|
int w = wcwidth(wch);
|
||||||
|
if (w < 0)
|
||||||
|
w = 1;
|
||||||
|
rc += static_cast<std::size_t>(w);
|
||||||
|
}
|
||||||
|
si += static_cast<std::size_t>(wch_len);
|
||||||
|
}
|
||||||
|
vsel_line_rx = rc;
|
||||||
|
}
|
||||||
|
src_i = 0;
|
||||||
|
render_col = 0;
|
||||||
// Syntax highlighting: fetch per-line spans (sanitized copy)
|
// Syntax highlighting: fetch per-line spans (sanitized copy)
|
||||||
std::vector<kte::HighlightSpan> sane_spans;
|
std::vector<kte::HighlightSpan> sane_spans;
|
||||||
if (buf->SyntaxEnabled() && buf->Highlighter() && buf->Highlighter()->
|
if (buf->SyntaxEnabled() && buf->Highlighter() && buf->Highlighter()->
|
||||||
@@ -247,7 +281,11 @@ TerminalRenderer::Draw(Editor &ed)
|
|||||||
}
|
}
|
||||||
// Now render visible spaces
|
// Now render visible spaces
|
||||||
while (next_tab > 0 && written < cols) {
|
while (next_tab > 0 && written < cols) {
|
||||||
bool in_sel = is_src_in_sel(li, src_i);
|
bool in_mark = is_src_in_mark_sel(li, src_i);
|
||||||
|
bool in_vsel =
|
||||||
|
vsel_on_line && !vsel_spot_is_eol && src_i ==
|
||||||
|
vsel_spot_src;
|
||||||
|
bool in_sel = in_mark || in_vsel;
|
||||||
bool in_hl = search_mode && is_src_in_hl(src_i);
|
bool in_hl = search_mode && is_src_in_hl(src_i);
|
||||||
bool in_cur =
|
bool in_cur =
|
||||||
has_current && li == cur_my && src_i >= cur_mx
|
has_current && li == cur_my && src_i >= cur_mx
|
||||||
@@ -297,7 +335,16 @@ TerminalRenderer::Draw(Editor &ed)
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool in_sel = from_src && is_src_in_sel(li, src_i);
|
bool in_mark = from_src && is_src_in_mark_sel(li, src_i);
|
||||||
|
bool in_vsel = false;
|
||||||
|
if (vsel_on_line) {
|
||||||
|
if (from_src) {
|
||||||
|
in_vsel = !vsel_spot_is_eol && src_i == vsel_spot_src;
|
||||||
|
} else {
|
||||||
|
in_vsel = vsel_spot_is_eol && render_col == vsel_line_rx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bool in_sel = in_mark || in_vsel;
|
||||||
bool in_hl = search_mode && from_src && is_src_in_hl(src_i);
|
bool in_hl = search_mode && from_src && is_src_in_hl(src_i);
|
||||||
bool in_cur = has_current && li == cur_my && from_src && src_i >= cur_mx &&
|
bool in_cur = has_current && li == cur_my && from_src && src_i >= cur_mx &&
|
||||||
src_i < cur_mend;
|
src_i < cur_mend;
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ TestFrontend::Init(int &argc, char **argv, Editor &ed)
|
|||||||
void
|
void
|
||||||
TestFrontend::Step(Editor &ed, bool &running)
|
TestFrontend::Step(Editor &ed, bool &running)
|
||||||
{
|
{
|
||||||
|
// Allow deferred opens (including swap recovery prompts) to run.
|
||||||
|
ed.ProcessPendingOpens();
|
||||||
|
|
||||||
MappedInput mi;
|
MappedInput mi;
|
||||||
if (input_.Poll(mi)) {
|
if (input_.Poll(mi)) {
|
||||||
if (mi.hasCommand) {
|
if (mi.hasCommand) {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ struct UndoNode {
|
|||||||
UndoType type{};
|
UndoType type{};
|
||||||
int row{};
|
int row{};
|
||||||
int col{};
|
int col{};
|
||||||
|
std::uint64_t group_id = 0; // 0 means ungrouped; non-zero means undo/redo as an atomic group
|
||||||
std::string text;
|
std::string text;
|
||||||
UndoNode *parent = nullptr; // previous state; null means pre-first-edit
|
UndoNode *parent = nullptr; // previous state; null means pre-first-edit
|
||||||
UndoNode *child = nullptr; // next in current timeline
|
UndoNode *child = nullptr; // next in current timeline
|
||||||
|
|||||||
@@ -8,6 +8,25 @@ UndoSystem::UndoSystem(Buffer &owner, UndoTree &tree)
|
|||||||
: buf_(&owner), tree_(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
|
void
|
||||||
UndoSystem::Begin(UndoType type)
|
UndoSystem::Begin(UndoType type)
|
||||||
{
|
{
|
||||||
@@ -64,10 +83,11 @@ UndoSystem::Begin(UndoType type)
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Start a new pending node.
|
// Start a new pending node.
|
||||||
tree_.pending = new UndoNode{};
|
tree_.pending = new UndoNode{};
|
||||||
tree_.pending->type = type;
|
tree_.pending->type = type;
|
||||||
tree_.pending->row = row;
|
tree_.pending->row = row;
|
||||||
tree_.pending->col = col;
|
tree_.pending->col = col;
|
||||||
|
tree_.pending->group_id = active_group_id_;
|
||||||
tree_.pending->text.clear();
|
tree_.pending->text.clear();
|
||||||
tree_.pending->parent = nullptr;
|
tree_.pending->parent = nullptr;
|
||||||
tree_.pending->child = nullptr;
|
tree_.pending->child = nullptr;
|
||||||
@@ -158,8 +178,12 @@ UndoSystem::undo()
|
|||||||
if (!tree_.current)
|
if (!tree_.current)
|
||||||
return;
|
return;
|
||||||
debug_log("undo");
|
debug_log("undo");
|
||||||
apply(tree_.current, -1);
|
const std::uint64_t gid = tree_.current->group_id;
|
||||||
tree_.current = tree_.current->parent;
|
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();
|
update_dirty_flag();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,8 +219,16 @@ UndoSystem::redo(int branch_index)
|
|||||||
}
|
}
|
||||||
|
|
||||||
debug_log("redo");
|
debug_log("redo");
|
||||||
apply(*head, +1);
|
UndoNode *node = *head;
|
||||||
tree_.current = *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();
|
update_dirty_flag();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -226,9 +258,11 @@ UndoSystem::clear()
|
|||||||
{
|
{
|
||||||
discard_pending();
|
discard_pending();
|
||||||
free_node(tree_.root);
|
free_node(tree_.root);
|
||||||
tree_.root = nullptr;
|
tree_.root = nullptr;
|
||||||
tree_.current = nullptr;
|
tree_.current = nullptr;
|
||||||
tree_.saved = nullptr;
|
tree_.saved = nullptr;
|
||||||
|
active_group_id_ = 0;
|
||||||
|
next_group_id_ = 1;
|
||||||
update_dirty_flag();
|
update_dirty_flag();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,12 @@ class UndoSystem {
|
|||||||
public:
|
public:
|
||||||
explicit UndoSystem(Buffer &owner, UndoTree &tree);
|
explicit UndoSystem(Buffer &owner, UndoTree &tree);
|
||||||
|
|
||||||
|
// Begin an atomic group: subsequent committed nodes with the same group_id will be
|
||||||
|
// undone/redone as a single step. Returns the active group id.
|
||||||
|
std::uint64_t BeginGroup();
|
||||||
|
|
||||||
|
void EndGroup();
|
||||||
|
|
||||||
void Begin(UndoType type);
|
void Begin(UndoType type);
|
||||||
|
|
||||||
void Append(char ch);
|
void Append(char ch);
|
||||||
@@ -66,6 +72,9 @@ private:
|
|||||||
|
|
||||||
PendingAppendMode pending_mode_ = PendingAppendMode::Append;
|
PendingAppendMode pending_mode_ = PendingAppendMode::Append;
|
||||||
|
|
||||||
|
std::uint64_t active_group_id_ = 0;
|
||||||
|
std::uint64_t next_group_id_ = 1;
|
||||||
|
|
||||||
Buffer *buf_;
|
Buffer *buf_;
|
||||||
UndoTree &tree_;
|
UndoTree &tree_;
|
||||||
};
|
};
|
||||||
@@ -12,11 +12,14 @@ Goals
|
|||||||
|
|
||||||
Model overview
|
Model overview
|
||||||
--------------
|
--------------
|
||||||
Per open buffer, maintain a sidecar swap journal next to the file:
|
Per open buffer, maintain a swap journal in a per-user state directory:
|
||||||
|
|
||||||
- Path: `.<basename>.kte.swp` in the same directory as the file (for
|
- Path: `$XDG_STATE_HOME/kte/swap/<encoded-path>.swp` (or
|
||||||
unnamed/unsaved buffers, use a per‑session temp dir like
|
`~/.local/state/kte/swap/...`)
|
||||||
`$TMPDIR/kte/` with a random UUID).
|
where `<encoded-path>` is the file path with separators replaced (e.g.
|
||||||
|
`/home/kyle/tmp/test.txt` → `home!kyle!tmp!test.txt.swp`).
|
||||||
|
Unnamed/unsaved
|
||||||
|
buffers use a unique `unnamed-<pid>-<counter>.swp` name.
|
||||||
- Format: append‑only journal of editing operations with periodic
|
- Format: append‑only journal of editing operations with periodic
|
||||||
checkpoints.
|
checkpoints.
|
||||||
- Crash safety: only append, fsync as per policy; checkpoint via
|
- Crash safety: only append, fsync as per policy; checkpoint via
|
||||||
@@ -84,7 +87,7 @@ Recovery flow
|
|||||||
|
|
||||||
On opening a file:
|
On opening a file:
|
||||||
|
|
||||||
1. Detect swap sidecar `.<basename>.kte.swp`.
|
1. Detect swap journal `$XDG_STATE_HOME/kte/swap/<encoded-path>.swp`.
|
||||||
2. Validate header, iterate records verifying CRCs.
|
2. Validate header, iterate records verifying CRCs.
|
||||||
3. Compare recorded original file identity against actual file; if
|
3. Compare recorded original file identity against actual file; if
|
||||||
mismatch, warn user but allow recovery (content wins).
|
mismatch, warn user but allow recovery (content wins).
|
||||||
@@ -98,7 +101,7 @@ Stability & corruption mitigation
|
|||||||
---------------------------------
|
---------------------------------
|
||||||
|
|
||||||
- Append‑only with per‑record CRC32 guards against torn writes.
|
- Append‑only with per‑record CRC32 guards against torn writes.
|
||||||
- Atomic checkpoint rotation: write `.<basename>.kte.swp.tmp`, fsync,
|
- Atomic checkpoint rotation: write `<encoded-path>.swp.tmp`, fsync,
|
||||||
then rename over old `.swp`.
|
then rename over old `.swp`.
|
||||||
- Size caps: rotate or compact when `.swp` exceeds a threshold (e.g.,
|
- Size caps: rotate or compact when `.swp` exceeds a threshold (e.g.,
|
||||||
64–128 MB). Compaction creates a fresh file with a single checkpoint.
|
64–128 MB). Compaction creates a fresh file with a single checkpoint.
|
||||||
@@ -117,8 +120,8 @@ Security considerations
|
|||||||
Interoperability & UX
|
Interoperability & UX
|
||||||
---------------------
|
---------------------
|
||||||
|
|
||||||
- Use a distinctive extension `.kte.swp` to avoid conflicts with other
|
- Use a distinctive directory (`$XDG_STATE_HOME/kte/swap`) to avoid
|
||||||
editors.
|
conflicts with other editors’ `.swp` conventions.
|
||||||
- Status bar indicator when swap is active; commands to purge/compact.
|
- Status bar indicator when swap is active; commands to purge/compact.
|
||||||
- On save: do not delete swap immediately; keep until the buffer is
|
- On save: do not delete swap immediately; keep until the buffer is
|
||||||
clean and idle for a short grace period (allows undo of accidental
|
clean and idle for a short grace period (allows undo of accidental
|
||||||
|
|||||||
237
docs/swap.md
Normal file
237
docs/swap.md
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
# Swap journaling (crash recovery)
|
||||||
|
|
||||||
|
kte has a small “swap” system: an append-only per-buffer journal that
|
||||||
|
records edits so they can be replayed after a crash.
|
||||||
|
|
||||||
|
This document describes the **currently implemented** swap system (stage
|
||||||
|
2), as implemented in `Swap.h` / `Swap.cc`.
|
||||||
|
|
||||||
|
## What it is (and what it is not)
|
||||||
|
|
||||||
|
- The swap file is a **journal of editing operations** (currently
|
||||||
|
inserts, deletes, and periodic full-buffer checkpoints).
|
||||||
|
- It is written by a **single background writer thread** owned by
|
||||||
|
`kte::SwapManager`.
|
||||||
|
- It is intended for **best-effort crash recovery**.
|
||||||
|
|
||||||
|
kte automatically deletes/resets swap journals after a **clean save**
|
||||||
|
and when
|
||||||
|
closing a clean buffer, so old swap files do not accumulate under normal
|
||||||
|
workflows. A best-effort prune also runs at startup to remove very old
|
||||||
|
swap
|
||||||
|
files.
|
||||||
|
|
||||||
|
## Automatic recovery prompt
|
||||||
|
|
||||||
|
When kte opens a file-backed buffer, it checks whether a corresponding
|
||||||
|
swap journal exists.
|
||||||
|
|
||||||
|
- If a swap file exists and replay succeeds *and* produces different
|
||||||
|
content than what is currently on disk, kte prompts:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Recover swap edits for <path>? (y/N, C-g cancel)
|
||||||
|
```
|
||||||
|
|
||||||
|
- `y`: open the file and apply swap replay (buffer becomes dirty)
|
||||||
|
- `Enter` (default) / any non-`y`: delete the swap file (
|
||||||
|
best-effort)
|
||||||
|
and open the file normally
|
||||||
|
- `C-g`: cancel opening the file
|
||||||
|
|
||||||
|
- If a swap file exists but is unreadable/corrupt, kte prompts:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Swap file unreadable for <path>. Delete it? (y/N, C-g cancel)
|
||||||
|
```
|
||||||
|
|
||||||
|
- `y`: delete the swap file (best-effort) and open the file normally
|
||||||
|
- `Enter` (default): keep the swap file and open the file normally
|
||||||
|
- `C-g`: cancel opening the file
|
||||||
|
|
||||||
|
## Where swap files live
|
||||||
|
|
||||||
|
Swap files are stored under an XDG-style per-user *state* directory:
|
||||||
|
|
||||||
|
- If `XDG_STATE_HOME` is set and non-empty:
|
||||||
|
- `$XDG_STATE_HOME/kte/swap/…`
|
||||||
|
- Otherwise, if `HOME` is set:
|
||||||
|
- `~/.local/state/kte/swap/…`
|
||||||
|
- Last resort fallback:
|
||||||
|
- `<system-temp>/kte/state/kte/swap/…` (via
|
||||||
|
`std::filesystem::temp_directory_path()`)
|
||||||
|
|
||||||
|
Swap files are always created with permissions `0600`.
|
||||||
|
|
||||||
|
### Swap file naming
|
||||||
|
|
||||||
|
For file-backed buffers, the swap filename is derived from the buffer’s
|
||||||
|
path:
|
||||||
|
|
||||||
|
1. Take a canonical-ish path key (`std::filesystem::weakly_canonical`,
|
||||||
|
else `absolute`, else the raw `Buffer::Filename()`).
|
||||||
|
2. Encode it so it’s human-identifiable:
|
||||||
|
- Strip one leading path separator (`/` or `\\`)
|
||||||
|
- Replace path separators (`/` and `\\`) with `!`
|
||||||
|
- Append `.swp`
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/home/kyle/tmp/test.txt -> home!kyle!tmp!test.txt.swp
|
||||||
|
```
|
||||||
|
|
||||||
|
If the resulting name would be long (over ~200 characters), kte falls
|
||||||
|
back to a shorter stable name:
|
||||||
|
|
||||||
|
```text
|
||||||
|
<basename>.<fnv1a64(path-key-as-hex)>.swp
|
||||||
|
```
|
||||||
|
|
||||||
|
For unnamed/unsaved buffers, kte uses:
|
||||||
|
|
||||||
|
```text
|
||||||
|
unnamed-<pid>-<counter>.swp
|
||||||
|
```
|
||||||
|
|
||||||
|
## Lifecycle (when swap is written)
|
||||||
|
|
||||||
|
`kte::SwapManager` is owned by `Editor` (see `Editor.cc`). Buffers are
|
||||||
|
attached for journaling when they are added/opened.
|
||||||
|
|
||||||
|
- `SwapManager::Attach(Buffer*)` starts tracking a buffer and
|
||||||
|
establishes its swap path.
|
||||||
|
- `Buffer` emits swap events from its low-level edit APIs:
|
||||||
|
- `Buffer::insert_text()` calls `SwapRecorder::OnInsert()`
|
||||||
|
- `Buffer::delete_text()` calls `SwapRecorder::OnDelete()`
|
||||||
|
- `Buffer::split_line()` / `join_lines()` are represented as
|
||||||
|
insert/delete of `\n` (they do **not** emit `SPLIT`/`JOIN` records
|
||||||
|
in stage 1).
|
||||||
|
- `SwapManager::Detach(Buffer*)` flushes queued records, `fsync()`s, and
|
||||||
|
closes the journal.
|
||||||
|
- On `Save As` / filename changes,
|
||||||
|
`SwapManager::NotifyFilenameChanged(Buffer&)` closes the existing
|
||||||
|
journal and switches to a new path.
|
||||||
|
- Note: the old swap file is currently left on disk (no
|
||||||
|
cleanup/rotation yet).
|
||||||
|
|
||||||
|
## Durability and performance
|
||||||
|
|
||||||
|
Swap writing is best-effort and asynchronous:
|
||||||
|
|
||||||
|
- Records are queued from the UI/editing thread(s).
|
||||||
|
- A background writer thread wakes at least every
|
||||||
|
`SwapConfig::flush_interval_ms` (default `200ms`) to write any queued
|
||||||
|
records.
|
||||||
|
- `fsync()` is throttled to at most once per
|
||||||
|
`SwapConfig::fsync_interval_ms` (default `1000ms`) per open swap file.
|
||||||
|
- `SwapManager::Flush()` blocks until the queue is fully written; it is
|
||||||
|
primarily used by tests and shutdown paths.
|
||||||
|
|
||||||
|
If a crash happens while writing, the swap file may end with a partial
|
||||||
|
record. Replay detects truncation/CRC mismatch and fails safely.
|
||||||
|
|
||||||
|
## On-disk format (v1)
|
||||||
|
|
||||||
|
The file is:
|
||||||
|
|
||||||
|
1. A fixed-size 64-byte header
|
||||||
|
2. Followed by a stream of records
|
||||||
|
|
||||||
|
All multi-byte integers in the swap file are **little-endian**.
|
||||||
|
|
||||||
|
### Header (64 bytes)
|
||||||
|
|
||||||
|
Layout (stage 1):
|
||||||
|
|
||||||
|
- `magic` (8 bytes): `KTE_SWP\0`
|
||||||
|
- `version` (`u32`): currently `1`
|
||||||
|
- `flags` (`u32`): currently `0`
|
||||||
|
- `created_time` (`u64`): Unix seconds
|
||||||
|
- remaining bytes are reserved/padding (currently zeroed)
|
||||||
|
|
||||||
|
### Record framing
|
||||||
|
|
||||||
|
Each record is:
|
||||||
|
|
||||||
|
```text
|
||||||
|
[type: u8][len: u24][payload: len bytes][crc32: u32]
|
||||||
|
```
|
||||||
|
|
||||||
|
- `len` is a 24-bit little-endian length of the payload (`0..0xFFFFFF`).
|
||||||
|
- `crc32` is computed over the 4-byte record header (`type + len`)
|
||||||
|
followed by the payload bytes.
|
||||||
|
|
||||||
|
### Record types
|
||||||
|
|
||||||
|
Type codes are defined in `SwapRecType` (`Swap.h`). Stage 1 primarily
|
||||||
|
emits:
|
||||||
|
|
||||||
|
- `INS` (`1`): insert bytes at `(row, col)`
|
||||||
|
- `DEL` (`2`): delete `len` bytes at `(row, col)`
|
||||||
|
|
||||||
|
Other type codes exist for forward compatibility (`SPLIT`, `JOIN`,
|
||||||
|
`META`, `CHKPT`), but are not produced by the current `SwapRecorder`
|
||||||
|
interface.
|
||||||
|
|
||||||
|
### Payload encoding (v1)
|
||||||
|
|
||||||
|
Every payload starts with:
|
||||||
|
|
||||||
|
```text
|
||||||
|
[encver: u8]
|
||||||
|
```
|
||||||
|
|
||||||
|
Currently `encver` must be `1`.
|
||||||
|
|
||||||
|
#### `INS` payload (encver = 1)
|
||||||
|
|
||||||
|
```text
|
||||||
|
[encver: u8 = 1]
|
||||||
|
[row: u32]
|
||||||
|
[col: u32]
|
||||||
|
[nbytes:u32]
|
||||||
|
[bytes: nbytes]
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `DEL` payload (encver = 1)
|
||||||
|
|
||||||
|
```text
|
||||||
|
[encver: u8 = 1]
|
||||||
|
[row: u32]
|
||||||
|
[col: u32]
|
||||||
|
[len: u32]
|
||||||
|
```
|
||||||
|
|
||||||
|
`row`/`col` are 0-based and are interpreted the same way as
|
||||||
|
`Buffer::insert_text()` / `Buffer::delete_text()`.
|
||||||
|
|
||||||
|
## Replay / recovery
|
||||||
|
|
||||||
|
Swap replay is implemented as a low-level API:
|
||||||
|
|
||||||
|
-
|
||||||
|
|
||||||
|
`bool kte::SwapManager::ReplayFile(Buffer &buf, const std::string &swap_path, std::string &err)`
|
||||||
|
|
||||||
|
Behavior:
|
||||||
|
|
||||||
|
- The caller supplies an **already-open** `Buffer` (typically loaded
|
||||||
|
from the on-disk file) and a swap path.
|
||||||
|
- `ReplayFile()` validates header magic/version, then iterates records.
|
||||||
|
- On a truncated file or CRC mismatch, it returns `false` and sets
|
||||||
|
`err`.
|
||||||
|
- On unknown record types, it ignores them (forward compatibility).
|
||||||
|
- On failure, the buffer may have had a prefix of records applied;
|
||||||
|
callers should treat this as “recovery failed”.
|
||||||
|
|
||||||
|
Important: if the buffer is currently attached to a `SwapManager`, you
|
||||||
|
should suspend/disable recording during replay (or detach first),
|
||||||
|
otherwise replayed edits would be re-journaled.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
Swap behavior and format are validated by unit tests:
|
||||||
|
|
||||||
|
- `tests/test_swap_writer.cc` (header, permissions, record CRC framing)
|
||||||
|
- `tests/test_swap_replay.cc` (record replay and truncation handling)
|
||||||
34
main.cc
34
main.cc
@@ -195,11 +195,12 @@ main(int argc, char *argv[])
|
|||||||
} else if (req_term) {
|
} else if (req_term) {
|
||||||
use_gui = false;
|
use_gui = false;
|
||||||
} else {
|
} else {
|
||||||
// Default depends on build target: kge defaults to GUI, kte to terminal
|
|
||||||
|
// Default depends on build target: kge defaults to GUI, kte to terminal
|
||||||
#if defined(KTE_DEFAULT_GUI)
|
#if defined(KTE_DEFAULT_GUI)
|
||||||
use_gui = true;
|
use_gui = true;
|
||||||
#else
|
#else
|
||||||
use_gui = false;
|
use_gui = false;
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
@@ -207,6 +208,9 @@ main(int argc, char *argv[])
|
|||||||
// Open files passed on the CLI; support +N to jump to line N in the next file.
|
// Open files passed on the CLI; support +N to jump to line N in the next file.
|
||||||
// If no files are provided, create an empty buffer.
|
// If no files are provided, create an empty buffer.
|
||||||
if (optind < argc) {
|
if (optind < argc) {
|
||||||
|
// Seed a scratch buffer so the UI has something to show while deferred opens
|
||||||
|
// (and potential swap recovery prompts) are processed.
|
||||||
|
editor.AddBuffer(Buffer());
|
||||||
std::size_t pending_line = 0; // 0 = no pending line
|
std::size_t pending_line = 0; // 0 = no pending line
|
||||||
for (int i = optind; i < argc; ++i) {
|
for (int i = optind; i < argc; ++i) {
|
||||||
const char *arg = argv[i];
|
const char *arg = argv[i];
|
||||||
@@ -242,29 +246,9 @@ main(int argc, char *argv[])
|
|||||||
// Fall through: not a +number, treat as filename starting with '+'
|
// Fall through: not a +number, treat as filename starting with '+'
|
||||||
}
|
}
|
||||||
|
|
||||||
std::string err;
|
|
||||||
const std::string path = arg;
|
const std::string path = arg;
|
||||||
if (!editor.OpenFile(path, err)) {
|
editor.RequestOpenFile(path, pending_line);
|
||||||
editor.SetStatus("open: " + err);
|
pending_line = 0; // consumed (if set)
|
||||||
std::cerr << "kte: " << err << "\n";
|
|
||||||
} else if (pending_line > 0) {
|
|
||||||
// Apply pending +N to the just-opened (current) buffer
|
|
||||||
if (Buffer *b = editor.CurrentBuffer()) {
|
|
||||||
std::size_t nrows = b->Nrows();
|
|
||||||
std::size_t line = pending_line > 0 ? pending_line - 1 : 0;
|
|
||||||
// 1-based to 0-based
|
|
||||||
if (nrows > 0) {
|
|
||||||
if (line >= nrows)
|
|
||||||
line = nrows - 1;
|
|
||||||
} else {
|
|
||||||
line = 0;
|
|
||||||
}
|
|
||||||
b->SetCursor(0, line);
|
|
||||||
// Do not force viewport offsets here; the frontend/renderer
|
|
||||||
// will establish dimensions and normalize visibility on first draw.
|
|
||||||
}
|
|
||||||
pending_line = 0; // consumed
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// If we ended with a pending +N but no subsequent file, ignore it.
|
// If we ended with a pending +N but no subsequent file, ignore it.
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
138
tests/TestHarness.h
Normal file
138
tests/TestHarness.h
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
// TestHarness.h - small helper layer for driving kte headlessly in tests
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <cstddef>
|
||||||
|
#include <string>
|
||||||
|
#include <string_view>
|
||||||
|
|
||||||
|
#include "Buffer.h"
|
||||||
|
#include "Command.h"
|
||||||
|
#include "Editor.h"
|
||||||
|
|
||||||
|
namespace ktet {
|
||||||
|
inline void
|
||||||
|
InstallDefaultCommandsOnce()
|
||||||
|
{
|
||||||
|
static bool installed = false;
|
||||||
|
if (!installed) {
|
||||||
|
InstallDefaultCommands();
|
||||||
|
installed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TestHarness {
|
||||||
|
public:
|
||||||
|
TestHarness()
|
||||||
|
{
|
||||||
|
InstallDefaultCommandsOnce();
|
||||||
|
editor_.SetDimensions(24, 80);
|
||||||
|
|
||||||
|
Buffer b;
|
||||||
|
b.SetVirtualName("+TEST+");
|
||||||
|
editor_.AddBuffer(std::move(b));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
Editor &
|
||||||
|
EditorRef()
|
||||||
|
{
|
||||||
|
return editor_;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
Buffer &
|
||||||
|
Buf()
|
||||||
|
{
|
||||||
|
return *editor_.CurrentBuffer();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
[[nodiscard]] const Buffer &
|
||||||
|
Buf() const
|
||||||
|
{
|
||||||
|
return *editor_.CurrentBuffer();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
bool
|
||||||
|
Exec(CommandId id, const std::string &arg = std::string(), int ucount = 0)
|
||||||
|
{
|
||||||
|
if (ucount > 0) {
|
||||||
|
editor_.SetUniversalArg(1, ucount);
|
||||||
|
} else {
|
||||||
|
editor_.UArgClear();
|
||||||
|
}
|
||||||
|
return Execute(editor_, id, arg);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
bool
|
||||||
|
InsertText(std::string_view text)
|
||||||
|
{
|
||||||
|
if (text.find('\n') != std::string_view::npos || text.find('\r') != std::string_view::npos)
|
||||||
|
return false;
|
||||||
|
return Exec(CommandId::InsertText, std::string(text));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
void
|
||||||
|
TypeText(std::string_view text)
|
||||||
|
{
|
||||||
|
for (char ch: text) {
|
||||||
|
if (ch == '\n') {
|
||||||
|
Exec(CommandId::Newline);
|
||||||
|
} else if (ch == '\r') {
|
||||||
|
// ignore
|
||||||
|
} else {
|
||||||
|
Exec(CommandId::InsertText, std::string(1, ch));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
[[nodiscard]] std::string
|
||||||
|
Text() const
|
||||||
|
{
|
||||||
|
const auto &rows = Buf().Rows();
|
||||||
|
std::string out;
|
||||||
|
for (std::size_t i = 0; i < rows.size(); ++i) {
|
||||||
|
out += static_cast<std::string>(rows[i]);
|
||||||
|
if (i + 1 < rows.size())
|
||||||
|
out.push_back('\n');
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
[[nodiscard]] std::string
|
||||||
|
Line(std::size_t y) const
|
||||||
|
{
|
||||||
|
return Buf().GetLineString(y);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
bool
|
||||||
|
SaveAs(const std::string &path, std::string &err)
|
||||||
|
{
|
||||||
|
return Buf().SaveAs(path, err);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
bool
|
||||||
|
Undo(int ucount = 0)
|
||||||
|
{
|
||||||
|
return Exec(CommandId::Undo, std::string(), ucount);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
bool
|
||||||
|
Redo(int ucount = 0)
|
||||||
|
{
|
||||||
|
return Exec(CommandId::Redo, std::string(), ucount);
|
||||||
|
}
|
||||||
|
|
||||||
|
private:
|
||||||
|
Editor editor_;
|
||||||
|
};
|
||||||
|
} // namespace ktet
|
||||||
142
tests/test_buffer_rows.cc
Normal file
142
tests/test_buffer_rows.cc
Normal 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);
|
||||||
|
}
|
||||||
110
tests/test_command_semantics.cc
Normal file
110
tests/test_command_semantics.cc
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
#include "Test.h"
|
||||||
|
|
||||||
|
#include "TestHarness.h"
|
||||||
|
|
||||||
|
using ktet::TestHarness;
|
||||||
|
|
||||||
|
|
||||||
|
TEST (CommandSemantics_KillToEOL_KillChain_And_Yank)
|
||||||
|
{
|
||||||
|
TestHarness h;
|
||||||
|
Editor &ed = h.EditorRef();
|
||||||
|
Buffer &b = h.Buf();
|
||||||
|
|
||||||
|
b.insert_text(0, 0, std::string("abc\ndef"));
|
||||||
|
b.SetCursor(1, 0); // a|bc
|
||||||
|
|
||||||
|
ed.KillRingClear();
|
||||||
|
ed.SetKillChain(false);
|
||||||
|
|
||||||
|
ASSERT_TRUE(h.Exec(CommandId::KillToEOL));
|
||||||
|
ASSERT_EQ(h.Text(), std::string("a\ndef"));
|
||||||
|
ASSERT_EQ(ed.KillRingHead(), std::string("bc"));
|
||||||
|
|
||||||
|
// At EOL, KillToEOL kills the newline (join).
|
||||||
|
ASSERT_TRUE(h.Exec(CommandId::KillToEOL));
|
||||||
|
ASSERT_EQ(h.Text(), std::string("adef"));
|
||||||
|
ASSERT_EQ(ed.KillRingHead(), std::string("bc\n"));
|
||||||
|
|
||||||
|
// Yank pastes the kill ring head and breaks the kill chain.
|
||||||
|
ASSERT_TRUE(h.Exec(CommandId::Yank));
|
||||||
|
ASSERT_EQ(h.Text(), std::string("abc\ndef"));
|
||||||
|
ASSERT_EQ(ed.KillRingHead(), std::string("bc\n"));
|
||||||
|
ASSERT_EQ(ed.KillChain(), false);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
TEST (CommandSemantics_ToggleMark_JumpToMark)
|
||||||
|
{
|
||||||
|
TestHarness h;
|
||||||
|
Buffer &b = h.Buf();
|
||||||
|
|
||||||
|
b.insert_text(0, 0, std::string("hello"));
|
||||||
|
b.SetCursor(2, 0);
|
||||||
|
ASSERT_EQ(b.MarkSet(), false);
|
||||||
|
|
||||||
|
ASSERT_TRUE(h.Exec(CommandId::ToggleMark));
|
||||||
|
ASSERT_EQ(b.MarkSet(), true);
|
||||||
|
ASSERT_EQ(b.MarkCurx(), (std::size_t) 2);
|
||||||
|
ASSERT_EQ(b.MarkCury(), (std::size_t) 0);
|
||||||
|
|
||||||
|
b.SetCursor(4, 0);
|
||||||
|
ASSERT_TRUE(h.Exec(CommandId::JumpToMark));
|
||||||
|
ASSERT_EQ(b.Curx(), (std::size_t) 2);
|
||||||
|
ASSERT_EQ(b.Cury(), (std::size_t) 0);
|
||||||
|
// Jump-to-mark swaps: mark becomes previous cursor.
|
||||||
|
ASSERT_EQ(b.MarkSet(), true);
|
||||||
|
ASSERT_EQ(b.MarkCurx(), (std::size_t) 4);
|
||||||
|
ASSERT_EQ(b.MarkCury(), (std::size_t) 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
TEST (CommandSemantics_CtrlGRefresh_ClearsMark_WhenNothingElseToCancel)
|
||||||
|
{
|
||||||
|
TestHarness h;
|
||||||
|
Buffer &b = h.Buf();
|
||||||
|
|
||||||
|
b.insert_text(0, 0, std::string("hello"));
|
||||||
|
b.SetCursor(2, 0);
|
||||||
|
ASSERT_EQ(b.MarkSet(), false);
|
||||||
|
|
||||||
|
ASSERT_TRUE(h.Exec(CommandId::ToggleMark));
|
||||||
|
ASSERT_EQ(b.MarkSet(), true);
|
||||||
|
|
||||||
|
// C-g is mapped to Refresh; when there's no prompt/search/visual-line mode to cancel,
|
||||||
|
// it should clear the mark.
|
||||||
|
ASSERT_TRUE(h.Exec(CommandId::Refresh));
|
||||||
|
ASSERT_EQ(b.MarkSet(), false);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
TEST (CommandSemantics_CopyRegion_And_KillRegion)
|
||||||
|
{
|
||||||
|
TestHarness h;
|
||||||
|
Editor &ed = h.EditorRef();
|
||||||
|
Buffer &b = h.Buf();
|
||||||
|
|
||||||
|
b.insert_text(0, 0, std::string("hello world"));
|
||||||
|
b.SetCursor(0, 0);
|
||||||
|
|
||||||
|
ed.KillRingClear();
|
||||||
|
ed.SetKillChain(false);
|
||||||
|
|
||||||
|
// Copy "hello" (region [0,5)).
|
||||||
|
ASSERT_TRUE(h.Exec(CommandId::ToggleMark));
|
||||||
|
b.SetCursor(5, 0);
|
||||||
|
ASSERT_TRUE(h.Exec(CommandId::CopyRegion));
|
||||||
|
ASSERT_EQ(ed.KillRingHead(), std::string("hello"));
|
||||||
|
ASSERT_EQ(b.MarkSet(), false);
|
||||||
|
ASSERT_EQ(h.Text(), std::string("hello world"));
|
||||||
|
|
||||||
|
// Kill "world" (region [6,11)).
|
||||||
|
ed.SetKillChain(false);
|
||||||
|
b.SetCursor(6, 0);
|
||||||
|
ASSERT_TRUE(h.Exec(CommandId::ToggleMark));
|
||||||
|
b.SetCursor(11, 0);
|
||||||
|
ASSERT_TRUE(h.Exec(CommandId::KillRegion));
|
||||||
|
ASSERT_EQ(ed.KillRingHead(), std::string("world"));
|
||||||
|
ASSERT_EQ(b.MarkSet(), false);
|
||||||
|
ASSERT_EQ(h.Text(), std::string("hello "));
|
||||||
|
}
|
||||||
12
tests/test_daily_driver_harness.cc
Normal file
12
tests/test_daily_driver_harness.cc
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
#include "Test.h"
|
||||||
|
|
||||||
|
#include "tests/TestHarness.h"
|
||||||
|
|
||||||
|
|
||||||
|
TEST (DailyDriverHarness_Smoke_CanCreateBufferAndInsertText)
|
||||||
|
{
|
||||||
|
ktet::TestHarness h;
|
||||||
|
|
||||||
|
ASSERT_TRUE(h.InsertText("hello"));
|
||||||
|
ASSERT_EQ(h.Line(0), std::string("hello"));
|
||||||
|
}
|
||||||
170
tests/test_daily_workflows.cc
Normal file
170
tests/test_daily_workflows.cc
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
#include "Test.h"
|
||||||
|
|
||||||
|
#include "Command.h"
|
||||||
|
#include "Editor.h"
|
||||||
|
|
||||||
|
#include "tests/TestHarness.h" // for ktet::InstallDefaultCommandsOnce
|
||||||
|
|
||||||
|
#include <cstdio>
|
||||||
|
#include <filesystem>
|
||||||
|
#include <fstream>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
|
||||||
|
static void
|
||||||
|
write_file_bytes(const std::string &path, const std::string &bytes)
|
||||||
|
{
|
||||||
|
std::ofstream out(path, std::ios::binary | std::ios::trunc);
|
||||||
|
out.write(bytes.data(), (std::streamsize) bytes.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
static std::string
|
||||||
|
read_file_bytes(const std::string &path)
|
||||||
|
{
|
||||||
|
std::ifstream in(path, std::ios::binary);
|
||||||
|
return std::string((std::istreambuf_iterator<char>(in)), std::istreambuf_iterator<char>());
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
static std::string
|
||||||
|
buffer_bytes_via_views(const Buffer &b)
|
||||||
|
{
|
||||||
|
const auto &rows = b.Rows();
|
||||||
|
std::string out;
|
||||||
|
for (std::size_t i = 0; i < rows.size(); i++) {
|
||||||
|
auto v = b.GetLineView(i);
|
||||||
|
out.append(v.data(), v.size());
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
TEST (DailyWorkflow_OpenEditSave_Transcript)
|
||||||
|
{
|
||||||
|
ktet::InstallDefaultCommandsOnce();
|
||||||
|
|
||||||
|
const std::string path = "./.kte_ut_daily_open_edit_save.txt";
|
||||||
|
std::remove(path.c_str());
|
||||||
|
write_file_bytes(path, "one\n");
|
||||||
|
const std::string npath = std::filesystem::canonical(path).string();
|
||||||
|
|
||||||
|
Editor ed;
|
||||||
|
ed.SetDimensions(24, 80);
|
||||||
|
// Seed an empty buffer so OpenFile can reuse it.
|
||||||
|
{
|
||||||
|
Buffer scratch;
|
||||||
|
ed.AddBuffer(std::move(scratch));
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string err;
|
||||||
|
ASSERT_TRUE(ed.OpenFile(path, err));
|
||||||
|
ASSERT_TRUE(ed.CurrentBuffer() != nullptr);
|
||||||
|
ASSERT_EQ(ed.CurrentBuffer()->Filename(), npath);
|
||||||
|
|
||||||
|
// Append two new lines via commands (no UI).
|
||||||
|
ASSERT_TRUE(Execute(ed, CommandId::MoveFileEnd));
|
||||||
|
ASSERT_TRUE(Execute(ed, CommandId::InsertText, "two"));
|
||||||
|
ASSERT_TRUE(Execute(ed, CommandId::Newline));
|
||||||
|
ASSERT_TRUE(Execute(ed, CommandId::InsertText, "three"));
|
||||||
|
|
||||||
|
ASSERT_TRUE(Execute(ed, CommandId::Save));
|
||||||
|
ASSERT_TRUE(ed.CurrentBuffer() != nullptr);
|
||||||
|
ASSERT_EQ(read_file_bytes(npath), buffer_bytes_via_views(*ed.CurrentBuffer()));
|
||||||
|
|
||||||
|
std::remove(path.c_str());
|
||||||
|
std::remove(npath.c_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
TEST (DailyWorkflow_MultiBufferSwitchClose_Transcript)
|
||||||
|
{
|
||||||
|
ktet::InstallDefaultCommandsOnce();
|
||||||
|
|
||||||
|
const std::string p1 = "./.kte_ut_daily_buf_1.txt";
|
||||||
|
const std::string p2 = "./.kte_ut_daily_buf_2.txt";
|
||||||
|
std::remove(p1.c_str());
|
||||||
|
std::remove(p2.c_str());
|
||||||
|
write_file_bytes(p1, "aaa\n");
|
||||||
|
write_file_bytes(p2, "bbb\n");
|
||||||
|
const std::string np1 = std::filesystem::canonical(p1).string();
|
||||||
|
const std::string np2 = std::filesystem::canonical(p2).string();
|
||||||
|
|
||||||
|
Editor ed;
|
||||||
|
ed.SetDimensions(24, 80);
|
||||||
|
{
|
||||||
|
Buffer scratch;
|
||||||
|
ed.AddBuffer(std::move(scratch));
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string err;
|
||||||
|
ASSERT_TRUE(ed.OpenFile(p1, err));
|
||||||
|
ASSERT_TRUE(ed.OpenFile(p2, err));
|
||||||
|
ASSERT_EQ(ed.BufferCount(), (std::size_t) 2);
|
||||||
|
ASSERT_TRUE(ed.CurrentBuffer() != nullptr);
|
||||||
|
ASSERT_EQ(ed.CurrentBuffer()->Filename(), np2);
|
||||||
|
|
||||||
|
// Switch back and forth.
|
||||||
|
ASSERT_TRUE(Execute(ed, CommandId::BufferPrev));
|
||||||
|
ASSERT_EQ(ed.CurrentBuffer()->Filename(), np1);
|
||||||
|
ASSERT_TRUE(Execute(ed, CommandId::BufferNext));
|
||||||
|
ASSERT_EQ(ed.CurrentBuffer()->Filename(), np2);
|
||||||
|
|
||||||
|
// Close current buffer (p2); ensure we land on p1.
|
||||||
|
ASSERT_TRUE(Execute(ed, CommandId::BufferClose));
|
||||||
|
ASSERT_EQ(ed.BufferCount(), (std::size_t) 1);
|
||||||
|
ASSERT_TRUE(ed.CurrentBuffer() != nullptr);
|
||||||
|
ASSERT_EQ(ed.CurrentBuffer()->Filename(), np1);
|
||||||
|
|
||||||
|
std::remove(p1.c_str());
|
||||||
|
std::remove(p2.c_str());
|
||||||
|
std::remove(np1.c_str());
|
||||||
|
std::remove(np2.c_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
TEST (DailyWorkflow_CrashRecovery_SwapReplay_Transcript)
|
||||||
|
{
|
||||||
|
ktet::InstallDefaultCommandsOnce();
|
||||||
|
|
||||||
|
const std::string path = "./.kte_ut_daily_swap_recover.txt";
|
||||||
|
std::remove(path.c_str());
|
||||||
|
write_file_bytes(path, "base\nline2\n");
|
||||||
|
|
||||||
|
Editor ed;
|
||||||
|
ed.SetDimensions(24, 80);
|
||||||
|
{
|
||||||
|
Buffer scratch;
|
||||||
|
ed.AddBuffer(std::move(scratch));
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string err;
|
||||||
|
ASSERT_TRUE(ed.OpenFile(path, err));
|
||||||
|
Buffer *buf = ed.CurrentBuffer();
|
||||||
|
ASSERT_TRUE(buf != nullptr);
|
||||||
|
|
||||||
|
// Make unsaved edits through command execution.
|
||||||
|
ASSERT_TRUE(Execute(ed, CommandId::MoveFileStart));
|
||||||
|
ASSERT_TRUE(Execute(ed, CommandId::InsertText, "X"));
|
||||||
|
ASSERT_TRUE(Execute(ed, CommandId::MoveDown));
|
||||||
|
ASSERT_TRUE(Execute(ed, CommandId::MoveHome));
|
||||||
|
ASSERT_TRUE(Execute(ed, CommandId::InsertText, "ZZ"));
|
||||||
|
ASSERT_TRUE(Execute(ed, CommandId::MoveFileEnd));
|
||||||
|
ASSERT_TRUE(Execute(ed, CommandId::InsertText, "TAIL"));
|
||||||
|
|
||||||
|
// Ensure journal is durable and capture expected bytes.
|
||||||
|
ed.Swap()->Flush(buf);
|
||||||
|
const std::string swap_path = kte::SwapManager::ComputeSwapPathForTests(*buf);
|
||||||
|
const std::string expected = buffer_bytes_via_views(*buf);
|
||||||
|
|
||||||
|
// "Crash": reopen from disk (original file content) into a fresh Buffer and replay.
|
||||||
|
Buffer recovered;
|
||||||
|
ASSERT_TRUE(recovered.OpenFromFile(path, err));
|
||||||
|
ASSERT_TRUE(kte::SwapManager::ReplayFile(recovered, swap_path, err));
|
||||||
|
ASSERT_EQ(buffer_bytes_via_views(recovered), expected);
|
||||||
|
|
||||||
|
// Cleanup.
|
||||||
|
ed.Swap()->Detach(buf);
|
||||||
|
std::remove(path.c_str());
|
||||||
|
std::remove(swap_path.c_str());
|
||||||
|
}
|
||||||
84
tests/test_kkeymap.cc
Normal file
84
tests/test_kkeymap.cc
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
#include "Test.h"
|
||||||
|
|
||||||
|
#include "KKeymap.h"
|
||||||
|
|
||||||
|
#include <ncurses.h>
|
||||||
|
|
||||||
|
|
||||||
|
TEST (KKeymap_KPrefix_CanonicalChords)
|
||||||
|
{
|
||||||
|
CommandId id{};
|
||||||
|
|
||||||
|
// From docs/ke.md (K-commands)
|
||||||
|
ASSERT_TRUE(KLookupKCommand('s', false, id));
|
||||||
|
ASSERT_EQ(id, CommandId::Save);
|
||||||
|
ASSERT_TRUE(KLookupKCommand('s', true, id)); // C-k C-s
|
||||||
|
ASSERT_EQ(id, CommandId::Save);
|
||||||
|
|
||||||
|
ASSERT_TRUE(KLookupKCommand('d', false, id));
|
||||||
|
ASSERT_EQ(id, CommandId::KillToEOL);
|
||||||
|
ASSERT_TRUE(KLookupKCommand('d', true, id)); // C-k C-d
|
||||||
|
ASSERT_EQ(id, CommandId::KillLine);
|
||||||
|
|
||||||
|
ASSERT_TRUE(KLookupKCommand(' ', false, id)); // C-k SPACE
|
||||||
|
ASSERT_EQ(id, CommandId::ToggleMark);
|
||||||
|
|
||||||
|
ASSERT_TRUE(KLookupKCommand('j', false, id));
|
||||||
|
ASSERT_EQ(id, CommandId::JumpToMark);
|
||||||
|
|
||||||
|
ASSERT_TRUE(KLookupKCommand('f', false, id));
|
||||||
|
ASSERT_EQ(id, CommandId::FlushKillRing);
|
||||||
|
|
||||||
|
ASSERT_TRUE(KLookupKCommand('y', false, id));
|
||||||
|
ASSERT_EQ(id, CommandId::Yank);
|
||||||
|
|
||||||
|
// Unknown should not map
|
||||||
|
ASSERT_EQ(KLookupKCommand('Z', false, id), false);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
TEST (KKeymap_CtrlChords_CanonicalChords)
|
||||||
|
{
|
||||||
|
CommandId id{};
|
||||||
|
|
||||||
|
// From docs/ke.md (other keybindings)
|
||||||
|
ASSERT_TRUE(KLookupCtrlCommand('n', id));
|
||||||
|
ASSERT_EQ(id, CommandId::MoveDown);
|
||||||
|
ASSERT_TRUE(KLookupCtrlCommand('p', id));
|
||||||
|
ASSERT_EQ(id, CommandId::MoveUp);
|
||||||
|
ASSERT_TRUE(KLookupCtrlCommand('f', id));
|
||||||
|
ASSERT_EQ(id, CommandId::MoveRight);
|
||||||
|
ASSERT_TRUE(KLookupCtrlCommand('b', id));
|
||||||
|
ASSERT_EQ(id, CommandId::MoveLeft);
|
||||||
|
|
||||||
|
ASSERT_TRUE(KLookupCtrlCommand('w', id));
|
||||||
|
ASSERT_EQ(id, CommandId::KillRegion);
|
||||||
|
ASSERT_TRUE(KLookupCtrlCommand('y', id));
|
||||||
|
ASSERT_EQ(id, CommandId::Yank);
|
||||||
|
|
||||||
|
ASSERT_EQ(KLookupCtrlCommand('z', id), false);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
TEST (KKeymap_EscChords_CanonicalChords)
|
||||||
|
{
|
||||||
|
CommandId id{};
|
||||||
|
|
||||||
|
// From docs/ke.md (ESC bindings)
|
||||||
|
ASSERT_TRUE(KLookupEscCommand('b', id));
|
||||||
|
ASSERT_EQ(id, CommandId::WordPrev);
|
||||||
|
ASSERT_TRUE(KLookupEscCommand('f', id));
|
||||||
|
ASSERT_EQ(id, CommandId::WordNext);
|
||||||
|
ASSERT_TRUE(KLookupEscCommand('d', id));
|
||||||
|
ASSERT_EQ(id, CommandId::DeleteWordNext);
|
||||||
|
ASSERT_TRUE(KLookupEscCommand('q', id));
|
||||||
|
ASSERT_EQ(id, CommandId::ReflowParagraph);
|
||||||
|
ASSERT_TRUE(KLookupEscCommand('w', id));
|
||||||
|
ASSERT_EQ(id, CommandId::CopyRegion);
|
||||||
|
|
||||||
|
// ESC BACKSPACE
|
||||||
|
ASSERT_TRUE(KLookupEscCommand(KEY_BACKSPACE, id));
|
||||||
|
ASSERT_EQ(id, CommandId::DeleteWordPrev);
|
||||||
|
|
||||||
|
ASSERT_EQ(KLookupEscCommand('z', id), false);
|
||||||
|
}
|
||||||
@@ -1,49 +1,181 @@
|
|||||||
#include "Test.h"
|
#include "Test.h"
|
||||||
#include "PieceTable.h"
|
#include "PieceTable.h"
|
||||||
|
#include <algorithm>
|
||||||
|
#include <array>
|
||||||
|
#include <random>
|
||||||
#include <string>
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
TEST(PieceTable_Insert_Delete_LineCount) {
|
|
||||||
PieceTable pt;
|
|
||||||
// start empty
|
|
||||||
ASSERT_EQ(pt.Size(), (std::size_t)0);
|
|
||||||
ASSERT_EQ(pt.LineCount(), (std::size_t)1); // empty buffer has 1 logical line
|
|
||||||
|
|
||||||
// Insert some text with newlines
|
static std::vector<std::size_t>
|
||||||
const char *t = "abc\n123\nxyz"; // last line without trailing NL
|
LineStartsFor(const std::string &s)
|
||||||
pt.Insert(0, t, 11);
|
{
|
||||||
ASSERT_EQ(pt.Size(), (std::size_t)11);
|
std::vector<std::size_t> starts;
|
||||||
ASSERT_EQ(pt.LineCount(), (std::size_t)3);
|
starts.push_back(0);
|
||||||
|
for (std::size_t i = 0; i < s.size(); i++) {
|
||||||
// Check get line
|
if (s[i] == '\n')
|
||||||
ASSERT_EQ(pt.GetLine(0), std::string("abc"));
|
starts.push_back(i + 1);
|
||||||
ASSERT_EQ(pt.GetLine(1), std::string("123"));
|
}
|
||||||
ASSERT_EQ(pt.GetLine(2), std::string("xyz"));
|
return starts;
|
||||||
|
|
||||||
// Delete middle line entirely including its trailing NL
|
|
||||||
auto r = pt.GetLineRange(1); // [start,end) points to start of line 1 to start of line 2
|
|
||||||
pt.Delete(r.first, r.second - r.first);
|
|
||||||
ASSERT_EQ(pt.LineCount(), (std::size_t)2);
|
|
||||||
ASSERT_EQ(pt.GetLine(0), std::string("abc"));
|
|
||||||
ASSERT_EQ(pt.GetLine(1), std::string("xyz"));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
TEST(PieceTable_LineCol_Conversions) {
|
|
||||||
PieceTable pt;
|
|
||||||
std::string s = "hello\nworld\n"; // two lines with trailing NL
|
|
||||||
pt.Insert(0, s.data(), s.size());
|
|
||||||
|
|
||||||
// Byte offsets of starts
|
static std::string
|
||||||
auto off0 = pt.LineColToByteOffset(0, 0);
|
LineContentFor(const std::string &s, std::size_t line_num)
|
||||||
auto off1 = pt.LineColToByteOffset(1, 0);
|
{
|
||||||
auto off2 = pt.LineColToByteOffset(2, 0); // EOF
|
auto starts = LineStartsFor(s);
|
||||||
ASSERT_EQ(off0, (std::size_t)0);
|
if (starts.empty() || line_num >= starts.size())
|
||||||
ASSERT_EQ(off1, (std::size_t)6); // "hello\n"
|
return std::string();
|
||||||
ASSERT_EQ(off2, pt.Size());
|
std::size_t start = starts[line_num];
|
||||||
|
std::size_t end = (line_num + 1 < starts.size()) ? starts[line_num + 1] : s.size();
|
||||||
auto lc0 = pt.ByteOffsetToLineCol(0);
|
if (end > start && s[end - 1] == '\n')
|
||||||
auto lc1 = pt.ByteOffsetToLineCol(6);
|
end -= 1;
|
||||||
ASSERT_EQ(lc0.first, (std::size_t)0);
|
return s.substr(start, end - start);
|
||||||
ASSERT_EQ(lc0.second, (std::size_t)0);
|
}
|
||||||
ASSERT_EQ(lc1.first, (std::size_t)1);
|
|
||||||
ASSERT_EQ(lc1.second, (std::size_t)0);
|
|
||||||
|
TEST (PieceTable_Insert_Delete_LineCount)
|
||||||
|
{
|
||||||
|
PieceTable pt;
|
||||||
|
// start empty
|
||||||
|
ASSERT_EQ(pt.Size(), (std::size_t) 0);
|
||||||
|
ASSERT_EQ(pt.LineCount(), (std::size_t) 1); // empty buffer has 1 logical line
|
||||||
|
|
||||||
|
// Insert some text with newlines
|
||||||
|
const char *t = "abc\n123\nxyz"; // last line without trailing NL
|
||||||
|
pt.Insert(0, t, 11);
|
||||||
|
ASSERT_EQ(pt.Size(), (std::size_t) 11);
|
||||||
|
ASSERT_EQ(pt.LineCount(), (std::size_t) 3);
|
||||||
|
|
||||||
|
// Check get line
|
||||||
|
ASSERT_EQ(pt.GetLine(0), std::string("abc"));
|
||||||
|
ASSERT_EQ(pt.GetLine(1), std::string("123"));
|
||||||
|
ASSERT_EQ(pt.GetLine(2), std::string("xyz"));
|
||||||
|
|
||||||
|
// Delete middle line entirely including its trailing NL
|
||||||
|
auto r = pt.GetLineRange(1); // [start,end) points to start of line 1 to start of line 2
|
||||||
|
pt.Delete(r.first, r.second - r.first);
|
||||||
|
ASSERT_EQ(pt.LineCount(), (std::size_t) 2);
|
||||||
|
ASSERT_EQ(pt.GetLine(0), std::string("abc"));
|
||||||
|
ASSERT_EQ(pt.GetLine(1), std::string("xyz"));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
TEST (PieceTable_LineCol_Conversions)
|
||||||
|
{
|
||||||
|
PieceTable pt;
|
||||||
|
std::string s = "hello\nworld\n"; // two lines with trailing NL
|
||||||
|
pt.Insert(0, s.data(), s.size());
|
||||||
|
|
||||||
|
// Byte offsets of starts
|
||||||
|
auto off0 = pt.LineColToByteOffset(0, 0);
|
||||||
|
auto off1 = pt.LineColToByteOffset(1, 0);
|
||||||
|
auto off2 = pt.LineColToByteOffset(2, 0); // EOF
|
||||||
|
ASSERT_EQ(off0, (std::size_t) 0);
|
||||||
|
ASSERT_EQ(off1, (std::size_t) 6); // "hello\n"
|
||||||
|
ASSERT_EQ(off2, pt.Size());
|
||||||
|
|
||||||
|
auto lc0 = pt.ByteOffsetToLineCol(0);
|
||||||
|
auto lc1 = pt.ByteOffsetToLineCol(6);
|
||||||
|
ASSERT_EQ(lc0.first, (std::size_t) 0);
|
||||||
|
ASSERT_EQ(lc0.second, (std::size_t) 0);
|
||||||
|
ASSERT_EQ(lc1.first, (std::size_t) 1);
|
||||||
|
ASSERT_EQ(lc1.second, (std::size_t) 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
TEST (PieceTable_ReferenceModel_RandomEdits_Deterministic)
|
||||||
|
{
|
||||||
|
PieceTable pt;
|
||||||
|
std::string model;
|
||||||
|
|
||||||
|
std::mt19937 rng(0xC0FFEEu);
|
||||||
|
const std::vector<std::string> corpus = {
|
||||||
|
"a",
|
||||||
|
"b",
|
||||||
|
"c",
|
||||||
|
"xyz",
|
||||||
|
"123",
|
||||||
|
"\n",
|
||||||
|
"!\n",
|
||||||
|
"foo\nbar",
|
||||||
|
"end\n",
|
||||||
|
};
|
||||||
|
|
||||||
|
auto check_invariants = [&](const char *where) {
|
||||||
|
(void) where;
|
||||||
|
ASSERT_EQ(pt.Size(), model.size());
|
||||||
|
ASSERT_EQ(pt.GetRange(0, pt.Size()), model);
|
||||||
|
|
||||||
|
auto starts = LineStartsFor(model);
|
||||||
|
ASSERT_EQ(pt.LineCount(), starts.size());
|
||||||
|
|
||||||
|
// Spot-check a few line ranges and contents.
|
||||||
|
std::size_t last = starts.empty() ? (std::size_t) 0 : (starts.size() - 1);
|
||||||
|
std::size_t mid = (starts.size() > 2) ? (std::size_t) 1 : last;
|
||||||
|
const std::array<std::size_t, 3> probe_lines = {(std::size_t) 0, last, mid};
|
||||||
|
for (auto line: probe_lines) {
|
||||||
|
if (starts.empty())
|
||||||
|
break;
|
||||||
|
if (line >= starts.size())
|
||||||
|
continue;
|
||||||
|
std::size_t exp_start = starts[line];
|
||||||
|
std::size_t exp_end = (line + 1 < starts.size()) ? starts[line + 1] : model.size();
|
||||||
|
auto r = pt.GetLineRange(line);
|
||||||
|
ASSERT_EQ(r.first, exp_start);
|
||||||
|
ASSERT_EQ(r.second, exp_end);
|
||||||
|
ASSERT_EQ(pt.GetLine(line), LineContentFor(model, line));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Round-trips for a few offsets.
|
||||||
|
const std::vector<std::size_t> probe_offsets = {
|
||||||
|
0,
|
||||||
|
model.size() / 2,
|
||||||
|
model.size(),
|
||||||
|
};
|
||||||
|
for (auto off: probe_offsets) {
|
||||||
|
auto lc = pt.ByteOffsetToLineCol(off);
|
||||||
|
auto back = pt.LineColToByteOffset(lc.first, lc.second);
|
||||||
|
ASSERT_EQ(back, off);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
check_invariants("initial");
|
||||||
|
|
||||||
|
for (int step = 0; step < 250; step++) {
|
||||||
|
bool do_insert = model.empty() || ((rng() % 3u) != 0u); // bias toward insert
|
||||||
|
if (do_insert) {
|
||||||
|
const std::string &ins = corpus[rng() % corpus.size()];
|
||||||
|
std::size_t pos = model.empty() ? 0 : (rng() % (model.size() + 1));
|
||||||
|
pt.Insert(pos, ins.data(), ins.size());
|
||||||
|
model.insert(pos, ins);
|
||||||
|
} else {
|
||||||
|
std::size_t pos = rng() % model.size();
|
||||||
|
std::size_t max = std::min<std::size_t>(8, model.size() - pos);
|
||||||
|
std::size_t len = 1 + (rng() % max);
|
||||||
|
pt.Delete(pos, len);
|
||||||
|
model.erase(pos, len);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also validate GetRange on a small random window when non-empty.
|
||||||
|
if (!model.empty()) {
|
||||||
|
std::size_t off = rng() % model.size();
|
||||||
|
std::size_t max = std::min<std::size_t>(16, model.size() - off);
|
||||||
|
std::size_t len = 1 + (rng() % max);
|
||||||
|
ASSERT_EQ(pt.GetRange(off, len), model.substr(off, len));
|
||||||
|
}
|
||||||
|
|
||||||
|
check_invariants("step");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Full line-by-line range verification at the end.
|
||||||
|
auto starts = LineStartsFor(model);
|
||||||
|
for (std::size_t line = 0; line < starts.size(); line++) {
|
||||||
|
std::size_t exp_start = starts[line];
|
||||||
|
std::size_t exp_end = (line + 1 < starts.size()) ? starts[line + 1] : model.size();
|
||||||
|
auto r = pt.GetLineRange(line);
|
||||||
|
ASSERT_EQ(r.first, exp_start);
|
||||||
|
ASSERT_EQ(r.second, exp_end);
|
||||||
|
ASSERT_EQ(pt.GetLine(line), LineContentFor(model, line));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
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";
|
||||||
|
}
|
||||||
|
}
|
||||||
129
tests/test_search_replace_flow.cc
Normal file
129
tests/test_search_replace_flow.cc
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
#include "Test.h"
|
||||||
|
|
||||||
|
#include "tests/TestHarness.h"
|
||||||
|
|
||||||
|
using ktet::TestHarness;
|
||||||
|
|
||||||
|
// These tests intentionally drive the prompt-based search/replace UI headlessly
|
||||||
|
// via `Execute(Editor&, CommandId, ...)` to lock down behavior without ncurses.
|
||||||
|
|
||||||
|
TEST (SearchFlow_FindStart_Success_LeavesCursorOnMatch_And_ClearsSearchState)
|
||||||
|
{
|
||||||
|
TestHarness h;
|
||||||
|
Editor &ed = h.EditorRef();
|
||||||
|
Buffer &b = h.Buf();
|
||||||
|
|
||||||
|
b.insert_text(0, 0, "abc def abc");
|
||||||
|
b.SetCursor(0, 0);
|
||||||
|
b.SetOffsets(0, 0);
|
||||||
|
|
||||||
|
// Keep a mark set to ensure search doesn't clobber it.
|
||||||
|
b.SetMark(0, 0);
|
||||||
|
ASSERT_TRUE(b.MarkSet());
|
||||||
|
|
||||||
|
ASSERT_TRUE(h.Exec(CommandId::FindStart));
|
||||||
|
ASSERT_TRUE(ed.PromptActive());
|
||||||
|
ASSERT_EQ(ed.CurrentPromptKind(), Editor::PromptKind::Search);
|
||||||
|
ASSERT_TRUE(ed.SearchActive());
|
||||||
|
|
||||||
|
// Typing into the prompt uses InsertText and should jump to the first match.
|
||||||
|
ASSERT_TRUE(h.Exec(CommandId::InsertText, "def"));
|
||||||
|
ASSERT_EQ(b.Cury(), (std::size_t) 0);
|
||||||
|
ASSERT_EQ(b.Curx(), (std::size_t) 4);
|
||||||
|
|
||||||
|
// Enter (Newline) accepts the prompt and ends incremental search.
|
||||||
|
ASSERT_TRUE(h.Exec(CommandId::Newline));
|
||||||
|
ASSERT_TRUE(!ed.PromptActive());
|
||||||
|
ASSERT_TRUE(!ed.SearchActive());
|
||||||
|
ASSERT_TRUE(b.MarkSet());
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
TEST (SearchFlow_FindStart_NotFound_RestoresOrigin_And_ClearsSearchState)
|
||||||
|
{
|
||||||
|
TestHarness h;
|
||||||
|
Editor &ed = h.EditorRef();
|
||||||
|
Buffer &b = h.Buf();
|
||||||
|
|
||||||
|
b.insert_text(0, 0, "hello world\nsecond line\n");
|
||||||
|
b.SetCursor(3, 0);
|
||||||
|
b.SetOffsets(1, 2);
|
||||||
|
|
||||||
|
const std::size_t ox = b.Curx();
|
||||||
|
const std::size_t oy = b.Cury();
|
||||||
|
const std::size_t orow = b.Rowoffs();
|
||||||
|
const std::size_t ocol = b.Coloffs();
|
||||||
|
|
||||||
|
ASSERT_TRUE(h.Exec(CommandId::FindStart));
|
||||||
|
ASSERT_TRUE(ed.PromptActive());
|
||||||
|
ASSERT_TRUE(ed.SearchActive());
|
||||||
|
|
||||||
|
// Not-found should restore cursor/viewport to the saved origin while still in prompt.
|
||||||
|
ASSERT_TRUE(h.Exec(CommandId::InsertText, "zzzz"));
|
||||||
|
ASSERT_EQ(b.Curx(), ox);
|
||||||
|
ASSERT_EQ(b.Cury(), oy);
|
||||||
|
ASSERT_EQ(b.Rowoffs(), orow);
|
||||||
|
ASSERT_EQ(b.Coloffs(), ocol);
|
||||||
|
|
||||||
|
ASSERT_TRUE(h.Exec(CommandId::Newline));
|
||||||
|
ASSERT_TRUE(!ed.PromptActive());
|
||||||
|
ASSERT_TRUE(!ed.SearchActive());
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
TEST (SearchFlow_SearchReplace_EmptyFind_DoesNotMutateBuffer_And_ClearsState)
|
||||||
|
{
|
||||||
|
TestHarness h;
|
||||||
|
Editor &ed = h.EditorRef();
|
||||||
|
Buffer &b = h.Buf();
|
||||||
|
|
||||||
|
b.insert_text(0, 0, "abc abc\n");
|
||||||
|
b.SetCursor(0, 0);
|
||||||
|
|
||||||
|
const std::string before = h.Text();
|
||||||
|
|
||||||
|
ASSERT_TRUE(h.Exec(CommandId::SearchReplace));
|
||||||
|
ASSERT_TRUE(ed.PromptActive());
|
||||||
|
ASSERT_EQ(ed.CurrentPromptKind(), Editor::PromptKind::ReplaceFind);
|
||||||
|
|
||||||
|
// Accept empty find -> proceed to ReplaceWith.
|
||||||
|
ASSERT_TRUE(h.Exec(CommandId::Newline));
|
||||||
|
ASSERT_TRUE(ed.PromptActive());
|
||||||
|
ASSERT_EQ(ed.CurrentPromptKind(), Editor::PromptKind::ReplaceWith);
|
||||||
|
|
||||||
|
// Provide replacement and accept -> should cancel due to empty find.
|
||||||
|
ASSERT_TRUE(h.Exec(CommandId::InsertText, "X"));
|
||||||
|
ASSERT_TRUE(h.Exec(CommandId::Newline));
|
||||||
|
|
||||||
|
ASSERT_TRUE(!ed.PromptActive());
|
||||||
|
ASSERT_TRUE(!ed.SearchActive());
|
||||||
|
ASSERT_EQ(h.Text(), before);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
TEST (SearchFlow_RegexFind_InvalidPattern_FailsSafely_And_ClearsStateOnEnter)
|
||||||
|
{
|
||||||
|
TestHarness h;
|
||||||
|
Editor &ed = h.EditorRef();
|
||||||
|
Buffer &b = h.Buf();
|
||||||
|
|
||||||
|
b.insert_text(0, 0, "abc\ndef\n");
|
||||||
|
b.SetCursor(1, 0);
|
||||||
|
b.SetOffsets(0, 0);
|
||||||
|
|
||||||
|
const std::size_t ox = b.Curx();
|
||||||
|
const std::size_t oy = b.Cury();
|
||||||
|
|
||||||
|
ASSERT_TRUE(h.Exec(CommandId::RegexFindStart));
|
||||||
|
ASSERT_TRUE(ed.PromptActive());
|
||||||
|
ASSERT_EQ(ed.CurrentPromptKind(), Editor::PromptKind::RegexSearch);
|
||||||
|
|
||||||
|
// Invalid regex should not crash; cursor should remain at origin due to no matches.
|
||||||
|
ASSERT_TRUE(h.Exec(CommandId::InsertText, "("));
|
||||||
|
ASSERT_EQ(b.Curx(), ox);
|
||||||
|
ASSERT_EQ(b.Cury(), oy);
|
||||||
|
|
||||||
|
ASSERT_TRUE(h.Exec(CommandId::Newline));
|
||||||
|
ASSERT_TRUE(!ed.PromptActive());
|
||||||
|
ASSERT_TRUE(!ed.SearchActive());
|
||||||
|
}
|
||||||
131
tests/test_swap_cleanup.cc
Normal file
131
tests/test_swap_cleanup.cc
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
#include "Test.h"
|
||||||
|
|
||||||
|
#include "Command.h"
|
||||||
|
#include "Editor.h"
|
||||||
|
|
||||||
|
#include "tests/TestHarness.h" // for ktet::InstallDefaultCommandsOnce
|
||||||
|
|
||||||
|
#include <cstdio>
|
||||||
|
#include <cstdlib>
|
||||||
|
#include <filesystem>
|
||||||
|
#include <fstream>
|
||||||
|
#include <string>
|
||||||
|
#include <unistd.h>
|
||||||
|
|
||||||
|
namespace fs = std::filesystem;
|
||||||
|
|
||||||
|
|
||||||
|
static void
|
||||||
|
write_file_bytes(const std::string &path, const std::string &bytes)
|
||||||
|
{
|
||||||
|
std::ofstream out(path, std::ios::binary | std::ios::trunc);
|
||||||
|
out.write(bytes.data(), (std::streamsize) bytes.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
TEST (SwapCleanup_ResetJournalOnSave)
|
||||||
|
{
|
||||||
|
ktet::InstallDefaultCommandsOnce();
|
||||||
|
|
||||||
|
const fs::path xdg_root = fs::temp_directory_path() /
|
||||||
|
(std::string("kte_ut_xdg_state_swap_cleanup_") + std::to_string((int) ::getpid()));
|
||||||
|
fs::remove_all(xdg_root);
|
||||||
|
fs::create_directories(xdg_root);
|
||||||
|
|
||||||
|
const char *old_xdg_p = std::getenv("XDG_STATE_HOME");
|
||||||
|
const std::string old_xdg = old_xdg_p ? std::string(old_xdg_p) : std::string();
|
||||||
|
const std::string xdg_s = xdg_root.string();
|
||||||
|
setenv("XDG_STATE_HOME", xdg_s.c_str(), 1);
|
||||||
|
|
||||||
|
const std::string path = (xdg_root / "work" / "file.txt").string();
|
||||||
|
fs::create_directories((xdg_root / "work"));
|
||||||
|
std::remove(path.c_str());
|
||||||
|
write_file_bytes(path, "base\n");
|
||||||
|
|
||||||
|
Editor ed;
|
||||||
|
ed.SetDimensions(24, 80);
|
||||||
|
// Seed scratch buffer so OpenFile can reuse it.
|
||||||
|
ed.AddBuffer(Buffer());
|
||||||
|
std::string err;
|
||||||
|
ASSERT_TRUE(ed.OpenFile(path, err));
|
||||||
|
Buffer *b = ed.CurrentBuffer();
|
||||||
|
ASSERT_TRUE(b != nullptr);
|
||||||
|
|
||||||
|
// Edit to ensure swap is created.
|
||||||
|
ASSERT_TRUE(Execute(ed, CommandId::MoveFileStart));
|
||||||
|
ASSERT_TRUE(Execute(ed, CommandId::InsertText, "X"));
|
||||||
|
ASSERT_TRUE(b->Dirty());
|
||||||
|
|
||||||
|
ed.Swap()->Flush(b);
|
||||||
|
const std::string swp = kte::SwapManager::ComputeSwapPathForTests(*b);
|
||||||
|
ASSERT_TRUE(fs::exists(swp));
|
||||||
|
|
||||||
|
// Save should reset/delete the journal.
|
||||||
|
ASSERT_TRUE(Execute(ed, CommandId::Save));
|
||||||
|
ed.Swap()->Flush(b);
|
||||||
|
ASSERT_TRUE(!fs::exists(swp));
|
||||||
|
|
||||||
|
// Subsequent edits should recreate a fresh swap.
|
||||||
|
ASSERT_TRUE(Execute(ed, CommandId::InsertText, "Y"));
|
||||||
|
ed.Swap()->Flush(b);
|
||||||
|
ASSERT_TRUE(fs::exists(swp));
|
||||||
|
|
||||||
|
// Cleanup.
|
||||||
|
ed.Swap()->Detach(b);
|
||||||
|
std::remove(path.c_str());
|
||||||
|
std::remove(swp.c_str());
|
||||||
|
if (!old_xdg.empty())
|
||||||
|
setenv("XDG_STATE_HOME", old_xdg.c_str(), 1);
|
||||||
|
else
|
||||||
|
unsetenv("XDG_STATE_HOME");
|
||||||
|
fs::remove_all(xdg_root);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
TEST (SwapCleanup_PruneSwapDir_ByAge)
|
||||||
|
{
|
||||||
|
const fs::path xdg_root = fs::temp_directory_path() /
|
||||||
|
(std::string("kte_ut_xdg_state_swap_prune_") + std::to_string((int) ::getpid()));
|
||||||
|
fs::remove_all(xdg_root);
|
||||||
|
fs::create_directories(xdg_root);
|
||||||
|
|
||||||
|
const char *old_xdg_p = std::getenv("XDG_STATE_HOME");
|
||||||
|
const std::string old_xdg = old_xdg_p ? std::string(old_xdg_p) : std::string();
|
||||||
|
const std::string xdg_s = xdg_root.string();
|
||||||
|
setenv("XDG_STATE_HOME", xdg_s.c_str(), 1);
|
||||||
|
|
||||||
|
const fs::path swapdir = xdg_root / "kte" / "swap";
|
||||||
|
fs::create_directories(swapdir);
|
||||||
|
const fs::path oldp = swapdir / "old.swp";
|
||||||
|
const fs::path newp = swapdir / "new.swp";
|
||||||
|
const fs::path keep = swapdir / "note.txt";
|
||||||
|
write_file_bytes(oldp.string(), "x");
|
||||||
|
write_file_bytes(newp.string(), "y");
|
||||||
|
write_file_bytes(keep.string(), "z");
|
||||||
|
|
||||||
|
// Make old.swp look old (2 days ago) and new.swp recent.
|
||||||
|
std::error_code ec;
|
||||||
|
fs::last_write_time(oldp, fs::file_time_type::clock::now() - std::chrono::hours(48), ec);
|
||||||
|
fs::last_write_time(newp, fs::file_time_type::clock::now(), ec);
|
||||||
|
|
||||||
|
kte::SwapManager sm;
|
||||||
|
kte::SwapConfig cfg;
|
||||||
|
cfg.prune_on_startup = false;
|
||||||
|
cfg.prune_max_age_days = 1;
|
||||||
|
cfg.prune_max_files = 0; // disable count-based pruning for this test
|
||||||
|
sm.SetConfig(cfg);
|
||||||
|
sm.PruneSwapDir();
|
||||||
|
|
||||||
|
ASSERT_TRUE(!fs::exists(oldp));
|
||||||
|
ASSERT_TRUE(fs::exists(newp));
|
||||||
|
ASSERT_TRUE(fs::exists(keep));
|
||||||
|
|
||||||
|
// Cleanup.
|
||||||
|
std::remove(newp.string().c_str());
|
||||||
|
std::remove(keep.string().c_str());
|
||||||
|
if (!old_xdg.empty())
|
||||||
|
setenv("XDG_STATE_HOME", old_xdg.c_str(), 1);
|
||||||
|
else
|
||||||
|
unsetenv("XDG_STATE_HOME");
|
||||||
|
fs::remove_all(xdg_root);
|
||||||
|
}
|
||||||
94
tests/test_swap_git_editor.cc
Normal file
94
tests/test_swap_git_editor.cc
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
#include "Test.h"
|
||||||
|
|
||||||
|
#include "Command.h"
|
||||||
|
#include "Editor.h"
|
||||||
|
|
||||||
|
#include "tests/TestHarness.h"
|
||||||
|
|
||||||
|
#include <cstdio>
|
||||||
|
#include <cstdlib>
|
||||||
|
#include <filesystem>
|
||||||
|
#include <fstream>
|
||||||
|
#include <string>
|
||||||
|
#include <unistd.h>
|
||||||
|
|
||||||
|
namespace fs = std::filesystem;
|
||||||
|
|
||||||
|
|
||||||
|
static void
|
||||||
|
write_file_bytes(const std::string &path, const std::string &bytes)
|
||||||
|
{
|
||||||
|
std::ofstream out(path, std::ios::binary | std::ios::trunc);
|
||||||
|
out.write(bytes.data(), (std::streamsize) bytes.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Simulate git editor workflow: open file, edit, save, edit more, close.
|
||||||
|
// The swap file should be deleted on close, not left behind.
|
||||||
|
TEST (SwapCleanup_GitEditorWorkflow)
|
||||||
|
{
|
||||||
|
ktet::InstallDefaultCommandsOnce();
|
||||||
|
|
||||||
|
const fs::path xdg_root = fs::temp_directory_path() /
|
||||||
|
(std::string("kte_ut_xdg_state_git_editor_") + std::to_string((int) ::getpid()));
|
||||||
|
fs::remove_all(xdg_root);
|
||||||
|
fs::create_directories(xdg_root);
|
||||||
|
|
||||||
|
const char *old_xdg_p = std::getenv("XDG_STATE_HOME");
|
||||||
|
const std::string old_xdg = old_xdg_p ? std::string(old_xdg_p) : std::string();
|
||||||
|
const std::string xdg_s = xdg_root.string();
|
||||||
|
setenv("XDG_STATE_HOME", xdg_s.c_str(), 1);
|
||||||
|
|
||||||
|
// Simulate git's COMMIT_EDITMSG path
|
||||||
|
const std::string path = (xdg_root / ".git" / "COMMIT_EDITMSG").string();
|
||||||
|
fs::create_directories((xdg_root / ".git"));
|
||||||
|
std::remove(path.c_str());
|
||||||
|
write_file_bytes(path, "# Enter commit message\n");
|
||||||
|
|
||||||
|
Editor ed;
|
||||||
|
ed.SetDimensions(24, 80);
|
||||||
|
ed.AddBuffer(Buffer());
|
||||||
|
std::string err;
|
||||||
|
ASSERT_TRUE(ed.OpenFile(path, err));
|
||||||
|
Buffer *b = ed.CurrentBuffer();
|
||||||
|
ASSERT_TRUE(b != nullptr);
|
||||||
|
|
||||||
|
// User edits the file
|
||||||
|
ASSERT_TRUE(Execute(ed, CommandId::MoveFileStart));
|
||||||
|
ASSERT_TRUE(Execute(ed, CommandId::InsertText, "X"));
|
||||||
|
ASSERT_TRUE(b->Dirty());
|
||||||
|
|
||||||
|
// User saves (git will read this)
|
||||||
|
ASSERT_TRUE(Execute(ed, CommandId::Save));
|
||||||
|
ASSERT_TRUE(!b->Dirty());
|
||||||
|
ed.Swap()->Flush(b);
|
||||||
|
|
||||||
|
const std::string swp = kte::SwapManager::ComputeSwapPathForTests(*b);
|
||||||
|
// After save, swap should be deleted
|
||||||
|
ASSERT_TRUE(!fs::exists(swp));
|
||||||
|
|
||||||
|
// User makes more edits (common in git editor workflow - refining message)
|
||||||
|
ASSERT_TRUE(Execute(ed, CommandId::InsertText, "Y"));
|
||||||
|
ASSERT_TRUE(b->Dirty());
|
||||||
|
ed.Swap()->Flush(b);
|
||||||
|
|
||||||
|
// Now there's a new swap file for the unsaved edits
|
||||||
|
ASSERT_TRUE(fs::exists(swp));
|
||||||
|
|
||||||
|
// User closes the buffer (or kte exits)
|
||||||
|
// This simulates what happens when git is done and kte closes
|
||||||
|
const std::size_t idx = ed.CurrentBufferIndex();
|
||||||
|
ed.CloseBuffer(idx);
|
||||||
|
|
||||||
|
// The swap file should be deleted on close, even though buffer was dirty
|
||||||
|
// This prevents stale swap files when used as git editor
|
||||||
|
ASSERT_TRUE(!fs::exists(swp));
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
std::remove(path.c_str());
|
||||||
|
if (!old_xdg.empty())
|
||||||
|
setenv("XDG_STATE_HOME", old_xdg.c_str(), 1);
|
||||||
|
else
|
||||||
|
unsetenv("XDG_STATE_HOME");
|
||||||
|
fs::remove_all(xdg_root);
|
||||||
|
}
|
||||||
104
tests/test_swap_recorder.cc
Normal file
104
tests/test_swap_recorder.cc
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
#include "Test.h"
|
||||||
|
|
||||||
|
#include "Buffer.h"
|
||||||
|
#include "SwapRecorder.h"
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
struct SwapEvent {
|
||||||
|
enum class Type {
|
||||||
|
Insert,
|
||||||
|
Delete,
|
||||||
|
};
|
||||||
|
|
||||||
|
Type type;
|
||||||
|
int row;
|
||||||
|
int col;
|
||||||
|
std::string bytes;
|
||||||
|
std::size_t len = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
class FakeSwapRecorder final : public kte::SwapRecorder {
|
||||||
|
public:
|
||||||
|
std::vector<SwapEvent> events;
|
||||||
|
|
||||||
|
|
||||||
|
void OnInsert(int row, int col, std::string_view bytes) override
|
||||||
|
{
|
||||||
|
SwapEvent e;
|
||||||
|
e.type = SwapEvent::Type::Insert;
|
||||||
|
e.row = row;
|
||||||
|
e.col = col;
|
||||||
|
e.bytes = std::string(bytes);
|
||||||
|
e.len = 0;
|
||||||
|
events.push_back(std::move(e));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
void OnDelete(int row, int col, std::size_t len) override
|
||||||
|
{
|
||||||
|
SwapEvent e;
|
||||||
|
e.type = SwapEvent::Type::Delete;
|
||||||
|
e.row = row;
|
||||||
|
e.col = col;
|
||||||
|
e.len = len;
|
||||||
|
events.push_back(std::move(e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
|
||||||
|
TEST (SwapRecorder_InsertABC)
|
||||||
|
{
|
||||||
|
Buffer b;
|
||||||
|
FakeSwapRecorder rec;
|
||||||
|
b.SetSwapRecorder(&rec);
|
||||||
|
|
||||||
|
b.insert_text(0, 0, std::string_view("abc"));
|
||||||
|
|
||||||
|
ASSERT_EQ(rec.events.size(), (std::size_t) 1);
|
||||||
|
ASSERT_TRUE(rec.events[0].type == SwapEvent::Type::Insert);
|
||||||
|
ASSERT_EQ(rec.events[0].row, 0);
|
||||||
|
ASSERT_EQ(rec.events[0].col, 0);
|
||||||
|
ASSERT_EQ(rec.events[0].bytes, std::string("abc"));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
TEST (SwapRecorder_InsertNewline)
|
||||||
|
{
|
||||||
|
Buffer b;
|
||||||
|
FakeSwapRecorder rec;
|
||||||
|
b.SetSwapRecorder(&rec);
|
||||||
|
|
||||||
|
b.split_line(0, 0);
|
||||||
|
|
||||||
|
ASSERT_EQ(rec.events.size(), (std::size_t) 1);
|
||||||
|
ASSERT_TRUE(rec.events[0].type == SwapEvent::Type::Insert);
|
||||||
|
ASSERT_EQ(rec.events[0].row, 0);
|
||||||
|
ASSERT_EQ(rec.events[0].col, 0);
|
||||||
|
ASSERT_EQ(rec.events[0].bytes, std::string("\n"));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
TEST (SwapRecorder_DeleteSpanningNewline)
|
||||||
|
{
|
||||||
|
Buffer b;
|
||||||
|
// Prepare content without a recorder (should be no-op)
|
||||||
|
b.insert_text(0, 0, std::string_view("ab"));
|
||||||
|
b.split_line(0, 2);
|
||||||
|
b.insert_text(1, 0, std::string_view("cd"));
|
||||||
|
|
||||||
|
FakeSwapRecorder rec;
|
||||||
|
b.SetSwapRecorder(&rec);
|
||||||
|
|
||||||
|
// Delete "b\n c" (3 bytes) starting at row 0, col 1.
|
||||||
|
b.delete_text(0, 1, 3);
|
||||||
|
|
||||||
|
ASSERT_EQ(rec.events.size(), (std::size_t) 1);
|
||||||
|
ASSERT_TRUE(rec.events[0].type == SwapEvent::Type::Delete);
|
||||||
|
ASSERT_EQ(rec.events[0].row, 0);
|
||||||
|
ASSERT_EQ(rec.events[0].col, 1);
|
||||||
|
ASSERT_EQ(rec.events[0].len, (std::size_t) 3);
|
||||||
|
}
|
||||||
280
tests/test_swap_recovery_prompt.cc
Normal file
280
tests/test_swap_recovery_prompt.cc
Normal file
@@ -0,0 +1,280 @@
|
|||||||
|
#include "Test.h"
|
||||||
|
|
||||||
|
#include "Buffer.h"
|
||||||
|
#include "Command.h"
|
||||||
|
#include "Editor.h"
|
||||||
|
#include "Swap.h"
|
||||||
|
|
||||||
|
#include "tests/TestHarness.h" // for ktet::InstallDefaultCommandsOnce
|
||||||
|
|
||||||
|
#include <cstdio>
|
||||||
|
#include <cstdlib>
|
||||||
|
#include <filesystem>
|
||||||
|
#include <fstream>
|
||||||
|
#include <string>
|
||||||
|
#include <unistd.h>
|
||||||
|
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
static void
|
||||||
|
write_file_bytes(const std::string &path, const std::string &bytes)
|
||||||
|
{
|
||||||
|
std::ofstream out(path, std::ios::binary | std::ios::trunc);
|
||||||
|
out.write(bytes.data(), (std::streamsize) bytes.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
static std::string
|
||||||
|
read_file_bytes(const std::string &path)
|
||||||
|
{
|
||||||
|
std::ifstream in(path, std::ios::binary);
|
||||||
|
return std::string((std::istreambuf_iterator<char>(in)), std::istreambuf_iterator<char>());
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
static std::string
|
||||||
|
buffer_bytes_via_views(const Buffer &b)
|
||||||
|
{
|
||||||
|
const auto &rows = b.Rows();
|
||||||
|
std::string out;
|
||||||
|
for (std::size_t i = 0; i < rows.size(); i++) {
|
||||||
|
auto v = b.GetLineView(i);
|
||||||
|
out.append(v.data(), v.size());
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
struct ScopedXdgStateHome {
|
||||||
|
std::string old;
|
||||||
|
bool had{false};
|
||||||
|
|
||||||
|
|
||||||
|
explicit ScopedXdgStateHome(const std::string &p)
|
||||||
|
{
|
||||||
|
const char *old_p = std::getenv("XDG_STATE_HOME");
|
||||||
|
had = (old_p && *old_p);
|
||||||
|
old = old_p ? std::string(old_p) : std::string();
|
||||||
|
setenv("XDG_STATE_HOME", p.c_str(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
~ScopedXdgStateHome()
|
||||||
|
{
|
||||||
|
if (had && !old.empty()) {
|
||||||
|
setenv("XDG_STATE_HOME", old.c_str(), 1);
|
||||||
|
} else {
|
||||||
|
unsetenv("XDG_STATE_HOME");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
|
||||||
|
TEST (SwapRecoveryPrompt_Recover_ReplaysSwap)
|
||||||
|
{
|
||||||
|
ktet::InstallDefaultCommandsOnce();
|
||||||
|
|
||||||
|
const std::filesystem::path xdg_root = std::filesystem::temp_directory_path() /
|
||||||
|
(std::string("kte_ut_xdg_state_recover_") +
|
||||||
|
std::to_string((int) ::getpid()));
|
||||||
|
std::filesystem::remove_all(xdg_root);
|
||||||
|
std::filesystem::create_directories(xdg_root);
|
||||||
|
const ScopedXdgStateHome scoped(xdg_root.string());
|
||||||
|
|
||||||
|
const std::filesystem::path work = xdg_root / "work";
|
||||||
|
std::filesystem::create_directories(work);
|
||||||
|
const std::string file_path = (work / "recover.txt").string();
|
||||||
|
write_file_bytes(file_path, "base\nline2\n");
|
||||||
|
|
||||||
|
// Create a swap journal with unsaved edits.
|
||||||
|
Buffer b;
|
||||||
|
std::string err;
|
||||||
|
ASSERT_TRUE(b.OpenFromFile(file_path, err));
|
||||||
|
kte::SwapManager sm;
|
||||||
|
sm.Attach(&b);
|
||||||
|
b.SetSwapRecorder(sm.RecorderFor(&b));
|
||||||
|
b.insert_text(0, 0, std::string("X"));
|
||||||
|
b.insert_text(1, 0, std::string("ZZ"));
|
||||||
|
sm.Flush(&b);
|
||||||
|
const std::string swap_path = kte::SwapManager::ComputeSwapPathForTests(b);
|
||||||
|
const std::string expected = buffer_bytes_via_views(b);
|
||||||
|
b.SetSwapRecorder(nullptr);
|
||||||
|
sm.Detach(&b);
|
||||||
|
|
||||||
|
// Now attempt to open via Editor deferred-open; this should trigger a recovery prompt.
|
||||||
|
Editor ed;
|
||||||
|
ed.SetDimensions(24, 80);
|
||||||
|
ed.AddBuffer(Buffer());
|
||||||
|
ed.RequestOpenFile(b.Filename());
|
||||||
|
ASSERT_EQ(ed.ProcessPendingOpens(), false);
|
||||||
|
ASSERT_EQ(ed.PendingRecoveryPrompt(), Editor::RecoveryPromptKind::RecoverOrDiscard);
|
||||||
|
ASSERT_EQ(ed.PromptActive(), true);
|
||||||
|
|
||||||
|
// Answer 'y' to recover.
|
||||||
|
ASSERT_TRUE(Execute(ed, CommandId::InsertText, "y"));
|
||||||
|
ASSERT_TRUE(Execute(ed, CommandId::Newline));
|
||||||
|
ASSERT_EQ(ed.PendingRecoveryPrompt(), Editor::RecoveryPromptKind::None);
|
||||||
|
ASSERT_EQ(ed.PromptActive(), false);
|
||||||
|
ASSERT_TRUE(ed.CurrentBuffer() != nullptr);
|
||||||
|
ASSERT_EQ(buffer_bytes_via_views(*ed.CurrentBuffer()), expected);
|
||||||
|
ASSERT_EQ(ed.CurrentBuffer()->Dirty(), true);
|
||||||
|
ASSERT_TRUE(std::filesystem::exists(swap_path));
|
||||||
|
|
||||||
|
std::remove(file_path.c_str());
|
||||||
|
std::remove(swap_path.c_str());
|
||||||
|
std::filesystem::remove_all(xdg_root);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
TEST (SwapRecoveryPrompt_Discard_DeletesSwapAndOpensClean)
|
||||||
|
{
|
||||||
|
ktet::InstallDefaultCommandsOnce();
|
||||||
|
|
||||||
|
const std::filesystem::path xdg_root = std::filesystem::temp_directory_path() /
|
||||||
|
(std::string("kte_ut_xdg_state_discard_") +
|
||||||
|
std::to_string((int) ::getpid()));
|
||||||
|
std::filesystem::remove_all(xdg_root);
|
||||||
|
std::filesystem::create_directories(xdg_root);
|
||||||
|
const ScopedXdgStateHome scoped(xdg_root.string());
|
||||||
|
|
||||||
|
const std::filesystem::path work = xdg_root / "work";
|
||||||
|
std::filesystem::create_directories(work);
|
||||||
|
const std::string file_path = (work / "discard.txt").string();
|
||||||
|
write_file_bytes(file_path, "base\n");
|
||||||
|
|
||||||
|
Buffer b;
|
||||||
|
std::string err;
|
||||||
|
ASSERT_TRUE(b.OpenFromFile(file_path, err));
|
||||||
|
kte::SwapManager sm;
|
||||||
|
sm.Attach(&b);
|
||||||
|
b.SetSwapRecorder(sm.RecorderFor(&b));
|
||||||
|
b.insert_text(0, 0, std::string("X"));
|
||||||
|
sm.Flush(&b);
|
||||||
|
const std::string swap_path = kte::SwapManager::ComputeSwapPathForTests(b);
|
||||||
|
b.SetSwapRecorder(nullptr);
|
||||||
|
sm.Detach(&b);
|
||||||
|
ASSERT_TRUE(std::filesystem::exists(swap_path));
|
||||||
|
|
||||||
|
Editor ed;
|
||||||
|
ed.SetDimensions(24, 80);
|
||||||
|
ed.AddBuffer(Buffer());
|
||||||
|
ed.RequestOpenFile(b.Filename());
|
||||||
|
ASSERT_EQ(ed.ProcessPendingOpens(), false);
|
||||||
|
ASSERT_EQ(ed.PendingRecoveryPrompt(), Editor::RecoveryPromptKind::RecoverOrDiscard);
|
||||||
|
ASSERT_EQ(ed.PromptActive(), true);
|
||||||
|
|
||||||
|
// Default answer (empty) is 'no' => discard.
|
||||||
|
ASSERT_TRUE(Execute(ed, CommandId::Newline));
|
||||||
|
ASSERT_EQ(ed.PendingRecoveryPrompt(), Editor::RecoveryPromptKind::None);
|
||||||
|
ASSERT_EQ(ed.PromptActive(), false);
|
||||||
|
ASSERT_TRUE(ed.CurrentBuffer() != nullptr);
|
||||||
|
ASSERT_EQ(buffer_bytes_via_views(*ed.CurrentBuffer()), read_file_bytes(b.Filename()));
|
||||||
|
ASSERT_EQ(ed.CurrentBuffer()->Dirty(), false);
|
||||||
|
ASSERT_EQ(std::filesystem::exists(swap_path), false);
|
||||||
|
|
||||||
|
std::remove(file_path.c_str());
|
||||||
|
std::filesystem::remove_all(xdg_root);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
TEST (SwapRecoveryPrompt_Cancel_AbortsOpen)
|
||||||
|
{
|
||||||
|
ktet::InstallDefaultCommandsOnce();
|
||||||
|
|
||||||
|
const std::filesystem::path xdg_root = std::filesystem::temp_directory_path() /
|
||||||
|
(std::string("kte_ut_xdg_state_cancel_") +
|
||||||
|
std::to_string((int) ::getpid()));
|
||||||
|
std::filesystem::remove_all(xdg_root);
|
||||||
|
std::filesystem::create_directories(xdg_root);
|
||||||
|
const ScopedXdgStateHome scoped(xdg_root.string());
|
||||||
|
|
||||||
|
const std::filesystem::path work = xdg_root / "work";
|
||||||
|
std::filesystem::create_directories(work);
|
||||||
|
const std::string file_path = (work / "cancel.txt").string();
|
||||||
|
write_file_bytes(file_path, "base\n");
|
||||||
|
|
||||||
|
Buffer b;
|
||||||
|
std::string err;
|
||||||
|
ASSERT_TRUE(b.OpenFromFile(file_path, err));
|
||||||
|
kte::SwapManager sm;
|
||||||
|
sm.Attach(&b);
|
||||||
|
b.SetSwapRecorder(sm.RecorderFor(&b));
|
||||||
|
b.insert_text(0, 0, std::string("X"));
|
||||||
|
sm.Flush(&b);
|
||||||
|
const std::string swap_path = kte::SwapManager::ComputeSwapPathForTests(b);
|
||||||
|
b.SetSwapRecorder(nullptr);
|
||||||
|
sm.Detach(&b);
|
||||||
|
|
||||||
|
Editor ed;
|
||||||
|
ed.SetDimensions(24, 80);
|
||||||
|
ed.AddBuffer(Buffer());
|
||||||
|
ed.RequestOpenFile(b.Filename());
|
||||||
|
ASSERT_EQ(ed.ProcessPendingOpens(), false);
|
||||||
|
ASSERT_EQ(ed.PendingRecoveryPrompt(), Editor::RecoveryPromptKind::RecoverOrDiscard);
|
||||||
|
ASSERT_EQ(ed.PromptActive(), true);
|
||||||
|
|
||||||
|
// Cancel the prompt (C-g / Refresh).
|
||||||
|
ASSERT_TRUE(Execute(ed, CommandId::Refresh));
|
||||||
|
ASSERT_EQ(ed.PendingRecoveryPrompt(), Editor::RecoveryPromptKind::None);
|
||||||
|
ASSERT_EQ(ed.PromptActive(), false);
|
||||||
|
ASSERT_TRUE(ed.CurrentBuffer() != nullptr);
|
||||||
|
ASSERT_EQ(ed.CurrentBuffer()->Filename().empty(), true);
|
||||||
|
ASSERT_TRUE(std::filesystem::exists(swap_path));
|
||||||
|
|
||||||
|
std::remove(file_path.c_str());
|
||||||
|
std::remove(swap_path.c_str());
|
||||||
|
std::filesystem::remove_all(xdg_root);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
TEST (SwapRecoveryPrompt_CorruptSwap_OffersDelete)
|
||||||
|
{
|
||||||
|
ktet::InstallDefaultCommandsOnce();
|
||||||
|
|
||||||
|
const std::filesystem::path xdg_root = std::filesystem::temp_directory_path() /
|
||||||
|
(std::string("kte_ut_xdg_state_corrupt_") +
|
||||||
|
std::to_string((int) ::getpid()));
|
||||||
|
std::filesystem::remove_all(xdg_root);
|
||||||
|
std::filesystem::create_directories(xdg_root);
|
||||||
|
const ScopedXdgStateHome scoped(xdg_root.string());
|
||||||
|
|
||||||
|
const std::filesystem::path work = xdg_root / "work";
|
||||||
|
std::filesystem::create_directories(work);
|
||||||
|
const std::string file_path = (work / "corrupt.txt").string();
|
||||||
|
write_file_bytes(file_path, "base\n");
|
||||||
|
|
||||||
|
Buffer b;
|
||||||
|
std::string err;
|
||||||
|
ASSERT_TRUE(b.OpenFromFile(file_path, err));
|
||||||
|
const std::string swap_path = kte::SwapManager::ComputeSwapPathForTests(b);
|
||||||
|
|
||||||
|
// Write a corrupt swap file at the expected location.
|
||||||
|
try {
|
||||||
|
std::filesystem::create_directories(std::filesystem::path(swap_path).parent_path());
|
||||||
|
} catch (...) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
write_file_bytes(swap_path, "x");
|
||||||
|
ASSERT_TRUE(std::filesystem::exists(swap_path));
|
||||||
|
|
||||||
|
Editor ed;
|
||||||
|
ed.SetDimensions(24, 80);
|
||||||
|
ed.AddBuffer(Buffer());
|
||||||
|
ed.RequestOpenFile(b.Filename());
|
||||||
|
ASSERT_EQ(ed.ProcessPendingOpens(), false);
|
||||||
|
ASSERT_EQ(ed.PendingRecoveryPrompt(), Editor::RecoveryPromptKind::DeleteCorruptSwap);
|
||||||
|
ASSERT_EQ(ed.PromptActive(), true);
|
||||||
|
|
||||||
|
// Answer 'y' to delete the corrupt swap and proceed.
|
||||||
|
ASSERT_TRUE(Execute(ed, CommandId::InsertText, "y"));
|
||||||
|
ASSERT_TRUE(Execute(ed, CommandId::Newline));
|
||||||
|
ASSERT_EQ(ed.PendingRecoveryPrompt(), Editor::RecoveryPromptKind::None);
|
||||||
|
ASSERT_EQ(ed.PromptActive(), false);
|
||||||
|
ASSERT_TRUE(ed.CurrentBuffer() != nullptr);
|
||||||
|
ASSERT_EQ(buffer_bytes_via_views(*ed.CurrentBuffer()), read_file_bytes(b.Filename()));
|
||||||
|
ASSERT_EQ(std::filesystem::exists(swap_path), false);
|
||||||
|
|
||||||
|
std::remove(file_path.c_str());
|
||||||
|
std::filesystem::remove_all(xdg_root);
|
||||||
|
}
|
||||||
227
tests/test_swap_replay.cc
Normal file
227
tests/test_swap_replay.cc
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
#include "Test.h"
|
||||||
|
|
||||||
|
#include "Buffer.h"
|
||||||
|
#include "Swap.h"
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
#include <cstdio>
|
||||||
|
#include <fstream>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
|
||||||
|
static void
|
||||||
|
write_file_bytes(const std::string &path, const std::string &bytes)
|
||||||
|
{
|
||||||
|
std::ofstream out(path, std::ios::binary | std::ios::trunc);
|
||||||
|
out.write(bytes.data(), (std::streamsize) bytes.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
static std::string
|
||||||
|
read_file_bytes(const std::string &path)
|
||||||
|
{
|
||||||
|
std::ifstream in(path, std::ios::binary);
|
||||||
|
return std::string((std::istreambuf_iterator<char>(in)), std::istreambuf_iterator<char>());
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
static std::string
|
||||||
|
buffer_bytes_via_views(const Buffer &b)
|
||||||
|
{
|
||||||
|
const auto &rows = b.Rows();
|
||||||
|
std::string out;
|
||||||
|
for (std::size_t i = 0; i < rows.size(); i++) {
|
||||||
|
auto v = b.GetLineView(i);
|
||||||
|
out.append(v.data(), v.size());
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
static std::vector<std::uint8_t>
|
||||||
|
record_types_from_bytes(const std::string &bytes)
|
||||||
|
{
|
||||||
|
std::vector<std::uint8_t> types;
|
||||||
|
if (bytes.size() < 64)
|
||||||
|
return types;
|
||||||
|
std::size_t off = 64;
|
||||||
|
while (off < bytes.size()) {
|
||||||
|
if (bytes.size() - off < 8)
|
||||||
|
break;
|
||||||
|
const std::uint8_t type = static_cast<std::uint8_t>(bytes[off + 0]);
|
||||||
|
const std::uint32_t len = (std::uint32_t) static_cast<std::uint8_t>(bytes[off + 1]) |
|
||||||
|
((std::uint32_t) static_cast<std::uint8_t>(bytes[off + 2]) << 8) |
|
||||||
|
((std::uint32_t) static_cast<std::uint8_t>(bytes[off + 3]) << 16);
|
||||||
|
const std::size_t crc_off = off + 4 + (std::size_t) len;
|
||||||
|
if (crc_off + 4 > bytes.size())
|
||||||
|
break;
|
||||||
|
types.push_back(type);
|
||||||
|
off = crc_off + 4;
|
||||||
|
}
|
||||||
|
return types;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
TEST (SwapReplay_RecordFlushReopenReplay_ExactBytesMatch)
|
||||||
|
{
|
||||||
|
const std::string path = "./.kte_ut_swap_replay_1.txt";
|
||||||
|
std::remove(path.c_str());
|
||||||
|
write_file_bytes(path, "base\nline2\n");
|
||||||
|
|
||||||
|
Buffer b;
|
||||||
|
std::string err;
|
||||||
|
ASSERT_TRUE(b.OpenFromFile(path, err));
|
||||||
|
|
||||||
|
kte::SwapManager sm;
|
||||||
|
sm.Attach(&b);
|
||||||
|
b.SetSwapRecorder(sm.RecorderFor(&b));
|
||||||
|
|
||||||
|
// Edits (no save): swap should capture these.
|
||||||
|
b.insert_text(0, 0, std::string("X")); // Xbase\nline2\n
|
||||||
|
b.delete_text(1, 1, 2); // delete "in" from "line2"
|
||||||
|
b.split_line(0, 3); // Xba\nse...
|
||||||
|
b.join_lines(0); // join back
|
||||||
|
b.insert_text(1, 0, std::string("ZZ")); // insert at start of line2
|
||||||
|
b.delete_text(0, 0, 1); // delete leading X
|
||||||
|
|
||||||
|
sm.Flush(&b);
|
||||||
|
const std::string swap_path = kte::SwapManager::ComputeSwapPathForTests(b);
|
||||||
|
const std::string expected = buffer_bytes_via_views(b);
|
||||||
|
|
||||||
|
// Close journal before replaying (for determinism)
|
||||||
|
b.SetSwapRecorder(nullptr);
|
||||||
|
sm.Detach(&b);
|
||||||
|
|
||||||
|
Buffer b2;
|
||||||
|
ASSERT_TRUE(b2.OpenFromFile(path, err));
|
||||||
|
ASSERT_TRUE(kte::SwapManager::ReplayFile(b2, swap_path, err));
|
||||||
|
ASSERT_EQ(buffer_bytes_via_views(b2), expected);
|
||||||
|
|
||||||
|
std::remove(path.c_str());
|
||||||
|
std::remove(swap_path.c_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
TEST (SwapReplay_TruncatedLog_FailsSafely)
|
||||||
|
{
|
||||||
|
const std::string path = "./.kte_ut_swap_replay_2.txt";
|
||||||
|
std::remove(path.c_str());
|
||||||
|
write_file_bytes(path, "hello\n");
|
||||||
|
|
||||||
|
Buffer b;
|
||||||
|
std::string err;
|
||||||
|
ASSERT_TRUE(b.OpenFromFile(path, err));
|
||||||
|
|
||||||
|
kte::SwapManager sm;
|
||||||
|
sm.Attach(&b);
|
||||||
|
b.SetSwapRecorder(sm.RecorderFor(&b));
|
||||||
|
b.insert_text(0, 0, std::string("X"));
|
||||||
|
sm.Flush(&b);
|
||||||
|
const std::string swap_path = kte::SwapManager::ComputeSwapPathForTests(b);
|
||||||
|
b.SetSwapRecorder(nullptr);
|
||||||
|
sm.Detach(&b);
|
||||||
|
|
||||||
|
const std::string bytes = read_file_bytes(swap_path);
|
||||||
|
ASSERT_TRUE(bytes.size() > 70); // header + at least one record
|
||||||
|
|
||||||
|
const std::string trunc_path = swap_path + ".trunc";
|
||||||
|
write_file_bytes(trunc_path, bytes.substr(0, bytes.size() - 1));
|
||||||
|
|
||||||
|
Buffer b2;
|
||||||
|
ASSERT_TRUE(b2.OpenFromFile(path, err));
|
||||||
|
std::string rerr;
|
||||||
|
ASSERT_EQ(kte::SwapManager::ReplayFile(b2, trunc_path, rerr), false);
|
||||||
|
ASSERT_EQ(rerr.empty(), false);
|
||||||
|
|
||||||
|
std::remove(path.c_str());
|
||||||
|
std::remove(swap_path.c_str());
|
||||||
|
std::remove(trunc_path.c_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
TEST (SwapReplay_Checkpoint_Midstream_ExactBytesMatch)
|
||||||
|
{
|
||||||
|
const std::string path = "./.kte_ut_swap_replay_chkpt_1.txt";
|
||||||
|
std::remove(path.c_str());
|
||||||
|
write_file_bytes(path, "base\nline2\n");
|
||||||
|
|
||||||
|
Buffer b;
|
||||||
|
std::string err;
|
||||||
|
ASSERT_TRUE(b.OpenFromFile(path, err));
|
||||||
|
|
||||||
|
kte::SwapManager sm;
|
||||||
|
sm.Attach(&b);
|
||||||
|
b.SetSwapRecorder(sm.RecorderFor(&b));
|
||||||
|
|
||||||
|
// Some edits, then an explicit checkpoint, then more edits.
|
||||||
|
b.insert_text(0, 0, std::string("X"));
|
||||||
|
sm.Checkpoint(&b);
|
||||||
|
b.insert_text(1, 0, std::string("ZZ"));
|
||||||
|
b.delete_text(0, 0, 1);
|
||||||
|
|
||||||
|
sm.Flush(&b);
|
||||||
|
const std::string swap_path = kte::SwapManager::ComputeSwapPathForTests(b);
|
||||||
|
const std::string expected = buffer_bytes_via_views(b);
|
||||||
|
|
||||||
|
b.SetSwapRecorder(nullptr);
|
||||||
|
sm.Detach(&b);
|
||||||
|
|
||||||
|
Buffer b2;
|
||||||
|
ASSERT_TRUE(b2.OpenFromFile(path, err));
|
||||||
|
ASSERT_TRUE(kte::SwapManager::ReplayFile(b2, swap_path, err));
|
||||||
|
ASSERT_EQ(buffer_bytes_via_views(b2), expected);
|
||||||
|
|
||||||
|
std::remove(path.c_str());
|
||||||
|
std::remove(swap_path.c_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
TEST (SwapCompaction_RewritesToSingleCheckpoint)
|
||||||
|
{
|
||||||
|
const std::string path = "./.kte_ut_swap_compact_1.txt";
|
||||||
|
std::remove(path.c_str());
|
||||||
|
write_file_bytes(path, "base\n");
|
||||||
|
|
||||||
|
Buffer b;
|
||||||
|
std::string err;
|
||||||
|
ASSERT_TRUE(b.OpenFromFile(path, err));
|
||||||
|
|
||||||
|
kte::SwapManager sm;
|
||||||
|
kte::SwapConfig cfg;
|
||||||
|
cfg.checkpoint_bytes = 0;
|
||||||
|
cfg.checkpoint_interval_ms = 0;
|
||||||
|
cfg.compact_bytes = 1; // force compaction on any checkpoint
|
||||||
|
sm.SetConfig(cfg);
|
||||||
|
|
||||||
|
sm.Attach(&b);
|
||||||
|
b.SetSwapRecorder(sm.RecorderFor(&b));
|
||||||
|
|
||||||
|
// Ensure there is at least one non-checkpoint record on disk first.
|
||||||
|
b.insert_text(0, 0, std::string("abc"));
|
||||||
|
sm.Flush(&b);
|
||||||
|
|
||||||
|
// Now emit a checkpoint; compaction should rewrite the file to just that checkpoint.
|
||||||
|
sm.Checkpoint(&b);
|
||||||
|
sm.Flush(&b);
|
||||||
|
|
||||||
|
const std::string swap_path = kte::SwapManager::ComputeSwapPathForTests(b);
|
||||||
|
const std::string expected = buffer_bytes_via_views(b);
|
||||||
|
|
||||||
|
// Close journal.
|
||||||
|
b.SetSwapRecorder(nullptr);
|
||||||
|
sm.Detach(&b);
|
||||||
|
|
||||||
|
const std::string bytes = read_file_bytes(swap_path);
|
||||||
|
const std::vector<std::uint8_t> types = record_types_from_bytes(bytes);
|
||||||
|
ASSERT_EQ(types.size(), (std::size_t) 1);
|
||||||
|
ASSERT_EQ(types[0], (std::uint8_t) kte::SwapRecType::CHKPT);
|
||||||
|
|
||||||
|
Buffer b2;
|
||||||
|
ASSERT_TRUE(b2.OpenFromFile(path, err));
|
||||||
|
ASSERT_TRUE(kte::SwapManager::ReplayFile(b2, swap_path, err));
|
||||||
|
ASSERT_EQ(buffer_bytes_via_views(b2), expected);
|
||||||
|
|
||||||
|
std::remove(path.c_str());
|
||||||
|
std::remove(swap_path.c_str());
|
||||||
|
}
|
||||||
241
tests/test_swap_writer.cc
Normal file
241
tests/test_swap_writer.cc
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
#include "Test.h"
|
||||||
|
|
||||||
|
#include "Buffer.h"
|
||||||
|
#include "Swap.h"
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
#include <filesystem>
|
||||||
|
#include <fstream>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include <cstdlib>
|
||||||
|
#include <cstdio>
|
||||||
|
#include <unistd.h>
|
||||||
|
|
||||||
|
#include <sys/stat.h>
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
std::vector<std::uint8_t>
|
||||||
|
read_all_bytes(const std::string &path)
|
||||||
|
{
|
||||||
|
std::ifstream in(path, std::ios::binary);
|
||||||
|
return std::vector<std::uint8_t>((std::istreambuf_iterator<char>(in)), std::istreambuf_iterator<char>());
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
std::uint32_t
|
||||||
|
read_le32(const std::uint8_t *p)
|
||||||
|
{
|
||||||
|
return (std::uint32_t) p[0] | ((std::uint32_t) p[1] << 8) | ((std::uint32_t) p[2] << 16) |
|
||||||
|
((std::uint32_t) p[3] << 24);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
std::uint64_t
|
||||||
|
read_le64(const std::uint8_t *p)
|
||||||
|
{
|
||||||
|
std::uint64_t v = 0;
|
||||||
|
for (int i = 7; i >= 0; --i) {
|
||||||
|
v = (v << 8) | p[i];
|
||||||
|
}
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
std::uint32_t
|
||||||
|
crc32(const std::uint8_t *data, std::size_t len, std::uint32_t seed = 0)
|
||||||
|
{
|
||||||
|
static std::uint32_t table[256];
|
||||||
|
static bool inited = false;
|
||||||
|
if (!inited) {
|
||||||
|
for (std::uint32_t i = 0; i < 256; ++i) {
|
||||||
|
std::uint32_t c = i;
|
||||||
|
for (int j = 0; j < 8; ++j)
|
||||||
|
c = (c & 1) ? (0xEDB88320u ^ (c >> 1)) : (c >> 1);
|
||||||
|
table[i] = c;
|
||||||
|
}
|
||||||
|
inited = true;
|
||||||
|
}
|
||||||
|
std::uint32_t c = ~seed;
|
||||||
|
for (std::size_t i = 0; i < len; ++i)
|
||||||
|
c = table[(c ^ data[i]) & 0xFFu] ^ (c >> 8);
|
||||||
|
return ~c;
|
||||||
|
}
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
|
||||||
|
TEST (SwapWriter_Header_Records_And_CRC)
|
||||||
|
{
|
||||||
|
const std::filesystem::path xdg_root = std::filesystem::temp_directory_path() /
|
||||||
|
(std::string("kte_ut_xdg_state_") + std::to_string((int) ::getpid()));
|
||||||
|
std::filesystem::remove_all(xdg_root);
|
||||||
|
|
||||||
|
const char *old_xdg_p = std::getenv("XDG_STATE_HOME");
|
||||||
|
const std::string old_xdg = old_xdg_p ? std::string(old_xdg_p) : std::string();
|
||||||
|
const std::string xdg_root_s = xdg_root.string();
|
||||||
|
setenv("XDG_STATE_HOME", xdg_root_s.c_str(), 1);
|
||||||
|
|
||||||
|
const std::string path = (xdg_root / "work" / "kte_ut_swap_writer.txt").string();
|
||||||
|
std::filesystem::create_directories((xdg_root / "work"));
|
||||||
|
|
||||||
|
// Clean up from prior runs
|
||||||
|
std::remove(path.c_str());
|
||||||
|
|
||||||
|
// Ensure file exists so buffer is file-backed
|
||||||
|
{
|
||||||
|
std::ofstream out(path, std::ios::binary);
|
||||||
|
out << "";
|
||||||
|
}
|
||||||
|
|
||||||
|
Buffer b;
|
||||||
|
std::string err;
|
||||||
|
ASSERT_TRUE(b.OpenFromFile(path, err));
|
||||||
|
ASSERT_TRUE(err.empty());
|
||||||
|
ASSERT_TRUE(b.IsFileBacked());
|
||||||
|
|
||||||
|
kte::SwapManager sm;
|
||||||
|
sm.Attach(&b);
|
||||||
|
b.SetSwapRecorder(sm.RecorderFor(&b));
|
||||||
|
const std::string swp = kte::SwapManager::ComputeSwapPathForTests(b);
|
||||||
|
std::remove(swp.c_str());
|
||||||
|
|
||||||
|
// Emit one INS and one DEL
|
||||||
|
b.insert_text(0, 0, std::string_view("abc"));
|
||||||
|
b.delete_text(0, 1, 1);
|
||||||
|
|
||||||
|
// Ensure all records are written before reading
|
||||||
|
sm.Flush(&b);
|
||||||
|
sm.Detach(&b);
|
||||||
|
b.SetSwapRecorder(nullptr);
|
||||||
|
|
||||||
|
ASSERT_TRUE(std::filesystem::exists(swp));
|
||||||
|
|
||||||
|
// Verify permissions 0600
|
||||||
|
struct stat st{};
|
||||||
|
ASSERT_TRUE(::stat(swp.c_str(), &st) == 0);
|
||||||
|
ASSERT_EQ((st.st_mode & 0777), 0600);
|
||||||
|
|
||||||
|
const std::vector<std::uint8_t> bytes = read_all_bytes(swp);
|
||||||
|
ASSERT_TRUE(bytes.size() >= 64);
|
||||||
|
|
||||||
|
// Header
|
||||||
|
static const std::uint8_t magic[8] = {'K', 'T', 'E', '_', 'S', 'W', 'P', '\0'};
|
||||||
|
for (int i = 0; i < 8; ++i)
|
||||||
|
ASSERT_EQ(bytes[(std::size_t) i], magic[i]);
|
||||||
|
ASSERT_EQ(read_le32(bytes.data() + 8), (std::uint32_t) 1);
|
||||||
|
// flags currently 0
|
||||||
|
ASSERT_EQ(read_le32(bytes.data() + 12), (std::uint32_t) 0);
|
||||||
|
ASSERT_TRUE(read_le64(bytes.data() + 16) != 0);
|
||||||
|
|
||||||
|
// Records
|
||||||
|
std::vector<std::uint8_t> types;
|
||||||
|
std::size_t off = 64;
|
||||||
|
while (off < bytes.size()) {
|
||||||
|
ASSERT_TRUE(bytes.size() - off >= 8); // at least header+crc
|
||||||
|
const std::uint8_t type = bytes[off + 0];
|
||||||
|
const std::uint32_t len = (std::uint32_t) bytes[off + 1] | ((std::uint32_t) bytes[off + 2] << 8) |
|
||||||
|
((std::uint32_t) bytes[off + 3] << 16);
|
||||||
|
const std::size_t payload_off = off + 4;
|
||||||
|
const std::size_t crc_off = payload_off + len;
|
||||||
|
ASSERT_TRUE(crc_off + 4 <= bytes.size());
|
||||||
|
|
||||||
|
const std::uint32_t got_crc = read_le32(bytes.data() + crc_off);
|
||||||
|
std::uint32_t c = 0;
|
||||||
|
c = crc32(bytes.data() + off, 4, c);
|
||||||
|
c = crc32(bytes.data() + payload_off, len, c);
|
||||||
|
ASSERT_EQ(got_crc, c);
|
||||||
|
|
||||||
|
types.push_back(type);
|
||||||
|
off = crc_off + 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
ASSERT_EQ(types.size(), (std::size_t) 3);
|
||||||
|
ASSERT_EQ(types[0], (std::uint8_t) kte::SwapRecType::INS);
|
||||||
|
ASSERT_EQ(types[1], (std::uint8_t) kte::SwapRecType::DEL);
|
||||||
|
ASSERT_EQ(types[2], (std::uint8_t) kte::SwapRecType::CHKPT);
|
||||||
|
|
||||||
|
std::remove(path.c_str());
|
||||||
|
std::remove(swp.c_str());
|
||||||
|
if (!old_xdg.empty()) {
|
||||||
|
setenv("XDG_STATE_HOME", old_xdg.c_str(), 1);
|
||||||
|
} else {
|
||||||
|
unsetenv("XDG_STATE_HOME");
|
||||||
|
}
|
||||||
|
std::filesystem::remove_all(xdg_root);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
TEST (SwapWriter_NoStomp_SameBasename)
|
||||||
|
{
|
||||||
|
const std::filesystem::path xdg_root = std::filesystem::temp_directory_path() /
|
||||||
|
(std::string("kte_ut_xdg_state_nostomp_") + std::to_string(
|
||||||
|
(int) ::getpid()));
|
||||||
|
std::filesystem::remove_all(xdg_root);
|
||||||
|
std::filesystem::create_directories(xdg_root);
|
||||||
|
|
||||||
|
const char *old_xdg_p = std::getenv("XDG_STATE_HOME");
|
||||||
|
const std::string old_xdg = old_xdg_p ? std::string(old_xdg_p) : std::string();
|
||||||
|
const std::string xdg_root_s = xdg_root.string();
|
||||||
|
setenv("XDG_STATE_HOME", xdg_root_s.c_str(), 1);
|
||||||
|
|
||||||
|
const std::filesystem::path d1 = xdg_root / "p1";
|
||||||
|
const std::filesystem::path d2 = xdg_root / "p2";
|
||||||
|
std::filesystem::create_directories(d1);
|
||||||
|
std::filesystem::create_directories(d2);
|
||||||
|
const std::filesystem::path f1 = d1 / "same.txt";
|
||||||
|
const std::filesystem::path f2 = d2 / "same.txt";
|
||||||
|
|
||||||
|
{
|
||||||
|
std::ofstream out(f1.string(), std::ios::binary);
|
||||||
|
out << "";
|
||||||
|
}
|
||||||
|
{
|
||||||
|
std::ofstream out(f2.string(), std::ios::binary);
|
||||||
|
out << "";
|
||||||
|
}
|
||||||
|
|
||||||
|
Buffer b1;
|
||||||
|
Buffer b2;
|
||||||
|
std::string err;
|
||||||
|
ASSERT_TRUE(b1.OpenFromFile(f1.string(), err));
|
||||||
|
ASSERT_TRUE(err.empty());
|
||||||
|
ASSERT_TRUE(b2.OpenFromFile(f2.string(), err));
|
||||||
|
ASSERT_TRUE(err.empty());
|
||||||
|
|
||||||
|
const std::string swp1 = kte::SwapManager::ComputeSwapPathForTests(b1);
|
||||||
|
const std::string swp2 = kte::SwapManager::ComputeSwapPathForTests(b2);
|
||||||
|
ASSERT_TRUE(swp1 != swp2);
|
||||||
|
|
||||||
|
// Actually write to both to ensure one doesn't clobber the other.
|
||||||
|
kte::SwapManager sm;
|
||||||
|
sm.Attach(&b1);
|
||||||
|
sm.Attach(&b2);
|
||||||
|
b1.SetSwapRecorder(sm.RecorderFor(&b1));
|
||||||
|
b2.SetSwapRecorder(sm.RecorderFor(&b2));
|
||||||
|
|
||||||
|
b1.insert_text(0, 0, std::string_view("one"));
|
||||||
|
b2.insert_text(0, 0, std::string_view("two"));
|
||||||
|
sm.Flush();
|
||||||
|
|
||||||
|
ASSERT_TRUE(std::filesystem::exists(swp1));
|
||||||
|
ASSERT_TRUE(std::filesystem::exists(swp2));
|
||||||
|
ASSERT_TRUE(std::filesystem::file_size(swp1) >= 64);
|
||||||
|
ASSERT_TRUE(std::filesystem::file_size(swp2) >= 64);
|
||||||
|
|
||||||
|
sm.Detach(&b1);
|
||||||
|
sm.Detach(&b2);
|
||||||
|
b1.SetSwapRecorder(nullptr);
|
||||||
|
b2.SetSwapRecorder(nullptr);
|
||||||
|
|
||||||
|
std::remove(swp1.c_str());
|
||||||
|
std::remove(swp2.c_str());
|
||||||
|
std::remove(f1.string().c_str());
|
||||||
|
std::remove(f2.string().c_str());
|
||||||
|
if (!old_xdg.empty()) {
|
||||||
|
setenv("XDG_STATE_HOME", old_xdg.c_str(), 1);
|
||||||
|
} else {
|
||||||
|
unsetenv("XDG_STATE_HOME");
|
||||||
|
}
|
||||||
|
std::filesystem::remove_all(xdg_root);
|
||||||
|
}
|
||||||
@@ -53,13 +53,15 @@ validate_undo_tree(const UndoSystem &u)
|
|||||||
#endif
|
#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;
|
Buffer b;
|
||||||
UndoSystem *u = b.Undo();
|
UndoSystem *u = b.Undo();
|
||||||
ASSERT_TRUE(u != nullptr);
|
ASSERT_TRUE(u != nullptr);
|
||||||
|
|
||||||
// Simulate two separate "typed" insert commands without committing in between.
|
|
||||||
b.SetCursor(0, 0);
|
b.SetCursor(0, 0);
|
||||||
u->Begin(UndoType::Insert);
|
u->Begin(UndoType::Insert);
|
||||||
b.insert_text(0, 0, std::string_view("h"));
|
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"));
|
b.insert_text(0, 1, std::string_view("i"));
|
||||||
u->Append('i');
|
u->Append('i');
|
||||||
b.SetCursor(2, 0);
|
b.SetCursor(2, 0);
|
||||||
|
|
||||||
u->commit();
|
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();
|
u->undo();
|
||||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string(""));
|
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;
|
Buffer b;
|
||||||
UndoSystem *u = b.Undo();
|
UndoSystem *u = b.Undo();
|
||||||
ASSERT_TRUE(u != nullptr);
|
ASSERT_TRUE(u != nullptr);
|
||||||
|
|
||||||
// Seed content.
|
|
||||||
b.insert_text(0, 0, std::string_view("abc"));
|
b.insert_text(0, 0, std::string_view("abc"));
|
||||||
b.SetCursor(3, 0);
|
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();
|
const auto &rows = b.Rows();
|
||||||
char deleted = rows[0][2];
|
char deleted = rows[0][2];
|
||||||
@@ -108,16 +134,242 @@ TEST (Undo_BackspaceRun_Coalesces)
|
|||||||
u->Begin(UndoType::Delete);
|
u->Begin(UndoType::Delete);
|
||||||
u->Append(deleted);
|
u->Append(deleted);
|
||||||
}
|
}
|
||||||
|
|
||||||
u->commit();
|
u->commit();
|
||||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string("a"));
|
ASSERT_EQ(std::string(b.Rows()[0]), std::string("a"));
|
||||||
|
|
||||||
// One undo should restore both characters.
|
|
||||||
u->undo();
|
u->undo();
|
||||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string("abc"));
|
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)
|
TEST (Undo_Branching_RedoPreservedAfterNewEdit)
|
||||||
{
|
{
|
||||||
Buffer b;
|
Buffer b;
|
||||||
@@ -460,7 +712,6 @@ TEST (Undo_StructuralInvariants_BranchingAndRoots)
|
|||||||
validate_undo_tree(*u);
|
validate_undo_tree(*u);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
TEST (Undo_BranchSelection_ThreeSiblingsAndHeadPersists)
|
TEST (Undo_BranchSelection_ThreeSiblingsAndHeadPersists)
|
||||||
{
|
{
|
||||||
Buffer b;
|
Buffer b;
|
||||||
@@ -540,6 +791,11 @@ TEST (Undo_BranchSelection_ThreeSiblingsAndHeadPersists)
|
|||||||
validate_undo_tree(*u);
|
validate_undo_tree(*u);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#endif
|
||||||
|
|
||||||
|
|
||||||
|
// Additional legacy tests below are useful, but kept disabled by default.
|
||||||
|
#if 0
|
||||||
|
|
||||||
TEST (Undo_Branching_SwitchBetweenTwoRedoBranches_TextAndCursor)
|
TEST (Undo_Branching_SwitchBetweenTwoRedoBranches_TextAndCursor)
|
||||||
{
|
{
|
||||||
@@ -938,3 +1194,5 @@ TEST (Undo_Command_RedoCountSelectsBranch)
|
|||||||
|
|
||||||
validate_undo_tree(*u);
|
validate_undo_tree(*u);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#endif // legacy tests
|
||||||
@@ -65,6 +65,49 @@ TEST (VisualLineMode_BroadcastInsert)
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
TEST (VisualLineMode_BroadcastInsert_UndoRedo)
|
||||||
|
{
|
||||||
|
InstallDefaultCommands();
|
||||||
|
|
||||||
|
Editor ed;
|
||||||
|
ed.SetDimensions(24, 80);
|
||||||
|
|
||||||
|
Buffer b;
|
||||||
|
b.insert_text(0, 0, "foo\nfoo\nfoo\n");
|
||||||
|
b.SetCursor(1, 0); // fo|o
|
||||||
|
ed.AddBuffer(std::move(b));
|
||||||
|
|
||||||
|
ASSERT_TRUE(ed.CurrentBuffer() != nullptr);
|
||||||
|
|
||||||
|
ASSERT_TRUE(Execute(ed, std::string("visual-line-toggle")));
|
||||||
|
ASSERT_TRUE(Execute(ed, std::string("down"), std::string(), 2));
|
||||||
|
|
||||||
|
// Broadcast insert to all selected lines.
|
||||||
|
ASSERT_TRUE(Execute(ed, std::string("insert"), std::string("X")));
|
||||||
|
{
|
||||||
|
const std::string got = dump_buf(*ed.CurrentBuffer());
|
||||||
|
const std::string exp = "fXoo\nfXoo\nfXoo\n\n";
|
||||||
|
ASSERT_TRUE(got == exp);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Undo should restore all affected lines in a single step.
|
||||||
|
ASSERT_TRUE(Execute(ed, std::string("undo")));
|
||||||
|
{
|
||||||
|
const std::string got = dump_buf(*ed.CurrentBuffer());
|
||||||
|
const std::string exp = "foo\nfoo\nfoo\n\n";
|
||||||
|
ASSERT_TRUE(got == exp);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redo should re-apply the whole insert.
|
||||||
|
ASSERT_TRUE(Execute(ed, std::string("redo")));
|
||||||
|
{
|
||||||
|
const std::string got = dump_buf(*ed.CurrentBuffer());
|
||||||
|
const std::string exp = "fXoo\nfXoo\nfXoo\n\n";
|
||||||
|
ASSERT_TRUE(got == exp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
TEST (VisualLineMode_BroadcastBackspace)
|
TEST (VisualLineMode_BroadcastBackspace)
|
||||||
{
|
{
|
||||||
InstallDefaultCommands();
|
InstallDefaultCommands();
|
||||||
@@ -92,6 +135,46 @@ TEST (VisualLineMode_BroadcastBackspace)
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
TEST (VisualLineMode_BroadcastBackspace_UndoRedo)
|
||||||
|
{
|
||||||
|
InstallDefaultCommands();
|
||||||
|
|
||||||
|
Editor ed;
|
||||||
|
ed.SetDimensions(24, 80);
|
||||||
|
|
||||||
|
Buffer b;
|
||||||
|
b.insert_text(0, 0, "abcd\nabcd\nabcd\n");
|
||||||
|
b.SetCursor(2, 0); // ab|cd
|
||||||
|
ed.AddBuffer(std::move(b));
|
||||||
|
|
||||||
|
ASSERT_TRUE(Execute(ed, std::string("visual-line-toggle")));
|
||||||
|
ASSERT_TRUE(Execute(ed, std::string("down"), std::string(), 2));
|
||||||
|
|
||||||
|
ASSERT_TRUE(Execute(ed, std::string("backspace")));
|
||||||
|
{
|
||||||
|
const std::string got = dump_buf(*ed.CurrentBuffer());
|
||||||
|
const std::string exp = "acd\nacd\nacd\n\n";
|
||||||
|
ASSERT_TRUE(got == exp);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Undo should restore all affected lines.
|
||||||
|
ASSERT_TRUE(Execute(ed, std::string("undo")));
|
||||||
|
{
|
||||||
|
const std::string got = dump_buf(*ed.CurrentBuffer());
|
||||||
|
const std::string exp = "abcd\nabcd\nabcd\n\n";
|
||||||
|
ASSERT_TRUE(got == exp);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redo should re-apply.
|
||||||
|
ASSERT_TRUE(Execute(ed, std::string("redo")));
|
||||||
|
{
|
||||||
|
const std::string got = dump_buf(*ed.CurrentBuffer());
|
||||||
|
const std::string exp = "acd\nacd\nacd\n\n";
|
||||||
|
ASSERT_TRUE(got == exp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
TEST (VisualLineMode_CancelWithCtrlG)
|
TEST (VisualLineMode_CancelWithCtrlG)
|
||||||
{
|
{
|
||||||
InstallDefaultCommands();
|
InstallDefaultCommands();
|
||||||
@@ -156,3 +239,94 @@ TEST (Yank_ClearsMarkAndVisualLine)
|
|||||||
ASSERT_TRUE(!buf->MarkSet());
|
ASSERT_TRUE(!buf->MarkSet());
|
||||||
ASSERT_TRUE(!buf->VisualLineActive());
|
ASSERT_TRUE(!buf->VisualLineActive());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
TEST (VisualLineMode_Yank_BroadcastsToBOL_AndUndo)
|
||||||
|
{
|
||||||
|
InstallDefaultCommands();
|
||||||
|
|
||||||
|
Editor ed;
|
||||||
|
ed.SetDimensions(24, 80);
|
||||||
|
|
||||||
|
Buffer b;
|
||||||
|
b.insert_text(0, 0, "aa\nbb\ncc\n");
|
||||||
|
b.SetCursor(1, 0); // a|a
|
||||||
|
ed.AddBuffer(std::move(b));
|
||||||
|
|
||||||
|
ASSERT_TRUE(ed.CurrentBuffer() != nullptr);
|
||||||
|
|
||||||
|
// Enter visual-line mode and extend selection to 3 lines.
|
||||||
|
ASSERT_TRUE(Execute(ed, std::string("visual-line-toggle")));
|
||||||
|
ASSERT_TRUE(Execute(ed, std::string("down"), std::string(), 2));
|
||||||
|
ASSERT_TRUE(ed.CurrentBuffer()->VisualLineActive());
|
||||||
|
|
||||||
|
ed.KillRingClear();
|
||||||
|
ed.KillRingPush("X");
|
||||||
|
|
||||||
|
// Yank in visual-line mode should paste at BOL on every affected line.
|
||||||
|
ASSERT_TRUE(Execute(ed, std::string("yank")));
|
||||||
|
{
|
||||||
|
const std::string got = dump_buf(*ed.CurrentBuffer());
|
||||||
|
// Note: buffers that end with a trailing '\n' have an extra empty row.
|
||||||
|
const std::string exp = "Xaa\nXbb\nXcc\n\n";
|
||||||
|
if (got != exp) {
|
||||||
|
std::cerr << "Expected (len=" << exp.size() << ") bytes: " << dump_bytes(exp) << "\n";
|
||||||
|
std::cerr << "Got (len=" << got.size() << ") bytes: " << dump_bytes(got) << "\n";
|
||||||
|
}
|
||||||
|
ASSERT_TRUE(got == exp);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Undo should restore all affected lines in a single step.
|
||||||
|
ASSERT_TRUE(Execute(ed, std::string("undo")));
|
||||||
|
{
|
||||||
|
const std::string got = dump_buf(*ed.CurrentBuffer());
|
||||||
|
const std::string exp = "aa\nbb\ncc\n\n";
|
||||||
|
if (got != exp) {
|
||||||
|
std::cerr << "Expected (len=" << exp.size() << ") bytes: " << dump_bytes(exp) << "\n";
|
||||||
|
std::cerr << "Got (len=" << got.size() << ") bytes: " << dump_bytes(got) << "\n";
|
||||||
|
}
|
||||||
|
ASSERT_TRUE(got == exp);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redo should re-apply the whole yank.
|
||||||
|
ASSERT_TRUE(Execute(ed, std::string("redo")));
|
||||||
|
{
|
||||||
|
const std::string got = dump_buf(*ed.CurrentBuffer());
|
||||||
|
const std::string exp = "Xaa\nXbb\nXcc\n\n";
|
||||||
|
ASSERT_TRUE(got == exp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
TEST (VisualLineMode_Highlight_IsPerLineCursorSpot)
|
||||||
|
{
|
||||||
|
Buffer b;
|
||||||
|
// Note: buffers that end with a trailing '\n' have an extra empty row.
|
||||||
|
b.insert_text(0, 0, "abcd\nx\nhi\n");
|
||||||
|
// Place primary cursor on line 0 at column 3 (abc|d).
|
||||||
|
b.SetCursor(3, 0);
|
||||||
|
|
||||||
|
// Select lines 0..2 in visual-line mode.
|
||||||
|
b.VisualLineStart();
|
||||||
|
b.VisualLineSetActiveY(2);
|
||||||
|
ASSERT_TRUE(b.VisualLineActive());
|
||||||
|
ASSERT_TRUE(b.VisualLineStartY() == 0);
|
||||||
|
ASSERT_TRUE(b.VisualLineEndY() == 2);
|
||||||
|
|
||||||
|
// Line 0: "abcd" (len=4) => spot is 3
|
||||||
|
ASSERT_TRUE(b.VisualLineSpotSelected(0, 3));
|
||||||
|
ASSERT_TRUE(!b.VisualLineSpotSelected(0, 0));
|
||||||
|
ASSERT_TRUE(!b.VisualLineSpotSelected(0, 2));
|
||||||
|
ASSERT_TRUE(!b.VisualLineSpotSelected(0, 4));
|
||||||
|
|
||||||
|
// Line 1: "x" (len=1) => spot clamps to EOL (1)
|
||||||
|
ASSERT_TRUE(b.VisualLineSpotSelected(1, 1));
|
||||||
|
ASSERT_TRUE(!b.VisualLineSpotSelected(1, 0));
|
||||||
|
|
||||||
|
// Line 2: "hi" (len=2) => spot clamps to EOL (2)
|
||||||
|
ASSERT_TRUE(b.VisualLineSpotSelected(2, 2));
|
||||||
|
ASSERT_TRUE(!b.VisualLineSpotSelected(2, 0));
|
||||||
|
|
||||||
|
// Outside the selected line range should never be highlighted.
|
||||||
|
ASSERT_TRUE(!b.VisualLineSpotSelected(3, 0));
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user