//! resticview — a TUI browser for restic backup repositories. mod app; mod envfile; mod restic; mod tree; mod ui; use std::io::{self, stdout}; use std::path::PathBuf; use std::time::Duration; use anyhow::{Context, Result}; use clap::Parser; use crossterm::event::{ self, EnableMouseCapture, Event, KeyCode, KeyEventKind, MouseEventKind, }; use crossterm::execute; use crossterm::terminal::{ EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode, }; use ratatui::backend::CrosstermBackend; use ratatui::layout::Constraint; use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, List, ListItem, ListState}; use ratatui::{Terminal, layout::Layout}; use crate::app::App; use crate::envfile::EnvFile; #[derive(Parser)] #[command( name = "resticview", version, about = "TUI browser for restic backup repositories" )] struct Cli { /// Directory containing restic env files (hidden dotfiles, also in hidden subdirs) #[arg(long, value_name = "DIR")] env_dir: Option, /// Show the env file picker even if only one env file exists #[arg(long)] select: bool, } fn main() -> Result<()> { let cli = Cli::parse(); let env_dir = cli.env_dir.unwrap_or_else(|| { dirs::home_dir() .expect("could not determine home directory") .join(".restic") }); let files = envfile::scan(&env_dir) .with_context(|| format!("failed to read env dir {}", env_dir.display()))?; if files.is_empty() { eprintln!( "resticview: no env files found in {} (expected hidden files like .env, also inside hidden subdirectories)", env_dir.display() ); std::process::exit(1); } let selected = if files.len() == 1 && !cli.select { Some(0) } else { run_picker(&files)? }; let Some(idx) = selected else { return Ok(()); // aborted in the picker }; let env_file = files.into_iter().nth(idx).expect("index from picker"); if env_file.vars.is_empty() { eprintln!( "resticview: {} contains no KEY=VALUE pairs", env_file.rel_path ); std::process::exit(1); } run_tui(env_file) } /// Restore the terminal on drop, whatever happens. struct TerminalGuard; impl TerminalGuard { fn new() -> io::Result { enable_raw_mode()?; execute!(stdout(), EnterAlternateScreen, EnableMouseCapture)?; Ok(Self) } } impl Drop for TerminalGuard { fn drop(&mut self) { let _ = disable_raw_mode(); let _ = execute!(stdout(), LeaveAlternateScreen, crossterm::event::DisableMouseCapture); } } /// Env file picker: shown when multiple env files exist (or with --select). /// Returns the chosen index, or None when aborted. fn run_picker(files: &[EnvFile]) -> Result> { let _guard = TerminalGuard::new()?; let mut terminal = Terminal::new(CrosstermBackend::new(stdout()))?; let mut state = ListState::default().with_selected(Some(0)); let len = files.len(); loop { terminal.draw(|frame| { let chunks = Layout::vertical([Constraint::Min(3), Constraint::Length(1)]) .split(frame.area()); let items: Vec = files .iter() .map(|f| { ListItem::new(Line::from(vec![ Span::styled( f.rel_path.clone(), Style::default().fg(Color::Yellow), ), Span::raw(" "), Span::styled( f.get("RESTIC_REPOSITORY").unwrap_or("").to_string(), Style::default().fg(Color::Gray), ), ])) }) .collect(); let list = List::new(items) .block(Block::bordered().title("Select repository")) .highlight_style( Style::default() .bg(Color::DarkGray) .add_modifier(Modifier::BOLD), ) .highlight_symbol("> "); frame.render_stateful_widget(list, chunks[0], &mut state); frame.render_widget( ratatui::widgets::Paragraph::new("Enter select · q/Esc quit") .style(Style::default().fg(Color::DarkGray)), chunks[1], ); })?; if event::poll(Duration::from_millis(200))? { match event::read()? { Event::Key(key) if key.kind == KeyEventKind::Press => { let cur = state.selected().unwrap_or(0); match key.code { KeyCode::Char('q') | KeyCode::Esc => return Ok(None), KeyCode::Enter => return Ok(state.selected()), KeyCode::Char('j') | KeyCode::Down => { state.select(Some((cur + 1).min(len - 1))); } KeyCode::Char('k') | KeyCode::Up => { state.select(Some(cur.saturating_sub(1))); } KeyCode::Char('g') => state.select(Some(0)), KeyCode::Char('G') => state.select(Some(len - 1)), KeyCode::Char('c') if key .modifiers .contains(crossterm::event::KeyModifiers::CONTROL) => { return Ok(None) } _ => {} } } Event::Mouse(mouse) => { let cur = state.selected().unwrap_or(0); match mouse.kind { MouseEventKind::ScrollUp => { state.select(Some(cur.saturating_sub(1))); } MouseEventKind::ScrollDown => { state.select(Some((cur + 1).min(len - 1))); } _ => {} } } _ => {} } } } } fn run_tui(env_file: EnvFile) -> Result<()> { let _guard = TerminalGuard::new()?; let mut terminal = Terminal::new(CrosstermBackend::new(stdout()))?; terminal.clear().context("failed to clear terminal")?; let mut app = App::new(env_file); loop { terminal .draw(|frame| ui::draw(frame, &mut app)) .context("failed to draw")?; app.drain_messages(); if event::poll(Duration::from_millis(80))? { match event::read()? { Event::Key(key) if key.kind == KeyEventKind::Press => app.on_key(key), Event::Mouse(mouse) => app.on_mouse(mouse.kind), Event::Resize(_, _) => {} // next draw picks up the new size _ => {} } } app.tick = app.tick.wrapping_add(1); if app.should_quit { app.cancel_background(); break; } } Ok(()) }