vim: ! support (#23169)

Conrad Irwin created

Closes #22885
Closes #12565 

This doesn't yet add history in the command palette, which is painfully
missing.

Release Notes:

- vim: Added `:!`, `:<range>!` and `:r!` support
- vim: Added `!` operator in normal/visual mode

Change summary

Cargo.lock                      |   2 
assets/keymaps/vim.json         |   8 
crates/project/src/terminals.rs |  61 +++++
crates/vim/Cargo.toml           |   4 
crates/vim/src/command.rs       | 372 ++++++++++++++++++++++++++++++++++
crates/vim/src/normal.rs        |   4 
crates/vim/src/state.rs         |   4 
crates/vim/src/vim.rs           |   9 
8 files changed, 452 insertions(+), 12 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -14305,6 +14305,7 @@ dependencies = [
  "indoc",
  "itertools 0.14.0",
  "language",
+ "libc",
  "log",
  "lsp",
  "multi_buffer",
@@ -14318,6 +14319,7 @@ dependencies = [
  "serde_derive",
  "serde_json",
  "settings",
+ "task",
  "theme",
  "tokio",
  "ui",

assets/keymaps/vim.json 🔗

@@ -221,6 +221,7 @@
       ">": ["vim::PushOperator", "Indent"],
       "<": ["vim::PushOperator", "Outdent"],
       "=": ["vim::PushOperator", "AutoIndent"],
+      "!": ["vim::PushOperator", "ShellCommand"],
       "g u": ["vim::PushOperator", "Lowercase"],
       "g shift-u": ["vim::PushOperator", "Uppercase"],
       "g ~": ["vim::PushOperator", "OppositeCase"],
@@ -287,6 +288,7 @@
       ">": "vim::Indent",
       "<": "vim::Outdent",
       "=": "vim::AutoIndent",
+      "!": "vim::ShellCommand",
       "i": ["vim::PushOperator", { "Object": { "around": false } }],
       "a": ["vim::PushOperator", { "Object": { "around": true } }],
       "g c": "vim::ToggleComments",
@@ -498,6 +500,12 @@
       "=": "vim::CurrentLine"
     }
   },
+  {
+    "context": "vim_operator == sh",
+    "bindings": {
+      "!": "vim::CurrentLine"
+    }
+  },
   {
     "context": "vim_operator == gc",
     "bindings": {

crates/project/src/terminals.rs 🔗

@@ -13,7 +13,7 @@ use std::{
     path::{Path, PathBuf},
     sync::Arc,
 };
-use task::{Shell, SpawnInTerminal};
+use task::{Shell, ShellBuilder, SpawnInTerminal};
 use terminal::{
     terminal_settings::{self, TerminalSettings, VenvSettings},
     TaskState, TaskStatus, Terminal, TerminalBuilder,
@@ -64,7 +64,7 @@ impl Project {
         }
     }
 
-    fn ssh_details(&self, cx: &AppContext) -> Option<(String, SshCommand)> {
+    pub fn ssh_details(&self, cx: &AppContext) -> Option<(String, SshCommand)> {
         if let Some(ssh_client) = &self.ssh_client {
             let ssh_client = ssh_client.read(cx);
             if let Some(args) = ssh_client.ssh_args() {
@@ -122,6 +122,63 @@ impl Project {
         })
     }
 
+    pub fn terminal_settings<'a>(
+        &'a self,
+        path: &'a Option<PathBuf>,
+        cx: &'a AppContext,
+    ) -> &'a TerminalSettings {
+        let mut settings_location = None;
+        if let Some(path) = path.as_ref() {
+            if let Some((worktree, _)) = self.find_worktree(path, cx) {
+                settings_location = Some(SettingsLocation {
+                    worktree_id: worktree.read(cx).id(),
+                    path,
+                });
+            }
+        }
+        TerminalSettings::get(settings_location, cx)
+    }
+
+    pub fn exec_in_shell(&self, command: String, cx: &AppContext) -> std::process::Command {
+        let path = self.first_project_directory(cx);
+        let ssh_details = self.ssh_details(cx);
+        let settings = self.terminal_settings(&path, cx).clone();
+
+        let builder = ShellBuilder::new(ssh_details.is_none(), &settings.shell);
+        let (command, args) = builder.build(command, &Vec::new());
+
+        let mut env = self
+            .environment
+            .read(cx)
+            .get_cli_environment()
+            .unwrap_or_default();
+        env.extend(settings.env.clone());
+
+        match &self.ssh_details(cx) {
+            Some((_, ssh_command)) => {
+                let (command, args) = wrap_for_ssh(
+                    ssh_command,
+                    Some((&command, &args)),
+                    path.as_deref(),
+                    env,
+                    None,
+                );
+                let mut command = std::process::Command::new(command);
+                command.args(args);
+                command
+            }
+            None => {
+                let mut command = std::process::Command::new(command);
+                command.args(args);
+                command.envs(env);
+                if let Some(path) = path {
+                    command.current_dir(path);
+                }
+                command
+            }
+        }
+    }
+
     pub fn create_terminal_with_venv(
         &mut self,
         kind: TerminalKind,

crates/vim/Cargo.toml 🔗

@@ -23,9 +23,11 @@ collections.workspace = true
 command_palette.workspace = true
 command_palette_hooks.workspace = true
 editor.workspace = true
+futures.workspace = true
 gpui.workspace = true
 itertools.workspace = true
 language.workspace = true
+libc.workspace = true
 log.workspace = true
 multi_buffer.workspace = true
 nvim-rs = { git = "https://github.com/KillTheMule/nvim-rs", branch = "master", features = ["use_tokio"], optional = true }
@@ -36,6 +38,7 @@ serde.workspace = true
 serde_derive.workspace = true
 serde_json.workspace = true
 settings.workspace = true
+task.workspace = true
 theme.workspace = true
 tokio = { version = "1.15", features = ["full"], optional = true }
 ui.workspace = true
@@ -47,7 +50,6 @@ zed_actions.workspace = true
 [dev-dependencies]
 command_palette.workspace = true
 editor = { workspace = true, features = ["test-support"] }
-futures.workspace = true
 gpui = { workspace = true, features = ["test-support"] }
 indoc.workspace = true
 language = { workspace = true, features = ["test-support"] }

crates/vim/src/command.rs 🔗

@@ -1,8 +1,10 @@
 use anyhow::{anyhow, Result};
+use collections::HashMap;
 use command_palette_hooks::CommandInterceptResult;
 use editor::{
     actions::{SortLinesCaseInsensitive, SortLinesCaseSensitive},
     display_map::ToDisplayPoint,
+    scroll::Autoscroll,
     Bias, Editor, ToPoint,
 };
 use gpui::{
@@ -15,14 +17,19 @@ use schemars::JsonSchema;
 use search::{BufferSearchBar, SearchOptions};
 use serde::Deserialize;
 use std::{
+    io::Write,
     iter::Peekable,
     ops::{Deref, Range},
+    process::Stdio,
     str::Chars,
     sync::OnceLock,
     time::Instant,
 };
+use task::{HideStrategy, RevealStrategy, SpawnInTerminal, TaskId};
+use ui::ActiveTheme;
 use util::ResultExt;
 use workspace::{notifications::NotifyResultExt, SaveIntent};
+use zed_actions::RevealTarget;
 
 use crate::{
     motion::{EndOfDocument, Motion, StartOfDocument},
@@ -30,6 +37,7 @@ use crate::{
         search::{FindCommand, ReplaceCommand, Replacement},
         JoinLines,
     },
+    object::Object,
     state::Mode,
     visual::VisualDeleteLine,
     Vim,
@@ -61,10 +69,17 @@ pub struct WithCount {
 #[derive(Debug)]
 struct WrappedAction(Box<dyn Action>);
 
-actions!(vim, [VisualCommand, CountCommand]);
+actions!(vim, [VisualCommand, CountCommand, ShellCommand]);
 impl_internal_actions!(
     vim,
-    [GoToLine, YankCommand, WithRange, WithCount, OnMatchingLines]
+    [
+        GoToLine,
+        YankCommand,
+        WithRange,
+        WithCount,
+        OnMatchingLines,
+        ShellExec
+    ]
 );
 
 impl PartialEq for WrappedAction {
@@ -96,17 +111,27 @@ pub fn register(editor: &mut Editor, cx: &mut ViewContext<Vim>) {
         })
     });
 
+    Vim::action(editor, cx, |vim, _: &ShellCommand, cx| {
+        let Some(workspace) = vim.workspace(cx) else {
+            return;
+        };
+        workspace.update(cx, |workspace, cx| {
+            command_palette::CommandPalette::toggle(workspace, "'<,'>!", cx);
+        })
+    });
+
     Vim::action(editor, cx, |vim, _: &CountCommand, cx| {
         let Some(workspace) = vim.workspace(cx) else {
             return;
         };
         let count = Vim::take_count(cx).unwrap_or(1);
+        let n = if count > 1 {
+            format!(".,.+{}", count.saturating_sub(1))
+        } else {
+            ".".to_string()
+        };
         workspace.update(cx, |workspace, cx| {
-            command_palette::CommandPalette::toggle(
-                workspace,
-                &format!(".,.+{}", count.saturating_sub(1)),
-                cx,
-            );
+            command_palette::CommandPalette::toggle(workspace, &n, cx);
         })
     });
 
@@ -209,6 +234,10 @@ pub fn register(editor: &mut Editor, cx: &mut ViewContext<Vim>) {
 
     Vim::action(editor, cx, |vim, action: &OnMatchingLines, cx| {
         action.run(vim, cx)
+    });
+
+    Vim::action(editor, cx, |vim, action: &ShellExec, cx| {
+        action.run(vim, cx)
     })
 }
 
@@ -817,6 +846,8 @@ pub fn command_interceptor(mut input: &str, cx: &AppContext) -> Option<CommandIn
         } else {
             None
         }
+    } else if query.contains('!') {
+        ShellExec::parse(query, range.clone())
     } else {
         None
     };
@@ -1057,6 +1088,333 @@ impl OnMatchingLines {
     }
 }
 
+#[derive(Clone, Debug, PartialEq)]
+pub struct ShellExec {
+    command: String,
+    range: Option<CommandRange>,
+    is_read: bool,
+}
+
+impl Vim {
+    pub fn cancel_running_command(&mut self, cx: &mut ViewContext<Self>) {
+        if self.running_command.take().is_some() {
+            self.update_editor(cx, |_, editor, cx| {
+                editor.transact(cx, |editor, _| {
+                    editor.clear_row_highlights::<ShellExec>();
+                })
+            });
+        }
+    }
+
+    fn prepare_shell_command(&mut self, command: &str, cx: &mut ViewContext<Self>) -> String {
+        let mut ret = String::new();
+        // N.B. non-standard escaping rules:
+        // * !echo % => "echo README.md"
+        // * !echo \% => "echo %"
+        // * !echo \\% => echo \%
+        // * !echo \\\% => echo \\%
+        for c in command.chars() {
+            if c != '%' && c != '!' {
+                ret.push(c);
+                continue;
+            } else if ret.chars().last() == Some('\\') {
+                ret.pop();
+                ret.push(c);
+                continue;
+            }
+            match c {
+                '%' => {
+                    self.update_editor(cx, |_, editor, cx| {
+                        if let Some((_, buffer, _)) = editor.active_excerpt(cx) {
+                            if let Some(file) = buffer.read(cx).file() {
+                                if let Some(local) = file.as_local() {
+                                    if let Some(str) = local.path().to_str() {
+                                        ret.push_str(str)
+                                    }
+                                }
+                            }
+                        }
+                    });
+                }
+                '!' => {
+                    if let Some(command) = &self.last_command {
+                        ret.push_str(command)
+                    }
+                }
+                _ => {}
+            }
+        }
+        self.last_command = Some(ret.clone());
+        ret
+    }
+
+    pub fn shell_command_motion(
+        &mut self,
+        motion: Motion,
+        times: Option<usize>,
+        cx: &mut ViewContext<Vim>,
+    ) {
+        self.stop_recording(cx);
+        let Some(workspace) = self.workspace(cx) else {
+            return;
+        };
+        let command = self.update_editor(cx, |_, editor, cx| {
+            let snapshot = editor.snapshot(cx);
+            let start = editor.selections.newest_display(cx);
+            let text_layout_details = editor.text_layout_details(cx);
+            let mut range = motion
+                .range(&snapshot, start.clone(), times, false, &text_layout_details)
+                .unwrap_or(start.range());
+            if range.start != start.start {
+                editor.change_selections(None, cx, |s| {
+                    s.select_ranges([
+                        range.start.to_point(&snapshot)..range.start.to_point(&snapshot)
+                    ]);
+                })
+            }
+            if range.end.row() > range.start.row() && range.end.column() != 0 {
+                *range.end.row_mut() -= 1
+            }
+            if range.end.row() == range.start.row() {
+                ".!".to_string()
+            } else {
+                format!(".,.+{}!", (range.end.row() - range.start.row()).0)
+            }
+        });
+        if let Some(command) = command {
+            workspace.update(cx, |workspace, cx| {
+                command_palette::CommandPalette::toggle(workspace, &command, cx);
+            });
+        }
+    }
+
+    pub fn shell_command_object(
+        &mut self,
+        object: Object,
+        around: bool,
+        cx: &mut ViewContext<Vim>,
+    ) {
+        self.stop_recording(cx);
+        let Some(workspace) = self.workspace(cx) else {
+            return;
+        };
+        let command = self.update_editor(cx, |_, editor, cx| {
+            let snapshot = editor.snapshot(cx);
+            let start = editor.selections.newest_display(cx);
+            let range = object
+                .range(&snapshot, start.clone(), around)
+                .unwrap_or(start.range());
+            if range.start != start.start {
+                editor.change_selections(None, cx, |s| {
+                    s.select_ranges([
+                        range.start.to_point(&snapshot)..range.start.to_point(&snapshot)
+                    ]);
+                })
+            }
+            if range.end.row() == range.start.row() {
+                ".!".to_string()
+            } else {
+                format!(".,.+{}!", (range.end.row() - range.start.row()).0)
+            }
+        });
+        if let Some(command) = command {
+            workspace.update(cx, |workspace, cx| {
+                command_palette::CommandPalette::toggle(workspace, &command, cx);
+            });
+        }
+    }
+}
+
+impl ShellExec {
+    pub fn parse(query: &str, range: Option<CommandRange>) -> Option<Box<dyn Action>> {
+        let (before, after) = query.split_once('!')?;
+        let before = before.trim();
+
+        if !"read".starts_with(before) {
+            return None;
+        }
+
+        Some(
+            ShellExec {
+                command: after.trim().to_string(),
+                range,
+                is_read: !before.is_empty(),
+            }
+            .boxed_clone(),
+        )
+    }
+
+    pub fn run(&self, vim: &mut Vim, cx: &mut ViewContext<Vim>) {
+        let Some(workspace) = vim.workspace(cx) else {
+            return;
+        };
+
+        let project = workspace.read(cx).project().clone();
+        let command = vim.prepare_shell_command(&self.command, cx);
+
+        if self.range.is_none() && !self.is_read {
+            workspace.update(cx, |workspace, cx| {
+                let project = workspace.project().read(cx);
+                let cwd = project.first_project_directory(cx);
+                let shell = project.terminal_settings(&cwd, cx).shell.clone();
+                cx.emit(workspace::Event::SpawnTask {
+                    action: Box::new(SpawnInTerminal {
+                        id: TaskId("vim".to_string()),
+                        full_label: self.command.clone(),
+                        label: self.command.clone(),
+                        command: command.clone(),
+                        args: Vec::new(),
+                        command_label: self.command.clone(),
+                        cwd,
+                        env: HashMap::default(),
+                        use_new_terminal: true,
+                        allow_concurrent_runs: true,
+                        reveal: RevealStrategy::NoFocus,
+                        reveal_target: RevealTarget::Dock,
+                        hide: HideStrategy::Never,
+                        shell,
+                        show_summary: false,
+                        show_command: false,
+                    }),
+                });
+            });
+            return;
+        };
+
+        let mut input_snapshot = None;
+        let mut input_range = None;
+        let mut needs_newline_prefix = false;
+        vim.update_editor(cx, |vim, editor, cx| {
+            let snapshot = editor.buffer().read(cx).snapshot(cx);
+            let range = if let Some(range) = self.range.clone() {
+                let Some(range) = range.buffer_range(vim, editor, cx).log_err() else {
+                    return;
+                };
+                Point::new(range.start.0, 0)
+                    ..snapshot.clip_point(Point::new(range.end.0 + 1, 0), Bias::Right)
+            } else {
+                let mut end = editor.selections.newest::<Point>(cx).range().end;
+                end = snapshot.clip_point(Point::new(end.row + 1, 0), Bias::Right);
+                needs_newline_prefix = end == snapshot.max_point();
+                end..end
+            };
+            if self.is_read {
+                input_range =
+                    Some(snapshot.anchor_after(range.end)..snapshot.anchor_after(range.end));
+            } else {
+                input_range =
+                    Some(snapshot.anchor_before(range.start)..snapshot.anchor_after(range.end));
+            }
+            editor.highlight_rows::<ShellExec>(
+                input_range.clone().unwrap(),
+                cx.theme().status().unreachable_background,
+                false,
+                cx,
+            );
+
+            if !self.is_read {
+                input_snapshot = Some(snapshot)
+            }
+        });
+
+        let Some(range) = input_range else { return };
+
+        let mut process = project.read(cx).exec_in_shell(command, cx);
+        process.stdout(Stdio::piped());
+        process.stderr(Stdio::piped());
+
+        if input_snapshot.is_some() {
+            process.stdin(Stdio::piped());
+        } else {
+            process.stdin(Stdio::null());
+        };
+
+        // https://registerspill.thorstenball.com/p/how-to-lose-control-of-your-shell
+        //
+        // safety: code in pre_exec should be signal safe.
+        // https://man7.org/linux/man-pages/man7/signal-safety.7.html
+        #[cfg(not(target_os = "windows"))]
+        unsafe {
+            use std::os::unix::process::CommandExt;
+            process.pre_exec(|| {
+                libc::setsid();
+                Ok(())
+            });
+        };
+        let is_read = self.is_read;
+
+        let task = cx.spawn(|vim, mut cx| async move {
+            let Some(mut running) = process.spawn().log_err() else {
+                vim.update(&mut cx, |vim, cx| {
+                    vim.cancel_running_command(cx);
+                })
+                .log_err();
+                return;
+            };
+
+            if let Some(mut stdin) = running.stdin.take() {
+                if let Some(snapshot) = input_snapshot {
+                    let range = range.clone();
+                    cx.background_executor()
+                        .spawn(async move {
+                            for chunk in snapshot.text_for_range(range) {
+                                if stdin.write_all(chunk.as_bytes()).log_err().is_none() {
+                                    return;
+                                }
+                            }
+                            stdin.flush().log_err();
+                        })
+                        .detach();
+                }
+            };
+
+            let output = cx
+                .background_executor()
+                .spawn(async move { running.wait_with_output() })
+                .await;
+
+            let Some(output) = output.log_err() else {
+                vim.update(&mut cx, |vim, cx| {
+                    vim.cancel_running_command(cx);
+                })
+                .log_err();
+                return;
+            };
+            let mut text = String::new();
+            if needs_newline_prefix {
+                text.push('\n');
+            }
+            text.push_str(&String::from_utf8_lossy(&output.stdout));
+            text.push_str(&String::from_utf8_lossy(&output.stderr));
+            if !text.is_empty() && text.chars().last() != Some('\n') {
+                text.push('\n');
+            }
+
+            vim.update(&mut cx, |vim, cx| {
+                vim.update_editor(cx, |_, editor, cx| {
+                    editor.transact(cx, |editor, cx| {
+                        editor.edit([(range.clone(), text)], cx);
+                        let snapshot = editor.buffer().read(cx).snapshot(cx);
+                        editor.change_selections(Some(Autoscroll::fit()), cx, |s| {
+                            let point = if is_read {
+                                let point = range.end.to_point(&snapshot);
+                                Point::new(point.row.saturating_sub(1), 0)
+                            } else {
+                                let point = range.start.to_point(&snapshot);
+                                Point::new(point.row, 0)
+                            };
+                            s.select_ranges([point..point]);
+                        })
+                    })
+                });
+                vim.cancel_running_command(cx);
+            })
+            .log_err();
+        });
+        vim.running_command.replace(task);
+    }
+}
+
 #[cfg(test)]
 mod test {
     use std::path::Path;

crates/vim/src/normal.rs 🔗

@@ -160,6 +160,7 @@ impl Vim {
             Some(Operator::AutoIndent) => {
                 self.indent_motion(motion, times, IndentDirection::Auto, cx)
             }
+            Some(Operator::ShellCommand) => self.shell_command_motion(motion, times, cx),
             Some(Operator::Lowercase) => {
                 self.change_case_motion(motion, times, CaseTarget::Lowercase, cx)
             }
@@ -195,6 +196,9 @@ impl Vim {
                 Some(Operator::AutoIndent) => {
                     self.indent_object(object, around, IndentDirection::Auto, cx)
                 }
+                Some(Operator::ShellCommand) => {
+                    self.shell_command_object(object, around, cx);
+                }
                 Some(Operator::Rewrap) => self.rewrap_object(object, around, cx),
                 Some(Operator::Lowercase) => {
                     self.change_case_object(object, around, CaseTarget::Lowercase, cx)

crates/vim/src/state.rs 🔗

@@ -96,6 +96,7 @@ pub enum Operator {
     Outdent,
     AutoIndent,
     Rewrap,
+    ShellCommand,
     Lowercase,
     Uppercase,
     OppositeCase,
@@ -495,6 +496,7 @@ impl Operator {
             Operator::Jump { line: false } => "`",
             Operator::Indent => ">",
             Operator::AutoIndent => "eq",
+            Operator::ShellCommand => "sh",
             Operator::Rewrap => "gq",
             Operator::Outdent => "<",
             Operator::Uppercase => "gU",
@@ -516,6 +518,7 @@ impl Operator {
                 prefix: Some(prefix),
             } => format!("^V{prefix}"),
             Operator::AutoIndent => "=".to_string(),
+            Operator::ShellCommand => "=".to_string(),
             _ => self.id().to_string(),
         }
     }
@@ -544,6 +547,7 @@ impl Operator {
             | Operator::Indent
             | Operator::Outdent
             | Operator::AutoIndent
+            | Operator::ShellCommand
             | Operator::Lowercase
             | Operator::Uppercase
             | Operator::Object { .. }

crates/vim/src/vim.rs 🔗

@@ -27,7 +27,7 @@ use editor::{
 };
 use gpui::{
     actions, impl_actions, Action, AppContext, Axis, Entity, EventEmitter, KeyContext,
-    KeystrokeEvent, Render, Subscription, View, ViewContext, WeakView,
+    KeystrokeEvent, Render, Subscription, Task, View, ViewContext, WeakView,
 };
 use insert::{NormalBefore, TemporaryNormal};
 use language::{CursorShape, Point, Selection, SelectionGoal, TransactionId};
@@ -76,7 +76,6 @@ actions!(
         ClearOperators,
         Tab,
         Enter,
-        Object,
         InnerObject,
         FindForward,
         FindBackward,
@@ -221,6 +220,8 @@ pub(crate) struct Vim {
 
     editor: WeakView<Editor>,
 
+    last_command: Option<String>,
+    running_command: Option<Task<()>>,
     _subscriptions: Vec<Subscription>,
 }
 
@@ -264,6 +265,9 @@ impl Vim {
             selected_register: None,
             search: SearchState::default(),
 
+            last_command: None,
+            running_command: None,
+
             editor: editor.downgrade(),
             _subscriptions: vec![
                 cx.observe_keystrokes(Self::observe_keystrokes),
@@ -519,6 +523,7 @@ impl Vim {
         self.mode = mode;
         self.operator_stack.clear();
         self.selected_register.take();
+        self.cancel_running_command(cx);
         if mode == Mode::Normal || mode != last_mode {
             self.current_tx.take();
             self.current_anchor.take();