diff options
| author | akbash <akbash@herdr.dev> | 2026-09-09 02:35:31 +0300 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-09-09 02:35:31 +0300 |
| commit | b9ce96869e89937278d673d70ae4c135dd318469 (patch) | |
| tree | fea7e39dad42f8cad1f1846af6babda546c1fa78 /src/client/shell/tests/workspace_navigation.rs | |
| parent | 8be4cf765afddfd9cf3206602ba4dab682a29fa1 (diff) | |
feat: navigate and highlight workspaces across machines (#3755)
* fix: highlight workspace navigation with saved machines
refs #3754
* feat: navigate workspaces across connected machines
refs #3754
* feat: navigate workspaces across machines
refs #3754
---------
Co-authored-by: akbash-bot <300245827+akbash-bot@users.noreply.github.com>
Co-authored-by: Ogulcan Celik <ogulcancelik@gmail.com>
Diffstat (limited to 'src/client/shell/tests/workspace_navigation.rs')
| -rw-r--r-- | src/client/shell/tests/workspace_navigation.rs | 503 |
1 files changed, 503 insertions, 0 deletions
diff --git a/src/client/shell/tests/workspace_navigation.rs b/src/client/shell/tests/workspace_navigation.rs new file mode 100644 index 00000000..3e19b87c --- /dev/null +++ b/src/client/shell/tests/workspace_navigation.rs @@ -0,0 +1,503 @@ +use super::*; + +fn workspaces(count: usize) -> ClientShellSnapshot { + let mut projected = snapshot(); + projected.workspaces = (1..=count) + .map(|number| { + let mut workspace = projected.workspaces[0].clone(); + workspace.workspace_id = format!("ws_{number}"); + workspace.number = number; + workspace.focused = number == 1; + workspace + }) + .collect(); + projected +} + +fn grouped_workspaces() -> ClientShellSnapshot { + let mut projected = workspaces(3); + for (index, linked) in [(0, false), (2, true)] { + projected.workspaces[index].worktree = Some(ClientShellWorktree { + key: "repo".into(), + label: "repo".into(), + is_linked_worktree: linked, + }); + } + projected +} + +fn navigation_state(mut projected: ClientShellSnapshot) -> (ClientShellState, ClientEndpointId) { + let (mut state, remote) = state_with_remote(); + state.set_snapshot(Box::new(projected.clone())); + projected.boot_id = "remote-boot".into(); + state.set_endpoint_snapshot(&remote, Box::new(projected)); + (state, remote) +} + +fn preview_key(state: &mut ClientShellState, bytes: &[u8]) { + let outcome = state.handle_input_bytes(bytes); + assert!(outcome.actions.is_empty(), "{bytes:?}"); + assert!(outcome.requests.is_empty(), "{bytes:?}"); + assert!(outcome.repaint, "{bytes:?}"); +} + +fn enter_navigation(state: &mut ClientShellState) { + preview_key(state, &[0x02]); + preview_key(state, b"w"); + assert_eq!(state.mode, ClientShellMode::Navigate); +} + +fn assert_selected(state: &ClientShellState, endpoint: &ClientEndpointId, workspace: &str) { + assert_eq!( + state.navigate_workspace_id, + state.navigation_target(endpoint, workspace) + ); +} + +fn workspace_rect(state: &ClientShellState, endpoint: &ClientEndpointId, workspace: &str) -> Rect { + state + .hits + .workspaces + .iter() + .find(|hit| &hit.endpoint_id == endpoint && hit.workspace_id == workspace) + .map(|hit| hit.rect) + .or_else(|| { + state.hits.mobile_targets.iter().find_map(|(rect, target)| { + matches!(target, ClientMobileTarget::Workspace { endpoint_id, workspace_id } + if endpoint_id == endpoint && workspace_id == workspace) + .then_some(*rect) + }) + }) + .expect("visible workspace") +} + +#[test] +fn navigation_highlights_only_the_preview_and_activates_on_enter() { + for (compact, cols) in [(true, 100), (false, 100), (false, 44)] { + for terminal_theme in [false, true] { + let (mut state, remote) = navigation_state(workspaces(2)); + state.sidebar_collapsed = compact; + if terminal_theme { + state.config.palette = Palette::terminal(); + } + state.compose(cols, 28).unwrap(); + enter_navigation(&mut state); + for (endpoint, collision, steps) in [ + (&ClientEndpointId::Local, &remote, 1), + (&remote, &ClientEndpointId::Local, 2), + ] { + for _ in 0..steps { + preview_key(&mut state, b"\x1b[B"); + } + assert_selected(&state, endpoint, "ws_2"); + let buffer = state + .compose(cols, 28) + .unwrap() + .to_ratatui_buffer() + .unwrap(); + let selected = workspace_rect(&state, endpoint, "ws_2"); + let other = workspace_rect(&state, collision, "ws_2"); + let focused = workspace_rect(&state, &ClientEndpointId::Local, "ws_1"); + let palette = &state.config.palette; + let color = if cols == 44 { + palette.surface0 + } else { + palette.selection_bg + }; + let color = if color == ratatui::style::Color::Reset { + palette.active_row_bg + } else { + color + }; + assert_eq!(buffer[(selected.x + 2, selected.y)].bg, color); + assert_ne!(buffer[(other.x + 2, other.y)].bg, color); + assert_eq!( + buffer[(focused.x + 2, focused.y)].bg, + if cols == 44 { + palette.surface_dim + } else { + palette.active_row_bg + } + ); + } + assert_eq!(state.snapshot.as_ref().unwrap().boot_id, "boot-1"); + assert_eq!( + state + .snapshot + .as_ref() + .unwrap() + .focused_workspace_id + .as_deref(), + Some("ws_1") + ); + assert_eq!(state.pane_surface.as_ref().unwrap().boot_id, "boot-1"); + let enter = state.handle_input_bytes(b"\r"); + assert!(enter.requests.is_empty()); + assert!( + matches!(enter.actions.as_slice(), [ClientShellAction::ActivateEndpoint { + endpoint_id, target: Some(ClientEndpointFocusTarget::Workspace(id)), + }] if endpoint_id == &remote && id == "ws_2") + ); + assert_eq!(state.active_endpoint_id, ClientEndpointId::Local); + assert_eq!(state.mode, ClientShellMode::Terminal); + assert!(state.navigate_workspace_id.is_none()); + } + } +} + +#[test] +fn foreign_preview_blocks_keyboard_actions_but_keeps_active_action_context() { + let (mut state, remote) = state_with_remote(); + state.compose(100, 28).unwrap(); + enter_navigation(&mut state); + preview_key(&mut state, b"\x1b[B"); + for confirm in [false, true] { + state.config.confirm_close = confirm; + for key in [ + b"W".as_slice(), + b"D", + b"\x1b[D", + b"\x1b[C", + b"\t", + b"1", + b"c", + b"N", + ] { + preview_key(&mut state, key); + assert!(state.overlay.is_none()); + assert_eq!(state.mode, ClientShellMode::Navigate); + } + } + assert_selected(&state, &remote, "ws_1"); + let mut remote_snapshot = workspaces(2); + remote_snapshot.boot_id = "remote-boot".into(); + state.set_endpoint_snapshot(&remote, Box::new(remote_snapshot)); + preview_key(&mut state, b"\x1b[B"); + assert_selected(&state, &remote, "ws_2"); + assert_eq!(state.workspace_action_id().as_deref(), Some("ws_1")); + state.config.prompt_new_workspace_name = false; + let mut create = ClientShellInput::default(); + state.record_binding( + crate::input::KeybindMatch::Action(crate::input::KeybindAction::NewWorkspace), + &mut create, + ); + assert!( + matches!(create.actions.as_slice(), [ClientShellAction::Endpoint { endpoint_id: ClientEndpointId::Local, request, .. }] + if matches!(&request.method, crate::api::schema::Method::WorkspaceCreate(params) if params.source_workspace_id.as_deref() == Some("ws_1"))) + ); + preview_key(&mut state, b"\x1b"); + assert!(state.navigate_workspace_id.is_none()); + assert_eq!(state.active_endpoint_id, ClientEndpointId::Local); + assert!(state.activate_endpoint_projection(&remote)); + enter_navigation(&mut state); + preview_key(&mut state, b"W"); + assert!(matches!(state.overlay, Some(ClientShellOverlay::Rename(_)))); +} + +#[test] +fn empty_workspace_navigation_enter_exits_without_focusing() { + let (mut state, _) = state_with_remote(); + let mut empty = workspaces(0); + empty.tabs.clear(); + empty.panes.clear(); + empty.focused_workspace_id = None; + empty.focused_tab_id = None; + empty.focused_pane_id = None; + state.set_snapshot(Box::new(empty)); + enter_navigation(&mut state); + assert!(state.navigate_workspace_id.is_none()); + let enter = state.handle_input_bytes(b"\r"); + assert!(enter.actions.is_empty() && enter.requests.is_empty() && enter.repaint); + assert_eq!(state.mode, ClientShellMode::Terminal); +} + +#[test] +fn foreign_workspace_preview_blocks_paste_into_hidden_copy_search() { + let (mut state, _) = state_with_remote(); + 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); + state.compose(100, 28).unwrap(); + assert!(state.enter_copy_mode(&mut ClientShellInput::default())); + state.copy_mode.as_mut().unwrap().search_prompt = Some(ClientCopySearchPrompt { + direction: crate::api::schema::PaneCopySearchDirection::Forward, + query: "original".into(), + }); + enter_navigation(&mut state); + preview_key(&mut state, b"\x1b[B"); + assert!(state.workspace_preview_action_blocked()); + assert!(!state.modal_paste_target_active()); + let key = crate::input::TerminalKey::new(KeyCode::Char('v'), KeyModifiers::CONTROL); + assert!(!state.handle_modal_paste_shortcut_with( + &key, + &mut ClientShellInput::default(), + || { panic!("hidden search must not read the clipboard") } + )); + let paste = state.handle_raw_events(vec![RawInputEvent::Paste("unexpected".into())]); + assert!(paste.actions.is_empty() && paste.requests.is_empty()); + assert_eq!( + state.copy_mode.unwrap().search_prompt.unwrap().query, + "original" + ); +} + +#[test] +fn mouse_clicks_cancel_remote_workspace_navigation() { + for pane in [false, true] { + let (mut state, _) = state_with_remote(); + state.compose(100, 28).unwrap(); + enter_navigation(&mut state); + preview_key(&mut state, b"\x1b[B"); + state.compose(100, 28).unwrap(); + let rect = if pane { + state.hits.panes[0].inner_rect + } else { + workspace_rect(&state, &ClientEndpointId::Local, "ws_1") + }; + for kind in [ + MouseEventKind::Down(MouseButton::Left), + MouseEventKind::Up(MouseButton::Left), + ] { + state.handle_raw_events(vec![RawInputEvent::Mouse(MouseEvent { + kind, + column: rect.x + 2, + row: rect.y, + modifiers: KeyModifiers::empty(), + })]); + } + assert_eq!(state.mode, ClientShellMode::Terminal); + assert!(state.navigate_workspace_id.is_none()); + enter_navigation(&mut state); + assert_selected(&state, &ClientEndpointId::Local, "ws_1"); + } +} + +#[test] +fn single_machine_compact_navigation_includes_visible_collapsed_group_children() { + let (mut state, _) = navigation_state(grouped_workspaces()); + state.set_endpoint_catalog(&[]); + state.toggle_collapsed_group(&ClientEndpointId::Local, "repo".into()); + state.sidebar_collapsed = true; + state.compose(100, 28).unwrap(); + workspace_rect(&state, &ClientEndpointId::Local, "ws_3"); + enter_navigation(&mut state); + for id in ["ws_2", "ws_3"] { + preview_key(&mut state, b"\x1b[B"); + assert_selected(&state, &ClientEndpointId::Local, id); + } +} + +#[test] +fn workspace_navigation_respects_each_machines_visible_worktree_groups() { + for (cols, compact, unavailable, show_child) in [ + (100, false, false, false), + (100, true, false, true), + (44, false, false, true), + (44, true, true, false), + ] { + let (mut state, remote) = navigation_state(grouped_workspaces()); + state.toggle_collapsed_group(&remote, "repo".into()); + state.sidebar_collapsed = compact; + if unavailable { + state.pane_surface = None; + } + state.compose(cols, 28).unwrap(); + enter_navigation(&mut state); + let local = if compact && !unavailable { + ["ws_2", "ws_3"] + } else { + ["ws_3", "ws_2"] + }; + let remote_ids: &[&str] = if !show_child { + &["ws_1", "ws_2"] + } else if compact { + &["ws_1", "ws_2", "ws_3"] + } else { + &["ws_1", "ws_3", "ws_2"] + }; + for (endpoint, ids) in [ + (&ClientEndpointId::Local, local.as_slice()), + (&remote, remote_ids), + ] { + for id in ids { + preview_key(&mut state, b"\x1b[B"); + assert_selected(&state, endpoint, id); + state.compose(cols, 28).unwrap(); + workspace_rect(&state, endpoint, id); + } + } + assert!(state.group_is_collapsed(&remote, "repo")); + assert!(!state.group_is_collapsed(&ClientEndpointId::Local, "repo")); + } +} + +#[test] +fn foreign_preview_survives_local_updates_and_rejects_stale_enter() { + for invalidation in [ + "offline", + "disabled", + "removed", + "deleted", + "boot", + "generation", + ] { + let (mut state, remote_id) = state_with_remote(); + let mut remote = workspaces(2); + remote.boot_id = "remote-boot".into(); + state.set_endpoint_snapshot_for_generation(&remote_id, 7, Box::new(remote.clone())); + state.compose(100, 28).unwrap(); + enter_navigation(&mut state); + for _ in 0..2 { + preview_key(&mut state, b"\x1b[B"); + } + assert_selected(&state, &remote_id, "ws_2"); + let selected = state.navigate_workspace_id.clone(); + remote.revision += 1; + state.set_endpoint_snapshot_for_generation(&remote_id, 7, Box::new(remote.clone())); + assert_eq!(state.navigate_workspace_id, selected); + assert!(state.navigation_target_valid(selected.as_ref().unwrap())); + let mut local = snapshot(); + local.revision += 1; + state.set_snapshot(Box::new(local)); + assert_eq!(state.navigate_workspace_id, selected); + match invalidation { + "offline" => state.set_endpoint_status(&remote_id, ClientEndpointStatus::Reconnecting), + "disabled" => { + let mut profile = remote_profile(); + profile.enabled = false; + state.set_endpoint_catalog(&[profile]); + } + "removed" => state.set_endpoint_catalog(&[]), + "deleted" => { + remote.revision += 1; + remote.workspaces.pop(); + state.set_endpoint_snapshot_for_generation(&remote_id, 7, Box::new(remote)); + } + "boot" => { + remote.boot_id = "restarted-remote".into(); + state.set_endpoint_snapshot_for_generation(&remote_id, 7, Box::new(remote)); + } + "generation" => { + state.cache_endpoint_snapshot_for_generation(&remote_id, 8, Box::new(remote)) + } + _ => unreachable!(), + } + preview_key(&mut state, b"\r"); + assert_eq!(state.active_endpoint_id, ClientEndpointId::Local); + assert_eq!(state.mode, ClientShellMode::Navigate); + assert!(state.visible_endpoint_notice.is_some()); + assert!(!state.navigation_target_valid(state.navigate_workspace_id.as_ref().unwrap())); + preview_key(&mut state, b"\x1b[B"); + assert!(state.navigation_target_valid(state.navigate_workspace_id.as_ref().unwrap())); + } +} + +#[test] +fn navigation_uses_displayed_group_order_when_local_is_unavailable() { + for cols in [100, 44] { + let (mut state, remote) = navigation_state(grouped_workspaces()); + state.set_endpoint_status(&ClientEndpointId::Local, ClientEndpointStatus::Reconnecting); + state.select_unavailable_local(); + state.sidebar_collapsed = true; + state.compose(cols, 18).unwrap(); + enter_navigation(&mut state); + for id in ["ws_1", "ws_3", "ws_2"] { + preview_key(&mut state, b"\x1b[B"); + assert_selected(&state, &remote, id); + state.compose(cols, 18).unwrap(); + workspace_rect(&state, &remote, id); + } + let enter = state.handle_input_bytes(b"\r"); + assert!( + matches!(enter.actions.as_slice(), [ClientShellAction::ActivateEndpoint { + endpoint_id, target: Some(ClientEndpointFocusTarget::Workspace(id)), + }] if endpoint_id == &remote && id == "ws_2") + ); + } +} + +#[test] +fn active_preview_is_not_retargeted_by_deletion_or_reboot() { + for invalidation in ["deleted", "boot", "generation"] { + let (mut state, _) = state_with_remote(); + let mut local = workspaces(2); + state.set_endpoint_snapshot_for_generation( + &ClientEndpointId::Local, + 7, + Box::new(local.clone()), + ); + state.compose(100, 28).unwrap(); + enter_navigation(&mut state); + preview_key(&mut state, b"\x1b[B"); + assert_selected(&state, &ClientEndpointId::Local, "ws_2"); + let selected = state.navigate_workspace_id.clone(); + match invalidation { + "boot" => local.boot_id = "new-local-boot".into(), + "deleted" => { + local.revision += 1; + local.workspaces.pop(); + } + _ => {} + } + let generation = if invalidation == "generation" { 8 } else { 7 }; + state.set_endpoint_snapshot_for_generation( + &ClientEndpointId::Local, + generation, + Box::new(local), + ); + assert_eq!(state.navigate_workspace_id, selected); + preview_key(&mut state, b"\r"); + assert_eq!(state.mode, ClientShellMode::Navigate); + assert!(state.visible_endpoint_notice.is_some()); + for confirm in [false, true] { + state.config.confirm_close = confirm; + for key in [b"W", b"D"] { + preview_key(&mut state, key); + } + assert!(state.overlay.is_none()); + assert_eq!(state.mode, ClientShellMode::Navigate); + } + assert_eq!(state.workspace_action_id().as_deref(), Some("ws_1")); + } +} + +#[test] +fn aggregate_navigation_reveals_overflow_and_preserves_order() { + for (compact, cols) in [(true, 100), (false, 100), (false, 44)] { + let (mut state, remote_id) = state_with_remote(); + let mut remote = workspaces(15); + remote.boot_id = "remote-boot".into(); + state.set_endpoint_snapshot(&remote_id, Box::new(remote)); + state.sidebar_collapsed = compact; + state.collapsed_endpoints.insert(remote_id.clone()); + state.compose(cols, 18).unwrap(); + enter_navigation(&mut state); + for number in 1..=15 { + preview_key(&mut state, b"\x1b[B"); + let id = format!("ws_{number}"); + assert_selected(&state, &remote_id, &id); + state.compose(cols, 18).unwrap(); + workspace_rect(&state, &remote_id, &id); + } + assert!(!state.collapsed_endpoints.contains(&remote_id)); + preview_key(&mut state, b"\x1b[B"); + if cols == 44 { + assert_selected(&state, &remote_id, "ws_15"); + } else { + assert_selected(&state, &ClientEndpointId::Local, "ws_1"); + } + preview_key(&mut state, b"\x1b[A"); + assert_selected( + &state, + &remote_id, + if cols == 44 { "ws_14" } else { "ws_15" }, + ); + state.set_endpoint_status(&remote_id, ClientEndpointStatus::Reconnecting); + preview_key(&mut state, b"\x1b[B"); + assert_selected(&state, &ClientEndpointId::Local, "ws_1"); + } +} |
