1use 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
40pub struct Pane {
41 pub id: PaneId,
43 pub index: u16,
45 pub is_active: bool,
47 pub title: String,
49 pub dirpath: PathBuf,
51 pub command: String,
53}
54
55impl FromStr for Pane {
56 type Err = Error;
57
58 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 pub async fn capture(&self) -> Result<Vec<u8>> {
100 let args = vec![
101 "capture-pane",
102 "-t",
103 self.id.as_str(),
104 "-J", "-e", "-p", "-S", "-", "-E", "-", ];
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
152pub 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 let result: Result<Vec<Pane>> = buffer
176 .trim_end() .split('\n')
178 .map(Pane::from_str)
179 .collect();
180
181 result
182}
183
184pub 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_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
218pub 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 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 let line = "@1:0:true:'title':'cmd':/path";
365 let result = Pane::from_str(line);
366
367 assert!(result.is_err());
368 }
369}