Terminal Markdown previewer — GUI-like experience.
1
fork

Configure Feed

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

Merge pull request #24 from RivoLink/feat/open-in-editor

feat: open in editor

authored by

Rivo Link and committed by
GitHub
9741d3bc b12ee0ed

+784 -18
+110
src/app/mod.rs
··· 22 22 pub(crate) use file_picker::{FilePickerMode, FilePickerState, PickerIndexTruncation}; 23 23 use file_picker::{PendingPicker, PickerLoadState}; 24 24 25 + #[derive(Clone, Debug, PartialEq, Eq)] 26 + pub(crate) enum EditorFlash { 27 + Opened(String), 28 + NoFile, 29 + EditorNotFound(String), 30 + } 31 + 25 32 pub(super) mod theme_picker; 26 33 pub(crate) use theme_picker::ThemePickerState; 27 34 35 + use crate::editor::EditorEntry; 36 + 37 + pub(crate) struct EditorPickerState { 38 + pub(super) open: bool, 39 + pub(super) editors: Vec<EditorEntry>, 40 + pub(super) index: usize, 41 + } 42 + 28 43 #[derive(Clone, Copy, Debug, PartialEq, Eq)] 29 44 pub(crate) struct FileState { 30 45 pub(crate) modified: SystemTime, ··· 49 64 search_idx: usize, 50 65 watch: bool, 51 66 flash_active: bool, 67 + editor_flash_active: bool, 52 68 } 53 69 54 70 pub(crate) struct AppConfig { ··· 87 103 pub(super) pending_picker: PendingPicker, 88 104 pub(super) picker_load_state: PickerLoadState, 89 105 pub(super) theme_picker: ThemePickerState, 106 + pub(super) editor_picker: EditorPickerState, 90 107 pub(super) render_width: usize, 108 + pub(super) editor_config: Option<String>, 109 + pub(super) editor_flash: Option<(EditorFlash, Instant)>, 91 110 } 92 111 93 112 impl App { ··· 192 211 original: None, 193 212 preview_cache: vec![None; crate::theme::THEME_PRESETS.len()], 194 213 }, 214 + editor_picker: EditorPickerState { 215 + open: false, 216 + editors: Vec::new(), 217 + index: 0, 218 + }, 195 219 render_width: 80, 220 + editor_config: None, 221 + editor_flash: None, 196 222 }; 197 223 app.store_current_theme_preview(); 198 224 app.refresh_static_caches(); ··· 354 380 .reload_flash 355 381 .map(|t| t.elapsed() < Duration::from_millis(1500)) 356 382 .unwrap_or(false), 383 + editor_flash_active: self 384 + .editor_flash 385 + .as_ref() 386 + .map(|(_, t)| t.elapsed() < Duration::from_millis(2000)) 387 + .unwrap_or(false), 357 388 }; 358 389 359 390 if self.status_cache_key.as_ref() == Some(&cache_key) { ··· 389 420 390 421 pub(crate) fn reload_flash_started(&self) -> Option<Instant> { 391 422 self.reload_flash 423 + } 424 + 425 + pub(crate) fn set_editor_config(&mut self, editor: Option<String>) { 426 + self.editor_config = editor; 427 + } 428 + 429 + pub(crate) fn editor_config(&self) -> Option<&str> { 430 + self.editor_config.as_deref() 431 + } 432 + 433 + pub(crate) fn set_editor_flash(&mut self, flash: EditorFlash) { 434 + self.editor_flash = Some((flash, Instant::now())); 435 + } 436 + 437 + pub(crate) fn editor_flash(&self) -> Option<&(EditorFlash, Instant)> { 438 + self.editor_flash.as_ref() 439 + } 440 + 441 + pub(crate) fn clear_editor_flash(&mut self) { 442 + self.editor_flash = None; 443 + } 444 + 445 + pub(crate) fn filepath(&self) -> Option<&std::path::Path> { 446 + self.filepath.as_deref() 447 + } 448 + 449 + pub(crate) fn open_editor_picker(&mut self) { 450 + let editors = crate::editor::scan_available_editors(); 451 + let current = self 452 + .editor_config 453 + .as_deref() 454 + .map(crate::editor::binary_name); 455 + let index = current 456 + .and_then(|bin| { 457 + editors 458 + .iter() 459 + .position(|e| crate::editor::binary_name(&e.command) == bin) 460 + }) 461 + .unwrap_or(0); 462 + self.editor_picker.editors = editors; 463 + self.editor_picker.index = index; 464 + self.editor_picker.open = true; 465 + } 466 + 467 + pub(crate) fn close_editor_picker(&mut self) { 468 + if let Some(entry) = self.editor_picker.editors.get(self.editor_picker.index) { 469 + self.editor_config = Some(entry.command.clone()); 470 + } 471 + self.editor_picker.open = false; 472 + } 473 + 474 + pub(crate) fn cancel_editor_picker(&mut self) { 475 + self.editor_picker.open = false; 476 + } 477 + 478 + pub(crate) fn is_editor_picker_open(&self) -> bool { 479 + self.editor_picker.open 480 + } 481 + 482 + pub(crate) fn move_editor_picker_up(&mut self) { 483 + let len = self.editor_picker.editors.len(); 484 + if len > 0 { 485 + self.editor_picker.index = (self.editor_picker.index + len - 1) % len; 486 + } 487 + } 488 + 489 + pub(crate) fn move_editor_picker_down(&mut self) { 490 + let len = self.editor_picker.editors.len(); 491 + if len > 0 { 492 + self.editor_picker.index = (self.editor_picker.index + 1) % len; 493 + } 494 + } 495 + 496 + pub(crate) fn editor_picker_index(&self) -> usize { 497 + self.editor_picker.index 498 + } 499 + 500 + pub(crate) fn editor_picker_entries(&self) -> &[EditorEntry] { 501 + &self.editor_picker.editors 392 502 } 393 503 394 504 pub(crate) fn set_last_file_state(&mut self, state: FileState) {
+13 -2
src/cli.rs
··· 12 12 pub(crate) print_version: bool, 13 13 pub(crate) file_arg: Option<String>, 14 14 pub(crate) theme: ThemePreset, 15 + pub(crate) editor: Option<String>, 15 16 } 16 17 17 18 pub(crate) fn usage_text() -> &'static str { 18 - "Usage: leaf [--watch] [--theme arctic|forest|ocean|solarized-dark] [file.md]\n leaf [--watch] --picker\n leaf --update\n echo '# Hello' | leaf" 19 + "Usage: leaf [--watch] [--theme arctic|forest|ocean|solarized-dark] [--editor <name>] [file.md]\n leaf [--watch] --picker\n leaf --update\n echo '# Hello' | leaf" 19 20 } 20 21 21 22 pub(crate) fn version_text() -> &'static str { ··· 64 65 options.theme = parse_theme_preset(name) 65 66 .ok_or_else(|| anyhow::anyhow!("Unknown theme: {name}"))?; 66 67 } 68 + "--editor" | "-e" => { 69 + let Some(value) = iter.next() else { 70 + anyhow::bail!("Missing value for --editor"); 71 + }; 72 + options.editor = Some(value.clone()); 73 + } 74 + _ if arg.starts_with("--editor=") => { 75 + options.editor = Some(arg["--editor=".len()..].to_string()); 76 + } 67 77 "--" => positional_only = true, 68 78 _ if arg.starts_with('-') => anyhow::bail!("Unknown flag: {arg}"), 69 79 _ if options.file_arg.is_none() => options.file_arg = Some(arg.clone()), ··· 76 86 || options.picker 77 87 || options.debug_input 78 88 || options.file_arg.is_some() 79 - || options.theme != ThemePreset::default(); 89 + || options.theme != ThemePreset::default() 90 + || options.editor.is_some(); 80 91 if has_non_update_flags { 81 92 anyhow::bail!("--update must be used on its own"); 82 93 }
+290
src/editor.rs
··· 1 + use std::path::{Path, PathBuf}; 2 + use std::process::Command; 3 + 4 + #[derive(Clone, Copy, Debug, PartialEq, Eq)] 5 + pub(crate) enum EditorKind { 6 + Terminal, 7 + Gui, 8 + } 9 + 10 + pub(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 + 18 + pub(crate) fn classify(editor_cmd: &str) -> EditorKind { 19 + match binary_name(editor_cmd) { 20 + "code" | "codium" | "subl" | "gedit" | "kate" | "mousepad" | "notepad" | "notepad++" 21 + | "zed" | "termux-open" => EditorKind::Gui, 22 + _ => EditorKind::Terminal, 23 + } 24 + } 25 + 26 + pub(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)] 34 + pub(crate) struct EditorEntry { 35 + pub(crate) name: String, 36 + pub(crate) command: String, 37 + pub(crate) kind: EditorKind, 38 + } 39 + 40 + const 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 + ("code", EditorKind::Gui), 49 + ("codium", EditorKind::Gui), 50 + ("subl", EditorKind::Gui), 51 + ("gedit", EditorKind::Gui), 52 + ("kate", EditorKind::Gui), 53 + ("mousepad", EditorKind::Gui), 54 + ("zed", EditorKind::Gui), 55 + ("notepad", EditorKind::Gui), 56 + ("notepad++", EditorKind::Gui), 57 + ]; 58 + 59 + pub(crate) fn which(bin: &str) -> Option<PathBuf> { 60 + if bin.contains('/') || bin.contains('\\') { 61 + let p = Path::new(bin); 62 + return p.is_file().then(|| p.to_path_buf()); 63 + } 64 + let path_var = std::env::var("PATH").ok()?; 65 + let separator = if cfg!(target_os = "windows") { 66 + ';' 67 + } else { 68 + ':' 69 + }; 70 + let candidates: Vec<String> = if cfg!(target_os = "windows") && !bin.contains('.') { 71 + vec![ 72 + format!("{bin}.exe"), 73 + format!("{bin}.cmd"), 74 + format!("{bin}.bat"), 75 + ] 76 + } else { 77 + vec![bin.to_string()] 78 + }; 79 + path_var.split(separator).find_map(|dir| { 80 + candidates 81 + .iter() 82 + .map(|name| Path::new(dir).join(name)) 83 + .find(|p| p.is_file()) 84 + }) 85 + } 86 + 87 + pub(crate) fn scan_available_editors() -> Vec<EditorEntry> { 88 + let mut found = Vec::new(); 89 + let is_termux = std::env::var("TERMUX_VERSION").is_ok(); 90 + 91 + for &(name, kind) in KNOWN_EDITORS { 92 + if is_termux && kind == EditorKind::Gui { 93 + continue; 94 + } 95 + if which(name).is_some() { 96 + found.push(EditorEntry { 97 + name: name.to_string(), 98 + command: name.to_string(), 99 + kind, 100 + }); 101 + } 102 + } 103 + 104 + if is_termux && which("termux-open").is_some() { 105 + found.push(EditorEntry { 106 + name: "intent".to_string(), 107 + command: "termux-open --chooser".to_string(), 108 + kind: EditorKind::Gui, 109 + }); 110 + } 111 + 112 + found.sort_by_key(|e| match e.kind { 113 + EditorKind::Terminal => 0, 114 + EditorKind::Gui => 1, 115 + }); 116 + found 117 + } 118 + 119 + #[derive(Clone, Debug, PartialEq, Eq)] 120 + pub(crate) enum TerminalEmulator { 121 + Kitty, 122 + GnomeTerminal, 123 + MacTerminal(String), 124 + WindowsTerminal, 125 + Termux, 126 + Unknown, 127 + } 128 + 129 + pub(crate) fn detect_terminal_emulator() -> TerminalEmulator { 130 + if std::env::var("KITTY_PID").is_ok() { 131 + return TerminalEmulator::Kitty; 132 + } 133 + if std::env::var("GNOME_TERMINAL_SCREEN").is_ok() { 134 + return TerminalEmulator::GnomeTerminal; 135 + } 136 + if std::env::var("WT_SESSION").is_ok() { 137 + return TerminalEmulator::WindowsTerminal; 138 + } 139 + if std::env::var("TERMUX_VERSION").is_ok() { 140 + return TerminalEmulator::Termux; 141 + } 142 + 143 + if let Ok(tp) = std::env::var("TERM_PROGRAM") { 144 + match tp.as_str() { 145 + "iTerm.app" | "iTerm2" | "Apple_Terminal" => { 146 + return TerminalEmulator::MacTerminal(tp); 147 + } 148 + _ => {} 149 + } 150 + } 151 + 152 + TerminalEmulator::Unknown 153 + } 154 + 155 + pub(crate) fn resolve_editor(cli_editor: Option<&str>) -> String { 156 + let raw = if let Some(e) = cli_editor { 157 + e.to_string() 158 + } else if let Some(e) = std::env::var("LEAF_EDITOR").ok().filter(|s| !s.is_empty()) { 159 + e 160 + } else if let Some(e) = std::env::var("VISUAL").ok().filter(|s| !s.is_empty()) { 161 + e 162 + } else if let Some(e) = std::env::var("EDITOR").ok().filter(|s| !s.is_empty()) { 163 + e 164 + } else { 165 + platform_fallback_editor().to_string() 166 + }; 167 + expand_editor_alias(&raw) 168 + } 169 + 170 + fn expand_editor_alias(editor: &str) -> String { 171 + match editor.trim() { 172 + "intent" => "termux-open --chooser".to_string(), 173 + _ => editor.to_string(), 174 + } 175 + } 176 + 177 + fn platform_fallback_editor() -> &'static str { 178 + if cfg!(target_os = "windows") { 179 + "notepad" 180 + } else { 181 + "nano" 182 + } 183 + } 184 + 185 + pub(crate) fn try_new_tab_command( 186 + editor: &str, 187 + file: &Path, 188 + emulator: &TerminalEmulator, 189 + ) -> Option<Command> { 190 + let (bin, args) = split_editor_cmd(editor); 191 + let file_str = file.display().to_string(); 192 + 193 + match emulator { 194 + TerminalEmulator::Kitty => { 195 + let mut cmd = Command::new("kitty"); 196 + cmd.arg("@") 197 + .arg("launch") 198 + .arg("--type=tab") 199 + .arg("--tab-title=leaf editor") 200 + .arg(bin); 201 + for a in &args { 202 + cmd.arg(a); 203 + } 204 + cmd.arg(&file_str); 205 + Some(cmd) 206 + } 207 + TerminalEmulator::GnomeTerminal => { 208 + let mut cmd = Command::new("gnome-terminal"); 209 + cmd.arg("--tab") 210 + .arg("--title=leaf editor") 211 + .arg("--") 212 + .arg(bin); 213 + for a in &args { 214 + cmd.arg(a); 215 + } 216 + cmd.arg(&file_str); 217 + Some(cmd) 218 + } 219 + TerminalEmulator::MacTerminal(ref tp) => { 220 + let app_name = if tp == "Apple_Terminal" { 221 + "Terminal" 222 + } else { 223 + "iTerm" 224 + }; 225 + let escaped_editor = editor.replace('\\', "\\\\").replace('"', "\\\""); 226 + let escaped_file = file_str.replace('\\', "\\\\").replace('"', "\\\""); 227 + let title_seq = r#"printf '\\033]0;leaf editor\\007'; "#; 228 + let script = if tp == "Apple_Terminal" { 229 + format!( 230 + "tell application \"{app_name}\" to do script \"{title_seq}{escaped_editor} {escaped_file}\"" 231 + ) 232 + } else { 233 + format!( 234 + "tell application \"{app_name}\" to tell current window to \ 235 + create tab with default profile command \"{title_seq}{escaped_editor} {escaped_file}\"" 236 + ) 237 + }; 238 + let mut cmd = Command::new("osascript"); 239 + cmd.arg("-e").arg(script); 240 + Some(cmd) 241 + } 242 + TerminalEmulator::WindowsTerminal => { 243 + let mut cmd = Command::new("wt"); 244 + cmd.arg("new-tab") 245 + .arg("--title") 246 + .arg("leaf editor") 247 + .arg(bin); 248 + for a in &args { 249 + cmd.arg(a); 250 + } 251 + cmd.arg(&file_str); 252 + Some(cmd) 253 + } 254 + TerminalEmulator::Termux | TerminalEmulator::Unknown => None, 255 + } 256 + } 257 + 258 + #[derive(Clone, Copy, Debug, PartialEq, Eq)] 259 + pub(crate) enum EditorResult { 260 + Opened, 261 + NeedsSameTerminal, 262 + } 263 + 264 + pub(crate) fn open_in_editor( 265 + editor: &str, 266 + file: &Path, 267 + kind: EditorKind, 268 + emulator: &TerminalEmulator, 269 + ) -> Result<EditorResult, String> { 270 + let (bin, args) = split_editor_cmd(editor); 271 + match kind { 272 + EditorKind::Gui => { 273 + let exec = which(bin).unwrap_or_else(|| PathBuf::from(bin)); 274 + Command::new(&exec) 275 + .args(&args) 276 + .arg(file) 277 + .spawn() 278 + .map_err(|e| format!("{bin}: {e}"))?; 279 + Ok(EditorResult::Opened) 280 + } 281 + EditorKind::Terminal => { 282 + if let Some(mut cmd) = try_new_tab_command(editor, file, emulator) { 283 + if cmd.spawn().is_ok() { 284 + return Ok(EditorResult::Opened); 285 + } 286 + } 287 + Ok(EditorResult::NeedsSameTerminal) 288 + } 289 + } 290 + }
+8
src/main.rs
··· 5 5 6 6 mod app; 7 7 mod cli; 8 + mod editor; 8 9 mod markdown; 9 10 mod render; 10 11 mod runtime; ··· 24 25 25 26 const MAX_STDIN_BYTES: usize = 8 * 1024 * 1024; 26 27 28 + #[cfg(test)] 29 + pub(crate) use editor::{binary_name, classify, resolve_editor, split_editor_cmd, EditorKind}; 27 30 #[cfg(test)] 28 31 pub(crate) use markdown::toc::{ 29 32 normalize_toc, should_hide_single_h1, should_promote_h2_when_no_h1, toc_display_level, TocEntry, ··· 83 86 debug_input, 84 87 file_arg, 85 88 theme, 89 + editor: cli_editor, 86 90 .. 87 91 } = options; 92 + let resolved_editor = editor::resolve_editor(cli_editor.as_deref()); 88 93 runtime::debug_log(debug_input, &format!("main start args={args:?}")); 89 94 set_theme_preset(theme); 90 95 ··· 166 171 }, 167 172 ); 168 173 app.set_last_content_hash(last_content_hash); 174 + app.set_editor_config(Some(resolved_editor)); 169 175 if let Some(dir) = open_browser_picker_dir { 170 176 app.queue_file_picker(dir); 171 177 } ··· 182 188 ); 183 189 184 190 let mut stdout = io::stdout(); 191 + print!("\x1b]0;leaf\x07"); 192 + let _ = io::stdout().flush(); 185 193 runtime::debug_log(debug_input, "terminal enter start"); 186 194 let mut session = TerminalSession::enter(&mut stdout)?; 187 195 runtime::debug_log(debug_input, "terminal enter done");
+2
src/render/mod.rs
··· 48 48 modal::render_file_picker(f, app); 49 49 } else if app.is_theme_picker_open() { 50 50 modal::render_theme_picker(f, app); 51 + } else if app.is_editor_picker_open() { 52 + modal::render_editor_picker(f, app); 51 53 } 52 54 } 53 55
+127 -6
src/render/modal.rs
··· 1 1 use crate::{ 2 2 app::App, 3 3 cli::version_text, 4 + editor::EditorKind, 4 5 theme::{app_theme, theme_preset_label, THEME_PRESETS}, 5 6 }; 6 7 use ratatui::{ ··· 14 15 15 16 pub(super) fn render_help_popup(f: &mut Frame) { 16 17 let theme = app_theme(); 17 - let area = centered_rect(56, 16, f.area()); 18 + let area = centered_rect(56, 17, f.area()); 18 19 let section_style = Style::default() 19 20 .fg(theme.ui.toc_primary_active) 20 21 .add_modifier(Modifier::BOLD); ··· 41 42 Span::styled("j/k, ↑/↓ ", key_style), 42 43 Span::styled("scroll", text_style), 43 44 Span::raw(" "), 44 - Span::styled("/, Ctrl+F ", key_style), 45 + Span::styled("Ctrl+F ", key_style), 45 46 Span::styled("search", text_style), 46 47 ]), 47 48 Line::from(vec![ ··· 61 62 Span::styled("r ", key_style), 62 63 Span::styled("reload (watch)", text_style), 63 64 Span::raw(" "), 64 - Span::styled("? ", key_style), 65 - Span::styled("show help", text_style), 65 + Span::styled("Ctrl+E ", key_style), 66 + Span::styled("edit", text_style), 66 67 ]), 67 68 Line::from(vec![ 68 69 Span::styled("t ", key_style), 69 70 Span::styled("toggle toc", text_style), 70 71 Span::raw(" "), 71 - Span::styled("q ", key_style), 72 - Span::styled("quit", text_style), 72 + Span::styled("? ", key_style), 73 + Span::styled("help", text_style), 73 74 ]), 74 75 Line::from(vec![ 75 76 Span::styled("T ", key_style), 76 77 Span::styled("theme picker", text_style), 78 + Span::raw(" "), 79 + Span::styled("q ", key_style), 80 + Span::styled("quit", text_style), 81 + ]), 82 + Line::from(vec![ 83 + Span::styled("E ", key_style), 84 + Span::styled("editor picker", text_style), 77 85 ]), 78 86 Line::from(""), 79 87 Line::from(vec![Span::styled("Esc or ? to close", footer_style)]), ··· 503 511 504 512 spans 505 513 } 514 + 515 + pub(super) fn render_editor_picker(f: &mut Frame, app: &App) { 516 + let theme = app_theme(); 517 + let entries = app.editor_picker_entries(); 518 + let selected = app.editor_picker_index(); 519 + let current_editor = app.editor_config().map(crate::editor::binary_name); 520 + 521 + let section_style = Style::default() 522 + .fg(theme.ui.toc_primary_active) 523 + .add_modifier(Modifier::BOLD); 524 + let footer_style = Style::default().fg(theme.ui.status_shortcut_fg); 525 + 526 + let title_style = Style::default().fg(theme.ui.status_shortcut_fg); 527 + 528 + let mut lines: Vec<Line<'static>> = Vec::new(); 529 + lines.push(Line::from(vec![Span::styled( 530 + "Choose an editor", 531 + title_style, 532 + )])); 533 + lines.push(Line::from("")); 534 + 535 + if entries.is_empty() { 536 + lines.push(Line::from(vec![Span::styled( 537 + "No editors found", 538 + Style::default().fg(theme.ui.status_search_error_fg), 539 + )])); 540 + } else { 541 + let has_terminal = entries.iter().any(|e| e.kind == EditorKind::Terminal); 542 + let has_gui = entries.iter().any(|e| e.kind == EditorKind::Gui); 543 + 544 + let mk_line = |entry: &crate::editor::EditorEntry, idx: usize| -> Line<'static> { 545 + let is_selected = idx == selected; 546 + let is_current = current_editor == Some(crate::editor::binary_name(&entry.command)); 547 + let bg = if is_selected { 548 + theme.ui.toc_active_bg 549 + } else { 550 + theme.ui.toc_bg 551 + }; 552 + let fg = if is_selected { 553 + theme.ui.toc_primary_active 554 + } else { 555 + theme.ui.toc_primary_inactive 556 + }; 557 + let mut modifier = Modifier::empty(); 558 + if is_selected || is_current { 559 + modifier |= Modifier::BOLD; 560 + } 561 + let marker = if is_selected { "▸ " } else { " " }; 562 + let check = if is_current { " ✓" } else { "" }; 563 + Line::from(vec![ 564 + Span::styled( 565 + marker.to_string(), 566 + Style::default() 567 + .fg(theme.ui.toc_accent) 568 + .bg(bg) 569 + .add_modifier(modifier), 570 + ), 571 + Span::styled( 572 + entry.name.clone(), 573 + Style::default().fg(fg).bg(bg).add_modifier(modifier), 574 + ), 575 + Span::styled( 576 + check.to_string(), 577 + Style::default() 578 + .fg(theme.ui.toc_accent) 579 + .bg(bg) 580 + .add_modifier(modifier), 581 + ), 582 + ]) 583 + }; 584 + 585 + let mut item_idx = 0usize; 586 + if has_terminal { 587 + lines.push(Line::from(vec![Span::styled("Terminal", section_style)])); 588 + for entry in entries.iter().filter(|e| e.kind == EditorKind::Terminal) { 589 + lines.push(mk_line(entry, item_idx)); 590 + item_idx += 1; 591 + } 592 + } 593 + if has_gui { 594 + if has_terminal { 595 + lines.push(Line::from("")); 596 + } 597 + lines.push(Line::from(vec![Span::styled("GUI", section_style)])); 598 + for entry in entries.iter().filter(|e| e.kind == EditorKind::Gui) { 599 + lines.push(mk_line(entry, item_idx)); 600 + item_idx += 1; 601 + } 602 + } 603 + } 604 + 605 + lines.push(Line::from("")); 606 + lines.push(Line::from(vec![Span::styled( 607 + "Enter select • Esc cancel", 608 + footer_style, 609 + )])); 610 + 611 + let height = (lines.len() as u16 + 2).min(18); 612 + let area = centered_rect(38, height, f.area()); 613 + 614 + f.render_widget(Clear, area); 615 + f.render_widget( 616 + Paragraph::new(lines).block( 617 + Block::default() 618 + .title("─ Editor ") 619 + .borders(Borders::ALL) 620 + .border_style(Style::default().fg(theme.ui.toc_border)) 621 + .style(Style::default().bg(theme.ui.toc_bg)) 622 + .padding(Padding::new(1, 1, 0, 0)), 623 + ), 624 + area, 625 + ); 626 + }
+34 -1
src/render/status.rs
··· 1 - use crate::{app::App, theme::app_theme}; 1 + use crate::{ 2 + app::{App, EditorFlash}, 3 + theme::app_theme, 4 + }; 2 5 use ratatui::{ 3 6 style::{Color, Modifier, Style}, 4 7 text::Span, ··· 171 174 )] 172 175 } 173 176 177 + fn editor_flash_section(app: &App) -> Option<Vec<Span<'static>>> { 178 + let (flash, started) = app.editor_flash()?; 179 + if started.elapsed() >= std::time::Duration::from_millis(2000) { 180 + return None; 181 + } 182 + let theme = app_theme(); 183 + let bar_bg = status_bar_bg(); 184 + let (message, fg) = match flash { 185 + EditorFlash::Opened(name) => (format!(" Opened in {name} "), theme.ui.status_reloaded_fg), 186 + EditorFlash::NoFile => ( 187 + " No file to edit ".to_string(), 188 + theme.ui.status_search_error_fg, 189 + ), 190 + EditorFlash::EditorNotFound(msg) => ( 191 + format!(" Editor not found: {msg} "), 192 + theme.ui.status_search_error_fg, 193 + ), 194 + }; 195 + Some(vec![Span::styled( 196 + message, 197 + Style::default().fg(fg).bg(bar_bg), 198 + )]) 199 + } 200 + 174 201 pub(crate) fn build_status_bar(app: &App, pct: u16) -> Vec<Span<'static>> { 175 202 let bar_bg = status_bar_bg(); 176 203 let outer_separator = Span::raw(" "); 204 + 205 + if let Some(flash_section) = editor_flash_section(app) { 206 + let mut left = status_brand_section(); 207 + left.extend(flash_section); 208 + return join_span_sections(vec![left], outer_separator); 209 + } 177 210 178 211 let mut left_section = status_brand_section(); 179 212 left_section.extend(status_filename_section(app.filename()));
+100 -9
src/runtime.rs
··· 1 1 use crate::{ 2 - app::{App, FileChange}, 2 + app::{App, EditorFlash, FileChange}, 3 + editor::{self, classify, open_in_editor, split_editor_cmd, EditorResult}, 3 4 render::{ui, CONTENT_HORIZONTAL_PADDING, SCROLLBAR_WIDTH}, 4 5 }; 5 6 use anyhow::Result; ··· 85 86 needs_redraw = false; 86 87 } 87 88 88 - let flash_timeout = app.reload_flash_started().and_then(|started| { 89 - let elapsed = started.elapsed(); 90 - (elapsed < FLASH_DURATION).then_some(FLASH_DURATION - elapsed) 91 - }); 92 - let resize_timeout = pending_resize.and_then(|started| { 93 - let elapsed = started.elapsed(); 94 - (elapsed < RESIZE_DEBOUNCE).then_some(RESIZE_DEBOUNCE - elapsed) 95 - }); 89 + let flash_timeout = app 90 + .reload_flash_started() 91 + .and_then(|started| FLASH_DURATION.checked_sub(started.elapsed())); 92 + let editor_flash_timeout = app 93 + .editor_flash() 94 + .and_then(|(_, started)| EDITOR_FLASH_DURATION.checked_sub(started.elapsed())); 95 + let resize_timeout = 96 + pending_resize.and_then(|started| RESIZE_DEBOUNCE.checked_sub(started.elapsed())); 96 97 let poll_timeout = [ 97 98 if app.is_watch_enabled() { 98 99 Some(WATCH_INTERVAL) ··· 105 106 None 106 107 }, 107 108 flash_timeout, 109 + editor_flash_timeout, 108 110 resize_timeout, 109 111 ] 110 112 .into_iter() ··· 231 233 if let Some(preset) = app.selected_theme_preset() { 232 234 app.preview_theme_preset(preset, ss, themes); 233 235 } 236 + } 237 + } else if app.is_editor_picker_open() { 238 + match key.code { 239 + KeyCode::Esc => app.cancel_editor_picker(), 240 + KeyCode::Enter => app.close_editor_picker(), 241 + KeyCode::Char('j') | KeyCode::Down => app.move_editor_picker_down(), 242 + KeyCode::Char('k') | KeyCode::Up => app.move_editor_picker_up(), 243 + _ => state_changed = false, 234 244 } 235 245 } else if app.is_search_mode() { 236 246 match key.code { ··· 265 275 KeyCode::Char('T') => { 266 276 app.open_theme_picker(); 267 277 } 278 + KeyCode::Char('E') => { 279 + app.open_editor_picker(); 280 + } 268 281 KeyCode::Char('?') => { 269 282 app.open_help(); 270 283 } ··· 277 290 KeyCode::Char('/') => app.begin_search(), 278 291 KeyCode::Char('n') => app.next_match(), 279 292 KeyCode::Char('N') => app.prev_match(), 293 + KeyCode::Char('e') if key.modifiers.contains(KeyModifiers::CONTROL) => { 294 + handle_open_in_editor(terminal, app, ss, themes)?; 295 + } 280 296 KeyCode::Char(c) if c.is_ascii_digit() && c != '0' => { 281 297 if let Some(n) = c.to_digit(10) { 282 298 app.jump_to_toc(n as usize - 1); ··· 345 361 needs_redraw = true; 346 362 } 347 363 } 364 + } 365 + 366 + if let Some((_, started)) = app.editor_flash() { 367 + if started.elapsed() >= EDITOR_FLASH_DURATION { 368 + app.clear_editor_flash(); 369 + needs_redraw = true; 370 + } 371 + } 372 + } 373 + Ok(()) 374 + } 375 + 376 + const EDITOR_FLASH_DURATION: Duration = Duration::from_millis(2000); 377 + 378 + fn strip_unc_prefix(path: std::path::PathBuf) -> std::path::PathBuf { 379 + if cfg!(target_os = "windows") { 380 + let s = path.to_string_lossy(); 381 + if let Some(stripped) = s.strip_prefix(r"\\?\") { 382 + return std::path::PathBuf::from(stripped); 383 + } 384 + } 385 + path 386 + } 387 + 388 + fn handle_open_in_editor( 389 + terminal: &mut Terminal<CrosstermBackend<io::Stdout>>, 390 + app: &mut App, 391 + ss: &SyntaxSet, 392 + themes: &ThemeSet, 393 + ) -> Result<()> { 394 + let filepath = match app.filepath() { 395 + Some(p) => strip_unc_prefix(p.canonicalize().unwrap_or_else(|_| p.to_path_buf())), 396 + None => { 397 + app.set_editor_flash(EditorFlash::NoFile); 398 + return Ok(()); 399 + } 400 + }; 401 + 402 + let editor_cmd = match app.editor_config() { 403 + Some(e) => e.to_string(), 404 + None => { 405 + app.set_editor_flash(EditorFlash::EditorNotFound("no editor configured".into())); 406 + return Ok(()); 407 + } 408 + }; 409 + 410 + let emulator = editor::detect_terminal_emulator(); 411 + let kind = classify(&editor_cmd); 412 + 413 + match open_in_editor(&editor_cmd, &filepath, kind, &emulator) { 414 + Ok(EditorResult::Opened) => { 415 + let name = editor::binary_name(&editor_cmd).to_string(); 416 + app.set_editor_flash(EditorFlash::Opened(name)); 417 + } 418 + Ok(EditorResult::NeedsSameTerminal) => { 419 + let (bin, args) = split_editor_cmd(&editor_cmd); 420 + crossterm::terminal::disable_raw_mode()?; 421 + crossterm::execute!(io::stdout(), crossterm::terminal::LeaveAlternateScreen)?; 422 + 423 + let status = std::process::Command::new(bin) 424 + .args(&args) 425 + .arg(&filepath) 426 + .status(); 427 + 428 + crossterm::terminal::enable_raw_mode()?; 429 + crossterm::execute!(io::stdout(), crossterm::terminal::EnterAlternateScreen)?; 430 + terminal.clear()?; 431 + app.reload(ss, themes); 432 + 433 + if let Err(e) = status { 434 + app.set_editor_flash(EditorFlash::EditorNotFound(format!("{bin}: {e}"))); 435 + } 436 + } 437 + Err(msg) => { 438 + app.set_editor_flash(EditorFlash::EditorNotFound(msg)); 348 439 } 349 440 } 350 441 Ok(())
+99
src/tests/editor.rs
··· 1 + use crate::*; 2 + 3 + #[test] 4 + fn binary_name_simple() { 5 + assert_eq!(binary_name("nano"), "nano"); 6 + } 7 + 8 + #[test] 9 + fn binary_name_full_path() { 10 + assert_eq!(binary_name("/usr/bin/code"), "code"); 11 + } 12 + 13 + #[test] 14 + fn binary_name_with_args() { 15 + assert_eq!(binary_name("emacs -nw"), "emacs"); 16 + } 17 + 18 + #[test] 19 + fn binary_name_path_with_args() { 20 + assert_eq!(binary_name("/usr/bin/emacs -nw"), "emacs"); 21 + } 22 + 23 + #[test] 24 + fn binary_name_windows() { 25 + assert_eq!(binary_name("notepad.exe"), "notepad"); 26 + } 27 + 28 + #[test] 29 + fn classify_gui_editors() { 30 + assert_eq!(classify("code"), EditorKind::Gui); 31 + assert_eq!(classify("codium"), EditorKind::Gui); 32 + assert_eq!(classify("subl"), EditorKind::Gui); 33 + assert_eq!(classify("gedit"), EditorKind::Gui); 34 + assert_eq!(classify("kate"), EditorKind::Gui); 35 + assert_eq!(classify("mousepad"), EditorKind::Gui); 36 + assert_eq!(classify("notepad.exe"), EditorKind::Gui); 37 + assert_eq!(classify("notepad++"), EditorKind::Gui); 38 + assert_eq!(classify("zed"), EditorKind::Gui); 39 + } 40 + 41 + #[test] 42 + fn classify_terminal_editors() { 43 + assert_eq!(classify("nano"), EditorKind::Terminal); 44 + assert_eq!(classify("vim"), EditorKind::Terminal); 45 + assert_eq!(classify("nvim"), EditorKind::Terminal); 46 + assert_eq!(classify("micro"), EditorKind::Terminal); 47 + assert_eq!(classify("helix"), EditorKind::Terminal); 48 + assert_eq!(classify("emacs"), EditorKind::Terminal); 49 + } 50 + 51 + #[test] 52 + fn classify_unknown_defaults_to_terminal() { 53 + assert_eq!(classify("some-unknown-editor"), EditorKind::Terminal); 54 + } 55 + 56 + #[test] 57 + fn classify_full_path() { 58 + assert_eq!(classify("/usr/bin/code"), EditorKind::Gui); 59 + assert_eq!(classify("/usr/local/bin/nano"), EditorKind::Terminal); 60 + } 61 + 62 + #[test] 63 + fn classify_with_args() { 64 + assert_eq!(classify("emacs -nw"), EditorKind::Terminal); 65 + assert_eq!(classify("/usr/bin/code --new-window"), EditorKind::Gui); 66 + } 67 + 68 + #[test] 69 + fn split_editor_cmd_simple() { 70 + let (bin, args) = split_editor_cmd("nano"); 71 + assert_eq!(bin, "nano"); 72 + assert!(args.is_empty()); 73 + } 74 + 75 + #[test] 76 + fn split_editor_cmd_with_args() { 77 + let (bin, args) = split_editor_cmd("emacs -nw"); 78 + assert_eq!(bin, "emacs"); 79 + assert_eq!(args, vec!["-nw"]); 80 + } 81 + 82 + #[test] 83 + fn split_editor_cmd_path_with_args() { 84 + let (bin, args) = split_editor_cmd("/usr/bin/emacs -nw --no-splash"); 85 + assert_eq!(bin, "/usr/bin/emacs"); 86 + assert_eq!(args, vec!["-nw", "--no-splash"]); 87 + } 88 + 89 + #[test] 90 + fn resolve_editor_cli_takes_priority() { 91 + let result = resolve_editor(Some("vim")); 92 + assert_eq!(result, "vim"); 93 + } 94 + 95 + #[test] 96 + fn resolve_editor_fallback_is_not_empty() { 97 + let result = resolve_editor(None); 98 + assert!(!result.is_empty()); 99 + }
+1
src/tests/mod.rs
··· 12 12 }; 13 13 14 14 mod app; 15 + mod editor; 15 16 mod file_picker; 16 17 mod markdown; 17 18 mod render;