vim: Add :w <filename> command (#29256)

Closes https://github.com/zed-industries/zed/issues/10920

Release Notes:

- vim: Adds support for `:w[rite] <filename>`

---------

Co-authored-by: Conrad Irwin <conrad.irwin@gmail.com>
This commit is contained in:
AidanV 2025-05-12 06:09:18 -07:00 committed by GitHub
parent 196586e352
commit 5a38bbbd22
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -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<dyn Action>);
#[derive(Clone, Deserialize, JsonSchema, PartialEq)]
struct VimSave {
pub save_intent: Option<SaveIntent>,
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>) {
})
});
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<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>>
@ -400,6 +454,14 @@ impl VimCommand {
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,
@ -415,19 +477,27 @@ impl VimCommand {
fn parse(
&self,
mut query: &str,
query: &str,
range: &Option<CommandRange>,
cx: &App,
) -> Option<Box<dyn Action>> {
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::<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()
@ -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<VimCommand> {
)
.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"),
@ -1035,7 +1123,7 @@ pub fn command_interceptor(mut input: &str, cx: &App) -> Vec<CommandInterceptRes
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.ends_with('!') {
if query.contains('!') {
string.push('!');
}
let positions = generate_positions(&string, &(range_prefix + query));
@ -1808,6 +1896,7 @@ mod test {
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,
@ -1890,6 +1979,39 @@ mod test {
});
}
#[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;