From 5d2d67ab0988959044a460be85a186c2208f0ef9 Mon Sep 17 00:00:00 2001 From: Valdemar Lorenzen Date: Thu, 8 Oct 2026 12:39:19 +0200 Subject: [PATCH] feat: add multiple buffers and file switching --- README.md | 28 +- src/editor.rs | 1303 +++++++++++++++++++++++++++++++++++++---------- src/main.rs | 2 + src/session.rs | 61 +++ src/tui.rs | 309 ++++++++++- test-file-2.abc | 1 + 6 files changed, 1403 insertions(+), 301 deletions(-) create mode 100644 test-file-2.abc diff --git a/README.md b/README.md index 2ae9b36..ef79422 100644 --- a/README.md +++ b/README.md @@ -66,10 +66,27 @@ Submitting an empty search repeats the last query. Esc cancels without moving th Commands: `:w [path]`, `:q`, `:q!`, `:wq [path]`, `:plugins`, and `:plugin-command [arguments]`. -`:q` refuses unsaved changes. `:wq` quits only after a successful save. Saves use a same-directory temporary file and rename, preserve existing permissions, and reject target symlinks. UTF-8 text is edited by extended grapheme; tabs render at four-cell stops. LF and trailing-newline structure are preserved; CRLF and CR are normalized to LF. Binary/terminal-control-containing text is rejected. Bracketed paste inserts only in Insert mode and never executes pasted commands in Normal mode. +`:q` refuses unsaved changes in any open buffer; `:q!` exits without saving them. `:wq` saves the active buffer and quits only if every buffer is then clean. Saves use a same-directory temporary file and rename, preserve existing permissions, and reject target symlinks. Writing to a path owned by another loaded buffer refuses before modifying the file. UTF-8 text is edited by extended grapheme; tabs render at four-cell stops. LF and trailing-newline structure are preserved; CRLF and CR are normalized to LF. Binary/terminal-control-containing text is rejected. Bracketed paste inserts only in Insert mode and never executes pasted commands in Normal mode. Paste is backend input: it is ignored in Normal, Command, Search, and Visual modes, and inserts literal text in Insert mode, with CRLF/CR normalization and a 4 MiB limit. Pasted text is one undo action, separate from typing before and after it. Rejected paste reports an error without changing text, cursor, mode, or pending insert history. Internal clipboard actions are separate from `Input::Paste` and use already-validated buffer text. +### Buffers + +One buffer is visible at a time. Each retains its text, path, saved state, directed selections and primary, mode, search state, and undo/redo history. The TUI retains separate vertical/horizontal scroll offsets by stable buffer ID, adjusting them only as needed to keep the primary visible after resizing. Clipboard fragments, bindings, plugin hosts, and plugin retirement remain session-wide. Plugin context and effects refer to the active buffer. + +| Command | Behavior | +| --- | --- | +| `:edit PATH` | Switch to an already-loaded file, or read and validate a new buffer; a nonexistent file opens empty | +| `:buffers` | List stable IDs and paths; `%` marks active, `+` marks dirty | +| `:buffer N` | Switch to buffer ID `N` | +| `:bnext` / `:bprev` | Switch in ascending-ID order, wrapping at either end | +| `:bdelete [N]` | Close the active or named buffer; refuse unsaved changes | +| `:bdelete! [N]` | Explicitly discard changes and close the active or named buffer | + +IDs start at 1 and are never renumbered or reused. Closing the last buffer creates a fresh unnamed clean buffer rather than quitting. Closing the active buffer selects the next remaining ID, with wraparound. Switching does not save, discard, or reload hidden edits. Existing-file aliases resolve to the same loaded buffer; the supplied path spelling remains the save/display path, so opening through a symlink does not bypass save rejection. Paths are relative to the process working directory; the entire `:edit` argument is the path, including spaces, with no shell expansion. + +Switching from Insert commits its pending undo group and parks that buffer in Normal. Explicit buffer switching from Visual retains that buffer's directed ranges and Visual mode; switching back restores them. Command/search prompts, palette queries, and pending motion counts are transient, not restored per buffer. The new commands appear in the command palette and can be bound by exact command name in Normal mode. When several buffers are open, status shows the active ID and total count. + ### Command palette Ctrl+p opens a searchable overlay without leaving the current mode. It lists core actions and registered Ex/plugin commands with their owner, effective bindings in the current mode, and eligible modes. Disabled or overridden bindings are not advertised; actions with no key remain available through the palette. Mode-ineligible entries are dimmed and cannot execute. Retired plugin commands disappear. @@ -84,9 +101,9 @@ Esc or Ctrl+p cancels without changing text, selections, the previous command/se One crate, four modules, no workspace or frontend-specific types in the editing backend: -- `editor`: owns buffer, the primary cursor and selection set, modes and internal clipboard fragments, search state/navigation and grapheme-coordinate range presentation, action metadata and command-palette state/filtering, key notation and binding resolution, modal paste policy, undo/redo, saving, command ownership, capabilities, and validated edit effects. Its low-level interface returns an `Invocation` when an external command is selected. -- `session`: `EditorSession` owns the editor and its Ruby hosts together. It loads configuration, registers eligible plugins before installing bindings, executes selected commands, and retires both host and command ownership on invocation or effect-validation failure. -- `tui`: translates terminal events to session input, renders read-only editor state, and advertises `text` and `styled_text` capabilities. It has no plugin lifecycle callback, host storage, or paste acceptance policy. +- `editor`: owns buffer lifecycle and per-buffer text, saved state, primary/selection set, modes, search, and undo/redo; session-wide clipboard fragments, action metadata and command-palette state/filtering, key notation and binding resolution, modal paste policy, saving, command ownership, capabilities, and validated edit effects. Its low-level interface returns an `Invocation` when an external command is selected. +- `session`: `EditorSession` owns the editor and its Ruby hosts together. It loads configuration once, registers eligible plugins before installing shared bindings, executes selected commands against the active buffer, and retires both host and command ownership on invocation or effect-validation failure. +- `tui`: translates terminal events to session input, renders read-only active-buffer state, keeps frontend-owned viewports by stable buffer ID, and advertises `text` and `styled_text` capabilities. It has no plugin lifecycle callback, host storage, or paste acceptance policy. - `ruby`: evaluates configuration and runs isolated plugin hosts. Protocol, deadlines, and process cleanup stay inside this module. - The executable selects paths and frontend capabilities, opens an editor session, and launches the TUI. @@ -94,6 +111,7 @@ Frontends drive `EditorSession::handle` or `execute` and inspect `editor()`; the Frontends inspect `selections()` for document-ordered, end-exclusive Visual ranges of grapheme coordinates (empty outside Visual), `carets()` for all active cursors in document order, `cursor()` for the primary, and `selection_count()` for the total target count. A range ending at the next row's column 0 includes the previous row's newline; an empty final-row range contains no text. Selection normalization, clipboard contents, edit planning, and history remain inside the editor module. Frontends inspect `palette()` for the optional read-only query, filtered command entries, selected index, effective bindings, and eligibility. Palette input handling and acceptance stay in the editor; registered command execution stays in the session. Frontends inspect `count()` for the optional pending motion prefix; parsing and counted motion dispatch remain editor-owned. +Frontends inspect `buffer_id()`, `buffer_count()`, `has_buffer(id)`, and the ascending-ID `buffers()` iterator of borrowed path/dirty/active metadata. Existing text, cursor, selection, search, and mode methods describe only the active buffer. The terminal adapter owns raw mode, alternate-screen state, bracketed paste, and its panic hook for one session lifetime. Entry failure, normal return, I/O errors, and unwinding panics restore the caller's hook; active-terminal cleanup runs before caller panic diagnostics. Panics retain their original payload and propagate rather than being swallowed. Terminal state and panic hooks are process-global: run one TUI at a time and do not replace its hook while that session is active. @@ -196,7 +214,7 @@ JSON uses Ruby's standard library. Protocol Buffers is not installed or required ## Current limits -- One text buffer; Normal, Insert, Command, Search, and characterwise Visual modes. No system clipboard integration, named registers, line/block selection, Vim operators, splits, regex/multiline search, LSP, or graphical frontend. +- One visible buffer at a time; Normal, Insert, Command, Search, and characterwise Visual modes. No system clipboard integration, named registers, line/block selection, Vim operators, splits/tabs, regex/multiline search, LSP, or graphical frontend. - Undo uses full-buffer snapshots and movement scans the current line. This favors straightforward correctness over large-file performance. - Plugin invocation is synchronous and may pause input for up to 500 ms before a host timeout. Config/plugin startup has a 2-second deadline per host. Buffer context must fit within a 4 MiB JSON request; a too-large request disables that plugin, not editing. - Disabling a plugin removes its commands and bindings until the next launch. There is no hot reload. diff --git a/src/editor.rs b/src/editor.rs index e933f2b..43982ee 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -1,12 +1,12 @@ use serde::Serialize; use std::borrow::Cow; -use std::collections::{HashMap, HashSet}; +use std::collections::{BTreeMap, HashMap, HashSet}; use std::fmt; use std::fs::{self, OpenOptions}; use std::io::{self, Write}; #[cfg(unix)] use std::os::unix::fs::OpenOptionsExt; -use std::path::{Path, PathBuf}; +use std::path::{Component, Path, PathBuf}; use std::sync::atomic::{AtomicU64, Ordering}; use unicode_segmentation::{GraphemeCursor, UnicodeSegmentation}; use unicode_width::UnicodeWidthStr; @@ -592,32 +592,33 @@ fn default_bindings() -> HashMap> { bindings } -pub struct Editor { +/// Borrowed buffer-list metadata; paths retain their supplied display/save spelling. +pub struct BufferInfo<'a> { + pub id: usize, + pub path: Option<&'a Path>, + pub dirty: bool, + pub active: bool, +} + +struct Buffer { + id: usize, lines: Vec, saved_lines: Vec, targets: Vec, primary: usize, mode: Mode, - command_line: String, search_query: String, search_match: Option, - clipboard: Vec, - message: String, path: Option, - quit: bool, - capabilities: Vec, - features: HashMap, - bindings: HashMap>, - palette: Option, - count: Option, + identity: Option, // ponytail: full-buffer history costs O(buffer size) per action; use edit deltas for large files. undo: Vec, redo: Vec, insert_start: Option, } -impl Editor { - pub fn open(path: Option, capabilities: Vec) -> io::Result { +impl Buffer { + fn open(id: usize, path: Option, identity: Option) -> io::Result { let text = match &path { Some(path) => match fs::read_to_string(path) { Ok(text) => text, @@ -628,37 +629,81 @@ impl Editor { }; validate_text(&text).map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?; let lines = split_lines(&text); - let mut editor = Self { + Ok(Self { + id, saved_lines: lines.clone(), lines, targets: vec![Target::caret(Cursor::default())], primary: 0, mode: Mode::Normal, - command_line: String::new(), search_query: String::new(), search_match: None, + path, + identity, + undo: Vec::new(), + redo: Vec::new(), + insert_start: None, + }) + } + + fn dirty(&self) -> bool { + self.lines != self.saved_lines + } + + fn info(&self, active: bool) -> BufferInfo<'_> { + BufferInfo { + id: self.id, + path: self.path.as_deref(), + dirty: self.dirty(), + active, + } + } +} + +pub struct Editor { + active: Buffer, + parked: BTreeMap, + next_buffer_id: usize, + command_line: String, + clipboard: Vec, + message: String, + quit: bool, + capabilities: Vec, + features: HashMap, + bindings: HashMap>, + palette: Option, + count: Option, +} + +impl Editor { + pub fn open(path: Option, capabilities: Vec) -> io::Result { + let identity = path.as_deref().map(path_identity).transpose()?; + let mut editor = Self { + active: Buffer::open(1, path, identity)?, + parked: BTreeMap::new(), + next_buffer_id: 2, + command_line: String::new(), clipboard: Vec::new(), message: String::new(), - path, quit: false, capabilities, features: HashMap::new(), bindings: default_bindings(), palette: None, count: None, - undo: Vec::new(), - redo: Vec::new(), - insert_start: None, }; editor .install( Feature { name: "core".into(), required_capabilities: Vec::new(), - commands: ["w", "q", "q!", "wq", "plugins"] - .into_iter() - .map(str::to_owned) - .collect(), + commands: [ + "w", "q", "q!", "wq", "plugins", "edit", "buffers", "buffer", "bnext", + "bprev", "bdelete", "bdelete!", + ] + .into_iter() + .map(str::to_owned) + .collect(), core: true, }, true, @@ -667,14 +712,40 @@ impl Editor { Ok(editor) } + /// Stable one-based ID, never reused after a buffer is deleted. + pub fn buffer_id(&self) -> usize { + self.active.id + } + + pub fn buffer_count(&self) -> usize { + self.parked.len() + 1 + } + + pub fn has_buffer(&self, id: usize) -> bool { + id == self.active.id || self.parked.contains_key(&id) + } + + /// All loaded buffers in ascending ID order, without copying their paths. + pub fn buffers(&self) -> impl Iterator> + '_ { + self.parked + .range(..self.active.id) + .map(|(_, buffer)| buffer.info(false)) + .chain(std::iter::once(self.active.info(true))) + .chain( + self.parked + .range(self.active.id..) + .map(|(_, buffer)| buffer.info(false)), + ) + } + pub fn lines(&self) -> &[String] { - &self.lines + &self.active.lines } pub fn cursor(&self) -> Cursor { - self.targets[self.primary].active + self.active.targets[self.active.primary].active } pub fn mode(&self) -> Mode { - self.mode + self.active.mode } /// Read-only display state for the overlay; opening it does not change mode. pub fn palette(&self) -> Option<&Palette> { @@ -689,12 +760,13 @@ impl Editor { &self.command_line } pub fn search_match(&self) -> Option { - self.search_match + self.active.search_match } /// Ordered, end-exclusive Visual ranges in document order; empty in other modes. pub fn selections(&self) -> Vec { - if self.mode == Mode::Visual { - self.targets + if self.active.mode == Mode::Visual { + self.active + .targets .iter() .map(|target| self.target_range(*target)) .collect() @@ -704,19 +776,23 @@ impl Editor { } /// All active cursors in document order, including the primary cursor. pub fn carets(&self) -> Vec { - self.targets.iter().map(|target| target.active).collect() + self.active + .targets + .iter() + .map(|target| target.active) + .collect() } pub fn selection_count(&self) -> usize { - self.targets.len() + self.active.targets.len() } pub fn message(&self) -> &str { &self.message } pub fn path(&self) -> Option<&Path> { - self.path.as_deref() + self.active.path.as_deref() } pub fn dirty(&self) -> bool { - self.lines != self.saved_lines + self.active.dirty() } pub fn should_quit(&self) -> bool { self.quit @@ -724,11 +800,12 @@ impl Editor { pub fn context(&self) -> Context { Context { - text: self.lines.join("\n"), + text: self.active.lines.join("\n"), row: self.cursor().row + 1, column: self.cursor().column + 1, - mode: self.mode.to_string(), + mode: self.active.mode.to_string(), path: self + .active .path .as_ref() .map(|path| path.to_string_lossy().into_owned()), @@ -842,7 +919,7 @@ impl Editor { // ponytail: scan mode bindings per result; index by target if catalogs grow. let mut bindings: Vec<_> = self .bindings - .get(&self.mode) + .get(&self.active.mode) .into_iter() .flat_map(|keys| keys.iter()) .filter(|(_, binding)| match (action, binding) { @@ -882,7 +959,7 @@ impl Editor { label, owner: "core".into(), bindings: self.palette_bindings(Some(action), ""), - enabled: modes.contains(&self.mode), + enabled: modes.contains(&self.active.mode), modes: modes.to_vec(), }); } @@ -898,7 +975,7 @@ impl Editor { label: command.clone(), owner: feature.name.clone(), bindings: self.palette_bindings(None, command), - enabled: self.mode == Mode::Normal, + enabled: self.active.mode == Mode::Normal, modes: vec![Mode::Normal], }); } @@ -931,13 +1008,13 @@ impl Editor { .entries .get(palette.selected) .filter(|entry| entry.enabled)?; - if let Some(action) = Action::named(self.mode, &entry.name) { + if let Some(action) = Action::named(self.active.mode, &entry.name) { self.palette = None; return self.act(action); } self.command_line = format!("{} ", entry.name); self.palette = None; - self.mode = Mode::Command; + self.active.mode = Mode::Command; } Input::Backspace => { if let Some((byte, _)) = palette.query.grapheme_indices(true).next_back() { @@ -965,7 +1042,7 @@ impl Editor { // Paste is literal input, never a configurable key or command. if let Input::Paste(text) = input { self.count = None; - if self.mode == Mode::Insert { + if self.active.mode == Mode::Insert { self.paste(&text); } return None; @@ -978,12 +1055,12 @@ impl Editor { if input == Input::Escape && self.count.take().is_some() { return None; } - if matches!(self.mode, Mode::Normal | Mode::Visual) + if matches!(self.active.mode, Mode::Normal | Mode::Visual) && let Input::Char(ch @ '0'..='9') = input { let binding = self .bindings - .get(&self.mode) + .get(&self.active.mode) .and_then(|keys| keys.get(&input)); let extends_count = ch == '0' && self.count.is_some() @@ -1001,7 +1078,7 @@ impl Editor { } match self .bindings - .get(&self.mode) + .get(&self.active.mode) .and_then(|keys| keys.get(&input)) { Some(Binding::Action(action)) => return self.act(*action), @@ -1016,13 +1093,13 @@ impl Editor { None => {} } self.count = None; - match (self.mode, input) { + match (self.active.mode, input) { (Mode::Insert, Input::Char(ch)) if !ch.is_control() || ch == '\t' => { let mut bytes = [0; 4]; self.insert_text(ch.encode_utf8(&mut bytes)); } (Mode::Command | Mode::Search, Input::Char(ch)) - if !ch.is_control() || (self.mode == Mode::Search && ch == '\t') => + if !ch.is_control() || (self.active.mode == Mode::Search && ch == '\t') => { self.command_line.push(ch); } @@ -1044,15 +1121,15 @@ impl Editor { } Action::Insert => self.begin_insert(), Action::InsertFirstNonblank | Action::AppendLineEnd => { - if self.mode == Mode::Visual { + if self.active.mode == Mode::Visual { let before = self.snapshot(); self.split_rows(matches!(action, Action::AppendLineEnd), true); self.begin_insert(); - self.insert_start = Some(before); + self.active.insert_start = Some(before); } else { self.begin_insert(); - for target in &mut self.targets { - let line = &self.lines[target.active.row]; + for target in &mut self.active.targets { + let line = &self.active.lines[target.active.row]; target.active.column = if matches!(action, Action::AppendLineEnd) { grapheme_count(line) } else { @@ -1064,9 +1141,9 @@ impl Editor { } Action::Append => { self.begin_insert(); - for target in &mut self.targets { + for target in &mut self.active.targets { target.active.column = (target.active.column + 1) - .min(grapheme_count(&self.lines[target.active.row])); + .min(grapheme_count(&self.active.lines[target.active.row])); } self.normalize_targets(); } @@ -1088,7 +1165,7 @@ impl Editor { | Action::BigWordEnd => self.move_targets(action, count), Action::JoinLines => self.join_lines(), Action::Delete => { - let before = (self.mode != Mode::Insert).then(|| self.snapshot()); + let before = (self.active.mode != Mode::Insert).then(|| self.snapshot()); self.delete_forward(); if let Some(before) = before { self.record(before); @@ -1097,7 +1174,7 @@ impl Editor { Action::Undo => self.undo(), Action::Redo => self.redo(), Action::Command | Action::Search => { - self.mode = if matches!(action, Action::Search) { + self.active.mode = if matches!(action, Action::Search) { Mode::Search } else { Mode::Command @@ -1107,13 +1184,13 @@ impl Editor { Action::SearchNext => self.seek_search(true, count), Action::SearchPrevious => self.seek_search(false, count), Action::Select => { - if self.mode == Mode::Visual { + if self.active.mode == Mode::Visual { self.clear_selection(); } else { - for target in &mut self.targets { + for target in &mut self.active.targets { target.anchor = target.active; } - self.mode = Mode::Visual; + self.active.mode = Mode::Visual; self.normalize_targets(); self.message.clear(); } @@ -1128,24 +1205,24 @@ impl Editor { Action::PasteBefore => self.paste_clipboard(false), Action::PasteAfter => self.paste_clipboard(true), Action::Normal => { - if self.mode == Mode::Normal { + if self.active.mode == Mode::Normal { self.collapse_primary(); - } else if self.mode == Mode::Insert { + } else if self.active.mode == Mode::Insert { self.finish_insert(); - self.mode = Mode::Normal; - for target in &mut self.targets { + self.active.mode = Mode::Normal; + for target in &mut self.active.targets { target.active.column = target.active.column.saturating_sub(1); } self.normalize_targets(); } else { self.command_line.clear(); - self.mode = Mode::Normal; + self.active.mode = Mode::Normal; self.normalize_targets(); } } Action::Newline => self.insert_text("\n"), Action::Backspace => { - if self.mode == Mode::Insert { + if self.active.mode == Mode::Insert { self.backspace(); } else if let Some((byte, _)) = self.command_line.grapheme_indices(true).next_back() { @@ -1154,11 +1231,11 @@ impl Editor { } Action::Submit => { let command = std::mem::take(&mut self.command_line); - let searching = self.mode == Mode::Search; - self.mode = Mode::Normal; + let searching = self.active.mode == Mode::Search; + self.active.mode = Mode::Normal; if searching { if !command.is_empty() { - self.search_query = command; + self.active.search_query = command; } self.seek_search(true, 1); } else { @@ -1170,14 +1247,15 @@ impl Editor { } fn seek_search(&mut self, forward: bool, count: usize) { - self.search_match = None; - if self.search_query.is_empty() { + self.active.search_match = None; + if self.active.search_query.is_empty() { self.set_message("No previous search"); return; } - let query = self.search_query.as_str(); + let query = self.active.search_query.as_str(); // ponytail: O(buffer graphemes × query bytes) scan; index matches if large buffers need it. let matches: Vec<_> = self + .active .lines .iter() .enumerate() @@ -1192,13 +1270,13 @@ impl Editor { }) .collect(); if matches.is_empty() { - self.set_message(format!("Not found: {}", self.search_query)); + self.set_message(format!("Not found: {}", self.active.search_query)); return; } - for (index, target) in self.targets.iter_mut().enumerate() { + for (index, target) in self.active.targets.iter_mut().enumerate() { let origin = ( target.active.row, - grapheme_byte(&self.lines[target.active.row], target.active.column), + grapheme_byte(&self.active.lines[target.active.row], target.active.column), ); let first = if forward { matches.partition_point(|position| *position <= origin) % matches.len() @@ -1217,11 +1295,11 @@ impl Editor { let found = matches[destination]; target.active = Cursor { row: found.0, - column: column_after_byte(&self.lines[found.0], found.1), + column: column_after_byte(&self.active.lines[found.0], found.1), }; target.preferred_columns = None; - if index == self.primary { - self.search_match = Some(SearchMatch { + if index == self.active.primary { + self.active.search_match = Some(SearchMatch { start: target.active, end_column: target.active.column + grapheme_count(query), }); @@ -1231,20 +1309,200 @@ impl Editor { self.message.clear(); } + fn park_source(&mut self) { + if self.active.mode == Mode::Insert { + self.finish_insert(); + } + if matches!( + self.active.mode, + Mode::Insert | Mode::Command | Mode::Search + ) { + self.active.mode = Mode::Normal; + self.normalize_targets(); + } + self.command_line.clear(); + self.count = None; + self.palette = None; + } + + fn activate(&mut self, buffer: Buffer) { + self.park_source(); + let previous = std::mem::replace(&mut self.active, buffer); + self.parked.insert(previous.id, previous); + self.show_buffer(); + } + + fn show_buffer(&mut self) { + let path = self + .path() + .map_or_else(|| Cow::Borrowed("[No Name]"), Path::to_string_lossy); + self.set_message(format!("Buffer {}: {path}", self.active.id)); + } + + fn switch_buffer(&mut self, id: usize) { + if id == self.active.id { + self.park_source(); + self.show_buffer(); + } else if let Some(buffer) = self.parked.remove(&id) { + self.activate(buffer); + } else { + self.set_message(format!("No buffer: {id}")); + } + } + + fn adjacent_buffer(&self, forward: bool) -> usize { + use std::ops::Bound::{Excluded, Unbounded}; + let id = self.active.id; + if forward { + self.parked + .range((Excluded(id), Unbounded)) + .next() + .or_else(|| self.parked.first_key_value()) + } else { + self.parked + .range(..id) + .next_back() + .or_else(|| self.parked.last_key_value()) + } + .map_or(id, |(id, _)| *id) + } + + fn edit_buffer(&mut self, path: PathBuf) { + let identity = match path_identity(&path) { + Ok(identity) => identity, + Err(error) => { + self.set_message(format!("Open failed: {error}")); + return; + } + }; + let loaded = std::iter::once(&self.active) + .chain(self.parked.values()) + .find(|buffer| buffer.identity.as_ref() == Some(&identity)) + .map(|buffer| buffer.id); + if let Some(id) = loaded { + self.switch_buffer(id); + return; + } + match Buffer::open(self.next_buffer_id, Some(path), Some(identity)) { + Ok(buffer) => { + self.next_buffer_id += 1; + self.activate(buffer); + } + Err(error) => self.set_message(format!("Open failed: {error}")), + } + } + + fn delete_buffer(&mut self, id: usize, force: bool) { + let buffer = if id == self.active.id { + Some(&self.active) + } else { + self.parked.get(&id) + }; + let Some(buffer) = buffer else { + self.set_message(format!("No buffer: {id}")); + return; + }; + if buffer.dirty() && !force { + self.set_message(format!( + "Buffer {id} has unsaved changes; use :w or :bdelete!" + )); + return; + } + if id == self.active.id { + let next = self.adjacent_buffer(true); + let replacement = if next == id { + let buffer = Buffer::open(self.next_buffer_id, None, None) + .expect("an unnamed empty buffer is valid"); + self.next_buffer_id += 1; + buffer + } else { + self.parked.remove(&next).expect("adjacent buffer exists") + }; + self.park_source(); + self.active = replacement; + } else { + self.parked.remove(&id); + self.park_source(); + } + self.set_message(format!( + "Deleted buffer {id}; active buffer {}", + self.active.id + )); + } + + fn buffer_command(&mut self, name: &str, args: &str) -> bool { + match name { + "edit" if args.is_empty() => self.set_message("Usage: :edit path"), + "edit" => self.edit_buffer(PathBuf::from(args)), + "buffers" | "bnext" | "bprev" if !args.is_empty() => { + self.set_message(format!("The {name} command takes no arguments")); + } + "buffers" => { + let mut message = String::new(); + for buffer in self.buffers() { + if !message.is_empty() { + message.push_str(" | "); + } + use std::fmt::Write as _; + write!( + message, + "{}{}{} {}", + buffer.id, + if buffer.active { "%" } else { "" }, + if buffer.dirty { "+" } else { "" }, + buffer + .path + .map_or_else(|| Cow::Borrowed("[No Name]"), Path::to_string_lossy,), + ) + .expect("writing into a String cannot fail"); + } + self.set_message(message); + } + "bnext" | "bprev" => self.switch_buffer(self.adjacent_buffer(name == "bnext")), + "buffer" | "bdelete" | "bdelete!" => { + let id = if args.is_empty() && name != "buffer" { + Some(self.active.id) + } else if !args.is_empty() && args.bytes().all(|byte| byte.is_ascii_digit()) { + args.parse::().ok().filter(|id| *id > 0) + } else { + None + }; + match id { + Some(id) if name == "buffer" => self.switch_buffer(id), + Some(id) => self.delete_buffer(id, name == "bdelete!"), + None => self.set_message(format!( + "Usage: :{name} {}", + if name == "buffer" { "N" } else { "[N]" }, + )), + } + } + _ => return false, + } + true + } + + fn any_dirty(&self) -> bool { + self.active.dirty() || self.parked.values().any(Buffer::dirty) + } + pub fn execute(&mut self, command: &str) -> Option { self.count = None; self.palette = None; - if self.mode == Mode::Insert { - self.finish_insert(); - } - self.clear_selection(); - self.mode = Mode::Normal; - self.normalize_targets(); let command = command.trim().strip_prefix(':').unwrap_or(command.trim()); let (name, args) = command .split_once(char::is_whitespace) .unwrap_or((command, "")); let args = args.trim(); + // Switching must park directed Visual selections before ordinary Ex cleanup. + if self.buffer_command(name, args) { + return None; + } + if self.active.mode == Mode::Insert { + self.finish_insert(); + } + self.clear_selection(); + self.active.mode = Mode::Normal; + self.normalize_targets(); let Some(feature) = self.owner(name) else { self.set_message(format!("Unknown command: {name}")); return None; @@ -1264,11 +1522,15 @@ impl Editor { Some(PathBuf::from(args)) }) && name == "wq" { - self.quit = true; + if self.any_dirty() { + self.set_message("Unsaved changes in other buffers; use :w or :q!"); + } else { + self.quit = true; + } } } "q" | "q!" if !args.is_empty() => self.set_message("Quit commands take no arguments"), - "q" if self.dirty() => self.set_message("Unsaved changes; use :w or :q!"), + "q" if self.any_dirty() => self.set_message("Unsaved changes; use :w or :q!"), "q" | "q!" => self.quit = true, "plugins" if !args.is_empty() => { self.set_message("The plugins command takes no arguments") @@ -1316,8 +1578,8 @@ impl Editor { validate_text(text)?; } // Validation precedes even history/message changes, so a rejected response is atomic. - let previous_mode = self.mode; - let inserting = self.mode == Mode::Insert; + let previous_mode = self.active.mode; + let inserting = self.active.mode == Mode::Insert; if inserting { self.finish_insert(); } @@ -1333,21 +1595,21 @@ impl Editor { self.clear_selection(); self.collapse_primary(); // Plugin inserts use one primary insertion point, not the selection set. - self.mode = Mode::Insert; + self.active.mode = Mode::Insert; } for effect in effects { match effect { Effect::Insert(text) => self.insert_text(&normalize_newlines(&text)), Effect::Replace(text) => { - self.lines = split_lines(&text); - self.search_match = None; + self.active.lines = split_lines(&text); + self.active.search_match = None; self.normalize_targets(); } Effect::Message(text) => self.set_message(text), } } if before.is_some() { - self.mode = if inserting { + self.active.mode = if inserting { Mode::Insert } else { Mode::Normal @@ -1358,23 +1620,42 @@ impl Editor { self.record(before); } if inserting { - self.insert_start = Some(self.snapshot()); + self.active.insert_start = Some(self.snapshot()); } - if self.mode != previous_mode { + if self.active.mode != previous_mode { self.refresh_palette(); } Ok(()) } fn save(&mut self, path: Option) -> bool { - let Some(path) = path.or_else(|| self.path.clone()) else { + let Some(path) = path.or_else(|| self.active.path.clone()) else { self.set_message("No file name; use :w path"); return false; }; - match atomic_save(&path, &self.lines) { + let identity = match path_identity(&path) { + Ok(identity) => identity, + Err(error) => { + self.set_message(format!("Write failed: {error}")); + return false; + } + }; + if let Some(buffer) = self + .parked + .values() + .find(|buffer| buffer.identity.as_ref() == Some(&identity)) + { + self.set_message(format!( + "Write refused: path belongs to buffer {}", + buffer.id + )); + return false; + } + match atomic_save(&path, &self.active.lines) { Ok(()) => { - self.saved_lines.clone_from(&self.lines); - self.path = Some(path); + self.active.saved_lines.clone_from(&self.active.lines); + self.active.path = Some(path); + self.active.identity = Some(identity); self.set_message("Written"); true } @@ -1387,17 +1668,17 @@ impl Editor { fn snapshot(&self) -> Snapshot { Snapshot { - lines: self.lines.clone(), - targets: self.targets.clone(), - primary: self.primary, + lines: self.active.lines.clone(), + targets: self.active.targets.clone(), + primary: self.active.primary, } } fn record(&mut self, before: Snapshot) { - if before.lines != self.lines { - self.search_match = None; - self.undo.push(before); - self.redo.clear(); + if before.lines != self.active.lines { + self.active.search_match = None; + self.active.undo.push(before); + self.active.redo.clear(); } } @@ -1408,9 +1689,9 @@ impl Editor { } fn next_boundary(&self, mut cursor: Cursor) -> Cursor { - if cursor.column < grapheme_count(&self.lines[cursor.row]) { + if cursor.column < grapheme_count(&self.active.lines[cursor.row]) { cursor.column += 1; - } else if cursor.row + 1 < self.lines.len() { + } else if cursor.row + 1 < self.active.lines.len() { cursor.row += 1; cursor.column = 0; } @@ -1422,7 +1703,7 @@ impl Editor { cursor.column -= 1; } else if cursor.row > 0 { cursor.row -= 1; - cursor.column = grapheme_count(&self.lines[cursor.row]); + cursor.column = grapheme_count(&self.active.lines[cursor.row]); } cursor } @@ -1441,8 +1722,10 @@ impl Editor { } fn max_column(&self, row: usize) -> usize { - let count = grapheme_count(&self.lines[row]); - if self.mode == Mode::Insert || (self.mode == Mode::Visual && row + 1 < self.lines.len()) { + let count = grapheme_count(&self.active.lines[row]); + if self.active.mode == Mode::Insert + || (self.active.mode == Mode::Visual && row + 1 < self.active.lines.len()) + { count } else { count.saturating_sub(1) @@ -1450,25 +1733,26 @@ impl Editor { } fn normalize_targets(&mut self) { - for index in 0..self.targets.len() { - let mut target = self.targets[index]; - target.active.row = target.active.row.min(self.lines.len() - 1); + for index in 0..self.active.targets.len() { + let mut target = self.active.targets[index]; + target.active.row = target.active.row.min(self.active.lines.len() - 1); target.active.column = target.active.column.min(self.max_column(target.active.row)); - if self.mode == Mode::Visual { - target.anchor.row = target.anchor.row.min(self.lines.len() - 1); + if self.active.mode == Mode::Visual { + target.anchor.row = target.anchor.row.min(self.active.lines.len() - 1); target.anchor.column = target.anchor.column.min(self.max_column(target.anchor.row)); } else { target.anchor = target.active; } - self.targets[index] = target; + self.active.targets[index] = target; } let mut ordered: Vec<_> = self + .active .targets .iter() .copied() .enumerate() .map(|(index, target)| { - let range = if self.mode == Mode::Visual { + let range = if self.active.mode == Mode::Visual { self.target_range(target) } else { Selection { @@ -1476,7 +1760,7 @@ impl Editor { end: target.active, } }; - (target, index == self.primary, range) + (target, index == self.active.primary, range) }) .collect(); ordered.sort_by_key(|(_, _, range)| (range.start, range.end)); @@ -1488,7 +1772,7 @@ impl Editor { let representative = if primary { target } else { *previous }; previous_range.start = previous_range.start.min(range.start); previous_range.end = previous_range.end.max(range.end); - *previous = if self.mode == Mode::Visual + *previous = if self.active.mode == Mode::Visual && self.target_range(representative) != *previous_range { self.range_target( @@ -1503,25 +1787,26 @@ impl Editor { } merged.push((target, primary, range)); } - self.primary = merged + self.active.primary = merged .iter() .position(|(_, primary, _)| *primary) .unwrap_or(0); - self.targets.clear(); - self.targets + self.active.targets.clear(); + self.active + .targets .extend(merged.into_iter().map(|(target, _, _)| target)); } fn collapse_primary(&mut self) { - let target = self.targets[self.primary]; - self.targets.clear(); - self.targets.push(target); - self.primary = 0; + let target = self.active.targets[self.active.primary]; + self.active.targets.clear(); + self.active.targets.push(target); + self.active.primary = 0; } fn clear_selection(&mut self) { - if self.mode == Mode::Visual { - self.mode = Mode::Normal; + if self.active.mode == Mode::Visual { + self.active.mode = Mode::Normal; self.normalize_targets(); } } @@ -1538,31 +1823,32 @@ impl Editor { range.end.row }; for row in range.start.row..=last { - if index == self.primary && row == primary_row { + if index == self.active.primary && row == primary_row { primary = carets.len(); } let column = if end { - grapheme_count(&self.lines[row]) + grapheme_count(&self.active.lines[row]) } else { - first_nonblank(&self.lines[row]) + first_nonblank(&self.active.lines[row]) }; carets.push(Target::caret(Cursor { row, column })); } } - self.targets = carets; - self.primary = primary; - self.mode = if insert { Mode::Insert } else { Mode::Normal }; + self.active.targets = carets; + self.active.primary = primary; + self.active.mode = if insert { Mode::Insert } else { Mode::Normal }; self.normalize_targets(); } fn clone_vertical(&mut self, down: bool) { - let count = self.targets.len(); - let primary = self.primary; + let count = self.active.targets.len(); + let primary = self.active.primary; for index in 0..count { - let target = self.targets[index]; + let target = self.active.targets[index]; let shift = |row: usize| { if down { - row.checked_add(1).filter(|row| *row < self.lines.len()) + row.checked_add(1) + .filter(|row| *row < self.active.lines.len()) } else { row.checked_sub(1) } @@ -1575,16 +1861,16 @@ impl Editor { }; let columns = target.preferred_columns.unwrap_or_else(|| { ( - display_column(&self.lines[target.anchor.row], target.anchor.column), - display_column(&self.lines[target.active.row], target.active.column), + display_column(&self.active.lines[target.anchor.row], target.anchor.column), + display_column(&self.active.lines[target.active.row], target.active.column), ) }); - self.targets[index].preferred_columns = Some(columns); + self.active.targets[index].preferred_columns = Some(columns); let cloned = Target { anchor: Cursor { row: anchor_row, column: column_at_display( - &self.lines[anchor_row], + &self.active.lines[anchor_row], columns.0, self.max_column(anchor_row), ), @@ -1592,7 +1878,7 @@ impl Editor { active: Cursor { row: active_row, column: column_at_display( - &self.lines[active_row], + &self.active.lines[active_row], columns.1, self.max_column(active_row), ), @@ -1600,20 +1886,20 @@ impl Editor { preferred_columns: Some(columns), }; if index == primary { - self.primary = self.targets.len(); + self.active.primary = self.active.targets.len(); } - self.targets.push(cloned); + self.active.targets.push(cloned); } self.normalize_targets(); } fn select_occurrences(&mut self, action: Action) { - let text = self.lines.join("\n"); - let offsets = line_offsets(&self.lines); + let text = self.active.lines.join("\n"); + let offsets = line_offsets(&self.active.lines); let ranges = self.selections(); - let primary_range = ranges[self.primary]; - let start = cursor_offset(&self.lines, &offsets, primary_range.start); - let end = cursor_offset(&self.lines, &offsets, primary_range.end); + let primary_range = ranges[self.active.primary]; + let start = cursor_offset(&self.active.lines, &offsets, primary_range.start); + let end = cursor_offset(&self.active.lines, &offsets, primary_range.end); if start == end { self.set_message("Nothing selected"); return; @@ -1623,8 +1909,8 @@ impl Editor { .iter() .map(|range| { ( - cursor_offset(&self.lines, &offsets, range.start), - cursor_offset(&self.lines, &offsets, range.end), + cursor_offset(&self.active.lines, &offsets, range.start), + cursor_offset(&self.active.lines, &offsets, range.end), ) }) .collect(); @@ -1649,10 +1935,10 @@ impl Editor { continue; } occupied.push((start, end)); - self.targets.push(self.range_target( + self.active.targets.push(self.range_target( Selection { - start: offset_cursor(&self.lines, &offsets, start, true), - end: offset_cursor(&self.lines, &offsets, end, true), + start: offset_cursor(&self.active.lines, &offsets, start, true), + end: offset_cursor(&self.active.lines, &offsets, end, true), }, true, )); @@ -1674,16 +1960,16 @@ impl Editor { }; let target = self.range_target( Selection { - start: offset_cursor(&self.lines, &offsets, start, true), - end: offset_cursor(&self.lines, &offsets, end, true), + start: offset_cursor(&self.active.lines, &offsets, start, true), + end: offset_cursor(&self.active.lines, &offsets, end, true), }, true, ); if matches!(action, Action::SkipOccurrence) { - self.targets[self.primary] = target; + self.active.targets[self.active.primary] = target; } else { - self.primary = self.targets.len(); - self.targets.push(target); + self.active.primary = self.active.targets.len(); + self.active.targets.push(target); } } self.normalize_targets(); @@ -1695,13 +1981,13 @@ impl Editor { let cut = matches!(action, Action::Cut | Action::Change); let changing = matches!(action, Action::Change); let before = cut.then(|| self.snapshot()); - let text = self.lines.join("\n"); - let offsets = line_offsets(&self.lines); + let text = self.active.lines.join("\n"); + let offsets = line_offsets(&self.active.lines); let fragments: Vec<_> = ranges .iter() .map(|range| { - let start = cursor_offset(&self.lines, &offsets, range.start); - let end = cursor_offset(&self.lines, &offsets, range.end); + let start = cursor_offset(&self.active.lines, &offsets, range.start); + let end = cursor_offset(&self.active.lines, &offsets, range.end); text[start..end].to_owned() }) .collect(); @@ -1714,26 +2000,26 @@ impl Editor { .enumerate() .map(|(target, range)| Edit { target, - start: cursor_offset(&self.lines, &offsets, range.start), - end: cursor_offset(&self.lines, &offsets, range.end), + start: cursor_offset(&self.active.lines, &offsets, range.start), + end: cursor_offset(&self.active.lines, &offsets, range.end), text: Cow::Borrowed(""), caret: 0, round_up: true, }) .collect(); - self.mode = Mode::Insert; + self.active.mode = Mode::Insert; if cut { self.apply_edits(edits); } else { - for (target, range) in self.targets.iter_mut().zip(&ranges) { + for (target, range) in self.active.targets.iter_mut().zip(&ranges) { target.active = range.start; } } if changing { - self.insert_start = before; + self.active.insert_start = before; self.message.clear(); } else { - self.mode = Mode::Normal; + self.active.mode = Mode::Normal; self.normalize_targets(); if let Some(before) = before { self.record(before); @@ -1753,14 +2039,15 @@ impl Editor { self.set_message("Clipboard is empty"); return; } - if self.clipboard.len() != 1 && self.clipboard.len() != self.targets.len() { + if self.clipboard.len() != 1 && self.clipboard.len() != self.active.targets.len() { self.set_message("Clipboard fragment count does not match caret count"); return; } let before = self.snapshot(); let clipboard = std::mem::take(&mut self.clipboard); - let offsets = line_offsets(&self.lines); + let offsets = line_offsets(&self.active.lines); let edits = self + .active .targets .iter() .enumerate() @@ -1768,9 +2055,9 @@ impl Editor { let mut cursor = item.active; if after { cursor.column = - (cursor.column + 1).min(grapheme_count(&self.lines[cursor.row])); + (cursor.column + 1).min(grapheme_count(&self.active.lines[cursor.row])); } - let start = cursor_offset(&self.lines, &offsets, cursor); + let start = cursor_offset(&self.active.lines, &offsets, cursor); let text = &clipboard[if clipboard.len() == 1 { 0 } else { target }]; Edit { target, @@ -1782,25 +2069,25 @@ impl Editor { } }) .collect(); - self.mode = Mode::Insert; + self.active.mode = Mode::Insert; self.apply_edits(edits); self.clipboard = clipboard; - for target in &mut self.targets { + for target in &mut self.active.targets { target.active.column = target.active.column.saturating_sub(1); } - self.mode = Mode::Normal; + self.active.mode = Mode::Normal; self.normalize_targets(); self.record(before); self.message.clear(); } fn begin_insert(&mut self) { - self.insert_start = Some(self.snapshot()); - self.mode = Mode::Insert; + self.active.insert_start = Some(self.snapshot()); + self.active.mode = Mode::Insert; } fn finish_insert(&mut self) { - if let Some(before) = self.insert_start.take() { + if let Some(before) = self.active.insert_start.take() { self.record(before); } } @@ -1822,21 +2109,21 @@ impl Editor { if let Some(before) = before { self.record(before); } - self.insert_start = Some(self.snapshot()); + self.active.insert_start = Some(self.snapshot()); } fn restore(&mut self, snapshot: Snapshot) { - self.lines = snapshot.lines; - self.targets = snapshot.targets; - self.primary = snapshot.primary; - self.mode = Mode::Normal; - self.search_match = None; + self.active.lines = snapshot.lines; + self.active.targets = snapshot.targets; + self.active.primary = snapshot.primary; + self.active.mode = Mode::Normal; + self.active.search_match = None; self.normalize_targets(); } fn undo(&mut self) { - if let Some(before) = self.undo.pop() { - self.redo.push(self.snapshot()); + if let Some(before) = self.active.undo.pop() { + self.active.redo.push(self.snapshot()); self.restore(before); } else { self.set_message("Nothing to undo"); @@ -1844,8 +2131,8 @@ impl Editor { } fn redo(&mut self) { - if let Some(after) = self.redo.pop() { - self.undo.push(self.snapshot()); + if let Some(after) = self.active.redo.pop() { + self.active.undo.push(self.snapshot()); self.restore(after); } else { self.set_message("Nothing to redo"); @@ -1853,8 +2140,8 @@ impl Editor { } fn move_targets(&mut self, action: Action, count: usize) { - for index in 0..self.targets.len() { - let mut cursor = self.targets[index].active; + for index in 0..self.active.targets.len() { + let mut cursor = self.active.targets[index].active; match action { Action::Left => cursor.column = cursor.column.saturating_sub(count), Action::Right => { @@ -1865,7 +2152,10 @@ impl Editor { } Action::Down | Action::Up => { let row = if matches!(action, Action::Down) { - cursor.row.saturating_add(count).min(self.lines.len() - 1) + cursor + .row + .saturating_add(count) + .min(self.active.lines.len() - 1) } else { cursor.row.saturating_sub(count) }; @@ -1882,21 +2172,21 @@ impl Editor { Action::LineEnd => cursor.column = self.max_column(cursor.row), _ => cursor = self.word_destination(cursor, action, count), } - self.targets[index].active = cursor; - self.targets[index].preferred_columns = None; + self.active.targets[index].active = cursor; + self.active.targets[index].preferred_columns = None; } self.normalize_targets(); } fn word_destination(&self, cursor: Cursor, action: Action, count: usize) -> Cursor { let row = cursor.row; - let byte = grapheme_byte(&self.lines[row], cursor.column); + let byte = grapheme_byte(&self.active.lines[row], cursor.column); let big = matches!( action, Action::BigWordNext | Action::BigWordPrevious | Action::BigWordEnd ); let destination = match action { Action::WordPrevious | Action::BigWordPrevious => { - let mut tokens = word_tokens(&self.lines[..=row], big) + let mut tokens = word_tokens(&self.active.lines[..=row], big) .rev() .skip_while(|(r, b, _)| (*r, *b) >= (row, byte)) .peekable(); @@ -1917,15 +2207,15 @@ impl Editor { destination } _ => { - let mut tokens = word_tokens(&self.lines[row..], big) + let mut tokens = word_tokens(&self.active.lines[row..], big) .skip_while(|(r, b, _)| (*r, *b) < (0, byte)) .peekable(); let mut current = tokens .next() .expect("cursor has a grapheme or line-end token") .2; - let last = self.lines.len() - 1; - let mut destination = (last, self.lines[last].len()); + let last = self.active.lines.len() - 1; + let mut destination = (last, self.active.lines[last].len()); for _ in 0..count { let next_start = matches!(action, Action::WordNext | Action::BigWordNext); if next_start && current != WordKind::EmptyLine { @@ -1937,7 +2227,7 @@ impl Editor { *kind != WordKind::Whitespace && (next_start || *kind != WordKind::EmptyLine) }) else { - destination = (last, self.lines[last].len()); + destination = (last, self.active.lines[last].len()); break; }; current = first.2; @@ -1951,11 +2241,11 @@ impl Editor { destination } }; - let line = &self.lines[destination.0]; + let line = &self.active.lines[destination.0]; let column = column_after_byte(line, destination.1); Cursor { row: destination.0, - column: if destination.1 == line.len() && self.mode != Mode::Insert { + column: if destination.1 == line.len() && self.active.mode != Mode::Insert { column.saturating_sub(1) } else { column @@ -1983,8 +2273,8 @@ impl Editor { } unique.push(index); } - let old = self.lines.join("\n"); - let offsets = line_offsets(&self.lines); + let old = self.active.lines.join("\n"); + let offsets = line_offsets(&self.active.lines); let removed: usize = unique .iter() .map(|index| edits[*index].end - edits[*index].start) @@ -2017,11 +2307,15 @@ impl Editor { position - removed + added }; let mut destinations: Vec<_> = self + .active .targets .iter() .map(|target| { ( - remap(cursor_offset(&self.lines, &offsets, target.active), true), + remap( + cursor_offset(&self.active.lines, &offsets, target.active), + true, + ), true, ) }) @@ -2030,12 +2324,12 @@ impl Editor { destinations[edit.target] = (remap(edit.start, false) + edit.caret, edit.round_up); } if text != old { - self.search_match = None; + self.active.search_match = None; } - self.lines = text.split('\n').map(str::to_owned).collect(); - let offsets = line_offsets(&self.lines); - for (target, (byte, round_up)) in self.targets.iter_mut().zip(destinations) { - target.active = offset_cursor(&self.lines, &offsets, byte, round_up); + self.active.lines = text.split('\n').map(str::to_owned).collect(); + let offsets = line_offsets(&self.active.lines); + for (target, (byte, round_up)) in self.active.targets.iter_mut().zip(destinations) { + target.active = offset_cursor(&self.active.lines, &offsets, byte, round_up); target.anchor = target.active; target.preferred_columns = None; } @@ -2046,13 +2340,14 @@ impl Editor { if text.is_empty() { return; } - let offsets = line_offsets(&self.lines); + let offsets = line_offsets(&self.active.lines); let edits = self + .active .targets .iter() .enumerate() .map(|(target, item)| { - let start = cursor_offset(&self.lines, &offsets, item.active); + let start = cursor_offset(&self.active.lines, &offsets, item.active); Edit { target, start, @@ -2067,15 +2362,16 @@ impl Editor { } fn open_lines(&mut self, below: bool) { - let offsets = line_offsets(&self.lines); + let offsets = line_offsets(&self.active.lines); let edits = self + .active .targets .iter() .enumerate() .map(|(target, item)| { let start = offsets[item.active.row] + if below { - self.lines[item.active.row].len() + self.active.lines[item.active.row].len() } else { 0 }; @@ -2093,17 +2389,20 @@ impl Editor { } fn delete_forward(&mut self) { - let offsets = line_offsets(&self.lines); + let offsets = line_offsets(&self.active.lines); let edits = self + .active .targets .iter() .enumerate() .filter_map(|(target, item)| { let cursor = item.active; - let start = cursor_offset(&self.lines, &offsets, cursor); - let end = if cursor.column < grapheme_count(&self.lines[cursor.row]) { - cursor_offset(&self.lines, &offsets, self.next_boundary(cursor)) - } else if self.mode == Mode::Insert && cursor.row + 1 < self.lines.len() { + let start = cursor_offset(&self.active.lines, &offsets, cursor); + let end = if cursor.column < grapheme_count(&self.active.lines[cursor.row]) { + cursor_offset(&self.active.lines, &offsets, self.next_boundary(cursor)) + } else if self.active.mode == Mode::Insert + && cursor.row + 1 < self.active.lines.len() + { start + 1 } else { return None; @@ -2123,15 +2422,16 @@ impl Editor { fn join_lines(&mut self) { let before = self.snapshot(); - let offsets = line_offsets(&self.lines); + let offsets = line_offsets(&self.active.lines); let edits = self + .active .targets .iter() .enumerate() .filter_map(|(target, item)| { let row = item.active.row; - let line = &self.lines[row]; - let next = self.lines.get(row + 1)?; + let line = &self.active.lines[row]; + let next = self.active.lines.get(row + 1)?; let trimmed = next.trim_start_matches([' ', '\t']); let separator = if !line.is_empty() && !trimmed.is_empty() @@ -2162,8 +2462,9 @@ impl Editor { } fn backspace(&mut self) { - let offsets = line_offsets(&self.lines); + let offsets = line_offsets(&self.active.lines); let edits = self + .active .targets .iter() .enumerate() @@ -2172,8 +2473,9 @@ impl Editor { if cursor.column == 0 && cursor.row == 0 { return None; } - let start = cursor_offset(&self.lines, &offsets, self.previous_boundary(cursor)); - let end = cursor_offset(&self.lines, &offsets, cursor); + let start = + cursor_offset(&self.active.lines, &offsets, self.previous_boundary(cursor)); + let end = cursor_offset(&self.active.lines, &offsets, cursor); Some(Edit { target, start, @@ -2363,6 +2665,42 @@ fn column_after_byte(line: &str, byte: usize) -> usize { .count() } +fn path_identity(path: &Path) -> io::Result { + let absolute = if path.is_absolute() { + path.to_owned() + } else { + std::env::current_dir()?.join(path) + }; + // Resolve the existing prefix too, so new files under aliased directories deduplicate. + for ancestor in absolute.ancestors() { + match fs::canonicalize(ancestor) { + Ok(prefix) => { + let mut identity = prefix; + for component in absolute + .strip_prefix(ancestor) + .expect("path ancestor") + .components() + { + match component { + Component::ParentDir => { + identity.pop(); + } + Component::CurDir => {} + component => identity.push(component.as_os_str()), + } + } + return Ok(identity); + } + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) => return Err(error), + } + } + Err(io::Error::new( + io::ErrorKind::NotFound, + "No existing path ancestor", + )) +} + fn target_metadata(path: &Path) -> io::Result> { match fs::symlink_metadata(path) { Ok(metadata) if metadata.file_type().is_symlink() => Err(io::Error::new( @@ -2467,7 +2805,7 @@ mod tests { )]) .unwrap(); set_carets(&mut editor, &[(0, 3), (4, 1)], 1); - let undo = editor.undo.len(); + let undo = editor.active.undo.len(); type_text(&mut editor, "2j"); assert_eq!( editor.carets(), @@ -2485,7 +2823,7 @@ mod tests { editor.handle(Input::Down); assert_eq!(editor.cursor(), Cursor { row: 6, column: 0 }); assert_eq!(editor.count(), None); - assert_eq!(editor.undo.len(), undo); + assert_eq!(editor.active.undo.len(), undo); assert_eq!( editor.lines(), &["e\u{301}界👩‍🚀z", "", "long", "four", "aa", "bbbb", "cccc"] @@ -2641,25 +2979,437 @@ mod tests { } fn set_carets(editor: &mut Editor, positions: &[(usize, usize)], primary: usize) { - editor.mode = Mode::Normal; - editor.targets = positions + editor.active.mode = Mode::Normal; + editor.active.targets = positions .iter() .map(|&(row, column)| Target::caret(Cursor { row, column })) .collect(); - editor.primary = primary; + editor.active.primary = primary; editor.normalize_targets(); } fn set_ranges(editor: &mut Editor, ranges: &[(Cursor, Cursor)], primary: usize) { - editor.mode = Mode::Visual; - editor.targets = ranges + editor.active.mode = Mode::Visual; + editor.active.targets = ranges .iter() .map(|&(start, end)| editor.range_target(Selection { start, end }, true)) .collect(); - editor.primary = primary; + editor.active.primary = primary; editor.normalize_targets(); } + #[test] + fn buffers_keep_independent_history_saved_text_carets_primary_and_search() { + let directory = Directory::new(); + let a = directory.path("a"); + let b = directory.path("b"); + fs::write(&a, "cat cat\ncat cat").unwrap(); + fs::write(&b, "dog dog\ndog dog").unwrap(); + let mut editor = Editor::open(Some(a.clone()), vec![]).unwrap(); + set_carets(&mut editor, &[(0, 0), (1, 0)], 1); + editor.handle(Input::Char('i')); + type_text(&mut editor, "X"); + editor.handle(Input::Escape); + submit_search(&mut editor, "cat"); + let a_carets = editor.carets(); + let a_primary = editor.cursor(); + let a_match = editor.search_match(); + editor.execute("w"); + assert!(!editor.dirty()); + + editor.execute(&format!("edit {}", b.display())); + assert_eq!(editor.buffer_id(), 2); + set_carets(&mut editor, &[(0, 0), (1, 0)], 0); + submit_search(&mut editor, "dog"); + let b_carets = editor.carets(); + let b_primary = editor.cursor(); + editor.handle(Input::Char('i')); + type_text(&mut editor, "Y"); + editor.handle(Input::Escape); + editor.handle(Input::Char('u')); + assert_eq!(editor.context().text, "dog dog\ndog dog"); + assert_eq!(editor.carets(), b_carets); + assert_eq!(editor.cursor(), b_primary); + assert!(!editor.dirty()); + + editor.execute("buffer 1"); + assert_eq!(editor.context().text, "Xcat cat\nXcat cat"); + assert_eq!(editor.carets(), a_carets); + assert_eq!(editor.cursor(), a_primary); + assert_eq!(editor.search_match(), a_match); + editor.handle(Input::Char('n')); + assert_eq!(editor.cursor(), Cursor { row: 1, column: 5 }); + editor.handle(Input::Char('u')); + assert_eq!(editor.context().text, "cat cat\ncat cat"); + assert!(editor.dirty()); + editor.handle(Input::Char('U')); + assert_eq!(editor.context().text, "Xcat cat\nXcat cat"); + assert!(!editor.dirty()); + editor.execute("buffer 2"); + editor.handle(Input::Char('n')); + assert_eq!(editor.cursor(), Cursor { row: 1, column: 0 }); + editor.handle(Input::Char('U')); + assert_eq!(editor.context().text, "dog Ydog\ndog Ydog"); + assert!(editor.dirty()); + editor.execute("w"); + assert!(!editor.dirty()); + assert_eq!(fs::read_to_string(&a).unwrap(), "Xcat cat\nXcat cat"); + assert_eq!(fs::read_to_string(&b).unwrap(), "dog Ydog\ndog Ydog"); + editor.execute("buffer 1"); + assert!(!editor.dirty()); + } + + #[test] + fn explicit_switch_preserves_directed_visual_ranges_and_bound_switch_restores_them() { + let directory = Directory::new(); + let a = directory.path("a"); + let b = directory.path("b"); + fs::write(&a, "abcdef\nabcdef").unwrap(); + fs::write(&b, "other").unwrap(); + let mut editor = Editor::open(Some(a), vec![]).unwrap(); + set_carets(&mut editor, &[(0, 4), (1, 4)], 1); + type_text(&mut editor, "vhh"); + let carets = editor.carets(); + let primary = editor.cursor(); + let ranges = editor.selections(); + editor.execute(&format!("edit {}", b.display())); + assert_eq!(editor.mode(), Mode::Normal); + editor.bind("normal", "g", Some("bprev".into())).unwrap(); + editor.handle(Input::Char('g')); + assert_eq!(editor.buffer_id(), 1); + assert_eq!(editor.mode(), Mode::Visual); + assert_eq!(editor.carets(), carets); + assert_eq!(editor.cursor(), primary); + assert_eq!(editor.selections(), ranges); + editor.execute("bnext"); + editor.execute("buffer 1"); + assert_eq!(editor.mode(), Mode::Visual); + editor.handle(Input::Char('h')); + assert_eq!(editor.cursor(), Cursor { row: 1, column: 1 }); + assert_eq!(editor.selections()[1].end.column, 5); + } + + #[test] + fn switching_commits_insert_groups_and_never_restores_transient_prompts() { + let directory = Directory::new(); + let a = directory.path("a"); + let b = directory.path("b"); + fs::write(&a, "base").unwrap(); + fs::write(&b, "other").unwrap(); + let mut editor = Editor::open(Some(a), vec![]).unwrap(); + editor.handle(Input::Char('i')); + type_text(&mut editor, "XY"); + editor.execute(&format!("edit {}", b.display())); + editor.execute("buffer 1"); + assert_eq!(editor.mode(), Mode::Normal); + editor.handle(Input::Char('u')); + assert_eq!(editor.context().text, "base"); + editor.handle(Input::Char('U')); + assert_eq!(editor.context().text, "XYbase"); + for prompt in [':', '/'] { + editor.handle(Input::Char(prompt)); + type_text(&mut editor, "unfinished"); + editor.execute("buffer 2"); + assert_eq!(editor.command_line(), ""); + editor.execute("buffer 1"); + assert_eq!(editor.mode(), Mode::Normal); + assert_eq!(editor.command_line(), ""); + } + } + + #[test] + fn failed_opens_and_invalid_buffer_arguments_leave_editing_state_untouched() { + let directory = Directory::new(); + let bad = directory.path("bad"); + fs::write(&bad, b"\xff").unwrap(); + let controls = directory.path("controls"); + fs::write(&controls, "bad\0").unwrap(); + let mut editor = editor(); + editor + .apply(vec![Effect::Replace("abcdef".into())]) + .unwrap(); + type_text(&mut editor, "llvhh"); + let ranges = editor.selections(); + let carets = editor.carets(); + let cursor = editor.cursor(); + let undo = editor.active.undo.len(); + let commands = [ + format!("edit {}", bad.display()), + format!("edit {}", controls.display()), + format!("edit {}", directory.0.display()), + "edit".into(), + "buffer".into(), + "buffer 0".into(), + "buffer -1".into(), + "buffer 99".into(), + "buffer 1 2".into(), + "bnext 1".into(), + "bprev 1".into(), + "buffers 1".into(), + "bdelete nope".into(), + "bdelete! 99".into(), + "bdelete 999999999999999999999999999999".into(), + ]; + for command in commands { + editor.execute(&command); + assert_eq!(editor.buffer_count(), 1); + assert_eq!(editor.buffer_id(), 1); + assert_eq!(editor.context().text, "abcdef"); + assert_eq!(editor.mode(), Mode::Visual); + assert_eq!(editor.selections(), ranges); + assert_eq!(editor.carets(), carets); + assert_eq!(editor.cursor(), cursor); + assert_eq!(editor.active.undo.len(), undo); + } + editor.handle(Input::Escape); + editor.handle(Input::Char('i')); + type_text(&mut editor, "X"); + editor.execute(&format!("edit {}", bad.display())); + assert_eq!(editor.mode(), Mode::Insert); + type_text(&mut editor, "Y"); + editor.handle(Input::Escape); + editor.handle(Input::Char('u')); + assert_eq!(editor.context().text, "abcdef"); + } + + #[test] + fn stable_buffer_ids_wrap_close_inactive_and_replace_the_last_buffer() { + let directory = Directory::new(); + let mut editor = editor(); + let a = directory.path("new a"); + let b = directory.path("new b"); + editor.execute(&format!("edit {}", a.display())); + editor.execute(&format!("edit {}", b.display())); + assert_eq!( + editor.buffers().map(|buffer| buffer.id).collect::>(), + vec![1, 2, 3], + ); + let info = editor.buffers().find(|buffer| buffer.id == 3).unwrap(); + assert_eq!(info.path, Some(b.as_path())); + assert!(info.active); + assert!(!info.dirty); + editor.execute("bdelete 2"); + assert_eq!(editor.buffer_id(), 3); + assert!(!editor.has_buffer(2)); + assert_eq!(editor.buffer_count(), 2); + editor.execute("bnext"); + assert_eq!(editor.buffer_id(), 1); + editor.execute("bprev"); + assert_eq!(editor.buffer_id(), 3); + editor.execute("bdelete"); + assert_eq!(editor.buffer_id(), 1); + editor.execute("bdelete"); + assert_eq!(editor.buffer_id(), 4); + assert_eq!(editor.buffer_count(), 1); + assert_eq!(editor.path(), None); + assert_eq!(editor.context().text, ""); + assert!(!editor.dirty()); + assert!(!editor.should_quit()); + editor.execute("bnext"); + editor.execute("bprev"); + assert_eq!(editor.buffer_id(), 4); + editor.execute(&format!("edit {}", a.display())); + assert_eq!(editor.buffer_id(), 5); + assert_eq!(editor.buffer_count(), 2); + editor.execute("buffer 2"); + assert_eq!(editor.buffer_id(), 5); + } + + #[test] + fn hidden_dirty_buffers_block_quit_write_quit_and_unforced_deletion() { + let directory = Directory::new(); + let a = directory.path("a"); + let b = directory.path("b"); + fs::write(&a, "a").unwrap(); + fs::write(&b, "b").unwrap(); + let mut editor = Editor::open(Some(a), vec![]).unwrap(); + editor + .apply(vec![Effect::Replace("dirty a".into())]) + .unwrap(); + editor.execute(&format!("edit {}", b.display())); + assert!(!editor.dirty()); + editor.execute("q"); + assert!(!editor.should_quit()); + editor + .apply(vec![Effect::Replace("saved b".into())]) + .unwrap(); + editor.execute("wq"); + assert!(!editor.should_quit()); + assert!(!editor.dirty()); + assert_eq!(fs::read_to_string(&b).unwrap(), "saved b"); + editor.execute("bdelete 1"); + assert!(editor.has_buffer(1)); + assert_eq!(editor.buffer_id(), 2); + editor.execute("buffer 1"); + assert_eq!(editor.context().text, "dirty a"); + editor.execute("bdelete"); + assert_eq!(editor.buffer_id(), 1); + editor.execute("bdelete!"); + assert_eq!(editor.buffer_id(), 2); + editor.execute("q"); + assert!(editor.should_quit()); + + let mut forced = Editor::open(Some(b), vec![]).unwrap(); + forced.apply(vec![Effect::Replace("dirty".into())]).unwrap(); + forced.execute(&format!("edit {}", directory.path("new").display())); + forced.execute("q! arguments"); + assert!(!forced.should_quit()); + forced.execute("bdelete! 1"); + assert_eq!(forced.buffer_id(), 2); + assert!(!forced.has_buffer(1)); + forced + .apply(vec![Effect::Replace("also dirty".into())]) + .unwrap(); + forced.execute("bdelete!"); + assert_eq!(forced.buffer_id(), 3); + assert!(!forced.dirty()); + forced + .apply(vec![Effect::Replace("quit dirty".into())]) + .unwrap(); + forced.execute(&format!("edit {}", directory.path("another").display())); + forced.execute("q!"); + assert!(forced.should_quit()); + } + + #[test] + fn loaded_paths_refuse_save_collisions_before_touching_files_or_saved_state() { + let directory = Directory::new(); + let a = directory.path("a"); + let b = directory.path("b"); + fs::write(&a, "a").unwrap(); + fs::write(&b, "b").unwrap(); + let mut editor = Editor::open(Some(a.clone()), vec![]).unwrap(); + editor.execute(&format!("edit {}", b.display())); + editor + .apply(vec![Effect::Replace("dirty b".into())]) + .unwrap(); + editor.execute(&format!("w {}", directory.0.join("./a").display())); + assert!(editor.message().contains("belongs to buffer 1")); + assert_eq!(editor.path(), Some(b.as_path())); + assert!(editor.dirty()); + assert_eq!(fs::read_to_string(&a).unwrap(), "a"); + assert_eq!(fs::read_to_string(&b).unwrap(), "b"); + assert_eq!(fs::read_dir(&directory.0).unwrap().count(), 2); + editor.execute(&format!("wq {}", a.display())); + assert!(!editor.should_quit()); + assert!(editor.dirty()); + editor.execute(&format!("w {}", directory.0.join("./b").display())); + assert!(!editor.dirty()); + assert_eq!(fs::read_to_string(&b).unwrap(), "dirty b"); + let new = directory.path("missing"); + editor.execute(&format!("edit {}", new.display())); + editor.execute("buffer 2"); + editor.execute(&format!("w {}", new.display())); + assert!(editor.message().contains("belongs to buffer 3")); + assert!(!new.exists()); + assert!(!editor.dirty()); + } + + #[test] + fn clipboard_bindings_plugins_and_palette_catalog_are_session_wide() { + let directory = Directory::new(); + let mut editor = editor(); + editor.register(plugin("fixture", &["greet"])).unwrap(); + editor.bind("normal", "g", Some("greet".into())).unwrap(); + editor.bind("normal", "z", Some("bnext".into())).unwrap(); + editor + .apply(vec![Effect::Replace("copied".into())]) + .unwrap(); + type_text(&mut editor, "v$y"); + editor.execute(&format!("edit {}", directory.path("new").display())); + editor.handle(Input::Char('p')); + assert_eq!(editor.context().text, "copied"); + assert_eq!(editor.handle(Input::Char('g')).unwrap().plugin, "fixture"); + editor.handle(Input::Char('z')); + assert_eq!(editor.buffer_id(), 1); + assert_eq!(editor.handle(Input::Char('g')).unwrap().command, "greet"); + editor.handle(Input::Ctrl('p')); + for command in [ + "edit", "buffers", "buffer", "bnext", "bprev", "bdelete", "bdelete!", + ] { + let entry = palette_entry(&editor, command); + assert_eq!(entry.owner, "core"); + assert!(entry.enabled); + } + assert_eq!(palette_entry(&editor, "bnext").bindings, vec!["z"]); + editor.disable_plugin("fixture", "failure"); + assert!( + !editor + .palette() + .unwrap() + .entries + .iter() + .any(|entry| entry.name == "greet") + ); + editor.handle(Input::Escape); + editor.execute("buffer 2"); + assert_eq!(editor.handle(Input::Char('g')), None); + editor.execute("plugins"); + assert!(!editor.message().contains("fixture")); + assert!(editor.message().contains("bdelete!")); + } + + #[cfg(unix)] + #[test] + fn canonical_aliases_reuse_dirty_buffers_and_new_paths_are_normalized() { + use std::os::unix::fs::symlink; + let directory = Directory::new(); + let path = directory.path("file"); + let link = directory.path("file link"); + fs::write(&path, "disk").unwrap(); + symlink(&path, &link).unwrap(); + let mut editor = Editor::open(Some(link.clone()), vec![]).unwrap(); + editor + .apply(vec![Effect::Replace("dirty memory".into())]) + .unwrap(); + editor.execute(&format!("edit {}", directory.path("other").display())); + fs::write(&path, "changed externally").unwrap(); + editor.execute(&format!("edit {}", path.display())); + assert_eq!(editor.buffer_id(), 1); + assert_eq!(editor.buffer_count(), 2); + assert_eq!(editor.path(), Some(link.as_path())); + assert_eq!(editor.context().text, "dirty memory"); + assert!(editor.dirty()); + editor.execute("w"); + assert!(editor.message().contains("symbolic link")); + assert!(editor.dirty()); + assert_eq!(fs::read_to_string(&path).unwrap(), "changed externally"); + editor.execute(&format!("w {}", path.display())); + assert!(!editor.dirty()); + assert_eq!(editor.path(), Some(path.as_path())); + + let alias = directory.path("directory link"); + symlink(&directory.0, &alias).unwrap(); + let new = alias.join("new"); + editor.execute(&format!("edit {}", new.display())); + assert_eq!(editor.buffer_id(), 3); + editor + .apply(vec![Effect::Replace("new memory".into())]) + .unwrap(); + editor.execute("buffer 1"); + editor.execute(&format!( + "edit {}", + directory.0.join("absent/../new").display() + )); + assert_eq!(editor.buffer_id(), 3); + assert_eq!(editor.buffer_count(), 3); + assert_eq!(editor.context().text, "new memory"); + assert_eq!(editor.path(), Some(new.as_path())); + editor.execute(&format!("w {}", link.display())); + assert!(editor.message().contains("belongs to buffer 1")); + assert!(editor.dirty()); + assert_eq!(fs::read_to_string(&path).unwrap(), "dirty memory"); + assert!(!new.exists()); + editor.execute("buffers"); + assert!(editor.message().contains("1 ")); + assert!(editor.message().contains("3%+ ")); + let control_path = directory.path("line\nbreak"); + editor.execute(&format!("edit {}", control_path.display())); + editor.execute("buffers"); + assert!(!editor.message().chars().any(char::is_control)); + assert!(editor.message().contains("line break")); + } + fn palette_entry<'a>(editor: &'a Editor, name: &str) -> &'a PaletteEntry { editor .palette() @@ -2710,17 +3460,17 @@ mod tests { } let snapshot = |state: &Snapshot| (state.lines.clone(), state.targets.clone(), state.primary); - let lines = editor.lines.clone(); - let targets = editor.targets.clone(); - let primary = editor.primary; + let lines = editor.active.lines.clone(); + let targets = editor.active.targets.clone(); + let primary = editor.active.primary; let selections = editor.selections(); let prompt = editor.command_line.clone(); - let search = editor.search_query.clone(); - let found = editor.search_match; + let search = editor.active.search_query.clone(); + let found = editor.active.search_match; let message = editor.message.clone(); - let undo: Vec<_> = editor.undo.iter().map(&snapshot).collect(); - let redo: Vec<_> = editor.redo.iter().map(&snapshot).collect(); - let insert = editor.insert_start.as_ref().map(snapshot); + let undo: Vec<_> = editor.active.undo.iter().map(&snapshot).collect(); + let redo: Vec<_> = editor.active.redo.iter().map(&snapshot).collect(); + let insert = editor.active.insert_start.as_ref().map(snapshot); editor.handle(Input::Ctrl('p')); assert!(editor.palette().is_some()); @@ -2735,17 +3485,23 @@ mod tests { assert!(editor.palette().is_none()); assert_eq!(editor.mode(), mode); - assert_eq!(editor.lines, lines); - assert_eq!(editor.targets, targets); - assert_eq!(editor.primary, primary); + assert_eq!(editor.active.lines, lines); + assert_eq!(editor.active.targets, targets); + assert_eq!(editor.active.primary, primary); assert_eq!(editor.selections(), selections); assert_eq!(editor.command_line, prompt); - assert_eq!(editor.search_query, search); - assert_eq!(editor.search_match, found); + assert_eq!(editor.active.search_query, search); + assert_eq!(editor.active.search_match, found); assert_eq!(editor.message, message); - assert_eq!(editor.undo.iter().map(&snapshot).collect::>(), undo); - assert_eq!(editor.redo.iter().map(&snapshot).collect::>(), redo); - assert_eq!(editor.insert_start.as_ref().map(snapshot), insert); + assert_eq!( + editor.active.undo.iter().map(&snapshot).collect::>(), + undo + ); + assert_eq!( + editor.active.redo.iter().map(&snapshot).collect::>(), + redo + ); + assert_eq!(editor.active.insert_start.as_ref().map(snapshot), insert); if mode == Mode::Insert { type_text(&mut editor, "Y"); editor.handle(Input::Escape); @@ -2835,7 +3591,7 @@ mod tests { } for mode in [Mode::Insert, Mode::Command, Mode::Search, Mode::Visual] { let mut editor = editor(); - editor.mode = mode; + editor.active.mode = mode; editor.command_line = "untouched".into(); editor.handle(Input::Ctrl('p')); type_text(&mut editor, "core.undo"); @@ -3027,7 +3783,7 @@ mod tests { type_text(&mut editor, "core.submit"); editor.handle(Input::Enter); assert_eq!(editor.mode(), Mode::Normal); - assert_eq!(editor.search_query, "beta"); + assert_eq!(editor.active.search_query, "beta"); assert_eq!(editor.cursor(), Cursor { row: 0, column: 6 }); } @@ -3039,10 +3795,10 @@ mod tests { .unwrap(); editor.handle(Input::Char('v')); editor.handle(Input::Right); - let targets = editor.targets.clone(); + let targets = editor.active.targets.clone(); editor.handle(Input::Ctrl('p')); type_text(&mut editor, "core.change"); - let undo = editor.undo.len(); + let undo = editor.active.undo.len(); assert!( editor .apply(vec![ @@ -3053,11 +3809,11 @@ mod tests { ); assert_eq!(editor.palette().unwrap().query, "core.change"); assert_eq!(editor.mode(), Mode::Visual); - assert_eq!(editor.targets, targets); - assert_eq!(editor.undo.len(), undo); + assert_eq!(editor.active.targets, targets); + assert_eq!(editor.active.undo.len(), undo); editor.apply(vec![Effect::Message("ok".into())]).unwrap(); assert_eq!(editor.palette().unwrap().query, "core.change"); - assert_eq!(editor.targets, targets); + assert_eq!(editor.active.targets, targets); editor.execute("plugins"); assert!(editor.palette().is_none()); assert_eq!(editor.mode(), Mode::Normal); @@ -3312,8 +4068,8 @@ mod tests { editor .apply(vec![Effect::Replace("a\u{301}👩‍🚀界z".into())]) .unwrap(); - editor.mode = Mode::Visual; - editor.targets = vec![ + editor.active.mode = Mode::Visual; + editor.active.targets = vec![ editor.range_target( Selection { start: Cursor { row: 0, column: 0 }, @@ -3336,7 +4092,7 @@ mod tests { true, ), ]; - editor.primary = 1; + editor.active.primary = 1; editor.normalize_targets(); assert_eq!(editor.selection_count(), 2); assert_eq!(editor.selections()[0].end.column, 3); @@ -3373,13 +4129,16 @@ mod tests { editor.handle(Input::Char('u')); editor.handle(Input::Escape); let before = editor.snapshot(); - let undo = editor.undo.len(); - let redo = editor.redo.len(); + let undo = editor.active.undo.len(); + let redo = editor.active.redo.len(); editor.handle(Input::Char('p')); - assert_eq!(editor.lines, before.lines); - assert_eq!(editor.targets, before.targets); - assert_eq!(editor.primary, before.primary); - assert_eq!((editor.undo.len(), editor.redo.len()), (undo, redo)); + assert_eq!(editor.active.lines, before.lines); + assert_eq!(editor.active.targets, before.targets); + assert_eq!(editor.active.primary, before.primary); + assert_eq!( + (editor.active.undo.len(), editor.active.redo.len()), + (undo, redo) + ); assert!(editor.message().contains("fragment count")); editor.clipboard = vec!["!".into()]; set_carets(&mut editor, &[(0, 0), (1, 0)], 0); @@ -4098,22 +4857,6 @@ mod tests { assert!(editor.should_quit()); } - #[test] - fn plugins_command_lists_active_features_and_capabilities_deterministically() { - let mut editor = editor(); - editor.register(plugin("zebra", &["last"])).unwrap(); - editor.register(plugin("alpha", &["first"])).unwrap(); - editor.execute("plugins"); - assert_eq!( - editor.message(), - "Capabilities: text | alpha: first | core [core]: w, q, q!, wq, plugins | zebra: last" - ); - editor.disable_plugin("alpha", "fault"); - editor.execute("plugins"); - assert!(!editor.message().contains("alpha")); - assert!(editor.message().contains("core [core]")); - } - #[test] fn readable_key_aliases_share_bindings_and_invalid_chords_leave_them_intact() { let mut editor = editor(); diff --git a/src/main.rs b/src/main.rs index 6ccf5c6..b1bcf59 100644 --- a/src/main.rs +++ b/src/main.rs @@ -22,6 +22,8 @@ Visual: motions extend selections, y copy, d/x/Delete cut, c change Insert: Esc returns to Normal retaining carets Search: Enter finds next literal match, Esc cancels (case-sensitive, wraps) Commands: :w [PATH], :q, :q!, :wq [PATH], :plugins, :PLUGIN [ARGS] +Buffers: :edit PATH, :buffers, :buffer N, :bnext, :bprev, :bdelete[!] [N] + :q refuses any dirty buffer; :wq saves active and quits only if all are clean Ruby config: $XDG_CONFIG_HOME/vamp/config.rb or ~/.config/vamp/config.rb Set VAMP_RUBY to choose the Ruby executable."; diff --git a/src/session.rs b/src/session.rs index 5d7561a..c349543 100644 --- a/src/session.rs +++ b/src/session.rs @@ -155,6 +155,67 @@ Vamp.command("healthy") { |editor, args| editor.replace "healthy #{args}" } } } + #[test] + fn buffers_share_config_and_plugin_host_but_keep_history_and_protect_hidden_edits() { + let fixture = Fixture::new(); + let first = fixture.file("buffer.txt", "alpha"); + let second = fixture.file("second.txt", "beta"); + fixture.file( + "shared.rb", + r##" +Vamp.plugin "shared", requires: ["text"] +calls = 0 +Vamp.command("stamp") do |editor, _| + calls += 1 + editor.insert "#{calls}:#{File.basename(editor.path)}:" +end +Vamp.command("fail") { |_, _| raise "shared host failure" } +"##, + ); + let mut session = fixture.open( + r#" +Vamp.configure do |config| + config.plugin "shared.rb" + config.bind "g", "stamp" +end +"#, + ); + session.handle(Input::Char('g')); + assert_eq!(session.editor().lines(), &["1:buffer.txt:alpha"]); + session.execute(&format!("edit {}", second.display())); + assert_eq!(session.editor().buffer_id(), 2); + session.handle(Input::Char('g')); + assert_eq!(session.editor().lines(), &["2:second.txt:beta"]); + session.execute("bdelete"); + assert_eq!(session.editor().buffer_id(), 2); + assert_eq!(session.editor().buffer_count(), 2); + session.handle(Input::Char('u')); + assert_eq!(session.editor().lines(), &["beta"]); + session.execute("q"); + assert!(!session.editor().should_quit()); + session.execute("wq"); + assert!(!session.editor().should_quit()); + assert_eq!(fs::read_to_string(&first).unwrap(), "alpha"); + assert_eq!(fs::read_to_string(&second).unwrap(), "beta"); + session.execute("buffer 1"); + assert_eq!(session.editor().lines(), &["1:buffer.txt:alpha"]); + session.handle(Input::Char('u')); + assert_eq!(session.editor().lines(), &["alpha"]); + session.handle(Input::Char('g')); + assert_eq!(session.editor().lines(), &["3:buffer.txt:alpha"]); + session.execute("w"); + assert_eq!(fs::read_to_string(&first).unwrap(), "3:buffer.txt:alpha"); + session.execute("bnext"); + assert_eq!(session.editor().lines(), &["beta"]); + session.execute("fail"); + assert!(session.editor().message().contains("disabled")); + session.execute("buffer 1"); + session.handle(Input::Char('g')); + assert_eq!(session.editor().lines(), &["3:buffer.txt:alpha"]); + session.execute("q"); + assert!(session.editor().should_quit()); + } + #[test] fn typed_palette_binding_prepares_arguments_and_retires_failed_plugin_commands() { let fixture = Fixture::new(); diff --git a/src/tui.rs b/src/tui.rs index 2dd9b7f..4c1d098 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -1,4 +1,6 @@ use std::{ + collections::BTreeMap, + fmt::Write as _, io::{self, Stdout}, panic::{self, AssertUnwindSafe, PanicHookInfo}, sync::{Arc, Mutex}, @@ -109,15 +111,28 @@ impl Drop for TerminalSession { } } +#[derive(Default)] +struct BufferViewports { + offsets: BTreeMap, +} + +impl BufferViewports { + fn draw(&mut self, frame: &mut Frame, editor: &Editor) { + self.offsets.retain(|id, _| editor.has_buffer(*id)); + let (top, left) = self.offsets.entry(editor.buffer_id()).or_default(); + draw(frame, editor, top, left); + } +} + /// Renders session state and translates terminal input; the session owns plugin /// execution and retirement, so the adapter needs no lifecycle callback. pub fn run(session: &mut EditorSession) -> io::Result<()> { TerminalSession::run(|| { let mut terminal: Terminal> = Terminal::new(CrosstermBackend::new(io::stdout()))?; - let (mut top, mut left) = (0, 0); + let mut viewports = BufferViewports::default(); while !session.editor().should_quit() { - terminal.draw(|frame| draw(frame, session.editor(), &mut top, &mut left))?; + terminal.draw(|frame| viewports.draw(frame, session.editor()))?; match event::read()? { Event::Key(key) if key.kind != KeyEventKind::Release => { if let Some(input) = translate(key) { @@ -325,24 +340,31 @@ fn draw(frame: &mut Frame, editor: &Editor, top: &mut usize, left: &mut usize) { .chars() .map(|c| if c.is_control() { '�' } else { c }) .collect(); - let status = format!( - " {} {}{} {}:{}{}{}", - editor.mode(), + let mut status = format!(" {}", editor.mode()); + if editor.buffer_count() > 1 { + write!( + status, + " [buffer {}/{}]", + editor.buffer_id(), + editor.buffer_count() + ) + .unwrap(); + } + write!( + status, + " {}{} {}:{}", path, if editor.dirty() { " [+]" } else { "" }, cursor.row + 1, cursor.column + 1, - if editor.selection_count() > 1 { - format!(" {} selections", editor.selection_count()) - } else { - String::new() - }, - if let Some(count) = editor.count() { - format!(" count: {count}") - } else { - String::new() - } - ); + ) + .unwrap(); + if editor.selection_count() > 1 { + write!(status, " {} selections", editor.selection_count()).unwrap(); + } + if let Some(count) = editor.count() { + write!(status, " count: {count}").unwrap(); + } frame.render_widget( Paragraph::new(status).style(Style::default().fg(Color::Black).bg(Color::Cyan)), status_area, @@ -479,6 +501,36 @@ fn draw_palette(frame: &mut Frame, palette: &Palette, mode: Mode) { #[cfg(test)] mod tests { use super::*; + use std::{ + fs, + path::PathBuf, + sync::atomic::{AtomicUsize, Ordering}, + }; + + struct Directory(PathBuf); + + impl Directory { + fn new() -> Self { + static NEXT_ID: AtomicUsize = AtomicUsize::new(0); + let id = NEXT_ID.fetch_add(1, Ordering::Relaxed); + let path = + std::env::temp_dir().join(format!("vamp-tui-test-{}-{id}", std::process::id())); + fs::create_dir(&path).unwrap(); + Self(path) + } + + fn file(&self, name: &str, text: &str) -> PathBuf { + let path = self.0.join(name); + fs::write(&path, text).unwrap(); + path + } + } + + impl Drop for Directory { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } + } fn screen_text(terminal: &Terminal) -> String { let buffer = terminal.backend().buffer(); @@ -493,6 +545,231 @@ mod tests { .join("\n") } + #[test] + fn buffer_switches_restore_independent_vertical_and_horizontal_viewports() { + let directory = Directory::new(); + let a = directory.file("a", &vec!["a".repeat(50); 20].join("\n")); + let b = directory.file("b", &vec!["b".repeat(40); 20].join("\n")); + let mut editor = Editor::open(Some(a), vec![]).unwrap(); + let a_id = editor.buffer_id(); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(16, 6)).unwrap(); + let mut viewports = BufferViewports::default(); + for _ in 0..14 { + editor.handle(Input::Down); + } + editor.handle(Input::End); + terminal + .draw(|frame| viewports.draw(frame, &editor)) + .unwrap(); + assert_eq!(viewports.offsets[&a_id], (11, 37)); + for _ in 0..2 { + editor.handle(Input::Up); + } + for _ in 0..3 { + editor.handle(Input::Left); + } + terminal + .draw(|frame| viewports.draw(frame, &editor)) + .unwrap(); + let a_viewport = viewports.offsets[&a_id]; + let a_cursor = terminal.get_cursor_position().unwrap(); + assert_eq!(a_viewport, (11, 37)); + + editor.execute(&format!("edit {}", b.display())); + let b_id = editor.buffer_id(); + assert_ne!(a_id, b_id); + terminal + .draw(|frame| viewports.draw(frame, &editor)) + .unwrap(); + assert_eq!(viewports.offsets[&b_id], (0, 0)); + for _ in 0..8 { + editor.handle(Input::Down); + } + editor.handle(Input::End); + terminal + .draw(|frame| viewports.draw(frame, &editor)) + .unwrap(); + editor.handle(Input::Up); + editor.handle(Input::Left); + editor.handle(Input::Left); + terminal + .draw(|frame| viewports.draw(frame, &editor)) + .unwrap(); + let b_viewport = viewports.offsets[&b_id]; + let b_cursor = terminal.get_cursor_position().unwrap(); + assert_eq!(b_viewport, (5, 27)); + + editor.execute(&format!("buffer {a_id}")); + terminal + .draw(|frame| viewports.draw(frame, &editor)) + .unwrap(); + assert_eq!(viewports.offsets[&a_id], a_viewport); + assert_eq!(terminal.get_cursor_position().unwrap(), a_cursor); + assert_eq!( + terminal.backend().buffer()[(a_cursor.x, a_cursor.y)].symbol(), + "a" + ); + editor.execute("bnext"); + terminal + .draw(|frame| viewports.draw(frame, &editor)) + .unwrap(); + assert_eq!(editor.buffer_id(), b_id); + assert_eq!(viewports.offsets[&b_id], b_viewport); + assert_eq!(terminal.get_cursor_position().unwrap(), b_cursor); + } + + #[test] + fn closing_inactive_buffer_retires_its_viewport_without_moving_active_buffer() { + let directory = Directory::new(); + let a = directory.file("a", &vec!["a".repeat(50); 20].join("\n")); + let b = directory.file("b", &vec!["b".repeat(40); 20].join("\n")); + let c = directory.file("c", "new buffer"); + let mut editor = Editor::open(Some(a), vec![]).unwrap(); + let a_id = editor.buffer_id(); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(16, 6)).unwrap(); + let mut viewports = BufferViewports::default(); + for _ in 0..14 { + editor.handle(Input::Down); + } + editor.handle(Input::End); + terminal + .draw(|frame| viewports.draw(frame, &editor)) + .unwrap(); + editor.execute(&format!("edit {}", b.display())); + let b_id = editor.buffer_id(); + for _ in 0..8 { + editor.handle(Input::Down); + } + editor.handle(Input::End); + terminal + .draw(|frame| viewports.draw(frame, &editor)) + .unwrap(); + let b_viewport = viewports.offsets[&b_id]; + let b_cursor = terminal.get_cursor_position().unwrap(); + assert_eq!(viewports.offsets.len(), 2); + + editor.execute(&format!("bdelete {a_id}")); + terminal + .draw(|frame| viewports.draw(frame, &editor)) + .unwrap(); + assert_eq!(editor.buffer_id(), b_id); + assert!(!editor.has_buffer(a_id)); + assert!(!viewports.offsets.contains_key(&a_id)); + assert_eq!(viewports.offsets.len(), 1); + assert_eq!(viewports.offsets[&b_id], b_viewport); + assert_eq!(terminal.get_cursor_position().unwrap(), b_cursor); + editor.execute(&format!("edit {}", c.display())); + let c_id = editor.buffer_id(); + assert!(c_id > b_id); + terminal + .draw(|frame| viewports.draw(frame, &editor)) + .unwrap(); + assert_eq!(viewports.offsets[&c_id], (0, 0)); + assert!(!viewports.offsets.contains_key(&a_id)); + } + + #[test] + fn restoring_buffer_after_narrow_resize_clamps_caret_inside_text_region() { + let directory = Directory::new(); + let a = directory.file("a", &vec!["a".repeat(50); 20].join("\n")); + let b = directory.file("b", "other"); + let mut editor = Editor::open(Some(a), vec![]).unwrap(); + let a_id = editor.buffer_id(); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(24, 8)).unwrap(); + let mut viewports = BufferViewports::default(); + for _ in 0..14 { + editor.handle(Input::Down); + } + editor.handle(Input::End); + terminal + .draw(|frame| viewports.draw(frame, &editor)) + .unwrap(); + assert_eq!(viewports.offsets[&a_id], (9, 29)); + editor.execute(&format!("edit {}", b.display())); + let b_id = editor.buffer_id(); + terminal + .draw(|frame| viewports.draw(frame, &editor)) + .unwrap(); + terminal.backend_mut().resize(4, 4); + terminal + .resize(ratatui::layout::Rect::new(0, 0, 4, 4)) + .unwrap(); + editor.execute(&format!("buffer {a_id}")); + terminal + .draw(|frame| viewports.draw(frame, &editor)) + .unwrap(); + assert_eq!(viewports.offsets[&a_id], (13, 49)); + assert_eq!(viewports.offsets[&b_id], (0, 0)); + assert_eq!( + terminal.get_cursor_position().unwrap(), + ratatui::layout::Position::new(3, 1) + ); + + for (width, height) in [(1, 1), (24, 8)] { + let area = ratatui::layout::Rect::new(0, 0, width, height); + terminal.backend_mut().resize(width, height); + terminal.resize(area).unwrap(); + terminal + .draw(|frame| viewports.draw(frame, &editor)) + .unwrap(); + let cursor = terminal.get_cursor_position().unwrap(); + assert!(area.contains(cursor), "{area:?}: {cursor:?}"); + assert_eq!( + terminal.backend().buffer()[(cursor.x, cursor.y)].symbol(), + "a" + ); + assert_eq!(viewports.offsets[&b_id], (0, 0)); + } + } + + #[test] + fn status_identifies_active_buffer_and_restored_visual_selection_count() { + let directory = Directory::new(); + let a = directory.file("a", "word\nword\nword"); + let b = directory.file("b", "other"); + let mut editor = Editor::open(Some(a), vec![]).unwrap(); + let a_id = editor.buffer_id(); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(240, 6)).unwrap(); + let mut viewports = BufferViewports::default(); + editor.handle(Input::Char('v')); + editor.handle(Input::Ctrl('a')); + assert_eq!(editor.selection_count(), 3); + terminal + .draw(|frame| viewports.draw(frame, &editor)) + .unwrap(); + assert!(!screen_text(&terminal).contains("[buffer")); + editor.execute(&format!("edit {}", b.display())); + let b_id = editor.buffer_id(); + terminal + .draw(|frame| viewports.draw(frame, &editor)) + .unwrap(); + let text = screen_text(&terminal); + assert!( + text.contains(&format!("NORMAL [buffer {b_id}/2]")), + "{text}" + ); + assert!(!text.contains("3 selections"), "{text}"); + editor.execute(&format!("buffer {a_id}")); + terminal + .draw(|frame| viewports.draw(frame, &editor)) + .unwrap(); + let text = screen_text(&terminal); + assert!( + text.contains(&format!("VISUAL [buffer {a_id}/2]")), + "{text}" + ); + assert!(text.contains("3 selections"), "{text}"); + let (top, left) = viewports.offsets[&a_id]; + for selection in editor.selections() { + let x = 2 + (selection.start.column - left) as u16; + let y = (selection.start.row - top) as u16; + assert!(matches!( + terminal.backend().buffer()[(x, y)].bg, + Color::Blue | Color::Magenta + )); + } + } + #[test] fn pending_motion_count_is_visible_and_escape_cancels_without_moving() { let mut editor = Editor::open(None, vec![]).unwrap(); diff --git a/test-file-2.abc b/test-file-2.abc new file mode 100644 index 0000000..b6fc4c6 --- /dev/null +++ b/test-file-2.abc @@ -0,0 +1 @@ +hello \ No newline at end of file