diff options
| author | akbash <akbash@herdr.dev> | 2026-09-08 17:51:59 +0300 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-09-08 17:51:59 +0300 |
| commit | 68c7b78ec237034cbb0e21c8666842ed7991641d (patch) | |
| tree | 3d5a63866fa785a3307959301f3022fa21bff5ed | |
| parent | ffa0892ef87d927eef78fe8068de4d90201fb20b (diff) | |
fix: collapse worktree groups with saved machines (#3781)
* fix: collapse worktree groups with saved machines
refs #3778
* fix: scope worktree collapse by machine
refs #3778
* fix: collapse machines without switching focus
refs #3778
---------
Co-authored-by: akbash-bot <300245827+akbash-bot@users.noreply.github.com>
Co-authored-by: Ogulcan Celik <ogulcancelik@gmail.com>
| -rw-r--r-- | docs/next/website/src/content/docs/connecting-machines.mdx | 2 | ||||
| -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 |
14 files changed, 502 insertions, 62 deletions
diff --git a/docs/next/website/src/content/docs/connecting-machines.mdx b/docs/next/website/src/content/docs/connecting-machines.mdx index e922e3a2..dfb68e8e 100644 --- a/docs/next/website/src/content/docs/connecting-machines.mdx +++ b/docs/next/website/src/content/docs/connecting-machines.mdx @@ -43,6 +43,8 @@ Run `herdr` to open the UI. If a local client is already open, added and enabled Choose a machine or one of its workspaces in the sidebar. The selected machine receives your pane input and terminal size, and supplies the visible terminal content and graphics. Other connected machines keep updating their workspace information, agent states, and notifications without streaming their pane screens. +Click the arrow beside any machine to collapse or expand its workspace list without switching away from your current workspace. This also works while that machine is reconnecting. + Local opens immediately on startup without waiting for SSH connections. A stalled machine cannot hold up another machine's input. Multiple Herdr clients can also view different tabs on the same server independently; see [Client and server](/docs/concepts/#client-and-server) for shared-tab sizing. When a connection is lost, the last workspace and agent state remains visible but dimmed. That is cached information, not live state. Input and navigation into those cached panes stay disabled until a fresh connection and matching screen arrive. Reconnecting never takes selection away from the machine you are using. 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"); } |
