diff --git a/src/app.rs b/src/app.rs index a226df0..ea9f1f4 100644 --- a/src/app.rs +++ b/src/app.rs @@ -2,6 +2,8 @@ use crate::config::{Config, RepositorySettings}; use crate::git::{Branch, GitManager, Worktree, WorktreeDetail}; use crate::hooks::SetupRunner; use crate::theme::Theme; +use std::path::Path; +use std::sync::mpsc; use thiserror::Error; #[derive(Error, Debug)] @@ -19,6 +21,7 @@ pub enum AppMode { Normal, Create, Confirm, + Deleting, Help, } @@ -28,6 +31,22 @@ pub enum ConfirmAction { Prune, } +/// Result of a background delete operation +#[derive(Debug)] +pub enum DeleteResult { + SingleCompleted { + worktree_name: String, + branch_name: Option, + branch_deleted: bool, + error_message: Option, + }, + PruneCompleted { + worktree_count: usize, + branch_count: usize, + }, + Error(String), +} + pub struct App { pub mode: AppMode, pub worktrees: Vec, @@ -43,8 +62,11 @@ pub struct App { pub should_quit: bool, pub selected_worktree_path: Option, pub theme: Theme, + pub deleting_message: Option, + pub tick: u64, config: Config, git: GitManager, + delete_receiver: Option>, } impl App { @@ -68,8 +90,11 @@ impl App { should_quit: false, selected_worktree_path: None, theme, + deleting_message: None, + tick: 0, config, git, + delete_receiver: None, }) } @@ -235,94 +260,127 @@ impl App { } pub fn confirm_action(&mut self, delete_branch: bool) -> Result<(), AppError> { + let repo_root = self.git.repo_root().clone(); + match self.confirm_action { Some(ConfirmAction::DeleteSingle) => { - self.delete_selected_worktree(delete_branch)?; - } - Some(ConfirmAction::Prune) => { - self.prune_merged_worktrees(delete_branch)?; - } - None => {} - } - self.enter_normal_mode(); - Ok(()) - } - - fn delete_selected_worktree(&mut self, delete_branch: bool) -> Result<(), AppError> { - if self.filtered_worktrees.is_empty() { - return Ok(()); - } - - let worktree = self.filtered_worktrees[self.selected_worktree].clone(); - if worktree.is_main { - self.message = Some("Cannot delete main worktree".to_string()); - return Ok(()); - } + if self.filtered_worktrees.is_empty() { + self.enter_normal_mode(); + return Ok(()); + } + let worktree = self.filtered_worktrees[self.selected_worktree].clone(); + if worktree.is_main { + self.message = Some("Cannot delete main worktree".to_string()); + self.enter_normal_mode(); + return Ok(()); + } - // Get branch name before deleting worktree - let branch_name = worktree.branch.clone(); + let branch_name = worktree.branch.clone(); + self.deleting_message = Some(format!("Deleting worktree '{}'...", worktree.name)); - // Delete the worktree - self.git.delete_worktree(&worktree.name)?; + let (tx, rx) = mpsc::channel(); + self.delete_receiver = Some(rx); + self.mode = AppMode::Deleting; + self.tick = 0; - // Delete the branch if requested - if delete_branch { - if let Some(ref branch) = branch_name { - if let Err(e) = self.git.delete_branch(branch) { - self.message = Some(format!( - "Deleted worktree '{}', but failed to delete branch '{}': {}", - worktree.name, branch, e - )); - self.refresh_worktrees()?; - return Ok(()); - } + let wt_name = worktree.name.clone(); + std::thread::spawn(move || { + let result = + execute_delete_single(&repo_root, &wt_name, branch_name, delete_branch); + let _ = tx.send(result); + }); } - } - - if delete_branch { - if let Some(ref branch) = branch_name { - self.message = Some(format!( - "Deleted worktree '{}' and branch '{}'", - worktree.name, branch - )); - } else { - self.message = Some(format!("Deleted worktree: {}", worktree.name)); + Some(ConfirmAction::Prune) => { + let worktrees: Vec<(String, Option)> = self + .merged_worktrees + .iter() + .map(|w| (w.name.clone(), w.branch.clone())) + .collect(); + let count = worktrees.len(); + self.deleting_message = Some(format!("Pruning {} worktree(s)...", count)); + + let (tx, rx) = mpsc::channel(); + self.delete_receiver = Some(rx); + self.mode = AppMode::Deleting; + self.tick = 0; + + std::thread::spawn(move || { + let result = execute_prune(&repo_root, worktrees, delete_branch); + let _ = tx.send(result); + }); + } + None => { + self.enter_normal_mode(); } - } else { - self.message = Some(format!("Deleted worktree: {}", worktree.name)); } - self.refresh_worktrees()?; - Ok(()) } - fn prune_merged_worktrees(&mut self, delete_branch: bool) -> Result<(), AppError> { - let count = self.merged_worktrees.len(); - let mut deleted_branches = 0; - - for worktree in &self.merged_worktrees.clone() { - let branch_name = worktree.branch.clone(); - self.git.delete_worktree(&worktree.name)?; + /// Check if a background delete operation has completed + pub fn check_delete_completion(&mut self) -> Result<(), AppError> { + let result = match self.delete_receiver { + Some(ref receiver) => match receiver.try_recv() { + Ok(result) => Some(result), + Err(mpsc::TryRecvError::Empty) => return Ok(()), + Err(mpsc::TryRecvError::Disconnected) => { + self.delete_receiver = None; + self.deleting_message = None; + self.enter_normal_mode(); + self.message = Some("Delete operation failed unexpectedly".to_string()); + return Ok(()); + } + }, + None => return Ok(()), + }; - if delete_branch { - if let Some(ref branch) = branch_name { - if self.git.delete_branch(branch).is_ok() { - deleted_branches += 1; + if let Some(result) = result { + match result { + DeleteResult::SingleCompleted { + worktree_name, + branch_name, + branch_deleted, + error_message, + } => { + if let Some(err_msg) = error_message { + self.message = Some(err_msg); + } else if branch_deleted { + if let Some(ref branch) = branch_name { + self.message = Some(format!( + "Deleted worktree '{}' and branch '{}'", + worktree_name, branch + )); + } else { + self.message = Some(format!("Deleted worktree: {}", worktree_name)); + } + } else { + self.message = Some(format!("Deleted worktree: {}", worktree_name)); } } + DeleteResult::PruneCompleted { + worktree_count, + branch_count, + } => { + if branch_count > 0 { + self.message = Some(format!( + "Pruned {} worktree(s) and {} branch(es)", + worktree_count, branch_count + )); + } else { + self.message = + Some(format!("Pruned {} merged worktree(s)", worktree_count)); + } + self.merged_worktrees.clear(); + } + DeleteResult::Error(err) => { + self.message = Some(format!("Error: {}", err)); + } } - } - if delete_branch && deleted_branches > 0 { - self.message = Some(format!( - "Pruned {} worktree(s) and {} branch(es)", - count, deleted_branches - )); - } else { - self.message = Some(format!("Pruned {} merged worktree(s)", count)); + self.delete_receiver = None; + self.deleting_message = None; + self.enter_normal_mode(); + self.refresh_worktrees()?; } - self.merged_worktrees.clear(); - self.refresh_worktrees()?; Ok(()) } @@ -534,16 +592,200 @@ impl App { should_quit: false, selected_worktree_path: None, theme, + deleting_message: None, + tick: 0, config, git, + delete_receiver: None, + } + } +} + +/// Execute single worktree deletion in a background thread +fn execute_delete_single( + repo_root: &Path, + worktree_name: &str, + branch_name: Option, + delete_branch: bool, +) -> DeleteResult { + let repo = match git2::Repository::open(repo_root) { + Ok(r) => r, + Err(e) => return DeleteResult::Error(format!("Failed to open repository: {}", e)), + }; + + // Delete the worktree (prune + remove directory) + match repo.find_worktree(worktree_name) { + Ok(wt) => { + let path = wt.path().to_path_buf(); + if let Err(e) = wt.prune(Some( + git2::WorktreePruneOptions::new() + .valid(true) + .working_tree(true), + )) { + return DeleteResult::Error(format!("Failed to prune worktree: {}", e)); + } + if path.exists() { + if let Err(e) = std::fs::remove_dir_all(&path) { + return DeleteResult::Error(format!("Failed to remove directory: {}", e)); + } + } + } + Err(e) => return DeleteResult::Error(format!("Worktree not found: {}", e)), + } + + // Delete the branch if requested + let mut branch_deleted = false; + let mut error_message = None; + if delete_branch { + if let Some(ref branch) = branch_name { + let output = std::process::Command::new("git") + .args(["branch", "-D", branch]) + .current_dir(repo_root) + .output(); + match output { + Ok(o) if o.status.success() => { + branch_deleted = true; + } + Ok(o) => { + let stderr = String::from_utf8_lossy(&o.stderr); + error_message = Some(format!( + "Deleted worktree '{}', but failed to delete branch '{}': {}", + worktree_name, + branch, + stderr.trim() + )); + } + Err(e) => { + error_message = Some(format!( + "Deleted worktree '{}', but failed to delete branch '{}': {}", + worktree_name, branch, e + )); + } + } } } + + DeleteResult::SingleCompleted { + worktree_name: worktree_name.to_string(), + branch_name, + branch_deleted, + error_message, + } +} + +/// Execute prune (multiple worktree deletion) in a background thread +fn execute_prune( + repo_root: &Path, + worktrees: Vec<(String, Option)>, + delete_branch: bool, +) -> DeleteResult { + let repo = match git2::Repository::open(repo_root) { + Ok(r) => r, + Err(e) => return DeleteResult::Error(format!("Failed to open repository: {}", e)), + }; + + let mut deleted_worktrees = 0; + let mut deleted_branches = 0; + + for (wt_name, branch_name) in &worktrees { + match repo.find_worktree(wt_name) { + Ok(wt) => { + let path = wt.path().to_path_buf(); + if wt + .prune(Some( + git2::WorktreePruneOptions::new() + .valid(true) + .working_tree(true), + )) + .is_err() + { + continue; + } + if path.exists() && std::fs::remove_dir_all(&path).is_err() { + continue; + } + } + Err(_) => continue, + } + deleted_worktrees += 1; + + if delete_branch { + if let Some(ref branch) = branch_name { + let output = std::process::Command::new("git") + .args(["branch", "-D", branch]) + .current_dir(repo_root) + .output(); + if let Ok(o) = output { + if o.status.success() { + deleted_branches += 1; + } + } + } + } + } + + DeleteResult::PruneCompleted { + worktree_count: deleted_worktrees, + branch_count: deleted_branches, + } } #[cfg(test)] mod tests { use super::*; use std::path::PathBuf; + use std::process::Command; + use tempfile::TempDir; + + /// Create a temporary git repository for testing execute_delete_* functions + fn setup_git_repo() -> (TempDir, std::path::PathBuf) { + let temp_dir = TempDir::new().unwrap(); + let repo_path = temp_dir.path().to_path_buf(); + + Command::new("git") + .args(["init", "-b", "main"]) + .current_dir(&repo_path) + .output() + .unwrap(); + Command::new("git") + .args(["config", "user.email", "test@test.com"]) + .current_dir(&repo_path) + .output() + .unwrap(); + Command::new("git") + .args(["config", "user.name", "Test User"]) + .current_dir(&repo_path) + .output() + .unwrap(); + std::fs::write(repo_path.join("README.md"), "# Test").unwrap(); + Command::new("git") + .args(["add", "."]) + .current_dir(&repo_path) + .output() + .unwrap(); + Command::new("git") + .args(["commit", "-m", "Initial commit"]) + .current_dir(&repo_path) + .output() + .unwrap(); + + (temp_dir, repo_path) + } + + /// Create a worktree with a branch in the test repo + fn create_test_worktree_in_repo(repo_path: &std::path::Path, branch: &str, wt_name: &str) { + Command::new("git") + .args(["branch", branch]) + .current_dir(repo_path) + .output() + .unwrap(); + let wt_path = repo_path.join(wt_name); + Command::new("git") + .args(["worktree", "add", wt_path.to_str().unwrap(), branch]) + .current_dir(repo_path) + .output() + .unwrap(); + } fn create_test_worktrees() -> Vec { vec![ @@ -1123,4 +1365,577 @@ mod tests { assert_eq!(app.confirm_action, Some(ConfirmAction::Prune)); } + + // ========== Background Delete Tests ========== + + #[test] + fn test_confirm_action_transitions_to_deleting_mode() { + let mut app = create_test_app(); + app.mode = AppMode::Confirm; + app.confirm_action = Some(ConfirmAction::DeleteSingle); + app.selected_worktree = 1; // non-main worktree + + let result = app.confirm_action(false); + assert!(result.is_ok()); + assert_eq!(app.mode, AppMode::Deleting); + assert!(app.deleting_message.is_some()); + assert!(app.delete_receiver.is_some()); + assert_eq!(app.tick, 0); + } + + #[test] + fn test_confirm_action_main_worktree_stays_normal() { + let mut app = create_test_app(); + app.mode = AppMode::Confirm; + app.confirm_action = Some(ConfirmAction::DeleteSingle); + app.selected_worktree = 0; // main worktree + + let result = app.confirm_action(false); + assert!(result.is_ok()); + assert_eq!(app.mode, AppMode::Normal); + assert!(app.message.as_ref().unwrap().contains("Cannot delete main")); + } + + #[test] + fn test_confirm_action_prune_transitions_to_deleting() { + let mut app = create_test_app(); + app.mode = AppMode::Confirm; + app.confirm_action = Some(ConfirmAction::Prune); + app.merged_worktrees = vec![Worktree { + name: "merged-wt".to_string(), + path: PathBuf::from("/repo/merged-wt"), + branch: Some("merged-branch".to_string()), + is_main: false, + }]; + + let result = app.confirm_action(false); + assert!(result.is_ok()); + assert_eq!(app.mode, AppMode::Deleting); + assert!(app.deleting_message.as_ref().unwrap().contains("Pruning")); + } + + #[test] + fn test_confirm_action_none_enters_normal() { + let mut app = create_test_app(); + app.mode = AppMode::Confirm; + app.confirm_action = None; + + let result = app.confirm_action(false); + assert!(result.is_ok()); + assert_eq!(app.mode, AppMode::Normal); + } + + #[test] + fn test_check_delete_completion_no_receiver() { + let mut app = create_test_app(); + // No receiver set - should be a no-op + let result = app.check_delete_completion(); + assert!(result.is_ok()); + assert_eq!(app.mode, AppMode::Normal); + } + + #[test] + fn test_check_delete_completion_pending() { + let mut app = create_test_app(); + let (_tx, rx) = mpsc::channel::(); + app.delete_receiver = Some(rx); + app.mode = AppMode::Deleting; + + // Nothing sent yet - should remain in Deleting mode + let result = app.check_delete_completion(); + assert!(result.is_ok()); + assert_eq!(app.mode, AppMode::Deleting); + assert!(app.delete_receiver.is_some()); + } + + #[test] + fn test_check_delete_completion_disconnected() { + let mut app = create_test_app(); + let (tx, rx) = mpsc::channel::(); + app.delete_receiver = Some(rx); + app.mode = AppMode::Deleting; + + // Drop sender to simulate thread crash + drop(tx); + + let result = app.check_delete_completion(); + assert!(result.is_ok()); + assert_eq!(app.mode, AppMode::Normal); + assert!(app.delete_receiver.is_none()); + assert!(app.message.as_ref().unwrap().contains("unexpectedly")); + } + + #[test] + fn test_check_delete_completion_single_success() { + let mut app = create_test_app(); + let (tx, rx) = mpsc::channel(); + app.delete_receiver = Some(rx); + app.mode = AppMode::Deleting; + + tx.send(DeleteResult::SingleCompleted { + worktree_name: "test-wt".to_string(), + branch_name: None, + branch_deleted: false, + error_message: None, + }) + .unwrap(); + + let result = app.check_delete_completion(); + assert!(result.is_ok()); + assert_eq!(app.mode, AppMode::Normal); + assert!(app.delete_receiver.is_none()); + assert!(app.deleting_message.is_none()); + assert!(app.message.as_ref().unwrap().contains("test-wt")); + } + + #[test] + fn test_check_delete_completion_single_with_branch() { + let mut app = create_test_app(); + let (tx, rx) = mpsc::channel(); + app.delete_receiver = Some(rx); + app.mode = AppMode::Deleting; + + tx.send(DeleteResult::SingleCompleted { + worktree_name: "test-wt".to_string(), + branch_name: Some("feature/test".to_string()), + branch_deleted: true, + error_message: None, + }) + .unwrap(); + + let result = app.check_delete_completion(); + assert!(result.is_ok()); + let msg = app.message.as_ref().unwrap(); + assert!(msg.contains("test-wt")); + assert!(msg.contains("feature/test")); + } + + #[test] + fn test_check_delete_completion_single_branch_error() { + let mut app = create_test_app(); + let (tx, rx) = mpsc::channel(); + app.delete_receiver = Some(rx); + app.mode = AppMode::Deleting; + + tx.send(DeleteResult::SingleCompleted { + worktree_name: "test-wt".to_string(), + branch_name: Some("feature/test".to_string()), + branch_deleted: false, + error_message: Some("failed to delete branch".to_string()), + }) + .unwrap(); + + let result = app.check_delete_completion(); + assert!(result.is_ok()); + assert!(app + .message + .as_ref() + .unwrap() + .contains("failed to delete branch")); + } + + #[test] + fn test_check_delete_completion_prune() { + let mut app = create_test_app(); + let (tx, rx) = mpsc::channel(); + app.delete_receiver = Some(rx); + app.mode = AppMode::Deleting; + app.merged_worktrees = vec![Worktree { + name: "wt".to_string(), + path: PathBuf::from("/repo/wt"), + branch: None, + is_main: false, + }]; + + tx.send(DeleteResult::PruneCompleted { + worktree_count: 3, + branch_count: 2, + }) + .unwrap(); + + let result = app.check_delete_completion(); + assert!(result.is_ok()); + assert_eq!(app.mode, AppMode::Normal); + let msg = app.message.as_ref().unwrap(); + assert!(msg.contains("3 worktree(s)")); + assert!(msg.contains("2 branch(es)")); + assert!(app.merged_worktrees.is_empty()); + } + + #[test] + fn test_check_delete_completion_error() { + let mut app = create_test_app(); + let (tx, rx) = mpsc::channel(); + app.delete_receiver = Some(rx); + app.mode = AppMode::Deleting; + + tx.send(DeleteResult::Error("something went wrong".to_string())) + .unwrap(); + + let result = app.check_delete_completion(); + assert!(result.is_ok()); + assert_eq!(app.mode, AppMode::Normal); + assert!(app + .message + .as_ref() + .unwrap() + .contains("something went wrong")); + } + + #[test] + fn test_confirm_action_delete_branch_true() { + let mut app = create_test_app(); + app.mode = AppMode::Confirm; + app.confirm_action = Some(ConfirmAction::DeleteSingle); + app.selected_worktree = 1; // non-main worktree with branch "feature/a" + + let result = app.confirm_action(true); + assert!(result.is_ok()); + assert_eq!(app.mode, AppMode::Deleting); + assert!(app.delete_receiver.is_some()); + } + + #[test] + fn test_confirm_action_empty_filtered_worktrees() { + let mut app = create_test_app(); + app.mode = AppMode::Confirm; + app.confirm_action = Some(ConfirmAction::DeleteSingle); + app.filtered_worktrees.clear(); + + let result = app.confirm_action(false); + assert!(result.is_ok()); + assert_eq!(app.mode, AppMode::Normal); + assert!(app.delete_receiver.is_none()); + } + + #[test] + fn test_check_delete_completion_clears_input() { + let mut app = create_test_app(); + let (tx, rx) = mpsc::channel(); + app.delete_receiver = Some(rx); + app.mode = AppMode::Deleting; + app.input = "leftover search".to_string(); + app.confirm_action = Some(ConfirmAction::DeleteSingle); + + tx.send(DeleteResult::SingleCompleted { + worktree_name: "test-wt".to_string(), + branch_name: None, + branch_deleted: false, + error_message: None, + }) + .unwrap(); + + let result = app.check_delete_completion(); + assert!(result.is_ok()); + assert!(app.input.is_empty(), "input should be cleared after delete"); + assert!( + app.confirm_action.is_none(), + "confirm_action should be cleared after delete" + ); + } + + #[test] + fn test_check_delete_completion_prune_without_branches() { + let mut app = create_test_app(); + let (tx, rx) = mpsc::channel(); + app.delete_receiver = Some(rx); + app.mode = AppMode::Deleting; + app.merged_worktrees = vec![Worktree { + name: "wt".to_string(), + path: PathBuf::from("/repo/wt"), + branch: None, + is_main: false, + }]; + + tx.send(DeleteResult::PruneCompleted { + worktree_count: 2, + branch_count: 0, + }) + .unwrap(); + + let result = app.check_delete_completion(); + assert!(result.is_ok()); + let msg = app.message.as_ref().unwrap(); + assert!(msg.contains("2 merged worktree(s)")); + assert!( + !msg.contains("branch"), + "should not mention branches when count is 0" + ); + } + + // ========== execute_delete_single Tests ========== + + #[test] + fn test_execute_delete_single_success() { + let (_temp_dir, repo_path) = setup_git_repo(); + create_test_worktree_in_repo(&repo_path, "feature-del", "wt-del"); + + let result = execute_delete_single(&repo_path, "wt-del", None, false); + + match result { + DeleteResult::SingleCompleted { + worktree_name, + branch_deleted, + error_message, + .. + } => { + assert_eq!(worktree_name, "wt-del"); + assert!(!branch_deleted); + assert!(error_message.is_none()); + // Verify worktree directory is removed + assert!(!repo_path.join("wt-del").exists()); + } + other => panic!("Expected SingleCompleted, got {:?}", other), + } + } + + #[test] + fn test_execute_delete_single_with_branch_deletion() { + let (_temp_dir, repo_path) = setup_git_repo(); + create_test_worktree_in_repo(&repo_path, "feature-br", "wt-br"); + + let result = + execute_delete_single(&repo_path, "wt-br", Some("feature-br".to_string()), true); + + match result { + DeleteResult::SingleCompleted { + worktree_name, + branch_deleted, + error_message, + .. + } => { + assert_eq!(worktree_name, "wt-br"); + assert!(branch_deleted); + assert!(error_message.is_none()); + } + other => panic!("Expected SingleCompleted, got {:?}", other), + } + } + + #[test] + fn test_execute_delete_single_worktree_not_found() { + let (_temp_dir, repo_path) = setup_git_repo(); + + let result = execute_delete_single(&repo_path, "nonexistent", None, false); + + match result { + DeleteResult::Error(msg) => { + assert!(msg.contains("Worktree not found")); + } + other => panic!("Expected Error, got {:?}", other), + } + } + + #[test] + fn test_execute_delete_single_invalid_repo() { + let temp_dir = TempDir::new().unwrap(); + let bad_path = temp_dir.path().to_path_buf(); + + let result = execute_delete_single(&bad_path, "wt", None, false); + + match result { + DeleteResult::Error(msg) => { + assert!(msg.contains("Failed to open repository")); + } + other => panic!("Expected Error, got {:?}", other), + } + } + + #[test] + fn test_execute_delete_single_branch_delete_fails() { + let (_temp_dir, repo_path) = setup_git_repo(); + create_test_worktree_in_repo(&repo_path, "feature-fail", "wt-fail"); + + // Try to delete with a non-existent branch name + let result = execute_delete_single( + &repo_path, + "wt-fail", + Some("nonexistent-branch".to_string()), + true, + ); + + match result { + DeleteResult::SingleCompleted { + worktree_name, + branch_deleted, + error_message, + .. + } => { + assert_eq!(worktree_name, "wt-fail"); + assert!(!branch_deleted); + assert!(error_message.is_some()); + assert!(error_message.unwrap().contains("failed to delete branch")); + } + other => panic!("Expected SingleCompleted with error, got {:?}", other), + } + } + + #[test] + fn test_execute_delete_single_no_branch_delete_when_false() { + let (_temp_dir, repo_path) = setup_git_repo(); + create_test_worktree_in_repo(&repo_path, "feature-keep", "wt-keep"); + + let result = execute_delete_single( + &repo_path, + "wt-keep", + Some("feature-keep".to_string()), + false, // do NOT delete branch + ); + + match result { + DeleteResult::SingleCompleted { + branch_deleted, + error_message, + .. + } => { + assert!(!branch_deleted); + assert!(error_message.is_none()); + // Verify branch still exists + let output = Command::new("git") + .args(["branch", "--list", "feature-keep"]) + .current_dir(&repo_path) + .output() + .unwrap(); + let branches = String::from_utf8_lossy(&output.stdout); + assert!( + branches.contains("feature-keep"), + "Branch should still exist when delete_branch=false" + ); + } + other => panic!("Expected SingleCompleted, got {:?}", other), + } + } + + // ========== execute_prune Tests ========== + + #[test] + fn test_execute_prune_success() { + let (_temp_dir, repo_path) = setup_git_repo(); + create_test_worktree_in_repo(&repo_path, "prune-a", "wt-prune-a"); + create_test_worktree_in_repo(&repo_path, "prune-b", "wt-prune-b"); + + let worktrees = vec![ + ("wt-prune-a".to_string(), Some("prune-a".to_string())), + ("wt-prune-b".to_string(), Some("prune-b".to_string())), + ]; + + let result = execute_prune(&repo_path, worktrees, false); + + match result { + DeleteResult::PruneCompleted { + worktree_count, + branch_count, + } => { + assert_eq!(worktree_count, 2); + assert_eq!(branch_count, 0); + } + other => panic!("Expected PruneCompleted, got {:?}", other), + } + } + + #[test] + fn test_execute_prune_with_branch_deletion() { + let (_temp_dir, repo_path) = setup_git_repo(); + create_test_worktree_in_repo(&repo_path, "prune-br-a", "wt-pbr-a"); + create_test_worktree_in_repo(&repo_path, "prune-br-b", "wt-pbr-b"); + + let worktrees = vec![ + ("wt-pbr-a".to_string(), Some("prune-br-a".to_string())), + ("wt-pbr-b".to_string(), Some("prune-br-b".to_string())), + ]; + + let result = execute_prune(&repo_path, worktrees, true); + + match result { + DeleteResult::PruneCompleted { + worktree_count, + branch_count, + } => { + assert_eq!(worktree_count, 2); + assert_eq!(branch_count, 2); + } + other => panic!("Expected PruneCompleted, got {:?}", other), + } + } + + #[test] + fn test_execute_prune_partial_failure() { + let (_temp_dir, repo_path) = setup_git_repo(); + create_test_worktree_in_repo(&repo_path, "prune-ok", "wt-prune-ok"); + + let worktrees = vec![ + ("wt-prune-ok".to_string(), None), + ("nonexistent-wt".to_string(), None), // will fail + ]; + + let result = execute_prune(&repo_path, worktrees, false); + + match result { + DeleteResult::PruneCompleted { + worktree_count, + branch_count, + } => { + assert_eq!(worktree_count, 1, "only one worktree should be deleted"); + assert_eq!(branch_count, 0); + } + other => panic!("Expected PruneCompleted, got {:?}", other), + } + } + + #[test] + fn test_execute_prune_all_fail() { + let (_temp_dir, repo_path) = setup_git_repo(); + + let worktrees = vec![ + ("no-such-wt-1".to_string(), None), + ("no-such-wt-2".to_string(), None), + ]; + + let result = execute_prune(&repo_path, worktrees, false); + + match result { + DeleteResult::PruneCompleted { + worktree_count, + branch_count, + } => { + assert_eq!(worktree_count, 0); + assert_eq!(branch_count, 0); + } + other => panic!("Expected PruneCompleted, got {:?}", other), + } + } + + #[test] + fn test_execute_prune_invalid_repo() { + let temp_dir = TempDir::new().unwrap(); + let bad_path = temp_dir.path().to_path_buf(); + + let worktrees = vec![("wt".to_string(), None)]; + let result = execute_prune(&bad_path, worktrees, false); + + match result { + DeleteResult::Error(msg) => { + assert!(msg.contains("Failed to open repository")); + } + other => panic!("Expected Error, got {:?}", other), + } + } + + #[test] + fn test_execute_prune_empty_list() { + let (_temp_dir, repo_path) = setup_git_repo(); + + let result = execute_prune(&repo_path, vec![], false); + + match result { + DeleteResult::PruneCompleted { + worktree_count, + branch_count, + } => { + assert_eq!(worktree_count, 0); + assert_eq!(branch_count, 0); + } + other => panic!("Expected PruneCompleted, got {:?}", other), + } + } } diff --git a/src/git/worktree.rs b/src/git/worktree.rs index 7e06ce9..613e3d7 100644 --- a/src/git/worktree.rs +++ b/src/git/worktree.rs @@ -374,6 +374,7 @@ impl GitManager { } /// Delete a worktree + #[allow(dead_code)] pub fn delete_worktree(&self, name: &str) -> Result<(), GitError> { let wt = self.repo.find_worktree(name)?; let path = wt.path().to_path_buf(); @@ -394,6 +395,7 @@ impl GitManager { } /// Delete a local branch (force delete, equivalent to `git branch -D`) + #[allow(dead_code)] pub fn delete_branch(&self, branch_name: &str) -> Result<(), GitError> { use std::process::Command; diff --git a/src/input.rs b/src/input.rs index c4e0d44..5478c31 100644 --- a/src/input.rs +++ b/src/input.rs @@ -14,6 +14,7 @@ pub fn handle_key_event(app: &mut App, key: KeyEvent) -> InputResult { AppMode::Normal => handle_normal_mode(app, key), AppMode::Create => handle_create_mode(app, key), AppMode::Confirm => handle_confirm_mode(app, key), + AppMode::Deleting => handle_deleting_mode(key), AppMode::Help => handle_help_mode(app, key), } } @@ -173,6 +174,11 @@ fn handle_confirm_mode(app: &mut App, key: KeyEvent) -> InputResult { } } +fn handle_deleting_mode(_key: KeyEvent) -> InputResult { + // Ignore all key input during deletion + InputResult::Continue +} + fn handle_help_mode(app: &mut App, key: KeyEvent) -> InputResult { match key.code { KeyCode::Esc | KeyCode::Enter | KeyCode::Char('q') => { @@ -430,6 +436,70 @@ mod tests { assert_eq!(app.mode, AppMode::Normal); } + #[test] + fn test_confirm_mode_cancel_upper_n() { + let mut app = create_test_app(); + app.mode = AppMode::Confirm; + + let result = handle_key_event(&mut app, key_shift('N')); + + assert!(matches!(result, InputResult::Continue)); + assert_eq!(app.mode, AppMode::Normal); + } + + #[test] + fn test_confirm_mode_accept_y() { + let mut app = create_test_app(); + app.mode = AppMode::Confirm; + app.confirm_action = Some(crate::app::ConfirmAction::DeleteSingle); + app.selected_worktree = 1; // non-main worktree + + let result = handle_key_event(&mut app, key(KeyCode::Char('y'))); + + assert!(matches!(result, InputResult::Continue)); + // Should transition to Deleting mode (background delete started) + assert_eq!(app.mode, AppMode::Deleting); + } + + #[test] + fn test_confirm_mode_accept_enter() { + let mut app = create_test_app(); + app.mode = AppMode::Confirm; + app.confirm_action = Some(crate::app::ConfirmAction::DeleteSingle); + app.selected_worktree = 1; // non-main worktree + + let result = handle_key_event(&mut app, key(KeyCode::Enter)); + + assert!(matches!(result, InputResult::Continue)); + assert_eq!(app.mode, AppMode::Deleting); + } + + #[test] + fn test_confirm_mode_accept_upper_y_deletes_branch() { + let mut app = create_test_app(); + app.mode = AppMode::Confirm; + app.confirm_action = Some(crate::app::ConfirmAction::DeleteSingle); + app.selected_worktree = 1; // non-main worktree + + let result = handle_key_event(&mut app, key_shift('Y')); + + assert!(matches!(result, InputResult::Continue)); + // Y triggers confirm_action(true) which also deletes branch + assert_eq!(app.mode, AppMode::Deleting); + } + + #[test] + fn test_confirm_mode_ignores_other_keys() { + let mut app = create_test_app(); + app.mode = AppMode::Confirm; + app.confirm_action = Some(crate::app::ConfirmAction::DeleteSingle); + + let result = handle_key_event(&mut app, key(KeyCode::Char('x'))); + + assert!(matches!(result, InputResult::Continue)); + assert_eq!(app.mode, AppMode::Confirm); + } + // ========== Help Mode Tests ========== #[test] @@ -475,9 +545,18 @@ mod tests { let result = handle_key_event(&mut app, key_shift('D')); assert!(matches!(result, InputResult::Continue)); - // Should enter confirm mode for prune (or show message if no merged worktrees) - // Since test app has no merged worktrees, it shows a message - assert!(app.message.is_some()); + // Should either enter confirm mode (merged worktrees found) + // or show "no merged worktrees" message (none found). + // The outcome depends on git repo state, so we verify that + // the key triggers the prune flow rather than being treated as input. + assert!( + app.mode == AppMode::Confirm || app.message.is_some(), + "Shift+D should trigger prune flow, not text input" + ); + assert!( + app.input.is_empty(), + "Shift+D should not add to search input" + ); } #[test] @@ -504,4 +583,26 @@ mod tests { // Lowercase 'd' should be added as search input assert_eq!(app.input, "d"); } + + // ========== Deleting Mode Tests ========== + + #[test] + fn test_deleting_mode_ignores_all_keys() { + let mut app = create_test_app(); + app.mode = AppMode::Deleting; + + // All keys should be ignored during deletion + for key_event in [ + key(KeyCode::Char('q')), + key(KeyCode::Esc), + key(KeyCode::Enter), + key(KeyCode::Char('y')), + key_ctrl('c'), + key_ctrl('q'), + ] { + let result = handle_key_event(&mut app, key_event); + assert!(matches!(result, InputResult::Continue)); + assert_eq!(app.mode, AppMode::Deleting); + } + } } diff --git a/src/main.rs b/src/main.rs index 6b8b49c..c0604f7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -18,6 +18,7 @@ use ratatui::{backend::CrosstermBackend, Terminal, Viewport}; use std::io::stdout; use std::path::PathBuf; use std::process::Command; +use std::time::Duration; /// Git Worktree Manager - A TUI application for managing git worktrees #[derive(Parser)] @@ -108,15 +109,34 @@ fn run_app( loop { terminal.draw(|frame| ui::draw(frame, app))?; - if let Event::Key(key) = event::read()? { - // Only handle key press events (not release) - if key.kind == KeyEventKind::Press { - match handle_key_event(app, key) { - InputResult::Quit => break, - InputResult::Continue => {} + // Check for background delete completion + if let Err(e) = app.check_delete_completion() { + app.message = Some(format!("Error: {}", e)); + } + + // Use shorter poll timeout during deletion for responsive spinner animation + let poll_timeout = if app.mode == app::AppMode::Deleting { + Duration::from_millis(80) + } else { + Duration::from_millis(250) + }; + + if event::poll(poll_timeout)? { + if let Event::Key(key) = event::read()? { + // Only handle key press events (not release) + if key.kind == KeyEventKind::Press { + match handle_key_event(app, key) { + InputResult::Quit => break, + InputResult::Continue => {} + } } } } + + // Increment tick for spinner animation during deletion + if app.mode == app::AppMode::Deleting { + app.tick = app.tick.wrapping_add(1); + } } Ok(()) diff --git a/src/ui.rs b/src/ui.rs index 4aa07eb..f1de2a7 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -11,6 +11,9 @@ use ratatui::{ /// Branch icon (NerdFont) const BRANCH_ICON: &str = "\u{e725}"; +/// Spinner animation frames (braille pattern) +const SPINNER_FRAMES: &[char] = &['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; + /// Format branch display with optional icon fn format_branch_with_icon(branch: &str, icons_enabled: bool) -> String { if icons_enabled { @@ -38,6 +41,10 @@ pub fn draw(frame: &mut Frame, app: &App) { draw_normal_mode(frame, app, area, colors); draw_confirm_dialog(frame, app, colors); } + AppMode::Deleting => { + draw_normal_mode(frame, app, area, colors); + draw_deleting_dialog(frame, app, colors); + } AppMode::Help => { draw_normal_mode(frame, app, area, colors); draw_help_dialog(frame, colors); @@ -479,7 +486,7 @@ fn draw_confirm_dialog(frame: &mut Frame, app: &App, colors: &ThemeColors) { let message = match app.confirm_action { Some(ConfirmAction::DeleteSingle) => { - let wt = &app.worktrees[app.selected_worktree]; + let wt = &app.filtered_worktrees[app.selected_worktree]; format!("Delete worktree '{}'?", wt.name) } Some(ConfirmAction::Prune) => { @@ -497,18 +504,65 @@ fn draw_confirm_dialog(frame: &mut Frame, app: &App, colors: &ThemeColors) { None => String::new(), }; - let dialog = Paragraph::new(format!( - "{}\n\n[y] worktree / [Y] worktree & branch / [n] cancel", - message - )) - .block( + let shortcut_line = Line::from(vec![ + Span::styled("y", Style::default().fg(colors.key)), + Span::styled(": worktree ", Style::default().fg(colors.description)), + Span::styled("Y", Style::default().fg(colors.key)), + Span::styled( + ": worktree & branch ", + Style::default().fg(colors.description), + ), + Span::styled("n", Style::default().fg(colors.key)), + Span::styled("/", Style::default().fg(colors.description)), + Span::styled("Esc", Style::default().fg(colors.key)), + Span::styled(": cancel", Style::default().fg(colors.description)), + ]); + + let mut lines: Vec = message + .lines() + .map(|l| { + Line::from(Span::styled( + l.to_string(), + Style::default().fg(colors.text), + )) + }) + .collect(); + lines.push(Line::from("")); + lines.push(shortcut_line); + + let dialog = Paragraph::new(lines).block( Block::default() .borders(Borders::ALL) .title("Confirm") .style(Style::default().fg(colors.warning)) .padding(Padding::horizontal(1)), - ) - .style(Style::default()); + ); + + frame.render_widget(Clear, area); + frame.render_widget(dialog, area); +} + +fn draw_deleting_dialog(frame: &mut Frame, app: &App, colors: &ThemeColors) { + let area = centered_rect(50, 20, frame.area()); + + let spinner = SPINNER_FRAMES[(app.tick as usize) % SPINNER_FRAMES.len()]; + let message = format!( + "{} {}", + spinner, + app.deleting_message.as_deref().unwrap_or("Deleting...") + ); + + let dialog = Paragraph::new(Line::from(vec![Span::styled( + message, + Style::default().fg(colors.warning), + )])) + .block( + Block::default() + .borders(Borders::ALL) + .title("Processing") + .style(Style::default().fg(colors.warning)) + .padding(Padding::horizontal(1)), + ); frame.render_widget(Clear, area); frame.render_widget(dialog, area);