diff options
| author | Aram Drevekenin <aram@poor.dev> | 2026-03-09 13:31:08 +0100 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-03-09 13:31:08 +0100 |
| commit | e7985f0d77c4ee847d014b808eeede55d20f6d79 (patch) | |
| tree | e41151bedba3bc525c117db3d82e264ac63464f9 /default-plugins | |
| parent | Add cli paste action (#4817) (diff) | |
| download | sl0ppij-e7985f0d77c4ee847d014b808eeede55d20f6d79.tar.gz sl0ppij-e7985f0d77c4ee847d014b808eeede55d20f6d79.tar.xz sl0ppij-e7985f0d77c4ee847d014b808eeede55d20f6d79.zip | |
Single screen session manager (#4821)
* initial implementation
* adjust ui
* preserve selection
* width responsiveness
* behavior fixes
* fix: do not count suppressed panes
* ui improvements
* multi-screen config and prompt hint
* tests + refactoring
* improve performance
* add pr
Diffstat (limited to 'default-plugins')
| -rw-r--r-- | default-plugins/session-manager/src/main.rs | 624 | ||||
| -rw-r--r-- | default-plugins/session-manager/src/new_session_info.rs | 287 | ||||
| -rw-r--r-- | default-plugins/session-manager/src/single_screen.rs | 939 | ||||
| -rw-r--r-- | default-plugins/session-manager/src/ui/components.rs | 676 | ||||
| -rw-r--r-- | default-plugins/session-manager/src/ui/mod.rs | 5 |
5 files changed, 2461 insertions, 70 deletions
diff --git a/default-plugins/session-manager/src/main.rs b/default-plugins/session-manager/src/main.rs index 85b120e7..59bdf258 100644 --- a/default-plugins/session-manager/src/main.rs +++ b/default-plugins/session-manager/src/main.rs @@ -1,16 +1,19 @@ mod new_session_info; mod resurrectable_sessions; mod session_list; +mod single_screen; mod ui; use std::collections::BTreeMap; use uuid::Uuid; use zellij_tile::prelude::*; use new_session_info::NewSessionInfo; +use single_screen::{SingleScreenMode, SingleScreenState, UnifiedSearchResult}; use ui::{ components::{ render_controls_line, render_error, render_new_session_block, render_prompt, - render_renaming_session_screen, render_screen_toggle, render_unsaved_changes_line, Colors, + render_renaming_session_screen, render_screen_toggle, render_single_screen_prompt, + render_unified_results, render_unsaved_changes_line, Colors, }, welcome_screen::{render_banner, render_welcome_boundaries}, SessionUiInfo, @@ -24,6 +27,7 @@ enum ActiveScreen { NewSession, AttachToSession, ResurrectSession, + SingleScreen, } impl Default for ActiveScreen { @@ -44,6 +48,8 @@ struct State { active_screen: ActiveScreen, colors: Colors, is_welcome_screen: bool, + is_multi_screen: bool, + single_screen_state: SingleScreenState, show_kill_all_sessions_warning: bool, request_ids: Vec<String>, is_web_client: bool, @@ -62,6 +68,14 @@ impl ZellijPlugin for State { self.active_screen = ActiveScreen::NewSession; } self.new_session_info.is_welcome_screen = self.is_welcome_screen; + self.is_multi_screen = configuration + .get("multi_screen") + .map(|v| v == "true") + .unwrap_or(false); + if !self.is_multi_screen { + self.active_screen = ActiveScreen::SingleScreen; + } + self.single_screen_state.is_welcome_screen = self.is_welcome_screen; if !self.is_welcome_screen { set_timeout(0.1); // for the current_session_last_saved_time polling } @@ -82,6 +96,10 @@ impl ZellijPlugin for State { Some(request_id_position) => { self.request_ids.remove(request_id_position); let new_session_folder = std::path::PathBuf::from(payload); + if !self.is_multi_screen { + self.single_screen_state.new_session_folder = + Some(new_session_folder.clone()); + } self.new_session_info.new_session_folder = Some(new_session_folder); }, None => { @@ -100,9 +118,12 @@ impl ZellijPlugin for State { let mut should_render = false; match event { Event::Timer(_) => { - self.current_session_last_saved_time = current_session_last_saved_time(); + let new_saved_time = current_session_last_saved_time(); + if new_saved_time != self.current_session_last_saved_time { + self.current_session_last_saved_time = new_saved_time; + should_render = true; + } set_timeout(1.0); - should_render = true; }, Event::ModeUpdate(mode_info) => { self.colors = Colors::new(mode_info.style.colors); @@ -125,6 +146,14 @@ impl ZellijPlugin for State { self.resurrectable_sessions .update(resurrectable_session_list); self.update_session_infos(session_infos); + if !self.is_multi_screen { + self.single_screen_state.update_search_term( + &self.sessions.session_ui_infos, + &self.resurrectable_sessions.all_resurrectable_sessions, + ); + self.single_screen_state.layout_list = + self.new_session_info.get_layout_list_clone(); + } should_render = true; }, _ => (), @@ -140,13 +169,16 @@ impl ZellijPlugin for State { if self.is_welcome_screen { render_banner(x, 0, rows.saturating_sub(height), width); } - render_screen_toggle( - self.active_screen, - x, - y, - width.saturating_sub(2), - &background, - ); + + if self.active_screen != ActiveScreen::SingleScreen { + render_screen_toggle( + self.active_screen, + x, + y, + width.saturating_sub(2), + &background, + ); + } match self.active_screen { ActiveScreen::NewSession => { @@ -180,19 +212,174 @@ impl ZellijPlugin for State { ActiveScreen::ResurrectSession => { self.resurrectable_sessions.render(height, width, x, y); }, + ActiveScreen::SingleScreen => { + match self.single_screen_state.mode { + SingleScreenMode::SearchAndSelect => { + if let Some(new_session_name) = self.renaming_session_name.as_ref() { + render_renaming_session_screen(new_session_name, height, width, x, y); + } else if self.show_kill_all_sessions_warning { + self.render_kill_all_sessions_warning(height, width, x, y); + } else { + // Use max_table_rows as fixed content height so the + // prompt position stays stable regardless of result count + let max_table_rows = height.saturating_sub(5); + let content_height = 2 + max_table_rows; // prompt + header + max data rows + // Available space above help lines (2 help rows at bottom) + let available = height.saturating_sub(3); + let y_offset = y + available.saturating_sub(content_height) / 2; + + // Horizontal centering: cap content block and center + // within the full pane width + let content_width = std::cmp::min(width, 90); + let x_centered = x + (width.saturating_sub(content_width)) / 2; + + let enter_action = if !self.single_screen_state.search_term.is_empty() { + if let Some(result) = self.single_screen_state.get_selected_result() + { + match result { + UnifiedSearchResult::ActiveSession { .. } => Some("Attach"), + UnifiedSearchResult::ResurrectableSession { .. } => { + Some("Resurrect") + }, + } + } else { + let typed = &self.single_screen_state.search_term; + if self.sessions.has_session(typed) { + Some("Attach") + } else if self.resurrectable_sessions.has_session(typed) { + Some("Resurrect") + } else { + Some("Create new") + } + } + } else { + None + }; + render_single_screen_prompt( + &self.single_screen_state.search_term, + enter_action, + self.colors, + x_centered, + y_offset, + ); + render_unified_results( + &self.single_screen_state.render_cache, + self.single_screen_state.selected_index, + max_table_rows, + content_width, + self.colors, + x_centered, + y_offset + 2, + ); + } + }, + SingleScreenMode::SelectingLayout => { + let new_session_name = if self.single_screen_state.search_term.is_empty() { + "<RANDOM>" + } else { + &self.single_screen_state.search_term + }; + let esc = self.colors.shortcuts("<ESC>"); + println!( + "\u{1b}[m\u{1b}[{};{}H{}: {} ({} to go back)", + y + 1, + x + 1, + self.colors.session_name_prompt("New session name"), + self.colors.session_and_folder_entry(new_session_name), + esc, + ); + + // Render layout selection + let layout_search_term = + &self.single_screen_state.layout_list.layout_search_term; + let search_term_len = layout_search_term.len(); + let layout_indication_line = if width > 73 + search_term_len { + Text::new(format!( + "New session layout: {}_ (Search and select from list, <ENTER> when done)", + layout_search_term + )) + .color_range(2, ..20 + search_term_len) + .color_range(3, 20..20 + search_term_len) + .color_range(3, 52 + search_term_len..59 + search_term_len) + } else { + Text::new(format!( + "New session layout: {}_ <ENTER>", + layout_search_term + )) + .color_range(2, ..20 + search_term_len) + .color_range(3, 20..20 + search_term_len) + .color_range(3, 22 + search_term_len..) + }; + print_text_with_coordinates(layout_indication_line, x, y + 2, None, None); + println!(); + + let max_layout_rows = height.saturating_sub(8); + let mut table = Table::new(); + for (i, (layout_info, indices, is_selected)) in self + .single_screen_state + .layout_list + .layouts_to_render(max_layout_rows) + .into_iter() + .enumerate() + { + let layout_name = layout_info.name(); + let layout_name_len = layout_name.len(); + let is_builtin = layout_info.is_builtin(); + if i > max_layout_rows.saturating_sub(1) { + break; + } + let mut layout_cell = if is_builtin { + Text::new(format!("{} (built-in)", layout_name)) + .color_range(1, 0..layout_name_len) + .color_range(0, layout_name_len + 1..) + .color_indices(3, indices) + } else { + Text::new(format!("{}", layout_name)) + .color_range(1, ..) + .color_indices(3, indices) + }; + if is_selected { + layout_cell = layout_cell.selected(); + } + let arrow_cell = if is_selected { + Text::new(format!("<↓↑>")).selected().color_range(3, ..) + } else { + Text::new(format!(" ")).color_range(3, ..) + }; + table = table.add_styled_row(vec![arrow_cell, layout_cell]); + } + print_table_with_coordinates(table, x, y + 4, None, None); + + // Render folder prompt + self.render_single_screen_folder_prompt( + x, + (y + height).saturating_sub(3), + width, + ); + }, + } + }, } if let Some(error) = self.error.as_ref() { render_error(&error, height, width, x, y); - } else if self.active_screen == ActiveScreen::AttachToSession && !self.is_welcome_screen { + } else if (self.active_screen == ActiveScreen::AttachToSession + || self.active_screen == ActiveScreen::SingleScreen) + && !self.is_welcome_screen + { + let help_x = if self.active_screen == ActiveScreen::SingleScreen { + let content_width = std::cmp::min(width, 90); + x + (width.saturating_sub(content_width)) / 2 + } else { + x + }; let help_offset = render_controls_line( self.active_screen, width, self.colors, - x, + help_x, rows.saturating_sub(1), ); - // Adjust position and width based on whether controls line shows "Help: " - let adjusted_x = x + help_offset; + let adjusted_x = help_x + help_offset; let adjusted_width = width.saturating_sub(help_offset); render_unsaved_changes_line( adjusted_width, @@ -223,6 +410,7 @@ impl State { ActiveScreen::NewSession => self.handle_new_session_key(key), ActiveScreen::AttachToSession => self.handle_attach_to_session(key), ActiveScreen::ResurrectSession => self.handle_resurrect_session_key(key), + ActiveScreen::SingleScreen => self.handle_single_screen_key(key), } } fn handle_new_session_key(&mut self, key: KeyWithModifier) -> bool { @@ -489,6 +677,238 @@ impl State { } should_render } + fn handle_single_screen_key(&mut self, key: KeyWithModifier) -> bool { + match self.single_screen_state.mode { + SingleScreenMode::SearchAndSelect => self.handle_single_screen_search_key(key), + SingleScreenMode::SelectingLayout => self.handle_single_screen_layout_key(key), + } + } + fn handle_single_screen_search_key(&mut self, key: KeyWithModifier) -> bool { + let mut should_render = false; + + // Handle kill-all warning overlay first + if self.show_kill_all_sessions_warning { + match key.bare_key { + BareKey::Char('y') if key.has_no_modifiers() => { + let all_other_sessions = self.sessions.all_other_sessions(); + kill_sessions(&all_other_sessions); + self.show_kill_all_sessions_warning = false; + should_render = true; + }, + BareKey::Char('n') | BareKey::Esc if key.has_no_modifiers() => { + self.show_kill_all_sessions_warning = false; + should_render = true; + }, + BareKey::Char('c') if key.has_modifiers(&[KeyModifier::Ctrl]) => { + self.show_kill_all_sessions_warning = false; + should_render = true; + }, + _ => {}, + } + return should_render; + } + + // Handle rename overlay + if self.renaming_session_name.is_some() { + match key.bare_key { + BareKey::Enter if key.has_no_modifiers() => { + self.handle_selection(); + should_render = true; + }, + BareKey::Char(c) if key.has_no_modifiers() => { + if c == '\n' { + self.handle_selection(); + } else if let Some(name) = self.renaming_session_name.as_mut() { + name.push(c); + } + should_render = true; + }, + BareKey::Backspace if key.has_no_modifiers() => { + if let Some(name) = self.renaming_session_name.as_mut() { + if name.is_empty() { + self.renaming_session_name = None; + } else { + name.pop(); + } + } + should_render = true; + }, + BareKey::Esc if key.has_no_modifiers() => { + self.renaming_session_name = None; + should_render = true; + }, + _ => {}, + } + return should_render; + } + + match key.bare_key { + BareKey::Char(character) if key.has_no_modifiers() => { + if character == '\n' { + self.handle_selection(); + } else { + self.single_screen_state.search_term.push(character); + self.single_screen_state.update_search_term( + &self.sessions.session_ui_infos, + &self.resurrectable_sessions.all_resurrectable_sessions, + ); + } + should_render = true; + }, + BareKey::Backspace if key.has_no_modifiers() => { + self.single_screen_state.search_term.pop(); + self.single_screen_state.update_search_term( + &self.sessions.session_ui_infos, + &self.resurrectable_sessions.all_resurrectable_sessions, + ); + should_render = true; + }, + BareKey::Enter if key.has_no_modifiers() => { + self.handle_selection(); + should_render = true; + }, + BareKey::Down if key.has_no_modifiers() => { + self.single_screen_state.move_selection_down(); + should_render = true; + }, + BareKey::Up if key.has_no_modifiers() => { + self.single_screen_state.move_selection_up(); + should_render = true; + }, + BareKey::Tab if key.has_no_modifiers() => { + self.single_screen_state.tab_complete( + &self.sessions.session_ui_infos, + &self.resurrectable_sessions.all_resurrectable_sessions, + ); + should_render = true; + }, + BareKey::Char('r') if key.has_modifiers(&[KeyModifier::Ctrl]) => { + self.renaming_session_name = Some(String::new()); + should_render = true; + }, + BareKey::Delete if key.has_no_modifiers() => { + if let Some(result) = self.single_screen_state.get_selected_result() { + match result { + UnifiedSearchResult::ActiveSession { session_name, .. } => { + kill_sessions(&[session_name.clone()]); + }, + UnifiedSearchResult::ResurrectableSession { session_name, .. } => { + delete_dead_session(session_name); + }, + } + self.single_screen_state.selected_index = None; + } + should_render = true; + }, + BareKey::Char('d') if key.has_modifiers(&[KeyModifier::Ctrl]) => { + let all_other_sessions = self.sessions.all_other_sessions(); + if all_other_sessions.is_empty() { + self.show_error("No other sessions to kill. Quit to kill the current one."); + } else { + self.show_kill_all_sessions_warning = true; + } + should_render = true; + }, + BareKey::Char('x') if key.has_modifiers(&[KeyModifier::Ctrl]) => { + disconnect_other_clients(); + }, + BareKey::Char('a') if key.has_modifiers(&[KeyModifier::Ctrl]) => { + if !self.is_welcome_screen { + if let Err(e) = save_session() { + self.show_error(&format!("Couldn't save session: {}", e)); + } + } + }, + BareKey::Char('c') if key.has_modifiers(&[KeyModifier::Ctrl]) => { + if !self.single_screen_state.search_term.is_empty() { + self.single_screen_state.search_term.clear(); + self.single_screen_state.update_search_term( + &self.sessions.session_ui_infos, + &self.resurrectable_sessions.all_resurrectable_sessions, + ); + } else if !self.is_welcome_screen { + hide_self(); + } + should_render = true; + }, + BareKey::Esc if key.has_no_modifiers() => { + if self.single_screen_state.selected_index.is_some() { + self.single_screen_state.selected_index = None; + should_render = true; + } else if !self.is_welcome_screen { + hide_self(); + } + }, + _ => {}, + } + should_render + } + fn handle_single_screen_layout_key(&mut self, key: KeyWithModifier) -> bool { + let mut should_render = false; + match key.bare_key { + BareKey::Down if key.has_no_modifiers() => { + self.single_screen_state.layout_list.move_selection_down(); + should_render = true; + }, + BareKey::Up if key.has_no_modifiers() => { + self.single_screen_state.layout_list.move_selection_up(); + should_render = true; + }, + BareKey::Enter if key.has_no_modifiers() => { + self.handle_selection(); + should_render = true; + }, + BareKey::Char(character) if key.has_no_modifiers() => { + if character == '\n' { + self.handle_selection(); + } else { + self.single_screen_state + .layout_list + .layout_search_term + .push(character); + self.single_screen_state.layout_list.update_search_term(); + } + should_render = true; + }, + BareKey::Backspace if key.has_no_modifiers() => { + self.single_screen_state + .layout_list + .layout_search_term + .pop(); + self.single_screen_state.layout_list.update_search_term(); + should_render = true; + }, + BareKey::Char('f') if key.has_modifiers(&[KeyModifier::Ctrl]) => { + let request_id = Uuid::new_v4(); + let mut config = BTreeMap::new(); + let mut args = BTreeMap::new(); + self.request_ids.push(request_id.to_string()); + config.insert("request_id".to_owned(), request_id.to_string()); + args.insert("request_id".to_owned(), request_id.to_string()); + pipe_message_to_plugin( + MessageToPlugin::new("filepicker") + .with_plugin_url("filepicker") + .with_plugin_config(config) + .new_plugin_instance_should_have_pane_title( + "Select folder for the new session...", + ) + .new_plugin_instance_should_be_focused() + .with_args(args), + ); + should_render = true; + }, + BareKey::Char('c') if key.has_modifiers(&[KeyModifier::Ctrl]) => { + self.single_screen_state.new_session_folder = None; + should_render = true; + }, + BareKey::Esc if key.has_no_modifiers() => { + self.single_screen_state.transition_to_search(); + should_render = true; + }, + _ => {}, + } + should_render + } fn handle_selection(&mut self) { match self.active_screen { ActiveScreen::NewSession => { @@ -588,6 +1008,137 @@ impl State { } } }, + ActiveScreen::SingleScreen => { + // Handle rename + if let Some(renaming_session_name) = &self.renaming_session_name.take() { + if renaming_session_name.is_empty() { + self.show_error("New name must not be empty."); + return; + } else if self.session_name.as_ref() == Some(renaming_session_name) { + return; + } else if self.sessions.has_session(&renaming_session_name) { + self.show_error("A session by this name already exists."); + return; + } else if self + .resurrectable_sessions + .has_session(&renaming_session_name) + { + self.show_error("A resurrectable session by this name already exists."); + return; + } else { + if renaming_session_name.contains('/') { + self.show_error("Session names cannot contain '/'"); + return; + } + self.update_current_session_name_in_ui(&renaming_session_name); + rename_session(&renaming_session_name); + return; + } + } + + match self.single_screen_state.mode { + SingleScreenMode::SearchAndSelect => { + if let Some(result) = self.single_screen_state.get_selected_result() { + // User navigated to a specific result + let session_name = result.session_name().to_owned(); + match result { + UnifiedSearchResult::ActiveSession { + is_current_session, .. + } => { + if *is_current_session { + self.show_error("Already attached..."); + } else { + switch_session_with_focus(&session_name, None, None); + } + }, + UnifiedSearchResult::ResurrectableSession { .. } => { + switch_session(Some(&session_name)); + }, + } + self.single_screen_state.search_term.clear(); + self.single_screen_state.selected_index = None; + if self.is_welcome_screen { + quit_zellij(); + } else { + hide_self(); + } + } else { + // No navigation - use typed name + let typed_name = self.single_screen_state.search_term.clone(); + + // Validate name + if typed_name.len() >= 108 { + self.show_error("Session name must be shorter than 108 bytes"); + return; + } + if typed_name.contains('/') { + self.show_error("Session name cannot contain '/'"); + return; + } + if self.sessions.has_forbidden_session(&typed_name) { + self.show_error( + "This session exists and web clients cannot attach to it.", + ); + return; + } + + // Check exact match against active sessions + if self.sessions.has_session(&typed_name) { + if self.session_name.as_deref() == Some(&typed_name) { + self.show_error("Already attached..."); + } else { + switch_session_with_focus(&typed_name, None, None); + if self.is_welcome_screen { + quit_zellij(); + } else { + hide_self(); + } + } + return; + } + // Check exact match against resurrectable sessions + if self.resurrectable_sessions.has_session(&typed_name) { + switch_session(Some(&typed_name)); + if self.is_welcome_screen { + quit_zellij(); + } else { + hide_self(); + } + return; + } + // No match - transition to layout selection + self.single_screen_state.transition_to_layout_selection(); + } + }, + SingleScreenMode::SelectingLayout => { + let new_session_name = if self.single_screen_state.search_term.is_empty() { + None + } else { + Some(self.single_screen_state.search_term.as_str()) + }; + let layout = self.single_screen_state.layout_list.selected_layout_info(); + let cwd = self.single_screen_state.new_session_folder.clone(); + + if new_session_name != self.session_name.as_ref().map(|s| s.as_str()) { + match layout { + Some(layout_info) => { + switch_session_with_layout(new_session_name, layout_info, cwd); + }, + None => { + switch_session(new_session_name); + }, + } + } + self.single_screen_state.search_term.clear(); + self.single_screen_state.transition_to_search(); + if self.is_welcome_screen { + quit_zellij(); + } else { + hide_self(); + } + }, + } + }, } } fn toggle_active_screen(&mut self) { @@ -595,6 +1146,7 @@ impl State { ActiveScreen::NewSession => ActiveScreen::AttachToSession, ActiveScreen::AttachToSession => ActiveScreen::ResurrectSession, ActiveScreen::ResurrectSession => ActiveScreen::NewSession, + ActiveScreen::SingleScreen => ActiveScreen::SingleScreen, // no-op }; } fn show_error(&mut self, error_text: &str) { @@ -668,6 +1220,50 @@ impl State { let height = rows.saturating_sub(y); (x, y, width, height) } + fn render_single_screen_folder_prompt(&self, x: usize, y: usize, max_cols: usize) { + match self.single_screen_state.new_session_folder.as_ref() { + Some(new_session_folder) => { + let folder_prompt = "New session folder:"; + let new_session_folder_str = new_session_folder.display().to_string(); + let change_folder_shortcut = self.colors.shortcuts("<Ctrl f>"); + let reset_folder_shortcut = self.colors.shortcuts("<Ctrl c>"); + if max_cols >= folder_prompt.len() + new_session_folder_str.len() + 30 { + print!( + "\u{1b}[m\u{1b}[{};{}H{} {} ({} to change, {} to reset)", + y + 1, + x + 1, + self.colors.session_name_prompt(folder_prompt), + self.colors + .session_and_folder_entry(&new_session_folder_str), + change_folder_shortcut, + reset_folder_shortcut, + ); + } else { + print!( + "\u{1b}[m\u{1b}[{};{}H{} {} ({}/{})", + y + 1, + x + 1, + self.colors.session_name_prompt("Folder:"), + self.colors + .session_and_folder_entry(&new_session_folder_str), + change_folder_shortcut, + reset_folder_shortcut, + ); + } + }, + None => { + let folder_prompt = "New session folder:"; + let change_folder_shortcut = self.colors.shortcuts("<Ctrl f>"); + print!( + "\u{1b}[m\u{1b}[{};{}H{} ({} to set)", + y + 1, + x + 1, + self.colors.session_name_prompt(folder_prompt), + change_folder_shortcut, + ); + }, + } + } fn render_kill_all_sessions_warning(&self, rows: usize, columns: usize, x: usize, y: usize) { if rows == 0 || columns == 0 { return; diff --git a/default-plugins/session-manager/src/new_session_info.rs b/default-plugins/session-manager/src/new_session_info.rs index cc8c4af0..39e9be90 100644 --- a/default-plugins/session-manager/src/new_session_info.rs +++ b/default-plugins/session-manager/src/new_session_info.rs @@ -142,7 +142,7 @@ impl NewSessionInfo { } pub fn layout_list(&self, max_rows: usize) -> Vec<(LayoutInfo, bool)> { // bool - is_selected - let range_to_render = self.range_to_render( + let rtr = range_to_render( max_rows, self.layout_count(), Some(self.layout_list.selected_layout_index), @@ -152,8 +152,8 @@ impl NewSessionInfo { .iter() .enumerate() .map(|(i, l)| (l.clone(), i == self.layout_list.selected_layout_index)) - .take(range_to_render.1) - .skip(range_to_render.0) + .take(rtr.1) + .skip(rtr.0) .collect() } pub fn layouts_to_render(&self, max_rows: usize) -> Vec<(LayoutInfo, Vec<usize>, bool)> { @@ -180,7 +180,7 @@ impl NewSessionInfo { } pub fn layout_search_results(&self, max_rows: usize) -> Vec<(LayoutSearchResult, bool)> { // bool - is_selected - let range_to_render = self.range_to_render( + let rtr = range_to_render( max_rows, self.layout_list.layout_search_results.len(), Some(self.layout_list.selected_layout_index), @@ -190,30 +190,10 @@ impl NewSessionInfo { .iter() .enumerate() .map(|(i, l)| (l.clone(), i == self.layout_list.selected_layout_index)) - .take(range_to_render.1) - .skip(range_to_render.0) + .take(rtr.1) + .skip(rtr.0) .collect() } - // TODO: merge with similar function in resurrectable_sessions - fn range_to_render( - &self, - table_rows: usize, - results_len: usize, - selected_index: Option<usize>, - ) -> (usize, usize) { - if table_rows <= results_len { - let row_count_to_render = table_rows.saturating_sub(1); // 1 for the title - let first_row_index_to_render = selected_index - .unwrap_or(0) - .saturating_sub(row_count_to_render / 2); - let last_row_index_to_render = first_row_index_to_render + row_count_to_render; - (first_row_index_to_render, last_row_index_to_render) - } else { - let first_row_index_to_render = 0; - let last_row_index_to_render = results_len; - (first_row_index_to_render, last_row_index_to_render) - } - } pub fn is_searching(&self) -> bool { !self.layout_list.layout_search_term.is_empty() } @@ -224,27 +204,7 @@ impl NewSessionInfo { self.layout_list.selected_layout_info() } fn update_layout_search_term(&mut self) { - if self.layout_list.layout_search_term.is_empty() { - self.layout_list.clear_selection(); - self.layout_list.layout_search_results = vec![]; - } else { - let mut matches = vec![]; - let matcher = SkimMatcherV2::default().use_cache(true); - for layout_info in &self.layout_list.layout_list { - if let Some((score, indices)) = - matcher.fuzzy_indices(&layout_info.name(), &self.layout_list.layout_search_term) - { - matches.push(LayoutSearchResult { - layout_info: layout_info.clone(), - score, - indices, - }); - } - } - matches.sort_by(|a, b| b.score.cmp(&a.score)); - self.layout_list.layout_search_results = matches; - self.layout_list.clear_selection(); - } + self.layout_list.update_search_term(); } fn move_selection_up(&mut self) { self.layout_list.move_selection_up(); @@ -252,14 +212,17 @@ impl NewSessionInfo { fn move_selection_down(&mut self) { self.layout_list.move_selection_down(); } + pub fn get_layout_list_clone(&self) -> LayoutList { + self.layout_list.clone() + } } -#[derive(Default)] +#[derive(Default, Clone)] pub struct LayoutList { - layout_list: Vec<LayoutInfo>, - layout_search_results: Vec<LayoutSearchResult>, - selected_layout_index: usize, - layout_search_term: String, + pub layout_list: Vec<LayoutInfo>, + pub layout_search_results: Vec<LayoutSearchResult>, + pub selected_layout_index: usize, + pub layout_search_term: String, } impl LayoutList { @@ -283,14 +246,14 @@ impl LayoutList { pub fn clear_selection(&mut self) { self.selected_layout_index = 0; } - fn max_index(&self) -> usize { + pub fn max_index(&self) -> usize { if self.layout_search_term.is_empty() { self.layout_list.len().saturating_sub(1) } else { self.layout_search_results.len().saturating_sub(1) } } - fn move_selection_up(&mut self) { + pub fn move_selection_up(&mut self) { let max_index = self.max_index(); if self.selected_layout_index > 0 { self.selected_layout_index -= 1; @@ -298,7 +261,7 @@ impl LayoutList { self.selected_layout_index = max_index; } } - fn move_selection_down(&mut self) { + pub fn move_selection_down(&mut self) { let max_index = self.max_index(); if self.selected_layout_index < max_index { self.selected_layout_index += 1; @@ -306,6 +269,92 @@ impl LayoutList { self.selected_layout_index = 0; } } + pub fn update_search_term(&mut self) { + if self.layout_search_term.is_empty() { + self.clear_selection(); + self.layout_search_results = vec![]; + } else { + let mut matches = vec![]; + let matcher = SkimMatcherV2::default().use_cache(true); + for layout_info in &self.layout_list { + if let Some((score, indices)) = + matcher.fuzzy_indices(&layout_info.name(), &self.layout_search_term) + { + matches.push(LayoutSearchResult { + layout_info: layout_info.clone(), + score, + indices, + }); + } + } + matches.sort_by(|a, b| b.score.cmp(&a.score)); + self.layout_search_results = matches; + self.clear_selection(); + } + } + pub fn layouts_to_render(&self, max_rows: usize) -> Vec<(LayoutInfo, Vec<usize>, bool)> { + if !self.layout_search_term.is_empty() { + self.layout_search_results_to_render(max_rows) + } else { + self.layout_list_to_render(max_rows) + } + } + fn layout_list_to_render(&self, max_rows: usize) -> Vec<(LayoutInfo, Vec<usize>, bool)> { + let range_to_render = range_to_render( + max_rows, + self.layout_list.len(), + Some(self.selected_layout_index), + ); + self.layout_list + .iter() + .enumerate() + .map(|(i, l)| (l.clone(), vec![], i == self.selected_layout_index)) + .take(range_to_render.1) + .skip(range_to_render.0) + .collect() + } + fn layout_search_results_to_render( + &self, + max_rows: usize, + ) -> Vec<(LayoutInfo, Vec<usize>, bool)> { + let range_to_render = range_to_render( + max_rows, + self.layout_search_results.len(), + Some(self.selected_layout_index), + ); + self.layout_search_results + .iter() + .enumerate() + .map(|(i, l)| { + ( + l.layout_info.clone(), + l.indices.clone(), + i == self.selected_layout_index, + ) + }) + .take(range_to_render.1) + .skip(range_to_render.0) + .collect() + } +} + +pub fn range_to_render( + table_rows: usize, + results_len: usize, + selected_index: Option<usize>, +) -> (usize, usize) { + if table_rows <= results_len { + let row_count_to_render = table_rows.saturating_sub(1); // 1 for the title + let first_row_index_to_render = selected_index + .unwrap_or(0) + .saturating_sub(row_count_to_render / 2); + let last_row_index_to_render = first_row_index_to_render + row_count_to_render; + (first_row_index_to_render, last_row_index_to_render) + } else { + let first_row_index_to_render = 0; + let last_row_index_to_render = results_len; + (first_row_index_to_render, last_row_index_to_render) + } } #[derive(Clone)] @@ -314,3 +363,131 @@ pub struct LayoutSearchResult { pub score: i64, pub indices: Vec<usize>, } + +#[cfg(test)] +mod tests { + use super::*; + + fn make_layout(name: &str) -> LayoutInfo { + LayoutInfo::BuiltIn(name.to_string()) + } + + fn make_layout_list(names: &[&str]) -> LayoutList { + let mut ll = LayoutList::default(); + ll.layout_list = names.iter().map(|n| make_layout(n)).collect(); + ll + } + + // --------------------------------------------------------------- + // Section 6: Layout List Navigation + // --------------------------------------------------------------- + + #[test] + fn test_6_1_layout_navigation_wraps_down() { + let mut ll = make_layout_list(&["a", "b", "c"]); + ll.selected_layout_index = 2; + ll.move_selection_down(); + assert_eq!(ll.selected_layout_index, 0); + } + + #[test] + fn test_6_2_layout_navigation_wraps_up() { + let mut ll = make_layout_list(&["a", "b", "c"]); + ll.selected_layout_index = 0; + ll.move_selection_up(); + assert_eq!(ll.selected_layout_index, 2); + } + + #[test] + fn test_6_3_layout_search_filters_results() { + let mut ll = make_layout_list(&["default", "compact", "development"]); + ll.layout_search_term = "dev".to_string(); + ll.update_search_term(); + // "development" should match + let matched_names: Vec<&str> = ll + .layout_search_results + .iter() + .map(|r| r.layout_info.name()) + .collect(); + assert!(matched_names.contains(&"development")); + // "default" and "compact" should not match "dev" well enough + // (though "default" starts with "de" so it might fuzzy-match — check) + // The key assertion is that "development" is present and is the best match. + // With SkimMatcherV2, "default" may also match "dev" (d, e from "default"). + // So we just verify "development" is the top result. + assert_eq!( + ll.layout_search_results[0].layout_info.name(), + "development" + ); + } + + #[test] + fn test_6_4_selected_layout_info_returns_correct_layout() { + let ll = make_layout_list(&["a", "b", "c"]); + // selected_layout_index defaults to 0, set to 1 + let mut ll = ll; + ll.selected_layout_index = 1; + let info = ll.selected_layout_info(); + assert!(info.is_some()); + assert_eq!(info.unwrap().name(), "b"); + } + + #[test] + fn test_6_5_layout_viewport_windowing() { + let mut ll = make_layout_list(&[ + "layout-0", "layout-1", "layout-2", "layout-3", "layout-4", "layout-5", "layout-6", + "layout-7", "layout-8", "layout-9", + ]); + ll.selected_layout_index = 5; + let rendered = ll.layouts_to_render(5); + // Should return at most 5-1=4 entries (range_to_render subtracts 1 for title) + assert!(rendered.len() <= 5); + // The selected layout should be within the visible window + let selected_visible = rendered.iter().any(|(_, _, is_selected)| *is_selected); + assert!(selected_visible); + } + + // --------------------------------------------------------------- + // Section 7: Viewport Scrolling (range_to_render tests) + // --------------------------------------------------------------- + + #[test] + fn test_7_1_all_results_fit_in_viewport() { + // When table_rows > results_len, all results are shown + let (start, end) = range_to_render(10, 5, None); + assert_eq!(start, 0); + assert_eq!(end, 5); + } + + #[test] + fn test_7_2_viewport_scrolls_to_keep_selection_centered() { + // table_rows=6, results_len=20, selected=10 + // row_count_to_render = 6-1 = 5, half = 2 + // first = 10-2 = 8, last = 8+5 = 13 + let (start, end) = range_to_render(6, 20, Some(10)); + assert_eq!(start, 8); + assert_eq!(end, 13); + } + + #[test] + fn test_7_3_viewport_clamps_at_bottom() { + // table_rows=6, results_len=20, selected=19 + // row_count_to_render = 5, half = 2 + // first = 19-2 = 17, last = 17+5 = 22 > 20 + // Note: range_to_render does NOT clamp — it returns (17, 22) + // The actual clamping happens in the caller via .take().skip() + let (start, end) = range_to_render(6, 20, Some(19)); + assert_eq!(start, 17); + assert_eq!(end, 22); + } + + #[test] + fn test_7_4_viewport_clamps_at_top() { + // table_rows=6, results_len=20, selected=0 + // row_count_to_render = 5, half = 2 + // first = 0.saturating_sub(2) = 0, last = 0+5 = 5 + let (start, end) = range_to_render(6, 20, Some(0)); + assert_eq!(start, 0); + assert_eq!(end, 5); + } +} diff --git a/default-plugins/session-manager/src/single_screen.rs b/default-plugins/session-manager/src/single_screen.rs new file mode 100644 index 00000000..fb7b0385 --- /dev/null +++ b/default-plugins/session-manager/src/single_screen.rs @@ -0,0 +1,939 @@ +use fuzzy_matcher::skim::SkimMatcherV2; +use fuzzy_matcher::FuzzyMatcher; +use std::path::PathBuf; +use std::time::Duration; + +use crate::new_session_info::LayoutList; +use crate::ui::components::UnifiedResultsRenderCache; +use crate::ui::SessionUiInfo; + +#[derive(Debug, Clone, PartialEq)] +pub enum SingleScreenMode { + SearchAndSelect, + SelectingLayout, +} + +impl Default for SingleScreenMode { + fn default() -> Self { + SingleScreenMode::SearchAndSelect + } +} + +#[derive(Debug)] +pub enum UnifiedSearchResult { + ActiveSession { + score: i64, + indices: Vec<usize>, + session_name: String, + connected_users: usize, + tab_count: usize, + pane_count: usize, + is_current_session: bool, + creation_time: Duration, + }, + ResurrectableSession { + score: i64, + indices: Vec<usize>, + session_name: String, + ctime: Duration, + }, +} + +impl UnifiedSearchResult { + pub fn session_name(&self) -> &str { + match self { + UnifiedSearchResult::ActiveSession { session_name, .. } => session_name.as_str(), + UnifiedSearchResult::ResurrectableSession { session_name, .. } => session_name.as_str(), + } + } + fn score(&self) -> i64 { + match self { + UnifiedSearchResult::ActiveSession { score, .. } => *score, + UnifiedSearchResult::ResurrectableSession { score, .. } => *score, + } + } + /// Ordering by type (active before resurrectable), then by creation time ascending + /// (smaller elapsed duration = more recently created = appears first). + fn cmp_by_type_then_recency(&self, other: &Self) -> std::cmp::Ordering { + match (self, other) { + ( + UnifiedSearchResult::ActiveSession { + creation_time: ct_a, + .. + }, + UnifiedSearchResult::ActiveSession { + creation_time: ct_b, + .. + }, + ) => ct_a.cmp(ct_b), + ( + UnifiedSearchResult::ResurrectableSession { ctime: ct_a, .. }, + UnifiedSearchResult::ResurrectableSession { ctime: ct_b, .. }, + ) => ct_a.cmp(ct_b), + ( + UnifiedSearchResult::ActiveSession { .. }, + UnifiedSearchResult::ResurrectableSession { .. }, + ) => std::cmp::Ordering::Less, + ( + UnifiedSearchResult::ResurrectableSession { .. }, + UnifiedSearchResult::ActiveSession { .. }, + ) => std::cmp::Ordering::Greater, + } + } +} + +#[derive(Default)] +pub struct SingleScreenState { + pub search_term: String, + pub unified_results: Vec<UnifiedSearchResult>, + pub selected_index: Option<usize>, + pub mode: SingleScreenMode, + pub layout_list: LayoutList, + pub new_session_folder: Option<PathBuf>, + pub is_welcome_screen: bool, + pub render_cache: UnifiedResultsRenderCache, + fuzzy_matcher: Option<SkimMatcherV2>, +} + +impl std::fmt::Debug for SingleScreenState { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("SingleScreenState") + .field("search_term", &self.search_term) + .field("selected_index", &self.selected_index) + .field("mode", &self.mode) + .field("result_count", &self.unified_results.len()) + .finish() + } +} + +impl SingleScreenState { + pub fn update_search_term( + &mut self, + active_sessions: &[SessionUiInfo], + resurrectable_sessions: &[(String, Duration)], + ) { + let previously_selected_name = self.previously_selected_name(); + self.selected_index = None; + + if self.search_term.is_empty() { + self.unified_results = + Self::collect_all_sessions(active_sessions, resurrectable_sessions); + self.unified_results + .sort_by(|a, b| a.cmp_by_type_then_recency(b)); + } else { + self.unified_results = + self.collect_fuzzy_matched_sessions(active_sessions, resurrectable_sessions); + self.unified_results.sort_by(|a, b| { + let score_cmp = b.score().cmp(&a.score()); + if score_cmp != std::cmp::Ordering::Equal { + return score_cmp; + } + a.cmp_by_type_then_recency(b) + }); + } + + self.render_cache.rebuild(&self.unified_results); + self.restore_selection(previously_selected_name); + } + + fn previously_selected_name(&self) -> Option<String> { + self.selected_index + .and_then(|i| self.unified_results.get(i)) + .map(|r| r.session_name().to_owned()) + } + + fn restore_selection(&mut self, previously_selected_name: Option<String>) { + if let Some(prev_name) = previously_selected_name { + self.selected_index = self + .unified_results + .iter() + .position(|r| r.session_name() == prev_name); + } + } + + fn collect_all_sessions( + active_sessions: &[SessionUiInfo], + resurrectable_sessions: &[(String, Duration)], + ) -> Vec<UnifiedSearchResult> { + let mut results = Vec::new(); + for session in active_sessions { + results.push(Self::active_session_to_result(session, 0, vec![])); + } + for (name, ctime) in resurrectable_sessions { + results.push(UnifiedSearchResult::ResurrectableSession { + score: 0, + indices: vec![], + session_name: name.clone(), + ctime: *ctime, + }); + } + results + } + + fn collect_fuzzy_matched_sessions( + &mut self, + active_sessions: &[SessionUiInfo], + resurrectable_sessions: &[(String, Duration)], + ) -> Vec<UnifiedSearchResult> { + let matcher = self + .fuzzy_matcher + .get_or_insert_with(|| SkimMatcherV2::default().use_cache(true)); + let mut results = Vec::new(); + + for session in active_sessions { + if let Some((score, indices)) = matcher.fuzzy_indices(&session.name, &self.search_term) + { + results.push(Self::active_session_to_result(session, score, indices)); + } + } + for (name, ctime) in resurrectable_sessions { + if let Some((score, indices)) = matcher.fuzzy_indices(name, &self.search_term) { + results.push(UnifiedSearchResult::ResurrectableSession { + score, + indices, + session_name: name.clone(), + ctime: *ctime, + }); + } + } + results + } + + fn active_session_to_result( + session: &SessionUiInfo, + score: i64, + indices: Vec<usize>, + ) -> UnifiedSearchResult { + UnifiedSearchResult::ActiveSession { + score, + indices, + session_name: session.name.clone(), + connected_users: session.connected_users, + tab_count: session.tabs.len(), + pane_count: session.tabs.iter().fold(0, |acc, t| acc + t.panes.len()), + is_current_session: session.is_current_session, + creation_time: session.creation_time, + } + } + + fn is_current_session(&self, index: usize) -> bool { + matches!( + self.unified_results.get(index), + Some(UnifiedSearchResult::ActiveSession { + is_current_session: true, + .. + }) + ) + } + + fn next_selectable_down(&self, from: usize) -> Option<usize> { + let len = self.unified_results.len(); + for offset in 1..=len { + let candidate = (from + offset) % len; + if !self.is_current_session(candidate) { + return Some(candidate); + } + } + None + } + + fn next_selectable_up(&self, from: usize) -> Option<usize> { + let len = self.unified_results.len(); + for offset in 1..=len { + let candidate = (from + len - offset) % len; + if !self.is_current_session(candidate) { + return Some(candidate); + } + } + None + } + + pub fn move_selection_down(&mut self) { + if self.unified_results.is_empty() { + return; + } + match self.selected_index { + None => { + self.selected_index = + self.next_selectable_down(self.unified_results.len().saturating_sub(1)); + }, + Some(i) => { + self.selected_index = self.next_selectable_down(i); + }, + } + } + + pub fn move_selection_up(&mut self) { + if self.unified_results.is_empty() { + return; + } + match self.selected_index { + None => { + self.selected_index = self.next_selectable_up(0); + }, + Some(i) => { + self.selected_index = self.next_selectable_up(i); + }, + } + } + + pub fn tab_complete( + &mut self, + active_sessions: &[SessionUiInfo], + resurrectable_sessions: &[(String, Duration)], + ) { + let first_non_current = self.unified_results.iter().find(|r| { + !matches!( + r, + UnifiedSearchResult::ActiveSession { + is_current_session: true, + .. + } + ) + }); + if let Some(result) = first_non_current { + let name = result.session_name().to_owned(); + self.search_term = name; + self.update_search_term(active_sessions, resurrectable_sessions); + } + } + + pub fn get_selected_result(&self) -> Option<&UnifiedSearchResult> { + self.selected_index + .and_then(|i| self.unified_results.get(i)) + } + + pub fn transition_to_layout_selection(&mut self) { + self.mode = SingleScreenMode::SelectingLayout; + } + + pub fn transition_to_search(&mut self) { + self.mode = SingleScreenMode::SearchAndSelect; + self.layout_list.layout_search_term.clear(); + self.layout_list.clear_selection(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::Duration; + + fn make_active_session( + name: &str, + tabs: usize, + panes_per_tab: usize, + connected: usize, + is_current: bool, + creation_secs: u64, + ) -> SessionUiInfo { + SessionUiInfo { + name: name.to_string(), + tabs: (0..tabs) + .map(|i| crate::ui::TabUiInfo { + name: format!("tab-{}", i), + panes: (0..panes_per_tab) + .map(|j| crate::ui::PaneUiInfo { + name: format!("pane-{}", j), + exit_code: None, + pane_id: j as u32, + is_plugin: false, + }) + .collect(), + position: i, + }) + .collect(), + connected_users: connected, + is_current_session: is_current, + creation_time: Duration::from_secs(creation_secs), + } + } + + fn make_resurrectable(name: &str, ctime_secs: u64) -> (String, Duration) { + (name.to_string(), Duration::from_secs(ctime_secs)) + } + + // --------------------------------------------------------------- + // Section 1: Session Display and Sorting + // --------------------------------------------------------------- + + #[test] + fn test_1_1_active_sessions_sorted_by_recency() { + let mut state = SingleScreenState::default(); + state.search_term = String::new(); + let active = vec![ + make_active_session("sess-300", 1, 1, 1, false, 300), + make_active_session("sess-100", 1, 1, 1, false, 100), + make_active_session("sess-200", 1, 1, 1, false, 200), + ]; + state.update_search_term(&active, &[]); + assert_eq!(state.unified_results.len(), 3); + // Ascending creation_time: 100, 200, 300 (most recent first) + assert_eq!(state.unified_results[0].session_name(), "sess-100"); + assert_eq!(state.unified_results[1].session_name(), "sess-200"); + assert_eq!(state.unified_results[2].session_name(), "sess-300"); + for r in &state.unified_results { + assert!(matches!(r, UnifiedSearchResult::ActiveSession { .. })); + } + } + + #[test] + fn test_1_2_resurrectable_sessions_sorted_by_recency() { + let mut state = SingleScreenState::default(); + state.search_term = String::new(); + let resurrectable = vec![ + make_resurrectable("res-300", 300), + make_resurrectable("res-100", 100), + make_resurrectable("res-200", 200), + ]; + state.update_search_term(&[], &resurrectable); + assert_eq!(state.unified_results.len(), 3); + assert_eq!(state.unified_results[0].session_name(), "res-100"); + assert_eq!(state.unified_results[1].session_name(), "res-200"); + assert_eq!(state.unified_results[2].session_name(), "res-300"); + for r in &state.unified_results { + assert!(matches!( + r, + UnifiedSearchResult::ResurrectableSession { .. } + )); + } + } + + #[test] + fn test_1_3_mixed_active_and_resurrectable_active_first() { + let mut state = SingleScreenState::default(); + state.search_term = String::new(); + let active = vec![ + make_active_session("active-200", 1, 1, 1, false, 200), + make_active_session("active-100", 1, 1, 1, false, 100), + ]; + let resurrectable = vec![ + make_resurrectable("res-50", 50), + make_resurrectable("res-150", 150), + ]; + state.update_search_term(&active, &resurrectable); + assert_eq!(state.unified_results.len(), 4); + assert_eq!(state.unified_results[0].session_name(), "active-100"); + assert_eq!(state.unified_results[1].session_name(), "active-200"); + assert_eq!(state.unified_results[2].session_name(), "res-50"); + assert_eq!(state.unified_results[3].session_name(), "res-150"); + } + + #[test] + fn test_1_4_current_session_included_in_results() { + let mut state = SingleScreenState::default(); + state.search_term = String::new(); + let active = vec![ + make_active_session("current-sess", 1, 1, 1, true, 100), + make_active_session("other-sess", 1, 1, 1, false, 200), + ]; + state.update_search_term(&active, &[]); + assert_eq!(state.unified_results.len(), 2); + // Current session should be present (filtering is done in renderer) + let has_current = state.unified_results.iter().any(|r| match r { + UnifiedSearchResult::ActiveSession { + is_current_session, .. + } => *is_current_session, + _ => false, + }); + assert!(has_current); + } + + #[test] + fn test_1_5_pane_counts_reflect_tab_structure() { + let mut state = SingleScreenState::default(); + state.search_term = String::new(); + let active = vec![make_active_session("sess", 3, 2, 1, false, 100)]; + state.update_search_term(&active, &[]); + assert_eq!(state.unified_results.len(), 1); + match &state.unified_results[0] { + UnifiedSearchResult::ActiveSession { + tab_count, + pane_count, + .. + } => { + assert_eq!(*tab_count, 3); + assert_eq!(*pane_count, 6); + }, + _ => panic!("Expected ActiveSession"), + } + } + + #[test] + fn test_1_6_empty_sessions_list() { + let mut state = SingleScreenState::default(); + state.search_term = String::new(); + state.update_search_term(&[], &[]); + assert!(state.unified_results.is_empty()); + } + + // --------------------------------------------------------------- + // Section 2: Search and Fuzzy Matching + // --------------------------------------------------------------- + + #[test] + fn test_2_1_empty_search_term_shows_all() { + let mut state = SingleScreenState::default(); + state.search_term = String::new(); + let active = vec![ + make_active_session("a", 1, 1, 1, false, 100), + make_active_session("b", 1, 1, 1, false, 200), + make_active_session("c", 1, 1, 1, false, 300), + ]; + let resurrectable = vec![make_resurrectable("d", 400), make_resurrectable("e", 500)]; + state.update_search_term(&active, &resurrectable); + assert_eq!(state.unified_results.len(), 5); + } + + #[test] + fn test_2_2_typing_filters_results() { + let mut state = SingleScreenState::default(); + let active = vec![ + make_active_session("alpha", 1, 1, 1, false, 100), + make_active_session("beta", 1, 1, 1, false, 200), + make_active_session("gamma", 1, 1, 1, false, 300), + ]; + state.search_term = "alp".to_string(); + state.update_search_term(&active, &[]); + assert_eq!(state.unified_results.len(), 1); + assert_eq!(state.unified_results[0].session_name(), "alpha"); + match &state.unified_results[0] { + UnifiedSearchResult::ActiveSession { score, indices, .. } => { + assert!(*score > 0); + assert!(!indices.is_empty()); + }, + _ => panic!("Expected ActiveSession"), + } + } + + #[test] + fn test_2_3_fuzzy_matching_non_contiguous() { + let mut state = SingleScreenState::default(); + let active = vec![ + make_active_session("my-project", 1, 1, 1, false, 100), + make_active_session("mapping", 1, 1, 1, false, 200), + ]; + state.search_term = "mprj".to_string(); + state.update_search_term(&active, &[]); + // At least "my-project" should match (m, p, r, j fuzzy) + let has_my_project = state + .unified_results + .iter() + .any(|r| r.session_name() == "my-project"); + assert!(has_my_project); + } + + #[test] + fn test_2_4_score_based_ordering() { + let mut state = SingleScreenState::default(); + let active = vec![ + make_active_session("abc", 1, 1, 1, false, 100), + make_active_session("xabc", 1, 1, 1, false, 200), + make_active_session("xxabc", 1, 1, 1, false, 300), + ]; + state.search_term = "abc".to_string(); + state.update_search_term(&active, &[]); + assert!(!state.unified_results.is_empty()); + // "abc" (exact match) should have the highest score and appear first + assert_eq!(state.unified_results[0].session_name(), "abc"); + // Verify descending score order + let scores: Vec<i64> = state + .unified_results + .iter() + .map(|r| match r { + UnifiedSearchResult::ActiveSession { score, .. } => *score, + UnifiedSearchResult::ResurrectableSession { score, .. } => *score, + }) + .collect(); + for i in 0..scores.len().saturating_sub(1) { + assert!(scores[i] >= scores[i + 1]); + } + } + + #[test] + fn test_2_5_tie_breaking_active_before_resurrectable() { + let mut state = SingleScreenState::default(); + let active = vec![make_active_session("test-session", 1, 1, 1, false, 100)]; + let resurrectable = vec![make_resurrectable("test-session", 100)]; + state.search_term = "test".to_string(); + state.update_search_term(&active, &resurrectable); + assert!(state.unified_results.len() >= 2); + // Find positions of active and resurrectable with the same name + let active_pos = state + .unified_results + .iter() + .position(|r| { + matches!(r, UnifiedSearchResult::ActiveSession { session_name, .. } if session_name == "test-session") + }); + let resurrectable_pos = state + .unified_results + .iter() + .position(|r| { + matches!(r, UnifiedSearchResult::ResurrectableSession { session_name, .. } if session_name == "test-session") + }); + assert!(active_pos.unwrap() < resurrectable_pos.unwrap()); + } + + #[test] + fn test_2_6_tie_breaking_more_recent_first_at_equal_score_and_type() { + let mut state = SingleScreenState::default(); + let active = vec![ + make_active_session("test-a", 1, 1, 1, false, 100), + make_active_session("test-b", 1, 1, 1, false, 50), + ]; + state.search_term = "test".to_string(); + state.update_search_term(&active, &[]); + // At equal scores, ascending creation_time places test-b (50s) before test-a (100s) + let pos_a = state + .unified_results + .iter() + .position(|r| r.session_name() == "test-a"); + let pos_b = state + .unified_results + .iter() + .position(|r| r.session_name() == "test-b"); + if let (Some(a), Some(b)) = (pos_a, pos_b) { + // If scores are equal, test-b should come first (more recent) + let score_a = match &state.unified_results[a] { + UnifiedSearchResult::ActiveSession { score, .. } => *score, + _ => 0, + }; + let score_b = match &state.unified_results[b] { + UnifiedSearchResult::ActiveSession { score, .. } => *score, + _ => 0, + }; + if score_a == score_b { + assert!( + b < a, + "test-b (50s, more recent) should appear before test-a (100s)" + ); + } + } + } + + #[test] + fn test_2_7_search_term_matches_nothing() { + let mut state = SingleScreenState::default(); + let active = vec![ + make_active_session("alpha", 1, 1, 1, false, 100), + make_active_session("beta", 1, 1, 1, false, 200), + ]; + state.search_term = "zzz".to_string(); + state.update_search_term(&active, &[]); + assert!(state.unified_results.is_empty()); + } + + #[test] + fn test_2_8_selection_preserved_across_search_updates() { + let mut state = SingleScreenState::default(); + let active = vec![ + make_active_session("alpha", 1, 1, 1, false, 100), + make_active_session("beta", 1, 1, 1, false, 200), + make_active_session("gamma", 1, 1, 1, false, 300), + ]; + // Step 1: empty search, populate results + state.search_term = String::new(); + state.update_search_term(&active, &[]); + // Step 2: select "beta" (find its index) + let beta_idx = state + .unified_results + .iter() + .position(|r| r.session_name() == "beta") + .unwrap(); + state.selected_index = Some(beta_idx); + // Step 3: search for "a" — beta does not match + state.search_term = "a".to_string(); + state.update_search_term(&active, &[]); + // "beta" does not contain "a" in a way that fuzzy matches well. + // Check: if beta is not in results, selected_index should be None + let beta_still_present = state + .unified_results + .iter() + .any(|r| r.session_name() == "beta"); + if !beta_still_present { + assert_eq!(state.selected_index, None); + } else { + // If beta still matches, selected_index should point to its new position + let new_beta_idx = state + .unified_results + .iter() + .position(|r| r.session_name() == "beta") + .unwrap(); + assert_eq!(state.selected_index, Some(new_beta_idx)); + } + } + + // --------------------------------------------------------------- + // Section 3: Selection and Navigation + // --------------------------------------------------------------- + + fn setup_results_with_current(state: &mut SingleScreenState, names: &[(&str, bool)]) { + // Directly populate unified_results for navigation tests + state.unified_results = names + .iter() + .enumerate() + .map( + |(i, (name, is_current))| UnifiedSearchResult::ActiveSession { + score: 0, + indices: vec![], + session_name: name.to_string(), + connected_users: 1, + tab_count: 1, + pane_count: 1, + is_current_session: *is_current, + creation_time: Duration::from_secs(i as u64 * 100), + }, + ) + .collect(); + } + + #[test] + fn test_3_1_down_from_none_selects_first_non_current() { + let mut state = SingleScreenState::default(); + setup_results_with_current( + &mut state, + &[("current", true), ("other-a", false), ("other-b", false)], + ); + state.selected_index = None; + state.move_selection_down(); + assert_eq!(state.selected_index, Some(1)); + } + + #[test] + fn test_3_2_down_from_last_wraps_to_first_non_current() { + let mut state = SingleScreenState::default(); + setup_results_with_current( + &mut state, + &[("other-a", false), ("current", true), ("other-b", false)], + ); + state.selected_index = Some(2); // other-b, the last + state.move_selection_down(); + assert_eq!(state.selected_index, Some(0)); // wraps, skips current at 1 + } + + #[test] + fn test_3_3_up_from_none_selects_last_non_current() { + let mut state = SingleScreenState::default(); + setup_results_with_current( + &mut state, + &[("other-a", false), ("current", true), ("other-b", false)], + ); + state.selected_index = None; + state.move_selection_up(); + assert_eq!(state.selected_index, Some(2)); // other-b + } + + #[test] + fn test_3_4_up_from_first_wraps_to_last_non_current() { + let mut state = SingleScreenState::default(); + setup_results_with_current( + &mut state, + &[("other-a", false), ("current", true), ("other-b", false)], + ); + state.selected_index = Some(0); + state.move_selection_up(); + assert_eq!(state.selected_index, Some(2)); // wraps, skips current + } + + #[test] + fn test_3_5_navigation_with_only_current_session() { + let mut state = SingleScreenState::default(); + setup_results_with_current(&mut state, &[("current", true)]); + state.selected_index = None; + state.move_selection_down(); + assert_eq!(state.selected_index, None); + state.move_selection_up(); + assert_eq!(state.selected_index, None); + } + + #[test] + fn test_3_6_navigation_with_empty_results() { + let mut state = SingleScreenState::default(); + state.unified_results = vec![]; + state.selected_index = None; + state.move_selection_down(); + assert_eq!(state.selected_index, None); + state.move_selection_up(); + assert_eq!(state.selected_index, None); + } + + #[test] + fn test_3_7_sequential_down_visits_all_non_current() { + let mut state = SingleScreenState::default(); + setup_results_with_current( + &mut state, + &[ + ("a", false), + ("current", true), + ("b", false), + ("c", false), + ("d", false), + ], + ); + state.selected_index = None; + state.move_selection_down(); + assert_eq!(state.selected_index, Some(0)); // "a" + state.move_selection_down(); + assert_eq!(state.selected_index, Some(2)); // "b", skip current at 1 + state.move_selection_down(); + assert_eq!(state.selected_index, Some(3)); // "c" + state.move_selection_down(); + assert_eq!(state.selected_index, Some(4)); // "d" + state.move_selection_down(); + assert_eq!(state.selected_index, Some(0)); // "a", wrapped + } + + #[test] + fn test_3_8_sequential_up_visits_all_non_current_in_reverse() { + let mut state = SingleScreenState::default(); + setup_results_with_current( + &mut state, + &[ + ("a", false), + ("current", true), + ("b", false), + ("c", false), + ("d", false), + ], + ); + state.selected_index = None; + state.move_selection_up(); + assert_eq!(state.selected_index, Some(4)); // "d" + state.move_selection_up(); + assert_eq!(state.selected_index, Some(3)); // "c" + state.move_selection_up(); + assert_eq!(state.selected_index, Some(2)); // "b" + state.move_selection_up(); + assert_eq!(state.selected_index, Some(0)); // "a", skip current at 1 + state.move_selection_up(); + assert_eq!(state.selected_index, Some(4)); // "d", wrapped + } + + #[test] + fn test_3_9_get_selected_result_returns_correct_result() { + let mut state = SingleScreenState::default(); + setup_results_with_current(&mut state, &[("a", false), ("b", false), ("c", false)]); + state.selected_index = Some(1); + let result = state.get_selected_result(); + assert!(result.is_some()); + assert_eq!(result.unwrap().session_name(), "b"); + } + + #[test] + fn test_3_10_get_selected_result_returns_none_when_nothing_selected() { + let mut state = SingleScreenState::default(); + setup_results_with_current(&mut state, &[("a", false), ("b", false), ("c", false)]); + state.selected_index = None; + assert!(state.get_selected_result().is_none()); + } + + // --------------------------------------------------------------- + // Section 4: Tab Completion + // --------------------------------------------------------------- + + #[test] + fn test_4_1_basic_tab_completion() { + let mut state = SingleScreenState::default(); + let active = vec![ + make_active_session("mine", 1, 1, 1, true, 100), + make_active_session("alpha", 1, 1, 1, false, 200), + make_active_session("beta", 1, 1, 1, false, 300), + ]; + state.search_term = String::new(); + state.update_search_term(&active, &[]); + state.tab_complete(&active, &[]); + // First non-current in sorted results should be "alpha" or whichever is first + // With empty search, sorted by creation_time ascending: mine(100), alpha(200), beta(300) + // First non-current is "alpha" + assert_eq!(state.search_term, "alpha"); + } + + #[test] + fn test_4_2_tab_completion_only_current_session_noop() { + let mut state = SingleScreenState::default(); + let active = vec![make_active_session("mine", 1, 1, 1, true, 100)]; + state.search_term = String::new(); + state.update_search_term(&active, &[]); + state.tab_complete(&active, &[]); + assert_eq!(state.search_term, ""); + } + + #[test] + fn test_4_3_tab_completion_picks_resurrectable_if_no_non_current_active() { + let mut state = SingleScreenState::default(); + let active = vec![make_active_session("mine", 1, 1, 1, true, 100)]; + let resurrectable = vec![make_resurrectable("old-session", 100)]; + state.search_term = String::new(); + state.update_search_term(&active, &resurrectable); + state.tab_complete(&active, &resurrectable); + assert_eq!(state.search_term, "old-session"); + } + + #[test] + fn test_4_4_tab_completion_with_partial_search_term() { + let mut state = SingleScreenState::default(); + let active = vec![ + make_active_session("mine", 1, 1, 1, true, 100), + make_active_session("alpha", 1, 1, 1, false, 200), + make_active_session("beta", 1, 1, 1, false, 300), + ]; + let resurrectable: Vec<(String, Duration)> = vec![]; + state.search_term = "be".to_string(); + state.update_search_term(&active, &resurrectable); + state.tab_complete(&active, &resurrectable); + assert_eq!(state.search_term, "beta"); + } + + #[test] + fn test_4_5_tab_completion_with_empty_results_noop() { + let mut state = SingleScreenState::default(); + let active = vec![ + make_active_session("alpha", 1, 1, 1, false, 100), + make_active_session("beta", 1, 1, 1, false, 200), + ]; + state.search_term = "zzz".to_string(); + state.update_search_term(&active, &[]); + // Results should be empty — no matches + assert!(state.unified_results.is_empty()); + state.tab_complete(&active, &[]); + assert_eq!(state.search_term, "zzz"); // unchanged + } + + // --------------------------------------------------------------- + // Section 5: Mode Transitions + // --------------------------------------------------------------- + + #[test] + fn test_5_1_transition_to_layout_selection() { + let mut state = SingleScreenState::default(); + state.mode = SingleScreenMode::SearchAndSelect; + state.search_term = "my-session".to_string(); + state.transition_to_layout_selection(); + assert_eq!(state.mode, SingleScreenMode::SelectingLayout); + assert_eq!(state.search_term, "my-session"); // preserved + } + + #[test] + fn test_5_2_transition_back_to_search() { + let mut state = SingleScreenState::default(); + state.mode = SingleScreenMode::SelectingLayout; + state.layout_list.layout_search_term = "some-layout".to_string(); + state.layout_list.selected_layout_index = 3; + state.transition_to_search(); + assert_eq!(state.mode, SingleScreenMode::SearchAndSelect); + assert_eq!(state.layout_list.layout_search_term, ""); + assert_eq!(state.layout_list.selected_layout_index, 0); + } + + #[test] + fn test_5_3_round_trip_transition_preserves_search_term() { + let mut state = SingleScreenState::default(); + state.search_term = "my-new-session".to_string(); + state.mode = SingleScreenMode::SearchAndSelect; + state.transition_to_layout_selection(); + assert_eq!(state.mode, SingleScreenMode::SelectingLayout); + state.transition_to_search(); + assert_eq!(state.search_term, "my-new-session"); + assert_eq!(state.mode, SingleScreenMode::SearchAndSelect); + } +} diff --git a/default-plugins/session-manager/src/ui/components.rs b/default-plugins/session-manager/src/ui/components.rs index 8310d2d3..b698e6ca 100644 --- a/default-plugins/session-manager/src/ui/components.rs +++ b/default-plugins/session-manager/src/ui/components.rs @@ -5,9 +5,241 @@ use unicode_width::UnicodeWidthChar; use unicode_width::UnicodeWidthStr; use zellij_tile::prelude::*; +use crate::single_screen::UnifiedSearchResult; use crate::ui::{PaneUiInfo, SessionUiInfo, TabUiInfo}; use crate::{ActiveScreen, NewSessionInfo}; +// --------------------------------------------------------------- +// Render cache for unified results +// --------------------------------------------------------------- + +/// Pre-computed data for a single visible row, independent of selection state. +#[derive(Clone)] +pub struct CachedRowData { + pub session_name: String, + pub indices: Vec<usize>, + pub original_index: usize, + pub kind: CachedRowKind, + // Pre-formatted strings (computed once, reused across renders) + pub full_details: String, + pub abbr_details: String, + pub full_tag: &'static str, + pub abbr_tag: &'static str, + // Pre-computed widths + pub name_width: usize, + pub full_details_width: usize, + pub abbr_details_width: usize, + pub full_tag_width: usize, + // Color range data for details cell + pub details_color_ranges: DetailsColorRanges, + pub abbr_details_color_ranges: DetailsColorRanges, +} + +#[derive(Clone)] +pub enum CachedRowKind { + Active, + Resurrectable, +} + +/// Byte-offset ranges for coloring details cells. +#[derive(Clone, Default)] +pub struct DetailsColorRanges { + pub ranges: Vec<(usize, std::ops::Range<usize>)>, // (color_index, range) +} + +/// Cached intermediate representation of the unified results table. +/// +/// Stored on `SingleScreenState` and rebuilt only when `unified_results` +/// changes (via `update_search_term` / `SessionUpdate`). Selection changes +/// and timer re-renders reuse the cached data. +#[derive(Default)] +pub struct UnifiedResultsRenderCache { + /// Filtered rows (current session excluded), with pre-formatted strings. + pub rows: Vec<CachedRowData>, + /// Max column widths across all cached rows. + pub full_name_width: usize, + pub full_details_width: usize, + pub abbr_details_width: usize, + pub full_tag_width: usize, +} + +impl UnifiedResultsRenderCache { + /// Rebuild the cache from the current `unified_results`. + pub fn rebuild(&mut self, results: &[UnifiedSearchResult]) { + self.rows.clear(); + self.full_name_width = 0; + self.full_details_width = 0; + self.abbr_details_width = 0; + self.full_tag_width = 0; + + for (orig_i, result) in results.iter().enumerate() { + let is_current = matches!( + result, + UnifiedSearchResult::ActiveSession { + is_current_session: true, + .. + } + ); + if is_current { + continue; + } + + let row = match result { + UnifiedSearchResult::ActiveSession { + indices, + session_name, + connected_users, + tab_count, + pane_count, + .. + } => { + let client_word = if *connected_users == 1 { + "client" + } else { + "clients" + }; + + let tab_str = format!("{}", tab_count); + let pane_str = format!("{}", pane_count); + let conn_str = format!("{}", connected_users); + + // Full details + let full_details = format!( + "{} tabs, {} panes, {} {}", + tab_str, pane_str, conn_str, client_word + ); + let full_details_ranges = { + let tab_end = tab_str.len(); + let pane_offset = tab_str.len() + " tabs, ".len(); + let pane_end = pane_offset + pane_str.len(); + let conn_offset = pane_end + " panes, ".len(); + let conn_end = conn_offset + conn_str.len(); + DetailsColorRanges { + ranges: vec![ + (1, 0..tab_end), + (2, pane_offset..pane_end), + (2, conn_offset..conn_end), + ], + } + }; + + // Abbreviated details + let abbr_details = format!("{}t, {}p, {}c", tab_str, pane_str, conn_str); + let abbr_details_ranges = { + let tab_end = tab_str.len(); + let pane_offset = tab_str.len() + "t, ".len(); + let pane_end = pane_offset + pane_str.len(); + let conn_offset = pane_end + "p, ".len(); + let conn_end = conn_offset + conn_str.len(); + DetailsColorRanges { + ranges: vec![ + (1, 0..tab_end), + (2, pane_offset..pane_end), + (2, conn_offset..conn_end), + ], + } + }; + + let name_width = session_name.width(); + let full_details_width = full_details.width(); + let abbr_details_width = abbr_details.width(); + + CachedRowData { + session_name: session_name.clone(), + indices: indices.clone(), + original_index: orig_i, + kind: CachedRowKind::Active, + full_details, + abbr_details, + full_tag: "[ATTACH]", + abbr_tag: "[A]", + name_width, + full_details_width, + abbr_details_width, + full_tag_width: "[ATTACH]".len(), + details_color_ranges: full_details_ranges, + abbr_details_color_ranges: abbr_details_ranges, + } + }, + UnifiedSearchResult::ResurrectableSession { + indices, + session_name, + ctime, + .. + } => { + let duration = humantime::format_duration(*ctime).to_string(); + let mut formatted_duration = String::new(); + for part in duration.split_whitespace() { + if !part.ends_with('s') { + if !formatted_duration.is_empty() { + formatted_duration.push(' '); + } + formatted_duration.push_str(part); + } + } + if formatted_duration.is_empty() { + formatted_duration.push_str("<1m"); + } + + let full_details = format!("Created {} ago", formatted_duration); + let full_details_ranges = { + let created_len = "Created ".len(); + let duration_end = created_len + formatted_duration.len(); + DetailsColorRanges { + ranges: vec![(2, created_len..duration_end)], + } + }; + + let abbr_details = format!("{} ago", formatted_duration); + let abbr_details_ranges = { + let duration_end = formatted_duration.len(); + DetailsColorRanges { + ranges: vec![(2, 0..duration_end)], + } + }; + + let name_width = session_name.width(); + let full_details_width = full_details.width(); + let abbr_details_width = abbr_details.width(); + + CachedRowData { + session_name: session_name.clone(), + indices: indices.clone(), + original_index: orig_i, + kind: CachedRowKind::Resurrectable, + full_details, + abbr_details, + full_tag: "[RESURRECT]", + abbr_tag: "[R]", + name_width, + full_details_width, + abbr_details_width, + full_tag_width: "[RESURRECT]".len(), + details_color_ranges: full_details_ranges, + abbr_details_color_ranges: abbr_details_ranges, + } + }, + }; + + // Update max widths + if row.name_width > self.full_name_width { + self.full_name_width = row.name_width; + } + if row.full_details_width > self.full_details_width { + self.full_details_width = row.full_details_width; + } + if row.abbr_details_width > self.abbr_details_width { + self.abbr_details_width = row.abbr_details_width; + } + if row.full_tag_width > self.full_tag_width { + self.full_tag_width = row.full_tag_width; + } + + self.rows.push(row); + } + } +} + #[derive(Debug)] pub struct ListItem { pub name: String, @@ -210,6 +442,21 @@ impl Default for SpanStyle { } } +fn truncate_to_width(text: &str, max_width: usize) -> String { + use unicode_width::UnicodeWidthChar; + let mut result = String::new(); + let mut current_width = 0; + for ch in text.chars() { + let ch_width = ch.width().unwrap_or(0); + if current_width + ch_width > max_width { + break; + } + result.push(ch); + current_width += ch_width; + } + result +} + #[derive(Debug, Default)] pub struct TruncatableUiSpan { text: String, @@ -536,6 +783,272 @@ pub fn render_prompt(search_term: &str, colors: Colors, x: usize, y: usize) { ); } +pub fn render_single_screen_prompt( + search_term: &str, + enter_action: Option<&str>, + colors: Colors, + x: usize, + y: usize, +) { + let prompt = colors.session_name_prompt("Session:"); + let search_term_display = colors.bold(&format!("{}_", search_term)); + let enter_hint = match enter_action { + Some(action) => { + let enter_key = colors.shortcuts("<ENTER>"); + format!(" {} - {}", enter_key, action) + }, + None => String::new(), + }; + println!( + "\u{1b}[m\u{1b}[{};{}H\u{1b}[0m{} {}{}\n", + y + 2, + x + 1, + prompt, + search_term_display, + enter_hint, + ); +} + +pub fn render_unified_results( + cache: &UnifiedResultsRenderCache, + selected_index: Option<usize>, + max_rows: usize, + max_cols: usize, + _colors: Colors, + x: usize, + y: usize, +) { + if cache.rows.is_empty() { + return; + } + + // Map selected_index (from original results) to filtered/cached position + let filtered_selected = + selected_index.and_then(|sel| cache.rows.iter().position(|r| r.original_index == sel)); + + // Calculate viewport range over the cached (already filtered) list + let total = cache.rows.len(); + let data_rows = max_rows.saturating_sub(1); // 1 for the empty header + let (start, end) = if data_rows >= total { + (0, total) + } else { + let anchor = filtered_selected.unwrap_or(0); + let half = data_rows / 2; + let mut s = anchor.saturating_sub(half); + let mut e = s + data_rows; + if e > total { + e = total; + s = total.saturating_sub(data_rows); + } + (s, e) + }; + + // Hidden-item counts (single pass over two slices) + let (above_active, above_resurrectable) = count_by_kind(&cache.rows[..start]); + let (below_active, below_resurrectable) = count_by_kind(&cache.rows[end..]); + + let has_hidden_above = above_active > 0 || above_resurrectable > 0; + let has_hidden_below = below_active > 0 || below_resurrectable > 0; + let has_hidden = has_hidden_above || has_hidden_below; + + // 4th column content strings + let tab_header_full = "<TAB> Complete"; + let tab_header_short = "<TAB>"; + + let above_summary_full = if has_hidden_above { + format!( + "[+{} Active] [+{} Exited]", + above_active, above_resurrectable + ) + } else { + String::new() + }; + let above_summary_short = if has_hidden_above { + format!("[+{}] [+{}]", above_active, above_resurrectable) + } else { + String::new() + }; + let below_summary_full = if has_hidden_below { + format!( + "[+{} Active] [+{} Exited]", + below_active, below_resurrectable + ) + } else { + String::new() + }; + let below_summary_short = if has_hidden_below { + format!("[+{}] [+{}]", below_active, below_resurrectable) + } else { + String::new() + }; + + let max_summary_full_width = std::cmp::max( + if has_hidden_above { + above_summary_full.width() + } else { + 0 + }, + if has_hidden_below { + below_summary_full.width() + } else { + 0 + }, + ); + let max_summary_short_width = std::cmp::max( + if has_hidden_above { + above_summary_short.width() + } else { + 0 + }, + if has_hidden_below { + below_summary_short.width() + } else { + 0 + }, + ); + + let full_fourth_col_width = std::cmp::max( + tab_header_full.width(), + if has_hidden { + max_summary_full_width + } else { + 1 + }, + ); + let short_fourth_col_width = std::cmp::max( + tab_header_short.width(), + if has_hidden { + max_summary_short_width + } else { + 1 + }, + ); + + // Use pre-computed widths from cache — no format!() allocations needed + let (abbreviate_details, abbreviate_tags, abbreviate_fourth_col, name_max_width) = + compute_reduction_tier( + cache.full_name_width, + cache.full_details_width, + cache.full_tag_width, + full_fourth_col_width, + cache.abbr_details_width, + short_fourth_col_width, + max_cols, + ); + + // Build table from cached data + let mut table = Table::new(); + + // Empty header row + table = table.add_styled_row(vec![ + Text::new(" "), + Text::new(" "), + Text::new(" "), + Text::new(" "), + ]); + + let visible_count = end - start; + for (row_index, row) in cache.rows[start..end].iter().enumerate() { + let is_selected = filtered_selected == Some(start + row_index); + + // Name cell — use cached string, only truncate if needed + let display_name = match name_max_width { + Some(max_w) => truncate_to_width(&row.session_name, max_w), + None => row.session_name.clone(), + }; + + let display_indices: Vec<usize> = row + .indices + .iter() + .filter(|&&i| i < display_name.chars().count()) + .cloned() + .collect(); + + let mut name_cell = Text::new(display_name).color_range(1, ..); + if !display_indices.is_empty() { + name_cell = name_cell.color_indices(3, display_indices); + } + + // Details and tag cells — use cached pre-formatted strings + let color_ranges = if abbreviate_details { + &row.abbr_details_color_ranges + } else { + &row.details_color_ranges + }; + let details_text = if abbreviate_details { + &row.abbr_details + } else { + &row.full_details + }; + let mut details_cell = Text::new(details_text); + for (color_idx, range) in &color_ranges.ranges { + details_cell = details_cell.color_range(*color_idx, range.clone()); + } + + let tag_text = if abbreviate_tags { + row.abbr_tag + } else { + row.full_tag + }; + let tag_cell = Text::new(tag_text).color_range(0, ..); + + // 4th column + let fourth_cell = if row_index == 0 && has_hidden_above { + let (summary_text, active_count, resurrectable_count) = if abbreviate_fourth_col { + (&above_summary_short, above_active, above_resurrectable) + } else { + (&above_summary_full, above_active, above_resurrectable) + }; + Text::new(summary_text) + .color_substring(2, &format!("+{}", active_count)) + .color_substring(2, &format!("+{}", resurrectable_count)) + } else if row_index == 0 && selected_index.is_none() { + let tab_hint_text = if abbreviate_fourth_col { + tab_header_short + } else { + tab_header_full + }; + Text::new(tab_hint_text).color_substring(3, "<TAB>") + } else if row_index == visible_count - 1 && has_hidden_below { + let (summary_text, active_count, resurrectable_count) = if abbreviate_fourth_col { + (&below_summary_short, below_active, below_resurrectable) + } else { + (&below_summary_full, below_active, below_resurrectable) + }; + Text::new(summary_text) + .color_substring(2, &format!("+{}", active_count)) + .color_substring(2, &format!("+{}", resurrectable_count)) + } else { + Text::new(" ") + }; + + if is_selected { + table = table.add_styled_row(vec![ + name_cell.selected(), + details_cell.selected(), + tag_cell.selected(), + fourth_cell, + ]); + } else { + table = table.add_styled_row(vec![name_cell, details_cell, tag_cell, fourth_cell]); + } + } + + print_table_with_coordinates(table, x, y, Some(max_cols), Some(max_rows)); +} + +fn count_by_kind(rows: &[CachedRowData]) -> (usize, usize) { + let mut active = 0; + let mut resurrectable = 0; + for row in rows { + match row.kind { + CachedRowKind::Active => active += 1, + CachedRowKind::Resurrectable => resurrectable += 1, + } + } + (active, resurrectable) +} + pub fn render_screen_toggle( active_screen: ActiveScreen, x: usize, @@ -569,6 +1082,10 @@ pub fn render_screen_toggle( ActiveScreen::ResurrectSession => { exited_sessions_text = exited_sessions_text.selected(); }, + ActiveScreen::SingleScreen => { + // SingleScreen does not use the tab toggle; this arm exists for exhaustiveness + return; + }, } let bg_color = match background { PaletteColor::Rgb((r, g, b)) => format!("\u{1b}[48;2;{};{};{}m\u{1b}[0K", r, g, b), @@ -988,6 +1505,35 @@ pub fn render_controls_line( false } }, + ActiveScreen::SingleScreen => { + let rename = colors.shortcuts("<Ctrl r>"); + let rename_text = colors.bold("Rename"); + let disconnect = colors.shortcuts("<Ctrl x>"); + let disconnect_full_text = colors.bold("Disconnect others"); + let disconnect_short_text = colors.bold("Disconnect"); + let kill = colors.shortcuts("<Del>"); + let kill_text = colors.bold("Kill/Delete"); + + // Full: "Help: <Ctrl r> - Rename, <Ctrl x> - Disconnect others, <Del> - Kill/Delete" = 76 chars + if max_cols > 76 { + print!( + "\u{1b}[m\u{1b}[{y};{x}HHelp: {rename} - {rename_text}, {disconnect} - {disconnect_full_text}, {kill} - {kill_text}" + ); + true + // Medium: "Help: <Ctrl r> - Rename, <Ctrl x> - Disconnect, <Del> - Kill/Delete" = 69 chars + } else if max_cols > 69 { + print!( + "\u{1b}[m\u{1b}[{y};{x}HHelp: {rename} - {rename_text}, {disconnect} - {disconnect_short_text}, {kill} - {kill_text}" + ); + true + // Compact: "<Ctrl r>/<Ctrl x>/<Del>" = 23 chars + } else if max_cols >= 23 { + print!("\u{1b}[m\u{1b}[{y};{x}H{rename}/{disconnect}/{kill}"); + false + } else { + false + } + }, }; if shows_help_prefix { @@ -1148,6 +1694,62 @@ impl Colors { } } +/// Computes the width reduction tier for the unified results table. +/// +/// Returns `(abbreviate_details, abbreviate_tags, abbreviate_fourth_col, name_max_width)`. +/// - Tier 0: everything fits at full size +/// - Tier 1: abbreviate details only +/// - Tier 2: abbreviate details + tags +/// - Tier 3: abbreviate details + tags + 4th column +/// - Tier 4: abbreviate all + truncate session names +pub fn compute_reduction_tier( + full_name_width: usize, + full_details_width: usize, + full_tag_width: usize, + full_fourth_col_width: usize, + abbr_details_width: usize, + short_fourth_col_width: usize, + max_cols: usize, +) -> (bool, bool, bool, Option<usize>) { + let full_total = + full_name_width + full_details_width + full_tag_width + full_fourth_col_width + 4; + if full_total <= max_cols { + // Everything fits at full size + (false, false, false, None) + } else { + // Reduction 1: abbreviate details + let total_after_details = + full_name_width + abbr_details_width + full_tag_width + full_fourth_col_width + 4; + if total_after_details <= max_cols { + (true, false, false, None) + } else { + // Reduction 2: abbreviate tags + let abbr_tag_width = 3; // "[A]" or "[R]" + let total_after_tags = + full_name_width + abbr_details_width + abbr_tag_width + full_fourth_col_width + 4; + if total_after_tags <= max_cols { + (true, true, false, None) + } else { + // Reduction 3: abbreviate 4th column + let total_after_fourth = full_name_width + + abbr_details_width + + abbr_tag_width + + short_fourth_col_width + + 4; + if total_after_fourth <= max_cols { + (true, true, true, None) + } else { + // Reduction 4: truncate session names + let available_for_name = max_cols.saturating_sub( + abbr_details_width + abbr_tag_width + short_fourth_col_width + 4, + ); + (true, true, true, Some(available_for_name)) + } + } + } + } +} + fn truncate_path(path: PathBuf, mut char_count_to_remove: usize) -> String { let mut truncated = String::new(); let component_count = path.iter().count(); @@ -1169,3 +1771,77 @@ fn truncate_path(path: PathBuf, mut char_count_to_remove: usize) -> String { } truncated } + +#[cfg(test)] +mod tests { + use super::*; + + // --------------------------------------------------------------- + // Section 8: Width Responsiveness (compute_reduction_tier) + // --------------------------------------------------------------- + + #[test] + fn test_8_1_tier_0_full_width_everything_fits() { + // full_total = 20 + 30 + 11 + 15 + 4 = 80, max_cols = 120 + let (abbr_details, abbr_tags, abbr_fourth, name_max) = + compute_reduction_tier(20, 30, 11, 15, 10, 5, 120); + assert_eq!( + (abbr_details, abbr_tags, abbr_fourth, name_max), + (false, false, false, None) + ); + } + + #[test] + fn test_8_2_tier_1_abbreviate_details_only() { + // full_total = 20 + 30 + 11 + 15 + 4 = 80 + // after abbr details: 20 + 10 + 11 + 15 + 4 = 60 + // max_cols = 70: full doesn't fit (80 > 70), but abbr details fits (60 <= 70) + let (abbr_details, abbr_tags, abbr_fourth, name_max) = + compute_reduction_tier(20, 30, 11, 15, 10, 5, 70); + assert_eq!( + (abbr_details, abbr_tags, abbr_fourth, name_max), + (true, false, false, None) + ); + } + + #[test] + fn test_8_3_tier_2_abbreviate_details_and_tags() { + // full_total = 20 + 30 + 11 + 15 + 4 = 80 + // after abbr details: 20 + 10 + 11 + 15 + 4 = 60 + // after abbr tags: 20 + 10 + 3 + 15 + 4 = 52 + // max_cols = 55: abbr details doesn't fit (60 > 55), abbr tags fits (52 <= 55) + let (abbr_details, abbr_tags, abbr_fourth, name_max) = + compute_reduction_tier(20, 30, 11, 15, 10, 5, 55); + assert_eq!( + (abbr_details, abbr_tags, abbr_fourth, name_max), + (true, true, false, None) + ); + } + + #[test] + fn test_8_4_tier_3_abbreviate_details_tags_and_fourth_col() { + // full_total = 20 + 30 + 11 + 15 + 4 = 80 + // after abbr details: 20 + 10 + 11 + 15 + 4 = 60 + // after abbr tags: 20 + 10 + 3 + 15 + 4 = 52 + // after abbr fourth: 20 + 10 + 3 + 5 + 4 = 42 + // max_cols = 45: abbr tags doesn't fit (52 > 45), abbr fourth fits (42 <= 45) + let (abbr_details, abbr_tags, abbr_fourth, name_max) = + compute_reduction_tier(20, 30, 11, 15, 10, 5, 45); + assert_eq!( + (abbr_details, abbr_tags, abbr_fourth, name_max), + (true, true, true, None) + ); + } + + #[test] + fn test_8_5_tier_4_truncate_session_names() { + // full_total = 20 + 30 + 11 + 15 + 4 = 80 + // after abbr fourth: 20 + 10 + 3 + 5 + 4 = 42 + // max_cols = 30: doesn't fit at tier 3 (42 > 30) + // available_for_name = 30 - (10 + 3 + 5 + 4) = 8 + let (abbr_details, abbr_tags, abbr_fourth, name_max) = + compute_reduction_tier(20, 30, 11, 15, 10, 5, 30); + assert_eq!((abbr_details, abbr_tags, abbr_fourth), (true, true, true)); + assert_eq!(name_max, Some(8)); // 30 - (10 + 3 + 5 + 4) = 8 + } +} diff --git a/default-plugins/session-manager/src/ui/mod.rs b/default-plugins/session-manager/src/ui/mod.rs index b86db946..9503b957 100644 --- a/default-plugins/session-manager/src/ui/mod.rs +++ b/default-plugins/session-manager/src/ui/mod.rs @@ -1,5 +1,6 @@ pub mod components; pub mod welcome_screen; +use std::time::Duration; use zellij_tile::prelude::*; use crate::session_list::{SelectedIndex, SessionList}; @@ -227,6 +228,7 @@ pub struct SessionUiInfo { pub tabs: Vec<TabUiInfo>, pub connected_users: usize, pub is_current_session: bool, + pub creation_time: Duration, } impl SessionUiInfo { @@ -240,6 +242,7 @@ impl SessionUiInfo { .collect(), connected_users: session_info.connected_clients, is_current_session: session_info.is_current_session, + creation_time: session_info.creation_time, } } pub fn line_count(&self, selected_index: &SelectedIndex) -> usize { @@ -293,7 +296,7 @@ impl TabUiInfo { .map(|p| { p.iter() .filter_map(|pane_info| { - if pane_info.is_selectable { + if pane_info.is_selectable && !pane_info.is_suppressed { Some(PaneUiInfo { name: pane_info.title.clone(), exit_code: pane_info.exit_status.clone(), |
