diff --git a/crates/language/src/buffer.rs b/crates/language/src/buffer.rs index ea2405d04c32cba45963bc32747ee0b94292ffd9..3b4f24a400403f7e4dbd4f09ee7fb829f4cbbe00 100644 --- a/crates/language/src/buffer.rs +++ b/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 { diff --git a/crates/vim/src/command.rs b/crates/vim/src/command.rs index cba8351e8d36e784c77c20b15ac0dead41f84a13..70d0e93c5db5999878f2bb79c7fc42f16e6861a1 100644 --- a/crates/vim/src/command.rs +++ b/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, pub save_intent: Option, pub filename: String, } @@ -324,6 +325,134 @@ pub fn register(editor: &mut Editor, cx: &mut Context) { }); 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 { 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::() + .downcast_ref::() .and_then(|action| action.save_intent), filename, + range: None, } .boxed_clone(), ) + }) + .range(|action, range| { + let mut action: VimSave = action.as_any().downcast_ref::().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;