aboutsummaryrefslogtreecommitdiff
path: root/src/app/input/sidebar.rs
diff options
context:
space:
mode:
authorCan Celik <ogulcancelik@gmail.com>2026-09-01 15:48:22 +0300
committerGitHub <noreply@github.com>2026-09-01 15:48:22 +0300
commit207be3c771d281baae6e5fa0fb74be9a056e97a2 (patch)
treed7b037ecdf629224a048c841d11659efdc23507d /src/app/input/sidebar.rs
parent8a6d697308162874a236c84aac0b0f8c7ac01f83 (diff)
refactor: render the shell in the client (#3487)
* feat: add opt-in client-rendered shell * feat: add client-owned shell mouse controls * feat: complete client-owned shell chrome * feat: add client-owned shell settings and notifications * feat: run client shell custom commands * feat: render popup terminals in client shell * feat: add client-owned scrollback and copy mode * fix: preserve client worktree trust defaults * feat: render startup diagnostics in client shell * feat: render onboarding in client shell * feat: render product announcements in client shell * feat: render release notes in client shell * feat: render mobile switcher in client shell * feat: render kitty graphics in client shell * fix: preserve client shell lifecycle coherence * refactor: route client shell commands through endpoint connections * refactor: make the client-rendered shell the default * refactor: remove monolithic launch mode * refactor: complete client-rendered shell cutover * perf: retain client pane surface updates * perf: scale retained pane surface updates * perf: remove remote pane input latency * test: make client shell checks platform-independent * fix: prevent client timer starvation * test: fix client shell platform gates * test: accept retained surfaces in cross-area checks * test: make client mode assertions platform-neutral * fix: badge only outdated integrations
Diffstat (limited to 'src/app/input/sidebar.rs')
-rw-r--r--src/app/input/sidebar.rs1936
1 files changed, 0 insertions, 1936 deletions
diff --git a/src/app/input/sidebar.rs b/src/app/input/sidebar.rs
deleted file mode 100644
index 0923eef6..00000000
--- a/src/app/input/sidebar.rs
+++ /dev/null
@@ -1,1936 +0,0 @@
-use ratatui::layout::Rect;
-
-use crate::app::state::{AppState, ViewLayout};
-
-use super::ScrollbarClickTarget;
-
-impl AppState {
- pub(super) fn workspace_list_rect(&self) -> Rect {
- let sidebar = self.view.sidebar_rect;
- if self.sidebar_collapsed || sidebar.width <= 1 || sidebar.height == 0 {
- return Rect::default();
- }
- crate::ui::workspace_list_rect(sidebar, self.sidebar_section_split)
- }
-
- pub(super) fn agent_panel_rect(&self) -> Rect {
- let sidebar = self.view.sidebar_rect;
- if self.sidebar_collapsed || sidebar.width <= 1 || sidebar.height == 0 {
- return Rect::default();
- }
- let (_, detail_area) =
- crate::ui::expanded_sidebar_sections(sidebar, self.sidebar_section_split);
- detail_area
- }
-
- pub(super) fn workspace_list_scrollbar_target_at(
- &self,
- col: u16,
- row: u16,
- ) -> Option<ScrollbarClickTarget> {
- let area = self.workspace_list_rect();
- let metrics = crate::ui::workspace_list_scroll_metrics(self, area);
- let track = crate::ui::workspace_list_scrollbar_rect(self, area)?;
- if col < track.x
- || col >= track.x + track.width
- || row < track.y
- || row >= track.y + track.height
- {
- return None;
- }
- if let Some(grab_row_offset) = crate::ui::scrollbar_thumb_grab_offset(metrics, track, row) {
- Some(ScrollbarClickTarget::Thumb { grab_row_offset })
- } else {
- Some(ScrollbarClickTarget::Track {
- offset_from_bottom: crate::ui::scrollbar_offset_from_row(metrics, track, row),
- })
- }
- }
-
- pub(super) fn workspace_list_offset_for_drag_row(
- &self,
- row: u16,
- grab_row_offset: u16,
- ) -> Option<usize> {
- let area = self.workspace_list_rect();
- let metrics = crate::ui::workspace_list_scroll_metrics(self, area);
- let track = crate::ui::workspace_list_scrollbar_rect(self, area)?;
- Some(crate::ui::scrollbar_offset_from_drag_row(
- metrics,
- track,
- row,
- grab_row_offset,
- ))
- }
-
- pub(super) fn set_workspace_list_offset_from_bottom(&mut self, offset_from_bottom: usize) {
- let area = self.workspace_list_rect();
- let metrics = crate::ui::workspace_list_scroll_metrics(self, area);
- self.workspace_scroll = metrics
- .max_offset_from_bottom
- .saturating_sub(offset_from_bottom);
- self.workspace_scroll = crate::ui::normalized_workspace_scroll(
- self,
- self.view.sidebar_rect,
- self.workspace_scroll,
- );
- }
-
- pub(super) fn scroll_workspace_list(&mut self, delta: i16) {
- if delta.is_negative() {
- self.workspace_scroll = self
- .workspace_scroll
- .saturating_sub(delta.unsigned_abs() as usize);
- self.workspace_scroll = crate::ui::normalized_workspace_scroll(
- self,
- self.view.sidebar_rect,
- self.workspace_scroll,
- );
- return;
- }
-
- let area = self.workspace_list_rect();
- let metrics = crate::ui::workspace_list_scroll_metrics(self, area);
- self.workspace_scroll = self
- .workspace_scroll
- .saturating_add(delta as usize)
- .min(metrics.max_offset_from_bottom);
- self.workspace_scroll = crate::ui::normalized_workspace_scroll(
- self,
- self.view.sidebar_rect,
- self.workspace_scroll,
- );
- }
-
- pub(super) fn agent_panel_scrollbar_target_at(
- &self,
- col: u16,
- row: u16,
- ) -> Option<ScrollbarClickTarget> {
- let area = self.agent_panel_rect();
- let metrics = crate::ui::agent_panel_scroll_metrics(self, area);
- let track = crate::ui::agent_panel_scrollbar_rect(self, area)?;
- if col < track.x
- || col >= track.x + track.width
- || row < track.y
- || row >= track.y + track.height
- {
- return None;
- }
- if let Some(grab_row_offset) = crate::ui::scrollbar_thumb_grab_offset(metrics, track, row) {
- Some(ScrollbarClickTarget::Thumb { grab_row_offset })
- } else {
- Some(ScrollbarClickTarget::Track {
- offset_from_bottom: crate::ui::scrollbar_offset_from_row(metrics, track, row),
- })
- }
- }
-
- pub(super) fn agent_panel_offset_for_drag_row(
- &self,
- row: u16,
- grab_row_offset: u16,
- ) -> Option<usize> {
- let area = self.agent_panel_rect();
- let metrics = crate::ui::agent_panel_scroll_metrics(self, area);
- let track = crate::ui::agent_panel_scrollbar_rect(self, area)?;
- Some(crate::ui::scrollbar_offset_from_drag_row(
- metrics,
- track,
- row,
- grab_row_offset,
- ))
- }
-
- pub(super) fn set_agent_panel_offset_from_bottom(&mut self, offset_from_bottom: usize) {
- let area = self.agent_panel_rect();
- let metrics = crate::ui::agent_panel_scroll_metrics(self, area);
- self.agent_panel_scroll = metrics
- .max_offset_from_bottom
- .saturating_sub(offset_from_bottom);
- }
-
- pub(super) fn scroll_agent_panel(&mut self, delta: i16) {
- let area = self.agent_panel_rect();
- let max_scroll = crate::ui::agent_panel_scroll_metrics(self, area).max_offset_from_bottom;
- if delta.is_negative() {
- self.agent_panel_scroll = self
- .agent_panel_scroll
- .saturating_sub(delta.unsigned_abs() as usize);
- } else {
- self.agent_panel_scroll = self
- .agent_panel_scroll
- .saturating_add(delta as usize)
- .min(max_scroll);
- }
- }
-
- pub(crate) fn sidebar_footer_rect(&self) -> Rect {
- let ws_area = self.workspace_list_rect();
- if ws_area == Rect::default() {
- return Rect::default();
- }
- let y = ws_area.y + ws_area.height.saturating_sub(1);
- Rect::new(ws_area.x, y, ws_area.width, 1)
- }
-
- pub(crate) fn sidebar_new_button_rect(&self) -> Rect {
- let footer = self.sidebar_footer_rect();
- let width = 5u16.min(footer.width.max(1));
- Rect::new(footer.x, footer.y, width, footer.height)
- }
-
- pub(crate) fn global_launcher_rect(&self) -> Rect {
- if self.view.layout == ViewLayout::Mobile {
- return self.view.mobile_menu_hit_area;
- }
-
- let footer = self.sidebar_footer_rect();
- let width = if self.global_menu_attention_badge_visible() {
- 8
- } else {
- 6
- }
- .min(footer.width.max(1));
- let x = footer.x + footer.width.saturating_sub(width);
- Rect::new(x, footer.y, width, footer.height)
- }
-
- pub(crate) fn global_menu_labels(&self) -> Vec<&'static str> {
- let mut labels = vec!["settings", "keybinds", "reload config"];
- if self.update_available.is_some() {
- labels.push("update ready");
- } else if self.latest_release_notes_available {
- labels.push("what's new");
- }
- labels.push("detach");
- labels
- }
-
- pub(crate) fn global_menu_rect(&self) -> Rect {
- let screen = self.screen_rect();
- let launcher = self.global_launcher_rect();
- let labels = self.global_menu_labels();
- let content_width = labels
- .iter()
- .map(|label| {
- let badge_width = if self.global_menu_item_has_badge(label) {
- 2
- } else {
- 0
- };
- label.chars().count() as u16 + badge_width
- })
- .max()
- .unwrap_or(8)
- .saturating_add(2);
- let menu_w = content_width.saturating_add(2).min(screen.width.max(1));
- let menu_h = (labels.len() as u16 + 2).min(screen.height.max(1));
- let max_x = screen.x + screen.width.saturating_sub(menu_w);
- let desired_x = launcher.x + launcher.width.saturating_sub(menu_w);
- let x = desired_x.min(max_x);
- let y = launcher.y.saturating_sub(menu_h);
- Rect::new(x, y, menu_w, menu_h)
- }
-
- pub(super) fn on_sidebar_divider(&self, col: u16, row: u16) -> bool {
- if self.sidebar_collapsed {
- return false;
- }
- let sidebar = self.view.sidebar_rect;
- let toggle = crate::ui::expanded_sidebar_toggle_rect(sidebar);
- let on_toggle = toggle.width > 0
- && col >= toggle.x
- && col < toggle.x + toggle.width
- && row >= toggle.y
- && row < toggle.y + toggle.height;
- sidebar.width > 0
- && !on_toggle
- && col == sidebar.x + sidebar.width.saturating_sub(1)
- && row >= sidebar.y
- && row < sidebar.y + sidebar.height
- }
-
- pub(super) fn on_sidebar_toggle(&self, col: u16, row: u16) -> bool {
- let rect = if self.sidebar_collapsed {
- crate::ui::collapsed_sidebar_toggle_rect(self.view.sidebar_rect)
- } else {
- crate::ui::expanded_sidebar_toggle_rect(self.view.sidebar_rect)
- };
- rect.width > 0
- && col >= rect.x
- && col < rect.x + rect.width
- && row >= rect.y
- && row < rect.y + rect.height
- }
-
- pub(super) fn set_manual_sidebar_width(&mut self, divider_col: u16) {
- let sidebar = self.view.sidebar_rect;
- let width = divider_col.saturating_sub(sidebar.x).saturating_add(1);
- self.sidebar_width = width.clamp(self.sidebar_min_width, self.sidebar_max_width);
- self.sidebar_width_source = crate::app::state::SidebarWidthSource::Manual;
- self.mark_session_dirty();
- }
-
- pub(super) fn on_sidebar_section_divider(&self, col: u16, row: u16) -> bool {
- if self.sidebar_collapsed {
- return false;
- }
- let rect = crate::ui::sidebar_section_divider_rect(
- self.view.sidebar_rect,
- self.sidebar_section_split,
- );
- rect.width > 0
- && col >= rect.x
- && col < rect.x + rect.width
- && row >= rect.y
- && row < rect.y + rect.height
- }
-
- pub(super) fn set_sidebar_section_split(&mut self, row: u16) {
- let sidebar = self.view.sidebar_rect;
- let content_height = sidebar.height;
- if content_height < 6 {
- return;
- }
- let relative_y = row.saturating_sub(sidebar.y);
- let ratio = (relative_y as f32) / (content_height as f32);
- self.sidebar_section_split = ratio.clamp(0.1, 0.9);
- self.mark_session_dirty();
- }
-
- pub(super) fn workspace_at_row(&self, row: u16) -> Option<usize> {
- let footer = self.sidebar_footer_rect();
- if footer == Rect::default() {
- return None;
- }
-
- let cards = if self.view.workspace_card_areas.is_empty() {
- crate::ui::compute_workspace_card_areas(self, self.view.sidebar_rect)
- } else {
- self.view.workspace_card_areas.clone()
- };
-
- cards.iter().find_map(|card| {
- (row >= card.rect.y && row < card.rect.y + card.rect.height).then_some(card.ws_idx)
- })
- }
-
- pub(super) fn collapsed_workspace_at_row(&self, row: u16) -> Option<usize> {
- if !self.sidebar_collapsed {
- return None;
- }
-
- let (ws_area, _, _) = crate::ui::collapsed_sidebar_sections(self.view.sidebar_rect);
- if ws_area == Rect::default() || row < ws_area.y || row >= ws_area.y + ws_area.height {
- return None;
- }
-
- let idx = (row - ws_area.y) as usize;
- (idx < self.workspaces.len()).then_some(idx)
- }
-
- pub(super) fn collapsed_agent_detail_target_at(
- &self,
- row: u16,
- ) -> Option<(usize, usize, crate::layout::PaneId)> {
- if !self.sidebar_collapsed {
- return None;
- }
-
- let (_, _, detail_area) = crate::ui::collapsed_sidebar_sections(self.view.sidebar_rect);
- let detail_content_area = Rect::new(
- detail_area.x,
- detail_area.y,
- detail_area.width,
- detail_area.height.saturating_sub(1),
- );
- if detail_content_area == Rect::default()
- || row < detail_content_area.y
- || row >= detail_content_area.y + detail_content_area.height
- {
- return None;
- }
-
- let detail_idx = (row - detail_content_area.y) as usize;
- let details = crate::ui::agent_panel_entries(self);
- let detail = details.get(detail_idx)?;
- Some((detail.ws_idx, detail.tab_idx, detail.pane_id))
- }
-
- pub(super) fn workspace_drop_target_at_row(
- &self,
- row: u16,
- ) -> Option<crate::app::state::WorkspaceDropTarget> {
- let area = self.workspace_list_rect();
- let footer = self.sidebar_footer_rect();
- if area == Rect::default() || row < area.y || row >= footer.y {
- return None;
- }
-
- let cards = if self.view.workspace_card_areas.is_empty() {
- crate::ui::compute_workspace_card_areas(self, self.view.sidebar_rect)
- } else {
- self.view.workspace_card_areas.clone()
- };
- crate::ui::workspace_drop_slots(self, &cards, area)
- .into_iter()
- .enumerate()
- .min_by_key(|(slot_idx, (_, slot_row))| (row.abs_diff(*slot_row), *slot_idx))
- .map(|(_, (target, _))| target)
- }
-
- pub(super) fn workspace_move_block_params(
- &self,
- source_ws_idx: usize,
- drop_target: crate::app::state::WorkspaceDropTarget,
- ) -> Option<crate::api::schema::WorkspaceMoveBlockParams> {
- let source = self.workspaces.get(source_ws_idx)?;
- if source
- .worktree_space()
- .is_some_and(|space| space.is_linked_worktree)
- {
- return None;
- }
-
- let roots = crate::ui::workspace_list_entries_expanded(self)
- .into_iter()
- .filter_map(|entry| match entry {
- crate::ui::WorkspaceListEntry::Workspace {
- ws_idx,
- indented: false,
- } => Some(ws_idx),
- crate::ui::WorkspaceListEntry::Workspace { .. } => None,
- })
- .collect::<Vec<_>>();
- let source_pos = roots.iter().position(|ws_idx| *ws_idx == source_ws_idx)?;
- let remaining_roots = roots
- .iter()
- .copied()
- .filter(|ws_idx| *ws_idx != source_ws_idx)
- .collect::<Vec<_>>();
- let insert_pos = match drop_target {
- crate::app::state::WorkspaceDropTarget::Before(target_ws_idx) => remaining_roots
- .iter()
- .position(|ws_idx| *ws_idx == target_ws_idx)?,
- crate::app::state::WorkspaceDropTarget::End => remaining_roots.len(),
- };
- if insert_pos == source_pos {
- return None;
- }
-
- let workspace_ids = match source.worktree_space() {
- Some(source_space) => {
- let mut ids = vec![source.id.clone()];
- ids.extend(
- self.workspaces
- .iter()
- .filter(|workspace| workspace.id != source.id)
- .filter(|workspace| {
- workspace
- .worktree_space()
- .is_some_and(|space| space.key == source_space.key)
- })
- .map(|workspace| workspace.id.clone()),
- );
- ids
- }
- None => vec![source.id.clone()],
- };
- let before_workspace_id = match drop_target {
- crate::app::state::WorkspaceDropTarget::Before(target_ws_idx) => {
- let target = self.workspaces.get(target_ws_idx)?;
- let anchor = match crate::ui::workspace_parent_group_state(self, target_ws_idx)
- .and_then(|_| target.worktree_space())
- {
- Some(target_space) => self
- .workspaces
- .iter()
- .find(|workspace| {
- workspace
- .worktree_space()
- .is_some_and(|space| space.key == target_space.key)
- })
- .unwrap_or(target),
- None => target,
- };
- Some(anchor.id.clone())
- }
- crate::app::state::WorkspaceDropTarget::End => None,
- };
-
- Some(crate::api::schema::WorkspaceMoveBlockParams {
- workspace_ids,
- before_workspace_id,
- })
- }
-
- pub(super) fn on_agent_panel_sort_toggle(&self, col: u16, row: u16) -> bool {
- if self.sidebar_collapsed || self.agent_view_override.is_some() {
- return false;
- }
-
- let (_, detail_area) = crate::ui::expanded_sidebar_sections(
- self.view.sidebar_rect,
- self.sidebar_section_split,
- );
- let rect = crate::ui::agent_panel_toggle_rect(detail_area, self.agent_panel_sort);
- rect.width > 0
- && col >= rect.x
- && col < rect.x + rect.width
- && row >= rect.y
- && row < rect.y + rect.height
- }
-
- pub(super) fn agent_detail_target_at(
- &self,
- row: u16,
- ) -> Option<(usize, usize, crate::layout::PaneId)> {
- if self.sidebar_collapsed {
- return None;
- }
-
- let detail_area = self.agent_panel_rect();
- let metrics = crate::ui::agent_panel_scroll_metrics(self, detail_area);
- let body = crate::ui::agent_panel_body_rect(
- detail_area,
- crate::ui::should_show_scrollbar(metrics),
- );
- if body.height == 0 || row < body.y || row >= body.y + body.height {
- return None;
- }
-
- let mut row_y = body.y;
- let body_bottom = body.y + body.height;
- let entries = crate::ui::agent_panel_entries(self);
- let scroll = self.agent_panel_scroll.min(metrics.max_offset_from_bottom);
- for (index, detail) in entries.iter().enumerate().skip(scroll) {
- let height = crate::ui::agent_entry_height_in_body(self, detail, body.height);
- if row_y.saturating_add(height) > body_bottom {
- break;
- }
- if row >= row_y && row < row_y.saturating_add(height) {
- return Some((detail.ws_idx, detail.tab_idx, detail.pane_id));
- }
- row_y = row_y
- .saturating_add(height)
- .saturating_add(crate::ui::agent_entry_gap(self, index, entries.len()))
- .min(body_bottom);
- }
- None
- }
-}
-
-#[cfg(test)]
-mod tests {
- use std::fs;
-
- use crossterm::event::{MouseButton, MouseEventKind};
- use ratatui::layout::Rect;
-
- use super::super::{app_for_mouse_test, capture_snapshot, mouse, unique_temp_path};
- use crate::{
- app::state::{AgentPanelSort, DragTarget, Mode},
- config::SidebarCollapsedModeConfig,
- detect::{Agent, AgentState},
- workspace::Workspace,
- };
-
- #[test]
- fn clicking_launcher_opens_global_menu() {
- let mut app = app_for_mouse_test();
- let rect = app.state.global_launcher_rect();
-
- app.handle_mouse(mouse(
- MouseEventKind::Down(MouseButton::Left),
- rect.x + rect.width.saturating_sub(1),
- rect.y,
- ));
-
- assert_eq!(app.state.mode, Mode::GlobalMenu);
- }
-
- #[test]
- fn hovering_global_menu_updates_highlight() {
- let mut app = app_for_mouse_test();
- let launcher = app.state.global_launcher_rect();
- app.handle_mouse(mouse(
- MouseEventKind::Down(MouseButton::Left),
- launcher.x,
- launcher.y,
- ));
-
- let menu = app.state.global_menu_rect();
- app.handle_mouse(mouse(MouseEventKind::Moved, menu.x + 2, menu.y + 2));
-
- assert_eq!(app.state.global_menu.highlighted, 1);
- }
-
- #[test]
- fn clicking_keybinds_menu_item_opens_help() {
- let mut app = app_for_mouse_test();
- let launcher = app.state.global_launcher_rect();
- app.handle_mouse(mouse(
- MouseEventKind::Down(MouseButton::Left),
- launcher.x,
- launcher.y,
- ));
-
- let menu = app.state.global_menu_rect();
- app.handle_mouse(mouse(
- MouseEventKind::Down(MouseButton::Left),
- menu.x + 2,
- menu.y + 2,
- ));
-
- assert_eq!(app.state.mode, Mode::KeybindHelp);
- }
-
- #[test]
- fn clicking_settings_menu_item_opens_settings() {
- let mut app = app_for_mouse_test();
- let launcher = app.state.global_launcher_rect();
- app.handle_mouse(mouse(
- MouseEventKind::Down(MouseButton::Left),
- launcher.x,
- launcher.y,
- ));
-
- let menu = app.state.global_menu_rect();
- app.handle_mouse(mouse(
- MouseEventKind::Down(MouseButton::Left),
- menu.x + 2,
- menu.y + 1,
- ));
-
- assert_eq!(app.state.mode, Mode::Settings);
- }
-
- #[test]
- fn clicking_reload_config_menu_item_requests_reload() {
- let mut app = app_for_mouse_test();
- let launcher = app.state.global_launcher_rect();
- app.handle_mouse(mouse(
- MouseEventKind::Down(MouseButton::Left),
- launcher.x,
- launcher.y,
- ));
-
- let menu = app.state.global_menu_rect();
- app.handle_mouse(mouse(
- MouseEventKind::Down(MouseButton::Left),
- menu.x + 2,
- menu.y + 3,
- ));
-
- assert!(app.state.request_reload_config);
- assert_eq!(app.state.mode, Mode::Navigate);
- }
-
- #[test]
- fn update_pending_menu_surfaces_update_ready_entry() {
- let mut app = app_for_mouse_test();
- app.state.update_available = Some("0.3.2".into());
- app.state.latest_release_notes_available = true;
-
- let launcher = app.state.global_launcher_rect();
- app.handle_mouse(mouse(
- MouseEventKind::Down(MouseButton::Left),
- launcher.x,
- launcher.y,
- ));
-
- assert_eq!(
- app.state.global_menu_labels(),
- vec![
- "settings",
- "keybinds",
- "reload config",
- "update ready",
- "detach"
- ]
- );
- assert!(!app.state.should_quit);
- }
-
- #[test]
- fn persistence_mode_menu_surfaces_detach_action() {
- let mut app = app_for_mouse_test();
- app.state.detach_exits = false;
-
- let launcher = app.state.global_launcher_rect();
- app.handle_mouse(mouse(
- MouseEventKind::Down(MouseButton::Left),
- launcher.x,
- launcher.y,
- ));
-
- assert_eq!(
- app.state.global_menu_labels(),
- vec!["settings", "keybinds", "reload config", "detach"]
- );
-
- let menu = app.state.global_menu_rect();
- app.handle_mouse(mouse(
- MouseEventKind::Down(MouseButton::Left),
- menu.x + 2,
- menu.y + 4,
- ));
-
- assert!(app.state.detach_requested);
- assert!(!app.state.should_quit);
- assert_ne!(app.state.mode, Mode::GlobalMenu);
- }
-
- #[test]
- fn whats_new_remains_in_menu_for_latest_installed_release_notes() {
- let mut app = app_for_mouse_test();
- app.state.latest_release_notes_available = true;
-
- assert_eq!(
- app.state.global_menu_labels(),
- vec![
- "settings",
- "keybinds",
- "reload config",
- "what's new",
- "detach"
- ]
- );
- }
-
- #[test]
- fn clicking_agent_detail_row_switches_to_correct_tab_and_pane() {
- let mut app = app_for_mouse_test();
- let mut ws = Workspace::test_new("test");
- ws.tabs[0].set_custom_name("main".into());
- let first_pane = ws.tabs[0].root_pane;
- let first_tab = ws.test_add_tab(Some("logs"));
- let second_pane = ws.tabs[first_tab].root_pane;
- app.state.workspaces = vec![ws];
- app.state.ensure_test_terminals();
- let first_terminal_id = app.state.workspaces[0].tabs[0].panes[&first_pane]
- .attached_terminal_id
- .clone();
- app.state
- .terminals
- .get_mut(&first_terminal_id)
- .unwrap()
- .detected_agent = Some(Agent::Pi);
- let second_terminal_id = app.state.workspaces[0].tabs[first_tab].panes[&second_pane]
- .attached_terminal_id
- .clone();
- app.state
- .terminals
- .get_mut(&second_terminal_id)
- .unwrap()
- .detected_agent = Some(Agent::Claude);
- app.state.active = Some(0);
- app.state.selected = 0;
- app.state.mode = Mode::Terminal;
-
- app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 2, 16));
-
- assert_eq!(app.state.workspaces[0].active_tab, 1);
- assert_eq!(
- app.state.workspaces[0].tabs[1].layout.focused(),
- second_pane
- );
- assert_eq!(app.state.mode, Mode::Terminal);
- let snapshot = capture_snapshot(&app.state);
- assert_eq!(snapshot.workspaces[0].active_tab, first_tab);
- assert_eq!(
- snapshot.workspaces[0].tabs[first_tab].focused,
- Some(second_pane.raw())
- );
- }
-
- #[test]
- fn per_agent_row_heights_preserve_card_gaps_and_trailing_mouse_targets() {
- let mut app = app_for_mouse_test();
- let first = Workspace::test_new("one");
- let first_pane = first.tabs[0].root_pane;
- let second = Workspace::test_new("two");
- let second_pane = second.tabs[0].root_pane;
- app.state.workspaces = vec![first, second];
- app.state.ensure_test_terminals();
- for (ws_idx, pane_id, agent) in
- [(0, first_pane, Agent::Pi), (1, second_pane, Agent::Claude)]
- {
- let terminal_id = app.state.workspaces[ws_idx].tabs[0].panes[&pane_id]
- .attached_terminal_id
- .clone();
- app.state
- .terminals
- .get_mut(&terminal_id)
- .unwrap()
- .detected_agent = Some(agent);
- }
- app.state.sidebar_agents.rows = vec![vec![crate::config::AgentSidebarToken::Agent]];
- app.state.sidebar_agents.rows_by_agent.insert(
- "claude".into(),
- vec![
- vec![crate::config::AgentSidebarToken::Agent],
- vec![crate::config::AgentSidebarToken::Workspace],
- ],
- );
- app.state.sidebar_agents.row_gap = 1;
- let detail_area = app.state.agent_panel_rect();
- let metrics = crate::ui::agent_panel_scroll_metrics(&app.state, detail_area);
- let body = crate::ui::agent_panel_body_rect(
- detail_area,
- crate::ui::should_show_scrollbar(metrics),
- );
-
- assert_eq!(
- app.state.agent_detail_target_at(body.y),
- Some((0, 0, first_pane))
- );
- assert_eq!(app.state.agent_detail_target_at(body.y + 1), None);
- assert_eq!(
- app.state.agent_detail_target_at(body.y + 3),
- Some((1, 0, second_pane))
- );
-
- app.state.sidebar_agents.row_gap = 0;
- assert_eq!(
- app.state.agent_detail_target_at(body.y + 1),
- Some((1, 0, second_pane))
- );
- }
-
- #[test]
- fn agent_hit_testing_clamps_scroll_after_dynamic_filter_shrink() {
- let mut app = app_for_mouse_test();
- let first = Workspace::test_new("one");
- let first_pane = first.tabs[0].root_pane;
- let second = Workspace::test_new("two");
- let second_pane = second.tabs[0].root_pane;
- app.state.workspaces = vec![first, second];
- app.state.ensure_test_terminals();
- app.state.active = Some(0);
- app.state.selected = 0;
- for (ws_idx, pane_id) in [(0, first_pane), (1, second_pane)] {
- let terminal_id = app.state.workspaces[ws_idx].tabs[0].panes[&pane_id]
- .attached_terminal_id
- .clone();
- app.state
- .terminals
- .get_mut(&terminal_id)
- .unwrap()
- .detected_agent = Some(Agent::Claude);
- }
- app.state.agent_view_override = Some(crate::api::schema::AgentViewSetParams {
- source: "example.views".to_string(),
- label: None,
- filter: Some(crate::api::schema::AgentViewFilter::Eq {
- field: crate::api::schema::AgentViewField::Builtin(
- crate::api::schema::AgentViewBuiltinField::WorkspaceId,
- ),
- value: crate::api::schema::AgentViewValue::Context {
- context: crate::api::schema::AgentViewContext::CurrentWorkspaceId,
- },
- }),
- sort: Vec::new(),
- });
- app.state.agent_panel_scroll = 10;
- let detail_area = app.state.agent_panel_rect();
- let body = crate::ui::agent_panel_body_rect(detail_area, false);
-
- assert_eq!(
- app.state.agent_detail_target_at(body.y),
- Some((0, 0, first_pane))
- );
- }
-
- #[test]
- fn clicking_agent_panel_toggle_switches_sort() {
- let mut app = app_for_mouse_test();
- app.state.workspaces = vec![Workspace::test_new("test")];
- app.state.active = Some(0);
- app.state.selected = 0;
- app.state.mode = Mode::Terminal;
- app.state.agent_panel_scroll = 3;
-
- let (_, detail_area) = crate::ui::expanded_sidebar_sections(
- app.state.view.sidebar_rect,
- app.state.sidebar_section_split,
- );
- let toggle = crate::ui::agent_panel_toggle_rect(detail_area, app.state.agent_panel_sort);
- app.handle_mouse(mouse(
- MouseEventKind::Down(MouseButton::Left),
- toggle.x,
- toggle.y,
- ));
-
- assert_eq!(app.state.agent_panel_sort, AgentPanelSort::Priority);
- assert_eq!(app.state.agent_panel_scroll, 0);
- }
-
- #[test]
- fn clicking_all_workspaces_agent_row_switches_to_correct_workspace() {
- let mut app = app_for_mouse_test();
- let first = Workspace::test_new("one");
- let first_pane = first.tabs[0].root_pane;
-
- let second = Workspace::test_new("two");
- let second_pane = second.tabs[0].root_pane;
-
- app.state.workspaces = vec![first, second];
- app.state.ensure_test_terminals();
- let first_terminal_id = app.state.workspaces[0].tabs[0].panes[&first_pane]
- .attached_terminal_id
- .clone();
- app.state
- .terminals
- .get_mut(&first_terminal_id)
- .unwrap()
- .detected_agent = Some(Agent::Pi);
- let second_terminal_id = app.state.workspaces[1].tabs[0].panes[&second_pane]
- .attached_terminal_id
- .clone();
- app.state
- .terminals
- .get_mut(&second_terminal_id)
- .unwrap()
- .detected_agent = Some(Agent::Claude);
- app.state.active = Some(0);
- app.state.selected = 0;
- app.state.mode = Mode::Terminal;
-
- let (_, detail_area) = crate::ui::expanded_sidebar_sections(
- app.state.view.sidebar_rect,
- app.state.sidebar_section_split,
- );
- app.handle_mouse(mouse(
- MouseEventKind::Down(MouseButton::Left),
- detail_area.x + 2,
- detail_area.y + 6,
- ));
-
- assert_eq!(app.state.active, Some(1));
- assert_eq!(app.state.selected, 1);
- assert_eq!(app.state.workspaces[1].active_tab, 0);
- assert_eq!(
- app.state.workspaces[1].tabs[0].layout.focused(),
- second_pane
- );
- }
-
- #[test]
- fn scrolling_agent_panel_with_wheel_updates_agent_panel_scroll() {
- let mut app = app_for_mouse_test();
- let mut ws = Workspace::test_new("test");
- let first_pane = ws.tabs[0].root_pane;
-
- let mut tabs = Vec::new();
- for (tab_name, agent) in [
- ("logs", Agent::Claude),
- ("review", Agent::Codex),
- ("ops", Agent::Gemini),
- ] {
- let tab_idx = ws.test_add_tab(Some(tab_name));
- let pane_id = ws.tabs[tab_idx].root_pane;
- tabs.push((tab_idx, pane_id, agent));
- }
-
- app.state.workspaces = vec![ws];
- app.state.ensure_test_terminals();
- let first_terminal_id = app.state.workspaces[0].tabs[0].panes[&first_pane]
- .attached_terminal_id
- .clone();
- app.state
- .terminals
- .get_mut(&first_terminal_id)
- .unwrap()
- .detected_agent = Some(Agent::Pi);
- for (tab_idx, pane_id, agent) in tabs {
- let terminal_id = app.state.workspaces[0].tabs[tab_idx].panes[&pane_id]
- .attached_terminal_id
- .clone();
- app.state
- .terminals
- .get_mut(&terminal_id)
- .unwrap()
- .detected_agent = Some(agent);
- }
- app.state.active = Some(0);
- app.state.selected = 0;
- app.state.mode = Mode::Terminal;
-
- let detail_area = app.state.agent_panel_rect();
- assert!(crate::ui::should_show_scrollbar(
- crate::ui::agent_panel_scroll_metrics(&app.state, detail_area)
- ));
-
- app.handle_mouse(mouse(
- MouseEventKind::ScrollDown,
- detail_area.x + 1,
- detail_area.y + 4,
- ));
-
- assert_eq!(app.state.agent_panel_scroll, 1);
- assert_eq!(app.state.selected, 0);
- }
-
- #[test]
- fn clicking_scrolled_agent_detail_row_switches_to_correct_tab_and_pane() {
- let mut app = app_for_mouse_test();
- let mut ws = Workspace::test_new("test");
- let first_pane = ws.tabs[0].root_pane;
- let second_tab = ws.test_add_tab(Some("logs"));
- let second_pane = ws.tabs[second_tab].root_pane;
- let mut extra_tabs = Vec::new();
- for (tab_name, agent) in [("review", Agent::Codex), ("ops", Agent::Gemini)] {
- let tab_idx = ws.test_add_tab(Some(tab_name));
- let pane_id = ws.tabs[tab_idx].root_pane;
- extra_tabs.push((tab_idx, pane_id, agent));
- }
-
- app.state.workspaces = vec![ws];
- app.state.ensure_test_terminals();
- let first_terminal_id = app.state.workspaces[0].tabs[0].panes[&first_pane]
- .attached_terminal_id
- .clone();
- app.state
- .terminals
- .get_mut(&first_terminal_id)
- .unwrap()
- .detected_agent = Some(Agent::Pi);
- let second_terminal_id = app.state.workspaces[0].tabs[second_tab].panes[&second_pane]
- .attached_terminal_id
- .clone();
- app.state
- .terminals
- .get_mut(&second_terminal_id)
- .unwrap()
- .detected_agent = Some(Agent::Claude);
- for (tab_idx, pane_id, agent) in extra_tabs {
- let terminal_id = app.state.workspaces[0].tabs[tab_idx].panes[&pane_id]
- .attached_terminal_id
- .clone();
- app.state
- .terminals
- .get_mut(&terminal_id)
- .unwrap()
- .detected_agent = Some(agent);
- }
- app.state.active = Some(0);
- app.state.selected = 0;
- app.state.mode = Mode::Terminal;
- app.state.sidebar_agents.rows = vec![vec![crate::config::AgentSidebarToken::Agent]];
- app.state.sidebar_agents.rows_by_agent.insert(
- "claude".into(),
- vec![
- vec![crate::config::AgentSidebarToken::Agent],
- vec![crate::config::AgentSidebarToken::Workspace],
- ],
- );
- app.state.agent_panel_scroll = 1;
-
- let detail_area = app.state.agent_panel_rect();
- let body = crate::ui::agent_panel_body_rect(detail_area, true);
- app.handle_mouse(mouse(
- MouseEventKind::Down(MouseButton::Left),
- body.x + 1,
- body.y + 1,
- ));
-
- assert_eq!(app.state.workspaces[0].active_tab, second_tab);
- assert_eq!(
- app.state.workspaces[0].tabs[second_tab].layout.focused(),
- second_pane
- );
- assert_eq!(app.state.mode, Mode::Terminal);
- }
-
- #[test]
- fn clicking_collapsed_agent_row_switches_to_correct_tab_and_pane() {
- let mut app = app_for_mouse_test();
- let mut ws = Workspace::test_new("test");
- let first_pane = ws.tabs[0].root_pane;
- let second_tab = ws.test_add_tab(Some("logs"));
- let second_pane = ws.tabs[second_tab].root_pane;
- app.state.workspaces = vec![ws];
- app.state.ensure_test_terminals();
- let first_terminal_id = app.state.workspaces[0].tabs[0].panes[&first_pane]
- .attached_terminal_id
- .clone();
- app.state
- .terminals
- .get_mut(&first_terminal_id)
- .unwrap()
- .detected_agent = Some(Agent::Pi);
- let second_terminal_id = app.state.workspaces[0].tabs[second_tab].panes[&second_pane]
- .attached_terminal_id
- .clone();
- app.state
- .terminals
- .get_mut(&second_terminal_id)
- .unwrap()
- .detected_agent = Some(Agent::Claude);
- app.state.active = Some(0);
- app.state.selected = 0;
- app.state.mode = Mode::Terminal;
- app.state.sidebar_collapsed = true;
- app.state.view.sidebar_rect = Rect::new(0, 0, 4, 20);
- app.state.view.terminal_area = Rect::new(4, 0, 80, 20);
-
- let (_, _, detail_area) =
- crate::ui::collapsed_sidebar_sections(app.state.view.sidebar_rect);
- app.handle_mouse(mouse(
- MouseEventKind::Down(MouseButton::Left),
- detail_area.x,
- detail_area.y + 1,
- ));
-
- assert_eq!(app.state.workspaces[0].active_tab, 1);
- assert_eq!(
- app.state.workspaces[0].tabs[1].layout.focused(),
- second_pane
- );
- assert_eq!(app.state.mode, Mode::Terminal);
- }
-
- #[test]
- fn clicking_collapsed_priority_agent_row_switches_to_matching_workspace() {
- let mut app = app_for_mouse_test();
- let first = Workspace::test_new("one");
- let first_pane = first.tabs[0].root_pane;
- let second = Workspace::test_new("two");
- let second_pane = second.tabs[0].root_pane;
-
- app.state.workspaces = vec![first, second];
- app.state.ensure_test_terminals();
- app.state.active = Some(0);
- app.state.selected = 0;
- app.state.mode = Mode::Terminal;
- app.state.sidebar_collapsed = true;
- app.state.agent_panel_sort = AgentPanelSort::Priority;
- app.state.view.sidebar_rect = Rect::new(0, 0, 4, 20);
- app.state.view.terminal_area = Rect::new(4, 0, 80, 20);
-
- let set_state = |app: &mut crate::app::App, ws_idx: usize, pane_id, state| {
- let terminal_id = app.state.workspaces[ws_idx].tabs[0].panes[&pane_id]
- .attached_terminal_id
- .clone();
- let terminal = app.state.terminals.get_mut(&terminal_id).unwrap();
- terminal.detected_agent = Some(Agent::Claude);
- terminal.state = state;
- };
- set_state(&mut app, 0, first_pane, AgentState::Working);
- set_state(&mut app, 1, second_pane, AgentState::Blocked);
-
- let (_, _, detail_area) =
- crate::ui::collapsed_sidebar_sections(app.state.view.sidebar_rect);
- app.handle_mouse(mouse(
- MouseEventKind::Down(MouseButton::Left),
- detail_area.x,
- detail_area.y,
- ));
-
- assert_eq!(app.state.active, Some(1));
- assert_eq!(app.state.selected, 1);
- assert_eq!(
- app.state.workspaces[1].tabs[0].layout.focused(),
- second_pane
- );
- }
-
- #[test]
- fn clicking_collapsed_sidebar_toggle_expands_sidebar() {
- let mut app = app_for_mouse_test();
- app.state.sidebar_collapsed = true;
- app.state.view.sidebar_rect = Rect::new(0, 0, 4, 20);
- app.state.view.terminal_area = Rect::new(4, 0, 80, 20);
-
- let toggle = crate::ui::collapsed_sidebar_toggle_rect(app.state.view.sidebar_rect);
- app.handle_mouse(mouse(
- MouseEventKind::Down(MouseButton::Left),
- toggle.x,
- toggle.y,
- ));
-
- assert!(!app.state.sidebar_collapsed);
- }
-
- #[test]
- fn hidden_collapsed_sidebar_has_no_mouse_expand_hotspot() {
- let mut app = app_for_mouse_test();
- app.state.sidebar_collapsed = true;
- app.state.sidebar_collapsed_mode = SidebarCollapsedModeConfig::Hidden;
- app.state.view.sidebar_rect = Rect::new(0, 0, 0, 20);
- app.state.view.terminal_area = Rect::new(0, 0, 80, 20);
-
- app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 0, 19));
-
- assert!(app.state.sidebar_collapsed);
- }
-
- #[test]
- fn clicking_expanded_sidebar_toggle_collapses_sidebar() {
- let mut app = app_for_mouse_test();
- app.state.sidebar_collapsed = false;
- app.state.view.sidebar_rect = Rect::new(0, 0, 26, 20);
- app.state.view.terminal_area = Rect::new(26, 0, 80, 20);
-
- let toggle = crate::ui::expanded_sidebar_toggle_rect(app.state.view.sidebar_rect);
- app.handle_mouse(mouse(
- MouseEventKind::Down(MouseButton::Left),
- toggle.x,
- toggle.y,
- ));
-
- assert!(app.state.sidebar_collapsed);
- assert!(app.state.drag.is_none());
- }
-
- #[test]
- fn clicking_workspace_switches_on_mouse_up() {
- let mut app = app_for_mouse_test();
- app.state.workspaces = vec![Workspace::test_new("a"), Workspace::test_new("b")];
- app.state.active = Some(0);
- app.state.selected = 0;
- crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20));
- let target_row = app.state.view.workspace_card_areas[1].rect.y;
-
- app.handle_mouse(mouse(
- MouseEventKind::Down(MouseButton::Left),
- 2,
- target_row,
- ));
- assert_eq!(app.state.active, Some(0));
- assert_eq!(app.state.workspace_presses.len(), 1);
-
- app.handle_mouse(mouse(MouseEventKind::Up(MouseButton::Left), 2, target_row));
- assert_eq!(app.state.active, Some(1));
- assert_eq!(app.state.selected, 1);
- assert!(app.state.workspace_presses.is_empty());
- let snapshot = capture_snapshot(&app.state);
- assert_eq!(snapshot.active, Some(1));
- assert_eq!(snapshot.selected, 1);
- }
-
- #[test]
- fn clicking_worktree_parent_row_focuses_workspace_without_toggling() {
- let mut app = app_for_mouse_test();
- app.state.workspaces = vec![Workspace::test_new("main"), Workspace::test_new("issue")];
- for (idx, checkout_path) in ["/repo/herdr", "/repo/herdr-issue"].into_iter().enumerate() {
- app.state.workspaces[idx].worktree_space =
- Some(crate::workspace::WorktreeSpaceMembership {
- key: "repo-key".into(),
- label: "herdr".into(),
- repo_root: "/repo/herdr".into(),
- checkout_path: checkout_path.into(),
- is_linked_worktree: idx > 0,
- });
- }
- app.state.active = None;
- app.state.mode = Mode::Terminal;
- crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20));
- let parent = app.state.view.workspace_card_areas[0].rect;
-
- app.handle_mouse(mouse(
- MouseEventKind::Down(MouseButton::Left),
- parent.x + 2,
- parent.y,
- ));
- app.handle_mouse(mouse(
- MouseEventKind::Up(MouseButton::Left),
- parent.x + 2,
- parent.y,
- ));
-
- assert_eq!(app.state.active, Some(0));
- assert!(!app.state.collapsed_space_keys.contains("repo-key"));
- }
-
- #[test]
- fn clicking_worktree_parent_chevron_toggles_group_only() {
- let mut app = app_for_mouse_test();
- app.state.workspaces = vec![Workspace::test_new("main"), Workspace::test_new("issue")];
- for (idx, checkout_path) in ["/repo/herdr", "/repo/herdr-issue"].into_iter().enumerate() {
- app.state.workspaces[idx].worktree_space =
- Some(crate::workspace::WorktreeSpaceMembership {
- key: "repo-key".into(),
- label: "herdr".into(),
- repo_root: "/repo/herdr".into(),
- checkout_path: checkout_path.into(),
- is_linked_worktree: idx > 0,
- });
- }
- app.state.active = None;
- app.state.mode = Mode::Terminal;
- crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20));
- let parent = app.state.view.workspace_card_areas[0];
- let chevron = crate::ui::workspace_group_chevron_rect(&parent);
-
- app.handle_mouse(mouse(
- MouseEventKind::Down(MouseButton::Left),
- chevron.x,
- chevron.y,
- ));
-
- assert_eq!(app.state.active, None);
- assert!(app.state.workspace_presses.is_empty());
- assert!(app.state.collapsed_space_keys.contains("repo-key"));
-
- app.handle_mouse(mouse(
- MouseEventKind::Down(MouseButton::Left),
- chevron.x,
- chevron.y,
- ));
-
- assert!(!app.state.collapsed_space_keys.contains("repo-key"));
- }
-
- #[test]
- fn wheel_workspace_selection_follows_grouped_visual_order_without_scrollbar() {
- let mut app = app_for_mouse_test();
- app.state.workspaces = vec![
- Workspace::test_new("main"),
- Workspace::test_new("normal"),
- Workspace::test_new("issue"),
- ];
- for (idx, checkout_path) in [(0, "/repo/herdr"), (2, "/repo/herdr-issue")] {
- app.state.workspaces[idx].worktree_space =
- Some(crate::workspace::WorktreeSpaceMembership {
- key: "repo-key".into(),
- label: "herdr".into(),
- repo_root: "/repo/herdr".into(),
- checkout_path: checkout_path.into(),
- is_linked_worktree: idx != 0,
- });
- }
- app.state.active = Some(0);
- app.state.selected = 0;
- app.state.mode = Mode::Navigate;
- crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 30));
- let list = app.state.workspace_list_rect();
- assert!(!crate::ui::should_show_scrollbar(
- crate::ui::workspace_list_scroll_metrics(&app.state, list)
- ));
-
- app.handle_mouse(mouse(MouseEventKind::ScrollDown, list.x + 1, list.y + 1));
-
- assert_eq!(app.state.selected, 2);
- }
-
- #[test]
- fn dragging_workspace_reorders_without_changing_identity() {
- let mut app = app_for_mouse_test();
- app.state.workspaces = vec![
- Workspace::test_new("a"),
- Workspace::test_new("b"),
- Workspace::test_new("c"),
- ];
- app.state.sidebar_spaces.rows = vec![vec![crate::config::SpaceSidebarToken::Workspace]];
- app.state.sidebar_spaces.row_gap = 0;
- let active_id = app.state.workspaces[1].id.clone();
- let selected_id = app.state.workspaces[2].id.clone();
- app.state.active = Some(1);
- app.state.selected = 2;
- crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20));
- let packed_boundary_row = app.state.view.workspace_card_areas[1].rect.y;
- assert_eq!(
- app.state.workspace_drop_target_at_row(packed_boundary_row),
- Some(crate::app::state::WorkspaceDropTarget::Before(2))
- );
-
- let source_row = app.state.view.workspace_card_areas[1].rect.y;
- let target_row = crate::ui::workspace_drop_indicator_row(
- &app.state,
- &app.state.view.workspace_card_areas,
- app.state.workspace_list_rect(),
- crate::app::state::WorkspaceDropTarget::Before(0),
- )
- .unwrap();
-
- app.handle_mouse(mouse(
- MouseEventKind::Down(MouseButton::Left),
- 2,
- source_row,
- ));
- app.handle_mouse(mouse(
- MouseEventKind::Drag(MouseButton::Left),
- 2,
- target_row,
- ));
- assert!(matches!(
- app.state.drag.as_ref().map(|drag| &drag.target),
- Some(DragTarget::WorkspaceReorder {
- source_ws_idx: 1,
- drop_target: Some(crate::app::state::WorkspaceDropTarget::Before(0)),
- ..
- })
- ));
- app.handle_mouse(mouse(MouseEventKind::Up(MouseButton::Left), 2, target_row));
-
- let names: Vec<_> = app
- .state
- .workspaces
- .iter()
- .map(|ws| ws.display_name())
- .collect();
- assert_eq!(names, vec!["b", "a", "c"]);
- assert_eq!(app.state.active, Some(0));
- assert_eq!(app.state.selected, 2);
- assert_eq!(app.state.workspaces[0].id, active_id);
- assert_eq!(app.state.workspaces[2].id, selected_id);
- let events = app.event_hub.events_after(0);
- assert!(events.iter().any(|(_, event)| matches!(
- event.data,
- crate::api::schema::EventData::WorkspaceMoved { .. }
- )));
- assert!(!events.iter().any(|(_, event)| matches!(
- event.data,
- crate::api::schema::EventData::WorkspaceReordered { .. }
- )));
- let snapshot = capture_snapshot(&app.state);
- let captured_names: Vec<_> = snapshot
- .workspaces
- .iter()
- .map(|ws| ws.custom_name.clone().unwrap())
- .collect();
- assert_eq!(captured_names, vec!["b", "a", "c"]);
- }
-
- #[test]
- fn clicking_tab_scroll_button_reveals_hidden_tabs_without_renaming() {
- let mut app = app_for_mouse_test();
- let mut ws = Workspace::test_new("test");
- ws.test_add_tab(Some("logs"));
- ws.test_add_tab(Some("review"));
- ws.test_add_tab(Some("ops"));
- ws.test_add_tab(Some("notes"));
- app.state.workspaces = vec![ws];
- app.state.active = Some(0);
- app.state.selected = 0;
- crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 65, 20));
-
- let right = app.state.view.tab_scroll_right_hit_area;
- assert!(right.width > 0);
-
- app.handle_mouse(mouse(
- MouseEventKind::Down(MouseButton::Left),
- right.x + 1,
- right.y,
- ));
-
- assert_eq!(app.state.tab_scroll, 1);
- assert!(!app.state.tab_scroll_follow_active);
- assert_eq!(app.state.workspaces[0].active_tab, 0);
- assert_eq!(app.state.view.tab_hit_areas[0].width, 0);
- assert!(app.state.workspaces[0].tabs[0].custom_name.is_none());
- assert_eq!(
- app.state.workspaces[0].tabs[1].custom_name.as_deref(),
- Some("logs")
- );
- }
-
- #[test]
- fn clicking_last_visible_tab_at_right_edge_does_not_overscroll() {
- let mut app = app_for_mouse_test();
- let mut ws = Workspace::test_new("test");
- for name in [
- "one", "two", "three", "four", "five", "six", "seven", "eight",
- ] {
- ws.test_add_tab(Some(name));
- }
- app.state.workspaces = vec![ws];
- app.state.active = Some(0);
- app.state.selected = 0;
- app.state.tab_scroll = usize::MAX;
- app.state.tab_scroll_follow_active = false;
- crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 65, 20));
-
- let last_idx = app.state.workspaces[0].tabs.len() - 1;
- let target = app.state.view.tab_hit_areas[last_idx];
- let clamped_scroll = app.state.tab_scroll;
- assert!(target.width > 0, "last tab should already be visible");
-
- app.handle_mouse(mouse(
- MouseEventKind::Down(MouseButton::Left),
- target.x + 1,
- target.y,
- ));
- app.handle_mouse(mouse(
- MouseEventKind::Up(MouseButton::Left),
- target.x + 1,
- target.y,
- ));
-
- assert_eq!(app.state.workspaces[0].active_tab, last_idx);
- assert_eq!(app.state.tab_scroll, clamped_scroll);
- assert!(app.state.view.tab_hit_areas[last_idx].width > 0);
- }
-
- #[test]
- fn dragging_tab_reorders_auto_and_custom_names_without_materializing_numbers() {
- let mut app = app_for_mouse_test();
- let mut ws = Workspace::test_new("test");
- ws.test_add_tab(Some("foo"));
- ws.test_add_tab(None);
- let moved_root = ws.tabs[0].root_pane;
- app.state.workspaces = vec![ws];
- app.state.active = Some(0);
- app.state.selected = 0;
- crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20));
-
- let source = app.state.view.tab_hit_areas[0];
- let last = app.state.view.tab_hit_areas[2];
- let drop_col = last.x + last.width;
-
- app.handle_mouse(mouse(
- MouseEventKind::Down(MouseButton::Left),
- source.x + 1,
- source.y,
- ));
- app.handle_mouse(mouse(
- MouseEventKind::Drag(MouseButton::Left),
- drop_col,
- source.y,
- ));
- assert!(matches!(
- app.state.drag.as_ref().map(|drag| &drag.target),
- Some(DragTarget::TabReorder {
- ws_idx: 0,
- source_tab_idx: 0,
- insert_idx: Some(3),
- ..
- })
- ));
- app.handle_mouse(mouse(
- MouseEventKind::Up(MouseButton::Left),
- drop_col,
- source.y,
- ));
-
- let labels: Vec<_> = app.state.workspaces[0]
- .tabs
- .iter()
- .enumerate()
- .map(|(tab_idx, _)| app.state.workspaces[0].tab_display_name(tab_idx).unwrap())
- .collect();
- assert_eq!(labels, vec!["foo", "2", "3"]);
- assert_eq!(
- app.state.workspaces[0].tabs[0].custom_name.as_deref(),
- Some("foo")
- );
- assert!(app.state.workspaces[0].tabs[1].custom_name.is_none());
- assert!(app.state.workspaces[0].tabs[2].custom_name.is_none());
- assert_eq!(app.state.workspaces[0].tabs[0].number, 2);
- assert_eq!(app.state.workspaces[0].tabs[1].number, 3);
- assert_eq!(app.state.workspaces[0].tabs[2].number, 1);
- assert_eq!(app.state.workspaces[0].tabs[2].root_pane, moved_root);
- assert_eq!(app.state.workspaces[0].active_tab, 2);
- }
-
- fn temp_git_repo(branch: &str) -> std::path::PathBuf {
- let repo = unique_temp_path("sidebar-drop-slot-repo");
- fs::create_dir_all(repo.join(".git")).unwrap();
- fs::write(
- repo.join(".git/HEAD"),
- format!("ref: refs/heads/{branch}\n"),
- )
- .unwrap();
- repo
- }
-
- fn workspace_with_space(name: &str, key: &str) -> Workspace {
- let mut ws = Workspace::test_new(name);
- ws.worktree_space = Some(crate::workspace::WorktreeSpaceMembership {
- key: key.into(),
- label: "herdr".into(),
- repo_root: "/repo/herdr".into(),
- checkout_path: format!("/repo/{name}").into(),
- is_linked_worktree: name != "main",
- });
- ws
- }
-
- #[test]
- fn top_drop_slot_is_distinct_from_gap_below_first_workspace() {
- let mut app = app_for_mouse_test();
- let first_repo = temp_git_repo("main");
- let second_repo = temp_git_repo("main");
-
- let mut first = Workspace::test_new("a");
- let first_root = first.tabs[0].root_pane;
- first.identity_cwd = first_repo.clone();
- first.refresh_git_ahead_behind();
-
- let mut second = Workspace::test_new("b");
- let second_root = second.tabs[0].root_pane;
- second.identity_cwd = second_repo.clone();
- second.refresh_git_ahead_behind();
-
- app.state.workspaces = vec![first, second];
- app.state.ensure_test_terminals();
- let first_terminal_id = app.state.workspaces[0].tabs[0].panes[&first_root]
- .attached_terminal_id
- .clone();
- app.state.terminals.get_mut(&first_terminal_id).unwrap().cwd = first_repo.clone();
- let second_terminal_id = app.state.workspaces[1].tabs[0].panes[&second_root]
- .attached_terminal_id
- .clone();
- app.state
- .terminals
- .get_mut(&second_terminal_id)
- .unwrap()
- .cwd = second_repo.clone();
- app.state.sidebar_spaces.row_gap = 1;
- crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20));
-
- assert_eq!(
- app.state.workspace_drop_target_at_row(0),
- Some(crate::app::state::WorkspaceDropTarget::Before(0))
- );
- assert_eq!(
- app.state.workspace_drop_target_at_row(1),
- Some(crate::app::state::WorkspaceDropTarget::Before(0))
- );
- assert_eq!(
- app.state.workspace_drop_target_at_row(2),
- Some(crate::app::state::WorkspaceDropTarget::Before(0))
- );
- assert_eq!(
- app.state.workspace_drop_target_at_row(3),
- Some(crate::app::state::WorkspaceDropTarget::Before(1))
- );
-
- let _ = fs::remove_dir_all(first_repo);
- let _ = fs::remove_dir_all(second_repo);
- }
-
- #[test]
- fn bottom_drop_slot_stays_below_last_workspace_not_footer() {
- let mut app = app_for_mouse_test();
- app.state.workspaces = vec![
- Workspace::test_new("a"),
- Workspace::test_new("b"),
- Workspace::test_new("c"),
- ];
- crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 24));
-
- let cards = &app.state.view.workspace_card_areas;
- let bottom_slot = crate::ui::workspace_drop_indicator_row(
- &app.state,
- cards,
- app.state.workspace_list_rect(),
- crate::app::state::WorkspaceDropTarget::End,
- )
- .unwrap();
-
- let last = cards.last().unwrap().rect;
- assert_eq!(bottom_slot, last.y + last.height);
- assert!(bottom_slot < app.state.sidebar_footer_rect().y.saturating_sub(1));
- }
-
- #[test]
- fn grouped_sidebar_drop_slots_do_not_land_inside_compact_group() {
- let mut app = app_for_mouse_test();
- app.state.workspaces = vec![
- workspace_with_space("main", "repo-key"),
- Workspace::test_new("normal"),
- workspace_with_space("issue", "repo-key"),
- ];
- app.state.active = Some(1);
- app.state.selected = 1;
- crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 40));
-
- let cards = &app.state.view.workspace_card_areas;
- let order = cards.iter().map(|card| card.ws_idx).collect::<Vec<_>>();
- assert_eq!(order, vec![0, 2, 1]);
- let issue = cards.iter().find(|card| card.ws_idx == 2).unwrap();
- let normal = cards.iter().find(|card| card.ws_idx == 1).unwrap();
-
- assert_eq!(
- app.state.workspace_drop_target_at_row(issue.rect.y),
- Some(crate::app::state::WorkspaceDropTarget::Before(1))
- );
- assert_eq!(
- crate::ui::workspace_drop_indicator_row(
- &app.state,
- cards,
- app.state.workspace_list_rect(),
- crate::app::state::WorkspaceDropTarget::End,
- ),
- Some(normal.rect.y + normal.rect.height)
- );
- }
-
- #[test]
- fn plain_drag_anchors_to_the_selected_parentless_linked_workspace() {
- let mut app = app_for_mouse_test();
- app.state.workspaces = vec![
- workspace_with_space("one", "repo-key"),
- workspace_with_space("two", "repo-key"),
- Workspace::test_new("normal"),
- ];
- let target_id = app.state.workspaces[1].id.clone();
-
- let params = app
- .state
- .workspace_move_block_params(2, crate::app::state::WorkspaceDropTarget::Before(1))
- .unwrap();
-
- assert_eq!(params.workspace_ids, [app.state.workspaces[2].id.clone()]);
- assert_eq!(
- params.before_workspace_id.as_deref(),
- Some(target_id.as_str())
- );
- }
-
- #[test]
- fn dragging_worktree_parent_reorders_the_complete_group() {
- let mut app = app_for_mouse_test();
- app.state.workspaces = vec![
- workspace_with_space("main", "repo-key"),
- Workspace::test_new("normal"),
- workspace_with_space("issue", "repo-key"),
- ];
- app.state.active = Some(2);
- app.state.selected = 1;
- crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 40));
-
- let parent = app
- .state
- .view
- .workspace_card_areas
- .iter()
- .find(|card| card.ws_idx == 0)
- .unwrap()
- .rect;
- let target_row = crate::ui::workspace_drop_indicator_row(
- &app.state,
- &app.state.view.workspace_card_areas,
- app.state.workspace_list_rect(),
- crate::app::state::WorkspaceDropTarget::End,
- )
- .unwrap();
- let active_id = app.state.workspaces[2].id.clone();
- let selected_id = app.state.workspaces[1].id.clone();
-
- app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 2, parent.y));
- app.handle_mouse(mouse(
- MouseEventKind::Drag(MouseButton::Left),
- 2,
- target_row,
- ));
- assert!(matches!(
- app.state.drag.as_ref().map(|drag| &drag.target),
- Some(DragTarget::WorkspaceReorder {
- source_ws_idx: 0,
- drop_target: Some(crate::app::state::WorkspaceDropTarget::End),
- ..
- })
- ));
- app.handle_mouse(mouse(MouseEventKind::Up(MouseButton::Left), 2, target_row));
-
- assert_eq!(
- app.state
- .workspaces
- .iter()
- .map(|workspace| workspace.display_name())
- .collect::<Vec<_>>(),
- ["normal", "main", "issue"]
- );
- assert_eq!(
- app.state.workspaces[app.state.active.unwrap()].id,
- active_id
- );
- assert_eq!(app.state.workspaces[app.state.selected].id, selected_id);
- }
-
- #[test]
- fn dragging_collapsed_worktree_parent_still_moves_hidden_children() {
- let mut app = app_for_mouse_test();
- app.state.workspaces = vec![
- workspace_with_space("issue", "repo-key"),
- Workspace::test_new("normal"),
- workspace_with_space("main", "repo-key"),
- workspace_with_space("review", "repo-key"),
- ];
- app.state.active = Some(0);
- app.state.selected = 1;
- app.state.collapsed_space_keys.insert("repo-key".into());
- let active_id = app.state.workspaces[0].id.clone();
- let selected_id = app.state.workspaces[1].id.clone();
- crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 40));
- assert_eq!(app.state.view.workspace_card_areas.len(), 3);
-
- let parent = app.state.view.workspace_card_areas[0].rect;
- let target_row = crate::ui::workspace_drop_indicator_row(
- &app.state,
- &app.state.view.workspace_card_areas,
- app.state.workspace_list_rect(),
- crate::app::state::WorkspaceDropTarget::End,
- )
- .unwrap();
- app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 2, parent.y));
- app.handle_mouse(mouse(
- MouseEventKind::Drag(MouseButton::Left),
- 2,
- target_row,
- ));
- app.handle_mouse(mouse(MouseEventKind::Up(MouseButton::Left), 2, target_row));
-
- assert_eq!(
- app.state
- .workspaces
- .iter()
- .map(|workspace| workspace.display_name())
- .collect::<Vec<_>>(),
- ["normal", "main", "issue", "review"]
- );
- assert_eq!(
- app.state.workspaces[app.state.active.unwrap()].id,
- active_id
- );
- assert_eq!(app.state.workspaces[app.state.selected].id, selected_id);
- }
-
- #[test]
- fn dragging_worktree_space_member_does_not_reorder_workspaces() {
- let mut app = app_for_mouse_test();
- app.state.workspaces = vec![
- workspace_with_space("main", "repo-key"),
- Workspace::test_new("normal"),
- workspace_with_space("issue", "repo-key"),
- ];
- app.state.active = Some(0);
- app.state.selected = 0;
- crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 40));
-
- let source = app
- .state
- .view
- .workspace_card_areas
- .iter()
- .find(|card| card.ws_idx == 2)
- .unwrap()
- .rect;
- let target_row = crate::ui::workspace_drop_indicator_row(
- &app.state,
- &app.state.view.workspace_card_areas,
- app.state.workspace_list_rect(),
- crate::app::state::WorkspaceDropTarget::Before(0),
- )
- .unwrap();
-
- app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 2, source.y));
- app.handle_mouse(mouse(
- MouseEventKind::Drag(MouseButton::Left),
- 2,
- target_row,
- ));
- assert!(app.state.drag.is_none());
- app.handle_mouse(mouse(MouseEventKind::Up(MouseButton::Left), 2, target_row));
-
- let names = app
- .state
- .workspaces
- .iter()
- .map(|ws| ws.display_name())
- .collect::<Vec<_>>();
- assert_eq!(names, vec!["main", "normal", "issue"]);
- }
-
- #[test]
- fn dragging_sidebar_divider_sets_manual_width() {
- let mut app = app_for_mouse_test();
-
- app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 25, 5));
- app.handle_mouse(mouse(MouseEventKind::Drag(MouseButton::Left), 30, 5));
-
- assert_eq!(app.state.sidebar_width, 31);
- let snapshot = capture_snapshot(&app.state);
- assert_eq!(snapshot.sidebar_width, Some(31));
- }
-
- #[test]
- fn dragging_sidebar_bottom_divider_still_sets_manual_width() {
- let mut app = app_for_mouse_test();
- let divider_col = app.state.view.sidebar_rect.x + app.state.view.sidebar_rect.width - 1;
- let bottom_row = app.state.view.sidebar_rect.y + app.state.view.sidebar_rect.height - 1;
-
- app.handle_mouse(mouse(
- MouseEventKind::Down(MouseButton::Left),
- divider_col,
- bottom_row,
- ));
- app.handle_mouse(mouse(
- MouseEventKind::Drag(MouseButton::Left),
- divider_col + 5,
- bottom_row,
- ));
-
- assert_eq!(app.state.sidebar_width, 31);
- }
-
- #[test]
- fn dragging_past_max_clamps_to_configured_max() {
- let mut app = app_for_mouse_test();
- app.state.sidebar_max_width = 30;
-
- app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 25, 5));
- app.handle_mouse(mouse(MouseEventKind::Drag(MouseButton::Left), 50, 5));
-
- assert_eq!(app.state.sidebar_width, 30);
- }
-
- #[test]
- fn dragging_below_min_clamps_to_configured_min() {
- let mut app = app_for_mouse_test();
- app.state.sidebar_min_width = 22;
-
- app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 25, 5));
- app.handle_mouse(mouse(MouseEventKind::Drag(MouseButton::Left), 5, 5));
-
- assert_eq!(app.state.sidebar_width, 22);
- }
-
- #[test]
- fn dragging_sidebar_section_divider_sets_split_ratio() {
- let mut app = app_for_mouse_test();
- let divider = crate::ui::sidebar_section_divider_rect(
- app.state.view.sidebar_rect,
- app.state.sidebar_section_split,
- );
-
- app.handle_mouse(mouse(
- MouseEventKind::Down(MouseButton::Left),
- divider.x + 1,
- divider.y,
- ));
- app.handle_mouse(mouse(
- MouseEventKind::Drag(MouseButton::Left),
- divider.x + 1,
- divider.y + 4,
- ));
-
- assert!(app.state.sidebar_section_split > 0.5);
- let snapshot = capture_snapshot(&app.state);
- assert_eq!(
- snapshot.sidebar_section_split,
- Some(app.state.sidebar_section_split)
- );
- }
-
- #[test]
- fn double_clicking_sidebar_divider_resets_default_width() {
- let mut app = app_for_mouse_test();
- app.state.default_sidebar_width = 26;
- app.state.sidebar_width = 30;
-
- app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 25, 5));
- app.handle_mouse(mouse(MouseEventKind::Up(MouseButton::Left), 25, 5));
- app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 25, 5));
-
- assert_eq!(app.state.sidebar_width, 26);
- assert!(app.state.drag.is_none());
- let snapshot = capture_snapshot(&app.state);
- assert_eq!(snapshot.sidebar_width, Some(26));
- }
-}