focal 0.2.8

Terminal focus library - focus terminal windows and multiplexer panes
Documentation
1
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
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
//! Zellij-specific focus handler.
//!
//! Provides functionality to find and switch to a Zellij pane containing
//! a specific process.
//!
//! Unlike tmux which has `list-panes -a` with `#{pane_pid}`, Zellij doesn't
//! expose a direct pane-to-PID mapping via CLI. Instead, we:
//! 1. List all sessions with `zellij list-sessions`
//! 2. Find zellij-server processes and check if target PID is a descendant
//! 3. Switch to the session/tab containing the target
//! 4. Use best-effort heuristics for pane focus (move-focus commands)

use super::{Focuser, MuxFocusResult};
use crate::util::{DEFAULT_TIMEOUT, run_command_with_timeout};
use std::collections::HashMap;

/// Run a zellij command with timeout protection.
///
/// Spawns a detached thread to run the command and waits for the result
/// with a timeout. If the timeout expires, returns `None` immediately.
///
/// Note: On timeout, the spawned thread continues running until the zellij
/// command completes (there's no way to forcibly terminate it). This is
/// acceptable because:
/// - Zellij commands typically complete quickly
/// - The thread will clean up naturally when the command finishes
/// - The channel sender is dropped when the thread exits
fn run_zellij_with_timeout(args: &[&str]) -> Option<std::process::Output> {
    run_command_with_timeout("zellij", args, DEFAULT_TIMEOUT)
}

/// Find and switch to the Zellij 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: &HashMap<i32, i32, S>,
    focuser: &dyn Focuser,
) -> MuxFocusResult
where
    S: std::hash::BuildHasher,
{
    // Step 1: List all active Zellij sessions
    let sessions = match list_sessions() {
        Some(s) if !s.is_empty() => s,
        _ => return MuxFocusResult::NotFound,
    };

    // Step 2: Find which session contains our target PID
    // We do this by finding zellij-server processes and checking descendants
    let server_pids = find_zellij_servers();

    // First, try to find which server contains our target PID
    let mut target_server: Option<i32> = None;
    for &server_pid in &server_pids {
        if prock::is_descendant_of(pid, server_pid, parent_map) {
            target_server = Some(server_pid);
            break;
        }
    }

    // If we found a server, try to identify which session it belongs to
    if let Some(server_pid) = target_server {
        // Try to match server to session and focus it
        if let Some(session_name) = find_session_for_server(server_pid, &sessions, &server_pids) {
            return focus_session(&session_name, focuser);
        }

        // If we couldn't match to a specific session but only have one, use it
        if sessions.len() == 1 {
            return focus_session(&sessions[0], focuser);
        }
    }

    // Fallback: If we only have one session, try it
    if sessions.len() == 1 {
        return focus_session(&sessions[0], focuser);
    }

    // Try each session and see which one has an active client we can focus
    for session in &sessions {
        if let Some((client_tty, client_pid)) = get_session_client(session) {
            let tty_device = client_tty.trim_start_matches("/dev/");
            let _ = focuser.focus(tty_device, client_pid);
            return MuxFocusResult::Switched {
                session: session.clone(),
                window: "1".to_string(),
                pane: "0".to_string(),
            };
        }
    }

    MuxFocusResult::NotFound
}

/// List all active Zellij sessions.
fn list_sessions() -> Option<Vec<String>> {
    let output = run_zellij_with_timeout(&["list-sessions", "-n"])?;

    if !output.status.success() {
        return None;
    }

    let stdout = String::from_utf8_lossy(&output.stdout);
    let sessions: Vec<String> = stdout
        .lines()
        .filter(|line| !line.trim().is_empty())
        .map(|line| {
            // list-sessions -n outputs just session names, one per line
            // But some versions may include status info, so take first word
            line.split_whitespace().next().unwrap_or(line).to_string()
        })
        .collect();

    Some(sessions)
}

/// Find all zellij-server process IDs.
fn find_zellij_servers() -> Vec<i32> {
    let all_pids = prock::list_all_pids();
    let mut servers = Vec::new();

    for pid in all_pids {
        if let Some(comm) = prock::get_process_comm(pid)
            && comm.contains("zellij-server")
        {
            servers.push(pid);
        }
    }

    servers
}

