focal 0.2.8

Terminal focus library - focus terminal windows and multiplexer panes
Documentation
//! tmux-specific focus handler.
//!
//! Provides functionality to find and switch to a tmux pane containing
//! a specific process.

use super::{Focuser, MuxFocusResult};
use crate::util::{DEFAULT_TIMEOUT, run_command_with_timeout};
use std::process::Command;
use std::sync::mpsc;
use std::thread;

/// Run a tmux command with timeout protection.
fn run_tmux_with_timeout(args: &[&str]) -> Option<std::process::Output> {
    run_command_with_timeout("tmux", args, DEFAULT_TIMEOUT)
}

/// Find and switch to the tmux pane containing a process.
///
/// # Arguments
/// * `pid` - Process ID to find
/// * `parent_map` - Map of pid -> parent pid for ancestry checking
/// * `focuser` - Implementation of [`Focuser`] to focus the terminal window
///   after switching panes.
///
/// # Returns
/// The result of the focus operation.
pub fn focus_pane<S>(
    pid: i32,
    parent_map: &std::collections::HashMap<i32, i32, S>,
    focuser: &dyn Focuser,
) -> MuxFocusResult
where
    S: std::hash::BuildHasher,
{
    // Get list of all tmux panes with their shell PIDs (with timeout)
    let output = match run_tmux_with_timeout(&[
        "list-panes",
        "-a",
        "-F",
        "#{pane_pid} #{session_name} #{window_index} #{pane_index}",
    ]) {
        Some(o) => o,
        None => return MuxFocusResult::NotFound,
    };

    if !output.status.success() {
        return MuxFocusResult::NotFound;
    }

    let pane_list = String::from_utf8_lossy(&output.stdout);

    // Find which pane contains our target PID (check if pid is a descendant)
    for line in pane_list.lines() {
        let parts: Vec<&str> = line.split_whitespace().collect();
        if parts.len() < 4 {
            continue;
        }

        let Ok(pane_pid) = parts[0].parse::<i32>() else {
            continue;
        };
        let session_name = parts[1];
        let window_index = parts[2];
        let pane_index = parts[3];

        // Check if our target pid is a descendant of this pane's shell
        if prock::is_descendant_of(pid, pane_pid, parent_map) {
            // Start list-clients in a background thread IMMEDIATELY
            // This runs in parallel with select-window and select-pane
            // We get both TTY and PID for more reliable terminal detection
            let session_name_owned = session_name.to_string();
            let (tx, rx) = mpsc::channel();
            thread::spawn(move || {
                let output = Command::new("tmux")
                    .args([
                        "list-clients",
                        "-t",
                        &session_name_owned,
                        "-F",
                        "#{client_tty} #{client_pid}",
                    ])
                    .output();
                let _ = tx.send(output);
            });

            // Switch to this pane (while list-clients runs in background)
            // Combined select-window + select-pane in single tmux process
            let target = format!("{session_name}:{window_index}.{pane_index}");
            let window_target = format!("{session_name}:{window_index}");

            let pane_switched = run_tmux_with_timeout(&[
                "select-window",
                "-t",
                &window_target,
                ";", // tmux command separator
                "select-pane",
                "-t",
                &target,
            ])
            .is_some_and(|o| o.status.success());

            if pane_switched {
                // Now get the list-clients result (should be ready or nearly ready)
                // Use recv_timeout to avoid freezing if tmux list-clients hangs
                match rx.recv_timeout(DEFAULT_TIMEOUT) {
                    Ok(Ok(out)) if out.status.success() => {
                        let client_info = String::from_utf8_lossy(&out.stdout);
                        if let Some(line) = client_info.lines().next() {
                            // Parse "TTY PID" format
                            let parts: Vec<&str> = line.split_whitespace().collect();
                            if let Some(tty_path) = parts.first() {
                                let tty_device = tty_path.trim_start_matches("/dev/");
                                // Try TTY-based focus first, with client PID as backup context
                                let client_pid = parts.get(1).and_then(|s| s.parse::<i32>().ok());
                                let _ = focuser.focus(tty_device, client_pid);
                                return MuxFocusResult::Switched {
                                    session: session_name.to_string(),
                                    window: window_index.to_string(),
                                    pane: pane_index.to_string(),
                                };
                            }
                        }
                        // list-clients succeeded but returned empty output - no clients attached
                        return MuxFocusResult::NoClient {
                            session: session_name.to_string(),
                        };
                    }
                    Ok(Ok(_)) => {
                        // list-clients command failed (non-zero exit)
                        return MuxFocusResult::Error("Failed to query tmux clients".to_string());
                    }
                    Ok(Err(_)) | Err(_) => {
                        // Channel error or timeout - can't determine client status
                        return MuxFocusResult::Error("Timeout querying tmux clients".to_string());
                    }
                }
            }
            return MuxFocusResult::Error("Failed to switch tmux pane".to_string());
        }
    }

    MuxFocusResult::NotFound
}

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

    /// Mock focuser for testing.
    struct MockFocuser;

    impl Focuser for MockFocuser {
        fn focus(&self, _tty: &str, _client_pid: Option<i32>) -> bool {
            false
        }
    }

    #[test]
    fn test_focus_pane_tmux_not_available() {
        let parent_map = prock::build_parent_map();
        let focuser = MockFocuser;
        // Test with a PID that won't be in any tmux pane
        let result = focus_pane(999_999_999, &parent_map, &focuser);
        assert!(matches!(result, MuxFocusResult::NotFound));
    }
}