diff options
Diffstat (limited to 'src/client/shell')
| -rw-r--r-- | src/client/shell/composition.rs | 2 | ||||
| -rw-r--r-- | src/client/shell/config.rs | 17 | ||||
| -rw-r--r-- | src/client/shell/context_menu.rs | 10 | ||||
| -rw-r--r-- | src/client/shell/endpoint_navigation.rs | 6 | ||||
| -rw-r--r-- | src/client/shell/endpoint_sidebar.rs | 88 | ||||
| -rw-r--r-- | src/client/shell/endpoints.rs | 1 | ||||
| -rw-r--r-- | src/client/shell/mouse.rs | 26 | ||||
| -rw-r--r-- | src/client/shell/preferences.rs | 19 | ||||
| -rw-r--r-- | src/client/shell/render.rs | 1 | ||||
| -rw-r--r-- | src/client/shell/sidebar.rs | 58 | ||||
| -rw-r--r-- | src/client/shell/state.rs | 50 | ||||
| -rw-r--r-- | src/client/shell/tests/endpoints.rs | 264 | ||||
| -rw-r--r-- | src/client/shell/tests/keybindings_settings.rs | 20 |
13 files changed, 500 insertions, 62 deletions
diff --git a/src/client/shell/composition.rs b/src/client/shell/composition.rs index a80ebb3f..c54a56e2 100644 --- a/src/client/shell/composition.rs +++ b/src/client/shell/composition.rs @@ -45,6 +45,7 @@ impl ClientShellState { active_endpoint_id: &self.active_endpoint_id, collapsed_endpoints: &self.collapsed_endpoints, collapsed_groups: &self.collapsed_groups, + remote_collapsed_groups: &self.remote_collapsed_groups, workspace_scroll: &mut self.workspace_scroll, agent_scroll: &mut self.agent_scroll, tab_scroll: &mut self.tab_scroll, @@ -143,6 +144,7 @@ impl ClientShellState { active_endpoint_id: &self.active_endpoint_id, collapsed_endpoints: &self.collapsed_endpoints, collapsed_groups: &self.collapsed_groups, + remote_collapsed_groups: &self.remote_collapsed_groups, workspace_scroll: &mut self.workspace_scroll, agent_scroll: &mut self.agent_scroll, tab_scroll: &mut self.tab_scroll, diff --git a/src/client/shell/config.rs b/src/client/shell/config.rs index e8a9b4bb..36e1051f 100644 --- a/src/client/shell/config.rs +++ b/src/client/shell/config.rs @@ -32,6 +32,22 @@ impl ClientShellState { }; let mut collapsed_groups = self.collapsed_groups.iter().cloned().collect::<Vec<_>>(); collapsed_groups.sort(); + let mut remote_collapsed_groups = self + .remote_collapsed_groups + .iter() + .filter_map(|(endpoint_id, groups)| { + let ClientEndpointId::Ssh(profile_id) = endpoint_id else { + return None; + }; + let mut collapsed_groups = groups.iter().cloned().collect::<Vec<_>>(); + collapsed_groups.sort(); + (!collapsed_groups.is_empty()).then(|| preferences::ClientRemoteCollapsedGroups { + profile_id: profile_id.to_string(), + collapsed_groups, + }) + }) + .collect::<Vec<_>>(); + remote_collapsed_groups.sort_by(|left, right| left.profile_id.cmp(&right.profile_id)); let preferences = preferences::ClientChromePreferences { sidebar_width: self.sidebar_width_manual.then_some(self.sidebar_width), sidebar_section_split: self @@ -44,6 +60,7 @@ impl ClientShellState { .agent_panel_sort_manual .then_some(self.config.agent_panel_sort), collapsed_groups, + remote_collapsed_groups, }; if let Err(error) = preferences::store(path, preferences) { self.endpoint_error = Some(error); diff --git a/src/client/shell/context_menu.rs b/src/client/shell/context_menu.rs index af271bb6..926510c5 100644 --- a/src/client/shell/context_menu.rs +++ b/src/client/shell/context_menu.rs @@ -106,8 +106,9 @@ impl ClientShellState { .count() >= 2 }); - let collapsed = - worktree.is_some_and(|worktree| self.collapsed_groups.contains(&worktree.key)); + let collapsed = worktree.is_some_and(|worktree| { + self.group_is_collapsed(&self.active_endpoint_id, &worktree.key) + }); self.overlay = Some(ClientShellOverlay::ContextMenu(ClientContextMenuOverlay { target: ClientContextMenuTarget::Workspace { workspace_id, @@ -279,9 +280,8 @@ impl ClientShellState { .map(|worktree| worktree.key.clone()) }); if let Some(key) = key { - if !self.collapsed_groups.remove(&key) { - self.collapsed_groups.insert(key); - } + let endpoint_id = self.active_endpoint_id.clone(); + self.toggle_collapsed_group(&endpoint_id, key); self.persist_chrome_preferences(outcome); } } diff --git a/src/client/shell/endpoint_navigation.rs b/src/client/shell/endpoint_navigation.rs index adb29c93..4f528c3a 100644 --- a/src/client/shell/endpoint_navigation.rs +++ b/src/client/shell/endpoint_navigation.rs @@ -59,16 +59,16 @@ impl ClientShellState { point: (u16, u16), outcome: &mut ClientShellInput, ) -> bool { - let Some(endpoint_id) = self + let Some(hit) = self .hits .machines .iter() .find(|hit| super::contains(hit.rect, point)) - .map(|hit| hit.endpoint_id.clone()) else { return false; }; - if endpoint_id == self.active_endpoint_id { + let endpoint_id = hit.endpoint_id.clone(); + if super::contains(hit.collapse_toggle, point) || endpoint_id == self.active_endpoint_id { if !self.collapsed_endpoints.remove(&endpoint_id) { self.collapsed_endpoints.insert(endpoint_id); } diff --git a/src/client/shell/endpoint_sidebar.rs b/src/client/shell/endpoint_sidebar.rs index 51c940cd..d56f276c 100644 --- a/src/client/shell/endpoint_sidebar.rs +++ b/src/client/shell/endpoint_sidebar.rs @@ -1,6 +1,17 @@ use super::render::{display_width, put_right_text, put_text, ShellRenderState}; use super::*; +fn collapsed_groups_for_endpoint<'a>( + state: &'a ShellRenderState<'_>, + endpoint_id: &ClientEndpointId, +) -> Option<&'a HashSet<String>> { + if endpoint_id.is_local() { + Some(state.collapsed_groups) + } else { + state.remote_collapsed_groups.get(endpoint_id) + } +} + pub(super) fn render_collapsed( buffer: &mut Buffer, area: Rect, @@ -46,6 +57,7 @@ pub(super) fn render_collapsed( } hits.machines.push(MachineHit { rect, + collapse_toggle: Rect::new(rect.x, rect.y, u16::from(rect.width > 1), 1), endpoint_id: endpoint.endpoint_id.clone(), }); y = y.saturating_add(1); @@ -178,6 +190,8 @@ pub(super) fn render_expanded( .add_modifier(Modifier::BOLD), ); + let empty_collapsed_groups = HashSet::new(); + enum Row { Endpoint(usize), Workspace { @@ -192,8 +206,10 @@ pub(super) fn render_expanded( continue; } if let Some(snapshot) = endpoint.snapshot.as_deref() { + let collapsed_groups = collapsed_groups_for_endpoint(state, &endpoint.endpoint_id) + .unwrap_or(&empty_collapsed_groups); rows.extend( - super::sidebar::workspace_entries(snapshot, &HashSet::new()) + super::sidebar::workspace_entries(snapshot, collapsed_groups) .into_iter() .map(|entry| Row::Workspace { endpoint: endpoint_index, @@ -215,22 +231,33 @@ pub(super) fn render_expanded( .iter() .map(|row| match row { Row::Endpoint(_) => 1, - Row::Workspace { endpoint, entry } => state.endpoints[*endpoint] - .snapshot - .as_deref() - .and_then(|snapshot| snapshot.workspaces.get(entry.index)) - .map(|workspace| { - super::sidebar::workspace_rows( - workspace, - workspace.agent_status, - entry.indented, - &config.spaces, - ) - .len() - .max(1) - .min(u16::MAX as usize) as u16 - }) - .unwrap_or(1), + Row::Workspace { endpoint, entry } => { + let endpoint = &state.endpoints[*endpoint]; + let collapsed_groups = collapsed_groups_for_endpoint(state, &endpoint.endpoint_id) + .unwrap_or(&empty_collapsed_groups); + endpoint + .snapshot + .as_deref() + .and_then(|snapshot| { + let workspace = snapshot.workspaces.get(entry.index)?; + Some( + super::sidebar::workspace_rows( + workspace, + super::sidebar::displayed_workspace_status( + snapshot, + workspace, + collapsed_groups, + ), + entry.indented, + &config.spaces, + ) + .len() + .max(1) + .min(u16::MAX as usize) as u16, + ) + }) + .unwrap_or(1) + } }) .collect::<Vec<_>>(); let gaps = vec![0; rows.len()]; @@ -268,6 +295,12 @@ pub(super) fn render_expanded( ); hits.machines.push(MachineHit { rect, + collapse_toggle: Rect::new( + rect.x.saturating_add(1), + rect.y, + u16::from(rect.width > 1), + 1, + ), endpoint_id: endpoint.endpoint_id.clone(), }); y = y.saturating_add(1); @@ -280,9 +313,16 @@ pub(super) fn render_expanded( let Some(workspace) = snapshot.workspaces.get(entry.index) else { continue; }; + let collapsed_groups = collapsed_groups_for_endpoint(state, &endpoint.endpoint_id) + .unwrap_or(&empty_collapsed_groups); + let status = super::sidebar::displayed_workspace_status( + snapshot, + workspace, + collapsed_groups, + ); let tokens = super::sidebar::workspace_rows( workspace, - workspace.agent_status, + status, entry.indented, &config.spaces, ); @@ -302,7 +342,7 @@ pub(super) fn render_expanded( buffer, nested, workspace, - workspace.agent_status, + status, config.status_indicators, entry, tokens, @@ -319,12 +359,20 @@ pub(super) fn render_expanded( .add_modifier(Modifier::DIM), ); } + let group_toggle = super::sidebar::render_parent_group_toggle( + buffer, + rect, + snapshot, + entry.index, + collapsed_groups, + palette, + ); hits.workspaces.push(WorkspaceHit { rect, endpoint_id: endpoint.endpoint_id.clone(), workspace_id: workspace.workspace_id.clone(), indented: entry.indented, - group_toggle: None, + group_toggle, }); y = y.saturating_add(height); } diff --git a/src/client/shell/endpoints.rs b/src/client/shell/endpoints.rs index 2f5a8e46..1b890b97 100644 --- a/src/client/shell/endpoints.rs +++ b/src/client/shell/endpoints.rs @@ -15,6 +15,7 @@ pub(crate) struct ClientShellEndpoint { pub(super) struct MachineHit { pub(super) rect: Rect, + pub(super) collapse_toggle: Rect, pub(super) endpoint_id: ClientEndpointId, } diff --git a/src/client/shell/mouse.rs b/src/client/shell/mouse.rs index cd4a6c16..cf04fb89 100644 --- a/src/client/shell/mouse.rs +++ b/src/client/shell/mouse.rs @@ -480,7 +480,11 @@ impl ClientShellState { .map(|hit| (Some(hit.workspace_id.clone()), hit.rect.y.saturating_sub(1))) .collect::<Vec<_>>(); let snapshot = self.snapshot.as_deref()?; - let entries = render::workspace_entries(snapshot, &self.collapsed_groups); + let empty_collapsed_groups = HashSet::new(); + let collapsed_groups = self + .collapsed_groups_for_endpoint(&self.active_endpoint_id) + .unwrap_or(&empty_collapsed_groups); + let entries = render::workspace_entries(snapshot, collapsed_groups); let last_hit = self .hits .workspaces @@ -1972,17 +1976,15 @@ impl ClientShellState { self.persist_chrome_preferences(outcome); return; } - for hit in &self.hits.workspaces { - if let Some((rect, key)) = &hit.group_toggle { - if super::contains(*rect, point) { - if !self.collapsed_groups.remove(key) { - self.collapsed_groups.insert(key.clone()); - } - outcome.repaint = true; - self.persist_chrome_preferences(outcome); - return; - } - } + let group_toggle = self.hits.workspaces.iter().find_map(|hit| { + let (rect, key) = hit.group_toggle.as_ref()?; + super::contains(*rect, point).then(|| (hit.endpoint_id.clone(), key.clone())) + }); + if let Some((endpoint_id, key)) = group_toggle { + self.toggle_collapsed_group(&endpoint_id, key); + outcome.repaint = true; + self.persist_chrome_preferences(outcome); + return; } let workspace_press = self .hits diff --git a/src/client/shell/preferences.rs b/src/client/shell/preferences.rs index f657dfbf..fcfe1440 100644 --- a/src/client/shell/preferences.rs +++ b/src/client/shell/preferences.rs @@ -7,6 +7,13 @@ static NEXT_TEMP_FILE: AtomicU64 = AtomicU64::new(1); use serde::{Deserialize, Serialize}; +#[derive(Clone, Debug, Deserialize, Serialize)] +pub(super) struct ClientRemoteCollapsedGroups { + pub(super) profile_id: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub(super) collapsed_groups: Vec<String>, +} + #[derive(Clone, Debug, Default, Deserialize, Serialize)] pub(super) struct ClientChromePreferences { #[serde(default, skip_serializing_if = "Option::is_none")] @@ -19,6 +26,8 @@ pub(super) struct ClientChromePreferences { pub(super) agent_panel_sort: Option<crate::config::AgentPanelSortConfig>, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub(super) collapsed_groups: Vec<String>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub(super) remote_collapsed_groups: Vec<ClientRemoteCollapsedGroups>, } pub(super) fn path_for_local_endpoint(socket_path: &Path) -> PathBuf { @@ -74,6 +83,16 @@ mod tests { } #[test] + fn legacy_preferences_default_remote_collapses() { + let preferences: ClientChromePreferences = + serde_json::from_str(r#"{"collapsed_groups":["/repo"]}"#) + .expect("legacy client chrome preferences"); + + assert_eq!(preferences.collapsed_groups, ["/repo"]); + assert!(preferences.remote_collapsed_groups.is_empty()); + } + + #[test] fn concurrent_stores_leave_complete_preferences() { let path = std::env::temp_dir().join(format!( "herdr-shell-concurrent-preferences-{}.json", diff --git a/src/client/shell/render.rs b/src/client/shell/render.rs index ad9ac8b1..bcd9e49b 100644 --- a/src/client/shell/render.rs +++ b/src/client/shell/render.rs @@ -215,6 +215,7 @@ pub(super) struct ShellRenderState<'a> { pub(super) active_endpoint_id: &'a ClientEndpointId, pub(super) collapsed_endpoints: &'a HashSet<ClientEndpointId>, pub(super) collapsed_groups: &'a HashSet<String>, + pub(super) remote_collapsed_groups: &'a HashMap<ClientEndpointId, HashSet<String>>, pub(super) workspace_scroll: &'a mut usize, pub(super) agent_scroll: &'a mut usize, pub(super) tab_scroll: &'a mut usize, diff --git a/src/client/shell/sidebar.rs b/src/client/shell/sidebar.rs index 0aaa0d51..fe6abf00 100644 --- a/src/client/shell/sidebar.rs +++ b/src/client/shell/sidebar.rs @@ -316,22 +316,14 @@ pub(crate) fn render_sidebar( dragged, palette, ); - let group_toggle = parent_group_key(snapshot, entry.index).map(|key| { - let rect = Rect::new(rect.right().saturating_sub(1), rect.y, 1, 1); - put_text( - buffer, - rect.x, - rect.y, - rect.width, - if state.collapsed_groups.contains(&key) { - "▸" - } else { - "▾" - }, - Style::default().fg(palette.accent), - ); - (rect, key) - }); + let group_toggle = render_parent_group_toggle( + buffer, + rect, + snapshot, + entry.index, + state.collapsed_groups, + palette, + ); hits.workspaces.push(WorkspaceHit { rect, endpoint_id: ClientEndpointId::Local, @@ -534,7 +526,7 @@ pub(crate) fn workspace_entries( entries } -pub(super) fn parent_group_key(snapshot: &ClientShellSnapshot, index: usize) -> Option<String> { +fn parent_group_key(snapshot: &ClientShellSnapshot, index: usize) -> Option<String> { let workspace = snapshot.workspaces.get(index)?; let worktree = workspace.worktree.as_ref()?; if worktree.is_linked_worktree { @@ -554,7 +546,37 @@ pub(super) fn parent_group_key(snapshot: &ClientShellSnapshot, index: usize) -> .then(|| worktree.key.clone()) } -pub(super) fn displayed_workspace_status( +pub(in crate::client::shell) fn render_parent_group_toggle( + buffer: &mut Buffer, + workspace_rect: Rect, + snapshot: &ClientShellSnapshot, + workspace_index: usize, + collapsed_groups: &HashSet<String>, + palette: &Palette, +) -> Option<(Rect, String)> { + let key = parent_group_key(snapshot, workspace_index)?; + let toggle = Rect::new( + workspace_rect.right().saturating_sub(1), + workspace_rect.y, + 1, + 1, + ); + put_text( + buffer, + toggle.x, + toggle.y, + toggle.width, + if collapsed_groups.contains(&key) { + "▸" + } else { + "▾" + }, + Style::default().fg(palette.accent), + ); + Some((toggle, key)) +} + +pub(in crate::client::shell) fn displayed_workspace_status( snapshot: &ClientShellSnapshot, workspace: &ClientShellWorkspace, collapsed_groups: &HashSet<String>, diff --git a/src/client/shell/state.rs b/src/client/shell/state.rs index 81845d35..f4050feb 100644 --- a/src/client/shell/state.rs +++ b/src/client/shell/state.rs @@ -911,6 +911,7 @@ pub(crate) struct ClientShellState { pub(super) workspace_press: Option<ClientWorkspacePress>, pub(super) tab_press: Option<ClientTabPress>, pub(super) collapsed_groups: HashSet<String>, + pub(super) remote_collapsed_groups: HashMap<ClientEndpointId, HashSet<String>>, pub(super) workspace_scroll: usize, pub(super) agent_scroll: usize, pub(super) tab_scroll: usize, @@ -1031,6 +1032,16 @@ impl ClientShellState { if let Some(sort) = preferences.agent_panel_sort { config.agent_panel_sort = sort; } + let mut remote_collapsed_groups = HashMap::<ClientEndpointId, HashSet<String>>::new(); + for saved in preferences.remote_collapsed_groups { + let Ok(profile_id) = crate::client::endpoint::ProfileId::parse(saved.profile_id) else { + continue; + }; + remote_collapsed_groups + .entry(ClientEndpointId::Ssh(profile_id)) + .or_default() + .extend(saved.collapsed_groups); + } Self { config, snapshot: None, @@ -1054,6 +1065,7 @@ impl ClientShellState { workspace_press: None, tab_press: None, collapsed_groups: preferences.collapsed_groups.into_iter().collect(), + remote_collapsed_groups, workspace_scroll: 0, agent_scroll: 0, tab_scroll: 0, @@ -1141,14 +1153,48 @@ impl ClientShellState { .is_some_and(|(cols, rows)| !self.layout(cols, rows).mobile_header.is_empty()) } + pub(super) fn collapsed_groups_for_endpoint( + &self, + endpoint_id: &ClientEndpointId, + ) -> Option<&HashSet<String>> { + if endpoint_id.is_local() { + Some(&self.collapsed_groups) + } else { + self.remote_collapsed_groups.get(endpoint_id) + } + } + + pub(super) fn group_is_collapsed(&self, endpoint_id: &ClientEndpointId, key: &str) -> bool { + self.collapsed_groups_for_endpoint(endpoint_id) + .is_some_and(|groups| groups.contains(key)) + } + + pub(super) fn toggle_collapsed_group(&mut self, endpoint_id: &ClientEndpointId, key: String) { + let groups = if endpoint_id.is_local() { + &mut self.collapsed_groups + } else { + self.remote_collapsed_groups + .entry(endpoint_id.clone()) + .or_default() + }; + if !groups.remove(&key) { + groups.insert(key); + } + } + pub(super) fn navigation_workspace_entries( &self, snapshot: &ClientShellSnapshot, ) -> Vec<WorkspaceEntry> { + let empty_collapsed_groups = HashSet::new(); if self.mobile_layout_active() { - render::workspace_entries(snapshot, &HashSet::new()) + render::workspace_entries(snapshot, &empty_collapsed_groups) } else { - render::workspace_entries(snapshot, &self.collapsed_groups) + render::workspace_entries( + snapshot, + self.collapsed_groups_for_endpoint(&self.active_endpoint_id) + .unwrap_or(&empty_collapsed_groups), + ) } } diff --git a/src/client/shell/tests/endpoints.rs b/src/client/shell/tests/endpoints.rs index 52c71923..515c9837 100644 --- a/src/client/shell/tests/endpoints.rs +++ b/src/client/shell/tests/endpoints.rs @@ -256,6 +256,185 @@ fn sidebar_renders_local_and_saved_ssh_endpoints_with_status() { } #[test] +fn saved_machine_preserves_endpoint_scoped_worktree_collapses() { + fn add_worktree_group(snapshot: &mut ClientShellSnapshot, parent_id: &str, child_id: &str) { + snapshot.workspaces[0].workspace_id = parent_id.into(); + snapshot.workspaces[0].worktree = Some(ClientShellWorktree { + key: "repo".into(), + label: "repo".into(), + is_linked_worktree: false, + }); + let mut child = snapshot.workspaces[0].clone(); + child.workspace_id = child_id.into(); + child.active_tab_id = format!("tab_{child_id}"); + child.number = 2; + child.label = "feature".into(); + child.focused = false; + child.agent_status = AgentStatus::Blocked; + child.worktree = Some(ClientShellWorktree { + key: "repo".into(), + label: "repo".into(), + is_linked_worktree: true, + }); + snapshot.workspaces.push(child); + } + + let (mut state, remote_id) = state_with_remote(); + let mut local = snapshot(); + add_worktree_group(&mut local, "ws_1", "ws_2"); + state.set_snapshot(Box::new(local)); + let mut remote = snapshot(); + remote.boot_id = "remote-boot".into(); + remote.focused_workspace_id = Some("remote_ws_1".into()); + add_worktree_group(&mut remote, "remote_ws_1", "remote_ws_2"); + state.set_endpoint_snapshot(&remote_id, Box::new(remote)); + + state.open_workspace_context_menu("ws_1".into(), 0, 0); + let toggle_index = match state.overlay.as_ref() { + Some(ClientShellOverlay::ContextMenu(menu)) => menu + .items() + .iter() + .position(|item| item.action == ClientContextMenuAction::ToggleGroup) + .expect("collapse menu item"), + _ => panic!("workspace context menu"), + }; + state.activate_context_menu_item(toggle_index, &mut ClientShellInput::default()); + + let frame = state + .compose(100, 28) + .expect("collapsed local worktree group"); + assert!(!state + .hits + .workspaces + .iter() + .any(|hit| { hit.endpoint_id == ClientEndpointId::Local && hit.workspace_id == "ws_2" })); + assert!(state + .hits + .workspaces + .iter() + .any(|hit| hit.endpoint_id == remote_id && hit.workspace_id == "remote_ws_2")); + let local_parent = state + .hits + .workspaces + .iter() + .find(|hit| hit.endpoint_id == ClientEndpointId::Local && hit.workspace_id == "ws_1") + .expect("local parent workspace"); + let (local_toggle, key) = local_parent + .group_toggle + .as_ref() + .expect("local worktree group marker"); + assert_eq!(key, "repo"); + let buffer = frame.to_ratatui_buffer().expect("frame should reconstruct"); + assert_eq!(buffer[(local_toggle.x, local_toggle.y)].symbol(), "▸"); + assert!((local_parent.rect.x..local_parent.rect.right()) + .any(|x| buffer[(x, local_parent.rect.y)].fg == state.config.palette.red)); + + assert!(state.activate_endpoint_projection(&remote_id)); + let mut remote_surface = surface(); + remote_surface.boot_id = "remote-boot".into(); + state.set_pane_surface(remote_surface); + state + .compose(100, 28) + .expect("active remote worktree group"); + let mut switch = ClientShellInput::default(); + state.record_binding( + crate::input::KeybindMatch::Action(crate::input::KeybindAction::SwitchWorkspace(1)), + &mut switch, + ); + assert!(matches!( + &switch.actions[..], + [ClientShellAction::Endpoint { request, .. }] + if matches!( + &request.method, + crate::api::schema::Method::WorkspaceFocus(target) + if target.workspace_id == "remote_ws_2" + ) + )); + let remote_parent = state + .hits + .workspaces + .iter() + .find(|hit| hit.endpoint_id == remote_id && hit.workspace_id == "remote_ws_1") + .expect("visible remote worktree parent") + .rect; + let remote_child = state + .hits + .workspaces + .iter() + .find(|hit| hit.endpoint_id == remote_id && hit.workspace_id == "remote_ws_2") + .expect("visible remote worktree child") + .rect; + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: remote_parent.x + 3, + row: remote_parent.y, + modifiers: KeyModifiers::empty(), + })]); + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Drag(MouseButton::Left), + column: remote_child.x + 3, + row: remote_child.bottom(), + modifiers: KeyModifiers::empty(), + })]); + assert!(matches!( + state.chrome_drag, + Some(ClientChromeDrag::Workspace { + target: Some(_), + .. + }) + )); + state.chrome_drag = None; + state.workspace_press = None; + + let remote_toggle = state + .hits + .workspaces + .iter() + .find(|hit| hit.endpoint_id == remote_id && hit.workspace_id == "remote_ws_1") + .and_then(|hit| hit.group_toggle.as_ref()) + .expect("remote worktree group marker") + .0; + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: remote_toggle.x, + row: remote_toggle.y, + modifiers: KeyModifiers::empty(), + })]); + state.compose(100, 28).expect("both groups collapsed"); + assert!(!state + .hits + .workspaces + .iter() + .any(|hit| { hit.endpoint_id == remote_id && hit.workspace_id == "remote_ws_2" })); + + let local_toggle = state + .hits + .workspaces + .iter() + .find(|hit| hit.endpoint_id == ClientEndpointId::Local && hit.workspace_id == "ws_1") + .and_then(|hit| hit.group_toggle.as_ref()) + .expect("collapsed local group marker") + .0; + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: local_toggle.x, + row: local_toggle.y, + modifiers: KeyModifiers::empty(), + })]); + state.compose(100, 28).expect("only remote group collapsed"); + assert!(state + .hits + .workspaces + .iter() + .any(|hit| { hit.endpoint_id == ClientEndpointId::Local && hit.workspace_id == "ws_2" })); + assert!(!state + .hits + .workspaces + .iter() + .any(|hit| { hit.endpoint_id == remote_id && hit.workspace_id == "remote_ws_2" })); +} + +#[test] fn active_workspace_is_the_only_highlight_when_machine_is_expanded() { let (mut state, endpoint_id) = state_with_remote(); assert!(state.activate_endpoint_projection(&endpoint_id)); @@ -468,7 +647,7 @@ fn unselected_endpoint_completion_projects_done_client_side() { } #[test] -fn clicking_remote_endpoint_requests_activation_without_mutating_projection() { +fn clicking_remote_machine_name_requests_activation_without_mutating_projection() { let (mut state, endpoint_id) = state_with_remote(); state.compose(100, 28).expect("combined endpoint frame"); let hit = state @@ -480,7 +659,7 @@ fn clicking_remote_endpoint_requests_activation_without_mutating_projection() { .rect; let outcome = state.handle_raw_events(vec![RawInputEvent::Mouse(MouseEvent { kind: MouseEventKind::Down(MouseButton::Left), - column: hit.x, + column: hit.x + 3, row: hit.y, modifiers: KeyModifiers::empty(), })]); @@ -502,6 +681,87 @@ fn clicking_remote_endpoint_requests_activation_without_mutating_projection() { } #[test] +fn machine_arrow_toggles_inactive_machine_without_switching() { + for sidebar_collapsed in [false, true] { + for status in [ + ClientEndpointStatus::Online, + ClientEndpointStatus::Reconnecting, + ] { + let (mut state, remote_id) = state_with_remote(); + let mut other_profile = remote_profile(); + other_profile.id = ProfileId::parse("1123456789abcdef0123456789abcdef").unwrap(); + let other_id = ClientEndpointId::Ssh(other_profile.id.clone()); + state.set_endpoint_catalog(&[remote_profile(), other_profile]); + state.set_endpoint_status(&other_id, ClientEndpointStatus::Online); + state.set_endpoint_snapshot(&other_id, Box::new(snapshot())); + state.set_endpoint_status(&remote_id, status); + state.sidebar_collapsed = sidebar_collapsed; + + for collapsed in [true, false] { + let frame = state.compose(100, 28).expect("three machine frame"); + let machine = state + .hits + .machines + .iter() + .find(|hit| hit.endpoint_id == remote_id) + .expect("remote machine") + .rect; + let column = machine.x + u16::from(!sidebar_collapsed); + let buffer = frame.to_ratatui_buffer().expect("frame buffer"); + assert_eq!( + buffer[(column, machine.y)].symbol(), + if collapsed { "▾" } else { "▸" } + ); + let outcome = state.handle_raw_events(vec![RawInputEvent::Mouse(MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column, + row: machine.y, + modifiers: KeyModifiers::empty(), + })]); + assert!( + outcome.actions.is_empty(), + "collapse must not switch machines" + ); + assert!(outcome.requests.is_empty()); + assert!(outcome.repaint); + assert_eq!(state.active_endpoint_id, ClientEndpointId::Local); + 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.collapsed_endpoints.contains(&remote_id), collapsed); + assert!(!state.collapsed_endpoints.contains(&ClientEndpointId::Local)); + assert!(!state.collapsed_endpoints.contains(&other_id)); + assert!(state.endpoint_error.is_none()); + + state.compose(100, 28).expect("toggled machine frame"); + assert_eq!( + state + .hits + .workspaces + .iter() + .any(|hit| hit.endpoint_id == remote_id), + !collapsed + ); + for endpoint_id in [&ClientEndpointId::Local, &other_id] { + assert!(state + .hits + .workspaces + .iter() + .any(|hit| &hit.endpoint_id == endpoint_id)); + } + } + } + } +} + +#[test] fn context_menu_lookup_ignores_inactive_endpoint_workspaces() { let (mut state, endpoint_id) = state_with_remote(); state.compose(100, 28).expect("combined endpoint frame"); diff --git a/src/client/shell/tests/keybindings_settings.rs b/src/client/shell/tests/keybindings_settings.rs index b8f17e57..1654d0df 100644 --- a/src/client/shell/tests/keybindings_settings.rs +++ b/src/client/shell/tests/keybindings_settings.rs @@ -62,7 +62,18 @@ fn manual_client_chrome_preferences_round_trip_per_endpoint() { state.sidebar_collapsed_manual = true; state.collapsed_groups.insert("repo-two".into()); state.collapsed_groups.insert("repo-one".into()); + let profile = + SavedSshEndpoint::new("Build", "dev@build.example", "agents").expect("saved SSH profile"); + let remote_id = ClientEndpointId::Ssh(profile.id.clone()); + state + .remote_collapsed_groups + .insert(remote_id.clone(), HashSet::from(["/repo".to_owned()])); state.persist_chrome_preferences(&mut ClientShellInput::default()); + let stored = std::fs::read_to_string(&path).expect("stored client chrome preferences"); + assert!(stored.contains(profile.id.as_str())); + assert!(stored.contains("repo-one")); + assert!(!stored.contains(&profile.label)); + assert!(!stored.contains(&profile.target)); let reloaded_config = ClientShellConfig::from_config(&Config::default()).with_preferences_path(path.clone()); @@ -77,6 +88,15 @@ fn manual_client_chrome_preferences_round_trip_per_endpoint() { reloaded.collapsed_groups, HashSet::from(["repo-one".to_string(), "repo-two".to_string()]) ); + assert_eq!( + reloaded.remote_collapsed_groups.get(&remote_id), + Some(&HashSet::from(["/repo".to_owned()])) + ); + let mut reloaded = reloaded; + reloaded.persist_chrome_preferences(&mut ClientShellInput::default()); + let stored_again = std::fs::read_to_string(&path).expect("restored client chrome preferences"); + assert!(stored_again.contains(profile.id.as_str())); + assert!(stored_again.contains("/repo")); std::fs::remove_file(path).expect("remove client chrome preferences"); } |
