safari 1.0.0

Terminal UI for capturing and restoring Safari sessions on macOS
Documentation
use anyhow::Result;
use crossterm::{
    event::{
        self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind, KeyModifiers,
    },
    execute,
    terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{
    backend::{Backend, CrosstermBackend},
    Terminal,
};
use std::{io, time::Duration};

pub mod app;
pub mod ui;

use crate::backend::{safari, storage};
use app::{App, CurrentScreen, PendingAction};

pub fn run() -> Result<()> {
    enable_raw_mode()?;
    let mut stdout = io::stdout();
    execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
    let backend = CrosstermBackend::new(stdout);
    let mut terminal = Terminal::new(backend)?;

    let res = (|| -> Result<()> {
        let mut app = App::new()?;
        run_app(&mut terminal, &mut app)
    })();

    disable_raw_mode()?;
    execute!(
        terminal.backend_mut(),
        LeaveAlternateScreen,
        DisableMouseCapture
    )?;
    terminal.show_cursor()?;

    res
}

fn run_app<B: Backend>(terminal: &mut Terminal<B>, app: &mut App) -> Result<()> {
    loop {
        terminal.draw(|f| ui::draw(f, app))?;
        app.tick();

        if event::poll(Duration::from_millis(250))? {
            if let Event::Key(key) = event::read()? {
                if key.kind != KeyEventKind::Press {
                    continue;
                }

                if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) {
                    return Ok(());
                }

                if app.pending_action.is_some() {
                    handle_confirmation(key.code, app)?;
                    continue;
                }

                match app.current_screen {
                    CurrentScreen::Home => match key.code {
                        KeyCode::Char('q') => return Ok(()),
                        KeyCode::Down | KeyCode::Char('j') => app.next(),
                        KeyCode::Up | KeyCode::Char('k') => app.previous(),
                        KeyCode::Char('g') => app.first(),
                        KeyCode::Char('G') => app.last(),
                        KeyCode::Char('n') => {
                            capture_session(terminal, app)?;
                        }
                        KeyCode::Char('d') => {
                            app.request_delete_selected();
                        }
                        KeyCode::Enter => {
                            app.open_selected_session();
                        }
                        KeyCode::Char('r') => {
                            restore_preview(terminal, app)?;
                        }
                        KeyCode::Char('f') => match app.refresh_sessions() {
                            Ok(()) => app.set_success("Backup list refreshed."),
                            Err(error) => app.set_error(format!("Refresh failed: {error}")),
                        },
                        _ => {}
                    },
                    CurrentScreen::Detail => match key.code {
                        KeyCode::Esc | KeyCode::Char('q') => {
                            app.close_detail();
                        }
                        KeyCode::Tab => app.toggle_detail_focus(),
                        KeyCode::Left | KeyCode::Char('h') => app.focus_windows(),
                        KeyCode::Right | KeyCode::Char('l') => app.focus_tabs(),
                        KeyCode::Down | KeyCode::Char('j') => match app.detail_focus {
                            app::DetailFocus::Windows => app.next_detail_window(),
                            app::DetailFocus::Tabs => app.next_detail_tab(),
                        },
                        KeyCode::Up | KeyCode::Char('k') => match app.detail_focus {
                            app::DetailFocus::Windows => app.previous_detail_window(),
                            app::DetailFocus::Tabs => app.previous_detail_tab(),
                        },
                        KeyCode::Enter => app.focus_tabs(),
                        KeyCode::Char(' ') => app.toggle_current_window_selection(),
                        KeyCode::Char('a') => app.toggle_all_window_selection(),
                        KeyCode::Char('u') => app.clear_window_selection(),
                        KeyCode::Char('r') => {
                            restore_detail_window(terminal, app)?;
                        }
                        KeyCode::Char('s') => {
                            restore_marked_windows(terminal, app)?;
                        }
                        KeyCode::Char('o') => {
                            open_selected_url(terminal, app)?;
                        }
                        _ => {}
                    },
                }
            }
        }
    }
}

