1use 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
27pub struct Pane {
28 pub id: PaneId,
30 pub index: u16,
32 pub is_active: bool,
34 pub title: String,
36 pub dirpath: PathBuf,
38 pub command: String,
40}
41
42impl FromStr for Pane {
43 type Err = Error;
44
45 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 pub async fn capture(&self) -> Result<Vec<u8>> {
87 let args = vec![
88 "capture-pane",
89 "-t",
90 self.id.as_str(),
91 "-J", "-e", "-p", "-S", "-", "-E", "-", ];
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
139pub 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 let result: Result<Vec<Pane>> = buffer
163 .trim_end() .split('\n')
165 .map(Pane::from_str)
166 .collect();
167
168 result
169}
170
171pub 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_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
205pub 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 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 let line = "@1:0:true:'title':'cmd':/path";
352 let result = Pane::from_str(line);
353
354 assert!(result.is_err());
355 }
356}