diff --git a/Cargo.lock b/Cargo.lock index 45b1e3c..0672f71 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -148,6 +148,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + [[package]] name = "bumpalo" version = "3.20.3" @@ -353,6 +362,17 @@ dependencies = [ "phf", ] +[[package]] +name = "ctrlc" +version = "3.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0b1fab2ae45819af2d0731d60f2afe17227ebb1a1538a236da84c93e9a60162" +dependencies = [ + "dispatch2", + "nix 0.31.3", + "windows-sys", +] + [[package]] name = "darling" version = "0.23.0" @@ -452,6 +472,18 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.13.0", + "block2", + "libc", + "objc2", +] + [[package]] name = "document-features" version = "0.2.12" @@ -850,6 +882,7 @@ dependencies = [ "chrono", "clap", "crossterm", + "ctrlc", "directories", "fuzzy-matcher", "ratatui", @@ -865,7 +898,7 @@ version = "1.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303" dependencies = [ - "nix", + "nix 0.29.0", "winapi", ] @@ -921,6 +954,18 @@ dependencies = [ "memoffset", ] +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags 2.13.0", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "nom" version = "7.1.3" @@ -966,6 +1011,21 @@ dependencies = [ "libc", ] +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + [[package]] name = "once_cell" version = "1.21.4" @@ -1582,7 +1642,7 @@ dependencies = [ "libc", "log", "memmem", - "nix", + "nix 0.29.0", "num-derive", "num-traits", "ordered-float", diff --git a/Cargo.toml b/Cargo.toml index b07020d..d2d2372 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,6 +13,7 @@ categories = ["command-line-utilities"] [dependencies] chrono = "0.4.45" clap = { version = "4.6.1", features = ["derive"] } +ctrlc = "3.4.6" crossterm = "0.29.0" directories = "6.0.0" fuzzy-matcher = "0.3.7" diff --git a/src/app.rs b/src/app.rs index 0d4b0fc..772bf93 100644 --- a/src/app.rs +++ b/src/app.rs @@ -28,6 +28,7 @@ pub struct LogViewer { pub max_lines: usize, pub search: SearchState, pub search_cursor: Option, + pub error: Option, } impl LogViewer { @@ -41,6 +42,7 @@ impl LogViewer { max_lines, search: SearchState::default(), search_cursor: None, + error: None, } } @@ -49,6 +51,10 @@ impl LogViewer { } pub fn push_line(&mut self, line: String) { + if let Some(err) = line.strip_prefix("STDERR:") { + self.error = Some(err.to_string()); + return; + } if self.buffer.len() >= self.max_lines { self.buffer.pop_front(); if self.scroll > 0 { @@ -189,7 +195,7 @@ impl App { .enumerate() .filter_map(|(i, log)| { self.matcher - .fuzzy_match(&log.to_string(), &self.picker_filter) + .fuzzy_match(&log.search_text(), &self.picker_filter) .map(|score| (score, i)) }) .collect(); @@ -244,6 +250,9 @@ impl App { while let Ok(line) = viewer.receiver.try_recv() { viewer.push_line(line); } + if let Some(err) = viewer.error.take() { + self.message = Some(format!("SSH error: {}", err)); + } } } diff --git a/src/discovery.rs b/src/discovery.rs index 977edc3..212a12a 100644 --- a/src/discovery.rs +++ b/src/discovery.rs @@ -36,8 +36,36 @@ impl LogSource { } } + /// Human-readable label shown in the log picker. + pub fn display_name(&self) -> String { + match self { + LogSource::File { path } => { + let basename = std::path::Path::new(path) + .file_name() + .map(|s| s.to_string_lossy().to_string()) + .unwrap_or_else(|| path.clone()); + if let Some(parent) = std::path::Path::new(path).parent() { + let parent_str = parent.to_string_lossy(); + if parent_str == "/var/log" { + return format!("{} (system)", basename); + } else if parent_str.starts_with("/var/log/") || parent_str.starts_with("/opt/") { + return format!("{} ({})", basename, parent_str); + } + } + format!("{} ({})", basename, path) + } + LogSource::Docker { container, image } => format!("{} (docker: {})", container, image), + LogSource::Journal { unit } => format!("{} (journal)", unit), + } + } + + /// Text used for fuzzy searching (label + raw source). + pub fn search_text(&self) -> String { + format!("{} {}", self.display_name(), self) + } + pub fn sort_key(&self) -> String { - format!("{}:{}", self.category(), self) + format!("{}:{}", self.category(), self.display_name()) } } diff --git a/src/main.rs b/src/main.rs index 05f4264..857f839 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,11 +8,12 @@ mod ssh; mod ui; use std::io; +use std::sync::Arc; use std::time::Duration; use clap::{Parser, Subcommand}; use crossterm::{ - event::{DisableMouseCapture, EnableMouseCapture, poll, read}, + event::{poll, read}, execute, terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, }; @@ -48,6 +49,12 @@ enum Command { }, } +fn restore_terminal() { + let _ = disable_raw_mode(); + let _ = execute!(io::stdout(), LeaveAlternateScreen); + let _ = execute!(io::stdout(), crossterm::event::DisableMouseCapture); +} + fn main() -> io::Result<()> { let args = Args::parse(); let command = args.command.unwrap_or_else(|| { @@ -74,30 +81,43 @@ fn main() -> io::Result<()> { enable_raw_mode()?; let mut stdout = io::stdout(); - execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; + execute!(stdout, EnterAlternateScreen)?; let backend = CrosstermBackend::new(stdout); let mut terminal = Terminal::new(backend)?; - let result = run_app(&mut terminal, app, &rt); + // Ensure terminal is restored on panic or Ctrl-C + std::panic::set_hook(Box::new(|info| { + restore_terminal(); + eprintln!("Panic: {}", info); + })); + let running = Arc::new(std::sync::atomic::AtomicBool::new(true)); + let r = running.clone(); + ctrlc::set_handler(move || { + r.store(false, std::sync::atomic::Ordering::SeqCst); + }) + .expect("failed to set Ctrl-C handler"); + + let result = run_app(&mut terminal, app, &rt, running); disable_raw_mode()?; - execute!( - terminal.backend_mut(), - LeaveAlternateScreen, - DisableMouseCapture - )?; + execute!(terminal.backend_mut(), LeaveAlternateScreen)?; terminal.show_cursor()?; result } -fn run_app(terminal: &mut Terminal, mut app: App, rt: &Runtime) -> io::Result<()> +fn run_app( + terminal: &mut Terminal, + mut app: App, + rt: &Runtime, + running: Arc, +) -> io::Result<()> where io::Error: From<::Error>, { let tick_rate = Duration::from_millis(100); - loop { + while running.load(std::sync::atomic::Ordering::SeqCst) { app.update_stream(); if app.loading { @@ -113,4 +133,5 @@ where } } } + Ok(()) } diff --git a/src/ssh.rs b/src/ssh.rs index d94f121..2ad511c 100644 --- a/src/ssh.rs +++ b/src/ssh.rs @@ -52,8 +52,10 @@ impl SshSession { fn base_cmd(&self) -> Command { let mut cmd = Command::new(&self.command); - // Non-interactive mode: do not prompt for host keys or passwords. - cmd.arg("-o").arg("BatchMode=yes"); + // Avoid interactive prompts from ssh itself. Passphrase prompts from the + // SSH agent may still appear in the terminal, but password prompts are + // disabled and unknown host keys are accepted automatically. + cmd.arg("-o").arg("PasswordAuthentication=no"); cmd.arg("-o").arg("StrictHostKeyChecking=accept-new"); cmd.args(&self.target.args); cmd.stdin(Stdio::null()); @@ -104,20 +106,36 @@ impl SshSession { let mut cmd = self.base_cmd(); cmd.arg("--").arg(remote_command); cmd.stdout(Stdio::piped()); - cmd.stderr(Stdio::null()); + cmd.stderr(Stdio::piped()); cmd.kill_on_drop(true); cmd.spawn() } /// Spawn a remote command and return a channel of lines from stdout. + /// Lines coming from stderr are prefixed with `STDERR:` so the UI can + /// surface ssh errors without mixing them into the log output. pub fn stream_lines(&self, remote_command: &str) -> io::Result> { let mut child = self.spawn_stream(remote_command)?; let stdout = child.stdout.take().ok_or_else(|| { io::Error::other("failed to capture stdout") })?; + let stderr = child.stderr.take().ok_or_else(|| { + io::Error::other("failed to capture stderr") + })?; let (tx, rx) = mpsc::unbounded_channel(); tokio::spawn(async move { + let tx_err = tx.clone(); + tokio::spawn(async move { + let reader = BufReader::new(stderr); + let mut lines = reader.lines(); + while let Ok(Some(line)) = lines.next_line().await { + if tx_err.send(format!("STDERR:{}", line)).is_err() { + break; + } + } + }); + let reader = BufReader::new(stdout); let mut lines = reader.lines(); while let Ok(Some(line)) = lines.next_line().await { diff --git a/src/ui.rs b/src/ui.rs index 1f88e61..3926816 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -75,7 +75,7 @@ fn draw_log_list(f: &mut Frame, app: &App, area: ratatui::layout::Rect) { } else { Style::default() }; - ListItem::new(format!("[{}] {}", log.category(), log)).style(style) + ListItem::new(format!("[{}] {}", log.category(), log.display_name())).style(style) }) .collect();