Skip to content

Commit c9ccedc

Browse files
committed
Support VSCode and --edit-cmd as editor
1 parent 4d97c31 commit c9ccedc

7 files changed

Lines changed: 236 additions & 132 deletions

File tree

src/app_state.rs

Lines changed: 24 additions & 122 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,7 @@
11
use anyhow::{Context, Error, Result, bail};
22
use crossterm::{QueueableCommand, cursor, terminal};
3-
use serde::Deserialize;
43
use std::{
54
collections::HashSet,
6-
env,
75
fs::{File, OpenOptions},
86
io::{Read, Seek, StdoutLock, Write},
97
path::{MAIN_SEPARATOR_STR, Path},
@@ -12,12 +10,13 @@ use std::{
1210
atomic::{AtomicUsize, Ordering::Relaxed},
1311
mpsc,
1412
},
15-
thread::{self, JoinHandle},
13+
thread,
1614
};
1715

1816
use crate::{
1917
clear_terminal,
2018
cmd::CmdRunner,
19+
editor::{Editor, EditorJoinHandle},
2120
embedded::EMBEDDED_FILES,
2221
exercise::{Exercise, RunnableExercise},
2322
info_file::ExerciseInfo,
@@ -50,44 +49,6 @@ pub enum CheckProgress {
5049
Pending,
5150
}
5251

53-
#[derive(Deserialize)]
54-
struct Pane {
55-
id: u32,
56-
}
57-
58-
#[must_use]
59-
pub struct EditCmdJoinHandle(Option<JoinHandle<Result<(String, u32)>>>);
60-
61-
fn parse_pane_id(b: &[u8]) -> Option<(String, u32)> {
62-
// Remove newline
63-
let b = b.get("terminal_".len()..b.len().saturating_sub(1))?;
64-
let id_str = str::from_utf8(b).ok()?;
65-
66-
let (first, rest) = b.split_first()?;
67-
let mut id = u32::from(first - b'0');
68-
69-
for c in rest {
70-
id = 10 * id + u32::from(c - b'0');
71-
}
72-
73-
Some((id_str.to_owned(), id))
74-
}
75-
76-
fn close_pane(pane_id: &str) -> Result<()> {
77-
Command::new("zellij")
78-
.arg("action")
79-
.arg("close-pane")
80-
.arg("-p")
81-
.arg(pane_id)
82-
.stdin(Stdio::null())
83-
.stdout(Stdio::null())
84-
.stderr(Stdio::null())
85-
.status()
86-
.context("Failed to run `zellij action close-pane -p ID`")?;
87-
88-
Ok(())
89-
}
90-
9152
pub struct AppState {
9253
current_exercise_ind: usize,
9354
exercises: Vec<Exercise>,
@@ -100,15 +61,14 @@ pub struct AppState {
10061
official_exercises: bool,
10162
cmd_runner: CmdRunner,
10263
emit_file_links: bool,
103-
zellij: bool,
104-
open_pane: Option<(String, u32, usize)>,
64+
editor: Option<Editor>,
10565
}
10666

10767
impl AppState {
10868
pub fn new(
10969
exercise_infos: Vec<ExerciseInfo>,
11070
final_message: &'static str,
111-
zellij: bool,
71+
editor: Option<Editor>,
11272
) -> Result<(Self, StateFileStatus)> {
11373
let cmd_runner = CmdRunner::build()?;
11474
let mut state_file = OpenOptions::new()
@@ -150,7 +110,9 @@ impl AppState {
150110
Exercise {
151111
name: exercise_info.name,
152112
dir: exercise_info.dir,
153-
path: exercise_info.path(),
113+
// Leaking for `Editor::open`.
114+
// Leaking is fine since the app state exists until the end of the program.
115+
path: exercise_info.path().leak(),
154116
canonical_path,
155117
test: exercise_info.test,
156118
strict_clippy: exercise_info.strict_clippy,
@@ -216,9 +178,8 @@ impl AppState {
216178
official_exercises: !Path::new("info.toml").exists(),
217179
cmd_runner,
218180
// VS Code has its own file link handling
219-
emit_file_links: env::var_os("TERM_PROGRAM").is_none_or(|v| v != "vscode"),
220-
zellij,
221-
open_pane: None,
181+
emit_file_links: !matches!(editor, Some(Editor::VSCode)),
182+
editor,
222183
};
223184

224185
Ok((slf, state_file_status))
@@ -376,9 +337,9 @@ impl AppState {
376337
pub fn reset_current_exercise(&mut self) -> Result<&str> {
377338
self.set_pending(self.current_exercise_ind)?;
378339
let exercise = self.current_exercise();
379-
self.reset(self.current_exercise_ind, &exercise.path)?;
340+
self.reset(self.current_exercise_ind, exercise.path)?;
380341

381-
Ok(&exercise.path)
342+
Ok(exercise.path)
382343
}
383344

384345
// Reset the exercise by index and return its name.
@@ -389,7 +350,7 @@ impl AppState {
389350

390351
self.set_pending(exercise_ind)?;
391352
let exercise = &self.exercises[exercise_ind];
392-
self.reset(exercise_ind, &exercise.path)?;
353+
self.reset(exercise_ind, exercise.path)?;
393354

394355
Ok(exercise.name)
395356
}
@@ -598,81 +559,23 @@ impl AppState {
598559
Ok(())
599560
}
600561

601-
pub fn close_pane(&mut self) -> Result<()> {
602-
if let Some((pane_id_str, _, _)) = self.open_pane.take() {
603-
close_pane(&pane_id_str)?;
562+
pub fn open_editor(&mut self) -> Result<EditorJoinHandle> {
563+
if let Some(editor) = self.editor.take() {
564+
return editor.open(self.current_exercise_ind, self.current_exercise().path);
604565
}
605566

606-
Ok(())
567+
Ok(EditorJoinHandle::default())
607568
}
608569

609-
pub fn edit_cmd(&mut self) -> Result<EditCmdJoinHandle> {
610-
if !self.zellij {
611-
return Ok(EditCmdJoinHandle(None));
612-
}
613-
614-
let open_pane = self.open_pane.take();
615-
let current_exercise_ind = self.current_exercise_ind;
616-
let mut edit_cmd = Command::new("zellij");
617-
edit_cmd
618-
.arg("action")
619-
.arg("edit")
620-
.arg(&self.current_exercise().path)
621-
.stdin(Stdio::null())
622-
.stderr(Stdio::null());
623-
624-
let handle = thread::Builder::new()
625-
.spawn(move || {
626-
if let Some((pane_id_str, pane_id, exercise_ind)) = open_pane {
627-
if exercise_ind == current_exercise_ind {
628-
// Check if the pane is still open
629-
let mut output = Command::new("zellij")
630-
.arg("action")
631-
.arg("list-panes")
632-
.arg("-j")
633-
.stdin(Stdio::null())
634-
.stderr(Stdio::null())
635-
.output()
636-
.context("Failed to run `zellij action list-panes -j`")?;
637-
638-
if !output.status.success() {
639-
bail!("`zellij action list-panes -j` didn't exit successfully");
640-
}
641-
642-
// Remove newline
643-
output.stdout.pop();
644-
645-
let panes = serde_json::de::from_slice::<Vec<Pane>>(&output.stdout)
646-
.context(
647-
"Failed to parse the output of `zellij action list-panes -j`",
648-
)?;
570+
pub fn join_editor_handle(&mut self, handle: EditorJoinHandle) -> Result<()> {
571+
self.editor = handle.join()?;
649572

650-
if panes.iter().any(|pane| pane.id == pane_id) {
651-
return Ok((pane_id_str, pane_id));
652-
}
653-
} else {
654-
close_pane(&pane_id_str)?;
655-
}
656-
}
657-
658-
let output = edit_cmd.output()?;
659-
660-
if !output.status.success() {
661-
bail!("Failed to open a new Zellij editor pane");
662-
}
663-
664-
parse_pane_id(&output.stdout)
665-
.context("Failed to parse the ID of the new Zellij pane")
666-
})
667-
.context("Failed to spawn a thread to open and close Zellij panes")?;
668-
669-
Ok(EditCmdJoinHandle(Some(handle)))
573+
Ok(())
670574
}
671575

672-
pub fn join_edit_cmd(&mut self, handle: EditCmdJoinHandle) -> Result<()> {
673-
if let Some(handle) = handle.0 {
674-
let (pane_id_str, pane_id) = handle.join().unwrap()?;
675-
self.open_pane = Some((pane_id_str, pane_id, self.current_exercise_ind));
576+
pub fn close_editor(&mut self) -> Result<()> {
577+
if let Some(editor) = &mut self.editor {
578+
editor.close()?;
676579
}
677580

678581
Ok(())
@@ -711,7 +614,7 @@ mod tests {
711614
Exercise {
712615
name: "0",
713616
dir: None,
714-
path: String::from("exercises/0.rs"),
617+
path: "exercises/0.rs",
715618
canonical_path: None,
716619
test: false,
717620
strict_clippy: false,
@@ -732,8 +635,7 @@ mod tests {
732635
official_exercises: true,
733636
cmd_runner: CmdRunner::build().unwrap(),
734637
emit_file_links: true,
735-
zellij: false,
736-
open_pane: None,
638+
editor: None,
737639
};
738640

739641
let mut assert = |done: [bool; 3], expected: [Option<usize>; 3]| {

src/cli.rs

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,14 @@ use crate::dev::DevCommand;
88
pub struct Args {
99
#[command(subcommand)]
1010
pub command: Option<Command>,
11+
/// Open the current exercise by running the provided `EDIT_CMD EXERCISE_NAME`.
12+
/// Ignored in VS Code
13+
#[arg(long)]
14+
pub edit_cmd: Option<String>,
1115
/// Manually run the current exercise using `r` in the watch mode.
1216
/// Only use this if Rustlings fails to detect exercise file changes
1317
#[arg(long)]
1418
pub manual_run: bool,
15-
/// Open the current exercise in a new Zellij pane and close the last one if exists
16-
#[arg(long)]
17-
pub zellij: bool,
1819
}
1920

2021
#[derive(Subcommand)]

src/editor.rs

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
use std::{
2+
env,
3+
process::{Command, Stdio},
4+
thread::{self, JoinHandle},
5+
};
6+
7+
use anyhow::{Context, Result, bail};
8+
9+
mod zellij;
10+
11+
pub enum Editor {
12+
VSCode,
13+
Cmd(String, Vec<String>),
14+
Zellij(Option<(String, u32, usize)>),
15+
}
16+
17+
impl Editor {
18+
pub fn new(cmd: Option<String>) -> Option<Self> {
19+
if env::var_os("TERM_PROGRAM").is_some_and(|v| v == "vscode") {
20+
return Some(Self::VSCode);
21+
}
22+
23+
if let Some(cmd) = cmd {
24+
todo!()
25+
}
26+
27+
if env::var_os("ZELLIJ").is_some() {
28+
return Some(Self::Zellij(None));
29+
}
30+
31+
None
32+
}
33+
34+
pub fn open(
35+
self,
36+
exercise_ind: usize,
37+
exercise_path: &'static str,
38+
) -> Result<EditorJoinHandle> {
39+
let handle = thread::Builder::new()
40+
.spawn(move || match self {
41+
Editor::VSCode => {
42+
if !Command::new("code")
43+
.arg(exercise_path)
44+
.stdin(Stdio::null())
45+
.stdout(Stdio::null())
46+
.stderr(Stdio::null())
47+
.status()
48+
.context("Failed to run `code` to open the current exercise file")?
49+
.success()
50+
{
51+
bail!("Failed to run `code PATH` to open the current exercise file");
52+
}
53+
54+
Ok(Self::VSCode)
55+
}
56+
Editor::Cmd(program, args) => {
57+
if !Command::new("code")
58+
.arg(exercise_path)
59+
.stdin(Stdio::null())
60+
.stdout(Stdio::null())
61+
.stderr(Stdio::null())
62+
.status()
63+
.context("Failed to run the command from `--edit-cmd`")
64+
.is_ok_and(|status| status.success())
65+
{
66+
bail!("Failed to run the command from `--edit-cmd`");
67+
}
68+
69+
Ok(Self::Cmd(program, args))
70+
}
71+
Editor::Zellij(open_pane) => {
72+
if let Some((pane_id_str, pane_id, open_exercise_ind)) = open_pane {
73+
if open_exercise_ind == exercise_ind {
74+
if zellij::pane_open(pane_id)? {
75+
return Ok(Self::Zellij(Some((
76+
pane_id_str,
77+
pane_id,
78+
exercise_ind,
79+
))));
80+
}
81+
} else {
82+
zellij::close_pane(&pane_id_str)?;
83+
}
84+
}
85+
86+
let output = Command::new("zellij")
87+
.arg("action")
88+
.arg("edit")
89+
.arg(exercise_path)
90+
.stdin(Stdio::null())
91+
.stderr(Stdio::null())
92+
.output()
93+
.context("Failed to run `zellij`")?;
94+
95+
if !output.status.success() {
96+
bail!("Failed to open a new Zellij editor pane");
97+
}
98+
99+
let (pane_id_str, pane_id) = zellij::parse_pane_id(&output.stdout)
100+
.context("Failed to parse the ID of the new Zellij pane")?;
101+
102+
Ok(Self::Zellij(Some((pane_id_str, pane_id, exercise_ind))))
103+
}
104+
})
105+
.context("Failed to spawn a thread to open the editor")?;
106+
107+
Ok(EditorJoinHandle(Some(handle)))
108+
}
109+
110+
pub fn close(&mut self) -> Result<()> {
111+
match self {
112+
Editor::VSCode | Editor::Cmd(_, _) => (),
113+
Editor::Zellij(open_pane) => {
114+
if let Some((pane_id_str, _, _)) = open_pane.take() {
115+
zellij::close_pane(&pane_id_str)?;
116+
}
117+
}
118+
}
119+
120+
Ok(())
121+
}
122+
}
123+
124+
#[must_use]
125+
#[derive(Default)]
126+
pub struct EditorJoinHandle(Option<JoinHandle<Result<Editor>>>);
127+
128+
impl EditorJoinHandle {
129+
pub fn join(self) -> Result<Option<Editor>> {
130+
if let Some(handle) = self.0 {
131+
let editor = handle.join().unwrap()?;
132+
return Ok(Some(editor));
133+
}
134+
135+
Ok(None)
136+
}
137+
}

0 commit comments

Comments
 (0)