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/popup_focus_projection.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/popup_focus_projection.rs')
| -rw-r--r-- | src/client/shell/tests/popup_focus_projection.rs | 1226 |
1 files changed, 1226 insertions, 0 deletions
diff --git a/src/client/shell/tests/popup_focus_projection.rs b/src/client/shell/tests/popup_focus_projection.rs new file mode 100644 index 00000000..6ccdcf6e --- /dev/null +++ b/src/client/shell/tests/popup_focus_projection.rs @@ -0,0 +1,1226 @@ +use super::*; + +#[test] +fn clipboard_image_targets_the_focused_pane_or_active_popup() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + + assert_eq!( + state.clipboard_image_target(), + Some(crate::protocol::ClientClipboardImageTarget::Pane( + "pane_1".into() + )) + ); + + state.mode = ClientShellMode::Prefix; + assert_eq!(state.clipboard_image_target(), None); + state.mode = ClientShellMode::Terminal; + state.overlay = Some(ClientShellOverlay::Onboarding); + assert_eq!(state.clipboard_image_target(), None); + state.overlay = None; + + state.set_pane_surface(surface_with_popup()); + assert_eq!( + state.clipboard_image_target(), + Some(crate::protocol::ClientClipboardImageTarget::Popup( + "terminal-popup".into() + )) + ); + state.overlay = Some(ClientShellOverlay::Onboarding); + assert_eq!(state.clipboard_image_target(), None); +} + +#[test] +fn modal_paste_target_requires_a_focused_editable_client_field() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + assert!(!state.modal_paste_target_active()); + + state.overlay = Some(ClientShellOverlay::Rename(ClientRenameOverlay { + title: "rename pane", + input: String::new(), + replace_on_type: false, + target: ClientRenameTarget::Pane { + pane_id: "pane_1".into(), + }, + })); + assert!(state.modal_paste_target_active()); + + state.overlay = Some(ClientShellOverlay::WorktreeCreate( + ClientWorktreeCreateOverlay { + source_workspace_id: "ws_1".into(), + repo_name: "repo".into(), + branch: String::new(), + checkout_path: String::new(), + replace_on_type: false, + error: None, + creating: true, + }, + )); + assert!(!state.modal_paste_target_active()); + if let Some(ClientShellOverlay::WorktreeCreate(create)) = state.overlay.as_mut() { + create.creating = false; + } + assert!(state.modal_paste_target_active()); + + state.overlay = Some(ClientShellOverlay::WorktreeOpen( + ClientWorktreeOpenOverlay { + source_workspace_id: "ws_1".into(), + entries: Vec::new(), + selected: 0, + query: String::new(), + search_focused: false, + error: None, + opening: false, + }, + )); + assert!(!state.modal_paste_target_active()); + if let Some(ClientShellOverlay::WorktreeOpen(open)) = state.overlay.as_mut() { + open.search_focused = true; + } + assert!(state.modal_paste_target_active()); + + state.overlay = Some(ClientShellOverlay::Navigator(ClientNavigatorOverlay { + query: String::new(), + search_focused: false, + selected: 0, + scroll: 0, + filter: None, + expanded_workspaces: HashSet::new(), + })); + assert!(!state.modal_paste_target_active()); + if let Some(ClientShellOverlay::Navigator(navigator)) = state.overlay.as_mut() { + navigator.search_focused = true; + } + assert!(state.modal_paste_target_active()); + + state.overlay = Some(ClientShellOverlay::Help(ClientHelpOverlay { + query: String::new(), + search_focused: false, + scroll: 0, + })); + assert!(!state.modal_paste_target_active()); + if let Some(ClientShellOverlay::Help(help)) = state.overlay.as_mut() { + help.search_focused = true; + } + assert!(state.modal_paste_target_active()); + + state.overlay = None; + state.copy_mode = Some(ClientCopyModeState { + pane_id: "pane_1".into(), + content_revision: 0, + geometry: (80, 24), + cursor: crate::api::schema::PaneTextPoint { row: 0, col: 0 }, + offset_from_bottom: 0, + max_offset_from_bottom: 0, + entry_offset_from_bottom: 0, + selection: None, + search_prompt: Some(ClientCopySearchPrompt { + direction: crate::api::schema::PaneCopySearchDirection::Forward, + query: String::new(), + }), + search_query: String::new(), + search_direction: None, + search_matches: Vec::new(), + search_total: 0, + search_current: None, + search_current_global: None, + search_generation: 0, + copy_after_search: false, + }); + assert!(state.modal_paste_target_active()); + state.popup_pending = true; + assert!(!state.modal_paste_target_active()); +} + +#[test] +fn non_overlay_ctrl_v_is_forwarded_to_the_focused_pane() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + let key = crate::input::TerminalKey::new(KeyCode::Char('v'), KeyModifiers::CONTROL); + + let outcome = state.handle_raw_events(vec![RawInputEvent::Key(key)]); + + assert!(matches!( + &outcome.requests[..], + [ClientMessage::ClientShellPaneInput { pane_id, events }] + if pane_id == "pane_1" + && matches!(&events[..], [ClientPaneInputEvent::Key { .. }]) + )); +} + +#[test] +fn desktop_composition_keeps_shell_outside_origin_relative_surface() { + let config = ClientShellConfig::from_config(&Config::default()); + let mut state = ClientShellState::new(config); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + + let frame = state.compose(106, 20).expect("composed 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("spaces")); + assert!(text.contains("client-shell")); + assert!(text.contains("main")); + assert!(text.contains("LIVE")); + assert!(!text.contains("1 1")); + assert_eq!( + frame.cursor.as_ref().map(|cursor| (cursor.x, cursor.y)), + Some((27, 2)) + ); +} + +#[test] +fn client_composes_popup_terminal_content_inside_client_owned_chrome() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface_with_popup()); + + let frame = state.compose(106, 20).expect("popup frame"); + let popup = state.hits.popup.as_ref().expect("popup hit geometry"); + assert_eq!(popup.rect.width, 12); + assert_eq!(popup.rect.height, 5); + assert_eq!(popup.inner_rect.width, 9); + assert_eq!(popup.inner_rect.height, 3); + 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("popup tit")); + assert!(text.contains("popup-liv")); + assert_eq!( + frame.cursor.as_ref().map(|cursor| (cursor.x, cursor.y)), + Some((popup.inner_rect.x + 2, popup.inner_rect.y + 1)) + ); +} + +#[test] +fn popup_owns_keys_text_paste_and_mouse_before_shell_controls() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface_with_popup()); + state.compose(106, 20).expect("popup frame"); + + for bytes in [b"x".as_slice(), b"\x02".as_slice(), b"\x1b".as_slice()] { + let input = state.handle_input_bytes(bytes); + assert!(matches!( + &input.requests[..], + [ClientMessage::ClientShellPopupInput { terminal_id, .. }] + if terminal_id == "terminal-popup" + )); + assert_eq!(state.mode, ClientShellMode::Terminal); + } + + let text = state.handle_raw_events(vec![RawInputEvent::Text(crate::input::TextCommit::new( + "ime", + ))]); + assert!(matches!( + &text.requests[..], + [ClientMessage::ClientShellPopupInput { events, .. }] + if matches!(&events[..], [ClientPaneInputEvent::TextCommit(value)] if value == "ime") + )); + let paste = state.handle_raw_events(vec![RawInputEvent::Paste("paste".into())]); + assert!(matches!( + &paste.requests[..], + [ClientMessage::ClientShellPopupInput { events, .. }] + if matches!(&events[..], [ClientPaneInputEvent::Paste(value)] if value == "paste") + )); + + let popup = state.hits.popup.as_ref().expect("popup hit").clone(); + let mouse = state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: popup.inner_rect.x + 3, + row: popup.inner_rect.y + 1, + modifiers: KeyModifiers::empty(), + })]); + assert!(matches!( + &mouse.requests[..], + [ClientMessage::ClientShellPopupInput { events, .. }] + if matches!( + &events[..], + [ClientPaneInputEvent::Mouse { + position: ClientMousePosition::Cell { column: 3, row: 1 }, + .. + }] + ) + )); + assert!(state.pane_mouse_gesture.is_some()); + state.set_pane_surface(surface()); + assert!(state.pane_mouse_gesture.is_none()); +} + +#[test] +fn popup_transition_dismisses_client_overlays_and_restores_pane_input_after_close() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + state.record_binding( + crate::input::KeybindMatch::Action(crate::input::KeybindAction::Help), + &mut ClientShellInput::default(), + ); + assert!(state.overlay.is_some()); + + state.set_pane_surface(surface_with_popup()); + assert!(state.overlay.is_none()); + assert_eq!(state.mode, ClientShellMode::Terminal); + let popup_input = state.handle_input_bytes(b"p"); + assert!(matches!( + &popup_input.requests[..], + [ClientMessage::ClientShellPopupInput { .. }] + )); + + state.set_pane_surface(surface()); + let pane_input = state.handle_input_bytes(b"p"); + assert!(matches!( + &pane_input.requests[..], + [ClientMessage::ClientShellPaneInput { pane_id, .. }] if pane_id == "pane_1" + )); +} + +#[test] +fn popup_target_survives_surface_invalidation_during_resize() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface_with_popup()); + state.invalidate_pane_surface(); + + assert!(matches!( + &state.handle_input_bytes(b"x").requests[..], + [ClientMessage::ClientShellPopupInput { terminal_id, .. }] + if terminal_id == "terminal-popup" + )); + let mouse = state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: 0, + row: 0, + modifiers: KeyModifiers::empty(), + })]); + assert!(mouse.requests.is_empty()); + assert!(mouse.actions.is_empty()); +} + +#[test] +fn popup_close_reprocesses_held_key_repeats_into_the_focused_pane() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface_with_popup()); + + let press = state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new( + KeyCode::Char('x'), + KeyModifiers::empty(), + ))]); + assert!(matches!( + &press.requests[..], + [ClientMessage::ClientShellPopupInput { .. }] + )); + + state.set_pane_surface(surface()); + let repeat = state.handle_raw_events(vec![RawInputEvent::Key( + crate::input::TerminalKey::new(KeyCode::Char('x'), KeyModifiers::empty()) + .with_kind(crossterm::event::KeyEventKind::Repeat), + )]); + assert!(matches!( + &repeat.requests[..], + [ClientMessage::ClientShellPaneInput { pane_id, .. }] if pane_id == "pane_1" + )); +} + +#[test] +fn pending_popup_suppresses_held_pane_repeats_but_preserves_release() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + let key = crate::input::TerminalKey::new(KeyCode::Char('x'), KeyModifiers::empty()); + assert!(matches!( + &state + .handle_raw_events(vec![RawInputEvent::Key(key.clone())]) + .requests[..], + [ClientMessage::ClientShellPaneInput { .. }] + )); + + state.popup_pending = true; + let repeat = state.handle_raw_events(vec![RawInputEvent::Key( + key.clone() + .with_kind(crossterm::event::KeyEventKind::Repeat), + )]); + assert!(repeat.requests.is_empty()); + let release = state.handle_raw_events(vec![RawInputEvent::Key( + key.with_kind(crossterm::event::KeyEventKind::Release), + )]); + assert!(matches!( + &release.requests[..], + [ClientMessage::ClientShellPaneInput { events, .. }] + if matches!( + &events[..], + [ClientPaneInputEvent::Key { + kind: crate::protocol::ClientKeyKind::Release, + .. + }] + ) + )); +} + +#[test] +fn prefix_input_source_changes_are_client_owned_and_focus_safe() { + let mut config = ClientShellConfig::from_config(&Config::default()); + config.switch_ascii_input_source_in_prefix = true; + let mut state = ClientShellState::new(config); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + assert!(state.take_input_source_changes().is_empty()); + + let prefix = crate::input::TerminalKey::new(KeyCode::Char('b'), KeyModifiers::CONTROL); + state.handle_raw_events(vec![RawInputEvent::Key(prefix)]); + assert_eq!(state.take_input_source_changes(), vec![true]); + + state.handle_raw_events(vec![RawInputEvent::OuterFocusLost]); + assert!(state.take_input_source_changes().is_empty()); + let escape = crate::input::TerminalKey::new(KeyCode::Esc, KeyModifiers::empty()); + state.handle_raw_events(vec![RawInputEvent::Key(escape.clone())]); + assert!(state.take_input_source_changes().is_empty()); + state.handle_raw_events(vec![RawInputEvent::OuterFocusGained]); + assert_eq!(state.take_input_source_changes(), vec![false]); + + state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new( + KeyCode::Char('b'), + KeyModifiers::CONTROL, + ))]); + assert_eq!(state.take_input_source_changes(), vec![true]); + state.handle_raw_events(vec![RawInputEvent::OuterFocusLost]); + state.handle_raw_events(vec![RawInputEvent::OuterFocusGained]); + assert!(state.take_input_source_changes().is_empty()); + state.handle_raw_events(vec![RawInputEvent::Key(escape)]); + assert_eq!(state.take_input_source_changes(), vec![false]); +} + +#[test] +fn focus_loss_releases_held_pane_keys_before_reporting_focus() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + let key = crate::input::TerminalKey::new(KeyCode::Char('x'), KeyModifiers::empty()) + .with_generated_text(Some("x".to_owned())) + .with_windows_record(crate::input::WindowsKeyRecord { + key_down: true, + repeat_count: 1, + virtual_key_code: 0x58, + virtual_scan_code: 0x2d, + unicode: 'x' as u16, + control_key_state: 0, + }); + let press = state.handle_raw_events(vec![RawInputEvent::Key(key)]); + assert!(matches!( + &press.requests[..], + [ClientMessage::ClientShellPaneInput { .. }] + )); + + let lost = state.handle_raw_events(vec![RawInputEvent::OuterFocusLost]); + assert!(matches!( + &lost.requests[..], + [ + ClientMessage::ClientShellPaneInput { events, .. }, + ClientMessage::ClientShellFocus { focused: false } + ] if matches!( + &events[..], + [ClientPaneInputEvent::Key { + kind: crate::protocol::ClientKeyKind::Release, + .. + }] + ) + )); + assert!(state.input_leases.is_empty()); +} + +#[test] +fn focus_loss_releases_active_pane_mouse_before_reporting_focus() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + let mut pane_surface = surface(); + pane_surface.panes[0].mouse_reporting = true; + state.set_pane_surface(pane_surface); + state.compose(106, 20).expect("pane frame"); + let pane = state.hits.panes[0].clone(); + + let down = state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: pane.inner_rect.x + 2, + row: pane.inner_rect.y + 1, + modifiers: KeyModifiers::empty(), + })]); + assert!(matches!( + &down.requests[..], + [ClientMessage::ClientShellPaneInput { .. }] + )); + assert!(state.pane_mouse_gesture.is_some()); + + let lost = state.handle_raw_events(vec![RawInputEvent::OuterFocusLost]); + assert!(matches!( + &lost.requests[..], + [ + ClientMessage::ClientShellPaneInput { pane_id, events }, + ClientMessage::ClientShellFocus { focused: false } + ] if pane_id == "pane_1" && matches!( + &events[..], + [ClientPaneInputEvent::Mouse { + kind: crate::protocol::ClientMouseKind::Up( + crate::protocol::ClientMouseButton::Left + ), + position: ClientMousePosition::Cell { column: 2, row: 1 }, + .. + }] + ) + )); + assert!(state.pane_mouse_gesture.is_none()); +} + +#[test] +fn focus_gain_reports_focus_and_honors_redraw_policy() { + let mut config = Config::default(); + config.ui.redraw_on_focus_gained = false; + let mut state = ClientShellState::new(ClientShellConfig::from_config(&config)); + let gained = state.handle_raw_events(vec![RawInputEvent::OuterFocusGained]); + assert!(!gained.repaint); + assert!(gained.query_host_appearance); + assert!(matches!( + &gained.requests[..], + [ClientMessage::ClientShellFocus { focused: true }] + )); +} + +#[test] +fn pane_mouse_release_survives_popup_open_transition() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + let mut pane_surface = surface(); + pane_surface.panes[0].mouse_reporting = true; + state.set_pane_surface(pane_surface); + state.compose(106, 20).expect("pane frame"); + let pane = state.hits.panes[0].clone(); + + let down = state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: pane.inner_rect.x, + row: pane.inner_rect.y, + modifiers: KeyModifiers::empty(), + })]); + assert!(matches!( + &down.requests[..], + [ClientMessage::ClientShellPaneInput { .. }] + )); + assert!(state.pane_mouse_gesture.is_some()); + + state.set_pane_surface(surface_with_popup()); + let up = state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Up(MouseButton::Left), + column: 0, + row: 0, + modifiers: KeyModifiers::empty(), + })]); + assert!(matches!( + &up.requests[..], + [ClientMessage::ClientShellPaneInput { pane_id, events }] + if pane_id == "pane_1" + && matches!( + &events[..], + [ClientPaneInputEvent::Mouse { + kind: crate::protocol::ClientMouseKind::Up( + crate::protocol::ClientMouseButton::Left + ), + .. + }] + ) + )); + assert!(state.pane_mouse_gesture.is_none()); +} + +#[test] +fn popup_command_blocks_underlying_input_until_surface_or_error() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + let binding = crate::config::CustomCommandKeybind { + bindings: crate::config::ActionKeybinds::prefix("t"), + label: "prefix+t".into(), + command: "secret-popup-command".into(), + action: crate::config::CustomCommandAction::Popup, + description: None, + width: None, + height: None, + }; + let mut projection = snapshot(); + projection + .commands + .push(crate::protocol::ClientShellCommand { + command_id: "cmd_popup".into(), + binding_label: binding.label.clone(), + binding_labels: binding.bindings.labels(), + action: crate::protocol::ClientShellCommandAction::Popup, + description: None, + }); + state.set_snapshot(Box::new(projection)); + state.set_pane_surface(surface()); + + let mut invoke = ClientShellInput::default(); + state.record_binding(crate::input::KeybindMatch::Command(binding), &mut invoke); + assert!(state.popup_pending); + assert!(state + .handle_input_bytes(b"not-for-pane") + .requests + .is_empty()); + assert!(state + .handle_raw_events(vec![RawInputEvent::Paste("secret".into())]) + .requests + .is_empty()); + + let request_id = match &invoke.actions[..] { + [ClientShellAction::Endpoint { request, .. }] => request.id.clone(), + other => panic!("expected popup command request, got {other:?}"), + }; + let (repaint, _) = state.handle_endpoint_result( + "boot-1", + &request_id, + Err(ClientShellEndpointError { + code: Some("command_failed".into()), + message: "popup failed".into(), + }), + ); + assert!(repaint); + assert!(!state.popup_pending); + assert!(matches!( + &state.handle_input_bytes(b"p").requests[..], + [ClientMessage::ClientShellPaneInput { .. }] + )); + + let binding = crate::config::CustomCommandKeybind { + bindings: crate::config::ActionKeybinds::prefix("t"), + label: "prefix+t".into(), + command: "secret-popup-command".into(), + action: crate::config::CustomCommandAction::Popup, + description: None, + width: None, + height: None, + }; + let mut invoke = ClientShellInput::default(); + state.record_binding(crate::input::KeybindMatch::Command(binding), &mut invoke); + let request_id = match &invoke.actions[..] { + [ClientShellAction::Endpoint { request, .. }] => request.id.clone(), + other => panic!("expected popup command request, got {other:?}"), + }; + state.handle_endpoint_result( + "boot-1", + &request_id, + Ok(crate::api::schema::ResponseResult::Ok {}), + ); + assert!(state.popup_pending); + assert!(state + .handle_input_bytes(b"still-blocked") + .requests + .is_empty()); + let deadline = state.popup_pending_deadline.expect("pending timeout"); + state.tick_popup_pending(deadline); + assert!(!state.popup_pending); +} + +#[test] +fn shell_refuses_mismatched_projection_and_clears_stale_hits_in_either_order() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + state.compose(106, 20).expect("initial frame"); + assert!(!state.hits.panes.is_empty()); + + let mut replacement = snapshot(); + replacement.revision = 2; + state.set_snapshot(Box::new(replacement)); + assert!(state.hits.panes.is_empty()); + assert!(state.compose(106, 20).is_none()); + + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + state.compose(106, 20).expect("initial frame"); + let mut replacement_surface = surface(); + replacement_surface.projection_revision = 2; + state.set_pane_surface(replacement_surface); + assert!(state.hits.panes.is_empty()); + assert!(state.compose(106, 20).is_none()); +} + +#[test] +fn shell_ignores_older_same_boot_snapshot_and_surface() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + let mut current_snapshot = snapshot(); + current_snapshot.revision = 2; + current_snapshot.workspaces[0].label = "current".into(); + state.set_snapshot(Box::new(current_snapshot)); + let mut current_surface = surface(); + current_surface.projection_revision = 2; + current_surface.frame.cells[0].symbol = "N".into(); + state.set_pane_surface(current_surface); + state.compose(106, 20).expect("current shell"); + assert!(!state.hits.panes.is_empty()); + let held_key = crate::input::TerminalKey::new(KeyCode::Char('x'), KeyModifiers::empty()); + assert!(matches!( + &state + .handle_raw_events(vec![RawInputEvent::Key(held_key.clone())]) + .requests[..], + [ClientMessage::ClientShellPaneInput { .. }] + )); + + let mut stale_snapshot = snapshot(); + stale_snapshot.workspaces[0].label = "stale".into(); + state.set_snapshot(Box::new(stale_snapshot)); + let mut stale_surface = surface(); + stale_surface.frame.cells[0].symbol = "O".into(); + state.set_pane_surface(stale_surface); + + let installed_snapshot = state.snapshot.as_deref().expect("current snapshot"); + assert_eq!(installed_snapshot.revision, 2); + assert_eq!(installed_snapshot.workspaces[0].label, "current"); + let installed_surface = state.pane_surface.as_ref().expect("current pane surface"); + assert_eq!(installed_surface.projection_revision, 2); + assert_eq!(installed_surface.frame.cells[0].symbol, "N"); + + let mut ahead_surface = surface(); + ahead_surface.projection_revision = 4; + ahead_surface.frame.cells[0].symbol = "A".into(); + state.set_pane_surface(ahead_surface); + let mut delayed_surface = surface(); + delayed_surface.projection_revision = 3; + delayed_surface.frame.cells[0].symbol = "D".into(); + state.set_pane_surface(delayed_surface); + let installed_surface = state.pane_surface.as_ref().expect("newest pane surface"); + assert_eq!(installed_surface.projection_revision, 4); + assert_eq!(installed_surface.frame.cells[0].symbol, "A"); + + let mut replacement_boot = snapshot(); + replacement_boot.boot_id = "boot-2".into(); + state.set_snapshot(Box::new(replacement_boot)); + assert!(state.pane_surface.is_none()); + assert!(state.hits.panes.is_empty()); + let release = state.handle_raw_events(vec![RawInputEvent::Key( + held_key.with_kind(crossterm::event::KeyEventKind::Release), + )]); + assert!( + release.requests.is_empty(), + "boot replacement must discard held input leases" + ); + let mut prior_boot_surface = surface(); + prior_boot_surface.projection_revision = u64::MAX; + state.set_pane_surface(prior_boot_surface); + assert!( + state.pane_surface.is_none(), + "an old endpoint surface must not cross the boot boundary" + ); +} + +#[test] +fn resize_invalidation_drops_stale_hits_but_preserves_gesture_release() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + let mut pane_surface = surface(); + pane_surface.panes[0].mouse_reporting = true; + state.set_pane_surface(pane_surface); + state.compose(106, 20).expect("composed frame"); + let pane = state.hits.panes[0].clone(); + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: pane.inner_rect.x + 1, + row: pane.inner_rect.y + 1, + modifiers: KeyModifiers::empty(), + })]); + assert!(state.pane_mouse_gesture.is_some()); + + state.invalidate_pane_surface(); + assert!(state.pane_surface.is_none()); + assert!(state.hits.panes.is_empty()); + let stale_click = + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Right), + column: 27, + row: 1, + modifiers: KeyModifiers::empty(), + })]); + assert!(stale_click.requests.is_empty()); + assert!(stale_click.actions.is_empty()); + + let release = + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Up(MouseButton::Left), + column: pane.inner_rect.x + 1, + row: pane.inner_rect.y + 1, + modifiers: KeyModifiers::empty(), + })]); + assert!(matches!( + &release.requests[..], + [ClientMessage::ClientShellPaneInput { pane_id, events }] + if pane_id == "pane_1" + && matches!( + &events[..], + [ClientPaneInputEvent::Mouse { + kind: crate::protocol::ClientMouseKind::Up( + crate::protocol::ClientMouseButton::Left + ), + .. + }] + ) + )); + assert!(state.pane_mouse_gesture.is_none()); +} + +#[test] +fn pane_scrollbar_track_and_thumb_use_stable_endpoint_scroll_requests() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + let mut pane_surface = surface(); + pane_surface.panes[0].scrollbar_rect = Some(SurfaceRect { + x: 3, + y: 0, + width: 1, + height: 2, + }); + pane_surface.panes[0].scroll = Some(crate::protocol::PaneSurfaceScrollMetrics { + offset_from_bottom: 0, + max_offset_from_bottom: 20, + viewport_rows: 2, + }); + state.set_pane_surface(pane_surface); + state.compose(106, 20).expect("composed frame"); + let pane = state.hits.panes[0].clone(); + let track = pane.scrollbar_rect.expect("scrollbar track"); + let metrics = pane.scroll.expect("scroll metrics"); + + let track_click = + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: track.x, + row: track.y, + modifiers: KeyModifiers::empty(), + })]); + let expected = crate::ui::scrollbar_offset_from_row(metrics, track, track.y); + assert!(track_click.requests.is_empty()); + assert!(matches!( + &track_click.actions[..], + [ + ClientShellAction::Endpoint { request: focus, .. }, + ClientShellAction::Endpoint { request: scroll, .. } + ] if matches!( + &focus.method, + crate::api::schema::Method::PaneFocus(target) if target.pane_id == "pane_1" + ) && matches!( + &scroll.method, + crate::api::schema::Method::PaneScroll(params) + if params.pane_id == "pane_1" + && params.offset_from_bottom == expected as u64 + ) + )); + let track_scroll_id = match &track_click.actions[1] { + ClientShellAction::Endpoint { request, .. } => request.id.clone(), + _ => unreachable!(), + }; + state.handle_endpoint_result( + "boot-1", + &track_scroll_id, + Ok(pane_scroll_result(expected as u64, 20, 2)), + ); + + let thumb = crate::ui::scrollbar_thumb(metrics, track).expect("scrollbar thumb"); + let thumb_down = + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: track.x, + row: thumb.top, + modifiers: KeyModifiers::empty(), + })]); + assert!(matches!( + &thumb_down.actions[..], + [ClientShellAction::Endpoint { request, .. }] + if matches!( + &request.method, + crate::api::schema::Method::PaneFocus(target) if target.pane_id == "pane_1" + ) + )); + assert!(matches!( + state.chrome_drag, + Some(ClientChromeDrag::PaneScrollbar { .. }) + )); + + let drag = state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Drag(MouseButton::Left), + column: track.x, + row: track.y, + modifiers: KeyModifiers::empty(), + })]); + let expected = crate::ui::scrollbar_offset_from_drag_row(metrics, track, track.y, 0); + assert!(matches!( + &drag.actions[..], + [ClientShellAction::Endpoint { request, .. }] + if matches!( + &request.method, + crate::api::schema::Method::PaneScroll(params) + if params.pane_id == "pane_1" + && params.offset_from_bottom == expected as u64 + ) + )); + let release = + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Up(MouseButton::Left), + column: 0, + row: 0, + modifiers: KeyModifiers::empty(), + })]); + assert!(release.actions.is_empty()); + assert!(state.chrome_drag.is_none()); +} + +#[test] +fn edit_scrollback_binding_targets_the_focused_endpoint_pane() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + let mut input = ClientShellInput::default(); + state.record_binding( + crate::input::KeybindMatch::Action(crate::input::KeybindAction::EditScrollback), + &mut input, + ); + + assert!(input.requests.is_empty()); + assert!(matches!( + &input.actions[..], + [ClientShellAction::Endpoint { request, .. }] + if matches!( + &request.method, + crate::api::schema::Method::PaneEditScrollback(target) + if target.pane_id == "pane_1" + ) + )); +} + +#[test] +fn sidebar_scrollbars_use_proportional_shared_geometry_and_drag() { + let mut projected = snapshot(); + for index in 2..=10 { + let mut workspace = projected.workspaces[0].clone(); + workspace.workspace_id = format!("ws_{index}"); + workspace.number = index; + workspace.label = format!("workspace-{index}"); + workspace.focused = false; + projected.workspaces.push(workspace); + } + for index in 1..=10 { + projected.agents.push(crate::protocol::ClientShellAgent { + pane_id: format!("agent-pane-{index}"), + workspace_id: "ws_1".into(), + tab_id: "tab_1".into(), + name: Some(format!("agent-{index}")), + display_agent: None, + agent: Some("codex".into()), + title: None, + terminal_title: None, + terminal_title_stripped: None, + agent_status: AgentStatus::Idle, + state_change_seq: index, + state_labels: Vec::new(), + tokens: Vec::new(), + focused: false, + }); + } + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(projected)); + state.set_pane_surface(surface()); + state.compose(106, 20).expect("overflowing sidebars"); + + for agent in [false, true] { + let (track, metrics) = if agent { + ( + state.hits.agent_scrollbar, + state.hits.agent_scroll_metrics.expect("agent metrics"), + ) + } else { + ( + state.hits.workspace_scrollbar, + state + .hits + .workspace_scroll_metrics + .expect("workspace metrics"), + ) + }; + assert!(track.width > 0); + let thumb = crate::ui::scrollbar_thumb(metrics, track).expect("scrollbar thumb"); + assert!(thumb.len > 1); + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: track.x, + row: thumb.top, + modifiers: KeyModifiers::empty(), + })]); + let dragged = + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Drag(MouseButton::Left), + column: track.x, + row: track.bottom().saturating_sub(1), + modifiers: KeyModifiers::empty(), + })]); + assert!(dragged.repaint); + if agent { + assert_eq!(state.agent_scroll, metrics.max_offset_from_bottom); + } else { + assert_eq!(state.workspace_scroll, metrics.max_offset_from_bottom); + } + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Up(MouseButton::Left), + column: track.x, + row: track.bottom().saturating_sub(1), + modifiers: KeyModifiers::empty(), + })]); + } +} + +#[test] +fn popup_preemption_cancels_settings_theme_preview() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + state.open_settings_overlay(); + let original_theme = state.config.theme_name.clone(); + let original_palette = state.config.palette.clone(); + + state.handle_input_bytes(b"j"); + assert_ne!(state.config.theme_name, original_theme); + assert_ne!(state.config.palette.accent, original_palette.accent); + + state.set_pane_surface(surface_with_popup()); + + assert!(!matches!( + state.overlay, + Some(ClientShellOverlay::Settings(_)) + )); + assert_eq!(state.config.theme_name, original_theme); + assert_eq!(state.config.palette, original_palette); +} + +#[test] +fn pending_scroll_target_does_not_relabel_an_older_surface() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + let mut pane_surface = surface(); + pane_surface.panes[0].scroll = Some(crate::protocol::PaneSurfaceScrollMetrics { + offset_from_bottom: 0, + max_offset_from_bottom: 20, + viewport_rows: 2, + }); + state.set_pane_surface(pane_surface.clone()); + let mut outcome = ClientShellInput::default(); + state.push_pane_scroll_offset("pane_1".into(), 10, &mut outcome); + state.set_pane_surface(pane_surface); + assert_eq!( + state + .pane_surface + .as_ref() + .and_then(|surface| surface.panes[0].scroll) + .map(|scroll| scroll.offset_from_bottom), + Some(0) + ); + assert_eq!(state.pane_scroll_targets.get("pane_1"), Some(&10)); +} + +#[test] +fn retained_surface_patch_updates_only_pane_cells_without_recomposing_chrome() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + let pane_surface = surface(); + let mut updated_pane = pane_surface.panes[0].clone(); + updated_pane.content_revision = 2; + state.set_pane_surface(pane_surface); + let composed = state.compose(100, 30).expect("initial composed frame"); + let layout = state.layout(100, 30); + let patch = crate::protocol::PaneSurfacePatch { + boot_id: "boot-1".into(), + projection_revision: 1, + base_surface_revision: 1, + surface_revision: 2, + rows: vec![crate::protocol::PaneSurfacePatchRow { + x: 0, + y: 0, + cells: vec![ + crate::protocol::CellData { + symbol: "N".into(), + fg: 0, + bg: 0, + modifier: 0, + skip: false, + hyperlink: None, + }; + 4 + ], + }], + panes: vec![updated_pane], + cursor: None, + }; + + let ClientPaneSurfacePatchOutcome::Applied(Some(patch)) = state.apply_pane_surface_patch(patch) + else { + panic!("expected fast retained patch"); + }; + let patched = apply_composed_surface_patch(&composed, patch).expect("apply composed patch"); + let pane_index = usize::from(layout.pane_surface.y) * usize::from(patched.width) + + usize::from(layout.pane_surface.x); + assert_eq!(patched.cells[pane_index].symbol, "N"); + assert_eq!( + patched.cells[0], composed.cells[0], + "sidebar chrome changed" + ); + assert_eq!(state.pane_surface.as_ref().unwrap().surface_revision, 2); + + let mut forced_full = surface(); + forced_full.surface_revision = 3; + forced_full.frame.cells[0].symbol = "F".into(); + state.set_pane_surface(forced_full); + assert_eq!(state.pane_surface.as_ref().unwrap().surface_revision, 3); + assert_eq!( + state.pane_surface.as_ref().unwrap().frame.cells[0].symbol, + "F" + ); +} + +#[test] +fn retained_surface_patch_recomposes_client_owned_mode_and_diagnostic_rows() { + for diagnostic in [false, true] { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + let pane_surface = surface(); + let mut updated_pane = pane_surface.panes[0].clone(); + updated_pane.content_revision = 2; + state.set_pane_surface(pane_surface); + state.compose(100, 30).expect("initial composed frame"); + if diagnostic { + state.config_diagnostic = Some("invalid config".into()); + } else { + state.mode = ClientShellMode::Prefix; + } + let patch = crate::protocol::PaneSurfacePatch { + boot_id: "boot-1".into(), + projection_revision: 1, + base_surface_revision: 1, + surface_revision: 2, + rows: Vec::new(), + panes: vec![updated_pane], + cursor: None, + }; + + assert!(matches!( + state.apply_pane_surface_patch(patch), + ClientPaneSurfacePatchOutcome::Applied(None) + )); + } +} + +#[test] +fn retained_surface_patch_updates_scrollbar_cells_and_pane_hit_metadata() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + let mut pane_surface = surface(); + pane_surface.frame = FrameData::from_ratatui_buffer_with_hyperlinks( + &Buffer::with_lines(["LIVE ", "PANE "]), + None, + &[], + ); + pane_surface.panes[0].rect.width = 5; + state.set_pane_surface(pane_surface.clone()); + let composed = state.compose(100, 30).expect("initial composed frame"); + let layout = state.layout(100, 30); + let mut updated_pane = pane_surface.panes[0].clone(); + updated_pane.scrollbar_rect = Some(SurfaceRect { + x: 4, + y: 0, + width: 1, + height: 2, + }); + updated_pane.scroll = Some(crate::protocol::PaneSurfaceScrollMetrics { + offset_from_bottom: 2, + max_offset_from_bottom: 8, + viewport_rows: 2, + }); + updated_pane.mouse_reporting = true; + updated_pane.sgr_pixel_mouse = true; + let patch = crate::protocol::PaneSurfacePatch { + boot_id: "boot-1".into(), + projection_revision: 1, + base_surface_revision: 1, + surface_revision: 2, + rows: vec![crate::protocol::PaneSurfacePatchRow { + x: 4, + y: 0, + cells: vec![crate::protocol::CellData { + symbol: "▐".into(), + fg: 0, + bg: 0, + modifier: 0, + skip: false, + hyperlink: None, + }], + }], + panes: vec![updated_pane], + cursor: None, + }; + + let ClientPaneSurfacePatchOutcome::Applied(Some(patch)) = state.apply_pane_surface_patch(patch) + else { + panic!("expected fast retained patch"); + }; + let patched = apply_composed_surface_patch(&composed, patch).expect("apply composed patch"); + let scrollbar_index = usize::from(layout.pane_surface.y) * usize::from(patched.width) + + usize::from(layout.pane_surface.x + 4); + assert_eq!(patched.cells[scrollbar_index].symbol, "▐"); + let hit = state + .hits + .panes + .iter() + .find(|hit| hit.pane_id == "pane_1") + .expect("pane hit"); + assert_eq!( + hit.scrollbar_rect, + Some(Rect::new( + layout.pane_surface.x + 4, + layout.pane_surface.y, + 1, + 2, + )) + ); + assert_eq!( + hit.scroll.map(|scroll| scroll.max_offset_from_bottom), + Some(8) + ); + assert!(hit.mouse_reporting); + assert!(hit.sgr_pixel_mouse); +} + +#[test] +fn retained_surface_patch_rejects_stale_base_without_mutating_surface() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + let before = state.pane_surface.clone(); + let outcome = state.apply_pane_surface_patch(crate::protocol::PaneSurfacePatch { + boot_id: "boot-1".into(), + projection_revision: 1, + base_surface_revision: 0, + surface_revision: 2, + rows: Vec::new(), + panes: Vec::new(), + cursor: None, + }); + assert!(matches!(outcome, ClientPaneSurfacePatchOutcome::Rejected)); + assert_eq!(state.pane_surface, before); +} |
