From 3852e4929b1f1b3b9c744e63b50710326893ff80 Mon Sep 17 00:00:00 2001 From: Avarel Date: Mon, 8 Jan 2024 23:43:50 -0800 Subject: [PATCH] Add ability to run commands --- Cargo.lock | 66 ++++++++++++++++ Cargo.toml | 2 + crates/cli/app/keybinding.rs | 3 + crates/cli/app/mod.rs | 146 +++++++++++++++++++++++++++++++---- crates/cli/app/widgets.rs | 29 ++++--- crates/cli/colors.rs | 2 + crates/cli/components/mux.rs | 5 ++ 7 files changed, 229 insertions(+), 24 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 94a9150..b2cc288 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -152,6 +152,8 @@ dependencies = [ "lru", "ratatui", "regex", + "shellexpand", + "shlex", ] [[package]] @@ -338,6 +340,27 @@ dependencies = [ "powerfmt", ] +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.48.0", +] + [[package]] name = "either" version = "1.9.0" @@ -484,6 +507,17 @@ version = "0.2.152" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13e3bf6590cbc649f4d1a3eefc9d5d6eb746f5200ffb04e5e142700b8faa56e7" +[[package]] +name = "libredox" +version = "0.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85c833ca1e66078851dba29046874e38f08b2c883700aa29a03ddd3b23814ee8" +dependencies = [ + "bitflags 2.4.1", + "libc", + "redox_syscall", +] + [[package]] name = "lock_api" version = "0.4.11" @@ -641,6 +675,12 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + [[package]] name = "parking_lot" version = "0.12.1" @@ -771,6 +811,17 @@ dependencies = [ "bitflags 1.3.2", ] +[[package]] +name = "redox_users" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a18479200779601e498ada4e8c1e1f50e3ee19deb0259c25825a98b5603b2cb4" +dependencies = [ + "getrandom", + "libredox", + "thiserror", +] + [[package]] name = "regex" version = "1.10.2" @@ -832,6 +883,21 @@ dependencies = [ "syn 2.0.48", ] +[[package]] +name = "shellexpand" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da03fa3b94cc19e3ebfc88c4229c49d8f08cdbd1228870a45f0ffdf84988e14b" +dependencies = [ + "dirs", +] + +[[package]] +name = "shlex" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7cee0529a6d40f580e7a5e6c495c8fbfe21b7b52795ed4bb5e62cdf92bc6380" + [[package]] name = "signal-hook" version = "0.3.17" diff --git a/Cargo.toml b/Cargo.toml index 49d7fc8..863adb9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -39,6 +39,8 @@ itoa = "1.0" bitflags = "2.4" lru = "0.12" arboard = "3.3" +shellexpand = "3.1" +shlex = "1.2" [profile.release] strip = true diff --git a/crates/cli/app/keybinding.rs b/crates/cli/app/keybinding.rs index c28e9a7..02fd74c 100644 --- a/crates/cli/app/keybinding.rs +++ b/crates/cli/app/keybinding.rs @@ -225,6 +225,9 @@ impl Keybinding { KeyCode::Char('/') => Some(Action::SwitchMode(InputMode::Command( PromptMode::NewFilter, ))), + KeyCode::Char('!') => Some(Action::SwitchMode(InputMode::Command( + PromptMode::Shell, + ))), KeyCode::Char('?') => { Some(Action::SwitchMode(InputMode::Command(PromptMode::NewLit))) } diff --git a/crates/cli/app/mod.rs b/crates/cli/app/mod.rs index f74a97e..e6f44c0 100644 --- a/crates/cli/app/mod.rs +++ b/crates/cli/app/mod.rs @@ -52,6 +52,7 @@ pub enum InputMode { #[derive(PartialEq, Clone, Copy)] pub enum PromptMode { Command, + Shell, NewFilter, NewLit, } @@ -70,7 +71,7 @@ pub struct App { status: StatusApp, prompt: PromptApp, keybinds: Keybinding, - clipboard: Clipboard, + clipboard: Option, gutter: bool, action_queue: VecDeque, } @@ -83,7 +84,7 @@ impl App { mux: MultiplexerApp::new(), status: StatusApp::new(), keybinds: Keybinding::Hardcoded, - clipboard: Clipboard::new().unwrap(), + clipboard: Clipboard::new().ok(), gutter: true, action_queue: VecDeque::new(), } @@ -112,7 +113,7 @@ impl App { self.mux.push_viewer(viewer); } - pub fn run_app(&mut self, terminal: &mut Terminal) -> Result<()> { + fn enter_terminal(terminal: &mut Terminal) -> Result<()> { enable_raw_mode()?; crossterm::execute!( terminal.backend_mut(), @@ -120,22 +121,27 @@ impl App { EnableBracketedPaste, EnableMouseCapture, )?; + Ok(()) + } - self.event_loop(terminal)?; - - // restore terminal + fn exit_terminal(terminal: &mut Terminal) -> Result<()> { disable_raw_mode()?; crossterm::execute!( terminal.backend_mut(), - DisableMouseCapture, - DisableBracketedPaste, LeaveAlternateScreen, + DisableBracketedPaste, + DisableMouseCapture, )?; - terminal.show_cursor()?; - Ok(()) } + pub fn run_app(&mut self, terminal: &mut Terminal) -> Result<()> { + Self::enter_terminal(terminal)?; + let result = self.event_loop(terminal); + Self::exit_terminal(terminal)?; + result + } + fn event_loop(&mut self, terminal: &mut Terminal) -> Result<()> { let mut mouse_handler = MouseHandler::new(); @@ -160,14 +166,14 @@ impl App { }, }; - if !self.process_action(action) { + if !self.process_action(action, terminal) { break; } } Ok(()) } - fn process_action(&mut self, action: Action) -> bool { + fn process_action(&mut self, action: Action, terminal: &mut Terminal) -> bool { match action { Action::Exit => return false, Action::SwitchMode(new_mode) => { @@ -335,6 +341,10 @@ impl App { let command = self.prompt.take(); self.process_search(&command, matches!(mode, PromptMode::NewLit)) } + InputMode::Command(PromptMode::Shell) => { + let command = self.prompt.take(); + self.process_shell(&command, true, terminal) + } InputMode::Normal | InputMode::Visual | InputMode::Filter => unreachable!(), }; self.mode = InputMode::Normal; @@ -383,6 +393,96 @@ impl App { true } + fn context(&mut self, s: &str) -> Result>, std::env::VarError> { + match s { + "SEL" => { + if let Some(viewer) = self.mux.active_viewer_mut() { + match viewer.export_string() { + Ok(text) => Ok(Some(text.into())), + Err(err) => { + self.status.submit_message( + format!("selection expansion: {err}"), + Some(Duration::from_secs(2)), + ); + Ok(Some("".into())) + } + } + } else { + Ok(Some("".into())) + } + } + s => match std::env::var(s) { + Ok(value) => Ok(Some(value.into())), + Err(std::env::VarError::NotPresent) => Ok(Some("".into())), + Err(e) => Err(e), + }, + } + } + + fn process_shell(&mut self, command: &str, terminate: bool, terminal: &mut Terminal) -> bool { + let Ok(expanded) = shellexpand::env_with_context(command, |s| self.context(s)) else { + self.status.submit_message( + format!("shell: expansion failed"), + Some(Duration::from_secs(2)), + ); + return true; + }; + + let mut shl = shlex::Shlex::new(&expanded); + let Some(cmd) = shl.next() else { + self.status.submit_message( + format!("shell: no command provided"), + Some(Duration::from_secs(2)), + ); + return true; + }; + + let args = shl.by_ref().collect::>(); + + if shl.had_error { + self.status.submit_message( + format!("shell: lexing failed"), + Some(Duration::from_secs(2)), + ); + return true; + } + + let mut command = std::process::Command::new(&cmd); + command.args(args); + + let mut child = match command.spawn() { + Err(err) => { + self.status.submit_message( + format!("shell: {err}"), + Some(Duration::from_secs(2)), + ); + return true; + } + Ok(child) => { + if terminate { + self.mux.clear(); + Self::exit_terminal(terminal).ok(); + } + child + } + }; + + let status = match child.wait() { + Err(err) => { + self.status + .submit_message(format!("shell: {err}"), Some(Duration::from_secs(2))); + return true; + } + Ok(status) => status, + }; + + if terminate { + std::process::exit(status.code().unwrap_or(0)); + } + + !terminate + } + fn process_search(&mut self, pat: &str, escaped: bool) -> bool { let pat = if escaped { Cow::Owned(regex::escape(pat)) @@ -428,9 +528,29 @@ impl App { } } Some("pb" | "pbcopy") => { + let Some(clipboard) = self.clipboard.as_mut() else { + self.status.submit_message( + format!("pbcopy: clipboard not available"), + Some(Duration::from_secs(2)), + ); + return true; + }; if let Some(viewer) = self.mux.active_viewer_mut() { match viewer.export_string() { - Ok(text) => self.clipboard.set_text(text).unwrap(), + Ok(text) => match clipboard.set_text(text) { + Ok(_) => { + self.status.submit_message( + format!("pbcopy: copied to clipboard"), + Some(Duration::from_secs(2)), + ); + } + Err(err) => { + self.status.submit_message( + format!("pbcopy: {err}"), + Some(Duration::from_secs(2)), + ); + } + }, Err(err) => { self.status.submit_message( format!("pbcopy: {err}"), diff --git a/crates/cli/app/widgets.rs b/crates/cli/app/widgets.rs index fc4bbc4..79efb86 100644 --- a/crates/cli/app/widgets.rs +++ b/crates/cli/app/widgets.rs @@ -28,7 +28,10 @@ impl<'a> Widget for StatusWidget<'a> { .bg(colors::STATUS_BAR); let accent_color = match self.input_mode { - InputMode::Command(_) => colors::COMMAND_ACCENT, + InputMode::Command(PromptMode::Command) => colors::COMMAND_ACCENT, + InputMode::Command(PromptMode::Shell) => colors::SHELL_ACCENT, + InputMode::Command(PromptMode::NewFilter) => colors::FILTER_ACCENT, + InputMode::Command(PromptMode::NewLit) => colors::FILTER_ACCENT, InputMode::Normal => colors::VIEWER_ACCENT, InputMode::Visual => colors::SELECT_ACCENT, InputMode::Filter => colors::FILTER_ACCENT, @@ -38,7 +41,10 @@ impl<'a> Widget for StatusWidget<'a> { v.push( Span::from(match self.input_mode { - InputMode::Command(_) => " COMMAND ", + InputMode::Command(PromptMode::Command) => " COMMAND ", + InputMode::Command(PromptMode::Shell) => " SHELL ", + InputMode::Command(PromptMode::NewFilter) => " FILTER REGEX ", + InputMode::Command(PromptMode::NewLit) => " FILTER LITERAL ", InputMode::Normal => " NORMAL ", InputMode::Visual => " VISUAL ", InputMode::Filter => " FILTER ", @@ -117,21 +123,22 @@ impl Widget for PromptWidget<'_> { return; }; - let c = match mode { - PromptMode::Command => ":", - PromptMode::NewFilter => "/", - PromptMode::NewLit => "?", + let indicator = match mode { + PromptMode::Command => Span::raw(":").fg(colors::COMMAND_ACCENT), + PromptMode::NewFilter => Span::raw("/").fg(colors::FILTER_ACCENT), + PromptMode::NewLit => Span::raw("?").fg(colors::FILTER_ACCENT), + PromptMode::Shell => Span::raw("!").fg(colors::SHELL_ACCENT), }; let input = Paragraph::new(Line::from(match self.inner.cursor() { Cursor::Singleton(_) => { - vec![Span::from(c), Span::from(self.inner.buf())] + vec![indicator, Span::raw(self.inner.buf())] } Cursor::Selection(start, end, _) => vec![ - Span::from(c), - Span::from(&self.inner.buf()[..start]), - Span::from(&self.inner.buf()[start..end]).bg(colors::COMMAND_BAR_SELECT), - Span::from(&self.inner.buf()[end..]), + indicator, + Span::raw(&self.inner.buf()[..start]), + Span::raw(&self.inner.buf()[start..end]).bg(colors::COMMAND_BAR_SELECT), + Span::raw(&self.inner.buf()[end..]), ], })) .bg(colors::BG); diff --git a/crates/cli/colors.rs b/crates/cli/colors.rs index 5ba9ce0..870074a 100644 --- a/crates/cli/colors.rs +++ b/crates/cli/colors.rs @@ -25,6 +25,8 @@ pub const COMMAND_ACCENT: Color = Color::Indexed(48); pub const SELECT_ACCENT: Color = Color::Indexed(170); pub const FILTER_ACCENT: Color = Color::Indexed(178); +pub const SHELL_ACCENT: Color = Color::Indexed(161); + pub const SEARCH_COLOR_LIST: &[Color] = &[ Color::Red, Color::Indexed(178), // Orange diff --git a/crates/cli/components/mux.rs b/crates/cli/components/mux.rs index c12ac26..6dcc929 100644 --- a/crates/cli/components/mux.rs +++ b/crates/cli/components/mux.rs @@ -91,4 +91,9 @@ impl MultiplexerApp { pub fn set_mode(&mut self, mode: MultiplexerMode) { self.mode = mode; } + + pub fn clear(&mut self) { + self.views.clear(); + self.active = 0; + } }