cephas 0.1.18

Privacy-first GTK/WebKit browser with local agent tooling.
Documentation
use serde::{Deserialize, Serialize};
use std::collections::VecDeque;
use url::Url;

pub const DEFAULT_MAX_TABS: usize = 100;
pub const DEFAULT_MAX_RECENTLY_CLOSED: usize = 20;

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct TabId(pub u64);

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct WindowId(pub u64);

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum LoadState {
    Idle,
    Loading,
    Error(String),
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TabState {
    pub id: TabId,
    pub url: Option<Url>,
    pub title: String,
    pub pinned: bool,
    pub muted: bool,
    pub private: bool,
    pub zoom: f64,
    pub load_state: LoadState,
}

impl TabState {
    fn new(id: TabId, url: Option<Url>, private: bool) -> Self {
        Self {
            id,
            title: url
                .as_ref()
                .map(ToString::to_string)
                .unwrap_or_else(|| "Start".to_string()),
            url,
            pinned: false,
            muted: false,
            private,
            zoom: 1.0,
            load_state: LoadState::Idle,
        }
    }
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ClosedTab {
    pub tab: TabState,
    pub closed_from_index: usize,
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum BrowserEvent {
    TabCreated { tab_id: TabId, url: Option<Url> },
    TabClosed { tab_id: TabId },
    TabActivated { tab_id: TabId },
    TabDuplicated { source: TabId, duplicate: TabId },
    TabPinned { tab_id: TabId, pinned: bool },
    TabMuted { tab_id: TabId, muted: bool },
    NavigateRequested { tab_id: TabId, url: Url },
    LoadStarted { tab_id: TabId, url: Url },
    LoadFinished { tab_id: TabId, url: Url },
    TitleChanged { tab_id: TabId, title: String },
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BrowserState {
    pub window_id: WindowId,
    pub tabs: Vec<TabState>,
    pub active_tab: Option<TabId>,
    pub recently_closed: VecDeque<ClosedTab>,
    next_tab_id: u64,
    max_tabs: usize,
    max_recently_closed: usize,
}

impl Default for BrowserState {
    fn default() -> Self {
        Self {
            window_id: WindowId(1),
            tabs: Vec::new(),
            active_tab: None,
            recently_closed: VecDeque::new(),
            next_tab_id: 1,
            max_tabs: DEFAULT_MAX_TABS,
            max_recently_closed: DEFAULT_MAX_RECENTLY_CLOSED,
        }
    }
}

impl BrowserState {
    pub fn create_tab(&mut self, url: Option<Url>, private: bool) -> BrowserEvent {
        self.ensure_tab_capacity();
        let id = self.alloc_tab_id();
        let tab = TabState::new(id, url.clone(), private);
        self.tabs.push(tab);
        self.active_tab = Some(id);
        BrowserEvent::TabCreated { tab_id: id, url }
    }

    pub fn close_tab(&mut self, id: TabId) -> Option<BrowserEvent> {
        let index = self.tabs.iter().position(|tab| tab.id == id)?;
        let tab = self.tabs.remove(index);
        self.recently_closed.push_front(ClosedTab {
            tab,
            closed_from_index: index,
        });
        while self.recently_closed.len() > self.max_recently_closed {
            self.recently_closed.pop_back();
        }

        if self.active_tab == Some(id) {
            self.active_tab = self
                .tabs
                .get(index)
                .or_else(|| index.checked_sub(1).and_then(|index| self.tabs.get(index)))
                .map(|tab| tab.id);
        }

        Some(BrowserEvent::TabClosed { tab_id: id })
    }

    pub fn activate_tab(&mut self, id: TabId) -> Option<BrowserEvent> {
        self.tabs.iter().any(|tab| tab.id == id).then(|| {
            self.active_tab = Some(id);
            BrowserEvent::TabActivated { tab_id: id }
        })
    }

    pub fn duplicate_tab(&mut self, id: TabId) -> Option<BrowserEvent> {
        let mut duplicate = self.tabs.iter().find(|tab| tab.id == id)?.clone();
        self.ensure_tab_capacity();
        duplicate.id = self.alloc_tab_id();
        let duplicate_id = duplicate.id;
        self.tabs.push(duplicate);
        self.active_tab = Some(duplicate_id);
        Some(BrowserEvent::TabDuplicated {
            source: id,
            duplicate: duplicate_id,
        })
    }

    pub fn reopen_closed_tab(&mut self) -> Option<BrowserEvent> {
        let closed = self.recently_closed.pop_front()?;
        self.ensure_tab_capacity();
        let mut tab = closed.tab;
        tab.id = self.alloc_tab_id();
        let id = tab.id;
        let url = tab.url.clone();
        let index = closed.closed_from_index.min(self.tabs.len());
        self.tabs.insert(index, tab);
        self.active_tab = Some(id);
        Some(BrowserEvent::TabCreated { tab_id: id, url })
    }

    pub fn set_pinned(&mut self, id: TabId, pinned: bool) -> Option<BrowserEvent> {
        let tab = self.tabs.iter_mut().find(|tab| tab.id == id)?;
        tab.pinned = pinned;
        Some(BrowserEvent::TabPinned { tab_id: id, pinned })
    }

    pub fn set_muted(&mut self, id: TabId, muted: bool) -> Option<BrowserEvent> {
        let tab = self.tabs.iter_mut().find(|tab| tab.id == id)?;
        tab.muted = muted;
        Some(BrowserEvent::TabMuted { tab_id: id, muted })
    }

    pub fn update_title(&mut self, id: TabId, title: impl Into<String>) -> Option<BrowserEvent> {
        let title = title.into();
        let tab = self.tabs.iter_mut().find(|tab| tab.id == id)?;
        tab.title = title.clone();
        Some(BrowserEvent::TitleChanged { tab_id: id, title })
    }

    pub fn update_url(&mut self, id: TabId, url: Url) -> Option<BrowserEvent> {
        let tab = self.tabs.iter_mut().find(|tab| tab.id == id)?;
        tab.url = Some(url.clone());
        Some(BrowserEvent::LoadFinished { tab_id: id, url })
    }

    fn alloc_tab_id(&mut self) -> TabId {
        let id = TabId(self.next_tab_id);
        self.next_tab_id += 1;
        id
    }

    fn ensure_tab_capacity(&mut self) {
        let max_tabs = self.max_tabs.max(1);
        while self.tabs.len() >= max_tabs {
            let index = self
                .tabs
                .iter()
                .position(|tab| !tab.pinned && Some(tab.id) != self.active_tab)
                .or_else(|| {
                    self.tabs
                        .iter()
                        .position(|tab| Some(tab.id) != self.active_tab)
                })
                .unwrap_or(0);
            let tab = self.tabs.remove(index);
            self.recently_closed.push_front(ClosedTab {
                tab,
                closed_from_index: index,
            });
            while self.recently_closed.len() > self.max_recently_closed.max(1) {
                self.recently_closed.pop_back();
            }
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn creates_and_activates_tabs() {
        let mut state = BrowserState::default();
        let first = state.create_tab(None, false);
        let second_url = Url::parse("https://example.com/").unwrap();
        let second = state.create_tab(Some(second_url.clone()), false);

        assert!(matches!(first, BrowserEvent::TabCreated { .. }));
        assert_eq!(state.tabs.len(), 2);
        assert_eq!(state.active_tab, Some(TabId(2)));
        assert_eq!(
            second,
            BrowserEvent::TabCreated {
                tab_id: TabId(2),
                url: Some(second_url)
            }
        );
    }

    #[test]
    fn close_tracks_recently_closed_and_reopens() {
        let mut state = BrowserState::default();
        state.create_tab(Some(Url::parse("https://a.example/").unwrap()), false);
        state.create_tab(Some(Url::parse("https://b.example/").unwrap()), false);

        state.close_tab(TabId(2)).unwrap();
        assert_eq!(state.tabs.len(), 1);
        assert_eq!(state.recently_closed.len(), 1);
        assert_eq!(state.active_tab, Some(TabId(1)));

        state.reopen_closed_tab().unwrap();
        assert_eq!(state.tabs.len(), 2);
        assert_eq!(state.active_tab, Some(TabId(3)));
    }

    #[test]
    fn duplicate_preserves_state_with_new_id() {
        let mut state = BrowserState::default();
        state.create_tab(Some(Url::parse("https://example.com/").unwrap()), false);
        state.set_pinned(TabId(1), true).unwrap();
        state.duplicate_tab(TabId(1)).unwrap();

        assert_eq!(state.tabs.len(), 2);
        assert_eq!(state.tabs[1].id, TabId(2));
        assert!(state.tabs[1].pinned);
    }

    #[test]
    fn tab_capacity_evicts_to_recently_closed() {
        let mut state = BrowserState {
            max_tabs: 2,
            ..BrowserState::default()
        };
        state.create_tab(Some(Url::parse("https://one.example/").unwrap()), false);
        state.create_tab(Some(Url::parse("https://two.example/").unwrap()), false);
        state.create_tab(Some(Url::parse("https://three.example/").unwrap()), false);

        assert_eq!(state.tabs.len(), 2);
        assert_eq!(state.recently_closed.len(), 1);
        assert_eq!(state.active_tab, Some(TabId(3)));
    }

    #[test]
    fn reopen_closed_tab_preserves_tab_capacity() {
        let mut state = BrowserState {
            max_tabs: 2,
            max_recently_closed: 3,
            ..BrowserState::default()
        };
        state.create_tab(Some(Url::parse("https://one.example/").unwrap()), false);
        state.create_tab(Some(Url::parse("https://two.example/").unwrap()), false);
        state.create_tab(Some(Url::parse("https://three.example/").unwrap()), false);

        assert_eq!(state.tabs.len(), 2);
        assert_eq!(state.recently_closed.len(), 1);

        state.reopen_closed_tab().unwrap();

        assert_eq!(state.tabs.len(), 2);
        assert_eq!(state.recently_closed.len(), 1);
        assert_eq!(state.active_tab, Some(TabId(4)));
    }
}