vim: Add :<range>w <filename> command (#41256)

Release Notes:

- Adds support for `:[range]w {file}`
  - This writes the lines in the range to the specified
- Adds support for `:[range]w`
  - This replaces the current file with the selected lines
This commit is contained in:
AidanV
2025-11-13 13:27:08 -07:00
committed by GitHub
parent 03fad4b951
commit 84f24e4b62
2 changed files with 197 additions and 11 deletions
+5
View File
@@ -2055,6 +2055,11 @@ impl Buffer {
}
}
/// Marks the buffer as having a conflict regardless of current buffer state.
pub fn set_conflict(&mut self) {
self.has_conflict = true;
}
/// Checks if the buffer and its file have both changed since the buffer
/// was last saved or reloaded.
pub fn has_conflict(&self) -> bool {
+192 -11
View File
@@ -189,6 +189,7 @@ pub struct VimSet {
#[derive(Clone, PartialEq, Action)]
#[action(namespace = vim, no_json, no_register)]
struct VimSave {
pub range: Option<CommandRange>,
pub save_intent: Option<SaveIntent>,
pub filename: String,
}
@@ -324,6 +325,134 @@ pub fn register(editor: &mut Editor, cx: &mut Context<Vim>) {
});
Vim::action(editor, cx, |vim, action: &VimSave, window, cx| {
if let Some(range) = &action.range {
vim.update_editor(cx, |vim, editor, cx| {
let Some(range) = range.buffer_range(vim, editor, window, cx).ok() else {
return;
};
let Some((line_ending, text, whole_buffer)) = editor.buffer().update(cx, |multi, cx| {
Some(multi.as_singleton()?.update(cx, |buffer, _| {
(
buffer.line_ending(),
buffer.as_rope().slice_rows(range.start.0..range.end.0 + 1),
range.start.0 == 0 && range.end.0 + 1 >= buffer.row_count(),
)
}))
}) else {
return;
};
let filename = action.filename.clone();
let filename = if filename.is_empty() {
let Some(file) = editor
.buffer()
.read(cx)
.as_singleton()
.and_then(|buffer| buffer.read(cx).file())
else {
let _ = window.prompt(
gpui::PromptLevel::Warning,
"No file name",
Some("Partial buffer write requires file name."),
&["Cancel"],
cx,
);
return;
};
file.path().display(file.path_style(cx)).to_string()
} else {
filename
};
if action.filename.is_empty() {
if whole_buffer {
if let Some(workspace) = vim.workspace(window) {
workspace.update(cx, |workspace, cx| {
workspace
.save_active_item(
action.save_intent.unwrap_or(SaveIntent::Save),
window,
cx,
)
.detach_and_prompt_err("Failed to save", window, cx, |_, _, _| None);
});
}
return;
}
if Some(SaveIntent::Overwrite) != action.save_intent {
let _ = window.prompt(
gpui::PromptLevel::Warning,
"Use ! to write partial buffer",
Some("Overwriting the current file with selected buffer content requires '!'."),
&["Cancel"],
cx,
);
return;
}
editor.buffer().update(cx, |multi, cx| {
if let Some(buffer) = multi.as_singleton() {
buffer.update(cx, |buffer, _| buffer.set_conflict());
}
});
};
editor.project().unwrap().update(cx, |project, cx| {
let worktree = project.visible_worktrees(cx).next().unwrap();
worktree.update(cx, |worktree, cx| {
let path_style = worktree.path_style();
let Some(path) = RelPath::new(Path::new(&filename), path_style).ok() else {
return;
};
let rx = (worktree.entry_for_path(&path).is_some() && Some(SaveIntent::Overwrite) != action.save_intent).then(|| {
window.prompt(
gpui::PromptLevel::Warning,
&format!("{path:?} already exists. Do you want to replace it?"),
Some(
"A file or folder with the same name already exists. Replacing it will overwrite its current contents.",
),
&["Replace", "Cancel"],
cx
)
});
let filename = filename.clone();
cx.spawn_in(window, async move |this, cx| {
if let Some(rx) = rx
&& Ok(0) != rx.await
{
return;
}
let _ = this.update_in(cx, |worktree, window, cx| {
let Some(path) = RelPath::new(Path::new(&filename), path_style).ok() else {
return;
};
worktree
.write_file(path.into_arc(), text.clone(), line_ending, cx)
.detach_and_prompt_err("Failed to write lines", window, cx, |_, _, _| None);
});
})
.detach();
});
});
});
return;
}
if action.filename.is_empty() {
if let Some(workspace) = vim.workspace(window) {
workspace.update(cx, |workspace, cx| {
workspace
.save_active_item(
action.save_intent.unwrap_or(SaveIntent::Save),
window,
cx,
)
.detach_and_prompt_err("Failed to save", window, cx, |_, _, _| None);
});
}
return;
}
vim.update_editor(cx, |_, editor, cx| {
let Some(project) = editor.project().cloned() else {
return;
@@ -1175,24 +1304,34 @@ fn generate_commands(_: &App) -> Vec<VimCommand> {
vec![
VimCommand::new(
("w", "rite"),
workspace::Save {
VimSave {
save_intent: Some(SaveIntent::Save),
filename: "".into(),
range: None,
},
)
.bang(workspace::Save {
.bang(VimSave {
save_intent: Some(SaveIntent::Overwrite),
filename: "".into(),
range: None,
})
.filename(|action, filename| {
Some(
VimSave {
save_intent: action
.as_any()
.downcast_ref::<workspace::Save>()
.downcast_ref::<VimSave>()
.and_then(|action| action.save_intent),
filename,
range: None,
}
.boxed_clone(),
)
})
.range(|action, range| {
let mut action: VimSave = action.as_any().downcast_ref::<VimSave>().unwrap().clone();
action.range.replace(range.clone());
Some(Box::new(action))
}),
VimCommand::new(("e", "dit"), editor::actions::ReloadFile)
.bang(editor::actions::ReloadFile)
@@ -1692,12 +1831,12 @@ pub fn command_interceptor(
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,
};
let (range, query) = VimCommand::parse_range(&string[1..]);
let action =
match cx.update(|cx| commands(cx).get(cmd_idx)?.parse(&query, &range, cx)) {
Ok(Some(action)) => action,
_ => continue,
};
results.push(CommandInterceptItem {
action,
string,
@@ -2302,7 +2441,7 @@ impl ShellExec {
#[cfg(test)]
mod test {
use std::path::Path;
use std::path::{Path, PathBuf};
use crate::{
VimAddon,
@@ -2314,7 +2453,7 @@ mod test {
use indoc::indoc;
use settings::Settings;
use util::path;
use workspace::Workspace;
use workspace::{OpenOptions, Workspace};
#[gpui::test]
async fn test_command_basics(cx: &mut TestAppContext) {
@@ -2619,6 +2758,48 @@ mod test {
});
}
#[gpui::test]
async fn test_command_write_range(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.set_state(
indoc! {"
The quick
brown« fox
jumpsˇ» over
the lazy dog
"},
Mode::Visual,
);
cx.simulate_keystrokes(": w space dir/other.rs");
cx.simulate_keystrokes("enter");
let other = path!("/root/dir/other.rs");
let _ = cx
.workspace(|workspace, window, cx| {
workspace.open_abs_path(PathBuf::from(other), OpenOptions::default(), window, cx)
})
.await;
cx.workspace(|workspace, _, cx| {
assert_active_item(
workspace,
other,
indoc! {"
brown fox
jumps over
"},
cx,
);
});
}
#[gpui::test]
async fn test_command_matching_lines(cx: &mut TestAppContext) {
let mut cx = NeovimBackedTestContext::new(cx).await;