diff --git a/Cargo.lock b/Cargo.lock index da1d472..bdce05c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -818,7 +818,7 @@ dependencies = [ [[package]] name = "sigrs" -version = "0.1.1" +version = "0.1.2" dependencies = [ "anyhow", "clap", diff --git a/Cargo.toml b/Cargo.toml index eaf2f25..63a9164 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "sigrs" -version = "0.1.1" +version = "0.1.2" authors = ["ynqa "] edition = "2021" description = "Interactive grep (for streaming)" diff --git a/README.md b/README.md index ec60cf7..738321a 100644 --- a/README.md +++ b/README.md @@ -12,8 +12,16 @@ Interactive grep - Interactive grep (for streaming) - *sig* allows users to interactively search through (streaming) data, updating results in real-time. +- Re-execute command + - If `--cmd` is specified instread of piping data to *sig*, + the command will be executed on initial and retries. + - This feature is designed to address the issue where data streams + past while the user is fine-tuning the search criteria. + In other words, even if the data has already passed, + executing the command again allows + the retrieval of the data for re-evaluation. - Archived mode - - In Archived mode, since there is no seeking capability + - In archived mode, since there is no seeking capability for streaming data received through a pipe, it is not possible to search backwards without exiting the process. Therefore, in *sig*, the latest N entries of streaming data are saved, @@ -79,11 +87,28 @@ in } ``` +## Examples + +```bash +stern --context kind-kind etcd |& sig +# or +sig --cmd "stern --context kind-kind etcd" # this is able to retry command by ctrl+r. +``` + +### Archived mode + +```bash +cat README.md |& sig -a +# or +sig -a --cmd "cat README.md" +``` + ## Keymap | Key | Action | :- | :- | Ctrl + C | Exit `sig` +| Ctrl + R | Retry command if `--cmd` is specified | Ctrl + F | Enter Archived mode | | Move the cursor one character to the left | | Move the cursor one character to the right @@ -111,6 +136,17 @@ Interactive grep (for streaming) Usage: sig [OPTIONS] +Examples: + +$ stern --context kind-kind etcd |& sig +Or the method to retry command by pressing ctrl+r: +$ sig --cmd "stern --context kind-kind etcd" + +Archived mode: +$ cat README.md |& sig -a +Or +$ sig -a --cmd "cat README.md" + Options: --retrieval-timeout Timeout to read a next line from the stream in milliseconds. [default: 10] @@ -122,6 +158,8 @@ Options: Archived mode to grep through static data. -i, --ignore-case Case insensitive search. + --cmd + Command to execute on initial and retries. -h, --help Print help (see more with '--help') -V, --version diff --git a/src/archived.rs b/src/archived.rs index e0ec2e5..998ebb2 100644 --- a/src/archived.rs +++ b/src/archived.rs @@ -20,6 +20,7 @@ struct Archived { lines: Snapshot, highlight_style: ContentStyle, case_insensitive: bool, + cmd: Option, } impl promkit::Finalizer for Archived { @@ -39,7 +40,12 @@ impl promkit::Renderer for Archived { } fn evaluate(&mut self, event: &Event) -> anyhow::Result { - let signal = self.keymap.get()(event, &mut self.text_editor_snapshot, &mut self.lines); + let signal = self.keymap.get()( + event, + &mut self.text_editor_snapshot, + &mut self.lines, + self.cmd.clone(), + ); if self .text_editor_snapshot .after() @@ -85,6 +91,7 @@ pub fn run( lines: listbox::State, highlight_style: ContentStyle, case_insensitive: bool, + cmd: Option, ) -> anyhow::Result<()> { Prompt { renderer: Archived { @@ -93,6 +100,7 @@ pub fn run( lines: Snapshot::new(lines), highlight_style, case_insensitive, + cmd, }, } .run() diff --git a/src/archived/keymap.rs b/src/archived/keymap.rs index fafd36d..830cd12 100644 --- a/src/archived/keymap.rs +++ b/src/archived/keymap.rs @@ -9,17 +9,33 @@ pub type Keymap = fn( &Event, &mut Snapshot, &mut Snapshot, + Option, ) -> anyhow::Result; pub fn default( event: &Event, text_editor_snapshot: &mut Snapshot, logs_snapshot: &mut Snapshot, + cmd: Option, ) -> anyhow::Result { let text_editor_state = text_editor_snapshot.after_mut(); let logs_state = logs_snapshot.after_mut(); match event { + Event::Key(KeyEvent { + code: KeyCode::Char('r'), + modifiers: KeyModifiers::CONTROL, + kind: KeyEventKind::Press, + state: KeyEventState::NONE, + }) => { + if cmd.is_some() { + // Exiting archive mode here allows + // the caller to re-enter streaming mode, + // as it is running in an infinite loop. + return Ok(PromptSignal::Quit); + } + } + Event::Key(KeyEvent { code: KeyCode::Char('c'), modifiers: KeyModifiers::CONTROL, diff --git a/src/cmd.rs b/src/cmd.rs new file mode 100644 index 0000000..ba3295c --- /dev/null +++ b/src/cmd.rs @@ -0,0 +1,54 @@ +use std::process::Stdio; + +use tokio::{ + io::{AsyncBufReadExt, BufReader}, + process::Command, + sync::mpsc, + time::{timeout, Duration}, +}; +use tokio_util::sync::CancellationToken; + +pub async fn execute( + cmdstr: &str, + tx: mpsc::Sender, + retrieval_timeout: Duration, + canceled: CancellationToken, +) -> anyhow::Result<()> { + let args: Vec<&str> = cmdstr.split_whitespace().collect(); + let mut child = Command::new(args[0]) + .args(&args[1..]) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn()?; + + let stdout = child + .stdout + .take() + .ok_or_else(|| anyhow::anyhow!("stdout is not available"))?; + let stderr = child + .stderr + .take() + .ok_or_else(|| anyhow::anyhow!("stderr is not available"))?; + let mut stdout_reader = BufReader::new(stdout).lines(); + let mut stderr_reader = BufReader::new(stderr).lines(); + + while !canceled.is_cancelled() { + tokio::select! { + stdout_res = timeout(retrieval_timeout, stdout_reader.next_line()) => { + if let Ok(Ok(Some(line))) = stdout_res { + let escaped = strip_ansi_escapes::strip_str(line.replace(['\n', '\t'], " ")); + tx.send(escaped).await?; + } + }, + stderr_res = timeout(retrieval_timeout, stderr_reader.next_line()) => { + if let Ok(Ok(Some(line))) = stderr_res { + let escaped = strip_ansi_escapes::strip_str(line.replace(['\n', '\t'], " ")); + tx.send(escaped).await?; + } + } + } + } + + child.kill().await?; + Ok(()) +} diff --git a/src/main.rs b/src/main.rs index 3ef6e8a..cd49659 100644 --- a/src/main.rs +++ b/src/main.rs @@ -19,13 +19,44 @@ use promkit::{ }; mod archived; +mod cmd; mod sig; mod stdin; mod terminal; +#[derive(Eq, PartialEq)] +pub enum Signal { + Continue, + GotoArchived, + GotoStreaming, +} + /// Interactive grep (for streaming) #[derive(Parser)] #[command(name = "sig", version)] +#[command( + name = "sig", + version, + help_template = " +{about} + +Usage: {usage} + +Examples: + +$ stern --context kind-kind etcd |& sig +Or the method to retry command by pressing ctrl+r: +$ sig --cmd \"stern --context kind-kind etcd\" + +Archived mode: +$ cat README.md |& sig -a +Or +$ sig -a --cmd \"cat README.md\" + +Options: +{options} +" +)] pub struct Args { #[arg( long = "retrieval-timeout", @@ -71,6 +102,14 @@ pub struct Args { help = "Case insensitive search." )] pub case_insensitive: bool, + + #[arg( + long = "cmd", + help = "Command to execute on initial and retries.", + long_help = "This command is invoked initially and + whenever a retry is triggered according to key mappings." + )] + pub cmd: Option, } impl Drop for Args { @@ -92,14 +131,26 @@ async fn main() -> anyhow::Result<()> { if args.archived { let (tx, mut rx) = mpsc::channel(1); - tokio::spawn(async move { - stdin::streaming( - tx, - Duration::from_millis(args.retrieval_timeout_millis), - CancellationToken::new(), - ) - .await - }); + if let Some(cmd) = args.cmd.clone() { + tokio::spawn(async move { + cmd::execute( + &cmd, + tx, + Duration::from_millis(args.retrieval_timeout_millis), + CancellationToken::new(), + ) + .await + }); + } else { + tokio::spawn(async move { + stdin::streaming( + tx, + Duration::from_millis(args.retrieval_timeout_millis), + CancellationToken::new(), + ) + .await + }); + } let mut queue = VecDeque::with_capacity(args.queue_capacity); loop { @@ -149,9 +200,11 @@ async fn main() -> anyhow::Result<()> { }, highlight_style, args.case_insensitive, + // In archived mode, command for retry is meaningless. + None, )?; } else { - let queue = sig::run( + while let Ok((signal, queue)) = sig::run( text_editor::State { texteditor: Default::default(), history: Default::default(), @@ -169,39 +222,62 @@ async fn main() -> anyhow::Result<()> { Duration::from_millis(args.render_interval_millis), args.queue_capacity, args.case_insensitive, + args.cmd.clone(), ) - .await?; + .await + { + crossterm::execute!( + io::stdout(), + crossterm::terminal::Clear(crossterm::terminal::ClearType::All), + crossterm::terminal::Clear(crossterm::terminal::ClearType::Purge), + cursor::MoveTo(0, 0), + )?; - crossterm::execute!( - io::stdout(), - crossterm::terminal::Clear(crossterm::terminal::ClearType::All), - crossterm::terminal::Clear(crossterm::terminal::ClearType::Purge), - cursor::MoveTo(0, 0), - )?; + match signal { + Signal::GotoArchived => { + archived::run( + text_editor::State { + texteditor: Default::default(), + history: Default::default(), + prefix: String::from("❯❯❯ "), + mask: Default::default(), + prefix_style: StyleBuilder::new().fgc(Color::DarkBlue).build(), + active_char_style: StyleBuilder::new().bgc(Color::DarkCyan).build(), + inactive_char_style: StyleBuilder::new().build(), + edit_mode: Default::default(), + word_break_chars: Default::default(), + lines: Default::default(), + }, + listbox::State { + listbox: listbox::Listbox::from_iter(queue), + cursor: String::from("❯ "), + active_item_style: None, + inactive_item_style: None, + lines: Default::default(), + }, + highlight_style, + args.case_insensitive, + args.cmd.clone(), + )?; - archived::run( - text_editor::State { - texteditor: Default::default(), - history: Default::default(), - prefix: String::from("❯❯❯ "), - mask: Default::default(), - prefix_style: StyleBuilder::new().fgc(Color::DarkBlue).build(), - active_char_style: StyleBuilder::new().bgc(Color::DarkCyan).build(), - inactive_char_style: StyleBuilder::new().build(), - edit_mode: Default::default(), - word_break_chars: Default::default(), - lines: Default::default(), - }, - listbox::State { - listbox: listbox::Listbox::from_iter(queue), - cursor: String::from("❯ "), - active_item_style: None, - inactive_item_style: None, - lines: Default::default(), - }, - highlight_style, - args.case_insensitive, - )?; + // Re-enable raw mode and hide the cursor again here + // because they are disabled and shown, respectively, by promkit. + enable_raw_mode()?; + execute!(io::stdout(), cursor::Hide)?; + + crossterm::execute!( + io::stdout(), + crossterm::terminal::Clear(crossterm::terminal::ClearType::All), + crossterm::terminal::Clear(crossterm::terminal::ClearType::Purge), + cursor::MoveTo(0, 0), + )?; + } + Signal::GotoStreaming => { + continue; + } + _ => {} + } + } } Ok(()) diff --git a/src/sig.rs b/src/sig.rs index 57911d3..4081108 100644 --- a/src/sig.rs +++ b/src/sig.rs @@ -15,11 +15,11 @@ use promkit::{ crossterm::{self, event, style::ContentStyle}, grapheme::StyledGraphemes, switch::ActiveKeySwitcher, - text_editor, PaneFactory, PromptSignal, + text_editor, PaneFactory, }; mod keymap; -use crate::{stdin, terminal::Terminal}; +use crate::{cmd, stdin, terminal::Terminal, Signal}; fn matched(queries: &[&str], line: &str, case_insensitive: bool) -> anyhow::Result> { let mut matched = Vec::new(); @@ -78,7 +78,8 @@ pub async fn run( render_interval: Duration, queue_capacity: usize, case_insensitive: bool, -) -> anyhow::Result> { + cmd: Option, +) -> anyhow::Result<(Signal, VecDeque)> { let keymap = ActiveKeySwitcher::new("default", keymap::default); let size = crossterm::terminal::size()?; @@ -95,8 +96,11 @@ pub async fn run( let canceler = CancellationToken::new(); let canceled = canceler.clone(); - let streaming = - tokio::spawn(async move { stdin::streaming(tx, retrieval_timeout, canceled).await }); + let streaming = if let Some(cmd) = cmd.clone() { + tokio::spawn(async move { cmd::execute(&cmd, tx, retrieval_timeout, canceled).await }) + } else { + tokio::spawn(async move { stdin::streaming(tx, retrieval_timeout, canceled).await }) + }; let keeping: JoinHandle>> = tokio::spawn(async move { let mut queue = VecDeque::with_capacity(queue_capacity); @@ -135,11 +139,12 @@ pub async fn run( Ok(queue) }); + let mut signal: Signal; loop { let event = event::read()?; let mut text_editor = shared_text_editor.write().await; - let signal = keymap.get()(&event, &mut text_editor)?; - if signal == PromptSignal::Quit { + signal = keymap.get()(&event, &mut text_editor, cmd.clone())?; + if signal == Signal::GotoArchived || signal == Signal::GotoStreaming { break; } @@ -152,5 +157,5 @@ pub async fn run( canceler.cancel(); let _: anyhow::Result<(), anyhow::Error> = streaming.await?; - keeping.await? + Ok((signal, keeping.await??)) } diff --git a/src/sig/keymap.rs b/src/sig/keymap.rs index 7747a53..69b6078 100644 --- a/src/sig/keymap.rs +++ b/src/sig/keymap.rs @@ -1,16 +1,33 @@ use promkit::{ crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers}, - text_editor, PromptSignal, + text_editor, }; -pub fn default(event: &Event, state: &mut text_editor::State) -> anyhow::Result { +use crate::Signal; + +pub fn default( + event: &Event, + state: &mut text_editor::State, + cmd: Option, +) -> anyhow::Result { match event { Event::Key(KeyEvent { code: KeyCode::Char('f'), modifiers: KeyModifiers::CONTROL, kind: KeyEventKind::Press, state: KeyEventState::NONE, - }) => return Ok(PromptSignal::Quit), + }) => return Ok(Signal::GotoArchived), + + Event::Key(KeyEvent { + code: KeyCode::Char('r'), + modifiers: KeyModifiers::CONTROL, + kind: KeyEventKind::Press, + state: KeyEventState::NONE, + }) => { + if cmd.is_some() { + return Ok(Signal::GotoStreaming); + } + } Event::Key(KeyEvent { code: KeyCode::Char('c'), @@ -82,5 +99,5 @@ pub fn default(event: &Event, state: &mut text_editor::State) -> anyhow::Result< _ => (), } - Ok(PromptSignal::Continue) + Ok(Signal::Continue) }