Disable mouse capture, improve SSH error handling, add log labels

This commit is contained in:
SW 2026-06-17 21:51:08 +02:00
parent bafce8e49c
commit 4b7389cfe7
7 changed files with 155 additions and 18 deletions

64
Cargo.lock generated
View file

@ -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",

View file

@ -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"

View file

@ -28,6 +28,7 @@ pub struct LogViewer {
pub max_lines: usize,
pub search: SearchState,
pub search_cursor: Option<usize>,
pub error: Option<String>,
}
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));
}
}
}

View file

@ -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())
}
}

View file

@ -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<B: Backend>(terminal: &mut Terminal<B>, mut app: App, rt: &Runtime) -> io::Result<()>
fn run_app<B: Backend>(
terminal: &mut Terminal<B>,
mut app: App,
rt: &Runtime,
running: Arc<std::sync::atomic::AtomicBool>,
) -> io::Result<()>
where
io::Error: From<<B as Backend>::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(())
}

View file

@ -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<mpsc::UnboundedReceiver<String>> {
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 {

View file

@ -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();