tmux_lib/
pane.rs

1//! This module provides a few types and functions to handle Tmux Panes.
2//!
3//! The main use cases are running Tmux commands & parsing Tmux panes
4//! information.
5
6use std::path::PathBuf;
7use std::str::FromStr;
8
9use nom::{
10    character::complete::{char, digit1, not_line_ending},
11    combinator::{all_consuming, map_res},
12    IResult, Parser,
13};
14use serde::{Deserialize, Serialize};
15use smol::process::Command;
16
17use crate::{
18    error::{check_empty_process_output, check_process_success, map_add_intent, Error},
19    pane_id::{parse::pane_id, PaneId},
20    parse::{boolean, quoted_nonempty_string, quoted_string},
21    window_id::WindowId,
22    Result,
23};
24
25/// A Tmux pane.
26#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
27pub struct Pane {
28    /// Pane identifier, e.g. `%37`.
29    pub id: PaneId,
30    /// Describes the Pane index in the Window
31    pub index: u16,
32    /// Describes if the pane is currently active (focused).
33    pub is_active: bool,
34    /// Title of the Pane (usually defaults to the hostname)
35    pub title: String,
36    /// Current dirpath of the Pane
37    pub dirpath: PathBuf,
38    /// Current command executed in the Pane
39    pub command: String,
40}
41
42impl FromStr for Pane {
43    type Err = Error;
44
45    /// Parse a string containing tmux panes status into a new `Pane`.
46    ///
47    /// This returns a `Result<Pane, Error>` as this call can obviously
48    /// fail if provided an invalid format.
49    ///
50    /// The expected format of the tmux status is
51    ///
52    /// ```text
53    /// %20:0:false:'rmbp':'nvim':/Users/graelo/code/rust/tmux-backup
54    /// %21:1:true:'rmbp':'tmux':/Users/graelo/code/rust/tmux-backup
55    /// %27:2:false:'rmbp':'man man':/Users/graelo/code/rust/tmux-backup
56    /// ```
57    ///
58    /// This status line is obtained with
59    ///
60    /// ```text
61    /// tmux list-panes -F "#{pane_id}:#{pane_index}:#{?pane_active,true,false}:'#{pane_title}':'#{pane_current_command}':#{pane_current_path}"
62    /// ```
63    ///
64    /// For definitions, look at `Pane` type and the tmux man page for
65    /// definitions.
66    fn from_str(input: &str) -> std::result::Result<Self, Self::Err> {
67        let desc = "Pane";
68        let intent = "##{pane_id}:##{pane_index}:##{?pane_active,true,false}:'##{pane_title}':'##{pane_current_command}':##{pane_current_path}";
69
70        let (_, pane) = all_consuming(parse::pane)
71            .parse(input)
72            .map_err(|e| map_add_intent(desc, intent, e))?;
73
74        Ok(pane)
75    }
76}
77
78impl Pane {
79    /// Return the entire Pane content as a `Vec<u8>`.
80    ///
81    /// # Note
82    ///
83    /// The output contains the escape codes, joined lines with trailing spaces. This output is
84    /// processed by the function `tmux_lib::utils::cleanup_captured_buffer`.
85    ///
86    pub async fn capture(&self) -> Result<Vec<u8>> {
87        let args = vec![
88            "capture-pane",
89            "-t",
90            self.id.as_str(),
91            "-J", // preserves trailing spaces & joins any wrapped lines
92            "-e", // include escape sequences for text & background
93            "-p", // output goes to stdout
94            "-S", // starting line number
95            "-",  // start of history
96            "-E", // ending line number
97            "-",  // end of history
98        ];
99
100        let output = Command::new("tmux").args(&args).output().await?;
101
102        Ok(output.stdout)
103    }
104}
105
106pub(crate) mod parse {
107    use super::*;
108
109    pub(crate) fn pane(input: &str) -> IResult<&str, Pane> {
110        let (input, (id, _, index, _, is_active, _, title, _, command, _, dirpath)) = (
111            pane_id,
112            char(':'),
113            map_res(digit1, str::parse),
114            char(':'),
115            boolean,
116            char(':'),
117            quoted_string,
118            char(':'),
119            quoted_nonempty_string,
120            char(':'),
121            not_line_ending,
122        )
123            .parse(input)?;
124
125        Ok((
126            input,
127            Pane {
128                id,
129                index,
130                is_active,
131                title: title.into(),
132                dirpath: dirpath.into(),
133                command: command.into(),
134            },
135        ))
136    }
137}
138
139// ------------------------------
140// Ops
141// ------------------------------
142
143/// Return a list of all `Pane` from all sessions.
144pub async fn available_panes() -> Result<Vec<Pane>> {
145    let args = vec![
146        "list-panes",
147        "-a",
148        "-F",
149        "#{pane_id}\
150        :#{pane_index}\
151        :#{?pane_active,true,false}\
152        :'#{pane_title}'\
153        :'#{pane_current_command}'\
154        :#{pane_current_path}",
155    ];
156
157    let output = Command::new("tmux").args(&args).output().await?;
158    let buffer = String::from_utf8(output.stdout)?;
159
160    // Each call to `Pane::parse` returns a `Result<Pane, _>`. All results
161    // are collected into a Result<Vec<Pane>, _>, thanks to `collect()`.
162    let result: Result<Vec<Pane>> = buffer
163        .trim_end() // trim last '\n' as it would create an empty line
164        .split('\n')
165        .map(Pane::from_str)
166        .collect();
167
168    result
169}
170
171/// Create a new pane (horizontal split) in the window with `window_id`, and return the new
172/// pane id.
173pub async fn new_pane(
174    reference_pane: &Pane,
175    pane_command: Option<&str>,
176    window_id: &WindowId,
177) -> Result<PaneId> {
178    let mut args = vec![
179        "split-window",
180        "-h",
181        "-c",
182        reference_pane.dirpath.to_str().unwrap(),
183        "-t",
184        window_id.as_str(),
185        "-P",
186        "-F",
187        "#{pane_id}",
188    ];
189    if let Some(pane_command) = pane_command {
190        args.push(pane_command);
191    }
192
193    let output = Command::new("tmux").args(&args).output().await?;
194
195    // Check exit status before parsing to avoid confusing parse errors
196    // when tmux fails and returns empty/garbage stdout.
197    check_process_success(&output, "split-window")?;
198
199    let buffer = String::from_utf8(output.stdout)?;
200
201    let new_id = PaneId::from_str(buffer.trim_end())?;
202    Ok(new_id)
203}
204
205/// Select (make active) the pane with `pane_id`.
206pub async fn select_pane(pane_id: &PaneId) -> Result<()> {
207    let args = vec!["select-pane", "-t", pane_id.as_str()];
208
209    let output = Command::new("tmux").args(&args).output().await?;
210    check_empty_process_output(&output, "select-pane")
211}
212
213#[cfg(test)]
214mod tests {
215    use super::Pane;
216    use super::PaneId;
217    use crate::Result;
218    use std::path::PathBuf;
219    use std::str::FromStr;
220
221    #[test]
222    fn parse_list_panes() {
223        let output = [
224            "%20:0:false:'rmbp':'nvim':/Users/graelo/code/rust/tmux-backup",
225            "%21:1:true:'graelo@server: ~':'tmux':/Users/graelo/code/rust/tmux-backup",
226            "%27:2:false:'rmbp':'man man':/Users/graelo/code/rust/tmux-backup",
227        ];
228        let panes: Result<Vec<Pane>> = output.iter().map(|&line| Pane::from_str(line)).collect();
229        let panes = panes.expect("Could not parse tmux panes");
230
231        let expected = vec![
232            Pane {
233                id: PaneId::from_str("%20").unwrap(),
234                index: 0,
235                is_active: false,
236                title: String::from("rmbp"),
237                dirpath: PathBuf::from_str("/Users/graelo/code/rust/tmux-backup").unwrap(),
238                command: String::from("nvim"),
239            },
240            Pane {
241                id: PaneId(String::from("%21")),
242                index: 1,
243                is_active: true,
244                title: String::from("graelo@server: ~"),
245                dirpath: PathBuf::from_str("/Users/graelo/code/rust/tmux-backup").unwrap(),
246                command: String::from("tmux"),
247            },
248            Pane {
249                id: PaneId(String::from("%27")),
250                index: 2,
251                is_active: false,
252                title: String::from("rmbp"),
253                dirpath: PathBuf::from_str("/Users/graelo/code/rust/tmux-backup").unwrap(),
254                command: String::from("man man"),
255            },
256        ];
257
258        assert_eq!(panes, expected);
259    }
260
261    #[test]
262    fn parse_pane_with_empty_title() {
263        let line = "%20:0:false:'':'nvim':/Users/graelo/code/rust/tmux-backup";
264        let pane = Pane::from_str(line).expect("Could not parse pane with empty title");
265
266        let expected = Pane {
267            id: PaneId::from_str("%20").unwrap(),
268            index: 0,
269            is_active: false,
270            title: String::from(""),
271            dirpath: PathBuf::from_str("/Users/graelo/code/rust/tmux-backup").unwrap(),
272            command: String::from("nvim"),
273        };
274
275        assert_eq!(pane, expected);
276    }
277
278    #[test]
279    fn parse_pane_with_large_index() {
280        let line = "%999:99:true:'host':'zsh':/home/user";
281        let pane = Pane::from_str(line).expect("Should parse pane with large index");
282
283        assert_eq!(pane.id, PaneId::from_str("%999").unwrap());
284        assert_eq!(pane.index, 99);
285        assert!(pane.is_active);
286    }
287
288    #[test]
289    fn parse_pane_with_spaces_in_path() {
290        let line = "%1:0:false:'title':'vim':/Users/user/My Documents/project";
291        let pane = Pane::from_str(line).expect("Should parse pane with spaces in path");
292
293        assert_eq!(
294            pane.dirpath,
295            PathBuf::from("/Users/user/My Documents/project")
296        );
297    }
298
299    #[test]
300    fn parse_pane_with_unicode_title() {
301        let line = "%1:0:true:'日本語タイトル':'bash':/home/user";
302        let pane = Pane::from_str(line).expect("Should parse pane with unicode title");
303
304        assert_eq!(pane.title, "日本語タイトル");
305    }
306
307    #[test]
308    fn parse_pane_with_complex_command() {
309        let line = "%1:0:false:'host':'python -m http.server 8080':/tmp";
310        let pane = Pane::from_str(line).expect("Should parse pane with complex command");
311
312        assert_eq!(pane.command, "python -m http.server 8080");
313    }
314
315    #[test]
316    fn parse_pane_fails_on_missing_id() {
317        let line = "0:false:'title':'cmd':/path";
318        let result = Pane::from_str(line);
319
320        assert!(result.is_err());
321    }
322
323    #[test]
324    fn parse_pane_fails_on_invalid_boolean() {
325        let line = "%1:0:yes:'title':'cmd':/path";
326        let result = Pane::from_str(line);
327
328        assert!(result.is_err());
329    }
330
331    #[test]
332    fn parse_pane_fails_on_empty_command() {
333        // Command must be nonempty (uses quoted_nonempty_string)
334        let line = "%1:0:true:'title':'':/path";
335        let result = Pane::from_str(line);
336
337        assert!(result.is_err());
338    }
339
340    #[test]
341    fn parse_pane_fails_on_missing_path() {
342        let line = "%1:0:true:'title':'cmd'";
343        let result = Pane::from_str(line);
344
345        assert!(result.is_err());
346    }
347
348    #[test]
349    fn parse_pane_fails_on_wrong_id_prefix() {
350        // % is for pane, @ is for window, $ is for session
351        let line = "@1:0:true:'title':'cmd':/path";
352        let result = Pane::from_str(line);
353
354        assert!(result.is_err());
355    }
356}