Squashed commit of the following:
commit 38f68d5ed489f416ea91630aea3496ab15365e66 Author: Louis Knight-Webb <louis@bloop.ai> Date: Mon Jun 16 16:16:28 2025 -0400 Fix click and drag commit eb5c41cf31fd8032fe88fd47fe5f3e7f517f6d30 Author: Louis Knight-Webb <louis@bloop.ai> Date: Mon Jun 16 15:57:13 2025 -0400 Update tasks commit 979d4b15373df3193eb1bd41c18ece1dbe044eba Author: Louis Knight-Webb <louis@bloop.ai> Date: Mon Jun 16 15:19:20 2025 -0400 Status commit fa26f1fa8fefe1d84b5b2153327c7e8c0132952a Author: Louis Knight-Webb <louis@bloop.ai> Date: Mon Jun 16 14:54:48 2025 -0400 Cleanup project card commit 14d7a1d7d7574dd8745167b280c04603ba22b189 Author: Louis Knight-Webb <louis@bloop.ai> Date: Mon Jun 16 14:49:19 2025 -0400 Improve existing vs new repo commit 277e1f05ef68e5c67d73b246557a6df2ab23d32c Author: Louis Knight-Webb <louis@bloop.ai> Date: Mon Jun 16 13:01:21 2025 -0400 Make repo path unique commit f80ef55f2ba16836276a81844fc33639872bcc53 Author: Louis Knight-Webb <louis@bloop.ai> Date: Mon Jun 16 12:52:20 2025 -0400 Fix styles commit 077869458fcab199a10ef0fe2fe39f9f4216ce5b Author: Louis Knight-Webb <louis@bloop.ai> Date: Mon Jun 16 12:41:48 2025 -0400 First select repo commit 1b0d9c0280e4cb75294348bb53b2a534458a2e37 Author: Louis Knight-Webb <louis@bloop.ai> Date: Mon Jun 16 11:45:19 2025 -0400 Init
This commit is contained in:
@@ -25,6 +25,7 @@ dotenvy = "0.15"
|
|||||||
bcrypt = "0.15"
|
bcrypt = "0.15"
|
||||||
jsonwebtoken = "9.2"
|
jsonwebtoken = "9.2"
|
||||||
ts-rs = { version = "9.0", features = ["uuid-impl", "chrono-impl"] }
|
ts-rs = { version = "9.0", features = ["uuid-impl", "chrono-impl"] }
|
||||||
|
dirs = "5.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"] }
|
||||||
|
|||||||
2
backend/migrations/004_add_git_repo_path.sql
Normal file
2
backend/migrations/004_add_git_repo_path.sql
Normal file
@@ -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 '';
|
||||||
2
backend/migrations/005_unique_git_repo_path.sql
Normal file
2
backend/migrations/005_unique_git_repo_path.sql
Normal file
@@ -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);
|
||||||
33
backend/migrations/006_create_task_attempts.sql
Normal file
33
backend/migrations/006_create_task_attempts.sql
Normal file
@@ -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();
|
||||||
@@ -56,7 +56,20 @@ export {}
|
|||||||
export {}
|
export {}
|
||||||
|
|
||||||
export {}
|
export {}
|
||||||
"#,
|
|
||||||
|
export {}
|
||||||
|
|
||||||
|
export {}
|
||||||
|
|
||||||
|
export {}
|
||||||
|
|
||||||
|
export {}
|
||||||
|
|
||||||
|
export {}
|
||||||
|
|
||||||
|
export {}
|
||||||
|
|
||||||
|
export {}"#,
|
||||||
bloop_backend::models::ApiResponse::<()>::decl(),
|
bloop_backend::models::ApiResponse::<()>::decl(),
|
||||||
bloop_backend::models::project::CreateProject::decl(),
|
bloop_backend::models::project::CreateProject::decl(),
|
||||||
bloop_backend::models::project::Project::decl(),
|
bloop_backend::models::project::Project::decl(),
|
||||||
@@ -65,11 +78,18 @@ export {}
|
|||||||
bloop_backend::models::task::TaskStatus::decl(),
|
bloop_backend::models::task::TaskStatus::decl(),
|
||||||
bloop_backend::models::task::Task::decl(),
|
bloop_backend::models::task::Task::decl(),
|
||||||
bloop_backend::models::task::UpdateTask::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::CreateUser::decl(),
|
||||||
bloop_backend::models::user::LoginRequest::decl(),
|
bloop_backend::models::user::LoginRequest::decl(),
|
||||||
bloop_backend::models::user::LoginResponse::decl(),
|
bloop_backend::models::user::LoginResponse::decl(),
|
||||||
bloop_backend::models::user::UpdateUser::decl(),
|
bloop_backend::models::user::UpdateUser::decl(),
|
||||||
bloop_backend::models::user::UserResponse::decl(),
|
bloop_backend::models::user::UserResponse::decl(),
|
||||||
|
bloop_backend::routes::filesystem::DirectoryEntry::decl(),
|
||||||
);
|
);
|
||||||
|
|
||||||
std::fs::write(shared_path.join("types.ts"), consolidated_content).unwrap();
|
std::fs::write(shared_path.join("types.ts"), consolidated_content).unwrap();
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ mod routes;
|
|||||||
|
|
||||||
use auth::{auth_middleware, hash_password};
|
use auth::{auth_middleware, hash_password};
|
||||||
use models::ApiResponse;
|
use models::ApiResponse;
|
||||||
use routes::{health, projects, tasks, users};
|
use routes::{health, projects, tasks, users, filesystem};
|
||||||
|
|
||||||
async fn echo_handler(
|
async fn echo_handler(
|
||||||
Json(payload): Json<serde_json::Value>,
|
Json(payload): Json<serde_json::Value>,
|
||||||
@@ -67,6 +67,7 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
.merge(projects::projects_router())
|
.merge(projects::projects_router())
|
||||||
.merge(tasks::tasks_router())
|
.merge(tasks::tasks_router())
|
||||||
.merge(users::protected_users_router())
|
.merge(users::protected_users_router())
|
||||||
|
.merge(filesystem::filesystem_router())
|
||||||
.layer(Extension(pool.clone()))
|
.layer(Extension(pool.clone()))
|
||||||
.layer(middleware::from_fn(auth_middleware));
|
.layer(middleware::from_fn(auth_middleware));
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
pub mod api_response;
|
pub mod api_response;
|
||||||
pub mod project;
|
pub mod project;
|
||||||
pub mod task;
|
pub mod task;
|
||||||
|
pub mod task_attempt;
|
||||||
|
pub mod task_attempt_activity;
|
||||||
pub mod user;
|
pub mod user;
|
||||||
|
|
||||||
pub use api_response::ApiResponse;
|
pub use api_response::ApiResponse;
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ use uuid::Uuid;
|
|||||||
pub struct Project {
|
pub struct Project {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
|
pub git_repo_path: String,
|
||||||
pub owner_id: Uuid, // Foreign key to User
|
pub owner_id: Uuid, // Foreign key to User
|
||||||
#[ts(type = "Date")]
|
#[ts(type = "Date")]
|
||||||
pub created_at: DateTime<Utc>,
|
pub created_at: DateTime<Utc>,
|
||||||
@@ -20,10 +21,13 @@ pub struct Project {
|
|||||||
#[ts(export)]
|
#[ts(export)]
|
||||||
pub struct CreateProject {
|
pub struct CreateProject {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
|
pub git_repo_path: String,
|
||||||
|
pub use_existing_repo: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, TS)]
|
#[derive(Debug, Deserialize, TS)]
|
||||||
#[ts(export)]
|
#[ts(export)]
|
||||||
pub struct UpdateProject {
|
pub struct UpdateProject {
|
||||||
pub name: Option<String>,
|
pub name: Option<String>,
|
||||||
|
pub git_repo_path: Option<String>,
|
||||||
}
|
}
|
||||||
|
|||||||
44
backend/src/models/task_attempt.rs
Normal file
44
backend/src/models/task_attempt.rs
Normal file
@@ -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<String>,
|
||||||
|
pub merge_commit: Option<String>,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
pub updated_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, TS)]
|
||||||
|
#[ts(export)]
|
||||||
|
pub struct CreateTaskAttempt {
|
||||||
|
pub task_id: Uuid,
|
||||||
|
pub worktree_path: String,
|
||||||
|
pub base_commit: Option<String>,
|
||||||
|
pub merge_commit: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, TS)]
|
||||||
|
#[ts(export)]
|
||||||
|
pub struct UpdateTaskAttempt {
|
||||||
|
pub worktree_path: Option<String>,
|
||||||
|
pub base_commit: Option<String>,
|
||||||
|
pub merge_commit: Option<String>,
|
||||||
|
}
|
||||||
25
backend/src/models/task_attempt_activity.rs
Normal file
25
backend/src/models/task_attempt_activity.rs
Normal file
@@ -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<String>,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, TS)]
|
||||||
|
#[ts(export)]
|
||||||
|
pub struct CreateTaskAttemptActivity {
|
||||||
|
pub task_attempt_id: Uuid,
|
||||||
|
pub status: Option<TaskAttemptStatus>, // Default to Init if not provided
|
||||||
|
pub note: Option<String>,
|
||||||
|
}
|
||||||
207
backend/src/routes/filesystem.rs
Normal file
207
backend/src/routes/filesystem.rs
Normal file
@@ -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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_directory(
|
||||||
|
_auth: AuthUser,
|
||||||
|
Query(query): Query<ListDirectoryQuery>,
|
||||||
|
) -> Result<ResponseJson<ApiResponse<Vec<DirectoryEntry>>>, 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<ListDirectoryQuery>,
|
||||||
|
) -> Result<ResponseJson<ApiResponse<bool>>, 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<ListDirectoryQuery>,
|
||||||
|
) -> Result<ResponseJson<ApiResponse<()>>, 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))
|
||||||
|
}
|
||||||
@@ -2,3 +2,4 @@ pub mod health;
|
|||||||
pub mod projects;
|
pub mod projects;
|
||||||
pub mod tasks;
|
pub mod tasks;
|
||||||
pub mod users;
|
pub mod users;
|
||||||
|
pub mod filesystem;
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ pub async fn get_projects(
|
|||||||
) -> Result<ResponseJson<ApiResponse<Vec<Project>>>, StatusCode> {
|
) -> Result<ResponseJson<ApiResponse<Vec<Project>>>, StatusCode> {
|
||||||
match sqlx::query_as!(
|
match sqlx::query_as!(
|
||||||
Project,
|
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)
|
.fetch_all(&pool)
|
||||||
.await
|
.await
|
||||||
@@ -43,7 +43,7 @@ pub async fn get_project(
|
|||||||
) -> Result<ResponseJson<ApiResponse<Project>>, StatusCode> {
|
) -> Result<ResponseJson<ApiResponse<Project>>, StatusCode> {
|
||||||
match sqlx::query_as!(
|
match sqlx::query_as!(
|
||||||
Project,
|
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
|
id
|
||||||
)
|
)
|
||||||
.fetch_optional(&pool)
|
.fetch_optional(&pool)
|
||||||
@@ -72,11 +72,110 @@ pub async fn create_project(
|
|||||||
|
|
||||||
tracing::debug!("Creating project '{}' for user {}", payload.name, auth.user_id);
|
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!(
|
match sqlx::query_as!(
|
||||||
Project,
|
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,
|
id,
|
||||||
payload.name,
|
payload.name,
|
||||||
|
payload.git_repo_path,
|
||||||
auth.user_id,
|
auth.user_id,
|
||||||
now,
|
now,
|
||||||
now
|
now
|
||||||
@@ -106,7 +205,7 @@ pub async fn update_project(
|
|||||||
// Check if project exists first
|
// Check if project exists first
|
||||||
let existing_project = sqlx::query_as!(
|
let existing_project = sqlx::query_as!(
|
||||||
Project,
|
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
|
id
|
||||||
)
|
)
|
||||||
.fetch_optional(&pool)
|
.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 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!(
|
match sqlx::query_as!(
|
||||||
Project,
|
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,
|
id,
|
||||||
name,
|
name,
|
||||||
|
git_repo_path,
|
||||||
now
|
now
|
||||||
)
|
)
|
||||||
.fetch_one(&pool)
|
.fetch_one(&pool)
|
||||||
@@ -208,15 +339,16 @@ mod tests {
|
|||||||
.unwrap()
|
.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 id = Uuid::new_v4();
|
||||||
let now = Utc::now();
|
let now = Utc::now();
|
||||||
|
|
||||||
sqlx::query_as!(
|
sqlx::query_as!(
|
||||||
Project,
|
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,
|
id,
|
||||||
name,
|
name,
|
||||||
|
git_repo_path,
|
||||||
owner_id,
|
owner_id,
|
||||||
now,
|
now,
|
||||||
now
|
now
|
||||||
@@ -231,9 +363,9 @@ mod tests {
|
|||||||
let user = create_test_user(&pool, "test@example.com", "password123", false).await;
|
let user = create_test_user(&pool, "test@example.com", "password123", false).await;
|
||||||
|
|
||||||
// Create multiple projects
|
// Create multiple projects
|
||||||
create_test_project(&pool, "Project 1", user.id).await;
|
create_test_project(&pool, "Project 1", "/tmp/test1", user.id).await;
|
||||||
create_test_project(&pool, "Project 2", user.id).await;
|
create_test_project(&pool, "Project 2", "/tmp/test2", user.id).await;
|
||||||
create_test_project(&pool, "Project 3", user.id).await;
|
create_test_project(&pool, "Project 3", "/tmp/test3", user.id).await;
|
||||||
|
|
||||||
let auth = AuthUser {
|
let auth = AuthUser {
|
||||||
user_id: user.id,
|
user_id: user.id,
|
||||||
@@ -272,7 +404,7 @@ mod tests {
|
|||||||
#[sqlx::test]
|
#[sqlx::test]
|
||||||
async fn test_get_project_success(pool: PgPool) {
|
async fn test_get_project_success(pool: PgPool) {
|
||||||
let user = create_test_user(&pool, "test@example.com", "password123", false).await;
|
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 {
|
let auth = AuthUser {
|
||||||
user_id: user.id,
|
user_id: user.id,
|
||||||
@@ -320,6 +452,7 @@ mod tests {
|
|||||||
|
|
||||||
let create_request = CreateProject {
|
let create_request = CreateProject {
|
||||||
name: "New Project".to_string(),
|
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;
|
let result = create_project(auth.clone(), Extension(pool), Json(create_request)).await;
|
||||||
@@ -346,6 +479,7 @@ mod tests {
|
|||||||
|
|
||||||
let create_request = CreateProject {
|
let create_request = CreateProject {
|
||||||
name: "Admin Project".to_string(),
|
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;
|
let result = create_project(auth.clone(), Extension(pool), Json(create_request)).await;
|
||||||
@@ -362,10 +496,11 @@ mod tests {
|
|||||||
#[sqlx::test]
|
#[sqlx::test]
|
||||||
async fn test_update_project_success(pool: PgPool) {
|
async fn test_update_project_success(pool: PgPool) {
|
||||||
let user = create_test_user(&pool, "test@example.com", "password123", false).await;
|
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 {
|
let update_request = UpdateProject {
|
||||||
name: Some("Updated Name".to_string()),
|
name: Some("Updated Name".to_string()),
|
||||||
|
git_repo_path: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
let result = update_project(Path(project.id), Extension(pool), Json(update_request)).await;
|
let result = update_project(Path(project.id), Extension(pool), Json(update_request)).await;
|
||||||
@@ -383,11 +518,12 @@ mod tests {
|
|||||||
#[sqlx::test]
|
#[sqlx::test]
|
||||||
async fn test_update_project_partial(pool: PgPool) {
|
async fn test_update_project_partial(pool: PgPool) {
|
||||||
let user = create_test_user(&pool, "test@example.com", "password123", false).await;
|
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)
|
// Update with no changes (None for name should keep existing name)
|
||||||
let update_request = UpdateProject {
|
let update_request = UpdateProject {
|
||||||
name: None,
|
name: None,
|
||||||
|
git_repo_path: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
let result = update_project(Path(project.id), Extension(pool), Json(update_request)).await;
|
let result = update_project(Path(project.id), Extension(pool), Json(update_request)).await;
|
||||||
@@ -407,6 +543,7 @@ mod tests {
|
|||||||
|
|
||||||
let update_request = UpdateProject {
|
let update_request = UpdateProject {
|
||||||
name: Some("Updated Name".to_string()),
|
name: Some("Updated Name".to_string()),
|
||||||
|
git_repo_path: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
let result = update_project(Path(nonexistent_project_id), Extension(pool), Json(update_request)).await;
|
let result = update_project(Path(nonexistent_project_id), Extension(pool), Json(update_request)).await;
|
||||||
@@ -417,7 +554,7 @@ mod tests {
|
|||||||
#[sqlx::test]
|
#[sqlx::test]
|
||||||
async fn test_delete_project_success(pool: PgPool) {
|
async fn test_delete_project_success(pool: PgPool) {
|
||||||
let user = create_test_user(&pool, "test@example.com", "password123", false).await;
|
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;
|
let result = delete_project(Path(project.id), Extension(pool)).await;
|
||||||
assert!(result.is_ok());
|
assert!(result.is_ok());
|
||||||
@@ -441,7 +578,7 @@ mod tests {
|
|||||||
use crate::models::task::{Task, TaskStatus};
|
use crate::models::task::{Task, TaskStatus};
|
||||||
|
|
||||||
let user = create_test_user(&pool, "test@example.com", "password123", false).await;
|
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
|
// Create a task in the project
|
||||||
let task_id = Uuid::new_v4();
|
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 user1 = create_test_user(&pool, "user1@example.com", "password123", false).await;
|
||||||
let user2 = create_test_user(&pool, "user2@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 project1 = create_test_project(&pool, "User 1 Project", "/tmp/user1", user1.id).await;
|
||||||
let project2 = create_test_project(&pool, "User 2 Project", user2.id).await;
|
let project2 = create_test_project(&pool, "User 2 Project", "/tmp/user2", user2.id).await;
|
||||||
|
|
||||||
// Verify project ownership
|
// Verify project ownership
|
||||||
assert_eq!(project1.owner_id, user1.id);
|
assert_eq!(project1.owner_id, user1.id);
|
||||||
|
|||||||
@@ -10,7 +10,12 @@ use sqlx::PgPool;
|
|||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
use chrono::Utc;
|
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;
|
use crate::auth::AuthUser;
|
||||||
|
|
||||||
pub async fn get_project_tasks(
|
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<PgPool>
|
||||||
|
) -> Result<ResponseJson<ApiResponse<Vec<TaskAttempt>>>, 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<PgPool>
|
||||||
|
) -> Result<ResponseJson<ApiResponse<Vec<TaskAttemptActivity>>>, 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<PgPool>,
|
||||||
|
Json(mut payload): Json<CreateTaskAttempt>
|
||||||
|
) -> Result<ResponseJson<ApiResponse<TaskAttempt>>, 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::<String>::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<PgPool>,
|
||||||
|
Json(mut payload): Json<CreateTaskAttemptActivity>
|
||||||
|
) -> Result<ResponseJson<ApiResponse<TaskAttemptActivity>>, 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 {
|
pub fn tasks_router() -> Router {
|
||||||
use axum::routing::{post, put, delete};
|
use axum::routing::{post, put, delete};
|
||||||
|
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/projects/:project_id/tasks", get(get_project_tasks).post(create_task))
|
.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", 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)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -1,88 +1,267 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from "react";
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from "@/components/ui/label";
|
||||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
import {
|
||||||
import { Project, CreateProject, UpdateProject } from 'shared/types'
|
Dialog,
|
||||||
import { AlertCircle } from 'lucide-react'
|
DialogContent,
|
||||||
import { makeAuthenticatedRequest } from '@/lib/auth'
|
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 {
|
interface ProjectFormProps {
|
||||||
open: boolean
|
open: boolean;
|
||||||
onClose: () => void
|
onClose: () => void;
|
||||||
onSuccess: () => void
|
onSuccess: () => void;
|
||||||
project?: Project | null
|
project?: Project | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ProjectForm({ open, onClose, onSuccess, project }: ProjectFormProps) {
|
export function ProjectForm({
|
||||||
const [name, setName] = useState(project?.name || '')
|
open,
|
||||||
const [loading, setLoading] = useState(false)
|
onClose,
|
||||||
const [error, setError] = useState('')
|
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) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault();
|
||||||
setError('')
|
setError("");
|
||||||
setLoading(true)
|
setLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
let finalGitRepoPath = gitRepoPath;
|
||||||
|
|
||||||
|
// For new repo mode, construct the full path
|
||||||
|
if (!isEditing && repoMode === "new") {
|
||||||
|
finalGitRepoPath = `${parentPath}/${folderName}`.replace(/\/+/g, "/");
|
||||||
|
}
|
||||||
|
|
||||||
if (isEditing) {
|
if (isEditing) {
|
||||||
const updateData: UpdateProject = { name }
|
const updateData: UpdateProject = {
|
||||||
const response = await makeAuthenticatedRequest(`/api/projects/${project.id}`, {
|
name,
|
||||||
method: 'PUT',
|
git_repo_path: finalGitRepoPath,
|
||||||
body: JSON.stringify(updateData),
|
};
|
||||||
})
|
const response = await makeAuthenticatedRequest(
|
||||||
|
`/api/projects/${project.id}`,
|
||||||
|
{
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify(updateData),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
if (!response.ok) {
|
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 {
|
} else {
|
||||||
const createData: CreateProject = {
|
const createData: CreateProject = {
|
||||||
name
|
name,
|
||||||
}
|
git_repo_path: finalGitRepoPath,
|
||||||
const response = await makeAuthenticatedRequest('/api/projects', {
|
use_existing_repo: repoMode === "existing",
|
||||||
method: 'POST',
|
};
|
||||||
|
const response = await makeAuthenticatedRequest("/api/projects", {
|
||||||
|
method: "POST",
|
||||||
body: JSON.stringify(createData),
|
body: JSON.stringify(createData),
|
||||||
})
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
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()
|
onSuccess();
|
||||||
setName('')
|
setName("");
|
||||||
|
setGitRepoPath("");
|
||||||
|
setParentPath("");
|
||||||
|
setFolderName("");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setError(error instanceof Error ? error.message : 'An error occurred')
|
setError(error instanceof Error ? error.message : "An error occurred");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
setName(project?.name || '')
|
setName(project?.name || "");
|
||||||
setError('')
|
setGitRepoPath(project?.git_repo_path || "");
|
||||||
onClose()
|
setError("");
|
||||||
}
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={handleClose}>
|
<Dialog open={open} onOpenChange={handleClose}>
|
||||||
<DialogContent className="sm:max-w-[425px]">
|
<DialogContent className="sm:max-w-[425px]">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>
|
<DialogTitle>
|
||||||
{isEditing ? 'Edit Project' : 'Create New Project'}
|
{isEditing ? "Edit Project" : "Create New Project"}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
{isEditing
|
{isEditing
|
||||||
? 'Make changes to your project here. Click save when you\'re done.'
|
? "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.'
|
: "Choose whether to use an existing git repository or create a new one."}
|
||||||
}
|
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
{!isEditing && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label>Repository Type</Label>
|
||||||
|
<div className="flex space-x-4">
|
||||||
|
<label className="flex items-center space-x-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="repoMode"
|
||||||
|
value="existing"
|
||||||
|
checked={repoMode === "existing"}
|
||||||
|
onChange={(e) =>
|
||||||
|
setRepoMode(e.target.value as "existing" | "new")
|
||||||
|
}
|
||||||
|
className="text-primary"
|
||||||
|
/>
|
||||||
|
<span className="text-sm">Use existing repository</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center space-x-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="repoMode"
|
||||||
|
value="new"
|
||||||
|
checked={repoMode === "new"}
|
||||||
|
onChange={(e) =>
|
||||||
|
setRepoMode(e.target.value as "existing" | "new")
|
||||||
|
}
|
||||||
|
className="text-primary"
|
||||||
|
/>
|
||||||
|
<span className="text-sm">Create new repository</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{repoMode === "existing" || isEditing ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="git-repo-path">Git Repository Path</Label>
|
||||||
|
<div className="flex space-x-2">
|
||||||
|
<Input
|
||||||
|
id="git-repo-path"
|
||||||
|
type="text"
|
||||||
|
value={gitRepoPath}
|
||||||
|
onChange={(e) => handleGitRepoPathChange(e.target.value)}
|
||||||
|
placeholder="/path/to/your/existing/repo"
|
||||||
|
required
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setShowFolderPicker(true)}
|
||||||
|
>
|
||||||
|
<Folder className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{!isEditing && (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Select a folder that already contains a git repository
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="parent-path">Parent Directory</Label>
|
||||||
|
<div className="flex space-x-2">
|
||||||
|
<Input
|
||||||
|
id="parent-path"
|
||||||
|
type="text"
|
||||||
|
value={parentPath}
|
||||||
|
onChange={(e) => setParentPath(e.target.value)}
|
||||||
|
placeholder="/path/to/parent/directory"
|
||||||
|
required
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setShowFolderPicker(true)}
|
||||||
|
>
|
||||||
|
<Folder className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Choose where to create the new repository
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="folder-name">Repository Folder Name</Label>
|
||||||
|
<Input
|
||||||
|
id="folder-name"
|
||||||
|
type="text"
|
||||||
|
value={folderName}
|
||||||
|
onChange={(e) => {
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
The project name will be auto-populated from this folder name
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="name">Project Name</Label>
|
<Label htmlFor="name">Project Name</Label>
|
||||||
<Input
|
<Input
|
||||||
@@ -98,9 +277,7 @@ export function ProjectForm({ open, onClose, onSuccess, project }: ProjectFormPr
|
|||||||
{error && (
|
{error && (
|
||||||
<Alert variant="destructive">
|
<Alert variant="destructive">
|
||||||
<AlertCircle className="h-4 w-4" />
|
<AlertCircle className="h-4 w-4" />
|
||||||
<AlertDescription>
|
<AlertDescription>{error}</AlertDescription>
|
||||||
{error}
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -113,12 +290,49 @@ export function ProjectForm({ open, onClose, onSuccess, project }: ProjectFormPr
|
|||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" disabled={loading || !name.trim()}>
|
<Button
|
||||||
{loading ? 'Saving...' : isEditing ? 'Save Changes' : 'Create Project'}
|
type="submit"
|
||||||
|
disabled={
|
||||||
|
loading ||
|
||||||
|
!name.trim() ||
|
||||||
|
(repoMode === "existing" || isEditing
|
||||||
|
? !gitRepoPath.trim()
|
||||||
|
: !parentPath.trim() || !folderName.trim())
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{loading
|
||||||
|
? "Saving..."
|
||||||
|
: isEditing
|
||||||
|
? "Save Changes"
|
||||||
|
: "Create Project"}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</form>
|
</form>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|
||||||
|
<FolderPicker
|
||||||
|
open={showFolderPicker}
|
||||||
|
onClose={() => 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"
|
||||||
|
}
|
||||||
|
/>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,13 @@ import { Alert, AlertDescription } from '@/components/ui/alert'
|
|||||||
import { Project, ApiResponse } from 'shared/types'
|
import { Project, ApiResponse } from 'shared/types'
|
||||||
import { ProjectForm } from './project-form'
|
import { ProjectForm } from './project-form'
|
||||||
import { makeAuthenticatedRequest } from '@/lib/auth'
|
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() {
|
export function ProjectList() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
@@ -118,54 +124,64 @@ export function ProjectList() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||||
{projects.map((project) => (
|
{projects.map((project) => (
|
||||||
<Card key={project.id} className="hover:shadow-md transition-shadow">
|
<Card
|
||||||
|
key={project.id}
|
||||||
|
className="hover:shadow-md transition-shadow cursor-pointer"
|
||||||
|
onClick={() => navigate(`/projects/${project.id}/tasks`)}
|
||||||
|
>
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<CardTitle
|
<CardTitle className="text-lg">
|
||||||
className="text-lg cursor-pointer hover:text-primary"
|
|
||||||
onClick={() => navigate(`/projects/${project.id}`)}
|
|
||||||
>
|
|
||||||
{project.name}
|
{project.name}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<Badge variant="secondary" className="ml-2">
|
<div className="flex items-center gap-2">
|
||||||
Active
|
<Badge variant="secondary">
|
||||||
</Badge>
|
Active
|
||||||
|
</Badge>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild onClick={(e) => e.stopPropagation()}>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 w-8 p-0"
|
||||||
|
>
|
||||||
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
navigate(`/projects/${project.id}`)
|
||||||
|
}}>
|
||||||
|
<ExternalLink className="mr-2 h-4 w-4" />
|
||||||
|
View Project
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
handleEdit(project)
|
||||||
|
}}>
|
||||||
|
<Edit className="mr-2 h-4 w-4" />
|
||||||
|
Edit
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
handleDelete(project.id, project.name)
|
||||||
|
}}
|
||||||
|
className="text-red-600"
|
||||||
|
>
|
||||||
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
|
Delete
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<CardDescription className="flex items-center">
|
<CardDescription className="flex items-center">
|
||||||
<Calendar className="mr-1 h-3 w-3" />
|
<Calendar className="mr-1 h-3 w-3" />
|
||||||
Created {new Date(project.created_at).toLocaleDateString()}
|
Created {new Date(project.created_at).toLocaleDateString()}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
onClick={() => navigate(`/projects/${project.id}/tasks`)}
|
|
||||||
className="h-8"
|
|
||||||
>
|
|
||||||
<CheckSquare className="mr-1 h-3 w-3" />
|
|
||||||
Tasks
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => handleEdit(project)}
|
|
||||||
className="h-8"
|
|
||||||
>
|
|
||||||
<Edit className="mr-1 h-3 w-3" />
|
|
||||||
Edit
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => handleDelete(project.id, project.name)}
|
|
||||||
className="h-8 text-red-600 hover:text-red-700 hover:bg-red-50"
|
|
||||||
>
|
|
||||||
<Trash2 className="mr-1 h-3 w-3" />
|
|
||||||
Delete
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
92
frontend/src/components/tasks/TaskCard.tsx
Normal file
92
frontend/src/components/tasks/TaskCard.tsx
Normal file
@@ -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 (
|
||||||
|
<KanbanCard
|
||||||
|
key={task.id}
|
||||||
|
id={task.id}
|
||||||
|
name={task.title}
|
||||||
|
index={index}
|
||||||
|
parent={status}
|
||||||
|
onClick={() => onViewDetails(task)}
|
||||||
|
>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex-1 pr-2">
|
||||||
|
<h4 className="font-medium text-sm">
|
||||||
|
{task.title}
|
||||||
|
</h4>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-1">
|
||||||
|
{/* Actions Menu */}
|
||||||
|
<div
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-6 w-6 p-0 hover:bg-gray-100"
|
||||||
|
>
|
||||||
|
<MoreHorizontal className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem onClick={() => onEdit(task)}>
|
||||||
|
<Edit className="h-4 w-4 mr-2" />
|
||||||
|
Edit
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => onDelete(task.id)}
|
||||||
|
className="text-red-600"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4 mr-2" />
|
||||||
|
Delete
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{task.description && (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{task.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</KanbanCard>
|
||||||
|
)
|
||||||
|
}
|
||||||
71
frontend/src/components/tasks/TaskCreateDialog.tsx
Normal file
71
frontend/src/components/tasks/TaskCreateDialog.tsx
Normal file
@@ -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<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Create New Task</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="title">Title</Label>
|
||||||
|
<Input
|
||||||
|
id="title"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
placeholder="Enter task title"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="description">Description</Label>
|
||||||
|
<Textarea
|
||||||
|
id="description"
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
placeholder="Enter task description (optional)"
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end space-x-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleCreate}>Create Task</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
235
frontend/src/components/tasks/TaskDetailsDialog.tsx
Normal file
235
frontend/src/components/tasks/TaskDetailsDialog.tsx
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { Card, CardContent } from '@/components/ui/card'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle
|
||||||
|
} from '@/components/ui/dialog'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { makeAuthenticatedRequest } from '@/lib/auth'
|
||||||
|
import type { TaskStatus, TaskAttempt, TaskAttemptActivity } from 'shared/types'
|
||||||
|
|
||||||
|
interface Task {
|
||||||
|
id: string
|
||||||
|
project_id: string
|
||||||
|
title: string
|
||||||
|
description: string | null
|
||||||
|
status: TaskStatus
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ApiResponse<T> {
|
||||||
|
success: boolean
|
||||||
|
data: T | null
|
||||||
|
message: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TaskDetailsDialogProps {
|
||||||
|
isOpen: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
task: Task | null
|
||||||
|
projectId: string
|
||||||
|
onError: (error: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusLabels: Record<TaskStatus, string> = {
|
||||||
|
todo: 'To Do',
|
||||||
|
inprogress: 'In Progress',
|
||||||
|
inreview: 'In Review',
|
||||||
|
done: 'Done',
|
||||||
|
cancelled: 'Cancelled'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TaskDetailsDialog({ isOpen, onOpenChange, task, projectId, onError }: TaskDetailsDialogProps) {
|
||||||
|
const [taskAttempts, setTaskAttempts] = useState<TaskAttempt[]>([])
|
||||||
|
const [taskAttemptsLoading, setTaskAttemptsLoading] = useState(false)
|
||||||
|
const [selectedAttempt, setSelectedAttempt] = useState<TaskAttempt | null>(null)
|
||||||
|
const [attemptActivities, setAttemptActivities] = useState<TaskAttemptActivity[]>([])
|
||||||
|
const [activitiesLoading, setActivitiesLoading] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen && task) {
|
||||||
|
fetchTaskAttempts(task.id)
|
||||||
|
}
|
||||||
|
}, [isOpen, task])
|
||||||
|
|
||||||
|
const fetchTaskAttempts = async (taskId: string) => {
|
||||||
|
try {
|
||||||
|
setTaskAttemptsLoading(true)
|
||||||
|
const response = await makeAuthenticatedRequest(`/api/projects/${projectId}/tasks/${taskId}/attempts`)
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const result: ApiResponse<TaskAttempt[]> = await response.json()
|
||||||
|
if (result.success && result.data) {
|
||||||
|
setTaskAttempts(result.data)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
onError('Failed to load task attempts')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
onError('Failed to load task attempts')
|
||||||
|
} finally {
|
||||||
|
setTaskAttemptsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchAttemptActivities = async (attemptId: string) => {
|
||||||
|
if (!task) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
setActivitiesLoading(true)
|
||||||
|
const response = await makeAuthenticatedRequest(`/api/projects/${projectId}/tasks/${task.id}/attempts/${attemptId}/activities`)
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const result: ApiResponse<TaskAttemptActivity[]> = await response.json()
|
||||||
|
if (result.success && result.data) {
|
||||||
|
setAttemptActivities(result.data)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
onError('Failed to load attempt activities')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
onError('Failed to load attempt activities')
|
||||||
|
} finally {
|
||||||
|
setActivitiesLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAttemptClick = (attempt: TaskAttempt) => {
|
||||||
|
setSelectedAttempt(attempt)
|
||||||
|
fetchAttemptActivities(attempt.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-4xl max-h-[80vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Task Details: {task?.title}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Task Info */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h3 className="text-lg font-semibold">Task Information</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<Label>Title</Label>
|
||||||
|
<p className="text-sm text-muted-foreground">{task?.title}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>Status</Label>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{task ? statusLabels[task.status] : ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{task?.description && (
|
||||||
|
<div>
|
||||||
|
<Label>Description</Label>
|
||||||
|
<p className="text-sm text-muted-foreground">{task.description}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Task Attempts */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-lg font-semibold">Task Attempts</h3>
|
||||||
|
{taskAttemptsLoading ? (
|
||||||
|
<div className="text-center py-4">Loading attempts...</div>
|
||||||
|
) : taskAttempts.length === 0 ? (
|
||||||
|
<div className="text-center py-4 text-muted-foreground">
|
||||||
|
No attempts found for this task
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{taskAttempts.map((attempt) => (
|
||||||
|
<Card
|
||||||
|
key={attempt.id}
|
||||||
|
className={`cursor-pointer transition-colors ${
|
||||||
|
selectedAttempt?.id === attempt.id ? 'bg-blue-50 border-blue-200' : 'hover:bg-gray-50'
|
||||||
|
}`}
|
||||||
|
onClick={() => handleAttemptClick(attempt)}
|
||||||
|
>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex justify-between items-start">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">Worktree: {attempt.worktree_path}</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Created: {new Date(attempt.created_at).toLocaleDateString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">Base Commit</Label>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
{attempt.base_commit || 'None'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">Merge Commit</Label>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
{attempt.merge_commit || 'None'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Activity History */}
|
||||||
|
{selectedAttempt && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-lg font-semibold">
|
||||||
|
Activity History for Attempt: {selectedAttempt.worktree_path}
|
||||||
|
</h3>
|
||||||
|
{activitiesLoading ? (
|
||||||
|
<div className="text-center py-4">Loading activities...</div>
|
||||||
|
) : attemptActivities.length === 0 ? (
|
||||||
|
<div className="text-center py-4 text-muted-foreground">
|
||||||
|
No activities found for this attempt
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{attemptActivities.map((activity) => (
|
||||||
|
<Card key={activity.id}>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex justify-between items-start">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<span className={`px-2 py-1 rounded-full text-xs font-medium ${
|
||||||
|
activity.status === 'init' ? 'bg-gray-100 text-gray-800' :
|
||||||
|
activity.status === 'inprogress' ? 'bg-blue-100 text-blue-800' :
|
||||||
|
'bg-yellow-100 text-yellow-800'
|
||||||
|
}`}>
|
||||||
|
{activity.status === 'init' ? 'Init' :
|
||||||
|
activity.status === 'inprogress' ? 'In Progress' :
|
||||||
|
'Paused'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{activity.note && (
|
||||||
|
<p className="text-sm text-muted-foreground">{activity.note}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{new Date(activity.created_at).toLocaleString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
115
frontend/src/components/tasks/TaskEditDialog.tsx
Normal file
115
frontend/src/components/tasks/TaskEditDialog.tsx
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
import { useState, useEffect } 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'
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue
|
||||||
|
} from '@/components/ui/select'
|
||||||
|
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 TaskEditDialogProps {
|
||||||
|
isOpen: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
task: Task | null
|
||||||
|
onUpdateTask: (title: string, description: string, status: TaskStatus) => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TaskEditDialog({ isOpen, onOpenChange, task, onUpdateTask }: TaskEditDialogProps) {
|
||||||
|
const [title, setTitle] = useState('')
|
||||||
|
const [description, setDescription] = useState('')
|
||||||
|
const [status, setStatus] = useState<TaskStatus>('todo')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (task) {
|
||||||
|
setTitle(task.title)
|
||||||
|
setDescription(task.description || '')
|
||||||
|
setStatus(task.status)
|
||||||
|
}
|
||||||
|
}, [task])
|
||||||
|
|
||||||
|
const handleUpdate = async () => {
|
||||||
|
if (!title.trim()) return
|
||||||
|
|
||||||
|
await onUpdateTask(title, description, status)
|
||||||
|
onOpenChange(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Edit Task</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="edit-title">Title</Label>
|
||||||
|
<Input
|
||||||
|
id="edit-title"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
placeholder="Enter task title"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="edit-description">Description</Label>
|
||||||
|
<Textarea
|
||||||
|
id="edit-description"
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
placeholder="Enter task description (optional)"
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="edit-status">Status</Label>
|
||||||
|
<Select
|
||||||
|
value={status}
|
||||||
|
onValueChange={(value) => setStatus(value as TaskStatus)}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="todo">To Do</SelectItem>
|
||||||
|
<SelectItem value="inprogress">In Progress</SelectItem>
|
||||||
|
<SelectItem value="inreview">In Review</SelectItem>
|
||||||
|
<SelectItem value="done">Done</SelectItem>
|
||||||
|
<SelectItem value="cancelled">Cancelled</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end space-x-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleUpdate}>Update Task</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
95
frontend/src/components/tasks/TaskKanbanBoard.tsx
Normal file
95
frontend/src/components/tasks/TaskKanbanBoard.tsx
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import {
|
||||||
|
KanbanProvider,
|
||||||
|
KanbanBoard,
|
||||||
|
KanbanHeader,
|
||||||
|
KanbanCards,
|
||||||
|
type DragEndEvent
|
||||||
|
} from '@/components/ui/shadcn-io/kanban'
|
||||||
|
import { TaskCard } from './TaskCard'
|
||||||
|
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 TaskKanbanBoardProps {
|
||||||
|
tasks: Task[]
|
||||||
|
onDragEnd: (event: DragEndEvent) => void
|
||||||
|
onEditTask: (task: Task) => void
|
||||||
|
onDeleteTask: (taskId: string) => void
|
||||||
|
onViewTaskDetails: (task: Task) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const allTaskStatuses: TaskStatus[] = ['todo', 'inprogress', 'inreview', 'done', 'cancelled']
|
||||||
|
|
||||||
|
const statusLabels: Record<TaskStatus, string> = {
|
||||||
|
todo: 'To Do',
|
||||||
|
inprogress: 'In Progress',
|
||||||
|
inreview: 'In Review',
|
||||||
|
done: 'Done',
|
||||||
|
cancelled: 'Cancelled'
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusBoardColors: Record<TaskStatus, string> = {
|
||||||
|
todo: '#64748b',
|
||||||
|
inprogress: '#3b82f6',
|
||||||
|
inreview: '#f59e0b',
|
||||||
|
done: '#22c55e',
|
||||||
|
cancelled: '#ef4444'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TaskKanbanBoard({ tasks, onDragEnd, onEditTask, onDeleteTask, onViewTaskDetails }: TaskKanbanBoardProps) {
|
||||||
|
const groupTasksByStatus = () => {
|
||||||
|
const groups: Record<TaskStatus, Task[]> = {} as Record<TaskStatus, Task[]>
|
||||||
|
|
||||||
|
// Initialize groups for all possible statuses
|
||||||
|
allTaskStatuses.forEach(status => {
|
||||||
|
groups[status] = []
|
||||||
|
})
|
||||||
|
|
||||||
|
tasks.forEach(task => {
|
||||||
|
// Convert old capitalized status to lowercase if needed
|
||||||
|
const normalizedStatus = task.status.toLowerCase() as TaskStatus
|
||||||
|
if (groups[normalizedStatus]) {
|
||||||
|
groups[normalizedStatus].push(task)
|
||||||
|
} else {
|
||||||
|
// Default to todo if status doesn't match any expected value
|
||||||
|
groups['todo'].push(task)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return groups
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<KanbanProvider onDragEnd={onDragEnd}>
|
||||||
|
{Object.entries(groupTasksByStatus()).map(([status, statusTasks]) => (
|
||||||
|
<KanbanBoard key={status} id={status as TaskStatus}>
|
||||||
|
<KanbanHeader
|
||||||
|
name={statusLabels[status as TaskStatus]}
|
||||||
|
color={statusBoardColors[status as TaskStatus]}
|
||||||
|
/>
|
||||||
|
<KanbanCards>
|
||||||
|
{statusTasks.map((task, index) => (
|
||||||
|
<TaskCard
|
||||||
|
key={task.id}
|
||||||
|
task={task}
|
||||||
|
index={index}
|
||||||
|
status={status}
|
||||||
|
onEdit={onEditTask}
|
||||||
|
onDelete={onDeleteTask}
|
||||||
|
onViewDetails={onViewTaskDetails}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</KanbanCards>
|
||||||
|
</KanbanBoard>
|
||||||
|
))}
|
||||||
|
</KanbanProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
5
frontend/src/components/tasks/index.ts
Normal file
5
frontend/src/components/tasks/index.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export { TaskCreateDialog } from './TaskCreateDialog'
|
||||||
|
export { TaskEditDialog } from './TaskEditDialog'
|
||||||
|
export { TaskDetailsDialog } from './TaskDetailsDialog'
|
||||||
|
export { TaskCard } from './TaskCard'
|
||||||
|
export { TaskKanbanBoard } from './TaskKanbanBoard'
|
||||||
248
frontend/src/components/ui/folder-picker.tsx
Normal file
248
frontend/src/components/ui/folder-picker.tsx
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
import React, { useState, useEffect } from 'react'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||||
|
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||||
|
import { Folder, FolderOpen, File, AlertCircle, Home, ChevronUp } from 'lucide-react'
|
||||||
|
import { makeAuthenticatedRequest } from '@/lib/auth'
|
||||||
|
import { DirectoryEntry } from 'shared/types'
|
||||||
|
|
||||||
|
interface FolderPickerProps {
|
||||||
|
open: boolean
|
||||||
|
onClose: () => void
|
||||||
|
onSelect: (path: string) => void
|
||||||
|
value?: string
|
||||||
|
title?: string
|
||||||
|
description?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FolderPicker({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
onSelect,
|
||||||
|
value = '',
|
||||||
|
title = 'Select Folder',
|
||||||
|
description = 'Choose a folder for your project'
|
||||||
|
}: FolderPickerProps) {
|
||||||
|
const [currentPath, setCurrentPath] = useState<string>('')
|
||||||
|
const [entries, setEntries] = useState<DirectoryEntry[]>([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [manualPath, setManualPath] = useState(value)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setManualPath(value)
|
||||||
|
loadDirectory()
|
||||||
|
}
|
||||||
|
}, [open, value])
|
||||||
|
|
||||||
|
const loadDirectory = async (path?: string) => {
|
||||||
|
setLoading(true)
|
||||||
|
setError('')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const queryParam = path ? `?path=${encodeURIComponent(path)}` : ''
|
||||||
|
const response = await makeAuthenticatedRequest(`/api/filesystem/list${queryParam}`)
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to load directory')
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
setEntries(data.data || [])
|
||||||
|
const newPath = path || data.message || ''
|
||||||
|
setCurrentPath(newPath)
|
||||||
|
// Update manual path if we have a specific path (not for initial home directory load)
|
||||||
|
if (path) {
|
||||||
|
setManualPath(newPath)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setError(data.message || 'Failed to load directory')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to load directory')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFolderClick = (entry: DirectoryEntry) => {
|
||||||
|
if (entry.is_directory) {
|
||||||
|
loadDirectory(entry.path)
|
||||||
|
setManualPath(entry.path) // Auto-populate the manual path field
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleParentDirectory = () => {
|
||||||
|
const parentPath = currentPath.split('/').slice(0, -1).join('/')
|
||||||
|
const newPath = parentPath || '/'
|
||||||
|
loadDirectory(newPath)
|
||||||
|
setManualPath(newPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleHomeDirectory = () => {
|
||||||
|
loadDirectory()
|
||||||
|
// Don't set manual path here since home directory path varies by system
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleManualPathChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setManualPath(e.target.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleManualPathSubmit = () => {
|
||||||
|
loadDirectory(manualPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSelectCurrent = () => {
|
||||||
|
onSelect(manualPath || currentPath)
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSelectManual = () => {
|
||||||
|
onSelect(manualPath)
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
setError('')
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={handleClose}>
|
||||||
|
<DialogContent className="max-w-[600px] w-full h-[500px] flex flex-col overflow-hidden">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{title}</DialogTitle>
|
||||||
|
<DialogDescription>{description}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="flex-1 flex flex-col space-y-4 overflow-hidden">
|
||||||
|
{/* Legend */}
|
||||||
|
<div className="text-xs text-muted-foreground border-b pb-2">
|
||||||
|
Click folder names to navigate • Use action buttons to select
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Manual path input */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-sm font-medium">Enter path manually:</div>
|
||||||
|
<div className="flex space-x-2 min-w-0">
|
||||||
|
<Input
|
||||||
|
value={manualPath}
|
||||||
|
onChange={handleManualPathChange}
|
||||||
|
placeholder="/path/to/your/project"
|
||||||
|
className="flex-1 min-w-0"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
onClick={handleManualPathSubmit}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="flex-shrink-0"
|
||||||
|
>
|
||||||
|
Go
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Navigation */}
|
||||||
|
<div className="flex items-center space-x-2 min-w-0">
|
||||||
|
<Button
|
||||||
|
onClick={handleHomeDirectory}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="flex-shrink-0"
|
||||||
|
>
|
||||||
|
<Home className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleParentDirectory}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={!currentPath || currentPath === '/'}
|
||||||
|
className="flex-shrink-0"
|
||||||
|
>
|
||||||
|
<ChevronUp className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<div className="text-sm text-muted-foreground flex-1 truncate min-w-0">
|
||||||
|
{currentPath || 'Home'}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={handleSelectCurrent}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={!currentPath}
|
||||||
|
className="flex-shrink-0"
|
||||||
|
>
|
||||||
|
Select Current
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Directory listing */}
|
||||||
|
<div className="flex-1 border rounded-md overflow-auto">
|
||||||
|
{loading ? (
|
||||||
|
<div className="p-4 text-center text-muted-foreground">
|
||||||
|
Loading...
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<Alert variant="destructive" className="m-4">
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<AlertDescription>{error}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
) : entries.length === 0 ? (
|
||||||
|
<div className="p-4 text-center text-muted-foreground">
|
||||||
|
No folders found
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="p-2">
|
||||||
|
{entries.map((entry, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className={`flex items-center space-x-2 p-2 rounded cursor-pointer hover:bg-accent ${
|
||||||
|
!entry.is_directory ? 'opacity-50 cursor-not-allowed' : ''
|
||||||
|
}`}
|
||||||
|
onClick={() => entry.is_directory && handleFolderClick(entry)}
|
||||||
|
title={entry.name} // Show full name on hover
|
||||||
|
>
|
||||||
|
{entry.is_directory ? (
|
||||||
|
entry.is_git_repo ? (
|
||||||
|
<FolderOpen className="h-4 w-4 text-green-600 flex-shrink-0" />
|
||||||
|
) : (
|
||||||
|
<Folder className="h-4 w-4 text-blue-600 flex-shrink-0" />
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<File className="h-4 w-4 text-gray-400 flex-shrink-0" />
|
||||||
|
)}
|
||||||
|
<span className="text-sm flex-1 truncate min-w-0">{entry.name}</span>
|
||||||
|
{entry.is_git_repo && (
|
||||||
|
<span className="text-xs text-green-600 bg-green-100 px-2 py-1 rounded flex-shrink-0">
|
||||||
|
git repo
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleClose}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleSelectManual}
|
||||||
|
disabled={!manualPath.trim()}
|
||||||
|
>
|
||||||
|
Select Path
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,17 +1,20 @@
|
|||||||
'use client';
|
"use client";
|
||||||
|
|
||||||
import { Card } from '@/components/ui/card';
|
import { Card } from "@/components/ui/card";
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from "@/lib/utils";
|
||||||
import {
|
import {
|
||||||
DndContext,
|
DndContext,
|
||||||
|
PointerSensor,
|
||||||
rectIntersection,
|
rectIntersection,
|
||||||
useDraggable,
|
useDraggable,
|
||||||
useDroppable,
|
useDroppable,
|
||||||
} from '@dnd-kit/core';
|
useSensor,
|
||||||
import type { DragEndEvent } from '@dnd-kit/core';
|
useSensors,
|
||||||
import type { ReactNode } from 'react';
|
} from "@dnd-kit/core";
|
||||||
|
import type { DragEndEvent } from "@dnd-kit/core";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
export type { DragEndEvent } from '@dnd-kit/core';
|
export type { DragEndEvent } from "@dnd-kit/core";
|
||||||
|
|
||||||
export type Status = {
|
export type Status = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -28,7 +31,7 @@ export type Feature = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type KanbanBoardProps = {
|
export type KanbanBoardProps = {
|
||||||
id: Status['id'];
|
id: Status["id"];
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
};
|
};
|
||||||
@@ -39,8 +42,8 @@ export const KanbanBoard = ({ id, children, className }: KanbanBoardProps) => {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex h-full min-h-40 flex-col gap-2 rounded-md border bg-secondary p-2 text-xs shadow-sm outline outline-2 transition-all',
|
"flex h-full min-h-40 flex-col gap-2 rounded-md border bg-secondary p-2 text-xs shadow-sm outline outline-2 transition-all",
|
||||||
isOver ? 'outline-primary' : 'outline-transparent',
|
isOver ? "outline-primary" : "outline-transparent",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
ref={setNodeRef}
|
ref={setNodeRef}
|
||||||
@@ -50,11 +53,12 @@ export const KanbanBoard = ({ id, children, className }: KanbanBoardProps) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export type KanbanCardProps = Pick<Feature, 'id' | 'name'> & {
|
export type KanbanCardProps = Pick<Feature, "id" | "name"> & {
|
||||||
index: number;
|
index: number;
|
||||||
parent: string;
|
parent: string;
|
||||||
children?: ReactNode;
|
children?: ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
onClick?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const KanbanCard = ({
|
export const KanbanCard = ({
|
||||||
@@ -64,6 +68,7 @@ export const KanbanCard = ({
|
|||||||
parent,
|
parent,
|
||||||
children,
|
children,
|
||||||
className,
|
className,
|
||||||
|
onClick,
|
||||||
}: KanbanCardProps) => {
|
}: KanbanCardProps) => {
|
||||||
const { attributes, listeners, setNodeRef, transform, isDragging } =
|
const { attributes, listeners, setNodeRef, transform, isDragging } =
|
||||||
useDraggable({
|
useDraggable({
|
||||||
@@ -74,18 +79,19 @@ export const KanbanCard = ({
|
|||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
className={cn(
|
className={cn(
|
||||||
'rounded-md p-3 shadow-sm',
|
"rounded-md p-3 shadow-sm",
|
||||||
isDragging && 'cursor-grabbing',
|
isDragging && "cursor-grabbing",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
transform: transform
|
transform: transform
|
||||||
? `translateX(${transform.x}px) translateY(${transform.y}px)`
|
? `translateX(${transform.x}px) translateY(${transform.y}px)`
|
||||||
: 'none',
|
: "none",
|
||||||
}}
|
}}
|
||||||
{...listeners}
|
{...listeners}
|
||||||
{...attributes}
|
{...attributes}
|
||||||
ref={setNodeRef}
|
ref={setNodeRef}
|
||||||
|
onClick={onClick}
|
||||||
>
|
>
|
||||||
{children ?? <p className="m-0 font-medium text-sm">{name}</p>}
|
{children ?? <p className="m-0 font-medium text-sm">{name}</p>}
|
||||||
</Card>
|
</Card>
|
||||||
@@ -98,7 +104,7 @@ export type KanbanCardsProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const KanbanCards = ({ children, className }: KanbanCardsProps) => (
|
export const KanbanCards = ({ children, className }: KanbanCardsProps) => (
|
||||||
<div className={cn('flex flex-1 flex-col gap-2', className)}>{children}</div>
|
<div className={cn("flex flex-1 flex-col gap-2", className)}>{children}</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
export type KanbanHeaderProps =
|
export type KanbanHeaderProps =
|
||||||
@@ -106,16 +112,16 @@ export type KanbanHeaderProps =
|
|||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
name: Status['name'];
|
name: Status["name"];
|
||||||
color: Status['color'];
|
color: Status["color"];
|
||||||
className?: string;
|
className?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const KanbanHeader = (props: KanbanHeaderProps) =>
|
export const KanbanHeader = (props: KanbanHeaderProps) =>
|
||||||
'children' in props ? (
|
"children" in props ? (
|
||||||
props.children
|
props.children
|
||||||
) : (
|
) : (
|
||||||
<div className={cn('flex shrink-0 items-center gap-2', props.className)}>
|
<div className={cn("flex shrink-0 items-center gap-2", props.className)}>
|
||||||
<div
|
<div
|
||||||
className="h-2 w-2 rounded-full"
|
className="h-2 w-2 rounded-full"
|
||||||
style={{ backgroundColor: props.color }}
|
style={{ backgroundColor: props.color }}
|
||||||
@@ -134,12 +140,27 @@ export const KanbanProvider = ({
|
|||||||
children,
|
children,
|
||||||
onDragEnd,
|
onDragEnd,
|
||||||
className,
|
className,
|
||||||
}: KanbanProviderProps) => (
|
}: KanbanProviderProps) => {
|
||||||
<DndContext collisionDetection={rectIntersection} onDragEnd={onDragEnd}>
|
const sensors = useSensors(
|
||||||
<div
|
useSensor(PointerSensor, {
|
||||||
className={cn('grid w-full auto-cols-fr grid-flow-col gap-4', className)}
|
activationConstraint: { distance: 8 },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DndContext
|
||||||
|
collisionDetection={rectIntersection}
|
||||||
|
onDragEnd={onDragEnd}
|
||||||
|
sensors={sensors}
|
||||||
>
|
>
|
||||||
{children}
|
<div
|
||||||
</div>
|
className={cn(
|
||||||
</DndContext>
|
"grid w-full auto-cols-fr grid-flow-col gap-4",
|
||||||
);
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</DndContext>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
@@ -2,39 +2,14 @@ import { useState, useEffect } from 'react'
|
|||||||
import { useParams, useNavigate } from 'react-router-dom'
|
import { useParams, useNavigate } from 'react-router-dom'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Card, CardContent } from '@/components/ui/card'
|
import { Card, CardContent } from '@/components/ui/card'
|
||||||
import {
|
import { ArrowLeft, Plus } from 'lucide-react'
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle
|
|
||||||
} from '@/components/ui/dialog'
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuTrigger
|
|
||||||
} from '@/components/ui/dropdown-menu'
|
|
||||||
import { Input } from '@/components/ui/input'
|
|
||||||
import { Textarea } from '@/components/ui/textarea'
|
|
||||||
import { Label } from '@/components/ui/label'
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue
|
|
||||||
} from '@/components/ui/select'
|
|
||||||
import { ArrowLeft, Plus, MoreHorizontal, Trash2, Edit } from 'lucide-react'
|
|
||||||
import { makeAuthenticatedRequest } from '@/lib/auth'
|
import { makeAuthenticatedRequest } from '@/lib/auth'
|
||||||
import {
|
import { TaskCreateDialog } from '@/components/tasks/TaskCreateDialog'
|
||||||
KanbanProvider,
|
import { TaskEditDialog } from '@/components/tasks/TaskEditDialog'
|
||||||
KanbanBoard,
|
import { TaskDetailsDialog } from '@/components/tasks/TaskDetailsDialog'
|
||||||
KanbanHeader,
|
import { TaskKanbanBoard } from '@/components/tasks/TaskKanbanBoard'
|
||||||
KanbanCards,
|
|
||||||
KanbanCard,
|
|
||||||
type DragEndEvent
|
|
||||||
} from '@/components/ui/shadcn-io/kanban'
|
|
||||||
import type { TaskStatus } from 'shared/types'
|
import type { TaskStatus } from 'shared/types'
|
||||||
|
import type { DragEndEvent } from '@/components/ui/shadcn-io/kanban'
|
||||||
|
|
||||||
interface Task {
|
interface Task {
|
||||||
id: string
|
id: string
|
||||||
@@ -62,24 +37,7 @@ interface ApiResponse<T> {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
// All possible task statuses from shared types
|
|
||||||
const allTaskStatuses: TaskStatus[] = ['todo', 'inprogress', 'inreview', 'done', 'cancelled']
|
|
||||||
|
|
||||||
const statusLabels: Record<TaskStatus, string> = {
|
|
||||||
todo: 'To Do',
|
|
||||||
inprogress: 'In Progress',
|
|
||||||
inreview: 'In Review',
|
|
||||||
done: 'Done',
|
|
||||||
cancelled: 'Cancelled'
|
|
||||||
}
|
|
||||||
|
|
||||||
const statusBoardColors: Record<TaskStatus, string> = {
|
|
||||||
todo: '#64748b',
|
|
||||||
inprogress: '#3b82f6',
|
|
||||||
inreview: '#f59e0b',
|
|
||||||
done: '#22c55e',
|
|
||||||
cancelled: '#ef4444'
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ProjectTasks() {
|
export function ProjectTasks() {
|
||||||
const { projectId } = useParams<{ projectId: string }>()
|
const { projectId } = useParams<{ projectId: string }>()
|
||||||
@@ -91,13 +49,9 @@ export function ProjectTasks() {
|
|||||||
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false)
|
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false)
|
||||||
const [editingTask, setEditingTask] = useState<Task | null>(null)
|
const [editingTask, setEditingTask] = useState<Task | null>(null)
|
||||||
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false)
|
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false)
|
||||||
|
const [selectedTask, setSelectedTask] = useState<Task | null>(null)
|
||||||
|
const [isTaskDetailsDialogOpen, setIsTaskDetailsDialogOpen] = useState(false)
|
||||||
|
|
||||||
// Form states
|
|
||||||
const [newTaskTitle, setNewTaskTitle] = useState('')
|
|
||||||
const [newTaskDescription, setNewTaskDescription] = useState('')
|
|
||||||
const [editTaskTitle, setEditTaskTitle] = useState('')
|
|
||||||
const [editTaskDescription, setEditTaskDescription] = useState('')
|
|
||||||
const [editTaskStatus, setEditTaskStatus] = useState<Task['status']>('todo')
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (projectId) {
|
if (projectId) {
|
||||||
@@ -144,24 +98,19 @@ export function ProjectTasks() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const createTask = async () => {
|
const handleCreateTask = async (title: string, description: string) => {
|
||||||
if (!newTaskTitle.trim()) return
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await makeAuthenticatedRequest(`/api/projects/${projectId}/tasks`, {
|
const response = await makeAuthenticatedRequest(`/api/projects/${projectId}/tasks`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
project_id: projectId,
|
project_id: projectId,
|
||||||
title: newTaskTitle,
|
title,
|
||||||
description: newTaskDescription || null
|
description: description || null
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
await fetchTasks()
|
await fetchTasks()
|
||||||
setNewTaskTitle('')
|
|
||||||
setNewTaskDescription('')
|
|
||||||
setIsCreateDialogOpen(false)
|
|
||||||
} else {
|
} else {
|
||||||
setError('Failed to create task')
|
setError('Failed to create task')
|
||||||
}
|
}
|
||||||
@@ -170,23 +119,22 @@ export function ProjectTasks() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateTask = async () => {
|
const handleUpdateTask = async (title: string, description: string, status: TaskStatus) => {
|
||||||
if (!editingTask || !editTaskTitle.trim()) return
|
if (!editingTask) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await makeAuthenticatedRequest(`/api/projects/${projectId}/tasks/${editingTask.id}`, {
|
const response = await makeAuthenticatedRequest(`/api/projects/${projectId}/tasks/${editingTask.id}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
title: editTaskTitle,
|
title,
|
||||||
description: editTaskDescription || null,
|
description: description || null,
|
||||||
status: editTaskStatus
|
status
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
await fetchTasks()
|
await fetchTasks()
|
||||||
setEditingTask(null)
|
setEditingTask(null)
|
||||||
setIsEditDialogOpen(false)
|
|
||||||
} else {
|
} else {
|
||||||
setError('Failed to update task')
|
setError('Failed to update task')
|
||||||
}
|
}
|
||||||
@@ -195,7 +143,7 @@ export function ProjectTasks() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const deleteTask = async (taskId: string) => {
|
const handleDeleteTask = async (taskId: string) => {
|
||||||
if (!confirm('Are you sure you want to delete this task?')) return
|
if (!confirm('Are you sure you want to delete this task?')) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -213,14 +161,16 @@ export function ProjectTasks() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const openEditDialog = (task: Task) => {
|
const handleEditTask = (task: Task) => {
|
||||||
setEditingTask(task)
|
setEditingTask(task)
|
||||||
setEditTaskTitle(task.title)
|
|
||||||
setEditTaskDescription(task.description || '')
|
|
||||||
setEditTaskStatus(task.status)
|
|
||||||
setIsEditDialogOpen(true)
|
setIsEditDialogOpen(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleViewTaskDetails = (task: Task) => {
|
||||||
|
setSelectedTask(task)
|
||||||
|
setIsTaskDetailsDialogOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
const handleDragEnd = async (event: DragEndEvent) => {
|
const handleDragEnd = async (event: DragEndEvent) => {
|
||||||
const { active, over } = event
|
const { active, over } = event
|
||||||
|
|
||||||
@@ -264,27 +214,7 @@ export function ProjectTasks() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const groupTasksByStatus = () => {
|
|
||||||
const groups: Record<TaskStatus, Task[]> = {} as Record<TaskStatus, Task[]>
|
|
||||||
|
|
||||||
// Initialize groups for all possible statuses
|
|
||||||
allTaskStatuses.forEach(status => {
|
|
||||||
groups[status] = []
|
|
||||||
})
|
|
||||||
|
|
||||||
tasks.forEach(task => {
|
|
||||||
// Convert old capitalized status to lowercase if needed
|
|
||||||
const normalizedStatus = task.status.toLowerCase() as TaskStatus
|
|
||||||
if (groups[normalizedStatus]) {
|
|
||||||
groups[normalizedStatus].push(task)
|
|
||||||
} else {
|
|
||||||
// Default to todo if status doesn't match any expected value
|
|
||||||
groups['todo'].push(task)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return groups
|
|
||||||
}
|
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <div className="text-center py-8">Loading tasks...</div>
|
return <div className="text-center py-8">Loading tasks...</div>
|
||||||
@@ -324,43 +254,11 @@ export function ProjectTasks() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Dialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
|
<TaskCreateDialog
|
||||||
<DialogContent>
|
isOpen={isCreateDialogOpen}
|
||||||
<DialogHeader>
|
onOpenChange={setIsCreateDialogOpen}
|
||||||
<DialogTitle>Create New Task</DialogTitle>
|
onCreateTask={handleCreateTask}
|
||||||
</DialogHeader>
|
/>
|
||||||
<div className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="title">Title</Label>
|
|
||||||
<Input
|
|
||||||
id="title"
|
|
||||||
value={newTaskTitle}
|
|
||||||
onChange={(e) => setNewTaskTitle(e.target.value)}
|
|
||||||
placeholder="Enter task title"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="description">Description</Label>
|
|
||||||
<Textarea
|
|
||||||
id="description"
|
|
||||||
value={newTaskDescription}
|
|
||||||
onChange={(e) => setNewTaskDescription(e.target.value)}
|
|
||||||
placeholder="Enter task description (optional)"
|
|
||||||
rows={3}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-end space-x-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setIsCreateDialogOpen(false)}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button onClick={createTask}>Create Task</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
{/* Tasks View */}
|
{/* Tasks View */}
|
||||||
{tasks.length === 0 ? (
|
{tasks.length === 0 ? (
|
||||||
@@ -377,139 +275,29 @@ export function ProjectTasks() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<KanbanProvider onDragEnd={handleDragEnd}>
|
<TaskKanbanBoard
|
||||||
{Object.entries(groupTasksByStatus()).map(([status, statusTasks]) => (
|
tasks={tasks}
|
||||||
<KanbanBoard key={status} id={status as Task['status']}>
|
onDragEnd={handleDragEnd}
|
||||||
<KanbanHeader
|
onEditTask={handleEditTask}
|
||||||
name={statusLabels[status as Task['status']]}
|
onDeleteTask={handleDeleteTask}
|
||||||
color={statusBoardColors[status as Task['status']]}
|
onViewTaskDetails={handleViewTaskDetails}
|
||||||
/>
|
/>
|
||||||
<KanbanCards>
|
|
||||||
{statusTasks.map((task, index) => (
|
|
||||||
<KanbanCard
|
|
||||||
key={task.id}
|
|
||||||
id={task.id}
|
|
||||||
name={task.title}
|
|
||||||
index={index}
|
|
||||||
parent={status}
|
|
||||||
>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex items-start justify-between">
|
|
||||||
<div
|
|
||||||
className="flex-1 cursor-pointer pr-2"
|
|
||||||
onClick={() => openEditDialog(task)}
|
|
||||||
>
|
|
||||||
<h4 className="font-medium text-sm">
|
|
||||||
{task.title}
|
|
||||||
</h4>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className="flex-shrink-0"
|
|
||||||
onPointerDown={(e) => e.stopPropagation()}
|
|
||||||
onMouseDown={(e) => e.stopPropagation()}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-8 w-8 p-0 hover:bg-gray-100"
|
|
||||||
>
|
|
||||||
<MoreHorizontal className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end">
|
|
||||||
<DropdownMenuItem onClick={() => openEditDialog(task)}>
|
|
||||||
<Edit className="h-4 w-4 mr-2" />
|
|
||||||
Edit
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() => deleteTask(task.id)}
|
|
||||||
className="text-red-600"
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4 mr-2" />
|
|
||||||
Delete
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{task.description && (
|
|
||||||
<div
|
|
||||||
className="cursor-pointer"
|
|
||||||
onClick={() => openEditDialog(task)}
|
|
||||||
>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{task.description}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</KanbanCard>
|
|
||||||
))}
|
|
||||||
</KanbanCards>
|
|
||||||
</KanbanBoard>
|
|
||||||
))}
|
|
||||||
</KanbanProvider>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Edit Task Dialog */}
|
<TaskEditDialog
|
||||||
<Dialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
|
isOpen={isEditDialogOpen}
|
||||||
<DialogContent>
|
onOpenChange={setIsEditDialogOpen}
|
||||||
<DialogHeader>
|
task={editingTask}
|
||||||
<DialogTitle>Edit Task</DialogTitle>
|
onUpdateTask={handleUpdateTask}
|
||||||
</DialogHeader>
|
/>
|
||||||
<div className="space-y-4">
|
|
||||||
<div>
|
<TaskDetailsDialog
|
||||||
<Label htmlFor="edit-title">Title</Label>
|
isOpen={isTaskDetailsDialogOpen}
|
||||||
<Input
|
onOpenChange={setIsTaskDetailsDialogOpen}
|
||||||
id="edit-title"
|
task={selectedTask}
|
||||||
value={editTaskTitle}
|
projectId={projectId!}
|
||||||
onChange={(e) => setEditTaskTitle(e.target.value)}
|
onError={setError}
|
||||||
placeholder="Enter task title"
|
/>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="edit-description">Description</Label>
|
|
||||||
<Textarea
|
|
||||||
id="edit-description"
|
|
||||||
value={editTaskDescription}
|
|
||||||
onChange={(e) => setEditTaskDescription(e.target.value)}
|
|
||||||
placeholder="Enter task description (optional)"
|
|
||||||
rows={3}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="edit-status">Status</Label>
|
|
||||||
<Select
|
|
||||||
value={editTaskStatus}
|
|
||||||
onValueChange={(value) => setEditTaskStatus(value as Task['status'])}
|
|
||||||
>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="todo">To Do</SelectItem>
|
|
||||||
<SelectItem value="inprogress">In Progress</SelectItem>
|
|
||||||
<SelectItem value="inreview">In Review</SelectItem>
|
|
||||||
<SelectItem value="done">Done</SelectItem>
|
|
||||||
<SelectItem value="cancelled">Cancelled</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-end space-x-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setIsEditDialogOpen(false)}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button onClick={updateTask}>Update Task</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,11 +3,11 @@
|
|||||||
|
|
||||||
export type ApiResponse<T> = { success: boolean, data: T | null, message: string | null, };
|
export type ApiResponse<T> = { success: boolean, data: T | null, message: string | null, };
|
||||||
|
|
||||||
export type CreateProject = { name: string, };
|
export type CreateProject = { name: string, git_repo_path: string, use_existing_repo: boolean, };
|
||||||
|
|
||||||
export type Project = { id: string, name: string, owner_id: string, created_at: Date, updated_at: Date, };
|
export type Project = { id: string, name: string, git_repo_path: string, owner_id: string, created_at: Date, updated_at: Date, };
|
||||||
|
|
||||||
export type UpdateProject = { name: string | null, };
|
export type UpdateProject = { name: string | null, git_repo_path: string | null, };
|
||||||
|
|
||||||
export type CreateTask = { project_id: string, title: string, description: string | null, };
|
export type CreateTask = { project_id: string, title: string, description: string | null, };
|
||||||
|
|
||||||
@@ -17,6 +17,18 @@ export type Task = { id: string, project_id: string, title: string, description:
|
|||||||
|
|
||||||
export type UpdateTask = { title: string | null, description: string | null, status: TaskStatus | null, };
|
export type UpdateTask = { title: string | null, description: string | null, status: TaskStatus | null, };
|
||||||
|
|
||||||
|
export type TaskAttemptStatus = "init" | "inprogress" | "paused";
|
||||||
|
|
||||||
|
export type TaskAttempt = { id: string, task_id: string, worktree_path: string, base_commit: string | null, merge_commit: string | null, created_at: string, updated_at: string, };
|
||||||
|
|
||||||
|
export type CreateTaskAttempt = { task_id: string, worktree_path: string, base_commit: string | null, merge_commit: string | null, };
|
||||||
|
|
||||||
|
export type UpdateTaskAttempt = { worktree_path: string | null, base_commit: string | null, merge_commit: string | null, };
|
||||||
|
|
||||||
|
export type TaskAttemptActivity = { id: string, task_attempt_id: string, status: TaskAttemptStatus, note: string | null, created_at: string, };
|
||||||
|
|
||||||
|
export type CreateTaskAttemptActivity = { task_attempt_id: string, status: TaskAttemptStatus | null, note: string | null, };
|
||||||
|
|
||||||
export type CreateUser = { email: string, password: string, is_admin: boolean | null, };
|
export type CreateUser = { email: string, password: string, is_admin: boolean | null, };
|
||||||
|
|
||||||
export type LoginRequest = { email: string, password: string, };
|
export type LoginRequest = { email: string, password: string, };
|
||||||
@@ -26,3 +38,5 @@ export type LoginResponse = { user: User, token: string, };
|
|||||||
export type UpdateUser = { email: string | null, password: string | null, is_admin: boolean | null, };
|
export type UpdateUser = { email: string | null, password: string | null, is_admin: boolean | null, };
|
||||||
|
|
||||||
export type User = { id: string, email: string, is_admin: boolean, created_at: Date, updated_at: Date, };
|
export type User = { id: string, email: string, is_admin: boolean, created_at: Date, updated_at: Date, };
|
||||||
|
|
||||||
|
export type DirectoryEntry = { name: string, path: string, is_directory: boolean, is_git_repo: boolean, };
|
||||||
Reference in New Issue
Block a user