Squashed commit of the following:

commit f29ca62b42df9ac7ff2dafd5132c3e12a1a6a3e7
Author: Louis Knight-Webb <louis@bloop.ai>
Date:   Thu Jun 19 12:52:48 2025 -0400

    Settings

commit 1215b493bbabeac9f446dd2996cb6275df069770
Author: Louis Knight-Webb <louis@bloop.ai>
Date:   Thu Jun 19 12:44:36 2025 -0400

    Consolidate types

commit d0960d989d24d6068728056d28820415c6cdea2c
Author: Louis Knight-Webb <louis@bloop.ai>
Date:   Thu Jun 19 12:32:15 2025 -0400

    Partial
This commit is contained in:
Louis Knight-Webb
2025-06-19 12:53:41 -04:00
parent 33a29a9be3
commit 57e31ea623
16 changed files with 425 additions and 54 deletions

View File

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

View File

@@ -3,3 +3,4 @@ pub mod executor;
pub mod executors;
pub mod models;
pub mod routes;
pub mod utils;

View File

@@ -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 didnt give us a home directory")
} else {
ProjectDirs::from("ai", "bloop", env!("CARGO_PKG_NAME"))
.expect("OS didnt 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()
}

View File

@@ -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<Self> {
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(())
}
}

View File

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

View File

@@ -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<Arc<RwLock<Config>>>,
) -> ResponseJson<ApiResponse<Config>> {
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<Arc<RwLock<Config>>>,
Json(new_config): Json<Config>,
) -> ResponseJson<ApiResponse<Config>> {
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)),
}),
}
}

View File

@@ -1,3 +1,4 @@
pub mod config;
pub mod filesystem;
pub mod health;
pub mod projects;

View File

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

21
backend/src/utils.rs Normal file
View File

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

View File

@@ -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={<TaskAttemptComparePage />}
/>
<Route path="/settings" element={<Settings />} />
</Routes>
</div>
</div>

View File

