use crate::app::terminal::TerminalGuard;
use crate::domain::Msg;
use anyhow::Result;
use crossterm::event::EventStream;
use std::path::{Path, PathBuf};
fn resolve_editor() -> Option<String> {
["VISUAL", "EDITOR"].iter().find_map(|var| {
std::env::var(var)
.ok()
.map(|v| v.trim().to_string())
.filter(|v| !v.is_empty())
})
}
fn write_compose_file(text: &str) -> std::io::Result<PathBuf> {
let dir = crate::utils::private_temp_dir()?;
let path = dir.join(format!("compose-{}.md", std::process::id()));
std::fs::write(&path, text)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))?;
}
Ok(path)
}
fn run_editor_blocking(editor: &str, path: &Path) -> std::io::Result<std::process::ExitStatus> {
#[cfg(unix)]
{
let quoted = format!("'{}'", path.display().to_string().replace('\'', r"'\''"));
std::process::Command::new("sh")
.arg("-c")
.arg(format!("{editor} {quoted}"))
.status()
}
#[cfg(windows)]
{
std::process::Command::new("cmd")
.arg("/C")
.arg(format!("{editor} \"{}\"", path.display()))
.status()
}
}
fn read_back(path: &Path) -> std::io::Result<String> {
let bytes = std::fs::read(path)?;
let mut text = String::from_utf8_lossy(&bytes).into_owned();
if text.ends_with('\n') {
text.pop();
if text.ends_with('\r') {
text.pop();
}
}
Ok(text)
}
pub(crate) async fn compose_in_editor(
terminal: &mut Option<TerminalGuard>,
events: &mut Option<EventStream>,
text: String,
) -> Result<Msg> {
let Some(editor) = resolve_editor() else {
return Ok(Msg::TransientStatus {
text: "No editor configured: set $VISUAL or $EDITOR.".to_string(),
});
};
let path = match write_compose_file(&text) {
Ok(p) => p,
Err(err) => {
return Ok(Msg::TransientStatus {
text: format!("Could not stage the compose file: {err}"),
});
},
};
events.take();
if let Some(mut guard) = terminal.take() {
guard.restore_now();
}
let editor_for_task = editor.clone();
let path_for_task = path.clone();
let status =
tokio::task::spawn_blocking(move || run_editor_blocking(&editor_for_task, &path_for_task))
.await;
*terminal = Some(TerminalGuard::setup()?);
*events = Some(EventStream::new());
let msg = match status {
Ok(Ok(st)) if st.success() => match read_back(&path) {
Ok(text) => Msg::EditorReturned { text: Some(text) },
Err(err) => Msg::TransientStatus {
text: format!("Editor exited but the draft could not be read back: {err}"),
},
},
Ok(Ok(st)) => Msg::TransientStatus {
text: format!("Editor exited with {st}; draft left unchanged."),
},
Ok(Err(err)) => Msg::TransientStatus {
text: format!("Could not launch '{editor}': {err}"),
},
Err(err) => Msg::TransientStatus {
text: format!("Editor task failed: {err}"),
},
};
let _ = std::fs::remove_file(&path);
Ok(msg)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn read_back_strips_one_trailing_newline() {
let dir = std::env::temp_dir();
let path = dir.join(format!("mermaid-editor-readback-{}", std::process::id()));
std::fs::write(&path, "draft body\n").unwrap();
assert_eq!(read_back(&path).unwrap(), "draft body");
std::fs::write(&path, "keep\n\n").unwrap();
assert_eq!(read_back(&path).unwrap(), "keep\n");
std::fs::write(&path, "crlf\r\n").unwrap();
assert_eq!(read_back(&path).unwrap(), "crlf");
std::fs::write(&path, "").unwrap();
assert_eq!(read_back(&path).unwrap(), "");
let _ = std::fs::remove_file(&path);
}
#[test]
fn compose_file_is_owner_only() {
let path = write_compose_file("secret draft").unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = std::fs::metadata(&path).unwrap().permissions().mode();
assert_eq!(mode & 0o777, 0o600);
}
assert_eq!(std::fs::read_to_string(&path).unwrap(), "secret draft");
let _ = std::fs::remove_file(&path);
}
}