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>
This commit is contained in:
akbash
2026-09-09 02:35:31 +03:00
committed by GitHub
co-authored by akbash-bot Ogulcan Celik
parent 8be4cf765a
commit b9ce96869e
17 changed files with 934 additions and 133 deletions
@@ -45,6 +45,10 @@ Choose a machine or one of its workspaces in the sidebar. The selected machine r
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.
For keyboard navigation, press `prefix+w`, then use the workspace navigation keys (Up/Down by default) to highlight workspaces across connected machines in sidebar order. Enter activates the highlighted workspace; Esc or the prefix key cancels without switching. Compact and expanded sidebars reveal the highlighted row, including under a collapsed machine. On desktop, navigation wraps at the ends; the mobile switcher stops at the first or last workspace. Disconnected machines are skipped.
When highlighting a workspace on another machine, press Enter before using other keyboard actions. This prevents pane, tab, workspace, or custom-command shortcuts from acting on the current machine by mistake. Clicking cancels a remote desktop keyboard preview. Expanded sidebars respect each machine's collapsed worktree groups.
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.
+2
View File
@@ -3,6 +3,8 @@ use std::collections::{HashMap, HashSet, VecDeque};
mod actions;
mod agent_sidebar;
mod aggregate_navigation;
mod workspace_navigation;
use workspace_navigation::WorkspaceNavigationTarget;
mod composition;
mod config;
mod context_menu;
+18 -5
View File
@@ -13,12 +13,27 @@ impl ClientShellState {
crate::input::KeybindMatch::Action(crate::input::KeybindAction::ToggleSidebar) => {
self.sidebar_collapsed = !self.sidebar_collapsed;
self.sidebar_collapsed_manual = true;
self.reveal_navigation_workspace = true;
self.invalidate_pane_surface();
outcome.repaint = true;
outcome.resize = true;
self.persist_chrome_preferences(outcome);
}
crate::input::KeybindMatch::Action(action) => {
if self.workspace_preview_action_blocked()
&& matches!(
action,
crate::input::KeybindAction::RenameWorkspace
| crate::input::KeybindAction::CloseWorkspace
)
{
self.receive_endpoint_unavailable(
"Select an available workspace and press Enter before renaming or closing it"
.into(),
);
outcome.repaint = true;
return;
}
if matches!(
action,
crate::input::KeybindAction::NewWorktree
@@ -129,10 +144,8 @@ impl ClientShellState {
self.mobile_switcher_scroll = 0;
self.reveal_mobile_workspace = false;
self.mode = ClientShellMode::Navigate;
self.navigate_workspace_id = self
.snapshot
.as_deref()
.and_then(|snapshot| snapshot.focused_workspace_id.clone());
self.navigate_workspace_id = self.focused_navigation_target();
self.reveal_navigation_workspace = true;
outcome.repaint = true;
return;
}
@@ -337,7 +350,7 @@ impl ClientShellState {
self.push_endpoint_method_with_kind(method, PendingEndpointKind::Generic, outcome);
}
fn push_endpoint_notice(
pub(super) fn push_endpoint_notice(
&mut self,
kind: ClientEndpointNoticeKind,
code: impl Into<String>,
+27 -5
View File
@@ -35,6 +35,11 @@ impl ClientShellState {
} else {
Rect::new(0, 1, cols, rows.saturating_sub(2))
};
let valid_navigation_target = self.mode == ClientShellMode::Navigate
&& self
.navigate_workspace_id
.as_ref()
.is_some_and(|target| self.navigation_target_valid(target));
super::endpoint_sidebar::render_expanded(
&mut buffer,
sidebar,
@@ -54,7 +59,11 @@ impl ClientShellState {
sidebar_collapsed: false,
sidebar_section_split: self.sidebar_section_split,
tab_drag_insert_index: None,
selected_workspace_id: self.navigate_workspace_id.as_deref(),
selected_workspace_id: self
.navigate_workspace_id
.as_ref()
.filter(|_| valid_navigation_target),
reveal_navigation_workspace: &mut self.reveal_navigation_workspace,
dragged_workspace_id: None,
workspace_drop_indicator_row: None,
},
@@ -100,7 +109,16 @@ impl ClientShellState {
}
pub(crate) fn compose(&mut self, cols: u16, rows: u16) -> Option<FrameData> {
if self.last_composed_size != Some((cols, rows)) && self.mode == ClientShellMode::Navigate {
self.reveal_navigation_workspace = true;
self.reveal_mobile_workspace = true;
}
self.last_composed_size = Some((cols, rows));
let valid_navigation_target = self.mode == ClientShellMode::Navigate
&& self
.navigate_workspace_id
.as_ref()
.is_some_and(|target| self.navigation_target_valid(target));
if self.snapshot.is_none() || self.pane_surface.is_none() {
return Some(self.compose_unavailable(cols, rows));
}
@@ -153,9 +171,11 @@ impl ClientShellState {
sidebar_collapsed: self.sidebar_collapsed,
sidebar_section_split: self.sidebar_section_split,
tab_drag_insert_index,
selected_workspace_id: (self.mode == ClientShellMode::Navigate)
.then_some(self.navigate_workspace_id.as_deref())
.flatten(),
selected_workspace_id: self
.navigate_workspace_id
.as_ref()
.filter(|_| valid_navigation_target),
reveal_navigation_workspace: &mut self.reveal_navigation_workspace,
dragged_workspace_id,
workspace_drop_indicator_row,
},
@@ -511,7 +531,9 @@ impl ClientShellState {
&self.endpoints,
&self.active_endpoint_id,
&self.config,
self.navigate_workspace_id.as_deref(),
self.navigate_workspace_id
.as_ref()
.filter(|_| valid_navigation_target),
&mut self.mobile_switcher_scroll,
&mut self.reveal_mobile_workspace,
&mut self.hits,
+116 -30
View File
@@ -22,6 +22,42 @@ pub(super) fn render_collapsed(
let palette = &config.palette;
super::render::render_sidebar_background(buffer, area, palette);
let (workspace_area, divider_y, detail_area) = super::sidebar::collapsed_sidebar_sections(area);
let mut total_rows = 0usize;
let mut selected_row = None;
let reveal = std::mem::take(state.reveal_navigation_workspace);
for endpoint in state.endpoints {
total_rows += 1;
if state.collapsed_endpoints.contains(&endpoint.endpoint_id) {
continue;
}
if let Some(snapshot) = endpoint.snapshot.as_deref() {
if reveal {
if let Some(target) = state
.selected_workspace_id
.filter(|target| target.endpoint_id == endpoint.endpoint_id)
{
selected_row = snapshot
.workspaces
.iter()
.position(|workspace| workspace.workspace_id == target.workspace_id)
.map(|index| total_rows + index);
}
}
total_rows += snapshot.workspaces.len();
}
}
let height = usize::from(workspace_area.height);
let max_scroll = total_rows.saturating_sub(height);
*state.workspace_scroll = (*state.workspace_scroll).min(max_scroll);
if let Some(row) = selected_row {
if row < *state.workspace_scroll {
*state.workspace_scroll = row;
} else if row >= state.workspace_scroll.saturating_add(height) {
*state.workspace_scroll = row.saturating_add(1).saturating_sub(height).min(max_scroll);
}
}
hits.workspace_max_scroll = max_scroll;
let mut skip = *state.workspace_scroll;
let mut y = workspace_area.y;
for (index, endpoint) in state.endpoints.iter().enumerate() {
if y >= workspace_area.bottom() {
@@ -30,37 +66,41 @@ pub(super) fn render_collapsed(
let rect = Rect::new(workspace_area.x, y, workspace_area.width, 1);
let active = &endpoint.endpoint_id == state.active_endpoint_id;
let collapsed = state.collapsed_endpoints.contains(&endpoint.endpoint_id);
if active && collapsed {
buffer.set_style(rect, Style::default().bg(palette.active_row_bg));
}
let label = if endpoint.endpoint_id.is_local() {
"L".to_owned()
if skip > 0 {
skip -= 1;
} else {
(index + 1).to_string()
};
let marker = if collapsed { "" } else { "" };
put_text(
buffer,
rect.x,
rect.y,
rect.width.saturating_sub(1),
&format!("{marker}{label}"),
Style::default().fg(if endpoint.status == ClientEndpointStatus::Online {
palette.text
if active && collapsed {
buffer.set_style(rect, Style::default().bg(palette.active_row_bg));
}
let label = if endpoint.endpoint_id.is_local() {
"L".to_owned()
} else {
palette.overlay0
}),
);
if !endpoint.endpoint_id.is_local() {
let (glyph, _, color) = endpoint_status_presentation(endpoint.status, palette);
put_right_text(buffer, rect, rect.y, glyph, Style::default().fg(color));
(index + 1).to_string()
};
let marker = if collapsed { "" } else { "" };
put_text(
buffer,
rect.x,
rect.y,
rect.width.saturating_sub(1),
&format!("{marker}{label}"),
Style::default().fg(if endpoint.status == ClientEndpointStatus::Online {
palette.text
} else {
palette.overlay0
}),
);
if !endpoint.endpoint_id.is_local() {
let (glyph, _, color) = endpoint_status_presentation(endpoint.status, palette);
put_right_text(buffer, rect, rect.y, glyph, Style::default().fg(color));
}
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);
}
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);
if collapsed {
continue;
}
@@ -68,12 +108,26 @@ pub(super) fn render_collapsed(
continue;
};
for workspace in &snapshot.workspaces {
if skip > 0 {
skip -= 1;
continue;
}
if y >= workspace_area.bottom() {
break;
}
let rect = Rect::new(workspace_area.x, y, workspace_area.width, 1);
let focused = active && workspace.focused;
if focused {
let selected = state.selected_workspace_id.is_some_and(|target| {
target.matches(&endpoint.endpoint_id, &workspace.workspace_id)
});
let selection_background = if palette.selection_bg == ratatui::style::Color::Reset {
palette.active_row_bg
} else {
palette.selection_bg
};
if selected {
buffer.set_style(rect, Style::default().bg(selection_background));
} else if focused {
buffer.set_style(rect, Style::default().bg(palette.active_row_bg));
}
let stale = endpoint.status != ClientEndpointStatus::Online;
@@ -261,6 +315,32 @@ pub(super) fn render_expanded(
})
.collect::<Vec<_>>();
let gaps = vec![0; rows.len()];
if std::mem::take(state.reveal_navigation_workspace) {
let selected_row = rows.iter().position(|row| match row {
Row::Workspace { endpoint, entry } => {
let endpoint = &state.endpoints[*endpoint];
endpoint
.snapshot
.as_deref()
.and_then(|snapshot| snapshot.workspaces.get(entry.index))
.is_some_and(|workspace| {
state.selected_workspace_id.is_some_and(|target| {
target.matches(&endpoint.endpoint_id, &workspace.workspace_id)
})
})
}
Row::Endpoint(_) => false,
});
if let Some(selected_row) = selected_row {
*state.workspace_scroll = super::scroll::list_scroll_start_to_reveal(
&row_heights,
&gaps,
body.height,
*state.workspace_scroll,
selected_row,
);
}
}
let metrics = super::scroll::list_scroll_metrics(
&row_heights,
&gaps,
@@ -338,6 +418,9 @@ pub(super) fn render_expanded(
rect.height,
);
let endpoint_active = &endpoint.endpoint_id == state.active_endpoint_id;
let selected = state.selected_workspace_id.is_some_and(|target| {
target.matches(&endpoint.endpoint_id, &workspace.workspace_id)
});
super::sidebar::render_workspace_rows(
buffer,
nested,
@@ -347,10 +430,13 @@ pub(super) fn render_expanded(
entry,
tokens,
endpoint_active,
false,
selected,
false,
palette,
);
if selected && palette.selection_bg == ratatui::style::Color::Reset {
buffer.set_style(nested, Style::default().bg(palette.active_row_bg));
}
if endpoint.status != ClientEndpointStatus::Online {
buffer.set_style(
rect,
+25 -52
View File
@@ -128,7 +128,9 @@ impl ClientShellState {
}
fn prepare_committed_text(&mut self, text: &str, outcome: &mut ClientShellInput) -> bool {
if self.insert_copy_search_text(text) {
if !(self.mode == ClientShellMode::Navigate && self.workspace_preview_action_blocked())
&& self.insert_copy_search_text(text)
{
outcome.repaint = true;
return true;
}
@@ -425,7 +427,12 @@ impl ClientShellState {
}
pub(super) fn modal_paste_target_active(&self) -> bool {
if self.popup_pending || self.popup_input_target().is_some() {
if self.popup_pending
|| self.popup_input_target().is_some()
|| (self.overlay.is_none()
&& self.mode == ClientShellMode::Navigate
&& self.workspace_preview_action_blocked())
{
return false;
}
if self
@@ -655,6 +662,22 @@ impl ClientShellState {
return;
}
let (code, modifiers) = crate::config::normalize_key_combo((key.code, key.modifiers));
if code == KeyCode::Enter && modifiers.is_empty() {
self.accept_navigate_workspace(outcome);
return;
}
if self.workspace_preview_action_blocked() {
self.push_endpoint_notice(
ClientEndpointNoticeKind::Rejected,
"navigate_endpoint_inactive",
"Confirm workspace first",
"Select an available workspace and press Enter before using workspace or pane actions",
);
outcome.repaint = true;
return;
}
if let Some(index) = ('1'..='9').position(|digit| {
crate::config::terminal_key_matches_combo(
key,
@@ -678,24 +701,8 @@ impl ClientShellState {
return;
}
let (code, modifiers) = crate::config::normalize_key_combo((key.code, key.modifiers));
if modifiers.is_empty() {
match code {
KeyCode::Enter => {
let selected = self.navigate_workspace_id.clone();
self.mode = ClientShellMode::Terminal;
self.navigate_workspace_id = None;
if let Some(workspace_id) = selected {
self.push_endpoint_method(
crate::api::schema::Method::WorkspaceFocus(
crate::api::schema::WorkspaceTarget { workspace_id },
),
outcome,
);
}
outcome.repaint = true;
return;
}
KeyCode::Tab => {
self.record_navigate_binding(
KeybindMatch::Action(KeybindAction::CyclePaneNext),
@@ -804,7 +811,6 @@ impl ClientShellState {
if !self.indexed_navigation_target_exists(&binding) {
return;
}
if let KeybindMatch::Action(KeybindAction::CyclePaneNext) = binding {
self.cycle_pane(false, outcome);
} else if let KeybindMatch::Action(KeybindAction::CyclePanePrevious) = binding {
@@ -862,39 +868,6 @@ impl ClientShellState {
}
}
fn move_navigate_workspace(&mut self, delta: isize) {
let Some(snapshot) = self.snapshot.as_deref() else {
return;
};
let mobile = self.mobile_layout_active();
let entries = self.navigation_workspace_entries(snapshot);
if entries.is_empty() {
return;
}
let current = self
.navigate_workspace_id
.as_deref()
.and_then(|selected| {
entries
.iter()
.position(|entry| snapshot.workspaces[entry.index].workspace_id == selected)
})
.unwrap_or(0);
let next = if mobile {
(current as isize + delta).clamp(0, entries.len().saturating_sub(1) as isize) as usize
} else {
(current as isize + delta).rem_euclid(entries.len() as isize) as usize
};
let workspace_id = snapshot.workspaces[entries[next].index]
.workspace_id
.clone();
self.navigate_workspace_id = Some(workspace_id.clone());
self.reveal_mobile_workspace = mobile;
if !mobile {
self.reveal_workspace(&workspace_id);
}
}
fn cycle_pane(&mut self, reverse: bool, outcome: &mut ClientShellInput) {
let Some(snapshot) = self.snapshot.as_deref() else {
return;
+12 -10
View File
@@ -367,7 +367,7 @@ pub(super) fn render_mobile_switcher(
endpoints: &[ClientShellEndpoint],
active_endpoint_id: &ClientEndpointId,
config: &ClientShellConfig,
selected_workspace_id: Option<&str>,
selected_workspace_id: Option<&WorkspaceNavigationTarget>,
scroll: &mut usize,
reveal_workspace: &mut bool,
hits: &mut ShellHitMap,
@@ -457,7 +457,7 @@ pub(super) fn render_mobile_switcher(
Some(ClientMobileTarget::Workspace {
endpoint_id,
workspace_id,
}) if endpoint_id == active_endpoint_id && workspace_id == selected_workspace_id
}) if selected_workspace_id.matches(endpoint_id, workspace_id)
) {
if start < *scroll {
*scroll = start;
@@ -577,7 +577,7 @@ fn mobile_items(
endpoints: &[ClientShellEndpoint],
active_endpoint_id: &ClientEndpointId,
config: &ClientShellConfig,
selected_workspace_id: Option<&str>,
selected_workspace_id: Option<&WorkspaceNavigationTarget>,
content_width: u16,
) -> Vec<MobileItem> {
let palette = &config.palette;
@@ -755,10 +755,15 @@ fn mobile_items(
let Some(workspace) = endpoint.snapshot.workspaces.get(entry.index) else {
continue;
};
let selected = endpoint.endpoint_id == active_endpoint_id
&& selected_workspace_id == Some(workspace.workspace_id.as_str());
let selected = selected_workspace_id.is_some_and(|target| {
target.matches(endpoint.endpoint_id, &workspace.workspace_id)
});
let background = if selected {
palette.surface0
if palette.surface0 == ratatui::style::Color::Reset {
palette.active_row_bg
} else {
palette.surface0
}
} else if endpoint.endpoint_id == active_endpoint_id && workspace.focused {
palette.surface_dim
} else {
@@ -950,10 +955,7 @@ impl ClientShellState {
self.mobile_switcher_scroll = 0;
self.reveal_mobile_workspace = false;
self.mode = ClientShellMode::Navigate;
self.navigate_workspace_id = self
.snapshot
.as_deref()
.and_then(|snapshot| snapshot.focused_workspace_id.clone());
self.navigate_workspace_id = self.focused_navigation_target();
outcome.repaint = true;
return true;
}
+10
View File
@@ -607,6 +607,16 @@ impl ClientShellState {
pub(super) fn handle_mouse(&mut self, mouse: MouseEvent, outcome: &mut ClientShellInput) {
let point = (mouse.column, mouse.row);
if self.mode == ClientShellMode::Navigate
&& self.workspace_preview_action_blocked()
&& self.overlay.is_none()
&& !self.mobile_layout_active()
&& mouse.kind == MouseEventKind::Down(MouseButton::Left)
{
self.mode = self.copy_or_terminal_mode();
self.navigate_workspace_id = None;
outcome.repaint = true;
}
if matches!(self.overlay, Some(ClientShellOverlay::Onboarding)) {
if mouse.kind == MouseEventKind::Down(MouseButton::Left)
&& super::contains(self.hits.overlay_primary, point)
+14 -9
View File
@@ -313,11 +313,18 @@ impl ClientShellState {
}
pub(super) fn workspace_action_id(&self) -> Option<String> {
self.navigate_workspace_id.clone().or_else(|| {
self.snapshot
.as_deref()
.and_then(|snapshot| snapshot.focused_workspace_id.clone())
})
self.navigate_workspace_id
.as_ref()
.filter(|target| {
target.endpoint_id == self.active_endpoint_id
&& self.navigation_target_valid(target)
})
.map(|target| target.workspace_id.clone())
.or_else(|| {
self.snapshot
.as_deref()
.and_then(|snapshot| snapshot.focused_workspace_id.clone())
})
}
pub(super) fn open_new_workspace_overlay(&mut self) {
@@ -913,10 +920,8 @@ impl ClientShellState {
} else if key.code == KeyCode::Esc {
self.overlay = None;
self.mode = ClientShellMode::Navigate;
self.navigate_workspace_id = self
.snapshot
.as_deref()
.and_then(|snapshot| snapshot.focused_workspace_id.clone());
self.navigate_workspace_id = self.focused_navigation_target();
self.reveal_navigation_workspace = true;
outcome.repaint = true;
}
return;
+5 -2
View File
@@ -224,7 +224,8 @@ pub(super) struct ShellRenderState<'a> {
pub(super) sidebar_collapsed: bool,
pub(super) sidebar_section_split: f32,
pub(super) tab_drag_insert_index: Option<usize>,
pub(super) selected_workspace_id: Option<&'a str>,
pub(super) selected_workspace_id: Option<&'a WorkspaceNavigationTarget>,
pub(super) reveal_navigation_workspace: &'a mut bool,
pub(super) dragged_workspace_id: Option<&'a str>,
pub(super) workspace_drop_indicator_row: Option<u16>,
}
@@ -272,7 +273,9 @@ pub(super) fn render_shell(
layout.sidebar,
snapshot,
config,
state.selected_workspace_id,
state
.selected_workspace_id
.map(|target| target.workspace_id.as_str()),
&mut hits,
);
} else {
+3 -1
View File
@@ -294,7 +294,9 @@ pub(crate) fn render_sidebar(
break;
}
let rect = Rect::new(body.x, y, content_width, row_height);
let selected = state.selected_workspace_id == Some(workspace.workspace_id.as_str());
let selected = state.selected_workspace_id.is_some_and(|target| {
target.matches(state.active_endpoint_id, &workspace.workspace_id)
});
let dragged = state.dragged_workspace_id == Some(workspace.workspace_id.as_str());
if selected {
buffer.set_style(rect, Style::default().bg(palette.selection_bg));
+13 -10
View File
@@ -927,7 +927,8 @@ pub(crate) struct ClientShellState {
pub(super) active_endpoint_id: ClientEndpointId,
pub(super) collapsed_endpoints: HashSet<ClientEndpointId>,
pub(super) mode: ClientShellMode,
pub(super) navigate_workspace_id: Option<String>,
pub(super) navigate_workspace_id: Option<WorkspaceNavigationTarget>,
pub(super) reveal_navigation_workspace: bool,
pub(super) overlay: Option<ClientShellOverlay>,
pub(super) previous_pane_id: Option<String>,
pub(super) pane_mouse_gesture: Option<ClientPaneMouseGesture>,
@@ -1082,6 +1083,7 @@ impl ClientShellState {
collapsed_endpoints: HashSet::new(),
mode: ClientShellMode::Terminal,
navigate_workspace_id: None,
reveal_navigation_workspace: false,
overlay,
previous_pane_id: None,
pane_mouse_gesture: None,
@@ -1361,7 +1363,12 @@ impl ClientShellState {
self.hits = ShellHitMap::default();
}
if boot_changed {
// A reboot must not turn Enter on a stale preview into focus on a reused ID.
let preview = (self.mode == ClientShellMode::Navigate)
.then(|| self.navigate_workspace_id.take())
.flatten();
self.reset_endpoint_projection();
self.navigate_workspace_id = preview;
} else if let Some(previous) = self
.snapshot
.as_deref()
@@ -1477,15 +1484,11 @@ impl ClientShellState {
}
}
}
if self.mode == ClientShellMode::Navigate
&& self.navigate_workspace_id.as_ref().is_none_or(|selected| {
!snapshot
.workspaces
.iter()
.any(|workspace| &workspace.workspace_id == selected)
})
{
self.navigate_workspace_id = snapshot.focused_workspace_id.clone();
if self.mode == ClientShellMode::Navigate && self.navigate_workspace_id.is_none() {
self.navigate_workspace_id = snapshot
.focused_workspace_id
.as_deref()
.and_then(|id| self.navigation_target(&self.active_endpoint_id, id));
self.reveal_mobile_workspace = self.mobile_layout_active();
}
let pane_exists =
@@ -747,7 +747,7 @@ fn workspace_actions_preserve_selected_target_and_client_confirmation() {
let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
state.set_snapshot(Box::new(snapshot));
state.mode = ClientShellMode::Navigate;
state.navigate_workspace_id = Some("ws_2".into());
state.navigate_workspace_id = state.navigation_target(&ClientEndpointId::Local, "ws_2");
let rename = state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new(
KeyCode::Char('w'),
@@ -773,7 +773,7 @@ fn workspace_actions_preserve_selected_target_and_client_confirmation() {
if params.workspace_id == "ws_2" && params.label == "renamed"
));
state.navigate_workspace_id = Some("ws_2".into());
state.navigate_workspace_id = state.navigation_target(&ClientEndpointId::Local, "ws_2");
let mut close = ClientShellInput::default();
state.record_binding(
crate::input::KeybindMatch::Action(crate::input::KeybindAction::CloseWorkspace),
@@ -813,13 +813,18 @@ fn desktop_workspace_navigation_reveals_overflowing_selection() {
state.set_snapshot(Box::new(projected));
state.set_pane_surface(surface());
state.mode = ClientShellMode::Navigate;
state.navigate_workspace_id = Some("ws_1".into());
state.navigate_workspace_id = state.navigation_target(&ClientEndpointId::Local, "ws_1");
state.compose(106, 12).expect("overflowing sidebar");
for _ in 0..6 {
state.handle_input_bytes(b"\x1b[B");
state.compose(106, 12).expect("revealed workspace");
let selected = state.navigate_workspace_id.as_deref().expect("selection");
let selected = state
.navigate_workspace_id
.as_ref()
.expect("selection")
.workspace_id
.as_str();
assert!(
state
.hits
@@ -883,16 +888,25 @@ fn navigate_mode_selects_workspace_locally_then_focuses_by_stable_id() {
let enter_navigate = state.handle_input_bytes(b"w");
assert!(enter_navigate.repaint);
assert_eq!(state.mode, ClientShellMode::Navigate);
assert_eq!(state.navigate_workspace_id.as_deref(), Some("ws_1"));
assert_eq!(
state.navigate_workspace_id,
state.navigation_target(&ClientEndpointId::Local, "ws_1")
);
let invalid = state.handle_input_bytes(b"9");
assert!(invalid.actions.is_empty());
assert_eq!(state.mode, ClientShellMode::Navigate);
assert_eq!(state.navigate_workspace_id.as_deref(), Some("ws_1"));
assert_eq!(
state.navigate_workspace_id,
state.navigation_target(&ClientEndpointId::Local, "ws_1")
);
let move_selection = state.handle_input_bytes(b"\x1b[B");
assert!(move_selection.actions.is_empty());
assert_eq!(state.navigate_workspace_id.as_deref(), Some("ws_2"));
assert_eq!(
state.navigate_workspace_id,
state.navigation_target(&ClientEndpointId::Local, "ws_2")
);
let frame = state.compose(106, 20).expect("navigate frame");
let text = frame
.cells
+3
View File
@@ -1,4 +1,7 @@
use super::*;
#[path = "workspace_navigation.rs"]
mod workspace_navigation;
use crate::client::endpoint::{
ClientEndpointId, ClientEndpointStatus, ProfileId, SavedSshEndpoint,
};
+5 -2
View File
@@ -363,7 +363,7 @@ fn mobile_background_workspace_uses_its_own_active_tab_status() {
state.set_snapshot(Box::new(projected));
state.set_pane_surface(surface());
state.mode = ClientShellMode::Navigate;
state.navigate_workspace_id = Some("ws_2".into());
state.navigate_workspace_id = state.navigation_target(&ClientEndpointId::Local, "ws_2");
let frame = state.compose(44, 20).expect("mobile switcher");
let text = frame
.cells
@@ -615,7 +615,10 @@ fn mobile_switcher_scroll_close_and_width_transition_clear_mobile_hits() {
))]);
}
state.compose(44, 10).expect("revealed mobile selection");
assert_eq!(state.navigate_workspace_id.as_deref(), Some("ws_8"));
assert_eq!(
state.navigate_workspace_id,
state.navigation_target(&ClientEndpointId::Local, "ws_8")
);
assert!(state.mobile_switcher_scroll > 2);
assert!(state.hits.mobile_targets.iter().any(|(_, target)| {
matches!(
@@ -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");
}
}
+153
View File
@@ -0,0 +1,153 @@
use super::*;
/// A client-only preview. Snapshot identity prevents Enter from using a reused workspace ID.
#[derive(Clone, Debug, PartialEq, Eq)]
pub(super) struct WorkspaceNavigationTarget {
pub(super) endpoint_id: ClientEndpointId,
pub(super) workspace_id: String,
boot_id: String,
generation: Option<u64>,
}
impl WorkspaceNavigationTarget {
pub(super) fn matches(&self, endpoint_id: &ClientEndpointId, workspace_id: &str) -> bool {
&self.endpoint_id == endpoint_id && self.workspace_id == workspace_id
}
}
impl ClientShellState {
pub(super) fn navigation_target(
&self,
endpoint_id: &ClientEndpointId,
workspace_id: &str,
) -> Option<WorkspaceNavigationTarget> {
let endpoint = self
.endpoints
.iter()
.find(|entry| &entry.endpoint_id == endpoint_id)?;
let snapshot = endpoint.snapshot.as_deref()?;
Some(WorkspaceNavigationTarget {
endpoint_id: endpoint_id.clone(),
workspace_id: workspace_id.to_owned(),
boot_id: snapshot.boot_id.clone(),
generation: endpoint.snapshot_generation,
})
}
pub(super) fn focused_navigation_target(&self) -> Option<WorkspaceNavigationTarget> {
let workspace_id = self.snapshot.as_deref()?.focused_workspace_id.as_deref()?;
self.navigation_target(&self.active_endpoint_id, workspace_id)
}
pub(super) fn navigation_target_valid(&self, target: &WorkspaceNavigationTarget) -> bool {
self.endpoints.iter().any(|endpoint| {
endpoint.endpoint_id == target.endpoint_id
&& endpoint.status == ClientEndpointStatus::Online
&& endpoint.snapshot_generation == target.generation
&& endpoint.snapshot.as_deref().is_some_and(|snapshot| {
snapshot.boot_id == target.boot_id
&& snapshot
.workspaces
.iter()
.any(|workspace| workspace.workspace_id == target.workspace_id)
})
})
}
pub(super) fn workspace_preview_action_blocked(&self) -> bool {
self.navigate_workspace_id.as_ref().is_some_and(|target| {
target.endpoint_id != self.active_endpoint_id || !self.navigation_target_valid(target)
})
}
pub(super) fn move_navigate_workspace(&mut self, delta: isize) {
let mobile = self.mobile_layout_active();
let surface_available = self.snapshot.is_some() && self.pane_surface.is_some();
let empty_collapsed_groups = HashSet::new();
let mut targets = Vec::new();
for endpoint in &self.endpoints {
if endpoint.status != ClientEndpointStatus::Online {
continue;
}
let Some(snapshot) = endpoint.snapshot.as_deref() else {
continue;
};
let entries = if self.sidebar_collapsed && !mobile && surface_available {
snapshot
.workspaces
.iter()
.enumerate()
.map(|(index, _)| WorkspaceEntry {
index,
indented: false,
last_child: false,
})
.collect()
} else {
let collapsed_groups = if mobile && surface_available {
&empty_collapsed_groups
} else {
self.collapsed_groups_for_endpoint(&endpoint.endpoint_id)
.unwrap_or(&empty_collapsed_groups)
};
render::workspace_entries(snapshot, collapsed_groups)
};
for entry in entries {
targets.push(WorkspaceNavigationTarget {
endpoint_id: endpoint.endpoint_id.clone(),
workspace_id: snapshot.workspaces[entry.index].workspace_id.clone(),
boot_id: snapshot.boot_id.clone(),
generation: endpoint.snapshot_generation,
});
}
}
if targets.is_empty() {
return;
}
let current = self
.navigate_workspace_id
.as_ref()
.and_then(|selected| targets.iter().position(|target| target == selected));
let next = match current {
Some(current) if mobile => {
(current as isize + delta).clamp(0, targets.len() as isize - 1) as usize
}
Some(current) => (current as isize + delta).rem_euclid(targets.len() as isize) as usize,
None if delta < 0 => targets.len() - 1,
None => 0,
};
let target = targets.swap_remove(next);
self.collapsed_endpoints.remove(&target.endpoint_id);
if self.endpoints.len() == 1 && !mobile {
self.reveal_workspace(&target.workspace_id);
}
self.navigate_workspace_id = Some(target);
self.reveal_mobile_workspace = mobile;
self.reveal_navigation_workspace =
!mobile || self.snapshot.is_none() || self.pane_surface.is_none();
}
pub(super) fn accept_navigate_workspace(&mut self, outcome: &mut ClientShellInput) {
let Some(target) = self.navigate_workspace_id.clone() else {
self.mode = self.copy_or_terminal_mode();
outcome.repaint = true;
return;
};
if !self.navigation_target_valid(&target) {
self.receive_endpoint_unavailable(
"Workspace is no longer available; select a connected workspace".into(),
);
outcome.repaint = true;
return;
}
if self.focus_or_activate(
target.endpoint_id,
ClientEndpointFocusTarget::Workspace(target.workspace_id),
outcome,
) {
self.mode = ClientShellMode::Terminal;
self.navigate_workspace_id = None;
}
outcome.repaint = true;
}
}