diff --git a/src/app_state.rs b/src/app_state.rs index 411afe36..31053f73 100644 --- a/src/app_state.rs +++ b/src/app_state.rs @@ -1,5 +1,6 @@ use anyhow::{Context, Error, Result, bail}; use crossterm::{QueueableCommand, cursor, terminal}; +use serde::Deserialize; use std::{ collections::HashSet, env, @@ -11,7 +12,7 @@ use std::{ atomic::{AtomicUsize, Ordering::Relaxed}, mpsc, }, - thread, + thread::{self, JoinHandle}, }; use crate::{ @@ -49,6 +50,44 @@ pub enum CheckProgress { Pending, } +#[derive(Deserialize)] +struct Pane { + id: u32, +} + +#[must_use] +pub struct EditCmdJoinHandle(Option>>); + +fn parse_pane_id(b: &[u8]) -> Option<(String, u32)> { + // Remove newline + let b = b.get("terminal_".len()..b.len().saturating_sub(1))?; + let id_str = str::from_utf8(b).ok()?; + + let (first, rest) = b.split_first()?; + let mut id = u32::from(first - b'0'); + + for c in rest { + id = 10 * id + u32::from(c - b'0'); + } + + Some((id_str.to_owned(), id)) +} + +fn close_pane(pane_id: &str) -> Result<()> { + Command::new("zellij") + .arg("action") + .arg("close-pane") + .arg("-p") + .arg(pane_id) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .context("Failed to run `zellij action close-pane -p ID`")?; + + Ok(()) +} + pub struct AppState { current_exercise_ind: usize, exercises: Vec, @@ -61,12 +100,15 @@ pub struct AppState { official_exercises: bool, cmd_runner: CmdRunner, emit_file_links: bool, + zellij: bool, + open_pane: Option<(String, u32, usize)>, } impl AppState { pub fn new( exercise_infos: Vec, final_message: &'static str, + zellij: bool, ) -> Result<(Self, StateFileStatus)> { let cmd_runner = CmdRunner::build()?; let mut state_file = OpenOptions::new() @@ -175,6 +217,8 @@ impl AppState { cmd_runner, // VS Code has its own file link handling emit_file_links: env::var_os("TERM_PROGRAM").is_none_or(|v| v != "vscode"), + zellij, + open_pane: None, }; Ok((slf, state_file_status)) @@ -553,6 +597,86 @@ impl AppState { Ok(()) } + + pub fn close_pane(&mut self) -> Result<()> { + if let Some((pane_id_str, _, _)) = self.open_pane.take() { + close_pane(&pane_id_str)?; + } + + Ok(()) + } + + pub fn edit_cmd(&mut self) -> Result { + if !self.zellij { + return Ok(EditCmdJoinHandle(None)); + } + + let open_pane = self.open_pane.take(); + let current_exercise_ind = self.current_exercise_ind; + let mut edit_cmd = Command::new("zellij"); + edit_cmd + .arg("action") + .arg("edit") + .arg(&self.current_exercise().path) + .stdin(Stdio::null()) + .stderr(Stdio::null()); + + let handle = thread::Builder::new() + .spawn(move || { + if let Some((pane_id_str, pane_id, exercise_ind)) = open_pane { + if exercise_ind == current_exercise_ind { + // Check if the pane is still open + let mut output = Command::new("zellij") + .arg("action") + .arg("list-panes") + .arg("-j") + .stdin(Stdio::null()) + .stderr(Stdio::null()) + .output() + .context("Failed to run `zellij action list-panes -j`")?; + + if !output.status.success() { + bail!("`zellij action list-panes -j` didn't exit successfully"); + } + + // Remove newline + output.stdout.pop(); + + let panes = serde_json::de::from_slice::>(&output.stdout) + .context( + "Failed to parse the output of `zellij action list-panes -j`", + )?; + + if panes.iter().any(|pane| pane.id == pane_id) { + return Ok((pane_id_str, pane_id)); + } + } else { + close_pane(&pane_id_str)?; + } + } + + let output = edit_cmd.output()?; + + if !output.status.success() { + bail!("Failed to open a new Zellij editor pane"); + } + + parse_pane_id(&output.stdout) + .context("Failed to parse the ID of the new Zellij pane") + }) + .context("Failed to spawn a thread to open and close Zellij panes")?; + + Ok(EditCmdJoinHandle(Some(handle))) + } + + pub fn join_edit_cmd(&mut self, handle: EditCmdJoinHandle) -> Result<()> { + if let Some(handle) = handle.0 { + let (pane_id_str, pane_id) = handle.join().unwrap()?; + self.open_pane = Some((pane_id_str, pane_id, self.current_exercise_ind)); + } + + Ok(()) + } } const BAD_INDEX_ERR: &str = "The current exercise index is higher than the number of exercises"; @@ -608,6 +732,8 @@ mod tests { official_exercises: true, cmd_runner: CmdRunner::build().unwrap(), emit_file_links: true, + zellij: false, + open_pane: None, }; let mut assert = |done: [bool; 3], expected: [Option; 3]| { diff --git a/src/cli.rs b/src/cli.rs index 2bea5544..dbc31f9c 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -9,28 +9,33 @@ pub struct Args { #[command(subcommand)] pub command: Option, /// Manually run the current exercise using `r` in the watch mode. - /// Only use this if Rustlings fails to detect exercise file changes. + /// Only use this if Rustlings fails to detect exercise file changes #[arg(long)] pub manual_run: bool, + /// Open the current exercise in a new Zellij pane and close the last one if exists + #[arg(long)] + pub zellij: bool, } #[derive(Subcommand)] pub enum Command { /// Initialize the official Rustlings exercises Init, - /// Run a single exercise. Runs the next pending exercise if the exercise name is not specified + /// Run a single exercise. + /// Runs the next pending exercise if the exercise name is not specified Run { /// The name of the exercise name: Option, }, - /// Check all the exercises, marking them as done or pending accordingly. + /// Check all the exercises, marking them as done or pending accordingly CheckAll, /// Reset a single exercise Reset { /// The name of the exercise name: String, }, - /// Show a hint. Shows the hint of the next pending exercise if the exercise name is not specified + /// Show a hint. + /// Shows the hint of the next pending exercise if the exercise name is not specified Hint { /// The name of the exercise name: Option, diff --git a/src/main.rs b/src/main.rs index 8f31703b..29ec3d01 100644 --- a/src/main.rs +++ b/src/main.rs @@ -61,6 +61,7 @@ fn main() -> Result { let (mut app_state, state_file_status) = AppState::new( info_file.exercises, info_file.final_message.unwrap_or_default(), + args.zellij, )?; // Show the welcome message if the state file doesn't exist yet. diff --git a/src/watch/state.rs b/src/watch/state.rs index 1b285d67..9e7d1ddc 100644 --- a/src/watch/state.rs +++ b/src/watch/state.rs @@ -78,14 +78,16 @@ impl<'a> WatchState<'a> { // Ignore any input until running the exercise is done. let _input_pause_guard = InputPauseGuard::scoped_pause(); - self.show_hint = false; - writeln!( stdout, "\nChecking the exercise `{}`. Please wait…", self.app_state.current_exercise().name, )?; + let edit_cmd_handle = self.app_state.edit_cmd()?; + + self.show_hint = false; + let success = self .app_state .current_exercise() @@ -105,7 +107,9 @@ impl<'a> WatchState<'a> { self.done_status = DoneStatus::Pending; } + self.app_state.join_edit_cmd(edit_cmd_handle)?; self.render(stdout)?; + Ok(()) } @@ -127,9 +131,10 @@ impl<'a> WatchState<'a> { match answer[0] { b'y' | b'Y' => { + self.app_state.close_pane()?; self.app_state.reset_current_exercise()?; - // The file watcher reruns the exercise otherwise. + // The file watcher reruns the exercise otherwise if self.manual_run { self.run_current_exercise(stdout)?; } diff --git a/tmp.txt b/tmp.txt new file mode 100644 index 00000000..3aefeefc --- /dev/null +++ b/tmp.txt @@ -0,0 +1 @@ +226.867688ms \ No newline at end of file