diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-05-21 15:17:37 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-05-21 15:17:37 +0300 |
| commit | 06bcd06a17be3bd242e2b8230dd9b803b1969f3c (patch) | |
| tree | ecec609d7cccd346a81d749c63b094ce3a97a11f /src | |
| parent | dde0ddb3aec0522f2bc4c6fa1cb7d25f99fc8403 (diff) | |
feat: use local keybindings for remote attach
Diffstat (limited to 'src')
| -rw-r--r-- | src/client/mod.rs | 19 | ||||
| -rw-r--r-- | src/config.rs | 44 | ||||
| -rw-r--r-- | src/config/keybinds.rs | 10 | ||||
| -rw-r--r-- | src/config/model.rs | 5 | ||||
| -rw-r--r-- | src/main.rs | 6 | ||||
| -rw-r--r-- | src/remote.rs | 335 | ||||
| -rw-r--r-- | src/server/client_transport.rs | 176 | ||||
| -rw-r--r-- | src/server/headless.rs | 139 | ||||
| -rw-r--r-- | src/server/protocol.rs | 18 |
9 files changed, 512 insertions, 240 deletions
diff --git a/src/client/mod.rs b/src/client/mod.rs index ff5c144f..4d962b64 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -32,8 +32,8 @@ use tracing::{debug, info, warn}; use crate::server::headless::client_socket_path; use crate::server::protocol::{ - self, ClientMessage, NotifyKind, RenderEncoding, ServerMessage, MAX_CLIPBOARD_IMAGE_PAYLOAD, - MAX_FRAME_SIZE, MAX_GRAPHICS_FRAME_SIZE, PROTOCOL_VERSION, + self, ClientKeybindings, ClientMessage, NotifyKind, RenderEncoding, ServerMessage, + MAX_CLIPBOARD_IMAGE_PAYLOAD, MAX_FRAME_SIZE, MAX_GRAPHICS_FRAME_SIZE, PROTOCOL_VERSION, }; static RECEIVED_KITTY_GRAPHICS_IDS: OnceLock<Mutex<HashSet<u32>>> = OnceLock::new(); @@ -309,6 +309,20 @@ fn requested_render_encoding() -> RenderEncoding { } } +fn requested_keybindings() -> ClientKeybindings { + match std::env::var(crate::remote::REMOTE_KEYBINDINGS_ENV_VAR) + .ok() + .as_deref() + { + Some("local") => crate::config::Config::load() + .config + .local_keybindings_profile_toml() + .map(|keys_toml| ClientKeybindings::Local { keys_toml }) + .unwrap_or(ClientKeybindings::Server), + _ => ClientKeybindings::Server, + } +} + /// Performs the client→server handshake. /// /// Sends Hello with the terminal size and protocol version, reads the Welcome @@ -333,6 +347,7 @@ fn do_handshake( cell_width_px, cell_height_px, requested_encoding, + keybindings: requested_keybindings(), }; protocol::write_message(stream, &hello) .map_err(|e| ClientError::ConnectionFailed(io::Error::other(e.to_string())))?; diff --git a/src/config.rs b/src/config.rs index 48ebb8f6..b6406874 100644 --- a/src/config.rs +++ b/src/config.rs @@ -19,7 +19,7 @@ pub use self::{ }, model::{ validated_sidebar_bounds, AgentPanelScopeConfig, Config, ConfigReloadReport, - ConfigReloadStatus, ToastConfig, ToastDelivery, + ConfigReloadStatus, KeysConfig, ToastConfig, ToastDelivery, }, sound::SoundConfig, theme::{parse_color, CustomThemeColors, ThemeConfig}, @@ -67,4 +67,46 @@ impl Config { Err(diagnostics) } } + + pub(crate) fn local_keybindings_profile_toml(&self) -> Result<String, toml::ser::Error> { + let mut keys = self.keys.clone(); + keys.command.clear(); + + #[derive(serde::Serialize)] + struct KeysProfile { + keys: KeysConfig, + } + + toml::to_string_pretty(&KeysProfile { keys }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn local_keybindings_profile_includes_defaults_and_excludes_commands() { + let config: Config = toml::from_str( + r#" +[keys] +prefix = "ctrl+a" +new_tab = "prefix+t" + +[[keys.command]] +key = "prefix+g" +command = "lazygit" +"#, + ) + .unwrap(); + + let profile = config.local_keybindings_profile_toml().unwrap(); + assert!(profile.contains("[keys]")); + assert!(profile.contains("prefix = \"ctrl+a\"")); + assert!(profile.contains("new_tab = \"prefix+t\"")); + assert!(profile.contains("next_tab = \"prefix+n\"")); + assert!(!profile.contains("lazygit")); + assert!(!profile.contains("command =")); + assert!(!profile.contains("[[keys.command]]")); + } } diff --git a/src/config/keybinds.rs b/src/config/keybinds.rs index 3a4cf754..1c9c491b 100644 --- a/src/config/keybinds.rs +++ b/src/config/keybinds.rs @@ -1,7 +1,7 @@ #[cfg(test)] use crossterm::event::KeyEvent; use crossterm::event::{KeyCode, KeyModifiers}; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use tracing::warn; use super::Config; @@ -9,13 +9,13 @@ use crate::input::TerminalKey; pub type KeyCombo = (KeyCode, KeyModifiers); -#[derive(Debug)] +#[derive(Debug, Clone)] pub struct LiveKeybindConfig { pub prefix: KeyCombo, pub keybinds: Keybinds, } -#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] #[serde(untagged)] pub enum BindingConfig { One(String), @@ -45,7 +45,7 @@ impl BindingConfig { } } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, Default)] #[serde(rename_all = "snake_case")] pub enum CommandKeybindType { #[default] @@ -53,7 +53,7 @@ pub enum CommandKeybindType { Pane, } -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Deserialize, Serialize)] #[serde(default)] pub struct CommandKeybindConfig { /// Key that runs a command. Use `prefix+g` for prefix mode or a modified chord for direct mode. diff --git a/src/config/model.rs b/src/config/model.rs index 40d425a3..e099d510 100644 --- a/src/config/model.rs +++ b/src/config/model.rs @@ -89,7 +89,7 @@ pub struct LoadedConfig { pub invalid_sections: Vec<String>, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Clone, Deserialize, Serialize)] #[serde(default)] pub struct KeysConfig { /// Prefix key to enter prefix mode (e.g. "ctrl+b", "f12", "esc"). @@ -168,10 +168,11 @@ pub struct KeysConfig { /// Optional indexed shortcuts expanded over number keys 1-9. pub indexed: IndexedKeysConfig, /// Prefix-mode custom command bindings. + #[serde(skip_serializing_if = "Vec::is_empty")] pub command: Vec<CommandKeybindConfig>, } -#[derive(Debug, Default, Deserialize)] +#[derive(Debug, Default, Clone, Deserialize, Serialize)] #[serde(default)] pub struct IndexedKeysConfig { /// Modifier combo for tab shortcuts 1-9. Unset by default. diff --git a/src/main.rs b/src/main.rs index 17052159..c8a2c26a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -394,6 +394,8 @@ fn main() -> io::Result<()> { println!(" --no-session Run monolithically (no server/client, escape hatch)"); println!(" --session <name> Use or create a named persistent session"); println!(" --remote <target> Attach through SSH to a remote Herdr server"); + println!(" --remote-keybindings <local|server>"); + println!(" Keybindings for --remote app attach (default: local)"); println!(" --default-config Print default configuration and exit"); println!(" --version, -V Print version and exit"); println!(" --help, -h Show this help"); @@ -420,6 +422,7 @@ fn main() -> io::Result<()> { "--no-session", "--session", "--remote", + "--remote-keybindings", "--version", "-V", "--default-config", @@ -427,7 +430,8 @@ fn main() -> io::Result<()> { "-h", ]; for arg in &args[1..] { - if arg.starts_with('-') && !known_flags.contains(&arg.as_str()) { + let arg_name = arg.split_once('=').map(|(name, _)| name).unwrap_or(arg); + if arg.starts_with('-') && !known_flags.contains(&arg_name) { eprintln!("unknown option: {arg}"); eprintln!("run 'herdr --help' for usage"); std::process::exit(1); 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] diff --git a/src/server/client_transport.rs b/src/server/client_transport.rs index fc6ab0be..0abea899 100644 --- a/src/server/client_transport.rs +++ b/src/server/client_transport.rs @@ -14,8 +14,8 @@ use tokio::sync::mpsc; use tracing::{debug, warn}; use crate::server::protocol::{ - self, ClientMessage, RenderEncoding, ServerMessage, MAX_CLIPBOARD_IMAGE_PAYLOAD, - MAX_FRAME_SIZE, MAX_GRAPHICS_FRAME_SIZE, PROTOCOL_VERSION, + self, ClientKeybindings, ClientMessage, RenderEncoding, ServerMessage, + MAX_CLIPBOARD_IMAGE_PAYLOAD, MAX_FRAME_SIZE, MAX_GRAPHICS_FRAME_SIZE, PROTOCOL_VERSION, }; /// Minimum accepted attached client size. @@ -55,6 +55,7 @@ pub(crate) enum ServerEvent { cell_width_px: u32, cell_height_px: u32, render_encoding: RenderEncoding, + keybindings: Option<Box<crate::config::LiveKeybindConfig>>, writer: ClientWriter, }, /// A client sent an input message. @@ -96,6 +97,23 @@ pub(crate) fn clamp_terminal_size(cols: u16, rows: u16) -> (u16, u16) { (clamped_cols, clamped_rows) } +fn parse_client_keybindings( + keybindings: ClientKeybindings, +) -> Result<Option<Box<crate::config::LiveKeybindConfig>>, String> { + match keybindings { + ClientKeybindings::Server => Ok(None), + ClientKeybindings::Local { keys_toml } => { + let mut config = toml::from_str::<crate::config::Config>(&keys_toml) + .map_err(|err| format!("invalid client keybindings: {err}"))?; + config.keys.command.clear(); + Ok(Some(Box::new(crate::config::LiveKeybindConfig { + prefix: config.prefix_key(), + keybinds: config.keybinds(), + }))) + } + } +} + /// Handles the client handshake on a blocking thread. /// /// Reads the `Hello` message, validates the version, sends `Welcome`, @@ -130,52 +148,68 @@ pub(crate) fn handle_client_handshake( } }; - let (client_cols, client_rows, cell_width_px, cell_height_px, render_encoding) = match hello { - ClientMessage::Hello { - version, - cols, - rows, - cell_width_px, - cell_height_px, - requested_encoding, - } => { - // Version check. - match protocol::check_client_version(version) { - protocol::VersionCheck::Compatible => {} - protocol::VersionCheck::Incompatible(reason) => { - // Send rejection Welcome. - let welcome = ServerMessage::Welcome { - version: PROTOCOL_VERSION, - encoding: RenderEncoding::SemanticFrame, - error: Some(reason), - }; - let _ = protocol::write_message(&mut stream, &welcome); - return Ok(()); - } - } - - // Clamp size. - let (clamped_cols, clamped_rows) = clamp_terminal_size(cols, rows); - ( - clamped_cols, - clamped_rows, + let (client_cols, client_rows, cell_width_px, cell_height_px, render_encoding, keybindings) = + match hello { + ClientMessage::Hello { + version, + cols, + rows, cell_width_px, cell_height_px, requested_encoding, - ) - } - _ => { - // First message must be Hello. - debug!(client_id, "first message was not Hello, closing"); - let welcome = ServerMessage::Welcome { - version: PROTOCOL_VERSION, - encoding: RenderEncoding::SemanticFrame, - error: Some("expected Hello as first message".to_owned()), - }; - let _ = protocol::write_message(&mut stream, &welcome); - return Ok(()); - } - }; + keybindings, + } => { + // Version check. + match protocol::check_client_version(version) { + protocol::VersionCheck::Compatible => {} + protocol::VersionCheck::Incompatible(reason) => { + // Send rejection Welcome. + let welcome = ServerMessage::Welcome { + version: PROTOCOL_VERSION, + encoding: RenderEncoding::SemanticFrame, + error: Some(reason), + }; + let _ = protocol::write_message(&mut stream, &welcome); + return Ok(()); + } + } + + let keybindings = match parse_client_keybindings(keybindings) { + Ok(keybindings) => keybindings, + Err(error) => { + let welcome = ServerMessage::Welcome { + version: PROTOCOL_VERSION, + encoding: RenderEncoding::SemanticFrame, + error: Some(error), + }; + let _ = protocol::write_message(&mut stream, &welcome); + return Ok(()); + } + }; + + // Clamp size. + let (clamped_cols, clamped_rows) = clamp_terminal_size(cols, rows); + ( + clamped_cols, + clamped_rows, + cell_width_px, + cell_height_px, + requested_encoding, + keybindings, + ) + } + _ => { + // First message must be Hello. + debug!(client_id, "first message was not Hello, closing"); + let welcome = ServerMessage::Welcome { + version: PROTOCOL_VERSION, + encoding: RenderEncoding::SemanticFrame, + error: Some("expected Hello as first message".to_owned()), + }; + let _ = protocol::write_message(&mut stream, &welcome); + return Ok(()); + } + }; // Send Welcome. let welcome = ServerMessage::Welcome { @@ -204,6 +238,7 @@ pub(crate) fn handle_client_handshake( cell_width_px, cell_height_px, render_encoding, + keybindings, writer, }); @@ -447,6 +482,54 @@ mod tests { } #[test] + fn parse_client_keybindings_accepts_local_profile() { + let keybindings = parse_client_keybindings(ClientKeybindings::Local { + keys_toml: r#" +[keys] +prefix = "ctrl+a" +new_tab = "prefix+t" + +[[keys.command]] +key = "prefix+g" +command = "lazygit" +"# + .to_owned(), + }) + .expect("valid client keybindings") + .expect("local profile"); + + assert_eq!(keybindings.prefix.0, crossterm::event::KeyCode::Char('a')); + assert!(keybindings + .keybinds + .new_tab + .bindings + .iter() + .any(|binding| binding.label == "prefix+t")); + assert!(keybindings.keybinds.custom_commands.is_empty()); + } + + #[test] + fn parse_client_keybindings_tolerates_disabled_bindings() { + let keybindings = parse_client_keybindings(ClientKeybindings::Local { + keys_toml: r#" +[keys] +new_tab = "ctrl+notakey" +"# + .to_owned(), + }) + .expect("diagnostic-only client keybindings should be accepted") + .expect("local profile"); + + assert!(keybindings.keybinds.new_tab.bindings.is_empty()); + assert!(keybindings + .keybinds + .next_tab + .bindings + .iter() + .any(|binding| binding.label == "prefix+n")); + } + + #[test] fn handshake_negotiates_terminal_ansi_encoding() { let (mut client_stream, server_stream) = UnixStream::pair().expect("socket pair"); let (server_event_tx, mut server_event_rx) = mpsc::channel(4); @@ -465,6 +548,7 @@ mod tests { cell_width_px: 8, cell_height_px: 16, requested_encoding: RenderEncoding::TerminalAnsi, + keybindings: ClientKeybindings::Server, }, ) .expect("write hello"); @@ -495,12 +579,14 @@ mod tests { cell_width_px, cell_height_px, render_encoding, + keybindings, writer, } => { assert_eq!(client_id, 42); assert_eq!((cols, rows), (100, 30)); assert_eq!((cell_width_px, cell_height_px), (8, 16)); assert_eq!(render_encoding, RenderEncoding::TerminalAnsi); + assert!(keybindings.is_none()); drop(writer); } other => panic!("expected ClientConnected, got {other:?}"), diff --git a/src/server/headless.rs b/src/server/headless.rs index 539892e0..ddfd631c 100644 --- a/src/server/headless.rs +++ b/src/server/headless.rs @@ -209,6 +209,19 @@ fn derive_client_socket_from_api_socket(api_socket_path: &Path) -> PathBuf { parent.join(format!("{stem}-client.sock")) } +fn app_keybindings(app: &app::App) -> crate::config::LiveKeybindConfig { + crate::config::LiveKeybindConfig { + prefix: (app.state.prefix_code, app.state.prefix_mods), + keybinds: app.state.keybinds.clone(), + } +} + +fn apply_keybindings(app: &mut app::App, keybindings: &crate::config::LiveKeybindConfig) { + app.state.prefix_code = keybindings.prefix.0; + app.state.prefix_mods = keybindings.prefix.1; + app.state.keybinds = keybindings.keybinds.clone(); +} + // --------------------------------------------------------------------------- // Connected client state // --------------------------------------------------------------------------- @@ -231,6 +244,8 @@ type RenderTarget = ( struct ClientConnection { /// Whether this connection is the full app client or a direct terminal attach. mode: ClientConnectionMode, + /// Client-local app keybindings. None means use the server's keybindings. + keybindings: Option<Box<crate::config::LiveKeybindConfig>>, /// The client's terminal size (after clamping). terminal_size: (u16, u16), /// Pixel size of one client terminal cell. @@ -270,6 +285,7 @@ impl ClientConnection { ) -> Self { Self::new_with_mode( ClientConnectionMode::App, + None, terminal_size, cell_size, host_terminal_theme, @@ -282,6 +298,7 @@ impl ClientConnection { fn new_with_mode( mode: ClientConnectionMode, + keybindings: Option<Box<crate::config::LiveKeybindConfig>>, terminal_size: (u16, u16), cell_size: crate::kitty_graphics::HostCellSize, host_terminal_theme: crate::terminal_theme::TerminalTheme, @@ -292,6 +309,7 @@ impl ClientConnection { ) -> Self { Self { mode, + keybindings, terminal_size, cell_size, host_terminal_theme, @@ -349,8 +367,10 @@ pub struct HeadlessServer { client_socket_path: PathBuf, clients: HashMap<u64, ClientConnection>, next_client_id: u64, - /// The client currently driving the shared pane runtime size and theme. + /// The client currently driving the shared pane runtime size, theme, and input keybindings. foreground_client_id: Option<u64>, + /// Server-owned keybindings, restored when foreground clients use server mode. + server_keybindings: crate::config::LiveKeybindConfig, /// Writable direct attach owner per terminal id string. terminal_attach_owners: HashMap<String, u64>, /// Monotonic activity counter used to pick the most recently active client. @@ -390,6 +410,7 @@ impl HeadlessServer { // Channel for server events from client threads. let (server_event_tx, server_event_rx) = mpsc::channel(64); + let server_keybindings = app_keybindings(&app); Ok(Self { app, @@ -398,6 +419,7 @@ impl HeadlessServer { clients: HashMap::new(), next_client_id: 1, foreground_client_id: None, + server_keybindings, terminal_attach_owners: HashMap::new(), next_activity_stamp: 1, effective_size: (MIN_COLS, MIN_ROWS), @@ -498,7 +520,7 @@ impl HeadlessServer { if self.app.state.request_reload_config { self.app.state.request_reload_config = false; - self.app.reload_config(); + self.reload_server_config(); needs_render = true; } @@ -611,17 +633,27 @@ impl HeadlessServer { let Some(client_id) = self.foreground_client_id else { self.effective_size = (MIN_COLS, MIN_ROWS); self.app.state.outer_terminal_focus = None; + let server_keybindings = self.server_keybindings.clone(); + apply_keybindings(&mut self.app, &server_keybindings); return; }; let Some(client) = self.clients.get(&client_id) else { self.foreground_client_id = None; self.effective_size = (MIN_COLS, MIN_ROWS); self.app.state.outer_terminal_focus = None; + let server_keybindings = self.server_keybindings.clone(); + apply_keybindings(&mut self.app, &server_keybindings); return; }; self.effective_size = client.terminal_size; self.app.state.outer_terminal_focus = client.outer_terminal_focus; + let keybindings = client + .keybindings + .as_deref() + .unwrap_or(&self.server_keybindings) + .clone(); + apply_keybindings(&mut self.app, &keybindings); if client.outer_terminal_focus == Some(true) { self.app.state.mark_active_tab_seen(); } @@ -630,6 +662,15 @@ impl HeadlessServer { } } + fn reload_server_config(&mut self) -> crate::config::ConfigReloadReport { + let server_keybindings = self.server_keybindings.clone(); + apply_keybindings(&mut self.app, &server_keybindings); + let report = self.app.reload_config(); + self.server_keybindings = app_keybindings(&self.app); + self.sync_foreground_client_state(); + report + } + fn foreground_client_outer_focus(&self) -> Option<bool> { let client_id = self.foreground_client_id?; self.clients.get(&client_id)?.outer_terminal_focus @@ -1393,6 +1434,7 @@ impl HeadlessServer { rows, cell_width_px, cell_height_px, + keybindings, writer, render_encoding, } => { @@ -1410,6 +1452,7 @@ impl HeadlessServer { client_id, ClientConnection::new_with_mode( ClientConnectionMode::App, + keybindings, (cols, rows), crate::kitty_graphics::HostCellSize { width_px: cell_width_px, @@ -1675,7 +1718,31 @@ impl HeadlessServer { }; self.sync_foreground_client_state(); - let response = self.app.handle_api_request(msg.request); + let response = if matches!( + &msg.request.method, + api::schema::Method::ServerReloadConfig(_) + ) { + let report = self.reload_server_config(); + serde_json::to_string(&api::schema::SuccessResponse { + id: msg.request.id.clone(), + result: api::schema::ResponseResult::ConfigReload { + status: report.status, + diagnostics: report.diagnostics, + }, + }) + .unwrap_or_else(|err| { + serde_json::to_string(&api::schema::ErrorResponse { + id: String::new(), + error: api::schema::ErrorBody { + code: "serialization_error".into(), + message: err.to_string(), + }, + }) + .unwrap_or_else(|_| "{}".to_string()) + }) + } else { + self.app.handle_api_request(msg.request) + }; let _ = msg.respond_to.send(response); // Forward new toast state only when a client-local delivery mode is selected. @@ -2373,6 +2440,7 @@ mod tests { .set_nonblocking(true) .expect("set listener nonblocking"); let (server_event_tx, server_event_rx) = mpsc::channel(64); + let server_keybindings = app_keybindings(&app); HeadlessServer { app, @@ -2381,6 +2449,7 @@ mod tests { clients: HashMap::new(), next_client_id: 1, foreground_client_id: None, + server_keybindings, terminal_attach_owners: HashMap::new(), next_activity_stamp: 1, effective_size: (MIN_COLS, MIN_ROWS), @@ -2428,6 +2497,68 @@ mod tests { } #[test] + fn foreground_client_applies_client_keybindings() { + let mut server = test_headless_server(); + let local_config: crate::config::Config = toml::from_str( + r#" +[keys] +prefix = "ctrl+a" +new_tab = "prefix+t" +"#, + ) + .unwrap(); + let local_keybindings = local_config.live_keybinds().unwrap(); + let (writer_a, _control_a, _render_a) = test_client_writer(); + let (writer_b, _control_b, _render_b) = test_client_writer(); + + assert!(server.handle_server_event(ServerEvent::ClientConnected { + client_id: 1, + cols: 80, + rows: 24, + cell_width_px: 0, + cell_height_px: 0, + render_encoding: RenderEncoding::SemanticFrame, + keybindings: Some(Box::new(local_keybindings)), + writer: writer_a, + })); + assert_eq!( + server.app.state.prefix_code, + crossterm::event::KeyCode::Char('a') + ); + assert!(server + .app + .state + .keybinds + .new_tab + .bindings + .iter() + .any(|binding| binding.label == "prefix+t")); + + assert!(server.handle_server_event(ServerEvent::ClientConnected { + client_id: 2, + cols: 80, + rows: 24, + cell_width_px: 0, + cell_height_px: 0, + render_encoding: RenderEncoding::SemanticFrame, + keybindings: None, + writer: writer_b, + })); + assert_eq!( + server.app.state.prefix_code, + crossterm::event::KeyCode::Char('b') + ); + assert!(server + .app + .state + .keybinds + .new_tab + .bindings + .iter() + .any(|binding| binding.label == "prefix+c")); + } + + #[test] fn terminal_attach_rejects_missing_terminal_and_removes_client() { let mut server = test_headless_server(); let (writer, control_rx, _render_rx) = test_client_writer(); @@ -2439,6 +2570,7 @@ mod tests { cell_width_px: 0, cell_height_px: 0, render_encoding: RenderEncoding::TerminalAnsi, + keybindings: None, writer, })); assert!(server.clients.contains_key(&7)); @@ -2479,6 +2611,7 @@ mod tests { cell_width_px: 0, cell_height_px: 0, render_encoding: RenderEncoding::TerminalAnsi, + keybindings: None, writer, })); assert!( diff --git a/src/server/protocol.rs b/src/server/protocol.rs index 6710bc80..33a5f513 100644 --- a/src/server/protocol.rs +++ b/src/server/protocol.rs @@ -13,7 +13,7 @@ use serde::{Deserialize, Serialize}; // --------------------------------------------------------------------------- /// Current protocol version. Bumped when wire format changes incompatibly. -pub const PROTOCOL_VERSION: u32 = 8; +pub const PROTOCOL_VERSION: u32 = 9; /// Maximum allowed frame payload size (2 MB). Frames larger than this are /// rejected to prevent denial-of-service via oversized length prefixes. @@ -43,6 +43,15 @@ pub enum RenderEncoding { TerminalAnsi, } +/// Keybinding profile requested by an attached app client. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum ClientKeybindings { + /// Use the server's own keybinding config. + Server, + /// Use this attached client's normalized local `[keys]` config. + Local { keys_toml: String }, +} + /// Messages sent from the client to the server over the client protocol socket. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub enum ClientMessage { @@ -60,6 +69,8 @@ pub enum ClientMessage { cell_height_px: u32, /// Render encoding requested by the client. requested_encoding: RenderEncoding, + /// Keybinding profile requested by the client. + keybindings: ClientKeybindings, }, /// Raw input bytes read from the client's stdin. @@ -608,6 +619,7 @@ mod tests { cell_width_px: 8, cell_height_px: 16, requested_encoding: RenderEncoding::SemanticFrame, + keybindings: ClientKeybindings::Server, }; let encoded = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap(); let (decoded, _): (ClientMessage, _) = @@ -892,6 +904,7 @@ mod tests { cell_width_px: 8, cell_height_px: 16, requested_encoding: RenderEncoding::SemanticFrame, + keybindings: ClientKeybindings::Server, }; let mut buf = Vec::new(); write_message(&mut buf, &msg).unwrap(); @@ -964,6 +977,7 @@ mod tests { cell_width_px: 8, cell_height_px: 16, requested_encoding: RenderEncoding::SemanticFrame, + keybindings: ClientKeybindings::Server, }, 1 => ClientMessage::Input { data: vec![(i % 256) as u8; (i as usize % 50) + 1], @@ -1398,6 +1412,7 @@ mod tests { cell_width_px: 8, cell_height_px: 16, requested_encoding: RenderEncoding::SemanticFrame, + keybindings: ClientKeybindings::Server, }; let mut buf = Vec::new(); write_message(&mut buf, &msg).unwrap(); @@ -1431,6 +1446,7 @@ mod tests { cell_width_px: 8, cell_height_px: 16, requested_encoding: RenderEncoding::SemanticFrame, + keybindings: ClientKeybindings::Server, }, ClientMessage::Input { data: b"hello world".to_vec(), |
