focal 0.2.8

Terminal focus library - focus terminal windows and multiplexer panes
Documentation
//! Terminal multiplexer handlers.
//!
//! This module provides support for terminal multiplexers like tmux, zellij,
//! and screen. Multiplexers require special handling because:
//!
//! 1. The target process runs inside a multiplexer pane
//! 2. We need to switch to the correct pane within the multiplexer
//! 3. We then need to focus the terminal window containing the multiplexer client
//!
//! # Architecture
//!
//! Multiplexer handlers use the [`Focuser`] trait to decouple pane switching
//! from terminal window focusing. This enables testing multiplexer logic in
//! isolation with mock focusers.

pub mod screen;
pub mod tmux;
pub mod zellij;

use std::collections::HashMap;

/// Trait for focusing terminal windows after multiplexer pane switching.
///
/// This abstraction decouples multiplexer logic from terminal focusing,
/// enabling mock-based testing and cleaner separation of concerns.
pub trait Focuser {
    /// Focus the terminal window.
    ///
    /// # Arguments
    /// * `tty` - TTY device path (e.g., "ttys003")
    /// * `client_pid` - Optional PID of the multiplexer client for fallback detection
    ///
    /// # Returns
    /// `true` if focusing succeeded, `false` otherwise.
    fn focus(&self, tty: &str, client_pid: Option<i32>) -> bool;
}

/// Known terminal multiplexer types.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum Multiplexer {
    /// tmux terminal multiplexer
    Tmux,
    /// zellij terminal multiplexer
    Zellij,
    /// GNU screen terminal multiplexer
    Screen,
}

/// Result of focusing a multiplexer pane.
#[derive(Debug, Clone)]
pub enum MuxFocusResult {
    /// Successfully switched to the pane.
    Switched {
        /// Name of the session
        session: String,
        /// Window identifier
        window: String,
        /// Pane identifier
        pane: String,
    },
    /// Pane switched but no terminal client is attached to the session.
    NoClient {
        /// Name of the session
        session: String,
    },
    /// The multiplexer is not running or the pane was not found.
    NotFound,
    /// An error occurred.
    Error(String),
}

/// Detect if a process is running inside a terminal multiplexer.
///
/// Checks process ancestry for known multiplexer processes.
/// Returns the innermost (first found) multiplexer when nested.
///
/// Uses prock syscalls to get command names for ancestors,
/// which is ~9,000x faster than spawning `ps`.
///
/// # Arguments
/// * `pid` - Process ID to check
/// * `parent_map` - Map of pid -> parent pid for process tree traversal
///
/// # Returns
/// The detected multiplexer, or `None` if not in a multiplexer.
#[must_use]
pub fn detect<S: std::hash::BuildHasher>(
    pid: i32,
    parent_map: &HashMap<i32, i32, S>,
) -> Option<Multiplexer> {
    // Walk up the process tree checking each ancestor
    let mut current = pid;
    for _ in 0..50 {
        // Get command name using fast syscall
        if let Some(comm) = prock::get_process_comm(current) {
            // Check for tmux
            if comm.contains("tmux") {
                return Some(Multiplexer::Tmux);
            }
            // Check for zellij (processes run under zellij-server)
            if comm.contains("zellij") {
                return Some(Multiplexer::Zellij);
            }
            // Check for GNU screen (server process is "screen" or "SCREEN")
            if comm == "screen" || comm == "SCREEN" {
                return Some(Multiplexer::Screen);
            }
        }

        // Move to parent
        current = *parent_map.get(&current)?;
        if current <= 1 {
            break;
        }
    }

    None
}

/// Focus the pane containing a process within a multiplexer.
///
/// # Arguments
/// * `mux` - The multiplexer type
/// * `pid` - Process ID to focus
/// * `parent_map` - Map of pid -> parent pid for process tree traversal
/// * `focuser` - Implementation of [`Focuser`] to focus the terminal window
///   after switching panes.
///
/// # Returns
/// The result of the focus operation.
pub fn focus<S>(
    mux: Multiplexer,
    pid: i32,
    parent_map: &HashMap<i32, i32, S>,
    focuser: &dyn Focuser,
) -> MuxFocusResult
where
    S: std::hash::BuildHasher,
{
    match mux {
        Multiplexer::Tmux => tmux::focus_pane(pid, parent_map, focuser),
        Multiplexer::Zellij => zellij::focus_pane(pid, parent_map, focuser),
        Multiplexer::Screen => screen::focus_pane(pid, parent_map, focuser),
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::cell::Cell;

    /// Mock focuser for testing that tracks calls.
    struct MockFocuser {
        called: Cell<bool>,
        should_succeed: bool,
    }

    impl MockFocuser {
        fn new(should_succeed: bool) -> Self {
            Self {
                called: Cell::new(false),
                should_succeed,
            }
        }

        fn was_called(&self) -> bool {
            self.called.get()
        }
    }

    impl Focuser for MockFocuser {
        fn focus(&self, _tty: &str, _client_pid: Option<i32>) -> bool {
            self.called.set(true);
            self.should_succeed
        }
    }

    #[test]
    fn test_detect_no_mux() {
        let parent_map = prock::build_parent_map();
        // Current process is unlikely to be in a multiplexer during tests
        // (though it might be - this test just verifies the function doesn't crash)
        let _ = detect(std::process::id() as i32, &parent_map);
    }

    #[test]
    fn test_focus_with_mock_focuser() {
        let parent_map = prock::build_parent_map();
        let focuser = MockFocuser::new(true);

        // Try to focus a nonexistent PID - should return NotFound
        // and NOT call the focuser (since no mux pane was found)
        let result = focus(Multiplexer::Tmux, 999_999_999, &parent_map, &focuser);

        assert!(matches!(result, MuxFocusResult::NotFound));
        // Focuser should not be called since pane wasn't found
        assert!(!focuser.was_called());
    }
}