aboutsummaryrefslogtreecommitdiff
path: root/src/remote.rs
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-05-20 18:55:41 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-05-20 18:55:41 +0300
commit836fd7e585af15cc1ccb0d4f04586ffec28d2a3c (patch)
treea3bf07e7af7ffd884a38fce3d65f8c2243bc0615 /src/remote.rs
parentbe66f33d2acde30d25794bcead991e5e7b7eaff8 (diff)
feat: add keybinding v2
refs #154 refs #201 refs #202 refs #219
Diffstat (limited to 'src/remote.rs')
-rw-r--r--src/remote.rs163
1 files changed, 163 insertions, 0 deletions
diff --git a/src/remote.rs b/src/remote.rs
index 5ab7e6fd..089772b4 100644
--- a/src/remote.rs
+++ b/src/remote.rs
@@ -284,6 +284,7 @@ fn prepare_remote_herdr(target: &str) -> io::Result<RemoteHerdr> {
)));
}
warn_if_remote_bin_not_on_path(target)?;
+ maybe_copy_local_keybindings_to_remote(target, &remote_herdr)?;
Ok(remote_herdr)
}
@@ -652,6 +653,131 @@ 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,
@@ -1096,6 +1222,43 @@ mod tests {
}
#[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());
+ }
+
+ #[test]
fn remote_bridge_command_uses_installed_binary() {
let remote_herdr = RemoteHerdr::for_platform(RemotePlatform {
os: "linux",