From 7955cbe8906babdc1fa38efc1c4048774234cd43 Mon Sep 17 00:00:00 2001 From: Louis Knight-Webb Date: Fri, 20 Jun 2025 16:56:57 +0100 Subject: [PATCH 1/2] Task attempt 92d72de4-1404-4eee-94c4-a679c302425c - Final changes --- backend/src/models/task_attempt.rs | 73 +++++++++++++++++++++ backend/src/routes/tasks.rs | 44 ++++++++++++- frontend/src/pages/task-attempt-compare.tsx | 52 ++++++++++++++- 3 files changed, 166 insertions(+), 3 deletions(-) diff --git a/backend/src/models/task_attempt.rs b/backend/src/models/task_attempt.rs index 132b71f5..8fa35779 100644 --- a/backend/src/models/task_attempt.rs +++ b/backend/src/models/task_attempt.rs @@ -902,4 +902,77 @@ impl TaskAttempt { // No need to update database as we now get base_commit live from git Ok(new_base_commit) } + + /// Delete a file from the worktree and commit the change + pub async fn delete_file( + pool: &SqlitePool, + attempt_id: Uuid, + task_id: Uuid, + project_id: Uuid, + file_path: &str, + ) -> Result { + // Get the task attempt with validation + let attempt = sqlx::query_as!( + TaskAttempt, + r#"SELECT ta.id as "id!: Uuid", ta.task_id as "task_id!: Uuid", ta.worktree_path, ta.merge_commit, ta.executor, ta.stdout, ta.stderr, ta.created_at as "created_at!: DateTime", ta.updated_at as "updated_at!: DateTime" + FROM task_attempts ta + JOIN tasks t ON ta.task_id = t.id + WHERE ta.id = $1 AND t.id = $2 AND t.project_id = $3"#, + attempt_id, + task_id, + project_id + ) + .fetch_optional(pool) + .await? + .ok_or(TaskAttemptError::TaskNotFound)?; + + // Open the worktree repository + let repo = Repository::open(&attempt.worktree_path)?; + + // Get the absolute path to the file within the worktree + let worktree_path = Path::new(&attempt.worktree_path); + let file_full_path = worktree_path.join(file_path); + + // Check if file exists and delete it + if file_full_path.exists() { + std::fs::remove_file(&file_full_path) + .map_err(|e| TaskAttemptError::Git(GitError::from_str(&format!( + "Failed to delete file {}: {}", + file_path, + e + ))))?; + + debug!("Deleted file: {}", file_path); + } else { + info!("File {} does not exist, skipping deletion", file_path); + } + + // Stage the deletion + let mut index = repo.index()?; + index.remove_path(Path::new(file_path))?; + index.write()?; + + // Create a commit for the file deletion + let signature = repo.signature()?; + let tree_id = index.write_tree()?; + let tree = repo.find_tree(tree_id)?; + + // Get the current HEAD commit + let head = repo.head()?; + let parent_commit = head.peel_to_commit()?; + + let commit_message = format!("Delete file: {}", file_path); + let commit_id = repo.commit( + Some("HEAD"), + &signature, + &signature, + &commit_message, + &tree, + &[&parent_commit], + )?; + + info!("File {} deleted and committed: {}", file_path, commit_id); + + Ok(commit_id.to_string()) + } } diff --git a/backend/src/routes/tasks.rs b/backend/src/routes/tasks.rs index 060c2511..e0e95f31 100644 --- a/backend/src/routes/tasks.rs +++ b/backend/src/routes/tasks.rs @@ -1,5 +1,5 @@ use axum::{ - extract::{Extension, Path}, + extract::{Extension, Path, Query}, http::StatusCode, response::Json as ResponseJson, routing::get, @@ -554,6 +554,44 @@ pub async fn rebase_task_attempt( } } +#[derive(serde::Deserialize)] +pub struct DeleteFileQuery { + file_path: String, +} + +#[axum::debug_handler] +pub async fn delete_task_attempt_file( + Path((project_id, task_id, attempt_id)): Path<(Uuid, Uuid, Uuid)>, + Query(query): Query, + Extension(pool): Extension, +) -> Result>, StatusCode> { + // Verify task attempt exists and belongs to the correct task + match TaskAttempt::exists_for_task(&pool, attempt_id, task_id, project_id).await { + Ok(false) => return Err(StatusCode::NOT_FOUND), + Err(e) => { + tracing::error!("Failed to check task attempt existence: {}", e); + return Err(StatusCode::INTERNAL_SERVER_ERROR); + } + Ok(true) => {} + } + + match TaskAttempt::delete_file(&pool, attempt_id, task_id, project_id, &query.file_path).await { + Ok(_commit_id) => Ok(ResponseJson(ApiResponse { + success: true, + data: None, + message: Some(format!("File '{}' deleted successfully", query.file_path)), + })), + Err(e) => { + tracing::error!("Failed to delete file '{}' from task attempt {}: {}", query.file_path, attempt_id, e); + Ok(ResponseJson(ApiResponse { + success: false, + data: None, + message: Some(e.to_string()), + })) + } + } +} + pub fn tasks_router() -> Router { use axum::routing::post; @@ -598,6 +636,10 @@ pub fn tasks_router() -> Router { "/projects/:project_id/tasks/:task_id/attempts/:attempt_id/open-editor", post(open_task_attempt_in_editor), ) + .route( + "/projects/:project_id/tasks/:task_id/attempts/:attempt_id/delete-file", + post(delete_task_attempt_file), + ) } #[cfg(test)] diff --git a/frontend/src/pages/task-attempt-compare.tsx b/frontend/src/pages/task-attempt-compare.tsx index df75ecc6..05b1c4d2 100644 --- a/frontend/src/pages/task-attempt-compare.tsx +++ b/frontend/src/pages/task-attempt-compare.tsx @@ -2,7 +2,7 @@ import { useState, useEffect } from "react"; import { useParams, useNavigate } from "react-router-dom"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; -import { ArrowLeft, FileText, ChevronDown, ChevronUp, RefreshCw, GitBranch } from "lucide-react"; +import { ArrowLeft, FileText, ChevronDown, ChevronUp, RefreshCw, GitBranch, Trash2 } from "lucide-react"; import { makeRequest } from "@/lib/api"; import type { WorktreeDiff, DiffChunkType, DiffChunk, BranchStatus } from "shared/types"; @@ -30,6 +30,7 @@ export function TaskAttemptComparePage() { const [mergeSuccess, setMergeSuccess] = useState(false); const [rebaseSuccess, setRebaseSuccess] = useState(false); const [expandedSections, setExpandedSections] = useState>(new Set()); + const [deletingFiles, setDeletingFiles] = useState>(new Set()); useEffect(() => { if (projectId && taskId && attemptId) { @@ -319,6 +320,40 @@ export function TaskAttemptComparePage() { }); }; + const handleDeleteFile = async (filePath: string) => { + if (!projectId || !taskId || !attemptId) return; + + try { + setDeletingFiles(prev => new Set(prev).add(filePath)); + const response = await makeRequest( + `/api/projects/${projectId}/tasks/${taskId}/attempts/${attemptId}/delete-file?file_path=${encodeURIComponent(filePath)}`, + { + method: 'POST', + } + ); + + if (response.ok) { + const result: ApiResponse = await response.json(); + if (result.success) { + // Refresh the diff to show updated state + fetchDiff(); + } else { + setError(result.message || "Failed to delete file"); + } + } else { + setError("Failed to delete file"); + } + } catch (err) { + setError("Failed to delete file"); + } finally { + setDeletingFiles(prev => { + const newSet = new Set(prev); + newSet.delete(filePath); + return newSet; + }); + } + }; + if (loading || branchStatusLoading) { return (
@@ -439,10 +474,23 @@ export function TaskAttemptComparePage() {
{diff.files.map((file, fileIndex) => (
-
+

{file.path}

+
{processFileChunks(file.chunks, fileIndex).map((section, sectionIndex) => { From 4859a24ff558a64c801949d47bdad93222c292ae Mon Sep 17 00:00:00 2001 From: Louis Knight-Webb Date: Fri, 20 Jun 2025 17:04:28 +0100 Subject: [PATCH 2/2] File deletion confirmation --- frontend/src/pages/task-attempt-compare.tsx | 335 +++++++++++++------- 1 file changed, 225 insertions(+), 110 deletions(-) diff --git a/frontend/src/pages/task-attempt-compare.tsx b/frontend/src/pages/task-attempt-compare.tsx index 05b1c4d2..39fe2334 100644 --- a/frontend/src/pages/task-attempt-compare.tsx +++ b/frontend/src/pages/task-attempt-compare.tsx @@ -2,9 +2,30 @@ import { useState, useEffect } from "react"; import { useParams, useNavigate } from "react-router-dom"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; -import { ArrowLeft, FileText, ChevronDown, ChevronUp, RefreshCw, GitBranch, Trash2 } from "lucide-react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + ArrowLeft, + FileText, + ChevronDown, + ChevronUp, + RefreshCw, + GitBranch, + Trash2, +} from "lucide-react"; import { makeRequest } from "@/lib/api"; -import type { WorktreeDiff, DiffChunkType, DiffChunk, BranchStatus } from "shared/types"; +import type { + WorktreeDiff, + DiffChunkType, + DiffChunk, + BranchStatus, +} from "shared/types"; interface ApiResponse { success: boolean; @@ -29,8 +50,11 @@ export function TaskAttemptComparePage() { const [rebasing, setRebasing] = useState(false); const [mergeSuccess, setMergeSuccess] = useState(false); const [rebaseSuccess, setRebaseSuccess] = useState(false); - const [expandedSections, setExpandedSections] = useState>(new Set()); + const [expandedSections, setExpandedSections] = useState>( + new Set() + ); const [deletingFiles, setDeletingFiles] = useState>(new Set()); + const [fileToDelete, setFileToDelete] = useState(null); useEffect(() => { if (projectId && taskId && attemptId) { @@ -103,7 +127,7 @@ export function TaskAttemptComparePage() { const response = await makeRequest( `/api/projects/${projectId}/tasks/${taskId}/attempts/${attemptId}/merge`, { - method: 'POST', + method: "POST", } ); @@ -135,7 +159,7 @@ export function TaskAttemptComparePage() { const response = await makeRequest( `/api/projects/${projectId}/tasks/${taskId}/attempts/${attemptId}/rebase`, { - method: 'POST', + method: "POST", } ); @@ -161,13 +185,13 @@ export function TaskAttemptComparePage() { const getChunkClassName = (chunkType: DiffChunkType) => { const baseClass = "font-mono text-sm whitespace-pre px-3 py-1"; - + switch (chunkType) { - case 'Insert': + case "Insert": return `${baseClass} bg-green-50 text-green-800 border-l-2 border-green-400`; - case 'Delete': + case "Delete": return `${baseClass} bg-red-50 text-red-800 border-l-2 border-red-400`; - case 'Equal': + case "Equal": default: return `${baseClass} text-muted-foreground`; } @@ -175,13 +199,13 @@ export function TaskAttemptComparePage() { const getChunkPrefix = (chunkType: DiffChunkType) => { switch (chunkType) { - case 'Insert': - return '+'; - case 'Delete': - return '-'; - case 'Equal': + case "Insert": + return "+"; + case "Delete": + return "-"; + case "Equal": default: - return ' '; + return " "; } }; @@ -192,7 +216,7 @@ export function TaskAttemptComparePage() { } interface ProcessedSection { - type: 'context' | 'change' | 'expanded'; + type: "context" | "change" | "expanded"; lines: ProcessedLine[]; expandKey?: string; expandedAbove?: boolean; @@ -205,14 +229,15 @@ export function TaskAttemptComparePage() { let currentLineNumber = 1; // Convert chunks to lines with line numbers - chunks.forEach(chunk => { - const chunkLines = chunk.content.split('\n'); + chunks.forEach((chunk) => { + const chunkLines = chunk.content.split("\n"); chunkLines.forEach((line, index) => { - if (index < chunkLines.length - 1 || line !== '') { // Skip empty last line from split + if (index < chunkLines.length - 1 || line !== "") { + // Skip empty last line from split lines.push({ content: line, chunkType: chunk.chunk_type, - lineNumber: currentLineNumber++ + lineNumber: currentLineNumber++, }); } }); @@ -224,30 +249,38 @@ export function TaskAttemptComparePage() { while (i < lines.length) { const line = lines[i]; - if (line.chunkType === 'Equal') { + if (line.chunkType === "Equal") { // Look for the next change or end of file let nextChangeIndex = i + 1; - while (nextChangeIndex < lines.length && lines[nextChangeIndex].chunkType === 'Equal') { + while ( + nextChangeIndex < lines.length && + lines[nextChangeIndex].chunkType === "Equal" + ) { nextChangeIndex++; } const contextLength = nextChangeIndex - i; const hasNextChange = nextChangeIndex < lines.length; - const hasPrevChange = sections.length > 0 && sections[sections.length - 1].type === 'change'; + const hasPrevChange = + sections.length > 0 && + sections[sections.length - 1].type === "change"; - if (contextLength <= CONTEXT_LINES * 2 || (!hasPrevChange && !hasNextChange)) { + if ( + contextLength <= CONTEXT_LINES * 2 || + (!hasPrevChange && !hasNextChange) + ) { // Show all context if it's short or if there are no changes around it sections.push({ - type: 'context', - lines: lines.slice(i, nextChangeIndex) + type: "context", + lines: lines.slice(i, nextChangeIndex), }); } else { // Split into context sections with expandable middle if (hasPrevChange) { // Add context after previous change sections.push({ - type: 'context', - lines: lines.slice(i, i + CONTEXT_LINES) + type: "context", + lines: lines.slice(i, i + CONTEXT_LINES), }); i += CONTEXT_LINES; } @@ -256,36 +289,39 @@ export function TaskAttemptComparePage() { // Add expandable section const expandStart = hasPrevChange ? i : i + CONTEXT_LINES; const expandEnd = nextChangeIndex - CONTEXT_LINES; - + if (expandEnd > expandStart) { const expandKey = `${fileIndex}-${expandStart}-${expandEnd}`; const isExpanded = expandedSections.has(expandKey); - + if (isExpanded) { sections.push({ - type: 'expanded', + type: "expanded", lines: lines.slice(expandStart, expandEnd), - expandKey + expandKey, }); } else { sections.push({ - type: 'context', + type: "context", lines: [], - expandKey + expandKey, }); } } // Add context before next change sections.push({ - type: 'context', - lines: lines.slice(nextChangeIndex - CONTEXT_LINES, nextChangeIndex) + type: "context", + lines: lines.slice( + nextChangeIndex - CONTEXT_LINES, + nextChangeIndex + ), }); } else if (!hasPrevChange) { // No changes around, just show first few lines sections.push({ - type: 'context', - lines: lines.slice(i, i + CONTEXT_LINES) + type: "context", + lines: lines.slice(i, i + CONTEXT_LINES), }); } } @@ -294,13 +330,13 @@ export function TaskAttemptComparePage() { } else { // Found a change, collect all consecutive changes const changeStart = i; - while (i < lines.length && lines[i].chunkType !== 'Equal') { + while (i < lines.length && lines[i].chunkType !== "Equal") { i++; } sections.push({ - type: 'change', - lines: lines.slice(changeStart, i) + type: "change", + lines: lines.slice(changeStart, i), }); } } @@ -309,7 +345,7 @@ export function TaskAttemptComparePage() { }; const toggleExpandSection = (expandKey: string) => { - setExpandedSections(prev => { + setExpandedSections((prev) => { const newSet = new Set(prev); if (newSet.has(expandKey)) { newSet.delete(expandKey); @@ -320,15 +356,21 @@ export function TaskAttemptComparePage() { }); }; - const handleDeleteFile = async (filePath: string) => { - if (!projectId || !taskId || !attemptId) return; + const handleDeleteFileClick = (filePath: string) => { + setFileToDelete(filePath); + }; + + const handleConfirmDelete = async () => { + if (!fileToDelete || !projectId || !taskId || !attemptId) return; try { - setDeletingFiles(prev => new Set(prev).add(filePath)); + setDeletingFiles((prev) => new Set(prev).add(fileToDelete)); const response = await makeRequest( - `/api/projects/${projectId}/tasks/${taskId}/attempts/${attemptId}/delete-file?file_path=${encodeURIComponent(filePath)}`, + `/api/projects/${projectId}/tasks/${taskId}/attempts/${attemptId}/delete-file?file_path=${encodeURIComponent( + fileToDelete + )}`, { - method: 'POST', + method: "POST", } ); @@ -346,14 +388,19 @@ export function TaskAttemptComparePage() { } catch (err) { setError("Failed to delete file"); } finally { - setDeletingFiles(prev => { + setDeletingFiles((prev) => { const newSet = new Set(prev); - newSet.delete(filePath); + newSet.delete(fileToDelete); return newSet; }); + setFileToDelete(null); } }; + const handleCancelDelete = () => { + setFileToDelete(null); + }; + if (loading || branchStatusLoading) { return (
@@ -401,11 +448,13 @@ export function TaskAttemptComparePage() { Up to date ) : branchStatus.is_behind === true ? ( - {branchStatus.commits_behind} commit{branchStatus.commits_behind !== 1 ? 's' : ''} behind main + {branchStatus.commits_behind} commit + {branchStatus.commits_behind !== 1 ? "s" : ""} behind main ) : ( - {branchStatus.commits_ahead} commit{branchStatus.commits_ahead !== 1 ? 's' : ''} ahead of main + {branchStatus.commits_ahead} commit + {branchStatus.commits_ahead !== 1 ? "s" : ""} ahead of main )}
@@ -430,21 +479,30 @@ export function TaskAttemptComparePage() { {/* Action Buttons */}
- {branchStatus && branchStatus.is_behind === true && !branchStatus.merged && ( - - )} + {branchStatus && + branchStatus.is_behind === true && + !branchStatus.merged && ( + + )} {!branchStatus?.merged && ( -
- {processFileChunks(file.chunks, fileIndex).map((section, sectionIndex) => { - if (section.type === 'context' && section.lines.length === 0 && section.expandKey) { - // Render expand button - const lineCount = parseInt(section.expandKey.split('-')[2]) - parseInt(section.expandKey.split('-')[1]); + {processFileChunks(file.chunks, fileIndex).map( + (section, sectionIndex) => { + if ( + section.type === "context" && + section.lines.length === 0 && + section.expandKey + ) { + // Render expand button + const lineCount = + parseInt(section.expandKey.split("-")[2]) - + parseInt(section.expandKey.split("-")[1]); + return ( +
+ +
+ ); + } + + // Render lines (context, change, or expanded) return ( -
- +
+ {section.type === "expanded" && + section.expandKey && ( + + )} + {section.lines.map((line, lineIndex) => ( +
+ {getChunkPrefix(line.chunkType)} + {line.content} +
+ ))}
); } - - // Render lines (context, change, or expanded) - return ( -
- {section.type === 'expanded' && section.expandKey && ( - - )} - {section.lines.map((line, lineIndex) => ( -
- {getChunkPrefix(line.chunkType)}{line.content} -
- ))} -
- ); - })} + )}
))} @@ -544,6 +624,41 @@ export function TaskAttemptComparePage() { )} + + {/* Delete File Confirmation Dialog */} + handleCancelDelete()}> + + + Delete File + + Are you sure you want to delete the file{" "} + "{fileToDelete}"? + + +
+
+

+ Warning: This action will permanently remove + the entire file from the worktree. This cannot be undone. +

+
+
+ + + + +
+
); }