Terminal Markdown previewer — GUI-like experience.
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 292 lines 8.6 kB view raw
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}