Add compare page
This commit is contained in:
@@ -28,6 +28,7 @@ ts-rs = { version = "9.0", features = ["uuid-impl", "chrono-impl"] }
|
|||||||
dirs = "5.0"
|
dirs = "5.0"
|
||||||
git2 = "0.18"
|
git2 = "0.18"
|
||||||
async-trait = "0.1"
|
async-trait = "0.1"
|
||||||
|
dissimilar = "1.0"
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
ts-rs = { version = "9.0", features = ["uuid-impl", "chrono-impl"] }
|
ts-rs = { version = "9.0", features = ["uuid-impl", "chrono-impl"] }
|
||||||
|
|||||||
@@ -87,6 +87,34 @@ pub struct UpdateTaskAttempt {
|
|||||||
pub merge_commit: Option<String>,
|
pub merge_commit: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
||||||
|
#[ts(export)]
|
||||||
|
pub enum DiffChunkType {
|
||||||
|
Equal,
|
||||||
|
Insert,
|
||||||
|
Delete,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
||||||
|
#[ts(export)]
|
||||||
|
pub struct DiffChunk {
|
||||||
|
pub chunk_type: DiffChunkType,
|
||||||
|
pub content: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
||||||
|
#[ts(export)]
|
||||||
|
pub struct FileDiff {
|
||||||
|
pub path: String,
|
||||||
|
pub chunks: Vec<DiffChunk>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
||||||
|
#[ts(export)]
|
||||||
|
pub struct WorktreeDiff {
|
||||||
|
pub files: Vec<FileDiff>,
|
||||||
|
}
|
||||||
|
|
||||||
impl TaskAttempt {
|
impl TaskAttempt {
|
||||||
pub async fn find_by_id(pool: &PgPool, id: Uuid) -> Result<Option<Self>, sqlx::Error> {
|
pub async fn find_by_id(pool: &PgPool, id: Uuid) -> Result<Option<Self>, sqlx::Error> {
|
||||||
sqlx::query_as!(
|
sqlx::query_as!(
|
||||||
@@ -251,4 +279,110 @@ impl TaskAttempt {
|
|||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get the git diff between the base commit and the current worktree state
|
||||||
|
pub async fn get_diff(
|
||||||
|
pool: &PgPool,
|
||||||
|
attempt_id: Uuid,
|
||||||
|
task_id: Uuid,
|
||||||
|
project_id: Uuid,
|
||||||
|
) -> Result<WorktreeDiff, TaskAttemptError> {
|
||||||
|
// Get the task attempt with validation
|
||||||
|
let attempt = sqlx::query_as!(
|
||||||
|
TaskAttempt,
|
||||||
|
r#"SELECT ta.id, ta.task_id, ta.worktree_path, ta.base_commit, ta.merge_commit, ta.executor, ta.stdout, ta.stderr, ta.created_at, ta.updated_at
|
||||||
|
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)?;
|
||||||
|
|
||||||
|
// Get the project to access the main repository
|
||||||
|
let _task = Task::find_by_id(pool, task_id)
|
||||||
|
.await?
|
||||||
|
.ok_or(TaskAttemptError::TaskNotFound)?;
|
||||||
|
|
||||||
|
let _project = Project::find_by_id(pool, project_id)
|
||||||
|
.await?
|
||||||
|
.ok_or(TaskAttemptError::ProjectNotFound)?;
|
||||||
|
|
||||||
|
// Open the worktree repository
|
||||||
|
let worktree_repo = Repository::open(&attempt.worktree_path)?;
|
||||||
|
|
||||||
|
// Get the base commit
|
||||||
|
let base_commit_str = attempt
|
||||||
|
.base_commit
|
||||||
|
.ok_or_else(|| TaskAttemptError::Git(GitError::from_str("No base commit found")))?;
|
||||||
|
|
||||||
|
let base_oid =
|
||||||
|
git2::Oid::from_str(&base_commit_str).map_err(|e| TaskAttemptError::Git(e))?;
|
||||||
|
|
||||||
|
let base_commit = worktree_repo.find_commit(base_oid)?;
|
||||||
|
let base_tree = base_commit.tree()?;
|
||||||
|
|
||||||
|
// Get status of all files in the worktree
|
||||||
|
let statuses = worktree_repo.statuses(None)?;
|
||||||
|
let mut files = Vec::new();
|
||||||
|
|
||||||
|
for status_entry in statuses.iter() {
|
||||||
|
if let Some(path_str) = status_entry.path() {
|
||||||
|
let path = std::path::Path::new(path_str);
|
||||||
|
let full_path = std::path::Path::new(&attempt.worktree_path).join(path);
|
||||||
|
|
||||||
|
// Get old content from base commit
|
||||||
|
let old_content = match base_tree.get_path(path) {
|
||||||
|
Ok(entry) => match entry.to_object(&worktree_repo) {
|
||||||
|
Ok(obj) => {
|
||||||
|
if let Some(blob) = obj.as_blob() {
|
||||||
|
String::from_utf8_lossy(blob.content()).to_string()
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(_) => String::new(),
|
||||||
|
},
|
||||||
|
Err(_) => String::new(), // File didn't exist in base commit
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get new content from working directory
|
||||||
|
let new_content = std::fs::read_to_string(&full_path).unwrap_or_default();
|
||||||
|
|
||||||
|
// Generate diff chunks using dissimilar
|
||||||
|
if old_content != new_content {
|
||||||
|
let chunks = dissimilar::diff(&old_content, &new_content);
|
||||||
|
let mut diff_chunks = Vec::new();
|
||||||
|
|
||||||
|
for chunk in chunks {
|
||||||
|
let diff_chunk = match chunk {
|
||||||
|
dissimilar::Chunk::Equal(text) => DiffChunk {
|
||||||
|
chunk_type: DiffChunkType::Equal,
|
||||||
|
content: text.to_string(),
|
||||||
|
},
|
||||||
|
dissimilar::Chunk::Delete(text) => DiffChunk {
|
||||||
|
chunk_type: DiffChunkType::Delete,
|
||||||
|
content: text.to_string(),
|
||||||
|
},
|
||||||
|
dissimilar::Chunk::Insert(text) => DiffChunk {
|
||||||
|
chunk_type: DiffChunkType::Insert,
|
||||||
|
content: text.to_string(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
diff_chunks.push(diff_chunk);
|
||||||
|
}
|
||||||
|
|
||||||
|
files.push(FileDiff {
|
||||||
|
path: path_str.to_string(),
|
||||||
|
chunks: diff_chunks,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(WorktreeDiff { files })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ use crate::auth::AuthUser;
|
|||||||
use crate::models::{
|
use crate::models::{
|
||||||
project::Project,
|
project::Project,
|
||||||
task::{CreateTask, Task, TaskWithAttemptStatus, UpdateTask},
|
task::{CreateTask, Task, TaskWithAttemptStatus, UpdateTask},
|
||||||
task_attempt::{CreateTaskAttempt, TaskAttempt, TaskAttemptStatus},
|
task_attempt::{CreateTaskAttempt, TaskAttempt, TaskAttemptStatus, WorktreeDiff},
|
||||||
task_attempt_activity::{CreateTaskAttemptActivity, TaskAttemptActivity},
|
task_attempt_activity::{CreateTaskAttemptActivity, TaskAttemptActivity},
|
||||||
ApiResponse,
|
ApiResponse,
|
||||||
};
|
};
|
||||||
@@ -376,6 +376,24 @@ pub async fn stop_task_attempt(
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn get_task_attempt_diff(
|
||||||
|
_auth: AuthUser,
|
||||||
|
Path((project_id, task_id, attempt_id)): Path<(Uuid, Uuid, Uuid)>,
|
||||||
|
Extension(pool): Extension<PgPool>,
|
||||||
|
) -> Result<ResponseJson<ApiResponse<WorktreeDiff>>, StatusCode> {
|
||||||
|
match TaskAttempt::get_diff(&pool, attempt_id, task_id, project_id).await {
|
||||||
|
Ok(diff) => Ok(ResponseJson(ApiResponse {
|
||||||
|
success: true,
|
||||||
|
data: Some(diff),
|
||||||
|
message: None,
|
||||||
|
})),
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("Failed to get diff for task attempt {}: {}", attempt_id, e);
|
||||||
|
Err(StatusCode::INTERNAL_SERVER_ERROR)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn tasks_router() -> Router {
|
pub fn tasks_router() -> Router {
|
||||||
use axum::routing::{delete, post, put};
|
use axum::routing::{delete, post, put};
|
||||||
|
|
||||||
@@ -400,6 +418,10 @@ pub fn tasks_router() -> Router {
|
|||||||
"/projects/:project_id/tasks/:task_id/attempts/:attempt_id/stop",
|
"/projects/:project_id/tasks/:task_id/attempts/:attempt_id/stop",
|
||||||
post(stop_task_attempt),
|
post(stop_task_attempt),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/projects/:project_id/tasks/:task_id/attempts/:attempt_id/diff",
|
||||||
|
get(get_task_attempt_diff),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { HomePage } from '@/pages/home'
|
|||||||
import { Projects } from '@/pages/projects'
|
import { Projects } from '@/pages/projects'
|
||||||
import { ProjectTasks } from '@/pages/project-tasks'
|
import { ProjectTasks } from '@/pages/project-tasks'
|
||||||
import { TaskDetailsPage } from '@/pages/task-details'
|
import { TaskDetailsPage } from '@/pages/task-details'
|
||||||
|
import { TaskAttemptComparePage } from '@/pages/task-attempt-compare'
|
||||||
import { Users } from '@/pages/users'
|
import { Users } from '@/pages/users'
|
||||||
import { AuthProvider, useAuth } from '@/contexts/auth-context'
|
import { AuthProvider, useAuth } from '@/contexts/auth-context'
|
||||||
|
|
||||||
@@ -44,6 +45,7 @@ function AppContent() {
|
|||||||
<Route path="/projects/:projectId" element={<Projects />} />
|
<Route path="/projects/:projectId" element={<Projects />} />
|
||||||
<Route path="/projects/:projectId/tasks" element={<ProjectTasks />} />
|
<Route path="/projects/:projectId/tasks" element={<ProjectTasks />} />
|
||||||
<Route path="/projects/:projectId/tasks/:taskId" element={<TaskDetailsPage />} />
|
<Route path="/projects/:projectId/tasks/:taskId" element={<TaskDetailsPage />} />
|
||||||
|
<Route path="/projects/:projectId/tasks/:taskId/attempts/:attemptId/compare" element={<TaskAttemptComparePage />} />
|
||||||
<Route path="/users" element={<Users />} />
|
<Route path="/users" element={<Users />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
174
frontend/src/pages/task-attempt-compare.tsx
Normal file
174
frontend/src/pages/task-attempt-compare.tsx
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
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 } from "lucide-react";
|
||||||
|
import { makeAuthenticatedRequest } from "@/lib/auth";
|
||||||
|
import type { WorktreeDiff, DiffChunkType } from "shared/types";
|
||||||
|
|
||||||
|
interface ApiResponse<T> {
|
||||||
|
success: boolean;
|
||||||
|
data: T | null;
|
||||||
|
message: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TaskAttemptComparePage() {
|
||||||
|
const { projectId, taskId, attemptId } = useParams<{
|
||||||
|
projectId: string;
|
||||||
|
taskId: string;
|
||||||
|
attemptId: string;
|
||||||
|
}>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const [diff, setDiff] = useState<WorktreeDiff | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (projectId && taskId && attemptId) {
|
||||||
|
fetchDiff();
|
||||||
|
}
|
||||||
|
}, [projectId, taskId, attemptId]);
|
||||||
|
|
||||||
|
const fetchDiff = async () => {
|
||||||
|
if (!projectId || !taskId || !attemptId) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const response = await makeAuthenticatedRequest(
|
||||||
|
`/api/projects/${projectId}/tasks/${taskId}/attempts/${attemptId}/diff`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const result: ApiResponse<WorktreeDiff> = await response.json();
|
||||||
|
if (result.success && result.data) {
|
||||||
|
setDiff(result.data);
|
||||||
|
} else {
|
||||||
|
setError("Failed to load diff");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setError("Failed to load diff");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError("Failed to load diff");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBackClick = () => {
|
||||||
|
navigate(`/projects/${projectId}/tasks/${taskId}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getChunkClassName = (chunkType: DiffChunkType) => {
|
||||||
|
const baseClass = "font-mono text-sm whitespace-pre px-3 py-1";
|
||||||
|
|
||||||
|
switch (chunkType) {
|
||||||
|
case 'Insert':
|
||||||
|
return `${baseClass} bg-green-50 text-green-800 border-l-2 border-green-400`;
|
||||||
|
case 'Delete':
|
||||||
|
return `${baseClass} bg-red-50 text-red-800 border-l-2 border-red-400`;
|
||||||
|
case 'Equal':
|
||||||
|
default:
|
||||||
|
return `${baseClass} text-gray-700`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getChunkPrefix = (chunkType: DiffChunkType) => {
|
||||||
|
switch (chunkType) {
|
||||||
|
case 'Insert':
|
||||||
|
return '+';
|
||||||
|
case 'Delete':
|
||||||
|
return '-';
|
||||||
|
case 'Equal':
|
||||||
|
default:
|
||||||
|
return ' ';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-background flex items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900 mx-auto mb-4"></div>
|
||||||
|
<p className="text-muted-foreground">Loading diff...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-background flex items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-red-600 mb-4">{error}</p>
|
||||||
|
<Button onClick={handleBackClick} variant="outline">
|
||||||
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
|
Back to Task
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto py-6">
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Button onClick={handleBackClick} variant="outline" size="sm">
|
||||||
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
|
Back to Task
|
||||||
|
</Button>
|
||||||
|
<h1 className="text-2xl font-bold flex items-center gap-2">
|
||||||
|
<FileText className="h-6 w-6" />
|
||||||
|
Compare Changes
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg">
|
||||||
|
Diff: Base Commit vs. Current Worktree
|
||||||
|
</CardTitle>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Shows changes made in the task attempt worktree compared to the base commit
|
||||||
|
</p>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{!diff || diff.files.length === 0 ? (
|
||||||
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
|
<FileText className="h-12 w-12 mx-auto mb-4 opacity-50" />
|
||||||
|
<p>No changes detected</p>
|
||||||
|
<p className="text-sm">The worktree is identical to the base commit</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{diff.files.map((file, fileIndex) => (
|
||||||
|
<div key={fileIndex} className="border rounded-lg overflow-hidden">
|
||||||
|
<div className="bg-gray-50 px-3 py-2 border-b">
|
||||||
|
<p className="text-sm font-medium text-gray-700 font-mono">
|
||||||
|
{file.path}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="max-h-[600px] overflow-y-auto">
|
||||||
|
{file.chunks.map((chunk, chunkIndex) =>
|
||||||
|
chunk.content.split('\n').map((line, lineIndex) => (
|
||||||
|
<div
|
||||||
|
key={`${chunkIndex}-${lineIndex}`}
|
||||||
|
className={getChunkClassName(chunk.chunk_type)}
|
||||||
|
>
|
||||||
|
{getChunkPrefix(chunk.chunk_type)}{line}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { ArrowLeft } from "lucide-react";
|
import { ArrowLeft, FileText } from "lucide-react";
|
||||||
import { makeAuthenticatedRequest } from "@/lib/auth";
|
import { makeAuthenticatedRequest } from "@/lib/auth";
|
||||||
import type {
|
import type {
|
||||||
TaskStatus,
|
TaskStatus,
|
||||||
@@ -659,6 +659,17 @@ export function TaskDetailsPage() {
|
|||||||
Actions
|
Actions
|
||||||
</Label>
|
</Label>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
|
{selectedAttempt && (
|
||||||
|
<Button
|
||||||
|
onClick={() => navigate(`/projects/${projectId}/tasks/${taskId}/attempts/${selectedAttempt.id}/compare`)}
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
<FileText className="mr-2 h-4 w-4" />
|
||||||
|
View Changes
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
{isAttemptRunning && (
|
{isAttemptRunning && (
|
||||||
<Button
|
<Button
|
||||||
onClick={stopTaskAttempt}
|
onClick={stopTaskAttempt}
|
||||||
|
|||||||
Reference in New Issue
Block a user