diff --git a/Cargo.lock b/Cargo.lock index f471a020cf..8afdcd0e52 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3325,6 +3325,7 @@ dependencies = [ "ansi_term", "chrono", "fuzzy-matcher", + "humantime", "unicode-width", "zellij-tile", ] diff --git a/default-plugins/session-manager/Cargo.toml b/default-plugins/session-manager/Cargo.toml index c2030f9610..97100ed690 100644 --- a/default-plugins/session-manager/Cargo.toml +++ b/default-plugins/session-manager/Cargo.toml @@ -10,3 +10,4 @@ zellij-tile = { path = "../../zellij-tile" } chrono = "0.4.0" fuzzy-matcher = "0.3.7" unicode-width = "0.1.10" +humantime = "2.1.0" diff --git a/default-plugins/session-manager/src/main.rs b/default-plugins/session-manager/src/main.rs index bd0b1f2b74..7e39307d9a 100644 --- a/default-plugins/session-manager/src/main.rs +++ b/default-plugins/session-manager/src/main.rs @@ -1,3 +1,4 @@ +mod resurrectable_sessions; mod session_list; mod ui; use zellij_tile::prelude::*; @@ -5,18 +6,24 @@ use zellij_tile::prelude::*; use std::collections::BTreeMap; use ui::{ - components::{render_controls_line, render_new_session_line, render_prompt, Colors}, + components::{ + render_controls_line, render_new_session_line, render_prompt, render_resurrection_toggle, + Colors, + }, SessionUiInfo, }; +use resurrectable_sessions::ResurrectableSessions; use session_list::SessionList; #[derive(Default)] struct State { session_name: Option, sessions: SessionList, + resurrectable_sessions: ResurrectableSessions, search_term: String, new_session_name: Option, + browsing_resurrection_sessions: bool, colors: Colors, } @@ -45,7 +52,9 @@ impl ZellijPlugin for State { Event::PermissionRequestResult(_result) => { should_render = true; }, - Event::SessionUpdate(session_infos) => { + Event::SessionUpdate(session_infos, resurrectable_session_list) => { + self.resurrectable_sessions + .update(resurrectable_session_list); self.update_session_infos(session_infos); should_render = true; }, @@ -55,6 +64,11 @@ impl ZellijPlugin for State { } fn render(&mut self, rows: usize, cols: usize) { + if self.browsing_resurrection_sessions { + self.resurrectable_sessions.render(rows, cols); + return; + } + render_resurrection_toggle(cols, false); render_prompt( self.new_session_name.is_some(), &self.search_term, @@ -94,12 +108,16 @@ impl State { } should_render = true; } else if let Key::Down = key { - if self.new_session_name.is_none() { + if self.browsing_resurrection_sessions { + self.resurrectable_sessions.move_selection_down(); + } else if self.new_session_name.is_none() { self.sessions.move_selection_down(); } should_render = true; } else if let Key::Up = key { - if self.new_session_name.is_none() { + if self.browsing_resurrection_sessions { + self.resurrectable_sessions.move_selection_up(); + } else if self.new_session_name.is_none() { self.sessions.move_selection_up(); } should_render = true; @@ -108,6 +126,8 @@ impl State { self.handle_selection(); } else if let Some(new_session_name) = self.new_session_name.as_mut() { new_session_name.push(character); + } else if self.browsing_resurrection_sessions { + self.resurrectable_sessions.handle_character(character); } else { self.search_term.push(character); self.sessions @@ -121,6 +141,8 @@ impl State { } else { new_session_name.pop(); } + } else if self.browsing_resurrection_sessions { + self.resurrectable_sessions.handle_backspace(); } else { self.search_term.pop(); self.sessions @@ -153,13 +175,33 @@ impl State { hide_self(); } should_render = true; + } else if let Key::BackTab = key { + self.browsing_resurrection_sessions = !self.browsing_resurrection_sessions; + should_render = true; + } else if let Key::Delete = key { + if self.browsing_resurrection_sessions { + self.resurrectable_sessions.delete_selected_session(); + should_render = true; + } + } else if let Key::Ctrl('d') = key { + if self.browsing_resurrection_sessions { + self.resurrectable_sessions + .show_delete_all_sessions_warning(); + should_render = true; + } } else if let Key::Esc = key { hide_self(); } should_render } fn handle_selection(&mut self) { - if let Some(new_session_name) = &self.new_session_name { + if self.browsing_resurrection_sessions { + if let Some(session_name_to_resurrect) = + self.resurrectable_sessions.get_selected_session_name() + { + switch_session(Some(&session_name_to_resurrect)); + } + } else if let Some(new_session_name) = &self.new_session_name { if new_session_name.is_empty() { switch_session(None); } else if self.session_name.as_ref() == Some(new_session_name) { diff --git a/default-plugins/session-manager/src/resurrectable_sessions.rs b/default-plugins/session-manager/src/resurrectable_sessions.rs new file mode 100644 index 0000000000..beed254fd6 --- /dev/null +++ b/default-plugins/session-manager/src/resurrectable_sessions.rs @@ -0,0 +1,338 @@ +use fuzzy_matcher::skim::SkimMatcherV2; +use fuzzy_matcher::FuzzyMatcher; +use humantime::format_duration; + +use crate::ui::components::render_resurrection_toggle; + +use std::time::Duration; + +use zellij_tile::shim::*; + +#[derive(Debug, Default)] +pub struct ResurrectableSessions { + pub all_resurrectable_sessions: Vec<(String, Duration)>, + pub selected_index: Option, + pub selected_search_index: Option, + pub search_results: Vec, + pub is_searching: bool, + pub search_term: String, + pub delete_all_dead_sessions_warning: bool, +} + +impl ResurrectableSessions { + pub fn update(&mut self, mut list: Vec<(String, Duration)>) { + list.sort_by(|a, b| a.1.cmp(&b.1)); + self.all_resurrectable_sessions = list; + } + pub fn render(&self, rows: usize, columns: usize) { + if self.delete_all_dead_sessions_warning { + self.render_delete_all_sessions_warning(rows, columns); + return; + } + render_resurrection_toggle(columns, true); + let search_indication = Text::new(format!("> {}_", self.search_term)).color_range(1, ..); + let table_rows = rows.saturating_sub(3); + let table_columns = columns; + let table = if self.is_searching { + self.render_search_results(table_rows, columns) + } else { + self.render_all_entries(table_rows, columns) + }; + print_text_with_coordinates(search_indication, 0, 0, None, None); + print_table_with_coordinates(table, 0, 1, Some(table_columns), Some(table_rows)); + self.render_controls_line(rows); + } + fn render_search_results(&self, table_rows: usize, _table_columns: usize) -> Table { + let mut table = Table::new().add_row(vec![" ", " ", " "]); // skip the title row + let (first_row_index_to_render, last_row_index_to_render) = self.range_to_render( + table_rows, + self.search_results.len(), + self.selected_search_index, + ); + for i in first_row_index_to_render..last_row_index_to_render { + if let Some(search_result) = self.search_results.get(i) { + let is_selected = Some(i) == self.selected_search_index; + let mut table_cells = vec![ + self.render_session_name( + &search_result.session_name, + Some(search_result.indices.clone()), + ), + self.render_ctime(&search_result.ctime), + self.render_more_indication_or_enter_as_needed( + i, + first_row_index_to_render, + last_row_index_to_render, + self.search_results.len(), + is_selected, + ), + ]; + if is_selected { + table_cells = table_cells.drain(..).map(|t| t.selected()).collect(); + } + table = table.add_styled_row(table_cells); + } + } + table + } + fn render_all_entries(&self, table_rows: usize, _table_columns: usize) -> Table { + let mut table = Table::new().add_row(vec![" ", " ", " "]); // skip the title row + let (first_row_index_to_render, last_row_index_to_render) = self.range_to_render( + table_rows, + self.all_resurrectable_sessions.len(), + self.selected_index, + ); + for i in first_row_index_to_render..last_row_index_to_render { + if let Some(session) = self.all_resurrectable_sessions.get(i) { + let is_selected = Some(i) == self.selected_index; + let mut table_cells = vec![ + self.render_session_name(&session.0, None), + self.render_ctime(&session.1), + self.render_more_indication_or_enter_as_needed( + i, + first_row_index_to_render, + last_row_index_to_render, + self.all_resurrectable_sessions.len(), + is_selected, + ), + ]; + if is_selected { + table_cells = table_cells.drain(..).map(|t| t.selected()).collect(); + } + table = table.add_styled_row(table_cells); + } + } + table + } + fn render_delete_all_sessions_warning(&self, rows: usize, columns: usize) { + if rows == 0 || columns == 0 { + return; + } + let session_count = self.all_resurrectable_sessions.len(); + let session_count_len = session_count.to_string().chars().count(); + let warning_description_text = + format!("This will delete {} resurrectable sessions", session_count,); + let confirmation_text = "Are you sure? (y/n)"; + let warning_y_location = (rows / 2).saturating_sub(1); + let confirmation_y_location = (rows / 2) + 1; + let warning_x_location = + columns.saturating_sub(warning_description_text.chars().count()) / 2; + let confirmation_x_location = columns.saturating_sub(confirmation_text.chars().count()) / 2; + print_text_with_coordinates( + Text::new(warning_description_text).color_range(0, 17..18 + 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, + ); + } + fn range_to_render( + &self, + table_rows: usize, + results_len: usize, + selected_index: Option, + ) -> (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) + } + } + fn render_session_name(&self, session_name: &str, indices: Option>) -> Text { + let text = Text::new(&session_name).color_range(0, ..); + match indices { + Some(indices) => text.color_indices(1, indices), + None => text, + } + } + fn render_ctime(&self, ctime: &Duration) -> Text { + let duration = format_duration(ctime.clone()).to_string(); + let duration_parts = duration.split_whitespace(); + let mut formatted_duration = String::new(); + for part in duration_parts { + 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 duration_len = formatted_duration.chars().count(); + Text::new(format!("Created {} ago", formatted_duration)).color_range(2, 8..9 + duration_len) + } + fn render_more_indication_or_enter_as_needed( + &self, + i: usize, + first_row_index_to_render: usize, + last_row_index_to_render: usize, + results_len: usize, + is_selected: bool, + ) -> Text { + if is_selected { + Text::new(format!(" - Resurrect Session")).color_range(3, 0..7) + } else if i == first_row_index_to_render && i > 0 { + Text::new(format!("+ {} more", first_row_index_to_render)).color_range(1, ..) + } else if i == last_row_index_to_render.saturating_sub(1) + && last_row_index_to_render < results_len + { + Text::new(format!( + "+ {} more", + results_len.saturating_sub(last_row_index_to_render) + )) + .color_range(1, ..) + } else { + Text::new(" ") + } + } + fn render_controls_line(&self, rows: usize) { + let controls_line = Text::new(format!( + "Help: <↓↑> - Navigate, - Delete Session, - Delete all sessions" + )) + .color_range(3, 6..10) + .color_range(3, 23..29) + .color_range(3, 47..56); + print_text_with_coordinates(controls_line, 0, rows.saturating_sub(1), None, None); + } + pub fn move_selection_down(&mut self) { + if self.is_searching { + if let Some(selected_index) = self.selected_search_index.as_mut() { + if *selected_index == self.search_results.len().saturating_sub(1) { + *selected_index = 0; + } else { + *selected_index = *selected_index + 1; + } + } else { + self.selected_search_index = Some(0); + } + } else { + if let Some(selected_index) = self.selected_index.as_mut() { + if *selected_index == self.all_resurrectable_sessions.len().saturating_sub(1) { + *selected_index = 0; + } else { + *selected_index = *selected_index + 1; + } + } else { + self.selected_index = Some(0); + } + } + } + pub fn move_selection_up(&mut self) { + if self.is_searching { + if let Some(selected_index) = self.selected_search_index.as_mut() { + if *selected_index == 0 { + *selected_index = self.search_results.len().saturating_sub(1); + } else { + *selected_index = selected_index.saturating_sub(1); + } + } else { + self.selected_search_index = Some(self.search_results.len().saturating_sub(1)); + } + } else { + if let Some(selected_index) = self.selected_index.as_mut() { + if *selected_index == 0 { + *selected_index = self.all_resurrectable_sessions.len().saturating_sub(1); + } else { + *selected_index = selected_index.saturating_sub(1); + } + } else { + self.selected_index = Some(self.all_resurrectable_sessions.len().saturating_sub(1)); + } + } + } + pub fn get_selected_session_name(&self) -> Option { + if self.is_searching { + self.selected_search_index + .and_then(|i| self.search_results.get(i)) + .map(|search_result| search_result.session_name.clone()) + } else { + self.selected_index + .and_then(|i| self.all_resurrectable_sessions.get(i)) + .map(|session_name_and_creation_time| session_name_and_creation_time.0.clone()) + } + } + pub fn delete_selected_session(&mut self) { + self.selected_index + .and_then(|i| { + if self.all_resurrectable_sessions.len() > i { + // optimistic update + if i == 0 { + self.selected_index = None; + } else if i == self.all_resurrectable_sessions.len().saturating_sub(1) { + self.selected_index = Some(i.saturating_sub(1)); + } + Some(self.all_resurrectable_sessions.remove(i)) + } else { + None + } + }) + .map(|session_name_and_creation_time| { + delete_dead_session(&session_name_and_creation_time.0) + }); + } + fn delete_all_sessions(&mut self) { + // optimistic update + self.all_resurrectable_sessions = vec![]; + self.delete_all_dead_sessions_warning = false; + delete_all_dead_sessions(); + } + pub fn show_delete_all_sessions_warning(&mut self) { + self.delete_all_dead_sessions_warning = true; + } + pub fn handle_character(&mut self, character: char) { + if self.delete_all_dead_sessions_warning && character == 'y' { + self.delete_all_sessions(); + } else if self.delete_all_dead_sessions_warning && character == 'n' { + self.delete_all_dead_sessions_warning = false; + } else { + self.search_term.push(character); + self.update_search_term(); + } + } + pub fn handle_backspace(&mut self) { + self.search_term.pop(); + self.update_search_term(); + } + fn update_search_term(&mut self) { + let mut matches = vec![]; + let matcher = SkimMatcherV2::default().use_cache(true); + for (session_name, ctime) in &self.all_resurrectable_sessions { + if let Some((score, indices)) = matcher.fuzzy_indices(&session_name, &self.search_term) + { + matches.push(SearchResult { + session_name: session_name.to_owned(), + ctime: ctime.clone(), + score, + indices, + }); + } + } + matches.sort_by(|a, b| b.score.cmp(&a.score)); + self.search_results = matches; + self.is_searching = !self.search_term.is_empty(); + self.selected_search_index = Some(0); + } +} + +#[derive(Debug)] +pub struct SearchResult { + score: i64, + indices: Vec, + session_name: String, + ctime: Duration, +} diff --git a/default-plugins/session-manager/src/ui/components.rs b/default-plugins/session-manager/src/ui/components.rs index 72aa218123..be3da41df7 100644 --- a/default-plugins/session-manager/src/ui/components.rs +++ b/default-plugins/session-manager/src/ui/components.rs @@ -478,12 +478,63 @@ pub fn minimize_lines( pub fn render_prompt(typing_session_name: bool, search_term: &str, colors: Colors) { if !typing_session_name { let prompt = colors.bold(&format!("> {}_", search_term)); - println!("{}\n", prompt); + println!("\u{1b}[H{}\n", prompt); } else { println!("\n"); } } +pub fn render_resurrection_toggle(cols: usize, resurrection_screen_is_active: bool) { + let key_indication_text = ""; + let running_sessions_text = "Running"; + let exited_sessions_text = "Exited"; + let key_indication_len = key_indication_text.chars().count() + 1; + let first_ribbon_length = running_sessions_text.chars().count() + 4; + let second_ribbon_length = exited_sessions_text.chars().count() + 4; + let key_indication_x = + cols.saturating_sub(key_indication_len + first_ribbon_length + second_ribbon_length); + let first_ribbon_x = key_indication_x + key_indication_len; + let second_ribbon_x = first_ribbon_x + first_ribbon_length; + print_text_with_coordinates( + Text::new(key_indication_text).color_range(3, ..), + key_indication_x, + 0, + None, + None, + ); + if resurrection_screen_is_active { + print_ribbon_with_coordinates( + Text::new(running_sessions_text), + first_ribbon_x, + 0, + None, + None, + ); + print_ribbon_with_coordinates( + Text::new(exited_sessions_text).selected(), + second_ribbon_x, + 0, + None, + None, + ); + } else { + print_ribbon_with_coordinates( + Text::new(running_sessions_text).selected(), + first_ribbon_x, + 0, + None, + None, + ); + print_ribbon_with_coordinates( + Text::new(exited_sessions_text), + second_ribbon_x, + 0, + None, + None, + ); + } +} + pub fn render_new_session_line(session_name: &Option, is_searching: bool, colors: Colors) { if is_searching { return; diff --git a/zellij-server/src/background_jobs.rs b/zellij-server/src/background_jobs.rs index 3f2777b766..5fa6ffcb54 100644 --- a/zellij-server/src/background_jobs.rs +++ b/zellij-server/src/background_jobs.rs @@ -1,12 +1,11 @@ use zellij_utils::async_std::task; use zellij_utils::consts::{ session_info_cache_file_name, session_info_folder_for_session, session_layout_cache_file_name, - ZELLIJ_SOCK_DIR, + ZELLIJ_SESSION_INFO_CACHE_DIR, ZELLIJ_SOCK_DIR, }; use zellij_utils::data::{Event, HttpVerb, SessionInfo}; use zellij_utils::errors::{prelude::*, BackgroundJobContext, ContextType}; use zellij_utils::surf::{ - self, http::{Method, Url}, RequestBuilder, }; @@ -35,7 +34,7 @@ pub enum BackgroundJob { StopPluginLoadingAnimation(u32), // u32 - plugin_id ReadAllSessionInfosOnMachine, // u32 - plugin_id ReportSessionInfo(String, SessionInfo), // String - session name - ReportLayoutInfo((String, BTreeMap)), // HashMap + ReportLayoutInfo((String, BTreeMap)), // BTreeMap RunCommand( PluginId, ClientId, @@ -163,89 +162,23 @@ pub(crate) fn background_jobs_main(bus: Bus) -> Result<()> { let current_session_layout = current_session_layout.clone(); async move { loop { - // write state of current session - - // write it to disk let current_session_name = current_session_name.lock().unwrap().to_string(); - let metadata_cache_file_name = - session_info_cache_file_name(¤t_session_name); let current_session_info = current_session_info.lock().unwrap().clone(); - let (current_session_layout, layout_files_to_write) = + let current_session_layout = current_session_layout.lock().unwrap().clone(); - let _wrote_metadata_file = std::fs::create_dir_all( - session_info_folder_for_session(¤t_session_name).as_path(), - ) - .and_then(|_| std::fs::File::create(metadata_cache_file_name)) - .and_then(|mut f| write!(f, "{}", current_session_info.to_string())); - - if !current_session_layout.is_empty() { - let layout_cache_file_name = - session_layout_cache_file_name(¤t_session_name); - let _wrote_layout_file = std::fs::create_dir_all( - session_info_folder_for_session(¤t_session_name) - .as_path(), - ) - .and_then(|_| std::fs::File::create(layout_cache_file_name)) - .and_then(|mut f| write!(f, "{}", current_session_layout)) - .and_then(|_| { - let session_info_folder = - session_info_folder_for_session(¤t_session_name); - for (external_file_name, external_file_contents) in - layout_files_to_write - { - std::fs::File::create( - session_info_folder.join(external_file_name), - ) - .and_then(|mut f| write!(f, "{}", external_file_contents)) - .unwrap_or_else( - |e| { - log::error!( - "Failed to write layout metadata file: {:?}", - e - ); - }, - ); - } - Ok(()) - }); - } - // start a background job (if not already running) that'll periodically read this and other - // sesion infos and report back - - // read state of all sessions - let mut other_session_names = vec![]; - let mut session_infos_on_machine = BTreeMap::new(); - // we do this so that the session infos will be actual and we're - // reasonably sure their session is running - if let Ok(files) = fs::read_dir(&*ZELLIJ_SOCK_DIR) { - files.for_each(|file| { - if let Ok(file) = file { - if let Ok(file_name) = file.file_name().into_string() { - if file.file_type().unwrap().is_socket() { - other_session_names.push(file_name); - } - } - } - }); - } - - for session_name in other_session_names { - let session_cache_file_name = - session_info_cache_file_name(&session_name); - if let Ok(raw_session_info) = - fs::read_to_string(&session_cache_file_name) - { - if let Ok(session_info) = SessionInfo::from_string( - &raw_session_info, - ¤t_session_name, - ) { - session_infos_on_machine.insert(session_name, session_info); - } - } - } + write_session_state_to_disk( + current_session_name.clone(), + current_session_info, + current_session_layout, + ); + let session_infos_on_machine = + read_other_live_session_states(¤t_session_name); + let resurrectable_sessions = + find_resurrectable_sessions(&session_infos_on_machine); let _ = senders.send_to_screen(ScreenInstruction::UpdateSessionInfos( session_infos_on_machine, + resurrectable_sessions, )); let _ = senders.send_to_screen(ScreenInstruction::DumpLayoutToHd); task::sleep(std::time::Duration::from_millis(SESSION_READ_DURATION)) @@ -396,3 +329,110 @@ fn job_already_running( }, } } + +fn write_session_state_to_disk( + current_session_name: String, + current_session_info: SessionInfo, + current_session_layout: (String, BTreeMap), +) { + let metadata_cache_file_name = session_info_cache_file_name(¤t_session_name); + let (current_session_layout, layout_files_to_write) = current_session_layout; + let _wrote_metadata_file = + std::fs::create_dir_all(session_info_folder_for_session(¤t_session_name).as_path()) + .and_then(|_| std::fs::File::create(metadata_cache_file_name)) + .and_then(|mut f| write!(f, "{}", current_session_info.to_string())); + + if !current_session_layout.is_empty() { + let layout_cache_file_name = session_layout_cache_file_name(¤t_session_name); + let _wrote_layout_file = std::fs::create_dir_all( + session_info_folder_for_session(¤t_session_name).as_path(), + ) + .and_then(|_| std::fs::File::create(layout_cache_file_name)) + .and_then(|mut f| write!(f, "{}", current_session_layout)) + .and_then(|_| { + let session_info_folder = session_info_folder_for_session(¤t_session_name); + for (external_file_name, external_file_contents) in layout_files_to_write { + std::fs::File::create(session_info_folder.join(external_file_name)) + .and_then(|mut f| write!(f, "{}", external_file_contents)) + .unwrap_or_else(|e| { + log::error!("Failed to write layout metadata file: {:?}", e); + }); + } + Ok(()) + }); + } +} + +fn read_other_live_session_states(current_session_name: &str) -> BTreeMap { + let mut other_session_names = vec![]; + let mut session_infos_on_machine = BTreeMap::new(); + // we do this so that the session infos will be actual and we're + // reasonably sure their session is running + if let Ok(files) = fs::read_dir(&*ZELLIJ_SOCK_DIR) { + files.for_each(|file| { + if let Ok(file) = file { + if let Ok(file_name) = file.file_name().into_string() { + if file.file_type().unwrap().is_socket() { + other_session_names.push(file_name); + } + } + } + }); + } + + for session_name in other_session_names { + let session_cache_file_name = session_info_cache_file_name(&session_name); + if let Ok(raw_session_info) = fs::read_to_string(&session_cache_file_name) { + if let Ok(session_info) = + SessionInfo::from_string(&raw_session_info, ¤t_session_name) + { + session_infos_on_machine.insert(session_name, session_info); + } + } + } + session_infos_on_machine +} + +fn find_resurrectable_sessions( + session_infos_on_machine: &BTreeMap, +) -> BTreeMap { + match fs::read_dir(&*ZELLIJ_SESSION_INFO_CACHE_DIR) { + Ok(files_in_session_info_folder) => { + let files_that_are_folders = files_in_session_info_folder + .filter_map(|f| f.ok().map(|f| f.path())) + .filter(|f| f.is_dir()); + files_that_are_folders + .filter_map(|folder_name| { + let session_name = folder_name.file_name()?.to_str()?.to_owned(); + if session_infos_on_machine.contains_key(&session_name) { + // this is not a dead session... + return None; + } + let layout_file_name = session_layout_cache_file_name(&session_name); + let ctime = match std::fs::metadata(&layout_file_name) + .and_then(|metadata| metadata.created()) + { + Ok(created) => Some(created), + Err(e) => { + log::error!( + "Failed to read created stamp of resurrection file: {:?}", + e + ); + None + }, + }; + let elapsed_duration = ctime + .map(|ctime| { + Duration::from_secs(ctime.elapsed().ok().unwrap_or_default().as_secs()) + }) + .unwrap_or_default(); + Some((session_name, elapsed_duration)) + }) + .collect() + }, + Err(e) => { + log::error!("Failed to read session info cache dir: {:?}", e); + BTreeMap::new() + }, + } +} diff --git a/zellij-server/src/plugins/zellij_exports.rs b/zellij-server/src/plugins/zellij_exports.rs index 524d0a8f30..38b89ceac7 100644 --- a/zellij-server/src/plugins/zellij_exports.rs +++ b/zellij-server/src/plugins/zellij_exports.rs @@ -27,7 +27,7 @@ use url::Url; use crate::{panes::PaneId, screen::ScreenInstruction}; use zellij_utils::{ - consts::VERSION, + consts::{VERSION, ZELLIJ_SESSION_INFO_CACHE_DIR, ZELLIJ_SOCK_DIR}, data::{ CommandToRun, Direction, Event, EventType, FileToOpen, InputMode, PluginCommand, PluginIds, PluginMessage, Resize, ResizeStrategy, @@ -224,6 +224,10 @@ fn host_run_plugin_command(env: FunctionEnvMut) { connect_to_session.tab_position, connect_to_session.pane_id, )?, + PluginCommand::DeleteDeadSession(session_name) => { + delete_dead_session(session_name)? + }, + PluginCommand::DeleteAllDeadSessions => delete_all_dead_sessions()?, PluginCommand::OpenFileInPlace(file_to_open) => { open_file_in_place(env, file_to_open) }, @@ -837,6 +841,52 @@ fn switch_session( Ok(()) } +fn delete_dead_session(session_name: String) -> Result<()> { + std::fs::remove_dir_all(&*ZELLIJ_SESSION_INFO_CACHE_DIR.join(&session_name)) + .with_context(|| format!("Failed to delete dead session: {:?}", &session_name)) +} + +fn delete_all_dead_sessions() -> Result<()> { + use std::os::unix::fs::FileTypeExt; + let mut live_sessions = vec![]; + if let Ok(files) = std::fs::read_dir(&*ZELLIJ_SOCK_DIR) { + files.for_each(|file| { + if let Ok(file) = file { + if let Ok(file_name) = file.file_name().into_string() { + if file.file_type().unwrap().is_socket() { + live_sessions.push(file_name); + } + } + } + }); + } + let dead_sessions: Vec = match std::fs::read_dir(&*ZELLIJ_SESSION_INFO_CACHE_DIR) { + Ok(files_in_session_info_folder) => { + let files_that_are_folders = files_in_session_info_folder + .filter_map(|f| f.ok().map(|f| f.path())) + .filter(|f| f.is_dir()); + files_that_are_folders + .filter_map(|folder_name| { + let session_name = folder_name.file_name()?.to_str()?.to_owned(); + if live_sessions.contains(&session_name) { + // this is not a dead session... + return None; + } + Some(session_name) + }) + .collect() + }, + Err(e) => { + log::error!("Failed to read session info cache dir: {:?}", e); + vec![] + }, + }; + for session in dead_sessions { + delete_dead_session(session)?; + } + Ok(()) +} + fn edit_scrollback(env: &ForeignFunctionEnv) { let action = Action::EditScrollback; let error_msg = || format!("Failed to edit scrollback"); @@ -1263,6 +1313,8 @@ fn check_command_permission( | PluginCommand::RenameTerminalPane(..) | PluginCommand::RenamePluginPane(..) | PluginCommand::SwitchSession(..) + | PluginCommand::DeleteDeadSession(..) + | PluginCommand::DeleteAllDeadSessions | PluginCommand::RenameTab(..) => PermissionType::ChangeApplicationState, _ => return (PermissionStatus::Granted, None), }; diff --git a/zellij-server/src/screen.rs b/zellij-server/src/screen.rs index 4f5833f4b6..d2b35a9bac 100644 --- a/zellij-server/src/screen.rs +++ b/zellij-server/src/screen.rs @@ -5,6 +5,7 @@ use std::collections::{BTreeMap, HashMap, HashSet}; use std::path::PathBuf; use std::rc::Rc; use std::str; +use std::time::Duration; use zellij_utils::data::{ Direction, PaneManifest, PluginPermission, Resize, ResizeStrategy, SessionInfo, @@ -301,7 +302,10 @@ pub enum ScreenInstruction { BreakPane(Box, Option, ClientId), BreakPaneRight(ClientId), BreakPaneLeft(ClientId), - UpdateSessionInfos(BTreeMap), // String is the session name + UpdateSessionInfos( + BTreeMap, // String is the session name + BTreeMap, // resurrectable sessions - + ), ReplacePane( PaneId, HoldForCommand, @@ -558,6 +562,8 @@ pub(crate) struct Screen { session_name: String, session_infos_on_machine: BTreeMap, // String is the session name, can // also be this session + resurrectable_sessions: BTreeMap, // String is the session name, duration is + // its creation time default_layout: Box, default_shell: Option, arrow_fonts: bool, @@ -585,6 +591,7 @@ impl Screen { let session_name = mode_info.session_name.clone().unwrap_or_default(); let session_info = SessionInfo::new(session_name.clone()); let mut session_infos_on_machine = BTreeMap::new(); + let resurrectable_sessions = BTreeMap::new(); session_infos_on_machine.insert(session_name.clone(), session_info); Screen { bus, @@ -616,6 +623,7 @@ impl Screen { serialize_pane_viewport, scrollback_lines_to_serialize, arrow_fonts, + resurrectable_sessions, } } @@ -1417,14 +1425,22 @@ impl Screen { pub fn update_session_infos( &mut self, new_session_infos: BTreeMap, + resurrectable_sessions: BTreeMap, ) -> Result<()> { self.session_infos_on_machine = new_session_infos; + self.resurrectable_sessions = resurrectable_sessions; self.bus .senders .send_to_plugin(PluginInstruction::Update(vec![( None, None, - Event::SessionUpdate(self.session_infos_on_machine.values().cloned().collect()), + Event::SessionUpdate( + self.session_infos_on_machine.values().cloned().collect(), + self.resurrectable_sessions + .iter() + .map(|(n, c)| (n.clone(), c.clone())) + .collect(), + ), )])) .context("failed to update session info")?; Ok(()) @@ -3421,8 +3437,8 @@ pub(crate) fn screen_thread_main( ScreenInstruction::BreakPaneLeft(client_id) => { screen.break_pane_to_new_tab(Direction::Left, client_id)?; }, - ScreenInstruction::UpdateSessionInfos(new_session_infos) => { - screen.update_session_infos(new_session_infos)?; + ScreenInstruction::UpdateSessionInfos(new_session_infos, resurrectable_sessions) => { + screen.update_session_infos(new_session_infos, resurrectable_sessions)?; }, ScreenInstruction::ReplacePane( new_pane_id, diff --git a/zellij-tile/src/shim.rs b/zellij-tile/src/shim.rs index 0ec41018de..eb898b4fc7 100644 --- a/zellij-tile/src/shim.rs +++ b/zellij-tile/src/shim.rs @@ -666,6 +666,22 @@ pub fn switch_session_with_focus( unsafe { host_run_plugin_command() }; } +/// Permanently delete a resurrectable session with the given name +pub fn delete_dead_session(name: &str) { + let plugin_command = PluginCommand::DeleteDeadSession(name.to_owned()); + let protobuf_plugin_command: ProtobufPluginCommand = plugin_command.try_into().unwrap(); + object_to_stdout(&protobuf_plugin_command.encode_to_vec()); + unsafe { host_run_plugin_command() }; +} + +/// Permanently delete aall resurrectable sessions on this machine +pub fn delete_all_dead_sessions() { + let plugin_command = PluginCommand::DeleteAllDeadSessions; + let protobuf_plugin_command: ProtobufPluginCommand = plugin_command.try_into().unwrap(); + object_to_stdout(&protobuf_plugin_command.encode_to_vec()); + unsafe { host_run_plugin_command() }; +} + // Utility Functions #[allow(unused)] diff --git a/zellij-utils/assets/prost/api.event.rs b/zellij-utils/assets/prost/api.event.rs index 0f6221dfc6..c095bed21b 100644 --- a/zellij-utils/assets/prost/api.event.rs +++ b/zellij-utils/assets/prost/api.event.rs @@ -55,6 +55,8 @@ pub mod event { pub struct SessionUpdatePayload { #[prost(message, repeated, tag = "1")] pub session_manifests: ::prost::alloc::vec::Vec, + #[prost(message, repeated, tag = "2")] + pub resurrectable_sessions: ::prost::alloc::vec::Vec, } #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] @@ -173,6 +175,14 @@ pub struct SessionManifest { } #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] +pub struct ResurrectableSession { + #[prost(string, tag = "1")] + pub name: ::prost::alloc::string::String, + #[prost(uint64, tag = "2")] + pub creation_time: u64, +} +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] pub struct PaneInfo { #[prost(uint32, tag = "1")] pub id: u32, diff --git a/zellij-utils/assets/prost/api.plugin_command.rs b/zellij-utils/assets/prost/api.plugin_command.rs index a97032b40c..f0d2fe02fb 100644 --- a/zellij-utils/assets/prost/api.plugin_command.rs +++ b/zellij-utils/assets/prost/api.plugin_command.rs @@ -5,7 +5,7 @@ pub struct PluginCommand { pub name: i32, #[prost( oneof = "plugin_command::Payload", - tags = "2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44" + tags = "2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45" )] pub payload: ::core::option::Option, } @@ -100,6 +100,8 @@ pub mod plugin_command { RunCommandPayload(super::RunCommandPayload), #[prost(message, tag = "44")] WebRequestPayload(super::WebRequestPayload), + #[prost(string, tag = "45")] + DeleteDeadSessionPayload(::prost::alloc::string::String), } } #[allow(clippy::derive_partial_eq_without_eq)] @@ -311,6 +313,8 @@ pub enum CommandName { OpenFileInPlace = 70, RunCommand = 71, WebRequest = 72, + DeleteDeadSession = 73, + DeleteAllDeadSessions = 74, } impl CommandName { /// String value of the enum field names used in the ProtoBuf definition. @@ -392,6 +396,8 @@ impl CommandName { CommandName::OpenFileInPlace => "OpenFileInPlace", CommandName::RunCommand => "RunCommand", CommandName::WebRequest => "WebRequest", + CommandName::DeleteDeadSession => "DeleteDeadSession", + CommandName::DeleteAllDeadSessions => "DeleteAllDeadSessions", } } /// Creates an enum from field names used in the ProtoBuf definition. @@ -470,6 +476,8 @@ impl CommandName { "OpenFileInPlace" => Some(Self::OpenFileInPlace), "RunCommand" => Some(Self::RunCommand), "WebRequest" => Some(Self::WebRequest), + "DeleteDeadSession" => Some(Self::DeleteDeadSession), + "DeleteAllDeadSessions" => Some(Self::DeleteAllDeadSessions), _ => None, } } diff --git a/zellij-utils/src/data.rs b/zellij-utils/src/data.rs index 91cf130874..82daf118d0 100644 --- a/zellij-utils/src/data.rs +++ b/zellij-utils/src/data.rs @@ -6,6 +6,7 @@ use std::collections::{BTreeMap, HashMap, HashSet}; use std::fmt; use std::path::{Path, PathBuf}; use std::str::FromStr; +use std::time::Duration; use strum_macros::{Display, EnumDiscriminants, EnumIter, EnumString, ToString}; pub type ClientId = u16; // TODO: merge with crate type? @@ -495,7 +496,10 @@ pub enum Event { FileSystemDelete(Vec), /// A Result of plugin permission request PermissionRequestResult(PermissionStatus), - SessionUpdate(Vec), + SessionUpdate( + Vec, + Vec<(String, Duration)>, // resurrectable sessions + ), RunCommandResult(Option, Vec, Vec, BTreeMap), // exit_code, STDOUT, STDERR, // context WebRequestResult( @@ -1082,6 +1086,8 @@ pub enum PluginCommand { ReportPanic(String), // stringified panic RequestPluginPermissions(Vec), SwitchSession(ConnectToSession), + DeleteDeadSession(String), // String -> session name + DeleteAllDeadSessions, // String -> session name OpenTerminalInPlace(FileToOpen), // only used for the path as cwd OpenFileInPlace(FileToOpen), OpenCommandPaneInPlace(CommandToRun), diff --git a/zellij-utils/src/plugin_api/event.proto b/zellij-utils/src/plugin_api/event.proto index cfffe06f5e..0d964a354a 100644 --- a/zellij-utils/src/plugin_api/event.proto +++ b/zellij-utils/src/plugin_api/event.proto @@ -70,6 +70,7 @@ message Event { message SessionUpdatePayload { repeated SessionManifest session_manifests = 1; + repeated ResurrectableSession resurrectable_sessions = 2; } message RunCommandResultPayload { @@ -153,6 +154,11 @@ message SessionManifest { bool is_current_session = 5; } +message ResurrectableSession { + string name = 1; + uint64 creation_time = 2; +} + message PaneInfo { uint32 id = 1; bool is_plugin = 2; diff --git a/zellij-utils/src/plugin_api/event.rs b/zellij-utils/src/plugin_api/event.rs index a322ec3bfc..d9e5341d6d 100644 --- a/zellij-utils/src/plugin_api/event.rs +++ b/zellij-utils/src/plugin_api/event.rs @@ -6,6 +6,7 @@ pub use super::generated_api::api::{ EventType as ProtobufEventType, InputModeKeybinds as ProtobufInputModeKeybinds, KeyBind as ProtobufKeyBind, ModeUpdatePayload as ProtobufModeUpdatePayload, PaneInfo as ProtobufPaneInfo, PaneManifest as ProtobufPaneManifest, + ResurrectableSession as ProtobufResurrectableSession, SessionManifest as ProtobufSessionManifest, TabInfo as ProtobufTabInfo, *, }, input_mode::InputMode as ProtobufInputMode, @@ -23,6 +24,7 @@ use crate::input::actions::Action; use std::collections::{HashMap, HashSet}; use std::convert::TryFrom; use std::path::PathBuf; +use std::time::Duration; impl TryFrom for Event { type Error = &'static str; @@ -176,10 +178,19 @@ impl TryFrom for Event { protobuf_session_update_payload, )) => { let mut session_infos: Vec = vec![]; + let mut resurrectable_sessions: Vec<(String, Duration)> = vec![]; for protobuf_session_info in protobuf_session_update_payload.session_manifests { session_infos.push(SessionInfo::try_from(protobuf_session_info)?); } - Ok(Event::SessionUpdate(session_infos)) + for protobuf_resurrectable_session in + protobuf_session_update_payload.resurrectable_sessions + { + resurrectable_sessions.push(protobuf_resurrectable_session.into()); + } + Ok(Event::SessionUpdate( + session_infos, + resurrectable_sessions.into(), + )) }, _ => Err("Malformed payload for the SessionUpdate Event"), }, @@ -359,13 +370,18 @@ impl TryFrom for ProtobufEvent { )), }) }, - Event::SessionUpdate(session_infos) => { + Event::SessionUpdate(session_infos, resurrectable_sessions) => { let mut protobuf_session_manifests = vec![]; for session_info in session_infos { protobuf_session_manifests.push(session_info.try_into()?); } + let mut protobuf_resurrectable_sessions = vec![]; + for resurrectable_session in resurrectable_sessions { + protobuf_resurrectable_sessions.push(resurrectable_session.into()); + } let session_update_payload = SessionUpdatePayload { session_manifests: protobuf_session_manifests, + resurrectable_sessions: protobuf_resurrectable_sessions, }; Ok(ProtobufEvent { name: ProtobufEventType::SessionUpdate as i32, @@ -887,6 +903,24 @@ impl TryFrom for ProtobufEventType { } } +impl From for (String, Duration) { + fn from(protobuf_resurrectable_session: ProtobufResurrectableSession) -> (String, Duration) { + ( + protobuf_resurrectable_session.name, + Duration::from_secs(protobuf_resurrectable_session.creation_time), + ) + } +} + +impl From<(String, Duration)> for ProtobufResurrectableSession { + fn from(session_name_and_creation_time: (String, Duration)) -> ProtobufResurrectableSession { + ProtobufResurrectableSession { + name: session_name_and_creation_time.0, + creation_time: session_name_and_creation_time.1.as_secs(), + } + } +} + #[test] fn serialize_mode_update_event() { use prost::Message; @@ -1249,7 +1283,7 @@ fn serialize_file_system_delete_event() { #[test] fn serialize_session_update_event() { use prost::Message; - let session_update_event = Event::SessionUpdate(Default::default()); + let session_update_event = Event::SessionUpdate(Default::default(), Default::default()); let protobuf_event: ProtobufEvent = session_update_event.clone().try_into().unwrap(); let serialized_protobuf_event = protobuf_event.encode_to_vec(); let deserialized_protobuf_event: ProtobufEvent = @@ -1360,8 +1394,9 @@ fn serialize_session_update_event_with_non_default_values() { is_current_session: false, }; let session_infos = vec![session_info_1, session_info_2]; + let resurrectable_sessions = vec![]; - let session_update_event = Event::SessionUpdate(session_infos); + let session_update_event = Event::SessionUpdate(session_infos, resurrectable_sessions); let protobuf_event: ProtobufEvent = session_update_event.clone().try_into().unwrap(); let serialized_protobuf_event = protobuf_event.encode_to_vec(); let deserialized_protobuf_event: ProtobufEvent = diff --git a/zellij-utils/src/plugin_api/plugin_command.proto b/zellij-utils/src/plugin_api/plugin_command.proto index 93e7d6b2bd..0b950ff0e7 100644 --- a/zellij-utils/src/plugin_api/plugin_command.proto +++ b/zellij-utils/src/plugin_api/plugin_command.proto @@ -84,6 +84,8 @@ enum CommandName { OpenFileInPlace = 70; RunCommand = 71; WebRequest = 72; + DeleteDeadSession = 73; + DeleteAllDeadSessions = 74; } message PluginCommand { @@ -132,6 +134,7 @@ message PluginCommand { OpenCommandPanePayload open_command_pane_in_place_payload = 42; RunCommandPayload run_command_payload = 43; WebRequestPayload web_request_payload = 44; + string delete_dead_session_payload = 45; } } diff --git a/zellij-utils/src/plugin_api/plugin_command.rs b/zellij-utils/src/plugin_api/plugin_command.rs index 84c083ad58..6b70b18ebc 100644 --- a/zellij-utils/src/plugin_api/plugin_command.rs +++ b/zellij-utils/src/plugin_api/plugin_command.rs @@ -628,6 +628,13 @@ impl TryFrom for PluginCommand { }, _ => Err("Mismatched payload for WebRequest"), }, + Some(CommandName::DeleteDeadSession) => match protobuf_plugin_command.payload { + Some(Payload::DeleteDeadSessionPayload(dead_session_name)) => { + Ok(PluginCommand::DeleteDeadSession(dead_session_name)) + }, + _ => Err("Mismatched payload for DeleteDeadSession"), + }, + Some(CommandName::DeleteAllDeadSessions) => Ok(PluginCommand::DeleteAllDeadSessions), None => Err("Unrecognized plugin command"), } } @@ -1044,6 +1051,14 @@ impl TryFrom for ProtobufPluginCommand { })), }) }, + PluginCommand::DeleteDeadSession(dead_session_name) => Ok(ProtobufPluginCommand { + name: CommandName::DeleteDeadSession as i32, + payload: Some(Payload::DeleteDeadSessionPayload(dead_session_name)), + }), + PluginCommand::DeleteAllDeadSessions => Ok(ProtobufPluginCommand { + name: CommandName::DeleteAllDeadSessions as i32, + payload: None, + }), } } }