diff --git a/backend/src/bin/generate_types.rs b/backend/src/bin/generate_types.rs index fa3c0950..5643f394 100644 --- a/backend/src/bin/generate_types.rs +++ b/backend/src/bin/generate_types.rs @@ -64,8 +64,14 @@ export {} export {} +export {} + +export {} + export {}"#, vibe_kanban::models::ApiResponse::<()>::decl(), + vibe_kanban::models::config::Config::decl(), + vibe_kanban::models::config::ThemeMode::decl(), vibe_kanban::executor::ExecutorConfig::decl(), vibe_kanban::models::project::CreateProject::decl(), vibe_kanban::models::project::Project::decl(), diff --git a/backend/src/lib.rs b/backend/src/lib.rs index 73bbfc30..e92b472c 100644 --- a/backend/src/lib.rs +++ b/backend/src/lib.rs @@ -3,3 +3,4 @@ pub mod executor; pub mod executors; pub mod models; pub mod routes; +pub mod utils; diff --git a/backend/src/main.rs b/backend/src/main.rs index fdde70cf..55752060 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -11,7 +11,7 @@ use rust_embed::RustEmbed; use sqlx::{sqlite::SqliteConnectOptions, SqlitePool}; use std::str::FromStr; use std::{collections::HashMap, env, sync::Arc}; -use tokio::sync::Mutex; +use tokio::sync::{Mutex, RwLock}; use tower_http::cors::CorsLayer; mod execution_monitor; @@ -19,10 +19,11 @@ mod executor; mod executors; mod models; mod routes; +mod utils; use execution_monitor::{execution_monitor, AppState}; -use models::ApiResponse; -use routes::{filesystem, health, projects, tasks}; +use models::{ApiResponse, Config}; +use routes::{config, filesystem, health, projects, tasks}; #[derive(RustEmbed)] #[folder = "../frontend/dist"] @@ -86,20 +87,25 @@ async fn main() -> anyhow::Result<()> { tracing_subscriber::fmt().init(); // Create asset directory if it doesn't exist - if !asset_dir().exists() { - std::fs::create_dir_all(asset_dir())?; + if !utils::asset_dir().exists() { + std::fs::create_dir_all(utils::asset_dir())?; } // Database connection let database_url = format!( "sqlite://{}", - asset_dir().join("db.sqlite").to_string_lossy() + utils::asset_dir().join("db.sqlite").to_string_lossy() ); let options = SqliteConnectOptions::from_str(&database_url)?.create_if_missing(true); let pool = SqlitePool::connect_with(options).await?; sqlx::migrate!("./migrations").run(&pool).await?; + // Load configuration + let config_path = utils::config_path(); + let config = Config::load(&config_path)?; + let config_arc = Arc::new(RwLock::new(config)); + // Create app state let app_state = AppState { running_executions: Arc::new(Mutex::new(HashMap::new())), @@ -124,9 +130,11 @@ async fn main() -> anyhow::Result<()> { Router::new() .merge(projects::projects_router()) .merge(tasks::tasks_router()) - .merge(filesystem::filesystem_router()), + .merge(filesystem::filesystem_router()) + .merge(config::config_router()), ) - .layer(Extension(pool.clone())); + .layer(Extension(pool.clone())) + .layer(Extension(config_arc)); let app = Router::new() .merge(public_routes) @@ -154,18 +162,3 @@ async fn main() -> anyhow::Result<()> { Ok(()) } - -fn asset_dir() -> std::path::PathBuf { - let proj = if cfg!(debug_assertions) { - ProjectDirs::from("ai", "bloop-dev", env!("CARGO_PKG_NAME")) - .expect("OS didn’t give us a home directory") - } else { - ProjectDirs::from("ai", "bloop", env!("CARGO_PKG_NAME")) - .expect("OS didn’t give us a home directory") - }; - - // ✔ macOS → ~/Library/Application Support/MyApp - // ✔ Linux → ~/.local/share/myapp (respects XDG_DATA_HOME) - // ✔ Windows → %APPDATA%\Example\MyApp - proj.data_dir().to_path_buf() -} diff --git a/backend/src/models/config.rs b/backend/src/models/config.rs new file mode 100644 index 00000000..d995200d --- /dev/null +++ b/backend/src/models/config.rs @@ -0,0 +1,49 @@ +use crate::executor::ExecutorConfig; +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; +use ts_rs::TS; + +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export)] +pub struct Config { + pub theme: ThemeMode, + pub executor: ExecutorConfig, +} + +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export)] +#[serde(rename_all = "lowercase")] +pub enum ThemeMode { + Light, + Dark, + System, +} + +impl Default for Config { + fn default() -> Self { + Self { + theme: ThemeMode::System, + executor: ExecutorConfig::Claude, + } + } +} + +impl Config { + pub fn load(config_path: &PathBuf) -> anyhow::Result { + if config_path.exists() { + let content = std::fs::read_to_string(config_path)?; + let config: Config = serde_json::from_str(&content)?; + Ok(config) + } else { + let config = Config::default(); + config.save(config_path)?; + Ok(config) + } + } + + pub fn save(&self, config_path: &PathBuf) -> anyhow::Result<()> { + let content = serde_json::to_string_pretty(self)?; + std::fs::write(config_path, content)?; + Ok(()) + } +} diff --git a/backend/src/models/mod.rs b/backend/src/models/mod.rs index 26e8abda..edd9df20 100644 --- a/backend/src/models/mod.rs +++ b/backend/src/models/mod.rs @@ -1,7 +1,9 @@ pub mod api_response; +pub mod config; pub mod project; pub mod task; pub mod task_attempt; pub mod task_attempt_activity; pub use api_response::ApiResponse; +pub use config::Config; diff --git a/backend/src/routes/config.rs b/backend/src/routes/config.rs new file mode 100644 index 00000000..513db4f0 --- /dev/null +++ b/backend/src/routes/config.rs @@ -0,0 +1,53 @@ +use axum::{ + extract::Extension, + response::Json as ResponseJson, + routing::{get, post}, + Json, Router, +}; +use std::sync::Arc; +use tokio::sync::RwLock; + +use crate::models::{config::Config, ApiResponse}; +use crate::utils; + +pub fn config_router() -> Router { + Router::new() + .route("/config", get(get_config)) + .route("/config", post(update_config)) +} + +async fn get_config( + Extension(config): Extension>>, +) -> ResponseJson> { + let config = config.read().await; + ResponseJson(ApiResponse { + success: true, + data: Some(config.clone()), + message: Some("Config retrieved successfully".to_string()), + }) +} + +async fn update_config( + Extension(config_arc): Extension>>, + Json(new_config): Json, +) -> ResponseJson> { + let config_path = utils::config_path(); + + match new_config.save(&config_path) { + Ok(_) => { + let mut config = config_arc.write().await; + *config = new_config.clone(); + + ResponseJson(ApiResponse { + success: true, + data: Some(new_config), + message: Some("Config updated successfully".to_string()), + }) + } + Err(e) => ResponseJson(ApiResponse { + success: false, + data: None, + message: Some(format!("Failed to save config: {}", e)), + }), + } +} diff --git a/backend/src/routes/mod.rs b/backend/src/routes/mod.rs index f6120cf8..c460189a 100644 --- a/backend/src/routes/mod.rs +++ b/backend/src/routes/mod.rs @@ -1,3 +1,4 @@ +pub mod config; pub mod filesystem; pub mod health; pub mod projects; diff --git a/backend/src/routes/projects.rs b/backend/src/routes/projects.rs index 8007087a..90c959bf 100644 --- a/backend/src/routes/projects.rs +++ b/backend/src/routes/projects.rs @@ -309,16 +309,19 @@ async fn search_files_in_repo( for result in walker { let entry = result?; let path = entry.path(); - + // Skip the root directory itself if path == repo_path { continue; } let relative_path = path.strip_prefix(repo_path)?; - + // Skip .git directory and its contents - if relative_path.components().any(|component| component.as_os_str() == ".git") { + if relative_path + .components() + .any(|component| component.as_os_str() == ".git") + { continue; } let relative_path_str = relative_path.to_string_lossy().to_lowercase(); diff --git a/backend/src/utils.rs b/backend/src/utils.rs new file mode 100644 index 00000000..f55a5b8f --- /dev/null +++ b/backend/src/utils.rs @@ -0,0 +1,21 @@ +use directories::ProjectDirs; +use std::env; + +pub fn asset_dir() -> std::path::PathBuf { + let proj = if cfg!(debug_assertions) { + ProjectDirs::from("ai", "bloop-dev", env!("CARGO_PKG_NAME")) + .expect("OS didn't give us a home directory") + } else { + ProjectDirs::from("ai", "bloop", env!("CARGO_PKG_NAME")) + .expect("OS didn't give us a home directory") + }; + + // ✔ macOS → ~/Library/Application Support/MyApp + // ✔ Linux → ~/.local/share/myapp (respects XDG_DATA_HOME) + // ✔ Windows → %APPDATA%\Example\MyApp + proj.data_dir().to_path_buf() +} + +pub fn config_path() -> std::path::PathBuf { + asset_dir().join("config.json") +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 5442a242..5be2957a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -4,6 +4,7 @@ import { Projects } from "@/pages/projects"; import { ProjectTasks } from "@/pages/project-tasks"; import { TaskDetailsPage } from "@/pages/task-details"; import { TaskAttemptComparePage } from "@/pages/task-attempt-compare"; +import { Settings } from "@/pages/Settings"; function AppContent() { const showNavbar = true; @@ -25,6 +26,7 @@ function AppContent() { path="/projects/:projectId/tasks/:taskId/attempts/:attemptId/compare" element={} /> + } /> diff --git a/frontend/src/components/layout/navbar.tsx b/frontend/src/components/layout/navbar.tsx index 7aafc052..10de0907 100644 --- a/frontend/src/components/layout/navbar.tsx +++ b/frontend/src/components/layout/navbar.tsx @@ -1,7 +1,6 @@ import { Link, useLocation } from "react-router-dom"; import { Button } from "@/components/ui/button"; -import { ArrowLeft, FolderOpen } from "lucide-react"; -import { ThemeToggle } from "@/components/theme-toggle"; +import { ArrowLeft, FolderOpen, Settings } from "lucide-react"; import { Logo } from "@/components/logo"; export function Navbar() { @@ -27,10 +26,21 @@ export function Navbar() { Projects +
- {!isHome && ( +
+ + + ); +} diff --git a/frontend/src/pages/task-details.tsx b/frontend/src/pages/task-details.tsx index 630728ea..327cc7c7 100644 --- a/frontend/src/pages/task-details.tsx +++ b/frontend/src/pages/task-details.tsx @@ -19,6 +19,8 @@ import type { TaskAttempt, TaskAttemptActivity, TaskAttemptStatus, + Config, + ApiResponse, } from "shared/types"; interface Task { @@ -31,11 +33,7 @@ interface Task { updated_at: string; } -interface ApiResponse { - success: boolean; - data: T | null; - message: string | null; -} + const statusLabels: Record = { todo: "To Do", @@ -124,6 +122,24 @@ export function TaskDetailsPage() { } }, [projectId, taskId]); + // Load config to get default executor + useEffect(() => { + const loadConfig = async () => { + try { + const response = await makeRequest("/api/config"); + if (response.ok) { + const result: ApiResponse = await response.json(); + if (result.success && result.data) { + setSelectedExecutor(result.data.executor.type); + } + } + } catch (err) { + console.error("Failed to load config:", err); + } + }; + loadConfig(); + }, []); + useEffect(() => { if (task) { fetchTaskAttempts(task.id); diff --git a/shared/types.ts b/shared/types.ts index 2d5efada..a2c1e176 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -3,6 +3,10 @@ export type ApiResponse = { success: boolean, data: T | null, message: string | null, }; +export type Config = { theme: ThemeMode, executor: ExecutorConfig, }; + +export type ThemeMode = "light" | "dark" | "system"; + export type ExecutorConfig = { "type": "echo" } | { "type": "claude" } | { "type": "amp" }; export type CreateProject = { name: string, git_repo_path: string, use_existing_repo: boolean, setup_script: string | null, };