summaryrefslogtreecommitdiff
path: root/src/remote.rs
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-05-21 15:17:37 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-05-21 15:17:37 +0300
commit06bcd06a17be3bd242e2b8230dd9b803b1969f3c (patch)
treeecec609d7cccd346a81d749c63b094ce3a97a11f /src/remote.rs
parentdde0ddb3aec0522f2bc4c6fa1cb7d25f99fc8403 (diff)
feat: use local keybindings for remote attach
Diffstat (limited to 'src/remote.rs')
-rw-r--r--src/remote.rs335
1 files changed, 155 insertions, 180 deletions
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<Self, String> {
+ 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<PreparedRemoteHerdr> {
)));
}
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<Option<String>> {
- 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<String> {
- let mut value = content.parse::<toml::Value>().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<String> {
- 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<bool> {
- 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<bool> {
- 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<R: io::Read, W: io::Write>(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]