use crate::binding::{Workspace, WorkspaceKind};
use crate::nav::ws_key;
use std::path::PathBuf;
#[derive(Debug, Clone)]
pub struct Item {
pub ws: Workspace,
pub attention: usize,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Tab {
pub ws: PathBuf,
pub name: String,
pub attention: usize,
pub selected: bool,
pub pinned: bool,
pub replay: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct TabBar {
pub tabs: Vec<Tab>,
pub overflow: Vec<Tab>,
}
impl TabBar {
pub fn overflow_attention(&self) -> usize {
self.overflow.iter().map(|t| t.attention).sum()
}
}
pub fn build(items: &[Item], pinned: &[String], focused: Option<&std::path::Path>) -> TabBar {
let mut tabs: Vec<Tab> = Vec::new();
for key in pinned {
if let Some(it) = items.iter().find(|it| ws_key(&it.ws.path) == *key) {
tabs.push(tab(it, focused, true));
}
}
let mut named: Vec<Tab> = items
.iter()
.filter(|it| !is_pinned(it, pinned) && matches!(it.ws.kind, WorkspaceKind::Named { .. }))
.map(|it| tab(it, focused, false))
.collect();
named.sort_by(|a, b| a.name.cmp(&b.name));
tabs.extend(named);
let overflow = items
.iter()
.filter(|it| !is_pinned(it, pinned) && !matches!(it.ws.kind, WorkspaceKind::Named { .. }))
.map(|it| tab(it, focused, false))
.collect();
TabBar { tabs, overflow }
}
fn is_pinned(it: &Item, pinned: &[String]) -> bool {
pinned.iter().any(|k| *k == ws_key(&it.ws.path))
}
fn tab(it: &Item, focused: Option<&std::path::Path>, pinned: bool) -> Tab {
let name = it
.ws
.path
.file_name()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_default();
Tab {
ws: it.ws.path.clone(),
name,
attention: it.attention,
selected: focused == Some(it.ws.path.as_path()),
pinned,
replay: it.ws.kind == WorkspaceKind::Replay,
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
fn item(path: &str, kind: WorkspaceKind, attention: usize) -> Item {
Item {
ws: Workspace {
path: PathBuf::from(path),
kind,
},
attention,
}
}
fn named(path: &str, name: &str, attention: usize) -> Item {
item(
path,
WorkspaceKind::Named {
name: name.to_owned(),
},
attention,
)
}
#[test]
fn named_workspaces_tab_in_name_order_and_carry_facts() {
let items = [
named("/y/zeta-pug", "zeta-pug", 2),
named("/y/alba-koi", "alba-koi", 0),
];
let bar = build(&items, &[], Some(Path::new("/y/zeta-pug")));
let names: Vec<&str> = bar.tabs.iter().map(|t| t.name.as_str()).collect();
assert_eq!(names, ["alba-koi", "zeta-pug"]);
assert!(bar.overflow.is_empty());
let zeta = &bar.tabs[1];
assert!(zeta.selected && !zeta.pinned && !zeta.replay);
assert_eq!(zeta.attention, 2);
assert!(!bar.tabs[0].selected);
}
#[test]
fn foreign_and_replay_go_to_overflow_with_aggregate_attention() {
let items = [
named("/y/alba-koi", "alba-koi", 0),
item("/l/workspaces/20260101T-aa", WorkspaceKind::Foreign, 1),
item("/l/replays/20260102T-bb", WorkspaceKind::Replay, 2),
];
let bar = build(&items, &[], None);
assert_eq!(bar.tabs.len(), 1);
let names: Vec<&str> = bar.overflow.iter().map(|t| t.name.as_str()).collect();
assert_eq!(names, ["20260101T-aa", "20260102T-bb"]);
assert!(!bar.overflow[0].replay, "foreign is not a replay");
assert!(bar.overflow[1].replay, "replay flagged read-only");
assert_eq!(bar.overflow_attention(), 3, "menu badge sums entries");
}
#[test]
fn pinning_hoists_any_kind_into_the_tabs_in_pin_order() {
let items = [
named("/y/alba-koi", "alba-koi", 0),
item("/l/workspaces/20260101T-aa", WorkspaceKind::Foreign, 0),
item("/l/replays/20260102T-bb", WorkspaceKind::Replay, 0),
];
let pins = [
"/l/replays/20260102T-bb".to_owned(),
"/l/workspaces/20260101T-aa".to_owned(),
];
let bar = build(&items, &pins, None);
let names: Vec<&str> = bar.tabs.iter().map(|t| t.name.as_str()).collect();
assert_eq!(
names,
["20260102T-bb", "20260101T-aa", "alba-koi"],
"pins first in pin order, then named"
);
assert!(bar.tabs[0].pinned && bar.tabs[0].replay);
assert!(bar.overflow.is_empty(), "hoisted entries leave the menu");
}
#[test]
fn a_stale_pin_key_matches_nothing_and_is_dropped() {
let items = [named("/y/alba-koi", "alba-koi", 0)];
let bar = build(&items, &["/gone".to_owned()], None);
assert_eq!(bar.tabs.len(), 1);
assert!(!bar.tabs[0].pinned);
}
}