10027 lines
377 KiB
Rust
10027 lines
377 KiB
Rust
use crate::clipboard::{ClipboardMode, SystemClipboard};
|
||
use crate::files::{DockSide, FilePicker, FilePresentation, FileView};
|
||
use crate::plugin::{Diagnostic, TextPosition};
|
||
use serde::Serialize;
|
||
use std::borrow::Cow;
|
||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||
use std::fmt;
|
||
use std::fs::{self, OpenOptions};
|
||
use std::io::{self, Write};
|
||
#[cfg(unix)]
|
||
use std::os::unix::fs::OpenOptionsExt;
|
||
use std::path::{Component, Path, PathBuf};
|
||
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, Debug, Eq, Hash, PartialEq)]
|
||
pub enum Input {
|
||
Char(char),
|
||
Ctrl(char),
|
||
Paste(String),
|
||
Escape,
|
||
Enter,
|
||
Backspace,
|
||
Delete,
|
||
Left,
|
||
Right,
|
||
Up,
|
||
Down,
|
||
Home,
|
||
End,
|
||
}
|
||
|
||
#[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,
|
||
/// Current filtered results, sorted by label then name.
|
||
pub entries: Vec<PaletteEntry>,
|
||
pub selected: usize,
|
||
}
|
||
|
||
pub struct Hover {
|
||
text: String,
|
||
line_starts: Vec<usize>,
|
||
/// 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,
|
||
}
|
||
|
||
impl Hover {
|
||
pub fn text(&self) -> &str {
|
||
&self.text
|
||
}
|
||
|
||
pub fn lines(&self) -> impl ExactSizeIterator<Item = &str> {
|
||
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<String>,
|
||
pub enabled: bool,
|
||
pub modes: Vec<Mode>,
|
||
}
|
||
|
||
#[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<String>,
|
||
pub commands: Vec<String>,
|
||
pub core: bool,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||
pub enum Effect {
|
||
Insert(String),
|
||
Replace(String),
|
||
Message(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<String>,
|
||
}
|
||
|
||
#[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<String>,
|
||
targets: Vec<Target>,
|
||
primary: usize,
|
||
}
|
||
|
||
struct Edit<'a> {
|
||
target: usize,
|
||
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<InsertStep>,
|
||
}
|
||
|
||
#[cfg_attr(test, derive(Debug))]
|
||
enum EditStart {
|
||
Insert(Action),
|
||
Selection {
|
||
change: bool,
|
||
selector: SelectionIntent,
|
||
},
|
||
Delete,
|
||
Join,
|
||
Paste {
|
||
after: bool,
|
||
fragments: Vec<String>,
|
||
},
|
||
}
|
||
|
||
#[cfg_attr(test, derive(Debug))]
|
||
enum SelectionIntent {
|
||
Motions(Vec<(Action, usize)>),
|
||
// Whole graphemes, with a newline counting as one boundary.
|
||
Spans(Vec<usize>),
|
||
}
|
||
|
||
#[cfg_attr(test, derive(Debug))]
|
||
enum InsertStep {
|
||
Typed(String),
|
||
Bulk(String),
|
||
Move(Action, usize),
|
||
Delete(Vec<bool>),
|
||
Backspace(Vec<bool>),
|
||
}
|
||
|
||
#[cfg_attr(test, derive(Debug))]
|
||
#[derive(Clone, Copy, Eq, PartialEq)]
|
||
enum Action {
|
||
Insert,
|
||
InsertFirstNonblank,
|
||
Append,
|
||
AppendLineEnd,
|
||
OpenBelow,
|
||
OpenAbove,
|
||
Left,
|
||
Right,
|
||
Down,
|
||
Up,
|
||
JoinLines,
|
||
LineStart,
|
||
LineEnd,
|
||
Delete,
|
||
Undo,
|
||
Redo,
|
||
RepeatLastEdit,
|
||
Command,
|
||
Normal,
|
||
Newline,
|
||
Backspace,
|
||
Submit,
|
||
Search,
|
||
SearchNext,
|
||
SearchPrevious,
|
||
WordNext,
|
||
WordPrevious,
|
||
WordEnd,
|
||
BigWordNext,
|
||
BigWordPrevious,
|
||
BigWordEnd,
|
||
Select,
|
||
Copy,
|
||
Cut,
|
||
CopySystem,
|
||
Change,
|
||
PasteBefore,
|
||
PasteAfter,
|
||
PasteSystemBefore,
|
||
PasteSystemAfter,
|
||
SelectLines,
|
||
CloneAbove,
|
||
CloneBelow,
|
||
SelectNext,
|
||
SkipOccurrence,
|
||
SelectAll,
|
||
Palette,
|
||
FilePicker,
|
||
FileExplorer,
|
||
LspDefinition,
|
||
LspHover,
|
||
LspDiagnostics,
|
||
}
|
||
|
||
// Names and mode eligibility are shared by configured bindings and the palette.
|
||
const CORE_ACTIONS: &[(&str, Action, &[Mode])] = &[
|
||
("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_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.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.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.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.lsp.definition",
|
||
Action::LspDefinition,
|
||
&[Mode::Normal],
|
||
),
|
||
("core.lsp.hover", Action::LspHover, &[Mode::Normal]),
|
||
(
|
||
"core.lsp.diagnostics",
|
||
Action::LspDiagnostics,
|
||
&[Mode::Normal],
|
||
),
|
||
];
|
||
|
||
impl Action {
|
||
fn named(mode: Mode, name: &str) -> Option<Self> {
|
||
CORE_ACTIONS
|
||
.iter()
|
||
.find(|(candidate, _, modes)| *candidate == name && modes.contains(&mode))
|
||
.map(|(_, action, _)| *action)
|
||
}
|
||
}
|
||
|
||
enum Binding {
|
||
Action(Action),
|
||
Command(String),
|
||
Disabled,
|
||
}
|
||
|
||
fn parse_key(key: &str) -> Result<Input, String> {
|
||
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:?}");
|
||
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(ch)) if ch.is_ascii_alphabetic() => {
|
||
if modifier.eq_ignore_ascii_case("ctrl") {
|
||
Ok(control_key(ch))
|
||
} 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::Char(' ') => display.push_str("Space"),
|
||
Input::Char(ch) => display.push(*ch),
|
||
Input::Ctrl(ch) => write!(display, "Ctrl+{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::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<Mode, HashMap<Input, Binding>> {
|
||
use Action::*;
|
||
let mut bindings: HashMap<Mode, HashMap<Input, Binding>> = HashMap::new();
|
||
for (key, action) in [
|
||
('i', Insert),
|
||
('I', InsertFirstNonblank),
|
||
('a', Append),
|
||
('A', AppendLineEnd),
|
||
('o', OpenBelow),
|
||
('O', OpenAbove),
|
||
('J', JoinLines),
|
||
('x', Delete),
|
||
('u', Undo),
|
||
('U', Redo),
|
||
(':', Command),
|
||
('/', Search),
|
||
('p', PasteAfter),
|
||
('P', PasteBefore),
|
||
] {
|
||
bindings
|
||
.entry(Mode::Normal)
|
||
.or_default()
|
||
.insert(Input::Char(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),
|
||
('n', SearchNext),
|
||
('N', SearchPrevious),
|
||
('v', Select),
|
||
('.', RepeatLastEdit),
|
||
] {
|
||
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));
|
||
}
|
||
for mode in [Mode::Insert, Mode::Command, Mode::Search] {
|
||
for (key, action) in [
|
||
(Input::Escape, Normal),
|
||
(Input::Backspace, Backspace),
|
||
(
|
||
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));
|
||
}
|
||
}
|
||
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,
|
||
}
|
||
|
||
struct AnnotationSet {
|
||
revision: u64,
|
||
items: Vec<Diagnostic>,
|
||
}
|
||
|
||
struct Buffer {
|
||
id: usize,
|
||
lines: Vec<String>,
|
||
saved_lines: Vec<String>,
|
||
text_revision: u64,
|
||
save_revision: u64,
|
||
diagnostics: BTreeMap<String, AnnotationSet>,
|
||
targets: Vec<Target>,
|
||
primary: usize,
|
||
mode: Mode,
|
||
search_query: String,
|
||
search_match: Option<SearchMatch>,
|
||
path: Option<PathBuf>,
|
||
identity: Option<PathBuf>,
|
||
// ponytail: full-buffer history costs O(buffer size) per action; use edit deltas for large files.
|
||
undo: Vec<Snapshot>,
|
||
redo: Vec<Snapshot>,
|
||
insert_start: Option<Snapshot>,
|
||
pending_edit: Option<EditRecipe>,
|
||
visual_motions: Option<Vec<(Action, usize)>>,
|
||
}
|
||
|
||
impl Buffer {
|
||
fn open(
|
||
id: usize,
|
||
path: Option<PathBuf>,
|
||
identity: Option<PathBuf>,
|
||
allow_missing: bool,
|
||
) -> io::Result<Self> {
|
||
let text = match &path {
|
||
Some(path) => match fs::read_to_string(path) {
|
||
Ok(text) => text,
|
||
Err(error) if allow_missing && error.kind() == io::ErrorKind::NotFound => {
|
||
String::new()
|
||
}
|
||
Err(error) => return Err(error),
|
||
},
|
||
None => String::new(),
|
||
};
|
||
validate_text(&text).map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?;
|
||
let lines = split_lines(&text);
|
||
Ok(Self {
|
||
id,
|
||
saved_lines: lines.clone(),
|
||
lines,
|
||
text_revision: 0,
|
||
save_revision: 0,
|
||
diagnostics: BTreeMap::new(),
|
||
targets: vec![Target::caret(Cursor::default())],
|
||
primary: 0,
|
||
mode: Mode::Normal,
|
||
search_query: String::new(),
|
||
search_match: None,
|
||
path,
|
||
identity,
|
||
undo: Vec::new(),
|
||
redo: Vec::new(),
|
||
insert_start: None,
|
||
pending_edit: None,
|
||
visual_motions: None,
|
||
})
|
||
}
|
||
|
||
fn dirty(&self) -> bool {
|
||
self.lines != self.saved_lines
|
||
}
|
||
|
||
fn info(&self, active: bool) -> BufferInfo<'_> {
|
||
BufferInfo {
|
||
id: self.id,
|
||
path: self.path.as_deref(),
|
||
dirty: self.dirty(),
|
||
active,
|
||
}
|
||
}
|
||
|
||
fn document(&self) -> DocumentInfo<'_> {
|
||
DocumentInfo {
|
||
id: self.id,
|
||
revision: self.text_revision,
|
||
path: self.path.as_deref(),
|
||
lines: &self.lines,
|
||
save_revision: self.save_revision,
|
||
}
|
||
}
|
||
|
||
fn text_changed(&mut self) {
|
||
self.text_revision = self.text_revision.wrapping_add(1);
|
||
self.diagnostics.clear();
|
||
}
|
||
}
|
||
|
||
pub struct Editor {
|
||
active: Buffer,
|
||
parked: BTreeMap<usize, Buffer>,
|
||
next_buffer_id: usize,
|
||
command_line: String,
|
||
clipboard: Vec<String>,
|
||
clipboard_mode: ClipboardMode,
|
||
system_clipboard: SystemClipboard,
|
||
// Exact exported bytes bridge our fragments; external newlines never imply boundaries.
|
||
owned_system_clipboard: Option<(String, Vec<String>)>,
|
||
last_edit: Option<EditRecipe>,
|
||
message: String,
|
||
quit: bool,
|
||
capabilities: Vec<String>,
|
||
features: HashMap<String, Feature>,
|
||
bindings: HashMap<Mode, HashMap<Input, Binding>>,
|
||
selection_bindings: HashMap<Input, Binding>,
|
||
selection_navigation_hint: String,
|
||
tree_bindings: HashMap<Input, Binding>,
|
||
tree_navigation_hint: String,
|
||
tree_filter_navigation_hint: String,
|
||
leader: Input,
|
||
leader_bindings: HashMap<Mode, HashMap<Vec<Input>, Binding>>,
|
||
leader_suffix: Vec<Input>,
|
||
leader_display: String,
|
||
palette: Option<Palette>,
|
||
hover: Option<Hover>,
|
||
project_root: PathBuf,
|
||
file_picker: Option<FilePicker>,
|
||
file_explorer_side: DockSide,
|
||
file_picker_view: FileView,
|
||
file_explorer_view: FileView,
|
||
count: Option<usize>,
|
||
}
|
||
|
||
impl Editor {
|
||
pub fn open(path: Option<PathBuf>, capabilities: Vec<String>) -> io::Result<Self> {
|
||
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,
|
||
command_line: String::new(),
|
||
clipboard: Vec::new(),
|
||
clipboard_mode: ClipboardMode::System,
|
||
system_clipboard: SystemClipboard::new(),
|
||
owned_system_clipboard: None,
|
||
last_edit: None,
|
||
message: String::new(),
|
||
quit: 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(' '),
|
||
leader_bindings: HashMap::new(),
|
||
leader_suffix: Vec::new(),
|
||
leader_display: String::new(),
|
||
palette: None,
|
||
hover: 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,
|
||
};
|
||
editor.refresh_selection_navigation_hint();
|
||
editor
|
||
.install(
|
||
Feature {
|
||
name: "core".into(),
|
||
required_capabilities: Vec::new(),
|
||
commands: [
|
||
"w",
|
||
"q",
|
||
"q!",
|
||
"wq",
|
||
"plugins",
|
||
"edit",
|
||
"buffers",
|
||
"buffer",
|
||
"bnext",
|
||
"bprev",
|
||
"bdelete",
|
||
"bdelete!",
|
||
"lsp.definition",
|
||
"lsp.hover",
|
||
"lsp.diagnostics",
|
||
]
|
||
.into_iter()
|
||
.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", "w"),
|
||
("normal", "leader b n", "bnext"),
|
||
("normal", "leader b p", "bprev"),
|
||
("normal", "leader b l", "buffers"),
|
||
("normal", "leader b d", "bdelete"),
|
||
] {
|
||
editor
|
||
.bind(mode, key, Some(target.into()))
|
||
.map_err(io::Error::other)?;
|
||
}
|
||
Ok(editor)
|
||
}
|
||
|
||
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.parked.len() + 1
|
||
}
|
||
|
||
pub fn has_buffer(&self, id: usize) -> bool {
|
||
id == self.active.id || self.parked.contains_key(&id)
|
||
}
|
||
|
||
/// All loaded buffers in ascending ID order, without copying their paths.
|
||
pub fn buffers(&self) -> impl Iterator<Item = BufferInfo<'_>> + '_ {
|
||
self.parked
|
||
.range(..self.active.id)
|
||
.map(|(_, buffer)| buffer.info(false))
|
||
.chain(std::iter::once(self.active.info(true)))
|
||
.chain(
|
||
self.parked
|
||
.range(self.active.id..)
|
||
.map(|(_, buffer)| buffer.info(false)),
|
||
)
|
||
}
|
||
|
||
pub fn documents(&self) -> impl Iterator<Item = DocumentInfo<'_>> + '_ {
|
||
self.parked
|
||
.range(..self.active.id)
|
||
.map(|(_, buffer)| buffer.document())
|
||
.chain(std::iter::once(self.active.document()))
|
||
.chain(
|
||
self.parked
|
||
.range(self.active.id..)
|
||
.map(|(_, buffer)| buffer.document()),
|
||
)
|
||
}
|
||
|
||
pub fn project_root(&self) -> &Path {
|
||
&self.project_root
|
||
}
|
||
|
||
fn buffer(&self, id: usize) -> Option<&Buffer> {
|
||
if id == self.active.id {
|
||
Some(&self.active)
|
||
} else {
|
||
self.parked.get(&id)
|
||
}
|
||
}
|
||
|
||
pub fn diagnostics(&self) -> impl Iterator<Item = &Diagnostic> {
|
||
self.active
|
||
.diagnostics
|
||
.values()
|
||
.filter(|annotations| 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<bool, String> {
|
||
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<Diagnostic>,
|
||
) -> Result<bool, String> {
|
||
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<Diagnostic>,
|
||
) -> 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.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());
|
||
}
|
||
self.edit_buffer(path, false)
|
||
.map_err(|error| error.to_string())?;
|
||
self.park_source();
|
||
self.active.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.targets = vec![Target::caret(Cursor { row, column })];
|
||
self.active.primary = 0;
|
||
self.active.search_match = None;
|
||
self.active.visual_motions = None;
|
||
self.normalize_targets();
|
||
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.targets[self.active.primary].active
|
||
}
|
||
pub fn mode(&self) -> Mode {
|
||
self.active.mode
|
||
}
|
||
/// Read-only display state for the overlay; opening it does not change mode.
|
||
pub fn palette(&self) -> Option<&Palette> {
|
||
self.palette.as_ref()
|
||
}
|
||
|
||
/// 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.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.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> {
|
||
self.file_picker.as_ref()
|
||
}
|
||
|
||
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.
|
||
pub fn count(&self) -> Option<usize> {
|
||
self.count
|
||
}
|
||
/// Actual pending leader prefix, cached until the next input.
|
||
pub fn leader_pending(&self) -> Option<&str> {
|
||
(!self.leader_display.is_empty()).then_some(self.leader_display.as_str())
|
||
}
|
||
|
||
fn clear_leader(&mut self) {
|
||
self.leader_suffix.clear();
|
||
self.leader_display.clear();
|
||
}
|
||
|
||
fn cancel_leader(&mut self) {
|
||
if self.leader_pending().is_some() {
|
||
self.count = None;
|
||
self.clear_leader();
|
||
}
|
||
}
|
||
|
||
/// 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))
|
||
})
|
||
{
|
||
return Err(format!(
|
||
"Leader key already bound: {}",
|
||
display_key(&leader)
|
||
));
|
||
}
|
||
self.cancel_leader();
|
||
self.leader = leader;
|
||
self.refresh_palette();
|
||
Ok(())
|
||
}
|
||
/// Editable Ex/search prompt, without its `:` or `/` prefix.
|
||
pub fn command_line(&self) -> &str {
|
||
&self.command_line
|
||
}
|
||
pub fn search_match(&self) -> Option<SearchMatch> {
|
||
self.active.search_match
|
||
}
|
||
/// Ordered, end-exclusive Visual ranges in document order; empty in other modes.
|
||
pub fn selections(&self) -> Vec<Selection> {
|
||
if self.active.mode == Mode::Visual {
|
||
self.active
|
||
.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<Cursor> {
|
||
self.active
|
||
.targets
|
||
.iter()
|
||
.map(|target| target.active)
|
||
.collect()
|
||
}
|
||
pub fn selection_count(&self) -> usize {
|
||
self.active.targets.len()
|
||
}
|
||
pub fn message(&self) -> &str {
|
||
&self.message
|
||
}
|
||
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
|
||
}
|
||
|
||
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.mode.to_string(),
|
||
path: self
|
||
.active
|
||
.path
|
||
.as_ref()
|
||
.map(|path| path.to_string_lossy().into_owned()),
|
||
}
|
||
}
|
||
|
||
pub fn set_message(&mut self, message: impl Into<String>) {
|
||
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
|
||
};
|
||
}
|
||
|
||
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_leader();
|
||
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.leader_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<String>) -> 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 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 suffix = if tokens
|
||
.next()
|
||
.is_some_and(|token| token.eq_ignore_ascii_case("leader"))
|
||
{
|
||
if !matches!(mode, Mode::Normal | Mode::Visual) {
|
||
return Err("Leader sequences can only be bound in Normal or Visual mode".into());
|
||
}
|
||
let suffix: Vec<_> = tokens.map(parse_key).collect::<Result<_, _>>()?;
|
||
if suffix.is_empty() {
|
||
return Err("Leader binding requires at least one suffix key".into());
|
||
}
|
||
if self.leader_bindings.get(&mode).is_some_and(|bindings| {
|
||
bindings.keys().any(|existing| {
|
||
*existing != suffix
|
||
&& (existing.starts_with(&suffix) || suffix.starts_with(existing))
|
||
})
|
||
}) {
|
||
return Err(format!("Ambiguous leader binding: {key:?}"));
|
||
}
|
||
Some(suffix)
|
||
} else {
|
||
None
|
||
};
|
||
let input = if suffix.is_none() {
|
||
Some(parse_key(key)?)
|
||
} else {
|
||
None
|
||
};
|
||
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 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_leader();
|
||
if let Some(suffix) = suffix {
|
||
self.leader_bindings
|
||
.entry(mode)
|
||
.or_default()
|
||
.insert(suffix, 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.file_picker
|
||
&& 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::<usize>() + 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 Some(picker) = self
|
||
.file_picker
|
||
.as_mut()
|
||
.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
|
||
};
|
||
}
|
||
true
|
||
}
|
||
|
||
fn palette_bindings(&self, action: Option<Action>, command: &str) -> Vec<String> {
|
||
// ponytail: scan mode bindings per result; index by target if catalogs grow.
|
||
let mut bindings: Vec<_> = self
|
||
.bindings
|
||
.get(&self.active.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();
|
||
if !self
|
||
.bindings
|
||
.get(&self.active.mode)
|
||
.is_some_and(|keys| keys.contains_key(&self.leader))
|
||
{
|
||
for (suffix, binding) in self
|
||
.leader_bindings
|
||
.get(&self.active.mode)
|
||
.into_iter()
|
||
.flat_map(|keys| keys.iter())
|
||
{
|
||
if match (action, binding) {
|
||
(Some(action), Binding::Action(bound)) => action == *bound,
|
||
(None, Binding::Command(bound)) => command == bound,
|
||
_ => false,
|
||
} {
|
||
let mut display = display_key(&self.leader);
|
||
for input in suffix {
|
||
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();
|
||
// Derived labels contain the same searchable words as their names.
|
||
let matches = |name: &str, owner: &str| {
|
||
query
|
||
.split_whitespace()
|
||
.all(|token| name.contains(token) || owner.contains(token))
|
||
};
|
||
palette.entries.clear();
|
||
for &(name, action, modes) in CORE_ACTIONS {
|
||
if action == Action::Palette {
|
||
continue;
|
||
}
|
||
if !matches(name, "core") {
|
||
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.active.mode),
|
||
modes: modes.to_vec(),
|
||
});
|
||
}
|
||
for feature in self.features.values() {
|
||
let owner = feature.name.to_lowercase();
|
||
for command in &feature.commands {
|
||
let name = command.to_lowercase();
|
||
if !matches(&name, &owner) {
|
||
continue;
|
||
}
|
||
palette.entries.push(PaletteEntry {
|
||
name: command.clone(),
|
||
label: command.clone(),
|
||
owner: feature.name.clone(),
|
||
bindings: self.palette_bindings(None, command),
|
||
enabled: self.active.mode == Mode::Normal,
|
||
modes: vec![Mode::Normal],
|
||
});
|
||
}
|
||
}
|
||
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<Invocation> {
|
||
let palette = self.palette.as_mut().expect("open palette receives input");
|
||
match input {
|
||
Input::Escape | Input::Ctrl('p') => self.palette = None,
|
||
Input::Enter => {
|
||
let entry = palette
|
||
.entries
|
||
.get(palette.selected)
|
||
.filter(|entry| entry.enabled)?;
|
||
if let Some(action) = Action::named(self.active.mode, &entry.name) {
|
||
self.palette = None;
|
||
return self.act(action);
|
||
}
|
||
self.command_line = format!("{} ", entry.name);
|
||
self.palette = None;
|
||
if self.active.mode == Mode::Insert {
|
||
self.finish_insert();
|
||
}
|
||
self.active.mode = Mode::Command;
|
||
}
|
||
Input::Backspace => {
|
||
if let Some((byte, _)) = palette.query.grapheme_indices(true).next_back() {
|
||
palette.query.truncate(byte);
|
||
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) {
|
||
let view = match presentation {
|
||
FilePresentation::Floating => self.file_picker_view,
|
||
FilePresentation::Docked => self.file_explorer_view,
|
||
};
|
||
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_leader();
|
||
}
|
||
|
||
fn blur_file_picker(&mut self) {
|
||
match self.file_picker.as_mut() {
|
||
Some(picker) if picker.presentation == FilePresentation::Docked => {
|
||
picker.focused = false;
|
||
}
|
||
_ => 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.file_picker = None,
|
||
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(),
|
||
Input::Ctrl('u') => picker.clear_filter(),
|
||
Input::Backspace if picker.view == FileView::List || picker.searching => {
|
||
picker.backspace()
|
||
}
|
||
Input::Char('i') if picker.view == FileView::Tree && !picker.searching => {
|
||
picker.start_search()
|
||
}
|
||
Input::Char(ch)
|
||
if !ch.is_control() && (picker.view == FileView::List || picker.searching) =>
|
||
{
|
||
picker.query.push(ch);
|
||
picker.refilter();
|
||
}
|
||
Input::Enter => {
|
||
let Some(path) = picker.accept() else { return };
|
||
match self.edit_buffer(path, false) {
|
||
Ok(()) => 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<Invocation> {
|
||
// Normalize genuine terminal aliases before list and editing dispatch.
|
||
let input = match input {
|
||
Input::Ctrl(ch) => control_key(ch),
|
||
input => input,
|
||
};
|
||
if self.handle_hover(&input) {
|
||
return None;
|
||
}
|
||
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;
|
||
}
|
||
if picker_focused {
|
||
self.handle_file_picker(input);
|
||
return None;
|
||
}
|
||
if self.palette.is_some() {
|
||
return self.handle_palette(input);
|
||
}
|
||
// Paste is literal input, never a configurable key or command.
|
||
if let Input::Paste(text) = input {
|
||
self.count = None;
|
||
self.clear_leader();
|
||
if self.active.mode == Mode::Insert {
|
||
self.paste(&text);
|
||
}
|
||
return None;
|
||
}
|
||
if self.leader_pending().is_some() {
|
||
if input == Input::Escape {
|
||
self.cancel_leader();
|
||
return None;
|
||
}
|
||
return self.handle_leader(input);
|
||
}
|
||
if input == Input::Escape && self.count.take().is_some() {
|
||
return None;
|
||
}
|
||
if matches!(self.active.mode, Mode::Normal | Mode::Visual)
|
||
&& let Input::Char(ch @ '0'..='9') = input
|
||
{
|
||
let binding = self
|
||
.bindings
|
||
.get(&self.active.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(&self.active.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 matches!(self.active.mode, Mode::Normal | Mode::Visual) && input == self.leader {
|
||
append_key(&mut self.leader_display, &input);
|
||
return None;
|
||
}
|
||
self.count = None;
|
||
match (self.active.mode, input) {
|
||
(Mode::Insert, Input::Char(ch)) if !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.mode == Mode::Search && ch == '\t') =>
|
||
{
|
||
self.command_line.push(ch);
|
||
}
|
||
_ => {}
|
||
}
|
||
None
|
||
}
|
||
|
||
fn handle_leader(&mut self, input: Input) -> Option<Invocation> {
|
||
self.leader_display.push(' ');
|
||
append_key(&mut self.leader_display, &input);
|
||
self.leader_suffix.push(input);
|
||
let bindings = self.leader_bindings.get(&self.active.mode);
|
||
match bindings.and_then(|keys| keys.get(self.leader_suffix.as_slice())) {
|
||
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_leader();
|
||
return None;
|
||
}
|
||
None => {}
|
||
}
|
||
// ponytail: scan finite leader routes; use a trie if catalogs grow large.
|
||
if bindings.is_some_and(|keys| {
|
||
keys.keys()
|
||
.any(|suffix| suffix.starts_with(&self.leader_suffix))
|
||
}) {
|
||
return None;
|
||
}
|
||
self.set_message(format!("Unknown leader sequence: {}", self.leader_display));
|
||
self.cancel_leader();
|
||
None
|
||
}
|
||
|
||
fn act(&mut self, action: Action) -> Option<Invocation> {
|
||
self.clear_hover();
|
||
let count = self.count.take().unwrap_or(1);
|
||
self.clear_leader();
|
||
match action {
|
||
Action::Palette => {
|
||
self.blur_file_picker();
|
||
self.palette = Some(Palette {
|
||
query: String::new(),
|
||
entries: Vec::new(),
|
||
selected: 0,
|
||
});
|
||
self.refresh_palette();
|
||
}
|
||
Action::FilePicker => self.open_file_picker(FilePresentation::Floating),
|
||
Action::FileExplorer => self.open_file_picker(FilePresentation::Docked),
|
||
Action::LspDefinition => return self.execute("lsp.definition"),
|
||
Action::LspHover => return self.execute("lsp.hover"),
|
||
Action::LspDiagnostics => return self.execute("lsp.diagnostics"),
|
||
Action::Insert
|
||
| Action::InsertFirstNonblank
|
||
| Action::AppendLineEnd
|
||
| Action::Append
|
||
| Action::OpenBelow
|
||
| Action::OpenAbove => {
|
||
let before = self.snapshot();
|
||
if self.active.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::WordNext
|
||
| Action::WordPrevious
|
||
| Action::WordEnd
|
||
| Action::BigWordNext
|
||
| Action::BigWordPrevious
|
||
| Action::BigWordEnd => {
|
||
if self.active.mode == Mode::Visual {
|
||
if let Some(motions) = &mut self.active.visual_motions {
|
||
motions.push((action, count));
|
||
}
|
||
} else if self.active.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 => {
|
||
let before = (self.active.mode != Mode::Insert).then(|| self.snapshot());
|
||
let mask = self.deletion_mask(false);
|
||
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 => {
|
||
self.active.mode = if matches!(action, Action::Search) {
|
||
Mode::Search
|
||
} else {
|
||
Mode::Command
|
||
};
|
||
self.command_line.clear();
|
||
}
|
||
Action::SearchNext => self.seek_search(true, count),
|
||
Action::SearchPrevious => self.seek_search(false, count),
|
||
Action::Select => {
|
||
if self.active.mode == Mode::Visual {
|
||
self.clear_selection();
|
||
} else {
|
||
for target in &mut self.active.targets {
|
||
target.anchor = target.active;
|
||
}
|
||
self.active.mode = Mode::Visual;
|
||
self.active.visual_motions = Some(Vec::new());
|
||
self.normalize_targets();
|
||
self.message.clear();
|
||
}
|
||
}
|
||
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.mode == Mode::Normal {
|
||
self.collapse_primary();
|
||
} else if self.active.mode == Mode::Insert {
|
||
self.finish_insert();
|
||
self.leave_insert();
|
||
} else {
|
||
self.command_line.clear();
|
||
self.active.visual_motions = None;
|
||
self.active.mode = Mode::Normal;
|
||
self.normalize_targets();
|
||
}
|
||
}
|
||
Action::Newline => self.type_native("\n"),
|
||
Action::Backspace => {
|
||
if self.active.mode == Mode::Insert {
|
||
let mask = self.deletion_mask(true);
|
||
self.backspace(&mask);
|
||
if mask.iter().any(|active| *active) {
|
||
self.push_insert_step(InsertStep::Backspace(mask));
|
||
}
|
||
} else if let Some((byte, _)) = self.command_line.grapheme_indices(true).next_back()
|
||
{
|
||
self.command_line.truncate(byte);
|
||
}
|
||
}
|
||
Action::Submit => {
|
||
let command = std::mem::take(&mut self.command_line);
|
||
let searching = self.active.mode == Mode::Search;
|
||
self.active.mode = Mode::Normal;
|
||
if searching {
|
||
if !command.is_empty() {
|
||
self.active.search_query = command;
|
||
}
|
||
self.seek_search(true, 1);
|
||
} else {
|
||
return self.execute(&command);
|
||
}
|
||
}
|
||
}
|
||
None
|
||
}
|
||
|
||
fn seek_search(&mut self, forward: bool, count: usize) {
|
||
self.active.search_match = None;
|
||
if self.active.search_query.is_empty() {
|
||
self.set_message("No previous search");
|
||
return;
|
||
}
|
||
let query = self.active.search_query.as_str();
|
||
// ponytail: O(buffer graphemes × query bytes) scan; index matches if large buffers need it.
|
||
let matches: Vec<_> = self
|
||
.active
|
||
.lines
|
||
.iter()
|
||
.enumerate()
|
||
.flat_map(|(row, line)| {
|
||
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.search_query));
|
||
return;
|
||
}
|
||
for (index, target) in self.active.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.primary {
|
||
self.active.search_match = Some(SearchMatch {
|
||
start: target.active,
|
||
end_column: target.active.column + grapheme_count(query),
|
||
});
|
||
}
|
||
}
|
||
self.active.visual_motions = None;
|
||
self.normalize_targets();
|
||
self.message.clear();
|
||
}
|
||
|
||
fn park_source(&mut self) {
|
||
self.clear_hover();
|
||
if self.active.mode == Mode::Insert {
|
||
self.finish_insert();
|
||
}
|
||
if matches!(
|
||
self.active.mode,
|
||
Mode::Insert | Mode::Command | Mode::Search
|
||
) {
|
||
self.active.mode = Mode::Normal;
|
||
self.normalize_targets();
|
||
}
|
||
self.command_line.clear();
|
||
self.count = None;
|
||
self.clear_leader();
|
||
self.palette = None;
|
||
}
|
||
|
||
fn activate(&mut self, buffer: Buffer) {
|
||
self.park_source();
|
||
let previous = std::mem::replace(&mut self.active, buffer);
|
||
self.parked.insert(previous.id, previous);
|
||
self.show_buffer();
|
||
}
|
||
|
||
fn show_buffer(&mut self) {
|
||
let path = self
|
||
.path()
|
||
.map_or_else(|| Cow::Borrowed("[No Name]"), Path::to_string_lossy);
|
||
self.set_message(format!("Buffer {}: {path}", self.active.id));
|
||
}
|
||
|
||
fn switch_buffer(&mut self, id: usize) {
|
||
if id == self.active.id {
|
||
self.park_source();
|
||
self.show_buffer();
|
||
} else if let Some(buffer) = self.parked.remove(&id) {
|
||
self.activate(buffer);
|
||
} else {
|
||
self.set_message(format!("No buffer: {id}"));
|
||
}
|
||
}
|
||
|
||
fn adjacent_buffer(&self, forward: bool) -> usize {
|
||
use std::ops::Bound::{Excluded, Unbounded};
|
||
let id = self.active.id;
|
||
if forward {
|
||
self.parked
|
||
.range((Excluded(id), Unbounded))
|
||
.next()
|
||
.or_else(|| self.parked.first_key_value())
|
||
} else {
|
||
self.parked
|
||
.range(..id)
|
||
.next_back()
|
||
.or_else(|| self.parked.last_key_value())
|
||
}
|
||
.map_or(id, |(id, _)| *id)
|
||
}
|
||
|
||
fn edit_buffer(&mut self, path: PathBuf, 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.next_buffer_id += 1;
|
||
self.activate(buffer);
|
||
Ok(())
|
||
}
|
||
|
||
fn delete_buffer(&mut self, id: usize, force: bool) {
|
||
let buffer = if id == self.active.id {
|
||
Some(&self.active)
|
||
} else {
|
||
self.parked.get(&id)
|
||
};
|
||
let Some(buffer) = buffer else {
|
||
self.set_message(format!("No buffer: {id}"));
|
||
return;
|
||
};
|
||
if buffer.dirty() && !force {
|
||
self.set_message(format!(
|
||
"Buffer {id} has unsaved changes; use :w or :bdelete!"
|
||
));
|
||
return;
|
||
}
|
||
if id == self.active.id {
|
||
let next = self.adjacent_buffer(true);
|
||
let replacement = if next == id {
|
||
let buffer = Buffer::open(self.next_buffer_id, None, None, true)
|
||
.expect("an unnamed empty buffer is valid");
|
||
self.next_buffer_id += 1;
|
||
buffer
|
||
} else {
|
||
self.parked.remove(&next).expect("adjacent buffer exists")
|
||
};
|
||
self.park_source();
|
||
self.active = replacement;
|
||
} else {
|
||
self.parked.remove(&id);
|
||
self.park_source();
|
||
}
|
||
self.set_message(format!(
|
||
"Deleted buffer {id}; active buffer {}",
|
||
self.active.id
|
||
));
|
||
}
|
||
|
||
fn buffer_command(&mut self, name: &str, args: &str) -> bool {
|
||
match name {
|
||
"edit" if args.is_empty() => self.set_message("Usage: :edit path"),
|
||
"edit" => {
|
||
if let Err(error) = self.edit_buffer(PathBuf::from(args), true) {
|
||
self.set_message(format!("Open failed: {error}"));
|
||
}
|
||
}
|
||
"buffers" | "bnext" | "bprev" if !args.is_empty() => {
|
||
self.set_message(format!("The {name} command takes no arguments"));
|
||
}
|
||
"buffers" => {
|
||
let mut message = String::new();
|
||
for buffer in self.buffers() {
|
||
if !message.is_empty() {
|
||
message.push_str(" | ");
|
||
}
|
||
use std::fmt::Write as _;
|
||
write!(
|
||
message,
|
||
"{}{}{} {}",
|
||
buffer.id,
|
||
if buffer.active { "%" } else { "" },
|
||
if buffer.dirty { "+" } else { "" },
|
||
buffer
|
||
.path
|
||
.map_or_else(|| Cow::Borrowed("[No Name]"), Path::to_string_lossy,),
|
||
)
|
||
.expect("writing into a String cannot fail");
|
||
}
|
||
self.set_message(message);
|
||
}
|
||
"bnext" | "bprev" => self.switch_buffer(self.adjacent_buffer(name == "bnext")),
|
||
"buffer" | "bdelete" | "bdelete!" => {
|
||
let id = if args.is_empty() && name != "buffer" {
|
||
Some(self.active.id)
|
||
} else if !args.is_empty() && args.bytes().all(|byte| byte.is_ascii_digit()) {
|
||
args.parse::<usize>().ok().filter(|id| *id > 0)
|
||
} else {
|
||
None
|
||
};
|
||
match id {
|
||
Some(id) if name == "buffer" => self.switch_buffer(id),
|
||
Some(id) => self.delete_buffer(id, name == "bdelete!"),
|
||
None => self.set_message(format!(
|
||
"Usage: :{name} {}",
|
||
if name == "buffer" { "N" } else { "[N]" },
|
||
)),
|
||
}
|
||
}
|
||
_ => return false,
|
||
}
|
||
true
|
||
}
|
||
|
||
fn any_dirty(&self) -> bool {
|
||
self.active.dirty() || self.parked.values().any(Buffer::dirty)
|
||
}
|
||
|
||
pub fn execute(&mut self, command: &str) -> Option<Invocation> {
|
||
self.clear_hover();
|
||
self.count = None;
|
||
self.clear_leader();
|
||
self.palette = None;
|
||
self.blur_file_picker();
|
||
let command = command.trim().strip_prefix(':').unwrap_or(command.trim());
|
||
let (name, args) = command
|
||
.split_once(char::is_whitespace)
|
||
.unwrap_or((command, ""));
|
||
let args = args.trim();
|
||
// Switching must park directed Visual selections before ordinary Ex cleanup.
|
||
if self.buffer_command(name, args) {
|
||
return None;
|
||
}
|
||
if self.active.mode == Mode::Insert {
|
||
self.finish_insert();
|
||
}
|
||
self.clear_selection();
|
||
self.active.mode = Mode::Normal;
|
||
self.normalize_targets();
|
||
let Some(feature) = self.owner(name) else {
|
||
self.set_message(format!("Unknown command: {name}"));
|
||
return None;
|
||
};
|
||
if !feature.core {
|
||
return Some(Invocation {
|
||
plugin: feature.name.clone(),
|
||
command: name.into(),
|
||
args: args.into(),
|
||
});
|
||
}
|
||
match name {
|
||
"lsp.definition" | "lsp.hover" | "lsp.diagnostics" if !args.is_empty() => {
|
||
self.set_message(format!("The {name} command takes no arguments"));
|
||
}
|
||
"lsp.definition" | "lsp.hover" | "lsp.diagnostics" => {
|
||
return Some(Invocation {
|
||
plugin: "core".into(),
|
||
command: name.into(),
|
||
args: String::new(),
|
||
});
|
||
}
|
||
"w" | "wq" => {
|
||
if self.save(if args.is_empty() {
|
||
None
|
||
} else {
|
||
Some(PathBuf::from(args))
|
||
}) && name == "wq"
|
||
{
|
||
if self.any_dirty() {
|
||
self.set_message("Unsaved changes in other buffers; use :w or :q!");
|
||
} else {
|
||
self.quit = true;
|
||
}
|
||
}
|
||
}
|
||
"q" | "q!" if !args.is_empty() => self.set_message("Quit commands take no arguments"),
|
||
"q" if self.any_dirty() => self.set_message("Unsaved changes; use :w or :q!"),
|
||
"q" | "q!" => self.quit = true,
|
||
"plugins" if !args.is_empty() => {
|
||
self.set_message("The plugins command takes no arguments")
|
||
}
|
||
"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 message-only effects retain the complete set.
|
||
pub fn apply(&mut self, effects: Vec<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,
|
||
};
|
||
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());
|
||
}
|
||
validate_text(text)?;
|
||
}
|
||
// Validation precedes even history/message changes, so a rejected response is atomic.
|
||
let previous_mode = self.active.mode;
|
||
let inserting = self.active.mode == Mode::Insert;
|
||
if inserting {
|
||
self.finish_insert();
|
||
}
|
||
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_leader();
|
||
self.clear_selection();
|
||
self.collapse_primary();
|
||
// Plugin inserts use one primary insertion point, not the selection set.
|
||
self.active.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.active.search_match = None;
|
||
self.normalize_targets();
|
||
}
|
||
Effect::Message(text) => self.set_message(text),
|
||
}
|
||
}
|
||
if before.is_some() {
|
||
self.active.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.mode != previous_mode {
|
||
self.refresh_palette();
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
fn save(&mut self, path: Option<PathBuf>) -> bool {
|
||
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;
|
||
}
|
||
match atomic_save(&path, &self.active.lines) {
|
||
Ok(()) => {
|
||
self.active.saved_lines.clone_from(&self.active.lines);
|
||
self.active.save_revision = self.active.save_revision.wrapping_add(1);
|
||
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.targets.clone(),
|
||
primary: self.active.primary,
|
||
}
|
||
}
|
||
|
||
fn record(&mut self, before: Snapshot) {
|
||
if before.lines != self.active.lines {
|
||
self.active.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 {
|
||
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.mode == Mode::Insert
|
||
|| (self.active.mode == Mode::Visual && row + 1 < self.active.lines.len())
|
||
{
|
||
count
|
||
} else {
|
||
count.saturating_sub(1)
|
||
}
|
||
}
|
||
|
||
fn normalize_targets(&mut self) {
|
||
for index in 0..self.active.targets.len() {
|
||
let mut target = self.active.targets[index];
|
||
target.active.row = target.active.row.min(self.active.lines.len() - 1);
|
||
target.active.column = target.active.column.min(self.max_column(target.active.row));
|
||
if self.active.mode == Mode::Visual {
|
||
target.anchor.row = target.anchor.row.min(self.active.lines.len() - 1);
|
||
target.anchor.column = target.anchor.column.min(self.max_column(target.anchor.row));
|
||
} else {
|
||
target.anchor = target.active;
|
||
}
|
||
self.active.targets[index] = target;
|
||
}
|
||
let ordered: Vec<_> = self
|
||
.active
|
||
.targets
|
||
.iter()
|
||
.copied()
|
||
.enumerate()
|
||
.map(|(index, target)| {
|
||
let range = if self.active.mode == Mode::Visual {
|
||
self.target_range(target)
|
||
} else {
|
||
Selection {
|
||
start: target.active,
|
||
end: target.active,
|
||
}
|
||
};
|
||
(target, index == self.active.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<Selection> {
|
||
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.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.visual_motions = None;
|
||
}
|
||
let ranges = if keep_ranges {
|
||
merged.iter().map(|(_, _, range)| *range).collect()
|
||
} else {
|
||
Vec::new()
|
||
};
|
||
self.active.primary = merged
|
||
.iter()
|
||
.position(|(_, primary, _)| *primary)
|
||
.unwrap_or(0);
|
||
self.active.targets.clear();
|
||
self.active
|
||
.targets
|
||
.extend(merged.into_iter().map(|(target, _, _)| target));
|
||
ranges
|
||
}
|
||
|
||
fn collapse_primary(&mut self) {
|
||
let target = self.active.targets[self.active.primary];
|
||
self.active.targets.clear();
|
||
self.active.targets.push(target);
|
||
self.active.primary = 0;
|
||
}
|
||
|
||
fn clear_selection(&mut self) {
|
||
if self.active.mode == Mode::Visual {
|
||
self.active.visual_motions = None;
|
||
self.active.mode = Mode::Normal;
|
||
self.normalize_targets();
|
||
}
|
||
}
|
||
|
||
fn split_rows(&mut self, end: bool, insert: bool) {
|
||
self.active.visual_motions = None;
|
||
let ranges = self.selections();
|
||
let primary_row = self.cursor().row;
|
||
let mut carets = Vec::new();
|
||
let mut primary = 0;
|
||
for (index, range) in ranges.into_iter().enumerate() {
|
||
let last = if range.end.column == 0 && range.end.row > range.start.row {
|
||
range.end.row - 1
|
||
} else {
|
||
range.end.row
|
||
};
|
||
for row in range.start.row..=last {
|
||
if index == self.active.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.targets = carets;
|
||
self.active.primary = primary;
|
||
self.active.mode = if insert { Mode::Insert } else { Mode::Normal };
|
||
self.normalize_targets();
|
||
}
|
||
|
||
fn clone_vertical(&mut self, down: bool) {
|
||
self.active.visual_motions = None;
|
||
let count = self.active.targets.len();
|
||
let primary = self.active.primary;
|
||
for index in 0..count {
|
||
let target = self.active.targets[index];
|
||
let shift = |row: usize| {
|
||
if down {
|
||
row.checked_add(1)
|
||
.filter(|row| *row < self.active.lines.len())
|
||
} else {
|
||
row.checked_sub(1)
|
||
}
|
||
};
|
||
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.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.primary = self.active.targets.len();
|
||
}
|
||
self.active.targets.push(cloned);
|
||
}
|
||
self.normalize_targets();
|
||
}
|
||
|
||
fn select_occurrences(&mut self, action: Action) {
|
||
let text = self.active.lines.join("\n");
|
||
let offsets = line_offsets(&self.active.lines);
|
||
let ranges = self.selections();
|
||
let primary_range = ranges[self.active.primary];
|
||
let start = cursor_offset(&self.active.lines, &offsets, primary_range.start);
|
||
let end = cursor_offset(&self.active.lines, &offsets, primary_range.end);
|
||
if start == end {
|
||
self.set_message("Nothing selected");
|
||
return;
|
||
}
|
||
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 candidates: Vec<_> = text
|
||
.grapheme_indices(true)
|
||
.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"))
|
||
.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.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.targets[self.active.primary] = target;
|
||
} else {
|
||
self.active.primary = self.active.targets.len();
|
||
self.active.targets.push(target);
|
||
}
|
||
}
|
||
self.active.visual_motions = None;
|
||
self.normalize_targets();
|
||
self.message.clear();
|
||
}
|
||
|
||
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.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);
|
||
self.message.clear();
|
||
return;
|
||
}
|
||
self.edit_ranges(action, &ranges);
|
||
self.active.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<String> {
|
||
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<String>, system: bool) -> Result<(), String> {
|
||
if fragments.iter().all(|fragment| fragment.is_empty()) {
|
||
return Ok(());
|
||
}
|
||
if system {
|
||
let exported = fragments.join("\n");
|
||
self.system_clipboard.write(&exported)?;
|
||
self.owned_system_clipboard = Some((exported, fragments));
|
||
} else {
|
||
self.clipboard = fragments;
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
// Range mutation has no register/history side effects: replay publishes only after validation.
|
||
fn edit_ranges(&mut self, action: Action, ranges: &[Selection]) {
|
||
let cut = matches!(action, Action::Cut | Action::Change);
|
||
self.active.visual_motions = None;
|
||
let offsets = line_offsets(&self.active.lines);
|
||
self.active.mode = Mode::Insert;
|
||
if cut {
|
||
let edits = ranges
|
||
.iter()
|
||
.enumerate()
|
||
.map(|(target, range)| Edit {
|
||
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.targets.iter_mut().zip(ranges) {
|
||
target.active = range.start;
|
||
}
|
||
}
|
||
}
|
||
|
||
fn read_system_clipboard(&mut self) -> Result<Vec<String>, String> {
|
||
let text = self.system_clipboard.read()?;
|
||
validate_paste_text(&text)?;
|
||
if let Some((exported, fragments)) = &self.owned_system_clipboard
|
||
&& *exported == text
|
||
{
|
||
return Ok(fragments.clone());
|
||
}
|
||
self.owned_system_clipboard = None;
|
||
Ok(if text.is_empty() {
|
||
Vec::new()
|
||
} else {
|
||
vec![normalize_newlines(&text).into_owned()]
|
||
})
|
||
}
|
||
|
||
fn paste_clipboard(&mut self, after: bool, system: bool) {
|
||
let mut fragments = if system {
|
||
match self.read_system_clipboard() {
|
||
Ok(fragments) => fragments,
|
||
Err(error) => {
|
||
self.set_message(error);
|
||
return;
|
||
}
|
||
}
|
||
} else {
|
||
std::mem::take(&mut self.clipboard)
|
||
};
|
||
if fragments.is_empty() {
|
||
self.set_message("Clipboard is empty");
|
||
} else if fragments.len() != 1 && fragments.len() != self.active.targets.len() {
|
||
self.set_message("Clipboard fragment count does not match caret count");
|
||
} else {
|
||
let before = self.snapshot();
|
||
self.paste_fragments(after, &fragments);
|
||
if before.lines != self.active.lines {
|
||
self.last_edit = Some(EditRecipe {
|
||
start: EditStart::Paste {
|
||
after,
|
||
fragments: if system {
|
||
std::mem::take(&mut fragments)
|
||
} else {
|
||
fragments.clone()
|
||
},
|
||
},
|
||
steps: Vec::new(),
|
||
});
|
||
}
|
||
self.record(before);
|
||
self.message.clear();
|
||
}
|
||
if !system {
|
||
self.clipboard = fragments;
|
||
}
|
||
}
|
||
|
||
fn paste_fragments(&mut self, after: bool, fragments: &[String]) {
|
||
let offsets = line_offsets(&self.active.lines);
|
||
let edits = self
|
||
.active
|
||
.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,
|
||
start,
|
||
end: start,
|
||
text: Cow::Borrowed(text),
|
||
caret: text.len(),
|
||
round_up: true,
|
||
}
|
||
})
|
||
.collect();
|
||
self.active.mode = Mode::Insert;
|
||
self.apply_edits(edits);
|
||
self.leave_insert();
|
||
}
|
||
|
||
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.insert_start = Some(before);
|
||
self.active.pending_edit = Some(EditRecipe {
|
||
start,
|
||
steps: Vec::new(),
|
||
});
|
||
self.active.visual_motions = None;
|
||
self.active.mode = Mode::Insert;
|
||
}
|
||
|
||
fn finish_insert(&mut self) {
|
||
let recipe = self.active.pending_edit.take();
|
||
if let Some(before) = self.active.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.targets {
|
||
target.active.column = target.active.column.saturating_sub(1);
|
||
}
|
||
self.active.mode = Mode::Normal;
|
||
self.normalize_targets();
|
||
}
|
||
|
||
fn place_insert(&mut self, action: Action) {
|
||
self.active.mode = Mode::Insert;
|
||
if matches!(action, Action::OpenBelow | Action::OpenAbove) {
|
||
self.open_lines(matches!(action, Action::OpenBelow));
|
||
return;
|
||
}
|
||
for target in &mut self.active.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.pending_edit {
|
||
recipe.steps.push(step);
|
||
}
|
||
}
|
||
|
||
fn type_native(&mut self, text: &str) {
|
||
self.insert_text(text);
|
||
if let Some(recipe) = &mut self.active.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: bool) -> Vec<bool> {
|
||
let mut mask: Vec<_> = self
|
||
.active
|
||
.targets
|
||
.iter()
|
||
.map(|target| {
|
||
let cursor = target.active;
|
||
if backwards {
|
||
cursor.row != 0 || cursor.column != 0
|
||
} else {
|
||
cursor.column < grapheme_count(&self.active.lines[cursor.row])
|
||
|| (self.active.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.visual_motions.take() {
|
||
return SelectionIntent::Motions(motions);
|
||
}
|
||
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),
|
||
..
|
||
} if self.active.mode != Mode::Visual => Some(spans.len()),
|
||
_ => None,
|
||
};
|
||
if cardinality.is_some_and(|count| count != 1 && count != self.active.targets.len()) {
|
||
self.set_message("Repeat fragment/span count does not match target count");
|
||
self.last_edit = Some(recipe);
|
||
return;
|
||
}
|
||
let before = self.snapshot();
|
||
let mode = self.active.mode;
|
||
let selector = self.active.visual_motions.take();
|
||
let search_match = self.active.search_match;
|
||
let result = self
|
||
.replay_edit(&recipe)
|
||
.map_err(str::to_owned)
|
||
.and_then(|fragments| {
|
||
if let Some(fragments) = fragments {
|
||
self.store_clipboard(fragments, self.clipboard_mode == ClipboardMode::System)?;
|
||
}
|
||
Ok(())
|
||
});
|
||
match result {
|
||
Ok(()) => {
|
||
self.active.visual_motions = None;
|
||
self.record(before);
|
||
self.message.clear();
|
||
}
|
||
Err(error) => {
|
||
self.active.lines = before.lines;
|
||
self.active.targets = before.targets;
|
||
self.active.primary = before.primary;
|
||
self.active.mode = mode;
|
||
self.active.visual_motions = selector;
|
||
self.active.search_match = search_match;
|
||
self.set_message(error);
|
||
}
|
||
}
|
||
self.last_edit = Some(recipe);
|
||
}
|
||
|
||
fn replay_edit(&mut self, recipe: &EditRecipe) -> Result<Option<Vec<String>>, &'static str> {
|
||
let mut clipboard = None;
|
||
match &recipe.start {
|
||
EditStart::Insert(action) => self.place_insert(*action),
|
||
EditStart::Selection { change, selector } => {
|
||
let ranges = self.replay_ranges(selector);
|
||
clipboard = Some(self.selection_fragments(&ranges));
|
||
self.edit_ranges(if *change { Action::Change } else { Action::Cut }, &ranges);
|
||
if !*change {
|
||
self.active.mode = Mode::Normal;
|
||
self.normalize_targets();
|
||
}
|
||
}
|
||
EditStart::Delete => {
|
||
self.active.mode = Mode::Normal;
|
||
self.delete_forward(&[true]);
|
||
}
|
||
EditStart::Join => {
|
||
self.active.mode = Mode::Normal;
|
||
self.join_lines();
|
||
}
|
||
EditStart::Paste { after, fragments } => self.paste_fragments(*after, fragments),
|
||
}
|
||
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.targets.len() {
|
||
return Err("Repeat deletion count does not match target count");
|
||
}
|
||
if matches!(step, InsertStep::Delete(_)) {
|
||
self.delete_forward(mask);
|
||
} else {
|
||
self.backspace(mask);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if self.active.mode == Mode::Insert {
|
||
self.leave_insert();
|
||
} else {
|
||
self.active.mode = Mode::Normal;
|
||
self.normalize_targets();
|
||
}
|
||
Ok(clipboard)
|
||
}
|
||
|
||
fn replay_ranges(&mut self, selector: &SelectionIntent) -> Vec<Selection> {
|
||
if self.active.mode == Mode::Visual {
|
||
return self.selections();
|
||
}
|
||
self.active.mode = Mode::Visual;
|
||
match selector {
|
||
SelectionIntent::Motions(motions) => {
|
||
for target in &mut self.active.targets {
|
||
target.anchor = target.active;
|
||
}
|
||
for (action, count) in motions {
|
||
self.move_targets(*action, *count);
|
||
}
|
||
self.normalize_targets();
|
||
self.selections()
|
||
}
|
||
SelectionIntent::Spans(spans) => {
|
||
let ordered = self
|
||
.active
|
||
.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.primary,
|
||
range,
|
||
)
|
||
})
|
||
.collect();
|
||
self.merge_target_ranges(ordered, true)
|
||
}
|
||
}
|
||
}
|
||
|
||
fn paste(&mut self, text: &str) {
|
||
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.active.lines != snapshot.lines {
|
||
self.active.text_changed();
|
||
}
|
||
self.active.lines = snapshot.lines;
|
||
self.active.targets = snapshot.targets;
|
||
self.active.primary = snapshot.primary;
|
||
self.active.mode = Mode::Normal;
|
||
self.active.search_match = None;
|
||
self.active.visual_motions = None;
|
||
self.normalize_targets();
|
||
}
|
||
|
||
fn undo(&mut self) {
|
||
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 let Some(after) = self.active.redo.pop() {
|
||
self.active.undo.push(self.snapshot());
|
||
self.restore(after);
|
||
} else {
|
||
self.set_message("Nothing to redo");
|
||
}
|
||
}
|
||
|
||
fn move_targets(&mut self, action: Action, count: usize) {
|
||
for index in 0..self.active.targets.len() {
|
||
let mut cursor = self.active.targets[index].active;
|
||
match action {
|
||
Action::Left => cursor.column = cursor.column.saturating_sub(count),
|
||
Action::Right => {
|
||
cursor.column = cursor
|
||
.column
|
||
.saturating_add(count)
|
||
.min(self.max_column(cursor.row))
|
||
}
|
||
Action::Down | Action::Up => {
|
||
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),
|
||
_ => cursor = self.word_destination(cursor, action, count),
|
||
}
|
||
self.active.targets[index].active = cursor;
|
||
self.active.targets[index].preferred_columns = None;
|
||
}
|
||
self.normalize_targets();
|
||
}
|
||
fn word_destination(&self, cursor: Cursor, action: Action, count: usize) -> Cursor {
|
||
let row = cursor.row;
|
||
let byte = grapheme_byte(&self.active.lines[row], cursor.column);
|
||
let big = matches!(
|
||
action,
|
||
Action::BigWordNext | Action::BigWordPrevious | Action::BigWordEnd
|
||
);
|
||
let destination = match action {
|
||
Action::WordPrevious | Action::BigWordPrevious => {
|
||
let mut tokens = word_tokens(&self.active.lines[..=row], big)
|
||
.rev()
|
||
.skip_while(|(r, b, _)| (*r, *b) >= (row, byte))
|
||
.peekable();
|
||
let mut destination = (0, 0);
|
||
for _ in 0..count {
|
||
let Some(mut first) = tokens.find(|(_, _, kind)| *kind != WordKind::Whitespace)
|
||
else {
|
||
destination = (0, 0);
|
||
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 = word_tokens(&self.active.lines[row..], big)
|
||
.skip_while(|(r, b, _)| (*r, *b) < (0, byte))
|
||
.peekable();
|
||
let mut current = tokens
|
||
.next()
|
||
.expect("cursor has a grapheme or line-end token")
|
||
.2;
|
||
let last = self.active.lines.len() - 1;
|
||
let mut destination = (last, self.active.lines[last].len());
|
||
for _ in 0..count {
|
||
let next_start = matches!(action, Action::WordNext | Action::BigWordNext);
|
||
if next_start && current != WordKind::EmptyLine {
|
||
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 = (row + 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.mode != Mode::Insert {
|
||
column.saturating_sub(1)
|
||
} else {
|
||
column
|
||
},
|
||
}
|
||
}
|
||
|
||
/// 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, mut edits: Vec<Edit<'_>>) {
|
||
if edits.is_empty() {
|
||
return;
|
||
}
|
||
edits.sort_by_key(|edit| (edit.start, edit.end));
|
||
let mut unique: Vec<usize> = 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 old = self.active.lines.join("\n");
|
||
let offsets = line_offsets(&self.active.lines);
|
||
let removed: usize = unique
|
||
.iter()
|
||
.map(|index| edits[*index].end - edits[*index].start)
|
||
.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| {
|
||
let mut removed = 0;
|
||
let mut added = 0;
|
||
for index in &unique {
|
||
let edit = &edits[*index];
|
||
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
|
||
};
|
||
let mut destinations: Vec<_> = self
|
||
.active
|
||
.targets
|
||
.iter()
|
||
.map(|target| {
|
||
(
|
||
remap(
|
||
cursor_offset(&self.active.lines, &offsets, target.active),
|
||
true,
|
||
),
|
||
true,
|
||
)
|
||
})
|
||
.collect();
|
||
for edit in &edits {
|
||
destinations[edit.target] = (remap(edit.start, false) + edit.caret, edit.round_up);
|
||
}
|
||
if text != old {
|
||
self.active.text_changed();
|
||
self.active.search_match = None;
|
||
}
|
||
self.active.lines = text.split('\n').map(str::to_owned).collect();
|
||
let offsets = line_offsets(&self.active.lines);
|
||
for (target, (byte, round_up)) in self.active.targets.iter_mut().zip(destinations) {
|
||
target.active = offset_cursor(&self.active.lines, &offsets, byte, round_up);
|
||
target.anchor = target.active;
|
||
target.preferred_columns = None;
|
||
}
|
||
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
|
||
.targets
|
||
.iter()
|
||
.enumerate()
|
||
.map(|(target, item)| {
|
||
let start = cursor_offset(&self.active.lines, &offsets, item.active);
|
||
Edit {
|
||
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
|
||
.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,
|
||
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
|
||
.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.mode == Mode::Insert
|
||
&& cursor.row + 1 < self.active.lines.len()
|
||
{
|
||
start + 1
|
||
} else {
|
||
return None;
|
||
};
|
||
Some(Edit {
|
||
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
|
||
.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,
|
||
start,
|
||
end: start + 1 + next.len() - trimmed.len(),
|
||
text: Cow::Borrowed(separator),
|
||
caret: 0,
|
||
round_up: false,
|
||
})
|
||
})
|
||
.collect();
|
||
self.apply_edits(edits);
|
||
}
|
||
|
||
fn backspace(&mut self, mask: &[bool]) {
|
||
let offsets = line_offsets(&self.active.lines);
|
||
let edits = self
|
||
.active
|
||
.targets
|
||
.iter()
|
||
.enumerate()
|
||
.filter_map(|(target, item)| {
|
||
if !mask[if mask.len() == 1 { 0 } else { target }] {
|
||
return None;
|
||
}
|
||
let cursor = item.active;
|
||
if cursor.column == 0 && cursor.row == 0 {
|
||
return None;
|
||
}
|
||
let start =
|
||
cursor_offset(&self.active.lines, &offsets, self.previous_boundary(cursor));
|
||
let end = cursor_offset(&self.active.lines, &offsets, cursor);
|
||
Some(Edit {
|
||
target,
|
||
start,
|
||
end,
|
||
text: Cow::Borrowed(""),
|
||
caret: 0,
|
||
round_up: true,
|
||
})
|
||
})
|
||
.collect();
|
||
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<usize> {
|
||
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<Item = (usize, usize, WordKind)> + '_ {
|
||
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 == '-')
|
||
})
|
||
}
|
||
|
||
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)
|
||
}
|
||
}
|
||
|
||
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(())
|
||
}
|
||
}
|
||
|
||
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<String> {
|
||
normalize_newlines(text)
|
||
.split('\n')
|
||
.map(str::to_owned)
|
||
.collect()
|
||
}
|
||
|
||
fn grapheme_count(line: &str) -> usize {
|
||
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 path_identity(path: &Path) -> io::Result<PathBuf> {
|
||
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<Option<fs::Metadata>> {
|
||
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 atomic_save(path: &Path, lines: &[String]) -> io::Result<()> {
|
||
let metadata = target_metadata(path)?;
|
||
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()?;
|
||
drop(file);
|
||
// Recheck immediately before rename: never knowingly replace a symlink.
|
||
target_metadata(path)?;
|
||
fs::rename(&temporary, path)?;
|
||
#[cfg(unix)]
|
||
fs::File::open(parent)?.sync_all()?;
|
||
Ok(())
|
||
})();
|
||
if result.is_err() {
|
||
let _ = fs::remove_file(&temporary);
|
||
}
|
||
result
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
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
|
||
}
|
||
fn type_text(editor: &mut Editor, text: &str) {
|
||
for ch in text.chars() {
|
||
editor.handle(if ch == '\n' {
|
||
Input::Enter
|
||
} else {
|
||
Input::Char(ch)
|
||
});
|
||
}
|
||
}
|
||
fn project_editor(directory: &Directory) -> Editor {
|
||
let mut editor = editor();
|
||
editor.project_root = directory.0.clone();
|
||
editor
|
||
}
|
||
|
||
#[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.targets.clone();
|
||
let primary = editor.active.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_eq!(editor.active.targets, targets);
|
||
assert_eq!(editor.active.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_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<u8> {
|
||
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();
|
||
}
|
||
}
|
||
}
|
||
|
||
// Includes every replay-owned state, not the intentionally changed error message.
|
||
fn state(editor: &Editor) -> String {
|
||
format!(
|
||
"{:?}",
|
||
(
|
||
(
|
||
&editor.active.lines,
|
||
&editor.active.targets,
|
||
editor.active.primary,
|
||
editor.active.mode,
|
||
&editor.active.search_query,
|
||
editor.active.search_match,
|
||
&editor.active.undo,
|
||
&editor.active.redo,
|
||
),
|
||
(
|
||
&editor.active.insert_start,
|
||
&editor.active.pending_edit,
|
||
&editor.active.visual_motions,
|
||
&editor.clipboard,
|
||
&editor.owned_system_clipboard,
|
||
&editor.last_edit,
|
||
editor.clipboard_mode,
|
||
),
|
||
),
|
||
)
|
||
}
|
||
|
||
#[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.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).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).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).unwrap();
|
||
fixture.fail("fail-write", true);
|
||
for key in ['y', 'd', 'c'] {
|
||
editor.active.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.search_query = "aa".into();
|
||
editor.active.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.mode = mode;
|
||
editor.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.targets.clone();
|
||
let primary = editor.active.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.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.targets, targets);
|
||
assert_eq!(editor.active.primary, primary);
|
||
assert_eq!(editor.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::<Vec<_>>(),
|
||
undo
|
||
);
|
||
assert_eq!(
|
||
editor.active.redo.iter().map(&snapshot).collect::<Vec<_>>(),
|
||
redo
|
||
);
|
||
assert_eq!(editor.active.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_does_not_clear_palette_or_idle_dock() {
|
||
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_eq!(editor.palette().unwrap().query, "core");
|
||
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.mode = mode;
|
||
editor.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.targets.clone();
|
||
let primary = editor.active.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.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.targets, targets);
|
||
assert_eq!(editor.active.primary, primary);
|
||
assert_eq!(editor.command_line, "original prompt");
|
||
assert_eq!(editor.count(), Some(7));
|
||
assert_eq!(
|
||
editor.active.undo.iter().map(&snapshot).collect::<Vec<_>>(),
|
||
undo
|
||
);
|
||
assert_eq!(
|
||
editor.active.redo.iter().map(&snapshot).collect::<Vec<_>>(),
|
||
redo
|
||
);
|
||
assert_eq!(editor.active.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.leader_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.leader_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")),
|
||
("selection", "alt+x", 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.leader_pending(), Some(pending.as_str()));
|
||
}
|
||
editor.cancel_leader();
|
||
// 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.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.command_line, "");
|
||
editor.handle(Input::Enter);
|
||
assert!(editor.palette().is_none());
|
||
assert_eq!(editor.mode(), Mode::Command);
|
||
assert_eq!(editor.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.leader_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.leader_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.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());
|
||
type_text(&mut editor, " eifile");
|
||
editor.execute("buffers");
|
||
assert!(!editor.file_picker().unwrap().focused);
|
||
assert_eq!(editor.file_picker().unwrap().query, "file");
|
||
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.leader_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.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.mode = mode;
|
||
editor.command_line = "underlying".into();
|
||
let targets = editor.active.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.leader_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::<Vec<_>>();
|
||
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::<Vec<_>>(),
|
||
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.command_line, "underlying");
|
||
assert_eq!(editor.active.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.leader_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")),
|
||
("alt+x", None),
|
||
] {
|
||
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.leader_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.mode = mode;
|
||
editor.command_line = "query".into();
|
||
let targets = editor.active.targets.clone();
|
||
let undo = editor.active.undo.len();
|
||
editor.handle(Input::Ctrl('h'));
|
||
assert_eq!(editor.context().text, "abc");
|
||
assert_eq!(editor.active.targets, targets);
|
||
assert_eq!(editor.active.undo.len(), undo);
|
||
assert_eq!(editor.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 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::<Vec<_>>();
|
||
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 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.leader_pending(), Some("Space b"));
|
||
editor.handle(Input::Char('n'));
|
||
assert_eq!(editor.buffer_id(), 1);
|
||
assert_eq!(editor.leader_pending(), None);
|
||
type_text(&mut editor, " bp");
|
||
assert_eq!(editor.buffer_id(), 2);
|
||
type_text(&mut editor, " bl");
|
||
assert!(editor.message().contains("first"));
|
||
assert!(editor.message().contains("second"));
|
||
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.leader_pending(), Some("Space"));
|
||
editor.handle(Input::Char(' '));
|
||
assert!(editor.palette().is_some());
|
||
assert_eq!(editor.mode(), mode);
|
||
assert_eq!(editor.leader_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.leader_pending(), Some("Space g Ctrl+r"));
|
||
editor.handle(Input::Char('7'));
|
||
assert_eq!(editor.count(), Some(8));
|
||
assert_eq!(editor.leader_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.leader_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.leader_pending(), None);
|
||
assert_eq!(editor.count(), None);
|
||
editor.handle(Input::Ctrl('p'));
|
||
assert_eq!(palette_entry(&editor, "bnext").bindings, vec![", b n"]);
|
||
editor
|
||
.bind("normal", ",", Some("core.command".into()))
|
||
.unwrap();
|
||
assert!(palette_entry(&editor, "bnext").bindings.is_empty());
|
||
editor.set_leader(",").unwrap();
|
||
editor.handle(Input::Escape);
|
||
editor.handle(Input::Char(','));
|
||
assert_eq!(editor.mode(), Mode::Command);
|
||
assert_eq!(editor.leader_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.leader_pending(), None);
|
||
editor.set_leader("\\").unwrap();
|
||
type_text(&mut editor, "\\b");
|
||
assert_eq!(editor.leader_pending(), Some("\\ b"));
|
||
editor.handle(Input::Char('n'));
|
||
assert_eq!(editor.leader_pending(), None);
|
||
editor.handle(Input::Ctrl('p'));
|
||
assert_eq!(palette_entry(&editor, "bnext").bindings, vec!["\\ b n"]);
|
||
}
|
||
|
||
#[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 <Esc>"] {
|
||
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);
|
||
assert!(
|
||
editor
|
||
.leader_bindings
|
||
.get(&Mode::Normal)
|
||
.unwrap()
|
||
.contains_key(&vec![Input::Char('b'), Input::Char('n')])
|
||
);
|
||
}
|
||
|
||
#[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.leader_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.leader_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.leader_pending(), None);
|
||
}
|
||
type_text(&mut editor, "4 b");
|
||
editor.handle(Input::Char('x'));
|
||
assert!(
|
||
editor
|
||
.message()
|
||
.contains("Unknown leader sequence: Space b 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.leader_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.leader_pending(), None);
|
||
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.leader_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.leader_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.leader_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.leader_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.leader_pending(), None);
|
||
assert_eq!(editor.count(), None);
|
||
type_text(&mut editor, "2 g");
|
||
editor.bind("normal", "z", None).unwrap();
|
||
assert_eq!(editor.leader_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.leader_pending(), None);
|
||
assert_eq!(editor.count(), None);
|
||
assert_eq!(editor.handle(Input::Char('g')), None);
|
||
type_text(&mut editor, " g");
|
||
assert_eq!(editor.leader_pending(), None);
|
||
assert!(editor.message().contains("Unknown leader sequence"));
|
||
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",
|
||
"buffers",
|
||
"buffer 999",
|
||
] {
|
||
type_text(&mut editor, "3,b");
|
||
assert_eq!(editor.leader_pending(), Some(", b"));
|
||
editor.execute(command);
|
||
assert_eq!(editor.leader_pending(), None);
|
||
assert_eq!(editor.count(), None);
|
||
editor.handle(Input::Char(','));
|
||
assert_eq!(editor.leader_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.leader_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.leader_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.leader_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);
|
||
type_text(&mut editor, "3g");
|
||
assert_eq!(editor.count(), None);
|
||
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,
|
||
}
|
||
}
|
||
|
||
fn set_carets(editor: &mut Editor, positions: &[(usize, usize)], primary: usize) {
|
||
editor.active.mode = Mode::Normal;
|
||
editor.active.visual_motions = None;
|
||
editor.active.targets = positions
|
||
.iter()
|
||
.map(|&(row, column)| Target::caret(Cursor { row, column }))
|
||
.collect();
|
||
editor.active.primary = primary;
|
||
editor.normalize_targets();
|
||
}
|
||
|
||
fn set_ranges(editor: &mut Editor, ranges: &[(Cursor, Cursor)], primary: usize) {
|
||
editor.active.mode = Mode::Visual;
|
||
editor.active.visual_motions = None;
|
||
editor.active.targets = ranges
|
||
.iter()
|
||
.map(|&(start, end)| editor.range_target(Selection { start, end }, true))
|
||
.collect();
|
||
editor.active.primary = primary;
|
||
editor.normalize_targets();
|
||
}
|
||
|
||
#[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.targets.clone();
|
||
let primary = editor.active.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.targets, targets);
|
||
assert_eq!(editor.active.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.targets.clone();
|
||
editor.handle(Input::Char('.'));
|
||
assert_eq!(editor.context().text, "1\n2\n3");
|
||
assert_eq!(editor.active.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("bprev".into())).unwrap();
|
||
editor.handle(Input::Char('g'));
|
||
assert_eq!(editor.buffer_id(), 1);
|
||
assert_eq!(editor.mode(), Mode::Visual);
|
||
assert_eq!(editor.carets(), carets);
|
||
assert_eq!(editor.cursor(), primary);
|
||
assert_eq!(editor.selections(), ranges);
|
||
editor.execute("bnext");
|
||
editor.execute("buffer 1");
|
||
assert_eq!(editor.mode(), Mode::Visual);
|
||
editor.handle(Input::Char('h'));
|
||
assert_eq!(editor.cursor(), Cursor { row: 1, column: 1 });
|
||
assert_eq!(editor.selections()[1].end.column, 5);
|
||
}
|
||
|
||
#[test]
|
||
fn switching_commits_insert_groups_and_never_restores_transient_prompts() {
|
||
let directory = Directory::new();
|
||
let a = directory.path("a");
|
||
let b = directory.path("b");
|
||
fs::write(&a, "base").unwrap();
|
||
fs::write(&b, "other").unwrap();
|
||
let mut editor = Editor::open(Some(a), vec![]).unwrap();
|
||
editor.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<_>>(),
|
||
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();
|
||
editor.execute(&format!("w {}", directory.0.join("./a").display()));
|
||
assert!(editor.message().contains("belongs to buffer 1"));
|
||
assert_eq!(editor.path(), Some(b.as_path()));
|
||
assert!(editor.dirty());
|
||
assert_eq!(fs::read_to_string(&a).unwrap(), "a");
|
||
assert_eq!(fs::read_to_string(&b).unwrap(), "b");
|
||
assert_eq!(fs::read_dir(&directory.0).unwrap().count(), 2);
|
||
editor.execute(&format!("wq {}", a.display()));
|
||
assert!(!editor.should_quit());
|
||
assert!(editor.dirty());
|
||
editor.execute(&format!("w {}", directory.0.join("./b").display()));
|
||
assert!(!editor.dirty());
|
||
assert_eq!(fs::read_to_string(&b).unwrap(), "dirty b");
|
||
let new = directory.path("missing");
|
||
editor.execute(&format!("edit {}", new.display()));
|
||
editor.execute("buffer 2");
|
||
editor.execute(&format!("w {}", new.display()));
|
||
assert!(editor.message().contains("belongs to buffer 3"));
|
||
assert!(!new.exists());
|
||
assert!(!editor.dirty());
|
||
}
|
||
|
||
#[test]
|
||
fn clipboard_bindings_plugins_and_palette_catalog_are_session_wide() {
|
||
let directory = Directory::new();
|
||
let mut editor = editor();
|
||
editor.register(plugin("fixture", &["greet"])).unwrap();
|
||
editor.bind("normal", "g", Some("greet".into())).unwrap();
|
||
editor.bind("normal", "z", Some("bnext".into())).unwrap();
|
||
editor
|
||
.apply(vec![Effect::Replace("copied".into())])
|
||
.unwrap();
|
||
type_text(&mut editor, "v$y");
|
||
editor.execute(&format!("edit {}", directory.path("new").display()));
|
||
editor.handle(Input::Char('p'));
|
||
assert_eq!(editor.context().text, "copied");
|
||
assert_eq!(editor.handle(Input::Char('g')).unwrap().plugin, "fixture");
|
||
editor.handle(Input::Char('z'));
|
||
assert_eq!(editor.buffer_id(), 1);
|
||
assert_eq!(editor.handle(Input::Char('g')).unwrap().command, "greet");
|
||
editor.handle(Input::Ctrl('p'));
|
||
for command in [
|
||
"edit", "buffers", "buffer", "bnext", "bprev", "bdelete", "bdelete!",
|
||
] {
|
||
let entry = palette_entry(&editor, command);
|
||
assert_eq!(entry.owner, "core");
|
||
assert!(entry.enabled);
|
||
}
|
||
assert_eq!(
|
||
palette_entry(&editor, "bnext").bindings,
|
||
vec!["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(path.as_path()));
|
||
|
||
let alias = directory.path("directory link");
|
||
symlink(&directory.0, &alias).unwrap();
|
||
let new = alias.join("new");
|
||
editor.execute(&format!("edit {}", new.display()));
|
||
assert_eq!(editor.buffer_id(), 3);
|
||
editor
|
||
.apply(vec![Effect::Replace("new memory".into())])
|
||
.unwrap();
|
||
editor.execute("buffer 1");
|
||
editor.execute(&format!(
|
||
"edit {}",
|
||
directory.0.join("absent/../new").display()
|
||
));
|
||
assert_eq!(editor.buffer_id(), 3);
|
||
assert_eq!(editor.buffer_count(), 3);
|
||
assert_eq!(editor.context().text, "new memory");
|
||
assert_eq!(editor.path(), Some(new.as_path()));
|
||
editor.execute(&format!("w {}", link.display()));
|
||
assert!(editor.message().contains("belongs to buffer 1"));
|
||
assert!(editor.dirty());
|
||
assert_eq!(fs::read_to_string(&path).unwrap(), "dirty memory");
|
||
assert!(!new.exists());
|
||
editor.execute("buffers");
|
||
assert!(editor.message().contains("1 "));
|
||
assert!(editor.message().contains("3%+ "));
|
||
let control_path = directory.path("line\nbreak");
|
||
editor.execute(&format!("edit {}", control_path.display()));
|
||
editor.execute("buffers");
|
||
assert!(!editor.message().chars().any(char::is_control));
|
||
assert!(editor.message().contains("line break"));
|
||
}
|
||
|
||
fn palette_entry<'a>(editor: &'a Editor, name: &str) -> &'a PaletteEntry {
|
||
editor
|
||
.palette()
|
||
.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.targets.clone();
|
||
let primary = editor.active.primary;
|
||
let selections = editor.selections();
|
||
let prompt = editor.command_line.clone();
|
||
let search = editor.active.search_query.clone();
|
||
let found = editor.active.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.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.targets, targets);
|
||
assert_eq!(editor.active.primary, primary);
|
||
assert_eq!(editor.selections(), selections);
|
||
assert_eq!(editor.command_line, prompt);
|
||
assert_eq!(editor.active.search_query, search);
|
||
assert_eq!(editor.active.search_match, found);
|
||
assert_eq!(editor.message, message);
|
||
assert_eq!(
|
||
editor.active.undo.iter().map(&snapshot).collect::<Vec<_>>(),
|
||
undo
|
||
);
|
||
assert_eq!(
|
||
editor.active.redo.iter().map(&snapshot).collect::<Vec<_>>(),
|
||
redo
|
||
);
|
||
assert_eq!(editor.active.insert_start.as_ref().map(snapshot), insert);
|
||
if mode == Mode::Insert {
|
||
type_text(&mut editor, "Y");
|
||
editor.handle(Input::Escape);
|
||
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() {
|
||
let mut names = HashSet::new();
|
||
for &(name, action, modes) in CORE_ACTIONS {
|
||
assert!(names.insert(name));
|
||
for mode in [
|
||
Mode::Normal,
|
||
Mode::Insert,
|
||
Mode::Command,
|
||
Mode::Search,
|
||
Mode::Visual,
|
||
] {
|
||
assert!(Action::named(mode, name) == modes.contains(&mode).then_some(action));
|
||
}
|
||
}
|
||
for mode in [Mode::Insert, Mode::Command, Mode::Search, Mode::Visual] {
|
||
let mut editor = editor();
|
||
editor.active.mode = mode;
|
||
editor.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_eq!(editor.palette().unwrap().entries.len(), 1);
|
||
assert!(!palette_entry(&editor, "q!").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_eq!(editor.command_line(), "q! ");
|
||
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.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.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.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.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<_>>(),
|
||
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.mode = Mode::Visual;
|
||
editor.active.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.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);
|
||
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.targets, before.targets);
|
||
assert_eq!(editor.active.primary, before.primary);
|
||
assert_eq!(
|
||
(editor.active.undo.len(), editor.active.redo.len()),
|
||
(undo, redo)
|
||
);
|
||
assert!(editor.message().contains("fragment count"));
|
||
editor.clipboard = vec!["!".into()];
|
||
set_carets(&mut editor, &[(0, 0), (1, 0)], 0);
|
||
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<_>>(),
|
||
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_and_invocation_are_single_primary_but_messages_are_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())]).unwrap();
|
||
assert_eq!(editor.selections(), ranges);
|
||
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 [
|
||
"",
|
||
"<C-r>",
|
||
"<Esc>",
|
||
"ctrl+",
|
||
"+r",
|
||
"ctrl++r",
|
||
"ctrl+shift+j",
|
||
"shift+tab",
|
||
"shift+1",
|
||
"alt+j",
|
||
"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 plugin_effects_validate_before_any_mutation_and_group_undo() {
|
||
let mut editor = editor();
|
||
editor.set_message("unchanged");
|
||
assert!(
|
||
editor
|
||
.apply(vec![
|
||
Effect::Insert("good".into()),
|
||
Effect::Message("bad\u{1b}".into())
|
||
])
|
||
.is_err()
|
||
);
|
||
assert_eq!(editor.context().text, "");
|
||
assert_eq!(editor.message(), "unchanged");
|
||
assert!(!editor.dirty());
|
||
assert!(
|
||
editor
|
||
.apply(vec![
|
||
Effect::Insert("a".repeat(MAX_EDIT_BYTES)),
|
||
Effect::Insert("x".into())
|
||
])
|
||
.is_err()
|
||
);
|
||
assert_eq!(editor.context().text, "");
|
||
editor
|
||
.apply(vec![
|
||
Effect::Insert("one".into()),
|
||
Effect::Insert("\ntwo".into()),
|
||
Effect::Message("ok".into()),
|
||
])
|
||
.unwrap();
|
||
assert_eq!(editor.context().text, "one\ntwo");
|
||
assert_eq!(editor.message(), "ok");
|
||
editor.handle(Input::Char('u'));
|
||
assert_eq!(editor.context().text, "");
|
||
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();
|
||
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();
|
||
editor.execute("wq");
|
||
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.targets = vec![Target::caret(Cursor { row: 0, column: 1 })];
|
||
assert_eq!(editor.diagnostic_at_cursor().unwrap().message, "first");
|
||
editor.active.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.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.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");
|
||
}
|
||
}
|