mirror of
https://github.com/herdrdev/herdr.git
synced 2026-09-21 16:01:04 +00:00
feat: navigate and highlight workspaces across machines (#3755)
* fix: highlight workspace navigation with saved machines refs #3754 * feat: navigate workspaces across connected machines refs #3754 * feat: navigate workspaces across machines refs #3754 --------- Co-authored-by: akbash-bot <300245827+akbash-bot@users.noreply.github.com> Co-authored-by: Ogulcan Celik <ogulcancelik@gmail.com>
This commit is contained in:
co-authored by
akbash-bot
Ogulcan Celik
parent
8be4cf765a
commit
b9ce96869e
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -22,6 +22,42 @@ pub(super) fn render_collapsed(
|
||||
let palette = &config.palette;
|
||||
super::render::render_sidebar_background(buffer, area, palette);
|
||||
let (workspace_area, divider_y, detail_area) = super::sidebar::collapsed_sidebar_sections(area);
|
||||
let mut total_rows = 0usize;
|
||||
let mut selected_row = None;
|
||||
let reveal = std::mem::take(state.reveal_navigation_workspace);
|
||||
for endpoint in state.endpoints {
|
||||
total_rows += 1;
|
||||
if state.collapsed_endpoints.contains(&endpoint.endpoint_id) {
|
||||
continue;
|
||||
}
|
||||
if let Some(snapshot) = endpoint.snapshot.as_deref() {
|
||||
if reveal {
|
||||
if let Some(target) = state
|
||||
.selected_workspace_id
|
||||
.filter(|target| target.endpoint_id == endpoint.endpoint_id)
|
||||
{
|
||||
selected_row = snapshot
|
||||
.workspaces
|
||||
.iter()
|
||||
.position(|workspace| workspace.workspace_id == target.workspace_id)
|
||||
.map(|index| total_rows + index);
|
||||
}
|
||||
}
|
||||
total_rows += snapshot.workspaces.len();
|
||||
}
|
||||
}
|
||||
let height = usize::from(workspace_area.height);
|
||||
let max_scroll = total_rows.saturating_sub(height);
|
||||
*state.workspace_scroll = (*state.workspace_scroll).min(max_scroll);
|
||||
if let Some(row) = selected_row {
|
||||
if row < *state.workspace_scroll {
|
||||
*state.workspace_scroll = row;
|
||||
} else if row >= state.workspace_scroll.saturating_add(height) {
|
||||
*state.workspace_scroll = row.saturating_add(1).saturating_sub(height).min(max_scroll);
|
||||
}
|
||||
}
|
||||
hits.workspace_max_scroll = max_scroll;
|
||||
let mut skip = *state.workspace_scroll;
|
||||
let mut y = workspace_area.y;
|
||||
for (index, endpoint) in state.endpoints.iter().enumerate() {
|
||||
if y >= workspace_area.bottom() {
|
||||
@@ -30,37 +66,41 @@ pub(super) fn render_collapsed(
|
||||
let rect = Rect::new(workspace_area.x, y, workspace_area.width, 1);
|
||||
let active = &endpoint.endpoint_id == state.active_endpoint_id;
|
||||
let collapsed = state.collapsed_endpoints.contains(&endpoint.endpoint_id);
|
||||
if active && collapsed {
|
||||
buffer.set_style(rect, Style::default().bg(palette.active_row_bg));
|
||||
}
|
||||
let label = if endpoint.endpoint_id.is_local() {
|
||||
"L".to_owned()
|
||||
if skip > 0 {
|
||||
skip -= 1;
|
||||
} else {
|
||||
(index + 1).to_string()
|
||||
};
|
||||
let marker = if collapsed { "▸" } else { "▾" };
|
||||
put_text(
|
||||
buffer,
|
||||
rect.x,
|
||||
rect.y,
|
||||
rect.width.saturating_sub(1),
|
||||
&format!("{marker}{label}"),
|
||||
Style::default().fg(if endpoint.status == ClientEndpointStatus::Online {
|
||||
palette.text
|
||||
if active && collapsed {
|
||||
buffer.set_style(rect, Style::default().bg(palette.active_row_bg));
|
||||
}
|
||||
let label = if endpoint.endpoint_id.is_local() {
|
||||
"L".to_owned()
|
||||
} else {
|
||||
palette.overlay0
|
||||
}),
|
||||
);
|
||||
if !endpoint.endpoint_id.is_local() {
|
||||
let (glyph, _, color) = endpoint_status_presentation(endpoint.status, palette);
|
||||
put_right_text(buffer, rect, rect.y, glyph, Style::default().fg(color));
|
||||
(index + 1).to_string()
|
||||
};
|
||||
let marker = if collapsed { "▸" } else { "▾" };
|
||||
put_text(
|
||||
buffer,
|
||||
rect.x,
|
||||
rect.y,
|
||||
rect.width.saturating_sub(1),
|
||||
&format!("{marker}{label}"),
|
||||
Style::default().fg(if endpoint.status == ClientEndpointStatus::Online {
|
||||
palette.text
|
||||
} else {
|
||||
palette.overlay0
|
||||
}),
|
||||
);
|
||||
if !endpoint.endpoint_id.is_local() {
|
||||
let (glyph, _, color) = endpoint_status_presentation(endpoint.status, palette);
|
||||
put_right_text(buffer, rect, rect.y, glyph, Style::default().fg(color));
|
||||
}
|
||||
hits.machines.push(MachineHit {
|
||||
rect,
|
||||
collapse_toggle: Rect::new(rect.x, rect.y, u16::from(rect.width > 1), 1),
|
||||
endpoint_id: endpoint.endpoint_id.clone(),
|
||||
});
|
||||
y = y.saturating_add(1);
|
||||
}
|
||||
hits.machines.push(MachineHit {
|
||||
rect,
|
||||
collapse_toggle: Rect::new(rect.x, rect.y, u16::from(rect.width > 1), 1),
|
||||
endpoint_id: endpoint.endpoint_id.clone(),
|
||||
});
|
||||
y = y.saturating_add(1);
|
||||
if collapsed {
|
||||
continue;
|
||||
}
|
||||
@@ -68,12 +108,26 @@ pub(super) fn render_collapsed(
|
||||
continue;
|
||||
};
|
||||
for workspace in &snapshot.workspaces {
|
||||
if skip > 0 {
|
||||
skip -= 1;
|
||||
continue;
|
||||
}
|
||||
if y >= workspace_area.bottom() {
|
||||
break;
|
||||
}
|
||||
let rect = Rect::new(workspace_area.x, y, workspace_area.width, 1);
|
||||
let focused = active && workspace.focused;
|
||||
if focused {
|
||||
let selected = state.selected_workspace_id.is_some_and(|target| {
|
||||
target.matches(&endpoint.endpoint_id, &workspace.workspace_id)
|
||||
});
|
||||
let selection_background = if palette.selection_bg == ratatui::style::Color::Reset {
|
||||
palette.active_row_bg
|
||||
} else {
|
||||
palette.selection_bg
|
||||
};
|
||||
if selected {
|
||||
buffer.set_style(rect, Style::default().bg(selection_background));
|
||||
} else if focused {
|
||||
buffer.set_style(rect, Style::default().bg(palette.active_row_bg));
|
||||
}
|
||||
let stale = endpoint.status != ClientEndpointStatus::Online;
|
||||
@@ -261,6 +315,32 @@ pub(super) fn render_expanded(
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let gaps = vec![0; rows.len()];
|
||||
if std::mem::take(state.reveal_navigation_workspace) {
|
||||
let selected_row = rows.iter().position(|row| match row {
|
||||
Row::Workspace { endpoint, entry } => {
|
||||
let endpoint = &state.endpoints[*endpoint];
|
||||
endpoint
|
||||
.snapshot
|
||||
.as_deref()
|
||||
.and_then(|snapshot| snapshot.workspaces.get(entry.index))
|
||||
.is_some_and(|workspace| {
|
||||
state.selected_workspace_id.is_some_and(|target| {
|
||||
target.matches(&endpoint.endpoint_id, &workspace.workspace_id)
|
||||
})
|
||||
})
|
||||
}
|
||||
Row::Endpoint(_) => false,
|
||||
});
|
||||
if let Some(selected_row) = selected_row {
|
||||
*state.workspace_scroll = super::scroll::list_scroll_start_to_reveal(
|
||||
&row_heights,
|
||||
&gaps,
|
||||
body.height,
|
||||
*state.workspace_scroll,
|
||||
selected_row,
|
||||
);
|
||||
}
|
||||
}
|
||||
let metrics = super::scroll::list_scroll_metrics(
|
||||
&row_heights,
|
||||
&gaps,
|
||||
@@ -338,6 +418,9 @@ pub(super) fn render_expanded(
|
||||
rect.height,
|
||||
);
|
||||
let endpoint_active = &endpoint.endpoint_id == state.active_endpoint_id;
|
||||
let selected = state.selected_workspace_id.is_some_and(|target| {
|
||||
target.matches(&endpoint.endpoint_id, &workspace.workspace_id)
|
||||
});
|
||||
super::sidebar::render_workspace_rows(
|
||||
buffer,
|
||||
nested,
|
||||
@@ -347,10 +430,13 @@ pub(super) fn render_expanded(
|
||||
entry,
|
||||
tokens,
|
||||
endpoint_active,
|
||||
false,
|
||||
selected,
|
||||
false,
|
||||
palette,
|
||||
);
|
||||
if selected && palette.selection_bg == ratatui::style::Color::Reset {
|
||||
buffer.set_style(nested, Style::default().bg(palette.active_row_bg));
|
||||
}
|
||||
if endpoint.status != ClientEndpointStatus::Online {
|
||||
buffer.set_style(
|
||||
rect,
|
||||
|
||||
+25
-52
@@ -128,7 +128,9 @@ impl ClientShellState {
|
||||
}
|
||||
|
||||
fn prepare_committed_text(&mut self, text: &str, outcome: &mut ClientShellInput) -> bool {
|
||||
if self.insert_copy_search_text(text) {
|
||||
if !(self.mode == ClientShellMode::Navigate && self.workspace_preview_action_blocked())
|
||||
&& self.insert_copy_search_text(text)
|
||||
{
|
||||
outcome.repaint = true;
|
||||
return true;
|
||||
}
|
||||
@@ -425,7 +427,12 @@ impl ClientShellState {
|
||||
}
|
||||
|
||||
pub(super) fn modal_paste_target_active(&self) -> bool {
|
||||
if self.popup_pending || self.popup_input_target().is_some() {
|
||||
if self.popup_pending
|
||||
|| self.popup_input_target().is_some()
|
||||
|| (self.overlay.is_none()
|
||||
&& self.mode == ClientShellMode::Navigate
|
||||
&& self.workspace_preview_action_blocked())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if self
|
||||
@@ -655,6 +662,22 @@ impl ClientShellState {
|
||||
return;
|
||||
}
|
||||
|
||||
let (code, modifiers) = crate::config::normalize_key_combo((key.code, key.modifiers));
|
||||
if code == KeyCode::Enter && modifiers.is_empty() {
|
||||
self.accept_navigate_workspace(outcome);
|
||||
return;
|
||||
}
|
||||
if self.workspace_preview_action_blocked() {
|
||||
self.push_endpoint_notice(
|
||||
ClientEndpointNoticeKind::Rejected,
|
||||
"navigate_endpoint_inactive",
|
||||
"Confirm workspace first",
|
||||
"Select an available workspace and press Enter before using workspace or pane actions",
|
||||
);
|
||||
outcome.repaint = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(index) = ('1'..='9').position(|digit| {
|
||||
crate::config::terminal_key_matches_combo(
|
||||
key,
|
||||
@@ -678,24 +701,8 @@ impl ClientShellState {
|
||||
return;
|
||||
}
|
||||
|
||||
let (code, modifiers) = crate::config::normalize_key_combo((key.code, key.modifiers));
|
||||
if modifiers.is_empty() {
|
||||
match code {
|
||||
KeyCode::Enter => {
|
||||
let selected = self.navigate_workspace_id.clone();
|
||||
self.mode = ClientShellMode::Terminal;
|
||||
self.navigate_workspace_id = None;
|
||||
if let Some(workspace_id) = selected {
|
||||
self.push_endpoint_method(
|
||||
crate::api::schema::Method::WorkspaceFocus(
|
||||
crate::api::schema::WorkspaceTarget { workspace_id },
|
||||
),
|
||||
outcome,
|
||||
);
|
||||
}
|
||||
outcome.repaint = true;
|
||||
return;
|
||||
}
|
||||
KeyCode::Tab => {
|
||||
self.record_navigate_binding(
|
||||
KeybindMatch::Action(KeybindAction::CyclePaneNext),
|
||||
@@ -804,7 +811,6 @@ impl ClientShellState {
|
||||
if !self.indexed_navigation_target_exists(&binding) {
|
||||
return;
|
||||
}
|
||||
|
||||
if let KeybindMatch::Action(KeybindAction::CyclePaneNext) = binding {
|
||||
self.cycle_pane(false, outcome);
|
||||
} else if let KeybindMatch::Action(KeybindAction::CyclePanePrevious) = binding {
|
||||
@@ -862,39 +868,6 @@ impl ClientShellState {
|
||||
}
|
||||
}
|
||||
|
||||
fn move_navigate_workspace(&mut self, delta: isize) {
|
||||
let Some(snapshot) = self.snapshot.as_deref() else {
|
||||
return;
|
||||
};
|
||||
let mobile = self.mobile_layout_active();
|
||||
let entries = self.navigation_workspace_entries(snapshot);
|
||||
if entries.is_empty() {
|
||||
return;
|
||||
}
|
||||
let current = self
|
||||
.navigate_workspace_id
|
||||
.as_deref()
|
||||
.and_then(|selected| {
|
||||
entries
|
||||
.iter()
|
||||
.position(|entry| snapshot.workspaces[entry.index].workspace_id == selected)
|
||||
})
|
||||
.unwrap_or(0);
|
||||
let next = if mobile {
|
||||
(current as isize + delta).clamp(0, entries.len().saturating_sub(1) as isize) as usize
|
||||
} else {
|
||||
(current as isize + delta).rem_euclid(entries.len() as isize) as usize
|
||||
};
|
||||
let workspace_id = snapshot.workspaces[entries[next].index]
|
||||
.workspace_id
|
||||
.clone();
|
||||
self.navigate_workspace_id = Some(workspace_id.clone());
|
||||
self.reveal_mobile_workspace = mobile;
|
||||
if !mobile {
|
||||
self.reveal_workspace(&workspace_id);
|
||||
}
|
||||
}
|
||||
|
||||
fn cycle_pane(&mut self, reverse: bool, outcome: &mut ClientShellInput) {
|
||||
let Some(snapshot) = self.snapshot.as_deref() else {
|
||||
return;
|
||||
|
||||
+12
-10
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -294,7 +294,9 @@ pub(crate) fn render_sidebar(
|
||||
break;
|
||||
}
|
||||
let rect = Rect::new(body.x, y, content_width, row_height);
|
||||
let selected = state.selected_workspace_id == Some(workspace.workspace_id.as_str());
|
||||
let selected = state.selected_workspace_id.is_some_and(|target| {
|
||||
target.matches(state.active_endpoint_id, &workspace.workspace_id)
|
||||
});
|
||||
let dragged = state.dragged_workspace_id == Some(workspace.workspace_id.as_str());
|
||||
if selected {
|
||||
buffer.set_style(rect, Style::default().bg(palette.selection_bg));
|
||||
|
||||
+13
-10
@@ -927,7 +927,8 @@ pub(crate) struct ClientShellState {
|
||||
pub(super) active_endpoint_id: ClientEndpointId,
|
||||
pub(super) collapsed_endpoints: HashSet<ClientEndpointId>,
|
||||
pub(super) mode: ClientShellMode,
|
||||
pub(super) navigate_workspace_id: Option<String>,
|
||||
pub(super) navigate_workspace_id: Option<WorkspaceNavigationTarget>,
|
||||
pub(super) reveal_navigation_workspace: bool,
|
||||
pub(super) overlay: Option<ClientShellOverlay>,
|
||||
pub(super) previous_pane_id: Option<String>,
|
||||
pub(super) pane_mouse_gesture: Option<ClientPaneMouseGesture>,
|
||||
@@ -1082,6 +1083,7 @@ impl ClientShellState {
|
||||
collapsed_endpoints: HashSet::new(),
|
||||
mode: ClientShellMode::Terminal,
|
||||
navigate_workspace_id: None,
|
||||
reveal_navigation_workspace: false,
|
||||
overlay,
|
||||
previous_pane_id: None,
|
||||
pane_mouse_gesture: None,
|
||||
@@ -1361,7 +1363,12 @@ impl ClientShellState {
|
||||
self.hits = ShellHitMap::default();
|
||||
}
|
||||
if boot_changed {
|
||||
// A reboot must not turn Enter on a stale preview into focus on a reused ID.
|
||||
let preview = (self.mode == ClientShellMode::Navigate)
|
||||
.then(|| self.navigate_workspace_id.take())
|
||||
.flatten();
|
||||
self.reset_endpoint_projection();
|
||||
self.navigate_workspace_id = preview;
|
||||
} else if let Some(previous) = self
|
||||
.snapshot
|
||||
.as_deref()
|
||||
@@ -1477,15 +1484,11 @@ impl ClientShellState {
|
||||
}
|
||||
}
|
||||
}
|
||||
if self.mode == ClientShellMode::Navigate
|
||||
&& self.navigate_workspace_id.as_ref().is_none_or(|selected| {
|
||||
!snapshot
|
||||
.workspaces
|
||||
.iter()
|
||||
.any(|workspace| &workspace.workspace_id == selected)
|
||||
})
|
||||
{
|
||||
self.navigate_workspace_id = snapshot.focused_workspace_id.clone();
|
||||
if self.mode == ClientShellMode::Navigate && self.navigate_workspace_id.is_none() {
|
||||
self.navigate_workspace_id = snapshot
|
||||
.focused_workspace_id
|
||||
.as_deref()
|
||||
.and_then(|id| self.navigation_target(&self.active_endpoint_id, id));
|
||||
self.reveal_mobile_workspace = self.mobile_layout_active();
|
||||
}
|
||||
let pane_exists =
|
||||
|
||||
@@ -747,7 +747,7 @@ fn workspace_actions_preserve_selected_target_and_client_confirmation() {
|
||||
let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
|
||||
state.set_snapshot(Box::new(snapshot));
|
||||
state.mode = ClientShellMode::Navigate;
|
||||
state.navigate_workspace_id = Some("ws_2".into());
|
||||
state.navigate_workspace_id = state.navigation_target(&ClientEndpointId::Local, "ws_2");
|
||||
|
||||
let rename = state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new(
|
||||
KeyCode::Char('w'),
|
||||
@@ -773,7 +773,7 @@ fn workspace_actions_preserve_selected_target_and_client_confirmation() {
|
||||
if params.workspace_id == "ws_2" && params.label == "renamed"
|
||||
));
|
||||
|
||||
state.navigate_workspace_id = Some("ws_2".into());
|
||||
state.navigate_workspace_id = state.navigation_target(&ClientEndpointId::Local, "ws_2");
|
||||
let mut close = ClientShellInput::default();
|
||||
state.record_binding(
|
||||
crate::input::KeybindMatch::Action(crate::input::KeybindAction::CloseWorkspace),
|
||||
@@ -813,13 +813,18 @@ fn desktop_workspace_navigation_reveals_overflowing_selection() {
|
||||
state.set_snapshot(Box::new(projected));
|
||||
state.set_pane_surface(surface());
|
||||
state.mode = ClientShellMode::Navigate;
|
||||
state.navigate_workspace_id = Some("ws_1".into());
|
||||
state.navigate_workspace_id = state.navigation_target(&ClientEndpointId::Local, "ws_1");
|
||||
state.compose(106, 12).expect("overflowing sidebar");
|
||||
|
||||
for _ in 0..6 {
|
||||
state.handle_input_bytes(b"\x1b[B");
|
||||
state.compose(106, 12).expect("revealed workspace");
|
||||
let selected = state.navigate_workspace_id.as_deref().expect("selection");
|
||||
let selected = state
|
||||
.navigate_workspace_id
|
||||
.as_ref()
|
||||
.expect("selection")
|
||||
.workspace_id
|
||||
.as_str();
|
||||
assert!(
|
||||
state
|
||||
.hits
|
||||
@@ -883,16 +888,25 @@ fn navigate_mode_selects_workspace_locally_then_focuses_by_stable_id() {
|
||||
let enter_navigate = state.handle_input_bytes(b"w");
|
||||
assert!(enter_navigate.repaint);
|
||||
assert_eq!(state.mode, ClientShellMode::Navigate);
|
||||
assert_eq!(state.navigate_workspace_id.as_deref(), Some("ws_1"));
|
||||
assert_eq!(
|
||||
state.navigate_workspace_id,
|
||||
state.navigation_target(&ClientEndpointId::Local, "ws_1")
|
||||
);
|
||||
|
||||
let invalid = state.handle_input_bytes(b"9");
|
||||
assert!(invalid.actions.is_empty());
|
||||
assert_eq!(state.mode, ClientShellMode::Navigate);
|
||||
assert_eq!(state.navigate_workspace_id.as_deref(), Some("ws_1"));
|
||||
assert_eq!(
|
||||
state.navigate_workspace_id,
|
||||
state.navigation_target(&ClientEndpointId::Local, "ws_1")
|
||||
);
|
||||
|
||||
let move_selection = state.handle_input_bytes(b"\x1b[B");
|
||||
assert!(move_selection.actions.is_empty());
|
||||
assert_eq!(state.navigate_workspace_id.as_deref(), Some("ws_2"));
|
||||
assert_eq!(
|
||||
state.navigate_workspace_id,
|
||||
state.navigation_target(&ClientEndpointId::Local, "ws_2")
|
||||
);
|
||||
let frame = state.compose(106, 20).expect("navigate frame");
|
||||
let text = frame
|
||||
.cells
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
use super::*;
|
||||
|
||||
#[path = "workspace_navigation.rs"]
|
||||
mod workspace_navigation;
|
||||
use crate::client::endpoint::{
|
||||
ClientEndpointId, ClientEndpointStatus, ProfileId, SavedSshEndpoint,
|
||||
};
|
||||
|
||||
@@ -363,7 +363,7 @@ fn mobile_background_workspace_uses_its_own_active_tab_status() {
|
||||
state.set_snapshot(Box::new(projected));
|
||||
state.set_pane_surface(surface());
|
||||
state.mode = ClientShellMode::Navigate;
|
||||
state.navigate_workspace_id = Some("ws_2".into());
|
||||
state.navigate_workspace_id = state.navigation_target(&ClientEndpointId::Local, "ws_2");
|
||||
let frame = state.compose(44, 20).expect("mobile switcher");
|
||||
let text = frame
|
||||
.cells
|
||||
@@ -615,7 +615,10 @@ fn mobile_switcher_scroll_close_and_width_transition_clear_mobile_hits() {
|
||||
))]);
|
||||
}
|
||||
state.compose(44, 10).expect("revealed mobile selection");
|
||||
assert_eq!(state.navigate_workspace_id.as_deref(), Some("ws_8"));
|
||||
assert_eq!(
|
||||
state.navigate_workspace_id,
|
||||
state.navigation_target(&ClientEndpointId::Local, "ws_8")
|
||||
);
|
||||
assert!(state.mobile_switcher_scroll > 2);
|
||||
assert!(state.hits.mobile_targets.iter().any(|(_, target)| {
|
||||
matches!(
|
||||
|
||||
@@ -0,0 +1,503 @@
|
||||
use super::*;
|
||||
|
||||
fn workspaces(count: usize) -> ClientShellSnapshot {
|
||||
let mut projected = snapshot();
|
||||
projected.workspaces = (1..=count)
|
||||
.map(|number| {
|
||||
let mut workspace = projected.workspaces[0].clone();
|
||||
workspace.workspace_id = format!("ws_{number}");
|
||||
workspace.number = number;
|
||||
workspace.focused = number == 1;
|
||||
workspace
|
||||
})
|
||||
.collect();
|
||||
projected
|
||||
}
|
||||
|
||||
fn grouped_workspaces() -> ClientShellSnapshot {
|
||||
let mut projected = workspaces(3);
|
||||
for (index, linked) in [(0, false), (2, true)] {
|
||||
projected.workspaces[index].worktree = Some(ClientShellWorktree {
|
||||
key: "repo".into(),
|
||||
label: "repo".into(),
|
||||
is_linked_worktree: linked,
|
||||
});
|
||||
}
|
||||
projected
|
||||
}
|
||||
|
||||
fn navigation_state(mut projected: ClientShellSnapshot) -> (ClientShellState, ClientEndpointId) {
|
||||
let (mut state, remote) = state_with_remote();
|
||||
state.set_snapshot(Box::new(projected.clone()));
|
||||
projected.boot_id = "remote-boot".into();
|
||||
state.set_endpoint_snapshot(&remote, Box::new(projected));
|
||||
(state, remote)
|
||||
}
|
||||
|
||||
fn preview_key(state: &mut ClientShellState, bytes: &[u8]) {
|
||||
let outcome = state.handle_input_bytes(bytes);
|
||||
assert!(outcome.actions.is_empty(), "{bytes:?}");
|
||||
assert!(outcome.requests.is_empty(), "{bytes:?}");
|
||||
assert!(outcome.repaint, "{bytes:?}");
|
||||
}
|
||||
|
||||
fn enter_navigation(state: &mut ClientShellState) {
|
||||
preview_key(state, &[0x02]);
|
||||
preview_key(state, b"w");
|
||||
assert_eq!(state.mode, ClientShellMode::Navigate);
|
||||
}
|
||||
|
||||
fn assert_selected(state: &ClientShellState, endpoint: &ClientEndpointId, workspace: &str) {
|
||||
assert_eq!(
|
||||
state.navigate_workspace_id,
|
||||
state.navigation_target(endpoint, workspace)
|
||||
);
|
||||
}
|
||||
|
||||
fn workspace_rect(state: &ClientShellState, endpoint: &ClientEndpointId, workspace: &str) -> Rect {
|
||||
state
|
||||
.hits
|
||||
.workspaces
|
||||
.iter()
|
||||
.find(|hit| &hit.endpoint_id == endpoint && hit.workspace_id == workspace)
|
||||
.map(|hit| hit.rect)
|
||||
.or_else(|| {
|
||||
state.hits.mobile_targets.iter().find_map(|(rect, target)| {
|
||||
matches!(target, ClientMobileTarget::Workspace { endpoint_id, workspace_id }
|
||||
if endpoint_id == endpoint && workspace_id == workspace)
|
||||
.then_some(*rect)
|
||||
})
|
||||
})
|
||||
.expect("visible workspace")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn navigation_highlights_only_the_preview_and_activates_on_enter() {
|
||||
for (compact, cols) in [(true, 100), (false, 100), (false, 44)] {
|
||||
for terminal_theme in [false, true] {
|
||||
let (mut state, remote) = navigation_state(workspaces(2));
|
||||
state.sidebar_collapsed = compact;
|
||||
if terminal_theme {
|
||||
state.config.palette = Palette::terminal();
|
||||
}
|
||||
state.compose(cols, 28).unwrap();
|
||||
enter_navigation(&mut state);
|
||||
for (endpoint, collision, steps) in [
|
||||
(&ClientEndpointId::Local, &remote, 1),
|
||||
(&remote, &ClientEndpointId::Local, 2),
|
||||
] {
|
||||
for _ in 0..steps {
|
||||
preview_key(&mut state, b"\x1b[B");
|
||||
}
|
||||
assert_selected(&state, endpoint, "ws_2");
|
||||
let buffer = state
|
||||
.compose(cols, 28)
|
||||
.unwrap()
|
||||
.to_ratatui_buffer()
|
||||
.unwrap();
|
||||
let selected = workspace_rect(&state, endpoint, "ws_2");
|
||||
let other = workspace_rect(&state, collision, "ws_2");
|
||||
let focused = workspace_rect(&state, &ClientEndpointId::Local, "ws_1");
|
||||
let palette = &state.config.palette;
|
||||
let color = if cols == 44 {
|
||||
palette.surface0
|
||||
} else {
|
||||
palette.selection_bg
|
||||
};
|
||||
let color = if color == ratatui::style::Color::Reset {
|
||||
palette.active_row_bg
|
||||
} else {
|
||||
color
|
||||
};
|
||||
assert_eq!(buffer[(selected.x + 2, selected.y)].bg, color);
|
||||
assert_ne!(buffer[(other.x + 2, other.y)].bg, color);
|
||||
assert_eq!(
|
||||
buffer[(focused.x + 2, focused.y)].bg,
|
||||
if cols == 44 {
|
||||
palette.surface_dim
|
||||
} else {
|
||||
palette.active_row_bg
|
||||
}
|
||||
);
|
||||
}
|
||||
assert_eq!(state.snapshot.as_ref().unwrap().boot_id, "boot-1");
|
||||
assert_eq!(
|
||||
state
|
||||
.snapshot
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.focused_workspace_id
|
||||
.as_deref(),
|
||||
Some("ws_1")
|
||||
);
|
||||
assert_eq!(state.pane_surface.as_ref().unwrap().boot_id, "boot-1");
|
||||
let enter = state.handle_input_bytes(b"\r");
|
||||
assert!(enter.requests.is_empty());
|
||||
assert!(
|
||||
matches!(enter.actions.as_slice(), [ClientShellAction::ActivateEndpoint {
|
||||
endpoint_id, target: Some(ClientEndpointFocusTarget::Workspace(id)),
|
||||
}] if endpoint_id == &remote && id == "ws_2")
|
||||
);
|
||||
assert_eq!(state.active_endpoint_id, ClientEndpointId::Local);
|
||||
assert_eq!(state.mode, ClientShellMode::Terminal);
|
||||
assert!(state.navigate_workspace_id.is_none());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn foreign_preview_blocks_keyboard_actions_but_keeps_active_action_context() {
|
||||
let (mut state, remote) = state_with_remote();
|
||||
state.compose(100, 28).unwrap();
|
||||
enter_navigation(&mut state);
|
||||
preview_key(&mut state, b"\x1b[B");
|
||||
for confirm in [false, true] {
|
||||
state.config.confirm_close = confirm;
|
||||
for key in [
|
||||
b"W".as_slice(),
|
||||
b"D",
|
||||
b"\x1b[D",
|
||||
b"\x1b[C",
|
||||
b"\t",
|
||||
b"1",
|
||||
b"c",
|
||||
b"N",
|
||||
] {
|
||||
preview_key(&mut state, key);
|
||||
assert!(state.overlay.is_none());
|
||||
assert_eq!(state.mode, ClientShellMode::Navigate);
|
||||
}
|
||||
}
|
||||
assert_selected(&state, &remote, "ws_1");
|
||||
let mut remote_snapshot = workspaces(2);
|
||||
remote_snapshot.boot_id = "remote-boot".into();
|
||||
state.set_endpoint_snapshot(&remote, Box::new(remote_snapshot));
|
||||
preview_key(&mut state, b"\x1b[B");
|
||||
assert_selected(&state, &remote, "ws_2");
|
||||
assert_eq!(state.workspace_action_id().as_deref(), Some("ws_1"));
|
||||
state.config.prompt_new_workspace_name = false;
|
||||
let mut create = ClientShellInput::default();
|
||||
state.record_binding(
|
||||
crate::input::KeybindMatch::Action(crate::input::KeybindAction::NewWorkspace),
|
||||
&mut create,
|
||||
);
|
||||
assert!(
|
||||
matches!(create.actions.as_slice(), [ClientShellAction::Endpoint { endpoint_id: ClientEndpointId::Local, request, .. }]
|
||||
if matches!(&request.method, crate::api::schema::Method::WorkspaceCreate(params) if params.source_workspace_id.as_deref() == Some("ws_1")))
|
||||
);
|
||||
preview_key(&mut state, b"\x1b");
|
||||
assert!(state.navigate_workspace_id.is_none());
|
||||
assert_eq!(state.active_endpoint_id, ClientEndpointId::Local);
|
||||
assert!(state.activate_endpoint_projection(&remote));
|
||||
enter_navigation(&mut state);
|
||||
preview_key(&mut state, b"W");
|
||||
assert!(matches!(state.overlay, Some(ClientShellOverlay::Rename(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_workspace_navigation_enter_exits_without_focusing() {
|
||||
let (mut state, _) = state_with_remote();
|
||||
let mut empty = workspaces(0);
|
||||
empty.tabs.clear();
|
||||
empty.panes.clear();
|
||||
empty.focused_workspace_id = None;
|
||||
empty.focused_tab_id = None;
|
||||
empty.focused_pane_id = None;
|
||||
state.set_snapshot(Box::new(empty));
|
||||
enter_navigation(&mut state);
|
||||
assert!(state.navigate_workspace_id.is_none());
|
||||
let enter = state.handle_input_bytes(b"\r");
|
||||
assert!(enter.actions.is_empty() && enter.requests.is_empty() && enter.repaint);
|
||||
assert_eq!(state.mode, ClientShellMode::Terminal);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn foreign_workspace_preview_blocks_paste_into_hidden_copy_search() {
|
||||
let (mut state, _) = state_with_remote();
|
||||
let mut pane_surface = surface();
|
||||
pane_surface.panes[0].scroll = Some(crate::protocol::PaneSurfaceScrollMetrics {
|
||||
offset_from_bottom: 0,
|
||||
max_offset_from_bottom: 20,
|
||||
viewport_rows: 2,
|
||||
});
|
||||
state.set_pane_surface(pane_surface);
|
||||
state.compose(100, 28).unwrap();
|
||||
assert!(state.enter_copy_mode(&mut ClientShellInput::default()));
|
||||
state.copy_mode.as_mut().unwrap().search_prompt = Some(ClientCopySearchPrompt {
|
||||
direction: crate::api::schema::PaneCopySearchDirection::Forward,
|
||||
query: "original".into(),
|
||||
});
|
||||
enter_navigation(&mut state);
|
||||
preview_key(&mut state, b"\x1b[B");
|
||||
assert!(state.workspace_preview_action_blocked());
|
||||
assert!(!state.modal_paste_target_active());
|
||||
let key = crate::input::TerminalKey::new(KeyCode::Char('v'), KeyModifiers::CONTROL);
|
||||
assert!(!state.handle_modal_paste_shortcut_with(
|
||||
&key,
|
||||
&mut ClientShellInput::default(),
|
||||
|| { panic!("hidden search must not read the clipboard") }
|
||||
));
|
||||
let paste = state.handle_raw_events(vec![RawInputEvent::Paste("unexpected".into())]);
|
||||
assert!(paste.actions.is_empty() && paste.requests.is_empty());
|
||||
assert_eq!(
|
||||
state.copy_mode.unwrap().search_prompt.unwrap().query,
|
||||
"original"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mouse_clicks_cancel_remote_workspace_navigation() {
|
||||
for pane in [false, true] {
|
||||
let (mut state, _) = state_with_remote();
|
||||
state.compose(100, 28).unwrap();
|
||||
enter_navigation(&mut state);
|
||||
preview_key(&mut state, b"\x1b[B");
|
||||
state.compose(100, 28).unwrap();
|
||||
let rect = if pane {
|
||||
state.hits.panes[0].inner_rect
|
||||
} else {
|
||||
workspace_rect(&state, &ClientEndpointId::Local, "ws_1")
|
||||
};
|
||||
for kind in [
|
||||
MouseEventKind::Down(MouseButton::Left),
|
||||
MouseEventKind::Up(MouseButton::Left),
|
||||
] {
|
||||
state.handle_raw_events(vec![RawInputEvent::Mouse(MouseEvent {
|
||||
kind,
|
||||
column: rect.x + 2,
|
||||
row: rect.y,
|
||||
modifiers: KeyModifiers::empty(),
|
||||
})]);
|
||||
}
|
||||
assert_eq!(state.mode, ClientShellMode::Terminal);
|
||||
assert!(state.navigate_workspace_id.is_none());
|
||||
enter_navigation(&mut state);
|
||||
assert_selected(&state, &ClientEndpointId::Local, "ws_1");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_machine_compact_navigation_includes_visible_collapsed_group_children() {
|
||||
let (mut state, _) = navigation_state(grouped_workspaces());
|
||||
state.set_endpoint_catalog(&[]);
|
||||
state.toggle_collapsed_group(&ClientEndpointId::Local, "repo".into());
|
||||
state.sidebar_collapsed = true;
|
||||
state.compose(100, 28).unwrap();
|
||||
workspace_rect(&state, &ClientEndpointId::Local, "ws_3");
|
||||
enter_navigation(&mut state);
|
||||
for id in ["ws_2", "ws_3"] {
|
||||
preview_key(&mut state, b"\x1b[B");
|
||||
assert_selected(&state, &ClientEndpointId::Local, id);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_navigation_respects_each_machines_visible_worktree_groups() {
|
||||
for (cols, compact, unavailable, show_child) in [
|
||||
(100, false, false, false),
|
||||
(100, true, false, true),
|
||||
(44, false, false, true),
|
||||
(44, true, true, false),
|
||||
] {
|
||||
let (mut state, remote) = navigation_state(grouped_workspaces());
|
||||
state.toggle_collapsed_group(&remote, "repo".into());
|
||||
state.sidebar_collapsed = compact;
|
||||
if unavailable {
|
||||
state.pane_surface = None;
|
||||
}
|
||||
state.compose(cols, 28).unwrap();
|
||||
enter_navigation(&mut state);
|
||||
let local = if compact && !unavailable {
|
||||
["ws_2", "ws_3"]
|
||||
} else {
|
||||
["ws_3", "ws_2"]
|
||||
};
|
||||
let remote_ids: &[&str] = if !show_child {
|
||||
&["ws_1", "ws_2"]
|
||||
} else if compact {
|
||||
&["ws_1", "ws_2", "ws_3"]
|
||||
} else {
|
||||
&["ws_1", "ws_3", "ws_2"]
|
||||
};
|
||||
for (endpoint, ids) in [
|
||||
(&ClientEndpointId::Local, local.as_slice()),
|
||||
(&remote, remote_ids),
|
||||
] {
|
||||
for id in ids {
|
||||
preview_key(&mut state, b"\x1b[B");
|
||||
assert_selected(&state, endpoint, id);
|
||||
state.compose(cols, 28).unwrap();
|
||||
workspace_rect(&state, endpoint, id);
|
||||
}
|
||||
}
|
||||
assert!(state.group_is_collapsed(&remote, "repo"));
|
||||
assert!(!state.group_is_collapsed(&ClientEndpointId::Local, "repo"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn foreign_preview_survives_local_updates_and_rejects_stale_enter() {
|
||||
for invalidation in [
|
||||
"offline",
|
||||
"disabled",
|
||||
"removed",
|
||||
"deleted",
|
||||
"boot",
|
||||
"generation",
|
||||
] {
|
||||
let (mut state, remote_id) = state_with_remote();
|
||||
let mut remote = workspaces(2);
|
||||
remote.boot_id = "remote-boot".into();
|
||||
state.set_endpoint_snapshot_for_generation(&remote_id, 7, Box::new(remote.clone()));
|
||||
state.compose(100, 28).unwrap();
|
||||
enter_navigation(&mut state);
|
||||
for _ in 0..2 {
|
||||
preview_key(&mut state, b"\x1b[B");
|
||||
}
|
||||
assert_selected(&state, &remote_id, "ws_2");
|
||||
let selected = state.navigate_workspace_id.clone();
|
||||
remote.revision += 1;
|
||||
state.set_endpoint_snapshot_for_generation(&remote_id, 7, Box::new(remote.clone()));
|
||||
assert_eq!(state.navigate_workspace_id, selected);
|
||||
assert!(state.navigation_target_valid(selected.as_ref().unwrap()));
|
||||
let mut local = snapshot();
|
||||
local.revision += 1;
|
||||
state.set_snapshot(Box::new(local));
|
||||
assert_eq!(state.navigate_workspace_id, selected);
|
||||
match invalidation {
|
||||
"offline" => state.set_endpoint_status(&remote_id, ClientEndpointStatus::Reconnecting),
|
||||
"disabled" => {
|
||||
let mut profile = remote_profile();
|
||||
profile.enabled = false;
|
||||
state.set_endpoint_catalog(&[profile]);
|
||||
}
|
||||
"removed" => state.set_endpoint_catalog(&[]),
|
||||
"deleted" => {
|
||||
remote.revision += 1;
|
||||
remote.workspaces.pop();
|
||||
state.set_endpoint_snapshot_for_generation(&remote_id, 7, Box::new(remote));
|
||||
}
|
||||
"boot" => {
|
||||
remote.boot_id = "restarted-remote".into();
|
||||
state.set_endpoint_snapshot_for_generation(&remote_id, 7, Box::new(remote));
|
||||
}
|
||||
"generation" => {
|
||||
state.cache_endpoint_snapshot_for_generation(&remote_id, 8, Box::new(remote))
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
preview_key(&mut state, b"\r");
|
||||
assert_eq!(state.active_endpoint_id, ClientEndpointId::Local);
|
||||
assert_eq!(state.mode, ClientShellMode::Navigate);
|
||||
assert!(state.visible_endpoint_notice.is_some());
|
||||
assert!(!state.navigation_target_valid(state.navigate_workspace_id.as_ref().unwrap()));
|
||||
preview_key(&mut state, b"\x1b[B");
|
||||
assert!(state.navigation_target_valid(state.navigate_workspace_id.as_ref().unwrap()));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn navigation_uses_displayed_group_order_when_local_is_unavailable() {
|
||||
for cols in [100, 44] {
|
||||
let (mut state, remote) = navigation_state(grouped_workspaces());
|
||||
state.set_endpoint_status(&ClientEndpointId::Local, ClientEndpointStatus::Reconnecting);
|
||||
state.select_unavailable_local();
|
||||
state.sidebar_collapsed = true;
|
||||
state.compose(cols, 18).unwrap();
|
||||
enter_navigation(&mut state);
|
||||
for id in ["ws_1", "ws_3", "ws_2"] {
|
||||
preview_key(&mut state, b"\x1b[B");
|
||||
assert_selected(&state, &remote, id);
|
||||
state.compose(cols, 18).unwrap();
|
||||
workspace_rect(&state, &remote, id);
|
||||
}
|
||||
let enter = state.handle_input_bytes(b"\r");
|
||||
assert!(
|
||||
matches!(enter.actions.as_slice(), [ClientShellAction::ActivateEndpoint {
|
||||
endpoint_id, target: Some(ClientEndpointFocusTarget::Workspace(id)),
|
||||
}] if endpoint_id == &remote && id == "ws_2")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn active_preview_is_not_retargeted_by_deletion_or_reboot() {
|
||||
for invalidation in ["deleted", "boot", "generation"] {
|
||||
let (mut state, _) = state_with_remote();
|
||||
let mut local = workspaces(2);
|
||||
state.set_endpoint_snapshot_for_generation(
|
||||
&ClientEndpointId::Local,
|
||||
7,
|
||||
Box::new(local.clone()),
|
||||
);
|
||||
state.compose(100, 28).unwrap();
|
||||
enter_navigation(&mut state);
|
||||
preview_key(&mut state, b"\x1b[B");
|
||||
assert_selected(&state, &ClientEndpointId::Local, "ws_2");
|
||||
let selected = state.navigate_workspace_id.clone();
|
||||
match invalidation {
|
||||
"boot" => local.boot_id = "new-local-boot".into(),
|
||||
"deleted" => {
|
||||
local.revision += 1;
|
||||
local.workspaces.pop();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let generation = if invalidation == "generation" { 8 } else { 7 };
|
||||
state.set_endpoint_snapshot_for_generation(
|
||||
&ClientEndpointId::Local,
|
||||
generation,
|
||||
Box::new(local),
|
||||
);
|
||||
assert_eq!(state.navigate_workspace_id, selected);
|
||||
preview_key(&mut state, b"\r");
|
||||
assert_eq!(state.mode, ClientShellMode::Navigate);
|
||||
assert!(state.visible_endpoint_notice.is_some());
|
||||
for confirm in [false, true] {
|
||||
state.config.confirm_close = confirm;
|
||||
for key in [b"W", b"D"] {
|
||||
preview_key(&mut state, key);
|
||||
}
|
||||
assert!(state.overlay.is_none());
|
||||
assert_eq!(state.mode, ClientShellMode::Navigate);
|
||||
}
|
||||
assert_eq!(state.workspace_action_id().as_deref(), Some("ws_1"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aggregate_navigation_reveals_overflow_and_preserves_order() {
|
||||
for (compact, cols) in [(true, 100), (false, 100), (false, 44)] {
|
||||
let (mut state, remote_id) = state_with_remote();
|
||||
let mut remote = workspaces(15);
|
||||
remote.boot_id = "remote-boot".into();
|
||||
state.set_endpoint_snapshot(&remote_id, Box::new(remote));
|
||||
state.sidebar_collapsed = compact;
|
||||
state.collapsed_endpoints.insert(remote_id.clone());
|
||||
state.compose(cols, 18).unwrap();
|
||||
enter_navigation(&mut state);
|
||||
for number in 1..=15 {
|
||||
preview_key(&mut state, b"\x1b[B");
|
||||
let id = format!("ws_{number}");
|
||||
assert_selected(&state, &remote_id, &id);
|
||||
state.compose(cols, 18).unwrap();
|
||||
workspace_rect(&state, &remote_id, &id);
|
||||
}
|
||||
assert!(!state.collapsed_endpoints.contains(&remote_id));
|
||||
preview_key(&mut state, b"\x1b[B");
|
||||
if cols == 44 {
|
||||
assert_selected(&state, &remote_id, "ws_15");
|
||||
} else {
|
||||
assert_selected(&state, &ClientEndpointId::Local, "ws_1");
|
||||
}
|
||||
preview_key(&mut state, b"\x1b[A");
|
||||
assert_selected(
|
||||
&state,
|
||||
&remote_id,
|
||||
if cols == 44 { "ws_14" } else { "ws_15" },
|
||||
);
|
||||
state.set_endpoint_status(&remote_id, ClientEndpointStatus::Reconnecting);
|
||||
preview_key(&mut state, b"\x1b[B");
|
||||
assert_selected(&state, &ClientEndpointId::Local, "ws_1");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user