diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-05-20 22:03:49 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-05-20 22:08:26 +0300 |
| commit | df357b250c0fcf4edf2681d4f114e199afb51899 (patch) | |
| tree | eba744d9238057e8758e2098670e0561d920d998 /src | |
| parent | c584037c77c63f6a08f8837f357c89432b8043e7 (diff) | |
fix: clarify keybinding conflict warnings
refs #154
Diffstat (limited to 'src')
| -rw-r--r-- | src/app/input/modal.rs | 38 | ||||
| -rw-r--r-- | src/app/input/navigate.rs | 12 | ||||
| -rw-r--r-- | src/app/input/sidebar.rs | 8 | ||||
| -rw-r--r-- | src/app/mod.rs | 28 | ||||
| -rw-r--r-- | src/app/state.rs | 6 | ||||
| -rw-r--r-- | src/config/io.rs | 39 | ||||
| -rw-r--r-- | src/config/keybinds.rs | 15 | ||||
| -rw-r--r-- | src/config/model.rs | 7 | ||||
| -rw-r--r-- | src/main.rs | 3 | ||||
| -rw-r--r-- | src/ui/keybind_help.rs | 3 | ||||
| -rw-r--r-- | src/ui/menus.rs | 6 | ||||
| -rw-r--r-- | src/ui/status.rs | 41 |
12 files changed, 116 insertions, 90 deletions
diff --git a/src/app/input/modal.rs b/src/app/input/modal.rs index 5b585221..084f44a9 100644 --- a/src/app/input/modal.rs +++ b/src/app/input/modal.rs @@ -67,7 +67,7 @@ pub(super) fn modal_action_from_buttons<A: Copy>( #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum GlobalMenuAction { - Quit, + Detach, WhatsNew, Keybinds, ReloadConfig, @@ -83,7 +83,7 @@ pub(super) fn global_menu_actions(state: &AppState) -> Vec<GlobalMenuAction> { if state.update_available.is_some() || state.latest_release_notes_available { actions.push(GlobalMenuAction::WhatsNew); } - actions.push(GlobalMenuAction::Quit); + actions.push(GlobalMenuAction::Detach); actions } @@ -111,19 +111,19 @@ fn open_update_release_notes(state: &mut AppState) { state.mode = Mode::ReleaseNotes; } -pub(super) fn request_quit_or_detach(state: &mut AppState) { - if state.quit_detaches { - state.detach_requested = true; - } else { +pub(super) fn request_detach(state: &mut AppState) { + if state.detach_exits { state.should_quit = true; + } else { + state.detach_requested = true; } } pub(super) fn apply_global_menu_action(state: &mut AppState, action: GlobalMenuAction) { match action { - GlobalMenuAction::Quit => { + GlobalMenuAction::Detach => { leave_modal(state); - request_quit_or_detach(state); + request_detach(state); } GlobalMenuAction::WhatsNew => open_update_release_notes(state), GlobalMenuAction::Keybinds => open_keybind_help(state), @@ -690,6 +690,28 @@ mod tests { } #[test] + fn detach_requests_client_detach_in_persistence_mode() { + let mut state = state_with_workspaces(&["test"]); + state.detach_exits = false; + + request_detach(&mut state); + + assert!(state.detach_requested); + assert!(!state.should_quit); + } + + #[test] + fn detach_exits_in_no_session_mode() { + let mut state = state_with_workspaces(&["test"]); + state.detach_exits = true; + + request_detach(&mut state); + + assert!(state.should_quit); + assert!(!state.detach_requested); + } + + #[test] fn global_menu_whats_new_opens_saved_release_notes() { let _guard = config_env_lock().lock().unwrap(); let path = temp_config_path("whats-new-saved-release-notes"); diff --git a/src/app/input/navigate.rs b/src/app/input/navigate.rs index 8bf10832..17180fed 100644 --- a/src/app/input/navigate.rs +++ b/src/app/input/navigate.rs @@ -479,7 +479,6 @@ pub(crate) enum NavigateAction { CyclePanePrevious, Help, Settings, - Quit, ReloadConfig, OpenNotificationTarget, Detach, @@ -545,7 +544,6 @@ fn action_for_key( for (bindings, action) in [ (&kb.help, NavigateAction::Help), (&kb.settings, NavigateAction::Settings), - (&kb.quit, NavigateAction::Quit), (&kb.workspace_picker, NavigateAction::WorkspacePicker), (&kb.new_workspace, NavigateAction::NewWorkspace), (&kb.rename_workspace, NavigateAction::RenameWorkspace), @@ -727,10 +725,6 @@ pub(super) fn execute_navigate_action_in_context( } NavigateAction::Help => super::modal::open_keybind_help(state), NavigateAction::Settings => super::settings::open_settings(state), - NavigateAction::Quit => { - super::modal::request_quit_or_detach(state); - leave_navigate_mode(state); - } NavigateAction::ReloadConfig => { state.request_reload_config = true; leave_navigate_mode(state); @@ -742,7 +736,7 @@ pub(super) fn execute_navigate_action_in_context( } } NavigateAction::Detach => { - state.detach_requested = true; + super::modal::request_detach(state); leave_navigate_mode(state); } } @@ -1449,9 +1443,9 @@ mod tests { } #[test] - fn persistence_mode_navigate_q_detaches_instead_of_quitting_server() { + fn navigate_q_detaches_in_persistence_mode() { let mut state = crate::app::state::AppState::test_new(); - state.quit_detaches = true; + state.detach_exits = false; handle_navigate_key( &mut state, diff --git a/src/app/input/sidebar.rs b/src/app/input/sidebar.rs index 7491c9c1..dc192e50 100644 --- a/src/app/input/sidebar.rs +++ b/src/app/input/sidebar.rs @@ -192,7 +192,7 @@ impl AppState { } else if self.latest_release_notes_available { labels.push("what's new"); } - labels.push(if self.quit_detaches { "detach" } else { "quit" }); + labels.push("detach"); labels } @@ -578,7 +578,7 @@ mod tests { "keybinds", "reload config", "update ready", - "quit" + "detach" ] ); assert!(!app.state.should_quit); @@ -587,7 +587,7 @@ mod tests { #[test] fn persistence_mode_menu_surfaces_detach_action() { let mut app = app_for_mouse_test(); - app.state.quit_detaches = true; + app.state.detach_exits = false; let launcher = app.state.global_launcher_rect(); app.handle_mouse(mouse( @@ -625,7 +625,7 @@ mod tests { "keybinds", "reload config", "what's new", - "quit" + "detach" ] ); } diff --git a/src/app/mod.rs b/src/app/mod.rs index 63bf690d..b87eec66 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -338,7 +338,7 @@ impl App { selected, mode, should_quit: false, - quit_detaches: !no_session, + detach_exits: no_session, detach_requested: false, request_new_workspace: false, request_new_tab: false, @@ -466,10 +466,7 @@ impl App { }); Self { - config_diagnostic_deadline: state - .config_diagnostic - .as_ref() - .map(|_| Instant::now() + Duration::from_secs(8)), + config_diagnostic_deadline: None, toast_deadline: None, state, event_tx, @@ -797,7 +794,7 @@ impl App { self.state.toast = None; self.state.config_diagnostic = crate::config::config_diagnostic_summary(&diagnostics); - self.config_diagnostic_deadline = Some(Instant::now() + Duration::from_secs(8)); + self.config_diagnostic_deadline = None; crate::config::ConfigReloadReport { status: crate::config::ConfigReloadStatus::Failed, diagnostics, @@ -827,11 +824,11 @@ impl App { self.state.keybinds = live.keybinds; } Err(keybind_diagnostics) => { - let mut message = keybind_diagnostics.join("; "); - if !message.contains("keeping current keybinds") { - message.push_str("; keeping current keybinds"); - } - diagnostics.push(message); + diagnostics.extend( + keybind_diagnostics + .into_iter() + .map(|diagnostic| format!("{diagnostic}; kept current keybinds")), + ); } } } @@ -928,7 +925,7 @@ impl App { } } else { self.state.config_diagnostic = crate::config::config_diagnostic_summary(&diagnostics); - self.config_diagnostic_deadline = Some(Instant::now() + Duration::from_secs(8)); + self.config_diagnostic_deadline = None; if notify_success { self.state.toast = Some(crate::app::state::ToastNotification { kind: crate::app::state::ToastKind::UpdateInstalled, @@ -1545,8 +1542,7 @@ mod tests { .config_diagnostic .as_deref() .is_some_and(|message| { - message.contains("keys.new_workspace") - && message.contains("keeping current keybinds") + message.contains("keys.new_workspace") && message.contains("kept current keybinds") })); std::env::remove_var(crate::config::CONFIG_PATH_ENV_VAR); @@ -2514,7 +2510,7 @@ mod tests { app.state.workspaces = vec![Workspace::test_new("test")]; app.state.active = Some(0); app.state.selected = 0; - app.state.quit_detaches = true; + app.state.detach_exits = false; // Start in navigate mode. app.state.mode = Mode::Navigate; @@ -2540,7 +2536,7 @@ mod tests { app.state.workspaces = vec![Workspace::test_new("test")]; app.state.active = Some(0); app.state.selected = 0; - app.state.quit_detaches = true; + app.state.detach_exits = false; // Start in terminal mode (default after workspace creation). app.state.mode = Mode::Terminal; diff --git a/src/app/state.rs b/src/app/state.rs index 921a82c9..7ee79c4a 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -900,8 +900,8 @@ pub struct AppState { pub selected: usize, pub mode: Mode, pub should_quit: bool, - /// In persistence mode, client quit actions detach instead of stopping the server. - pub quit_detaches: bool, + /// In monolithic --no-session mode, detach exits the app because there is no server to detach from. + pub detach_exits: bool, /// Set when the current client should detach from the persistent session. /// The server's event loop checks this and handles client detach. pub detach_requested: bool, @@ -1159,7 +1159,7 @@ impl AppState { selected: 0, mode: Mode::Navigate, should_quit: false, - quit_detaches: false, + detach_exits: false, detach_requested: false, request_new_workspace: false, request_new_tab: false, diff --git a/src/config/io.rs b/src/config/io.rs index 12176815..3b483ba6 100644 --- a/src/config/io.rs +++ b/src/config/io.rs @@ -92,17 +92,22 @@ pub fn config_path() -> PathBuf { } pub fn config_diagnostic_summary(diagnostics: &[String]) -> Option<String> { + const MAX_VISIBLE_DIAGNOSTICS: usize = 4; + if diagnostics.is_empty() { - None - } else if diagnostics.len() == 1 { - Some(diagnostics[0].clone()) - } else { - Some(format!( - "{} (and {} more)", - diagnostics[0], - diagnostics.len() - 1 - )) + return None; + } + + let mut lines: Vec<String> = diagnostics + .iter() + .take(MAX_VISIBLE_DIAGNOSTICS) + .map(|diagnostic| diagnostic.split_whitespace().collect::<Vec<_>>().join(" ")) + .collect(); + let hidden = diagnostics.len().saturating_sub(MAX_VISIBLE_DIAGNOSTICS); + if hidden > 0 { + lines.push(format!("and {hidden} more config warnings")); } + Some(lines.join("\n")) } pub fn load_live_config() -> Result<LoadedConfig, Vec<String>> { @@ -435,6 +440,22 @@ mod tests { } #[test] + fn config_diagnostic_summary_keeps_multiple_warnings_visible() { + let diagnostics = vec![ + "one".to_string(), + "two".to_string(), + "three".to_string(), + "four".to_string(), + "five".to_string(), + ]; + + assert_eq!( + config_diagnostic_summary(&diagnostics).as_deref(), + Some("one\ntwo\nthree\nfour\nand 1 more config warnings") + ); + } + + #[test] fn remove_keybinding_config_sections_removes_keys_tables_only() { let content = r#"onboarding = false diff --git a/src/config/keybinds.rs b/src/config/keybinds.rs index 58c26020..3a4cf754 100644 --- a/src/config/keybinds.rs +++ b/src/config/keybinds.rs @@ -245,7 +245,6 @@ pub struct CustomCommandKeybind { pub struct Keybinds { pub help: ActionKeybinds, pub settings: ActionKeybinds, - pub quit: ActionKeybinds, pub new_workspace: ActionKeybinds, pub rename_workspace: ActionKeybinds, pub close_workspace: ActionKeybinds, @@ -375,7 +374,6 @@ impl Config { let mut keybinds = Keybinds { help: action!("keys.help", &self.keys.help), settings: action!("keys.settings", &self.keys.settings), - quit: action!("keys.quit", &self.keys.quit), new_workspace: action!("keys.new_workspace", &self.keys.new_workspace), rename_workspace: action!("keys.rename_workspace", &self.keys.rename_workspace), close_workspace: action!("keys.close_workspace", &self.keys.close_workspace), @@ -619,10 +617,7 @@ fn reject_binding( } if let Some(first_field) = registry.conflict(binding) { - let diag = format!( - "duplicate keybinding: {field} = {:?} conflicts with {}; disabling binding", - binding.label, first_field - ); + let diag = format!("{}: kept {first_field}, disabled {field}", binding.label); warn!(message = %diag, "config diagnostic"); diagnostics.push(diag); return true; @@ -1232,7 +1227,7 @@ command = "echo no" assert!(!keybinds.new_tab.bindings.is_empty()); assert!(keybinds.custom_commands.is_empty()); assert!(diagnostics.iter().any(|diag| { - diag.contains("duplicate keybinding") && diag.contains("keys.command[0].key") + diag.contains("kept keys.new_tab") && diag.contains("disabled keys.command[0].key") })); } @@ -1296,8 +1291,8 @@ new_workspace = "prefix+n" let diagnostics = config.collect_diagnostics(); let kb = config.keybinds(); assert!(kb.next_tab.bindings.is_empty() || kb.new_workspace.bindings.is_empty()); - assert!(diagnostics - .iter() - .any(|diag| diag.contains("duplicate keybinding"))); + assert!(diagnostics.iter().any(|diag| { + diag.contains("kept keys.new_workspace") && diag.contains("disabled keys.next_tab") + })); } } diff --git a/src/config/model.rs b/src/config/model.rs index 10e80417..f0557072 100644 --- a/src/config/model.rs +++ b/src/config/model.rs @@ -98,8 +98,6 @@ pub struct KeysConfig { pub help: BindingConfig, /// Open settings. Default: "prefix+s" pub settings: BindingConfig, - /// Quit or detach. Default: "prefix+q" - pub quit: BindingConfig, /// Create a new workspace. Default: "prefix+shift+n" pub new_workspace: BindingConfig, /// Rename the selected workspace. Default: "prefix+shift+w" @@ -108,7 +106,7 @@ pub struct KeysConfig { pub close_workspace: BindingConfig, /// Open the workspace navigation surface. Default: "prefix+w" pub workspace_picker: BindingConfig, - /// Optional explicit detach shortcut in server/client mode. Default: "prefix+d". + /// Detach from server/client mode, or exit --no-session mode. Default: "prefix+q". pub detach: BindingConfig, /// Reload config.toml in the running app/server. Default: "prefix+shift+r". pub reload_config: BindingConfig, @@ -234,12 +232,11 @@ impl Default for KeysConfig { prefix: "ctrl+b".into(), help: BindingConfig::one("prefix+?"), settings: BindingConfig::one("prefix+s"), - quit: BindingConfig::one("prefix+q"), new_workspace: BindingConfig::one("prefix+shift+n"), rename_workspace: BindingConfig::one("prefix+shift+w"), close_workspace: BindingConfig::one("prefix+shift+d"), workspace_picker: BindingConfig::one("prefix+w"), - detach: BindingConfig::one("prefix+d"), + detach: BindingConfig::one("prefix+q"), reload_config: BindingConfig::one("prefix+shift+r"), open_notification_target: BindingConfig::one("prefix+o"), previous_workspace: BindingConfig::empty(), diff --git a/src/main.rs b/src/main.rs index 2120555e..bbf04df2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -94,8 +94,7 @@ const DEFAULT_CONFIG: &str = r##"# herdr configuration # Prefix-mode actions # help = "prefix+?" # settings = "prefix+s" -# quit = "prefix+q" -# detach = "prefix+d" +# detach = "prefix+q" # reload_config = "prefix+shift+r" # open_notification_target = "prefix+o" # workspace_picker = "prefix+w" diff --git a/src/ui/keybind_help.rs b/src/ui/keybind_help.rs index fe3d7582..fb79077e 100644 --- a/src/ui/keybind_help.rs +++ b/src/ui/keybind_help.rs @@ -56,8 +56,7 @@ pub(super) fn keybind_help_groups( ), (keybind_label(&kb.help), "keybinds"), (keybind_label(&kb.settings), "settings"), - (keybind_label(&kb.quit), "quit"), - (keybind_label(&kb.detach), "detach from server"), + (keybind_label(&kb.detach), "detach"), (keybind_label(&kb.reload_config), "reload config"), ( keybind_label(&kb.open_notification_target), diff --git a/src/ui/menus.rs b/src/ui/menus.rs index aa5e3b79..c83b569f 100644 --- a/src/ui/menus.rs +++ b/src/ui/menus.rs @@ -76,7 +76,7 @@ pub(super) fn render_navigate_overlay(app: &AppState, frame: &mut Frame, area: R let resize = prefix_rhs_label(&kb.resize_mode); let help = prefix_rhs_label(&kb.help); let settings = prefix_rhs_label(&kb.settings); - let quit = prefix_rhs_label(&kb.quit); + let detach = prefix_rhs_label(&kb.detach); let line = Line::from(vec![ Span::styled(" NAVIGATE ", mode_style), Span::raw(" "), @@ -102,8 +102,8 @@ pub(super) fn render_navigate_overlay(app: &AppState, frame: &mut Frame, area: R Span::styled(" keybinds ", dim), Span::styled(settings, key), Span::styled(" settings ", dim), - Span::styled(quit, key), - Span::styled(" quit", dim), + Span::styled(detach, key), + Span::styled(" detach", dim), ]); let overlay_y = area.y + area.height.saturating_sub(1); diff --git a/src/ui/status.rs b/src/ui/status.rs index bb506da6..28f5d17b 100644 --- a/src/ui/status.rs +++ b/src/ui/status.rs @@ -75,26 +75,29 @@ pub(super) fn render_toast_notification( } pub(super) fn render_config_diagnostic(frame: &mut Frame, area: Rect, message: &str, p: &Palette) { - let text = format!(" config warning: {message} "); - let width = text.len() as u16 + 2; - let notif_area = Rect::new( - area.x + area.width.saturating_sub(width.min(area.width)), - area.y, - width.min(area.width), - 1, - ); + let style = Style::default() + .fg(panel_contrast_fg(p)) + .bg(p.yellow) + .add_modifier(Modifier::BOLD); - frame.render_widget(Clear, notif_area); - frame.render_widget( - Paragraph::new(Span::styled( - text, - Style::default() - .fg(panel_contrast_fg(p)) - .bg(p.yellow) - .add_modifier(Modifier::BOLD), - )), - notif_area, - ); + for (row, line) in message + .lines() + .filter(|line| !line.trim().is_empty()) + .take(area.height as usize) + .enumerate() + { + let text = format!(" config warning: {line} "); + let width = (text.len() as u16).min(area.width); + let notif_area = Rect::new( + area.x + area.width.saturating_sub(width), + area.y + row as u16, + width, + 1, + ); + + frame.render_widget(Clear, notif_area); + frame.render_widget(Paragraph::new(Span::styled(text, style)), notif_area); + } } pub(super) fn state_dot(state: AgentState, seen: bool, p: &Palette) -> (&'static str, Style) { |
