From 06bcd06a17be3bd242e2b8230dd9b803b1969f3c Mon Sep 17 00:00:00 2001 From: Ogulcan Celik Date: Thu, 21 May 2026 15:17:37 +0300 Subject: feat: use local keybindings for remote attach --- src/remote.rs | 335 +++++++++++++++++++++++++++------------------------------- 1 file changed, 155 insertions(+), 180 deletions(-) (limited to 'src/remote.rs') diff --git a/src/remote.rs b/src/remote.rs index 66b9b6e5..cfe2d49e 100644 --- a/src/remote.rs +++ b/src/remote.rs @@ -25,9 +25,35 @@ const UPDATE_MANIFEST_URL: &str = "https://herdr.dev/latest.json"; const REMOTE_BINARY_ENV_VAR: &str = "HERDR_REMOTE_BINARY"; pub(crate) const REATTACH_COMMAND_ENV_VAR: &str = "HERDR_REATTACH_COMMAND"; +pub(crate) const REMOTE_KEYBINDINGS_ENV_VAR: &str = "HERDR_REMOTE_KEYBINDINGS"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum RemoteKeybindings { + Local, + Server, +} + +impl RemoteKeybindings { + fn parse(value: &str) -> Result { + match value { + "local" => Ok(Self::Local), + "server" => Ok(Self::Server), + _ => Err("--remote-keybindings must be 'local' or 'server'".to_string()), + } + } + + fn as_str(self) -> &'static str { + match self { + Self::Local => "local", + Self::Server => "server", + } + } +} + #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct RemoteLaunch { pub(crate) target: String, + pub(crate) keybindings: RemoteKeybindings, } pub(crate) fn extract_remote_args( @@ -38,30 +64,49 @@ pub(crate) fn extract_remote_args( cleaned.push(program.clone()); } - let mut remote = None; + let mut remote_target = None; + let mut keybindings = RemoteKeybindings::Local; + let mut keybindings_seen = false; let mut index = 1; while index < args.len() { let arg = &args[index]; if arg == "--remote" { - if remote.is_some() { + if remote_target.is_some() { return Err("--remote can only be specified once".to_string()); } let Some(value) = args.get(index + 1) else { return Err("missing value for --remote".to_string()); }; - remote = Some(RemoteLaunch { - target: validate_remote_target(value)?.to_owned(), - }); + remote_target = Some(validate_remote_target(value)?.to_owned()); index += 2; continue; } if let Some(value) = arg.strip_prefix("--remote=") { - if remote.is_some() { + if remote_target.is_some() { return Err("--remote can only be specified once".to_string()); } - remote = Some(RemoteLaunch { - target: validate_remote_target(value)?.to_owned(), - }); + remote_target = Some(validate_remote_target(value)?.to_owned()); + index += 1; + continue; + } + if arg == "--remote-keybindings" { + if keybindings_seen { + return Err("--remote-keybindings can only be specified once".to_string()); + } + let Some(value) = args.get(index + 1) else { + return Err("missing value for --remote-keybindings".to_string()); + }; + keybindings = RemoteKeybindings::parse(value)?; + keybindings_seen = true; + index += 2; + continue; + } + if let Some(value) = arg.strip_prefix("--remote-keybindings=") { + if keybindings_seen { + return Err("--remote-keybindings can only be specified once".to_string()); + } + keybindings = RemoteKeybindings::parse(value)?; + keybindings_seen = true; index += 1; continue; } @@ -70,6 +115,14 @@ pub(crate) fn extract_remote_args( index += 1; } + let remote = remote_target.map(|target| RemoteLaunch { + target, + keybindings, + }); + if remote.is_none() && keybindings_seen { + return Err("--remote-keybindings requires --remote".to_string()); + } + Ok((cleaned, remote)) } @@ -90,7 +143,8 @@ pub(crate) fn run_remote(remote: RemoteLaunch) -> io::Result<()> { let program = std::env::args() .next() .unwrap_or_else(|| "herdr".to_string()); - let reattach_command = reattach_command(&program, &remote.target, &session_name); + let reattach_command = + reattach_command(&program, &remote.target, &session_name, remote.keybindings); let prepared_remote = prepare_remote_herdr(&remote.target)?; ensure_remote_server_ready( &remote.target, @@ -105,7 +159,7 @@ pub(crate) fn run_remote(remote: RemoteLaunch) -> io::Result<()> { session_name, )?; - run_client_process(&local_socket, &reattach_command) + run_client_process(&local_socket, &reattach_command, remote.keybindings) } pub(crate) fn run_remote_client_bridge() -> io::Result<()> { @@ -301,7 +355,6 @@ fn prepare_remote_herdr(target: &str) -> io::Result { ))); } warn_if_remote_bin_not_on_path(target)?; - maybe_copy_local_keybindings_to_remote(target, &remote_herdr)?; Ok(PreparedRemoteHerdr { remote_herdr, @@ -787,131 +840,6 @@ fn confirm_remote_install( Ok(()) } -fn maybe_copy_local_keybindings_to_remote( - target: &str, - remote_herdr: &RemoteHerdr, -) -> io::Result<()> { - let Some(config_toml) = local_keybindings_config_toml()? else { - return Ok(()); - }; - let remote_config_path = remote_config_path(target, remote_herdr)?; - if remote_path_exists(target, &remote_config_path)? { - return Ok(()); - } - if !confirm_remote_keybindings_copy(target)? { - return Ok(()); - } - upload_remote_config(target, &remote_config_path, config_toml.as_bytes()) -} - -fn local_keybindings_config_toml() -> io::Result> { - let path = crate::config::config_path(); - if !path.exists() { - return Ok(None); - } - let content = fs::read_to_string(&path)?; - Ok(local_keybindings_config_toml_from_str(&content)) -} - -fn local_keybindings_config_toml_from_str(content: &str) -> Option { - let mut value = content.parse::().ok()?; - let root = value.as_table_mut()?; - let mut keys = root.remove("keys")?.as_table()?.clone(); - keys.remove("command"); - if keys.is_empty() { - return None; - } - - let mut out = toml::map::Map::new(); - out.insert("keys".to_string(), toml::Value::Table(keys)); - toml::to_string_pretty(&toml::Value::Table(out)).ok() -} - -fn remote_config_path(target: &str, remote_herdr: &RemoteHerdr) -> io::Result { - let command = format!("{} --help", remote_herdr.shell_path); - let output = ssh_output(target, &command)?; - if !output.status.success() { - return Err(command_failed("remote config path probe failed", &output)); - } - let stdout = String::from_utf8_lossy(&output.stdout); - stdout - .lines() - .find_map(|line| line.trim().strip_prefix("Config: ").map(str::to_string)) - .ok_or_else(|| io::Error::other("remote config path probe did not print a Config line")) -} - -fn remote_path_exists(target: &str, path: &str) -> io::Result { - let command = format!("test -e {}", shell_quote(path)); - let output = ssh_output(target, &command)?; - Ok(output.status.success()) -} - -fn confirm_remote_keybindings_copy(target: &str) -> io::Result { - if !io::stdin().is_terminal() { - return Ok(false); - } - eprintln!("remote Herdr config is not present on {target}."); - eprintln!( - "Herdr can copy your local [keys] settings so the remote server uses the same keybindings." - ); - eprintln!("Custom command keybindings are not copied because they run on the remote host."); - eprint!("Copy local Herdr keybindings to {target}? [Y/n] "); - io::stderr().flush()?; - - let mut answer = String::new(); - io::stdin().read_line(&mut answer)?; - let answer = answer.trim().to_ascii_lowercase(); - Ok(!(answer == "n" || answer == "no")) -} - -fn upload_remote_config(target: &str, path: &str, content: &[u8]) -> io::Result<()> { - let script = format!( - r#"dest={} -dir="${{dest%/*}}" -mkdir -p "$dir" -umask 077 -tmp="${{dest}}.tmp.$$" -cat > "$tmp" -mv "$tmp" "$dest" -"#, - shell_quote(path) - ); - - let mut child = Command::new("ssh") - .arg("-T") - .arg(target) - .arg(format!("sh -eu -c {}", shell_quote(&script))) - .stdin(Stdio::piped()) - .stdout(Stdio::inherit()) - .stderr(Stdio::inherit()) - .spawn() - .map_err(|err| { - io::Error::new( - err.kind(), - format!("failed to start ssh config upload: {err}"), - ) - })?; - - let copy_result = if let Some(mut stdin) = child.stdin.take() { - stdin.write_all(content) - } else { - Err(io::Error::new( - io::ErrorKind::BrokenPipe, - "ssh config upload stdin missing", - )) - }; - let status = child.wait()?; - copy_result?; - - if status.success() { - Ok(()) - } else { - Err(io::Error::other(format!( - "remote config upload exited with {status}" - ))) - } -} - fn install_remote_herdr( target: &str, remote_herdr: &RemoteHerdr, @@ -978,9 +906,18 @@ fn remote_bridge_command(remote_herdr: &RemoteHerdr, session_name: &str) -> Stri command } -fn reattach_command(program: &str, target: &str, session_name: &str) -> String { +fn reattach_command( + program: &str, + target: &str, + session_name: &str, + keybindings: RemoteKeybindings, +) -> String { let program = if program.is_empty() { "herdr" } else { program }; let mut command = format!("{} --remote {}", shell_quote(program), shell_quote(target)); + if keybindings != RemoteKeybindings::Local { + command.push_str(" --remote-keybindings "); + command.push_str(keybindings.as_str()); + } if session_name != crate::session::DEFAULT_SESSION_NAME { command.push_str(" --session "); command.push_str(&shell_quote(session_name)); @@ -1149,7 +1086,11 @@ fn copy_flush(reader: &mut R, writer: &mut W) -> io:: } } -fn run_client_process(local_socket: &Path, reattach_command: &str) -> io::Result<()> { +fn run_client_process( + local_socket: &Path, + reattach_command: &str, + keybindings: RemoteKeybindings, +) -> io::Result<()> { let exe = std::env::current_exe()?; let status = Command::new(exe) .arg("client") @@ -1159,6 +1100,7 @@ fn run_client_process(local_socket: &Path, reattach_command: &str) -> io::Result ) .env("HERDR_RENDER_ENCODING", "terminal-ansi") .env(REATTACH_COMMAND_ENV_VAR, reattach_command) + .env(REMOTE_KEYBINDINGS_ENV_VAR, keybindings.as_str()) .env_remove(crate::api::SOCKET_PATH_ENV_VAR) .stdin(Stdio::inherit()) .stdout(Stdio::inherit()) @@ -1278,7 +1220,9 @@ mod tests { ]; let (cleaned, remote) = extract_remote_args(&args).unwrap(); assert_eq!(cleaned, vec!["herdr", "--help"]); - assert_eq!(remote.unwrap().target, "dev"); + let remote = remote.unwrap(); + assert_eq!(remote.target, "dev"); + assert_eq!(remote.keybindings, RemoteKeybindings::Local); } #[test] @@ -1286,7 +1230,56 @@ mod tests { let args = vec!["herdr".into(), "--remote=user@host".into()]; let (cleaned, remote) = extract_remote_args(&args).unwrap(); assert_eq!(cleaned, vec!["herdr"]); - assert_eq!(remote.unwrap().target, "user@host"); + let remote = remote.unwrap(); + assert_eq!(remote.target, "user@host"); + assert_eq!(remote.keybindings, RemoteKeybindings::Local); + } + + #[test] + fn extract_remote_args_accepts_remote_keybindings_server() { + let args = vec![ + "herdr".into(), + "--remote".into(), + "dev".into(), + "--remote-keybindings=server".into(), + ]; + let (cleaned, remote) = extract_remote_args(&args).unwrap(); + assert_eq!(cleaned, vec!["herdr"]); + let remote = remote.unwrap(); + assert_eq!(remote.target, "dev"); + assert_eq!(remote.keybindings, RemoteKeybindings::Server); + } + + #[test] + fn extract_remote_args_accepts_remote_keybindings_space_form() { + let args = vec![ + "herdr".into(), + "--remote=dev".into(), + "--remote-keybindings".into(), + "server".into(), + ]; + let (cleaned, remote) = extract_remote_args(&args).unwrap(); + assert_eq!(cleaned, vec!["herdr"]); + assert_eq!(remote.unwrap().keybindings, RemoteKeybindings::Server); + } + + #[test] + fn extract_remote_args_rejects_remote_keybindings_without_remote() { + let args = vec!["herdr".into(), "--remote-keybindings=server".into()]; + let err = extract_remote_args(&args).unwrap_err(); + assert_eq!(err, "--remote-keybindings requires --remote"); + } + + #[test] + fn extract_remote_args_rejects_duplicate_remote_keybindings() { + let args = vec![ + "herdr".into(), + "--remote=dev".into(), + "--remote-keybindings=local".into(), + "--remote-keybindings=server".into(), + ]; + let err = extract_remote_args(&args).unwrap_err(); + assert_eq!(err, "--remote-keybindings can only be specified once"); } #[test] @@ -1346,50 +1339,32 @@ mod tests { #[test] fn reattach_command_includes_remote_and_session() { assert_eq!( - reattach_command("target/release/herdr", "user@host", "work"), + reattach_command( + "target/release/herdr", + "user@host", + "work", + RemoteKeybindings::Local, + ), "target/release/herdr --remote user@host --session work" ); assert_eq!( - reattach_command("herdr", "host name", crate::session::DEFAULT_SESSION_NAME), + reattach_command( + "herdr", + "host name", + crate::session::DEFAULT_SESSION_NAME, + RemoteKeybindings::Local, + ), "herdr --remote 'host name'" ); - } - - #[test] - fn local_keybindings_config_extracts_only_keys_without_commands() { - let toml = r#" -[theme] -name = "one-dark" - -[keys] -prefix = "ctrl+a" -new_tab = ["prefix+c", "ctrl+alt+n"] -next_tab = "prefix+n" - -[keys.indexed] -tabs = "ctrl" - -[[keys.command]] -key = "prefix+g" -type = "pane" -command = "lazygit" -"#; - - let copied = local_keybindings_config_toml_from_str(toml).expect("copied key config"); - assert!(copied.contains("[keys]")); - assert!(copied.contains("prefix = \"ctrl+a\"")); - assert!(copied.contains("prefix+c")); - assert!(copied.contains("ctrl+alt+n")); - assert!(copied.contains("[keys.indexed]")); - assert!(copied.contains("tabs = \"ctrl\"")); - assert!(!copied.contains("one-dark")); - assert!(!copied.contains("lazygit")); - assert!(!copied.contains("[[keys.command]]")); - } - - #[test] - fn local_keybindings_config_returns_none_without_keys() { - assert!(local_keybindings_config_toml_from_str("[theme]\nname = \"one-dark\"\n").is_none()); + assert_eq!( + reattach_command( + "herdr", + "host", + crate::session::DEFAULT_SESSION_NAME, + RemoteKeybindings::Server, + ), + "herdr --remote host --remote-keybindings server" + ); } #[test] -- cgit v1.2.3