A secondary GUI window shared the primary's buffer vector but attached buffers through its own SwapManager. Closing the window destroyed that manager while buffers still held its recorders, so the next edit called into freed memory; Rehome through the wrong manager also dropped journaling for the primary's buffers. Audit finding M3. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015rcVQiiWyTzoNrsvLSaAfB
743 lines
15 KiB
C++
743 lines
15 KiB
C++
/*
|
|
* Editor.h - top-level editor state and buffer management
|
|
*
|
|
* Editor is the top-level coordinator in kte. It manages:
|
|
*
|
|
* - Buffer collection: Multiple open documents (buffers_), current buffer selection
|
|
* - UI state: Dimensions, status messages, prompts, search state
|
|
* - Kill ring: Shared clipboard for cut/copy/paste operations across buffers
|
|
* - Universal argument: Repeat count mechanism (C-u)
|
|
* - Mode flags: Editor modes (normal, k-command, search, prompt, etc.)
|
|
* - Swap/crash recovery: SwapManager integration for journaling
|
|
* - File operations: Opening files, managing pending opens, recovery prompts
|
|
*
|
|
* Key responsibilities:
|
|
*
|
|
* 1. Buffer lifecycle:
|
|
* - AddBuffer(): Add new buffers to the collection
|
|
* - OpenFile(): Load files into buffers
|
|
* - SwitchTo(): Change active buffer
|
|
* - CloseBuffer(): Remove buffers with dirty checks
|
|
*
|
|
* 2. UI coordination:
|
|
* - SetDimensions(): Terminal/window size for viewport calculations
|
|
* - SetStatus(): Status line messages with timestamps
|
|
* - Prompt system: Multi-step prompts for file open, buffer switch, etc.
|
|
* - Search state: Active search, query, match position, origin tracking
|
|
*
|
|
* 3. Shared editor state:
|
|
* - Kill ring: Circular buffer of killed text (max 60 entries)
|
|
* - Universal argument: C-u digit collection for command repetition
|
|
* - Mode tracking: Current input mode (normal, k-command, ESC, prompt)
|
|
*
|
|
* 4. Integration points:
|
|
* - Commands operate on Editor and current Buffer
|
|
* - Frontend (Terminal/GUI) queries Editor for rendering
|
|
* - SwapManager journals all buffer modifications
|
|
*
|
|
* Design note: Editor owns the buffer collection but doesn't directly edit content.
|
|
* Commands modify buffers through Buffer's API, and Editor coordinates the UI state.
|
|
*/
|
|
#pragma once
|
|
#include <cstddef>
|
|
#include <ctime>
|
|
#include <deque>
|
|
#include <string>
|
|
#include <vector>
|
|
|
|
#include "Buffer.h"
|
|
#include "Swap.h"
|
|
|
|
|
|
class Editor {
|
|
public:
|
|
Editor();
|
|
|
|
// Dimensions (terminal or viewport)
|
|
void SetDimensions(std::size_t rows, std::size_t cols);
|
|
|
|
|
|
[[nodiscard]] std::size_t Rows() const
|
|
{
|
|
return rows_;
|
|
}
|
|
|
|
|
|
[[nodiscard]] std::size_t Cols() const
|
|
{
|
|
return cols_;
|
|
}
|
|
|
|
|
|
[[nodiscard]] std::size_t ContentRows() const
|
|
{
|
|
// Always compute from current rows_ to avoid stale values.
|
|
// Reserve 1 row for status line.
|
|
if (rows_ == 0)
|
|
return 1;
|
|
return std::max<std::size_t>(1, rows_ - 1);
|
|
}
|
|
|
|
|
|
// Mode and flags (mirroring legacy fields)
|
|
void SetMode(int m)
|
|
{
|
|
mode_ = m;
|
|
}
|
|
|
|
|
|
[[nodiscard]] int Mode() const
|
|
{
|
|
return mode_;
|
|
}
|
|
|
|
|
|
void SetKillChain(bool on)
|
|
{
|
|
kill_ = on ? 1 : 0;
|
|
}
|
|
|
|
|
|
[[nodiscard]] bool KillChain() const
|
|
{
|
|
return kill_ != 0;
|
|
}
|
|
|
|
|
|
void SetNoKill(bool on)
|
|
{
|
|
no_kill_ = on ? 1 : 0;
|
|
}
|
|
|
|
|
|
[[nodiscard]] bool NoKill() const
|
|
{
|
|
return no_kill_ != 0;
|
|
}
|
|
|
|
|
|
// Kill ring API
|
|
void KillRingClear()
|
|
{
|
|
kill_ring_.clear();
|
|
}
|
|
|
|
|
|
void KillRingPush(const std::string &text)
|
|
{
|
|
if (text.empty())
|
|
return;
|
|
// push to front
|
|
kill_ring_.insert(kill_ring_.begin(), text);
|
|
if (kill_ring_.size() > kill_ring_max_)
|
|
kill_ring_.resize(kill_ring_max_);
|
|
}
|
|
|
|
|
|
void KillRingAppend(const std::string &text)
|
|
{
|
|
if (text.empty())
|
|
return;
|
|
if (kill_ring_.empty()) {
|
|
KillRingPush(text);
|
|
} else {
|
|
kill_ring_.front() += text;
|
|
}
|
|
}
|
|
|
|
|
|
void KillRingPrepend(const std::string &text)
|
|
{
|
|
if (text.empty())
|
|
return;
|
|
if (kill_ring_.empty()) {
|
|
KillRingPush(text);
|
|
} else {
|
|
kill_ring_.front() = text + kill_ring_.front();
|
|
}
|
|
}
|
|
|
|
|
|
[[nodiscard]] std::string KillRingHead() const
|
|
{
|
|
return kill_ring_.empty() ? std::string() : kill_ring_.front();
|
|
}
|
|
|
|
|
|
void SetDirtyEx(int d)
|
|
{
|
|
dirtyex_ = d;
|
|
}
|
|
|
|
|
|
[[nodiscard]] int DirtyEx() const
|
|
{
|
|
return dirtyex_;
|
|
}
|
|
|
|
|
|
void SetUniversalArg(int uarg, int ucount)
|
|
{
|
|
uarg_ = uarg;
|
|
ucount_ = ucount;
|
|
}
|
|
|
|
|
|
[[nodiscard]] int UArg() const
|
|
{
|
|
return uarg_;
|
|
}
|
|
|
|
|
|
[[nodiscard]] int UCount() const
|
|
{
|
|
return ucount_;
|
|
}
|
|
|
|
|
|
// --- Universal argument control (C-u) ---
|
|
// Begin or extend a universal argument (like ke's uarg_start)
|
|
void UArgStart();
|
|
|
|
// Add a digit 0..9 to the current universal argument (like ke's uarg_digit)
|
|
void UArgDigit(int d);
|
|
|
|
// Clear universal-argument state (like ke's uarg_clear)
|
|
void UArgClear();
|
|
|
|
// Consume the current universal argument, returning count >= 1.
|
|
// If no universal argument active, returns 1.
|
|
int UArgGet();
|
|
|
|
// Repeatable command flag: input layer can mark the next command as repeatable
|
|
void SetRepeatable(bool on)
|
|
{
|
|
repeatable_ = on;
|
|
}
|
|
|
|
|
|
[[nodiscard]] bool Repeatable() const
|
|
{
|
|
return repeatable_;
|
|
}
|
|
|
|
|
|
// Status message storage. Rendering is renderer-dependent; the editor
|
|
// merely stores the current message and its timestamp.
|
|
void SetStatus(const std::string &message);
|
|
|
|
|
|
[[nodiscard]] const std::string &Status() const
|
|
{
|
|
return msg_;
|
|
}
|
|
|
|
|
|
// --- Quit/Exit state ---
|
|
void SetQuitRequested(bool on)
|
|
{
|
|
quit_requested_ = on;
|
|
}
|
|
|
|
|
|
[[nodiscard]] bool QuitRequested() const
|
|
{
|
|
return quit_requested_;
|
|
}
|
|
|
|
|
|
void SetNewWindowRequested(bool on)
|
|
{
|
|
new_window_requested_ = on;
|
|
}
|
|
|
|
|
|
[[nodiscard]] bool NewWindowRequested() const
|
|
{
|
|
return new_window_requested_;
|
|
}
|
|
|
|
|
|
void SetQuitConfirmPending(bool on)
|
|
{
|
|
quit_confirm_pending_ = on;
|
|
}
|
|
|
|
|
|
[[nodiscard]] bool QuitConfirmPending() const
|
|
{
|
|
return quit_confirm_pending_;
|
|
}
|
|
|
|
|
|
// --- Buffer close/save confirmation state ---
|
|
void SetCloseConfirmPending(bool on)
|
|
{
|
|
close_confirm_pending_ = on;
|
|
}
|
|
|
|
|
|
[[nodiscard]] bool CloseConfirmPending() const
|
|
{
|
|
return close_confirm_pending_;
|
|
}
|
|
|
|
|
|
void SetCloseAfterSave(bool on)
|
|
{
|
|
close_after_save_ = on;
|
|
}
|
|
|
|
|
|
[[nodiscard]] bool CloseAfterSave() const
|
|
{
|
|
return close_after_save_;
|
|
}
|
|
|
|
|
|
[[nodiscard]] std::time_t StatusTime() const
|
|
{
|
|
return msgtm_;
|
|
}
|
|
|
|
|
|
// --- Minimal search state for incremental search (milestone 6) ---
|
|
void SetSearchActive(bool on)
|
|
{
|
|
search_active_ = on;
|
|
}
|
|
|
|
|
|
[[nodiscard]] bool SearchActive() const
|
|
{
|
|
return search_active_;
|
|
}
|
|
|
|
|
|
void SetSearchQuery(const std::string &q)
|
|
{
|
|
search_query_ = q;
|
|
}
|
|
|
|
|
|
[[nodiscard]] const std::string &SearchQuery() const
|
|
{
|
|
return search_query_;
|
|
}
|
|
|
|
|
|
void SetSearchMatch(std::size_t y, std::size_t x, std::size_t len)
|
|
{
|
|
search_y_ = y;
|
|
search_x_ = x;
|
|
search_len_ = len;
|
|
}
|
|
|
|
|
|
[[nodiscard]] std::size_t SearchMatchY() const
|
|
{
|
|
return search_y_;
|
|
}
|
|
|
|
|
|
[[nodiscard]] std::size_t SearchMatchX() const
|
|
{
|
|
return search_x_;
|
|
}
|
|
|
|
|
|
[[nodiscard]] std::size_t SearchMatchLen() const
|
|
{
|
|
return search_len_;
|
|
}
|
|
|
|
|
|
// Additional helpers for search session bookkeeping
|
|
void SetSearchOrigin(std::size_t x, std::size_t y, std::size_t rowoffs, std::size_t coloffs)
|
|
{
|
|
search_origin_set_ = true;
|
|
search_orig_x_ = x;
|
|
search_orig_y_ = y;
|
|
search_orig_rowoffs_ = rowoffs;
|
|
search_orig_coloffs_ = coloffs;
|
|
}
|
|
|
|
|
|
void ClearSearchOrigin()
|
|
{
|
|
search_origin_set_ = false;
|
|
search_orig_x_ = search_orig_y_ = search_orig_rowoffs_ = search_orig_coloffs_ = 0;
|
|
}
|
|
|
|
|
|
[[nodiscard]] bool SearchOriginSet() const
|
|
{
|
|
return search_origin_set_;
|
|
}
|
|
|
|
|
|
[[nodiscard]] std::size_t SearchOrigX() const
|
|
{
|
|
return search_orig_x_;
|
|
}
|
|
|
|
|
|
[[nodiscard]] std::size_t SearchOrigY() const
|
|
{
|
|
return search_orig_y_;
|
|
}
|
|
|
|
|
|
[[nodiscard]] std::size_t SearchOrigRowoffs() const
|
|
{
|
|
return search_orig_rowoffs_;
|
|
}
|
|
|
|
|
|
[[nodiscard]] std::size_t SearchOrigColoffs() const
|
|
{
|
|
return search_orig_coloffs_;
|
|
}
|
|
|
|
|
|
void SetSearchIndex(int i)
|
|
{
|
|
search_index_ = i;
|
|
}
|
|
|
|
|
|
[[nodiscard]] int SearchIndex() const
|
|
{
|
|
return search_index_;
|
|
}
|
|
|
|
|
|
// --- Generic Prompt subsystem (for search, open-file, save-as, etc.) ---
|
|
enum class PromptKind {
|
|
None = 0,
|
|
Search,
|
|
RegexSearch,
|
|
RegexReplaceFind, // step 1 of Regex Search & Replace: find pattern
|
|
RegexReplaceWith, // step 2 of Regex Search & Replace: replacement text
|
|
OpenFile,
|
|
SaveAs,
|
|
Confirm,
|
|
BufferSwitch,
|
|
GotoLine,
|
|
Chdir,
|
|
ReplaceFind, // step 1 of Search & Replace: find what
|
|
ReplaceWith, // step 2 of Search & Replace: replace with
|
|
Command // generic command prompt (": ")
|
|
};
|
|
|
|
|
|
void StartPrompt(PromptKind kind, const std::string &label, const std::string &initial)
|
|
{
|
|
prompt_active_ = true;
|
|
prompt_kind_ = kind;
|
|
prompt_label_ = label;
|
|
prompt_text_ = initial;
|
|
}
|
|
|
|
|
|
void CancelPrompt()
|
|
{
|
|
prompt_active_ = false;
|
|
prompt_kind_ = PromptKind::None;
|
|
prompt_label_.clear();
|
|
prompt_text_.clear();
|
|
}
|
|
|
|
|
|
void AcceptPrompt()
|
|
{
|
|
// Editor-level accept only ends prompt; commands act on value.
|
|
prompt_active_ = false;
|
|
}
|
|
|
|
|
|
void SetPromptText(const std::string &t)
|
|
{
|
|
prompt_text_ = t;
|
|
}
|
|
|
|
|
|
void AppendPromptText(const std::string &t)
|
|
{
|
|
prompt_text_ += t;
|
|
}
|
|
|
|
|
|
void BackspacePromptText()
|
|
{
|
|
if (!prompt_text_.empty())
|
|
prompt_text_.pop_back();
|
|
}
|
|
|
|
|
|
[[nodiscard]] bool PromptActive() const
|
|
{
|
|
return prompt_active_;
|
|
}
|
|
|
|
|
|
[[nodiscard]] PromptKind CurrentPromptKind() const
|
|
{
|
|
return prompt_kind_;
|
|
}
|
|
|
|
|
|
// --- Overwrite confirmation (save-as on existing file) ---
|
|
void SetPendingOverwritePath(const std::string &path)
|
|
{
|
|
pending_overwrite_path_ = path;
|
|
}
|
|
|
|
|
|
void ClearPendingOverwritePath()
|
|
{
|
|
pending_overwrite_path_.clear();
|
|
}
|
|
|
|
|
|
[[nodiscard]] const std::string &PendingOverwritePath() const
|
|
{
|
|
return pending_overwrite_path_;
|
|
}
|
|
|
|
|
|
[[nodiscard]] const std::string &PromptLabel() const
|
|
{
|
|
return prompt_label_;
|
|
}
|
|
|
|
|
|
[[nodiscard]] const std::string &PromptText() const
|
|
{
|
|
return prompt_text_;
|
|
}
|
|
|
|
|
|
// Buffers
|
|
[[nodiscard]] std::size_t BufferCount() const
|
|
{
|
|
return Buffers().size();
|
|
}
|
|
|
|
|
|
[[nodiscard]] std::size_t CurrentBufferIndex() const
|
|
{
|
|
return curbuf_;
|
|
}
|
|
|
|
|
|
// Clamp curbuf_ to valid range. Call when the shared buffer list may
|
|
// have been modified by another editor (e.g., buffer closed in another window).
|
|
void ValidateBufferIndex()
|
|
{
|
|
const auto &bufs = Buffers();
|
|
if (bufs.empty()) {
|
|
curbuf_ = 0;
|
|
} else if (curbuf_ >= bufs.size()) {
|
|
curbuf_ = bufs.size() - 1;
|
|
}
|
|
}
|
|
|
|
|
|
Buffer *CurrentBuffer();
|
|
|
|
const Buffer *CurrentBuffer() const;
|
|
|
|
// Compute a display-friendly short name for a buffer path that is the
|
|
// shortest unique suffix among all open buffers. If buffer has no name,
|
|
// returns "[no name]".
|
|
[[nodiscard]] std::string DisplayNameFor(const Buffer &buf) const;
|
|
|
|
// Add an existing buffer (copy/move) or open from file path
|
|
std::size_t AddBuffer(const Buffer &buf);
|
|
|
|
std::size_t AddBuffer(Buffer &&buf);
|
|
|
|
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
|
|
bool SwitchTo(std::size_t index);
|
|
|
|
bool CloseBuffer(std::size_t index);
|
|
|
|
// Reset to initial state
|
|
void Reset();
|
|
|
|
// Direct access when needed (try to prefer methods above)
|
|
[[nodiscard]] const std::vector<Buffer> &Buffers() const
|
|
{
|
|
return shared_buffers_ ? *shared_buffers_ : buffers_;
|
|
}
|
|
|
|
|
|
std::vector<Buffer> &Buffers()
|
|
{
|
|
return shared_buffers_ ? *shared_buffers_ : buffers_;
|
|
}
|
|
|
|
|
|
// Share another editor's buffer list. When set, this editor operates on
|
|
// the provided vector instead of its own. Pass nullptr to detach.
|
|
// Buffers hold recorders owned by the SwapManager that attached them, so
|
|
// editors sharing buffers must also share that manager (shared_swap);
|
|
// otherwise closing one window destroys recorders the other still uses.
|
|
void SetSharedBuffers(std::vector<Buffer> *shared, kte::SwapManager *shared_swap = nullptr)
|
|
{
|
|
shared_buffers_ = shared;
|
|
shared_swap_ = shared ? shared_swap : nullptr;
|
|
curbuf_ = 0;
|
|
}
|
|
|
|
|
|
// Swap manager access (for advanced integrations/tests)
|
|
[[nodiscard]] kte::SwapManager *Swap()
|
|
{
|
|
return shared_swap_ ? shared_swap_ : swap_.get();
|
|
}
|
|
|
|
|
|
// --- GUI: Visual File Picker state ---
|
|
void SetFilePickerVisible(bool on)
|
|
{
|
|
file_picker_visible_ = on;
|
|
}
|
|
|
|
|
|
[[nodiscard]] bool FilePickerVisible() const
|
|
{
|
|
return file_picker_visible_;
|
|
}
|
|
|
|
|
|
void SetFilePickerDir(const std::string &path)
|
|
{
|
|
file_picker_dir_ = path;
|
|
}
|
|
|
|
|
|
[[nodiscard]] const std::string &FilePickerDir() const
|
|
{
|
|
return file_picker_dir_;
|
|
}
|
|
|
|
private:
|
|
struct PendingOpen {
|
|
std::string path;
|
|
std::size_t line1{0}; // 1-based; 0 = none
|
|
};
|
|
|
|
std::size_t rows_ = 0, cols_ = 0;
|
|
int mode_ = 0;
|
|
int kill_ = 0; // KILL CHAIN
|
|
int no_kill_ = 0; // don't kill in delete_row
|
|
int dirtyex_ = 0;
|
|
std::string msg_;
|
|
std::time_t msgtm_ = 0;
|
|
int uarg_ = 0, ucount_ = 0; // C-u support
|
|
bool repeatable_ = false; // whether the next command is repeatable
|
|
|
|
std::vector<Buffer> buffers_;
|
|
std::vector<Buffer> *shared_buffers_ = nullptr; // if set, use this instead of buffers_
|
|
std::size_t curbuf_ = 0; // index into buffers_
|
|
|
|
// Swap journaling manager (lifetime = editor)
|
|
std::unique_ptr<kte::SwapManager> swap_;
|
|
kte::SwapManager *shared_swap_ = nullptr; // set with shared_buffers_; not owned
|
|
|
|
// Kill ring (Emacs-like)
|
|
std::vector<std::string> kill_ring_;
|
|
std::size_t kill_ring_max_ = 60;
|
|
|
|
// Quit state
|
|
bool quit_requested_ = false;
|
|
bool new_window_requested_ = false;
|
|
bool quit_confirm_pending_ = false;
|
|
bool close_confirm_pending_ = false; // awaiting y/N to save-before-close
|
|
bool close_after_save_ = false; // if true, close buffer after successful Save/SaveAs
|
|
|
|
// Search state
|
|
bool search_active_ = false;
|
|
std::string search_query_;
|
|
std::size_t search_y_ = 0, search_x_ = 0, search_len_ = 0;
|
|
// Search session bookkeeping
|
|
bool search_origin_set_ = false;
|
|
std::size_t search_orig_x_ = 0, search_orig_y_ = 0;
|
|
std::size_t search_orig_rowoffs_ = 0, search_orig_coloffs_ = 0;
|
|
int search_index_ = -1;
|
|
|
|
// Prompt state
|
|
bool prompt_active_ = false;
|
|
PromptKind prompt_kind_ = PromptKind::None;
|
|
std::string prompt_label_;
|
|
std::string prompt_text_;
|
|
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)
|
|
bool file_picker_visible_ = false;
|
|
std::string file_picker_dir_;
|
|
|
|
// Temporary state for Search & Replace flow
|
|
public:
|
|
void SetReplaceFindTmp(const std::string &s)
|
|
{
|
|
replace_find_tmp_ = s;
|
|
}
|
|
|
|
|
|
void SetReplaceWithTmp(const std::string &s)
|
|
{
|
|
replace_with_tmp_ = s;
|
|
}
|
|
|
|
|
|
[[nodiscard]] const std::string &ReplaceFindTmp() const
|
|
{
|
|
return replace_find_tmp_;
|
|
}
|
|
|
|
|
|
[[nodiscard]] const std::string &ReplaceWithTmp() const
|
|
{
|
|
return replace_with_tmp_;
|
|
}
|
|
|
|
private:
|
|
std::string replace_find_tmp_;
|
|
std::string replace_with_tmp_;
|
|
}; |