use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum Editor {
#[default]
None,
Helix,
Neovim,
Vim,
Nano,
Micro,
Emacs,
VSCode,
Zed,
Sublime,
RustRover,
IntelliJIdea,
WebStorm,
PyCharm,
GoLand,
CLion,
Fleet,
AndroidStudio,
Custom(String),
}
pub const EDITOR_NAMES: &[&str] = &[
"Helix",
"Neovim",
"Vim",
"Nano",
"Micro",
"Emacs",
"VS Code",
"Zed",
"Sublime Text",
"RustRover",
"IntelliJ IDEA",
"WebStorm",
"PyCharm",
"GoLand",
"CLion",
"Fleet",
"Android Studio",
];
impl Editor {
pub fn binary(&self) -> Option<String> {
match self {
Editor::None => Option::None,
Editor::Helix => Some(Self::resolve_helix()),
Editor::Neovim => Some("nvim".into()),
Editor::Vim => Some("vim".into()),
Editor::Nano => Some("nano".into()),
Editor::Micro => Some("micro".into()),
Editor::Emacs => Some("emacs".into()),
Editor::VSCode => Some("code --reuse-window".into()),
Editor::Zed => Some("zed".into()),
Editor::Sublime => Some("subl".into()),
Editor::RustRover => Some("rustrover".into()),
Editor::IntelliJIdea => Some("idea".into()),
Editor::WebStorm => Some("webstorm".into()),
Editor::PyCharm => Some("pycharm".into()),
Editor::GoLand => Some("goland".into()),
Editor::CLion => Some("clion".into()),
Editor::Fleet => Some("fleet".into()),
Editor::AndroidStudio => Some("studio".into()),
Editor::Custom(s) => Some(s.clone()),
}
}
pub fn display_name(&self) -> &str {
match self {
Editor::None => "None",
Editor::Helix => "Helix",
Editor::Neovim => "Neovim",
Editor::Vim => "Vim",
Editor::Nano => "Nano",
Editor::Micro => "Micro",
Editor::Emacs => "Emacs",
Editor::VSCode => "VS Code",
Editor::Zed => "Zed",
Editor::Sublime => "Sublime Text",
Editor::RustRover => "RustRover",
Editor::IntelliJIdea => "IntelliJ IDEA",
Editor::WebStorm => "WebStorm",
Editor::PyCharm => "PyCharm",
Editor::GoLand => "GoLand",
Editor::CLion => "CLion",
Editor::Fleet => "Fleet",
Editor::AndroidStudio => "Android Studio",
Editor::Custom(_) => "Custom",
}
}
pub fn from_index(index: usize) -> Self {
match index {
0 => Editor::Helix,
1 => Editor::Neovim,
2 => Editor::Vim,
3 => Editor::Nano,
4 => Editor::Micro,
5 => Editor::Emacs,
6 => Editor::VSCode,
7 => Editor::Zed,
8 => Editor::Sublime,
9 => Editor::RustRover,
10 => Editor::IntelliJIdea,
11 => Editor::WebStorm,
12 => Editor::PyCharm,
13 => Editor::GoLand,
14 => Editor::CLion,
15 => Editor::Fleet,
16 => Editor::AndroidStudio,
_ => Editor::None,
}
}
pub fn open_file(&self, file_path: &std::path::Path) -> anyhow::Result<()> {
let bin = self.binary().ok_or_else(|| {
anyhow::anyhow!("no editor configured — select one from the editor picker")
})?;
let parts: Vec<&str> = bin.split_whitespace().collect();
let (cmd, args) = parts
.split_first()
.ok_or_else(|| anyhow::anyhow!("empty editor binary"))?;
let mut command = std::process::Command::new(cmd);
command.args(args.iter());
command.arg(file_path);
command.stdin(std::process::Stdio::null());
command.stdout(std::process::Stdio::null());
command.stderr(std::process::Stdio::null());
command
.spawn()
.map_err(|e| anyhow::anyhow!("failed to launch '{}': {}", cmd, e))?;
Ok(())
}
fn resolve_helix() -> String {
for candidate in &["hx", "helix"] {
if std::process::Command::new(candidate)
.arg("--version")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.is_ok()
{
return candidate.to_string();
}
}
"hx".to_string()
}
}
impl std::fmt::Display for Editor {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.display_name())
}
}
pub fn open_file_default(file_path: &std::path::Path) -> anyhow::Result<()> {
#[cfg(target_os = "linux")]
{
std::process::Command::new("xdg-open")
.arg(file_path)
.spawn()
.map_err(|e| anyhow::anyhow!("xdg-open failed: {}", e))?;
}
#[cfg(target_os = "macos")]
{
std::process::Command::new("open")
.arg(file_path)
.spawn()
.map_err(|e| anyhow::anyhow!("open failed: {}", e))?;
}
#[cfg(target_os = "windows")]
{
std::process::Command::new("cmd")
.args(["/C", "start", ""])
.arg(file_path)
.spawn()
.map_err(|e| anyhow::anyhow!("start failed: {}", e))?;
}
Ok(())
}
pub fn show_in_folder(file_path: &std::path::Path) -> anyhow::Result<()> {
let folder = if file_path.is_file() {
file_path.parent().unwrap_or(file_path)
} else {
file_path
};
open_file_default(folder)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn editor_binary_returns_correct_values() {
assert_eq!(Editor::Neovim.binary(), Some("nvim".into()));
assert_eq!(Editor::VSCode.binary(), Some("code --reuse-window".into()));
assert_eq!(Editor::None.binary(), None);
assert_eq!(
Editor::Custom("my-editor".into()).binary(),
Some("my-editor".into())
);
}
#[test]
fn editor_display_name() {
assert_eq!(Editor::VSCode.display_name(), "VS Code");
assert_eq!(Editor::IntelliJIdea.display_name(), "IntelliJ IDEA");
assert_eq!(Editor::None.display_name(), "None");
}
#[test]
fn editor_from_index_round_trips() {
for i in 0..EDITOR_NAMES.len() {
let editor = Editor::from_index(i);
assert_ne!(editor, Editor::None, "index {i} should not be None");
}
assert_eq!(Editor::from_index(999), Editor::None);
}
#[test]
fn editor_names_count_matches() {
assert_eq!(EDITOR_NAMES.len(), 17);
}
#[test]
fn editor_serialize_deserialize() {
let editor = Editor::VSCode;
let json = serde_json::to_string(&editor).unwrap();
let back: Editor = serde_json::from_str(&json).unwrap();
assert_eq!(back, Editor::VSCode);
}
#[test]
fn vscode_binary_includes_reuse_window() {
assert_eq!(Editor::VSCode.binary(), Some("code --reuse-window".into()));
}
#[test]
fn editor_display_implements_display_trait() {
let editor = Editor::Neovim;
assert_eq!(format!("{editor}"), "Neovim");
}
#[test]
fn custom_editor_preserves_value() {
let editor = Editor::Custom("/usr/bin/my-editor --flag".into());
assert_eq!(editor.binary(), Some("/usr/bin/my-editor --flag".into()));
assert_eq!(editor.display_name(), "Custom");
}
}