From 6b3703c3d49e49bc27b5f21a6c88a316fab1c5f0 Mon Sep 17 00:00:00 2001 From: dan Date: Wed, 7 Jan 2026 15:10:47 -0800 Subject: [PATCH 1/4] feat: add show_all_sessions option, session stability tracking, ci Add show_all_sessions config option to display all active sessions, not just those matching zoxide directories. Useful for seeing sessions created outside of zsm. Add stability tracking to prevent UI flickering from Zellij's inconsistent session updates. Sessions must be missing for 3 consecutive updates before removal, and new sessions appear immediately. Changes: - Add show_all_sessions config option (default: false) - Add update_sessions_stable() with missing-count tracking - Add find_matching_zoxide_dir() helper to reduce code duplication - Add unit tests for stability tracking logic - Add GitHub Actions CI workflow (test, build-wasm, clippy, fmt) - Fix all clippy warnings and formatting issues --- .github/workflows/ci.yml | 67 +++++++++++ src/config.rs | 9 +- src/main.rs | 118 +++++++++---------- src/new_session_info.rs | 21 ++-- src/session/manager.rs | 241 +++++++++++++++++++++++++++++++++++++-- src/state.rs | 123 ++++++++++---------- src/ui/components.rs | 14 +-- src/ui/renderer.rs | 2 +- src/zoxide/search.rs | 2 +- 9 files changed, 441 insertions(+), 156 deletions(-) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..a0ed6e4 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,67 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +env: + CARGO_TERM_COLOR: always + RUST_BACKTRACE: 1 + +jobs: + test: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install Rust + uses: actions-rust-lang/setup-rust-toolchain@v1 + + - name: Run tests + run: cargo test + + build-wasm: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install Rust + uses: actions-rust-lang/setup-rust-toolchain@v1 + with: + target: wasm32-wasip1 + + - name: Build WASM plugin + run: cargo build --target wasm32-wasip1 + + clippy: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install Rust + uses: actions-rust-lang/setup-rust-toolchain@v1 + with: + target: wasm32-wasip1 + components: clippy + + - name: Run clippy + run: cargo clippy --target wasm32-wasip1 -- -D warnings + + fmt: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install Rust + uses: actions-rust-lang/setup-rust-toolchain@v1 + with: + components: rustfmt + + - name: Check formatting + run: cargo fmt --check diff --git a/src/config.rs b/src/config.rs index a19cc1f..7b8b902 100644 --- a/src/config.rs +++ b/src/config.rs @@ -11,6 +11,8 @@ pub struct Config { pub show_resurrectable_sessions: bool, /// Base paths to strip from directory names when generating session names pub base_paths: Vec, + /// Whether to show all sessions, not just those matching zoxide directories + pub show_all_sessions: bool, } impl Default for Config { @@ -20,6 +22,7 @@ impl Default for Config { session_separator: ".".to_string(), show_resurrectable_sessions: false, base_paths: Vec::new(), + show_all_sessions: false, } } } @@ -46,7 +49,11 @@ impl Config { .filter(|p| !p.is_empty()) .collect() }) - .unwrap_or_else(Vec::new), + .unwrap_or_default(), + show_all_sessions: config + .get("show_all_sessions") + .map(|v| v == "true") + .unwrap_or(false), } } } diff --git a/src/main.rs b/src/main.rs index 2b011f0..2f01664 100644 --- a/src/main.rs +++ b/src/main.rs @@ -64,9 +64,11 @@ impl ZellijPlugin for PluginState { } } Event::SessionUpdate(session_infos, resurrectable_session_infos) => { - self.update_sessions(session_infos); - self.update_resurrectable_sessions(resurrectable_session_infos); - should_render = true; + // Only render if sessions actually changed (handles Zellij's inconsistent updates) + let sessions_changed = self.update_sessions(session_infos); + let resurrectable_changed = + self.update_resurrectable_sessions(resurrectable_session_infos); + should_render = sessions_changed || resurrectable_changed; } Event::RunCommandResult(exit_code, stdout, stderr, context) => { if context.contains_key("zoxide_query") { @@ -93,44 +95,43 @@ impl ZellijPlugin for PluginState { fn pipe(&mut self, pipe_message: PipeMessage) -> bool { // Handle filepicker results for new session creation if pipe_message.name == "filepicker_result" { - match (pipe_message.payload, pipe_message.args.get("request_id")) { - (Some(payload), Some(request_id)) => { - // Check if this request ID is valid for our plugin - if self.is_valid_request_id(request_id) { - self.remove_request_id(request_id); - let selected_path = std::path::PathBuf::from(payload); - - // Determine if we should use the path or its parent directory - let session_folder = if selected_path.exists() { - // Path exists, check if it's a file or directory - if selected_path.is_file() { - // If it's a file, use the parent directory - selected_path - .parent() - .map(|p| p.to_path_buf()) - .unwrap_or(selected_path) - } else { - // It's a directory, use it directly - selected_path - } + if let (Some(payload), Some(request_id)) = + (pipe_message.payload, pipe_message.args.get("request_id")) + { + // Check if this request ID is valid for our plugin + if self.is_valid_request_id(request_id) { + self.remove_request_id(request_id); + let selected_path = std::path::PathBuf::from(payload); + + // Determine if we should use the path or its parent directory + let session_folder = if selected_path.exists() { + // Path exists, check if it's a file or directory + if selected_path.is_file() { + // If it's a file, use the parent directory + selected_path + .parent() + .map(|p| p.to_path_buf()) + .unwrap_or(selected_path) } else { - // Path doesn't exist, try to infer from extension or structure - if let Some(_extension) = selected_path.extension() { - // Has an extension, likely a file - use parent directory - selected_path - .parent() - .map(|p| p.to_path_buf()) - .unwrap_or(selected_path) - } else { - // No extension, assume it's a directory - selected_path - } - }; - - self.set_new_session_folder(Some(session_folder)); - } + // It's a directory, use it directly + selected_path + } + } else { + // Path doesn't exist, try to infer from extension or structure + if selected_path.extension().is_some() { + // Has an extension, likely a file - use parent directory + selected_path + .parent() + .map(|p| p.to_path_buf()) + .unwrap_or(selected_path) + } else { + // No extension, assume it's a directory + selected_path + } + }; + + self.set_new_session_folder(Some(session_folder)); } - _ => {} } true } else { @@ -186,7 +187,7 @@ impl PluginState { self.update_zoxide_directories(directories); } - fn generate_smart_session_names(&self, directories: &mut Vec) { + fn generate_smart_session_names(&self, directories: &mut [zoxide::ZoxideDirectory]) { use std::collections::HashMap; // First pass: collect all basenames and find conflicts @@ -198,10 +199,7 @@ impl PluginState { .unwrap_or_default() .to_string_lossy() .to_string(); - basename_groups - .entry(basename) - .or_insert_with(Vec::new) - .push(i); + basename_groups.entry(basename).or_default().push(i); } // Second pass: generate names with context for conflicts and nested directories @@ -344,7 +342,7 @@ impl PluginState { fn normalize_path(&self, path: &str) -> String { let base_paths = &self.config().base_paths; - + // If no base paths configured, return the original path if base_paths.is_empty() { return path.to_string(); @@ -353,31 +351,31 @@ impl PluginState { // Find the longest matching base path let mut longest_match: Option<&String> = None; let mut longest_match_len = 0; - + for base_path in base_paths { // Normalize base path (remove trailing slash) let normalized_base = base_path.trim_end_matches('/'); - + // Check if path starts with this base path if path.starts_with(normalized_base) { // Make sure it's a directory boundary (not partial match) - if path.len() == normalized_base.len() || path.chars().nth(normalized_base.len()) == Some('/') { - if normalized_base.len() > longest_match_len { - longest_match = Some(base_path); - longest_match_len = normalized_base.len(); - } + let is_directory_boundary = path.len() == normalized_base.len() + || path.chars().nth(normalized_base.len()) == Some('/'); + if is_directory_boundary && normalized_base.len() > longest_match_len { + longest_match = Some(base_path); + longest_match_len = normalized_base.len(); } } } - + if let Some(base_path) = longest_match { let normalized_base = base_path.trim_end_matches('/'); - + // If path exactly matches the base path, keep the full path if path == normalized_base { return path.to_string(); } - + // Strip the base path and the following slash if let Some(stripped) = path.strip_prefix(normalized_base) { let stripped = stripped.strip_prefix('/').unwrap_or(stripped); @@ -386,14 +384,13 @@ impl PluginState { } } } - + path.to_string() } fn apply_smart_truncation(&self, segments: &[&str], min_segments: usize) -> String { let separator = &self.config().session_separator; let max_length = 29; - eprintln!("Applying smart truncation for segments: {:?} with min_segments: {}", segments, min_segments); // Start with minimum required segments from the right let mut result_segments: Vec = segments @@ -420,10 +417,8 @@ impl PluginState { // If still too long with just one segment, truncate it if current_length > max_length && result_segments.len() == 1 { - let sep_len = if result_segments.len() > 1 { separator.len() } else { 0 }; - let available = max_length.saturating_sub(sep_len); - result_segments[0].truncate(available); - current_length = result_segments.join(separator).len(); + result_segments[0].truncate(max_length); + current_length = result_segments[0].len(); } } @@ -437,7 +432,6 @@ impl PluginState { let mut test_segments = vec![abbreviated.clone()]; test_segments.extend(result_segments.clone()); let test_length = test_segments.join(separator).len(); - eprintln!("test_length with segment '{}': {}", segment, test_length); if test_length <= max_length { result_segments.insert(0, abbreviated); diff --git a/src/new_session_info.rs b/src/new_session_info.rs index 1918d1e..3d8dd35 100644 --- a/src/new_session_info.rs +++ b/src/new_session_info.rs @@ -11,18 +11,13 @@ pub struct NewSessionInfo { pub new_session_folder: Option, } -#[derive(Eq, PartialEq)] +#[derive(Default, Eq, PartialEq)] enum EnteringState { + #[default] EnteringName, EnteringLayoutSearch, } -impl Default for EnteringState { - fn default() -> Self { - EnteringState::EnteringName - } -} - impl NewSessionInfo { pub fn name(&self) -> &str { &self.name @@ -163,19 +158,19 @@ impl NewSessionInfo { match layout_info { Some(layout) => { - let cwd = self.new_session_folder.as_ref().map(|c| PathBuf::from(c)); + let cwd = self.new_session_folder.clone(); switch_session_with_layout(new_session_name, layout, cwd); } None => { // Default layout not found, create without layout but with folder - let cwd = self.new_session_folder.as_ref().map(|c| PathBuf::from(c)); + let cwd = self.new_session_folder.clone(); switch_session_with_cwd(new_session_name, cwd); } } } None => { // No default layout configured, create without layout but with folder - let cwd = self.new_session_folder.as_ref().map(|c| PathBuf::from(c)); + let cwd = self.new_session_folder.clone(); switch_session_with_cwd(new_session_name, cwd); } } @@ -198,11 +193,11 @@ impl NewSessionInfo { if new_session_name != current_session_name.as_ref().map(|s| s.as_str()) { match new_session_layout { Some(new_session_layout) => { - let cwd = self.new_session_folder.as_ref().map(|c| PathBuf::from(c)); + let cwd = self.new_session_folder.clone(); switch_session_with_layout(new_session_name, new_session_layout, cwd) } None => { - let cwd = self.new_session_folder.as_ref().map(|c| PathBuf::from(c)); + let cwd = self.new_session_folder.clone(); switch_session_with_cwd(new_session_name, cwd); } } @@ -311,7 +306,7 @@ impl NewSessionInfo { 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) + matcher.fuzzy_indices(layout_info.name(), &self.layout_list.layout_search_term) { matches.push(LayoutSearchResult { layout_info: layout_info.clone(), diff --git a/src/session/manager.rs b/src/session/manager.rs index 6963c81..9648b12 100644 --- a/src/session/manager.rs +++ b/src/session/manager.rs @@ -1,7 +1,11 @@ use crate::session::types::SessionAction; +use std::collections::HashMap; use std::time::Duration; use zellij_tile::prelude::{delete_dead_session, kill_sessions, switch_session, SessionInfo}; +/// Number of consecutive updates a session must be missing before we remove it +const MISSING_THRESHOLD: u8 = 3; + /// Manages session operations and state #[derive(Debug, Default)] pub struct SessionManager { @@ -11,20 +15,101 @@ pub struct SessionManager { pending_deletion: Option, /// Resurrectable sessions resurrectable_sessions: Vec<(String, Duration)>, + /// Tracks how many consecutive updates each session has been missing + /// Key is lowercase session name for case-insensitive matching + missing_counts: HashMap, } impl SessionManager { - /// Update the session list with new session information - pub fn update_sessions(&mut self, sessions: Vec) { - self.sessions = sessions; + /// Update session list with stability tracking + /// Returns true if the visible session list changed + pub fn update_sessions_stable(&mut self, new_sessions: Vec) -> bool { + let mut changed = false; + + // Build a set of session names from the new update (lowercase for comparison) + let new_session_names: HashMap = new_sessions + .iter() + .map(|s| (s.name.to_lowercase(), s)) + .collect(); + + // Check for sessions that are in the new list - reset their missing count + // and add any genuinely new sessions + for new_session in &new_sessions { + let key = new_session.name.to_lowercase(); + self.missing_counts.remove(&key); + + // Check if this is a new session we haven't seen before + let exists = self.sessions.iter().any(|s| s.name.to_lowercase() == key); + if !exists { + // New session - add it + self.sessions.push(new_session.clone()); + changed = true; + } else { + // Update existing session info (e.g., is_current_session flag) + if let Some(existing) = self + .sessions + .iter_mut() + .find(|s| s.name.to_lowercase() == key) + { + if existing.is_current_session != new_session.is_current_session { + existing.is_current_session = new_session.is_current_session; + changed = true; + } + } + } + } + + // Check for sessions that are missing from the new list + let mut sessions_to_remove = Vec::new(); + for session in &self.sessions { + let key = session.name.to_lowercase(); + if !new_session_names.contains_key(&key) { + // Session is missing - increment its missing count + let count = self.missing_counts.entry(key.clone()).or_insert(0); + *count += 1; + + if *count >= MISSING_THRESHOLD { + // Session has been missing long enough - remove it + sessions_to_remove.push(key); + changed = true; + } + } + } + + // Remove sessions that have been missing for too long + for key in sessions_to_remove { + self.sessions.retain(|s| s.name.to_lowercase() != key); + self.missing_counts.remove(&key); + } + + changed } - /// Update the resurrectable sessions - pub fn update_resurrectable_sessions( + /// Update resurrectable sessions with stability tracking + /// Returns true if the visible list changed + pub fn update_resurrectable_stable( &mut self, - resurrectable_sessions: Vec<(String, Duration)>, - ) { - self.resurrectable_sessions = resurrectable_sessions; + new_resurrectable: Vec<(String, Duration)>, + ) -> bool { + // For resurrectable sessions, we use simpler logic: + // Just check if the set of names changed (case-insensitive) + let mut current_names: Vec = self + .resurrectable_sessions + .iter() + .map(|(name, _)| name.to_lowercase()) + .collect(); + let mut new_names: Vec = new_resurrectable + .iter() + .map(|(name, _)| name.to_lowercase()) + .collect(); + + current_names.sort(); + new_names.sort(); + + let changed = current_names != new_names; + // Always update to get fresh durations + self.resurrectable_sessions = new_resurrectable; + changed } /// Get all sessions @@ -108,7 +193,145 @@ impl SessionManager { "{}{}{}", base_name, separator, - uuid::Uuid::new_v4().to_string()[..8].to_string() + &uuid::Uuid::new_v4().to_string()[..8] ) } } + +#[cfg(test)] +mod tests { + use super::*; + + fn make_session(name: &str, is_current: bool) -> SessionInfo { + use std::collections::BTreeMap; + use zellij_tile::prelude::PaneManifest; + SessionInfo { + name: name.to_string(), + is_current_session: is_current, + tabs: vec![], + panes: PaneManifest { + panes: std::collections::HashMap::new(), + }, + connected_clients: 0, + available_layouts: vec![], + plugins: BTreeMap::new(), + tab_history: BTreeMap::new(), + web_client_count: 0, + web_clients_allowed: true, + } + } + + #[test] + fn test_new_session_added_immediately() { + let mut manager = SessionManager::default(); + + let changed = manager.update_sessions_stable(vec![make_session("test", false)]); + + assert!(changed); + assert_eq!(manager.sessions().len(), 1); + assert_eq!(manager.sessions()[0].name, "test"); + } + + #[test] + fn test_session_not_removed_on_single_missing_update() { + let mut manager = SessionManager::default(); + + // Add a session + manager.update_sessions_stable(vec![make_session("test", false)]); + + // Session disappears for one update + let changed = manager.update_sessions_stable(vec![]); + + // Should NOT be removed yet (needs MISSING_THRESHOLD updates) + assert!(!changed); + assert_eq!(manager.sessions().len(), 1); + } + + #[test] + fn test_session_removed_after_threshold_missing_updates() { + let mut manager = SessionManager::default(); + + // Add a session + manager.update_sessions_stable(vec![make_session("test", false)]); + + // Session disappears for MISSING_THRESHOLD updates + for i in 0..MISSING_THRESHOLD { + let changed = manager.update_sessions_stable(vec![]); + if i < MISSING_THRESHOLD - 1 { + assert!(!changed, "Should not report changed before threshold"); + assert_eq!(manager.sessions().len(), 1, "Session should still exist"); + } else { + assert!(changed, "Should report changed when removed"); + assert_eq!(manager.sessions().len(), 0, "Session should be removed"); + } + } + } + + #[test] + fn test_session_reappearing_resets_missing_count() { + let mut manager = SessionManager::default(); + + // Add a session + manager.update_sessions_stable(vec![make_session("test", false)]); + + // Session disappears for 2 updates (less than threshold) + manager.update_sessions_stable(vec![]); + manager.update_sessions_stable(vec![]); + + // Session reappears + let changed = manager.update_sessions_stable(vec![make_session("test", false)]); + assert!(!changed); // No visible change + assert_eq!(manager.sessions().len(), 1); + + // Now it disappears again - counter should have been reset + let changed = manager.update_sessions_stable(vec![]); + assert!(!changed); + assert_eq!(manager.sessions().len(), 1); // Still there after 1 missing + } + + #[test] + fn test_is_current_session_update_triggers_change() { + let mut manager = SessionManager::default(); + + // Add a non-current session + manager.update_sessions_stable(vec![make_session("test", false)]); + + // Update it to be current + let changed = manager.update_sessions_stable(vec![make_session("test", true)]); + + assert!(changed); + assert!(manager.sessions()[0].is_current_session); + } + + #[test] + fn test_resurrectable_name_change_triggers_update() { + let mut manager = SessionManager::default(); + + // Add initial resurrectable sessions + let changed = manager + .update_resurrectable_stable(vec![("session1".to_string(), Duration::from_secs(60))]); + assert!(changed); + + // Same sessions - no change + let changed = manager + .update_resurrectable_stable(vec![("session1".to_string(), Duration::from_secs(120))]); + assert!(!changed); + + // Different sessions - change + let changed = manager + .update_resurrectable_stable(vec![("session2".to_string(), Duration::from_secs(60))]); + assert!(changed); + } + + #[test] + fn test_resurrectable_case_insensitive_comparison() { + let mut manager = SessionManager::default(); + + manager.update_resurrectable_stable(vec![("Session".to_string(), Duration::from_secs(60))]); + + // Same name different case - should NOT trigger change + let changed = manager + .update_resurrectable_stable(vec![("session".to_string(), Duration::from_secs(60))]); + assert!(!changed); + } +} diff --git a/src/state.rs b/src/state.rs index a0f948a..f8d461b 100644 --- a/src/state.rs +++ b/src/state.rs @@ -1,4 +1,4 @@ -use std::collections::BTreeMap; +use std::collections::{BTreeMap, HashSet}; use std::time::Duration; use zellij_tile::prelude::*; @@ -8,6 +8,7 @@ use crate::session::{SessionAction, SessionItem, SessionManager}; use crate::zoxide::{SearchEngine, ZoxideDirectory}; /// The main plugin state +#[derive(Default)] pub struct PluginState { /// Plugin configuration config: Config, @@ -34,47 +35,25 @@ pub struct PluginState { } /// Represents the different screens in the plugin -#[derive(Debug, Clone, Copy, PartialEq)] +#[derive(Debug, Clone, Copy, Default, PartialEq)] pub enum ActiveScreen { /// Main screen showing zoxide directories and sessions + #[default] Main, /// New session creation screen NewSession, } -impl Default for ActiveScreen { - fn default() -> Self { - ActiveScreen::Main - } -} - -impl Default for PluginState { - fn default() -> Self { - Self { - config: Config::default(), - session_manager: SessionManager::default(), - zoxide_directories: Vec::new(), - search_engine: SearchEngine::default(), - new_session_info: NewSessionInfo::default(), - active_screen: ActiveScreen::default(), - error: None, - colors: None, - current_session_name: None, - request_ids: Vec::new(), - selected_index: None, - } - } -} - impl PluginState { /// Initialize plugin with configuration pub fn initialize(&mut self, configuration: BTreeMap) { self.config = Config::from_zellij_config(&configuration); } - /// Update session information - pub fn update_sessions(&mut self, sessions: Vec) { - // Store current session name + /// Update session information with stability tracking + /// Returns true if the session list actually changed + pub fn update_sessions(&mut self, sessions: Vec) -> bool { + // Store current session name and layouts from the incoming data for session in &sessions { if session.is_current_session { self.current_session_name = Some(session.name.clone()); @@ -84,18 +63,27 @@ impl PluginState { } } - self.session_manager.update_sessions(sessions); - self.update_search_if_needed(); + // Use stable update that handles Zellij's inconsistent data + let changed = self.session_manager.update_sessions_stable(sessions); + if changed { + self.update_search_if_needed(); + } + changed } /// Update session information for resurrectable sessions + /// Returns true if the resurrectable session list actually changed pub fn update_resurrectable_sessions( &mut self, resurrectable_sessions: Vec<(String, Duration)>, - ) { - self.session_manager - .update_resurrectable_sessions(resurrectable_sessions); - self.update_search_if_needed(); + ) -> bool { + let changed = self + .session_manager + .update_resurrectable_stable(resurrectable_sessions); + if changed { + self.update_search_if_needed(); + } + changed } /// Update zoxide directories (managed separately from sessions) @@ -149,40 +137,42 @@ impl PluginState { /// Combine sessions and zoxide directories for display fn combined_items(&self) -> Vec { let mut items = Vec::new(); + let mut added_session_names = HashSet::new(); // First, add existing sessions that match zoxide directories (including incremented ones) for session in self.session_manager.sessions() { - // Check if this session name matches any generated session name from zoxide directories - for zoxide_dir in &self.zoxide_directories { - // Match exact name or incremented names (e.g., "project" matches "project.2", "project.3", etc.) - if session.name == zoxide_dir.session_name - || self.is_incremented_session(&session.name, &zoxide_dir.session_name) - { - items.push(SessionItem::ExistingSession { - name: session.name.clone(), - directory: zoxide_dir.directory.clone(), - is_current: session.is_current_session, - }); - break; - } + if let Some(zoxide_dir) = self.find_matching_zoxide_dir(&session.name) { + items.push(SessionItem::ExistingSession { + name: session.name.clone(), + directory: zoxide_dir.directory.clone(), + is_current: session.is_current_session, + }); + added_session_names.insert(session.name.clone()); + } else if self.config.show_all_sessions { + // Session didn't match any zoxide dir, but show_all_sessions is enabled + items.push(SessionItem::ExistingSession { + name: session.name.clone(), + directory: String::new(), + is_current: session.is_current_session, + }); + added_session_names.insert(session.name.clone()); } } // Add resurrectable sessions if configured to show them if self.config.show_resurrectable_sessions { for (name, duration) in self.session_manager.resurrectable_sessions() { - // Check if this session name matches any generated session name from zoxide directories - for zoxide_dir in &self.zoxide_directories { - // Match exact name or incremented names (e.g., "project" matches "project.2", "project.3", etc.) - if name == &zoxide_dir.session_name - || self.is_incremented_session(name, &zoxide_dir.session_name) - { - items.push(SessionItem::ResurrectableSession { - name: name.clone(), - duration: duration.clone(), - }); - break; - } + // Skip if already added as existing session + if added_session_names.contains(name) { + continue; + } + + let matches_zoxide = self.find_matching_zoxide_dir(name).is_some(); + if matches_zoxide || self.config.show_all_sessions { + items.push(SessionItem::ResurrectableSession { + name: name.clone(), + duration: *duration, + }); } } } @@ -198,7 +188,7 @@ impl PluginState { items } - /// Check if session name is an incremented version of base name + /// Check if session name is an incremented version of base name fn is_incremented_session(&self, session_name: &str, base_name: &str) -> bool { if session_name.len() <= base_name.len() || !session_name.starts_with(base_name) { return false; @@ -213,6 +203,15 @@ impl PluginState { number_part.parse::().is_ok() && !number_part.is_empty() } + /// Check if a session name matches any zoxide directory (exact or incremented name) + /// Returns the matching directory if found + fn find_matching_zoxide_dir(&self, session_name: &str) -> Option<&ZoxideDirectory> { + self.zoxide_directories.iter().find(|zoxide_dir| { + session_name == zoxide_dir.session_name + || self.is_incremented_session(session_name, &zoxide_dir.session_name) + }) + } + /// Get search engine (for UI rendering) pub fn search_engine(&self) -> &SearchEngine { &self.search_engine @@ -433,7 +432,7 @@ impl PluginState { if *selected == items_len.saturating_sub(1) { *selected = 0; } else { - *selected = *selected + 1; + *selected += 1; } } else { self.selected_index = Some(0); diff --git a/src/ui/components.rs b/src/ui/components.rs index 7888b6a..65cfe3f 100644 --- a/src/ui/components.rs +++ b/src/ui/components.rs @@ -28,7 +28,7 @@ pub fn render_new_session_block( let long_instruction = "when done, blank for random"; let new_session_name = new_session_info.name(); if max_cols_of_new_session_block > 70 { - let session_name_text = Text::new(&format!( + let session_name_text = Text::new(format!( "{} {}_ ( {})", prompt, new_session_name, long_instruction )) @@ -44,7 +44,7 @@ pub fn render_new_session_block( ); print_text_with_coordinates(session_name_text, x, y + 1, None, None); } else { - let session_name_text = Text::new(&format!("{} {}_ ", prompt, new_session_name)) + let session_name_text = Text::new(format!("{} {}_ ", prompt, new_session_name)) .color_range(3, ..prompt.len()) .color_range( 0, @@ -60,7 +60,7 @@ pub fn render_new_session_block( new_session_info.name() }; let prompt = "New session name:"; - let session_name_text = Text::new(&format!( + let session_name_text = Text::new(format!( "{} {} (Ctrl+ to correct)", prompt, new_session_name )) @@ -139,7 +139,7 @@ pub fn render_layout_selection_list( .color_range(0, layout_name.len() + 1..) .color_indices(3, indices) } else { - Text::new(format!("{}", layout_name)) + Text::new(layout_name.to_string()) .color_range(1, ..) .color_indices(3, indices) }; @@ -170,7 +170,7 @@ pub fn render_new_session_folder_prompt( let short_folder_prompt = "New session folder:"; let folder_path = folder.to_string_lossy(); if max_cols > short_folder_prompt.len() + folder_path.len() + 40 { - let folder_text = Text::new(&format!( + let folder_text = Text::new(format!( "{} {} (Ctrl+ to change, Ctrl+ to clear)", short_folder_prompt, folder_path )) @@ -193,7 +193,7 @@ pub fn render_new_session_folder_prompt( print_text_with_coordinates(folder_text, x, y + 1, None, None); } else { let folder_text = - Text::new(&format!("{} {} Ctrl+", short_folder_prompt, folder_path)) + Text::new(format!("{} {} Ctrl+", short_folder_prompt, folder_path)) .color_range(2, ..short_folder_prompt.len()) .color_range( 1, @@ -206,7 +206,7 @@ pub fn render_new_session_folder_prompt( } None => { let folder_prompt = "New session folder (optional):"; - let folder_text = Text::new(&format!("{} Ctrl+ to select", folder_prompt)) + let folder_text = Text::new(format!("{} Ctrl+ to select", folder_prompt)) .color_range(2, ..folder_prompt.len()) .color_range(3, folder_prompt.len() + 1..folder_prompt.len() + 9); print_text_with_coordinates(folder_text, x, y + 1, None, None); diff --git a/src/ui/renderer.rs b/src/ui/renderer.rs index c07b53f..1005019 100644 --- a/src/ui/renderer.rs +++ b/src/ui/renderer.rs @@ -326,7 +326,7 @@ impl PluginRenderer { "If this is a resurrectable session, it will be deleted. This action cannot be undone."; let prompt = "Press 'y' to confirm, 'n' or Esc to cancel"; - let dialog_lines = vec![ + let dialog_lines = [ "┌".to_string() + &"─".repeat(dialog_width.saturating_sub(2)) + "┐", format!( "│{:^width$}│", diff --git a/src/zoxide/search.rs b/src/zoxide/search.rs index fa0c09f..5ba80b0 100644 --- a/src/zoxide/search.rs +++ b/src/zoxide/search.rs @@ -112,7 +112,7 @@ impl SearchEngine { if *selected == self.results.len().saturating_sub(1) { *selected = 0; } else { - *selected = *selected + 1; + *selected += 1; } } else if !self.results.is_empty() { self.selected_index = Some(0); From 2e6e8b666d258f5a811835c54da9224e1506d678 Mon Sep 17 00:00:00 2001 From: dan Date: Wed, 7 Jan 2026 16:02:42 -0800 Subject: [PATCH 2/4] fix: add claude.md --- CLAUDE.md | 104 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 104 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..ed5936d --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,104 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +ZSM (Zoxide Session Manager) is a Zellij plugin written in Rust that integrates zoxide (smart directory navigation) with Zellij session management. It compiles to WebAssembly and runs inside Zellij. + +## Build Commands + +```bash +# Build for development (debug mode) +cargo build --target wasm32-wasip1 + +# Build for release +cargo build --target wasm32-wasip1 --release + +# Add WASM target if not installed +rustup target add wasm32-wasip1 +``` + +Output locations: +- Debug: `target/wasm32-wasip1/debug/zsm.wasm` +- Release: `target/wasm32-wasip1/release/zsm.wasm` + +## Development Workflow + +Start the plugin development layout (includes hot-reload keybinding): +```bash +zellij -l zellij.kdl +``` +- Press `Alt+R` to reload the plugin after rebuilding +- Re-launch plugin manually: `zellij action launch-or-focus-plugin file:target/wasm32-wasip1/debug/zsm.wasm` + +Alternative with watchexec: +```bash +watchexec --exts rs -- 'cargo build --target wasm32-wasip1; zellij action start-or-reload-plugin file:target/wasm32-wasip1/debug/zsm.wasm' +``` + +## Architecture + +### Core Modules + +- **`main.rs`** - Plugin entry point. Implements `ZellijPlugin` trait, handles Zellij events (key input, permissions, session updates), and processes zoxide output. Contains smart session naming logic. + +- **`state.rs`** - `PluginState` struct holds all plugin state: config, session manager, zoxide directories, search engine, and UI state. Orchestrates key handling between screens (Main vs NewSession). + +- **`config.rs`** - Plugin configuration parsed from Zellij layout options (default_layout, session_separator, show_resurrectable_sessions, show_all_sessions, base_paths). + +### Session Module (`session/`) + +- **`manager.rs`** - `SessionManager` handles session operations: tracking existing/resurrectable sessions, generating incremented names (e.g., `project.2`), and executing actions (switch/delete sessions). + +- **`types.rs`** - `SessionItem` enum (ExistingSession, ResurrectableSession, Directory) and `SessionAction` enum for session operations. + +### Zoxide Module (`zoxide/`) + +- **`directory.rs`** - `ZoxideDirectory` struct: ranking score, directory path, generated session name. + +- **`search.rs`** - `SearchEngine` for fuzzy-finding directories/sessions using `fuzzy-matcher` crate. + +### UI Module (`ui/`) + +- **`renderer.rs`** - `PluginRenderer` handles all terminal rendering. Renders main list, new session screen, search results, and deletion confirmations. + +- **`components.rs`** - UI color utilities. + +- **`theme.rs`** - Theme/palette handling. + +### Other + +- **`new_session_info.rs`** - State for new session creation screen (name input, folder selection, layout selection). + +## Key Concepts + +**Smart Session Naming**: The plugin generates session names from directory paths, handling conflicts (adds parent context), nested directories, and truncation (max 29 chars due to Unix socket path limits). + +**Two-Screen UI**: Main screen shows directory list with fuzzy search; NewSession screen handles session name/folder/layout configuration. + +**Filepicker Integration**: Communicates with Zellij's filepicker plugin via `pipe_message_to_plugin` for folder selection. + +**Session Stability**: Zellij sends inconsistent `SessionUpdate` events that can omit sessions temporarily. The `SessionManager` uses stability tracking with a missing-count threshold (3 updates) before removing sessions from the UI, preventing flickering. + +## Testing + +Tests must run on the **native target**, not WASM (WASM binaries can't execute directly): + +```bash +# Run tests (uses native target automatically when not cross-compiling) +cargo test + +# Or explicitly specify target +cargo test --target aarch64-apple-darwin +``` + +Note: `SessionInfo` from `zellij-tile` has many required fields. See `manager.rs` test helper `make_session()` for how to construct test instances. + +## CI + +GitHub Actions workflow (`.github/workflows/ci.yml`) runs on PRs and pushes to main: +- `test` - Runs unit tests on native target +- `build-wasm` - Verifies WASM compilation +- `clippy` - Lints with `-D warnings` +- `fmt` - Checks formatting From f845091b21f2a393333514d1f09da3e7aba95332 Mon Sep 17 00:00:00 2001 From: dan Date: Wed, 7 Jan 2026 21:33:03 -0800 Subject: [PATCH 3/4] fix(ci): use native target for tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The .cargo/config.toml sets wasm32-wasip1 as the default target, but tests must run on a native target. Explicitly specify x86_64-unknown-linux-gnu for the test job. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a0ed6e4..5cef76c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,7 +21,7 @@ jobs: uses: actions-rust-lang/setup-rust-toolchain@v1 - name: Run tests - run: cargo test + run: cargo test --target x86_64-unknown-linux-gnu build-wasm: runs-on: ubuntu-latest From 3e5d785f12ee04b0eccfeb84a683df6a5be50561 Mon Sep 17 00:00:00 2001 From: dan Date: Wed, 7 Jan 2026 23:07:11 -0800 Subject: [PATCH 4/4] fix: don't wait for stability tracking when deleting a session --- src/session/manager.rs | 70 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/src/session/manager.rs b/src/session/manager.rs index 9648b12..4687f76 100644 --- a/src/session/manager.rs +++ b/src/session/manager.rs @@ -149,9 +149,22 @@ impl SessionManager { self.pending_deletion = Some(session_name); } + /// Remove a session from local state immediately (bypasses stability tracking) + /// Used when user explicitly deletes a session + fn remove_session_from_local_state(&mut self, session_name: &str) { + let key = session_name.to_lowercase(); + self.sessions.retain(|s| s.name.to_lowercase() != key); + self.resurrectable_sessions + .retain(|(name, _)| name.to_lowercase() != key); + self.missing_counts.remove(&key); + } + /// Confirm session deletion + /// Immediately removes the session from local lists (bypasses stability tracking) pub fn confirm_deletion(&mut self) { if let Some(session_name) = self.pending_deletion.take() { + // Immediately remove from local lists - user explicitly requested deletion + self.remove_session_from_local_state(&session_name); self.execute_action(SessionAction::Kill(session_name)); } } @@ -334,4 +347,61 @@ mod tests { .update_resurrectable_stable(vec![("session".to_string(), Duration::from_secs(60))]); assert!(!changed); } + + #[test] + fn test_remove_session_from_local_state() { + let mut manager = SessionManager::default(); + + // Add sessions + manager.update_sessions_stable(vec![ + make_session("keep", false), + make_session("delete-me", false), + ]); + assert_eq!(manager.sessions().len(), 2); + + // Remove session from local state (called by confirm_deletion) + manager.remove_session_from_local_state("delete-me"); + + // Session should be removed immediately (no waiting for stability threshold) + assert_eq!(manager.sessions().len(), 1); + assert_eq!(manager.sessions()[0].name, "keep"); + } + + #[test] + fn test_remove_resurrectable_from_local_state() { + let mut manager = SessionManager::default(); + + // Add resurrectable sessions + manager.update_resurrectable_stable(vec![ + ("keep".to_string(), Duration::from_secs(60)), + ("delete-me".to_string(), Duration::from_secs(60)), + ]); + assert_eq!(manager.resurrectable_sessions().len(), 2); + + // Remove session from local state (called by confirm_deletion) + manager.remove_session_from_local_state("delete-me"); + + // Session should be removed immediately + assert_eq!(manager.resurrectable_sessions().len(), 1); + assert_eq!(manager.resurrectable_sessions()[0].0, "keep"); + } + + #[test] + fn test_remove_session_clears_missing_count() { + let mut manager = SessionManager::default(); + + // Add session then let it go "missing" to build up a count + manager.update_sessions_stable(vec![make_session("test", false)]); + manager.update_sessions_stable(vec![]); // Missing once + manager.update_sessions_stable(vec![]); // Missing twice + + // Verify missing count exists + assert!(manager.missing_counts.contains_key("test")); + + // Remove session explicitly + manager.remove_session_from_local_state("test"); + + // Missing count should be cleared + assert!(!manager.missing_counts.contains_key("test")); + } }