#[path = "editor_review.rs"] mod local_review; use local_review::ReviewBuffer; use crate::chrome::{ChromeLine, PopupConfig, validate_name}; use crate::clipboard::{ClipboardMode, SystemClipboard}; use crate::diff::{DiffState, Focus, Operation, Outcome, Phase, RevisionTarget, ViewMode}; use crate::files::{DockSide, FilePicker, FilePresentation, FileView}; use crate::panes::{PaneDirection, PaneRect, PaneTree, SplitAxis, directional_neighbor}; use crate::plugin::{ CompletionItem, Diagnostic, SemanticToken, SnippetTabstop, TextPosition, Utf16Range, }; use serde::Serialize; use std::borrow::Cow; use std::cell::Cell; use std::collections::{BTreeMap, HashMap, HashSet, VecDeque}; use std::fmt; use std::fs::{self, OpenOptions}; use std::io::{self, Read, Write}; use std::num::{NonZeroU8, NonZeroUsize}; use std::ops::Range; #[cfg(unix)] use std::os::unix::fs::{MetadataExt, OpenOptionsExt}; use std::path::{Component, Path, PathBuf}; use std::sync::Arc; use std::sync::atomic::{AtomicU64, Ordering}; use unicode_segmentation::{GraphemeCursor, UnicodeSegmentation}; use unicode_width::UnicodeWidthStr; const MAX_EDIT_BYTES: usize = 4 * 1024 * 1024; const PROMPT_HISTORY_LIMIT: usize = 100; static TEMP_ID: AtomicU64 = AtomicU64::new(0); #[derive(Clone, Copy, Debug, Default, Eq, PartialEq, serde::Deserialize)] #[serde(rename_all = "snake_case")] pub enum WhitespaceScope { #[default] All, Leading, } #[derive(Clone, Debug, Eq, Hash, PartialEq)] pub enum Input { Char(char), Ctrl(char), Alt(char), CtrlAlt(char), Paste(String), Escape, Enter, Backspace, AltBackspace, SuperBackspace, Delete, BackTab, Left, Right, Up, Down, Home, End, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(crate) enum BackspaceKind { Grapheme, Word, Line, } impl BackspaceKind { pub(crate) fn from_input(input: &Input) -> Option { match input { Input::Backspace => Some(Self::Grapheme), Input::AltBackspace => Some(Self::Word), Input::SuperBackspace => Some(Self::Line), _ => None, } } fn start(self, prefix: &str) -> usize { match self { Self::Grapheme => prefix .grapheme_indices(true) .next_back() .map_or(0, |(byte, _)| byte), Self::Word => prefix .split_word_bound_indices() .rev() .find(|(_, word)| !word.chars().all(char::is_whitespace)) .map_or(0, |(byte, _)| byte), Self::Line => prefix.rfind('\n').map_or(0, |byte| byte + 1), } } pub(crate) fn truncate(self, text: &mut String) -> bool { let start = self.start(text); let changed = start != text.len(); text.truncate(start); changed } } #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] pub enum Mode { Normal, Insert, Command, Search, Visual, } impl fmt::Display for Mode { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str(match self { Self::Normal => "NORMAL", Self::Insert => "INSERT", Self::Command => "COMMAND", Self::Search => "SEARCH", Self::Visual => "VISUAL", }) } } pub struct Palette { pub query: String, /// Buffer results use ascending IDs; command results sort by label then name. pub entries: Vec, pub selected: usize, pub buffers: bool, } pub struct StartupDiagnostic { pub lines: Vec, pub top: usize, pub left: usize, max_column: usize, } pub(crate) struct ShellOutput { pub(crate) title: String, pub(crate) content: StartupDiagnostic, pub(crate) wrapping: bool, pub(crate) wrapped_rows: Cell, pub(crate) wrapped_lines: std::cell::RefCell<(usize, Vec<(usize, crate::wrap::Segment)>)>, } pub struct Hover { text: String, line_starts: Vec, /// First visible logical line. pub top: usize, /// First visible extended grapheme, independent of terminal geometry. pub left: usize, max_column: usize, buffer_id: usize, revision: u64, caret: Cursor, } /// A live preview; zero selects the immutable original document. pub struct Completion { pub items: Vec, pub selected: usize, } struct CompletionState { popup: Completion, original: Snapshot, } struct SnippetState { tabstops: Vec, active: usize, selected: bool, selections: Vec, replacement: Range, recipe_step: Option, } #[cfg_attr(test, derive(Debug))] struct RelativeCompletionEdit { start: isize, end: isize, text: String, // Extra edits are replayed only against the same surrounding logical lines. context: Option>, } impl Hover { pub fn text(&self) -> &str { &self.text } pub fn lines(&self) -> impl ExactSizeIterator { self.line_starts.iter().enumerate().map(|(index, &start)| { let end = self .line_starts .get(index + 1) .map_or(self.text.len(), |next| next - 1); &self.text[start..end] }) } } pub struct PaletteEntry { pub name: String, pub label: String, pub owner: String, /// Effective bindings in the underlying editor mode. pub bindings: Vec, pub enabled: bool, pub modes: Vec, pub buffer: Option, } pub struct PaletteBuffer { pub id: usize, pub active: bool, pub dirty: bool, } #[derive(Clone, Copy, Debug, Default, Eq, Ord, PartialEq, PartialOrd)] pub struct Cursor { pub row: usize, /// Zero-based extended grapheme index, not a byte or display-cell offset. pub column: usize, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct SearchMatch { pub start: Cursor, /// Exclusive end, in grapheme columns on the same row as `start`. pub end_column: usize, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct Selection { /// Inclusive start of the ordered range, in grapheme coordinates. pub start: Cursor, /// Exclusive end in grapheme coordinates; may be the next row's column 0. pub end: Cursor, } #[derive(Clone, Debug, Eq, PartialEq)] pub struct Feature { pub name: String, pub required_capabilities: Vec, pub commands: Vec, pub core: bool, } #[derive(Clone, Debug, Eq, PartialEq)] pub enum Effect { Insert(String), Replace(String), Message(String), Label { name: String, text: String }, } #[derive(Clone, Debug, Eq, PartialEq)] pub struct Invocation { pub plugin: String, pub command: String, pub args: String, } #[derive(Debug, Serialize)] pub struct Context { pub buffer_id: usize, pub revision: u64, pub root: String, pub text: String, /// One-based row, including the empty row after a trailing newline. pub row: usize, /// One-based extended grapheme column. pub column: usize, pub mode: String, pub path: Option, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] struct Target { anchor: Cursor, active: Cursor, // Display columns survive a clone onto a shorter line. preferred_columns: Option<(usize, usize)>, } impl Target { fn caret(cursor: Cursor) -> Self { Self { anchor: cursor, active: cursor, preferred_columns: None, } } } #[cfg_attr(test, derive(Debug))] struct Snapshot { lines: Vec, targets: Vec, primary: usize, } /// Retain only changed rows between undo groups, not another copy of the file. #[cfg_attr(test, derive(Debug))] struct HistoryEntry { start: usize, end: usize, before: Snapshot, } impl HistoryEntry { fn new(mut before: Snapshot, after: &[String]) -> Self { let start = before .lines .iter() .zip(after) .take_while(|(a, b)| a == b) .count(); let suffix = before.lines[start..] .iter() .rev() .zip(after[start..].iter().rev()) .take_while(|(a, b)| a == b) .count(); before.lines = before .lines .drain(start..before.lines.len() - suffix) .collect(); Self { start, end: after.len() - suffix, before, } } } struct Edit<'a> { target: Option, start: usize, end: usize, text: Cow<'a, str>, caret: usize, round_up: bool, } // Recipes contain resolved native intent, never input keys or source-buffer offsets. #[cfg_attr(test, derive(Debug))] struct EditRecipe { start: EditStart, steps: Vec, } #[cfg_attr(test, derive(Debug))] enum EditStart { Insert(Action), Selection { change: bool, selector: SelectionIntent, }, Lines { change: bool, count: usize, }, Normal { action: Action, count: usize, }, Replace { target: char, count: usize, }, ReplaceSelection { target: char, selector: SelectionIntent, }, Join, Paste { after: bool, fragments: Vec, linewise: bool, selector: Option, }, } #[cfg_attr(test, derive(Debug))] enum SelectionIntent { Motions { motions: Vec, linewise: bool, }, // Whole graphemes, with a newline counting as one boundary. Spans(Vec), Rows(Vec), } #[cfg_attr(test, derive(Debug))] #[derive(Clone, Copy)] enum Motion { Action(Action, usize), Seek { forward: bool, target: char, count: usize, }, } #[cfg_attr(test, derive(Debug))] enum InsertStep { Typed(String), Bulk(String), Move(Action, usize), Delete(Vec), Backspace(Vec, BackspaceKind), Completion { primary_edit: usize, edits: Vec, caret: usize, }, } #[derive(Serialize)] struct CoreAction { name: &'static str, ex: Option<&'static str>, #[serde(skip)] action: Action, #[serde(skip)] modes: &'static [Mode], #[serde(skip)] prompt: bool, #[serde(skip)] palette_prompt: bool, } macro_rules! core_actions { ($(($name:literal, Action::$action:ident, $modes:expr $(, $command:tt $(, $prompt:literal $(, $palette_prompt:literal)?)?)? $(,)?)),* $(,)?) => { #[cfg_attr(test, derive(Debug))] #[derive(Clone, Copy, Eq, PartialEq)] enum Action { $($action),* } // Generated together: each discriminant indexes its own catalog entry. const CORE_ACTIONS: &[CoreAction] = &[$(CoreAction { name: $name, action: Action::$action, modes: $modes, ex: core_actions!(@ex $name $(, $command)?), prompt: core_actions!(@prompt $($($prompt)?)?), palette_prompt: core_actions!(@prompt $($($($palette_prompt)?)?)?), }),*]; impl Action { const fn entry(self) -> &'static CoreAction { &CORE_ACTIONS[self as usize] } } }; (@ex $name:literal, true) => { Some($name.split_at("core.".len()).1) }; (@ex $name:literal) => { None }; (@prompt $prompt:literal) => { $prompt }; (@prompt) => { false }; } // One catalog owns typed actions, derived canonical Ex commands, and palette eligibility. core_actions![ ( "core.pane.split_horizontal", Action::PaneSplitHorizontal, &[Mode::Normal], true ), ( "core.pane.split_vertical", Action::PaneSplitVertical, &[Mode::Normal], true ), ( "core.pane.focus_left", Action::PaneFocusLeft, &[Mode::Normal], true ), ( "core.pane.focus_down", Action::PaneFocusDown, &[Mode::Normal], true ), ( "core.pane.focus_up", Action::PaneFocusUp, &[Mode::Normal], true ), ( "core.pane.focus_right", Action::PaneFocusRight, &[Mode::Normal], true ), ("core.pane.close", Action::PaneClose, &[Mode::Normal], true), ( "core.pane.force_close", Action::PaneForceClose, &[Mode::Normal], true, false, true ), ("core.tab.new", Action::TabNew, &[Mode::Normal], true), ( "core.tab.previous", Action::TabPrevious, &[Mode::Normal], true ), ("core.tab.next", Action::TabNext, &[Mode::Normal], true), ("core.tab.close", Action::TabClose, &[Mode::Normal], true), ( "core.tab.force_close", Action::TabForceClose, &[Mode::Normal], true, false, true ), ("core.insert", Action::Insert, &[Mode::Normal]), ( "core.insert_first_nonblank", Action::InsertFirstNonblank, &[Mode::Normal, Mode::Visual], ), ("core.append", Action::Append, &[Mode::Normal]), ( "core.append_line_end", Action::AppendLineEnd, &[Mode::Normal, Mode::Visual], ), ("core.open_below", Action::OpenBelow, &[Mode::Normal]), ("core.open_above", Action::OpenAbove, &[Mode::Normal]), ("core.join_lines", Action::JoinLines, &[Mode::Normal]), ("core.undo", Action::Undo, &[Mode::Normal]), ("core.redo", Action::Redo, &[Mode::Normal]), ( "core.repeat_last_edit", Action::RepeatLastEdit, &[Mode::Normal, Mode::Visual], ), ( "core.command", Action::Command, &[Mode::Normal, Mode::Visual] ), ("core.search", Action::Search, &[Mode::Normal]), ("core.search_word", Action::SearchWord, &[Mode::Normal]), ( "core.search_next", Action::SearchNext, &[Mode::Normal, Mode::Visual], ), ( "core.search_previous", Action::SearchPrevious, &[Mode::Normal, Mode::Visual], ), ("core.select", Action::Select, &[Mode::Normal, Mode::Visual]), ( "core.select_linewise", Action::SelectLinewise, &[Mode::Normal, Mode::Visual], ), ("core.select_lines", Action::SelectLines, &[Mode::Visual]), ( "core.clone_above", Action::CloneAbove, &[Mode::Normal, Mode::Visual], ), ( "core.clone_below", Action::CloneBelow, &[Mode::Normal, Mode::Visual], ), ("core.select_next", Action::SelectNext, &[Mode::Visual]), ( "core.skip_occurrence", Action::SkipOccurrence, &[Mode::Visual], ), ("core.select_all", Action::SelectAll, &[Mode::Visual]), ("core.copy", Action::Copy, &[Mode::Visual]), ("core.copy_system", Action::CopySystem, &[Mode::Visual]), ("core.cut", Action::Cut, &[Mode::Visual]), ("core.change", Action::Change, &[Mode::Visual]), ("core.delete_line", Action::DeleteLine, &[Mode::Normal]), ("core.change_line", Action::ChangeLine, &[Mode::Normal]), ("core.copy_line", Action::CopyLine, &[Mode::Normal]), ( "core.change_to_line_end", Action::ChangeToLineEnd, &[Mode::Normal] ), ( "core.delete_to_line_end", Action::DeleteToLineEnd, &[Mode::Normal] ), ( "core.copy_to_line_end", Action::CopyToLineEnd, &[Mode::Normal] ), ( "core.replace_character", Action::ReplaceCharacter, &[Mode::Normal, Mode::Visual] ), ( "core.substitute_character", Action::SubstituteCharacter, &[Mode::Normal] ), ( "core.delete_word_forward", Action::DeleteWordForward, &[Mode::Normal] ), ( "core.delete_word_backward", Action::DeleteWordBackward, &[Mode::Normal] ), ( "core.change_word_forward", Action::ChangeWordForward, &[Mode::Normal] ), ( "core.change_word_backward", Action::ChangeWordBackward, &[Mode::Normal] ), ( "core.paste_before", Action::PasteBefore, &[Mode::Normal, Mode::Visual] ), ( "core.paste_after", Action::PasteAfter, &[Mode::Normal, Mode::Visual] ), ( "core.paste_system_before", Action::PasteSystemBefore, &[Mode::Normal, Mode::Visual], ), ( "core.paste_system_after", Action::PasteSystemAfter, &[Mode::Normal, Mode::Visual], ), ( "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, Mode::Command, Mode::Search, ], ), ( "core.up", Action::Up, &[ Mode::Normal, Mode::Insert, Mode::Visual, Mode::Command, Mode::Search, ], ), ( "core.scroll_up", Action::ScrollUp, &[Mode::Normal, Mode::Visual] ), ( "core.scroll_down", Action::ScrollDown, &[Mode::Normal, Mode::Visual] ), ( "core.scroll_step_up", Action::ScrollStepUp, &[Mode::Normal, Mode::Visual] ), ( "core.scroll_step_down", Action::ScrollStepDown, &[Mode::Normal, Mode::Visual] ), ( "core.line_start", Action::LineStart, &[Mode::Normal, Mode::Insert, Mode::Visual], ), ( "core.line_end", Action::LineEnd, &[Mode::Normal, Mode::Insert, Mode::Visual], ), ( "core.document_start", Action::DocumentStart, &[Mode::Normal, Mode::Visual], ), ( "core.document_end", Action::DocumentEnd, &[Mode::Normal, Mode::Visual], ), ( "core.go_to_line", Action::GoToLine, &[Mode::Normal, Mode::Visual] ), ( "core.seek_forward", Action::SeekForward, &[Mode::Normal, Mode::Visual], ), ( "core.seek_backward", Action::SeekBackward, &[Mode::Normal, Mode::Visual], ), ( "core.word_next", Action::WordNext, &[Mode::Normal, Mode::Insert, Mode::Visual], ), ( "core.word_previous", Action::WordPrevious, &[Mode::Normal, Mode::Insert, Mode::Visual], ), ( "core.word_end", Action::WordEnd, &[Mode::Normal, Mode::Insert, Mode::Visual], ), ( "core.big_word_next", Action::BigWordNext, &[Mode::Normal, Mode::Insert, Mode::Visual], ), ( "core.big_word_previous", Action::BigWordPrevious, &[Mode::Normal, Mode::Insert, Mode::Visual], ), ( "core.big_word_end", Action::BigWordEnd, &[Mode::Normal, Mode::Insert, Mode::Visual], ), ("core.delete", Action::Delete, &[Mode::Normal, Mode::Insert]), ( "core.normal", Action::Normal, &[ Mode::Normal, Mode::Insert, Mode::Command, Mode::Search, Mode::Visual, ], ), ("core.newline", Action::Newline, &[Mode::Insert]), ( "core.backspace", Action::Backspace, &[Mode::Insert, Mode::Command, Mode::Search], ), ( "core.backspace_word", Action::BackspaceWord, &[Mode::Insert, Mode::Command, Mode::Search], ), ( "core.backspace_line", Action::BackspaceLine, &[Mode::Insert, Mode::Command, Mode::Search], ), ( "core.submit", Action::Submit, &[Mode::Command, Mode::Search], ), ( "core.command_complete", Action::CommandComplete, &[Mode::Command], ), ( "core.command_complete_previous", Action::CommandCompletePrevious, &[Mode::Command], ), ( "core.palette", Action::Palette, &[ Mode::Normal, Mode::Insert, Mode::Command, Mode::Search, Mode::Visual, ], ), ( "core.file_picker", Action::FilePicker, &[Mode::Normal, Mode::Visual], ), ( "core.file_explorer", Action::FileExplorer, &[Mode::Normal, Mode::Visual], ), ( "core.follow_link", Action::FollowLink, &[Mode::Normal], true ), ("core.jump_back", Action::JumpBack, &[Mode::Normal]), ("core.jump_forward", Action::JumpForward, &[Mode::Normal]), ( "core.lsp.definition", Action::LspDefinition, &[Mode::Normal], true, ), ("core.lsp.hover", Action::LspHover, &[Mode::Normal], true), ( "core.lsp.completion", Action::LspCompletion, &[Mode::Insert], ), ("core.snippet.next", Action::SnippetNext, &[Mode::Insert]), ( "core.snippet.previous", Action::SnippetPrevious, &[Mode::Insert], ), ( "core.lsp.diagnostics", Action::LspDiagnostics, &[Mode::Normal], true, ), ("core.save", Action::Save, &[Mode::Normal], true), ( "core.force_save", Action::ForceSave, &[Mode::Normal], true, false, true ), ("core.diff", Action::Diff, &[Mode::Normal], true), ("core.resolve", Action::Resolve, &[Mode::Normal], true), ( "core.resolve.next", Action::ResolveNext, &[Mode::Normal], true ), ( "core.resolve.previous", Action::ResolvePrevious, &[Mode::Normal], true ), ( "core.resolve.use_source", Action::ResolveUseSource, &[Mode::Normal], true ), ( "core.resolve.source", Action::ResolveSource, &[Mode::Normal], true ), ( "core.resolve.base", Action::ResolveBase, &[Mode::Normal], true ), ( "core.resolve.next_file", Action::ResolveNextFile, &[Mode::Normal], true ), ( "core.resolve.previous_file", Action::ResolvePreviousFile, &[Mode::Normal], true ), ( "core.resolve.files", Action::ResolveFiles, &[Mode::Normal], true ), ( "core.resolve.draft", Action::ResolveDraft, &[Mode::Normal], true ), ( "core.resolve.history", Action::ResolveHistory, &[Mode::Normal], true ), ( "core.resolve.view.toggle", Action::ResolveViewToggle, &[Mode::Normal], true ), ( "core.resolve.close", Action::ResolveClose, &[Mode::Normal], true ), ( "core.pull_requests", Action::PullRequests, &[Mode::Normal], true ), ( "core.diff.mode.toggle", Action::DiffModeToggle, &[Mode::Normal], true, ), ( "core.diff.view.toggle", Action::DiffViewToggle, &[Mode::Normal], true, ), ( "core.diff.orientation.toggle", Action::DiffOrientationToggle, &[Mode::Normal], true, ), ( "core.diff.review.toggle", Action::DiffReviewToggle, &[Mode::Normal], true, ), ( "core.comment", Action::Comment, &[Mode::Normal, Mode::Visual], true ), ( "core.review.edit", Action::ReviewEdit, &[Mode::Normal], true, true ), ( "core.review.delete", Action::ReviewDelete, &[Mode::Normal], true, true ), ( "core.review.resolve", Action::ReviewResolve, &[Mode::Normal], true, true ), ( "core.review.unresolve", Action::ReviewUnresolve, &[Mode::Normal], true, true ), ( "core.review.next", Action::ReviewNext, &[Mode::Normal], true ), ( "core.review.previous", Action::ReviewPrevious, &[Mode::Normal], true ), ( "core.review.overview", Action::ReviewOverview, &[Mode::Normal], true ), ( "core.review.copy", Action::ReviewCopy, &[Mode::Normal], true ), ( "core.review.summary", Action::ReviewSummary, &[Mode::Normal], true ), ( "core.review.verdict", Action::ReviewVerdict, &[Mode::Normal], true, true ), ( "core.review.save", Action::ReviewSave, &[Mode::Normal], true, true ), ( "core.review.open", Action::ReviewOpen, &[Mode::Normal], true, true ), ("core.review.new", Action::ReviewNew, &[Mode::Normal], true), ( "core.word_wrap.toggle", Action::WordWrapToggle, &[ Mode::Normal, Mode::Insert, Mode::Visual, Mode::Command, Mode::Search ], true, ), ( "core.git_blame.toggle", Action::GitBlameToggle, &[ Mode::Normal, Mode::Insert, Mode::Visual, Mode::Command, Mode::Search ], true, ), ( "core.diff.close", Action::DiffClose, &[ Mode::Normal, Mode::Insert, Mode::Visual, Mode::Command, Mode::Search ], true, ), ("core.quit", Action::Quit, &[Mode::Normal], true), ( "core.force_quit", Action::ForceQuit, &[Mode::Normal], true, false, true ), ("core.restart", Action::Restart, &[Mode::Normal], true), ( "core.force_restart", Action::ForceRestart, &[Mode::Normal], true, false, true ), ( "core.save_and_quit", Action::SaveAndQuit, &[Mode::Normal], true ), ( "core.force_save_and_quit", Action::ForceSaveAndQuit, &[Mode::Normal], true, false, true ), ("core.plugins", Action::Plugins, &[Mode::Normal], true), ("core.edit", Action::Edit, &[Mode::Normal], true, true), ("core.buffers", Action::Buffers, &[Mode::Normal], true), ("core.buffer", Action::Buffer, &[Mode::Normal], true, true), ( "core.buffer_next", Action::BufferNext, &[Mode::Normal], true ), ( "core.buffer_previous", Action::BufferPrevious, &[Mode::Normal], true ), ( "core.buffer_delete", Action::BufferDelete, &[Mode::Normal], true ), ( "core.buffer_force_delete", Action::BufferForceDelete, &[Mode::Normal], true, false, true ), ]; // Alias targets stay typed; canonical spelling comes from the action catalog. const COMMAND_ALIASES: &[(&str, Action)] = &[ ("w", Action::Save), ("w!", Action::ForceSave), ("q", Action::Quit), ("q!", Action::ForceQuit), ("restart!", Action::ForceRestart), ("wq", Action::SaveAndQuit), ("wq!", Action::ForceSaveAndQuit), ("bnext", Action::BufferNext), ("bprev", Action::BufferPrevious), ("bdelete", Action::BufferDelete), ("bdelete!", Action::BufferForceDelete), ]; fn core_command(name: &str) -> Option<&'static CoreAction> { COMMAND_ALIASES .iter() .find(|(alias, _)| *alias == name) .map(|(_, action)| action.entry()) .or_else(|| CORE_ACTIONS.iter().find(|entry| entry.ex == Some(name))) } pub(crate) struct CoreCommandAliases; impl Serialize for CoreCommandAliases { fn serialize(&self, serializer: S) -> Result { serializer.collect_map(COMMAND_ALIASES.iter().map(|(alias, action)| { ( alias, action.entry().ex.expect("aliases target command actions"), ) })) } } /// Serializes authoritative typed names and their derived canonical Ex commands. pub(crate) struct CoreActionCatalog; impl Serialize for CoreActionCatalog { fn serialize(&self, serializer: S) -> Result { serializer.collect_seq(CORE_ACTIONS) } } impl Action { fn mutates(self, mode: Mode) -> bool { matches!( self, Self::Insert | Self::InsertFirstNonblank | Self::Append | Self::AppendLineEnd | Self::OpenBelow | Self::OpenAbove | Self::JoinLines | Self::Undo | Self::Redo | Self::RepeatLastEdit | Self::Delete | Self::DeleteLine | Self::ChangeLine | Self::ChangeToLineEnd | Self::DeleteToLineEnd | Self::ReplaceCharacter | Self::SubstituteCharacter | Self::DeleteWordForward | Self::DeleteWordBackward | Self::ChangeWordForward | Self::ChangeWordBackward | Self::Cut | Self::Change | Self::PasteBefore | Self::PasteAfter | Self::PasteSystemBefore | Self::PasteSystemAfter | Self::Newline | Self::Save | Self::ForceSave | Self::SaveAndQuit | Self::ForceSaveAndQuit | Self::BufferDelete | Self::BufferForceDelete | Self::LspCompletion ) || (self.backspace_kind().is_some() && mode == Mode::Insert) || (self == Self::SnippetNext && mode == Mode::Insert) } fn backspace_kind(self) -> Option { match self { Self::Backspace => Some(BackspaceKind::Grapheme), Self::BackspaceWord => Some(BackspaceKind::Word), Self::BackspaceLine => Some(BackspaceKind::Line), _ => None, } } fn named(mode: Mode, name: &str) -> Option { CORE_ACTIONS .iter() .find(|entry| entry.name == name && entry.modes.contains(&mode)) .map(|entry| entry.action) } } enum Binding { Action(Action), Command(String), Disabled, } #[derive(Default, Eq, Hash, PartialEq)] struct BindingSequence { // None keeps leader routes symbolic when the configured leader changes. prefix: Option, keys: Vec, } fn parse_key(key: &str) -> Result { let character = |name: &str| { let mut chars = name.chars(); chars .next() .filter(|ch| !ch.is_control() && chars.next().is_none()) }; // Keep literal punctuation, especially "+", usable without escaping. if let Some(ch) = character(key) { return Ok(Input::Char(ch)); } let invalid = || format!("Invalid binding key: {key:?}"); if let Some((first, rest)) = key.split_once('+') && let Some((second, name)) = rest.split_once('+') { let alt = |modifier: &str| { modifier.eq_ignore_ascii_case("alt") || modifier.eq_ignore_ascii_case("option") }; if ((first.eq_ignore_ascii_case("ctrl") && alt(second)) || (alt(first) && second.eq_ignore_ascii_case("ctrl"))) && let Some(ch) = character(name).filter(char::is_ascii_alphabetic) { return Ok(Input::CtrlAlt(ch.to_ascii_lowercase())); } return Err(invalid()); } let (modifier, name) = key .split_once('+') .map_or((None, key), |(modifier, name)| (Some(modifier), name)); let input = if let Some(ch) = character(name) { Input::Char(ch) } else { [ ("esc", Input::Escape), ("enter", Input::Enter), ("backspace", Input::Backspace), ("delete", Input::Delete), ("left", Input::Left), ("right", Input::Right), ("up", Input::Up), ("down", Input::Down), ("home", Input::Home), ("end", Input::End), ("tab", Input::Char('\t')), ("space", Input::Char(' ')), ("minus", Input::Char('-')), ("comma", Input::Char(',')), ("ampersand", Input::Char('&')), ("plus", Input::Char('+')), ("backtick", Input::Char('`')), ] .into_iter() .find(|(key, _)| name.eq_ignore_ascii_case(key)) .map(|(_, input)| input) .ok_or_else(invalid)? }; match (modifier, input) { (None, input) => Ok(input), (Some(modifier), Input::Char('\t')) if modifier.eq_ignore_ascii_case("shift") => { Ok(Input::BackTab) } (Some(modifier), Input::Backspace) if modifier.eq_ignore_ascii_case("alt") || modifier.eq_ignore_ascii_case("option") => { Ok(Input::AltBackspace) } (Some(modifier), Input::Backspace) if modifier.eq_ignore_ascii_case("cmd") || modifier.eq_ignore_ascii_case("super") => { Ok(Input::SuperBackspace) } (Some(modifier), Input::Char(ch)) if ch.is_ascii_alphabetic() => { if modifier.eq_ignore_ascii_case("ctrl") { Ok(control_key(ch)) } else if modifier.eq_ignore_ascii_case("alt") || modifier.eq_ignore_ascii_case("option") { Ok(Input::Alt(ch.to_ascii_lowercase())) } else if modifier.eq_ignore_ascii_case("shift") { Ok(Input::Char(ch.to_ascii_uppercase())) } else { Err(invalid()) } } _ => Err(invalid()), } } fn control_key(ch: char) -> Input { // Preserve distinguishable control keys; Ctrl+M retains its Enter alias. match ch.to_ascii_lowercase() { 'm' => Input::Enter, ch => Input::Ctrl(ch), } } fn display_key(input: &Input) -> String { let mut display = String::new(); append_key(&mut display, input); display } fn append_key(display: &mut String, input: &Input) { use std::fmt::Write as _; match input { Input::Char('\t') => display.push_str("Tab"), Input::BackTab => display.push_str("Shift+Tab"), Input::Char(' ') => display.push_str("Space"), Input::Char(ch) => display.push(*ch), Input::Ctrl(ch) => write!(display, "Ctrl+{ch}").expect("writing to a String"), Input::Alt(ch) => write!(display, "Alt+{ch}").expect("writing to a String"), Input::CtrlAlt(ch) => write!(display, "Ctrl+Alt+{ch}").expect("writing to a String"), Input::Escape => display.push_str("Esc"), Input::Enter => display.push_str("Enter"), Input::Backspace => display.push_str("Backspace"), Input::AltBackspace => display.push_str("Alt+Backspace"), Input::SuperBackspace => display.push_str("Cmd+Backspace"), Input::Delete => display.push_str("Delete"), Input::Left => display.push_str("Left"), Input::Right => display.push_str("Right"), Input::Up => display.push_str("Up"), Input::Down => display.push_str("Down"), Input::Home => display.push_str("Home"), Input::End => display.push_str("End"), Input::Paste(_) => unreachable!("paste cannot be bound"), } } fn default_bindings() -> HashMap> { use Action::*; let mut bindings: HashMap> = HashMap::new(); for (key, action) in [ ('i', Insert), ('I', InsertFirstNonblank), ('a', Append), ('A', AppendLineEnd), ('o', OpenBelow), ('O', OpenAbove), ('J', JoinLines), ('x', Delete), ('C', ChangeToLineEnd), ('D', DeleteToLineEnd), ('Y', CopyToLineEnd), ('s', SubstituteCharacter), ('u', Undo), ('U', Redo), ('/', Search), ('*', SearchWord), ('p', PasteAfter), ('P', PasteBefore), ('_', PaneSplitHorizontal), ('|', PaneSplitVertical), ] { bindings .entry(Mode::Normal) .or_default() .insert(Input::Char(key), Binding::Action(action)); } for (key, action) in [ (Input::Ctrl('o'), JumpBack), (Input::Ctrl('b'), DiffModeToggle), (Input::Char('\t'), JumpForward), (Input::Ctrl('i'), JumpForward), (Input::Alt('h'), PaneFocusLeft), (Input::Alt('j'), PaneFocusDown), (Input::Alt('k'), PaneFocusUp), (Input::Alt('l'), PaneFocusRight), (Input::CtrlAlt('h'), TabPrevious), (Input::CtrlAlt('l'), TabNext), ] { bindings .entry(Mode::Normal) .or_default() .insert(key, Binding::Action(action)); } for mode in [Mode::Normal, Mode::Visual] { for (key, action) in [ (':', Command), ('h', Left), ('j', Down), ('k', Up), ('l', Right), ('0', LineStart), ('$', LineEnd), ('w', WordNext), ('b', WordPrevious), ('e', WordEnd), ('W', BigWordNext), ('B', BigWordPrevious), ('E', BigWordEnd), ('f', SeekForward), ('F', SeekBackward), ('n', SearchNext), ('N', SearchPrevious), ('v', Select), ('V', SelectLinewise), ('.', RepeatLastEdit), ('r', ReplaceCharacter), ] { bindings .entry(mode) .or_default() .insert(Input::Char(key), Binding::Action(action)); } for (key, action) in [ (Input::Ctrl('k'), CloneAbove), (Input::Ctrl('e'), CloneBelow), (Input::Ctrl('u'), ScrollStepUp), (Input::Ctrl('d'), ScrollStepDown), (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::Char('p'), PasteAfter), (Input::Char('P'), PasteBefore), (Input::Delete, Cut), (Input::Char('I'), InsertFirstNonblank), (Input::Char('A'), AppendLineEnd), (Input::Ctrl('l'), SelectLines), (Input::Ctrl('n'), SelectNext), (Input::Ctrl('s'), SkipOccurrence), (Input::Ctrl('a'), SelectAll), ] { bindings .entry(Mode::Visual) .or_default() .insert(key, Binding::Action(action)); } bindings .entry(Mode::Insert) .or_default() .insert(Input::Ctrl('n'), Binding::Action(LspCompletion)); for (key, action) in [ (Input::Char('\t'), SnippetNext), (Input::Ctrl('i'), SnippetNext), (Input::BackTab, SnippetPrevious), ] { bindings .entry(Mode::Insert) .or_default() .insert(key, Binding::Action(action)); } for mode in [Mode::Insert, Mode::Command, Mode::Search] { for (key, action) in [ (Input::Escape, Normal), (Input::Backspace, Backspace), (Input::Ctrl('u'), BackspaceLine), ( Input::Enter, if mode == Mode::Insert { Newline } else { Submit }, ), ( Input::Ctrl('j'), if mode == Mode::Insert { Newline } else { Down }, ), ] { bindings .entry(mode) .or_default() .insert(key, Binding::Action(action)); } #[cfg(target_os = "macos")] for (key, action) in [ (Input::AltBackspace, BackspaceWord), (Input::SuperBackspace, BackspaceLine), ] { bindings .entry(mode) .or_default() .insert(key, Binding::Action(action)); } } for (key, action) in [ (Input::Char('\t'), CommandComplete), (Input::BackTab, CommandCompletePrevious), ] { bindings .entry(Mode::Command) .or_default() .insert(key, Binding::Action(action)); } for mode in [Mode::Command, Mode::Search] { for (key, action) in [(Input::Up, Up), (Input::Ctrl('k'), Up), (Input::Down, Down)] { bindings .entry(mode) .or_default() .insert(key, Binding::Action(action)); } } for mode in [ Mode::Normal, Mode::Insert, Mode::Command, Mode::Search, Mode::Visual, ] { bindings .entry(mode) .or_default() .insert(Input::Ctrl('p'), Binding::Action(Palette)); } bindings } /// Borrowed buffer-list metadata; paths retain their supplied display/save spelling. pub struct BufferInfo<'a> { pub id: usize, pub path: Option<&'a Path>, pub dirty: bool, pub active: bool, } /// Borrowed document state, including hidden and unnamed buffers. pub struct DocumentInfo<'a> { pub id: usize, pub revision: u64, pub path: Option<&'a Path>, pub lines: &'a [String], pub save_revision: u64, pub dirty: bool, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum ResolverStatus { Unresolved, Unsaved, Checking, Resolved, Unconfirmed, } pub struct ResolverSource { pub label: String, pub text: String, pub base: bool, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum ResolverView { Resolution, History, } #[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize)] #[serde(rename_all = "snake_case")] pub enum ResolverLayout { Columns, BaseBelow, } pub struct ResolverFile { pub path: PathBuf, pub draft_buffer: usize, pub status: ResolverStatus, /// Retained through draft edits and failed/pending jj confirmation. jj_conflicted: bool, pub sources: Vec, pub text: bool, pub whole_file_choice: Option, pub error: Option, pub seed_source: usize, projection: Option, diff: DiffState, comparison_key: Option<(u64, usize)>, } impl ResolverFile { fn apply_jj_status(&mut self, status: crate::resolve::Status) { self.status = match status { crate::resolve::Status::Unresolved => { self.jj_conflicted = true; ResolverStatus::Unresolved } crate::resolve::Status::Resolved => { self.jj_conflicted = false; ResolverStatus::Resolved } crate::resolve::Status::Unconfirmed => ResolverStatus::Unconfirmed, }; } } pub struct ResolverWorkspace { pub files: Vec, pub current_file: usize, pub selected_source: usize, pub draft_pane: usize, pub alternative_pane: Option, pub history_panes: Vec, pub view: ResolverView, pub history_sources: [usize; 2], pub selected_base: Option, history_bounds: Vec<(usize, PaneRect)>, files_bounds: Option, pub loading: bool, pub error: Option, model: Option, preferred_path: Option, pending: bool, } pub(crate) enum ResolverRequest { Load { tab: usize, root: PathBuf, }, Save { tab: usize, file: usize, buffer: usize, workspace: crate::resolve::Workspace, resolution: crate::resolve::Resolution, quit: bool, }, } pub(crate) enum ResolverOutcome { Loaded { tab: usize, result: Result, }, Saved { tab: usize, file: usize, buffer: usize, quit: bool, result: Result, }, } struct AnnotationSet { revision: u64, items: Vec, } /// Pane-local interaction state. The focused view occupies the buffer's view slot; /// all other views live in their owning pane, never alongside copied source text. struct ViewState { targets: Vec, primary: usize, mode: Mode, word_wrap: bool, linewise: bool, search_query: String, search_whole_word: bool, search_highlight: bool, search_match: Option, command_line: String, command_completion_prefix: Option, prompt_history_index: Option, prompt_draft: String, command_from_visual: bool, caret_reveal_revision: u64, lsp_center: Cell>, viewport_scroll: Cell<(isize, isize)>, insert_start: Option, pending_edit: Option, visual_motions: Option>, } impl ViewState { fn empty() -> Self { Self { targets: Vec::new(), primary: 0, mode: Mode::Normal, word_wrap: false, linewise: false, search_query: String::new(), search_whole_word: false, search_highlight: false, search_match: None, command_line: String::new(), command_completion_prefix: None, prompt_history_index: None, prompt_draft: String::new(), command_from_visual: false, caret_reveal_revision: 0, lsp_center: Cell::new(None), viewport_scroll: Cell::new((0, 0)), insert_start: None, pending_edit: None, visual_motions: None, } } fn inherit(&self) -> Self { Self { targets: self.targets.clone(), primary: self.primary, mode: self.mode, word_wrap: self.word_wrap, linewise: self.linewise, search_query: self.search_query.clone(), search_whole_word: self.search_whole_word, search_highlight: self.search_highlight, search_match: self.search_match, command_line: self.command_line.clone(), command_completion_prefix: self.command_completion_prefix.clone(), prompt_history_index: self.prompt_history_index, prompt_draft: self.prompt_draft.clone(), command_from_visual: self.command_from_visual, caret_reveal_revision: self.caret_reveal_revision, visual_motions: self.visual_motions.clone(), ..Self::empty() } } fn clamp(&mut self, lines: &[String]) { for target in &mut self.targets { let clamp = |cursor: &mut Cursor| { cursor.row = cursor.row.min(lines.len() - 1); let count = grapheme_count(&lines[cursor.row]); let maximum = if self.mode == Mode::Insert || (self.mode == Mode::Visual && cursor.row + 1 < lines.len()) { count } else { count.saturating_sub(1) }; cursor.column = cursor.column.min(maximum); }; clamp(&mut target.active); clamp(&mut target.anchor); if self.mode != Mode::Visual { target.anchor = target.active; } target.preferred_columns = None; } self.search_match = None; self.visual_motions = None; } fn reload(&mut self, lines: &[String]) { self.clamp(lines); self.insert_start = (self.mode == Mode::Insert).then(|| Snapshot { lines: lines.to_vec(), targets: self.targets.clone(), primary: self.primary, }); self.pending_edit = (self.mode == Mode::Insert).then(|| EditRecipe { start: EditStart::Insert(Action::Insert), steps: Vec::new(), }); } } struct Pane { buffer_id: usize, origin: Option, views: BTreeMap, } struct Tab { pane_id: usize, pane_layout: PaneTree, review_pane: Option, local_review: crate::review::ReviewState, file_picker: Option, diff: Option, parked_diff: Option, diff_owner: Option, diff_prompt_mode: Option, diff_pane_bounds: [(Focus, PaneRect); 3], diff_bound: Option<(u64, usize, Focus, usize)>, diff_return_buffer: Option, outstanding_diff_request: Option, } impl Tab { fn diff(&self) -> Option<&DiffState> { self.diff.as_ref().or(self.parked_diff.as_ref()) } } #[derive(Clone, Copy, Eq, PartialEq)] struct FileStamp { modified: std::time::SystemTime, len: u64, #[cfg(unix)] identity: (u64, u64, i64, i64), } impl FileStamp { fn from_metadata(metadata: &fs::Metadata) -> io::Result { if !metadata.is_file() { return Err(io::Error::other("File is not a regular file")); } Ok(Self { modified: metadata.modified()?, len: metadata.len(), #[cfg(unix)] identity: ( metadata.dev(), metadata.ino(), metadata.ctime(), metadata.ctime_nsec(), ), }) } fn read(path: &Path) -> io::Result> { match fs::metadata(path) { Ok(metadata) => Self::from_metadata(&metadata).map(Some), Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None), Err(error) => Err(error), } } } struct GutterChanges { revision: u64, path: PathBuf, lines: Vec>, } struct Buffer { id: usize, diff_snapshot: Option<(u64, usize, Focus)>, review_buffer: Option, resolver_readonly: bool, snapshot_document_path: Option, snapshot_root: Option, snapshot_blame: Option>, lines: Vec, saved_lines: Vec, // Invalidate whenever text or the saved baseline changes, including temporary swaps. dirty_cache: Cell>, disk_stamp: Option, reload_error: Option, text_revision: u64, save_revision: u64, diagnostics: BTreeMap, semantic_tokens: Option>, gutter_changes: Option, view: ViewState, path: Option, identity: Option, undo: Vec, redo: Vec, } impl Buffer { fn open( id: usize, path: Option, identity: Option, allow_missing: bool, ) -> io::Result { let mut disk_stamp = None; let text = match &path { Some(path) => match fs::File::open(path) { Ok(mut file) => { disk_stamp = Some(FileStamp::from_metadata(&file.metadata()?)?); let mut text = String::new(); file.read_to_string(&mut text)?; text } Err(error) if allow_missing && error.kind() == io::ErrorKind::NotFound => { String::new() } Err(error) => return Err(error), }, None => String::new(), }; Self::from_text(id, path, identity, &text, disk_stamp) } fn from_text( id: usize, path: Option, identity: Option, text: &str, disk_stamp: Option, ) -> io::Result { validate_text(text).map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?; let lines = split_lines(text); Ok(Self { id, diff_snapshot: None, review_buffer: None, resolver_readonly: false, snapshot_document_path: None, snapshot_root: None, snapshot_blame: None, saved_lines: lines.clone(), dirty_cache: Cell::new(Some(false)), disk_stamp, reload_error: None, lines, text_revision: 0, save_revision: 0, diagnostics: BTreeMap::new(), semantic_tokens: None, gutter_changes: None, view: ViewState { targets: vec![Target::caret(Cursor::default())], ..ViewState::empty() }, path, identity, undo: Vec::new(), redo: Vec::new(), }) } fn dirty(&self) -> bool { if self.resolver_readonly || self.diff_snapshot.is_some() { return false; } if let Some(dirty) = self.dirty_cache.get() { return dirty; } let dirty = self.lines != self.saved_lines; self.dirty_cache.set(Some(dirty)); dirty } fn refresh_file(&mut self) -> io::Result { if self.diff_snapshot.is_some() { return Ok(false); } let Some(path) = &self.path else { return Ok(false); }; let stamp = FileStamp::read(path)?; if self.dirty() { if stamp.is_none() && self.disk_stamp.is_some() { return Err(io::Error::new(io::ErrorKind::NotFound, "File was removed")); } return Ok(false); } if stamp == self.disk_stamp { return Ok(false); } let Some(stamp) = stamp else { return Err(io::Error::new(io::ErrorKind::NotFound, "File was removed")); }; let mut file = fs::File::open(path)?; if FileStamp::from_metadata(&file.metadata()?)? != stamp { return Err(io::Error::new( io::ErrorKind::WouldBlock, "File changed while reloading", )); } let mut text = String::new(); file.read_to_string(&mut text)?; if FileStamp::from_metadata(&file.metadata()?)? != stamp { return Err(io::Error::new( io::ErrorKind::WouldBlock, "File changed while reloading", )); } validate_text(&text).map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?; self.disk_stamp = Some(stamp); if normalize_newlines(&text) .split('\n') .eq(self.lines.iter().map(String::as_str)) { return Ok(false); } self.replace_lines(split_lines(&text)); self.saved_lines.clone_from(&self.lines); self.dirty_cache.set(Some(false)); self.undo.clear(); self.redo.clear(); self.view.reload(&self.lines); Ok(true) } fn info(&self, active: bool) -> BufferInfo<'_> { BufferInfo { id: self.id, path: self.path.as_deref(), dirty: self.dirty(), active, } } fn document(&self) -> DocumentInfo<'_> { DocumentInfo { id: self.id, revision: self.text_revision, path: self .snapshot_document_path .as_deref() .or(self.path.as_deref()), lines: &self.lines, save_revision: self.save_revision, dirty: self.dirty(), } } /// Keep unaffected semantic colors until the server supplies this revision's tokens. fn replace_lines(&mut self, lines: Vec) -> Vec { if self.lines != lines { if let Some(tokens) = &mut self.semantic_tokens { retain_semantic_tokens(tokens, &self.lines, &lines); } self.text_changed(); } std::mem::replace(&mut self.lines, lines) } fn text_changed(&mut self) { self.text_revision = self.text_revision.wrapping_add(1); self.dirty_cache.set(None); self.diagnostics.clear(); self.view.search_match = None; } } #[derive(Clone, Copy, Debug, Eq, PartialEq)] struct DiffJumpLocation { comparison_id: u64, file: usize, focus: Focus, text_focus: Focus, live_buffer: Option, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] struct JumpLocation { tab_id: usize, pane_id: usize, buffer_id: usize, cursor: Cursor, diff: Option, } struct PendingLspNavigation { tab: usize, pane: usize, source: JumpLocation, revision: u64, bound: (u64, usize, Focus, usize), file: usize, focus: Focus, range: Utf16Range, target_range: Option, request: Option, } #[derive(Clone)] pub(crate) struct BlameSource { buffer: usize, revision: u64, pub(crate) path: Arc, pub(crate) snapshot: Option>, pub(crate) row: usize, save_revision: u64, disk_stamp: Option, pane: usize, tab: usize, } pub struct Editor { active: Buffer, parked: BTreeMap, next_buffer_id: usize, pane_id: usize, next_pane_id: usize, panes: BTreeMap, pane_layout: PaneTree, review_pane: Option, pub(crate) local_review: crate::review::ReviewState, tab_id: usize, next_tab_id: usize, tab_order: Vec, tabs: BTreeMap, resolvers: BTreeMap, resolver_requests: VecDeque, pub(crate) resolve_history_layout: ResolverLayout, pane_bounds: PaneRect, render_pane_focused: bool, diff_owner: Option, parked_diff: Option, review_directory: Option, diff_pane_bounds: [(Focus, PaneRect); 3], // Closed destinations stay until bounded eviction so they cannot shift jump_index. jumps: VecDeque, jump_index: usize, command_history: VecDeque, search_history: VecDeque, clipboard: Vec, clipboard_linewise: bool, clipboard_mode: ClipboardMode, system_clipboard: SystemClipboard, // Exact exported bytes bridge our fragments; external newlines never imply boundaries. owned_system_clipboard: Option<(String, Vec, bool)>, last_edit: Option, message: String, notification_visible: bool, top_bar: ChromeLine, bottom_bar: ChromeLine, popup: PopupConfig, labels: BTreeMap, scroll_distance: Option, scroll_step_lines: Option, highlight_trailing_whitespace: bool, whitespace_characters: BTreeMap, whitespace_scope: WhitespaceScope, tab_spaces: Option, language_tab_spaces: BTreeMap>, battery_charge: Option, battery_label: String, shell_label: String, command_output: Option, queued_command_outputs: VecDeque, git_blame: bool, gutter_style: crate::gutter::GutterStyle, line_blame: Option<(BlameSource, Arc, String)>, lsp_names: Vec, lsp_label: String, quit: bool, restart: bool, capabilities: Vec, features: HashMap, bindings: HashMap>, selection_bindings: HashMap, selection_navigation_hint: String, tree_bindings: HashMap, tree_navigation_hint: String, tree_filter_navigation_hint: String, leader: Input, sequence_bindings: HashMap>, pending_sequence: BindingSequence, sequence_display: String, g_pending: bool, line_pending: Option<(char, usize)>, seek_pending: Option<(bool, usize)>, palette: Option, pane_close_picker_origin: Option<(usize, usize, Option)>, hover: Option, completion: Option, snippet: Option, startup_diagnostic: Option, project_root: PathBuf, file_picker: Option, picker_repository_changes: Vec, file_explorer_side: DockSide, file_picker_view: FileView, file_explorer_view: FileView, count: Option, diff: Option, diff_prompt_mode: Option, diff_bound: Option<(u64, usize, Focus, usize)>, diff_return_buffer: Option, diff_context_options: (usize, usize), diff_display_options: (ViewMode, SplitAxis), pub(crate) pull_request_review_requested_symbol: String, pub(crate) pull_request_approved_symbol: String, pub(crate) pull_request_preferences: crate::pull_requests::Preferences, pub(crate) pull_request_preferences_changed: bool, pub(crate) pull_request_configured: [bool; 2], next_diff_request: u64, outstanding_diff_request: Option, pending_lsp_navigation: Option, } impl Editor { pub fn open(path: Option, capabilities: Vec) -> io::Result { let identity = path.as_deref().map(path_identity).transpose()?; let mut editor = Self { active: Buffer::open(1, path, identity, true)?, parked: BTreeMap::new(), next_buffer_id: 2, pane_id: 1, next_pane_id: 2, panes: BTreeMap::from([( 1, Pane { buffer_id: 1, origin: None, views: BTreeMap::from([(1, ViewState::empty())]), }, )]), pane_layout: PaneTree::new(1), review_pane: None, local_review: Default::default(), tab_id: 1, next_tab_id: 2, tab_order: vec![1], tabs: BTreeMap::new(), resolvers: BTreeMap::new(), resolver_requests: VecDeque::new(), resolve_history_layout: ResolverLayout::Columns, pane_bounds: PaneRect { x: 0, y: 0, width: 120, height: 40, }, render_pane_focused: true, diff_owner: None, parked_diff: None, review_directory: None, diff_pane_bounds: [( Focus::Files, PaneRect { x: 0, y: 0, width: 0, height: 0, }, ); 3], jumps: VecDeque::new(), jump_index: 0, command_history: VecDeque::new(), search_history: VecDeque::new(), clipboard: Vec::new(), clipboard_linewise: false, clipboard_mode: ClipboardMode::System, system_clipboard: SystemClipboard::new(), owned_system_clipboard: None, last_edit: None, message: String::new(), notification_visible: true, top_bar: ChromeLine::top(), bottom_bar: ChromeLine::bottom(), popup: PopupConfig::default(), labels: BTreeMap::new(), scroll_distance: Some(0), scroll_step_lines: None, highlight_trailing_whitespace: false, whitespace_characters: BTreeMap::new(), whitespace_scope: WhitespaceScope::All, tab_spaces: None, language_tab_spaces: BTreeMap::new(), battery_charge: None, battery_label: String::new(), shell_label: String::new(), command_output: None, queued_command_outputs: VecDeque::new(), git_blame: true, gutter_style: crate::gutter::GutterStyle::default(), line_blame: None, lsp_names: Vec::new(), lsp_label: String::new(), quit: false, restart: false, capabilities, features: HashMap::new(), bindings: default_bindings(), selection_bindings: [ (Input::Up, Action::Up), (Input::Ctrl('k'), Action::Up), (Input::Down, Action::Down), (Input::Ctrl('j'), Action::Down), (Input::Ctrl('n'), Action::Down), ] .into_iter() .map(|(key, action)| (key, Binding::Action(action))) .collect(), selection_navigation_hint: String::new(), tree_bindings: [ (Input::Left, Action::Left), (Input::Ctrl('h'), Action::Left), (Input::Right, Action::Right), (Input::Ctrl('l'), Action::Right), (Input::Char('h'), Action::Left), (Input::Char('j'), Action::Down), (Input::Char('k'), Action::Up), (Input::Char('l'), Action::Right), ] .into_iter() .map(|(key, action)| (key, Binding::Action(action))) .collect(), tree_navigation_hint: String::new(), tree_filter_navigation_hint: String::new(), leader: Input::Char(' '), sequence_bindings: HashMap::new(), pending_sequence: BindingSequence::default(), sequence_display: String::new(), g_pending: false, line_pending: None, seek_pending: None, palette: None, pane_close_picker_origin: None, hover: None, completion: None, snippet: None, startup_diagnostic: None, project_root: std::env::current_dir()?, file_picker: None, picker_repository_changes: Vec::new(), file_explorer_side: DockSide::Right, file_picker_view: FileView::Tree, file_explorer_view: FileView::Tree, count: None, diff: None, diff_prompt_mode: None, diff_bound: None, diff_return_buffer: None, diff_context_options: (10, 15), diff_display_options: (ViewMode::Split, SplitAxis::Vertical), pull_request_review_requested_symbol: "šŸ™".into(), pull_request_approved_symbol: "šŸ‘".into(), pull_request_preferences: Default::default(), pull_request_preferences_changed: false, pull_request_configured: [false; 2], next_diff_request: 0, outstanding_diff_request: None, pending_lsp_navigation: None, }; editor.refresh_selection_navigation_hint(); editor .install( Feature { name: "core".into(), required_capabilities: Vec::new(), commands: CORE_ACTIONS .iter() .filter_map(|entry| entry.ex) .chain(COMMAND_ALIASES.iter().map(|(alias, _)| *alias)) .map(str::to_owned) .collect(), core: true, }, true, ) .map_err(io::Error::other)?; for (mode, key, target) in [ ("normal", "leader space", "core.palette"), ("visual", "leader space", "core.palette"), ("normal", "leader o", "core.file_picker"), ("visual", "leader o", "core.file_picker"), ("normal", "leader e", "core.file_explorer"), ("visual", "leader e", "core.file_explorer"), ("normal", "leader w", "core.save"), ("normal", "leader b n", "core.buffer_next"), ("normal", "leader b p", "core.buffer_previous"), ("normal", "leader b l", "core.buffers"), ("normal", "leader b d", "core.buffer_delete"), ("normal", "leader v", "core.diff.view.toggle"), ("normal", "leader s", "core.diff.orientation.toggle"), ] { editor .bind(mode, key, Some(target.into())) .map_err(io::Error::other)?; } Ok(editor) } pub fn resolver(&self) -> Option<&ResolverWorkspace> { self.resolvers.get(&self.tab_id) } pub fn focus_resolver_pane(&mut self, pane: usize) { if !self.resolver_visible_panes().contains(&pane) { return; } self.finish_pane_interaction(); let focus = if pane == self.resolvers[&self.tab_id].draft_pane { Focus::Before } else { Focus::After }; if let Some(diff) = self.resolver_diff_mut() { diff.focus = focus; } self.blur_file_picker(); self.select_pane(pane); self.refresh_resolver_comparison(); } pub fn focus_resolver_files(&mut self) { self.finish_pane_interaction(); self.open_file_picker(FilePresentation::Docked); } fn open_resolver(&mut self) { if let Some(tab) = self.resolvers.keys().next().copied() { self.finish_pane_interaction(); self.select_tab(tab); return; } let root = self .active .snapshot_root .clone() .or_else(|| { self.active .path .as_deref() .and_then(Path::parent) .map(Path::to_path_buf) }) .unwrap_or_else(|| self.project_root.clone()); let preferred_path = self .active .path .as_deref() .and_then(|path| path_identity(path).ok()); self.new_tab(); let draft_pane = self.pane_id; self.resolvers.insert( self.tab_id, ResolverWorkspace { files: Vec::new(), current_file: 0, selected_source: 0, draft_pane, alternative_pane: None, history_panes: Vec::new(), view: ResolverView::Resolution, history_sources: [0, 0], selected_base: None, history_bounds: Vec::new(), files_bounds: None, loading: true, error: None, model: None, preferred_path, pending: false, }, ); self.resolver_requests.push_back(ResolverRequest::Load { tab: self.tab_id, root, }); } pub(crate) fn take_resolver_request(&mut self) -> Option { self.refresh_resolver_status(); self.resolver_requests.pop_front() } pub(crate) fn refresh_resolver_status(&mut self) { let active = &self.active; let parked = &self.parked; for resolver in self.resolvers.values_mut() { for (index, file) in resolver.files.iter_mut().enumerate() { let buffer = if active.id == file.draft_buffer { Some(active) } else { parked.get(&file.draft_buffer) }; if file.status != ResolverStatus::Checking && (buffer.is_some_and(Buffer::dirty) || file.whole_file_choice.is_some()) { file.status = ResolverStatus::Unsaved; } else if file.status == ResolverStatus::Unsaved { file.status = match resolver .model .as_ref() .map(|model| model.files[index].status) { Some(crate::resolve::Status::Resolved) => ResolverStatus::Resolved, Some(crate::resolve::Status::Unconfirmed) => ResolverStatus::Unconfirmed, _ => ResolverStatus::Unresolved, }; } } if let Some(mut picker) = resolver .files .get_mut(resolver.current_file) .and_then(|file| file.diff.files.take()) { for file in &resolver.files { picker.set_conflicted(&file.path, file.jj_conflicted); } resolver.files[resolver.current_file].diff.files = Some(picker); } } } pub(crate) fn apply_resolver_outcome(&mut self, outcome: ResolverOutcome) -> bool { match outcome { ResolverOutcome::Loaded { tab, result } => { if !self.resolvers.contains_key(&tab) { return false; } match result { Err(error) => { let resolver = self.resolvers.get_mut(&tab).unwrap(); resolver.loading = false; resolver.error = Some(error.clone()); self.set_message(error); } Ok(model) => { let preferred = self.resolvers[&tab].preferred_path.as_ref(); let selected = model .files .iter() .position(|file| { preferred.is_some_and(|path| *path == model.root.join(&file.path)) }) .unwrap_or(0); let mut files = Vec::with_capacity(model.files.len()); for file in &model.files { let path = model.root.join(&file.path); let identity = path_identity(&path).unwrap_or_else(|_| path.clone()); let existing = std::iter::once(&self.active) .chain(self.parked.values()) .find(|buffer| { !buffer.resolver_readonly && buffer.identity.as_ref() == Some(&identity) }) .map(|buffer| buffer.id); let text = file .result .as_ref() .is_some_and(|text| validate_text(text).is_ok()) && file.sources.iter().all(|source| { (source.kind == crate::resolve::FileKind::Deleted && source.base) || (source.kind == crate::resolve::FileKind::Text && source .text() .is_some_and(|text| validate_text(text).is_ok())) }); let draft_buffer = if let Some(id) = existing { id } else { let contents = if text { file.result.as_deref().unwrap() } else { "Non-text/type conflict: choose an explicit source, then :w.\n" }; let id = self.next_buffer_id; match Buffer::from_text( id, Some(path.clone()), Some(identity), contents, if text { FileStamp::read(&path).ok().flatten() } else { None }, ) { Ok(mut buffer) => { buffer.resolver_readonly = !text; self.next_buffer_id += 1; self.parked.insert(id, buffer); id } Err(error) => { self.resolvers.get_mut(&tab).unwrap().error = Some(error.to_string()); self.resolvers.get_mut(&tab).unwrap().loading = false; return true; } } }; let seed_source = file .sources .iter() .position(|source| !source.base) .unwrap_or(0); let projection = if text { let raw = self.buffer(draft_buffer).unwrap().lines.join("\n"); match crate::resolve::Projection::new( &raw, &file.sources, seed_source, ) { Ok(projection) => Some(projection), Err(error) => { let resolver = self.resolvers.get_mut(&tab).unwrap(); resolver.error = Some(format!("{}: {error}", file.path.display())); resolver.loading = false; return true; } } } else { None }; files.push(ResolverFile { path: file.path.clone(), draft_buffer, status: ResolverStatus::Unresolved, text, whole_file_choice: None, jj_conflicted: file.status != crate::resolve::Status::Resolved, error: file.notice.clone(), seed_source, projection, diff: DiffState::new(), comparison_key: None, sources: file.sources.iter().map(|source| ResolverSource { label: source.label.clone(), base: source.base, text: source.text().filter(|text| validate_text(text).is_ok()).map(str::to_owned).unwrap_or_else(|| format!("{:?} input ({} bytes)\nChoose this source explicitly to resolve the whole file.", source.kind, source.bytes.len())), }).collect(), }); } for file in &files { let Some(projection) = &file.projection else { continue; }; let draft_buffer = file.draft_buffer; let buffer = if self.active.id == draft_buffer { &mut self.active } else { self.parked.get_mut(&draft_buffer).unwrap() }; let seed = split_lines(projection.seed()); if buffer.lines != seed { let previous = buffer.replace_lines(seed); buffer.undo.push(HistoryEntry::new( Snapshot { lines: previous, targets: if buffer.view.targets.is_empty() { vec![Target::caret(Cursor::default())] } else { buffer.view.targets.clone() }, primary: buffer.view.primary, }, &buffer.lines, )); buffer.redo.clear(); buffer.view.clamp(&buffer.lines); for pane in self.panes.values_mut() { if let Some(view) = pane.views.get_mut(&draft_buffer) { view.clamp(&buffer.lines); } } } } let resolver = self.resolvers.get_mut(&tab).unwrap(); resolver.files = files; resolver.loading = false; resolver.model = Some(model); let previous_tab = self.tab_id; self.select_tab(tab); self.select_resolver_file(selected); self.select_tab(previous_tab); self.refresh_resolver_status(); } } } ResolverOutcome::Saved { tab, file, buffer, quit, result, } => { let Some(resolver) = self.resolvers.get_mut(&tab) else { return false; }; resolver.pending = false; match result { Err(error) => { resolver.files[file].status = ResolverStatus::Unsaved; resolver.files[file].error = Some(error.clone()); self.set_message(format!("Resolution not saved: {error}")); } Ok(outcome) => { let model = resolver.model.as_mut().unwrap(); model.checkout = outcome.checkout; model.files[file] = outcome.file; let target_path = model.root.join(&model.files[file].path); let saved_text = model.files[file] .result .as_deref() .filter(|text| validate_text(text).is_ok()) .map(split_lines); let was_text = resolver.files[file].text; resolver.files[file].text = saved_text.is_some(); resolver.files[file].apply_jj_status(model.files[file].status); let status = resolver.files[file].status; resolver.files[file].error = outcome.confirmation.clone(); resolver.files[file].whole_file_choice = None; if let Some(buffer) = if self.active.id == buffer { Some(&mut self.active) } else { self.parked.get_mut(&buffer) } { if let Some(saved) = saved_text { if !was_text && !buffer.dirty() { buffer.replace_lines(saved.clone()); buffer.view.clamp(&buffer.lines); } buffer.saved_lines = saved; buffer.dirty_cache.set(None); buffer.identity = Some(target_path.clone()); buffer.path = Some(target_path); buffer.resolver_readonly = false; } else if !buffer.dirty() { buffer.replace_lines(vec!["Non-text resolution saved; reopen the file to inspect its current type.".into()]); buffer.saved_lines.clone_from(&buffer.lines); buffer.dirty_cache.set(Some(false)); buffer.path = None; buffer.identity = None; buffer.resolver_readonly = true; buffer.view.clamp(&buffer.lines); } buffer.disk_stamp = buffer .path .as_deref() .and_then(|path| FileStamp::read(path).ok().flatten()); buffer.save_revision = buffer.save_revision.wrapping_add(1); buffer.reload_error = None; } self.set_message(outcome.confirmation.unwrap_or_else(|| match status { ResolverStatus::Resolved => "Written; jj confirms resolved".into(), ResolverStatus::Unresolved => { "Written; conflict remains unresolved".into() } _ => "Written; resolution unconfirmed".into(), })); self.refresh_resolver_status(); if quit && !self.any_dirty() && status != ResolverStatus::Unconfirmed { self.quit = true; } } } } } true } pub fn select_resolver_file(&mut self, file: usize) { let Some(resolver) = self.resolver() else { return; }; let Some(selected) = resolver.files.get(file) else { return; }; let first_open = selected.diff.comparison.is_none(); let draft = selected.draft_buffer; let sources: Vec<_> = selected .sources .iter() .map(|source| source.text.clone()) .collect(); let seed = selected.seed_source; let side_indices: Vec<_> = selected .sources .iter() .enumerate() .filter(|(_, source)| !source.base) .map(|(index, _)| index) .collect(); let base = selected.sources.iter().position(|source| source.base); let draft_pane = resolver.draft_pane; let previous_file = resolver.current_file; let old: Vec<_> = resolver .history_panes .iter() .copied() .chain(resolver.alternative_pane) .collect(); let resolver = self.resolvers.get_mut(&self.tab_id).unwrap(); resolver.alternative_pane = None; resolver.history_panes.clear(); self.finish_pane_interaction(); self.select_pane(draft_pane); self.switch_buffer(draft); for pane in old { let buffer = self.panes[&pane].buffer_id; self.pane_layout.close(pane); self.panes.remove(&pane); self.forget_buffer(buffer); } let mut history_panes = Vec::new(); for text in &sources { history_panes.push(self.resolver_readonly_pane(text, draft_pane)); } let alternative_pane = self.resolver_readonly_pane("", draft_pane); let resolver = self.resolvers.get_mut(&self.tab_id).unwrap(); resolver.current_file = file; resolver.selected_source = side_indices .iter() .copied() .find(|index| *index != seed) .unwrap_or(seed); resolver.history_sources = [ side_indices.first().copied().unwrap_or(0), side_indices.get(1).copied().unwrap_or(seed), ]; resolver.selected_base = base; resolver.history_panes = history_panes; resolver.alternative_pane = Some(alternative_pane); let picker = resolver.files[previous_file].diff.files.take(); let visible = resolver.files[previous_file].diff.files_visible; let previous_layout = { let diff = &resolver.files[previous_file].diff; (diff.phase == Phase::View).then_some((diff.view_mode, diff.split_axis, diff.word_wrap)) }; let reviewed = std::mem::take(&mut resolver.files[previous_file].diff.reviewed_files); let had_picker = picker.is_some(); if let Some(picker) = picker { resolver.files[file].diff.files = Some(picker); resolver.files[file].diff.files_visible = visible; } resolver.files[file].diff.reviewed_files = reviewed; if let Some((view, axis, wrap)) = previous_layout { resolver.files[file].diff.set_view_mode(view); resolver.files[file].diff.split_axis = axis; resolver.files[file].diff.word_wrap = wrap; } let root = resolver .model .as_ref() .map_or_else(|| self.project_root.clone(), |model| model.root.clone()); let paths = resolver .files .iter() .map(|file| file.path.clone()) .collect(); resolver.files[file] .diff .set_resolver_paths(root, paths, file); if first_open && !had_picker { resolver.files[file] .diff .set_view_mode(self.diff_display_options.0); resolver.files[file].diff.split_axis = self.diff_display_options.1; resolver.files[file].diff.files_visible = false; } resolver.files[file].diff.writable_side = resolver.files[file].text.then_some(Focus::Before); resolver.files[file].comparison_key = None; self.blur_file_picker(); self.refresh_resolver_comparison(); self.refresh_resolver_status(); if self.resolver().unwrap().view == ResolverView::History { self.set_resolver_view(ResolverView::History); } } fn resolver_readonly_pane(&mut self, text: &str, draft_pane: usize) -> usize { let id = self.next_buffer_id; let mut buffer = Buffer::from_text(id, None, None, text, None).expect("resolver text was validated"); buffer.resolver_readonly = true; self.next_buffer_id += 1; self.parked.insert(id, buffer); self.split_pane(SplitAxis::Vertical); self.switch_buffer(id); let pane = self.pane_id; self.select_pane(draft_pane); pane } pub fn resolver_visible_panes(&self) -> Vec { let Some(resolver) = self.resolver() else { return Vec::new(); }; match resolver.view { ResolverView::Resolution => std::iter::once(resolver.draft_pane) .chain(resolver.alternative_pane) .collect(), ResolverView::History => { let mut panes = Vec::new(); let sources = if self.resolve_history_layout == ResolverLayout::BaseBelow { [ Some(resolver.history_sources[0]), Some(resolver.history_sources[1]), resolver.selected_base, ] } else { [ Some(resolver.history_sources[0]), resolver.selected_base, Some(resolver.history_sources[1]), ] }; for index in sources.into_iter().flatten() { if let Some(&pane) = resolver.history_panes.get(index) && !panes.contains(&pane) { panes.push(pane); } } panes } } } fn set_resolver_view(&mut self, view: ResolverView) { let Some(resolver) = self.resolvers.get_mut(&self.tab_id) else { return; }; if view == ResolverView::History && resolver.history_panes.is_empty() { return; } resolver.view = view; let pane = match view { ResolverView::Resolution => Some(resolver.draft_pane), ResolverView::History => resolver .history_panes .get(resolver.history_sources[0]) .copied(), }; self.blur_file_picker(); if let Some(pane) = pane { self.focus_resolver_pane(pane); } } pub fn resolver_diff(&self) -> Option<&DiffState> { let resolver = self.resolver()?; let alternative = resolver.alternative_pane?; if resolver.view != ResolverView::Resolution || !self.panes.contains_key(&alternative) { return None; } self.resolver_workspace_diff().filter(|diff| { diff.phase == Phase::View && diff.file == resolver.current_file && diff.selected_file_loaded() }) } fn resolver_workspace_diff(&self) -> Option<&DiffState> { let resolver = self.resolver()?; resolver .files .get(resolver.current_file) .map(|file| &file.diff) } pub fn resolver_buffer_focus(&self) -> Option { let resolver = self.resolver()?; self.resolver_diff()?; if self.file_picker().is_some_and(|picker| picker.focused) { return None; } if self.pane_id == resolver.draft_pane { (resolver.files[resolver.current_file].draft_buffer == self.active.id) .then_some(Focus::Before) } else if Some(self.pane_id) == resolver.alternative_pane { (self.pane_buffer_id(self.pane_id) == Some(self.active.id)).then_some(Focus::After) } else { None } } fn resolver_diff_mut(&mut self) -> Option<&mut DiffState> { let resolver = self.resolvers.get_mut(&self.tab_id)?; resolver .files .get_mut(resolver.current_file) .map(|file| &mut file.diff) } pub(crate) fn refresh_resolver_comparison(&mut self) { let Some(resolver) = self.resolver() else { return; }; let file_index = resolver.current_file; let Some(file) = resolver.files.get(file_index) else { return; }; let Some(alternative_pane) = resolver.alternative_pane else { return; }; let draft_buffer = file.draft_buffer; let writable = file.text && file.whole_file_choice.is_none(); let alternative_buffer = self.panes[&alternative_pane].buffer_id; let source = resolver.selected_source; let Some(buffer) = self.buffer(draft_buffer) else { return; }; let key = (buffer.text_revision, source); let changed = file.comparison_key != Some(key); let first_comparison = file.diff.comparison_id.is_none(); if changed { if file.comparison_key.is_none_or(|(_, index)| index != source) { let alternative = match &file.projection { Some(projection) => projection.alternative(source), None => Ok(file .sources .get(source) .map(|source| source.text.as_str()) .unwrap_or("")), }; let alternative = match alternative { Ok(text) => split_lines(text), Err(error) => { let resolver = self.resolvers.get_mut(&self.tab_id).unwrap(); resolver.files[file_index].error = Some(error); return; } }; let buffer = if self.active.id == alternative_buffer { &mut self.active } else { self.parked.get_mut(&alternative_buffer).unwrap() }; if buffer.lines != alternative { buffer.replace_lines(alternative.clone()); buffer.saved_lines = alternative; buffer.dirty_cache.set(Some(false)); buffer.view.clamp(&buffer.lines); for pane in self.panes.values_mut() { if let Some(view) = pane.views.get_mut(&alternative_buffer) { view.clamp(&buffer.lines); } } } } let draft = if self.active.id == draft_buffer { &self.active } else { &self.parked[&draft_buffer] }; let alternative = if self.active.id == alternative_buffer { &self.active } else { &self.parked[&alternative_buffer] }; let file = &mut self.resolvers.get_mut(&self.tab_id).unwrap().files[file_index]; file.diff .set_context_options(self.diff_context_options.0, self.diff_context_options.1); let previous_id = file.diff.comparison_id; file.diff .set_resolver_text(&draft.lines, &alternative.lines); if file.diff.comparison_id != previous_id { self.next_diff_request = self .next_diff_request .checked_add(1) .expect("diff comparison IDs exhausted"); file.diff.comparison_id = Some(self.next_diff_request); } file.comparison_key = Some(key); } if let Some(diff) = self.resolver_diff_mut() { diff.writable_side = writable.then_some(Focus::Before); } if first_comparison && self.resolver_buffer_focus() == Some(Focus::Before) && let Some(row) = self .resolver_diff() .and_then(|diff| diff.comparison.as_ref()?.files[diff.file].hunks.first()) .map(|hunk| hunk.before_start) { self.active.view.targets = vec![Target::caret(Cursor { row: row.min(self.active.lines.len() - 1), column: 0, })]; self.active.view.primary = 0; self.reveal_caret(); } let focus = self.resolver_buffer_focus(); let row = self.cursor().row; let follow = self.active.view.caret_reveal_revision; if let Some(diff) = self.resolver_diff_mut() && let Some(focus) = focus { let follow_caret = changed || (diff.focus == focus && diff.live_revision != Some(follow)); diff.focus = focus; if follow_caret { let top = diff.top; diff.reveal_source_row(focus, row); diff.top = top; } diff.follow_caret |= follow_caret; diff.live_revision = Some(follow); } } fn resolver_decision(&mut self, forward: bool) { let Some(resolver) = self.resolver() else { return; }; let pane = resolver.draft_pane; self.set_resolver_view(ResolverView::Resolution); self.focus_resolver_pane(pane); let row = self.cursor().row; let Some(diff) = self.resolver_diff() else { return; }; let hunks = &diff.comparison.as_ref().unwrap().files[diff.file].hunks; let hunk = if forward { hunks .iter() .find(|hunk| hunk.before_start > row) .or(hunks.first()) } else { hunks .iter() .rev() .find(|hunk| hunk.before_start < row) .or(hunks.last()) }; if let Some(hunk) = hunk { self.active.view.targets = vec![Target::caret(Cursor { row: hunk.before_start.min(self.active.lines.len() - 1), column: 0, })]; self.active.view.primary = 0; self.reveal_caret(); self.refresh_resolver_comparison(); } else { self.set_message("Draft and Alternative have no remaining differences"); } } fn resolver_command(&mut self, action: Action, args: &str) { self.prepare_command(); self.finish_command_mode(); if action == Action::Resolve { if args.is_empty() { self.open_resolver() } else { self.set_message("Usage: :resolve") } return; } let Some(resolver) = self.resolver() else { self.set_message("Open the current jj conflicts with :resolve"); return; }; if !matches!( action, Action::ResolveSource | Action::ResolveBase | Action::ResolveUseSource ) && !args.is_empty() { self.set_message("This resolution command takes no arguments"); return; } let source = if args.is_empty() { if action == Action::ResolveUseSource && resolver.view == ResolverView::History { resolver .history_panes .iter() .position(|pane| *pane == self.pane_id) } else { Some(resolver.selected_source) } } else { args.parse::() .ok() .and_then(|index| index.checked_sub(1)) }; match action { Action::ResolveNext | Action::ResolvePrevious => { self.resolver_decision(action == Action::ResolveNext) } Action::ResolveDraft => self.set_resolver_view(ResolverView::Resolution), Action::ResolveHistory => self.set_resolver_view(ResolverView::History), Action::ResolveViewToggle => { self.set_resolver_view(if resolver.view == ResolverView::Resolution { ResolverView::History } else { ResolverView::Resolution }) } Action::ResolveFiles => { if self.file_picker().is_some() { self.close_file_picker(); } else { self.focus_resolver_files(); } } Action::ResolveClose => self.close_tab(false), Action::ResolveNextFile | Action::ResolvePreviousFile => { let count = resolver.files.len(); if count > 0 { let next = (resolver.current_file + if action == Action::ResolveNextFile { 1 } else { count - 1 }) % count; self.select_resolver_file(next); } } Action::ResolveSource | Action::ResolveBase => { let Some(file) = resolver.files.get(resolver.current_file) else { return; }; let index = if action == Action::ResolveBase { let bases: Vec<_> = file .sources .iter() .enumerate() .filter(|(_, source)| source.base) .map(|(index, _)| index) .collect(); if args.is_empty() { let next = resolver .selected_base .and_then(|base| bases.iter().position(|index| *index == base)) .map_or(0, |index| (index + 1) % bases.len().max(1)); bases.get(next).copied() } else { source.and_then(|index| bases.get(index).copied()) } } else if args.is_empty() { let sides: Vec<_> = file .sources .iter() .enumerate() .filter(|(_, source)| !source.base) .map(|(index, _)| index) .collect(); let current = if resolver.view == ResolverView::History { resolver .history_panes .iter() .position(|pane| *pane == self.pane_id) .unwrap_or(resolver.selected_source) } else { resolver.selected_source }; let next = sides .iter() .position(|index| *index == current) .map_or(0, |index| (index + 1) % sides.len().max(1)); sides.get(next).copied() } else { source .filter(|index| file.sources.get(*index).is_some_and(|source| !source.base)) }; let Some(index) = index else { self.set_message("No such conflict source/base"); return; }; if action == Action::ResolveBase { self.resolvers.get_mut(&self.tab_id).unwrap().selected_base = Some(index); self.set_resolver_view(ResolverView::History); let pane = self.resolver().unwrap().history_panes[index]; self.focus_resolver_pane(pane); } else if resolver.view == ResolverView::Resolution { let pane = resolver.alternative_pane; self.resolvers .get_mut(&self.tab_id) .unwrap() .selected_source = index; self.refresh_resolver_comparison(); if let Some(pane) = pane { self.focus_resolver_pane(pane) } } else { let right = resolver.history_panes.get(resolver.history_sources[1]) == Some(&self.pane_id); let resolver = self.resolvers.get_mut(&self.tab_id).unwrap(); let slot = usize::from(right); if resolver.history_sources[1 - slot] == index { resolver.history_sources[1 - slot] = resolver.history_sources[slot]; } resolver.history_sources[usize::from(right)] = index; let pane = resolver.history_panes[index]; self.focus_resolver_pane(pane); } } Action::ResolveUseSource => { let Some(index) = source.filter(|index| { resolver .files .get(resolver.current_file) .is_some_and(|file| *index < file.sources.len()) }) else { self.set_message("No such conflict source"); return; }; let file = resolver.current_file; let deleted = resolver.model.as_ref().is_some_and(|model| { model.files[file].sources[index].kind == crate::resolve::FileKind::Deleted }); let whole = !resolver.files[file].text || deleted; let alternative_row = (Some(self.pane_id) == resolver.alternative_pane) .then(|| { self.resolver_diff() .and_then(|diff| diff.comparison.as_ref()) .and_then(|comparison| { comparison.files[self.resolver_diff().unwrap().file] .hunks .iter() .find(|hunk| { let row = self.cursor().row; if hunk.after_len == 0 { hunk.after_start == row } else { (hunk.after_start..hunk.after_start + hunk.after_len) .contains(&row) } }) }) .map(|hunk| hunk.before_start) }) .flatten(); self.resolvers .get_mut(&self.tab_id) .unwrap() .selected_source = index; self.set_resolver_view(ResolverView::Resolution); if let Some(row) = alternative_row { self.active.view.targets = vec![Target::caret(Cursor { row: row.min(self.active.lines.len() - 1), column: 0, })]; self.active.view.primary = 0; } if whole { let resolver = self.resolvers.get_mut(&self.tab_id).unwrap(); resolver.files[file].whole_file_choice = Some(index); resolver.files[file].status = ResolverStatus::Unsaved; self.set_message("Whole-file source selected; :w applies the explicit choice"); return; } self.refresh_resolver_comparison(); let row = self.cursor().row; let diff = self.resolver_diff().unwrap(); let comparison = &diff.comparison.as_ref().unwrap().files[diff.file]; let Some(hunk) = comparison.hunks.iter().find(|hunk| { if hunk.before_len == 0 { hunk.before_start == row } else { (hunk.before_start..hunk.before_start + hunk.before_len).contains(&row) } }) else { self.set_message( "Move the Draft caret into a difference; :resolve.next navigates decisions", ); return; }; let mut text = String::new(); for index in hunk.after_start..hunk.after_start + hunk.after_len { text.push_str(&comparison.after[index]); if index + 1 < comparison.after.len() || comparison.after_eof { text.push('\n'); } } let offsets = line_offsets(&self.active.lines); let length = self.active.lines.iter().map(String::len).sum::() + self.active.lines.len() - 1; let start = offsets.get(hunk.before_start).copied().unwrap_or(length); let end = offsets .get(hunk.before_start + hunk.before_len) .copied() .unwrap_or(length); let before = self.snapshot(); self.apply_edits(vec![Edit { target: Some(self.active.view.primary), start, end, text: Cow::Owned(text), caret: 0, round_up: false, }]); self.active.view.targets = vec![Target::caret(Cursor { row: row.min(self.active.lines.len() - 1), column: 0, })]; self.active.view.primary = 0; self.resolvers.get_mut(&self.tab_id).unwrap().files[file].whole_file_choice = None; self.record(before); self.reveal_caret(); self.refresh_resolver_status(); self.refresh_resolver_comparison(); } _ => unreachable!("resolver actions only"), } } fn queue_resolver_save(&mut self, path: Option<&Path>, quit: bool) -> bool { let owner = self.resolvers.iter().find_map(|(tab, resolver)| { resolver .files .iter() .position(|file| file.draft_buffer == self.active.id) .map(|file| (*tab, file)) }); let Some((tab, file)) = owner else { return false; }; if path.is_some_and(|path| self.active.path.as_deref() != Some(path)) { self.set_message("Resolution Draft must be written to its original conflict path"); return true; } let resolver = self.resolvers.get_mut(&tab).unwrap(); if resolver.pending { self.set_message("Resolution save/check is already running"); return true; } let model = resolver.model.as_ref().unwrap(); let conflict_path = model.root.join(&model.files[file].path); if resolver.files[file].whole_file_choice.is_some() && std::iter::once(&self.active) .chain(self.parked.values()) .any(|buffer| { buffer.dirty() && buffer.identity.as_ref().is_some_and(|path| { path != &conflict_path && path.starts_with(&conflict_path) }) }) { self.set_message( "Directory resolution refused: an open child buffer has unsaved changes", ); return true; } if resolver.files[file].text && model.files[file] .result .as_ref() .is_some_and(|text| split_lines(text) != self.active.saved_lines) { self.set_message("Resolution draft predates the loaded disk content; preserve the draft before reopening"); return true; } let resolution = if let Some(source) = resolver.files[file].whole_file_choice { crate::resolve::Resolution::Source(source) } else if resolver.files[file].text { crate::resolve::Resolution::Text(self.active.lines.join("\n")) } else { self.set_message("Choose an explicit whole-file source before writing"); return true; }; resolver.pending = true; resolver.files[file].status = ResolverStatus::Checking; self.resolver_requests.push_back(ResolverRequest::Save { tab, file, buffer: self.active.id, workspace: model.clone(), resolution, quit, }); true } fn resolver_close_blocked(&self) -> bool { self.resolver().is_some_and(|resolver| { resolver.pending || resolver.files.iter().any(|file| { file.whole_file_choice.is_some() || self.buffer(file.draft_buffer).is_some_and(Buffer::dirty) }) }) } fn handle_resolver_history_focus(&mut self, input: &Input) -> bool { let Some(_) = self .resolver() .filter(|resolver| resolver.view == ResolverView::History) else { return false; }; if self.palette.is_some() || self.active.view.mode != Mode::Normal || self.sequence_pending().is_some() || self.seek_pending.is_some() || self.line_pending.is_some() || self.count.is_some() || !matches!(input, Input::Char('\t') | Input::BackTab) { return false; } let panes = self.resolver_visible_panes(); let files = self.file_picker().is_some(); let current = if self.file_picker().is_some_and(|picker| picker.focused) { panes.len() } else { panes .iter() .position(|pane| *pane == self.pane_id) .unwrap_or(0) }; let count = panes.len() + usize::from(files); if count == 0 { return false; } let next = (current + if *input == Input::BackTab { count - 1 } else { 1 }) % count; if let Some(pane) = panes.get(next) { self.focus_resolver_pane(*pane); } else { self.focus_resolver_files(); } true } pub fn tab_id(&self) -> usize { self.tab_id } pub fn tab_index(&self) -> usize { self.tab_order .iter() .position(|id| *id == self.tab_id) .unwrap() } pub fn tab_count(&self) -> usize { self.tab_order.len() } pub fn tab_buffer(&self, index: usize) -> Option> { let id = *self.tab_order.get(index)?; let pane = if id == self.tab_id { self.pane_id } else { self.tabs.get(&id)?.pane_id }; self.buffer(self.pane_owned_buffer(pane)) .map(|buffer| buffer.info(id == self.tab_id)) } pub fn tab_is_diff(&self, index: usize) -> bool { self.tab_diff_at(index).is_some() } pub(crate) fn tab_diff_at(&self, index: usize) -> Option<&DiffState> { self.tab_diff(*self.tab_order.get(index)?) } pub(crate) fn tab_diff(&self, id: usize) -> Option<&DiffState> { if id == self.tab_id { self.workspace_diff() } else { self.tabs.get(&id)?.diff() } } fn select_tab(&mut self, id: usize) { if id == self.tab_id { return; } self.pane_close_picker_origin = None; let next = self.tabs.remove(&id).expect("selected tab exists"); self.stash_view(); let previous = Tab { pane_id: std::mem::replace(&mut self.pane_id, next.pane_id), pane_layout: std::mem::replace(&mut self.pane_layout, next.pane_layout), review_pane: std::mem::replace(&mut self.review_pane, next.review_pane), local_review: std::mem::replace(&mut self.local_review, next.local_review), file_picker: std::mem::replace(&mut self.file_picker, next.file_picker), diff: std::mem::replace(&mut self.diff, next.diff), parked_diff: std::mem::replace(&mut self.parked_diff, next.parked_diff), diff_owner: std::mem::replace(&mut self.diff_owner, next.diff_owner), diff_pane_bounds: std::mem::replace(&mut self.diff_pane_bounds, next.diff_pane_bounds), diff_prompt_mode: std::mem::replace(&mut self.diff_prompt_mode, next.diff_prompt_mode), diff_bound: std::mem::replace(&mut self.diff_bound, next.diff_bound), diff_return_buffer: std::mem::replace( &mut self.diff_return_buffer, next.diff_return_buffer, ), outstanding_diff_request: std::mem::replace( &mut self.outstanding_diff_request, next.outstanding_diff_request, ), }; self.tabs.insert(self.tab_id, previous); self.tab_id = id; let buffer_id = self.panes[&self.pane_id].buffer_id; if buffer_id != self.active.id { let buffer = self.parked.remove(&buffer_id).expect("tab buffer exists"); let previous = std::mem::replace(&mut self.active, buffer); self.parked.insert(previous.id, previous); } self.load_view(); } fn new_tab(&mut self) { self.finish_pane_interaction(); let buffer_id = self.active.id; let view = self.active.view.inherit(); let pane_id = self.next_pane_id; self.next_pane_id += 1; self.panes.insert( pane_id, Pane { buffer_id, origin: None, views: BTreeMap::from([(buffer_id, view)]), }, ); let id = self.next_tab_id; self.next_tab_id += 1; self.tabs.insert( id, Tab { pane_id, pane_layout: PaneTree::new(pane_id), review_pane: None, local_review: Default::default(), file_picker: None, diff: None, parked_diff: None, diff_owner: None, diff_pane_bounds: self.diff_pane_bounds, diff_prompt_mode: None, diff_bound: None, diff_return_buffer: None, outstanding_diff_request: None, }, ); self.tab_order.push(id); self.select_tab(id); self.normalize_targets(); if self.active.view.mode == Mode::Insert { self.begin_insert(); } } fn adjacent_tab(&mut self, forward: bool) { self.finish_pane_interaction(); let index = self.tab_index(); let count = self.tab_count(); let next = if forward { (index + 1) % count } else { (index + count - 1) % count }; self.select_tab(self.tab_order[next]); if self.active.view.mode == Mode::Insert { self.begin_insert(); } } pub fn pane_id(&self) -> usize { self.pane_id } pub fn pane_layout(&self) -> &PaneTree { &self.pane_layout } pub fn set_pane_bounds(&mut self, bounds: PaneRect) { self.pane_bounds = bounds; } pub fn set_diff_pane_bounds(&mut self, bounds: [(Focus, PaneRect); 3]) { self.diff_pane_bounds = bounds; } pub(crate) fn set_resolver_history_bounds( &mut self, panes: impl IntoIterator, files: Option, ) { if let Some(resolver) = self.resolvers.get_mut(&self.tab_id) { resolver.history_bounds.clear(); resolver.history_bounds.extend(panes); resolver.files_bounds = files; } } pub fn pane_buffer_id(&self, id: usize) -> Option { self.panes.get(&id).map(|pane| pane.buffer_id) } pub fn pane_origin(&self, id: usize) -> Option { self.panes.get(&id).and_then(|pane| pane.origin) } pub fn render_pane_focused(&self) -> bool { self.render_pane_focused } fn stash_view(&mut self) { let pane = self .panes .get_mut(&self.pane_id) .expect("focused pane exists"); pane.buffer_id = self.active.id; let view = pane .views .entry(self.active.id) .or_insert_with(ViewState::empty); std::mem::swap(&mut self.active.view, view); } fn load_view(&mut self) { let pane = self .panes .get_mut(&self.pane_id) .expect("focused pane exists"); pane.buffer_id = self.active.id; let view = pane.views.entry(self.active.id).or_insert_with(|| { if self.active.view.targets.is_empty() { ViewState { targets: vec![Target::caret(Cursor::default())], ..ViewState::empty() } } else { std::mem::replace(&mut self.active.view, ViewState::empty()) } }); std::mem::swap(&mut self.active.view, view); } // No input dispatch or document copies: source documents stay unique in active/parked. fn select_pane(&mut self, id: usize) { if id == self.pane_id { return; } self.pane_close_picker_origin = None; let buffer_id = self.panes[&id].buffer_id; self.stash_view(); if self.diff_owner == Some(self.pane_id) { self.parked_diff = self.diff.take(); } if buffer_id != self.active.id { let buffer = self.parked.remove(&buffer_id).expect("pane buffer exists"); let previous = std::mem::replace(&mut self.active, buffer); self.parked.insert(previous.id, previous); } self.pane_id = id; self.load_view(); if self.diff_owner == Some(id) { self.diff = self.parked_diff.take(); } } fn with_pane(&mut self, id: usize, callback: impl FnOnce(&mut Self) -> R) -> R { assert!(self.panes.contains_key(&id), "rendered pane exists"); let focused = self.pane_id; let tab = self.tab_id; let target_tab = if self.pane_layout.contains(id) || self.review_pane == Some(id) { tab } else { *self .tabs .iter() .find(|(_, tab)| tab.pane_layout.contains(id) || tab.review_pane == Some(id)) .map(|(id, _)| id) .expect("pane belongs to a tab") }; let target_focused = if target_tab == tab { focused } else { self.tabs[&target_tab].pane_id }; let interaction = (id != focused).then(|| { ( self.palette.take(), self.pane_close_picker_origin.take(), self.hover.take(), self.completion.take(), self.snippet.take(), self.file_picker.take(), self.count.take(), std::mem::take(&mut self.pending_sequence), std::mem::take(&mut self.sequence_display), std::mem::take(&mut self.g_pending), self.line_pending.take(), self.seek_pending.take(), std::mem::take(&mut self.message), std::mem::replace(&mut self.notification_visible, true), ) }); self.select_tab(target_tab); self.select_pane(id); let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| callback(self))); self.select_pane(target_focused); self.select_tab(tab); if let Some(interaction) = interaction { ( self.palette, self.pane_close_picker_origin, self.hover, self.completion, self.snippet, self.file_picker, self.count, self.pending_sequence, self.sequence_display, self.g_pending, self.line_pending, self.seek_pending, self.message, self.notification_visible, ) = interaction; } match result { Ok(value) => value, Err(error) => std::panic::resume_unwind(error), } } pub fn with_render_pane(&mut self, id: usize, callback: impl FnOnce(&Editor) -> R) -> R { let focused = self.render_pane_focused; let is_focused = focused && self.pane_id == id && !self.file_picker().is_some_and(|picker| picker.focused); self.render_pane_focused = is_focused; let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { self.with_pane(id, |editor| callback(editor)) })); self.render_pane_focused = focused; match result { Ok(value) => value, Err(error) => std::panic::resume_unwind(error), } } fn finish_pane_interaction(&mut self) { self.pending_lsp_navigation = None; self.finish_completion(); self.snippet = None; self.clear_hover(); if self.active.view.mode == Mode::Insert { self.finish_insert(); } self.palette = None; self.pane_close_picker_origin = None; self.count = None; self.clear_sequence(); self.g_pending = false; self.line_pending = None; self.seek_pending = None; } fn split_pane(&mut self, axis: SplitAxis) { self.finish_pane_interaction(); let id = self.next_pane_id; self.next_pane_id += 1; let buffer_id = self.active.id; let view = self.active.view.inherit(); self.panes.insert( id, Pane { buffer_id, origin: Some(self.pane_id), views: BTreeMap::from([(buffer_id, view)]), }, ); assert!(self.pane_layout.split(self.pane_id, id, axis)); self.select_pane(id); self.normalize_targets(); if self.active.view.mode == Mode::Insert { self.begin_insert(); } } fn focus_pane(&mut self, direction: PaneDirection) { if self .resolver() .is_some_and(|resolver| resolver.view == ResolverView::History) { let resolver = self.resolver().unwrap(); let files_focused = self.file_picker().is_some_and(|picker| picker.focused); let current = if files_focused { resolver.files_bounds } else { resolver .history_bounds .iter() .find(|(pane, _)| *pane == self.pane_id) .map(|(_, rect)| *rect) }; let Some(current) = current else { return }; let files = resolver.history_bounds.len(); let neighbor = directional_neighbor( current, direction, resolver .history_bounds .iter() .enumerate() .filter_map(|(index, (pane, rect))| { (files_focused || *pane != self.pane_id).then_some((index, *rect)) }) .chain( resolver .files_bounds .filter(|_| !files_focused) .map(|rect| (files, rect)), ), ); if let Some(neighbor) = neighbor { if neighbor == files { self.focus_resolver_files(); } else { let pane = resolver.history_bounds[neighbor].0; self.focus_resolver_pane(pane); } } return; } if let Some(diff) = self.diff().filter(|diff| diff.phase == Phase::View) { let focus = diff.focus; if let Some((_, current)) = self .diff_pane_bounds .iter() .find(|(side, rect)| *side == focus && rect.width > 0 && rect.height > 0) { let neighbor = directional_neighbor( *current, direction, self.diff_pane_bounds .iter() .enumerate() .filter_map(|(id, (side, rect))| { (*side != focus && rect.width > 0 && rect.height > 0) .then_some((id, *rect)) }), ); if let Some(neighbor) = neighbor { self.finish_pane_interaction(); let focus = self.diff_pane_bounds[neighbor].0; let diff = self.interaction_diff_mut().expect("comparison stays open"); diff.focus = focus; if let Some(files) = &mut diff.files { files.focused = diff.files_visible && diff.focus == Focus::Files; } self.bind_diff_buffer(); return; } } } if self.resolver().is_some() { return; } if let Some(id) = self .pane_layout .neighbor(self.pane_id, direction, self.pane_bounds) { self.finish_pane_interaction(); if self.diff.is_none() { self.blur_file_picker(); } self.select_pane(id); if self.active.view.mode == Mode::Insert { self.begin_insert(); } } } fn pane_buffers(&self, id: usize) -> [Option; 3] { let (diff, return_buffer) = if self.diff_owner == Some(id) { (self.workspace_diff(), self.diff_return_buffer) } else if let Some(tab) = self.tabs.values().find(|tab| tab.diff_owner == Some(id)) { (tab.diff(), tab.diff_return_buffer) } else { (None, None) }; [ Some(self.panes[&id].buffer_id), diff.and_then(|diff| diff.live_buffer), return_buffer, ] } fn pane_owned_buffer(&self, id: usize) -> usize { let buffers = self.pane_buffers(id); buffers[1].or(buffers[2]).or(buffers[0]).unwrap() } fn closing_buffers(&self, panes: &[usize]) -> Vec { let mut buffers = Vec::new(); for pane in panes { for buffer in self.pane_buffers(*pane).into_iter().flatten() { if !buffers.contains(&buffer) && !self.panes.keys().any(|other| { !panes.contains(other) && self.pane_buffers(*other).contains(&Some(buffer)) }) { buffers.push(buffer); } } } buffers } fn forget_buffer(&mut self, id: usize) { if self .resolvers .values() .any(|resolver| resolver.files.iter().any(|file| file.draft_buffer == id)) { return; } self.parked.remove(&id); for pane in self.panes.values_mut() { pane.views.remove(&id); } } fn close_tab(&mut self, force: bool) { if !force && self.local_review_close_blocked() { return; } if self.resolver_close_blocked() { self.set_message( "Resolution drafts are unsaved; write them before closing the workspace", ); return; } if self.tab_count() == 1 { if !force && self.any_dirty() { self.set_message("Unsaved changes; use :w or :tab.force_close"); } else { self.quit = true; } return; } let panes: Vec<_> = self .panes .keys() .copied() .filter(|id| self.pane_layout.contains(*id) || self.review_pane == Some(*id)) .collect(); let mut buffers = self.closing_buffers(&panes); if let Some(resolver) = self.resolver() { for file in &resolver.files { if !buffers.contains(&file.draft_buffer) && !self.panes.iter().any(|(id, pane)| { !panes.contains(id) && pane.buffer_id == file.draft_buffer }) { buffers.push(file.draft_buffer); } } } if !force && buffers .iter() .any(|id| self.buffer(*id).is_some_and(Buffer::dirty)) { self.set_message("Unsaved changes; use :w or :tab.force_close"); return; } self.finish_pane_interaction(); if let Some(owner) = self.diff_owner { self.with_pane(owner, |editor| { editor.detach_review_buffers(); editor.release_diff_buffers(); editor.diff = None; }); self.parked_diff = None; self.diff_owner = None; self.outstanding_diff_request = None; } let id = self.tab_id; let index = self.tab_index(); let next = self.tab_order[(index + 1) % self.tab_count()]; self.select_tab(next); self.tabs.remove(&id); self.tab_order.remove(index); self.resolvers.remove(&id); for pane in panes { self.panes.remove(&pane); } for buffer in buffers { self.forget_buffer(buffer); } } fn close_pane(&mut self, force: bool) { if self.dismiss_review_panel() { return; } if self.resolver().is_some() { self.close_tab(force); return; } self.count = None; self.clear_sequence(); let picker_origin = self.pane_close_picker_origin.take().is_some_and(|origin| { origin == ( self.tab_id, self.pane_id, self.diff.as_ref().and_then(|diff| diff.comparison_id), ) }); if self.file_picker().is_some_and(|picker| { (picker.focused || picker_origin) && picker.presentation == FilePresentation::Docked }) { self.palette = None; self.close_file_picker(); return; } if self.pane_layout.len() == 1 { if self.tab_count() > 1 { self.close_tab(force); } else if !force && self.any_dirty() { self.set_message("Unsaved changes; use :w or :pane.force_close"); } else { self.quit = true; } return; } let id = self.pane_id; let buffers = self.closing_buffers(&[id]); if !force && let Some(buffer_id) = buffers .iter() .find(|buffer| self.buffer(**buffer).is_some_and(Buffer::dirty)) { self.set_message(format!( "Buffer {buffer_id} has unsaved changes; use :w or :pane.force_close" )); return; } self.finish_pane_interaction(); let review_source = self.review_pane.and_then(|panel| self.panes[&panel].origin) == Some(id); if (self.diff_owner == Some(id) || review_source) && !force && self.local_review_close_blocked() { return; } if self.diff_owner == Some(id) { self.detach_review_buffers(); self.release_diff_buffers(); self.diff = None; self.parked_diff = None; self.diff_owner = None; self.outstanding_diff_request = None; } let next = self.pane_layout.close(id).expect("another pane exists"); self.select_pane(next); if review_source && let Some(panel) = self.review_pane { self.panes.get_mut(&panel).unwrap().origin = Some(next); } self.panes.remove(&id); for buffer in buffers { self.forget_buffer(buffer); } } pub fn clipboard_mode(&self) -> ClipboardMode { self.clipboard_mode } pub fn set_clipboard_mode(&mut self, mode: &str) -> Result<(), String> { self.clipboard_mode = if mode.eq_ignore_ascii_case("system") { ClipboardMode::System } else if mode.eq_ignore_ascii_case("separate") { ClipboardMode::Separate } else { return Err("Clipboard mode must be system or separate".into()); }; Ok(()) } #[cfg(test)] pub(crate) fn set_system_clipboard(&mut self, clipboard: SystemClipboard) { self.system_clipboard = clipboard; self.owned_system_clipboard = None; } /// Stable one-based ID, never reused after a buffer is deleted. pub fn buffer_id(&self) -> usize { self.active.id } pub fn buffer_count(&self) -> usize { self.buffers().count() } pub fn has_buffer(&self, id: usize) -> bool { id == self.active.id || self.parked.contains_key(&id) } /// All loaded buffers in ascending ID order, without copying their paths. pub fn buffers(&self) -> impl Iterator> + '_ { self.parked .range(..self.active.id) .filter(|(_, buffer)| buffer.diff_snapshot.is_none() && !buffer.resolver_readonly) .map(|(_, buffer)| buffer.info(false)) .chain( (self.active.diff_snapshot.is_none() && !self.active.resolver_readonly) .then(|| self.active.info(true)), ) .chain( self.parked .range(self.active.id..) .filter(|(_, buffer)| { buffer.diff_snapshot.is_none() && !buffer.resolver_readonly }) .map(|(_, buffer)| buffer.info(false)), ) } pub fn documents(&self) -> impl Iterator> + '_ { self.parked .range(..self.active.id) .filter(|(_, buffer)| buffer.diff_snapshot.is_none() && !buffer.resolver_readonly) .map(|(_, buffer)| buffer.document()) .chain( (self.active.diff_snapshot.is_none() && !self.active.resolver_readonly) .then(|| self.active.document()), ) .chain( self.parked .range(self.active.id..) .filter(|(_, buffer)| { buffer.diff_snapshot.is_none() && !buffer.resolver_readonly }) .map(|(_, buffer)| buffer.document()), ) } pub(crate) fn interaction_documents(&self) -> impl Iterator> + '_ { self.documents().chain( std::iter::once(&self.active) .chain(self.parked.values()) .filter(|buffer| buffer.diff_snapshot.is_some() || buffer.resolver_readonly) .map(Buffer::document), ) } pub fn project_root(&self) -> &Path { &self.project_root } pub fn diff(&self) -> Option<&DiffState> { self.resolver_diff().or(self.diff.as_ref()) } fn interaction_diff_mut(&mut self) -> Option<&mut DiffState> { if self .resolver() .is_some_and(|resolver| resolver.view == ResolverView::Resolution) { self.resolver_diff_mut() } else { self.diff.as_mut() } } fn picker_diff_mut(&mut self) -> Option<&mut DiffState> { if self.resolver().is_some() { self.resolver_diff_mut() } else { self.diff.as_mut() } } pub(crate) fn picker_diff(&self) -> Option<&DiffState> { self.resolver_workspace_diff().or(self.diff.as_ref()) } pub(crate) fn workspace_diff(&self) -> Option<&DiffState> { self.diff.as_ref().or(self.parked_diff.as_ref()) } pub(crate) fn diff_root(&mut self, tab: usize) -> PathBuf { let owner = if tab == self.tab_id { self.diff_owner.unwrap_or(self.pane_id) } else { let tab = &self.tabs[&tab]; tab.diff_owner.unwrap_or(tab.pane_id) }; self.with_pane(owner, |editor| { if let Some(root) = &editor.active.snapshot_root { return root.clone(); } editor .path() .and_then(Path::parent) .filter(|path| !path.as_os_str().is_empty()) .unwrap_or_else(|| editor.project_root()) .to_path_buf() }) } pub fn word_wrap(&self) -> bool { self.resolver_diff() .or(self.diff.as_ref()) .filter(|diff| diff.phase == Phase::View) .map_or(self.active.view.word_wrap, |diff| diff.word_wrap) } pub fn scroll_distance(&self) -> Option { self.scroll_distance } pub(crate) fn set_scroll_distance(&mut self, distance: Option) { self.scroll_distance = distance; } pub(crate) fn set_scroll_step_lines(&mut self, lines: Option) { self.scroll_step_lines = lines; } pub(crate) fn highlight_trailing_whitespace(&self) -> bool { self.highlight_trailing_whitespace } pub(crate) fn set_highlight_trailing_whitespace(&mut self, enabled: bool) { self.highlight_trailing_whitespace = enabled; } pub(crate) fn whitespace_characters(&self) -> &BTreeMap { &self.whitespace_characters } pub(crate) fn set_whitespace_characters(&mut self, characters: BTreeMap) { self.whitespace_characters = characters; } pub(crate) fn whitespace_scope(&self) -> WhitespaceScope { self.whitespace_scope } pub(crate) fn set_whitespace_scope(&mut self, scope: WhitespaceScope) { self.whitespace_scope = scope; } pub(crate) fn set_tab_spaces( &mut self, spaces: Option, languages: BTreeMap>, ) { self.tab_spaces = spaces; self.language_tab_spaces = languages; } pub(crate) fn set_diff_context_options(&mut self, context_lines: usize, expand_lines: usize) { self.diff_context_options = (context_lines, expand_lines); if let Some(diff) = &mut self.diff { diff.set_context_options(context_lines, expand_lines); } for resolver in self.resolvers.values_mut() { for file in &mut resolver.files { file.diff.set_context_options(context_lines, expand_lines); } } } pub fn set_diff_display_options(&mut self, view_mode: ViewMode, split_axis: SplitAxis) { self.diff_display_options = (view_mode, split_axis); } pub(crate) fn queue_checkout_refreshes(&mut self, follow_working: bool) { for index in 0..self.tab_order.len() { let tab = self.tab_order[index]; let owner = if tab == self.tab_id { self.diff_owner } else { self.tabs.get(&tab).and_then(|tab| tab.diff_owner) }; if let Some(owner) = owner { self.with_pane(owner, |editor| { if let Some(diff) = &mut editor.diff { diff.queue_checkout_refresh(follow_working); } }); } } } pub(crate) fn take_next_diff_request(&mut self) -> Option<(usize, u64, Operation)> { let tab = std::iter::once(self.tab_id) .chain( self.tab_order .iter() .copied() .filter(|tab| *tab != self.tab_id), ) .find(|tab| self.tab_diff(*tab).is_some_and(DiffState::request_ready))?; let owner = if tab == self.tab_id { self.diff_owner } else { self.tabs.get(&tab)?.diff_owner }?; self.with_pane(owner, |editor| { let (id, operation) = editor.take_diff_request()?; Some((tab, id, operation)) }) } pub(crate) fn take_diff_request(&mut self) -> Option<(u64, Operation)> { let mut operation = self .diff .as_mut() .or(self.parked_diff.as_mut())? .take_request()?; let buffers = match &mut operation { Operation::Compare { after, buffers, .. } if after.target == RevisionTarget::Working => { Some(buffers) } Operation::RefreshCheckout { buffers, .. } if self .diff .as_ref() .or(self.parked_diff.as_ref()) .and_then(|diff| diff.after.as_ref()) .is_some_and(|after| after.target == RevisionTarget::Working) => { Some(buffers) } _ => None, }; if let Some(buffers) = buffers { *buffers = std::iter::once(&self.active) .chain(self.parked.values()) .filter(|buffer| buffer.dirty()) .filter_map(|buffer| { let path = buffer.identity.as_ref()?; let metadata = fs::symlink_metadata(buffer.path.as_ref()?).ok()?; (metadata.is_file() && fs::read_to_string(path).is_ok()) .then(|| (path.clone(), buffer.lines.join("\n"))) }) .collect(); } self.next_diff_request = self .next_diff_request .checked_add(1) .expect("diff request IDs exhausted"); let id = self.next_diff_request; self.outstanding_diff_request = Some(id); if let Operation::LoadFile { comparison_id, file_index, } = &operation && let Some(pending) = &mut self.pending_lsp_navigation && pending.bound.0 == *comparison_id && pending.file == *file_index { pending.request = Some(id); } Some((id, operation)) } pub(crate) fn apply_diff_result(&mut self, id: u64, result: Result) -> bool { if self .pending_lsp_navigation .as_ref() .is_some_and(|pending| !self.lsp_navigation_current(pending)) { self.pending_lsp_navigation = None; } if self.outstanding_diff_request != Some(id) { let owner = self .tabs .values() .find(|tab| tab.outstanding_diff_request == Some(id)) .and_then(|tab| tab.diff_owner); return owner.is_some_and(|owner| { self.with_pane(owner, |editor| editor.apply_diff_result(id, result)) }); } if let Some(owner) = self.diff_owner.filter(|owner| *owner != self.pane_id) { return self.with_pane(owner, |editor| editor.apply_diff_result(id, result)); } if matches!(&result, Ok(Outcome::PullRequestPage { .. })) { return self .diff .as_mut() .is_some_and(|diff| diff.apply_result(id, result)); } self.outstanding_diff_request = None; if self.diff.as_ref().is_some_and(DiffState::refresh_pending) && !matches!(&result, Ok(Outcome::CheckoutRefresh(None))) { self.finish_completion(); self.snippet = None; self.clear_hover(); if self.active.view.mode == Mode::Insert { self.finish_insert(); self.leave_insert(); } } let preserved_view = (self.diff.as_ref().is_some_and(|diff| { diff.refresh_pending() && (matches!(&result, Ok(Outcome::BookmarkRefresh(_))) || diff .after .as_ref() .is_some_and(|after| after.target != RevisionTarget::Working)) && !matches!(&result, Ok(Outcome::CheckoutRefresh(None))) || matches!(&result, Ok(Outcome::File { .. })) && diff.refresh_view.is_some() })) .then(|| self.active.view.inherit()); let Some(diff) = &mut self.diff else { return false; }; if diff.request_queued() && !diff.refresh_pending() { return false; } let refresh = diff.refresh_pending(); let previous_comparison = diff.comparison_id; let restore_reviewed = matches!( &result, Ok(Outcome::Comparison(_) | Outcome::File { .. } | Outcome::CheckoutRefresh(Some(_)) | Outcome::BookmarkRefresh(_)) ); if !diff.apply_result(id, result) { return false; } if refresh { if diff.comparison_id != previous_comparison { self.pending_lsp_navigation = None; } self.diff_bound = None; } else if let Some(files) = &mut diff.files { files.set_view(self.file_explorer_view); } let obsolete_snapshot = self.active.diff_snapshot.is_some_and(|role| { self.diff.as_ref().and_then(|diff| diff.comparison_id) != Some(role.0) }); self.bind_diff_buffer(); let pinned_refresh = preserved_view.is_some(); if self.active.diff_snapshot.is_some() && let Some(mut view) = preserved_view { view.clamp(&self.active.lines); self.active.view = view; } if self.review_directory.is_some() && self.resolver().is_none() && self .workspace_diff() .is_some_and(|diff| diff.phase == Phase::View) && let Err(error) = self.initialize_local_review() { self.set_message(format!("Local review: {error}")); } if restore_reviewed && let Some(diff) = &mut self.diff { diff.restore_reviewed_files(&self.local_review); } let reviewed = self.diff.as_mut().and_then(|diff| { (refresh && diff .after .as_ref() .is_some_and(|after| after.target != RevisionTarget::Working)) .then(|| std::mem::take(&mut diff.reviewed_files)) }); self.refresh_live_diff(); if let Some(reviewed) = reviewed { self.diff .as_mut() .expect("comparison remains open") .reviewed_files = reviewed; } if pinned_refresh { self.diff .as_mut() .expect("comparison remains open") .follow_caret = false; } if refresh || obsolete_snapshot { self.prune_diff_snapshots(); } let completed_navigation = self .pending_lsp_navigation .as_ref() .is_some_and(|pending| pending.request == Some(id)); if completed_navigation && let Some(pending) = self.pending_lsp_navigation.take() && self.lsp_navigation_current(&pending) { let ready = self.diff.as_ref().is_some_and(|diff| { diff.error.is_none() && diff.comparison.as_ref().is_some_and(|comparison| { comparison .files .get(pending.file) .is_some_and(|file| file.loaded) }) }); if ready && let Err(error) = self.navigate_lsp_file( pending.file, pending.focus, pending.range, pending.target_range, ) { self.set_message(format!("LSP: definition failed: {error}")); } if !ready && let Some(error) = self.diff.as_ref().and_then(|diff| diff.error.as_ref()) { self.set_message(format!("LSP: definition failed: {error}")); } } self.finish_review_navigation(); true } fn open_diff(&mut self, mut diff: DiffState) { if self.local_review_close_blocked() { return; } self.detach_review_buffers(); if let Some(owner) = self.diff_owner.filter(|owner| *owner != self.pane_id) { self.with_pane(owner, |editor| { editor.release_diff_buffers(); editor.diff = None; }); self.parked_diff = None; } self.diff_owner = Some(self.pane_id); self.release_diff_buffers(); self.diff_return_buffer = Some(self.active.id); self.count = None; self.clear_sequence(); self.palette = None; self.hover = None; self.startup_diagnostic = None; self.blur_file_picker(); if self.active.view.mode == Mode::Command { self.active.view.command_line.clear(); self.active.view.mode = Mode::Normal; } self.outstanding_diff_request = None; diff.word_wrap = self.active.view.word_wrap; diff.view_mode = self.diff_display_options.0; diff.split_axis = self.diff_display_options.1; diff.set_context_options(self.diff_context_options.0, self.diff_context_options.1); self.diff = Some(diff); } fn binding_mode(&self) -> Mode { if self.command_output.is_some() { return Mode::Normal; } if self.diff.is_some() && !self.diff_interacting() && !matches!(self.active.view.mode, Mode::Command | Mode::Search) { Mode::Normal } else { self.active.view.mode } } /// Direct document motions can share a frame without bypassing key dispatch. pub(crate) fn can_batch_navigation(&self, input: &Input) -> bool { matches!(self.active.view.mode, Mode::Normal | Mode::Visual) && self.document_actions_available() && self.startup_diagnostic.is_none() && self.hover.is_none() && self.command_output.is_none() && self.palette.is_none() && self.completion.is_none() && !self.file_picker().is_some_and(|picker| picker.focused) && self.sequence_pending().is_none() && !self.g_pending && self.line_pending.is_none() && self.seek_pending.is_none() && matches!( self.bindings .get(&self.active.view.mode) .and_then(|keys| keys.get(input)), Some(Binding::Action( Action::Left | Action::Right | Action::Down | Action::Up | Action::ScrollUp | Action::ScrollDown | Action::ScrollStepUp | Action::ScrollStepDown | Action::LineStart | Action::LineEnd | Action::DocumentStart | Action::DocumentEnd | Action::GoToLine | Action::WordNext | Action::WordPrevious | Action::WordEnd | Action::BigWordNext | Action::BigWordPrevious | Action::BigWordEnd )) ) } pub(crate) fn viewport_scroll_action(&self, input: &Input) -> Option<&'static str> { match self.bindings.get(&self.active.view.mode)?.get(input)? { Binding::Action( action @ (Action::ScrollUp | Action::ScrollDown | Action::ScrollStepUp | Action::ScrollStepDown), ) => Some(action.entry().name), _ => None, } } pub fn diff_editable(&self) -> bool { self.diff().is_some_and(|diff| { self.diff_buffer_focus() .is_some_and(|focus| diff.side_editable(focus)) && if let Some(resolver) = self.resolver() { resolver.files[resolver.current_file].draft_buffer == self.active.id } else { diff.live_buffer == Some(self.active.id) } }) } pub fn diff_editing(&self) -> bool { self.diff_editable() && self.diff_interacting() } pub fn diff_interacting(&self) -> bool { self.diff_buffer_focus().is_some_and(|focus| { self.diff() .is_some_and(|diff| diff.focus == focus && diff.phase == Phase::View) }) } pub(crate) fn diff_buffer_focus(&self) -> Option { if self.resolver().is_some() { return self.resolver_buffer_focus(); } let (comparison, file, focus, buffer) = self.diff_bound?; self.diff .as_ref() .filter(|diff| { diff.comparison_id == Some(comparison) && diff.file == file && self.active.id == buffer }) .map(|_| focus) } pub(crate) fn diff_semantic_tokens(&self, focus: Focus) -> &[SemanticToken] { if let Some(resolver) = self.resolver() { let pane = if focus == Focus::Before { Some(resolver.draft_pane) } else { resolver.alternative_pane }; return pane .and_then(|pane| self.pane_buffer_id(pane)) .and_then(|buffer| self.buffer(buffer)) .and_then(|buffer| buffer.semantic_tokens.as_deref()) .unwrap_or_default(); } let Some(diff) = self.diff.as_ref() else { return &[]; }; let buffer = if focus == Focus::After && diff.after_editable() { diff.live_buffer .and_then(|id| self.buffer(id)) .filter(|buffer| Some(buffer.text_revision) == diff.live_revision) } else { let Some(comparison) = diff.comparison_id else { return &[]; }; std::iter::once(&self.active) .chain(self.parked.values()) .find(|buffer| buffer.diff_snapshot == Some((comparison, diff.file, focus))) }; buffer .and_then(|buffer| buffer.semantic_tokens.as_deref()) .unwrap_or_default() } pub(crate) fn mutation_blocked(&self) -> bool { self.active.resolver_readonly || self.active.diff_snapshot.is_some() || (self.diff().is_some() && !self.diff_editing()) || self.resolvers.values().any(|resolver| { resolver.files.iter().any(|file| { (!file.text || file.whole_file_choice.is_some()) && file.draft_buffer == self.active.id }) }) } fn leave_diff_on_navigation(&mut self) { if self.diff.is_none() || self.diff_owner != Some(self.pane_id) { return; } if self .diff_bound .is_some_and(|bound| bound.3 != self.active.id) { if self.current_review_dirty() { self.bind_diff_buffer(); self.set_message("Save the local review before replacing its comparison"); return; } if let Some(role) = self.active.diff_snapshot { if self .diff .as_ref() .is_none_or(|diff| diff.comparison_id != Some(role.0)) { self.diff_bound = None; self.diff = None; self.diff_owner = None; self.outstanding_diff_request = None; return; } self.diff_bound = Some((role.0, role.1, role.2, self.active.id)); if let Some(diff) = &mut self.diff { if diff.file != role.1 && let Some(path) = self.active.path.as_deref() { diff.select_file(path); } diff.focus = role.2; } } else { self.release_diff_buffers(); self.diff = None; self.diff_owner = None; self.outstanding_diff_request = None; } } } fn release_diff_buffers(&mut self) { if self.active.diff_snapshot.is_some() && let Some(id) = self .diff .as_ref() .and_then(|diff| diff.live_buffer) .or(self.diff_return_buffer) && let Some(buffer) = self.parked.remove(&id) { self.activate(buffer); } self.prune_diff_snapshots(); self.diff_bound = None; self.diff_return_buffer = None; } fn prune_diff_snapshots(&mut self) { self.parked.retain(|id, buffer| { buffer.diff_snapshot.is_none() || self .panes .iter() .any(|(pane_id, pane)| *pane_id != self.pane_id && pane.buffer_id == *id) || buffer.diff_snapshot.is_some_and(|role| { self.tabs.values().any(|tab| { tab.diff() .is_some_and(|diff| diff.comparison_id == Some(role.0)) }) }) }); for pane in self.panes.values_mut() { pane.views .retain(|id, _| *id == self.active.id || self.parked.contains_key(id)); } } fn bind_diff_buffer(&mut self) { if self.diff_prompt_mode.is_some() { return; } if let Some(resolver) = self.resolver() { if resolver.view == ResolverView::Resolution { let focus = self.resolver_diff().map(|diff| diff.focus); let pane = match focus { Some(Focus::Before) => Some(resolver.draft_pane), Some(Focus::After) => resolver.alternative_pane, _ => None, }; if let Some(pane) = pane.filter(|pane| *pane != self.pane_id) { let from = self.resolver_buffer_focus(); let row = self.cursor().row; let wanted = display_column(&self.active.lines[row], self.cursor().column); let synchronized = from.zip(focus).and_then(|(from, to)| { self.resolver_diff()?.corresponding_row(from, to, row) }); self.select_pane(pane); if let Some(row) = synchronized { let row = row.min(self.active.lines.len() - 1); let column = column_at_display( &self.active.lines[row], wanted, self.max_column(row), ); self.active.view.targets = vec![Target::caret(Cursor { row, column })]; self.active.view.primary = 0; } } } return; } let Some(diff) = &self.diff else { return }; if diff.phase != Phase::View || !diff.selected_file_loaded() { return; } let Some(comparison_id) = diff.comparison_id else { return; }; if diff.pane_layout() == crate::diff::PaneLayout::Empty || (diff.rows.is_empty() && !diff.after_editable()) { if self.active.diff_snapshot.is_some() && let Some(buffer) = self .diff_return_buffer .and_then(|id| self.parked.remove(&id)) { let palette = self.palette.take(); self.activate(buffer); self.palette = palette; } self.diff_bound = None; return; } let Some(comparison) = &diff.comparison else { return; }; let Some(file) = comparison.files.get(diff.file) else { return; }; let focus = if diff.focus == Focus::Files { self.diff_buffer_focus().unwrap_or_else(|| { if diff.after_editable() { Focus::After } else { diff.first_text_focus() } }) } else { diff.focus }; let key = (comparison_id, diff.file, focus); let writable = focus == Focus::After && diff.after_editable(); if self.diff_bound.is_some_and(|(id, file, side, buffer)| { (id, file, side) == key && buffer == self.active.id && if writable { self.active.diff_snapshot.is_none() && diff.live_buffer == Some(self.active.id) } else { self.active.diff_snapshot == Some(key) } }) { return; } let synchronized = self.diff_bound.and_then(|(id, file, from, buffer)| { if id != comparison_id || file != diff.file || from == focus || buffer != self.active.id { return None; } let target = self.active.view.targets[self.active.view.primary]; let column = target .preferred_columns .map(|columns| columns.1) .unwrap_or_else(|| { display_column(&self.active.lines[target.active.row], target.active.column) }); diff.corresponding_row(from, focus, target.active.row) .map(|row| (row, column)) }); // Binding a comparison side is not a user departure from its palette. let palette = self.palette.take(); if writable { let path = comparison.root.join(&file.path); let root = comparison.root.canonicalize(); let opened = (|| -> io::Result<()> { let metadata = fs::symlink_metadata(&path)?; if !metadata.is_file() { return Err(io::Error::other("Working file is not a regular file")); } let identity = path.canonicalize()?; if !identity.starts_with(root?) { return Err(io::Error::other("Working file escapes repository root")); } if self.active.identity.as_ref() != Some(&identity) { self.edit_buffer(path, false)?; } Ok(()) })(); if let Err(error) = opened { let diff = self.diff.as_mut().expect("diff remains open"); if let Some(comparison) = &mut diff.comparison { comparison .editable_paths .remove(&comparison.files[diff.file].path); } diff.live_buffer = None; diff.live_revision = None; diff.error = Some(format!("Open failed: {error}")); self.palette = palette; self.bind_diff_buffer(); return; } let diff = self.diff.as_mut().expect("diff remains open"); diff.live_buffer = Some(self.active.id); diff.live_revision = None; } else { let existing = std::iter::once(&self.active) .chain(self.parked.values()) .find(|buffer| buffer.diff_snapshot == Some(key)) .map(|buffer| buffer.id); if let Some(id) = existing { self.switch_buffer(id); } else { let buffer = self.new_diff_snapshot(key.1, focus); self.add_buffer(buffer); } } self.diff_bound = Some((key.0, key.1, key.2, self.active.id)); self.palette = palette; if let Some((row, wanted)) = synchronized { let row = row.min(self.active.lines.len() - 1); let cursor = Cursor { row, column: column_at_display(&self.active.lines[row], wanted, self.max_column(row)), }; let primary = self.active.view.primary; self.active.view.targets[primary].active = cursor; if self.active.view.mode != Mode::Visual { self.active.view.targets[primary].anchor = cursor; } self.active.view.targets[primary].preferred_columns = Some((wanted, wanted)); self.active.view.search_match = None; self.normalize_targets(); } self.diff.as_mut().expect("diff remains open").follow_caret = true; } fn new_diff_snapshot(&mut self, file_index: usize, focus: Focus) -> Buffer { let side = usize::from(focus == Focus::After); let diff = self.diff.as_ref().expect("snapshot comparison"); let comparison = diff.comparison.as_ref().expect("snapshot comparison"); let file = &comparison.files[file_index]; let relative = if focus == Focus::Before { file.old_path.as_ref().unwrap_or(&file.path) } else { &file.path }; let source = comparison.root.join(relative); let root = comparison.root.clone(); let blame = comparison.blame[side].as_ref().map(|baseline| { Arc::new(crate::blame::RevisionSource { store: baseline.store.clone(), commit: baseline.commit.clone(), keepalive: baseline.keepalive.clone(), path: relative.clone(), }) }); let lines = diff.snapshot_lines_at(file_index, focus); let key = ( diff.comparison_id.expect("snapshot comparison"), file_index, focus, ); let directory = diff.snapshot_roots[side].clone().unwrap_or_else(|| { loop { let nonce = TEMP_ID.fetch_add(1, Ordering::Relaxed); let directory = root.join(format!(".vamp-readonly-{}-{nonce}", std::process::id())); if !directory.exists() && !std::iter::once(&self.active) .chain(self.parked.values()) .any(|buffer| { buffer .identity .as_ref() .is_some_and(|path| path.starts_with(&directory)) || buffer .document() .path .is_some_and(|path| path.starts_with(&directory)) }) { break directory; } } }); let document_path = directory.join(relative); self.diff .as_mut() .expect("snapshot comparison") .snapshot_roots[side] = Some(directory); let mut buffer = Buffer::open(self.next_buffer_id, None, None, false).expect("unnamed snapshot"); buffer.diff_snapshot = Some(key); buffer.path = Some(source); buffer.snapshot_document_path = Some(document_path); buffer.snapshot_root = Some(root); buffer.snapshot_blame = blame; buffer.lines = lines; buffer.dirty_cache.set(None); buffer } pub(crate) fn refresh_live_diff(&mut self) { self.refresh_resolver_comparison(); for (diff, parked, review) in std::iter::once(( &mut self.diff, &mut self.parked_diff, &mut self.local_review, )) .chain( self.tabs .values_mut() .map(|tab| (&mut tab.diff, &mut tab.parked_diff, &mut tab.local_review)), ) { let Some(diff) = diff.as_mut().or(parked.as_mut()) else { continue; }; let buffer = diff.live_buffer.and_then(|id| { if id == self.active.id { Some(&self.active) } else { self.parked.get(&id) } }); if let Some(buffer) = buffer && diff.live_after_available() && diff.live_revision != Some(buffer.text_revision) { diff.refresh_after(&buffer.lines, buffer.text_revision, review); diff.follow_caret = true; } } if let Err(error) = self.autosave_local_review() { self.set_message(format!("Review not saved: {error}")); } let Some(focus) = self.diff_buffer_focus() else { return; }; let mut moved = false; for index in 0..self.active.view.targets.len() { let cursor = self.active.view.targets[index].active; let Some(row) = self.diff().unwrap().clamp_source_row(focus, cursor.row) else { continue; }; if row != cursor.row { self.active.view.targets[index].active = Cursor { row, column: cursor.column.min(self.max_column(row)), }; self.active.view.targets[index].preferred_columns = None; moved = true; } } if moved { self.active.view.search_match = None; self.normalize_targets(); } } pub(crate) fn scroll_diff(&mut self, focus: Focus, down: bool) { self.pane_close_picker_origin = None; if self.diff().is_some_and(|diff| diff.focus != focus) { self.pending_lsp_navigation = None; } if let Some(owner) = self.diff_owner.filter(|owner| *owner != self.pane_id) { self.with_pane(owner, |editor| editor.scroll_diff(focus, down)); return; } if let Some(diff) = self.picker_diff_mut() && diff.phase == Phase::View { diff.focus = focus; diff.normalize_focus(); diff.follow_caret = false; if let Some(files) = &mut diff.files { files.focused = diff.files_visible && diff.focus == Focus::Files; } diff.navigate(down); } self.bind_diff_buffer(); self.refresh_live_diff(); if let Some(diff) = self.picker_diff_mut() { diff.follow_caret = false; } } pub(crate) fn scroll_diff_horizontal(&mut self, focus: Focus, right: bool, width: usize) { self.pane_close_picker_origin = None; if let Some(owner) = self.diff_owner.filter(|owner| *owner != self.pane_id) { self.with_pane(owner, |editor| { editor.scroll_diff_horizontal(focus, right, width) }); return; } if let Some(diff) = self.interaction_diff_mut() && diff.phase == Phase::View { diff.scroll_horizontal(focus, right, width); } } pub(crate) fn set_diff_viewport( &mut self, comparison_id: u64, file: usize, top: usize, left_before: usize, left_after: usize, ) { if self.resolver().is_some() { if let Some(diff) = self.resolver_diff_mut() && diff.comparison_id == Some(comparison_id) && diff.file == file { diff.top = top; diff.left_before = left_before; diff.left_after = left_after; } return; } if let Some(diff) = self.diff.as_mut().or(self.parked_diff.as_mut()) && diff.comparison_id == Some(comparison_id) && diff.file == file { diff.top = top; diff.left_before = left_before; diff.left_after = left_after; } } fn handle_diff(&mut self, input: Input) -> Option { if self.palette.is_some() || self.diff_prompt_mode.is_some() || self.startup_diagnostic.is_some() || self.hover.is_some() { return self.handle_input(input); } let phase = self.picker_diff().expect("active diff").phase; let files_focused = phase == Phase::View && self.picker_diff().unwrap().focus == Focus::Files; if phase != Phase::View && self.picker_diff().unwrap().pull_request_remote.is_some() && (self.picker_diff().unwrap().pull_request_menu.is_some() || matches!(input, Input::Ctrl('s' | 'f'))) { if !matches!( input, Input::Char('0'..='9' | ' ') | Input::Ctrl('s' | 'f') | Input::Escape | Input::Enter | Input::Left | Input::Right ) && BackspaceKind::from_input(&input).is_none() && self.handle_selection_binding(&input) { return None; } let diff = self.picker_diff_mut().unwrap(); diff.handle(input); if std::mem::take(&mut diff.pull_request_preferences_changed) { let preferences = diff.pull_request_preferences.clone(); self.pull_request_preferences_changed |= self.pull_request_preferences.sort != preferences.sort; self.pull_request_preferences = preferences.clone(); for diff in self .diff .iter_mut() .chain(self.parked_diff.iter_mut()) .chain( self.tabs .values_mut() .flat_map(|tab| tab.diff.iter_mut().chain(tab.parked_diff.iter_mut())), ) .filter(|diff| diff.pull_request_remote.is_some()) { diff.set_pull_request_preferences(preferences.clone()); } } return None; } if phase != Phase::View && input == Input::Escape { return self.execute_action(Action::DiffClose.entry(), ""); } let text_focused = phase != Phase::View || (files_focused && self .picker_diff() .and_then(|diff| diff.files.as_ref()) .is_some_and(|files| files.searching || files.view == FileView::List)); if text_focused && self.sequence_pending().is_none() && matches!(input, Input::Char(ch) if !ch.is_control()) { if !self.handle_selection_binding(&input) { self.picker_diff_mut().unwrap().handle(input); } return None; } if self .bindings .get(&self.binding_mode()) .and_then(|keys| keys.get(&input)) .is_some_and(|binding| { matches!( binding, Binding::Action( Action::PaneSplitHorizontal | Action::PaneSplitVertical | Action::PaneFocusLeft | Action::PaneFocusDown | Action::PaneFocusUp | Action::PaneFocusRight | Action::PaneClose | Action::PaneForceClose | Action::TabNew | Action::TabPrevious | Action::TabNext | Action::TabClose | Action::TabForceClose ) ) }) { return self.handle_input(input); } if (self.diff_interacting() && self.active.view.mode != Mode::Normal) || self.count.is_some() || self.sequence_pending().is_some() || self.g_pending || self.line_pending.is_some() || self.seek_pending.is_some() { return self.handle_input(input); } if self.diff_interacting() { if matches!(input, Input::Char('[' | ']')) { let focus = self.diff_buffer_focus().unwrap(); let row = self.cursor().row; self.interaction_diff_mut().unwrap().expand_source_excerpt( focus, input == Input::Char(']'), row, ); return None; } if !matches!(input, Input::Char('\t') | Input::BackTab) { return self.handle_input(input); } } if files_focused && input == Input::Ctrl('w') && input != self.leader && !self.has_sequence_prefix(self.binding_mode(), &input) { self.close_file_picker(); return None; } if files_focused && self.handle_tree_binding(&input) { return None; } if !self.diff_interacting() && self.handle_selection_binding(&input) { return None; } if files_focused && input == Input::Char('c') && self .picker_diff() .and_then(|diff| diff.files.as_ref()) .is_some_and(|files| files.view == FileView::Tree && !files.searching) && input != self.leader && !self.has_sequence_prefix(self.binding_mode(), &input) && !self .bindings .get(&self.binding_mode()) .is_some_and(|keys| keys.contains_key(&input)) { return self.execute_action(Action::DiffReviewToggle.entry(), ""); } if self.sequence_pending().is_some() || (phase == Phase::View && (input == self.leader || self.has_sequence_prefix(self.binding_mode(), &input))) || self .bindings .get(&self.binding_mode()) .and_then(|keys| keys.get(&input)) .is_some_and(|binding| { matches!( binding, Binding::Command(_) | Binding::Action( Action::Command | Action::Palette | Action::FilePicker | Action::FileExplorer | Action::PullRequests | Action::DiffClose | Action::DiffModeToggle | Action::DiffViewToggle | Action::DiffOrientationToggle | Action::DiffReviewToggle | Action::Comment | Action::ReviewEdit | Action::ReviewDelete | Action::ReviewResolve | Action::ReviewUnresolve | Action::ReviewNext | Action::ReviewPrevious | Action::ReviewOverview | Action::ReviewCopy | Action::ReviewSummary | Action::ReviewVerdict | Action::ReviewSave | Action::ReviewOpen | Action::ReviewNew | Action::WordWrapToggle | Action::GitBlameToggle | Action::Quit | Action::ForceQuit | Action::Restart | Action::ForceRestart | Action::Plugins | Action::Buffers ) ) || (input != Input::Char('\t') && matches!( binding, Binding::Action(Action::JumpBack | Action::JumpForward) )) }) { return self.handle_input(input); } let escape = input == Input::Escape; let return_focus = files_focused .then(|| { self.diff_buffer_focus() .or(self.diff_bound.map(|bound| bound.2)) }) .flatten(); self.picker_diff_mut().unwrap().handle(input); if files_focused && escape && self.picker_diff().unwrap().focus != Focus::Files && let Some(focus) = return_focus { let diff = self.picker_diff_mut().unwrap(); diff.focus = focus; diff.normalize_focus(); } None } fn buffer(&self, id: usize) -> Option<&Buffer> { if id == self.active.id { Some(&self.active) } else { self.parked.get(&id) } } pub fn semantic_tokens(&self) -> &[SemanticToken] { self.active.semantic_tokens.as_deref().unwrap_or_default() } pub(crate) fn validate_semantic_tokens( &self, buffer_id: usize, revision: u64, tokens: Option<&[SemanticToken]>, ) -> Result { let Some(buffer) = self.buffer(buffer_id) else { return Ok(false); }; if buffer.text_revision != revision { return Ok(false); } let Some(tokens) = tokens else { return Ok(true); }; if tokens.len() > MAX_EDIT_BYTES / std::mem::size_of::() { return Err("Semantic tokens exceed 4 MiB".into()); } let mut previous = None; let mut row_length = None; for token in tokens { if previous.is_some_and(|start| token.start < start) { return Err("Semantic token starts must be sorted".into()); } if token.start.row != token.end.row || token.start >= token.end { return Err("Semantic tokens must have nonempty single-line ranges".into()); } if token.modifiers & !1023 != 0 { return Err("Semantic token modifiers are not canonical".into()); } if row_length.is_none_or(|(row, _)| row != token.start.row) { let line = buffer .lines .get(token.start.row) .ok_or("Semantic token position is outside the document")?; row_length = Some((token.start.row, grapheme_count(line))); } if token.end.column > row_length.unwrap().1 { return Err("Semantic token position is outside the document".into()); } previous = Some(token.start); } Ok(true) } pub fn replace_semantic_tokens( &mut self, buffer_id: usize, revision: u64, tokens: Option>, ) -> Result { if !self.validate_semantic_tokens(buffer_id, revision, tokens.as_deref())? { return Ok(false); } Ok(self.replace_validated_semantic_tokens(buffer_id, revision, tokens)) } /// Only session's fully prevalidated batch may use this storage path. pub(crate) fn replace_validated_semantic_tokens( &mut self, buffer_id: usize, revision: u64, tokens: Option>, ) -> bool { let buffer = if buffer_id == self.active.id { &mut self.active } else if let Some(buffer) = self.parked.get_mut(&buffer_id) { buffer } else { return false; }; if buffer.text_revision != revision { return false; } let tokens = tokens.filter(|tokens| !tokens.is_empty()); match (&buffer.semantic_tokens, &tokens) { (None, None) => return false, (Some(current), Some(next)) if Arc::ptr_eq(current, next) => return false, _ => {} } buffer.semantic_tokens = tokens; true } pub fn clear_semantic_tokens(&mut self) { self.active.semantic_tokens = None; for buffer in self.parked.values_mut() { buffer.semantic_tokens = None; } } pub fn diagnostics(&self) -> impl Iterator { self.active .diagnostics .values() .filter(|annotations| { self.active.view.mode != Mode::Insert && annotations.revision == self.active.text_revision }) .flat_map(|annotations| &annotations.items) } pub fn diagnostic_at_cursor(&self) -> Option<&Diagnostic> { let cursor = self.cursor(); let position = TextPosition { row: cursor.row, column: cursor.column, }; self.diagnostics().find(|diagnostic| { diagnostic.start <= position && (position < diagnostic.end || (diagnostic.start == diagnostic.end && position == diagnostic.start)) }) } pub(crate) fn validate_diagnostics( &self, buffer_id: usize, revision: u64, items: &[Diagnostic], ) -> Result { let Some(buffer) = self.buffer(buffer_id) else { return Ok(false); }; if buffer.text_revision != revision { return Ok(false); } let mut bytes = 0usize; for diagnostic in items { for position in [diagnostic.start, diagnostic.end] { if buffer .lines .get(position.row) .is_none_or(|line| position.column > grapheme_count(line)) { return Err("Diagnostic position is outside the document".into()); } } if diagnostic.end < diagnostic.start { return Err("Diagnostic range is reversed".into()); } if diagnostic.message.chars().any(char::is_control) { return Err("Diagnostic messages cannot contain controls".into()); } bytes = bytes .checked_add(diagnostic.message.len()) .ok_or("Diagnostics exceed 4 MiB")?; if bytes > MAX_EDIT_BYTES { return Err("Diagnostics exceed 4 MiB".into()); } } Ok(true) } pub fn replace_diagnostics( &mut self, owner: &str, buffer_id: usize, revision: u64, items: Vec, ) -> Result { if !self.validate_diagnostics(buffer_id, revision, &items)? { return Ok(false); } Ok(self.replace_validated_diagnostics(owner, buffer_id, revision, items)) } /// Only session's fully prevalidated batch may use this storage path. pub(crate) fn replace_validated_diagnostics( &mut self, owner: &str, buffer_id: usize, revision: u64, items: Vec, ) -> bool { let buffer = if buffer_id == self.active.id { &mut self.active } else if let Some(buffer) = self.parked.get_mut(&buffer_id) { buffer } else { return false; }; if buffer.text_revision != revision { return false; } if items.is_empty() { return buffer.diagnostics.remove(owner).is_some(); } if buffer.diagnostics.get(owner).is_some_and(|annotations| { annotations.revision == revision && annotations.items == items }) { return false; } buffer .diagnostics .insert(owner.to_owned(), AnnotationSet { revision, items }); true } pub fn clear_plugin_diagnostics(&mut self, owner: &str) { self.active.diagnostics.remove(owner); for buffer in self.parked.values_mut() { buffer.diagnostics.remove(owner); } } /// Reuses loaded (including dirty) buffers and leaves the source unchanged on failure. pub fn navigate_to(&mut self, path: PathBuf, position: TextPosition) -> Result<(), String> { self.pane_close_picker_origin = None; self.pending_lsp_navigation = None; self.finish_completion(); self.snippet = None; let source = self.jump_location(); self.cancel_sequence(); self.clear_hover(); if path.as_os_str().is_empty() || path.to_string_lossy().chars().any(char::is_control) { return Err("Navigation path must be nonempty and contain no controls".into()); } let snapshot = std::iter::once(&self.active) .chain(self.parked.values()) .find(|buffer| buffer.snapshot_document_path.as_ref() == Some(&path)) .map(|buffer| buffer.id); if let Some(id) = snapshot { self.switch_buffer(id); } else { self.edit_buffer(path, false) .map_err(|error| error.to_string())?; } self.place_navigation(position); self.record_jump(source); self.leave_diff_on_navigation(); Ok(()) } /// Native LSP positions are UTF-16; plugin navigation remains grapheme-based. pub(crate) fn navigate_lsp_to( &mut self, path: PathBuf, range: Utf16Range, target_range: Option, ) -> Result<(), String> { self.pending_lsp_navigation = None; self.active.view.lsp_center.set(None); if path.as_os_str().is_empty() || path.to_string_lossy().chars().any(char::is_control) { return Err("Navigation path must be nonempty and contain no controls".into()); } if let Some((file, focus)) = self.lsp_diff_target(&path)? { if !self .diff .as_ref() .expect("comparison target") .comparison .as_ref() .expect("comparison target") .files[file] .loaded { let bound = self .diff_bound .ok_or("LSP navigation requires a diff text pane")?; self.pending_lsp_navigation = Some(PendingLspNavigation { tab: self.tab_id, pane: self.pane_id, source: self.jump_location(), revision: self.text_revision(), bound, file, focus, range, target_range, request: None, }); self.diff .as_mut() .expect("comparison target") .load_file(file); return Ok(()); } return self.navigate_lsp_file(file, focus, range, target_range); } let (buffer, id) = self.preflight_lsp_buffer(path)?; let lines = buffer .as_ref() .unwrap_or_else(|| self.buffer(id).expect("loaded target")); let position = lsp_navigation_position(&lines.lines, range, target_range)?; self.finish_lsp_navigation(buffer, id, position, self.diff.is_some()); Ok(()) } fn lsp_diff_target(&self, path: &Path) -> Result, String> { let Some(diff) = self.diff.as_ref().filter(|diff| diff.phase == Phase::View) else { return Ok(None); }; let Some(comparison) = &diff.comparison else { return Ok(None); }; let identity = path_identity(path).map_err(|error| error.to_string())?; let virtual_side = diff .snapshot_roots .iter() .enumerate() .find_map(|(side, root)| { let root = path_identity(root.as_ref()?).ok()?; identity.strip_prefix(root).ok().map(|relative| { ( if side == 0 { Focus::Before } else { Focus::After }, relative.to_path_buf(), ) }) }); let focus = virtual_side.as_ref().map_or_else( || self.diff_buffer_focus().unwrap_or(Focus::After), |(focus, _)| *focus, ); let found = comparison .files .iter() .enumerate() .find_map(|(index, file)| { let relative = if focus == Focus::Before { file.old_path.as_ref().unwrap_or(&file.path) } else { &file.path }; let matches = if let Some((_, target)) = &virtual_side { relative == target } else { path_identity(&comparison.root.join(relative)).ok().as_ref() == Some(&identity) }; matches.then_some((index, focus)) }); if virtual_side.is_some() && found.is_none() { return Err("Historical navigation target is not available in this comparison".into()); } Ok(found) } fn lsp_navigation_current(&self, pending: &PendingLspNavigation) -> bool { self.tab_id == pending.tab && self.pane_id == pending.pane && self.jump_location() == pending.source && self.text_revision() == pending.revision && self.diff_bound == Some(pending.bound) && self.diff_interacting() && self.diff.as_ref().is_some_and(|diff| { diff.comparison_id == Some(pending.bound.0) && diff.file == pending.bound.1 }) } fn preflight_lsp_buffer(&self, path: PathBuf) -> Result<(Option, usize), String> { let identity = path_identity(&path).map_err(|error| error.to_string())?; let loaded = std::iter::once(&self.active) .chain(self.parked.values()) .find(|buffer| { buffer.identity.as_ref() == Some(&identity) || buffer .snapshot_document_path .as_ref() .is_some_and(|snapshot| { path_identity(snapshot).ok().as_ref() == Some(&identity) }) }); if let Some(buffer) = loaded { return Ok((None, buffer.id)); } let file = fs::File::open(&path).map_err(|error| error.to_string())?; let metadata = file.metadata().map_err(|error| error.to_string())?; if !metadata.is_file() { return Err("Definition target is not a regular file".into()); } if metadata.len() > MAX_EDIT_BYTES as u64 { return Err("Definition target exceeds 4 MiB".into()); } let mut text = String::new(); file.take((MAX_EDIT_BYTES + 1) as u64) .read_to_string(&mut text) .map_err(|error| error.to_string())?; if text.len() > MAX_EDIT_BYTES { return Err("Definition target exceeds 4 MiB".into()); } let buffer = Buffer::from_text( self.next_buffer_id, Some(path), Some(identity), &text, Some(FileStamp::from_metadata(&metadata).map_err(|error| error.to_string())?), ) .map_err(|error| error.to_string())?; Ok((Some(buffer), self.next_buffer_id)) } fn navigate_lsp_file( &mut self, file_index: usize, focus: Focus, range: Utf16Range, target_range: Option, ) -> Result<(), String> { let diff = self.diff.as_ref().ok_or("Comparison is closed")?; let comparison = diff.comparison.as_ref().ok_or("Comparison is closed")?; let file = comparison .files .get(file_index) .ok_or("Changed file is no longer available")?; if matches!( (file.status, focus), (crate::diff::FileStatus::Added, Focus::Before) | (crate::diff::FileStatus::Removed, Focus::After) ) { return Err("Definition target does not exist on this comparison side".into()); } if file .notice .as_deref() .is_some_and(|notice| notice.contains("Binary or non-UTF-8")) { return Err("Definition target is binary or non-UTF-8".into()); } let writable = focus == Focus::After && comparison.editable_paths.contains(&file.path) && file.status != crate::diff::FileStatus::Removed && file .notice .as_deref() .is_none_or(|notice| !notice.contains("Binary or non-UTF-8")); let key = ( diff.comparison_id.ok_or("Comparison is closed")?, file_index, focus, ); let (buffer, id) = if writable { self.preflight_lsp_buffer(comparison.root.join(&file.path))? } else if let Some(buffer) = std::iter::once(&self.active) .chain(self.parked.values()) .find(|buffer| buffer.diff_snapshot == Some(key)) { (None, buffer.id) } else { let buffer = self.new_diff_snapshot(file_index, focus); (Some(buffer), self.next_buffer_id) }; let target = buffer .as_ref() .unwrap_or_else(|| self.buffer(id).expect("loaded target")); let position = lsp_navigation_position(&target.lines, range, target_range)?; let row = position.row; let extent = target_range.unwrap_or(range); let first = extent.start.0; let last = extent.end.0 - usize::from(extent.end.1 == 0 && extent.end.0 > extent.start.0); let diff = self.diff.as_ref().expect("comparison target"); let comparison = diff.comparison.as_ref().expect("comparison target"); let file = &comparison.files[file_index]; let live = if writable { Some(target) } else if focus == Focus::Before && comparison.editable_paths.contains(&file.path) { let identity = path_identity(&comparison.root.join(&file.path)).ok(); std::iter::once(&self.active) .chain(self.parked.values()) .find(|buffer| identity.is_some() && buffer.identity == identity) } else { None }; let live_id = live.map(|buffer| buffer.id); let changed = if let Some(live) = live.filter(|live| { diff.file != file_index || diff.live_buffer != Some(live.id) || diff.live_revision != Some(live.text_revision) }) { let eof = live.lines.len() > 1 && live.lines.last().is_some_and(String::is_empty); let content = &live.lines[..live.lines.len() - usize::from(eof)]; crate::diff::line_hunks(&file.before, file.before_eof, content, eof) .iter() .any(|hunk| { let (start, len) = if focus == Focus::Before { (hunk.before_start, hunk.before_len) } else { (hunk.after_start, hunk.after_len) }; len > 0 && start <= last && first < start.saturating_add(len) }) } else { diff.source_range_changed(file_index, focus, first, last) }; if !changed { self.finish_lsp_navigation(buffer, id, position, true); return Ok(()); } let source = self.jump_location(); if let Some(buffer) = buffer { self.add_buffer(buffer); } else { self.switch_buffer(id); } let diff = self.diff.as_mut().expect("comparison target"); if diff.file != file_index { diff.select_file_index(file_index); } diff.focus = focus; diff.normalize_focus(); if let Some(live) = live_id { diff.live_buffer = Some(live); diff.live_revision = None; } self.diff_bound = Some((key.0, key.1, key.2, id)); self.refresh_live_diff(); let diff = self.diff.as_mut().expect("comparison target"); diff.focus = focus; diff.normalize_focus(); diff.reveal_source_row(focus, row); self.place_navigation(position); self.request_lsp_center(); self.record_jump(source); Ok(()) } fn finish_lsp_navigation( &mut self, buffer: Option, id: usize, position: TextPosition, new_tab: bool, ) { let source = self.jump_location(); if new_tab { self.new_tab(); } if let Some(buffer) = buffer { self.add_buffer(buffer); } else { self.switch_buffer(id); } self.place_navigation(position); self.request_lsp_center(); self.record_jump(source); } fn place_navigation(&mut self, position: TextPosition) { self.reveal_caret(); self.park_source(); self.active.view.mode = Mode::Normal; let row = position.row.min(self.active.lines.len() - 1); let column = position .column .min(grapheme_count(&self.active.lines[row]).saturating_sub(1)); self.active.view.targets = vec![Target::caret(Cursor { row, column })]; self.active.view.primary = 0; self.active.view.search_match = None; self.active.view.visual_motions = None; self.normalize_targets(); } fn follow_link(&mut self) -> Result<(), String> { let departure = self.jump_location(); if self .active .path .as_deref() .is_some_and(|path| !crate::syntax::is_markdown_path(path)) { return Err("follow_link requires a Markdown document".into()); } let source = self.active.lines.join("\n"); let cursor = self.cursor(); let target = crate::markdown::link_at( &source, TextPosition { row: cursor.row, column: cursor.column, }, )?; let Some(path) = target.path else { let row = match target.fragment { Some(fragment) => crate::markdown::heading_row(&source, &fragment)?, None => 0, }; self.place_navigation(TextPosition { row, column: 0 }); self.record_jump(departure); return Ok(()); }; let path = if path.is_absolute() { path } else { let source_path = self .active .identity .as_deref() .or(self.active.path.as_deref()) .ok_or("Relative links require a named Markdown document")?; source_path .parent() .ok_or("Markdown document has no parent directory")? .join(path) }; if path.to_string_lossy().chars().any(char::is_control) { return Err("Link path cannot contain control characters".into()); } let identity = path_identity(&path).map_err(|error| error.to_string())?; let heading_row = |buffer: &Buffer| { target.fragment.as_deref().map_or(Ok(0), |fragment| { crate::markdown::heading_row(&buffer.lines.join("\n"), fragment) }) }; let loaded = std::iter::once(&self.active) .chain(self.parked.values()) .find(|buffer| buffer.identity.as_ref() == Some(&identity)); let row = if let Some(buffer) = loaded { let row = heading_row(buffer)?; let id = buffer.id; self.switch_buffer(id); row } else { let buffer = Buffer::open(self.next_buffer_id, Some(path), Some(identity), false) .map_err(|error| format!("Cannot open link target: {error}"))?; let row = heading_row(&buffer)?; self.add_buffer(buffer); row }; self.place_navigation(TextPosition { row, column: 0 }); self.record_jump(departure); Ok(()) } pub fn lines(&self) -> &[String] { &self.active.lines } /// Changes only when this buffer's text changes, independent of cursor/view state. pub fn text_revision(&self) -> u64 { self.active.text_revision } pub fn cursor(&self) -> Cursor { self.active.view.targets[self.active.view.primary].active } /// Navigation requests caret following even when its logical position stays unchanged. pub(crate) fn caret_reveal_revision(&self) -> u64 { self.active.view.caret_reveal_revision } fn request_lsp_center(&self) { self.active.view.lsp_center.set(Some(( self.text_revision(), self.caret_reveal_revision(), self.cursor(), ))); } /// Consume only when the destination has a usable text viewport. pub(crate) fn take_lsp_center(&self) -> bool { self.active.view.lsp_center.take() == Some(( self.text_revision(), self.caret_reveal_revision(), self.cursor(), )) } pub(crate) fn take_viewport_scroll(&self, height: usize) -> isize { let (rows, steps) = self.active.view.viewport_scroll.take(); let lines = self .scroll_step_lines .map_or((height / 2).max(1), NonZeroUsize::get); let lines = isize::try_from(lines).unwrap_or(isize::MAX); rows.saturating_add(steps.saturating_mul(lines)) } fn reveal_caret(&mut self) { self.active.view.lsp_center.set(None); self.active.view.viewport_scroll.set((0, 0)); self.active.view.caret_reveal_revision = self.active.view.caret_reveal_revision.wrapping_add(1); } pub fn mode(&self) -> Mode { self.active.view.mode } /// Read-only display state for the overlay; opening it does not change mode. pub fn palette(&self) -> Option<&Palette> { self.palette.as_ref() } pub fn startup_diagnostic(&self) -> Option<&StartupDiagnostic> { self.startup_diagnostic.as_ref() } pub(crate) fn show_startup_diagnostic(&mut self, text: String) { self.finish_completion(); self.snippet = None; let lines = split_lines(&text); let max_column = lines .iter() .map(|line| line.graphemes(true).count()) .max() .unwrap_or(0); self.startup_diagnostic = Some(StartupDiagnostic { lines, top: 0, left: 0, max_column, }); } /// Borrowed live-completion state; original document bookkeeping stays private. pub fn completion(&self) -> Option<&Completion> { self.completion.as_ref().map(|state| &state.popup) } /// End-exclusive ranges of the active argument, including its mirrored occurrences. pub fn snippet_selections(&self) -> &[Selection] { self.snippet .as_ref() .map_or(&[], |state| state.selections.as_slice()) } /// Validates the entire response before changing text, history, or popup state. pub fn validate_completion(&self, items: &[CompletionItem]) -> Result<(), String> { validate_completion_items(&self.active.lines, items) } pub fn show_completion(&mut self, mut items: Vec) -> Result<(), String> { if self.mutation_blocked() { return Err("Diff pane is read-only".into()); } if self.mode() != Mode::Insert || self.palette.is_some() || self.startup_diagnostic.is_some() || self .file_picker .as_ref() .is_some_and(|picker| picker.focused) { return Err("Completion requires Insert editor focus".into()); } self.validate_completion(&items)?; self.finish_completion(); self.snippet = None; if items.is_empty() { self.set_message("No completions"); return Ok(()); } for item in &mut items { for edit in &mut item.edits { if let Cow::Owned(normalized) = normalize_newlines(&edit.text) { edit.text = normalized; } } } let original = self.snapshot(); self.preview_completion(&original, &items[0]); self.completion = Some(CompletionState { popup: Completion { items, selected: 1 }, original, }); Ok(()) } fn preview_completion(&mut self, original: &Snapshot, item: &CompletionItem) { let offsets = line_offsets(&original.lines); let mut edits = completion_edits(&original.lines, &offsets, original.primary, item); let primary_start = edits[item.primary_edit].start; edits.sort_by_key(|edit| (edit.start, edit.end)); let start = translate_edit_offset(primary_start, false, edits.iter()); self.snippet = item.snippet.as_ref().map(|snippet| SnippetState { tabstops: snippet .tabstops .iter() .map(|stop| SnippetTabstop { index: stop.index, ancestors: stop.ancestors.clone(), ranges: stop .ranges .iter() .map(|range| start + range.start..start + range.end) .collect(), }) .collect(), active: 0, selected: true, selections: Vec::new(), replacement: start..start + item.edits[item.primary_edit].text.len(), recipe_step: None, }); self.apply_edits_from(edits, Some(original), true); self.select_snippet_stop(); } fn cycle_completion(&mut self, next: bool) { let mut state = self.completion.take().expect("completion is active"); let count = state.popup.items.len() + 1; state.popup.selected = if next { (state.popup.selected + 1) % count } else { (state.popup.selected + count - 1) % count }; if state.popup.selected == 0 { self.snippet = None; if self.active.lines != state.original.lines { self.active.replace_lines(state.original.lines.clone()); self.active.view.search_match = None; } self.clamp_other_views(); self.active.view.targets.clone_from(&state.original.targets); self.active.view.primary = state.original.primary; } else { self.preview_completion( &state.original, &state.popup.items[state.popup.selected - 1], ); } self.completion = Some(state); } /// Retains the displayed candidate and records only its resolved native edits. fn finish_completion(&mut self) { let Some(state) = self.completion.take() else { return; }; if state.popup.selected == 0 || (state.original.lines == self.active.lines && self.snippet.is_none()) { return; } let item = state .popup .items .into_iter() .nth(state.popup.selected - 1) .expect("selected completion exists"); let caret = state.original.targets[state.original.primary].active; let origin = grapheme_offset(&state.original.lines, caret) as isize; let offsets = line_offsets(&state.original.lines); let mut batch = completion_edits( &state.original.lines, &offsets, state.original.primary, &item, ); let start = batch[item.primary_edit].start; batch.sort_by_key(|edit| (edit.start, edit.end)); let start = translate_edit_offset(start, false, batch.iter()); drop(batch); let offsets = line_offsets(&self.active.lines); let caret = cursor_offset(&self.active.lines, &offsets, self.cursor()) - start; if let (Some(snippet), Some(recipe)) = (&mut self.snippet, &self.active.view.pending_edit) { snippet.recipe_step = Some(recipe.steps.len()); } let edits = item .edits .into_iter() .enumerate() .map(|(index, edit)| RelativeCompletionEdit { start: grapheme_offset( &state.original.lines, Cursor { row: edit.start.row, column: edit.start.column, }, ) as isize - origin, end: grapheme_offset( &state.original.lines, Cursor { row: edit.end.row, column: edit.end.column, }, ) as isize - origin, text: edit.text, context: (index != item.primary_edit) .then(|| state.original.lines[edit.start.row..=edit.end.row].to_vec()), }) .collect(); self.push_insert_step(InsertStep::Completion { primary_edit: item.primary_edit, edits, caret, }); } fn select_snippet_stop(&mut self) { let Some(mut state) = self.snippet.take() else { return; }; let offsets = line_offsets(&self.active.lines); let stop = &state.tabstops[state.active]; let cursor = offset_cursor(&self.active.lines, &offsets, stop.ranges[0].start, true); self.active.view.targets[self.active.view.primary] = Target::caret(cursor); state.selected = true; state.selections.clear(); state .selections .extend(stop.ranges.iter().map(|range| Selection { start: offset_cursor(&self.active.lines, &offsets, range.start, true), end: offset_cursor(&self.active.lines, &offsets, range.end, true), })); self.sync_snippet_recipe(&state, false); self.reveal_caret(); if stop.index != 0 { self.snippet = Some(state); } } fn navigate_snippet(&mut self, next: bool) { let Some(state) = &mut self.snippet else { return; }; state.active = if next { (state.active + 1).min(state.tabstops.len() - 1) } else { state.active.saturating_sub(1) }; self.select_snippet_stop(); } fn sync_snippet_recipe(&mut self, state: &SnippetState, update_text: bool) { let Some(step) = state.recipe_step else { return; }; let offsets = line_offsets(&self.active.lines); let caret = cursor_offset(&self.active.lines, &offsets, self.cursor()) .saturating_sub(state.replacement.start); let text = update_text.then(|| byte_range_text(&self.active.lines, &offsets, &state.replacement)); if let Some(InsertStep::Completion { primary_edit, edits, caret: destination, }) = self .active .view .pending_edit .as_mut() .and_then(|recipe| recipe.steps.get_mut(step)) { *destination = caret; if let Some(text) = text { edits[*primary_edit].text = text; } } } // Only the primary argument and its declared mirrors participate in these edits. fn edit_snippet(&mut self, text: &str, deletion: Option) -> bool { let Some(mut state) = self.snippet.take() else { return false; }; let offsets = line_offsets(&self.active.lines); let stop = &state.tabstops[state.active]; let index = stop.index; let argument = stop.ranges[0].clone(); let caret = cursor_offset(&self.active.lines, &offsets, self.cursor()); if caret < argument.start || caret > argument.end { return false; } let mut value = byte_range_text(&self.active.lines, &offsets, &argument); let position = caret - argument.start; let (start, end) = if state.selected { (0, value.len()) } else if let Some(action) = deletion { if let Some(kind) = action.backspace_kind() { (kind.start(&value[..position]), position) } else { let end = value[position..] .graphemes(true) .next() .map_or(position, |grapheme| position + grapheme.len()); (position, end) } } else { (position, position) }; value.replace_range(start..end, text); let mut edits = Vec::with_capacity(stop.ranges.len()); let mut occurrence = 0; while occurrence < stop.ranges.len() { let range = &stop.ranges[occurrence]; let count = if range.is_empty() { stop.ranges[occurrence..] .iter() .take_while(|mirror| *mirror == range) .count() } else { 1 }; edits.push(Edit { target: (occurrence == 0).then_some(self.active.view.primary), start: range.start, end: range.end, text: if count == 1 { Cow::Borrowed(value.as_str()) } else { Cow::Owned(value.repeat(count)) }, caret: start + text.len(), round_up: true, }); occurrence += count; } edits.sort_by_key(|edit| (edit.start, edit.end)); // Retire overwritten nested occurrences, not independent mirrors outside the parent. state.tabstops.retain_mut(|other| { if other.index == index || other.index == 0 { return true; } let mut occurrence = 0; other.ranges.retain(|_| { let keep = !other.ancestors[occurrence].contains(&index); occurrence += 1; keep }); other .ancestors .retain(|ancestors| !ancestors.contains(&index)); !other.ranges.is_empty() }); state.active = state .tabstops .iter() .position(|stop| stop.index == index) .expect("edited stop is retained"); let (before, rest) = state.tabstops.split_at_mut(state.active); let (active, after) = rest.split_first_mut().expect("edited stop is retained"); for stop in before.iter_mut().chain(after) { for range in &mut stop.ranges { let descendant_at = |position| { active .ranges .iter() .zip(&active.ancestors) .any(|(edited, ancestors)| { edited.start == position && range.start <= edited.start && edited.end <= range.end && ancestors.contains(&stop.index) }) }; let descendant = active .ranges .iter() .zip(&active.ancestors) .any(|(edited, ancestors)| { range.start <= edited.start && edited.end <= range.end && ancestors.contains(&stop.index) }); let starts_after = !descendant && (Range::is_empty(range) || edits .iter() .any(|edit| edit.start == edit.end && edit.start == range.start)); let ends_after = Range::is_empty(range) && !descendant || !edits.iter().any(|edit| edit.start == range.end) || descendant_at(range.end); let start = translate_edit_offset(range.start, starts_after, edits.iter()); let end = translate_edit_offset(range.end, ends_after, edits.iter()); *range = start..end; } } let mut previous_empty = None; let mut coincident = 0; for range in &mut active.ranges { if Range::is_empty(range) && previous_empty == Some(range.start) { coincident += 1; } else { coincident = 0; } previous_empty = Range::is_empty(range).then_some(range.start); let start = translate_edit_offset(range.start, false, edits.iter()) + coincident * value.len(); *range = start..start + value.len(); } state.replacement = translate_edit_offset(state.replacement.start, false, edits.iter()) ..translate_edit_offset(state.replacement.end, true, edits.iter()); state.selected = false; self.apply_edits_from(edits, None, true); self.sync_snippet_recipe(&state, true); let offsets = line_offsets(&self.active.lines); // Grapheme merging with literal text can invalidate a stop: keep the text, end navigation. if state.tabstops.iter().any(|stop| { stop.ranges.windows(2).any(|pair| { pair[0].end > pair[1].start || pair[0].start == pair[1].start && (!pair[0].is_empty() || !pair[1].is_empty()) }) }) { return true; } if state .tabstops .iter() .flat_map(|stop| &stop.ranges) .any(|range| { !document_grapheme_boundary(&self.active.lines, &offsets, range.start) || !document_grapheme_boundary(&self.active.lines, &offsets, range.end) }) { return true; } state.selections.clear(); state .selections .extend( state.tabstops[state.active] .ranges .iter() .map(|range| Selection { start: offset_cursor(&self.active.lines, &offsets, range.start, true), end: offset_cursor(&self.active.lines, &offsets, range.end, true), }), ); self.snippet = Some(state); true } /// Borrowed popup state, visible only while its source remains current. pub fn hover(&self) -> Option<&Hover> { self.hover.as_ref().filter(|hover| { hover.buffer_id == self.active.id && hover.revision == self.text_revision() && hover.caret == self.cursor() && self.mode() == Mode::Normal && self.document_actions_available() && self.palette.is_none() && !self .file_picker .as_ref() .is_some_and(|picker| picker.focused) }) } pub fn show_hover(&mut self, text: String) -> Result<(), String> { validate_hover_text(&text)?; if self.mode() != Mode::Normal || !self.document_actions_available() || self.palette.is_some() || self .file_picker .as_ref() .is_some_and(|picker| picker.focused) { return Err("Hover requires Normal editor focus".into()); } self.hover = if text.is_empty() { None } else { let text = match normalize_newlines(&text) { Cow::Borrowed(_) => text, Cow::Owned(normalized) => normalized, }; let mut line_starts = Vec::new(); let mut offset = 0; let mut max_column = 0; for line in text.split('\n') { line_starts.push(offset); offset += line.len() + 1; max_column = max_column.max(grapheme_count(line).saturating_sub(1)); } Some(Hover { text, line_starts, top: 0, left: 0, max_column, buffer_id: self.active.id, revision: self.text_revision(), caret: self.cursor(), }) }; Ok(()) } pub fn clear_hover(&mut self) { self.hover = None; } fn handle_hover(&mut self, input: &Input) -> bool { if self.hover().is_none() { self.clear_hover(); return false; } let hover = self.hover.as_mut().expect("current hover exists"); match input { Input::Up | Input::Ctrl('k') => hover.top = hover.top.saturating_sub(1), Input::Down | Input::Ctrl('j') => { hover.top = hover.top.saturating_add(1).min(hover.line_starts.len() - 1); } Input::Left | Input::Ctrl('h') => hover.left = hover.left.saturating_sub(1), Input::Right | Input::Ctrl('l') => { hover.left = hover.left.saturating_add(1).min(hover.max_column); } Input::Home => hover.left = 0, Input::End => hover.left = hover.max_column, Input::Escape => self.clear_hover(), _ => { self.clear_hover(); return false; } } true } pub fn file_picker(&self) -> Option<&FilePicker> { if let Some(diff) = self.resolver_workspace_diff().or(self.diff.as_ref()) { diff.files.as_ref().filter(|_| diff.files_visible) } else { self.file_picker.as_ref() } } pub(crate) fn normal_file_picker(&self) -> Option<&FilePicker> { self.file_picker.as_ref().filter(|_| self.diff.is_none()) } pub(crate) fn set_picker_repository_changes( &mut self, root: &Path, changes: Vec, ) -> bool { if root != self.project_root { return false; } let mut changed = false; for picker in self.file_picker.iter_mut().chain( self.tabs .values_mut() .filter_map(|tab| tab.file_picker.as_mut()), ) { if picker.root == root { changed |= picker.set_repository_changes(changes.clone()); } } self.picker_repository_changes = changes; changed } 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); } if let Some(picker) = self .picker_diff_mut() .and_then(|diff| diff.files.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; self.picker_repository_changes.clear(); } /// Pending decimal prefix for the next Normal/Visual motion or editing action. pub fn count(&self) -> Option { match self.line_pending { Some((_, prefix)) => Some(prefix.saturating_mul(self.count.unwrap_or(1))) .filter(|count| *count > 1 || self.count.is_some()), None => self.count, } } /// Actual pending key sequence, cached until the next input. pub fn sequence_pending(&self) -> Option<&str> { (!self.sequence_display.is_empty()).then_some(self.sequence_display.as_str()) } /// Whether the built-in `g` navigation prefix is awaiting its second key. pub fn g_pending(&self) -> bool { self.g_pending } /// Native `d`/`c` operator or `r` replacement awaiting its next key. pub fn line_operator_pending(&self) -> Option { self.line_pending.map(|(operator, _)| operator) } /// Native character seek awaiting a literal target. pub fn seek_pending(&self) -> Option { self.seek_pending .map(|(forward, _)| if forward { 'f' } else { 'F' }) } fn clear_sequence(&mut self) { self.g_pending = false; self.line_pending = None; self.seek_pending = None; self.pending_sequence.prefix = None; self.pending_sequence.keys.clear(); self.sequence_display.clear(); } fn cancel_sequence(&mut self) { if self.sequence_pending().is_some() || self.g_pending || self.line_pending.is_some() || self.seek_pending.is_some() { self.count = None; self.clear_sequence(); } } /// Changes the symbolic leader without moving its bindings. pub fn set_leader(&mut self, key: &str) -> Result<(), String> { let leader = parse_key(key)?; if leader == Input::Escape || matches!(leader, Input::Char(ch) if ch.is_ascii_digit()) { return Err("Leader cannot be Escape or an ASCII digit".into()); } if leader != self.leader && [Mode::Normal, Mode::Visual].into_iter().any(|mode| { self.bindings .get(&mode) .is_some_and(|keys| keys.contains_key(&leader)) || self.has_sequence_prefix(mode, &leader) }) { return Err(format!( "Leader key already bound: {}", display_key(&leader) )); } self.cancel_sequence(); self.leader = leader; self.refresh_palette(); Ok(()) } /// Editable Ex/search prompt, without its `:` or `/` prefix. pub fn command_line(&self) -> &str { &self.active.view.command_line } pub fn search_match(&self) -> Option { self.active.view.search_match } pub fn search_matches_on_row(&self, row: usize) -> impl Iterator + '_ { let query = if self.active.view.search_highlight { self.active.view.search_query.as_str() } else { "" }; let line = &self.active.lines[row]; let columns = grapheme_count(query); search_match_bytes(line, query, self.active.view.search_whole_word).map(move |byte| { let column = column_after_byte(line, byte); SearchMatch { start: Cursor { row, column }, end_column: column + columns, } }) } /// Whole-row shape belongs to the active buffer, not the Visual binding context. pub fn visual_linewise(&self) -> bool { (self.active.view.mode == Mode::Visual || (self.active.view.mode == Mode::Command && self.active.view.command_from_visual)) && self.active.view.linewise } /// Inclusive selected rows, including an empty final row with an empty text range. pub fn linewise_rows(&self) -> impl Iterator> + '_ { self.active .view .targets .iter() .filter(|_| self.visual_linewise()) .map(|target| { target.anchor.row.min(target.active.row)..=target.anchor.row.max(target.active.row) }) } /// Ordered Visual ranges, including selections retained by command input. pub fn selections(&self) -> Vec { if self.active.view.mode == Mode::Visual || (self.active.view.mode == Mode::Command && self.active.view.command_from_visual) { self.active .view .targets .iter() .map(|target| self.target_range(*target)) .collect() } else { Vec::new() } } /// All active cursors in document order, including the primary cursor. pub fn carets(&self) -> Vec { self.active .view .targets .iter() .map(|target| target.active) .collect() } pub fn selection_count(&self) -> usize { self.active.view.targets.len() } pub fn message(&self) -> &str { &self.message } pub fn notification_visible(&self) -> bool { self.notification_visible } pub fn top_bar_config(&self) -> &ChromeLine { &self.top_bar } pub fn bottom_bar_config(&self) -> &ChromeLine { &self.bottom_bar } pub fn popup_config(&self) -> &PopupConfig { &self.popup } pub fn set_popup_config(&mut self, popup: PopupConfig) { self.popup = popup; } pub fn label(&self, name: &str) -> &str { self.labels.get(name).map_or("", String::as_str) } pub fn battery_label(&self) -> &str { &self.battery_label } pub(crate) fn shell_label(&self) -> &str { &self.shell_label } pub(crate) fn set_shell_label(&mut self, label: String) -> bool { if self.shell_label == label { return false; } self.shell_label = label; true } pub(crate) fn command_output(&self) -> Option<&ShellOutput> { self.command_output.as_ref() } pub(crate) fn show_command_output( &mut self, command: String, result: Result, ) { let (status, text) = match result { Ok(output) => { let status = output.code.map_or_else( || "terminated by signal".into(), |code| format!("exit {code}"), ); let mut text = String::from_utf8_lossy(&output.stdout).into_owned(); if !output.stderr.is_empty() { if !text.is_empty() && !text.ends_with('\n') { text.push('\n'); } text.push_str("[stderr]\n"); text.push_str(&String::from_utf8_lossy(&output.stderr)); } if text.is_empty() { text.push_str("(no output)"); } (status, text) } Err(error) => ("failed".into(), error), }; // Captured bytes are text, never terminal escape sequences. let lines: Vec = normalize_newlines(&text) .split('\n') .map(|line| { line.chars() .map(|ch| { if ch.is_control() && ch != '\t' { 'ļæ½' } else { ch } }) .collect() }) .collect(); let max_column = lines .iter() .map(|line| grapheme_count(line).saturating_sub(1)) .max() .unwrap_or(0); let command: String = command .chars() .map(|ch| if ch.is_control() { 'ļæ½' } else { ch }) .collect(); let output = ShellOutput { title: format!("{status} Ā· !{command}"), wrapping: false, wrapped_rows: Cell::new(1), wrapped_lines: Default::default(), content: StartupDiagnostic { lines, top: 0, left: 0, max_column, }, }; if self.command_output.is_some() { self.queued_command_outputs.push_back(output); } else { self.command_output = Some(output); } } fn handle_command_output(&mut self, input: &Input) -> bool { if self.command_output.is_none() { return false; } if self.sequence_pending().is_some() { if matches!(input, Input::Escape | Input::Paste(_)) { self.cancel_sequence(); } else { self.handle_sequence(input.clone()); } return true; } let mode = self.binding_mode(); if self.start_sequence(mode, input) { return true; } if matches!( self.bindings.get(&mode).and_then(|keys| keys.get(input)), Some(Binding::Action(Action::WordWrapToggle)) ) { self.act(Action::WordWrapToggle); return true; } let output = self.command_output.as_mut().unwrap(); let rows = if output.wrapping { output.wrapped_rows.get() } else { output.content.lines.len() }; let content = &mut output.content; match input { Input::Escape | Input::Enter => { self.command_output = self.queued_command_outputs.pop_front() } Input::Up | Input::Ctrl('k') => content.top = content.top.saturating_sub(1), Input::Down | Input::Ctrl('j') => { content.top = content.top.saturating_add(1).min(rows.saturating_sub(1)); } Input::Left | Input::Ctrl('h') if !output.wrapping => { content.left = content.left.saturating_sub(1) } Input::Right | Input::Ctrl('l') if !output.wrapping => { content.left = content.left.saturating_add(1).min(content.max_column); } Input::Home => { content.top = 0; content.left = 0; } Input::End => content.top = rows.saturating_sub(1), _ => {} } true } pub(crate) fn gutter_style(&self) -> crate::gutter::GutterStyle { self.gutter_style } pub(crate) fn set_gutter_style(&mut self, style: crate::gutter::GutterStyle) { self.gutter_style = style; } pub(crate) fn gutter_change(&self, row: usize) -> Option { if self.active.diff_snapshot.is_some() || self.active.reload_error.is_some() { return None; } let changes = self.active.gutter_changes.as_ref()?; // Keep the last completed markers visible while the worker recomputes after edits. if Some(changes.path.as_path()) != self.path() { return None; } changes.lines.get(row).copied().flatten() } pub(crate) fn set_gutter_changes( &mut self, buffer: usize, revision: u64, path: &Path, changes: Vec>, ) -> bool { let buffer = if buffer == self.active.id { &mut self.active } else if let Some(buffer) = self.parked.get_mut(&buffer) { buffer } else { return false; }; if buffer.diff_snapshot.is_some() || buffer.text_revision != revision || buffer.path.as_deref() != Some(path) || changes.len() > buffer.lines.len() { return false; } if buffer .gutter_changes .as_ref() .is_some_and(|old| old.revision == revision && old.path == path && old.lines == changes) { return false; } let visible = changes.iter().any(Option::is_some) || buffer .gutter_changes .as_ref() .is_some_and(|old| old.lines.iter().any(Option::is_some)); buffer.gutter_changes = Some(GutterChanges { revision, path: path.to_path_buf(), lines: changes, }); visible } pub fn git_blame(&self) -> bool { self.git_blame } pub(crate) fn set_git_blame(&mut self, enabled: bool) { self.git_blame = enabled; self.line_blame = None; } pub(crate) fn blame_source(&self) -> Option { if !self.git_blame || self.should_quit() || (self.active.diff_snapshot.is_some() && self.active.snapshot_blame.is_none()) || self.active.reload_error.is_some() { return None; } Some(BlameSource { buffer: self.buffer_id(), revision: self.text_revision(), path: Arc::from(self.path()?), snapshot: self.active.snapshot_blame.clone(), row: self.cursor().row, save_revision: self.active.save_revision, disk_stamp: self.active.disk_stamp, pane: self.pane_id(), tab: self.tab_id(), }) } pub(crate) fn blame_file_source_is_current(&self, source: &BlameSource) -> bool { self.git_blame && !self.should_quit() && self.active.reload_error.is_none() && source.buffer == self.buffer_id() && source.revision == self.text_revision() && source.save_revision == self.active.save_revision && source.disk_stamp == self.active.disk_stamp && source.snapshot == self.active.snapshot_blame && Some(source.path.as_ref()) == self.path() } pub(crate) fn blame_source_is_current(&self, source: &BlameSource) -> bool { self.blame_file_source_is_current(source) && source.row == self.cursor().row && source.pane == self.pane_id() && source.tab == self.tab_id() } pub(crate) fn clear_line_blame(&mut self) -> bool { self.line_blame.take().is_some() } pub(crate) fn set_cached_line_blame( &mut self, source: &BlameSource, blame: Arc, ) -> bool { let mut source = source.clone(); source.row = self.cursor().row; source.pane = self.pane_id(); source.tab = self.tab_id(); self.set_line_blame(source, blame) } pub(crate) fn set_line_blame( &mut self, source: BlameSource, blame: Arc, ) -> bool { if !self.blame_source_is_current(&source) { return false; } if let Some((previous, metadata, _)) = &mut self.line_blame && Arc::ptr_eq(metadata, &blame) { *previous = source; return false; } let label = if blame.uncommitted { "Uncommitted".to_owned() } else { sanitize_label(format!( "{} Ā· {} ({:.8})", blame.author, blame.summary, blame.commit )) }; let changed = self .line_blame .as_ref() .is_none_or(|(_, _, previous)| previous != &label); self.line_blame = Some((source, blame, label)); changed } pub(crate) fn line_blame(&self) -> Option<&str> { self.line_blame .as_ref() .filter(|(source, _, _)| self.blame_source_is_current(source)) .map(|(_, _, label)| label.as_str()) } pub fn lsp_names(&self) -> &[String] { &self.lsp_names } pub fn lsp_label(&self) -> &str { &self.lsp_label } pub fn path(&self) -> Option<&Path> { self.active.path.as_deref() } pub fn dirty(&self) -> bool { self.active.dirty() } pub fn should_quit(&self) -> bool { self.quit || self.restart } /// Whether frontend shutdown should relaunch the executable rather than exit. pub fn should_restart(&self) -> bool { self.restart } pub fn context(&self) -> Context { Context { buffer_id: self.active.id, revision: self.active.text_revision, root: self.project_root.to_string_lossy().into_owned(), text: self.active.lines.join("\n"), row: self.cursor().row + 1, column: self.cursor().column + 1, mode: self.active.view.mode.to_string(), path: self .active .path .as_ref() .map(|path| path.to_string_lossy().into_owned()), } } pub(crate) fn interaction_document_root(&self, id: usize) -> &Path { if let Some(root) = self .buffer(id) .and_then(|buffer| buffer.snapshot_root.as_deref()) { return root; } self.workspace_diff() .into_iter() .chain(self.tabs.values().filter_map(Tab::diff)) .find(|diff| diff.live_buffer == Some(id)) .and_then(|diff| diff.comparison.as_ref()) .map_or(&self.project_root, |comparison| comparison.root.as_path()) } pub(crate) fn lsp_context(&self) -> Context { let mut context = self.context(); context.path = self .active .document() .path .map(|path| path.to_string_lossy().into_owned()); context.root = self .interaction_document_root(self.active.id) .to_string_lossy() .into_owned(); context } pub(crate) fn document_actions_available(&self) -> bool { (self.diff().is_none() || self.diff_interacting()) && !self.file_picker().is_some_and(|picker| picker.focused) } pub fn set_message(&mut self, message: impl Into) { let message = message.into(); self.message = if message.chars().any(char::is_control) { message .chars() .map(|ch| if ch.is_control() { ' ' } else { ch }) .collect() } else { message }; self.notification_visible = true; } pub fn set_chrome_config( &mut self, mut top: ChromeLine, mut bottom: ChromeLine, labels: BTreeMap, ) -> Result<(), String> { top.validate()?; bottom.validate()?; let mut total = 0usize; for (name, text) in &labels { validate_label(name, text)?; total = total .checked_add(name.len()) .and_then(|total| total.checked_add(text.len())) .ok_or("Labels exceed 4 MiB")?; if total > MAX_EDIT_BYTES { return Err("Labels exceed 4 MiB".into()); } } self.top_bar = top; self.bottom_bar = bottom; self.labels = labels .into_iter() .map(|(name, text)| (name, sanitize_label(text))) .collect(); Ok(()) } pub fn set_label(&mut self, name: String, text: String) -> Result { validate_label(&name, &text)?; let text = sanitize_label(text); if self.label(&name) == text { return Ok(false); } if text.is_empty() { self.labels.remove(&name); } else { self.labels.insert(name, text); } Ok(true) } pub(crate) fn set_lsp_names(&mut self, mut names: Vec) -> bool { for name in &mut names { if name.chars().any(char::is_control) { *name = sanitize_label(std::mem::take(name)); } } if self.lsp_names == names { return false; } self.lsp_label = names.join(" + "); self.lsp_names = names; true } pub(crate) fn set_battery_charge(&mut self, charge: Option) -> bool { let charge = charge.filter(|charge| *charge <= 100); if self.battery_charge == charge { return false; } self.battery_charge = charge; self.battery_label.clear(); if let Some(charge) = charge { use std::fmt::Write as _; write!(self.battery_label, "bat {charge}%").unwrap(); } true } pub fn register(&mut self, feature: Feature) -> Result<(), String> { self.install(feature, false) } fn install(&mut self, feature: Feature, privileged: bool) -> Result<(), String> { if feature.core && !privileged { return Err("Plugins cannot register core features".into()); } if feature.name.trim().is_empty() || feature.name.chars().any(char::is_control) { return Err("Feature names must be nonempty and contain no controls".into()); } if self.features.contains_key(&feature.name) { return Err(format!("Feature already registered: {}", feature.name)); } for capability in &feature.required_capabilities { if !self.capabilities.contains(capability) { return Err(format!( "Feature {} requires unavailable capability: {capability}", feature.name )); } } let mut names = HashSet::new(); for command in &feature.commands { if !valid_command(command) { return Err(format!("Invalid command name: {command}")); } if !names.insert(command.as_str()) || self.owner(command).is_some() { return Err(format!("Command already registered: {command}")); } } self.features.insert(feature.name.clone(), feature); self.refresh_palette(); Ok(()) } fn owner(&self, command: &str) -> Option<&Feature> { self.features .values() .find(|feature| feature.commands.iter().any(|name| name == command)) } pub fn disable_plugin(&mut self, name: &str, reason: &str) { self.cancel_sequence(); if self.features.get(name).is_some_and(|feature| feature.core) { self.set_message("Core features cannot be disabled"); return; } self.clear_plugin_diagnostics(name); if let Some(feature) = self.features.remove(name) { for bindings in self.bindings.values_mut() { bindings.retain(|_, binding| { !matches!(binding, Binding::Command(command) if feature.commands.contains(command)) }); } for bindings in self.sequence_bindings.values_mut() { bindings.retain(|_, binding| { !matches!(binding, Binding::Command(command) if feature.commands.contains(command)) }); } } self.refresh_palette(); self.set_message(format!("Plugin {name} disabled: {reason}")); } /// Overrides an editing route or single selection/tree navigation key. /// `None` disables it; invalid overrides are atomic. pub fn bind(&mut self, mode: &str, key: &str, target: Option) -> Result<(), String> { if matches!(mode, "selection" | "tree") { let input = parse_key(key)?; let binding = match target.as_deref() { None => Binding::Disabled, Some("core.up") => Binding::Action(Action::Up), Some("core.down") => Binding::Action(Action::Down), Some("core.left") if mode == "tree" => Binding::Action(Action::Left), Some("core.right") if mode == "tree" => Binding::Action(Action::Right), Some(target) => return Err(format!("Unknown {mode} action: {target}")), }; if self.seek_pending.is_some() { self.cancel_sequence(); } if mode == "tree" { self.tree_bindings.insert(input, binding); } else { self.selection_bindings.insert(input, binding); } self.refresh_selection_navigation_hint(); return Ok(()); } let mode = match mode { "normal" => Mode::Normal, "insert" => Mode::Insert, "command" => Mode::Command, "search" => Mode::Search, "visual" => Mode::Visual, _ => return Err(format!("Unknown binding mode: {mode}")), }; let mut tokens = key.split_whitespace(); let first = tokens .next() .ok_or_else(|| format!("Invalid binding key: {key:?}"))?; let symbolic = first.eq_ignore_ascii_case("leader"); let keys: Vec<_> = tokens.map(parse_key).collect::>()?; if symbolic && keys.is_empty() { return Err("Leader binding requires at least one suffix key".into()); } let (sequence, input) = if symbolic || !keys.is_empty() { if !matches!(mode, Mode::Normal | Mode::Visual) { return Err("Key sequences can only be bound in Normal or Visual mode".into()); } let prefix = if symbolic { None } else { Some(parse_key(first)?) }; if let Some(prefix) = &prefix { if *prefix == Input::Escape || matches!(prefix, Input::Char('0'..='9')) { return Err("Sequence prefix cannot be Escape or an ASCII digit".into()); } if *prefix == self.leader { return Err( "Use leader for sequences starting with the configured leader".into(), ); } if self .bindings .get(&mode) .is_some_and(|bindings| bindings.contains_key(prefix)) { return Err(format!( "Sequence prefix already bound: {}", display_key(prefix) )); } } let sequence = BindingSequence { prefix, keys }; if self.sequence_bindings.get(&mode).is_some_and(|bindings| { bindings.keys().any(|existing| { existing.prefix == sequence.prefix && existing.keys != sequence.keys && (existing.keys.starts_with(&sequence.keys) || sequence.keys.starts_with(&existing.keys)) }) }) { return Err(format!("Ambiguous key sequence: {key:?}")); } (Some(sequence), None) } else { let input = parse_key(first)?; if self.has_sequence_prefix(mode, &input) { return Err(format!("Key is already a sequence prefix: {key:?}")); } (None, Some(input)) }; let binding = match target { None => Binding::Disabled, Some(target) if target.starts_with("core.") => Binding::Action( Action::named(mode, &target) .ok_or_else(|| format!("Unknown action for {mode}: {target}"))?, ), Some(command) => { if let Some(entry) = core_command(&command) { return Err(format!( "Built-in command {command} requires typed action {}", entry.name )); } if mode != Mode::Normal { return Err("Ex/plugin commands can only be bound in Normal mode".into()); } if self.owner(&command).is_none() { return Err(format!("Unknown command: {command}")); } Binding::Command(command) } }; self.cancel_sequence(); if let Some(sequence) = sequence { self.sequence_bindings .entry(mode) .or_default() .insert(sequence, binding); } else { self.bindings .entry(mode) .or_default() .insert(input.expect("single-key binding"), binding); } self.refresh_palette(); Ok(()) } pub fn selection_navigation_hint(&self) -> &str { &self.selection_navigation_hint } pub fn file_navigation_hint(&self) -> &str { if let Some(picker) = self.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::() + 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 binding = match self.tree_bindings.get(input) { Some(Binding::Action(action)) => Some(*action), Some(Binding::Disabled) => None, None => return false, _ => unreachable!("tree bindings only accept navigation or disabled"), }; let picker = if self.picker_diff().is_some() { self.picker_diff_mut() .and_then(|diff| diff.files.as_mut().filter(|_| diff.files_visible)) } else { self.file_picker.as_mut() }; let Some(picker) = picker.filter(|picker| picker.focused && picker.view == FileView::Tree) else { return false; }; if picker.searching && matches!(input, Input::Char(_)) { return false; } match binding { Some(Action::Down) => picker.navigate(true), Some(Action::Up) => picker.navigate(false), Some(Action::Left) => picker.left(), Some(Action::Right) => picker.right(), None => {} _ => unreachable!("tree bindings only accept navigation or disabled"), } true } fn handle_selection_binding(&mut self, input: &Input) -> bool { let next = match self.selection_bindings.get(input) { Some(Binding::Action(Action::Down)) => true, Some(Binding::Action(Action::Up)) => false, Some(Binding::Disabled) => return true, None => return false, _ => unreachable!("selection bindings only accept navigation or disabled"), }; if let Some(picker) = self.file_picker.as_mut().filter(|picker| picker.focused) { picker.navigate(next); } else if let Some(palette) = self.palette.as_mut() && !palette.entries.is_empty() { palette.selected = if next { (palette.selected + 1) % palette.entries.len() } else if palette.selected == 0 { palette.entries.len() - 1 } else { palette.selected - 1 }; } else if self.palette.is_none() && let Some(diff) = self.picker_diff_mut() { diff.navigate(next); } true } fn palette_bindings(&self, action: Option, command: &str) -> Vec { // ponytail: scan mode bindings per result; index by target if catalogs grow. let mut bindings: Vec<_> = self .bindings .get(&self.active.view.mode) .into_iter() .flat_map(|keys| keys.iter()) .filter(|(_, binding)| match (action, binding) { (Some(action), Binding::Action(bound)) => action == *bound, (None, Binding::Command(bound)) => command == bound, _ => false, }) .map(|(input, _)| display_key(input)) .collect(); let native = match action { Some(Action::FollowLink) if self.active.view.mode == Mode::Normal => Some("gf"), Some(Action::DeleteLine) if self.active.view.mode == Mode::Normal => Some("dd"), Some(Action::ChangeLine) if self.active.view.mode == Mode::Normal => Some("cc"), Some(Action::DocumentStart) if matches!(self.active.view.mode, Mode::Normal | Mode::Visual) => { Some("gt") } Some(Action::DocumentEnd) if matches!(self.active.view.mode, Mode::Normal | Mode::Visual) => { Some("gb") } _ => None, }; if let Some(native) = native { let prefix = Input::Char(native.as_bytes()[0] as char); if self.leader != prefix && !self .bindings .get(&self.active.view.mode) .is_some_and(|keys| keys.contains_key(&prefix)) && !self.has_sequence_prefix(self.active.view.mode, &prefix) { bindings.push(native.into()); } } for (sequence, binding) in self .sequence_bindings .get(&self.active.view.mode) .into_iter() .flat_map(|keys| keys.iter()) { let prefix = sequence.prefix.as_ref().unwrap_or(&self.leader); if self .bindings .get(&self.active.view.mode) .is_some_and(|keys| keys.contains_key(prefix)) { continue; } if match (action, binding) { (Some(action), Binding::Action(bound)) => action == *bound, (None, Binding::Command(bound)) => command == bound, _ => false, } { let mut display = display_key(prefix); for input in &sequence.keys { display.push(' '); append_key(&mut display, input); } bindings.push(display); } } bindings.sort(); bindings } fn refresh_palette(&mut self) { let Some(mut palette) = self.palette.take() else { return; }; let query = palette.query.to_lowercase(); if palette.buffers { palette.entries.clear(); for buffer in self.buffers() { let label = sanitize_label(buffer.path.map_or_else( || "[No Name]".into(), |path| path.to_string_lossy().into_owned(), )); let searchable = format!( "{}{}{} {label}", buffer.id, if buffer.active { "%" } else { "" }, if buffer.dirty { "+" } else { "" }, ) .to_lowercase(); if query .split_whitespace() .all(|token| searchable.contains(token)) { palette.entries.push(PaletteEntry { name: format!("buffer {}", buffer.id), label, owner: String::new(), bindings: Vec::new(), enabled: true, modes: Vec::new(), buffer: Some(PaletteBuffer { id: buffer.id, active: buffer.active, dirty: buffer.dirty, }), }); } } palette.selected = 0; self.palette = Some(palette); return; } // Derived labels contain the same searchable words as their names. let matches = |name: &str, owner: &str, ex: &str, action: Option| { query.split_whitespace().all(|token| { name.contains(token) || owner.contains(token) || ex.contains(token) || action.is_some_and(|action| { COMMAND_ALIASES .iter() .any(|(alias, target)| *target == action && alias.contains(token)) }) }) }; palette.entries.clear(); for entry in CORE_ACTIONS { let CoreAction { name, action, modes, ex, .. } = *entry; if action == Action::Palette { continue; } if !matches(name, "core", ex.unwrap_or(""), Some(action)) { continue; } let mut label = name.trim_start_matches("core.").replace('_', " "); label[..1].make_ascii_uppercase(); palette.entries.push(PaletteEntry { name: name.into(), label, owner: "core".into(), bindings: self.palette_bindings(Some(action), ""), enabled: modes.contains(&self.binding_mode()) && (!matches!( action, Action::LspDefinition | Action::LspHover | Action::LspDiagnostics ) || self.document_actions_available()), modes: modes.to_vec(), buffer: None, }); } for feature in self.features.values() { if feature.core { continue; } let owner = feature.name.to_lowercase(); for command in &feature.commands { let name = command.to_lowercase(); if !matches(&name, &owner, "", None) { continue; } palette.entries.push(PaletteEntry { name: command.clone(), label: command.clone(), owner: feature.name.clone(), bindings: self.palette_bindings(None, command), enabled: self.active.view.mode == Mode::Normal, modes: vec![Mode::Normal], buffer: None, }); } } palette.entries.sort_by(|left, right| { left.label .cmp(&right.label) .then_with(|| left.name.cmp(&right.name)) }); palette.selected = 0; self.palette = Some(palette); } fn handle_palette(&mut self, input: Input) -> Option { let mode = self.binding_mode(); let palette = self.palette.as_mut().expect("open palette receives input"); match input { Input::Escape | Input::Ctrl('p') => { self.palette = None; self.pane_close_picker_origin = None; } Input::Ctrl('u') => { if BackspaceKind::Line.truncate(&mut palette.query) { self.refresh_palette(); } } Input::Enter => { let entry = palette .entries .get(palette.selected) .filter(|entry| entry.enabled)?; if palette.buffers { let command = palette.entries.swap_remove(palette.selected).name; return self.execute(&command); } if let Some(action) = Action::named(mode, &entry.name) { if !matches!(action, Action::PaneClose | Action::PaneForceClose) { self.pane_close_picker_origin = None; } if let Some(core) = CORE_ACTIONS .iter() .find(|core| core.action == action && core.palette_prompt) { self.open_action_prompt(core); return None; } self.palette = None; return self.act(action); } self.pane_close_picker_origin = None; self.active.view.command_line = format!("{} ", entry.name); self.active.view.prompt_history_index = None; self.active.view.prompt_draft.clear(); self.palette = None; self.active.view.command_from_visual = self.active.view.mode == Mode::Visual; if self.diff.is_some() && !self.diff_interacting() { self.diff_prompt_mode.get_or_insert(self.active.view.mode); } else if self.active.view.mode == Mode::Insert { self.finish_insert(); } self.active.view.mode = Mode::Command; } input if BackspaceKind::from_input(&input).is_some() => { if BackspaceKind::from_input(&input) .expect("backspace input") .truncate(&mut palette.query) { self.refresh_palette(); } } Input::Char(ch) if !ch.is_control() => { palette.query.push(ch); self.refresh_palette(); } _ => {} } None } fn open_file_picker(&mut self, presentation: FilePresentation) { self.pane_close_picker_origin = None; if self .resolver() .is_some_and(|resolver| resolver.files.is_empty()) { return; } if presentation == FilePresentation::Docked && self.file_picker().is_some_and(|picker| { picker.focused && picker.presentation == FilePresentation::Docked && picker.view == FileView::Tree && !picker.searching }) { let return_focus = self.diff_bound.map(|bound| bound.2); self.blur_file_picker(); if let Some(diff) = self.picker_diff_mut() && let Some(focus) = return_focus { diff.focus = focus; diff.normalize_focus(); } return; } let view = match presentation { FilePresentation::Floating => self.file_picker_view, FilePresentation::Docked => self.file_explorer_view, }; if let Some(diff) = self.picker_diff_mut() { if diff.phase == Phase::View { let picker = diff.files.as_mut().expect("comparison picker"); picker.presentation = presentation; picker.set_view(view); picker.focused = true; picker.clear_error(); if presentation == FilePresentation::Floating && view == FileView::Tree && !picker.searching { picker.start_search(); } diff.files_visible = true; diff.focus = Focus::Files; self.palette = None; self.count = None; self.clear_sequence(); } return; } if let Some(picker) = &mut self.file_picker { picker.presentation = presentation; picker.set_view(view); picker.focused = true; picker.clear_error(); } else { match FilePicker::scan(self.project_root.clone(), presentation, view) { Ok(mut picker) => { picker.set_repository_changes(self.picker_repository_changes.clone()); self.file_picker = Some(picker); } Err(error) => { self.set_message(format!("File scan failed: {error}")); return; } } } if presentation == FilePresentation::Floating && view == FileView::Tree && let Some(picker) = &mut self.file_picker && !picker.searching { picker.start_search(); } self.palette = None; self.count = None; self.clear_sequence(); } fn blur_file_picker(&mut self) { if let Some(diff) = self.picker_diff_mut() && diff.phase == Phase::View { if let Some(picker) = &mut diff.files { picker.focused = false; if picker.presentation == FilePresentation::Floating { diff.files_visible = false; } } if diff.focus == Focus::Files { diff.focus = diff.first_text_focus(); } } match self.file_picker.as_mut() { Some(picker) if picker.presentation == FilePresentation::Docked => { picker.focused = false; } _ => self.file_picker = None, } } fn close_file_picker(&mut self) { self.pane_close_picker_origin = None; let return_focus = self.diff_bound.map(|bound| bound.2); if let Some(diff) = self.picker_diff_mut() { diff.files_visible = false; if let Some(picker) = &mut diff.files { picker.focused = false; } diff.focus = return_focus.unwrap_or_else(|| diff.first_text_focus()); diff.normalize_focus(); } else { self.file_picker = None; } } fn handle_file_picker(&mut self, input: Input) { let picker = self .file_picker .as_mut() .expect("focused picker receives input"); match input { Input::Ctrl('w') => self.close_file_picker(), Input::Escape if picker.view == FileView::Tree && picker.searching => { picker.finish_search() } Input::Escape => self.blur_file_picker(), Input::Ctrl('p') => { self.act(Action::Palette); } Input::Char('\t') if picker.presentation == FilePresentation::Docked => { picker.focused = false; } Input::Ctrl('r') => picker.refresh(), Input::Ctrl('t') => picker.toggle_view(), _ if picker.handle_filter_input(&input) => {} Input::Enter => { let Some(path) = picker.accept() else { return }; let source = self.jump_location(); match self.edit_buffer(path, false) { Ok(()) => { self.record_jump(source); self.blur_file_picker(); } Err(error) => { self.file_picker .as_mut() .expect("failed open retains picker") .error = Some(format!("Open failed: {error}")); } } } _ => {} } } pub fn handle(&mut self, input: Input) -> Option { self.pending_lsp_navigation = None; self.active.view.lsp_center.set(None); // Normalize genuine terminal aliases before list and editing dispatch. let input = match input { Input::Ctrl(ch) => control_key(ch), input => input, }; if self.handle_command_output(&input) { return None; } if self.handle_review_overview(&input) { return None; } if self.handle_resolver_history_focus(&input) { return None; } if self.palette.is_none() && self.active.view.mode != Mode::Command && !matches!( self.bindings .get(&self.binding_mode()) .and_then(|keys| keys.get(&input)), Some(Binding::Action( Action::Submit | Action::PaneClose | Action::PaneForceClose )) ) { self.pane_close_picker_origin = None; } self.bind_diff_buffer(); let invocation = if self.diff().is_some() || (self.resolver().is_some() && self.file_picker().is_some_and(|picker| picker.focused)) { self.handle_diff(input) } else { self.handle_input(input) }; if let Some(file) = self .resolver_diff_mut() .and_then(|diff| diff.requested_file.take()) && self .resolver() .is_some_and(|resolver| resolver.current_file != file) { self.select_resolver_file(file); } self.leave_diff_on_navigation(); self.bind_diff_buffer(); self.refresh_live_diff(); self.refresh_resolver_status(); self.refresh_resolver_comparison(); if self.diff_interacting() && self.active.view.viewport_scroll.get() == (0, 0) && let Some(diff) = self.interaction_diff_mut() { diff.follow_caret = true; } invocation } fn handle_input(&mut self, input: Input) -> Option { let mode = self.binding_mode(); if !matches!( self.bindings.get(&mode).and_then(|keys| keys.get(&input)), Some(Binding::Action( Action::CommandComplete | Action::CommandCompletePrevious )) ) { self.active.view.command_completion_prefix = None; } if self.completion.is_some() && (self.diff().is_none() || self.diff_editing()) { let request = matches!( self.bindings .get(&self.active.view.mode) .and_then(|keys| keys.get(&input)), Some(Binding::Action(Action::LspCompletion)) ); if request || input == Input::Ctrl('j') { self.cycle_completion(true); return None; } match input { Input::Ctrl('k') => { self.cycle_completion(false); return None; } _ => self.finish_completion(), } } if let Some((forward, count)) = self.seek_pending.take() { if matches!(self.active.view.mode, Mode::Normal | Mode::Visual) && self.startup_diagnostic.is_none() && self.hover().is_none() && self.palette.is_none() && !self .file_picker .as_ref() .is_some_and(|picker| picker.focused) && let Input::Char(target) = input && (!target.is_control() || target == '\t') { let motion = Motion::Seek { forward, target, count, }; if let Some(motions) = &mut self.active.view.visual_motions { motions.push(motion); } self.move_motion(motion); return None; } self.count = None; if input == Input::Escape { return None; } } if std::mem::take(&mut self.g_pending) { if matches!(self.active.view.mode, Mode::Normal | Mode::Visual) && self.startup_diagnostic.is_none() && self.hover().is_none() && self.palette.is_none() && !self .file_picker .as_ref() .is_some_and(|picker| picker.focused) { let action = match input { Input::Char('f') if self.active.view.mode == Mode::Normal => { Some(Action::FollowLink) } Input::Char('t') => Some(Action::DocumentStart), Input::Char('b') => Some(Action::DocumentEnd), _ => None, }; if let Some(action) = action { return self.act(action); } } self.count = None; if input == Input::Escape { return None; } } if let Some(diagnostic) = &mut self.startup_diagnostic { match input { Input::Enter | Input::Escape => self.startup_diagnostic = None, Input::Up | Input::Ctrl('k') => diagnostic.top = diagnostic.top.saturating_sub(1), Input::Down | Input::Ctrl('j') => { diagnostic.top = diagnostic .top .saturating_add(1) .min(diagnostic.lines.len() - 1) } Input::Left | Input::Ctrl('h') => { diagnostic.left = diagnostic.left.saturating_sub(1) } Input::Right | Input::Ctrl('l') => { diagnostic.left = diagnostic.left.saturating_add(1).min(diagnostic.max_column) } Input::Home => { diagnostic.top = 0; diagnostic.left = 0; } _ => {} } return None; } if self.handle_hover(&input) { return None; } if self.sequence_pending().is_some() { if input == Input::Escape || matches!(input, Input::Paste(_)) { self.cancel_sequence(); if input == Input::Escape { return None; } } else { return self.handle_sequence(input); } } let picker_focused = self .file_picker .as_ref() .is_some_and(|picker| picker.focused); if picker_focused && self.handle_tree_binding(&input) { return None; } if (picker_focused || self.palette.is_some()) && self.handle_selection_binding(&input) { return None; } let picker_browsing = self .file_picker .as_ref() .is_some_and(|picker| picker.view == FileView::Tree && !picker.searching); if picker_focused && (!matches!(input, Input::Char(_)) || picker_browsing) && self.start_sequence(mode, &input) { return None; } if picker_focused && let Some(Binding::Action(action)) = self.bindings.get(&mode).and_then(|keys| keys.get(&input)) && matches!( action, Action::PaneFocusLeft | Action::PaneFocusDown | Action::PaneFocusUp | Action::PaneFocusRight | Action::PaneClose | Action::PaneForceClose | Action::TabNew | Action::TabPrevious | Action::TabNext | Action::TabClose | Action::TabForceClose | Action::FilePicker | Action::FileExplorer ) { let action = *action; if self .file_picker .as_mut() .expect("focused picker exists") .handle_filter_input(&input) { return None; } return self.act(action); } if picker_focused { self.handle_file_picker(input); return None; } if self.palette.is_some() { return self.handle_palette(input); } if let Some((operator, prefix)) = self.line_pending.take() { if operator == 'r' { if matches!(self.active.view.mode, Mode::Normal | Mode::Visual) && let Input::Char(target) = input && (!target.is_control() || target == '\t') { if self.mutation_blocked() { self.set_message("Diff pane is read-only"); } else { let before = self.snapshot(); let (ranges, start) = if self.active.view.mode == Mode::Visual { let ranges = self.selections(); let selector = self.selection_intent(&ranges); (ranges, EditStart::ReplaceSelection { target, selector }) } else { ( self.normal_ranges(Action::SubstituteCharacter, prefix), EditStart::Replace { target, count: prefix, }, ) }; self.replace_characters(target, &ranges); self.record_native( before, EditRecipe { start, steps: Vec::new(), }, ); } } self.count = None; return None; } if self.active.view.mode == Mode::Normal { if let Input::Char(ch @ '0'..='9') = input && (ch != '0' || self.count.is_some()) { self.count = Some( self.count .unwrap_or(0) .saturating_mul(10) .saturating_add(usize::from(ch as u8 - b'0')), ); self.line_pending = Some((operator, prefix)); return None; } let action = match input { Input::Char(ch) if ch == operator => Some(match operator { 'd' => Action::DeleteLine, 'y' => Action::CopyLine, _ => Action::ChangeLine, }), Input::Char('w') if operator == 'd' => Some(Action::DeleteWordForward), Input::Char('b') if operator == 'd' => Some(Action::DeleteWordBackward), Input::Char('w') if operator == 'c' => Some(Action::ChangeWordForward), Input::Char('b') if operator == 'c' => Some(Action::ChangeWordBackward), _ => None, }; if let Some(action) = action { self.count = Some(prefix.saturating_mul(self.count.unwrap_or(1))); return self.act(action); } } self.count = None; if input == Input::Escape { return None; } } // Paste is literal input, never a configurable key or command. if let Input::Paste(text) = input { self.count = None; self.clear_sequence(); if self.active.view.mode == Mode::Insert && (self.diff.is_none() || self.diff_editing()) { self.paste(&text); } return None; } if input == Input::Escape && self.count.take().is_some() { return None; } if input == Input::Escape && self.active.view.mode == Mode::Normal && self.notification_visible && (!self.message.is_empty() || self.diagnostic_at_cursor().is_some()) { self.notification_visible = false; self.active.view.search_highlight = false; self.active.view.search_match = None; return None; } if matches!(self.active.view.mode, Mode::Normal | Mode::Visual) && let Input::Char(ch @ '0'..='9') = input { let binding = self .bindings .get(&self.active.view.mode) .and_then(|keys| keys.get(&input)); let extends_count = ch == '0' && self.count.is_some() && matches!(binding, Some(Binding::Action(Action::LineStart))); if (binding.is_none() || extends_count) && (ch != '0' || self.count.is_some()) { let digit = usize::from(ch as u8 - b'0'); self.count = Some( self.count .unwrap_or(0) .saturating_mul(10) .saturating_add(digit), ); return None; } } match self.bindings.get(&mode).and_then(|keys| keys.get(&input)) { Some(Binding::Action(action)) => return self.act(*action), Some(Binding::Command(command)) => { let command = command.clone(); return self.execute(&command); } Some(Binding::Disabled) => { self.count = None; return None; } None => {} } if self.start_sequence(mode, &input) { return None; } if matches!(mode, Mode::Normal | Mode::Visual) && input == Input::Char('g') { if self.count.is_some() { return self.act(Action::GoToLine); } self.count = None; self.g_pending = true; return None; } if self.active.view.mode == Mode::Normal && let Input::Char(operator @ ('c' | 'd' | 'y')) = input { self.line_pending = Some((operator, self.count.take().unwrap_or(1))); return None; } self.count = None; match (self.active.view.mode, input) { (Mode::Insert, Input::Char(ch)) if (self.diff.is_none() || self.diff_editing()) && (!ch.is_control() || ch == '\t') => { let mut bytes = [0; 4]; self.type_native(ch.encode_utf8(&mut bytes)); } (Mode::Command | Mode::Search, Input::Char(ch)) if !ch.is_control() || (self.active.view.mode == Mode::Search && ch == '\t') => { self.active.view.command_line.push(ch); } _ => {} } None } fn has_sequence_prefix(&self, mode: Mode, input: &Input) -> bool { self.sequence_bindings.get(&mode).is_some_and(|bindings| { bindings .keys() .any(|sequence| sequence.prefix.as_ref() == Some(input)) }) } fn start_sequence(&mut self, mode: Mode, input: &Input) -> bool { if !matches!(mode, Mode::Normal | Mode::Visual) || (*input != self.leader && !self.has_sequence_prefix(mode, input)) || self .bindings .get(&mode) .is_some_and(|keys| keys.contains_key(input)) { return false; } self.pending_sequence.prefix = (*input != self.leader).then(|| input.clone()); append_key(&mut self.sequence_display, input); true } fn handle_sequence(&mut self, input: Input) -> Option { self.sequence_display.push(' '); append_key(&mut self.sequence_display, &input); self.pending_sequence.keys.push(input); let bindings = self.sequence_bindings.get(&self.binding_mode()); match bindings.and_then(|keys| keys.get(&self.pending_sequence)) { Some(Binding::Action(action)) => { if self.command_output.is_some() && *action != Action::WordWrapToggle { self.cancel_sequence(); return None; } return self.act(*action); } Some(Binding::Command(command)) => { if self.command_output.is_some() { self.cancel_sequence(); return None; } let command = command.clone(); return self.execute(&command); } Some(Binding::Disabled) => { self.cancel_sequence(); return None; } None => {} } // ponytail: scan finite sequence routes; use a trie if catalogs grow large. if bindings.is_some_and(|keys| { keys.keys().any(|sequence| { sequence.prefix == self.pending_sequence.prefix && sequence.keys.starts_with(&self.pending_sequence.keys) }) }) { return None; } self.set_message(format!("Unknown key sequence: {}", self.sequence_display)); self.cancel_sequence(); None } fn finish_prompt(&mut self) { if let Some(mode) = self.diff_prompt_mode.take() { self.active.view.mode = mode; self.active.view.command_line.clear(); } if std::mem::take(&mut self.active.view.command_from_visual) { self.active.view.mode = Mode::Visual; self.active.view.command_line.clear(); } } fn open_action_prompt(&mut self, entry: &CoreAction) { self.active.view.command_from_visual = self.active.view.mode == Mode::Visual; if self.diff().is_some() && !self.diff_interacting() { self.diff_prompt_mode.get_or_insert(self.active.view.mode); self.palette = None; self.count = None; self.clear_sequence(); } else { self.prepare_command(); } self.active.view.command_line = format!("{} ", entry.ex.expect("prompt has Ex alias")); self.active.view.prompt_history_index = None; self.active.view.prompt_draft.clear(); self.active.view.mode = Mode::Command; } fn complete_command(&mut self, forward: bool) { let view = &mut self.active.view; let command = view.command_line.trim_start(); let name = command.strip_prefix(':').unwrap_or(command); if name.contains(char::is_whitespace) { return; } let start = view.command_line.len() - name.len(); let cycling = view.command_completion_prefix.is_some(); let prefix = view .command_completion_prefix .get_or_insert_with(|| name.to_owned()); let precedes = |left: &str, right: &str| { if forward { left < right } else { left > right } }; let mut first = None; let mut next = None; for candidate in self.features.values().flat_map(|feature| &feature.commands) { let candidate = candidate.as_str(); if !candidate.starts_with(prefix.as_str()) { continue; } if first.is_none_or(|first| precedes(candidate, first)) { first = Some(candidate); } if cycling && precedes(name, candidate) && next.is_none_or(|next| precedes(candidate, next)) { next = Some(candidate); } } if let Some(candidate) = next.or(first) { view.command_line.replace_range(start.., candidate); } } fn navigate_prompt_history(&mut self, previous: bool) { let history = if self.active.view.mode == Mode::Search { &self.search_history } else { &self.command_history }; if history.is_empty() { return; } let view = &mut self.active.view; let index = match view.prompt_history_index { None if !previous => return, None => { view.prompt_draft = std::mem::take(&mut view.command_line); history.len() - 1 } Some(index) if previous => index.saturating_sub(1).min(history.len() - 1), Some(index) if index + 1 < history.len() => index + 1, Some(_) => { view.command_line = std::mem::take(&mut view.prompt_draft); view.prompt_history_index = None; return; } }; view.command_line.clone_from(&history[index]); view.prompt_history_index = Some(index); } fn act(&mut self, action: Action) -> Option { if (self.active.view.mode != Mode::Command || action == Action::Normal) && !matches!( action, Action::Palette | Action::Submit | Action::PaneClose | Action::PaneForceClose ) { self.pane_close_picker_origin = None; } if action == Action::Normal && (self.diff_prompt_mode.is_some() || self.active.view.command_from_visual) { self.finish_prompt(); self.count = None; self.clear_sequence(); return None; } let read_only = self.mutation_blocked(); if read_only && action.mutates(self.active.view.mode) && !(matches!( action, Action::Save | Action::ForceSave | Action::SaveAndQuit | Action::ForceSaveAndQuit ) && (self.active.review_buffer.is_some() || self.resolvers.values().any(|resolver| { resolver .files .iter() .any(|file| file.draft_buffer == self.active.id) }))) { self.set_message("Input pane is read-only"); self.count = None; self.clear_sequence(); return None; } let entry = action.entry(); if entry.ex.is_some() { if entry.prompt { self.open_action_prompt(entry); return None; } return self.execute_action(entry, ""); } if !read_only { self.finish_completion(); } if !read_only && !matches!( action, Action::SnippetNext | Action::SnippetPrevious | Action::Newline | Action::Backspace | Action::BackspaceWord | Action::BackspaceLine | Action::Delete ) { self.snippet = None; } if !read_only && !matches!(action, Action::JumpBack | Action::JumpForward) { self.clear_hover(); } let count = self.count.take().unwrap_or(1); self.clear_sequence(); match action { Action::CommandComplete | Action::CommandCompletePrevious => { self.complete_command(action == Action::CommandComplete); } Action::SnippetNext => { if self.snippet.is_some() { self.navigate_snippet(true); } else if self.active.view.mode == Mode::Insert { self.type_native("\t"); } } Action::SnippetPrevious => self.navigate_snippet(false), Action::Palette => { self.pane_close_picker_origin = self .file_picker() .filter(|picker| { picker.focused && picker.presentation == FilePresentation::Docked }) .map(|_| { ( self.tab_id, self.pane_id, self.picker_diff().and_then(|diff| diff.comparison_id), ) }); self.blur_file_picker(); self.palette = Some(Palette { query: String::new(), entries: Vec::new(), selected: 0, buffers: false, }); self.refresh_palette(); } Action::FilePicker => self.open_file_picker(FilePresentation::Floating), Action::FileExplorer => self.open_file_picker(FilePresentation::Docked), Action::FollowLink | Action::PaneSplitHorizontal | Action::PaneSplitVertical | Action::PaneFocusLeft | Action::PaneFocusDown | Action::PaneFocusUp | Action::PaneFocusRight | Action::PaneClose | Action::PaneForceClose | Action::TabNew | Action::TabPrevious | Action::TabNext | Action::TabClose | Action::TabForceClose | Action::Save | Action::ForceSave | Action::Quit | Action::ForceQuit | Action::Restart | Action::ForceRestart | Action::SaveAndQuit | Action::ForceSaveAndQuit | Action::Plugins | Action::Edit | Action::Buffers | Action::Buffer | Action::BufferNext | Action::BufferPrevious | Action::BufferDelete | Action::BufferForceDelete | Action::Diff | Action::Resolve | Action::ResolveNext | Action::ResolvePrevious | Action::ResolveUseSource | Action::ResolveSource | Action::ResolveBase | Action::ResolveNextFile | Action::ResolvePreviousFile | Action::ResolveFiles | Action::ResolveDraft | Action::ResolveHistory | Action::ResolveViewToggle | Action::ResolveClose | Action::PullRequests | Action::DiffClose | Action::DiffModeToggle | Action::DiffViewToggle | Action::DiffOrientationToggle | Action::DiffReviewToggle | Action::Comment | Action::ReviewEdit | Action::ReviewDelete | Action::ReviewResolve | Action::ReviewUnresolve | Action::ReviewNext | Action::ReviewPrevious | Action::ReviewOverview | Action::ReviewCopy | Action::ReviewSummary | Action::ReviewVerdict | Action::ReviewSave | Action::ReviewOpen | Action::ReviewNew | Action::WordWrapToggle | Action::GitBlameToggle | Action::LspDefinition | Action::LspHover | Action::LspDiagnostics => unreachable!("catalog Ex actions dispatch above"), Action::JumpBack => self.traverse_jumps(false, count), Action::JumpForward => self.traverse_jumps(true, count), Action::ScrollUp | Action::ScrollDown | Action::ScrollStepUp | Action::ScrollStepDown => { if self.document_actions_available() { let amount = isize::try_from(count).unwrap_or(isize::MAX); let amount = if matches!(action, Action::ScrollUp | Action::ScrollStepUp) { -amount } else { amount }; let (mut rows, mut steps) = self.active.view.viewport_scroll.get(); if matches!(action, Action::ScrollStepUp | Action::ScrollStepDown) { steps = steps.saturating_add(amount); } else { rows = rows.saturating_add(amount); } self.active.view.viewport_scroll.set((rows, steps)); if let Some(diff) = self.interaction_diff_mut() { diff.follow_caret = false; } } } Action::SeekForward | Action::SeekBackward => { self.seek_pending = Some((action == Action::SeekForward, count)); } Action::DeleteLine | Action::ChangeLine => { self.edit_lines(matches!(action, Action::ChangeLine), count); } Action::CopyLine => self.copy_lines(count), Action::ReplaceCharacter => { self.line_pending = Some(('r', count)); } Action::ChangeToLineEnd | Action::DeleteToLineEnd | Action::CopyToLineEnd | Action::SubstituteCharacter | Action::DeleteWordForward | Action::DeleteWordBackward | Action::ChangeWordForward | Action::ChangeWordBackward => self.edit_normal(action, count), Action::LspCompletion => { if self.active.view.mode == Mode::Insert { return Some(Invocation { plugin: "core".into(), command: "lsp.completion".into(), args: String::new(), }); } } Action::Insert | Action::InsertFirstNonblank | Action::AppendLineEnd | Action::Append | Action::OpenBelow | Action::OpenAbove => { let before = self.snapshot(); if self.active.view.mode == Mode::Visual { self.split_rows(matches!(action, Action::AppendLineEnd), true); } self.start_insert(before, EditStart::Insert(action)); self.place_insert(action); } Action::Up | Action::Down if matches!(self.active.view.mode, Mode::Command | Mode::Search) => { self.navigate_prompt_history(action == Action::Up); } Action::Left | Action::Right | Action::Down | Action::Up | Action::LineStart | Action::LineEnd | Action::DocumentStart | Action::DocumentEnd | Action::GoToLine | Action::WordNext | Action::WordPrevious | Action::WordEnd | Action::BigWordNext | Action::BigWordPrevious | Action::BigWordEnd => { if self.active.view.mode == Mode::Visual { if let Some(motions) = &mut self.active.view.visual_motions { motions.push(Motion::Action(action, count)); } } else if self.active.view.mode == Mode::Insert { self.push_insert_step(InsertStep::Move(action, count)); } self.move_targets(action, count); } Action::JoinLines => { let before = self.snapshot(); self.join_lines(); self.record_native( before, EditRecipe { start: EditStart::Join, steps: Vec::new(), }, ); } Action::Delete => { if self.active.view.mode == Mode::Normal { self.edit_normal(action, 1); return None; } if self.active.view.mode == Mode::Insert && self.edit_snippet("", Some(action)) { return None; } let mask = self.deletion_mask(None); self.delete_forward(&mask); 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.view.command_from_visual = action == Action::Command && self.active.view.mode == Mode::Visual; if self.diff().is_some() && !self.diff_interacting() { self.diff_prompt_mode.get_or_insert(self.active.view.mode); } self.active.view.mode = if matches!(action, Action::Search) { Mode::Search } else { Mode::Command }; self.active.view.command_line.clear(); self.active.view.prompt_history_index = None; self.active.view.prompt_draft.clear(); } Action::SearchWord | Action::SearchNext | Action::SearchPrevious => { self.seek_search(action, count); } Action::Select | Action::SelectLinewise => { let linewise = matches!(action, Action::SelectLinewise); if self.active.view.mode == Mode::Visual && self.active.view.linewise == linewise { self.clear_selection(); } else { if self.active.view.mode != Mode::Visual { for target in &mut self.active.view.targets { target.anchor = target.active; } self.active.view.visual_motions = Some(Vec::new()); } else { self.active.view.visual_motions = None; } self.active.view.linewise = linewise; self.active.view.mode = Mode::Visual; self.normalize_targets(); } } Action::SelectLines => self.split_rows(false, false), Action::CloneAbove => self.clone_vertical(false), Action::CloneBelow => self.clone_vertical(true), Action::SelectNext | Action::SkipOccurrence | Action::SelectAll => { self.select_occurrences(action); } Action::Copy | Action::CopySystem | Action::Cut | Action::Change => { self.edit_selection(action) } Action::PasteBefore | Action::PasteAfter => self.paste_clipboard( matches!(action, Action::PasteAfter), self.clipboard_mode == ClipboardMode::System, ), Action::PasteSystemBefore | Action::PasteSystemAfter => { self.paste_clipboard(matches!(action, Action::PasteSystemAfter), true) } Action::Normal => { if self.active.view.mode == Mode::Normal { self.active.view.search_highlight = false; self.active.view.search_match = None; self.collapse_primary(); } else if self.active.view.mode == Mode::Insert { self.finish_insert(); self.leave_insert(); } else { self.active.view.command_line.clear(); self.active.view.visual_motions = None; self.active.view.mode = Mode::Normal; self.normalize_targets(); } } Action::Newline => self.type_native("\n"), Action::Backspace | Action::BackspaceWord | Action::BackspaceLine => { let kind = action.backspace_kind().expect("backspace action"); if self.active.view.mode == Mode::Insert { if self.edit_snippet("", Some(action)) { return None; } let mask = self.deletion_mask(Some(kind)); self.backspace(&mask, kind); if mask.iter().any(|active| *active) { self.push_insert_step(InsertStep::Backspace(mask, kind)); } } else { kind.truncate(&mut self.active.view.command_line); } } Action::Submit => { let command = std::mem::take(&mut self.active.view.command_line); let searching = self.active.view.mode == Mode::Search; let history = if searching { &mut self.search_history } else { &mut self.command_history }; if !(command.is_empty() || (!searching && command.trim().is_empty())) && history.back() != Some(&command) { if history.len() == PROMPT_HISTORY_LIMIT { history.pop_front(); } history.push_back(command.clone()); } self.active.view.mode = Mode::Normal; if searching { if !command.is_empty() { self.active.view.search_query = command; self.active.view.search_whole_word = false; } self.seek_search(Action::Search, 1); } else { return self.execute(&command); } } } None } fn set_word_search(&mut self) -> Option<(Cursor, bool)> { let cursor = self.cursor(); let line = &self.active.lines[cursor.row]; let origin = grapheme_byte(line, cursor.column); let tokens = || { line.grapheme_indices(true) .map(|(byte, grapheme)| (byte, word_kind(grapheme, false))) }; let Some((byte, kind)) = tokens() .find(|(byte, kind)| *byte >= origin && *kind == WordKind::Keyword) .or_else(|| { tokens().find(|(byte, kind)| *byte >= origin && *kind != WordKind::Whitespace) }) else { self.set_message("No word under cursor"); return None; }; let start = tokens() .rev() .skip_while(|(position, _)| *position >= byte) .take_while(|(_, previous)| *previous == kind) .last() .map_or(byte, |(position, _)| position); let end = tokens() .find(|(position, next)| *position > byte && *next != kind) .map_or(line.len(), |(position, _)| position); let query = &line[start..end]; let whole_word = kind == WordKind::Keyword; let newly_active = !self.active.view.search_highlight || self.active.view.search_query != query || self.active.view.search_whole_word != whole_word; if newly_active { if self.active.view.search_query != query { self.active.view.search_query = query.into(); } self.active.view.search_whole_word = whole_word; self.active.view.search_highlight = true; } Some(( Cursor { row: cursor.row, column: column_after_byte(line, start), }, newly_active, )) } fn seek_search(&mut self, mut action: Action, mut count: usize) { self.reveal_caret(); let primary_origin = if action == Action::SearchWord { let Some((origin, newly_active)) = self.set_word_search() else { return; }; if newly_active { action = Action::Search; count = 1; None } else { Some(origin) } } else { None }; let source = self.jump_location(); let forward = action != Action::SearchPrevious; self.active.view.search_match = None; if self.active.view.search_query.is_empty() { self.set_message("No previous search"); return; } self.active.view.search_highlight = true; let query = self.active.view.search_query.as_str(); let whole_word = self.active.view.search_whole_word; // ponytail: O(buffer graphemes Ɨ query bytes) scan; index matches if large buffers need it. let matches: Vec<_> = self .navigable_rows(0, self.active.lines.len()) .flat_map(|row| { let line = &self.active.lines[row]; search_match_bytes(line, query, whole_word).map(move |byte| (row, byte)) }) .collect(); if matches.is_empty() { self.set_message(format!("Not found: {}", self.active.view.search_query)); return; } for (index, target) in self.active.view.targets.iter_mut().enumerate() { let cursor = if index == self.active.view.primary { primary_origin.unwrap_or(target.active) } else { target.active }; let origin = ( cursor.row, grapheme_byte(&self.active.lines[cursor.row], cursor.column), ); let current = (action == Action::Search) .then(|| matches.partition_point(|position| *position <= origin)) .and_then(|end| end.checked_sub(1)) .filter(|index| { let found = matches[*index]; found.0 == origin.0 && origin.1 < found.1 + query.len() }); let first = if let Some(current) = current { current } else 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]; let start = Cursor { row: found.0, column: column_after_byte(&self.active.lines[found.0], found.1), }; if current.is_none() { target.active = start; target.preferred_columns = None; } if index == self.active.view.primary { self.active.view.search_match = Some(SearchMatch { start, end_column: start.column + grapheme_count(query), }); } } self.active.view.visual_motions = None; self.normalize_targets(); self.record_jump(source); } fn jump_location(&self) -> JumpLocation { let diff = self.diff_buffer_focus().and_then(|text_focus| { let diff = self.diff.as_ref()?; Some(DiffJumpLocation { comparison_id: diff.comparison_id?, file: diff.file, focus: diff.focus, text_focus, live_buffer: diff.live_buffer, }) }); JumpLocation { tab_id: self.tab_id, pane_id: self.pane_id, buffer_id: self.active.id, cursor: self.cursor(), diff, } } fn record_jump(&mut self, source: JumpLocation) { let destination = self.jump_location(); if source == destination { return; } if self.jumps.is_empty() { self.jumps.push_back(source); } else { self.jumps.truncate(self.jump_index + 1); self.jumps[self.jump_index] = source; } self.jumps.push_back(destination); if self.jumps.len() > 100 { self.jumps.pop_front(); } self.jump_index = self.jumps.len() - 1; } fn resolve_jump(&self, mut location: JumpLocation) -> Option { let buffer = self.buffer(location.buffer_id)?; location.cursor.row = location.cursor.row.min(buffer.lines.len() - 1); location.cursor.column = location .cursor .column .min(grapheme_count(&buffer.lines[location.cursor.row]).saturating_sub(1)); let pane_tab = |pane| { if self.pane_layout.contains(pane) { Some(self.tab_id) } else { self.tabs .iter() .find(|(_, tab)| tab.pane_layout.contains(pane)) .map(|(id, _)| *id) } }; let owner = |tab| { if tab == self.tab_id { self.diff_owner } else { self.tabs.get(&tab).and_then(|tab| tab.diff_owner) } }; let original_pane = pane_tab(location.pane_id) == Some(location.tab_id); if original_pane && let Some(context) = location.diff && owner(location.tab_id) == Some(location.pane_id) && self.tab_diff(location.tab_id).is_some_and(|diff| { diff.phase == Phase::View && diff.comparison_id == Some(context.comparison_id) && diff .comparison .as_ref() .and_then(|comparison| comparison.files.get(context.file)) .is_some_and(|file| file.loaded) }) { return Some(location); } let ordinary_pane = |pane| { let tab = pane_tab(pane)?; (owner(tab) != Some(pane)).then_some((tab, pane)) }; // Closed/replaced comparisons are never revived or displaced by history. let (tab, pane) = original_pane .then(|| ordinary_pane(location.pane_id)) .flatten() .or_else(|| { self.panes.iter().find_map(|(id, pane)| { (pane.buffer_id == location.buffer_id) .then(|| ordinary_pane(*id)) .flatten() }) }) .or_else(|| ordinary_pane(self.pane_id)) .or_else(|| self.panes.keys().find_map(|id| ordinary_pane(*id)))?; location.tab_id = tab; location.pane_id = pane; location.diff = None; Some(location) } fn restore_jump(&mut self, location: JumpLocation) { self.finish_pane_interaction(); self.select_tab(location.tab_id); self.select_pane(location.pane_id); self.switch_buffer(location.buffer_id); if let Some(context) = location.diff { let live_buffer = context.live_buffer.filter(|id| self.has_buffer(*id)); let diff = self.diff.as_mut().expect("resolved comparison exists"); if diff.file != context.file { diff.select_file_index(context.file); } if diff.live_buffer != live_buffer { diff.live_buffer = live_buffer; diff.live_revision = None; } diff.focus = context.focus; if context.focus == Focus::Files { diff.files_visible = true; } diff.normalize_focus(); if let Some(files) = &mut diff.files { files.focused = diff.files_visible && diff.focus == Focus::Files; } if context.text_focus == Focus::After && self.active.diff_snapshot.is_none() { diff.live_buffer = Some(self.active.id); } self.diff_bound = Some(( context.comparison_id, context.file, context.text_focus, self.active.id, )); self.refresh_live_diff(); self.diff .as_mut() .expect("resolved comparison exists") .reveal_source_row(context.text_focus, location.cursor.row); } self.place_navigation(TextPosition { row: location.cursor.row, column: location.cursor.column, }); } fn traverse_jumps(&mut self, forward: bool, count: usize) { self.reveal_caret(); let departure = self.jump_location(); let mut destination = departure; let mut index = self.jump_index; let mut remaining = count; let mut destination_index = None; // The timeline bounds this scan, even for a saturated count. while remaining > 0 { let next = if forward { index.checked_add(1).filter(|next| *next < self.jumps.len()) } else { index.checked_sub(1) }; let Some(next) = next else { break }; index = next; let Some(location) = self.resolve_jump(self.jumps[index]) else { continue; }; if location == destination { continue; } destination = location; destination_index = Some(index); remaining -= 1; } let Some(index) = destination_index else { self.set_message(if forward { "No newer jump location" } else { "No older jump location" }); return; }; self.jumps[self.jump_index] = departure; self.restore_jump(destination); self.jump_index = index; } fn park_source(&mut self) { self.finish_completion(); self.snippet = None; self.clear_hover(); if self.active.view.mode == Mode::Insert { self.finish_insert(); } if matches!( self.active.view.mode, Mode::Insert | Mode::Command | Mode::Search ) { self.active.view.mode = Mode::Normal; self.normalize_targets(); } self.active.view.command_line.clear(); self.active.view.command_from_visual = false; self.count = None; self.clear_sequence(); self.palette = None; } fn activate(&mut self, buffer: Buffer) { self.park_source(); self.stash_view(); let previous = std::mem::replace(&mut self.active, buffer); self.parked.insert(previous.id, previous); self.load_view(); self.reveal_caret(); } fn switch_buffer(&mut self, id: usize) { if id == self.active.id { self.reveal_caret(); self.park_source(); } else if let Some(buffer) = self.parked.remove(&id) { self.activate(buffer); } else { self.set_message(format!("No buffer: {id}")); } } fn adjacent_buffer(&self, forward: bool) -> usize { let mut buffers = self.buffers().filter(|buffer| !buffer.active); if forward { buffers .find(|buffer| buffer.id > self.active.id) .or_else(|| self.buffers().find(|buffer| !buffer.active)) } else { buffers .filter(|buffer| buffer.id < self.active.id) .last() .or_else(|| self.buffers().filter(|buffer| !buffer.active).last()) } .map_or(self.active.id, |buffer| buffer.id) } fn edit_buffer(&mut self, path: PathBuf, allow_missing: bool) -> io::Result<()> { let identity = path_identity(&path)?; let loaded = std::iter::once(&self.active) .chain(self.parked.values()) .find(|buffer| !buffer.resolver_readonly && buffer.identity.as_ref() == Some(&identity)) .map(|buffer| buffer.id); if let Some(id) = loaded { self.switch_buffer(id); } else { let buffer = Buffer::open( self.next_buffer_id, Some(path), Some(identity), allow_missing, )?; self.add_buffer(buffer); } if self.review_directory.is_some() && self.workspace_diff().is_none() && self.resolver().is_none() && let Err(error) = self.initialize_local_review() { self.set_message(format!("Local review: {error}")); } Ok(()) } fn add_buffer(&mut self, buffer: Buffer) { self.next_buffer_id += 1; self.activate(buffer); } fn delete_buffer(&mut self, id: usize, force: bool) { if self .resolvers .values() .any(|resolver| resolver.files.iter().any(|file| file.draft_buffer == id)) { self.set_message("Close the resolver workspace before deleting its Draft buffer"); return; } let buffer = if id == self.active.id { Some(&self.active) } else { self.parked.get(&id) }; let Some(buffer) = buffer else { self.set_message(format!("No buffer: {id}")); return; }; if buffer.dirty() && !force { self.set_message(format!( "Buffer {id} has unsaved changes; use :w or :bdelete!" )); return; } let owners: Vec<_> = self .diff_owner .filter(|_| { self.diff_return_buffer == Some(id) || self .workspace_diff() .is_some_and(|diff| diff.live_buffer == Some(id)) }) .into_iter() .chain( self.tabs .values() .filter(|tab| { tab.diff_return_buffer == Some(id) || tab.diff().is_some_and(|diff| diff.live_buffer == Some(id)) }) .filter_map(|tab| tab.diff_owner), ) .collect(); if !force && owners .iter() .any(|owner| self.with_pane(*owner, |editor| editor.current_review_dirty())) { self.set_message("Save the local review before deleting its comparison buffer"); return; } for owner in owners { self.with_pane(owner, |editor| { editor.detach_review_buffers(); editor.release_diff_buffers(); editor.diff = None; editor.parked_diff = None; editor.diff_owner = None; editor.outstanding_diff_request = None; }); } let replacement_id = if id == self.active.id { let next = self.adjacent_buffer(true); if next == id { let buffer = Buffer::open(self.next_buffer_id, None, None, true) .expect("an unnamed empty buffer is valid"); self.next_buffer_id += 1; self.activate(buffer); } else { self.switch_buffer(next); } self.active.id } else { self.active.id }; self.parked.remove(&id); for pane in self.panes.values_mut() { pane.views.remove(&id); if pane.buffer_id == id { pane.buffer_id = replacement_id; pane.views .entry(replacement_id) .or_insert_with(|| ViewState { targets: vec![Target::caret(Cursor::default())], ..ViewState::empty() }); } } self.set_message(format!( "Deleted buffer {id}; active buffer {}", self.active.id )); } fn buffer_command(&mut self, action: Action, name: &str, args: &str) -> bool { let source = self.jump_location(); match action { Action::Edit if args.is_empty() => self.set_message("Usage: :edit path"), Action::Edit => { if let Err(error) = self.edit_buffer(PathBuf::from(args), true) { self.set_message(format!("Open failed: {error}")); } } Action::Buffers | Action::BufferNext | Action::BufferPrevious if !args.is_empty() => { self.set_message(format!("The {name} command takes no arguments")); } Action::Buffers => { self.prepare_command(); self.palette = Some(Palette { query: String::new(), entries: Vec::new(), selected: 0, buffers: true, }); self.refresh_palette(); } Action::BufferNext | Action::BufferPrevious => { self.switch_buffer(self.adjacent_buffer(action == Action::BufferNext)) } Action::Buffer | Action::BufferDelete | Action::BufferForceDelete => { let id = if args.is_empty() && action != Action::Buffer { Some(self.active.id) } else if !args.is_empty() && args.bytes().all(|byte| byte.is_ascii_digit()) { args.parse::().ok().filter(|id| *id > 0) } else { None }; match id { Some(id) if self .buffer(id) .is_some_and(|buffer| buffer.diff_snapshot.is_some()) => { self.set_message(format!("No buffer {id}")) } Some(id) if action == Action::Buffer => self.switch_buffer(id), Some(id) => self.delete_buffer(id, action == Action::BufferForceDelete), None => self.set_message(format!( "Usage: :{name} {}", if action == Action::Buffer { "N" } else { "[N]" }, )), } } _ => return false, } if matches!( action, Action::Edit | Action::Buffer | Action::BufferNext | Action::BufferPrevious ) { self.record_jump(source); } true } fn any_dirty(&self) -> bool { self.active.dirty() || self.parked.values().any(Buffer::dirty) || self.local_reviews_dirty() || self.resolvers.values().any(|resolver| { resolver.pending || resolver .files .iter() .any(|file| file.whole_file_choice.is_some()) }) } fn restart(&mut self, force: bool) { if !force && self.local_reviews_dirty() { self.set_message("Unsaved local review; use :review.save "); return; } if force || !self.any_dirty() { self.restart = true; return; } let owners: Vec<_> = self .diff_owner .into_iter() .chain(self.tabs.values().filter_map(|tab| tab.diff_owner)) .collect(); for owner in owners { self.with_pane(owner, |editor| { editor.execute_action(Action::DiffClose.entry(), ""); }); } if !self.active.dirty() { let dirty = self.buffers().find(|buffer| buffer.dirty).unwrap().id; self.switch_buffer(dirty); } let clean: Vec<_> = self .parked .values() .filter(|buffer| !buffer.dirty()) .map(|buffer| buffer.id) .collect(); for id in clean { if self.has_buffer(id) { self.delete_buffer(id, false); } } let count = self.buffers().filter(|buffer| buffer.dirty).count(); self.set_message(format!( "Restart blocked: {count} buffer(s) have unsaved changes; clean buffers closed. Save them or use :restart!" )); } pub fn execute(&mut self, command: &str) -> Option { self.pending_lsp_navigation = None; self.finish_prompt(); let invocation = self.execute_command(command); self.leave_diff_on_navigation(); self.refresh_live_diff(); self.refresh_resolver_status(); self.refresh_resolver_comparison(); invocation } fn execute_command(&mut self, command: &str) -> Option { let command = command.trim().strip_prefix(':').unwrap_or(command.trim()); if let Some(command) = command.strip_prefix('!') { self.prepare_command(); self.active.view.command_line.clear(); self.finish_command_mode(); if command.trim().is_empty() { self.set_message("Shell command is empty"); return None; } return Some(Invocation { plugin: "core".into(), command: "shell".into(), args: command.into(), }); } let (name, args) = command .split_once(char::is_whitespace) .unwrap_or((command, "")); let args = args.trim(); if let Some(entry) = core_command(name) { return self.execute_action(entry, args); } self.pane_close_picker_origin = None; if self.diff.is_some() && !self.diff_editing() { self.count = None; self.clear_sequence(); self.palette = None; self.active.view.command_line.clear(); } else { self.prepare_command(); if self.active.view.mode != Mode::Visual { self.finish_command_mode(); } } let Some(feature) = self.owner(name) else { self.set_message(format!("Unknown command: {name}")); return None; }; Some(Invocation { plugin: feature.name.clone(), command: name.into(), args: args.into(), }) } fn prepare_command(&mut self) { self.finish_completion(); self.snippet = None; self.clear_hover(); self.count = None; self.clear_sequence(); self.palette = None; self.blur_file_picker(); } fn finish_command_mode(&mut self) { self.active.view.command_from_visual = false; if self.active.view.mode == Mode::Insert { self.finish_insert(); } self.clear_selection(); self.active.view.mode = Mode::Normal; self.normalize_targets(); } fn execute_action(&mut self, entry: &CoreAction, args: &str) -> Option { if entry.action == Action::WordWrapToggle && let Some(output) = &mut self.command_output { if args.is_empty() { output.wrapping = !output.wrapping; output.content.top = 0; output.content.left = 0; } else { self.set_message("The word_wrap.toggle command takes no arguments"); } self.clear_sequence(); return None; } if matches!( entry.action, Action::Comment | Action::ReviewEdit | Action::ReviewDelete | Action::ReviewResolve | Action::ReviewUnresolve | Action::ReviewNext | Action::ReviewPrevious | Action::ReviewOverview | Action::ReviewCopy | Action::ReviewSummary | Action::ReviewVerdict | Action::ReviewSave | Action::ReviewOpen | Action::ReviewNew ) { self.finish_prompt(); if let Err(error) = self.local_review_command(entry.action, args) { self.set_message(error); } return None; } if self.review_directory.is_some() && matches!( entry.action, Action::TabPrevious | Action::TabNext | Action::TabNew | Action::TabClose | Action::Quit | Action::Restart ) && let Err(error) = self.autosave_local_review() { self.set_message(format!("Review not saved: {error}")); return None; } if self.review_panel().is_some() && matches!( entry.action, Action::PaneSplitHorizontal | Action::PaneSplitVertical | Action::PaneFocusLeft | Action::PaneFocusRight | Action::PaneFocusUp | Action::PaneFocusDown | Action::TabNew | Action::Edit | Action::Buffers | Action::Buffer | Action::BufferNext | Action::BufferPrevious | Action::BufferDelete | Action::BufferForceDelete | Action::Diff | Action::DiffClose | Action::PullRequests | Action::Resolve ) { self.dismiss_review_panel(); } if matches!( entry.action, Action::Resolve | Action::ResolveNext | Action::ResolvePrevious | Action::ResolveUseSource | Action::ResolveSource | Action::ResolveBase | Action::ResolveNextFile | Action::ResolvePreviousFile | Action::ResolveFiles | Action::ResolveDraft | Action::ResolveHistory | Action::ResolveViewToggle | Action::ResolveClose ) { self.resolver_command(entry.action, args); return None; } if matches!( entry.action, Action::Save | Action::ForceSave | Action::SaveAndQuit | Action::ForceSaveAndQuit ) { self.prepare_command(); self.finish_command_mode(); let quit = matches!(entry.action, Action::SaveAndQuit | Action::ForceSaveAndQuit); if self.active.review_buffer.is_some() { match self.save_local_review((!args.is_empty()).then(|| Path::new(args))) { Ok(()) if quit && !self.any_dirty() => self.quit = true, Ok(()) => {} Err(error) => self.set_message(error), } return None; } if self.queue_resolver_save((!args.is_empty()).then(|| Path::new(args)), quit) { return None; } } if self.resolver().is_some_and(|resolver| resolver.loading) && !matches!( entry.action, Action::TabPrevious | Action::TabNext | Action::TabNew | Action::TabClose | Action::TabForceClose | Action::PaneClose | Action::PaneForceClose ) { self.set_message("Conflict inputs are still loading"); return None; } if self.resolver().is_some() && matches!( entry.action, Action::PaneSplitHorizontal | Action::PaneSplitVertical ) { self.set_message("Resolver panes are fixed; use :tab.new for an independent workspace"); return None; } if self.resolver().is_some() && matches!( entry.action, Action::Edit | Action::Buffer | Action::BufferNext | Action::BufferPrevious | Action::Diff | Action::PullRequests ) { self.new_tab(); } if !matches!(entry.action, Action::PaneClose | Action::PaneForceClose) { self.pane_close_picker_origin = None; } self.finish_prompt(); if matches!( entry.action, Action::PaneSplitHorizontal | Action::PaneSplitVertical | Action::PaneFocusLeft | Action::PaneFocusDown | Action::PaneFocusUp | Action::PaneFocusRight | Action::PaneClose | Action::PaneForceClose | Action::TabNew | Action::TabPrevious | Action::TabNext | Action::TabClose | Action::TabForceClose ) { if !args.is_empty() { self.pane_close_picker_origin = None; self.set_message(format!( "The {} command takes no arguments", entry.ex.unwrap() )); return None; } match entry.action { Action::PaneSplitHorizontal => self.split_pane(SplitAxis::Horizontal), Action::PaneSplitVertical => self.split_pane(SplitAxis::Vertical), Action::PaneFocusLeft => self.focus_pane(PaneDirection::Left), Action::PaneFocusDown => self.focus_pane(PaneDirection::Down), Action::PaneFocusUp => self.focus_pane(PaneDirection::Up), Action::PaneFocusRight => self.focus_pane(PaneDirection::Right), Action::PaneClose => self.close_pane(false), Action::PaneForceClose => self.close_pane(true), Action::TabNew => self.new_tab(), Action::TabPrevious => self.adjacent_tab(false), Action::TabNext => self.adjacent_tab(true), Action::TabClose => self.close_tab(false), Action::TabForceClose => self.close_tab(true), _ => unreachable!("pane actions handled above"), } return None; } if matches!( entry.action, Action::DiffModeToggle | Action::DiffViewToggle | Action::DiffOrientationToggle | Action::DiffReviewToggle ) { let name = entry.ex.expect("diff action has an Ex command"); if !args.is_empty() { self.set_message(format!("The {name} command takes no arguments")); return None; } if entry.action == Action::DiffReviewToggle && self .file_picker .as_ref() .is_some_and(|picker| picker.focused) { self.set_message("Select a file in the comparison"); return None; } if let Some(owner) = self.diff_owner.filter(|owner| *owner != self.pane_id) { return self.with_pane(owner, |editor| editor.execute_action(entry, args)); } self.count = None; self.clear_sequence(); self.palette = None; if self.active.view.mode == Mode::Command { self.active.view.command_line.clear(); self.active.view.mode = Mode::Normal; } if entry.action == Action::DiffModeToggle { let Some(diff) = self.diff.as_mut() else { self.set_message("No comparison is open"); return None; }; if let Err(error) = diff.toggle_comparison_mode() { self.set_message(error); return None; } self.release_diff_buffers(); self.diff_return_buffer = Some(self.active.id); return None; } if entry.action == Action::DiffReviewToggle { let mut review = std::mem::take(&mut self.local_review); let result = self .picker_diff_mut() .filter(|diff| diff.phase == Phase::View) .ok_or("No comparison is open") .and_then(|diff| diff.toggle_reviewed(&mut review)); self.local_review = review; match result { Ok(reviewed) => { if self.resolver().is_none() && let Err(error) = self.autosave_local_review() { self.set_message(format!("Review not saved: {error}")); return None; } self.set_message(if reviewed { "File marked reviewed" } else { "File marked unreviewed" }); } Err(error) => self.set_message(error), } return None; } let Some(diff) = self .picker_diff_mut() .filter(|diff| diff.phase == Phase::View) else { self.set_message("No comparison is open"); return None; }; if entry.action == Action::DiffViewToggle { diff.set_view_mode(match diff.view_mode { ViewMode::Split => ViewMode::Unified, ViewMode::Unified => ViewMode::Split, }); } else if diff.view_mode == ViewMode::Split { diff.split_axis = match diff.split_axis { SplitAxis::Vertical => SplitAxis::Horizontal, SplitAxis::Horizontal => SplitAxis::Vertical, }; } else { self.set_message("Diff orientation applies only to split view"); } return None; } if entry.action == Action::GitBlameToggle { if !args.is_empty() { self.set_message("The git_blame.toggle command takes no arguments"); return None; } self.set_git_blame(!self.git_blame); self.count = None; self.clear_sequence(); self.palette = None; if self.active.view.mode == Mode::Command { self.active.view.command_line.clear(); self.active.view.mode = Mode::Normal; } return None; } if entry.action == Action::WordWrapToggle { if !args.is_empty() { self.set_message("The word_wrap.toggle command takes no arguments"); return None; } if let Some(diff) = self.interaction_diff_mut() && diff.phase == Phase::View { diff.word_wrap = !diff.word_wrap; diff.follow_caret = true; } else { self.active.view.word_wrap = !self.active.view.word_wrap; } self.count = None; self.clear_sequence(); self.palette = None; if self.active.view.mode == Mode::Command { self.active.view.command_line.clear(); self.active.view.mode = Mode::Normal; } return None; } if entry.action == Action::DiffClose { if let Some(owner) = self.diff_owner.filter(|owner| *owner != self.pane_id) { return self.with_pane(owner, |editor| editor.execute_action(entry, args)); } if args.is_empty() { if self.resolver().is_some() { self.close_tab(false); return None; } self.diff.as_ref()?; if self.local_review_close_blocked() { return None; } self.detach_review_buffers(); self.release_diff_buffers(); self.diff = None; self.diff_owner = None; self.outstanding_diff_request = None; self.palette = None; self.count = None; self.clear_sequence(); if self.active.view.mode == Mode::Command { self.active.view.command_line.clear(); self.active.view.mode = Mode::Normal; } } else { self.set_message("The diff.close command takes no arguments"); } return None; } if self.mutation_blocked() && entry.action.mutates(self.active.view.mode) { self.set_message("Diff pane is read-only"); return None; } if matches!(entry.action, Action::Diff | Action::PullRequests) { if entry.action == Action::PullRequests { if args.split_whitespace().count() > 1 { self.set_message("Usage: pull_requests [remote]"); } else { let mut diff = DiffState::pull_requests(args.into()); diff.set_pull_request_preferences(self.pull_request_preferences.clone()); self.open_diff(diff); } } else if args.is_empty() { self.open_diff(DiffState::new()); } else { self.set_message("The diff command takes no arguments"); } return None; } let read_only = self.diff.is_some() && !self.diff_editing(); if !read_only { self.prepare_command(); } let action = entry.action; let name = entry .ex .expect("Ex dispatcher requires a canonical command"); // Switching must park directed Visual selections before ordinary Ex cleanup. if self.buffer_command(action, name, args) { return None; } // Link preflight must precede Ex cleanup: failure retains carets and text. if action == Action::FollowLink { if !args.is_empty() { self.set_message("The follow_link command takes no arguments"); } else if let Err(error) = self.follow_link() { self.set_message(error); } return None; } if !read_only { self.finish_command_mode(); } match action { Action::LspDefinition | Action::LspHover | Action::LspDiagnostics if !args.is_empty() => { self.set_message(format!("The {name} command takes no arguments")); } Action::LspDefinition | Action::LspHover | Action::LspDiagnostics => { if !self.document_actions_available() { self.set_message("LSP commands require a diff text pane"); return None; } return Some(Invocation { plugin: "core".into(), command: name.into(), args: String::new(), }); } Action::Save | Action::ForceSave | Action::SaveAndQuit | Action::ForceSaveAndQuit => { let force = matches!(action, Action::ForceSave | Action::ForceSaveAndQuit); if self.save( if args.is_empty() { None } else { Some(PathBuf::from(args)) }, force, ) && matches!(action, Action::SaveAndQuit | Action::ForceSaveAndQuit) { if self.any_dirty() { self.set_message("Unsaved changes in other buffers; use :w or :q!"); } else { self.quit = true; } } } Action::Quit | Action::ForceQuit if !args.is_empty() => { self.set_message("Quit commands take no arguments") } Action::Quit if self.any_dirty() => self.set_message("Unsaved changes; use :w or :q!"), Action::Quit | Action::ForceQuit => self.quit = true, Action::Restart | Action::ForceRestart if !args.is_empty() => { self.set_message("Restart commands take no arguments"); } Action::Restart | Action::ForceRestart => { self.restart(action == Action::ForceRestart); } Action::Plugins if !args.is_empty() => { self.set_message("The plugins command takes no arguments") } Action::Plugins => { let mut features: Vec<_> = self.features.values().collect(); features.sort_unstable_by(|left, right| left.name.cmp(&right.name)); let mut message = format!("Capabilities: {}", self.capabilities.join(", ")); for feature in features { message.push_str(" | "); message.push_str(&feature.name); if feature.core { message.push_str(" [core]"); } message.push_str(": "); for (index, command) in feature.commands.iter().enumerate() { if index > 0 { message.push_str(", "); } message.push_str(command); } } self.set_message(message); } _ => unreachable!("privately installed core commands"), } None } /// Applies one validated plugin response. Successful text effects collapse /// to the primary caret: Insert runs once there, Replace replaces the buffer. /// Rejected responses and presentation-only effects retain the complete set. pub fn apply(&mut self, effects: Vec) -> Result<(), String> { if self.mutation_blocked() && effects .iter() .any(|effect| matches!(effect, Effect::Insert(_) | Effect::Replace(_))) { return Err("Diff view is read-only".into()); } validate_effects(&effects)?; if !effects .iter() .any(|effect| matches!(effect, Effect::Insert(_) | Effect::Replace(_))) { for effect in effects { match effect { Effect::Message(text) => self.set_message(text), Effect::Label { name, text } => { self.set_label(name, text)?; } _ => unreachable!("presentation-only effects"), } } return Ok(()); } // Validation precedes even history/presentation changes, so rejection is atomic. let previous_mode = self.active.view.mode; let inserting = self.active.view.mode == Mode::Insert; if inserting { self.finish_insert(); } self.finish_completion(); let before = effects .iter() .any(|effect| { matches!(effect, Effect::Insert(text) if !text.is_empty()) || matches!(effect, Effect::Replace(_)) }) .then(|| self.snapshot()); if before.is_some() { self.clear_hover(); self.count = None; self.clear_sequence(); self.clear_selection(); self.collapse_primary(); // Plugin inserts use one primary insertion point, not the selection set. self.active.view.mode = Mode::Insert; } for effect in effects { match effect { Effect::Insert(text) => self.insert_text(&normalize_newlines(&text)), Effect::Replace(text) => { self.active.replace_lines(split_lines(&text)); self.clamp_other_views(); self.active.view.search_match = None; self.normalize_targets(); } Effect::Message(text) => self.set_message(text), Effect::Label { name, text } => { self.set_label(name, text)?; } } } if before.is_some() { self.active.view.mode = if inserting { Mode::Insert } else { Mode::Normal }; } self.normalize_targets(); if let Some(before) = before { self.record(before); } if inserting { self.begin_insert(); } if self.active.view.mode != previous_mode { self.refresh_palette(); } self.refresh_live_diff(); Ok(()) } /// Refreshes every live buffer, including views parked in inactive tabs. pub(crate) fn refresh_files(&mut self) -> bool { let active_id = self.active.id; let mut changed = false; let mut active_reloaded = false; let mut notice = None; let resolver_buffers: HashSet<_> = self .resolvers .values() .flat_map(|resolver| resolver.files.iter().map(|file| file.draft_buffer)) .collect(); for buffer in std::iter::once(&mut self.active).chain(self.parked.values_mut()) { if buffer.resolver_readonly || resolver_buffers.contains(&buffer.id) { continue; } match buffer.refresh_file() { Ok(reloaded) => { buffer.reload_error = None; if !reloaded { continue; } changed = true; active_reloaded |= buffer.id == active_id; for pane in self.panes.values_mut() { if let Some(view) = pane.views.get_mut(&buffer.id) { view.reload(&buffer.lines); } } } Err(error) => { let message = format!( "Reload failed for {}: {error}", buffer.path.as_deref().unwrap().display(), ); if buffer.reload_error.as_ref() != Some(&message) { buffer.reload_error = Some(message.clone()); notice = Some(message); changed = true; } } } } if active_reloaded { self.completion = None; self.snippet = None; self.clear_hover(); self.pending_lsp_navigation = None; self.normalize_targets(); } if let Some(notice) = notice { self.set_message(notice); } if changed { self.refresh_live_diff(); } changed } fn save(&mut self, path: Option, force: bool) -> bool { if self.active.review_buffer.is_some() { return match self.save_local_review(path.as_deref()) { Ok(()) => true, Err(error) => { self.set_message(error); false } }; } if self.queue_resolver_save(path.as_deref(), false) { return false; } if self.mutation_blocked() { self.set_message("Diff pane is read-only"); return false; } let Some(path) = path.or_else(|| self.active.path.clone()) else { self.set_message("No file name; use :w path"); return false; }; let identity = match path_identity(&path) { Ok(identity) => identity, Err(error) => { self.set_message(format!("Write failed: {error}")); return false; } }; if let Some(buffer) = self .parked .values() .find(|buffer| buffer.identity.as_ref() == Some(&identity)) { self.set_message(format!( "Write refused: path belongs to buffer {}", buffer.id )); return false; } let expected = (!force && (self.active.identity.as_ref() == Some(&identity) || self.active.path.as_ref() == Some(&path))) .then_some(( self.active.saved_lines.as_slice(), self.active.disk_stamp.is_some(), )); match atomic_save(&path, &self.active.lines, expected) { Ok(stamp) => { self.active.saved_lines.clone_from(&self.active.lines); self.active.dirty_cache.set(Some(false)); self.active.save_revision = self.active.save_revision.wrapping_add(1); self.active.disk_stamp = Some(stamp); self.active.reload_error = None; if self.active.path.as_ref() != Some(&path) { self.active.semantic_tokens = None; } if self.active.identity.as_ref() != Some(&identity) && let Some(diff) = &mut self.diff { diff.live_buffer = None; diff.live_revision = None; } self.active.path = Some(path); self.active.identity = Some(identity); self.set_message("Written"); true } Err(error) => { self.set_message(format!("Write failed: {error}")); false } } } fn snapshot(&self) -> Snapshot { Snapshot { lines: self.active.lines.clone(), targets: self.active.view.targets.clone(), primary: self.active.view.primary, } } fn record(&mut self, before: Snapshot) { if before.lines != self.active.lines { self.active.view.search_match = None; self.active .undo .push(HistoryEntry::new(before, &self.active.lines)); self.active.redo.clear(); } } fn record_native(&mut self, before: Snapshot, recipe: EditRecipe) { if before.lines != self.active.lines { self.last_edit = Some(recipe); } self.record(before); } fn target_range(&self, target: Target) -> Selection { if self.visual_linewise() { let first = target.anchor.row.min(target.active.row); let last = target.anchor.row.max(target.active.row); return Selection { start: Cursor { row: first, column: 0, }, end: if last + 1 < self.active.lines.len() { Cursor { row: last + 1, column: 0, } } else { Cursor { row: last, column: grapheme_count(&self.active.lines[last]), } }, }; } let start = target.anchor.min(target.active); let end = self.next_boundary(target.anchor.max(target.active)); Selection { start, end } } fn next_boundary(&self, mut cursor: Cursor) -> Cursor { if cursor.column < grapheme_count(&self.active.lines[cursor.row]) { cursor.column += 1; } else if cursor.row + 1 < self.active.lines.len() { cursor.row += 1; cursor.column = 0; } cursor } fn previous_boundary(&self, mut cursor: Cursor) -> Cursor { if cursor.column > 0 { cursor.column -= 1; } else if cursor.row > 0 { cursor.row -= 1; cursor.column = grapheme_count(&self.active.lines[cursor.row]); } cursor } fn range_target(&self, range: Selection, forward: bool) -> Target { let last = if range.start == range.end { range.start } else { self.previous_boundary(range.end) }; Target { anchor: if forward { range.start } else { last }, active: if forward { last } else { range.start }, preferred_columns: None, } } fn max_column(&self, row: usize) -> usize { let count = grapheme_count(&self.active.lines[row]); if self.active.view.mode == Mode::Insert || (self.active.view.mode == Mode::Visual && row + 1 < self.active.lines.len()) { count } else { count.saturating_sub(1) } } fn normalize_targets(&mut self) { if self.active.view.mode != Mode::Visual { self.active.view.linewise = false; } for index in 0..self.active.view.targets.len() { let mut target = self.active.view.targets[index]; target.active.row = target.active.row.min(self.active.lines.len() - 1); target.active.column = target.active.column.min(self.max_column(target.active.row)); if self.active.view.mode == Mode::Visual { target.anchor.row = target.anchor.row.min(self.active.lines.len() - 1); target.anchor.column = target.anchor.column.min(self.max_column(target.anchor.row)); } else { target.anchor = target.active; } self.active.view.targets[index] = target; } if self.visual_linewise() { let mut ordered: Vec<_> = self .active .view .targets .iter() .copied() .enumerate() .map(|(index, target)| (target, index == self.active.view.primary)) .collect(); ordered.sort_by_key(|(target, _)| { ( target.anchor.row.min(target.active.row), target.anchor.row.max(target.active.row), ) }); let mut merged: Vec<(Target, bool)> = Vec::with_capacity(ordered.len()); for (target, primary) in ordered { if let Some((previous, was_primary)) = merged.last_mut() && target.anchor.row.min(target.active.row) <= previous.anchor.row.max(previous.active.row) { let first = previous .anchor .row .min(previous.active.row) .min(target.anchor.row.min(target.active.row)); let last = previous .anchor .row .max(previous.active.row) .max(target.anchor.row.max(target.active.row)); let representative = if primary { target } else { *previous }; *previous = representative; if representative.active >= representative.anchor { previous.anchor.row = first; previous.active.row = last; } else { previous.anchor.row = last; previous.active.row = first; } previous.anchor.column = previous .anchor .column .min(self.max_column(previous.anchor.row)); previous.active.column = previous .active .column .min(self.max_column(previous.active.row)); *was_primary |= primary; } else { merged.push((target, primary)); } } if merged.len() != self.active.view.targets.len() { self.active.view.visual_motions = None; } self.active.view.primary = merged.iter().position(|(_, primary)| *primary).unwrap_or(0); self.active.view.targets.clear(); self.active .view .targets .extend(merged.into_iter().map(|(target, _)| target)); return; } let ordered: Vec<_> = self .active .view .targets .iter() .copied() .enumerate() .map(|(index, target)| { let range = if self.active.view.mode == Mode::Visual { self.target_range(target) } else { Selection { start: target.active, end: target.active, } }; (target, index == self.active.view.primary, range) }) .collect(); self.merge_target_ranges(ordered, false); } fn merge_target_ranges( &mut self, mut ordered: Vec<(Target, bool, Selection)>, keep_ranges: bool, ) -> Vec { let count = ordered.len(); ordered.sort_by_key(|(_, _, range)| (range.start, range.end)); let mut merged: Vec<(Target, bool, Selection)> = Vec::with_capacity(ordered.len()); for (target, primary, range) in ordered { if let Some((previous, was_primary, previous_range)) = merged.last_mut() && ranges_intersect(*previous_range, range) { let representative = if primary { target } else { *previous }; previous_range.start = previous_range.start.min(range.start); previous_range.end = previous_range.end.max(range.end); *previous = if self.active.view.mode == Mode::Visual && self.target_range(representative) != *previous_range { self.range_target( *previous_range, representative.active >= representative.anchor, ) } else { representative }; *was_primary |= primary; continue; } merged.push((target, primary, range)); } if merged.len() != count { self.active.view.visual_motions = None; } let ranges = if keep_ranges { merged.iter().map(|(_, _, range)| *range).collect() } else { Vec::new() }; self.active.view.primary = merged .iter() .position(|(_, primary, _)| *primary) .unwrap_or(0); self.active.view.targets.clear(); self.active .view .targets .extend(merged.into_iter().map(|(target, _, _)| target)); ranges } fn collapse_primary(&mut self) { let target = self.active.view.targets[self.active.view.primary]; self.active.view.targets.clear(); self.active.view.targets.push(target); self.active.view.primary = 0; } fn clear_selection(&mut self) { if self.active.view.mode == Mode::Visual { self.active.view.visual_motions = None; self.active.view.mode = Mode::Normal; self.normalize_targets(); } } fn split_rows(&mut self, end: bool, insert: bool) { self.active.view.visual_motions = None; let rows: Vec<_> = if self.visual_linewise() { self.linewise_rows().collect() } else { self.selections() .into_iter() .map(|range| { let last = if range.end.column == 0 && range.end.row > range.start.row { range.end.row - 1 } else { range.end.row }; range.start.row..=last }) .collect() }; let primary_row = self.cursor().row; let mut carets = Vec::new(); let mut primary = 0; for (index, rows) in rows.into_iter().enumerate() { for row in self.navigable_rows(*rows.start(), rows.end().saturating_add(1)) { if index == self.active.view.primary && row == primary_row { primary = carets.len(); } let column = if end { grapheme_count(&self.active.lines[row]) } else { first_nonblank(&self.active.lines[row]) }; carets.push(Target::caret(Cursor { row, column })); } } self.active.view.targets = carets; self.active.view.primary = primary; self.active.view.mode = if insert { Mode::Insert } else { Mode::Normal }; self.normalize_targets(); } fn clone_vertical(&mut self, down: bool) { self.active.view.visual_motions = None; let count = self.active.view.targets.len(); let primary = self.active.view.primary; for index in 0..count { let target = self.active.view.targets[index]; let shift = |row: usize| { if down { self.navigable_rows(row.saturating_add(1), self.active.lines.len()) .next() } else { self.navigable_rows(0, row).next_back() } }; let Some(anchor_row) = shift(target.anchor.row) else { continue; }; let Some(active_row) = shift(target.active.row) else { continue; }; let columns = target.preferred_columns.unwrap_or_else(|| { ( display_column(&self.active.lines[target.anchor.row], target.anchor.column), display_column(&self.active.lines[target.active.row], target.active.column), ) }); self.active.view.targets[index].preferred_columns = Some(columns); let cloned = Target { anchor: Cursor { row: anchor_row, column: column_at_display( &self.active.lines[anchor_row], columns.0, self.max_column(anchor_row), ), }, active: Cursor { row: active_row, column: column_at_display( &self.active.lines[active_row], columns.1, self.max_column(active_row), ), }, preferred_columns: Some(columns), }; if index == primary { self.active.view.primary = self.active.view.targets.len(); } self.active.view.targets.push(cloned); } self.normalize_targets(); } fn select_occurrences(&mut self, action: Action) { // Occurrences are literal text ranges, so rebuilding them is characterwise. if self.visual_linewise() { let ranges = self.selections(); let targets = self .active .view .targets .iter() .zip(ranges) .map(|(target, range)| self.range_target(range, target.active >= target.anchor)) .collect(); self.active.view.targets = targets; self.active.view.linewise = false; self.active.view.visual_motions = None; } let text = self.active.lines.join("\n"); let offsets = line_offsets(&self.active.lines); let ranges = self.selections(); let primary_range = ranges[self.active.view.primary]; let start = cursor_offset(&self.active.lines, &offsets, primary_range.start); let end = cursor_offset(&self.active.lines, &offsets, primary_range.end); if start == end { self.set_message("Nothing selected"); return; } let phrase = &text[start..end]; let mut occupied: Vec<_> = ranges .iter() .map(|range| { ( cursor_offset(&self.active.lines, &offsets, range.start), cursor_offset(&self.active.lines, &offsets, range.end), ) }) .collect(); // ponytail: literal scan is O(buffer graphemes Ɨ phrase bytes); index only for large buffers. let diff = self .diff_buffer_focus() .and_then(|focus| self.diff().map(|diff| (diff, focus))); let candidates: Vec<_> = self .navigable_rows(0, self.active.lines.len()) .flat_map(|row| { let offset = offsets[row]; let line = &self.active.lines[row]; line.grapheme_indices(true) .map(move |(byte, _)| offset + byte) .chain(std::iter::once(offset + line.len())) }) .filter_map(|byte| { let end = byte + phrase.len(); (text[byte..].starts_with(phrase) && GraphemeCursor::new(end, text.len(), true) .is_boundary(&text, 0) .expect("complete buffer supplies grapheme context") && diff.is_none_or(|(diff, focus)| { let last = offsets .partition_point(|offset| *offset < end) .saturating_sub(1); diff.source_display_row(focus, last).is_some() })) .then_some((byte, end)) }) .collect(); if matches!(action, Action::SelectAll) { for (start, end) in candidates { if occupied .iter() .any(|(left, right)| start < *right && *left < end) { continue; } occupied.push((start, end)); self.active.view.targets.push(self.range_target( Selection { start: offset_cursor(&self.active.lines, &offsets, start, true), end: offset_cursor(&self.active.lines, &offsets, end, true), }, true, )); } } else { let available = |(left, right): &(usize, usize)| { !occupied .iter() .any(|(start, end)| *left < *end && *start < *right) }; let next = candidates .iter() .filter(|range| available(range)) .find(|(left, _)| *left > start) .or_else(|| candidates.iter().find(|range| available(range))); let Some(&(start, end)) = next else { self.set_message("No more occurrences"); return; }; let target = self.range_target( Selection { start: offset_cursor(&self.active.lines, &offsets, start, true), end: offset_cursor(&self.active.lines, &offsets, end, true), }, true, ); if matches!(action, Action::SkipOccurrence) { self.active.view.targets[self.active.view.primary] = target; } else { self.active.view.primary = self.active.view.targets.len(); self.active.view.targets.push(target); } } self.active.view.visual_motions = None; self.normalize_targets(); } fn line_selections(&mut self, count: usize) -> Vec { self.active.view.mode = Mode::Visual; self.active.view.linewise = true; self.active.view.visual_motions = None; let last = self.active.lines.len() - 1; for target in &mut self.active.view.targets { target.anchor = Cursor { row: target.active.row, column: 0, }; target.active = Cursor { row: target .anchor .row .saturating_add(count.saturating_sub(1)) .min(last), column: 0, }; target.preferred_columns = None; } self.normalize_targets(); self.selections() } fn copy_lines(&mut self, count: usize) { let targets = self.active.view.targets.clone(); let primary = self.active.view.primary; let mode = self.active.view.mode; let linewise = self.active.view.linewise; let visual_motions = self.active.view.visual_motions.take(); let ranges = self.line_selections(count); let fragments = self.selection_fragments(&ranges); self.active.view.targets = targets; self.active.view.primary = primary; self.active.view.mode = mode; self.active.view.linewise = linewise; self.active.view.visual_motions = visual_motions; match self.store_clipboard( fragments, self.clipboard_mode == ClipboardMode::System, true, ) { Ok(()) => self.set_message("Copied lines"), Err(error) => self.set_message(error), } } fn edit_lines(&mut self, change: bool, count: usize) { let targets = self.active.view.targets.clone(); let primary = self.active.view.primary; let ranges = self.line_selections(count); let fragments = self.selection_fragments(&ranges); if let Err(error) = self.store_clipboard( fragments, self.clipboard_mode == ClipboardMode::System, true, ) { self.active.view.targets = targets; self.active.view.primary = primary; self.active.view.mode = Mode::Normal; self.active.view.linewise = false; self.set_message(error); return; } let before = Snapshot { lines: self.active.lines.clone(), targets, primary, }; let start = EditStart::Lines { change, count }; if change { self.start_insert(before, start); self.edit_ranges(Action::Change, &ranges); } else { self.edit_ranges(Action::Cut, &ranges); self.active.view.mode = Mode::Normal; self.normalize_targets(); self.record_native( before, EditRecipe { start, steps: Vec::new(), }, ); } self.reveal_caret(); } fn normal_ranges(&mut self, action: Action, count: usize) -> Vec { // Insert boundaries include EOF; Normal word motions clamp it to the last grapheme. self.active.view.mode = Mode::Insert; self.active.view.linewise = false; let ordered = self .active .view .targets .iter() .copied() .enumerate() .map(|(index, target)| { let cursor = target.active; let end = match action { Action::DeleteWordForward | Action::ChangeWordForward => { self.word_destination(cursor, Action::DeleteWordForward, count) } Action::DeleteWordBackward | Action::ChangeWordBackward => { self.word_destination(cursor, Action::DeleteWordBackward, count) } Action::Delete | Action::SubstituteCharacter => Cursor { row: cursor.row, column: cursor .column .saturating_add(count) .min(grapheme_count(&self.active.lines[cursor.row])), }, _ => Cursor { row: cursor.row, column: grapheme_count(&self.active.lines[cursor.row]), }, }; ( target, index == self.active.view.primary, Selection { start: cursor.min(end), end: cursor.max(end), }, ) }) .collect(); self.merge_target_ranges(ordered, true) } fn edit_normal(&mut self, action: Action, count: usize) { let copy = action == Action::CopyToLineEnd; let saved = copy.then(|| (self.active.view.targets.clone(), self.active.view.primary)); let before = (!copy).then(|| self.snapshot()); let ranges = self.normal_ranges(action, count); let result = self.store_clipboard( self.selection_fragments(&ranges), self.clipboard_mode == ClipboardMode::System, false, ); if copy || result.is_err() { let (targets, primary) = saved.unwrap_or_else(|| { let before = before.unwrap(); (before.targets, before.primary) }); self.active.view.targets = targets; self.active.view.primary = primary; self.active.view.mode = Mode::Normal; if let Err(error) = result { self.set_message(error); } else { self.set_message("Copied line suffix"); } return; } let before = before.unwrap(); let start = EditStart::Normal { action, count }; if matches!( action, Action::ChangeToLineEnd | Action::SubstituteCharacter | Action::ChangeWordForward | Action::ChangeWordBackward ) { self.start_insert(before, start); self.edit_ranges(Action::Change, &ranges); } else { self.edit_ranges(Action::Cut, &ranges); self.active.view.mode = Mode::Normal; self.normalize_targets(); self.record_native( before, EditRecipe { start, steps: Vec::new(), }, ); } self.reveal_caret(); } fn replace_characters(&mut self, replacement: char, ranges: &[Selection]) { let offsets = line_offsets(&self.active.lines); let mut bytes = [0; 4]; let replacement: &str = replacement.encode_utf8(&mut bytes); let edits = ranges .iter() .enumerate() .filter(|(_, range)| range.start != range.end) .map(|(target, range)| { let start = cursor_offset(&self.active.lines, &offsets, range.start); let end = cursor_offset(&self.active.lines, &offsets, range.end); let text = if range.start.row == range.end.row { let count = range.end.column - range.start.column; if count == 1 { Cow::Borrowed(replacement) } else { Cow::Owned(replacement.repeat(count)) } } else { let mut text = String::with_capacity(end - start); for row in range.start.row..=range.end.row { let first = if row == range.start.row { range.start.column } else { 0 }; let last = if row == range.end.row { range.end.column } else { grapheme_count(&self.active.lines[row]) }; text.reserve((last - first) * replacement.len()); for _ in first..last { text.push_str(replacement); } if row < range.end.row { text.push('\n'); } } Cow::Owned(text) }; Edit { target: Some(target), start, end, text, caret: 0, round_up: false, } }) .collect(); self.apply_edits(edits); self.active.view.visual_motions = None; self.active.view.linewise = false; self.active.view.mode = Mode::Normal; self.normalize_targets(); } fn edit_selection(&mut self, action: Action) { let ranges = self.selections(); let fragments = self.selection_fragments(&ranges); let nonempty = fragments.iter().any(|fragment| !fragment.is_empty()); if let Err(error) = self.store_clipboard( fragments, matches!(action, Action::CopySystem) || self.clipboard_mode == ClipboardMode::System, self.visual_linewise(), ) { self.set_message(error); return; } let cut = matches!(action, Action::Cut | Action::Change); let changing = matches!(action, Action::Change); let recipe = cut.then(|| EditRecipe { start: EditStart::Selection { change: changing, selector: self.selection_intent(&ranges), }, steps: Vec::new(), }); let before = cut.then(|| self.snapshot()); if changing { self.start_insert(before.unwrap(), recipe.unwrap().start); self.edit_ranges(action, &ranges); return; } self.edit_ranges(action, &ranges); self.active.view.mode = Mode::Normal; self.normalize_targets(); if let Some(before) = before { self.record_native(before, recipe.unwrap()); } self.set_message(if !nonempty { "Nothing selected" } else if cut { "Cut selections" } else { "Copied selections" }); } fn selection_fragments(&self, ranges: &[Selection]) -> Vec { if self.active.view.linewise { return self .active .view .targets .iter() .map(|target| { let first = target.anchor.row.min(target.active.row); let last = target.anchor.row.max(target.active.row); let mut fragment = self.active.lines[first..=last].join("\n"); fragment.push('\n'); fragment }) .collect(); } let offsets = line_offsets(&self.active.lines); ranges .iter() .map(|range| { let start = cursor_offset(&self.active.lines, &offsets, range.start); let end = cursor_offset(&self.active.lines, &offsets, range.end); let mut fragment = String::with_capacity(end - start); for (row, &offset) in offsets .iter() .enumerate() .take(range.end.row + 1) .skip(range.start.row) { if row != range.start.row { fragment.push('\n'); } let line = &self.active.lines[row]; let start = if row == range.start.row { start - offset } else { 0 }; let end = if row == range.end.row { end - offset } else { line.len() }; fragment.push_str(&line[start..end]); } fragment }) .collect() } fn store_clipboard( &mut self, fragments: Vec, system: bool, linewise: bool, ) -> Result<(), String> { if fragments.iter().all(|fragment| fragment.is_empty()) { return Ok(()); } if system { let exported = fragments.join(if linewise { "" } else { "\n" }); self.system_clipboard.write(&exported)?; self.owned_system_clipboard = Some((exported, fragments, linewise)); } else { self.clipboard = fragments; self.clipboard_linewise = linewise; } Ok(()) } // Range mutation has no register/history side effects. fn edit_ranges(&mut self, action: Action, ranges: &[Selection]) { let cut = matches!(action, Action::Cut | Action::Change); self.active.view.visual_motions = None; let offsets = line_offsets(&self.active.lines); if self.active.view.linewise { let last_row = self.active.lines.len() - 1; let eof = offsets[last_row] + self.active.lines[last_row].len(); let changing = matches!(action, Action::Change); self.active.view.mode = Mode::Insert; self.active.view.linewise = false; if cut { let tail_start = if !changing && self.active.view.targets.last().is_some_and(|target| { target.anchor.row.max(target.active.row) == last_row }) { let mut index = self.active.view.targets.len() - 1; while index > 0 { let current = self.active.view.targets[index]; let previous = self.active.view.targets[index - 1]; if previous.anchor.row.max(previous.active.row) + 1 != current.anchor.row.min(current.active.row) { break; } index -= 1; } Some(index) } else { None }; let edits = self .active .view .targets .iter() .enumerate() .map(|(target, item)| { let first = item.anchor.row.min(item.active.row); let last = item.anchor.row.max(item.active.row); let mut start = offsets[first]; let end = if last < last_row { offsets[last + 1] } else { eof }; // Remove the tail's preceding separator once, at its first edit. if tail_start == Some(target) && first > 0 { start -= 1; } Edit { target: Some(target), start, end, text: Cow::Borrowed(if changing && last < last_row { "\n" } else { "" }), caret: 0, round_up: true, } }) .collect(); self.apply_edits(edits); if !changing { for target in &mut self.active.view.targets { target.active.column = first_nonblank(&self.active.lines[target.active.row]); } } } else { for target in &mut self.active.view.targets { target.active = Cursor { row: target.anchor.row.min(target.active.row), column: 0, }; } } return; } self.active.view.mode = Mode::Insert; if cut { let edits = ranges .iter() .enumerate() .map(|(target, range)| Edit { target: Some(target), start: cursor_offset(&self.active.lines, &offsets, range.start), end: cursor_offset(&self.active.lines, &offsets, range.end), text: Cow::Borrowed(""), caret: 0, round_up: true, }) .collect(); self.apply_edits(edits); } else { for (target, range) in self.active.view.targets.iter_mut().zip(ranges) { target.active = range.start; } } } fn read_system_clipboard(&mut self) -> Result<(Vec, bool), String> { let text = self.system_clipboard.read()?; validate_paste_text(&text)?; if let Some((exported, fragments, linewise)) = &self.owned_system_clipboard && *exported == text { return Ok((fragments.clone(), *linewise)); } self.owned_system_clipboard = None; Ok(( if text.is_empty() { Vec::new() } else { vec![normalize_newlines(&text).into_owned()] }, false, )) } fn paste_clipboard(&mut self, after: bool, system: bool) { let (mut fragments, linewise) = if system { match self.read_system_clipboard() { Ok(payload) => payload, Err(error) => { self.set_message(error); return; } } } else { (std::mem::take(&mut self.clipboard), self.clipboard_linewise) }; if fragments.is_empty() { self.set_message("Clipboard is empty"); } else if fragments.len() != 1 && fragments.len() != self.active.view.targets.len() { self.set_message("Clipboard fragment count does not match caret count"); } else { let before = self.snapshot(); let ranges = self.selections(); let selector = (!ranges.is_empty()).then(|| self.selection_intent(&ranges)); if let Err(error) = self.paste_fragments(after, &fragments, linewise) { self.set_message(error); if !system { self.clipboard = fragments; } return; } if before.lines != self.active.lines { self.last_edit = Some(EditRecipe { start: EditStart::Paste { after, linewise, selector, fragments: if system { std::mem::take(&mut fragments) } else { fragments.clone() }, }, steps: Vec::new(), }); } self.record(before); } if !system { self.clipboard = fragments; } } fn paste_fragments( &mut self, after: bool, fragments: &[String], linewise: bool, ) -> Result<(), &'static str> { let offsets = line_offsets(&self.active.lines); if self.active.view.mode == Mode::Visual { let edits = self .active .view .targets .iter() .enumerate() .map(|(target, item)| { let range = self.target_range(*item); let text = &fragments[if fragments.len() == 1 { 0 } else { target }]; Edit { target: Some(target), start: cursor_offset(&self.active.lines, &offsets, range.start), end: cursor_offset(&self.active.lines, &offsets, range.end), text: Cow::Borrowed(text), caret: text.len(), round_up: true, } }) .collect(); self.active.view.mode = Mode::Insert; self.active.view.linewise = false; self.apply_edits(edits); self.leave_insert(); return Ok(()); } if linewise { if fragments.len() != 1 && self .active .view .targets .windows(2) .enumerate() .any(|(index, targets)| { targets[0].active.row == targets[1].active.row && fragments[index] != fragments[index + 1] }) { return Err("Conflicting linewise clipboard fragments target the same row"); } let last = self.active.lines.len() - 1; let edits = self .active .view .targets .iter() .enumerate() .map(|(target, item)| { let fragment = &fragments[if fragments.len() == 1 { 0 } else { target }]; let row = item.active.row; let (start, text, prefix) = if after && row == last { let mut text = String::with_capacity(fragment.len()); text.push('\n'); text.push_str(fragment.strip_suffix('\n').unwrap_or(fragment)); ( offsets[row] + self.active.lines[row].len(), Cow::Owned(text), 1, ) } else { ( offsets[row + usize::from(after)], Cow::Borrowed(fragment.as_str()), 0, ) }; let first = fragment.split('\n').next().unwrap(); Edit { target: Some(target), start, end: start, caret: prefix + grapheme_byte(first, first_nonblank(first)), text, round_up: true, } }) .collect(); self.active.view.mode = Mode::Insert; self.active.view.linewise = false; self.apply_edits(edits); self.active.view.mode = Mode::Normal; self.normalize_targets(); return Ok(()); } let edits = self .active .view .targets .iter() .enumerate() .map(|(target, item)| { let mut cursor = item.active; if after { cursor.column = (cursor.column + 1).min(grapheme_count(&self.active.lines[cursor.row])); } let start = cursor_offset(&self.active.lines, &offsets, cursor); let text = &fragments[if fragments.len() == 1 { 0 } else { target }]; Edit { target: Some(target), start, end: start, text: Cow::Borrowed(text), caret: text.len(), round_up: true, } }) .collect(); self.active.view.mode = Mode::Insert; self.apply_edits(edits); self.leave_insert(); Ok(()) } fn begin_insert(&mut self) { self.start_insert(self.snapshot(), EditStart::Insert(Action::Insert)); } fn start_insert(&mut self, before: Snapshot, start: EditStart) { self.active.view.insert_start = Some(before); self.active.view.pending_edit = Some(EditRecipe { start, steps: Vec::new(), }); self.active.view.visual_motions = None; self.active.view.mode = Mode::Insert; } fn finish_insert(&mut self) { self.finish_completion(); self.snippet = None; let recipe = self.active.view.pending_edit.take(); if let Some(before) = self.active.view.insert_start.take() { if let Some(mut recipe) = recipe { while matches!(recipe.steps.last(), Some(InsertStep::Move(_, _))) { recipe.steps.pop(); } self.record_native(before, recipe); } else { self.record(before); } } } fn leave_insert(&mut self) { for target in &mut self.active.view.targets { target.active.column = target.active.column.saturating_sub(1); } self.active.view.mode = Mode::Normal; self.normalize_targets(); } fn place_insert(&mut self, action: Action) { self.active.view.mode = Mode::Insert; if matches!(action, Action::OpenBelow | Action::OpenAbove) { self.open_lines(matches!(action, Action::OpenBelow)); return; } for target in &mut self.active.view.targets { let line = &self.active.lines[target.active.row]; target.active.column = match action { Action::InsertFirstNonblank => first_nonblank(line), Action::AppendLineEnd => grapheme_count(line), Action::Append => (target.active.column + 1).min(grapheme_count(line)), _ => target.active.column, }; } self.normalize_targets(); } fn push_insert_step(&mut self, step: InsertStep) { if let Some(recipe) = &mut self.active.view.pending_edit { recipe.steps.push(step); } } fn type_native(&mut self, text: &str) { const SPACES: [u8; u8::MAX as usize] = [b' '; u8::MAX as usize]; let text = if text == "\t" { let width = if self.language_tab_spaces.is_empty() { self.tab_spaces } else { crate::syntax::language_id( self.active.path.as_deref(), self.active.lines.first().map(String::as_str), ) .and_then(|language| self.language_tab_spaces.get(language)) .copied() .unwrap_or(self.tab_spaces) }; width.map_or(text, |width| { std::str::from_utf8(&SPACES[..usize::from(width.get())]).expect("spaces are UTF-8") }) } else { text }; if self.edit_snippet(text, None) { return; } self.insert_text(text); if let Some(recipe) = &mut self.active.view.pending_edit { if let Some(InsertStep::Typed(typed)) = recipe.steps.last_mut() { typed.push_str(text); } else { recipe.steps.push(InsertStep::Typed(text.to_owned())); } } } fn deletion_mask(&self, backwards: Option) -> Vec { let mut mask: Vec<_> = self .active .view .targets .iter() .map(|target| { let cursor = target.active; if let Some(kind) = backwards { cursor.column != 0 || (kind != BackspaceKind::Line && cursor.row != 0) } else { cursor.column < grapheme_count(&self.active.lines[cursor.row]) || (self.active.view.mode == Mode::Insert && cursor.row + 1 < self.active.lines.len()) } }) .collect(); if mask.iter().all(|active| *active) { mask.truncate(1); } mask } fn selection_intent(&mut self, ranges: &[Selection]) -> SelectionIntent { if let Some(motions) = self.active.view.visual_motions.take() { return SelectionIntent::Motions { motions, linewise: self.visual_linewise(), }; } if self.visual_linewise() { let mut rows: Vec<_> = self .active .view .targets .iter() .map(|target| { let count = target.anchor.row.abs_diff(target.active.row) as isize + 1; if target.active >= target.anchor { count } else { -count } }) .collect(); if rows.iter().all(|row| *row == rows[0]) { rows.truncate(1); } return SelectionIntent::Rows(rows); } let mut spans: Vec<_> = ranges .iter() .map(|range| { let mut cursor = range.start; let mut span = 0; while cursor < range.end { cursor = self.next_boundary(cursor); span += 1; } span }) .collect(); if spans.iter().all(|span| *span == spans[0]) { spans.truncate(1); } SelectionIntent::Spans(spans) } fn repeat_last_edit(&mut self) { let Some(recipe) = self.last_edit.take() else { self.set_message("Nothing to repeat"); return; }; let cardinality = match &recipe.start { EditStart::Paste { fragments, .. } => Some(fragments.len()), EditStart::Selection { selector: SelectionIntent::Spans(spans), .. } | EditStart::ReplaceSelection { selector: SelectionIntent::Spans(spans), .. } if self.active.view.mode != Mode::Visual => Some(spans.len()), EditStart::Selection { selector: SelectionIntent::Rows(rows), .. } | EditStart::ReplaceSelection { selector: SelectionIntent::Rows(rows), .. } if self.active.view.mode != Mode::Visual => Some(rows.len()), _ => None, }; if cardinality.is_some_and(|count| count != 1 && count != self.active.view.targets.len()) { self.set_message("Repeat fragment/span count does not match target count"); self.last_edit = Some(recipe); return; } let mut before = self.snapshot(); let mode = self.active.view.mode; let selector = self.active.view.visual_motions.take(); let linewise = self.active.view.linewise; let search_match = self.active.view.search_match; let revision = self.active.text_revision; let dirty_cache = self.active.dirty_cache.get(); let diagnostics = std::mem::take(&mut self.active.diagnostics); let semantic_tokens = self.active.semantic_tokens.clone(); let mut restored = false; let result = self .replay_edit(&recipe) .map_err(str::to_owned) .and_then(|fragments| { if let Some((fragments, shape)) = fragments { // Validate the entire replay first, then publish while the original // document is restored, and only then commit the computed deletion. std::mem::swap(&mut self.active.lines, &mut before.lines); self.active.dirty_cache.set(None); std::mem::swap(&mut self.active.view.targets, &mut before.targets); std::mem::swap(&mut self.active.view.primary, &mut before.primary); restored = true; self.store_clipboard( fragments, self.clipboard_mode == ClipboardMode::System, shape, )?; std::mem::swap(&mut self.active.lines, &mut before.lines); self.active.dirty_cache.set(None); std::mem::swap(&mut self.active.view.targets, &mut before.targets); std::mem::swap(&mut self.active.view.primary, &mut before.primary); restored = false; } Ok(()) }); match result { Ok(()) => { if self.active.text_revision == revision { self.active.diagnostics = diagnostics; self.active.semantic_tokens = semantic_tokens; } self.active.view.visual_motions = None; self.record(before); } Err(error) => { if !restored { self.active.lines = before.lines; self.clamp_other_views(); self.active.view.targets = before.targets; self.active.view.primary = before.primary; } self.active.text_revision = revision; self.active.dirty_cache.set(dirty_cache); self.active.diagnostics = diagnostics; self.active.semantic_tokens = semantic_tokens; self.active.view.mode = mode; self.active.view.linewise = linewise; self.active.view.visual_motions = selector; self.active.view.search_match = search_match; self.set_message(error); } } self.last_edit = Some(recipe); } fn replay_edit( &mut self, recipe: &EditRecipe, ) -> Result, bool)>, &'static str> { let mut clipboard = None; match &recipe.start { EditStart::Insert(action) => self.place_insert(*action), EditStart::Normal { action, count } => { let ranges = self.normal_ranges(*action, *count); clipboard = Some((self.selection_fragments(&ranges), false)); let change = matches!( action, Action::ChangeToLineEnd | Action::SubstituteCharacter | Action::ChangeWordForward | Action::ChangeWordBackward ); self.edit_ranges(if change { Action::Change } else { Action::Cut }, &ranges); if !change { self.active.view.mode = Mode::Normal; self.normalize_targets(); } } EditStart::Replace { target, count } => { let ranges = self.normal_ranges(Action::SubstituteCharacter, *count); self.replace_characters(*target, &ranges); } EditStart::ReplaceSelection { target, selector } => { let ranges = self.replay_ranges(selector); self.replace_characters(*target, &ranges); } EditStart::Lines { change, count } => { let ranges = self.line_selections(*count); clipboard = Some((self.selection_fragments(&ranges), true)); self.edit_ranges(if *change { Action::Change } else { Action::Cut }, &ranges); if !*change { self.active.view.mode = Mode::Normal; self.normalize_targets(); } } EditStart::Selection { change, selector } => { let ranges = self.replay_ranges(selector); clipboard = Some((self.selection_fragments(&ranges), self.visual_linewise())); self.edit_ranges(if *change { Action::Change } else { Action::Cut }, &ranges); if !*change { self.active.view.mode = Mode::Normal; self.normalize_targets(); } } EditStart::Join => { self.active.view.mode = Mode::Normal; self.join_lines(); } EditStart::Paste { after, fragments, linewise, selector, } => { if let Some(selector) = selector { let count = match selector { SelectionIntent::Spans(spans) => spans.len(), SelectionIntent::Rows(rows) => rows.len(), SelectionIntent::Motions { .. } => 1, }; if self.active.view.mode != Mode::Visual && count != 1 && count != self.active.view.targets.len() { return Err("Repeat span count does not match target count"); } self.replay_ranges(selector); } self.paste_fragments(*after, fragments, *linewise)?; } } for step in &recipe.steps { match step { InsertStep::Typed(text) => { // Scalar boundaries matter when an insertion merges with its right neighbor. for ch in text.chars() { let mut bytes = [0; 4]; self.insert_text(ch.encode_utf8(&mut bytes)); } } InsertStep::Bulk(text) => self.insert_text(text), InsertStep::Move(action, count) => self.move_targets(*action, *count), InsertStep::Delete(mask) | InsertStep::Backspace(mask, _) => { // Earlier native steps may have merged carets. Refuse the entire transaction // rather than pairing a recorded partial deletion with different targets. if mask.len() != 1 && mask.len() != self.active.view.targets.len() { return Err("Repeat deletion count does not match target count"); } if let InsertStep::Backspace(_, kind) = step { self.backspace(mask, *kind); } else { self.delete_forward(mask); } } InsertStep::Completion { primary_edit, edits, caret, } => { let origin = grapheme_offset(&self.active.lines, self.cursor()); let resolve = |distance: isize| { origin .checked_add_signed(distance) .and_then(|index| grapheme_cursor(&self.active.lines, index)) .ok_or("Repeat completion range is outside the document") }; let offsets = line_offsets(&self.active.lines); let mut batch = Vec::with_capacity(edits.len()); for (index, edit) in edits.iter().enumerate() { let start = resolve(edit.start)?; let end = resolve(edit.end)?; if edit.context.as_ref().is_some_and(|context| { *context != self.active.lines[start.row..=end.row] }) { return Err("Repeat completion additional-edit context changed"); } // Translating every boundary by the same origin preserves // the validated ordering and nonoverlap of the original batch. batch.push(Edit { target: (index == *primary_edit).then_some(self.active.view.primary), start: cursor_offset(&self.active.lines, &offsets, start), end: cursor_offset(&self.active.lines, &offsets, end), text: Cow::Borrowed(&edit.text), caret: if index == *primary_edit { *caret } else { edit.text.len() }, round_up: true, }); } self.apply_edits_from(batch, None, true); } } } if self.active.view.mode == Mode::Insert { self.leave_insert(); } else { self.active.view.mode = Mode::Normal; self.normalize_targets(); } Ok(clipboard) } fn replay_ranges(&mut self, selector: &SelectionIntent) -> Vec { if self.active.view.mode == Mode::Visual { return self.selections(); } self.active.view.mode = Mode::Visual; match selector { SelectionIntent::Rows(rows) => { self.active.view.linewise = true; let last = self.active.lines.len() - 1; for (index, target) in self.active.view.targets.iter_mut().enumerate() { target.anchor = target.active; let count = rows[if rows.len() == 1 { 0 } else { index }]; let distance = count.unsigned_abs().saturating_sub(1); target.active.row = if count >= 0 { target.active.row.saturating_add(distance).min(last) } else { target.active.row.saturating_sub(distance) }; } self.normalize_targets(); self.selections() } SelectionIntent::Motions { motions, linewise } => { self.active.view.linewise = *linewise; for target in &mut self.active.view.targets { target.anchor = target.active; } for motion in motions { self.move_motion(*motion); } self.normalize_targets(); self.selections() } SelectionIntent::Spans(spans) => { let ordered = self .active .view .targets .iter() .enumerate() .map(|(index, target)| { let start = target.active; let mut end = start; for _ in 0..spans[if spans.len() == 1 { 0 } else { index }] { let next = self.next_boundary(end); if next == end { break; } end = next; } let range = Selection { start, end }; ( self.range_target(range, true), index == self.active.view.primary, range, ) }) .collect(); self.merge_target_ranges(ordered, true) } } } fn paste(&mut self, text: &str) { self.snippet = None; if let Err(error) = validate_paste_text(text) { self.set_message(error); return; } // Preserve the separate typing / paste / typing undo groups. self.finish_insert(); let text = normalize_newlines(text); if !text.is_empty() { let before = self.snapshot(); self.insert_text(&text); self.record_native( before, EditRecipe { start: EditStart::Insert(Action::Insert), steps: vec![InsertStep::Bulk(text.into_owned())], }, ); } self.begin_insert(); } fn restore(&mut self, snapshot: Snapshot) { if self.mutation_blocked() { return; } self.active.replace_lines(snapshot.lines); self.clamp_other_views(); self.active.view.targets = snapshot.targets; self.active.view.primary = snapshot.primary; self.active.view.mode = Mode::Normal; self.active.view.search_match = None; self.active.view.visual_motions = None; self.normalize_targets(); } fn restore_history(&mut self, entry: HistoryEntry) -> HistoryEntry { let mut lines = self.active.lines.clone(); let end = entry.start + entry.before.lines.len(); let before = Snapshot { lines: lines .splice(entry.start..entry.end, entry.before.lines) .collect(), targets: self.active.view.targets.clone(), primary: self.active.view.primary, }; self.restore(Snapshot { lines, targets: entry.before.targets, primary: entry.before.primary, }); HistoryEntry { start: entry.start, end, before, } } fn undo(&mut self) { if self.mutation_blocked() { return; } if let Some(before) = self.active.undo.pop() { let reverse = self.restore_history(before); self.active.redo.push(reverse); } else { self.set_message("Nothing to undo"); } } fn redo(&mut self) { if self.mutation_blocked() { return; } if let Some(after) = self.active.redo.pop() { let reverse = self.restore_history(after); self.active.undo.push(reverse); } else { self.set_message("Nothing to redo"); } } fn navigable_rows( &self, start: usize, end: usize, ) -> impl DoubleEndedIterator + '_ { let diff = self .diff_buffer_focus() .and_then(|focus| self.diff().map(|diff| (diff, focus))); (start..if diff.is_none() { end } else { start }).chain( diff.into_iter() .flat_map(move |(diff, focus)| diff.source_rows_in(focus, start, end)), ) } fn move_motion(&mut self, motion: Motion) { match motion { Motion::Action(action, count) => self.move_targets(action, count), Motion::Seek { forward, target, count, } => { self.reveal_caret(); for caret in &mut self.active.view.targets { if let Some(column) = seek_column( &self.active.lines[caret.active.row], caret.active.column, forward, target, count, ) { caret.active.column = column; caret.preferred_columns = None; } } self.normalize_targets(); } } } fn move_targets(&mut self, action: Action, count: usize) { self.reveal_caret(); if action == Action::GoToLine && let Some(focus) = self.diff_buffer_focus() { let row = count.saturating_sub(1).min(self.active.lines.len() - 1); self.interaction_diff_mut() .unwrap() .reveal_source_row(focus, row); } for index in 0..self.active.view.targets.len() { let mut cursor = self.active.view.targets[index].active; match action { Action::Left => cursor.column = cursor.column.saturating_sub(count), Action::Right => { cursor.column = cursor .column .saturating_add(count) .min(self.max_column(cursor.row)) } Action::Down | Action::Up => { if self.diff_buffer_focus().is_some() { let step = |cursor: Cursor, row| Cursor { row, column: cursor.column.min(self.max_column(row)), }; cursor = if action == Action::Down { self.navigable_rows( cursor.row.saturating_add(1), self.active.lines.len(), ) .take(count) .fold(cursor, step) } else { self.navigable_rows(0, cursor.row) .rev() .take(count) .fold(cursor, step) }; } else { let row = if matches!(action, Action::Down) { cursor .row .saturating_add(count) .min(self.active.lines.len() - 1) } else { cursor.row.saturating_sub(count) }; let first = cursor.row.min(row); let last = cursor.row.max(row); cursor.column = (first..=last) .map(|row| self.max_column(row)) .min() .unwrap_or(0) .min(cursor.column); cursor.row = row; } } Action::LineStart => cursor.column = 0, Action::LineEnd => cursor.column = self.max_column(cursor.row), Action::GoToLine => { cursor.row = count.saturating_sub(1).min(self.active.lines.len() - 1); cursor.column = first_nonblank(&self.active.lines[cursor.row]); } Action::DocumentStart | Action::DocumentEnd => { cursor.row = if action == Action::DocumentStart { self.navigable_rows(0, self.active.lines.len()) .next() .unwrap_or(cursor.row) } else { self.navigable_rows(0, self.active.lines.len()) .next_back() .unwrap_or(cursor.row) }; cursor.column = first_nonblank(&self.active.lines[cursor.row]); } _ => cursor = self.word_destination(cursor, action, count), } self.active.view.targets[index].active = cursor; self.active.view.targets[index].preferred_columns = None; } self.normalize_targets(); } fn word_destination(&self, cursor: Cursor, action: Action, count: usize) -> Cursor { let row = cursor.row; let byte = grapheme_byte(&self.active.lines[row], cursor.column); let big = matches!( action, Action::BigWordNext | Action::BigWordPrevious | Action::BigWordEnd ); let backward = matches!( action, Action::WordPrevious | Action::BigWordPrevious | Action::DeleteWordBackward ); let line_local = matches!( action, Action::DeleteWordForward | Action::DeleteWordBackward ); let start = if line_local || !backward { row } else { 0 }; let end = if line_local || backward { row + 1 } else { self.active.lines.len() }; let tokens = self.navigable_rows(start, end).flat_map(move |source_row| { word_tokens(std::slice::from_ref(&self.active.lines[source_row]), big) .map(move |(_, byte, kind)| (source_row, byte, kind)) }); let destination = match action { Action::WordPrevious | Action::BigWordPrevious | Action::DeleteWordBackward => { let mut tokens = tokens .rev() .skip_while(|(r, b, _)| (*r, *b) >= (row, byte)) .peekable(); let fallback = (self.navigable_rows(start, end).next().unwrap_or(row), 0); let mut destination = fallback; for _ in 0..count { let Some(mut first) = tokens.find(|(_, _, kind)| *kind != WordKind::Whitespace) else { destination = fallback; break; }; if first.2 != WordKind::EmptyLine { while tokens.peek().is_some_and(|(_, _, kind)| *kind == first.2) { first = tokens.next().expect("peeked token"); } } destination = (first.0, first.1); } destination } _ => { let mut tokens = tokens .skip_while(|(r, b, _)| (*r, *b) < (row, byte)) .peekable(); let Some(first) = tokens.next() else { return cursor; }; let mut current = first.2; let last = self.navigable_rows(start, end).next_back().unwrap_or(row); let mut destination = (last, self.active.lines[last].len()); for _ in 0..count { let next_start = matches!( action, Action::WordNext | Action::BigWordNext | Action::DeleteWordForward ); if next_start && current != WordKind::EmptyLine { while tokens.peek().is_some_and(|(_, _, kind)| *kind == current) { tokens.next(); } } let Some(mut first) = tokens.find(|(_, _, kind)| { *kind != WordKind::Whitespace && (next_start || *kind != WordKind::EmptyLine) }) else { destination = (last, self.active.lines[last].len()); break; }; current = first.2; if !next_start { while tokens.peek().is_some_and(|(_, _, kind)| *kind == current) { first = tokens.next().expect("peeked token"); } } destination = (first.0, first.1); } destination } }; let line = &self.active.lines[destination.0]; let column = column_after_byte(line, destination.1); Cursor { row: destination.0, column: if destination.1 == line.len() && self.active.view.mode != Mode::Insert { column.saturating_sub(1) } else { column }, } } fn clamp_other_views(&mut self) { let lines = &self.active.lines; for pane in self.panes.values_mut() { if let Some(view) = pane.views.get_mut(&self.active.id) { view.clamp(lines); } } } /// Every edit in a batch refers to this one pre-edit buffer. Duplicate row /// operations share an edit; caret destinations are remapped only afterwards. // ponytail: rebuilding text costs O(buffer + carets²); use per-line edits for large buffers. fn apply_edits(&mut self, edits: Vec>) { self.apply_edits_from(edits, None, false); } fn apply_edits_from( &mut self, mut edits: Vec>, original: Option<&Snapshot>, preserve_targets: bool, ) { if self.mutation_blocked() { return; } if edits.is_empty() { return; } // Ordinary typing/deletion can edit one line without copying the entire file. // Snapshot previews, shared panes and semantic overlays use the general remapping path. if original.is_none() && edits.len() == 1 && self.panes.len() == 1 && self.active.semantic_tokens.is_none() && !edits[0].text.contains('\n') { let edit = &edits[0]; let mut offsets = line_offsets(&self.active.lines); let row = offsets .partition_point(|offset| *offset <= edit.start) .saturating_sub(1); let start = edit.start - offsets[row]; let end = edit.end - offsets[row]; if end <= self.active.lines[row].len() { let mut destinations: Vec<_> = self .active .view .targets .iter() .map(|target| { ( translate_edit_offset( cursor_offset(&self.active.lines, &offsets, target.active), true, std::iter::once(edit), ), true, ) }) .collect(); if let Some(target) = edit.target { destinations[target] = (edit.start + edit.caret, edit.round_up); } if self.active.lines[row][start..end] != *edit.text { self.active.lines[row].replace_range(start..end, &edit.text); self.active.text_changed(); } for offset in &mut offsets[row + 1..] { *offset = *offset - (end - start) + edit.text.len(); } for (target, (byte, round_up)) in self.active.view.targets.iter_mut().zip(destinations) { target.active = offset_cursor(&self.active.lines, &offsets, byte, round_up); target.anchor = target.active; target.preferred_columns = None; } if !preserve_targets { self.normalize_targets(); } return; } } edits.sort_by_key(|edit| (edit.start, edit.end)); let mut unique: Vec = Vec::with_capacity(edits.len()); for (index, edit) in edits.iter().enumerate() { if unique.last().is_some_and(|previous| { let previous = &edits[*previous]; previous.start == edit.start && previous.end == edit.end && previous.text == edit.text }) { continue; } unique.push(index); } let lines = original.map_or(&self.active.lines, |before| &before.lines); let targets = original.map_or(&self.active.view.targets, |before| &before.targets); let old = lines.join("\n"); let offsets = line_offsets(lines); let removed: usize = unique .iter() .map(|index| edits[*index].end - edits[*index].start) .sum(); let added: usize = unique.iter().map(|index| edits[*index].text.len()).sum(); let mut text = String::with_capacity(old.len() - removed + added); let mut consumed = 0; for index in &unique { let edit = &edits[*index]; assert!(edit.start >= consumed, "batch edits must not overlap"); text.push_str(&old[consumed..edit.start]); text.push_str(&edit.text); consumed = edit.end; } text.push_str(&old[consumed..]); let remap = |position: usize, after: bool| { translate_edit_offset(position, after, unique.iter().map(|index| &edits[*index])) }; let mut destinations: Vec<_> = targets .iter() .map(|target| { ( remap(cursor_offset(lines, &offsets, target.active), true), true, ) }) .collect(); for edit in &edits { if let Some(target) = edit.target { destinations[target] = (remap(edit.start, false) + edit.caret, edit.round_up); } } let new_lines: Vec = text.split('\n').map(str::to_owned).collect(); let new_offsets = line_offsets(&new_lines); if self.panes.len() > 1 { for pane in self.panes.values_mut() { if let Some(view) = pane.views.get_mut(&self.active.id) && !view.targets.is_empty() { if original.is_some() { view.clamp(lines); } let mode = view.mode; let cursor_at = |byte: usize, round_up| { let mut cursor = offset_cursor(&new_lines, &new_offsets, byte, round_up); if round_up && mode != Mode::Insert && !(mode == Mode::Visual && cursor.row + 1 < new_lines.len()) && byte >= new_offsets[cursor.row] + new_lines[cursor.row].len() { cursor.column = cursor.column.saturating_sub(1); } cursor }; for target in &mut view.targets { target.active = cursor_at( remap(cursor_offset(lines, &offsets, target.active), true), true, ); target.anchor = if mode == Mode::Visual { cursor_at( remap(cursor_offset(lines, &offsets, target.anchor), false), false, ) } else { target.active }; target.preferred_columns = None; } view.search_match = None; view.visual_motions = None; } } } if let Some(original) = original { self.active.view.targets.clone_from(&original.targets); self.active.view.primary = original.primary; } self.active.replace_lines(new_lines); let offsets = new_offsets; for (target, (byte, round_up)) in self.active.view.targets.iter_mut().zip(destinations) { target.active = offset_cursor(&self.active.lines, &offsets, byte, round_up); target.anchor = target.active; target.preferred_columns = None; } if !preserve_targets { self.normalize_targets(); } } fn insert_text(&mut self, text: &str) { if text.is_empty() { return; } let offsets = line_offsets(&self.active.lines); let edits = self .active .view .targets .iter() .enumerate() .map(|(target, item)| { let start = cursor_offset(&self.active.lines, &offsets, item.active); Edit { target: Some(target), start, end: start, text: Cow::Borrowed(text), caret: text.len(), round_up: true, } }) .collect(); self.apply_edits(edits); } fn open_lines(&mut self, below: bool) { let offsets = line_offsets(&self.active.lines); let edits = self .active .view .targets .iter() .enumerate() .map(|(target, item)| { let start = offsets[item.active.row] + if below { self.active.lines[item.active.row].len() } else { 0 }; Edit { target: Some(target), start, end: start, text: Cow::Borrowed("\n"), caret: usize::from(below), round_up: true, } }) .collect(); self.apply_edits(edits); } fn delete_forward(&mut self, mask: &[bool]) { let offsets = line_offsets(&self.active.lines); let edits = self .active .view .targets .iter() .enumerate() .filter_map(|(target, item)| { if !mask[if mask.len() == 1 { 0 } else { target }] { return None; } let cursor = item.active; let start = cursor_offset(&self.active.lines, &offsets, cursor); let end = if cursor.column < grapheme_count(&self.active.lines[cursor.row]) { cursor_offset(&self.active.lines, &offsets, self.next_boundary(cursor)) } else if self.active.view.mode == Mode::Insert && cursor.row + 1 < self.active.lines.len() { start + 1 } else { return None; }; Some(Edit { target: Some(target), start, end, text: Cow::Borrowed(""), caret: 0, round_up: true, }) }) .collect(); self.apply_edits(edits); } fn join_lines(&mut self) { let offsets = line_offsets(&self.active.lines); let edits = self .active .view .targets .iter() .enumerate() .filter_map(|(target, item)| { let row = item.active.row; let line = &self.active.lines[row]; let next = self.active.lines.get(row + 1)?; let trimmed = next.trim_start_matches([' ', '\t']); let separator = if !line.is_empty() && !trimmed.is_empty() && !line.ends_with([' ', '\t']) && !trimmed.starts_with(')') { if line.ends_with(['.', '?', '!']) { " " } else { " " } } else { "" }; let start = offsets[row] + line.len(); Some(Edit { target: Some(target), start, end: start + 1 + next.len() - trimmed.len(), text: Cow::Borrowed(separator), caret: 0, round_up: false, }) }) .collect(); self.apply_edits(edits); } fn backspace_destination(&self, mut cursor: Cursor, kind: BackspaceKind) -> Cursor { if kind == BackspaceKind::Grapheme { return self.previous_boundary(cursor); } let mut prefix = &self.active.lines[cursor.row] [..grapheme_byte(&self.active.lines[cursor.row], cursor.column)]; if kind == BackspaceKind::Word { while cursor.row > 0 && prefix.chars().all(char::is_whitespace) { cursor.row -= 1; prefix = &self.active.lines[cursor.row]; } } cursor.column = prefix[..kind.start(prefix)].graphemes(true).count(); cursor } fn backspace(&mut self, mask: &[bool], kind: BackspaceKind) { let offsets = line_offsets(&self.active.lines); let mut edits: Vec<_> = self .active .view .targets .iter() .enumerate() .filter_map(|(target, item)| { if !mask[if mask.len() == 1 { 0 } else { target }] { return None; } let cursor = item.active; let destination = self.backspace_destination(cursor, kind); if destination == cursor { return None; } let start = cursor_offset(&self.active.lines, &offsets, destination); let end = cursor_offset(&self.active.lines, &offsets, cursor); Some(Edit { target: Some(target), start, end, text: Cow::Borrowed(""), caret: 0, round_up: true, }) }) .collect(); // Backward ranges follow document-ordered carets; overlapping ranges delete once. let mut group = 0; for next in 1..=edits.len() { if next < edits.len() && edits[next].start < edits[next - 1].end { continue; } let start = edits[group].start; let end = edits[next - 1].end; for edit in &mut edits[group..next] { edit.start = start; edit.end = end; } group = next; } self.apply_edits(edits); } } fn ranges_intersect(left: Selection, right: Selection) -> bool { if left.start == left.end { left.start == right.start || (right.start < left.start && left.start < right.end) } else if right.start == right.end { left.start <= right.start && right.start < left.end } else { left.start < right.end && right.start < left.end } } fn first_nonblank(line: &str) -> usize { line.graphemes(true) .take_while(|grapheme| matches!(*grapheme, " " | "\t")) .count() } fn display_column(line: &str, column: usize) -> usize { line.graphemes(true) .take(column) .fold(0, |cells, grapheme| { cells + if grapheme == "\t" { 4 - cells % 4 } else { grapheme.width() } }) } fn column_at_display(line: &str, wanted: usize, maximum: usize) -> usize { let mut cells = 0; let mut count = 0; for (column, grapheme) in line.graphemes(true).enumerate() { let width = if grapheme == "\t" { 4 - cells % 4 } else { grapheme.width() }; if cells + width > wanted { return column.min(maximum); } cells += width; count = column + 1; } count.min(maximum) } fn line_offsets(lines: &[String]) -> Vec { let mut byte = 0; lines .iter() .map(|line| { let start = byte; byte += line.len() + 1; start }) .collect() } fn cursor_offset(lines: &[String], offsets: &[usize], cursor: Cursor) -> usize { offsets[cursor.row] + grapheme_byte(&lines[cursor.row], cursor.column) } fn offset_cursor(lines: &[String], offsets: &[usize], byte: usize, round_up: bool) -> Cursor { let row = offsets .partition_point(|offset| *offset <= byte) .saturating_sub(1); let local = (byte - offsets[row]).min(lines[row].len()); let column = if round_up { column_after_byte(&lines[row], local) } else { lines[row] .grapheme_indices(true) .take_while(|(byte, _)| *byte <= local) .count() .saturating_sub(1) }; Cursor { row, column } } #[derive(Clone, Copy, Eq, PartialEq)] enum WordKind { Whitespace, Keyword, Punctuation, EmptyLine, } fn search_match_bytes<'a>( line: &'a str, query: &'a str, whole_word: bool, ) -> impl Iterator + 'a { let line = if query.is_empty() { "" } else { 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") && (!whole_word || (line[..byte] .graphemes(true) .next_back() .is_none_or(|g| word_kind(g, false) != WordKind::Keyword) && line[byte + query.len()..] .graphemes(true) .next() .is_none_or(|g| word_kind(g, false) != WordKind::Keyword)))) .then_some(byte) }) } fn word_kind(grapheme: &str, big: bool) -> WordKind { let ch = grapheme.chars().next().expect("graphemes are nonempty"); if ch.is_whitespace() { WordKind::Whitespace } else if big || ch == '_' || ch.is_alphanumeric() { WordKind::Keyword } else { WordKind::Punctuation } } fn word_tokens( lines: &[String], big: bool, ) -> impl DoubleEndedIterator + '_ { lines.iter().enumerate().flat_map(move |(row, line)| { line.grapheme_indices(true) .map(move |(byte, grapheme)| { let kind = word_kind(grapheme, big); (row, byte, kind) }) .chain(std::iter::once(( row, line.len(), if line.is_empty() { WordKind::EmptyLine } else { WordKind::Whitespace }, ))) }) } fn valid_command(command: &str) -> bool { let command = command.strip_suffix('!').unwrap_or(command); command.split('.').all(|segment| { let mut chars = segment.chars(); chars.next().is_some_and(|ch| ch.is_ascii_alphabetic()) && chars.all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '-') }) } fn grapheme_offset(lines: &[String], cursor: Cursor) -> usize { lines[..cursor.row] .iter() .map(|line| grapheme_count(line) + 1) .sum::() + cursor.column } fn grapheme_cursor(lines: &[String], mut index: usize) -> Option { for (row, line) in lines.iter().enumerate() { let columns = grapheme_count(line); if index <= columns { return Some(Cursor { row, column: index }); } index -= columns + 1; } None } fn document_graphemes(lines: &[String]) -> impl DoubleEndedIterator { lines.iter().enumerate().flat_map(move |(row, line)| { line.graphemes(true) .chain((row + 1 < lines.len()).then_some("\n")) }) } fn retain_semantic_tokens(tokens: &mut Arc<[SemanticToken]>, before: &[String], after: &[String]) { // Skip whole unchanged lines before doing Unicode segmentation at the edit boundaries. let first = before .iter() .zip(after) .take_while(|(left, right)| left == right) .count() .min(before.len() - 1) .min(after.len() - 1); let suffix = before[first..] .iter() .rev() .zip(after[first..].iter().rev()) .take_while(|(left, right)| left == right) .count(); // Include one common suffix line so its preceding newline remains part of the comparison. let before = &before[first..before.len() - suffix.saturating_sub(1)]; let after = &after[first..after.len() - suffix.saturating_sub(1)]; let mut old = document_graphemes(before).peekable(); let mut new = document_graphemes(after).peekable(); let mut start = TextPosition { row: first, column: 0, }; while let (Some(left), Some(right)) = (old.peek(), new.peek()) { if left != right { break; } if *left == "\n" { start.row += 1; start.column = 0; } else { start.column += 1; } old.next(); new.next(); } let end = |lines: &[String]| TextPosition { row: first + lines.len() - 1, column: grapheme_count(lines.last().unwrap()), }; let mut old_end = end(before); let mut new_end = end(after); for (left, right) in old.rev().zip(new.rev()) { if left != right { break; } for (position, lines) in [(&mut old_end, before), (&mut new_end, after)] { if left == "\n" { position.row -= 1; position.column = grapheme_count(&lines[position.row - first]); } else { position.column -= 1; } } } if tokens .iter() .all(|token| token.end <= start || (token.start >= old_end && old_end == new_end)) { return; } let shift = |position: TextPosition| TextPosition { row: new_end.row + (position.row - old_end.row), column: if position.row == old_end.row { new_end.column + (position.column - old_end.column) } else { position.column }, }; // ponytail: one changed range; retain between disjoint edits with per-edit mapping if needed. *tokens = tokens .iter() .filter_map(|token| { if token.end <= start { Some(*token) } else if token.start >= old_end { Some(SemanticToken { start: shift(token.start), end: shift(token.end), ..*token }) } else { None } }) .collect(); } fn translate_edit_offset<'a, 'b>( position: usize, after: bool, edits: impl Iterator>, ) -> usize where 'b: 'a, { let mut removed = 0; let mut added = 0; for edit in edits { if position < edit.start { break; } if position == edit.start || position < edit.end { return edit.start - removed + added + if after { edit.text.len() } else { 0 }; } removed += edit.end - edit.start; added += edit.text.len(); } position - removed + added } fn byte_range_text(lines: &[String], offsets: &[usize], range: &Range) -> String { let first = offsets.partition_point(|offset| *offset <= range.start) - 1; let last = offsets.partition_point(|offset| *offset <= range.end) - 1; let mut text = String::with_capacity(range.end - range.start); for row in first..=last { if row != first { text.push('\n'); } let start = if row == first { range.start - offsets[row] } else { 0 }; let end = if row == last { range.end - offsets[row] } else { lines[row].len() }; text.push_str(&lines[row][start..end]); } text } fn document_grapheme_boundary(lines: &[String], offsets: &[usize], byte: usize) -> bool { let Some(row) = offsets .partition_point(|offset| *offset <= byte) .checked_sub(1) else { return false; }; let line = &lines[row]; let column = byte - offsets[row]; column <= line.len() && line.is_char_boundary(column) && GraphemeCursor::new(column, line.len(), true) .is_boundary(line, 0) .expect("complete line supplies grapheme context") } fn validate_snippet(text: &str, stops: &[SnippetTabstop]) -> Result { if stops.is_empty() || stops.last().is_none_or(|stop| stop.index != 0) { return Err("Snippet requires a final caret stop".into()); } if stops.len() > MAX_EDIT_BYTES / std::mem::size_of::() { return Err("Snippet metadata exceeds 4 MiB".into()); } let mut metadata_bytes = std::mem::size_of_val(stops); let mut ranges = Vec::new(); let mut previous = 0; for (group, stop) in stops.iter().enumerate() { if stop.ranges.is_empty() || stop.ancestors.len() != stop.ranges.len() || stop.index == 0 && group + 1 != stops.len() || stop.index != 0 && stop.index <= previous || stop.index == 0 && (stop.ranges.len() != 1 || !stop.ranges[0].is_empty()) { return Err("Snippet tabstops are unordered or empty".into()); } previous = stop.index; let mut end = None; let mut mirror = None; for (occurrence, range) in stop.ranges.iter().enumerate() { if range.start > range.end || range.end > text.len() { return Err("Snippet range is outside its replacement".into()); } for byte in [range.start, range.end] { if !text.is_char_boundary(byte) || !GraphemeCursor::new(byte, text.len(), true) .is_boundary(text, 0) .expect("complete replacement supplies grapheme context") { return Err("Snippet range splits a grapheme".into()); } } if end.is_some_and(|(start, end)| { range.start < end || range.start == start && !(range.is_empty() && start == end) }) { return Err("Snippet mirror ranges overlap or are unordered".into()); } end = Some((range.start, range.end)); let value = &text[range.clone()]; if mirror.is_some_and(|mirror| mirror != value) { return Err("Snippet mirrors have different defaults".into()); } mirror = Some(value); if stop.ancestors[occurrence].len() > 128 { return Err("Snippet nesting exceeds 128 levels".into()); } ranges.push((range.start, range.end, group, occurrence)); metadata_bytes += std::mem::size_of::>() + std::mem::size_of::>() + stop.ancestors[occurrence].len() * std::mem::size_of::(); if metadata_bytes > MAX_EDIT_BYTES { return Err("Snippet metadata exceeds 4 MiB".into()); } } } let numbered = &stops[..stops.len() - 1]; for stop in stops { for (range, ancestors) in stop.ranges.iter().zip(&stop.ancestors) { if stop.index == 0 && !ancestors.is_empty() { return Err("Snippet final caret must be outside nested defaults".into()); } for (depth, ancestor) in ancestors.iter().enumerate() { if *ancestor == stop.index || ancestors[..depth].contains(ancestor) { return Err("Snippet ancestry contains a cycle".into()); } let parent = numbered .binary_search_by_key(ancestor, |stop| stop.index) .map(|index| &numbered[index]) .map_err(|_| "Snippet ancestry references an unknown tabstop")?; if !parent .ranges .iter() .zip(&parent.ancestors) .any(|(enclosing, ancestry)| { enclosing.start <= range.start && range.end <= enclosing.end && ancestry.as_slice() == &ancestors[..depth] }) { return Err("Snippet ancestor does not contain its child".into()); } } } } // Nested and coincident defaults require declared ancestry; crossing ranges are invalid. ranges.sort_unstable_by_key(|(start, end, group, occurrence)| { ( *start, std::cmp::Reverse(*end), stops[*group].ancestors[*occurrence].len(), ) }); let mut enclosing: Vec<(usize, usize, usize)> = Vec::new(); for (start, end, group, occurrence) in ranges { while enclosing .last() .is_some_and(|(_, parent_end, _)| *parent_end <= start) { enclosing.pop(); } if let Some((parent_start, parent_end, parent)) = enclosing.last() { if end > *parent_end { return Err("Snippet placeholder ranges cross".into()); } if stops[group].index != 0 && (start != end || start > *parent_start) && !stops[group].ancestors[occurrence].contains(&stops[*parent].index) { return Err("Snippet overlapping placeholders require ancestry".into()); } } if start != end { enclosing.push((start, end, group)); } } Ok(metadata_bytes) } fn validate_completion_items(lines: &[String], items: &[CompletionItem]) -> Result<(), String> { let mut total = 0usize; let offsets = line_offsets(lines); let document = items .iter() .any(|item| item.snippet.is_some()) .then(|| lines.join("\n")); for item in items { if item.primary_edit >= item.edits.len() { return Err("Completion has no primary edit".into()); } for text in std::iter::once(item.label.as_str()) .chain(item.detail.as_deref()) .chain(item.edits.iter().map(|edit| edit.text.as_str())) { total = total .checked_add(text.len()) .ok_or("Completions exceed 4 MiB")?; if total > MAX_EDIT_BYTES { return Err("Completions exceed 4 MiB".into()); } validate_text(text)?; } let mut ranges = Vec::with_capacity(item.edits.len()); for edit in &item.edits { for position in [edit.start, edit.end] { if lines .get(position.row) .is_none_or(|line| position.column > grapheme_count(line)) { return Err("Completion range is outside the document".into()); } } let start = cursor_offset( lines, &offsets, Cursor { row: edit.start.row, column: edit.start.column, }, ); let end = cursor_offset( lines, &offsets, Cursor { row: edit.end.row, column: edit.end.column, }, ); if start > end { return Err("Completion range is reversed".into()); } ranges.push((start, end)); } ranges.sort_unstable(); if ranges .windows(2) .any(|pair| pair[0].1 > pair[1].0 || pair[0].0 == pair[1].0) { return Err("Completion edits overlap".into()); } if let Some(snippet) = &item.snippet { let text = &item.edits[item.primary_edit].text; if text.contains('\r') { return Err( "Snippet replacement must normalize newlines before assigning ranges".into(), ); } total = total .checked_add(validate_snippet(text, &snippet.tabstops)?) .filter(|bytes| *bytes <= MAX_EDIT_BYTES) .ok_or("Completions exceed 4 MiB")?; let mut edits = completion_edits(lines, &offsets, 0, item); let primary_start = edits[item.primary_edit].start; for (edit, source) in edits.iter_mut().zip(&item.edits) { edit.text = normalize_newlines(&source.text); } edits.sort_by_key(|edit| (edit.start, edit.end)); let base = translate_edit_offset(primary_start, false, edits.iter()); let original = document .as_deref() .expect("snippet validation has document text"); let removed: usize = edits.iter().map(|edit| edit.end - edit.start).sum(); let added: usize = edits.iter().map(|edit| edit.text.len()).sum(); let mut expanded = String::with_capacity(original.len() - removed + added); let mut consumed = 0; for edit in &edits { expanded.push_str(&original[consumed..edit.start]); expanded.push_str(&edit.text); consumed = edit.end; } expanded.push_str(&original[consumed..]); for range in snippet.tabstops.iter().flat_map(|stop| &stop.ranges) { for byte in [base + range.start, base + range.end] { if !GraphemeCursor::new(byte, expanded.len(), true) .is_boundary(&expanded, 0) .expect("complete document supplies grapheme context") { return Err("Snippet range merges with surrounding graphemes".into()); } } } } } Ok(()) } fn completion_edits<'a>( lines: &[String], offsets: &[usize], primary: usize, item: &'a CompletionItem, ) -> Vec> { item.edits .iter() .enumerate() .map(|(index, edit)| Edit { target: (index == item.primary_edit).then_some(primary), start: cursor_offset( lines, offsets, Cursor { row: edit.start.row, column: edit.start.column, }, ), end: cursor_offset( lines, offsets, Cursor { row: edit.end.row, column: edit.end.column, }, ), text: Cow::Borrowed(&edit.text), caret: edit.text.len(), round_up: true, }) .collect() } pub(crate) fn validate_hover_text(text: &str) -> Result<(), String> { if text.len() > MAX_EDIT_BYTES { Err("Hover exceeds 4 MiB".into()) } else { validate_text(text) } } fn validate_paste_text(text: &str) -> Result<(), String> { if text.len() > MAX_EDIT_BYTES { Err("Paste exceeds 4 MiB".into()) } else { validate_text(text) } } pub(crate) fn validate_effects(effects: &[Effect]) -> Result<(), String> { let mut total = 0usize; for effect in effects { let text = match effect { Effect::Insert(text) | Effect::Replace(text) | Effect::Message(text) => text, Effect::Label { name, text } => { validate_label(name, text)?; total = total .checked_add(name.len()) .ok_or("Plugin effects exceed 4 MiB")?; text } }; total = total .checked_add(text.len()) .ok_or("Plugin effects exceed 4 MiB")?; if total > MAX_EDIT_BYTES { return Err("Plugin effects exceed 4 MiB".into()); } if !matches!(effect, Effect::Label { .. }) { validate_text(text)?; } } Ok(()) } pub(crate) fn validate_label(name: &str, text: &str) -> Result<(), String> { validate_name(name)?; if name .len() .checked_add(text.len()) .is_none_or(|bytes| bytes > MAX_EDIT_BYTES) { return Err("Label exceeds 4 MiB".into()); } Ok(()) } fn sanitize_label(text: String) -> String { if text.chars().any(char::is_control) { text.chars() .map(|ch| if ch.is_control() { '\u{fffd}' } else { ch }) .collect() } else { text } } fn validate_text(text: &str) -> Result<(), String> { if let Some(ch) = text .chars() .find(|ch| ch.is_control() && !matches!(ch, '\t' | '\n' | '\r')) { Err(format!("Unsupported control character U+{:04X}", ch as u32)) } else { Ok(()) } } pub(crate) fn normalize_newlines(text: &str) -> Cow<'_, str> { if !text.contains('\r') { return Cow::Borrowed(text); } let mut normalized = String::with_capacity(text.len()); let mut chars = text.chars().peekable(); while let Some(ch) = chars.next() { if ch == '\r' { if chars.peek() == Some(&'\n') { chars.next(); } normalized.push('\n'); } else { normalized.push(ch); } } Cow::Owned(normalized) } fn split_lines(text: &str) -> Vec { normalize_newlines(text) .split('\n') .map(str::to_owned) .collect() } fn seek_column( line: &str, column: usize, forward: bool, target: char, count: usize, ) -> Option { let occurrence = count.saturating_sub(1); if forward { line.graphemes(true) .enumerate() .skip(column.saturating_add(1)) .flat_map(|(column, grapheme)| { grapheme .chars() .filter(move |ch| *ch == target) .map(move |_| column) }) .nth(occurrence) } else { line[..grapheme_byte(line, column)] .graphemes(true) .rev() .enumerate() .flat_map(|(offset, grapheme)| { grapheme .chars() .filter(move |ch| *ch == target) .map(move |_| column - offset - 1) }) .nth(occurrence) } } fn grapheme_count(line: &str) -> usize { if line.is_ascii() { // CRLF is the only multi-byte ASCII grapheme. line.len() - line.matches("\r\n").count() } else { line.graphemes(true).count() } } fn grapheme_byte(line: &str, column: usize) -> usize { line.grapheme_indices(true) .nth(column) .map_or(line.len(), |(byte, _)| byte) } fn column_after_byte(line: &str, byte: usize) -> usize { line.grapheme_indices(true) .take_while(|(index, _)| *index < byte) .count() } fn lsp_navigation_position( lines: &[String], range: Utf16Range, target_range: Option, ) -> Result { for range in std::iter::once(range).chain(target_range) { if range.start > range.end { return Err("Reversed LSP range".into()); } for (row, _) in [range.start, range.end] { if row >= lines.len() { return Err("LSP line is outside the document".into()); } } } let (row, units) = range.start; let mut offset = 0; let mut column = 0; for cluster in lines[row].graphemes(true) { let width = cluster.encode_utf16().count(); if units < offset + width { return Ok(TextPosition { row, column }); } offset += width; column += 1; } Ok(TextPosition { row, column }) } pub(crate) fn path_identity(path: &Path) -> io::Result { let absolute = if path.is_absolute() { path.to_owned() } else { std::env::current_dir()?.join(path) }; // Resolve the existing prefix too, so new files under aliased directories deduplicate. for ancestor in absolute.ancestors() { match fs::canonicalize(ancestor) { Ok(prefix) => { let mut identity = prefix; for component in absolute .strip_prefix(ancestor) .expect("path ancestor") .components() { match component { Component::ParentDir => { identity.pop(); } Component::CurDir => {} component => identity.push(component.as_os_str()), } } return Ok(identity); } Err(error) if error.kind() == io::ErrorKind::NotFound => {} Err(error) => return Err(error), } } Err(io::Error::new( io::ErrorKind::NotFound, "No existing path ancestor", )) } fn target_metadata(path: &Path) -> io::Result> { match fs::symlink_metadata(path) { Ok(metadata) if metadata.file_type().is_symlink() => Err(io::Error::new( io::ErrorKind::InvalidInput, "Refusing to save through a symbolic link", )), Ok(metadata) if !metadata.is_file() => Err(io::Error::new( io::ErrorKind::InvalidInput, "Save target is not a regular file", )), Ok(metadata) => Ok(Some(metadata)), Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None), Err(error) => Err(error), } } fn check_disk_contents(path: &Path, expected: Option<(&[String], bool)>) -> io::Result<()> { let Some((lines, existed)) = expected else { return Ok(()); }; let matches = match fs::read_to_string(path) { Ok(text) => { existed && normalize_newlines(&text) .split('\n') .eq(lines.iter().map(String::as_str)) } Err(error) if error.kind() == io::ErrorKind::NotFound => !existed, Err(error) if error.kind() == io::ErrorKind::InvalidData => false, Err(error) => return Err(error), }; if matches { Ok(()) } else { Err(io::Error::new( io::ErrorKind::AlreadyExists, "File changed outside vamp; use :w! to overwrite", )) } } fn atomic_save( path: &Path, lines: &[String], expected: Option<(&[String], bool)>, ) -> io::Result { let metadata = target_metadata(path)?; check_disk_contents(path, expected)?; let parent = path .parent() .filter(|parent| !parent.as_os_str().is_empty()) .unwrap_or(Path::new(".")); let (temporary, 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 = (|| { let mut writer = io::BufWriter::new(&file); for (index, line) in lines.iter().enumerate() { if index > 0 { writer.write_all(b"\n")?; } writer.write_all(line.as_bytes())?; } writer.flush()?; drop(writer); if let Some(metadata) = metadata { file.set_permissions(metadata.permissions())?; } file.sync_all()?; let stamp = FileStamp::from_metadata(&file.metadata()?)?; drop(file); // Recheck immediately before rename: never knowingly replace a symlink. target_metadata(path)?; check_disk_contents(path, expected)?; fs::rename(&temporary, path)?; #[cfg(unix)] fs::File::open(parent)?.sync_all()?; Ok(stamp) })(); if result.is_err() { let _ = fs::remove_file(&temporary); } result } #[cfg(test)] // These fixtures intentionally contain ranges, not lists of indices. #[allow(clippy::single_range_in_vec_init)] #[path = "../tests/unit/editor.rs"] mod tests;