Detailed changes
@@ -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",
@@ -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": {
@@ -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,
@@ -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"] }
@@ -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;
@@ -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)
@@ -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 { .. }
@@ -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();