aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorakbash <akbash@herdr.dev>2026-09-09 02:35:31 +0300
committerGitHub <noreply@github.com>2026-09-09 02:35:31 +0300
commitb9ce96869e89937278d673d70ae4c135dd318469 (patch)
treefea7e39dad42f8cad1f1846af6babda546c1fa78
parent8be4cf765afddfd9cf3206602ba4dab682a29fa1 (diff)
feat: navigate and highlight workspaces across machines (#3755)
* fix: highlight workspace navigation with saved machines refs #3754 * feat: navigate workspaces across connected machines refs #3754 * feat: navigate workspaces across machines refs #3754 --------- Co-authored-by: akbash-bot <300245827+akbash-bot@users.noreply.github.com> Co-authored-by: Ogulcan Celik <ogulcancelik@gmail.com>
-rw-r--r--docs/next/website/src/content/docs/connecting-machines.mdx4
-rw-r--r--src/client/shell.rs2
-rw-r--r--src/client/shell/actions.rs23
-rw-r--r--src/client/shell/composition.rs32
-rw-r--r--src/client/shell/endpoint_sidebar.rs146
-rw-r--r--src/client/shell/input.rs77
-rw-r--r--src/client/shell/mobile.rs22
-rw-r--r--src/client/shell/mouse.rs10
-rw-r--r--src/client/shell/overlay_input.rs23
-rw-r--r--src/client/shell/render.rs7
-rw-r--r--src/client/shell/sidebar.rs4
-rw-r--r--src/client/shell/state.rs23
-rw-r--r--src/client/shell/tests/agents_worktrees_notifications.rs28
-rw-r--r--src/client/shell/tests/endpoints.rs3
-rw-r--r--src/client/shell/tests/mobile.rs7
-rw-r--r--src/client/shell/tests/workspace_navigation.rs503
-rw-r--r--src/client/shell/workspace_navigation.rs153
17 files changed, 934 insertions, 133 deletions
diff --git a/docs/next/website/src/content/docs/connecting-machines.mdx b/docs/next/website/src/content/docs/connecting-machines.mdx
index dfb68e8e..a9120102 100644
--- a/docs/next/website/src/content/docs/connecting-machines.mdx
+++ b/docs/next/website/src/content/docs/connecting-machines.mdx
@@ -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.
diff --git a/src/client/shell.rs b/src/client/shell.rs
index 14f4f660..98b77d25 100644
--- a/src/client/shell.rs
+++ b/src/client/shell.rs
@@ -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;
diff --git a/src/client/shell/actions.rs b/src/client/shell/actions.rs
index 31408457..119988f6 100644
--- a/src/client/shell/actions.rs
+++ b/src/client/shell/actions.rs
@@ -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>,
diff --git a/src/client/shell/composition.rs b/src/client/shell/composition.rs
index c54a56e2..523cec87 100644
--- a/src/client/shell/composition.rs
+++ b/src/client/shell/composition.rs
@@ -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,
diff --git a/src/client/shell/endpoint_sidebar.rs b/src/client/shell/endpoint_sidebar.rs
index d56f276c..193d75d1 100644
--- a/src/client/shell/endpoint_sidebar.rs
+++ b/src/client/shell/endpoint_sidebar.rs
@@ -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,
diff --git a/src/client/shell/input.rs b/src/client/shell/input.rs
index 9b5fff21..dd7bf87d 100644
--- a/src/client/shell/input.rs
+++ b/src/client/shell/input.rs
@@ -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;
diff --git a/src/client/shell/mobile.rs b/src/client/shell/mobile.rs
index abe50590..95036de5 100644
--- a/src/client/shell/mobile.rs
+++ b/src/client/shell/mobile.rs
@@ -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;
}
diff --git a/src/client/shell/mouse.rs b/src/client/shell/mouse.rs
index cf04fb89..ec231c92 100644
--- a/src/client/shell/mouse.rs
+++ b/src/client/shell/mouse.rs
@@ -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)
diff --git a/src/client/shell/overlay_input.rs b/src/client/shell/overlay_input.rs
index db089f4e..42e331af 100644
--- a/src/client/shell/overlay_input.rs
+++ b/src/client/shell/overlay_input.rs
@@ -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;
diff --git a/src/client/shell/render.rs b/src/client/shell/render.rs
index bcd9e49b..d51586a0 100644
--- a/src/client/shell/render.rs
+++ b/src/client/shell/render.rs
@@ -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 {
diff --git a/src/client/shell/sidebar.rs b/src/client/shell/sidebar.rs
index fe6abf00..c002fd62 100644
--- a/src/client/shell/sidebar.rs
+++ b/src/client/shell/sidebar.rs
@@ -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));
diff --git a/src/client/shell/state.rs b/src/client/shell/state.rs
index f4050feb..4ac49680 100644
--- a/src/client/shell/state.rs
+++ b/src/client/shell/state.rs
@@ -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 =
diff --git a/src/client/shell/tests/agents_worktrees_notifications.rs b/src/client/shell/tests/agents_worktrees_notifications.rs
index 2291318e..8b4cc8bd 100644
--- a/src/client/shell/tests/agents_worktrees_notifications.rs
+++ b/src/client/shell/tests/agents_worktrees_notifications.rs
@@ -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
diff --git a/src/client/shell/tests/endpoints.rs b/src/client/shell/tests/endpoints.rs
index 515c9837..e425b794 100644
--- a/src/client/shell/tests/endpoints.rs
+++ b/src/client/shell/tests/endpoints.rs
@@ -1,4 +1,7 @@
use super::*;
+
+#[path = "workspace_navigation.rs"]
+mod workspace_navigation;
use crate::client::endpoint::{
ClientEndpointId, ClientEndpointStatus, ProfileId, SavedSshEndpoint,
};
diff --git a/src/client/shell/tests/mobile.rs b/src/client/shell/tests/mobile.rs
index b7cc2a47..0c6f1908 100644
--- a/src/client/shell/tests/mobile.rs
+++ b/src/client/shell/tests/mobile.rs
@@ -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!(
diff --git a/src/client/shell/tests/workspace_navigation.rs b/src/client/shell/tests/workspace_navigation.rs
new file mode 100644
index 00000000..3e19b87c
--- /dev/null
+++ b/src/client/shell/tests/workspace_navigation.rs
@@ -0,0 +1,503 @@
+use super::*;
+
+fn workspaces(count: usize) -> ClientShellSnapshot {
+ let mut projected = snapshot();
+ projected.workspaces = (1..=count)
+ .map(|number| {
+ let mut workspace = projected.workspaces[0].clone();
+ workspace.workspace_id = format!("ws_{number}");
+ workspace.number = number;
+ workspace.focused = number == 1;
+ workspace
+ })
+ .collect();
+ projected
+}
+
+fn grouped_workspaces() -> ClientShellSnapshot {
+ let mut projected = workspaces(3);
+ for (index, linked) in [(0, false), (2, true)] {
+ projected.workspaces[index].worktree = Some(ClientShellWorktree {
+ key: "repo".into(),
+ label: "repo".into(),
+ is_linked_worktree: linked,
+ });
+ }
+ projected
+}
+
+fn navigation_state(mut projected: ClientShellSnapshot) -> (ClientShellState, ClientEndpointId) {
+ let (mut state, remote) = state_with_remote();
+ state.set_snapshot(Box::new(projected.clone()));
+ projected.boot_id = "remote-boot".into();
+ state.set_endpoint_snapshot(&remote, Box::new(projected));
+ (state, remote)
+}
+
+fn preview_key(state: &mut ClientShellState, bytes: &[u8]) {
+ let outcome = state.handle_input_bytes(bytes);
+ assert!(outcome.actions.is_empty(), "{bytes:?}");
+ assert!(outcome.requests.is_empty(), "{bytes:?}");
+ assert!(outcome.repaint, "{bytes:?}");
+}
+
+fn enter_navigation(state: &mut ClientShellState) {
+ preview_key(state, &[0x02]);
+ preview_key(state, b"w");
+ assert_eq!(state.mode, ClientShellMode::Navigate);
+}
+
+fn assert_selected(state: &ClientShellState, endpoint: &ClientEndpointId, workspace: &str) {
+ assert_eq!(
+ state.navigate_workspace_id,
+ state.navigation_target(endpoint, workspace)
+ );
+}
+
+fn workspace_rect(state: &ClientShellState, endpoint: &ClientEndpointId, workspace: &str) -> Rect {
+ state
+ .hits
+ .workspaces
+ .iter()
+ .find(|hit| &hit.endpoint_id == endpoint && hit.workspace_id == workspace)
+ .map(|hit| hit.rect)
+ .or_else(|| {
+ state.hits.mobile_targets.iter().find_map(|(rect, target)| {
+ matches!(target, ClientMobileTarget::Workspace { endpoint_id, workspace_id }
+ if endpoint_id == endpoint && workspace_id == workspace)
+ .then_some(*rect)
+ })
+ })
+ .expect("visible workspace")
+}
+
+#[test]
+fn navigation_highlights_only_the_preview_and_activates_on_enter() {
+ for (compact, cols) in [(true, 100), (false, 100), (false, 44)] {
+ for terminal_theme in [false, true] {
+ let (mut state, remote) = navigation_state(workspaces(2));
+ state.sidebar_collapsed = compact;
+ if terminal_theme {
+ state.config.palette = Palette::terminal();
+ }
+ state.compose(cols, 28).unwrap();
+ enter_navigation(&mut state);
+ for (endpoint, collision, steps) in [
+ (&ClientEndpointId::Local, &remote, 1),
+ (&remote, &ClientEndpointId::Local, 2),
+ ] {
+ for _ in 0..steps {
+ preview_key(&mut state, b"\x1b[B");
+ }
+ assert_selected(&state, endpoint, "ws_2");
+ let buffer = state
+ .compose(cols, 28)
+ .unwrap()
+ .to_ratatui_buffer()
+ .unwrap();
+ let selected = workspace_rect(&state, endpoint, "ws_2");
+ let other = workspace_rect(&state, collision, "ws_2");
+ let focused = workspace_rect(&state, &ClientEndpointId::Local, "ws_1");
+ let palette = &state.config.palette;
+ let color = if cols == 44 {
+ palette.surface0
+ } else {
+ palette.selection_bg
+ };
+ let color = if color == ratatui::style::Color::Reset {
+ palette.active_row_bg
+ } else {
+ color
+ };
+ assert_eq!(buffer[(selected.x + 2, selected.y)].bg, color);
+ assert_ne!(buffer[(other.x + 2, other.y)].bg, color);
+ assert_eq!(
+ buffer[(focused.x + 2, focused.y)].bg,
+ if cols == 44 {
+ palette.surface_dim
+ } else {
+ palette.active_row_bg
+ }
+ );
+ }
+ assert_eq!(state.snapshot.as_ref().unwrap().boot_id, "boot-1");
+ assert_eq!(
+ state
+ .snapshot
+ .as_ref()
+ .unwrap()
+ .focused_workspace_id
+ .as_deref(),
+ Some("ws_1")
+ );
+ assert_eq!(state.pane_surface.as_ref().unwrap().boot_id, "boot-1");
+ let enter = state.handle_input_bytes(b"\r");
+ assert!(enter.requests.is_empty());
+ assert!(
+ matches!(enter.actions.as_slice(), [ClientShellAction::ActivateEndpoint {
+ endpoint_id, target: Some(ClientEndpointFocusTarget::Workspace(id)),
+ }] if endpoint_id == &remote && id == "ws_2")
+ );
+ assert_eq!(state.active_endpoint_id, ClientEndpointId::Local);
+ assert_eq!(state.mode, ClientShellMode::Terminal);
+ assert!(state.navigate_workspace_id.is_none());
+ }
+ }
+}
+
+#[test]
+fn foreign_preview_blocks_keyboard_actions_but_keeps_active_action_context() {
+ let (mut state, remote) = state_with_remote();
+ state.compose(100, 28).unwrap();
+ enter_navigation(&mut state);
+ preview_key(&mut state, b"\x1b[B");
+ for confirm in [false, true] {
+ state.config.confirm_close = confirm;
+ for key in [
+ b"W".as_slice(),
+ b"D",
+ b"\x1b[D",
+ b"\x1b[C",
+ b"\t",
+ b"1",
+ b"c",
+ b"N",
+ ] {
+ preview_key(&mut state, key);
+ assert!(state.overlay.is_none());
+ assert_eq!(state.mode, ClientShellMode::Navigate);
+ }
+ }
+ assert_selected(&state, &remote, "ws_1");
+ let mut remote_snapshot = workspaces(2);
+ remote_snapshot.boot_id = "remote-boot".into();
+ state.set_endpoint_snapshot(&remote, Box::new(remote_snapshot));
+ preview_key(&mut state, b"\x1b[B");
+ assert_selected(&state, &remote, "ws_2");
+ assert_eq!(state.workspace_action_id().as_deref(), Some("ws_1"));
+ state.config.prompt_new_workspace_name = false;
+ let mut create = ClientShellInput::default();
+ state.record_binding(
+ crate::input::KeybindMatch::Action(crate::input::KeybindAction::NewWorkspace),
+ &mut create,
+ );
+ assert!(
+ matches!(create.actions.as_slice(), [ClientShellAction::Endpoint { endpoint_id: ClientEndpointId::Local, request, .. }]
+ if matches!(&request.method, crate::api::schema::Method::WorkspaceCreate(params) if params.source_workspace_id.as_deref() == Some("ws_1")))
+ );
+ preview_key(&mut state, b"\x1b");
+ assert!(state.navigate_workspace_id.is_none());
+ assert_eq!(state.active_endpoint_id, ClientEndpointId::Local);
+ assert!(state.activate_endpoint_projection(&remote));
+ enter_navigation(&mut state);
+ preview_key(&mut state, b"W");
+ assert!(matches!(state.overlay, Some(ClientShellOverlay::Rename(_))));
+}
+
+#[test]
+fn empty_workspace_navigation_enter_exits_without_focusing() {
+ let (mut state, _) = state_with_remote();
+ let mut empty = workspaces(0);
+ empty.tabs.clear();
+ empty.panes.clear();
+ empty.focused_workspace_id = None;
+ empty.focused_tab_id = None;
+ empty.focused_pane_id = None;
+ state.set_snapshot(Box::new(empty));
+ enter_navigation(&mut state);
+ assert!(state.navigate_workspace_id.is_none());
+ let enter = state.handle_input_bytes(b"\r");
+ assert!(enter.actions.is_empty() && enter.requests.is_empty() && enter.repaint);
+ assert_eq!(state.mode, ClientShellMode::Terminal);
+}
+
+#[test]
+fn foreign_workspace_preview_blocks_paste_into_hidden_copy_search() {
+ let (mut state, _) = state_with_remote();
+ let mut pane_surface = surface();
+ pane_surface.panes[0].scroll = Some(crate::protocol::PaneSurfaceScrollMetrics {
+ offset_from_bottom: 0,
+ max_offset_from_bottom: 20,
+ viewport_rows: 2,
+ });
+ state.set_pane_surface(pane_surface);
+ state.compose(100, 28).unwrap();
+ assert!(state.enter_copy_mode(&mut ClientShellInput::default()));
+ state.copy_mode.as_mut().unwrap().search_prompt = Some(ClientCopySearchPrompt {
+ direction: crate::api::schema::PaneCopySearchDirection::Forward,
+ query: "original".into(),
+ });
+ enter_navigation(&mut state);
+ preview_key(&mut state, b"\x1b[B");
+ assert!(state.workspace_preview_action_blocked());
+ assert!(!state.modal_paste_target_active());
+ let key = crate::input::TerminalKey::new(KeyCode::Char('v'), KeyModifiers::CONTROL);
+ assert!(!state.handle_modal_paste_shortcut_with(
+ &key,
+ &mut ClientShellInput::default(),
+ || { panic!("hidden search must not read the clipboard") }
+ ));
+ let paste = state.handle_raw_events(vec![RawInputEvent::Paste("unexpected".into())]);
+ assert!(paste.actions.is_empty() && paste.requests.is_empty());
+ assert_eq!(
+ state.copy_mode.unwrap().search_prompt.unwrap().query,
+ "original"
+ );
+}
+
+#[test]
+fn mouse_clicks_cancel_remote_workspace_navigation() {
+ for pane in [false, true] {
+ let (mut state, _) = state_with_remote();
+ state.compose(100, 28).unwrap();
+ enter_navigation(&mut state);
+ preview_key(&mut state, b"\x1b[B");
+ state.compose(100, 28).unwrap();
+ let rect = if pane {
+ state.hits.panes[0].inner_rect
+ } else {
+ workspace_rect(&state, &ClientEndpointId::Local, "ws_1")
+ };
+ for kind in [
+ MouseEventKind::Down(MouseButton::Left),
+ MouseEventKind::Up(MouseButton::Left),
+ ] {
+ state.handle_raw_events(vec![RawInputEvent::Mouse(MouseEvent {
+ kind,
+ column: rect.x + 2,
+ row: rect.y,
+ modifiers: KeyModifiers::empty(),
+ })]);
+ }
+ assert_eq!(state.mode, ClientShellMode::Terminal);
+ assert!(state.navigate_workspace_id.is_none());
+ enter_navigation(&mut state);
+ assert_selected(&state, &ClientEndpointId::Local, "ws_1");
+ }
+}
+
+#[test]
+fn single_machine_compact_navigation_includes_visible_collapsed_group_children() {
+ let (mut state, _) = navigation_state(grouped_workspaces());
+ state.set_endpoint_catalog(&[]);
+ state.toggle_collapsed_group(&ClientEndpointId::Local, "repo".into());
+ state.sidebar_collapsed = true;
+ state.compose(100, 28).unwrap();
+ workspace_rect(&state, &ClientEndpointId::Local, "ws_3");
+ enter_navigation(&mut state);
+ for id in ["ws_2", "ws_3"] {
+ preview_key(&mut state, b"\x1b[B");
+ assert_selected(&state, &ClientEndpointId::Local, id);
+ }
+}
+
+#[test]
+fn workspace_navigation_respects_each_machines_visible_worktree_groups() {
+ for (cols, compact, unavailable, show_child) in [
+ (100, false, false, false),
+ (100, true, false, true),
+ (44, false, false, true),
+ (44, true, true, false),
+ ] {
+ let (mut state, remote) = navigation_state(grouped_workspaces());
+ state.toggle_collapsed_group(&remote, "repo".into());
+ state.sidebar_collapsed = compact;
+ if unavailable {
+ state.pane_surface = None;
+ }
+ state.compose(cols, 28).unwrap();
+ enter_navigation(&mut state);
+ let local = if compact && !unavailable {
+ ["ws_2", "ws_3"]
+ } else {
+ ["ws_3", "ws_2"]
+ };
+ let remote_ids: &[&str] = if !show_child {
+ &["ws_1", "ws_2"]
+ } else if compact {
+ &["ws_1", "ws_2", "ws_3"]
+ } else {
+ &["ws_1", "ws_3", "ws_2"]
+ };
+ for (endpoint, ids) in [
+ (&ClientEndpointId::Local, local.as_slice()),
+ (&remote, remote_ids),
+ ] {
+ for id in ids {
+ preview_key(&mut state, b"\x1b[B");
+ assert_selected(&state, endpoint, id);
+ state.compose(cols, 28).unwrap();
+ workspace_rect(&state, endpoint, id);
+ }
+ }
+ assert!(state.group_is_collapsed(&remote, "repo"));
+ assert!(!state.group_is_collapsed(&ClientEndpointId::Local, "repo"));
+ }
+}
+
+#[test]
+fn foreign_preview_survives_local_updates_and_rejects_stale_enter() {
+ for invalidation in [
+ "offline",
+ "disabled",
+ "removed",
+ "deleted",
+ "boot",
+ "generation",
+ ] {
+ let (mut state, remote_id) = state_with_remote();
+ let mut remote = workspaces(2);
+ remote.boot_id = "remote-boot".into();
+ state.set_endpoint_snapshot_for_generation(&remote_id, 7, Box::new(remote.clone()));
+ state.compose(100, 28).unwrap();
+ enter_navigation(&mut state);
+ for _ in 0..2 {
+ preview_key(&mut state, b"\x1b[B");
+ }
+ assert_selected(&state, &remote_id, "ws_2");
+ let selected = state.navigate_workspace_id.clone();
+ remote.revision += 1;
+ state.set_endpoint_snapshot_for_generation(&remote_id, 7, Box::new(remote.clone()));
+ assert_eq!(state.navigate_workspace_id, selected);
+ assert!(state.navigation_target_valid(selected.as_ref().unwrap()));
+ let mut local = snapshot();
+ local.revision += 1;
+ state.set_snapshot(Box::new(local));
+ assert_eq!(state.navigate_workspace_id, selected);
+ match invalidation {
+ "offline" => state.set_endpoint_status(&remote_id, ClientEndpointStatus::Reconnecting),
+ "disabled" => {
+ let mut profile = remote_profile();
+ profile.enabled = false;
+ state.set_endpoint_catalog(&[profile]);
+ }
+ "removed" => state.set_endpoint_catalog(&[]),
+ "deleted" => {
+ remote.revision += 1;
+ remote.workspaces.pop();
+ state.set_endpoint_snapshot_for_generation(&remote_id, 7, Box::new(remote));
+ }
+ "boot" => {
+ remote.boot_id = "restarted-remote".into();
+ state.set_endpoint_snapshot_for_generation(&remote_id, 7, Box::new(remote));
+ }
+ "generation" => {
+ state.cache_endpoint_snapshot_for_generation(&remote_id, 8, Box::new(remote))
+ }
+ _ => unreachable!(),
+ }
+ preview_key(&mut state, b"\r");
+ assert_eq!(state.active_endpoint_id, ClientEndpointId::Local);
+ assert_eq!(state.mode, ClientShellMode::Navigate);
+ assert!(state.visible_endpoint_notice.is_some());
+ assert!(!state.navigation_target_valid(state.navigate_workspace_id.as_ref().unwrap()));
+ preview_key(&mut state, b"\x1b[B");
+ assert!(state.navigation_target_valid(state.navigate_workspace_id.as_ref().unwrap()));
+ }
+}
+
+#[test]
+fn navigation_uses_displayed_group_order_when_local_is_unavailable() {
+ for cols in [100, 44] {
+ let (mut state, remote) = navigation_state(grouped_workspaces());
+ state.set_endpoint_status(&ClientEndpointId::Local, ClientEndpointStatus::Reconnecting);
+ state.select_unavailable_local();
+ state.sidebar_collapsed = true;
+ state.compose(cols, 18).unwrap();
+ enter_navigation(&mut state);
+ for id in ["ws_1", "ws_3", "ws_2"] {
+ preview_key(&mut state, b"\x1b[B");
+ assert_selected(&state, &remote, id);
+ state.compose(cols, 18).unwrap();
+ workspace_rect(&state, &remote, id);
+ }
+ let enter = state.handle_input_bytes(b"\r");
+ assert!(
+ matches!(enter.actions.as_slice(), [ClientShellAction::ActivateEndpoint {
+ endpoint_id, target: Some(ClientEndpointFocusTarget::Workspace(id)),
+ }] if endpoint_id == &remote && id == "ws_2")
+ );
+ }
+}
+
+#[test]
+fn active_preview_is_not_retargeted_by_deletion_or_reboot() {
+ for invalidation in ["deleted", "boot", "generation"] {
+ let (mut state, _) = state_with_remote();
+ let mut local = workspaces(2);
+ state.set_endpoint_snapshot_for_generation(
+ &ClientEndpointId::Local,
+ 7,
+ Box::new(local.clone()),
+ );
+ state.compose(100, 28).unwrap();
+ enter_navigation(&mut state);
+ preview_key(&mut state, b"\x1b[B");
+ assert_selected(&state, &ClientEndpointId::Local, "ws_2");
+ let selected = state.navigate_workspace_id.clone();
+ match invalidation {
+ "boot" => local.boot_id = "new-local-boot".into(),
+ "deleted" => {
+ local.revision += 1;
+ local.workspaces.pop();
+ }
+ _ => {}
+ }
+ let generation = if invalidation == "generation" { 8 } else { 7 };
+ state.set_endpoint_snapshot_for_generation(
+ &ClientEndpointId::Local,
+ generation,
+ Box::new(local),
+ );
+ assert_eq!(state.navigate_workspace_id, selected);
+ preview_key(&mut state, b"\r");
+ assert_eq!(state.mode, ClientShellMode::Navigate);
+ assert!(state.visible_endpoint_notice.is_some());
+ for confirm in [false, true] {
+ state.config.confirm_close = confirm;
+ for key in [b"W", b"D"] {
+ preview_key(&mut state, key);
+ }
+ assert!(state.overlay.is_none());
+ assert_eq!(state.mode, ClientShellMode::Navigate);
+ }
+ assert_eq!(state.workspace_action_id().as_deref(), Some("ws_1"));
+ }
+}
+
+#[test]
+fn aggregate_navigation_reveals_overflow_and_preserves_order() {
+ for (compact, cols) in [(true, 100), (false, 100), (false, 44)] {
+ let (mut state, remote_id) = state_with_remote();
+ let mut remote = workspaces(15);
+ remote.boot_id = "remote-boot".into();
+ state.set_endpoint_snapshot(&remote_id, Box::new(remote));
+ state.sidebar_collapsed = compact;
+ state.collapsed_endpoints.insert(remote_id.clone());
+ state.compose(cols, 18).unwrap();
+ enter_navigation(&mut state);
+ for number in 1..=15 {
+ preview_key(&mut state, b"\x1b[B");
+ let id = format!("ws_{number}");
+ assert_selected(&state, &remote_id, &id);
+ state.compose(cols, 18).unwrap();
+ workspace_rect(&state, &remote_id, &id);
+ }
+ assert!(!state.collapsed_endpoints.contains(&remote_id));
+ preview_key(&mut state, b"\x1b[B");
+ if cols == 44 {
+ assert_selected(&state, &remote_id, "ws_15");
+ } else {
+ assert_selected(&state, &ClientEndpointId::Local, "ws_1");
+ }
+ preview_key(&mut state, b"\x1b[A");
+ assert_selected(
+ &state,
+ &remote_id,
+ if cols == 44 { "ws_14" } else { "ws_15" },
+ );
+ state.set_endpoint_status(&remote_id, ClientEndpointStatus::Reconnecting);
+ preview_key(&mut state, b"\x1b[B");
+ assert_selected(&state, &ClientEndpointId::Local, "ws_1");
+ }
+}
diff --git a/src/client/shell/workspace_navigation.rs b/src/client/shell/workspace_navigation.rs
new file mode 100644
index 00000000..6139fe0d
--- /dev/null
+++ b/src/client/shell/workspace_navigation.rs
@@ -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;
+ }
+}