aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-05-23 02:30:25 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-05-23 02:30:25 +0300
commit09d9ddde97d5def9107c9ad6a0939bf12703203e (patch)
tree27a1a7aaf87d40412034ca79c78cd9ffdb052573 /src
parent3de6ea799965a7d2aad0f7db2f23f54cd24cb039 (diff)
fix: preserve local keybindings in remote sessions
Diffstat (limited to 'src')
-rw-r--r--src/app/input/modal.rs3
-rw-r--r--src/app/mod.rs14
-rw-r--r--src/config.rs6
-rw-r--r--src/server/headless.rs268
4 files changed, 275 insertions, 16 deletions
diff --git a/src/app/input/modal.rs b/src/app/input/modal.rs
index 1fae3800..acd481b8 100644
--- a/src/app/input/modal.rs
+++ b/src/app/input/modal.rs
@@ -675,8 +675,7 @@ mod tests {
use super::*;
fn config_env_lock() -> &'static std::sync::Mutex<()> {
- static LOCK: std::sync::OnceLock<std::sync::Mutex<()>> = std::sync::OnceLock::new();
- LOCK.get_or_init(|| std::sync::Mutex::new(()))
+ crate::config::test_config_env_lock()
}
fn temp_config_path(name: &str) -> std::path::PathBuf {
diff --git a/src/app/mod.rs b/src/app/mod.rs
index 4bcbada7..16b98193 100644
--- a/src/app/mod.rs
+++ b/src/app/mod.rs
@@ -90,6 +90,7 @@ pub struct App {
pub(crate) full_redraw_pending: bool,
pub(crate) overlay_panes: HashMap<crate::layout::PaneId, OverlayPaneState>,
pub(crate) local_terminal_notifications: bool,
+ pub(crate) config_reloaded_from_disk: bool,
}
pub(crate) enum LoopEvent {
@@ -535,6 +536,7 @@ impl App {
full_redraw_pending: false,
overlay_panes: HashMap::new(),
local_terminal_notifications: true,
+ config_reloaded_from_disk: false,
}
}
@@ -880,10 +882,17 @@ impl App {
self.apply_config_from_disk(true)
}
+ pub(crate) fn take_config_reloaded_from_disk(&mut self) -> bool {
+ let reloaded = self.config_reloaded_from_disk;
+ self.config_reloaded_from_disk = false;
+ reloaded
+ }
+
pub(crate) fn apply_config_from_disk(
&mut self,
notify_success: bool,
) -> crate::config::ConfigReloadReport {
+ self.config_reloaded_from_disk = true;
let previous_toast = self.state.toast.clone();
let report = match crate::config::load_live_config() {
Ok(loaded) => self.apply_live_config(
@@ -1237,7 +1246,7 @@ mod tests {
use crate::terminal::TerminalRuntime;
use crate::workspace::Workspace;
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
- use std::sync::{Mutex, OnceLock};
+ use std::sync::Mutex;
fn raw_key(
code: KeyCode,
@@ -1270,8 +1279,7 @@ mod tests {
}
fn config_env_lock() -> &'static Mutex<()> {
- static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
- LOCK.get_or_init(|| Mutex::new(()))
+ crate::config::test_config_env_lock()
}
fn temp_config_path(name: &str) -> std::path::PathBuf {
diff --git a/src/config.rs b/src/config.rs
index 33558b4b..d874455e 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -36,6 +36,12 @@ pub(crate) fn app_dir_name() -> &'static str {
io::app_dir_name()
}
+#[cfg(test)]
+pub(crate) fn test_config_env_lock() -> &'static std::sync::Mutex<()> {
+ static LOCK: std::sync::OnceLock<std::sync::Mutex<()>> = std::sync::OnceLock::new();
+ LOCK.get_or_init(|| std::sync::Mutex::new(()))
+}
+
impl Config {
pub fn should_show_onboarding(&self) -> bool {
self.onboarding.unwrap_or(true)
diff --git a/src/server/headless.rs b/src/server/headless.rs
index 5c6a1ec6..bf28f36c 100644
--- a/src/server/headless.rs
+++ b/src/server/headless.rs
@@ -104,6 +104,10 @@ pub struct HeadlessServer {
foreground_client_id: Option<u64>,
/// Server-owned keybindings, restored when foreground clients use server mode.
server_keybindings: crate::config::LiveKeybindConfig,
+ /// Full server config warning shown to clients that use server keybindings.
+ server_config_diagnostic: Option<String>,
+ /// Server config warning with keybinding diagnostics removed for local-keybinding clients.
+ server_config_diagnostic_without_keybindings: Option<String>,
/// 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.
@@ -128,7 +132,7 @@ impl HeadlessServer {
/// 1. Prepares the client socket path (cleans up stale sockets)
/// 2. Binds the client socket listener
/// 3. Returns the server ready to run
- pub fn new(app: app::App) -> io::Result<Self> {
+ pub fn new(app: app::App, config_diagnostics: &[String]) -> io::Result<Self> {
let client_path = client_socket_path();
prepare_socket_path(&client_path)?;
@@ -144,6 +148,8 @@ 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);
+ let (server_config_diagnostic, server_config_diagnostic_without_keybindings) =
+ server_config_diagnostic_summaries(config_diagnostics);
Ok(Self {
app,
@@ -153,6 +159,8 @@ impl HeadlessServer {
next_client_id: 1,
foreground_client_id: None,
server_keybindings,
+ server_config_diagnostic,
+ server_config_diagnostic_without_keybindings,
terminal_attach_owners: HashMap::new(),
next_activity_stamp: 1,
effective_size: (MIN_COLS, MIN_ROWS),
@@ -294,7 +302,7 @@ impl HeadlessServer {
if self.app.state.request_reload_config {
self.app.state.request_reload_config = false;
- self.reload_server_config();
+ self.reload_server_config(true);
needs_render = true;
}
@@ -418,6 +426,7 @@ impl HeadlessServer {
self.app.state.outer_terminal_focus = None;
let server_keybindings = self.server_keybindings.clone();
apply_keybindings(&mut self.app, &server_keybindings);
+ self.sync_visible_server_config_diagnostic(false);
return;
};
let Some(client) = self.clients.get(&client_id) else {
@@ -426,30 +435,56 @@ impl HeadlessServer {
self.app.state.outer_terminal_focus = None;
let server_keybindings = self.server_keybindings.clone();
apply_keybindings(&mut self.app, &server_keybindings);
+ self.sync_visible_server_config_diagnostic(false);
return;
};
- self.effective_size = client.terminal_size;
- self.app.state.outer_terminal_focus = client.outer_terminal_focus;
+ let terminal_size = client.terminal_size;
+ let outer_terminal_focus = client.outer_terminal_focus;
+ let host_terminal_theme = client.host_terminal_theme;
+ let uses_local_keybindings = client.keybindings.is_some();
let keybindings = client
.keybindings
.as_deref()
.unwrap_or(&self.server_keybindings)
.clone();
+
+ self.effective_size = terminal_size;
+ self.app.state.outer_terminal_focus = outer_terminal_focus;
apply_keybindings(&mut self.app, &keybindings);
- if client.outer_terminal_focus == Some(true) {
+ self.sync_visible_server_config_diagnostic(uses_local_keybindings);
+ if outer_terminal_focus == Some(true) {
self.app.state.mark_active_tab_seen();
}
- if !client.host_terminal_theme.is_empty() {
- self.app.set_host_terminal_theme(client.host_terminal_theme);
+ if !host_terminal_theme.is_empty() {
+ self.app.set_host_terminal_theme(host_terminal_theme);
+ }
+ }
+
+ fn sync_visible_server_config_diagnostic(&mut self, uses_local_keybindings: bool) {
+ let visible = if uses_local_keybindings {
+ &self.server_config_diagnostic_without_keybindings
+ } else {
+ &self.server_config_diagnostic
+ };
+ if self.app.state.config_diagnostic == self.server_config_diagnostic
+ || self.app.state.config_diagnostic == self.server_config_diagnostic_without_keybindings
+ {
+ self.app.state.config_diagnostic = visible.clone();
}
}
- fn reload_server_config(&mut self) -> crate::config::ConfigReloadReport {
+ fn reload_server_config(&mut self, notify_success: bool) -> crate::config::ConfigReloadReport {
let server_keybindings = self.server_keybindings.clone();
apply_keybindings(&mut self.app, &server_keybindings);
- let report = self.app.reload_config();
+ let report = self.app.apply_config_from_disk(notify_success);
+ self.app.take_config_reloaded_from_disk();
self.server_keybindings = app_keybindings(&self.app);
+ let (server_config_diagnostic, server_config_diagnostic_without_keybindings) =
+ server_config_diagnostic_summaries(&report.diagnostics);
+ self.server_config_diagnostic = server_config_diagnostic;
+ self.server_config_diagnostic_without_keybindings =
+ server_config_diagnostic_without_keybindings;
self.sync_foreground_client_state();
report
}
@@ -1225,6 +1260,11 @@ impl HeadlessServer {
let theme_changed = self.update_client_host_theme_from_events(client_id, &events);
self.app
.route_client_events(events, self.foreground_client_id == Some(client_id));
+ if self.app.take_config_reloaded_from_disk() {
+ self.reload_server_config(false);
+ } else {
+ self.sync_foreground_client_state();
+ }
// Check if the detach keybind was triggered during input processing.
if self.app.state.detach_requested {
@@ -1431,7 +1471,7 @@ impl HeadlessServer {
&msg.request.method,
api::schema::Method::ServerReloadConfig(_)
) {
- let report = self.reload_server_config();
+ let report = self.reload_server_config(true);
serde_json::to_string(&api::schema::SuccessResponse {
id: msg.request.id.clone(),
result: api::schema::ResponseResult::ConfigReload {
@@ -2020,6 +2060,22 @@ async fn sleep_until_or_pending(deadline: Option<Instant>) {
}
}
+fn server_config_diagnostic_summaries(diagnostics: &[String]) -> (Option<String>, Option<String>) {
+ let without_keybindings = diagnostics
+ .iter()
+ .filter(|diagnostic| !is_keybinding_config_diagnostic(diagnostic))
+ .cloned()
+ .collect::<Vec<_>>();
+ (
+ config::config_diagnostic_summary(diagnostics),
+ config::config_diagnostic_summary(&without_keybindings),
+ )
+}
+
+fn is_keybinding_config_diagnostic(diagnostic: &str) -> bool {
+ diagnostic.contains("keybinding") || diagnostic.contains("keys.")
+}
+
// ---------------------------------------------------------------------------
// Entry point
// ---------------------------------------------------------------------------
@@ -2067,7 +2123,7 @@ pub fn run_server() -> io::Result<()> {
app.local_terminal_notifications = false;
// Create the headless server.
- let mut server = match HeadlessServer::new(app) {
+ let mut server = match HeadlessServer::new(app, &loaded_config.diagnostics) {
Ok(server) => server,
Err(err) if err.kind() == io::ErrorKind::AddrInUse => {
eprintln!("error: herdr server is already running");
@@ -2154,6 +2210,8 @@ mod tests {
next_client_id: 1,
foreground_client_id: None,
server_keybindings,
+ server_config_diagnostic: None,
+ server_config_diagnostic_without_keybindings: None,
terminal_attach_owners: HashMap::new(),
next_activity_stamp: 1,
effective_size: (MIN_COLS, MIN_ROWS),
@@ -2263,6 +2321,194 @@ new_tab = "prefix+t"
}
#[test]
+ fn local_keybinding_client_hides_server_keybinding_warnings() {
+ let mut server = test_headless_server();
+ let diagnostics = vec![
+ "unsafe direct keybinding: keys.close_pane = \"x\" would intercept typing".to_owned(),
+ "theme warning".to_owned(),
+ ];
+ let (full, without_keybindings) = server_config_diagnostic_summaries(&diagnostics);
+ server.server_config_diagnostic = full.clone();
+ server.server_config_diagnostic_without_keybindings = without_keybindings.clone();
+ server.app.state.config_diagnostic = full;
+ let local_keybindings = crate::config::Config::default().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.config_diagnostic, without_keybindings);
+
+ 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.config_diagnostic,
+ server.server_config_diagnostic
+ );
+ }
+
+ #[test]
+ fn local_keybinding_client_keeps_local_keybindings_after_settings_save() {
+ let path = std::env::temp_dir().join(format!(
+ "herdr-headless-settings-{}-{}.toml",
+ std::process::id(),
+ std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .map(|d| d.as_nanos())
+ .unwrap_or(0)
+ ));
+ std::fs::write(&path, "onboarding = false\n").unwrap();
+ let _guard = crate::config::test_config_env_lock().lock().unwrap();
+ std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path);
+
+ let mut server = test_headless_server();
+ let local_config: crate::config::Config = toml::from_str(
+ r#"
+[keys]
+prefix = "ctrl+a"
+new_workspace = "prefix+n"
+next_tab = ""
+"#,
+ )
+ .unwrap();
+ let local_keybindings = local_config.live_keybinds().unwrap();
+ let (writer, _control, _render) = 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,
+ }));
+ server.app.state.mode = crate::app::Mode::Settings;
+ server.app.state.settings.section = crate::app::state::SettingsSection::Toast;
+ server.app.state.settings.list.selected = 1;
+
+ assert!(server.handle_server_event(ServerEvent::ClientInput {
+ client_id: 1,
+ data: b"\r".to_vec(),
+ }));
+
+ assert_eq!(
+ server.app.state.prefix_code,
+ crossterm::event::KeyCode::Char('a')
+ );
+ assert!(server
+ .app
+ .state
+ .keybinds
+ .new_workspace
+ .bindings
+ .iter()
+ .any(|binding| binding.label == "prefix+n"));
+ assert!(server.app.state.toast.is_none());
+ let content = std::fs::read_to_string(&path).unwrap();
+ assert!(content.contains("delivery = \"herdr\""));
+
+ std::env::remove_var(crate::config::CONFIG_PATH_ENV_VAR);
+ let _ = std::fs::remove_file(path);
+ }
+
+ #[test]
+ fn invalid_server_keybindings_do_not_cache_local_keybindings_after_settings_save() {
+ let path = std::env::temp_dir().join(format!(
+ "herdr-headless-invalid-settings-{}-{}.toml",
+ std::process::id(),
+ std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .map(|d| d.as_nanos())
+ .unwrap_or(0)
+ ));
+ std::fs::write(
+ &path,
+ "onboarding = false\n[keys]\nnew_workspace = \"x\"\n[ui.toast]\ndelivery = \"off\"\n",
+ )
+ .unwrap();
+ let _guard = crate::config::test_config_env_lock().lock().unwrap();
+ std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path);
+
+ let mut server = test_headless_server();
+ let previous_server_config: crate::config::Config =
+ toml::from_str("[keys]\nprefix = \"ctrl+c\"\nnew_workspace = \"prefix+m\"\n").unwrap();
+ server.server_keybindings = previous_server_config.live_keybinds().unwrap();
+ let local_config: crate::config::Config = toml::from_str(
+ r#"
+[keys]
+prefix = "ctrl+a"
+new_workspace = "prefix+n"
+next_tab = ""
+"#,
+ )
+ .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_config.live_keybinds().unwrap())),
+ writer: writer_a,
+ }));
+ server.app.state.mode = crate::app::Mode::Settings;
+ server.app.state.settings.section = crate::app::state::SettingsSection::Toast;
+ server.app.state.settings.list.selected = 1;
+
+ assert!(server.handle_server_event(ServerEvent::ClientInput {
+ client_id: 1,
+ data: b"\r".to_vec(),
+ }));
+
+ 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('c')
+ );
+ assert!(server
+ .app
+ .state
+ .keybinds
+ .new_workspace
+ .bindings
+ .iter()
+ .any(|binding| binding.label == "prefix+m"));
+
+ std::env::remove_var(crate::config::CONFIG_PATH_ENV_VAR);
+ let _ = std::fs::remove_file(path);
+ }
+
+ #[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();