211 lines
7.2 KiB
Rust
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(())
|
|
}
|