fn capture_session<B: Backend>(terminal: &mut Terminal<B>, app: &mut App) -> Result<()> {
    app.set_info("Capturing current Safari session...");
    terminal.draw(|f| ui::draw(f, app))?;

    match safari::get_current_session() {
        Ok(session) => match storage::save_session(&session) {
            Ok(path) => {
                app.refresh_sessions()?;
                app.select_path(&path);
                app.set_success("Backup captured.");
            }
            Err(error) => app.set_error(format!("Save failed: {error}")),
        },
        Err(error) => app.set_error(format!("Capture failed: {error}")),
    }

    Ok(())
}

fn restore_preview<B: Backend>(terminal: &mut Terminal<B>, app: &mut App) -> Result<()> {
    let Some(session) = app.selected_session().cloned() else {
        app.set_info("No backup selected.");
        return Ok(());
    };

    app.set_info("Restoring Safari windows...");
    terminal.draw(|f| ui::draw(f, app))?;

    match safari::restore_session(&session) {
        Ok(()) => app.set_success("Safari session restored."),
        Err(error) => app.set_error(format!("Restore failed: {error}")),
    }

    Ok(())
}

fn restore_detail_window<B: Backend>(terminal: &mut Terminal<B>, app: &mut App) -> Result<()> {
    let Some(session) = app.current_window_session() else {
        app.set_info("No window selected.");
        return Ok(());
    };

    app.set_info("Restoring selected Safari window...");
    terminal.draw(|f| ui::draw(f, app))?;

    match safari::restore_session(&session) {
        Ok(()) => app.set_success("Safari window restored."),
        Err(error) => app.set_error(format!("Restore failed: {error}")),
    }

    Ok(())
}

fn restore_marked_windows<B: Backend>(terminal: &mut Terminal<B>, app: &mut App) -> Result<()> {
    let Some(session) = app.marked_windows_session() else {
        app.set_info("No marked windows. Press space in the window list first.");
        return Ok(());
    };

    app.set_info(format!(
        "Restoring {} marked Safari window(s)...",
        session.window_count()
    ));
    terminal.draw(|f| ui::draw(f, app))?;

    match safari::restore_session(&session) {
        Ok(()) => app.set_success(format!(
            "Restored {} marked window(s).",
            session.window_count()
        )),
        Err(error) => app.set_error(format!("Restore failed: {error}")),
    }

    Ok(())
}

fn open_selected_url<B: Backend>(terminal: &mut Terminal<B>, app: &mut App) -> Result<()> {
    let Some(url) = app.selected_tab_url().map(str::to_owned) else {
        app.set_info("No tab selected.");
        return Ok(());
    };

    app.set_info("Opening selected URL in Safari...");
    terminal.draw(|f| ui::draw(f, app))?;

    match safari::open_url(&url) {
        Ok(()) => app.set_success("Selected URL opened in Safari."),
        Err(error) => app.set_error(format!("Open URL failed: {error}")),
    }

    Ok(())
}

fn handle_confirmation(code: KeyCode, app: &mut App) -> Result<()> {
    match (app.pending_action, code) {
        (Some(PendingAction::DeleteSelected), KeyCode::Char('y'))
        | (Some(PendingAction::DeleteSelected), KeyCode::Enter) => {
            if let Some(summary) = app.selected_summary() {
                let path = summary.path.clone();
                match storage::delete_session(&path) {
                    Ok(()) => {
                        app.clear_pending_action();
                        app.refresh_sessions()?;
                        app.set_success("Backup deleted.");
                    }
                    Err(error) => {
                        app.clear_pending_action();
                        app.set_error(format!("Delete failed: {error}"));
                    }
                }
            } else {
                app.clear_pending_action();
            }
        }
        (Some(PendingAction::DeleteSelected), KeyCode::Esc)
        | (Some(PendingAction::DeleteSelected), KeyCode::Char('n')) => {
            app.clear_pending_action();
            app.set_info("Delete cancelled.");
        }
        _ => {}
    }

    Ok(())
}