gcp-snap-crab 0.3.0

A minimalist, powerful, terminal-based GCP backup and restore tool written in Rust
Documentation
mod popups;
mod render;
mod widgets;

use popups::{
    render_create_backup_warning_popup, render_error_popup, render_help_popup,
    render_manual_input_popup, render_restore_warning_popup,
};
use render::{
    render_content, render_footer, render_header,
};

use anyhow::Result;
use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers};
use ratatui::{backend::Backend, style::Color, Frame, Terminal};
use std::time::{Duration, Instant};

use crate::app::App;
use crate::types::{AppState, InputMode};

pub(super) const BASE_FG: Color = Color::Rgb(216, 222, 233);
pub(super) const BASE_BG: Color = Color::Rgb(46, 52, 64);
pub(super) const ACCENT_COLOR: Color = Color::Rgb(136, 192, 208);
pub(super) const SUCCESS_COLOR: Color = Color::Rgb(163, 190, 140);
pub(super) const WARNING_COLOR: Color = Color::Rgb(235, 203, 139);
pub(super) const HIGHLIGHT_BG: Color = Color::Rgb(59, 66, 82);
pub(super) const BORDER_COLOR: Color = Color::Rgb(76, 86, 106);
pub(super) const INPUT_TEXT: Color = Color::Rgb(235, 203, 139);

pub async fn run_app<B: Backend>(terminal: &mut Terminal<B>, mut app: App) -> Result<()>
where
    <B as Backend>::Error: Send + Sync + 'static,
{
    app.initialize().await?;
    let mut last_tick = Instant::now();
    let mut last_status_check = Instant::now();
    let tick_rate = Duration::from_millis(250);
    let status_check_interval = Duration::from_secs(5);

    loop {
        terminal.draw(|f| ui(f, &mut app))?;

        let timeout = tick_rate
            .checked_sub(last_tick.elapsed())
            .unwrap_or_else(|| Duration::from_secs(0));

        if crossterm::event::poll(timeout)? {
            if let Event::Key(key) = event::read()? {
                if key.kind == KeyEventKind::Press {
                    match app.input_mode {
                        InputMode::Normal => {
                            if let Err(e) =
                                handle_normal_input(&mut app, key.code, key.modifiers).await
                            {
                                app.state = AppState::Error(e.to_string());
                            }
                        }
                        InputMode::Editing => {
                            handle_edit_input(&mut app, key.code).await?;
                        }
                        InputMode::Filtering => {
                            handle_filter_input(&mut app, key.code).await?;
                        }
                    }
                }
            }
        }

        if last_tick.elapsed() >= tick_rate {
            last_tick = Instant::now();
        }

        if last_status_check.elapsed() >= status_check_interval {
            if app.restore_flow.operation_id.is_some() {
                let _ = app.check_restore_status().await;
            }
            if app.create_backup_flow.operation_id.is_some() {
                let _ = app.check_backup_status().await;
            }
            last_status_check = Instant::now();
        }

        if matches!(app.state, AppState::Quitting) {
            break;
        }
        if matches!(app.state, AppState::Error(_)) && !app.show_help {
            break;
        }
    }

    Ok(())
}

