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

AidanV created

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

Change summary

crates/language/src/buffer.rs |   5 
crates/vim/src/command.rs     | 203 ++++++++++++++++++++++++++++++++++--
2 files changed, 197 insertions(+), 11 deletions(-)

Detailed changes

crates/language/src/buffer.rs 🔗

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

crates/vim/src/command.rs 🔗

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