safari 1.0.0

Terminal UI for capturing and restoring Safari sessions on macOS
Documentation
use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};

use anyhow::Result;

use crate::backend::models::{SafariSession, SessionSummary, SessionTab, SessionWindow};
use crate::backend::storage;

const STATUS_TTL: Duration = Duration::from_secs(4);

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CurrentScreen {
    Home,
    Detail,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PendingAction {
    DeleteSelected,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StatusKind {
    Info,
    Success,
    Error,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DetailFocus {
    Windows,
    Tabs,
}

#[derive(Debug, Clone)]
pub struct StatusMessage {
    pub kind: StatusKind,
    pub text: String,
    created_at: Instant,
}

pub struct App {
    pub storage_dir: PathBuf,
    pub sessions: Vec<SessionSummary>,
    pub selected_session_index: usize,
    pub current_screen: CurrentScreen,
    pub preview_session: Option<SafariSession>,
    pub current_session_data: Option<SafariSession>,
    pub detail_focus: DetailFocus,
    pub detail_selected_window_index: usize,
    pub detail_selected_tab_index: usize,
    pub marked_window_indexes: BTreeSet<usize>,
    pub pending_action: Option<PendingAction>,
    pub status: Option<StatusMessage>,
}

impl App {
    pub fn new() -> Result<Self> {
        let storage_dir = storage::storage_dir()?;
        let mut app = Self {
            storage_dir,
            sessions: Vec::new(),
            selected_session_index: 0,
            current_screen: CurrentScreen::Home,
            preview_session: None,
            current_session_data: None,
            detail_focus: DetailFocus::Windows,
            detail_selected_window_index: 0,
            detail_selected_tab_index: 0,
            marked_window_indexes: BTreeSet::new(),
            pending_action: None,
            status: None,
        };

        app.refresh_sessions()?;
        if app.sessions.is_empty() {
            app.set_info("No backups yet. Press n to capture the current Safari session.");
        } else {
            app.set_info(format!("Loaded {} backup(s).", app.sessions.len()));
        }

        Ok(app)
    }

    pub fn refresh_sessions(&mut self) -> Result<()> {
        let selected_path = self.selected_summary().map(|summary| summary.path.clone());
        self.sessions = storage::list_sessions()?;
        self.selected_session_index = selected_path
            .and_then(|path| {
                self.sessions
                    .iter()
                    .position(|summary| summary.path == path)
            })
            .unwrap_or_else(|| {
                self.selected_session_index
                    .min(self.sessions.len().saturating_sub(1))
            });
        self.load_preview();
        Ok(())
    }

    pub fn next(&mut self) {
        if self.selected_session_index + 1 < self.sessions.len() {
            self.selected_session_index += 1;
            self.load_preview();
        }
    }

    pub fn previous(&mut self) {
        if self.selected_session_index > 0 {
            self.selected_session_index -= 1;
            self.load_preview();
        }
    }

    pub fn first(&mut self) {
        if !self.sessions.is_empty() {
            self.selected_session_index = 0;
            self.load_preview();
        }
    }

    pub fn last(&mut self) {
        if !self.sessions.is_empty() {
            self.selected_session_index = self.sessions.len() - 1;
            self.load_preview();
        }
    }

    pub fn open_selected_session(&mut self) {
        if let Some(session) = self.preview_session.clone() {
            self.current_session_data = Some(session);
            self.current_screen = CurrentScreen::Detail;
            self.reset_detail_state();
        }
    }

    pub fn close_detail(&mut self) {
        self.current_screen = CurrentScreen::Home;
        self.current_session_data = None;
        self.reset_detail_state();
    }

    pub fn request_delete_selected(&mut self) {
        if self.selected_summary().is_some() {
            self.pending_action = Some(PendingAction::DeleteSelected);
        }
    }

    pub fn clear_pending_action(&mut self) {
        self.pending_action = None;
    }

    pub fn tick(&mut self) {
        if let Some(status) = &self.status {
            if status.created_at.elapsed() >= STATUS_TTL {
                self.status = None;
            }
        }
    }

    pub fn set_info<T>(&mut self, text: T)
    where
        T: Into<String>,
    {
        self.status = Some(StatusMessage {
            kind: StatusKind::Info,
            text: text.into(),
            created_at: Instant::now(),
        });
    }

    pub fn set_success<T>(&mut self, text: T)
    where
        T: Into<String>,
    {
        self.status = Some(StatusMessage {
            kind: StatusKind::Success,
            text: text.into(),
            created_at: Instant::now(),
        });
    }

    pub fn set_error<T>(&mut self, text: T)
    where
        T: Into<String>,
    {
        self.status = Some(StatusMessage {
            kind: StatusKind::Error,
            text: text.into(),
            created_at: Instant::now(),
        });
    }

    pub fn select_path(&mut self, path: &Path) {
        if let Some(index) = self
            .sessions
            .iter()
            .position(|summary| summary.path == path)
        {
            self.selected_session_index = index;
            self.load_preview();
        }
    }

    pub fn selected_summary(&self) -> Option<&SessionSummary> {
        self.sessions.get(self.selected_session_index)
    }

    pub fn selected_session(&self) -> Option<&SafariSession> {
        self.preview_session.as_ref()
    }

    pub fn detail_session(&self) -> Option<&SafariSession> {
        self.current_session_data.as_ref()
    }

    pub fn selected_detail_window(&self) -> Option<&SessionWindow> {
        self.detail_session()?
            .windows
            .get(self.detail_selected_window_index)
    }

    pub fn selected_detail_tab(&self) -> Option<&SessionTab> {
        self.selected_detail_window()?
            .tabs
            .get(self.detail_selected_tab_index)
    }

    pub fn focus_windows(&mut self) {
        self.detail_focus = DetailFocus::Windows;
    }

    pub fn focus_tabs(&mut self) {
        if self.selected_detail_window().is_some() {
            self.detail_focus = DetailFocus::Tabs;
            self.clamp_detail_selection();
        }
    }

    pub fn toggle_detail_focus(&mut self) {
        match self.detail_focus {
            DetailFocus::Windows => self.focus_tabs(),
            DetailFocus::Tabs => self.focus_windows(),
        }
    }

    pub fn next_detail_window(&mut self) {
        let window_count = self
            .detail_session()
            .map(|session| session.windows.len())
            .unwrap_or(0);
        if self.detail_selected_window_index + 1 < window_count {
            self.detail_selected_window_index += 1;
            self.clamp_detail_selection();
        }
    }

    pub fn previous_detail_window(&mut self) {
        if self.detail_selected_window_index > 0 {
            self.detail_selected_window_index -= 1;
            self.clamp_detail_selection();
        }
    }

    pub fn next_detail_tab(&mut self) {
        let tab_count = self
            .selected_detail_window()
            .map(|window| window.tabs.len())
            .unwrap_or(0);
        if self.detail_selected_tab_index + 1 < tab_count {
            self.detail_selected_tab_index += 1;
        }
    }

    pub fn previous_detail_tab(&mut self) {
        if self.detail_selected_tab_index > 0 {
            self.detail_selected_tab_index -= 1;
        }
    }

    pub fn toggle_current_window_selection(&mut self) {
        let index = self.detail_selected_window_index;
        if self.marked_window_indexes.contains(&index) {
            self.marked_window_indexes.remove(&index);
        } else if self.selected_detail_window().is_some() {
            self.marked_window_indexes.insert(index);
        }
    }

    pub fn toggle_all_window_selection(&mut self) {
        let Some(session) = self.detail_session() else {
            return;
        };

        if self.marked_window_indexes.len() == session.windows.len() {
            self.marked_window_indexes.clear();
            return;
        }

        self.marked_window_indexes = (0..session.windows.len()).collect();
    }

    pub fn clear_window_selection(&mut self) {
        self.marked_window_indexes.clear();
    }

    pub fn marked_window_count(&self) -> usize {
        self.marked_window_indexes.len()
    }

    pub fn current_window_session(&self) -> Option<SafariSession> {
        let session = self.detail_session()?;
        let window = session
            .windows
            .get(self.detail_selected_window_index)?
            .clone();
        Some(SafariSession::new(
            session.captured_at.clone(),
            vec![window],
        ))
    }

    pub fn marked_windows_session(&self) -> Option<SafariSession> {
        if self.marked_window_indexes.is_empty() {
            return None;
        }

        let session = self.detail_session()?;
        let windows: Vec<SessionWindow> = self
            .marked_window_indexes
            .iter()
            .filter_map(|index| session.windows.get(*index).cloned())
            .collect();

        if windows.is_empty() {
            None
        } else {
            Some(SafariSession::new(session.captured_at.clone(), windows))
        }
    }

    pub fn selected_tab_url(&self) -> Option<&str> {
        self.selected_detail_tab().map(|tab| tab.url.as_str())
    }

    fn load_preview(&mut self) {
        self.preview_session = self
            .selected_summary()
            .and_then(|summary| storage::load_session(&summary.path).ok());

        if self.sessions.is_empty() {
            self.selected_session_index = 0;
            self.preview_session = None;
            self.current_session_data = None;
        }
    }

    fn reset_detail_state(&mut self) {
        self.detail_focus = DetailFocus::Windows;
        self.detail_selected_window_index = 0;
        self.detail_selected_tab_index = 0;
        self.marked_window_indexes.clear();
        self.clamp_detail_selection();
    }

    fn clamp_detail_selection(&mut self) {
        let Some((window_count, tab_count)) = self.detail_session().map(|session| {
            let window_count = session.windows.len();
            let safe_window_index = self
                .detail_selected_window_index
                .min(window_count.saturating_sub(1));
            let tab_count = session
                .windows
                .get(safe_window_index)
                .map(|window| window.tabs.len())
                .unwrap_or(0);

            (window_count, tab_count)
        }) else {
            self.detail_selected_window_index = 0;
            self.detail_selected_tab_index = 0;
            return;
        };

        self.detail_selected_window_index = self
            .detail_selected_window_index
            .min(window_count.saturating_sub(1));

        self.detail_selected_tab_index = self
            .detail_selected_tab_index
            .min(tab_count.saturating_sub(1));

        self.marked_window_indexes
            .retain(|index| *index < window_count);
    }
}