diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..5cef76c --- /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 --target x86_64-unknown-linux-gnu + + 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/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 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..4687f76 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 @@ -64,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)); } } @@ -108,7 +206,202 @@ 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); + } + + #[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")); + } +} 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);