aboutsummaryrefslogtreecommitdiff
path: root/src/client/shell
diff options
context:
space:
mode:
Diffstat (limited to 'src/client/shell')
-rw-r--r--src/client/shell/composition.rs2
-rw-r--r--src/client/shell/config.rs17
-rw-r--r--src/client/shell/context_menu.rs10
-rw-r--r--src/client/shell/endpoint_navigation.rs6
-rw-r--r--src/client/shell/endpoint_sidebar.rs88
-rw-r--r--src/client/shell/endpoints.rs1
-rw-r--r--src/client/shell/mouse.rs26
-rw-r--r--src/client/shell/preferences.rs19
-rw-r--r--src/client/shell/render.rs1
-rw-r--r--src/client/shell/sidebar.rs58
-rw-r--r--src/client/shell/state.rs50
-rw-r--r--src/client/shell/tests/endpoints.rs264
-rw-r--r--src/client/shell/tests/keybindings_settings.rs20
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");
}