use std::fmt;
use tokio::sync::watch;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct TabId(u64);
impl fmt::Display for TabId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "tab-{}", self.0)
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct TabInfo {
pub id: TabId,
pub url: String,
pub title: String,
pub active: bool,
pub can_go_back: bool,
pub can_go_forward: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TabEvent {
Opened(TabId),
Closed(TabId),
ActiveChanged {
previous: Option<TabId>,
current: Option<TabId>,
},
NavStateChanged(TabId),
}
#[derive(Debug, Clone, PartialEq)]
pub struct TabsSnapshot {
pub tabs: Vec<TabInfo>,
pub active: Option<TabId>,
pub event: Option<TabEvent>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
pub enum TabError {
#[error("no tab with id {0}")]
NotFound(TabId),
}
struct TabEntry<P> {
id: TabId,
page: P,
url: String,
title: String,
can_go_back: bool,
can_go_forward: bool,
}
pub struct TabRegistry<P> {
entries: Vec<TabEntry<P>>,
active: Option<TabId>,
next_id: u64,
tx: watch::Sender<TabsSnapshot>,
_anchor_rx: watch::Receiver<TabsSnapshot>,
}
impl<P: Clone> TabRegistry<P> {
pub fn new() -> (Self, watch::Receiver<TabsSnapshot>) {
let (tx, rx) = watch::channel(TabsSnapshot {
tabs: Vec::new(),
active: None,
event: None,
});
let anchor = rx.clone();
(
Self {
entries: Vec::new(),
active: None,
next_id: 1,
tx,
_anchor_rx: anchor,
},
rx,
)
}
pub fn subscribe(&self) -> watch::Receiver<TabsSnapshot> {
self.tx.subscribe()
}
pub fn open(&mut self, page: P, url: impl Into<String>, title: impl Into<String>) -> TabId {
let id = TabId(self.next_id);
self.next_id += 1;
self.entries.push(TabEntry {
id,
page,
url: url.into(),
title: title.into(),
can_go_back: false,
can_go_forward: false,
});
self.active = Some(id);
self.publish(TabEvent::Opened(id));
id
}
pub fn close(&mut self, id: TabId) -> Option<P> {
let idx = self.entries.iter().position(|e| e.id == id)?;
let removed = self.entries.remove(idx);
if self.active == Some(id) {
let neighbor = self
.entries
.get(idx)
.or_else(|| idx.checked_sub(1).and_then(|i| self.entries.get(i)))
.map(|e| e.id);
self.active = neighbor;
self.publish(TabEvent::ActiveChanged {
previous: Some(id),
current: neighbor,
});
} else {
self.publish(TabEvent::Closed(id));
}
Some(removed.page)
}
pub fn close_all(&mut self) -> Vec<P> {
let ids: Vec<TabId> = self.entries.iter().map(|e| e.id).collect();
ids.into_iter().filter_map(|id| self.close(id)).collect()
}
pub fn switch(&mut self, id: TabId) -> Result<(), TabError> {
if !self.entries.iter().any(|e| e.id == id) {
return Err(TabError::NotFound(id));
}
if self.active == Some(id) {
return Ok(());
}
let previous = self.active;
self.active = Some(id);
self.publish(TabEvent::ActiveChanged {
previous,
current: Some(id),
});
Ok(())
}
pub fn active_id(&self) -> Option<TabId> {
self.active
}
pub fn active_url(&self) -> Option<String> {
self.active
.and_then(|id| self.entries.iter().find(|e| e.id == id))
.map(|e| e.url.clone())
}
pub fn active_page(&self) -> Option<P> {
self.active.and_then(|id| self.page(id))
}
pub fn page(&self, id: TabId) -> Option<P> {
self.entries
.iter()
.find(|e| e.id == id)
.map(|e| e.page.clone())
}
pub fn pages(&self) -> Vec<(TabId, P)> {
self.entries
.iter()
.map(|e| (e.id, e.page.clone()))
.collect()
}
pub fn list(&self) -> Vec<TabInfo> {
self.entries
.iter()
.map(|e| TabInfo {
id: e.id,
url: e.url.clone(),
title: e.title.clone(),
active: self.active == Some(e.id),
can_go_back: e.can_go_back,
can_go_forward: e.can_go_forward,
})
.collect()
}
pub fn update_nav_state(
&mut self,
id: TabId,
url: impl Into<String>,
title: impl Into<String>,
can_go_back: bool,
can_go_forward: bool,
) {
let url = url.into();
let title = title.into();
let mut changed = false;
if let Some(e) = self.entries.iter_mut().find(|e| e.id == id) {
if e.url != url
|| e.title != title
|| e.can_go_back != can_go_back
|| e.can_go_forward != can_go_forward
{
e.url = url;
e.title = title;
e.can_go_back = can_go_back;
e.can_go_forward = can_go_forward;
changed = true;
}
}
if changed {
self.publish(TabEvent::NavStateChanged(id));
}
}
fn publish(&self, event: TabEvent) {
let _ = self.tx.send(TabsSnapshot {
tabs: self.list(),
active: self.active,
event: Some(event),
});
}
}
#[cfg(test)]
mod tests {
use super::*;
type FakePage = &'static str;
fn registry() -> (TabRegistry<FakePage>, watch::Receiver<TabsSnapshot>) {
TabRegistry::new()
}
#[test]
fn open_adds_a_tab_and_makes_it_active() {
let (mut reg, _rx) = registry();
let id = reg.open("page-a", "http://a", "A");
assert_eq!(reg.active_id(), Some(id));
let tabs = reg.list();
assert_eq!(tabs.len(), 1);
assert!(tabs[0].active);
assert_eq!(tabs[0].url, "http://a");
assert_eq!(tabs[0].title, "A");
assert!(!tabs[0].can_go_back);
assert!(!tabs[0].can_go_forward);
}
#[test]
fn open_twice_makes_the_second_active_and_keeps_the_first() {
let (mut reg, _rx) = registry();
let first = reg.open("page-a", "http://a", "A");
let second = reg.open("page-b", "http://b", "B");
assert_eq!(reg.active_id(), Some(second));
let tabs = reg.list();
assert_eq!(tabs.len(), 2);
assert_eq!(tabs[0].id, first);
assert!(!tabs[0].active);
assert_eq!(tabs[1].id, second);
assert!(tabs[1].active);
}
#[test]
fn active_page_routes_to_whichever_tab_is_active() {
let (mut reg, _rx) = registry();
let first = reg.open("page-a", "http://a", "A");
assert_eq!(reg.active_page(), Some("page-a"));
let second = reg.open("page-b", "http://b", "B");
assert_eq!(reg.active_page(), Some("page-b"));
reg.switch(first).unwrap();
assert_eq!(reg.active_page(), Some("page-a"));
assert_eq!(reg.page(second), Some("page-b"));
}
#[test]
fn close_non_active_tab_leaves_active_unchanged() {
let (mut reg, _rx) = registry();
let first = reg.open("page-a", "http://a", "A");
let second = reg.open("page-b", "http://b", "B");
assert_eq!(reg.active_id(), Some(second));
let closed_page = reg.close(first);
assert_eq!(closed_page, Some("page-a"));
assert_eq!(
reg.active_id(),
Some(second),
"closing a background tab must not move focus"
);
assert_eq!(reg.list().len(), 1);
}
#[test]
fn close_active_tab_activates_the_right_neighbor() {
let (mut reg, _rx) = registry();
let first = reg.open("page-a", "http://a", "A");
let second = reg.open("page-b", "http://b", "B");
let third = reg.open("page-c", "http://c", "C");
reg.switch(second).unwrap();
assert_eq!(reg.active_id(), Some(second));
reg.close(second);
assert_eq!(
reg.active_id(),
Some(third),
"closing the active middle tab should activate its right neighbor"
);
let ids: Vec<TabId> = reg.list().iter().map(|t| t.id).collect();
assert_eq!(ids, vec![first, third]);
}
#[test]
fn close_active_rightmost_tab_falls_back_to_left_neighbor() {
let (mut reg, _rx) = registry();
let first = reg.open("page-a", "http://a", "A");
let second = reg.open("page-b", "http://b", "B");
assert_eq!(reg.active_id(), Some(second));
reg.close(second);
assert_eq!(
reg.active_id(),
Some(first),
"closing the rightmost active tab should fall back to the left neighbor"
);
}
#[test]
fn close_last_tab_leaves_the_empty_state() {
let (mut reg, _rx) = registry();
let only = reg.open("page-a", "http://a", "A");
let page = reg.close(only);
assert_eq!(page, Some("page-a"));
assert_eq!(reg.active_id(), None);
assert!(reg.list().is_empty());
assert_eq!(
reg.active_page(),
None,
"no dangling handle in the empty state"
);
}
#[test]
fn close_unknown_id_is_a_safe_noop() {
let (mut reg, _rx) = registry();
let real = reg.open("page-a", "http://a", "A");
let bogus = TabId(9999);
assert_eq!(reg.close(bogus), None);
assert_eq!(
reg.active_id(),
Some(real),
"closing a bogus id must not disturb state"
);
assert_eq!(reg.list().len(), 1);
}
#[test]
fn close_all_closes_every_tab_and_returns_their_pages() {
let (mut reg, _rx) = registry();
reg.open("page-a", "http://a", "A");
reg.open("page-b", "http://b", "B");
reg.open("page-c", "http://c", "C");
let mut closed = reg.close_all();
closed.sort();
assert_eq!(closed, vec!["page-a", "page-b", "page-c"]);
assert!(reg.list().is_empty());
assert_eq!(reg.active_id(), None);
}
#[test]
fn switch_to_existing_tab_changes_active() {
let (mut reg, _rx) = registry();
let first = reg.open("page-a", "http://a", "A");
let second = reg.open("page-b", "http://b", "B");
assert_eq!(reg.active_id(), Some(second));
reg.switch(first).unwrap();
assert_eq!(reg.active_id(), Some(first));
assert!(reg.list()[0].active);
assert!(!reg.list()[1].active);
}
#[test]
fn switch_to_unknown_id_errors() {
let (mut reg, _rx) = registry();
let real = reg.open("page-a", "http://a", "A");
let bogus = TabId(9999);
let err = reg.switch(bogus).unwrap_err();
assert_eq!(err, TabError::NotFound(bogus));
assert_eq!(
reg.active_id(),
Some(real),
"a failed switch must not change active"
);
}
#[test]
fn switch_to_already_active_tab_is_a_noop_ok() {
let (mut reg, mut rx) = registry();
let only = reg.open("page-a", "http://a", "A");
rx.mark_unchanged();
reg.switch(only).unwrap();
assert_eq!(reg.active_id(), Some(only));
assert!(
!rx.has_changed().unwrap(),
"switching to the already-active tab must not publish a new snapshot"
);
}
#[test]
fn update_nav_state_updates_fields_and_is_reflected_in_list() {
let (mut reg, _rx) = registry();
let id = reg.open("page-a", "about:blank", "");
reg.update_nav_state(id, "http://a/2", "Page A", true, false);
let tab = ®.list()[0];
assert_eq!(tab.url, "http://a/2");
assert_eq!(tab.title, "Page A");
assert!(tab.can_go_back);
assert!(!tab.can_go_forward);
}
#[test]
fn update_nav_state_is_a_noop_when_nothing_changed() {
let (mut reg, mut rx) = registry();
let id = reg.open("page-a", "http://a", "A");
rx.mark_unchanged();
reg.update_nav_state(id, "http://a", "A", false, false);
assert!(
!rx.has_changed().unwrap(),
"re-asserting identical nav state must not publish (avoids polling churn)"
);
}
#[tokio::test]
async fn subscribe_sees_open_close_switch_events_in_order() {
let (mut reg, _initial_rx) = registry();
let mut rx = reg.subscribe();
rx.mark_unchanged();
let first = reg.open("page-a", "http://a", "A");
rx.changed().await.unwrap();
assert_eq!(rx.borrow().event, Some(TabEvent::Opened(first)));
let second = reg.open("page-b", "http://b", "B");
rx.changed().await.unwrap();
assert_eq!(rx.borrow().event, Some(TabEvent::Opened(second)));
reg.switch(first).unwrap();
rx.changed().await.unwrap();
assert_eq!(
rx.borrow().event,
Some(TabEvent::ActiveChanged {
previous: Some(second),
current: Some(first)
})
);
reg.close(first);
rx.changed().await.unwrap();
assert_eq!(
rx.borrow().event,
Some(TabEvent::ActiveChanged {
previous: Some(first),
current: Some(second)
})
);
}
#[test]
fn publish_persists_even_with_no_external_subscriber() {
let (mut reg, initial_rx) = registry();
drop(initial_rx);
let id = reg.open("page-a", "http://a", "A");
let late_rx = reg.subscribe();
let snapshot = late_rx.borrow();
assert_eq!(snapshot.active, Some(id));
assert_eq!(snapshot.tabs.len(), 1);
assert_eq!(snapshot.event, Some(TabEvent::Opened(id)));
}
#[test]
fn tab_id_display_is_stable_and_distinguishable() {
let (mut reg, _rx) = registry();
let a = reg.open("page-a", "http://a", "A");
let b = reg.open("page-b", "http://b", "B");
assert_ne!(a.to_string(), b.to_string());
}
#[test]
fn closed_tab_ids_are_never_reused() {
let (mut reg, _rx) = registry();
let closed = reg.open("page-a", "http://a", "A");
assert_eq!(reg.close(closed), Some("page-a"));
let reopened = reg.open("page-b", "http://b", "B");
assert_ne!(
reopened, closed,
"a stale tab id must never alias a new tab"
);
assert!(reopened > closed, "tab ids come from a monotonic counter");
}
}