pub const MAX_TABS: usize = 32;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TabId(pub u32);
#[derive(Debug, Clone)]
pub struct Tab {
pub id: TabId,
pub repo: String,
#[allow(dead_code)] pub needs_action_count: Option<usize>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OpenOutcome {
Focused,
Opened,
Capped,
}
#[allow(clippy::struct_field_names)]
#[derive(Debug)]
pub struct Tabs {
pub tabs: Vec<Tab>,
pub active: Option<TabId>,
pub previous: Option<TabId>,
next_id: u32,
}
impl Tabs {
pub fn new() -> Self {
Self { tabs: Vec::new(), active: None, previous: None, next_id: 0 }
}
fn alloc_id(&mut self) -> TabId {
let id = TabId(self.next_id);
self.next_id += 1;
id
}
fn index_of(&self, id: TabId) -> Option<usize> {
self.tabs.iter().position(|t| t.id == id)
}
pub fn active_tab(&self) -> Option<&Tab> {
self.active.and_then(|id| self.tabs.get(self.index_of(id)?))
}
pub fn active_index(&self) -> Option<usize> {
self.active.and_then(|id| self.index_of(id))
}
pub fn set_active(&mut self, id: TabId) {
if self.active != Some(id) {
self.previous = self.active;
self.active = Some(id);
}
}
pub fn open_or_focus(&mut self, repo: &str) -> (TabId, OpenOutcome) {
if let Some(existing) = self.tabs.iter().find(|t| t.repo == repo) {
let id = existing.id;
self.set_active(id);
return (id, OpenOutcome::Focused);
}
if self.tabs.len() >= MAX_TABS {
let fallback = self.active.unwrap_or(TabId(0));
return (fallback, OpenOutcome::Capped);
}
let id = self.alloc_id();
self.tabs.push(Tab { id, repo: repo.to_owned(), needs_action_count: None });
self.set_active(id);
(id, OpenOutcome::Opened)
}
#[allow(dead_code)] pub fn close(&mut self, id: TabId) -> bool {
let Some(idx) = self.index_of(id) else {
return false;
};
self.tabs.remove(idx);
if self.tabs.is_empty() {
self.active = None;
self.previous = None;
return true;
}
if let Some(prev) = self.previous
&& prev != id
&& self.index_of(prev).is_some()
{
self.previous = None;
self.active = Some(prev);
} else {
let new_idx = idx.min(self.tabs.len() - 1);
self.active = Some(self.tabs[new_idx].id);
self.previous = None;
}
true
}
pub fn len(&self) -> usize {
self.tabs.len()
}
pub fn is_empty(&self) -> bool {
self.tabs.is_empty()
}
pub fn next(&mut self) {
let Some(idx) = self.active_index() else {
return;
};
if self.tabs.is_empty() {
return;
}
let next_idx = (idx + 1) % self.tabs.len();
let id = self.tabs[next_idx].id;
self.set_active(id);
}
pub fn prev(&mut self) {
let Some(idx) = self.active_index() else {
return;
};
if self.tabs.is_empty() {
return;
}
let prev_idx = if idx == 0 { self.tabs.len() - 1 } else { idx - 1 };
let id = self.tabs[prev_idx].id;
self.set_active(id);
}
pub fn set_active_by_index(&mut self, idx: usize) {
if let Some(tab) = self.tabs.get(idx) {
let id = tab.id;
self.set_active(id);
}
}
#[allow(dead_code)] pub fn activate_previous(&mut self) {
let Some(prev) = self.previous else {
return;
};
if self.index_of(prev).is_none() {
self.previous = None;
return;
}
let current = self.active;
self.active = Some(prev);
self.previous = current;
}
}
impl Default for Tabs {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn open_or_focus_creates_new_tab() {
let mut tabs = Tabs::new();
let (_, outcome) = tabs.open_or_focus("rust-lang/rust");
assert_eq!(outcome, OpenOutcome::Opened);
assert_eq!(tabs.len(), 1);
assert!(tabs.active.is_some());
}
#[test]
fn open_or_focus_dedupes_by_repo() {
let mut tabs = Tabs::new();
tabs.open_or_focus("rust-lang/rust");
let (_, outcome) = tabs.open_or_focus("rust-lang/rust");
assert_eq!(outcome, OpenOutcome::Focused);
assert_eq!(tabs.len(), 1);
}
#[test]
fn open_or_focus_caps_at_max_tabs() {
let mut tabs = Tabs::new();
for i in 0..MAX_TABS {
tabs.open_or_focus(&format!("owner/repo{i}"));
}
assert_eq!(tabs.len(), MAX_TABS);
let (_, outcome) = tabs.open_or_focus("overflow/repo");
assert_eq!(outcome, OpenOutcome::Capped);
assert_eq!(tabs.len(), MAX_TABS);
}
#[test]
fn close_active_last_tab() {
let mut tabs = Tabs::new();
let (id, _) = tabs.open_or_focus("a/b");
assert!(tabs.close(id));
assert_eq!(tabs.len(), 0);
assert!(tabs.active.is_none());
}
#[test]
fn next_prev_wraparound() {
let mut tabs = Tabs::new();
let (a_id, _) = tabs.open_or_focus("a/a");
tabs.open_or_focus("b/b");
let (c_id, _) = tabs.open_or_focus("c/c");
tabs.next();
assert_eq!(tabs.active, Some(a_id));
tabs.prev();
assert_eq!(tabs.active, Some(c_id));
}
#[test]
fn set_active_by_index_out_of_range_is_noop() {
let mut tabs = Tabs::new();
tabs.open_or_focus("a/a");
let active_before = tabs.active;
tabs.set_active_by_index(99);
assert_eq!(tabs.active, active_before);
}
#[test]
fn next_noop_when_one_tab() {
let mut tabs = Tabs::new();
let (id, _) = tabs.open_or_focus("a/a");
tabs.next();
assert_eq!(tabs.active, Some(id));
}
}