Add Zellij support

This commit is contained in:
mo8it 2026-04-05 18:17:10 +02:00
parent 95b6160b54
commit 4d97c31c0f
5 changed files with 146 additions and 8 deletions

View File

@ -1,5 +1,6 @@
use anyhow::{Context, Error, Result, bail}; use anyhow::{Context, Error, Result, bail};
use crossterm::{QueueableCommand, cursor, terminal}; use crossterm::{QueueableCommand, cursor, terminal};
use serde::Deserialize;
use std::{ use std::{
collections::HashSet, collections::HashSet,
env, env,
@ -11,7 +12,7 @@ use std::{
atomic::{AtomicUsize, Ordering::Relaxed}, atomic::{AtomicUsize, Ordering::Relaxed},
mpsc, mpsc,
}, },
thread, thread::{self, JoinHandle},
}; };
use crate::{ use crate::{
@ -49,6 +50,44 @@ pub enum CheckProgress {
Pending, Pending,
} }
#[derive(Deserialize)]
struct Pane {
id: u32,
}
#[must_use]
pub struct EditCmdJoinHandle(Option<JoinHandle<Result<(String, u32)>>>);
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 { pub struct AppState {
current_exercise_ind: usize, current_exercise_ind: usize,
exercises: Vec<Exercise>, exercises: Vec<Exercise>,
@ -61,12 +100,15 @@ pub struct AppState {
official_exercises: bool, official_exercises: bool,
cmd_runner: CmdRunner, cmd_runner: CmdRunner,
emit_file_links: bool, emit_file_links: bool,
zellij: bool,
open_pane: Option<(String, u32, usize)>,
} }
impl AppState { impl AppState {
pub fn new( pub fn new(
exercise_infos: Vec<ExerciseInfo>, exercise_infos: Vec<ExerciseInfo>,
final_message: &'static str, final_message: &'static str,
zellij: bool,
) -> Result<(Self, StateFileStatus)> { ) -> Result<(Self, StateFileStatus)> {
let cmd_runner = CmdRunner::build()?; let cmd_runner = CmdRunner::build()?;
let mut state_file = OpenOptions::new() let mut state_file = OpenOptions::new()
@ -175,6 +217,8 @@ impl AppState {
cmd_runner, cmd_runner,
// VS Code has its own file link handling // VS Code has its own file link handling
emit_file_links: env::var_os("TERM_PROGRAM").is_none_or(|v| v != "vscode"), emit_file_links: env::var_os("TERM_PROGRAM").is_none_or(|v| v != "vscode"),
zellij,
open_pane: None,
}; };
Ok((slf, state_file_status)) Ok((slf, state_file_status))
@ -553,6 +597,86 @@ impl AppState {
Ok(()) 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<EditCmdJoinHandle> {
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::<Vec<Pane>>(&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"; 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, official_exercises: true,
cmd_runner: CmdRunner::build().unwrap(), cmd_runner: CmdRunner::build().unwrap(),
emit_file_links: true, emit_file_links: true,
zellij: false,
open_pane: None,
}; };
let mut assert = |done: [bool; 3], expected: [Option<usize>; 3]| { let mut assert = |done: [bool; 3], expected: [Option<usize>; 3]| {

View File

@ -9,28 +9,33 @@ pub struct Args {
#[command(subcommand)] #[command(subcommand)]
pub command: Option<Command>, pub command: Option<Command>,
/// Manually run the current exercise using `r` in the watch mode. /// 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)] #[arg(long)]
pub manual_run: bool, 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)] #[derive(Subcommand)]
pub enum Command { pub enum Command {
/// Initialize the official Rustlings exercises /// Initialize the official Rustlings exercises
Init, 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 { Run {
/// The name of the exercise /// The name of the exercise
name: Option<String>, name: Option<String>,
}, },
/// Check all the exercises, marking them as done or pending accordingly. /// Check all the exercises, marking them as done or pending accordingly
CheckAll, CheckAll,
/// Reset a single exercise /// Reset a single exercise
Reset { Reset {
/// The name of the exercise /// The name of the exercise
name: String, 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 { Hint {
/// The name of the exercise /// The name of the exercise
name: Option<String>, name: Option<String>,

View File

@ -61,6 +61,7 @@ fn main() -> Result<ExitCode> {
let (mut app_state, state_file_status) = AppState::new( let (mut app_state, state_file_status) = AppState::new(
info_file.exercises, info_file.exercises,
info_file.final_message.unwrap_or_default(), info_file.final_message.unwrap_or_default(),
args.zellij,
)?; )?;
// Show the welcome message if the state file doesn't exist yet. // Show the welcome message if the state file doesn't exist yet.

View File

@ -78,14 +78,16 @@ impl<'a> WatchState<'a> {
// Ignore any input until running the exercise is done. // Ignore any input until running the exercise is done.
let _input_pause_guard = InputPauseGuard::scoped_pause(); let _input_pause_guard = InputPauseGuard::scoped_pause();
self.show_hint = false;
writeln!( writeln!(
stdout, stdout,
"\nChecking the exercise `{}`. Please wait…", "\nChecking the exercise `{}`. Please wait…",
self.app_state.current_exercise().name, self.app_state.current_exercise().name,
)?; )?;
let edit_cmd_handle = self.app_state.edit_cmd()?;
self.show_hint = false;
let success = self let success = self
.app_state .app_state
.current_exercise() .current_exercise()
@ -105,7 +107,9 @@ impl<'a> WatchState<'a> {
self.done_status = DoneStatus::Pending; self.done_status = DoneStatus::Pending;
} }
self.app_state.join_edit_cmd(edit_cmd_handle)?;
self.render(stdout)?; self.render(stdout)?;
Ok(()) Ok(())
} }
@ -127,9 +131,10 @@ impl<'a> WatchState<'a> {
match answer[0] { match answer[0] {
b'y' | b'Y' => { b'y' | b'Y' => {
self.app_state.close_pane()?;
self.app_state.reset_current_exercise()?; self.app_state.reset_current_exercise()?;
// The file watcher reruns the exercise otherwise. // The file watcher reruns the exercise otherwise
if self.manual_run { if self.manual_run {
self.run_current_exercise(stdout)?; self.run_current_exercise(stdout)?;
} }

1
tmp.txt Normal file
View File

@ -0,0 +1 @@
226.867688ms