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::{DeleteTarget, 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_single_screen_prompt, render_unified_results, render_unsaved_changes_line, Colors, }, welcome_screen::{render_banner, render_welcome_boundaries}, SessionUiInfo, }; use resurrectable_sessions::ResurrectableSessions; use session_list::SessionList; #[derive(Clone, Debug, Copy, PartialEq)] enum ActiveScreen { NewSession, AttachToSession, ResurrectSession, SingleScreen, } impl Default for ActiveScreen { fn default() -> Self { ActiveScreen::AttachToSession } } #[derive(Default)] pub(crate) struct State { session_name: Option, pub(crate) sessions: SessionList, pub(crate) resurrectable_sessions: ResurrectableSessions, search_term: String, new_session_info: NewSessionInfo, pub(crate) renaming_session_name: Option, pub(crate) error: Option, pub(crate) active_screen: ActiveScreen, pub(crate) colors: Colors, pub(crate) is_welcome_screen: bool, is_multi_screen: bool, pub(crate) single_screen_state: SingleScreenState, pub(crate) show_kill_all_sessions_warning: bool, request_ids: Vec, is_web_client: bool, current_session_last_saved_time: Option, is_visible: bool, refresh_timer_armed: bool, } register_plugin!(State); impl ZellijPlugin for State { fn load(&mut self, configuration: BTreeMap) { self.is_welcome_screen = configuration .get("welcome_screen") .map(|v| v == "true") .unwrap_or(false); if self.is_welcome_screen { 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; self.is_visible = true; subscribe(&[ EventType::ModeUpdate, EventType::SessionUpdate, EventType::Key, EventType::RunCommandResult, EventType::Timer, EventType::Visible, ]); rename_plugin_pane(get_plugin_ids().plugin_id, "Session Manager"); self.refresh_session_list(); if !self.is_welcome_screen { self.arm_refresh_timer(); } } fn pipe(&mut self, pipe_message: PipeMessage) -> bool { if pipe_message.name == "filepicker_result" { match (pipe_message.payload, pipe_message.args.get("request_id")) { (Some(payload), Some(request_id)) => { match self.request_ids.iter().position(|p| p == request_id) { 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 => { eprintln!("request id not found"); }, } }, _ => {}, } true } else { false } } fn update(&mut self, event: Event) -> bool { let mut should_render = false; match event { Event::Timer(_) => { self.refresh_timer_armed = false; if !self.is_visible { return false; } 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; } if self.refresh_session_list() { should_render = true; } self.arm_refresh_timer(); }, Event::Visible(is_visible) => { let was_visible = self.is_visible; self.is_visible = is_visible; if is_visible && !was_visible { if self.refresh_session_list() { should_render = true; } self.arm_refresh_timer(); } }, Event::ModeUpdate(mode_info) => { self.colors = Colors::new(mode_info.style.colors); let was_web_client = self.is_web_client; self.is_web_client = mode_info.is_web_client.unwrap_or(false); if was_web_client != self.is_web_client { self.refresh_session_list(); } should_render = true; }, Event::Key(key) => { should_render = self.handle_key(key); }, Event::PermissionRequestResult(_result) => { should_render = true; }, Event::SessionUpdate(session_infos, resurrectable_session_list) => { for session_info in &session_infos { if session_info.is_current_session { self.new_session_info .update_layout_list(session_info.available_layouts.clone()); } } 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, ); let previous_selection = self.single_screen_state.layout_list.selected_layout_index; let previous_search_term = self .single_screen_state .layout_list .layout_search_term .clone(); self.single_screen_state.layout_list = self.new_session_info.get_layout_list_clone(); self.single_screen_state.layout_list.layout_search_term = previous_search_term; self.single_screen_state.layout_list.update_search_term(); self.single_screen_state.layout_list.selected_layout_index = previous_selection.min(self.single_screen_state.layout_list.max_index()); } should_render = true; }, _ => (), }; should_render } fn render(&mut self, rows: usize, cols: usize) { let (x, y, width, height) = self.main_menu_size(rows, cols); let background = self.colors.palette.text_unselected.background; if self.is_welcome_screen { render_banner(x, 0, rows.saturating_sub(height), width); } 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 => { render_new_session_block( &self.new_session_info, self.colors, height.saturating_sub(2), width, x, y + 2, ); }, ActiveScreen::AttachToSession => { if let Some(new_session_name) = self.renaming_session_name.as_ref() { render_renaming_session_screen(&new_session_name, height, width, x, y + 2); } else if self.show_kill_all_sessions_warning { self.render_kill_all_sessions_warning(height, width, x, y); } else { render_prompt(&self.search_term, self.colors, x, y + 2); let bottom_lines = 7; let room_for_list = height.saturating_sub(bottom_lines); self.sessions.update_rows(room_for_list); let list = self.sessions .render(room_for_list, width.saturating_sub(7), self.colors); // 7 for various ui for (i, line) in list.iter().enumerate() { print!("\u{1b}[{};{}H{}", y + i + 5, x, line.render()); } } }, 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() { "" } else { &self.single_screen_state.search_term }; let esc = self.colors.shortcuts(""); 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, 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: {}_ ", 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.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, help_x, rows.saturating_sub(1), ); let adjusted_x = help_x + help_offset; let adjusted_width = width.saturating_sub(help_offset); render_unsaved_changes_line( adjusted_width, adjusted_x, rows, self.current_session_last_saved_time, ); } else { let _ = render_controls_line(self.active_screen, width, self.colors, x, rows); } if self.is_welcome_screen { render_welcome_boundaries(rows, cols); // explicitly done in the end to override some // stuff, see comment in function } } } impl State { fn reset_selected_index(&mut self) { self.sessions.reset_selected_index(); } fn handle_key(&mut self, key: KeyWithModifier) -> bool { if self.error.is_some() { self.error = None; return true; } match self.active_screen { 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 { let mut should_render = false; match key.bare_key { BareKey::Down if key.has_no_modifiers() => { self.new_session_info.handle_key(key); should_render = true; }, BareKey::Up if key.has_no_modifiers() => { self.new_session_info.handle_key(key); 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.new_session_info.handle_key(key); } should_render = true; }, BareKey::Backspace if key.has_no_modifiers() => { self.new_session_info.handle_key(key); should_render = true; }, BareKey::Char('w') if key.has_modifiers(&[KeyModifier::Ctrl]) => { self.active_screen = ActiveScreen::NewSession; should_render = true; }, BareKey::Tab if key.has_no_modifiers() => { self.toggle_active_screen(); 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()); // we insert this into the config so that a new plugin will be opened (the plugin's // uniqueness is determined by its name/url as well as its config) config.insert("request_id".to_owned(), request_id.to_string()); // we also insert this into the args so that the plugin will have an easier access to // it 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.new_session_info.new_session_folder = None; should_render = true; }, BareKey::Esc if key.has_no_modifiers() => { self.new_session_info.handle_key(key); should_render = true; }, _ => {}, } should_render } fn handle_attach_to_session(&mut self, key: KeyWithModifier) -> bool { let mut should_render = false; 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(); let was_searching = self.sessions.is_searching; let prev_search_idx = self.sessions.selected_search_index; let prev_top_idx = self.sessions.selected_index.0; match kill_sessions(&all_other_sessions) { Ok(()) => { self.sessions .session_ui_infos .retain(|s| !all_other_sessions.contains(&s.name)); self.sessions .update_search_term(&self.search_term, &self.colors); self.sessions.restore_selection_after_delete( was_searching, prev_search_idx, prev_top_idx, ); }, Err(e) => { self.show_error(&format!("Failed to kill sessions: {}", e)); }, } 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; }, _ => {}, } } else { match key.bare_key { BareKey::Right if key.has_no_modifiers() => { self.sessions.result_expand(); should_render = true; }, BareKey::Left if key.has_no_modifiers() => { self.sessions.result_shrink(); should_render = true; }, BareKey::Down if key.has_no_modifiers() => { self.sessions.move_selection_down(); should_render = true; }, BareKey::Up if key.has_no_modifiers() => { self.sessions.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 if let Some(new_session_name) = self.renaming_session_name.as_mut() { new_session_name.push(character); } else { self.search_term.push(character); self.sessions .update_search_term(&self.search_term, &self.colors); } should_render = true; }, BareKey::Backspace if key.has_no_modifiers() => { if let Some(new_session_name) = self.renaming_session_name.as_mut() { if new_session_name.is_empty() { self.renaming_session_name = None; } else { new_session_name.pop(); } } else { self.search_term.pop(); self.sessions .update_search_term(&self.search_term, &self.colors); } should_render = true; }, BareKey::Char('w') if key.has_modifiers(&[KeyModifier::Ctrl]) => { self.active_screen = ActiveScreen::NewSession; 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(selected_session_name) = self.sessions.get_selected_session_name() { let was_searching = self.sessions.is_searching; let prev_search_idx = self.sessions.selected_search_index; let prev_top_idx = self.sessions.selected_index.0; match kill_sessions(&[selected_session_name.clone()]) { Ok(()) => { self.sessions .session_ui_infos .retain(|s| s.name != selected_session_name); self.sessions .update_search_term(&self.search_term, &self.colors); self.sessions.restore_selection_after_delete( was_searching, prev_search_idx, prev_top_idx, ); }, Err(e) => { self.show_error(&format!("Failed to kill session: {}", e)); }, } } else { self.show_error("Must select session before killing it."); } 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('c') if key.has_modifiers(&[KeyModifier::Ctrl]) => { if !self.search_term.is_empty() { self.search_term.clear(); self.sessions .update_search_term(&self.search_term, &self.colors); self.reset_selected_index(); } else if !self.is_welcome_screen { self.reset_selected_index(); close_self(); } should_render = true; }, BareKey::Tab if key.has_no_modifiers() => { self.toggle_active_screen(); should_render = true; }, BareKey::Esc if key.has_no_modifiers() => { if self.renaming_session_name.is_some() { self.renaming_session_name = None; should_render = true; } else if !self.is_welcome_screen { close_self(); } }, BareKey::Char('a') if key.has_modifiers(&[KeyModifier::Ctrl]) => { if !self.is_welcome_screen { // we don't want to save welcome screen sessions if let Err(e) = save_session() { self.show_error(&format!("Couldn't save session: {}", e)); } } }, _ => {}, } } should_render } fn handle_resurrect_session_key(&mut self, key: KeyWithModifier) -> bool { let mut should_render = false; match key.bare_key { BareKey::Down if key.has_no_modifiers() => { self.resurrectable_sessions.move_selection_down(); should_render = true; }, BareKey::Up if key.has_no_modifiers() => { self.resurrectable_sessions.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.resurrectable_sessions.handle_character(character); } should_render = true; }, BareKey::Backspace if key.has_no_modifiers() => { self.resurrectable_sessions.handle_backspace(); should_render = true; }, BareKey::Char('w') if key.has_modifiers(&[KeyModifier::Ctrl]) => { self.active_screen = ActiveScreen::NewSession; should_render = true; }, BareKey::Tab if key.has_no_modifiers() => { self.toggle_active_screen(); should_render = true; }, BareKey::Delete if key.has_no_modifiers() => { self.resurrectable_sessions.delete_selected_session(); should_render = true; }, BareKey::Char('d') if key.has_modifiers(&[KeyModifier::Ctrl]) => { self.resurrectable_sessions .show_delete_all_sessions_warning(); should_render = true; }, BareKey::Esc if key.has_no_modifiers() => { if !self.is_welcome_screen { close_self(); } }, _ => {}, } 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(); let previous_index = self.single_screen_state.selected_index; match kill_sessions(&all_other_sessions) { Ok(()) => { self.sessions .session_ui_infos .retain(|s| !all_other_sessions.contains(&s.name)); self.single_screen_state.update_search_term( &self.sessions.session_ui_infos, &self.resurrectable_sessions.all_resurrectable_sessions, ); self.single_screen_state .restore_selection_after_delete(previous_index); }, Err(e) => { self.show_error(&format!("Failed to kill sessions: {}", e)); }, } 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() => { let selected = self .single_screen_state .get_selected_result() .map(|r| r.as_delete_target()); if let Some(target) = selected { let previous_index = self.single_screen_state.selected_index; let outcome: Result<(), String> = match &target { DeleteTarget::Active(name) => kill_sessions(&[name.clone()]).map(|()| { self.sessions.session_ui_infos.retain(|s| s.name != *name); }), DeleteTarget::Resurrectable(name) => delete_dead_session(name).map(|()| { self.resurrectable_sessions .all_resurrectable_sessions .retain(|(n, _)| n != name); }), }; match outcome { Ok(()) => { self.single_screen_state.update_search_term( &self.sessions.session_ui_infos, &self.resurrectable_sessions.all_resurrectable_sessions, ); self.single_screen_state .restore_selection_after_delete(previous_index); }, Err(e) => { self.show_error(&format!("Failed to delete session: {}", e)); }, } } 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 { close_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 { close_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 => { if self.new_session_info.name().len() >= 108 { // this is due to socket path limitations // TODO: get this from Zellij (for reference: this is part of the interprocess // package, we should get if from there if possible because it's configurable // through the package) self.show_error("Session name must be shorter than 108 bytes"); return; } else if self.new_session_info.name().contains('/') { self.show_error("Session name cannot contain '/'"); return; } else if self .sessions .has_forbidden_session(self.new_session_info.name()) { self.show_error("This session exists and web clients cannot attach to it."); return; } self.new_session_info.handle_selection(&self.session_name); }, ActiveScreen::AttachToSession => { 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; // so that we don't hide self } else if self.session_name.as_ref() == Some(renaming_session_name) { // noop - we're already called that! return; // so that we don't hide self } else if self.sessions.has_session(&renaming_session_name) { self.show_error("A session by this name already exists."); return; // so that we don't hide self } else if self .resurrectable_sessions .has_session(&renaming_session_name) { self.show_error("A resurrectable session by this name already exists."); return; // s that we don't hide self } 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; // s that we don't hide self } } if let Some(selected_session_name) = self.sessions.get_selected_session_name() { let selected_tab = self.sessions.get_selected_tab_position(); let selected_pane = self.sessions.get_selected_pane_id(); let is_current_session = self.sessions.selected_is_current_session(); if is_current_session { if let Some((pane_id, is_plugin)) = selected_pane { if is_plugin { focus_plugin_pane(pane_id, true, false); } else { focus_terminal_pane(pane_id, true, false); } } else if let Some(tab_position) = selected_tab { go_to_tab(tab_position as u32); } else { self.show_error("Already attached..."); } } else { switch_session_with_focus( &selected_session_name, selected_tab, selected_pane, ); } } self.reset_selected_index(); self.search_term.clear(); self.sessions .update_search_term(&self.search_term, &self.colors); if self.is_welcome_screen { // the welcome screen has done its job and now we need to quit this temporary // session so as not to leave garbage sessions behind quit_zellij(); } else { hide_self(); } }, ActiveScreen::ResurrectSession => { if let Some(session_name_to_resurrect) = self.resurrectable_sessions.get_selected_session_name() { switch_session(Some(&session_name_to_resurrect)); if self.is_welcome_screen { // the welcome screen has done its job and now we need to quit this temporary // session so as not to leave garbage sessions behind quit_zellij(); } else { hide_self(); } } }, 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) { self.active_screen = match self.active_screen { 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) { self.error = Some(error_text.to_owned()); } fn update_current_session_name_in_ui(&mut self, new_name: &str) { if let Some(old_session_name) = self.session_name.as_ref() { self.sessions .update_session_name(&old_session_name, new_name); } self.session_name = Some(new_name.to_owned()); } fn arm_refresh_timer(&mut self) { if !self.refresh_timer_armed { set_timeout(1.0); self.refresh_timer_armed = true; } } fn refresh_session_list(&mut self) -> bool { let snapshot = match get_session_list() { Ok(snapshot) => snapshot, Err(_) => return false, }; for session_info in &snapshot.live_sessions { if session_info.is_current_session { self.new_session_info .update_layout_list(session_info.available_layouts.clone()); } } self.resurrectable_sessions .update(snapshot.resurrectable_sessions); self.update_session_infos(snapshot.live_sessions); if !self.is_multi_screen { self.single_screen_state.update_search_term( &self.sessions.session_ui_infos, &self.resurrectable_sessions.all_resurrectable_sessions, ); let previous_selection = self.single_screen_state.layout_list.selected_layout_index; let previous_search_term = self .single_screen_state .layout_list .layout_search_term .clone(); self.single_screen_state.layout_list = self.new_session_info.get_layout_list_clone(); self.single_screen_state.layout_list.layout_search_term = previous_search_term; self.single_screen_state.layout_list.update_search_term(); self.single_screen_state.layout_list.selected_layout_index = previous_selection.min(self.single_screen_state.layout_list.max_index()); } true } fn update_session_infos(&mut self, session_infos: Vec) { let session_ui_infos: Vec = session_infos .iter() .filter_map(|s| { if self.is_web_client && !s.web_clients_allowed { None } else if self.is_welcome_screen && s.is_current_session { // do not display current session if we're the welcome screen // because: // 1. attaching to the welcome screen from the welcome screen is not a thing // 2. it can cause issues on the web (since we're disconnecting and // reconnecting to a session we just closed by disconnecting...) None } else { Some(SessionUiInfo::from_session_info(s)) } }) .collect(); let forbidden_sessions: Vec = session_infos .iter() .filter_map(|s| { if self.is_web_client && !s.web_clients_allowed { Some(SessionUiInfo::from_session_info(s)) } else { None } }) .collect(); let current_session_name = session_infos.iter().find_map(|s| { if s.is_current_session { Some(s.name.clone()) } else { None } }); if let Some(current_session_name) = current_session_name { self.session_name = Some(current_session_name); } self.sessions .set_sessions(session_ui_infos, forbidden_sessions); } fn main_menu_size(&self, rows: usize, cols: usize) -> (usize, usize, usize, usize) { // x, y, width, height let width = if self.is_welcome_screen { std::cmp::min(cols, 101) } else { cols }; let x = if self.is_welcome_screen { (cols.saturating_sub(width) as f64 / 2.0).floor() as usize + 2 } else { 0 }; let y = if self.is_welcome_screen { (rows.saturating_sub(15) as f64 / 2.0).floor() as usize } else { 0 }; 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(""); let reset_folder_shortcut = self.colors.shortcuts(""); 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(""); print!( "\u{1b}[m\u{1b}[{};{}H{} ({} to set)", y + 1, x + 1, self.colors.session_name_prompt(folder_prompt), change_folder_shortcut, ); }, } } pub(crate) fn render_kill_all_sessions_warning( &self, rows: usize, columns: usize, x: usize, y: usize, ) { if rows == 0 || columns == 0 { return; } let session_count = self.sessions.all_other_sessions().len(); let session_count_len = session_count.to_string().chars().count(); let warning_description_text = format!("This will kill {} active sessions", session_count); let confirmation_text = "Are you sure? (y/n)"; let warning_y_location = y + (rows / 2).saturating_sub(1); let confirmation_y_location = y + (rows / 2) + 1; let warning_x_location = x + columns.saturating_sub(warning_description_text.chars().count()) / 2; let confirmation_x_location = x + columns.saturating_sub(confirmation_text.chars().count()) / 2; print_text_with_coordinates( Text::new(warning_description_text).color_range(0, 15..16 + session_count_len), warning_x_location, warning_y_location, None, None, ); print_text_with_coordinates( Text::new(confirmation_text).color_indices(2, vec![15, 17]), confirmation_x_location, confirmation_y_location, None, None, ); } }