Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"] }
Expand Down
11 changes: 9 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -180,6 +186,7 @@ Arguments:
Options:
-c, --config <CONFIG_FILE> Path to the configuration file.
--default-filter <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
```
Expand Down Expand Up @@ -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
Expand Down
26 changes: 24 additions & 2 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -70,6 +72,12 @@ pub struct Args {
"
)]
default_filter: Option<String>,

#[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.
Expand Down Expand Up @@ -194,17 +202,31 @@ 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,
editor,
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(())
}
16 changes: 12 additions & 4 deletions src/prompt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@
}),
..Default::default()
},
..Default::default()

Check warning on line 73 in src/prompt.rs

View workflow job for this annotation

GitHub Actions / test

struct update has no effect, all the fields in the struct have already been specified
},
Err(e) => text::State {
text: Text::from(format!("Failed to copy to clipboard: {e}")),
Expand All @@ -81,7 +81,7 @@
}),
..Default::default()
},
..Default::default()

Check warning on line 84 in src/prompt.rs

View workflow job for this annotation

GitHub Actions / test

struct update has no effect, all the fields in the struct have already been specified
},
},
// arboard fails (in the specific environment like linux?) on Clipboard::new()
Expand All @@ -96,7 +96,7 @@
}),
..Default::default()
},
..Default::default()

Check warning on line 99 in src/prompt.rs

View workflow job for this annotation

GitHub Actions / test

struct update has no effect, all the fields in the struct have already been specified
},
}
}
Expand All @@ -123,7 +123,8 @@
loading_suggestions_task: JoinHandle<anyhow::Result<()>>,
no_hint: bool,
keybinds: Keybinds,
) -> anyhow::Result<()> {
write_to_stdout: bool,
) -> anyhow::Result<Option<String>> {
enable_raw_mode()?;
execute!(io::stdout(), cursor::Hide)?;

Expand Down Expand Up @@ -243,7 +244,7 @@
}),
..Default::default()
},
..Default::default()

Check warning on line 247 in src/prompt.rs

View workflow job for this annotation

GitHub Actions / test

struct update has no effect, all the fields in the struct have already been specified
}.create_pane(size.0, size.1),
),
]).render().await?;
Expand Down Expand Up @@ -274,7 +275,7 @@
}),
..Default::default()
},
..Default::default()

Check warning on line 278 in src/prompt.rs

View workflow job for this annotation

GitHub Actions / test

struct update has no effect, all the fields in the struct have already been specified
}.create_pane(size.0, size.1)),
]).render().await?;
}
Expand Down Expand Up @@ -385,11 +386,11 @@
})
};

let shared_visualizer = Arc::new(Mutex::new(initializing.await?));
let processor_task: JoinHandle<anyhow::Result<()>> = {
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! {
Expand Down Expand Up @@ -460,6 +461,13 @@

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();
Expand All @@ -470,5 +478,5 @@
execute!(io::stdout(), cursor::Show, DisableMouseCapture)?;
disable_raw_mode()?;

Ok(())
Ok(output)
}
89 changes: 89 additions & 0 deletions src/stdout_redirect.rs
Original file line number Diff line number Diff line change
@@ -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<OwnedFd>,
}

impl StdoutRedirect {
pub fn try_new_for_tui(write_to_stdout: bool) -> anyhow::Result<Self> {
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();
}
}
Loading