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

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;