pub async fn handle_normal_input(
    app: &mut App,
    key: KeyCode,
    _modifiers: KeyModifiers,
) -> Result<()> {
    match key {
        KeyCode::Char('q') => {
            app.state = AppState::Quitting;
        }
        KeyCode::Esc => {
            if app.error.is_some() {
                app.error = None;
            } else if app.show_help {
                app.toggle_help();
            } else if app.manual_input_active {
                app.cancel_manual_input();
            } else {
                match app.state {
                    AppState::ConfirmRestore => {
                        app.restore_flow.target_instance = None;
                        app.restore_flow.selected_instance_index = 0;
                        app.state = AppState::SelectingTargetInstance;
                    }
                    AppState::ConfirmCreateBackup => {
                        app.create_backup_flow.config = None;
                        app.state = AppState::EnteringBackupName;
                    }
                    AppState::SelectingSourceInstance => {
                        app.restore_flow.source_project = None;
                        app.restore_flow.instances.clear();
                        app.restore_flow.selected_instance_index = 0;
                        app.state = AppState::SelectingSourceProject;
                    }
                    AppState::SelectingBackup => {
                        app.restore_flow.source_instance = None;
                        app.restore_flow.backups.clear();
                        app.restore_flow.selected_backup_index = 0;
                        app.state = AppState::SelectingSourceInstance;
                    }
                    AppState::SelectingTargetProject => {
                        app.restore_flow.selected_backup = None;
                        app.state = AppState::SelectingBackup;
                    }
                    AppState::SelectingTargetInstance => {
                        app.restore_flow.target_project = None;
                        app.restore_flow.instances.clear();
                        app.restore_flow.selected_instance_index = 0;
                        app.state = AppState::SelectingTargetProject;
                    }
                    AppState::PerformingRestore => {
                        app.state = AppState::SelectingTargetInstance;
                    }
                    AppState::SelectingInstanceForBackup => {
                        app.create_backup_flow.project = None;
                        app.create_backup_flow.instances.clear();
                        app.create_backup_flow.selected_instance_index = 0;
                        app.state = AppState::SelectingProjectForBackup;
                    }
                    AppState::EnteringBackupName => {
                        app.create_backup_flow.instance = None;
                        app.state = AppState::SelectingInstanceForBackup;
                    }
                    AppState::PerformingCreateBackup => {
                        app.state = AppState::ConfirmCreateBackup;
                    }
                    _ => {
                        app.state = AppState::SelectingOperation;
                    }
                }
            }
        }
        KeyCode::Char('/') => {
            if app.input_mode != InputMode::Filtering {
                match app.state {
                    AppState::SelectingSourceInstance
                    | AppState::SelectingTargetInstance
                    | AppState::SelectingInstanceForBackup
                    | AppState::SelectingBackup => {
                        app.input_mode = InputMode::Filtering;
                    }
                    _ => {}
                }
            }
        }
        KeyCode::Char('h') => app.toggle_help(),
        KeyCode::Up => app.move_selection_up(),
        KeyCode::Down => app.move_selection_down(),
        KeyCode::Enter => app.select_current_item().await?,
        KeyCode::Char('m') => match app.state {
            AppState::SelectingSourceProject
            | AppState::SelectingTargetProject
            | AppState::SelectingProjectForBackup => {
                app.start_manual_input("source_project");
            }
            AppState::SelectingSourceInstance
            | AppState::SelectingTargetInstance
            | AppState::SelectingInstanceForBackup => {
                app.start_manual_input("instance");
            }
            AppState::SelectingBackup => {
                app.start_manual_input("backup");
            }
            AppState::EnteringBackupName => {
                app.start_manual_input("backup_name");
            }
            _ => {}
        },
        KeyCode::Char('r') => {
            match app.state {
                AppState::SelectingSourceInstance | AppState::SelectingTargetInstance => {
                    if let Some(project) = &app.restore_flow.source_project.clone() {
                        app.load_instances(project).await?;
                    }
                }
                AppState::SelectingInstanceForBackup => {
                    if let Some(project) = &app.create_backup_flow.project.clone() {
                        app.load_instances(project).await?;
                    }
                }
                AppState::SelectingBackup => {
                    if let (Some(project), Some(instance)) = (
                        &app.restore_flow.source_project.clone(),
                        &app.restore_flow.source_instance.clone(),
                    ) {
                        app.load_backups(project, instance).await?;
                    }
                }
                _ => {}
            }
            if app.restore_flow.operation_id.is_some() {
                app.check_restore_status().await?;
            }
            if app.create_backup_flow.operation_id.is_some() {
                app.check_backup_status().await?;
            }
        }
        KeyCode::Char('n') => {
            app.state = AppState::SelectingOperation;
            app.operation_mode = None;
            app.restore_flow = crate::state::restore_flow::RestoreFlow::new();
            app.create_backup_flow = crate::state::create_backup_flow::CreateBackupFlow::new();
        }
        KeyCode::Char('y') => {
            let text = if let Some(op_id) = &app.restore_flow.operation_id {
                Some(op_id.clone())
            } else if let Some(op_id) = &app.create_backup_flow.operation_id {
                Some(op_id.clone())
            } else if matches!(app.state, AppState::SelectingBackup) {
                app.filtered_backups()
                    .get(app.restore_flow.selected_backup_index)
                    .map(|b| b.id.clone())
            } else {
                app.restore_flow.selected_backup.clone()
            };
            if let Some(text) = text {
                if let Ok(mut clipboard) = arboard::Clipboard::new() {
                    if clipboard.set_text(text.clone()).is_ok() {
                        app.yank_notification = Some((text, std::time::Instant::now()));
                    }
                }
            }
        }
        _ => {}
    }
    Ok(())
}

