vim: Add vim command filename autocomplete (#36332)

Release Notes:

- Adds filename autocomplete for vim commands:
  - write
  - edit
  - split
  - vsplit
  - tabedit
  - tabnew
- Makes command palette interceptor async
<img width="1382" height="634" alt="image"
src="https://github.com/user-attachments/assets/e7bf01c5-e9cd-4a7d-b38c-12fc3df5069f"
/>

---------

Co-authored-by: Conrad Irwin <conrad.irwin@gmail.com>
This commit is contained in:
AidanV
2025-10-17 04:19:01 +00:00
committed by GitHub
co-authored by Conrad Irwin
parent 908ae95cf8
commit 0cbab311a1
7 changed files with 426 additions and 184 deletions
+316 -107
View File
@@ -1,13 +1,15 @@
use anyhow::{Result, anyhow};
use collections::{HashMap, HashSet};
use command_palette_hooks::CommandInterceptResult;
use command_palette_hooks::{CommandInterceptItem, CommandInterceptResult};
use editor::{
Bias, Editor, EditorSettings, SelectionEffects, ToPoint,
actions::{SortLinesCaseInsensitive, SortLinesCaseSensitive},
display_map::ToDisplayPoint,
};
use futures::AsyncWriteExt as _;
use gpui::{Action, App, AppContext as _, Context, Global, Keystroke, Task, Window, actions};
use gpui::{
Action, App, AppContext as _, Context, Global, Keystroke, Task, WeakEntity, Window, actions,
};
use itertools::Itertools;
use language::Point;
use multi_buffer::MultiBufferRow;
@@ -20,7 +22,7 @@ use settings::{Settings, SettingsStore};
use std::{
iter::Peekable,
ops::{Deref, Range},
path::Path,
path::{Path, PathBuf},
process::Stdio,
str::Chars,
sync::OnceLock,
@@ -28,8 +30,12 @@ use std::{
};
use task::{HideStrategy, RevealStrategy, SpawnInTerminal, TaskId};
use ui::ActiveTheme;
use util::{ResultExt, rel_path::RelPath};
use workspace::{Item, SaveIntent, notifications::NotifyResultExt};
use util::{
ResultExt,
paths::PathStyle,
rel_path::{RelPath, RelPathBuf},
};
use workspace::{Item, SaveIntent, Workspace, notifications::NotifyResultExt};
use workspace::{SplitDirection, notifications::DetachAndPromptErr};
use zed_actions::{OpenDocs, RevealTarget};
@@ -85,7 +91,7 @@ pub enum VimOption {
}
impl VimOption {
fn possible_commands(query: &str) -> Vec<CommandInterceptResult> {
fn possible_commands(query: &str) -> Vec<CommandInterceptItem> {
let mut prefix_of_options = Vec::new();
let mut options = query.split(" ").collect::<Vec<_>>();
let prefix = options.pop().unwrap_or_default();
@@ -102,7 +108,7 @@ impl VimOption {
let mut options = prefix_of_options.clone();
options.push(possible);
CommandInterceptResult {
CommandInterceptItem {
string: format!(
":set {}",
options.iter().map(|opt| opt.to_string()).join(" ")
@@ -725,6 +731,13 @@ struct VimCommand {
>,
>,
has_count: bool,
has_filename: bool,
}
struct ParsedQuery {
args: String,
has_bang: bool,
has_space: bool,
}
impl VimCommand {
@@ -760,6 +773,15 @@ impl VimCommand {
self
}
fn filename(
mut self,
f: impl Fn(Box<dyn Action>, String) -> Option<Box<dyn Action>> + Send + Sync + 'static,
) -> Self {
self.args = Some(Box::new(f));
self.has_filename = true;
self
}
fn range(
mut self,
f: impl Fn(Box<dyn Action>, &CommandRange) -> Option<Box<dyn Action>> + Send + Sync + 'static,
@@ -773,14 +795,80 @@ impl VimCommand {
self
}
fn parse(
&self,
query: &str,
range: &Option<CommandRange>,
cx: &App,
) -> Option<Box<dyn Action>> {
fn generate_filename_completions(
parsed_query: &ParsedQuery,
workspace: WeakEntity<Workspace>,
cx: &mut App,
) -> Task<Vec<String>> {
let ParsedQuery {
args,
has_bang: _,
has_space: _,
} = parsed_query;
let Some(workspace) = workspace.upgrade() else {
return Task::ready(Vec::new());
};
let (task, args_path) = workspace.update(cx, |workspace, cx| {
let prefix = workspace
.project()
.read(cx)
.visible_worktrees(cx)
.map(|worktree| worktree.read(cx).abs_path().to_path_buf())
.next()
.or_else(std::env::home_dir)
.unwrap_or_else(|| PathBuf::from(""));
let rel_path = match RelPath::new(Path::new(&args), PathStyle::local()) {
Ok(path) => path.to_rel_path_buf(),
Err(_) => {
return (Task::ready(Ok(Vec::new())), RelPathBuf::new());
}
};
let rel_path = if args.ends_with(PathStyle::local().separator()) {
rel_path
} else {
rel_path
.parent()
.map(|rel_path| rel_path.to_rel_path_buf())
.unwrap_or(RelPathBuf::new())
};
let task = workspace.project().update(cx, |project, cx| {
let path = prefix
.join(rel_path.as_std_path())
.to_string_lossy()
.to_string();
project.list_directory(path, cx)
});
(task, rel_path)
});
cx.background_spawn(async move {
let directories = task.await.unwrap_or_default();
directories
.iter()
.map(|dir| {
let path = RelPath::new(dir.path.as_path(), PathStyle::local())
.map(|cow| cow.into_owned())
.unwrap_or(RelPathBuf::new());
let mut path_string = args_path
.join(&path)
.display(PathStyle::local())
.to_string();
if dir.is_dir {
path_string.push_str(PathStyle::local().separator());
}
path_string
})
.collect()
})
}
fn get_parsed_query(&self, query: String) -> Option<ParsedQuery> {
let rest = query
.to_string()
.strip_prefix(self.prefix)?
.to_string()
.chars()
@@ -789,6 +877,7 @@ impl VimCommand {
.filter_map(|e| e.left())
.collect::<String>();
let has_bang = rest.starts_with('!');
let has_space = rest.starts_with("! ") || rest.starts_with(' ');
let args = if has_bang {
rest.strip_prefix('!')?.trim().to_string()
} else if rest.is_empty() {
@@ -796,7 +885,24 @@ impl VimCommand {
} else {
rest.strip_prefix(' ')?.trim().to_string()
};
Some(ParsedQuery {
args,
has_bang,
has_space,
})
}
fn parse(
&self,
query: &str,
range: &Option<CommandRange>,
cx: &App,
) -> Option<Box<dyn Action>> {
let ParsedQuery {
args,
has_bang,
has_space: _,
} = self.get_parsed_query(query.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() {
@@ -1056,18 +1162,43 @@ fn generate_commands(_: &App) -> Vec<VimCommand> {
.bang(workspace::Save {
save_intent: Some(SaveIntent::Overwrite),
})
.args(|action, args| {
.filename(|action, filename| {
Some(
VimSave {
save_intent: action
.as_any()
.downcast_ref::<workspace::Save>()
.and_then(|action| action.save_intent),
filename: args,
filename,
}
.boxed_clone(),
)
}),
VimCommand::new(("e", "dit"), editor::actions::ReloadFile)
.bang(editor::actions::ReloadFile)
.filename(|_, filename| Some(VimEdit { filename }.boxed_clone())),
VimCommand::new(("sp", "lit"), workspace::SplitHorizontal).filename(|_, filename| {
Some(
VimSplit {
vertical: false,
filename,
}
.boxed_clone(),
)
}),
VimCommand::new(("vs", "plit"), workspace::SplitVertical).filename(|_, filename| {
Some(
VimSplit {
vertical: true,
filename,
}
.boxed_clone(),
)
}),
VimCommand::new(("tabe", "dit"), workspace::NewFile)
.filename(|_action, filename| Some(VimEdit { filename }.boxed_clone())),
VimCommand::new(("tabnew", ""), workspace::NewFile)
.filename(|_action, filename| Some(VimEdit { filename }.boxed_clone())),
VimCommand::new(
("q", "uit"),
workspace::CloseActiveItem {
@@ -1164,24 +1295,6 @@ fn generate_commands(_: &App) -> Vec<VimCommand> {
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 {
@@ -1224,10 +1337,6 @@ fn generate_commands(_: &App) -> Vec<VimCommand> {
VimCommand::str(("ls", ""), "tab_switcher::ToggleAll"),
VimCommand::new(("new", ""), workspace::NewFileSplitHorizontal),
VimCommand::new(("vne", "w"), workspace::NewFileSplitVertical),
VimCommand::new(("tabe", "dit"), workspace::NewFile)
.args(|_action, args| Some(VimEdit { filename: args }.boxed_clone())),
VimCommand::new(("tabnew", ""), workspace::NewFile)
.args(|_action, args| Some(VimEdit { filename: args }.boxed_clone())),
VimCommand::new(("tabn", "ext"), workspace::ActivateNextItem).count(),
VimCommand::new(("tabp", "revious"), workspace::ActivatePreviousItem).count(),
VimCommand::new(("tabN", "ext"), workspace::ActivatePreviousItem).count(),
@@ -1327,9 +1436,6 @@ fn generate_commands(_: &App) -> Vec<VimCommand> {
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"),
@@ -1383,18 +1489,30 @@ fn wrap_count(action: Box<dyn Action>, range: &CommandRange) -> Option<Box<dyn A
})
}
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).
pub fn command_interceptor(
mut input: &str,
workspace: WeakEntity<Workspace>,
cx: &mut App,
) -> Task<CommandInterceptResult> {
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 has_trailing_space = query.ends_with(" ");
let mut query = query.as_str().trim();
let action = if range.is_some() && query.is_empty() {
let on_matching_lines = (query.starts_with('g') || query.starts_with('v'))
.then(|| {
let (pattern, range, search, invert) = OnMatchingLines::parse(query, &range)?;
let start_idx = query.len() - pattern.len();
query = query[start_idx..].trim();
Some((range, search, invert))
})
.flatten();
let mut action = if range.is_some() && query.is_empty() {
Some(
GoToLine {
range: range.clone().unwrap(),
@@ -1418,7 +1536,10 @@ pub fn command_interceptor(mut input: &str, cx: &App) -> Vec<CommandInterceptRes
command.positions = generate_positions(&command.string, &query);
}
}
return commands;
return Task::ready(CommandInterceptResult {
results: commands,
exclusive: false,
});
} else if query.starts_with('s') {
let mut substitute = "substitute".chars().peekable();
let mut query = query.chars().peekable();
@@ -1438,58 +1559,138 @@ pub fn command_interceptor(mut input: &str, cx: &App) -> Vec<CommandInterceptRes
} 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 }),
});
OnMatchingLines::parse(query, invert, range, cx).map(|action| action.boxed_clone())
} else if query.contains('!') {
ShellExec::parse(query, range.clone())
} else if on_matching_lines.is_some() {
commands(cx)
.iter()
.find_map(|command| command.parse(query, &range, cx))
} else {
None
};
if let Some((range, search, invert)) = on_matching_lines
&& let Some(ref inner) = action
{
action = Some(Box::new(OnMatchingLines {
range,
search,
action: WrappedAction(inner.boxed_clone()),
invert,
}));
};
if let Some(action) = action {
let string = input.to_string();
let positions = generate_positions(&string, &(range_prefix + query));
return vec![CommandInterceptResult {
action,
string,
positions,
}];
return Task::ready(CommandInterceptResult {
results: vec![CommandInterceptItem {
action,
string,
positions,
}],
exclusive: false,
});
}
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));
let Some((mut results, filenames)) =
commands(cx).iter().enumerate().find_map(|(idx, command)| {
let action = command.parse(query, &range, cx)?;
let parsed_query = command.get_parsed_query(query.into())?;
let display_string = ":".to_owned()
+ &range_prefix
+ command.prefix
+ command.suffix
+ if parsed_query.has_bang { "!" } else { "" };
let space = if parsed_query.has_space { " " } else { "" };
return vec![CommandInterceptResult {
let string = format!("{}{}{}", &display_string, &space, &parsed_query.args);
let positions = generate_positions(&string, &(range_prefix.clone() + query));
let results = vec![CommandInterceptItem {
action,
string,
positions,
}];
}
let no_args_positions =
generate_positions(&display_string, &(range_prefix.clone() + query));
// The following are valid autocomplete scenarios:
// :w!filename.txt
// :w filename.txt
// :w[space]
if !command.has_filename
|| (!has_trailing_space && !parsed_query.has_bang && parsed_query.args.is_empty())
{
return Some((results, None));
}
Some((
results,
Some((idx, parsed_query, display_string, no_args_positions)),
))
})
else {
return Task::ready(CommandInterceptResult::default());
};
if let Some((cmd_idx, parsed_query, display_string, no_args_positions)) = filenames {
let filenames = VimCommand::generate_filename_completions(&parsed_query, workspace, cx);
cx.spawn(async move |cx| {
let filenames = filenames.await;
const MAX_RESULTS: usize = 100;
let executor = cx.background_executor().clone();
let mut candidates = Vec::with_capacity(filenames.len());
for (idx, filename) in filenames.iter().enumerate() {
candidates.push(fuzzy::StringMatchCandidate::new(idx, &filename));
}
let filenames = fuzzy::match_strings(
&candidates,
&parsed_query.args,
false,
true,
MAX_RESULTS,
&Default::default(),
executor,
)
.await;
for fuzzy::StringMatch {
candidate_id: _,
score: _,
positions,
string,
} in filenames
{
let offset = display_string.len() + 1;
let mut positions: Vec<_> = positions.iter().map(|&pos| pos + offset).collect();
positions.splice(0..0, no_args_positions.clone());
let string = format!("{display_string} {string}");
let action = match cx
.update(|cx| commands(cx).get(cmd_idx)?.parse(&string[1..], &range, cx))
{
Ok(Some(action)) => action,
_ => continue,
};
results.push(CommandInterceptItem {
action,
string,
positions,
});
}
CommandInterceptResult {
results,
exclusive: true,
}
})
} else {
Task::ready(CommandInterceptResult {
results,
exclusive: false,
})
}
Vec::default()
}
fn generate_positions(string: &str, query: &str) -> Vec<usize> {
@@ -1530,19 +1731,40 @@ impl OnMatchingLines {
// 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| {
query: &str,
range: &Option<CommandRange>,
) -> Option<(String, CommandRange, String, bool)> {
let mut global = "global".chars().peekable();
let mut query_chars = query.chars().peekable();
let mut invert = false;
if query_chars.peek() == Some(&'v') {
invert = true;
query_chars.next();
}
while global
.peek()
.is_some_and(|char| Some(char) == query_chars.peek())
{
global.next();
query_chars.next();
}
if !invert && query_chars.peek() == Some(&'!') {
invert = true;
query_chars.next();
}
let range = range.clone().unwrap_or(CommandRange {
start: Position::Line { row: 0, offset: 0 },
end: Some(Position::LastLine { offset: 0 }),
});
let delimiter = query_chars.next().filter(|c| {
!c.is_alphanumeric() && *c != '"' && *c != '|' && *c != '\'' && *c != '!'
})?;
let mut search = String::new();
let mut escaped = false;
for c in chars.by_ref() {
for c in query_chars.by_ref() {
if escaped {
escaped = false;
// unescape escaped parens
@@ -1563,21 +1785,7 @@ impl OnMatchingLines {
}
}
let command: String = chars.collect();
let action = WrappedAction(
command_interceptor(&command, cx)
.first()?
.action
.boxed_clone(),
);
Some(Self {
range,
search,
invert,
action,
})
Some((query_chars.collect::<String>(), range, search, invert))
}
pub fn run(&self, vim: &mut Vim, window: &mut Window, cx: &mut Context<Vim>) {
@@ -2184,7 +2392,8 @@ mod test {
assert_eq!(fs.load(path).await.unwrap().replace("\r\n", "\n"), "oops\n");
assert!(!cx.has_pending_prompt());
cx.simulate_keystrokes(": w ! enter");
cx.simulate_keystrokes(": w !");
cx.simulate_keystrokes("enter");
assert!(!cx.has_pending_prompt());
assert_eq!(fs.load(path).await.unwrap().replace("\r\n", "\n"), "@@\n");
}
@@ -2342,7 +2551,7 @@ mod test {
}
#[gpui::test]
async fn test_w_command(cx: &mut TestAppContext) {
async fn test_command_write_filename(cx: &mut TestAppContext) {
let mut cx = VimTestContext::new(cx, true).await;
cx.workspace(|workspace, _, cx| {