diff --git a/Cargo.lock b/Cargo.lock index 460d149..95f0730 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -857,6 +857,7 @@ dependencies = [ "jaq-json", "jaq-std", "promkit-widgets", + "rustix", "serde", "termcfg", "tokio", diff --git a/Cargo.toml b/Cargo.toml index eef4861..5d5743e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,6 +17,7 @@ duration-string = { version = "0.5.3", features = ["serde"] } derive_builder = "0.20.2" dirs = "6.0.0" futures = "0.3.32" +rustix = { version = "1.1.4", features = ["stdio"] } serde = "1.0.228" termcfg = { version = "0.2.0", features = ["crossterm_0_29_0"] } tokio = { version = "1.49.0", features = ["full"] } diff --git a/README.md b/README.md index bea138c..e6a0226 100644 --- a/README.md +++ b/README.md @@ -111,8 +111,14 @@ cargo install jnv ```bash cat data.json | jnv + # or jnv data.json + +# or write current result to stdout on exit (UNIX only) +cat data.json | jnv --write-to-stdout | some-command +# and also output to file +cat data.json | jnv -- --write-to-stdout > result.json ``` ## Keymap @@ -180,6 +186,7 @@ Arguments: Options: -c, --config Path to the configuration file. --default-filter Default jq filter to apply to the input data + --write-to-stdout Write the current JSON result to stdout when exiting -h, --help Print help (see more with '--help') -V, --version Print version ``` @@ -389,9 +396,9 @@ on_completion.down = ["Down", "Tab"] # Keybindings for JSON viewer operations [keybinds.on_json_viewer] # Move up in JSON viewer -up = ["Up", "Ctrl+K"] +up = ["Up", "Ctrl+K", "ScrollUp"] # Move down in JSON viewer -down = ["Down", "Ctrl+J"] +down = ["Down", "Ctrl+J", "ScrollDown"] # Move to the top of JSON viewer move_to_head = ["Ctrl+L"] # Move to the bottom of JSON viewer diff --git a/src/main.rs b/src/main.rs index 6d900b4..393d505 100644 --- a/src/main.rs +++ b/src/main.rs @@ -17,6 +17,8 @@ use editor::Editor; mod config; mod json; use json::JsonStreamProvider; +mod stdout_redirect; +use stdout_redirect::StdoutRedirect; mod processor; use processor::{ init::ViewInitializer, monitor::ContextMonitor, spinner::SpinnerSpawner, Context, Processor, @@ -70,6 +72,12 @@ pub struct Args { " )] default_filter: Option, + + #[arg( + long = "write-to-stdout", + help = "Write the current JSON result to stdout when exiting" + )] + write_to_stdout: bool, } /// Parses the input based on the provided arguments. @@ -194,8 +202,10 @@ async fn main() -> anyhow::Result<()> { config.keybinds.on_editor.clone(), ); + let mut stdout_redirect = StdoutRedirect::try_new_for_tui(args.write_to_stdout)?; + // TODO: put all logics here. - prompt::run( + let maybe_output = prompt::run( item, config.reactivity_control, provider, @@ -203,8 +213,20 @@ async fn main() -> anyhow::Result<()> { loading_suggestions_task, config.no_hint, config.keybinds, + args.write_to_stdout, ) - .await?; + .await; + + stdout_redirect.restore()?; + let maybe_output = maybe_output?; + + if let Some(output) = maybe_output { + let mut stdout = io::stdout(); + stdout.write_all(output.as_bytes())?; + if !output.ends_with('\n') { + stdout.write_all(b"\n")?; + } + } Ok(()) } diff --git a/src/prompt.rs b/src/prompt.rs index 911d67d..2f55685 100644 --- a/src/prompt.rs +++ b/src/prompt.rs @@ -123,7 +123,8 @@ pub async fn run( loading_suggestions_task: JoinHandle>, no_hint: bool, keybinds: Keybinds, -) -> anyhow::Result<()> { + write_to_stdout: bool, +) -> anyhow::Result> { enable_raw_mode()?; execute!(io::stdout(), cursor::Hide)?; @@ -385,11 +386,11 @@ pub async fn run( }) }; + let shared_visualizer = Arc::new(Mutex::new(initializing.await?)); let processor_task: JoinHandle> = { let shared_renderer = shared_renderer.clone(); let shared_editor = shared_editor.clone(); - let visualizer = initializing.await?; - let shared_visualizer = Arc::new(Mutex::new(visualizer)); + let shared_visualizer = shared_visualizer.clone(); tokio::spawn(async move { loop { tokio::select! { @@ -460,6 +461,13 @@ pub async fn run( main_task.await??; + let output = if write_to_stdout { + let visualizer = shared_visualizer.lock().await; + Some(visualizer.content_to_copy().await) + } else { + None + }; + loading_suggestions_task.abort(); spinning.abort(); query_debouncer.abort(); @@ -470,5 +478,5 @@ pub async fn run( execute!(io::stdout(), cursor::Show, DisableMouseCapture)?; disable_raw_mode()?; - Ok(()) + Ok(output) } diff --git a/src/stdout_redirect.rs b/src/stdout_redirect.rs new file mode 100644 index 0000000..dc0e67d --- /dev/null +++ b/src/stdout_redirect.rs @@ -0,0 +1,89 @@ +use std::{ + fs::File, + io::{self, IsTerminal}, +}; + +use anyhow::anyhow; + +#[cfg(unix)] +use rustix::{ + io::dup, + stdio::{dup2_stdout, stdout}, +}; +#[cfg(unix)] +use std::os::fd::OwnedFd; + +/// Redirects `stdout` to the controlling TTY while the TUI is running. +/// +/// This is needed when `--write-to-stdout` is used with piped stdout, e.g. +/// `cat data.json | jnv --write-to-stdout | pbcopy`. +/// During interactive rendering, cursor controls and screen output must go to +/// a terminal, not to the downstream pipe. +/// +/// Unix flow: +/// 1. Save current `fd=1` with `dup` (`saved_stdout`). +/// 2. Replace `fd=1` with `/dev/tty` via `dup2_stdout` for TUI rendering. +/// 3. Restore the original `fd=1` on exit. +/// +/// After restore, writing to `io::stdout()` again goes to the original pipe +/// (for example `pbcopy`) so the final JSON can be emitted there. +/// +/// Note: +/// `stdout` is file descriptor 1 (FD 1), not the screen itself. +/// Its destination is chosen by the shell when the process starts: +/// terminal (`cmd`), file (`cmd > out.txt`), or pipe (`cmd | next`). +/// Therefore, we cannot just write to `stdout` for TUI rendering when it's piped. +/// Instead, we must write directly to the terminal device (`/dev/tty` on Unix). +pub struct StdoutRedirect { + #[cfg(unix)] + saved_stdout: Option, +} + +impl StdoutRedirect { + pub fn try_new_for_tui(write_to_stdout: bool) -> anyhow::Result { + if !write_to_stdout || io::stdout().is_terminal() { + return Ok(Self { + #[cfg(unix)] + saved_stdout: None, + }); + } + + #[cfg(unix)] + { + let tty = File::options() + .read(true) + .write(true) + .open("/dev/tty") + .map_err(|e| anyhow!("Failed to open /dev/tty for TUI rendering: {e}"))?; + + let saved_fd = dup(stdout()).map_err(|e| anyhow!("Failed to duplicate stdout: {e}"))?; + dup2_stdout(&tty).map_err(|e| anyhow!("Failed to redirect stdout to /dev/tty: {e}"))?; + + Ok(Self { + saved_stdout: Some(saved_fd), + }) + } + + #[cfg(not(unix))] + { + Err(anyhow!( + "`--write-to-stdout` with piped stdout is not supported on this platform" + )) + } + } + + pub fn restore(&mut self) -> anyhow::Result<()> { + #[cfg(unix)] + if let Some(saved_stdout) = self.saved_stdout.take() { + dup2_stdout(&saved_stdout).map_err(|e| anyhow!("Failed to restore stdout: {e}"))?; + } + + Ok(()) + } +} + +impl Drop for StdoutRedirect { + fn drop(&mut self) { + let _ = self.restore(); + } +}