aboutsummaryrefslogtreecommitdiff
path: root/src/app/input/sidebar.rs
diff options
context:
space:
mode:
authorCan Celik <ogulcancelik@gmail.com>2026-04-21 16:30:04 +0300
committerGitHub <noreply@github.com>2026-04-21 16:30:04 +0300
commit3397e1ce2922c4ae9b814e8110e81de7d201de81 (patch)
treec457a7cf0af271dea1a412474fad5a33c1b60be8 /src/app/input/sidebar.rs
parent2818ff492d45d17fd4f9b15478d703c6c664a8c0 (diff)
feat: add persistent server-client sessions
Make herdr persistent by default. Launching herdr now starts or reattaches to a background session server. Clients can detach and reattach while panes and agent processes keep running. Session mode now supports multi-client attach, auto-detect startup, and a thin-client/headless-server split. This also refactors the large app, ui, input, pane, config, workspace, and persistence modules into smaller focused submodules, while preserving behavior and colocating tests with the code they exercise. Upgrade notes: - persistence mode is now the default - in-app quit detaches the current client instead of stopping the server - use `herdr server stop` to stop the background session - use `--no-session` for the old single-process behavior - default socket paths now live under the config directory
Diffstat (limited to 'src/app/input/sidebar.rs')
-rw-r--r--src/app/input/sidebar.rs1219
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));
+ }
+}