From 80621093ca8187b2f47a02b76221e5d1fc7c27f4 Mon Sep 17 00:00:00 2001 From: harehare Date: Thu, 1 Jan 2026 22:09:56 +0900 Subject: [PATCH 1/2] =?UTF-8?q?=E2=9C=A8=20feat(repl):=20add=20/edit=20com?= =?UTF-8?q?mand=20to=20open=20external=20editor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a new /edit command to the mq REPL that allows users to edit mq code in their preferred external editor ($EDITOR or $VISUAL environment variable, defaulting to vi). The command creates a temporary file, opens it in the editor, and evaluates the edited content when the editor closes. Also adds Alt+O keybinding for quick access. --- Cargo.lock | 1 + crates/mq-repl/Cargo.toml | 1 + crates/mq-repl/src/command_context.rs | 63 ++++++++++++++++++++++++++- crates/mq-repl/src/repl.rs | 7 ++- 4 files changed, 70 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5422146d4..93ca541cb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2749,6 +2749,7 @@ dependencies = [ "rustyline", "scopeguard", "strum", + "tempfile", ] [[package]] diff --git a/crates/mq-repl/Cargo.toml b/crates/mq-repl/Cargo.toml index 850a6772c..37b0bd6b1 100644 --- a/crates/mq-repl/Cargo.toml +++ b/crates/mq-repl/Cargo.toml @@ -22,6 +22,7 @@ mq-markdown = {workspace = true} regex-lite = {workspace = true} rustyline = {workspace = true, default-features = false, features = ["custom-bindings", "with-file-history"]} strum = {workspace = true, features = ["derive"]} +tempfile = {workspace = true} [target.'cfg(not(target_os = "android"))'.dependencies] arboard = {workspace = true, optional = true} diff --git a/crates/mq-repl/src/command_context.rs b/crates/mq-repl/src/command_context.rs index acbb38078..1bf139853 100644 --- a/crates/mq-repl/src/command_context.rs +++ b/crates/mq-repl/src/command_context.rs @@ -1,4 +1,4 @@ -use std::{fmt, fs}; +use std::{fmt, fs, io::Write, process::Command as ProcessCommand}; #[cfg(all(feature = "clipboard", not(target_os = "android")))] use arboard::Clipboard; @@ -15,6 +15,7 @@ pub enum CommandOutput { #[derive(Debug, Clone, strum::EnumIter)] pub enum Command { Copy, + Edit, Env(String, String), Help, Quit, @@ -29,6 +30,7 @@ impl fmt::Display for Command { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Command::Copy => write!(f, "/copy"), + Command::Edit => write!(f, "/edit"), Command::Env(_, _) => { write!(f, "/env") } @@ -47,6 +49,7 @@ impl Command { pub fn help(&self) -> String { match self { Command::Copy => format!("{:<12}{}", "/copy", "Copy the execution results to the clipboard"), + Command::Edit => format!("{:<12}{}", "/edit", "Edit the current buffer in external editor"), Command::Env(_, _) => { format!("{:<12}{}", "/env", "Set environment variables (key value)") } @@ -65,6 +68,7 @@ impl From for Command { fn from(s: String) -> Self { match s.as_str().split_whitespace().collect::>().as_slice() { ["/copy"] => Command::Copy, + ["/edit"] => Command::Edit, ["/env", name, value] => Command::Env(name.to_string(), value.to_string()), ["/help"] => Command::Help, ["/quit"] => Command::Quit, @@ -135,6 +139,60 @@ impl CommandContext { Err(miette!("Clipboard functionality is not available on this platform")) } } + Command::Edit => { + // Get editor from environment variables + let editor = std::env::var("EDITOR") + .or_else(|_| std::env::var("VISUAL")) + .unwrap_or_else(|_| "vi".to_string()); + + // Create a temporary file + let mut temp_file = tempfile::Builder::new() + .prefix("mq-edit-") + .suffix(".mq") + .tempfile() + .into_diagnostic()?; + + // Write current buffer to temp file (empty for now) + temp_file.write_all(b"").into_diagnostic()?; + temp_file.flush().into_diagnostic()?; + + let temp_path = temp_file.path().to_path_buf(); + + // Close the file before opening in editor + drop(temp_file); + + // Launch the editor + let status = ProcessCommand::new(&editor) + .arg(&temp_path) + .status() + .into_diagnostic()?; + + if !status.success() { + return Err(miette!("Editor exited with non-zero status")); + } + + // Read the edited content + let edited_content = fs::read_to_string(&temp_path).into_diagnostic()?; + + // Clean up temp file + fs::remove_file(&temp_path).ok(); + + // Evaluate the edited content + let code = edited_content.trim(); + if code.is_empty() { + Ok(CommandOutput::None) + } else { + let result = self.engine.eval(code, self.input.clone().into_iter()); + + result + .map(|result| { + self.hir.add_line_of_code(self.source_id, self.scope_id, code); + self.input = result.values().clone(); + Ok(CommandOutput::Value(result.values().clone())) + }) + .map_err(|e| *e)? + } + } Command::Env(name, value) => { unsafe { std::env::set_var(name, value) }; Ok(CommandOutput::None) @@ -255,6 +313,7 @@ mod tests { #[test] fn test_command_from_string() { assert!(matches!(Command::from("/copy".to_string()), Command::Copy)); + assert!(matches!(Command::from("/edit".to_string()), Command::Edit)); assert!(matches!(Command::from("/help".to_string()), Command::Help)); assert!(matches!(Command::from("/quit".to_string()), Command::Quit)); assert!(matches!(Command::from("/vars".to_string()), Command::Vars)); @@ -283,6 +342,7 @@ mod tests { #[test] fn test_command_display() { assert_eq!(format!("{}", Command::Copy), "/copy"); + assert_eq!(format!("{}", Command::Edit), "/edit"); assert_eq!(format!("{}", Command::Help), "/help"); assert_eq!(format!("{}", Command::Quit), "/quit"); assert_eq!(format!("{}", Command::Vars), "/vars"); @@ -303,6 +363,7 @@ mod tests { match cmd { Command::Copy => assert!(help.contains("/copy")), + Command::Edit => assert!(help.contains("/edit")), Command::Help => assert!(help.contains("/help")), Command::Quit => assert!(help.contains("/quit")), Command::Vars => assert!(help.contains("/vars")), diff --git a/crates/mq-repl/src/repl.rs b/crates/mq-repl/src/repl.rs index 7a15961e0..11a374210 100644 --- a/crates/mq-repl/src/repl.rs +++ b/crates/mq-repl/src/repl.rs @@ -16,7 +16,7 @@ use crate::command_context::{Command, CommandContext, CommandOutput}; fn highlight_mq_syntax(line: &str) -> Cow<'_, str> { let mut result = line.to_string(); - let commands_pattern = r"^(/copy|/env|/help|/quit|/load|/vars|/version)\b"; + let commands_pattern = r"^(/copy|/edit|/env|/help|/quit|/load|/vars|/version)\b"; if let Ok(re) = regex_lite::Regex::new(commands_pattern) { result = re .replace_all(&result, |caps: ®ex_lite::Captures| { @@ -232,6 +232,11 @@ impl Repl { KeyEvent(KeyCode::Char('c'), Modifiers::ALT), Cmd::Kill(Movement::WholeBuffer), ); + // Bind Esc+O (Alt+O) to open editor + editor.bind_sequence( + KeyEvent(KeyCode::Char('o'), Modifiers::ALT), + Cmd::Insert(1, "/edit\n".to_string()), + ); let config_dir = config_dir(); From 25bd7839d4f2c2e520047dca74dd4399afcf9204 Mon Sep 17 00:00:00 2001 From: Takahiro Sato Date: Fri, 2 Jan 2026 08:30:26 +0900 Subject: [PATCH 2/2] Update crates/mq-repl/src/command_context.rs --- crates/mq-repl/src/command_context.rs | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/crates/mq-repl/src/command_context.rs b/crates/mq-repl/src/command_context.rs index 1bf139853..f4991d043 100644 --- a/crates/mq-repl/src/command_context.rs +++ b/crates/mq-repl/src/command_context.rs @@ -182,15 +182,12 @@ impl CommandContext { if code.is_empty() { Ok(CommandOutput::None) } else { - let result = self.engine.eval(code, self.input.clone().into_iter()); - - result - .map(|result| { - self.hir.add_line_of_code(self.source_id, self.scope_id, code); - self.input = result.values().clone(); - Ok(CommandOutput::Value(result.values().clone())) - }) - .map_err(|e| *e)? + let eval_result = self.engine.eval(code, self.input.clone().into_iter()).map_err(|e| *e)?; + + self.hir.add_line_of_code(self.source_id, self.scope_id, code); + self.input = eval_result.values().clone(); + + Ok(CommandOutput::Value(eval_result.values().clone())) } } Command::Env(name, value) => {