From 734c2d9c30e1d8d7f29e342f8c3617b1ca876a56 Mon Sep 17 00:00:00 2001 From: RivoLink Date: Tue, 12 May 2026 22:51:40 +0300 Subject: [PATCH] fix: editor path with spaces --- config.toml | 7 +++++ src/config.rs | 24 ++++++++++----- src/editor.rs | 62 ++++++++++++++++++++++++++++++++----- src/runtime/mouse.rs | 52 +++++++++++++++++++++++-------- src/tests/editor.rs | 73 ++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 192 insertions(+), 26 deletions(-) diff --git a/config.toml b/config.toml index 85fad07..7a3ac53 100644 --- a/config.toml +++ b/config.toml @@ -28,6 +28,13 @@ theme = "ocean" # Default editor for Ctrl+E # Any editor name available in PATH: nano, vim, nvim, micro, # helix, emacs, code, subl, gedit, kate, mousepad, zed, etc. +# +# For paths with spaces, just write the path: +# editor = 'C:\Program Files\Notepad++\notepad++.exe' +# +# For paths with spaces AND arguments, use inner quotes: +# editor = '"C:\Program Files\Notepad++\notepad++.exe" --some-flag' +# # Falls back to: LEAF_EDITOR > VISUAL > EDITOR > nano (notepad on Windows) # editor = "nano" diff --git a/src/config.rs b/src/config.rs index 14de120..fb863d4 100644 --- a/src/config.rs +++ b/src/config.rs @@ -91,14 +91,24 @@ fn write_default_config(dest: &Path) -> anyhow::Result<()> { fn open_config_in_editor(path: &Path) -> anyhow::Result<()> { let (config, _) = load_config(); let editor = crate::editor::resolve_editor(None, config.editor.as_deref()); - let (bin, args) = crate::editor::split_editor_cmd(&editor); - let status = std::process::Command::new(bin) + + if try_launch_editor(&editor, path) { + return Ok(()); + } + + if let Some(fallback) = crate::editor::resolve_fallback_editor(&editor) { + try_launch_editor(fallback, path); + } + + Ok(()) +} + +fn try_launch_editor(editor: &str, path: &Path) -> bool { + let (bin, args) = crate::editor::split_editor_cmd(editor); + std::process::Command::new(bin) .args(args) .arg(path) .status() - .with_context(|| format!("Cannot open editor: {bin}"))?; - if !status.success() { - anyhow::bail!("Editor exited with status: {status}"); - } - Ok(()) + .map(|s| s.success()) + .unwrap_or(false) } diff --git a/src/editor.rs b/src/editor.rs index 8ad986b..f3f652c 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -8,11 +8,12 @@ pub(crate) enum EditorKind { } pub(crate) fn binary_name(editor_cmd: &str) -> &str { - let full = Path::new(editor_cmd.split_whitespace().next().unwrap_or(editor_cmd)) - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or(editor_cmd); - full.strip_suffix(".exe").unwrap_or(full) + let (bin, _) = split_editor_cmd(editor_cmd); + let after_sep = bin + .rsplit_once(['/', '\\']) + .map(|(_, name)| name) + .unwrap_or(bin); + after_sep.strip_suffix(".exe").unwrap_or(after_sep) } pub(crate) fn classify(editor_cmd: &str) -> EditorKind { @@ -24,8 +25,46 @@ pub(crate) fn classify(editor_cmd: &str) -> EditorKind { } pub(crate) fn split_editor_cmd(cmd: &str) -> (&str, Vec<&str>) { - let mut parts = cmd.split_whitespace(); - let bin = parts.next().unwrap_or(cmd); + let trimmed = cmd.trim(); + + for quote in ['"', '\''] { + if trimmed.starts_with(quote) { + if let Some(end) = trimmed[1..].find(quote) { + let bin = &trimmed[1..=end]; + let rest = trimmed[end + 2..].trim(); + let args = if rest.is_empty() { + vec![] + } else { + rest.split_whitespace().collect() + }; + return (bin, args); + } + } + } + + if !trimmed.contains(' ') && !trimmed.contains('\t') { + return (trimmed, vec![]); + } + + let first_space = trimmed.find([' ', '\t']).unwrap_or(trimmed.len()); + if trimmed[..first_space].contains('\\') { + let parts: Vec<&str> = trimmed.split_whitespace().collect(); + let mut split_at = parts.len(); + while split_at > 1 && parts[split_at - 1].starts_with('-') { + split_at -= 1; + } + let bin_end = if split_at < parts.len() { + parts[split_at].as_ptr() as usize - trimmed.as_ptr() as usize + } else { + trimmed.len() + }; + let bin = trimmed[..bin_end].trim_end(); + let args = parts[split_at..].to_vec(); + return (bin, args); + } + + let mut parts = trimmed.split_whitespace(); + let bin = parts.next().unwrap_or(trimmed); let args: Vec<&str> = parts.collect(); (bin, args) } @@ -178,6 +217,15 @@ fn expand_editor_alias(editor: &str) -> String { } } +pub(crate) fn resolve_fallback_editor(editor_cmd: &str) -> Option<&'static str> { + let fallback = platform_fallback_editor(); + if binary_name(editor_cmd) != binary_name(fallback) { + Some(fallback) + } else { + None + } +} + fn platform_fallback_editor() -> &'static str { if cfg!(target_os = "windows") { "notepad" diff --git a/src/runtime/mouse.rs b/src/runtime/mouse.rs index 4fef56e..689c9cb 100644 --- a/src/runtime/mouse.rs +++ b/src/runtime/mouse.rs @@ -163,21 +163,48 @@ pub(super) fn handle_open_in_editor( }; let emulator = editor::detect_terminal_emulator(); - let kind = classify(&editor_cmd); - match open_in_editor(&editor_cmd, &filepath, kind, &emulator) { + if let Some(flash) = + try_open_editor(&editor_cmd, &filepath, &emulator, terminal, app, ss, themes)? + { + app.set_editor_flash(flash); + return Ok(()); + } + + if let Some(fallback) = editor::resolve_fallback_editor(&editor_cmd) { + if let Some(flash) = + try_open_editor(fallback, &filepath, &emulator, terminal, app, ss, themes)? + { + app.set_editor_flash(flash); + } + } + + Ok(()) +} + +fn try_open_editor( + editor_cmd: &str, + filepath: &std::path::Path, + emulator: &editor::TerminalEmulator, + terminal: &mut Terminal>, + app: &mut App, + ss: &SyntaxSet, + themes: &ThemeSet, +) -> Result> { + let kind = classify(editor_cmd); + match open_in_editor(editor_cmd, filepath, kind, emulator) { Ok(EditorResult::Opened) => { - let name = editor::binary_name(&editor_cmd).to_string(); - app.set_editor_flash(EditorFlash::Opened(name)); + let name = editor::binary_name(editor_cmd).to_string(); + Ok(Some(EditorFlash::Opened(name))) } Ok(EditorResult::NeedsSameTerminal) => { - let (bin, args) = split_editor_cmd(&editor_cmd); + let (bin, args) = split_editor_cmd(editor_cmd); crossterm::terminal::disable_raw_mode()?; crossterm::execute!(io::stdout(), crossterm::terminal::LeaveAlternateScreen)?; let status = std::process::Command::new(bin) .args(&args) - .arg(&filepath) + .arg(filepath) .status(); crossterm::terminal::enable_raw_mode()?; @@ -185,15 +212,16 @@ pub(super) fn handle_open_in_editor( terminal.clear()?; app.reload(ss, themes); - if let Err(e) = status { - app.set_editor_flash(EditorFlash::EditorNotFound(format!("{bin}: {e}"))); + match status { + Ok(s) if s.success() => { + let name = editor::binary_name(editor_cmd).to_string(); + Ok(Some(EditorFlash::Opened(name))) + } + _ => Ok(None), } } - Err(msg) => { - app.set_editor_flash(EditorFlash::EditorNotFound(msg)); - } + Err(_) => Ok(None), } - Ok(()) } pub(super) fn is_on_scrollbar(area: Rect, col: u16, row: u16) -> bool { diff --git a/src/tests/editor.rs b/src/tests/editor.rs index 0e2e192..1316632 100644 --- a/src/tests/editor.rs +++ b/src/tests/editor.rs @@ -88,6 +88,79 @@ fn split_editor_cmd_path_with_args() { assert_eq!(args, vec!["-nw", "--no-splash"]); } +#[test] +fn split_editor_cmd_inner_double_quotes() { + let (bin, args) = split_editor_cmd(r#""C:\Program Files\Notepad++\notepad++.exe" --arg"#); + assert_eq!(bin, r"C:\Program Files\Notepad++\notepad++.exe"); + assert_eq!(args, vec!["--arg"]); +} + +#[test] +fn split_editor_cmd_inner_double_quotes_no_args() { + let (bin, args) = split_editor_cmd(r#""C:\Program Files\Notepad++\notepad++.exe""#); + assert_eq!(bin, r"C:\Program Files\Notepad++\notepad++.exe"); + assert!(args.is_empty()); +} + +#[test] +fn split_editor_cmd_inner_single_quotes() { + let (bin, args) = split_editor_cmd("'/opt/My Apps/editor' -nw"); + assert_eq!(bin, "/opt/My Apps/editor"); + assert_eq!(args, vec!["-nw"]); +} + +#[test] +fn split_editor_cmd_windows_path_no_args() { + let (bin, args) = split_editor_cmd(r"C:\Program Files\Notepad++\notepad++.exe"); + assert_eq!(bin, r"C:\Program Files\Notepad++\notepad++.exe"); + assert!(args.is_empty()); +} + +#[test] +fn split_editor_cmd_windows_path_trailing_args() { + let (bin, args) = split_editor_cmd(r"C:\Program Files\Notepad++\notepad++.exe --no-session"); + assert_eq!(bin, r"C:\Program Files\Notepad++\notepad++.exe"); + assert_eq!(args, vec!["--no-session"]); +} + +#[test] +fn split_editor_cmd_windows_path_duplicate_trailing_args() { + let (bin, args) = split_editor_cmd(r"C:\Program Files\app.exe -nw -nw"); + assert_eq!(bin, r"C:\Program Files\app.exe"); + assert_eq!(args, vec!["-nw", "-nw"]); +} + +#[test] +fn split_editor_cmd_unix_path_with_args() { + let (bin, args) = split_editor_cmd("/usr/bin/emacs -nw --no-splash"); + assert_eq!(bin, "/usr/bin/emacs"); + assert_eq!(args, vec!["-nw", "--no-splash"]); +} + +#[test] +fn binary_name_windows_path_with_spaces() { + assert_eq!( + binary_name(r"C:\Program Files\Notepad++\notepad++.exe"), + "notepad++" + ); +} + +#[test] +fn binary_name_quoted_windows_path() { + assert_eq!( + binary_name(r#""C:\Program Files\Notepad++\notepad++.exe" --arg"#), + "notepad++" + ); +} + +#[test] +fn classify_windows_path_with_spaces() { + assert_eq!( + classify(r"C:\Program Files\Notepad++\notepad++.exe"), + EditorKind::Gui + ); +} + #[test] fn resolve_editor_cli_takes_priority() { let result = resolve_editor(Some("vim"), None);