fix(worktree): remove auto-commit on deletion and add uncommitted changes warning
Worktree deletion was failing with ETIMEDOUT because git add/commit scanned massive node_modules directories (Electron.app bundles). The auto-commit step was unnecessary — the Python backend never does it, and users explicitly choose to delete. Changes: - Remove auto-commit step from worktree-cleanup.ts and all call sites - Add /bin/rm -rf fallback when Node.js rm() fails on macOS .app bundles - Add TASK_CHECK_WORKTREE_CHANGES IPC to detect uncommitted changes - Show amber warning in delete dialog when worktree has uncommitted files - Add deleteDialog i18n keys for en/fr Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,7 @@ import { ipcMain, nativeImage } from 'electron';
|
||||
import { IPC_CHANNELS, AUTO_BUILD_PATHS, getSpecsDir } from '../../../shared/constants';
|
||||
import type { IPCResult, Task, TaskMetadata } from '../../../shared/types';
|
||||
import path from 'path';
|
||||
import { execFileSync } from 'child_process';
|
||||
import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, Dirent } from 'fs';
|
||||
import { projectStore } from '../../project-store';
|
||||
import { titleGenerator } from '../../title-generator';
|
||||
@@ -10,6 +11,8 @@ import { findTaskAndProject } from './shared';
|
||||
import { findAllSpecPaths, isValidTaskId } from '../../utils/spec-path-helpers';
|
||||
import { isPathWithinBase, findTaskWorktree } from '../../worktree-paths';
|
||||
import { cleanupWorktree } from '../../utils/worktree-cleanup';
|
||||
import { getToolPath } from '../../cli-tool-manager';
|
||||
import { getIsolatedGitEnv } from '../../utils/git-isolation';
|
||||
import { taskStateManager } from '../../task-state-manager';
|
||||
|
||||
/**
|
||||
@@ -284,7 +287,6 @@ export function registerTaskCRUDHandlers(agentManager: AgentManager): void {
|
||||
worktreePath,
|
||||
projectPath: project.path,
|
||||
specId: task.specId,
|
||||
commitMessage: 'Auto-save before task deletion',
|
||||
logPrefix: '[TASK_DELETE]',
|
||||
deleteBranch: true
|
||||
});
|
||||
@@ -293,13 +295,8 @@ export function registerTaskCRUDHandlers(agentManager: AgentManager): void {
|
||||
console.error(`[TASK_DELETE] Worktree cleanup failed:`, cleanupResult.warnings);
|
||||
hasErrors = true;
|
||||
errors.push(`Worktree cleanup: ${cleanupResult.warnings.join('; ')}`);
|
||||
} else {
|
||||
if (cleanupResult.autoCommitted) {
|
||||
console.warn(`[TASK_DELETE] Auto-committed uncommitted work before deletion`);
|
||||
}
|
||||
if (cleanupResult.warnings.length > 0) {
|
||||
console.warn(`[TASK_DELETE] Cleanup warnings:`, cleanupResult.warnings);
|
||||
}
|
||||
} else if (cleanupResult.warnings.length > 0) {
|
||||
console.warn(`[TASK_DELETE] Cleanup warnings:`, cleanupResult.warnings);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -650,4 +647,41 @@ export function registerTaskCRUDHandlers(agentManager: AgentManager): void {
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Check if a task's worktree has uncommitted changes
|
||||
* Used by the UI before showing the delete confirmation dialog
|
||||
*/
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.TASK_CHECK_WORKTREE_CHANGES,
|
||||
async (_, taskId: string): Promise<IPCResult<{ hasChanges: boolean; worktreePath?: string; changedFileCount?: number }>> => {
|
||||
const { task, project } = findTaskAndProject(taskId);
|
||||
if (!task || !project) {
|
||||
return { success: true, data: { hasChanges: false } };
|
||||
}
|
||||
|
||||
const worktreePath = findTaskWorktree(project.path, task.specId);
|
||||
if (!worktreePath) {
|
||||
return { success: true, data: { hasChanges: false } };
|
||||
}
|
||||
|
||||
try {
|
||||
const status = execFileSync(getToolPath('git'), ['status', '--porcelain'], {
|
||||
cwd: worktreePath,
|
||||
encoding: 'utf-8',
|
||||
env: getIsolatedGitEnv(),
|
||||
timeout: 5000
|
||||
}).trim();
|
||||
|
||||
const changedFiles = status ? status.split('\n').length : 0;
|
||||
return {
|
||||
success: true,
|
||||
data: { hasChanges: changedFiles > 0, worktreePath, changedFileCount: changedFiles }
|
||||
};
|
||||
} catch {
|
||||
// On error/timeout, return false as fail-safe (don't block deletion)
|
||||
return { success: true, data: { hasChanges: false, worktreePath } };
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2310,7 +2310,6 @@ export function registerWorktreeHandlers(
|
||||
worktreePath,
|
||||
projectPath: project.path,
|
||||
specId: task.specId,
|
||||
commitMessage: 'Auto-save before merge cleanup',
|
||||
logPrefix: '[TASK_WORKTREE_MERGE]',
|
||||
deleteBranch: true
|
||||
});
|
||||
@@ -2753,7 +2752,6 @@ export function registerWorktreeHandlers(
|
||||
worktreePath,
|
||||
projectPath: project.path,
|
||||
specId: task.specId,
|
||||
commitMessage: 'Auto-save before discard',
|
||||
logPrefix: '[TASK_WORKTREE_DISCARD]',
|
||||
deleteBranch: true
|
||||
});
|
||||
@@ -2770,9 +2768,6 @@ export function registerWorktreeHandlers(
|
||||
if (cleanupResult.warnings.length > 0) {
|
||||
console.warn('[TASK_WORKTREE_DISCARD] Cleanup warnings:', cleanupResult.warnings);
|
||||
}
|
||||
if (cleanupResult.autoCommitted) {
|
||||
console.warn('[TASK_WORKTREE_DISCARD] Auto-committed uncommitted work before discard');
|
||||
}
|
||||
|
||||
|
||||
// Only send status change to backlog if not skipped
|
||||
@@ -2844,13 +2839,11 @@ export function registerWorktreeHandlers(
|
||||
};
|
||||
}
|
||||
|
||||
// Use cleanupWorktree which auto-commits any uncommitted changes before deletion
|
||||
// This preserves work in git history (recoverable via reflog for ~90 days)
|
||||
// Use cleanupWorktree for robust, cross-platform worktree deletion
|
||||
const cleanupResult = await cleanupWorktree({
|
||||
worktreePath,
|
||||
projectPath: project.path,
|
||||
specId: specName,
|
||||
commitMessage: 'Auto-save before orphaned worktree deletion',
|
||||
logPrefix: '[ORPHAN_CLEANUP]',
|
||||
deleteBranch: true
|
||||
});
|
||||
@@ -2866,9 +2859,7 @@ export function registerWorktreeHandlers(
|
||||
success: true,
|
||||
data: {
|
||||
success: true,
|
||||
message: cleanupResult.autoCommitted
|
||||
? 'Orphaned worktree deleted (uncommitted changes were auto-saved)'
|
||||
: 'Orphaned worktree deleted successfully'
|
||||
message: 'Orphaned worktree deleted successfully'
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
|
||||
@@ -789,7 +789,6 @@ async function removeTerminalWorktree(
|
||||
logPrefix: '[TerminalWorktree]',
|
||||
deleteBranch: deleteBranch && config.hasGitBranch,
|
||||
branchName: config.branchName || undefined,
|
||||
commitMessage: 'Auto-save before terminal worktree deletion',
|
||||
});
|
||||
|
||||
if (!cleanupResult.success) {
|
||||
|
||||
@@ -7,10 +7,10 @@
|
||||
* The standard `git worktree remove --force` fails on Windows when the worktree
|
||||
* contains untracked files (node_modules, build artifacts, etc.). This utility:
|
||||
*
|
||||
* 1. Auto-commits any uncommitted work (preserves in git history for ~90 days via reflog)
|
||||
* 2. Manually deletes the worktree directory with retry logic for Windows file locks
|
||||
* 3. Prunes git's internal worktree references
|
||||
* 4. Optionally deletes the associated branch
|
||||
* 1. Manually deletes the worktree directory with retry logic for file locks
|
||||
* (falls back to shell `rm -rf` on Unix when Node.js rm() fails)
|
||||
* 2. Prunes git's internal worktree references
|
||||
* 3. Optionally deletes the associated branch
|
||||
*
|
||||
* Related issue: https://github.com/AndyMik90/Auto-Claude/issues/1539
|
||||
*/
|
||||
@@ -32,8 +32,6 @@ export interface WorktreeCleanupOptions {
|
||||
projectPath: string;
|
||||
/** Spec ID for generating branch name (e.g., "001-my-feature") */
|
||||
specId: string;
|
||||
/** Custom commit message for auto-commit (default: "Auto-save before deletion") */
|
||||
commitMessage?: string;
|
||||
/** Log prefix for console messages (e.g., "[TASK_DELETE]") */
|
||||
logPrefix?: string;
|
||||
/** Whether to delete the associated branch (default: true) */
|
||||
@@ -56,8 +54,6 @@ export interface WorktreeCleanupResult {
|
||||
success: boolean;
|
||||
/** The branch that was deleted (if deleteBranch was true) */
|
||||
branch?: string;
|
||||
/** Whether uncommitted changes were auto-committed */
|
||||
autoCommitted?: boolean;
|
||||
/** Warnings that occurred during cleanup (non-fatal issues) */
|
||||
warnings: string[];
|
||||
}
|
||||
@@ -133,7 +129,18 @@ async function deleteDirectoryWithRetry(
|
||||
}
|
||||
}
|
||||
|
||||
// All retries exhausted
|
||||
// All retries exhausted - try shell rm -rf as fallback on Unix
|
||||
// Node's rm() can fail with ENOTEMPTY on macOS .app bundles
|
||||
if (process.platform !== 'win32') {
|
||||
try {
|
||||
console.warn(`${logPrefix} Node.js rm() failed, trying /bin/rm -rf fallback...`);
|
||||
execFileSync('/bin/rm', ['-rf', dirPath], { timeout: 60000 });
|
||||
return;
|
||||
} catch {
|
||||
// Fall through to throw original error
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError || new Error('Failed to delete directory after retries');
|
||||
}
|
||||
|
||||
@@ -143,10 +150,10 @@ async function deleteDirectoryWithRetry(
|
||||
* This function handles the Windows-specific issue where `git worktree remove --force`
|
||||
* fails when the worktree contains untracked files. The approach is:
|
||||
*
|
||||
* 1. Auto-commit any uncommitted changes (preserves work in git history)
|
||||
* 2. Manually delete the directory with retry logic for file locks
|
||||
* 3. Run `git worktree prune` to clean up git's internal references
|
||||
* 4. Optionally delete the associated branch
|
||||
* 1. Manually delete the directory with retry logic for file locks
|
||||
* (falls back to shell `rm -rf` on Unix when Node.js rm() fails)
|
||||
* 2. Run `git worktree prune` to clean up git's internal references
|
||||
* 3. Optionally delete the associated branch
|
||||
*
|
||||
* All errors except directory deletion are logged but don't fail the operation.
|
||||
*
|
||||
@@ -164,9 +171,6 @@ async function deleteDirectoryWithRetry(
|
||||
*
|
||||
* if (result.success) {
|
||||
* console.log('Cleanup successful');
|
||||
* if (result.autoCommitted) {
|
||||
* console.log('Note: Uncommitted work was auto-saved');
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
@@ -175,7 +179,6 @@ export async function cleanupWorktree(options: WorktreeCleanupOptions): Promise<
|
||||
worktreePath,
|
||||
projectPath,
|
||||
specId,
|
||||
commitMessage = 'Auto-save before deletion',
|
||||
logPrefix = '[WORKTREE_CLEANUP]',
|
||||
deleteBranch = true,
|
||||
branchName,
|
||||
@@ -185,7 +188,6 @@ export async function cleanupWorktree(options: WorktreeCleanupOptions): Promise<
|
||||
} = options;
|
||||
|
||||
const warnings: string[] = [];
|
||||
let autoCommitted = false;
|
||||
|
||||
// Security: Validate that worktreePath is within the expected worktree directories
|
||||
// This prevents path traversal attacks and accidental deletion of wrong directories
|
||||
@@ -209,48 +211,7 @@ export async function cleanupWorktree(options: WorktreeCleanupOptions): Promise<
|
||||
console.warn(`${logPrefix} Associated branch: ${branch}`);
|
||||
}
|
||||
|
||||
// 2. Auto-commit any uncommitted changes to preserve work
|
||||
// This ensures the user can recover their work via `git reflog` for ~90 days
|
||||
if (existsSync(worktreePath)) {
|
||||
try {
|
||||
// Check if there are any changes to commit
|
||||
const status = execFileSync(getToolPath('git'), ['status', '--porcelain'], {
|
||||
cwd: worktreePath,
|
||||
encoding: 'utf-8',
|
||||
env: getIsolatedGitEnv(),
|
||||
timeout
|
||||
});
|
||||
|
||||
if (status.trim()) {
|
||||
// There are uncommitted changes - commit them before deletion
|
||||
console.warn(`${logPrefix} Found uncommitted changes, auto-committing...`);
|
||||
|
||||
execFileSync(getToolPath('git'), ['add', '-A'], {
|
||||
cwd: worktreePath,
|
||||
encoding: 'utf-8',
|
||||
env: getIsolatedGitEnv(),
|
||||
timeout
|
||||
});
|
||||
|
||||
execFileSync(getToolPath('git'), ['commit', '-m', commitMessage], {
|
||||
cwd: worktreePath,
|
||||
encoding: 'utf-8',
|
||||
env: getIsolatedGitEnv(),
|
||||
timeout
|
||||
});
|
||||
|
||||
console.warn(`${logPrefix} Auto-committed changes before deletion`);
|
||||
autoCommitted = true;
|
||||
}
|
||||
} catch (commitError) {
|
||||
// Non-critical - log and continue with deletion
|
||||
const msg = commitError instanceof Error ? commitError.message : String(commitError);
|
||||
console.warn(`${logPrefix} Failed to auto-commit changes (non-critical): ${msg}`);
|
||||
warnings.push(`Auto-commit failed: ${msg}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Delete the worktree directory manually
|
||||
// 2. Delete the worktree directory manually
|
||||
// This is required because `git worktree remove --force` fails on Windows
|
||||
// when the directory contains untracked files (node_modules, build artifacts, etc.)
|
||||
if (existsSync(worktreePath)) {
|
||||
@@ -265,7 +226,6 @@ export async function cleanupWorktree(options: WorktreeCleanupOptions): Promise<
|
||||
return {
|
||||
success: false,
|
||||
branch: branch || undefined,
|
||||
autoCommitted,
|
||||
warnings: [...warnings, `Directory deletion failed: ${msg}`]
|
||||
};
|
||||
}
|
||||
@@ -273,7 +233,7 @@ export async function cleanupWorktree(options: WorktreeCleanupOptions): Promise<
|
||||
console.warn(`${logPrefix} Worktree directory already deleted`);
|
||||
}
|
||||
|
||||
// 4. Prune git's internal worktree references
|
||||
// 3. Prune git's internal worktree references
|
||||
// After manual deletion, git still thinks the worktree exists in .git/worktrees/
|
||||
// Running prune cleans up these stale references
|
||||
try {
|
||||
@@ -291,7 +251,7 @@ export async function cleanupWorktree(options: WorktreeCleanupOptions): Promise<
|
||||
warnings.push(`Worktree prune failed: ${msg}`);
|
||||
}
|
||||
|
||||
// 5. Delete the branch if requested
|
||||
// 4. Delete the branch if requested
|
||||
if (deleteBranch && branch) {
|
||||
try {
|
||||
execFileSync(getToolPath('git'), ['branch', '-D', branch], {
|
||||
@@ -313,7 +273,6 @@ export async function cleanupWorktree(options: WorktreeCleanupOptions): Promise<
|
||||
return {
|
||||
success: true,
|
||||
branch: branch || undefined,
|
||||
autoCommitted,
|
||||
warnings
|
||||
};
|
||||
}
|
||||
|
||||
@@ -53,6 +53,9 @@ export interface TaskAPI {
|
||||
checkTaskRunning: (taskId: string) => Promise<IPCResult<boolean>>;
|
||||
resumePausedTask: (taskId: string) => Promise<IPCResult>;
|
||||
|
||||
// Worktree Change Detection
|
||||
checkWorktreeChanges: (taskId: string) => Promise<IPCResult<{ hasChanges: boolean; worktreePath?: string; changedFileCount?: number }>>;
|
||||
|
||||
// Image Operations
|
||||
loadImageThumbnail: (projectPath: string, specId: string, imagePath: string) => Promise<IPCResult<string>>;
|
||||
|
||||
@@ -148,6 +151,10 @@ export const createTaskAPI = (): TaskAPI => ({
|
||||
resumePausedTask: (taskId: string): Promise<IPCResult> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.TASK_RESUME_PAUSED, taskId),
|
||||
|
||||
// Worktree Change Detection
|
||||
checkWorktreeChanges: (taskId: string): Promise<IPCResult<{ hasChanges: boolean; worktreePath?: string; changedFileCount?: number }>> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.TASK_CHECK_WORKTREE_CHANGES, taskId),
|
||||
|
||||
// Image Operations
|
||||
loadImageThumbnail: (projectPath: string, specId: string, imagePath: string): Promise<IPCResult<string>> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.TASK_LOAD_IMAGE_THUMBNAIL, projectPath, specId, imagePath),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Play, Square, CheckCircle2, RotateCcw, Trash2, Loader2, AlertTriangle } from 'lucide-react';
|
||||
import { Button } from '../ui/button';
|
||||
import {
|
||||
@@ -21,6 +22,8 @@ interface TaskActionsProps {
|
||||
showDeleteDialog: boolean;
|
||||
isDeleting: boolean;
|
||||
deleteError: string | null;
|
||||
worktreeChangesInfo: { hasChanges: boolean; worktreePath?: string; changedFileCount?: number } | null;
|
||||
isCheckingChanges: boolean;
|
||||
onStartStop: () => void;
|
||||
onRecover: () => void;
|
||||
onDelete: () => void;
|
||||
@@ -36,11 +39,14 @@ export function TaskActions({
|
||||
showDeleteDialog,
|
||||
isDeleting,
|
||||
deleteError,
|
||||
worktreeChangesInfo,
|
||||
isCheckingChanges,
|
||||
onStartStop,
|
||||
onRecover,
|
||||
onDelete,
|
||||
onShowDeleteDialog
|
||||
}: TaskActionsProps) {
|
||||
const { t } = useTranslation(['tasks']);
|
||||
return (
|
||||
<>
|
||||
<div className="p-4">
|
||||
@@ -117,15 +123,32 @@ export function TaskActions({
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle className="flex items-center gap-2">
|
||||
<AlertTriangle className="h-5 w-5 text-destructive" />
|
||||
Delete Task
|
||||
{t('tasks:deleteDialog.title')}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription asChild>
|
||||
<div className="text-sm text-muted-foreground space-y-3">
|
||||
<p>
|
||||
Are you sure you want to delete <strong className="text-foreground">"{task.title}"</strong>?
|
||||
{t('tasks:deleteDialog.confirmMessage')} <strong className="text-foreground">"{task.title}"</strong>?
|
||||
</p>
|
||||
{isCheckingChanges && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
{t('tasks:deleteDialog.checkingChanges')}
|
||||
</div>
|
||||
)}
|
||||
{worktreeChangesInfo?.hasChanges && (
|
||||
<div className="bg-amber-500/10 border border-amber-500/30 px-3 py-2 rounded-lg text-sm space-y-1">
|
||||
<p className="font-medium text-amber-600 dark:text-amber-400 flex items-center gap-1.5">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
{t('tasks:deleteDialog.uncommittedChanges', { count: worktreeChangesInfo.changedFileCount })}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t('tasks:deleteDialog.uncommittedChangesHint')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-destructive">
|
||||
This action cannot be undone. All task files, including the spec, implementation plan, and any generated code will be permanently deleted from the project.
|
||||
{t('tasks:deleteDialog.destructiveWarning')}
|
||||
</p>
|
||||
{deleteError && (
|
||||
<p className="text-destructive bg-destructive/10 px-3 py-2 rounded-lg text-sm">
|
||||
@@ -136,7 +159,7 @@ export function TaskActions({
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isDeleting}>Cancel</AlertDialogCancel>
|
||||
<AlertDialogCancel disabled={isDeleting}>{t('tasks:deleteDialog.cancel')}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
@@ -148,12 +171,12 @@ export function TaskActions({
|
||||
{isDeleting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Deleting...
|
||||
{t('tasks:deleteDialog.deleting')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete Permanently
|
||||
{t('tasks:deleteDialog.deletePermanently')}
|
||||
</>
|
||||
)}
|
||||
</AlertDialogAction>
|
||||
|
||||
@@ -617,15 +617,32 @@ function TaskDetailModalContent({ open, task, onOpenChange, onSwitchToTerminals,
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle className="flex items-center gap-2">
|
||||
<AlertTriangle className="h-5 w-5 text-destructive" />
|
||||
Delete Task
|
||||
{t('tasks:deleteDialog.title')}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription asChild>
|
||||
<div className="text-sm text-muted-foreground space-y-3">
|
||||
<p>
|
||||
Are you sure you want to delete <strong className="text-foreground">"{task.title}"</strong>?
|
||||
{t('tasks:deleteDialog.confirmMessage')} <strong className="text-foreground">"{task.title}"</strong>?
|
||||
</p>
|
||||
{state.isCheckingChanges && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
{t('tasks:deleteDialog.checkingChanges')}
|
||||
</div>
|
||||
)}
|
||||
{state.worktreeChangesInfo?.hasChanges && (
|
||||
<div className="bg-amber-500/10 border border-amber-500/30 px-3 py-2 rounded-lg text-sm space-y-1">
|
||||
<p className="font-medium text-amber-600 dark:text-amber-400 flex items-center gap-1.5">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
{t('tasks:deleteDialog.uncommittedChanges', { count: state.worktreeChangesInfo.changedFileCount })}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t('tasks:deleteDialog.uncommittedChangesHint')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-destructive">
|
||||
This action cannot be undone. All task files, including the spec, implementation plan, and any generated code will be permanently deleted from the project.
|
||||
{t('tasks:deleteDialog.destructiveWarning')}
|
||||
</p>
|
||||
{state.deleteError && (
|
||||
<p className="text-destructive bg-destructive/10 px-3 py-2 rounded-lg text-sm">
|
||||
@@ -636,7 +653,7 @@ function TaskDetailModalContent({ open, task, onOpenChange, onSwitchToTerminals,
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={state.isDeleting}>Cancel</AlertDialogCancel>
|
||||
<AlertDialogCancel disabled={state.isDeleting}>{t('tasks:deleteDialog.cancel')}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
@@ -648,12 +665,12 @@ function TaskDetailModalContent({ open, task, onOpenChange, onSwitchToTerminals,
|
||||
{state.isDeleting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Deleting...
|
||||
{t('tasks:deleteDialog.deleting')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete Permanently
|
||||
{t('tasks:deleteDialog.deletePermanently')}
|
||||
</>
|
||||
)}
|
||||
</AlertDialogAction>
|
||||
|
||||
@@ -61,6 +61,8 @@ export function useTaskDetail({ task }: UseTaskDetailOptions) {
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||
const [worktreeChangesInfo, setWorktreeChangesInfo] = useState<{ hasChanges: boolean; worktreePath?: string; changedFileCount?: number } | null>(null);
|
||||
const [isCheckingChanges, setIsCheckingChanges] = useState(false);
|
||||
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
|
||||
const [worktreeStatus, setWorktreeStatus] = useState<WorktreeStatus | null>(null);
|
||||
const [worktreeDiff, setWorktreeDiff] = useState<WorktreeDiff | null>(null);
|
||||
@@ -133,6 +135,21 @@ export function useTaskDetail({ task }: UseTaskDetailOptions) {
|
||||
return () => clearInterval(intervalId);
|
||||
}, [task.id, isActiveTask]);
|
||||
|
||||
// Check for uncommitted worktree changes when delete dialog opens
|
||||
useEffect(() => {
|
||||
if (showDeleteDialog && task) {
|
||||
setIsCheckingChanges(true);
|
||||
window.electronAPI.checkWorktreeChanges(task.id).then((result) => {
|
||||
if (result.success && result.data) {
|
||||
setWorktreeChangesInfo(result.data);
|
||||
}
|
||||
setIsCheckingChanges(false);
|
||||
}).catch(() => setIsCheckingChanges(false));
|
||||
} else {
|
||||
setWorktreeChangesInfo(null);
|
||||
}
|
||||
}, [showDeleteDialog, task]);
|
||||
|
||||
// Handle scroll events in logs to detect if user scrolled away from anchor
|
||||
const handleLogsScroll = (e: React.UIEvent<HTMLDivElement>) => {
|
||||
const target = e.target as HTMLDivElement;
|
||||
@@ -486,6 +503,8 @@ export function useTaskDetail({ task }: UseTaskDetailOptions) {
|
||||
showDeleteDialog,
|
||||
isDeleting,
|
||||
deleteError,
|
||||
worktreeChangesInfo,
|
||||
isCheckingChanges,
|
||||
isEditDialogOpen,
|
||||
worktreeStatus,
|
||||
worktreeDiff,
|
||||
@@ -530,6 +549,8 @@ export function useTaskDetail({ task }: UseTaskDetailOptions) {
|
||||
setShowDeleteDialog,
|
||||
setIsDeleting,
|
||||
setDeleteError,
|
||||
setWorktreeChangesInfo,
|
||||
setIsCheckingChanges,
|
||||
setIsEditDialogOpen,
|
||||
setWorktreeStatus,
|
||||
setWorktreeDiff,
|
||||
|
||||
@@ -306,6 +306,12 @@ const browserMockAPI: ElectronAPI = {
|
||||
data: { path: cliPath }
|
||||
}),
|
||||
|
||||
// Worktree Change Detection
|
||||
checkWorktreeChanges: async () => ({
|
||||
success: true,
|
||||
data: { hasChanges: false, changedFileCount: 0 }
|
||||
}),
|
||||
|
||||
// Terminal Worktree Operations
|
||||
createTerminalWorktree: async () => ({
|
||||
success: false,
|
||||
|
||||
@@ -76,6 +76,12 @@ export const taskMock = {
|
||||
|
||||
resumePausedTask: async () => ({ success: true }),
|
||||
|
||||
// Worktree change detection
|
||||
checkWorktreeChanges: async (_taskId: string) => ({
|
||||
success: true as const,
|
||||
data: { hasChanges: false }
|
||||
}),
|
||||
|
||||
// Image operations
|
||||
loadImageThumbnail: async (_projectPath: string, _specId: string, _imagePath: string) => ({
|
||||
success: false,
|
||||
|
||||
@@ -33,6 +33,7 @@ export const IPC_CHANNELS = {
|
||||
TASK_CHECK_RUNNING: 'task:checkRunning',
|
||||
TASK_RESUME_PAUSED: 'task:resumePaused', // Resume a rate-limited or auth-paused task
|
||||
TASK_LOAD_IMAGE_THUMBNAIL: 'task:loadImageThumbnail',
|
||||
TASK_CHECK_WORKTREE_CHANGES: 'task:checkWorktreeChanges',
|
||||
|
||||
// Workspace management (for human review)
|
||||
// Per-spec architecture: Each spec has its own worktree at .worktrees/{spec-name}/
|
||||
|
||||
@@ -330,6 +330,17 @@
|
||||
"hint": "Use an external screenshot tool and paste directly into the task description with {{shortcut}}."
|
||||
}
|
||||
},
|
||||
"deleteDialog": {
|
||||
"title": "Delete Task",
|
||||
"confirmMessage": "Are you sure you want to delete",
|
||||
"destructiveWarning": "This action cannot be undone. All task files, including the spec, implementation plan, and any generated code will be permanently deleted from the project.",
|
||||
"checkingChanges": "Checking for uncommitted changes...",
|
||||
"uncommittedChanges": "This task's worktree has {{count}} uncommitted file(s)",
|
||||
"uncommittedChangesHint": "These changes have not been committed or merged. Deleting this task will permanently discard all uncommitted work in the worktree.",
|
||||
"cancel": "Cancel",
|
||||
"deletePermanently": "Delete Permanently",
|
||||
"deleting": "Deleting..."
|
||||
},
|
||||
"referenceImages": {
|
||||
"title": "Reference Images (optional)",
|
||||
"description": "Add visual references like screenshots or designs to help the AI understand your requirements."
|
||||
|
||||
@@ -329,6 +329,17 @@
|
||||
"hint": "Utilisez un outil de capture d'écran externe et collez directement dans la description de la tâche avec {{shortcut}}."
|
||||
}
|
||||
},
|
||||
"deleteDialog": {
|
||||
"title": "Supprimer la t\u00e2che",
|
||||
"confirmMessage": "\u00cates-vous s\u00fbr de vouloir supprimer",
|
||||
"destructiveWarning": "Cette action est irr\u00e9versible. Tous les fichiers de t\u00e2che, y compris la sp\u00e9cification, le plan d'impl\u00e9mentation et tout code g\u00e9n\u00e9r\u00e9 seront d\u00e9finitivement supprim\u00e9s du projet.",
|
||||
"checkingChanges": "V\u00e9rification des changements non valid\u00e9s...",
|
||||
"uncommittedChanges": "Le worktree de cette t\u00e2che contient {{count}} fichier(s) non valid\u00e9(s)",
|
||||
"uncommittedChangesHint": "Ces changements n'ont pas \u00e9t\u00e9 valid\u00e9s ni fusionn\u00e9s. La suppression de cette t\u00e2che supprimera d\u00e9finitivement tout le travail non valid\u00e9 dans le worktree.",
|
||||
"cancel": "Annuler",
|
||||
"deletePermanently": "Supprimer d\u00e9finitivement",
|
||||
"deleting": "Suppression..."
|
||||
},
|
||||
"referenceImages": {
|
||||
"title": "Images de référence (facultatif)",
|
||||
"description": "Ajoutez des références visuelles comme des captures d'écran ou des conceptions pour aider l'IA à comprendre vos exigences."
|
||||
|
||||
@@ -209,6 +209,9 @@ export interface ElectronAPI {
|
||||
// Image operations
|
||||
loadImageThumbnail: (projectPath: string, specId: string, imagePath: string) => Promise<IPCResult<string>>;
|
||||
|
||||
// Worktree change detection
|
||||
checkWorktreeChanges: (taskId: string) => Promise<IPCResult<{ hasChanges: boolean; worktreePath?: string; changedFileCount?: number }>>;
|
||||
|
||||
// Workspace management (for human review)
|
||||
// Per-spec architecture: Each spec has its own worktree at .worktrees/{spec-name}/
|
||||
getWorktreeStatus: (taskId: string) => Promise<IPCResult<WorktreeStatus>>;
|
||||
|
||||
Reference in New Issue
Block a user