From b9ce96869e89937278d673d70ae4c135dd318469 Mon Sep 17 00:00:00 2001 From: akbash Date: Wed, 9 Sep 2026 02:35:31 +0300 Subject: [PATCH] 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 --- .../src/content/docs/connecting-machines.mdx | 4 + src/client/shell.rs | 2 + src/client/shell/actions.rs | 23 +- src/client/shell/composition.rs | 32 +- src/client/shell/endpoint_sidebar.rs | 146 +++-- src/client/shell/input.rs | 77 +-- src/client/shell/mobile.rs | 22 +- src/client/shell/mouse.rs | 10 + src/client/shell/overlay_input.rs | 23 +- src/client/shell/render.rs | 7 +- src/client/shell/sidebar.rs | 4 +- src/client/shell/state.rs | 23 +- .../tests/agents_worktrees_notifications.rs | 28 +- src/client/shell/tests/endpoints.rs | 3 + src/client/shell/tests/mobile.rs | 7 +- .../shell/tests/workspace_navigation.rs | 503 ++++++++++++++++++ src/client/shell/workspace_navigation.rs | 153 ++++++ 17 files changed, 934 insertions(+), 133 deletions(-) create mode 100644 src/client/shell/tests/workspace_navigation.rs create mode 100644 src/client/shell/workspace_navigation.rs 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, 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 { + 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::>(); 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 { 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 { - 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, - 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, } @@ -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, pub(super) mode: ClientShellMode, - pub(super) navigate_workspace_id: Option, + pub(super) navigate_workspace_id: Option, + pub(super) reveal_navigation_workspace: bool, pub(super) overlay: Option, pub(super) previous_pane_id: Option, pub(super) pane_mouse_gesture: Option, @@ -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, +} + +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 { + 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 { + 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; + } +}