/// Try to match a server PID to a session name.
///
/// Zellij server processes can be matched to sessions by:
/// 1. Using `lsof` to find which socket the server has open (socket names contain session names)
/// 2. If that fails, fall back to heuristics
fn find_session_for_server(
    server_pid: i32,
    sessions: &[String],
    _server_pids: &[i32],
) -> Option<String> {
    // If we only have one session, it must be this one
    if sessions.len() == 1 {
        return Some(sessions[0].clone());
    }

    // Try to find the session name from the server's open sockets
    // Zellij servers have sockets named like: /tmp/zellij-<uid>/<session-name>
    if let Some(session) = find_session_from_server_sockets(server_pid, sessions) {
        return Some(session);
    }

    // Fallback: check each session to see if this server PID is the one serving it
    // This works by calling list-clients on each session and checking if it responds
    // (the server handles the request)
    for session in sessions {
        if run_zellij_with_timeout(&["--session", session, "action", "list-clients"])
            .is_some_and(|output| output.status.success())
        {
            // This session is active, might be ours
            // We can't definitively prove it's this server without more info,
            // but if we only have a few sessions, this is a reasonable approach
            return Some(session.clone());
        }
    }

    None
}

/// Find session name from server's open sockets using lsof.
///
/// Zellij servers open Unix sockets named after their session, typically:
/// `/tmp/zellij-<uid>/<session-name>`
fn find_session_from_server_sockets(server_pid: i32, sessions: &[String]) -> Option<String> {
    // Use lsof to find Unix sockets for this process
    // lsof -p <pid> -a -U -F n
    // -p: specific PID
    // -a: AND (combine filters)
    // -U: Unix sockets only
    // -F n: parseable output format (n = name field)
    let output = std::process::Command::new("lsof")
        .args(["-p", &server_pid.to_string(), "-a", "-U", "-F", "n"])
        .output()
        .ok()?;

    if !output.status.success() {
        return None;
    }

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

    // Parse lsof output - lines starting with 'n' contain the socket path
    // Example: n/tmp/zellij-501/my-session
    for line in stdout.lines() {
        if let Some(path) = line.strip_prefix('n') {
            // Extract potential session name from socket path
            // Socket paths look like: /tmp/zellij-<uid>/<session-name>
            if let Some(session_name) = path.rsplit('/').next() {
                // Check if this matches one of our known sessions
                if sessions.iter().any(|s| s == session_name) {
                    return Some(session_name.to_string());
                }
            }
        }
    }

    None
}

/// Get client info for a session (TTY and PID).
///
/// Uses `zellij --session <session> action list-clients` to find the client
/// TTY attached to the specific session, avoiding the bug where we returned
/// the first client found regardless of session.
fn get_session_client(session: &str) -> Option<(String, Option<i32>)> {
    // Use list-clients to get the TTY for this specific session
    let output = run_zellij_with_timeout(&["--session", session, "action", "list-clients"])?;

    if !output.status.success() {
        return None;
    }

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

    // Parse list-clients output to extract TTY
    // Format is typically: "CLIENT_ID USERNAME TTY" (space-separated)
    // Example: "1 user ttys003"
    for line in stdout.lines() {
        let parts: Vec<&str> = line.split_whitespace().collect();
        // We need at least 3 parts: client_id, username, tty
        if parts.len() >= 3 {
            let tty = parts[2];
            // Now find the zellij client process on this TTY to get its PID
            let tty_normalized = if tty.starts_with("/dev/") {
                tty.to_string()
            } else {
                format!("/dev/{}", tty)
            };

            for pid in prock::list_all_pids() {
                if let Some(comm) = prock::get_process_comm(pid)
                    && comm == "zellij"
                    && let Some(proc_tty) = prock::get_tty(pid)
                {
                    let proc_tty_normalized = if proc_tty.starts_with("/dev/") {
                        proc_tty
                    } else {
                        format!("/dev/{}", proc_tty)
                    };
                    if proc_tty_normalized == tty_normalized {
                        return Some((tty_normalized, Some(pid)));
                    }
                }
            }

            // If we couldn't find the PID but have the TTY, return it anyway
            return Some((tty_normalized, None));
        }
    }

    None
}

