diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-05-22 02:21:12 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-05-22 02:23:35 +0300 |
| commit | 0148c1379b76c5c0a9d05588c07dcb06ec32f93f (patch) | |
| tree | 554766d959c738ca2aa433c3d4b4a7de25568008 /src/app/actions.rs | |
| parent | a9ff4012bdca0dbdbc77bc6e53a430689fc899be (diff) | |
feat: add git worktree workspace management
refs #137
Diffstat (limited to 'src/app/actions.rs')
| -rw-r--r-- | src/app/actions.rs | 237 |
1 files changed, 215 insertions, 22 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"); + } } |
