From 22edb7a1db8c92715086258dbf7b11867d3fc825 Mon Sep 17 00:00:00 2001 From: Louis Knight-Webb Date: Mon, 16 Jun 2025 16:16:42 -0400 Subject: [PATCH] Squashed commit of the following: commit 38f68d5ed489f416ea91630aea3496ab15365e66 Author: Louis Knight-Webb Date: Mon Jun 16 16:16:28 2025 -0400 Fix click and drag commit eb5c41cf31fd8032fe88fd47fe5f3e7f517f6d30 Author: Louis Knight-Webb Date: Mon Jun 16 15:57:13 2025 -0400 Update tasks commit 979d4b15373df3193eb1bd41c18ece1dbe044eba Author: Louis Knight-Webb Date: Mon Jun 16 15:19:20 2025 -0400 Status commit fa26f1fa8fefe1d84b5b2153327c7e8c0132952a Author: Louis Knight-Webb Date: Mon Jun 16 14:54:48 2025 -0400 Cleanup project card commit 14d7a1d7d7574dd8745167b280c04603ba22b189 Author: Louis Knight-Webb Date: Mon Jun 16 14:49:19 2025 -0400 Improve existing vs new repo commit 277e1f05ef68e5c67d73b246557a6df2ab23d32c Author: Louis Knight-Webb Date: Mon Jun 16 13:01:21 2025 -0400 Make repo path unique commit f80ef55f2ba16836276a81844fc33639872bcc53 Author: Louis Knight-Webb Date: Mon Jun 16 12:52:20 2025 -0400 Fix styles commit 077869458fcab199a10ef0fe2fe39f9f4216ce5b Author: Louis Knight-Webb Date: Mon Jun 16 12:41:48 2025 -0400 First select repo commit 1b0d9c0280e4cb75294348bb53b2a534458a2e37 Author: Louis Knight-Webb Date: Mon Jun 16 11:45:19 2025 -0400 Init --- backend/Cargo.toml | 1 + backend/migrations/004_add_git_repo_path.sql | 2 + .../migrations/005_unique_git_repo_path.sql | 2 + .../migrations/006_create_task_attempts.sql | 33 ++ backend/src/bin/generate_types.rs | 22 +- backend/src/main.rs | 3 +- backend/src/models/mod.rs | 2 + backend/src/models/project.rs | 4 + backend/src/models/task_attempt.rs | 44 +++ backend/src/models/task_attempt_activity.rs | 25 ++ backend/src/routes/filesystem.rs | 207 +++++++++++ backend/src/routes/mod.rs | 1 + backend/src/routes/projects.rs | 173 ++++++++- backend/src/routes/tasks.rs | 241 ++++++++++++- .../src/components/projects/project-form.tsx | 328 +++++++++++++++--- .../src/components/projects/project-list.tsx | 94 ++--- frontend/src/components/tasks/TaskCard.tsx | 92 +++++ .../src/components/tasks/TaskCreateDialog.tsx | 71 ++++ .../components/tasks/TaskDetailsDialog.tsx | 235 +++++++++++++ .../src/components/tasks/TaskEditDialog.tsx | 115 ++++++ .../src/components/tasks/TaskKanbanBoard.tsx | 95 +++++ frontend/src/components/tasks/index.ts | 5 + frontend/src/components/ui/folder-picker.tsx | 248 +++++++++++++ .../components/ui/shadcn-io/kanban/index.tsx | 75 ++-- frontend/src/pages/project-tasks.tsx | 312 +++-------------- shared/types.ts | 20 +- 26 files changed, 2041 insertions(+), 409 deletions(-) create mode 100644 backend/migrations/004_add_git_repo_path.sql create mode 100644 backend/migrations/005_unique_git_repo_path.sql create mode 100644 backend/migrations/006_create_task_attempts.sql create mode 100644 backend/src/models/task_attempt.rs create mode 100644 backend/src/models/task_attempt_activity.rs create mode 100644 backend/src/routes/filesystem.rs create mode 100644 frontend/src/components/tasks/TaskCard.tsx create mode 100644 frontend/src/components/tasks/TaskCreateDialog.tsx create mode 100644 frontend/src/components/tasks/TaskDetailsDialog.tsx create mode 100644 frontend/src/components/tasks/TaskEditDialog.tsx create mode 100644 frontend/src/components/tasks/TaskKanbanBoard.tsx create mode 100644 frontend/src/components/tasks/index.ts create mode 100644 frontend/src/components/ui/folder-picker.tsx diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 1dffbf8d..e31b386c 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -25,6 +25,7 @@ dotenvy = "0.15" bcrypt = "0.15" jsonwebtoken = "9.2" ts-rs = { version = "9.0", features = ["uuid-impl", "chrono-impl"] } +dirs = "5.0" [build-dependencies] ts-rs = { version = "9.0", features = ["uuid-impl", "chrono-impl"] } diff --git a/backend/migrations/004_add_git_repo_path.sql b/backend/migrations/004_add_git_repo_path.sql new file mode 100644 index 00000000..4a3853a9 --- /dev/null +++ b/backend/migrations/004_add_git_repo_path.sql @@ -0,0 +1,2 @@ +-- Add git_repo_path field to projects table +ALTER TABLE projects ADD COLUMN git_repo_path VARCHAR(500) NOT NULL DEFAULT ''; diff --git a/backend/migrations/005_unique_git_repo_path.sql b/backend/migrations/005_unique_git_repo_path.sql new file mode 100644 index 00000000..bb0b654c --- /dev/null +++ b/backend/migrations/005_unique_git_repo_path.sql @@ -0,0 +1,2 @@ +-- Add unique constraint to git_repo_path to prevent duplicate repository paths +ALTER TABLE projects ADD CONSTRAINT unique_git_repo_path UNIQUE (git_repo_path); diff --git a/backend/migrations/006_create_task_attempts.sql b/backend/migrations/006_create_task_attempts.sql new file mode 100644 index 00000000..3d55d41f --- /dev/null +++ b/backend/migrations/006_create_task_attempts.sql @@ -0,0 +1,33 @@ +-- Create task_attempt_status enum +CREATE TYPE task_attempt_status AS ENUM ('init', 'inprogress', 'paused'); + +-- Create task_attempts table +CREATE TABLE task_attempts ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + task_id UUID NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, + worktree_path VARCHAR(255) NOT NULL, + base_commit VARCHAR(255), + merge_commit VARCHAR(255), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Create task_attempt_activities table +CREATE TABLE task_attempt_activities ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + task_attempt_id UUID NOT NULL REFERENCES task_attempts(id) ON DELETE CASCADE, + status task_attempt_status NOT NULL DEFAULT 'init', + note TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Create indexes for better performance +CREATE INDEX idx_task_attempts_task_id ON task_attempts(task_id); +CREATE INDEX idx_task_attempt_activities_task_attempt_id ON task_attempt_activities(task_attempt_id); +CREATE INDEX idx_task_attempt_activities_status ON task_attempt_activities(status); +CREATE INDEX idx_task_attempt_activities_created_at ON task_attempt_activities(created_at); + +-- Create triggers to auto-update updated_at +CREATE TRIGGER update_task_attempts_updated_at + BEFORE UPDATE ON task_attempts + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); diff --git a/backend/src/bin/generate_types.rs b/backend/src/bin/generate_types.rs index 8a2b5e4d..5758c2ea 100644 --- a/backend/src/bin/generate_types.rs +++ b/backend/src/bin/generate_types.rs @@ -56,7 +56,20 @@ export {} export {} export {} -"#, + +export {} + +export {} + +export {} + +export {} + +export {} + +export {} + +export {}"#, bloop_backend::models::ApiResponse::<()>::decl(), bloop_backend::models::project::CreateProject::decl(), bloop_backend::models::project::Project::decl(), @@ -65,11 +78,18 @@ export {} bloop_backend::models::task::TaskStatus::decl(), bloop_backend::models::task::Task::decl(), bloop_backend::models::task::UpdateTask::decl(), + bloop_backend::models::task_attempt::TaskAttemptStatus::decl(), + bloop_backend::models::task_attempt::TaskAttempt::decl(), + bloop_backend::models::task_attempt::CreateTaskAttempt::decl(), + bloop_backend::models::task_attempt::UpdateTaskAttempt::decl(), + bloop_backend::models::task_attempt_activity::TaskAttemptActivity::decl(), + bloop_backend::models::task_attempt_activity::CreateTaskAttemptActivity::decl(), bloop_backend::models::user::CreateUser::decl(), bloop_backend::models::user::LoginRequest::decl(), bloop_backend::models::user::LoginResponse::decl(), bloop_backend::models::user::UpdateUser::decl(), bloop_backend::models::user::UserResponse::decl(), + bloop_backend::routes::filesystem::DirectoryEntry::decl(), ); std::fs::write(shared_path.join("types.ts"), consolidated_content).unwrap(); diff --git a/backend/src/main.rs b/backend/src/main.rs index 272f1cff..133b528e 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -15,7 +15,7 @@ mod routes; use auth::{auth_middleware, hash_password}; use models::ApiResponse; -use routes::{health, projects, tasks, users}; +use routes::{health, projects, tasks, users, filesystem}; async fn echo_handler( Json(payload): Json, @@ -67,6 +67,7 @@ async fn main() -> anyhow::Result<()> { .merge(projects::projects_router()) .merge(tasks::tasks_router()) .merge(users::protected_users_router()) + .merge(filesystem::filesystem_router()) .layer(Extension(pool.clone())) .layer(middleware::from_fn(auth_middleware)); diff --git a/backend/src/models/mod.rs b/backend/src/models/mod.rs index d6f6240f..1eff7217 100644 --- a/backend/src/models/mod.rs +++ b/backend/src/models/mod.rs @@ -1,6 +1,8 @@ pub mod api_response; pub mod project; pub mod task; +pub mod task_attempt; +pub mod task_attempt_activity; pub mod user; pub use api_response::ApiResponse; diff --git a/backend/src/models/project.rs b/backend/src/models/project.rs index a6f2e66a..84ff7c14 100644 --- a/backend/src/models/project.rs +++ b/backend/src/models/project.rs @@ -9,6 +9,7 @@ use uuid::Uuid; pub struct Project { pub id: Uuid, pub name: String, + pub git_repo_path: String, pub owner_id: Uuid, // Foreign key to User #[ts(type = "Date")] pub created_at: DateTime, @@ -20,10 +21,13 @@ pub struct Project { #[ts(export)] pub struct CreateProject { pub name: String, + pub git_repo_path: String, + pub use_existing_repo: bool, } #[derive(Debug, Deserialize, TS)] #[ts(export)] pub struct UpdateProject { pub name: Option, + pub git_repo_path: Option, } diff --git a/backend/src/models/task_attempt.rs b/backend/src/models/task_attempt.rs new file mode 100644 index 00000000..9a670171 --- /dev/null +++ b/backend/src/models/task_attempt.rs @@ -0,0 +1,44 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use sqlx::{FromRow, Type}; +use ts_rs::TS; +use uuid::Uuid; + +#[derive(Debug, Clone, Type, Serialize, Deserialize, PartialEq, TS)] +#[sqlx(type_name = "task_attempt_status", rename_all = "lowercase")] +#[serde(rename_all = "lowercase")] +#[ts(export)] +pub enum TaskAttemptStatus { + Init, + InProgress, + Paused, +} + +#[derive(Debug, Clone, FromRow, Serialize, Deserialize, TS)] +#[ts(export)] +pub struct TaskAttempt { + pub id: Uuid, + pub task_id: Uuid, // Foreign key to Task + pub worktree_path: String, + pub base_commit: Option, + pub merge_commit: Option, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Debug, Deserialize, TS)] +#[ts(export)] +pub struct CreateTaskAttempt { + pub task_id: Uuid, + pub worktree_path: String, + pub base_commit: Option, + pub merge_commit: Option, +} + +#[derive(Debug, Deserialize, TS)] +#[ts(export)] +pub struct UpdateTaskAttempt { + pub worktree_path: Option, + pub base_commit: Option, + pub merge_commit: Option, +} diff --git a/backend/src/models/task_attempt_activity.rs b/backend/src/models/task_attempt_activity.rs new file mode 100644 index 00000000..91d466cc --- /dev/null +++ b/backend/src/models/task_attempt_activity.rs @@ -0,0 +1,25 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use sqlx::FromRow; +use ts_rs::TS; +use uuid::Uuid; + +use super::task_attempt::TaskAttemptStatus; + +#[derive(Debug, Clone, FromRow, Serialize, Deserialize, TS)] +#[ts(export)] +pub struct TaskAttemptActivity { + pub id: Uuid, + pub task_attempt_id: Uuid, // Foreign key to TaskAttempt + pub status: TaskAttemptStatus, + pub note: Option, + pub created_at: DateTime, +} + +#[derive(Debug, Deserialize, TS)] +#[ts(export)] +pub struct CreateTaskAttemptActivity { + pub task_attempt_id: Uuid, + pub status: Option, // Default to Init if not provided + pub note: Option, +} diff --git a/backend/src/routes/filesystem.rs b/backend/src/routes/filesystem.rs new file mode 100644 index 00000000..cdab2b05 --- /dev/null +++ b/backend/src/routes/filesystem.rs @@ -0,0 +1,207 @@ +use axum::{ + routing::get, + Router, + Json, + response::Json as ResponseJson, + extract::{Query, Extension}, + http::StatusCode, +}; +use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; +use std::fs; +use ts_rs::TS; + +use crate::models::ApiResponse; +use crate::auth::AuthUser; + +#[derive(Debug, Serialize, TS)] +#[ts(export)] +pub struct DirectoryEntry { + pub name: String, + pub path: String, + pub is_directory: bool, + pub is_git_repo: bool, +} + +#[derive(Debug, Deserialize)] +pub struct ListDirectoryQuery { + path: Option, +} + +pub async fn list_directory( + _auth: AuthUser, + Query(query): Query, +) -> Result>>, StatusCode> { + let path_str = query.path.unwrap_or_else(|| { + // Default to user's home directory + dirs::home_dir() + .unwrap_or_else(|| PathBuf::from("/")) + .to_string_lossy() + .to_string() + }); + + let path = Path::new(&path_str); + + if !path.exists() { + return Ok(ResponseJson(ApiResponse { + success: false, + data: None, + message: Some("Directory does not exist".to_string()), + })); + } + + if !path.is_dir() { + return Ok(ResponseJson(ApiResponse { + success: false, + data: None, + message: Some("Path is not a directory".to_string()), + })); + } + + match fs::read_dir(path) { + Ok(entries) => { + let mut directory_entries = Vec::new(); + + for entry in entries { + if let Ok(entry) = entry { + let path = entry.path(); + let metadata = entry.metadata().ok(); + + if let Some(name) = path.file_name().and_then(|n| n.to_str()) { + // Skip hidden files/directories + if name.starts_with('.') && name != ".." { + continue; + } + + let is_directory = metadata.map_or(false, |m| m.is_dir()); + let is_git_repo = if is_directory { + path.join(".git").exists() + } else { + false + }; + + directory_entries.push(DirectoryEntry { + name: name.to_string(), + path: path.to_string_lossy().to_string(), + is_directory, + is_git_repo, + }); + } + } + } + + // Sort: directories first, then files, both alphabetically + directory_entries.sort_by(|a, b| { + match (a.is_directory, b.is_directory) { + (true, false) => std::cmp::Ordering::Less, + (false, true) => std::cmp::Ordering::Greater, + _ => a.name.to_lowercase().cmp(&b.name.to_lowercase()), + } + }); + + Ok(ResponseJson(ApiResponse { + success: true, + data: Some(directory_entries), + message: None, + })) + } + Err(e) => { + tracing::error!("Failed to read directory: {}", e); + Ok(ResponseJson(ApiResponse { + success: false, + data: None, + message: Some(format!("Failed to read directory: {}", e)), + })) + } + } +} + +pub async fn validate_git_path( + _auth: AuthUser, + Query(query): Query, +) -> Result>, StatusCode> { + let path_str = query.path.ok_or(StatusCode::BAD_REQUEST)?; + let path = Path::new(&path_str); + + // Check if path exists and is a git repo + let is_valid_git_repo = path.exists() && path.is_dir() && path.join(".git").exists(); + + Ok(ResponseJson(ApiResponse { + success: true, + data: Some(is_valid_git_repo), + message: if is_valid_git_repo { + Some("Valid git repository".to_string()) + } else { + Some("Not a valid git repository".to_string()) + }, + })) +} + +pub async fn create_git_repo( + _auth: AuthUser, + Query(query): Query, +) -> Result>, StatusCode> { + let path_str = query.path.ok_or(StatusCode::BAD_REQUEST)?; + let path = Path::new(&path_str); + + // Create directory if it doesn't exist + if !path.exists() { + if let Err(e) = fs::create_dir_all(path) { + tracing::error!("Failed to create directory: {}", e); + return Ok(ResponseJson(ApiResponse { + success: false, + data: None, + message: Some(format!("Failed to create directory: {}", e)), + })); + } + } + + // Check if it's already a git repo + if path.join(".git").exists() { + return Ok(ResponseJson(ApiResponse { + success: true, + data: Some(()), + message: Some("Directory is already a git repository".to_string()), + })); + } + + // Initialize git repository + match std::process::Command::new("git") + .arg("init") + .current_dir(path) + .output() + { + Ok(output) => { + if output.status.success() { + Ok(ResponseJson(ApiResponse { + success: true, + data: Some(()), + message: Some("Git repository initialized successfully".to_string()), + })) + } else { + let error_msg = String::from_utf8_lossy(&output.stderr); + tracing::error!("Git init failed: {}", error_msg); + Ok(ResponseJson(ApiResponse { + success: false, + data: None, + message: Some(format!("Git init failed: {}", error_msg)), + })) + } + } + Err(e) => { + tracing::error!("Failed to run git init: {}", e); + Ok(ResponseJson(ApiResponse { + success: false, + data: None, + message: Some(format!("Failed to run git init: {}", e)), + })) + } + } +} + +pub fn filesystem_router() -> Router { + Router::new() + .route("/filesystem/list", get(list_directory)) + .route("/filesystem/validate-git", get(validate_git_path)) + .route("/filesystem/create-git", get(create_git_repo)) +} diff --git a/backend/src/routes/mod.rs b/backend/src/routes/mod.rs index 054729f9..839971c2 100644 --- a/backend/src/routes/mod.rs +++ b/backend/src/routes/mod.rs @@ -2,3 +2,4 @@ pub mod health; pub mod projects; pub mod tasks; pub mod users; +pub mod filesystem; diff --git a/backend/src/routes/projects.rs b/backend/src/routes/projects.rs index 597f98aa..e22e8243 100644 --- a/backend/src/routes/projects.rs +++ b/backend/src/routes/projects.rs @@ -19,7 +19,7 @@ pub async fn get_projects( ) -> Result>>, StatusCode> { match sqlx::query_as!( Project, - "SELECT id, name, owner_id, created_at, updated_at FROM projects ORDER BY created_at DESC" + "SELECT id, name, git_repo_path, owner_id, created_at, updated_at FROM projects ORDER BY created_at DESC" ) .fetch_all(&pool) .await @@ -43,7 +43,7 @@ pub async fn get_project( ) -> Result>, StatusCode> { match sqlx::query_as!( Project, - "SELECT id, name, owner_id, created_at, updated_at FROM projects WHERE id = $1", + "SELECT id, name, git_repo_path, owner_id, created_at, updated_at FROM projects WHERE id = $1", id ) .fetch_optional(&pool) @@ -72,11 +72,110 @@ pub async fn create_project( tracing::debug!("Creating project '{}' for user {}", payload.name, auth.user_id); + // Check if git repo path is already used by another project + let existing_project = sqlx::query!( + "SELECT id FROM projects WHERE git_repo_path = $1", + payload.git_repo_path + ) + .fetch_optional(&pool) + .await; + + match existing_project { + Ok(Some(_)) => { + return Ok(ResponseJson(ApiResponse { + success: false, + data: None, + message: Some("A project with this git repository path already exists".to_string()), + })); + } + Ok(None) => { + // Path is available, continue + } + Err(e) => { + tracing::error!("Failed to check for existing git repo path: {}", e); + return Err(StatusCode::INTERNAL_SERVER_ERROR); + } + } + + // Validate and setup git repository + let path = std::path::Path::new(&payload.git_repo_path); + + if payload.use_existing_repo { + // For existing repos, validate that the path exists and is a git repository + if !path.exists() { + return Ok(ResponseJson(ApiResponse { + success: false, + data: None, + message: Some("The specified path does not exist".to_string()), + })); + } + + if !path.is_dir() { + return Ok(ResponseJson(ApiResponse { + success: false, + data: None, + message: Some("The specified path is not a directory".to_string()), + })); + } + + if !path.join(".git").exists() { + return Ok(ResponseJson(ApiResponse { + success: false, + data: None, + message: Some("The specified directory is not a git repository".to_string()), + })); + } + } else { + // For new repos, create directory and initialize git + + // Create directory if it doesn't exist + if !path.exists() { + if let Err(e) = std::fs::create_dir_all(path) { + tracing::error!("Failed to create directory: {}", e); + return Ok(ResponseJson(ApiResponse { + success: false, + data: None, + message: Some(format!("Failed to create directory: {}", e)), + })); + } + } + + // Check if it's already a git repo, if not initialize it + if !path.join(".git").exists() { + match std::process::Command::new("git") + .arg("init") + .current_dir(path) + .output() + { + Ok(output) => { + if !output.status.success() { + let error_msg = String::from_utf8_lossy(&output.stderr); + tracing::error!("Git init failed: {}", error_msg); + return Ok(ResponseJson(ApiResponse { + success: false, + data: None, + message: Some(format!("Git init failed: {}", error_msg)), + })); + } + } + Err(e) => { + tracing::error!("Failed to run git init: {}", e); + return Ok(ResponseJson(ApiResponse { + success: false, + data: None, + message: Some(format!("Failed to run git init: {}", e)), + })); + } + } + } + } + match sqlx::query_as!( Project, - "INSERT INTO projects (id, name, owner_id, created_at, updated_at) VALUES ($1, $2, $3, $4, $5) RETURNING id, name, owner_id, created_at, updated_at", + "INSERT INTO projects (id, name, git_repo_path, owner_id, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id, name, git_repo_path, owner_id, created_at, updated_at", id, payload.name, + payload.git_repo_path, auth.user_id, now, now @@ -106,7 +205,7 @@ pub async fn update_project( // Check if project exists first let existing_project = sqlx::query_as!( Project, - "SELECT id, name, owner_id, created_at, updated_at FROM projects WHERE id = $1", + "SELECT id, name, git_repo_path, owner_id, created_at, updated_at FROM projects WHERE id = $1", id ) .fetch_optional(&pool) @@ -121,14 +220,46 @@ pub async fn update_project( } }; - // Use existing name if not provided in update + // If git_repo_path is being changed, check if the new path is already used by another project + if let Some(new_git_repo_path) = &payload.git_repo_path { + if new_git_repo_path != &existing_project.git_repo_path { + let duplicate_project = sqlx::query!( + "SELECT id FROM projects WHERE git_repo_path = $1 AND id != $2", + new_git_repo_path, + id + ) + .fetch_optional(&pool) + .await; + + match duplicate_project { + Ok(Some(_)) => { + return Ok(ResponseJson(ApiResponse { + success: false, + data: None, + message: Some("A project with this git repository path already exists".to_string()), + })); + } + Ok(None) => { + // Path is available, continue + } + Err(e) => { + tracing::error!("Failed to check for existing git repo path: {}", e); + return Err(StatusCode::INTERNAL_SERVER_ERROR); + } + } + } + } + + // Use existing values if not provided in update let name = payload.name.unwrap_or(existing_project.name); + let git_repo_path = payload.git_repo_path.unwrap_or(existing_project.git_repo_path.clone()); match sqlx::query_as!( Project, - "UPDATE projects SET name = $2, updated_at = $3 WHERE id = $1 RETURNING id, name, owner_id, created_at, updated_at", + "UPDATE projects SET name = $2, git_repo_path = $3, updated_at = $4 WHERE id = $1 RETURNING id, name, git_repo_path, owner_id, created_at, updated_at", id, name, + git_repo_path, now ) .fetch_one(&pool) @@ -208,15 +339,16 @@ mod tests { .unwrap() } - async fn create_test_project(pool: &PgPool, name: &str, owner_id: Uuid) -> Project { + async fn create_test_project(pool: &PgPool, name: &str, git_repo_path: &str, owner_id: Uuid) -> Project { let id = Uuid::new_v4(); let now = Utc::now(); sqlx::query_as!( Project, - "INSERT INTO projects (id, name, owner_id, created_at, updated_at) VALUES ($1, $2, $3, $4, $5) RETURNING id, name, owner_id, created_at, updated_at", + "INSERT INTO projects (id, name, git_repo_path, owner_id, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id, name, git_repo_path, owner_id, created_at, updated_at", id, name, + git_repo_path, owner_id, now, now @@ -231,9 +363,9 @@ mod tests { let user = create_test_user(&pool, "test@example.com", "password123", false).await; // Create multiple projects - create_test_project(&pool, "Project 1", user.id).await; - create_test_project(&pool, "Project 2", user.id).await; - create_test_project(&pool, "Project 3", user.id).await; + create_test_project(&pool, "Project 1", "/tmp/test1", user.id).await; + create_test_project(&pool, "Project 2", "/tmp/test2", user.id).await; + create_test_project(&pool, "Project 3", "/tmp/test3", user.id).await; let auth = AuthUser { user_id: user.id, @@ -272,7 +404,7 @@ mod tests { #[sqlx::test] async fn test_get_project_success(pool: PgPool) { let user = create_test_user(&pool, "test@example.com", "password123", false).await; - let project = create_test_project(&pool, "Test Project", user.id).await; + let project = create_test_project(&pool, "Test Project", "/tmp/test", user.id).await; let auth = AuthUser { user_id: user.id, @@ -320,6 +452,7 @@ mod tests { let create_request = CreateProject { name: "New Project".to_string(), + git_repo_path: "/tmp/new-project".to_string(), }; let result = create_project(auth.clone(), Extension(pool), Json(create_request)).await; @@ -346,6 +479,7 @@ mod tests { let create_request = CreateProject { name: "Admin Project".to_string(), + git_repo_path: "/tmp/admin-project".to_string(), }; let result = create_project(auth.clone(), Extension(pool), Json(create_request)).await; @@ -362,10 +496,11 @@ mod tests { #[sqlx::test] async fn test_update_project_success(pool: PgPool) { let user = create_test_user(&pool, "test@example.com", "password123", false).await; - let project = create_test_project(&pool, "Original Name", user.id).await; + let project = create_test_project(&pool, "Original Name", "/tmp/original", user.id).await; let update_request = UpdateProject { name: Some("Updated Name".to_string()), + git_repo_path: None, }; let result = update_project(Path(project.id), Extension(pool), Json(update_request)).await; @@ -383,11 +518,12 @@ mod tests { #[sqlx::test] async fn test_update_project_partial(pool: PgPool) { let user = create_test_user(&pool, "test@example.com", "password123", false).await; - let project = create_test_project(&pool, "Original Name", user.id).await; + let project = create_test_project(&pool, "Original Name", "/tmp/original", user.id).await; // Update with no changes (None for name should keep existing name) let update_request = UpdateProject { name: None, + git_repo_path: None, }; let result = update_project(Path(project.id), Extension(pool), Json(update_request)).await; @@ -407,6 +543,7 @@ mod tests { let update_request = UpdateProject { name: Some("Updated Name".to_string()), + git_repo_path: None, }; let result = update_project(Path(nonexistent_project_id), Extension(pool), Json(update_request)).await; @@ -417,7 +554,7 @@ mod tests { #[sqlx::test] async fn test_delete_project_success(pool: PgPool) { let user = create_test_user(&pool, "test@example.com", "password123", false).await; - let project = create_test_project(&pool, "Project to Delete", user.id).await; + let project = create_test_project(&pool, "Project to Delete", "/tmp/to-delete", user.id).await; let result = delete_project(Path(project.id), Extension(pool)).await; assert!(result.is_ok()); @@ -441,7 +578,7 @@ mod tests { use crate::models::task::{Task, TaskStatus}; let user = create_test_user(&pool, "test@example.com", "password123", false).await; - let project = create_test_project(&pool, "Project with Tasks", user.id).await; + let project = create_test_project(&pool, "Project with Tasks", "/tmp/with-tasks", user.id).await; // Create a task in the project let task_id = Uuid::new_v4(); @@ -490,8 +627,8 @@ mod tests { let user1 = create_test_user(&pool, "user1@example.com", "password123", false).await; let user2 = create_test_user(&pool, "user2@example.com", "password123", false).await; - let project1 = create_test_project(&pool, "User 1 Project", user1.id).await; - let project2 = create_test_project(&pool, "User 2 Project", user2.id).await; + let project1 = create_test_project(&pool, "User 1 Project", "/tmp/user1", user1.id).await; + let project2 = create_test_project(&pool, "User 2 Project", "/tmp/user2", user2.id).await; // Verify project ownership assert_eq!(project1.owner_id, user1.id); diff --git a/backend/src/routes/tasks.rs b/backend/src/routes/tasks.rs index b4db6af1..be71eb3d 100644 --- a/backend/src/routes/tasks.rs +++ b/backend/src/routes/tasks.rs @@ -10,7 +10,12 @@ use sqlx::PgPool; use uuid::Uuid; use chrono::Utc; -use crate::models::{ApiResponse, task::{Task, CreateTask, UpdateTask, TaskStatus}}; +use crate::models::{ + ApiResponse, + task::{Task, CreateTask, UpdateTask, TaskStatus}, + task_attempt::{TaskAttempt, CreateTaskAttempt, UpdateTaskAttempt, TaskAttemptStatus}, + task_attempt_activity::{TaskAttemptActivity, CreateTaskAttemptActivity} +}; use crate::auth::AuthUser; pub async fn get_project_tasks( @@ -217,12 +222,246 @@ pub async fn delete_task( } } +// Task Attempts endpoints +pub async fn get_task_attempts( + _auth: AuthUser, + Path((project_id, task_id)): Path<(Uuid, Uuid)>, + Extension(pool): Extension +) -> Result>>, StatusCode> { + // Verify task exists in project first + let task_exists = sqlx::query!( + "SELECT id FROM tasks WHERE id = $1 AND project_id = $2", + task_id, + project_id + ) + .fetch_optional(&pool) + .await; + + match task_exists { + Ok(None) => return Err(StatusCode::NOT_FOUND), + Err(e) => { + tracing::error!("Failed to check task existence: {}", e); + return Err(StatusCode::INTERNAL_SERVER_ERROR); + } + Ok(Some(_)) => {} + } + + match sqlx::query_as!( + TaskAttempt, + r#"SELECT id, task_id, worktree_path, base_commit, merge_commit, created_at, updated_at + FROM task_attempts + WHERE task_id = $1 + ORDER BY created_at DESC"#, + task_id + ) + .fetch_all(&pool) + .await + { + Ok(attempts) => Ok(ResponseJson(ApiResponse { + success: true, + data: Some(attempts), + message: None, + })), + Err(e) => { + tracing::error!("Failed to fetch task attempts for task {}: {}", task_id, e); + Err(StatusCode::INTERNAL_SERVER_ERROR) + } + } +} + +pub async fn get_task_attempt_activities( + _auth: AuthUser, + Path((project_id, task_id, attempt_id)): Path<(Uuid, Uuid, Uuid)>, + Extension(pool): Extension +) -> Result>>, StatusCode> { + // Verify task attempt exists and belongs to the correct task + let attempt_exists = sqlx::query!( + "SELECT ta.id 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; + + match attempt_exists { + Ok(None) => return Err(StatusCode::NOT_FOUND), + Err(e) => { + tracing::error!("Failed to check task attempt existence: {}", e); + return Err(StatusCode::INTERNAL_SERVER_ERROR); + } + Ok(Some(_)) => {} + } + + match sqlx::query_as!( + TaskAttemptActivity, + r#"SELECT id, task_attempt_id, status as "status!: TaskAttemptStatus", note, created_at + FROM task_attempt_activities + WHERE task_attempt_id = $1 + ORDER BY created_at DESC"#, + attempt_id + ) + .fetch_all(&pool) + .await + { + Ok(activities) => Ok(ResponseJson(ApiResponse { + success: true, + data: Some(activities), + message: None, + })), + Err(e) => { + tracing::error!("Failed to fetch task attempt activities for attempt {}: {}", attempt_id, e); + Err(StatusCode::INTERNAL_SERVER_ERROR) + } + } +} + +pub async fn create_task_attempt( + _auth: AuthUser, + Path((project_id, task_id)): Path<(Uuid, Uuid)>, + Extension(pool): Extension, + Json(mut payload): Json +) -> Result>, StatusCode> { + // Verify task exists in project first + let task_exists = sqlx::query!( + "SELECT id FROM tasks WHERE id = $1 AND project_id = $2", + task_id, + project_id + ) + .fetch_optional(&pool) + .await; + + match task_exists { + Ok(None) => return Err(StatusCode::NOT_FOUND), + Err(e) => { + tracing::error!("Failed to check task existence: {}", e); + return Err(StatusCode::INTERNAL_SERVER_ERROR); + } + Ok(Some(_)) => {} + } + + let id = Uuid::new_v4(); + let now = Utc::now(); + + // Ensure the task_id in the payload matches the path parameter + payload.task_id = task_id; + + match sqlx::query_as!( + TaskAttempt, + r#"INSERT INTO task_attempts (id, task_id, worktree_path, base_commit, merge_commit, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING id, task_id, worktree_path, base_commit, merge_commit, created_at, updated_at"#, + id, + payload.task_id, + payload.worktree_path, + payload.base_commit, + payload.merge_commit, + now, + now + ) + .fetch_one(&pool) + .await + { + Ok(attempt) => { + // Create initial activity record + let activity_id = Uuid::new_v4(); + let _ = sqlx::query!( + r#"INSERT INTO task_attempt_activities (id, task_attempt_id, status, note, created_at) + VALUES ($1, $2, $3, $4, $5)"#, + activity_id, + attempt.id, + TaskAttemptStatus::Init as TaskAttemptStatus, + Option::::None, + now + ) + .execute(&pool) + .await; + + Ok(ResponseJson(ApiResponse { + success: true, + data: Some(attempt), + message: Some("Task attempt created successfully".to_string()), + })) + } + Err(e) => { + tracing::error!("Failed to create task attempt: {}", e); + Err(StatusCode::INTERNAL_SERVER_ERROR) + } + } +} + +pub async fn create_task_attempt_activity( + _auth: AuthUser, + Path((project_id, task_id, attempt_id)): Path<(Uuid, Uuid, Uuid)>, + Extension(pool): Extension, + Json(mut payload): Json +) -> Result>, StatusCode> { + // Verify task attempt exists and belongs to the correct task + let attempt_exists = sqlx::query!( + "SELECT ta.id 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; + + match attempt_exists { + Ok(None) => return Err(StatusCode::NOT_FOUND), + Err(e) => { + tracing::error!("Failed to check task attempt existence: {}", e); + return Err(StatusCode::INTERNAL_SERVER_ERROR); + } + Ok(Some(_)) => {} + } + + let id = Uuid::new_v4(); + let now = Utc::now(); + + // Ensure the task_attempt_id in the payload matches the path parameter + payload.task_attempt_id = attempt_id; + + // Default to Init status if not provided + let status = payload.status.unwrap_or(TaskAttemptStatus::Init); + + match sqlx::query_as!( + TaskAttemptActivity, + r#"INSERT INTO task_attempt_activities (id, task_attempt_id, status, note, created_at) + VALUES ($1, $2, $3, $4, $5) + RETURNING id, task_attempt_id, status as "status!: TaskAttemptStatus", note, created_at"#, + id, + payload.task_attempt_id, + status as TaskAttemptStatus, + payload.note, + now + ) + .fetch_one(&pool) + .await + { + Ok(activity) => Ok(ResponseJson(ApiResponse { + success: true, + data: Some(activity), + message: Some("Task attempt activity created successfully".to_string()), + })), + Err(e) => { + tracing::error!("Failed to create task attempt activity: {}", e); + Err(StatusCode::INTERNAL_SERVER_ERROR) + } + } +} + pub fn tasks_router() -> Router { use axum::routing::{post, put, delete}; Router::new() .route("/projects/:project_id/tasks", get(get_project_tasks).post(create_task)) .route("/projects/:project_id/tasks/:task_id", get(get_task).put(update_task).delete(delete_task)) + .route("/projects/:project_id/tasks/:task_id/attempts", get(get_task_attempts).post(create_task_attempt)) + .route("/projects/:project_id/tasks/:task_id/attempts/:attempt_id/activities", get(get_task_attempt_activities).post(create_task_attempt_activity)) } #[cfg(test)] diff --git a/frontend/src/components/projects/project-form.tsx b/frontend/src/components/projects/project-form.tsx index 091a9870..40bf61af 100644 --- a/frontend/src/components/projects/project-form.tsx +++ b/frontend/src/components/projects/project-form.tsx @@ -1,88 +1,267 @@ -import { useState } from 'react' -import { Button } from '@/components/ui/button' -import { Input } from '@/components/ui/input' -import { Label } from '@/components/ui/label' -import { Alert, AlertDescription } from '@/components/ui/alert' -import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog' -import { Project, CreateProject, UpdateProject } from 'shared/types' -import { AlertCircle } from 'lucide-react' -import { makeAuthenticatedRequest } from '@/lib/auth' +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { FolderPicker } from "@/components/ui/folder-picker"; +import { Project, CreateProject, UpdateProject } from "shared/types"; +import { AlertCircle, Folder } from "lucide-react"; +import { makeAuthenticatedRequest } from "@/lib/auth"; interface ProjectFormProps { - open: boolean - onClose: () => void - onSuccess: () => void - project?: Project | null + open: boolean; + onClose: () => void; + onSuccess: () => void; + project?: Project | null; } -export function ProjectForm({ open, onClose, onSuccess, project }: ProjectFormProps) { - const [name, setName] = useState(project?.name || '') - const [loading, setLoading] = useState(false) - const [error, setError] = useState('') +export function ProjectForm({ + open, + onClose, + onSuccess, + project, +}: ProjectFormProps) { + const [name, setName] = useState(project?.name || ""); + const [gitRepoPath, setGitRepoPath] = useState(project?.git_repo_path || ""); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + const [showFolderPicker, setShowFolderPicker] = useState(false); + const [repoMode, setRepoMode] = useState<"existing" | "new">("existing"); + const [parentPath, setParentPath] = useState(""); + const [folderName, setFolderName] = useState(""); - const isEditing = !!project + const isEditing = !!project; + + // Auto-populate project name from directory name + const handleGitRepoPathChange = (path: string) => { + setGitRepoPath(path); + + // Only auto-populate name for new projects + if (!isEditing && path) { + // Extract the last part of the path (directory name) + const dirName = path.split("/").filter(Boolean).pop() || ""; + if (dirName) { + // Clean up the directory name for a better project name + const cleanName = dirName + .replace(/[-_]/g, " ") // Replace hyphens and underscores with spaces + .replace(/\b\w/g, (l) => l.toUpperCase()); // Capitalize first letter of each word + setName(cleanName); + } + } + }; const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault() - setError('') - setLoading(true) + e.preventDefault(); + setError(""); + setLoading(true); try { + let finalGitRepoPath = gitRepoPath; + + // For new repo mode, construct the full path + if (!isEditing && repoMode === "new") { + finalGitRepoPath = `${parentPath}/${folderName}`.replace(/\/+/g, "/"); + } + if (isEditing) { - const updateData: UpdateProject = { name } - const response = await makeAuthenticatedRequest(`/api/projects/${project.id}`, { - method: 'PUT', - body: JSON.stringify(updateData), - }) - + const updateData: UpdateProject = { + name, + git_repo_path: finalGitRepoPath, + }; + const response = await makeAuthenticatedRequest( + `/api/projects/${project.id}`, + { + method: "PUT", + body: JSON.stringify(updateData), + } + ); + if (!response.ok) { - throw new Error('Failed to update project') + throw new Error("Failed to update project"); + } + + const data = await response.json(); + if (!data.success) { + throw new Error(data.message || "Failed to update project"); } } else { - const createData: CreateProject = { - name - } - const response = await makeAuthenticatedRequest('/api/projects', { - method: 'POST', + const createData: CreateProject = { + name, + git_repo_path: finalGitRepoPath, + use_existing_repo: repoMode === "existing", + }; + const response = await makeAuthenticatedRequest("/api/projects", { + method: "POST", body: JSON.stringify(createData), - }) - + }); + if (!response.ok) { - throw new Error('Failed to create project') + throw new Error("Failed to create project"); + } + + const data = await response.json(); + if (!data.success) { + throw new Error(data.message || "Failed to create project"); } } - onSuccess() - setName('') + onSuccess(); + setName(""); + setGitRepoPath(""); + setParentPath(""); + setFolderName(""); } catch (error) { - setError(error instanceof Error ? error.message : 'An error occurred') + setError(error instanceof Error ? error.message : "An error occurred"); } finally { - setLoading(false) + setLoading(false); } - } + }; const handleClose = () => { - setName(project?.name || '') - setError('') - onClose() - } + setName(project?.name || ""); + setGitRepoPath(project?.git_repo_path || ""); + setError(""); + onClose(); + }; return ( - {isEditing ? 'Edit Project' : 'Create New Project'} + {isEditing ? "Edit Project" : "Create New Project"} - {isEditing - ? 'Make changes to your project here. Click save when you\'re done.' - : 'Add a new project to your workspace. You can always edit it later.' - } + {isEditing + ? "Make changes to your project here. Click save when you're done." + : "Choose whether to use an existing git repository or create a new one."} - +
+ {!isEditing && ( +
+ +
+ + +
+
+ )} + + {repoMode === "existing" || isEditing ? ( +
+ +
+ handleGitRepoPathChange(e.target.value)} + placeholder="/path/to/your/existing/repo" + required + className="flex-1" + /> + +
+ {!isEditing && ( +

+ Select a folder that already contains a git repository +

+ )} +
+ ) : ( +
+
+ +
+ setParentPath(e.target.value)} + placeholder="/path/to/parent/directory" + required + className="flex-1" + /> + +
+

