
Closes #21198 Release Notes: - Adds support for `:norm` - Allows for vim and zed style modified keys specified in issue - Vim style <C-w> and zed style <ctrl-w> - Differs from vim in how multi-line is handled - vim is sequential - zed is combinational (with multi-cursor)
2480 lines
83 KiB
Rust
2480 lines
83 KiB
Rust
use anyhow::Result;
|
|
use collections::{HashMap, HashSet};
|
|
use command_palette_hooks::CommandInterceptResult;
|
|
use editor::{
|
|
Bias, Editor, SelectionEffects, ToPoint,
|
|
actions::{SortLinesCaseInsensitive, SortLinesCaseSensitive},
|
|
display_map::ToDisplayPoint,
|
|
};
|
|
use gpui::{Action, App, AppContext as _, Context, Global, Keystroke, Window, actions};
|
|
use itertools::Itertools;
|
|
use language::Point;
|
|
use multi_buffer::MultiBufferRow;
|
|
use project::ProjectPath;
|
|
use regex::Regex;
|
|
use schemars::JsonSchema;
|
|
use search::{BufferSearchBar, SearchOptions};
|
|
use serde::Deserialize;
|
|
use std::{
|
|
io::Write,
|
|
iter::Peekable,
|
|
ops::{Deref, Range},
|
|
path::Path,
|
|
process::Stdio,
|
|
str::Chars,
|
|
sync::{Arc, OnceLock},
|
|
time::Instant,
|
|
};
|
|
use task::{HideStrategy, RevealStrategy, SpawnInTerminal, TaskId};
|
|
use ui::ActiveTheme;
|
|
use util::ResultExt;
|
|
use workspace::{Item, SaveIntent, notifications::NotifyResultExt};
|
|
use workspace::{SplitDirection, notifications::DetachAndPromptErr};
|
|
use zed_actions::{OpenDocs, RevealTarget};
|
|
|
|
use crate::{
|
|
ToggleMarksView, ToggleRegistersView, Vim,
|
|
motion::{EndOfDocument, Motion, MotionKind, StartOfDocument},
|
|
normal::{
|
|
JoinLines,
|
|
search::{FindCommand, ReplaceCommand, Replacement},
|
|
},
|
|
object::Object,
|
|
state::{Mark, Mode},
|
|
visual::VisualDeleteLine,
|
|
};
|
|
|
|
/// Goes to the specified line number in the editor.
|
|
#[derive(Clone, Debug, PartialEq, Action)]
|
|
#[action(namespace = vim, no_json, no_register)]
|
|
pub struct GoToLine {
|
|
range: CommandRange,
|
|
}
|
|
|
|
/// Yanks (copies) text based on the specified range.
|
|
#[derive(Clone, Debug, PartialEq, Action)]
|
|
#[action(namespace = vim, no_json, no_register)]
|
|
pub struct YankCommand {
|
|
range: CommandRange,
|
|
}
|
|
|
|
/// Executes a command with the specified range.
|
|
#[derive(Clone, Debug, PartialEq, Action)]
|
|
#[action(namespace = vim, no_json, no_register)]
|
|
pub struct WithRange {
|
|
restore_selection: bool,
|
|
range: CommandRange,
|
|
action: WrappedAction,
|
|
}
|
|
|
|
/// Executes a command with the specified count.
|
|
#[derive(Clone, Debug, PartialEq, Action)]
|
|
#[action(namespace = vim, no_json, no_register)]
|
|
pub struct WithCount {
|
|
count: u32,
|
|
action: WrappedAction,
|
|
}
|
|
|
|
#[derive(Clone, Deserialize, JsonSchema, PartialEq)]
|
|
pub enum VimOption {
|
|
Wrap(bool),
|
|
Number(bool),
|
|
RelativeNumber(bool),
|
|
}
|
|
|
|
impl VimOption {
|
|
fn possible_commands(query: &str) -> Vec<CommandInterceptResult> {
|
|
let mut prefix_of_options = Vec::new();
|
|
let mut options = query.split(" ").collect::<Vec<_>>();
|
|
let prefix = options.pop().unwrap_or_default();
|
|
for option in options {
|
|
if let Some(opt) = Self::from(option) {
|
|
prefix_of_options.push(opt)
|
|
} else {
|
|
return vec![];
|
|
}
|
|
}
|
|
|
|
Self::possibilities(&prefix)
|
|
.map(|possible| {
|
|
let mut options = prefix_of_options.clone();
|
|
options.push(possible);
|
|
|
|
CommandInterceptResult {
|
|
string: format!(
|
|
":set {}",
|
|
options.iter().map(|opt| opt.to_string()).join(" ")
|
|
),
|
|
action: VimSet { options }.boxed_clone(),
|
|
positions: vec![],
|
|
}
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn possibilities(query: &str) -> impl Iterator<Item = Self> + '_ {
|
|
[
|
|
(None, VimOption::Wrap(true)),
|
|
(None, VimOption::Wrap(false)),
|
|
(None, VimOption::Number(true)),
|
|
(None, VimOption::Number(false)),
|
|
(None, VimOption::RelativeNumber(true)),
|
|
(None, VimOption::RelativeNumber(false)),
|
|
(Some("rnu"), VimOption::RelativeNumber(true)),
|
|
(Some("nornu"), VimOption::RelativeNumber(false)),
|
|
]
|
|
.into_iter()
|
|
.filter(move |(prefix, option)| prefix.unwrap_or(option.to_string()).starts_with(query))
|
|
.map(|(_, option)| option)
|
|
}
|
|
|
|
fn from(option: &str) -> Option<Self> {
|
|
match option {
|
|
"wrap" => Some(Self::Wrap(true)),
|
|
"nowrap" => Some(Self::Wrap(false)),
|
|
|
|
"number" => Some(Self::Number(true)),
|
|
"nu" => Some(Self::Number(true)),
|
|
"nonumber" => Some(Self::Number(false)),
|
|
"nonu" => Some(Self::Number(false)),
|
|
|
|
"relativenumber" => Some(Self::RelativeNumber(true)),
|
|
"rnu" => Some(Self::RelativeNumber(true)),
|
|
"norelativenumber" => Some(Self::RelativeNumber(false)),
|
|
"nornu" => Some(Self::RelativeNumber(false)),
|
|
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
fn to_string(&self) -> &'static str {
|
|
match self {
|
|
VimOption::Wrap(true) => "wrap",
|
|
VimOption::Wrap(false) => "nowrap",
|
|
VimOption::Number(true) => "number",
|
|
VimOption::Number(false) => "nonumber",
|
|
VimOption::RelativeNumber(true) => "relativenumber",
|
|
VimOption::RelativeNumber(false) => "norelativenumber",
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Sets vim options and configuration values.
|
|
#[derive(Clone, PartialEq, Action)]
|
|
#[action(namespace = vim, no_json, no_register)]
|
|
pub struct VimSet {
|
|
options: Vec<VimOption>,
|
|
}
|
|
|
|
/// Saves the current file with optional save intent.
|
|
#[derive(Clone, PartialEq, Action)]
|
|
#[action(namespace = vim, no_json, no_register)]
|
|
struct VimSave {
|
|
pub save_intent: Option<SaveIntent>,
|
|
pub filename: String,
|
|
}
|
|
|
|
/// Deletes the specified marks from the editor.
|
|
#[derive(Clone, PartialEq, Action)]
|
|
#[action(namespace = vim, no_json, no_register)]
|
|
struct VimSplit {
|
|
pub vertical: bool,
|
|
pub filename: String,
|
|
}
|
|
|
|
#[derive(Clone, PartialEq, Action)]
|
|
#[action(namespace = vim, no_json, no_register)]
|
|
enum DeleteMarks {
|
|
Marks(String),
|
|
AllLocal,
|
|
}
|
|
|
|
actions!(
|
|
vim,
|
|
[
|
|
/// Executes a command in visual mode.
|
|
VisualCommand,
|
|
/// Executes a command with a count prefix.
|
|
CountCommand,
|
|
/// Executes a shell command.
|
|
ShellCommand,
|
|
/// Indicates that an argument is required for the command.
|
|
ArgumentRequired
|
|
]
|
|
);
|
|
|
|
/// Opens the specified file for editing.
|
|
#[derive(Clone, PartialEq, Action)]
|
|
#[action(namespace = vim, no_json, no_register)]
|
|
struct VimEdit {
|
|
pub filename: String,
|
|
}
|
|
|
|
#[derive(Clone, PartialEq, Action)]
|
|
#[action(namespace = vim, no_json, no_register)]
|
|
struct VimNorm {
|
|
pub range: Option<CommandRange>,
|
|
pub command: String,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct WrappedAction(Box<dyn Action>);
|
|
|
|
impl PartialEq for WrappedAction {
|
|
fn eq(&self, other: &Self) -> bool {
|
|
self.0.partial_eq(&*other.0)
|
|
}
|
|
}
|
|
|
|
impl Clone for WrappedAction {
|
|
fn clone(&self) -> Self {
|
|
Self(self.0.boxed_clone())
|
|
}
|
|
}
|
|
|
|
impl Deref for WrappedAction {
|
|
type Target = dyn Action;
|
|
fn deref(&self) -> &dyn Action {
|
|
&*self.0
|
|
}
|
|
}
|
|
|
|
pub fn register(editor: &mut Editor, cx: &mut Context<Vim>) {
|
|
// Vim::action(editor, cx, |vim, action: &StartOfLine, window, cx| {
|
|
Vim::action(editor, cx, |vim, action: &VimSet, window, cx| {
|
|
for option in action.options.iter() {
|
|
vim.update_editor(window, cx, |_, editor, _, cx| match option {
|
|
VimOption::Wrap(true) => {
|
|
editor
|
|
.set_soft_wrap_mode(language::language_settings::SoftWrap::EditorWidth, cx);
|
|
}
|
|
VimOption::Wrap(false) => {
|
|
editor.set_soft_wrap_mode(language::language_settings::SoftWrap::None, cx);
|
|
}
|
|
VimOption::Number(enabled) => {
|
|
editor.set_show_line_numbers(*enabled, cx);
|
|
}
|
|
VimOption::RelativeNumber(enabled) => {
|
|
editor.set_relative_line_number(Some(*enabled), cx);
|
|
}
|
|
});
|
|
}
|
|
});
|
|
Vim::action(editor, cx, |vim, _: &VisualCommand, window, cx| {
|
|
let Some(workspace) = vim.workspace(window) else {
|
|
return;
|
|
};
|
|
workspace.update(cx, |workspace, cx| {
|
|
command_palette::CommandPalette::toggle(workspace, "'<,'>", window, cx);
|
|
})
|
|
});
|
|
|
|
Vim::action(editor, cx, |vim, _: &ShellCommand, window, cx| {
|
|
let Some(workspace) = vim.workspace(window) else {
|
|
return;
|
|
};
|
|
workspace.update(cx, |workspace, cx| {
|
|
command_palette::CommandPalette::toggle(workspace, "'<,'>!", window, cx);
|
|
})
|
|
});
|
|
|
|
Vim::action(editor, cx, |_, _: &ArgumentRequired, window, cx| {
|
|
let _ = window.prompt(
|
|
gpui::PromptLevel::Critical,
|
|
"Argument required",
|
|
None,
|
|
&["Cancel"],
|
|
cx,
|
|
);
|
|
});
|
|
|
|
Vim::action(editor, cx, |vim, _: &ShellCommand, window, cx| {
|
|
let Some(workspace) = vim.workspace(window) else {
|
|
return;
|
|
};
|
|
workspace.update(cx, |workspace, cx| {
|
|
command_palette::CommandPalette::toggle(workspace, "'<,'>!", window, cx);
|
|
})
|
|
});
|
|
|
|
Vim::action(editor, cx, |vim, action: &VimSave, window, cx| {
|
|
vim.update_editor(window, cx, |_, editor, window, cx| {
|
|
let Some(project) = editor.project.clone() else {
|
|
return;
|
|
};
|
|
let Some(worktree) = project.read(cx).visible_worktrees(cx).next() else {
|
|
return;
|
|
};
|
|
let project_path = ProjectPath {
|
|
worktree_id: worktree.read(cx).id(),
|
|
path: Arc::from(Path::new(&action.filename)),
|
|
};
|
|
|
|
if project.read(cx).entry_for_path(&project_path, cx).is_some() && action.save_intent != Some(SaveIntent::Overwrite) {
|
|
let answer = window.prompt(
|
|
gpui::PromptLevel::Critical,
|
|
&format!("{} already exists. Do you want to replace it?", project_path.path.to_string_lossy()),
|
|
Some(
|
|
"A file or folder with the same name already exists. Replacing it will overwrite its current contents.",
|
|
),
|
|
&["Replace", "Cancel"],
|
|
cx);
|
|
cx.spawn_in(window, async move |editor, cx| {
|
|
if answer.await.ok() != Some(0) {
|
|
return;
|
|
}
|
|
|
|
let _ = editor.update_in(cx, |editor, window, cx|{
|
|
editor
|
|
.save_as(project, project_path, window, cx)
|
|
.detach_and_prompt_err("Failed to :w", window, cx, |_, _, _| None);
|
|
});
|
|
}).detach();
|
|
} else {
|
|
editor
|
|
.save_as(project, project_path, window, cx)
|
|
.detach_and_prompt_err("Failed to :w", window, cx, |_, _, _| None);
|
|
}
|
|
});
|
|
});
|
|
|
|
Vim::action(editor, cx, |vim, action: &VimSplit, window, cx| {
|
|
let Some(workspace) = vim.workspace(window) else {
|
|
return;
|
|
};
|
|
|
|
workspace.update(cx, |workspace, cx| {
|
|
let project = workspace.project().clone();
|
|
let Some(worktree) = project.read(cx).visible_worktrees(cx).next() else {
|
|
return;
|
|
};
|
|
let project_path = ProjectPath {
|
|
worktree_id: worktree.read(cx).id(),
|
|
path: Arc::from(Path::new(&action.filename)),
|
|
};
|
|
|
|
let direction = if action.vertical {
|
|
SplitDirection::vertical(cx)
|
|
} else {
|
|
SplitDirection::horizontal(cx)
|
|
};
|
|
|
|
workspace
|
|
.split_path_preview(project_path, false, Some(direction), window, cx)
|
|
.detach_and_log_err(cx);
|
|
})
|
|
});
|
|
|
|
Vim::action(editor, cx, |vim, action: &DeleteMarks, window, cx| {
|
|
fn err(s: String, window: &mut Window, cx: &mut Context<Editor>) {
|
|
let _ = window.prompt(
|
|
gpui::PromptLevel::Critical,
|
|
&format!("Invalid argument: {}", s),
|
|
None,
|
|
&["Cancel"],
|
|
cx,
|
|
);
|
|
}
|
|
vim.update_editor(window, cx, |vim, editor, window, cx| match action {
|
|
DeleteMarks::Marks(s) => {
|
|
if s.starts_with('-') || s.ends_with('-') || s.contains(['\'', '`']) {
|
|
err(s.clone(), window, cx);
|
|
return;
|
|
}
|
|
|
|
let to_delete = if s.len() < 3 {
|
|
Some(s.clone())
|
|
} else {
|
|
s.chars()
|
|
.tuple_windows::<(_, _, _)>()
|
|
.map(|(a, b, c)| {
|
|
if b == '-' {
|
|
if match a {
|
|
'a'..='z' => a <= c && c <= 'z',
|
|
'A'..='Z' => a <= c && c <= 'Z',
|
|
'0'..='9' => a <= c && c <= '9',
|
|
_ => false,
|
|
} {
|
|
Some((a..=c).collect_vec())
|
|
} else {
|
|
None
|
|
}
|
|
} else if a == '-' {
|
|
if c == '-' { None } else { Some(vec![c]) }
|
|
} else if c == '-' {
|
|
if a == '-' { None } else { Some(vec![a]) }
|
|
} else {
|
|
Some(vec![a, b, c])
|
|
}
|
|
})
|
|
.fold_options(HashSet::<char>::default(), |mut set, chars| {
|
|
set.extend(chars.iter().copied());
|
|
set
|
|
})
|
|
.map(|set| set.iter().collect::<String>())
|
|
};
|
|
|
|
let Some(to_delete) = to_delete else {
|
|
err(s.clone(), window, cx);
|
|
return;
|
|
};
|
|
|
|
for c in to_delete.chars().filter(|c| !c.is_whitespace()) {
|
|
vim.delete_mark(c.to_string(), editor, window, cx);
|
|
}
|
|
}
|
|
DeleteMarks::AllLocal => {
|
|
for s in 'a'..='z' {
|
|
vim.delete_mark(s.to_string(), editor, window, cx);
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
Vim::action(editor, cx, |vim, action: &VimEdit, window, cx| {
|
|
vim.update_editor(window, cx, |vim, editor, window, cx| {
|
|
let Some(workspace) = vim.workspace(window) else {
|
|
return;
|
|
};
|
|
let Some(project) = editor.project.clone() else {
|
|
return;
|
|
};
|
|
let Some(worktree) = project.read(cx).visible_worktrees(cx).next() else {
|
|
return;
|
|
};
|
|
let project_path = ProjectPath {
|
|
worktree_id: worktree.read(cx).id(),
|
|
path: Arc::from(Path::new(&action.filename)),
|
|
};
|
|
|
|
let _ = workspace.update(cx, |workspace, cx| {
|
|
workspace
|
|
.open_path(project_path, None, true, window, cx)
|
|
.detach_and_log_err(cx);
|
|
});
|
|
});
|
|
});
|
|
|
|
Vim::action(editor, cx, |vim, action: &VimNorm, window, cx| {
|
|
let keystrokes = action
|
|
.command
|
|
.chars()
|
|
.map(|c| Keystroke::parse(&c.to_string()).unwrap())
|
|
.collect();
|
|
vim.switch_mode(Mode::Normal, true, window, cx);
|
|
let initial_selections = vim.update_editor(window, cx, |_, editor, _, _| {
|
|
editor.selections.disjoint_anchors()
|
|
});
|
|
if let Some(range) = &action.range {
|
|
let result = vim.update_editor(window, cx, |vim, editor, window, cx| {
|
|
let range = range.buffer_range(vim, editor, window, cx)?;
|
|
editor.change_selections(
|
|
SelectionEffects::no_scroll().nav_history(false),
|
|
window,
|
|
cx,
|
|
|s| {
|
|
s.select_ranges(
|
|
(range.start.0..=range.end.0)
|
|
.map(|line| Point::new(line, 0)..Point::new(line, 0)),
|
|
);
|
|
},
|
|
);
|
|
anyhow::Ok(())
|
|
});
|
|
if let Some(Err(err)) = result {
|
|
log::error!("Error selecting range: {}", err);
|
|
return;
|
|
}
|
|
};
|
|
|
|
let Some(workspace) = vim.workspace(window) else {
|
|
return;
|
|
};
|
|
let task = workspace.update(cx, |workspace, cx| {
|
|
workspace.send_keystrokes_impl(keystrokes, window, cx)
|
|
});
|
|
let had_range = action.range.is_some();
|
|
|
|
cx.spawn_in(window, async move |vim, cx| {
|
|
task.await;
|
|
vim.update_in(cx, |vim, window, cx| {
|
|
vim.update_editor(window, cx, |_, editor, window, cx| {
|
|
if had_range {
|
|
editor.change_selections(SelectionEffects::default(), window, cx, |s| {
|
|
s.select_anchor_ranges([s.newest_anchor().range()]);
|
|
})
|
|
}
|
|
});
|
|
if matches!(vim.mode, Mode::Insert | Mode::Replace) {
|
|
vim.normal_before(&Default::default(), window, cx);
|
|
} else {
|
|
vim.switch_mode(Mode::Normal, true, window, cx);
|
|
}
|
|
vim.update_editor(window, cx, |_, editor, _, cx| {
|
|
if let Some(first_sel) = initial_selections {
|
|
if let Some(tx_id) = editor
|
|
.buffer()
|
|
.update(cx, |multi, cx| multi.last_transaction_id(cx))
|
|
{
|
|
let last_sel = editor.selections.disjoint_anchors();
|
|
editor.modify_transaction_selection_history(tx_id, |old| {
|
|
old.0 = first_sel;
|
|
old.1 = Some(last_sel);
|
|
});
|
|
}
|
|
}
|
|
});
|
|
})
|
|
.ok();
|
|
})
|
|
.detach();
|
|
});
|
|
|
|
Vim::action(editor, cx, |vim, _: &CountCommand, window, cx| {
|
|
let Some(workspace) = vim.workspace(window) else {
|
|
return;
|
|
};
|
|
let count = Vim::take_count(cx).unwrap_or(1);
|
|
Vim::take_forced_motion(cx);
|
|
let n = if count > 1 {
|
|
format!(".,.+{}", count.saturating_sub(1))
|
|
} else {
|
|
".".to_string()
|
|
};
|
|
workspace.update(cx, |workspace, cx| {
|
|
command_palette::CommandPalette::toggle(workspace, &n, window, cx);
|
|
})
|
|
});
|
|
|
|
Vim::action(editor, cx, |vim, action: &GoToLine, window, cx| {
|
|
vim.switch_mode(Mode::Normal, false, window, cx);
|
|
let result = vim.update_editor(window, cx, |vim, editor, window, cx| {
|
|
let snapshot = editor.snapshot(window, cx);
|
|
let buffer_row = action.range.head().buffer_row(vim, editor, window, cx)?;
|
|
let current = editor.selections.newest::<Point>(cx);
|
|
let target = snapshot
|
|
.buffer_snapshot
|
|
.clip_point(Point::new(buffer_row.0, current.head().column), Bias::Left);
|
|
editor.change_selections(Default::default(), window, cx, |s| {
|
|
s.select_ranges([target..target]);
|
|
});
|
|
|
|
anyhow::Ok(())
|
|
});
|
|
if let Some(e @ Err(_)) = result {
|
|
let Some(workspace) = vim.workspace(window) else {
|
|
return;
|
|
};
|
|
workspace.update(cx, |workspace, cx| {
|
|
e.notify_err(workspace, cx);
|
|
});
|
|
return;
|
|
}
|
|
});
|
|
|
|
Vim::action(editor, cx, |vim, action: &YankCommand, window, cx| {
|
|
vim.update_editor(window, cx, |vim, editor, window, cx| {
|
|
let snapshot = editor.snapshot(window, cx);
|
|
if let Ok(range) = action.range.buffer_range(vim, editor, window, cx) {
|
|
let end = if range.end < snapshot.buffer_snapshot.max_row() {
|
|
Point::new(range.end.0 + 1, 0)
|
|
} else {
|
|
snapshot.buffer_snapshot.max_point()
|
|
};
|
|
vim.copy_ranges(
|
|
editor,
|
|
MotionKind::Linewise,
|
|
true,
|
|
vec![Point::new(range.start.0, 0)..end],
|
|
window,
|
|
cx,
|
|
)
|
|
}
|
|
});
|
|
});
|
|
|
|
Vim::action(editor, cx, |_, action: &WithCount, window, cx| {
|
|
for _ in 0..action.count {
|
|
window.dispatch_action(action.action.boxed_clone(), cx)
|
|
}
|
|
});
|
|
|
|
Vim::action(editor, cx, |vim, action: &WithRange, window, cx| {
|
|
let result = vim.update_editor(window, cx, |vim, editor, window, cx| {
|
|
action.range.buffer_range(vim, editor, window, cx)
|
|
});
|
|
|
|
let range = match result {
|
|
None => return,
|
|
Some(e @ Err(_)) => {
|
|
let Some(workspace) = vim.workspace(window) else {
|
|
return;
|
|
};
|
|
workspace.update(cx, |workspace, cx| {
|
|
e.notify_err(workspace, cx);
|
|
});
|
|
return;
|
|
}
|
|
Some(Ok(result)) => result,
|
|
};
|
|
|
|
let previous_selections = vim
|
|
.update_editor(window, cx, |_, editor, window, cx| {
|
|
let selections = action.restore_selection.then(|| {
|
|
editor
|
|
.selections
|
|
.disjoint_anchor_ranges()
|
|
.collect::<Vec<_>>()
|
|
});
|
|
editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
|
|
let end = Point::new(range.end.0, s.buffer().line_len(range.end));
|
|
s.select_ranges([end..Point::new(range.start.0, 0)]);
|
|
});
|
|
selections
|
|
})
|
|
.flatten();
|
|
window.dispatch_action(action.action.boxed_clone(), cx);
|
|
cx.defer_in(window, move |vim, window, cx| {
|
|
vim.update_editor(window, cx, |_, editor, window, cx| {
|
|
editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
|
|
if let Some(previous_selections) = previous_selections {
|
|
s.select_ranges(previous_selections);
|
|
} else {
|
|
s.select_ranges([
|
|
Point::new(range.start.0, 0)..Point::new(range.start.0, 0)
|
|
]);
|
|
}
|
|
})
|
|
});
|
|
});
|
|
});
|
|
|
|
Vim::action(editor, cx, |vim, action: &OnMatchingLines, window, cx| {
|
|
action.run(vim, window, cx)
|
|
});
|
|
|
|
Vim::action(editor, cx, |vim, action: &ShellExec, window, cx| {
|
|
action.run(vim, window, cx)
|
|
})
|
|
}
|
|
|
|
#[derive(Default)]
|
|
struct VimCommand {
|
|
prefix: &'static str,
|
|
suffix: &'static str,
|
|
action: Option<Box<dyn Action>>,
|
|
action_name: Option<&'static str>,
|
|
bang_action: Option<Box<dyn Action>>,
|
|
args: Option<
|
|
Box<dyn Fn(Box<dyn Action>, String) -> Option<Box<dyn Action>> + Send + Sync + 'static>,
|
|
>,
|
|
range: Option<
|
|
Box<
|
|
dyn Fn(Box<dyn Action>, &CommandRange) -> Option<Box<dyn Action>>
|
|
+ Send
|
|
+ Sync
|
|
+ 'static,
|
|
>,
|
|
>,
|
|
has_count: bool,
|
|
}
|
|
|
|
impl VimCommand {
|
|
fn new(pattern: (&'static str, &'static str), action: impl Action) -> Self {
|
|
Self {
|
|
prefix: pattern.0,
|
|
suffix: pattern.1,
|
|
action: Some(action.boxed_clone()),
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
// from_str is used for actions in other crates.
|
|
fn str(pattern: (&'static str, &'static str), action_name: &'static str) -> Self {
|
|
Self {
|
|
prefix: pattern.0,
|
|
suffix: pattern.1,
|
|
action_name: Some(action_name),
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
fn bang(mut self, bang_action: impl Action) -> Self {
|
|
self.bang_action = Some(bang_action.boxed_clone());
|
|
self
|
|
}
|
|
|
|
fn args(
|
|
mut self,
|
|
f: impl Fn(Box<dyn Action>, String) -> Option<Box<dyn Action>> + Send + Sync + 'static,
|
|
) -> Self {
|
|
self.args = Some(Box::new(f));
|
|
self
|
|
}
|
|
|
|
fn range(
|
|
mut self,
|
|
f: impl Fn(Box<dyn Action>, &CommandRange) -> Option<Box<dyn Action>> + Send + Sync + 'static,
|
|
) -> Self {
|
|
self.range = Some(Box::new(f));
|
|
self
|
|
}
|
|
|
|
fn count(mut self) -> Self {
|
|
self.has_count = true;
|
|
self
|
|
}
|
|
|
|
fn parse(
|
|
&self,
|
|
query: &str,
|
|
range: &Option<CommandRange>,
|
|
cx: &App,
|
|
) -> Option<Box<dyn Action>> {
|
|
let rest = query
|
|
.to_string()
|
|
.strip_prefix(self.prefix)?
|
|
.to_string()
|
|
.chars()
|
|
.zip_longest(self.suffix.to_string().chars())
|
|
.skip_while(|e| e.clone().both().map(|(s, q)| s == q).unwrap_or(false))
|
|
.filter_map(|e| e.left())
|
|
.collect::<String>();
|
|
let has_bang = rest.starts_with('!');
|
|
let args = if has_bang {
|
|
rest.strip_prefix('!')?.trim().to_string()
|
|
} else if rest.is_empty() {
|
|
"".into()
|
|
} else {
|
|
rest.strip_prefix(' ')?.trim().to_string()
|
|
};
|
|
|
|
let action = if has_bang && self.bang_action.is_some() {
|
|
self.bang_action.as_ref().unwrap().boxed_clone()
|
|
} else if let Some(action) = self.action.as_ref() {
|
|
action.boxed_clone()
|
|
} else if let Some(action_name) = self.action_name {
|
|
cx.build_action(action_name, None).log_err()?
|
|
} else {
|
|
return None;
|
|
};
|
|
|
|
let action = if args.is_empty() {
|
|
action
|
|
} else {
|
|
// if command does not accept args and we have args then we should do no action
|
|
self.args.as_ref()?(action, args)?
|
|
};
|
|
|
|
if let Some(range) = range {
|
|
self.range.as_ref().and_then(|f| f(action, range))
|
|
} else {
|
|
Some(action)
|
|
}
|
|
}
|
|
|
|
// TODO: ranges with search queries
|
|
fn parse_range(query: &str) -> (Option<CommandRange>, String) {
|
|
let mut chars = query.chars().peekable();
|
|
|
|
match chars.peek() {
|
|
Some('%') => {
|
|
chars.next();
|
|
return (
|
|
Some(CommandRange {
|
|
start: Position::Line { row: 1, offset: 0 },
|
|
end: Some(Position::LastLine { offset: 0 }),
|
|
}),
|
|
chars.collect(),
|
|
);
|
|
}
|
|
Some('*') => {
|
|
chars.next();
|
|
return (
|
|
Some(CommandRange {
|
|
start: Position::Mark {
|
|
name: '<',
|
|
offset: 0,
|
|
},
|
|
end: Some(Position::Mark {
|
|
name: '>',
|
|
offset: 0,
|
|
}),
|
|
}),
|
|
chars.collect(),
|
|
);
|
|
}
|
|
_ => {}
|
|
}
|
|
|
|
let start = Self::parse_position(&mut chars);
|
|
|
|
match chars.peek() {
|
|
Some(',' | ';') => {
|
|
chars.next();
|
|
(
|
|
Some(CommandRange {
|
|
start: start.unwrap_or(Position::CurrentLine { offset: 0 }),
|
|
end: Self::parse_position(&mut chars),
|
|
}),
|
|
chars.collect(),
|
|
)
|
|
}
|
|
_ => (
|
|
start.map(|start| CommandRange { start, end: None }),
|
|
chars.collect(),
|
|
),
|
|
}
|
|
}
|
|
|
|
fn parse_position(chars: &mut Peekable<Chars>) -> Option<Position> {
|
|
match chars.peek()? {
|
|
'0'..='9' => {
|
|
let row = Self::parse_u32(chars);
|
|
Some(Position::Line {
|
|
row,
|
|
offset: Self::parse_offset(chars),
|
|
})
|
|
}
|
|
'\'' => {
|
|
chars.next();
|
|
let name = chars.next()?;
|
|
Some(Position::Mark {
|
|
name,
|
|
offset: Self::parse_offset(chars),
|
|
})
|
|
}
|
|
'.' => {
|
|
chars.next();
|
|
Some(Position::CurrentLine {
|
|
offset: Self::parse_offset(chars),
|
|
})
|
|
}
|
|
'+' | '-' => Some(Position::CurrentLine {
|
|
offset: Self::parse_offset(chars),
|
|
}),
|
|
'$' => {
|
|
chars.next();
|
|
Some(Position::LastLine {
|
|
offset: Self::parse_offset(chars),
|
|
})
|
|
}
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
fn parse_offset(chars: &mut Peekable<Chars>) -> i32 {
|
|
let mut res: i32 = 0;
|
|
while matches!(chars.peek(), Some('+' | '-')) {
|
|
let sign = if chars.next().unwrap() == '+' { 1 } else { -1 };
|
|
let amount = if matches!(chars.peek(), Some('0'..='9')) {
|
|
(Self::parse_u32(chars) as i32).saturating_mul(sign)
|
|
} else {
|
|
sign
|
|
};
|
|
res = res.saturating_add(amount)
|
|
}
|
|
res
|
|
}
|
|
|
|
fn parse_u32(chars: &mut Peekable<Chars>) -> u32 {
|
|
let mut res: u32 = 0;
|
|
while matches!(chars.peek(), Some('0'..='9')) {
|
|
res = res
|
|
.saturating_mul(10)
|
|
.saturating_add(chars.next().unwrap() as u32 - '0' as u32);
|
|
}
|
|
res
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq)]
|
|
enum Position {
|
|
Line { row: u32, offset: i32 },
|
|
Mark { name: char, offset: i32 },
|
|
LastLine { offset: i32 },
|
|
CurrentLine { offset: i32 },
|
|
}
|
|
|
|
impl Position {
|
|
fn buffer_row(
|
|
&self,
|
|
vim: &Vim,
|
|
editor: &mut Editor,
|
|
window: &mut Window,
|
|
cx: &mut App,
|
|
) -> Result<MultiBufferRow> {
|
|
let snapshot = editor.snapshot(window, cx);
|
|
let target = match self {
|
|
Position::Line { row, offset } => {
|
|
if let Some(anchor) = editor.active_excerpt(cx).and_then(|(_, buffer, _)| {
|
|
editor.buffer().read(cx).buffer_point_to_anchor(
|
|
&buffer,
|
|
Point::new(row.saturating_sub(1), 0),
|
|
cx,
|
|
)
|
|
}) {
|
|
anchor
|
|
.to_point(&snapshot.buffer_snapshot)
|
|
.row
|
|
.saturating_add_signed(*offset)
|
|
} else {
|
|
row.saturating_add_signed(offset.saturating_sub(1))
|
|
}
|
|
}
|
|
Position::Mark { name, offset } => {
|
|
let Some(Mark::Local(anchors)) =
|
|
vim.get_mark(&name.to_string(), editor, window, cx)
|
|
else {
|
|
anyhow::bail!("mark {name} not set");
|
|
};
|
|
let Some(mark) = anchors.last() else {
|
|
anyhow::bail!("mark {name} contains empty anchors");
|
|
};
|
|
mark.to_point(&snapshot.buffer_snapshot)
|
|
.row
|
|
.saturating_add_signed(*offset)
|
|
}
|
|
Position::LastLine { offset } => snapshot
|
|
.buffer_snapshot
|
|
.max_row()
|
|
.0
|
|
.saturating_add_signed(*offset),
|
|
Position::CurrentLine { offset } => editor
|
|
.selections
|
|
.newest_anchor()
|
|
.head()
|
|
.to_point(&snapshot.buffer_snapshot)
|
|
.row
|
|
.saturating_add_signed(*offset),
|
|
};
|
|
|
|
Ok(MultiBufferRow(target).min(snapshot.buffer_snapshot.max_row()))
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
pub(crate) struct CommandRange {
|
|
start: Position,
|
|
end: Option<Position>,
|
|
}
|
|
|
|
impl CommandRange {
|
|
fn head(&self) -> &Position {
|
|
self.end.as_ref().unwrap_or(&self.start)
|
|
}
|
|
|
|
pub(crate) fn buffer_range(
|
|
&self,
|
|
vim: &Vim,
|
|
editor: &mut Editor,
|
|
window: &mut Window,
|
|
cx: &mut App,
|
|
) -> Result<Range<MultiBufferRow>> {
|
|
let start = self.start.buffer_row(vim, editor, window, cx)?;
|
|
let end = if let Some(end) = self.end.as_ref() {
|
|
end.buffer_row(vim, editor, window, cx)?
|
|
} else {
|
|
start
|
|
};
|
|
if end < start {
|
|
anyhow::Ok(end..start)
|
|
} else {
|
|
anyhow::Ok(start..end)
|
|
}
|
|
}
|
|
|
|
pub fn as_count(&self) -> Option<u32> {
|
|
if let CommandRange {
|
|
start: Position::Line { row, offset: 0 },
|
|
end: None,
|
|
} = &self
|
|
{
|
|
Some(*row)
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
}
|
|
|
|
fn generate_commands(_: &App) -> Vec<VimCommand> {
|
|
vec![
|
|
VimCommand::new(
|
|
("w", "rite"),
|
|
workspace::Save {
|
|
save_intent: Some(SaveIntent::Save),
|
|
},
|
|
)
|
|
.bang(workspace::Save {
|
|
save_intent: Some(SaveIntent::Overwrite),
|
|
})
|
|
.args(|action, args| {
|
|
Some(
|
|
VimSave {
|
|
save_intent: action
|
|
.as_any()
|
|
.downcast_ref::<workspace::Save>()
|
|
.and_then(|action| action.save_intent),
|
|
filename: args,
|
|
}
|
|
.boxed_clone(),
|
|
)
|
|
}),
|
|
VimCommand::new(
|
|
("q", "uit"),
|
|
workspace::CloseActiveItem {
|
|
save_intent: Some(SaveIntent::Close),
|
|
close_pinned: false,
|
|
},
|
|
)
|
|
.bang(workspace::CloseActiveItem {
|
|
save_intent: Some(SaveIntent::Skip),
|
|
close_pinned: true,
|
|
}),
|
|
VimCommand::new(
|
|
("wq", ""),
|
|
workspace::CloseActiveItem {
|
|
save_intent: Some(SaveIntent::Save),
|
|
close_pinned: false,
|
|
},
|
|
)
|
|
.bang(workspace::CloseActiveItem {
|
|
save_intent: Some(SaveIntent::Overwrite),
|
|
close_pinned: true,
|
|
}),
|
|
VimCommand::new(
|
|
("x", "it"),
|
|
workspace::CloseActiveItem {
|
|
save_intent: Some(SaveIntent::SaveAll),
|
|
close_pinned: false,
|
|
},
|
|
)
|
|
.bang(workspace::CloseActiveItem {
|
|
save_intent: Some(SaveIntent::Overwrite),
|
|
close_pinned: true,
|
|
}),
|
|
VimCommand::new(
|
|
("exi", "t"),
|
|
workspace::CloseActiveItem {
|
|
save_intent: Some(SaveIntent::SaveAll),
|
|
close_pinned: false,
|
|
},
|
|
)
|
|
.bang(workspace::CloseActiveItem {
|
|
save_intent: Some(SaveIntent::Overwrite),
|
|
close_pinned: true,
|
|
}),
|
|
VimCommand::new(
|
|
("up", "date"),
|
|
workspace::Save {
|
|
save_intent: Some(SaveIntent::SaveAll),
|
|
},
|
|
),
|
|
VimCommand::new(
|
|
("wa", "ll"),
|
|
workspace::SaveAll {
|
|
save_intent: Some(SaveIntent::SaveAll),
|
|
},
|
|
)
|
|
.bang(workspace::SaveAll {
|
|
save_intent: Some(SaveIntent::Overwrite),
|
|
}),
|
|
VimCommand::new(
|
|
("qa", "ll"),
|
|
workspace::CloseAllItemsAndPanes {
|
|
save_intent: Some(SaveIntent::Close),
|
|
},
|
|
)
|
|
.bang(workspace::CloseAllItemsAndPanes {
|
|
save_intent: Some(SaveIntent::Skip),
|
|
}),
|
|
VimCommand::new(
|
|
("quita", "ll"),
|
|
workspace::CloseAllItemsAndPanes {
|
|
save_intent: Some(SaveIntent::Close),
|
|
},
|
|
)
|
|
.bang(workspace::CloseAllItemsAndPanes {
|
|
save_intent: Some(SaveIntent::Skip),
|
|
}),
|
|
VimCommand::new(
|
|
("xa", "ll"),
|
|
workspace::CloseAllItemsAndPanes {
|
|
save_intent: Some(SaveIntent::SaveAll),
|
|
},
|
|
)
|
|
.bang(workspace::CloseAllItemsAndPanes {
|
|
save_intent: Some(SaveIntent::Overwrite),
|
|
}),
|
|
VimCommand::new(
|
|
("wqa", "ll"),
|
|
workspace::CloseAllItemsAndPanes {
|
|
save_intent: Some(SaveIntent::SaveAll),
|
|
},
|
|
)
|
|
.bang(workspace::CloseAllItemsAndPanes {
|
|
save_intent: Some(SaveIntent::Overwrite),
|
|
}),
|
|
VimCommand::new(("cq", "uit"), zed_actions::Quit),
|
|
VimCommand::new(("sp", "lit"), workspace::SplitHorizontal).args(|_, args| {
|
|
Some(
|
|
VimSplit {
|
|
vertical: false,
|
|
filename: args,
|
|
}
|
|
.boxed_clone(),
|
|
)
|
|
}),
|
|
VimCommand::new(("vs", "plit"), workspace::SplitVertical).args(|_, args| {
|
|
Some(
|
|
VimSplit {
|
|
vertical: true,
|
|
filename: args,
|
|
}
|
|
.boxed_clone(),
|
|
)
|
|
}),
|
|
VimCommand::new(
|
|
("bd", "elete"),
|
|
workspace::CloseActiveItem {
|
|
save_intent: Some(SaveIntent::Close),
|
|
close_pinned: false,
|
|
},
|
|
)
|
|
.bang(workspace::CloseActiveItem {
|
|
save_intent: Some(SaveIntent::Skip),
|
|
close_pinned: true,
|
|
}),
|
|
VimCommand::new(
|
|
("norm", "al"),
|
|
VimNorm {
|
|
command: "".into(),
|
|
range: None,
|
|
},
|
|
)
|
|
.args(|_, args| {
|
|
Some(
|
|
VimNorm {
|
|
command: args,
|
|
range: None,
|
|
}
|
|
.boxed_clone(),
|
|
)
|
|
})
|
|
.range(|action, range| {
|
|
let mut action: VimNorm = action.as_any().downcast_ref::<VimNorm>().unwrap().clone();
|
|
action.range.replace(range.clone());
|
|
Some(Box::new(action))
|
|
}),
|
|
VimCommand::new(("bn", "ext"), workspace::ActivateNextItem).count(),
|
|
VimCommand::new(("bN", "ext"), workspace::ActivatePreviousItem).count(),
|
|
VimCommand::new(("bp", "revious"), workspace::ActivatePreviousItem).count(),
|
|
VimCommand::new(("bf", "irst"), workspace::ActivateItem(0)),
|
|
VimCommand::new(("br", "ewind"), workspace::ActivateItem(0)),
|
|
VimCommand::new(("bl", "ast"), workspace::ActivateLastItem),
|
|
VimCommand::str(("buffers", ""), "tab_switcher::ToggleAll"),
|
|
VimCommand::str(("ls", ""), "tab_switcher::ToggleAll"),
|
|
VimCommand::new(("new", ""), workspace::NewFileSplitHorizontal),
|
|
VimCommand::new(("vne", "w"), workspace::NewFileSplitVertical),
|
|
VimCommand::new(("tabe", "dit"), workspace::NewFile),
|
|
VimCommand::new(("tabnew", ""), workspace::NewFile),
|
|
VimCommand::new(("tabn", "ext"), workspace::ActivateNextItem).count(),
|
|
VimCommand::new(("tabp", "revious"), workspace::ActivatePreviousItem).count(),
|
|
VimCommand::new(("tabN", "ext"), workspace::ActivatePreviousItem).count(),
|
|
VimCommand::new(
|
|
("tabc", "lose"),
|
|
workspace::CloseActiveItem {
|
|
save_intent: Some(SaveIntent::Close),
|
|
close_pinned: false,
|
|
},
|
|
),
|
|
VimCommand::new(
|
|
("tabo", "nly"),
|
|
workspace::CloseOtherItems {
|
|
save_intent: Some(SaveIntent::Close),
|
|
close_pinned: false,
|
|
},
|
|
)
|
|
.bang(workspace::CloseOtherItems {
|
|
save_intent: Some(SaveIntent::Skip),
|
|
close_pinned: false,
|
|
}),
|
|
VimCommand::new(
|
|
("on", "ly"),
|
|
workspace::CloseInactiveTabsAndPanes {
|
|
save_intent: Some(SaveIntent::Close),
|
|
},
|
|
)
|
|
.bang(workspace::CloseInactiveTabsAndPanes {
|
|
save_intent: Some(SaveIntent::Skip),
|
|
}),
|
|
VimCommand::str(("cl", "ist"), "diagnostics::Deploy"),
|
|
VimCommand::new(("cc", ""), editor::actions::Hover),
|
|
VimCommand::new(("ll", ""), editor::actions::Hover),
|
|
VimCommand::new(("cn", "ext"), editor::actions::GoToDiagnostic::default())
|
|
.range(wrap_count),
|
|
VimCommand::new(
|
|
("cp", "revious"),
|
|
editor::actions::GoToPreviousDiagnostic::default(),
|
|
)
|
|
.range(wrap_count),
|
|
VimCommand::new(
|
|
("cN", "ext"),
|
|
editor::actions::GoToPreviousDiagnostic::default(),
|
|
)
|
|
.range(wrap_count),
|
|
VimCommand::new(
|
|
("lp", "revious"),
|
|
editor::actions::GoToPreviousDiagnostic::default(),
|
|
)
|
|
.range(wrap_count),
|
|
VimCommand::new(
|
|
("lN", "ext"),
|
|
editor::actions::GoToPreviousDiagnostic::default(),
|
|
)
|
|
.range(wrap_count),
|
|
VimCommand::new(("j", "oin"), JoinLines).range(select_range),
|
|
VimCommand::new(("fo", "ld"), editor::actions::FoldSelectedRanges).range(act_on_range),
|
|
VimCommand::new(("foldo", "pen"), editor::actions::UnfoldLines)
|
|
.bang(editor::actions::UnfoldRecursive)
|
|
.range(act_on_range),
|
|
VimCommand::new(("foldc", "lose"), editor::actions::Fold)
|
|
.bang(editor::actions::FoldRecursive)
|
|
.range(act_on_range),
|
|
VimCommand::new(("dif", "fupdate"), editor::actions::ToggleSelectedDiffHunks)
|
|
.range(act_on_range),
|
|
VimCommand::str(("rev", "ert"), "git::Restore").range(act_on_range),
|
|
VimCommand::new(("d", "elete"), VisualDeleteLine).range(select_range),
|
|
VimCommand::new(("y", "ank"), gpui::NoAction).range(|_, range| {
|
|
Some(
|
|
YankCommand {
|
|
range: range.clone(),
|
|
}
|
|
.boxed_clone(),
|
|
)
|
|
}),
|
|
VimCommand::new(("reg", "isters"), ToggleRegistersView).bang(ToggleRegistersView),
|
|
VimCommand::new(("di", "splay"), ToggleRegistersView).bang(ToggleRegistersView),
|
|
VimCommand::new(("marks", ""), ToggleMarksView).bang(ToggleMarksView),
|
|
VimCommand::new(("delm", "arks"), ArgumentRequired)
|
|
.bang(DeleteMarks::AllLocal)
|
|
.args(|_, args| Some(DeleteMarks::Marks(args).boxed_clone())),
|
|
VimCommand::new(("sor", "t"), SortLinesCaseSensitive).range(select_range),
|
|
VimCommand::new(("sort i", ""), SortLinesCaseInsensitive).range(select_range),
|
|
VimCommand::str(("E", "xplore"), "project_panel::ToggleFocus"),
|
|
VimCommand::str(("H", "explore"), "project_panel::ToggleFocus"),
|
|
VimCommand::str(("L", "explore"), "project_panel::ToggleFocus"),
|
|
VimCommand::str(("S", "explore"), "project_panel::ToggleFocus"),
|
|
VimCommand::str(("Ve", "xplore"), "project_panel::ToggleFocus"),
|
|
VimCommand::str(("te", "rm"), "terminal_panel::ToggleFocus"),
|
|
VimCommand::str(("T", "erm"), "terminal_panel::ToggleFocus"),
|
|
VimCommand::str(("C", "ollab"), "collab_panel::ToggleFocus"),
|
|
VimCommand::str(("Ch", "at"), "chat_panel::ToggleFocus"),
|
|
VimCommand::str(("No", "tifications"), "notification_panel::ToggleFocus"),
|
|
VimCommand::str(("A", "I"), "agent::ToggleFocus"),
|
|
VimCommand::str(("G", "it"), "git_panel::ToggleFocus"),
|
|
VimCommand::str(("D", "ebug"), "debug_panel::ToggleFocus"),
|
|
VimCommand::new(("noh", "lsearch"), search::buffer_search::Dismiss),
|
|
VimCommand::new(("$", ""), EndOfDocument),
|
|
VimCommand::new(("%", ""), EndOfDocument),
|
|
VimCommand::new(("0", ""), StartOfDocument),
|
|
VimCommand::new(("e", "dit"), editor::actions::ReloadFile)
|
|
.bang(editor::actions::ReloadFile)
|
|
.args(|_, args| Some(VimEdit { filename: args }.boxed_clone())),
|
|
VimCommand::new(("ex", ""), editor::actions::ReloadFile).bang(editor::actions::ReloadFile),
|
|
VimCommand::new(("cpp", "link"), editor::actions::CopyPermalinkToLine).range(act_on_range),
|
|
VimCommand::str(("opt", "ions"), "zed::OpenDefaultSettings"),
|
|
VimCommand::str(("map", ""), "vim::OpenDefaultKeymap"),
|
|
VimCommand::new(("h", "elp"), OpenDocs),
|
|
]
|
|
}
|
|
|
|
struct VimCommands(Vec<VimCommand>);
|
|
// safety: we only ever access this from the main thread (as ensured by the cx argument)
|
|
// actions are not Sync so we can't otherwise use a OnceLock.
|
|
unsafe impl Sync for VimCommands {}
|
|
impl Global for VimCommands {}
|
|
|
|
fn commands(cx: &App) -> &Vec<VimCommand> {
|
|
static COMMANDS: OnceLock<VimCommands> = OnceLock::new();
|
|
&COMMANDS
|
|
.get_or_init(|| VimCommands(generate_commands(cx)))
|
|
.0
|
|
}
|
|
|
|
fn act_on_range(action: Box<dyn Action>, range: &CommandRange) -> Option<Box<dyn Action>> {
|
|
Some(
|
|
WithRange {
|
|
restore_selection: true,
|
|
range: range.clone(),
|
|
action: WrappedAction(action),
|
|
}
|
|
.boxed_clone(),
|
|
)
|
|
}
|
|
|
|
fn select_range(action: Box<dyn Action>, range: &CommandRange) -> Option<Box<dyn Action>> {
|
|
Some(
|
|
WithRange {
|
|
restore_selection: false,
|
|
range: range.clone(),
|
|
action: WrappedAction(action),
|
|
}
|
|
.boxed_clone(),
|
|
)
|
|
}
|
|
|
|
fn wrap_count(action: Box<dyn Action>, range: &CommandRange) -> Option<Box<dyn Action>> {
|
|
range.as_count().map(|count| {
|
|
WithCount {
|
|
count,
|
|
action: WrappedAction(action),
|
|
}
|
|
.boxed_clone()
|
|
})
|
|
}
|
|
|
|
pub fn command_interceptor(mut input: &str, cx: &App) -> Vec<CommandInterceptResult> {
|
|
// NOTE: We also need to support passing arguments to commands like :w
|
|
// (ideally with filename autocompletion).
|
|
while input.starts_with(':') {
|
|
input = &input[1..];
|
|
}
|
|
|
|
let (range, query) = VimCommand::parse_range(input);
|
|
let range_prefix = input[0..(input.len() - query.len())].to_string();
|
|
let query = query.as_str().trim();
|
|
|
|
let action = if range.is_some() && query.is_empty() {
|
|
Some(
|
|
GoToLine {
|
|
range: range.clone().unwrap(),
|
|
}
|
|
.boxed_clone(),
|
|
)
|
|
} else if query.starts_with('/') || query.starts_with('?') {
|
|
Some(
|
|
FindCommand {
|
|
query: query[1..].to_string(),
|
|
backwards: query.starts_with('?'),
|
|
}
|
|
.boxed_clone(),
|
|
)
|
|
} else if query.starts_with("se ") || query.starts_with("set ") {
|
|
let (prefix, option) = query.split_once(' ').unwrap();
|
|
let mut commands = VimOption::possible_commands(option);
|
|
if !commands.is_empty() {
|
|
let query = prefix.to_string() + " " + option;
|
|
for command in &mut commands {
|
|
command.positions = generate_positions(&command.string, &query);
|
|
}
|
|
}
|
|
return commands;
|
|
} else if query.starts_with('s') {
|
|
let mut substitute = "substitute".chars().peekable();
|
|
let mut query = query.chars().peekable();
|
|
while substitute
|
|
.peek()
|
|
.is_some_and(|char| Some(char) == query.peek())
|
|
{
|
|
substitute.next();
|
|
query.next();
|
|
}
|
|
if let Some(replacement) = Replacement::parse(query) {
|
|
let range = range.clone().unwrap_or(CommandRange {
|
|
start: Position::CurrentLine { offset: 0 },
|
|
end: None,
|
|
});
|
|
Some(ReplaceCommand { replacement, range }.boxed_clone())
|
|
} else {
|
|
None
|
|
}
|
|
} else if query.starts_with('g') || query.starts_with('v') {
|
|
let mut global = "global".chars().peekable();
|
|
let mut query = query.chars().peekable();
|
|
let mut invert = false;
|
|
if query.peek() == Some(&'v') {
|
|
invert = true;
|
|
query.next();
|
|
}
|
|
while global.peek().is_some_and(|char| Some(char) == query.peek()) {
|
|
global.next();
|
|
query.next();
|
|
}
|
|
if !invert && query.peek() == Some(&'!') {
|
|
invert = true;
|
|
query.next();
|
|
}
|
|
let range = range.clone().unwrap_or(CommandRange {
|
|
start: Position::Line { row: 0, offset: 0 },
|
|
end: Some(Position::LastLine { offset: 0 }),
|
|
});
|
|
if let Some(action) = OnMatchingLines::parse(query, invert, range, cx) {
|
|
Some(action.boxed_clone())
|
|
} else {
|
|
None
|
|
}
|
|
} else if query.contains('!') {
|
|
ShellExec::parse(query, range.clone())
|
|
} else {
|
|
None
|
|
};
|
|
if let Some(action) = action {
|
|
let string = input.to_string();
|
|
let positions = generate_positions(&string, &(range_prefix + query));
|
|
return vec![CommandInterceptResult {
|
|
action,
|
|
string,
|
|
positions,
|
|
}];
|
|
}
|
|
|
|
for command in commands(cx).iter() {
|
|
if let Some(action) = command.parse(query, &range, cx) {
|
|
let mut string = ":".to_owned() + &range_prefix + command.prefix + command.suffix;
|
|
if query.contains('!') {
|
|
string.push('!');
|
|
}
|
|
let positions = generate_positions(&string, &(range_prefix + query));
|
|
|
|
return vec![CommandInterceptResult {
|
|
action,
|
|
string,
|
|
positions,
|
|
}];
|
|
}
|
|
}
|
|
return Vec::default();
|
|
}
|
|
|
|
fn generate_positions(string: &str, query: &str) -> Vec<usize> {
|
|
let mut positions = Vec::new();
|
|
let mut chars = query.chars();
|
|
|
|
let Some(mut current) = chars.next() else {
|
|
return positions;
|
|
};
|
|
|
|
for (i, c) in string.char_indices() {
|
|
if c == current {
|
|
positions.push(i);
|
|
if let Some(c) = chars.next() {
|
|
current = c;
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
positions
|
|
}
|
|
|
|
/// Applies a command to all lines matching a pattern.
|
|
#[derive(Debug, PartialEq, Clone, Action)]
|
|
#[action(namespace = vim, no_json, no_register)]
|
|
pub(crate) struct OnMatchingLines {
|
|
range: CommandRange,
|
|
search: String,
|
|
action: WrappedAction,
|
|
invert: bool,
|
|
}
|
|
|
|
impl OnMatchingLines {
|
|
// convert a vim query into something more usable by zed.
|
|
// we don't attempt to fully convert between the two regex syntaxes,
|
|
// but we do flip \( and \) to ( and ) (and vice-versa) in the pattern,
|
|
// and convert \0..\9 to $0..$9 in the replacement so that common idioms work.
|
|
pub(crate) fn parse(
|
|
mut chars: Peekable<Chars>,
|
|
invert: bool,
|
|
range: CommandRange,
|
|
cx: &App,
|
|
) -> Option<Self> {
|
|
let delimiter = chars.next().filter(|c| {
|
|
!c.is_alphanumeric() && *c != '"' && *c != '|' && *c != '\'' && *c != '!'
|
|
})?;
|
|
|
|
let mut search = String::new();
|
|
let mut escaped = false;
|
|
|
|
while let Some(c) = chars.next() {
|
|
if escaped {
|
|
escaped = false;
|
|
// unescape escaped parens
|
|
if c != '(' && c != ')' && c != delimiter {
|
|
search.push('\\')
|
|
}
|
|
search.push(c)
|
|
} else if c == '\\' {
|
|
escaped = true;
|
|
} else if c == delimiter {
|
|
break;
|
|
} else {
|
|
// escape unescaped parens
|
|
if c == '(' || c == ')' {
|
|
search.push('\\')
|
|
}
|
|
search.push(c)
|
|
}
|
|
}
|
|
|
|
let command: String = chars.collect();
|
|
|
|
let action = WrappedAction(
|
|
command_interceptor(&command, cx)
|
|
.first()?
|
|
.action
|
|
.boxed_clone(),
|
|
);
|
|
|
|
Some(Self {
|
|
range,
|
|
search,
|
|
invert,
|
|
action,
|
|
})
|
|
}
|
|
|
|
pub fn run(&self, vim: &mut Vim, window: &mut Window, cx: &mut Context<Vim>) {
|
|
let result = vim.update_editor(window, cx, |vim, editor, window, cx| {
|
|
self.range.buffer_range(vim, editor, window, cx)
|
|
});
|
|
|
|
let range = match result {
|
|
None => return,
|
|
Some(e @ Err(_)) => {
|
|
let Some(workspace) = vim.workspace(window) else {
|
|
return;
|
|
};
|
|
workspace.update(cx, |workspace, cx| {
|
|
e.notify_err(workspace, cx);
|
|
});
|
|
return;
|
|
}
|
|
Some(Ok(result)) => result,
|
|
};
|
|
|
|
let mut action = self.action.boxed_clone();
|
|
let mut last_pattern = self.search.clone();
|
|
|
|
let mut regexes = match Regex::new(&self.search) {
|
|
Ok(regex) => vec![(regex, !self.invert)],
|
|
e @ Err(_) => {
|
|
let Some(workspace) = vim.workspace(window) else {
|
|
return;
|
|
};
|
|
workspace.update(cx, |workspace, cx| {
|
|
e.notify_err(workspace, cx);
|
|
});
|
|
return;
|
|
}
|
|
};
|
|
while let Some(inner) = action
|
|
.boxed_clone()
|
|
.as_any()
|
|
.downcast_ref::<OnMatchingLines>()
|
|
{
|
|
let Some(regex) = Regex::new(&inner.search).ok() else {
|
|
break;
|
|
};
|
|
last_pattern = inner.search.clone();
|
|
action = inner.action.boxed_clone();
|
|
regexes.push((regex, !inner.invert))
|
|
}
|
|
|
|
if let Some(pane) = vim.pane(window, cx) {
|
|
pane.update(cx, |pane, cx| {
|
|
if let Some(search_bar) = pane.toolbar().read(cx).item_of_type::<BufferSearchBar>()
|
|
{
|
|
search_bar.update(cx, |search_bar, cx| {
|
|
if search_bar.show(window, cx) {
|
|
let _ = search_bar.search(
|
|
&last_pattern,
|
|
Some(SearchOptions::REGEX | SearchOptions::CASE_SENSITIVE),
|
|
window,
|
|
cx,
|
|
);
|
|
}
|
|
});
|
|
}
|
|
});
|
|
};
|
|
|
|
vim.update_editor(window, cx, |_, editor, window, cx| {
|
|
let snapshot = editor.snapshot(window, cx);
|
|
let mut row = range.start.0;
|
|
|
|
let point_range = Point::new(range.start.0, 0)
|
|
..snapshot
|
|
.buffer_snapshot
|
|
.clip_point(Point::new(range.end.0 + 1, 0), Bias::Left);
|
|
cx.spawn_in(window, async move |editor, cx| {
|
|
let new_selections = cx
|
|
.background_spawn(async move {
|
|
let mut line = String::new();
|
|
let mut new_selections = Vec::new();
|
|
let chunks = snapshot
|
|
.buffer_snapshot
|
|
.text_for_range(point_range)
|
|
.chain(["\n"]);
|
|
|
|
for chunk in chunks {
|
|
for (newline_ix, text) in chunk.split('\n').enumerate() {
|
|
if newline_ix > 0 {
|
|
if regexes.iter().all(|(regex, should_match)| {
|
|
regex.is_match(&line) == *should_match
|
|
}) {
|
|
new_selections
|
|
.push(Point::new(row, 0).to_display_point(&snapshot))
|
|
}
|
|
row += 1;
|
|
line.clear();
|
|
}
|
|
line.push_str(text)
|
|
}
|
|
}
|
|
|
|
new_selections
|
|
})
|
|
.await;
|
|
|
|
if new_selections.is_empty() {
|
|
return;
|
|
}
|
|
editor
|
|
.update_in(cx, |editor, window, cx| {
|
|
editor.start_transaction_at(Instant::now(), window, cx);
|
|
editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
|
|
s.replace_cursors_with(|_| new_selections);
|
|
});
|
|
window.dispatch_action(action, cx);
|
|
cx.defer_in(window, move |editor, window, cx| {
|
|
let newest = editor.selections.newest::<Point>(cx).clone();
|
|
editor.change_selections(
|
|
SelectionEffects::no_scroll(),
|
|
window,
|
|
cx,
|
|
|s| {
|
|
s.select(vec![newest]);
|
|
},
|
|
);
|
|
editor.end_transaction_at(Instant::now(), cx);
|
|
})
|
|
})
|
|
.ok();
|
|
})
|
|
.detach();
|
|
});
|
|
}
|
|
}
|
|
|
|
/// Executes a shell command and returns the output.
|
|
#[derive(Clone, Debug, PartialEq, Action)]
|
|
#[action(namespace = vim, no_json, no_register)]
|
|
pub struct ShellExec {
|
|
command: String,
|
|
range: Option<CommandRange>,
|
|
is_read: bool,
|
|
}
|
|
|
|
impl Vim {
|
|
pub fn cancel_running_command(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
|
if self.running_command.take().is_some() {
|
|
self.update_editor(window, cx, |_, editor, window, cx| {
|
|
editor.transact(window, cx, |editor, _window, _cx| {
|
|
editor.clear_row_highlights::<ShellExec>();
|
|
})
|
|
});
|
|
}
|
|
}
|
|
|
|
fn prepare_shell_command(
|
|
&mut self,
|
|
command: &str,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) -> String {
|
|
let mut ret = String::new();
|
|
// N.B. non-standard escaping rules:
|
|
// * !echo % => "echo README.md"
|
|
// * !echo \% => "echo %"
|
|
// * !echo \\% => echo \%
|
|
// * !echo \\\% => echo \\%
|
|
for c in command.chars() {
|
|
if c != '%' && c != '!' {
|
|
ret.push(c);
|
|
continue;
|
|
} else if ret.chars().last() == Some('\\') {
|
|
ret.pop();
|
|
ret.push(c);
|
|
continue;
|
|
}
|
|
match c {
|
|
'%' => {
|
|
self.update_editor(window, cx, |_, editor, _window, cx| {
|
|
if let Some((_, buffer, _)) = editor.active_excerpt(cx) {
|
|
if let Some(file) = buffer.read(cx).file() {
|
|
if let Some(local) = file.as_local() {
|
|
if let Some(str) = local.path().to_str() {
|
|
ret.push_str(str)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
'!' => {
|
|
if let Some(command) = &self.last_command {
|
|
ret.push_str(command)
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
self.last_command = Some(ret.clone());
|
|
ret
|
|
}
|
|
|
|
pub fn shell_command_motion(
|
|
&mut self,
|
|
motion: Motion,
|
|
times: Option<usize>,
|
|
forced_motion: bool,
|
|
window: &mut Window,
|
|
cx: &mut Context<Vim>,
|
|
) {
|
|
self.stop_recording(cx);
|
|
let Some(workspace) = self.workspace(window) else {
|
|
return;
|
|
};
|
|
let command = self.update_editor(window, cx, |_, editor, window, cx| {
|
|
let snapshot = editor.snapshot(window, cx);
|
|
let start = editor.selections.newest_display(cx);
|
|
let text_layout_details = editor.text_layout_details(window);
|
|
let (mut range, _) = motion
|
|
.range(
|
|
&snapshot,
|
|
start.clone(),
|
|
times,
|
|
&text_layout_details,
|
|
forced_motion,
|
|
)
|
|
.unwrap_or((start.range(), MotionKind::Exclusive));
|
|
if range.start != start.start {
|
|
editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
|
|
s.select_ranges([
|
|
range.start.to_point(&snapshot)..range.start.to_point(&snapshot)
|
|
]);
|
|
})
|
|
}
|
|
if range.end.row() > range.start.row() && range.end.column() != 0 {
|
|
*range.end.row_mut() -= 1
|
|
}
|
|
if range.end.row() == range.start.row() {
|
|
".!".to_string()
|
|
} else {
|
|
format!(".,.+{}!", (range.end.row() - range.start.row()).0)
|
|
}
|
|
});
|
|
if let Some(command) = command {
|
|
workspace.update(cx, |workspace, cx| {
|
|
command_palette::CommandPalette::toggle(workspace, &command, window, cx);
|
|
});
|
|
}
|
|
}
|
|
|
|
pub fn shell_command_object(
|
|
&mut self,
|
|
object: Object,
|
|
around: bool,
|
|
window: &mut Window,
|
|
cx: &mut Context<Vim>,
|
|
) {
|
|
self.stop_recording(cx);
|
|
let Some(workspace) = self.workspace(window) else {
|
|
return;
|
|
};
|
|
let command = self.update_editor(window, cx, |_, editor, window, cx| {
|
|
let snapshot = editor.snapshot(window, cx);
|
|
let start = editor.selections.newest_display(cx);
|
|
let range = object
|
|
.range(&snapshot, start.clone(), around, None)
|
|
.unwrap_or(start.range());
|
|
if range.start != start.start {
|
|
editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
|
|
s.select_ranges([
|
|
range.start.to_point(&snapshot)..range.start.to_point(&snapshot)
|
|
]);
|
|
})
|
|
}
|
|
if range.end.row() == range.start.row() {
|
|
".!".to_string()
|
|
} else {
|
|
format!(".,.+{}!", (range.end.row() - range.start.row()).0)
|
|
}
|
|
});
|
|
if let Some(command) = command {
|
|
workspace.update(cx, |workspace, cx| {
|
|
command_palette::CommandPalette::toggle(workspace, &command, window, cx);
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
impl ShellExec {
|
|
pub fn parse(query: &str, range: Option<CommandRange>) -> Option<Box<dyn Action>> {
|
|
let (before, after) = query.split_once('!')?;
|
|
let before = before.trim();
|
|
|
|
if !"read".starts_with(before) {
|
|
return None;
|
|
}
|
|
|
|
Some(
|
|
ShellExec {
|
|
command: after.trim().to_string(),
|
|
range,
|
|
is_read: !before.is_empty(),
|
|
}
|
|
.boxed_clone(),
|
|
)
|
|
}
|
|
|
|
pub fn run(&self, vim: &mut Vim, window: &mut Window, cx: &mut Context<Vim>) {
|
|
let Some(workspace) = vim.workspace(window) else {
|
|
return;
|
|
};
|
|
|
|
let project = workspace.read(cx).project().clone();
|
|
let command = vim.prepare_shell_command(&self.command, window, cx);
|
|
|
|
if self.range.is_none() && !self.is_read {
|
|
workspace.update(cx, |workspace, cx| {
|
|
let project = workspace.project().read(cx);
|
|
let cwd = project.first_project_directory(cx);
|
|
let shell = project.terminal_settings(&cwd, cx).shell.clone();
|
|
|
|
let spawn_in_terminal = SpawnInTerminal {
|
|
id: TaskId("vim".to_string()),
|
|
full_label: command.clone(),
|
|
label: command.clone(),
|
|
command: Some(command.clone()),
|
|
args: Vec::new(),
|
|
command_label: command.clone(),
|
|
cwd,
|
|
env: HashMap::default(),
|
|
use_new_terminal: true,
|
|
allow_concurrent_runs: true,
|
|
reveal: RevealStrategy::NoFocus,
|
|
reveal_target: RevealTarget::Dock,
|
|
hide: HideStrategy::Never,
|
|
shell,
|
|
show_summary: false,
|
|
show_command: false,
|
|
show_rerun: false,
|
|
};
|
|
|
|
let task_status = workspace.spawn_in_terminal(spawn_in_terminal, window, cx);
|
|
cx.background_spawn(async move {
|
|
match task_status.await {
|
|
Some(Ok(status)) => {
|
|
if status.success() {
|
|
log::debug!("Vim shell exec succeeded");
|
|
} else {
|
|
log::debug!("Vim shell exec failed, code: {:?}", status.code());
|
|
}
|
|
}
|
|
Some(Err(e)) => log::error!("Vim shell exec failed: {e}"),
|
|
None => log::debug!("Vim shell exec got cancelled"),
|
|
}
|
|
})
|
|
.detach();
|
|
});
|
|
return;
|
|
};
|
|
|
|
let mut input_snapshot = None;
|
|
let mut input_range = None;
|
|
let mut needs_newline_prefix = false;
|
|
vim.update_editor(window, cx, |vim, editor, window, cx| {
|
|
let snapshot = editor.buffer().read(cx).snapshot(cx);
|
|
let range = if let Some(range) = self.range.clone() {
|
|
let Some(range) = range.buffer_range(vim, editor, window, cx).log_err() else {
|
|
return;
|
|
};
|
|
Point::new(range.start.0, 0)
|
|
..snapshot.clip_point(Point::new(range.end.0 + 1, 0), Bias::Right)
|
|
} else {
|
|
let mut end = editor.selections.newest::<Point>(cx).range().end;
|
|
end = snapshot.clip_point(Point::new(end.row + 1, 0), Bias::Right);
|
|
needs_newline_prefix = end == snapshot.max_point();
|
|
end..end
|
|
};
|
|
if self.is_read {
|
|
input_range =
|
|
Some(snapshot.anchor_after(range.end)..snapshot.anchor_after(range.end));
|
|
} else {
|
|
input_range =
|
|
Some(snapshot.anchor_before(range.start)..snapshot.anchor_after(range.end));
|
|
}
|
|
editor.highlight_rows::<ShellExec>(
|
|
input_range.clone().unwrap(),
|
|
cx.theme().status().unreachable_background,
|
|
Default::default(),
|
|
cx,
|
|
);
|
|
|
|
if !self.is_read {
|
|
input_snapshot = Some(snapshot)
|
|
}
|
|
});
|
|
|
|
let Some(range) = input_range else { return };
|
|
|
|
let mut process = project.read(cx).exec_in_shell(command, cx);
|
|
process.stdout(Stdio::piped());
|
|
process.stderr(Stdio::piped());
|
|
|
|
if input_snapshot.is_some() {
|
|
process.stdin(Stdio::piped());
|
|
} else {
|
|
process.stdin(Stdio::null());
|
|
};
|
|
|
|
util::set_pre_exec_to_start_new_session(&mut process);
|
|
let is_read = self.is_read;
|
|
|
|
let task = cx.spawn_in(window, async move |vim, cx| {
|
|
let Some(mut running) = process.spawn().log_err() else {
|
|
vim.update_in(cx, |vim, window, cx| {
|
|
vim.cancel_running_command(window, cx);
|
|
})
|
|
.log_err();
|
|
return;
|
|
};
|
|
|
|
if let Some(mut stdin) = running.stdin.take() {
|
|
if let Some(snapshot) = input_snapshot {
|
|
let range = range.clone();
|
|
cx.background_spawn(async move {
|
|
for chunk in snapshot.text_for_range(range) {
|
|
if stdin.write_all(chunk.as_bytes()).log_err().is_none() {
|
|
return;
|
|
}
|
|
}
|
|
stdin.flush().log_err();
|
|
})
|
|
.detach();
|
|
}
|
|
};
|
|
|
|
let output = cx
|
|
.background_spawn(async move { running.wait_with_output() })
|
|
.await;
|
|
|
|
let Some(output) = output.log_err() else {
|
|
vim.update_in(cx, |vim, window, cx| {
|
|
vim.cancel_running_command(window, cx);
|
|
})
|
|
.log_err();
|
|
return;
|
|
};
|
|
let mut text = String::new();
|
|
if needs_newline_prefix {
|
|
text.push('\n');
|
|
}
|
|
text.push_str(&String::from_utf8_lossy(&output.stdout));
|
|
text.push_str(&String::from_utf8_lossy(&output.stderr));
|
|
if !text.is_empty() && text.chars().last() != Some('\n') {
|
|
text.push('\n');
|
|
}
|
|
|
|
vim.update_in(cx, |vim, window, cx| {
|
|
vim.update_editor(window, cx, |_, editor, window, cx| {
|
|
editor.transact(window, cx, |editor, window, cx| {
|
|
editor.edit([(range.clone(), text)], cx);
|
|
let snapshot = editor.buffer().read(cx).snapshot(cx);
|
|
editor.change_selections(Default::default(), window, cx, |s| {
|
|
let point = if is_read {
|
|
let point = range.end.to_point(&snapshot);
|
|
Point::new(point.row.saturating_sub(1), 0)
|
|
} else {
|
|
let point = range.start.to_point(&snapshot);
|
|
Point::new(point.row, 0)
|
|
};
|
|
s.select_ranges([point..point]);
|
|
})
|
|
})
|
|
});
|
|
vim.cancel_running_command(window, cx);
|
|
})
|
|
.log_err();
|
|
});
|
|
vim.running_command.replace(task);
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod test {
|
|
use std::path::Path;
|
|
|
|
use crate::{
|
|
VimAddon,
|
|
state::Mode,
|
|
test::{NeovimBackedTestContext, VimTestContext},
|
|
};
|
|
use editor::Editor;
|
|
use gpui::{Context, TestAppContext};
|
|
use indoc::indoc;
|
|
use util::path;
|
|
use workspace::Workspace;
|
|
|
|
#[gpui::test]
|
|
async fn test_command_basics(cx: &mut TestAppContext) {
|
|
let mut cx = NeovimBackedTestContext::new(cx).await;
|
|
|
|
cx.set_shared_state(indoc! {"
|
|
ˇa
|
|
b
|
|
c"})
|
|
.await;
|
|
|
|
cx.simulate_shared_keystrokes(": j enter").await;
|
|
|
|
// hack: our cursor positioning after a join command is wrong
|
|
cx.simulate_shared_keystrokes("^").await;
|
|
cx.shared_state().await.assert_eq(indoc! {
|
|
"ˇa b
|
|
c"
|
|
});
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_command_goto(cx: &mut TestAppContext) {
|
|
let mut cx = NeovimBackedTestContext::new(cx).await;
|
|
|
|
cx.set_shared_state(indoc! {"
|
|
ˇa
|
|
b
|
|
c"})
|
|
.await;
|
|
cx.simulate_shared_keystrokes(": 3 enter").await;
|
|
cx.shared_state().await.assert_eq(indoc! {"
|
|
a
|
|
b
|
|
ˇc"});
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_command_replace(cx: &mut TestAppContext) {
|
|
let mut cx = NeovimBackedTestContext::new(cx).await;
|
|
|
|
cx.set_shared_state(indoc! {"
|
|
ˇa
|
|
b
|
|
b
|
|
c"})
|
|
.await;
|
|
cx.simulate_shared_keystrokes(": % s / b / d enter").await;
|
|
cx.shared_state().await.assert_eq(indoc! {"
|
|
a
|
|
d
|
|
ˇd
|
|
c"});
|
|
cx.simulate_shared_keystrokes(": % s : . : \\ 0 \\ 0 enter")
|
|
.await;
|
|
cx.shared_state().await.assert_eq(indoc! {"
|
|
aa
|
|
dd
|
|
dd
|
|
ˇcc"});
|
|
cx.simulate_shared_keystrokes("k : s / d d / e e enter")
|
|
.await;
|
|
cx.shared_state().await.assert_eq(indoc! {"
|
|
aa
|
|
dd
|
|
ˇee
|
|
cc"});
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_command_search(cx: &mut TestAppContext) {
|
|
let mut cx = NeovimBackedTestContext::new(cx).await;
|
|
|
|
cx.set_shared_state(indoc! {"
|
|
ˇa
|
|
b
|
|
a
|
|
c"})
|
|
.await;
|
|
cx.simulate_shared_keystrokes(": / b enter").await;
|
|
cx.shared_state().await.assert_eq(indoc! {"
|
|
a
|
|
ˇb
|
|
a
|
|
c"});
|
|
cx.simulate_shared_keystrokes(": ? a enter").await;
|
|
cx.shared_state().await.assert_eq(indoc! {"
|
|
ˇa
|
|
b
|
|
a
|
|
c"});
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_command_write(cx: &mut TestAppContext) {
|
|
let mut cx = VimTestContext::new(cx, true).await;
|
|
let path = Path::new(path!("/root/dir/file.rs"));
|
|
let fs = cx.workspace(|workspace, _, cx| workspace.project().read(cx).fs().clone());
|
|
|
|
cx.simulate_keystrokes("i @ escape");
|
|
cx.simulate_keystrokes(": w enter");
|
|
|
|
assert_eq!(fs.load(path).await.unwrap().replace("\r\n", "\n"), "@\n");
|
|
|
|
fs.as_fake().insert_file(path, b"oops\n".to_vec()).await;
|
|
|
|
// conflict!
|
|
cx.simulate_keystrokes("i @ escape");
|
|
cx.simulate_keystrokes(": w enter");
|
|
cx.simulate_prompt_answer("Cancel");
|
|
|
|
assert_eq!(fs.load(path).await.unwrap().replace("\r\n", "\n"), "oops\n");
|
|
assert!(!cx.has_pending_prompt());
|
|
cx.simulate_keystrokes(": w ! enter");
|
|
assert!(!cx.has_pending_prompt());
|
|
assert_eq!(fs.load(path).await.unwrap().replace("\r\n", "\n"), "@@\n");
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_command_quit(cx: &mut TestAppContext) {
|
|
let mut cx = VimTestContext::new(cx, true).await;
|
|
|
|
cx.simulate_keystrokes(": n e w enter");
|
|
cx.workspace(|workspace, _, cx| assert_eq!(workspace.items(cx).count(), 2));
|
|
cx.simulate_keystrokes(": q enter");
|
|
cx.workspace(|workspace, _, cx| assert_eq!(workspace.items(cx).count(), 1));
|
|
cx.simulate_keystrokes(": n e w enter");
|
|
cx.workspace(|workspace, _, cx| assert_eq!(workspace.items(cx).count(), 2));
|
|
cx.simulate_keystrokes(": q a enter");
|
|
cx.workspace(|workspace, _, cx| assert_eq!(workspace.items(cx).count(), 0));
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_offsets(cx: &mut TestAppContext) {
|
|
let mut cx = NeovimBackedTestContext::new(cx).await;
|
|
|
|
cx.set_shared_state("ˇ1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n11\n")
|
|
.await;
|
|
|
|
cx.simulate_shared_keystrokes(": + enter").await;
|
|
cx.shared_state()
|
|
.await
|
|
.assert_eq("1\nˇ2\n3\n4\n5\n6\n7\n8\n9\n10\n11\n");
|
|
|
|
cx.simulate_shared_keystrokes(": 1 0 - enter").await;
|
|
cx.shared_state()
|
|
.await
|
|
.assert_eq("1\n2\n3\n4\n5\n6\n7\n8\nˇ9\n10\n11\n");
|
|
|
|
cx.simulate_shared_keystrokes(": . - 2 enter").await;
|
|
cx.shared_state()
|
|
.await
|
|
.assert_eq("1\n2\n3\n4\n5\n6\nˇ7\n8\n9\n10\n11\n");
|
|
|
|
cx.simulate_shared_keystrokes(": % enter").await;
|
|
cx.shared_state()
|
|
.await
|
|
.assert_eq("1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n11\nˇ");
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_command_ranges(cx: &mut TestAppContext) {
|
|
let mut cx = NeovimBackedTestContext::new(cx).await;
|
|
|
|
cx.set_shared_state("ˇ1\n2\n3\n4\n4\n3\n2\n1").await;
|
|
|
|
cx.simulate_shared_keystrokes(": 2 , 4 d enter").await;
|
|
cx.shared_state().await.assert_eq("1\nˇ4\n3\n2\n1");
|
|
|
|
cx.simulate_shared_keystrokes(": 2 , 4 s o r t enter").await;
|
|
cx.shared_state().await.assert_eq("1\nˇ2\n3\n4\n1");
|
|
|
|
cx.simulate_shared_keystrokes(": 2 , 4 j o i n enter").await;
|
|
cx.shared_state().await.assert_eq("1\nˇ2 3 4\n1");
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_command_visual_replace(cx: &mut TestAppContext) {
|
|
let mut cx = NeovimBackedTestContext::new(cx).await;
|
|
|
|
cx.set_shared_state("ˇ1\n2\n3\n4\n4\n3\n2\n1").await;
|
|
|
|
cx.simulate_shared_keystrokes("v 2 j : s / . / k enter")
|
|
.await;
|
|
cx.shared_state().await.assert_eq("k\nk\nˇk\n4\n4\n3\n2\n1");
|
|
}
|
|
|
|
#[track_caller]
|
|
fn assert_active_item(
|
|
workspace: &mut Workspace,
|
|
expected_path: &str,
|
|
expected_text: &str,
|
|
cx: &mut Context<Workspace>,
|
|
) {
|
|
let active_editor = workspace.active_item_as::<Editor>(cx).unwrap();
|
|
|
|
let buffer = active_editor
|
|
.read(cx)
|
|
.buffer()
|
|
.read(cx)
|
|
.as_singleton()
|
|
.unwrap();
|
|
|
|
let text = buffer.read(cx).text();
|
|
let file = buffer.read(cx).file().unwrap();
|
|
let file_path = file.as_local().unwrap().abs_path(cx);
|
|
|
|
assert_eq!(text, expected_text);
|
|
assert_eq!(file_path, Path::new(expected_path));
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_command_gf(cx: &mut TestAppContext) {
|
|
let mut cx = VimTestContext::new(cx, true).await;
|
|
|
|
// Assert base state, that we're in /root/dir/file.rs
|
|
cx.workspace(|workspace, _, cx| {
|
|
assert_active_item(workspace, path!("/root/dir/file.rs"), "", cx);
|
|
});
|
|
|
|
// Insert a new file
|
|
let fs = cx.workspace(|workspace, _, cx| workspace.project().read(cx).fs().clone());
|
|
fs.as_fake()
|
|
.insert_file(
|
|
path!("/root/dir/file2.rs"),
|
|
"This is file2.rs".as_bytes().to_vec(),
|
|
)
|
|
.await;
|
|
fs.as_fake()
|
|
.insert_file(
|
|
path!("/root/dir/file3.rs"),
|
|
"go to file3".as_bytes().to_vec(),
|
|
)
|
|
.await;
|
|
|
|
// Put the path to the second file into the currently open buffer
|
|
cx.set_state(indoc! {"go to fiˇle2.rs"}, Mode::Normal);
|
|
|
|
// Go to file2.rs
|
|
cx.simulate_keystrokes("g f");
|
|
|
|
// We now have two items
|
|
cx.workspace(|workspace, _, cx| assert_eq!(workspace.items(cx).count(), 2));
|
|
cx.workspace(|workspace, _, cx| {
|
|
assert_active_item(
|
|
workspace,
|
|
path!("/root/dir/file2.rs"),
|
|
"This is file2.rs",
|
|
cx,
|
|
);
|
|
});
|
|
|
|
// Update editor to point to `file2.rs`
|
|
cx.editor =
|
|
cx.workspace(|workspace, _, cx| workspace.active_item_as::<Editor>(cx).unwrap());
|
|
|
|
// Put the path to the third file into the currently open buffer,
|
|
// but remove its suffix, because we want that lookup to happen automatically.
|
|
cx.set_state(indoc! {"go to fiˇle3"}, Mode::Normal);
|
|
|
|
// Go to file3.rs
|
|
cx.simulate_keystrokes("g f");
|
|
|
|
// We now have three items
|
|
cx.workspace(|workspace, _, cx| assert_eq!(workspace.items(cx).count(), 3));
|
|
cx.workspace(|workspace, _, cx| {
|
|
assert_active_item(workspace, path!("/root/dir/file3.rs"), "go to file3", cx);
|
|
});
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_w_command(cx: &mut TestAppContext) {
|
|
let mut cx = VimTestContext::new(cx, true).await;
|
|
|
|
cx.workspace(|workspace, _, cx| {
|
|
assert_active_item(workspace, path!("/root/dir/file.rs"), "", cx);
|
|
});
|
|
|
|
cx.simulate_keystrokes(": w space other.rs");
|
|
cx.simulate_keystrokes("enter");
|
|
|
|
cx.workspace(|workspace, _, cx| {
|
|
assert_active_item(workspace, path!("/root/other.rs"), "", cx);
|
|
});
|
|
|
|
cx.simulate_keystrokes(": w space dir/file.rs");
|
|
cx.simulate_keystrokes("enter");
|
|
|
|
cx.simulate_prompt_answer("Replace");
|
|
cx.run_until_parked();
|
|
|
|
cx.workspace(|workspace, _, cx| {
|
|
assert_active_item(workspace, path!("/root/dir/file.rs"), "", cx);
|
|
});
|
|
|
|
cx.simulate_keystrokes(": w ! space other.rs");
|
|
cx.simulate_keystrokes("enter");
|
|
|
|
cx.workspace(|workspace, _, cx| {
|
|
assert_active_item(workspace, path!("/root/other.rs"), "", cx);
|
|
});
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_command_matching_lines(cx: &mut TestAppContext) {
|
|
let mut cx = NeovimBackedTestContext::new(cx).await;
|
|
|
|
cx.set_shared_state(indoc! {"
|
|
ˇa
|
|
b
|
|
a
|
|
b
|
|
a
|
|
"})
|
|
.await;
|
|
|
|
cx.simulate_shared_keystrokes(":").await;
|
|
cx.simulate_shared_keystrokes("g / a / d").await;
|
|
cx.simulate_shared_keystrokes("enter").await;
|
|
|
|
cx.shared_state().await.assert_eq(indoc! {"
|
|
b
|
|
b
|
|
ˇ"});
|
|
|
|
cx.simulate_shared_keystrokes("u").await;
|
|
|
|
cx.shared_state().await.assert_eq(indoc! {"
|
|
ˇa
|
|
b
|
|
a
|
|
b
|
|
a
|
|
"});
|
|
|
|
cx.simulate_shared_keystrokes(":").await;
|
|
cx.simulate_shared_keystrokes("v / a / d").await;
|
|
cx.simulate_shared_keystrokes("enter").await;
|
|
|
|
cx.shared_state().await.assert_eq(indoc! {"
|
|
a
|
|
a
|
|
ˇa"});
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_del_marks(cx: &mut TestAppContext) {
|
|
let mut cx = NeovimBackedTestContext::new(cx).await;
|
|
|
|
cx.set_shared_state(indoc! {"
|
|
ˇa
|
|
b
|
|
a
|
|
b
|
|
a
|
|
"})
|
|
.await;
|
|
|
|
cx.simulate_shared_keystrokes("m a").await;
|
|
|
|
let mark = cx.update_editor(|editor, window, cx| {
|
|
let vim = editor.addon::<VimAddon>().unwrap().entity.clone();
|
|
vim.update(cx, |vim, cx| vim.get_mark("a", editor, window, cx))
|
|
});
|
|
assert!(mark.is_some());
|
|
|
|
cx.simulate_shared_keystrokes(": d e l m space a").await;
|
|
cx.simulate_shared_keystrokes("enter").await;
|
|
|
|
let mark = cx.update_editor(|editor, window, cx| {
|
|
let vim = editor.addon::<VimAddon>().unwrap().entity.clone();
|
|
vim.update(cx, |vim, cx| vim.get_mark("a", editor, window, cx))
|
|
});
|
|
assert!(mark.is_none())
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_normal_command(cx: &mut TestAppContext) {
|
|
let mut cx = NeovimBackedTestContext::new(cx).await;
|
|
|
|
cx.set_shared_state(indoc! {"
|
|
The quick
|
|
brown« fox
|
|
jumpsˇ» over
|
|
the lazy dog
|
|
"})
|
|
.await;
|
|
|
|
cx.simulate_shared_keystrokes(": n o r m space w C w o r d")
|
|
.await;
|
|
cx.simulate_shared_keystrokes("enter").await;
|
|
|
|
cx.shared_state().await.assert_eq(indoc! {"
|
|
The quick
|
|
brown word
|
|
jumps worˇd
|
|
the lazy dog
|
|
"});
|
|
|
|
cx.simulate_shared_keystrokes(": n o r m space _ w c i w t e s t")
|
|
.await;
|
|
cx.simulate_shared_keystrokes("enter").await;
|
|
|
|
cx.shared_state().await.assert_eq(indoc! {"
|
|
The quick
|
|
brown word
|
|
jumps tesˇt
|
|
the lazy dog
|
|
"});
|
|
|
|
cx.simulate_shared_keystrokes("_ l v l : n o r m space s l a")
|
|
.await;
|
|
cx.simulate_shared_keystrokes("enter").await;
|
|
|
|
cx.shared_state().await.assert_eq(indoc! {"
|
|
The quick
|
|
brown word
|
|
lˇaumps test
|
|
the lazy dog
|
|
"});
|
|
|
|
cx.set_shared_state(indoc! {"
|
|
ˇThe quick
|
|
brown fox
|
|
jumps over
|
|
the lazy dog
|
|
"})
|
|
.await;
|
|
|
|
cx.simulate_shared_keystrokes("c i w M y escape").await;
|
|
|
|
cx.shared_state().await.assert_eq(indoc! {"
|
|
Mˇy quick
|
|
brown fox
|
|
jumps over
|
|
the lazy dog
|
|
"});
|
|
|
|
cx.simulate_shared_keystrokes(": n o r m space u").await;
|
|
cx.simulate_shared_keystrokes("enter").await;
|
|
|
|
cx.shared_state().await.assert_eq(indoc! {"
|
|
ˇThe quick
|
|
brown fox
|
|
jumps over
|
|
the lazy dog
|
|
"});
|
|
// Once ctrl-v to input character literals is added there should be a test for redo
|
|
}
|
|
}
|