diff options
Diffstat (limited to 'src/app/input/sidebar.rs')
| -rw-r--r-- | src/app/input/sidebar.rs | 1219 |
1 files changed, 1219 insertions, 0 deletions
diff --git a/src/app/input/sidebar.rs b/src/app/input/sidebar.rs new file mode 100644 index 00000000..34845861 --- /dev/null +++ b/src/app/input/sidebar.rs @@ -0,0 +1,1219 @@ +use ratatui::layout::Rect; + +use crate::app::state::{AppState, Mode}; + +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); + } + + 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); + return; + } + + for _ in 0..delta as usize { + let cards = crate::ui::compute_workspace_card_areas(self, self.view.sidebar_rect); + let Some(last) = cards.last() else { + break; + }; + if last.ws_idx + 1 >= self.workspaces.len() { + break; + } + self.workspace_scroll = self.workspace_scroll.saturating_add(1); + } + } + + 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 { + let footer = self.sidebar_footer_rect(); + let width = if self.update_available.is_some() { + 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 keybinds"]; + if self.update_available.is_some() { + labels.push("update ready"); + } else if self.latest_release_notes_available { + labels.push("what's new"); + } + labels.push(if self.quit_detaches { "detach" } else { "quit" }); + 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 extra = if *label == "update ready" { 2 } else { 0 }; + label.chars().count() as u16 + extra + }) + .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; + sidebar.width > 0 + && col == sidebar.x + sidebar.width.saturating_sub(1) + && row >= sidebar.y + && row < sidebar.y + sidebar.height + } + + pub(super) fn on_collapsed_sidebar_toggle(&self, col: u16, row: u16) -> bool { + if !self.sidebar_collapsed { + return false; + } + let rect = crate::ui::collapsed_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(crate::ui::MIN_SIDEBAR_WIDTH, crate::ui::MAX_SIDEBAR_WIDTH); + 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) + } + + fn collapsed_detail_workspace_idx(&self) -> Option<usize> { + if matches!( + self.mode, + Mode::Navigate + | Mode::RenameWorkspace + | Mode::Resize + | Mode::ConfirmClose + | Mode::ContextMenu + | Mode::Settings + | Mode::GlobalMenu + | Mode::KeybindHelp + ) { + Some(self.selected) + } else { + self.active + } + } + + 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 ws_idx = self.collapsed_detail_workspace_idx()?; + let ws = self.workspaces.get(ws_idx)?; + let detail_idx = (row - detail_content_area.y) as usize; + let details = ws.pane_details(); + let detail = details.get(detail_idx)?; + Some((ws_idx, detail.tab_idx, detail.pane_id)) + } + + pub(super) fn workspace_drop_index_at_row(&self, row: u16) -> Option<usize> { + 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() + }; + if cards.is_empty() { + return Some(0); + } + + let mut insert_indices = Vec::with_capacity(cards.len() + 1); + insert_indices.push(cards[0].ws_idx); + insert_indices.extend(cards.iter().skip(1).map(|card| card.ws_idx)); + insert_indices.push(cards.last().map(|card| card.ws_idx + 1).unwrap_or(0)); + + let mut best: Option<(usize, u16)> = None; + for insert_idx in insert_indices { + let Some(slot_row) = crate::ui::workspace_drop_indicator_row(&cards, area, insert_idx) + else { + continue; + }; + let distance = row.abs_diff(slot_row); + match best { + Some((best_idx, best_distance)) + if distance > best_distance + || (distance == best_distance && insert_idx < best_idx) => {} + _ => best = Some((insert_idx, distance)), + } + } + + best.map(|(insert_idx, _)| insert_idx) + } + + pub(super) fn on_agent_panel_scope_toggle(&self, col: u16, row: u16) -> bool { + if self.sidebar_collapsed { + 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_scope); + 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 < 2 || row < body.y || row >= body.y + body.height { + return None; + } + + let mut row_y = body.y; + for detail in crate::ui::agent_panel_entries(self) + .into_iter() + .skip(self.agent_panel_scroll) + { + if row_y.saturating_add(1) >= body.y + body.height { + break; + } + if row == row_y || row == row_y + 1 { + return Some((detail.ws_idx, detail.tab_idx, detail.pane_id)); + } + row_y = row_y.saturating_add(2); + if row_y < body.y + body.height { + row_y = row_y.saturating_add(1); + } + } + 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::{AgentPanelScope, DragTarget, Mode}, + detect::Agent, + 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_keybinds_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_keybinds); + 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 keybinds", + "update ready", + "quit" + ] + ); + assert!(!app.state.should_quit); + } + + #[test] + fn persistence_mode_menu_surfaces_detach_action() { + let mut app = app_for_mouse_test(); + app.state.quit_detaches = 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 keybinds", "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); + } + + #[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 keybinds", + "what's new", + "quit" + ] + ); + } + + #[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; + ws.tabs[0] + .panes + .get_mut(&first_pane) + .unwrap() + .detected_agent = Some(Agent::Pi); + let second_tab = ws.test_add_tab(Some("logs")); + let second_pane = ws.tabs[second_tab].root_pane; + ws.tabs[second_tab] + .panes + .get_mut(&second_pane) + .unwrap() + .detected_agent = Some(Agent::Claude); + app.state.workspaces = vec![ws]; + 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, second_tab); + assert_eq!( + snapshot.workspaces[0].tabs[second_tab].focused, + Some(second_pane.raw()) + ); + } + + #[test] + fn clicking_agent_panel_toggle_switches_scope() { + 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_scope); + app.handle_mouse(mouse( + MouseEventKind::Down(MouseButton::Left), + toggle.x, + toggle.y, + )); + + assert_eq!(app.state.agent_panel_scope, AgentPanelScope::AllWorkspaces); + assert_eq!(app.state.agent_panel_scroll, 0); + let snapshot = capture_snapshot(&app.state); + assert_eq!(snapshot.agent_panel_scope, AgentPanelScope::AllWorkspaces); + } + + #[test] + fn clicking_all_workspaces_agent_row_switches_to_correct_workspace() { + let mut app = app_for_mouse_test(); + let mut first = Workspace::test_new("one"); + let first_pane = first.tabs[0].root_pane; + first.tabs[0] + .panes + .get_mut(&first_pane) + .unwrap() + .detected_agent = Some(Agent::Pi); + + let mut second = Workspace::test_new("two"); + let second_pane = second.tabs[0].root_pane; + second.tabs[0] + .panes + .get_mut(&second_pane) + .unwrap() + .detected_agent = Some(Agent::Claude); + + app.state.workspaces = vec![first, second]; + app.state.active = Some(0); + app.state.selected = 0; + app.state.mode = Mode::Terminal; + app.state.agent_panel_scope = AgentPanelScope::AllWorkspaces; + + 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; + ws.tabs[0] + .panes + .get_mut(&first_pane) + .unwrap() + .detected_agent = Some(Agent::Pi); + + 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; + ws.tabs[tab_idx] + .panes + .get_mut(&pane_id) + .unwrap() + .detected_agent = Some(agent); + } + + app.state.workspaces = vec![ws]; + 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; + ws.tabs[0] + .panes + .get_mut(&first_pane) + .unwrap() + .detected_agent = Some(Agent::Pi); + + let second_tab = ws.test_add_tab(Some("logs")); + let second_pane = ws.tabs[second_tab].root_pane; + ws.tabs[second_tab] + .panes + .get_mut(&second_pane) + .unwrap() + .detected_agent = Some(Agent::Claude); + + 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; + ws.tabs[tab_idx] + .panes + .get_mut(&pane_id) + .unwrap() + .detected_agent = Some(agent); + } + + app.state.workspaces = vec![ws]; + app.state.active = Some(0); + app.state.selected = 0; + app.state.mode = Mode::Terminal; + 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, + )); + + 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; + ws.tabs[0] + .panes + .get_mut(&first_pane) + .unwrap() + .detected_agent = Some(Agent::Pi); + let second_tab = ws.test_add_tab(Some("logs")); + let second_pane = ws.tabs[second_tab].root_pane; + ws.tabs[second_tab] + .panes + .get_mut(&second_pane) + .unwrap() + .detected_agent = Some(Agent::Claude); + app.state.workspaces = vec![ws]; + 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_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 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!(app.state.workspace_press.is_some()); + + 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_press.is_none()); + let snapshot = capture_snapshot(&app.state); + assert_eq!(snapshot.active, Some(1)); + assert_eq!(snapshot.selected, 1); + } + + #[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"), + ]; + 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 source_row = app.state.view.workspace_card_areas[1].rect.y; + let target_row = crate::ui::workspace_drop_indicator_row( + &app.state.view.workspace_card_areas, + app.state.workspace_list_rect(), + 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, + insert_idx: Some(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 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, 52, 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, 52, 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() + .map(|tab| tab.display_name()) + .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[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 + } + + #[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.tabs[0] + .pane_cwds + .insert(first_root, first_repo.clone()); + + let mut second = Workspace::test_new("b"); + let second_root = second.tabs[0].root_pane; + second.identity_cwd = second_repo.clone(); + second.tabs[0] + .pane_cwds + .insert(second_root, second_repo.clone()); + + app.state.workspaces = vec![first, second]; + crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); + + assert_eq!(app.state.workspace_drop_index_at_row(0), Some(0)); + assert_eq!(app.state.workspace_drop_index_at_row(1), Some(0)); + assert_eq!(app.state.workspace_drop_index_at_row(2), Some(0)); + assert_eq!(app.state.workspace_drop_index_at_row(3), Some(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, 20)); + + let cards = &app.state.view.workspace_card_areas; + let bottom_slot = crate::ui::workspace_drop_indicator_row( + cards, + app.state.workspace_list_rect(), + cards.len(), + ) + .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 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_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)); + } +} |
