Skip to main content

gitkraft_core/features/
editor.rs

1//! Editor configuration — which editor/IDE to launch for file editing.
2
3use serde::{Deserialize, Serialize};
4
5/// Supported editors and IDEs.
6#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
7pub enum Editor {
8    /// No editor configured.
9    #[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    /// A user-supplied binary name or path.
29    Custom(String),
30}
31
32/// All named editor variants (excluding None and Custom) for UI pickers.
33pub 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    /// Return the launch binary for this editor. Returns `None` for `Editor::None`.
55    pub fn binary(&self) -> Option<String> {
56        self.binary_candidates().into_iter().next()
57    }
58
59    /// Return the ordered list of binary names to try when launching this editor.
60    ///
61    /// Most editors have exactly one binary name.  Helix has two (`helix` on
62    /// Linux, `hx` on macOS) so we return both and let the caller try them in
63    /// order, stopping at the first one that is found in `$PATH`.
64    pub fn binary_candidates(&self) -> Vec<String> {
65        match self {
66            Editor::None => vec![],
67            Editor::Helix => {
68                // macOS (Homebrew) installs Helix as `hx`.
69                // Linux package managers install it as `helix` (Arch, Debian,
70                // Fedora) or occasionally `hx`.  We try the platform default
71                // first, then the alternative.
72                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    /// Returns `true` for editors that run inside a terminal (TTY required).
99    ///
100    /// These editors cannot be spawned in the background from a TUI
101    /// application — the TUI must suspend itself first, run the editor
102    /// synchronously, then resume.
103    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    /// macOS application bundle name for GUI editors.
116    /// Returns `None` for terminal editors (they can't be activated via `open -a`).
117    #[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            // Terminal editors and Helix/Neovim/Vim/Nano/Micro/Emacs don't
132            // have a stable macOS bundle name we can rely on for `open -a`.
133            _ => None,
134        }
135    }
136
137    /// Display name for the editor.
138    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    /// Get editor by index into EDITOR_NAMES.
163    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    /// Open a file in this editor as a **background** process.
187    ///
188    /// Stdin/stdout/stderr are detached so the caller is not blocked.
189    /// This is correct for **GUI editors** (VS Code, Zed, IntelliJ, …).
190    ///
191    /// **Do not call this for terminal editors from a running TUI** — use the
192    /// `pending_editor_open` mechanism in `App` instead so the TUI can
193    /// suspend itself before handing the terminal to the editor.
194    ///
195    /// On macOS, GUI editors are opened via `open -a "App Name" file` so the
196    /// existing application window is activated and brought to the front.
197    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        // Try each binary candidate in order, stopping at the first found.
213        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    /// Open a file in this editor, falling back to the system default opener
244    /// (`xdg-open` / `open` / `start`) when no editor is configured or the
245    /// configured editor fails to launch.
246    ///
247    /// Always succeeds as long as the system has a default file handler.
248    /// Returns the method used: `"editor"`, `"system default"`, or an error.
249    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
271/// Open a file in the system's default application (xdg-open, open, etc).
272pub 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
298/// Open a folder in the system's file manager.
299pub 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}