1use serde::{Deserialize, Serialize};
4
5#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
7pub enum Editor {
8 #[default]
10 None,
11 Helix,
12 Neovim,
13 Vim,
14 Nano,
15 Micro,
16 Emacs,
17 VSCode,
18 Zed,
19 Sublime,
20 RustRover,
21 IntelliJIdea,
22 WebStorm,
23 PyCharm,
24 GoLand,
25 CLion,
26 Fleet,
27 AndroidStudio,
28 Custom(String),
30}
31
32pub const EDITOR_NAMES: &[&str] = &[
34 "Helix",
35 "Neovim",
36 "Vim",
37 "Nano",
38 "Micro",
39 "Emacs",
40 "VS Code",
41 "Zed",
42 "Sublime Text",
43 "RustRover",
44 "IntelliJ IDEA",
45 "WebStorm",
46 "PyCharm",
47 "GoLand",
48 "CLion",
49 "Fleet",
50 "Android Studio",
51];
52
53impl Editor {
54 pub fn binary(&self) -> Option<String> {
56 self.binary_candidates().into_iter().next()
57 }
58
59 pub fn binary_candidates(&self) -> Vec<String> {
65 match self {
66 Editor::None => vec![],
67 Editor::Helix => {
68 if cfg!(target_os = "macos") {
73 vec!["hx".into(), "helix".into()]
74 } else {
75 vec!["helix".into(), "hx".into()]
76 }
77 }
78 Editor::Neovim => vec!["nvim".into()],
79 Editor::Vim => vec!["vim".into()],
80 Editor::Nano => vec!["nano".into()],
81 Editor::Micro => vec!["micro".into()],
82 Editor::Emacs => vec!["emacs".into()],
83 Editor::VSCode => vec!["code --reuse-window".into()],
84 Editor::Zed => vec!["zed".into()],
85 Editor::Sublime => vec!["subl".into()],
86 Editor::RustRover => vec!["rustrover".into()],
87 Editor::IntelliJIdea => vec!["idea".into()],
88 Editor::WebStorm => vec!["webstorm".into()],
89 Editor::PyCharm => vec!["pycharm".into()],
90 Editor::GoLand => vec!["goland".into()],
91 Editor::CLion => vec!["clion".into()],
92 Editor::Fleet => vec!["fleet".into()],
93 Editor::AndroidStudio => vec!["studio".into()],
94 Editor::Custom(s) => vec![s.clone()],
95 }
96 }
97
98 pub fn is_terminal_editor(&self) -> bool {
104 matches!(
105 self,
106 Editor::Helix
107 | Editor::Neovim
108 | Editor::Vim
109 | Editor::Nano
110 | Editor::Micro
111 | Editor::Emacs
112 )
113 }
114
115 #[cfg(target_os = "macos")]
118 fn macos_app_name(&self) -> Option<&'static str> {
119 match self {
120 Editor::VSCode => Some("Visual Studio Code"),
121 Editor::Zed => Some("Zed"),
122 Editor::Sublime => Some("Sublime Text"),
123 Editor::RustRover => Some("RustRover"),
124 Editor::IntelliJIdea => Some("IntelliJ IDEA"),
125 Editor::WebStorm => Some("WebStorm"),
126 Editor::PyCharm => Some("PyCharm"),
127 Editor::GoLand => Some("GoLand"),
128 Editor::CLion => Some("CLion"),
129 Editor::Fleet => Some("Fleet"),
130 Editor::AndroidStudio => Some("Android Studio"),
131 _ => None,
134 }
135 }
136
137 pub fn display_name(&self) -> &str {
139 match self {
140 Editor::None => "None",
141 Editor::Helix => "Helix",
142 Editor::Neovim => "Neovim",
143 Editor::Vim => "Vim",
144 Editor::Nano => "Nano",
145 Editor::Micro => "Micro",
146 Editor::Emacs => "Emacs",
147 Editor::VSCode => "VS Code",
148 Editor::Zed => "Zed",
149 Editor::Sublime => "Sublime Text",
150 Editor::RustRover => "RustRover",
151 Editor::IntelliJIdea => "IntelliJ IDEA",
152 Editor::WebStorm => "WebStorm",
153 Editor::PyCharm => "PyCharm",
154 Editor::GoLand => "GoLand",
155 Editor::CLion => "CLion",
156 Editor::Fleet => "Fleet",
157 Editor::AndroidStudio => "Android Studio",
158 Editor::Custom(_) => "Custom",
159 }
160 }
161
162 pub fn from_index(index: usize) -> Self {
164 match index {
165 0 => Editor::Helix,
166 1 => Editor::Neovim,
167 2 => Editor::Vim,
168 3 => Editor::Nano,
169 4 => Editor::Micro,
170 5 => Editor::Emacs,
171 6 => Editor::VSCode,
172 7 => Editor::Zed,
173 8 => Editor::Sublime,
174 9 => Editor::RustRover,
175 10 => Editor::IntelliJIdea,
176 11 => Editor::WebStorm,
177 12 => Editor::PyCharm,
178 13 => Editor::GoLand,
179 14 => Editor::CLion,
180 15 => Editor::Fleet,
181 16 => Editor::AndroidStudio,
182 _ => Editor::None,
183 }
184 }
185
186 pub fn open_file(&self, file_path: &std::path::Path) -> anyhow::Result<()> {
198 #[cfg(target_os = "macos")]
199 if let Some(app_name) = self.macos_app_name() {
200 std::process::Command::new("open")
201 .arg("-a")
202 .arg(app_name)
203 .arg(file_path)
204 .stdin(std::process::Stdio::null())
205 .stdout(std::process::Stdio::null())
206 .stderr(std::process::Stdio::null())
207 .spawn()
208 .map_err(|e| anyhow::anyhow!("failed to run 'open -a {}': {}", app_name, e))?;
209 return Ok(());
210 }
211
212 let candidates = self.binary_candidates();
214 if candidates.is_empty() {
215 anyhow::bail!("no editor configured — select one from the editor picker");
216 }
217 let mut last_err =
218 anyhow::anyhow!("no editor configured — select one from the editor picker");
219 for bin in &candidates {
220 let parts: Vec<&str> = bin.split_whitespace().collect();
221 let Some((cmd, args)) = parts.split_first() else {
222 continue;
223 };
224 match std::process::Command::new(cmd)
225 .args(args.iter())
226 .arg(file_path)
227 .stdin(std::process::Stdio::null())
228 .stdout(std::process::Stdio::null())
229 .stderr(std::process::Stdio::null())
230 .spawn()
231 {
232 Ok(_) => return Ok(()),
233 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
234 last_err = anyhow::anyhow!("'{}' not found in PATH", cmd);
235 continue;
236 }
237 Err(e) => return Err(anyhow::anyhow!("failed to launch '{}': {}", cmd, e)),
238 }
239 }
240 Err(last_err)
241 }
242
243 pub fn open_file_or_default(&self, file_path: &std::path::Path) -> anyhow::Result<String> {
250 if !matches!(self, Editor::None) {
251 match self.open_file(file_path) {
252 Ok(()) => return Ok(self.display_name().to_string()),
253 Err(e) => {
254 tracing::warn!(
255 "configured editor failed ({e}), falling back to system default"
256 );
257 }
258 }
259 }
260 open_file_default(file_path)?;
261 Ok("system default".to_string())
262 }
263}
264
265impl std::fmt::Display for Editor {
266 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
267 write!(f, "{}", self.display_name())
268 }
269}
270
271pub fn open_file_default(file_path: &std::path::Path) -> anyhow::Result<()> {
273 #[cfg(target_os = "linux")]
274 {
275 std::process::Command::new("xdg-open")
276 .arg(file_path)
277 .spawn()
278 .map_err(|e| anyhow::anyhow!("xdg-open failed: {}", e))?;
279 }
280 #[cfg(target_os = "macos")]
281 {
282 std::process::Command::new("open")
283 .arg(file_path)
284 .spawn()
285 .map_err(|e| anyhow::anyhow!("open failed: {}", e))?;
286 }
287 #[cfg(target_os = "windows")]
288 {
289 std::process::Command::new("cmd")
290 .args(["/C", "start", ""])
291 .arg(file_path)
292 .spawn()
293 .map_err(|e| anyhow::anyhow!("start failed: {}", e))?;
294 }
295 Ok(())
296}
297
298pub fn show_in_folder(file_path: &std::path::Path) -> anyhow::Result<()> {
300 let folder = if file_path.is_file() {
301 file_path.parent().unwrap_or(file_path)
302 } else {
303 file_path
304 };
305 open_file_default(folder)
306}
307
308#[cfg(test)]
309mod tests {
310 use super::*;
311
312 #[test]
313 fn editor_binary_returns_correct_values() {
314 assert_eq!(Editor::Neovim.binary(), Some("nvim".into()));
315 assert_eq!(Editor::VSCode.binary(), Some("code --reuse-window".into()));
316 assert_eq!(Editor::None.binary(), None);
317 assert_eq!(
318 Editor::Custom("my-editor".into()).binary(),
319 Some("my-editor".into())
320 );
321 }
322
323 #[test]
324 fn helix_binary_candidates_platform_default_first() {
325 let candidates = Editor::Helix.binary_candidates();
326 assert_eq!(candidates.len(), 2);
327 #[cfg(target_os = "macos")]
328 assert_eq!(candidates[0], "hx");
329 #[cfg(not(target_os = "macos"))]
330 assert_eq!(candidates[0], "helix");
331 }
332
333 #[test]
334 fn is_terminal_editor_classifies_correctly() {
335 assert!(Editor::Helix.is_terminal_editor());
336 assert!(Editor::Neovim.is_terminal_editor());
337 assert!(Editor::Vim.is_terminal_editor());
338 assert!(Editor::Nano.is_terminal_editor());
339 assert!(!Editor::VSCode.is_terminal_editor());
340 assert!(!Editor::Zed.is_terminal_editor());
341 assert!(!Editor::IntelliJIdea.is_terminal_editor());
342 assert!(!Editor::None.is_terminal_editor());
343 }
344
345 #[test]
346 fn binary_candidates_single_for_most_editors() {
347 assert_eq!(Editor::Neovim.binary_candidates(), vec!["nvim"]);
348 assert_eq!(
349 Editor::VSCode.binary_candidates(),
350 vec!["code --reuse-window"]
351 );
352 assert_eq!(Editor::Zed.binary_candidates(), vec!["zed"]);
353 }
354
355 #[test]
356 fn editor_display_name() {
357 assert_eq!(Editor::VSCode.display_name(), "VS Code");
358 assert_eq!(Editor::IntelliJIdea.display_name(), "IntelliJ IDEA");
359 assert_eq!(Editor::None.display_name(), "None");
360 }
361
362 #[test]
363 fn editor_from_index_round_trips() {
364 for i in 0..EDITOR_NAMES.len() {
365 let editor = Editor::from_index(i);
366 assert_ne!(editor, Editor::None, "index {i} should not be None");
367 }
368 assert_eq!(Editor::from_index(999), Editor::None);
369 }
370
371 #[test]
372 fn editor_names_count_matches() {
373 assert_eq!(EDITOR_NAMES.len(), 17);
374 }
375
376 #[test]
377 fn editor_serialize_deserialize() {
378 let editor = Editor::VSCode;
379 let json = serde_json::to_string(&editor).unwrap();
380 let back: Editor = serde_json::from_str(&json).unwrap();
381 assert_eq!(back, Editor::VSCode);
382 }
383
384 #[test]
385 fn vscode_binary_includes_reuse_window() {
386 assert_eq!(Editor::VSCode.binary(), Some("code --reuse-window".into()));
387 }
388
389 #[test]
390 fn editor_display_implements_display_trait() {
391 let editor = Editor::Neovim;
392 assert_eq!(format!("{editor}"), "Neovim");
393 }
394
395 #[test]
396 fn custom_editor_preserves_value() {
397 let editor = Editor::Custom("/usr/bin/my-editor --flag".into());
398 assert_eq!(editor.binary(), Some("/usr/bin/my-editor --flag".into()));
399 assert_eq!(editor.display_name(), "Custom");
400 }
401}