From e0507237ad8d41f60aece10d00ef29ce2339ca34 Mon Sep 17 00:00:00 2001 From: Can Celik Date: Sat, 19 Sep 2026 15:43:47 +0300 Subject: feat: show every agent and terminal in the go to picker (#4384) --- src/client/shell/aggregate_navigation.rs | 143 +++--- src/client/shell/composition.rs | 2 + src/client/shell/mouse.rs | 53 ++- src/client/shell/overlay_input.rs | 105 ++--- src/client/shell/overlays.rs | 253 +++++++---- src/client/shell/state.rs | 12 +- src/client/shell/tests/copy.rs | 552 +++++++++++++++++++++-- src/client/shell/tests/endpoints.rs | 57 ++- src/client/shell/tests/graphics.rs | 1 - src/client/shell/tests/popup_focus_projection.rs | 1 - 10 files changed, 913 insertions(+), 266 deletions(-) (limited to 'src/client') diff --git a/src/client/shell/aggregate_navigation.rs b/src/client/shell/aggregate_navigation.rs index c0195f93..9d3108cc 100644 --- a/src/client/shell/aggregate_navigation.rs +++ b/src/client/shell/aggregate_navigation.rs @@ -299,49 +299,92 @@ pub(super) fn navigator_rows( let endpoint_query_matches = !query.is_empty() && text(&endpoint.label); let mut endpoint_rows = Vec::new(); if let Some(snapshot) = endpoint.snapshot.as_deref() { + let agents = snapshot + .agents + .iter() + .map(|agent| (agent.pane_id.as_str(), agent)) + .collect::>(); for workspace in &snapshot.workspaces { - let workspace_meta = workspace.branch.clone().unwrap_or_default(); + let workspace_matches = endpoint_query_matches + || text(&workspace.label) + || workspace.branch.as_deref().is_some_and(text); let mut children = Vec::new(); - for tab in snapshot + let workspace_tabs = snapshot .tabs .iter() - .filter(|tab| tab.workspace_id == workspace.workspace_id) - { - let mut panes = Vec::new(); - for (index, pane) in snapshot + .filter(|tab| tab.workspace_id == workspace.workspace_id); + let multiple_tabs = workspace_tabs.clone().nth(1).is_some(); + for tab in workspace_tabs { + let tab_matches = workspace_matches || text(&tab.label); + let tab_panes = snapshot .panes .iter() .filter(|pane| pane.tab_id == tab.tab_id) - .enumerate() - { - let agent = snapshot - .agents - .iter() - .find(|agent| agent.pane_id == pane.pane_id); + .collect::>(); + for (index, pane) in tab_panes.iter().enumerate() { + let agent = agents.get(pane.pane_id.as_str()).copied(); let status = agent .map_or(crate::api::schema::AgentStatus::Unknown, |agent| { agent.agent_status }); - let label = pane + let agent_kind = agent.and_then(|agent| { + agent.agent.as_deref().or(agent.display_agent.as_deref()) + }); + let name = pane .label - .clone() - .or_else(|| agent.and_then(|agent| agent.name.clone())) - .or_else(|| agent.and_then(|agent| agent.display_agent.clone())) - .or_else(|| agent.and_then(|agent| agent.title.clone())) - .unwrap_or_else(|| format!("pane {}", index + 1)); + .as_deref() + .or_else(|| agent.and_then(|agent| agent.name.as_deref())); + let title = agent.and_then(|agent| { + agent + .title + .as_deref() + .or(agent.terminal_title_stripped.as_deref()) + }); + let tab_name = (tab.custom_label || tab.label.parse::().is_err()) + .then_some(tab.label.as_str()); + let label = if tab_panes.len() == 1 { + match name.or(tab_name).or(title) { + Some(label) => label.to_owned(), + None if multiple_tabs => { + format!("{} · {}", agent_kind.unwrap_or("terminal"), tab.label) + } + None => workspace.label.clone(), + } + } else { + let pane_name = name.or(title).or(agent_kind).unwrap_or("terminal"); + match tab_name { + Some(tab_name) if tab_name != pane_name => { + format!("{tab_name} · {pane_name} · {}", index + 1) + } + _ => format!("{pane_name} · {}", index + 1), + } + }; let meta = pane .foreground_cwd - .clone() - .or_else(|| pane.cwd.clone()) + .as_deref() + .or(pane.cwd.as_deref()) .unwrap_or_default(); - if !filtering - || filter(status) - && (endpoint_query_matches || text(&label) || text(&meta)) + if filter(status) + && (tab_matches + || text(&label) + || text(meta) + || pane.cwd.as_deref().is_some_and(text) + || agent_kind.is_some_and(text) + || title.is_some_and(text) + || agent + .and_then(|agent| agent.display_agent.as_deref()) + .is_some_and(text) + || text(&pane.pane_id)) { - panes.push(ClientNavigatorRow { - depth: 2 + depth_offset, + children.push(ClientNavigatorRow { + depth: 1 + depth_offset, label, - meta, + meta: meta.to_owned(), + detail: format!( + "{} / {} / {}", + workspace.label, tab.label, pane.pane_id + ), + agent: agent_kind.map(str::to_owned), status: Some(status), stale, current: endpoint.endpoint_id == *active_endpoint_id @@ -353,40 +396,17 @@ pub(super) fn navigator_rows( }); } } - if !filtering - || filter(tab.agent_status) && (endpoint_query_matches || text(&tab.label)) - || !panes.is_empty() - { - children.push(ClientNavigatorRow { - depth: 1 + depth_offset, - label: tab.label.clone(), - meta: format!( - "{} panes", - snapshot - .panes - .iter() - .filter(|pane| pane.tab_id == tab.tab_id) - .count() - ), - status: None, - stale, - current: false, - target: ClientNavigatorTarget::Tab { - endpoint_id: endpoint.endpoint_id.clone(), - tab_id: tab.tab_id.clone(), - }, - }); - children.extend(panes); - } } - let workspace_matches = filter(workspace.agent_status) - && (endpoint_query_matches || text(&workspace.label) || text(&workspace_meta)); - if !filtering || workspace_matches || !children.is_empty() { - let key = (endpoint.endpoint_id.clone(), workspace.workspace_id.clone()); + if !filtering + || !children.is_empty() + || (navigator.filter.is_none() && !query.is_empty() && workspace_matches) + { endpoint_rows.push(ClientNavigatorRow { depth: depth_offset, label: workspace.label.clone(), - meta: workspace_meta, + meta: workspace.branch.clone().unwrap_or_default(), + detail: workspace.new_workspace_cwd.clone(), + agent: None, status: None, stale, current: false, @@ -395,9 +415,7 @@ pub(super) fn navigator_rows( workspace_id: workspace.workspace_id.clone(), }, }); - if navigator.expanded_workspaces.contains(&key) || filtering { - endpoint_rows.extend(children); - } + endpoint_rows.extend(children); } } } @@ -407,6 +425,8 @@ pub(super) fn navigator_rows( depth: 0, label: endpoint.label.to_owned(), meta: String::new(), + detail: String::new(), + agent: None, status: None, stale, current: false, @@ -427,7 +447,10 @@ pub(super) fn navigator_selected_index( ) -> Option { match navigator.selected.as_ref() { Some(target) => rows.iter().position(|row| row.target == *target), - None => (!rows.is_empty()).then_some(0), + None => rows + .iter() + .position(|row| matches!(row.target, ClientNavigatorTarget::Pane { .. })) + .or_else(|| (!rows.is_empty()).then_some(0)), } } diff --git a/src/client/shell/composition.rs b/src/client/shell/composition.rs index e0888d0d..10dc6633 100644 --- a/src/client/shell/composition.rs +++ b/src/client/shell/composition.rs @@ -649,6 +649,8 @@ impl ClientShellState { self.hits.navigator_popup = rendered.navigator_popup; self.hits.navigator_search = rendered.navigator_search; self.hits.navigator_rows = rendered.navigator_rows; + self.hits.navigator_scrollbar = rendered.navigator_scrollbar; + self.hits.navigator_scroll_metrics = rendered.navigator_scroll_metrics; self.hits.worktree_search = rendered.worktree_search; self.hits.worktree_rows = rendered.worktree_rows; self.hits.help_popup = rendered.help_popup; diff --git a/src/client/shell/mouse.rs b/src/client/shell/mouse.rs index 0b9326d0..a7e0dd81 100644 --- a/src/client/shell/mouse.rs +++ b/src/client/shell/mouse.rs @@ -1032,6 +1032,22 @@ impl ClientShellState { } return; } + Some(ClientChromeDrag::NavigatorScrollbar { grab_row_offset }) => { + if let Some(metrics) = self.hits.navigator_scroll_metrics { + let offset = crate::ui::scrollbar_offset_from_drag_row( + metrics, + self.hits.navigator_scrollbar, + mouse.row, + *grab_row_offset, + ); + self.scroll_navigator_to( + metrics.max_offset_from_bottom.saturating_sub(offset), + metrics.viewport_rows, + ); + outcome.repaint = true; + } + return; + } Some(ClientChromeDrag::HelpScrollbar { grab_row_offset }) => { if let (Some(metrics), Some(ClientShellOverlay::Help(help))) = (self.hits.help_scroll_metrics, self.overlay.as_mut()) @@ -1317,6 +1333,7 @@ impl ClientShellState { ClientChromeDrag::WorkspaceScrollbar { .. } | ClientChromeDrag::AgentScrollbar { .. } | ClientChromeDrag::HelpScrollbar { .. } + | ClientChromeDrag::NavigatorScrollbar { .. } | ClientChromeDrag::ProductAnnouncementScrollbar { .. } | ClientChromeDrag::ReleaseNotesScrollbar { .. } => {} } @@ -1613,7 +1630,29 @@ impl ClientShellState { } } MouseEventKind::Down(MouseButton::Left) => { - if super::contains(self.hits.navigator_search, point) { + if super::contains(self.hits.navigator_scrollbar, point) { + if let Some(metrics) = self.hits.navigator_scroll_metrics { + if let Some(grab_row_offset) = crate::ui::scrollbar_thumb_grab_offset( + metrics, + self.hits.navigator_scrollbar, + mouse.row, + ) { + self.chrome_drag = + Some(ClientChromeDrag::NavigatorScrollbar { grab_row_offset }); + } else { + let offset = crate::ui::scrollbar_offset_from_row( + metrics, + self.hits.navigator_scrollbar, + mouse.row, + ); + self.scroll_navigator_to( + metrics.max_offset_from_bottom.saturating_sub(offset), + metrics.viewport_rows, + ); + outcome.repaint = true; + } + } + } else if super::contains(self.hits.navigator_search, point) { if let Some(ClientShellOverlay::Navigator(navigator)) = self.overlay.as_mut() { @@ -1621,19 +1660,13 @@ impl ClientShellState { navigator.filter = None; } outcome.repaint = true; - } else if let Some((rect, target)) = row_hit { + } else if let Some((_, target)) = row_hit { if let Some(ClientShellOverlay::Navigator(navigator)) = self.overlay.as_mut() { - navigator.selected = Some(target.clone()); - } - let workspace = matches!(target, ClientNavigatorTarget::Workspace { .. }); - if workspace && mouse.column <= rect.x.saturating_add(3) { - self.toggle_selected_navigator_workspace(); - outcome.repaint = true; - } else { - self.accept_navigator_selection(outcome); + navigator.selected = Some(target); } + self.accept_navigator_selection(outcome); } else if !super::contains(self.hits.navigator_popup, point) { self.overlay = None; outcome.repaint = true; diff --git a/src/client/shell/overlay_input.rs b/src/client/shell/overlay_input.rs index 7cf151cb..e4248304 100644 --- a/src/client/shell/overlay_input.rs +++ b/src/client/shell/overlay_input.rs @@ -191,21 +191,12 @@ impl ClientShellState { } pub(super) fn open_navigator_overlay(&mut self) { - let expanded_workspaces = - super::aggregate_navigation::cached_endpoint_snapshots(&self.endpoints) - .flat_map(|endpoint| { - endpoint.snapshot.workspaces.iter().map(move |workspace| { - (endpoint.endpoint_id.clone(), workspace.workspace_id.clone()) - }) - }) - .collect(); let mut navigator = ClientNavigatorOverlay { query: TextEditor::default(), search_focused: false, selected: None, scroll: 0, filter: None, - expanded_workspaces, }; let rows = render::client_navigator_rows(&self.endpoints, &self.active_endpoint_id, &navigator); @@ -233,6 +224,55 @@ impl ClientShellState { navigator.selected = Some(rows[next].target.clone()); } + pub(super) fn scroll_navigator_to(&mut self, scroll: usize, viewport_rows: usize) { + let Some(ClientShellOverlay::Navigator(navigator)) = self.overlay.as_mut() else { + return; + }; + let rows = + render::client_navigator_rows(&self.endpoints, &self.active_endpoint_id, navigator); + let viewport_rows = viewport_rows.max(1); + navigator.scroll = scroll.min(rows.len().saturating_sub(viewport_rows)); + let selected = + super::aggregate_navigation::navigator_selected_index(&rows, navigator).unwrap_or(0); + // Keep the selection in the dragged viewport so rendering does not snap back to it. + let selected = selected.clamp(navigator.scroll, navigator.scroll + viewport_rows - 1); + navigator.selected = rows.get(selected).map(|row| row.target.clone()); + } + + fn move_navigator_workspace(&mut self, forward: bool) { + let Some(ClientShellOverlay::Navigator(navigator)) = self.overlay.as_mut() else { + return; + }; + let rows = + render::client_navigator_rows(&self.endpoints, &self.active_endpoint_id, navigator); + let Some(selected) = + super::aggregate_navigation::navigator_selected_index(&rows, navigator) + else { + return; + }; + let section = rows[..=selected] + .iter() + .rposition(|row| !matches!(row.target, ClientNavigatorTarget::Pane { .. })) + .unwrap_or(selected); + let mut destinations = rows.windows(2).enumerate().filter(|(index, pair)| { + matches!(pair[0].target, ClientNavigatorTarget::Workspace { .. }) + && matches!(pair[1].target, ClientNavigatorTarget::Pane { .. }) + && if forward { + *index > section + } else { + *index < section + } + }); + let destination = if forward { + destinations.next() + } else { + destinations.next_back() + }; + if let Some((_, pair)) = destination { + navigator.selected = Some(pair[1].target.clone()); + } + } + pub(super) fn accept_navigator_selection(&mut self, outcome: &mut ClientShellInput) { let target = self.overlay.as_ref().and_then(|overlay| match overlay { ClientShellOverlay::Navigator(navigator) => { @@ -260,12 +300,6 @@ impl ClientShellState { ClientEndpointFocusTarget::Workspace(workspace_id), outcome, ), - ClientNavigatorTarget::Tab { - endpoint_id, - tab_id, - } => { - self.focus_or_activate(endpoint_id, ClientEndpointFocusTarget::Tab(tab_id), outcome) - } ClientNavigatorTarget::Pane { endpoint_id, pane_id, @@ -281,37 +315,6 @@ impl ClientShellState { outcome.repaint = true; } - pub(super) fn toggle_selected_navigator_workspace(&mut self) { - let workspace_key = self.overlay.as_ref().and_then(|overlay| match overlay { - ClientShellOverlay::Navigator(navigator) => { - let rows = render::client_navigator_rows( - &self.endpoints, - &self.active_endpoint_id, - navigator, - ); - super::aggregate_navigation::selected_navigator_target(&rows, navigator).and_then( - |target| match target { - ClientNavigatorTarget::Workspace { - endpoint_id, - workspace_id, - } => Some((endpoint_id, workspace_id)), - _ => None, - }, - ) - } - _ => None, - }); - if let (Some(workspace_key), Some(ClientShellOverlay::Navigator(navigator))) = - (workspace_key, self.overlay.as_mut()) - { - if !navigator.expanded_workspaces.remove(&workspace_key) { - navigator.expanded_workspaces.insert(workspace_key); - } - navigator.selected = None; - navigator.scroll = 0; - } - } - pub(super) fn workspace_action_id(&self) -> Option { self.navigate_workspace_id .as_ref() @@ -676,6 +679,11 @@ impl ClientShellState { } return; } + if matches!(code, KeyCode::Left | KeyCode::Right) && modifiers.is_empty() { + self.move_navigator_workspace(code == KeyCode::Right); + outcome.repaint = true; + return; + } if code == KeyCode::Backspace && modifiers.is_empty() { if let Some(ClientShellOverlay::Navigator(navigator)) = self.overlay.as_mut() { if navigator.filter.take().is_some() { @@ -762,11 +770,6 @@ impl ClientShellState { outcome.repaint = true; return; } - if code == KeyCode::Char(' ') && modifiers.is_empty() { - self.toggle_selected_navigator_workspace(); - outcome.repaint = true; - return; - } return; } diff --git a/src/client/shell/overlays.rs b/src/client/shell/overlays.rs index 07417bd0..c8eee5f4 100644 --- a/src/client/shell/overlays.rs +++ b/src/client/shell/overlays.rs @@ -13,6 +13,8 @@ pub(crate) struct OverlayRender { pub(crate) navigator_popup: Rect, pub(crate) navigator_search: Rect, pub(crate) navigator_rows: Vec<(Rect, ClientNavigatorTarget)>, + pub(crate) navigator_scrollbar: Rect, + pub(crate) navigator_scroll_metrics: Option, pub(crate) worktree_search: Rect, pub(crate) worktree_rows: Vec<(Rect, usize)>, pub(crate) help_popup: Rect, @@ -685,24 +687,6 @@ fn render_rename_overlay( }) } -/// Mark following siblings in preorder without rescanning descendants for each row. -/// The reverse stack contains at most one entry per depth; every entry is pushed -/// and popped at most once, so this pass is linear in the number of rows. -fn navigator_following_siblings(rows: &[ClientNavigatorRow]) -> Vec { - let mut following = vec![false; rows.len()]; - let mut depths = Vec::new(); - for (index, row) in rows.iter().enumerate().rev() { - while depths.last().is_some_and(|depth| *depth > row.depth) { - depths.pop(); - } - following[index] = depths.last() == Some(&row.depth); - if !following[index] { - depths.push(row.depth); - } - } - following -} - fn render_navigator_overlay( b: &mut Buffer, n: &ClientNavigatorOverlay, @@ -711,16 +695,27 @@ fn render_navigator_overlay( p: &Palette, ) -> Option { let a = b.area; - let mx = (a.width / 16).max(2); - let my = (a.height / 10).max(1); + let width = a.width.saturating_sub(4).min(116); + let height = a.height.saturating_sub(2).min(42); + if width < 4 || height < 9 { + return None; + } let q = Rect::new( - a.x + mx, - a.y + my, - a.width.saturating_sub(mx * 2).max(4), - a.height.saturating_sub(my * 2).max(4), + a.x + (a.width - width) / 2, + a.y + (a.height - height) / 2, + width, + height, ) .intersection(a); let i = panel(b, q, p.accent, p.panel_bg)?; + put_text( + b, + q.x + 2, + q.y, + q.width.saturating_sub(4), + " Go to ", + Style::default().fg(p.accent).bg(p.panel_bg), + ); let rows = super::aggregate_navigation::navigator_rows(endpoints, active_endpoint_id, n); let search = if n.search_focused { " / ".to_owned() @@ -735,26 +730,32 @@ fn render_navigator_overlay( } ) } else if n.query.is_empty() { - " / search panes".to_owned() + " / search agents and terminals".to_owned() } else { format!(" / {}", n.query) }; + let terminal_count = rows + .iter() + .filter(|row| matches!(row.target, ClientNavigatorTarget::Pane { .. })) + .count(); + let count = format!( + "{terminal_count} {}", + if terminal_count == 1 { + "terminal" + } else { + "terminals" + } + ); put_text( b, i.x, i.y, - i.width, + i.width.saturating_sub(display_width(&count) + 1), &search, Style::default() .fg(if n.search_focused { p.text } else { p.overlay0 }) .bg(p.panel_bg), ); - let count = format!( - "{} panes", - rows.iter() - .filter(|row| matches!(row.target, ClientNavigatorTarget::Pane { .. })) - .count() - ); let cursor = if n.search_focused { text_editor::render( b, @@ -785,7 +786,7 @@ fn render_navigator_overlay( &"─".repeat(i.width as usize), Style::default().fg(p.surface1).bg(p.panel_bg), ); - let body = Rect::new(i.x, i.y + 2, i.width, i.height.saturating_sub(4)); + let body = Rect::new(i.x, i.y + 2, i.width, i.height.saturating_sub(5)); let selected = super::aggregate_navigation::navigator_selected_index(&rows, n).unwrap_or(0); let max = rows.len().saturating_sub(body.height as usize); let scroll = n @@ -793,17 +794,32 @@ fn render_navigator_overlay( .max(selected.saturating_sub(body.height.saturating_sub(1) as usize)) .min(selected) .min(max); - let following_siblings = navigator_following_siblings(&rows); - let mut ancestor_siblings = Vec::new(); - let federated = endpoints.len() > 1; + let metrics = crate::pane::ScrollMetrics { + offset_from_bottom: max.saturating_sub(scroll), + max_offset_from_bottom: max, + viewport_rows: usize::from(body.height), + }; + let scrollbar = + (max > 0 && body.width > 1).then_some(Rect::new(body.right() - 1, body.y, 1, body.height)); + let row_width = body.width.saturating_sub(u16::from(scrollbar.is_some())); let mut row_hits = Vec::new(); - for (ix, r) in rows.iter().enumerate().take(scroll + body.height as usize) { - ancestor_siblings.truncate(usize::from(r.depth)); - ancestor_siblings.push(following_siblings[ix]); - if ix < scroll { - continue; - } - let rect = Rect::new(body.x, body.y + (ix - scroll) as u16, body.width, 1); + if rows.is_empty() { + put_text( + b, + body.x, + body.y, + body.width, + " No matching agents or terminals", + Style::default().fg(p.overlay0).bg(p.panel_bg), + ); + } + for (ix, r) in rows + .iter() + .enumerate() + .skip(scroll) + .take(body.height as usize) + { + let rect = Rect::new(body.x, body.y + (ix - scroll) as u16, row_width, 1); row_hits.push((rect, r.target.clone())); let st = if r.stale { Style::default() @@ -821,50 +837,74 @@ fn render_navigator_overlay( .add_modifier(Modifier::BOLD) } else { Style::default() - .fg(if r.current { p.text } else { p.subtext0 }) + .fg( + if matches!(r.target, ClientNavigatorTarget::Machine { .. }) { + p.subtext0 + } else { + p.text + }, + ) .bg(p.panel_bg) }; - b.set_style(rect, st); - let tree = match &r.target { - ClientNavigatorTarget::Machine { .. } => "▾".to_owned(), - ClientNavigatorTarget::Workspace { - endpoint_id, - workspace_id, - } if n - .expanded_workspaces - .contains(&(endpoint_id.clone(), workspace_id.clone())) => - { - if r.depth == 0 { "▾" } else { " ▾" }.to_owned() - } - ClientNavigatorTarget::Workspace { .. } => { - if r.depth == 0 { "▸" } else { " ▸" }.to_owned() - } - ClientNavigatorTarget::Tab { .. } | ClientNavigatorTarget::Pane { .. } => { - // Machines and workspaces keep their existing caret decoration. - // Connected branches begin below each workspace. - let mut prefix = if federated { " " } else { "" }.to_owned(); - for &following in ancestor_siblings - .iter() - .take(usize::from(r.depth)) - .skip(if federated { 2 } else { 1 }) - { - prefix.push_str(if following { "│ " } else { " " }); - } - prefix.push_str(if following_siblings[ix] { - "├──" - } else { - "└──" - }); - prefix - } + let is_pane = matches!(r.target, ClientNavigatorTarget::Pane { .. }); + let connector = if !is_pane { + "" + } else if rows + .get(ix + 1) + .is_some_and(|next| matches!(next.target, ClientNavigatorTarget::Pane { .. })) + { + "├─ " + } else { + "└─ " }; + let padding = u16::from(r.depth.saturating_sub(u8::from(is_pane))) * 2 + 1; + let connector_x = rect.x + padding; + let indent = format!("{:width$}{connector}", "", width = usize::from(padding)); let current = if r.current { "◆ " } else { "" }; let status = r.status.map(status_dot).unwrap_or_default(); let status_separator = if status.is_empty() { "" } else { " " }; - let label = format!(" {tree} {current}{status}{status_separator}{}", r.label); - put_text(b, rect.x, rect.y, rect.width, &label, st); + let label = format!("{indent}{current}{status}{status_separator}{}", r.label); + let st = if r.status.is_none() { + st.add_modifier(Modifier::BOLD) + } else { + st + }; + b.set_style(rect, st); + let columns = if r.status.is_some() { + if rect.width >= 64 { + 24 + } else if rect.width >= 36 { + 12 + } else { + 0 + } + } else { + 0 + }; + put_text( + b, + rect.x, + rect.y, + rect.width.saturating_sub(columns), + &label, + st, + ); + if is_pane { + put_text( + b, + connector_x, + rect.y, + rect.right().saturating_sub(connector_x).min(2), + connector, + if r.stale || ix == selected { + st + } else { + st.fg(p.overlay0) + }, + ); + } if let Some(status) = r.status { - let prefix = format!(" {tree} {current}"); + let prefix = format!("{indent}{current}"); let status_style = if r.stale || ix == selected { st } else { @@ -878,6 +918,35 @@ fn render_navigator_overlay( status_dot(status), status_style, ); + let meta_style = if r.stale || ix == selected { + st + } else { + st.fg(p.overlay0) + }; + if columns > 0 { + put_text( + b, + rect.right() - columns + 1, + rect.y, + 11, + r.agent.as_deref().unwrap_or("terminal"), + meta_style, + ); + } + if columns == 24 { + put_text( + b, + rect.right() - 11, + rect.y, + 11, + if r.agent.is_some() { + status_text(status) + } else { + "shell" + }, + meta_style, + ); + } } let machine_status = match &r.target { ClientNavigatorTarget::Machine { endpoint_id } if !endpoint_id.is_local() => endpoints @@ -906,7 +975,7 @@ fn render_navigator_overlay( }) }; put_right_text(b, rect, rect.y, &signal, signal_style); - } else if !r.meta.is_empty() { + } else if r.status.is_none() && !r.meta.is_empty() { let label_width = display_width(&label).min(rect.width); let meta = Rect::new( rect.x.saturating_add(label_width).saturating_add(1), @@ -917,16 +986,26 @@ fn render_navigator_overlay( put_right_text(b, meta, rect.y, &r.meta, st) } } - let dy = i.bottom() - 2; + if let Some(track) = scrollbar { + crate::ui::render_scrollbar_buffer(b, metrics, track, p.overlay0, p.overlay1, "▐"); + } if let Some(r) = rows.get(selected) { put_text( b, i.x, - dy, + i.bottom() - 3, + i.width, + &format!(" {}", r.detail), + Style::default().fg(p.subtext0).bg(p.panel_bg), + ); + put_text( + b, + i.x, + i.bottom() - 2, i.width, - &format!(" {} · {}", r.label, r.meta), + &format!(" {}", r.meta), Style::default().fg(p.overlay0).bg(p.panel_bg), - ) + ); } put_text( b, @@ -936,7 +1015,7 @@ fn render_navigator_overlay( if n.search_focused { " search type · move ↑↓/ctrl+n/p · open enter · back esc" } else { - " move j/k · expand space · filter a/b/w/i/d · search / · open enter · close esc" + " ↑↓/j/k rows · ←→ workspace · / search · a/b/w/i/d filter · enter open · esc close" }, Style::default().fg(p.overlay0).bg(p.panel_bg), ); @@ -948,6 +1027,8 @@ fn render_navigator_overlay( navigator_popup: q, navigator_search: Rect::new(i.x, i.y, i.width, 1), navigator_rows: row_hits, + navigator_scrollbar: scrollbar.unwrap_or_default(), + navigator_scroll_metrics: Some(metrics), worktree_search: Rect::default(), worktree_rows: Vec::new(), cursor, diff --git a/src/client/shell/state.rs b/src/client/shell/state.rs index b975b2f6..7128db57 100644 --- a/src/client/shell/state.rs +++ b/src/client/shell/state.rs @@ -121,6 +121,8 @@ pub(super) struct ShellHitMap { pub(super) navigator_popup: Rect, pub(super) navigator_search: Rect, pub(super) navigator_rows: Vec<(Rect, ClientNavigatorTarget)>, + pub(super) navigator_scrollbar: Rect, + pub(super) navigator_scroll_metrics: Option, pub(super) worktree_search: Rect, pub(super) worktree_rows: Vec<(Rect, usize)>, pub(super) help_popup: Rect, @@ -196,6 +198,9 @@ pub(super) enum ClientChromeDrag { HelpScrollbar { grab_row_offset: u16, }, + NavigatorScrollbar { + grab_row_offset: u16, + }, ProductAnnouncementScrollbar { grab_row_offset: u16, }, @@ -336,10 +341,6 @@ pub(super) enum ClientNavigatorTarget { endpoint_id: ClientEndpointId, workspace_id: String, }, - Tab { - endpoint_id: ClientEndpointId, - tab_id: String, - }, Pane { endpoint_id: ClientEndpointId, pane_id: String, @@ -351,6 +352,8 @@ pub(super) struct ClientNavigatorRow { pub(super) depth: u8, pub(super) label: String, pub(super) meta: String, + pub(super) detail: String, + pub(super) agent: Option, pub(super) status: Option, pub(super) stale: bool, pub(super) current: bool, @@ -364,7 +367,6 @@ pub(super) struct ClientNavigatorOverlay { pub(super) selected: Option, pub(super) scroll: usize, pub(super) filter: Option, - pub(super) expanded_workspaces: HashSet<(ClientEndpointId, String)>, } #[derive(Debug)] diff --git a/src/client/shell/tests/copy.rs b/src/client/shell/tests/copy.rs index eda4da89..bdd192a6 100644 --- a/src/client/shell/tests/copy.rs +++ b/src/client/shell/tests/copy.rs @@ -940,7 +940,34 @@ fn copy_search_owns_prompt_repeat_highlights_selection_and_restore() { } #[test] -fn navigator_renders_connected_siblings_and_ancestor_lines() { +fn navigator_workspace_headings_use_the_active_themes_primary_text() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.config.theme_runtime.auto_switch = false; + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + state.open_navigator_overlay(); + for palette in [ + Palette::catppuccin(), + Palette::catppuccin_latte(), + Palette::terminal(), + ] { + state.config.palette = palette; + let frame = state.compose(106, 30).expect("navigator"); + let (rect, _) = state + .hits + .navigator_rows + .iter() + .find(|(_, target)| matches!(target, ClientNavigatorTarget::Workspace { .. })) + .expect("workspace heading"); + let position = cell_symbol_position(&frame, *rect, "client-shell"); + let buffer = frame.to_ratatui_buffer().expect("buffer"); + assert_eq!(buffer[position].fg, state.config.palette.text); + assert!(buffer[position].modifier.contains(Modifier::BOLD)); + } +} + +#[test] +fn navigator_renders_every_terminal_in_workspace_sections() { let mut snapshot = snapshot(); snapshot.focused_pane_id = None; snapshot.tabs[0].label = "editor".into(); @@ -986,39 +1013,46 @@ fn navigator_renders_connected_siblings_and_ancestor_lines() { state.set_snapshot(Box::new(snapshot)); state.set_pane_surface(surface()); state.open_navigator_overlay(); - let prefixes = |state: &mut ClientShellState, height| { + let visible_rows = |state: &mut ClientShellState, height| { let frame = state.compose(106, height).expect("navigator frame"); state .hits .navigator_rows .iter() .map(|(rect, _)| { - frame.cells[rect.y as usize * frame.width as usize + rect.x as usize + 1..] + frame.cells[rect.y as usize * frame.width as usize + rect.x as usize..] .iter() - .take(6) + .take(rect.width as usize) .map(|cell| cell.symbol.as_str()) .collect::() }) .collect::>() }; - assert_eq!( - prefixes(&mut state, 30), - [ - "▾ clie", - "├── ed", - "│ ├──", - "│ └──", - "├── no", - "│ └──", - "└── lo", - " └──", - "▾ seco", - "└── la", - " └──" - ] - ); + let visible = visible_rows(&mut state, 30); + assert_eq!(visible.len(), 7); + for (row, prefix) in visible + .iter() + .zip([" client", " ├─ ", " ├─ ", " ├─ ", " └─ ", " second", " └─ "]) + { + assert!( + row.starts_with(prefix), + "{row:?} should start with {prefix:?}" + ); + } + for (row, label) in visible.iter().zip([ + "client-shell", + "editor · agent · 1", + "editor · shell · 2", + "notes", + "logs", + "second", + "agent", + ]) { + assert!(row.contains(label), "{row:?} should contain {label}"); + assert!(!row.contains("/repo")); + assert!(!row.contains("──")); + } - // The editor ancestor is above this viewport; the logs sibling is below it. let Some(ClientShellOverlay::Navigator(navigator)) = state.overlay.as_mut() else { panic!("expected navigator"); }; @@ -1027,24 +1061,477 @@ fn navigator_renders_connected_siblings_and_ancestor_lines() { endpoint_id: state.active_endpoint_id.clone(), pane_id: "pane_shell".into(), }); - assert_eq!( - prefixes(&mut state, 12), - ["│ ├──", "│ └──", "├── no", "│ └──"] - ); + let visible = visible_rows(&mut state, 11); + assert_eq!(visible.len(), 2); + assert!(visible.iter().any(|row| row.contains("editor · shell · 2"))); + assert!(visible[0].starts_with(" ├─ ")); + assert!(visible[1].starts_with(" ├─ ")); - // Excluded siblings must not leave dangling continuation lines. + // Filtering retains the section and the exact split destination. let Some(ClientShellOverlay::Navigator(navigator)) = state.overlay.as_mut() else { panic!("expected navigator"); }; - navigator.query = "shell".into(); + navigator.query = "pane_shell".into(); navigator.scroll = 0; - assert_eq!(prefixes(&mut state, 30), ["▾ clie", "└── ed", " └──"]); + let visible = visible_rows(&mut state, 30); + assert_eq!(visible.len(), 2); + assert!(visible[1].starts_with(" └─ ")); + assert!(visible.iter().any(|row| row.contains("editor · shell · 2"))); + assert!(visible.iter().all(|row| !row.contains("second"))); +} + +#[test] +fn navigator_searches_ancestor_context_and_keeps_split_agents_individually_actionable() { + let mut projected = snapshot(); + projected.tabs[0].label = "review".into(); + projected.tabs[0].custom_label = true; + let mut second = projected.panes[0].clone(); + second.pane_id = "pane_2".into(); + second.focused = false; + second.foreground_cwd = Some("/repo/subproject".into()); + projected.panes.push(second); + let first_agent = ClientShellAgent { + pane_id: "pane_1".into(), + workspace_id: "ws_1".into(), + tab_id: "tab_1".into(), + name: Some("writer".into()), + display_agent: None, + agent: Some("pi".into()), + title: Some("implementing navigation".into()), + terminal_title: None, + terminal_title_stripped: None, + agent_status: AgentStatus::Working, + state_change_seq: 1, + state_labels: Vec::new(), + tokens: Vec::new(), + focused: true, + }; + let mut second_agent = first_agent.clone(); + second_agent.pane_id = "pane_2".into(); + second_agent.name = Some("reviewer".into()); + second_agent.agent = Some("claude".into()); + second_agent.title = Some("checking navigation".into()); + second_agent.agent_status = AgentStatus::Blocked; + second_agent.focused = false; + projected.agents = vec![first_agent, second_agent]; + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(projected)); + state.set_pane_surface(surface()); + state.open_navigator_overlay(); + for (query, filter, expected) in [ + ("", None, vec!["pane_1", "pane_2"]), + ("review", None, vec!["pane_1", "pane_2"]), + ("client-shell", None, vec!["pane_1", "pane_2"]), + ("main", None, vec!["pane_1", "pane_2"]), + ("claude", None, vec!["pane_2"]), + ("checking navigation", None, vec!["pane_2"]), + ("/repo/subproject", None, vec!["pane_2"]), + ( + "review", + Some(ClientNavigatorFilter::Blocked), + vec!["pane_2"], + ), + ("", Some(ClientNavigatorFilter::Working), vec!["pane_1"]), + ("no such agent", None, vec![]), + ] { + let Some(ClientShellOverlay::Navigator(navigator)) = state.overlay.as_mut() else { + panic!("navigator"); + }; + navigator.query = query.into(); + navigator.filter = filter; + navigator.selected = None; + let rows = + render::client_navigator_rows(&state.endpoints, &state.active_endpoint_id, navigator); + let pane_ids = rows + .iter() + .filter_map(|row| match &row.target { + ClientNavigatorTarget::Pane { pane_id, .. } => Some(pane_id.as_str()), + _ => None, + }) + .collect::>(); + assert_eq!(pane_ids, expected, "query={query:?} filter={filter:?}"); + assert_eq!( + rows.len(), + if expected.is_empty() { + 0 + } else { + expected.len() + 1 + } + ); + if !expected.is_empty() { + let selected = + super::super::aggregate_navigation::navigator_selected_index(&rows, navigator) + .expect("search destination"); + assert!(matches!( + rows[selected].target, + ClientNavigatorTarget::Pane { .. } + )); + } + } let Some(ClientShellOverlay::Navigator(navigator)) = state.overlay.as_mut() else { - panic!("expected navigator"); + panic!("navigator"); }; navigator.query.clear(); - navigator.expanded_workspaces.clear(); - assert_eq!(prefixes(&mut state, 30), ["▸ clie", "▸ seco"]); + let frame = state.compose(160, 48).expect("navigator"); + assert_eq!(state.hits.navigator_popup.width, 116); + let pane_rows = state + .hits + .navigator_rows + .iter() + .filter(|(_, target)| matches!(target, ClientNavigatorTarget::Pane { .. })) + .collect::>(); + assert_eq!(pane_rows.len(), 2); + for ((rect, _), (name, kind, status)) in pane_rows.iter().zip([ + ("writer", "pi", "working"), + ("reviewer", "claude", "blocked"), + ]) { + cell_symbol_position(&frame, *rect, name); + cell_symbol_position(&frame, *rect, kind); + cell_symbol_position(&frame, *rect, status); + } + let rect = pane_rows[1].0; + let outcome = state.handle_raw_events(vec![RawInputEvent::Mouse(MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: rect.right() - 1, + row: rect.y, + modifiers: KeyModifiers::empty(), + })]); + assert!( + matches!(outcome.actions.as_slice(), [ClientShellAction::Endpoint { request, .. }] + if matches!(&request.method, crate::api::schema::Method::PaneFocus(target) if target.pane_id == "pane_2")) + ); +} + +#[test] +fn navigator_distinguishes_unnamed_terminals_on_numbered_tabs() { + let mut projected = snapshot(); + for (number, label) in [(2, "2"), (3, "logs")] { + let mut tab = projected.tabs[0].clone(); + tab.tab_id = format!("tab_{number}"); + tab.number = number; + tab.label = label.into(); + tab.custom_label = number == 3; + tab.focused = false; + let mut pane = projected.panes[0].clone(); + pane.pane_id = format!("pane_{number}"); + pane.tab_id = tab.tab_id.clone(); + pane.focused = false; + projected.tabs.push(tab); + projected.panes.push(pane); + } + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(projected)); + state.open_navigator_overlay(); + let Some(ClientShellOverlay::Navigator(navigator)) = &state.overlay else { + panic!("navigator"); + }; + let rows = + render::client_navigator_rows(&state.endpoints, &state.active_endpoint_id, navigator); + let labels = rows + .iter() + .filter(|row| matches!(row.target, ClientNavigatorTarget::Pane { .. })) + .map(|row| row.label.as_str()) + .collect::>(); + assert_eq!(labels, ["terminal · 1", "terminal · 2", "logs"]); +} + +#[test] +fn navigator_keeps_empty_workspaces_searchable_without_status_filters() { + let mut projected = snapshot(); + projected.tabs.clear(); + projected.panes.clear(); + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(projected)); + state.open_navigator_overlay(); + for (query, filter, expected) in [ + ("", None, true), + ("client-shell", None, true), + ("main", None, true), + ("missing", None, false), + ("main", Some(ClientNavigatorFilter::Idle), false), + ("", Some(ClientNavigatorFilter::Working), false), + ] { + let Some(ClientShellOverlay::Navigator(navigator)) = state.overlay.as_mut() else { + panic!("navigator"); + }; + navigator.query = query.into(); + navigator.filter = filter; + let rows = + render::client_navigator_rows(&state.endpoints, &state.active_endpoint_id, navigator); + assert_eq!( + rows.len(), + usize::from(expected), + "query={query:?}, filter={filter:?}" + ); + let target = + super::super::aggregate_navigation::selected_navigator_target(&rows, navigator); + assert_eq!( + target, + expected.then(|| ClientNavigatorTarget::Workspace { + endpoint_id: ClientEndpointId::Local, + workspace_id: "ws_1".into(), + }) + ); + } +} + +#[test] +fn navigator_horizontal_arrows_jump_sections_but_edit_the_search_cursor() { + let mut projected = snapshot(); + projected.panes[0].label = Some("needle-first".into()); + let mut sibling = projected.panes[0].clone(); + sibling.pane_id = "pane_sibling".into(); + sibling.label = Some("other".into()); + sibling.focused = false; + projected.panes.push(sibling); + let mut empty = projected.workspaces[0].clone(); + empty.workspace_id = "ws_empty".into(); + empty.label = "empty".into(); + empty.focused = false; + projected.workspaces.push(empty); + let mut last = projected.workspaces[0].clone(); + last.workspace_id = "ws_last".into(); + last.label = "last".into(); + last.active_tab_id = "tab_last".into(); + last.focused = false; + let mut tab = projected.tabs[0].clone(); + tab.workspace_id = last.workspace_id.clone(); + tab.tab_id = last.active_tab_id.clone(); + tab.focused = false; + for (id, label) in [ + ("pane_last", "needle-last"), + ("pane_last_sibling", "other-last"), + ] { + let mut pane = projected.panes[0].clone(); + pane.pane_id = id.into(); + pane.label = Some(label.into()); + pane.workspace_id = last.workspace_id.clone(); + pane.tab_id = tab.tab_id.clone(); + pane.focused = false; + projected.panes.push(pane); + } + projected.workspaces.push(last); + projected.tabs.push(tab); + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(projected)); + state.set_pane_surface(surface()); + state.open_navigator_overlay(); + let press = |state: &mut ClientShellState, code| { + let outcome = state.handle_raw_events(vec![RawInputEvent::Key( + crate::input::TerminalKey::new(code, KeyModifiers::empty()), + )]); + assert!(outcome.actions.is_empty()); + }; + let selected = |state: &ClientShellState| { + let Some(ClientShellOverlay::Navigator(navigator)) = &state.overlay else { + panic!("navigator"); + }; + navigator.selected.clone() + }; + let target = |id: &str| { + Some(ClientNavigatorTarget::Pane { + endpoint_id: ClientEndpointId::Local, + pane_id: id.into(), + }) + }; + press(&mut state, KeyCode::Left); + assert_eq!(selected(&state), target("pane_1")); + press(&mut state, KeyCode::Right); + assert_eq!(selected(&state), target("pane_last")); + press(&mut state, KeyCode::Down); + assert_eq!(selected(&state), target("pane_last_sibling")); + press(&mut state, KeyCode::Right); + assert_eq!(selected(&state), target("pane_last_sibling")); + press(&mut state, KeyCode::Left); + assert_eq!(selected(&state), target("pane_1")); + let Some(ClientShellOverlay::Navigator(navigator)) = state.overlay.as_mut() else { + panic!("navigator"); + }; + navigator.query = "needle".into(); + press(&mut state, KeyCode::Right); + assert_eq!(selected(&state), target("pane_last")); + press(&mut state, KeyCode::Left); + assert_eq!(selected(&state), target("pane_1")); + press(&mut state, KeyCode::Char('/')); + press(&mut state, KeyCode::Left); + press(&mut state, KeyCode::Right); + assert_eq!(selected(&state), target("pane_1")); + press(&mut state, KeyCode::Left); + press(&mut state, KeyCode::Char('X')); + let Some(ClientShellOverlay::Navigator(navigator)) = state.overlay.as_mut() else { + panic!("navigator"); + }; + assert_eq!(navigator.query.as_str(), "needlXe"); + navigator.search_focused = false; + navigator.selected = None; + press(&mut state, KeyCode::Left); + press(&mut state, KeyCode::Right); + assert_eq!(selected(&state), None); +} + +#[test] +fn navigator_scrollbar_click_and_drag_scroll_without_opening_a_destination() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + state.open_navigator_overlay(); + state.compose(106, 24).expect("small navigator"); + assert!(state.hits.navigator_scrollbar.is_empty()); + + let mut projected = snapshot(); + for index in 2..=60 { + let mut pane = projected.panes[0].clone(); + pane.pane_id = format!("pane_{index}"); + pane.label = Some(format!("agent {index}")); + pane.focused = false; + projected.panes.push(pane); + } + state.set_snapshot(Box::new(projected)); + let frame = state.compose(106, 24).expect("overflowing navigator"); + let track = state.hits.navigator_scrollbar; + let metrics = state.hits.navigator_scroll_metrics.expect("scroll metrics"); + assert!(!track.is_empty()); + assert_eq!(metrics.offset_from_bottom, metrics.max_offset_from_bottom); + assert!(track.y > state.hits.navigator_search.y); + assert!(track.bottom() < state.hits.navigator_popup.bottom() - 3); + assert!(state + .hits + .navigator_rows + .iter() + .all(|(rect, _)| rect.right() == track.x)); + let buffer = frame.to_ratatui_buffer().expect("buffer"); + assert_eq!(buffer[(track.x, track.y)].fg, state.config.palette.overlay1); + assert_eq!( + buffer[(track.x, track.bottom() - 1)].fg, + state.config.palette.overlay0 + ); + let mouse = |state: &mut ClientShellState, kind, row| { + let outcome = state.handle_raw_events(vec![RawInputEvent::Mouse(MouseEvent { + kind, + column: track.x, + row, + modifiers: KeyModifiers::empty(), + })]); + assert!(outcome.actions.is_empty()); + assert!(matches!( + state.overlay, + Some(ClientShellOverlay::Navigator(_)) + )); + }; + mouse( + &mut state, + MouseEventKind::Down(MouseButton::Left), + track.bottom() - 1, + ); + state.compose(106, 24).expect("track jump"); + assert_eq!( + state + .hits + .navigator_scroll_metrics + .expect("metrics") + .offset_from_bottom, + 0 + ); + assert!(state.hits.navigator_rows.iter().any(|(_, target)| matches!(target, ClientNavigatorTarget::Pane { pane_id, .. } if pane_id == "pane_60"))); + mouse(&mut state, MouseEventKind::Down(MouseButton::Left), track.y); + state.compose(106, 24).expect("jump back to top"); + assert_eq!( + state + .hits + .navigator_scroll_metrics + .expect("metrics") + .offset_from_bottom, + metrics.max_offset_from_bottom + ); + let thumb = crate::ui::scrollbar_thumb(metrics, track).expect("thumb"); + let grab = thumb.len - 1; + mouse( + &mut state, + MouseEventKind::Down(MouseButton::Left), + thumb.top + grab, + ); + assert!( + matches!(state.chrome_drag, Some(ClientChromeDrag::NavigatorScrollbar { grab_row_offset }) if grab_row_offset == grab) + ); + mouse( + &mut state, + MouseEventKind::Drag(MouseButton::Left), + thumb.top + grab, + ); + state.compose(106, 24).expect("grab does not move viewport"); + assert_eq!( + state + .hits + .navigator_scroll_metrics + .expect("metrics") + .offset_from_bottom, + metrics.max_offset_from_bottom + ); + mouse( + &mut state, + MouseEventKind::Drag(MouseButton::Left), + track.bottom() + 5, + ); + state.compose(106, 24).expect("drag to bottom"); + assert_eq!( + state + .hits + .navigator_scroll_metrics + .expect("metrics") + .offset_from_bottom, + 0 + ); + mouse( + &mut state, + MouseEventKind::Up(MouseButton::Left), + track.bottom() + 5, + ); + assert!(state.chrome_drag.is_none()); + state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new( + KeyCode::Up, + KeyModifiers::empty(), + ))]); + state.compose(106, 24).expect("keyboard resumes after drag"); + assert_eq!( + state + .hits + .navigator_scroll_metrics + .expect("metrics") + .offset_from_bottom, + 1 + ); + + let Some(ClientShellOverlay::Navigator(navigator)) = state.overlay.as_mut() else { + panic!("navigator"); + }; + navigator.query = "pane_60".into(); + navigator.selected = None; + state.compose(106, 24).expect("filtered navigator"); + assert!(state.hits.navigator_scrollbar.is_empty()); + assert_eq!(state.hits.navigator_rows.len(), 2); + state.compose(106, 90).expect("tall filtered navigator"); + assert!(state.hits.navigator_scrollbar.is_empty()); +} + +#[test] +fn navigator_narrow_layout_and_long_search_stay_inside_the_popup() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + state.open_navigator_overlay(); + for (width, height) in [(24, 12), (50, 24), (106, 30)] { + let Some(ClientShellOverlay::Navigator(navigator)) = state.overlay.as_mut() else { + panic!("navigator"); + }; + navigator.search_focused = true; + navigator.query = "界".repeat(100).as_str().into(); + let frame = state.compose(width, height).expect("navigator frame"); + let popup = state.hits.navigator_popup; + let cursor = frame.cursor.expect("search cursor"); + assert!(super::super::contains(popup, (cursor.x, cursor.y))); + assert!(state.hits.navigator_rows.is_empty()); + assert!(popup.right() <= width && popup.bottom() <= height); + } } #[test] @@ -1098,7 +1585,8 @@ fn navigator_owns_search_mouse_selection_and_stable_target_focus() { .collect::>() .join("\n"); assert!(navigator_text.contains("client-shell")); - assert!(navigator_text.contains("pane 1")); + assert!(navigator_text.contains("terminal")); + assert!(!navigator_text.contains("pane 1")); let search = state.hits.navigator_search; let focus_search = diff --git a/src/client/shell/tests/endpoints.rs b/src/client/shell/tests/endpoints.rs index e4914971..4434e119 100644 --- a/src/client/shell/tests/endpoints.rs +++ b/src/client/shell/tests/endpoints.rs @@ -2056,18 +2056,16 @@ fn navigator_uses_machine_parents_only_for_federated_clients() { assert!(rows.iter().all(|row| match row.target { ClientNavigatorTarget::Machine { .. } => row.depth == 0 && row.status.is_none(), ClientNavigatorTarget::Workspace { .. } => row.depth == 1 && row.status.is_none(), - ClientNavigatorTarget::Tab { .. } => row.depth == 2 && row.status.is_none(), - ClientNavigatorTarget::Pane { .. } => row.depth == 3 && row.status.is_some(), + ClientNavigatorTarget::Pane { .. } => row.depth == 2 && row.status.is_some(), })); assert_eq!(rows.iter().filter(|row| row.current).count(), 1); let frame = state.compose(106, 30).expect("federated navigator"); for (rect, target) in &state.hits.navigator_rows { let expected = match target { - ClientNavigatorTarget::Machine { .. } => " ▾ ", - ClientNavigatorTarget::Workspace { .. } => " ▾ ", - ClientNavigatorTarget::Tab { .. } => " └── ", - ClientNavigatorTarget::Pane { .. } => " └── ", + ClientNavigatorTarget::Machine { .. } => " ", + ClientNavigatorTarget::Workspace { .. } => " ", + ClientNavigatorTarget::Pane { .. } => " └─ ", }; let prefix = frame.cells[rect.y as usize * frame.width as usize + rect.x as usize..] .iter() @@ -2105,8 +2103,7 @@ fn navigator_uses_machine_parents_only_for_federated_clients() { .all(|row| !matches!(row.target, ClientNavigatorTarget::Machine { .. }))); assert!(rows.iter().all(|row| match row.target { ClientNavigatorTarget::Workspace { .. } => row.depth == 0, - ClientNavigatorTarget::Tab { .. } => row.depth == 1, - ClientNavigatorTarget::Pane { .. } => row.depth == 2, + ClientNavigatorTarget::Pane { .. } => row.depth == 1, ClientNavigatorTarget::Machine { .. } => false, })); } @@ -2143,10 +2140,6 @@ fn navigator_keeps_saved_machine_visible_before_metadata_arrives() { endpoint_id: target, .. } - | ClientNavigatorTarget::Tab { - endpoint_id: target, - .. - } | ClientNavigatorTarget::Pane { endpoint_id: target, .. @@ -2497,7 +2490,32 @@ fn collapsed_aggregate_workspace_status_uses_its_status_color() { } #[test] -fn navigator_foreign_tab_selection_keeps_the_tab_target() { +fn navigator_workspace_arrows_cross_machine_headings_without_activating_them() { + let (mut state, endpoint_id) = state_with_remote(); + state.open_navigator_overlay(); + for (key, expected_endpoint) in [ + (KeyCode::Right, endpoint_id), + (KeyCode::Left, ClientEndpointId::Local), + ] { + let outcome = state.handle_raw_events(vec![RawInputEvent::Key( + crate::input::TerminalKey::new(key, KeyModifiers::empty()), + )]); + assert!(outcome.actions.is_empty()); + let Some(ClientShellOverlay::Navigator(navigator)) = &state.overlay else { + panic!("navigator"); + }; + assert_eq!( + navigator.selected, + Some(ClientNavigatorTarget::Pane { + endpoint_id: expected_endpoint, + pane_id: "pane_1".into(), + }) + ); + } +} + +#[test] +fn navigator_foreign_workspace_heading_keeps_the_workspace_target() { let (mut state, endpoint_id) = state_with_remote(); state.open_navigator_overlay(); let selected = { @@ -2510,15 +2528,14 @@ fn navigator_foreign_tab_selection_keeps_the_tab_target() { .find(|row| { matches!( &row.target, - ClientNavigatorTarget::Tab { + ClientNavigatorTarget::Workspace { endpoint_id: target_endpoint, - tab_id, - .. - } if target_endpoint == &endpoint_id && tab_id == "tab_1" + workspace_id, + } if target_endpoint == &endpoint_id && workspace_id == "ws_1" ) }) .map(|row| row.target.clone()) - .expect("remote tab row") + .expect("remote workspace heading") }; if let Some(ClientShellOverlay::Navigator(navigator)) = state.overlay.as_mut() { navigator.selected = Some(selected); @@ -2531,7 +2548,7 @@ fn navigator_foreign_tab_selection_keeps_the_tab_target() { outcome.actions.as_slice(), [ClientShellAction::ActivateEndpoint { endpoint_id: activated, - target: Some(ClientEndpointFocusTarget::Tab(tab_id)), - }] if activated == &endpoint_id && tab_id == "tab_1" + target: Some(ClientEndpointFocusTarget::Workspace(workspace_id)), + }] if activated == &endpoint_id && workspace_id == "ws_1" )); } diff --git a/src/client/shell/tests/graphics.rs b/src/client/shell/tests/graphics.rs index c5fa1f4a..98fc62fa 100644 --- a/src/client/shell/tests/graphics.rs +++ b/src/client/shell/tests/graphics.rs @@ -230,7 +230,6 @@ fn every_dialog_and_menu_occludes_its_panel_not_the_whole_screen() { selected: None, scroll: 0, filter: None, - expanded_workspaces: HashSet::new(), }), ClientShellOverlay::WorktreeCreate(ClientWorktreeCreateOverlay { source_workspace_id: "ws_1".into(), diff --git a/src/client/shell/tests/popup_focus_projection.rs b/src/client/shell/tests/popup_focus_projection.rs index 7f3ea137..c9f02278 100644 --- a/src/client/shell/tests/popup_focus_projection.rs +++ b/src/client/shell/tests/popup_focus_projection.rs @@ -83,7 +83,6 @@ fn modal_paste_target_requires_a_focused_editable_client_field() { selected: None, scroll: 0, filter: None, - expanded_workspaces: HashSet::new(), })); assert!(!state.modal_paste_target_active()); if let Some(ClientShellOverlay::Navigator(navigator)) = state.overlay.as_mut() { -- cgit v1.2.3