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(&["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
175fn run_interactive(cmd: &mut Command) -> Result<(), Box<ytil_cmd::CmdError>> {
182 let status = cmd.status().map_err(|source| {
183 Box::new(ytil_cmd::CmdError::Io {
184 cmd: ytil_cmd::Cmd::from(&*cmd),
185 source,
186 })
187 })?;
188 if !status.success() {
189 return Err(Box::new(ytil_cmd::CmdError::CmdFailure {
190 cmd: ytil_cmd::Cmd::from(&*cmd),
191 stderr: String::new(),
192 stdout: String::new(),
193 status,
194 }));
195 }
196 Ok(())
197}
198
199pub fn pane_count() -> rootcause::Result<usize> {
204 let output = Command::new(BIN).args(["action", "list-panes"]).exec()?;
205 Ok(output.stdout.split(|&b| b == b'\n').filter(|l| !l.is_empty()).count())
206}
207
208pub fn action(args: &[&str]) -> rootcause::Result<()> {
213 let full: Vec<&str> = std::iter::once("action").chain(args.iter().copied()).collect();
214 ytil_cmd::silent_cmd(BIN).args(&full).exec()?;
215 Ok(())
216}
217
218pub fn focused_pane_command() -> rootcause::Result<Option<String>> {
225 let output = Command::new(BIN).args(["action", "list-clients"]).exec()?;
226 let stdout = String::from_utf8_lossy(&output.stdout);
227 let cmd = stdout
228 .lines()
229 .nth(1)
230 .and_then(|line| line.split_whitespace().nth(2))
231 .map(String::from);
232 Ok(cmd)
233}
234
235pub fn move_focus(direction: Direction) -> rootcause::Result<()> {
240 action(&["move-focus", direction.as_str()])
241}
242
243pub fn write_byte(byte: u8) -> rootcause::Result<()> {
248 let s = byte.to_string();
249 action(&["write", &s])
250}
251
252pub fn write_chars(text: &str) -> rootcause::Result<()> {
257 action(&["write-chars", text])
258}
259
260pub fn edit(path: &str, direction: Direction, line_number: Option<i64>) -> rootcause::Result<()> {
265 let lnum_str;
266 let dir = direction.as_str();
267 let mut args = vec!["edit", "--direction", dir, path];
268 if let Some(n) = line_number {
269 lnum_str = n.to_string();
270 args.extend(["--line-number", &lnum_str]);
271 }
272 action(&args)
273}
274
275pub fn new_pane(direction: Direction, command: &[&str]) -> rootcause::Result<()> {
280 let mut args = vec!["new-pane", "--direction", direction.as_str(), "--"];
281 args.extend(command);
282 action(&args)
283}
284
285pub fn resize_increase(direction: Direction, times: u32) -> rootcause::Result<()> {
290 for _ in 0..times {
291 action(&["resize", "increase", direction.as_str()])?;
292 }
293 Ok(())
294}
295
296#[cfg(test)]
297mod tests {
298 use rstest::rstest;
299
300 use super::*;
301
302 const ANSI_LINE: &str = "\x1b[32;1madept-viola\x1b[m [Created \x1b[35;1m15s\x1b[m ago]";
303
304 #[rstest]
305 #[case::plain_text(
306 "my-session [Created 5m ago]",
307 Session {
308 name: "my-session".into(),
309 display: "my-session [Created 5m ago]".into(),
310 },
311 )]
312 #[case::ansi_codes(
313 ANSI_LINE,
314 Session {
315 name: "adept-viola".into(),
316 display: ANSI_LINE.into(),
317 },
318 )]
319 #[case::empty_line(
320 "",
321 Session {
322 name: String::new(),
323 display: String::new(),
324 },
325 )]
326 #[case::name_only(
327 "simple",
328 Session {
329 name: "simple".into(),
330 display: "simple".into(),
331 },
332 )]
333 fn session_new_parses_list_sessions_line(#[case] input: &str, #[case] expected: Session) {
334 pretty_assertions::assert_eq!(Session::new(input), expected);
335 }
336}