+ Choose where to create the new repository +

+
+ +
+ + { + setFolderName(e.target.value); + if (e.target.value) { + setName( + e.target.value + .replace(/[-_]/g, " ") + .replace(/\b\w/g, (l) => l.toUpperCase()) + ); + } + }} + placeholder="my-awesome-project" + required + className="flex-1" + /> +

+ The project name will be auto-populated from this folder name +

+
+
+ )} +
- - {error} - + {error} )} @@ -113,12 +290,49 @@ export function ProjectForm({ open, onClose, onSuccess, project }: ProjectFormPr > Cancel - + + setShowFolderPicker(false)} + onSelect={(path) => { + if (repoMode === "existing" || isEditing) { + handleGitRepoPathChange(path); + } else { + setParentPath(path); + } + setShowFolderPicker(false); + }} + value={repoMode === "existing" || isEditing ? gitRepoPath : parentPath} + title={ + repoMode === "existing" || isEditing + ? "Select Git Repository" + : "Select Parent Directory" + } + description={ + repoMode === "existing" || isEditing + ? "Choose an existing git repository" + : "Choose where to create the new repository" + } + />
- ) + ); } diff --git a/frontend/src/components/projects/project-list.tsx b/frontend/src/components/projects/project-list.tsx index 032d2406..e4634af7 100644 --- a/frontend/src/components/projects/project-list.tsx +++ b/frontend/src/components/projects/project-list.tsx @@ -7,7 +7,13 @@ import { Alert, AlertDescription } from '@/components/ui/alert' import { Project, ApiResponse } from 'shared/types' import { ProjectForm } from './project-form' import { makeAuthenticatedRequest } from '@/lib/auth' -import { Plus, Edit, Trash2, Calendar, AlertCircle, Loader2, CheckSquare } from 'lucide-react' +import { Plus, Edit, Trash2, Calendar, AlertCircle, Loader2, MoreHorizontal, ExternalLink } from 'lucide-react' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu' export function ProjectList() { const navigate = useNavigate() @@ -118,54 +124,64 @@ export function ProjectList() { ) : (
{projects.map((project) => ( - + navigate(`/projects/${project.id}/tasks`)} + >
- navigate(`/projects/${project.id}`)} - > + {project.name} - - Active - +
+ + Active + + + e.stopPropagation()}> + + + + { + e.stopPropagation() + navigate(`/projects/${project.id}`) + }}> + + View Project + + { + e.stopPropagation() + handleEdit(project) + }}> + + Edit + + { + e.stopPropagation() + handleDelete(project.id, project.name) + }} + className="text-red-600" + > + + Delete + + + +
Created {new Date(project.created_at).toLocaleDateString()}
- -
- - - -
-
))}
diff --git a/frontend/src/components/tasks/TaskCard.tsx b/frontend/src/components/tasks/TaskCard.tsx new file mode 100644 index 00000000..655dd86c --- /dev/null +++ b/frontend/src/components/tasks/TaskCard.tsx @@ -0,0 +1,92 @@ +import { Button } from '@/components/ui/button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger +} from '@/components/ui/dropdown-menu' +import { KanbanCard } from '@/components/ui/shadcn-io/kanban' +import { MoreHorizontal, Trash2, Edit } from 'lucide-react' +import type { TaskStatus } from 'shared/types' + +interface Task { + id: string + project_id: string + title: string + description: string | null + status: TaskStatus + created_at: string + updated_at: string +} + +interface TaskCardProps { + task: Task + index: number + status: string + onEdit: (task: Task) => void + onDelete: (taskId: string) => void + onViewDetails: (task: Task) => void +} + +export function TaskCard({ task, index, status, onEdit, onDelete, onViewDetails }: TaskCardProps) { + return ( + onViewDetails(task)} + > +
+
+
+

