tmux_lib/
window.rs

1//! This module provides a few types and functions to handle Tmux windows.
2//!
3//! The main use cases are running Tmux commands & parsing Tmux window information.
4
5use std::str::FromStr;
6
7use smol::process::Command;
8
9use nom::{
10    character::complete::{char, digit1},
11    combinator::{all_consuming, map_res, recognize},
12    IResult, Parser,
13};
14use serde::{Deserialize, Serialize};
15
16use crate::{
17    error::{check_empty_process_output, check_process_success, map_add_intent, Error},
18    layout::{self, window_layout},
19    pane::Pane,
20    pane_id::{parse::pane_id, PaneId},
21    parse::{boolean, quoted_nonempty_string},
22    session::Session,
23    window_id::{parse::window_id, WindowId},
24    Result,
25};
26
27/// A Tmux window.
28#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
29pub struct Window {
30    /// Window identifier, e.g. `@3`.
31    pub id: WindowId,
32    /// Index of the Window in the Session.
33    pub index: u16,
34    /// Describes whether the Window is active.
35    pub is_active: bool,
36    /// Describes how panes are laid out in the Window.
37    pub layout: String,
38    /// Name of the Window.
39    pub name: String,
40    /// Name of Sessions to which this Window is attached.
41    pub sessions: Vec<String>,
42}
43
44impl FromStr for Window {
45    type Err = Error;
46
47    /// Parse a string containing the tmux window status into a new `Window`.
48    ///
49    /// This returns a `Result<Window, Error>` as this call can obviously
50    /// fail if provided an invalid format.
51    ///
52    /// The expected format of the tmux status is
53    ///
54    /// ```text
55    /// @1:0:true:035d,334x85,0,0{167x85,0,0,1,166x85,168,0[166x48,168,0,2,166x36,168,49,3]}:'ignite':'pytorch'
56    /// @2:1:false:4438,334x85,0,0[334x41,0,0{167x41,0,0,4,166x41,168,0,5},334x43,0,42{167x43,0,42,6,166x43,168,42,7}]:'dates-attn':'pytorch'
57    /// @3:2:false:9e8b,334x85,0,0{167x85,0,0,8,166x85,168,0,9}:'th-bits':'pytorch'
58    /// @4:3:false:64ef,334x85,0,0,10:'docker-pytorch':'pytorch'
59    /// @5:0:true:64f0,334x85,0,0,11:'ben':'rust'
60    /// @6:1:false:64f1,334x85,0,0,12:'pyo3':'rust'
61    /// @7:2:false:64f2,334x85,0,0,13:'mdns-repeater':'rust'
62    /// @8:0:true:64f3,334x85,0,0,14:'combine':'swift'
63    /// @9:0:false:64f4,334x85,0,0,15:'copyrat':'tmux-hacking'
64    /// @10:1:false:ae3a,334x85,0,0[334x48,0,0,17,334x36,0,49{175x36,0,49,18,158x36,176,49,19}]:'mytui-app':'tmux-hacking'
65    /// @11:2:true:e2e2,334x85,0,0{175x85,0,0,20,158x85,176,0[158x42,176,0,21,158x42,176,43,27]}:'tmux-backup':'tmux-hacking'
66    /// ```
67    ///
68    /// This status line is obtained with
69    ///
70    /// ```text
71    /// tmux list-windows -a -F "#{window_id}:#{window_index}:#{?window_active,true,false}:#{window_layout}:'#{window_name}':'#{window_linked_sessions_list}'"
72    /// ```
73    ///
74    /// For definitions, look at `Window` type and the tmux man page for
75    /// definitions.
76    fn from_str(input: &str) -> std::result::Result<Self, Self::Err> {
77        let desc = "Window";
78        let intent = "##{window_id}:##{window_index}:##{?window_active,true,false}:##{window_layout}:'##{window_name}':'##{window_linked_sessions_list}'";
79
80        let (_, window) = all_consuming(parse::window)
81            .parse(input)
82            .map_err(|e| map_add_intent(desc, intent, e))?;
83
84        Ok(window)
85    }
86}
87
88impl Window {
89    /// Return all `PaneId` in this window.
90    pub fn pane_ids(&self) -> Vec<PaneId> {
91        let layout = layout::parse_window_layout(&self.layout).unwrap();
92        layout.pane_ids().iter().map(PaneId::from).collect()
93    }
94}
95
96pub(crate) mod parse {
97    use super::*;
98
99    pub(crate) fn window(input: &str) -> IResult<&str, Window> {
100        let (input, (id, _, index, _, is_active, _, layout, _, name, _, session_names)) = (
101            window_id,
102            char(':'),
103            map_res(digit1, str::parse),
104            char(':'),
105            boolean,
106            char(':'),
107            recognize(window_layout),
108            char(':'),
109            quoted_nonempty_string,
110            char(':'),
111            quoted_nonempty_string,
112        )
113            .parse(input)?;
114
115        Ok((
116            input,
117            Window {
118                id,
119                index,
120                is_active,
121                layout: layout.to_string(),
122                name: name.to_string(),
123                sessions: vec![session_names.to_string()],
124            },
125        ))
126    }
127}
128
129// ------------------------------
130// Ops
131// ------------------------------
132
133/// Return a list of all `Window` from all sessions.
134pub async fn available_windows() -> Result<Vec<Window>> {
135    let args = vec![
136        "list-windows",
137        "-a",
138        "-F",
139        "#{window_id}\
140        :#{window_index}\
141        :#{?window_active,true,false}\
142        :#{window_layout}\
143        :'#{window_name}'\
144        :'#{window_linked_sessions_list}'",
145    ];
146
147    let output = Command::new("tmux").args(&args).output().await?;
148    let buffer = String::from_utf8(output.stdout)?;
149
150    // Note: each call to the `Window::from_str` returns a `Result<Window, _>`.
151    // All results are then collected into a Result<Vec<Window>, _>, via
152    // `collect()`.
153    let result: Result<Vec<Window>> = buffer
154        .trim_end() // trim last '\n' as it would create an empty line
155        .split('\n')
156        .map(Window::from_str)
157        .collect();
158
159    result
160}
161
162/// Create a Tmux window in a session exactly named as the passed `session`.
163///
164/// The new window attributes:
165///
166/// - created in the `session`
167/// - the window name is taken from the passed `window`
168/// - the working directory is the pane's working directory.
169///
170pub async fn new_window(
171    session: &Session,
172    window: &Window,
173    pane: &Pane,
174    pane_command: Option<&str>,
175) -> Result<(WindowId, PaneId)> {
176    // Use session ID for targeting - it's unambiguous and immediately valid
177    // after session creation, unlike names which may have parsing issues
178    // (e.g., names containing colons) or brief lookup race conditions.
179    let target_session = session.id.as_str();
180
181    let mut args = vec![
182        "new-window",
183        "-d",
184        "-c",
185        pane.dirpath.to_str().unwrap(),
186        "-n",
187        &window.name,
188        "-t",
189        target_session,
190        "-P",
191        "-F",
192        "#{window_id}:#{pane_id}",
193    ];
194    if let Some(pane_command) = pane_command {
195        args.push(pane_command);
196    }
197
198    let output = Command::new("tmux").args(&args).output().await?;
199
200    // Check exit status before parsing to avoid confusing parse errors
201    // when tmux fails and returns empty/garbage stdout.
202    check_process_success(&output, "new-window")?;
203
204    let buffer = String::from_utf8(output.stdout)?;
205    let buffer = buffer.trim_end();
206
207    let desc = "new-window";
208    let intent = "##{window_id}:##{pane_id}";
209
210    let (_, (new_window_id, _, new_pane_id)) = all_consuming((window_id, char(':'), pane_id))
211        .parse(buffer)
212        .map_err(|e| map_add_intent(desc, intent, e))?;
213
214    Ok((new_window_id, new_pane_id))
215}
216
217/// Apply the provided `layout` to the window with `window_id`.
218pub async fn set_layout(layout: &str, window_id: &WindowId) -> Result<()> {
219    let args = vec!["select-layout", "-t", window_id.as_str(), layout];
220
221    let output = Command::new("tmux").args(&args).output().await?;
222    check_empty_process_output(&output, "select-layout")
223}
224
225/// Select (make active) the window with `window_id`.
226pub async fn select_window(window_id: &WindowId) -> Result<()> {
227    let args = vec!["select-window", "-t", window_id.as_str()];
228
229    let output = Command::new("tmux").args(&args).output().await?;
230    check_empty_process_output(&output, "select-window")
231}
232
233#[cfg(test)]
234mod tests {
235    use super::Window;
236    use super::WindowId;
237    use crate::pane_id::PaneId;
238    use crate::Result;
239    use std::str::FromStr;
240
241    #[test]
242    fn parse_list_sessions() {
243        let output = vec![
244            "@1:0:true:035d,334x85,0,0{167x85,0,0,1,166x85,168,0[166x48,168,0,2,166x36,168,49,3]}:'ignite':'pytorch'",
245            "@2:1:false:4438,334x85,0,0[334x41,0,0{167x41,0,0,4,166x41,168,0,5},334x43,0,42{167x43,0,42,6,166x43,168,42,7}]:'dates-attn':'pytorch'",
246            "@3:2:false:9e8b,334x85,0,0{167x85,0,0,8,166x85,168,0,9}:'th-bits':'pytorch'",
247            "@4:3:false:64ef,334x85,0,0,10:'docker-pytorch':'pytorch'",
248            "@5:0:true:64f0,334x85,0,0,11:'ben':'rust'",
249            "@6:1:false:64f1,334x85,0,0,12:'pyo3':'rust'",
250            "@7:2:false:64f2,334x85,0,0,13:'mdns-repeater':'rust'",
251            "@8:0:true:64f3,334x85,0,0,14:'combine':'swift'",
252            "@9:0:false:64f4,334x85,0,0,15:'copyrat':'tmux-hacking'",
253            "@10:1:false:ae3a,334x85,0,0[334x48,0,0,17,334x36,0,49{175x36,0,49,18,158x36,176,49,19}]:'mytui-app':'tmux-hacking'",
254            "@11:2:true:e2e2,334x85,0,0{175x85,0,0,20,158x85,176,0[158x42,176,0,21,158x42,176,43,27]}:'tmux-backup':'tmux-hacking'",
255        ];
256        let sessions: Result<Vec<Window>> =
257            output.iter().map(|&line| Window::from_str(line)).collect();
258        let windows = sessions.expect("Could not parse tmux sessions");
259
260        let expected = vec![
261            Window {
262                id: WindowId::from_str("@1").unwrap(),
263                index: 0,
264                is_active: true,
265                layout: String::from(
266                    "035d,334x85,0,0{167x85,0,0,1,166x85,168,0[166x48,168,0,2,166x36,168,49,3]}",
267                ),
268                name: String::from("ignite"),
269                sessions: vec![String::from("pytorch")],
270            },
271            Window {
272                id: WindowId::from_str("@2").unwrap(),
273                index: 1,
274                is_active: false,
275                layout: String::from(
276                    "4438,334x85,0,0[334x41,0,0{167x41,0,0,4,166x41,168,0,5},334x43,0,42{167x43,0,42,6,166x43,168,42,7}]",
277                ),
278                name: String::from("dates-attn"),
279                sessions: vec![String::from("pytorch")],
280            },
281            Window {
282                id: WindowId::from_str("@3").unwrap(),
283                index: 2,
284                is_active: false,
285                layout: String::from(
286                    "9e8b,334x85,0,0{167x85,0,0,8,166x85,168,0,9}",
287                ),
288                name: String::from("th-bits"),
289                sessions: vec![String::from("pytorch")],
290            },
291            Window {
292                id: WindowId::from_str("@4").unwrap(),
293                index: 3,
294                is_active: false,
295                layout: String::from(
296                    "64ef,334x85,0,0,10",
297                ),
298                name: String::from("docker-pytorch"),
299                sessions: vec![String::from("pytorch")],
300            },
301            Window {
302                id: WindowId::from_str("@5").unwrap(),
303                index: 0,
304                is_active: true,
305                layout: String::from(
306                    "64f0,334x85,0,0,11",
307                ),
308                name: String::from("ben"),
309                sessions: vec![String::from("rust")],
310            },
311            Window {
312                id: WindowId::from_str("@6").unwrap(),
313                index: 1,
314                is_active: false,
315                layout: String::from(
316                    "64f1,334x85,0,0,12",
317                ),
318                name: String::from("pyo3"),
319                sessions: vec![String::from("rust")],
320            },
321            Window {
322                id: WindowId::from_str("@7").unwrap(),
323                index: 2,
324                is_active: false,
325                layout: String::from(
326                    "64f2,334x85,0,0,13",
327                ),
328                name: String::from("mdns-repeater"),
329                sessions: vec![String::from("rust")],
330            },
331            Window {
332                id: WindowId::from_str("@8").unwrap(),
333                index: 0,
334                is_active: true,
335                layout: String::from(
336                    "64f3,334x85,0,0,14",
337                ),
338                name: String::from("combine"),
339                sessions: vec![String::from("swift")],
340            },
341            Window {
342                id: WindowId::from_str("@9").unwrap(),
343                index: 0,
344                is_active: false,
345                layout: String::from(
346                    "64f4,334x85,0,0,15",
347                ),
348                name: String::from("copyrat"),
349                sessions: vec![String::from("tmux-hacking")],
350            },
351            Window {
352                id: WindowId::from_str("@10").unwrap(),
353                index: 1,
354                is_active: false,
355                layout: String::from(
356                    "ae3a,334x85,0,0[334x48,0,0,17,334x36,0,49{175x36,0,49,18,158x36,176,49,19}]",
357                ),
358                name: String::from("mytui-app"),
359                sessions: vec![String::from("tmux-hacking")],
360            },
361            Window {
362                id: WindowId::from_str("@11").unwrap(),
363                index: 2,
364                is_active: true,
365                layout: String::from(
366                    "e2e2,334x85,0,0{175x85,0,0,20,158x85,176,0[158x42,176,0,21,158x42,176,43,27]}",
367                ),
368                name: String::from("tmux-backup"),
369                sessions: vec![String::from("tmux-hacking")],
370            },
371        ];
372
373        assert_eq!(windows, expected);
374    }
375
376    #[test]
377    fn parse_window_single_pane() {
378        let input = "@5:0:true:64f0,334x85,0,0,11:'ben':'rust'";
379        let window = Window::from_str(input).expect("Should parse window with single pane");
380
381        assert_eq!(window.id, WindowId::from_str("@5").unwrap());
382        assert_eq!(window.index, 0);
383        assert!(window.is_active);
384        assert_eq!(window.name, "ben");
385        assert_eq!(window.sessions, vec!["rust".to_string()]);
386    }
387
388    #[test]
389    fn parse_window_with_large_index() {
390        let input = "@100:99:false:64f0,334x85,0,0,11:'test':'session'";
391        let window = Window::from_str(input).expect("Should parse window with large index");
392
393        assert_eq!(window.id, WindowId::from_str("@100").unwrap());
394        assert_eq!(window.index, 99);
395        assert!(!window.is_active);
396    }
397
398    #[test]
399    fn parse_window_fails_on_missing_id() {
400        let input = "0:true:64f0,334x85,0,0,11:'name':'session'";
401        let result = Window::from_str(input);
402
403        assert!(result.is_err());
404    }
405
406    #[test]
407    fn parse_window_fails_on_invalid_boolean() {
408        let input = "@1:0:yes:64f0,334x85,0,0,11:'name':'session'";
409        let result = Window::from_str(input);
410
411        assert!(result.is_err());
412    }
413
414    #[test]
415    fn parse_window_fails_on_empty_name() {
416        let input = "@1:0:true:64f0,334x85,0,0,11:'':'session'";
417        let result = Window::from_str(input);
418
419        assert!(result.is_err());
420    }
421
422    #[test]
423    fn window_pane_ids_single_pane() {
424        let window = Window {
425            id: WindowId::from_str("@1").unwrap(),
426            index: 0,
427            is_active: true,
428            layout: String::from("64f0,334x85,0,0,11"),
429            name: String::from("test"),
430            sessions: vec![String::from("session")],
431        };
432
433        let pane_ids = window.pane_ids();
434        assert_eq!(pane_ids.len(), 1);
435        assert_eq!(pane_ids[0], PaneId::from_str("%11").unwrap());
436    }
437
438    #[test]
439    fn window_pane_ids_multiple_panes() {
440        let window = Window {
441            id: WindowId::from_str("@3").unwrap(),
442            index: 2,
443            is_active: false,
444            layout: String::from("9e8b,334x85,0,0{167x85,0,0,8,166x85,168,0,9}"),
445            name: String::from("th-bits"),
446            sessions: vec![String::from("pytorch")],
447        };
448
449        let pane_ids = window.pane_ids();
450        assert_eq!(pane_ids.len(), 2);
451        assert_eq!(pane_ids[0], PaneId::from_str("%8").unwrap());
452        assert_eq!(pane_ids[1], PaneId::from_str("%9").unwrap());
453    }
454
455    #[test]
456    fn window_pane_ids_complex_layout() {
457        // Complex nested layout with 4 panes
458        let window = Window {
459            id: WindowId::from_str("@1").unwrap(),
460            index: 0,
461            is_active: true,
462            layout: String::from(
463                "035d,334x85,0,0{167x85,0,0,1,166x85,168,0[166x48,168,0,2,166x36,168,49,3]}",
464            ),
465            name: String::from("ignite"),
466            sessions: vec![String::from("pytorch")],
467        };
468
469        let pane_ids = window.pane_ids();
470        assert_eq!(pane_ids.len(), 3);
471        assert_eq!(pane_ids[0], PaneId::from_str("%1").unwrap());
472        assert_eq!(pane_ids[1], PaneId::from_str("%2").unwrap());
473        assert_eq!(pane_ids[2], PaneId::from_str("%3").unwrap());
474    }
475}