Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 44 additions & 4 deletions src/tui/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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;
Expand Down
26 changes: 24 additions & 2 deletions src/tui/ui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand All @@ -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(""),
Expand Down Expand Up @@ -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),
Expand Down
23 changes: 23 additions & 0 deletions src/use_cases.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
46 changes: 46 additions & 0 deletions tests/use_cases_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
// ---------------------------------------------------------------------------
Expand Down