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-16 21:19:01 -07:00 committed by GitHub
parent 908ae95cf8
commit 0cbab311a1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 426 additions and 184 deletions

2
Cargo.lock generated
View file

@ -3755,6 +3755,7 @@ dependencies = [
"collections",
"derive_more",
"gpui",
"workspace",
"workspace-hack",
]
@ -18743,6 +18744,7 @@ dependencies = [
"editor",
"env_logger 0.11.8",
"futures 0.3.31",
"fuzzy",
"git_ui",
"gpui",
"indoc",

View file

@ -9,7 +9,8 @@ use std::{
use client::parse_zed_link;
use command_palette_hooks::{
CommandInterceptResult, CommandPaletteFilter, CommandPaletteInterceptor,
CommandInterceptItem, CommandInterceptResult, CommandPaletteFilter,
GlobalCommandPaletteInterceptor,
};
use fuzzy::{StringMatch, StringMatchCandidate};
@ -81,14 +82,17 @@ impl CommandPalette {
let Some(previous_focus_handle) = window.focused(cx) else {
return;
};
let entity = cx.weak_entity();
workspace.toggle_modal(window, cx, move |window, cx| {
CommandPalette::new(previous_focus_handle, query, window, cx)
CommandPalette::new(previous_focus_handle, query, entity, window, cx)
});
}
fn new(
previous_focus_handle: FocusHandle,
query: &str,
entity: WeakEntity<Workspace>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
@ -109,8 +113,12 @@ impl CommandPalette {
})
.collect();
let delegate =
CommandPaletteDelegate::new(cx.entity().downgrade(), commands, previous_focus_handle);
let delegate = CommandPaletteDelegate::new(
cx.entity().downgrade(),
entity,
commands,
previous_focus_handle,
);
let picker = cx.new(|cx| {
let picker = Picker::uniform_list(delegate, window, cx);
@ -146,6 +154,7 @@ impl Render for CommandPalette {
pub struct CommandPaletteDelegate {
latest_query: String,
command_palette: WeakEntity<CommandPalette>,
workspace: WeakEntity<Workspace>,
all_commands: Vec<Command>,
commands: Vec<Command>,
matches: Vec<StringMatch>,
@ -153,7 +162,7 @@ pub struct CommandPaletteDelegate {
previous_focus_handle: FocusHandle,
updating_matches: Option<(
Task<()>,
postage::dispatch::Receiver<(Vec<Command>, Vec<StringMatch>)>,
postage::dispatch::Receiver<(Vec<Command>, Vec<StringMatch>, CommandInterceptResult)>,
)>,
}
@ -174,11 +183,13 @@ impl Clone for Command {
impl CommandPaletteDelegate {
fn new(
command_palette: WeakEntity<CommandPalette>,
workspace: WeakEntity<Workspace>,
commands: Vec<Command>,
previous_focus_handle: FocusHandle,
) -> Self {
Self {
command_palette,
workspace,
all_commands: commands.clone(),
matches: vec![],
commands,
@ -194,30 +205,19 @@ impl CommandPaletteDelegate {
query: String,
mut commands: Vec<Command>,
mut matches: Vec<StringMatch>,
cx: &mut Context<Picker<Self>>,
intercept_result: CommandInterceptResult,
_: &mut Context<Picker<Self>>,
) {
self.updating_matches.take();
self.latest_query = query.clone();
let mut intercept_results = CommandPaletteInterceptor::try_global(cx)
.map(|interceptor| interceptor.intercept(&query, cx))
.unwrap_or_default();
if parse_zed_link(&query, cx).is_some() {
intercept_results = vec![CommandInterceptResult {
action: OpenZedUrl { url: query.clone() }.boxed_clone(),
string: query,
positions: vec![],
}]
}
self.latest_query = query;
let mut new_matches = Vec::new();
for CommandInterceptResult {
for CommandInterceptItem {
action,
string,
positions,
} in intercept_results
} in intercept_result.results
{
if let Some(idx) = matches
.iter()
@ -236,7 +236,9 @@ impl CommandPaletteDelegate {
score: 0.0,
})
}
new_matches.append(&mut matches);
if !intercept_result.exclusive {
new_matches.append(&mut matches);
}
self.commands = commands;
self.matches = new_matches;
if self.matches.is_empty() {
@ -295,12 +297,22 @@ impl PickerDelegate for CommandPaletteDelegate {
if let Some(alias) = settings.command_aliases.get(&query) {
query = alias.to_string();
}
let workspace = self.workspace.clone();
let intercept_task = GlobalCommandPaletteInterceptor::intercept(&query, workspace, cx);
let (mut tx, mut rx) = postage::dispatch::channel(1);
let query_str = query.as_str();
let is_zed_link = parse_zed_link(query_str, cx).is_some();
let task = cx.background_spawn({
let mut commands = self.all_commands.clone();
let hit_counts = self.hit_counts();
let executor = cx.background_executor().clone();
let query = normalize_action_query(query.as_str());
let query = normalize_action_query(query_str);
let query_for_link = query_str.to_string();
async move {
commands.sort_by_key(|action| {
(
@ -326,13 +338,34 @@ impl PickerDelegate for CommandPaletteDelegate {
)
.await;
tx.send((commands, matches)).await.log_err();
let intercept_result = if is_zed_link {
CommandInterceptResult {
results: vec![CommandInterceptItem {
action: OpenZedUrl {
url: query_for_link.clone(),
}
.boxed_clone(),
string: query_for_link,
positions: vec![],
}],
exclusive: false,
}
} else if let Some(task) = intercept_task {
task.await
} else {
CommandInterceptResult::default()
};
tx.send((commands, matches, intercept_result))
.await
.log_err();
}
});
self.updating_matches = Some((task, rx.clone()));
cx.spawn_in(window, async move |picker, cx| {
let Some((commands, matches)) = rx.recv().await else {
let Some((commands, matches, intercept_result)) = rx.recv().await else {
return;
};
@ -340,7 +373,7 @@ impl PickerDelegate for CommandPaletteDelegate {
.update(cx, |picker, cx| {
picker
.delegate
.matches_updated(query, commands, matches, cx)
.matches_updated(query, commands, matches, intercept_result, cx)
})
.log_err();
})
@ -361,8 +394,8 @@ impl PickerDelegate for CommandPaletteDelegate {
.background_executor()
.block_with_timeout(duration, rx.clone().recv())
{
Ok(Some((commands, matches))) => {
self.matches_updated(query, commands, matches, cx);
Ok(Some((commands, matches, interceptor_result))) => {
self.matches_updated(query, commands, matches, interceptor_result, cx);
true
}
_ => {

View file

@ -17,3 +17,4 @@ collections.workspace = true
derive_more.workspace = true
gpui.workspace = true
workspace-hack.workspace = true
workspace.workspace = true

View file

@ -2,16 +2,16 @@
#![deny(missing_docs)]
use std::any::TypeId;
use std::{any::TypeId, rc::Rc};
use collections::HashSet;
use derive_more::{Deref, DerefMut};
use gpui::{Action, App, BorrowAppContext, Global};
use gpui::{Action, App, BorrowAppContext, Global, Task, WeakEntity};
use workspace::Workspace;
/// Initializes the command palette hooks.
pub fn init(cx: &mut App) {
cx.set_global(GlobalCommandPaletteFilter::default());
cx.set_global(GlobalCommandPaletteInterceptor::default());
}
/// A filter for the command palette.
@ -94,7 +94,7 @@ impl CommandPaletteFilter {
/// The result of intercepting a command palette command.
#[derive(Debug)]
pub struct CommandInterceptResult {
pub struct CommandInterceptItem {
/// The action produced as a result of the interception.
pub action: Box<dyn Action>,
/// The display string to show in the command palette for this result.
@ -104,50 +104,50 @@ pub struct CommandInterceptResult {
pub positions: Vec<usize>,
}
/// An interceptor for the command palette.
#[derive(Default)]
pub struct CommandPaletteInterceptor(
Option<Box<dyn Fn(&str, &App) -> Vec<CommandInterceptResult>>>,
);
/// The result of intercepting a command palette command.
#[derive(Default, Debug)]
pub struct CommandInterceptResult {
/// The items
pub results: Vec<CommandInterceptItem>,
/// Whether or not to continue to show the normal matches
pub exclusive: bool,
}
#[derive(Default)]
struct GlobalCommandPaletteInterceptor(CommandPaletteInterceptor);
/// An interceptor for the command palette.
#[derive(Clone)]
pub struct GlobalCommandPaletteInterceptor(
Rc<dyn Fn(&str, WeakEntity<Workspace>, &mut App) -> Task<CommandInterceptResult>>,
);
impl Global for GlobalCommandPaletteInterceptor {}
impl CommandPaletteInterceptor {
/// Returns the global [`CommandPaletteInterceptor`], if one is set.
pub fn try_global(cx: &App) -> Option<&CommandPaletteInterceptor> {
cx.try_global::<GlobalCommandPaletteInterceptor>()
.map(|interceptor| &interceptor.0)
}
/// Updates the global [`CommandPaletteInterceptor`] using the given closure.
pub fn update_global<F, R>(cx: &mut App, update: F) -> R
where
F: FnOnce(&mut Self, &mut App) -> R,
{
cx.update_global(|this: &mut GlobalCommandPaletteInterceptor, cx| update(&mut this.0, cx))
}
/// Intercepts the given query from the command palette.
pub fn intercept(&self, query: &str, cx: &App) -> Vec<CommandInterceptResult> {
if let Some(handler) = self.0.as_ref() {
(handler)(query, cx)
} else {
Vec::new()
}
}
/// Clears the global interceptor.
pub fn clear(&mut self) {
self.0 = None;
}
impl GlobalCommandPaletteInterceptor {
/// Sets the global interceptor.
///
/// This will override the previous interceptor, if it exists.
pub fn set(&mut self, handler: Box<dyn Fn(&str, &App) -> Vec<CommandInterceptResult>>) {
self.0 = Some(handler);
pub fn set(
cx: &mut App,
interceptor: impl Fn(&str, WeakEntity<Workspace>, &mut App) -> Task<CommandInterceptResult>
+ 'static,
) {
cx.set_global(Self(Rc::new(interceptor)));
}
/// Clears the global interceptor.
pub fn clear(cx: &mut App) {
if cx.has_global::<Self>() {
cx.remove_global::<Self>();
}
}
/// Intercepts the given query from the command palette.
pub fn intercept(
query: &str,
workspace: WeakEntity<Workspace>,
cx: &mut App,
) -> Option<Task<CommandInterceptResult>> {
let interceptor = cx.try_global::<Self>()?;
let handler = interceptor.0.clone();
Some(handler(query, workspace, cx))
}
}

View file

@ -26,6 +26,7 @@ db.workspace = true
editor.workspace = true
env_logger.workspace = true
futures.workspace = true
fuzzy.workspace = true
gpui.workspace = true
itertools.workspace = true
language.workspace = true

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| {

View file

@ -6,7 +6,7 @@ use crate::{ToggleMarksView, ToggleRegistersView, UseSystemClipboard, Vim, VimAd
use crate::{motion::Motion, object::Object};
use anyhow::Result;
use collections::HashMap;
use command_palette_hooks::{CommandPaletteFilter, CommandPaletteInterceptor};
use command_palette_hooks::{CommandPaletteFilter, GlobalCommandPaletteInterceptor};
use db::{
sqlez::{domain::Domain, thread_safe_connection::ThreadSafeConnection},
sqlez_macros::sql,
@ -718,9 +718,7 @@ impl VimGlobals {
CommandPaletteFilter::update_global(cx, |filter, _| {
filter.show_namespace(Vim::NAMESPACE);
});
CommandPaletteInterceptor::update_global(cx, |interceptor, _| {
interceptor.set(Box::new(command_interceptor));
});
GlobalCommandPaletteInterceptor::set(cx, command_interceptor);
for window in cx.windows() {
if let Some(workspace) = window.downcast::<Workspace>() {
workspace
@ -735,9 +733,7 @@ impl VimGlobals {
} else {
KeyBinding::set_vim_mode(cx, false);
*Vim::globals(cx) = VimGlobals::default();
CommandPaletteInterceptor::update_global(cx, |interceptor, _| {
interceptor.clear();
});
GlobalCommandPaletteInterceptor::clear(cx);
CommandPaletteFilter::update_global(cx, |filter, _| {
filter.hide_namespace(Vim::NAMESPACE);
});