use crate::chrome::{ChromeLine, PopupConfig, validate_name}; use crate::clipboard::{ClipboardMode, SystemClipboard}; use crate::diff::{DiffState, Focus, Operation, Outcome, Phase, RevisionTarget, ViewMode}; use crate::files::{DockSide, FilePicker, FilePresentation, FileView}; use crate::panes::{PaneDirection, PaneRect, PaneTree, SplitAxis, directional_neighbor}; use crate::plugin::{ CompletionItem, Diagnostic, SemanticToken, SnippetTabstop, TextPosition, Utf16Range, }; use serde::Serialize; use std::borrow::Cow; use std::cell::Cell; use std::collections::{BTreeMap, HashMap, HashSet, VecDeque}; use std::fmt; use std::fs::{self, OpenOptions}; use std::io::{self, Read, Write}; use std::num::NonZeroU8; use std::ops::Range; #[cfg(unix)] use std::os::unix::fs::{MetadataExt, OpenOptionsExt}; use std::path::{Component, Path, PathBuf}; use std::sync::Arc; use std::sync::atomic::{AtomicU64, Ordering}; use unicode_segmentation::{GraphemeCursor, UnicodeSegmentation}; use unicode_width::UnicodeWidthStr; const MAX_EDIT_BYTES: usize = 4 * 1024 * 1024; static TEMP_ID: AtomicU64 = AtomicU64::new(0); #[derive(Clone, Copy, Debug, Default, Eq, PartialEq, serde::Deserialize)] #[serde(rename_all = "snake_case")] pub enum WhitespaceScope { #[default] All, Leading, } #[derive(Clone, Debug, Eq, Hash, PartialEq)] pub enum Input { Char(char), Ctrl(char), Alt(char), CtrlAlt(char), Paste(String), Escape, Enter, Backspace, AltBackspace, SuperBackspace, Delete, BackTab, Left, Right, Up, Down, Home, End, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(crate) enum BackspaceKind { Grapheme, Word, Line, } impl BackspaceKind { pub(crate) fn from_input(input: &Input) -> Option { match input { Input::Backspace => Some(Self::Grapheme), Input::AltBackspace => Some(Self::Word), Input::SuperBackspace => Some(Self::Line), _ => None, } } fn start(self, prefix: &str) -> usize { match self { Self::Grapheme => prefix .grapheme_indices(true) .next_back() .map_or(0, |(byte, _)| byte), Self::Word => prefix .split_word_bound_indices() .rev() .find(|(_, word)| !word.chars().all(char::is_whitespace)) .map_or(0, |(byte, _)| byte), Self::Line => prefix.rfind('\n').map_or(0, |byte| byte + 1), } } pub(crate) fn truncate(self, text: &mut String) -> bool { let start = self.start(text); let changed = start != text.len(); text.truncate(start); changed } } #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] pub enum Mode { Normal, Insert, Command, Search, Visual, } impl fmt::Display for Mode { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str(match self { Self::Normal => "NORMAL", Self::Insert => "INSERT", Self::Command => "COMMAND", Self::Search => "SEARCH", Self::Visual => "VISUAL", }) } } pub struct Palette { pub query: String, /// Buffer results use ascending IDs; command results sort by label then name. pub entries: Vec, pub selected: usize, pub buffers: bool, } pub struct StartupDiagnostic { pub lines: Vec, pub top: usize, pub left: usize, max_column: usize, } pub struct Hover { text: String, line_starts: Vec, /// First visible logical line. pub top: usize, /// First visible extended grapheme, independent of terminal geometry. pub left: usize, max_column: usize, buffer_id: usize, revision: u64, caret: Cursor, } /// A live preview; zero selects the immutable original document. pub struct Completion { pub items: Vec, pub selected: usize, } struct CompletionState { popup: Completion, original: Snapshot, } struct SnippetState { tabstops: Vec, active: usize, selected: bool, selections: Vec, replacement: Range, recipe_step: Option, } #[cfg_attr(test, derive(Debug))] struct RelativeCompletionEdit { start: isize, end: isize, text: String, // Extra edits are replayed only against the same surrounding logical lines. context: Option>, } impl Hover { pub fn text(&self) -> &str { &self.text } pub fn lines(&self) -> impl ExactSizeIterator { self.line_starts.iter().enumerate().map(|(index, &start)| { let end = self .line_starts .get(index + 1) .map_or(self.text.len(), |next| next - 1); &self.text[start..end] }) } } pub struct PaletteEntry { pub name: String, pub label: String, pub owner: String, /// Effective bindings in the underlying editor mode. pub bindings: Vec, pub enabled: bool, pub modes: Vec, pub buffer: Option, } pub struct PaletteBuffer { pub id: usize, pub active: bool, pub dirty: bool, } #[derive(Clone, Copy, Debug, Default, Eq, Ord, PartialEq, PartialOrd)] pub struct Cursor { pub row: usize, /// Zero-based extended grapheme index, not a byte or display-cell offset. pub column: usize, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct SearchMatch { pub start: Cursor, /// Exclusive end, in grapheme columns on the same row as `start`. pub end_column: usize, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct Selection { /// Inclusive start of the ordered range, in grapheme coordinates. pub start: Cursor, /// Exclusive end in grapheme coordinates; may be the next row's column 0. pub end: Cursor, } #[derive(Clone, Debug, Eq, PartialEq)] pub struct Feature { pub name: String, pub required_capabilities: Vec, pub commands: Vec, pub core: bool, } #[derive(Clone, Debug, Eq, PartialEq)] pub enum Effect { Insert(String), Replace(String), Message(String), Label { name: String, text: String }, } #[derive(Clone, Debug, Eq, PartialEq)] pub struct Invocation { pub plugin: String, pub command: String, pub args: String, } #[derive(Debug, Serialize)] pub struct Context { pub buffer_id: usize, pub revision: u64, pub root: String, pub text: String, /// One-based row, including the empty row after a trailing newline. pub row: usize, /// One-based extended grapheme column. pub column: usize, pub mode: String, pub path: Option, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] struct Target { anchor: Cursor, active: Cursor, // Display columns survive a clone onto a shorter line. preferred_columns: Option<(usize, usize)>, } impl Target { fn caret(cursor: Cursor) -> Self { Self { anchor: cursor, active: cursor, preferred_columns: None, } } } #[cfg_attr(test, derive(Debug))] struct Snapshot { lines: Vec, targets: Vec, primary: usize, } struct Edit<'a> { target: Option, start: usize, end: usize, text: Cow<'a, str>, caret: usize, round_up: bool, } // Recipes contain resolved native intent, never input keys or source-buffer offsets. #[cfg_attr(test, derive(Debug))] struct EditRecipe { start: EditStart, steps: Vec, } #[cfg_attr(test, derive(Debug))] enum EditStart { Insert(Action), Selection { change: bool, selector: SelectionIntent, }, Lines { change: bool, count: usize, }, Normal { action: Action, count: usize, }, Replace { target: char, count: usize, }, ReplaceSelection { target: char, selector: SelectionIntent, }, Delete, Join, Paste { after: bool, fragments: Vec, linewise: bool, }, } #[cfg_attr(test, derive(Debug))] enum SelectionIntent { Motions { motions: Vec, linewise: bool, }, // Whole graphemes, with a newline counting as one boundary. Spans(Vec), Rows(Vec), } #[cfg_attr(test, derive(Debug))] #[derive(Clone, Copy)] enum Motion { Action(Action, usize), Seek { forward: bool, target: char, count: usize, }, } #[cfg_attr(test, derive(Debug))] enum InsertStep { Typed(String), Bulk(String), Move(Action, usize), Delete(Vec), Backspace(Vec, BackspaceKind), Completion { primary_edit: usize, edits: Vec, caret: usize, }, } #[derive(Serialize)] struct CoreAction { name: &'static str, ex: Option<&'static str>, #[serde(skip)] action: Action, #[serde(skip)] modes: &'static [Mode], #[serde(skip)] prompt: bool, #[serde(skip)] palette_prompt: bool, } macro_rules! core_actions { ($(($name:literal, Action::$action:ident, $modes:expr $(, $command:tt $(, $prompt:literal $(, $palette_prompt:literal)?)?)? $(,)?)),* $(,)?) => { #[cfg_attr(test, derive(Debug))] #[derive(Clone, Copy, Eq, PartialEq)] enum Action { $($action),* } // Generated together: each discriminant indexes its own catalog entry. const CORE_ACTIONS: &[CoreAction] = &[$(CoreAction { name: $name, action: Action::$action, modes: $modes, ex: core_actions!(@ex $name $(, $command)?), prompt: core_actions!(@prompt $($($prompt)?)?), palette_prompt: core_actions!(@prompt $($($($palette_prompt)?)?)?), }),*]; impl Action { const fn entry(self) -> &'static CoreAction { &CORE_ACTIONS[self as usize] } } }; (@ex $name:literal, true) => { Some($name.split_at("core.".len()).1) }; (@ex $name:literal) => { None }; (@prompt $prompt:literal) => { $prompt }; (@prompt) => { false }; } // One catalog owns typed actions, derived canonical Ex commands, and palette eligibility. core_actions![ ( "core.pane.split_horizontal", Action::PaneSplitHorizontal, &[Mode::Normal], true ), ( "core.pane.split_vertical", Action::PaneSplitVertical, &[Mode::Normal], true ), ( "core.pane.focus_left", Action::PaneFocusLeft, &[Mode::Normal], true ), ( "core.pane.focus_down", Action::PaneFocusDown, &[Mode::Normal], true ), ( "core.pane.focus_up", Action::PaneFocusUp, &[Mode::Normal], true ), ( "core.pane.focus_right", Action::PaneFocusRight, &[Mode::Normal], true ), ("core.pane.close", Action::PaneClose, &[Mode::Normal], true), ( "core.pane.force_close", Action::PaneForceClose, &[Mode::Normal], true, false, true ), ("core.tab.new", Action::TabNew, &[Mode::Normal], true), ( "core.tab.previous", Action::TabPrevious, &[Mode::Normal], true ), ("core.tab.next", Action::TabNext, &[Mode::Normal], true), ("core.tab.close", Action::TabClose, &[Mode::Normal], true), ( "core.tab.force_close", Action::TabForceClose, &[Mode::Normal], true, false, true ), ("core.insert", Action::Insert, &[Mode::Normal]), ( "core.insert_first_nonblank", Action::InsertFirstNonblank, &[Mode::Normal, Mode::Visual], ), ("core.append", Action::Append, &[Mode::Normal]), ( "core.append_line_end", Action::AppendLineEnd, &[Mode::Normal, Mode::Visual], ), ("core.open_below", Action::OpenBelow, &[Mode::Normal]), ("core.open_above", Action::OpenAbove, &[Mode::Normal]), ("core.join_lines", Action::JoinLines, &[Mode::Normal]), ("core.undo", Action::Undo, &[Mode::Normal]), ("core.redo", Action::Redo, &[Mode::Normal]), ( "core.repeat_last_edit", Action::RepeatLastEdit, &[Mode::Normal, Mode::Visual], ), ("core.command", Action::Command, &[Mode::Normal]), ("core.search", Action::Search, &[Mode::Normal]), ( "core.search_next", Action::SearchNext, &[Mode::Normal, Mode::Visual], ), ( "core.search_previous", Action::SearchPrevious, &[Mode::Normal, Mode::Visual], ), ("core.select", Action::Select, &[Mode::Normal, Mode::Visual]), ( "core.select_linewise", Action::SelectLinewise, &[Mode::Normal, Mode::Visual], ), ("core.select_lines", Action::SelectLines, &[Mode::Visual]), ( "core.clone_above", Action::CloneAbove, &[Mode::Normal, Mode::Visual], ), ( "core.clone_below", Action::CloneBelow, &[Mode::Normal, Mode::Visual], ), ("core.select_next", Action::SelectNext, &[Mode::Visual]), ( "core.skip_occurrence", Action::SkipOccurrence, &[Mode::Visual], ), ("core.select_all", Action::SelectAll, &[Mode::Visual]), ("core.copy", Action::Copy, &[Mode::Visual]), ("core.copy_system", Action::CopySystem, &[Mode::Visual]), ("core.cut", Action::Cut, &[Mode::Visual]), ("core.change", Action::Change, &[Mode::Visual]), ("core.delete_line", Action::DeleteLine, &[Mode::Normal]), ("core.change_line", Action::ChangeLine, &[Mode::Normal]), ("core.copy_line", Action::CopyLine, &[Mode::Normal]), ( "core.change_to_line_end", Action::ChangeToLineEnd, &[Mode::Normal] ), ( "core.delete_to_line_end", Action::DeleteToLineEnd, &[Mode::Normal] ), ( "core.copy_to_line_end", Action::CopyToLineEnd, &[Mode::Normal] ), ( "core.replace_character", Action::ReplaceCharacter, &[Mode::Normal, Mode::Visual] ), ( "core.substitute_character", Action::SubstituteCharacter, &[Mode::Normal] ), ( "core.delete_word_forward", Action::DeleteWordForward, &[Mode::Normal] ), ( "core.delete_word_backward", Action::DeleteWordBackward, &[Mode::Normal] ), ( "core.change_word_forward", Action::ChangeWordForward, &[Mode::Normal] ), ( "core.change_word_backward", Action::ChangeWordBackward, &[Mode::Normal] ), ("core.paste_before", Action::PasteBefore, &[Mode::Normal]), ("core.paste_after", Action::PasteAfter, &[Mode::Normal]), ( "core.paste_system_before", Action::PasteSystemBefore, &[Mode::Normal], ), ( "core.paste_system_after", Action::PasteSystemAfter, &[Mode::Normal], ), ( "core.left", Action::Left, &[Mode::Normal, Mode::Insert, Mode::Visual], ), ( "core.right", Action::Right, &[Mode::Normal, Mode::Insert, Mode::Visual], ), ( "core.down", Action::Down, &[Mode::Normal, Mode::Insert, Mode::Visual], ), ( "core.up", Action::Up, &[Mode::Normal, Mode::Insert, Mode::Visual], ), ( "core.line_start", Action::LineStart, &[Mode::Normal, Mode::Insert, Mode::Visual], ), ( "core.line_end", Action::LineEnd, &[Mode::Normal, Mode::Insert, Mode::Visual], ), ( "core.document_start", Action::DocumentStart, &[Mode::Normal, Mode::Visual], ), ( "core.document_end", Action::DocumentEnd, &[Mode::Normal, Mode::Visual], ), ( "core.go_to_line", Action::GoToLine, &[Mode::Normal, Mode::Visual] ), ( "core.seek_forward", Action::SeekForward, &[Mode::Normal, Mode::Visual], ), ( "core.seek_backward", Action::SeekBackward, &[Mode::Normal, Mode::Visual], ), ( "core.word_next", Action::WordNext, &[Mode::Normal, Mode::Insert, Mode::Visual], ), ( "core.word_previous", Action::WordPrevious, &[Mode::Normal, Mode::Insert, Mode::Visual], ), ( "core.word_end", Action::WordEnd, &[Mode::Normal, Mode::Insert, Mode::Visual], ), ( "core.big_word_next", Action::BigWordNext, &[Mode::Normal, Mode::Insert, Mode::Visual], ), ( "core.big_word_previous", Action::BigWordPrevious, &[Mode::Normal, Mode::Insert, Mode::Visual], ), ( "core.big_word_end", Action::BigWordEnd, &[Mode::Normal, Mode::Insert, Mode::Visual], ), ("core.delete", Action::Delete, &[Mode::Normal, Mode::Insert]), ( "core.normal", Action::Normal, &[ Mode::Normal, Mode::Insert, Mode::Command, Mode::Search, Mode::Visual, ], ), ("core.newline", Action::Newline, &[Mode::Insert]), ( "core.backspace", Action::Backspace, &[Mode::Insert, Mode::Command, Mode::Search], ), ( "core.backspace_word", Action::BackspaceWord, &[Mode::Insert, Mode::Command, Mode::Search], ), ( "core.backspace_line", Action::BackspaceLine, &[Mode::Insert, Mode::Command, Mode::Search], ), ( "core.submit", Action::Submit, &[Mode::Command, Mode::Search], ), ( "core.palette", Action::Palette, &[ Mode::Normal, Mode::Insert, Mode::Command, Mode::Search, Mode::Visual, ], ), ( "core.file_picker", Action::FilePicker, &[Mode::Normal, Mode::Visual], ), ( "core.file_explorer", Action::FileExplorer, &[Mode::Normal, Mode::Visual], ), ( "core.follow_link", Action::FollowLink, &[Mode::Normal], true ), ("core.jump_back", Action::JumpBack, &[Mode::Normal]), ("core.jump_forward", Action::JumpForward, &[Mode::Normal]), ( "core.lsp.definition", Action::LspDefinition, &[Mode::Normal], true, ), ("core.lsp.hover", Action::LspHover, &[Mode::Normal], true), ( "core.lsp.completion", Action::LspCompletion, &[Mode::Insert], ), ("core.snippet.next", Action::SnippetNext, &[Mode::Insert]), ( "core.snippet.previous", Action::SnippetPrevious, &[Mode::Insert], ), ( "core.lsp.diagnostics", Action::LspDiagnostics, &[Mode::Normal], true, ), ("core.save", Action::Save, &[Mode::Normal], true), ( "core.force_save", Action::ForceSave, &[Mode::Normal], true, false, true ), ("core.diff", Action::Diff, &[Mode::Normal], true), ( "core.diff.mode.toggle", Action::DiffModeToggle, &[Mode::Normal], true, ), ( "core.diff.view.toggle", Action::DiffViewToggle, &[Mode::Normal], true, ), ( "core.diff.orientation.toggle", Action::DiffOrientationToggle, &[Mode::Normal], true, ), ( "core.diff.review.toggle", Action::DiffReviewToggle, &[Mode::Normal], true, ), ( "core.word_wrap.toggle", Action::WordWrapToggle, &[ Mode::Normal, Mode::Insert, Mode::Visual, Mode::Command, Mode::Search ], true, ), ( "core.git_blame.toggle", Action::GitBlameToggle, &[ Mode::Normal, Mode::Insert, Mode::Visual, Mode::Command, Mode::Search ], true, ), ( "core.diff.close", Action::DiffClose, &[ Mode::Normal, Mode::Insert, Mode::Visual, Mode::Command, Mode::Search ], true, ), ("core.quit", Action::Quit, &[Mode::Normal], true), ( "core.force_quit", Action::ForceQuit, &[Mode::Normal], true, false, true ), ("core.restart", Action::Restart, &[Mode::Normal], true), ( "core.force_restart", Action::ForceRestart, &[Mode::Normal], true, false, true ), ( "core.save_and_quit", Action::SaveAndQuit, &[Mode::Normal], true ), ( "core.force_save_and_quit", Action::ForceSaveAndQuit, &[Mode::Normal], true, false, true ), ("core.plugins", Action::Plugins, &[Mode::Normal], true), ("core.edit", Action::Edit, &[Mode::Normal], true, true), ("core.buffers", Action::Buffers, &[Mode::Normal], true), ("core.buffer", Action::Buffer, &[Mode::Normal], true, true), ( "core.buffer_next", Action::BufferNext, &[Mode::Normal], true ), ( "core.buffer_previous", Action::BufferPrevious, &[Mode::Normal], true ), ( "core.buffer_delete", Action::BufferDelete, &[Mode::Normal], true ), ( "core.buffer_force_delete", Action::BufferForceDelete, &[Mode::Normal], true, false, true ), ]; // Alias targets stay typed; canonical spelling comes from the action catalog. const COMMAND_ALIASES: &[(&str, Action)] = &[ ("w", Action::Save), ("w!", Action::ForceSave), ("q", Action::Quit), ("q!", Action::ForceQuit), ("restart!", Action::ForceRestart), ("wq", Action::SaveAndQuit), ("wq!", Action::ForceSaveAndQuit), ("bnext", Action::BufferNext), ("bprev", Action::BufferPrevious), ("bdelete", Action::BufferDelete), ("bdelete!", Action::BufferForceDelete), ]; fn core_command(name: &str) -> Option<&'static CoreAction> { COMMAND_ALIASES .iter() .find(|(alias, _)| *alias == name) .map(|(_, action)| action.entry()) .or_else(|| CORE_ACTIONS.iter().find(|entry| entry.ex == Some(name))) } pub(crate) struct CoreCommandAliases; impl Serialize for CoreCommandAliases { fn serialize(&self, serializer: S) -> Result { serializer.collect_map(COMMAND_ALIASES.iter().map(|(alias, action)| { ( alias, action.entry().ex.expect("aliases target command actions"), ) })) } } /// Serializes authoritative typed names and their derived canonical Ex commands. pub(crate) struct CoreActionCatalog; impl Serialize for CoreActionCatalog { fn serialize(&self, serializer: S) -> Result { serializer.collect_seq(CORE_ACTIONS) } } impl Action { fn mutates(self, mode: Mode) -> bool { matches!( self, Self::Insert | Self::InsertFirstNonblank | Self::Append | Self::AppendLineEnd | Self::OpenBelow | Self::OpenAbove | Self::JoinLines | Self::Undo | Self::Redo | Self::RepeatLastEdit | Self::Delete | Self::DeleteLine | Self::ChangeLine | Self::ChangeToLineEnd | Self::DeleteToLineEnd | Self::ReplaceCharacter | Self::SubstituteCharacter | Self::DeleteWordForward | Self::DeleteWordBackward | Self::ChangeWordForward | Self::ChangeWordBackward | Self::Cut | Self::Change | Self::PasteBefore | Self::PasteAfter | Self::PasteSystemBefore | Self::PasteSystemAfter | Self::Newline | Self::Save | Self::ForceSave | Self::SaveAndQuit | Self::ForceSaveAndQuit | Self::BufferDelete | Self::BufferForceDelete | Self::LspCompletion ) || (self.backspace_kind().is_some() && mode == Mode::Insert) || (self == Self::SnippetNext && mode == Mode::Insert) } fn backspace_kind(self) -> Option { match self { Self::Backspace => Some(BackspaceKind::Grapheme), Self::BackspaceWord => Some(BackspaceKind::Word), Self::BackspaceLine => Some(BackspaceKind::Line), _ => None, } } fn named(mode: Mode, name: &str) -> Option { CORE_ACTIONS .iter() .find(|entry| entry.name == name && entry.modes.contains(&mode)) .map(|entry| entry.action) } } enum Binding { Action(Action), Command(String), Disabled, } #[derive(Default, Eq, Hash, PartialEq)] struct BindingSequence { // None keeps leader routes symbolic when the configured leader changes. prefix: Option, keys: Vec, } fn parse_key(key: &str) -> Result { let character = |name: &str| { let mut chars = name.chars(); chars .next() .filter(|ch| !ch.is_control() && chars.next().is_none()) }; // Keep literal punctuation, especially "+", usable without escaping. if let Some(ch) = character(key) { return Ok(Input::Char(ch)); } let invalid = || format!("Invalid binding key: {key:?}"); if let Some((first, rest)) = key.split_once('+') && let Some((second, name)) = rest.split_once('+') { let alt = |modifier: &str| { modifier.eq_ignore_ascii_case("alt") || modifier.eq_ignore_ascii_case("option") }; if ((first.eq_ignore_ascii_case("ctrl") && alt(second)) || (alt(first) && second.eq_ignore_ascii_case("ctrl"))) && let Some(ch) = character(name).filter(char::is_ascii_alphabetic) { return Ok(Input::CtrlAlt(ch.to_ascii_lowercase())); } return Err(invalid()); } let (modifier, name) = key .split_once('+') .map_or((None, key), |(modifier, name)| (Some(modifier), name)); let input = if let Some(ch) = character(name) { Input::Char(ch) } else { [ ("esc", Input::Escape), ("enter", Input::Enter), ("backspace", Input::Backspace), ("delete", Input::Delete), ("left", Input::Left), ("right", Input::Right), ("up", Input::Up), ("down", Input::Down), ("home", Input::Home), ("end", Input::End), ("tab", Input::Char('\t')), ("space", Input::Char(' ')), ("minus", Input::Char('-')), ("comma", Input::Char(',')), ("ampersand", Input::Char('&')), ("plus", Input::Char('+')), ("backtick", Input::Char('`')), ] .into_iter() .find(|(key, _)| name.eq_ignore_ascii_case(key)) .map(|(_, input)| input) .ok_or_else(invalid)? }; match (modifier, input) { (None, input) => Ok(input), (Some(modifier), Input::Char('\t')) if modifier.eq_ignore_ascii_case("shift") => { Ok(Input::BackTab) } (Some(modifier), Input::Backspace) if modifier.eq_ignore_ascii_case("alt") || modifier.eq_ignore_ascii_case("option") => { Ok(Input::AltBackspace) } (Some(modifier), Input::Backspace) if modifier.eq_ignore_ascii_case("cmd") || modifier.eq_ignore_ascii_case("super") => { Ok(Input::SuperBackspace) } (Some(modifier), Input::Char(ch)) if ch.is_ascii_alphabetic() => { if modifier.eq_ignore_ascii_case("ctrl") { Ok(control_key(ch)) } else if modifier.eq_ignore_ascii_case("alt") || modifier.eq_ignore_ascii_case("option") { Ok(Input::Alt(ch.to_ascii_lowercase())) } else if modifier.eq_ignore_ascii_case("shift") { Ok(Input::Char(ch.to_ascii_uppercase())) } else { Err(invalid()) } } _ => Err(invalid()), } } fn control_key(ch: char) -> Input { // Terminals encode these controls as named keys. match ch.to_ascii_lowercase() { 'i' => Input::Char('\t'), 'm' => Input::Enter, ch => Input::Ctrl(ch), } } fn display_key(input: &Input) -> String { let mut display = String::new(); append_key(&mut display, input); display } fn append_key(display: &mut String, input: &Input) { use std::fmt::Write as _; match input { Input::Char('\t') => display.push_str("Tab"), Input::BackTab => display.push_str("Shift+Tab"), Input::Char(' ') => display.push_str("Space"), Input::Char(ch) => display.push(*ch), Input::Ctrl(ch) => write!(display, "Ctrl+{ch}").expect("writing to a String"), Input::Alt(ch) => write!(display, "Alt+{ch}").expect("writing to a String"), Input::CtrlAlt(ch) => write!(display, "Ctrl+Alt+{ch}").expect("writing to a String"), Input::Escape => display.push_str("Esc"), Input::Enter => display.push_str("Enter"), Input::Backspace => display.push_str("Backspace"), Input::AltBackspace => display.push_str("Alt+Backspace"), Input::SuperBackspace => display.push_str("Cmd+Backspace"), Input::Delete => display.push_str("Delete"), Input::Left => display.push_str("Left"), Input::Right => display.push_str("Right"), Input::Up => display.push_str("Up"), Input::Down => display.push_str("Down"), Input::Home => display.push_str("Home"), Input::End => display.push_str("End"), Input::Paste(_) => unreachable!("paste cannot be bound"), } } fn default_bindings() -> HashMap> { use Action::*; let mut bindings: HashMap> = HashMap::new(); for (key, action) in [ ('i', Insert), ('I', InsertFirstNonblank), ('a', Append), ('A', AppendLineEnd), ('o', OpenBelow), ('O', OpenAbove), ('J', JoinLines), ('x', Delete), ('C', ChangeToLineEnd), ('D', DeleteToLineEnd), ('Y', CopyToLineEnd), ('s', SubstituteCharacter), ('u', Undo), ('U', Redo), (':', Command), ('/', Search), ('p', PasteAfter), ('P', PasteBefore), ('_', PaneSplitHorizontal), ('|', PaneSplitVertical), ] { bindings .entry(Mode::Normal) .or_default() .insert(Input::Char(key), Binding::Action(action)); } for (key, action) in [ (Input::Ctrl('o'), JumpBack), (Input::Ctrl('b'), DiffModeToggle), (Input::Char('\t'), JumpForward), (Input::Alt('h'), PaneFocusLeft), (Input::Alt('j'), PaneFocusDown), (Input::Alt('k'), PaneFocusUp), (Input::Alt('l'), PaneFocusRight), (Input::CtrlAlt('h'), TabPrevious), (Input::CtrlAlt('l'), TabNext), ] { bindings .entry(Mode::Normal) .or_default() .insert(key, Binding::Action(action)); } for mode in [Mode::Normal, Mode::Visual] { for (key, action) in [ ('h', Left), ('j', Down), ('k', Up), ('l', Right), ('0', LineStart), ('$', LineEnd), ('w', WordNext), ('b', WordPrevious), ('e', WordEnd), ('W', BigWordNext), ('B', BigWordPrevious), ('E', BigWordEnd), ('f', SeekForward), ('F', SeekBackward), ('n', SearchNext), ('N', SearchPrevious), ('v', Select), ('V', SelectLinewise), ('.', RepeatLastEdit), ('r', ReplaceCharacter), ] { bindings .entry(mode) .or_default() .insert(Input::Char(key), Binding::Action(action)); } for (key, action) in [ (Input::Ctrl('k'), CloneAbove), (Input::Ctrl('e'), CloneBelow), (Input::Escape, Normal), ] { bindings .entry(mode) .or_default() .insert(key, Binding::Action(action)); } } for mode in [Mode::Normal, Mode::Insert, Mode::Visual] { for (key, action) in [ (Input::Left, Left), (Input::Right, Right), (Input::Down, Down), (Input::Up, Up), (Input::Home, LineStart), (Input::End, LineEnd), ] { bindings .entry(mode) .or_default() .insert(key, Binding::Action(action)); } } for mode in [Mode::Normal, Mode::Insert] { bindings .entry(mode) .or_default() .insert(Input::Delete, Binding::Action(Delete)); } bindings .entry(Mode::Visual) .or_default() .insert(Input::Escape, Binding::Action(Normal)); for (key, action) in [ (Input::Char('y'), Copy), (Input::Char('d'), Cut), (Input::Char('c'), Change), (Input::Char('x'), Cut), (Input::Delete, Cut), (Input::Char('I'), InsertFirstNonblank), (Input::Char('A'), AppendLineEnd), (Input::Ctrl('l'), SelectLines), (Input::Ctrl('n'), SelectNext), (Input::Ctrl('s'), SkipOccurrence), (Input::Ctrl('a'), SelectAll), ] { bindings .entry(Mode::Visual) .or_default() .insert(key, Binding::Action(action)); } bindings .entry(Mode::Insert) .or_default() .insert(Input::Ctrl('n'), Binding::Action(LspCompletion)); for (key, action) in [ (Input::Char('\t'), SnippetNext), (Input::BackTab, SnippetPrevious), ] { bindings .entry(Mode::Insert) .or_default() .insert(key, Binding::Action(action)); } for mode in [Mode::Insert, Mode::Command, Mode::Search] { for (key, action) in [ (Input::Escape, Normal), (Input::Backspace, Backspace), (Input::Ctrl('u'), BackspaceLine), ( Input::Enter, if mode == Mode::Insert { Newline } else { Submit }, ), ( Input::Ctrl('j'), if mode == Mode::Insert { Newline } else { Submit }, ), ] { bindings .entry(mode) .or_default() .insert(key, Binding::Action(action)); } #[cfg(target_os = "macos")] for (key, action) in [ (Input::AltBackspace, BackspaceWord), (Input::SuperBackspace, BackspaceLine), ] { bindings .entry(mode) .or_default() .insert(key, Binding::Action(action)); } } for mode in [ Mode::Normal, Mode::Insert, Mode::Command, Mode::Search, Mode::Visual, ] { bindings .entry(mode) .or_default() .insert(Input::Ctrl('p'), Binding::Action(Palette)); } bindings } /// 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, } /// Borrowed document state, including hidden and unnamed buffers. pub struct DocumentInfo<'a> { pub id: usize, pub revision: u64, pub path: Option<&'a Path>, pub lines: &'a [String], pub save_revision: u64, pub dirty: bool, } struct AnnotationSet { revision: u64, items: Vec, } /// Pane-local interaction state. The focused view occupies the buffer's view slot; /// all other views live in their owning pane, never alongside copied source text. struct ViewState { targets: Vec, primary: usize, mode: Mode, word_wrap: bool, linewise: bool, search_query: String, search_match: Option, command_line: String, caret_reveal_revision: u64, lsp_center: Cell>, insert_start: Option, pending_edit: Option, visual_motions: Option>, } impl ViewState { fn empty() -> Self { Self { targets: Vec::new(), primary: 0, mode: Mode::Normal, word_wrap: false, linewise: false, search_query: String::new(), search_match: None, command_line: String::new(), caret_reveal_revision: 0, lsp_center: Cell::new(None), insert_start: None, pending_edit: None, visual_motions: None, } } fn inherit(&self) -> Self { Self { targets: self.targets.clone(), primary: self.primary, mode: self.mode, word_wrap: self.word_wrap, linewise: self.linewise, search_query: self.search_query.clone(), search_match: self.search_match, command_line: self.command_line.clone(), caret_reveal_revision: self.caret_reveal_revision, visual_motions: self.visual_motions.clone(), ..Self::empty() } } fn clamp(&mut self, lines: &[String]) { for target in &mut self.targets { let clamp = |cursor: &mut Cursor| { cursor.row = cursor.row.min(lines.len() - 1); let count = grapheme_count(&lines[cursor.row]); let maximum = if self.mode == Mode::Insert || (self.mode == Mode::Visual && cursor.row + 1 < lines.len()) { count } else { count.saturating_sub(1) }; cursor.column = cursor.column.min(maximum); }; clamp(&mut target.active); clamp(&mut target.anchor); if self.mode != Mode::Visual { target.anchor = target.active; } target.preferred_columns = None; } self.search_match = None; self.visual_motions = None; } fn reload(&mut self, lines: &[String]) { self.clamp(lines); self.insert_start = (self.mode == Mode::Insert).then(|| Snapshot { lines: lines.to_vec(), targets: self.targets.clone(), primary: self.primary, }); self.pending_edit = (self.mode == Mode::Insert).then(|| EditRecipe { start: EditStart::Insert(Action::Insert), steps: Vec::new(), }); } } struct Pane { buffer_id: usize, origin: Option, views: BTreeMap, } struct Tab { pane_id: usize, pane_layout: PaneTree, file_picker: Option, diff: Option, parked_diff: Option, diff_owner: Option, diff_prompt_mode: Option, diff_pane_bounds: [(Focus, PaneRect); 3], diff_bound: Option<(u64, usize, Focus, usize)>, diff_return_buffer: Option, outstanding_diff_request: Option, } impl Tab { fn diff(&self) -> Option<&DiffState> { self.diff.as_ref().or(self.parked_diff.as_ref()) } } #[derive(Clone, Copy, Eq, PartialEq)] struct FileStamp { modified: std::time::SystemTime, len: u64, #[cfg(unix)] identity: (u64, u64, i64, i64), } impl FileStamp { fn from_metadata(metadata: &fs::Metadata) -> io::Result { if !metadata.is_file() { return Err(io::Error::other("File is not a regular file")); } Ok(Self { modified: metadata.modified()?, len: metadata.len(), #[cfg(unix)] identity: ( metadata.dev(), metadata.ino(), metadata.ctime(), metadata.ctime_nsec(), ), }) } fn read(path: &Path) -> io::Result> { match fs::metadata(path) { Ok(metadata) => Self::from_metadata(&metadata).map(Some), Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None), Err(error) => Err(error), } } } struct GutterChanges { revision: u64, path: PathBuf, lines: Vec>, } struct Buffer { id: usize, diff_snapshot: Option<(u64, usize, Focus)>, snapshot_document_path: Option, snapshot_root: Option, lines: Vec, saved_lines: Vec, disk_stamp: Option, reload_error: Option, text_revision: u64, save_revision: u64, diagnostics: BTreeMap, semantic_tokens: Option>, gutter_changes: Option, view: ViewState, path: Option, identity: Option, // ponytail: full-buffer history costs O(buffer size) per action; use edit deltas for large files. undo: Vec, redo: Vec, } impl Buffer { fn open( id: usize, path: Option, identity: Option, allow_missing: bool, ) -> io::Result { let mut disk_stamp = None; let text = match &path { Some(path) => match fs::File::open(path) { Ok(mut file) => { disk_stamp = Some(FileStamp::from_metadata(&file.metadata()?)?); let mut text = String::new(); file.read_to_string(&mut text)?; text } Err(error) if allow_missing && error.kind() == io::ErrorKind::NotFound => { String::new() } Err(error) => return Err(error), }, None => String::new(), }; Self::from_text(id, path, identity, &text, disk_stamp) } fn from_text( id: usize, path: Option, identity: Option, text: &str, disk_stamp: Option, ) -> io::Result { validate_text(text).map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?; let lines = split_lines(text); Ok(Self { id, diff_snapshot: None, snapshot_document_path: None, snapshot_root: None, saved_lines: lines.clone(), disk_stamp, reload_error: None, lines, text_revision: 0, save_revision: 0, diagnostics: BTreeMap::new(), semantic_tokens: None, gutter_changes: None, view: ViewState { targets: vec![Target::caret(Cursor::default())], ..ViewState::empty() }, path, identity, undo: Vec::new(), redo: Vec::new(), }) } fn dirty(&self) -> bool { self.diff_snapshot.is_none() && self.lines != self.saved_lines } fn refresh_file(&mut self) -> io::Result { if self.diff_snapshot.is_some() { return Ok(false); } let Some(path) = &self.path else { return Ok(false); }; let stamp = FileStamp::read(path)?; if self.dirty() { if stamp.is_none() && self.disk_stamp.is_some() { return Err(io::Error::new(io::ErrorKind::NotFound, "File was removed")); } return Ok(false); } if stamp == self.disk_stamp { return Ok(false); } let Some(stamp) = stamp else { return Err(io::Error::new(io::ErrorKind::NotFound, "File was removed")); }; let mut file = fs::File::open(path)?; if FileStamp::from_metadata(&file.metadata()?)? != stamp { return Err(io::Error::new( io::ErrorKind::WouldBlock, "File changed while reloading", )); } let mut text = String::new(); file.read_to_string(&mut text)?; if FileStamp::from_metadata(&file.metadata()?)? != stamp { return Err(io::Error::new( io::ErrorKind::WouldBlock, "File changed while reloading", )); } validate_text(&text).map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?; self.disk_stamp = Some(stamp); if normalize_newlines(&text) .split('\n') .eq(self.lines.iter().map(String::as_str)) { return Ok(false); } self.lines = split_lines(&text); self.saved_lines.clone_from(&self.lines); self.undo.clear(); self.redo.clear(); self.text_changed(); self.view.reload(&self.lines); Ok(true) } fn info(&self, active: bool) -> BufferInfo<'_> { BufferInfo { id: self.id, path: self.path.as_deref(), dirty: self.dirty(), active, } } fn document(&self) -> DocumentInfo<'_> { DocumentInfo { id: self.id, revision: self.text_revision, path: self .snapshot_document_path .as_deref() .or(self.path.as_deref()), lines: &self.lines, save_revision: self.save_revision, dirty: self.dirty(), } } fn text_changed(&mut self) { self.text_revision = self.text_revision.wrapping_add(1); self.diagnostics.clear(); self.semantic_tokens = None; } } #[derive(Clone, Copy, Debug, Eq, PartialEq)] struct DiffJumpLocation { comparison_id: u64, file: usize, focus: Focus, text_focus: Focus, live_buffer: Option, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] struct JumpLocation { tab_id: usize, pane_id: usize, buffer_id: usize, cursor: Cursor, diff: Option, } struct PendingLspNavigation { tab: usize, pane: usize, source: JumpLocation, revision: u64, bound: (u64, usize, Focus, usize), file: usize, focus: Focus, range: Utf16Range, target_range: Option, request: Option, } #[derive(Clone)] pub(crate) struct BlameSource { buffer: usize, revision: u64, pub(crate) path: Arc, pub(crate) row: usize, save_revision: u64, disk_stamp: Option, pane: usize, tab: usize, } pub struct Editor { active: Buffer, parked: BTreeMap, next_buffer_id: usize, pane_id: usize, next_pane_id: usize, panes: BTreeMap, pane_layout: PaneTree, tab_id: usize, next_tab_id: usize, tab_order: Vec, tabs: BTreeMap, pane_bounds: PaneRect, render_pane_focused: bool, diff_owner: Option, parked_diff: Option, diff_pane_bounds: [(Focus, PaneRect); 3], // Closed destinations stay until bounded eviction so they cannot shift jump_index. jumps: VecDeque, jump_index: usize, clipboard: Vec, clipboard_linewise: bool, clipboard_mode: ClipboardMode, system_clipboard: SystemClipboard, // Exact exported bytes bridge our fragments; external newlines never imply boundaries. owned_system_clipboard: Option<(String, Vec, bool)>, last_edit: Option, message: String, notification_visible: bool, top_bar: ChromeLine, bottom_bar: ChromeLine, popup: PopupConfig, labels: BTreeMap, scroll_distance: Option, highlight_trailing_whitespace: bool, whitespace_characters: BTreeMap, whitespace_scope: WhitespaceScope, tab_spaces: Option, language_tab_spaces: BTreeMap>, battery_charge: Option, battery_label: String, git_blame: bool, gutter_style: crate::gutter::GutterStyle, line_blame: Option<(BlameSource, Arc, String)>, lsp_names: Vec, lsp_label: String, quit: bool, restart: bool, capabilities: Vec, features: HashMap, bindings: HashMap>, selection_bindings: HashMap, selection_navigation_hint: String, tree_bindings: HashMap, tree_navigation_hint: String, tree_filter_navigation_hint: String, leader: Input, sequence_bindings: HashMap>, pending_sequence: BindingSequence, sequence_display: String, g_pending: bool, line_pending: Option<(char, usize)>, seek_pending: Option<(bool, usize)>, palette: Option, pane_close_picker_origin: Option<(usize, usize, Option)>, hover: Option, completion: Option, snippet: Option, startup_diagnostic: Option, project_root: PathBuf, file_picker: Option, file_explorer_side: DockSide, file_picker_view: FileView, file_explorer_view: FileView, count: Option, diff: Option, diff_prompt_mode: Option, diff_bound: Option<(u64, usize, Focus, usize)>, diff_return_buffer: Option, diff_context_options: (usize, usize), diff_display_options: (ViewMode, SplitAxis), next_diff_request: u64, outstanding_diff_request: Option, pending_lsp_navigation: 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, true)?, parked: BTreeMap::new(), next_buffer_id: 2, pane_id: 1, next_pane_id: 2, panes: BTreeMap::from([( 1, Pane { buffer_id: 1, origin: None, views: BTreeMap::from([(1, ViewState::empty())]), }, )]), pane_layout: PaneTree::new(1), tab_id: 1, next_tab_id: 2, tab_order: vec![1], tabs: BTreeMap::new(), pane_bounds: PaneRect { x: 0, y: 0, width: 120, height: 40, }, render_pane_focused: true, diff_owner: None, parked_diff: None, diff_pane_bounds: [( Focus::Files, PaneRect { x: 0, y: 0, width: 0, height: 0, }, ); 3], jumps: VecDeque::new(), jump_index: 0, clipboard: Vec::new(), clipboard_linewise: false, clipboard_mode: ClipboardMode::System, system_clipboard: SystemClipboard::new(), owned_system_clipboard: None, last_edit: None, message: String::new(), notification_visible: true, top_bar: ChromeLine::top(), bottom_bar: ChromeLine::bottom(), popup: PopupConfig::default(), labels: BTreeMap::new(), scroll_distance: Some(0), highlight_trailing_whitespace: false, whitespace_characters: BTreeMap::new(), whitespace_scope: WhitespaceScope::All, tab_spaces: None, language_tab_spaces: BTreeMap::new(), battery_charge: None, battery_label: String::new(), git_blame: true, gutter_style: crate::gutter::GutterStyle::default(), line_blame: None, lsp_names: Vec::new(), lsp_label: String::new(), quit: false, restart: false, capabilities, features: HashMap::new(), bindings: default_bindings(), selection_bindings: [ (Input::Up, Action::Up), (Input::Ctrl('k'), Action::Up), (Input::Down, Action::Down), (Input::Ctrl('j'), Action::Down), (Input::Ctrl('n'), Action::Down), ] .into_iter() .map(|(key, action)| (key, Binding::Action(action))) .collect(), selection_navigation_hint: String::new(), tree_bindings: [ (Input::Left, Action::Left), (Input::Ctrl('h'), Action::Left), (Input::Right, Action::Right), (Input::Ctrl('l'), Action::Right), (Input::Char('h'), Action::Left), (Input::Char('j'), Action::Down), (Input::Char('k'), Action::Up), (Input::Char('l'), Action::Right), ] .into_iter() .map(|(key, action)| (key, Binding::Action(action))) .collect(), tree_navigation_hint: String::new(), tree_filter_navigation_hint: String::new(), leader: Input::Char(' '), sequence_bindings: HashMap::new(), pending_sequence: BindingSequence::default(), sequence_display: String::new(), g_pending: false, line_pending: None, seek_pending: None, palette: None, pane_close_picker_origin: None, hover: None, completion: None, snippet: None, startup_diagnostic: None, project_root: std::env::current_dir()?, file_picker: None, file_explorer_side: DockSide::Right, file_picker_view: FileView::Tree, file_explorer_view: FileView::Tree, count: None, diff: None, diff_prompt_mode: None, diff_bound: None, diff_return_buffer: None, diff_context_options: (10, 15), diff_display_options: (ViewMode::Split, SplitAxis::Vertical), next_diff_request: 0, outstanding_diff_request: None, pending_lsp_navigation: None, }; editor.refresh_selection_navigation_hint(); editor .install( Feature { name: "core".into(), required_capabilities: Vec::new(), commands: CORE_ACTIONS .iter() .filter_map(|entry| entry.ex) .chain(COMMAND_ALIASES.iter().map(|(alias, _)| *alias)) .map(str::to_owned) .collect(), core: true, }, true, ) .map_err(io::Error::other)?; for (mode, key, target) in [ ("normal", "leader space", "core.palette"), ("visual", "leader space", "core.palette"), ("normal", "leader o", "core.file_picker"), ("visual", "leader o", "core.file_picker"), ("normal", "leader e", "core.file_explorer"), ("visual", "leader e", "core.file_explorer"), ("normal", "leader w", "core.save"), ("normal", "leader b n", "core.buffer_next"), ("normal", "leader b p", "core.buffer_previous"), ("normal", "leader b l", "core.buffers"), ("normal", "leader b d", "core.buffer_delete"), ("normal", "leader v", "core.diff.view.toggle"), ("normal", "leader s", "core.diff.orientation.toggle"), ] { editor .bind(mode, key, Some(target.into())) .map_err(io::Error::other)?; } Ok(editor) } pub fn tab_id(&self) -> usize { self.tab_id } pub fn tab_index(&self) -> usize { self.tab_order .iter() .position(|id| *id == self.tab_id) .unwrap() } pub fn tab_count(&self) -> usize { self.tab_order.len() } pub fn tab_buffer(&self, index: usize) -> Option> { let id = *self.tab_order.get(index)?; let pane = if id == self.tab_id { self.pane_id } else { self.tabs.get(&id)?.pane_id }; self.buffer(self.pane_owned_buffer(pane)) .map(|buffer| buffer.info(id == self.tab_id)) } pub fn tab_is_diff(&self, index: usize) -> bool { self.tab_order .get(index) .is_some_and(|id| self.tab_diff(*id).is_some()) } pub(crate) fn tab_diff(&self, id: usize) -> Option<&DiffState> { if id == self.tab_id { self.workspace_diff() } else { self.tabs.get(&id)?.diff() } } fn select_tab(&mut self, id: usize) { if id == self.tab_id { return; } self.pane_close_picker_origin = None; let next = self.tabs.remove(&id).expect("selected tab exists"); self.stash_view(); let previous = Tab { pane_id: std::mem::replace(&mut self.pane_id, next.pane_id), pane_layout: std::mem::replace(&mut self.pane_layout, next.pane_layout), file_picker: std::mem::replace(&mut self.file_picker, next.file_picker), diff: std::mem::replace(&mut self.diff, next.diff), parked_diff: std::mem::replace(&mut self.parked_diff, next.parked_diff), diff_owner: std::mem::replace(&mut self.diff_owner, next.diff_owner), diff_pane_bounds: std::mem::replace(&mut self.diff_pane_bounds, next.diff_pane_bounds), diff_prompt_mode: std::mem::replace(&mut self.diff_prompt_mode, next.diff_prompt_mode), diff_bound: std::mem::replace(&mut self.diff_bound, next.diff_bound), diff_return_buffer: std::mem::replace( &mut self.diff_return_buffer, next.diff_return_buffer, ), outstanding_diff_request: std::mem::replace( &mut self.outstanding_diff_request, next.outstanding_diff_request, ), }; self.tabs.insert(self.tab_id, previous); self.tab_id = id; let buffer_id = self.panes[&self.pane_id].buffer_id; if buffer_id != self.active.id { let buffer = self.parked.remove(&buffer_id).expect("tab buffer exists"); let previous = std::mem::replace(&mut self.active, buffer); self.parked.insert(previous.id, previous); } self.load_view(); } fn new_tab(&mut self) { self.finish_pane_interaction(); let buffer_id = self.active.id; let view = self.active.view.inherit(); let pane_id = self.next_pane_id; self.next_pane_id += 1; self.panes.insert( pane_id, Pane { buffer_id, origin: None, views: BTreeMap::from([(buffer_id, view)]), }, ); let id = self.next_tab_id; self.next_tab_id += 1; self.tabs.insert( id, Tab { pane_id, pane_layout: PaneTree::new(pane_id), file_picker: None, diff: None, parked_diff: None, diff_owner: None, diff_pane_bounds: self.diff_pane_bounds, diff_prompt_mode: None, diff_bound: None, diff_return_buffer: None, outstanding_diff_request: None, }, ); self.tab_order.push(id); self.select_tab(id); self.normalize_targets(); if self.active.view.mode == Mode::Insert { self.begin_insert(); } } fn adjacent_tab(&mut self, forward: bool) { self.finish_pane_interaction(); let index = self.tab_index(); let count = self.tab_count(); let next = if forward { (index + 1) % count } else { (index + count - 1) % count }; self.select_tab(self.tab_order[next]); if self.active.view.mode == Mode::Insert { self.begin_insert(); } } pub fn pane_id(&self) -> usize { self.pane_id } pub fn pane_layout(&self) -> &PaneTree { &self.pane_layout } pub fn set_pane_bounds(&mut self, bounds: PaneRect) { self.pane_bounds = bounds; } pub fn set_diff_pane_bounds(&mut self, bounds: [(Focus, PaneRect); 3]) { self.diff_pane_bounds = bounds; } pub fn pane_buffer_id(&self, id: usize) -> Option { self.panes.get(&id).map(|pane| pane.buffer_id) } pub fn pane_origin(&self, id: usize) -> Option { self.panes.get(&id).and_then(|pane| pane.origin) } pub fn render_pane_focused(&self) -> bool { self.render_pane_focused } fn stash_view(&mut self) { let pane = self .panes .get_mut(&self.pane_id) .expect("focused pane exists"); pane.buffer_id = self.active.id; let view = pane .views .entry(self.active.id) .or_insert_with(ViewState::empty); std::mem::swap(&mut self.active.view, view); } fn load_view(&mut self) { let pane = self .panes .get_mut(&self.pane_id) .expect("focused pane exists"); pane.buffer_id = self.active.id; let view = pane.views.entry(self.active.id).or_insert_with(|| { if self.active.view.targets.is_empty() { ViewState { targets: vec![Target::caret(Cursor::default())], ..ViewState::empty() } } else { std::mem::replace(&mut self.active.view, ViewState::empty()) } }); std::mem::swap(&mut self.active.view, view); } // No input dispatch or document copies: source documents stay unique in active/parked. fn select_pane(&mut self, id: usize) { if id == self.pane_id { return; } self.pane_close_picker_origin = None; let buffer_id = self.panes[&id].buffer_id; self.stash_view(); if self.diff_owner == Some(self.pane_id) { self.parked_diff = self.diff.take(); } if buffer_id != self.active.id { let buffer = self.parked.remove(&buffer_id).expect("pane buffer exists"); let previous = std::mem::replace(&mut self.active, buffer); self.parked.insert(previous.id, previous); } self.pane_id = id; self.load_view(); if self.diff_owner == Some(id) { self.diff = self.parked_diff.take(); } } fn with_pane(&mut self, id: usize, callback: impl FnOnce(&mut Self) -> R) -> R { assert!(self.panes.contains_key(&id), "rendered pane exists"); let focused = self.pane_id; let tab = self.tab_id; let target_tab = if self.pane_layout.contains(id) { tab } else { *self .tabs .iter() .find(|(_, tab)| tab.pane_layout.contains(id)) .map(|(id, _)| id) .expect("pane belongs to a tab") }; let target_focused = if target_tab == tab { focused } else { self.tabs[&target_tab].pane_id }; let interaction = (id != focused).then(|| { ( self.palette.take(), self.pane_close_picker_origin.take(), self.hover.take(), self.completion.take(), self.snippet.take(), self.file_picker.take(), self.count.take(), std::mem::take(&mut self.pending_sequence), std::mem::take(&mut self.sequence_display), std::mem::take(&mut self.g_pending), self.line_pending.take(), self.seek_pending.take(), std::mem::take(&mut self.message), std::mem::replace(&mut self.notification_visible, true), ) }); self.select_tab(target_tab); self.select_pane(id); let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| callback(self))); self.select_pane(target_focused); self.select_tab(tab); if let Some(interaction) = interaction { ( self.palette, self.pane_close_picker_origin, self.hover, self.completion, self.snippet, self.file_picker, self.count, self.pending_sequence, self.sequence_display, self.g_pending, self.line_pending, self.seek_pending, self.message, self.notification_visible, ) = interaction; } match result { Ok(value) => value, Err(error) => std::panic::resume_unwind(error), } } pub fn with_render_pane(&mut self, id: usize, callback: impl FnOnce(&Editor) -> R) -> R { let focused = self.render_pane_focused; let is_focused = focused && self.pane_id == id; self.render_pane_focused = is_focused; let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { self.with_pane(id, |editor| callback(editor)) })); self.render_pane_focused = focused; match result { Ok(value) => value, Err(error) => std::panic::resume_unwind(error), } } fn finish_pane_interaction(&mut self) { self.pending_lsp_navigation = None; self.finish_completion(); self.snippet = None; self.clear_hover(); if self.active.view.mode == Mode::Insert { self.finish_insert(); } self.palette = None; self.pane_close_picker_origin = None; self.count = None; self.clear_sequence(); self.g_pending = false; self.line_pending = None; self.seek_pending = None; } fn split_pane(&mut self, axis: SplitAxis) { self.finish_pane_interaction(); let id = self.next_pane_id; self.next_pane_id += 1; let buffer_id = self.active.id; let view = self.active.view.inherit(); self.panes.insert( id, Pane { buffer_id, origin: Some(self.pane_id), views: BTreeMap::from([(buffer_id, view)]), }, ); assert!(self.pane_layout.split(self.pane_id, id, axis)); self.select_pane(id); self.normalize_targets(); if self.active.view.mode == Mode::Insert { self.begin_insert(); } } fn focus_pane(&mut self, direction: PaneDirection) { if let Some(diff) = self.diff.as_ref().filter(|diff| diff.phase == Phase::View) { let focus = diff.focus; if let Some((_, current)) = self .diff_pane_bounds .iter() .find(|(side, rect)| *side == focus && rect.width > 0 && rect.height > 0) { let neighbor = directional_neighbor( *current, direction, self.diff_pane_bounds .iter() .enumerate() .filter_map(|(id, (side, rect))| { (*side != focus && rect.width > 0 && rect.height > 0) .then_some((id, *rect)) }), ); if let Some(neighbor) = neighbor { self.finish_pane_interaction(); let diff = self.diff.as_mut().expect("comparison stays open"); diff.focus = self.diff_pane_bounds[neighbor].0; if let Some(files) = &mut diff.files { files.focused = diff.files_visible && diff.focus == Focus::Files; } self.bind_diff_buffer(); return; } } } if let Some(id) = self .pane_layout .neighbor(self.pane_id, direction, self.pane_bounds) { self.finish_pane_interaction(); if self.diff.is_none() { self.blur_file_picker(); } self.select_pane(id); if self.active.view.mode == Mode::Insert { self.begin_insert(); } } } fn pane_buffers(&self, id: usize) -> [Option; 3] { let (diff, return_buffer) = if self.diff_owner == Some(id) { (self.workspace_diff(), self.diff_return_buffer) } else if let Some(tab) = self.tabs.values().find(|tab| tab.diff_owner == Some(id)) { (tab.diff(), tab.diff_return_buffer) } else { (None, None) }; [ Some(self.panes[&id].buffer_id), diff.and_then(|diff| diff.live_buffer), return_buffer, ] } fn pane_owned_buffer(&self, id: usize) -> usize { let buffers = self.pane_buffers(id); buffers[1].or(buffers[2]).or(buffers[0]).unwrap() } fn closing_buffers(&self, panes: &[usize]) -> Vec { let mut buffers = Vec::new(); for pane in panes { for buffer in self.pane_buffers(*pane).into_iter().flatten() { if !buffers.contains(&buffer) && !self.panes.keys().any(|other| { !panes.contains(other) && self.pane_buffers(*other).contains(&Some(buffer)) }) { buffers.push(buffer); } } } buffers } fn forget_buffer(&mut self, id: usize) { self.parked.remove(&id); for pane in self.panes.values_mut() { pane.views.remove(&id); } } fn close_tab(&mut self, force: bool) { if self.tab_count() == 1 { if !force && self.any_dirty() { self.set_message("Unsaved changes; use :w or :tab.force_close"); } else { self.quit = true; } return; } let panes: Vec<_> = self .panes .keys() .copied() .filter(|id| self.pane_layout.contains(*id)) .collect(); let buffers = self.closing_buffers(&panes); if !force && buffers .iter() .any(|id| self.buffer(*id).is_some_and(Buffer::dirty)) { self.set_message("Unsaved changes; use :w or :tab.force_close"); return; } self.finish_pane_interaction(); if let Some(owner) = self.diff_owner { self.with_pane(owner, |editor| { editor.release_diff_buffers(); editor.diff = None; }); self.parked_diff = None; self.diff_owner = None; self.outstanding_diff_request = None; } let id = self.tab_id; let index = self.tab_index(); let next = self.tab_order[(index + 1) % self.tab_count()]; self.select_tab(next); self.tabs.remove(&id); self.tab_order.remove(index); for pane in panes { self.panes.remove(&pane); } for buffer in buffers { self.forget_buffer(buffer); } } fn close_pane(&mut self, force: bool) { self.count = None; self.clear_sequence(); let picker_origin = self.pane_close_picker_origin.take().is_some_and(|origin| { origin == ( self.tab_id, self.pane_id, self.diff.as_ref().and_then(|diff| diff.comparison_id), ) }); if self.file_picker().is_some_and(|picker| { (picker.focused || picker_origin) && picker.presentation == FilePresentation::Docked }) { self.palette = None; self.close_file_picker(); return; } if self.pane_layout.len() == 1 { if self.tab_count() > 1 { self.close_tab(force); } else if !force && self.any_dirty() { self.set_message("Unsaved changes; use :w or :pane.force_close"); } else { self.quit = true; } return; } let id = self.pane_id; let buffers = self.closing_buffers(&[id]); if !force && let Some(buffer_id) = buffers .iter() .find(|buffer| self.buffer(**buffer).is_some_and(Buffer::dirty)) { self.set_message(format!( "Buffer {buffer_id} has unsaved changes; use :w or :pane.force_close" )); return; } self.finish_pane_interaction(); if self.diff_owner == Some(id) { self.release_diff_buffers(); self.diff = None; self.parked_diff = None; self.diff_owner = None; self.outstanding_diff_request = None; } let next = self.pane_layout.close(id).expect("another pane exists"); self.select_pane(next); self.panes.remove(&id); for buffer in buffers { self.forget_buffer(buffer); } } pub fn clipboard_mode(&self) -> ClipboardMode { self.clipboard_mode } pub fn set_clipboard_mode(&mut self, mode: &str) -> Result<(), String> { self.clipboard_mode = if mode.eq_ignore_ascii_case("system") { ClipboardMode::System } else if mode.eq_ignore_ascii_case("separate") { ClipboardMode::Separate } else { return Err("Clipboard mode must be system or separate".into()); }; Ok(()) } #[cfg(test)] pub(crate) fn set_system_clipboard(&mut self, clipboard: SystemClipboard) { self.system_clipboard = clipboard; self.owned_system_clipboard = None; } /// 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.buffers().count() } 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) .filter(|(_, buffer)| buffer.diff_snapshot.is_none()) .map(|(_, buffer)| buffer.info(false)) .chain((self.active.diff_snapshot.is_none()).then(|| self.active.info(true))) .chain( self.parked .range(self.active.id..) .filter(|(_, buffer)| buffer.diff_snapshot.is_none()) .map(|(_, buffer)| buffer.info(false)), ) } pub fn documents(&self) -> impl Iterator> + '_ { self.parked .range(..self.active.id) .filter(|(_, buffer)| buffer.diff_snapshot.is_none()) .map(|(_, buffer)| buffer.document()) .chain((self.active.diff_snapshot.is_none()).then(|| self.active.document())) .chain( self.parked .range(self.active.id..) .filter(|(_, buffer)| buffer.diff_snapshot.is_none()) .map(|(_, buffer)| buffer.document()), ) } pub(crate) fn interaction_documents(&self) -> impl Iterator> + '_ { self.documents().chain( std::iter::once(&self.active) .chain(self.parked.values()) .filter(|buffer| buffer.diff_snapshot.is_some()) .map(Buffer::document), ) } pub fn project_root(&self) -> &Path { &self.project_root } pub fn diff(&self) -> Option<&DiffState> { self.diff.as_ref() } pub(crate) fn workspace_diff(&self) -> Option<&DiffState> { self.diff.as_ref().or(self.parked_diff.as_ref()) } pub(crate) fn diff_root(&mut self, tab: usize) -> PathBuf { let owner = if tab == self.tab_id { self.diff_owner.unwrap_or(self.pane_id) } else { let tab = &self.tabs[&tab]; tab.diff_owner.unwrap_or(tab.pane_id) }; self.with_pane(owner, |editor| { if let Some(root) = &editor.active.snapshot_root { return root.clone(); } editor .path() .and_then(Path::parent) .filter(|path| !path.as_os_str().is_empty()) .unwrap_or_else(|| editor.project_root()) .to_path_buf() }) } pub fn word_wrap(&self) -> bool { self.diff .as_ref() .filter(|diff| diff.phase == Phase::View) .map_or(self.active.view.word_wrap, |diff| diff.word_wrap) } pub fn scroll_distance(&self) -> Option { self.scroll_distance } pub(crate) fn set_scroll_distance(&mut self, distance: Option) { self.scroll_distance = distance; } pub(crate) fn highlight_trailing_whitespace(&self) -> bool { self.highlight_trailing_whitespace } pub(crate) fn set_highlight_trailing_whitespace(&mut self, enabled: bool) { self.highlight_trailing_whitespace = enabled; } pub(crate) fn whitespace_characters(&self) -> &BTreeMap { &self.whitespace_characters } pub(crate) fn set_whitespace_characters(&mut self, characters: BTreeMap) { self.whitespace_characters = characters; } pub(crate) fn whitespace_scope(&self) -> WhitespaceScope { self.whitespace_scope } pub(crate) fn set_whitespace_scope(&mut self, scope: WhitespaceScope) { self.whitespace_scope = scope; } pub(crate) fn set_tab_spaces( &mut self, spaces: Option, languages: BTreeMap>, ) { self.tab_spaces = spaces; self.language_tab_spaces = languages; } pub(crate) fn set_diff_context_options(&mut self, context_lines: usize, expand_lines: usize) { self.diff_context_options = (context_lines, expand_lines); if let Some(diff) = &mut self.diff { diff.set_context_options(context_lines, expand_lines); } } pub fn set_diff_display_options(&mut self, view_mode: ViewMode, split_axis: SplitAxis) { self.diff_display_options = (view_mode, split_axis); } pub(crate) fn queue_checkout_refreshes(&mut self, follow_working: bool) { for index in 0..self.tab_order.len() { let tab = self.tab_order[index]; let owner = if tab == self.tab_id { self.diff_owner } else { self.tabs.get(&tab).and_then(|tab| tab.diff_owner) }; if let Some(owner) = owner { self.with_pane(owner, |editor| { if let Some(diff) = &mut editor.diff { diff.queue_checkout_refresh(follow_working); } }); } } } pub(crate) fn take_next_diff_request(&mut self) -> Option<(usize, u64, Operation)> { let tab = std::iter::once(self.tab_id) .chain( self.tab_order .iter() .copied() .filter(|tab| *tab != self.tab_id), ) .find(|tab| self.tab_diff(*tab).is_some_and(DiffState::request_ready))?; let owner = if tab == self.tab_id { self.diff_owner } else { self.tabs.get(&tab)?.diff_owner }?; self.with_pane(owner, |editor| { let (id, operation) = editor.take_diff_request()?; Some((tab, id, operation)) }) } pub(crate) fn take_diff_request(&mut self) -> Option<(u64, Operation)> { let mut operation = self .diff .as_mut() .or(self.parked_diff.as_mut())? .take_request()?; let buffers = match &mut operation { Operation::Compare { after, buffers, .. } if after.target == RevisionTarget::Working => { Some(buffers) } Operation::RefreshCheckout { follow_working: true, buffers, .. } if self .diff .as_ref() .or(self.parked_diff.as_ref()) .and_then(|diff| diff.after.as_ref()) .is_some_and(|after| after.target == RevisionTarget::Working) => { Some(buffers) } _ => None, }; if let Some(buffers) = buffers { *buffers = std::iter::once(&self.active) .chain(self.parked.values()) .filter(|buffer| buffer.dirty()) .filter_map(|buffer| { let path = buffer.identity.as_ref()?; let metadata = fs::symlink_metadata(buffer.path.as_ref()?).ok()?; (metadata.is_file() && fs::read_to_string(path).is_ok()) .then(|| (path.clone(), buffer.lines.join("\n"))) }) .collect(); } self.next_diff_request = self .next_diff_request .checked_add(1) .expect("diff request IDs exhausted"); let id = self.next_diff_request; self.outstanding_diff_request = Some(id); if let Operation::LoadFile { comparison_id, file_index, } = &operation && let Some(pending) = &mut self.pending_lsp_navigation && pending.bound.0 == *comparison_id && pending.file == *file_index { pending.request = Some(id); } Some((id, operation)) } pub(crate) fn apply_diff_result(&mut self, id: u64, result: Result) -> bool { if self .pending_lsp_navigation .as_ref() .is_some_and(|pending| !self.lsp_navigation_current(pending)) { self.pending_lsp_navigation = None; } if self.outstanding_diff_request != Some(id) { let owner = self .tabs .values() .find(|tab| tab.outstanding_diff_request == Some(id)) .and_then(|tab| tab.diff_owner); return owner.is_some_and(|owner| { self.with_pane(owner, |editor| editor.apply_diff_result(id, result)) }); } if let Some(owner) = self.diff_owner.filter(|owner| *owner != self.pane_id) { return self.with_pane(owner, |editor| editor.apply_diff_result(id, result)); } self.outstanding_diff_request = None; if self.diff.as_ref().is_some_and(DiffState::refresh_pending) && !matches!(&result, Ok(Outcome::CheckoutRefresh(None))) { self.finish_completion(); self.snippet = None; self.clear_hover(); if self.active.view.mode == Mode::Insert { self.finish_insert(); self.leave_insert(); } } let preserved_view = (self.diff.as_ref().is_some_and(|diff| { diff.refresh_pending() && diff .after .as_ref() .is_some_and(|after| after.target != RevisionTarget::Working) }) && !matches!(&result, Ok(Outcome::CheckoutRefresh(None)))) .then(|| self.active.view.inherit()); let Some(diff) = &mut self.diff else { return false; }; if diff.request_queued() && !diff.refresh_pending() { return false; } let refresh = diff.refresh_pending(); if !diff.apply_result(id, result) { return false; } if refresh { self.pending_lsp_navigation = None; self.diff_bound = None; } else if let Some(files) = &mut diff.files { files.set_view(self.file_explorer_view); } let obsolete_snapshot = self.active.diff_snapshot.is_some_and(|role| { self.diff.as_ref().and_then(|diff| diff.comparison_id) != Some(role.0) }); self.bind_diff_buffer(); let pinned_refresh = preserved_view.is_some(); if self.active.diff_snapshot.is_some() && let Some(mut view) = preserved_view { view.clamp(&self.active.lines); self.active.view = view; } let reviewed = self.diff.as_mut().and_then(|diff| { (refresh && diff .after .as_ref() .is_some_and(|after| after.target != RevisionTarget::Working)) .then(|| std::mem::take(&mut diff.reviewed_files)) }); self.refresh_live_diff(); if let Some(reviewed) = reviewed { self.diff .as_mut() .expect("comparison remains open") .reviewed_files = reviewed; } if pinned_refresh { self.diff .as_mut() .expect("comparison remains open") .follow_caret = false; } if refresh || obsolete_snapshot { self.prune_diff_snapshots(); } let completed_navigation = self .pending_lsp_navigation .as_ref() .is_some_and(|pending| pending.request == Some(id)); if completed_navigation && let Some(pending) = self.pending_lsp_navigation.take() && self.lsp_navigation_current(&pending) { let ready = self.diff.as_ref().is_some_and(|diff| { diff.error.is_none() && diff.comparison.as_ref().is_some_and(|comparison| { comparison .files .get(pending.file) .is_some_and(|file| file.loaded) }) }); if ready && let Err(error) = self.navigate_lsp_file( pending.file, pending.focus, pending.range, pending.target_range, ) { self.set_message(format!("LSP: definition failed: {error}")); } if !ready && let Some(error) = self.diff.as_ref().and_then(|diff| diff.error.as_ref()) { self.set_message(format!("LSP: definition failed: {error}")); } } true } fn open_diff(&mut self) { if let Some(owner) = self.diff_owner.filter(|owner| *owner != self.pane_id) { self.with_pane(owner, |editor| { editor.release_diff_buffers(); editor.diff = None; }); self.parked_diff = None; } self.diff_owner = Some(self.pane_id); self.release_diff_buffers(); self.diff_return_buffer = Some(self.active.id); self.count = None; self.clear_sequence(); self.palette = None; self.hover = None; self.startup_diagnostic = None; self.blur_file_picker(); if self.active.view.mode == Mode::Command { self.active.view.command_line.clear(); self.active.view.mode = Mode::Normal; } self.outstanding_diff_request = None; let mut diff = DiffState::new(); diff.word_wrap = self.active.view.word_wrap; diff.view_mode = self.diff_display_options.0; diff.split_axis = self.diff_display_options.1; diff.set_context_options(self.diff_context_options.0, self.diff_context_options.1); self.diff = Some(diff); } fn binding_mode(&self) -> Mode { if self.diff.is_some() && !self.diff_interacting() && !matches!(self.active.view.mode, Mode::Command | Mode::Search) { Mode::Normal } else { self.active.view.mode } } pub fn diff_editable(&self) -> bool { self.diff .as_ref() .is_some_and(|diff| diff.after_editable() && diff.live_buffer == Some(self.active.id)) } pub fn diff_editing(&self) -> bool { self.diff_editable() && self.diff_interacting() && self .diff .as_ref() .is_some_and(|diff| diff.focus == Focus::After) } pub fn diff_interacting(&self) -> bool { self.diff_buffer_focus().is_some_and(|focus| { self.diff .as_ref() .is_some_and(|diff| diff.focus == focus && diff.phase == Phase::View) }) } pub(crate) fn diff_buffer_focus(&self) -> Option { let (comparison, file, focus, buffer) = self.diff_bound?; self.diff .as_ref() .filter(|diff| { diff.comparison_id == Some(comparison) && diff.file == file && self.active.id == buffer }) .map(|_| focus) } pub(crate) fn diff_semantic_tokens(&self, focus: Focus) -> &[SemanticToken] { let Some(diff) = self.diff.as_ref() else { return &[]; }; let buffer = if focus == Focus::After && diff.after_editable() { diff.live_buffer .and_then(|id| self.buffer(id)) .filter(|buffer| Some(buffer.text_revision) == diff.live_revision) } else { let Some(comparison) = diff.comparison_id else { return &[]; }; std::iter::once(&self.active) .chain(self.parked.values()) .find(|buffer| buffer.diff_snapshot == Some((comparison, diff.file, focus))) }; buffer .and_then(|buffer| buffer.semantic_tokens.as_deref()) .unwrap_or_default() } pub(crate) fn mutation_blocked(&self) -> bool { self.active.diff_snapshot.is_some() || (self.diff.is_some() && !self.diff_editing()) } fn leave_diff_on_navigation(&mut self) { if self.diff.is_none() || self.diff_owner != Some(self.pane_id) { return; } if self .diff_bound .is_some_and(|bound| bound.3 != self.active.id) { if let Some(role) = self.active.diff_snapshot { if self .diff .as_ref() .is_none_or(|diff| diff.comparison_id != Some(role.0)) { self.diff_bound = None; self.diff = None; self.diff_owner = None; self.outstanding_diff_request = None; return; } self.diff_bound = Some((role.0, role.1, role.2, self.active.id)); if let Some(diff) = &mut self.diff { if diff.file != role.1 && let Some(path) = self.active.path.as_deref() { diff.select_file(path); } diff.focus = role.2; } } else { self.release_diff_buffers(); self.diff = None; self.diff_owner = None; self.outstanding_diff_request = None; } } } fn release_diff_buffers(&mut self) { if self.active.diff_snapshot.is_some() && let Some(id) = self .diff .as_ref() .and_then(|diff| diff.live_buffer) .or(self.diff_return_buffer) && let Some(buffer) = self.parked.remove(&id) { self.activate(buffer); } self.prune_diff_snapshots(); self.diff_bound = None; self.diff_return_buffer = None; } fn prune_diff_snapshots(&mut self) { self.parked.retain(|id, buffer| { buffer.diff_snapshot.is_none() || self .panes .iter() .any(|(pane_id, pane)| *pane_id != self.pane_id && pane.buffer_id == *id) || buffer.diff_snapshot.is_some_and(|role| { self.tabs.values().any(|tab| { tab.diff() .is_some_and(|diff| diff.comparison_id == Some(role.0)) }) }) }); for pane in self.panes.values_mut() { pane.views .retain(|id, _| *id == self.active.id || self.parked.contains_key(id)); } } fn bind_diff_buffer(&mut self) { if self.diff_prompt_mode.is_some() { return; } let Some(diff) = &self.diff else { return }; if diff.phase != Phase::View || !diff.selected_file_loaded() { return; } let Some(comparison_id) = diff.comparison_id else { return; }; if diff.pane_layout() == crate::diff::PaneLayout::Empty || (diff.rows.is_empty() && !diff.after_editable()) { if self.active.diff_snapshot.is_some() && let Some(buffer) = self .diff_return_buffer .and_then(|id| self.parked.remove(&id)) { let palette = self.palette.take(); self.activate(buffer); self.palette = palette; } self.diff_bound = None; return; } let Some(comparison) = &diff.comparison else { return; }; let Some(file) = comparison.files.get(diff.file) else { return; }; let focus = if diff.focus == Focus::Files { self.diff_buffer_focus().unwrap_or_else(|| { if diff.after_editable() { Focus::After } else { diff.first_text_focus() } }) } else { diff.focus }; let key = (comparison_id, diff.file, focus); let writable = focus == Focus::After && diff.after_editable(); if self.diff_bound.is_some_and(|(id, file, side, buffer)| { (id, file, side) == key && buffer == self.active.id && if writable { self.active.diff_snapshot.is_none() && diff.live_buffer == Some(self.active.id) } else { self.active.diff_snapshot == Some(key) } }) { return; } let synchronized = self.diff_bound.and_then(|(id, file, from, buffer)| { if id != comparison_id || file != diff.file || from == focus || buffer != self.active.id { return None; } let target = self.active.view.targets[self.active.view.primary]; let column = target .preferred_columns .map(|columns| columns.1) .unwrap_or_else(|| { display_column(&self.active.lines[target.active.row], target.active.column) }); diff.corresponding_row(from, focus, target.active.row) .map(|row| (row, column)) }); // Binding a comparison side is not a user departure from its palette. let palette = self.palette.take(); if writable { let path = comparison.root.join(&file.path); let root = comparison.root.canonicalize(); let opened = (|| -> io::Result<()> { let metadata = fs::symlink_metadata(&path)?; if !metadata.is_file() { return Err(io::Error::other("Working file is not a regular file")); } let identity = path.canonicalize()?; if !identity.starts_with(root?) { return Err(io::Error::other("Working file escapes repository root")); } if self.active.identity.as_ref() != Some(&identity) { self.edit_buffer(path, false)?; } Ok(()) })(); if let Err(error) = opened { let diff = self.diff.as_mut().expect("diff remains open"); if let Some(comparison) = &mut diff.comparison { comparison .editable_paths .remove(&comparison.files[diff.file].path); } diff.live_buffer = None; diff.live_revision = None; diff.error = Some(format!("Open failed: {error}")); self.palette = palette; self.bind_diff_buffer(); return; } let diff = self.diff.as_mut().expect("diff remains open"); diff.live_buffer = Some(self.active.id); diff.live_revision = None; } else { let existing = std::iter::once(&self.active) .chain(self.parked.values()) .find(|buffer| buffer.diff_snapshot == Some(key)) .map(|buffer| buffer.id); if let Some(id) = existing { self.switch_buffer(id); } else { let buffer = self.new_diff_snapshot(key.1, focus); self.add_buffer(buffer); } } self.diff_bound = Some((key.0, key.1, key.2, self.active.id)); self.palette = palette; if let Some((row, wanted)) = synchronized { let row = row.min(self.active.lines.len() - 1); let cursor = Cursor { row, column: column_at_display(&self.active.lines[row], wanted, self.max_column(row)), }; let primary = self.active.view.primary; self.active.view.targets[primary].active = cursor; if self.active.view.mode != Mode::Visual { self.active.view.targets[primary].anchor = cursor; } self.active.view.targets[primary].preferred_columns = Some((wanted, wanted)); self.active.view.search_match = None; self.normalize_targets(); } self.diff.as_mut().expect("diff remains open").follow_caret = true; } fn new_diff_snapshot(&mut self, file_index: usize, focus: Focus) -> Buffer { let side = usize::from(focus == Focus::After); let diff = self.diff.as_ref().expect("snapshot comparison"); let comparison = diff.comparison.as_ref().expect("snapshot comparison"); let file = &comparison.files[file_index]; let relative = if focus == Focus::Before { file.old_path.as_ref().unwrap_or(&file.path) } else { &file.path }; let source = comparison.root.join(relative); let root = comparison.root.clone(); let lines = diff.snapshot_lines_at(file_index, focus); let key = ( diff.comparison_id.expect("snapshot comparison"), file_index, focus, ); let directory = diff.snapshot_roots[side].clone().unwrap_or_else(|| { loop { let nonce = TEMP_ID.fetch_add(1, Ordering::Relaxed); let directory = root.join(format!(".vamp-readonly-{}-{nonce}", std::process::id())); if !directory.exists() && !std::iter::once(&self.active) .chain(self.parked.values()) .any(|buffer| { buffer .identity .as_ref() .is_some_and(|path| path.starts_with(&directory)) || buffer .document() .path .is_some_and(|path| path.starts_with(&directory)) }) { break directory; } } }); let document_path = directory.join(relative); self.diff .as_mut() .expect("snapshot comparison") .snapshot_roots[side] = Some(directory); let mut buffer = Buffer::open(self.next_buffer_id, None, None, false).expect("unnamed snapshot"); buffer.diff_snapshot = Some(key); buffer.path = Some(source); buffer.snapshot_document_path = Some(document_path); buffer.snapshot_root = Some(root); buffer.lines = lines; buffer } pub(crate) fn refresh_live_diff(&mut self) { for diff in self .diff .iter_mut() .chain(self.parked_diff.iter_mut()) .chain( self.tabs .values_mut() .flat_map(|tab| tab.diff.iter_mut().chain(tab.parked_diff.iter_mut())), ) { let buffer = diff.live_buffer.and_then(|id| { if id == self.active.id { Some(&self.active) } else { self.parked.get(&id) } }); if let Some(buffer) = buffer && diff.live_after_available() && diff.live_revision != Some(buffer.text_revision) { diff.refresh_after(&buffer.lines, buffer.text_revision); diff.follow_caret = true; } } let Some(focus) = self.diff_buffer_focus() else { return; }; let mut moved = false; for index in 0..self.active.view.targets.len() { let cursor = self.active.view.targets[index].active; let Some(row) = self .diff .as_ref() .unwrap() .clamp_source_row(focus, cursor.row) else { continue; }; if row != cursor.row { self.active.view.targets[index].active = Cursor { row, column: cursor.column.min(self.max_column(row)), }; self.active.view.targets[index].preferred_columns = None; moved = true; } } if moved { self.active.view.search_match = None; self.normalize_targets(); } } pub(crate) fn scroll_diff(&mut self, focus: Focus, down: bool) { self.pane_close_picker_origin = None; if self.diff.as_ref().is_some_and(|diff| diff.focus != focus) { self.pending_lsp_navigation = None; } if let Some(owner) = self.diff_owner.filter(|owner| *owner != self.pane_id) { self.with_pane(owner, |editor| editor.scroll_diff(focus, down)); return; } if let Some(diff) = &mut self.diff && diff.phase == Phase::View { diff.focus = focus; diff.normalize_focus(); diff.follow_caret = false; if let Some(files) = &mut diff.files { files.focused = diff.files_visible && diff.focus == Focus::Files; } diff.navigate(down); } self.bind_diff_buffer(); self.refresh_live_diff(); if let Some(diff) = &mut self.diff { diff.follow_caret = false; } } pub(crate) fn scroll_diff_horizontal(&mut self, focus: Focus, right: bool, width: usize) { self.pane_close_picker_origin = None; if let Some(owner) = self.diff_owner.filter(|owner| *owner != self.pane_id) { self.with_pane(owner, |editor| { editor.scroll_diff_horizontal(focus, right, width) }); return; } if let Some(diff) = &mut self.diff && diff.phase == Phase::View { diff.scroll_horizontal(focus, right, width); } } pub(crate) fn set_diff_viewport( &mut self, comparison_id: u64, file: usize, top: usize, left_before: usize, left_after: usize, ) { if let Some(diff) = self.diff.as_mut().or(self.parked_diff.as_mut()) && diff.comparison_id == Some(comparison_id) && diff.file == file { diff.top = top; diff.left_before = left_before; diff.left_after = left_after; } } fn handle_diff(&mut self, input: Input) -> Option { if self.palette.is_some() || self.diff_prompt_mode.is_some() || self.startup_diagnostic.is_some() || self.hover.is_some() { return self.handle_input(input); } let phase = self.diff.as_ref().expect("active diff").phase; let files_focused = phase == Phase::View && self.diff.as_ref().unwrap().focus == Focus::Files; if phase != Phase::View && input == Input::Escape { return self.execute_action(Action::DiffClose.entry(), ""); } let text_focused = phase != Phase::View || (files_focused && self .diff .as_ref() .and_then(|diff| diff.files.as_ref()) .is_some_and(|files| files.searching || files.view == FileView::List)); if text_focused && self.sequence_pending().is_none() && matches!(input, Input::Char(ch) if !ch.is_control()) { if !self.handle_selection_binding(&input) { self.diff.as_mut().unwrap().handle(input); } return None; } if self .bindings .get(&self.binding_mode()) .and_then(|keys| keys.get(&input)) .is_some_and(|binding| { matches!( binding, Binding::Action( Action::PaneSplitHorizontal | Action::PaneSplitVertical | Action::PaneFocusLeft | Action::PaneFocusDown | Action::PaneFocusUp | Action::PaneFocusRight | Action::PaneClose | Action::PaneForceClose | Action::TabNew | Action::TabPrevious | Action::TabNext | Action::TabClose | Action::TabForceClose ) ) }) { return self.handle_input(input); } if (self.diff_interacting() && self.active.view.mode != Mode::Normal) || self.count.is_some() || self.sequence_pending().is_some() || self.g_pending || self.line_pending.is_some() || self.seek_pending.is_some() { return self.handle_input(input); } if self.diff_interacting() { if matches!(input, Input::Char('[' | ']')) { let focus = self.diff_buffer_focus().unwrap(); let row = self.cursor().row; self.diff.as_mut().unwrap().expand_source_excerpt( focus, input == Input::Char(']'), row, ); return None; } if !matches!(input, Input::Char('\t') | Input::BackTab) { return self.handle_input(input); } } if files_focused && input == Input::Ctrl('w') && input != self.leader && !self.has_sequence_prefix(self.binding_mode(), &input) { self.close_file_picker(); return None; } if files_focused && self.handle_tree_binding(&input) { return None; } if !self.diff_interacting() && self.handle_selection_binding(&input) { return None; } if files_focused && input == Input::Char('c') && self .diff .as_ref() .and_then(|diff| diff.files.as_ref()) .is_some_and(|files| files.view == FileView::Tree && !files.searching) && input != self.leader && !self.has_sequence_prefix(self.binding_mode(), &input) && !self .bindings .get(&self.binding_mode()) .is_some_and(|keys| keys.contains_key(&input)) { return self.execute_action(Action::DiffReviewToggle.entry(), ""); } if self.sequence_pending().is_some() || (phase == Phase::View && (input == self.leader || self.has_sequence_prefix(self.binding_mode(), &input))) || self .bindings .get(&self.binding_mode()) .and_then(|keys| keys.get(&input)) .is_some_and(|binding| { matches!( binding, Binding::Command(_) | Binding::Action( Action::Command | Action::Palette | Action::FilePicker | Action::FileExplorer | Action::DiffClose | Action::DiffModeToggle | Action::DiffViewToggle | Action::DiffOrientationToggle | Action::DiffReviewToggle | Action::WordWrapToggle | Action::GitBlameToggle | Action::Quit | Action::ForceQuit | Action::Restart | Action::ForceRestart | Action::Plugins | Action::Buffers ) ) }) { return self.handle_input(input); } let escape = input == Input::Escape; let return_focus = files_focused .then(|| self.diff_bound.map(|bound| bound.2)) .flatten(); self.diff.as_mut().unwrap().handle(input); if files_focused && escape && self.diff.as_ref().unwrap().focus != Focus::Files && let Some(focus) = return_focus { let diff = self.diff.as_mut().unwrap(); diff.focus = focus; diff.normalize_focus(); } None } fn buffer(&self, id: usize) -> Option<&Buffer> { if id == self.active.id { Some(&self.active) } else { self.parked.get(&id) } } pub fn semantic_tokens(&self) -> &[SemanticToken] { self.active.semantic_tokens.as_deref().unwrap_or_default() } pub(crate) fn validate_semantic_tokens( &self, buffer_id: usize, revision: u64, tokens: Option<&[SemanticToken]>, ) -> Result { let Some(buffer) = self.buffer(buffer_id) else { return Ok(false); }; if buffer.text_revision != revision { return Ok(false); } let Some(tokens) = tokens else { return Ok(true); }; if tokens.len() > MAX_EDIT_BYTES / std::mem::size_of::() { return Err("Semantic tokens exceed 4 MiB".into()); } let mut previous = None; let mut row_length = None; for token in tokens { if previous.is_some_and(|start| token.start < start) { return Err("Semantic token starts must be sorted".into()); } if token.start.row != token.end.row || token.start >= token.end { return Err("Semantic tokens must have nonempty single-line ranges".into()); } if token.modifiers & !1023 != 0 { return Err("Semantic token modifiers are not canonical".into()); } if row_length.is_none_or(|(row, _)| row != token.start.row) { let line = buffer .lines .get(token.start.row) .ok_or("Semantic token position is outside the document")?; row_length = Some((token.start.row, grapheme_count(line))); } if token.end.column > row_length.unwrap().1 { return Err("Semantic token position is outside the document".into()); } previous = Some(token.start); } Ok(true) } pub fn replace_semantic_tokens( &mut self, buffer_id: usize, revision: u64, tokens: Option>, ) -> Result { if !self.validate_semantic_tokens(buffer_id, revision, tokens.as_deref())? { return Ok(false); } Ok(self.replace_validated_semantic_tokens(buffer_id, revision, tokens)) } /// Only session's fully prevalidated batch may use this storage path. pub(crate) fn replace_validated_semantic_tokens( &mut self, buffer_id: usize, revision: u64, tokens: Option>, ) -> bool { let buffer = if buffer_id == self.active.id { &mut self.active } else if let Some(buffer) = self.parked.get_mut(&buffer_id) { buffer } else { return false; }; if buffer.text_revision != revision { return false; } let tokens = tokens.filter(|tokens| !tokens.is_empty()); match (&buffer.semantic_tokens, &tokens) { (None, None) => return false, (Some(current), Some(next)) if Arc::ptr_eq(current, next) => return false, _ => {} } buffer.semantic_tokens = tokens; true } pub fn clear_semantic_tokens(&mut self) { self.active.semantic_tokens = None; for buffer in self.parked.values_mut() { buffer.semantic_tokens = None; } } pub fn diagnostics(&self) -> impl Iterator { self.active .diagnostics .values() .filter(|annotations| { self.active.view.mode != Mode::Insert && annotations.revision == self.active.text_revision }) .flat_map(|annotations| &annotations.items) } pub fn diagnostic_at_cursor(&self) -> Option<&Diagnostic> { let cursor = self.cursor(); let position = TextPosition { row: cursor.row, column: cursor.column, }; self.diagnostics().find(|diagnostic| { diagnostic.start <= position && (position < diagnostic.end || (diagnostic.start == diagnostic.end && position == diagnostic.start)) }) } pub(crate) fn validate_diagnostics( &self, buffer_id: usize, revision: u64, items: &[Diagnostic], ) -> Result { let Some(buffer) = self.buffer(buffer_id) else { return Ok(false); }; if buffer.text_revision != revision { return Ok(false); } let mut bytes = 0usize; for diagnostic in items { for position in [diagnostic.start, diagnostic.end] { if buffer .lines .get(position.row) .is_none_or(|line| position.column > grapheme_count(line)) { return Err("Diagnostic position is outside the document".into()); } } if diagnostic.end < diagnostic.start { return Err("Diagnostic range is reversed".into()); } if diagnostic.message.chars().any(char::is_control) { return Err("Diagnostic messages cannot contain controls".into()); } bytes = bytes .checked_add(diagnostic.message.len()) .ok_or("Diagnostics exceed 4 MiB")?; if bytes > MAX_EDIT_BYTES { return Err("Diagnostics exceed 4 MiB".into()); } } Ok(true) } pub fn replace_diagnostics( &mut self, owner: &str, buffer_id: usize, revision: u64, items: Vec, ) -> Result { if !self.validate_diagnostics(buffer_id, revision, &items)? { return Ok(false); } Ok(self.replace_validated_diagnostics(owner, buffer_id, revision, items)) } /// Only session's fully prevalidated batch may use this storage path. pub(crate) fn replace_validated_diagnostics( &mut self, owner: &str, buffer_id: usize, revision: u64, items: Vec, ) -> bool { let buffer = if buffer_id == self.active.id { &mut self.active } else if let Some(buffer) = self.parked.get_mut(&buffer_id) { buffer } else { return false; }; if buffer.text_revision != revision { return false; } if items.is_empty() { return buffer.diagnostics.remove(owner).is_some(); } if buffer.diagnostics.get(owner).is_some_and(|annotations| { annotations.revision == revision && annotations.items == items }) { return false; } buffer .diagnostics .insert(owner.to_owned(), AnnotationSet { revision, items }); true } pub fn clear_plugin_diagnostics(&mut self, owner: &str) { self.active.diagnostics.remove(owner); for buffer in self.parked.values_mut() { buffer.diagnostics.remove(owner); } } /// Reuses loaded (including dirty) buffers and leaves the source unchanged on failure. pub fn navigate_to(&mut self, path: PathBuf, position: TextPosition) -> Result<(), String> { self.pane_close_picker_origin = None; self.pending_lsp_navigation = None; self.finish_completion(); self.snippet = None; let source = self.jump_location(); self.cancel_sequence(); self.clear_hover(); if path.as_os_str().is_empty() || path.to_string_lossy().chars().any(char::is_control) { return Err("Navigation path must be nonempty and contain no controls".into()); } let snapshot = std::iter::once(&self.active) .chain(self.parked.values()) .find(|buffer| buffer.snapshot_document_path.as_ref() == Some(&path)) .map(|buffer| buffer.id); if let Some(id) = snapshot { self.switch_buffer(id); } else { self.edit_buffer(path, false) .map_err(|error| error.to_string())?; } self.place_navigation(position); self.record_jump(source); self.leave_diff_on_navigation(); Ok(()) } /// Native LSP positions are UTF-16; plugin navigation remains grapheme-based. pub(crate) fn navigate_lsp_to( &mut self, path: PathBuf, range: Utf16Range, target_range: Option, ) -> Result<(), String> { self.pending_lsp_navigation = None; self.active.view.lsp_center.set(None); if path.as_os_str().is_empty() || path.to_string_lossy().chars().any(char::is_control) { return Err("Navigation path must be nonempty and contain no controls".into()); } if let Some((file, focus)) = self.lsp_diff_target(&path)? { if !self .diff .as_ref() .expect("comparison target") .comparison .as_ref() .expect("comparison target") .files[file] .loaded { let bound = self .diff_bound .ok_or("LSP navigation requires a diff text pane")?; self.pending_lsp_navigation = Some(PendingLspNavigation { tab: self.tab_id, pane: self.pane_id, source: self.jump_location(), revision: self.text_revision(), bound, file, focus, range, target_range, request: None, }); self.diff .as_mut() .expect("comparison target") .load_file(file); return Ok(()); } return self.navigate_lsp_file(file, focus, range, target_range); } let (buffer, id) = self.preflight_lsp_buffer(path)?; let lines = buffer .as_ref() .unwrap_or_else(|| self.buffer(id).expect("loaded target")); let position = lsp_navigation_position(&lines.lines, range, target_range)?; self.finish_lsp_navigation(buffer, id, position, self.diff.is_some()); Ok(()) } fn lsp_diff_target(&self, path: &Path) -> Result, String> { let Some(diff) = self.diff.as_ref().filter(|diff| diff.phase == Phase::View) else { return Ok(None); }; let Some(comparison) = &diff.comparison else { return Ok(None); }; let identity = path_identity(path).map_err(|error| error.to_string())?; let virtual_side = diff .snapshot_roots .iter() .enumerate() .find_map(|(side, root)| { let root = path_identity(root.as_ref()?).ok()?; identity.strip_prefix(root).ok().map(|relative| { ( if side == 0 { Focus::Before } else { Focus::After }, relative.to_path_buf(), ) }) }); let focus = virtual_side.as_ref().map_or_else( || self.diff_buffer_focus().unwrap_or(Focus::After), |(focus, _)| *focus, ); let found = comparison .files .iter() .enumerate() .find_map(|(index, file)| { let relative = if focus == Focus::Before { file.old_path.as_ref().unwrap_or(&file.path) } else { &file.path }; let matches = if let Some((_, target)) = &virtual_side { relative == target } else { path_identity(&comparison.root.join(relative)).ok().as_ref() == Some(&identity) }; matches.then_some((index, focus)) }); if virtual_side.is_some() && found.is_none() { return Err("Historical navigation target is not available in this comparison".into()); } Ok(found) } fn lsp_navigation_current(&self, pending: &PendingLspNavigation) -> bool { self.tab_id == pending.tab && self.pane_id == pending.pane && self.jump_location() == pending.source && self.text_revision() == pending.revision && self.diff_bound == Some(pending.bound) && self.diff_interacting() && self.diff.as_ref().is_some_and(|diff| { diff.comparison_id == Some(pending.bound.0) && diff.file == pending.bound.1 }) } fn preflight_lsp_buffer(&self, path: PathBuf) -> Result<(Option, usize), String> { let identity = path_identity(&path).map_err(|error| error.to_string())?; let loaded = std::iter::once(&self.active) .chain(self.parked.values()) .find(|buffer| { buffer.identity.as_ref() == Some(&identity) || buffer .snapshot_document_path .as_ref() .is_some_and(|snapshot| { path_identity(snapshot).ok().as_ref() == Some(&identity) }) }); if let Some(buffer) = loaded { return Ok((None, buffer.id)); } let file = fs::File::open(&path).map_err(|error| error.to_string())?; let metadata = file.metadata().map_err(|error| error.to_string())?; if !metadata.is_file() { return Err("Definition target is not a regular file".into()); } if metadata.len() > MAX_EDIT_BYTES as u64 { return Err("Definition target exceeds 4 MiB".into()); } let mut text = String::new(); file.take((MAX_EDIT_BYTES + 1) as u64) .read_to_string(&mut text) .map_err(|error| error.to_string())?; if text.len() > MAX_EDIT_BYTES { return Err("Definition target exceeds 4 MiB".into()); } let buffer = Buffer::from_text( self.next_buffer_id, Some(path), Some(identity), &text, Some(FileStamp::from_metadata(&metadata).map_err(|error| error.to_string())?), ) .map_err(|error| error.to_string())?; Ok((Some(buffer), self.next_buffer_id)) } fn navigate_lsp_file( &mut self, file_index: usize, focus: Focus, range: Utf16Range, target_range: Option, ) -> Result<(), String> { let diff = self.diff.as_ref().ok_or("Comparison is closed")?; let comparison = diff.comparison.as_ref().ok_or("Comparison is closed")?; let file = comparison .files .get(file_index) .ok_or("Changed file is no longer available")?; if matches!( (file.status, focus), (crate::diff::FileStatus::Added, Focus::Before) | (crate::diff::FileStatus::Removed, Focus::After) ) { return Err("Definition target does not exist on this comparison side".into()); } if file .notice .as_deref() .is_some_and(|notice| notice.contains("Binary or non-UTF-8")) { return Err("Definition target is binary or non-UTF-8".into()); } let writable = focus == Focus::After && comparison.editable_paths.contains(&file.path) && file.status != crate::diff::FileStatus::Removed && file .notice .as_deref() .is_none_or(|notice| !notice.contains("Binary or non-UTF-8")); let key = ( diff.comparison_id.ok_or("Comparison is closed")?, file_index, focus, ); let (buffer, id) = if writable { self.preflight_lsp_buffer(comparison.root.join(&file.path))? } else if let Some(buffer) = std::iter::once(&self.active) .chain(self.parked.values()) .find(|buffer| buffer.diff_snapshot == Some(key)) { (None, buffer.id) } else { let buffer = self.new_diff_snapshot(file_index, focus); (Some(buffer), self.next_buffer_id) }; let target = buffer .as_ref() .unwrap_or_else(|| self.buffer(id).expect("loaded target")); let position = lsp_navigation_position(&target.lines, range, target_range)?; let row = position.row; let extent = target_range.unwrap_or(range); let first = extent.start.0; let last = extent.end.0 - usize::from(extent.end.1 == 0 && extent.end.0 > extent.start.0); let diff = self.diff.as_ref().expect("comparison target"); let comparison = diff.comparison.as_ref().expect("comparison target"); let file = &comparison.files[file_index]; let live = if writable { Some(target) } else if focus == Focus::Before && comparison.editable_paths.contains(&file.path) { let identity = path_identity(&comparison.root.join(&file.path)).ok(); std::iter::once(&self.active) .chain(self.parked.values()) .find(|buffer| identity.is_some() && buffer.identity == identity) } else { None }; let live_id = live.map(|buffer| buffer.id); let changed = if let Some(live) = live.filter(|live| { diff.file != file_index || diff.live_buffer != Some(live.id) || diff.live_revision != Some(live.text_revision) }) { let eof = live.lines.len() > 1 && live.lines.last().is_some_and(String::is_empty); let content = &live.lines[..live.lines.len() - usize::from(eof)]; crate::diff::line_hunks(&file.before, file.before_eof, content, eof) .iter() .any(|hunk| { let (start, len) = if focus == Focus::Before { (hunk.before_start, hunk.before_len) } else { (hunk.after_start, hunk.after_len) }; len > 0 && start <= last && first < start.saturating_add(len) }) } else { diff.source_range_changed(file_index, focus, first, last) }; if !changed { self.finish_lsp_navigation(buffer, id, position, true); return Ok(()); } let source = self.jump_location(); if let Some(buffer) = buffer { self.add_buffer(buffer); } else { self.switch_buffer(id); } let diff = self.diff.as_mut().expect("comparison target"); if diff.file != file_index { diff.select_file_index(file_index); } diff.focus = focus; diff.normalize_focus(); if let Some(live) = live_id { diff.live_buffer = Some(live); diff.live_revision = None; } self.diff_bound = Some((key.0, key.1, key.2, id)); self.refresh_live_diff(); let diff = self.diff.as_mut().expect("comparison target"); diff.focus = focus; diff.normalize_focus(); diff.reveal_source_row(focus, row); self.place_navigation(position); self.request_lsp_center(); self.record_jump(source); Ok(()) } fn finish_lsp_navigation( &mut self, buffer: Option, id: usize, position: TextPosition, new_tab: bool, ) { let source = self.jump_location(); if new_tab { self.new_tab(); } if let Some(buffer) = buffer { self.add_buffer(buffer); } else { self.switch_buffer(id); } self.place_navigation(position); self.request_lsp_center(); self.record_jump(source); } fn place_navigation(&mut self, position: TextPosition) { self.reveal_caret(); self.park_source(); self.active.view.mode = Mode::Normal; let row = position.row.min(self.active.lines.len() - 1); let column = position .column .min(grapheme_count(&self.active.lines[row]).saturating_sub(1)); self.active.view.targets = vec![Target::caret(Cursor { row, column })]; self.active.view.primary = 0; self.active.view.search_match = None; self.active.view.visual_motions = None; self.normalize_targets(); } fn follow_link(&mut self) -> Result<(), String> { let departure = self.jump_location(); if self .active .path .as_deref() .is_some_and(|path| !crate::syntax::is_markdown_path(path)) { return Err("follow_link requires a Markdown document".into()); } let source = self.active.lines.join("\n"); let cursor = self.cursor(); let target = crate::markdown::link_at( &source, TextPosition { row: cursor.row, column: cursor.column, }, )?; let Some(path) = target.path else { let row = match target.fragment { Some(fragment) => crate::markdown::heading_row(&source, &fragment)?, None => 0, }; self.place_navigation(TextPosition { row, column: 0 }); self.record_jump(departure); return Ok(()); }; let path = if path.is_absolute() { path } else { let source_path = self .active .identity .as_deref() .or(self.active.path.as_deref()) .ok_or("Relative links require a named Markdown document")?; source_path .parent() .ok_or("Markdown document has no parent directory")? .join(path) }; if path.to_string_lossy().chars().any(char::is_control) { return Err("Link path cannot contain control characters".into()); } let identity = path_identity(&path).map_err(|error| error.to_string())?; let heading_row = |buffer: &Buffer| { target.fragment.as_deref().map_or(Ok(0), |fragment| { crate::markdown::heading_row(&buffer.lines.join("\n"), fragment) }) }; let loaded = std::iter::once(&self.active) .chain(self.parked.values()) .find(|buffer| buffer.identity.as_ref() == Some(&identity)); let row = if let Some(buffer) = loaded { let row = heading_row(buffer)?; let id = buffer.id; self.switch_buffer(id); row } else { let buffer = Buffer::open(self.next_buffer_id, Some(path), Some(identity), false) .map_err(|error| format!("Cannot open link target: {error}"))?; let row = heading_row(&buffer)?; self.add_buffer(buffer); row }; self.place_navigation(TextPosition { row, column: 0 }); self.record_jump(departure); Ok(()) } pub fn lines(&self) -> &[String] { &self.active.lines } /// Changes only when this buffer's text changes, independent of cursor/view state. pub fn text_revision(&self) -> u64 { self.active.text_revision } pub fn cursor(&self) -> Cursor { self.active.view.targets[self.active.view.primary].active } /// Navigation requests caret following even when its logical position stays unchanged. pub(crate) fn caret_reveal_revision(&self) -> u64 { self.active.view.caret_reveal_revision } fn request_lsp_center(&self) { self.active.view.lsp_center.set(Some(( self.text_revision(), self.caret_reveal_revision(), self.cursor(), ))); } /// Consume only when the destination has a usable text viewport. pub(crate) fn take_lsp_center(&self) -> bool { self.active.view.lsp_center.take() == Some(( self.text_revision(), self.caret_reveal_revision(), self.cursor(), )) } fn reveal_caret(&mut self) { self.active.view.lsp_center.set(None); self.active.view.caret_reveal_revision = self.active.view.caret_reveal_revision.wrapping_add(1); } pub fn mode(&self) -> Mode { self.active.view.mode } /// Read-only display state for the overlay; opening it does not change mode. pub fn palette(&self) -> Option<&Palette> { self.palette.as_ref() } pub fn startup_diagnostic(&self) -> Option<&StartupDiagnostic> { self.startup_diagnostic.as_ref() } pub(crate) fn show_startup_diagnostic(&mut self, text: String) { self.finish_completion(); self.snippet = None; let lines = split_lines(&text); let max_column = lines .iter() .map(|line| line.graphemes(true).count()) .max() .unwrap_or(0); self.startup_diagnostic = Some(StartupDiagnostic { lines, top: 0, left: 0, max_column, }); } /// Borrowed live-completion state; original document bookkeeping stays private. pub fn completion(&self) -> Option<&Completion> { self.completion.as_ref().map(|state| &state.popup) } /// End-exclusive ranges of the active argument, including its mirrored occurrences. pub fn snippet_selections(&self) -> &[Selection] { self.snippet .as_ref() .map_or(&[], |state| state.selections.as_slice()) } /// Validates the entire response before changing text, history, or popup state. pub fn validate_completion(&self, items: &[CompletionItem]) -> Result<(), String> { validate_completion_items(&self.active.lines, items) } pub fn show_completion(&mut self, mut items: Vec) -> Result<(), String> { if self.mutation_blocked() { return Err("Diff pane is read-only".into()); } if self.mode() != Mode::Insert || self.palette.is_some() || self.startup_diagnostic.is_some() || self .file_picker .as_ref() .is_some_and(|picker| picker.focused) { return Err("Completion requires Insert editor focus".into()); } self.validate_completion(&items)?; self.finish_completion(); self.snippet = None; if items.is_empty() { self.set_message("No completions"); return Ok(()); } for item in &mut items { for edit in &mut item.edits { if let Cow::Owned(normalized) = normalize_newlines(&edit.text) { edit.text = normalized; } } } let original = self.snapshot(); self.preview_completion(&original, &items[0]); self.completion = Some(CompletionState { popup: Completion { items, selected: 1 }, original, }); Ok(()) } fn preview_completion(&mut self, original: &Snapshot, item: &CompletionItem) { let offsets = line_offsets(&original.lines); let mut edits = completion_edits(&original.lines, &offsets, original.primary, item); let primary_start = edits[item.primary_edit].start; edits.sort_by_key(|edit| (edit.start, edit.end)); let start = translate_edit_offset(primary_start, false, edits.iter()); self.snippet = item.snippet.as_ref().map(|snippet| SnippetState { tabstops: snippet .tabstops .iter() .map(|stop| SnippetTabstop { index: stop.index, ancestors: stop.ancestors.clone(), ranges: stop .ranges .iter() .map(|range| start + range.start..start + range.end) .collect(), }) .collect(), active: 0, selected: true, selections: Vec::new(), replacement: start..start + item.edits[item.primary_edit].text.len(), recipe_step: None, }); self.apply_edits_from(edits, Some(original), true); self.select_snippet_stop(); } fn cycle_completion(&mut self, next: bool) { let mut state = self.completion.take().expect("completion is active"); let count = state.popup.items.len() + 1; state.popup.selected = if next { (state.popup.selected + 1) % count } else { (state.popup.selected + count - 1) % count }; if state.popup.selected == 0 { self.snippet = None; if self.active.lines != state.original.lines { self.active.text_changed(); self.active.view.search_match = None; } self.active.lines.clone_from(&state.original.lines); self.clamp_other_views(); self.active.view.targets.clone_from(&state.original.targets); self.active.view.primary = state.original.primary; } else { self.preview_completion( &state.original, &state.popup.items[state.popup.selected - 1], ); } self.completion = Some(state); } /// Retains the displayed candidate and records only its resolved native edits. fn finish_completion(&mut self) { let Some(state) = self.completion.take() else { return; }; if state.popup.selected == 0 || (state.original.lines == self.active.lines && self.snippet.is_none()) { return; } let item = state .popup .items .into_iter() .nth(state.popup.selected - 1) .expect("selected completion exists"); let caret = state.original.targets[state.original.primary].active; let origin = grapheme_offset(&state.original.lines, caret) as isize; let offsets = line_offsets(&state.original.lines); let mut batch = completion_edits( &state.original.lines, &offsets, state.original.primary, &item, ); let start = batch[item.primary_edit].start; batch.sort_by_key(|edit| (edit.start, edit.end)); let start = translate_edit_offset(start, false, batch.iter()); drop(batch); let offsets = line_offsets(&self.active.lines); let caret = cursor_offset(&self.active.lines, &offsets, self.cursor()) - start; if let (Some(snippet), Some(recipe)) = (&mut self.snippet, &self.active.view.pending_edit) { snippet.recipe_step = Some(recipe.steps.len()); } let edits = item .edits .into_iter() .enumerate() .map(|(index, edit)| RelativeCompletionEdit { start: grapheme_offset( &state.original.lines, Cursor { row: edit.start.row, column: edit.start.column, }, ) as isize - origin, end: grapheme_offset( &state.original.lines, Cursor { row: edit.end.row, column: edit.end.column, }, ) as isize - origin, text: edit.text, context: (index != item.primary_edit) .then(|| state.original.lines[edit.start.row..=edit.end.row].to_vec()), }) .collect(); self.push_insert_step(InsertStep::Completion { primary_edit: item.primary_edit, edits, caret, }); } fn select_snippet_stop(&mut self) { let Some(mut state) = self.snippet.take() else { return; }; let offsets = line_offsets(&self.active.lines); let stop = &state.tabstops[state.active]; let cursor = offset_cursor(&self.active.lines, &offsets, stop.ranges[0].start, true); self.active.view.targets[self.active.view.primary] = Target::caret(cursor); state.selected = true; state.selections.clear(); state .selections .extend(stop.ranges.iter().map(|range| Selection { start: offset_cursor(&self.active.lines, &offsets, range.start, true), end: offset_cursor(&self.active.lines, &offsets, range.end, true), })); self.sync_snippet_recipe(&state, false); self.reveal_caret(); if stop.index != 0 { self.snippet = Some(state); } } fn navigate_snippet(&mut self, next: bool) { let Some(state) = &mut self.snippet else { return; }; state.active = if next { (state.active + 1).min(state.tabstops.len() - 1) } else { state.active.saturating_sub(1) }; self.select_snippet_stop(); } fn sync_snippet_recipe(&mut self, state: &SnippetState, update_text: bool) { let Some(step) = state.recipe_step else { return; }; let offsets = line_offsets(&self.active.lines); let caret = cursor_offset(&self.active.lines, &offsets, self.cursor()) .saturating_sub(state.replacement.start); let text = update_text.then(|| byte_range_text(&self.active.lines, &offsets, &state.replacement)); if let Some(InsertStep::Completion { primary_edit, edits, caret: destination, }) = self .active .view .pending_edit .as_mut() .and_then(|recipe| recipe.steps.get_mut(step)) { *destination = caret; if let Some(text) = text { edits[*primary_edit].text = text; } } } // Only the primary argument and its declared mirrors participate in these edits. fn edit_snippet(&mut self, text: &str, deletion: Option) -> bool { let Some(mut state) = self.snippet.take() else { return false; }; let offsets = line_offsets(&self.active.lines); let stop = &state.tabstops[state.active]; let index = stop.index; let argument = stop.ranges[0].clone(); let caret = cursor_offset(&self.active.lines, &offsets, self.cursor()); if caret < argument.start || caret > argument.end { return false; } let mut value = byte_range_text(&self.active.lines, &offsets, &argument); let position = caret - argument.start; let (start, end) = if state.selected { (0, value.len()) } else if let Some(action) = deletion { if let Some(kind) = action.backspace_kind() { (kind.start(&value[..position]), position) } else { let end = value[position..] .graphemes(true) .next() .map_or(position, |grapheme| position + grapheme.len()); (position, end) } } else { (position, position) }; value.replace_range(start..end, text); let mut edits = Vec::with_capacity(stop.ranges.len()); let mut occurrence = 0; while occurrence < stop.ranges.len() { let range = &stop.ranges[occurrence]; let count = if range.is_empty() { stop.ranges[occurrence..] .iter() .take_while(|mirror| *mirror == range) .count() } else { 1 }; edits.push(Edit { target: (occurrence == 0).then_some(self.active.view.primary), start: range.start, end: range.end, text: if count == 1 { Cow::Borrowed(value.as_str()) } else { Cow::Owned(value.repeat(count)) }, caret: start + text.len(), round_up: true, }); occurrence += count; } edits.sort_by_key(|edit| (edit.start, edit.end)); // Retire overwritten nested occurrences, not independent mirrors outside the parent. state.tabstops.retain_mut(|other| { if other.index == index || other.index == 0 { return true; } let mut occurrence = 0; other.ranges.retain(|_| { let keep = !other.ancestors[occurrence].contains(&index); occurrence += 1; keep }); other .ancestors .retain(|ancestors| !ancestors.contains(&index)); !other.ranges.is_empty() }); state.active = state .tabstops .iter() .position(|stop| stop.index == index) .expect("edited stop is retained"); let (before, rest) = state.tabstops.split_at_mut(state.active); let (active, after) = rest.split_first_mut().expect("edited stop is retained"); for stop in before.iter_mut().chain(after) { for range in &mut stop.ranges { let descendant_at = |position| { active .ranges .iter() .zip(&active.ancestors) .any(|(edited, ancestors)| { edited.start == position && range.start <= edited.start && edited.end <= range.end && ancestors.contains(&stop.index) }) }; let descendant = active .ranges .iter() .zip(&active.ancestors) .any(|(edited, ancestors)| { range.start <= edited.start && edited.end <= range.end && ancestors.contains(&stop.index) }); let starts_after = !descendant && (Range::is_empty(range) || edits .iter() .any(|edit| edit.start == edit.end && edit.start == range.start)); let ends_after = Range::is_empty(range) && !descendant || !edits.iter().any(|edit| edit.start == range.end) || descendant_at(range.end); let start = translate_edit_offset(range.start, starts_after, edits.iter()); let end = translate_edit_offset(range.end, ends_after, edits.iter()); *range = start..end; } } let mut previous_empty = None; let mut coincident = 0; for range in &mut active.ranges { if Range::is_empty(range) && previous_empty == Some(range.start) { coincident += 1; } else { coincident = 0; } previous_empty = Range::is_empty(range).then_some(range.start); let start = translate_edit_offset(range.start, false, edits.iter()) + coincident * value.len(); *range = start..start + value.len(); } state.replacement = translate_edit_offset(state.replacement.start, false, edits.iter()) ..translate_edit_offset(state.replacement.end, true, edits.iter()); state.selected = false; self.apply_edits_from(edits, None, true); self.sync_snippet_recipe(&state, true); let offsets = line_offsets(&self.active.lines); // Grapheme merging with literal text can invalidate a stop: keep the text, end navigation. if state.tabstops.iter().any(|stop| { stop.ranges.windows(2).any(|pair| { pair[0].end > pair[1].start || pair[0].start == pair[1].start && (!pair[0].is_empty() || !pair[1].is_empty()) }) }) { return true; } if state .tabstops .iter() .flat_map(|stop| &stop.ranges) .any(|range| { !document_grapheme_boundary(&self.active.lines, &offsets, range.start) || !document_grapheme_boundary(&self.active.lines, &offsets, range.end) }) { return true; } state.selections.clear(); state .selections .extend( state.tabstops[state.active] .ranges .iter() .map(|range| Selection { start: offset_cursor(&self.active.lines, &offsets, range.start, true), end: offset_cursor(&self.active.lines, &offsets, range.end, true), }), ); self.snippet = Some(state); true } /// Borrowed popup state, visible only while its source remains current. pub fn hover(&self) -> Option<&Hover> { self.hover.as_ref().filter(|hover| { hover.buffer_id == self.active.id && hover.revision == self.text_revision() && hover.caret == self.cursor() && self.mode() == Mode::Normal && self.document_actions_available() && self.palette.is_none() && !self .file_picker .as_ref() .is_some_and(|picker| picker.focused) }) } pub fn show_hover(&mut self, text: String) -> Result<(), String> { validate_hover_text(&text)?; if self.mode() != Mode::Normal || !self.document_actions_available() || self.palette.is_some() || self .file_picker .as_ref() .is_some_and(|picker| picker.focused) { return Err("Hover requires Normal editor focus".into()); } self.hover = if text.is_empty() { None } else { let text = match normalize_newlines(&text) { Cow::Borrowed(_) => text, Cow::Owned(normalized) => normalized, }; let mut line_starts = Vec::new(); let mut offset = 0; let mut max_column = 0; for line in text.split('\n') { line_starts.push(offset); offset += line.len() + 1; max_column = max_column.max(grapheme_count(line).saturating_sub(1)); } Some(Hover { text, line_starts, top: 0, left: 0, max_column, buffer_id: self.active.id, revision: self.text_revision(), caret: self.cursor(), }) }; Ok(()) } pub fn clear_hover(&mut self) { self.hover = None; } fn handle_hover(&mut self, input: &Input) -> bool { if self.hover().is_none() { self.clear_hover(); return false; } let hover = self.hover.as_mut().expect("current hover exists"); match input { Input::Up | Input::Ctrl('k') => hover.top = hover.top.saturating_sub(1), Input::Down | Input::Ctrl('j') => { hover.top = hover.top.saturating_add(1).min(hover.line_starts.len() - 1); } Input::Left | Input::Ctrl('h') => hover.left = hover.left.saturating_sub(1), Input::Right | Input::Ctrl('l') => { hover.left = hover.left.saturating_add(1).min(hover.max_column); } Input::Home => hover.left = 0, Input::End => hover.left = hover.max_column, Input::Escape => self.clear_hover(), _ => { self.clear_hover(); return false; } } true } pub fn file_picker(&self) -> Option<&FilePicker> { if let Some(diff) = &self.diff { diff.files.as_ref().filter(|_| diff.files_visible) } else { self.file_picker.as_ref() } } pub(crate) fn normal_file_picker(&self) -> Option<&FilePicker> { self.file_picker.as_ref().filter(|_| self.diff.is_none()) } pub(crate) fn set_picker_repository_changes( &mut self, tab: usize, root: &Path, changes: Vec, ) -> bool { let picker = if tab == self.tab_id { self.file_picker.as_mut().filter(|_| self.diff.is_none()) } else { self.tabs .get_mut(&tab) .and_then(|tab| tab.file_picker.as_mut().filter(|_| tab.diff.is_none())) }; let Some(picker) = picker.filter(|picker| picker.root == root) else { return false; }; picker.set_repository_changes(changes) } pub fn file_explorer_side(&self) -> DockSide { self.file_explorer_side } pub fn set_file_explorer_side(&mut self, side: &str) -> Result<(), String> { self.file_explorer_side = match side.to_ascii_lowercase().as_str() { "left" => DockSide::Left, "right" => DockSide::Right, "top" => DockSide::Top, "bottom" => DockSide::Bottom, _ => return Err(format!("Invalid file explorer side: {side:?}")), }; Ok(()) } pub fn file_picker_view(&self) -> FileView { self.file_picker_view } pub fn file_explorer_view(&self) -> FileView { self.file_explorer_view } pub fn set_file_picker_view(&mut self, view: &str) -> Result<(), String> { self.set_file_view(view, FilePresentation::Floating) } pub fn set_file_explorer_view(&mut self, view: &str) -> Result<(), String> { self.set_file_view(view, FilePresentation::Docked) } fn set_file_view(&mut self, view: &str, presentation: FilePresentation) -> Result<(), String> { let view = match view.to_ascii_lowercase().as_str() { "list" => FileView::List, "tree" => FileView::Tree, _ => return Err(format!("Invalid file view: {view:?}")), }; match presentation { FilePresentation::Floating => self.file_picker_view = view, FilePresentation::Docked => self.file_explorer_view = view, } if let Some(picker) = self .file_picker .as_mut() .filter(|picker| picker.presentation == presentation) { picker.set_view(view); } Ok(()) } #[cfg(test)] pub(crate) fn set_project_root(&mut self, root: PathBuf) { self.project_root = root; } /// Pending decimal prefix for the next Normal/Visual motion or editing action. pub fn count(&self) -> Option { match self.line_pending { Some((_, prefix)) => Some(prefix.saturating_mul(self.count.unwrap_or(1))) .filter(|count| *count > 1 || self.count.is_some()), None => self.count, } } /// Actual pending key sequence, cached until the next input. pub fn sequence_pending(&self) -> Option<&str> { (!self.sequence_display.is_empty()).then_some(self.sequence_display.as_str()) } /// Whether the built-in `g` navigation prefix is awaiting its second key. pub fn g_pending(&self) -> bool { self.g_pending } /// Native `d`/`c` operator or `r` replacement awaiting its next key. pub fn line_operator_pending(&self) -> Option { self.line_pending.map(|(operator, _)| operator) } /// Native character seek awaiting a literal target. pub fn seek_pending(&self) -> Option { self.seek_pending .map(|(forward, _)| if forward { 'f' } else { 'F' }) } fn clear_sequence(&mut self) { self.g_pending = false; self.line_pending = None; self.seek_pending = None; self.pending_sequence.prefix = None; self.pending_sequence.keys.clear(); self.sequence_display.clear(); } fn cancel_sequence(&mut self) { if self.sequence_pending().is_some() || self.g_pending || self.line_pending.is_some() || self.seek_pending.is_some() { self.count = None; self.clear_sequence(); } } /// Changes the symbolic leader without moving its bindings. pub fn set_leader(&mut self, key: &str) -> Result<(), String> { let leader = parse_key(key)?; if leader == Input::Escape || matches!(leader, Input::Char(ch) if ch.is_ascii_digit()) { return Err("Leader cannot be Escape or an ASCII digit".into()); } if leader != self.leader && [Mode::Normal, Mode::Visual].into_iter().any(|mode| { self.bindings .get(&mode) .is_some_and(|keys| keys.contains_key(&leader)) || self.has_sequence_prefix(mode, &leader) }) { return Err(format!( "Leader key already bound: {}", display_key(&leader) )); } self.cancel_sequence(); self.leader = leader; self.refresh_palette(); Ok(()) } /// Editable Ex/search prompt, without its `:` or `/` prefix. pub fn command_line(&self) -> &str { &self.active.view.command_line } pub fn search_match(&self) -> Option { self.active.view.search_match } /// Whole-row shape belongs to the active buffer, not the Visual binding context. pub fn visual_linewise(&self) -> bool { self.active.view.mode == Mode::Visual && self.active.view.linewise } /// Inclusive selected rows, including an empty final row with an empty text range. pub fn linewise_rows(&self) -> impl Iterator> + '_ { self.active .view .targets .iter() .filter(|_| self.visual_linewise()) .map(|target| { target.anchor.row.min(target.active.row)..=target.anchor.row.max(target.active.row) }) } /// Ordered, end-exclusive Visual ranges in document order; empty in other modes. pub fn selections(&self) -> Vec { if self.active.view.mode == Mode::Visual { self.active .view .targets .iter() .map(|target| self.target_range(*target)) .collect() } else { Vec::new() } } /// All active cursors in document order, including the primary cursor. pub fn carets(&self) -> Vec { self.active .view .targets .iter() .map(|target| target.active) .collect() } pub fn selection_count(&self) -> usize { self.active.view.targets.len() } pub fn message(&self) -> &str { &self.message } pub fn notification_visible(&self) -> bool { self.notification_visible } pub fn top_bar_config(&self) -> &ChromeLine { &self.top_bar } pub fn bottom_bar_config(&self) -> &ChromeLine { &self.bottom_bar } pub fn popup_config(&self) -> &PopupConfig { &self.popup } pub fn set_popup_config(&mut self, popup: PopupConfig) { self.popup = popup; } pub fn label(&self, name: &str) -> &str { self.labels.get(name).map_or("", String::as_str) } pub fn battery_label(&self) -> &str { &self.battery_label } pub(crate) fn gutter_style(&self) -> crate::gutter::GutterStyle { self.gutter_style } pub(crate) fn set_gutter_style(&mut self, style: crate::gutter::GutterStyle) { self.gutter_style = style; } pub(crate) fn gutter_change(&self, row: usize) -> Option { if self.active.diff_snapshot.is_some() || self.active.reload_error.is_some() { return None; } let changes = self.active.gutter_changes.as_ref()?; // Keep the last completed markers visible while the worker recomputes after edits. if Some(changes.path.as_path()) != self.path() { return None; } changes.lines.get(row).copied().flatten() } pub(crate) fn set_gutter_changes( &mut self, buffer: usize, revision: u64, path: &Path, changes: Vec>, ) -> bool { let buffer = if buffer == self.active.id { &mut self.active } else if let Some(buffer) = self.parked.get_mut(&buffer) { buffer } else { return false; }; if buffer.diff_snapshot.is_some() || buffer.text_revision != revision || buffer.path.as_deref() != Some(path) || changes.len() > buffer.lines.len() { return false; } if buffer .gutter_changes .as_ref() .is_some_and(|old| old.revision == revision && old.path == path && old.lines == changes) { return false; } let visible = changes.iter().any(Option::is_some) || buffer .gutter_changes .as_ref() .is_some_and(|old| old.lines.iter().any(Option::is_some)); buffer.gutter_changes = Some(GutterChanges { revision, path: path.to_path_buf(), lines: changes, }); visible } pub fn git_blame(&self) -> bool { self.git_blame } pub(crate) fn set_git_blame(&mut self, enabled: bool) { self.git_blame = enabled; self.line_blame = None; } pub(crate) fn blame_source(&self) -> Option { if !self.git_blame || self.should_quit() || self.diff.is_some() || self.active.diff_snapshot.is_some() || self.active.reload_error.is_some() { return None; } Some(BlameSource { buffer: self.buffer_id(), revision: self.text_revision(), path: Arc::from(self.path()?), row: self.cursor().row, save_revision: self.active.save_revision, disk_stamp: self.active.disk_stamp, pane: self.pane_id(), tab: self.tab_id(), }) } pub(crate) fn blame_file_source_is_current(&self, source: &BlameSource) -> bool { self.git_blame && !self.should_quit() && self.diff.is_none() && self.active.diff_snapshot.is_none() && self.active.reload_error.is_none() && source.buffer == self.buffer_id() && source.revision == self.text_revision() && source.save_revision == self.active.save_revision && source.disk_stamp == self.active.disk_stamp && Some(source.path.as_ref()) == self.path() } pub(crate) fn blame_source_is_current(&self, source: &BlameSource) -> bool { self.blame_file_source_is_current(source) && source.row == self.cursor().row && source.pane == self.pane_id() && source.tab == self.tab_id() } pub(crate) fn clear_line_blame(&mut self) -> bool { self.line_blame.take().is_some() } pub(crate) fn set_cached_line_blame( &mut self, source: &BlameSource, blame: Arc, ) -> bool { let mut source = source.clone(); source.row = self.cursor().row; source.pane = self.pane_id(); source.tab = self.tab_id(); self.set_line_blame(source, blame) } pub(crate) fn set_line_blame( &mut self, source: BlameSource, blame: Arc, ) -> bool { if !self.blame_source_is_current(&source) { return false; } if let Some((previous, metadata, _)) = &mut self.line_blame && Arc::ptr_eq(metadata, &blame) { *previous = source; return false; } let label = if blame.uncommitted { "Uncommitted".to_owned() } else { sanitize_label(format!( "{} · {} ({:.8})", blame.author, blame.summary, blame.commit )) }; let changed = self .line_blame .as_ref() .is_none_or(|(_, _, previous)| previous != &label); self.line_blame = Some((source, blame, label)); changed } pub(crate) fn line_blame(&self) -> Option<&str> { self.line_blame .as_ref() .filter(|(source, _, _)| self.blame_source_is_current(source)) .map(|(_, _, label)| label.as_str()) } pub fn lsp_names(&self) -> &[String] { &self.lsp_names } pub fn lsp_label(&self) -> &str { &self.lsp_label } pub fn path(&self) -> Option<&Path> { self.active.path.as_deref() } pub fn dirty(&self) -> bool { self.active.dirty() } pub fn should_quit(&self) -> bool { self.quit || self.restart } /// Whether frontend shutdown should relaunch the executable rather than exit. pub fn should_restart(&self) -> bool { self.restart } pub fn context(&self) -> Context { Context { buffer_id: self.active.id, revision: self.active.text_revision, root: self.project_root.to_string_lossy().into_owned(), text: self.active.lines.join("\n"), row: self.cursor().row + 1, column: self.cursor().column + 1, mode: self.active.view.mode.to_string(), path: self .active .path .as_ref() .map(|path| path.to_string_lossy().into_owned()), } } pub(crate) fn interaction_document_root(&self, id: usize) -> &Path { if let Some(root) = self .buffer(id) .and_then(|buffer| buffer.snapshot_root.as_deref()) { return root; } self.workspace_diff() .into_iter() .chain(self.tabs.values().filter_map(Tab::diff)) .find(|diff| diff.live_buffer == Some(id)) .and_then(|diff| diff.comparison.as_ref()) .map_or(&self.project_root, |comparison| comparison.root.as_path()) } pub(crate) fn lsp_context(&self) -> Context { let mut context = self.context(); context.path = self .active .document() .path .map(|path| path.to_string_lossy().into_owned()); context.root = self .interaction_document_root(self.active.id) .to_string_lossy() .into_owned(); context } pub(crate) fn document_actions_available(&self) -> bool { self.diff.is_none() || self.diff_interacting() } pub fn set_message(&mut self, message: impl Into) { let message = message.into(); self.message = if message.chars().any(char::is_control) { message .chars() .map(|ch| if ch.is_control() { ' ' } else { ch }) .collect() } else { message }; self.notification_visible = true; } pub fn set_chrome_config( &mut self, mut top: ChromeLine, mut bottom: ChromeLine, labels: BTreeMap, ) -> Result<(), String> { top.validate()?; bottom.validate()?; let mut total = 0usize; for (name, text) in &labels { validate_label(name, text)?; total = total .checked_add(name.len()) .and_then(|total| total.checked_add(text.len())) .ok_or("Labels exceed 4 MiB")?; if total > MAX_EDIT_BYTES { return Err("Labels exceed 4 MiB".into()); } } self.top_bar = top; self.bottom_bar = bottom; self.labels = labels .into_iter() .map(|(name, text)| (name, sanitize_label(text))) .collect(); Ok(()) } pub fn set_label(&mut self, name: String, text: String) -> Result { validate_label(&name, &text)?; let text = sanitize_label(text); if self.label(&name) == text { return Ok(false); } if text.is_empty() { self.labels.remove(&name); } else { self.labels.insert(name, text); } Ok(true) } pub(crate) fn set_lsp_names(&mut self, mut names: Vec) -> bool { for name in &mut names { if name.chars().any(char::is_control) { *name = sanitize_label(std::mem::take(name)); } } if self.lsp_names == names { return false; } self.lsp_label = names.join(" + "); self.lsp_names = names; true } pub(crate) fn set_battery_charge(&mut self, charge: Option) -> bool { let charge = charge.filter(|charge| *charge <= 100); if self.battery_charge == charge { return false; } self.battery_charge = charge; self.battery_label.clear(); if let Some(charge) = charge { use std::fmt::Write as _; write!(self.battery_label, "bat {charge}%").unwrap(); } true } pub fn register(&mut self, feature: Feature) -> Result<(), String> { self.install(feature, false) } fn install(&mut self, feature: Feature, privileged: bool) -> Result<(), String> { if feature.core && !privileged { return Err("Plugins cannot register core features".into()); } if feature.name.trim().is_empty() || feature.name.chars().any(char::is_control) { return Err("Feature names must be nonempty and contain no controls".into()); } if self.features.contains_key(&feature.name) { return Err(format!("Feature already registered: {}", feature.name)); } for capability in &feature.required_capabilities { if !self.capabilities.contains(capability) { return Err(format!( "Feature {} requires unavailable capability: {capability}", feature.name )); } } let mut names = HashSet::new(); for command in &feature.commands { if !valid_command(command) { return Err(format!("Invalid command name: {command}")); } if !names.insert(command.as_str()) || self.owner(command).is_some() { return Err(format!("Command already registered: {command}")); } } self.features.insert(feature.name.clone(), feature); self.refresh_palette(); Ok(()) } fn owner(&self, command: &str) -> Option<&Feature> { self.features .values() .find(|feature| feature.commands.iter().any(|name| name == command)) } pub fn disable_plugin(&mut self, name: &str, reason: &str) { self.cancel_sequence(); if self.features.get(name).is_some_and(|feature| feature.core) { self.set_message("Core features cannot be disabled"); return; } self.clear_plugin_diagnostics(name); if let Some(feature) = self.features.remove(name) { for bindings in self.bindings.values_mut() { bindings.retain(|_, binding| { !matches!(binding, Binding::Command(command) if feature.commands.contains(command)) }); } for bindings in self.sequence_bindings.values_mut() { bindings.retain(|_, binding| { !matches!(binding, Binding::Command(command) if feature.commands.contains(command)) }); } } self.refresh_palette(); self.set_message(format!("Plugin {name} disabled: {reason}")); } /// Overrides an editing route or single selection/tree navigation key. /// `None` disables it; invalid overrides are atomic. pub fn bind(&mut self, mode: &str, key: &str, target: Option) -> Result<(), String> { if matches!(mode, "selection" | "tree") { let input = parse_key(key)?; let binding = match target.as_deref() { None => Binding::Disabled, Some("core.up") => Binding::Action(Action::Up), Some("core.down") => Binding::Action(Action::Down), Some("core.left") if mode == "tree" => Binding::Action(Action::Left), Some("core.right") if mode == "tree" => Binding::Action(Action::Right), Some(target) => return Err(format!("Unknown {mode} action: {target}")), }; if self.seek_pending.is_some() { self.cancel_sequence(); } if mode == "tree" { self.tree_bindings.insert(input, binding); } else { self.selection_bindings.insert(input, binding); } self.refresh_selection_navigation_hint(); return Ok(()); } let mode = match mode { "normal" => Mode::Normal, "insert" => Mode::Insert, "command" => Mode::Command, "search" => Mode::Search, "visual" => Mode::Visual, _ => return Err(format!("Unknown binding mode: {mode}")), }; let mut tokens = key.split_whitespace(); let first = tokens .next() .ok_or_else(|| format!("Invalid binding key: {key:?}"))?; let symbolic = first.eq_ignore_ascii_case("leader"); let keys: Vec<_> = tokens.map(parse_key).collect::>()?; if symbolic && keys.is_empty() { return Err("Leader binding requires at least one suffix key".into()); } let (sequence, input) = if symbolic || !keys.is_empty() { if !matches!(mode, Mode::Normal | Mode::Visual) { return Err("Key sequences can only be bound in Normal or Visual mode".into()); } let prefix = if symbolic { None } else { Some(parse_key(first)?) }; if let Some(prefix) = &prefix { if *prefix == Input::Escape || matches!(prefix, Input::Char('0'..='9')) { return Err("Sequence prefix cannot be Escape or an ASCII digit".into()); } if *prefix == self.leader { return Err( "Use leader for sequences starting with the configured leader".into(), ); } if self .bindings .get(&mode) .is_some_and(|bindings| bindings.contains_key(prefix)) { return Err(format!( "Sequence prefix already bound: {}", display_key(prefix) )); } } let sequence = BindingSequence { prefix, keys }; if self.sequence_bindings.get(&mode).is_some_and(|bindings| { bindings.keys().any(|existing| { existing.prefix == sequence.prefix && existing.keys != sequence.keys && (existing.keys.starts_with(&sequence.keys) || sequence.keys.starts_with(&existing.keys)) }) }) { return Err(format!("Ambiguous key sequence: {key:?}")); } (Some(sequence), None) } else { let input = parse_key(first)?; if self.has_sequence_prefix(mode, &input) { return Err(format!("Key is already a sequence prefix: {key:?}")); } (None, Some(input)) }; let binding = match target { None => Binding::Disabled, Some(target) if target.starts_with("core.") => Binding::Action( Action::named(mode, &target) .ok_or_else(|| format!("Unknown action for {mode}: {target}"))?, ), Some(command) => { if let Some(entry) = core_command(&command) { return Err(format!( "Built-in command {command} requires typed action {}", entry.name )); } if mode != Mode::Normal { return Err("Ex/plugin commands can only be bound in Normal mode".into()); } if self.owner(&command).is_none() { return Err(format!("Unknown command: {command}")); } Binding::Command(command) } }; self.cancel_sequence(); if let Some(sequence) = sequence { self.sequence_bindings .entry(mode) .or_default() .insert(sequence, binding); } else { self.bindings .entry(mode) .or_default() .insert(input.expect("single-key binding"), binding); } self.refresh_palette(); Ok(()) } pub fn selection_navigation_hint(&self) -> &str { &self.selection_navigation_hint } pub fn file_navigation_hint(&self) -> &str { if let Some(picker) = self .diff .as_ref() .and_then(|diff| diff.files.as_ref()) .or(self.file_picker.as_ref()) && picker.view == FileView::Tree { if picker.searching { &self.tree_filter_navigation_hint } else { &self.tree_navigation_hint } } else { &self.selection_navigation_hint } } fn refresh_selection_navigation_hint(&mut self) { let keys = |action, tree: bool, filtering: bool| { let applies = |input: &Input| tree && (!filtering || !matches!(input, Input::Char(_))); let mut keys: Vec<_> = self .selection_bindings .iter() .filter(|(key, _)| !applies(key) || !self.tree_bindings.contains_key(*key)) .chain(self.tree_bindings.iter().filter(|(key, _)| applies(key))) .filter( |(_, binding)| matches!(binding, Binding::Action(bound) if *bound == action), ) .map(|(input, _)| { let priority = usize::from(!(tree && !filtering && matches!(input, Input::Char(_)))); (priority, display_key(input)) }) .collect(); keys.sort(); if keys.is_empty() { "unbound".into() } else { let capacity = keys.iter().map(|(_, key)| key.len()).sum::() + keys.len() - 1; let mut hint = String::with_capacity(capacity); for (_, key) in keys { if !hint.is_empty() { hint.push('/'); } hint.push_str(&key); } hint } }; self.selection_navigation_hint = format!( "{} next · {} previous", keys(Action::Down, false, false), keys(Action::Up, false, false) ); self.tree_navigation_hint = format!( "{} next · {} previous · {} parent/close · {} open/child", keys(Action::Down, true, false), keys(Action::Up, true, false), keys(Action::Left, true, false), keys(Action::Right, true, false) ); self.tree_filter_navigation_hint = format!( "{} next · {} previous · {} parent · {} child", keys(Action::Down, true, true), keys(Action::Up, true, true), keys(Action::Left, true, true), keys(Action::Right, true, true) ); } fn handle_tree_binding(&mut self, input: &Input) -> bool { let picker = if let Some(diff) = &mut self.diff { diff.files.as_mut().filter(|_| diff.files_visible) } else { self.file_picker.as_mut() }; let Some(picker) = picker.filter(|picker| picker.focused && picker.view == FileView::Tree) else { return false; }; if picker.searching && matches!(input, Input::Char(_)) { return false; } match self.tree_bindings.get(input) { Some(Binding::Action(Action::Down)) => picker.navigate(true), Some(Binding::Action(Action::Up)) => picker.navigate(false), Some(Binding::Action(Action::Left)) => picker.left(), Some(Binding::Action(Action::Right)) => picker.right(), Some(Binding::Disabled) => {} None => return false, _ => unreachable!("tree bindings only accept navigation or disabled"), } true } fn handle_selection_binding(&mut self, input: &Input) -> bool { let next = match self.selection_bindings.get(input) { Some(Binding::Action(Action::Down)) => true, Some(Binding::Action(Action::Up)) => false, Some(Binding::Disabled) => return true, None => return false, _ => unreachable!("selection bindings only accept navigation or disabled"), }; if let Some(picker) = self.file_picker.as_mut().filter(|picker| picker.focused) { picker.navigate(next); } else if let Some(palette) = self.palette.as_mut() && !palette.entries.is_empty() { palette.selected = if next { (palette.selected + 1) % palette.entries.len() } else if palette.selected == 0 { palette.entries.len() - 1 } else { palette.selected - 1 }; } else if self.palette.is_none() && let Some(diff) = &mut self.diff { diff.navigate(next); } true } fn palette_bindings(&self, action: Option, command: &str) -> Vec { // ponytail: scan mode bindings per result; index by target if catalogs grow. let mut bindings: Vec<_> = self .bindings .get(&self.active.view.mode) .into_iter() .flat_map(|keys| keys.iter()) .filter(|(_, binding)| match (action, binding) { (Some(action), Binding::Action(bound)) => action == *bound, (None, Binding::Command(bound)) => command == bound, _ => false, }) .map(|(input, _)| display_key(input)) .collect(); let native = match action { Some(Action::FollowLink) if self.active.view.mode == Mode::Normal => Some("gf"), Some(Action::DeleteLine) if self.active.view.mode == Mode::Normal => Some("dd"), Some(Action::ChangeLine) if self.active.view.mode == Mode::Normal => Some("cc"), Some(Action::DocumentStart) if matches!(self.active.view.mode, Mode::Normal | Mode::Visual) => { Some("gt") } Some(Action::DocumentEnd) if matches!(self.active.view.mode, Mode::Normal | Mode::Visual) => { Some("gb") } _ => None, }; if let Some(native) = native { let prefix = Input::Char(native.as_bytes()[0] as char); if self.leader != prefix && !self .bindings .get(&self.active.view.mode) .is_some_and(|keys| keys.contains_key(&prefix)) && !self.has_sequence_prefix(self.active.view.mode, &prefix) { bindings.push(native.into()); } } for (sequence, binding) in self .sequence_bindings .get(&self.active.view.mode) .into_iter() .flat_map(|keys| keys.iter()) { let prefix = sequence.prefix.as_ref().unwrap_or(&self.leader); if self .bindings .get(&self.active.view.mode) .is_some_and(|keys| keys.contains_key(prefix)) { continue; } if match (action, binding) { (Some(action), Binding::Action(bound)) => action == *bound, (None, Binding::Command(bound)) => command == bound, _ => false, } { let mut display = display_key(prefix); for input in &sequence.keys { display.push(' '); append_key(&mut display, input); } bindings.push(display); } } bindings.sort(); bindings } fn refresh_palette(&mut self) { let Some(mut palette) = self.palette.take() else { return; }; let query = palette.query.to_lowercase(); if palette.buffers { palette.entries.clear(); for buffer in self.buffers() { let label = sanitize_label(buffer.path.map_or_else( || "[No Name]".into(), |path| path.to_string_lossy().into_owned(), )); let searchable = format!( "{}{}{} {label}", buffer.id, if buffer.active { "%" } else { "" }, if buffer.dirty { "+" } else { "" }, ) .to_lowercase(); if query .split_whitespace() .all(|token| searchable.contains(token)) { palette.entries.push(PaletteEntry { name: format!("buffer {}", buffer.id), label, owner: String::new(), bindings: Vec::new(), enabled: true, modes: Vec::new(), buffer: Some(PaletteBuffer { id: buffer.id, active: buffer.active, dirty: buffer.dirty, }), }); } } palette.selected = 0; self.palette = Some(palette); return; } // Derived labels contain the same searchable words as their names. let matches = |name: &str, owner: &str, ex: &str, action: Option| { query.split_whitespace().all(|token| { name.contains(token) || owner.contains(token) || ex.contains(token) || action.is_some_and(|action| { COMMAND_ALIASES .iter() .any(|(alias, target)| *target == action && alias.contains(token)) }) }) }; palette.entries.clear(); for entry in CORE_ACTIONS { let CoreAction { name, action, modes, ex, .. } = *entry; if action == Action::Palette { continue; } if !matches(name, "core", ex.unwrap_or(""), Some(action)) { continue; } let mut label = name.trim_start_matches("core.").replace('_', " "); label[..1].make_ascii_uppercase(); palette.entries.push(PaletteEntry { name: name.into(), label, owner: "core".into(), bindings: self.palette_bindings(Some(action), ""), enabled: modes.contains(&self.binding_mode()) && (!matches!( action, Action::LspDefinition | Action::LspHover | Action::LspDiagnostics ) || self.document_actions_available()), modes: modes.to_vec(), buffer: None, }); } for feature in self.features.values() { if feature.core { continue; } let owner = feature.name.to_lowercase(); for command in &feature.commands { let name = command.to_lowercase(); if !matches(&name, &owner, "", None) { continue; } palette.entries.push(PaletteEntry { name: command.clone(), label: command.clone(), owner: feature.name.clone(), bindings: self.palette_bindings(None, command), enabled: self.active.view.mode == Mode::Normal, modes: vec![Mode::Normal], buffer: None, }); } } palette.entries.sort_by(|left, right| { left.label .cmp(&right.label) .then_with(|| left.name.cmp(&right.name)) }); palette.selected = 0; self.palette = Some(palette); } fn handle_palette(&mut self, input: Input) -> Option { let mode = self.binding_mode(); let palette = self.palette.as_mut().expect("open palette receives input"); match input { Input::Escape | Input::Ctrl('p') => { self.palette = None; self.pane_close_picker_origin = None; } Input::Ctrl('u') => { if BackspaceKind::Line.truncate(&mut palette.query) { self.refresh_palette(); } } Input::Enter => { let entry = palette .entries .get(palette.selected) .filter(|entry| entry.enabled)?; if palette.buffers { let command = palette.entries.swap_remove(palette.selected).name; return self.execute(&command); } if let Some(action) = Action::named(mode, &entry.name) { if !matches!(action, Action::PaneClose | Action::PaneForceClose) { self.pane_close_picker_origin = None; } if let Some(core) = CORE_ACTIONS .iter() .find(|core| core.action == action && core.palette_prompt) { self.open_action_prompt(core); return None; } self.palette = None; return self.act(action); } self.pane_close_picker_origin = None; self.active.view.command_line = format!("{} ", entry.name); self.palette = None; if self.diff.is_some() && !self.diff_interacting() { self.diff_prompt_mode.get_or_insert(self.active.view.mode); } else if self.active.view.mode == Mode::Insert { self.finish_insert(); } self.active.view.mode = Mode::Command; } input if BackspaceKind::from_input(&input).is_some() => { if BackspaceKind::from_input(&input) .expect("backspace input") .truncate(&mut palette.query) { self.refresh_palette(); } } Input::Char(ch) if !ch.is_control() => { palette.query.push(ch); self.refresh_palette(); } _ => {} } None } fn open_file_picker(&mut self, presentation: FilePresentation) { self.pane_close_picker_origin = None; if presentation == FilePresentation::Docked && self.file_picker().is_some_and(|picker| { picker.focused && picker.presentation == FilePresentation::Docked && picker.view == FileView::Tree && !picker.searching }) { let return_focus = self.diff_bound.map(|bound| bound.2); self.blur_file_picker(); if let Some(diff) = &mut self.diff && let Some(focus) = return_focus { diff.focus = focus; diff.normalize_focus(); } return; } let view = match presentation { FilePresentation::Floating => self.file_picker_view, FilePresentation::Docked => self.file_explorer_view, }; if let Some(diff) = &mut self.diff { if diff.phase == Phase::View { let picker = diff.files.as_mut().expect("comparison picker"); picker.presentation = presentation; picker.set_view(view); picker.focused = true; picker.clear_error(); if presentation == FilePresentation::Floating && view == FileView::Tree && !picker.searching { picker.start_search(); } diff.files_visible = true; diff.focus = Focus::Files; self.palette = None; self.count = None; self.clear_sequence(); } return; } if let Some(picker) = &mut self.file_picker { picker.presentation = presentation; picker.set_view(view); picker.focused = true; picker.clear_error(); } else { match FilePicker::scan(self.project_root.clone(), presentation, view) { Ok(picker) => { self.file_picker = Some(picker); } Err(error) => { self.set_message(format!("File scan failed: {error}")); return; } } } if presentation == FilePresentation::Floating && view == FileView::Tree && let Some(picker) = &mut self.file_picker && !picker.searching { picker.start_search(); } self.palette = None; self.count = None; self.clear_sequence(); } fn blur_file_picker(&mut self) { if let Some(diff) = &mut self.diff && diff.phase == Phase::View { if let Some(picker) = &mut diff.files { picker.focused = false; if picker.presentation == FilePresentation::Floating { diff.files_visible = false; } } if diff.focus == Focus::Files { diff.focus = diff.first_text_focus(); } } match self.file_picker.as_mut() { Some(picker) if picker.presentation == FilePresentation::Docked => { picker.focused = false; } _ => self.file_picker = None, } } fn close_file_picker(&mut self) { self.pane_close_picker_origin = None; if let Some(diff) = &mut self.diff { diff.files_visible = false; diff.focus = self .diff_bound .map(|bound| bound.2) .unwrap_or_else(|| diff.first_text_focus()); diff.normalize_focus(); } else { self.file_picker = None; } } fn handle_file_picker(&mut self, input: Input) { let picker = self .file_picker .as_mut() .expect("focused picker receives input"); match input { Input::Ctrl('w') => self.close_file_picker(), Input::Escape if picker.view == FileView::Tree && picker.searching => { picker.finish_search() } Input::Escape => self.blur_file_picker(), Input::Ctrl('p') => { self.act(Action::Palette); } Input::Char('\t') if picker.presentation == FilePresentation::Docked => { picker.focused = false; } Input::Ctrl('r') => picker.refresh(), Input::Ctrl('t') => picker.toggle_view(), _ if picker.handle_filter_input(&input) => {} Input::Enter => { let Some(path) = picker.accept() else { return }; let source = self.jump_location(); match self.edit_buffer(path, false) { Ok(()) => { self.record_jump(source); self.blur_file_picker(); } Err(error) => { self.file_picker .as_mut() .expect("failed open retains picker") .error = Some(format!("Open failed: {error}")); } } } _ => {} } } pub fn handle(&mut self, input: Input) -> Option { self.pending_lsp_navigation = None; self.active.view.lsp_center.set(None); // Normalize genuine terminal aliases before list and editing dispatch. let input = match input { Input::Ctrl(ch) => control_key(ch), input => input, }; if self.palette.is_none() && self.active.view.mode != Mode::Command && !matches!( self.bindings .get(&self.binding_mode()) .and_then(|keys| keys.get(&input)), Some(Binding::Action( Action::Submit | Action::PaneClose | Action::PaneForceClose )) ) { self.pane_close_picker_origin = None; } self.bind_diff_buffer(); let invocation = if self.diff.is_some() { self.handle_diff(input) } else { self.handle_input(input) }; self.leave_diff_on_navigation(); self.bind_diff_buffer(); self.refresh_live_diff(); if self.diff_interacting() && let Some(diff) = &mut self.diff { diff.follow_caret = true; } invocation } fn handle_input(&mut self, input: Input) -> Option { let mode = self.binding_mode(); if self.completion.is_some() && (self.diff.is_none() || self.diff_editing()) { let request = matches!( self.bindings .get(&self.active.view.mode) .and_then(|keys| keys.get(&input)), Some(Binding::Action(Action::LspCompletion)) ); if request || input == Input::Ctrl('j') { self.cycle_completion(true); return None; } match input { Input::Ctrl('k') => { self.cycle_completion(false); return None; } _ => self.finish_completion(), } } if let Some((forward, count)) = self.seek_pending.take() { if matches!(self.active.view.mode, Mode::Normal | Mode::Visual) && self.startup_diagnostic.is_none() && self.hover().is_none() && self.palette.is_none() && !self .file_picker .as_ref() .is_some_and(|picker| picker.focused) && let Input::Char(target) = input && (!target.is_control() || target == '\t') { let motion = Motion::Seek { forward, target, count, }; if let Some(motions) = &mut self.active.view.visual_motions { motions.push(motion); } self.move_motion(motion); return None; } self.count = None; if input == Input::Escape { return None; } } if std::mem::take(&mut self.g_pending) { if matches!(self.active.view.mode, Mode::Normal | Mode::Visual) && self.startup_diagnostic.is_none() && self.hover().is_none() && self.palette.is_none() && !self .file_picker .as_ref() .is_some_and(|picker| picker.focused) { let action = match input { Input::Char('f') if self.active.view.mode == Mode::Normal => { Some(Action::FollowLink) } Input::Char('t') => Some(Action::DocumentStart), Input::Char('b') => Some(Action::DocumentEnd), _ => None, }; if let Some(action) = action { return self.act(action); } } self.count = None; if input == Input::Escape { return None; } } if let Some(diagnostic) = &mut self.startup_diagnostic { match input { Input::Enter | Input::Escape => self.startup_diagnostic = None, Input::Up | Input::Ctrl('k') => diagnostic.top = diagnostic.top.saturating_sub(1), Input::Down | Input::Ctrl('j') => { diagnostic.top = diagnostic .top .saturating_add(1) .min(diagnostic.lines.len() - 1) } Input::Left | Input::Ctrl('h') => { diagnostic.left = diagnostic.left.saturating_sub(1) } Input::Right | Input::Ctrl('l') => { diagnostic.left = diagnostic.left.saturating_add(1).min(diagnostic.max_column) } Input::Home => { diagnostic.top = 0; diagnostic.left = 0; } _ => {} } return None; } if self.handle_hover(&input) { return None; } if self.sequence_pending().is_some() { if input == Input::Escape || matches!(input, Input::Paste(_)) { self.cancel_sequence(); if input == Input::Escape { return None; } } else { return self.handle_sequence(input); } } let picker_focused = self .file_picker .as_ref() .is_some_and(|picker| picker.focused); if picker_focused && self.handle_tree_binding(&input) { return None; } if (picker_focused || self.palette.is_some()) && self.handle_selection_binding(&input) { return None; } let picker_browsing = self .file_picker .as_ref() .is_some_and(|picker| picker.view == FileView::Tree && !picker.searching); if picker_focused && (!matches!(input, Input::Char(_)) || picker_browsing) && self.start_sequence(mode, &input) { return None; } if picker_focused && let Some(Binding::Action(action)) = self.bindings.get(&mode).and_then(|keys| keys.get(&input)) && matches!( action, Action::PaneFocusLeft | Action::PaneFocusDown | Action::PaneFocusUp | Action::PaneFocusRight | Action::PaneClose | Action::PaneForceClose | Action::TabNew | Action::TabPrevious | Action::TabNext | Action::TabClose | Action::TabForceClose | Action::FilePicker | Action::FileExplorer ) { let action = *action; if self .file_picker .as_mut() .expect("focused picker exists") .handle_filter_input(&input) { return None; } return self.act(action); } if picker_focused { self.handle_file_picker(input); return None; } if self.palette.is_some() { return self.handle_palette(input); } if let Some((operator, prefix)) = self.line_pending.take() { if operator == 'r' { if matches!(self.active.view.mode, Mode::Normal | Mode::Visual) && let Input::Char(target) = input && (!target.is_control() || target == '\t') { if self.mutation_blocked() { self.set_message("Diff pane is read-only"); } else { let before = self.snapshot(); let (ranges, start) = if self.active.view.mode == Mode::Visual { let ranges = self.selections(); let selector = self.selection_intent(&ranges); (ranges, EditStart::ReplaceSelection { target, selector }) } else { ( self.normal_ranges(Action::SubstituteCharacter, prefix), EditStart::Replace { target, count: prefix, }, ) }; self.replace_characters(target, &ranges); self.record_native( before, EditRecipe { start, steps: Vec::new(), }, ); } } self.count = None; return None; } if self.active.view.mode == Mode::Normal { if let Input::Char(ch @ '0'..='9') = input && (ch != '0' || self.count.is_some()) { self.count = Some( self.count .unwrap_or(0) .saturating_mul(10) .saturating_add(usize::from(ch as u8 - b'0')), ); self.line_pending = Some((operator, prefix)); return None; } let action = match input { Input::Char(ch) if ch == operator => Some(match operator { 'd' => Action::DeleteLine, 'y' => Action::CopyLine, _ => Action::ChangeLine, }), Input::Char('w') if operator == 'd' => Some(Action::DeleteWordForward), Input::Char('b') if operator == 'd' => Some(Action::DeleteWordBackward), Input::Char('w') if operator == 'c' => Some(Action::ChangeWordForward), Input::Char('b') if operator == 'c' => Some(Action::ChangeWordBackward), _ => None, }; if let Some(action) = action { self.count = Some(prefix.saturating_mul(self.count.unwrap_or(1))); return self.act(action); } } self.count = None; if input == Input::Escape { return None; } } // Paste is literal input, never a configurable key or command. if let Input::Paste(text) = input { self.count = None; self.clear_sequence(); if self.active.view.mode == Mode::Insert && (self.diff.is_none() || self.diff_editing()) { self.paste(&text); } return None; } if input == Input::Escape && self.count.take().is_some() { return None; } if input == Input::Escape && self.active.view.mode == Mode::Normal && self.notification_visible && (!self.message.is_empty() || self.diagnostic_at_cursor().is_some()) { self.notification_visible = false; return None; } if matches!(self.active.view.mode, Mode::Normal | Mode::Visual) && let Input::Char(ch @ '0'..='9') = input { let binding = self .bindings .get(&self.active.view.mode) .and_then(|keys| keys.get(&input)); let extends_count = ch == '0' && self.count.is_some() && matches!(binding, Some(Binding::Action(Action::LineStart))); if (binding.is_none() || extends_count) && (ch != '0' || self.count.is_some()) { let digit = usize::from(ch as u8 - b'0'); self.count = Some( self.count .unwrap_or(0) .saturating_mul(10) .saturating_add(digit), ); return None; } } match self.bindings.get(&mode).and_then(|keys| keys.get(&input)) { Some(Binding::Action(action)) => return self.act(*action), Some(Binding::Command(command)) => { let command = command.clone(); return self.execute(&command); } Some(Binding::Disabled) => { self.count = None; return None; } None => {} } if self.start_sequence(mode, &input) { return None; } if matches!(mode, Mode::Normal | Mode::Visual) && input == Input::Char('g') { if self.count.is_some() { return self.act(Action::GoToLine); } self.count = None; self.g_pending = true; return None; } if self.active.view.mode == Mode::Normal && let Input::Char(operator @ ('c' | 'd' | 'y')) = input { self.line_pending = Some((operator, self.count.take().unwrap_or(1))); return None; } self.count = None; match (self.active.view.mode, input) { (Mode::Insert, Input::Char(ch)) if (self.diff.is_none() || self.diff_editing()) && (!ch.is_control() || ch == '\t') => { let mut bytes = [0; 4]; self.type_native(ch.encode_utf8(&mut bytes)); } (Mode::Command | Mode::Search, Input::Char(ch)) if !ch.is_control() || (self.active.view.mode == Mode::Search && ch == '\t') => { self.active.view.command_line.push(ch); } _ => {} } None } fn has_sequence_prefix(&self, mode: Mode, input: &Input) -> bool { self.sequence_bindings.get(&mode).is_some_and(|bindings| { bindings .keys() .any(|sequence| sequence.prefix.as_ref() == Some(input)) }) } fn start_sequence(&mut self, mode: Mode, input: &Input) -> bool { if !matches!(mode, Mode::Normal | Mode::Visual) || (*input != self.leader && !self.has_sequence_prefix(mode, input)) || self .bindings .get(&mode) .is_some_and(|keys| keys.contains_key(input)) { return false; } self.pending_sequence.prefix = (*input != self.leader).then(|| input.clone()); append_key(&mut self.sequence_display, input); true } fn handle_sequence(&mut self, input: Input) -> Option { self.sequence_display.push(' '); append_key(&mut self.sequence_display, &input); self.pending_sequence.keys.push(input); let bindings = self.sequence_bindings.get(&self.binding_mode()); match bindings.and_then(|keys| keys.get(&self.pending_sequence)) { Some(Binding::Action(action)) => return self.act(*action), Some(Binding::Command(command)) => { let command = command.clone(); return self.execute(&command); } Some(Binding::Disabled) => { self.cancel_sequence(); return None; } None => {} } // ponytail: scan finite sequence routes; use a trie if catalogs grow large. if bindings.is_some_and(|keys| { keys.keys().any(|sequence| { sequence.prefix == self.pending_sequence.prefix && sequence.keys.starts_with(&self.pending_sequence.keys) }) }) { return None; } self.set_message(format!("Unknown key sequence: {}", self.sequence_display)); self.cancel_sequence(); None } fn finish_diff_prompt(&mut self) { if let Some(mode) = self.diff_prompt_mode.take() { self.active.view.mode = mode; self.active.view.command_line.clear(); } } fn open_action_prompt(&mut self, entry: &CoreAction) { if self.diff.is_some() && !self.diff_interacting() { self.diff_prompt_mode.get_or_insert(self.active.view.mode); self.palette = None; self.count = None; self.clear_sequence(); } else { self.prepare_command(); } self.active.view.command_line = format!("{} ", entry.ex.expect("prompt has Ex alias")); self.active.view.mode = Mode::Command; } fn act(&mut self, action: Action) -> Option { if (self.active.view.mode != Mode::Command || action == Action::Normal) && !matches!( action, Action::Palette | Action::Submit | Action::PaneClose | Action::PaneForceClose ) { self.pane_close_picker_origin = None; } if action == Action::Normal && self.diff_prompt_mode.is_some() { self.finish_diff_prompt(); self.count = None; self.clear_sequence(); return None; } let read_only = self.mutation_blocked(); if read_only && action.mutates(self.active.view.mode) { self.set_message("Diff pane is read-only"); self.count = None; self.clear_sequence(); return None; } let entry = action.entry(); if entry.ex.is_some() { if entry.prompt { self.open_action_prompt(entry); return None; } return self.execute_action(entry, ""); } if !read_only { self.finish_completion(); } if !read_only && !matches!( action, Action::SnippetNext | Action::SnippetPrevious | Action::Newline | Action::Backspace | Action::BackspaceWord | Action::BackspaceLine | Action::Delete ) { self.snippet = None; } if !read_only && !matches!(action, Action::JumpBack | Action::JumpForward) { self.clear_hover(); } let count = self.count.take().unwrap_or(1); self.clear_sequence(); match action { Action::SnippetNext => { if self.snippet.is_some() { self.navigate_snippet(true); } else if self.active.view.mode == Mode::Insert { self.type_native("\t"); } } Action::SnippetPrevious => self.navigate_snippet(false), Action::Palette => { self.pane_close_picker_origin = self .file_picker() .filter(|picker| { picker.focused && picker.presentation == FilePresentation::Docked }) .map(|_| { ( self.tab_id, self.pane_id, self.diff.as_ref().and_then(|diff| diff.comparison_id), ) }); self.blur_file_picker(); self.palette = Some(Palette { query: String::new(), entries: Vec::new(), selected: 0, buffers: false, }); self.refresh_palette(); } Action::FilePicker => self.open_file_picker(FilePresentation::Floating), Action::FileExplorer => self.open_file_picker(FilePresentation::Docked), Action::FollowLink | Action::PaneSplitHorizontal | Action::PaneSplitVertical | Action::PaneFocusLeft | Action::PaneFocusDown | Action::PaneFocusUp | Action::PaneFocusRight | Action::PaneClose | Action::PaneForceClose | Action::TabNew | Action::TabPrevious | Action::TabNext | Action::TabClose | Action::TabForceClose | Action::Save | Action::ForceSave | Action::Quit | Action::ForceQuit | Action::Restart | Action::ForceRestart | Action::SaveAndQuit | Action::ForceSaveAndQuit | Action::Plugins | Action::Edit | Action::Buffers | Action::Buffer | Action::BufferNext | Action::BufferPrevious | Action::BufferDelete | Action::BufferForceDelete | Action::Diff | Action::DiffClose | Action::DiffModeToggle | Action::DiffViewToggle | Action::DiffOrientationToggle | Action::DiffReviewToggle | Action::WordWrapToggle | Action::GitBlameToggle | Action::LspDefinition | Action::LspHover | Action::LspDiagnostics => unreachable!("catalog Ex actions dispatch above"), Action::JumpBack => self.traverse_jumps(false, count), Action::JumpForward => self.traverse_jumps(true, count), Action::SeekForward | Action::SeekBackward => { self.seek_pending = Some((action == Action::SeekForward, count)); } Action::DeleteLine | Action::ChangeLine => { self.edit_lines(matches!(action, Action::ChangeLine), count); } Action::CopyLine => self.copy_lines(count), Action::ReplaceCharacter => { self.line_pending = Some(('r', count)); } Action::ChangeToLineEnd | Action::DeleteToLineEnd | Action::CopyToLineEnd | Action::SubstituteCharacter | Action::DeleteWordForward | Action::DeleteWordBackward | Action::ChangeWordForward | Action::ChangeWordBackward => self.edit_normal(action, count), Action::LspCompletion => { if self.active.view.mode == Mode::Insert { return Some(Invocation { plugin: "core".into(), command: "lsp.completion".into(), args: String::new(), }); } } Action::Insert | Action::InsertFirstNonblank | Action::AppendLineEnd | Action::Append | Action::OpenBelow | Action::OpenAbove => { let before = self.snapshot(); if self.active.view.mode == Mode::Visual { self.split_rows(matches!(action, Action::AppendLineEnd), true); } self.start_insert(before, EditStart::Insert(action)); self.place_insert(action); } Action::Left | Action::Right | Action::Down | Action::Up | Action::LineStart | Action::LineEnd | Action::DocumentStart | Action::DocumentEnd | Action::GoToLine | Action::WordNext | Action::WordPrevious | Action::WordEnd | Action::BigWordNext | Action::BigWordPrevious | Action::BigWordEnd => { if self.active.view.mode == Mode::Visual { if let Some(motions) = &mut self.active.view.visual_motions { motions.push(Motion::Action(action, count)); } } else if self.active.view.mode == Mode::Insert { self.push_insert_step(InsertStep::Move(action, count)); } self.move_targets(action, count); } Action::JoinLines => { let before = self.snapshot(); self.join_lines(); self.record_native( before, EditRecipe { start: EditStart::Join, steps: Vec::new(), }, ); } Action::Delete => { if self.active.view.mode == Mode::Insert && self.edit_snippet("", Some(action)) { return None; } let before = (self.active.view.mode != Mode::Insert).then(|| self.snapshot()); let mask = self.deletion_mask(None); self.delete_forward(&mask); if let Some(before) = before { self.record_native( before, EditRecipe { start: EditStart::Delete, steps: Vec::new(), }, ); } else if mask.iter().any(|active| *active) { self.push_insert_step(InsertStep::Delete(mask)); } } Action::Undo => self.undo(), Action::Redo => self.redo(), Action::RepeatLastEdit => self.repeat_last_edit(), Action::Command | Action::Search => { if self.diff.is_some() && !self.diff_interacting() { self.diff_prompt_mode.get_or_insert(self.active.view.mode); } self.active.view.mode = if matches!(action, Action::Search) { Mode::Search } else { Mode::Command }; self.active.view.command_line.clear(); } Action::SearchNext => self.seek_search(true, count), Action::SearchPrevious => self.seek_search(false, count), Action::Select | Action::SelectLinewise => { let linewise = matches!(action, Action::SelectLinewise); if self.active.view.mode == Mode::Visual && self.active.view.linewise == linewise { self.clear_selection(); } else { if self.active.view.mode != Mode::Visual { for target in &mut self.active.view.targets { target.anchor = target.active; } self.active.view.visual_motions = Some(Vec::new()); } else { self.active.view.visual_motions = None; } self.active.view.linewise = linewise; self.active.view.mode = Mode::Visual; self.normalize_targets(); } } Action::SelectLines => self.split_rows(false, false), Action::CloneAbove => self.clone_vertical(false), Action::CloneBelow => self.clone_vertical(true), Action::SelectNext | Action::SkipOccurrence | Action::SelectAll => { self.select_occurrences(action); } Action::Copy | Action::CopySystem | Action::Cut | Action::Change => { self.edit_selection(action) } Action::PasteBefore | Action::PasteAfter => self.paste_clipboard( matches!(action, Action::PasteAfter), self.clipboard_mode == ClipboardMode::System, ), Action::PasteSystemBefore | Action::PasteSystemAfter => { self.paste_clipboard(matches!(action, Action::PasteSystemAfter), true) } Action::Normal => { if self.active.view.mode == Mode::Normal { self.collapse_primary(); } else if self.active.view.mode == Mode::Insert { self.finish_insert(); self.leave_insert(); } else { self.active.view.command_line.clear(); self.active.view.visual_motions = None; self.active.view.mode = Mode::Normal; self.normalize_targets(); } } Action::Newline => self.type_native("\n"), Action::Backspace | Action::BackspaceWord | Action::BackspaceLine => { let kind = action.backspace_kind().expect("backspace action"); if self.active.view.mode == Mode::Insert { if self.edit_snippet("", Some(action)) { return None; } let mask = self.deletion_mask(Some(kind)); self.backspace(&mask, kind); if mask.iter().any(|active| *active) { self.push_insert_step(InsertStep::Backspace(mask, kind)); } } else { kind.truncate(&mut self.active.view.command_line); } } Action::Submit => { let command = std::mem::take(&mut self.active.view.command_line); let searching = self.active.view.mode == Mode::Search; self.active.view.mode = Mode::Normal; if searching { if !command.is_empty() { self.active.view.search_query = command; } self.seek_search(true, 1); } else { return self.execute(&command); } } } None } fn seek_search(&mut self, forward: bool, count: usize) { self.reveal_caret(); let source = self.jump_location(); self.active.view.search_match = None; if self.active.view.search_query.is_empty() { self.set_message("No previous search"); return; } let query = self.active.view.search_query.as_str(); // ponytail: O(buffer graphemes × query bytes) scan; index matches if large buffers need it. let matches: Vec<_> = self .navigable_rows(0, self.active.lines.len()) .flat_map(|row| { let line = &self.active.lines[row]; line.grapheme_indices(true).filter_map(move |(byte, _)| { (line[byte..].starts_with(query) && GraphemeCursor::new(byte + query.len(), line.len(), true) .is_boundary(line, 0) .expect("complete line supplies grapheme context")) .then_some((row, byte)) }) }) .collect(); if matches.is_empty() { self.set_message(format!("Not found: {}", self.active.view.search_query)); return; } for (index, target) in self.active.view.targets.iter_mut().enumerate() { let origin = ( target.active.row, grapheme_byte(&self.active.lines[target.active.row], target.active.column), ); let first = if forward { matches.partition_point(|position| *position <= origin) % matches.len() } else { matches .partition_point(|position| *position < origin) .checked_sub(1) .unwrap_or(matches.len() - 1) }; let distance = (count - 1) % matches.len(); let destination = if forward { (first + distance) % matches.len() } else { (first + matches.len() - distance) % matches.len() }; let found = matches[destination]; target.active = Cursor { row: found.0, column: column_after_byte(&self.active.lines[found.0], found.1), }; target.preferred_columns = None; if index == self.active.view.primary { self.active.view.search_match = Some(SearchMatch { start: target.active, end_column: target.active.column + grapheme_count(query), }); } } self.active.view.visual_motions = None; self.normalize_targets(); self.record_jump(source); } fn jump_location(&self) -> JumpLocation { let diff = self.diff_buffer_focus().and_then(|text_focus| { let diff = self.diff.as_ref()?; Some(DiffJumpLocation { comparison_id: diff.comparison_id?, file: diff.file, focus: diff.focus, text_focus, live_buffer: diff.live_buffer, }) }); JumpLocation { tab_id: self.tab_id, pane_id: self.pane_id, buffer_id: self.active.id, cursor: self.cursor(), diff, } } fn record_jump(&mut self, source: JumpLocation) { let destination = self.jump_location(); if source == destination { return; } if self.jumps.is_empty() { self.jumps.push_back(source); } else { self.jumps.truncate(self.jump_index + 1); self.jumps[self.jump_index] = source; } self.jumps.push_back(destination); if self.jumps.len() > 100 { self.jumps.pop_front(); } self.jump_index = self.jumps.len() - 1; } fn resolve_jump(&self, mut location: JumpLocation) -> Option { let buffer = self.buffer(location.buffer_id)?; location.cursor.row = location.cursor.row.min(buffer.lines.len() - 1); location.cursor.column = location .cursor .column .min(grapheme_count(&buffer.lines[location.cursor.row]).saturating_sub(1)); let pane_tab = |pane| { if self.pane_layout.contains(pane) { Some(self.tab_id) } else { self.tabs .iter() .find(|(_, tab)| tab.pane_layout.contains(pane)) .map(|(id, _)| *id) } }; let owner = |tab| { if tab == self.tab_id { self.diff_owner } else { self.tabs.get(&tab).and_then(|tab| tab.diff_owner) } }; let original_pane = pane_tab(location.pane_id) == Some(location.tab_id); if original_pane && let Some(context) = location.diff && owner(location.tab_id) == Some(location.pane_id) && self.tab_diff(location.tab_id).is_some_and(|diff| { diff.phase == Phase::View && diff.comparison_id == Some(context.comparison_id) && diff .comparison .as_ref() .and_then(|comparison| comparison.files.get(context.file)) .is_some_and(|file| file.loaded) }) { return Some(location); } let ordinary_pane = |pane| { let tab = pane_tab(pane)?; (owner(tab) != Some(pane)).then_some((tab, pane)) }; // Closed/replaced comparisons are never revived or displaced by history. let (tab, pane) = original_pane .then(|| ordinary_pane(location.pane_id)) .flatten() .or_else(|| { self.panes.iter().find_map(|(id, pane)| { (pane.buffer_id == location.buffer_id) .then(|| ordinary_pane(*id)) .flatten() }) }) .or_else(|| ordinary_pane(self.pane_id)) .or_else(|| self.panes.keys().find_map(|id| ordinary_pane(*id)))?; location.tab_id = tab; location.pane_id = pane; location.diff = None; Some(location) } fn restore_jump(&mut self, location: JumpLocation) { self.finish_pane_interaction(); self.select_tab(location.tab_id); self.select_pane(location.pane_id); self.switch_buffer(location.buffer_id); if let Some(context) = location.diff { let live_buffer = context.live_buffer.filter(|id| self.has_buffer(*id)); let diff = self.diff.as_mut().expect("resolved comparison exists"); if diff.file != context.file { diff.select_file_index(context.file); } if diff.live_buffer != live_buffer { diff.live_buffer = live_buffer; diff.live_revision = None; } diff.focus = context.focus; if context.focus == Focus::Files { diff.files_visible = true; } diff.normalize_focus(); if let Some(files) = &mut diff.files { files.focused = diff.files_visible && diff.focus == Focus::Files; } if context.text_focus == Focus::After && self.active.diff_snapshot.is_none() { diff.live_buffer = Some(self.active.id); } self.diff_bound = Some(( context.comparison_id, context.file, context.text_focus, self.active.id, )); self.refresh_live_diff(); self.diff .as_mut() .expect("resolved comparison exists") .reveal_source_row(context.text_focus, location.cursor.row); } self.place_navigation(TextPosition { row: location.cursor.row, column: location.cursor.column, }); } fn traverse_jumps(&mut self, forward: bool, count: usize) { self.reveal_caret(); let departure = self.jump_location(); let mut destination = departure; let mut index = self.jump_index; let mut remaining = count; let mut destination_index = None; // The timeline bounds this scan, even for a saturated count. while remaining > 0 { let next = if forward { index.checked_add(1).filter(|next| *next < self.jumps.len()) } else { index.checked_sub(1) }; let Some(next) = next else { break }; index = next; let Some(location) = self.resolve_jump(self.jumps[index]) else { continue; }; if location == destination { continue; } destination = location; destination_index = Some(index); remaining -= 1; } let Some(index) = destination_index else { self.set_message(if forward { "No newer jump location" } else { "No older jump location" }); return; }; self.jumps[self.jump_index] = departure; self.restore_jump(destination); self.jump_index = index; } fn park_source(&mut self) { self.finish_completion(); self.snippet = None; self.clear_hover(); if self.active.view.mode == Mode::Insert { self.finish_insert(); } if matches!( self.active.view.mode, Mode::Insert | Mode::Command | Mode::Search ) { self.active.view.mode = Mode::Normal; self.normalize_targets(); } self.active.view.command_line.clear(); self.count = None; self.clear_sequence(); self.palette = None; } fn activate(&mut self, buffer: Buffer) { self.park_source(); self.stash_view(); let previous = std::mem::replace(&mut self.active, buffer); self.parked.insert(previous.id, previous); self.load_view(); self.reveal_caret(); } fn switch_buffer(&mut self, id: usize) { if id == self.active.id { self.reveal_caret(); self.park_source(); } 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 { let mut buffers = self.buffers().filter(|buffer| !buffer.active); if forward { buffers .find(|buffer| buffer.id > self.active.id) .or_else(|| self.buffers().find(|buffer| !buffer.active)) } else { buffers .filter(|buffer| buffer.id < self.active.id) .last() .or_else(|| self.buffers().filter(|buffer| !buffer.active).last()) } .map_or(self.active.id, |buffer| buffer.id) } fn edit_buffer(&mut self, path: PathBuf, allow_missing: bool) -> io::Result<()> { let identity = path_identity(&path)?; 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 Ok(()); } let buffer = Buffer::open( self.next_buffer_id, Some(path), Some(identity), allow_missing, )?; self.add_buffer(buffer); Ok(()) } fn add_buffer(&mut self, buffer: Buffer) { self.next_buffer_id += 1; self.activate(buffer); } 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; } let owners: Vec<_> = self .diff_owner .filter(|_| { self.diff_return_buffer == Some(id) || self .workspace_diff() .is_some_and(|diff| diff.live_buffer == Some(id)) }) .into_iter() .chain( self.tabs .values() .filter(|tab| { tab.diff_return_buffer == Some(id) || tab.diff().is_some_and(|diff| diff.live_buffer == Some(id)) }) .filter_map(|tab| tab.diff_owner), ) .collect(); for owner in owners { self.with_pane(owner, |editor| { editor.release_diff_buffers(); editor.diff = None; editor.parked_diff = None; editor.diff_owner = None; editor.outstanding_diff_request = None; }); } let replacement_id = if id == self.active.id { let next = self.adjacent_buffer(true); if next == id { let buffer = Buffer::open(self.next_buffer_id, None, None, true) .expect("an unnamed empty buffer is valid"); self.next_buffer_id += 1; self.activate(buffer); } else { self.switch_buffer(next); } self.active.id } else { self.active.id }; self.parked.remove(&id); for pane in self.panes.values_mut() { pane.views.remove(&id); if pane.buffer_id == id { pane.buffer_id = replacement_id; pane.views .entry(replacement_id) .or_insert_with(|| ViewState { targets: vec![Target::caret(Cursor::default())], ..ViewState::empty() }); } } self.set_message(format!( "Deleted buffer {id}; active buffer {}", self.active.id )); } fn buffer_command(&mut self, action: Action, name: &str, args: &str) -> bool { let source = self.jump_location(); match action { Action::Edit if args.is_empty() => self.set_message("Usage: :edit path"), Action::Edit => { if let Err(error) = self.edit_buffer(PathBuf::from(args), true) { self.set_message(format!("Open failed: {error}")); } } Action::Buffers | Action::BufferNext | Action::BufferPrevious if !args.is_empty() => { self.set_message(format!("The {name} command takes no arguments")); } Action::Buffers => { self.prepare_command(); self.palette = Some(Palette { query: String::new(), entries: Vec::new(), selected: 0, buffers: true, }); self.refresh_palette(); } Action::BufferNext | Action::BufferPrevious => { self.switch_buffer(self.adjacent_buffer(action == Action::BufferNext)) } Action::Buffer | Action::BufferDelete | Action::BufferForceDelete => { let id = if args.is_empty() && action != Action::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 self .buffer(id) .is_some_and(|buffer| buffer.diff_snapshot.is_some()) => { self.set_message(format!("No buffer {id}")) } Some(id) if action == Action::Buffer => self.switch_buffer(id), Some(id) => self.delete_buffer(id, action == Action::BufferForceDelete), None => self.set_message(format!( "Usage: :{name} {}", if action == Action::Buffer { "N" } else { "[N]" }, )), } } _ => return false, } if matches!( action, Action::Edit | Action::Buffer | Action::BufferNext | Action::BufferPrevious ) { self.record_jump(source); } true } fn any_dirty(&self) -> bool { self.active.dirty() || self.parked.values().any(Buffer::dirty) } fn restart(&mut self, force: bool) { if force || !self.any_dirty() { self.restart = true; return; } let owners: Vec<_> = self .diff_owner .into_iter() .chain(self.tabs.values().filter_map(|tab| tab.diff_owner)) .collect(); for owner in owners { self.with_pane(owner, |editor| { editor.execute_action(Action::DiffClose.entry(), ""); }); } if !self.active.dirty() { let dirty = self.buffers().find(|buffer| buffer.dirty).unwrap().id; self.switch_buffer(dirty); } let clean: Vec<_> = self .parked .values() .filter(|buffer| !buffer.dirty()) .map(|buffer| buffer.id) .collect(); for id in clean { if self.has_buffer(id) { self.delete_buffer(id, false); } } let count = self.buffers().filter(|buffer| buffer.dirty).count(); self.set_message(format!( "Restart blocked: {count} buffer(s) have unsaved changes; clean buffers closed. Save them or use :restart!" )); } pub fn execute(&mut self, command: &str) -> Option { self.pending_lsp_navigation = None; self.finish_diff_prompt(); let invocation = self.execute_command(command); self.leave_diff_on_navigation(); self.refresh_live_diff(); invocation } fn execute_command(&mut self, command: &str) -> Option { 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(); if let Some(entry) = core_command(name) { return self.execute_action(entry, args); } self.pane_close_picker_origin = None; if self.diff.is_some() && !self.diff_editing() { self.count = None; self.clear_sequence(); self.palette = None; self.active.view.command_line.clear(); } else { self.prepare_command(); self.finish_command_mode(); } let Some(feature) = self.owner(name) else { self.set_message(format!("Unknown command: {name}")); return None; }; Some(Invocation { plugin: feature.name.clone(), command: name.into(), args: args.into(), }) } fn prepare_command(&mut self) { self.finish_completion(); self.snippet = None; self.clear_hover(); self.count = None; self.clear_sequence(); self.palette = None; self.blur_file_picker(); } fn finish_command_mode(&mut self) { if self.active.view.mode == Mode::Insert { self.finish_insert(); } self.clear_selection(); self.active.view.mode = Mode::Normal; self.normalize_targets(); } fn execute_action(&mut self, entry: &CoreAction, args: &str) -> Option { if !matches!(entry.action, Action::PaneClose | Action::PaneForceClose) { self.pane_close_picker_origin = None; } self.finish_diff_prompt(); if matches!( entry.action, Action::PaneSplitHorizontal | Action::PaneSplitVertical | Action::PaneFocusLeft | Action::PaneFocusDown | Action::PaneFocusUp | Action::PaneFocusRight | Action::PaneClose | Action::PaneForceClose | Action::TabNew | Action::TabPrevious | Action::TabNext | Action::TabClose | Action::TabForceClose ) { if !args.is_empty() { self.pane_close_picker_origin = None; self.set_message(format!( "The {} command takes no arguments", entry.ex.unwrap() )); return None; } match entry.action { Action::PaneSplitHorizontal => self.split_pane(SplitAxis::Horizontal), Action::PaneSplitVertical => self.split_pane(SplitAxis::Vertical), Action::PaneFocusLeft => self.focus_pane(PaneDirection::Left), Action::PaneFocusDown => self.focus_pane(PaneDirection::Down), Action::PaneFocusUp => self.focus_pane(PaneDirection::Up), Action::PaneFocusRight => self.focus_pane(PaneDirection::Right), Action::PaneClose => self.close_pane(false), Action::PaneForceClose => self.close_pane(true), Action::TabNew => self.new_tab(), Action::TabPrevious => self.adjacent_tab(false), Action::TabNext => self.adjacent_tab(true), Action::TabClose => self.close_tab(false), Action::TabForceClose => self.close_tab(true), _ => unreachable!("pane actions handled above"), } return None; } if matches!( entry.action, Action::DiffModeToggle | Action::DiffViewToggle | Action::DiffOrientationToggle | Action::DiffReviewToggle ) { let name = entry.ex.expect("diff action has an Ex command"); if !args.is_empty() { self.set_message(format!("The {name} command takes no arguments")); return None; } if entry.action == Action::DiffReviewToggle && self .file_picker .as_ref() .is_some_and(|picker| picker.focused) { self.set_message("Select a file in the comparison"); return None; } if let Some(owner) = self.diff_owner.filter(|owner| *owner != self.pane_id) { return self.with_pane(owner, |editor| editor.execute_action(entry, args)); } self.count = None; self.clear_sequence(); self.palette = None; if self.active.view.mode == Mode::Command { self.active.view.command_line.clear(); self.active.view.mode = Mode::Normal; } if entry.action == Action::DiffModeToggle { let Some(diff) = self.diff.as_mut() else { self.set_message("No comparison is open"); return None; }; if let Err(error) = diff.toggle_comparison_mode() { self.set_message(error); return None; } self.release_diff_buffers(); self.diff_return_buffer = Some(self.active.id); return None; } let Some(diff) = self.diff.as_mut().filter(|diff| diff.phase == Phase::View) else { self.set_message("No comparison is open"); return None; }; if entry.action == Action::DiffReviewToggle { match diff.toggle_reviewed() { Ok(true) => self.set_message("File marked reviewed"), Ok(false) => self.set_message("File marked unreviewed"), Err(error) => self.set_message(error), } return None; } if entry.action == Action::DiffViewToggle { diff.set_view_mode(match diff.view_mode { ViewMode::Split => ViewMode::Unified, ViewMode::Unified => ViewMode::Split, }); } else if diff.view_mode == ViewMode::Split { diff.split_axis = match diff.split_axis { SplitAxis::Vertical => SplitAxis::Horizontal, SplitAxis::Horizontal => SplitAxis::Vertical, }; } else { self.set_message("Diff orientation applies only to split view"); } return None; } if entry.action == Action::GitBlameToggle { if !args.is_empty() { self.set_message("The git_blame.toggle command takes no arguments"); return None; } self.set_git_blame(!self.git_blame); self.count = None; self.clear_sequence(); self.palette = None; if self.active.view.mode == Mode::Command { self.active.view.command_line.clear(); self.active.view.mode = Mode::Normal; } return None; } if entry.action == Action::WordWrapToggle { if !args.is_empty() { self.set_message("The word_wrap.toggle command takes no arguments"); return None; } if let Some(diff) = &mut self.diff && diff.phase == Phase::View { diff.word_wrap = !diff.word_wrap; diff.follow_caret = true; } else { self.active.view.word_wrap = !self.active.view.word_wrap; } self.count = None; self.clear_sequence(); self.palette = None; if self.active.view.mode == Mode::Command { self.active.view.command_line.clear(); self.active.view.mode = Mode::Normal; } return None; } if entry.action == Action::DiffClose { if let Some(owner) = self.diff_owner.filter(|owner| *owner != self.pane_id) { return self.with_pane(owner, |editor| editor.execute_action(entry, args)); } if args.is_empty() { self.diff.as_ref()?; self.release_diff_buffers(); self.diff = None; self.diff_owner = None; self.outstanding_diff_request = None; self.palette = None; self.count = None; self.clear_sequence(); if self.active.view.mode == Mode::Command { self.active.view.command_line.clear(); self.active.view.mode = Mode::Normal; } } else { self.set_message("The diff.close command takes no arguments"); } return None; } if self.mutation_blocked() && entry.action.mutates(self.active.view.mode) { self.set_message("Diff pane is read-only"); return None; } if entry.action == Action::Diff { if args.is_empty() { self.open_diff(); } else { self.set_message("The diff command takes no arguments"); } return None; } let read_only = self.diff.is_some() && !self.diff_editing(); if !read_only { self.prepare_command(); } let action = entry.action; let name = entry .ex .expect("Ex dispatcher requires a canonical command"); // Switching must park directed Visual selections before ordinary Ex cleanup. if self.buffer_command(action, name, args) { return None; } // Link preflight must precede Ex cleanup: failure retains carets and text. if action == Action::FollowLink { if !args.is_empty() { self.set_message("The follow_link command takes no arguments"); } else if let Err(error) = self.follow_link() { self.set_message(error); } return None; } if !read_only { self.finish_command_mode(); } match action { Action::LspDefinition | Action::LspHover | Action::LspDiagnostics if !args.is_empty() => { self.set_message(format!("The {name} command takes no arguments")); } Action::LspDefinition | Action::LspHover | Action::LspDiagnostics => { if !self.document_actions_available() { self.set_message("LSP commands require a diff text pane"); return None; } return Some(Invocation { plugin: "core".into(), command: name.into(), args: String::new(), }); } Action::Save | Action::ForceSave | Action::SaveAndQuit | Action::ForceSaveAndQuit => { let force = matches!(action, Action::ForceSave | Action::ForceSaveAndQuit); if self.save( if args.is_empty() { None } else { Some(PathBuf::from(args)) }, force, ) && matches!(action, Action::SaveAndQuit | Action::ForceSaveAndQuit) { if self.any_dirty() { self.set_message("Unsaved changes in other buffers; use :w or :q!"); } else { self.quit = true; } } } Action::Quit | Action::ForceQuit if !args.is_empty() => { self.set_message("Quit commands take no arguments") } Action::Quit if self.any_dirty() => self.set_message("Unsaved changes; use :w or :q!"), Action::Quit | Action::ForceQuit => self.quit = true, Action::Restart | Action::ForceRestart if !args.is_empty() => { self.set_message("Restart commands take no arguments"); } Action::Restart | Action::ForceRestart => { self.restart(action == Action::ForceRestart); } Action::Plugins if !args.is_empty() => { self.set_message("The plugins command takes no arguments") } Action::Plugins => { let mut features: Vec<_> = self.features.values().collect(); features.sort_unstable_by(|left, right| left.name.cmp(&right.name)); let mut message = format!("Capabilities: {}", self.capabilities.join(", ")); for feature in features { message.push_str(" | "); message.push_str(&feature.name); if feature.core { message.push_str(" [core]"); } message.push_str(": "); for (index, command) in feature.commands.iter().enumerate() { if index > 0 { message.push_str(", "); } message.push_str(command); } } self.set_message(message); } _ => unreachable!("privately installed core commands"), } None } /// Applies one validated plugin response. Successful text effects collapse /// to the primary caret: Insert runs once there, Replace replaces the buffer. /// Rejected responses and presentation-only effects retain the complete set. pub fn apply(&mut self, effects: Vec) -> Result<(), String> { if self.mutation_blocked() && effects .iter() .any(|effect| matches!(effect, Effect::Insert(_) | Effect::Replace(_))) { return Err("Diff view is read-only".into()); } validate_effects(&effects)?; if !effects .iter() .any(|effect| matches!(effect, Effect::Insert(_) | Effect::Replace(_))) { for effect in effects { match effect { Effect::Message(text) => self.set_message(text), Effect::Label { name, text } => { self.set_label(name, text)?; } _ => unreachable!("presentation-only effects"), } } return Ok(()); } // Validation precedes even history/presentation changes, so rejection is atomic. let previous_mode = self.active.view.mode; let inserting = self.active.view.mode == Mode::Insert; if inserting { self.finish_insert(); } self.finish_completion(); let before = effects .iter() .any(|effect| { matches!(effect, Effect::Insert(text) if !text.is_empty()) || matches!(effect, Effect::Replace(_)) }) .then(|| self.snapshot()); if before.is_some() { self.clear_hover(); self.count = None; self.clear_sequence(); self.clear_selection(); self.collapse_primary(); // Plugin inserts use one primary insertion point, not the selection set. self.active.view.mode = Mode::Insert; } for effect in effects { match effect { Effect::Insert(text) => self.insert_text(&normalize_newlines(&text)), Effect::Replace(text) => { let lines = split_lines(&text); if lines != self.active.lines { self.active.text_changed(); } self.active.lines = lines; self.clamp_other_views(); self.active.view.search_match = None; self.normalize_targets(); } Effect::Message(text) => self.set_message(text), Effect::Label { name, text } => { self.set_label(name, text)?; } } } if before.is_some() { self.active.view.mode = if inserting { Mode::Insert } else { Mode::Normal }; } self.normalize_targets(); if let Some(before) = before { self.record(before); } if inserting { self.begin_insert(); } if self.active.view.mode != previous_mode { self.refresh_palette(); } self.refresh_live_diff(); Ok(()) } /// Refreshes every live buffer, including views parked in inactive tabs. pub(crate) fn refresh_files(&mut self) -> bool { let active_id = self.active.id; let mut changed = false; let mut active_reloaded = false; let mut notice = None; for buffer in std::iter::once(&mut self.active).chain(self.parked.values_mut()) { match buffer.refresh_file() { Ok(reloaded) => { buffer.reload_error = None; if !reloaded { continue; } changed = true; active_reloaded |= buffer.id == active_id; for pane in self.panes.values_mut() { if let Some(view) = pane.views.get_mut(&buffer.id) { view.reload(&buffer.lines); } } } Err(error) => { let message = format!( "Reload failed for {}: {error}", buffer.path.as_deref().unwrap().display(), ); if buffer.reload_error.as_ref() != Some(&message) { buffer.reload_error = Some(message.clone()); notice = Some(message); changed = true; } } } } if active_reloaded { self.completion = None; self.snippet = None; self.clear_hover(); self.pending_lsp_navigation = None; self.normalize_targets(); } if let Some(notice) = notice { self.set_message(notice); } if changed { self.refresh_live_diff(); } changed } fn save(&mut self, path: Option, force: bool) -> bool { if self.mutation_blocked() { self.set_message("Diff pane is read-only"); return false; } let Some(path) = path.or_else(|| self.active.path.clone()) else { self.set_message("No file name; use :w path"); return false; }; 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; } let expected = (!force && (self.active.identity.as_ref() == Some(&identity) || self.active.path.as_ref() == Some(&path))) .then_some(( self.active.saved_lines.as_slice(), self.active.disk_stamp.is_some(), )); match atomic_save(&path, &self.active.lines, expected) { Ok(stamp) => { self.active.saved_lines.clone_from(&self.active.lines); self.active.save_revision = self.active.save_revision.wrapping_add(1); self.active.disk_stamp = Some(stamp); self.active.reload_error = None; if self.active.path.as_ref() != Some(&path) { self.active.semantic_tokens = None; } if self.active.identity.as_ref() != Some(&identity) && let Some(diff) = &mut self.diff { diff.live_buffer = None; diff.live_revision = None; } self.active.path = Some(path); self.active.identity = Some(identity); self.set_message("Written"); true } Err(error) => { self.set_message(format!("Write failed: {error}")); false } } } fn snapshot(&self) -> Snapshot { Snapshot { lines: self.active.lines.clone(), targets: self.active.view.targets.clone(), primary: self.active.view.primary, } } fn record(&mut self, before: Snapshot) { if before.lines != self.active.lines { self.active.view.search_match = None; self.active.undo.push(before); self.active.redo.clear(); } } fn record_native(&mut self, before: Snapshot, recipe: EditRecipe) { if before.lines != self.active.lines { self.last_edit = Some(recipe); } self.record(before); } fn target_range(&self, target: Target) -> Selection { if self.visual_linewise() { let first = target.anchor.row.min(target.active.row); let last = target.anchor.row.max(target.active.row); return Selection { start: Cursor { row: first, column: 0, }, end: if last + 1 < self.active.lines.len() { Cursor { row: last + 1, column: 0, } } else { Cursor { row: last, column: grapheme_count(&self.active.lines[last]), } }, }; } let start = target.anchor.min(target.active); let end = self.next_boundary(target.anchor.max(target.active)); Selection { start, end } } fn next_boundary(&self, mut cursor: Cursor) -> Cursor { if cursor.column < grapheme_count(&self.active.lines[cursor.row]) { cursor.column += 1; } else if cursor.row + 1 < self.active.lines.len() { cursor.row += 1; cursor.column = 0; } cursor } fn previous_boundary(&self, mut cursor: Cursor) -> Cursor { if cursor.column > 0 { cursor.column -= 1; } else if cursor.row > 0 { cursor.row -= 1; cursor.column = grapheme_count(&self.active.lines[cursor.row]); } cursor } fn range_target(&self, range: Selection, forward: bool) -> Target { let last = if range.start == range.end { range.start } else { self.previous_boundary(range.end) }; Target { anchor: if forward { range.start } else { last }, active: if forward { last } else { range.start }, preferred_columns: None, } } fn max_column(&self, row: usize) -> usize { let count = grapheme_count(&self.active.lines[row]); if self.active.view.mode == Mode::Insert || (self.active.view.mode == Mode::Visual && row + 1 < self.active.lines.len()) { count } else { count.saturating_sub(1) } } fn normalize_targets(&mut self) { if self.active.view.mode != Mode::Visual { self.active.view.linewise = false; } for index in 0..self.active.view.targets.len() { let mut target = self.active.view.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.active.view.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.active.view.targets[index] = target; } if self.visual_linewise() { let mut ordered: Vec<_> = self .active .view .targets .iter() .copied() .enumerate() .map(|(index, target)| (target, index == self.active.view.primary)) .collect(); ordered.sort_by_key(|(target, _)| { ( target.anchor.row.min(target.active.row), target.anchor.row.max(target.active.row), ) }); let mut merged: Vec<(Target, bool)> = Vec::with_capacity(ordered.len()); for (target, primary) in ordered { if let Some((previous, was_primary)) = merged.last_mut() && target.anchor.row.min(target.active.row) <= previous.anchor.row.max(previous.active.row) { let first = previous .anchor .row .min(previous.active.row) .min(target.anchor.row.min(target.active.row)); let last = previous .anchor .row .max(previous.active.row) .max(target.anchor.row.max(target.active.row)); let representative = if primary { target } else { *previous }; *previous = representative; if representative.active >= representative.anchor { previous.anchor.row = first; previous.active.row = last; } else { previous.anchor.row = last; previous.active.row = first; } previous.anchor.column = previous .anchor .column .min(self.max_column(previous.anchor.row)); previous.active.column = previous .active .column .min(self.max_column(previous.active.row)); *was_primary |= primary; } else { merged.push((target, primary)); } } if merged.len() != self.active.view.targets.len() { self.active.view.visual_motions = None; } self.active.view.primary = merged.iter().position(|(_, primary)| *primary).unwrap_or(0); self.active.view.targets.clear(); self.active .view .targets .extend(merged.into_iter().map(|(target, _)| target)); return; } let ordered: Vec<_> = self .active .view .targets .iter() .copied() .enumerate() .map(|(index, target)| { let range = if self.active.view.mode == Mode::Visual { self.target_range(target) } else { Selection { start: target.active, end: target.active, } }; (target, index == self.active.view.primary, range) }) .collect(); self.merge_target_ranges(ordered, false); } fn merge_target_ranges( &mut self, mut ordered: Vec<(Target, bool, Selection)>, keep_ranges: bool, ) -> Vec { let count = ordered.len(); ordered.sort_by_key(|(_, _, range)| (range.start, range.end)); let mut merged: Vec<(Target, bool, Selection)> = Vec::with_capacity(ordered.len()); for (target, primary, range) in ordered { if let Some((previous, was_primary, previous_range)) = merged.last_mut() && ranges_intersect(*previous_range, range) { 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.active.view.mode == Mode::Visual && self.target_range(representative) != *previous_range { self.range_target( *previous_range, representative.active >= representative.anchor, ) } else { representative }; *was_primary |= primary; continue; } merged.push((target, primary, range)); } if merged.len() != count { self.active.view.visual_motions = None; } let ranges = if keep_ranges { merged.iter().map(|(_, _, range)| *range).collect() } else { Vec::new() }; self.active.view.primary = merged .iter() .position(|(_, primary, _)| *primary) .unwrap_or(0); self.active.view.targets.clear(); self.active .view .targets .extend(merged.into_iter().map(|(target, _, _)| target)); ranges } fn collapse_primary(&mut self) { let target = self.active.view.targets[self.active.view.primary]; self.active.view.targets.clear(); self.active.view.targets.push(target); self.active.view.primary = 0; } fn clear_selection(&mut self) { if self.active.view.mode == Mode::Visual { self.active.view.visual_motions = None; self.active.view.mode = Mode::Normal; self.normalize_targets(); } } fn split_rows(&mut self, end: bool, insert: bool) { self.active.view.visual_motions = None; let rows: Vec<_> = if self.visual_linewise() { self.linewise_rows().collect() } else { self.selections() .into_iter() .map(|range| { let last = if range.end.column == 0 && range.end.row > range.start.row { range.end.row - 1 } else { range.end.row }; range.start.row..=last }) .collect() }; let primary_row = self.cursor().row; let mut carets = Vec::new(); let mut primary = 0; for (index, rows) in rows.into_iter().enumerate() { for row in self.navigable_rows(*rows.start(), rows.end().saturating_add(1)) { if index == self.active.view.primary && row == primary_row { primary = carets.len(); } let column = if end { grapheme_count(&self.active.lines[row]) } else { first_nonblank(&self.active.lines[row]) }; carets.push(Target::caret(Cursor { row, column })); } } self.active.view.targets = carets; self.active.view.primary = primary; self.active.view.mode = if insert { Mode::Insert } else { Mode::Normal }; self.normalize_targets(); } fn clone_vertical(&mut self, down: bool) { self.active.view.visual_motions = None; let count = self.active.view.targets.len(); let primary = self.active.view.primary; for index in 0..count { let target = self.active.view.targets[index]; let shift = |row: usize| { if down { self.navigable_rows(row.saturating_add(1), self.active.lines.len()) .next() } else { self.navigable_rows(0, row).next_back() } }; let Some(anchor_row) = shift(target.anchor.row) else { continue; }; let Some(active_row) = shift(target.active.row) else { continue; }; let columns = target.preferred_columns.unwrap_or_else(|| { ( display_column(&self.active.lines[target.anchor.row], target.anchor.column), display_column(&self.active.lines[target.active.row], target.active.column), ) }); self.active.view.targets[index].preferred_columns = Some(columns); let cloned = Target { anchor: Cursor { row: anchor_row, column: column_at_display( &self.active.lines[anchor_row], columns.0, self.max_column(anchor_row), ), }, active: Cursor { row: active_row, column: column_at_display( &self.active.lines[active_row], columns.1, self.max_column(active_row), ), }, preferred_columns: Some(columns), }; if index == primary { self.active.view.primary = self.active.view.targets.len(); } self.active.view.targets.push(cloned); } self.normalize_targets(); } fn select_occurrences(&mut self, action: Action) { // Occurrences are literal text ranges, so rebuilding them is characterwise. if self.visual_linewise() { let ranges = self.selections(); let targets = self .active .view .targets .iter() .zip(ranges) .map(|(target, range)| self.range_target(range, target.active >= target.anchor)) .collect(); self.active.view.targets = targets; self.active.view.linewise = false; self.active.view.visual_motions = None; } let text = self.active.lines.join("\n"); let offsets = line_offsets(&self.active.lines); let ranges = self.selections(); let primary_range = ranges[self.active.view.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; } let phrase = &text[start..end]; let mut occupied: Vec<_> = ranges .iter() .map(|range| { ( cursor_offset(&self.active.lines, &offsets, range.start), cursor_offset(&self.active.lines, &offsets, range.end), ) }) .collect(); // ponytail: literal scan is O(buffer graphemes × phrase bytes); index only for large buffers. let diff = self .diff_buffer_focus() .and_then(|focus| self.diff.as_ref().map(|diff| (diff, focus))); let candidates: Vec<_> = self .navigable_rows(0, self.active.lines.len()) .flat_map(|row| { let offset = offsets[row]; let line = &self.active.lines[row]; line.grapheme_indices(true) .map(move |(byte, _)| offset + byte) .chain(std::iter::once(offset + line.len())) }) .filter_map(|byte| { let end = byte + phrase.len(); (text[byte..].starts_with(phrase) && GraphemeCursor::new(end, text.len(), true) .is_boundary(&text, 0) .expect("complete buffer supplies grapheme context") && diff.is_none_or(|(diff, focus)| { let last = offsets .partition_point(|offset| *offset < end) .saturating_sub(1); diff.source_display_row(focus, last).is_some() })) .then_some((byte, end)) }) .collect(); if matches!(action, Action::SelectAll) { for (start, end) in candidates { if occupied .iter() .any(|(left, right)| start < *right && *left < end) { continue; } occupied.push((start, end)); self.active.view.targets.push(self.range_target( Selection { start: offset_cursor(&self.active.lines, &offsets, start, true), end: offset_cursor(&self.active.lines, &offsets, end, true), }, true, )); } } else { let available = |(left, right): &(usize, usize)| { !occupied .iter() .any(|(start, end)| *left < *end && *start < *right) }; let next = candidates .iter() .filter(|range| available(range)) .find(|(left, _)| *left > start) .or_else(|| candidates.iter().find(|range| available(range))); let Some(&(start, end)) = next else { self.set_message("No more occurrences"); return; }; let target = self.range_target( Selection { 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.active.view.targets[self.active.view.primary] = target; } else { self.active.view.primary = self.active.view.targets.len(); self.active.view.targets.push(target); } } self.active.view.visual_motions = None; self.normalize_targets(); } fn line_selections(&mut self, count: usize) -> Vec { self.active.view.mode = Mode::Visual; self.active.view.linewise = true; self.active.view.visual_motions = None; let last = self.active.lines.len() - 1; for target in &mut self.active.view.targets { target.anchor = Cursor { row: target.active.row, column: 0, }; target.active = Cursor { row: target .anchor .row .saturating_add(count.saturating_sub(1)) .min(last), column: 0, }; target.preferred_columns = None; } self.normalize_targets(); self.selections() } fn copy_lines(&mut self, count: usize) { let targets = self.active.view.targets.clone(); let primary = self.active.view.primary; let mode = self.active.view.mode; let linewise = self.active.view.linewise; let visual_motions = self.active.view.visual_motions.take(); let ranges = self.line_selections(count); let fragments = self.selection_fragments(&ranges); self.active.view.targets = targets; self.active.view.primary = primary; self.active.view.mode = mode; self.active.view.linewise = linewise; self.active.view.visual_motions = visual_motions; match self.store_clipboard( fragments, self.clipboard_mode == ClipboardMode::System, true, ) { Ok(()) => self.set_message("Copied lines"), Err(error) => self.set_message(error), } } fn edit_lines(&mut self, change: bool, count: usize) { let targets = self.active.view.targets.clone(); let primary = self.active.view.primary; let ranges = self.line_selections(count); let fragments = self.selection_fragments(&ranges); if let Err(error) = self.store_clipboard( fragments, self.clipboard_mode == ClipboardMode::System, true, ) { self.active.view.targets = targets; self.active.view.primary = primary; self.active.view.mode = Mode::Normal; self.active.view.linewise = false; self.set_message(error); return; } let before = Snapshot { lines: self.active.lines.clone(), targets, primary, }; let start = EditStart::Lines { change, count }; if change { self.start_insert(before, start); self.edit_ranges(Action::Change, &ranges); } else { self.edit_ranges(Action::Cut, &ranges); self.active.view.mode = Mode::Normal; self.normalize_targets(); self.record_native( before, EditRecipe { start, steps: Vec::new(), }, ); } self.reveal_caret(); } fn normal_ranges(&mut self, action: Action, count: usize) -> Vec { // Insert boundaries include EOF; Normal word motions clamp it to the last grapheme. self.active.view.mode = Mode::Insert; self.active.view.linewise = false; let ordered = self .active .view .targets .iter() .copied() .enumerate() .map(|(index, target)| { let cursor = target.active; let end = match action { Action::DeleteWordForward | Action::ChangeWordForward => { self.word_destination(cursor, Action::DeleteWordForward, count) } Action::DeleteWordBackward | Action::ChangeWordBackward => { self.word_destination(cursor, Action::DeleteWordBackward, count) } Action::SubstituteCharacter => Cursor { row: cursor.row, column: cursor .column .saturating_add(count) .min(grapheme_count(&self.active.lines[cursor.row])), }, _ => Cursor { row: cursor.row, column: grapheme_count(&self.active.lines[cursor.row]), }, }; ( target, index == self.active.view.primary, Selection { start: cursor.min(end), end: cursor.max(end), }, ) }) .collect(); self.merge_target_ranges(ordered, true) } fn edit_normal(&mut self, action: Action, count: usize) { let copy = action == Action::CopyToLineEnd; let saved = copy.then(|| (self.active.view.targets.clone(), self.active.view.primary)); let before = (!copy).then(|| self.snapshot()); let ranges = self.normal_ranges(action, count); let result = self.store_clipboard( self.selection_fragments(&ranges), self.clipboard_mode == ClipboardMode::System, false, ); if copy || result.is_err() { let (targets, primary) = saved.unwrap_or_else(|| { let before = before.unwrap(); (before.targets, before.primary) }); self.active.view.targets = targets; self.active.view.primary = primary; self.active.view.mode = Mode::Normal; if let Err(error) = result { self.set_message(error); } else { self.set_message("Copied line suffix"); } return; } let before = before.unwrap(); let start = EditStart::Normal { action, count }; if matches!( action, Action::ChangeToLineEnd | Action::SubstituteCharacter | Action::ChangeWordForward | Action::ChangeWordBackward ) { self.start_insert(before, start); self.edit_ranges(Action::Change, &ranges); } else { self.edit_ranges(Action::Cut, &ranges); self.active.view.mode = Mode::Normal; self.normalize_targets(); self.record_native( before, EditRecipe { start, steps: Vec::new(), }, ); } self.reveal_caret(); } fn replace_characters(&mut self, replacement: char, ranges: &[Selection]) { let offsets = line_offsets(&self.active.lines); let mut bytes = [0; 4]; let replacement: &str = replacement.encode_utf8(&mut bytes); let edits = ranges .iter() .enumerate() .filter(|(_, range)| range.start != range.end) .map(|(target, range)| { let start = cursor_offset(&self.active.lines, &offsets, range.start); let end = cursor_offset(&self.active.lines, &offsets, range.end); let text = if range.start.row == range.end.row { let count = range.end.column - range.start.column; if count == 1 { Cow::Borrowed(replacement) } else { Cow::Owned(replacement.repeat(count)) } } else { let mut text = String::with_capacity(end - start); for row in range.start.row..=range.end.row { let first = if row == range.start.row { range.start.column } else { 0 }; let last = if row == range.end.row { range.end.column } else { grapheme_count(&self.active.lines[row]) }; text.reserve((last - first) * replacement.len()); for _ in first..last { text.push_str(replacement); } if row < range.end.row { text.push('\n'); } } Cow::Owned(text) }; Edit { target: Some(target), start, end, text, caret: 0, round_up: false, } }) .collect(); self.apply_edits(edits); self.active.view.visual_motions = None; self.active.view.linewise = false; self.active.view.mode = Mode::Normal; self.normalize_targets(); } fn edit_selection(&mut self, action: Action) { let ranges = self.selections(); let fragments = self.selection_fragments(&ranges); let nonempty = fragments.iter().any(|fragment| !fragment.is_empty()); if let Err(error) = self.store_clipboard( fragments, matches!(action, Action::CopySystem) || self.clipboard_mode == ClipboardMode::System, self.visual_linewise(), ) { self.set_message(error); return; } let cut = matches!(action, Action::Cut | Action::Change); let changing = matches!(action, Action::Change); let recipe = cut.then(|| EditRecipe { start: EditStart::Selection { change: changing, selector: self.selection_intent(&ranges), }, steps: Vec::new(), }); let before = cut.then(|| self.snapshot()); if changing { self.start_insert(before.unwrap(), recipe.unwrap().start); self.edit_ranges(action, &ranges); return; } self.edit_ranges(action, &ranges); self.active.view.mode = Mode::Normal; self.normalize_targets(); if let Some(before) = before { self.record_native(before, recipe.unwrap()); } self.set_message(if !nonempty { "Nothing selected" } else if cut { "Cut selections" } else { "Copied selections" }); } fn selection_fragments(&self, ranges: &[Selection]) -> Vec { if self.active.view.linewise { return self .active .view .targets .iter() .map(|target| { let first = target.anchor.row.min(target.active.row); let last = target.anchor.row.max(target.active.row); let mut fragment = self.active.lines[first..=last].join("\n"); fragment.push('\n'); fragment }) .collect(); } let offsets = line_offsets(&self.active.lines); ranges .iter() .map(|range| { let start = cursor_offset(&self.active.lines, &offsets, range.start); let end = cursor_offset(&self.active.lines, &offsets, range.end); let mut fragment = String::with_capacity(end - start); for (row, &offset) in offsets .iter() .enumerate() .take(range.end.row + 1) .skip(range.start.row) { if row != range.start.row { fragment.push('\n'); } let line = &self.active.lines[row]; let start = if row == range.start.row { start - offset } else { 0 }; let end = if row == range.end.row { end - offset } else { line.len() }; fragment.push_str(&line[start..end]); } fragment }) .collect() } fn store_clipboard( &mut self, fragments: Vec, system: bool, linewise: bool, ) -> Result<(), String> { if fragments.iter().all(|fragment| fragment.is_empty()) { return Ok(()); } if system { let exported = fragments.join(if linewise { "" } else { "\n" }); self.system_clipboard.write(&exported)?; self.owned_system_clipboard = Some((exported, fragments, linewise)); } else { self.clipboard = fragments; self.clipboard_linewise = linewise; } Ok(()) } // Range mutation has no register/history side effects. fn edit_ranges(&mut self, action: Action, ranges: &[Selection]) { let cut = matches!(action, Action::Cut | Action::Change); self.active.view.visual_motions = None; let offsets = line_offsets(&self.active.lines); if self.active.view.linewise { let last_row = self.active.lines.len() - 1; let eof = offsets[last_row] + self.active.lines[last_row].len(); let changing = matches!(action, Action::Change); self.active.view.mode = Mode::Insert; self.active.view.linewise = false; if cut { let tail_start = if !changing && self.active.view.targets.last().is_some_and(|target| { target.anchor.row.max(target.active.row) == last_row }) { let mut index = self.active.view.targets.len() - 1; while index > 0 { let current = self.active.view.targets[index]; let previous = self.active.view.targets[index - 1]; if previous.anchor.row.max(previous.active.row) + 1 != current.anchor.row.min(current.active.row) { break; } index -= 1; } Some(index) } else { None }; let edits = self .active .view .targets .iter() .enumerate() .map(|(target, item)| { let first = item.anchor.row.min(item.active.row); let last = item.anchor.row.max(item.active.row); let mut start = offsets[first]; let end = if last < last_row { offsets[last + 1] } else { eof }; // Remove the tail's preceding separator once, at its first edit. if tail_start == Some(target) && first > 0 { start -= 1; } Edit { target: Some(target), start, end, text: Cow::Borrowed(if changing && last < last_row { "\n" } else { "" }), caret: 0, round_up: true, } }) .collect(); self.apply_edits(edits); if !changing { for target in &mut self.active.view.targets { target.active.column = first_nonblank(&self.active.lines[target.active.row]); } } } else { for target in &mut self.active.view.targets { target.active = Cursor { row: target.anchor.row.min(target.active.row), column: 0, }; } } return; } self.active.view.mode = Mode::Insert; if cut { let edits = ranges .iter() .enumerate() .map(|(target, range)| Edit { target: Some(target), 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.apply_edits(edits); } else { for (target, range) in self.active.view.targets.iter_mut().zip(ranges) { target.active = range.start; } } } fn read_system_clipboard(&mut self) -> Result<(Vec, bool), String> { let text = self.system_clipboard.read()?; validate_paste_text(&text)?; if let Some((exported, fragments, linewise)) = &self.owned_system_clipboard && *exported == text { return Ok((fragments.clone(), *linewise)); } self.owned_system_clipboard = None; Ok(( if text.is_empty() { Vec::new() } else { vec![normalize_newlines(&text).into_owned()] }, false, )) } fn paste_clipboard(&mut self, after: bool, system: bool) { let (mut fragments, linewise) = if system { match self.read_system_clipboard() { Ok(payload) => payload, Err(error) => { self.set_message(error); return; } } } else { (std::mem::take(&mut self.clipboard), self.clipboard_linewise) }; if fragments.is_empty() { self.set_message("Clipboard is empty"); } else if fragments.len() != 1 && fragments.len() != self.active.view.targets.len() { self.set_message("Clipboard fragment count does not match caret count"); } else { let before = self.snapshot(); if let Err(error) = self.paste_fragments(after, &fragments, linewise) { self.set_message(error); if !system { self.clipboard = fragments; } return; } if before.lines != self.active.lines { self.last_edit = Some(EditRecipe { start: EditStart::Paste { after, linewise, fragments: if system { std::mem::take(&mut fragments) } else { fragments.clone() }, }, steps: Vec::new(), }); } self.record(before); } if !system { self.clipboard = fragments; } } fn paste_fragments( &mut self, after: bool, fragments: &[String], linewise: bool, ) -> Result<(), &'static str> { let offsets = line_offsets(&self.active.lines); if linewise { if fragments.len() != 1 && self .active .view .targets .windows(2) .enumerate() .any(|(index, targets)| { targets[0].active.row == targets[1].active.row && fragments[index] != fragments[index + 1] }) { return Err("Conflicting linewise clipboard fragments target the same row"); } let last = self.active.lines.len() - 1; let edits = self .active .view .targets .iter() .enumerate() .map(|(target, item)| { let fragment = &fragments[if fragments.len() == 1 { 0 } else { target }]; let row = item.active.row; let (start, text, prefix) = if after && row == last { let mut text = String::with_capacity(fragment.len()); text.push('\n'); text.push_str(fragment.strip_suffix('\n').unwrap_or(fragment)); ( offsets[row] + self.active.lines[row].len(), Cow::Owned(text), 1, ) } else { ( offsets[row + usize::from(after)], Cow::Borrowed(fragment.as_str()), 0, ) }; let first = fragment.split('\n').next().unwrap(); Edit { target: Some(target), start, end: start, caret: prefix + grapheme_byte(first, first_nonblank(first)), text, round_up: true, } }) .collect(); self.active.view.mode = Mode::Insert; self.active.view.linewise = false; self.apply_edits(edits); self.active.view.mode = Mode::Normal; self.normalize_targets(); return Ok(()); } let edits = self .active .view .targets .iter() .enumerate() .map(|(target, item)| { let mut cursor = item.active; if after { cursor.column = (cursor.column + 1).min(grapheme_count(&self.active.lines[cursor.row])); } let start = cursor_offset(&self.active.lines, &offsets, cursor); let text = &fragments[if fragments.len() == 1 { 0 } else { target }]; Edit { target: Some(target), start, end: start, text: Cow::Borrowed(text), caret: text.len(), round_up: true, } }) .collect(); self.active.view.mode = Mode::Insert; self.apply_edits(edits); self.leave_insert(); Ok(()) } fn begin_insert(&mut self) { self.start_insert(self.snapshot(), EditStart::Insert(Action::Insert)); } fn start_insert(&mut self, before: Snapshot, start: EditStart) { self.active.view.insert_start = Some(before); self.active.view.pending_edit = Some(EditRecipe { start, steps: Vec::new(), }); self.active.view.visual_motions = None; self.active.view.mode = Mode::Insert; } fn finish_insert(&mut self) { self.finish_completion(); self.snippet = None; let recipe = self.active.view.pending_edit.take(); if let Some(before) = self.active.view.insert_start.take() { if let Some(mut recipe) = recipe { while matches!(recipe.steps.last(), Some(InsertStep::Move(_, _))) { recipe.steps.pop(); } self.record_native(before, recipe); } else { self.record(before); } } } fn leave_insert(&mut self) { for target in &mut self.active.view.targets { target.active.column = target.active.column.saturating_sub(1); } self.active.view.mode = Mode::Normal; self.normalize_targets(); } fn place_insert(&mut self, action: Action) { self.active.view.mode = Mode::Insert; if matches!(action, Action::OpenBelow | Action::OpenAbove) { self.open_lines(matches!(action, Action::OpenBelow)); return; } for target in &mut self.active.view.targets { let line = &self.active.lines[target.active.row]; target.active.column = match action { Action::InsertFirstNonblank => first_nonblank(line), Action::AppendLineEnd => grapheme_count(line), Action::Append => (target.active.column + 1).min(grapheme_count(line)), _ => target.active.column, }; } self.normalize_targets(); } fn push_insert_step(&mut self, step: InsertStep) { if let Some(recipe) = &mut self.active.view.pending_edit { recipe.steps.push(step); } } fn type_native(&mut self, text: &str) { const SPACES: [u8; u8::MAX as usize] = [b' '; u8::MAX as usize]; let text = if text == "\t" { let width = if self.language_tab_spaces.is_empty() { self.tab_spaces } else { crate::syntax::language_id( self.active.path.as_deref(), self.active.lines.first().map(String::as_str), ) .and_then(|language| self.language_tab_spaces.get(language)) .copied() .unwrap_or(self.tab_spaces) }; width.map_or(text, |width| { std::str::from_utf8(&SPACES[..usize::from(width.get())]).expect("spaces are UTF-8") }) } else { text }; if self.edit_snippet(text, None) { return; } self.insert_text(text); if let Some(recipe) = &mut self.active.view.pending_edit { if let Some(InsertStep::Typed(typed)) = recipe.steps.last_mut() { typed.push_str(text); } else { recipe.steps.push(InsertStep::Typed(text.to_owned())); } } } fn deletion_mask(&self, backwards: Option) -> Vec { let mut mask: Vec<_> = self .active .view .targets .iter() .map(|target| { let cursor = target.active; if let Some(kind) = backwards { cursor.column != 0 || (kind != BackspaceKind::Line && cursor.row != 0) } else { cursor.column < grapheme_count(&self.active.lines[cursor.row]) || (self.active.view.mode == Mode::Insert && cursor.row + 1 < self.active.lines.len()) } }) .collect(); if mask.iter().all(|active| *active) { mask.truncate(1); } mask } fn selection_intent(&mut self, ranges: &[Selection]) -> SelectionIntent { if let Some(motions) = self.active.view.visual_motions.take() { return SelectionIntent::Motions { motions, linewise: self.visual_linewise(), }; } if self.visual_linewise() { let mut rows: Vec<_> = self .active .view .targets .iter() .map(|target| { let count = target.anchor.row.abs_diff(target.active.row) as isize + 1; if target.active >= target.anchor { count } else { -count } }) .collect(); if rows.iter().all(|row| *row == rows[0]) { rows.truncate(1); } return SelectionIntent::Rows(rows); } let mut spans: Vec<_> = ranges .iter() .map(|range| { let mut cursor = range.start; let mut span = 0; while cursor < range.end { cursor = self.next_boundary(cursor); span += 1; } span }) .collect(); if spans.iter().all(|span| *span == spans[0]) { spans.truncate(1); } SelectionIntent::Spans(spans) } fn repeat_last_edit(&mut self) { let Some(recipe) = self.last_edit.take() else { self.set_message("Nothing to repeat"); return; }; let cardinality = match &recipe.start { EditStart::Paste { fragments, .. } => Some(fragments.len()), EditStart::Selection { selector: SelectionIntent::Spans(spans), .. } | EditStart::ReplaceSelection { selector: SelectionIntent::Spans(spans), .. } if self.active.view.mode != Mode::Visual => Some(spans.len()), EditStart::Selection { selector: SelectionIntent::Rows(rows), .. } | EditStart::ReplaceSelection { selector: SelectionIntent::Rows(rows), .. } if self.active.view.mode != Mode::Visual => Some(rows.len()), _ => None, }; if cardinality.is_some_and(|count| count != 1 && count != self.active.view.targets.len()) { self.set_message("Repeat fragment/span count does not match target count"); self.last_edit = Some(recipe); return; } let mut before = self.snapshot(); let mode = self.active.view.mode; let selector = self.active.view.visual_motions.take(); let linewise = self.active.view.linewise; let search_match = self.active.view.search_match; let revision = self.active.text_revision; let diagnostics = std::mem::take(&mut self.active.diagnostics); let semantic_tokens = self.active.semantic_tokens.take(); let mut restored = false; let result = self .replay_edit(&recipe) .map_err(str::to_owned) .and_then(|fragments| { if let Some((fragments, shape)) = fragments { // Validate the entire replay first, then publish while the original // document is restored, and only then commit the computed deletion. std::mem::swap(&mut self.active.lines, &mut before.lines); std::mem::swap(&mut self.active.view.targets, &mut before.targets); std::mem::swap(&mut self.active.view.primary, &mut before.primary); restored = true; self.store_clipboard( fragments, self.clipboard_mode == ClipboardMode::System, shape, )?; std::mem::swap(&mut self.active.lines, &mut before.lines); std::mem::swap(&mut self.active.view.targets, &mut before.targets); std::mem::swap(&mut self.active.view.primary, &mut before.primary); restored = false; } Ok(()) }); match result { Ok(()) => { if self.active.text_revision == revision { self.active.diagnostics = diagnostics; self.active.semantic_tokens = semantic_tokens; } self.active.view.visual_motions = None; self.record(before); } Err(error) => { if !restored { self.active.lines = before.lines; self.clamp_other_views(); self.active.view.targets = before.targets; self.active.view.primary = before.primary; } self.active.text_revision = revision; self.active.diagnostics = diagnostics; self.active.semantic_tokens = semantic_tokens; self.active.view.mode = mode; self.active.view.linewise = linewise; self.active.view.visual_motions = selector; self.active.view.search_match = search_match; self.set_message(error); } } self.last_edit = Some(recipe); } fn replay_edit( &mut self, recipe: &EditRecipe, ) -> Result, bool)>, &'static str> { let mut clipboard = None; match &recipe.start { EditStart::Insert(action) => self.place_insert(*action), EditStart::Normal { action, count } => { let ranges = self.normal_ranges(*action, *count); clipboard = Some((self.selection_fragments(&ranges), false)); let change = matches!( action, Action::ChangeToLineEnd | Action::SubstituteCharacter | Action::ChangeWordForward | Action::ChangeWordBackward ); self.edit_ranges(if change { Action::Change } else { Action::Cut }, &ranges); if !change { self.active.view.mode = Mode::Normal; self.normalize_targets(); } } EditStart::Replace { target, count } => { let ranges = self.normal_ranges(Action::SubstituteCharacter, *count); self.replace_characters(*target, &ranges); } EditStart::ReplaceSelection { target, selector } => { let ranges = self.replay_ranges(selector); self.replace_characters(*target, &ranges); } EditStart::Lines { change, count } => { let ranges = self.line_selections(*count); clipboard = Some((self.selection_fragments(&ranges), true)); self.edit_ranges(if *change { Action::Change } else { Action::Cut }, &ranges); if !*change { self.active.view.mode = Mode::Normal; self.normalize_targets(); } } EditStart::Selection { change, selector } => { let ranges = self.replay_ranges(selector); clipboard = Some((self.selection_fragments(&ranges), self.visual_linewise())); self.edit_ranges(if *change { Action::Change } else { Action::Cut }, &ranges); if !*change { self.active.view.mode = Mode::Normal; self.normalize_targets(); } } EditStart::Delete => { self.active.view.mode = Mode::Normal; self.delete_forward(&[true]); } EditStart::Join => { self.active.view.mode = Mode::Normal; self.join_lines(); } EditStart::Paste { after, fragments, linewise, } => self.paste_fragments(*after, fragments, *linewise)?, } for step in &recipe.steps { match step { InsertStep::Typed(text) => { // Scalar boundaries matter when an insertion merges with its right neighbor. for ch in text.chars() { let mut bytes = [0; 4]; self.insert_text(ch.encode_utf8(&mut bytes)); } } InsertStep::Bulk(text) => self.insert_text(text), InsertStep::Move(action, count) => self.move_targets(*action, *count), InsertStep::Delete(mask) | InsertStep::Backspace(mask, _) => { // Earlier native steps may have merged carets. Refuse the entire transaction // rather than pairing a recorded partial deletion with different targets. if mask.len() != 1 && mask.len() != self.active.view.targets.len() { return Err("Repeat deletion count does not match target count"); } if let InsertStep::Backspace(_, kind) = step { self.backspace(mask, *kind); } else { self.delete_forward(mask); } } InsertStep::Completion { primary_edit, edits, caret, } => { let origin = grapheme_offset(&self.active.lines, self.cursor()); let resolve = |distance: isize| { origin .checked_add_signed(distance) .and_then(|index| grapheme_cursor(&self.active.lines, index)) .ok_or("Repeat completion range is outside the document") }; let offsets = line_offsets(&self.active.lines); let mut batch = Vec::with_capacity(edits.len()); for (index, edit) in edits.iter().enumerate() { let start = resolve(edit.start)?; let end = resolve(edit.end)?; if edit.context.as_ref().is_some_and(|context| { *context != self.active.lines[start.row..=end.row] }) { return Err("Repeat completion additional-edit context changed"); } // Translating every boundary by the same origin preserves // the validated ordering and nonoverlap of the original batch. batch.push(Edit { target: (index == *primary_edit).then_some(self.active.view.primary), start: cursor_offset(&self.active.lines, &offsets, start), end: cursor_offset(&self.active.lines, &offsets, end), text: Cow::Borrowed(&edit.text), caret: if index == *primary_edit { *caret } else { edit.text.len() }, round_up: true, }); } self.apply_edits_from(batch, None, true); } } } if self.active.view.mode == Mode::Insert { self.leave_insert(); } else { self.active.view.mode = Mode::Normal; self.normalize_targets(); } Ok(clipboard) } fn replay_ranges(&mut self, selector: &SelectionIntent) -> Vec { if self.active.view.mode == Mode::Visual { return self.selections(); } self.active.view.mode = Mode::Visual; match selector { SelectionIntent::Rows(rows) => { self.active.view.linewise = true; let last = self.active.lines.len() - 1; for (index, target) in self.active.view.targets.iter_mut().enumerate() { target.anchor = target.active; let count = rows[if rows.len() == 1 { 0 } else { index }]; let distance = count.unsigned_abs().saturating_sub(1); target.active.row = if count >= 0 { target.active.row.saturating_add(distance).min(last) } else { target.active.row.saturating_sub(distance) }; } self.normalize_targets(); self.selections() } SelectionIntent::Motions { motions, linewise } => { self.active.view.linewise = *linewise; for target in &mut self.active.view.targets { target.anchor = target.active; } for motion in motions { self.move_motion(*motion); } self.normalize_targets(); self.selections() } SelectionIntent::Spans(spans) => { let ordered = self .active .view .targets .iter() .enumerate() .map(|(index, target)| { let start = target.active; let mut end = start; for _ in 0..spans[if spans.len() == 1 { 0 } else { index }] { let next = self.next_boundary(end); if next == end { break; } end = next; } let range = Selection { start, end }; ( self.range_target(range, true), index == self.active.view.primary, range, ) }) .collect(); self.merge_target_ranges(ordered, true) } } } fn paste(&mut self, text: &str) { self.snippet = None; if let Err(error) = validate_paste_text(text) { self.set_message(error); return; } // Preserve the separate typing / paste / typing undo groups. self.finish_insert(); let text = normalize_newlines(text); if !text.is_empty() { let before = self.snapshot(); self.insert_text(&text); self.record_native( before, EditRecipe { start: EditStart::Insert(Action::Insert), steps: vec![InsertStep::Bulk(text.into_owned())], }, ); } self.begin_insert(); } fn restore(&mut self, snapshot: Snapshot) { if self.mutation_blocked() { return; } if self.active.lines != snapshot.lines { self.active.text_changed(); } self.active.lines = snapshot.lines; self.clamp_other_views(); self.active.view.targets = snapshot.targets; self.active.view.primary = snapshot.primary; self.active.view.mode = Mode::Normal; self.active.view.search_match = None; self.active.view.visual_motions = None; self.normalize_targets(); } fn undo(&mut self) { if self.mutation_blocked() { return; } if let Some(before) = self.active.undo.pop() { self.active.redo.push(self.snapshot()); self.restore(before); } else { self.set_message("Nothing to undo"); } } fn redo(&mut self) { if self.mutation_blocked() { return; } if let Some(after) = self.active.redo.pop() { self.active.undo.push(self.snapshot()); self.restore(after); } else { self.set_message("Nothing to redo"); } } fn navigable_rows( &self, start: usize, end: usize, ) -> impl DoubleEndedIterator + '_ { let diff = self .diff_buffer_focus() .and_then(|focus| self.diff.as_ref().map(|diff| (diff, focus))); (start..if diff.is_none() { end } else { start }).chain( diff.into_iter() .flat_map(|(diff, focus)| diff.source_rows(focus)) .filter(move |row| start <= *row && *row < end), ) } fn move_motion(&mut self, motion: Motion) { match motion { Motion::Action(action, count) => self.move_targets(action, count), Motion::Seek { forward, target, count, } => { self.reveal_caret(); for caret in &mut self.active.view.targets { if let Some(column) = seek_column( &self.active.lines[caret.active.row], caret.active.column, forward, target, count, ) { caret.active.column = column; caret.preferred_columns = None; } } self.normalize_targets(); } } } fn move_targets(&mut self, action: Action, count: usize) { self.reveal_caret(); if action == Action::GoToLine && let Some(focus) = self.diff_buffer_focus() { let row = count.saturating_sub(1).min(self.active.lines.len() - 1); self.diff.as_mut().unwrap().reveal_source_row(focus, row); } for index in 0..self.active.view.targets.len() { let mut cursor = self.active.view.targets[index].active; match action { Action::Left => cursor.column = cursor.column.saturating_sub(count), Action::Right => { cursor.column = cursor .column .saturating_add(count) .min(self.max_column(cursor.row)) } Action::Down | Action::Up => { if self.diff_buffer_focus().is_some() { let step = |cursor: Cursor, row| Cursor { row, column: cursor.column.min(self.max_column(row)), }; cursor = if action == Action::Down { self.navigable_rows( cursor.row.saturating_add(1), self.active.lines.len(), ) .take(count) .fold(cursor, step) } else { self.navigable_rows(0, cursor.row) .rev() .take(count) .fold(cursor, step) }; } else { let row = if matches!(action, Action::Down) { cursor .row .saturating_add(count) .min(self.active.lines.len() - 1) } else { cursor.row.saturating_sub(count) }; let first = cursor.row.min(row); let last = cursor.row.max(row); cursor.column = (first..=last) .map(|row| self.max_column(row)) .min() .unwrap_or(0) .min(cursor.column); cursor.row = row; } } Action::LineStart => cursor.column = 0, Action::LineEnd => cursor.column = self.max_column(cursor.row), Action::GoToLine => { cursor.row = count.saturating_sub(1).min(self.active.lines.len() - 1); cursor.column = first_nonblank(&self.active.lines[cursor.row]); } Action::DocumentStart | Action::DocumentEnd => { cursor.row = if action == Action::DocumentStart { self.navigable_rows(0, self.active.lines.len()) .next() .unwrap_or(cursor.row) } else { self.navigable_rows(0, self.active.lines.len()) .next_back() .unwrap_or(cursor.row) }; cursor.column = first_nonblank(&self.active.lines[cursor.row]); } _ => cursor = self.word_destination(cursor, action, count), } self.active.view.targets[index].active = cursor; self.active.view.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.active.lines[row], cursor.column); let big = matches!( action, Action::BigWordNext | Action::BigWordPrevious | Action::BigWordEnd ); let backward = matches!( action, Action::WordPrevious | Action::BigWordPrevious | Action::DeleteWordBackward ); let line_local = matches!( action, Action::DeleteWordForward | Action::DeleteWordBackward ); let start = if line_local || !backward { row } else { 0 }; let end = if line_local || backward { row + 1 } else { self.active.lines.len() }; let tokens = self.navigable_rows(start, end).flat_map(move |source_row| { word_tokens(std::slice::from_ref(&self.active.lines[source_row]), big) .map(move |(_, byte, kind)| (source_row, byte, kind)) }); let destination = match action { Action::WordPrevious | Action::BigWordPrevious | Action::DeleteWordBackward => { let mut tokens = tokens .rev() .skip_while(|(r, b, _)| (*r, *b) >= (row, byte)) .peekable(); let fallback = (self.navigable_rows(start, end).next().unwrap_or(row), 0); let mut destination = fallback; for _ in 0..count { let Some(mut first) = tokens.find(|(_, _, kind)| *kind != WordKind::Whitespace) else { destination = fallback; break; }; if first.2 != WordKind::EmptyLine { while tokens.peek().is_some_and(|(_, _, kind)| *kind == first.2) { first = tokens.next().expect("peeked token"); } } destination = (first.0, first.1); } destination } _ => { let mut tokens = tokens .skip_while(|(r, b, _)| (*r, *b) < (row, byte)) .peekable(); let Some(first) = tokens.next() else { return cursor; }; let mut current = first.2; let last = self.navigable_rows(start, end).next_back().unwrap_or(row); let mut destination = (last, self.active.lines[last].len()); for _ in 0..count { let next_start = matches!( action, Action::WordNext | Action::BigWordNext | Action::DeleteWordForward ); if next_start && current != WordKind::EmptyLine { while tokens.peek().is_some_and(|(_, _, kind)| *kind == current) { tokens.next(); } } let Some(mut first) = tokens.find(|(_, _, kind)| { *kind != WordKind::Whitespace && (next_start || *kind != WordKind::EmptyLine) }) else { destination = (last, self.active.lines[last].len()); break; }; current = first.2; if !next_start { while tokens.peek().is_some_and(|(_, _, kind)| *kind == current) { first = tokens.next().expect("peeked token"); } } destination = (first.0, first.1); } destination } }; 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.active.view.mode != Mode::Insert { column.saturating_sub(1) } else { column }, } } fn clamp_other_views(&mut self) { let lines = &self.active.lines; for pane in self.panes.values_mut() { if let Some(view) = pane.views.get_mut(&self.active.id) { view.clamp(lines); } } } /// Every edit in a batch refers to this one pre-edit buffer. Duplicate row /// operations share an edit; caret destinations are remapped only afterwards. // ponytail: rebuilding text costs O(buffer + carets²); use per-line edits for large buffers. fn apply_edits(&mut self, edits: Vec>) { self.apply_edits_from(edits, None, false); } fn apply_edits_from( &mut self, mut edits: Vec>, original: Option<&Snapshot>, preserve_targets: bool, ) { if self.mutation_blocked() { return; } if edits.is_empty() { return; } edits.sort_by_key(|edit| (edit.start, edit.end)); let mut unique: Vec = Vec::with_capacity(edits.len()); for (index, edit) in edits.iter().enumerate() { if unique.last().is_some_and(|previous| { let previous = &edits[*previous]; previous.start == edit.start && previous.end == edit.end && previous.text == edit.text }) { continue; } unique.push(index); } let lines = original.map_or(&self.active.lines, |before| &before.lines); let targets = original.map_or(&self.active.view.targets, |before| &before.targets); let old = lines.join("\n"); let offsets = line_offsets(lines); let removed: usize = unique .iter() .map(|index| edits[*index].end - edits[*index].start) .sum(); let added: usize = unique.iter().map(|index| edits[*index].text.len()).sum(); let mut text = String::with_capacity(old.len() - removed + added); let mut consumed = 0; for index in &unique { let edit = &edits[*index]; assert!(edit.start >= consumed, "batch edits must not overlap"); text.push_str(&old[consumed..edit.start]); text.push_str(&edit.text); consumed = edit.end; } text.push_str(&old[consumed..]); let remap = |position: usize, after: bool| { translate_edit_offset(position, after, unique.iter().map(|index| &edits[*index])) }; let mut destinations: Vec<_> = targets .iter() .map(|target| { ( remap(cursor_offset(lines, &offsets, target.active), true), true, ) }) .collect(); for edit in &edits { if let Some(target) = edit.target { destinations[target] = (remap(edit.start, false) + edit.caret, edit.round_up); } } let new_lines: Vec = text.split('\n').map(str::to_owned).collect(); let new_offsets = line_offsets(&new_lines); if self.panes.len() > 1 { for pane in self.panes.values_mut() { if let Some(view) = pane.views.get_mut(&self.active.id) && !view.targets.is_empty() { if original.is_some() { view.clamp(lines); } let mode = view.mode; let cursor_at = |byte: usize, round_up| { let mut cursor = offset_cursor(&new_lines, &new_offsets, byte, round_up); if round_up && mode != Mode::Insert && !(mode == Mode::Visual && cursor.row + 1 < new_lines.len()) && byte >= new_offsets[cursor.row] + new_lines[cursor.row].len() { cursor.column = cursor.column.saturating_sub(1); } cursor }; for target in &mut view.targets { target.active = cursor_at( remap(cursor_offset(lines, &offsets, target.active), true), true, ); target.anchor = if mode == Mode::Visual { cursor_at( remap(cursor_offset(lines, &offsets, target.anchor), false), false, ) } else { target.active }; target.preferred_columns = None; } view.search_match = None; view.visual_motions = None; } } } if !text .split('\n') .eq(self.active.lines.iter().map(String::as_str)) { self.active.text_changed(); self.active.view.search_match = None; } if let Some(original) = original { self.active.view.targets.clone_from(&original.targets); self.active.view.primary = original.primary; } self.active.lines = new_lines; let offsets = new_offsets; for (target, (byte, round_up)) in self.active.view.targets.iter_mut().zip(destinations) { target.active = offset_cursor(&self.active.lines, &offsets, byte, round_up); target.anchor = target.active; target.preferred_columns = None; } if !preserve_targets { self.normalize_targets(); } } fn insert_text(&mut self, text: &str) { if text.is_empty() { return; } let offsets = line_offsets(&self.active.lines); let edits = self .active .view .targets .iter() .enumerate() .map(|(target, item)| { let start = cursor_offset(&self.active.lines, &offsets, item.active); Edit { target: Some(target), start, end: start, text: Cow::Borrowed(text), caret: text.len(), round_up: true, } }) .collect(); self.apply_edits(edits); } fn open_lines(&mut self, below: bool) { let offsets = line_offsets(&self.active.lines); let edits = self .active .view .targets .iter() .enumerate() .map(|(target, item)| { let start = offsets[item.active.row] + if below { self.active.lines[item.active.row].len() } else { 0 }; Edit { target: Some(target), start, end: start, text: Cow::Borrowed("\n"), caret: usize::from(below), round_up: true, } }) .collect(); self.apply_edits(edits); } fn delete_forward(&mut self, mask: &[bool]) { let offsets = line_offsets(&self.active.lines); let edits = self .active .view .targets .iter() .enumerate() .filter_map(|(target, item)| { if !mask[if mask.len() == 1 { 0 } else { target }] { return None; } let cursor = item.active; 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.view.mode == Mode::Insert && cursor.row + 1 < self.active.lines.len() { start + 1 } else { return None; }; Some(Edit { target: Some(target), start, end, text: Cow::Borrowed(""), caret: 0, round_up: true, }) }) .collect(); self.apply_edits(edits); } fn join_lines(&mut self) { let offsets = line_offsets(&self.active.lines); let edits = self .active .view .targets .iter() .enumerate() .filter_map(|(target, item)| { let row = item.active.row; 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() && !line.ends_with([' ', '\t']) && !trimmed.starts_with(')') { if line.ends_with(['.', '?', '!']) { " " } else { " " } } else { "" }; let start = offsets[row] + line.len(); Some(Edit { target: Some(target), start, end: start + 1 + next.len() - trimmed.len(), text: Cow::Borrowed(separator), caret: 0, round_up: false, }) }) .collect(); self.apply_edits(edits); } fn backspace_destination(&self, mut cursor: Cursor, kind: BackspaceKind) -> Cursor { if kind == BackspaceKind::Grapheme { return self.previous_boundary(cursor); } let mut prefix = &self.active.lines[cursor.row] [..grapheme_byte(&self.active.lines[cursor.row], cursor.column)]; if kind == BackspaceKind::Word { while cursor.row > 0 && prefix.chars().all(char::is_whitespace) { cursor.row -= 1; prefix = &self.active.lines[cursor.row]; } } cursor.column = prefix[..kind.start(prefix)].graphemes(true).count(); cursor } fn backspace(&mut self, mask: &[bool], kind: BackspaceKind) { let offsets = line_offsets(&self.active.lines); let mut edits: Vec<_> = self .active .view .targets .iter() .enumerate() .filter_map(|(target, item)| { if !mask[if mask.len() == 1 { 0 } else { target }] { return None; } let cursor = item.active; let destination = self.backspace_destination(cursor, kind); if destination == cursor { return None; } let start = cursor_offset(&self.active.lines, &offsets, destination); let end = cursor_offset(&self.active.lines, &offsets, cursor); Some(Edit { target: Some(target), start, end, text: Cow::Borrowed(""), caret: 0, round_up: true, }) }) .collect(); // Backward ranges follow document-ordered carets; overlapping ranges delete once. let mut group = 0; for next in 1..=edits.len() { if next < edits.len() && edits[next].start < edits[next - 1].end { continue; } let start = edits[group].start; let end = edits[next - 1].end; for edit in &mut edits[group..next] { edit.start = start; edit.end = end; } group = next; } self.apply_edits(edits); } } fn ranges_intersect(left: Selection, right: Selection) -> bool { if left.start == left.end { left.start == right.start || (right.start < left.start && left.start < right.end) } else if right.start == right.end { left.start <= right.start && right.start < left.end } else { left.start < right.end && right.start < left.end } } fn first_nonblank(line: &str) -> usize { line.graphemes(true) .take_while(|grapheme| matches!(*grapheme, " " | "\t")) .count() } fn display_column(line: &str, column: usize) -> usize { line.graphemes(true) .take(column) .fold(0, |cells, grapheme| { cells + if grapheme == "\t" { 4 - cells % 4 } else { grapheme.width() } }) } fn column_at_display(line: &str, wanted: usize, maximum: usize) -> usize { let mut cells = 0; let mut count = 0; for (column, grapheme) in line.graphemes(true).enumerate() { let width = if grapheme == "\t" { 4 - cells % 4 } else { grapheme.width() }; if cells + width > wanted { return column.min(maximum); } cells += width; count = column + 1; } count.min(maximum) } fn line_offsets(lines: &[String]) -> Vec { let mut byte = 0; lines .iter() .map(|line| { let start = byte; byte += line.len() + 1; start }) .collect() } fn cursor_offset(lines: &[String], offsets: &[usize], cursor: Cursor) -> usize { offsets[cursor.row] + grapheme_byte(&lines[cursor.row], cursor.column) } fn offset_cursor(lines: &[String], offsets: &[usize], byte: usize, round_up: bool) -> Cursor { let row = offsets .partition_point(|offset| *offset <= byte) .saturating_sub(1); let local = (byte - offsets[row]).min(lines[row].len()); let column = if round_up { column_after_byte(&lines[row], local) } else { lines[row] .grapheme_indices(true) .take_while(|(byte, _)| *byte <= local) .count() .saturating_sub(1) }; Cursor { row, column } } #[derive(Clone, Copy, Eq, PartialEq)] enum WordKind { Whitespace, Keyword, Punctuation, EmptyLine, } fn word_tokens( lines: &[String], big: bool, ) -> impl DoubleEndedIterator + '_ { lines.iter().enumerate().flat_map(move |(row, line)| { line.grapheme_indices(true) .map(move |(byte, grapheme)| { let ch = grapheme.chars().next().expect("graphemes are nonempty"); let kind = if ch.is_whitespace() { WordKind::Whitespace } else if big || ch == '_' || ch.is_alphanumeric() { WordKind::Keyword } else { WordKind::Punctuation }; (row, byte, kind) }) .chain(std::iter::once(( row, line.len(), if line.is_empty() { WordKind::EmptyLine } else { WordKind::Whitespace }, ))) }) } fn valid_command(command: &str) -> bool { let command = command.strip_suffix('!').unwrap_or(command); command.split('.').all(|segment| { let mut chars = segment.chars(); chars.next().is_some_and(|ch| ch.is_ascii_alphabetic()) && chars.all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '-') }) } fn grapheme_offset(lines: &[String], cursor: Cursor) -> usize { lines[..cursor.row] .iter() .map(|line| grapheme_count(line) + 1) .sum::() + cursor.column } fn grapheme_cursor(lines: &[String], mut index: usize) -> Option { for (row, line) in lines.iter().enumerate() { let columns = grapheme_count(line); if index <= columns { return Some(Cursor { row, column: index }); } index -= columns + 1; } None } fn translate_edit_offset<'a, 'b>( position: usize, after: bool, edits: impl Iterator>, ) -> usize where 'b: 'a, { let mut removed = 0; let mut added = 0; for edit in edits { if position < edit.start { break; } if position == edit.start || position < edit.end { return edit.start - removed + added + if after { edit.text.len() } else { 0 }; } removed += edit.end - edit.start; added += edit.text.len(); } position - removed + added } fn byte_range_text(lines: &[String], offsets: &[usize], range: &Range) -> String { let first = offsets.partition_point(|offset| *offset <= range.start) - 1; let last = offsets.partition_point(|offset| *offset <= range.end) - 1; let mut text = String::with_capacity(range.end - range.start); for row in first..=last { if row != first { text.push('\n'); } let start = if row == first { range.start - offsets[row] } else { 0 }; let end = if row == last { range.end - offsets[row] } else { lines[row].len() }; text.push_str(&lines[row][start..end]); } text } fn document_grapheme_boundary(lines: &[String], offsets: &[usize], byte: usize) -> bool { let Some(row) = offsets .partition_point(|offset| *offset <= byte) .checked_sub(1) else { return false; }; let line = &lines[row]; let column = byte - offsets[row]; column <= line.len() && line.is_char_boundary(column) && GraphemeCursor::new(column, line.len(), true) .is_boundary(line, 0) .expect("complete line supplies grapheme context") } fn validate_snippet(text: &str, stops: &[SnippetTabstop]) -> Result { if stops.is_empty() || stops.last().is_none_or(|stop| stop.index != 0) { return Err("Snippet requires a final caret stop".into()); } if stops.len() > MAX_EDIT_BYTES / std::mem::size_of::() { return Err("Snippet metadata exceeds 4 MiB".into()); } let mut metadata_bytes = std::mem::size_of_val(stops); let mut ranges = Vec::new(); let mut previous = 0; for (group, stop) in stops.iter().enumerate() { if stop.ranges.is_empty() || stop.ancestors.len() != stop.ranges.len() || stop.index == 0 && group + 1 != stops.len() || stop.index != 0 && stop.index <= previous || stop.index == 0 && (stop.ranges.len() != 1 || !stop.ranges[0].is_empty()) { return Err("Snippet tabstops are unordered or empty".into()); } previous = stop.index; let mut end = None; let mut mirror = None; for (occurrence, range) in stop.ranges.iter().enumerate() { if range.start > range.end || range.end > text.len() { return Err("Snippet range is outside its replacement".into()); } for byte in [range.start, range.end] { if !text.is_char_boundary(byte) || !GraphemeCursor::new(byte, text.len(), true) .is_boundary(text, 0) .expect("complete replacement supplies grapheme context") { return Err("Snippet range splits a grapheme".into()); } } if end.is_some_and(|(start, end)| { range.start < end || range.start == start && !(range.is_empty() && start == end) }) { return Err("Snippet mirror ranges overlap or are unordered".into()); } end = Some((range.start, range.end)); let value = &text[range.clone()]; if mirror.is_some_and(|mirror| mirror != value) { return Err("Snippet mirrors have different defaults".into()); } mirror = Some(value); if stop.ancestors[occurrence].len() > 128 { return Err("Snippet nesting exceeds 128 levels".into()); } ranges.push((range.start, range.end, group, occurrence)); metadata_bytes += std::mem::size_of::>() + std::mem::size_of::>() + stop.ancestors[occurrence].len() * std::mem::size_of::(); if metadata_bytes > MAX_EDIT_BYTES { return Err("Snippet metadata exceeds 4 MiB".into()); } } } let numbered = &stops[..stops.len() - 1]; for stop in stops { for (range, ancestors) in stop.ranges.iter().zip(&stop.ancestors) { if stop.index == 0 && !ancestors.is_empty() { return Err("Snippet final caret must be outside nested defaults".into()); } for (depth, ancestor) in ancestors.iter().enumerate() { if *ancestor == stop.index || ancestors[..depth].contains(ancestor) { return Err("Snippet ancestry contains a cycle".into()); } let parent = numbered .binary_search_by_key(ancestor, |stop| stop.index) .map(|index| &numbered[index]) .map_err(|_| "Snippet ancestry references an unknown tabstop")?; if !parent .ranges .iter() .zip(&parent.ancestors) .any(|(enclosing, ancestry)| { enclosing.start <= range.start && range.end <= enclosing.end && ancestry.as_slice() == &ancestors[..depth] }) { return Err("Snippet ancestor does not contain its child".into()); } } } } // Nested and coincident defaults require declared ancestry; crossing ranges are invalid. ranges.sort_unstable_by_key(|(start, end, group, occurrence)| { ( *start, std::cmp::Reverse(*end), stops[*group].ancestors[*occurrence].len(), ) }); let mut enclosing: Vec<(usize, usize, usize)> = Vec::new(); for (start, end, group, occurrence) in ranges { while enclosing .last() .is_some_and(|(_, parent_end, _)| *parent_end <= start) { enclosing.pop(); } if let Some((parent_start, parent_end, parent)) = enclosing.last() { if end > *parent_end { return Err("Snippet placeholder ranges cross".into()); } if stops[group].index != 0 && (start != end || start > *parent_start) && !stops[group].ancestors[occurrence].contains(&stops[*parent].index) { return Err("Snippet overlapping placeholders require ancestry".into()); } } if start != end { enclosing.push((start, end, group)); } } Ok(metadata_bytes) } fn validate_completion_items(lines: &[String], items: &[CompletionItem]) -> Result<(), String> { let mut total = 0usize; let offsets = line_offsets(lines); let document = items .iter() .any(|item| item.snippet.is_some()) .then(|| lines.join("\n")); for item in items { if item.primary_edit >= item.edits.len() { return Err("Completion has no primary edit".into()); } for text in std::iter::once(item.label.as_str()) .chain(item.detail.as_deref()) .chain(item.edits.iter().map(|edit| edit.text.as_str())) { total = total .checked_add(text.len()) .ok_or("Completions exceed 4 MiB")?; if total > MAX_EDIT_BYTES { return Err("Completions exceed 4 MiB".into()); } validate_text(text)?; } let mut ranges = Vec::with_capacity(item.edits.len()); for edit in &item.edits { for position in [edit.start, edit.end] { if lines .get(position.row) .is_none_or(|line| position.column > grapheme_count(line)) { return Err("Completion range is outside the document".into()); } } let start = cursor_offset( lines, &offsets, Cursor { row: edit.start.row, column: edit.start.column, }, ); let end = cursor_offset( lines, &offsets, Cursor { row: edit.end.row, column: edit.end.column, }, ); if start > end { return Err("Completion range is reversed".into()); } ranges.push((start, end)); } ranges.sort_unstable(); if ranges .windows(2) .any(|pair| pair[0].1 > pair[1].0 || pair[0].0 == pair[1].0) { return Err("Completion edits overlap".into()); } if let Some(snippet) = &item.snippet { let text = &item.edits[item.primary_edit].text; if text.contains('\r') { return Err( "Snippet replacement must normalize newlines before assigning ranges".into(), ); } total = total .checked_add(validate_snippet(text, &snippet.tabstops)?) .filter(|bytes| *bytes <= MAX_EDIT_BYTES) .ok_or("Completions exceed 4 MiB")?; let mut edits = completion_edits(lines, &offsets, 0, item); let primary_start = edits[item.primary_edit].start; for (edit, source) in edits.iter_mut().zip(&item.edits) { edit.text = normalize_newlines(&source.text); } edits.sort_by_key(|edit| (edit.start, edit.end)); let base = translate_edit_offset(primary_start, false, edits.iter()); let original = document .as_deref() .expect("snippet validation has document text"); let removed: usize = edits.iter().map(|edit| edit.end - edit.start).sum(); let added: usize = edits.iter().map(|edit| edit.text.len()).sum(); let mut expanded = String::with_capacity(original.len() - removed + added); let mut consumed = 0; for edit in &edits { expanded.push_str(&original[consumed..edit.start]); expanded.push_str(&edit.text); consumed = edit.end; } expanded.push_str(&original[consumed..]); for range in snippet.tabstops.iter().flat_map(|stop| &stop.ranges) { for byte in [base + range.start, base + range.end] { if !GraphemeCursor::new(byte, expanded.len(), true) .is_boundary(&expanded, 0) .expect("complete document supplies grapheme context") { return Err("Snippet range merges with surrounding graphemes".into()); } } } } } Ok(()) } fn completion_edits<'a>( lines: &[String], offsets: &[usize], primary: usize, item: &'a CompletionItem, ) -> Vec> { item.edits .iter() .enumerate() .map(|(index, edit)| Edit { target: (index == item.primary_edit).then_some(primary), start: cursor_offset( lines, offsets, Cursor { row: edit.start.row, column: edit.start.column, }, ), end: cursor_offset( lines, offsets, Cursor { row: edit.end.row, column: edit.end.column, }, ), text: Cow::Borrowed(&edit.text), caret: edit.text.len(), round_up: true, }) .collect() } pub(crate) fn validate_hover_text(text: &str) -> Result<(), String> { if text.len() > MAX_EDIT_BYTES { Err("Hover exceeds 4 MiB".into()) } else { validate_text(text) } } fn validate_paste_text(text: &str) -> Result<(), String> { if text.len() > MAX_EDIT_BYTES { Err("Paste exceeds 4 MiB".into()) } else { validate_text(text) } } pub(crate) fn validate_effects(effects: &[Effect]) -> Result<(), String> { let mut total = 0usize; for effect in effects { let text = match effect { Effect::Insert(text) | Effect::Replace(text) | Effect::Message(text) => text, Effect::Label { name, text } => { validate_label(name, text)?; total = total .checked_add(name.len()) .ok_or("Plugin effects exceed 4 MiB")?; text } }; total = total .checked_add(text.len()) .ok_or("Plugin effects exceed 4 MiB")?; if total > MAX_EDIT_BYTES { return Err("Plugin effects exceed 4 MiB".into()); } if !matches!(effect, Effect::Label { .. }) { validate_text(text)?; } } Ok(()) } pub(crate) fn validate_label(name: &str, text: &str) -> Result<(), String> { validate_name(name)?; if name .len() .checked_add(text.len()) .is_none_or(|bytes| bytes > MAX_EDIT_BYTES) { return Err("Label exceeds 4 MiB".into()); } Ok(()) } fn sanitize_label(text: String) -> String { if text.chars().any(char::is_control) { text.chars() .map(|ch| if ch.is_control() { '\u{fffd}' } else { ch }) .collect() } else { text } } fn validate_text(text: &str) -> Result<(), String> { if let Some(ch) = text .chars() .find(|ch| ch.is_control() && !matches!(ch, '\t' | '\n' | '\r')) { Err(format!("Unsupported control character U+{:04X}", ch as u32)) } else { Ok(()) } } pub(crate) fn normalize_newlines(text: &str) -> Cow<'_, str> { if !text.contains('\r') { return Cow::Borrowed(text); } let mut normalized = String::with_capacity(text.len()); let mut chars = text.chars().peekable(); while let Some(ch) = chars.next() { if ch == '\r' { if chars.peek() == Some(&'\n') { chars.next(); } normalized.push('\n'); } else { normalized.push(ch); } } Cow::Owned(normalized) } fn split_lines(text: &str) -> Vec { normalize_newlines(text) .split('\n') .map(str::to_owned) .collect() } fn seek_column( line: &str, column: usize, forward: bool, target: char, count: usize, ) -> Option { let occurrence = count.saturating_sub(1); if forward { line.graphemes(true) .enumerate() .skip(column.saturating_add(1)) .flat_map(|(column, grapheme)| { grapheme .chars() .filter(move |ch| *ch == target) .map(move |_| column) }) .nth(occurrence) } else { line[..grapheme_byte(line, column)] .graphemes(true) .rev() .enumerate() .flat_map(|(offset, grapheme)| { grapheme .chars() .filter(move |ch| *ch == target) .map(move |_| column - offset - 1) }) .nth(occurrence) } } fn grapheme_count(line: &str) -> usize { if line.is_ascii() { // CRLF is the only multi-byte ASCII grapheme. line.len() - line.matches("\r\n").count() } else { line.graphemes(true).count() } } fn grapheme_byte(line: &str, column: usize) -> usize { line.grapheme_indices(true) .nth(column) .map_or(line.len(), |(byte, _)| byte) } fn column_after_byte(line: &str, byte: usize) -> usize { line.grapheme_indices(true) .take_while(|(index, _)| *index < byte) .count() } fn lsp_navigation_position( lines: &[String], range: Utf16Range, target_range: Option, ) -> Result { for range in std::iter::once(range).chain(target_range) { if range.start > range.end { return Err("Reversed LSP range".into()); } for (row, _) in [range.start, range.end] { if row >= lines.len() { return Err("LSP line is outside the document".into()); } } } let (row, units) = range.start; let mut offset = 0; let mut column = 0; for cluster in lines[row].graphemes(true) { let width = cluster.encode_utf16().count(); if units < offset + width { return Ok(TextPosition { row, column }); } offset += width; column += 1; } Ok(TextPosition { row, column }) } pub(crate) 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( io::ErrorKind::InvalidInput, "Refusing to save through a symbolic link", )), Ok(metadata) if !metadata.is_file() => Err(io::Error::new( io::ErrorKind::InvalidInput, "Save target is not a regular file", )), Ok(metadata) => Ok(Some(metadata)), Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None), Err(error) => Err(error), } } fn check_disk_contents(path: &Path, expected: Option<(&[String], bool)>) -> io::Result<()> { let Some((lines, existed)) = expected else { return Ok(()); }; let matches = match fs::read_to_string(path) { Ok(text) => { existed && normalize_newlines(&text) .split('\n') .eq(lines.iter().map(String::as_str)) } Err(error) if error.kind() == io::ErrorKind::NotFound => !existed, Err(error) if error.kind() == io::ErrorKind::InvalidData => false, Err(error) => return Err(error), }; if matches { Ok(()) } else { Err(io::Error::new( io::ErrorKind::AlreadyExists, "File changed outside vamp; use :w! to overwrite", )) } } fn atomic_save( path: &Path, lines: &[String], expected: Option<(&[String], bool)>, ) -> io::Result { let metadata = target_metadata(path)?; check_disk_contents(path, expected)?; let parent = path .parent() .filter(|parent| !parent.as_os_str().is_empty()) .unwrap_or(Path::new(".")); let (temporary, mut file) = loop { let id = TEMP_ID.fetch_add(1, Ordering::Relaxed); let temporary = parent.join(format!(".vamp-{}-{id}.tmp", std::process::id())); let mut options = OpenOptions::new(); options.write(true).create_new(true); #[cfg(unix)] options.mode(0o600); match options.open(&temporary) { Ok(file) => break (temporary, file), Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue, Err(error) => return Err(error), } }; let result = (|| { for (index, line) in lines.iter().enumerate() { if index > 0 { file.write_all(b"\n")?; } file.write_all(line.as_bytes())?; } if let Some(metadata) = metadata { file.set_permissions(metadata.permissions())?; } file.sync_all()?; let stamp = FileStamp::from_metadata(&file.metadata()?)?; drop(file); // Recheck immediately before rename: never knowingly replace a symlink. target_metadata(path)?; check_disk_contents(path, expected)?; fs::rename(&temporary, path)?; #[cfg(unix)] fs::File::open(parent)?.sync_all()?; Ok(stamp) })(); if result.is_err() { let _ = fs::remove_file(&temporary); } result } #[cfg(test)] // These fixtures intentionally contain ranges, not lists of indices. #[allow(clippy::single_range_in_vec_init)] mod tests { use super::*; use crate::plugin::CompletionEdit; use std::collections::BTreeSet; struct Directory(PathBuf); impl Directory { fn new() -> Self { let id = TEMP_ID.fetch_add(1, Ordering::Relaxed); let path = std::env::temp_dir().join(format!("vamp-editor-test-{}-{id}", std::process::id())); fs::create_dir(&path).unwrap(); Self(path) } fn path(&self, name: &str) -> PathBuf { self.0.join(name) } } impl Drop for Directory { fn drop(&mut self) { let _ = fs::remove_dir_all(&self.0); } } fn editor() -> Editor { let mut editor = Editor::open(None, vec!["text".into()]).unwrap(); editor.set_clipboard_mode("separate").unwrap(); editor } #[test] fn copy_line_action_and_y_prefix_respect_bindings_and_cancellation() { let mut editor = editor(); editor .apply(vec![Effect::Replace("one\ntwo".into())]) .unwrap(); editor .bind("normal", "q", Some("core.copy_line".into())) .unwrap(); assert!( editor .bind("visual", "q", Some("core.copy_line".into())) .is_err() ); assert!( editor .bind("insert", "q", Some("core.copy_line".into())) .is_err() ); type_text(&mut editor, "q"); assert_eq!(editor.clipboard, ["one\n"]); type_text(&mut editor, "2y3"); assert_eq!(editor.line_operator_pending(), Some('y')); assert_eq!(editor.count(), Some(6)); editor.handle(Input::Escape); assert_eq!(editor.line_operator_pending(), None); assert_eq!(editor.count(), None); editor .bind("normal", "y", Some("core.down".into())) .unwrap(); type_text(&mut editor, "y"); assert_eq!(editor.cursor().row, 1); assert_eq!(editor.line_operator_pending(), None); editor.bind("normal", "y", None).unwrap(); type_text(&mut editor, "yy"); assert_eq!(editor.clipboard, ["one\n"]); } #[test] fn copy_line_restores_overlapping_multicarets_and_preserves_redo_and_repeat() { let mut editor = editor(); editor .apply(vec![Effect::Replace("one\ntwo\nthree".into())]) .unwrap(); type_text(&mut editor, "iX"); editor.handle(Input::Escape); editor.handle(Input::Char('u')); editor.active.view.targets = vec![ Target { anchor: Cursor { row: 0, column: 1 }, active: Cursor { row: 0, column: 1 }, preferred_columns: Some((1, 1)), }, Target { anchor: Cursor { row: 1, column: 2 }, active: Cursor { row: 1, column: 2 }, preferred_columns: Some((2, 2)), }, ]; editor.active.view.primary = 1; let targets = editor.active.view.targets.clone(); let history = format!( "{:?}", (&editor.active.undo, &editor.active.redo, &editor.last_edit) ); let revision = editor.text_revision(); type_text(&mut editor, "2yy"); assert_eq!(editor.clipboard, ["one\ntwo\nthree\n"]); assert!(editor.clipboard_linewise); assert_eq!(editor.active.view.targets, targets); assert_eq!(editor.active.view.primary, 1); assert_eq!(editor.mode(), Mode::Normal); assert!(!editor.active.view.linewise); assert_eq!(editor.text_revision(), revision); assert_eq!( format!( "{:?}", (&editor.active.undo, &editor.active.redo, &editor.last_edit) ), history ); editor.handle(Input::Char('U')); assert_eq!(editor.context().text, "Xone\ntwo\nthree"); editor.handle(Input::Char('.')); assert_eq!(editor.context().text, "XXone\ntwo\nthree"); } fn type_text(editor: &mut Editor, text: &str) { for ch in text.chars() { editor.handle(if ch == '\n' { Input::Enter } else { Input::Char(ch) }); } } #[test] fn external_reload_clamps_shared_views_and_restarts_clean_insert_history() { let directory = Directory::new(); let path = directory.path("file.txt"); fs::write(&path, "one\ntwo\nthree").unwrap(); let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap(); editor .apply(vec![Effect::Replace("old edit".into())]) .unwrap(); editor.handle(Input::Char('u')); type_text(&mut editor, "gb$"); editor.execute("tab.new"); editor.handle(Input::Char('|')); let pane = editor.pane_id(); let revision = editor.text_revision(); fs::write(&path, "é").unwrap(); assert!(editor.refresh_files()); assert_eq!(editor.lines(), ["é"]); assert!(!editor.dirty()); assert!(editor.text_revision() > revision); assert_eq!(editor.cursor(), Cursor::default()); editor.handle(Input::Alt('h')); assert_ne!(editor.pane_id(), pane); assert_eq!(editor.cursor(), Cursor::default()); editor.execute("tab.previous"); assert_eq!(editor.cursor(), Cursor::default()); editor.handle(Input::Char('U')); assert_eq!(editor.lines(), ["é"]); editor.handle(Input::Char('i')); fs::write(&path, "😀ZZ").unwrap(); assert!(editor.refresh_files()); assert_eq!(editor.mode(), Mode::Insert); type_text(&mut editor, "X"); editor.handle(Input::Escape); editor.handle(Input::Char('u')); assert_eq!(editor.lines(), ["😀ZZ"]); assert!(!editor.dirty()); } #[test] fn external_dirty_writes_require_force_even_without_a_poll() { for (normal, force, quits) in [ ("w", "w!", false), ("save", "force_save", false), ("wq", "wq!", true), ("save_and_quit", "force_save_and_quit", true), ] { let directory = Directory::new(); let path = directory.path("file.txt"); fs::write(&path, "baseline").unwrap(); let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap(); editor .apply(vec![Effect::Replace("local edits".into())]) .unwrap(); fs::write(&path, "external").unwrap(); assert!(!editor.refresh_files()); editor.execute(normal); assert_eq!(fs::read_to_string(&path).unwrap(), "external"); assert_eq!(editor.lines(), ["local edits"]); assert!(editor.dirty()); assert!(!editor.should_quit()); assert!(editor.message().contains(":w!")); editor.execute(force); assert_eq!(fs::read_to_string(&path).unwrap(), "local edits"); assert!(!editor.dirty()); assert_eq!(editor.should_quit(), quits); } } #[test] fn external_deletion_is_not_a_new_file_and_invalid_reload_preserves_text() { let directory = Directory::new(); let path = directory.path("file.txt"); fs::write(&path, "").unwrap(); let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap(); fs::remove_file(&path).unwrap(); assert!(editor.refresh_files()); assert!(!editor.refresh_files()); editor.execute("w"); assert!(!path.exists()); editor.execute("w!"); assert_eq!(fs::read_to_string(&path).unwrap(), ""); fs::write(&path, b"\xff").unwrap(); let revision = editor.text_revision(); assert!(editor.refresh_files()); assert_eq!(editor.text_revision(), revision); assert_eq!(editor.lines(), [""]); editor.execute("wq"); assert!(!editor.should_quit()); assert_eq!(fs::read(&path).unwrap(), b"\xff"); fs::write(&path, "recovered\r\n").unwrap(); assert!(editor.refresh_files()); assert_eq!(editor.lines(), ["recovered", ""]); assert!(!editor.dirty()); let missing = directory.path("new.txt"); let mut new = Editor::open(Some(missing.clone()), vec![]).unwrap(); new.execute("w"); assert_eq!(fs::read_to_string(missing).unwrap(), ""); } #[test] fn external_same_text_keeps_history_and_newline_normalization_allows_save() { let directory = Directory::new(); let path = directory.path("file.txt"); fs::write(&path, "same\r\n").unwrap(); let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap(); editor .apply(vec![Effect::Replace("old edit".into())]) .unwrap(); editor.handle(Input::Char('u')); let revision = editor.text_revision(); fs::write(&path, "same\n").unwrap(); assert!(!editor.refresh_files()); assert_eq!(editor.text_revision(), revision); editor.handle(Input::Char('U')); assert_eq!(editor.lines(), ["old edit"]); editor.execute("w"); assert_eq!(fs::read_to_string(path).unwrap(), "old edit"); assert!(!editor.dirty()); } #[cfg(unix)] #[test] fn external_atomic_replacement_with_same_size_and_mtime_reloads() { let directory = Directory::new(); let path = directory.path("file.txt"); let replacement = directory.path("replacement"); let fixed = std::time::UNIX_EPOCH + std::time::Duration::from_secs(123); fs::write(&path, "before").unwrap(); fs::File::open(&path).unwrap().set_modified(fixed).unwrap(); let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap(); fs::write(&replacement, "after!").unwrap(); fs::File::open(&replacement) .unwrap() .set_modified(fixed) .unwrap(); fs::rename(&replacement, &path).unwrap(); assert!(editor.refresh_files()); assert_eq!(editor.lines(), ["after!"]); } #[test] fn external_reload_refreshes_working_diff_but_not_historical_snapshots() { let directory = Directory::new(); let path = directory.path("file.txt"); fs::write(&path, "disk").unwrap(); let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap(); working_diff(&mut editor, &directory.0, &["file.txt"]); fs::write(&path, "external working").unwrap(); assert!(editor.refresh_files()); assert_eq!( editor.diff().unwrap().comparison.as_ref().unwrap().files[0].after, ["external working"], ); editor.execute("diff.close"); historical_diff( &mut editor, &directory.0, vec!["before".into()], vec!["after".into()], ); let revision = editor.text_revision(); fs::write(&path, "new disk").unwrap(); assert!(editor.refresh_files()); assert_eq!(editor.lines(), ["before"]); assert_eq!(editor.text_revision(), revision); editor.execute("w!"); assert_eq!(fs::read_to_string(path).unwrap(), "new disk"); } #[test] fn guarded_restart_closes_historical_views_without_losing_dirty_source_history() { let directory = Directory::new(); let path = directory.path("file.txt"); fs::write(&path, "disk").unwrap(); let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap(); editor .apply(vec![Effect::Replace("unsaved".into())]) .unwrap(); let dirty = editor.buffer_id(); historical_diff( &mut editor, &directory.0, vec!["before".into()], vec!["after".into()], ); editor.execute("tab.new"); let snapshot = editor.buffer_id(); assert!(editor.active.diff_snapshot.is_some()); editor.execute("restart"); assert!(!editor.should_restart()); assert!(!editor.has_buffer(snapshot)); assert_eq!(editor.buffer_id(), dirty); assert_eq!(editor.lines(), ["unsaved"]); assert!(editor.diff().is_none()); assert!(editor.tabs.values().all(|tab| tab.diff().is_none())); editor.handle(Input::Char('u')); assert_eq!(editor.lines(), ["disk"]); assert!(!editor.dirty()); assert_eq!(fs::read_to_string(path).unwrap(), "disk"); editor.execute("restart"); assert!(editor.should_restart()); assert!(editor.should_quit()); } #[test] fn notification_escape_preserves_carets_then_ordinary_escape_collapses_them() { let mut editor = editor(); editor .apply(vec![Effect::Replace("abcd\nabcd".into())]) .unwrap(); set_carets(&mut editor, &[(0, 1), (1, 1)], 1); assert!(editor.notification_visible()); editor.set_message("Written"); editor.handle(Input::Right); let targets = editor.active.view.targets.clone(); let revision = editor.text_revision(); let history = (editor.active.undo.len(), editor.active.redo.len()); editor.handle(Input::Escape); assert!(!editor.notification_visible()); assert_eq!(editor.message(), "Written"); assert_eq!(editor.active.view.targets, targets); assert_eq!(editor.active.view.primary, 1); assert_eq!(editor.text_revision(), revision); assert_eq!( (editor.active.undo.len(), editor.active.redo.len()), history ); editor.handle(Input::Escape); assert_eq!(editor.selection_count(), 1); editor.set_message("Written"); assert!(editor.notification_visible()); type_text(&mut editor, "iX"); editor.handle(Input::Escape); assert!(editor.notification_visible()); assert_eq!(editor.message(), "Written"); editor.handle(Input::Char('v')); editor.handle(Input::Escape); assert!(editor.notification_visible()); for prompt in [':', '/'] { editor.handle(Input::Char(prompt)); editor.handle(Input::Char('x')); editor.handle(Input::Escape); assert_eq!(editor.mode(), Mode::Normal); assert!(editor.notification_visible()); } } #[test] fn notification_escape_waits_for_prefixes_and_focused_overlays() { let mut editor = editor(); editor .apply(vec![Effect::Replace("abcd\nabcd".into())]) .unwrap(); set_carets(&mut editor, &[(0, 1), (1, 1)], 1); editor.set_message("Written"); let targets = editor.active.view.targets.clone(); for prefix in ["f", "g", "2d", "4", " b"] { type_text(&mut editor, prefix); editor.handle(Input::Escape); assert!(editor.notification_visible(), "{prefix}"); assert_eq!(editor.active.view.targets, targets, "{prefix}"); assert_eq!(editor.count(), None); } editor.show_startup_diagnostic("startup".into()); editor.handle(Input::Escape); assert!(editor.startup_diagnostic().is_none()); assert!(editor.notification_visible()); editor.show_hover("hover".into()).unwrap(); editor.handle(Input::Escape); assert!(editor.hover().is_none()); assert!(editor.notification_visible()); editor.handle(Input::Ctrl('p')); editor.handle(Input::Escape); assert!(editor.palette().is_none()); assert!(editor.notification_visible()); editor.open_file_picker(FilePresentation::Docked); editor.handle(Input::Char('i')); assert!(editor.file_picker().unwrap().searching); editor.handle(Input::Escape); assert!(editor.file_picker().unwrap().focused); assert!(editor.notification_visible()); editor.handle(Input::Escape); assert!(!editor.file_picker().unwrap().focused); assert!(editor.notification_visible()); assert_eq!(editor.active.view.targets, targets); editor.handle(Input::Escape); assert!(!editor.notification_visible()); assert_eq!(editor.active.view.targets, targets); } #[test] fn docked_picker_leaders_replace_or_blur_consistently_without_stealing_filter_text() { let directory = Directory::new(); fs::write(directory.path("file.txt"), "source").unwrap(); for comparison in [false, true] { let mut editor = editor(); editor.project_root = directory.0.clone(); if comparison { historical_diff( &mut editor, &directory.0, vec!["before".into()], vec!["after".into()], ); } type_text(&mut editor, " e"); assert!(editor.file_picker().unwrap().focused); assert!(!editor.file_picker().unwrap().searching); type_text(&mut editor, " o"); assert_eq!( editor.file_picker().unwrap().presentation, FilePresentation::Floating ); assert!(editor.file_picker().unwrap().searching); editor.handle(Input::Escape); editor.handle(Input::Escape); type_text(&mut editor, " e"); let selected = editor.file_picker().unwrap().selected; editor.handle(Input::Escape); let escape_focus = editor.diff().map(|diff| diff.focus); type_text(&mut editor, " e"); assert!(editor.file_picker().unwrap().focused); type_text(&mut editor, " e"); assert!(!editor.file_picker().unwrap().focused); assert_eq!( editor.file_picker().unwrap().presentation, FilePresentation::Docked ); assert_eq!(editor.file_picker().unwrap().selected, selected); assert_eq!(editor.diff().map(|diff| diff.focus), escape_focus); type_text(&mut editor, " e"); editor.handle(Input::Char('i')); type_text(&mut editor, " o e"); assert_eq!(editor.file_picker().unwrap().query, " o e"); assert!(editor.file_picker().unwrap().searching); assert_eq!( editor.file_picker().unwrap().presentation, FilePresentation::Docked ); } } #[test] fn focused_text_fields_keep_printable_normal_bindings_as_text() { let directory = Directory::new(); fs::write(directory.path("sample.txt"), "text").unwrap(); for field in [ "floating", "docked", "diff_tree", "diff_list", "palette", "command", "search", ] { let mut editor = editor(); editor.project_root = directory.0.clone(); editor .bind("normal", "x", Some("core.pane.split_vertical".into())) .unwrap(); match field { "floating" => editor.open_file_picker(FilePresentation::Floating), "docked" => { editor.open_file_picker(FilePresentation::Docked); editor.handle(Input::Char('i')); } "diff_tree" | "diff_list" => { historical_diff( &mut editor, &directory.0, vec!["before".into()], vec!["after".into()], ); if field == "diff_list" { editor.set_file_explorer_view("list").unwrap(); } editor.open_file_picker(FilePresentation::Docked); if field == "diff_tree" { editor.handle(Input::Char('i')); } } "palette" => { editor.handle(Input::Ctrl('p')); } "command" => { editor.handle(Input::Char(':')); } "search" => { editor.handle(Input::Char('/')); } _ => unreachable!(), } let document = editor.lines().to_vec(); editor.handle(Input::Char(':')); editor.handle(Input::Char('x')); let query = match field { "palette" => editor.palette().unwrap().query.as_str(), "command" | "search" => editor.command_line(), _ => editor.file_picker().unwrap().query.as_str(), }; assert_eq!(query, ":x", "{field}"); assert_eq!(editor.pane_layout().len(), 1, "{field}"); assert_eq!(editor.lines(), document, "{field}"); } } #[test] fn empty_notification_does_not_swallow_escape_but_cursor_diagnostic_does() { let mut editor = editor(); editor .apply(vec![Effect::Replace("abcd\nabcd".into())]) .unwrap(); set_carets(&mut editor, &[(0, 1), (1, 1)], 1); editor.handle(Input::Escape); assert_eq!(editor.selection_count(), 1); assert!(editor.notification_visible()); editor .replace_diagnostics( "fixture", editor.buffer_id(), editor.text_revision(), vec![annotation((1, 0), (1, 3), "diagnostic")], ) .unwrap(); set_carets(&mut editor, &[(0, 1), (1, 1)], 1); editor.handle(Input::Escape); assert!(!editor.notification_visible()); assert_eq!(editor.selection_count(), 2); assert_eq!(editor.diagnostic_at_cursor().unwrap().message, "diagnostic"); editor.handle(Input::Escape); assert_eq!(editor.selection_count(), 1); } #[test] fn hidden_pane_interactions_cannot_reopen_or_dismiss_focused_notifications() { let mut editor = editor(); let hidden = editor.pane_id(); editor.execute("pane.split_vertical"); editor.set_message("focused"); editor.handle(Input::Escape); assert!(!editor.notification_visible()); editor.with_pane(hidden, |pane| pane.set_message("background")); assert_eq!(editor.message(), "focused"); assert!(!editor.notification_visible()); editor.with_render_pane(hidden, |pane| { assert!(pane.message().is_empty()); }); assert!(!editor.notification_visible()); editor.set_message("focused"); editor.with_pane(hidden, |pane| { pane.set_message("background"); pane.handle(Input::Escape); }); assert!(editor.notification_visible()); assert_eq!(editor.message(), "focused"); editor.execute("tab.new"); editor.set_message("new tab"); editor.handle(Input::Escape); editor.with_pane(hidden, |pane| pane.set_message("hidden tab")); assert_eq!(editor.message(), "new tab"); assert!(!editor.notification_visible()); } #[test] fn buffer_activation_preserves_last_operation_and_dismissal() { let directory = Directory::new(); let path = directory.path("opened.txt"); fs::write(&path, "text").unwrap(); let mut editor = editor(); editor.set_message("Written"); editor.execute(&format!("edit {}", path.display())); assert_eq!(editor.message(), "Written"); editor.handle(Input::Escape); for command in ["buffer 1", "bnext", "bprev", "buffer 1"] { editor.execute(command); assert_eq!(editor.message(), "Written"); assert!(!editor.notification_visible()); } editor.execute("buffer 999"); assert!(editor.message().contains("No buffer")); assert!(editor.notification_visible()); } #[test] fn native_lsp_cache_is_independent_presentation_metadata() { let mut editor = editor(); editor .apply(vec![Effect::Replace("document".into())]) .unwrap(); type_text(&mut editor, "vl"); editor.set_message("Written"); editor.notification_visible = false; editor .set_label("lsp".into(), "configured label".into()) .unwrap(); let targets = editor.active.view.targets.clone(); let revision = editor.text_revision(); let history = (editor.active.undo.len(), editor.active.redo.len()); assert!(editor.lsp_names().is_empty()); assert_eq!(editor.lsp_label(), ""); assert!(editor.set_lsp_names(vec!["rust-analyzer".into(), "界 server".into()])); assert_eq!(editor.lsp_names(), ["rust-analyzer", "界 server"]); assert_eq!(editor.lsp_label(), "rust-analyzer + 界 server"); assert!(!editor.set_lsp_names(vec!["rust-analyzer".into(), "界 server".into()])); assert!(editor.set_lsp_names(vec!["unsafe\nname".into()])); assert_eq!(editor.lsp_label(), "unsafe�name"); assert!(editor.set_lsp_names(Vec::new())); assert_eq!(editor.lsp_label(), ""); assert_eq!(editor.label("lsp"), "configured label"); assert_eq!(editor.message(), "Written"); assert!(!editor.notification_visible()); assert_eq!(editor.mode(), Mode::Visual); assert_eq!(editor.active.view.targets, targets); assert_eq!(editor.text_revision(), revision); assert_eq!( (editor.active.undo.len(), editor.active.redo.len()), history ); } #[test] fn pane_focus_remaps_do_not_steal_printable_picker_filter_input() { let directory = Directory::new(); fs::write(directory.path("hello.txt"), "alpha").unwrap(); let mut editor = editor(); editor.project_root = directory.0.clone(); editor.handle(Input::Char('|')); editor .bind("normal", "h", Some("core.pane.focus_left".into())) .unwrap(); editor.open_file_picker(FilePresentation::Floating); editor.handle(Input::Char('h')); assert_eq!(editor.pane_id(), 2); assert_eq!(editor.file_picker().unwrap().query, "h"); } #[test] fn pane_focus_bindings_escape_focused_explorer_without_changing_documents() { let directory = Directory::new(); fs::write(directory.path("a.txt"), "alpha").unwrap(); fs::write(directory.path("b.txt"), "beta").unwrap(); let mut editor = editor(); editor.project_root = directory.0.clone(); editor.handle(Input::Char('|')); editor.handle(Input::Char('_')); let original = editor.lines().to_vec(); for (key, pane) in [('k', 2), ('h', 1), ('l', 2), ('j', 3)] { editor.open_file_picker(FilePresentation::Docked); assert!(editor.file_picker().unwrap().focused); editor.handle(Input::Alt(key)); assert_eq!(editor.pane_id(), pane); assert!(!editor.file_picker().unwrap().focused); assert_eq!(editor.lines(), original); } } #[test] fn explorer_navigation_overrides_take_precedence_over_pane_focus() { for context in ["tree", "selection"] { for target in [None, Some("core.down")] { let directory = Directory::new(); fs::write(directory.path("a.txt"), "alpha").unwrap(); fs::write(directory.path("b.txt"), "beta").unwrap(); let mut editor = editor(); editor.project_root = directory.0.clone(); editor.handle(Input::Char('|')); editor .bind(context, "alt+h", target.map(str::to_owned)) .unwrap(); editor.open_file_picker(FilePresentation::Docked); editor.handle(Input::Alt('h')); assert_eq!(editor.pane_id(), 2); let picker = editor.file_picker().unwrap(); assert!(picker.focused); assert_eq!(picker.selected, usize::from(target.is_some())); } } } #[test] fn nested_panes_inherit_views_and_focus_geometrically_without_touching_ctrl_j() { let mut editor = editor(); editor .apply(vec![Effect::Replace("abc\ndef\nghi".into())]) .unwrap(); set_carets(&mut editor, &[(1, 1)], 0); editor.handle(Input::Char('|')); assert_eq!(editor.pane_id(), 2); assert_eq!(editor.cursor(), Cursor { row: 1, column: 1 }); editor.handle(Input::Char('_')); assert_eq!(editor.pane_layout().len(), 3); editor.handle(Input::Alt('k')); assert_eq!(editor.pane_id(), 2); editor.handle(Input::Alt('h')); assert_eq!(editor.pane_id(), 1); editor.handle(Input::Alt('l')); assert_eq!(editor.pane_id(), 2); assert_eq!(parse_key("alt+j").unwrap(), Input::Alt('j')); assert_eq!(parse_key("option+K").unwrap(), Input::Alt('k')); editor.handle(Input::Char('i')); editor.handle(Input::Ctrl('j')); assert_eq!(editor.lines(), ["abc", "d", "ef", "ghi"]); editor.handle(Input::Escape); } #[test] fn panes_share_edits_saved_state_and_undo_but_rebase_independent_visual_views() { let mut editor = editor(); editor .apply(vec![Effect::Replace("abcdef".into())]) .unwrap(); editor.handle(Input::Char('|')); set_ranges( &mut editor, &[(Cursor { row: 0, column: 2 }, Cursor { row: 0, column: 4 })], 0, ); let selected = editor.selections(); let selected_cursor = editor.cursor(); editor.execute("pane.focus_left"); set_carets(&mut editor, &[(0, 0)], 0); type_text(&mut editor, "iX"); editor.handle(Input::Escape); assert_eq!(editor.documents().count(), 1); let focused = editor.pane_id(); let lines_pointer = editor.lines().as_ptr(); editor.with_render_pane(2, |pane| { assert!(!pane.render_pane_focused()); assert_eq!(pane.mode(), Mode::Visual); assert_eq!(pane.lines().as_ptr(), lines_pointer); assert_eq!(pane.cursor().column, selected_cursor.column + 1); assert_eq!( pane.selections()[0].start.column, selected[0].start.column + 1 ); assert!(pane.dirty()); }); assert_eq!(editor.pane_id(), focused); editor.execute("pane.focus_right"); editor.handle(Input::Escape); editor.handle(Input::Char('u')); assert_eq!(editor.lines(), ["abcdef"]); editor.execute("pane.focus_left"); editor.handle(Input::Char('U')); assert_eq!(editor.lines(), ["Xabcdef"]); let directory = Directory::new(); editor.execute(&format!("save {}", directory.path("saved.txt").display())); assert!(!editor.dirty()); editor.with_render_pane(2, |pane| assert!(!pane.dirty())); editor.apply(vec![Effect::Replace("z".into())]).unwrap(); editor.with_render_pane(2, |pane| assert_eq!(pane.cursor(), Cursor::default())); } #[test] fn panes_keep_modes_search_and_wrap_local_and_close_exclusive_clean_buffers() { let directory = Directory::new(); let path = directory.path("clean.txt"); fs::write(&path, "one\ntwo\nthree").unwrap(); let mut editor = Editor::open(Some(path), vec![]).unwrap(); editor.handle(Input::Char('|')); submit_search(&mut editor, "two"); editor.execute("word_wrap.toggle"); editor.execute("pane.focus_left"); assert!(!editor.word_wrap()); assert!(editor.active.view.search_query.is_empty()); editor.handle(Input::Char('i')); type_text(&mut editor, "X"); editor.execute("pane.focus_right"); assert_eq!(editor.active.view.search_query, "two"); assert!(editor.word_wrap()); assert_eq!(editor.mode(), Mode::Normal); editor.execute("pane.focus_left"); assert_eq!(editor.mode(), Mode::Insert); type_text(&mut editor, "Y"); editor.handle(Input::Escape); editor.handle(Input::Char('u')); assert_eq!(editor.lines(), ["Xone", "two", "three"]); editor.handle(Input::Char('u')); assert_eq!(editor.lines(), ["one", "two", "three"]); editor.execute(&format!("edit {}", directory.path("second.txt").display())); let exclusive = editor.buffer_id(); editor.execute("pane.close"); assert_eq!(editor.pane_layout().len(), 1); assert!(!editor.has_buffer(exclusive)); assert_eq!(editor.buffer_id(), 1); } #[test] fn tabs_preserve_panes_explorers_and_independent_views_of_shared_history() { let directory = Directory::new(); let mut editor = editor(); editor.project_root = directory.0.clone(); editor .apply(vec![Effect::Replace("abcdef".into())]) .unwrap(); editor.execute("pane.split_vertical"); let source_pane = editor.pane_id(); set_carets(&mut editor, &[(0, 2)], 0); editor.open_file_picker(FilePresentation::Docked); let layout = format!("{:?}", editor.pane_layout()); editor.execute("tab.new"); let new_pane = editor.pane_id(); assert_ne!(source_pane, new_pane); assert_eq!(editor.tab_index(), 1); assert_eq!(editor.tab_count(), 2); assert_eq!(editor.buffer_id(), 1); assert_eq!(editor.buffers().count(), 1); assert_eq!(editor.pane_layout().len(), 1); assert!(editor.file_picker().is_none()); set_carets(&mut editor, &[(0, 4)], 0); editor.handle(Input::CtrlAlt('h')); assert_eq!(editor.pane_id(), source_pane); assert_eq!(format!("{:?}", editor.pane_layout()), layout); assert_eq!(editor.cursor().column, 2); assert!(editor.file_picker().unwrap().focused); editor.handle(Input::CtrlAlt('l')); assert_eq!(editor.pane_id(), new_pane); assert_eq!(editor.cursor().column, 4); type_text(&mut editor, "iX"); editor.handle(Input::Escape); editor.handle(Input::CtrlAlt('l')); assert_eq!(editor.tab_index(), 0); assert_eq!(editor.lines(), ["abcdXef"]); assert_eq!(editor.cursor().column, 2); editor.blur_file_picker(); editor.handle(Input::Char('u')); assert_eq!(editor.lines(), ["abcdef"]); editor.handle(Input::CtrlAlt('h')); assert_eq!(editor.tab_index(), 1); assert_eq!(editor.lines(), ["abcdef"]); assert_eq!(editor.pane_buffer_id(source_pane), Some(1)); assert_eq!(editor.tab_buffer(0).unwrap().id, 1); assert_eq!(editor.tab_buffer(1).unwrap().id, 1); assert!(editor.tab_buffer(2).is_none()); } #[test] fn tab_close_guards_final_dirty_owners_and_force_keeps_shared_documents() { let directory = Directory::new(); let mut editor = editor(); editor.apply(vec![Effect::Insert("shared".into())]).unwrap(); editor.execute("tab.new"); editor.execute("tab.force_close"); assert_eq!(editor.tab_count(), 1); assert!(editor.has_buffer(1)); assert!(editor.dirty()); editor.execute("tab.new"); editor.execute(&format!( "edit {}", directory.path("exclusive.txt").display() )); let exclusive = editor.buffer_id(); editor .apply(vec![Effect::Insert("exclusive".into())]) .unwrap(); editor.execute("pane.split_horizontal"); editor.execute("tab.close"); assert_eq!(editor.tab_count(), 2); assert!(editor.message().contains("tab.force_close")); editor.execute("pane.close"); assert_eq!(editor.pane_layout().len(), 1); editor.execute("pane.close"); assert_eq!(editor.tab_count(), 2); editor.execute("pane.force_close"); assert_eq!(editor.tab_count(), 1); assert!(editor.has_buffer(1)); assert!(!editor.has_buffer(exclusive)); assert_eq!(editor.lines(), ["shared"]); editor.execute("tab.close"); assert!(!editor.should_quit()); editor.execute("tab.force_close"); assert!(editor.should_quit()); } #[test] fn closing_tab_checks_dirty_diff_return_buffer_even_when_live_buffer_is_shared() { let directory = Directory::new(); let path = directory.path("file.txt"); fs::write(&path, "base").unwrap(); let mut editor = Editor::open(Some(path), vec![]).unwrap(); editor.execute("tab.new"); editor.execute(&format!("edit {}", directory.path("return.txt").display())); let return_buffer = editor.buffer_id(); editor .apply(vec![Effect::Insert("dirty return".into())]) .unwrap(); working_diff(&mut editor, &directory.0, &["file.txt"]); editor.diff.as_mut().unwrap().focus = Focus::Before; editor.bind_diff_buffer(); assert!(!editor.dirty()); editor.execute("tab.close"); assert_eq!(editor.tab_count(), 2); assert!(editor.message().contains("unsaved") || editor.message().contains("Unsaved")); editor.execute("tab.force_close"); assert_eq!(editor.tab_count(), 1); assert!(editor.has_buffer(1)); assert!(!editor.has_buffer(return_buffer)); assert_eq!(editor.lines(), ["base"]); } #[test] fn tab_modifiers_aliases_catalog_and_palette_follow_pane_conventions() { for key in ["ctrl+alt+h", "CTRL+Option+H", "alt+ctrl+h", "option+ctrl+h"] { assert_eq!(parse_key(key).unwrap(), Input::CtrlAlt('h')); } for key in [ "ctrl+shift+h", "ctrl+alt+shift+h", "alt+alt+h", "ctrl+alt+left", ] { assert!(parse_key(key).is_err(), "{key}"); } assert_eq!(display_key(&Input::CtrlAlt('h')), "Ctrl+Alt+h"); let mut editor = editor(); for command in [ "tab.new", "tab.previous", "tab.next", "tab.close", "tab.force_close", ] { assert!(core_command(command).is_some()); assert!(Action::named(Mode::Normal, &format!("core.{command}")).is_some()); assert!(Action::named(Mode::Insert, &format!("core.{command}")).is_none()); } editor.execute("tab.new extra"); assert_eq!(editor.tab_count(), 1); editor.handle(Input::Ctrl('p')); type_text(&mut editor, "core.tab.new"); editor.handle(Input::Enter); assert_eq!(editor.tab_count(), 2); editor.handle(Input::Char('i')); editor.handle(Input::CtrlAlt('h')); assert_eq!(editor.tab_index(), 1); editor.handle(Input::Escape); editor.bind("normal", "option+ctrl+h", None).unwrap(); editor.handle(Input::CtrlAlt('h')); assert_eq!(editor.tab_index(), 1); editor .bind("normal", "ctrl+alt+h", Some("core.tab.previous".into())) .unwrap(); editor.handle(Input::CtrlAlt('h')); assert_eq!(editor.tab_index(), 0); editor.handle(Input::Ctrl('p')); type_text(&mut editor, "core.tab.force_close"); editor.handle(Input::Enter); assert_eq!(editor.command_line(), "tab.force_close "); assert_eq!(editor.tab_count(), 2); editor.handle(Input::Enter); assert_eq!(editor.tab_count(), 1); } #[test] fn hidden_tab_diff_results_restore_visible_insert_interaction_and_route_request_ids() { use crate::diff::{ Catalog, Category, ChangedFile, Comparison, FileStatus, RepoKind, Revision, }; let mut editor = editor(); editor.execute("diff"); let first_tab = editor.tab_id(); let (first_request, _) = editor.take_diff_request().unwrap(); editor.execute("pane.split_vertical"); let hidden_focused_pane = editor.pane_id(); editor.execute("tab.new"); editor.execute("diff"); let second_tab = editor.tab_id(); let (second_request, _) = editor.take_diff_request().unwrap(); assert!(second_request > first_request); editor.execute("tab.new"); let visible_tab = editor.tab_id(); let visible_pane = editor.pane_id(); type_text(&mut editor, "iX"); let cursor = editor.cursor(); editor.set_message("visible"); let catalog = || { Outcome::Catalog(Catalog { kind: RepoKind::Git, entries: [ RevisionTarget::Commit("base".into()), RevisionTarget::Working, ] .into_iter() .map(|target| Revision { target, label: "Revision".into(), description: String::new(), category: Category::Commits, }) .collect(), notice: None, }) }; assert!(editor.apply_diff_result(second_request, Ok(catalog()))); assert!(editor.apply_diff_result(first_request, Ok(catalog()))); assert!(!editor.apply_diff_result(first_request, Ok(catalog()))); assert_eq!(editor.tab_id(), visible_tab); assert_eq!(editor.pane_id(), visible_pane); assert_eq!(editor.cursor(), cursor); assert_eq!(editor.mode(), Mode::Insert); assert_eq!(editor.message(), "visible"); assert_eq!(editor.lines(), ["X"]); assert!(editor.diff().is_none()); assert!(!editor.tab_diff(first_tab).unwrap().loading); assert!(!editor.tab_diff(second_tab).unwrap().loading); let owner = editor.tabs[&first_tab].diff_owner.unwrap(); let (comparison_request, _) = editor.with_pane(owner, |editor| { editor.handle(Input::Enter); editor.handle(Input::Down); editor.handle(Input::Enter); editor.take_diff_request().unwrap() }); let before = vec!["base".to_owned()]; let after = vec!["changed".to_owned()]; let hunks = crate::diff::line_hunks(&before, false, &after, false); assert!(editor.apply_diff_result( comparison_request, Ok(Outcome::Comparison(Comparison { root: editor.project_root().to_owned(), before: "base".into(), after: "working".into(), editable_paths: Default::default(), files: vec![ChangedFile { loaded: true, path: "file.txt".into(), old_path: None, status: FileStatus::Modified, before, after, before_eof: false, after_eof: false, hunks, notice: None, }], })) )); assert_eq!(editor.tab_id(), visible_tab); assert_eq!(editor.pane_id(), visible_pane); assert_eq!(editor.buffer_id(), 1); assert_eq!(editor.cursor(), cursor); assert_eq!(editor.mode(), Mode::Insert); assert_eq!(editor.message(), "visible"); assert_ne!(editor.pane_buffer_id(owner), Some(1)); assert_eq!(editor.tabs[&first_tab].pane_id, hidden_focused_pane); editor.handle(Input::Escape); editor.handle(Input::Char('u')); assert_eq!(editor.lines(), [""]); } #[test] fn tab_snapshots_remain_readonly_and_other_comparisons_survive_release() { let directory = Directory::new(); let mut editor = editor(); historical_diff( &mut editor, &directory.0, vec!["base".into()], vec!["first".into()], ); let first_tab = editor.tab_id(); let first_snapshot = editor.buffer_id(); let first_comparison = editor.diff().unwrap().comparison_id; editor.execute("tab.new"); assert_eq!(editor.buffer_id(), first_snapshot); assert!(editor.diff().is_none()); assert!( editor .apply(vec![Effect::Insert("rejected".into())]) .is_err() ); editor.execute("buffer 1"); historical_diff( &mut editor, &directory.0, vec!["base".into()], vec!["second".into()], ); let second_snapshot = editor.buffer_id(); assert_ne!(editor.diff().unwrap().comparison_id, first_comparison); editor.execute("tab.new"); assert_eq!(editor.buffer_id(), second_snapshot); editor.execute("tab.previous"); editor.execute("diff.close"); assert!(editor.tab_diff(first_tab).is_some()); assert!(editor.has_buffer(first_snapshot)); assert!(editor.has_buffer(second_snapshot)); editor.execute("tab.next"); assert_eq!(editor.buffer_id(), second_snapshot); assert!(editor.mutation_blocked()); editor.execute("tab.next"); assert_eq!(editor.buffer_id(), first_snapshot); assert_eq!(editor.diff().unwrap().comparison_id, first_comparison); assert_eq!(editor.diff().unwrap().focus, Focus::Before); } #[test] fn hidden_tab_comparisons_refresh_shared_edits_and_deletion_closes_all_owners() { let directory = Directory::new(); let path = directory.path("file.txt"); fs::write(&path, "base").unwrap(); let mut editor = Editor::open(Some(path), vec![]).unwrap(); working_diff(&mut editor, &directory.0, &["file.txt"]); editor.diff.as_mut().unwrap().focus = Focus::After; editor.bind_diff_buffer(); let first_tab = editor.tab_id(); editor.execute("tab.new"); working_diff(&mut editor, &directory.0, &["file.txt"]); editor.diff.as_mut().unwrap().focus = Focus::After; editor.bind_diff_buffer(); let second_tab = editor.tab_id(); editor.execute("tab.new"); editor .apply(vec![Effect::Insert("changed".into())]) .unwrap(); let revision = editor.text_revision(); assert_eq!( editor.tab_diff(first_tab).unwrap().live_revision, Some(revision) ); assert_eq!( editor.tab_diff(second_tab).unwrap().live_revision, Some(revision) ); editor.execute("tab.close"); assert_eq!(editor.tab_count(), 2); assert!(editor.has_buffer(1)); editor.execute("bdelete! 1"); assert!(!editor.has_buffer(1)); assert!(editor.tab_diff(first_tab).is_none()); assert!(editor.tab_diff(second_tab).is_none()); editor.execute("tab.next"); assert!(editor.diff().is_none()); assert!(editor.has_buffer(editor.buffer_id())); } #[test] fn pane_views_survive_buffer_switches_and_deletion_replaces_every_reference() { let directory = Directory::new(); let path = directory.path("other.txt"); fs::write(&path, "other").unwrap(); let mut editor = editor(); editor .apply(vec![Effect::Replace("abcdef".into())]) .unwrap(); editor.handle(Input::Char('|')); set_carets(&mut editor, &[(0, 4)], 0); editor.execute(&format!("edit {}", path.display())); let other = editor.buffer_id(); set_carets(&mut editor, &[(0, 2)], 0); editor.execute("buffer 1"); assert_eq!(editor.cursor().column, 4); editor.execute("pane.focus_left"); assert_eq!(editor.cursor().column, 0); editor.execute(&format!("buffer {other}")); assert_eq!(editor.cursor().column, 0); editor.execute("pane.focus_right"); editor.execute(&format!("buffer {other}")); assert_eq!(editor.cursor().column, 2); editor.execute(&format!("bdelete {other}")); assert!(!editor.has_buffer(other)); assert_eq!(editor.pane_buffer_id(1), Some(1)); assert_eq!(editor.pane_buffer_id(2), Some(1)); editor.with_render_pane(1, |pane| assert_eq!(pane.buffer_id(), 1)); } #[test] fn pane_close_protects_exclusive_and_hidden_dirty_buffers_but_retains_shared_edits() { for force in [false, true] { let mut editor = editor(); editor .apply(vec![Effect::Replace("unsaved".into())]) .unwrap(); editor.handle(Input::Char('|')); editor.execute(if force { "pane.force_close" } else { "pane.close" }); assert_eq!(editor.pane_layout().len(), 1); assert_eq!(editor.lines(), ["unsaved"]); assert!(editor.dirty()); editor.execute("pane.close"); assert!(!editor.should_quit()); editor.execute("pane.force_close"); assert!(editor.should_quit()); } let directory = Directory::new(); let path = directory.path("clean.txt"); fs::write(&path, "clean").unwrap(); let mut editor = editor(); editor.handle(Input::Char('|')); editor.execute(&format!("edit {}", path.display())); let exclusive = editor.buffer_id(); editor .apply(vec![Effect::Insert("changed".into())]) .unwrap(); editor.execute("pane.close"); assert_eq!(editor.pane_layout().len(), 2); assert!(editor.message().contains("pane.force_close")); editor.execute("pane.force_close"); assert_eq!(editor.pane_layout().len(), 1); assert!(!editor.has_buffer(exclusive)); editor .apply(vec![Effect::Insert("hidden dirty".into())]) .unwrap(); editor.execute(&format!("edit {}", path.display())); editor.execute("pane.close"); assert!(!editor.should_quit()); editor.execute("pane.force_close"); assert!(editor.should_quit()); } fn close_pane_using(editor: &mut Editor, binding: Option<&str>, force: bool) { let command = if force { "pane.force_close" } else { "pane.close" }; if let Some(binding) = binding { editor .bind("normal", binding, Some(format!("core.{command}"))) .unwrap(); for key in binding.split_whitespace() { editor.handle(parse_key(key).unwrap()); } } else { editor.execute(command); } } #[test] fn pane_close_targets_focused_docked_lists_without_closing_dirty_documents_or_tabs() { let directory = Directory::new(); fs::create_dir(directory.path("dir")).unwrap(); let path = directory.path("dir/file.txt"); fs::write(&path, "base").unwrap(); for comparison in [false, true] { for another_tab in [false, true] { for force in [false, true] { for binding in [ None, Some("ctrl+x"), Some("alt+x"), Some("ctrl+alt+x"), Some("delete"), Some("ctrl+w c"), ] { let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap(); editor.project_root = directory.0.clone(); if another_tab { editor.execute("tab.new"); } type_text(&mut editor, "iX"); editor.handle(Input::Escape); if comparison { working_diff(&mut editor, &directory.0, &["dir/file.txt"]); editor.handle(Input::Char('\t')); editor.handle(Input::Char('\t')); assert_eq!(editor.diff().unwrap().focus, Focus::After); } let owner = (editor.tab_id(), editor.pane_id(), editor.buffer_id()); let tabs = editor.tab_count(); let buffers = editor.buffer_count(); let revision = editor.text_revision(); let targets = editor.active.view.targets.clone(); let history = (editor.active.undo.len(), editor.active.redo.len()); let comparison_id = editor.diff().and_then(|diff| diff.comparison_id); editor.act(Action::FileExplorer); type_text(&mut editor, "ifile"); editor.handle(Input::Escape); let selected = editor.file_picker().unwrap().selected; let rows = editor .file_picker() .unwrap() .entries() .map(|row| (row.path.to_owned(), row.expanded)) .collect::>(); close_pane_using(&mut editor, binding, force); assert!(!editor.should_quit()); assert_eq!(editor.sequence_pending(), None); assert!(editor.file_picker().is_none()); assert_eq!( (editor.tab_id(), editor.pane_id(), editor.buffer_id()), owner ); assert_eq!(editor.pane_layout().len(), 1); assert_eq!(editor.tab_count(), tabs); assert_eq!(editor.buffer_count(), buffers); assert_eq!(editor.lines(), ["Xbase"]); assert!(editor.dirty()); assert_eq!(editor.text_revision(), revision); assert_eq!(editor.active.view.targets, targets); assert_eq!( (editor.active.undo.len(), editor.active.redo.len()), history ); if comparison { let diff = editor.diff().unwrap(); assert_eq!(diff.comparison_id, comparison_id); assert_eq!(diff.focus, Focus::After); assert!(!diff.files_visible); assert!(!diff.files.as_ref().unwrap().focused); editor.act(Action::FileExplorer); let picker = editor.file_picker().unwrap(); assert_eq!(picker.query, "file"); assert_eq!(picker.selected, selected); assert_eq!( picker .entries() .map(|row| (row.path.to_owned(), row.expanded)) .collect::>(), rows ); editor.handle(Input::Ctrl('w')); if binding == Some("ctrl+w c") { assert!(editor.file_picker().is_some()); assert!(editor.sequence_pending().is_some()); editor.handle(Input::Char('c')); } assert!(editor.file_picker().is_none()); assert_eq!(editor.diff().unwrap().focus, Focus::After); } else { editor.act(Action::FileExplorer); assert_eq!(editor.file_picker().unwrap().query, ""); editor.handle(Input::Ctrl('w')); if binding == Some("ctrl+w c") { assert!(editor.file_picker().is_some()); assert!(editor.sequence_pending().is_some()); editor.handle(Input::Char('c')); } assert!(editor.file_picker().is_none()); } editor.handle(Input::Char('u')); assert_eq!(editor.lines(), ["base"]); } } } } } #[test] fn pane_close_does_not_target_unfocused_docked_lists() { let directory = Directory::new(); let path = directory.path("file.txt"); fs::write(&path, "base").unwrap(); for comparison in [false, true] { let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap(); editor.project_root = directory.0.clone(); type_text(&mut editor, "iX"); editor.handle(Input::Escape); editor.execute("pane.split_vertical"); if comparison { working_diff(&mut editor, &directory.0, &["file.txt"]); } editor.act(Action::FileExplorer); editor.handle(Input::Escape); assert!(!editor.file_picker().unwrap().focused); editor.execute("pane.close"); assert_eq!(editor.pane_layout().len(), 1); assert_eq!(editor.pane_id(), 1); assert!(!editor.should_quit()); assert_eq!(editor.lines(), ["Xbase"]); assert!(editor.dirty()); assert!(editor.diff().is_none()); if comparison { assert!(editor.file_picker().is_none()); } else { assert!(!editor.file_picker().unwrap().focused); } } } #[test] fn floating_pane_close_closes_underlying_panes_and_retains_shared_dirty_buffers() { let directory = Directory::new(); let base = directory.path("base.txt"); let other = directory.path("other.txt"); fs::write(&base, "base").unwrap(); fs::write(&other, "other").unwrap(); for comparison in [false, true] { for shared in [false, true] { for force in [false, true] { let mut editor = Editor::open(Some(base.clone()), vec![]).unwrap(); editor.project_root = directory.0.clone(); if shared { type_text(&mut editor, "iX"); editor.handle(Input::Escape); } editor.execute("pane.split_vertical"); if !shared { editor.execute(&format!("edit {}", other.display())); } let source = editor.buffer_id(); if comparison { let file = if shared { "base.txt" } else { "other.txt" }; working_diff(&mut editor, &directory.0, &[file]); editor.handle(Input::Char('\t')); editor.handle(Input::Char('\t')); } editor.act(Action::FilePicker); type_text(&mut editor, "txt"); close_pane_using(&mut editor, Some("alt+x"), force); assert_eq!(editor.pane_layout().len(), 1); assert_eq!(editor.pane_id(), 1); assert!(!editor.should_quit()); assert_eq!(editor.has_buffer(source), shared); assert_eq!(editor.lines(), [if shared { "Xbase" } else { "base" }]); assert_eq!(editor.dirty(), shared); assert!(editor.diff().is_none()); if comparison { assert!(editor.file_picker().is_none()); } else { let picker = editor.file_picker().unwrap(); assert!(picker.focused); assert_eq!(picker.presentation, FilePresentation::Floating); assert_eq!(picker.query, "txt"); } } } } } #[test] fn floating_pane_close_refuses_dirty_loss_then_force_closes_without_dismissing_first() { let directory = Directory::new(); let base = directory.path("base.txt"); let other = directory.path("other.txt"); fs::write(&base, "base").unwrap(); fs::write(&other, "other").unwrap(); for comparison in [false, true] { for binding in [ None, Some("ctrl+x"), Some("alt+x"), Some("ctrl+alt+x"), Some("delete"), Some("ctrl+w c"), ] { let mut editor = Editor::open(Some(base.clone()), vec![]).unwrap(); editor.project_root = directory.0.clone(); editor.execute("pane.split_vertical"); editor.execute(&format!("edit {}", other.display())); type_text(&mut editor, "iX"); editor.handle(Input::Escape); let source = editor.buffer_id(); if comparison { working_diff(&mut editor, &directory.0, &["other.txt"]); editor.handle(Input::Char('\t')); editor.handle(Input::Char('\t')); } let comparison_id = editor.diff().and_then(|diff| diff.comparison_id); let revision = editor.text_revision(); let targets = editor.active.view.targets.clone(); let history = (editor.active.undo.len(), editor.active.redo.len()); editor.act(Action::FilePicker); type_text(&mut editor, "other"); let selected = editor.file_picker().unwrap().selected; close_pane_using(&mut editor, binding, false); assert_eq!(editor.pane_layout().len(), 2); assert_eq!(editor.pane_id(), 2); assert_eq!(editor.buffer_id(), source); assert!(!editor.should_quit()); assert_eq!(editor.sequence_pending(), None); assert_eq!(editor.lines(), ["Xother"]); assert!(editor.dirty()); assert_eq!(editor.text_revision(), revision); assert_eq!(editor.active.view.targets, targets); assert_eq!( (editor.active.undo.len(), editor.active.redo.len()), history ); let picker = editor.file_picker().unwrap(); assert!(picker.focused); assert!(picker.searching); assert_eq!(picker.presentation, FilePresentation::Floating); assert_eq!(picker.query, "other"); assert_eq!(picker.selected, selected); assert!(editor.message().contains("pane.force_close")); assert_eq!( editor.diff().and_then(|diff| diff.comparison_id), comparison_id ); close_pane_using(&mut editor, binding, true); assert_eq!(editor.pane_layout().len(), 1); assert_eq!(editor.pane_id(), 1); assert!(!editor.has_buffer(source)); assert_eq!(editor.lines(), ["base"]); assert!(!editor.should_quit()); assert!(editor.diff().is_none()); assert_eq!(editor.file_picker().is_some(), !comparison); } } } #[test] fn floating_pane_close_reaches_tab_and_last_pane_guards() { let directory = Directory::new(); let path = directory.path("file.txt"); fs::write(&path, "base").unwrap(); for another_tab in [false, true] { for dirty in [false, true] { let mut editor = project_editor(&directory); if another_tab { editor.execute("tab.new"); } editor.execute(&format!("edit {}", path.display())); let source = editor.buffer_id(); let tab = editor.tab_id(); if dirty { type_text(&mut editor, "iX"); editor.handle(Input::Escape); } editor.act(Action::FilePicker); type_text(&mut editor, "file"); close_pane_using(&mut editor, Some("ctrl+x"), false); if dirty { assert_eq!(editor.tab_id(), tab); assert_eq!(editor.buffer_id(), source); assert!(editor.has_buffer(source)); assert!(!editor.should_quit()); assert!(editor.file_picker().unwrap().focused); assert_eq!(editor.file_picker().unwrap().query, "file"); close_pane_using(&mut editor, Some("ctrl+x"), true); } assert_eq!(editor.should_quit(), !another_tab); assert_eq!(editor.tab_count(), 1); if another_tab { assert_ne!(editor.tab_id(), tab); assert!(!editor.has_buffer(source)); assert!(editor.file_picker().is_none()); } else { assert!(editor.file_picker().unwrap().focused); } } } } #[test] fn pane_close_bindings_preserve_picker_filter_and_escape_priority() { let directory = Directory::new(); fs::write(directory.path("xz.txt"), "base").unwrap(); for comparison in [false, true] { for presentation in [FilePresentation::Floating, FilePresentation::Docked] { for view in ["tree", "list"] { let mut editor = project_editor(&directory); editor.apply(vec![Effect::Insert("dirty".into())]).unwrap(); if comparison { historical_diff( &mut editor, &directory.0, vec!["before".into()], vec!["after".into()], ); } editor .bind("normal", "x", Some("core.pane.close".into())) .unwrap(); editor .bind("normal", "z", Some("core.pane.force_close".into())) .unwrap(); editor.set_file_picker_view(view).unwrap(); editor.set_file_explorer_view(view).unwrap(); editor.act(match presentation { FilePresentation::Floating => Action::FilePicker, FilePresentation::Docked => Action::FileExplorer, }); if presentation == FilePresentation::Docked && view == "tree" { editor.handle(Input::Char('i')); } let text = editor.lines().to_vec(); type_text(&mut editor, "xz"); assert_eq!(editor.file_picker().unwrap().query, "xz"); assert_eq!(editor.pane_layout().len(), 1); assert_eq!(editor.tab_count(), 1); assert!(!editor.should_quit()); assert_eq!(editor.lines(), text); editor.handle(Input::Escape); if view == "tree" { assert!(editor.file_picker().unwrap().focused); assert!(!editor.file_picker().unwrap().searching); editor.handle(Input::Escape); } match presentation { FilePresentation::Floating => assert!(editor.file_picker().is_none()), FilePresentation::Docked => { assert!(!editor.file_picker().unwrap().focused); } } assert!(editor.has_buffer(1)); assert!(!editor.should_quit()); } } } } #[test] fn pane_close_bindings_yield_to_ordinary_tree_and_selection_overrides() { let directory = Directory::new(); fs::write(directory.path("a.txt"), "a").unwrap(); fs::write(directory.path("b.txt"), "b").unwrap(); for presentation in [FilePresentation::Floating, FilePresentation::Docked] { for context in ["tree", "selection"] { for target in [None, Some("core.down")] { for force in [false, true] { let mut editor = project_editor(&directory); editor .bind( "normal", "ctrl+x", Some(if force { "core.pane.force_close".into() } else { "core.pane.close".into() }), ) .unwrap(); editor .bind(context, "ctrl+x", target.map(str::to_owned)) .unwrap(); editor.act(match presentation { FilePresentation::Floating => Action::FilePicker, FilePresentation::Docked => Action::FileExplorer, }); editor.handle(Input::Ctrl('x')); let picker = editor.file_picker().unwrap(); assert!(picker.focused); assert_eq!(picker.selected, usize::from(target.is_some())); assert_eq!(editor.pane_layout().len(), 1); assert!(!editor.should_quit()); } } } } } #[test] fn render_swaps_restore_focus_on_panics_and_isolate_caret_reveal_requests() { let mut editor = editor(); editor.apply(vec![Effect::Replace("abc".into())]).unwrap(); editor.handle(Input::Char('|')); let inactive_revision = editor.with_render_pane(1, Editor::caret_reveal_revision); let inactive_cursor = editor.with_render_pane(1, Editor::cursor); editor.handle(Input::Home); type_text(&mut editor, "iX"); editor.handle(Input::Escape); assert_eq!( editor.with_render_pane(1, Editor::caret_reveal_revision), inactive_revision ); assert_eq!( editor.with_render_pane(1, Editor::cursor).column, inactive_cursor.column + 1 ); let cursor = editor.cursor(); let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { editor.with_render_pane(1, |_| panic!("render failed")); })); assert!(result.is_err()); assert_eq!(editor.pane_id(), 2); assert_eq!(editor.cursor(), cursor); assert!(editor.render_pane_focused()); } #[test] fn diff_panes_share_working_edits_and_focus_internal_regions_before_workspace() { let directory = Directory::new(); let path = directory.path("file.txt"); fs::write(&path, "base\n").unwrap(); let mut editor = Editor::open(Some(path), vec![]).unwrap(); working_diff(&mut editor, &directory.0, &["file.txt"]); editor.diff.as_mut().unwrap().focus = Focus::After; editor.bind_diff_buffer(); let working = editor.buffer_id(); editor.handle(Input::Char('|')); assert!(editor.diff().is_none()); type_text(&mut editor, "iX"); editor.handle(Input::Escape); assert_eq!( editor.workspace_diff().unwrap().live_revision, Some(editor.text_revision()) ); editor.set_diff_pane_bounds([ ( Focus::Files, PaneRect { x: 0, y: 0, width: 0, height: 0, }, ), ( Focus::Before, PaneRect { x: 0, y: 0, width: 30, height: 40, }, ), ( Focus::After, PaneRect { x: 30, y: 0, width: 30, height: 40, }, ), ]); editor.handle(Input::Alt('h')); assert_eq!(editor.pane_id(), 1); assert_eq!(editor.diff().unwrap().focus, Focus::After); editor.handle(Input::Alt('h')); assert_eq!(editor.pane_id(), 1); assert_eq!(editor.diff().unwrap().focus, Focus::Before); let snapshot = editor.buffer_id(); editor.execute("pane.split_horizontal"); assert_eq!(editor.buffer_id(), snapshot); assert!(editor.diff().is_none()); assert!( editor .apply(vec![Effect::Insert("rejected".into())]) .is_err() ); editor.execute("diff.close"); assert!(editor.has_buffer(snapshot)); assert!(editor.has_buffer(working)); assert_eq!(editor.pane_id(), 3); assert_eq!(editor.buffer_id(), snapshot); editor.with_render_pane(3, |pane| { assert_eq!(pane.buffer_id(), snapshot); assert!(pane.mutation_blocked()); assert_eq!(pane.lines(), ["base", ""]); }); editor.execute("pane.close"); assert!(!editor.has_buffer(snapshot)); assert!(editor.has_buffer(working)); assert_eq!(editor.pane_layout().len(), 2); } #[test] fn diff_results_bind_the_owning_pane_without_disturbing_focused_document_or_mode() { use crate::diff::{Catalog, Category, RepoKind, Revision}; let directory = Directory::new(); let path = directory.path("other.txt"); fs::write(&path, "other").unwrap(); let mut editor = editor(); editor.execute("diff"); editor.execute("pane.split_vertical"); editor.execute(&format!("edit {}", path.display())); type_text(&mut editor, "iX"); let focused_buffer = editor.buffer_id(); let focused_cursor = editor.cursor(); let (id, _) = editor.take_diff_request().unwrap(); assert!(editor.apply_diff_result( id, Ok(Outcome::Catalog(Catalog { kind: RepoKind::Git, entries: vec![Revision { target: RevisionTarget::Working, label: "Working".into(), description: String::new(), category: Category::Commits, }], notice: None, })) )); assert_eq!(editor.pane_id(), 2); assert_eq!(editor.buffer_id(), focused_buffer); assert_eq!(editor.cursor(), focused_cursor); assert_eq!(editor.mode(), Mode::Insert); assert_eq!(editor.lines(), ["Xother"]); assert!(editor.diff().is_none()); editor.with_render_pane(1, |pane| assert!(pane.diff().is_some())); editor.handle(Input::Escape); editor.handle(Input::Char('u')); assert_eq!(editor.lines(), ["other"]); } #[test] fn destructive_pane_close_from_palette_requires_command_confirmation() { let mut editor = editor(); editor.apply(vec![Effect::Insert("dirty".into())]).unwrap(); editor.handle(Input::Ctrl('p')); type_text(&mut editor, "core.pane.force_close"); editor.handle(Input::Enter); assert_eq!(editor.mode(), Mode::Command); assert!(!editor.should_quit()); assert_eq!(editor.command_line(), "pane.force_close "); editor.handle(Input::Enter); assert!(editor.should_quit()); } #[test] fn palette_pane_close_keeps_focused_dock_target_through_force_confirmation() { let directory = Directory::new(); let path = directory.path("file.txt"); fs::write(&path, "base").unwrap(); for comparison in [false, true] { for force in [false, true] { let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap(); editor.project_root = directory.0.clone(); type_text(&mut editor, "iX"); editor.handle(Input::Escape); let source = editor.buffer_id(); let revision = editor.text_revision(); let history = (editor.active.undo.len(), editor.active.redo.len()); if comparison { working_diff(&mut editor, &directory.0, &["file.txt"]); } let owner = (editor.tab_id(), editor.pane_id()); let comparison_id = editor.diff().and_then(|diff| diff.comparison_id); if !editor.file_picker().is_some_and(|picker| picker.focused) { editor.act(Action::FileExplorer); } editor.handle(Input::Ctrl('p')); assert!(editor.pane_close_picker_origin.is_some()); assert!(editor.palette().is_some()); assert!(!editor.file_picker().unwrap().focused); let buffers = editor.buffer_count(); type_text( &mut editor, if force { "core.pane.force_close" } else { "core.pane.close" }, ); editor.handle(Input::Enter); if force { assert_eq!(editor.mode(), Mode::Command); assert_eq!(editor.command_line(), "pane.force_close "); assert!(editor.file_picker().is_some()); assert!(editor.pane_close_picker_origin.is_some()); assert!(!editor.should_quit()); editor.handle(Input::Char(' ')); editor.handle(Input::Backspace); editor.handle(Input::Backspace); assert!(editor.pane_close_picker_origin.is_some()); editor.handle(Input::Enter); } assert_eq!((editor.tab_id(), editor.pane_id()), owner); assert_eq!(editor.tab_count(), 1); assert_eq!(editor.pane_layout().len(), 1); assert_eq!(editor.buffer_count(), buffers); assert!(!editor.should_quit()); assert!(editor.palette().is_none()); assert!(editor.file_picker().is_none()); assert_eq!(editor.pane_close_picker_origin, None); let source = editor.buffer(source).unwrap(); assert_eq!(source.lines, ["Xbase"]); assert!(source.dirty()); assert_eq!(source.text_revision, revision); assert_eq!((source.undo.len(), source.redo.len()), history); assert_eq!( editor.diff().and_then(|diff| diff.comparison_id), comparison_id ); } } } #[test] fn palette_pane_close_origin_clears_on_cancel_edit_or_unrelated_action() { let directory = Directory::new(); let path = directory.path("file.txt"); fs::write(&path, "base").unwrap(); for comparison in [false, true] { for cancellation in [ "escape", "toggle", "unrelated", "force_escape", "force_other_command", "invalid_args", ] { let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap(); editor.project_root = directory.0.clone(); editor.apply(vec![Effect::Insert("dirty".into())]).unwrap(); if comparison { working_diff(&mut editor, &directory.0, &["file.txt"]); } if !editor.file_picker().is_some_and(|picker| picker.focused) { editor.act(Action::FileExplorer); } editor.handle(Input::Ctrl('p')); assert!(editor.pane_close_picker_origin.is_some()); assert!(editor.palette().is_some()); match cancellation { "escape" => { editor.handle(Input::Escape); } "toggle" => { editor.handle(Input::Ctrl('p')); } "unrelated" => { type_text(&mut editor, "core.word_wrap.toggle"); editor.handle(Input::Enter); } "force_escape" | "force_other_command" => { type_text(&mut editor, "core.pane.force_close"); editor.handle(Input::Enter); assert!(editor.pane_close_picker_origin.is_some()); if cancellation == "force_escape" { editor.handle(Input::Escape); } else { for _ in 0..editor.command_line().chars().count() { editor.handle(Input::Backspace); } type_text(&mut editor, "word_wrap.toggle"); editor.handle(Input::Enter); } } "invalid_args" => { editor.execute("pane.close unexpected"); } _ => unreachable!(), } assert_eq!(editor.pane_close_picker_origin, None, "{cancellation}"); assert!( editor.file_picker().is_some(), "comparison={comparison}, cancellation={cancellation}" ); assert!(!editor.should_quit()); editor.execute("pane.force_close"); assert!(editor.should_quit(), "{cancellation}"); assert!( editor.file_picker().is_some(), "comparison={comparison}, cancellation={cancellation}" ); } } } #[test] fn document_and_floating_palette_origins_never_intercept_pane_close() { let directory = Directory::new(); let path = directory.path("file.txt"); fs::write(&path, "base").unwrap(); for comparison in [false, true] { for presentation in [FilePresentation::Docked, FilePresentation::Floating] { let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap(); editor.project_root = directory.0.clone(); editor.apply(vec![Effect::Insert("dirty".into())]).unwrap(); let source = editor.buffer_id(); if comparison { working_diff(&mut editor, &directory.0, &["file.txt"]); } editor.act(match presentation { FilePresentation::Docked => Action::FileExplorer, FilePresentation::Floating => Action::FilePicker, }); if presentation == FilePresentation::Docked { editor.handle(Input::Escape); } editor.handle(Input::Ctrl('p')); assert_eq!(editor.pane_close_picker_origin, None); assert!(editor.palette().is_some()); type_text(&mut editor, "core.pane.close"); editor.handle(Input::Enter); assert!(!editor.should_quit()); assert!(editor.palette().is_none()); let source = editor.buffer(source).unwrap(); assert_eq!(source.lines, ["dirtybase"]); assert!(source.dirty()); assert_eq!(editor.pane_layout().len(), 1); assert_eq!( editor.file_picker().is_some(), presentation == FilePresentation::Docked ); editor.handle(Input::Ctrl('p')); type_text(&mut editor, "core.pane.force_close"); editor.handle(Input::Enter); assert_eq!(editor.mode(), Mode::Command); assert!(!editor.should_quit()); editor.handle(Input::Enter); assert!(editor.should_quit()); } } } #[test] fn palette_pane_close_origin_is_render_local_and_expires_on_owner_changes() { let directory = Directory::new(); fs::write(directory.path("file.txt"), "base").unwrap(); for owner_change in ["none", "pane", "tab", "hidden", "floating"] { let mut editor = project_editor(&directory); editor.apply(vec![Effect::Insert("dirty".into())]).unwrap(); editor.execute("pane.split_vertical"); editor.act(Action::FileExplorer); editor.handle(Input::Ctrl('p')); let origin = editor.pane_close_picker_origin; assert!(origin.is_some()); editor.with_render_pane(1, |pane| { assert!(pane.palette().is_none()); assert_eq!(pane.pane_close_picker_origin, None); }); assert_eq!(editor.pane_close_picker_origin, origin); match owner_change { "none" => {} "pane" => editor.select_pane(1), "tab" => { editor.execute("tab.new"); } "hidden" => editor.file_picker = None, "floating" => { editor.file_picker.as_mut().unwrap().presentation = FilePresentation::Floating; } _ => unreachable!(), } if owner_change == "none" { type_text(&mut editor, "core.pane.close"); editor.handle(Input::Enter); assert_eq!(editor.pane_layout().len(), 2); assert!(editor.file_picker().is_none()); } else { editor.execute("pane.force_close"); assert_eq!( editor.pane_layout().len(), if owner_change == "tab" { 2 } else { 1 } ); assert_eq!(editor.pane_close_picker_origin, None); assert_eq!(editor.lines(), ["dirty"]); } assert!(!editor.should_quit()); } } #[test] fn palette_pane_close_rejects_a_replaced_comparison_origin() { let directory = Directory::new(); let path = directory.path("file.txt"); fs::write(&path, "base").unwrap(); let mut editor = Editor::open(Some(path), vec![]).unwrap(); editor.apply(vec![Effect::Insert("dirty".into())]).unwrap(); let source = editor.buffer_id(); working_diff(&mut editor, &directory.0, &["file.txt"]); editor.handle(Input::Ctrl('p')); assert!(editor.pane_close_picker_origin.is_some()); assert!(editor.palette().is_some()); let diff = editor.diff.as_mut().unwrap(); diff.comparison_id = diff.comparison_id.map(|id| id + 1); type_text(&mut editor, "core.pane.close"); editor.handle(Input::Enter); assert!(!editor.should_quit()); assert!(editor.palette().is_none()); assert_eq!(editor.pane_layout().len(), 1); assert_eq!(editor.tab_count(), 1); let source = editor.buffer(source).unwrap(); assert_eq!(source.lines, ["dirtybase"]); assert!(source.dirty()); assert!(editor.file_picker().is_some()); assert_eq!(editor.pane_close_picker_origin, None); } #[test] fn closing_comparison_checks_the_dirty_working_buffer_not_its_readonly_snapshot() { let directory = Directory::new(); let path = directory.path("file.txt"); fs::write(&path, "base").unwrap(); let mut editor = Editor::open(Some(path), vec![]).unwrap(); editor.handle(Input::Char('|')); editor.execute(&format!("edit {}", directory.path("other.txt").display())); editor.execute("pane.focus_left"); editor.apply(vec![Effect::Insert("dirty".into())]).unwrap(); working_diff(&mut editor, &directory.0, &["file.txt"]); editor.handle(Input::Char('\t')); assert!(!editor.dirty()); editor.execute("pane.close"); assert_eq!(editor.pane_layout().len(), 2); assert!(editor.message().contains("unsaved")); editor.execute("pane.force_close"); assert_eq!(editor.pane_layout().len(), 1); assert!(!editor.has_buffer(1)); } #[test] fn diff_preserves_parked_buffers_visual_carets_and_insert_history() { let directory = Directory::new(); let a = directory.path("a.txt"); let b = directory.path("b.txt"); fs::write(&a, "alpha").unwrap(); fs::write(&b, "beta").unwrap(); let mut editor = Editor::open(Some(a), vec![]).unwrap(); type_text(&mut editor, "iunsaved"); editor.handle(Input::Escape); editor.execute(&format!("edit {}", b.display())); type_text(&mut editor, "iother"); editor.handle(Input::Escape); type_text(&mut editor, "vl"); let carets = editor.carets(); let selections = editor.selections(); let documents = editor .documents() .map(|document| (document.id, document.revision, document.lines.to_vec())) .collect::>(); editor.execute("diff"); for input in [ Input::Paste("bad".into()), Input::Delete, Input::Char('d'), Input::Enter, ] { editor.handle(input); } type_text(&mut editor, ":unknown\n"); editor.handle(Input::Ctrl('p')); editor.handle(Input::Escape); assert!(editor.diff().is_some()); editor.handle(Input::Escape); assert!(editor.diff().is_none()); assert_eq!(editor.mode(), Mode::Visual); assert_eq!(editor.carets(), carets); assert_eq!(editor.selections(), selections); assert_eq!( editor .documents() .map(|document| (document.id, document.revision, document.lines.to_vec())) .collect::>(), documents ); editor.handle(Input::Escape); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, "beta"); editor.execute("buffer 1"); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, "alpha"); editor.handle(Input::Char('i')); type_text(&mut editor, "start"); let caret = editor.cursor(); editor.execute("diff"); editor.handle(Input::Paste("ignored".into())); type_text(&mut editor, ":unfinished"); editor .apply(vec![Effect::Message("message without edits".into())]) .unwrap(); editor.handle(Input::Escape); assert!(editor.diff().is_none()); assert_eq!(editor.mode(), Mode::Insert); assert_eq!(editor.cursor(), caret); type_text(&mut editor, "finish"); editor.handle(Input::Escape); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, "alpha"); } fn working_diff(editor: &mut Editor, root: &Path, paths: &[&str]) -> Box<[(PathBuf, String)]> { use crate::diff::{ Catalog, Category, ChangedFile, Comparison, FileStatus, RepoKind, Revision, }; editor.execute("diff"); let (id, _) = editor.take_diff_request().unwrap(); let revision = |target, label: &str| Revision { target, label: label.into(), description: String::new(), category: Category::Commits, }; editor.apply_diff_result( id, Ok(Outcome::Catalog(Catalog { kind: RepoKind::Git, entries: vec![ revision(RevisionTarget::Commit("base".into()), "base"), revision(RevisionTarget::Working, "Working"), ], notice: None, })), ); editor.handle(Input::Down); editor.handle(Input::Enter); editor.handle(Input::Enter); let (id, operation) = editor.take_diff_request().unwrap(); let Operation::Compare { buffers, .. } = operation else { panic!("comparison request") }; let files = paths .iter() .map(|path| { let text = fs::read_to_string(root.join(path)).unwrap(); let lines: Vec = text.lines().map(str::to_owned).collect(); ChangedFile { loaded: true, path: PathBuf::from(*path), old_path: None, status: FileStatus::Modified, before: lines.clone(), after: lines, before_eof: text.ends_with('\n'), after_eof: text.ends_with('\n'), hunks: Vec::new(), notice: None, } }) .collect(); assert!(editor.apply_diff_result( id, Ok(Outcome::Comparison(Comparison { root: root.canonicalize().unwrap(), before: "base".into(), after: "Working".into(), editable_paths: paths.iter().map(|path| PathBuf::from(*path)).collect(), files, })) )); buffers } fn historical_diff(editor: &mut Editor, root: &Path, before: Vec, after: Vec) { use crate::diff::{ Catalog, Category, ChangedFile, Comparison, FileStatus, RepoKind, Revision, }; editor.execute("diff"); let (id, _) = editor.take_diff_request().unwrap(); let revision = |id: &str| Revision { target: RevisionTarget::Commit(id.into()), label: id.into(), description: String::new(), category: Category::Commits, }; editor.apply_diff_result( id, Ok(Outcome::Catalog(Catalog { kind: RepoKind::Git, entries: vec![revision("base"), revision("head")], notice: None, })), ); editor.handle(Input::Enter); editor.handle(Input::Down); editor.handle(Input::Enter); let (id, _) = editor.take_diff_request().unwrap(); let hunks = crate::diff::line_hunks(&before, false, &after, false); editor.apply_diff_result( id, Ok(Outcome::Comparison(Comparison { root: root.to_owned(), before: "base".into(), after: "head".into(), editable_paths: Default::default(), files: vec![ChangedFile { loaded: true, path: "file.txt".into(), old_path: None, status: FileStatus::Modified, before, after, before_eof: false, after_eof: false, hunks, notice: None, }], })), ); editor.handle(Input::Char('\t')); assert_eq!(editor.diff().unwrap().focus, Focus::Before); assert!(editor.diff_interacting()); } #[test] fn working_refresh_routes_hidden_owners_and_keeps_obsolete_shared_snapshots_readonly() { let directory = Directory::new(); fs::write(directory.path("file.txt"), "working").unwrap(); let mut editor = editor(); working_diff(&mut editor, &directory.0, &["file.txt"]); editor.diff.as_mut().unwrap().focus = Focus::Before; editor.bind_diff_buffer(); let old_snapshot = editor.buffer_id(); let old_comparison = editor.diff().unwrap().comparison_id.unwrap(); let owner = editor.pane_id(); let hidden_tab = editor.tab_id(); editor.execute("tab.new"); let visible_tab = editor.tab_id(); let visible_pane = editor.pane_id(); let cursor = editor.cursor(); editor.queue_checkout_refreshes(true); let (tab, request, operation) = editor.take_next_diff_request().unwrap(); assert_eq!(tab, hidden_tab); assert!( matches!(operation, Operation::RefreshCheckout { comparison_id, .. } if comparison_id == old_comparison) ); assert!(!editor.apply_diff_result(request, Ok(Outcome::CheckoutRefresh(None)))); assert_eq!( editor.tab_diff(hidden_tab).unwrap().comparison_id, Some(old_comparison) ); assert_eq!(editor.tab_id(), visible_tab); assert_eq!(editor.pane_id(), visible_pane); assert_eq!(editor.buffer_id(), old_snapshot); assert_eq!(editor.cursor(), cursor); editor.queue_checkout_refreshes(true); let (_, request, _) = editor.take_next_diff_request().unwrap(); let replacement = editor.with_pane(owner, |editor| { editor.diff.as_mut().unwrap().comparison.take().unwrap() }); assert!(editor.apply_diff_result(request, Ok(Outcome::CheckoutRefresh(Some(replacement))))); assert_eq!( editor.tab_diff(hidden_tab).unwrap().comparison_id, Some(request) ); assert_eq!(editor.tab_id(), visible_tab); assert_eq!(editor.buffer_id(), old_snapshot); assert_eq!(editor.cursor(), cursor); assert!(editor.active.diff_snapshot.is_some()); assert!( editor .apply(vec![Effect::Insert("rejected".into())]) .is_err() ); assert!(!editor.apply_diff_result(request, Ok(Outcome::CheckoutRefresh(None)))); editor.with_pane(owner, |editor| { assert_ne!(editor.buffer_id(), old_snapshot); editor.switch_buffer(old_snapshot); editor.leave_diff_on_navigation(); assert!(editor.diff().is_none()); assert!(editor.active.diff_snapshot.is_some()); assert!(editor.mutation_blocked()); }); } #[test] fn completed_working_refresh_serializes_lazy_selection_and_invalidates_pending_navigation() { for request_kind in [ "selection", "after_selection", "navigation", "noop_navigation", ] { let directory = Directory::new(); fs::write(directory.path("file.txt"), "working").unwrap(); let mut editor = editor(); working_diff(&mut editor, &directory.0, &["file.txt"]); editor.diff.as_mut().unwrap().focus = Focus::Before; editor.bind_diff_buffer(); let old_snapshot = editor.buffer_id(); let mut target = lazy_lsp_target(&mut editor); let old_comparison = editor.diff().unwrap().comparison_id.unwrap(); let virtual_root = editor.diff().unwrap().snapshot_roots[0].clone().unwrap(); editor.queue_checkout_refreshes(true); let (_, refresh, _) = editor.take_next_diff_request().unwrap(); let loaded_before = target.before.clone(); let loaded_after = target.after.clone(); target.loaded = false; let replacement = crate::diff::Comparison { root: directory.0.clone(), before: "base".into(), after: "ahead".into(), editable_paths: Default::default(), files: vec![target], }; let selection = request_kind.ends_with("selection"); let focus = if request_kind == "after_selection" { Focus::After } else { Focus::Before }; if selection { assert!(editor.diff.as_mut().unwrap().select_file_index(1)); editor.diff.as_mut().unwrap().focus = focus; } else { editor .navigate_lsp_to(virtual_root.join("nested/old.txt"), lsp_point(1, 4), None) .unwrap(); assert!(editor.pending_lsp_navigation.is_some()); assert_eq!(editor.diff().unwrap().file, 0); } assert!(editor.take_next_diff_request().is_none()); let changed = request_kind != "noop_navigation"; let outcome = if changed { Some(replacement) } else { None }; assert_eq!( editor.apply_diff_result(refresh, Ok(Outcome::CheckoutRefresh(outcome))), changed ); if changed { assert!(editor.pending_lsp_navigation.is_none()); assert_eq!(editor.diff().unwrap().comparison_id, Some(refresh)); assert_eq!(editor.diff().unwrap().file, 0); assert_eq!(editor.diff().unwrap().focus, focus); } else { assert!(editor.pending_lsp_navigation.is_some()); assert_eq!(editor.diff().unwrap().comparison_id, Some(old_comparison)); } let (_, load, operation) = editor.take_next_diff_request().unwrap(); assert!( matches!(operation, Operation::LoadFile { comparison_id, file_index } if comparison_id == if changed { refresh } else { old_comparison } && file_index == if changed { 0 } else { 1 }) ); if !changed { assert_eq!( editor.pending_lsp_navigation.as_ref().unwrap().request, Some(load) ); } if changed { let file = crate::diff::ChangedFile { path: "nested/new.txt".into(), old_path: Some("nested/old.txt".into()), status: crate::diff::FileStatus::Renamed, loaded: true, hunks: crate::diff::line_hunks(&loaded_before, false, &loaded_after, false), before: loaded_before, after: loaded_after, before_eof: false, after_eof: false, notice: None, }; assert!(editor.apply_diff_result( load, Ok(Outcome::File { comparison_id: refresh, file_index: 0, file, }) )); assert_eq!(editor.diff().unwrap().focus, focus); assert!(!editor.parked.contains_key(&old_snapshot)); } } } fn lsp_point(row: usize, column: usize) -> Utf16Range { Utf16Range { start: (row, column), end: (row, column), } } fn lazy_lsp_target(editor: &mut Editor) -> crate::diff::ChangedFile { use crate::diff::{ChangedFile, FileStatus}; let before = vec!["context".into(), "😀e\u{301} before".into()]; let after = vec!["context".into(), "😀e\u{301} after".into()]; let hunks = crate::diff::line_hunks(&before, false, &after, false); let diff = editor.diff.as_mut().unwrap(); let comparison = diff.comparison.as_mut().unwrap(); comparison.files.push(ChangedFile { path: "nested/new.txt".into(), old_path: Some("nested/old.txt".into()), status: FileStatus::Renamed, loaded: false, before: Vec::new(), after: Vec::new(), before_eof: false, after_eof: false, hunks: Vec::new(), notice: None, }); diff.files = Some(FilePicker::from_paths( comparison.root.clone(), comparison .files .iter() .enumerate() .map(|(index, file)| (index, file.path.clone())), )); ChangedFile { path: "nested/new.txt".into(), old_path: Some("nested/old.txt".into()), status: FileStatus::Renamed, loaded: true, before, after, before_eof: false, after_eof: false, hunks, notice: None, } } #[test] fn lsp_symbol_extent_reveals_exact_omitted_declaration_without_losing_comparison() { let directory = Directory::new(); let mut editor = editor(); let before: Vec<_> = (0..70).map(|row| format!("line {row}")).collect(); let mut after = before.clone(); after[60] = "changed body".into(); historical_diff(&mut editor, &directory.0, before, after); editor.set_diff_context_options(1, 3); let comparison = editor.diff().unwrap().comparison_id; let snapshot = editor.buffer_id(); let path = editor.active.snapshot_document_path.clone().unwrap(); assert!( editor .diff() .unwrap() .source_display_row(Focus::Before, 4) .is_none() ); editor .navigate_lsp_to( path.clone(), lsp_point(4, 2), Some(Utf16Range { start: (4, 0), end: (61, 0), }), ) .unwrap(); assert_eq!(editor.tab_count(), 1); assert_eq!(editor.buffer_id(), snapshot); assert_eq!(editor.cursor(), Cursor { row: 4, column: 2 }); assert_eq!(editor.diff().unwrap().comparison_id, comparison); assert!( editor .diff() .unwrap() .source_display_row(Focus::Before, 4) .is_some() ); editor.refresh_live_diff(); assert_eq!(editor.cursor().row, 4); editor .navigate_lsp_to(path, lsp_point(20, 1), None) .unwrap(); assert_eq!(editor.tab_count(), 2); assert!(editor.diff().is_none()); assert_eq!(editor.buffer_id(), snapshot); assert!(editor.mutation_blocked()); editor.execute("tab.previous"); assert_eq!(editor.diff().unwrap().comparison_id, comparison); assert_eq!(editor.cursor(), Cursor { row: 4, column: 2 }); } #[test] fn lazy_lsp_navigation_is_precise_and_source_departures_cancel_permanently() { for departure in [ "none", "caret", "side", "tab", "pane", "close", "replace", "supersede", ] { let directory = Directory::new(); let mut editor = editor(); historical_diff( &mut editor, &directory.0, vec!["context".into(), "before".into()], vec!["context".into(), "after".into()], ); let file = lazy_lsp_target(&mut editor); let comparison_id = editor.diff().unwrap().comparison_id.unwrap(); let source = editor.buffer_id(); let virtual_root = editor.diff().unwrap().snapshot_roots[0].clone().unwrap(); editor .navigate_lsp_to(virtual_root.join("nested/old.txt"), lsp_point(1, 4), None) .unwrap(); assert_eq!(editor.buffer_id(), source); assert_eq!(editor.diff().unwrap().file, 0); let (id, operation) = editor.take_diff_request().unwrap(); assert!( matches!(operation, Operation::LoadFile { comparison_id: owner, file_index: 1 } if owner == comparison_id) ); match departure { "none" => {} "caret" => { editor.handle(Input::Right); editor.handle(Input::Left); } "side" => { editor.scroll_diff(Focus::After, true); editor.scroll_diff(Focus::Before, false); } "tab" => { editor.execute("tab.new"); editor.execute("tab.previous"); } "pane" => { editor.execute("pane.split_vertical"); } "close" => { editor.execute("diff.close"); } "replace" => { editor.execute("diff"); } "supersede" => { let path = editor.active.snapshot_document_path.clone().unwrap(); editor.navigate_lsp_to(path, lsp_point(1, 0), None).unwrap(); } _ => unreachable!(), } let location = ( editor.tab_id, editor.pane_id, editor.buffer_id(), editor.cursor(), ); let selected = editor .workspace_diff() .map(|diff| (diff.comparison_id, diff.file, diff.focus)); let tabs = editor.tab_count(); editor.apply_diff_result( id, Ok(Outcome::File { comparison_id, file_index: 1, file, }), ); if departure == "none" { assert_eq!(editor.diff().unwrap().file, 1); assert_eq!(editor.diff().unwrap().focus, Focus::Before); assert_eq!(editor.cursor(), Cursor { row: 1, column: 2 }); assert_eq!( editor.path(), Some(directory.path("nested/old.txt").as_path()) ); assert_eq!( editor.active.snapshot_document_path.as_ref(), Some(&virtual_root.join("nested/old.txt")) ); assert_eq!(editor.tab_count(), 1); } else { assert_eq!( ( editor.tab_id, editor.pane_id, editor.buffer_id(), editor.cursor() ), location, "{departure}" ); assert_eq!( editor .workspace_diff() .map(|diff| (diff.comparison_id, diff.file, diff.focus)), selected, "{departure}" ); assert_eq!(editor.tab_count(), tabs, "{departure}"); } assert!(editor.pending_lsp_navigation.is_none()); } } #[test] fn lazy_lsp_preflight_validates_both_ranges_before_committing_a_target() { for invalid_target_range in [false, true] { let directory = Directory::new(); let mut editor = editor(); historical_diff( &mut editor, &directory.0, vec!["context".into(), "before".into()], vec!["context".into(), "after".into()], ); let file = lazy_lsp_target(&mut editor); let comparison_id = editor.diff().unwrap().comparison_id.unwrap(); let source = editor.jump_location(); let documents = editor.interaction_documents().count(); let invalid = Utf16Range { start: (1, 0), end: (99, 0), }; editor .navigate_lsp_to( directory.path("nested/old.txt"), if invalid_target_range { lsp_point(1, 0) } else { invalid }, invalid_target_range.then_some(invalid), ) .unwrap(); let (id, _) = editor.take_diff_request().unwrap(); editor.apply_diff_result( id, Ok(Outcome::File { comparison_id, file_index: 1, file, }), ); assert_eq!(editor.jump_location(), source); assert_eq!(editor.diff().unwrap().file, 0); assert_eq!(editor.tab_count(), 1); assert_eq!(editor.interaction_documents().count(), documents); assert!(editor.message().contains("definition failed")); } } #[test] fn superseded_lazy_results_cannot_consume_the_new_queued_navigation() { let directory = Directory::new(); let mut editor = editor(); historical_diff( &mut editor, &directory.0, vec!["context".into(), "before".into()], vec!["context".into(), "after".into()], ); let file = lazy_lsp_target(&mut editor); let comparison_id = editor.diff().unwrap().comparison_id.unwrap(); let path = directory.path("nested/old.txt"); let source = editor.jump_location(); editor .navigate_lsp_to(path.clone(), lsp_point(1, 0), None) .unwrap(); let (old, _) = editor.take_diff_request().unwrap(); editor.navigate_lsp_to(path, lsp_point(1, 4), None).unwrap(); assert!(!editor.apply_diff_result(old, Err("old load failed".into()))); assert_eq!(editor.jump_location(), source); assert!(editor.pending_lsp_navigation.is_some()); let (current, _) = editor.take_diff_request().unwrap(); assert_ne!(current, old); assert!(!editor.apply_diff_result(old, Err("late old load failed".into()))); assert!(editor.pending_lsp_navigation.is_some()); editor.apply_diff_result( current, Ok(Outcome::File { comparison_id, file_index: 1, file, }), ); assert_eq!(editor.diff().unwrap().file, 1); assert_eq!(editor.cursor(), Cursor { row: 1, column: 2 }); assert!(editor.pending_lsp_navigation.is_none()); assert!(!editor.message().contains("old load failed")); } #[test] fn lazy_renamed_before_target_uses_shared_dirty_working_after_hunks() { let directory = Directory::new(); fs::create_dir(directory.path("nested")).unwrap(); let path = directory.path("nested/new.txt"); fs::write(&path, "context\nbefore").unwrap(); let mut editor = editor(); historical_diff( &mut editor, &directory.0, vec!["context".into(), "source before".into()], vec!["context".into(), "source after".into()], ); let mut file = lazy_lsp_target(&mut editor); file.before = vec!["context".into(), "before".into()]; file.after = file.before.clone(); file.hunks.clear(); let diff = editor.diff.as_mut().unwrap(); diff.after.as_mut().unwrap().target = RevisionTarget::Working; diff.comparison .as_mut() .unwrap() .editable_paths .insert("nested/new.txt".into()); let comparison_id = diff.comparison_id.unwrap(); let mut live = Buffer::open( editor.next_buffer_id, Some(path.clone()), Some(path.canonicalize().unwrap()), false, ) .unwrap(); live.lines[1] = "dirty after".into(); live.text_changed(); let live_id = live.id; editor.next_buffer_id += 1; editor.parked.insert(live_id, live); editor .navigate_lsp_to(directory.path("nested/old.txt"), lsp_point(1, 0), None) .unwrap(); let (id, _) = editor.take_diff_request().unwrap(); editor.apply_diff_result( id, Ok(Outcome::File { comparison_id, file_index: 1, file, }), ); assert_eq!(editor.tab_count(), 1); assert_eq!(editor.diff().unwrap().file, 1); assert_eq!(editor.diff().unwrap().focus, Focus::Before); assert_eq!(editor.lines(), ["context", "before"]); assert_eq!(editor.diff().unwrap().live_buffer, Some(live_id)); assert!( editor .diff() .unwrap() .source_range_changed(1, Focus::Before, 1, 1) ); assert!(editor.mutation_blocked()); assert_eq!( editor.interaction_document_root(editor.buffer_id()), directory.0.as_path() ); assert_eq!(editor.diff_root(editor.tab_id()), directory.0); editor.handle(Input::Char('\t')); assert_eq!(editor.buffer_id(), live_id); assert!(editor.diff_editing()); editor.navigate_lsp_to(path, lsp_point(1, 0), None).unwrap(); assert_eq!(editor.tab_count(), 1); assert_eq!(editor.diff().unwrap().comparison_id, Some(comparison_id)); assert_eq!(editor.buffer_id(), live_id); assert_eq!(editor.lines(), ["context", "dirty after"]); assert!(editor.dirty()); } #[test] fn diff_documents_dismiss_notifications_but_files_and_selectors_keep_escape() { let directory = Directory::new(); let mut editor = editor(); historical_diff( &mut editor, &directory.0, vec!["before".into(), "second".into()], vec!["after".into(), "second".into()], ); set_carets(&mut editor, &[(0, 1), (1, 1)], 1); editor.set_message("operation"); editor.handle(Input::Escape); assert!(!editor.notification_visible()); assert_eq!(editor.selection_count(), 2); assert!(editor.diff_interacting()); editor.handle(Input::Escape); assert_eq!(editor.selection_count(), 1); editor.set_message("operation"); editor.diff.as_mut().unwrap().focus = Focus::Files; editor.handle(Input::Escape); assert!(editor.notification_visible()); assert_ne!(editor.diff().unwrap().focus, Focus::Files); editor.execute("diff.close"); editor.execute("diff"); editor.handle(Input::Char('q')); assert_eq!(editor.diff().unwrap().query, "q"); editor.handle(Input::Escape); assert!(editor.diff().is_none()); assert!(editor.notification_visible()); } #[test] fn readonly_native_panes_allow_selection_yank_search_and_multicarets() { for focus in [Focus::Before, Focus::After] { let directory = Directory::new(); let mut editor = editor(); let lines = ["alpha beta alpha", "second line", "third line"] .map(str::to_owned) .to_vec(); let mut after = lines.clone(); after[2] = "third changed".into(); historical_diff(&mut editor, &directory.0, lines.clone(), after.clone()); if focus == Focus::After { editor.handle(Input::Char('\t')); } let id = editor.buffer_id(); type_text(&mut editor, "2w"); assert_eq!(editor.cursor().column, 11); let targets = editor.active.view.targets.clone(); let revision = editor.text_revision(); type_text(&mut editor, "yy"); assert_eq!(editor.clipboard, ["alpha beta alpha\n"]); assert!(editor.clipboard_linewise); assert_eq!(editor.active.view.targets, targets); assert_eq!(editor.text_revision(), revision); editor.handle(Input::Char('p')); assert_eq!(editor.active.view.targets, targets); assert_eq!(editor.text_revision(), revision); type_text(&mut editor, "0v4ly"); assert_eq!(editor.clipboard, ["alpha"]); assert_eq!(editor.mode(), Mode::Normal); type_text(&mut editor, "Vjy"); assert!(editor.clipboard_linewise); assert_eq!(editor.clipboard, ["alpha beta alpha\nsecond line\n"]); type_text(&mut editor, "gt/alpha\n"); assert_eq!(editor.cursor(), Cursor { row: 0, column: 11 }); editor.handle(Input::Char('n')); assert_eq!(editor.cursor(), Cursor::default()); editor.handle(Input::Ctrl('e')); assert_eq!(editor.selection_count(), 2); editor.handle(Input::Escape); type_text(&mut editor, "gtv4l"); editor.handle(Input::Ctrl('a')); assert_eq!(editor.selection_count(), 2); assert_eq!( editor.lines(), if focus == Focus::Before { &lines } else { &after } ); assert_eq!(editor.buffer_id(), id); assert_eq!(editor.buffer_count(), 1); assert_eq!(editor.documents().count(), 1); assert!(!editor.dirty()); } } #[test] fn readonly_native_mutation_rejection_is_atomic_and_cannot_write_disk() { for focus in [Focus::Before, Focus::After] { let directory = Directory::new(); let path = directory.path("file.txt"); fs::write(&path, "disk").unwrap(); let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap(); editor.set_clipboard_mode("separate").unwrap(); type_text(&mut editor, "iunsaved"); editor.handle(Input::Escape); let original_id = editor.buffer_id(); historical_diff( &mut editor, &directory.0, vec!["snapshot text".into()], vec!["historic text".into()], ); if focus == Focus::After { editor.handle(Input::Char('\t')); } editor .bind("normal", "z", Some("core.insert".into())) .unwrap(); editor .bind("normal", "leader d", Some("core.delete_line".into())) .unwrap(); editor .bind("normal", "Q", Some("core.save".into())) .unwrap(); let text = editor.lines().to_vec(); let revision = editor.text_revision(); for keys in [ "i", "a", "o", "O", "dd", "cc", "x", "J", "u", ".", "p", "z", " d", "Q", ] { type_text(&mut editor, keys); assert_eq!(editor.mode(), Mode::Normal, "{keys}"); assert_eq!(editor.lines(), text, "{keys}"); } editor.handle(Input::Ctrl('r')); editor.handle(Input::Paste("literal paste".into())); type_text(&mut editor, "vl"); let selections = editor.selections(); for keys in ["d", "c", "p", "."] { type_text(&mut editor, keys); assert_eq!(editor.selections(), selections, "{keys}"); assert_eq!(editor.lines(), text, "{keys}"); } for effect in [ Effect::Insert("plugin".into()), Effect::Replace("plugin".into()), ] { assert!( editor .apply(vec![Effect::Message("must not leak".into()), effect]) .is_err() ); assert_eq!(editor.selections(), selections); assert_eq!(editor.lines(), text); } for command in [ "w".to_owned(), format!("w {}", path.display()), format!("wq {}", path.display()), "save_and_quit".into(), ] { editor.execute(&command); assert_eq!(fs::read_to_string(&path).unwrap(), "disk"); assert!(!editor.should_quit()); } assert_eq!(editor.text_revision(), revision); let snapshot_id = editor.buffer_id(); editor.execute("diff.close"); assert_eq!(editor.buffer_id(), original_id); assert_eq!(editor.lines(), ["unsaveddisk"]); assert!(!editor.has_buffer(snapshot_id)); assert_eq!(editor.parked.len(), 0); editor.handle(Input::Char('u')); assert_eq!(editor.lines(), ["disk"]); } } #[test] fn diff_focus_maps_aligned_rows_and_retains_display_column_through_short_lines() { let directory = Directory::new(); let mut editor = editor(); let before = ["start", "removed", "anchor", "\t界abcdef", "tail"] .map(str::to_owned) .to_vec(); let after = ["start", "added one", "added two", "anchor", "\t界a", "tail"] .map(str::to_owned) .to_vec(); historical_diff(&mut editor, &directory.0, before, after); type_text(&mut editor, "3j6l"); let original = editor.cursor(); assert_eq!(original, Cursor { row: 3, column: 6 }); editor.handle(Input::Char('\t')); assert_eq!(editor.cursor(), Cursor { row: 4, column: 2 }); editor.handle(Input::BackTab); assert_eq!(editor.cursor(), original); editor.scroll_diff(Focus::After, true); assert_eq!(editor.cursor(), Cursor { row: 4, column: 2 }); editor.scroll_diff(Focus::Before, false); assert_eq!(editor.cursor(), original); editor.handle(Input::BackTab); assert_eq!(editor.diff().unwrap().focus, Focus::Files); editor.handle(Input::Escape); assert_eq!(editor.diff().unwrap().focus, Focus::Before); assert_eq!(editor.cursor(), original); type_text(&mut editor, "gtj"); editor.handle(Input::Char('\t')); assert_eq!(editor.cursor().row, 1); type_text(&mut editor, "gt2j"); assert_eq!(editor.cursor().row, 2); assert!( editor .diff() .unwrap() .rows .iter() .any(|row| row.after == Some(2) && row.before.is_none()) ); editor.handle(Input::BackTab); assert_eq!(editor.cursor().row, 1); } #[test] fn readonly_excerpts_skip_hidden_rows_for_motions_search_clones_and_expand_only_normal() { let directory = Directory::new(); let mut editor = editor(); let before: Vec<_> = (0..60).map(|row| format!("line {row}")).collect(); let mut after = before.clone(); after[10] = "changed 10".into(); after[40] = "changed 40".into(); editor.set_diff_context_options(1, 3); historical_diff(&mut editor, &directory.0, before, after); for focus in [Focus::Before, Focus::After] { if editor.diff().unwrap().focus != focus { editor.handle(Input::Char('\t')); } type_text(&mut editor, "gt3j"); assert_eq!(editor.cursor().row, 39); type_text(&mut editor, "gt2j"); assert_eq!(editor.cursor().row, 11); editor.handle(Input::Ctrl('e')); assert_eq!( editor .carets() .iter() .map(|cursor| cursor.row) .collect::>(), [11, 39] ); editor.handle(Input::Escape); type_text(&mut editor, "/line 20\n"); assert!(editor.message().starts_with("Not found:")); type_text(&mut editor, "gtv]"); assert_eq!(editor.mode(), Mode::Visual); assert!( editor .diff() .unwrap() .source_display_row(focus, 12) .is_none() ); editor.handle(Input::Escape); type_text(&mut editor, "gt]"); assert!( editor .diff() .unwrap() .source_display_row(focus, 12) .is_some() ); // Rebuild the excerpt configuration before checking the other pane. editor.set_diff_context_options(2, 3); editor.set_diff_context_options(1, 3); } } #[test] fn readonly_snapshots_keep_pane_selection_and_safe_document_identity_for_navigation() { let directory = Directory::new(); let mut editor = editor(); let before = ["alpha beta", "second line", "before tail"] .map(str::to_owned) .to_vec(); let after = ["alpha beta", "second line", "after tail"] .map(str::to_owned) .to_vec(); historical_diff(&mut editor, &directory.0, before, after); let before_id = editor.buffer_id(); let source_path = editor.path().unwrap().to_owned(); let snapshot_path = editor .interaction_documents() .find(|document| document.id == before_id) .unwrap() .path .unwrap() .to_owned(); assert_ne!(snapshot_path, source_path); assert!(!snapshot_path.exists()); assert!(editor.active.identity.is_none()); type_text(&mut editor, "v3l"); editor.handle(Input::Ctrl('e')); let anchor = editor.active.view.targets[editor.active.view.primary].anchor; let carets = editor.selection_count(); editor.scroll_diff(Focus::After, true); assert_eq!(editor.mode(), Mode::Normal); editor.scroll_diff(Focus::Before, false); assert_eq!(editor.buffer_id(), before_id); assert_eq!(editor.mode(), Mode::Visual); assert_eq!(editor.selection_count(), carets); assert_eq!( editor.active.view.targets[editor.active.view.primary].anchor, anchor ); editor .navigate_to(snapshot_path, TextPosition { row: 1, column: 2 }) .unwrap(); assert!(editor.diff_interacting()); assert_eq!(editor.cursor(), Cursor { row: 1, column: 2 }); editor .register(Feature { name: "readonly_fixture".into(), required_capabilities: vec![], core: false, commands: vec!["readonly_fixture.command".into()], }) .unwrap(); editor .bind("normal", "R", Some("readonly_fixture.command".into())) .unwrap(); let invocation = editor.handle(Input::Char('R')).unwrap(); assert_eq!(invocation.plugin, "readonly_fixture"); assert!( editor .apply(vec![Effect::Insert("blocked".into())]) .is_err() ); editor.handle(Input::Ctrl('p')); type_text(&mut editor, "core.word_end"); editor.handle(Input::Enter); assert_eq!(editor.cursor().column, 5); editor.execute("diff.close"); assert!( editor .interaction_documents() .all(|document| document.id != before_id) ); } #[test] fn no_native_diff_pane_cannot_mutate_hidden_live_buffer() { let directory = Directory::new(); let path = directory.path("file.txt"); fs::write(&path, "original").unwrap(); let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap(); working_diff(&mut editor, &directory.0, &["file.txt"]); let file = &mut editor .diff .as_mut() .unwrap() .comparison .as_mut() .unwrap() .files[0]; file.status = crate::diff::FileStatus::Renamed; file.old_path = Some("old.txt".into()); file.before = file.after.clone(); file.before_eof = file.after_eof; file.hunks.clear(); editor.handle(Input::Char('\t')); assert!(!editor.diff_interacting()); type_text(&mut editor, "iddccx"); editor .apply(vec![Effect::Insert("blocked".into())]) .unwrap_err(); editor.execute(&format!("w {}", path.display())); assert_eq!(fs::read_to_string(path).unwrap(), "original"); editor.execute("diff.close"); assert_eq!(editor.lines(), ["original"]); } #[test] fn readonly_follow_link_and_jump_use_native_navigation_without_snapshot_identity() { let directory = Directory::new(); let mut editor = editor(); let before = ["[go](#section)", "# Section", "before"] .map(str::to_owned) .to_vec(); let after = ["[go](#section)", "# Section", "after"] .map(str::to_owned) .to_vec(); historical_diff(&mut editor, &directory.0, before, after); editor.active.path = Some(directory.path("file.md")); type_text(&mut editor, "gf"); assert_eq!(editor.cursor().row, 1); assert!(editor.diff_interacting()); editor.handle(Input::Ctrl('o')); assert_eq!(editor.cursor().row, 0); assert!(editor.diff_interacting()); let target = directory.path("target.md"); fs::write(&target, "# Target\n").unwrap(); editor.execute("diff.close"); historical_diff( &mut editor, &directory.0, vec!["[go](target.md)".into()], vec!["[after](target.md)".into()], ); editor.active.path = Some(directory.path("file.md")); type_text(&mut editor, "gf"); assert!(editor.diff().is_none()); assert_eq!(editor.path(), Some(target.as_path())); assert_eq!(editor.lines(), ["# Target", ""]); } #[test] fn editable_diff_brackets_expand_caret_excerpt_without_moving_source_anchor() { for selected in [30, 50] { let directory = Directory::new(); let path = directory.path("file.txt"); let before: Vec = (0..80).map(|row| format!("line {row}")).collect(); fs::write(&path, before.join("\n")).unwrap(); let mut editor = Editor::open(Some(path), vec![]).unwrap(); let mut after = before; for row in [10, 30, 50] { after[row] = format!("changed {row}"); } editor .apply(vec![Effect::Replace(after.join("\n"))]) .unwrap(); editor.set_diff_context_options(1, 4); working_diff(&mut editor, &directory.0, &["file.txt"]); editor.handle(Input::BackTab); assert!(editor.diff_editing()); editor.active.view.targets = vec![Target::caret(Cursor { row: selected, column: 2, })]; let top = editor.diff().unwrap().after_display_row(9).unwrap(); editor.diff.as_mut().unwrap().top = top; let cursor = editor.cursor(); let visible = |editor: &Editor| { editor .diff() .unwrap() .source_rows(Focus::After) .collect::>() }; assert_eq!( visible(&editor), (9..12).chain(29..32).chain(49..52).collect::>() ); editor.handle(Input::Char('[')); editor.handle(Input::Char(']')); let expected = if selected == 30 { (9..12).chain(25..36).chain(49..52).collect::>() } else { (9..12).chain(29..32).chain(45..56).collect::>() }; assert_eq!(visible(&editor), expected); assert_eq!(editor.cursor(), cursor); assert_eq!( editor.diff().unwrap().after_display_row(9), Some(editor.diff().unwrap().top) ); if selected == 30 { // The caret keeps targeting the second excerpt after it merges // with the third, rather than expanding the first excerpt. for _ in 0..5 { editor.handle(Input::Char(']')); } assert_eq!(visible(&editor), (9..12).chain(25..56).collect::>()); assert_eq!(editor.cursor(), cursor); assert_eq!( editor.diff().unwrap().after_display_row(9), Some(editor.diff().unwrap().top) ); } } } #[test] fn working_diff_reuses_dirty_buffers_and_normal_edit_undo_save_paths() { let directory = Directory::new(); let a = directory.path("a.txt"); let b = directory.path("b.txt"); fs::write(&a, "alpha\n").unwrap(); fs::write(&b, "beta").unwrap(); let mut editor = Editor::open(Some(a.clone()), vec![]).unwrap(); type_text(&mut editor, "iunsaved"); editor.handle(Input::Escape); editor.execute(&format!("edit {}", b.display())); type_text(&mut editor, "iother"); editor.handle(Input::Escape); let overlays = working_diff(&mut editor, &directory.0, &["a.txt", "b.txt"]); assert_eq!(overlays.len(), 2); assert!(overlays.contains(&(a.canonicalize().unwrap(), "unsavedalpha\n".into()))); assert!(overlays.contains(&(b.canonicalize().unwrap(), "otherbeta".into()))); assert_eq!(editor.buffer_count(), 2); assert_eq!(editor.buffer_id(), 1); assert!(editor.diff_editable()); assert!(!editor.diff_editing()); editor.handle(Input::BackTab); assert!(editor.diff_editing()); type_text(&mut editor, "0i[]"); editor.handle(Input::Char('\t')); editor.handle(Input::Escape); assert_eq!(editor.lines()[0], "[]\tunsavedalpha"); assert!(editor.diff().is_some()); editor.handle(Input::Char('u')); assert_eq!(editor.lines()[0], "unsavedalpha"); editor.handle(Input::Char('u')); assert_eq!(editor.lines(), ["alpha", ""]); let row = editor.cursor().row; assert!(editor.diff().unwrap().after_display_row(row).is_some()); type_text(&mut editor, "ccreplacement"); editor.handle(Input::Escape); editor.execute("w"); assert_eq!(fs::read_to_string(&a).unwrap(), "replacement\n"); assert!(!editor.dirty()); assert_eq!(editor.buffer(2).unwrap().lines, ["otherbeta"]); editor.execute("diff.close"); assert!(editor.diff().is_none()); editor.execute("buffer 2"); assert!(editor.dirty()); editor.handle(Input::Char('u')); assert_eq!(editor.lines(), ["beta"]); for refresh_error in [false, true] { let path = directory.path("file.txt"); fs::write(&path, "original").unwrap(); let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap(); historical_diff( &mut editor, &directory.0, vec!["before".into()], vec!["original".into()], ); let endpoints = ( editor.diff().unwrap().before.clone(), editor.diff().unwrap().after.clone(), ); editor.queue_checkout_refreshes(false); let (_, request, _) = editor.take_next_diff_request().unwrap(); let mut promoted = editor.diff.as_mut().unwrap().comparison.take().unwrap(); promoted.editable_paths.insert("file.txt".into()); editor.apply_diff_result(request, Ok(Outcome::CheckoutRefresh(Some(promoted)))); editor.diff.as_mut().unwrap().focus = Focus::After; editor.bind_diff_buffer(); assert!(editor.diff_editing()); let live = editor.buffer_id(); type_text(&mut editor, "ix"); assert_eq!(editor.mode(), Mode::Insert); let comparison_id = editor.diff().unwrap().comparison_id; editor.queue_checkout_refreshes(false); let (_, request, _) = editor.take_next_diff_request().unwrap(); if refresh_error { assert!(editor.apply_diff_result(request, Err("checkout unavailable".into()))); assert_eq!(editor.diff().unwrap().comparison_id, comparison_id); } else { let mut demoted = editor.diff.as_mut().unwrap().comparison.take().unwrap(); demoted.editable_paths.clear(); demoted.files[0].after = vec!["original".into()]; demoted.files[0].after_eof = false; demoted.files[0].hunks = crate::diff::line_hunks( &demoted.files[0].before, false, &demoted.files[0].after, false, ); assert!( editor.apply_diff_result(request, Ok(Outcome::CheckoutRefresh(Some(demoted))),) ); } assert!(!editor.diff_editable()); assert_eq!(editor.lines(), ["original"]); assert_eq!(editor.buffer(live).unwrap().lines, ["xoriginal"]); assert!(editor.buffer(live).unwrap().dirty()); assert_eq!(editor.buffer(live).unwrap().view.mode, Mode::Normal); assert!( editor .apply(vec![Effect::Insert("blocked".into())]) .is_err() ); editor.execute("w"); assert_eq!(fs::read_to_string(&path).unwrap(), "original"); editor.queue_checkout_refreshes(false); let (_, request, _) = editor.take_next_diff_request().unwrap(); let mut promoted = editor.diff.as_mut().unwrap().comparison.take().unwrap(); promoted.editable_paths.insert("file.txt".into()); editor.apply_diff_result(request, Ok(Outcome::CheckoutRefresh(Some(promoted)))); assert!(editor.diff_editing()); assert!(editor.diff().unwrap().error.is_none()); assert_eq!(editor.buffer_id(), live); assert_eq!(editor.lines(), ["xoriginal"]); assert_eq!( ( editor.diff().unwrap().before.clone(), editor.diff().unwrap().after.clone(), ), endpoints, ); editor.handle(Input::Char('u')); assert_eq!(editor.lines(), ["original"]); } } #[test] fn diff_review_actions_preserve_readonly_text_history_and_parked_tabs() { let directory = Directory::new(); let mut editor = editor(); editor.execute("diff.review.toggle"); assert_eq!(editor.message(), "No comparison is open"); historical_diff( &mut editor, &directory.0, vec!["before".into(), "second".into()], vec!["after".into(), "second".into()], ); editor .bind("normal", "ctrl+y", Some("core.diff.review.toggle".into())) .unwrap(); let text = editor.lines().to_vec(); let carets = editor.carets(); let revision = editor.text_revision(); let undo = editor.active.undo.len(); let redo = editor.active.redo.len(); let dirty = editor.dirty(); assert!(editor.mutation_blocked()); editor.handle(Input::Ctrl('y')); assert_eq!(editor.diff().unwrap().reviewed_files, BTreeSet::from([0])); editor.execute("diff.review.toggle unexpected"); assert!(editor.message().contains("takes no arguments")); assert_eq!(editor.diff().unwrap().reviewed_files, BTreeSet::from([0])); editor.handle(Input::Ctrl('p')); type_text(&mut editor, "diff.review.toggle"); editor.handle(Input::Enter); assert!(editor.diff().unwrap().reviewed_files.is_empty()); assert_eq!(editor.lines(), text); assert_eq!(editor.carets(), carets); assert_eq!(editor.text_revision(), revision); assert_eq!(editor.active.undo.len(), undo); assert_eq!(editor.active.redo.len(), redo); assert_eq!(editor.dirty(), dirty); assert!(!directory.path("file.txt").exists()); editor.handle(Input::Char('\t')); assert_eq!(editor.diff().unwrap().focus, Focus::After); assert!(editor.mutation_blocked()); editor.execute(":diff.review.toggle"); editor.execute("diff.view.toggle"); editor.execute("diff.view.toggle"); editor.execute("diff.orientation.toggle"); let first_tab = editor.tab_id(); editor.execute("tab.new"); historical_diff( &mut editor, &directory.0, vec!["base".into()], vec!["different".into()], ); assert!(editor.diff().unwrap().reviewed_files.is_empty()); editor.execute("tab.previous"); assert_eq!(editor.tab_id(), first_tab); assert_eq!(editor.diff().unwrap().reviewed_files, BTreeSet::from([0])); editor.execute("diff.close"); historical_diff( &mut editor, &directory.0, vec!["before".into()], vec!["after".into()], ); assert!(editor.diff().unwrap().reviewed_files.is_empty()); } #[test] fn diff_review_c_toggles_only_while_browsing_and_respects_overrides() { let directory = Directory::new(); let mut editor = editor(); historical_diff( &mut editor, &directory.0, vec!["before".into()], vec!["after".into()], ); editor.handle(Input::BackTab); assert_eq!(editor.diff().unwrap().focus, Focus::Files); editor.handle(Input::Char('c')); assert_eq!(editor.diff().unwrap().reviewed_files, BTreeSet::from([0])); editor.handle(Input::Char('c')); assert!(editor.diff().unwrap().reviewed_files.is_empty()); editor.handle(Input::Char('i')); editor.handle(Input::Char('c')); assert_eq!(editor.diff().unwrap().files.as_ref().unwrap().query, "c"); assert!(editor.diff().unwrap().reviewed_files.is_empty()); editor.handle(Input::Escape); editor.handle(Input::Ctrl('u')); editor.bind("normal", "c", None).unwrap(); editor.handle(Input::Char('c')); assert!(editor.diff().unwrap().reviewed_files.is_empty()); } #[test] fn diff_review_files_action_targets_highlight_without_stealing_filter_text() { let directory = Directory::new(); fs::write(directory.path("z.txt"), "z").unwrap(); fs::write(directory.path("a.txt"), "a").unwrap(); let mut editor = editor(); working_diff(&mut editor, &directory.0, &["z.txt", "a.txt"]); editor .bind("normal", "ctrl+y", Some("core.diff.review.toggle".into())) .unwrap(); editor .bind("normal", "Q", Some("core.diff.review.toggle".into())) .unwrap(); let files = editor.diff.as_mut().unwrap().files.as_mut().unwrap(); files.set_view(FileView::List); files.reveal_path(Path::new("a.txt")); editor.handle(Input::Ctrl('y')); assert_eq!(editor.diff().unwrap().file, 0); assert_eq!(editor.diff().unwrap().reviewed_files, BTreeSet::from([1])); editor.handle(Input::Char('Q')); assert_eq!(editor.file_picker().unwrap().query, "Q"); assert_eq!(editor.diff().unwrap().reviewed_files, BTreeSet::from([1])); editor.handle(Input::Ctrl('u')); let files = editor.diff.as_mut().unwrap().files.as_mut().unwrap(); files.set_view(FileView::Tree); files.start_search(); editor.handle(Input::Char('Q')); assert_eq!(editor.file_picker().unwrap().query, "Q"); assert_eq!(editor.diff().unwrap().reviewed_files, BTreeSet::from([1])); editor.handle(Input::Ctrl('y')); assert_eq!(editor.message(), "No changed file is selected"); assert_eq!(editor.diff().unwrap().reviewed_files, BTreeSet::from([1])); editor.file_picker = Some( FilePicker::scan( directory.0.clone(), FilePresentation::Docked, FileView::List, ) .unwrap(), ); editor.execute("diff.review.toggle"); assert_eq!(editor.message(), "Select a file in the comparison"); assert_eq!(editor.diff().unwrap().reviewed_files, BTreeSet::from([1])); } #[test] fn working_review_marks_survive_save_and_clear_only_the_edited_file() { let directory = Directory::new(); let first = directory.path("first.txt"); fs::write(&first, "base\n").unwrap(); fs::write(directory.path("second.txt"), "second").unwrap(); let mut editor = Editor::open(Some(first.clone()), vec![]).unwrap(); working_diff(&mut editor, &directory.0, &["first.txt", "second.txt"]); editor.execute("diff.review.toggle"); editor .diff .as_mut() .unwrap() .files .as_mut() .unwrap() .reveal_path(Path::new("second.txt")); editor.execute("diff.review.toggle"); assert_eq!( editor.diff().unwrap().reviewed_files, BTreeSet::from([0, 1]) ); editor.handle(Input::BackTab); assert!(editor.diff_editing()); editor.execute("w"); assert_eq!( editor.diff().unwrap().reviewed_files, BTreeSet::from([0, 1]) ); type_text(&mut editor, "iX"); editor.handle(Input::Escape); assert_eq!(editor.diff().unwrap().reviewed_files, BTreeSet::from([1])); editor.execute("diff.review.toggle"); editor.execute("w"); assert_eq!( editor.diff().unwrap().reviewed_files, BTreeSet::from([0, 1]) ); assert_eq!(fs::read_to_string(&first).unwrap(), "Xbase\n"); editor.handle(Input::Char('u')); assert_eq!(editor.diff().unwrap().reviewed_files, BTreeSet::from([1])); } #[test] fn diff_display_defaults_tabs_and_remaps_preserve_native_snapshot_carets() { let directory = Directory::new(); let mut editor = editor(); editor.execute("diff.view.toggle"); assert_eq!(editor.message(), "No comparison is open"); editor.set_diff_display_options(ViewMode::Unified, SplitAxis::Horizontal); editor.execute("diff"); type_text(&mut editor, " v"); assert_eq!(editor.diff().unwrap().query, " v"); assert_eq!(editor.diff().unwrap().view_mode, ViewMode::Unified); historical_diff( &mut editor, &directory.0, vec!["old one".into(), "old two".into()], vec!["new one".into(), "new two".into()], ); let first_tab = editor.tab_id(); let buffer = editor.buffer_id(); editor.handle(Input::Char('j')); let caret = editor.cursor(); let text = editor.lines().to_vec(); assert!(!editor.diff_editing()); assert_eq!(editor.diff().unwrap().view_mode, ViewMode::Unified); editor.execute("diff.orientation.toggle"); assert_eq!(editor.diff().unwrap().split_axis, SplitAxis::Horizontal); editor .bind("normal", "Q", Some("core.diff.view.toggle".into())) .unwrap(); assert!( editor .bind("insert", "Q", Some("core.diff.view.toggle".into())) .is_err() ); editor.handle(Input::Char('Q')); assert_eq!(editor.diff().unwrap().view_mode, ViewMode::Split); editor .bind("normal", "W", Some("core.diff.orientation.toggle".into())) .unwrap(); editor.handle(Input::Char('W')); assert_eq!(editor.diff().unwrap().split_axis, SplitAxis::Vertical); type_text(&mut editor, " v"); assert_eq!(editor.diff().unwrap().view_mode, ViewMode::Unified); assert_eq!(editor.buffer_id(), buffer); assert_eq!(editor.cursor(), caret); assert_eq!(editor.lines(), text); editor.set_diff_display_options(ViewMode::Split, SplitAxis::Horizontal); assert_eq!(editor.diff().unwrap().view_mode, ViewMode::Unified); editor.handle(Input::Ctrl('p')); type_text(&mut editor, "diff.view.toggle"); assert!( editor .palette() .unwrap() .entries .iter() .any(|entry| entry.name == "core.diff.view.toggle" && entry.enabled) ); editor.handle(Input::Enter); assert_eq!(editor.diff().unwrap().view_mode, ViewMode::Split); editor.execute("tab.new"); historical_diff( &mut editor, &directory.0, vec!["old".into()], vec!["new".into()], ); assert_eq!(editor.diff().unwrap().view_mode, ViewMode::Split); assert_eq!(editor.diff().unwrap().split_axis, SplitAxis::Horizontal); editor.execute("tab.previous"); assert_eq!(editor.tab_id(), first_tab); assert_eq!(editor.buffer_id(), buffer); assert_eq!(editor.cursor(), caret); assert_eq!(editor.diff().unwrap().split_axis, SplitAxis::Vertical); } #[test] fn diff_display_actions_keep_working_edits_files_and_filter_input_native() { let directory = Directory::new(); let first = directory.path("first.txt"); fs::write(&first, "base").unwrap(); fs::write(directory.path("second.txt"), "second").unwrap(); let mut editor = Editor::open(Some(first), vec![]).unwrap(); let working = editor.buffer_id(); working_diff(&mut editor, &directory.0, &["first.txt", "second.txt"]); assert_eq!(editor.diff().unwrap().focus, Focus::Files); type_text(&mut editor, " s"); assert_eq!(editor.diff().unwrap().split_axis, SplitAxis::Horizontal); type_text(&mut editor, " v"); assert_eq!(editor.diff().unwrap().view_mode, ViewMode::Unified); editor.handle(Input::Char('i')); type_text(&mut editor, " v"); assert_eq!(editor.diff().unwrap().view_mode, ViewMode::Unified); assert_eq!(editor.file_picker().unwrap().query, " v"); editor.handle(Input::Escape); editor.handle(Input::BackTab); assert!(editor.diff_editing()); assert_eq!(editor.buffer_id(), working); type_text(&mut editor, "i dv"); assert_eq!(editor.lines(), [" dvbase"]); editor.handle(Input::Escape); assert_eq!(editor.diff().unwrap().view_mode, ViewMode::Unified); assert!(editor.diff().unwrap().after_display_row(0).is_some()); let caret = editor.cursor(); type_text(&mut editor, " v"); assert_eq!(editor.diff().unwrap().view_mode, ViewMode::Split); assert_eq!(editor.cursor(), caret); assert!(editor.diff_editing()); assert!(editor.diff.as_mut().unwrap().select_file_index(1)); editor.bind_diff_buffer(); assert_eq!(editor.diff().unwrap().file, 1); assert_eq!(editor.diff().unwrap().split_axis, SplitAxis::Horizontal); assert_eq!(editor.diff().unwrap().view_mode, ViewMode::Split); assert_eq!(editor.diff().unwrap().focus, Focus::After); type_text(&mut editor, " v"); assert_eq!(editor.diff().unwrap().view_mode, ViewMode::Unified); assert!(editor.diff_editing()); assert_eq!(editor.lines(), ["second"]); } #[test] fn word_wrap_toggle_retains_buffers_and_readonly_diff_state() { let directory = Directory::new(); let mut editor = editor(); assert!(!editor.word_wrap()); editor .bind("normal", "W", Some("core.word_wrap.toggle".into())) .unwrap(); editor.handle(Input::Char('W')); assert!(editor.word_wrap()); let original = editor.buffer_id(); editor.execute(&format!("edit {}", directory.path("other").display())); assert!(!editor.word_wrap()); editor.execute(&format!("buffer {original}")); assert!(editor.word_wrap()); editor.execute("word_wrap.toggle unexpected"); assert!(editor.word_wrap()); editor.execute("word_wrap.toggle"); assert!(!editor.word_wrap()); editor.execute("word_wrap.toggle"); historical_diff( &mut editor, &directory.0, vec!["alpha beta".into()], vec!["alpha changed".into()], ); assert!(editor.word_wrap()); editor .bind("visual", "W", Some("core.word_wrap.toggle".into())) .unwrap(); type_text(&mut editor, "v2l"); let text = editor.lines().to_vec(); let selections = editor.selections(); let cursor = editor.cursor(); let revision = editor.active.text_revision; let history = (editor.active.undo.len(), editor.active.redo.len()); let comparison = editor.diff().unwrap().comparison_id; editor.execute("word_wrap.toggle unexpected"); assert!(editor.word_wrap()); editor.handle(Input::Char('W')); assert!(!editor.word_wrap()); assert_eq!(editor.lines(), text); assert_eq!(editor.selections(), selections); assert_eq!(editor.cursor(), cursor); assert_eq!(editor.active.text_revision, revision); assert_eq!( (editor.active.undo.len(), editor.active.redo.len()), history ); assert_eq!(editor.diff().unwrap().comparison_id, comparison); assert_eq!(editor.diff().unwrap().phase, Phase::View); editor.handle(Input::Ctrl('p')); type_text(&mut editor, "word_wrap.toggle"); editor.handle(Input::Enter); assert!(editor.word_wrap()); editor.execute("diff.close"); assert!(editor.word_wrap()); } #[test] fn diff_readonly_panes_dispatch_prompts_palette_and_picker_remaps_without_edits() { let directory = Directory::new(); let path = directory.path("file.txt"); fs::write(&path, "base").unwrap(); let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap(); working_diff(&mut editor, &directory.0, &["file.txt"]); editor.handle(Input::Char('\t')); assert_eq!(editor.diff().unwrap().focus, Focus::Before); type_text(&mut editor, "idd"); assert_eq!(editor.lines(), ["base"]); editor.execute("w"); assert_eq!(fs::read_to_string(&path).unwrap(), "base"); assert!( editor .apply(vec![Effect::Insert("blocked".into())]) .is_err() ); editor .bind("normal", "ctrl+e", Some("core.right".into())) .unwrap(); editor.handle(Input::Ctrl('e')); assert_eq!(editor.diff().unwrap().focus, Focus::Before); editor .bind("normal", "leader f", Some("core.file_picker".into())) .unwrap(); type_text(&mut editor, " f"); assert_eq!( editor.file_picker().unwrap().presentation, FilePresentation::Floating ); editor.handle(Input::Escape); editor.handle(Input::Escape); assert!(editor.file_picker().is_none()); assert!(editor.diff().is_some()); editor.handle(Input::Ctrl('p')); type_text(&mut editor, "diff.close"); editor.handle(Input::Enter); assert!(editor.diff().is_none()); editor.execute("diff"); type_text(&mut editor, ":diffclose\n"); assert!(editor.diff().is_some()); editor.execute("diff.close"); assert!(editor.diff().is_none()); } #[test] fn diff_wheel_scroll_does_not_move_or_edit_insert_caret_and_plugin_edits_refresh() { let directory = Directory::new(); let path = directory.path("file.txt"); fs::write(&path, "first\nsecond\n").unwrap(); let mut editor = Editor::open(Some(path), vec![]).unwrap(); working_diff(&mut editor, &directory.0, &["file.txt"]); editor.handle(Input::BackTab); editor.handle(Input::Char('i')); let caret = editor.cursor(); editor.scroll_diff(Focus::After, true); assert_eq!(editor.cursor(), caret); assert!(!editor.diff().unwrap().follow_caret); assert_eq!(editor.lines(), ["first", "second", ""]); editor.apply(vec![Effect::Insert("plugin".into())]).unwrap(); assert_eq!( editor.diff().unwrap().comparison.as_ref().unwrap().files[0].after, editor.lines() ); assert_eq!( editor.diff().unwrap().live_revision, Some(editor.text_revision()) ); editor.handle(Input::Escape); editor.handle(Input::Char('u')); assert_eq!(editor.lines(), ["first", "second", ""]); editor.execute("diff.close"); } #[cfg(unix)] #[test] fn working_diff_rejects_symlinks_and_paths_escaping_repository_root() { use std::os::unix::fs::symlink; let directory = Directory::new(); let outside = Directory::new(); let main = directory.path("main.txt"); let target = outside.path("target.txt"); fs::write(&main, "main").unwrap(); fs::write(&target, "outside").unwrap(); symlink(&target, directory.path("link.txt")).unwrap(); symlink(&outside.0, directory.path("escape")).unwrap(); let mut editor = Editor::open(Some(main), vec![]).unwrap(); for path in ["link.txt", "escape/target.txt"] { working_diff(&mut editor, &directory.0, &[path]); assert!(!editor.diff_editable()); assert_eq!(editor.buffer_count(), 1); editor.handle(Input::BackTab); type_text(&mut editor, "iX"); editor.execute("w"); assert_eq!(fs::read_to_string(&target).unwrap(), "outside"); editor.execute("diff.close"); assert_eq!(editor.lines(), ["main"]); } } #[test] fn selecting_removed_or_historical_diff_file_never_opens_an_unrelated_buffer() { let directory = Directory::new(); let main = directory.path("main.txt"); let other = directory.path("other.txt"); fs::write(&main, "main").unwrap(); fs::write(&other, "other").unwrap(); let mut editor = Editor::open(Some(main), vec![]).unwrap(); working_diff(&mut editor, &directory.0, &["main.txt", "other.txt"]); let diff = editor.diff.as_mut().unwrap(); let comparison = diff.comparison.as_mut().unwrap(); comparison.editable_paths.remove(Path::new("other.txt")); comparison.files[1].status = crate::diff::FileStatus::Removed; comparison.files[1].after.clear(); assert!(diff.select_file(Path::new("other.txt"))); editor.handle(Input::BackTab); assert!(!editor.diff_editable()); assert_eq!(editor.buffer_count(), 1); type_text(&mut editor, "iX"); editor.execute("w"); assert_eq!(fs::read_to_string(&other).unwrap(), "other"); let diff = editor.diff.as_mut().unwrap(); diff.after.as_mut().unwrap().target = RevisionTarget::Commit("old".into()); assert!(diff.select_file(Path::new("main.txt"))); editor.handle(Input::Char('i')); assert!(!editor.diff_editable()); assert_eq!(editor.buffer_count(), 1); editor.execute("diff.close"); assert_eq!(editor.lines(), ["main"]); } #[test] fn line_actions_counted_delete_restores_caret_and_pastes_whole_rows() { let mut editor = editor(); let source = "zero\n αone\n\tlast\nend"; editor.apply(vec![Effect::Replace(source.into())]).unwrap(); type_text(&mut editor, "jll2dd"); assert_eq!(editor.context().text, "zero\nend"); assert_eq!(editor.mode(), Mode::Normal); assert_eq!(editor.cursor(), Cursor { row: 1, column: 0 }); type_text(&mut editor, "u"); assert_eq!(editor.context().text, source); assert_eq!(editor.cursor(), Cursor { row: 1, column: 2 }); type_text(&mut editor, "UP"); assert_eq!(editor.context().text, source); } #[test] fn line_actions_counted_change_groups_typing_and_repeats_resolved_rows() { let mut editor = editor(); let source = "keep\n one\ntwo\nend"; editor.apply(vec![Effect::Replace(source.into())]).unwrap(); type_text(&mut editor, "j2cc"); assert_eq!(editor.context().text, "keep\n\nend"); assert_eq!(editor.mode(), Mode::Insert); assert_eq!(editor.cursor(), Cursor { row: 1, column: 0 }); type_text(&mut editor, "new"); editor.handle(Input::Escape); type_text(&mut editor, "u"); assert_eq!(editor.context().text, source); type_text(&mut editor, "Uj."); assert_eq!(editor.context().text, "keep\nnew\nnew"); assert_eq!(editor.mode(), Mode::Normal); type_text(&mut editor, "u"); assert_eq!(editor.context().text, "keep\nnew\nend"); } #[test] fn line_actions_eof_empty_rows_and_repeat_keep_the_requested_count() { for (source, keys, expected) in [ ("a\nb", "jdd", "a"), ("a\nb\n", "jdd", "a\n"), ("a\nb\n", "gbdd", "a\nb"), ("a", "dd", ""), ("", "dd", ""), ("a\nb\nc", "2dd", "c"), ("a\nb", "jcc", "a\n"), ] { let mut editor = editor(); editor.apply(vec![Effect::Replace(source.into())]).unwrap(); type_text(&mut editor, keys); assert_eq!(editor.context().text, expected, "{keys} on {source:?}"); if editor.mode() == Mode::Insert { editor.handle(Input::Escape); } if source != expected { type_text(&mut editor, "u"); assert_eq!(editor.context().text, source); } } let directory = Directory::new(); let target = directory.path("repeat"); fs::write(&target, "1\n2\n3\n4\n5").unwrap(); let mut editor = editor(); editor .apply(vec![Effect::Replace("a\nb\nc".into())]) .unwrap(); type_text(&mut editor, "gb3dd"); assert_eq!(editor.context().text, "a\nb"); editor.execute(&format!("edit {}", target.display())); type_text(&mut editor, "."); assert_eq!(editor.context().text, "4\n5"); type_text(&mut editor, "u"); assert_eq!(editor.context().text, "1\n2\n3\n4\n5"); type_text(&mut editor, &format!("{}dd", usize::MAX)); assert_eq!(editor.context().text, ""); } #[test] fn line_actions_merge_overlaps_but_keep_adjacent_change_carets() { let mut editor = editor(); let source = "aa\nbb\ncc\ndd\nee"; editor.apply(vec![Effect::Replace(source.into())]).unwrap(); editor.handle(Input::Ctrl('e')); let carets = editor.carets(); type_text(&mut editor, "2dd"); assert_eq!(editor.context().text, "dd\nee"); type_text(&mut editor, "u"); assert_eq!(editor.context().text, source); assert_eq!(editor.carets(), carets); type_text(&mut editor, "UP"); assert_eq!(editor.context().text, source); editor.handle(Input::Escape); editor.handle(Input::Ctrl('e')); type_text(&mut editor, "ccX"); editor.handle(Input::Escape); assert_eq!(editor.context().text, "X\nX\ncc\ndd\nee"); type_text(&mut editor, "u"); assert_eq!(editor.context().text, source); } #[test] fn line_actions_prefix_cancellation_bindings_and_insert_literals_are_independent() { let mut editor = editor(); editor .apply(vec![Effect::Replace("a\nb\nc\nd".into())]) .unwrap(); editor.handle(Input::Ctrl('e')); let carets = editor.carets(); type_text(&mut editor, "2d"); editor.handle(Input::Escape); assert_eq!(editor.carets(), carets); assert_eq!(editor.context().text, "a\nb\nc\nd"); editor.handle(Input::Escape); type_text(&mut editor, "gtdj"); assert_eq!(editor.cursor().row, 1); editor.bind("normal", "d", None).unwrap(); type_text(&mut editor, "dd"); assert_eq!(editor.context().text, "a\nb\nc\nd"); editor .bind("normal", "z", Some("core.delete_line".into())) .unwrap(); type_text(&mut editor, "2z"); assert_eq!(editor.context().text, "a\nd"); editor .bind("normal", "q", Some("core.change_line".into())) .unwrap(); type_text(&mut editor, "qccdd"); editor.handle(Input::Escape); assert_eq!(editor.context().text, "a\nccdd"); } fn project_editor(directory: &Directory) -> Editor { let mut editor = editor(); editor.project_root = directory.0.clone(); editor } #[test] fn jump_history_follows_links_and_restores_primary_grapheme_locations() { let directory = Directory::new(); let source = directory.path("source.md"); let target = directory.path("target.md"); fs::write(&source, "intro\n[go](target.md#destination)").unwrap(); fs::write(&target, "intro\n\n# Destination").unwrap(); let mut editor = Editor::open(Some(source), vec![]).unwrap(); type_text(&mut editor, "jl"); let departure = editor.cursor(); let source_id = editor.buffer_id(); type_text(&mut editor, "gf"); let target_id = editor.buffer_id(); assert_ne!(source_id, target_id); assert_eq!(editor.cursor(), Cursor { row: 2, column: 0 }); assert_eq!(editor.jumps.len(), 2); fs::remove_file(target).unwrap(); editor.handle(Input::Ctrl('O')); assert_eq!(editor.buffer_id(), source_id); assert_eq!(editor.cursor(), departure); editor.handle(Input::Ctrl('I')); assert_eq!(editor.buffer_id(), target_id); assert_eq!(editor.cursor(), Cursor { row: 2, column: 0 }); assert_eq!(editor.lines(), ["intro", "", "# Destination"]); assert_eq!(editor.buffer_count(), 2); } #[test] fn jump_history_records_same_file_fragments_once() { for link in ["#destination", "source.md#destination"] { let directory = Directory::new(); let source = directory.path("source.md"); fs::write(&source, format!("[go]({link})\n\n# Destination")).unwrap(); let mut editor = Editor::open(Some(source), vec![]).unwrap(); type_text(&mut editor, "gf"); assert_eq!(editor.buffer_count(), 1); assert_eq!(editor.cursor().row, 2); editor.handle(Input::Ctrl('o')); assert_eq!(editor.cursor(), Cursor::default()); let targets = editor.active.view.targets.clone(); editor.handle(Input::Ctrl('o')); assert_eq!(editor.active.view.targets, targets); editor.handle(Input::Char('\t')); assert_eq!(editor.cursor(), Cursor { row: 2, column: 0 }); } } #[test] fn jump_history_records_edit_buffer_neighbors_and_picker_transactions() { let directory = Directory::new(); let a = directory.path("a.txt"); let b = directory.path("b.txt"); fs::write(&a, "alpha").unwrap(); fs::write(&b, "bravo").unwrap(); fs::write(directory.path("c.txt"), "charlie").unwrap(); let mut editor = Editor::open(Some(a), vec![]).unwrap(); editor.project_root = directory.0.clone(); type_text(&mut editor, "ll"); editor.execute(&format!("edit {}", b.display())); assert_eq!(editor.jumps.len(), 2); editor.execute("bnext"); assert_eq!(editor.buffer_id(), 1); assert_eq!(editor.cursor().column, 2); editor.execute("bprev"); assert_eq!(editor.buffer_id(), 2); editor.execute("buffer 1"); assert_eq!(editor.jumps.len(), 5); editor.execute("buffer 1"); assert_eq!(editor.jumps.len(), 5); editor.set_file_picker_view("list").unwrap(); type_text(&mut editor, " oc.txt\n"); assert_eq!(editor.buffer_id(), 3); assert_eq!(editor.jumps.len(), 6); editor.handle(Input::Ctrl('o')); assert_eq!(editor.buffer_id(), 1); assert_eq!(editor.cursor().column, 2); type_text(&mut editor, "3"); editor.handle(Input::Ctrl('o')); assert_eq!(editor.buffer_id(), 2); type_text(&mut editor, "4"); editor.handle(Input::Ctrl('i')); assert_eq!(editor.buffer_id(), 3); } #[test] fn jump_history_records_search_without_losing_query_or_highlight_invariants() { let mut editor = editor(); editor .apply(vec![Effect::Replace("hit\n中👩‍💻 hit\nhit".into())]) .unwrap(); type_text(&mut editor, "/hit\n"); assert_eq!(editor.cursor(), Cursor { row: 1, column: 3 }); type_text(&mut editor, "nN"); assert_eq!(editor.jumps.len(), 4); editor.handle(Input::Right); let departure = editor.cursor(); editor.handle(Input::Ctrl('o')); assert_eq!(editor.cursor(), Cursor { row: 2, column: 0 }); assert!(editor.search_match().is_none()); assert_eq!(editor.active.view.search_query, "hit"); editor.handle(Input::Ctrl('i')); assert_eq!(editor.cursor(), departure); type_text(&mut editor, "2N"); assert_eq!(editor.cursor(), Cursor::default()); } #[test] fn jump_history_saves_roaming_departures_in_both_directions() { let directory = Directory::new(); let a = directory.path("a.txt"); let b = directory.path("b.txt"); fs::write(&a, "abcdef").unwrap(); fs::write(&b, "first\nabcdef").unwrap(); let mut editor = Editor::open(Some(a), vec![]).unwrap(); editor .navigate_to(b, TextPosition { row: 1, column: 0 }) .unwrap(); type_text(&mut editor, "ll"); editor.handle(Input::Ctrl('o')); assert_eq!(editor.buffer_id(), 1); type_text(&mut editor, "lll"); editor.handle(Input::Ctrl('i')); assert_eq!(editor.buffer_id(), 2); assert_eq!(editor.cursor(), Cursor { row: 1, column: 2 }); editor.handle(Input::Ctrl('o')); assert_eq!(editor.cursor(), Cursor { row: 0, column: 3 }); } #[test] fn jump_history_branches_only_after_successful_distinct_jumps() { let directory = Directory::new(); let path = directory.path("text.txt"); fs::write(&path, "zero\none\ntwo\nthree").unwrap(); let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap(); for row in [1, 2] { editor .navigate_to(path.clone(), TextPosition { row, column: 0 }) .unwrap(); } editor.handle(Input::Ctrl('o')); editor .navigate_to(path.clone(), TextPosition { row: 1, column: 0 }) .unwrap(); editor .navigate_to( directory.path("missing"), TextPosition { row: 0, column: 0 }, ) .unwrap_err(); editor.execute("buffer 999"); editor.execute("edit"); editor.execute("follow_link"); type_text(&mut editor, "/missing\n"); editor.handle(Input::Ctrl('i')); assert_eq!(editor.cursor().row, 2); editor.handle(Input::Ctrl('o')); editor.handle(Input::Right); editor .navigate_to(path, TextPosition { row: 3, column: 0 }) .unwrap(); editor.handle(Input::Ctrl('i')); assert_eq!(editor.cursor().row, 3); editor.handle(Input::Ctrl('o')); assert_eq!(editor.cursor(), Cursor { row: 1, column: 1 }); } #[test] fn jump_history_is_bounded_and_counts_stop_at_available_locations() { let directory = Directory::new(); let path = directory.path("text.txt"); fs::write( &path, (0..=120) .map(|n| n.to_string()) .collect::>() .join("\n"), ) .unwrap(); let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap(); for row in 1..=120 { editor .navigate_to(path.clone(), TextPosition { row, column: 0 }) .unwrap(); } type_text(&mut editor, "2"); editor.handle(Input::Ctrl('o')); assert_eq!(editor.cursor().row, 118); type_text(&mut editor, &usize::MAX.to_string()); editor.handle(Input::Ctrl('o')); assert_eq!(editor.cursor().row, 21); let targets = editor.active.view.targets.clone(); let revision = editor.text_revision(); editor.handle(Input::Ctrl('o')); assert_eq!(editor.active.view.targets, targets); assert_eq!(editor.text_revision(), revision); type_text(&mut editor, &usize::MAX.to_string()); editor.handle(Input::Ctrl('i')); assert_eq!(editor.cursor().row, 120); } #[test] fn jump_history_skips_closed_buffers_without_reopening_paths() { let directory = Directory::new(); let mut editor = editor(); for name in ["b", "c"] { let path = directory.path(name); fs::write(&path, name).unwrap(); editor.execute(&format!("edit {}", path.display())); } editor.execute("bdelete 2"); editor.handle(Input::Ctrl('o')); assert_eq!(editor.buffer_id(), 1); editor.handle(Input::Ctrl('i')); assert_eq!(editor.buffer_id(), 3); assert_eq!(editor.buffer_count(), 2); editor.execute("bdelete"); let targets = editor.active.view.targets.clone(); editor.handle(Input::Ctrl('o')); assert_eq!(editor.buffer_id(), 1); assert_eq!(editor.buffer_count(), 1); assert_eq!(editor.active.view.targets, targets); } #[test] fn deleting_older_jump_buffers_preserves_the_current_position_and_forward_branch() { let directory = Directory::new(); let mut editor = editor(); for name in ["b", "c"] { let path = directory.path(name); fs::write(&path, name).unwrap(); editor .navigate_to(path, TextPosition { row: 0, column: 0 }) .unwrap(); } editor.handle(Input::Ctrl('o')); assert_eq!(editor.context().text, "b"); editor.execute("bdelete 1"); editor.handle(Input::Ctrl('i')); assert_eq!(editor.context().text, "c"); editor.handle(Input::Ctrl('o')); let path = directory.path("d"); fs::write(&path, "d").unwrap(); editor .navigate_to(path, TextPosition { row: 0, column: 0 }) .unwrap(); editor.handle(Input::Ctrl('i')); assert_eq!(editor.context().text, "d"); type_text(&mut editor, &usize::MAX.to_string()); editor.handle(Input::Ctrl('o')); assert_eq!(editor.context().text, "b"); editor.handle(Input::Ctrl('i')); assert_eq!(editor.context().text, "d"); } #[test] fn jump_history_clamps_edited_locations_and_preserves_dirty_text_and_undo() { let directory = Directory::new(); let a = directory.path("a.txt"); let b = directory.path("b.txt"); fs::write(&a, "original\nabcdef").unwrap(); fs::write(&b, "other").unwrap(); let mut editor = Editor::open(Some(a), vec![]).unwrap(); type_text(&mut editor, "jlllll"); editor .navigate_to(b, TextPosition { row: 0, column: 0 }) .unwrap(); editor.parked.get_mut(&1).unwrap().lines = vec!["中👩‍💻".into()]; editor.parked.get_mut(&1).unwrap().text_changed(); editor.handle(Input::Ctrl('o')); assert_eq!(editor.cursor(), Cursor { row: 0, column: 1 }); assert!(editor.dirty()); type_text(&mut editor, "iX"); editor.handle(Input::Escape); let text = editor.context().text; let undo = editor.active.undo.len(); let revision = editor.text_revision(); editor.handle(Input::Ctrl('i')); editor.handle(Input::Ctrl('o')); assert_eq!(editor.context().text, text); assert_eq!(editor.active.undo.len(), undo); assert_eq!(editor.text_revision(), revision); assert!(editor.dirty()); type_text(&mut editor, "u"); assert_eq!(editor.context().text, "中👩‍💻"); let redo = editor.active.redo.len(); editor.handle(Input::Ctrl('i')); editor.handle(Input::Ctrl('o')); assert_eq!(editor.active.redo.len(), redo); type_text(&mut editor, "U"); assert_eq!(editor.context().text, text); } #[test] fn jump_history_distinguishes_tabs_and_panes_at_identical_buffer_coordinates() { let mut editor = editor(); editor .apply(vec![Effect::Replace("shared".into())]) .unwrap(); let source = editor.jump_location(); editor.execute("tab.new"); let tab = editor.tab_id(); let tab_pane = editor.pane_id(); editor.record_jump(source); editor.execute("pane.split_vertical"); let pane = editor.pane_id(); editor.record_jump(JumpLocation { tab_id: tab, pane_id: tab_pane, ..editor.jump_location() }); editor.handle(Input::Ctrl('o')); assert_eq!((editor.tab_id(), editor.pane_id()), (tab, tab_pane)); editor.handle(Input::Ctrl('o')); assert_eq!( (editor.tab_id(), editor.pane_id()), (source.tab_id, source.pane_id) ); type_text(&mut editor, "2"); editor.handle(Input::Ctrl('i')); assert_eq!((editor.tab_id(), editor.pane_id()), (tab, pane)); assert_eq!(editor.context().text, "shared"); assert!(editor.dirty()); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, ""); } #[test] fn jump_history_closed_tabs_and_panes_use_surviving_ordinary_buffer_views() { for close in ["tab.close", "pane.close"] { let directory = Directory::new(); let path = directory.path("target.txt"); fs::write(&path, "target").unwrap(); let mut editor = editor(); editor.execute(if close == "tab.close" { "tab.new" } else { "pane.split_vertical" }); let closed = (editor.tab_id(), editor.pane_id()); editor .navigate_to(path.clone(), TextPosition { row: 0, column: 2 }) .unwrap(); editor.handle(Input::Ctrl('o')); editor.execute(close); fs::remove_file(&path).unwrap(); editor.handle(Input::Ctrl('i')); assert_ne!((editor.tab_id(), editor.pane_id()), closed); assert_eq!(editor.cursor(), Cursor { row: 0, column: 2 }); assert_eq!(editor.context().text, "target"); assert_eq!(editor.tab_count(), 1); assert_eq!(editor.pane_layout().len(), 1); assert_eq!(editor.buffer_count(), 2); } } #[test] fn jump_history_closed_comparison_falls_back_without_reopening_or_losing_readonly_identity() { let directory = Directory::new(); let mut editor = editor(); historical_diff( &mut editor, &directory.0, vec!["before".into()], vec!["after".into()], ); let snapshot = editor.buffer_id(); let document = editor.active.snapshot_document_path.clone(); let source = editor.jump_location(); editor.execute("tab.new"); editor.record_jump(source); editor.execute("tab.previous"); editor.execute("diff.close"); editor.execute("tab.next"); editor.handle(Input::Ctrl('o')); assert_eq!(editor.tab_id(), source.tab_id); assert_eq!(editor.buffer_id(), snapshot); assert_eq!(editor.active.snapshot_document_path, document); assert!(editor.diff().is_none()); type_text(&mut editor, "ix"); assert_eq!(editor.context().text, "before"); assert!(!editor.dirty()); editor.handle(Input::Ctrl('i')); assert_eq!(editor.tab_count(), 2); assert_ne!(editor.tab_id(), source.tab_id); } #[test] fn jump_history_never_replaces_a_new_comparison_in_an_old_destination_view() { let directory = Directory::new(); let target = directory.path("external.txt"); fs::write(&target, "external").unwrap(); let mut editor = editor(); fs::write(directory.path("file.txt"), "working source").unwrap(); working_diff(&mut editor, &directory.0, &["file.txt"]); let source_buffer = editor.buffer_id(); editor .navigate_lsp_to(target, lsp_point(0, 2), None) .unwrap(); let external_tab = editor.tab_id(); let external_buffer = editor.buffer_id(); editor.execute("tab.previous"); editor.execute("diff.close"); historical_diff( &mut editor, &directory.0, vec!["new before".into()], vec!["new after".into()], ); let comparison = editor.diff().unwrap().comparison_id; let snapshot = editor.buffer_id(); let cursor = editor.cursor(); editor.execute("tab.next"); editor.handle(Input::Ctrl('o')); assert_eq!(editor.tab_id(), external_tab); assert_eq!(editor.buffer_id(), source_buffer); assert_eq!(editor.context().text, "working source"); assert!(editor.diff().is_none()); editor.handle(Input::Ctrl('i')); assert_eq!(editor.buffer_id(), external_buffer); assert_eq!(editor.cursor(), Cursor { row: 0, column: 2 }); editor.execute("tab.previous"); assert_eq!(editor.diff().unwrap().comparison_id, comparison); assert_eq!(editor.buffer_id(), snapshot); assert_eq!(editor.cursor(), cursor); assert_eq!(editor.context().text, "new before"); } #[test] fn jump_history_skips_missing_contexts_without_mutating_the_only_comparison() { let directory = Directory::new(); let path = directory.path("old.txt"); fs::write(&path, "old").unwrap(); let mut editor = editor(); editor .navigate_to(path, TextPosition { row: 0, column: 1 }) .unwrap(); let source_buffer = editor.buffer_id(); historical_diff( &mut editor, &directory.0, vec!["before".into()], vec!["after".into()], ); let comparison = editor.diff().unwrap().comparison_id; let snapshot = editor.buffer_id(); let cursor = editor.cursor(); type_text(&mut editor, &usize::MAX.to_string()); editor.handle(Input::Ctrl('o')); assert_eq!(editor.buffer_id(), snapshot); assert_eq!(editor.cursor(), cursor); assert_eq!(editor.diff().unwrap().comparison_id, comparison); assert!(editor.has_buffer(source_buffer)); assert_eq!(editor.tab_count(), 1); } #[test] fn comparison_tab_identity_includes_selector_and_parked_pane_states() { let mut editor = editor(); assert!(!editor.tab_is_diff(0)); assert!(!editor.tab_is_diff(1)); editor.execute("diff"); assert!(editor.tab_is_diff(0)); editor.execute("pane.split_vertical"); assert!(editor.diff().is_none()); assert!(editor.tab_is_diff(0)); editor.execute("tab.new"); assert!(editor.tab_is_diff(0)); assert!(!editor.tab_is_diff(1)); editor.execute("tab.previous"); editor.execute("diff.close"); assert!(!editor.tab_is_diff(0)); } #[test] fn jump_history_skips_entries_clamped_to_the_current_location() { let directory = Directory::new(); let path = directory.path("text.txt"); fs::write(&path, "zero\none\ntwo").unwrap(); let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap(); for row in [1, 2] { editor .navigate_to(path.clone(), TextPosition { row, column: 0 }) .unwrap(); } editor.apply(vec![Effect::Replace("x".into())]).unwrap(); let targets = editor.active.view.targets.clone(); editor.handle(Input::Ctrl('o')); assert_eq!(editor.active.view.targets, targets); assert_eq!(editor.cursor(), Cursor::default()); } #[test] fn jump_history_counts_revisit_older_entries_at_the_same_coordinates() { let directory = Directory::new(); let path = directory.path("text.txt"); fs::write(&path, "zero\none").unwrap(); let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap(); for row in [1, 0] { editor .navigate_to(path.clone(), TextPosition { row, column: 0 }) .unwrap(); } type_text(&mut editor, "2"); editor.handle(Input::Ctrl('o')); assert_eq!(editor.cursor().row, 0); editor.handle(Input::Ctrl('i')); assert_eq!(editor.cursor().row, 1); } #[test] fn failed_jump_traversal_preserves_multiple_carets_search_and_edit_state() { let mut editor = editor(); editor .apply(vec![Effect::Replace("hit\nhit".into())]) .unwrap(); type_text(&mut editor, "/hit\n"); editor.handle(Input::Ctrl('k')); assert_eq!(editor.carets().len(), 2); let targets = editor.active.view.targets.clone(); let primary = editor.active.view.primary; let search = editor.search_match(); let undo = editor.active.undo.len(); let revision = editor.text_revision(); let timeline = editor.jumps.clone(); for input in [Input::Ctrl('i'), Input::Ctrl('o')] { editor.handle(input); assert_eq!(editor.active.view.targets, targets); assert_eq!(editor.active.view.primary, primary); assert_eq!(editor.search_match(), search); assert_eq!(editor.active.view.search_query, "hit"); assert_eq!(editor.active.undo.len(), undo); assert_eq!(editor.text_revision(), revision); assert_eq!(editor.jumps, timeline); assert_eq!(editor.mode(), Mode::Normal); assert!(editor.dirty()); } } #[test] fn ordinary_motions_edits_and_failed_jumps_do_not_seed_history() { let mut editor = editor(); editor .apply(vec![Effect::Replace("alpha beta".into())]) .unwrap(); type_text(&mut editor, "wlhiX"); editor.handle(Input::Escape); type_text(&mut editor, "uU"); editor.execute("follow_link"); editor.execute("bnext"); editor.execute("buffer 999"); let targets = editor.active.view.targets.clone(); let revision = editor.text_revision(); editor.handle(Input::Ctrl('o')); editor.handle(Input::Ctrl('i')); assert_eq!(editor.active.view.targets, targets); assert_eq!(editor.text_revision(), revision); } #[test] fn jump_keys_honor_control_aliases_custom_bindings_and_normal_mode_eligibility() { let directory = Directory::new(); let path = directory.path("text.txt"); fs::write(&path, "zero\none\ntwo").unwrap(); let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap(); editor .navigate_to(path, TextPosition { row: 1, column: 0 }) .unwrap(); editor .bind("normal", "ctrl+o", Some("core.right".into())) .unwrap(); editor.handle(Input::Ctrl('o')); assert_eq!(editor.cursor(), Cursor { row: 1, column: 1 }); editor .bind("normal", "leader [", Some("core.jump_back".into())) .unwrap(); type_text(&mut editor, " ["); assert_eq!(editor.cursor(), Cursor::default()); editor .bind("normal", "ctrl+i", Some("core.right".into())) .unwrap(); editor.handle(Input::Char('\t')); assert_eq!(editor.cursor().column, 1); editor .bind("normal", "tab", Some("core.jump_forward".into())) .unwrap(); editor.handle(Input::Ctrl('I')); assert_eq!(editor.cursor(), Cursor { row: 1, column: 1 }); for mode in ["insert", "visual", "command", "search"] { for action in ["core.jump_back", "core.jump_forward"] { assert!(editor.bind(mode, "z", Some(action.into())).is_err()); } } editor.handle(Input::Char('i')); editor.handle(Input::Ctrl('i')); editor.handle(Input::Char('\t')); assert_eq!(editor.lines()[1], "o\t\tne"); assert_eq!(editor.mode(), Mode::Insert); editor.handle(Input::Ctrl('p')); for action in ["core.jump_back", "core.jump_forward"] { assert!(!palette_entry(&editor, action).enabled); } editor.handle(Input::Escape); editor.handle(Input::Escape); editor.handle(Input::Ctrl('p')); assert!(palette_entry(&editor, "core.jump_back").enabled); assert!(palette_entry(&editor, "core.jump_forward").enabled); assert!(!palette_entry(&editor, "core.jump_back").bindings.is_empty()); } #[test] fn follow_link_resolves_relative_paths_from_source_not_cwd_or_project_root() { let directory = Directory::new(); let nested = directory.path("notes"); fs::create_dir(&nested).unwrap(); let source = nested.join("source.md"); let target = directory.path("Target Name.MD"); fs::write(&source, "[go](../Target%20Name.MD#target-heading)").unwrap(); fs::write(&target, "intro\n\n# Target heading\nbody").unwrap(); let mut editor = Editor::open(Some(source), vec![]).unwrap(); assert_ne!(std::env::current_dir().unwrap(), nested); editor.handle(Input::Char('g')); assert!(editor.g_pending()); editor.handle(Input::Char('f')); assert!(!editor.g_pending()); assert_eq!(editor.buffer_id(), 2); assert_eq!(editor.cursor(), Cursor { row: 2, column: 0 }); assert_eq!(editor.context().text, "intro\n\n# Target heading\nbody"); assert_eq!(editor.buffer_count(), 2); } #[test] fn follow_link_reference_and_duplicate_heading_use_native_ex_command() { let directory = Directory::new(); let source = directory.path("source.md"); let target = directory.path("target.md"); fs::write( &source, "[go][section]\n\n[section]: target.md#hello-world-1", ) .unwrap(); fs::write( &target, "# Hello [world](ignored.md)\n\nHello *world*\n=============\n", ) .unwrap(); let mut editor = Editor::open(Some(source), vec![]).unwrap(); type_text(&mut editor, ":follow_link\n"); assert_eq!(editor.cursor(), Cursor { row: 2, column: 0 }); assert_eq!(editor.lines()[0], "# Hello [world](ignored.md)"); } #[test] fn follow_link_reuses_dirty_target_even_after_disk_file_disappears() { let directory = Directory::new(); let source = directory.path("source.md"); let target = directory.path("target.md"); fs::write(&source, "[go](target.md#dirty-heading)").unwrap(); fs::write(&target, "# Disk heading").unwrap(); let mut editor = Editor::open(Some(source), vec![]).unwrap(); editor.execute(&format!("edit {}", target.display())); let target_id = editor.buffer_id(); editor .apply(vec![Effect::Replace("unsaved\n\n# Dirty heading".into())]) .unwrap(); let revision = editor.text_revision(); editor.execute("buffer 1"); fs::remove_file(&target).unwrap(); editor .bind("normal", "z", Some("core.follow_link".into())) .unwrap(); assert_eq!(editor.handle(Input::Char('z')), None); assert_eq!(editor.buffer_id(), target_id); assert_eq!(editor.buffer_count(), 2); assert_eq!(editor.context().text, "unsaved\n\n# Dirty heading"); assert_eq!(editor.text_revision(), revision); assert!(editor.dirty()); assert_eq!(editor.cursor(), Cursor { row: 2, column: 0 }); } #[test] fn follow_link_failures_leave_source_carets_selections_text_and_buffer_ids_untouched() { let directory = Directory::new(); fs::write(directory.path("target.md"), "# Existing").unwrap(); for (filename, text, command) in [ ("source.md", "[x](missing.md)", "follow_link"), ("source.md", "[x](target.md#missing)", "follow_link"), ("source.md", "[x](#missing)", "follow_link"), ("source.md", "[missing][undefined]", "follow_link"), ("source.md", "[x](https://example.com)", "follow_link"), ("source.md", "not a link", "follow_link"), ("source.txt", "[x](target.md)", "follow_link"), ("source.md", "[x](target.md)", "follow_link extra"), ] { let source = directory.path(filename); fs::write(&source, text).unwrap(); let mut editor = Editor::open(Some(source), vec![]).unwrap(); type_text(&mut editor, "vl"); let carets = editor.carets(); let selections = editor.selections(); let revision = editor.text_revision(); assert_eq!(editor.execute(command), None); assert_eq!(editor.mode(), Mode::Visual); assert_eq!(editor.buffer_id(), 1); assert_eq!(editor.buffer_count(), 1); assert_eq!(editor.carets(), carets); assert_eq!(editor.selections(), selections); assert_eq!(editor.context().text, text); assert_eq!(editor.text_revision(), revision); assert!(!editor.message().is_empty()); if command == "follow_link" { editor.handle(Input::Escape); let carets = editor.carets(); type_text(&mut editor, "gf"); assert_eq!(editor.buffer_id(), 1); assert_eq!(editor.buffer_count(), 1); assert_eq!(editor.carets(), carets); assert_eq!(editor.context().text, text); } editor.execute(&format!("edit {}", directory.path("target.md").display())); assert_eq!(editor.buffer_id(), 2); } } #[test] fn follow_link_same_document_fragments_work_without_named_buffer() { let mut editor = editor(); editor .apply(vec![Effect::Replace( "[go](#hello-world)\n\n# Hello **world**\n".into(), )]) .unwrap(); let revision = editor.text_revision(); editor .bind("normal", "leader f", Some("core.follow_link".into())) .unwrap(); type_text(&mut editor, " f"); assert_eq!(editor.cursor(), Cursor { row: 2, column: 0 }); assert_eq!(editor.buffer_count(), 1); assert_eq!(editor.path(), None); assert_eq!(editor.text_revision(), revision); assert_eq!( editor.context().text, "[go](#hello-world)\n\n# Hello **world**\n" ); editor.handle(Input::Home); editor.handle(Input::Up); editor.handle(Input::Up); editor .apply(vec![Effect::Replace("[go](target.md)".into())]) .unwrap(); editor.execute("follow_link"); assert_eq!(editor.buffer_count(), 1); assert_eq!(editor.cursor(), Cursor::default()); assert!(editor.message().contains("named")); } #[test] fn follow_link_explicit_same_file_path_reuses_current_dirty_buffer() { let directory = Directory::new(); let source = directory.path("source.md"); fs::write(&source, "# Old disk heading").unwrap(); let mut editor = Editor::open(Some(source), vec![]).unwrap(); let text = "[go](source.md#dirty-heading)\n\n# Dirty heading"; editor.apply(vec![Effect::Replace(text.into())]).unwrap(); let revision = editor.text_revision(); type_text(&mut editor, "gf"); assert_eq!(editor.buffer_id(), 1); assert_eq!(editor.buffer_count(), 1); assert_eq!(editor.cursor(), Cursor { row: 2, column: 0 }); assert_eq!(editor.context().text, text); assert_eq!(editor.text_revision(), revision); assert!(editor.dirty()); } #[test] fn follow_link_uses_only_primary_caret_once() { let directory = Directory::new(); let source = directory.path("source.md"); let target = directory.path("target.md"); fs::write(&source, "[bad](missing.md)\n[go](target.md)").unwrap(); fs::write(&target, "# Target").unwrap(); let mut editor = Editor::open(Some(source), vec![]).unwrap(); editor.handle(Input::Ctrl('e')); assert_eq!(editor.carets().len(), 2); assert_eq!(editor.cursor(), Cursor { row: 1, column: 0 }); type_text(&mut editor, "gf"); assert_eq!(editor.buffer_id(), 2); assert_eq!(editor.buffer_count(), 2); assert_eq!(editor.carets(), vec![Cursor::default()]); assert_eq!(editor.lines(), ["# Target"]); } #[test] fn follow_link_accepts_nonmarkdown_targets_without_fragments() { let directory = Directory::new(); let source = directory.path("source.md"); let target = directory.path("main.rs"); fs::write(&source, "[code](main.rs)").unwrap(); fs::write(&target, "fn main() {}").unwrap(); let mut editor = Editor::open(Some(source), vec![]).unwrap(); type_text(&mut editor, "gf"); assert_eq!(editor.cursor(), Cursor::default()); assert_eq!(editor.count(), None); assert_eq!(editor.buffer_count(), 2); assert_eq!(editor.lines(), ["fn main() {}"]); } #[test] fn counted_g_reveals_exact_hidden_source_rows_in_readonly_diffs() { let directory = Directory::new(); let mut editor = editor(); let before: Vec<_> = (0..80).map(|row| format!("row {row}")).collect(); let mut after = before.clone(); after[40] = "changed".into(); historical_diff(&mut editor, &directory.0, before, after); editor.diff.as_mut().unwrap().set_context_options(0, 1); let text = editor.lines().to_vec(); type_text(&mut editor, "10g"); assert_eq!(editor.cursor().row, 9); let focus = editor.diff_buffer_focus().unwrap(); assert!( editor .diff() .unwrap() .source_display_row(focus, 9) .is_some() ); assert!(editor.mutation_blocked()); assert_eq!(editor.lines(), text); type_text(&mut editor, "79g"); assert_eq!(editor.cursor().row, 78); assert!( editor .diff() .unwrap() .source_display_row(focus, 78) .is_some() ); } #[test] fn document_navigation_moves_all_carets_to_first_nonblank_without_edit_history() { let mut editor = editor(); let text = " e\u{301}top\nmiddle\n\t 界bottom"; editor.apply(vec![Effect::Replace(text.into())]).unwrap(); type_text(&mut editor, "j"); editor.handle(Input::Ctrl('e')); assert_eq!(editor.selection_count(), 2); type_text(&mut editor, "gt"); assert_eq!(editor.carets(), vec![Cursor { row: 0, column: 2 }]); type_text(&mut editor, "gb"); assert_eq!(editor.cursor(), Cursor { row: 2, column: 2 }); assert_eq!(editor.context().text, text); type_text(&mut editor, "u"); assert_eq!(editor.context().text, ""); for (text, last_row) in [("", 0), ("\n", 1), ("top\n", 1)] { editor.apply(vec![Effect::Replace(text.into())]).unwrap(); type_text(&mut editor, "gb"); assert_eq!( editor.cursor(), Cursor { row: last_row, column: 0 } ); type_text(&mut editor, "gt"); assert_eq!(editor.cursor(), Cursor { row: 0, column: 0 }); assert_eq!(editor.context().text, text); } } #[test] fn document_navigation_extends_both_visual_shapes_and_escape_cancels_prefix_only() { for selector in ['v', 'V'] { let mut editor = editor(); editor .apply(vec![Effect::Replace(" top\nmiddle\n\t end".into())]) .unwrap(); type_text(&mut editor, "jl"); editor.handle(Input::Char(selector)); type_text(&mut editor, "gb"); assert_eq!(editor.cursor(), Cursor { row: 2, column: 2 }); assert_eq!(editor.visual_linewise(), selector == 'V'); assert_eq!( editor.selections(), vec![if selector == 'V' { Selection { start: Cursor { row: 1, column: 0 }, end: Cursor { row: 2, column: 5 }, } } else { Selection { start: Cursor { row: 1, column: 1 }, end: Cursor { row: 2, column: 3 }, } }] ); let selected = editor.selections(); type_text(&mut editor, "g"); editor.handle(Input::Escape); assert_eq!(editor.mode(), Mode::Visual); assert_eq!(editor.selections(), selected); type_text(&mut editor, "gt"); assert_eq!(editor.cursor(), Cursor { row: 0, column: 2 }); assert_eq!( editor.selections(), vec![if selector == 'V' { Selection { start: Cursor { row: 0, column: 0 }, end: Cursor { row: 2, column: 0 }, } } else { Selection { start: Cursor { row: 0, column: 2 }, end: Cursor { row: 1, column: 2 }, } }] ); assert_eq!(editor.context().text, " top\nmiddle\n\t end"); } } #[test] fn document_navigation_prefix_respects_bindings_and_literal_insert_input() { let mut editor = editor(); editor .apply(vec![Effect::Replace("top\nmiddle\nbottom".into())]) .unwrap(); type_text(&mut editor, "jv"); editor .bind("visual", "g", Some("core.right".into())) .unwrap(); type_text(&mut editor, "gb"); assert_eq!(editor.cursor(), Cursor { row: 1, column: 0 }); assert_eq!(editor.mode(), Mode::Visual); editor.handle(Input::Escape); editor.bind("normal", "g", None).unwrap(); type_text(&mut editor, "gt"); assert_eq!(editor.cursor().row, 1); type_text(&mut editor, "igtgb"); editor.handle(Input::Escape); assert_eq!(editor.context().text, "top\ngtgbmiddle\nbottom"); } #[test] fn document_navigation_actions_remap_and_yield_to_g_as_leader() { let mut editor = editor(); editor .apply(vec![Effect::Replace("top\nmiddle\nbottom".into())]) .unwrap(); editor.set_leader("g").unwrap(); editor .bind("normal", "leader t", Some("core.document_end".into())) .unwrap(); type_text(&mut editor, "gt"); assert_eq!(editor.cursor(), Cursor { row: 2, column: 0 }); editor .bind("normal", "h", Some("core.document_start".into())) .unwrap(); type_text(&mut editor, "h"); assert_eq!(editor.cursor(), Cursor { row: 0, column: 0 }); editor .bind("normal", "g", Some("core.document_end".into())) .unwrap(); type_text(&mut editor, "g"); assert_eq!(editor.cursor(), Cursor { row: 2, column: 0 }); type_text(&mut editor, "igtgb"); editor.handle(Input::Escape); assert_eq!(editor.context().text, "top\nmiddle\ngtgbbottom"); } #[test] fn native_gf_prefix_yields_to_configured_g_commands_and_preserves_counts() { let mut editor = editor(); editor .apply(vec![Effect::Replace("a\nb\nc\nd".into())]) .unwrap(); editor.handle(Input::Ctrl('p')); assert_eq!( palette_entry(&editor, "core.follow_link").bindings, vec!["gf"] ); assert!(palette_entry(&editor, "core.follow_link").enabled); editor.handle(Input::Escape); editor .bind("normal", "g", Some("core.down".into())) .unwrap(); type_text(&mut editor, "2g"); assert_eq!(editor.cursor(), Cursor { row: 2, column: 0 }); assert_eq!(editor.count(), None); assert!(!editor.g_pending()); editor.handle(Input::Ctrl('p')); assert!( palette_entry(&editor, "core.follow_link") .bindings .is_empty() ); editor.handle(Input::Escape); editor.bind("normal", "g", None).unwrap(); type_text(&mut editor, "g"); assert!(!editor.g_pending()); editor.register(plugin("fixture", &["greet"])).unwrap(); editor.bind("normal", "g", Some("greet".into())).unwrap(); type_text(&mut editor, "3"); assert_eq!( editor.handle(Input::Char('g')), Some(Invocation { plugin: "fixture".into(), command: "greet".into(), args: String::new(), }) ); assert_eq!(editor.count(), None); assert!(!editor.g_pending()); for mode in ["insert", "visual", "command", "search"] { assert!( editor .bind(mode, "z", Some("core.follow_link".into())) .is_err() ); } editor.handle(Input::Char('v')); editor.handle(Input::Ctrl('p')); assert!(!palette_entry(&editor, "core.follow_link").enabled); assert!( palette_entry(&editor, "core.follow_link") .bindings .is_empty() ); } #[test] fn native_g_prefix_cancels_on_escape_intervening_input_commands_and_lifecycle() { let directory = Directory::new(); let mut editor = editor(); editor .apply(vec![Effect::Replace("a\nb\nc".into())]) .unwrap(); type_text(&mut editor, "g"); editor.handle(Input::Escape); assert!(!editor.g_pending()); assert_eq!(editor.count(), None); type_text(&mut editor, "gj"); assert_eq!(editor.cursor(), Cursor { row: 1, column: 0 }); assert!(!editor.g_pending()); type_text(&mut editor, "gi"); assert_eq!(editor.mode(), Mode::Insert); assert!(!editor.g_pending()); editor.handle(Input::Char('f')); editor.handle(Input::Escape); assert_eq!(editor.context().text, "a\nfb\nc"); for command in ["plugins", "bnext", "bprev", "buffer 1", "bdelete!"] { editor.handle(Input::Char('g')); assert!(editor.g_pending()); editor.execute(command); assert!(!editor.g_pending()); } editor.handle(Input::Char('g')); editor.handle(Input::Paste("f".into())); assert!(!editor.g_pending()); editor.handle(Input::Char('g')); editor .navigate_to( directory.path("missing.md"), TextPosition { row: 0, column: 0 }, ) .unwrap_err(); assert!(!editor.g_pending()); editor.handle(Input::Char('g')); editor .bind("normal", "z", Some("core.follow_link".into())) .unwrap(); assert!(!editor.g_pending()); editor.set_leader("g").unwrap(); editor.handle(Input::Char('g')); assert!(!editor.g_pending()); assert_eq!(editor.sequence_pending(), Some("g")); editor.handle(Input::Escape); editor.handle(Input::Ctrl('p')); assert_eq!( palette_entry(&editor, "core.follow_link").bindings, vec!["z"] ); } #[test] fn semantic_snapshots_are_borrowed_buffer_local_and_invalidated_by_text_and_path() { use crate::plugin::SemanticTokenKind; let directory = Directory::new(); let a = directory.path("a.py"); let b = directory.path("b.py"); fs::write(&a, "e\u{301}界").unwrap(); fs::write(&b, "other").unwrap(); let mut editor = Editor::open(Some(a.clone()), vec![]).unwrap(); editor.set_clipboard_mode("separate").unwrap(); let tokens: Arc<[SemanticToken]> = Arc::from([SemanticToken { start: TextPosition { row: 0, column: 0 }, end: TextPosition { row: 0, column: 2 }, kind: SemanticTokenKind::Variable, modifiers: 1023, }]); assert!( editor .replace_semantic_tokens(1, 0, Some(tokens.clone())) .unwrap() ); assert!(std::ptr::eq(editor.semantic_tokens(), tokens.as_ref())); assert!(!editor.dirty()); assert!(editor.active.undo.is_empty()); editor.execute(&format!("edit {}", b.display())); assert!(editor.semantic_tokens().is_empty()); assert!( editor .replace_semantic_tokens(2, 0, Some(tokens.clone())) .unwrap() ); editor.execute("buffer 1"); assert!(std::ptr::eq(editor.semantic_tokens(), tokens.as_ref())); assert!( !editor .replace_semantic_tokens(1, 0, Some(tokens.clone())) .unwrap() ); editor.execute("w"); assert_eq!(editor.semantic_tokens(), tokens.as_ref()); editor.execute(&format!("w {}", directory.path("renamed.py").display())); assert_eq!(editor.text_revision(), 0); assert!(editor.semantic_tokens().is_empty()); assert!( editor .replace_semantic_tokens(1, 0, Some(tokens.clone())) .unwrap() ); editor.handle(Input::Char('x')); assert!(editor.semantic_tokens().is_empty()); editor.handle(Input::Char('u')); assert_eq!(editor.lines(), ["e\u{301}界"]); assert!(editor.semantic_tokens().is_empty()); assert!( !editor .replace_semantic_tokens(1, 0, Some(tokens.clone())) .unwrap() ); editor.execute("buffer 2"); assert_eq!(editor.semantic_tokens(), tokens.as_ref()); editor.clear_semantic_tokens(); assert!(editor.semantic_tokens().is_empty()); editor.execute("buffer 1"); assert!(editor.semantic_tokens().is_empty()); } #[test] fn semantic_validation_rejects_malformed_live_tokens_but_ignores_stale_and_unloaded() { use crate::plugin::SemanticTokenKind; let mut editor = editor(); editor .apply(vec![Effect::Replace("e\u{301}界\nx".into())]) .unwrap(); let revision = editor.text_revision(); let token = SemanticToken { start: TextPosition { row: 0, column: 0 }, end: TextPosition { row: 0, column: 2 }, kind: SemanticTokenKind::Variable, modifiers: 0, }; let invalid = [ SemanticToken { end: TextPosition { row: 0, column: 3 }, ..token }, SemanticToken { end: token.start, ..token }, SemanticToken { end: TextPosition { row: 1, column: 1 }, ..token }, SemanticToken { start: TextPosition { row: 9, column: 0 }, end: TextPosition { row: 9, column: 1 }, ..token }, SemanticToken { modifiers: 1024, ..token }, ]; for token in invalid { assert!( editor .validate_semantic_tokens(1, revision, Some(&[token])) .is_err() ); assert!( !editor .validate_semantic_tokens(1, revision - 1, Some(&[token])) .unwrap() ); assert!( !editor .validate_semantic_tokens(999, revision, Some(&[token])) .unwrap() ); } let later = SemanticToken { start: TextPosition { row: 0, column: 1 }, ..token }; assert!( editor .validate_semantic_tokens(1, revision, Some(&[later, token])) .is_err() ); // Raw adjacent LSP tokens may map outward to the same grapheme. assert!( editor .validate_semantic_tokens(1, revision, Some(&[token, token, later])) .unwrap() ); let oversized = vec![token; MAX_EDIT_BYTES / std::mem::size_of::() + 1]; assert!( editor .validate_semantic_tokens(1, revision, Some(&oversized)) .is_err() ); let tokens: Arc<[SemanticToken]> = Arc::from([token]); editor .replace_semantic_tokens(1, revision, Some(tokens)) .unwrap(); assert!(editor.replace_semantic_tokens(1, revision, None).unwrap()); assert!(editor.semantic_tokens().is_empty()); } #[test] fn semantic_column_bounds_preserve_ascii_tabs_and_unicode_graphemes() { use crate::plugin::SemanticTokenKind; for text in ["a\tb", "e\u{301}界👩‍🚀"] { let mut editor = editor(); editor.apply(vec![Effect::Replace(text.into())]).unwrap(); let token = SemanticToken { start: TextPosition { row: 0, column: 0 }, end: TextPosition { row: 0, column: 3 }, kind: SemanticTokenKind::Variable, modifiers: 0, }; editor .replace_semantic_tokens(1, editor.text_revision(), Some(Arc::from([token]))) .unwrap(); let outside = SemanticToken { end: TextPosition { row: 0, column: 4 }, ..token }; assert!( editor .replace_semantic_tokens(1, editor.text_revision(), Some(Arc::from([outside]))) .is_err(), "{text:?}", ); assert_eq!(editor.semantic_tokens(), &[token]); } } #[test] fn hover_scroll_and_escape_preserve_carets_and_edits_dismiss_normally() { let mut editor = editor(); editor .apply(vec![Effect::Replace("alpha\nbeta".into())]) .unwrap(); set_carets(&mut editor, &[(0, 1), (1, 2)], 1); let targets = editor.active.view.targets.clone(); let primary = editor.active.view.primary; let text = editor.context().text; let revision = editor.text_revision(); let history = (editor.active.undo.len(), editor.active.redo.len()); for command in [ ":lsp_hover", ":lsp_definition", ":lsp_diagnostics", ":lsp.hover unexpected extra", ":lsp.definition unexpected extra", ":lsp.diagnostics unexpected extra", ] { assert!(editor.execute(command).is_none(), "{command}"); } editor .bind("normal", "K", Some("core.lsp.hover".into())) .unwrap(); type_text(&mut editor, "9"); assert_eq!( editor.handle(Input::Char('K')), Some(Invocation { plugin: "core".into(), command: "lsp.hover".into(), args: String::new(), }) ); assert_eq!(editor.count(), None); editor .show_hover("界e\u{301}👩‍🚀\tend\r\nsecond\nlast".into()) .unwrap(); assert!(editor.show_hover("\u{1b}[31m".into()).is_err()); assert_eq!(editor.hover().unwrap().lines().nth(1), Some("second")); for input in [ Input::Down, Input::Ctrl('j'), Input::Down, Input::Right, Input::Ctrl('l'), ] { assert_eq!(editor.handle(input), None); } assert_eq!( (editor.hover().unwrap().top, editor.hover().unwrap().left), (2, 2) ); editor.handle(Input::Escape); assert!(editor.hover().is_none()); assert!(editor.notification_visible()); assert_eq!(editor.active.view.targets, targets); assert_eq!(editor.active.view.primary, primary); assert_eq!(editor.context().text, text); assert_eq!(editor.text_revision(), revision); assert_eq!( (editor.active.undo.len(), editor.active.redo.len()), history ); assert_eq!(editor.mode(), Mode::Normal); editor.handle(Input::Escape); assert!(!editor.notification_visible()); assert_eq!(editor.active.view.targets, targets); editor.handle(Input::Escape); assert_eq!(editor.selection_count(), 1); editor.show_hover("documentation".into()).unwrap(); editor.handle(Input::Char('i')); assert!(editor.hover().is_none()); assert_eq!(editor.mode(), Mode::Insert); editor.handle(Input::Char('X')); editor.handle(Input::Escape); assert_eq!(editor.context().text, "alpha\nbeXta"); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, text); editor.show_hover("documentation".into()).unwrap(); editor.apply(vec![Effect::Replace("new".into())]).unwrap(); assert!(editor.hover().is_none()); } #[cfg(unix)] mod system_clipboard { use super::*; use std::os::unix::fs::PermissionsExt; struct Fixture(Directory); impl Fixture { fn new() -> Self { let fixture = Self(Directory::new()); for name in ["state", "reads", "writes"] { fs::write(fixture.0.path(name), b"").unwrap(); } let quoted = |name: &str| { format!( "'{}'", fixture .0 .path(name) .display() .to_string() .replace('\'', "'\\''") ) }; let reader = format!( "#!/bin/sh\nprintf . >> {}\n[ ! -f {} ] || exit 23\nexec /bin/cat {}\n", quoted("reads"), quoted("fail-read"), quoted("state"), ); let writer = format!( "#!/bin/sh\nprintf . >> {}\n[ ! -f {} ] || exit 23\n/bin/cat > {} || exit 24\nexec /bin/mv {} {}\n", quoted("writes"), quoted("fail-write"), quoted("next"), quoted("next"), quoted("state"), ); for (name, script) in [("reader", reader), ("writer", writer)] { let path = fixture.0.path(name); fs::write(&path, script).unwrap(); fs::set_permissions(path, fs::Permissions::from_mode(0o700)).unwrap(); } fixture } fn editor(&self) -> Editor { let mut editor = Editor::open(None, vec!["text".into()]).unwrap(); editor.set_system_clipboard(SystemClipboard::with_commands( self.0.path("reader"), self.0.path("writer"), )); editor } fn bytes(&self, name: &str) -> Vec { fs::read(self.0.path(name)).unwrap() } fn external(&self, text: impl AsRef<[u8]>) { fs::write(self.0.path("state"), text).unwrap(); } fn fail(&self, name: &str, enabled: bool) { let path = self.0.path(name); if enabled { fs::write(path, b"").unwrap(); } else { fs::remove_file(path).unwrap(); } } } #[test] fn line_actions_clipboard_failure_preserves_carets_history_and_repeat() { let fixture = Fixture::new(); fixture.external("keep"); let mut editor = fixture.editor(); editor .apply(vec![Effect::Replace("one\ntwo\nthree".into())]) .unwrap(); type_text(&mut editor, "iX"); editor.handle(Input::Escape); type_text(&mut editor, "l"); editor.handle(Input::Ctrl('e')); let carets = editor.carets(); let revision = editor.text_revision(); fixture.fail("fail-write", true); for keys in [ "yy", "2y3y", "2dd", "cc", "C", "D", "Y", "s", "d2w", "d2b", "c2w", "c2b", ] { type_text(&mut editor, keys); assert_eq!(editor.context().text, "Xone\ntwo\nthree"); assert_eq!(editor.carets(), carets); assert_eq!(editor.mode(), Mode::Normal); assert_eq!(editor.text_revision(), revision); assert_eq!(fixture.bytes("state"), b"keep"); } type_text(&mut editor, "u"); assert_eq!(editor.context().text, "one\ntwo\nthree"); type_text(&mut editor, "U."); assert_eq!(editor.context().text, "XXone\ntXwo\nthree"); } #[test] fn copy_line_publication_failure_keeps_owned_shape_register_and_full_state() { let fixture = Fixture::new(); let mut editor = fixture.editor(); editor .apply(vec![Effect::Replace("one\ntwo\n".into())]) .unwrap(); fixture.fail("fail-write", true); let initial = state(&editor); type_text(&mut editor, "yy"); assert_eq!(state(&editor), initial); assert!(editor.owned_system_clipboard.is_none()); fixture.fail("fail-write", false); type_text(&mut editor, "yy"); assert_eq!(fixture.bytes("state"), b"one\n"); assert_eq!( editor.owned_system_clipboard, Some(("one\n".into(), vec!["one\n".into()], true)) ); editor.handle(Input::Char('j')); fixture.fail("fail-write", true); let before = state(&editor); type_text(&mut editor, "2yy"); assert_eq!(state(&editor), before); assert_eq!(fixture.bytes("state"), b"one\n"); fixture.fail("fail-write", false); type_text(&mut editor, "jyy"); assert_eq!(fixture.bytes("state"), b"\n"); assert_eq!( editor.owned_system_clipboard, Some(("\n".into(), vec!["\n".into()], true)) ); } // Includes every replay-owned state, not the intentionally changed error message. fn state(editor: &Editor) -> String { format!( "{:?}", ( ( &editor.active.lines, &editor.active.view.targets, editor.active.view.primary, editor.active.view.mode, editor.active.view.linewise, editor.active.text_revision, &editor.active.view.search_query, editor.active.view.search_match, &editor.active.undo, &editor.active.redo, ), ( &editor.active.view.insert_start, &editor.active.view.pending_edit, &editor.active.view.visual_motions, &editor.clipboard, editor.clipboard_linewise, &editor.owned_system_clipboard, &editor.last_edit, editor.clipboard_mode, ), ), ) } #[test] fn linewise_owned_system_bytes_keep_shape_external_changes_do_not_guess_it() { let fixture = Fixture::new(); let mut editor = fixture.editor(); editor.apply(vec![Effect::Replace("row".into())]).unwrap(); type_text(&mut editor, "Vy"); assert_eq!(fixture.bytes("state"), b"row\n"); editor.apply(vec![Effect::Replace("X".into())]).unwrap(); editor.handle(Input::Char('P')); assert_eq!(editor.context().text, "row\nX"); fixture.external("row\n"); editor.apply(vec![Effect::Replace("YZ".into())]).unwrap(); editor.handle(Input::Char('p')); assert_eq!(editor.context().text, "YZ\nrow"); fixture.external("other\n"); editor.apply(vec![Effect::Replace("YZ".into())]).unwrap(); editor.handle(Input::Char('p')); assert_eq!(editor.context().text, "Yother\nZ"); assert!(editor.owned_system_clipboard.is_none()); editor.handle(Input::Char('u')); fixture.external("row\n"); editor.handle(Input::Char('p')); assert_eq!(editor.context().text, "Yrow\nZ"); } #[test] fn linewise_empty_final_row_publication_failures_leave_selection_unchanged() { for key in ['y', 'd', 'c'] { let fixture = Fixture::new(); let mut editor = fixture.editor(); editor.apply(vec![Effect::Replace("a\n".into())]).unwrap(); type_text(&mut editor, "jV"); fixture.fail("fail-write", true); let before = state(&editor); editor.handle(Input::Char(key)); assert_eq!(state(&editor), before); assert!(editor.visual_linewise()); assert_eq!(fixture.bytes("state"), b""); fixture.fail("fail-write", false); editor.handle(Input::Char(key)); assert_eq!(fixture.bytes("state"), b"\n"); assert_eq!(editor.context().text, if key == 'd' { "a" } else { "a\n" }); } } #[test] fn linewise_repeat_publication_failure_restores_shape_and_recipe_atomically() { let fixture = Fixture::new(); let mut editor = fixture.editor(); editor .apply(vec![Effect::Replace("a\nb\nc".into())]) .unwrap(); type_text(&mut editor, "Vd"); editor .apply(vec![Effect::Replace("aa\nbb\ncc".into())]) .unwrap(); type_text(&mut editor, "Vj"); fixture.fail("fail-write", true); let before = state(&editor); editor.handle(Input::Char('.')); assert_eq!(state(&editor), before); assert_eq!(fixture.bytes("state"), b"a\n"); assert!(editor.visual_linewise()); fixture.fail("fail-write", false); editor.handle(Input::Char('.')); assert_eq!(editor.context().text, "cc"); assert_eq!(fixture.bytes("state"), b"aa\nbb\n"); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, "aa\nbb\ncc"); assert_eq!(editor.mode(), Mode::Normal); } #[test] fn linewise_internal_shape_is_independent_of_owned_system_shape() { let fixture = Fixture::new(); let mut editor = fixture.editor(); editor.set_clipboard_mode("separate").unwrap(); editor .apply(vec![Effect::Replace("inside".into())]) .unwrap(); type_text(&mut editor, "Vy"); editor .bind("visual", "q", Some("core.copy_system".into())) .unwrap(); editor.apply(vec![Effect::Replace("out".into())]).unwrap(); type_text(&mut editor, "veq"); assert_eq!(fixture.bytes("state"), b"out"); editor.apply(vec![Effect::Replace("XY".into())]).unwrap(); editor.handle(Input::Char('p')); assert_eq!(editor.context().text, "XY\ninside"); editor.apply(vec![Effect::Replace("XY".into())]).unwrap(); editor.set_clipboard_mode("system").unwrap(); editor.handle(Input::Char('p')); assert_eq!(editor.context().text, "XoutY"); } #[test] fn linewise_dot_paste_keeps_captured_shape_after_metadata_loss_and_read_failure() { let fixture = Fixture::new(); let mut editor = fixture.editor(); editor.apply(vec![Effect::Replace("row".into())]).unwrap(); type_text(&mut editor, "Vyp"); assert_eq!(editor.context().text, "row\nrow"); editor.apply(vec![Effect::Replace("AB".into())]).unwrap(); fixture.fail("fail-read", true); let before = state(&editor); editor.handle(Input::Char('p')); assert_eq!(state(&editor), before); fixture.fail("fail-read", false); fixture.external(""); editor.handle(Input::Char('p')); assert_eq!(editor.context().text, "AB"); assert!(editor.owned_system_clipboard.is_none()); let reads = fixture.bytes("reads").len(); fixture.fail("fail-read", true); editor.handle(Input::Char('.')); assert_eq!(editor.context().text, "AB\nrow"); assert_eq!(fixture.bytes("reads").len(), reads); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, "AB"); } #[test] fn default_system_native_copy_cut_change_exports_exact_selected_text() { for key in ['y', 'd', 'x', 'c'] { let fixture = Fixture::new(); let mut editor = fixture.editor(); assert_eq!(editor.clipboard_mode(), ClipboardMode::System); editor.clipboard = vec!["internal".into()]; editor .apply(vec![Effect::Replace("a界e\u{301}z\n👩‍🚀tail\n".into())]) .unwrap(); set_ranges( &mut editor, &[(Cursor { row: 0, column: 1 }, Cursor { row: 1, column: 1 })], 0, ); let undo = editor.active.undo.len(); editor.handle(Input::Char(key)); assert_eq!(fixture.bytes("state"), "界e\u{301}z\n👩‍🚀".as_bytes()); assert_eq!(fixture.bytes("writes").len(), 1); assert_eq!(fixture.bytes("reads").len(), 0); assert_eq!(editor.clipboard, ["internal"]); match key { 'y' => { assert_eq!(editor.context().text, "a界e\u{301}z\n👩‍🚀tail\n"); assert_eq!(editor.active.undo.len(), undo); } 'c' => { assert_eq!(editor.mode(), Mode::Insert); assert!(editor.active.view.pending_edit.is_some()); type_text(&mut editor, "Q"); editor.handle(Input::Escape); assert_eq!(editor.context().text, "aQtail\n"); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, "a界e\u{301}z\n👩‍🚀tail\n"); } _ => { assert_eq!(editor.context().text, "atail\n"); assert_eq!(editor.active.undo.len(), undo + 1); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, "a界e\u{301}z\n👩‍🚀tail\n"); } } } } #[test] fn system_paste_before_after_normalizes_valid_external_text_and_records_it() { for (key, action, expected) in [ ('P', None, "界\t\n:q!\nab"), ('p', None, "a界\t\n:q!\nb"), ('z', Some("core.paste_system_before"), "界\t\n:q!\nab"), ('z', Some("core.paste_system_after"), "a界\t\n:q!\nb"), ] { let fixture = Fixture::new(); let mut editor = fixture.editor(); if let Some(action) = action { editor.set_clipboard_mode("separate").unwrap(); editor.bind("normal", "z", Some(action.into())).unwrap(); } editor.apply(vec![Effect::Replace("ab".into())]).unwrap(); editor.clipboard = vec!["stale".into()]; fixture.external("界\t\r\n:q!\r"); editor.handle(Input::Char(key)); assert_eq!(editor.context().text, expected); assert_eq!(editor.mode(), Mode::Normal); assert_eq!(editor.clipboard, ["stale"]); assert_eq!(fixture.bytes("state"), "界\t\r\n:q!\r".as_bytes()); assert_eq!(fixture.bytes("writes").len(), 0); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, "ab"); assert!(!editor.should_quit()); } } #[test] fn own_fragments_pair_external_multiline_broadcasts_and_empty_never_uses_internal() { let fixture = Fixture::new(); let mut editor = fixture.editor(); editor .apply(vec![Effect::Replace("ab\nxyz".into())]) .unwrap(); set_carets(&mut editor, &[(0, 0), (1, 0)], 1); type_text(&mut editor, "vey"); assert_eq!(fixture.bytes("state"), b"ab\nxyz"); assert_eq!( editor.owned_system_clipboard.as_ref().unwrap().1, ["ab", "xyz"] ); editor .apply(vec![Effect::Replace("1\n2\n3".into())]) .unwrap(); set_carets(&mut editor, &[(0, 0), (1, 0), (2, 0)], 2); let before = state(&editor); editor.handle(Input::Char('P')); assert_eq!(state(&editor), before); assert!(editor.message().contains("fragment count")); editor.apply(vec![Effect::Replace("1\n2".into())]).unwrap(); set_carets(&mut editor, &[(0, 0), (1, 0)], 1); editor.handle(Input::Char('P')); assert_eq!(editor.context().text, "ab1\nxyz2"); editor.apply(vec![Effect::Replace("1\n2".into())]).unwrap(); set_carets(&mut editor, &[(0, 0), (1, 0)], 1); fixture.external("left\nright\n"); editor.handle(Input::Char('P')); assert_eq!(editor.context().text, "left\nright\n1\nleft\nright\n2"); assert!(editor.owned_system_clipboard.is_none()); editor.apply(vec![Effect::Replace("1\n2".into())]).unwrap(); set_carets(&mut editor, &[(0, 0), (1, 0)], 1); fixture.external("ab\nxyz"); editor.handle(Input::Char('P')); assert_eq!(editor.context().text, "ab\nxyz1\nab\nxyz2"); editor.clipboard = vec!["stale".into()]; fixture.external(""); let before = state(&editor); editor.handle(Input::Char('p')); assert_eq!(state(&editor), before); assert!(editor.message().contains("empty")); } #[test] fn separate_and_explicit_system_actions_are_independent_and_mode_checked() { let fixture = Fixture::new(); let mut editor = fixture.editor(); editor.set_clipboard_mode("SePaRaTe").unwrap(); assert_eq!(editor.clipboard_mode(), ClipboardMode::Separate); for mode in ["", "invalid", " system "] { assert!(editor.set_clipboard_mode(mode).is_err()); assert_eq!(editor.clipboard_mode(), ClipboardMode::Separate); } fixture.external("outside"); fixture.fail("fail-read", true); fixture.fail("fail-write", true); editor .apply(vec![Effect::Replace("inside".into())]) .unwrap(); type_text(&mut editor, "veyP"); assert_eq!(editor.context().text, "insideinside"); assert_eq!(editor.clipboard, ["inside"]); assert_eq!(fixture.bytes("reads").len(), 0); assert_eq!(fixture.bytes("writes").len(), 0); fixture.fail("fail-read", false); fixture.fail("fail-write", false); for action in ["core.paste_system_before", "core.paste_system_after"] { assert!(editor.bind("visual", "z", Some(action.into())).is_err()); assert!(editor.bind("insert", "z", Some(action.into())).is_err()); editor.bind("normal", "z", Some(action.into())).unwrap(); } assert!( editor .bind("normal", "q", Some("core.copy_system".into())) .is_err() ); assert!( editor .bind("insert", "q", Some("core.copy_system".into())) .is_err() ); editor .bind("visual", "q", Some("core.copy_system".into())) .unwrap(); editor .apply(vec![Effect::Replace("system".into())]) .unwrap(); editor.handle(Input::Home); type_text(&mut editor, "veq"); assert_eq!(fixture.bytes("state"), b"system"); assert_eq!(editor.clipboard, ["inside"]); editor.apply(vec![Effect::Replace("!".into())]).unwrap(); editor.handle(Input::Char('z')); assert_eq!(editor.context().text, "!system"); assert_eq!(editor.clipboard, ["inside"]); editor.handle(Input::Char('P')); assert_eq!(editor.context().text, "!systeinsidem"); assert_eq!(fixture.bytes("state"), b"system"); editor.set_clipboard_mode("SYSTEM").unwrap(); editor.handle(Input::Ctrl('p')); let entries = &editor.palette().unwrap().entries; for name in [ "core.copy_system", "core.paste_system_before", "core.paste_system_after", ] { let entry = entries.iter().find(|entry| entry.name == name).unwrap(); assert_eq!(entry.enabled, name != "core.copy_system"); } } #[test] fn provider_and_invalid_text_errors_preserve_all_editing_state() { for key in ['y', 'd', 'x', 'c'] { let fixture = Fixture::new(); let mut editor = fixture.editor(); editor .apply(vec![Effect::Replace("ab\ncd".into())]) .unwrap(); type_text(&mut editor, "iX"); editor.handle(Input::Escape); editor.handle(Input::Char('u')); editor.clipboard = vec!["internal".into()]; editor .store_clipboard(vec!["owned".into()], true, false) .unwrap(); set_carets(&mut editor, &[(0, 0), (1, 0)], 1); type_text(&mut editor, "ve"); fixture.fail("fail-write", true); let before = state(&editor); editor.handle(Input::Char(key)); assert_eq!(state(&editor), before); assert_eq!(fixture.bytes("state"), b"owned"); assert!(!editor.message().is_empty()); } for invalid in [ b"valid\nbad\0".to_vec(), b"\x1b[31m".to_vec(), b"\xff".to_vec(), vec![b'x'; MAX_EDIT_BYTES + 1], ] { let fixture = Fixture::new(); let mut editor = fixture.editor(); editor .apply(vec![Effect::Replace("original".into())]) .unwrap(); editor .store_clipboard(vec!["owned".into()], true, false) .unwrap(); editor.clipboard = vec!["internal".into()]; fixture.external(invalid); let before = state(&editor); editor.handle(Input::Char('p')); assert_eq!(state(&editor), before); assert!(!editor.message().is_empty()); } let fixture = Fixture::new(); let mut editor = fixture.editor(); type_text(&mut editor, "iX"); editor.handle(Input::Escape); fixture.fail("fail-read", true); let before = state(&editor); editor.handle(Input::Char('P')); assert_eq!(state(&editor), before); assert!(!editor.message().is_empty()); } #[test] fn separate_cut_change_and_copy_never_invoke_system_transport() { for key in ['y', 'd', 'x', 'c'] { let fixture = Fixture::new(); let mut editor = fixture.editor(); editor.set_clipboard_mode("separate").unwrap(); fixture.external("outside"); fixture.fail("fail-read", true); fixture.fail("fail-write", true); editor .apply(vec![Effect::Replace("inside".into())]) .unwrap(); type_text(&mut editor, "ve"); editor.handle(Input::Char(key)); assert_eq!(editor.clipboard, ["inside"]); assert_eq!( editor.context().text, if key == 'y' { "inside" } else { "" } ); assert_eq!( editor.mode(), if key == 'c' { Mode::Insert } else { Mode::Normal } ); assert_eq!(fixture.bytes("state"), b"outside"); assert_eq!(fixture.bytes("reads").len(), 0); assert_eq!(fixture.bytes("writes").len(), 0); } } #[test] fn empty_visual_copy_and_change_preserve_both_registers() { let fixture = Fixture::new(); let mut editor = fixture.editor(); editor.clipboard = vec!["internal".into()]; editor .store_clipboard(vec!["owned".into()], true, false) .unwrap(); fixture.fail("fail-write", true); for key in ['y', 'd', 'c'] { editor.active.view.mode = Mode::Visual; editor.handle(Input::Char(key)); assert_eq!(editor.clipboard, ["internal"]); assert_eq!(fixture.bytes("state"), b"owned"); assert_eq!(fixture.bytes("writes").len(), 1); assert_eq!(editor.context().text, ""); if key == 'c' { assert_eq!(editor.mode(), Mode::Insert); type_text(&mut editor, "X"); editor.handle(Input::Escape); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, ""); } } } #[test] fn registers_and_mode_are_session_wide_across_buffers() { let fixture = Fixture::new(); let mut editor = fixture.editor(); editor.set_clipboard_mode("separate").unwrap(); editor .apply(vec![Effect::Replace("inside".into())]) .unwrap(); type_text(&mut editor, "vey"); editor.set_clipboard_mode("system").unwrap(); editor .apply(vec![Effect::Replace("ab\nxyz".into())]) .unwrap(); set_carets(&mut editor, &[(0, 0), (1, 0)], 1); type_text(&mut editor, "vey"); let other = fixture.0.path("other"); fs::write(&other, "1\n2").unwrap(); editor.execute(&format!("edit {}", other.display())); assert_eq!(editor.clipboard_mode(), ClipboardMode::System); set_carets(&mut editor, &[(0, 0), (1, 0)], 1); editor.handle(Input::Char('P')); assert_eq!(editor.context().text, "ab1\nxyz2"); editor.set_clipboard_mode("separate").unwrap(); editor.execute("buffer 1"); assert_eq!(editor.clipboard_mode(), ClipboardMode::Separate); set_carets(&mut editor, &[(0, 0)], 0); editor.handle(Input::Char('P')); assert_eq!(editor.context().text, "insideab\nxyz"); assert_eq!(fixture.bytes("state"), b"ab\nxyz"); } #[test] fn dot_paste_uses_captured_fragments_without_reading_or_publishing() { let fixture = Fixture::new(); let mut editor = fixture.editor(); editor .apply(vec![Effect::Replace("ab\nxyz".into())]) .unwrap(); set_carets(&mut editor, &[(0, 0), (1, 0)], 1); type_text(&mut editor, "vey"); editor.apply(vec![Effect::Replace("1\n2".into())]).unwrap(); set_carets(&mut editor, &[(0, 0), (1, 0)], 1); editor.handle(Input::Char('P')); fixture.external("external"); fixture.fail("fail-read", true); fixture.fail("fail-write", true); editor .apply(vec![Effect::Replace("3\n4\n5".into())]) .unwrap(); set_carets(&mut editor, &[(0, 0), (1, 0), (2, 0)], 2); let before = state(&editor); editor.handle(Input::Char('.')); assert_eq!(state(&editor), before); editor.set_clipboard_mode("separate").unwrap(); editor.clipboard = vec!["other".into()]; set_carets(&mut editor, &[(0, 0), (1, 0)], 1); editor.handle(Input::Char('.')); assert_eq!(editor.context().text, "ab3\nxyz4\n5"); assert_eq!(editor.clipboard, ["other"]); assert_eq!(fixture.bytes("reads").len(), 1); assert_eq!(fixture.bytes("writes").len(), 1); assert_eq!(fixture.bytes("state"), b"external"); } #[test] fn dot_selection_stages_publication_and_rolls_back_step_or_provider_failures() { let fixture = Fixture::new(); let mut editor = fixture.editor(); editor .apply(vec![Effect::Replace("ab\ncd".into())]) .unwrap(); set_carets(&mut editor, &[(0, 0), (1, 0)], 1); type_text(&mut editor, "vc"); editor.handle(Input::Backspace); type_text(&mut editor, "X"); editor.handle(Input::Escape); let original_clipboard = fixture.bytes("state"); editor .apply(vec![Effect::Replace("aa\nbb\ncc".into())]) .unwrap(); set_carets(&mut editor, &[(0, 0), (1, 0), (2, 0)], 2); editor.handle(Input::Char('v')); let before = state(&editor); editor.handle(Input::Char('.')); assert_eq!(state(&editor), before); assert_eq!(fixture.bytes("writes").len(), 1); assert_eq!(fixture.bytes("state"), original_clipboard); assert!(editor.message().contains("count")); editor .apply(vec![Effect::Replace("aa\nbb".into())]) .unwrap(); set_carets(&mut editor, &[(0, 0), (1, 0)], 1); editor.handle(Input::Char('v')); editor.active.view.search_query = "aa".into(); editor.active.view.search_match = Some(SearchMatch { start: Cursor::default(), end_column: 2, }); fixture.fail("fail-write", true); let before = state(&editor); editor.handle(Input::Char('.')); assert_eq!(state(&editor), before); assert_eq!(fixture.bytes("state"), original_clipboard); assert_eq!(fixture.bytes("writes").len(), 2); fixture.fail("fail-write", false); let undo = editor.active.undo.len(); editor.handle(Input::Char('.')); assert_eq!(editor.context().text, "XaXb"); assert_eq!(fixture.bytes("state"), b"a\nb"); assert_eq!(fixture.bytes("writes").len(), 3); assert_eq!(editor.active.undo.len(), undo + 1); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, "aa\nbb"); assert_eq!(fixture.bytes("state"), b"a\nb"); } #[test] fn dot_unequal_spans_refuses_before_publication_and_preserves_recipe() { let fixture = Fixture::new(); let mut editor = fixture.editor(); editor .apply(vec![Effect::Replace("ab\nxyz".into())]) .unwrap(); set_ranges( &mut editor, &[ (Cursor::default(), Cursor { row: 0, column: 2 }), (Cursor { row: 1, column: 0 }, Cursor { row: 1, column: 3 }), ], 1, ); editor.handle(Input::Char('d')); editor .apply(vec![Effect::Replace("12\n345".into())]) .unwrap(); set_carets(&mut editor, &[(0, 0)], 0); let before = state(&editor); editor.handle(Input::Char('.')); assert_eq!(state(&editor), before); assert_eq!(fixture.bytes("writes").len(), 1); set_carets(&mut editor, &[(0, 0), (1, 0)], 1); editor.handle(Input::Char('.')); assert_eq!(editor.context().text, "\n"); assert_eq!(fixture.bytes("state"), b"12\n345"); assert_eq!(fixture.bytes("writes").len(), 2); } #[test] fn dot_cut_follows_current_policy_without_copying_plain_deletes_or_plugin_edits() { let fixture = Fixture::new(); let mut editor = fixture.editor(); editor.set_clipboard_mode("separate").unwrap(); editor.apply(vec![Effect::Replace("first".into())]).unwrap(); type_text(&mut editor, "ved"); assert_eq!(editor.clipboard, ["first"]); assert_eq!(fixture.bytes("writes").len(), 0); editor.set_clipboard_mode("system").unwrap(); editor .apply(vec![Effect::Replace("second".into())]) .unwrap(); set_carets(&mut editor, &[(0, 0)], 0); editor.handle(Input::Char('.')); assert_eq!(editor.context().text, ""); assert_eq!(fixture.bytes("state"), b"second"); assert_eq!(editor.clipboard, ["first"]); editor.set_clipboard_mode("separate").unwrap(); editor.apply(vec![Effect::Replace("third".into())]).unwrap(); set_carets(&mut editor, &[(0, 0)], 0); editor.handle(Input::Char('.')); assert_eq!(editor.clipboard, ["third"]); assert_eq!(fixture.bytes("state"), b"second"); editor.set_clipboard_mode("system").unwrap(); editor.apply(vec![Effect::Replace("plain".into())]).unwrap(); set_carets(&mut editor, &[(0, 0)], 0); type_text(&mut editor, "x."); type_text(&mut editor, "iX"); editor.handle(Input::Backspace); editor.handle(Input::Delete); editor.handle(Input::Escape); editor.handle(Input::Char('.')); assert_eq!(fixture.bytes("writes").len(), 1); assert_eq!(fixture.bytes("reads").len(), 0); } } #[test] fn floating_default_tree_starts_filtering_then_escape_navigates_before_close() { let directory = Directory::new(); fs::create_dir(directory.path("dir")).unwrap(); fs::write(directory.path("dir/first.txt"), "first").unwrap(); fs::write(directory.path("dir/second.txt"), "second").unwrap(); let mut editor = project_editor(&directory); type_text(&mut editor, " osecond.txt"); let picker = editor.file_picker().unwrap(); assert_eq!(picker.view, FileView::Tree); assert!(picker.focused && picker.searching); assert_eq!(picker.query, "second.txt"); assert_eq!( picker.entries().nth(picker.selected).unwrap().path, Path::new("dir/second.txt") ); editor.handle(Input::Escape); let picker = editor.file_picker().unwrap(); assert!(picker.focused && !picker.searching); assert_eq!(picker.query, "second.txt"); assert_eq!( picker.entries().nth(picker.selected).unwrap().path, Path::new("dir/second.txt") ); editor.handle(Input::Char('i')); assert!(editor.file_picker().unwrap().searching); assert_eq!(editor.file_picker().unwrap().query, "second.txt"); editor.handle(Input::Escape); editor.handle(Input::Char('h')); editor.handle(Input::Char('h')); let picker = editor.file_picker().unwrap(); assert_eq!(picker.entries().len(), 1); assert_eq!(picker.entries().next().unwrap().path, Path::new("dir")); assert!(!picker.entries().next().unwrap().expanded); assert_eq!(picker.query, "second.txt"); editor.handle(Input::Char('l')); assert!( editor .file_picker() .unwrap() .entries() .next() .unwrap() .expanded ); editor.handle(Input::Escape); assert!(editor.file_picker().is_none()); assert_eq!(editor.lines(), &[String::new()]); editor.set_file_picker_view("list").unwrap(); type_text(&mut editor, " osecond.txt"); assert_eq!(editor.file_picker().unwrap().view, FileView::List); editor.handle(Input::Enter); assert_eq!(editor.lines(), &["second"]); assert!(editor.file_picker().is_none()); } #[test] fn filter_escape_resume_and_clear_do_not_edit_underlying_buffers_or_history() { let directory = Directory::new(); fs::create_dir_all(directory.path("src/deep")).unwrap(); fs::write(directory.path("src/deep/needle.txt"), "needle").unwrap(); fs::write(directory.path("src/other.txt"), "other").unwrap(); let selected_path = |editor: &Editor| { let picker = editor.file_picker().unwrap(); picker .entries() .nth(picker.selected) .unwrap() .path .to_owned() }; for presentation in [FilePresentation::Floating, FilePresentation::Docked] { for mode in [ Mode::Normal, Mode::Insert, Mode::Command, Mode::Search, Mode::Visual, ] { let mut editor = project_editor(&directory); editor .apply(vec![Effect::Replace("alpha\nbeta".into())]) .unwrap(); editor .apply(vec![Effect::Replace("changed".into())]) .unwrap(); editor.act(Action::Undo); set_carets(&mut editor, &[(0, 1), (1, 2)], 1); editor.begin_insert(); editor.active.view.mode = mode; editor.active.view.command_line = "original prompt".into(); editor.open_file_picker(presentation); if !editor.file_picker().unwrap().searching { editor.handle(Input::Char('i')); } editor.count = Some(7); let snapshot = |state: &Snapshot| (state.lines.clone(), state.targets.clone(), state.primary); let lines = editor.lines().to_owned(); let targets = editor.active.view.targets.clone(); let primary = editor.active.view.primary; let undo: Vec<_> = editor.active.undo.iter().map(&snapshot).collect(); let redo: Vec<_> = editor.active.redo.iter().map(&snapshot).collect(); let insert = editor.active.view.insert_start.as_ref().map(snapshot); let buffer = editor.buffer_id(); let buffers = editor.buffer_count(); type_text(&mut editor, "needle"); let expected = PathBuf::from("src/deep/needle.txt"); assert_eq!(selected_path(&editor), expected); editor.handle(Input::Escape); assert!(!editor.file_picker().unwrap().searching); assert_eq!(editor.file_picker().unwrap().query, "needle"); assert_eq!(selected_path(&editor), expected); editor.handle(Input::Char('i')); assert!(editor.file_picker().unwrap().searching); assert_eq!(editor.file_picker().unwrap().query, "needle"); assert_eq!(selected_path(&editor), expected); editor.handle(Input::Ctrl('U')); assert!(editor.file_picker().unwrap().searching); assert!(editor.file_picker().unwrap().query.is_empty()); assert_eq!(selected_path(&editor), expected); type_text(&mut editor, "needle"); editor.handle(Input::Escape); editor.handle(Input::Ctrl('u')); assert!(!editor.file_picker().unwrap().searching); assert!(editor.file_picker().unwrap().query.is_empty()); assert_eq!(selected_path(&editor), expected); editor.handle(Input::Ctrl('u')); assert_eq!(selected_path(&editor), expected); editor.handle(Input::Char('i')); type_text(&mut editor, "missing"); editor.handle(Input::Ctrl('j')); editor.handle(Input::Ctrl('k')); editor.handle(Input::Escape); assert_eq!(editor.file_picker().unwrap().entries().len(), 0); editor.handle(Input::Ctrl('u')); assert_eq!(selected_path(&editor), expected); assert!(editor.file_picker().unwrap().focused); assert!(!editor.file_picker().unwrap().searching); assert_eq!(editor.mode(), mode); assert_eq!(editor.lines(), lines); assert_eq!(editor.active.view.targets, targets); assert_eq!(editor.active.view.primary, primary); assert_eq!(editor.active.view.command_line, "original prompt"); assert_eq!(editor.count(), Some(7)); assert_eq!(editor.buffer_id(), buffer); assert_eq!(editor.buffer_count(), buffers); assert_eq!( editor.active.undo.iter().map(&snapshot).collect::>(), undo ); assert_eq!( editor.active.redo.iter().map(&snapshot).collect::>(), redo ); assert_eq!( editor.active.view.insert_start.as_ref().map(snapshot), insert ); editor.handle(Input::Escape); match presentation { FilePresentation::Floating => assert!(editor.file_picker().is_none()), FilePresentation::Docked => { assert!(!editor.file_picker().unwrap().focused); editor.open_file_picker(presentation); assert!(!editor.file_picker().unwrap().searching); assert_eq!(selected_path(&editor), expected); } } } } } #[test] fn filtered_folder_keys_and_directory_accept_work_in_both_presentations() { let directory = Directory::new(); fs::create_dir_all(directory.path("src/deep")).unwrap(); fs::write(directory.path("src/deep/needle.txt"), "needle").unwrap(); fs::write(directory.path("src/other.txt"), "other").unwrap(); for presentation in [FilePresentation::Floating, FilePresentation::Docked] { let mut editor = project_editor(&directory); editor.open_file_picker(presentation); if !editor.file_picker().unwrap().searching { editor.handle(Input::Char('i')); } type_text(&mut editor, "needle"); editor.handle(Input::Ctrl('h')); let picker = editor.file_picker().unwrap(); assert!(picker.searching); assert_eq!(picker.query, "needle"); assert_eq!( picker.entries().nth(picker.selected).unwrap().path, Path::new("src/deep") ); assert!(picker.entries().nth(picker.selected).unwrap().expanded); editor.handle(Input::Ctrl('l')); let picker = editor.file_picker().unwrap(); assert_eq!( picker.entries().nth(picker.selected).unwrap().path, Path::new("src/deep/needle.txt") ); editor.handle(Input::Escape); editor.handle(Input::Char('h')); editor.handle(Input::Char('h')); let picker = editor.file_picker().unwrap(); assert!(!picker.searching); assert_eq!(picker.query, "needle"); assert_eq!(picker.entries().len(), 2); assert_eq!( picker.entries().nth(picker.selected).unwrap().path, Path::new("src/deep") ); assert!(!picker.entries().nth(picker.selected).unwrap().expanded); editor.handle(Input::Char('l')); assert!( editor .file_picker() .unwrap() .entries() .nth(1) .unwrap() .expanded ); editor.handle(Input::Char('l')); editor.handle(Input::Char('h')); editor.handle(Input::Enter); assert!( !editor .file_picker() .unwrap() .entries() .nth(1) .unwrap() .expanded ); editor.handle(Input::Enter); assert!( editor .file_picker() .unwrap() .entries() .nth(1) .unwrap() .expanded ); editor.handle(Input::Char('i')); editor.handle(Input::Enter); let picker = editor.file_picker().unwrap(); assert!(!picker.searching && picker.focused); assert_eq!(picker.query, "needle"); assert_eq!( picker.entries().nth(picker.selected).unwrap().path, Path::new("src/deep") ); assert!(!picker.entries().nth(picker.selected).unwrap().expanded); assert_eq!(editor.context().text, ""); editor.handle(Input::Ctrl('r')); editor.handle(Input::Ctrl('t')); editor.handle(Input::Ctrl('t')); let picker = editor.file_picker().unwrap(); assert!(!picker.searching && picker.focused); assert_eq!(picker.query, "needle"); assert!(!picker.entries().nth(picker.selected).unwrap().expanded); if presentation == FilePresentation::Docked { editor.handle(Input::Escape); editor.open_file_picker(presentation); assert!(!editor.file_picker().unwrap().searching); assert_eq!(editor.file_picker().unwrap().query, "needle"); } editor.open_file_picker(FilePresentation::Floating); assert!(editor.file_picker().unwrap().searching); assert_eq!(editor.file_picker().unwrap().query, "needle"); } } #[test] fn filter_clear_respects_navigation_bindings_and_preserves_idle_dock_queries() { let directory = Directory::new(); for name in ["a.txt", "b.txt"] { fs::write(directory.path(name), "text").unwrap(); } for view in [FileView::List, FileView::Tree] { let mut editor = project_editor(&directory); editor.open_file_picker(FilePresentation::Docked); editor.file_picker.as_mut().unwrap().set_view(view); if view == FileView::Tree { editor.handle(Input::Char('i')); } type_text(&mut editor, "txt"); editor .bind("selection", "ctrl+u", Some("core.down".into())) .unwrap(); editor.handle(Input::Ctrl('u')); assert_eq!(editor.file_picker().unwrap().query, "txt"); assert_eq!(editor.file_picker().unwrap().selected, 1); editor.bind("selection", "ctrl+u", None).unwrap(); editor.handle(Input::Ctrl('u')); assert_eq!(editor.file_picker().unwrap().query, "txt"); editor.selection_bindings.remove(&Input::Ctrl('u')); if view == FileView::Tree { editor.bind("tree", "ctrl+u", None).unwrap(); editor.handle(Input::Ctrl('u')); assert_eq!(editor.file_picker().unwrap().query, "txt"); editor.tree_bindings.remove(&Input::Ctrl('u')); } editor.handle(Input::Ctrl('u')); assert!(editor.file_picker().unwrap().query.is_empty()); assert_eq!(editor.file_picker().unwrap().selected, 1); assert_eq!( editor.file_picker().unwrap().searching, view == FileView::Tree ); type_text(&mut editor, "txt"); editor.handle(Input::Char('\t')); editor.handle(Input::Ctrl('u')); assert_eq!(editor.file_picker().unwrap().query, "txt"); editor.handle(Input::Ctrl('p')); type_text(&mut editor, "core"); editor.handle(Input::Ctrl('u')); assert!(editor.palette().unwrap().query.is_empty()); assert_eq!(editor.file_picker().unwrap().query, "txt"); } } #[test] fn selection_navigation_wraps_without_touching_any_underlying_mode() { let directory = Directory::new(); for name in ["a.txt", "b.txt", "c.txt"] { fs::write(directory.path(name), "file text").unwrap(); } for surface in [ None, Some(FilePresentation::Floating), Some(FilePresentation::Docked), ] { for mode in [ Mode::Normal, Mode::Insert, Mode::Command, Mode::Search, Mode::Visual, ] { let mut editor = project_editor(&directory); editor .apply(vec![Effect::Replace("alpha\nbeta".into())]) .unwrap(); set_carets(&mut editor, &[(0, 1), (1, 2)], 1); editor.active.view.mode = mode; editor.active.view.command_line = "original prompt".into(); if let Some(presentation) = surface { editor.open_file_picker(presentation); editor .file_picker .as_mut() .unwrap() .set_view(FileView::List); type_text(&mut editor, "txt"); } else { editor.handle(Input::Ctrl('p')); type_text(&mut editor, "core"); } let list = |editor: &Editor| { if let Some(picker) = editor.file_picker() { ( picker.query.clone(), picker.selected, picker.entries().len(), ) } else { let palette = editor.palette().unwrap(); ( palette.query.clone(), palette.selected, palette.entries.len(), ) } }; let (query, _, len) = list(&editor); assert!(len > 1); let snapshot = |state: &Snapshot| (state.lines.clone(), state.targets.clone(), state.primary); let text = editor.context().text; let targets = editor.active.view.targets.clone(); let primary = editor.active.view.primary; let undo: Vec<_> = editor.active.undo.iter().map(&snapshot).collect(); let redo: Vec<_> = editor.active.redo.iter().map(&snapshot).collect(); let insert = editor.active.view.insert_start.as_ref().map(snapshot); editor.count = Some(7); for (input, selected) in [ (Input::Ctrl('K'), len - 1), (Input::Ctrl('J'), 0), (Input::Ctrl('j'), 1), (Input::Ctrl('k'), 0), (Input::Up, len - 1), (Input::Down, 0), (Input::Ctrl('n'), 1), ] { assert_eq!(editor.handle(input), None); assert_eq!(list(&editor), (query.clone(), selected, len)); } assert_eq!(editor.mode(), mode); assert_eq!(editor.context().text, text); assert_eq!(editor.active.view.targets, targets); assert_eq!(editor.active.view.primary, primary); assert_eq!(editor.active.view.command_line, "original prompt"); assert_eq!(editor.count(), Some(7)); assert_eq!( editor.active.undo.iter().map(&snapshot).collect::>(), undo ); assert_eq!( editor.active.redo.iter().map(&snapshot).collect::>(), redo ); assert_eq!( editor.active.view.insert_start.as_ref().map(snapshot), insert ); type_text(&mut editor, "missing"); assert_eq!(list(&editor).2, 0); for input in [Input::Ctrl('j'), Input::Ctrl('k'), Input::Enter] { assert_eq!(editor.handle(input), None); assert_eq!(list(&editor).1, 0); assert_eq!(editor.context().text, text); assert_eq!(editor.mode(), mode); } } } } #[test] fn selection_remaps_and_disables_are_shared_and_invalid_edits_are_atomic() { let directory = Directory::new(); for name in ["a.txt", "b.txt", "c.txt"] { fs::write(directory.path(name), "file text").unwrap(); } for surface in [ None, Some(FilePresentation::Floating), Some(FilePresentation::Docked), ] { let mut editor = project_editor(&directory); assert_eq!( editor.selection_navigation_hint(), "Ctrl+j/Ctrl+n/Down next · Ctrl+k/Up previous" ); type_text(&mut editor, "3 b"); let pending = editor.sequence_pending().unwrap().to_owned(); editor .bind("selection", "x", Some("core.down".into())) .unwrap(); editor .bind("selection", "z", Some("core.up".into())) .unwrap(); editor.bind("selection", "ctrl+j", None).unwrap(); editor.bind("selection", "!", None).unwrap(); assert_eq!(editor.count(), Some(3)); assert_eq!(editor.sequence_pending(), Some(pending.as_str())); assert_eq!( editor.selection_navigation_hint(), "Ctrl+n/Down/x next · Ctrl+k/Up/z previous" ); let hint = editor.selection_navigation_hint().to_owned(); for (mode, key, target) in [ ("selection", "x", Some("core.delete")), ("selection", "x", Some("core.palette")), ("selection", "x", Some("w")), ("selection", "leader x", Some("core.down")), ("selection", "LEADER x", None), ("selection", "x z", Some("core.up")), ("invalid", "x", Some("core.up")), ] { assert!(editor.bind(mode, key, target.map(str::to_owned)).is_err()); assert_eq!(editor.selection_navigation_hint(), hint); assert_eq!(editor.count(), Some(3)); assert_eq!(editor.sequence_pending(), Some(pending.as_str())); } editor.cancel_sequence(); // Editing bindings cannot disable or remap list bindings. editor .bind("normal", "x", Some("core.insert".into())) .unwrap(); editor.bind("normal", "ctrl+k", None).unwrap(); if let Some(presentation) = surface { editor.open_file_picker(presentation); editor .file_picker .as_mut() .unwrap() .set_view(FileView::List); } else { editor.handle(Input::Ctrl('p')); } let selected = |editor: &Editor| { if let Some(picker) = editor.file_picker() { picker.selected } else { editor.palette().unwrap().selected } }; for (input, expected) in [ (Input::Char('x'), 1), (Input::Ctrl('j'), 1), (Input::Char('!'), 1), (Input::Char('z'), 0), (Input::Char('x'), 1), (Input::Ctrl('k'), 0), ] { editor.handle(input); assert_eq!(selected(&editor), expected); assert_eq!(editor.mode(), Mode::Normal); } // Updating the map does not rebuild/reset the selected list. editor.handle(Input::Char('x')); editor .bind("selection", "ctrl+p", Some("core.down".into())) .unwrap(); assert_eq!(selected(&editor), 1); editor.handle(Input::Ctrl('p')); assert_eq!(selected(&editor), 2); editor.bind("selection", "backspace", None).unwrap(); type_text(&mut editor, "a"); editor.handle(Input::Backspace); let query = if let Some(picker) = editor.file_picker() { &picker.query } else { &editor.palette().unwrap().query }; assert_eq!(query, "a"); for key in ["down", "ctrl+n", "x", "ctrl+p"] { editor.bind("selection", key, None).unwrap(); } assert_eq!( editor.selection_navigation_hint(), "unbound next · Ctrl+k/Up/z previous" ); editor.handle(Input::Escape); editor.handle(Input::Char('x')); assert_eq!(editor.mode(), Mode::Insert); } } #[test] fn ctrl_j_is_distinct_from_enter_and_preserves_editor_defaults() { assert_eq!(parse_key("CTRL+J").unwrap(), Input::Ctrl('j')); for (key, input) in [ ("ctrl+m", Input::Enter), ("ctrl+h", Input::Ctrl('h')), ("ctrl+i", Input::Char('\t')), ] { assert_eq!(parse_key(key).unwrap(), input); } let mut editor = editor(); editor .bind("normal", "ctrl+j", Some("core.insert".into())) .unwrap(); editor.handle(Input::Enter); assert_eq!(editor.mode(), Mode::Normal); editor.handle(Input::Ctrl('j')); assert_eq!(editor.mode(), Mode::Insert); editor.bind("insert", "enter", None).unwrap(); type_text(&mut editor, "one"); editor.handle(Input::Enter); editor.handle(Input::Ctrl('j')); type_text(&mut editor, "two"); assert_eq!(editor.context().text, "one\ntwo"); editor.handle(Input::Escape); editor.register(plugin("fixture", &["greet"])).unwrap(); type_text(&mut editor, ":greet arguments"); let invocation = editor.handle(Input::Ctrl('j')).unwrap(); assert_eq!(invocation.command, "greet"); assert_eq!(invocation.args, "arguments"); assert_eq!(editor.mode(), Mode::Normal); type_text(&mut editor, "/two"); editor.handle(Input::Ctrl('j')); assert_eq!(editor.mode(), Mode::Normal); assert_eq!(editor.active.view.search_query, "two"); assert_eq!(editor.cursor().row, 1); editor .bind("visual", "ctrl+j", Some("core.up".into())) .unwrap(); editor.handle(Input::Char('v')); editor.handle(Input::Ctrl('j')); assert_eq!(editor.mode(), Mode::Visual); assert_eq!(editor.cursor().row, 0); } #[test] fn ctrl_j_navigates_lists_while_enter_accepts() { let directory = Directory::new(); fs::write(directory.path("a.txt"), "first").unwrap(); fs::write(directory.path("b.txt"), "second").unwrap(); for presentation in [FilePresentation::Floating, FilePresentation::Docked] { let mut editor = project_editor(&directory); editor.open_file_picker(presentation); editor.handle(Input::Ctrl('j')); assert_eq!(editor.file_picker().unwrap().selected, 1); assert_eq!(editor.context().text, ""); editor.handle(Input::Enter); assert_eq!(editor.context().text, "second"); assert_eq!(editor.path(), Some(directory.path("b.txt").as_path())); } let mut editor = editor(); editor .register(plugin("fixture", &["greet_one", "greet_two"])) .unwrap(); editor.handle(Input::Ctrl('p')); type_text(&mut editor, "greet"); editor.handle(Input::Ctrl('j')); assert_eq!(editor.palette().unwrap().selected, 1); assert_eq!(editor.mode(), Mode::Normal); assert_eq!(editor.active.view.command_line, ""); editor.handle(Input::Enter); assert!(editor.palette().is_none()); assert_eq!(editor.mode(), Mode::Command); assert_eq!(editor.active.view.command_line, "greet_two "); } #[test] fn file_actions_default_leaders_palette_and_side_configuration() { let directory = Directory::new(); fs::write(directory.path("file.txt"), "abcdef").unwrap(); for visual in [false, true] { let mut editor = project_editor(&directory); editor .apply(vec![Effect::Replace("abcdef".into())]) .unwrap(); if visual { type_text(&mut editor, "vl"); } let mode = editor.mode(); let selections = editor.selections(); let carets = editor.carets(); for (suffix, presentation) in [ ("3 o", FilePresentation::Floating), ("2 e", FilePresentation::Docked), ] { type_text(&mut editor, suffix); let picker = editor.file_picker().unwrap(); assert_eq!(picker.presentation, presentation); assert!(picker.focused); assert_eq!(editor.mode(), mode); assert_eq!(editor.selections(), selections); assert_eq!(editor.carets(), carets); assert_eq!(editor.count(), None); assert_eq!(editor.sequence_pending(), None); editor.handle(Input::Ctrl('w')); assert!(editor.file_picker().is_none()); } editor.handle(Input::Ctrl('p')); for (name, binding) in [ ("core.file_picker", "Space o"), ("core.file_explorer", "Space e"), ] { let entry = palette_entry(&editor, name); assert!(entry.enabled); assert_eq!(entry.bindings, vec![binding]); } editor.handle(Input::Escape); assert_eq!(editor.file_explorer_side(), DockSide::Right); for (name, side) in [ ("LEFT", DockSide::Left), ("rIgHt", DockSide::Right), ("top", DockSide::Top), ("BOTTOM", DockSide::Bottom), ] { editor.set_file_explorer_side(name).unwrap(); assert_eq!(editor.file_explorer_side(), side); } assert!(editor.set_file_explorer_side("middle").is_err()); assert_eq!(editor.file_explorer_side(), DockSide::Bottom); assert!( editor .bind("insert", "ctrl+o", Some("core.file_picker".into())) .is_err() ); assert!( editor .bind("command", "ctrl+e", Some("core.file_explorer".into())) .is_err() ); } } #[test] fn picker_focus_literal_input_navigation_and_empty_selection() { let directory = Directory::new(); for name in ["a space 界[e\u{301}].txt", "b.txt", "c.txt"] { fs::write(directory.path(name), "text").unwrap(); } let mut editor = project_editor(&directory); editor.set_file_picker_view("list").unwrap(); type_text(&mut editor, " o"); editor.handle(Input::Ctrl('K')); assert_eq!(editor.file_picker().unwrap().selected, 2); editor.handle(Input::Ctrl('N')); assert_eq!(editor.file_picker().unwrap().selected, 0); editor.handle(Input::Up); editor.handle(Input::Down); assert_eq!(editor.file_picker().unwrap().selected, 0); type_text(&mut editor, "界 SPACE [E\u{301}]"); assert_eq!(editor.file_picker().unwrap().entries().len(), 1); assert_eq!(editor.context().text, ""); assert_eq!(editor.count(), None); assert_eq!(editor.sequence_pending(), None); let query = editor.file_picker().unwrap().query.clone(); editor.handle(Input::Paste("ignored 5 b n".into())); assert_eq!(editor.file_picker().unwrap().query, query); editor.handle(Input::Enter); assert!(editor.file_picker().is_none()); assert_eq!( editor.path(), Some(directory.path("a space 界[e\u{301}].txt").as_path()) ); assert_eq!(editor.context().text, "text"); type_text(&mut editor, " o"); type_text(&mut editor, "e\u{301}👩‍💻"); editor.handle(Input::Backspace); assert_eq!(editor.file_picker().unwrap().query, "e\u{301}"); editor.handle(Input::Backspace); assert_eq!(editor.file_picker().unwrap().query, ""); editor.handle(Input::Backspace); type_text(&mut editor, "missing 123"); let id = editor.buffer_id(); editor.handle(Input::Up); editor.handle(Input::Down); editor.handle(Input::Enter); assert_eq!(editor.buffer_id(), id); assert_eq!(editor.file_picker().unwrap().selected, 0); assert!(editor.file_picker().unwrap().focused); editor.handle(Input::Escape); let empty = Directory::new(); editor.project_root = empty.0.clone(); type_text(&mut editor, " e"); editor.handle(Input::Up); editor.handle(Input::Down); editor.handle(Input::Ctrl('j')); editor.handle(Input::Ctrl('k')); editor.handle(Input::Enter); assert_eq!(editor.buffer_id(), id); assert_eq!(editor.file_picker().unwrap().entries().len(), 0); } #[test] fn picker_conversion_reuses_query_and_scan_refreshes_only_on_request() { let directory = Directory::new(); fs::write(directory.path("keep.txt"), "text").unwrap(); let mut editor = project_editor(&directory); type_text(&mut editor, " eikeep"); editor.handle(Input::Char('\t')); assert!(!editor.file_picker().unwrap().focused); fs::write(directory.path("keep new.txt"), "new").unwrap(); type_text(&mut editor, " o"); let picker = editor.file_picker().unwrap(); assert_eq!(picker.presentation, FilePresentation::Floating); assert_eq!(picker.query, "keep"); assert_eq!(picker.entries().len(), 1); editor.open_file_picker(FilePresentation::Docked); assert_eq!(editor.file_picker().unwrap().query, "keep"); editor.handle(Input::Ctrl('R')); assert_eq!(editor.file_picker().unwrap().entries().len(), 2); assert_eq!(editor.file_picker().unwrap().query, "keep"); editor.handle(Input::Char('\t')); assert!(!editor.file_picker().unwrap().focused); type_text(&mut editor, " e"); assert!(editor.file_picker().unwrap().focused); assert_eq!(editor.file_picker().unwrap().query, "keep"); fs::remove_file(directory.path("keep new.txt")).unwrap(); editor.handle(Input::Ctrl('r')); assert_eq!(editor.file_picker().unwrap().entries().len(), 1); editor.handle(Input::Ctrl('w')); assert!(editor.file_picker().is_none()); type_text(&mut editor, " o"); assert_eq!(editor.file_picker().unwrap().query, ""); editor.handle(Input::Escape); assert!(editor.file_picker().unwrap().focused); editor.handle(Input::Escape); assert!(editor.file_picker().is_none()); } #[test] fn picker_open_reuses_loaded_dirty_buffer_and_dock_survives_lifecycle() { let directory = Directory::new(); let a = directory.path("a space 界.txt"); let b = directory.path("b.txt"); fs::write(&a, "original").unwrap(); fs::write(&b, "other").unwrap(); let mut editor = project_editor(&directory); editor.execute(&format!("edit {}", a.display())); editor.apply(vec![Effect::Replace("dirty".into())]).unwrap(); type_text(&mut editor, "vl"); let selections = editor.selections(); let id = editor.buffer_id(); editor.execute(&format!("edit {}", b.display())); fs::write(&a, b"\xff").unwrap(); type_text(&mut editor, " eia space 界"); editor.handle(Input::Enter); assert_eq!(editor.buffer_id(), id); assert_eq!(editor.buffer_count(), 3); assert_eq!(editor.context().text, "dirty"); assert!(editor.dirty()); assert_eq!(editor.mode(), Mode::Visual); assert_eq!(editor.selections(), selections); assert!(!editor.file_picker().unwrap().focused); assert_eq!(editor.file_picker().unwrap().query, "a space 界"); editor.execute("bnext"); assert!(editor.file_picker().is_some()); editor.execute("bdelete"); assert!(editor.file_picker().is_some()); editor.execute("buffer 1"); assert_eq!( editor.file_picker().unwrap().presentation, FilePresentation::Docked ); assert_eq!(editor.file_picker().unwrap().query, "a space 界"); } #[test] fn failed_picker_selection_preserves_visual_state_and_insert_group() { let directory = Directory::new(); let bad = directory.path("bad.txt"); fs::write(&bad, b"\xff").unwrap(); let mut editor = project_editor(&directory); editor .apply(vec![Effect::Replace("abcdef".into())]) .unwrap(); type_text(&mut editor, "llvhh"); let selections = editor.selections(); let carets = editor.carets(); let undo = editor.active.undo.len(); type_text(&mut editor, " obad"); editor.handle(Input::Enter); assert_eq!(editor.mode(), Mode::Visual); assert_eq!(editor.selections(), selections); assert_eq!(editor.carets(), carets); assert_eq!(editor.context().text, "abcdef"); assert_eq!(editor.active.undo.len(), undo); assert_eq!(editor.next_buffer_id, 2); assert_eq!(editor.buffer_count(), 1); assert!(editor.file_picker().unwrap().focused); assert!( editor .file_picker() .unwrap() .error .as_ref() .unwrap() .contains("Open failed") ); editor.handle(Input::Down); assert!(editor.file_picker().unwrap().error.is_none()); editor.handle(Input::Enter); editor.handle(Input::Backspace); assert!(editor.file_picker().unwrap().error.is_none()); editor.handle(Input::Escape); editor.handle(Input::Escape); editor.handle(Input::Escape); editor.handle(Input::Char('i')); type_text(&mut editor, "X"); editor.open_file_picker(FilePresentation::Docked); editor.handle(Input::Enter); assert_eq!(editor.mode(), Mode::Insert); assert!(editor.active.view.insert_start.is_some()); assert!(editor.file_picker().unwrap().error.is_some()); fs::remove_file(&bad).unwrap(); editor.handle(Input::Enter); assert_eq!(editor.buffer_id(), 1); assert_eq!(editor.mode(), Mode::Insert); assert!(editor.file_picker().unwrap().error.is_some()); fs::create_dir(&bad).unwrap(); editor.handle(Input::Enter); assert_eq!(editor.buffer_count(), 1); assert!(editor.file_picker().unwrap().focused); editor.handle(Input::Escape); assert!(!editor.file_picker().unwrap().focused); type_text(&mut editor, "Y"); editor.handle(Input::Escape); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, "abcdef"); assert!(editor.file_picker().is_some()); } #[test] fn picker_palette_and_external_commands_are_mutually_exclusive() { let directory = Directory::new(); fs::write(directory.path("file.txt"), "text").unwrap(); let mut editor = project_editor(&directory); editor.handle(Input::Ctrl('p')); editor.open_file_picker(FilePresentation::Docked); assert!(editor.palette().is_none()); editor.handle(Input::Ctrl('p')); assert!(editor.palette().is_some()); assert!(!editor.file_picker().unwrap().focused); type_text(&mut editor, "file picker"); editor.handle(Input::Enter); assert!(editor.palette().is_none()); assert_eq!( editor.file_picker().unwrap().presentation, FilePresentation::Floating ); assert!(editor.file_picker().unwrap().focused); editor.handle(Input::Ctrl('p')); assert!(editor.file_picker().is_none()); assert!(editor.palette().is_some()); editor.handle(Input::Escape); type_text(&mut editor, " o"); editor.execute("buffers"); assert!(editor.file_picker().is_none()); editor.handle(Input::Escape); type_text(&mut editor, " eifile"); editor.execute("buffers"); assert!(!editor.file_picker().unwrap().focused); assert_eq!(editor.file_picker().unwrap().query, "file"); editor.handle(Input::Escape); type_text(&mut editor, " e"); editor.handle(Input::Ctrl('w')); assert!(editor.file_picker().is_none()); } #[test] fn unfocused_dock_keeps_editor_counts_leaders_palette_and_literal_input() { let directory = Directory::new(); fs::write(directory.path("file.txt"), "text").unwrap(); let mut editor = project_editor(&directory); editor .apply(vec![Effect::Replace("a\nb\nc\nd".into())]) .unwrap(); type_text(&mut editor, " eifile"); editor.handle(Input::Char('\t')); type_text(&mut editor, "2j"); assert_eq!(editor.cursor().row, 2); assert_eq!(editor.file_picker().unwrap().query, "file"); type_text(&mut editor, "3 b"); assert_eq!(editor.sequence_pending(), Some("Space b")); assert_eq!(editor.count(), Some(3)); editor.handle(Input::Escape); type_text(&mut editor, "i o e123"); editor.handle(Input::Paste(" literal".into())); assert!(editor.context().text.contains(" o e123 literal")); assert_eq!(editor.file_picker().unwrap().query, "file"); editor.handle(Input::Escape); type_text(&mut editor, ": o e123"); assert_eq!(editor.mode(), Mode::Command); assert_eq!(editor.command_line(), " o e123"); editor.handle(Input::Escape); type_text(&mut editor, "/ o e123"); assert_eq!(editor.mode(), Mode::Search); assert_eq!(editor.command_line(), " o e123"); editor.handle(Input::Escape); type_text(&mut editor, " "); assert!(editor.palette().is_some()); assert!(!editor.file_picker().unwrap().focused); } #[test] fn canceling_picker_does_not_commit_insert_and_root_failure_is_not_empty_success() { let directory = Directory::new(); let mut editor = project_editor(&directory); type_text(&mut editor, "iX"); editor.open_file_picker(FilePresentation::Floating); editor.handle(Input::Paste("ignored".into())); editor.handle(Input::Escape); assert!(editor.file_picker().unwrap().focused); editor.handle(Input::Escape); assert_eq!(editor.mode(), Mode::Insert); assert!(editor.active.view.insert_start.is_some()); assert_eq!(editor.context().text, "X"); type_text(&mut editor, "Y"); editor.handle(Input::Escape); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, ""); fs::remove_dir_all(&directory.0).unwrap(); type_text(&mut editor, " o"); assert!(editor.file_picker().is_none()); assert!(editor.message().contains("File scan failed")); assert_eq!(editor.buffer_count(), 1); } #[cfg(unix)] #[test] fn picker_opens_raw_paths_even_when_sanitized_display_labels_collide() { let directory = Directory::new(); let names = [PathBuf::from("same\n.txt"), PathBuf::from("same\t.txt")]; fs::write(directory.0.join(&names[0]), "first").unwrap(); fs::write(directory.0.join(&names[1]), "second").unwrap(); let mut editor = project_editor(&directory); for (name, text) in names.iter().zip(["first", "second"]) { type_text(&mut editor, " osame"); let picker = editor.file_picker.as_mut().unwrap(); assert_eq!(picker.entries().len(), 2); assert_eq!( picker.entries().next().unwrap().label, picker.entries().last().unwrap().label, ); assert!( picker .entries() .all(|entry| !entry.label.chars().any(char::is_control)) ); let selected = picker .entries() .position(|entry| entry.path == name.as_path()) .unwrap(); picker.selected = selected; editor.handle(Input::Enter); assert_eq!(editor.path(), Some(directory.0.join(name).as_path())); assert_eq!(editor.context().text, text); } assert_eq!(editor.buffer_count(), 3); } #[test] fn file_view_preferences_are_independent_atomic_and_applied_to_current_presentation() { let directory = Directory::new(); fs::create_dir(directory.path("folder")).unwrap(); fs::write(directory.path("file.txt"), "text").unwrap(); let mut editor = project_editor(&directory); editor.open_file_picker(FilePresentation::Docked); assert_eq!(editor.file_picker().unwrap().view, FileView::Tree); editor.set_file_picker_view("TREE").unwrap(); assert_eq!(editor.file_picker().unwrap().view, FileView::Tree); editor.set_file_explorer_view("LiSt").unwrap(); assert_eq!(editor.file_picker().unwrap().view, FileView::List); for invalid in ["", "flat", " tree "] { assert!(editor.set_file_picker_view(invalid).is_err()); assert!(editor.set_file_explorer_view(invalid).is_err()); assert_eq!(editor.file_picker_view(), FileView::Tree); assert_eq!(editor.file_explorer_view(), FileView::List); assert_eq!(editor.file_picker().unwrap().view, FileView::List); } editor.handle(Input::Ctrl('t')); assert_eq!(editor.file_picker().unwrap().view, FileView::Tree); assert_eq!(editor.file_explorer_view(), FileView::List); editor.handle(Input::Char('\t')); editor.open_file_picker(FilePresentation::Docked); assert_eq!(editor.file_picker().unwrap().view, FileView::List); editor.open_file_picker(FilePresentation::Floating); assert_eq!(editor.file_picker().unwrap().view, FileView::Tree); assert_eq!( editor.file_picker().unwrap().presentation, FilePresentation::Floating ); editor.set_file_explorer_view("tree").unwrap(); editor.set_file_picker_view("list").unwrap(); assert_eq!(editor.file_picker().unwrap().view, FileView::List); assert_eq!(editor.file_picker().unwrap().entries().len(), 1); } #[test] fn tree_hjkl_navigates_normal_and_control_hjkl_navigates_filter_without_changing_text() { let directory = Directory::new(); fs::create_dir(directory.path("dir")).unwrap(); fs::write(directory.path("dir/hjkl-a.txt"), "a").unwrap(); fs::write(directory.path("dir/hjkl-b.txt"), "b").unwrap(); for presentation in [FilePresentation::Floating, FilePresentation::Docked] { let mut editor = project_editor(&directory); editor.set_file_picker_view("tree").unwrap(); editor.open_file_picker(presentation); if presentation == FilePresentation::Floating { editor.handle(Input::Escape); } let selected = |editor: &Editor| { let picker = editor.file_picker().unwrap(); picker .entries() .nth(picker.selected) .unwrap() .path .to_owned() }; editor.handle(Input::Char('l')); assert!( editor .file_picker() .unwrap() .entries() .next() .unwrap() .expanded ); editor.handle(Input::Char('l')); assert_eq!(selected(&editor), Path::new("dir/hjkl-a.txt")); editor.handle(Input::Char('j')); assert_eq!(selected(&editor), Path::new("dir/hjkl-b.txt")); editor.handle(Input::Char('k')); assert_eq!(selected(&editor), Path::new("dir/hjkl-a.txt")); editor.handle(Input::Char('h')); assert_eq!(selected(&editor), Path::new("dir")); assert!( editor .file_picker() .unwrap() .entries() .next() .unwrap() .expanded ); editor.handle(Input::Char('h')); assert!( !editor .file_picker() .unwrap() .entries() .next() .unwrap() .expanded ); editor.handle(Input::Char('i')); type_text(&mut editor, "hjkl"); assert_eq!(editor.file_picker().unwrap().query, "hjkl"); assert_eq!(selected(&editor), Path::new("dir/hjkl-a.txt")); editor.handle(Input::Ctrl('j')); assert_eq!(selected(&editor), Path::new("dir/hjkl-b.txt")); editor.handle(Input::Ctrl('k')); assert_eq!(selected(&editor), Path::new("dir/hjkl-a.txt")); editor.handle(Input::Ctrl('h')); assert_eq!(selected(&editor), Path::new("dir")); editor.handle(Input::Ctrl('l')); assert_eq!(selected(&editor), Path::new("dir/hjkl-a.txt")); assert_eq!(editor.file_picker().unwrap().query, "hjkl"); editor.handle(Input::Escape); assert!(editor.file_picker().unwrap().focused); assert_eq!(selected(&editor), Path::new("dir/hjkl-a.txt")); assert_eq!(editor.file_picker().unwrap().query, "hjkl"); editor.handle(Input::Ctrl('u')); assert_eq!(selected(&editor), Path::new("dir/hjkl-a.txt")); editor.handle(Input::Char('h')); editor.handle(Input::Char('h')); assert!( !editor .file_picker() .unwrap() .entries() .next() .unwrap() .expanded ); editor.bind("tree", "j", Some("core.up".into())).unwrap(); editor.bind("tree", "k", None).unwrap(); editor.handle(Input::Char('l')); editor.handle(Input::Char('j')); assert_eq!(selected(&editor), Path::new("dir/hjkl-b.txt")); editor.handle(Input::Char('k')); assert_eq!(selected(&editor), Path::new("dir/hjkl-b.txt")); editor.handle(Input::Char('i')); type_text(&mut editor, "jk"); assert_eq!(editor.file_picker().unwrap().query, "jk"); assert_eq!(editor.lines(), &[String::new()]); assert_eq!(editor.mode(), Mode::Normal); assert_eq!(editor.cursor(), Cursor::default()); } } #[test] fn tree_browse_and_search_have_separate_input_and_two_stage_escape() { let directory = Directory::new(); fs::create_dir_all(directory.path("folder/deep")).unwrap(); fs::write(directory.path("folder/deep/needle.txt"), "text").unwrap(); fs::write(directory.path("folder/other.txt"), "other").unwrap(); for presentation in [FilePresentation::Floating, FilePresentation::Docked] { for mode in [ Mode::Normal, Mode::Visual, Mode::Insert, Mode::Command, Mode::Search, ] { let mut editor = project_editor(&directory); editor .apply(vec![Effect::Replace("alpha\nbeta".into())]) .unwrap(); set_carets(&mut editor, &[(0, 1), (1, 2)], 1); editor.active.view.mode = mode; editor.active.view.command_line = "underlying".into(); let targets = editor.active.view.targets.clone(); let text = editor.context().text; let undo = editor.active.undo.len(); editor.set_file_picker_view("tree").unwrap(); editor.open_file_picker(presentation); if presentation == FilePresentation::Floating { editor.handle(Input::Escape); } type_text(&mut editor, "123 b xyz"); editor.handle(Input::Paste("ignored".into())); editor.handle(Input::Backspace); assert_eq!(editor.file_picker().unwrap().query, ""); assert_eq!(editor.count(), None); assert_eq!(editor.sequence_pending(), None); editor.handle(Input::Enter); assert_eq!(editor.buffer_count(), 1); assert!( editor .file_picker() .unwrap() .entries() .next() .unwrap() .expanded ); editor.handle(Input::Ctrl('l')); assert_eq!( editor.file_picker().unwrap().entries().nth(1).unwrap().path, Path::new("folder/deep") ); assert_eq!(editor.file_picker().unwrap().selected, 1); editor.handle(Input::Ctrl('h')); assert_eq!(editor.file_picker().unwrap().selected, 0); type_text(&mut editor, "ideep/"); assert!(editor.file_picker().unwrap().searching); assert_eq!(editor.file_picker().unwrap().query, "deep/"); editor.handle(Input::Ctrl('H')); assert_eq!(editor.file_picker().unwrap().query, "deep/"); editor.handle(Input::Backspace); assert_eq!(editor.file_picker().unwrap().query, "deep"); type_text(&mut editor, "👩‍💻"); editor.handle(Input::Backspace); assert_eq!(editor.file_picker().unwrap().query, "deep"); let before = editor .file_picker() .unwrap() .entries() .map(|row| row.path.to_owned()) .collect::>(); let selected_before = editor.file_picker().unwrap().selected; editor.handle(Input::Escape); let picker = editor.file_picker().unwrap(); assert!(picker.focused); assert!(!picker.searching); assert_eq!(picker.query, "deep"); assert_eq!(picker.selected, selected_before); assert_eq!( picker .entries() .map(|row| row.path.to_owned()) .collect::>(), before ); editor.handle(Input::Escape); match presentation { FilePresentation::Floating => assert!(editor.file_picker().is_none()), FilePresentation::Docked => assert!(!editor.file_picker().unwrap().focused), } assert_eq!(editor.mode(), mode); assert_eq!(editor.active.view.command_line, "underlying"); assert_eq!(editor.active.view.targets, targets); assert_eq!(editor.context().text, text); assert_eq!(editor.active.undo.len(), undo); } } } #[test] fn tree_binding_precedence_is_modal_and_navigation_preferences_are_atomic() { let directory = Directory::new(); fs::create_dir(directory.path("folder")).unwrap(); fs::write(directory.path("folder/a.txt"), "a").unwrap(); fs::write(directory.path("folder/b.txt"), "b").unwrap(); fs::write(directory.path("z.txt"), "z").unwrap(); let mut editor = project_editor(&directory); editor .bind("selection", "x", Some("core.down".into())) .unwrap(); editor.bind("tree", "x", Some("core.right".into())).unwrap(); editor .bind("tree", "ctrl+j", Some("core.left".into())) .unwrap(); editor.bind("tree", "ctrl+k", None).unwrap(); type_text(&mut editor, "3 b"); let pending = editor.sequence_pending().unwrap().to_owned(); let hint = editor.tree_navigation_hint.clone(); for (key, target) in [ ("x", Some("core.delete")), ("x", Some("core.palette")), ("x", Some("w")), ("leader x", Some("core.right")), ("x y", Some("core.left")), ] { assert!(editor.bind("tree", key, target.map(str::to_owned)).is_err()); assert_eq!(editor.tree_navigation_hint, hint); assert_eq!(editor.count(), Some(3)); assert_eq!(editor.sequence_pending(), Some(pending.as_str())); } editor.open_file_picker(FilePresentation::Docked); editor.handle(Input::Char('x')); assert_eq!(editor.file_picker().unwrap().selected, 0); assert!( editor .file_picker() .unwrap() .entries() .next() .unwrap() .expanded ); editor.handle(Input::Char('x')); assert_eq!(editor.file_picker().unwrap().selected, 1); editor.handle(Input::Ctrl('k')); assert_eq!(editor.file_picker().unwrap().selected, 1); editor.handle(Input::Ctrl('j')); assert_eq!(editor.file_picker().unwrap().selected, 0); editor.handle(Input::Char('i')); editor.handle(Input::Char('x')); assert_eq!(editor.file_picker().unwrap().selected, 1); assert_eq!(editor.file_picker().unwrap().query, ""); editor.handle(Input::Ctrl('j')); assert_eq!(editor.file_picker().unwrap().selected, 0); editor.handle(Input::Ctrl('h')); assert_eq!(editor.file_picker().unwrap().selected, 0); editor.handle(Input::Ctrl('t')); assert_eq!(editor.file_picker().unwrap().view, FileView::List); assert_eq!( editor.file_navigation_hint(), editor.selection_navigation_hint() ); editor.handle(Input::Char('x')); assert_eq!(editor.file_picker().unwrap().selected, 1); editor.handle(Input::Ctrl('w')); editor.handle(Input::Char('x')); assert_eq!(editor.mode(), Mode::Normal); assert_eq!(editor.context().text, ""); editor.handle(Input::Ctrl('p')); let selected = editor.palette().unwrap().selected; editor.handle(Input::Char('x')); assert_eq!(editor.palette().unwrap().selected, selected + 1); editor.handle(Input::Ctrl('h')); assert_eq!(editor.palette().unwrap().query, ""); } #[test] fn ctrl_h_is_not_backspace_unless_explicitly_bound() { assert_eq!(parse_key("CTRL+H").unwrap(), Input::Ctrl('h')); assert_eq!(parse_key("backspace").unwrap(), Input::Backspace); for mode in [ Mode::Normal, Mode::Visual, Mode::Insert, Mode::Command, Mode::Search, ] { let mut editor = editor(); editor.apply(vec![Effect::Replace("abc".into())]).unwrap(); set_carets(&mut editor, &[(0, 2)], 0); editor.active.view.mode = mode; editor.active.view.command_line = "query".into(); let targets = editor.active.view.targets.clone(); let undo = editor.active.undo.len(); editor.handle(Input::Ctrl('h')); assert_eq!(editor.context().text, "abc"); assert_eq!(editor.active.view.targets, targets); assert_eq!(editor.active.undo.len(), undo); assert_eq!(editor.active.view.command_line, "query"); assert_eq!(editor.mode(), mode); } let mut editor = editor(); type_text(&mut editor, "iabc"); editor.handle(Input::Ctrl('h')); assert_eq!(editor.context().text, "abc"); editor .bind("insert", "ctrl+h", Some("core.backspace".into())) .unwrap(); editor.handle(Input::Ctrl('h')); assert_eq!(editor.context().text, "ab"); editor.bind("insert", "ctrl+h", None).unwrap(); editor.handle(Input::Ctrl('h')); assert_eq!(editor.context().text, "ab"); editor.handle(Input::Backspace); assert_eq!(editor.context().text, "a"); let directory = Directory::new(); fs::write(directory.path("a.txt"), "text").unwrap(); let mut editor = project_editor(&directory); editor.open_file_picker(FilePresentation::Floating); type_text(&mut editor, "a"); editor.handle(Input::Ctrl('h')); assert_eq!(editor.file_picker().unwrap().query, "a"); editor.handle(Input::Backspace); assert_eq!(editor.file_picker().unwrap().query, ""); editor.handle(Input::Ctrl('p')); type_text(&mut editor, "core"); editor.handle(Input::Ctrl('h')); assert_eq!(editor.palette().unwrap().query, "core"); editor.handle(Input::Backspace); assert_eq!(editor.palette().unwrap().query, "cor"); } #[test] fn modified_backspace_preserves_suffixes_and_uses_unicode_boundaries() { for (key, action, prefix, suffix, expected) in [ ( "option+backspace", "core.backspace_word", "alpha βeta ", "SUFFIX\nlast", "alpha SUFFIX\nlast", ), ( "alt+backspace", "core.backspace_word", "foo don't", "!", "foo !", ), ( "alt+backspace", "core.backspace_word", "alpha e\u{301}", " tail", "alpha tail", ), ( "alt+backspace", "core.backspace_word", "alpha 👩‍🚀", " tail", "alpha tail", ), ( "alt+backspace", "core.backspace_word", "one word\n ", "tail", "one tail", ), ( "cmd+backspace", "core.backspace_line", "first\n βeta ", "SUFFIX\nlast", "first\nSUFFIX\nlast", ), ( "super+backspace", "core.backspace_line", "first\n", "tail", "first\ntail", ), ("alt+backspace", "core.backspace_word", "", "tail", "tail"), ] { let mut editor = editor(); editor.bind("insert", key, Some(action.into())).unwrap(); let original = format!("{prefix}{suffix}"); editor .apply(vec![Effect::Replace(original.clone())]) .unwrap(); let row = prefix.bytes().filter(|&byte| byte == b'\n').count(); let column = prefix.rsplit('\n').next().unwrap().graphemes(true).count(); set_carets(&mut editor, &[(row, column)], 0); let undo = editor.active.undo.len(); let revision = editor.text_revision(); editor.handle(Input::Char('i')); editor.handle(parse_key(key).unwrap()); assert_eq!(editor.context().text, expected, "{prefix:?}"); editor.handle(Input::Escape); if original == expected { assert_eq!(editor.text_revision(), revision); assert_eq!(editor.active.undo.len(), undo); } else { assert_eq!(editor.active.undo.len(), undo + 1); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, original); assert_eq!(editor.cursor(), Cursor { row, column }); } } } #[test] fn modified_backspace_merges_overlapping_carets_and_repeats_new_boundaries() { for (key, action, expected) in [ ( "alt+backspace", "core.backspace_word", "foo tail\nkeep row", ), ("cmd+backspace", "core.backspace_line", " tail\n row"), ] { let mut editor = editor(); editor.bind("insert", key, Some(action.into())).unwrap(); let original = "foo elephant tail\nkeep second row"; editor .apply(vec![Effect::Replace(original.into())]) .unwrap(); set_carets(&mut editor, &[(0, 5), (0, 12), (1, 11)], 1); let carets = editor.carets(); editor.handle(Input::Char('i')); editor.handle(parse_key(key).unwrap()); editor.handle(Input::Escape); assert_eq!(editor.context().text, expected); assert_eq!(editor.selection_count(), 2); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, original); assert_eq!(editor.carets(), carets); let next = "foo hippopotamus tail\nkeep longer row"; editor.apply(vec![Effect::Replace(next.into())]).unwrap(); set_carets(&mut editor, &[(0, 5), (0, 16), (1, 11)], 1); editor.handle(Input::Char('.')); assert_eq!(editor.context().text, expected); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, next); } } #[test] fn modified_backspace_queries_never_edit_the_underlying_buffer() { for opening in [Input::Char(':'), Input::Char('/'), Input::Ctrl('p')] { let mut editor = editor(); for mode in ["command", "search"] { editor .bind(mode, "alt+backspace", Some("core.backspace_word".into())) .unwrap(); editor .bind(mode, "cmd+backspace", Some("core.backspace_line".into())) .unwrap(); } editor .apply(vec![Effect::Replace("buffer text".into())]) .unwrap(); let revision = editor.text_revision(); let carets = editor.carets(); editor.handle(opening); type_text(&mut editor, "discard this query"); editor.handle(Input::Ctrl('u')); assert_eq!( editor .palette() .map_or(editor.command_line(), |palette| palette.query.as_str()), "" ); type_text(&mut editor, "alpha βeta "); editor.handle(Input::AltBackspace); assert_eq!( editor .palette() .map_or(editor.command_line(), |palette| palette.query.as_str()), "alpha " ); editor.handle(Input::SuperBackspace); assert_eq!( editor .palette() .map_or(editor.command_line(), |palette| palette.query.as_str()), "" ); assert_eq!(editor.context().text, "buffer text"); assert_eq!(editor.text_revision(), revision); assert_eq!(editor.carets(), carets); } } #[test] fn ctrl_u_deletes_line_prefixes_and_honors_disabled_and_remapped_bindings() { let mut editor = editor(); let original = "first\nalpha βeta tail\nlast"; editor .apply(vec![Effect::Replace(original.into())]) .unwrap(); set_carets(&mut editor, &[(1, 11)], 0); editor.handle(Input::Char('i')); editor.handle(Input::Ctrl('u')); assert_eq!(editor.context().text, "first\ntail\nlast"); assert_eq!(editor.cursor(), Cursor { row: 1, column: 0 }); assert_eq!(editor.mode(), Mode::Insert); editor.handle(Input::Escape); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, original); editor.bind("insert", "ctrl+u", None).unwrap(); editor.handle(Input::Char('i')); editor.handle(Input::Ctrl('u')); assert_eq!(editor.context().text, original); editor .bind("insert", "ctrl+u", Some("core.backspace_word".into())) .unwrap(); editor.handle(Input::Ctrl('u')); assert_eq!(editor.context().text, "first\nalpha tail\nlast"); editor.handle(Input::Escape); for (opening, mode) in [(':', "command"), ('/', "search")] { editor.bind(mode, "ctrl+u", None).unwrap(); editor.handle(Input::Char(opening)); type_text(&mut editor, "alpha beta"); editor.handle(Input::Ctrl('u')); assert_eq!(editor.command_line(), "alpha beta"); editor .bind(mode, "ctrl+u", Some("core.backspace_word".into())) .unwrap(); editor.handle(Input::Ctrl('u')); assert_eq!(editor.command_line(), "alpha "); assert_eq!(editor.context().text, "first\nalpha tail\nlast"); editor.handle(Input::Escape); } } #[test] fn tree_leaf_search_opens_direct_match_and_directory_search_enters_browse() { let directory = Directory::new(); fs::create_dir_all(directory.path("src/module")).unwrap(); fs::write(directory.path("src/module/needle.txt"), "needle").unwrap(); fs::write(directory.path("src/sibling.txt"), "sibling").unwrap(); for presentation in [FilePresentation::Floating, FilePresentation::Docked] { let mut editor = project_editor(&directory); editor.set_file_picker_view("tree").unwrap(); editor.open_file_picker(presentation); if presentation == FilePresentation::Docked { editor.handle(Input::Char('i')); } type_text(&mut editor, "needle.txt"); let picker = editor.file_picker().unwrap(); assert_eq!( picker.entries().nth(picker.selected).unwrap().path, Path::new("src/module/needle.txt") ); editor.handle(Input::Enter); assert_eq!(editor.context().text, "needle"); assert_eq!( editor.path(), Some(directory.path("src/module/needle.txt").as_path()) ); editor.open_file_picker(presentation); editor.handle(Input::Ctrl('w')); editor.open_file_picker(presentation); if presentation == FilePresentation::Docked { editor.handle(Input::Char('i')); } type_text(&mut editor, "module"); assert!( editor .file_picker() .unwrap() .entries() .nth(editor.file_picker().unwrap().selected) .unwrap() .is_dir ); let id = editor.buffer_id(); editor.handle(Input::Enter); let picker = editor.file_picker().unwrap(); assert_eq!(editor.buffer_id(), id); assert!(!picker.searching); assert_eq!(picker.query, "module"); let selected = picker.entries().nth(picker.selected).unwrap(); assert_eq!(selected.path, Path::new("src/module")); assert!(!selected.expanded); assert!( !picker .entries() .any(|row| row.path == Path::new("src/module/needle.txt")) ); editor.handle(Input::Enter); let picker = editor.file_picker().unwrap(); assert_eq!(picker.query, "module"); assert!(picker.entries().nth(picker.selected).unwrap().expanded); assert!( picker .entries() .any(|row| row.path == Path::new("src/module/needle.txt")) ); assert_eq!(editor.buffer_count(), 2); } } #[cfg(unix)] #[test] fn tree_search_opens_raw_nested_paths_and_deduplicates_dirty_buffers() { let directory = Directory::new(); fs::create_dir_all(directory.path("dir\n/deep")).unwrap(); let a = directory.path("dir\n/deep/same\n.txt"); let b = directory.path("dir\n/deep/same\t.txt"); fs::write(&a, "first").unwrap(); fs::write(&b, "second").unwrap(); for presentation in [FilePresentation::Floating, FilePresentation::Docked] { fs::write(&a, "first").unwrap(); let mut editor = project_editor(&directory); editor.set_file_picker_view("tree").unwrap(); editor.edit_buffer(a.clone(), false).unwrap(); editor .apply(vec![Effect::Replace("dirty first".into())]) .unwrap(); type_text(&mut editor, "vl"); let selections = editor.selections(); let id = editor.buffer_id(); editor.edit_buffer(b.clone(), false).unwrap(); fs::write(&a, b"\xff").unwrap(); editor.open_file_picker(presentation); if presentation == FilePresentation::Docked { editor.handle(Input::Char('i')); } type_text(&mut editor, "same"); let picker = editor.file_picker.as_mut().unwrap(); let labels = picker .entries() .filter(|row| !row.is_dir) .map(|row| row.label) .collect::>(); assert_eq!(labels.len(), 2); assert_eq!(labels[0], labels[1]); assert!( picker .entries() .all(|row| !row.label.chars().any(char::is_control)) ); let selected = picker .entries() .position(|row| picker.root.join(row.path) == a) .unwrap(); picker.selected = selected; editor.handle(Input::Enter); assert_eq!(editor.path(), Some(a.as_path())); assert_eq!(editor.buffer_id(), id); assert_eq!(editor.buffer_count(), 3); assert_eq!(editor.context().text, "dirty first"); assert!(editor.dirty()); assert_eq!(editor.mode(), Mode::Visual); assert_eq!(editor.selections(), selections); match presentation { FilePresentation::Floating => assert!(editor.file_picker().is_none()), FilePresentation::Docked => { assert!(!editor.file_picker().unwrap().focused); assert!(editor.file_picker().unwrap().searching); assert_eq!(editor.file_picker().unwrap().query, "same"); } } } } #[test] fn typed_quit_checks_all_buffers_and_force_quit_is_immediate_from_keys() { let directory = Directory::new(); let mut editor = editor(); editor .bind("normal", "Q", Some("core.quit".into())) .unwrap(); editor .bind("normal", "Z", Some("core.force_quit".into())) .unwrap(); editor .apply(vec![Effect::Replace("unsaved".into())]) .unwrap(); editor.execute(&format!("edit {}", directory.path("other").display())); editor.handle(Input::Char('Q')); assert!(!editor.should_quit()); assert!(editor.message().contains("Unsaved changes")); editor.handle(Input::Char('Z')); assert!(editor.should_quit()); } #[test] fn typed_save_and_quit_refuses_dirty_parked_buffers_and_save_failure() { let directory = Directory::new(); let path = directory.path("current"); let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap(); editor .bind("normal", "Z", Some("core.save_and_quit".into())) .unwrap(); editor.apply(vec![Effect::Replace("first".into())]).unwrap(); editor.execute(&format!("edit {}", directory.path("other").display())); editor .apply(vec![Effect::Replace("second".into())]) .unwrap(); editor.handle(Input::Char('Z')); assert!(!editor.should_quit()); assert!(!editor.dirty()); assert!(editor.message().contains("other buffers")); editor.execute("buffer 1"); editor.handle(Input::Char('Z')); assert!(editor.should_quit()); assert_eq!(fs::read_to_string(path).unwrap(), "first"); let mut unnamed = Editor::open(None, vec![]).unwrap(); unnamed .bind("normal", "Z", Some("core.save_and_quit".into())) .unwrap(); unnamed .apply(vec![Effect::Replace("unsaved".into())]) .unwrap(); unnamed.handle(Input::Char('Z')); assert!(!unnamed.should_quit()); assert!(unnamed.dirty()); } #[test] fn required_argument_actions_prompt_and_share_ex_execution() { let directory = Directory::new(); let path = directory.path("file with spaces"); fs::write(&path, "opened").unwrap(); let mut editor = editor(); assert!(editor.bind("normal", "E", Some("edit".into())).is_err()); editor .bind("normal", "E", Some("core.edit".into())) .unwrap(); editor.handle(Input::Char('E')); assert_eq!(editor.mode(), Mode::Command); assert_eq!(editor.command_line(), "edit "); type_text(&mut editor, &path.to_string_lossy()); editor.handle(Input::Enter); assert_eq!(editor.context().text, "opened"); editor.handle(Input::Ctrl('p')); type_text(&mut editor, "core.buffer"); editor.handle(Input::Enter); assert_eq!(editor.mode(), Mode::Command); assert_eq!(editor.command_line(), "buffer "); type_text(&mut editor, "1"); editor.handle(Input::Enter); assert_eq!(editor.buffer_id(), 1); editor.execute("edit"); assert_eq!(editor.message(), "Usage: :edit path"); } #[test] fn force_delete_palette_requires_submission_before_discarding_dirty_buffer() { let mut editor = editor(); editor .apply(vec![Effect::Replace("unsaved".into())]) .unwrap(); let dirty_id = editor.buffer_id(); editor.handle(Input::Ctrl('p')); type_text(&mut editor, "bdelete!"); editor.handle(Input::Enter); assert_eq!(editor.mode(), Mode::Command); assert!(editor.has_buffer(dirty_id)); assert!(editor.dirty()); editor.handle(Input::Enter); assert!(!editor.has_buffer(dirty_id)); } #[test] fn built_in_alias_bindings_require_typed_actions() { let mut editor = editor(); for entry in CORE_ACTIONS.iter().filter(|entry| entry.ex.is_some()) { assert!( editor .bind("normal", "Z", Some(entry.ex.unwrap().into())) .is_err() ); assert!(editor.bind("normal", "Z", Some(entry.name.into())).is_ok()); } } #[test] fn buffer_picker_searches_sanitized_paths_and_preserves_dirty_history_on_accept_or_cancel() { let directory = Directory::new(); let mut editor = editor(); let unnamed = editor.buffer_id(); let first = directory.path("Älpha\nnote.txt"); let second = directory.path("Älpha\tnote.txt"); fs::write(&first, "first").unwrap(); fs::write(&second, "second").unwrap(); editor.execute(&format!("edit {}", first.display())); let first_id = editor.buffer_id(); editor .apply(vec![Effect::Replace("first unsaved".into())]) .unwrap(); editor.execute(&format!("edit {}", second.display())); let second_id = editor.buffer_id(); editor .apply(vec![Effect::Replace("second unsaved".into())]) .unwrap(); editor.execute(&format!("buffer {unnamed}")); editor.bind("selection", "ctrl+j", None).unwrap(); editor .bind("selection", "ctrl+x", Some("core.down".into())) .unwrap(); type_text(&mut editor, "3 bl"); assert_eq!(editor.count(), None); assert_eq!(editor.sequence_pending(), None); type_text(&mut editor, "NOTE ÄLPHA"); let picker = editor.palette().unwrap(); assert_eq!(picker.entries.len(), 2); assert!( picker .entries .iter() .all(|entry| !entry.label.chars().any(char::is_control)) ); editor.handle(Input::Ctrl('j')); editor.handle(Input::Enter); assert_eq!(editor.buffer_id(), first_id); assert_eq!(editor.context().text, "first unsaved"); assert!(editor.dirty()); editor.execute("buffers"); type_text(&mut editor, "ÄLPHA NOTE"); editor.handle(Input::Ctrl('x')); editor.handle(Input::Enter); assert_eq!(editor.buffer_id(), second_id); assert_eq!(editor.context().text, "second unsaved"); let cursor = editor.cursor(); editor.execute("buffers"); type_text(&mut editor, ":_|no-match"); editor.handle(Input::Enter); assert_eq!(editor.buffer_id(), second_id); assert_eq!(editor.palette().unwrap().query, ":_|no-match"); assert_eq!(editor.cursor(), cursor); editor.handle(Input::Ctrl('u')); type_text(&mut editor, "e\u{301}👩‍🚀"); editor.handle(Input::Backspace); assert_eq!(editor.palette().unwrap().query, "e\u{301}"); editor.handle(Input::Escape); assert_eq!(editor.context().text, "second unsaved"); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, "second"); assert!(!editor.dirty()); editor.execute("buffers"); type_text(&mut editor, "no name"); editor.handle(Input::Enter); assert_eq!(editor.buffer_id(), unnamed); editor.execute(&format!("buffer {first_id}")); assert_eq!(editor.context().text, "first unsaved"); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, "first"); assert_eq!(fs::read_to_string(&second).unwrap(), "second"); } #[test] fn buffer_picker_in_diff_excludes_snapshots_and_switches_only_the_focused_pane() { let directory = Directory::new(); let first = directory.path("file.txt"); let second = directory.path("other.txt"); fs::write(&first, "live first").unwrap(); fs::write(&second, "live second").unwrap(); let mut editor = Editor::open(Some(first), vec![]).unwrap(); editor.set_clipboard_mode("separate").unwrap(); let first_id = editor.buffer_id(); editor.execute("tab.new"); let other_tab = editor.tab_id(); editor.execute(&format!("edit {}", second.display())); let second_id = editor.buffer_id(); editor .apply(vec![Effect::Replace("hidden unsaved".into())]) .unwrap(); editor.execute("tab.previous"); let source_tab = editor.tab_id(); let source_pane = editor.pane_id(); editor.execute("pane.split_vertical"); let focused = editor.pane_id(); historical_diff( &mut editor, &directory.0, vec!["before".into()], vec!["after".into()], ); let snapshot = editor.buffer_id(); let comparison = editor.diff().unwrap().comparison_id; let cursor = editor.cursor(); editor.execute("buffers"); type_text(&mut editor, "file.txt"); assert_eq!(editor.palette().unwrap().entries.len(), 1); editor.handle(Input::Escape); assert_eq!(editor.buffer_id(), snapshot); assert_eq!(editor.diff().unwrap().comparison_id, comparison); assert_eq!(editor.cursor(), cursor); editor.execute("buffers"); type_text(&mut editor, "other.txt"); editor.handle(Input::Enter); assert_eq!(editor.buffer_id(), second_id); assert_eq!(editor.context().text, "hidden unsaved"); assert_eq!(editor.tab_id(), source_tab); assert_eq!(editor.pane_id(), focused); assert!(editor.diff().is_none()); assert_eq!(editor.panes[&source_pane].buffer_id, first_id); editor.execute("tab.next"); assert_eq!(editor.tab_id(), other_tab); assert_eq!(editor.buffer_id(), second_id); assert_eq!(editor.context().text, "hidden unsaved"); } #[test] fn default_leader_routes_save_switch_list_delete_and_open_palette() { let directory = Directory::new(); let path = directory.path("first"); let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap(); editor.set_clipboard_mode("separate").unwrap(); type_text(&mut editor, "ihello"); editor.handle(Input::Escape); type_text(&mut editor, " w"); assert_eq!(fs::read_to_string(&path).unwrap(), "hello"); assert!(!editor.dirty()); editor.execute(&format!("edit {}", directory.path("second").display())); type_text(&mut editor, " b"); assert_eq!(editor.sequence_pending(), Some("Space b")); editor.handle(Input::Char('n')); assert_eq!(editor.buffer_id(), 1); assert_eq!(editor.sequence_pending(), None); type_text(&mut editor, " bp"); assert_eq!(editor.buffer_id(), 2); type_text(&mut editor, " bd"); assert!(!editor.has_buffer(2)); assert_eq!(editor.buffer_id(), 1); for mode in [Mode::Normal, Mode::Visual] { if mode == Mode::Visual { editor.handle(Input::Char('v')); } editor.handle(Input::Char(' ')); assert_eq!(editor.sequence_pending(), Some("Space")); editor.handle(Input::Char(' ')); assert!(editor.palette().is_some()); assert_eq!(editor.mode(), mode); assert_eq!(editor.sequence_pending(), None); editor.handle(Input::Escape); } } #[test] fn nested_leader_dispatches_plugin_once_with_count_and_multiple_carets() { let mut editor = editor(); editor .apply(vec![Effect::Replace("aa\nbb".into())]) .unwrap(); set_carets(&mut editor, &[(0, 0), (1, 0)], 1); editor.register(plugin("fixture", &["greet"])).unwrap(); editor .bind("normal", "LeAdEr g ctrl+r 7 shift+i", Some("greet".into())) .unwrap(); type_text(&mut editor, "8 g"); editor.handle(Input::Ctrl('R')); assert_eq!(editor.sequence_pending(), Some("Space g Ctrl+r")); editor.handle(Input::Char('7')); assert_eq!(editor.count(), Some(8)); assert_eq!(editor.sequence_pending(), Some("Space g Ctrl+r 7")); assert_eq!( editor.handle(Input::Char('I')), Some(Invocation { plugin: "fixture".into(), command: "greet".into(), args: String::new(), }) ); assert_eq!(editor.selection_count(), 2); assert_eq!(editor.context().text, "aa\nbb"); assert_eq!(editor.count(), None); assert_eq!(editor.sequence_pending(), None); editor.handle(Input::Ctrl('p')); assert_eq!( palette_entry(&editor, "greet").bindings, vec!["Space g Ctrl+r 7 I"] ); } #[test] fn custom_leader_is_symbolic_and_ordinary_trigger_bindings_preempt_by_mode() { let mut editor = editor(); type_text(&mut editor, "3 b"); editor.set_leader("comma").unwrap(); assert_eq!(editor.sequence_pending(), None); assert_eq!(editor.count(), None); editor.handle(Input::Ctrl('p')); assert_eq!( palette_entry(&editor, "core.buffer_next").bindings, vec![", b n"] ); editor .bind("normal", ",", Some("core.command".into())) .unwrap(); assert!( palette_entry(&editor, "core.buffer_next") .bindings .is_empty() ); editor.set_leader(",").unwrap(); editor.handle(Input::Escape); editor.handle(Input::Char(',')); assert_eq!(editor.mode(), Mode::Command); assert_eq!(editor.sequence_pending(), None); editor.handle(Input::Escape); editor.handle(Input::Char('v')); type_text(&mut editor, ", "); assert!(editor.palette().is_some()); assert_eq!(editor.mode(), Mode::Visual); editor.handle(Input::Escape); editor.handle(Input::Escape); editor.bind("normal", ",", None).unwrap(); type_text(&mut editor, "4,"); assert_eq!(editor.count(), None); assert_eq!(editor.sequence_pending(), None); editor.set_leader("\\").unwrap(); type_text(&mut editor, "\\b"); assert_eq!(editor.sequence_pending(), Some("\\ b")); editor.handle(Input::Char('n')); assert_eq!(editor.sequence_pending(), None); editor.handle(Input::Ctrl('p')); assert_eq!( palette_entry(&editor, "core.buffer_next").bindings, vec!["\\ b n"] ); } #[test] fn modified_key_sequences_preserve_counts_and_cancel_without_edits() { let mut editor = editor(); editor .apply(vec![Effect::Replace("a\nb\nc\nd".into())]) .unwrap(); editor .bind("normal", "ctrl+w ctrl+j 0", Some("core.down".into())) .unwrap(); editor .bind("visual", "ctrl+w j", Some("core.down".into())) .unwrap(); editor.handle(Input::Char('2')); editor.handle(Input::Ctrl('w')); editor.handle(Input::Ctrl('j')); editor.handle(Input::Char('0')); assert_eq!(editor.cursor().row, 2); assert_eq!(editor.count(), None); editor.handle(Input::Ctrl('w')); editor.handle(Input::Char('i')); assert_eq!(editor.mode(), Mode::Normal); assert_eq!(editor.cursor().row, 2); assert_eq!(editor.sequence_pending(), None); editor.handle(Input::Char('v')); let selections = editor.selections().to_vec(); editor.handle(Input::Char('3')); editor.handle(Input::Ctrl('w')); editor.handle(Input::Escape); assert_eq!(editor.mode(), Mode::Visual); assert_eq!(editor.selections(), selections); assert_eq!(editor.count(), None); editor.handle(Input::Ctrl('w')); editor.handle(Input::Paste("ignored".into())); assert_eq!(editor.sequence_pending(), None); assert_eq!(editor.selections(), selections); assert_eq!(editor.lines(), &["a", "b", "c", "d"]); } #[test] fn sequence_prefix_conflicts_and_invalid_replacements_are_atomic() { let mut editor = editor(); editor .apply(vec![Effect::Replace("a\nb\nc".into())]) .unwrap(); editor .bind("normal", "ctrl+w j", Some("core.down".into())) .unwrap(); for (key, target) in [ ("ctrl+w", Some("core.up")), ("ctrl+w j k", None), ("ctrl+w j", Some("missing")), ("esc j", Some("core.down")), ("2 j", Some("core.down")), ("space j", Some("core.down")), ("ctrl+i j", Some("core.down")), ] { assert!( editor .bind("normal", key, target.map(str::to_owned)) .is_err(), "{key}" ); } assert!(editor.set_leader("ctrl+w").is_err()); editor.handle(Input::Ctrl('w')); editor.handle(Input::Char('j')); assert_eq!(editor.cursor().row, 1); editor.bind("normal", "ctrl+w j", None).unwrap(); assert!( editor .bind("normal", "ctrl+w j k", Some("core.down".into())) .is_err() ); editor.handle(Input::Ctrl('w')); editor.handle(Input::Char('j')); assert_eq!(editor.cursor().row, 1); assert_eq!(editor.sequence_pending(), None); editor.bind("normal", "ctrl+x", None).unwrap(); assert!( editor .bind("normal", "ctrl+x j", Some("core.down".into())) .is_err() ); } #[test] fn modified_key_sequences_focus_panes_without_replacing_the_leader() { let directory = Directory::new(); let mut editor = editor(); editor.project_root = directory.0.clone(); editor .apply(vec![Effect::Replace("first\nsecond".into())]) .unwrap(); editor .bind("normal", "ctrl+w j", Some("core.pane.focus_down".into())) .unwrap(); editor .bind("normal", "ctrl+w h", Some("core.pane.focus_left".into())) .unwrap(); editor .bind("normal", "leader z", Some("core.down".into())) .unwrap(); editor.handle(Input::Char('|')); editor.handle(Input::Char('_')); editor.handle(Input::Alt('k')); assert_eq!(editor.pane_id(), 2); editor.handle(Input::Ctrl('w')); assert_eq!(editor.pane_id(), 2); assert_eq!(editor.sequence_pending(), Some("Ctrl+w")); editor.handle(Input::Char('j')); assert_eq!(editor.pane_id(), 3); assert_eq!(editor.sequence_pending(), None); type_text(&mut editor, " z"); assert_eq!(editor.cursor().row, 1); editor.open_file_picker(FilePresentation::Docked); editor.handle(Input::Ctrl('w')); editor.handle(Input::Char('h')); assert_eq!(editor.pane_id(), 1); assert!(!editor.file_picker().unwrap().focused); assert_eq!(editor.lines(), &["first", "second"]); } #[test] fn leader_prefix_conflicts_and_invalid_replacements_are_atomic() { let mut editor = editor(); assert!(editor.bind("normal", "leader b", None).is_err()); editor .bind("normal", "leader z", Some("core.down".into())) .unwrap(); assert!(editor.bind("normal", "leader z n", None).is_err()); assert!( editor .bind("normal", "leader z", Some("missing".into())) .is_err() ); editor .apply(vec![Effect::Replace("a\nb\nc".into())]) .unwrap(); type_text(&mut editor, " z"); assert_eq!(editor.cursor().row, 1); editor.bind("normal", "leader z", None).unwrap(); assert!( editor .bind("normal", "leader z n", Some("core.down".into())) .is_err() ); type_text(&mut editor, "2 z"); assert_eq!(editor.cursor().row, 1); assert_eq!(editor.count(), None); editor.bind("normal", "leader g n", None).unwrap(); assert!(editor.bind("normal", "leader g", None).is_err()); for key in ["leader", "leader ctrl+", "leader b ctrl++r", "leader "] { assert!(editor.bind("normal", key, None).is_err(), "{key}"); } editor .bind("normal", " LEADER\tz ", Some("core.up".into())) .unwrap(); type_text(&mut editor, " z"); assert_eq!(editor.cursor().row, 0); } #[test] fn leader_validation_preserves_choice_and_reuses_action_mode_eligibility() { let mut editor = editor(); editor.register(plugin("fixture", &["greet"])).unwrap(); for mode in ["insert", "command", "search", "unknown"] { assert!(editor.bind(mode, "leader z", None).is_err(), "{mode}"); } assert!( editor .bind("visual", "leader z", Some("core.insert".into())) .is_err() ); assert!( editor .bind("visual", "leader z", Some("greet".into())) .is_err() ); editor .bind("visual", "leader z", Some("core.right".into())) .unwrap(); editor.bind("normal", "comma", None).unwrap(); editor.bind("visual", "\\", None).unwrap(); for key in [ "", "esc", "ESC", "0", "9", "i", "ctrl+p", ",", "\\", "leader b", "alt+j", ] { assert!(editor.set_leader(key).is_err(), "{key}"); assert_eq!(editor.leader, Input::Char(' ')); } editor.bind("insert", "界", None).unwrap(); editor.set_leader("界").unwrap(); editor.handle(Input::Char('界')); assert_eq!(editor.sequence_pending(), Some("界")); editor.handle(Input::Escape); editor.set_leader("space").unwrap(); editor.bind("normal", "space", None).unwrap(); editor.set_leader("SPACE").unwrap(); assert_eq!(editor.leader, Input::Char(' ')); } #[test] fn leader_escape_unknown_and_paste_cancel_count_without_changing_selections() { for visual in [false, true] { let mut editor = editor(); editor .apply(vec![Effect::Replace("abcd\nabcd".into())]) .unwrap(); set_carets(&mut editor, &[(0, 1), (1, 1)], 1); if visual { editor .bind("visual", "leader b n", Some("core.down".into())) .unwrap(); type_text(&mut editor, "vl"); } let mode = editor.mode(); let carets = editor.carets(); let selections = editor.selections(); for prefix in [" b", "3 b"] { type_text(&mut editor, prefix); assert_eq!(editor.sequence_pending(), Some("Space b")); editor.handle(Input::Escape); assert_eq!(editor.mode(), mode); assert_eq!(editor.carets(), carets); assert_eq!(editor.selections(), selections); assert_eq!(editor.count(), None); assert_eq!(editor.sequence_pending(), None); } type_text(&mut editor, "4 b"); editor.handle(Input::Char('x')); assert_eq!(editor.context().text, "abcd\nabcd"); assert_eq!(editor.carets(), carets); assert_eq!(editor.selections(), selections); assert_eq!(editor.mode(), mode); assert_eq!(editor.count(), None); assert_eq!(editor.sequence_pending(), None); type_text(&mut editor, "5 b"); editor.handle(Input::Paste("n".into())); assert_eq!(editor.context().text, "abcd\nabcd"); assert_eq!(editor.carets(), carets); assert_eq!(editor.count(), None); assert_eq!(editor.sequence_pending(), None); editor.handle(Input::Escape); if !visual { assert!(!editor.notification_visible()); assert_eq!(editor.selection_count(), 2); editor.handle(Input::Escape); } assert_eq!(editor.mode(), Mode::Normal); assert_eq!(editor.selection_count(), if visual { 2 } else { 1 }); } } #[test] fn leader_digit_suffix_uses_pending_count_for_remapped_motion_not_new_count() { let mut editor = editor(); editor .apply(vec![Effect::Replace(["abcd"; 12].join("\n"))]) .unwrap(); set_carets(&mut editor, &[(0, 0), (1, 0)], 1); editor .bind("normal", "leader g 0 2", Some("core.down".into())) .unwrap(); type_text(&mut editor, "3 g0"); assert_eq!(editor.count(), Some(3)); assert_eq!(editor.sequence_pending(), Some("Space g 0")); editor.handle(Input::Char('2')); assert_eq!( editor.carets(), vec![Cursor { row: 3, column: 0 }, Cursor { row: 4, column: 0 }] ); assert_eq!(editor.count(), None); assert_eq!(editor.sequence_pending(), None); editor .bind("visual", "leader 7", Some("core.right".into())) .unwrap(); editor.handle(Input::Char('v')); type_text(&mut editor, "2 7"); assert_eq!(editor.cursor(), Cursor { row: 4, column: 2 }); assert_eq!(editor.mode(), Mode::Visual); assert_eq!(editor.selection_count(), 2); assert_eq!(editor.count(), None); } #[test] fn leader_keys_are_literal_in_insert_prompts_and_palette_queries() { for (key, ch) in [("space", ' '), ("comma", ',')] { for mode_key in ['i', ':', '/'] { let mut editor = editor(); editor.set_leader(key).unwrap(); editor.handle(Input::Char(mode_key)); let text = format!("{ch}b n{ch}"); type_text(&mut editor, &text); assert_eq!(editor.sequence_pending(), None); assert_eq!(editor.count(), None); if mode_key == 'i' { assert_eq!(editor.context().text, text); assert_eq!(editor.mode(), Mode::Insert); } else { assert_eq!(editor.command_line(), text); assert_eq!( editor.mode(), if mode_key == ':' { Mode::Command } else { Mode::Search } ); } let mode = editor.mode(); editor.handle(Input::Ctrl('p')); type_text(&mut editor, "core word next "); assert_eq!(editor.palette().unwrap().query, "core word next "); assert_eq!(editor.sequence_pending(), None); editor.handle(Input::Escape); assert_eq!(editor.mode(), mode); } } } #[test] fn leader_routes_retire_with_plugins_and_binding_changes_cancel_pending() { let mut editor = editor(); editor.register(plugin("fixture", &["greet"])).unwrap(); editor.bind("normal", "g", Some("greet".into())).unwrap(); editor .bind("normal", "leader g n", Some("greet".into())) .unwrap(); type_text(&mut editor, "3 g"); editor .bind("normal", "leader g n", Some("core.down".into())) .unwrap(); assert_eq!(editor.sequence_pending(), None); assert_eq!(editor.count(), None); type_text(&mut editor, "2 g"); editor.bind("normal", "z", None).unwrap(); assert_eq!(editor.sequence_pending(), None); assert_eq!(editor.count(), None); editor .bind("normal", "leader g n", Some("greet".into())) .unwrap(); type_text(&mut editor, "4 g"); editor.disable_plugin("fixture", "retired"); assert_eq!(editor.sequence_pending(), None); assert_eq!(editor.count(), None); assert_eq!(editor.handle(Input::Char('g')), None); type_text(&mut editor, " g"); assert_eq!(editor.sequence_pending(), None); assert!( editor .bind("normal", "leader g n", Some("greet".into())) .is_err() ); editor.handle(Input::Ctrl('p')); assert!( !editor .palette() .unwrap() .entries .iter() .any(|entry| entry.name == "greet") ); } #[test] fn pending_leader_never_survives_external_commands_or_buffer_transitions() { let directory = Directory::new(); let mut editor = editor(); editor.apply(vec![Effect::Replace("abcd".into())]).unwrap(); editor.execute(&format!("edit {}", directory.path("other").display())); editor.set_leader(",").unwrap(); for command in ["buffer 1", "buffer 1", "bnext", "bprev", "buffer 999"] { type_text(&mut editor, "3,b"); assert_eq!(editor.sequence_pending(), Some(", b")); editor.execute(command); assert_eq!(editor.sequence_pending(), None); assert_eq!(editor.count(), None); editor.handle(Input::Char(',')); assert_eq!(editor.sequence_pending(), Some(",")); editor.handle(Input::Escape); } editor.execute("buffer 1"); type_text(&mut editor, "vl"); let selections = editor.selections(); type_text(&mut editor, "2,"); editor.execute("buffer 2"); editor.execute("buffer 1"); assert_eq!(editor.mode(), Mode::Visual); assert_eq!(editor.selections(), selections); assert_eq!(editor.sequence_pending(), None); assert_eq!(editor.count(), None); editor.handle(Input::Char(',')); editor .apply(vec![Effect::Replace("replacement".into())]) .unwrap(); assert_eq!(editor.mode(), Mode::Normal); assert_eq!(editor.sequence_pending(), None); editor .bind("normal", "leader i", Some("core.insert".into())) .unwrap(); type_text(&mut editor, ",i"); assert_eq!(editor.mode(), Mode::Insert); assert_eq!(editor.sequence_pending(), None); } #[test] fn counted_cursor_motions_apply_to_all_carets_and_clamp_through_short_rows() { let mut editor = editor(); editor .apply(vec![Effect::Replace( "e\u{301}界👩‍🚀z\n\nlong\nfour\naa\nbbbb\ncccc".into(), )]) .unwrap(); set_carets(&mut editor, &[(0, 3), (4, 1)], 1); let undo = editor.active.undo.len(); type_text(&mut editor, "2j"); assert_eq!( editor.carets(), vec![Cursor { row: 2, column: 0 }, Cursor { row: 6, column: 1 }] ); assert_eq!(editor.cursor(), Cursor { row: 6, column: 1 }); type_text(&mut editor, "100k"); assert_eq!(editor.carets(), vec![Cursor::default()]); type_text(&mut editor, "2l"); assert_eq!(editor.cursor(), Cursor { row: 0, column: 2 }); type_text(&mut editor, "9h"); assert_eq!(editor.cursor(), Cursor::default()); type_text(&mut editor, "999999999999999999999999999999999999"); assert_eq!(editor.count(), Some(usize::MAX)); editor.handle(Input::Down); assert_eq!(editor.cursor(), Cursor { row: 6, column: 0 }); assert_eq!(editor.count(), None); assert_eq!(editor.active.undo.len(), undo); assert_eq!( editor.lines(), &["e\u{301}界👩‍🚀z", "", "long", "four", "aa", "bbbb", "cccc"] ); } #[test] fn counted_words_match_repeated_unicode_motions_without_intermediate_selection_merging() { let text = "foo_bar...baz qux\n\n \n界e\u{301}_2 👩‍🚀!! tail\n"; for key in ['w', 'b', 'e', 'W', 'B', 'E'] { for positions in [&[(0, 0), (3, 0)][..], &[(0, 8), (3, 8)][..]] { let mut counted = editor(); counted.apply(vec![Effect::Replace(text.into())]).unwrap(); set_carets(&mut counted, positions, 1); let mut repeated = editor(); repeated.apply(vec![Effect::Replace(text.into())]).unwrap(); set_carets(&mut repeated, positions, 1); type_text(&mut counted, &format!("3{key}")); for _ in 0..3 { repeated.handle(Input::Char(key)); } assert_eq!(counted.carets(), repeated.carets(), "{key} {positions:?}"); assert_eq!(counted.cursor(), repeated.cursor(), "{key} {positions:?}"); } } let mut editor = editor(); editor .apply(vec![Effect::Replace( "one two three\none longer third".into(), )]) .unwrap(); editor.handle(Input::Ctrl('e')); editor.handle(Input::Char('v')); type_text(&mut editor, "2e"); assert_eq!( editor.selections(), vec![ Selection { start: Cursor { row: 0, column: 0 }, end: Cursor { row: 0, column: 7 } }, Selection { start: Cursor { row: 1, column: 0 }, end: Cursor { row: 1, column: 10 } }, ] ); editor.handle(Input::Char('c')); type_text(&mut editor, "item"); editor.handle(Input::Escape); assert_eq!(editor.lines(), &["item three", "item third"]); editor.handle(Input::Char('u')); assert_eq!(editor.lines(), &["one two three", "one longer third"]); assert_eq!(editor.selection_count(), 2); type_text(&mut editor, &format!("{}w", usize::MAX)); assert_eq!(editor.cursor(), Cursor { row: 1, column: 15 }); } #[test] fn count_digits_zero_and_escape_preserve_modal_editing_and_custom_bindings() { let mut editor = editor(); editor .apply(vec![Effect::Replace(["abc"; 12].join("\n"))]) .unwrap(); type_text(&mut editor, "10j"); assert_eq!(editor.cursor(), Cursor { row: 10, column: 0 }); editor.handle(Input::End); type_text(&mut editor, "0"); assert_eq!(editor.cursor().column, 0); editor.handle(Input::Ctrl('k')); editor.handle(Input::Char('v')); let selected = editor.selections(); type_text(&mut editor, "4"); editor.handle(Input::Escape); assert_eq!(editor.mode(), Mode::Visual); assert_eq!(editor.selections(), selected); assert_eq!(editor.count(), None); editor.handle(Input::Escape); assert_eq!(editor.mode(), Mode::Normal); assert_eq!(editor.selection_count(), 2); editor.bind("normal", "2", None).unwrap(); type_text(&mut editor, "42"); assert_eq!(editor.count(), None); editor .bind("normal", "3", Some("core.down".into())) .unwrap(); type_text(&mut editor, "3"); assert_eq!(editor.cursor().row, 10); assert_eq!(editor.count(), None); type_text(&mut editor, "5i12"); editor.handle(Input::Escape); assert_eq!(editor.lines()[10], "12abc"); editor.handle(Input::Char('u')); assert_eq!(editor.lines()[10], "abc"); editor.handle(Input::Char('/')); type_text(&mut editor, "123"); assert_eq!(editor.command_line(), "123"); assert_eq!(editor.count(), None); } #[test] fn counted_search_wraps_per_target_and_unrelated_actions_execute_once() { let mut editor = editor(); editor .apply(vec![Effect::Replace("cat cat cat\ncat cat cat".into())]) .unwrap(); set_carets(&mut editor, &[(0, 0), (1, 0)], 1); submit_search(&mut editor, "cat"); type_text(&mut editor, "2n"); assert_eq!( editor.carets(), vec![Cursor { row: 0, column: 0 }, Cursor { row: 1, column: 0 }] ); type_text(&mut editor, "2N"); assert_eq!( editor.carets(), vec![Cursor { row: 0, column: 4 }, Cursor { row: 1, column: 4 }] ); editor.register(plugin("once", &["once"])).unwrap(); editor.bind("normal", "g", Some("once".into())).unwrap(); type_text(&mut editor, "8"); let invocation = editor.handle(Input::Char('g')).unwrap(); assert_eq!(invocation.command, "once"); assert_eq!(editor.count(), None); type_text(&mut editor, "7"); editor.handle(Input::Ctrl('p')); type_text(&mut editor, "core.word_next"); editor.handle(Input::Enter); assert_eq!( editor.carets(), vec![Cursor { row: 0, column: 8 }, Cursor { row: 1, column: 8 }] ); assert_eq!(editor.count(), None); type_text(&mut editor, "9"); editor.handle(Input::Paste("ignored".into())); editor.handle(Input::Char('h')); assert_eq!(editor.cursor(), Cursor { row: 1, column: 7 }); } fn submit_search(editor: &mut Editor, query: &str) { editor.handle(Input::Char('/')); type_text(editor, query); editor.handle(Input::Enter); } fn plugin(name: &str, commands: &[&str]) -> Feature { Feature { name: name.into(), required_capabilities: vec!["text".into()], commands: commands.iter().map(|name| (*name).into()).collect(), core: false, } } #[test] fn character_seek_counts_are_strict_line_local_and_atomic_when_missing() { let mut editor = editor(); editor .apply(vec![Effect::Replace("xaxaax\nx\n\nx".into())]) .unwrap(); let undo = editor.active.undo.len(); for (column, keys, expected) in [ (0, "fx", 2), (0, "2fx", 5), (0, "3fx", 0), (5, "Fx", 2), (5, "2Fx", 0), (5, "3Fx", 5), (2, "fz", 2), (0, "999999999999999999999999fx", 0), ] { set_carets(&mut editor, &[(0, column)], 0); type_text(&mut editor, keys); assert_eq!( editor.cursor(), Cursor { row: 0, column: expected } ); assert_eq!(editor.count(), None); assert_eq!(editor.seek_pending(), None); } for row in [1, 2, 3] { set_carets(&mut editor, &[(row, 0)], 0); type_text(&mut editor, "fxFx"); assert_eq!(editor.cursor(), Cursor { row, column: 0 }); } assert_eq!(editor.active.undo.len(), undo); } #[test] fn character_seek_consumes_literal_targets_before_bindings_counts_and_prefixes() { for target in ['0', '2', 'g', 'f', 'F', '[', ']', ' ', '\t'] { let mut editor = editor(); editor .apply(vec![Effect::Replace(format!("a{target}b{target}"))]) .unwrap(); let key = display_key(&Input::Char(target)); editor .bind("normal", &key, Some("core.right".into())) .unwrap(); editor .bind("normal", "z", Some("core.seek_forward".into())) .unwrap(); type_text(&mut editor, "2z"); editor.handle(Input::Char(target)); assert_eq!(editor.cursor().column, 3); assert_eq!(editor.count(), None); assert!(!editor.g_pending()); assert!(editor.sequence_pending().is_none()); assert!(editor.seek_pending().is_none()); } let mut editor = editor(); editor.apply(vec![Effect::Replace("axbx".into())]).unwrap(); editor .bind("normal", "f", Some("core.right".into())) .unwrap(); type_text(&mut editor, "f"); assert_eq!(editor.cursor().column, 1); assert!(editor.seek_pending().is_none()); editor .bind("normal", "leader f", Some("core.seek_forward".into())) .unwrap(); set_carets(&mut editor, &[(0, 0)], 0); type_text(&mut editor, "2 fx"); assert_eq!(editor.cursor().column, 3); for mode in ["insert", "command", "search", "selection", "tree"] { assert!( editor .bind(mode, "z", Some("core.seek_forward".into())) .is_err() ); assert!( editor .bind(mode, "z", Some("core.seek_backward".into())) .is_err() ); } editor.apply(vec![Effect::Replace(String::new())]).unwrap(); type_text(&mut editor, "ifFgf"); editor.handle(Input::Escape); assert_eq!(editor.context().text, "fFgf"); } #[test] fn character_seek_unicode_scalars_land_on_graphemes_for_all_carets_and_visual_shapes() { let mut editor = editor(); editor .apply(vec![Effect::Replace( "界e\u{301}👩‍🚀e\u{301}x\n\naxax".into(), )]) .unwrap(); type_text(&mut editor, "f\u{301}"); assert_eq!(editor.cursor().column, 1); type_text(&mut editor, "f🚀"); assert_eq!(editor.cursor().column, 2); type_text(&mut editor, "F\u{301}"); assert_eq!(editor.cursor().column, 1); type_text(&mut editor, "F\u{301}"); assert_eq!(editor.cursor().column, 1); // The current grapheme is excluded. for selector in ["", "v", "V"] { set_carets(&mut editor, &[(0, 0), (1, 0), (2, 0)], 2); type_text(&mut editor, selector); type_text(&mut editor, "2fx"); assert_eq!( editor.carets(), vec![ Cursor { row: 0, column: 0 }, Cursor { row: 1, column: 0 }, Cursor { row: 2, column: 3 }, ] ); assert_eq!(editor.visual_linewise(), selector == "V"); } } #[test] fn character_seek_escape_and_lifecycle_cancel_pending_without_changing_selection() { let directory = Directory::new(); let mut editor = editor(); editor .apply(vec![Effect::Replace("axbx\nnext".into())]) .unwrap(); for selector in ["", "v", "V"] { set_carets(&mut editor, &[(0, 0)], 0); type_text(&mut editor, selector); let mode = editor.mode(); let targets = editor.active.view.targets.clone(); type_text(&mut editor, "2f"); assert_eq!(editor.seek_pending(), Some('f')); editor.handle(Input::Escape); assert_eq!(editor.mode(), mode); assert_eq!(editor.active.view.targets, targets); assert_eq!(editor.count(), None); assert_eq!(editor.seek_pending(), None); } set_carets(&mut editor, &[(0, 0)], 0); type_text(&mut editor, "2f"); editor.handle(Input::Right); assert_eq!(editor.cursor().column, 1); assert_eq!(editor.seek_pending(), None); type_text(&mut editor, "2f"); editor.handle(Input::Paste("x".into())); assert_eq!(editor.cursor().column, 1); assert_eq!(editor.seek_pending(), None); for command in ["plugins", "bnext", "bprev", "buffer 1"] { type_text(&mut editor, "f"); editor.execute(command); assert_eq!(editor.seek_pending(), None); } type_text(&mut editor, "f"); editor .bind("normal", "z", Some("core.right".into())) .unwrap(); assert_eq!(editor.seek_pending(), None); type_text(&mut editor, "f"); editor .bind("selection", "z", Some("core.down".into())) .unwrap(); assert_eq!(editor.seek_pending(), None); type_text(&mut editor, "f"); editor.set_leader(",").unwrap(); assert_eq!(editor.seek_pending(), None); type_text(&mut editor, "f"); editor .navigate_to( directory.path("missing"), TextPosition { row: 0, column: 0 }, ) .unwrap_err(); assert_eq!(editor.seek_pending(), None); type_text(&mut editor, "f"); editor.execute("palette"); assert_eq!(editor.seek_pending(), None); editor.handle(Input::Escape); type_text(&mut editor, "f"); editor .apply(vec![Effect::Replace("replacement".into())]) .unwrap(); assert_eq!(editor.seek_pending(), None); } #[test] fn character_seek_visual_edit_replay_resolves_target_direction_and_count_fresh() { for (seek, original, replacement, column) in [ ("2fx", "axbxTAIL", "aLONGxbLONGxTAIL", 0), ("2Fx", "xaxbTAIL", "xLONGxaLONGbTAIL", 11), ] { for change in [false, true] { let mut editor = editor(); editor .apply(vec![Effect::Replace(original.into())]) .unwrap(); let start = if seek == "2Fx" { 3 } else { 0 }; set_carets(&mut editor, &[(0, start)], 0); type_text( &mut editor, &format!("v{seek}{}", if change { "c!" } else { "d" }), ); if change { editor.handle(Input::Escape); } editor .bind("visual", "f", Some("core.left".into())) .unwrap(); editor .bind("visual", "F", Some("core.right".into())) .unwrap(); editor .bind("visual", "x", Some("core.line_end".into())) .unwrap(); editor .apply(vec![Effect::Replace(replacement.into())]) .unwrap(); set_carets(&mut editor, &[(0, column)], 0); editor.handle(Input::Char('.')); assert_eq!(editor.context().text, if change { "!TAIL" } else { "TAIL" }); assert_eq!(editor.mode(), Mode::Normal); assert_eq!(editor.seek_pending(), None); } } let mut editor = editor(); editor .apply(vec![Effect::Replace("ax\nkeep".into())]) .unwrap(); type_text(&mut editor, "Vfxd"); editor .apply(vec![Effect::Replace("alongx\nkeep".into())]) .unwrap(); set_carets(&mut editor, &[(0, 0)], 0); editor.handle(Input::Char('.')); assert_eq!(editor.context().text, "keep"); assert!(matches!( &editor.last_edit.as_ref().unwrap().start, EditStart::Selection { selector: SelectionIntent::Motions { linewise: true, .. }, .. } )); } #[test] fn readonly_diff_character_seek_consumes_brackets_and_tab_before_pane_dispatch() { let directory = Directory::new(); let mut editor = editor(); historical_diff( &mut editor, &directory.0, vec!["a[b]\tc".into()], vec!["a[b]\td".into()], ); for focus in [Focus::Before, Focus::After] { if editor.diff().unwrap().focus != focus { editor.handle(Input::Char('\t')); } let text = editor.context().text; let rows = editor.diff().unwrap().display_rows.clone(); type_text(&mut editor, "f["); assert_eq!(editor.cursor().column, 1); type_text(&mut editor, "f]"); assert_eq!(editor.cursor().column, 3); type_text(&mut editor, "f\t"); assert_eq!(editor.cursor().column, 4); type_text(&mut editor, "F["); assert_eq!(editor.cursor().column, 1); assert_eq!(editor.diff().unwrap().focus, focus); assert_eq!(editor.diff().unwrap().display_rows, rows); assert_eq!(editor.context().text, text); assert!(editor.active.undo.is_empty()); } } #[test] fn normal_edits_merge_overlapping_ranges_and_replay_without_source_keys() { for (keys, positions, expected) in [ ("C", [(0, 0), (0, 4)], "!\nkeep"), ("D", [(0, 0), (0, 4)], "\nkeep"), ("4s", [(0, 0), (0, 2)], "!o three four\nkeep"), ("d2w", [(0, 0), (0, 4)], "four\nkeep"), ("d2b", [(0, 8), (0, 14)], "four\nkeep"), ("c2w", [(0, 0), (0, 4)], "!four\nkeep"), ("c2b", [(0, 8), (0, 14)], "!four\nkeep"), ("4rX", [(0, 0), (0, 2)], "XXXXXXo three four\nkeep"), ] { let mut editor = editor(); editor .apply(vec![Effect::Replace("one two three four\nkeep".into())]) .unwrap(); set_carets(&mut editor, &positions, 1); let original = editor.active.view.targets.clone(); let undo = editor.active.undo.len(); type_text(&mut editor, keys); if editor.mode() == Mode::Insert { type_text(&mut editor, "!"); editor.handle(Input::Escape); } assert_eq!(editor.context().text, expected, "{keys}"); assert_eq!(editor.active.undo.len(), undo + 1, "{keys}"); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, "one two three four\nkeep", "{keys}"); assert_eq!(editor.active.view.targets, original, "{keys}"); assert_eq!(editor.active.view.primary, 1, "{keys}"); for key in ["C", "D", "s", "r", "w", "b"] { editor .bind("normal", key, Some("core.right".into())) .unwrap(); } editor.handle(Input::Char('.')); assert_eq!(editor.context().text, expected, "{keys}"); assert_eq!(editor.mode(), Mode::Normal); assert_eq!(editor.selection_count(), 1); } } #[test] fn normal_yank_merges_suffix_fragments_without_merging_carets_or_history() { let mut editor = editor(); editor .apply(vec![Effect::Replace("abcdef\nkeep".into())]) .unwrap(); set_carets(&mut editor, &[(0, 1), (0, 3)], 1); let targets = editor.active.view.targets.clone(); let undo = editor.active.undo.len(); editor.handle(Input::Char('Y')); assert_eq!(editor.clipboard, ["bcdef"]); assert!(!editor.clipboard_linewise); assert_eq!(editor.active.view.targets, targets); assert_eq!(editor.active.view.primary, 1); assert_eq!(editor.active.undo.len(), undo); assert!(editor.last_edit.is_none()); } #[test] fn readonly_normal_edit_actions_and_pending_replace_preserve_document_and_history() { let directory = Directory::new(); let mut editor = editor(); historical_diff( &mut editor, &directory.0, vec!["one two three".into()], vec!["four five six".into()], ); for focus in [Focus::Before, Focus::After] { if editor.diff().unwrap().focus != focus { editor.handle(Input::Char('\t')); } let text = editor.context().text; let targets = editor.active.view.targets.clone(); let undo = editor.active.undo.len(); let clipboard = editor.clipboard.clone(); for keys in ["C", "D", "s", "2r", "d2w", "d2b", "c2w", "c2b"] { type_text(&mut editor, keys); assert_eq!(editor.context().text, text, "{keys}"); assert_eq!(editor.active.view.targets, targets, "{keys}"); assert_eq!(editor.mode(), Mode::Normal); assert_eq!(editor.count(), None); assert_eq!(editor.line_operator_pending(), None); assert_eq!(editor.clipboard, clipboard); assert_eq!(editor.active.undo.len(), undo); } // A pending replacement must still guard mutation if editability changed. editor.line_pending = Some(('r', 2)); editor.handle(Input::Char('X')); assert_eq!(editor.context().text, text); assert_eq!(editor.active.view.targets, targets); assert_eq!(editor.active.undo.len(), undo); assert_eq!(editor.line_operator_pending(), None); editor.handle(Input::Char('Y')); assert_eq!(editor.clipboard, [text]); assert!(!editor.clipboard_linewise); } } #[test] fn readonly_visual_replacement_preserves_snapshot_selections_and_history() { let directory = Directory::new(); let mut editor = editor(); historical_diff( &mut editor, &directory.0, vec!["one two three".into()], vec!["four five six".into()], ); for focus in [Focus::Before, Focus::After] { if editor.diff().unwrap().focus != focus { editor.handle(Input::Char('\t')); } type_text(&mut editor, "vl"); let text = editor.context().text; let targets = editor.active.view.targets.clone(); let undo = editor.active.undo.len(); let clipboard = editor.clipboard.clone(); for pending in [false, true] { if pending { editor.line_pending = Some(('r', 1)); } else { editor.handle(Input::Char('r')); } editor.handle(Input::Char('X')); assert_eq!(editor.context().text, text); assert_eq!(editor.active.view.targets, targets); assert_eq!(editor.mode(), Mode::Visual); assert_eq!(editor.line_operator_pending(), None); assert_eq!(editor.clipboard, clipboard); assert_eq!(editor.active.undo.len(), undo); } editor.handle(Input::Escape); } } fn set_carets(editor: &mut Editor, positions: &[(usize, usize)], primary: usize) { editor.active.view.mode = Mode::Normal; editor.active.view.visual_motions = None; editor.active.view.targets = positions .iter() .map(|&(row, column)| Target::caret(Cursor { row, column })) .collect(); editor.active.view.primary = primary; editor.normalize_targets(); } fn set_ranges(editor: &mut Editor, ranges: &[(Cursor, Cursor)], primary: usize) { editor.active.view.mode = Mode::Visual; editor.active.view.visual_motions = None; editor.active.view.targets = ranges .iter() .map(|&(start, end)| editor.range_target(Selection { start, end }, true)) .collect(); editor.active.view.primary = primary; editor.normalize_targets(); } #[test] fn linewise_selection_preserves_bidirectional_endpoints_and_selector_toggles() { let mut editor = editor(); editor .apply(vec![Effect::Replace("a界z\nb\nccc\n".into())]) .unwrap(); type_text(&mut editor, "jjlVkh"); assert!(editor.visual_linewise()); assert_eq!(editor.mode(), Mode::Visual); assert_eq!(editor.cursor(), Cursor { row: 1, column: 0 }); assert_eq!(editor.linewise_rows().collect::>(), [1..=2]); assert_eq!( editor.selections(), [Selection { start: Cursor { row: 1, column: 0 }, end: Cursor { row: 3, column: 0 }, }] ); editor.handle(Input::Char('v')); assert!(!editor.visual_linewise()); assert_eq!(editor.selections()[0].end, Cursor { row: 2, column: 2 }); editor.handle(Input::Char('V')); assert_eq!(editor.linewise_rows().collect::>(), [1..=2]); editor.handle(Input::Char('V')); assert_eq!(editor.mode(), Mode::Normal); assert!(!editor.visual_linewise()); type_text(&mut editor, "Vj"); assert_eq!(editor.linewise_rows().collect::>(), [1..=2]); editor.handle(Input::Escape); assert!(editor.linewise_rows().next().is_none()); type_text(&mut editor, "vv"); assert_eq!(editor.mode(), Mode::Normal); } #[test] fn linewise_parking_cloning_merging_and_row_insertions_preserve_primary() { let directory = Directory::new(); let other = directory.path("other"); fs::write(&other, "other").unwrap(); let mut editor = editor(); editor .apply(vec![Effect::Replace("abc\ndef\nghi\n".into())]) .unwrap(); type_text(&mut editor, "lV"); editor.handle(Input::Ctrl('e')); assert_eq!(editor.linewise_rows().collect::>(), [0..=0, 1..=1]); assert_eq!(editor.cursor(), Cursor { row: 1, column: 1 }); editor.execute(&format!("edit {}", other.display())); assert!(!editor.visual_linewise()); editor.execute("buffer 1"); assert!(editor.visual_linewise()); assert_eq!(editor.cursor(), Cursor { row: 1, column: 1 }); editor.handle(Input::Char('j')); assert_eq!(editor.selection_count(), 1); assert_eq!(editor.linewise_rows().collect::>(), [0..=2]); assert_eq!(editor.cursor(), Cursor { row: 2, column: 1 }); editor.handle(Input::Char('v')); assert_eq!(editor.selections()[0].start, Cursor { row: 0, column: 1 }); editor.handle(Input::Char('V')); editor.handle(Input::Char('A')); type_text(&mut editor, "!"); editor.handle(Input::Escape); assert_eq!(editor.context().text, "abc!\ndef!\nghi!\n"); assert_eq!(editor.selection_count(), 3); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, "abc\ndef\nghi\n"); set_carets(&mut editor, &[(2, 0)], 0); type_text(&mut editor, "Vj"); editor.handle(Input::Ctrl('l')); assert_eq!( editor.carets(), [Cursor { row: 2, column: 0 }, Cursor { row: 3, column: 0 }] ); assert!(!editor.visual_linewise()); } #[test] fn linewise_paste_inserts_above_or_below_rows_across_eof_and_empty_rows() { for (text, row, key, expected) in [ ("x\ny", 0, 'p', "x\n a\n\ny"), ("x\ny", 0, 'P', " a\n\nx\ny"), ("x\ny", 1, 'p', "x\ny\n a\n"), ("x\ny", 1, 'P', "x\n a\n\ny"), ("x\n", 1, 'p', "x\n\n a\n"), ("x\n", 1, 'P', "x\n a\n\n"), ("", 0, 'p', "\n a\n"), ("", 0, 'P', " a\n\n"), ] { let mut editor = editor(); editor .apply(vec![Effect::Replace(" a\n\nlast".into())]) .unwrap(); type_text(&mut editor, "Vjy"); editor.apply(vec![Effect::Replace(text.into())]).unwrap(); set_carets(&mut editor, &[(row, 1)], 0); editor.handle(Input::Char(key)); assert_eq!(editor.context().text, expected, "{text:?} {key}"); let first = if key == 'p' { row + 1 } else { row }; assert_eq!( editor.cursor(), Cursor { row: first, column: 1 } ); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, text); assert_eq!(editor.mode(), Mode::Normal); editor.handle(Input::Char('U')); assert_eq!(editor.context().text, expected); } } #[test] fn linewise_empty_final_row_is_a_real_clipboard_row_and_cut_target() { let mut editor = editor(); editor.apply(vec![Effect::Replace("a\n".into())]).unwrap(); type_text(&mut editor, "jVy"); editor.apply(vec![Effect::Replace("z".into())]).unwrap(); editor.handle(Input::Char('p')); assert_eq!(editor.context().text, "z\n"); editor.handle(Input::Char('u')); editor.handle(Input::Char('P')); assert_eq!(editor.context().text, "\nz"); editor.apply(vec![Effect::Replace("a\n".into())]).unwrap(); type_text(&mut editor, "jVd"); assert_eq!(editor.context().text, "a"); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, "a\n"); assert!(!editor.visual_linewise()); type_text(&mut editor, "VcQ"); editor.handle(Input::Escape); assert_eq!(editor.context().text, "a\nQ"); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, "a\n"); } #[test] fn linewise_cut_and_change_handle_eof_all_rows_and_group_typing_history() { for (text, keys, expected) in [ ("a\nbb\nccc", "jVjd", "a"), ("a\nbb\nccc", "jVjcQ", "a\nQ"), ("a\nbb\nccc", "V2jd", ""), ("a\nbb\nccc", "V2jcQ", "Q"), ("a\nbb\n", "V2jd", ""), ("a\nbb\n", "V2jcQ", "Q"), ("a\nbb\nccc", "VjcQ", "Q\nccc"), ("", "VcQ", "Q"), ] { let mut editor = editor(); editor.apply(vec![Effect::Replace(text.into())]).unwrap(); type_text(&mut editor, keys); editor.handle(Input::Escape); assert_eq!(editor.context().text, expected, "{keys}"); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, text); assert_eq!(editor.mode(), Mode::Normal); assert!(!editor.visual_linewise()); editor.handle(Input::Char('U')); assert_eq!(editor.context().text, expected); } } #[test] fn linewise_multitarget_cut_change_and_paste_share_one_transaction() { let mut editor = editor(); editor .apply(vec![Effect::Replace("a\nbb\nccc".into())]) .unwrap(); set_carets(&mut editor, &[(1, 1), (2, 1)], 1); type_text(&mut editor, "Vd"); assert_eq!(editor.context().text, "a"); assert_eq!(editor.selection_count(), 1); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, "a\nbb\nccc"); assert_eq!(editor.cursor().row, 2); type_text(&mut editor, "VcQ"); editor.handle(Input::Escape); assert_eq!(editor.context().text, "a\nQ\nQ"); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, "a\nbb\nccc"); editor.apply(vec![Effect::Replace("x\ny".into())]).unwrap(); set_carets(&mut editor, &[(0, 0), (1, 0)], 1); editor.handle(Input::Char('P')); assert_eq!(editor.context().text, "bb\nx\nccc\ny"); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, "x\ny"); set_carets(&mut editor, &[(0, 0)], 0); editor.handle(Input::Char('p')); assert_eq!(editor.context().text, "x\ny"); assert!(editor.message().contains("count")); } #[test] fn linewise_paste_merges_identical_same_row_targets_and_refuses_conflicts() { let mut editor = editor(); editor .apply(vec![Effect::Replace("one\ntwo".into())]) .unwrap(); set_carets(&mut editor, &[(0, 0), (1, 0)], 1); type_text(&mut editor, "Vy"); editor.apply(vec![Effect::Replace("abcd".into())]).unwrap(); set_carets(&mut editor, &[(0, 0), (0, 2)], 1); let carets = editor.carets(); editor.handle(Input::Char('P')); assert_eq!(editor.context().text, "abcd"); assert_eq!(editor.carets(), carets); assert!(editor.message().contains("Conflicting")); set_carets(&mut editor, &[(0, 0)], 0); type_text(&mut editor, "Vy"); set_carets(&mut editor, &[(0, 0), (0, 2)], 1); editor.handle(Input::Char('p')); assert_eq!(editor.context().text, "abcd\nabcd"); assert_eq!(editor.selection_count(), 1); assert_eq!(editor.cursor(), Cursor { row: 1, column: 0 }); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, "abcd"); assert_eq!(editor.carets(), carets); } #[test] fn linewise_counted_search_and_occurrences_keep_shape_rules() { let mut editor = editor(); editor .apply(vec![Effect::Replace("hit a\nz\nhit b\nz\nhit c\n".into())]) .unwrap(); submit_search(&mut editor, "hit"); assert_eq!(editor.cursor().row, 2); type_text(&mut editor, "Vn"); assert_eq!(editor.linewise_rows().collect::>(), [2..=4]); type_text(&mut editor, "2N"); assert_eq!(editor.linewise_rows().collect::>(), [0..=2]); assert_eq!(editor.cursor(), Cursor { row: 0, column: 0 }); editor.handle(Input::Escape); editor .apply(vec![Effect::Replace("same\nsame\nlast".into())]) .unwrap(); editor.handle(Input::Char('V')); editor.handle(Input::Ctrl('n')); assert_eq!(editor.mode(), Mode::Visual); assert!(!editor.visual_linewise()); assert_eq!(editor.selection_count(), 2); assert_eq!( editor.selections(), [ Selection { start: Cursor { row: 0, column: 0 }, end: Cursor { row: 1, column: 0 } }, Selection { start: Cursor { row: 1, column: 0 }, end: Cursor { row: 2, column: 0 } }, ] ); } #[test] fn linewise_dot_retargets_row_spans_and_captures_paste_shape() { let mut editor = editor(); editor .apply(vec![Effect::Replace("a\nbb\nccc\nlast".into())]) .unwrap(); type_text(&mut editor, "Vjd"); assert_eq!(editor.context().text, "ccc\nlast"); editor.handle(Input::Char('.')); assert_eq!(editor.context().text, ""); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, "ccc\nlast"); editor .apply(vec![Effect::Replace("a\nb\nc\nd".into())]) .unwrap(); type_text(&mut editor, "jVkcX"); editor.handle(Input::Escape); assert_eq!(editor.context().text, "X\nc\nd"); type_text(&mut editor, "jj."); assert_eq!(editor.context().text, "X\nX"); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, "X\nc\nd"); type_text(&mut editor, "V."); assert_eq!(editor.context().text, "X\nc\nX"); editor.apply(vec![Effect::Replace("row".into())]).unwrap(); type_text(&mut editor, "Vyp"); assert_eq!(editor.context().text, "row\nrow"); editor.handle(Input::Char('u')); type_text(&mut editor, "vy"); editor.handle(Input::Char('.')); assert_eq!(editor.context().text, "row\nrow"); } #[test] fn repeat_visual_word_change_resolves_fresh_words_for_every_caret_and_ignores_remaps() { let mut editor = editor(); editor .apply(vec![Effect::Replace("foo\nlonger".into())]) .unwrap(); set_carets(&mut editor, &[(0, 0), (1, 0)], 1); type_text(&mut editor, "vecitem"); editor.handle(Input::Escape); editor .bind("visual", "e", Some("core.right".into())) .unwrap(); editor .apply(vec![Effect::Replace("tiny\nextraordinary".into())]) .unwrap(); set_carets(&mut editor, &[(0, 0), (1, 0)], 1); let undo = editor.active.undo.len(); type_text(&mut editor, "9."); assert_eq!(editor.context().text, "item\nitem"); assert_eq!(editor.mode(), Mode::Normal); assert_eq!(editor.selection_count(), 2); assert_eq!(editor.cursor().row, 1); assert_eq!(editor.count(), None); assert_eq!(editor.clipboard, vec!["tiny", "extraordinary"]); assert_eq!(editor.active.undo.len(), undo + 1); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, "tiny\nextraordinary"); assert_eq!( editor.carets(), vec![Cursor { row: 0, column: 0 }, Cursor { row: 1, column: 0 }] ); assert_eq!(editor.cursor(), Cursor { row: 1, column: 0 }); editor.handle(Input::Char('U')); assert_eq!(editor.context().text, "item\nitem"); editor.handle(Input::Char('u')); editor.handle(Input::Char('.')); assert_eq!(editor.context().text, "item\nitem"); } #[test] fn repeat_visual_current_ranges_supersede_unequal_recorded_spans_and_cardinality() { let mut editor = editor(); editor .apply(vec![Effect::Replace("abcd\nlonger".into())]) .unwrap(); set_ranges( &mut editor, &[ (Cursor { row: 0, column: 0 }, Cursor { row: 0, column: 2 }), (Cursor { row: 1, column: 0 }, Cursor { row: 1, column: 5 }), ], 1, ); type_text(&mut editor, "cQ"); editor.handle(Input::Escape); editor .apply(vec![Effect::Replace("alpha beta\nsecond\nthird".into())]) .unwrap(); set_ranges( &mut editor, &[ (Cursor { row: 0, column: 0 }, Cursor { row: 0, column: 10 }), (Cursor { row: 1, column: 0 }, Cursor { row: 1, column: 6 }), (Cursor { row: 2, column: 0 }, Cursor { row: 2, column: 5 }), ], 2, ); let undo = editor.active.undo.len(); editor.handle(Input::Char('.')); assert_eq!(editor.context().text, "Q\nQ\nQ"); assert_eq!(editor.mode(), Mode::Normal); assert_eq!(editor.selection_count(), 3); assert_eq!(editor.cursor().row, 2); assert_eq!(editor.active.undo.len(), undo + 1); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, "alpha beta\nsecond\nthird"); } #[test] fn repeat_cut_retargets_fresh_unequal_words_without_recreating_cloned_carets() { let mut editor = editor(); editor .apply(vec![Effect::Replace("cat\nlongword".into())]) .unwrap(); type_text(&mut editor, "ve"); editor.handle(Input::Ctrl('e')); editor.handle(Input::Char('d')); editor .apply(vec![Effect::Replace("small\nhugeword\nlast".into())]) .unwrap(); set_carets(&mut editor, &[(0, 0), (1, 0), (2, 0)], 2); // Cloning invalidated the selector; both recorded spans have length three. editor.handle(Input::Char('.')); assert_eq!(editor.context().text, "ll\neword\nt"); assert_eq!(editor.selection_count(), 3); editor .apply(vec![Effect::Replace("cat\nlongword".into())]) .unwrap(); set_carets(&mut editor, &[(0, 0), (1, 0)], 1); type_text(&mut editor, "ved"); editor .apply(vec![Effect::Replace("tiny\nenormous".into())]) .unwrap(); set_carets(&mut editor, &[(0, 0), (1, 0)], 1); editor.handle(Input::Char('.')); assert_eq!(editor.context().text, "\n"); assert_eq!(editor.clipboard, vec!["tiny", "enormous"]); } #[test] fn repeat_relative_spans_count_graphemes_and_newlines_and_keep_zero_spans_empty() { let mut editor = editor(); editor .apply(vec![Effect::Replace("a界\n👩‍🚀x".into())]) .unwrap(); set_ranges( &mut editor, &[(Cursor { row: 0, column: 1 }, Cursor { row: 1, column: 1 })], 0, ); editor.handle(Input::Char('d')); editor .apply(vec![Effect::Replace("e\u{301}Z\n\n👨‍💻tail".into())]) .unwrap(); set_carets(&mut editor, &[(0, 0)], 0); editor.handle(Input::Char('.')); assert_eq!(editor.context().text, "\n👨‍💻tail"); assert_eq!(editor.clipboard, vec!["e\u{301}Z\n"]); editor.apply(vec![Effect::Replace("".into())]).unwrap(); set_ranges(&mut editor, &[(Cursor::default(), Cursor::default())], 0); type_text(&mut editor, "cQ"); editor.handle(Input::Escape); editor.apply(vec![Effect::Replace("abc".into())]).unwrap(); set_carets(&mut editor, &[(0, 0)], 0); editor.handle(Input::Char('.')); assert_eq!(editor.context().text, "Qabc"); } #[test] fn repeat_unequal_span_distribution_refuses_atomically_and_recipe_survives() { let mut editor = editor(); editor .apply(vec![Effect::Replace("abcd\nlonger".into())]) .unwrap(); set_ranges( &mut editor, &[ (Cursor { row: 0, column: 0 }, Cursor { row: 0, column: 2 }), (Cursor { row: 1, column: 0 }, Cursor { row: 1, column: 3 }), ], 1, ); editor.handle(Input::Char('d')); editor .apply(vec![Effect::Replace("1234\n56789".into())]) .unwrap(); set_carets(&mut editor, &[(0, 1)], 0); let undo = editor.active.undo.len(); let redo = editor.active.redo.len(); let clipboard = editor.clipboard.clone(); editor.handle(Input::Char('.')); assert_eq!(editor.context().text, "1234\n56789"); assert_eq!(editor.cursor(), Cursor { row: 0, column: 1 }); assert_eq!(editor.mode(), Mode::Normal); assert_eq!(editor.active.undo.len(), undo); assert_eq!(editor.active.redo.len(), redo); assert_eq!(editor.clipboard, clipboard); assert!(editor.message().contains("count")); set_carets(&mut editor, &[(0, 0), (1, 0)], 1); editor.handle(Input::Char('.')); assert_eq!(editor.context().text, "34\n89"); } #[test] fn repeat_insert_placements_recalculate_for_current_multi_caret_lines() { for key in ['i', 'I', 'a', 'A', 'o', 'O'] { let mut recorded = editor(); recorded .apply(vec![Effect::Replace("short".into())]) .unwrap(); recorded.handle(Input::Char(key)); type_text(&mut recorded, "X\nY"); recorded.handle(Input::Escape); let text = " alpha\n\tbeta\nz"; recorded.apply(vec![Effect::Replace(text.into())]).unwrap(); set_carets(&mut recorded, &[(0, 2), (1, 1), (2, 0)], 1); let mut expected = editor(); expected.apply(vec![Effect::Replace(text.into())]).unwrap(); set_carets(&mut expected, &[(0, 2), (1, 1), (2, 0)], 1); expected.handle(Input::Char(key)); type_text(&mut expected, "X\nY"); expected.handle(Input::Escape); let undo = recorded.active.undo.len(); recorded.handle(Input::Char('.')); assert_eq!(recorded.context().text, expected.context().text, "{key}"); assert_eq!(recorded.carets(), expected.carets(), "{key}"); assert_eq!(recorded.cursor(), expected.cursor(), "{key}"); assert_eq!(recorded.mode(), Mode::Normal); assert_eq!(recorded.active.undo.len(), undo + 1); recorded.handle(Input::Char('u')); assert_eq!(recorded.context().text, text, "{key}"); assert_eq!(recorded.selection_count(), 3); } } #[test] fn repeat_visual_line_insert_uses_current_carets_without_rebuilding_old_row_targets() { for (key, expected) in [('I', " !alpha\n !beta"), ('A', " alpha!\n beta!")] { let mut editor = editor(); editor .apply(vec![Effect::Replace("a\nb\nc".into())]) .unwrap(); type_text(&mut editor, "v2j"); editor.handle(Input::Char(key)); type_text(&mut editor, "!"); editor.handle(Input::Escape); assert_eq!(editor.selection_count(), 3); editor .apply(vec![Effect::Replace(" alpha\n beta".into())]) .unwrap(); set_carets(&mut editor, &[(0, 2), (1, 2)], 1); editor.handle(Input::Char('.')); assert_eq!(editor.context().text, expected); assert_eq!(editor.selection_count(), 2); assert_eq!(editor.cursor().row, 1); } } #[test] fn repeat_counted_visual_motion_uses_resolved_action_and_normalizes_overlaps() { let mut editor = editor(); editor .apply(vec![Effect::Replace("one two three".into())]) .unwrap(); type_text(&mut editor, "v2ecQ"); editor.handle(Input::Escape); editor .bind("visual", "e", Some("core.left".into())) .unwrap(); editor .apply(vec![Effect::Replace("a enormous tail".into())]) .unwrap(); set_carets(&mut editor, &[(0, 0), (0, 2)], 1); let undo = editor.active.undo.len(); editor.handle(Input::Char('.')); assert_eq!(editor.context().text, "Q"); assert_eq!(editor.selection_count(), 1); assert_eq!(editor.active.undo.len(), undo + 1); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, "a enormous tail"); assert_eq!( editor.carets(), vec![Cursor { row: 0, column: 0 }, Cursor { row: 0, column: 2 }] ); assert_eq!(editor.cursor(), Cursor { row: 0, column: 2 }); } #[test] fn repeat_insert_native_motion_delete_backspace_and_newline_are_one_edit() { fn edit(editor: &mut Editor) { editor.handle(Input::Char('i')); editor.handle(Input::Right); type_text(editor, "XY"); editor.handle(Input::Left); editor.handle(Input::Backspace); editor.handle(Input::Delete); editor.handle(Input::Enter); type_text(editor, "Z"); editor.handle(Input::Right); // Trailing navigation is not part of the edit. editor.handle(Input::Escape); } let mut recorded = editor(); recorded .apply(vec![Effect::Replace("abcdef".into())]) .unwrap(); edit(&mut recorded); recorded .apply(vec![Effect::Replace("123456\nlongertext".into())]) .unwrap(); set_carets(&mut recorded, &[(0, 1), (1, 2)], 1); let mut expected = editor(); expected .apply(vec![Effect::Replace("123456\nlongertext".into())]) .unwrap(); set_carets(&mut expected, &[(0, 1), (1, 2)], 1); edit(&mut expected); let undo = recorded.active.undo.len(); recorded.handle(Input::Char('.')); assert_eq!(recorded.context().text, expected.context().text); assert_eq!(recorded.active.undo.len(), undo + 1); recorded.handle(Input::Char('u')); assert_eq!(recorded.context().text, "123456\nlongertext"); } #[test] fn repeat_omits_noop_insert_deletions_and_preserves_partial_noop_carets() { let mut editor = editor(); editor.handle(Input::Char('i')); editor.handle(Input::Backspace); editor.handle(Input::Delete); type_text(&mut editor, "X"); editor.handle(Input::Escape); editor.apply(vec![Effect::Replace("abcd".into())]).unwrap(); set_carets(&mut editor, &[(0, 2)], 0); editor.handle(Input::Char('.')); assert_eq!(editor.context().text, "abXcd"); editor.apply(vec![Effect::Replace("a\nab".into())]).unwrap(); set_carets(&mut editor, &[(0, 0), (1, 1)], 1); editor.handle(Input::Char('i')); editor.handle(Input::Backspace); type_text(&mut editor, "X"); editor.handle(Input::Escape); editor .apply(vec![Effect::Replace("cde\nfgh".into())]) .unwrap(); set_carets(&mut editor, &[(0, 2), (1, 2)], 1); editor.handle(Input::Char('.')); assert_eq!(editor.context().text, "cdXe\nfXh"); } #[test] fn repeat_partial_deletion_mismatch_rolls_back_visual_change_and_clipboard() { let mut editor = editor(); editor .apply(vec![Effect::Replace("ab\ncd".into())]) .unwrap(); set_carets(&mut editor, &[(0, 0), (1, 0)], 1); type_text(&mut editor, "vc"); editor.handle(Input::Backspace); type_text(&mut editor, "X"); editor.handle(Input::Escape); editor .apply(vec![Effect::Replace("aa\nbb\ncc".into())]) .unwrap(); set_carets(&mut editor, &[(0, 0), (1, 0), (2, 0)], 2); editor.handle(Input::Char('v')); let ranges = editor.selections(); let targets = editor.active.view.targets.clone(); let primary = editor.active.view.primary; let clipboard = editor.clipboard.clone(); let undo = editor.active.undo.len(); let redo = editor.active.redo.len(); editor.handle(Input::Char('.')); assert_eq!(editor.context().text, "aa\nbb\ncc"); assert_eq!(editor.mode(), Mode::Visual); assert_eq!(editor.selections(), ranges); assert_eq!(editor.active.view.targets, targets); assert_eq!(editor.active.view.primary, primary); assert_eq!(editor.clipboard, clipboard); assert_eq!(editor.active.undo.len(), undo); assert_eq!(editor.active.redo.len(), redo); assert!(editor.message().contains("count")); editor.handle(Input::Escape); set_carets(&mut editor, &[(0, 0), (1, 0)], 1); editor.handle(Input::Char('.')); assert_eq!(editor.context().text, "XaXb\ncc"); } #[test] fn repeat_typed_scalars_keep_right_neighbor_combining_and_zwj_boundaries() { for (typed, neighbor, result) in [("ab", "\u{301}", "a\u{301}b"), ("👩x", "\u{200d}🚀", "👩‍🚀x")] { let mut editor = editor(); editor.handle(Input::Char('i')); type_text(&mut editor, typed); editor.handle(Input::Escape); editor .apply(vec![Effect::Replace(neighbor.into())]) .unwrap(); set_carets(&mut editor, &[(0, 0)], 0); let undo = editor.active.undo.len(); editor.handle(Input::Char('.')); assert_eq!(editor.context().text, result); assert_eq!(editor.active.undo.len(), undo + 1); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, neighbor); editor.handle(Input::Char('U')); assert_eq!(editor.context().text, result); } } #[test] fn repeat_literal_paste_is_bulk_literal_and_keeps_original_typing_groups() { let mut editor = editor(); type_text(&mut editor, "iX"); editor.handle(Input::Paste("ab\r\n:q!".into())); type_text(&mut editor, "Y"); editor.handle(Input::Escape); assert_eq!(editor.context().text, "Xab\n:q!Y"); for expected in ["Xab\n:q!", "X", ""] { editor.handle(Input::Char('u')); assert_eq!(editor.context().text, expected); } editor.apply(vec![Effect::Replace("fresh".into())]).unwrap(); set_carets(&mut editor, &[(0, 0)], 0); editor.handle(Input::Char('.')); assert_eq!(editor.context().text, "Yfresh"); type_text(&mut editor, "i"); editor.handle(Input::Paste("ab".into())); editor.handle(Input::Escape); editor .apply(vec![Effect::Replace("\u{301}".into())]) .unwrap(); set_carets(&mut editor, &[(0, 0)], 0); editor.handle(Input::Char('.')); assert_eq!(editor.context().text, "ab\u{301}"); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, "\u{301}"); editor.handle(Input::Char('i')); editor.handle(Input::Paste(":q!\r\n".into())); editor.handle(Input::Escape); editor .apply(vec![Effect::Replace("target".into())]) .unwrap(); set_carets(&mut editor, &[(0, 0)], 0); editor.handle(Input::Char('.')); assert_eq!(editor.context().text, ":q!\ntarget"); assert!(!editor.should_quit()); } #[test] fn repeat_clipboard_captures_fragments_pairs_broadcasts_and_refuses_mismatch() { let mut editor = editor(); editor .apply(vec![Effect::Replace("ab\nxyz".into())]) .unwrap(); set_ranges( &mut editor, &[ (Cursor { row: 0, column: 0 }, Cursor { row: 0, column: 2 }), (Cursor { row: 1, column: 0 }, Cursor { row: 1, column: 3 }), ], 1, ); editor.handle(Input::Char('y')); set_carets(&mut editor, &[(0, 0), (1, 0)], 1); editor.handle(Input::Char('P')); editor.apply(vec![Effect::Replace("OTHER".into())]).unwrap(); set_carets(&mut editor, &[(0, 0)], 0); type_text(&mut editor, "vey"); assert_eq!(editor.clipboard, vec!["OTHER"]); editor .apply(vec![Effect::Replace("1\n2\n3".into())]) .unwrap(); set_carets(&mut editor, &[(0, 0), (1, 0), (2, 0)], 2); let undo = editor.active.undo.len(); let targets = editor.active.view.targets.clone(); editor.handle(Input::Char('.')); assert_eq!(editor.context().text, "1\n2\n3"); assert_eq!(editor.active.view.targets, targets); assert_eq!(editor.active.undo.len(), undo); assert_eq!(editor.clipboard, vec!["OTHER"]); set_carets(&mut editor, &[(0, 0), (1, 0)], 1); editor.handle(Input::Char('.')); assert_eq!(editor.context().text, "ab1\nxyz2\n3"); assert_eq!(editor.clipboard, vec!["OTHER"]); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, "1\n2\n3"); editor.clipboard = vec!["!".into()]; editor.handle(Input::Char('p')); editor .apply(vec![Effect::Replace("a\nb\nc".into())]) .unwrap(); set_carets(&mut editor, &[(0, 0), (1, 0), (2, 0)], 1); editor.handle(Input::Char('.')); assert_eq!(editor.context().text, "a!\nb!\nc!"); } #[test] fn repeat_join_and_normal_delete_are_native_and_noops_keep_the_previous_recipe() { let mut editor = editor(); editor.apply(vec![Effect::Replace("a\n b".into())]).unwrap(); editor.handle(Input::Char('J')); editor .apply(vec![Effect::Replace("Done.\n again\nlast".into())]) .unwrap(); set_carets(&mut editor, &[(0, 0)], 0); let undo = editor.active.undo.len(); editor.handle(Input::Char('.')); assert_eq!(editor.context().text, "Done. again\nlast"); assert_eq!(editor.active.undo.len(), undo + 1); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, "Done.\n again\nlast"); editor.apply(vec![Effect::Replace("".into())]).unwrap(); editor.handle(Input::Char('J')); editor.handle(Input::Char('x')); editor .apply(vec![Effect::Replace("one\n two".into())]) .unwrap(); editor.handle(Input::Char('.')); assert_eq!(editor.context().text, "one two"); editor.handle(Input::Char('x')); editor .apply(vec![Effect::Replace("界e\u{301}\n👩‍🚀z".into())]) .unwrap(); set_carets(&mut editor, &[(0, 0), (1, 0)], 1); editor.handle(Input::Char('.')); assert_eq!(editor.context().text, "e\u{301}\nz"); assert_eq!(editor.selection_count(), 2); } #[test] fn repeat_identical_change_empty_insert_rejected_paste_and_history_keep_recipe_and_redo() { let mut editor = editor(); editor.apply(vec![Effect::Replace("abc".into())]).unwrap(); type_text(&mut editor, "iX"); editor.handle(Input::Escape); editor.handle(Input::Char('u')); type_text(&mut editor, "vecabc"); editor.handle(Input::Escape); set_carets(&mut editor, &[(0, 0)], 0); type_text(&mut editor, "i"); editor.handle(Input::Backspace); editor.handle(Input::Escape); let redo = editor.active.redo.len(); editor.handle(Input::Char('i')); editor.handle(Input::Paste("bad\0".into())); editor.handle(Input::Escape); assert_eq!(editor.active.redo.len(), redo); editor.handle(Input::Char('U')); assert_eq!(editor.context().text, "Xabc"); editor.handle(Input::Char('u')); set_carets(&mut editor, &[(0, 0)], 0); editor.handle(Input::Char('.')); assert_eq!(editor.context().text, "Xabc"); } #[test] fn repeat_is_session_wide_and_plugin_effects_commit_but_never_record_native_intent() { let directory = Directory::new(); let other = directory.path("other"); fs::write(&other, "base").unwrap(); let mut editor = editor(); type_text(&mut editor, "iN"); editor.execute(&format!("edit {}", other.display())); editor.handle(Input::Char('.')); assert_eq!(editor.context().text, "Nbase"); editor.register(plugin("fixture", &["greet"])).unwrap(); assert_eq!(editor.execute("greet").unwrap().command, "greet"); editor .apply(vec![ Effect::Replace("plugin".into()), Effect::Insert("!".into()), ]) .unwrap(); set_carets(&mut editor, &[(0, 0)], 0); assert_eq!(editor.handle(Input::Char('.')), None); assert_eq!(editor.context().text, "N!plugin"); editor.handle(Input::Char('i')); type_text(&mut editor, "T"); editor.apply(vec![Effect::Insert("P".into())]).unwrap(); editor.handle(Input::Escape); editor.apply(vec![Effect::Replace("fresh".into())]).unwrap(); set_carets(&mut editor, &[(0, 0)], 0); editor.handle(Input::Char('.')); assert_eq!(editor.context().text, "Tfresh"); editor.execute("buffer 1"); assert_eq!(editor.context().text, "N"); editor.handle(Input::Char('.')); assert_eq!(editor.context().text, "TN"); } #[test] fn repeat_action_no_recipe_remaps_and_literal_queries_do_not_record_or_execute_edits() { let directory = Directory::new(); fs::write(directory.path("file.txt"), "file").unwrap(); let mut editor = project_editor(&directory); editor.handle(Input::Char('.')); assert_eq!(editor.message(), "Nothing to repeat"); assert_eq!(editor.context().text, ""); assert!(editor.active.undo.is_empty()); editor .bind("normal", "r", Some("core.repeat_last_edit".into())) .unwrap(); editor .bind("visual", "r", Some("core.repeat_last_edit".into())) .unwrap(); assert!( editor .bind("insert", "r", Some("core.repeat_last_edit".into())) .is_err() ); type_text(&mut editor, "iX"); editor.open_file_picker(FilePresentation::Docked); editor.handle(Input::Char('i')); type_text(&mut editor, "."); assert_eq!(editor.file_picker().unwrap().query, "."); assert_eq!(editor.context().text, "X"); editor.handle(Input::Ctrl('p')); type_text(&mut editor, "."); assert_eq!(editor.palette().unwrap().query, "."); editor.handle(Input::Escape); type_text(&mut editor, "Y"); editor.handle(Input::Escape); editor.apply(vec![Effect::Replace("fresh".into())]).unwrap(); set_carets(&mut editor, &[(0, 0)], 0); editor.handle(Input::Char('r')); assert_eq!(editor.context().text, "XYfresh"); type_text(&mut editor, "i."); editor.handle(Input::Escape); editor .apply(vec![Effect::Replace("target".into())]) .unwrap(); set_carets(&mut editor, &[(0, 0)], 0); editor.handle(Input::Char('r')); assert_eq!(editor.context().text, ".target"); type_text(&mut editor, "/."); assert_eq!(editor.command_line(), "."); editor.handle(Input::Escape); type_text(&mut editor, ":."); assert_eq!(editor.command_line(), "."); editor.handle(Input::Escape); editor.apply(vec![Effect::Replace("again".into())]).unwrap(); set_carets(&mut editor, &[(0, 0)], 0); editor.handle(Input::Char('r')); assert_eq!(editor.context().text, ".again"); } #[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(); editor.set_clipboard_mode("separate").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(); editor.set_clipboard_mode("separate").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("core.buffer_previous".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.set_clipboard_mode("separate").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.set_clipboard_mode("separate").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.set_clipboard_mode("separate").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.set_clipboard_mode("separate").unwrap(); editor.execute(&format!("edit {}", b.display())); editor .apply(vec![Effect::Replace("dirty b".into())]) .unwrap(); for command in ["w", "w!", "wq", "wq!"] { editor.execute(&format!("{command} {}", 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!(!editor.should_quit()); 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!("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("core.buffer_next".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')); assert_eq!( palette_entry(&editor, "core.buffer_next").bindings, vec!["Space b n", "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.set_clipboard_mode("separate").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(link.as_path())); 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()); } fn palette_entry<'a>(editor: &'a Editor, name: &str) -> &'a PaletteEntry { editor .palette() .unwrap() .entries .iter() .find(|entry| entry.name == name) .unwrap() } #[test] fn palette_cancellation_preserves_every_underlying_mode_and_insert_group() { for mode in [ Mode::Normal, Mode::Insert, Mode::Command, Mode::Search, Mode::Visual, ] { for cancel in [Input::Escape, Input::Ctrl('p')] { let mut editor = editor(); let original = "alpha beta\nalpha beta"; editor .apply(vec![Effect::Replace(original.into())]) .unwrap(); editor.apply(vec![Effect::Insert("extra".into())]).unwrap(); editor.undo(); submit_search(&mut editor, "alpha"); set_carets(&mut editor, &[(0, 0), (1, 0)], 1); match mode { Mode::Normal => {} Mode::Insert => { editor.handle(Input::Char('i')); type_text(&mut editor, "X"); } Mode::Command => { editor.handle(Input::Char(':')); type_text(&mut editor, "w output"); } Mode::Search => { editor.handle(Input::Char('/')); type_text(&mut editor, "alpha e\u{301}"); } Mode::Visual => { editor.handle(Input::Char('v')); editor.handle(Input::Right); } } let snapshot = |state: &Snapshot| (state.lines.clone(), state.targets.clone(), state.primary); let lines = editor.active.lines.clone(); let targets = editor.active.view.targets.clone(); let primary = editor.active.view.primary; let selections = editor.selections(); let prompt = editor.active.view.command_line.clone(); let search = editor.active.view.search_query.clone(); let found = editor.active.view.search_match; let message = editor.message.clone(); let undo: Vec<_> = editor.active.undo.iter().map(&snapshot).collect(); let redo: Vec<_> = editor.active.redo.iter().map(&snapshot).collect(); let insert = editor.active.view.insert_start.as_ref().map(snapshot); editor.handle(Input::Ctrl('p')); assert!(editor.palette().is_some()); editor.handle(Input::Ctrl('j')); editor.handle(Input::Ctrl('k')); type_text(&mut editor, "query Δe\u{301}👩‍🚀 "); editor.handle(Input::Backspace); editor.handle(Input::Backspace); assert_eq!(editor.palette().unwrap().query, "query Δe\u{301}"); editor.handle(Input::Paste("ignored\n:q!".into())); editor.handle(Input::Down); editor.handle(Input::Ctrl('k')); editor.handle(cancel); assert!(editor.palette().is_none()); assert_eq!(editor.mode(), mode); assert_eq!(editor.active.lines, lines); assert_eq!(editor.active.view.targets, targets); assert_eq!(editor.active.view.primary, primary); assert_eq!(editor.selections(), selections); assert_eq!(editor.active.view.command_line, prompt); assert_eq!(editor.active.view.search_query, search); assert_eq!(editor.active.view.search_match, found); assert_eq!(editor.message, message); assert_eq!( editor.active.undo.iter().map(&snapshot).collect::>(), undo ); assert_eq!( editor.active.redo.iter().map(&snapshot).collect::>(), redo ); assert_eq!( editor.active.view.insert_start.as_ref().map(snapshot), insert ); if mode == Mode::Insert { type_text(&mut editor, "Y"); editor.handle(Input::Escape); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, original); } } } } #[test] fn palette_filters_all_tokens_and_wraps_without_executing_empty_results() { let mut editor = editor(); editor .register(plugin("Älpha Tools", &["greet_one", "greet_two"])) .unwrap(); editor.handle(Input::Ctrl('p')); let order: Vec<_> = editor .palette() .unwrap() .entries .iter() .map(|entry| (entry.label.clone(), entry.name.clone())) .collect(); assert!(order.windows(2).all(|pair| pair[0] <= pair[1])); assert!( !editor .palette() .unwrap() .entries .iter() .any(|entry| entry.name == "core.palette") ); type_text(&mut editor, "GREET ÄLPHA"); assert_eq!(editor.palette().unwrap().entries.len(), 2); assert_eq!(editor.palette().unwrap().entries[0].name, "greet_one"); editor.handle(Input::Up); assert_eq!(editor.palette().unwrap().selected, 1); editor.handle(Input::Ctrl('n')); assert_eq!(editor.palette().unwrap().selected, 0); editor.handle(Input::Down); assert_eq!(editor.palette().unwrap().selected, 1); editor.handle(Input::Ctrl('k')); assert_eq!(editor.palette().unwrap().selected, 0); editor.handle(Input::Down); editor.handle(Input::Char(' ')); assert_eq!(editor.palette().unwrap().selected, 0); type_text(&mut editor, "missing"); assert!(editor.palette().unwrap().entries.is_empty()); for input in [ Input::Enter, Input::Up, Input::Down, Input::Ctrl('j'), Input::Ctrl('k'), Input::Ctrl('n'), ] { assert_eq!(editor.handle(input), None); assert_eq!(editor.palette().unwrap().selected, 0); } while !editor.palette().unwrap().query.is_empty() { editor.handle(Input::Backspace); } type_text(&mut editor, "e\u{301}👩‍🚀"); editor.handle(Input::Backspace); assert_eq!(editor.palette().unwrap().query, "e\u{301}"); editor.handle(Input::Backspace); assert_eq!(editor.palette().unwrap().query, ""); assert!(!editor.palette().unwrap().entries.is_empty()); editor.handle(Input::Ctrl('i')); assert_eq!(editor.palette().unwrap().query, ""); } #[test] fn palette_catalog_shares_binding_eligibility_and_keeps_disabled_rows() { for mode in [Mode::Insert, Mode::Command, Mode::Search, Mode::Visual] { let mut editor = editor(); editor.active.view.mode = mode; editor.active.view.command_line = "untouched".into(); editor.handle(Input::Ctrl('p')); type_text(&mut editor, "core.undo"); let entry = palette_entry(&editor, "core.undo"); assert!(!entry.enabled); assert_eq!(entry.modes, vec![Mode::Normal]); assert!(entry.bindings.is_empty()); editor.handle(Input::Ctrl('j')); editor.handle(Input::Ctrl('k')); assert_eq!(editor.palette().unwrap().query, "core.undo"); assert_eq!(editor.handle(Input::Enter), None); assert!(editor.palette().is_some()); assert_eq!(editor.mode(), mode); assert_eq!(editor.command_line(), "untouched"); while !editor.palette().unwrap().query.is_empty() { editor.handle(Input::Backspace); } type_text(&mut editor, "q!"); assert!(!palette_entry(&editor, "core.force_quit").enabled); editor.handle(Input::Enter); assert!(!editor.should_quit()); assert!(editor.palette().is_some()); } } #[test] fn palette_effective_bindings_refresh_with_canonical_terminal_names() { let mut editor = editor(); editor.handle(Input::Ctrl('p')); assert_eq!(palette_entry(&editor, "core.insert").bindings, vec!["i"]); editor.bind("normal", "i", None).unwrap(); assert!(palette_entry(&editor, "core.insert").bindings.is_empty()); for key in [ "shift+i", "ctrl+m", "ctrl+h", "ctrl+i", "space", "ctrl+r", "界", ] { editor .bind("normal", key, Some("core.insert".into())) .unwrap(); } editor .bind("normal", "ctrl+j", Some("core.insert".into())) .unwrap(); assert_eq!( palette_entry(&editor, "core.insert").bindings, vec![ "Ctrl+h", "Ctrl+j", "Ctrl+r", "Enter", "I", "Space", "Tab", "界" ] ); assert!( palette_entry(&editor, "core.insert_first_nonblank") .bindings .is_empty() ); editor.bind("normal", "ENTER", None).unwrap(); assert!( !palette_entry(&editor, "core.insert") .bindings .iter() .any(|key| key == "Enter") ); editor .bind("normal", "界", Some("core.undo".into())) .unwrap(); assert_eq!( palette_entry(&editor, "core.undo").bindings, vec!["u", "界"] ); assert!( !palette_entry(&editor, "core.insert") .bindings .iter() .any(|key| key == "界") ); assert!( editor .bind("insert", "g", Some("core.undo".into())) .is_err() ); editor.handle(Input::Escape); editor.bind("normal", "ctrl+p", None).unwrap(); editor .bind("normal", "ctrl+f", Some("core.palette".into())) .unwrap(); editor.handle(Input::Ctrl('p')); assert!(editor.palette().is_none()); editor.handle(Input::Ctrl('f')); assert!(editor.palette().is_some()); editor.handle(Input::Ctrl('p')); assert!(editor.palette().is_none()); } #[test] fn palette_registered_commands_refresh_and_retire_with_plugins() { let mut editor = editor(); editor.handle(Input::Ctrl('p')); type_text(&mut editor, "fixture"); assert!(editor.palette().unwrap().entries.is_empty()); editor.register(plugin("fixture", &["greet"])).unwrap(); assert_eq!(editor.palette().unwrap().entries.len(), 1); assert_eq!(palette_entry(&editor, "greet").owner, "fixture"); assert!(palette_entry(&editor, "greet").enabled); editor.bind("normal", "g", Some("greet".into())).unwrap(); assert_eq!(palette_entry(&editor, "greet").bindings, vec!["g"]); editor.disable_plugin("fixture", "retired"); assert!(editor.palette().unwrap().entries.is_empty()); editor.handle(Input::Enter); assert!(editor.palette().is_some()); editor.handle(Input::Escape); assert_eq!(editor.handle(Input::Char('g')), None); assert!(editor.bind("normal", "g", Some("greet".into())).is_err()); } #[test] fn palette_commands_require_prompt_submission_and_dispatch_arguments_once() { let mut editor = editor(); editor.register(plugin("fixture", &["greet"])).unwrap(); editor.handle(Input::Ctrl('p')); type_text(&mut editor, "greet"); assert_eq!(editor.handle(Input::Enter), None); assert!(editor.palette().is_none()); assert_eq!(editor.mode(), Mode::Command); assert_eq!(editor.command_line(), "greet "); type_text(&mut editor, "界!"); assert_eq!( editor.handle(Input::Enter), Some(Invocation { plugin: "fixture".into(), command: "greet".into(), args: "界!".into(), }) ); assert_eq!(editor.mode(), Mode::Normal); assert_eq!(editor.handle(Input::Enter), None); editor.handle(Input::Ctrl('p')); type_text(&mut editor, "q!"); editor.handle(Input::Enter); assert!(!editor.should_quit()); editor.handle(Input::Enter); assert!(editor.should_quit()); } #[test] fn palette_actions_dispatch_visual_change_insert_newline_and_original_prompts() { let mut editor = editor(); editor .apply(vec![Effect::Replace("alpha beta".into())]) .unwrap(); set_ranges( &mut editor, &[(Cursor::default(), Cursor { row: 0, column: 5 })], 0, ); editor.handle(Input::Ctrl('p')); type_text(&mut editor, "core.change"); assert!(palette_entry(&editor, "core.change").enabled); editor.handle(Input::Enter); assert!(editor.palette().is_none()); assert_eq!(editor.mode(), Mode::Insert); assert_eq!(editor.context().text, " beta"); type_text(&mut editor, "Ω"); editor.handle(Input::Escape); assert_eq!(editor.context().text, "Ω beta"); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, "alpha beta"); let mut insertion = self::editor(); insertion.handle(Input::Char('i')); type_text(&mut insertion, "A"); insertion.handle(Input::Ctrl('p')); type_text(&mut insertion, "core.newline"); insertion.handle(Input::Enter); assert_eq!(insertion.mode(), Mode::Insert); assert_eq!(insertion.lines(), &["A", ""]); type_text(&mut insertion, "B"); insertion.handle(Input::Escape); insertion.handle(Input::Char('u')); assert_eq!(insertion.context().text, ""); insertion.handle(Input::Char('U')); assert_eq!(insertion.context().text, "A\nB"); editor.handle(Input::Char(':')); type_text(&mut editor, "plugins"); editor.handle(Input::Ctrl('p')); type_text(&mut editor, "core.submit"); editor.handle(Input::Enter); assert_eq!(editor.mode(), Mode::Normal); assert!(editor.message().starts_with("Capabilities: ")); assert_eq!(editor.command_line(), ""); editor.handle(Input::Char('/')); type_text(&mut editor, "beta"); editor.handle(Input::Ctrl('p')); type_text(&mut editor, "core.submit"); editor.handle(Input::Enter); assert_eq!(editor.mode(), Mode::Normal); assert_eq!(editor.active.view.search_query, "beta"); assert_eq!(editor.cursor(), Cursor { row: 0, column: 6 }); } #[test] fn palette_survives_rejected_effects_but_external_execution_closes_it() { let mut editor = editor(); editor .apply(vec![Effect::Replace("alpha beta".into())]) .unwrap(); editor.handle(Input::Char('v')); editor.handle(Input::Right); let targets = editor.active.view.targets.clone(); editor.handle(Input::Ctrl('p')); type_text(&mut editor, "core.change"); let undo = editor.active.undo.len(); assert!( editor .apply(vec![ Effect::Message("changed".into()), Effect::Insert("\0".into()) ]) .is_err() ); assert_eq!(editor.palette().unwrap().query, "core.change"); assert_eq!(editor.mode(), Mode::Visual); assert_eq!(editor.active.view.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.active.view.targets, targets); editor.execute("plugins"); assert!(editor.palette().is_none()); assert_eq!(editor.mode(), Mode::Normal); editor.handle(Input::Char('v')); editor.handle(Input::Ctrl('p')); type_text(&mut editor, "core.change"); editor.apply(vec![Effect::Insert("!".into())]).unwrap(); assert_eq!(editor.mode(), Mode::Normal); assert!(!palette_entry(&editor, "core.change").enabled); } #[test] fn visual_change_replaces_reversed_multiline_graphemes_in_one_undo_group() { let mut editor = editor(); let original = "pre界e\u{301}\n\n👩‍🚀tail\npost"; editor .apply(vec![Effect::Replace(original.into())]) .unwrap(); submit_search(&mut editor, "👩‍🚀"); editor.handle(Input::Char('v')); editor.handle(Input::Up); editor.handle(Input::Up); for _ in 0..3 { editor.handle(Input::Right); } editor.handle(Input::Char('c')); assert_eq!(editor.mode(), Mode::Insert); assert!(editor.selections().is_empty()); assert_eq!(editor.search_match(), None); assert_eq!(editor.cursor(), Cursor { row: 0, column: 3 }); assert_eq!(editor.context().text, "pretail\npost"); editor.handle(Input::Paste("\0".into())); type_text(&mut editor, "α\nβ"); editor.handle(Input::Escape); assert_eq!(editor.context().text, "preα\nβtail\npost"); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, original); assert_eq!(editor.mode(), Mode::Normal); editor.handle(Input::Char('U')); assert_eq!(editor.context().text, "preα\nβtail\npost"); } #[test] fn visual_change_at_eof_and_empty_ranges_preserves_clipboard() { let mut editor = editor(); editor.apply(vec![Effect::Replace("abX".into())]).unwrap(); editor.handle(Input::End); editor.handle(Input::Char('v')); editor.handle(Input::Char('c')); assert_eq!(editor.cursor(), Cursor { row: 0, column: 2 }); type_text(&mut editor, "YZ"); editor.handle(Input::Escape); assert_eq!(editor.context().text, "abYZ"); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, "abX"); editor.apply(vec![Effect::Replace("".into())]).unwrap(); editor.handle(Input::Char('v')); assert_eq!(editor.selections()[0].start, editor.selections()[0].end); editor.handle(Input::Char('c')); type_text(&mut editor, "new"); editor.handle(Input::Escape); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, ""); editor.handle(Input::Char('p')); assert_eq!(editor.context().text, "X"); } #[test] fn visual_change_with_identical_replacement_preserves_redo() { let mut editor = editor(); editor.apply(vec![Effect::Replace("abc".into())]).unwrap(); editor.handle(Input::Char('i')); type_text(&mut editor, "X"); editor.handle(Input::Escape); editor.handle(Input::Char('u')); editor.handle(Input::Char('v')); editor.handle(Input::Char('e')); editor.handle(Input::Char('c')); type_text(&mut editor, "abc"); editor.handle(Input::Escape); editor.handle(Input::Char('U')); assert_eq!(editor.context().text, "Xabc"); } #[test] fn visual_ranges_copy_both_directions_and_cut_and_paste_multiline_graphemes() { let mut editor = editor(); let text = "a界e\u{301}z\n\n👩‍🚀tail\n"; editor.apply(vec![Effect::Replace(text.into())]).unwrap(); editor.handle(Input::Right); editor.handle(Input::Char('v')); editor.handle(Input::Right); assert_eq!( editor.selections(), vec![Selection { start: Cursor { row: 0, column: 1 }, end: Cursor { row: 0, column: 3 }, }] ); editor.handle(Input::Left); editor.handle(Input::Left); assert_eq!( editor.selections()[0], Selection { start: Cursor { row: 0, column: 0 }, end: Cursor { row: 0, column: 2 }, } ); editor.handle(Input::Down); assert_eq!(editor.selections()[0].end, Cursor { row: 2, column: 0 }); let selected = editor.selections(); editor.handle(Input::Paste(":q!\n".into())); assert_eq!(editor.selections(), selected); editor.handle(Input::Down); editor.handle(Input::Char('d')); assert!(editor.selections().is_empty()); assert_eq!(editor.context().text, "atail\n"); editor.handle(Input::Char('P')); assert_eq!(editor.context().text, text); editor.handle(Input::Char('u')); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, text); assert_eq!(editor.mode(), Mode::Normal); } #[test] fn clipboard_copy_preserves_redo_and_survives_history_and_grapheme_merging() { let mut editor = editor(); editor .apply(vec![Effect::Replace("\u{301}".into())]) .unwrap(); editor.handle(Input::Char('v')); editor.handle(Input::Char('y')); editor.handle(Input::Char('u')); editor.handle(Input::Char('U')); assert_eq!(editor.context().text, "\u{301}"); editor.apply(vec![Effect::Replace("a".into())]).unwrap(); editor.handle(Input::Char('p')); assert_eq!(editor.context().text, "a\u{301}"); assert_eq!(editor.cursor(), Cursor::default()); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, "a"); } #[test] fn visual_empty_lines_include_real_newlines_and_row_split_excludes_exclusive_end() { let mut editor = editor(); editor .apply(vec![Effect::Replace("ab\n\ncd\n".into())]) .unwrap(); editor.handle(Input::Down); editor.handle(Input::Char('v')); assert_eq!( editor.selections(), vec![Selection { start: Cursor { row: 1, column: 0 }, end: Cursor { row: 2, column: 0 }, }] ); editor.handle(Input::Ctrl('l')); assert_eq!(editor.carets(), vec![Cursor { row: 1, column: 0 }]); editor.handle(Input::Char('v')); editor.handle(Input::Delete); assert_eq!(editor.context().text, "ab\ncd\n"); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, "ab\n\ncd\n"); } #[test] fn batch_insert_newline_backspace_and_delete_use_prebuffer_offsets() { let mut editor = editor(); editor.apply(vec![Effect::Replace("abcd".into())]).unwrap(); set_carets(&mut editor, &[(0, 0), (0, 2)], 1); editor.handle(Input::Char('i')); type_text(&mut editor, "X"); assert_eq!(editor.context().text, "XabXcd"); editor.handle(Input::Enter); assert_eq!(editor.context().text, "X\nabX\ncd"); editor.handle(Input::Backspace); assert_eq!(editor.context().text, "XabXcd"); editor.handle(Input::Delete); assert_eq!(editor.context().text, "XbXd"); editor.handle(Input::Escape); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, "abcd"); assert_eq!( editor.carets(), vec![Cursor { row: 0, column: 0 }, Cursor { row: 0, column: 2 }] ); assert_eq!(editor.cursor(), Cursor { row: 0, column: 2 }); } #[test] fn visual_insert_and_append_split_ragged_rows_without_padding() { for (key, expected) in [('I', " !aa\n!\n !zzz"), ('A', " aa!\n!\n zzz!")] { let mut editor = editor(); editor .apply(vec![Effect::Replace(" aa\n\n zzz".into())]) .unwrap(); editor.handle(Input::Char('v')); editor.handle(Input::Down); editor.handle(Input::Down); editor.handle(Input::Char(key)); assert_eq!(editor.mode(), Mode::Insert); assert_eq!(editor.selection_count(), 3); type_text(&mut editor, "!"); editor.handle(Input::Escape); assert_eq!(editor.context().text, expected); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, " aa\n\n zzz"); } } #[test] fn unequal_word_changes_and_typing_share_undo_and_restore_primary_carets() { let mut editor = editor(); editor .apply(vec![Effect::Replace("foo\nlonger".into())]) .unwrap(); editor.handle(Input::Ctrl('e')); editor.handle(Input::Char('v')); editor.handle(Input::Char('e')); assert_eq!( editor .selections() .iter() .map(|range| range.end.column) .collect::>(), vec![3, 6] ); let before = editor.carets(); let primary = editor.cursor(); editor.handle(Input::Char('c')); type_text(&mut editor, "X"); editor.handle(Input::Escape); assert_eq!(editor.context().text, "X\nX"); assert_eq!(editor.clipboard, vec!["foo", "longer"]); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, "foo\nlonger"); assert_eq!(editor.carets(), before); assert_eq!(editor.cursor(), primary); assert_eq!(editor.mode(), Mode::Normal); assert!(editor.selections().is_empty()); editor.handle(Input::Char('U')); assert_eq!(editor.context().text, "X\nX"); } #[test] fn unicode_overlap_merges_but_adjacency_preserves_distinct_fragments() { let mut editor = editor(); editor .apply(vec![Effect::Replace("a\u{301}👩‍🚀界z".into())]) .unwrap(); editor.active.view.mode = Mode::Visual; editor.active.view.targets = vec![ editor.range_target( Selection { start: Cursor { row: 0, column: 0 }, end: Cursor { row: 0, column: 2 }, }, true, ), editor.range_target( Selection { start: Cursor { row: 0, column: 1 }, end: Cursor { row: 0, column: 3 }, }, false, ), editor.range_target( Selection { start: Cursor { row: 0, column: 3 }, end: Cursor { row: 0, column: 4 }, }, true, ), ]; editor.active.view.primary = 1; editor.normalize_targets(); assert_eq!(editor.selection_count(), 2); assert_eq!(editor.selections()[0].end.column, 3); assert_eq!(editor.cursor().column, 0); editor.handle(Input::Char('d')); assert_eq!(editor.context().text, ""); assert_eq!(editor.clipboard, vec!["a\u{301}👩‍🚀界", "z"]); assert_eq!(editor.selection_count(), 1); editor.handle(Input::Char('u')); assert_eq!( editor.carets(), vec![Cursor { row: 0, column: 0 }, Cursor { row: 0, column: 3 }] ); } #[test] fn clipboard_pairs_in_document_order_broadcasts_one_and_refuses_mismatch_atomically() { let mut editor = editor(); editor .apply(vec![Effect::Replace("ab\nxyzq".into())]) .unwrap(); set_ranges( &mut editor, &[ (Cursor { row: 0, column: 0 }, Cursor { row: 0, column: 1 }), (Cursor { row: 1, column: 0 }, Cursor { row: 1, column: 3 }), ], 1, ); editor.handle(Input::Char('d')); assert_eq!(editor.context().text, "b\nq"); editor.handle(Input::Char('P')); assert_eq!(editor.context().text, "ab\nxyzq"); editor.handle(Input::Char('u')); editor.handle(Input::Escape); assert!(!editor.notification_visible()); assert_eq!(editor.selection_count(), 2); editor.handle(Input::Escape); assert_eq!(editor.selection_count(), 1); let before = editor.snapshot(); let undo = editor.active.undo.len(); let redo = editor.active.redo.len(); editor.handle(Input::Char('p')); assert_eq!(editor.active.lines, before.lines); assert_eq!(editor.active.view.targets, before.targets); assert_eq!(editor.active.view.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); editor.handle(Input::Char('P')); assert_eq!(editor.context().text, "!b\n!q"); } #[test] fn literal_multiline_occurrences_skip_wrap_select_all_and_escape_in_two_stages() { let mut editor = editor(); editor .apply(vec![Effect::Replace("α\nβ α\nβ α\nβ".into())]) .unwrap(); set_ranges( &mut editor, &[(Cursor { row: 0, column: 0 }, Cursor { row: 1, column: 1 })], 0, ); editor.handle(Input::Ctrl('n')); assert_eq!(editor.selection_count(), 2); assert_eq!(editor.cursor(), Cursor { row: 2, column: 0 }); editor.handle(Input::Ctrl('s')); assert_eq!(editor.selection_count(), 2); assert_eq!(editor.cursor(), Cursor { row: 3, column: 0 }); editor.handle(Input::Ctrl('n')); assert_eq!(editor.selection_count(), 3); assert_eq!(editor.cursor(), Cursor { row: 2, column: 0 }); editor.handle(Input::Ctrl('a')); assert_eq!(editor.selection_count(), 3); editor.handle(Input::Escape); assert_eq!(editor.mode(), Mode::Normal); assert_eq!(editor.selection_count(), 3); let primary = editor.cursor(); editor.handle(Input::Escape); assert_eq!(editor.carets(), vec![primary]); } #[test] fn occurrences_reject_grapheme_intersections_and_gather_nonoverlapping_literals() { let mut editor = editor(); editor .apply(vec![Effect::Replace("aa aa aaa a\u{301}a".into())]) .unwrap(); editor.handle(Input::Char('v')); editor.handle(Input::Right); editor.handle(Input::Ctrl('a')); assert_eq!( editor .selections() .iter() .map(|range| range.start.column) .collect::>(), vec![0, 3, 6] ); assert_eq!(editor.cursor().column, 1); editor.handle(Input::Ctrl('n')); assert_eq!(editor.selection_count(), 3); } #[test] fn vertical_clones_keep_display_columns_through_tabs_wide_and_short_rows() { let mut editor = editor(); editor .apply(vec![Effect::Replace("a\t界z\nx\n123456z".into())]) .unwrap(); editor.handle(Input::End); editor.handle(Input::Ctrl('e')); assert_eq!(editor.cursor(), Cursor { row: 1, column: 0 }); editor.handle(Input::Ctrl('e')); assert_eq!(editor.selection_count(), 3); assert_eq!(editor.cursor(), Cursor { row: 2, column: 6 }); editor.handle(Input::Char('i')); type_text(&mut editor, "!"); editor.handle(Input::Escape); assert_eq!(editor.context().text, "a\t界!z\n!x\n123456!z"); assert_eq!(editor.selection_count(), 3); editor.handle(Input::Escape); assert_eq!(editor.selection_count(), 1); } #[test] fn visual_clones_preserve_directed_ranges_and_motion_targets_every_range() { let mut editor = editor(); editor .apply(vec![Effect::Replace("abc\nabcd\nabcde".into())]) .unwrap(); editor.handle(Input::Right); editor.handle(Input::Char('v')); editor.handle(Input::Right); editor.handle(Input::Ctrl('e')); assert_eq!(editor.selection_count(), 2); editor.handle(Input::Ctrl('e')); assert_eq!(editor.selection_count(), 3); editor.handle(Input::Left); assert!( editor .selections() .iter() .all(|range| range.start.column == 1 && range.end.column == 2) ); editor.handle(Input::Char('c')); type_text(&mut editor, "!"); editor.handle(Input::Escape); assert_eq!(editor.context().text, "a!c\na!cd\na!cde"); } #[test] fn batch_open_deduplicates_rows_and_join_uses_original_neighbor_rows() { for (key, expected) in [('o', "abc\n\nxyz\n"), ('O', "\nabc\n\nxyz")] { let mut editor = editor(); editor .apply(vec![Effect::Replace("abc\nxyz".into())]) .unwrap(); set_carets(&mut editor, &[(0, 0), (0, 2), (1, 1)], 2); editor.handle(Input::Char(key)); editor.handle(Input::Escape); assert_eq!(editor.context().text, expected); assert_eq!(editor.selection_count(), 2); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, "abc\nxyz"); assert_eq!(editor.selection_count(), 3); } let mut editor = editor(); editor .apply(vec![Effect::Replace("a\n b\n c".into())]) .unwrap(); set_carets(&mut editor, &[(0, 0), (1, 1)], 1); editor.handle(Input::Char('J')); assert_eq!(editor.context().text, "a b c"); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, "a\n b\n c"); assert_eq!(editor.selection_count(), 2); } #[test] fn plugin_text_effects_collapse_selection_but_presentation_effects_do_not() { let mut editor = editor(); editor .apply(vec![Effect::Replace("aa\nbb\ncc".into())]) .unwrap(); editor.register(plugin("sample", &["sample"])).unwrap(); set_carets(&mut editor, &[(0, 0), (1, 0), (2, 0)], 1); assert_eq!(editor.execute("sample").unwrap().command, "sample"); assert_eq!(editor.selection_count(), 3); editor.handle(Input::Char('v')); let ranges = editor.selections(); assert!(editor.apply(vec![Effect::Insert("\0".into())]).is_err()); editor .apply(vec![ Effect::Message("ok".into()), Effect::Label { name: "battery".into(), text: "ready".into(), }, ]) .unwrap(); assert_eq!(editor.label("battery"), "ready"); assert_eq!(editor.selections(), ranges); assert_eq!(editor.mode(), Mode::Visual); editor.apply(vec![Effect::Insert("!".into())]).unwrap(); assert_eq!(editor.context().text, "aa\n!bb\ncc"); assert_eq!(editor.selection_count(), 1); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, "aa\nbb\ncc"); assert_eq!(editor.selection_count(), 3); assert_eq!(editor.cursor(), Cursor { row: 1, column: 0 }); assert_eq!(editor.mode(), Mode::Normal); } #[test] fn literal_paste_broadcasts_and_retains_separate_typing_history_for_all_carets() { let mut editor = editor(); editor.apply(vec![Effect::Replace("a\nb".into())]).unwrap(); editor.handle(Input::Ctrl('e')); editor.handle(Input::Char('i')); type_text(&mut editor, "x"); editor.handle(Input::Paste("界e\u{301}\r\n".into())); type_text(&mut editor, "z"); editor.handle(Input::Escape); assert_eq!(editor.context().text, "x界e\u{301}\nza\nx界e\u{301}\nzb"); for expected in ["x界e\u{301}\na\nx界e\u{301}\nb", "xa\nxb", "a\nb"] { editor.handle(Input::Char('u')); assert_eq!(editor.context().text, expected); assert_eq!(editor.selection_count(), 2); } } #[test] fn caret_motions_and_search_apply_to_all_targets_and_collapse_duplicates() { let mut editor = editor(); editor .apply(vec![Effect::Replace("cat cat\ncat cat".into())]) .unwrap(); set_carets(&mut editor, &[(0, 0), (1, 0), (1, 0)], 2); assert_eq!(editor.selection_count(), 2); assert_eq!(editor.cursor(), Cursor { row: 1, column: 0 }); submit_search(&mut editor, "cat"); assert_eq!( editor.carets(), vec![Cursor { row: 0, column: 4 }, Cursor { row: 1, column: 4 }] ); editor.handle(Input::Char('b')); assert!(editor.carets().iter().all(|cursor| cursor.column == 0)); editor.handle(Input::Up); assert_eq!(editor.carets(), vec![Cursor::default()]); } #[test] fn word_motions_distinguish_punctuation_cross_empty_lines_and_preserve_history() { let mut editor = editor(); let text = "foo_bar...baz qux\n\n \n界e\u{301}_2 👩‍🚀!! tail"; editor.apply(vec![Effect::Replace(text.into())]).unwrap(); for (key, row, column) in [ ('w', 0, 7), ('w', 0, 10), ('w', 0, 15), ('w', 1, 0), ('w', 3, 0), ('w', 3, 5), ('w', 3, 9), ('w', 3, 12), ('w', 3, 12), ('b', 3, 9), ('b', 3, 5), ('b', 3, 0), ('b', 1, 0), ('b', 0, 15), ('b', 0, 10), ('b', 0, 7), ('b', 0, 0), ('b', 0, 0), ('e', 0, 6), ('e', 0, 9), ('e', 0, 12), ('e', 0, 17), ('e', 3, 3), ('e', 3, 7), ('e', 3, 12), ('e', 3, 12), ] { editor.handle(Input::Char(key)); assert_eq!(editor.cursor(), Cursor { row, column }, "{key}"); } assert_eq!(editor.context().text, text); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, ""); editor.handle(Input::Char('U')); assert_eq!(editor.context().text, text); } #[test] fn big_word_motions_use_only_whitespace_and_handle_buffer_edges() { let mut editor = editor(); editor .apply(vec![Effect::Replace( "foo_bar...baz qux\n\n \n界e\u{301}_2 👩‍🚀!! tail".into(), )]) .unwrap(); for (key, row, column) in [ ('W', 0, 15), ('W', 1, 0), ('W', 3, 0), ('W', 3, 5), ('W', 3, 9), ('W', 3, 12), ('B', 3, 9), ('B', 3, 5), ('B', 3, 0), ('B', 1, 0), ('B', 0, 15), ('B', 0, 0), ('B', 0, 0), ('E', 0, 12), ('E', 0, 17), ('E', 3, 3), ('E', 3, 7), ('E', 3, 12), ('E', 3, 12), ] { editor.handle(Input::Char(key)); assert_eq!(editor.cursor(), Cursor { row, column }, "{key}"); } for (text, next, end) in [("", 0, 0), (" ", 2, 2), ("word ", 6, 3)] { editor.apply(vec![Effect::Replace(text.into())]).unwrap(); editor.handle(Input::Home); editor.handle(Input::Char('e')); assert_eq!( editor.cursor(), Cursor { row: 0, column: end } ); editor.handle(Input::Char('w')); assert_eq!( editor.cursor(), Cursor { row: 0, column: next } ); editor.handle(Input::Char('b')); assert_eq!(editor.cursor(), Cursor { row: 0, column: 0 }); } editor .apply(vec![Effect::Replace("one\u{2003}two\u{a0}three\n".into())]) .unwrap(); editor.handle(Input::Home); editor.handle(Input::Char('w')); assert_eq!(editor.cursor(), Cursor { row: 0, column: 4 }); editor.handle(Input::Char('w')); assert_eq!(editor.cursor(), Cursor { row: 0, column: 8 }); editor.handle(Input::Char('w')); assert_eq!(editor.cursor(), Cursor { row: 1, column: 0 }); editor.handle(Input::Char('e')); assert_eq!(editor.cursor(), Cursor { row: 1, column: 0 }); editor.handle(Input::Char('b')); assert_eq!(editor.cursor(), Cursor { row: 0, column: 8 }); } #[test] fn search_wraps_both_ways_and_cancel_failure_and_navigation_preserve_edits() { let mut editor = editor(); let text = "zero cat\ncat cat\nCAT\n"; editor.apply(vec![Effect::Replace(text.into())]).unwrap(); submit_search(&mut editor, "cat"); assert_eq!(editor.cursor(), Cursor { row: 0, column: 5 }); assert_eq!(editor.search_match().unwrap().end_column, 8); editor.handle(Input::Char('n')); assert_eq!(editor.cursor(), Cursor { row: 1, column: 0 }); editor.handle(Input::Char('n')); assert_eq!(editor.cursor(), Cursor { row: 1, column: 4 }); editor.handle(Input::Char('n')); assert_eq!(editor.cursor(), Cursor { row: 0, column: 5 }); editor.handle(Input::Char('N')); assert_eq!(editor.cursor(), Cursor { row: 1, column: 4 }); editor.handle(Input::Char('N')); assert_eq!(editor.cursor(), Cursor { row: 1, column: 0 }); let cursor = editor.cursor(); let active = editor.search_match(); editor.handle(Input::Char('/')); type_text(&mut editor, "other界e\u{301}"); editor.handle(Input::Backspace); assert_eq!(editor.command_line(), "other界"); editor.handle(Input::Paste(":q!\n".into())); assert_eq!(editor.command_line(), "other界"); editor.handle(Input::Escape); assert_eq!(editor.mode(), Mode::Normal); assert_eq!(editor.cursor(), cursor); assert_eq!(editor.search_match(), active); assert!(editor.command_line().is_empty()); submit_search(&mut editor, ""); assert_eq!(editor.cursor(), Cursor { row: 1, column: 4 }); let cursor = editor.cursor(); submit_search(&mut editor, "missing"); assert_eq!(editor.mode(), Mode::Normal); assert_eq!(editor.cursor(), cursor); assert_eq!(editor.search_match(), None); editor.handle(Input::Char('N')); assert_eq!(editor.cursor(), cursor); assert!(!editor.should_quit()); assert_eq!(editor.context().text, text); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, ""); editor.handle(Input::Char('U')); assert_eq!(editor.context().text, text); } #[test] fn search_is_literal_overlapping_and_grapheme_safe_and_rechecks_after_edits() { let mut editor = editor(); editor .apply(vec![Effect::Replace("e\u{301} e 👩‍🚀 👩 aa... aaa".into())]) .unwrap(); submit_search(&mut editor, "e"); assert_eq!(editor.cursor().column, 2); submit_search(&mut editor, "\u{301}"); assert_eq!(editor.cursor().column, 2); assert_eq!(editor.search_match(), None); submit_search(&mut editor, "👩"); assert_eq!(editor.cursor().column, 6); submit_search(&mut editor, "👩‍🚀"); assert_eq!(editor.cursor().column, 4); assert_eq!(editor.search_match().unwrap().end_column, 5); submit_search(&mut editor, "."); assert_eq!(editor.cursor().column, 10); editor.handle(Input::Char('n')); assert_eq!(editor.cursor().column, 11); submit_search(&mut editor, "aa"); assert_eq!(editor.cursor().column, 14); editor.handle(Input::Char('n')); assert_eq!(editor.cursor().column, 15); editor.handle(Input::Char('n')); assert_eq!(editor.cursor().column, 8); submit_search(&mut editor, "e\u{301}"); assert_eq!(editor.cursor().column, 0); assert_eq!(editor.search_match().unwrap().end_column, 1); let active = editor.search_match(); assert!(editor.apply(vec![Effect::Replace("\0".into())]).is_err()); assert_eq!(editor.search_match(), active); editor.handle(Input::Char('x')); assert_eq!(editor.search_match(), None); editor.handle(Input::Char('n')); assert_eq!(editor.search_match(), None); editor.handle(Input::Char('u')); assert_eq!(editor.search_match(), None); editor.handle(Input::Char('n')); assert_eq!(editor.cursor().column, 0); assert_eq!(editor.search_match().unwrap().end_column, 1); editor .apply(vec![Effect::Replace("e\u{301}".into())]) .unwrap(); assert_eq!(editor.search_match(), None); editor.handle(Input::Char('n')); assert_eq!(editor.cursor().column, 0); assert_eq!(editor.search_match().unwrap().end_column, 1); } #[test] fn join_lines_matches_vim_spacing_and_grapheme_cursor_positions() { for (original, expected, column) in [ ("alpha\n \tbeta\ntail", "alpha beta\ntail", 5), ("alpha \n\tbeta", "alpha beta", 7), ("alpha\t\n beta", "alpha\tbeta", 6), ("\n beta", "beta", 0), ("alpha\n \t", "alpha", 4), ("\n", "", 0), ("alpha\n )tail", "alpha)tail", 5), ("Sentence.\n next", "Sentence. next", 9), ("界e\u{301}\n next", "界e\u{301} next", 2), ("x \n\u{301}y", "x \u{301}y", 1), ] { let mut editor = editor(); editor .apply(vec![Effect::Replace(original.into())]) .unwrap(); editor.handle(Input::Char('J')); assert_eq!(editor.context().text, expected); assert_eq!(editor.cursor(), Cursor { row: 0, column }); assert_eq!(editor.mode(), Mode::Normal); } } #[test] fn join_is_one_undo_action_on_the_current_row_and_restores_cursor() { let mut editor = editor(); let original = "above\nalpha\n beta\nbelow"; editor .apply(vec![Effect::Replace(original.into())]) .unwrap(); editor.handle(Input::Down); editor.handle(Input::Right); let before = editor.cursor(); editor.handle(Input::Char('J')); assert_eq!(editor.context().text, "above\nalpha beta\nbelow"); assert_eq!(editor.cursor(), Cursor { row: 1, column: 5 }); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, original); assert_eq!(editor.cursor(), before); editor.handle(Input::Char('U')); assert_eq!(editor.context().text, "above\nalpha beta\nbelow"); editor.handle(Input::Char('x')); assert_eq!(editor.context().text, "above\nalphabeta\nbelow"); } #[test] fn join_at_eof_preserves_history_and_insert_mode_j_is_literal() { let mut editor = editor(); editor .apply(vec![Effect::Replace("single".into())]) .unwrap(); editor.handle(Input::Char('u')); editor.handle(Input::Char('J')); assert_eq!(editor.context().text, ""); editor.handle(Input::Char('U')); assert_eq!(editor.context().text, "single"); editor.handle(Input::Char('J')); assert_eq!(editor.context().text, "single"); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, ""); editor.handle(Input::Char('U')); editor.handle(Input::Char('i')); editor.handle(Input::Char('J')); editor.handle(Input::Escape); assert_eq!(editor.context().text, "Jsingle"); } #[test] fn uppercase_u_redoes_and_control_r_no_longer_does() { let mut editor = editor(); editor.handle(Input::Char('i')); type_text(&mut editor, "changed"); editor.handle(Input::Escape); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, ""); editor.handle(Input::Ctrl('r')); assert_eq!(editor.context().text, ""); editor.handle(Input::Char('U')); assert_eq!(editor.context().text, "changed"); } #[test] fn uppercase_insert_keys_target_line_positions_and_preserve_undo_cursor() { for (original, key, expected) in [ (" \t界e\u{301} tail", 'I', " \tX界e\u{301} tail"), (" \t ", 'I', " \t X"), ("", 'I', "X"), (" \t界e\u{301} tail", 'A', " \t界e\u{301} tailX"), ("", 'A', "X"), ] { let mut editor = editor(); editor .apply(vec![Effect::Replace(original.into())]) .unwrap(); editor.handle(if key == 'I' { Input::End } else { Input::Right }); let before = editor.cursor(); editor.handle(Input::Char(key)); assert_eq!(editor.mode(), Mode::Insert); type_text(&mut editor, "X"); editor.handle(Input::Escape); assert_eq!(editor.context().text, expected); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, original); assert_eq!(editor.cursor(), before); editor.handle(Input::Char('U')); assert_eq!(editor.context().text, expected); } } #[test] fn paste_is_ignored_in_normal_and_command_modes_without_dispatching() { let mut editor = editor(); assert_eq!(editor.handle(Input::Paste(":q!\r\n".into())), None); assert_eq!(editor.mode(), Mode::Normal); assert_eq!(editor.context().text, ""); assert!(!editor.dirty()); assert!(!editor.should_quit()); editor.handle(Input::Char(':')); type_text(&mut editor, "q"); assert_eq!(editor.handle(Input::Paste("\rq!\n\0".into())), None); assert_eq!(editor.mode(), Mode::Command); assert_eq!(editor.command_line(), "q"); assert_eq!(editor.context().text, ""); assert!(!editor.should_quit()); editor.handle(Input::Enter); assert!(editor.should_quit()); } #[test] fn pasted_unicode_and_newlines_are_literal_and_a_separate_undo_action() { let mut editor = editor(); editor.handle(Input::Char('i')); type_text(&mut editor, "a"); assert_eq!( editor.handle(Input::Paste("界e\u{301}\t\r\n:q!\r".into())), None ); assert_eq!(editor.mode(), Mode::Insert); assert_eq!(editor.cursor(), Cursor { row: 2, column: 0 }); type_text(&mut editor, "z"); editor.handle(Input::Escape); assert_eq!(editor.context().text, "a界e\u{301}\t\n:q!\nz"); assert!(!editor.should_quit()); for expected in ["a界e\u{301}\t\n:q!\n", "a", ""] { editor.handle(Input::Char('u')); assert_eq!(editor.context().text, expected); } for expected in ["a", "a界e\u{301}\t\n:q!\n", "a界e\u{301}\t\n:q!\nz"] { editor.handle(Input::Char('U')); assert_eq!(editor.context().text, expected); } } #[test] fn rejected_paste_preserves_text_cursor_mode_and_pending_insert_history() { for text in [ "valid prefix\ninvalid\0".to_owned(), "\u{1b}[31m".to_owned(), "x".repeat(MAX_EDIT_BYTES + 1), ] { let mut editor = editor(); editor.handle(Input::Char('i')); type_text(&mut editor, "before"); let cursor = editor.cursor(); editor.handle(Input::Paste(text)); assert_eq!(editor.context().text, "before"); assert_eq!(editor.cursor(), cursor); assert_eq!(editor.mode(), Mode::Insert); assert!( editor.message().contains("Unsupported control character") || editor.message().contains("Paste exceeds 4 MiB") ); type_text(&mut editor, "after"); editor.handle(Input::Escape); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, ""); editor.handle(Input::Char('U')); assert_eq!(editor.context().text, "beforeafter"); } } #[test] fn insert_sessions_and_open_lines_are_single_undo_actions() { let mut editor = editor(); editor.handle(Input::Char('i')); type_text(&mut editor, "hello\n世界"); editor.handle(Input::Escape); assert_eq!(editor.context().text, "hello\n世界"); assert!(editor.dirty()); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, ""); assert!(!editor.dirty()); editor.handle(Input::Char('U')); assert_eq!(editor.context().text, "hello\n世界"); editor.handle(Input::Char('O')); type_text(&mut editor, "above"); editor.handle(Input::Escape); assert_eq!(editor.context().text, "hello\nabove\n世界"); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, "hello\n世界"); editor.handle(Input::Char('o')); type_text(&mut editor, "below"); editor.handle(Input::Escape); assert_eq!(editor.context().text, "hello\n世界\nbelow"); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, "hello\n世界"); } #[test] fn grapheme_navigation_and_deletion_do_not_split_unicode() { let mut editor = editor(); editor .apply(vec![Effect::Replace("a\u{301}👩‍💻界".into())]) .unwrap(); editor.handle(Input::Char('l')); assert_eq!(editor.cursor().column, 1); editor.handle(Input::Char('x')); assert_eq!(editor.context().text, "a\u{301}界"); editor.handle(Input::Char('0')); editor.handle(Input::Delete); assert_eq!(editor.context().text, "界"); editor.handle(Input::Char('a')); type_text(&mut editor, "👨‍👩‍👧"); editor.handle(Input::Backspace); assert_eq!(editor.context().text, "界"); editor.handle(Input::Escape); assert_eq!(editor.cursor().column, 0); assert_eq!(editor.context().column, 1); } #[test] fn insertion_merges_combining_graphemes_and_joins_lines() { let mut editor = editor(); editor.handle(Input::Char('i')); type_text(&mut editor, "a\u{301}\nb"); editor.handle(Input::Home); editor.handle(Input::Backspace); assert_eq!(editor.context().text, "a\u{301}b"); assert_eq!(editor.cursor(), Cursor { row: 0, column: 1 }); editor.handle(Input::Enter); editor.handle(Input::Up); editor.handle(Input::End); editor.handle(Input::Delete); assert_eq!(editor.context().text, "a\u{301}b"); editor.handle(Input::Escape); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, ""); } #[test] fn cursor_is_clamped_on_short_and_empty_lines() { let mut editor = editor(); editor .apply(vec![Effect::Replace("long\nx\n".into())]) .unwrap(); editor.handle(Input::End); assert_eq!(editor.cursor().column, 3); editor.handle(Input::Down); assert_eq!(editor.cursor(), Cursor { row: 1, column: 0 }); editor.handle(Input::Char('j')); editor.handle(Input::Char('l')); editor.handle(Input::Char('x')); assert_eq!(editor.cursor(), Cursor { row: 2, column: 0 }); assert_eq!(editor.context().row, 3); editor.handle(Input::Char('i')); editor.handle(Input::Left); editor.handle(Input::Delete); editor.handle(Input::Escape); assert_eq!(editor.context().text, "long\nx\n"); } #[test] fn registry_rejects_incompatible_duplicate_and_forged_core_atomically() { let mut editor = editor(); let mut feature = plugin("graphics", &["draw"]); feature.required_capabilities.push("graphics".into()); assert!(editor.register(feature).is_err()); assert!(editor.bind("normal", "g", Some("draw".into())).is_err()); assert!( editor .register(plugin("duplicate", &["newcmd", "w"])) .is_err() ); assert!(editor.bind("normal", "g", Some("newcmd".into())).is_err()); assert!( editor .register(plugin("repeat", &["same", "same"])) .is_err() ); assert!(editor.register(plugin("invalid", &["bad name"])).is_err()); for name in [ ".hover", "lsp.", "lsp..hover", "lsp.3hover", "lsp.hover!extra", ] { assert!( editor .register(plugin("invalid", &["newcmd", name])) .is_err() ); assert!(editor.bind("normal", "g", Some("newcmd".into())).is_err()); } assert!(editor.register(plugin("", &["emptyname"])).is_err()); let mut feature = plugin("forged", &["override"]); feature.core = true; assert!(editor.register(feature).is_err()); editor.register(plugin("working", &["greet"])).unwrap(); assert!(editor.register(plugin("working", &["different"])).is_err()); assert!(editor.register(plugin("other", &["greet"])).is_err()); assert!(editor.register(plugin("collision", &["plugins"])).is_err()); editor.disable_plugin("core", "attempt"); editor.execute("q"); assert!(editor.should_quit()); } #[test] fn readable_key_aliases_share_bindings_and_invalid_chords_leave_them_intact() { let mut editor = editor(); editor.bind("normal", "shift+i", None).unwrap(); editor.handle(Input::Char('I')); assert_eq!(editor.mode(), Mode::Normal); editor .bind("normal", "I", Some("core.insert".into())) .unwrap(); editor.handle(Input::Char('I')); assert_eq!(editor.mode(), Mode::Insert); editor .bind("insert", "plus", Some("core.newline".into())) .unwrap(); editor.bind("insert", "+", None).unwrap(); editor.handle(Input::Char('+')); type_text(&mut editor, "界"); assert_eq!(editor.lines(), &["界"]); editor .bind("insert", "plus", Some("core.newline".into())) .unwrap(); editor.handle(Input::Char('+')); assert_eq!(editor.lines(), &["界", ""]); editor.bind("insert", "CTRL+I", None).unwrap(); editor.handle(Input::Char('\t')); assert_eq!(editor.lines(), &["界", ""]); editor .bind("insert", "TAB", Some("core.newline".into())) .unwrap(); editor.handle(Input::Ctrl('i')); assert_eq!(editor.lines(), &["界", "", ""]); editor .bind("insert", "shift+j", Some("core.normal".into())) .unwrap(); for key in [ "", "", "", "ctrl+", "+r", "ctrl++r", "ctrl+shift+j", "shift+1", "cmd+j", "ctrl+界", ] { assert!( editor .bind("insert", key, Some("core.newline".into())) .is_err(), "{key}" ); } editor.handle(Input::Char('J')); assert_eq!(editor.mode(), Mode::Normal); editor.handle(Input::Char('u')); assert_eq!(editor.lines(), &[String::new()]); } #[test] fn plugin_bindings_dispatch_only_normal_mode_and_retire_with_owner() { let mut editor = editor(); editor.register(plugin("greeting", &["greet"])).unwrap(); assert!(editor.bind("insert", "g", Some("greet".into())).is_err()); editor.bind("normal", "g", Some("greet".into())).unwrap(); assert_eq!( editor.handle(Input::Char('g')), Some(Invocation { plugin: "greeting".into(), command: "greet".into(), args: String::new(), }) ); editor.handle(Input::Char('i')); assert_eq!(editor.handle(Input::Char('g')), None); assert_eq!(editor.context().text, "g"); editor.handle(Input::Escape); editor.disable_plugin("greeting", "crashed\u{1b}[31m"); assert_eq!(editor.handle(Input::Char('g')), None); assert!(editor.bind("normal", "g", Some("greet".into())).is_err()); assert!(!editor.message().contains('\u{1b}')); } #[test] fn command_mode_dispatches_arguments_and_backspaces_whole_graphemes() { let mut editor = editor(); editor.register(plugin("greeting", &["greet"])).unwrap(); editor.handle(Input::Char(':')); type_text(&mut editor, "greet a\u{301}"); editor.handle(Input::Backspace); assert_eq!(editor.command_line(), "greet "); type_text(&mut editor, "world"); assert_eq!( editor.handle(Input::Enter), Some(Invocation { plugin: "greeting".into(), command: "greet".into(), args: "world".into(), }) ); assert_eq!(editor.mode(), Mode::Normal); assert_eq!(editor.command_line(), ""); editor.handle(Input::Char(':')); type_text(&mut editor, "q!"); editor.handle(Input::Escape); assert!(!editor.should_quit()); assert_eq!(editor.command_line(), ""); } #[test] fn chrome_installation_and_invalid_label_batches_are_atomic() { let mut editor = editor(); editor.set_label("battery".into(), "ready".into()).unwrap(); let mut top = ChromeLine::top(); top.left = vec!["mode".into(), "language".into()]; top.center.push("workspace".into()); let mut bottom = ChromeLine::bottom(); bottom.left = vec!["tabs".into()]; editor .set_chrome_config( top.clone(), bottom.clone(), BTreeMap::from([ ("battery".into(), "ready".into()), ("workspace".into(), "tools\n".into()), ]), ) .unwrap(); assert_eq!(editor.label("workspace"), "tools�"); assert_eq!( editor.top_bar_config().segment("workspace"), &crate::chrome::SegmentConfig::default() ); let installed_top = editor.top_bar_config().clone(); let installed_bottom = editor.bottom_bar_config().clone(); assert_eq!(installed_top.left, ["mode", "language"]); assert_eq!(installed_bottom.left, ["tabs"]); top.right.push("uninstalled".into()); bottom.center.push("uninstalled".into()); let mut invalid_top = top.clone(); invalid_top.segments.get_mut("tabs").unwrap().title = "{missing}".into(); let mut invalid_bottom = bottom.clone(); invalid_bottom.segments.get_mut("mode").unwrap().padding = 65; for (top, bottom, labels) in [ (invalid_top, bottom.clone(), BTreeMap::new()), (top.clone(), invalid_bottom, BTreeMap::new()), ( top, bottom, BTreeMap::from([("bad-name".into(), "invalid".into())]), ), ] { assert!(editor.set_chrome_config(top, bottom, labels).is_err()); assert_eq!(editor.top_bar_config(), &installed_top); assert_eq!(editor.bottom_bar_config(), &installed_bottom); assert_eq!(editor.label("battery"), "ready"); assert_eq!(editor.label("workspace"), "tools�"); } assert!( editor .apply(vec![ Effect::Message("must not leak".into()), Effect::Label { name: "battery".into(), text: "changed".into() }, Effect::Insert("must not edit".into()), Effect::Label { name: "bad-name".into(), text: "invalid".into() }, ]) .is_err() ); assert_eq!(editor.label("battery"), "ready"); assert_eq!(editor.message(), ""); assert_eq!(editor.context().text, ""); assert_eq!(editor.text_revision(), 0); } #[test] fn native_battery_cache_validates_changes_without_editing_or_replacing_labels() { let mut editor = editor(); editor.handle(Input::Char('i')); editor.handle(Input::Paste("document".into())); editor.handle(Input::Escape); editor.handle(Input::Char('v')); let text = editor.context().text; let selection = editor.selections(); let revision = editor.text_revision(); let history = (editor.active.undo.len(), editor.active.redo.len()); assert_eq!(editor.battery_label(), ""); assert!(!editor.set_battery_charge(None)); assert!(!editor.set_battery_charge(Some(101))); assert!(editor.set_battery_charge(Some(27))); assert_eq!(editor.battery_label(), "bat 27%"); assert!(!editor.set_battery_charge(Some(27))); assert_eq!(editor.label("battery"), ""); editor.set_label("battery".into(), "custom".into()).unwrap(); assert!(editor.set_battery_charge(Some(0))); assert_eq!(editor.battery_label(), "bat 0%"); assert_eq!(editor.label("battery"), "custom"); assert!(editor.set_battery_charge(Some(100))); assert_eq!(editor.battery_label(), "bat 100%"); assert!(editor.set_battery_charge(Some(255))); assert_eq!(editor.battery_label(), ""); assert!(!editor.set_battery_charge(None)); assert_eq!(editor.label("battery"), "custom"); assert_eq!(editor.context().text, text); assert_eq!(editor.selections(), selection); assert_eq!(editor.mode(), Mode::Visual); assert_eq!(editor.text_revision(), revision); assert_eq!( (editor.active.undo.len(), editor.active.redo.len()), history ); assert!(editor.set_battery_charge(Some(28))); editor.execute("tab.new"); assert_eq!(editor.battery_label(), "bat 28%"); editor.set_label("battery".into(), String::new()).unwrap(); assert_eq!(editor.battery_label(), "bat 28%"); assert_eq!(editor.label("battery"), ""); assert!(editor.set_battery_charge(None)); assert_eq!(editor.battery_label(), ""); } #[test] fn named_labels_sanitize_and_survive_tabs_until_independently_cleared() { let mut editor = editor(); assert_eq!(editor.label("battery"), ""); assert!( editor .set_label("battery".into(), "界 e\u{301} 👩‍🚀".into()) .unwrap() ); assert!( !editor .set_label("battery".into(), "界 e\u{301} 👩‍🚀".into()) .unwrap() ); assert!( editor .set_label("battery".into(), "界\0\t\r\n\u{1b}\u{85}👩‍🚀".into()) .unwrap() ); editor .set_label("workspace".into(), "tools".into()) .unwrap(); assert_eq!(editor.label("battery"), "界������👩‍🚀"); editor.set_message("transient"); editor.execute("tab.new"); assert_eq!(editor.label("battery"), "界������👩‍🚀"); editor.execute("tab.previous"); assert_eq!(editor.label("workspace"), "tools"); assert!(editor.set_label("battery".into(), String::new()).unwrap()); editor.execute("tab.next"); assert_eq!(editor.label("battery"), ""); assert!(!editor.set_label("battery".into(), String::new()).unwrap()); assert_eq!(editor.label("workspace"), "tools"); } #[test] fn readonly_diff_accepts_atomic_label_updates_without_editing_state() { let directory = Directory::new(); let mut editor = editor(); historical_diff( &mut editor, &directory.0, vec!["base".into()], vec!["head".into()], ); editor.handle(Input::Char('v')); let selection = editor.selections(); let revision = editor.text_revision(); let text = editor.context().text; let history = (editor.active.undo.len(), editor.active.redo.len()); editor .apply(vec![ Effect::Label { name: "battery".into(), text: "界\n\u{1b}".into(), }, Effect::Message("read only".into()), ]) .unwrap(); assert_eq!(editor.label("battery"), "界��"); assert_eq!(editor.message(), "read only"); assert_eq!(editor.mode(), Mode::Visual); assert_eq!(editor.selections(), selection); assert_eq!(editor.text_revision(), revision); assert_eq!(editor.context().text, text); assert_eq!( (editor.active.undo.len(), editor.active.redo.len()), history ); assert!( editor .apply(vec![ Effect::Label { name: "battery".into(), text: "must not leak".into() }, Effect::Insert("rejected".into()), ]) .is_err() ); assert_eq!(editor.label("battery"), "界��"); editor .apply(vec![Effect::Label { name: "battery".into(), text: String::new(), }]) .unwrap(); assert_eq!(editor.label("battery"), ""); } #[test] fn plugin_effects_validate_before_any_mutation_and_group_undo() { let mut editor = editor(); editor.set_message("unchanged"); editor.handle(Input::Escape); assert!(!editor.notification_visible()); editor .set_label("battery".into(), "unchanged".into()) .unwrap(); assert!( editor .apply(vec![ Effect::Label { name: "battery".into(), text: "must not leak".into() }, Effect::Insert("good".into()), Effect::Message("bad\u{1b}".into()) ]) .is_err() ); assert_eq!(editor.context().text, ""); assert_eq!(editor.message(), "unchanged"); assert!(!editor.notification_visible()); assert_eq!(editor.label("battery"), "unchanged"); assert!(!editor.dirty()); assert!( editor .apply(vec![ Effect::Label { name: "battery".into(), text: "x".into() }, Effect::Insert("a".repeat(MAX_EDIT_BYTES)) ]) .is_err() ); assert_eq!(editor.context().text, ""); assert_eq!(editor.label("battery"), "unchanged"); editor .apply(vec![ Effect::Insert("one".into()), Effect::Insert("\ntwo".into()), Effect::Message("ok".into()), Effect::Label { name: "battery".into(), text: "updated".into(), }, ]) .unwrap(); assert_eq!(editor.context().text, "one\ntwo"); assert_eq!(editor.message(), "ok"); assert!(editor.notification_visible()); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, ""); assert_eq!(editor.label("battery"), "updated"); editor.handle(Input::Char('U')); assert_eq!(editor.context().text, "one\ntwo"); editor .apply(vec![Effect::Replace("replacement".into())]) .unwrap(); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, "one\ntwo"); } #[test] fn plugin_effect_transactions_preserve_insert_sessions() { let mut editor = editor(); editor.handle(Input::Char('i')); type_text(&mut editor, "before"); let cursor = editor.cursor(); let undo_len = editor.active.undo.len(); editor .apply(vec![ Effect::Label { name: "battery".into(), text: "typing".into(), }, Effect::Message("notice".into()), ]) .unwrap(); assert_eq!(editor.active.undo.len(), undo_len); assert!( editor .apply(vec![Effect::Replace("invalid\0".into())]) .is_err() ); assert_eq!(editor.mode(), Mode::Insert); assert_eq!(editor.cursor(), cursor); type_text(&mut editor, "after"); editor.handle(Input::Escape); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, ""); editor.handle(Input::Char('i')); type_text(&mut editor, "a"); editor .apply(vec![ Effect::Insert("b".into()), Effect::Message("plugin".into()), ]) .unwrap(); type_text(&mut editor, "c"); editor.handle(Input::Escape); assert_eq!(editor.context().text, "abc"); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, "ab"); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, "a"); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, ""); } #[test] fn no_op_edits_preserve_redo_but_new_edits_clear_it() { let mut editor = editor(); editor.apply(vec![Effect::Insert("a".into())]).unwrap(); editor.handle(Input::Char('u')); editor.handle(Input::Char('i')); editor.handle(Input::Escape); editor .apply(vec![Effect::Message("notice".into())]) .unwrap(); editor.handle(Input::Char('U')); assert_eq!(editor.context().text, "a"); editor.handle(Input::Char('u')); editor.apply(vec![Effect::Insert("b".into())]).unwrap(); editor.handle(Input::Char('U')); assert_eq!(editor.context().text, "b"); } #[test] fn saves_preserve_empty_files_lf_trailing_newlines_and_dirty_history() { let directory = Directory::new(); for (name, text) in [ ("empty", ""), ("no-newline", "hello"), ("newline", "hello\n"), ("two-newlines", "hello\n\n"), ] { let path = directory.path(name); fs::write(&path, text).unwrap(); let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap(); editor.set_clipboard_mode("separate").unwrap(); editor.execute("w"); assert_eq!(fs::read_to_string(&path).unwrap(), text); assert!(!editor.dirty()); } let path = directory.path("new file"); let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap(); editor.set_clipboard_mode("separate").unwrap(); editor.handle(Input::Char('i')); type_text(&mut editor, "saved\n"); editor.handle(Input::Escape); editor.execute("q"); assert!(!editor.should_quit()); editor.execute("w"); assert_eq!(fs::read_to_string(&path).unwrap(), "saved\n"); assert!(!editor.dirty()); editor.handle(Input::Char('u')); assert!(editor.dirty()); editor.handle(Input::Char('U')); assert!(!editor.dirty()); editor.execute("q"); assert!(editor.should_quit()); } #[test] fn save_as_and_failed_wq_keep_path_dirty_state_and_quit_correct() { let directory = Directory::new(); let mut editor = editor(); editor .apply(vec![Effect::Insert("unsaved".into())]) .unwrap(); editor.execute("w"); assert!(editor.dirty()); let bad = directory.path("absent/file"); editor.execute(&format!("wq {}", bad.display())); assert!(editor.dirty()); assert!(!editor.should_quit()); assert_eq!(editor.path(), None); let path = directory.path("save as.txt"); editor.execute(&format!("w {}", path.display())); assert_eq!(editor.path(), Some(path.as_path())); assert!(!editor.dirty()); editor.apply(vec![Effect::Insert("new".into())]).unwrap(); editor.execute("q!"); assert!(editor.should_quit()); assert_eq!(fs::read_to_string(path).unwrap(), "unsaved"); } #[test] fn loaded_controls_are_rejected_and_cr_line_endings_are_normalized() { let directory = Directory::new(); let path = directory.path("control"); for text in ["nul\0", "escape\u{1b}[31m", "backspace\u{8}", "c1\u{85}"] { fs::write(&path, text).unwrap(); assert!( matches!(Editor::open(Some(path.clone()), vec![]), Err(error) if error.kind() == io::ErrorKind::InvalidData) ); } fs::write(&path, "a\t界\r\nb\rc\n").unwrap(); let editor = Editor::open(Some(path), vec![]).unwrap(); assert_eq!(editor.context().text, "a\t界\nb\nc\n"); assert!(!editor.dirty()); assert!(Editor::open(Some(directory.0.clone()), vec![]).is_err()); } #[cfg(unix)] #[test] fn saves_preserve_permissions_and_reject_symlinks_without_replacing_them() { use std::os::unix::fs::{PermissionsExt, symlink}; let directory = Directory::new(); let path = directory.path("original"); fs::write(&path, "original").unwrap(); fs::set_permissions(&path, fs::Permissions::from_mode(0o640)).unwrap(); let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap(); editor.set_clipboard_mode("separate").unwrap(); editor .apply(vec![Effect::Replace("changed".into())]) .unwrap(); editor.execute("w"); assert_eq!( fs::metadata(&path).unwrap().permissions().mode() & 0o777, 0o640 ); let link = directory.path("link"); symlink(&path, &link).unwrap(); let mut editor = Editor::open(Some(link.clone()), vec![]).unwrap(); editor.set_clipboard_mode("separate").unwrap(); editor .apply(vec![Effect::Replace("not saved".into())]) .unwrap(); for command in ["wq", "wq!"] { editor.execute(command); assert!(editor.dirty()); assert!(!editor.should_quit()); } assert!( fs::symlink_metadata(&link) .unwrap() .file_type() .is_symlink() ); assert_eq!(fs::read_to_string(&path).unwrap(), "changed"); assert_eq!(fs::read_dir(&directory.0).unwrap().count(), 2); } fn annotation(start: (usize, usize), end: (usize, usize), message: &str) -> Diagnostic { Diagnostic { start: TextPosition { row: start.0, column: start.1, }, end: TextPosition { row: end.0, column: end.1, }, severity: crate::plugin::DiagnosticSeverity::Warning, message: message.into(), } } #[test] fn annotations_are_owner_scoped_atomic_and_grapheme_bounded() { let mut editor = editor(); editor .apply(vec![Effect::Replace("a😀e\u{301}\n".into())]) .unwrap(); let id = editor.buffer_id(); let revision = editor.text_revision(); let first = annotation((0, 1), (0, 3), "first"); editor .replace_diagnostics("one", id, revision, vec![first.clone()]) .unwrap(); editor .replace_diagnostics("two", id, revision, vec![annotation((1, 0), (1, 0), "end")]) .unwrap(); for invalid in [ annotation((0, 0), (0, 4), "scalar column"), annotation((0, 3), (0, 2), "reversed"), annotation((2, 0), (2, 0), "missing row"), annotation((0, 0), (0, 1), "control\u{1b}"), ] { assert!( editor .replace_diagnostics("one", id, revision, vec![first.clone(), invalid]) .is_err() ); assert_eq!(editor.diagnostics().count(), 2); assert_eq!( editor.active.diagnostics["one"].items.as_slice(), std::slice::from_ref(&first) ); } assert!( !editor .replace_diagnostics( "one", id, revision - 1, vec![annotation((99, 0), (99, 0), "stale")] ) .unwrap() ); assert!( !editor .replace_diagnostics("one", 999, revision, vec![first.clone()]) .unwrap() ); editor.active.view.targets = vec![Target::caret(Cursor { row: 0, column: 1 })]; assert_eq!(editor.diagnostic_at_cursor().unwrap().message, "first"); editor.active.view.targets = vec![Target::caret(Cursor { row: 1, column: 0 })]; assert_eq!(editor.diagnostic_at_cursor().unwrap().message, "end"); editor.clear_plugin_diagnostics("one"); assert_eq!(editor.diagnostics().count(), 1); } #[test] fn annotations_do_not_split_typing_undo_and_clear_on_text_change() { let mut editor = editor(); editor.handle(Input::Char('i')); editor.handle(Input::Char('a')); let revision = editor.text_revision(); editor .replace_diagnostics( "one", editor.buffer_id(), revision, vec![annotation((0, 0), (0, 1), "typed")], ) .unwrap(); assert!(editor.active.view.insert_start.is_some()); assert!(editor.active.undo.is_empty()); editor.handle(Input::Char('b')); assert_eq!(editor.diagnostics().count(), 0); editor.handle(Input::Escape); editor.handle(Input::Char('u')); assert_eq!(editor.lines(), [""]); assert!(editor.active.undo.is_empty()); } #[test] fn navigation_reuses_dirty_hidden_text_and_failure_preserves_source() { let directory = Directory::new(); let source = directory.path("source"); let target = directory.path("target"); fs::write(&source, "source").unwrap(); fs::write(&target, "a😀e\u{301}\n").unwrap(); let mut editor = Editor::open(Some(source.clone()), vec!["text".into()]).unwrap(); editor.set_clipboard_mode("separate").unwrap(); editor.execute(&format!("edit {}", target.display())); editor .apply(vec![Effect::Replace("dirty😀e\u{301}\n".into())]) .unwrap(); let target_id = editor.buffer_id(); editor.execute("buffer 1"); editor.handle(Input::Char('i')); editor.handle(Input::Char('!')); let before = editor.context().text; let cursor = editor.cursor(); let undo = editor.active.undo.len(); assert!( editor .navigate_to( directory.path("missing"), TextPosition { row: 0, column: 0 } ) .is_err() ); assert_eq!(editor.buffer_id(), 1); assert_eq!(editor.context().text, before); assert_eq!(editor.cursor(), cursor); assert_eq!(editor.mode(), Mode::Insert); assert_eq!(editor.active.undo.len(), undo); assert!(editor.active.view.insert_start.is_some()); editor .navigate_to( target, TextPosition { row: 99, column: 99, }, ) .unwrap(); assert_eq!(editor.buffer_id(), target_id); assert_eq!(editor.lines(), ["dirty😀e\u{301}", ""]); assert_eq!(editor.buffer_count(), 2); assert_eq!(editor.cursor(), Cursor { row: 1, column: 0 }); assert_eq!(editor.mode(), Mode::Normal); assert_eq!(fs::read_to_string(source).unwrap(), "source"); } fn completion_item(start: (usize, usize), end: (usize, usize), text: &str) -> CompletionItem { CompletionItem { label: text.into(), detail: None, primary_edit: 0, snippet: None, edits: vec![CompletionEdit { start: TextPosition { row: start.0, column: start.1, }, end: TextPosition { row: end.0, column: end.1, }, text: text.into(), }], } } #[test] fn completion_cycles_from_original_spans_and_restores_pending_typing() { let mut editor = editor(); type_text(&mut editor, "iabcd"); let revision = editor.text_revision(); let steps = editor .active .view .pending_edit .as_ref() .unwrap() .steps .len(); editor .show_completion(vec![ completion_item((0, 2), (0, 4), "X"), completion_item((0, 0), (0, 1), "Q\nZ"), ]) .unwrap(); assert_eq!(editor.context().text, "abX"); assert_eq!(editor.completion().unwrap().selected, 1); editor.handle(Input::Ctrl('n')); assert_eq!(editor.context().text, "Q\nZbcd"); assert_eq!(editor.cursor(), Cursor { row: 1, column: 1 }); editor.handle(Input::Ctrl('j')); assert_eq!(editor.context().text, "abcd"); assert_eq!(editor.cursor(), Cursor { row: 0, column: 4 }); assert_eq!(editor.completion().unwrap().selected, 0); assert_eq!( editor .active .view .pending_edit .as_ref() .unwrap() .steps .len(), steps ); assert!(editor.text_revision() > revision); editor.handle(Input::Ctrl('k')); assert_eq!(editor.context().text, "Q\nZbcd"); editor.handle(Input::Char('!')); assert!(editor.completion().is_none()); assert_eq!(editor.context().text, "Q\nZ!bcd"); editor.handle(Input::Escape); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, ""); } #[test] fn completion_same_span_noop_preview_and_original_preserve_redo() { let mut editor = editor(); type_text(&mut editor, "iold"); editor.handle(Input::Escape); editor.handle(Input::Char('u')); assert_eq!(editor.active.redo.len(), 1); editor.handle(Input::Char('i')); editor .show_completion(vec![ completion_item((0, 0), (0, 0), "one"), completion_item((0, 0), (0, 0), "one"), ]) .unwrap(); let revision = editor.text_revision(); editor.handle(Input::Ctrl('n')); assert_eq!(editor.context().text, "one"); assert_eq!(editor.text_revision(), revision); editor.handle(Input::Ctrl('n')); assert_eq!(editor.context().text, ""); editor.handle(Input::Escape); assert_eq!(editor.active.redo.len(), 1); editor.handle(Input::Char('U')); assert_eq!(editor.context().text, "old"); } #[test] fn completion_noncycle_keys_keep_standard_insert_semantics() { for (key, expected, mode) in [ (Input::Char('!'), "completed!", Mode::Insert), (Input::Enter, "completed\n", Mode::Insert), (Input::Char('\t'), "completed\t", Mode::Insert), (Input::Escape, "completed", Mode::Normal), (Input::Left, "completed", Mode::Insert), (Input::Ctrl('z'), "completed", Mode::Insert), ] { let mut editor = editor(); type_text(&mut editor, "iprefix"); editor .show_completion(vec![completion_item((0, 0), (0, 6), "completed")]) .unwrap(); editor.handle(key); assert!(editor.completion().is_none()); assert_eq!(editor.context().text, expected); assert_eq!(editor.mode(), mode); if mode == Mode::Insert { editor.handle(Input::Escape); } editor.handle(Input::Char('u')); assert_eq!(editor.context().text, ""); } } #[test] fn completion_request_and_cycle_use_effective_insert_binding() { let mut editor = editor(); type_text(&mut editor, "iab"); let steps = editor .active .view .pending_edit .as_ref() .unwrap() .steps .len(); assert_eq!( editor.handle(Input::Ctrl('n')), Some(Invocation { plugin: "core".into(), command: "lsp.completion".into(), args: String::new(), }) ); assert_eq!(editor.mode(), Mode::Insert); assert_eq!( editor .active .view .pending_edit .as_ref() .unwrap() .steps .len(), steps ); assert!(editor.active.undo.is_empty()); editor .show_completion(vec![ completion_item((0, 0), (0, 2), "first"), completion_item((0, 0), (0, 2), "second"), ]) .unwrap(); editor.bind("insert", "Ctrl+n", None).unwrap(); editor .bind("insert", "Ctrl+x", Some("core.lsp.completion".into())) .unwrap(); editor.handle(Input::Ctrl('x')); assert_eq!(editor.context().text, "second"); editor.handle(Input::Ctrl('n')); assert!(editor.completion().is_none()); assert_eq!(editor.context().text, "second"); } #[test] fn completion_dot_rebases_only_final_choice_and_refuses_out_of_bounds_atomically() { let mut editor = editor(); editor .apply(vec![Effect::Replace("left ab".into())]) .unwrap(); editor.handle(Input::Char('A')); editor .show_completion(vec![ completion_item((0, 5), (0, 7), "wrong"), completion_item((0, 5), (0, 7), "final"), ]) .unwrap(); editor.handle(Input::Ctrl('n')); editor.handle(Input::Escape); editor .apply(vec![Effect::Replace("different zz".into())]) .unwrap(); editor.handle(Input::Char('.')); assert_eq!(editor.context().text, "different final"); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, "different zz"); editor.apply(vec![Effect::Replace("x".into())]).unwrap(); let revision = editor.text_revision(); let cursor = editor.cursor(); editor.handle(Input::Char('.')); assert_eq!(editor.context().text, "x"); assert_eq!(editor.cursor(), cursor); assert_eq!(editor.text_revision(), revision); assert!(editor.message().contains("outside")); editor .apply(vec![Effect::Replace("new yy".into())]) .unwrap(); editor.handle(Input::Char('.')); assert_eq!(editor.context().text, "new final"); } #[test] fn completion_additional_edits_translate_unicode_secondary_carets() { let mut editor = editor(); editor .apply(vec![Effect::Replace("e\u{301}x\n👩‍🚀z".into())]) .unwrap(); editor.handle(Input::Ctrl('e')); let undo_carets = editor.carets(); editor.handle(Input::Char('A')); let original = editor.carets(); let mut item = completion_item((1, 0), (1, 2), "界\n好"); item.edits.push(CompletionEdit { start: TextPosition { row: 0, column: 0 }, end: TextPosition { row: 0, column: 1 }, text: "prefix".into(), }); editor.show_completion(vec![item]).unwrap(); assert_eq!(editor.context().text, "prefixx\n界\n好"); assert_eq!(editor.cursor(), Cursor { row: 2, column: 1 }); assert_eq!( editor.carets(), [Cursor { row: 0, column: 7 }, Cursor { row: 2, column: 1 },] ); editor.handle(Input::Ctrl('n')); assert_eq!(editor.context().text, "e\u{301}x\n👩‍🚀z"); assert_eq!(editor.carets(), original); editor.handle(Input::Ctrl('k')); editor.handle(Input::Escape); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, "e\u{301}x\n👩‍🚀z"); assert_eq!(editor.carets(), undo_carets); } #[test] fn completion_validation_rejects_entire_set_before_any_mutation() { let mut editor = editor(); type_text(&mut editor, "iabc"); let good = completion_item((0, 0), (0, 3), "valid"); let mut bad_primary = good.clone(); bad_primary.primary_edit = 1; let mut overlap = good.clone(); overlap.edits.push(overlap.edits[0].clone()); for invalid in [ bad_primary, overlap, completion_item((1, 0), (1, 0), "bad"), completion_item((0, 4), (0, 4), "bad"), completion_item((0, 2), (0, 1), "bad"), completion_item((0, 0), (0, 3), "\0"), completion_item((0, 0), (0, 3), &"x".repeat(MAX_EDIT_BYTES + 1)), ] { let revision = editor.text_revision(); let steps = editor .active .view .pending_edit .as_ref() .unwrap() .steps .len(); assert!(editor.show_completion(vec![good.clone(), invalid]).is_err()); assert_eq!(editor.context().text, "abc"); assert_eq!(editor.cursor(), Cursor { row: 0, column: 3 }); assert_eq!(editor.text_revision(), revision); assert_eq!( editor .active .view .pending_edit .as_ref() .unwrap() .steps .len(), steps ); assert!(editor.completion().is_none()); assert!(editor.active.undo.is_empty()); } let revision = editor.text_revision(); editor.show_completion(Vec::new()).unwrap(); assert_eq!(editor.context().text, "abc"); assert_eq!(editor.text_revision(), revision); assert_eq!(editor.mode(), Mode::Insert); assert!(editor.completion().is_none()); assert_eq!(editor.message(), "No completions"); } #[test] fn completion_overlay_command_buffer_and_paste_boundaries_commit_displayed_choice() { for boundary in ["palette", "startup", "command", "paste", "rejected_paste"] { let mut editor = editor(); type_text(&mut editor, "iab"); editor .show_completion(vec![completion_item((0, 0), (0, 2), "chosen")]) .unwrap(); match boundary { "palette" => { editor.handle(Input::Ctrl('p')); } "startup" => editor.show_startup_diagnostic("notice".into()), "command" => { editor.execute("plugins"); } "paste" => { editor.handle(Input::Paste("!".into())); } _ => { editor.handle(Input::Paste("\0".into())); } } assert!(editor.completion().is_none()); assert_eq!( editor.context().text, if boundary == "paste" { "chosen!" } else { "chosen" } ); if boundary == "palette" || boundary == "startup" { editor.handle(Input::Escape); } if editor.mode() == Mode::Insert { editor.handle(Input::Escape); } if boundary == "paste" { editor.handle(Input::Char('u')); assert_eq!(editor.context().text, "chosen"); } editor.handle(Input::Char('u')); assert_eq!(editor.context().text, ""); } let directory = Directory::new(); let other = directory.path("other"); fs::write(&other, "other").unwrap(); let mut editor = editor(); type_text(&mut editor, "iab"); editor .show_completion(vec![completion_item((0, 0), (0, 2), "chosen")]) .unwrap(); editor.execute(&format!("edit {}", other.display())); assert!(editor.completion().is_none()); editor.execute("buffer 1"); assert_eq!(editor.context().text, "chosen"); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, ""); } #[test] fn completion_dot_additional_edits_require_compatible_context_and_replay_atomically() { let mut editor = editor(); editor .apply(vec![Effect::Replace("use\nab".into())]) .unwrap(); type_text(&mut editor, "jA"); let mut item = completion_item((1, 0), (1, 2), "done"); item.edits.push(CompletionEdit { start: TextPosition { row: 0, column: 0 }, end: TextPosition { row: 0, column: 3 }, text: "import".into(), }); editor.show_completion(vec![item]).unwrap(); editor.handle(Input::Escape); editor .apply(vec![Effect::Replace("use\nzz".into())]) .unwrap(); type_text(&mut editor, "j."); assert_eq!(editor.context().text, "import\ndone"); editor .apply(vec![Effect::Replace("bad\nzz".into())]) .unwrap(); type_text(&mut editor, "j"); let before = editor.context().text; let carets = editor.carets(); let revision = editor.text_revision(); editor.handle(Input::Char('.')); assert_eq!(editor.context().text, before); assert_eq!(editor.carets(), carets); assert_eq!(editor.text_revision(), revision); assert!(editor.message().contains("context changed")); } fn snippet_item( start: (usize, usize), end: (usize, usize), text: &str, stops: Vec<(u32, Vec>)>, ) -> CompletionItem { let mut item = completion_item(start, end, text); item.snippet = Some(crate::plugin::CompletionSnippet { tabstops: stops .into_iter() .map(|(index, ranges)| SnippetTabstop { index, ancestors: vec![Vec::new(); ranges.len()], ranges, }) .collect(), }); item } fn nested_stop( mut item: CompletionItem, index: u32, ancestors: Vec>, ) -> CompletionItem { item.snippet .as_mut() .unwrap() .tabstops .iter_mut() .find(|stop| stop.index == index) .unwrap() .ancestors = ancestors; item } #[test] fn snippet_arguments_navigation_undo_redo_and_dot_use_resolved_text() { let directory = Directory::new(); let mut editor = editor(); editor.set_tab_spaces(NonZeroU8::new(2), BTreeMap::new()); type_text(&mut editor, "ica"); editor .show_completion(vec![snippet_item( (0, 0), (0, 2), "call(arg, count)", vec![(1, vec![5..8]), (2, vec![10..15]), (0, vec![16..16])], )]) .unwrap(); assert_eq!(editor.context().text, "call(arg, count)"); assert_eq!(editor.cursor(), Cursor { row: 0, column: 5 }); assert_eq!( editor.snippet_selections(), [Selection { start: Cursor { row: 0, column: 5 }, end: Cursor { row: 0, column: 8 }, }] ); type_text(&mut editor, "item"); assert!(editor.completion().is_none()); assert_eq!(editor.context().text, "call(item, count)"); editor.handle(Input::Char('\t')); type_text(&mut editor, "3"); editor.handle(Input::BackTab); type_text(&mut editor, "foo"); editor.handle(Input::Char('\t')); type_text(&mut editor, "5"); editor.handle(Input::Char('\t')); assert!(editor.snippet_selections().is_empty()); assert_eq!(editor.cursor(), Cursor { row: 0, column: 12 }); type_text(&mut editor, ";\n\ttail"); editor.handle(Input::Escape); let resolved = "call(foo, 5);\n tail"; let caret = editor.cursor(); assert_eq!(editor.context().text, resolved); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, ""); editor.handle(Input::Char('U')); assert_eq!(editor.context().text, resolved); assert_eq!(editor.cursor(), caret); editor.execute(&format!("edit {}", directory.path("repeat").display())); editor.handle(Input::Char('.')); assert_eq!(editor.context().text, resolved); assert_eq!(editor.cursor(), caret); assert!(editor.completion().is_none()); assert!(editor.snippet_selections().is_empty()); } #[test] fn snippet_empty_stops_unicode_multiline_and_mirrors_track_argument_edits() { let mut editor = editor(); editor.handle(Input::Char('i')); editor .show_completion(vec![snippet_item( (0, 0), (0, 0), "f(, 😀,\ne\u{301}, 😀)", vec![ (1, vec![2..2]), (2, vec![4..8, 15..19]), (3, vec![10..13]), (0, vec![20..20]), ], )]) .unwrap(); assert_eq!( editor.snippet_selections()[0].start, editor.snippet_selections()[0].end ); type_text(&mut editor, "中"); editor.handle(Input::Char('\t')); type_text(&mut editor, "🦀x"); editor.handle(Input::Backspace); assert_eq!(editor.context().text, "f(中, 🦀,\ne\u{301}, 🦀)"); assert_eq!(editor.snippet_selections().len(), 2); editor.handle(Input::Char('\t')); type_text(&mut editor, "a\u{301}"); assert_eq!(editor.context().text, "f(中, 🦀,\na\u{301}, 🦀)"); assert_eq!(editor.cursor(), Cursor { row: 1, column: 1 }); editor.handle(Input::BackTab); editor.handle(Input::Delete); assert_eq!(editor.context().text, "f(中, ,\na\u{301}, )"); editor.handle(Input::Escape); assert_eq!(editor.mode(), Mode::Normal); assert!(editor.snippet_selections().is_empty()); } #[test] fn modified_backspace_snippets_stay_inside_arguments_and_update_mirrors() { let mut editor = editor(); editor .bind( "insert", "alt+backspace", Some("core.backspace_word".into()), ) .unwrap(); editor .bind( "insert", "cmd+backspace", Some("core.backspace_line".into()), ) .unwrap(); editor.handle(Input::Char('i')); editor .show_completion(vec![snippet_item( (0, 0), (0, 0), "f(one, one)", vec![(1, vec![2..5, 7..10]), (0, vec![11..11])], )]) .unwrap(); type_text(&mut editor, "alpha βeta"); editor.handle(Input::AltBackspace); assert_eq!(editor.context().text, "f(alpha , alpha )"); editor.handle(Input::SuperBackspace); assert_eq!(editor.context().text, "f(, )"); editor.handle(Input::SuperBackspace); editor.handle(Input::AltBackspace); assert_eq!(editor.context().text, "f(, )"); type_text(&mut editor, "keep"); assert_eq!(editor.context().text, "f(keep, keep)"); editor.handle(Input::Escape); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, ""); } #[test] fn snippet_nested_mirrors_update_parents_and_replacing_parent_retires_children() { let mut editor = editor(); editor.handle(Input::Char('i')); editor .show_completion(vec![nested_stop( snippet_item( (0, 0), (0, 0), "f(outer inner, outer inner, last)", vec![ (1, vec![2..13, 15..26]), (2, vec![8..13, 21..26]), (3, vec![28..32]), (0, vec![33..33]), ], ), 2, vec![vec![1]; 2], )]) .unwrap(); editor.handle(Input::Char('\t')); type_text(&mut editor, "word"); assert_eq!(editor.context().text, "f(outer word, outer word, last)"); editor.handle(Input::BackTab); type_text(&mut editor, "body"); editor.handle(Input::Char('\t')); type_text(&mut editor, "end"); editor.handle(Input::Char('\t')); assert_eq!(editor.context().text, "f(body, body, end)"); assert!(editor.snippet_selections().is_empty()); let mut editor = self::editor(); editor.handle(Input::Char('i')); editor .show_completion(vec![nested_stop( snippet_item( (0, 0), (0, 0), "f(foo, last)", vec![ (1, vec![2..5]), (2, vec![2..5]), (3, vec![7..11]), (0, vec![12..12]), ], ), 2, vec![vec![1]], )]) .unwrap(); type_text(&mut editor, "value"); editor.handle(Input::Char('\t')); type_text(&mut editor, "end"); assert_eq!(editor.context().text, "f(value, end)"); } #[test] fn snippet_cycling_restores_carets_and_argument_edits_never_broadcast_to_secondaries() { let mut editor = editor(); editor .apply(vec![Effect::Replace("ab\nzz".into())]) .unwrap(); editor.handle(Input::Ctrl('e')); let undo_carets = editor.carets(); editor.handle(Input::Char('A')); let original = editor.carets(); let mut item = snippet_item( (1, 0), (1, 2), "f(arg)", vec![(1, vec![2..5]), (0, vec![6..6])], ); item.edits.push(CompletionEdit { start: TextPosition { row: 0, column: 0 }, end: TextPosition { row: 0, column: 1 }, text: "prefix".into(), }); editor.show_completion(vec![item]).unwrap(); assert_eq!( editor.carets(), [Cursor { row: 0, column: 7 }, Cursor { row: 1, column: 2 },] ); editor.handle(Input::Ctrl('n')); assert_eq!(editor.context().text, "ab\nzz"); assert_eq!(editor.carets(), original); assert!(editor.snippet_selections().is_empty()); editor.handle(Input::Ctrl('k')); type_text(&mut editor, "long"); assert_eq!(editor.context().text, "prefixb\nf(long)"); assert_eq!(editor.carets()[0], Cursor { row: 0, column: 7 }); editor.handle(Input::Escape); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, "ab\nzz"); assert_eq!(editor.carets(), undo_carets); } #[test] fn snippet_keys_respect_disabled_remapped_bindings_and_tab_remains_literal_outside() { let mut editor = editor(); type_text(&mut editor, "i\t"); assert_eq!(editor.context().text, "\t"); editor.bind("insert", "Tab", None).unwrap(); editor .show_completion(vec![snippet_item( (0, 0), (0, 1), "f(one, two)", vec![(1, vec![2..5]), (2, vec![7..10]), (0, vec![11..11])], )]) .unwrap(); editor.handle(Input::Char('\t')); assert_eq!(editor.cursor(), Cursor { row: 0, column: 2 }); editor .bind("insert", "Ctrl+f", Some("core.snippet.next".into())) .unwrap(); editor .bind("insert", "Shift+Tab", Some("core.snippet.previous".into())) .unwrap(); editor.handle(Input::Ctrl('f')); assert_eq!(editor.cursor(), Cursor { row: 0, column: 7 }); editor.handle(Input::BackTab); assert_eq!(editor.cursor(), Cursor { row: 0, column: 2 }); editor .bind("insert", "Tab", Some("core.right".into())) .unwrap(); editor.handle(Input::Char('\t')); assert!(editor.snippet_selections().is_empty()); assert_eq!(editor.cursor(), Cursor { row: 0, column: 3 }); type_text(&mut editor, "x"); assert_eq!(editor.context().text, "f(oxne, two)"); } #[test] fn snippet_validation_is_atomic_and_newline_offsets_are_normalized() { let mut editor = editor(); type_text(&mut editor, "iab"); for stops in [ vec![(1, vec![2..3]), (0, vec![8..8])], vec![(2, vec![1..5]), (1, vec![5..7]), (0, vec![8..8])], vec![(1, vec![1..6]), (2, vec![5..7]), (0, vec![8..8])], vec![(1, vec![1..5, 5..7]), (0, vec![8..8])], vec![(1, vec![1..5])], ] { let before = editor.context().text; let carets = editor.carets(); let item = snippet_item((0, 0), (0, 2), "(😀ab)", stops); assert!(editor.show_completion(vec![item]).is_err()); assert_eq!(editor.context().text, before); assert_eq!(editor.carets(), carets); assert!(editor.completion().is_none()); } let before = editor.context().text; let carets = editor.carets(); assert!( editor .show_completion(vec![snippet_item( (0, 0), (0, 2), "f(\r\narg)", vec![(1, vec![3..6]), (0, vec![7..7])], )]) .is_err() ); assert_eq!(editor.context().text, before); assert_eq!(editor.carets(), carets); editor .show_completion(vec![snippet_item( (0, 0), (0, 2), "f(\narg)", vec![(1, vec![3..6]), (0, vec![7..7])], )]) .unwrap(); assert_eq!(editor.context().text, "f(\narg)"); assert_eq!(editor.cursor(), Cursor { row: 1, column: 0 }); type_text(&mut editor, "界"); assert_eq!(editor.context().text, "f(\n界)"); } #[test] fn snippet_motion_overlay_command_plugin_and_paste_boundaries_end_navigation() { for boundary in [ "motion", "overlay", "command", "plugin", "paste", "invalid_paste", ] { let mut editor = editor(); editor.handle(Input::Char('i')); editor .show_completion(vec![snippet_item( (0, 0), (0, 0), "f(arg)", vec![(1, vec![2..5]), (0, vec![6..6])], )]) .unwrap(); match boundary { "motion" => { editor.handle(Input::Right); } "overlay" => { editor.handle(Input::Ctrl('p')); } "command" => { editor.execute("plugins"); } "plugin" => { editor .apply(vec![Effect::Replace("replacement".into())]) .unwrap(); } "paste" => { editor.handle(Input::Paste("literal".into())); } "invalid_paste" => { editor.handle(Input::Paste("\0".into())); } _ => unreachable!(), } assert!(editor.snippet_selections().is_empty(), "{boundary}"); assert!(editor.completion().is_none(), "{boundary}"); } } #[test] fn snippet_final_caret_in_middle_replays_before_later_ordinary_typing() { let directory = Directory::new(); let mut editor = editor(); editor.handle(Input::Char('i')); editor .show_completion(vec![snippet_item( (0, 0), (0, 0), "f(arg) tail", vec![(1, vec![2..5]), (0, vec![6..6])], )]) .unwrap(); type_text(&mut editor, "X"); editor.handle(Input::Char('\t')); assert_eq!(editor.cursor(), Cursor { row: 0, column: 4 }); type_text(&mut editor, "!"); editor.handle(Input::Escape); let caret = editor.cursor(); assert_eq!(editor.context().text, "f(X)! tail"); editor.execute(&format!("edit {}", directory.path("repeat").display())); editor.handle(Input::Char('.')); assert_eq!(editor.context().text, "f(X)! tail"); assert_eq!(editor.cursor(), caret); } #[test] fn snippet_adjacent_empty_stops_remain_independent_when_typed() { let mut editor = editor(); editor.handle(Input::Char('i')); editor .show_completion(vec![snippet_item( (0, 0), (0, 0), "f()", vec![(1, vec![2..2]), (2, vec![2..2]), (0, vec![3..3])], )]) .unwrap(); type_text(&mut editor, "one"); editor.handle(Input::Char('\t')); type_text(&mut editor, "two"); editor.handle(Input::BackTab); type_text(&mut editor, "first"); editor.handle(Input::Char('\t')); type_text(&mut editor, "second"); assert_eq!(editor.context().text, "f(firstsecond)"); } #[test] fn snippet_ancestry_distinguishes_nested_empty_boundary_from_adjacent_root() { for nested in [false, true] { let mut editor = editor(); editor.handle(Input::Char('i')); let item = snippet_item( (0, 0), (0, 0), "f(foo)", vec![(1, vec![2..5]), (2, vec![5..5]), (0, vec![6..6])], ); let item = if nested { nested_stop(item, 2, vec![vec![1]]) } else { item }; editor.show_completion(vec![item]).unwrap(); type_text(&mut editor, "X"); editor.handle(Input::Char('\t')); if nested { assert!(editor.snippet_selections().is_empty()); assert_eq!(editor.cursor(), Cursor { row: 0, column: 4 }); } else { assert_eq!(editor.cursor(), Cursor { row: 0, column: 3 }); assert_eq!(editor.snippet_selections().len(), 1); type_text(&mut editor, "Y"); editor.handle(Input::BackTab); type_text(&mut editor, "Z"); assert_eq!(editor.context().text, "f(ZY)"); } } let mut editor = editor(); editor.handle(Input::Char('i')); editor .show_completion(vec![nested_stop( snippet_item( (0, 0), (0, 0), "f(foo)", vec![(1, vec![2..5]), (2, vec![5..5]), (0, vec![6..6])], ), 2, vec![vec![1]], )]) .unwrap(); editor.handle(Input::Char('\t')); type_text(&mut editor, "X"); editor.handle(Input::BackTab); type_text(&mut editor, "Y"); editor.handle(Input::Char('\t')); assert_eq!(editor.context().text, "f(Y)"); assert!(editor.snippet_selections().is_empty()); } #[test] fn snippet_replacing_parent_preserves_unrelated_child_mirror_occurrences() { let mut editor = editor(); editor.handle(Input::Char('i')); editor .show_completion(vec![nested_stop( snippet_item( (0, 0), (0, 0), "f(foo, foo)", vec![(1, vec![2..5]), (2, vec![2..5, 7..10]), (0, vec![11..11])], ), 2, vec![vec![1], vec![]], )]) .unwrap(); type_text(&mut editor, "X"); editor.handle(Input::Char('\t')); assert_eq!(editor.cursor(), Cursor { row: 0, column: 5 }); type_text(&mut editor, "Y"); assert_eq!(editor.context().text, "f(X, Y)"); } #[test] fn snippet_coincident_empty_mirror_multiplicity_survives_typing_and_deletion() { let mut editor = editor(); editor.handle(Input::Char('i')); editor .show_completion(vec![snippet_item( (0, 0), (0, 0), "f()", vec![(1, vec![2..2, 2..2]), (0, vec![3..3])], )]) .unwrap(); type_text(&mut editor, "xy"); assert_eq!(editor.context().text, "f(xyxy)"); editor.handle(Input::Backspace); assert_eq!(editor.context().text, "f(xx)"); editor.handle(Input::Backspace); assert_eq!(editor.context().text, "f()"); type_text(&mut editor, "z"); assert_eq!(editor.context().text, "f(zz)"); editor.handle(Input::Char('\t')); assert_eq!(editor.cursor(), Cursor { row: 0, column: 5 }); assert!(editor.snippet_selections().is_empty()); editor.handle(Input::Char('\t')); assert_eq!(editor.context().text, "f(zz)\t"); } #[test] fn snippet_ancestry_validation_rejects_misalignment_unknown_ids_cycles_and_false_containment() { let mut editor = editor(); editor.handle(Input::Char('i')); let item = || { snippet_item( (0, 0), (0, 0), "f(foo, bar)", vec![(1, vec![2..5]), (2, vec![7..10]), (0, vec![11..11])], ) }; let mut misaligned = item(); misaligned.snippet.as_mut().unwrap().tabstops[0] .ancestors .clear(); let unknown = nested_stop(item(), 2, vec![vec![999]]); let containment = nested_stop(item(), 2, vec![vec![1]]); let cycle = nested_stop(nested_stop(item(), 1, vec![vec![2]]), 2, vec![vec![1]]); for candidate in [misaligned, unknown, containment, cycle] { assert!(editor.show_completion(vec![candidate]).is_err()); assert_eq!(editor.context().text, ""); assert_eq!(editor.cursor(), Cursor::default()); assert!(editor.completion().is_none()); } } }