diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index 46edb6e..4c3d79a 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -1,3 +1,6 @@ +use std::time::Duration; + +use anyhow::Context; use clap::Parser; mod app; @@ -11,44 +14,93 @@ struct Cli { files: Vec, } +// ── RAII terminal guard ──────────────────────────────────────────── +// +// Holds the ratatui Terminal and guarantees terminal state restoration +// (disable raw mode, leave alternate screen, show cursor) on **every** +// exit path: normal return, `?` error propagation, and panic unwind. +// +// IMPORTANT: `Drop` does **not** run on `std::process::exit` or +// `panic = "abort"`. Therefore we must never call `process::exit` +// inside the guarded scope — use `?` to return `Err` instead. + +type Backend = ratatui::backend::CrosstermBackend; + +struct TerminalGuard { + terminal: ratatui::Terminal, +} + +impl TerminalGuard { + /// Enable raw mode, enter alternate screen, and create the terminal. + /// On partial failure, rolls back any steps that already succeeded. + fn enter() -> anyhow::Result { + crossterm::terminal::enable_raw_mode() + .map_err(|e| anyhow::anyhow!("enable_raw_mode: {e}"))?; + + let mut stdout = std::io::stdout(); + if let Err(e) = crossterm::execute!(stdout, crossterm::terminal::EnterAlternateScreen) { + let _ = crossterm::terminal::disable_raw_mode(); + return Err(anyhow::anyhow!("EnterAlternateScreen: {e}")); + } + + let backend = ratatui::backend::CrosstermBackend::new(stdout); + match ratatui::Terminal::new(backend) { + Ok(terminal) => Ok(Self { terminal }), + Err(e) => { + // Roll back: leave alternate screen + disable raw mode + let _ = crossterm::execute!( + std::io::stdout(), + crossterm::terminal::LeaveAlternateScreen + ); + let _ = crossterm::terminal::disable_raw_mode(); + Err(anyhow::anyhow!("Terminal::new: {e}")) + } + } + } + + fn terminal(&mut self) -> &mut ratatui::Terminal { + &mut self.terminal + } +} + +impl Drop for TerminalGuard { + fn drop(&mut self) { + // Best-effort cleanup; suppress errors (Drop must not panic). + let _ = crossterm::terminal::disable_raw_mode(); + let _ = crossterm::execute!( + self.terminal.backend_mut(), + crossterm::terminal::LeaveAlternateScreen + ); + let _ = self.terminal.show_cursor(); + } +} + +// ── main ─────────────────────────────────────────────────────────── + fn main() -> anyhow::Result<()> { let cli = Cli::parse(); - crossterm::terminal::enable_raw_mode()?; - let mut stdout = std::io::stdout(); - crossterm::execute!(stdout, crossterm::terminal::EnterAlternateScreen)?; - let backend = ratatui::backend::CrosstermBackend::new(stdout); - let mut terminal = ratatui::Terminal::new(backend)?; - + let mut guard = TerminalGuard::enter()?; let mut app = app::App::new(); app.color_config = log_viewer_core::config::ColorConfig::load(); - if let Some(file) = cli.files.first() - && let Err(e) = app.load_file(file) - { - eprintln!("Error loading file: {e}"); - std::process::exit(1); + if let Some(file) = cli.files.first() { + app.load_file(file) + .with_context(|| format!("loading file {file}"))?; } while !app.should_quit { app.poll_background_indexer(); app.poll_file_watcher(); - terminal.draw(|frame| ui::render(frame, &mut app))?; - if crossterm::event::poll(std::time::Duration::from_millis(100))? { + guard.terminal().draw(|frame| ui::render(frame, &mut app))?; + if crossterm::event::poll(Duration::from_millis(100))? { match crossterm::event::read()? { crossterm::event::Event::Key(key) => app.handle_key(key), - crossterm::event::Event::Resize(_w, _h) => {} + crossterm::event::Event::Resize(_, _) => {} _ => {} } } } - crossterm::terminal::disable_raw_mode()?; - crossterm::execute!( - terminal.backend_mut(), - crossterm::terminal::LeaveAlternateScreen - )?; - terminal.show_cursor()?; - Ok(()) }