- main: when the environment's locale is not UTF-8 (LANG unset, C/POSIX), switch LC_CTYPE to a UTF-8 locale so ncurses delivers and draws multibyte characters instead of dropping them. - Buffer::Id(): a process-unique id that survives moves and is fresh for copies. The terminal renderer's skip and cursor caches and the reload confirmation now key on it rather than the buffer's address, which is reused after a buffer is closed. - The reload confirmation is cancelled by any other command, like the quit confirmation. - TerminalFrontend redraws when a deferred open starts a prompt or sets a status (e.g. the swap recovery question), not only when a buffer opens. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015rcVQiiWyTzoNrsvLSaAfB
966 lines
28 KiB
C++
966 lines
28 KiB
C++
#include <fstream>
|
|
#include <sstream>
|
|
#include <filesystem>
|
|
#include <cstdlib>
|
|
#include <limits>
|
|
#include <cerrno>
|
|
#include <cstring>
|
|
#include <string_view>
|
|
|
|
#include <vector>
|
|
|
|
#include <fcntl.h>
|
|
#include <unistd.h>
|
|
#include <sys/stat.h>
|
|
#include <sys/types.h>
|
|
|
|
#include "Buffer.h"
|
|
#include "SwapRecorder.h"
|
|
#include "UndoSystem.h"
|
|
#include "UndoTree.h"
|
|
#include "ErrorHandler.h"
|
|
#include "SyscallWrappers.h"
|
|
#include "ErrorRecovery.h"
|
|
// For reconstructing highlighter state on copies
|
|
#include "syntax/HighlighterRegistry.h"
|
|
#include "syntax/NullHighlighter.h"
|
|
|
|
|
|
Buffer::Buffer()
|
|
{
|
|
// Initialize undo system per buffer
|
|
undo_tree_ = std::make_unique<UndoTree>();
|
|
undo_sys_ = std::make_unique<UndoSystem>(*this, *undo_tree_);
|
|
}
|
|
|
|
|
|
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 = kte::syscall::Open(dir.c_str(), O_RDONLY);
|
|
if (dfd < 0)
|
|
return;
|
|
(void) kte::syscall::Fsync(dfd);
|
|
(void) kte::syscall::Close(dfd);
|
|
} catch (...) {
|
|
// best-effort
|
|
}
|
|
}
|
|
|
|
|
|
// The process umask, read once during static initialisation (before any
|
|
// thread exists): reading it later needs umask(0) + umask(old), which briefly
|
|
// changes it for the swap writer thread creating files concurrently.
|
|
static const mode_t g_process_umask = [] {
|
|
const mode_t m = ::umask(0);
|
|
(void) ::umask(m);
|
|
return m;
|
|
}();
|
|
|
|
|
|
// Rewrite `path` in place (truncate, write, fsync). Used for files with
|
|
// several hard links, which a temp-file-and-rename save would split.
|
|
static bool
|
|
write_in_place(const std::string &path, const char *data, std::size_t len, std::string &err)
|
|
{
|
|
int flags = O_WRONLY | O_TRUNC;
|
|
#ifdef O_CLOEXEC
|
|
flags |= O_CLOEXEC;
|
|
#endif
|
|
const int fd = kte::syscall::Open(path.c_str(), flags);
|
|
if (fd < 0) {
|
|
err = std::string("Failed to open file for writing: ") + std::strerror(errno);
|
|
return false;
|
|
}
|
|
bool ok = write_all_fd(fd, data, len, err);
|
|
if (ok && kte::syscall::Fsync(fd) != 0) {
|
|
err = std::string("fsync failed: ") + std::strerror(errno);
|
|
ok = false;
|
|
}
|
|
(void) kte::syscall::Close(fd);
|
|
return ok;
|
|
}
|
|
|
|
|
|
// Create a new file named from `tmpl` (a mkstemp template, which receives
|
|
// the chosen name) holding the content, fsynced.
|
|
static bool
|
|
write_new_file(std::string &tmpl, const char *data, std::size_t len, std::string &err)
|
|
{
|
|
std::vector<char> name(tmpl.begin(), tmpl.end());
|
|
name.push_back('\0');
|
|
const int fd = kte::syscall::Mkstemp(name.data());
|
|
if (fd < 0) {
|
|
err = "Failed to create " + tmpl + ": " + std::strerror(errno);
|
|
return false;
|
|
}
|
|
tmpl.assign(name.data());
|
|
const std::string &path = tmpl;
|
|
bool ok = write_all_fd(fd, data, len, err);
|
|
if (ok && kte::syscall::Fsync(fd) != 0) {
|
|
err = std::string("fsync failed: ") + std::strerror(errno);
|
|
ok = false;
|
|
}
|
|
(void) kte::syscall::Close(fd);
|
|
if (!ok)
|
|
(void) ::unlink(path.c_str());
|
|
return ok;
|
|
}
|
|
|
|
|
|
static bool
|
|
atomic_write_file(const std::string &path_in, const char *data, std::size_t len, std::string &err)
|
|
{
|
|
// Write through a symlink to its target: renaming over the link itself
|
|
// would replace the link with a regular file.
|
|
std::string path = path_in;
|
|
try {
|
|
if (std::filesystem::is_symlink(path_in))
|
|
path = std::filesystem::weakly_canonical(path_in).string();
|
|
} catch (...) {
|
|
// Fall back to the given path.
|
|
}
|
|
|
|
struct stat dst_st{};
|
|
const bool dst_exists = ::stat(path.c_str(), &dst_st) == 0;
|
|
// Never replace a directory, device node, FIFO or socket with a file.
|
|
if (dst_exists && !S_ISREG(dst_st.st_mode)) {
|
|
err = "Refusing to overwrite a non-regular file: " + path;
|
|
return false;
|
|
}
|
|
|
|
// A file with other hard links must be rewritten in place, or those
|
|
// names would keep the old content.
|
|
if (dst_exists && S_ISREG(dst_st.st_mode) && dst_st.st_nlink > 1) {
|
|
// Truncating in place risks the only copy if the write then fails
|
|
// (ENOSPC, EIO, crash), so first write and sync a copy beside it. If
|
|
// no copy can be made (directory not writable, name too long), write
|
|
// in place anyway rather than refuse to save.
|
|
std::string copy = path + ".kte-save.XXXXXX";
|
|
std::string copy_err;
|
|
const bool have_copy = write_new_file(copy, data, len, copy_err);
|
|
if (!write_in_place(path, data, len, err)) {
|
|
if (have_copy)
|
|
err += " (the new content is in " + copy + ")";
|
|
return false;
|
|
}
|
|
if (have_copy)
|
|
(void) ::unlink(copy.c_str());
|
|
best_effort_fsync_dir(path);
|
|
return true;
|
|
}
|
|
|
|
// 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');
|
|
|
|
// Retry on transient errors for temp file creation
|
|
int fd = -1;
|
|
auto mkstemp_fn = [&]() -> bool {
|
|
// Reset buffer for each retry attempt
|
|
buf.assign(tmpl_s.begin(), tmpl_s.end());
|
|
buf.push_back('\0');
|
|
fd = kte::syscall::Mkstemp(buf.data());
|
|
return fd >= 0;
|
|
};
|
|
|
|
if (!kte::RetryOnTransientError(mkstemp_fn, kte::RetryPolicy::Aggressive(), err)) {
|
|
const int saved_errno = errno;
|
|
// No temp file in the directory (e.g. the file is writable but its
|
|
// directory is not): an existing regular file can still be written in
|
|
// place, as editors traditionally do.
|
|
if (fd < 0 && dst_exists && S_ISREG(dst_st.st_mode)) {
|
|
err.clear();
|
|
return write_in_place(path, data, len, err);
|
|
}
|
|
if (fd < 0) {
|
|
err = std::string("Failed to create temp file for save: ") + std::strerror(saved_errno) + err;
|
|
}
|
|
return false;
|
|
}
|
|
std::string tmp_path(buf.data());
|
|
|
|
if (dst_exists) {
|
|
// Carry over ownership (best effort; needs privilege to give a file
|
|
// away) and then permissions: chown clears setuid/setgid, so the
|
|
// mode has to be applied after it.
|
|
if (::fchown(fd, dst_st.st_uid, dst_st.st_gid) != 0) {
|
|
// Expected without privilege when the owner differs; keep ours.
|
|
}
|
|
(void) kte::syscall::Fchmod(fd, dst_st.st_mode & 07777);
|
|
} else {
|
|
// mkstemp creates 0600; a new file gets the usual 0666 & ~umask.
|
|
(void) kte::syscall::Fchmod(fd, 0666 & ~g_process_umask);
|
|
}
|
|
|
|
bool ok = write_all_fd(fd, data, len, err);
|
|
// Never retry fsync: after a writeback error Linux may mark the pages
|
|
// clean, so a second fsync can succeed although the data was lost, and
|
|
// the rename below would then replace a good file with a bad one.
|
|
if (ok && kte::syscall::Fsync(fd) != 0) {
|
|
err = std::string("fsync failed: ") + std::strerror(errno);
|
|
ok = false;
|
|
}
|
|
(void) kte::syscall::Close(fd);
|
|
|
|
if (ok) {
|
|
if (::rename(tmp_path.c_str(), path.c_str()) != 0) {
|
|
err = std::string("rename failed: ") + std::strerror(errno);
|
|
ok = false;
|
|
}
|
|
}
|
|
|
|
if (!ok) {
|
|
(void) ::unlink(tmp_path.c_str());
|
|
return false;
|
|
}
|
|
best_effort_fsync_dir(path);
|
|
return true;
|
|
}
|
|
|
|
|
|
Buffer::Buffer(const std::string &path)
|
|
{
|
|
std::string err;
|
|
OpenFromFile(path, err);
|
|
}
|
|
|
|
|
|
// Copy constructor/assignment: perform a deep copy of core fields; reinitialize undo for the new buffer.
|
|
Buffer::Buffer(const Buffer &other)
|
|
{
|
|
curx_ = other.curx_;
|
|
cury_ = other.cury_;
|
|
rx_ = other.rx_;
|
|
nrows_ = other.nrows_;
|
|
rowoffs_ = other.rowoffs_;
|
|
coloffs_ = other.coloffs_;
|
|
rows_ = other.rows_;
|
|
content_ = other.content_;
|
|
rows_cache_dirty_ = other.rows_cache_dirty_;
|
|
filename_ = other.filename_;
|
|
is_file_backed_ = other.is_file_backed_;
|
|
is_virtual_ = other.is_virtual_;
|
|
dirty_ = other.dirty_;
|
|
read_only_ = other.read_only_;
|
|
mark_set_ = other.mark_set_;
|
|
mark_curx_ = other.mark_curx_;
|
|
mark_cury_ = other.mark_cury_;
|
|
// Copy edit mode + syntax/highlighting flags
|
|
edit_mode_ = other.edit_mode_;
|
|
edit_mode_detected_ = other.edit_mode_detected_;
|
|
version_ = other.version_;
|
|
syntax_enabled_ = other.syntax_enabled_;
|
|
syntax_user_override_ = other.syntax_user_override_;
|
|
filetype_ = other.filetype_;
|
|
// Fresh undo system for the copy
|
|
undo_tree_ = std::make_unique<UndoTree>();
|
|
undo_sys_ = std::make_unique<UndoSystem>(*this, *undo_tree_);
|
|
|
|
// Recreate a highlighter engine for this copy based on filetype/syntax state
|
|
if (syntax_enabled_) {
|
|
// Allocate engine and install an appropriate highlighter
|
|
highlighter_ = std::make_unique<kte::HighlighterEngine>();
|
|
if (!filetype_.empty()) {
|
|
auto hl = kte::HighlighterRegistry::CreateFor(filetype_);
|
|
if (hl) {
|
|
highlighter_->SetHighlighter(std::move(hl));
|
|
} else {
|
|
// Unsupported filetype -> NullHighlighter keeps syntax pipeline active
|
|
highlighter_->SetHighlighter(std::make_unique<kte::NullHighlighter>());
|
|
}
|
|
} else {
|
|
// No filetype -> keep syntax enabled but use NullHighlighter
|
|
highlighter_->SetHighlighter(std::make_unique<kte::NullHighlighter>());
|
|
}
|
|
// Fresh engine has empty caches; nothing to invalidate
|
|
}
|
|
}
|
|
|
|
|
|
Buffer &
|
|
Buffer::operator=(const Buffer &other)
|
|
{
|
|
if (this == &other)
|
|
return *this;
|
|
curx_ = other.curx_;
|
|
cury_ = other.cury_;
|
|
rx_ = other.rx_;
|
|
nrows_ = other.nrows_;
|
|
rowoffs_ = other.rowoffs_;
|
|
coloffs_ = other.coloffs_;
|
|
rows_ = other.rows_;
|
|
content_ = other.content_;
|
|
rows_cache_dirty_ = other.rows_cache_dirty_;
|
|
filename_ = other.filename_;
|
|
is_file_backed_ = other.is_file_backed_;
|
|
is_virtual_ = other.is_virtual_;
|
|
id_ = NextBufferId(); // a copy is a different document
|
|
dirty_ = other.dirty_;
|
|
read_only_ = other.read_only_;
|
|
mark_set_ = other.mark_set_;
|
|
mark_curx_ = other.mark_curx_;
|
|
mark_cury_ = other.mark_cury_;
|
|
edit_mode_ = other.edit_mode_;
|
|
edit_mode_detected_ = other.edit_mode_detected_;
|
|
version_ = other.version_;
|
|
syntax_enabled_ = other.syntax_enabled_;
|
|
syntax_user_override_ = other.syntax_user_override_;
|
|
filetype_ = other.filetype_;
|
|
// Recreate undo system for this instance
|
|
undo_tree_ = std::make_unique<UndoTree>();
|
|
undo_sys_ = std::make_unique<UndoSystem>(*this, *undo_tree_);
|
|
|
|
// Recreate highlighter engine consistent with syntax settings
|
|
highlighter_.reset();
|
|
if (syntax_enabled_) {
|
|
highlighter_ = std::make_unique<kte::HighlighterEngine>();
|
|
if (!filetype_.empty()) {
|
|
auto hl = kte::HighlighterRegistry::CreateFor(filetype_);
|
|
if (hl) {
|
|
highlighter_->SetHighlighter(std::move(hl));
|
|
} else {
|
|
highlighter_->SetHighlighter(std::make_unique<kte::NullHighlighter>());
|
|
}
|
|
} else {
|
|
highlighter_->SetHighlighter(std::make_unique<kte::NullHighlighter>());
|
|
}
|
|
}
|
|
return *this;
|
|
}
|
|
|
|
|
|
// Move constructor: move all fields and update UndoSystem's buffer reference
|
|
Buffer::Buffer(Buffer &&other) noexcept
|
|
: curx_(other.curx_),
|
|
cury_(other.cury_),
|
|
rx_(other.rx_),
|
|
nrows_(other.nrows_),
|
|
rowoffs_(other.rowoffs_),
|
|
coloffs_(other.coloffs_),
|
|
rows_(std::move(other.rows_)),
|
|
filename_(std::move(other.filename_)),
|
|
is_file_backed_(other.is_file_backed_),
|
|
is_virtual_(other.is_virtual_),
|
|
id_(other.id_),
|
|
dirty_(other.dirty_),
|
|
read_only_(other.read_only_),
|
|
mark_set_(other.mark_set_),
|
|
mark_curx_(other.mark_curx_),
|
|
mark_cury_(other.mark_cury_),
|
|
visual_line_active_(other.visual_line_active_),
|
|
visual_line_anchor_y_(other.visual_line_anchor_y_),
|
|
visual_line_active_y_(other.visual_line_active_y_),
|
|
undo_tree_(std::move(other.undo_tree_)),
|
|
undo_sys_(std::move(other.undo_sys_))
|
|
{
|
|
// Move edit mode + syntax/highlighting state
|
|
edit_mode_ = other.edit_mode_;
|
|
edit_mode_detected_ = other.edit_mode_detected_;
|
|
version_ = other.version_;
|
|
syntax_enabled_ = other.syntax_enabled_;
|
|
syntax_user_override_ = other.syntax_user_override_;
|
|
filetype_ = std::move(other.filetype_);
|
|
highlighter_ = std::move(other.highlighter_);
|
|
content_ = std::move(other.content_);
|
|
rows_cache_dirty_ = other.rows_cache_dirty_;
|
|
on_disk_identity_ = other.on_disk_identity_;
|
|
// Non-owning: the recorder object itself is owned by SwapManager and outlives
|
|
// this move. The caller (Editor) is responsible for calling SwapManager::Rehome()
|
|
// so the journal's Buffer* key follows this object to its new address.
|
|
swap_rec_ = other.swap_rec_;
|
|
other.swap_rec_ = nullptr;
|
|
// Update UndoSystem's buffer reference to point to this object
|
|
if (undo_sys_) {
|
|
undo_sys_->UpdateBufferReference(*this);
|
|
}
|
|
}
|
|
|
|
|
|
// Move assignment: move all fields and update UndoSystem's buffer reference
|
|
Buffer &
|
|
Buffer::operator=(Buffer &&other) noexcept
|
|
{
|
|
if (this == &other)
|
|
return *this;
|
|
|
|
curx_ = other.curx_;
|
|
cury_ = other.cury_;
|
|
rx_ = other.rx_;
|
|
nrows_ = other.nrows_;
|
|
rowoffs_ = other.rowoffs_;
|
|
coloffs_ = other.coloffs_;
|
|
rows_ = std::move(other.rows_);
|
|
filename_ = std::move(other.filename_);
|
|
is_file_backed_ = other.is_file_backed_;
|
|
is_virtual_ = other.is_virtual_;
|
|
id_ = other.id_;
|
|
dirty_ = other.dirty_;
|
|
read_only_ = other.read_only_;
|
|
mark_set_ = other.mark_set_;
|
|
mark_curx_ = other.mark_curx_;
|
|
mark_cury_ = other.mark_cury_;
|
|
visual_line_active_ = other.visual_line_active_;
|
|
visual_line_anchor_y_ = other.visual_line_anchor_y_;
|
|
visual_line_active_y_ = other.visual_line_active_y_;
|
|
undo_tree_ = std::move(other.undo_tree_);
|
|
undo_sys_ = std::move(other.undo_sys_);
|
|
|
|
// Move edit mode + syntax/highlighting state
|
|
edit_mode_ = other.edit_mode_;
|
|
edit_mode_detected_ = other.edit_mode_detected_;
|
|
version_ = other.version_;
|
|
syntax_enabled_ = other.syntax_enabled_;
|
|
syntax_user_override_ = other.syntax_user_override_;
|
|
filetype_ = std::move(other.filetype_);
|
|
highlighter_ = std::move(other.highlighter_);
|
|
content_ = std::move(other.content_);
|
|
rows_cache_dirty_ = other.rows_cache_dirty_;
|
|
on_disk_identity_ = other.on_disk_identity_;
|
|
// Non-owning: the recorder object itself is owned by SwapManager and outlives
|
|
// this move. The caller (Editor) is responsible for calling SwapManager::Rehome()
|
|
// so the journal's Buffer* key follows this object to its new address.
|
|
swap_rec_ = other.swap_rec_;
|
|
other.swap_rec_ = nullptr;
|
|
// Update UndoSystem's buffer reference to point to this object
|
|
if (undo_sys_) {
|
|
undo_sys_->UpdateBufferReference(*this);
|
|
}
|
|
|
|
return *this;
|
|
}
|
|
|
|
|
|
bool
|
|
Buffer::OpenFromFile(const std::string &path, std::string &err)
|
|
{
|
|
auto normalize_path = [](const std::string &in) -> std::string {
|
|
std::string expanded = in;
|
|
// Expand leading '~' to HOME
|
|
if (!expanded.empty() && expanded[0] == '~') {
|
|
const char *home = std::getenv("HOME");
|
|
if (home && expanded.size() >= 2 && (expanded[1] == '/' || expanded[1] == '\\')) {
|
|
expanded = std::string(home) + expanded.substr(1);
|
|
} else if (home && expanded.size() == 1) {
|
|
expanded = std::string(home);
|
|
}
|
|
}
|
|
try {
|
|
std::filesystem::path p(expanded);
|
|
if (std::filesystem::exists(p)) {
|
|
return std::filesystem::canonical(p).string();
|
|
}
|
|
return std::filesystem::absolute(p).string();
|
|
} catch (...) {
|
|
// On any error, fall back to input
|
|
return expanded;
|
|
}
|
|
};
|
|
|
|
const std::string norm = normalize_path(path);
|
|
// If the file doesn't exist, initialize an empty, non-file-backed buffer
|
|
// with the provided filename. Do not touch the filesystem until Save/SaveAs.
|
|
std::error_code exists_ec;
|
|
const bool exists = std::filesystem::exists(norm, exists_ec);
|
|
if (exists_ec) {
|
|
// e.g. name too long, or a parent directory we may not search
|
|
err = "Cannot open " + norm + ": " + exists_ec.message();
|
|
kte::ErrorHandler::Instance().Error("Buffer", err, norm);
|
|
return false;
|
|
}
|
|
if (!exists) {
|
|
rows_.clear();
|
|
nrows_ = 0;
|
|
filename_ = norm;
|
|
is_file_backed_ = false;
|
|
is_virtual_ = false;
|
|
dirty_ = false;
|
|
|
|
// Reset cursor/viewport state
|
|
curx_ = cury_ = rx_ = 0;
|
|
rowoffs_ = coloffs_ = 0;
|
|
mark_set_ = false;
|
|
mark_curx_ = mark_cury_ = 0;
|
|
|
|
// Empty PieceTable
|
|
content_.Clear();
|
|
rows_cache_dirty_ = true;
|
|
// History recorded against the previous content no longer applies.
|
|
if (undo_sys_)
|
|
undo_sys_->clear();
|
|
MarkContentChanged();
|
|
|
|
return true;
|
|
}
|
|
|
|
// Open non-blocking so a named pipe cannot hang the editor, then accept
|
|
// regular files only: a directory reads as garbage (or throws), a FIFO
|
|
// blocks, and saving over a device node would replace it with a file.
|
|
int oflags = O_RDONLY | O_NONBLOCK;
|
|
#ifdef O_CLOEXEC
|
|
oflags |= O_CLOEXEC;
|
|
#endif
|
|
const int fd = kte::syscall::Open(norm.c_str(), oflags);
|
|
if (fd < 0) {
|
|
err = "Failed to open file: " + norm + ": " + std::strerror(errno);
|
|
kte::ErrorHandler::Instance().Error("Buffer", err, norm);
|
|
return false;
|
|
}
|
|
struct stat st{};
|
|
if (kte::syscall::Fstat(fd, &st) != 0 || !S_ISREG(st.st_mode)) {
|
|
err = S_ISDIR(st.st_mode) ? ("Is a directory: " + norm) : ("Not a regular file: " + norm);
|
|
(void) kte::syscall::Close(fd);
|
|
kte::ErrorHandler::Instance().Error("Buffer", err, norm);
|
|
return false;
|
|
}
|
|
|
|
// Read to EOF in chunks (the file may change size while we read).
|
|
std::string data;
|
|
try {
|
|
data.reserve(static_cast<std::size_t>(st.st_size));
|
|
char chunk[1 << 16];
|
|
for (;;) {
|
|
const ssize_t n = ::read(fd, chunk, sizeof(chunk));
|
|
if (n > 0) {
|
|
data.append(chunk, static_cast<std::size_t>(n));
|
|
} else if (n == 0) {
|
|
break;
|
|
} else if (errno != EINTR) {
|
|
err = "Failed to read file: " + norm + ": " + std::strerror(errno);
|
|
(void) kte::syscall::Close(fd);
|
|
kte::ErrorHandler::Instance().Error("Buffer", err, norm);
|
|
return false;
|
|
}
|
|
}
|
|
} catch (const std::bad_alloc &) {
|
|
(void) kte::syscall::Close(fd);
|
|
err = "File too large to load: " + norm;
|
|
kte::ErrorHandler::Instance().Error("Buffer", err, norm);
|
|
return false;
|
|
}
|
|
(void) kte::syscall::Close(fd);
|
|
// Build the new content aside and swap it in only once complete: if the
|
|
// copy runs out of memory, the buffer keeps its old text (clearing first
|
|
// left an empty buffer that a later save wrote over the file).
|
|
PieceTable fresh;
|
|
try {
|
|
if (!data.empty())
|
|
fresh.Append(data.data(), data.size());
|
|
std::string().swap(data);
|
|
} catch (const std::bad_alloc &) {
|
|
err = "File too large to load: " + norm;
|
|
kte::ErrorHandler::Instance().Error("Buffer", err, norm);
|
|
return false;
|
|
}
|
|
content_ = std::move(fresh);
|
|
rows_cache_dirty_ = true;
|
|
nrows_ = 0; // not used under PieceTable
|
|
filename_ = norm;
|
|
is_file_backed_ = true;
|
|
is_virtual_ = false;
|
|
dirty_ = false;
|
|
RefreshOnDiskIdentity();
|
|
|
|
// Reset/initialize undo system for this loaded file
|
|
if (!undo_tree_)
|
|
undo_tree_ = std::make_unique<UndoTree>();
|
|
if (!undo_sys_)
|
|
undo_sys_ = std::make_unique<UndoSystem>(*this, *undo_tree_);
|
|
// Clear any existing history for a fresh load
|
|
undo_sys_->clear();
|
|
MarkContentChanged();
|
|
|
|
// Reset cursor/viewport state
|
|
curx_ = cury_ = rx_ = 0;
|
|
rowoffs_ = coloffs_ = 0;
|
|
mark_set_ = false;
|
|
mark_curx_ = mark_cury_ = 0;
|
|
|
|
return true;
|
|
}
|
|
|
|
|
|
bool
|
|
Buffer::Save(std::string &err) const
|
|
{
|
|
if (!is_file_backed_ || filename_.empty()) {
|
|
err = "Buffer is not file-backed; use SaveAs()";
|
|
return false;
|
|
}
|
|
const std::size_t sz = content_.Size();
|
|
const char *data = sz ? content_.Data() : nullptr;
|
|
if (sz && !data) {
|
|
err = "Internal error: buffer materialization failed";
|
|
return false;
|
|
}
|
|
if (!atomic_write_file(filename_, data ? data : "", sz, err)) {
|
|
kte::ErrorHandler::Instance().Error("Buffer", err, filename_);
|
|
return false;
|
|
}
|
|
// Update observed on-disk identity after a successful save.
|
|
const_cast<Buffer *>(this)->RefreshOnDiskIdentity();
|
|
// Note: const method cannot change dirty_. Intentionally const to allow UI code
|
|
// to decide when to flip dirty flag after successful save.
|
|
return true;
|
|
}
|
|
|
|
|
|
bool
|
|
Buffer::SaveAs(const std::string &path, std::string &err)
|
|
{
|
|
// Normalize output path first
|
|
std::string out_path;
|
|
try {
|
|
std::filesystem::path p(path);
|
|
// Do a light expansion of '~'
|
|
std::string expanded = path;
|
|
if (!expanded.empty() && expanded[0] == '~') {
|
|
const char *home = std::getenv("HOME");
|
|
if (home && expanded.size() >= 2 && (expanded[1] == '/' || expanded[1] == '\\'))
|
|
expanded = std::string(home) + expanded.substr(1);
|
|
else if (home && expanded.size() == 1)
|
|
expanded = std::string(home);
|
|
}
|
|
std::filesystem::path ep(expanded);
|
|
out_path = std::filesystem::absolute(ep).string();
|
|
} catch (...) {
|
|
out_path = path;
|
|
}
|
|
|
|
const std::size_t sz = content_.Size();
|
|
const char *data = sz ? content_.Data() : nullptr;
|
|
if (sz && !data) {
|
|
err = "Internal error: buffer materialization failed";
|
|
return false;
|
|
}
|
|
if (!atomic_write_file(out_path, data ? data : "", sz, err)) {
|
|
kte::ErrorHandler::Instance().Error("Buffer", err, out_path);
|
|
return false;
|
|
}
|
|
|
|
filename_ = out_path;
|
|
is_file_backed_ = true;
|
|
is_virtual_ = false;
|
|
dirty_ = false;
|
|
RefreshOnDiskIdentity();
|
|
return true;
|
|
}
|
|
|
|
|
|
std::string
|
|
Buffer::AsString() const
|
|
{
|
|
std::stringstream ss;
|
|
ss << "Buffer<" << this->filename_;
|
|
if (this->Dirty()) {
|
|
ss << "*";
|
|
}
|
|
ss << ">: " << content_.LineCount() << " lines";
|
|
return ss.str();
|
|
}
|
|
|
|
|
|
// --- Raw editing APIs (no undo recording, cursor untouched) ---
|
|
void
|
|
Buffer::insert_text(int row, int col, std::string_view text)
|
|
{
|
|
if (row < 0)
|
|
row = 0;
|
|
if (col < 0)
|
|
col = 0;
|
|
const std::size_t off = content_.LineColToByteOffset(static_cast<std::size_t>(row),
|
|
static_cast<std::size_t>(col));
|
|
if (!text.empty()) {
|
|
content_.Insert(off, text.data(), text.size());
|
|
rows_cache_dirty_ = true;
|
|
edited_at_(row);
|
|
if (swap_rec_)
|
|
swap_rec_->OnInsert(row, col, text);
|
|
}
|
|
}
|
|
|
|
|
|
// ===== Adapter helpers for PieceTable-backed Buffer =====
|
|
std::string_view
|
|
Buffer::GetLineView(std::size_t row) const
|
|
{
|
|
// Get byte range for the logical line and return a view into materialized data
|
|
auto range = content_.GetLineRange(row); // [start,end) in bytes
|
|
const char *base = content_.Data(); // materializes if needed
|
|
if (!base)
|
|
return std::string_view();
|
|
const std::size_t start = range.first;
|
|
const std::size_t len = (range.second > range.first) ? (range.second - range.first) : 0;
|
|
return std::string_view(base + start, len);
|
|
}
|
|
|
|
|
|
void
|
|
Buffer::ensure_rows_cache() const
|
|
{
|
|
std::lock_guard<std::mutex> lock(buffer_mutex_);
|
|
if (!rows_cache_dirty_)
|
|
return;
|
|
rows_.clear();
|
|
const std::size_t lc = content_.LineCount();
|
|
rows_.reserve(lc);
|
|
for (std::size_t i = 0; i < lc; ++i) {
|
|
rows_.emplace_back(content_.GetLine(i));
|
|
}
|
|
// Keep nrows_ in sync for any legacy code that still reads it
|
|
const_cast<Buffer *>(this)->nrows_ = rows_.size();
|
|
rows_cache_dirty_ = false;
|
|
}
|
|
|
|
|
|
std::size_t
|
|
Buffer::content_LineCount_() const
|
|
{
|
|
return content_.LineCount();
|
|
}
|
|
|
|
|
|
#if defined(KTE_TESTS)
|
|
std::string
|
|
Buffer::BytesForTests() const
|
|
{
|
|
const std::size_t sz = content_.Size();
|
|
if (sz == 0)
|
|
return std::string();
|
|
const char *data = content_.Data();
|
|
if (!data)
|
|
return std::string();
|
|
return std::string(data, data + sz);
|
|
}
|
|
#endif
|
|
|
|
|
|
void
|
|
Buffer::delete_text(int row, int col, std::size_t len)
|
|
{
|
|
if (len == 0)
|
|
return;
|
|
if (row < 0)
|
|
row = 0;
|
|
if (col < 0)
|
|
col = 0;
|
|
|
|
// `len` counts each newline as one character, so the deletion is simply
|
|
// the next `len` bytes from the (clamped) start, capped at end of buffer.
|
|
const std::size_t start = content_.LineColToByteOffset(static_cast<std::size_t>(row),
|
|
static_cast<std::size_t>(col));
|
|
const std::size_t total = content_.Size();
|
|
const std::size_t actual = (total > start) ? std::min(len, total - start) : 0;
|
|
if (actual == 0)
|
|
return;
|
|
content_.Delete(start, actual);
|
|
rows_cache_dirty_ = true;
|
|
edited_at_(row);
|
|
if (swap_rec_)
|
|
swap_rec_->OnDelete(row, col, actual);
|
|
}
|
|
|
|
|
|
void
|
|
Buffer::split_line(int row, const int col)
|
|
{
|
|
int c = col;
|
|
if (row < 0)
|
|
row = 0;
|
|
if (c < 0)
|
|
c = 0;
|
|
const std::size_t off = content_.LineColToByteOffset(static_cast<std::size_t>(row),
|
|
static_cast<std::size_t>(c));
|
|
const char nl = '\n';
|
|
content_.Insert(off, &nl, 1);
|
|
rows_cache_dirty_ = true;
|
|
edited_at_(row);
|
|
if (swap_rec_)
|
|
swap_rec_->OnInsert(row, c, std::string_view("\n", 1));
|
|
}
|
|
|
|
|
|
void
|
|
Buffer::join_lines(int row)
|
|
{
|
|
if (row < 0)
|
|
row = 0;
|
|
std::size_t r = static_cast<std::size_t>(row);
|
|
if (r + 1 >= content_.LineCount())
|
|
return;
|
|
const int col = static_cast<int>(content_.GetLine(r).size());
|
|
// 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());
|
|
// end_of_line now equals line end (clamped before newline). The newline should be exactly at this position.
|
|
content_.Delete(end_of_line, 1);
|
|
rows_cache_dirty_ = true;
|
|
edited_at_(row);
|
|
if (swap_rec_)
|
|
swap_rec_->OnDelete(row, col, 1);
|
|
}
|
|
|
|
|
|
void
|
|
Buffer::insert_row(int row, const std::string_view text)
|
|
{
|
|
if (row < 0)
|
|
row = 0;
|
|
std::size_t off = content_.LineColToByteOffset(static_cast<std::size_t>(row), 0);
|
|
if (!text.empty())
|
|
content_.Insert(off, text.data(), text.size());
|
|
const char nl = '\n';
|
|
content_.Insert(off + text.size(), &nl, 1);
|
|
rows_cache_dirty_ = true;
|
|
edited_at_(row);
|
|
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));
|
|
}
|
|
}
|
|
|
|
|
|
void
|
|
Buffer::delete_row(int row)
|
|
{
|
|
if (row < 0)
|
|
row = 0;
|
|
std::size_t r = static_cast<std::size_t>(row);
|
|
if (r >= content_.LineCount())
|
|
return;
|
|
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 last line, end may equal total_size_. We still delete [start,end) which removes the last line content.
|
|
const std::size_t start = range.first;
|
|
const std::size_t end = range.second;
|
|
const std::size_t actual = (end > start) ? (end - start) : 0;
|
|
if (actual == 0)
|
|
return;
|
|
content_.Delete(start, actual);
|
|
rows_cache_dirty_ = true;
|
|
edited_at_(row);
|
|
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;
|
|
MarkContentChanged();
|
|
}
|
|
|
|
|
|
// Undo system accessors
|
|
UndoSystem *
|
|
Buffer::Undo()
|
|
{
|
|
return undo_sys_.get();
|
|
}
|
|
|
|
|
|
const UndoSystem *
|
|
Buffer::Undo() const
|
|
{
|
|
return undo_sys_.get();
|
|
} |