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:
Louis Knight-Webb
2025-06-16 16:16:42 -04:00
parent eca26240fe
commit 22edb7a1db
26 changed files with 2041 additions and 409 deletions

View File

@@ -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"] }

View 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 '';

View 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);

View 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();

View File

@@ -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();

View File

@@ -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));

View File

@@ -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;

View File

@@ -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>,
} }

View 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>,
}

View 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>,
}

View 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))
}

View File

@@ -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;

View File

@@ -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);

View File

@@ -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)]

View File

@@ -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>
) );
} }

View File

@@ -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>

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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'

View 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>
)
}

View File

@@ -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>
);
};

View File

@@ -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>
) )
} }

View File

@@ -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, };