vim: Add vim command filename autocomplete (#36332)

AidanV and Conrad Irwin created

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>

Change summary

Cargo.lock                                                |   2 
crates/command_palette/src/command_palette.rs             |  89 +
crates/command_palette_hooks/Cargo.toml                   |   1 
crates/command_palette_hooks/src/command_palette_hooks.rs |  78 
crates/vim/Cargo.toml                                     |   1 
crates/vim/src/command.rs                                 | 425 ++++++--
crates/vim/src/state.rs                                   |  10 
7 files changed, 424 insertions(+), 182 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -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",

crates/command_palette/src/command_palette.rs 🔗

@@ -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
             }
             _ => {

crates/command_palette_hooks/src/command_palette_hooks.rs 🔗

@@ -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>,
 }
 
+/// 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,
+}
+
 /// An interceptor for the command palette.
-#[derive(Default)]
-pub struct CommandPaletteInterceptor(
-    Option<Box<dyn Fn(&str, &App) -> Vec<CommandInterceptResult>>>,
+#[derive(Clone)]
+pub struct GlobalCommandPaletteInterceptor(
+    Rc<dyn Fn(&str, WeakEntity<Workspace>, &mut App) -> Task<CommandInterceptResult>>,
 );
 
-#[derive(Default)]
-struct GlobalCommandPaletteInterceptor(CommandPaletteInterceptor);
-
 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()
-        }
+impl GlobalCommandPaletteInterceptor {
+    /// Sets the global interceptor.
+    ///
+    /// This will override the previous interceptor, if it exists.
+    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(&mut self) {
-        self.0 = None;
+    pub fn clear(cx: &mut App) {
+        if cx.has_global::<Self>() {
+            cx.remove_global::<Self>();
+        }
     }
 
-    /// 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);
+    /// 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))
     }
 }

crates/vim/Cargo.toml 🔗

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

crates/vim/src/command.rs 🔗

@@ -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 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 action = if range.is_some() && query.is_empty() {
+    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));
-
-            return vec![CommandInterceptResult {
+    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 { "" };
+
+            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| {

crates/vim/src/state.rs 🔗

@@ -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);
                 });