From 5a38bbbd222591f9f7df7f1da31ef7c2fdb33502 Mon Sep 17 00:00:00 2001 From: AidanV <84053180+AidanV@users.noreply.github.com> Date: Mon, 12 May 2025 06:09:18 -0700 Subject: [PATCH] vim: Add `:w ` command (#29256) Closes https://github.com/zed-industries/zed/issues/10920 Release Notes: - vim: Adds support for `:w[rite] ` --------- Co-authored-by: Conrad Irwin --- crates/vim/src/command.rs | 152 ++++++++++++++++++++++++++++++++++---- 1 file changed, 137 insertions(+), 15 deletions(-) diff --git a/crates/vim/src/command.rs b/crates/vim/src/command.rs index 3c86cfa6a0f099829f0e2cb6030552d226d8b093..e7d29039e80c639456b0915e66498eb237b6a952 100644 --- a/crates/vim/src/command.rs +++ b/crates/vim/src/command.rs @@ -11,6 +11,7 @@ use gpui::{Action, App, AppContext as _, Context, Global, Window, actions, impl_ use itertools::Itertools; use language::Point; use multi_buffer::MultiBufferRow; +use project::ProjectPath; use regex::Regex; use schemars::JsonSchema; use search::{BufferSearchBar, SearchOptions}; @@ -19,15 +20,17 @@ use std::{ io::Write, iter::Peekable, ops::{Deref, Range}, + path::Path, process::Stdio, str::Chars, - sync::OnceLock, + sync::{Arc, OnceLock}, time::Instant, }; use task::{HideStrategy, RevealStrategy, SpawnInTerminal, TaskId}; use ui::ActiveTheme; use util::ResultExt; -use workspace::{SaveIntent, notifications::NotifyResultExt}; +use workspace::notifications::DetachAndPromptErr; +use workspace::{Item, SaveIntent, notifications::NotifyResultExt}; use zed_actions::{OpenDocs, RevealTarget}; use crate::{ @@ -157,6 +160,12 @@ pub struct VimSet { #[derive(Debug)] struct WrappedAction(Box); +#[derive(Clone, Deserialize, JsonSchema, PartialEq)] +struct VimSave { + pub save_intent: Option, + pub filename: String, +} + actions!(vim, [VisualCommand, CountCommand, ShellCommand]); impl_internal_actions!( vim, @@ -168,6 +177,7 @@ impl_internal_actions!( OnMatchingLines, ShellExec, VimSet, + VimSave, ] ); @@ -229,6 +239,47 @@ pub fn register(editor: &mut Editor, cx: &mut Context) { }) }); + 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, _: &CountCommand, window, cx| { let Some(workspace) = vim.workspace(window) else { return; @@ -364,6 +415,9 @@ struct VimCommand { action: Option>, action_name: Option<&'static str>, bang_action: Option>, + args: Option< + Box, String) -> Option> + Send + Sync + 'static>, + >, range: Option< Box< dyn Fn(Box, &CommandRange) -> Option> @@ -400,6 +454,14 @@ impl VimCommand { self } + fn args( + mut self, + f: impl Fn(Box, String) -> Option> + Send + Sync + 'static, + ) -> Self { + self.args = Some(Box::new(f)); + self + } + fn range( mut self, f: impl Fn(Box, &CommandRange) -> Option> + Send + Sync + 'static, @@ -415,19 +477,27 @@ impl VimCommand { fn parse( &self, - mut query: &str, + query: &str, range: &Option, cx: &App, ) -> Option> { - let has_bang = query.ends_with('!'); - if has_bang { - query = &query[..query.len() - 1]; - } - - let suffix = query.strip_prefix(self.prefix)?; - if !self.suffix.starts_with(suffix) { - return None; - } + 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::(); + 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() @@ -438,8 +508,14 @@ impl VimCommand { } else { return None; }; - - if let Some(range) = range { + if !args.is_empty() { + // if command does not accept args and we have args then we should do no action + if let Some(args_fn) = &self.args { + args_fn.deref()(action, args) + } else { + None + } + } else if let Some(range) = range { self.range.as_ref().and_then(|f| f(action, range)) } else { Some(action) @@ -680,6 +756,18 @@ fn generate_commands(_: &App) -> Vec { ) .bang(workspace::Save { save_intent: Some(SaveIntent::Overwrite), + }) + .args(|action, args| { + Some( + VimSave { + save_intent: action + .as_any() + .downcast_ref::() + .and_then(|action| action.save_intent), + filename: args, + } + .boxed_clone(), + ) }), VimCommand::new( ("q", "uit"), @@ -1035,7 +1123,7 @@ pub fn command_interceptor(mut input: &str, cx: &App) -> Vec