resticview/src/main.rs

211 lines
7.2 KiB
Rust

//! 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<PathBuf>,
/// 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<Self> {
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<Option<usize>> {
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<ListItem> = 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(())
}