pub async fn handle_edit_input(app: &mut App, key: KeyCode) -> Result<()> {
    match key {
        KeyCode::Enter => {
            if app.manual_input_active {
                app.finish_manual_input().await?;
            }
        }
        KeyCode::Esc => {
            if app.manual_input_active {
                app.cancel_manual_input();
            } else {
                app.input_mode = InputMode::Normal;
                app.input_buffer.clear();
            }
        }
        KeyCode::Char(c) => {
            if app.manual_input_active {
                app.manual_input_buffer.push(c);
            } else {
                app.input_buffer.push(c);
            }
        }
        KeyCode::Backspace => {
            if app.manual_input_active {
                app.manual_input_buffer.pop();
            } else {
                app.input_buffer.pop();
            }
        }
        _ => {}
    }
    Ok(())
}

pub async fn handle_filter_input(app: &mut App, key: KeyCode) -> Result<()> {
    match key {
        KeyCode::Esc | KeyCode::Enter | KeyCode::Char('/') => {
            app.input_mode = InputMode::Normal;
            if matches!(key, KeyCode::Esc) {
                app.filter_query.clear();
                app.restore_flow.selected_instance_index = 0;
                app.create_backup_flow.selected_instance_index = 0;
                app.restore_flow.selected_backup_index = 0;
            }
        }
        KeyCode::Char(c) => {
            app.filter_query.push(c);
            app.restore_flow.selected_instance_index = 0;
            app.create_backup_flow.selected_instance_index = 0;
            app.restore_flow.selected_backup_index = 0;
        }
        KeyCode::Backspace => {
            app.filter_query.pop();
            app.restore_flow.selected_instance_index = 0;
            app.create_backup_flow.selected_instance_index = 0;
            app.restore_flow.selected_backup_index = 0;
        }
        _ => {}
    }
    Ok(())
}

fn ui(f: &mut Frame, app: &mut App) {
    use ratatui::layout::{Constraint, Direction, Layout};

    let main_chunks = Layout::default()
        .direction(Direction::Vertical)
        .constraints([
            Constraint::Length(3),
            Constraint::Min(0),
            Constraint::Length(3),
        ])
        .split(f.area());

    render_header(f, main_chunks[0], app);
    render_content(f, main_chunks[1], app);
    render_footer(f, main_chunks[2], app);

    if app.show_help {
        render_help_popup(f, app);
    }
    if app.manual_input_active {
        render_manual_input_popup(f, app);
    }
    if matches!(app.state, AppState::ConfirmRestore) {
        render_restore_warning_popup(f, app);
    }
    if matches!(app.state, AppState::ConfirmCreateBackup) {
        render_create_backup_warning_popup(f, app);
    }
    if app.error.is_some() {
        render_error_popup(f, app);
    }
}