/// Focus a session and its terminal window.
fn focus_session(session: &str, focuser: &dyn Focuser) -> MuxFocusResult {
    // Switch to tab 1 as a best-effort focus. Zellij CLI doesn't expose which tab
    // contains a specific pane/process, so we can't determine the exact tab.
    // This at least ensures the session is active. Future improvement could parse
    // `zellij action dump-layout` to find the correct tab.
    let _ = run_zellij_with_timeout(&["--session", session, "action", "go-to-tab", "1"]);

    // Get client TTY for terminal focus
    if let Some((client_tty, client_pid)) = get_session_client(session) {
        let tty_device = client_tty.trim_start_matches("/dev/");
        let _ = focuser.focus(tty_device, client_pid);
        MuxFocusResult::Switched {
            session: session.to_string(),
            window: "1".to_string(),
            pane: "0".to_string(),
        }
    } else {
        MuxFocusResult::NoClient {
            session: session.to_string(),
        }
    }
}

#[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_nonexistent_pid() {
        let parent_map = prock::build_parent_map();
        let focuser = MockFocuser;
        // Test with a PID that won't be in any Zellij pane
        let result = focus_pane(999_999_999, &parent_map, &focuser);
        // If Zellij isn't running, we get NotFound
        // If Zellij IS running but pid isn't found, we might still get Switched
        // (best-effort fallback to first session) or NotFound
        // The function doesn't crash - that's the important thing
        match result {
            MuxFocusResult::NotFound => {}
            MuxFocusResult::Switched { .. } => {
                // If Zellij is running, we might focus a session even if PID not found
                // (fallback behavior for single session)
            }
            MuxFocusResult::NoClient { .. } => {
                // Session found but no client attached
            }
            MuxFocusResult::Error(_) => {}
        }
    }

    #[test]
    fn test_list_sessions() {
        // Should return None when Zellij isn't running, or a list when it is
        let sessions = list_sessions();
        // Either None or Some with valid list
        if let Some(s) = sessions {
            // If Zellij is running, we get sessions
            assert!(s.len() < 1000); // Sanity check
        }
    }

    #[test]
    fn test_find_zellij_servers() {
        // Should return empty when Zellij isn't running, or server PIDs when it is
        let servers = find_zellij_servers();
        assert!(servers.len() < 1000); // Sanity check
    }

    #[test]
    fn test_find_session_for_server_single_session() {
        // When there's only one session, it should always return that session
        let sessions = vec!["my-session".to_string()];
        let result = find_session_for_server(12345, &sessions, &[12345]);
        assert_eq!(result, Some("my-session".to_string()));
    }

    #[test]
    fn test_find_session_for_server_empty_sessions() {
        // When there are no sessions, should return None
        let sessions: Vec<String> = vec![];
        let result = find_session_for_server(12345, &sessions, &[12345]);
        assert_eq!(result, None);
    }

    #[test]
    fn test_parse_lsof_socket_path() {
        // Test the socket path parsing logic directly
        // Simulate what find_session_from_server_sockets does with the path
        let socket_path = "/tmp/zellij-501/my-session";
        let session_name = socket_path.rsplit('/').next();
        assert_eq!(session_name, Some("my-session"));
    }

    #[test]
    fn test_parse_lsof_socket_path_with_subdirs() {
        // Test socket path with more subdirectories
        let socket_path = "/var/run/user/1000/zellij/session-name";
        let session_name = socket_path.rsplit('/').next();
        assert_eq!(session_name, Some("session-name"));
    }

    #[test]
    fn test_tty_normalization() {
        // Test TTY normalization logic from get_session_client
        let tty_without_prefix = "ttys003";
        let tty_with_prefix = "/dev/ttys003";

        let normalized1 = if tty_without_prefix.starts_with("/dev/") {
            tty_without_prefix.to_string()
        } else {
            format!("/dev/{}", tty_without_prefix)
        };

        let normalized2 = if tty_with_prefix.starts_with("/dev/") {
            tty_with_prefix.to_string()
        } else {
            format!("/dev/{}", tty_with_prefix)
        };

        assert_eq!(normalized1, "/dev/ttys003");
        assert_eq!(normalized2, "/dev/ttys003");
        assert_eq!(normalized1, normalized2);
    }
}