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