+ {task.title} +

+
+
+ {/* Actions Menu */} +
e.stopPropagation()} + onMouseDown={(e) => e.stopPropagation()} + onClick={(e) => e.stopPropagation()} + > + + + + + + onEdit(task)}> + + Edit + + onDelete(task.id)} + className="text-red-600" + > + + Delete + + + +
+
+
+ {task.description && ( +
+

+ {task.description} +

+
+ )} +
+
+ ) +} diff --git a/frontend/src/components/tasks/TaskCreateDialog.tsx b/frontend/src/components/tasks/TaskCreateDialog.tsx new file mode 100644 index 00000000..a2f75b4f --- /dev/null +++ b/frontend/src/components/tasks/TaskCreateDialog.tsx @@ -0,0 +1,71 @@ +import { useState } from 'react' +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle +} from '@/components/ui/dialog' +import { Input } from '@/components/ui/input' +import { Textarea } from '@/components/ui/textarea' +import { Label } from '@/components/ui/label' + +interface TaskCreateDialogProps { + isOpen: boolean + onOpenChange: (open: boolean) => void + onCreateTask: (title: string, description: string) => Promise +} + +export function TaskCreateDialog({ isOpen, onOpenChange, onCreateTask }: TaskCreateDialogProps) { + const [title, setTitle] = useState('') + const [description, setDescription] = useState('') + + const handleCreate = async () => { + if (!title.trim()) return + + await onCreateTask(title, description) + setTitle('') + setDescription('') + onOpenChange(false) + } + + return ( + + + + Create New Task + +
+
+ + setTitle(e.target.value)} + placeholder="Enter task title" + /> +
+
+ +