@@ -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
</Link>
</Button>
<Button
asChild
variant={
location.pathname === "/settings" ? "default" : "ghost"
}
size="sm"
>
<Link to="/settings">
<Settings className="mr-2 h-4 w-4" />
Settings
</Link>
</Button>
</div>
</div>
<div className="flex items-center space-x-4">
<ThemeToggle />
{!isHome && (
<Button asChild variant="ghost">
<Link to="/">

View File

@@ -1,34 +1,45 @@
import React, { createContext, useContext, useEffect, useState } from "react";
type Theme = "dark" | "light" | "system";
import type { Config, ThemeMode, ApiResponse } from "shared/types";
type ThemeProviderProps = {
children: React.ReactNode;
defaultTheme?: Theme;
storageKey?: string;
};
type ThemeProviderState = {
theme: Theme;
setTheme: (theme: Theme) => void;
theme: ThemeMode;
setTheme: (theme: ThemeMode) => void;
loadThemeFromConfig: () => Promise<void>;
};
const initialState: ThemeProviderState = {
theme: "system",
setTheme: () => null,
loadThemeFromConfig: async () => {},
};
const ThemeProviderContext = createContext<ThemeProviderState>(initialState);
export function ThemeProvider({
children,
defaultTheme = "system",
storageKey = "vibe-kanban-ui-theme",
...props
}: ThemeProviderProps) {
const [theme, setTheme] = useState<Theme>(
() => (localStorage.getItem(storageKey) as Theme) || defaultTheme
);
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
const [theme, setThemeState] = useState<ThemeMode>("system");
// Load theme from backend config
const loadThemeFromConfig = async () => {
try {
const response = await fetch("/api/config");
const data: ApiResponse<Config> = await response.json();
if (data.success && data.data) {
setThemeState(data.data.theme);
}
} catch (err) {
console.error("Error loading theme from config:", err);
}
};
// Load theme on mount
useEffect(() => {
loadThemeFromConfig();
}, []);
useEffect(() => {
const root = window.document.documentElement;
@@ -48,12 +59,14 @@ export function ThemeProvider({
root.classList.add(theme);
}, [theme]);
const setTheme = (newTheme: ThemeMode) => {
setThemeState(newTheme);
};
const value = {
theme,
setTheme: (theme: Theme) => {
localStorage.setItem(storageKey, theme);
setTheme(theme);
},
setTheme,
loadThemeFromConfig,
};
return (

View File

@@ -7,7 +7,7 @@ import { ThemeProvider } from "@/components/theme-provider";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<ThemeProvider defaultTheme="dark" storageKey="vibe-kanban-ui-theme">
<ThemeProvider>
<ClickToComponent />
<App />
</ThemeProvider>

View File

@@ -0,0 +1,197 @@
import { useState, useEffect } from "react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Label } from "@/components/ui/label";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Loader2 } from "lucide-react";
import type { Config, ThemeMode, ApiResponse } from "shared/types";
import { useTheme } from "@/components/theme-provider";
export function Settings() {
const [config, setConfig] = useState<Config | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
const { setTheme, loadThemeFromConfig } = useTheme();
// Load initial config
useEffect(() => {
const loadConfig = async () => {
try {
const response = await fetch("/api/config");
const data: ApiResponse<Config> = await response.json();
if (data.success && data.data) {
setConfig(data.data);
} else {
setError(data.message || "Failed to load configuration");
}
} catch (err) {
setError("Failed to load configuration");
console.error("Error loading config:", err);
} finally {
setLoading(false);
}
};
loadConfig();
}, []);
const handleSave = async () => {
if (!config) return;
setSaving(true);
setError(null);
setSuccess(false);
try {
const response = await fetch("/api/config", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(config),
});
const data: ApiResponse<Config> = await response.json();
if (data.success) {
setSuccess(true);
// Update theme provider to reflect the saved theme
setTheme(config.theme);
setTimeout(() => setSuccess(false), 3000);
} else {
setError(data.message || "Failed to save configuration");
}
} catch (err) {
setError("Failed to save configuration");
console.error("Error saving config:", err);
} finally {
setSaving(false);
}
};
const updateConfig = (updates: Partial<Config>) => {
setConfig((prev: Config | null) => prev ? { ...prev, ...updates } : null);
};
if (loading) {
return (
<div className="container mx-auto px-4 py-8">
<div className="flex items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin" />
<span className="ml-2">Loading settings...</span>
</div>
</div>
);
}
if (!config) {
return (
<div className="container mx-auto px-4 py-8">
<Alert variant="destructive">
<AlertDescription>
Failed to load settings. {error}
</AlertDescription>
</Alert>
</div>
);
}
return (
<div className="container mx-auto px-4 py-8 max-w-4xl">
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold">Settings</h1>
<p className="text-muted-foreground">
Configure your preferences and application settings.
</p>
</div>
{error && (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{success && (
<Alert>
<AlertDescription>Settings saved successfully!</AlertDescription>
</Alert>
)}
<div className="grid gap-6">
<Card>
<CardHeader>
<CardTitle>Appearance</CardTitle>
<CardDescription>
Customize how the application looks and feels.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="theme">Theme</Label>
<Select
value={config.theme}
onValueChange={(value: ThemeMode) => updateConfig({ theme: value })}
>
<SelectTrigger id="theme">
<SelectValue placeholder="Select theme" />
</SelectTrigger>
<SelectContent>
<SelectItem value="light">Light</SelectItem>
<SelectItem value="dark">Dark</SelectItem>
<SelectItem value="system">System</SelectItem>
</SelectContent>
</Select>
<p className="text-sm text-muted-foreground">
Choose your preferred color scheme.
</p>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Task Execution</CardTitle>
<CardDescription>
Configure how tasks are executed and processed.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="executor">Default Executor</Label>
<Select
value={config.executor.type}
onValueChange={(value: "echo" | "claude" | "amp") => updateConfig({ executor: { type: value } })}
>
<SelectTrigger id="executor">
<SelectValue placeholder="Select executor" />
</SelectTrigger>
<SelectContent>
<SelectItem value="claude">Claude</SelectItem>
<SelectItem value="amp">Amp</SelectItem>
<SelectItem value="echo">Echo</SelectItem>
</SelectContent>
</Select>
<p className="text-sm text-muted-foreground">
Choose the default executor for running tasks.
</p>
</div>
</CardContent>
</Card>
</div>
<div className="flex justify-end">
<Button onClick={handleSave} disabled={saving}>
{saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Save Settings
</Button>
</div>
</div>
</div>
);
}

View File

@@ -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<T> {
success: boolean;
data: T | null;
message: string | null;
}
const statusLabels: Record<TaskStatus, string> = {
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<Config> = 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);

View File

@@ -3,6 +3,10 @@
export type ApiResponse<T> = { 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, };