use std::collections::HashSet;
use std::time::{Duration, Instant};
use ansi_to_tui::IntoText;
use ratatui::text::Text;
use ratatui::widgets::ListState;
use crate::group::GroupStore;
pub const UNGROUPED_LABEL: &str = "Ungrouped";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ClaudeState {
Working,
Waiting,
Done,
Error,
}
impl ClaudeState {
pub fn from_hook_event(event: &str) -> Option<Self> {
match event {
"UserPromptSubmit" | "PreToolUse" | "PostToolUse" | "PreCompact" => Some(Self::Working),
"Notification" => Some(Self::Waiting),
"Stop" | "SubagentStop" => Some(Self::Done),
"StopFailure" => Some(Self::Error),
_ => None,
}
}
pub fn as_token(self) -> &'static str {
match self {
Self::Working => "working",
Self::Waiting => "waiting",
Self::Done => "done",
Self::Error => "error",
}
}
pub fn from_token(token: &str) -> Option<Self> {
match token {
"working" => Some(Self::Working),
"waiting" => Some(Self::Waiting),
"done" => Some(Self::Done),
"error" => Some(Self::Error),
_ => None,
}
}
pub fn priority(self) -> u8 {
match self {
Self::Waiting => 3,
Self::Error => 2,
Self::Working => 1,
Self::Done => 0,
}
}
pub fn merge(a: Option<Self>, b: Option<Self>) -> Option<Self> {
match (a, b) {
(Some(x), Some(y)) => Some(if x.priority() >= y.priority() { x } else { y }),
(Some(x), None) => Some(x),
(None, b) => b,
}
}
}
#[derive(Debug, Clone)]
pub struct TmuxPane {
pub id: String,
pub index: u32,
#[allow(dead_code)]
pub width: u32,
#[allow(dead_code)]
pub height: u32,
pub active: bool,
pub current_command: String,
pub pid: u32,
pub has_claude: bool,
pub claude_state: Option<ClaudeState>,
}
#[derive(Debug, Clone)]
pub struct TmuxWindow {
pub index: u32,
pub name: String,
pub panes: Vec<TmuxPane>,
pub has_claude: bool,
pub claude_state: Option<ClaudeState>,
}
impl TmuxWindow {
pub fn get_active_pane(&self) -> Option<&TmuxPane> {
self.panes.iter().find(|p| p.active).or(self.panes.first())
}
}
#[derive(Debug, Clone)]
pub struct TmuxSession {
pub name: String,
pub windows: Vec<TmuxWindow>,
pub has_claude: bool,
pub claude_state: Option<ClaudeState>,
pub last_attached: i64,
pub activity: i64,
pub group: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum ViewMode {
TreeView,
MultiPreview,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Focus {
Sessions,
Windows,
Panes,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum InputMode {
Normal,
Input,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SessionSortKey {
LastAttached,
Alphabet,
}
impl SessionSortKey {
pub fn label(self) -> &'static str {
match self {
SessionSortKey::LastAttached => "recent",
SessionSortKey::Alphabet => "abc",
}
}
fn cmp_ascending(self, a: &TmuxSession, b: &TmuxSession) -> std::cmp::Ordering {
match self {
SessionSortKey::LastAttached => a
.last_attached
.cmp(&b.last_attached)
.then_with(|| a.activity.cmp(&b.activity)),
SessionSortKey::Alphabet => a
.name
.to_lowercase()
.cmp(&b.name.to_lowercase()),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SortDirection {
Desc,
Asc,
}
impl SortDirection {
pub fn arrow(self) -> char {
match self {
SortDirection::Desc => '↓',
SortDirection::Asc => '↑',
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SessionSort {
pub key: SessionSortKey,
pub direction: SortDirection,
}
impl SessionSort {
pub const ALL: &'static [SessionSort] = &[
SessionSort {
key: SessionSortKey::LastAttached,
direction: SortDirection::Desc,
},
SessionSort {
key: SessionSortKey::LastAttached,
direction: SortDirection::Asc,
},
SessionSort {
key: SessionSortKey::Alphabet,
direction: SortDirection::Desc,
},
SessionSort {
key: SessionSortKey::Alphabet,
direction: SortDirection::Asc,
},
];
pub fn label(self) -> String {
format!("{}{}", self.key.label(), self.direction.arrow())
}
pub fn next(self) -> Self {
let idx = Self::ALL.iter().position(|s| *s == self).unwrap_or(0);
Self::ALL[(idx + 1) % Self::ALL.len()]
}
pub fn apply(self, sessions: &mut [TmuxSession]) {
sessions.sort_by(|a, b| {
let ord = self.key.cmp_ascending(a, b);
let ord = match self.direction {
SortDirection::Desc => ord.reverse(),
SortDirection::Asc => ord,
};
ord.then_with(|| a.name.cmp(&b.name))
});
}
}
impl Default for SessionSort {
fn default() -> Self {
Self::ALL[0]
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum PopupMode {
NewSession,
RenameSession,
ConfirmKill,
GroupSession,
NewGroup,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum GroupChoice {
Existing(String),
Ungrouped,
New,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SessionRow {
Header {
group: Option<String>,
count: usize,
collapsed: bool,
},
Session { index: usize },
}
pub struct UIState {
pub view_mode: ViewMode,
pub last_space_press: Option<Instant>,
pub sessions: Vec<TmuxSession>,
pub selected_session: usize,
pub selected_window: usize,
pub selected_pane: usize,
pub focus: Focus,
pub session_list_state: ListState,
pub window_list_state: ListState,
pub pane_list_state: ListState,
pub session_sort: SessionSort,
pub groups: GroupStore,
pub collapsed_groups: HashSet<Option<String>>,
pub pending_z: bool,
pub multi_session: usize,
pub multi_window: usize,
pub pane_content: String,
pub pane_content_parsed: Option<Text<'static>>,
pub last_error: Option<String>,
#[allow(dead_code)]
pub interval: Duration,
pub input_mode: InputMode,
pub input_buffer: String,
pub input_cursor: usize,
pub popup_mode: Option<PopupMode>,
pub confirm_yes_selected: bool,
pub group_choices: Vec<String>,
pub group_choice_index: usize,
}
impl UIState {
pub fn new(interval_ms: u64) -> Self {
let mut state = Self {
view_mode: ViewMode::TreeView,
last_space_press: None,
sessions: Vec::new(),
selected_session: 0,
selected_window: 0,
selected_pane: 0,
focus: Focus::Sessions,
session_list_state: ListState::default(),
window_list_state: ListState::default(),
pane_list_state: ListState::default(),
session_sort: SessionSort::default(),
groups: GroupStore::load(),
collapsed_groups: HashSet::new(),
pending_z: false,
multi_session: 0,
multi_window: 0,
pane_content: String::new(),
pane_content_parsed: None,
last_error: None,
interval: Duration::from_millis(interval_ms),
input_mode: InputMode::Normal,
input_buffer: String::new(),
input_cursor: 0,
popup_mode: None,
group_choices: Vec::new(),
group_choice_index: 0,
confirm_yes_selected: false,
};
state.session_list_state.select(Some(0));
state.window_list_state.select(Some(0));
state.pane_list_state.select(Some(0));
state
}
pub fn refresh_claude_states(&mut self) {
crate::hook::apply_states(&mut self.sessions);
}
pub fn has_working_claude(&self) -> bool {
self.sessions
.iter()
.any(|s| s.claude_state == Some(ClaudeState::Working))
}
pub fn handle_space_press(&mut self) -> bool {
let now = Instant::now();
if let Some(last) = self.last_space_press
&& now.duration_since(last) < Duration::from_millis(300)
{
self.toggle_view_mode();
self.last_space_press = None;
return true;
}
self.last_space_press = Some(now);
false
}
pub fn toggle_view_mode(&mut self) {
self.view_mode = match self.view_mode {
ViewMode::TreeView => {
self.multi_session = self.selected_session;
self.multi_window = self.selected_window;
ViewMode::MultiPreview
}
ViewMode::MultiPreview => {
self.selected_session = self.multi_session;
self.selected_window = self.multi_window;
self.selected_pane = 0;
self.session_list_state.select(Some(self.selected_session));
self.window_list_state.select(Some(self.selected_window));
self.pane_list_state.select(Some(0));
ViewMode::TreeView
}
};
}
pub fn enter_input_mode(&mut self) {
self.input_mode = InputMode::Input;
self.input_buffer.clear();
self.input_cursor = 0;
}
pub fn exit_input_mode(&mut self) {
self.input_mode = InputMode::Normal;
self.input_buffer.clear();
self.input_cursor = 0;
}
pub fn get_current_target(&self) -> Option<String> {
match self.view_mode {
ViewMode::TreeView => self.get_selected_pane_target(),
ViewMode::MultiPreview => self.get_multi_selected_target(),
}
}
pub fn get_enter_target(&self) -> Option<String> {
match self.view_mode {
ViewMode::TreeView => match self.focus {
Focus::Sessions => self
.sessions
.get(self.selected_session)
.map(|s| s.name.clone()),
Focus::Windows => {
let session = self.sessions.get(self.selected_session)?;
let window = session.windows.get(self.selected_window)?;
Some(format!("{}:{}", session.name, window.index))
}
Focus::Panes => self.get_selected_pane_target(),
},
ViewMode::MultiPreview => self.get_multi_selected_target(),
}
}
pub fn input_char(&mut self, c: char) {
self.input_buffer.insert(self.input_cursor, c);
self.input_cursor += 1;
}
pub fn input_backspace(&mut self) {
if self.input_cursor > 0 {
self.input_cursor -= 1;
self.input_buffer.remove(self.input_cursor);
}
}
pub fn input_delete(&mut self) {
if self.input_cursor < self.input_buffer.len() {
self.input_buffer.remove(self.input_cursor);
}
}
pub fn input_move_left(&mut self) {
if self.input_cursor > 0 {
self.input_cursor -= 1;
}
}
pub fn input_move_right(&mut self) {
if self.input_cursor < self.input_buffer.len() {
self.input_cursor += 1;
}
}
pub fn input_move_home(&mut self) {
self.input_cursor = 0;
}
pub fn input_move_end(&mut self) {
self.input_cursor = self.input_buffer.len();
}
pub fn open_new_session_popup(&mut self) {
self.popup_mode = Some(PopupMode::NewSession);
self.input_buffer.clear();
self.input_cursor = 0;
}
pub fn open_rename_session_popup(&mut self) {
if let Some(session) = self.sessions.get(self.selected_session) {
self.popup_mode = Some(PopupMode::RenameSession);
self.input_buffer = session.name.clone();
self.input_cursor = self.input_buffer.len();
}
}
pub fn open_group_session_popup(&mut self) {
let Some(session) = self.sessions.get(self.selected_session) else {
return;
};
let current = session.group.clone();
self.popup_mode = Some(PopupMode::GroupSession);
self.group_choices = self.groups.group_names();
self.group_choice_index = match current {
Some(g) => self
.group_choices
.iter()
.position(|name| *name == g)
.unwrap_or(self.group_choices.len()),
None => self.group_choices.len(),
};
self.input_buffer.clear();
self.input_cursor = 0;
}
pub fn group_choice_count(&self) -> usize {
self.group_choices.len() + 2
}
pub fn selected_group_choice(&self) -> GroupChoice {
let n = self.group_choices.len();
if self.group_choice_index < n {
GroupChoice::Existing(self.group_choices[self.group_choice_index].clone())
} else if self.group_choice_index == n {
GroupChoice::Ungrouped
} else {
GroupChoice::New
}
}
pub fn group_choice_up(&mut self) {
let n = self.group_choice_count();
self.group_choice_index = (self.group_choice_index + n - 1) % n;
}
pub fn group_choice_down(&mut self) {
let n = self.group_choice_count();
self.group_choice_index = (self.group_choice_index + 1) % n;
}
pub fn begin_new_group_entry(&mut self) {
self.popup_mode = Some(PopupMode::NewGroup);
self.input_buffer.clear();
self.input_cursor = 0;
}
pub fn open_kill_session_popup(&mut self) {
if !self.sessions.is_empty() {
self.popup_mode = Some(PopupMode::ConfirmKill);
self.confirm_yes_selected = false; }
}
pub fn close_popup(&mut self) {
self.popup_mode = None;
self.input_buffer.clear();
self.input_cursor = 0;
self.confirm_yes_selected = false;
self.group_choices.clear();
self.group_choice_index = 0;
}
pub fn toggle_confirm_selection(&mut self) {
self.confirm_yes_selected = !self.confirm_yes_selected;
}
pub fn get_new_session_name(&self) -> String {
self.input_buffer.trim().to_string()
}
pub fn get_rename_session_info(&self) -> Option<(String, String)> {
let new_name = self.input_buffer.trim().to_string();
if new_name.is_empty() {
return None;
}
self.sessions
.get(self.selected_session)
.map(|s| (s.name.clone(), new_name))
}
pub fn get_group_session_input(&self) -> Option<String> {
let trimmed = self.input_buffer.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
}
pub fn get_kill_session_name(&self) -> Option<String> {
if self.confirm_yes_selected {
self.sessions
.get(self.selected_session)
.map(|s| s.name.clone())
} else {
None
}
}
pub fn update_sessions(&mut self, sessions: Vec<TmuxSession>) {
let current_name = self
.sessions
.get(self.selected_session)
.map(|s| s.name.clone());
self.sessions = sessions;
self.apply_group_labels();
self.order_sessions();
if let Some(name) = current_name
&& let Some(idx) = self.sessions.iter().position(|s| s.name == name)
{
self.selected_session = idx;
}
self.validate_selections();
self.last_error = None;
}
fn apply_group_labels(&mut self) {
for session in &mut self.sessions {
session.group = self.groups.group_of(&session.name);
}
}
fn order_sessions(&mut self) {
self.session_sort.apply(&mut self.sessions);
self.sessions.sort_by(|a, b| match (&a.group, &b.group) {
(Some(x), Some(y)) => x.to_lowercase().cmp(&y.to_lowercase()),
(Some(_), None) => std::cmp::Ordering::Less,
(None, Some(_)) => std::cmp::Ordering::Greater,
(None, None) => std::cmp::Ordering::Equal,
});
}
pub fn assign_selected_group(&mut self, group: Option<String>) {
let Some(session) = self.sessions.get(self.selected_session) else {
return;
};
let name = session.name.clone();
self.groups.set(&name, group.as_deref());
self.collapsed_groups.remove(&group);
self.apply_group_labels();
self.resort_sessions_preserve_selection();
}
fn any_grouped(&self) -> bool {
self.sessions.iter().any(|s| s.group.is_some())
}
fn is_collapsed(&self, group: &Option<String>) -> bool {
self.any_grouped() && self.collapsed_groups.contains(group)
}
fn is_group_head(&self, index: usize) -> bool {
match self.sessions.get(index) {
None => false,
Some(s) => index == 0 || self.sessions[index - 1].group != s.group,
}
}
fn is_cursor_stop(&self, index: usize) -> bool {
match self.sessions.get(index) {
None => false,
Some(s) => !self.is_collapsed(&s.group) || self.is_group_head(index),
}
}
pub fn selection_on_folded_header(&self) -> bool {
self.sessions
.get(self.selected_session)
.map(|s| self.is_collapsed(&s.group))
.unwrap_or(false)
}
pub fn toggle_fold_current_group(&mut self) {
if !self.any_grouped() {
return;
}
let Some(session) = self.sessions.get(self.selected_session) else {
return;
};
let group = session.group.clone();
if self.collapsed_groups.contains(&group) {
self.collapsed_groups.remove(&group);
} else {
self.collapsed_groups.insert(group.clone());
if let Some(head) = self.sessions.iter().position(|s| s.group == group) {
self.selected_session = head;
self.selected_window = 0;
self.selected_pane = 0;
self.window_list_state.select(Some(0));
self.pane_list_state.select(Some(0));
}
}
}
fn next_cursor_stop(&self, from: usize) -> Option<usize> {
((from + 1)..self.sessions.len()).find(|&i| self.is_cursor_stop(i))
}
fn prev_cursor_stop(&self, from: usize) -> Option<usize> {
(0..from).rev().find(|&i| self.is_cursor_stop(i))
}
pub fn session_rows(&self) -> Vec<SessionRow> {
let any_grouped = self.any_grouped();
let mut rows = Vec::with_capacity(self.sessions.len());
let mut current: Option<&Option<String>> = None;
for (index, session) in self.sessions.iter().enumerate() {
let collapsed = any_grouped && self.collapsed_groups.contains(&session.group);
if any_grouped && current != Some(&session.group) {
let count = self
.sessions
.iter()
.filter(|s| s.group == session.group)
.count();
rows.push(SessionRow::Header {
group: session.group.clone(),
count,
collapsed,
});
current = Some(&session.group);
}
if !collapsed {
rows.push(SessionRow::Session { index });
}
}
rows
}
pub fn cycle_session_sort(&mut self) {
self.session_sort = self.session_sort.next();
self.resort_sessions_preserve_selection();
}
fn resort_sessions_preserve_selection(&mut self) {
let current_name = self
.sessions
.get(self.selected_session)
.map(|s| s.name.clone());
self.order_sessions();
if let Some(name) = current_name
&& let Some(idx) = self.sessions.iter().position(|s| s.name == name)
{
self.selected_session = idx;
self.multi_session = self.multi_session.min(self.sessions.len().saturating_sub(1));
self.session_list_state.select(Some(idx));
}
}
pub fn update_pane_content(&mut self, content: String) {
self.pane_content_parsed = content.as_bytes().into_text().ok();
self.pane_content = content;
}
pub fn set_error(&mut self, message: String) {
self.last_error = Some(message);
}
pub fn validate_selections(&mut self) {
if !self.sessions.is_empty() {
self.selected_session = self.selected_session.min(self.sessions.len() - 1);
self.multi_session = self.multi_session.min(self.sessions.len() - 1);
if let Some(session) = self.sessions.get(self.selected_session)
&& !session.windows.is_empty()
{
self.selected_window = self.selected_window.min(session.windows.len() - 1);
if let Some(window) = session.windows.get(self.selected_window)
&& !window.panes.is_empty()
{
self.selected_pane = self.selected_pane.min(window.panes.len() - 1);
}
}
if let Some(session) = self.sessions.get(self.multi_session)
&& !session.windows.is_empty()
{
self.multi_window = self.multi_window.min(session.windows.len() - 1);
}
self.session_list_state.select(Some(self.selected_session));
self.window_list_state.select(Some(self.selected_window));
self.pane_list_state.select(Some(self.selected_pane));
} else {
self.session_list_state.select(None);
self.window_list_state.select(None);
self.pane_list_state.select(None);
}
}
pub fn get_selected_pane_target(&self) -> Option<String> {
let session = self.sessions.get(self.selected_session)?;
let window = session.windows.get(self.selected_window)?;
let pane = window.panes.get(self.selected_pane)?;
Some(format!("{}:{}.{}", session.name, window.index, pane.index))
}
pub fn get_selected_pane_target_with_capture_range(&self) -> Option<(String, i32, i32)> {
let session = self.sessions.get(self.selected_session)?;
let window = session.windows.get(self.selected_window)?;
let pane = window.panes.get(self.selected_pane)?;
let target = format!("{}:{}.{}", session.name, window.index, pane.index);
let height = i32::try_from(pane.height).unwrap_or(i32::MAX);
let start = 0;
let end = height;
Some((target, start, end))
}
pub fn tree_move_up(&mut self) {
match self.focus {
Focus::Sessions => {
if let Some(prev) = self.prev_cursor_stop(self.selected_session) {
self.selected_session = prev;
self.selected_window = 0;
self.selected_pane = 0;
self.window_list_state.select(Some(0));
self.pane_list_state.select(Some(0));
}
self.session_list_state.select(Some(self.selected_session));
}
Focus::Windows => {
if self.selected_window > 0 {
self.selected_window -= 1;
self.selected_pane = 0;
self.pane_list_state.select(Some(0));
}
self.window_list_state.select(Some(self.selected_window));
}
Focus::Panes => {
if self.selected_pane > 0 {
self.selected_pane -= 1;
}
self.pane_list_state.select(Some(self.selected_pane));
}
}
}
pub fn tree_move_down(&mut self) {
match self.focus {
Focus::Sessions => {
if let Some(next) = self.next_cursor_stop(self.selected_session) {
self.selected_session = next;
self.selected_window = 0;
self.selected_pane = 0;
self.window_list_state.select(Some(0));
self.pane_list_state.select(Some(0));
}
self.session_list_state.select(Some(self.selected_session));
}
Focus::Windows => {
if let Some(session) = self.sessions.get(self.selected_session)
&& self.selected_window < session.windows.len().saturating_sub(1)
{
self.selected_window += 1;
self.selected_pane = 0;
self.pane_list_state.select(Some(0));
}
self.window_list_state.select(Some(self.selected_window));
}
Focus::Panes => {
if let Some(session) = self.sessions.get(self.selected_session)
&& let Some(window) = session.windows.get(self.selected_window)
&& self.selected_pane < window.panes.len().saturating_sub(1)
{
self.selected_pane += 1;
}
self.pane_list_state.select(Some(self.selected_pane));
}
}
}
pub fn tree_next_focus(&mut self) {
self.focus = match self.focus {
Focus::Sessions => Focus::Windows,
Focus::Windows => Focus::Panes,
Focus::Panes => Focus::Sessions,
};
}
pub fn tree_prev_focus(&mut self) {
self.focus = match self.focus {
Focus::Sessions => Focus::Panes,
Focus::Windows => Focus::Sessions,
Focus::Panes => Focus::Windows,
};
}
pub fn get_multi_selected_target(&self) -> Option<String> {
let session = self.sessions.get(self.multi_session)?;
let window = session.windows.get(self.multi_window)?;
Some(format!("{}:{}", session.name, window.index))
}
pub fn multi_move_left(&mut self) {
if self.multi_session > 0 {
self.multi_session -= 1;
self.multi_window = 0;
}
}
pub fn multi_move_right(&mut self) {
if self.multi_session < self.sessions.len().saturating_sub(1) {
self.multi_session += 1;
self.multi_window = 0;
}
}
pub fn multi_move_up(&mut self) {
if self.multi_window > 0 {
self.multi_window -= 1;
}
}
pub fn multi_move_down(&mut self) {
if let Some(session) = self.sessions.get(self.multi_session)
&& self.multi_window < session.windows.len().saturating_sub(1)
{
self.multi_window += 1;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn session(name: &str) -> TmuxSession {
TmuxSession {
name: name.to_string(),
windows: Vec::new(),
has_claude: false,
claude_state: None,
last_attached: 0,
activity: 0,
group: None,
}
}
fn state_with(names: &[&str], groups: &[(&str, &str)]) -> UIState {
let mut state = UIState::new(100);
state.groups = GroupStore::default();
for (sess, grp) in groups {
state.groups.set(sess, Some(grp));
}
state.update_sessions(names.iter().map(|n| session(n)).collect());
state
}
#[test]
fn ungrouped_sessions_have_no_headers() {
let state = state_with(&["a", "b", "c"], &[]);
let rows = state.session_rows();
assert_eq!(rows.len(), 3);
assert!(rows.iter().all(|r| matches!(r, SessionRow::Session { .. })));
}
#[test]
fn grouped_sessions_cluster_with_ungrouped_last() {
let state = state_with(&["a", "b", "c"], &[("a", "work"), ("c", "work")]);
let ordered: Vec<&str> = state.sessions.iter().map(|s| s.name.as_str()).collect();
assert_eq!(ordered, vec!["a", "c", "b"]);
}
#[test]
fn rows_insert_one_header_per_group() {
let state = state_with(
&["a", "b", "c"],
&[("a", "work"), ("c", "work"), ("b", "play")],
);
let rows = state.session_rows();
let labels: Vec<String> = rows
.iter()
.filter_map(|r| match r {
SessionRow::Header { group, count, .. } => {
Some(format!("{}:{}", group.as_deref().unwrap_or("none"), count))
}
SessionRow::Session { .. } => None,
})
.collect();
assert_eq!(labels, vec!["play:1".to_string(), "work:2".to_string()]);
}
#[test]
fn ungrouped_bucket_gets_a_header_when_mixed() {
let state = state_with(&["a", "b"], &[("a", "work")]);
let rows = state.session_rows();
let has_ungrouped_header = rows.iter().any(|r| {
matches!(r, SessionRow::Header { group: None, count, .. } if *count == 1)
});
assert!(has_ungrouped_header);
}
#[test]
fn folding_hides_members_but_keeps_header() {
let mut state = state_with(
&["a", "b", "c"],
&[("a", "work"), ("c", "work"), ("b", "play")],
);
let work_idx = state.sessions.iter().position(|s| s.name == "a").unwrap();
state.selected_session = work_idx;
state.toggle_fold_current_group();
let rows = state.session_rows();
let work_collapsed = rows.iter().any(|r| matches!(
r,
SessionRow::Header { group: Some(g), collapsed: true, .. } if g == "work"
));
assert!(work_collapsed);
let visible_sessions: Vec<&str> = rows
.iter()
.filter_map(|r| match r {
SessionRow::Session { index } => Some(state.sessions[*index].name.as_str()),
_ => None,
})
.collect();
assert_eq!(visible_sessions, vec!["b"]);
assert!(state.selection_on_folded_header());
assert_eq!(state.sessions[state.selected_session].group.as_deref(), Some("work"));
state.toggle_fold_current_group();
let rows = state.session_rows();
let names: Vec<&str> = rows
.iter()
.filter_map(|r| match r {
SessionRow::Session { index } => Some(state.sessions[*index].name.as_str()),
_ => None,
})
.collect();
assert!(names.contains(&"a") && names.contains(&"c"));
assert!(!state.selection_on_folded_header());
}
#[test]
fn navigation_lands_on_folded_group_then_reopens() {
let mut state = state_with(
&["a", "b", "c"],
&[("a", "work"), ("c", "work"), ("b", "play")],
);
state.selected_session = state.sessions.iter().position(|s| s.name == "a").unwrap();
state.toggle_fold_current_group();
state.selected_session = state.sessions.iter().position(|s| s.name == "b").unwrap();
state.tree_move_down();
assert!(state.selection_on_folded_header());
assert_eq!(state.sessions[state.selected_session].group.as_deref(), Some("work"));
state.toggle_fold_current_group();
assert!(!state.selection_on_folded_header());
}
#[test]
fn fold_is_noop_without_groups() {
let mut state = state_with(&["a", "b"], &[]);
state.toggle_fold_current_group();
let rows = state.session_rows();
assert_eq!(rows.len(), 2);
assert!(rows.iter().all(|r| matches!(r, SessionRow::Session { .. })));
}
#[test]
fn assigning_group_updates_store_and_order() {
let mut state = state_with(&["a", "b"], &[]);
state.selected_session = 1; state.assign_selected_group(Some("work".to_string()));
assert_eq!(state.groups.group_of("b"), Some("work".to_string()));
let ordered: Vec<&str> = state.sessions.iter().map(|s| s.name.as_str()).collect();
assert_eq!(ordered, vec!["b", "a"]);
assert_eq!(state.sessions[state.selected_session].name, "b");
}
#[test]
fn group_popup_lists_existing_groups_and_highlights_current() {
let state = state_with(
&["a", "b", "c"],
&[("a", "work"), ("b", "personal"), ("c", "work")],
);
assert_eq!(state.groups.group_names(), vec!["personal", "work"]);
let mut state = state;
state.selected_session = state.sessions.iter().position(|s| s.name == "b").unwrap();
state.open_group_session_popup();
assert_eq!(state.popup_mode, Some(PopupMode::GroupSession));
assert_eq!(
state.selected_group_choice(),
GroupChoice::Existing("personal".to_string())
);
assert_eq!(state.group_choice_count(), 4);
}
#[test]
fn group_popup_defaults_to_ungrouped_for_ungrouped_session() {
let mut state = state_with(&["a", "b"], &[("b", "work")]);
state.selected_session = state.sessions.iter().position(|s| s.name == "a").unwrap();
state.open_group_session_popup();
assert_eq!(state.selected_group_choice(), GroupChoice::Ungrouped);
}
#[test]
fn group_choice_navigation_wraps_and_reaches_new() {
let mut state = state_with(&["a"], &[("a", "work")]);
state.open_group_session_popup();
assert_eq!(
state.selected_group_choice(),
GroupChoice::Existing("work".to_string())
);
state.group_choice_up(); assert_eq!(state.selected_group_choice(), GroupChoice::New);
state.group_choice_down(); assert_eq!(
state.selected_group_choice(),
GroupChoice::Existing("work".to_string())
);
state.group_choice_down();
assert_eq!(state.selected_group_choice(), GroupChoice::Ungrouped);
}
}