1use std::process::Command;
7
8use rootcause::prelude::ResultExt;
9use ytil_cmd::CmdExt;
10
11const BIN: &str = "zellij";
12
13#[derive(Clone, Copy, Debug)]
15pub enum Direction {
16 Up,
17 Down,
18 Left,
19 Right,
20}
21
22impl Direction {
23 const fn as_str(self) -> &'static str {
24 match self {
25 Self::Up => "up",
26 Self::Down => "down",
27 Self::Left => "left",
28 Self::Right => "right",
29 }
30 }
31}
32
33pub fn is_active() -> bool {
35 std::env::var_os("ZELLIJ").is_some()
36}
37
38pub fn list_sessions() -> rootcause::Result<Vec<Session>> {
46 let mut cmd = Command::new(BIN);
47 cmd.args(["list-sessions"]);
48 let output = cmd
49 .output()
50 .map_err(|source| ytil_cmd::CmdError::Io {
51 cmd: ytil_cmd::Cmd::from(&cmd),
52 source,
53 })
54 .attach("operation=list-sessions")?;
55 let stdout = String::from_utf8_lossy(&output.stdout);
56
57 Ok(stdout.lines().filter(|l| !l.is_empty()).map(Session::new).collect())
58}
59
60#[derive(Debug)]
62#[cfg_attr(test, derive(Eq, PartialEq))]
63pub struct Session {
64 pub name: String,
66 pub display: String,
68}
69
70impl Session {
71 fn new(line: &str) -> Self {
75 let mut plain = String::with_capacity(line.len());
76 let mut in_escape = false;
77 for c in line.chars() {
78 match (in_escape, c) {
79 (true, c) if c.is_ascii_alphabetic() => in_escape = false,
80 (false, '\x1b') => in_escape = true,
81 (false, c) => plain.push(c),
82 _ => {}
83 }
84 }
85
86 Self {
87 name: plain.split_whitespace().next().unwrap_or_default().to_string(),
88 display: line.to_string(),
89 }
90 }
91}
92
93pub fn forward(args: &[String]) -> rootcause::Result<()> {
98 let mut cmd = Command::new(BIN);
99 cmd.args(args);
100 Ok(run_interactive(&mut cmd)?)
101}
102
103pub fn help() -> rootcause::Result<()> {
108 let mut cmd = Command::new(BIN);
109 cmd.arg("--help");
110 Ok(run_interactive(&mut cmd)?)
111}
112
113pub fn kill_session(name: &str) -> rootcause::Result<()> {
118 ytil_cmd::silent_cmd(BIN)
119 .args(["kill-session", name])
120 .exec()
121 .attach(format!("session={name}"))?;
122 Ok(())
123}
124
125pub fn attach_session(name: &str) -> rootcause::Result<()> {
133 if is_active() {
134 action(None, &["switch-session", name])
135 .attach(format!("session={name}"))
136 .attach("mode=switch")?;
137 } else {
138 let mut cmd = Command::new(BIN);
139 cmd.args(["attach", name]);
140 run_interactive(&mut cmd).attach(format!("session={name}"))?;
141 }
142 Ok(())
143}
144
145pub fn new_session_with_layout(session: &str, layout: &str) -> rootcause::Result<()> {
153 let mut cmd = Command::new(BIN);
154 cmd.args(["--new-session-with-layout", layout, "--session", session]);
155 run_interactive(&mut cmd)
156 .attach(format!("session={session}"))
157 .attach(format!("layout={layout}"))?;
158 Ok(())
159}
160
161pub fn delete_session(name: &str) -> rootcause::Result<()> {
168 ytil_cmd::silent_cmd(BIN)
169 .args(["delete-session", "--force", name])
170 .exec()
171 .attach(format!("session={name}"))?;
172 Ok(())
173}
174
175pub fn pane_count() -> rootcause::Result<usize> {
180 let output = Command::new(BIN).args(["action", "list-panes"]).exec()?;
181 Ok(output.stdout.split(|&b| b == b'\n').filter(|l| !l.is_empty()).count())
182}
183
184pub fn action(session: Option<&str>, args: &[&str]) -> rootcause::Result<()> {
189 ytil_cmd::silent_cmd(BIN)
190 .args(zellij_action_args(session, args))
191 .exec()?;
192 Ok(())
193}
194
195pub fn focus_tab_terminal_pane(session: Option<&str>, tab_id: usize, pane_id: u32) -> rootcause::Result<()> {
200 let tab_id = tab_id.to_string();
201 let pane_id = format!("terminal_{pane_id}");
202 let tab_result = action(session, &["go-to-tab-by-id", &tab_id]);
203 let pane_result = action(session, &["focus-pane-id", &pane_id]);
204 tab_result?;
205 pane_result?;
206 Ok(())
207}
208
209pub fn focused_pane_command() -> rootcause::Result<Option<String>> {
216 let output = Command::new(BIN).args(["action", "list-clients"]).exec()?;
217 let stdout = String::from_utf8_lossy(&output.stdout);
218 let cmd = stdout
219 .lines()
220 .nth(1)
221 .and_then(|line| line.split_whitespace().nth(2))
222 .map(String::from);
223 Ok(cmd)
224}
225
226pub fn move_focus(direction: Direction) -> rootcause::Result<()> {
231 action(None, &["move-focus", direction.as_str()])
232}
233
234pub fn write_byte(byte: u8) -> rootcause::Result<()> {
239 let s = byte.to_string();
240 action(None, &["write", &s])
241}
242
243pub fn write_chars(text: &str) -> rootcause::Result<()> {
248 action(None, &["write-chars", text])
249}
250
251pub fn edit(path: &str, direction: Direction, line_number: Option<i64>) -> rootcause::Result<()> {
256 let lnum_str;
257 let dir = direction.as_str();
258 let mut args = vec!["edit", "--direction", dir, path];
259 if let Some(n) = line_number {
260 lnum_str = n.to_string();
261 args.extend(["--line-number", &lnum_str]);
262 }
263 action(None, &args)
264}
265
266pub fn new_pane(direction: Direction, command: &[&str]) -> rootcause::Result<()> {
271 let mut args = vec!["new-pane", "--direction", direction.as_str(), "--"];
272 args.extend(command);
273 action(None, &args)
274}
275
276pub fn resize_increase(direction: Direction, times: u32) -> rootcause::Result<()> {
281 for _ in 0..times {
282 action(None, &["resize", "increase", direction.as_str()])?;
283 }
284 Ok(())
285}
286
287fn run_interactive(cmd: &mut Command) -> Result<(), Box<ytil_cmd::CmdError>> {
294 let status = cmd.status().map_err(|source| {
295 Box::new(ytil_cmd::CmdError::Io {
296 cmd: ytil_cmd::Cmd::from(&*cmd),
297 source,
298 })
299 })?;
300 if !status.success() {
301 return Err(Box::new(ytil_cmd::CmdError::CmdFailure {
302 cmd: ytil_cmd::Cmd::from(&*cmd),
303 stderr: String::new(),
304 stdout: String::new(),
305 status,
306 }));
307 }
308 Ok(())
309}
310
311fn zellij_action_args(session: Option<&str>, action_args: &[&str]) -> Vec<String> {
312 let mut args = Vec::new();
313 if let Some(session) = session {
314 args.push("--session".to_string());
315 args.push(session.to_string());
316 }
317 args.push("action".to_string());
318 args.extend(action_args.iter().map(|arg| (*arg).to_string()));
319 args
320}
321
322#[cfg(test)]
323mod tests {
324 use rstest::rstest;
325
326 use super::*;
327
328 const ANSI_LINE: &str = "\x1b[32;1madept-viola\x1b[m [Created \x1b[35;1m15s\x1b[m ago]";
329
330 #[rstest]
331 #[case::plain_text(
332 "my-session [Created 5m ago]",
333 Session {
334 name: "my-session".into(),
335 display: "my-session [Created 5m ago]".into(),
336 },
337 )]
338 #[case::ansi_codes(
339 ANSI_LINE,
340 Session {
341 name: "adept-viola".into(),
342 display: ANSI_LINE.into(),
343 },
344 )]
345 #[case::empty_line(
346 "",
347 Session {
348 name: String::new(),
349 display: String::new(),
350 },
351 )]
352 #[case::name_only(
353 "simple",
354 Session {
355 name: "simple".into(),
356 display: "simple".into(),
357 },
358 )]
359 fn session_new_parses_list_sessions_line(#[case] input: &str, #[case] expected: Session) {
360 pretty_assertions::assert_eq!(Session::new(input), expected);
361 }
362
363 #[test]
364 fn test_zellij_action_args_with_session_include_session_and_action() {
365 pretty_assertions::assert_eq!(
366 zellij_action_args(Some("work"), &["focus-pane-id", "terminal_42"]),
367 vec![
368 "--session".to_string(),
369 "work".to_string(),
370 "action".to_string(),
371 "focus-pane-id".to_string(),
372 "terminal_42".to_string(),
373 ],
374 );
375 }
376
377 #[test]
378 fn test_zellij_action_args_without_session_target_current_session() {
379 pretty_assertions::assert_eq!(
380 zellij_action_args(None, &["go-to-tab-by-id", "10"]),
381 vec!["action".to_string(), "go-to-tab-by-id".to_string(), "10".to_string(),],
382 );
383 }
384}