Skip to main content

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