1use 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
29pub struct Window {
30 pub id: WindowId,
32 pub index: u16,
34 pub is_active: bool,
36 pub layout: String,
38 pub name: String,
40 pub sessions: Vec<String>,
42}
43
44impl FromStr for Window {
45 type Err = Error;
46
47 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 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
129pub 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 let result: Result<Vec<Window>> = buffer
154 .trim_end() .split('\n')
156 .map(Window::from_str)
157 .collect();
158
159 result
160}
161
162pub async fn new_window(
171 session: &Session,
172 window: &Window,
173 pane: &Pane,
174 pane_command: Option<&str>,
175) -> Result<(WindowId, PaneId)> {
176 let exact_session_name = format!("={}", session.name);
177
178 let mut args = vec![
179 "new-window",
180 "-d",
181 "-c",
182 pane.dirpath.to_str().unwrap(),
183 "-n",
184 &window.name,
185 "-t",
186 &exact_session_name,
187 "-P",
188 "-F",
189 "#{window_id}:#{pane_id}",
190 ];
191 if let Some(pane_command) = pane_command {
192 args.push(pane_command);
193 }
194
195 let output = Command::new("tmux").args(&args).output().await?;
196
197 check_process_success(&output, "new-window")?;
200
201 let buffer = String::from_utf8(output.stdout)?;
202 let buffer = buffer.trim_end();
203
204 let desc = "new-window";
205 let intent = "##{window_id}:##{pane_id}";
206
207 let (_, (new_window_id, _, new_pane_id)) = all_consuming((window_id, char(':'), pane_id))
208 .parse(buffer)
209 .map_err(|e| map_add_intent(desc, intent, e))?;
210
211 Ok((new_window_id, new_pane_id))
212}
213
214pub async fn set_layout(layout: &str, window_id: &WindowId) -> Result<()> {
216 let args = vec!["select-layout", "-t", window_id.as_str(), layout];
217
218 let output = Command::new("tmux").args(&args).output().await?;
219 check_empty_process_output(&output, "select-layout")
220}
221
222pub async fn select_window(window_id: &WindowId) -> Result<()> {
224 let args = vec!["select-window", "-t", window_id.as_str()];
225
226 let output = Command::new("tmux").args(&args).output().await?;
227 check_empty_process_output(&output, "select-window")
228}
229
230#[cfg(test)]
231mod tests {
232 use super::Window;
233 use super::WindowId;
234 use crate::pane_id::PaneId;
235 use crate::Result;
236 use std::str::FromStr;
237
238 #[test]
239 fn parse_list_sessions() {
240 let output = vec![
241 "@1:0:true:035d,334x85,0,0{167x85,0,0,1,166x85,168,0[166x48,168,0,2,166x36,168,49,3]}:'ignite':'pytorch'",
242 "@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'",
243 "@3:2:false:9e8b,334x85,0,0{167x85,0,0,8,166x85,168,0,9}:'th-bits':'pytorch'",
244 "@4:3:false:64ef,334x85,0,0,10:'docker-pytorch':'pytorch'",
245 "@5:0:true:64f0,334x85,0,0,11:'ben':'rust'",
246 "@6:1:false:64f1,334x85,0,0,12:'pyo3':'rust'",
247 "@7:2:false:64f2,334x85,0,0,13:'mdns-repeater':'rust'",
248 "@8:0:true:64f3,334x85,0,0,14:'combine':'swift'",
249 "@9:0:false:64f4,334x85,0,0,15:'copyrat':'tmux-hacking'",
250 "@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'",
251 "@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'",
252 ];
253 let sessions: Result<Vec<Window>> =
254 output.iter().map(|&line| Window::from_str(line)).collect();
255 let windows = sessions.expect("Could not parse tmux sessions");
256
257 let expected = vec![
258 Window {
259 id: WindowId::from_str("@1").unwrap(),
260 index: 0,
261 is_active: true,
262 layout: String::from(
263 "035d,334x85,0,0{167x85,0,0,1,166x85,168,0[166x48,168,0,2,166x36,168,49,3]}",
264 ),
265 name: String::from("ignite"),
266 sessions: vec![String::from("pytorch")],
267 },
268 Window {
269 id: WindowId::from_str("@2").unwrap(),
270 index: 1,
271 is_active: false,
272 layout: String::from(
273 "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}]",
274 ),
275 name: String::from("dates-attn"),
276 sessions: vec![String::from("pytorch")],
277 },
278 Window {
279 id: WindowId::from_str("@3").unwrap(),
280 index: 2,
281 is_active: false,
282 layout: String::from(
283 "9e8b,334x85,0,0{167x85,0,0,8,166x85,168,0,9}",
284 ),
285 name: String::from("th-bits"),
286 sessions: vec![String::from("pytorch")],
287 },
288 Window {
289 id: WindowId::from_str("@4").unwrap(),
290 index: 3,
291 is_active: false,
292 layout: String::from(
293 "64ef,334x85,0,0,10",
294 ),
295 name: String::from("docker-pytorch"),
296 sessions: vec![String::from("pytorch")],
297 },
298 Window {
299 id: WindowId::from_str("@5").unwrap(),
300 index: 0,
301 is_active: true,
302 layout: String::from(
303 "64f0,334x85,0,0,11",
304 ),
305 name: String::from("ben"),
306 sessions: vec![String::from("rust")],
307 },
308 Window {
309 id: WindowId::from_str("@6").unwrap(),
310 index: 1,
311 is_active: false,
312 layout: String::from(
313 "64f1,334x85,0,0,12",
314 ),
315 name: String::from("pyo3"),
316 sessions: vec![String::from("rust")],
317 },
318 Window {
319 id: WindowId::from_str("@7").unwrap(),
320 index: 2,
321 is_active: false,
322 layout: String::from(
323 "64f2,334x85,0,0,13",
324 ),
325 name: String::from("mdns-repeater"),
326 sessions: vec![String::from("rust")],
327 },
328 Window {
329 id: WindowId::from_str("@8").unwrap(),
330 index: 0,
331 is_active: true,
332 layout: String::from(
333 "64f3,334x85,0,0,14",
334 ),
335 name: String::from("combine"),
336 sessions: vec![String::from("swift")],
337 },
338 Window {
339 id: WindowId::from_str("@9").unwrap(),
340 index: 0,
341 is_active: false,
342 layout: String::from(
343 "64f4,334x85,0,0,15",
344 ),
345 name: String::from("copyrat"),
346 sessions: vec![String::from("tmux-hacking")],
347 },
348 Window {
349 id: WindowId::from_str("@10").unwrap(),
350 index: 1,
351 is_active: false,
352 layout: String::from(
353 "ae3a,334x85,0,0[334x48,0,0,17,334x36,0,49{175x36,0,49,18,158x36,176,49,19}]",
354 ),
355 name: String::from("mytui-app"),
356 sessions: vec![String::from("tmux-hacking")],
357 },
358 Window {
359 id: WindowId::from_str("@11").unwrap(),
360 index: 2,
361 is_active: true,
362 layout: String::from(
363 "e2e2,334x85,0,0{175x85,0,0,20,158x85,176,0[158x42,176,0,21,158x42,176,43,27]}",
364 ),
365 name: String::from("tmux-backup"),
366 sessions: vec![String::from("tmux-hacking")],
367 },
368 ];
369
370 assert_eq!(windows, expected);
371 }
372
373 #[test]
374 fn parse_window_single_pane() {
375 let input = "@5:0:true:64f0,334x85,0,0,11:'ben':'rust'";
376 let window = Window::from_str(input).expect("Should parse window with single pane");
377
378 assert_eq!(window.id, WindowId::from_str("@5").unwrap());
379 assert_eq!(window.index, 0);
380 assert!(window.is_active);
381 assert_eq!(window.name, "ben");
382 assert_eq!(window.sessions, vec!["rust".to_string()]);
383 }
384
385 #[test]
386 fn parse_window_with_large_index() {
387 let input = "@100:99:false:64f0,334x85,0,0,11:'test':'session'";
388 let window = Window::from_str(input).expect("Should parse window with large index");
389
390 assert_eq!(window.id, WindowId::from_str("@100").unwrap());
391 assert_eq!(window.index, 99);
392 assert!(!window.is_active);
393 }
394
395 #[test]
396 fn parse_window_fails_on_missing_id() {
397 let input = "0:true:64f0,334x85,0,0,11:'name':'session'";
398 let result = Window::from_str(input);
399
400 assert!(result.is_err());
401 }
402
403 #[test]
404 fn parse_window_fails_on_invalid_boolean() {
405 let input = "@1:0:yes:64f0,334x85,0,0,11:'name':'session'";
406 let result = Window::from_str(input);
407
408 assert!(result.is_err());
409 }
410
411 #[test]
412 fn parse_window_fails_on_empty_name() {
413 let input = "@1:0:true:64f0,334x85,0,0,11:'':'session'";
414 let result = Window::from_str(input);
415
416 assert!(result.is_err());
417 }
418
419 #[test]
420 fn window_pane_ids_single_pane() {
421 let window = Window {
422 id: WindowId::from_str("@1").unwrap(),
423 index: 0,
424 is_active: true,
425 layout: String::from("64f0,334x85,0,0,11"),
426 name: String::from("test"),
427 sessions: vec![String::from("session")],
428 };
429
430 let pane_ids = window.pane_ids();
431 assert_eq!(pane_ids.len(), 1);
432 assert_eq!(pane_ids[0], PaneId::from_str("%11").unwrap());
433 }
434
435 #[test]
436 fn window_pane_ids_multiple_panes() {
437 let window = Window {
438 id: WindowId::from_str("@3").unwrap(),
439 index: 2,
440 is_active: false,
441 layout: String::from("9e8b,334x85,0,0{167x85,0,0,8,166x85,168,0,9}"),
442 name: String::from("th-bits"),
443 sessions: vec![String::from("pytorch")],
444 };
445
446 let pane_ids = window.pane_ids();
447 assert_eq!(pane_ids.len(), 2);
448 assert_eq!(pane_ids[0], PaneId::from_str("%8").unwrap());
449 assert_eq!(pane_ids[1], PaneId::from_str("%9").unwrap());
450 }
451
452 #[test]
453 fn window_pane_ids_complex_layout() {
454 let window = Window {
456 id: WindowId::from_str("@1").unwrap(),
457 index: 0,
458 is_active: true,
459 layout: String::from(
460 "035d,334x85,0,0{167x85,0,0,1,166x85,168,0[166x48,168,0,2,166x36,168,49,3]}",
461 ),
462 name: String::from("ignite"),
463 sessions: vec![String::from("pytorch")],
464 };
465
466 let pane_ids = window.pane_ids();
467 assert_eq!(pane_ids.len(), 3);
468 assert_eq!(pane_ids[0], PaneId::from_str("%1").unwrap());
469 assert_eq!(pane_ids[1], PaneId::from_str("%2").unwrap());
470 assert_eq!(pane_ids[2], PaneId::from_str("%3").unwrap());
471 }
472}