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 | |
| parent | a9ff4012bdca0dbdbc77bc6e53a430689fc899be (diff) | |
feat: add git worktree workspace management
refs #137
Diffstat (limited to 'src')
| -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 | ||||
| -rw-r--r-- | src/config/io.rs | 8 | ||||
| -rw-r--r-- | src/config/keybinds.rs | 25 | ||||
| -rw-r--r-- | src/config/model.rs | 38 | ||||
| -rw-r--r-- | src/events.rs | 17 | ||||
| -rw-r--r-- | src/main.rs | 7 | ||||
| -rw-r--r-- | src/persist/io.rs | 1 | ||||
| -rw-r--r-- | src/persist/restore.rs | 31 | ||||
| -rw-r--r-- | src/persist/snapshot.rs | 38 | ||||
| -rw-r--r-- | src/server/headless.rs | 43 | ||||
| -rw-r--r-- | src/ui.rs | 31 | ||||
| -rw-r--r-- | src/ui/dialogs.rs | 497 | ||||
| -rw-r--r-- | src/ui/keybind_help.rs | 6 | ||||
| -rw-r--r-- | src/ui/sidebar.rs | 691 | ||||
| -rw-r--r-- | src/workspace.rs | 30 | ||||
| -rw-r--r-- | src/workspace/git.rs | 95 | ||||
| -rw-r--r-- | src/worktree.rs | 478 |
30 files changed, 4613 insertions, 125 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); + } +} diff --git a/src/config/io.rs b/src/config/io.rs index 3b483ba6..fe415d1c 100644 --- a/src/config/io.rs +++ b/src/config/io.rs @@ -191,6 +191,14 @@ fn load_live_config_from_str(content: &str) -> Result<LoadedConfig, Vec<String>> ); load_live_section( table, + "worktrees", + "worktree config", + &mut diagnostics, + &mut invalid_sections, + |section| config.worktrees = section, + ); + load_live_section( + table, "experimental", "experimental config", &mut diagnostics, diff --git a/src/config/keybinds.rs b/src/config/keybinds.rs index 1c9c491b..edd64c90 100644 --- a/src/config/keybinds.rs +++ b/src/config/keybinds.rs @@ -246,6 +246,9 @@ pub struct Keybinds { pub help: ActionKeybinds, pub settings: ActionKeybinds, pub new_workspace: ActionKeybinds, + pub new_worktree: ActionKeybinds, + pub open_worktree: ActionKeybinds, + pub remove_worktree: ActionKeybinds, pub rename_workspace: ActionKeybinds, pub close_workspace: ActionKeybinds, pub workspace_picker: ActionKeybinds, @@ -375,6 +378,9 @@ impl Config { help: action!("keys.help", &self.keys.help), settings: action!("keys.settings", &self.keys.settings), new_workspace: action!("keys.new_workspace", &self.keys.new_workspace), + new_worktree: action!("keys.new_worktree", &self.keys.new_worktree), + open_worktree: action!("keys.open_worktree", &self.keys.open_worktree), + remove_worktree: action!("keys.remove_worktree", &self.keys.remove_worktree), rename_workspace: action!("keys.rename_workspace", &self.keys.rename_workspace), close_workspace: action!("keys.close_workspace", &self.keys.close_workspace), workspace_picker: action!("keys.workspace_picker", &self.keys.workspace_picker), @@ -1042,6 +1048,25 @@ next_tab = "prefix+n" } #[test] + fn new_worktree_defaults_to_prefix_shift_g() { + let kb = Config::default().keybinds(); + assert_eq!( + binding_triggers(&kb.new_worktree), + vec![BindingTrigger::Prefix(( + KeyCode::Char('g'), + KeyModifiers::SHIFT + ))] + ); + } + + #[test] + fn open_and_remove_worktree_keybinds_are_unset_by_default() { + let kb = Config::default().keybinds(); + assert!(kb.open_worktree.bindings.is_empty()); + assert!(kb.remove_worktree.bindings.is_empty()); + } + + #[test] fn array_bindings_allow_prefix_and_modified_direct() { let config: Config = toml::from_str( r#" diff --git a/src/config/model.rs b/src/config/model.rs index f270a69c..215e6f32 100644 --- a/src/config/model.rs +++ b/src/config/model.rs @@ -81,6 +81,7 @@ pub struct Config { pub terminal: TerminalConfig, pub keys: KeysConfig, pub ui: UiConfig, + pub worktrees: WorktreesConfig, pub advanced: AdvancedConfig, pub experimental: ExperimentalConfig, } @@ -103,6 +104,12 @@ pub struct KeysConfig { pub settings: BindingConfig, /// Create a new workspace. Default: "prefix+shift+n" pub new_workspace: BindingConfig, + /// Create a Git worktree from the selected workspace. Default: "prefix+shift+g" + pub new_worktree: BindingConfig, + /// Open an existing Git worktree from the selected workspace. Unset by default. + pub open_worktree: BindingConfig, + /// Delete the selected managed worktree checkout after confirmation. Unset by default. + pub remove_worktree: BindingConfig, /// Rename the selected workspace. Default: "prefix+shift+w" pub rename_workspace: BindingConfig, /// Close the selected workspace. Default: "prefix+shift+d" @@ -188,6 +195,13 @@ pub struct IndexedKeysConfig { #[derive(Debug, Deserialize)] #[serde(default)] +pub struct WorktreesConfig { + /// Root directory under which Herdr creates <repo>/<branch-slug> checkouts. + pub directory: String, +} + +#[derive(Debug, Deserialize)] +#[serde(default)] pub struct UiConfig { pub sidebar_width: u16, /// Minimum sidebar width (columns) when expanded. Default: 18. @@ -288,6 +302,9 @@ impl Default for KeysConfig { help: BindingConfig::one("prefix+?"), settings: BindingConfig::one("prefix+s"), new_workspace: BindingConfig::one("prefix+shift+n"), + new_worktree: BindingConfig::one("prefix+shift+g"), + open_worktree: BindingConfig::empty(), + remove_worktree: BindingConfig::empty(), rename_workspace: BindingConfig::one("prefix+shift+w"), close_workspace: BindingConfig::one("prefix+shift+d"), workspace_picker: BindingConfig::one("prefix+w"), @@ -326,6 +343,14 @@ impl Default for KeysConfig { } } +impl Default for WorktreesConfig { + fn default() -> Self { + Self { + directory: "~/.herdr/worktrees".into(), + } + } +} + impl Default for UiConfig { fn default() -> Self { Self { @@ -432,6 +457,19 @@ show_agent_labels_on_pane_borders = true } #[test] + fn worktrees_directory_defaults_and_parses() { + let default_config = Config::default(); + assert_eq!(default_config.worktrees.directory, "~/.herdr/worktrees"); + + let toml = r#" +[worktrees] +directory = "~/Projects/herdr-worktrees" +"#; + let config: Config = toml::from_str(toml).unwrap(); + assert_eq!(config.worktrees.directory, "~/Projects/herdr-worktrees"); + } + + #[test] fn prompt_new_tab_name_defaults_on_and_parses() { let default_config = Config::default(); assert!(default_config.ui.prompt_new_tab_name); diff --git a/src/events.rs b/src/events.rs index 2e0143e3..90a65957 100644 --- a/src/events.rs +++ b/src/events.rs @@ -7,6 +7,19 @@ use crate::detect::{Agent, AgentState}; use crate::layout::PaneId; use crate::workspace::WorkspaceGitStatus; +#[derive(Debug)] +pub struct WorktreeAddResult { + pub path: std::path::PathBuf, + pub result: Result<(), String>, +} + +#[derive(Debug)] +pub struct WorktreeRemoveResult { + pub workspace_id: String, + pub path: std::path::PathBuf, + pub result: Result<(), String>, +} + /// An event from a background task to the main loop. #[derive(Debug)] pub enum AppEvent { @@ -52,4 +65,8 @@ pub enum AppEvent { ClipboardWrite { content: Vec<u8> }, /// Background git status refresh completed for workspaces. GitStatusRefreshed { results: Vec<WorkspaceGitStatus> }, + /// Background `git worktree add` completed. + WorktreeAddFinished(WorktreeAddResult), + /// Background `git worktree remove` completed. + WorktreeRemoveFinished(WorktreeRemoveResult), } diff --git a/src/main.rs b/src/main.rs index 59aac658..a846d092 100644 --- a/src/main.rs +++ b/src/main.rs @@ -50,6 +50,7 @@ mod terminal_theme; mod ui; mod update; mod workspace; +mod worktree; fn init_logging() { crate::logging::init_file_logging("herdr.log"); @@ -100,6 +101,9 @@ const DEFAULT_CONFIG: &str = r##"# herdr configuration # open_notification_target = "prefix+o" # workspace_picker = "prefix+w" # new_workspace = "prefix+shift+n" +# new_worktree = "prefix+shift+g" +# open_worktree = "" # optional, unset by default +# remove_worktree = "" # optional, unset by default; opens confirmation # rename_workspace = "prefix+shift+w" # close_workspace = "prefix+shift+d" # previous_workspace = "" # optional, unset by default @@ -144,6 +148,9 @@ const DEFAULT_CONFIG: &str = r##"# herdr configuration # workspaces = "" # e.g. "ctrl+shift" makes ctrl+shift+1..9 switch workspaces directly # agents = "" # e.g. "alt" makes alt+1..9 focus agent rows directly +# [worktrees] +# directory = "~/.herdr/worktrees" + [ui] # Sidebar width (auto-scaled based on workspace names, this sets the default) # sidebar_width = 26 diff --git a/src/persist/io.rs b/src/persist/io.rs index f8525bc5..1b589b09 100644 --- a/src/persist/io.rs +++ b/src/persist/io.rs @@ -133,6 +133,7 @@ mod tests { agent_panel_scope: AgentPanelScope::CurrentWorkspace, sidebar_width: Some(26), sidebar_section_split: Some(0.5), + collapsed_space_keys: std::collections::HashSet::new(), } } diff --git a/src/persist/restore.rs b/src/persist/restore.rs index f558761d..ebad2f16 100644 --- a/src/persist/restore.rs +++ b/src/persist/restore.rs @@ -99,6 +99,8 @@ fn restore_workspace( return None; } + let worktree_space = restored_worktree_space_membership(snap.worktree_space.clone()); + Some(( Workspace { id: snap @@ -107,8 +109,10 @@ fn restore_workspace( .unwrap_or_else(crate::workspace::generate_workspace_id), custom_name: snap.custom_name.clone(), identity_cwd: snap.identity_cwd.clone(), - cached_git_branch: None, + cached_git_branch: crate::workspace::git_branch(&snap.identity_cwd), cached_git_ahead_behind: None, + cached_git_space: crate::workspace::git_space_metadata(&snap.identity_cwd), + worktree_space, public_pane_numbers, next_public_pane_number, active_tab: snap.active_tab.min(tabs.len().saturating_sub(1)), @@ -121,6 +125,16 @@ fn restore_workspace( )) } +fn restored_worktree_space_membership( + space: Option<crate::workspace::WorktreeSpaceMembership>, +) -> Option<crate::workspace::WorktreeSpaceMembership> { + space.filter(|space| { + space.checkout_path.exists() + && crate::workspace::git_space_metadata(&space.checkout_path) + .is_some_and(|current| current.key == space.key) + }) +} + fn restore_tab( snap: &TabSnapshot, number: usize, @@ -407,4 +421,19 @@ mod tests { Some(first) ); } + + #[test] + fn restored_worktree_space_membership_drops_missing_checkout() { + let missing = + std::env::temp_dir().join(format!("herdr-missing-worktree-{}", std::process::id())); + let membership = crate::workspace::WorktreeSpaceMembership { + key: "repo-key".into(), + label: "herdr".into(), + repo_root: missing.join("repo"), + checkout_path: missing.join("checkout"), + is_linked_worktree: true, + }; + + assert_eq!(restored_worktree_space_membership(Some(membership)), None); + } } diff --git a/src/persist/snapshot.rs b/src/persist/snapshot.rs index a3811c36..79d5c406 100644 --- a/src/persist/snapshot.rs +++ b/src/persist/snapshot.rs @@ -26,6 +26,8 @@ pub struct SessionSnapshot { pub sidebar_width: Option<u16>, #[serde(default)] pub sidebar_section_split: Option<f32>, + #[serde(default)] + pub collapsed_space_keys: std::collections::HashSet<String>, } #[derive(Serialize, Deserialize)] @@ -35,6 +37,8 @@ pub struct WorkspaceSnapshot { #[serde(default)] pub custom_name: Option<String>, pub identity_cwd: PathBuf, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub worktree_space: Option<crate::workspace::WorktreeSpaceMembership>, pub tabs: Vec<TabSnapshot>, #[serde(default)] pub active_tab: usize, @@ -109,6 +113,7 @@ impl From<LegacyWorkspaceSnapshot> for WorkspaceSnapshot { id: None, custom_name: snap.custom_name, identity_cwd, + worktree_space: None, tabs: vec![tab], active_tab: 0, } @@ -131,6 +136,8 @@ struct RawSessionSnapshot { sidebar_width: Option<u16>, #[serde(default)] sidebar_section_split: Option<f32>, + #[serde(default)] + collapsed_space_keys: std::collections::HashSet<String>, } fn migrate_snapshot(raw: RawSessionSnapshot) -> Result<SessionSnapshot, String> { @@ -146,6 +153,7 @@ fn migrate_snapshot(raw: RawSessionSnapshot) -> Result<SessionSnapshot, String> agent_panel_scope: raw.agent_panel_scope, sidebar_width: raw.sidebar_width, sidebar_section_split: raw.sidebar_section_split, + collapsed_space_keys: raw.collapsed_space_keys, }) } @@ -208,6 +216,7 @@ pub fn capture( agent_panel_scope: crate::app::state::AgentPanelScope, sidebar_width: u16, sidebar_section_split: f32, + collapsed_space_keys: std::collections::HashSet<String>, ) -> SessionSnapshot { SessionSnapshot { version: SNAPSHOT_VERSION, @@ -220,6 +229,7 @@ pub fn capture( agent_panel_scope, sidebar_width: Some(sidebar_width), sidebar_section_split: Some(sidebar_section_split), + collapsed_space_keys, } } @@ -237,6 +247,7 @@ fn capture_workspace( identity_cwd: ws .resolved_identity_cwd_from(terminals, terminal_runtimes) .unwrap_or_else(|| ws.identity_cwd.clone()), + worktree_space: ws.worktree_space.clone(), tabs: ws .tabs .iter() @@ -375,6 +386,7 @@ mod tests { state.agent_panel_scope, state.sidebar_width, state.sidebar_section_split, + state.collapsed_space_keys.clone(), ) } @@ -395,6 +407,7 @@ mod tests { agent_panel_scope: AgentPanelScope::CurrentWorkspace, sidebar_width: Some(26), sidebar_section_split: Some(0.5), + collapsed_space_keys: std::collections::HashSet::new(), }; let json = serde_json::to_string(&snap).unwrap(); let restored = parse_snapshot(&json).unwrap(); @@ -451,6 +464,7 @@ mod tests { id: Some("wproj".to_string()), custom_name: Some("pi-mono".to_string()), identity_cwd: PathBuf::from("/home/can/Projects/herdr"), + worktree_space: None, tabs: vec![TabSnapshot { custom_name: Some("api".to_string()), layout: LayoutSnapshot::Split { @@ -471,6 +485,7 @@ mod tests { agent_panel_scope: AgentPanelScope::CurrentWorkspace, sidebar_width: Some(26), sidebar_section_split: Some(0.5), + collapsed_space_keys: std::collections::HashSet::new(), version: SNAPSHOT_VERSION, }; @@ -622,11 +637,32 @@ mod tests { state.sidebar_width = 31; state.sidebar_section_split = 0.4; state.agent_panel_scope = AgentPanelScope::AllWorkspaces; + state.collapsed_space_keys.insert("repo-key".into()); let snapshot = capture_from_state(&state); assert_eq!(snapshot.sidebar_width, Some(31)); assert_eq!(snapshot.sidebar_section_split, Some(0.4)); assert_eq!(snapshot.agent_panel_scope, AgentPanelScope::AllWorkspaces); + assert!(snapshot.collapsed_space_keys.contains("repo-key")); + } + + #[test] + fn capture_contract_tracks_worktree_space_membership() { + let mut state = state_with_workspaces(&["main"]); + state.workspaces[0].worktree_space = Some(crate::workspace::WorktreeSpaceMembership { + key: "repo-key".into(), + label: "herdr".into(), + repo_root: PathBuf::from("/repo/herdr"), + checkout_path: PathBuf::from("/repo/herdr/worktree-a"), + is_linked_worktree: true, + }); + + let snapshot = capture_from_state(&state); + + assert_eq!( + snapshot.workspaces[0].worktree_space, + state.workspaces[0].worktree_space + ); } #[test] @@ -776,6 +812,7 @@ mod tests { id: Some("test-ws".to_string()), custom_name: Some("fallback test".to_string()), identity_cwd: PathBuf::from("/tmp"), + worktree_space: None, tabs: vec![TabSnapshot { custom_name: None, layout: LayoutSnapshot::Split { @@ -796,6 +833,7 @@ mod tests { agent_panel_scope: AgentPanelScope::CurrentWorkspace, sidebar_width: Some(26), sidebar_section_split: Some(0.5), + collapsed_space_keys: std::collections::HashSet::new(), }; let json = serde_json::to_string(&snap).unwrap(); diff --git a/src/server/headless.rs b/src/server/headless.rs index 842ded80..5c6a1ec6 100644 --- a/src/server/headless.rs +++ b/src/server/headless.rs @@ -25,7 +25,7 @@ use std::time::{Duration, Instant}; use ratatui::layout::Rect; use tokio::sync::mpsc; -use tracing::{debug, info, warn}; +use tracing::{debug, error, info, warn}; use base64::Engine; use bytes::Bytes; @@ -251,6 +251,47 @@ impl HeadlessServer { needs_render = true; } + if let Some(ws_idx) = self.app.state.request_new_linked_worktree.take() { + self.app.open_new_linked_worktree_dialog(ws_idx); + needs_render = true; + } + + if let Some(ws_idx) = self.app.state.request_open_existing_worktree.take() { + self.app.open_existing_worktree_dialog(ws_idx); + needs_render = true; + } + + if let Some(cwd) = self.app.state.request_new_workspace_cwd.take() { + if let Err(err) = self.app.create_workspace_with_options(cwd, true) { + error!(err = %err, "failed to create workspace at requested cwd"); + self.app.state.mode = app::Mode::Navigate; + } + needs_render = true; + } + + if let Some(ws_idx) = self.app.state.request_remove_linked_worktree.take() { + self.app.open_remove_linked_worktree_confirmation(ws_idx); + needs_render = true; + } + + if self.app.state.request_submit_worktree_create { + self.app.state.request_submit_worktree_create = false; + self.app.start_worktree_add(); + needs_render = true; + } + + if self.app.state.request_submit_worktree_open { + self.app.state.request_submit_worktree_open = false; + self.app.open_selected_existing_worktree(); + needs_render = true; + } + + if self.app.state.request_submit_worktree_remove { + self.app.state.request_submit_worktree_remove = false; + self.app.start_worktree_remove(); + needs_render = true; + } + if self.app.state.request_reload_config { self.app.state.request_reload_config = false; self.reload_server_config(); @@ -19,7 +19,10 @@ mod status; mod tabs; mod widgets; -use self::dialogs::{render_confirm_close_overlay, render_rename_overlay}; +use self::dialogs::{ + render_confirm_close_overlay, render_new_linked_worktree_overlay, + render_open_existing_worktree_overlay, render_remove_worktree_overlay, render_rename_overlay, +}; use self::keybind_help::render_keybind_help_overlay; use self::menus::{ render_context_menu, render_global_launcher_menu, render_navigate_overlay, @@ -48,14 +51,20 @@ use self::sidebar::{render_sidebar, render_sidebar_collapsed}; use self::status::{render_config_diagnostic, render_toast_notification, toast_notification_rect}; use self::tabs::render_tab_bar; pub(crate) use self::{ - dialogs::{confirm_close_button_rects, confirm_close_popup_rect, rename_button_rects}, + dialogs::{ + confirm_close_button_rects, confirm_close_popup_rect, new_linked_worktree_button_rects, + new_linked_worktree_inner_rect, open_existing_worktree_button_rects, + open_existing_worktree_inner_rect, open_existing_worktree_visible_start, + remove_worktree_button_rects, remove_worktree_popup_rect, rename_button_rects, + }, settings::{settings_button_rects, settings_show_primary_action}, sidebar::{ agent_panel_body_rect, agent_panel_entries, agent_panel_scroll_metrics, agent_panel_scrollbar_rect, agent_panel_toggle_rect, collapsed_sidebar_sections, collapsed_sidebar_toggle_rect, compute_workspace_card_areas, expanded_sidebar_sections, - sidebar_section_divider_rect, workspace_drop_indicator_row, workspace_list_rect, - workspace_list_scroll_metrics, workspace_list_scrollbar_rect, + normalized_workspace_scroll, sidebar_section_divider_rect, workspace_drop_indicator_row, + workspace_list_entries, workspace_list_rect, workspace_list_scroll_metrics, + workspace_list_scrollbar_rect, workspace_parent_group_state, WorkspaceListEntry, }, }; pub(crate) use self::{ @@ -180,14 +189,15 @@ fn compute_view_internal( (Rect::default(), main_area) }; - app.workspace_scroll = app - .workspace_scroll - .min(app.workspaces.len().saturating_sub(1)); if !app.sidebar_collapsed { + app.workspace_scroll = normalized_workspace_scroll(app, sidebar_area, app.workspace_scroll); let (_, detail_area) = expanded_sidebar_sections(sidebar_area, app.sidebar_section_split); let max_agent_scroll = agent_panel_scroll_metrics(app, detail_area).max_offset_from_bottom; app.agent_panel_scroll = app.agent_panel_scroll.min(max_agent_scroll); } else { + app.workspace_scroll = app + .workspace_scroll + .min(app.workspaces.len().saturating_sub(1)); app.agent_panel_scroll = 0; } @@ -348,7 +358,7 @@ pub fn render_with_runtime_registry( } else if app.sidebar_collapsed { render_sidebar_collapsed(app, frame, sidebar_area); } else { - render_sidebar(app, frame, sidebar_area); + render_sidebar(app, terminal_runtimes, frame, sidebar_area); } if app.view.layout != ViewLayout::Mobile { render_tab_bar(app, frame, tab_bar_area); @@ -376,6 +386,11 @@ pub fn render_with_runtime_registry( Mode::RenameWorkspace | Mode::RenameTab | Mode::RenamePane => { render_rename_overlay(app, frame, frame.area()) } + Mode::NewLinkedWorktree => render_new_linked_worktree_overlay(app, frame, frame.area()), + Mode::OpenExistingWorktree => { + render_open_existing_worktree_overlay(app, frame, frame.area()) + } + Mode::ConfirmRemoveWorktree => render_remove_worktree_overlay(app, frame, frame.area()), Mode::GlobalMenu => render_global_launcher_menu(app, frame), Mode::KeybindHelp => render_keybind_help_overlay(app, frame), Mode::Terminal => {} diff --git a/src/ui/dialogs.rs b/src/ui/dialogs.rs index 3c481a97..f7396fd8 100644 --- a/src/ui/dialogs.rs +++ b/src/ui/dialogs.rs @@ -12,6 +12,22 @@ use super::widgets::{ }; use crate::app::{AppState, Mode}; +fn truncate_text(text: &str, max_width: usize) -> String { + let len = text.chars().count(); + if len <= max_width { + return text.to_string(); + } + if max_width <= 1 { + return "…".into(); + } + format!( + "{}…", + text.chars() + .take(max_width.saturating_sub(1)) + .collect::<String>() + ) +} + pub(crate) fn rename_button_rects(inner: Rect) -> (Rect, Rect, Rect) { let rects = action_button_row_rects( inner, @@ -109,23 +125,449 @@ pub(super) fn render_rename_overlay(app: &AppState, frame: &mut Frame, area: Rec ); } -pub(super) fn render_confirm_close_overlay(app: &AppState, frame: &mut Frame, area: Rect) { +pub(crate) fn new_linked_worktree_inner_rect(area: Rect) -> Option<Rect> { + centered_popup_rect(area, 68, 10).map(|popup| { + Rect::new( + popup.x + 1, + popup.y + 1, + popup.width.saturating_sub(2), + popup.height.saturating_sub(2), + ) + }) +} + +pub(crate) fn new_linked_worktree_button_rects(inner: Rect) -> (Rect, Rect) { + let rects = action_button_row_rects( + inner, + &[ + ActionButtonSpec { + hint: Some("↵"), + label: "create and open", + }, + ActionButtonSpec { + hint: Some("esc"), + label: "cancel", + }, + ], + 2, + inner.height.saturating_sub(1), + ); + (rects[0], rects[1]) +} + +pub(crate) fn remove_worktree_popup_rect(area: Rect) -> Option<Rect> { + centered_popup_rect(area, 72, 10) +} + +pub(crate) fn remove_worktree_button_rects(inner: Rect, force_confirmation: bool) -> (Rect, Rect) { + let primary_label = if force_confirmation { + "delete anyway" + } else { + "remove" + }; + let rects = action_button_row_rects( + inner, + &[ + ActionButtonSpec { + hint: Some("↵"), + label: primary_label, + }, + ActionButtonSpec { + hint: Some("esc"), + label: "cancel", + }, + ], + 2, + inner.height.saturating_sub(1), + ); + (rects[0], rects[1]) +} + +pub(crate) fn open_existing_worktree_inner_rect(area: Rect, entry_count: usize) -> Option<Rect> { + let height = (entry_count as u16).saturating_add(5).clamp(8, 18); + centered_popup_rect(area, 78, height).map(|popup| { + Rect::new( + popup.x + 1, + popup.y + 1, + popup.width.saturating_sub(2), + popup.height.saturating_sub(2), + ) + }) +} + +pub(crate) fn open_existing_worktree_visible_start(selected: usize, max_rows: usize) -> usize { + selected.saturating_sub(max_rows.saturating_sub(1)) +} + +pub(crate) fn open_existing_worktree_button_rects(inner: Rect) -> (Rect, Rect) { + let rects = action_button_row_rects( + inner, + &[ + ActionButtonSpec { + hint: Some("↵"), + label: "open", + }, + ActionButtonSpec { + hint: Some("esc"), + label: "cancel", + }, + ], + 2, + inner.height.saturating_sub(1), + ); + (rects[0], rects[1]) +} + +pub(super) fn render_new_linked_worktree_overlay(app: &AppState, frame: &mut Frame, area: Rect) { + let Some(create) = app.worktree_create.as_ref() else { + return; + }; + + super::dim_background(frame, area); + let Some(inner) = render_modal_shell(frame, area, 68, 10, &app.palette) else { + return; + }; + if inner.height < 7 { + return; + } + + let rows = Layout::vertical([ + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(1), + Constraint::Min(0), + ]) + .areas::<8>(inner); + + render_modal_header(frame, rows[0], "new worktree", &app.palette); + + frame.render_widget( + Paragraph::new(" branch").style(Style::default().fg(app.palette.overlay0)), + rows[1], + ); + let input_rect = Rect::new(rows[2].x, rows[2].y, rows[2].width, 1); + frame.render_widget(Clear, input_rect); + frame.render_widget( + Paragraph::new(format!(" {}█", app.name_input)).style( + Style::default() + .fg(app.palette.text) + .bg(app.palette.surface0), + ), + input_rect, + ); + + let checkout = create.checkout_path.display().to_string(); + frame.render_widget( + Paragraph::new(" checkout").style(Style::default().fg(app.palette.overlay0)), + rows[3], + ); + frame.render_widget( + Paragraph::new(format!(" {checkout}")).style(Style::default().fg(app.palette.subtext0)), + rows[4], + ); + + if create.creating { + frame.render_widget( + Paragraph::new(" creating…").style(Style::default().fg(app.palette.overlay0)), + rows[5], + ); + } else if let Some(error) = &create.error { + frame.render_widget( + Paragraph::new(format!(" {error}")).style(Style::default().fg(app.palette.red)), + rows[5], + ); + } + + let (create_rect, cancel_rect) = new_linked_worktree_button_rects(inner); + render_action_button( + frame, + create_rect, + Some("↵"), + "create and open", + Style::default() + .fg(panel_contrast_fg(&app.palette)) + .bg(app.palette.accent) + .add_modifier(Modifier::BOLD), + ); + render_action_button( + frame, + cancel_rect, + Some("esc"), + "cancel", + Style::default() + .fg(app.palette.text) + .bg(app.palette.surface0) + .add_modifier(Modifier::BOLD), + ); +} + +pub(super) fn render_remove_worktree_overlay(app: &AppState, frame: &mut Frame, area: Rect) { + let Some(remove) = app.worktree_remove.as_ref() else { + return; + }; + + super::dim_background(frame, area); + let Some(popup) = remove_worktree_popup_rect(area) else { + return; + }; + let Some(inner) = render_panel_shell(frame, popup, app.palette.red, app.palette.panel_bg) + else { + return; + }; + + let rows = Layout::vertical([ + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(1), + Constraint::Min(0), + ]) + .areas::<8>(inner); + + frame.render_widget( + Paragraph::new(Line::from(vec![Span::styled( + " delete worktree checkout?", + Style::default() + .fg(app.palette.red) + .add_modifier(Modifier::BOLD), + )])), + rows[0], + ); + frame.render_widget( + Paragraph::new(" This removes the checkout folder:") + .style(Style::default().fg(app.palette.overlay0)), + rows[1], + ); + frame.render_widget( + Paragraph::new(format!(" {}", remove.path.display())) + .style(Style::default().fg(app.palette.text)), + rows[2], + ); + frame.render_widget( + Paragraph::new(" The branch is not deleted. The Herdr workspace will close.") + .style(Style::default().fg(app.palette.overlay0)), + rows[3], + ); + if remove.force_confirmation { + frame.render_widget( + Paragraph::new(" Dirty or untracked files will be permanently deleted.") + .style(Style::default().fg(app.palette.red)), + rows[4], + ); + } + if remove.removing { + frame.render_widget( + Paragraph::new(" removing…").style(Style::default().fg(app.palette.overlay0)), + rows[5], + ); + } else if let Some(error) = &remove.error { + frame.render_widget( + Paragraph::new(format!(" {error}")).style(Style::default().fg(app.palette.red)), + rows[5], + ); + } + + let (remove_rect, cancel_rect) = remove_worktree_button_rects(inner, remove.force_confirmation); + let remove_label = if remove.force_confirmation { + "delete anyway" + } else { + "remove" + }; + render_action_button( + frame, + remove_rect, + Some("↵"), + remove_label, + Style::default() + .fg(panel_contrast_fg(&app.palette)) + .bg(app.palette.red) + .add_modifier(Modifier::BOLD), + ); + render_action_button( + frame, + cancel_rect, + Some("esc"), + "cancel", + Style::default() + .fg(app.palette.text) + .bg(app.palette.surface0) + .add_modifier(Modifier::BOLD), + ); +} + +pub(super) fn render_open_existing_worktree_overlay(app: &AppState, frame: &mut Frame, area: Rect) { + let Some(open) = app.worktree_open.as_ref() else { + return; + }; + + super::dim_background(frame, area); + let height = (open.entries.len() as u16).saturating_add(5).clamp(8, 18); + let Some(inner) = render_modal_shell(frame, area, 78, height, &app.palette) else { + return; + }; + if inner.height < 6 { + return; + } + + render_modal_header( + frame, + Rect::new(inner.x, inner.y, inner.width, 1), + "open worktree", + &app.palette, + ); + frame.render_widget( + Paragraph::new(" existing checkouts").style(Style::default().fg(app.palette.overlay0)), + Rect::new(inner.x, inner.y.saturating_add(1), inner.width, 1), + ); + + let max_rows = inner.height.saturating_sub(4) as usize; + let start = open_existing_worktree_visible_start(open.selected, max_rows); + for (visible_idx, (entry_idx, entry)) in open + .entries + .iter() + .enumerate() + .skip(start) + .take(max_rows) + .enumerate() + { + let selected = entry_idx == open.selected; + let y = inner.y.saturating_add(2 + visible_idx as u16); + let marker = if selected { "›" } else { " " }; + let branch = entry + .branch + .as_deref() + .unwrap_or(if entry.is_linked_worktree { + "detached" + } else { + "root" + }); + let open_label = if entry.already_open_ws_idx.is_some() { + " open" + } else { + "" + }; + let label = format!("{marker} {branch}{open_label} {}", entry.path.display()); + let style = if selected { + Style::default() + .fg(app.palette.text) + .bg(app.palette.surface0) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(app.palette.subtext0) + }; + frame.render_widget( + Paragraph::new(truncate_text(&label, inner.width as usize)).style(style), + Rect::new(inner.x, y, inner.width, 1), + ); + } + + if let Some(error) = &open.error { + frame.render_widget( + Paragraph::new(format!(" {error}")).style(Style::default().fg(app.palette.red)), + Rect::new( + inner.x, + inner.y + inner.height.saturating_sub(2), + inner.width, + 1, + ), + ); + } + + let (open_rect, cancel_rect) = open_existing_worktree_button_rects(inner); + render_action_button( + frame, + open_rect, + Some("↵"), + "open", + Style::default() + .fg(panel_contrast_fg(&app.palette)) + .bg(app.palette.accent) + .add_modifier(Modifier::BOLD), + ); + render_action_button( + frame, + cancel_rect, + Some("esc"), + "cancel", + Style::default() + .fg(app.palette.text) + .bg(app.palette.surface0) + .add_modifier(Modifier::BOLD), + ); +} + +fn confirm_close_overlay_text(app: &AppState) -> (String, String) { let ws_name = app .workspaces .get(app.selected) .map(|ws| ws.display_name()) .unwrap_or_else(|| "?".to_string()); - let pane_count = app + let selected_space = app .workspaces .get(app.selected) - .map(|ws| ws.layout.pane_count()) - .unwrap_or(0); + .and_then(|ws| ws.worktree_space()); + let group_member_indices = selected_space + .filter(|space| !space.is_linked_worktree) + .map(|space| { + app.workspaces + .iter() + .enumerate() + .filter_map(|(idx, ws)| { + ws.worktree_space() + .is_some_and(|member| member.key == space.key) + .then_some(idx) + }) + .collect::<Vec<_>>() + }) + .unwrap_or_default(); + let closes_group = group_member_indices.len() > 1; + let pane_count = if closes_group { + group_member_indices + .iter() + .filter_map(|idx| app.workspaces.get(*idx)) + .map(|ws| ws.layout.pane_count()) + .sum() + } else { + app.workspaces + .get(app.selected) + .map(|ws| ws.layout.pane_count()) + .unwrap_or(0) + }; let pane_text = if pane_count == 1 { "1 pane".to_string() } else { format!("{pane_count} panes") }; + let workspace_text = if closes_group { + let count = group_member_indices.len(); + if count == 1 { + "1 workspace, ".to_string() + } else { + format!("{count} workspaces, ") + } + } else { + String::new() + }; + + let title = if closes_group { + "Close worktree group?" + } else { + "Close workspace?" + }; + let detail = format!("{ws_name} — {workspace_text}{pane_text}"); + (title.to_string(), detail) +} + +pub(super) fn render_confirm_close_overlay(app: &AppState, frame: &mut Frame, area: Rect) { + let (title, detail) = confirm_close_overlay_text(app); super::dim_background(frame, area); @@ -138,16 +580,22 @@ pub(super) fn render_confirm_close_overlay(app: &AppState, frame: &mut Frame, ar .add_modifier(Modifier::BOLD); let dim = Style::default().fg(app.palette.overlay0); - let title_line = Line::from(vec![Span::styled(" Close workspace?", warn)]); + let title_line = Line::from(vec![Span::styled(format!(" {title}"), warn)]); let detail_line = Line::from(vec![ Span::styled( - format!(" {ws_name}"), + format!(" {}", detail.split(" — ").next().unwrap_or(&detail)), Style::default() .fg(app.palette.text) .add_modifier(Modifier::BOLD), ), - Span::styled(format!(" — {pane_text}"), dim), + Span::styled( + detail + .split_once(" — ") + .map(|(_, rest)| format!(" — {rest}")) + .unwrap_or_default(), + dim, + ), ]); let Some(inner) = render_panel_shell(frame, popup, app.palette.red, app.palette.panel_bg) @@ -212,3 +660,38 @@ pub(crate) fn confirm_close_button_rects(inner: Rect) -> (Rect, Rect) { ); (rects[0], rects[1]) } + +#[cfg(test)] +mod tests { + use crate::{app::AppState, workspace::Workspace}; + + use super::confirm_close_overlay_text; + + #[test] + fn confirm_close_text_reports_parent_group_scope() { + let mut app = AppState::test_new(); + let mut parent = Workspace::test_new("main"); + parent.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, + }); + let mut child = Workspace::test_new("issue"); + child.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.workspaces = vec![parent, child]; + app.selected = 0; + + let (title, detail) = confirm_close_overlay_text(&app); + + assert_eq!(title, "Close worktree group?"); + assert_eq!(detail, "main — 2 workspaces, 2 panes"); + } +} diff --git a/src/ui/keybind_help.rs b/src/ui/keybind_help.rs index fb79077e..6fc778ea 100644 --- a/src/ui/keybind_help.rs +++ b/src/ui/keybind_help.rs @@ -80,6 +80,12 @@ pub(super) fn keybind_help_groups( let workspace_tab = vec![ (keybind_label(&kb.workspace_picker), "workspace navigation"), (keybind_label(&kb.new_workspace), "new workspace"), + (keybind_label(&kb.new_worktree), "new worktree"), + (keybind_label(&kb.open_worktree), "open worktree"), + ( + keybind_label(&kb.remove_worktree), + "delete worktree checkout", + ), (keybind_label(&kb.rename_workspace), "rename workspace"), (keybind_label(&kb.close_workspace), "close workspace"), (keybind_label(&kb.previous_workspace), "previous workspace"), diff --git a/src/ui/sidebar.rs b/src/ui/sidebar.rs index a2432716..509c400b 100644 --- a/src/ui/sidebar.rs +++ b/src/ui/sidebar.rs @@ -11,6 +11,7 @@ use super::status::{agent_icon, state_dot, state_label, state_label_color}; use crate::app::state::{AgentPanelScope, Palette}; use crate::app::{AppState, Mode}; use crate::detect::AgentState; +use crate::terminal::TerminalRuntimeRegistry; const WORKSPACE_SECTION_HEADER_ROWS: u16 = 2; const AGENT_PANEL_HEADER_ROWS: u16 = 3; @@ -224,6 +225,189 @@ fn workspace_row_height(ws: &crate::workspace::Workspace) -> u16 { } } +fn workspace_attention_priority(state: AgentState, seen: bool) -> u8 { + match (state, seen) { + (AgentState::Blocked, _) => 4, + (AgentState::Idle, false) => 3, + (AgentState::Working, _) => 2, + (AgentState::Idle, true) => 1, + (AgentState::Unknown, _) => 0, + } +} + +fn space_aggregate_state(app: &AppState, key: &str) -> (AgentState, bool) { + app.workspaces + .iter() + .filter(|ws| ws.worktree_space().is_some_and(|space| space.key == key)) + .map(|ws| ws.aggregate_state(&app.terminals)) + .max_by_key(|(state, seen)| workspace_attention_priority(*state, *seen)) + .unwrap_or((AgentState::Unknown, true)) +} + +pub(crate) fn workspace_parent_group_state( + app: &AppState, + ws_idx: usize, +) -> Option<(String, bool)> { + let space = app.workspaces.get(ws_idx)?.worktree_space()?; + if space.is_linked_worktree { + return None; + } + let member_count = app + .workspaces + .iter() + .filter(|ws| { + ws.worktree_space() + .is_some_and(|member| member.key == space.key) + }) + .count(); + (member_count >= 2).then(|| { + ( + space.key.clone(), + app.collapsed_space_keys.contains(&space.key), + ) + }) +} + +fn grouped_child_display_label(label: &str, branch: Option<&str>, has_custom_name: bool) -> String { + if has_custom_name { + return label.to_string(); + } + let Some(branch) = branch else { + return label.to_string(); + }; + branch + .strip_prefix("worktree/") + .unwrap_or(branch) + .to_string() +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum WorkspaceListEntry { + Workspace { ws_idx: usize, indented: bool }, +} + +fn next_entry_is_indented_workspace(entries: &[WorkspaceListEntry], idx: usize) -> bool { + matches!( + entries.get(idx.saturating_add(1)), + Some(WorkspaceListEntry::Workspace { indented: true, .. }) + ) +} + +pub(crate) fn normalized_workspace_scroll(app: &AppState, area: Rect, requested: usize) -> usize { + let ws_area = workspace_list_rect(area, app.sidebar_section_split); + let body = workspace_list_body_rect(ws_area, false); + if body.height == 0 { + return requested; + } + + let entry_count = workspace_list_entries(app).len(); + if entry_count == 0 { + 0 + } else { + requested.min(entry_count.saturating_sub(1)) + } +} + +pub(crate) fn workspace_list_entries(app: &AppState) -> Vec<WorkspaceListEntry> { + let mut members_by_key = std::collections::HashMap::<String, Vec<usize>>::new(); + for (ws_idx, ws) in app.workspaces.iter().enumerate() { + if let Some(space) = ws.worktree_space() { + members_by_key + .entry(space.key.clone()) + .or_default() + .push(ws_idx); + } + } + let grouped_keys = members_by_key + .iter() + .filter(|(_, members)| { + members.len() >= 2 + && members.iter().any(|idx| { + app.workspaces + .get(*idx) + .and_then(|ws| ws.worktree_space()) + .is_some_and(|space| !space.is_linked_worktree) + }) + }) + .map(|(key, _)| key.clone()) + .collect::<std::collections::HashSet<_>>(); + + let visible_group_idx = if matches!(app.mode, Mode::Navigate) { + Some(app.selected) + } else { + app.active + }; + let active_group = visible_group_idx.and_then(|idx| { + app.workspaces + .get(idx) + .and_then(|ws| ws.worktree_space()) + .map(|space| space.key.clone()) + }); + + let mut emitted_groups = std::collections::HashSet::<String>::new(); + let mut entries = Vec::new(); + for (ws_idx, ws) in app.workspaces.iter().enumerate() { + let Some(space) = ws + .worktree_space() + .filter(|space| grouped_keys.contains(&space.key)) + else { + entries.push(WorkspaceListEntry::Workspace { + ws_idx, + indented: false, + }); + continue; + }; + + if !emitted_groups.insert(space.key.clone()) { + continue; + } + + let Some(members) = members_by_key.get(&space.key) else { + continue; + }; + let Some(parent_idx) = members.iter().copied().find(|idx| { + app.workspaces + .get(*idx) + .and_then(|member| member.worktree_space()) + .is_some_and(|member_space| !member_space.is_linked_worktree) + }) else { + entries.push(WorkspaceListEntry::Workspace { + ws_idx, + indented: false, + }); + continue; + }; + let collapsed = app.collapsed_space_keys.contains(&space.key); + entries.push(WorkspaceListEntry::Workspace { + ws_idx: parent_idx, + indented: false, + }); + + if collapsed { + if let Some(active_idx) = visible_group_idx + .filter(|idx| *idx != parent_idx) + .filter(|_| active_group.as_deref() == Some(space.key.as_str())) + { + entries.push(WorkspaceListEntry::Workspace { + ws_idx: active_idx, + indented: true, + }); + } + } else { + for member_idx in members { + if *member_idx == parent_idx { + continue; + } + entries.push(WorkspaceListEntry::Workspace { + ws_idx: *member_idx, + indented: true, + }); + } + } + } + entries +} + pub(crate) fn workspace_list_rect(area: Rect, split_ratio: f32) -> Rect { let (ws_area, _) = expanded_sidebar_sections(area, split_ratio); ws_area @@ -249,8 +433,24 @@ fn workspace_list_visible_count(app: &AppState, area: Rect, scroll: usize) -> us let mut used_rows = 0u16; let mut visible = 0usize; - for ws in app.workspaces.iter().skip(scroll) { - let needed = workspace_row_height(ws).saturating_add(1); + let entries = workspace_list_entries(app); + for (entry_idx, entry) in entries.iter().enumerate().skip(scroll) { + let needed = match entry { + WorkspaceListEntry::Workspace { ws_idx, indented } => { + let Some(ws) = app.workspaces.get(*ws_idx) else { + continue; + }; + let row_height = if *indented { + 1 + } else { + workspace_row_height(ws) + }; + let gap = u16::from( + !(*indented && next_entry_is_indented_workspace(&entries, entry_idx)), + ); + row_height.saturating_add(gap) + } + }; if used_rows.saturating_add(needed) > body.height { break; } @@ -264,11 +464,13 @@ pub(crate) fn workspace_list_scroll_metrics( app: &AppState, area: Rect, ) -> crate::pane::ScrollMetrics { - let viewport_rows = workspace_list_visible_count(app, area, app.workspace_scroll); - let total_rows = app.workspaces.len(); + let entries = workspace_list_entries(app); + let total_rows = entries.len(); + let scroll = app.workspace_scroll.min(total_rows.saturating_sub(1)); + let viewport_rows = workspace_list_visible_count(app, area, scroll); let max_offset_from_bottom = total_rows.saturating_sub(viewport_rows); let offset_from_bottom = total_rows - .saturating_sub(app.workspace_scroll) + .saturating_sub(scroll) .saturating_sub(viewport_rows); crate::pane::ScrollMetrics { @@ -344,38 +546,63 @@ pub(crate) fn agent_panel_scrollbar_rect(app: &AppState, area: Rect) -> Option<R )) } -pub(crate) fn compute_workspace_card_areas( +pub(crate) fn compute_workspace_list_areas( app: &AppState, area: Rect, -) -> Vec<crate::app::state::WorkspaceCardArea> { +) -> (Vec<crate::app::state::WorkspaceCardArea>, Vec<()>) { let ws_area = workspace_list_rect(area, app.sidebar_section_split); if ws_area == Rect::default() { - return Vec::new(); + return (Vec::new(), Vec::new()); } let metrics = workspace_list_scroll_metrics(app, ws_area); let body = workspace_list_body_rect(ws_area, should_show_scrollbar(metrics)); if body.width == 0 || body.height == 0 { - return Vec::new(); + return (Vec::new(), Vec::new()); } + let scroll = app.workspace_scroll; let mut row_y = body.y; let body_bottom = body.y + body.height; let mut cards = Vec::new(); - - for (ws_idx, ws) in app.workspaces.iter().enumerate().skip(app.workspace_scroll) { - let row_height = workspace_row_height(ws); - if row_y.saturating_add(row_height).saturating_add(1) > body_bottom { - break; + let headers = Vec::new(); + + let entries = workspace_list_entries(app); + for (entry_idx, entry) in entries.iter().enumerate().skip(scroll) { + match entry { + WorkspaceListEntry::Workspace { ws_idx, indented } => { + let Some(ws) = app.workspaces.get(*ws_idx) else { + continue; + }; + let row_height = if *indented { + 1 + } else { + workspace_row_height(ws) + }; + let gap = u16::from( + !(*indented && next_entry_is_indented_workspace(&entries, entry_idx)), + ); + if row_y.saturating_add(row_height).saturating_add(gap) > body_bottom { + break; + } + cards.push(crate::app::state::WorkspaceCardArea { + ws_idx: *ws_idx, + rect: Rect::new(body.x, row_y, body.width, row_height), + indented: *indented, + }); + row_y = row_y.saturating_add(row_height + gap); + } } - cards.push(crate::app::state::WorkspaceCardArea { - ws_idx, - rect: Rect::new(body.x, row_y, body.width, row_height), - }); - row_y = row_y.saturating_add(row_height + 1); } - cards + (cards, headers) +} + +pub(crate) fn compute_workspace_card_areas( + app: &AppState, + area: Rect, +) -> Vec<crate::app::state::WorkspaceCardArea> { + compute_workspace_list_areas(app, area).0 } /// Auto-scale sidebar width based on workspace identity + agent summary. @@ -530,18 +757,28 @@ pub(crate) fn workspace_drop_indicator_row( return first.rect.y.checked_sub(1).filter(|y| *y < list_bottom); } - if let Some(card) = cards.iter().find(|card| card.ws_idx == insert_idx) { - return card.rect.y.checked_sub(1).filter(|y| *y < list_bottom); - } - - cards + if let Some(row) = cards .last() .filter(|card| insert_idx == card.ws_idx.saturating_add(1)) .map(|card| card.rect.y.saturating_add(card.rect.height)) .filter(|y| *y < list_bottom) + { + return Some(row); + } + + if let Some(card) = cards.iter().find(|card| card.ws_idx == insert_idx) { + return card.rect.y.checked_sub(1).filter(|y| *y < list_bottom); + } + + None } -pub(super) fn render_sidebar(app: &AppState, frame: &mut Frame, area: Rect) { +pub(super) fn render_sidebar( + app: &AppState, + terminal_runtimes: &TerminalRuntimeRegistry, + frame: &mut Frame, + area: Rect, +) { let p = &app.palette; let is_navigating = matches!(app.mode, Mode::Navigate); let sep_style = if is_navigating { @@ -559,12 +796,18 @@ pub(super) fn render_sidebar(app: &AppState, frame: &mut Frame, area: Rect) { let (ws_area, detail_area) = expanded_sidebar_sections(area, app.sidebar_section_split); - render_workspace_list(app, frame, ws_area, is_navigating); + render_workspace_list(app, terminal_runtimes, frame, ws_area, is_navigating); render_agent_detail(app, frame, detail_area); render_sidebar_toggle(app, frame, area, false, p); } -fn render_workspace_list(app: &AppState, frame: &mut Frame, area: Rect, is_navigating: bool) { +fn render_workspace_list( + app: &AppState, + terminal_runtimes: &TerminalRuntimeRegistry, + frame: &mut Frame, + area: Rect, + is_navigating: bool, +) { let p = &app.palette; let dragged_ws_idx = match app.drag.as_ref().map(|drag| &drag.target) { Some(crate::app::state::DragTarget::WorkspaceReorder { source_ws_idx, .. }) => { @@ -625,17 +868,6 @@ fn render_workspace_list(app: &AppState, frame: &mut Frame, area: Rect, is_navig } } - if is_active { - let buf = frame.buffer_mut(); - for y in row_y..row_y + row_height { - if y >= list_bottom { - break; - } - buf[(card.rect.x, y)].set_symbol("▌"); - buf[(card.rect.x, y)].set_style(Style::default().fg(p.accent)); - } - } - let name_style = if selected || is_active || is_dragged { Style::default().fg(p.text).add_modifier(Modifier::BOLD) } else { @@ -643,12 +875,43 @@ fn render_workspace_list(app: &AppState, frame: &mut Frame, area: Rect, is_navig }; let (icon, icon_style) = state_dot(agg_state, agg_seen, p); - let line1 = vec![ - Span::styled(" ", Style::default()), - Span::styled(icon, icon_style), - Span::styled(" ", Style::default()), - Span::styled(ws.display_name(), name_style), - ]; + let label = ws.display_name_from(&app.terminals, terminal_runtimes); + let mut line1 = Vec::new(); + let mut show_workspace_icon = true; + if card.indented { + line1.push(Span::styled(" ", Style::default())); + } else if let Some((key, collapsed)) = workspace_parent_group_state(app, i) { + let icon = if collapsed { "▸" } else { "▾" }; + let (state_icon, state_style) = if collapsed { + let (state, seen) = space_aggregate_state(app, &key); + state_dot(state, seen, p) + } else { + (icon, Style::default().fg(p.accent)) + }; + line1.push(Span::styled(icon, Style::default().fg(p.accent))); + if collapsed { + line1.push(Span::styled(" ", Style::default())); + line1.push(Span::styled(state_icon, state_style)); + show_workspace_icon = false; + } + line1.push(Span::styled(" ", Style::default())); + } else { + line1.push(Span::styled(" ", Style::default())); + } + if show_workspace_icon { + line1.push(Span::styled(icon, icon_style)); + line1.push(Span::styled(" ", Style::default())); + } + if card.indented { + let display_label = grouped_child_display_label( + &label, + ws.branch().as_deref(), + ws.custom_name.is_some(), + ); + line1.push(Span::styled(display_label, name_style)); + } else { + line1.push(Span::styled(label, name_style)); + } frame.render_widget( Paragraph::new(Line::from(line1)), @@ -684,8 +947,9 @@ fn render_workspace_list(app: &AppState, frame: &mut Frame, area: Rect, is_navig } else { p.overlay0 }; + let branch_indent = if card.indented { " " } else { " " }; let mut spans = vec![ - Span::styled(" ", Style::default()), + Span::styled(branch_indent, Style::default()), Span::styled(branch_display, Style::default().fg(branch_color)), ]; if let Some(parts) = upstream_label { @@ -996,4 +1260,339 @@ mod tests { assert_eq!(divider, Rect::default()); } + + #[test] + fn grouped_child_label_keeps_custom_workspace_name() { + assert_eq!( + grouped_child_display_label("renamed issue", Some("worktree/issue-137"), true), + "renamed issue" + ); + } + + #[test] + fn grouped_child_label_uses_short_branch_for_auto_named_workspace() { + assert_eq!( + grouped_child_display_label("herdr-issue", Some("worktree/issue-137"), false), + "issue-137" + ); + } + + fn workspace_with_worktree_space( + name: &str, + key: Option<&str>, + checkout_key: &str, + ) -> crate::workspace::Workspace { + let mut ws = crate::workspace::Workspace::test_new(name); + if let Some(key) = key { + ws.worktree_space = Some(crate::workspace::WorktreeSpaceMembership { + key: key.into(), + label: "herdr".into(), + repo_root: std::path::PathBuf::from("/repo/herdr"), + checkout_path: std::path::PathBuf::from(checkout_key), + is_linked_worktree: name != "main", + }); + } + ws + } + + fn workspace_with_git_space(name: &str, key: &str) -> crate::workspace::Workspace { + let mut ws = crate::workspace::Workspace::test_new(name); + ws.cached_git_space = Some(crate::workspace::GitSpaceMetadata { + key: key.into(), + checkout_key: format!("/repo/{name}"), + label: "herdr".into(), + repo_root: std::path::PathBuf::from(format!("/repo/{name}")), + is_linked_worktree: false, + }); + ws + } + + #[test] + fn parent_workspace_row_stays_clickable_when_grouped() { + let mut app = AppState::test_new(); + app.workspaces = vec![ + workspace_with_worktree_space("main", Some("repo-key"), "/repo/herdr"), + workspace_with_worktree_space("issue", Some("repo-key"), "/repo/herdr-issue"), + ]; + + let (cards, headers) = compute_workspace_list_areas(&app, Rect::new(0, 0, 30, 20)); + + assert!(headers.is_empty()); + assert_eq!(cards[0].ws_idx, 0); + assert!(!cards[0].indented); + assert_eq!(cards[1].ws_idx, 1); + assert!(cards[1].indented); + assert_eq!(cards[1].rect.y, cards[0].rect.y + cards[0].rect.height + 1); + } + + #[test] + fn linked_only_worktree_members_do_not_form_parentless_group() { + let mut app = AppState::test_new(); + app.workspaces = vec![ + workspace_with_worktree_space("issue", Some("repo-key"), "/repo/herdr-issue"), + workspace_with_worktree_space("review", Some("repo-key"), "/repo/herdr-review"), + ]; + + let entries = workspace_list_entries(&app); + + assert_eq!( + entries, + vec![ + WorkspaceListEntry::Workspace { + ws_idx: 0, + indented: false + }, + WorkspaceListEntry::Workspace { + ws_idx: 1, + indented: false + }, + ] + ); + } + + #[test] + fn compact_space_group_scroll_offset_can_start_inside_group() { + let mut app = AppState::test_new(); + app.workspaces = vec![ + workspace_with_worktree_space("main", Some("repo-key"), "/repo/herdr"), + workspace_with_worktree_space("one", Some("repo-key"), "/repo/herdr-one"), + workspace_with_worktree_space("two", Some("repo-key"), "/repo/herdr-two"), + ]; + let area = Rect::new(0, 0, 30, 20); + app.workspace_scroll = normalized_workspace_scroll(&app, area, 2); + + let (cards, headers) = compute_workspace_list_areas(&app, area); + + assert!(headers.is_empty()); + assert_eq!(cards.len(), 1); + assert_eq!(cards[0].ws_idx, 2); + } + + #[test] + fn workspace_scroll_metrics_count_display_entries_not_raw_workspaces() { + let mut app = AppState::test_new(); + app.workspaces = vec![ + workspace_with_worktree_space("main", Some("repo-key"), "/repo/herdr"), + workspace_with_worktree_space("issue", Some("repo-key"), "/repo/herdr-issue"), + Workspace::test_new("notes"), + ]; + app.collapsed_space_keys.insert("repo-key".into()); + app.active = None; + app.mode = Mode::Terminal; + + let ws_area = Rect::new(0, 0, 30, 6); + let metrics = workspace_list_scroll_metrics(&app, ws_area); + + assert_eq!(metrics.viewport_rows, 1); + assert_eq!(metrics.max_offset_from_bottom, 1); + assert_eq!(metrics.offset_from_bottom, 1); + } + + #[test] + fn workspace_scroll_offset_applies_to_group_children() { + let mut app = AppState::test_new(); + app.workspaces = vec![ + workspace_with_worktree_space("main", Some("repo-key"), "/repo/herdr"), + workspace_with_worktree_space("issue", Some("repo-key"), "/repo/herdr-issue"), + Workspace::test_new("notes"), + ]; + app.collapsed_space_keys.insert("repo-key".into()); + app.active = None; + app.mode = Mode::Terminal; + app.workspace_scroll = 1; + + let (cards, headers) = compute_workspace_list_areas(&app, Rect::new(0, 0, 30, 12)); + + assert!(headers.is_empty()); + assert_eq!(cards.len(), 1); + assert_eq!(cards[0].ws_idx, 2); + } + + #[test] + fn workspace_list_entries_group_multiple_workspaces_in_same_git_space() { + let mut app = AppState::test_new(); + app.workspaces = vec![ + workspace_with_worktree_space("main", Some("repo-key"), "/repo/herdr"), + workspace_with_worktree_space("issue", Some("repo-key"), "/repo/herdr-issue"), + ]; + + assert_eq!( + workspace_list_entries(&app), + vec![ + WorkspaceListEntry::Workspace { + ws_idx: 0, + indented: false, + }, + WorkspaceListEntry::Workspace { + ws_idx: 1, + indented: true, + }, + ] + ); + } + + #[test] + fn workspace_list_entries_group_non_contiguous_explicit_members() { + let mut app = AppState::test_new(); + app.workspaces = vec![ + workspace_with_worktree_space("main", Some("repo-key"), "/repo/herdr"), + workspace_with_git_space("normal", "other-key"), + workspace_with_worktree_space("issue", Some("repo-key"), "/repo/herdr-issue"), + ]; + + assert_eq!( + workspace_list_entries(&app), + vec![ + WorkspaceListEntry::Workspace { + ws_idx: 0, + indented: false, + }, + WorkspaceListEntry::Workspace { + ws_idx: 2, + indented: true, + }, + WorkspaceListEntry::Workspace { + ws_idx: 1, + indented: false, + }, + ] + ); + } + + #[test] + fn workspace_list_entries_do_not_group_normal_git_workspaces() { + let mut app = AppState::test_new(); + app.workspaces = vec![ + workspace_with_git_space("one", "repo-key"), + workspace_with_git_space("two", "repo-key"), + ]; + + assert_eq!( + workspace_list_entries(&app), + vec![ + WorkspaceListEntry::Workspace { + ws_idx: 0, + indented: false, + }, + WorkspaceListEntry::Workspace { + ws_idx: 1, + indented: false, + }, + ] + ); + } + + #[test] + fn workspace_list_entries_do_not_auto_attach_normal_git_workspace_to_group() { + let mut app = AppState::test_new(); + app.workspaces = vec![ + workspace_with_worktree_space("main", Some("repo-key"), "/repo/herdr"), + workspace_with_git_space("scratch", "repo-key"), + workspace_with_worktree_space("issue", Some("repo-key"), "/repo/herdr-issue"), + ]; + + assert_eq!( + workspace_list_entries(&app), + vec![ + WorkspaceListEntry::Workspace { + ws_idx: 0, + indented: false, + }, + WorkspaceListEntry::Workspace { + ws_idx: 2, + indented: true, + }, + WorkspaceListEntry::Workspace { + ws_idx: 1, + indented: false, + }, + ] + ); + } + + #[test] + fn workspace_list_entries_leave_single_git_and_non_git_workspaces_flat() { + let mut app = AppState::test_new(); + app.workspaces = vec![ + workspace_with_git_space("one", "repo-key"), + workspace_with_worktree_space("notes", None, "/notes"), + ]; + + assert_eq!( + workspace_list_entries(&app), + vec![ + WorkspaceListEntry::Workspace { + ws_idx: 0, + indented: false, + }, + WorkspaceListEntry::Workspace { + ws_idx: 1, + indented: false, + }, + ] + ); + } + + #[test] + fn collapsed_group_hides_inactive_children_but_keeps_active_visible() { + let mut app = AppState::test_new(); + app.workspaces = vec![ + workspace_with_worktree_space("main", Some("repo-key"), "/repo/herdr"), + workspace_with_worktree_space("issue", Some("repo-key"), "/repo/herdr-issue"), + ]; + app.active = Some(1); + app.mode = Mode::Terminal; + app.collapsed_space_keys.insert("repo-key".into()); + + assert_eq!( + workspace_list_entries(&app), + vec![ + WorkspaceListEntry::Workspace { + ws_idx: 0, + indented: false, + }, + WorkspaceListEntry::Workspace { + ws_idx: 1, + indented: true, + }, + ] + ); + + app.active = None; + app.mode = Mode::Terminal; + assert_eq!( + workspace_list_entries(&app), + vec![WorkspaceListEntry::Workspace { + ws_idx: 0, + indented: false, + }] + ); + } + + #[test] + fn collapsed_group_keeps_selected_child_visible_in_navigate_mode() { + let mut app = AppState::test_new(); + app.workspaces = vec![ + workspace_with_worktree_space("main", Some("repo-key"), "/repo/herdr"), + workspace_with_worktree_space("issue", Some("repo-key"), "/repo/herdr-issue"), + ]; + app.mode = Mode::Navigate; + app.selected = 1; + app.active = Some(1); + app.collapsed_space_keys.insert("repo-key".into()); + + assert_eq!( + workspace_list_entries(&app), + vec![ + WorkspaceListEntry::Workspace { + ws_idx: 0, + indented: false, + }, + WorkspaceListEntry::Workspace { + ws_idx: 1, + indented: true, + }, + ] + ); + } } diff --git a/src/workspace.rs b/src/workspace.rs index 70ff97f5..a302f3f8 100644 --- a/src/workspace.rs +++ b/src/workspace.rs @@ -21,16 +21,26 @@ mod tab; use self::git::git_ahead_behind; pub use self::{ - git::{derive_label_from_cwd, git_branch}, + git::{derive_label_from_cwd, git_branch, git_space_metadata, GitSpaceMetadata}, tab::Tab, }; +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct WorktreeSpaceMembership { + pub key: String, + pub label: String, + pub repo_root: PathBuf, + pub checkout_path: PathBuf, + pub is_linked_worktree: bool, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct WorkspaceGitStatus { pub workspace_id: String, pub resolved_identity_cwd: PathBuf, pub branch: Option<String>, pub ahead_behind: Option<(usize, usize)>, + pub space: Option<GitSpaceMetadata>, } static NEXT_WORKSPACE_ID: AtomicU64 = AtomicU64::new(1); @@ -56,6 +66,10 @@ pub struct Workspace { pub(crate) cached_git_branch: Option<String>, /// Cached ahead/behind counts for the workspace repo's current branch upstream. pub(crate) cached_git_ahead_behind: Option<(usize, usize)>, + /// Cached derived Git repo metadata for worktree actions and status display. + pub(crate) cached_git_space: Option<GitSpaceMetadata>, + /// Explicit Herdr-managed worktree grouping provenance. + pub worktree_space: Option<WorktreeSpaceMembership>, /// Stable-ish public pane numbers within this workspace. /// New panes append at the end; closing a pane compacts higher numbers down. pub public_pane_numbers: HashMap<PaneId, usize>, @@ -182,6 +196,8 @@ impl Workspace { identity_cwd: initial_cwd.clone(), cached_git_branch: git_branch(&initial_cwd), cached_git_ahead_behind: None, + cached_git_space: None, + worktree_space: None, public_pane_numbers, next_public_pane_number: 2, tabs: vec![tab], @@ -576,11 +592,20 @@ impl Workspace { self.cached_git_ahead_behind } + pub fn git_space(&self) -> Option<&GitSpaceMetadata> { + self.cached_git_space.as_ref() + } + + pub fn worktree_space(&self) -> Option<&WorktreeSpaceMembership> { + self.worktree_space.as_ref() + } + #[cfg(test)] pub fn refresh_git_ahead_behind(&mut self) { let cwd = self.resolved_identity_cwd(); self.cached_git_branch = cwd.as_deref().and_then(git_branch); self.cached_git_ahead_behind = cwd.as_deref().and_then(git_ahead_behind); + self.cached_git_space = cwd.as_deref().and_then(git_space_metadata); } pub fn git_status_for_cwd( @@ -590,6 +615,7 @@ impl Workspace { WorkspaceGitStatus { branch: git_branch(&resolved_identity_cwd), ahead_behind: git_ahead_behind(&resolved_identity_cwd), + space: git_space_metadata(&resolved_identity_cwd), workspace_id, resolved_identity_cwd, } @@ -704,6 +730,8 @@ impl Workspace { identity_cwd: identity_cwd.clone(), cached_git_branch: git_branch(&identity_cwd), cached_git_ahead_behind: None, + cached_git_space: None, + worktree_space: None, public_pane_numbers, next_public_pane_number: 2, tabs: vec![tab], diff --git a/src/workspace/git.rs b/src/workspace/git.rs index ca453003..d06c8538 100644 --- a/src/workspace/git.rs +++ b/src/workspace/git.rs @@ -1,5 +1,23 @@ use std::path::{Path, PathBuf}; +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GitSpaceMetadata { + pub key: String, + pub checkout_key: String, + pub label: String, + pub repo_root: PathBuf, + pub is_linked_worktree: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GitWorktreeInfo { + pub repo_root: PathBuf, + pub git_dir: PathBuf, + pub git_common_dir: PathBuf, + pub is_bare: bool, + pub is_linked_worktree: bool, +} + pub fn derive_label_from_cwd(cwd: &Path) -> String { if let Some(repo_root) = git_repo_root(cwd) { if let Some(name) = repo_root.file_name().and_then(|n| n.to_str()) { @@ -21,6 +39,83 @@ pub fn derive_label_from_cwd(cwd: &Path) -> String { .unwrap_or_else(|| cwd.display().to_string()) } +pub fn git_worktree_info(cwd: &Path) -> Option<GitWorktreeInfo> { + let repo_root = git_rev_parse(cwd, &["--show-toplevel"])?; + let git_dir = git_rev_parse(cwd, &["--path-format=absolute", "--git-dir"])?; + let git_common_dir = git_rev_parse(cwd, &["--path-format=absolute", "--git-common-dir"])?; + let is_bare = git_rev_parse(cwd, &["--is-bare-repository"])? == "true"; + let is_linked_worktree = + canonicalize_best_effort(&git_dir) != canonicalize_best_effort(&git_common_dir); + + Some(GitWorktreeInfo { + repo_root: PathBuf::from(repo_root), + git_dir: PathBuf::from(git_dir), + git_common_dir: PathBuf::from(git_common_dir), + is_bare, + is_linked_worktree, + }) +} + +fn git_rev_parse(cwd: &Path, args: &[&str]) -> Option<String> { + let output = std::process::Command::new("git") + .arg("-C") + .arg(cwd) + .arg("rev-parse") + .args(args) + .output() + .ok()?; + + if !output.status.success() { + return None; + } + + let stdout = String::from_utf8(output.stdout).ok()?; + Some(stdout.trim().to_string()) +} + +fn canonicalize_best_effort(path: &str) -> PathBuf { + std::fs::canonicalize(path).unwrap_or_else(|_| PathBuf::from(path)) +} + +pub fn git_space_metadata(cwd: &Path) -> Option<GitSpaceMetadata> { + let info = git_worktree_info(cwd)?; + if info.is_bare { + return None; + } + let key = canonicalize_best_effort_path(&info.git_common_dir) + .display() + .to_string(); + let checkout_key = canonicalize_best_effort_path(&info.repo_root) + .display() + .to_string(); + let label_path = if info + .git_common_dir + .file_name() + .and_then(|name| name.to_str()) + == Some(".git") + { + info.git_common_dir.parent().unwrap_or(&info.repo_root) + } else { + &info.repo_root + }; + let label = label_path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("repo") + .to_string(); + Some(GitSpaceMetadata { + key, + checkout_key, + label, + repo_root: info.repo_root, + is_linked_worktree: info.is_linked_worktree, + }) +} + +fn canonicalize_best_effort_path(path: &Path) -> PathBuf { + std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()) +} + pub fn git_branch(cwd: &Path) -> Option<String> { let repo_root = git_repo_root(cwd)?; let git_dir = git_dir_for_repo_root(&repo_root)?; diff --git a/src/worktree.rs b/src/worktree.rs new file mode 100644 index 00000000..058ee2b8 --- /dev/null +++ b/src/worktree.rs @@ -0,0 +1,478 @@ +use std::path::{Path, PathBuf}; + +const DEFAULT_WORKTREE_PREFIX: &str = "worktree"; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct WorktreeCommand { + pub program: String, + pub args: Vec<String>, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ExistingWorktree { + pub path: PathBuf, + pub branch: Option<String>, + pub is_bare: bool, + pub is_detached: bool, + pub is_prunable: bool, +} + +pub(crate) fn generated_branch_slug(seed: u64) -> String { + let adjectives = [ + "brave", "calm", "clear", "green", "lucky", "quiet", "rapid", "silver", + ]; + let nouns = [ + "river", "cloud", "field", "forest", "harbor", "meadow", "stone", "valley", + ]; + let adjective = adjectives[(seed as usize) % adjectives.len()]; + let noun = nouns[((seed / adjectives.len() as u64) as usize) % nouns.len()]; + let suffix = seed & 0xffff; + format!("{DEFAULT_WORKTREE_PREFIX}/{adjective}-{noun}-{suffix:04x}") +} + +pub(crate) fn branch_to_path_slug(branch: &str) -> String { + let mut slug = String::new(); + let mut last_was_dash = false; + + for ch in branch.chars() { + if ch.is_ascii_alphanumeric() { + slug.push(ch.to_ascii_lowercase()); + last_was_dash = false; + } else if !last_was_dash { + slug.push('-'); + last_was_dash = true; + } + } + + let trimmed = slug.trim_matches('-').to_string(); + if trimmed.is_empty() { + DEFAULT_WORKTREE_PREFIX.to_string() + } else { + trimmed + } +} + +pub(crate) fn expand_tilde_path(path: &str) -> PathBuf { + if path == "~" { + return std::env::var("HOME") + .map(PathBuf::from) + .unwrap_or_else(|_| PathBuf::from(path)); + } + + if let Some(rest) = path.strip_prefix("~/") { + return std::env::var("HOME") + .map(|home| PathBuf::from(home).join(rest)) + .unwrap_or_else(|_| PathBuf::from(path)); + } + + PathBuf::from(path) +} + +pub(crate) fn canonical_or_original(path: &Path) -> PathBuf { + std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()) +} + +pub(crate) fn default_checkout_path(root: &Path, repo_name: &str, branch: &str) -> PathBuf { + root.join(repo_name).join(branch_to_path_slug(branch)) +} + +pub(crate) fn build_worktree_remove_command( + repo_root: &Path, + path: &Path, + force: bool, +) -> WorktreeCommand { + let mut args = vec![ + "-C".to_string(), + repo_root.display().to_string(), + "worktree".to_string(), + "remove".to_string(), + ]; + if force { + args.push("--force".to_string()); + } + args.push(path.display().to_string()); + + WorktreeCommand { + program: "git".to_string(), + args, + } +} + +pub(crate) fn is_dirty_worktree_remove_error(message: &str) -> bool { + let lower = message.to_ascii_lowercase(); + lower.contains("contains modified or untracked files") + && lower.contains("use --force to delete it") +} + +pub(crate) fn build_worktree_add_new_branch_command( + repo_root: &Path, + path: &Path, + branch: &str, + base: &str, +) -> WorktreeCommand { + WorktreeCommand { + program: "git".to_string(), + args: vec![ + "-C".to_string(), + repo_root.display().to_string(), + "worktree".to_string(), + "add".to_string(), + "-b".to_string(), + branch.to_string(), + path.display().to_string(), + base.to_string(), + ], + } +} + +pub(crate) fn run_worktree_command(command: &WorktreeCommand) -> Result<(), String> { + let output = std::process::Command::new(&command.program) + .args(&command.args) + .output() + .map_err(|err| err.to_string())?; + + if output.status.success() { + return Ok(()); + } + + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + let message = if stderr.is_empty() { stdout } else { stderr }; + Err(if message.is_empty() { + format!("{} failed with status {}", command.program, output.status) + } else { + message + }) +} + +pub(crate) fn parse_worktree_list_porcelain(output: &str) -> Vec<ExistingWorktree> { + let mut entries = Vec::new(); + let mut path: Option<PathBuf> = None; + let mut branch = None; + let mut is_bare = false; + let mut is_detached = false; + let mut is_prunable = false; + + let finish = |entries: &mut Vec<ExistingWorktree>, + path: &mut Option<PathBuf>, + branch: &mut Option<String>, + is_bare: &mut bool, + is_detached: &mut bool, + is_prunable: &mut bool| { + if let Some(path) = path.take() { + entries.push(ExistingWorktree { + path, + branch: branch.take(), + is_bare: *is_bare, + is_detached: *is_detached, + is_prunable: *is_prunable, + }); + } + *is_bare = false; + *is_detached = false; + *is_prunable = false; + }; + + for line in output.lines() { + if line.trim().is_empty() { + finish( + &mut entries, + &mut path, + &mut branch, + &mut is_bare, + &mut is_detached, + &mut is_prunable, + ); + continue; + } + if let Some(value) = line.strip_prefix("worktree ") { + path = Some(PathBuf::from(value)); + } else if let Some(value) = line.strip_prefix("branch ") { + branch = Some( + value + .strip_prefix("refs/heads/") + .unwrap_or(value) + .to_string(), + ); + } else if line == "detached" { + is_detached = true; + } else if line == "bare" { + is_bare = true; + } else if line.starts_with("prunable") { + is_prunable = true; + } + } + + finish( + &mut entries, + &mut path, + &mut branch, + &mut is_bare, + &mut is_detached, + &mut is_prunable, + ); + entries +} + +pub(crate) fn list_existing_worktrees(repo_root: &Path) -> Result<Vec<ExistingWorktree>, String> { + let output = std::process::Command::new("git") + .arg("-C") + .arg(repo_root) + .args(["worktree", "list", "--porcelain"]) + .output() + .map_err(|err| err.to_string())?; + + if output.status.success() { + let stdout = String::from_utf8_lossy(&output.stdout); + return Ok(parse_worktree_list_porcelain(&stdout)); + } + + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + Err(if stderr.is_empty() { + format!("git worktree list failed with status {}", output.status) + } else { + stderr + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn unique_temp_path(name: &str) -> 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: &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) -> 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 + } + + #[test] + fn generated_branch_slug_is_worktree_namespaced_and_stable() { + assert_eq!(generated_branch_slug(0), "worktree/brave-river-0000"); + assert_eq!(generated_branch_slug(9), "worktree/calm-cloud-0009"); + } + + #[test] + fn parses_git_worktree_list_porcelain() { + let output = "\ +worktree /repo/main +HEAD abc +branch refs/heads/main + +worktree /repo/issue +HEAD def +branch refs/heads/worktree/issue + +worktree /repo/detached +HEAD fed +detached +prunable stale + +"; + + assert_eq!( + parse_worktree_list_porcelain(output), + vec![ + ExistingWorktree { + path: PathBuf::from("/repo/main"), + branch: Some("main".into()), + is_bare: false, + is_detached: false, + is_prunable: false, + }, + ExistingWorktree { + path: PathBuf::from("/repo/issue"), + branch: Some("worktree/issue".into()), + is_bare: false, + is_detached: false, + is_prunable: false, + }, + ExistingWorktree { + path: PathBuf::from("/repo/detached"), + branch: None, + is_bare: false, + is_detached: true, + is_prunable: true, + }, + ] + ); + } + + #[test] + fn branch_to_path_slug_makes_branch_safe_folder_name() { + assert_eq!( + branch_to_path_slug("worktree/brave-river"), + "worktree-brave-river" + ); + assert_eq!( + branch_to_path_slug("issue/137 Worktree Spaces"), + "issue-137-worktree-spaces" + ); + assert_eq!(branch_to_path_slug("///"), "worktree"); + } + + #[test] + fn expand_tilde_path_uses_home_when_available() { + let old_home = std::env::var_os("HOME"); + std::env::set_var("HOME", "/home/me"); + assert_eq!( + expand_tilde_path("~/.herdr/worktrees"), + PathBuf::from("/home/me/.herdr/worktrees") + ); + assert_eq!( + expand_tilde_path("/tmp/worktrees"), + PathBuf::from("/tmp/worktrees") + ); + if let Some(home) = old_home { + std::env::set_var("HOME", home); + } else { + std::env::remove_var("HOME"); + } + } + + #[test] + fn default_checkout_path_appends_repo_and_branch_slug() { + assert_eq!( + default_checkout_path( + Path::new("/home/me/.herdr/worktrees"), + "herdr", + "worktree/brave-river", + ), + PathBuf::from("/home/me/.herdr/worktrees/herdr/worktree-brave-river") + ); + } + + #[test] + fn worktree_remove_command_preserves_branch_by_not_deleting_it() { + let command = build_worktree_remove_command( + Path::new("/repo/herdr"), + Path::new("/w/herdr/issue-137"), + false, + ); + assert_eq!(command.program, "git"); + assert_eq!( + command.args, + vec![ + "-C", + "/repo/herdr", + "worktree", + "remove", + "/w/herdr/issue-137" + ] + ); + } + + #[test] + fn forced_worktree_remove_command_uses_git_force_flag() { + let command = build_worktree_remove_command( + Path::new("/repo/herdr"), + Path::new("/w/herdr/issue-137"), + true, + ); + assert_eq!( + command.args, + vec![ + "-C", + "/repo/herdr", + "worktree", + "remove", + "--force", + "/w/herdr/issue-137" + ] + ); + } + + #[test] + fn dirty_remove_error_detection_matches_git_force_hint() { + assert!(is_dirty_worktree_remove_error( + "fatal: '/w/herdr' contains modified or untracked files, use --force to delete it" + )); + assert!(!is_dirty_worktree_remove_error( + "fatal: '/w/herdr' is a missing but already registered worktree" + )); + assert!(!is_dirty_worktree_remove_error( + "fatal: '/w/herdr' contains a locked worktree, use --force only if you know why" + )); + } + + #[test] + fn worktree_add_command_creates_new_branch_from_base() { + let command = build_worktree_add_new_branch_command( + Path::new("/repo/herdr"), + Path::new("/w/herdr/worktree-brave-river"), + "worktree/brave-river", + "HEAD", + ); + assert_eq!(command.program, "git"); + assert_eq!( + command.args, + vec![ + "-C", + "/repo/herdr", + "worktree", + "add", + "-b", + "worktree/brave-river", + "/w/herdr/worktree-brave-river", + "HEAD" + ] + ); + } + + #[test] + fn run_worktree_add_and_remove_create_and_delete_checkout() { + let repo = create_committed_repo("worktree-run-repo"); + let checkout = unique_temp_path("worktree-run-checkout"); + let branch = "worktree/test-create-remove"; + + let add = build_worktree_add_new_branch_command(&repo, &checkout, branch, "HEAD"); + run_worktree_command(&add).unwrap(); + + assert!(checkout.join("README.md").exists()); + let branch_name = std::process::Command::new("git") + .arg("-C") + .arg(&checkout) + .args(["branch", "--show-current"]) + .output() + .unwrap(); + assert!(branch_name.status.success()); + assert_eq!( + String::from_utf8(branch_name.stdout).unwrap().trim(), + branch + ); + + let remove = build_worktree_remove_command(&repo, &checkout, false); + run_worktree_command(&remove).unwrap(); + assert!(!checkout.exists()); + + let _ = std::fs::remove_dir_all(repo); + } +} |
