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 | |
| parent | a9ff4012bdca0dbdbc77bc6e53a430689fc899be (diff) | |
feat: add git worktree workspace management
refs #137
Diffstat (limited to 'src/app')
| -rw-r--r-- | src/app/actions.rs | 237 | ||||
| -rw-r--r-- | src/app/api.rs | 10 | ||||
| -rw-r--r-- | src/app/api/panes.rs | 45 | ||||
| -rw-r--r-- | src/app/api/workspaces.rs | 43 | ||||
| -rw-r--r-- | src/app/creation.rs | 2 | ||||
| -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 | ||||
| -rw-r--r-- | src/app/mod.rs | 76 | ||||
| -rw-r--r-- | src/app/session.rs | 1 | ||||
| -rw-r--r-- | src/app/state.rs | 180 | ||||
| -rw-r--r-- | src/app/worktrees.rs | 1090 |
14 files changed, 2641 insertions, 61 deletions
diff --git a/src/app/actions.rs b/src/app/actions.rs index 0c722125..d6b3ae36 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -152,24 +152,45 @@ impl AppState { return; } + let entries = crate::ui::workspace_list_entries(self); + let Some(target_entry_idx) = entries.iter().position(|entry| { + matches!( + entry, + crate::ui::WorkspaceListEntry::Workspace { ws_idx, .. } if *ws_idx == idx + ) + }) else { + return; + }; + + self.workspace_scroll = crate::ui::normalized_workspace_scroll( + self, + self.view.sidebar_rect, + self.workspace_scroll, + ); let mut cards = crate::ui::compute_workspace_card_areas(self, self.view.sidebar_rect); - if cards.is_empty() { - self.workspace_scroll = idx; + if cards.iter().any(|card| card.ws_idx == idx) { return; } - let first_idx = cards.first().map(|card| card.ws_idx).unwrap_or(0); - if idx < first_idx { - self.workspace_scroll = idx; + if target_entry_idx < self.workspace_scroll { + self.workspace_scroll = target_entry_idx; return; } - while cards.last().map(|card| card.ws_idx).unwrap_or(idx) < idx { + while !cards.iter().any(|card| card.ws_idx == idx) { let previous_scroll = self.workspace_scroll; self.workspace_scroll = self.workspace_scroll.saturating_add(1); if self.workspace_scroll == previous_scroll { break; } + self.workspace_scroll = crate::ui::normalized_workspace_scroll( + self, + self.view.sidebar_rect, + self.workspace_scroll, + ); + if self.workspace_scroll == previous_scroll { + break; + } cards = crate::ui::compute_workspace_card_areas(self, self.view.sidebar_rect); if cards.is_empty() { break; @@ -235,24 +256,66 @@ impl AppState { changed } + pub(crate) fn visible_workspace_order(&self) -> Vec<usize> { + let order = crate::ui::workspace_list_entries(self) + .into_iter() + .map(|entry| match entry { + crate::ui::WorkspaceListEntry::Workspace { ws_idx, .. } => ws_idx, + }) + .collect::<Vec<_>>(); + if order.is_empty() { + (0..self.workspaces.len()).collect() + } else { + order + } + } + + pub(crate) fn workspace_at_visible_position(&self, position: usize) -> Option<usize> { + self.visible_workspace_order().get(position).copied() + } + + pub(crate) fn move_selected_workspace_by_visible_delta(&mut self, delta: isize) { + if self.workspaces.is_empty() { + return; + } + let order = self.visible_workspace_order(); + let current_pos = order + .iter() + .position(|idx| *idx == self.selected) + .unwrap_or(0); + let target_pos = current_pos + .saturating_add_signed(delta) + .min(order.len().saturating_sub(1)); + if let Some(ws_idx) = order.get(target_pos).copied() { + self.selected = ws_idx; + self.ensure_workspace_visible(ws_idx); + } + } + pub fn next_workspace(&mut self) { - if !self.workspaces.is_empty() { - let current = self.active.unwrap_or(self.selected); - let next = (current + 1) % self.workspaces.len(); - self.switch_workspace(next); + if self.workspaces.is_empty() { + return; } + let current = self.active.unwrap_or(self.selected); + let order = self.visible_workspace_order(); + let current_pos = order.iter().position(|idx| *idx == current).unwrap_or(0); + let next = order[(current_pos + 1) % order.len()]; + self.switch_workspace(next); } pub fn previous_workspace(&mut self) { - if !self.workspaces.is_empty() { - let current = self.active.unwrap_or(self.selected); - let prev = if current == 0 { - self.workspaces.len() - 1 - } else { - current - 1 - }; - self.switch_workspace(prev); + if self.workspaces.is_empty() { + return; } + let current = self.active.unwrap_or(self.selected); + let order = self.visible_workspace_order(); + let current_pos = order.iter().position(|idx| *idx == current).unwrap_or(0); + let prev = if current_pos == 0 { + order[order.len() - 1] + } else { + order[current_pos - 1] + }; + self.switch_workspace(prev); } pub fn move_workspace(&mut self, source_idx: usize, insert_idx: usize) { @@ -477,10 +540,35 @@ impl AppState { self.selection = None; self.selection_autoscroll = None; self.mark_session_dirty(); - let terminal_ids = self.terminal_ids_for_workspace(self.selected); - let workspace_id = self.workspaces[self.selected].id.clone(); - crate::logging::workspace_closed(&workspace_id); - self.workspaces.remove(self.selected); + let close_indices = self + .workspaces + .get(self.selected) + .and_then(|ws| ws.worktree_space()) + .filter(|space| !space.is_linked_worktree) + .map(|space| { + self.workspaces + .iter() + .enumerate() + .filter_map(|(idx, ws)| { + ws.worktree_space() + .is_some_and(|member| member.key == space.key) + .then_some(idx) + }) + .collect::<Vec<_>>() + }) + .filter(|indices| indices.len() >= 2) + .unwrap_or_else(|| vec![self.selected]); + + let mut terminal_ids = Vec::new(); + for idx in &close_indices { + terminal_ids.extend(self.terminal_ids_for_workspace(*idx)); + if let Some(workspace_id) = self.workspaces.get(*idx).map(|ws| ws.id.clone()) { + crate::logging::workspace_closed(&workspace_id); + } + } + for idx in close_indices.iter().rev() { + self.workspaces.remove(*idx); + } self.remove_unattached_terminal_ids(terminal_ids); if self.workspaces.is_empty() { self.active = None; @@ -749,6 +837,10 @@ impl AppState { ws.cached_git_ahead_behind = result.ahead_behind; changed = true; } + if ws.cached_git_space != result.space { + ws.cached_git_space = result.space; + changed = true; + } } changed } @@ -840,6 +932,8 @@ impl AppState { let _ = results; Vec::new() } + AppEvent::WorktreeAddFinished(_) => Vec::new(), + AppEvent::WorktreeRemoveFinished(_) => Vec::new(), } } @@ -1016,6 +1110,16 @@ mod tests { state } + fn mark_linked_worktree(state: &mut AppState, ws_idx: usize) { + state.workspaces[ws_idx].worktree_space = Some(crate::workspace::WorktreeSpaceMembership { + key: "repo-key".into(), + label: "herdr".into(), + repo_root: "/repo/herdr".into(), + checkout_path: format!("/repo/worktree-{ws_idx}").into(), + is_linked_worktree: true, + }); + } + #[test] fn apply_workspace_git_statuses_updates_matching_workspace() { let mut state = app_with_workspaces(&["one", "two"]); @@ -1031,6 +1135,7 @@ mod tests { resolved_identity_cwd: first_cwd, branch: Some("main".into()), ahead_behind: Some((2, 1)), + space: None, }], ); @@ -1056,6 +1161,7 @@ mod tests { resolved_identity_cwd: std::path::PathBuf::from("/definitely/not/current"), branch: Some("main".into()), ahead_behind: Some((0, 1)), + space: None, }], ); @@ -1080,6 +1186,7 @@ mod tests { resolved_identity_cwd: cwd, branch: None, ahead_behind: None, + space: None, }], ); @@ -1089,6 +1196,36 @@ mod tests { } #[test] + fn apply_workspace_git_statuses_does_not_change_worktree_membership() { + let mut state = app_with_workspaces(&["one"]); + mark_linked_worktree(&mut state, 0); + let workspace_id = state.workspaces[0].id.clone(); + let cwd = state.workspaces[0].resolved_identity_cwd().unwrap(); + let membership = state.workspaces[0].worktree_space().cloned(); + + let terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new(); + let changed = state.apply_workspace_git_statuses( + &terminal_runtimes, + vec![WorkspaceGitStatus { + workspace_id, + resolved_identity_cwd: cwd, + branch: Some("scratch".into()), + ahead_behind: None, + space: Some(crate::workspace::GitSpaceMetadata { + key: "other-repo-key".into(), + checkout_key: "/other/checkout".into(), + label: "other".into(), + repo_root: "/other/repo".into(), + is_linked_worktree: false, + }), + }], + ); + + assert!(changed); + assert_eq!(state.workspaces[0].worktree_space().cloned(), membership); + } + + #[test] fn update_ready_sets_explicit_upgrade_toast() { let mut state = AppState::test_new(); state.toast_config.delivery = crate::config::ToastDelivery::Herdr; @@ -1324,6 +1461,34 @@ mod tests { } #[test] + fn close_parent_worktree_workspace_closes_group() { + let mut state = app_with_workspaces(&["main", "issue", "notes"]); + 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, + }); + state.selected = 0; + state.active = Some(0); + + state.close_selected_workspace(); + + assert_eq!(state.workspaces.len(), 1); + assert_eq!(state.workspaces[0].display_name(), "notes"); + assert_eq!(state.active, Some(0)); + assert_eq!(state.selected, 0); + } + + #[test] fn close_last_workspace_clears_active() { let mut state = app_with_workspaces(&["only"]); state.selected = 0; @@ -1868,4 +2033,32 @@ mod tests { assert_eq!(state.workspaces[0].display_name(), "selected"); assert!(!state.terminals.contains_key(&active_terminal_id)); } + + #[test] + fn close_tab_last_tab_in_linked_worktree_closes_workspace_only() { + let mut state = app_with_workspaces(&["selected", "active"]); + mark_linked_worktree(&mut state, 1); + state.active = Some(1); + state.selected = 0; + + state.close_tab(); + + assert_eq!(state.request_remove_linked_worktree, None); + assert_eq!(state.workspaces.len(), 1); + assert_eq!(state.workspaces[0].display_name(), "selected"); + } + + #[test] + fn close_pane_last_pane_in_linked_worktree_closes_workspace_only() { + let mut state = app_with_workspaces(&["selected", "active"]); + mark_linked_worktree(&mut state, 1); + state.active = Some(1); + state.selected = 0; + + state.close_pane(); + + assert_eq!(state.request_remove_linked_worktree, None); + assert_eq!(state.workspaces.len(), 1); + assert_eq!(state.workspaces[0].display_name(), "selected"); + } } diff --git a/src/app/api.rs b/src/app/api.rs index c8ae530a..877a9fee 100644 --- a/src/app/api.rs +++ b/src/app/api.rs @@ -31,6 +31,16 @@ impl App { return; } + if let AppEvent::WorktreeAddFinished(result) = ev { + self.handle_worktree_add_finished(result); + return; + } + + if let AppEvent::WorktreeRemoveFinished(result) = ev { + self.handle_worktree_remove_finished(result); + return; + } + let overlay_state = if let AppEvent::PaneDied { pane_id } = &ev { self.overlay_panes.remove(pane_id) } else { diff --git a/src/app/api/panes.rs b/src/app/api/panes.rs index b09ecb2a..2b1741d9 100644 --- a/src/app/api/panes.rs +++ b/src/app/api/panes.rs @@ -351,3 +351,48 @@ fn pane_not_found(id: String, pane_id: &str) -> String { fn invalid_agent(id: String) -> String { encode_error(id, "invalid_agent", "agent label must not be empty") } + +#[cfg(test)] +mod tests { + use super::*; + use crate::{api::schema::SuccessResponse, config::Config, workspace::Workspace}; + + fn app_with_linked_worktree() -> App { + let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); + let mut app = App::new( + &Config::default(), + true, + None, + api_rx, + crate::api::EventHub::default(), + ); + app.state.workspaces = vec![Workspace::test_new("issue")]; + app.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-issue".into(), + is_linked_worktree: true, + }); + app + } + + #[test] + fn api_pane_close_closes_linked_worktree_workspace_only() { + let mut app = app_with_linked_worktree(); + let pane_id = app.state.workspaces[0].tabs[0].root_pane; + let public_pane_id = app.public_pane_id(0, pane_id).unwrap(); + + let response = app.handle_pane_close( + "req".into(), + PaneTarget { + pane_id: public_pane_id, + }, + ); + + let success: SuccessResponse = serde_json::from_str(&response).unwrap(); + assert_eq!(success.id, "req"); + assert_eq!(app.state.request_remove_linked_worktree, None); + assert!(app.state.workspaces.is_empty()); + } +} diff --git a/src/app/api/workspaces.rs b/src/app/api/workspaces.rs index 2f8c1a28..f5e9a563 100644 --- a/src/app/api/workspaces.rs +++ b/src/app/api/workspaces.rs @@ -166,3 +166,46 @@ fn workspace_not_found(id: String, workspace_id: &str) -> String { format!("workspace {workspace_id} not found"), ) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::{api::schema::SuccessResponse, config::Config, workspace::Workspace}; + + fn app_with_linked_worktree() -> App { + let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); + let mut app = App::new( + &Config::default(), + true, + None, + api_rx, + crate::api::EventHub::default(), + ); + app.state.workspaces = vec![Workspace::test_new("issue")]; + app.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-issue".into(), + is_linked_worktree: true, + }); + app + } + + #[test] + fn api_workspace_close_closes_linked_worktree_workspace_only() { + let mut app = app_with_linked_worktree(); + + let response = app.handle_workspace_close( + "req".into(), + WorkspaceTarget { + workspace_id: app.state.workspaces[0].id.clone(), + }, + ); + + let success: SuccessResponse = serde_json::from_str(&response).unwrap(); + assert_eq!(success.id, "req"); + assert_eq!(app.state.request_remove_linked_worktree, None); + assert!(app.state.workspaces.is_empty()); + } +} diff --git a/src/app/creation.rs b/src/app/creation.rs index de1ad128..f6166a85 100644 --- a/src/app/creation.rs +++ b/src/app/creation.rs @@ -105,7 +105,7 @@ impl App { Ok(idx) } - pub(super) fn create_workspace_with_options( + pub(crate) fn create_workspace_with_options( &mut self, initial_cwd: std::path::PathBuf, focus: bool, 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(); diff --git a/src/app/mod.rs b/src/app/mod.rs index 107a4393..a1ae4a74 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -17,6 +17,7 @@ mod session; pub mod state; mod terminal_targets; mod theme_sync; +mod worktrees; use std::collections::{HashMap, HashSet}; use std::future::pending; @@ -234,6 +235,7 @@ impl App { sidebar_width, sidebar_width_source, sidebar_section_split, + collapsed_space_keys, ) = if no_session { ( Vec::new(), @@ -243,6 +245,7 @@ impl App { config.ui.sidebar_width, state::SidebarWidthSource::ConfigDefault, 0.5_f32, + std::collections::HashSet::new(), ) } else if let Some(snap) = crate::persist::load() { let (ws, terminals, terminal_runtimes) = crate::persist::restore( @@ -271,6 +274,7 @@ impl App { state::SidebarWidthSource::ConfigDefault }, snap.sidebar_section_split.unwrap_or(0.5), + snap.collapsed_space_keys, ) } else { crate::logging::session_restored(ws.len(), "ok"); @@ -288,6 +292,7 @@ impl App { state::SidebarWidthSource::ConfigDefault }, snap.sidebar_section_split.unwrap_or(0.5), + snap.collapsed_space_keys, ) } } else { @@ -299,6 +304,7 @@ impl App { config.ui.sidebar_width, state::SidebarWidthSource::ConfigDefault, 0.5_f32, + std::collections::HashSet::new(), ) }; @@ -320,6 +326,8 @@ impl App { (18, 36) }); + let worktree_directory = crate::worktree::expand_tilde_path(&config.worktrees.directory); + info!( pane_scrollback_limit_bytes = config.advanced.scrollback_limit_bytes, "using pane scrollback configuration" @@ -357,12 +365,24 @@ impl App { detach_requested: false, request_new_workspace: false, request_new_tab: false, + request_new_linked_worktree: None, + request_open_existing_worktree: None, + request_new_workspace_cwd: None, + request_remove_linked_worktree: None, + request_submit_worktree_create: false, + request_submit_worktree_open: false, + request_submit_worktree_remove: false, request_reload_config: false, request_client_sound_config_reload: false, request_clipboard_write: None, creating_new_tab: false, requested_new_tab_name: None, rename_pane_target: None, + worktree_create: None, + worktree_open: None, + worktree_remove: None, + worktree_directory, + collapsed_space_keys, request_complete_onboarding: false, name_input: String::new(), name_input_replace_on_type: false, @@ -569,6 +589,47 @@ impl App { needs_render = true; } + if let Some(ws_idx) = self.state.request_new_linked_worktree.take() { + self.open_new_linked_worktree_dialog(ws_idx); + needs_render = true; + } + + if let Some(ws_idx) = self.state.request_open_existing_worktree.take() { + self.open_existing_worktree_dialog(ws_idx); + needs_render = true; + } + + if let Some(cwd) = self.state.request_new_workspace_cwd.take() { + if let Err(err) = self.create_workspace_with_options(cwd, true) { + tracing::error!(err = %err, "failed to create workspace at requested cwd"); + self.state.mode = Mode::Navigate; + } + needs_render = true; + } + + if let Some(ws_idx) = self.state.request_remove_linked_worktree.take() { + self.open_remove_linked_worktree_confirmation(ws_idx); + needs_render = true; + } + + if self.state.request_submit_worktree_create { + self.state.request_submit_worktree_create = false; + self.start_worktree_add(); + needs_render = true; + } + + if self.state.request_submit_worktree_open { + self.state.request_submit_worktree_open = false; + self.open_selected_existing_worktree(); + needs_render = true; + } + + if self.state.request_submit_worktree_remove { + self.state.request_submit_worktree_remove = false; + self.start_worktree_remove(); + needs_render = true; + } + if self.state.request_reload_config { self.state.request_reload_config = false; self.reload_config(); @@ -945,6 +1006,11 @@ impl App { self.state.default_shell = config.terminal.default_shell.clone(); } + if !invalid_section("worktrees") { + self.state.worktree_directory = + crate::worktree::expand_tilde_path(&config.worktrees.directory); + } + if !invalid_section("theme") { self.state.palette = resolve_palette_with_legacy_accent(config, !invalid_section("ui")); self.state.theme_name = config @@ -1105,6 +1171,15 @@ impl App { Mode::RenameWorkspace | Mode::RenameTab | Mode::RenamePane => { input::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 => { input::handle_resize_key(&mut self.state, key); } @@ -1255,6 +1330,7 @@ mod tests { resolved_identity_cwd, branch: Some("render-dirty-test".into()), ahead_behind: Some((1, 0)), + space: None, }], }); diff --git a/src/app/session.rs b/src/app/session.rs index 976e320e..2adff1b8 100644 --- a/src/app/session.rs +++ b/src/app/session.rs @@ -34,6 +34,7 @@ impl App { self.state.agent_panel_scope, self.state.sidebar_width, self.state.sidebar_section_split, + self.state.collapsed_space_keys.clone(), ); crate::persist::save(&snap); } diff --git a/src/app/state.rs b/src/app/state.rs index b38afdb3..ff039c14 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -557,6 +557,52 @@ impl Palette { pub struct WorkspaceCardArea { pub ws_idx: usize, pub rect: Rect, + pub indented: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WorktreeCreateState { + pub source_workspace_id: String, + pub source_checkout_path: std::path::PathBuf, + pub source_existing_membership: Option<crate::workspace::WorktreeSpaceMembership>, + pub source_repo_root: std::path::PathBuf, + pub repo_key: String, + pub repo_name: String, + pub branch: String, + pub checkout_path: std::path::PathBuf, + pub error: Option<String>, + pub creating: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WorktreeRemoveState { + pub workspace_id: String, + pub repo_root: std::path::PathBuf, + pub path: std::path::PathBuf, + pub error: Option<String>, + pub removing: bool, + pub force_confirmation: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WorktreeOpenEntry { + pub path: std::path::PathBuf, + pub branch: Option<String>, + pub is_linked_worktree: bool, + pub already_open_ws_idx: Option<usize>, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WorktreeOpenState { + pub source_workspace_id: String, + pub source_existing_membership: Option<crate::workspace::WorktreeSpaceMembership>, + pub source_checkout_path: std::path::PathBuf, + pub source_repo_root: std::path::PathBuf, + pub repo_key: String, + pub repo_name: String, + pub entries: Vec<WorktreeOpenEntry>, + pub selected: usize, + pub error: Option<String>, } /// Computed view geometry — derived from AppState + terminal size. @@ -595,6 +641,9 @@ pub enum Mode { RenameWorkspace, RenameTab, RenamePane, + NewLinkedWorktree, + OpenExistingWorktree, + ConfirmRemoveWorktree, Resize, ConfirmClose, ContextMenu, @@ -785,11 +834,17 @@ pub(crate) struct TabPressState { pub start_row: u16, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq)] pub enum ContextMenuKind { Workspace { ws_idx: usize, }, + GitWorkspace { + ws_idx: usize, + is_linked_worktree: bool, + has_worktree_children: bool, + collapsed: bool, + }, Tab { ws_idx: usize, tab_idx: usize, @@ -812,6 +867,39 @@ impl ContextMenuState { pub fn items(&self) -> &'static [&'static str] { match self.kind { ContextMenuKind::Workspace { .. } => &["Rename", "Close"], + ContextMenuKind::GitWorkspace { + is_linked_worktree: false, + has_worktree_children: false, + .. + } => &["Rename", "Close", "New worktree", "Open worktree..."], + ContextMenuKind::GitWorkspace { + is_linked_worktree: true, + .. + } => &["Rename", "Close", "Delete worktree checkout..."], + ContextMenuKind::GitWorkspace { + is_linked_worktree: false, + has_worktree_children: true, + collapsed: true, + .. + } => &[ + "Rename", + "Close group", + "New worktree", + "Open worktree...", + "Expand", + ], + ContextMenuKind::GitWorkspace { + is_linked_worktree: false, + has_worktree_children: true, + collapsed: false, + .. + } => &[ + "Rename", + "Close group", + "New worktree", + "Open worktree...", + "Collapse", + ], ContextMenuKind::Tab { .. } => &["New tab", "Rename", "Close"], ContextMenuKind::Pane { has_manual_label: true, @@ -905,6 +993,13 @@ pub struct AppState { pub detach_requested: bool, pub request_new_workspace: bool, pub request_new_tab: bool, + pub request_new_linked_worktree: Option<usize>, + pub request_open_existing_worktree: Option<usize>, + pub request_new_workspace_cwd: Option<std::path::PathBuf>, + pub request_remove_linked_worktree: Option<usize>, + pub request_submit_worktree_create: bool, + pub request_submit_worktree_open: bool, + pub request_submit_worktree_remove: bool, pub request_reload_config: bool, /// Set when the headless server should ask attached clients to reload /// their client-local sound config from disk. @@ -915,6 +1010,11 @@ pub struct AppState { pub creating_new_tab: bool, pub requested_new_tab_name: Option<String>, pub rename_pane_target: Option<PaneId>, + pub worktree_create: Option<WorktreeCreateState>, + pub worktree_open: Option<WorktreeOpenState>, + pub worktree_remove: Option<WorktreeRemoveState>, + pub worktree_directory: std::path::PathBuf, + pub collapsed_space_keys: std::collections::HashSet<String>, pub request_complete_onboarding: bool, pub name_input: String, pub name_input_replace_on_type: bool, @@ -1182,12 +1282,24 @@ impl AppState { detach_requested: false, request_new_workspace: false, request_new_tab: false, + request_new_linked_worktree: None, + request_open_existing_worktree: None, + request_new_workspace_cwd: None, + request_remove_linked_worktree: None, + request_submit_worktree_create: false, + request_submit_worktree_open: false, + request_submit_worktree_remove: false, request_reload_config: false, request_client_sound_config_reload: false, request_clipboard_write: None, creating_new_tab: false, requested_new_tab_name: None, rename_pane_target: None, + worktree_create: None, + worktree_open: None, + worktree_remove: None, + worktree_directory: std::path::PathBuf::from("/tmp/herdr-worktrees"), + collapsed_space_keys: std::collections::HashSet::new(), request_complete_onboarding: false, name_input: String::new(), name_input_replace_on_type: false, @@ -1362,4 +1474,70 @@ mod tests { KeyModifiers::SHIFT, )); } + + #[test] + fn linked_worktree_context_menu_keeps_safe_close_and_explicit_remove() { + let menu = ContextMenuState { + kind: ContextMenuKind::GitWorkspace { + ws_idx: 0, + is_linked_worktree: true, + has_worktree_children: false, + collapsed: false, + }, + x: 0, + y: 0, + list: MenuListState::new(0), + }; + + assert_eq!( + menu.items(), + &["Rename", "Close", "Delete worktree checkout..."] + ); + } + + #[test] + fn git_workspace_context_menu_keeps_remove_for_managed_worktrees_only() { + let menu = ContextMenuState { + kind: ContextMenuKind::GitWorkspace { + ws_idx: 0, + is_linked_worktree: false, + has_worktree_children: false, + collapsed: false, + }, + x: 0, + y: 0, + list: MenuListState::new(0), + }; + + assert_eq!( + menu.items(), + &["Rename", "Close", "New worktree", "Open worktree..."] + ); + } + + #[test] + fn parent_worktree_context_menu_uses_repo_actions() { + 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), + }; + + assert_eq!( + menu.items(), + &[ + "Rename", + "Close group", + "New worktree", + "Open worktree...", + "Collapse" + ] + ); + } } diff --git a/src/app/worktrees.rs b/src/app/worktrees.rs new file mode 100644 index 00000000..d20b5fb0 --- /dev/null +++ b/src/app/worktrees.rs @@ -0,0 +1,1090 @@ +use std::sync::atomic::Ordering; +use std::time::{SystemTime, UNIX_EPOCH}; + +use crossterm::event::{KeyCode, KeyEvent}; + +use super::{ + state::{WorktreeCreateState, WorktreeOpenEntry, WorktreeOpenState, WorktreeRemoveState}, + App, Mode, +}; +use crate::events::{AppEvent, WorktreeAddResult, WorktreeRemoveResult}; + +impl App { + fn worktree_source_metadata( + &self, + ws_idx: usize, + ) -> Result< + ( + Option<crate::workspace::WorktreeSpaceMembership>, + crate::workspace::GitSpaceMetadata, + std::path::PathBuf, + String, + ), + String, + > { + let Some(ws) = self.state.workspaces.get(ws_idx) else { + return Err("Workspace not found.".into()); + }; + let existing_membership = ws.worktree_space().cloned(); + if existing_membership + .as_ref() + .is_some_and(|membership| membership.is_linked_worktree) + { + return Err( + "New and open worktree actions start from the repo parent workspace.".into(), + ); + } + + let git_space = ws.git_space().cloned().or_else(|| { + ws.resolved_identity_cwd_from(&self.state.terminals, &self.terminal_runtimes) + .as_deref() + .and_then(crate::workspace::git_space_metadata) + }); + if git_space + .as_ref() + .is_some_and(|metadata| metadata.is_linked_worktree) + { + return Err( + "New and open worktree actions start from the repo parent workspace.".into(), + ); + } + + let space = existing_membership + .as_ref() + .map_or(git_space, |membership| { + Some(crate::workspace::GitSpaceMetadata { + key: membership.key.clone(), + checkout_key: membership.checkout_path.display().to_string(), + label: membership.label.clone(), + repo_root: membership.repo_root.clone(), + is_linked_worktree: membership.is_linked_worktree, + }) + }) + .ok_or_else(|| { + "Herdr worktree actions require a workspace inside a Git work tree.".to_string() + })?; + let source_checkout_path = existing_membership + .as_ref() + .map(|membership| membership.checkout_path.clone()) + .unwrap_or_else(|| space.repo_root.clone()); + let source_workspace_id = self.state.workspaces[ws_idx].id.clone(); + Ok(( + existing_membership, + space, + source_checkout_path, + source_workspace_id, + )) + } + + pub(crate) fn open_new_linked_worktree_dialog(&mut self, ws_idx: usize) { + let (existing_membership, space, source_checkout_path, source_workspace_id) = + match self.worktree_source_metadata(ws_idx) { + Ok(metadata) => metadata, + Err(err) => { + self.state.config_diagnostic = Some(err); + return; + } + }; + + let repo_name = space.label.clone(); + let seed = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_micros().min(u128::from(u64::MAX)) as u64) + .unwrap_or(0); + let branch = crate::worktree::generated_branch_slug(seed); + let checkout_path = crate::worktree::default_checkout_path( + &self.state.worktree_directory, + &repo_name, + &branch, + ); + + tracing::info!( + ws_idx, + repo_root = %space.repo_root.display(), + branch, + checkout_path = %checkout_path.display(), + "opening worktree dialog" + ); + self.state.selected = ws_idx; + self.state.name_input = branch.clone(); + self.state.name_input_replace_on_type = true; + self.state.worktree_create = Some(WorktreeCreateState { + source_workspace_id, + source_checkout_path, + source_existing_membership: existing_membership, + source_repo_root: space.repo_root, + repo_key: space.key, + repo_name, + branch, + checkout_path, + error: None, + creating: false, + }); + self.state.mode = Mode::NewLinkedWorktree; + } + + pub(crate) fn open_remove_linked_worktree_confirmation(&mut self, ws_idx: usize) { + let Some(ws) = self.state.workspaces.get(ws_idx) else { + return; + }; + if !ws + .worktree_space() + .is_some_and(|space| space.is_linked_worktree) + { + self.state.config_diagnostic = + Some("This workspace is not a Herdr-managed worktree checkout.".into()); + return; + } + let Some(space) = ws.worktree_space().cloned() else { + return; + }; + self.state.selected = ws_idx; + self.state.worktree_remove = Some(WorktreeRemoveState { + workspace_id: ws.id.clone(), + repo_root: space.repo_root, + path: space.checkout_path, + error: None, + removing: false, + force_confirmation: false, + }); + self.state.mode = Mode::ConfirmRemoveWorktree; + } + + pub(crate) fn open_existing_worktree_dialog(&mut self, ws_idx: usize) { + let (existing_membership, space, source_checkout_path, source_workspace_id) = + match self.worktree_source_metadata(ws_idx) { + Ok(metadata) => metadata, + Err(err) => { + self.state.config_diagnostic = Some(err); + return; + } + }; + + let list = match crate::worktree::list_existing_worktrees(&space.repo_root) { + Ok(list) => list, + Err(err) => { + self.state.config_diagnostic = Some(err); + return; + } + }; + let entries = list + .into_iter() + .filter(|entry| !entry.is_bare && !entry.is_prunable) + .map(|entry| { + let entry_checkout_path = crate::worktree::canonical_or_original(&entry.path); + let entry_checkout_key = entry_checkout_path.display().to_string(); + let repo_checkout_path = crate::worktree::canonical_or_original(&space.repo_root); + let already_open_ws_idx = self.state.workspaces.iter().position(|ws| { + if let Some(membership) = ws.worktree_space() { + return crate::worktree::canonical_or_original(&membership.checkout_path) + == entry_checkout_path; + } + + let git_space = ws.git_space().cloned().or_else(|| { + ws.resolved_identity_cwd_from( + &self.state.terminals, + &self.terminal_runtimes, + ) + .as_deref() + .and_then(crate::workspace::git_space_metadata) + }); + if git_space + .as_ref() + .is_some_and(|metadata| metadata.checkout_key == entry_checkout_key) + { + return true; + } + + ws.resolved_identity_cwd_from(&self.state.terminals, &self.terminal_runtimes) + .as_deref() + .is_some_and(|cwd| { + crate::worktree::canonical_or_original(cwd) == entry_checkout_path + }) + }); + WorktreeOpenEntry { + is_linked_worktree: entry_checkout_path != repo_checkout_path, + path: entry.path, + branch: entry.branch, + already_open_ws_idx, + } + }) + .collect::<Vec<_>>(); + + if entries.is_empty() { + self.state.config_diagnostic = Some("No Git worktrees found for this repo.".into()); + return; + } + + self.state.selected = ws_idx; + self.state.worktree_open = Some(WorktreeOpenState { + source_workspace_id, + source_existing_membership: existing_membership, + source_checkout_path, + source_repo_root: space.repo_root, + repo_key: space.key, + repo_name: space.label, + entries, + selected: 0, + error: None, + }); + self.state.mode = Mode::OpenExistingWorktree; + } + + pub(crate) fn handle_worktree_create_key(&mut self, key: KeyEvent) { + match key.code { + KeyCode::Esc => { + if self + .state + .worktree_create + .as_ref() + .is_some_and(|create| create.creating) + { + return; + } + self.close_worktree_create_dialog(); + } + KeyCode::Enter => self.start_worktree_add(), + KeyCode::Backspace => { + if self.state.name_input_replace_on_type { + self.state.name_input.clear(); + self.state.name_input_replace_on_type = false; + } else { + self.state.name_input.pop(); + } + self.sync_worktree_branch_from_input(); + } + KeyCode::Char(c) => { + if self.state.name_input_replace_on_type { + self.state.name_input.clear(); + self.state.name_input_replace_on_type = false; + } + self.state.name_input.push(c); + self.sync_worktree_branch_from_input(); + } + _ => {} + } + } + + pub(crate) fn handle_worktree_open_key(&mut self, key: KeyEvent) { + match key.code { + KeyCode::Esc => { + self.state.worktree_open = None; + self.state.mode = if self.state.active.is_some() { + Mode::Terminal + } else { + Mode::Navigate + }; + } + KeyCode::Up => { + if let Some(open) = &mut self.state.worktree_open { + open.selected = open.selected.saturating_sub(1); + } + } + KeyCode::Down => { + if let Some(open) = &mut self.state.worktree_open { + open.selected = open + .selected + .saturating_add(1) + .min(open.entries.len().saturating_sub(1)); + } + } + KeyCode::Enter => self.open_selected_existing_worktree(), + _ => {} + } + } + + pub(crate) fn open_selected_existing_worktree(&mut self) { + let Some(open) = self.state.worktree_open.as_ref() else { + return; + }; + let Some(entry) = open.entries.get(open.selected).cloned() else { + return; + }; + let source_workspace_id = open.source_workspace_id.clone(); + let source_existing_membership = open.source_existing_membership.clone(); + let source_checkout_path = open.source_checkout_path.clone(); + let source_repo_root = open.source_repo_root.clone(); + let repo_key = open.repo_key.clone(); + let repo_name = open.repo_name.clone(); + self.state.worktree_open = None; + + if let Some(ws_idx) = entry.already_open_ws_idx { + self.mark_opened_existing_worktree_membership( + &source_workspace_id, + source_existing_membership, + source_checkout_path, + source_repo_root, + repo_key, + repo_name, + ws_idx, + entry.path, + entry.is_linked_worktree, + ); + self.state.switch_workspace(ws_idx); + self.state.mode = Mode::Terminal; + return; + } + + match self.create_workspace_with_options(entry.path.clone(), true) { + Ok(new_ws_idx) => { + self.mark_opened_existing_worktree_membership( + &source_workspace_id, + source_existing_membership, + source_checkout_path, + source_repo_root, + repo_key, + repo_name, + new_ws_idx, + entry.path, + entry.is_linked_worktree, + ); + } + Err(err) => { + self.state.worktree_open = Some(WorktreeOpenState { + source_workspace_id, + source_existing_membership, + source_checkout_path, + source_repo_root, + repo_key, + repo_name, + entries: vec![entry], + selected: 0, + error: Some(format!("failed to open worktree: {err}")), + }); + self.state.mode = Mode::OpenExistingWorktree; + } + } + } + + // The caller has already extracted the open-worktree dialog state; keeping the + // membership fields explicit here avoids borrowing AppState across workspace creation. + #[allow(clippy::too_many_arguments)] + fn mark_opened_existing_worktree_membership( + &mut self, + source_workspace_id: &str, + source_existing_membership: Option<crate::workspace::WorktreeSpaceMembership>, + source_checkout_path: std::path::PathBuf, + source_repo_root: std::path::PathBuf, + repo_key: String, + repo_name: String, + target_ws_idx: usize, + target_path: std::path::PathBuf, + target_is_linked_worktree: bool, + ) { + if let Some(source_ws_idx) = self + .state + .workspaces + .iter() + .position(|ws| ws.id == source_workspace_id) + { + if let Some(source_membership) = source_existing_membership { + self.state.workspaces[source_ws_idx].worktree_space = Some(source_membership); + } else { + self.state.workspaces[source_ws_idx].worktree_space = + Some(crate::workspace::WorktreeSpaceMembership { + key: repo_key.clone(), + label: repo_name.clone(), + repo_root: source_repo_root.clone(), + checkout_path: source_checkout_path, + is_linked_worktree: false, + }); + } + } + if let Some(target) = self.state.workspaces.get_mut(target_ws_idx) { + target.worktree_space = Some(crate::workspace::WorktreeSpaceMembership { + key: repo_key, + label: repo_name, + repo_root: source_repo_root, + checkout_path: target_path, + is_linked_worktree: target_is_linked_worktree, + }); + } + self.state.mark_session_dirty(); + } + + fn close_worktree_create_dialog(&mut self) { + self.state.worktree_create = None; + self.state.name_input.clear(); + self.state.name_input_replace_on_type = false; + self.state.mode = if self.state.active.is_some() { + Mode::Terminal + } else { + Mode::Navigate + }; + } + + fn sync_worktree_branch_from_input(&mut self) { + let Some(create) = &mut self.state.worktree_create else { + return; + }; + create.branch = self.state.name_input.clone(); + create.checkout_path = crate::worktree::default_checkout_path( + &self.state.worktree_directory, + &create.repo_name, + &create.branch, + ); + create.error = None; + } + + pub(crate) fn start_worktree_add(&mut self) { + self.sync_worktree_branch_from_input(); + let Some(create) = &mut self.state.worktree_create else { + return; + }; + let branch = create.branch.trim().to_string(); + if branch.is_empty() { + create.error = Some("branch is required".into()); + return; + } + if create.creating { + return; + } + + create.branch = branch.clone(); + self.state.name_input = branch.clone(); + create.checkout_path = crate::worktree::default_checkout_path( + &self.state.worktree_directory, + &create.repo_name, + &branch, + ); + create.creating = true; + create.error = None; + + let command = crate::worktree::build_worktree_add_new_branch_command( + &create.source_checkout_path, + &create.checkout_path, + &create.branch, + "HEAD", + ); + let parent_dir = create + .checkout_path + .parent() + .map(std::path::Path::to_path_buf); + tracing::info!( + repo_root = %create.source_repo_root.display(), + branch = %create.branch, + checkout_path = %create.checkout_path.display(), + "starting git worktree add" + ); + let path = create.checkout_path.clone(); + let event_tx = self.event_tx.clone(); + std::thread::spawn(move || { + let result = if let Some(parent_dir) = parent_dir { + std::fs::create_dir_all(&parent_dir) + .map_err(|err| err.to_string()) + .and_then(|()| crate::worktree::run_worktree_command(&command)) + } else { + crate::worktree::run_worktree_command(&command) + }; + let _ = event_tx.blocking_send(AppEvent::WorktreeAddFinished(WorktreeAddResult { + path, + result, + })); + }); + } + + pub(crate) fn handle_worktree_remove_key(&mut self, key: KeyEvent) { + match key.code { + KeyCode::Esc => { + if self + .state + .worktree_remove + .as_ref() + .is_some_and(|remove| remove.removing) + { + return; + } + self.state.worktree_remove = None; + self.state.mode = if self.state.active.is_some() { + Mode::Terminal + } else { + Mode::Navigate + }; + } + KeyCode::Enter => self.start_worktree_remove(), + _ => {} + } + } + + pub(crate) fn start_worktree_remove(&mut self) { + let Some(remove) = &mut self.state.worktree_remove else { + return; + }; + if remove.removing { + return; + } + remove.removing = true; + remove.error = None; + let force = remove.force_confirmation; + + let command = + crate::worktree::build_worktree_remove_command(&remove.repo_root, &remove.path, force); + tracing::info!(workspace_id = %remove.workspace_id, path = %remove.path.display(), force, "starting git worktree remove"); + let path = remove.path.clone(); + let workspace_id = remove.workspace_id.clone(); + let event_tx = self.event_tx.clone(); + std::thread::spawn(move || { + let result = crate::worktree::run_worktree_command(&command); + let _ = + event_tx.blocking_send(AppEvent::WorktreeRemoveFinished(WorktreeRemoveResult { + workspace_id, + path, + result, + })); + }); + } + + pub(crate) fn handle_worktree_add_finished(&mut self, result: WorktreeAddResult) { + let Some(create) = &mut self.state.worktree_create else { + return; + }; + if create.checkout_path != result.path { + return; + } + + match result.result { + Ok(()) => { + tracing::info!(checkout_path = %create.checkout_path.display(), "git worktree add completed"); + let path = create.checkout_path.clone(); + let source_workspace_id = create.source_workspace_id.clone(); + let source_checkout_path = create.source_checkout_path.clone(); + let source_existing_membership = create.source_existing_membership.clone(); + let repo_key = create.repo_key.clone(); + let repo_name = create.repo_name.clone(); + let source_repo_root = create.source_repo_root.clone(); + self.state.worktree_create = None; + self.state.name_input.clear(); + self.state.name_input_replace_on_type = false; + match self.create_workspace_with_options(path.clone(), true) { + Ok(ws_idx) => { + let source_membership = source_existing_membership.unwrap_or( + crate::workspace::WorktreeSpaceMembership { + key: repo_key.clone(), + label: repo_name.clone(), + repo_root: source_repo_root.clone(), + checkout_path: source_checkout_path, + is_linked_worktree: false, + }, + ); + if let Some(ws) = self + .state + .workspaces + .iter_mut() + .find(|ws| ws.id == source_workspace_id) + { + ws.worktree_space = Some(source_membership); + } + if let Some(ws) = self.state.workspaces.get_mut(ws_idx) { + ws.worktree_space = Some(crate::workspace::WorktreeSpaceMembership { + key: repo_key, + label: repo_name, + repo_root: source_repo_root, + checkout_path: path, + is_linked_worktree: true, + }); + } + self.state.mark_session_dirty(); + } + Err(err) => { + self.state.config_diagnostic = Some(format!( + "created worktree but failed to open workspace: {err}" + )); + self.state.mode = Mode::Navigate; + } + } + self.render_dirty.store(true, Ordering::Release); + self.render_notify.notify_one(); + } + Err(message) => { + tracing::warn!(checkout_path = %create.checkout_path.display(), error = %message, "git worktree add failed"); + create.creating = false; + create.error = Some(message); + self.render_dirty.store(true, Ordering::Release); + self.render_notify.notify_one(); + } + } + } + pub(crate) fn handle_worktree_remove_finished(&mut self, result: WorktreeRemoveResult) { + let Some(remove) = &mut self.state.worktree_remove else { + return; + }; + if remove.workspace_id != result.workspace_id || remove.path != result.path { + return; + } + + match result.result { + Ok(()) => { + tracing::info!(workspace_id = %result.workspace_id, path = %result.path.display(), "git worktree remove completed"); + self.state.worktree_remove = None; + if let Some(ws_idx) = self + .state + .workspaces + .iter() + .position(|ws| ws.id == result.workspace_id) + { + let still_same_linked_worktree = self.state.workspaces[ws_idx] + .worktree_space() + .is_some_and(|space| { + space.is_linked_worktree && space.checkout_path == result.path + }); + if still_same_linked_worktree { + self.state.selected = ws_idx; + self.state.close_selected_workspace(); + } + } + self.state.mode = if self.state.active.is_some() { + Mode::Terminal + } else { + Mode::Navigate + }; + self.render_dirty.store(true, Ordering::Release); + self.render_notify.notify_one(); + } + Err(message) => { + tracing::warn!(workspace_id = %result.workspace_id, path = %result.path.display(), error = %message, "git worktree remove failed"); + remove.removing = false; + if !remove.force_confirmation + && crate::worktree::is_dirty_worktree_remove_error(&message) + { + remove.force_confirmation = true; + remove.error = None; + } else { + remove.error = Some(message); + } + self.render_dirty.store(true, Ordering::Release); + self.render_notify.notify_one(); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn unique_temp_path(name: &str) -> std::path::PathBuf { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + std::env::temp_dir().join(format!("herdr-{name}-{}-{nanos}", std::process::id())) + } + + fn run_git(repo: &std::path::Path, args: &[&str]) { + let status = std::process::Command::new("git") + .arg("-C") + .arg(repo) + .args(args) + .status() + .unwrap(); + assert!( + status.success(), + "git command failed: git -C {} {}", + repo.display(), + args.join(" ") + ); + } + + fn create_committed_repo(name: &str) -> std::path::PathBuf { + let repo = unique_temp_path(name); + std::fs::create_dir_all(&repo).unwrap(); + run_git(&repo, &["init", "--quiet"]); + run_git(&repo, &["config", "user.email", "herdr@example.invalid"]); + run_git(&repo, &["config", "user.name", "Herdr Test"]); + std::fs::write(repo.join("README.md"), "test\n").unwrap(); + run_git(&repo, &["add", "README.md"]); + run_git(&repo, &["commit", "--quiet", "-m", "initial"]); + repo + } + + fn wait_for_worktree_event(app: &mut App) -> AppEvent { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); + while std::time::Instant::now() < deadline { + if let Ok(event) = app.event_rx.try_recv() { + return event; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + panic!("timed out waiting for worktree event"); + } + + fn app_for_worktree_tests() -> App { + App::new( + &crate::config::Config::default(), + true, + None, + tokio::sync::mpsc::unbounded_channel().1, + crate::api::EventHub::default(), + ) + } + + #[test] + fn open_selected_existing_worktree_focuses_already_open_workspace() { + let mut app = app_for_worktree_tests(); + app.state.workspaces = vec![ + crate::workspace::Workspace::test_new("main"), + crate::workspace::Workspace::test_new("issue"), + ]; + app.state.active = Some(0); + app.state.selected = 0; + app.state.worktree_open = Some(WorktreeOpenState { + source_workspace_id: app.state.workspaces[0].id.clone(), + 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![WorktreeOpenEntry { + path: "/repo/herdr-issue".into(), + branch: Some("worktree/issue".into()), + is_linked_worktree: true, + already_open_ws_idx: Some(1), + }], + selected: 0, + error: None, + }); + + app.open_selected_existing_worktree(); + + assert_eq!(app.state.active, Some(1)); + assert_eq!(app.state.selected, 1); + assert!(app.state.worktree_open.is_none()); + assert!(app.state.workspaces[0].worktree_space().is_some()); + let target_membership = app.state.workspaces[1].worktree_space().unwrap(); + assert_eq!(target_membership.key, "repo-key"); + assert_eq!( + target_membership.checkout_path, + std::path::PathBuf::from("/repo/herdr-issue") + ); + assert!(target_membership.is_linked_worktree); + } + + #[test] + fn open_existing_worktree_detects_already_open_checkout_from_subdirectory() { + let repo = create_committed_repo("app-worktree-open-existing-repo"); + let checkout = unique_temp_path("app-worktree-open-existing-checkout"); + run_git( + &repo, + &[ + "worktree", + "add", + "--quiet", + "-b", + "worktree/open-existing", + checkout.to_str().unwrap(), + "HEAD", + ], + ); + let subdir = checkout.join("nested"); + std::fs::create_dir_all(&subdir).unwrap(); + + let mut app = app_for_worktree_tests(); + app.state.workspaces = vec![ + crate::workspace::Workspace::test_new("main"), + crate::workspace::Workspace::test_new("nested"), + ]; + app.state.workspaces[0].identity_cwd = repo; + app.state.workspaces[1].identity_cwd = subdir; + + app.open_existing_worktree_dialog(0); + + let open = app.state.worktree_open.as_ref().unwrap(); + let checkout = crate::worktree::canonical_or_original(&checkout); + let entry = open + .entries + .iter() + .find(|entry| crate::worktree::canonical_or_original(&entry.path) == checkout) + .unwrap_or_else(|| panic!("missing checkout in entries: {:?}", open.entries)); + assert_eq!(entry.already_open_ws_idx, Some(1)); + } + + #[test] + fn worktree_create_and_open_dialogs_reject_linked_child_source() { + let mut app = app_for_worktree_tests(); + app.state.workspaces = vec![crate::workspace::Workspace::test_new("issue")]; + app.state.mode = Mode::Navigate; + app.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-issue".into(), + is_linked_worktree: true, + }); + + app.open_new_linked_worktree_dialog(0); + + assert_eq!(app.state.mode, Mode::Navigate); + assert!(app.state.worktree_create.is_none()); + assert_eq!( + app.state.config_diagnostic.as_deref(), + Some("New and open worktree actions start from the repo parent workspace.") + ); + + app.state.config_diagnostic = None; + app.open_existing_worktree_dialog(0); + + assert!(app.state.worktree_open.is_none()); + assert_eq!( + app.state.config_diagnostic.as_deref(), + Some("New and open worktree actions start from the repo parent workspace.") + ); + } + + #[test] + fn sync_worktree_branch_updates_derived_path() { + let mut app = app_for_worktree_tests(); + app.state.worktree_directory = std::path::PathBuf::from("/w"); + app.state.name_input = "issue/137".into(); + app.state.worktree_create = Some(WorktreeCreateState { + source_workspace_id: "source".into(), + source_checkout_path: std::path::PathBuf::from("/repo/herdr"), + source_existing_membership: None, + source_repo_root: std::path::PathBuf::from("/repo/herdr"), + repo_key: "repo-key".into(), + repo_name: "herdr".into(), + branch: "old".into(), + checkout_path: std::path::PathBuf::from("/old"), + error: Some("old error".into()), + creating: false, + }); + + app.sync_worktree_branch_from_input(); + + let create = app.state.worktree_create.unwrap(); + assert_eq!(create.branch, "issue/137"); + assert_eq!( + create.checkout_path, + std::path::PathBuf::from("/w/herdr/issue-137") + ); + assert_eq!(create.error, None); + } + + #[test] + fn start_worktree_add_runs_git_on_worker_and_emits_result() { + let repo = create_committed_repo("app-worktree-add-repo"); + let worktree_root = unique_temp_path("app-worktree-add-root"); + let branch = "worktree/app-worker"; + let checkout = crate::worktree::default_checkout_path(&worktree_root, "herdr", branch); + let mut app = app_for_worktree_tests(); + app.state.worktree_directory = worktree_root.clone(); + app.state.name_input = branch.into(); + app.state.worktree_create = Some(WorktreeCreateState { + source_workspace_id: "source".into(), + source_checkout_path: repo.clone(), + source_existing_membership: None, + source_repo_root: repo.clone(), + repo_key: "repo-key".into(), + repo_name: "herdr".into(), + branch: branch.into(), + checkout_path: checkout.clone(), + error: None, + creating: false, + }); + + app.start_worktree_add(); + + assert!(app + .state + .worktree_create + .as_ref() + .is_some_and(|create| create.creating)); + let event = wait_for_worktree_event(&mut app); + match event { + AppEvent::WorktreeAddFinished(result) => { + assert_eq!(result.path, checkout); + assert_eq!(result.result, Ok(())); + } + other => panic!("unexpected event: {other:?}"), + } + assert!(checkout.join("README.md").exists()); + + let remove = crate::worktree::build_worktree_remove_command(&repo, &checkout, false); + crate::worktree::run_worktree_command(&remove).unwrap(); + let _ = std::fs::remove_dir_all(worktree_root); + let _ = std::fs::remove_dir_all(repo); + } + + #[test] + fn start_worktree_add_uses_source_checkout_head_as_base() { + let repo = create_committed_repo("app-worktree-add-source-repo"); + let source_checkout = unique_temp_path("app-worktree-add-source-checkout"); + run_git( + &repo, + &[ + "worktree", + "add", + "--quiet", + "-b", + "worktree/source-base", + source_checkout.to_str().unwrap(), + "HEAD", + ], + ); + std::fs::write(source_checkout.join("SOURCE.md"), "source branch\n").unwrap(); + run_git(&source_checkout, &["add", "SOURCE.md"]); + run_git(&source_checkout, &["commit", "--quiet", "-m", "source"]); + + let worktree_root = unique_temp_path("app-worktree-add-from-source-root"); + let branch = "worktree/from-source"; + let checkout = crate::worktree::default_checkout_path(&worktree_root, "herdr", branch); + let mut app = app_for_worktree_tests(); + app.state.worktree_directory = worktree_root.clone(); + app.state.name_input = branch.into(); + app.state.worktree_create = Some(WorktreeCreateState { + source_workspace_id: "source".into(), + source_checkout_path: source_checkout.clone(), + source_existing_membership: None, + source_repo_root: repo.clone(), + repo_key: "repo-key".into(), + repo_name: "herdr".into(), + branch: branch.into(), + checkout_path: checkout.clone(), + error: None, + creating: false, + }); + + app.start_worktree_add(); + + let event = wait_for_worktree_event(&mut app); + match event { + AppEvent::WorktreeAddFinished(result) => { + assert_eq!(result.path, checkout); + assert_eq!(result.result, Ok(())); + } + other => panic!("unexpected event: {other:?}"), + } + assert!(checkout.join("SOURCE.md").exists()); + + let remove_new = crate::worktree::build_worktree_remove_command(&repo, &checkout, false); + crate::worktree::run_worktree_command(&remove_new).unwrap(); + let remove_source = + crate::worktree::build_worktree_remove_command(&repo, &source_checkout, false); + crate::worktree::run_worktree_command(&remove_source).unwrap(); + let _ = std::fs::remove_dir_all(worktree_root); + let _ = std::fs::remove_dir_all(repo); + } + + #[test] + fn dirty_worktree_remove_failure_requests_force_confirmation() { + let path = std::path::PathBuf::from("/w/herdr/dirty"); + let mut app = app_for_worktree_tests(); + app.state.worktree_remove = Some(WorktreeRemoveState { + workspace_id: "ws".into(), + repo_root: std::path::PathBuf::from("/repo/herdr"), + path: path.clone(), + error: None, + removing: true, + force_confirmation: false, + }); + + app.handle_worktree_remove_finished(WorktreeRemoveResult { + workspace_id: "ws".into(), + path, + result: Err( + "fatal: '/w/herdr/dirty' contains modified or untracked files, use --force to delete it" + .into(), + ), + }); + + let remove = app.state.worktree_remove.unwrap(); + assert!(!remove.removing); + assert!(remove.force_confirmation); + assert_eq!(remove.error, None); + } + + #[test] + fn non_dirty_worktree_remove_failure_keeps_error_message() { + let path = std::path::PathBuf::from("/w/herdr/missing"); + let mut app = app_for_worktree_tests(); + app.state.worktree_remove = Some(WorktreeRemoveState { + workspace_id: "ws".into(), + repo_root: std::path::PathBuf::from("/repo/herdr"), + path: path.clone(), + error: None, + removing: true, + force_confirmation: false, + }); + + app.handle_worktree_remove_finished(WorktreeRemoveResult { + workspace_id: "ws".into(), + path, + result: Err("fatal: '/w/herdr/missing' is not a working tree".into()), + }); + + let remove = app.state.worktree_remove.unwrap(); + assert!(!remove.removing); + assert!(!remove.force_confirmation); + assert_eq!( + remove.error, + Some("fatal: '/w/herdr/missing' is not a working tree".into()) + ); + } + + #[test] + fn dirty_worktree_remove_retries_with_force_and_closes_workspace() { + let repo = create_committed_repo("app-worktree-dirty-remove-repo"); + let checkout = unique_temp_path("app-worktree-dirty-remove-checkout"); + run_git( + &repo, + &[ + "worktree", + "add", + "--quiet", + "-b", + "worktree/dirty-remove", + checkout.to_str().unwrap(), + "HEAD", + ], + ); + std::fs::write(checkout.join("README.md"), "dirty\n").unwrap(); + + let mut app = app_for_worktree_tests(); + app.state.workspaces = vec![crate::workspace::Workspace::test_new("issue")]; + let workspace_id = app.state.workspaces[0].id.clone(); + app.state.workspaces[0].worktree_space = Some(crate::workspace::WorktreeSpaceMembership { + key: "repo-key".into(), + label: "herdr".into(), + repo_root: repo.clone(), + checkout_path: checkout.clone(), + is_linked_worktree: true, + }); + app.state.active = Some(0); + app.state.selected = 0; + app.open_remove_linked_worktree_confirmation(0); + + app.start_worktree_remove(); + let safe_event = wait_for_worktree_event(&mut app); + match safe_event { + AppEvent::WorktreeRemoveFinished(result) => { + assert_eq!(result.workspace_id, workspace_id); + assert_eq!(result.path, checkout); + assert!(result.result.is_err()); + app.handle_worktree_remove_finished(result); + } + other => panic!("unexpected event: {other:?}"), + } + + let remove = app.state.worktree_remove.as_ref().unwrap(); + assert!(!remove.removing); + assert!(remove.force_confirmation); + assert!(checkout.exists()); + + app.start_worktree_remove(); + let force_event = wait_for_worktree_event(&mut app); + match force_event { + AppEvent::WorktreeRemoveFinished(result) => { + assert_eq!(result.workspace_id, workspace_id); + assert_eq!(result.path, checkout); + assert_eq!(result.result, Ok(())); + app.handle_worktree_remove_finished(result); + } + other => panic!("unexpected event: {other:?}"), + } + + assert!(!checkout.exists()); + assert!(app.state.worktree_remove.is_none()); + assert!(app.state.workspaces.is_empty()); + + let _ = std::fs::remove_dir_all(repo); + } +} |
