diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-05-22 02:21:12 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-05-22 02:23:35 +0300 |
| commit | 0148c1379b76c5c0a9d05588c07dcb06ec32f93f (patch) | |
| tree | 554766d959c738ca2aa433c3d4b4a7de25568008 /src/app/input | |
| parent | a9ff4012bdca0dbdbc77bc6e53a430689fc899be (diff) | |
feat: add git worktree workspace management
refs #137
Diffstat (limited to 'src/app/input')
| -rw-r--r-- | src/app/input/mod.rs | 4 | ||||
| -rw-r--r-- | src/app/input/modal.rs | 110 | ||||
| -rw-r--r-- | src/app/input/mouse.rs | 383 | ||||
| -rw-r--r-- | src/app/input/navigate.rs | 285 | ||||
| -rw-r--r-- | src/app/input/sidebar.rs | 236 |
5 files changed, 981 insertions, 37 deletions
diff --git a/src/app/input/mod.rs b/src/app/input/mod.rs index 7c2bbe03..e357b33d 100644 --- a/src/app/input/mod.rs +++ b/src/app/input/mod.rs @@ -68,6 +68,9 @@ impl App { Mode::RenameWorkspace | Mode::RenameTab | Mode::RenamePane => { handle_rename_key(&mut self.state, key_event) } + Mode::NewLinkedWorktree => self.handle_worktree_create_key(key_event), + Mode::OpenExistingWorktree => self.handle_worktree_open_key(key_event), + Mode::ConfirmRemoveWorktree => self.handle_worktree_remove_key(key_event), Mode::Resize => handle_resize_key(&mut self.state, key), Mode::ConfirmClose => handle_confirm_close_key(&mut self.state, key_event), Mode::ContextMenu => { @@ -350,6 +353,7 @@ fn capture_snapshot(state: &AppState) -> crate::persist::SessionSnapshot { state.agent_panel_scope, state.sidebar_width, state.sidebar_section_split, + state.collapsed_space_keys.clone(), ) } diff --git a/src/app/input/modal.rs b/src/app/input/modal.rs index 868ee3d3..1fae3800 100644 --- a/src/app/input/modal.rs +++ b/src/app/input/modal.rs @@ -506,10 +506,49 @@ pub(super) fn apply_context_menu_action( ) { let item = menu.items().get(idx).copied(); match (menu.kind, item) { - (ContextMenuKind::Workspace { ws_idx }, Some("Rename")) => { + (ContextMenuKind::GitWorkspace { ws_idx, .. }, Some("New worktree")) => { + state.request_new_linked_worktree = Some(ws_idx); + leave_modal(state); + } + (ContextMenuKind::GitWorkspace { ws_idx, .. }, Some("Delete worktree checkout...")) => { + state.request_remove_linked_worktree = Some(ws_idx); + leave_modal(state); + } + (ContextMenuKind::GitWorkspace { ws_idx, .. }, Some("Open worktree...")) => { + state.request_open_existing_worktree = Some(ws_idx); + leave_modal(state); + } + ( + ContextMenuKind::GitWorkspace { + ws_idx, collapsed, .. + }, + Some("Collapse" | "Expand"), + ) => { + if let Some(key) = state + .workspaces + .get(ws_idx) + .and_then(|ws| ws.worktree_space()) + .map(|space| space.key.clone()) + { + if collapsed { + state.collapsed_space_keys.remove(&key); + } else { + state.collapsed_space_keys.insert(key); + } + state.mark_session_dirty(); + } + leave_modal(state); + } + ( + ContextMenuKind::Workspace { ws_idx } | ContextMenuKind::GitWorkspace { ws_idx, .. }, + Some("Rename"), + ) => { open_rename_workspace(state, ws_idx); } - (ContextMenuKind::Workspace { ws_idx }, Some("Close")) => { + ( + ContextMenuKind::Workspace { ws_idx } | ContextMenuKind::GitWorkspace { ws_idx, .. }, + Some("Close" | "Close group"), + ) => { state.selected = ws_idx; if state.confirm_close { open_confirm_close(state); @@ -1033,4 +1072,71 @@ mod tests { ); assert_eq!(state.workspaces.len(), 1); } + + #[test] + fn confirm_close_for_linked_worktree_closes_workspace_only() { + let mut state = state_with_workspaces(&["main", "issue"]); + state.mode = Mode::ConfirmClose; + state.selected = 1; + state.workspaces[1].worktree_space = Some(crate::workspace::WorktreeSpaceMembership { + key: "repo-key".into(), + label: "herdr".into(), + repo_root: "/repo/herdr".into(), + checkout_path: "/repo/herdr-issue".into(), + is_linked_worktree: true, + }); + + handle_confirm_close_key( + &mut state, + KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()), + ); + + assert_eq!(state.request_remove_linked_worktree, None); + assert_eq!(state.workspaces.len(), 1); + assert_eq!(state.workspaces[0].display_name(), "main"); + assert_eq!(state.mode, Mode::Terminal); + } + + #[test] + fn context_menu_close_group_opens_group_close_confirmation() { + let mut state = state_with_workspaces(&["main", "issue"]); + state.active = Some(0); + state.selected = 1; + state.workspaces[0].worktree_space = Some(crate::workspace::WorktreeSpaceMembership { + key: "repo-key".into(), + label: "herdr".into(), + repo_root: "/repo/herdr".into(), + checkout_path: "/repo/herdr".into(), + is_linked_worktree: false, + }); + state.workspaces[1].worktree_space = Some(crate::workspace::WorktreeSpaceMembership { + key: "repo-key".into(), + label: "herdr".into(), + repo_root: "/repo/herdr".into(), + checkout_path: "/repo/herdr-issue".into(), + is_linked_worktree: true, + }); + let menu = ContextMenuState { + kind: ContextMenuKind::GitWorkspace { + ws_idx: 0, + is_linked_worktree: false, + has_worktree_children: true, + collapsed: false, + }, + x: 0, + y: 0, + list: MenuListState::new(0), + }; + let mut terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new(); + + apply_context_menu_action(&mut state, &mut terminal_runtimes, menu, 1); + + assert_eq!(state.selected, 0); + assert_eq!(state.mode, Mode::ConfirmClose); + + confirm_close_accept(&mut state); + + assert!(state.workspaces.is_empty()); + assert_eq!(state.mode, Mode::Navigate); + } } diff --git a/src/app/input/mouse.rs b/src/app/input/mouse.rs index 8d7fc35d..691026de 100644 --- a/src/app/input/mouse.rs +++ b/src/app/input/mouse.rs @@ -141,6 +141,33 @@ impl AppState { && mouse.row >= sidebar.y && mouse.row < sidebar.y + sidebar.height; + if self.mode == Mode::OpenExistingWorktree { + match mouse.kind { + MouseEventKind::ScrollUp => { + if let Some(open) = &mut self.worktree_open { + open.selected = open.selected.saturating_sub(1); + } + return None; + } + MouseEventKind::ScrollDown => { + if let Some(open) = &mut self.worktree_open { + open.selected = + (open.selected + 1).min(open.entries.len().saturating_sub(1)); + } + return None; + } + _ => {} + } + } + + if matches!( + self.mode, + Mode::NewLinkedWorktree | Mode::OpenExistingWorktree | Mode::ConfirmRemoveWorktree + ) && !matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) + { + return None; + } + match mouse.kind { MouseEventKind::Down(MouseButton::Left) => { self.selection = None; @@ -171,6 +198,135 @@ impl AppState { return None; } + if self.mode == Mode::NewLinkedWorktree { + if let Some(inner) = + crate::ui::new_linked_worktree_inner_rect(self.screen_rect()) + { + let (create, cancel) = crate::ui::new_linked_worktree_button_rects(inner); + match modal_action_from_buttons( + mouse.column, + mouse.row, + &[ + (create, ModalAction::Confirm), + (cancel, ModalAction::Cancel), + ], + ) { + Some(ModalAction::Confirm) => { + self.request_submit_worktree_create = true; + } + Some(ModalAction::Cancel) + if !self + .worktree_create + .as_ref() + .is_some_and(|create| create.creating) => + { + self.worktree_create = None; + self.name_input.clear(); + self.name_input_replace_on_type = false; + leave_modal(self); + } + _ => {} + } + } + return None; + } + + if self.mode == Mode::OpenExistingWorktree { + if let Some(open) = self.worktree_open.as_ref() { + if let Some(inner) = crate::ui::open_existing_worktree_inner_rect( + self.screen_rect(), + open.entries.len(), + ) { + let max_rows = inner.height.saturating_sub(4) as usize; + let start = crate::ui::open_existing_worktree_visible_start( + open.selected, + max_rows, + ); + let row_idx = if rect_contains(inner, mouse.column, mouse.row) { + mouse + .row + .checked_sub(inner.y.saturating_add(2)) + .map(usize::from) + .filter(|row| *row < max_rows) + .and_then(|row| { + let entry_idx = start + row; + (entry_idx < open.entries.len()).then_some(entry_idx) + }) + } else { + None + }; + if let Some(entry_idx) = row_idx { + if let Some(open) = &mut self.worktree_open { + open.selected = entry_idx; + } + self.request_submit_worktree_open = true; + return None; + } + + let (open_button, cancel) = + crate::ui::open_existing_worktree_button_rects(inner); + match modal_action_from_buttons( + mouse.column, + mouse.row, + &[ + (open_button, ModalAction::Confirm), + (cancel, ModalAction::Cancel), + ], + ) { + Some(ModalAction::Confirm) => { + self.request_submit_worktree_open = true; + } + Some(ModalAction::Cancel) => { + self.worktree_open = None; + leave_modal(self); + } + _ => {} + } + } + } + return None; + } + + if self.mode == Mode::ConfirmRemoveWorktree { + if let Some(popup) = crate::ui::remove_worktree_popup_rect(self.screen_rect()) { + let inner = Rect::new( + popup.x + 1, + popup.y + 1, + popup.width.saturating_sub(2), + popup.height.saturating_sub(2), + ); + let force_confirmation = self + .worktree_remove + .as_ref() + .is_some_and(|remove| remove.force_confirmation); + let (remove, cancel) = + crate::ui::remove_worktree_button_rects(inner, force_confirmation); + match modal_action_from_buttons( + mouse.column, + mouse.row, + &[ + (remove, ModalAction::Confirm), + (cancel, ModalAction::Cancel), + ], + ) { + Some(ModalAction::Confirm) => { + self.request_submit_worktree_remove = true; + } + Some(ModalAction::Cancel) + if !self + .worktree_remove + .as_ref() + .is_some_and(|remove| remove.removing) => + { + self.worktree_remove = None; + leave_modal(self); + } + _ => {} + } + } + return None; + } + if matches!( self.mode, Mode::RenameWorkspace | Mode::RenameTab | Mode::RenamePane @@ -336,6 +492,29 @@ impl AppState { 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 let Some(card) = cards.iter().find(|card| { + mouse.row == card.rect.y + && mouse.column == card.rect.x + && mouse.column < card.rect.x + card.rect.width + }) { + if let Some((key, collapsed)) = + crate::ui::workspace_parent_group_state(self, card.ws_idx) + { + if collapsed { + self.collapsed_space_keys.remove(&key); + } else { + self.collapsed_space_keys.insert(key); + } + self.mark_session_dirty(); + return None; + } + } + if let Some(idx) = self.workspace_at_row(mouse.row) { self.workspace_press = Some(WorkspacePressState { ws_idx: idx, @@ -437,7 +616,11 @@ impl AppState { if let Some(press) = &self.workspace_press { let delta_col = mouse.column.abs_diff(press.start_col); let delta_row = mouse.row.abs_diff(press.start_row); - if delta_col.max(delta_row) >= WORKSPACE_DRAG_THRESHOLD { + let can_reorder = self + .workspaces + .get(press.ws_idx) + .is_some_and(|ws| ws.worktree_space().is_none()); + if can_reorder && delta_col.max(delta_row) >= WORKSPACE_DRAG_THRESHOLD { self.drag = Some(DragState { target: DragTarget::WorkspaceReorder { source_ws_idx: press.ws_idx, @@ -665,9 +848,8 @@ impl AppState { crate::ui::workspace_list_scroll_metrics(self, self.workspace_list_rect()), ) { self.scroll_workspace_list(-1); - } else if self.selected > 0 { - self.selected -= 1; - self.ensure_workspace_visible(self.selected); + } else { + self.move_selected_workspace_by_visible_delta(-1); } } MouseEventKind::ScrollDown if in_sidebar => { @@ -685,9 +867,8 @@ impl AppState { crate::ui::workspace_list_scroll_metrics(self, self.workspace_list_rect()), ) { self.scroll_workspace_list(1); - } else if !self.workspaces.is_empty() && self.selected < self.workspaces.len() - 1 { - self.selected += 1; - self.ensure_workspace_visible(self.selected); + } else { + self.move_selected_workspace_by_visible_delta(1); } } @@ -699,6 +880,8 @@ impl AppState { } MouseEventKind::Down(MouseButton::Right) if in_sidebar && !self.sidebar_collapsed => { + self.workspace_press = None; + self.tab_press = None; if self .workspace_list_scrollbar_target_at(mouse.column, mouse.row) .is_some() @@ -707,8 +890,40 @@ impl AppState { } if let Some(idx) = self.workspace_at_row(mouse.row) { self.selected = idx; + let kind = self + .workspaces + .get(idx) + .and_then(|ws| { + let group_state = crate::ui::workspace_parent_group_state(self, idx); + let git_space = ws.git_space().cloned().or_else(|| { + ws.resolved_identity_cwd_from(&self.terminals, terminal_runtimes) + .as_deref() + .and_then(crate::workspace::git_space_metadata) + }); + let is_linked_worktree = ws.worktree_space().map_or_else( + || { + git_space + .as_ref() + .is_some_and(|space| space.is_linked_worktree) + }, + |space| space.is_linked_worktree, + ); + let show_git_menu = ws.worktree_space().is_some() + || git_space + .as_ref() + .is_some_and(|space| !space.is_linked_worktree); + show_git_menu.then_some(ContextMenuKind::GitWorkspace { + ws_idx: idx, + is_linked_worktree, + has_worktree_children: group_state.is_some(), + collapsed: group_state + .as_ref() + .is_some_and(|(_, collapsed)| *collapsed), + }) + }) + .unwrap_or(ContextMenuKind::Workspace { ws_idx: idx }); self.context_menu = Some(ContextMenuState { - kind: ContextMenuKind::Workspace { ws_idx: idx }, + kind, x: mouse.column, y: mouse.row, list: MenuListState::new(0), @@ -1433,6 +1648,33 @@ mod tests { assert_eq!(metrics.offset_from_bottom, 7); } + fn sample_worktree_open_state() -> crate::app::state::WorktreeOpenState { + crate::app::state::WorktreeOpenState { + source_workspace_id: "source".into(), + source_existing_membership: None, + source_checkout_path: "/repo/herdr".into(), + source_repo_root: "/repo/herdr".into(), + repo_key: "repo-key".into(), + repo_name: "herdr".into(), + entries: vec![ + crate::app::state::WorktreeOpenEntry { + path: "/repo/herdr".into(), + branch: Some("main".into()), + is_linked_worktree: false, + already_open_ws_idx: Some(0), + }, + crate::app::state::WorktreeOpenEntry { + path: "/repo/herdr-issue".into(), + branch: Some("worktree/issue".into()), + is_linked_worktree: true, + already_open_ws_idx: None, + }, + ], + selected: 0, + error: None, + } + } + #[test] fn hovering_context_menu_updates_highlight() { let mut app = app_for_mouse_test(); @@ -1559,6 +1801,131 @@ mod tests { } #[test] + fn clicking_open_worktree_row_selects_and_requests_open() { + let mut app = app_for_mouse_test(); + app.state.mode = Mode::OpenExistingWorktree; + app.state.worktree_open = Some(sample_worktree_open_state()); + let inner = + crate::ui::open_existing_worktree_inner_rect(app.state.screen_rect(), 2).unwrap(); + + app.handle_mouse(mouse( + MouseEventKind::Down(MouseButton::Left), + inner.x + 1, + inner.y + 3, + )); + + assert_eq!(app.state.worktree_open.as_ref().unwrap().selected, 1); + assert!(app.state.request_submit_worktree_open); + } + + #[test] + fn clicking_open_worktree_buttons_requests_open_or_cancels() { + let mut app = app_for_mouse_test(); + app.state.mode = Mode::OpenExistingWorktree; + app.state.worktree_open = Some(sample_worktree_open_state()); + let inner = + crate::ui::open_existing_worktree_inner_rect(app.state.screen_rect(), 2).unwrap(); + let (open, _) = crate::ui::open_existing_worktree_button_rects(inner); + + app.handle_mouse(mouse( + MouseEventKind::Down(MouseButton::Left), + open.x, + open.y, + )); + + assert!(app.state.worktree_open.is_some()); + assert!(app.state.request_submit_worktree_open); + + let mut app = app_for_mouse_test(); + app.state.mode = Mode::OpenExistingWorktree; + app.state.worktree_open = Some(sample_worktree_open_state()); + let inner = + crate::ui::open_existing_worktree_inner_rect(app.state.screen_rect(), 2).unwrap(); + let (_, cancel) = crate::ui::open_existing_worktree_button_rects(inner); + + app.handle_mouse(mouse( + MouseEventKind::Down(MouseButton::Left), + cancel.x, + cancel.y, + )); + + assert!(app.state.worktree_open.is_none()); + assert_eq!(app.state.mode, Mode::Navigate); + } + + #[test] + fn scrolling_open_worktree_picker_moves_selection() { + let mut app = app_for_mouse_test(); + app.state.mode = Mode::OpenExistingWorktree; + app.state.worktree_open = Some(sample_worktree_open_state()); + + app.handle_mouse(mouse(MouseEventKind::ScrollDown, 1, 1)); + assert_eq!(app.state.worktree_open.as_ref().unwrap().selected, 1); + + app.handle_mouse(mouse(MouseEventKind::ScrollUp, 1, 1)); + assert_eq!(app.state.worktree_open.as_ref().unwrap().selected, 0); + } + + #[test] + fn clicking_remove_worktree_buttons_requests_remove_or_cancels() { + let mut app = app_for_mouse_test(); + app.state.mode = Mode::ConfirmRemoveWorktree; + app.state.worktree_remove = Some(crate::app::state::WorktreeRemoveState { + workspace_id: "issue".into(), + repo_root: "/repo/herdr".into(), + path: "/repo/herdr-issue".into(), + error: None, + removing: false, + force_confirmation: false, + }); + let popup = crate::ui::remove_worktree_popup_rect(app.state.screen_rect()).unwrap(); + let inner = Rect::new( + popup.x + 1, + popup.y + 1, + popup.width.saturating_sub(2), + popup.height.saturating_sub(2), + ); + let (remove, _) = crate::ui::remove_worktree_button_rects(inner, false); + + app.handle_mouse(mouse( + MouseEventKind::Down(MouseButton::Left), + remove.x, + remove.y, + )); + + assert!(app.state.worktree_remove.is_some()); + assert!(app.state.request_submit_worktree_remove); + + let mut app = app_for_mouse_test(); + app.state.mode = Mode::ConfirmRemoveWorktree; + app.state.worktree_remove = Some(crate::app::state::WorktreeRemoveState { + workspace_id: "issue".into(), + repo_root: "/repo/herdr".into(), + path: "/repo/herdr-issue".into(), + error: None, + removing: false, + force_confirmation: false, + }); + let popup = crate::ui::remove_worktree_popup_rect(app.state.screen_rect()).unwrap(); + let inner = Rect::new( + popup.x + 1, + popup.y + 1, + popup.width.saturating_sub(2), + popup.height.saturating_sub(2), + ); + let (_, cancel) = crate::ui::remove_worktree_button_rects(inner, false); + + app.handle_mouse(mouse( + MouseEventKind::Down(MouseButton::Left), + cancel.x, + cancel.y, + )); + + assert!(app.state.worktree_remove.is_none()); + assert_eq!(app.state.mode, Mode::Navigate); + } + + #[test] fn clicking_confirm_close_accepts_after_workspace_context_menu_close() { let mut app = app_for_mouse_test(); app.state.workspaces = vec![Workspace::test_new("a"), Workspace::test_new("b")]; diff --git a/src/app/input/navigate.rs b/src/app/input/navigate.rs index 9256bbbb..da3d49bf 100644 --- a/src/app/input/navigate.rs +++ b/src/app/input/navigate.rs @@ -383,24 +383,18 @@ pub(super) fn handle_navigate_reserved_key(state: &mut AppState, key: KeyEvent) } KeyCode::Char(c @ '1'..='9') => { let idx = (c as usize) - ('1' as usize); - if idx < state.workspaces.len() { - state.switch_workspace(idx); + if let Some(ws_idx) = state.workspace_at_visible_position(idx) { + state.switch_workspace(ws_idx); leave_navigate_mode(state); } true } KeyCode::Up => { - if state.selected > 0 { - state.selected -= 1; - state.ensure_workspace_visible(state.selected); - } + state.move_selected_workspace_by_visible_delta(-1); true } KeyCode::Down => { - if !state.workspaces.is_empty() && state.selected < state.workspaces.len() - 1 { - state.selected += 1; - state.ensure_workspace_visible(state.selected); - } + state.move_selected_workspace_by_visible_delta(1); true } KeyCode::Char('h') | KeyCode::Left => { @@ -459,6 +453,9 @@ pub(crate) fn handle_navigate_key(state: &mut AppState, key: KeyEvent) { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum NavigateAction { NewWorkspace, + NewWorktree, + OpenWorktree, + RemoveWorktree, RenameWorkspace, CloseWorkspace, SwitchWorkspace(usize), @@ -557,6 +554,9 @@ fn action_for_key( (&kb.settings, NavigateAction::Settings), (&kb.workspace_picker, NavigateAction::WorkspacePicker), (&kb.new_workspace, NavigateAction::NewWorkspace), + (&kb.new_worktree, NavigateAction::NewWorktree), + (&kb.open_worktree, NavigateAction::OpenWorktree), + (&kb.remove_worktree, NavigateAction::RemoveWorktree), (&kb.rename_workspace, NavigateAction::RenameWorkspace), (&kb.close_workspace, NavigateAction::CloseWorkspace), (&kb.previous_workspace, NavigateAction::PreviousWorkspace), @@ -619,13 +619,36 @@ pub(super) fn execute_navigate_action_in_context( state.request_new_workspace = true; leave_navigate_mode(state); } + NavigateAction::NewWorktree => { + if let Some(ws_idx) = workspace_action_target(state, context) + .filter(|idx| workspace_can_start_worktree_action(state, terminal_runtimes, *idx)) + { + state.request_new_linked_worktree = Some(ws_idx); + leave_navigate_mode(state); + } + } + NavigateAction::OpenWorktree => { + if let Some(ws_idx) = workspace_action_target(state, context) + .filter(|idx| workspace_can_start_worktree_action(state, terminal_runtimes, *idx)) + { + state.request_open_existing_worktree = Some(ws_idx); + leave_navigate_mode(state); + } + } + NavigateAction::RemoveWorktree => { + if let Some(ws_idx) = workspace_action_target(state, context) { + state.request_remove_linked_worktree = Some(ws_idx); + leave_navigate_mode(state); + } + } NavigateAction::RenameWorkspace => { - if !state.workspaces.is_empty() { - super::modal::open_rename_workspace(state, state.selected); + if let Some(ws_idx) = workspace_action_target(state, context) { + super::modal::open_rename_workspace(state, ws_idx); } } NavigateAction::CloseWorkspace => { - if !state.workspaces.is_empty() { + if let Some(ws_idx) = workspace_action_target(state, context) { + state.selected = ws_idx; if state.confirm_close { super::modal::open_confirm_close(state); } else { @@ -635,8 +658,8 @@ pub(super) fn execute_navigate_action_in_context( } } NavigateAction::SwitchWorkspace(idx) => { - if idx < state.workspaces.len() { - state.switch_workspace(idx); + if let Some(ws_idx) = state.workspace_at_visible_position(idx) { + state.switch_workspace(ws_idx); leave_navigate_mode(state); } } @@ -762,6 +785,36 @@ pub(super) fn execute_navigate_action_in_context( finish_action_context(state, context, previous_mode); } +fn workspace_action_target(state: &AppState, context: ActionContext) -> Option<usize> { + let idx = match context { + ActionContext::Direct | ActionContext::Prefix => state.active.unwrap_or(state.selected), + ActionContext::Navigate => state.selected, + }; + (idx < state.workspaces.len()).then_some(idx) +} + +fn workspace_can_start_worktree_action( + state: &AppState, + terminal_runtimes: &TerminalRuntimeRegistry, + ws_idx: usize, +) -> bool { + let Some(ws) = state.workspaces.get(ws_idx) else { + return false; + }; + if ws + .worktree_space() + .is_some_and(|space| space.is_linked_worktree) + { + return false; + } + let git_space = ws.git_space().cloned().or_else(|| { + ws.resolved_identity_cwd_from(&state.terminals, terminal_runtimes) + .as_deref() + .and_then(crate::workspace::git_space_metadata) + }); + !git_space.is_some_and(|space| space.is_linked_worktree) +} + fn leave_navigate_mode(state: &mut AppState) { if state.active.is_some() { state.mode = Mode::Terminal; @@ -866,6 +919,16 @@ mod tests { use super::*; use crate::{app::App, config::Config, input::TerminalKey, workspace::Workspace}; + fn mark_worktree_space_member(state: &mut AppState, ws_idx: usize, key: &str) { + state.workspaces[ws_idx].worktree_space = Some(crate::workspace::WorktreeSpaceMembership { + key: key.into(), + label: "herdr".into(), + repo_root: "/repo/herdr".into(), + checkout_path: format!("/repo/worktree-{ws_idx}").into(), + is_linked_worktree: ws_idx != 0, + }); + } + #[test] fn custom_rename_key_enters_rename_mode() { let mut state = state_with_workspaces(&["test"]); @@ -881,6 +944,55 @@ mod tests { } #[test] + fn prefix_rename_workspace_targets_active_workspace_not_stale_selection() { + let mut state = state_with_workspaces(&["main", "issue"]); + let mut terminal_runtimes = TerminalRuntimeRegistry::new(); + state.active = Some(1); + state.selected = 0; + state.mode = Mode::Prefix; + + execute_navigate_action_in_context( + &mut state, + &mut terminal_runtimes, + NavigateAction::RenameWorkspace, + ActionContext::Prefix, + ); + + assert_eq!(state.mode, Mode::RenameWorkspace); + assert_eq!(state.selected, 1); + assert_eq!(state.name_input, "issue"); + } + + #[test] + fn prefix_close_workspace_targets_active_linked_worktree_without_removing_checkout() { + let mut state = state_with_workspaces(&["main", "issue"]); + let mut terminal_runtimes = TerminalRuntimeRegistry::new(); + state.active = Some(1); + state.selected = 0; + state.mode = Mode::Prefix; + state.confirm_close = false; + state.workspaces[1].worktree_space = Some(crate::workspace::WorktreeSpaceMembership { + key: "repo-key".into(), + label: "herdr".into(), + repo_root: "/repo/herdr".into(), + checkout_path: "/repo/herdr-issue".into(), + is_linked_worktree: true, + }); + + execute_navigate_action_in_context( + &mut state, + &mut terminal_runtimes, + NavigateAction::CloseWorkspace, + ActionContext::Prefix, + ); + + assert_eq!(state.request_remove_linked_worktree, None); + assert_eq!(state.workspaces.len(), 1); + assert_eq!(state.workspaces[0].display_name(), "main"); + assert_eq!(state.mode, Mode::Terminal); + } + + #[test] fn custom_new_workspace_key_requests_and_exits_navigate() { let mut state = state_with_workspaces(&["test"]); state.keybinds.new_workspace = crate::config::ActionKeybinds::prefix("g"); @@ -895,6 +1007,126 @@ mod tests { } #[test] + fn custom_new_worktree_key_requests_selected_workspace() { + let mut state = state_with_workspaces(&["main", "scratch"]); + state.workspaces[1].identity_cwd = unique_temp_path("navigate-new-worktree-selected"); + state.mode = Mode::Navigate; + state.selected = 1; + state.active = Some(0); + state.keybinds.new_worktree = crate::config::ActionKeybinds::prefix("g"); + + handle_navigate_key( + &mut state, + KeyEvent::new(KeyCode::Char('g'), KeyModifiers::empty()), + ); + + assert_eq!(state.request_new_linked_worktree, Some(1)); + assert_eq!(state.mode, Mode::Terminal); + } + + #[test] + fn worktree_actions_do_not_start_from_linked_child_workspace() { + let mut terminal_runtimes = TerminalRuntimeRegistry::new(); + let mut state = state_with_workspaces(&["main", "issue"]); + mark_worktree_space_member(&mut state, 0, "repo-key"); + mark_worktree_space_member(&mut state, 1, "repo-key"); + state.mode = Mode::Navigate; + state.selected = 1; + state.active = Some(0); + + execute_navigate_action_in_context( + &mut state, + &mut terminal_runtimes, + NavigateAction::NewWorktree, + ActionContext::Navigate, + ); + assert_eq!(state.request_new_linked_worktree, None); + + execute_navigate_action_in_context( + &mut state, + &mut terminal_runtimes, + NavigateAction::OpenWorktree, + ActionContext::Navigate, + ); + assert_eq!(state.request_open_existing_worktree, None); + } + + #[test] + fn direct_new_worktree_action_targets_active_workspace() { + let mut terminal_runtimes = TerminalRuntimeRegistry::new(); + let mut state = state_with_workspaces(&["main", "scratch"]); + state.workspaces[0].identity_cwd = unique_temp_path("navigate-new-worktree-active"); + state.mode = Mode::Terminal; + state.selected = 1; + state.active = Some(0); + + execute_navigate_action_in_context( + &mut state, + &mut terminal_runtimes, + NavigateAction::NewWorktree, + ActionContext::Direct, + ); + + assert_eq!(state.request_new_linked_worktree, Some(0)); + } + + #[test] + fn navigate_down_follows_grouped_sidebar_visual_order() { + let mut state = state_with_workspaces(&["main", "normal", "issue"]); + mark_worktree_space_member(&mut state, 0, "repo-key"); + mark_worktree_space_member(&mut state, 2, "repo-key"); + state.mode = Mode::Navigate; + state.active = Some(0); + state.selected = 0; + + handle_navigate_key( + &mut state, + KeyEvent::new(KeyCode::Down, KeyModifiers::empty()), + ); + + assert_eq!(state.selected, 2); + } + + #[test] + fn navigate_number_keys_follow_grouped_sidebar_visual_order() { + let mut state = state_with_workspaces(&["main", "normal", "issue"]); + mark_worktree_space_member(&mut state, 0, "repo-key"); + mark_worktree_space_member(&mut state, 2, "repo-key"); + state.mode = Mode::Navigate; + state.active = Some(0); + state.selected = 0; + + handle_navigate_key( + &mut state, + KeyEvent::new(KeyCode::Char('2'), KeyModifiers::empty()), + ); + + assert_eq!(state.active, Some(2)); + assert_eq!(state.selected, 2); + } + + #[test] + fn indexed_switch_workspace_keybind_follows_grouped_sidebar_visual_order() { + let mut state = state_with_workspaces(&["main", "normal", "issue"]); + let mut terminal_runtimes = TerminalRuntimeRegistry::new(); + mark_worktree_space_member(&mut state, 0, "repo-key"); + mark_worktree_space_member(&mut state, 2, "repo-key"); + state.mode = Mode::Prefix; + state.active = Some(0); + state.selected = 0; + + execute_navigate_action_in_context( + &mut state, + &mut terminal_runtimes, + NavigateAction::SwitchWorkspace(1), + ActionContext::Prefix, + ); + + assert_eq!(state.active, Some(2)); + assert_eq!(state.selected, 2); + } + + #[test] fn custom_sidebar_toggle_key_toggles_and_exits_navigate() { let mut state = state_with_workspaces(&["test"]); state.keybinds.toggle_sidebar = crate::config::ActionKeybinds::prefix("g"); @@ -1226,6 +1458,29 @@ mod tests { assert!(state.workspaces.is_empty()); } + #[test] + fn closing_linked_worktree_closes_workspace_without_removing_checkout() { + let mut state = state_with_workspaces(&["main", "issue"]); + state.selected = 1; + state.active = Some(1); + state.mode = Mode::Navigate; + state.confirm_close = false; + state.workspaces[1].worktree_space = Some(crate::workspace::WorktreeSpaceMembership { + key: "repo-key".into(), + label: "herdr".into(), + repo_root: "/repo/herdr".into(), + checkout_path: "/repo/herdr-issue".into(), + is_linked_worktree: true, + }); + + execute_navigate_action(&mut state, NavigateAction::CloseWorkspace); + + assert_eq!(state.request_remove_linked_worktree, None); + assert_eq!(state.workspaces.len(), 1); + assert_eq!(state.workspaces[0].display_name(), "main"); + assert_eq!(state.mode, Mode::Terminal); + } + #[tokio::test] async fn custom_command_runs_from_prefix_key_in_navigate_mode() { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); diff --git a/src/app/input/sidebar.rs b/src/app/input/sidebar.rs index dc192e50..8ba12bc6 100644 --- a/src/app/input/sidebar.rs +++ b/src/app/input/sidebar.rs @@ -69,6 +69,11 @@ impl AppState { 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) { @@ -76,19 +81,25 @@ impl AppState { 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; } - 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); - } + 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( @@ -376,8 +387,23 @@ impl AppState { } 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)); + for (idx, card) in cards.iter().enumerate() { + let card_group = self + .workspaces + .get(card.ws_idx) + .and_then(|ws| ws.worktree_space()) + .map(|space| space.key.as_str()); + let previous_group = idx.checked_sub(1).and_then(|prev_idx| { + self.workspaces + .get(cards[prev_idx].ws_idx) + .and_then(|ws| ws.worktree_space()) + .map(|space| space.key.as_str()) + }); + let inside_group_gap = card_group.is_some() && card_group == previous_group; + if !inside_group_gap { + insert_indices.push(card.ws_idx); + } + } insert_indices.push(cards.last().map(|card| card.ws_idx + 1).unwrap_or(0)); let mut best: Option<(usize, u16)> = None; @@ -970,6 +996,110 @@ mod tests { } #[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].rect; + + app.handle_mouse(mouse( + MouseEventKind::Down(MouseButton::Left), + parent.x, + parent.y, + )); + + assert_eq!(app.state.active, None); + assert!(app.state.workspace_press.is_none()); + assert!(app.state.collapsed_space_keys.contains("repo-key")); + + app.handle_mouse(mouse( + MouseEventKind::Down(MouseButton::Left), + parent.x, + parent.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![ @@ -1166,6 +1296,18 @@ mod tests { 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(); @@ -1231,6 +1373,76 @@ mod tests { } #[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_index_at_row(issue.rect.y), Some(1)); + assert_eq!( + crate::ui::workspace_drop_indicator_row(cards, app.state.workspace_list_rect(), 2), + Some(normal.rect.y + normal.rect.height) + ); + } + + #[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.view.workspace_card_areas, + app.state.workspace_list_rect(), + 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(); |
