diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-07-05 20:18:24 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-07-05 20:19:13 +0300 |
| commit | 220b99a9fabf10fad83a570f641ad39737d85875 (patch) | |
| tree | a27a3b791f110d091ca6ef9b194bb6c1fb4e2b55 /src | |
| parent | 2bc1724c2dae72184a5d2ec070e30c70dc519f9b (diff) | |
fix: support custom command keybindings on windows
refs #1041
Diffstat (limited to 'src')
| -rw-r--r-- | src/app/input/navigate.rs | 6 | ||||
| -rw-r--r-- | src/main.rs | 1 | ||||
| -rw-r--r-- | src/pane.rs | 4 | ||||
| -rw-r--r-- | src/platform/fallback.rs | 17 | ||||
| -rw-r--r-- | src/platform/linux.rs | 17 | ||||
| -rw-r--r-- | src/platform/macos.rs | 17 | ||||
| -rw-r--r-- | src/platform/mod.rs | 31 | ||||
| -rw-r--r-- | src/platform/windows.rs | 107 |
8 files changed, 193 insertions, 7 deletions
diff --git a/src/app/input/navigate.rs b/src/app/input/navigate.rs index b278daac..f4719ace 100644 --- a/src/app/input/navigate.rs +++ b/src/app/input/navigate.rs @@ -1,7 +1,7 @@ use std::{ fs, io, io::Write, - process::{Command, Stdio}, + process::Stdio, time::{SystemTime, UNIX_EPOCH}, }; @@ -771,10 +771,8 @@ impl App { &self, binding: &crate::config::CustomCommandKeybind, ) -> std::io::Result<()> { - let mut command = Command::new("/bin/sh"); + let mut command = crate::platform::detached_custom_command_process(&binding.command); command - .arg("-lc") - .arg(&binding.command) .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::null()); diff --git a/src/main.rs b/src/main.rs index 04ff4894..3b4bed1c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -220,6 +220,7 @@ const DEFAULT_CONFIG: &str = r##"# herdr configuration # Custom commands use the same binding syntax. # type = "shell" runs detached in the background. # type = "pane" opens a temporary pane and closes it when the command exits. +# On Windows, command strings run through cmd.exe /d /c. # [[keys.command]] # key = "prefix+alt+g" # type = "pane" diff --git a/src/pane.rs b/src/pane.rs index 6094af83..a6edaaa7 100644 --- a/src/pane.rs +++ b/src/pane.rs @@ -1585,9 +1585,7 @@ impl PaneRuntime { render_notify: Arc<Notify>, render_dirty: Arc<AtomicBool>, ) -> std::io::Result<Self> { - let mut cmd = CommandBuilder::new("/bin/sh"); - cmd.arg("-c"); - cmd.arg(command); + let mut cmd = crate::platform::pane_custom_command_pty_builder(command); cmd.cwd(cwd); apply_pane_terminal_env(&mut cmd); apply_pane_launch_env(&mut cmd, launch_env); diff --git a/src/platform/fallback.rs b/src/platform/fallback.rs index e9134a8b..382ccf5e 100644 --- a/src/platform/fallback.rs +++ b/src/platform/fallback.rs @@ -6,6 +6,23 @@ use super::{ClipboardImage, ForegroundJob, Signal}; /// Unsupported platform stub. pub fn raise_server_nofile_limit() {} +fn raw_command_argv(command: &str, flag: &str) -> Vec<std::ffi::OsString> { + vec!["/bin/sh".into(), flag.into(), command.into()] +} + +pub(crate) fn detached_custom_command_process_platform(command: &str) -> std::process::Command { + let argv = raw_command_argv(command, "-lc"); + let mut command = std::process::Command::new(&argv[0]); + command.args(&argv[1..]); + command +} + +pub(crate) fn pane_custom_command_pty_builder_platform( + command: &str, +) -> portable_pty::CommandBuilder { + portable_pty::CommandBuilder::from_argv(raw_command_argv(command, "-c")) +} + /// Unsupported platform stub. pub(crate) fn scrollback_editor_argv(_path: &std::path::Path) -> std::io::Result<Vec<String>> { Err(std::io::Error::new( diff --git a/src/platform/linux.rs b/src/platform/linux.rs index 4e5574d0..be0c97b9 100644 --- a/src/platform/linux.rs +++ b/src/platform/linux.rs @@ -39,6 +39,23 @@ static FOREGROUND_MEMBERS_CACHE: Mutex<ForegroundMembersCache> = pub fn raise_server_nofile_limit() {} +fn raw_command_argv(command: &str, flag: &str) -> Vec<std::ffi::OsString> { + vec!["/bin/sh".into(), flag.into(), command.into()] +} + +pub(crate) fn detached_custom_command_process_platform(command: &str) -> std::process::Command { + let argv = raw_command_argv(command, "-lc"); + let mut command = std::process::Command::new(&argv[0]); + command.args(&argv[1..]); + command +} + +pub(crate) fn pane_custom_command_pty_builder_platform( + command: &str, +) -> portable_pty::CommandBuilder { + portable_pty::CommandBuilder::from_argv(raw_command_argv(command, "-c")) +} + pub(crate) fn scrollback_editor_argv(path: &std::path::Path) -> std::io::Result<Vec<String>> { let quoted_path = shell_quote(&path.display().to_string()); let command = format!( diff --git a/src/platform/macos.rs b/src/platform/macos.rs index e72d32fe..f2441f06 100644 --- a/src/platform/macos.rs +++ b/src/platform/macos.rs @@ -16,6 +16,23 @@ const PROC_PGRP_ONLY: u32 = 2; const SERVER_NOFILE_LIMIT_TARGET: libc::rlim_t = 8192; const CF_STRING_ENCODING_UTF8: u32 = 0x0800_0100; +fn raw_command_argv(command: &str, flag: &str) -> Vec<std::ffi::OsString> { + vec!["/bin/sh".into(), flag.into(), command.into()] +} + +pub(crate) fn detached_custom_command_process_platform(command: &str) -> std::process::Command { + let argv = raw_command_argv(command, "-lc"); + let mut command = std::process::Command::new(&argv[0]); + command.args(&argv[1..]); + command +} + +pub(crate) fn pane_custom_command_pty_builder_platform( + command: &str, +) -> portable_pty::CommandBuilder { + portable_pty::CommandBuilder::from_argv(raw_command_argv(command, "-c")) +} + pub(crate) fn scrollback_editor_argv(path: &Path) -> std::io::Result<Vec<String>> { let quoted_path = shell_quote(&path.display().to_string()); let command = format!( diff --git a/src/platform/mod.rs b/src/platform/mod.rs index 889c4ce9..e396966b 100644 --- a/src/platform/mod.rs +++ b/src/platform/mod.rs @@ -25,6 +25,14 @@ pub enum Signal { Kill, } +pub(crate) fn detached_custom_command_process(command: &str) -> std::process::Command { + detached_custom_command_process_platform(command) +} + +pub(crate) fn pane_custom_command_pty_builder(command: &str) -> portable_pty::CommandBuilder { + pane_custom_command_pty_builder_platform(command) +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) struct PlatformCapabilities { pub(crate) live_handoff: bool, @@ -199,6 +207,29 @@ mod tests { use super::*; #[test] + fn detached_custom_command_preserves_unix_login_shell_flag() { + let cmd = detached_custom_command_process("echo hello"); + assert_eq!(cmd.get_program(), std::ffi::OsStr::new("/bin/sh")); + assert_eq!( + cmd.get_args().collect::<Vec<_>>(), + [ + std::ffi::OsStr::new("-lc"), + std::ffi::OsStr::new("echo hello") + ] + ); + } + + #[test] + fn pane_custom_command_builder_preserves_unix_shell_flag() { + let expected: Vec<std::ffi::OsString> = + vec!["/bin/sh".into(), "-c".into(), "echo hello".into()]; + assert_eq!( + pane_custom_command_pty_builder("echo hello").get_argv(), + &expected + ); + } + + #[test] fn read_limited_reader_returns_complete_data_under_limit() { let input = std::io::Cursor::new(b"image".to_vec()); assert_eq!( diff --git a/src/platform/windows.rs b/src/platform/windows.rs index 50e0e905..cc486ace 100644 --- a/src/platform/windows.rs +++ b/src/platform/windows.rs @@ -46,6 +46,44 @@ struct WindowsProcessEntry { pub fn raise_server_nofile_limit() {} +fn raw_command_shell(comspec: Option<std::ffi::OsString>) -> std::ffi::OsString { + comspec + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| r"C:\Windows\System32\cmd.exe".into()) +} + +pub(crate) fn detached_custom_command_process_platform(command: &str) -> std::process::Command { + detached_custom_command_process_with_comspec(command, std::env::var_os("ComSpec")) +} + +fn detached_custom_command_process_with_comspec( + command: &str, + comspec: Option<std::ffi::OsString>, +) -> std::process::Command { + use std::os::windows::process::CommandExt; + + let mut process = std::process::Command::new(raw_command_shell(comspec)); + process.arg("/d").arg("/c").raw_arg(command); + process +} + +pub(crate) fn pane_custom_command_pty_builder_platform( + command: &str, +) -> portable_pty::CommandBuilder { + pane_custom_command_pty_builder_with_comspec(command, std::env::var_os("ComSpec")) +} + +fn pane_custom_command_pty_builder_with_comspec( + command: &str, + comspec: Option<std::ffi::OsString>, +) -> portable_pty::CommandBuilder { + let mut builder = portable_pty::CommandBuilder::new(raw_command_shell(comspec)); + builder.arg("/d"); + builder.arg("/c"); + builder.raw_arg(command); + builder +} + pub(crate) fn scrollback_editor_argv(path: &std::path::Path) -> std::io::Result<Vec<String>> { let editor = std::env::var("VISUAL") .ok() @@ -537,6 +575,75 @@ mod tests { time::{Duration, Instant}, }; + fn argv_strings(argv: &[std::ffi::OsString]) -> Vec<String> { + argv.into_iter() + .map(|arg| arg.to_string_lossy().into_owned()) + .collect() + } + + #[test] + fn pane_custom_command_uses_cmd() { + let builder = super::pane_custom_command_pty_builder_with_comspec( + "echo hello", + Some(r"C:\Windows\System32\cmd.exe".into()), + ); + + assert_eq!( + argv_strings(builder.get_argv()), + [r"C:\Windows\System32\cmd.exe", "/d", "/c"] + ); + } + + #[test] + fn detached_custom_command_uses_cmd() { + let expected_shell = std::env::var_os("ComSpec") + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| r"C:\Windows\System32\cmd.exe".into()) + .to_string_lossy() + .into_owned(); + + let process = super::detached_custom_command_process_platform("echo hello"); + + assert_eq!(process.get_program().to_string_lossy(), expected_shell); + assert_eq!( + process + .get_args() + .map(|arg| arg.to_string_lossy().into_owned()) + .collect::<Vec<_>>(), + ["/d", "/c", "echo hello"] + ); + } + + #[test] + fn custom_command_falls_back_when_comspec_is_empty() { + let builder = + super::pane_custom_command_pty_builder_with_comspec("echo hello", Some("".into())); + + assert_eq!( + argv_strings(builder.get_argv()), + [r"C:\Windows\System32\cmd.exe", "/d", "/c"] + ); + } + + #[test] + fn detached_custom_command_preserves_quoted_command_tail() { + let path = std::env::temp_dir().join(format!( + "herdr-raw-command-quotes-{}.txt", + std::process::id() + )); + let command = format!(r#"echo "hi" > "{}""#, path.display()); + + let status = super::detached_custom_command_process_platform(&command) + .status() + .expect("spawn raw command"); + + assert!(status.success(), "{status:?}"); + let content = std::fs::read_to_string(&path).expect("read command output"); + let _ = std::fs::remove_file(&path); + assert!(content.contains(r#""hi""#), "{content:?}"); + assert!(!content.contains(r#"\"hi\""#), "{content:?}"); + } + #[test] fn windows_process_cwd_reads_child_launch_directory() { let cwd = std::env::temp_dir().join(format!("herdr-cwd-test-{}", std::process::id())); |
