Terminal Markdown previewer — GUI-like experience.
1use std::path::{Path, PathBuf};
2use std::process::Command;
3
4#[derive(Clone, Copy, Debug, PartialEq, Eq)]
5pub(crate) enum EditorKind {
6 Terminal,
7 Gui,
8}
9
10pub(crate) fn binary_name(editor_cmd: &str) -> &str {
11 let full = Path::new(editor_cmd.split_whitespace().next().unwrap_or(editor_cmd))
12 .file_name()
13 .and_then(|n| n.to_str())
14 .unwrap_or(editor_cmd);
15 full.strip_suffix(".exe").unwrap_or(full)
16}
17
18pub(crate) fn classify(editor_cmd: &str) -> EditorKind {
19 match binary_name(editor_cmd) {
20 "code" | "codium" | "subl" | "gedit" | "kate" | "mousepad" | "notepad" | "notepad++"
21 | "zed" | "xjed" | "termux-open" => EditorKind::Gui,
22 _ => EditorKind::Terminal,
23 }
24}
25
26pub(crate) fn split_editor_cmd(cmd: &str) -> (&str, Vec<&str>) {
27 let mut parts = cmd.split_whitespace();
28 let bin = parts.next().unwrap_or(cmd);
29 let args: Vec<&str> = parts.collect();
30 (bin, args)
31}
32
33#[derive(Clone, Debug, PartialEq, Eq)]
34pub(crate) struct EditorEntry {
35 pub(crate) name: String,
36 pub(crate) command: String,
37 pub(crate) kind: EditorKind,
38}
39
40const KNOWN_EDITORS: &[(&str, EditorKind)] = &[
41 ("nano", EditorKind::Terminal),
42 ("vim", EditorKind::Terminal),
43 ("vi", EditorKind::Terminal),
44 ("nvim", EditorKind::Terminal),
45 ("micro", EditorKind::Terminal),
46 ("helix", EditorKind::Terminal),
47 ("emacs", EditorKind::Terminal),
48 ("jed", EditorKind::Terminal),
49 ("code", EditorKind::Gui),
50 ("codium", EditorKind::Gui),
51 ("subl", EditorKind::Gui),
52 ("gedit", EditorKind::Gui),
53 ("kate", EditorKind::Gui),
54 ("mousepad", EditorKind::Gui),
55 ("zed", EditorKind::Gui),
56 ("xjed", EditorKind::Gui),
57 ("notepad", EditorKind::Gui),
58 ("notepad++", EditorKind::Gui),
59];
60
61pub(crate) fn which(bin: &str) -> Option<PathBuf> {
62 if bin.contains('/') || bin.contains('\\') {
63 let p = Path::new(bin);
64 return p.is_file().then(|| p.to_path_buf());
65 }
66 let path_var = std::env::var("PATH").ok()?;
67 let separator = if cfg!(target_os = "windows") {
68 ';'
69 } else {
70 ':'
71 };
72 let candidates: Vec<String> = if cfg!(target_os = "windows") && !bin.contains('.') {
73 vec![
74 format!("{bin}.exe"),
75 format!("{bin}.cmd"),
76 format!("{bin}.bat"),
77 ]
78 } else {
79 vec![bin.to_string()]
80 };
81 path_var.split(separator).find_map(|dir| {
82 candidates
83 .iter()
84 .map(|name| Path::new(dir).join(name))
85 .find(|p| p.is_file())
86 })
87}
88
89pub(crate) fn scan_available_editors() -> Vec<EditorEntry> {
90 let mut found = Vec::new();
91 let is_termux = std::env::var("TERMUX_VERSION").is_ok();
92
93 for &(name, kind) in KNOWN_EDITORS {
94 if is_termux && kind == EditorKind::Gui {
95 continue;
96 }
97 if which(name).is_some() {
98 found.push(EditorEntry {
99 name: name.to_string(),
100 command: name.to_string(),
101 kind,
102 });
103 }
104 }
105
106 if is_termux && which("termux-open").is_some() {
107 found.push(EditorEntry {
108 name: "intent".to_string(),
109 command: "termux-open --chooser".to_string(),
110 kind: EditorKind::Gui,
111 });
112 }
113
114 found.sort_by_key(|e| match e.kind {
115 EditorKind::Terminal => 0,
116 EditorKind::Gui => 1,
117 });
118 found
119}
120
121#[derive(Clone, Debug, PartialEq, Eq)]
122pub(crate) enum TerminalEmulator {
123 Kitty,
124 GnomeTerminal,
125 MacTerminal(String),
126 WindowsTerminal,
127 Termux,
128 Unknown,
129}
130
131pub(crate) fn detect_terminal_emulator() -> TerminalEmulator {
132 if std::env::var("KITTY_PID").is_ok() {
133 return TerminalEmulator::Kitty;
134 }
135 if std::env::var("GNOME_TERMINAL_SCREEN").is_ok() {
136 return TerminalEmulator::GnomeTerminal;
137 }
138 if std::env::var("WT_SESSION").is_ok() {
139 return TerminalEmulator::WindowsTerminal;
140 }
141 if std::env::var("TERMUX_VERSION").is_ok() {
142 return TerminalEmulator::Termux;
143 }
144
145 if let Ok(tp) = std::env::var("TERM_PROGRAM") {
146 match tp.as_str() {
147 "iTerm.app" | "iTerm2" | "Apple_Terminal" => {
148 return TerminalEmulator::MacTerminal(tp);
149 }
150 _ => {}
151 }
152 }
153
154 TerminalEmulator::Unknown
155}
156
157pub(crate) fn resolve_editor(cli_editor: Option<&str>) -> String {
158 let raw = if let Some(e) = cli_editor {
159 e.to_string()
160 } else if let Some(e) = std::env::var("LEAF_EDITOR").ok().filter(|s| !s.is_empty()) {
161 e
162 } else if let Some(e) = std::env::var("VISUAL").ok().filter(|s| !s.is_empty()) {
163 e
164 } else if let Some(e) = std::env::var("EDITOR").ok().filter(|s| !s.is_empty()) {
165 e
166 } else {
167 platform_fallback_editor().to_string()
168 };
169 expand_editor_alias(&raw)
170}
171
172fn expand_editor_alias(editor: &str) -> String {
173 match editor.trim() {
174 "intent" => "termux-open --chooser".to_string(),
175 _ => editor.to_string(),
176 }
177}
178
179fn platform_fallback_editor() -> &'static str {
180 if cfg!(target_os = "windows") {
181 "notepad"
182 } else {
183 "nano"
184 }
185}
186
187pub(crate) fn try_new_tab_command(
188 editor: &str,
189 file: &Path,
190 emulator: &TerminalEmulator,
191) -> Option<Command> {
192 let (bin, args) = split_editor_cmd(editor);
193 let file_str = file.display().to_string();
194
195 match emulator {
196 TerminalEmulator::Kitty => {
197 let mut cmd = Command::new("kitty");
198 cmd.arg("@")
199 .arg("launch")
200 .arg("--type=tab")
201 .arg("--tab-title=leaf editor")
202 .arg(bin);
203 for a in &args {
204 cmd.arg(a);
205 }
206 cmd.arg(&file_str);
207 Some(cmd)
208 }
209 TerminalEmulator::GnomeTerminal => {
210 let mut cmd = Command::new("gnome-terminal");
211 cmd.arg("--tab")
212 .arg("--title=leaf editor")
213 .arg("--")
214 .arg(bin);
215 for a in &args {
216 cmd.arg(a);
217 }
218 cmd.arg(&file_str);
219 Some(cmd)
220 }
221 TerminalEmulator::MacTerminal(ref tp) => {
222 let app_name = if tp == "Apple_Terminal" {
223 "Terminal"
224 } else {
225 "iTerm"
226 };
227 let escaped_editor = editor.replace('\\', "\\\\").replace('"', "\\\"");
228 let escaped_file = file_str.replace('\\', "\\\\").replace('"', "\\\"");
229 let title_seq = r#"printf '\\033]0;leaf editor\\007'; "#;
230 let script = if tp == "Apple_Terminal" {
231 format!(
232 "tell application \"{app_name}\" to do script \"{title_seq}{escaped_editor} {escaped_file}\""
233 )
234 } else {
235 format!(
236 "tell application \"{app_name}\" to tell current window to \
237 create tab with default profile command \"{title_seq}{escaped_editor} {escaped_file}\""
238 )
239 };
240 let mut cmd = Command::new("osascript");
241 cmd.arg("-e").arg(script);
242 Some(cmd)
243 }
244 TerminalEmulator::WindowsTerminal => {
245 let mut cmd = Command::new("wt");
246 cmd.arg("new-tab")
247 .arg("--title")
248 .arg("leaf editor")
249 .arg(bin);
250 for a in &args {
251 cmd.arg(a);
252 }
253 cmd.arg(&file_str);
254 Some(cmd)
255 }
256 TerminalEmulator::Termux | TerminalEmulator::Unknown => None,
257 }
258}
259
260#[derive(Clone, Copy, Debug, PartialEq, Eq)]
261pub(crate) enum EditorResult {
262 Opened,
263 NeedsSameTerminal,
264}
265
266pub(crate) fn open_in_editor(
267 editor: &str,
268 file: &Path,
269 kind: EditorKind,
270 emulator: &TerminalEmulator,
271) -> Result<EditorResult, String> {
272 let (bin, args) = split_editor_cmd(editor);
273 match kind {
274 EditorKind::Gui => {
275 let exec = which(bin).unwrap_or_else(|| PathBuf::from(bin));
276 Command::new(&exec)
277 .args(&args)
278 .arg(file)
279 .spawn()
280 .map_err(|e| format!("{bin}: {e}"))?;
281 Ok(EditorResult::Opened)
282 }
283 EditorKind::Terminal => {
284 if let Some(mut cmd) = try_new_tab_command(editor, file, emulator) {
285 if cmd.spawn().is_ok() {
286 return Ok(EditorResult::Opened);
287 }
288 }
289 Ok(EditorResult::NeedsSameTerminal)
290 }
291 }
292}