1use std::str::FromStr;
6
7use smol::process::Command;
8
9use nom::{
10 IResult, Parser,
11 character::complete::{char, digit1},
12 combinator::{all_consuming, map_res, recognize},
13};
14use serde::{Deserialize, Serialize};
15
16use crate::{
17 Result,
18 error::{Error, check_empty_process_output, check_process_success, map_add_intent},
19 layout::{self, window_layout},
20 pane::Pane,
21 pane_id::{PaneId, parse::pane_id},
22 parse::{boolean, quoted_nonempty_string},
23 session::Session,
24 window_id::{WindowId, parse::window_id},
25};
26
27#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
42pub struct Window {
43 pub id: WindowId,
45 pub index: u16,
47 pub is_active: bool,
49 pub layout: String,
51 pub name: String,
53 pub sessions: Vec<String>,
55}
56
57impl FromStr for Window {
58 type Err = Error;
59
60 fn from_str(input: &str) -> std::result::Result<Self, Self::Err> {
90 let desc = "Window";
91 let intent = "##{window_id}:##{window_index}:##{?window_active,true,false}:##{window_layout}:'##{window_name}':'##{window_linked_sessions_list}'";
92
93 let (_, window) = all_consuming(parse::window)
94 .parse(input)
95 .map_err(|e| map_add_intent(desc, intent, e))?;
96
97 Ok(window)
98 }
99}
100
101impl Window {
102 pub fn pane_ids(&self) -> Vec<PaneId> {
104 let layout = layout::parse_window_layout(&self.layout).unwrap();
105 layout.pane_ids().iter().map(PaneId::from).collect()
106 }
107}
108
109pub(crate) mod parse {
110 use super::*;
111
112 pub(crate) fn window(input: &str) -> IResult<&str, Window> {
113 let (input, (id, _, index, _, is_active, _, layout, _, name, _, session_names)) = (
114 window_id,
115 char(':'),
116 map_res(digit1, str::parse),
117 char(':'),
118 boolean,
119 char(':'),
120 recognize(window_layout),
121 char(':'),
122 quoted_nonempty_string,
123 char(':'),
124 quoted_nonempty_string,
125 )
126 .parse(input)?;
127
128 Ok((
129 input,
130 Window {
131 id,
132 index,
133 is_active,
134 layout: layout.to_string(),
135 name: name.to_string(),
136 sessions: vec![session_names.to_string()],
137 },
138 ))
139 }
140}
141
142pub async fn available_windows() -> Result<Vec<Window>> {
148 let args = vec![
149 "list-windows",
150 "-a",
151 "-F",
152 "#{window_id}\
153 :#{window_index}\
154 :#{?window_active,true,false}\
155 :#{window_layout}\
156 :'#{window_name}'\
157 :'#{window_linked_sessions_list}'",
158 ];
159
160 let output = Command::new("tmux").args(&args).output().await?;
161 let buffer = String::from_utf8(output.stdout)?;
162
163 let result: Result<Vec<Window>> = buffer
167 .trim_end() .split('\n')
169 .map(Window::from_str)
170 .collect();
171
172 result
173}
174
175pub async fn new_window(
184 session: &Session,
185 window: &Window,
186 pane: &Pane,
187 pane_command: Option<&str>,
188) -> Result<(WindowId, PaneId)> {
189 let target_session = session.id.as_str();
193
194 let mut args = vec![
195 "new-window",
196 "-d",
197 "-c",
198 pane.dirpath.to_str().unwrap(),
199 "-n",
200 &window.name,
201 "-t",
202 target_session,
203 "-P",
204 "-F",
205 "#{window_id}:#{pane_id}",
206 ];
207 if let Some(pane_command) = pane_command {
208 args.push(pane_command);
209 }
210
211 let output = Command::new("tmux").args(&args).output().await?;
212
213 check_process_success(&output, "new-window")?;
216
217 let buffer = String::from_utf8(output.stdout)?;
218 let buffer = buffer.trim_end();
219
220 let desc = "new-window";
221 let intent = "##{window_id}:##{pane_id}";
222
223 let (_, (new_window_id, _, new_pane_id)) = all_consuming((window_id, char(':'), pane_id))
224 .parse(buffer)
225 .map_err(|e| map_add_intent(desc, intent, e))?;
226
227 Ok((new_window_id, new_pane_id))
228}
229
230pub async fn set_layout(layout: &str, window_id: &WindowId) -> Result<()> {
232 let args = vec!["select-layout", "-t", window_id.as_str(), layout];
233
234 let output = Command::new("tmux").args(&args).output().await?;
235 check_empty_process_output(&output, "select-layout")
236}
237
238pub async fn select_window(window_id: &WindowId) -> Result<()> {
240 let args = vec!["select-window", "-t", window_id.as_str()];
241
242 let output = Command::new("tmux").args(&args).output().await?;
243 check_empty_process_output(&output, "select-window")
244}
245
246#[cfg(test)]
247mod tests {
248 use super::Window;
249 use super::WindowId;
250 use crate::Result;
251 use crate::pane_id::PaneId;
252 use std::str::FromStr;
253
254 #[test]
255 fn parse_list_sessions() {
256 let output = vec![
257 "@1:0:true:035d,334x85,0,0{167x85,0,0,1,166x85,168,0[166x48,168,0,2,166x36,168,49,3]}:'ignite':'pytorch'",
258 "@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'",
259 "@3:2:false:9e8b,334x85,0,0{167x85,0,0,8,166x85,168,0,9}:'th-bits':'pytorch'",
260 "@4:3:false:64ef,334x85,0,0,10:'docker-pytorch':'pytorch'",
261 "@5:0:true:64f0,334x85,0,0,11:'ben':'rust'",
262 "@6:1:false:64f1,334x85,0,0,12:'pyo3':'rust'",
263 "@7:2:false:64f2,334x85,0,0,13:'mdns-repeater':'rust'",
264 "@8:0:true:64f3,334x85,0,0,14:'combine':'swift'",
265 "@9:0:false:64f4,334x85,0,0,15:'copyrat':'tmux-hacking'",
266 "@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'",
267 "@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'",
268 ];
269 let sessions: Result<Vec<Window>> =
270 output.iter().map(|&line| Window::from_str(line)).collect();
271 let windows = sessions.expect("Could not parse tmux sessions");
272
273 let expected = vec![
274 Window {
275 id: WindowId::from_str("@1").unwrap(),
276 index: 0,
277 is_active: true,
278 layout: String::from(
279 "035d,334x85,0,0{167x85,0,0,1,166x85,168,0[166x48,168,0,2,166x36,168,49,3]}",
280 ),
281 name: String::from("ignite"),
282 sessions: vec![String::from("pytorch")],
283 },
284 Window {
285 id: WindowId::from_str("@2").unwrap(),
286 index: 1,
287 is_active: false,
288 layout: String::from(
289 "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}]",
290 ),
291 name: String::from("dates-attn"),
292 sessions: vec![String::from("pytorch")],
293 },
294 Window {
295 id: WindowId::from_str("@3").unwrap(),
296 index: 2,
297 is_active: false,
298 layout: String::from("9e8b,334x85,0,0{167x85,0,0,8,166x85,168,0,9}"),
299 name: String::from("th-bits"),
300 sessions: vec![String::from("pytorch")],
301 },
302 Window {
303 id: WindowId::from_str("@4").unwrap(),
304 index: 3,
305 is_active: false,
306 layout: String::from("64ef,334x85,0,0,10"),
307 name: String::from("docker-pytorch"),
308 sessions: vec![String::from("pytorch")],
309 },
310 Window {
311 id: WindowId::from_str("@5").unwrap(),
312 index: 0,
313 is_active: true,
314 layout: String::from("64f0,334x85,0,0,11"),
315 name: String::from("ben"),
316 sessions: vec![String::from("rust")],
317 },
318 Window {
319 id: WindowId::from_str("@6").unwrap(),
320 index: 1,
321 is_active: false,
322 layout: String::from("64f1,334x85,0,0,12"),
323 name: String::from("pyo3"),
324 sessions: vec![String::from("rust")],
325 },
326 Window {
327 id: WindowId::from_str("@7").unwrap(),
328 index: 2,
329 is_active: false,
330 layout: String::from("64f2,334x85,0,0,13"),
331 name: String::from("mdns-repeater"),
332 sessions: vec![String::from("rust")],
333 },
334 Window {
335 id: WindowId::from_str("@8").unwrap(),
336 index: 0,
337 is_active: true,
338 layout: String::from("64f3,334x85,0,0,14"),
339 name: String::from("combine"),
340 sessions: vec![String::from("swift")],
341 },
342 Window {
343 id: WindowId::from_str("@9").unwrap(),
344 index: 0,
345 is_active: false,
346 layout: String::from("64f4,334x85,0,0,15"),
347 name: String::from("copyrat"),
348 sessions: vec![String::from("tmux-hacking")],
349 },
350 Window {
351 id: WindowId::from_str("@10").unwrap(),
352 index: 1,
353 is_active: false,
354 layout: String::from(
355 "ae3a,334x85,0,0[334x48,0,0,17,334x36,0,49{175x36,0,49,18,158x36,176,49,19}]",
356 ),
357 name: String::from("mytui-app"),
358 sessions: vec![String::from("tmux-hacking")],
359 },
360 Window {
361 id: WindowId::from_str("@11").unwrap(),
362 index: 2,
363 is_active: true,
364 layout: String::from(
365 "e2e2,334x85,0,0{175x85,0,0,20,158x85,176,0[158x42,176,0,21,158x42,176,43,27]}",
366 ),
367 name: String::from("tmux-backup"),
368 sessions: vec![String::from("tmux-hacking")],
369 },
370 ];
371
372 assert_eq!(windows, expected);
373 }
374
375 #[test]
376 fn parse_window_single_pane() {
377 let input = "@5:0:true:64f0,334x85,0,0,11:'ben':'rust'";
378 let window = Window::from_str(input).expect("Should parse window with single pane");
379
380 assert_eq!(window.id, WindowId::from_str("@5").unwrap());
381 assert_eq!(window.index, 0);
382 assert!(window.is_active);
383 assert_eq!(window.name, "ben");
384 assert_eq!(window.sessions, vec!["rust".to_string()]);
385 }
386
387 #[test]
388 fn parse_window_with_large_index() {
389 let input = "@100:99:false:64f0,334x85,0,0,11:'test':'session'";
390 let window = Window::from_str(input).expect("Should parse window with large index");
391
392 assert_eq!(window.id, WindowId::from_str("@100").unwrap());
393 assert_eq!(window.index, 99);
394 assert!(!window.is_active);
395 }
396
397 #[test]
398 fn parse_window_fails_on_missing_id() {
399 let input = "0:true:64f0,334x85,0,0,11:'name':'session'";
400 let result = Window::from_str(input);
401
402 assert!(result.is_err());
403 }
404
405 #[test]
406 fn parse_window_fails_on_invalid_boolean() {
407 let input = "@1:0:yes:64f0,334x85,0,0,11:'name':'session'";
408 let result = Window::from_str(input);
409
410 assert!(result.is_err());
411 }
412
413 #[test]
414 fn parse_window_fails_on_empty_name() {
415 let input = "@1:0:true:64f0,334x85,0,0,11:'':'session'";
416 let result = Window::from_str(input);
417
418 assert!(result.is_err());
419 }
420
421 #[test]
422 fn window_pane_ids_single_pane() {
423 let window = Window {
424 id: WindowId::from_str("@1").unwrap(),
425 index: 0,
426 is_active: true,
427 layout: String::from("64f0,334x85,0,0,11"),
428 name: String::from("test"),
429 sessions: vec![String::from("session")],
430 };
431
432 let pane_ids = window.pane_ids();
433 assert_eq!(pane_ids.len(), 1);
434 assert_eq!(pane_ids[0], PaneId::from_str("%11").unwrap());
435 }
436
437 #[test]
438 fn window_pane_ids_multiple_panes() {
439 let window = Window {
440 id: WindowId::from_str("@3").unwrap(),
441 index: 2,
442 is_active: false,
443 layout: String::from("9e8b,334x85,0,0{167x85,0,0,8,166x85,168,0,9}"),
444 name: String::from("th-bits"),
445 sessions: vec![String::from("pytorch")],
446 };
447
448 let pane_ids = window.pane_ids();
449 assert_eq!(pane_ids.len(), 2);
450 assert_eq!(pane_ids[0], PaneId::from_str("%8").unwrap());
451 assert_eq!(pane_ids[1], PaneId::from_str("%9").unwrap());
452 }
453
454 #[test]
455 fn window_pane_ids_complex_layout() {
456 let window = Window {
458 id: WindowId::from_str("@1").unwrap(),
459 index: 0,
460 is_active: true,
461 layout: String::from(
462 "035d,334x85,0,0{167x85,0,0,1,166x85,168,0[166x48,168,0,2,166x36,168,49,3]}",
463 ),
464 name: String::from("ignite"),
465 sessions: vec![String::from("pytorch")],
466 };
467
468 let pane_ids = window.pane_ids();
469 assert_eq!(pane_ids.len(), 3);
470 assert_eq!(pane_ids[0], PaneId::from_str("%1").unwrap());
471 assert_eq!(pane_ids[1], PaneId::from_str("%2").unwrap());
472 assert_eq!(pane_ids[2], PaneId::from_str("%3").unwrap());
473 }
474}