diff options
| author | Can Celik <ogulcancelik@gmail.com> | 2026-09-01 15:48:22 +0300 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-09-01 15:48:22 +0300 |
| commit | 207be3c771d281baae6e5fa0fb74be9a056e97a2 (patch) | |
| tree | d7b037ecdf629224a048c841d11659efdc23507d /src/client/shell/tests/keybindings_settings.rs | |
| parent | 8a6d697308162874a236c84aac0b0f8c7ac01f83 (diff) | |
refactor: render the shell in the client (#3487)
* feat: add opt-in client-rendered shell
* feat: add client-owned shell mouse controls
* feat: complete client-owned shell chrome
* feat: add client-owned shell settings and notifications
* feat: run client shell custom commands
* feat: render popup terminals in client shell
* feat: add client-owned scrollback and copy mode
* fix: preserve client worktree trust defaults
* feat: render startup diagnostics in client shell
* feat: render onboarding in client shell
* feat: render product announcements in client shell
* feat: render release notes in client shell
* feat: render mobile switcher in client shell
* feat: render kitty graphics in client shell
* fix: preserve client shell lifecycle coherence
* refactor: route client shell commands through endpoint connections
* refactor: make the client-rendered shell the default
* refactor: remove monolithic launch mode
* refactor: complete client-rendered shell cutover
* perf: retain client pane surface updates
* perf: scale retained pane surface updates
* perf: remove remote pane input latency
* test: make client shell checks platform-independent
* fix: prevent client timer starvation
* test: fix client shell platform gates
* test: accept retained surfaces in cross-area checks
* test: make client mode assertions platform-neutral
* fix: badge only outdated integrations
Diffstat (limited to 'src/client/shell/tests/keybindings_settings.rs')
| -rw-r--r-- | src/client/shell/tests/keybindings_settings.rs | 633 |
1 files changed, 633 insertions, 0 deletions
diff --git a/src/client/shell/tests/keybindings_settings.rs b/src/client/shell/tests/keybindings_settings.rs new file mode 100644 index 00000000..5d2d8243 --- /dev/null +++ b/src/client/shell/tests/keybindings_settings.rs @@ -0,0 +1,633 @@ +use super::*; + +#[test] +fn shell_new_controls_use_the_same_client_action_routes_as_keybinds() { + let mut config = Config::default(); + config.ui.prompt_new_workspace_name = false; + config.ui.prompt_new_tab_name = true; + let mut state = ClientShellState::new(ClientShellConfig::from_config(&config)); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + state.compose(106, 20).expect("composed frame"); + + let new_workspace = state.hits.new_workspace; + let create_workspace = + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: new_workspace.x + 1, + row: new_workspace.y, + modifiers: KeyModifiers::empty(), + })]); + let [ClientShellAction::Endpoint { request, .. }] = &create_workspace.actions[..] else { + panic!("new workspace click should use the endpoint API"); + }; + assert!(matches!( + request.method, + crate::api::schema::Method::WorkspaceCreate(_) + )); + + let new_tab = state.hits.new_tab; + let open_new_tab = + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: new_tab.x + 1, + row: new_tab.y, + modifiers: KeyModifiers::empty(), + })]); + assert!(open_new_tab.actions.is_empty()); + assert!(matches!( + state.overlay, + Some(ClientShellOverlay::Rename(ClientRenameOverlay { + target: ClientRenameTarget::NewTab { .. }, + .. + })) + )); +} + +#[test] +fn manual_client_chrome_preferences_round_trip_per_endpoint() { + let path = std::env::temp_dir().join(format!( + "herdr-client-shell-prefs-{}.json", + std::process::id() + )); + let _ = std::fs::remove_file(&path); + let config = + ClientShellConfig::from_config(&Config::default()).with_preferences_path(path.clone()); + let mut state = ClientShellState::new(config); + state.sidebar_width = 31; + state.sidebar_width_manual = true; + state.sidebar_section_split = 0.7; + state.sidebar_section_split_manual = true; + state.sidebar_collapsed = true; + state.sidebar_collapsed_manual = true; + state.collapsed_groups.insert("repo-two".into()); + state.collapsed_groups.insert("repo-one".into()); + state.persist_chrome_preferences(&mut ClientShellInput::default()); + + let reloaded_config = + ClientShellConfig::from_config(&Config::default()).with_preferences_path(path.clone()); + let reloaded = ClientShellState::new(reloaded_config); + assert_eq!(reloaded.sidebar_width, 31); + assert!(reloaded.sidebar_width_manual); + assert_eq!(reloaded.sidebar_section_split, 0.7); + assert!(reloaded.sidebar_section_split_manual); + assert!(reloaded.sidebar_collapsed); + assert!(reloaded.sidebar_collapsed_manual); + assert_eq!( + reloaded.collapsed_groups, + HashSet::from(["repo-one".to_string(), "repo-two".to_string()]) + ); + std::fs::remove_file(path).expect("remove client chrome preferences"); +} + +#[test] +fn tab_bar_renders_endpoint_status_ellipses_and_clamps_to_useful_scroll() { + let mut projected = snapshot(); + projected.tab_bar_right = vec![ + crate::protocol::ClientShellTabStatusSegment { + text: "ZOOM".into(), + accent: true, + }, + crate::protocol::ClientShellTabStatusSegment { + text: "host".into(), + accent: false, + }, + ]; + projected.tab_bar_right_separator = " · ".into(); + for number in 2..=8 { + projected.tabs.push(ClientShellTab { + tab_id: format!("tab_{number}"), + workspace_id: "ws_1".into(), + number, + label: number.to_string(), + custom_label: false, + zoomed: false, + focused: false, + agent_status: AgentStatus::Idle, + }); + } + let mut config = ClientShellConfig::from_config(&Config::default()); + config.mobile_width_threshold = 0; + let mut state = ClientShellState::new(config); + state.set_snapshot(Box::new(projected)); + state.set_pane_surface(surface()); + let frame = state.compose(106, 20).expect("status and overflow tabs"); + let top = frame.cells[..frame.width as usize] + .iter() + .map(|cell| cell.symbol.as_str()) + .collect::<String>(); + assert!(top.contains("ZOOM · host")); + assert!(top.contains('…')); + + state.tab_scroll = usize::MAX; + state.reveal_focused_tab = false; + state.compose(106, 20).expect("clamped tab scroll"); + assert!(state.tab_scroll < 7); + let manual_scroll = state.tab_scroll; + let mut replacement = (**state.snapshot.as_ref().expect("snapshot")).clone(); + replacement.revision = 2; + replacement.tab_bar_right[1].text = "tick".into(); + let mut replacement_surface = surface(); + replacement_surface.projection_revision = 2; + state.set_snapshot(Box::new(replacement)); + state.set_pane_surface(replacement_surface); + assert!(!state.reveal_focused_tab); + state.compose(106, 20).expect("same-width status update"); + assert_eq!(state.tab_scroll, manual_scroll); + + state.compose(45, 20).expect("narrow tabs win over status"); + let narrow = state.compose(45, 20).expect("narrow tab frame"); + let top = narrow.cells[..narrow.width as usize] + .iter() + .map(|cell| cell.symbol.as_str()) + .collect::<String>(); + assert!(!top.contains("ZOOM · host")); +} + +#[test] +fn configured_prefix_is_client_owned_and_renders_its_bar() { + let config = toml::from_str::<Config>( + r#" +[keys] +prefix = "ctrl+a" +detach = "prefix+x" +"#, + ) + .expect("configured keybinds"); + let mut state = ClientShellState::new(ClientShellConfig::from_config(&config)); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + + let old_default = state.handle_input_bytes(&[0x02]); + assert_eq!( + old_default.requests.len(), + 1, + "ctrl-b should reach the pane" + ); + + let prefix = state.handle_input_bytes(&[0x01]); + assert!(prefix.requests.is_empty()); + assert!(prefix.repaint); + let frame = state.compose(106, 20).expect("prefix frame"); + let text = frame + .cells + .chunks(frame.width as usize) + .map(|row| { + row.iter() + .map(|cell| cell.symbol.as_str()) + .collect::<String>() + }) + .collect::<Vec<_>>() + .join("\n"); + assert!(text.contains("PREFIX"), "frame: {text:?}"); + assert!(text.contains("ctrl+a"), "frame: {text:?}"); + + let detach = state.handle_input_bytes(b"x"); + assert!(detach.detach); + assert!(detach.requests.is_empty()); +} + +#[test] +fn prefix_endpoint_action_uses_public_api_with_stable_ids() { + let mut config = Config::default(); + config.ui.prompt_new_tab_name = false; + let mut state = ClientShellState::new(ClientShellConfig::from_config(&config)); + state.set_snapshot(Box::new(snapshot())); + + assert!(state.handle_input_bytes(&[0x02]).actions.is_empty()); + let create = state.handle_input_bytes(b"c"); + let [ClientShellAction::Endpoint { boot_id, request }] = &create.actions[..] else { + panic!("expected one endpoint action: {:?}", create.actions); + }; + assert_eq!(boot_id, "boot-1"); + match &request.method { + crate::api::schema::Method::TabCreate(params) => { + assert_eq!(params.workspace_id.as_deref(), Some("ws_1")); + assert!(params.focus); + } + other => panic!("expected tab.create, got {other:?}"), + } + assert!(state.pending_requests.contains_key(&request.id)); +} + +#[test] +fn remote_keybinding_sources_keep_local_commands_off_endpoints_and_apply_server_profiles() { + let local: Config = toml::from_str( + r#" +[keys] +prefix = "ctrl+a" +new_tab = "prefix+c" + +[[keys.command]] +key = "prefix+c" +command = "local-only" +"#, + ) + .unwrap(); + let remote_local = ClientShellConfig::from_config(&local) + .with_keybinding_source(ClientShellKeybindingSource::RemoteLocal); + assert_eq!(remote_local.keybinds.prefix.0, KeyCode::Char('a')); + assert!(remote_local.keybinds.keybinds.custom_commands.is_empty()); + assert_eq!( + remote_local.keybinds.keybinds.new_tab.label().as_deref(), + Some("prefix+c") + ); + + let mut local_state = ClientShellState::new( + ClientShellConfig::from_config(&local) + .with_keybinding_source(ClientShellKeybindingSource::Local), + ); + let mut local_projection = snapshot(); + local_projection + .commands + .push(crate::protocol::ClientShellCommand { + command_id: "cmd_loaded_endpoint".into(), + binding_label: "prefix+c / prefix+y".into(), + binding_labels: vec!["prefix+c".into(), "prefix+y".into()], + action: crate::protocol::ClientShellCommandAction::Shell, + description: Some("loaded endpoint command".into()), + }); + local_state.set_snapshot(Box::new(local_projection)); + assert_eq!( + local_state.config.keybinds.keybinds.custom_commands[0].label, + "prefix+y" + ); + assert_eq!( + local_state + .config + .keybinds + .keybinds + .new_tab + .label() + .as_deref(), + Some("prefix+c") + ); + let mut command_outcome = ClientShellInput::default(); + local_state.record_binding( + crate::input::KeybindMatch::Command( + local_state.config.keybinds.keybinds.custom_commands[0].clone(), + ), + &mut command_outcome, + ); + let [ClientShellAction::Endpoint { request, .. }] = &command_outcome.actions[..] else { + panic!("expected surviving endpoint command binding"); + }; + let crate::api::schema::Method::CommandInvoke(params) = &request.method else { + panic!("expected command invocation"); + }; + assert_eq!(params.command_id, "cmd_loaded_endpoint"); + + let mut id_only_projection = snapshot(); + id_only_projection.revision = 2; + id_only_projection + .commands + .push(crate::protocol::ClientShellCommand { + command_id: "cmd_reloaded_endpoint".into(), + binding_label: "prefix+c / prefix+y".into(), + binding_labels: vec!["prefix+c".into(), "prefix+y".into()], + action: crate::protocol::ClientShellCommandAction::Shell, + description: Some("loaded endpoint command".into()), + }); + local_state.mode = ClientShellMode::Prefix; + local_state.set_snapshot(Box::new(id_only_projection)); + assert_eq!(local_state.mode, ClientShellMode::Prefix); + assert_eq!( + local_state.config.keybinds.keybinds.custom_commands[0].command, + "cmd_reloaded_endpoint" + ); + + let endpoint: Config = toml::from_str( + r#" +[keys] +prefix = "ctrl+x" +new_tab = "prefix+n" +"#, + ) + .unwrap(); + let mut state = ClientShellState::new( + ClientShellConfig::from_config(&local) + .with_keybinding_source(ClientShellKeybindingSource::Endpoint), + ); + let mut projection = snapshot(); + projection.server_keybindings_toml = endpoint.local_keybindings_profile_toml().ok(); + projection + .commands + .push(crate::protocol::ClientShellCommand { + command_id: "cmd_remote".into(), + binding_label: "prefix+z".into(), + binding_labels: vec!["prefix+z".into()], + action: crate::protocol::ClientShellCommandAction::Shell, + description: Some("remote command".into()), + }); + state.set_snapshot(Box::new(projection)); + + assert_eq!(state.config.keybinds.prefix.0, KeyCode::Char('x')); + assert_eq!( + state.config.keybinds.keybinds.new_tab.label().as_deref(), + Some("prefix+n") + ); + assert_eq!( + state.config.keybinds.keybinds.custom_commands[0].label, + "prefix+z" + ); + assert_eq!( + state.config.keybinds.keybinds.custom_commands[0] + .description + .as_deref(), + Some("remote command") + ); + assert_eq!( + state.config.keybinds.keybinds.custom_commands[0].command, + "cmd_remote" + ); +} + +#[test] +fn custom_binding_invokes_only_the_endpoint_manifest_id() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + let binding = crate::config::CustomCommandKeybind { + bindings: crate::config::ActionKeybinds::prefix("z"), + label: "prefix+z".into(), + command: "secret-command --token hidden".into(), + action: crate::config::CustomCommandAction::Shell, + description: None, + width: None, + height: None, + }; + let mut projection = snapshot(); + projection + .commands + .push(crate::protocol::ClientShellCommand { + command_id: "cmd_0123456789abcdef0123456789abcdef".into(), + binding_label: binding.label.clone(), + binding_labels: binding.bindings.labels(), + action: crate::protocol::ClientShellCommandAction::Shell, + description: None, + }); + state.set_snapshot(Box::new(projection)); + + let mut outcome = ClientShellInput::default(); + state.record_binding(crate::input::KeybindMatch::Command(binding), &mut outcome); + + let [ClientShellAction::Endpoint { request, .. }] = &outcome.actions[..] else { + panic!("expected endpoint command invocation"); + }; + let crate::api::schema::Method::CommandInvoke(params) = &request.method else { + panic!("expected command.invoke"); + }; + assert_eq!(params.command_id, "cmd_0123456789abcdef0123456789abcdef"); + assert_eq!(params.workspace_id.as_deref(), Some("ws_1")); + assert_eq!(params.tab_id.as_deref(), Some("tab_1")); + assert_eq!(params.pane_id.as_deref(), Some("pane_1")); + assert_eq!(params.selection, None); + assert!(!serde_json::to_string(request) + .unwrap() + .contains("secret-command")); +} + +#[test] +fn plugin_command_carries_client_owned_selection_coordinates() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + let binding = crate::config::CustomCommandKeybind { + bindings: crate::config::ActionKeybinds::prefix("p"), + label: "prefix+p".into(), + command: "plugin.action".into(), + action: crate::config::CustomCommandAction::PluginAction, + description: None, + width: None, + height: None, + }; + let mut projection = snapshot(); + projection + .commands + .push(crate::protocol::ClientShellCommand { + command_id: "cmd_plugin".into(), + binding_label: binding.label.clone(), + binding_labels: binding.bindings.labels(), + action: crate::protocol::ClientShellCommandAction::PluginAction, + description: None, + }); + state.set_snapshot(Box::new(projection)); + let mut pane_surface = surface(); + pane_surface.panes[0].content_revision = 42; + state.set_pane_surface(pane_surface); + let mut selection = + crate::selection::Selection::absolute_range("pane_1".to_owned(), (2, 3), (4, 5)); + assert!(selection.finish()); + state.selection = Some(selection); + + let mut outcome = ClientShellInput::default(); + state.record_binding(crate::input::KeybindMatch::Command(binding), &mut outcome); + + let [ClientShellAction::Endpoint { request, .. }] = &outcome.actions[..] else { + panic!("expected endpoint command invocation"); + }; + let crate::api::schema::Method::CommandInvoke(params) = &request.method else { + panic!("expected command.invoke"); + }; + assert_eq!( + params.selection, + Some(crate::api::schema::PaneSelectionReadParams { + pane_id: "pane_1".into(), + anchor: crate::api::schema::PaneTextPoint { row: 2, col: 3 }, + cursor: crate::api::schema::PaneTextPoint { row: 4, col: 5 }, + content_revision: Some(42), + }) + ); +} + +#[test] +fn generic_endpoint_failures_and_control_errors_are_visible() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + let mut outcome = ClientShellInput::default(); + state.push_endpoint_method( + crate::api::schema::Method::WorkspaceFocus(crate::api::schema::WorkspaceTarget { + workspace_id: "missing".into(), + }), + &mut outcome, + ); + let request_id = match &outcome.actions[..] { + [ClientShellAction::Endpoint { request, .. }] => request.id.clone(), + other => panic!("expected generic endpoint request, got {other:?}"), + }; + let (repaint, actions) = state.handle_endpoint_result( + "boot-1", + &request_id, + Err(ClientShellEndpointError { + code: Some("not_found".into()), + message: "workspace no longer exists".into(), + }), + ); + assert!(repaint); + assert!(actions.is_empty()); + assert_eq!( + state.endpoint_error.as_deref(), + Some("workspace no longer exists") + ); + + assert!(state.receive_endpoint_error("Paste rejected: too large".into())); + assert_eq!( + state.endpoint_error.as_deref(), + Some("Paste rejected: too large") + ); + assert!(!state.receive_endpoint_error("Paste rejected: too large".into())); +} + +#[test] +fn custom_binding_missing_from_endpoint_manifest_is_not_forwarded() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + let binding = crate::config::CustomCommandKeybind { + bindings: crate::config::ActionKeybinds::prefix("z"), + label: "prefix+z".into(), + command: "secret-command".into(), + action: crate::config::CustomCommandAction::Shell, + description: None, + width: None, + height: None, + }; + + let mut outcome = ClientShellInput::default(); + state.record_binding(crate::input::KeybindMatch::Command(binding), &mut outcome); + + assert!(outcome.actions.is_empty()); + assert!(outcome.repaint); + assert!(state + .endpoint_error + .as_deref() + .is_some_and(|error| error.contains("not available"))); +} + +#[test] +fn help_overlay_restores_released_search_scroll_and_custom_binding_behavior() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + let mut projection = snapshot(); + projection + .commands + .push(crate::protocol::ClientShellCommand { + command_id: "plugin-action".into(), + binding_label: "prefix+z".into(), + binding_labels: vec!["prefix+z".into()], + action: crate::protocol::ClientShellCommandAction::PluginAction, + description: Some("run plugin action".into()), + }); + state.set_snapshot(Box::new(projection)); + state.set_pane_surface(surface()); + let mut open = ClientShellInput::default(); + state.record_binding( + crate::input::KeybindMatch::Action(crate::input::KeybindAction::Help), + &mut open, + ); + let initial = state.compose(106, 30).expect("help overlay"); + let text = initial + .cells + .chunks(initial.width as usize) + .map(|row| { + row.iter() + .map(|cell| cell.symbol.as_str()) + .collect::<String>() + }) + .collect::<Vec<_>>() + .join("\n"); + assert!(text.contains("global")); + assert!(state.hits.help_max_scroll > 0); + assert_ne!(state.hits.help_scrollbar, Rect::default()); + + state.handle_input_bytes(b"/"); + state.handle_input_bytes(b"plugin"); + let custom = state.compose(106, 30).expect("custom help search"); + let text = custom + .cells + .chunks(custom.width as usize) + .map(|row| { + row.iter() + .map(|cell| cell.symbol.as_str()) + .collect::<String>() + }) + .collect::<Vec<_>>() + .join("\n"); + assert!(text.contains("custom")); + assert!(text.contains("run plugin action")); + state.handle_input_bytes(b"\x1b"); + + state.handle_input_bytes(b"/"); + state.handle_input_bytes(b"does-not-exist"); + let empty = state.compose(106, 30).expect("empty help search"); + let text = empty + .cells + .chunks(empty.width as usize) + .map(|row| { + row.iter() + .map(|cell| cell.symbol.as_str()) + .collect::<String>() + }) + .collect::<Vec<_>>() + .join("\n"); + assert!(text.contains("no matching keybinds")); + + state.handle_input_bytes(b"\x1b"); + assert!(matches!( + state.overlay, + Some(ClientShellOverlay::Help(ClientHelpOverlay { + search_focused: false, + ref query, + scroll: 0, + })) if query.is_empty() + )); + state.compose(106, 30).expect("restored help"); + state.handle_input_bytes(b"\x1b[F"); + assert!(matches!( + state.overlay, + Some(ClientShellOverlay::Help(ClientHelpOverlay { scroll, .. })) + if scroll == state.hits.help_max_scroll + )); + state.handle_input_bytes(b"\x1b[H"); + assert!(matches!( + state.overlay, + Some(ClientShellOverlay::Help(ClientHelpOverlay { + scroll: 0, + .. + })) + )); + state.handle_input_bytes(b"?"); + assert!(state.overlay.is_none()); +} + +#[test] +fn resize_mode_reuses_endpoint_resize_and_stays_active_until_done() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + + assert!(state.handle_input_bytes(&[0x02]).actions.is_empty()); + assert!(state.handle_input_bytes(b"r").actions.is_empty()); + assert_eq!(state.mode, ClientShellMode::Resize); + + let modified = state.handle_input_bytes(b"\x1b[1;2D"); + assert!(matches!( + &modified.actions[..], + [ClientShellAction::Endpoint { request, .. }] + if matches!( + &request.method, + crate::api::schema::Method::PaneResize(params) + if params.direction == crate::api::schema::PaneDirection::Left + ) + )); + assert_eq!(state.mode, ClientShellMode::Resize); + + let resize = state.handle_input_bytes(b"h"); + let [ClientShellAction::Endpoint { request, .. }] = &resize.actions[..] else { + panic!("resize should use endpoint API"); + }; + assert!(matches!( + &request.method, + crate::api::schema::Method::PaneResize(params) + if params.pane_id.as_deref() == Some("pane_1") + && params.direction == crate::api::schema::PaneDirection::Left + )); + assert_eq!(state.mode, ClientShellMode::Resize); + + assert!(state.handle_input_bytes(b"\r").actions.is_empty()); + assert_eq!(state.mode, ClientShellMode::Terminal); +} |
