diff --git a/src/tui/app.rs b/src/tui/app.rs index 500f08e..b1dccfe 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -1186,6 +1186,43 @@ impl App { Ok(()) } + fn fully_delete_task(&mut self) -> Result<()> { + if self.tasks.is_empty() { + return Ok(()); + } + + let task = self.tasks.remove(self.selected_index); + let task_id = task.meta.task_id(); + + tracing::info!(task_id = %task_id, "TUI: full delete requested"); + self.log_output(format!("Fully deleting task {}...", task_id)); + + // Kill tmux sessions for all repos (side effect) + if task.meta.has_repos() { + for repo in &task.meta.repos { + let _ = Tmux::kill_session(&repo.tmux_session); + } + } + // Also kill the parent-dir session (used for repo-inspector in multi-repo tasks) + if task.meta.is_multi_repo() { + let parent_session = Config::tmux_session_name(&task.meta.name, &task.meta.branch_name); + let _ = Tmux::kill_session(&parent_session); + } + self.log_output(" Killed tmux session(s)".to_string()); + + // Delegate business logic to use_cases + use_cases::fully_delete_task(&self.config, task)?; + self.log_output(" Deleted task".to_string()); + + if self.selected_index >= self.tasks.len() && !self.tasks.is_empty() { + self.selected_index = self.tasks.len() - 1; + } + + self.set_status(format!("Deleted: {}", task_id)); + self.view = View::TaskList; + Ok(()) + } + fn start_feedback(&mut self) { // Clear the feedback editor and start in insert mode self.feedback_editor = VimTextArea::new(); @@ -2992,14 +3029,17 @@ impl App { if let Event::Key(key) = event { match key.code { KeyCode::Char('j') | KeyCode::Down => { - self.archive_mode_index = (self.archive_mode_index + 1) % 2; + self.archive_mode_index = (self.archive_mode_index + 1) % 3; } KeyCode::Char('k') | KeyCode::Up => { - self.archive_mode_index = if self.archive_mode_index == 0 { 1 } else { 0 }; + self.archive_mode_index = (self.archive_mode_index + 2) % 3; } KeyCode::Enter => { - let saved = self.archive_mode_index == 1; - self.archive_task(saved)?; + match self.archive_mode_index { + 0 => self.archive_task(false)?, + 1 => self.archive_task(true)?, + _ => self.fully_delete_task()?, + } } KeyCode::Esc | KeyCode::Char('q') => { self.view = View::TaskList; diff --git a/src/tui/ui.rs b/src/tui/ui.rs index 0e9653f..385e888 100644 --- a/src/tui/ui.rs +++ b/src/tui/ui.rs @@ -956,7 +956,7 @@ fn draw_feedback(f: &mut Frame, app: &mut App) { } fn draw_delete_confirm(f: &mut Frame, app: &App, retention_days: u64) { - let area = centered_rect(55, 45, f.area()); + let area = centered_rect(55, 55, f.area()); f.render_widget(Clear, area); @@ -983,9 +983,18 @@ fn draw_delete_confirm(f: &mut Frame, app: &App, retention_days: u64) { } else { Style::default().fg(Color::Gray) }; + let delete_style = if sel == 2 { + Style::default() + .fg(Color::White) + .bg(Color::Rgb(60, 20, 20)) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::Gray) + }; let archive_prefix = if sel == 0 { "▸ " } else { " " }; let save_prefix = if sel == 1 { "▸ " } else { " " }; + let delete_prefix = if sel == 2 { "▸ " } else { " " }; let text = vec![ Line::from(""), @@ -1021,12 +1030,25 @@ fn draw_delete_confirm(f: &mut Frame, app: &App, retention_days: u64) { " Use for tasks you want to keep permanently.", Style::default().fg(Color::LightCyan), )), + Line::from(""), + Line::from(Span::styled( + format!("{}Delete", delete_prefix), + delete_style, + )), + Line::from(Span::styled( + " Kill tmux, remove worktree, delete branches", + Style::default().fg(Color::LightRed), + )), + Line::from(Span::styled( + " and task files. Irreversible.", + Style::default().fg(Color::LightRed), + )), ]; let popup = Paragraph::new(text).block( Block::default() .title(Span::styled( - " Archive Task ", + " Remove Task ", Style::default() .fg(Color::LightBlue) .add_modifier(Modifier::BOLD), diff --git a/src/use_cases.rs b/src/use_cases.rs index 859c2ef..ac614d3 100644 --- a/src/use_cases.rs +++ b/src/use_cases.rs @@ -261,6 +261,29 @@ pub fn permanently_delete_archived_task(config: &Config, task: Task) -> Result<( Ok(()) } +/// Fully delete a task: remove worktrees, delete branches, and remove the task +/// directory. This is the "nuclear option" — everything is gone immediately. +/// +/// Like `archive_task`, this does NOT kill tmux sessions — the caller handles that. +pub fn fully_delete_task(config: &Config, task: Task) -> Result<()> { + tracing::info!(task_id = %task.meta.task_id(), "fully deleting task"); + + // Remove worktrees (best-effort) + for repo in &task.meta.repos { + let repo_path = config.repo_path(&repo.repo_name); + let _ = Git::remove_worktree(&repo_path, &repo.worktree_path); + } + + // Delete branches (best-effort) + for repo in &task.meta.repos { + let repo_path = config.repo_path(&repo.repo_name); + let _ = Git::delete_branch(&repo_path, &task.meta.branch_name); + } + + task.delete(config)?; + Ok(()) +} + /// Toggle the saved flag on an archived task. pub fn toggle_archive_saved(_config: &Config, task: &mut Task) -> Result<()> { let new_saved = !task.meta.saved; diff --git a/tests/use_cases_test.rs b/tests/use_cases_test.rs index 5c2de17..12d6864 100644 --- a/tests/use_cases_test.rs +++ b/tests/use_cases_test.rs @@ -350,6 +350,52 @@ fn permanently_delete_archived_task() { ); } +// --------------------------------------------------------------------------- +// Fully delete task +// --------------------------------------------------------------------------- + +#[test] +fn fully_delete_task() { + let tmp = tempfile::tempdir().unwrap(); + let config = test_config(&tmp); + let _repo_path = init_test_repo(&tmp, "myrepo"); + + let task = use_cases::create_task( + &config, + "myrepo", + "full-del", + "desc", + "new", + WorktreeSource::NewBranch { base_branch: None }, + false, + ) + .unwrap(); + + let task_dir = task.dir.clone(); + + // Branch should exist after creation + let branch_check = std::process::Command::new("git") + .args(["branch", "--list", "full-del"]) + .current_dir(&_repo_path) + .output() + .unwrap(); + assert!(!branch_check.stdout.is_empty(), "branch should exist after creation"); + + use_cases::fully_delete_task(&config, task).unwrap(); + assert!(!task_dir.exists(), "task directory should be removed"); + + // Branch should be deleted after full delete + let branch_check = std::process::Command::new("git") + .args(["branch", "--list", "full-del"]) + .current_dir(&_repo_path) + .output() + .unwrap(); + assert!( + branch_check.stdout.is_empty(), + "branch should be deleted after full delete" + ); +} + // --------------------------------------------------------------------------- // Stop task // ---------------------------------------------------------------------------