vim: ! support (#23169)

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
This commit is contained in:
Conrad Irwin 2025-01-16 21:19:15 -07:00 committed by GitHub
parent 21e7765a48
commit f94efb5008
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 452 additions and 12 deletions

2
Cargo.lock generated
View file

@ -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",

View file

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

View file

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

View file

@ -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"] }

View file

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

View file

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

View file

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

View file

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