use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
use crate::agent_display::extract_project_name;
use crate::config::{SidebarGroupBy, SidebarPosition, SidebarSort, StatusIcons};
use crate::git::GitStatus;
use crate::github::{CheckSummary, PrSummary};
use crate::multiplexer::{AgentPane, AgentStatus};
use super::app::{SidebarFilterMode, SidebarLayoutMode};
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct PrPathEntry {
pub branch: String,
pub summary: PrSummary,
}
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct CheckPathEntry {
pub branch: String,
pub summary: CheckSummary,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SidebarSnapshot {
pub position: SidebarPosition,
pub layout_mode: SidebarLayoutMode,
#[serde(default)]
pub filter_mode: SidebarFilterMode,
pub active_windows: HashSet<(String, String)>,
#[serde(default)]
pub active_pane_ids: HashSet<String>,
#[serde(default)]
pub window_pane_counts: HashMap<String, usize>,
#[serde(default)]
pub git_statuses: HashMap<PathBuf, GitStatus>,
#[serde(default)]
pub pr_statuses: HashMap<PathBuf, PrSummary>,
#[serde(default)]
pub check_statuses: HashMap<PathBuf, CheckSummary>,
#[serde(default)]
pub interrupted_pane_ids: HashSet<String>,
#[serde(default)]
pub sleeping_pane_ids: HashSet<String>,
#[serde(default)]
pub group_by: Option<SidebarGroupBy>,
#[serde(default)]
pub expanded_groups: Vec<String>,
#[serde(default)]
pub stale_pane_ids: HashSet<String>,
#[serde(default)]
pub collapse_stale: bool,
#[serde(default = "default_stale_threshold_secs")]
pub stale_threshold_secs: u64,
pub agents: Vec<AgentPane>,
#[serde(default)]
pub config_version: u64,
}
pub const UNKNOWN_GROUP_LABEL: &str = "(unknown)";
pub const STALE_THRESHOLD_SECS: u64 = crate::config::DEFAULT_STALE_AFTER_SECS;
fn default_stale_threshold_secs() -> u64 {
STALE_THRESHOLD_SECS
}
pub fn group_label(agent: &AgentPane, group_by: SidebarGroupBy) -> String {
let raw = match group_by {
SidebarGroupBy::Project => extract_project_name(&agent.path),
SidebarGroupBy::Session => agent.session.clone(),
SidebarGroupBy::None => String::new(),
};
if raw.trim().is_empty() {
UNKNOWN_GROUP_LABEL.to_string()
} else {
raw
}
}
fn group_sort_key(agent: &AgentPane, group_by: Option<SidebarGroupBy>) -> (String, String) {
match group_by {
Some(mode) => {
let label = group_label(agent, mode);
(label.to_lowercase(), label)
}
None => (String::new(), String::new()),
}
}
fn pane_num(agent: &AgentPane) -> u64 {
agent
.pane_id
.strip_prefix('%')
.unwrap_or(&agent.pane_id)
.parse()
.unwrap_or(u64::MAX)
}
fn activity_age(agent: &AgentPane, now: u64) -> u64 {
agent
.activity_ts()
.map(|ts| now.saturating_sub(ts))
.unwrap_or(u64::MAX)
}
#[derive(Clone, Copy)]
struct StalenessContext<'a> {
sleeping: &'a HashSet<String>,
interrupted: &'a HashSet<String>,
stale_threshold_secs: u64,
}
impl StalenessContext<'_> {
fn is_stale(&self, agent: &AgentPane, now: u64) -> bool {
crate::command::sidebar::template::context::agent_is_stale(
agent,
now,
self.stale_threshold_secs,
self.sleeping.contains(&agent.pane_id),
self.interrupted.contains(&agent.pane_id),
)
}
}
fn priority_category(agent: &AgentPane, now: u64, staleness: &StalenessContext<'_>) -> u8 {
if staleness.sleeping.contains(&agent.pane_id) {
return 5;
}
match agent.status {
Some(AgentStatus::Waiting) => 0,
Some(AgentStatus::Done) => 1,
Some(AgentStatus::Working) => {
if staleness.interrupted.contains(&agent.pane_id) {
4
} else {
2
}
}
None => {
if staleness.is_stale(agent, now) {
4
} else {
3
}
}
}
}
fn dormant_groups(
agents: &[AgentPane],
group_by: Option<SidebarGroupBy>,
now: u64,
staleness: &StalenessContext<'_>,
) -> HashSet<String> {
let Some(mode) = group_by else {
return HashSet::new();
};
let mut all = HashSet::new();
let mut live = HashSet::new();
for agent in agents {
let label = group_label(agent, mode);
if !staleness.is_stale(agent, now) {
live.insert(label.clone());
}
all.insert(label);
}
all.retain(|label| !live.contains(label));
all
}
fn order_agents(
agents: &mut [AgentPane],
group_by: Option<SidebarGroupBy>,
sort: SidebarSort,
now: u64,
staleness: &StalenessContext<'_>,
sink_dormant_groups: bool,
) {
let dormant = if sink_dormant_groups {
dormant_groups(agents, group_by, now, staleness)
} else {
HashSet::new()
};
let group_sort_key = |agent: &AgentPane| {
let (lower, label) = group_sort_key(agent, group_by);
let stale = sink_dormant_groups && staleness.is_stale(agent, now);
(dormant.contains(&label), lower, label, stale)
};
match sort {
SidebarSort::Recency => agents.sort_by_cached_key(|a| {
(
group_sort_key(a),
staleness.sleeping.contains(&a.pane_id),
activity_age(a, now),
pane_num(a),
)
}),
SidebarSort::Priority => agents.sort_by_cached_key(|a| {
(
group_sort_key(a),
priority_category(a, now, staleness),
activity_age(a, now),
pane_num(a),
)
}),
SidebarSort::Window => agents.sort_by_cached_key(|a| {
(
group_sort_key(a),
a.session.clone(),
a.window_index.map(u64::from).unwrap_or(u64::MAX),
pane_num(a),
)
}),
}
}
pub fn is_jump_target(snapshot: &SidebarSnapshot, agent: &AgentPane) -> bool {
snapshot.group_by.is_none() || !snapshot.stale_pane_ids.contains(&agent.pane_id)
}
#[derive(Default)]
pub(crate) struct SnapshotInputs {
pub agents: Vec<AgentPane>,
pub tmux_statuses: HashMap<String, Option<String>>,
pub pane_window_ids: HashMap<String, String>,
pub pane_window_indexes: HashMap<String, u32>,
pub active_windows: HashSet<(String, String)>,
pub active_pane_ids: HashSet<String>,
pub window_pane_counts: HashMap<String, usize>,
pub position: SidebarPosition,
pub layout_mode: SidebarLayoutMode,
pub filter_mode: SidebarFilterMode,
pub sort: SidebarSort,
pub group_by: Option<SidebarGroupBy>,
pub collapse_stale: bool,
pub stale_threshold_secs: Option<u64>,
pub expanded_groups: Vec<String>,
pub status_icons: StatusIcons,
pub git_statuses: HashMap<PathBuf, GitStatus>,
pub pr_statuses: HashMap<PathBuf, PrPathEntry>,
pub check_statuses: HashMap<PathBuf, CheckPathEntry>,
pub sleeping_pane_ids: HashSet<String>,
pub interrupted_pane_ids: HashSet<String>,
}
pub(crate) fn build_snapshot(input: SnapshotInputs) -> SidebarSnapshot {
let SnapshotInputs {
mut agents,
tmux_statuses,
pane_window_ids,
pane_window_indexes,
active_windows,
active_pane_ids,
window_pane_counts,
position,
layout_mode,
filter_mode,
sort,
group_by,
collapse_stale,
stale_threshold_secs,
expanded_groups,
status_icons,
git_statuses,
pr_statuses,
check_statuses,
sleeping_pane_ids,
interrupted_pane_ids,
} = input;
let done_icon = status_icons.done();
let waiting_icon = status_icons.waiting();
for agent in &mut agents {
if let Some(observed) = tmux_statuses.get(&agent.pane_id) {
match agent.status {
Some(AgentStatus::Done) if observed.as_deref() != Some(done_icon) => {
agent.status = None;
}
Some(AgentStatus::Waiting) if observed.as_deref() != Some(waiting_icon) => {
agent.status = None;
}
_ => {}
}
}
}
for agent in &mut agents {
if let Some(wid) = pane_window_ids.get(&agent.pane_id) {
agent.window_id = wid.clone();
}
agent.window_index = pane_window_indexes.get(&agent.pane_id).copied();
}
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let stale_threshold_secs = stale_threshold_secs.unwrap_or(STALE_THRESHOLD_SECS);
let stale_pane_ids: HashSet<String> = agents
.iter()
.filter(|agent| {
crate::command::sidebar::template::context::agent_is_stale(
agent,
now,
stale_threshold_secs,
sleeping_pane_ids.contains(&agent.pane_id),
interrupted_pane_ids.contains(&agent.pane_id),
)
})
.map(|agent| agent.pane_id.clone())
.collect();
let staleness = StalenessContext {
sleeping: &sleeping_pane_ids,
interrupted: &interrupted_pane_ids,
stale_threshold_secs,
};
order_agents(&mut agents, group_by, sort, now, &staleness, collapse_stale);
let live_sleeping: HashSet<String> = sleeping_pane_ids
.iter()
.filter(|id| agents.iter().any(|a| &a.pane_id == *id))
.cloned()
.collect();
let live_paths: HashSet<&PathBuf> = agents.iter().map(|a| &a.path).collect();
let pr_statuses = pr_statuses
.into_iter()
.filter_map(|(path, entry)| {
let branch = git_statuses.get(&path)?.branch.as_deref()?;
if live_paths.contains(&path)
&& branch != "main"
&& branch != "master"
&& branch == entry.branch
{
Some((path, entry.summary))
} else {
None
}
})
.collect();
let check_statuses = check_statuses
.into_iter()
.filter_map(|(path, entry)| {
let branch = git_statuses.get(&path)?.branch.as_deref()?;
if live_paths.contains(&path) && branch == entry.branch {
Some((path, entry.summary))
} else {
None
}
})
.collect();
SidebarSnapshot {
position,
layout_mode,
filter_mode,
active_windows,
active_pane_ids,
window_pane_counts,
git_statuses,
pr_statuses,
check_statuses,
interrupted_pane_ids,
sleeping_pane_ids: live_sleeping,
group_by,
expanded_groups,
stale_pane_ids,
collapse_stale,
stale_threshold_secs,
agents,
config_version: 0,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn agent(path: &str) -> AgentPane {
AgentPane {
session: "s".to_string(),
window_name: "w".to_string(),
pane_id: "%1".to_string(),
window_id: String::new(),
window_index: None,
path: PathBuf::from(path),
pane_title: None,
status: None,
status_ts: None,
activity_ts: None,
updated_ts: None,
window_cmd: None,
agent_command: None,
agent_kind: None,
}
}
fn pr(number: u32) -> PrSummary {
PrSummary {
number,
title: "test".to_string(),
state: "OPEN".to_string(),
is_draft: false,
checks: None,
check_meta: None,
url: None,
}
}
fn pr_entry(branch: &str, number: u32) -> PrPathEntry {
PrPathEntry {
branch: branch.to_string(),
summary: pr(number),
}
}
fn check_entry(branch: &str) -> CheckPathEntry {
CheckPathEntry {
branch: branch.to_string(),
summary: CheckSummary {
state: crate::github::CheckState::Success,
meta: None,
},
}
}
fn build(
agents: Vec<AgentPane>,
git_statuses: HashMap<PathBuf, GitStatus>,
pr_statuses: HashMap<PathBuf, PrPathEntry>,
) -> SidebarSnapshot {
build_with_checks(agents, git_statuses, pr_statuses, HashMap::new())
}
fn build_with_checks(
agents: Vec<AgentPane>,
git_statuses: HashMap<PathBuf, GitStatus>,
pr_statuses: HashMap<PathBuf, PrPathEntry>,
check_statuses: HashMap<PathBuf, CheckPathEntry>,
) -> SidebarSnapshot {
build_snapshot(SnapshotInputs {
agents,
git_statuses,
pr_statuses,
check_statuses,
..Default::default()
})
}
fn grouped_agent(project: &str, session: &str, pane: u32, age: u64, now: u64) -> AgentPane {
let mut a = agent(&format!("/tmp/{project}__worktrees/w{pane}"));
a.session = session.to_string();
a.pane_id = format!("%{pane}");
a.activity_ts = Some(now.saturating_sub(age));
a.status_ts = a.activity_ts;
a
}
#[test]
fn grouping_takes_stale_agents_out_of_the_hotkeys() {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let live = grouped_agent("api", "main", 1, 30, now);
let idle = grouped_agent("api", "main", 2, 6 * 60 * 60, now);
let mut snapshot = build(
vec![live.clone(), idle.clone()],
HashMap::new(),
HashMap::new(),
);
snapshot.group_by = Some(SidebarGroupBy::Project);
snapshot.stale_pane_ids = HashSet::from([idle.pane_id.clone()]);
snapshot.collapse_stale = true;
assert!(is_jump_target(&snapshot, &live));
assert!(!is_jump_target(&snapshot, &idle));
snapshot.expanded_groups = vec!["api".to_string()];
assert!(!is_jump_target(&snapshot, &idle));
snapshot.collapse_stale = false;
assert!(!is_jump_target(&snapshot, &idle));
snapshot.position = SidebarPosition::Top;
assert!(!is_jump_target(&snapshot, &idle));
snapshot.group_by = None;
assert!(is_jump_target(&snapshot, &idle));
assert!(is_jump_target(&snapshot, &live));
}
fn order(agents: &[AgentPane]) -> Vec<String> {
agents.iter().map(|a| a.pane_id.clone()).collect()
}
fn grouping_fixture(now: u64) -> Vec<AgentPane> {
let mut waiting_old = grouped_agent("mobile", "beta", 1, 10_000, now);
waiting_old.status = Some(AgentStatus::Waiting);
let mut working_fresh = grouped_agent("api", "alpha", 2, 5, now);
working_fresh.status = Some(AgentStatus::Working);
let mut done = grouped_agent("api", "beta", 3, 100, now);
done.status = Some(AgentStatus::Done);
let unknown_old = grouped_agent("mobile", "alpha", 4, 10_000, now);
let mut working_recent = grouped_agent("api", "alpha", 5, 1, now);
working_recent.status = Some(AgentStatus::Working);
vec![
waiting_old,
working_fresh,
done,
unknown_old,
working_recent,
]
}
fn ordered(
now: u64,
group_by: Option<SidebarGroupBy>,
sort: SidebarSort,
sleeping: &[&str],
interrupted: &[&str],
) -> Vec<String> {
let mut agents = grouping_fixture(now);
for a in &mut agents {
a.window_index = Some(a.pane_id.trim_start_matches('%').parse().unwrap());
}
let sleeping: HashSet<String> = sleeping.iter().map(|s| s.to_string()).collect();
let interrupted: HashSet<String> = interrupted.iter().map(|s| s.to_string()).collect();
let staleness = StalenessContext {
sleeping: &sleeping,
interrupted: &interrupted,
stale_threshold_secs: STALE_THRESHOLD_SECS,
};
order_agents(&mut agents, group_by, sort, now, &staleness, false);
order(&agents)
}
const NOW: u64 = 1_000_000;
#[test]
fn ungrouped_orders_match_each_sort() {
assert_eq!(
ordered(NOW, None, SidebarSort::Recency, &[], &[]),
["%5", "%2", "%3", "%1", "%4"]
);
assert_eq!(
ordered(NOW, None, SidebarSort::Priority, &[], &[]),
["%1", "%3", "%5", "%2", "%4"]
);
assert_eq!(
ordered(NOW, None, SidebarSort::Window, &[], &[]),
["%2", "%4", "%5", "%1", "%3"]
);
}
#[test]
fn project_grouping_keeps_groups_contiguous_and_alphabetical() {
let by_project = Some(SidebarGroupBy::Project);
assert_eq!(
ordered(NOW, by_project, SidebarSort::Recency, &[], &[]),
["%5", "%2", "%3", "%1", "%4"]
);
assert_eq!(
ordered(NOW, by_project, SidebarSort::Priority, &[], &[]),
["%3", "%5", "%2", "%1", "%4"]
);
assert_eq!(
ordered(NOW, by_project, SidebarSort::Window, &[], &[]),
["%2", "%5", "%3", "%4", "%1"]
);
}
#[test]
fn session_grouping_keeps_groups_contiguous_and_alphabetical() {
let by_session = Some(SidebarGroupBy::Session);
assert_eq!(
ordered(NOW, by_session, SidebarSort::Recency, &[], &[]),
["%5", "%2", "%4", "%3", "%1"]
);
assert_eq!(
ordered(NOW, by_session, SidebarSort::Priority, &[], &[]),
["%5", "%2", "%4", "%1", "%3"]
);
assert_eq!(
ordered(NOW, by_session, SidebarSort::Window, &[], &[]),
["%2", "%4", "%5", "%1", "%3"]
);
}
#[test]
fn waiting_and_done_stay_actionable_regardless_of_age() {
let order = ordered(NOW, None, SidebarSort::Priority, &[], &[]);
assert_eq!(order[0], "%1");
assert_eq!(order[1], "%3");
}
#[test]
fn interrupted_working_demotes_but_waiting_does_not() {
let order = ordered(NOW, None, SidebarSort::Priority, &[], &["%5"]);
assert_eq!(order, ["%1", "%3", "%2", "%5", "%4"]);
}
#[test]
fn sleeping_sinks_to_the_bottom_of_its_own_group() {
let order = ordered(
NOW,
Some(SidebarGroupBy::Project),
SidebarSort::Priority,
&["%3"],
&[],
);
assert_eq!(order, ["%5", "%2", "%3", "%1", "%4"]);
let order = ordered(NOW, None, SidebarSort::Priority, &["%3"], &[]);
assert_eq!(order, ["%1", "%5", "%2", "%4", "%3"]);
}
#[test]
fn window_sort_keeps_sleeping_agents_in_place() {
assert_eq!(
ordered(NOW, None, SidebarSort::Window, &["%2"], &[]),
ordered(NOW, None, SidebarSort::Window, &[], &[])
);
}
#[test]
fn priority_order_is_stable_across_ticks() {
let first = ordered(
NOW,
Some(SidebarGroupBy::Project),
SidebarSort::Priority,
&[],
&[],
);
let mut agents = grouping_fixture(NOW);
let empty = HashSet::new();
let staleness = StalenessContext {
sleeping: &empty,
interrupted: &empty,
stale_threshold_secs: STALE_THRESHOLD_SECS,
};
order_agents(
&mut agents,
Some(SidebarGroupBy::Project),
SidebarSort::Priority,
NOW + 30,
&staleness,
false,
);
assert_eq!(order(&agents), first);
}
#[test]
fn groups_with_no_live_work_sort_below_the_rest() {
let mut working = grouped_agent("zed", "s", 2, 5, NOW);
working.status = Some(AgentStatus::Working);
let quiet = grouped_agent("alpha", "s", 1, 10_000, NOW);
let empty = HashSet::new();
let staleness = StalenessContext {
sleeping: &empty,
interrupted: &empty,
stale_threshold_secs: STALE_THRESHOLD_SECS,
};
let mut agents = vec![quiet.clone(), working.clone()];
order_agents(
&mut agents,
Some(SidebarGroupBy::Project),
SidebarSort::Priority,
NOW,
&staleness,
true,
);
assert_eq!(order(&agents), ["%2", "%1"]);
let mut agents = vec![quiet, working];
order_agents(
&mut agents,
Some(SidebarGroupBy::Project),
SidebarSort::Priority,
NOW,
&staleness,
false,
);
assert_eq!(order(&agents), ["%1", "%2"]);
}
#[test]
fn group_label_falls_back_when_the_session_is_empty() {
let mut a = agent("/tmp/api__worktrees/w1");
a.session = String::new();
assert_eq!(
group_label(&a, SidebarGroupBy::Session),
UNKNOWN_GROUP_LABEL
);
assert_eq!(group_label(&a, SidebarGroupBy::Project), "api");
}
#[test]
fn group_order_is_case_insensitive_with_a_deterministic_tiebreak() {
let mut upper = grouped_agent("api", "API", 1, 5, NOW);
upper.session = "API".to_string();
let mut lower = grouped_agent("api", "api", 2, 5, NOW);
lower.session = "api".to_string();
let mut zed = grouped_agent("api", "zed", 3, 5, NOW);
zed.session = "zed".to_string();
let mut agents = vec![zed, lower, upper];
let empty = HashSet::new();
let staleness = StalenessContext {
sleeping: &empty,
interrupted: &empty,
stale_threshold_secs: STALE_THRESHOLD_SECS,
};
order_agents(
&mut agents,
Some(SidebarGroupBy::Session),
SidebarSort::Recency,
NOW,
&staleness,
false,
);
assert_eq!(order(&agents), ["%1", "%2", "%3"]);
}
#[test]
fn snapshot_without_group_by_or_threshold_uses_legacy_defaults() {
let snapshot = build_snapshot(SnapshotInputs::default());
let mut json: serde_json::Value = serde_json::to_value(&snapshot).unwrap();
let object = json.as_object_mut().unwrap();
object.remove("group_by");
object.remove("stale_threshold_secs");
let restored: SidebarSnapshot = serde_json::from_value(json).unwrap();
assert_eq!(restored.group_by, None);
assert_eq!(restored.stale_threshold_secs, STALE_THRESHOLD_SECS);
}
#[test]
fn configured_threshold_drives_snapshot_staleness() {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let mut idle = agent("/tmp/api__worktrees/idle");
idle.activity_ts = Some(now.saturating_sub(2 * 60 * 60));
let snapshot = build_snapshot(SnapshotInputs {
agents: vec![idle.clone()],
stale_threshold_secs: Some(5 * 60 * 60),
..Default::default()
});
assert!(!snapshot.stale_pane_ids.contains(&idle.pane_id));
assert_eq!(snapshot.stale_threshold_secs, 5 * 60 * 60);
let default_snapshot = build_snapshot(SnapshotInputs {
agents: vec![idle.clone()],
..Default::default()
});
assert!(default_snapshot.stale_pane_ids.contains(&idle.pane_id));
}
#[test]
fn newly_registered_agent_sorts_first_by_activity() {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs();
let mut registered = agent("/repo/new");
registered.pane_id = "%2".to_string();
registered.activity_ts = Some(now);
let mut done = agent("/repo/done");
done.pane_id = "%1".to_string();
done.status = Some(AgentStatus::Done);
done.status_ts = Some(now - 60);
done.activity_ts = Some(now - 60);
let snapshot = build(vec![done, registered], HashMap::new(), HashMap::new());
assert_eq!(snapshot.agents[0].pane_id, "%2");
assert!(snapshot.agents[0].status.is_none());
assert!(snapshot.agents[0].status_ts.is_none());
}
#[test]
fn legacy_statusless_registration_uses_updated_time_for_recency() {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs();
let mut registered = agent("/repo/new");
registered.pane_id = "%2".to_string();
registered.updated_ts = Some(now);
let mut done = agent("/repo/done");
done.pane_id = "%1".to_string();
done.status = Some(AgentStatus::Done);
done.status_ts = Some(now - 60);
let snapshot = build(vec![done, registered], HashMap::new(), HashMap::new());
assert_eq!(snapshot.agents[0].pane_id, "%2");
}
#[test]
fn window_index_stamped_from_tmux_state() {
let agents = vec![agent("/repo")];
let indexes = HashMap::from([("%1".to_string(), 4u32)]);
let snapshot = build_snapshot(SnapshotInputs {
agents,
pane_window_indexes: indexes,
..Default::default()
});
assert_eq!(snapshot.agents[0].window_index, Some(4));
let mut stale = agent("/repo");
stale.window_index = Some(9);
let snapshot =
build_with_checks(vec![stale], HashMap::new(), HashMap::new(), HashMap::new());
assert_eq!(snapshot.agents[0].window_index, None);
}
#[test]
fn window_sort_orders_by_index_not_recency() {
let mut a = agent("/repo/a");
a.pane_id = "%1".to_string();
a.status_ts = Some(1); let mut b = agent("/repo/b");
b.pane_id = "%2".to_string();
b.status_ts = Some(999);
let mut c = agent("/repo/c");
c.pane_id = "%3".to_string();
c.status_ts = Some(500);
let indexes = HashMap::from([("%1".to_string(), 2u32), ("%2".to_string(), 7u32)]);
let snapshot = build_snapshot(SnapshotInputs {
agents: vec![b, c, a],
pane_window_indexes: indexes,
sort: SidebarSort::Window,
..Default::default()
});
let order: Vec<_> = snapshot.agents.iter().map(|a| a.pane_id.clone()).collect();
assert_eq!(order, vec!["%1", "%2", "%3"]); }
#[test]
fn pr_statuses_exclude_main_branch_paths() {
let path = PathBuf::from("/repo");
let git = GitStatus {
branch: Some("main".to_string()),
base_branch: "main".to_string(),
..Default::default()
};
let snapshot = build(
vec![agent("/repo")],
HashMap::from([(path.clone(), git)]),
HashMap::from([(path.clone(), pr_entry("main", 10757))]),
);
assert!(!snapshot.pr_statuses.contains_key(&path));
}
#[test]
fn check_statuses_include_main_branch_paths() {
let path = PathBuf::from("/repo");
let git = GitStatus {
branch: Some("main".to_string()),
base_branch: "main".to_string(),
..Default::default()
};
let snapshot = build_with_checks(
vec![agent("/repo")],
HashMap::from([(path.clone(), git)]),
HashMap::new(),
HashMap::from([(path.clone(), check_entry("main"))]),
);
assert!(snapshot.check_statuses.contains_key(&path));
}
#[test]
fn check_statuses_exclude_mismatched_branch() {
let path = PathBuf::from("/repo");
let git = GitStatus {
branch: Some("feature-b".to_string()),
..Default::default()
};
let snapshot = build_with_checks(
vec![agent("/repo")],
HashMap::from([(path.clone(), git)]),
HashMap::new(),
HashMap::from([(path.clone(), check_entry("feature-a"))]),
);
assert!(!snapshot.check_statuses.contains_key(&path));
}
#[test]
fn pr_statuses_keep_feature_branch_paths() {
let path = PathBuf::from("/repo");
let git = GitStatus {
branch: Some("feature".to_string()),
..Default::default()
};
let snapshot = build(
vec![agent("/repo")],
HashMap::from([(path.clone(), git)]),
HashMap::from([(path.clone(), pr_entry("feature", 123))]),
);
assert_eq!(
snapshot.pr_statuses.get(&path).map(|pr| pr.number),
Some(123)
);
}
#[test]
fn pr_statuses_exclude_master_branch_paths() {
let path = PathBuf::from("/repo");
let git = GitStatus {
branch: Some("master".to_string()),
base_branch: "master".to_string(),
..Default::default()
};
let snapshot = build(
vec![agent("/repo")],
HashMap::from([(path.clone(), git)]),
HashMap::from([(path.clone(), pr_entry("master", 10757))]),
);
assert!(!snapshot.pr_statuses.contains_key(&path));
}
#[test]
fn pr_statuses_exclude_mismatched_branch() {
let path = PathBuf::from("/repo");
let git = GitStatus {
branch: Some("feature-b".to_string()),
..Default::default()
};
let snapshot = build(
vec![agent("/repo")],
HashMap::from([(path.clone(), git)]),
HashMap::from([(path.clone(), pr_entry("feature-a", 123))]),
);
assert!(!snapshot.pr_statuses.contains_key(&path));
}
#[test]
fn pr_statuses_exclude_missing_branch() {
let path = PathBuf::from("/repo");
let snapshot = build(
vec![agent("/repo")],
HashMap::from([(path.clone(), GitStatus::default())]),
HashMap::from([(path.clone(), pr_entry("feature", 123))]),
);
assert!(!snapshot.pr_statuses.contains_key(&path));
}
#[test]
fn pr_statuses_exclude_stale_paths() {
let live_path = PathBuf::from("/repo");
let stale_path = PathBuf::from("/old-repo");
let git = GitStatus {
branch: Some("feature".to_string()),
..Default::default()
};
let snapshot = build(
vec![agent("/repo")],
HashMap::from([(live_path.clone(), git.clone()), (stale_path.clone(), git)]),
HashMap::from([
(live_path.clone(), pr_entry("feature", 1)),
(stale_path.clone(), pr_entry("feature", 2)),
]),
);
assert!(snapshot.pr_statuses.contains_key(&live_path));
assert!(!snapshot.pr_statuses.contains_key(&stale_path));
}
}