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        match self {
57            Editor::None => Option::None,
58            Editor::Helix => Some(Self::resolve_helix()),
59            Editor::Neovim => Some("nvim".into()),
60            Editor::Vim => Some("vim".into()),
61            Editor::Nano => Some("nano".into()),
62            Editor::Micro => Some("micro".into()),
63            Editor::Emacs => Some("emacs".into()),
64            Editor::VSCode => Some("code --reuse-window".into()),
65            Editor::Zed => Some("zed".into()),
66            Editor::Sublime => Some("subl".into()),
67            Editor::RustRover => Some("rustrover".into()),
68            Editor::IntelliJIdea => Some("idea".into()),
69            Editor::WebStorm => Some("webstorm".into()),
70            Editor::PyCharm => Some("pycharm".into()),
71            Editor::GoLand => Some("goland".into()),
72            Editor::CLion => Some("clion".into()),
73            Editor::Fleet => Some("fleet".into()),
74            Editor::AndroidStudio => Some("studio".into()),
75            Editor::Custom(s) => Some(s.clone()),
76        }
77    }
78
79    /// Display name for the editor.
80    pub fn display_name(&self) -> &str {
81        match self {
82            Editor::None => "None",
83            Editor::Helix => "Helix",
84            Editor::Neovim => "Neovim",
85            Editor::Vim => "Vim",
86            Editor::Nano => "Nano",
87            Editor::Micro => "Micro",
88            Editor::Emacs => "Emacs",
89            Editor::VSCode => "VS Code",
90            Editor::Zed => "Zed",
91            Editor::Sublime => "Sublime Text",
92            Editor::RustRover => "RustRover",
93            Editor::IntelliJIdea => "IntelliJ IDEA",
94            Editor::WebStorm => "WebStorm",
95            Editor::PyCharm => "PyCharm",
96            Editor::GoLand => "GoLand",
97            Editor::CLion => "CLion",
98            Editor::Fleet => "Fleet",
99            Editor::AndroidStudio => "Android Studio",
100            Editor::Custom(_) => "Custom",
101        }
102    }
103
104    /// Get editor by index into EDITOR_NAMES.
105    pub fn from_index(index: usize) -> Self {
106        match index {
107            0 => Editor::Helix,
108            1 => Editor::Neovim,
109            2 => Editor::Vim,
110            3 => Editor::Nano,
111            4 => Editor::Micro,
112            5 => Editor::Emacs,
113            6 => Editor::VSCode,
114            7 => Editor::Zed,
115            8 => Editor::Sublime,
116            9 => Editor::RustRover,
117            10 => Editor::IntelliJIdea,
118            11 => Editor::WebStorm,
119            12 => Editor::PyCharm,
120            13 => Editor::GoLand,
121            14 => Editor::CLion,
122            15 => Editor::Fleet,
123            16 => Editor::AndroidStudio,
124            _ => Editor::None,
125        }
126    }
127
128    /// Open a file in this editor. Returns an error if the editor is not found.
129    pub fn open_file(&self, file_path: &std::path::Path) -> anyhow::Result<()> {
130        let bin = self.binary().ok_or_else(|| {
131            anyhow::anyhow!("no editor configured — select one from the editor picker")
132        })?;
133
134        let parts: Vec<&str> = bin.split_whitespace().collect();
135        let (cmd, args) = parts
136            .split_first()
137            .ok_or_else(|| anyhow::anyhow!("empty editor binary"))?;
138
139        let mut command = std::process::Command::new(cmd);
140        command.args(args.iter());
141        command.arg(file_path);
142        // Detach stdin/stdout/stderr so the editor runs independently
143        command.stdin(std::process::Stdio::null());
144        command.stdout(std::process::Stdio::null());
145        command.stderr(std::process::Stdio::null());
146        command
147            .spawn()
148            .map_err(|e| anyhow::anyhow!("failed to launch '{}': {}", cmd, e))?;
149
150        Ok(())
151    }
152
153    /// Probe `$PATH` for the Helix binary name.
154    fn resolve_helix() -> String {
155        for candidate in &["hx", "helix"] {
156            if std::process::Command::new(candidate)
157                .arg("--version")
158                .stdout(std::process::Stdio::null())
159                .stderr(std::process::Stdio::null())
160                .status()
161                .is_ok()
162            {
163                return candidate.to_string();
164            }
165        }
166        "hx".to_string()
167    }
168}
169
170impl std::fmt::Display for Editor {
171    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
172        write!(f, "{}", self.display_name())
173    }
174}
175
176/// Open a file in the system's default application (xdg-open, open, etc).
177pub fn open_file_default(file_path: &std::path::Path) -> anyhow::Result<()> {
178    #[cfg(target_os = "linux")]
179    {
180        std::process::Command::new("xdg-open")
181            .arg(file_path)
182            .spawn()
183            .map_err(|e| anyhow::anyhow!("xdg-open failed: {}", e))?;
184    }
185    #[cfg(target_os = "macos")]
186    {
187        std::process::Command::new("open")
188            .arg(file_path)
189            .spawn()
190            .map_err(|e| anyhow::anyhow!("open failed: {}", e))?;
191    }
192    #[cfg(target_os = "windows")]
193    {
194        std::process::Command::new("cmd")
195            .args(["/C", "start", ""])
196            .arg(file_path)
197            .spawn()
198            .map_err(|e| anyhow::anyhow!("start failed: {}", e))?;
199    }
200    Ok(())
201}
202
203/// Open a folder in the system's file manager.
204pub fn show_in_folder(file_path: &std::path::Path) -> anyhow::Result<()> {
205    let folder = if file_path.is_file() {
206        file_path.parent().unwrap_or(file_path)
207    } else {
208        file_path
209    };
210    open_file_default(folder)
211}
212
213#[cfg(test)]
214mod tests {
215    use super::*;
216
217    #[test]
218    fn editor_binary_returns_correct_values() {
219        assert_eq!(Editor::Neovim.binary(), Some("nvim".into()));
220        assert_eq!(Editor::VSCode.binary(), Some("code --reuse-window".into()));
221        assert_eq!(Editor::None.binary(), None);
222        assert_eq!(
223            Editor::Custom("my-editor".into()).binary(),
224            Some("my-editor".into())
225        );
226    }
227
228    #[test]
229    fn editor_display_name() {
230        assert_eq!(Editor::VSCode.display_name(), "VS Code");
231        assert_eq!(Editor::IntelliJIdea.display_name(), "IntelliJ IDEA");
232        assert_eq!(Editor::None.display_name(), "None");
233    }
234
235    #[test]
236    fn editor_from_index_round_trips() {
237        for i in 0..EDITOR_NAMES.len() {
238            let editor = Editor::from_index(i);
239            assert_ne!(editor, Editor::None, "index {i} should not be None");
240        }
241        assert_eq!(Editor::from_index(999), Editor::None);
242    }
243
244    #[test]
245    fn editor_names_count_matches() {
246        assert_eq!(EDITOR_NAMES.len(), 17);
247    }
248
249    #[test]
250    fn editor_serialize_deserialize() {
251        let editor = Editor::VSCode;
252        let json = serde_json::to_string(&editor).unwrap();
253        let back: Editor = serde_json::from_str(&json).unwrap();
254        assert_eq!(back, Editor::VSCode);
255    }
256
257    #[test]
258    fn vscode_binary_includes_reuse_window() {
259        assert_eq!(Editor::VSCode.binary(), Some("code --reuse-window".into()));
260    }
261
262    #[test]
263    fn editor_display_implements_display_trait() {
264        let editor = Editor::Neovim;
265        assert_eq!(format!("{editor}"), "Neovim");
266    }
267
268    #[test]
269    fn custom_editor_preserves_value() {
270        let editor = Editor::Custom("/usr/bin/my-editor --flag".into());
271        assert_eq!(editor.binary(), Some("/usr/bin/my-editor --flag".into()));
272        assert_eq!(editor.display_name(), "Custom");
273    }
274}