diff --git a/Cargo.lock b/Cargo.lock index f6835ac9e1..26161e7ce7 100644 --- a/Cargo.lock +++ b/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", diff --git a/assets/keymaps/vim.json b/assets/keymaps/vim.json index 5b14654728..1d4cd7f0c0 100644 --- a/assets/keymaps/vim.json +++ b/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": { diff --git a/crates/project/src/terminals.rs b/crates/project/src/terminals.rs index 4e028d16e6..caad9eadc4 100644 --- a/crates/project/src/terminals.rs +++ b/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, + 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, diff --git a/crates/vim/Cargo.toml b/crates/vim/Cargo.toml index 02d4136faa..9dbc92f81b 100644 --- a/crates/vim/Cargo.toml +++ b/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"] } diff --git a/crates/vim/src/command.rs b/crates/vim/src/command.rs index e2bd19af5f..abb4ec8bd8 100644 --- a/crates/vim/src/command.rs +++ b/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); -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::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::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, + is_read: bool, +} + +impl Vim { + pub fn cancel_running_command(&mut self, cx: &mut ViewContext) { + if self.running_command.take().is_some() { + self.update_editor(cx, |_, editor, cx| { + editor.transact(cx, |editor, _| { + editor.clear_row_highlights::(); + }) + }); + } + } + + fn prepare_shell_command(&mut self, command: &str, cx: &mut ViewContext) -> 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, + cx: &mut ViewContext, + ) { + 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, + ) { + 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) -> Option> { + 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) { + 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::(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::( + 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; diff --git a/crates/vim/src/normal.rs b/crates/vim/src/normal.rs index df01f2affc..34c183b20a 100644 --- a/crates/vim/src/normal.rs +++ b/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) diff --git a/crates/vim/src/state.rs b/crates/vim/src/state.rs index e401903c9a..1a54775aa3 100644 --- a/crates/vim/src/state.rs +++ b/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 { .. } diff --git a/crates/vim/src/vim.rs b/crates/vim/src/vim.rs index f2f213d481..e40eadf955 100644 --- a/crates/vim/src/vim.rs +++ b/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, + last_command: Option, + running_command: Option>, _subscriptions: Vec, } @@ -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();