Covers Tauri 2 scaffold, SQLite setup, Project CRUD (backend + UI), with 9 tasks and full TDD approach for Rust backend. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
44 KiB
Orchai Phase 1: Foundation Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Get a working Tauri 2 desktop app with SQLite storage and full Project CRUD (create from local path or clone URL, list, edit, delete) with a React UI.
Architecture: Tauri 2 (Rust backend) with React + TypeScript frontend. SQLite database via rusqlite for persistence. Tauri IPC commands expose backend operations to the frontend. React Router for navigation with a sidebar-based layout.
Tech Stack: Rust, Tauri 2, React 18, TypeScript, Vite, Tailwind CSS, SQLite (rusqlite), react-router-dom v6
Phasing Strategy
This is Plan 1 of 4:
- Plan 1 (this): Foundation -- Tauri scaffold, SQLite, Project Manager
- Plan 2: Tuleap Integration -- credentials, API client, poller, filter engine, tracker config
- Plan 3: Agent Pipeline -- orchestrator, worktree manager, ticket processing, results UI
- Plan 4: Notifications + Polish -- notifier, system notifications, dashboard
File Structure
orchai/
├── docs/ # existing
├── src-tauri/
│ ├── Cargo.toml # modify: add rusqlite, uuid, chrono, serde
│ ├── build.rs # from scaffold
│ ├── tauri.conf.json # modify: app name, identifier, permissions
│ ├── capabilities/
│ │ └── default.json # modify: add dialog permissions
│ ├── migrations/
│ │ └── 001_init.sql # create: full schema (all tables)
│ └── src/
│ ├── main.rs # from scaffold (unchanged)
│ ├── lib.rs # modify: setup app state, register commands
│ ├── db.rs # create: SQLite init + migration runner
│ ├── error.rs # create: shared error type
│ ├── models/
│ │ ├── mod.rs # create: re-exports
│ │ └── project.rs # create: Project struct + CRUD
│ └── commands/
│ ├── mod.rs # create: re-exports
│ └── project.rs # create: Tauri commands for project CRUD
├── src/
│ ├── main.tsx # from scaffold (unchanged)
│ ├── App.tsx # modify: router setup
│ ├── App.css # delete (replaced by Tailwind)
│ ├── index.css # modify: Tailwind directives
│ ├── lib/
│ │ ├── types.ts # create: TypeScript types matching Rust models
│ │ └── api.ts # create: typed invoke wrappers
│ ├── components/
│ │ ├── layout/
│ │ │ ├── AppLayout.tsx # create: sidebar + main content area
│ │ │ └── Sidebar.tsx # create: project list + add button
│ │ └── projects/
│ │ ├── ProjectList.tsx # create: empty state / project cards
│ │ ├── ProjectForm.tsx # create: create/edit form with folder picker
│ │ └── ProjectDashboard.tsx # create: project overview (placeholder)
├── index.html # from scaffold
├── package.json # modify: add dependencies
├── vite.config.ts # from scaffold
├── tsconfig.json # from scaffold
├── tsconfig.node.json # from scaffold
├── tailwind.config.js # create
├── postcss.config.js # create
└── .gitignore # from scaffold
Task 1: Scaffold Tauri 2 + React + TypeScript
Files:
-
Create: entire project scaffold via CLI
-
Preserve:
docs/,.git/ -
Step 1: Save existing repo contents
cd /home/leclere/Projets
cp -r orchai/docs /tmp/orchai-docs-backup
cp -r orchai/.git /tmp/orchai-git-backup
- Step 2: Scaffold Tauri 2 project
cd /home/leclere/Projets
rm -rf orchai
npm create tauri-app@latest orchai
When prompted, select:
-
Project name:
orchai -
Identifier:
com.orchai.app -
Frontend language:
TypeScript / JavaScript -
Package manager:
npm -
UI template:
React -
UI flavor:
TypeScript -
Step 3: Restore repo history and docs
cd /home/leclere/Projets/orchai
rm -rf .git
cp -r /tmp/orchai-git-backup .git
cp -r /tmp/orchai-docs-backup docs
rm -rf /tmp/orchai-docs-backup /tmp/orchai-git-backup
- Step 4: Install dependencies and verify
cd /home/leclere/Projets/orchai
npm install
cd src-tauri && cargo build
cd ..
Expected: build succeeds with no errors.
- Step 5: Verify dev server starts
npm run tauri dev
Expected: Tauri window opens with the default React starter page. Close it after verifying.
- Step 6: Commit
git add -A
git commit -m "scaffold: Tauri 2 + React + TypeScript via create-tauri-app"
Task 2: Configure Tailwind CSS + project metadata
Files:
-
Create:
tailwind.config.js,postcss.config.js -
Modify:
src/index.css,package.json,src-tauri/tauri.conf.json -
Delete:
src/App.css -
Step 1: Install Tailwind
cd /home/leclere/Projets/orchai
npm install -D tailwindcss @tailwindcss/vite
- Step 2: Add Tailwind to Vite config
Replace the contents of vite.config.ts:
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
const host = process.env.TAURI_DEV_HOST;
export default defineConfig(async () => ({
plugins: [react(), tailwindcss()],
clearScreen: false,
server: {
port: 1420,
strictPort: true,
host: host || false,
hmr: host
? {
protocol: "ws",
host,
port: 1421,
}
: undefined,
watch: {
ignored: ["**/src-tauri/**"],
},
},
}));
- Step 3: Replace index.css with Tailwind directives
Replace the contents of src/index.css:
@import "tailwindcss";
- Step 4: Delete App.css and clean up App.tsx
Delete src/App.css.
Replace src/App.tsx:
function App() {
return (
<div className="min-h-screen bg-gray-50 text-gray-900">
<h1 className="text-2xl font-bold p-8">Orchai</h1>
</div>
);
}
export default App;
- Step 5: Update Tauri config
In src-tauri/tauri.conf.json, update the app section:
{
"$schema": "https://raw.githubusercontent.com/tauri-apps/tauri/dev/crates/tauri-config-schema/schema.json",
"productName": "Orchai",
"version": "0.1.0",
"identifier": "com.orchai.app",
"build": {
"frontendDist": "../dist",
"devUrl": "http://localhost:1420",
"beforeDevCommand": "npm run dev",
"beforeBuildCommand": "npm run build"
},
"app": {
"title": "Orchai",
"windows": [
{
"title": "Orchai",
"width": 1200,
"height": 800
}
],
"security": {
"csp": null
}
}
}
- Step 6: Verify Tailwind works
npm run tauri dev
Expected: window opens, "Orchai" heading rendered with Tailwind styling (bold, large). Close after verifying.
- Step 7: Commit
git add -A
git commit -m "configure: Tailwind CSS + app metadata"
Task 3: SQLite database + migration system
Files:
-
Modify:
src-tauri/Cargo.toml -
Create:
src-tauri/migrations/001_init.sql -
Create:
src-tauri/src/db.rs -
Create:
src-tauri/src/error.rs -
Modify:
src-tauri/src/lib.rs -
Step 1: Write the failing test for db initialization
Add dependencies to src-tauri/Cargo.toml under [dependencies]:
rusqlite = { version = "0.31", features = ["bundled"] }
uuid = { version = "1", features = ["v4", "serde"] }
chrono = { version = "0.4", features = ["serde"] }
Create src-tauri/src/db.rs:
use rusqlite::{Connection, Result};
use std::path::Path;
pub fn init(db_path: &Path) -> Result<Connection> {
todo!()
}
pub fn init_in_memory() -> Result<Connection> {
todo!()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_init_in_memory_creates_tables() {
let conn = init_in_memory().expect("should initialize");
// Verify all 6 tables exist
let tables: Vec<String> = conn
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name")
.unwrap()
.query_map([], |row| row.get(0))
.unwrap()
.collect::<Result<Vec<String>, _>>()
.unwrap();
assert_eq!(
tables,
vec![
"notifications",
"processed_tickets",
"projects",
"tuleap_credentials",
"watched_trackers",
"worktrees",
]
);
}
#[test]
fn test_init_in_memory_enables_foreign_keys() {
let conn = init_in_memory().expect("should initialize");
let fk_enabled: i32 = conn
.query_row("PRAGMA foreign_keys", [], |row| row.get(0))
.unwrap();
assert_eq!(fk_enabled, 1);
}
#[test]
fn test_init_in_memory_sets_wal_mode() {
let conn = init_in_memory().expect("should initialize");
let mode: String = conn
.query_row("PRAGMA journal_mode", [], |row| row.get(0))
.unwrap();
assert_eq!(mode, "wal");
}
}
- Step 2: Run tests to verify they fail
cd /home/leclere/Projets/orchai/src-tauri
cargo test db::tests
Expected: 3 failures with not yet implemented.
- Step 3: Create migration SQL
Create src-tauri/migrations/001_init.sql:
CREATE TABLE IF NOT EXISTS projects (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
path TEXT NOT NULL,
cloned_from TEXT,
base_branch TEXT NOT NULL DEFAULT 'main',
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS tuleap_credentials (
id TEXT PRIMARY KEY,
tuleap_url TEXT NOT NULL,
username TEXT NOT NULL,
password_encrypted TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS watched_trackers (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
tracker_id INTEGER NOT NULL,
tracker_label TEXT NOT NULL,
polling_interval INTEGER NOT NULL DEFAULT 10,
agent_config_json TEXT NOT NULL,
filters_json TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS processed_tickets (
id TEXT PRIMARY KEY,
tracker_id TEXT NOT NULL REFERENCES watched_trackers(id) ON DELETE CASCADE,
artifact_id INTEGER NOT NULL,
artifact_title TEXT NOT NULL,
artifact_data TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'Pending',
analyst_report TEXT,
developer_report TEXT,
detected_at TEXT NOT NULL DEFAULT (datetime('now')),
processed_at TEXT
);
CREATE TABLE IF NOT EXISTS worktrees (
id TEXT PRIMARY KEY,
ticket_id TEXT NOT NULL REFERENCES processed_tickets(id),
path TEXT NOT NULL,
branch_name TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'Active',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
merged_at TEXT,
merged_into TEXT
);
CREATE TABLE IF NOT EXISTS notifications (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
ticket_id TEXT REFERENCES processed_tickets(id),
type TEXT NOT NULL,
title TEXT NOT NULL,
message TEXT NOT NULL,
read INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
- Step 4: Implement db::init and db::init_in_memory
Replace the todo!() implementations in src-tauri/src/db.rs:
use rusqlite::{Connection, Result};
use std::path::Path;
const MIGRATION_001: &str = include_str!("../migrations/001_init.sql");
pub fn init(db_path: &Path) -> Result<Connection> {
let conn = Connection::open(db_path)?;
configure(&conn)?;
migrate(&conn)?;
Ok(conn)
}
pub fn init_in_memory() -> Result<Connection> {
let conn = Connection::open_in_memory()?;
configure(&conn)?;
migrate(&conn)?;
Ok(conn)
}
fn configure(conn: &Connection) -> Result<()> {
conn.pragma_update(None, "journal_mode", "wal")?;
conn.pragma_update(None, "foreign_keys", "ON")?;
Ok(())
}
fn migrate(conn: &Connection) -> Result<()> {
let version: i32 = conn.pragma_query_value(None, "user_version", |row| row.get(0))?;
if version < 1 {
conn.execute_batch(MIGRATION_001)?;
conn.pragma_update(None, "user_version", 1)?;
}
Ok(())
}
- Step 5: Run tests to verify they pass
cd /home/leclere/Projets/orchai/src-tauri
cargo test db::tests
Expected: 3 tests pass. Note: test_init_in_memory_sets_wal_mode will return "memory" for in-memory DBs. Update the test:
Replace the WAL test assertion:
#[test]
fn test_init_in_memory_sets_wal_mode() {
// WAL is set but in-memory DBs report "memory" — verify no error on configure
let conn = init_in_memory().expect("should initialize");
let mode: String = conn
.query_row("PRAGMA journal_mode", [], |row| row.get(0))
.unwrap();
// In-memory databases report "memory" instead of "wal"
assert_eq!(mode, "memory");
}
Re-run tests. Expected: 3 pass.
- Step 6: Create error type
Create src-tauri/src/error.rs:
use serde::Serialize;
#[derive(Debug, Serialize)]
pub struct AppError {
pub message: String,
}
impl From<rusqlite::Error> for AppError {
fn from(e: rusqlite::Error) -> Self {
AppError {
message: e.to_string(),
}
}
}
impl From<std::io::Error> for AppError {
fn from(e: std::io::Error) -> Self {
AppError {
message: e.to_string(),
}
}
}
impl From<String> for AppError {
fn from(s: String) -> Self {
AppError { message: s }
}
}
impl std::fmt::Display for AppError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.message)
}
}
- Step 7: Wire up db module in lib.rs
Replace src-tauri/src/lib.rs:
mod db;
mod error;
use std::sync::Mutex;
pub struct AppState {
pub db: Mutex<rusqlite::Connection>,
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.setup(|app| {
let db_dir = app.path().app_data_dir()?;
std::fs::create_dir_all(&db_dir)?;
let db_path = db_dir.join("orchai.db");
let conn = db::init(&db_path).expect("Failed to initialize database");
app.manage(AppState {
db: Mutex::new(conn),
});
Ok(())
})
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
- Step 8: Verify it compiles and runs
cd /home/leclere/Projets/orchai/src-tauri
cargo build
Expected: compiles with no errors.
- Step 9: Commit
git add -A
git commit -m "feat: SQLite database with migration system and full schema"
Task 4: Project model + CRUD repository + tests
Files:
-
Create:
src-tauri/src/models/mod.rs -
Create:
src-tauri/src/models/project.rs -
Modify:
src-tauri/src/lib.rs(addmod models) -
Step 1: Write failing tests for Project CRUD
Create src-tauri/src/models/mod.rs:
pub mod project;
Create src-tauri/src/models/project.rs:
use rusqlite::{params, Connection, Result};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Project {
pub id: String,
pub name: String,
pub path: String,
pub cloned_from: Option<String>,
pub base_branch: String,
pub created_at: String,
}
impl Project {
pub fn insert(
conn: &Connection,
name: &str,
path: &str,
cloned_from: Option<&str>,
base_branch: &str,
) -> Result<Project> {
todo!()
}
pub fn list(conn: &Connection) -> Result<Vec<Project>> {
todo!()
}
pub fn get_by_id(conn: &Connection, id: &str) -> Result<Project> {
todo!()
}
pub fn update(conn: &Connection, id: &str, name: &str, base_branch: &str) -> Result<()> {
todo!()
}
pub fn delete(conn: &Connection, id: &str) -> Result<()> {
todo!()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db;
fn setup() -> Connection {
db::init_in_memory().expect("db init should succeed")
}
#[test]
fn test_insert_project_local_path() {
let conn = setup();
let project = Project::insert(&conn, "My Project", "/home/user/code/myproject", None, "main")
.expect("insert should succeed");
assert_eq!(project.name, "My Project");
assert_eq!(project.path, "/home/user/code/myproject");
assert!(project.cloned_from.is_none());
assert_eq!(project.base_branch, "main");
assert!(!project.id.is_empty());
assert!(!project.created_at.is_empty());
}
#[test]
fn test_insert_project_cloned() {
let conn = setup();
let project = Project::insert(
&conn,
"Cloned Project",
"/home/user/code/cloned",
Some("https://github.com/org/repo.git"),
"stable",
)
.expect("insert should succeed");
assert_eq!(project.cloned_from.as_deref(), Some("https://github.com/org/repo.git"));
assert_eq!(project.base_branch, "stable");
}
#[test]
fn test_list_projects_empty() {
let conn = setup();
let projects = Project::list(&conn).expect("list should succeed");
assert!(projects.is_empty());
}
#[test]
fn test_list_projects_returns_all() {
let conn = setup();
Project::insert(&conn, "A", "/path/a", None, "main").unwrap();
Project::insert(&conn, "B", "/path/b", None, "main").unwrap();
let projects = Project::list(&conn).expect("list should succeed");
assert_eq!(projects.len(), 2);
}
#[test]
fn test_get_by_id() {
let conn = setup();
let created = Project::insert(&conn, "Test", "/path/test", None, "main").unwrap();
let found = Project::get_by_id(&conn, &created.id).expect("get should succeed");
assert_eq!(found.id, created.id);
assert_eq!(found.name, "Test");
}
#[test]
fn test_get_by_id_not_found() {
let conn = setup();
let result = Project::get_by_id(&conn, "nonexistent");
assert!(result.is_err());
}
#[test]
fn test_update_project() {
let conn = setup();
let created = Project::insert(&conn, "Old Name", "/path", None, "main").unwrap();
Project::update(&conn, &created.id, "New Name", "develop").expect("update should succeed");
let updated = Project::get_by_id(&conn, &created.id).unwrap();
assert_eq!(updated.name, "New Name");
assert_eq!(updated.base_branch, "develop");
}
#[test]
fn test_delete_project() {
let conn = setup();
let created = Project::insert(&conn, "ToDelete", "/path", None, "main").unwrap();
Project::delete(&conn, &created.id).expect("delete should succeed");
let result = Project::get_by_id(&conn, &created.id);
assert!(result.is_err());
}
}
Add mod models; to src-tauri/src/lib.rs (after mod error;).
- Step 2: Run tests to verify they fail
cd /home/leclere/Projets/orchai/src-tauri
cargo test models::project::tests
Expected: 8 failures with not yet implemented.
- Step 3: Implement Project CRUD
Replace the todo!() implementations in src-tauri/src/models/project.rs:
pub fn insert(
conn: &Connection,
name: &str,
path: &str,
cloned_from: Option<&str>,
base_branch: &str,
) -> Result<Project> {
let id = Uuid::new_v4().to_string();
let now = chrono::Utc::now().to_rfc3339();
conn.execute(
"INSERT INTO projects (id, name, path, cloned_from, base_branch, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
params![id, name, path, cloned_from, base_branch, now],
)?;
Ok(Project {
id,
name: name.to_string(),
path: path.to_string(),
cloned_from: cloned_from.map(String::from),
base_branch: base_branch.to_string(),
created_at: now,
})
}
pub fn list(conn: &Connection) -> Result<Vec<Project>> {
let mut stmt = conn.prepare(
"SELECT id, name, path, cloned_from, base_branch, created_at FROM projects ORDER BY created_at DESC",
)?;
let rows = stmt.query_map([], |row| {
Ok(Project {
id: row.get(0)?,
name: row.get(1)?,
path: row.get(2)?,
cloned_from: row.get(3)?,
base_branch: row.get(4)?,
created_at: row.get(5)?,
})
})?;
rows.collect()
}
pub fn get_by_id(conn: &Connection, id: &str) -> Result<Project> {
conn.query_row(
"SELECT id, name, path, cloned_from, base_branch, created_at FROM projects WHERE id = ?1",
params![id],
|row| {
Ok(Project {
id: row.get(0)?,
name: row.get(1)?,
path: row.get(2)?,
cloned_from: row.get(3)?,
base_branch: row.get(4)?,
created_at: row.get(5)?,
})
},
)
}
pub fn update(conn: &Connection, id: &str, name: &str, base_branch: &str) -> Result<()> {
conn.execute(
"UPDATE projects SET name = ?1, base_branch = ?2 WHERE id = ?3",
params![name, base_branch, id],
)?;
Ok(())
}
pub fn delete(conn: &Connection, id: &str) -> Result<()> {
conn.execute("DELETE FROM projects WHERE id = ?1", params![id])?;
Ok(())
}
Add the missing imports at the top of the file:
use chrono;
- Step 4: Run tests to verify they pass
cd /home/leclere/Projets/orchai/src-tauri
cargo test models::project::tests
Expected: 8 tests pass.
- Step 5: Commit
git add -A
git commit -m "feat: Project model with CRUD operations and tests"
Task 5: Project Tauri commands
Files:
-
Create:
src-tauri/src/commands/mod.rs -
Create:
src-tauri/src/commands/project.rs -
Modify:
src-tauri/src/lib.rs -
Modify:
src-tauri/Cargo.toml(add tauri-plugin-dialog) -
Step 1: Add dialog plugin dependency
Add to src-tauri/Cargo.toml under [dependencies]:
tauri-plugin-dialog = "2"
Add to the capabilities/default.json permissions array:
"dialog:default"
- Step 2: Create commands module
Create src-tauri/src/commands/mod.rs:
pub mod project;
Create src-tauri/src/commands/project.rs:
use crate::error::AppError;
use crate::models::project::Project;
use crate::AppState;
use std::process::Command;
use tauri::State;
#[tauri::command]
pub fn create_project(
state: State<'_, AppState>,
name: String,
path_or_url: String,
base_branch: String,
) -> Result<Project, AppError> {
let is_url = path_or_url.starts_with("http://")
|| path_or_url.starts_with("https://")
|| path_or_url.starts_with("git@");
let (local_path, cloned_from) = if is_url {
let home = dirs::home_dir().ok_or_else(|| AppError::from("Cannot determine home directory".to_string()))?;
let clone_dir = home.join("orchai-repos").join(&name);
std::fs::create_dir_all(&clone_dir)?;
let output = Command::new("git")
.args(["clone", &path_or_url, clone_dir.to_str().unwrap()])
.output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(AppError::from(format!("git clone failed: {}", stderr)));
}
(clone_dir.to_string_lossy().to_string(), Some(path_or_url))
} else {
let path = std::path::Path::new(&path_or_url);
if !path.exists() {
return Err(AppError::from(format!("Path does not exist: {}", path_or_url)));
}
if !path.join(".git").exists() {
return Err(AppError::from(format!("Not a git repository: {}", path_or_url)));
}
(path_or_url, None)
};
let db = state.db.lock().unwrap();
let project = Project::insert(&db, &name, &local_path, cloned_from.as_deref(), &base_branch)?;
Ok(project)
}
#[tauri::command]
pub fn list_projects(state: State<'_, AppState>) -> Result<Vec<Project>, AppError> {
let db = state.db.lock().unwrap();
let projects = Project::list(&db)?;
Ok(projects)
}
#[tauri::command]
pub fn get_project(state: State<'_, AppState>, id: String) -> Result<Project, AppError> {
let db = state.db.lock().unwrap();
let project = Project::get_by_id(&db, &id)?;
Ok(project)
}
#[tauri::command]
pub fn update_project(
state: State<'_, AppState>,
id: String,
name: String,
base_branch: String,
) -> Result<(), AppError> {
let db = state.db.lock().unwrap();
Project::update(&db, &id, &name, &base_branch)?;
Ok(())
}
#[tauri::command]
pub fn delete_project(state: State<'_, AppState>, id: String) -> Result<(), AppError> {
let db = state.db.lock().unwrap();
Project::delete(&db, &id)?;
Ok(())
}
- Step 3: Add
dirsdependency
Add to src-tauri/Cargo.toml under [dependencies]:
dirs = "5"
- Step 4: Wire up commands in lib.rs
Replace src-tauri/src/lib.rs:
mod commands;
mod db;
mod error;
mod models;
use std::sync::Mutex;
pub struct AppState {
pub db: Mutex<rusqlite::Connection>,
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_dialog::init())
.setup(|app| {
let db_dir = app.path().app_data_dir()?;
std::fs::create_dir_all(&db_dir)?;
let db_path = db_dir.join("orchai.db");
let conn = db::init(&db_path).expect("Failed to initialize database");
app.manage(AppState {
db: Mutex::new(conn),
});
Ok(())
})
.invoke_handler(tauri::generate_handler![
commands::project::create_project,
commands::project::list_projects,
commands::project::get_project,
commands::project::update_project,
commands::project::delete_project,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
- Step 5: Verify it compiles
cd /home/leclere/Projets/orchai/src-tauri
cargo build
Expected: compiles with no errors.
- Step 6: Commit
git add -A
git commit -m "feat: Tauri commands for project CRUD with git clone support"
Task 6: TypeScript types + Tauri API wrappers
Files:
-
Create:
src/lib/types.ts -
Create:
src/lib/api.ts -
Step 1: Install frontend dependencies
cd /home/leclere/Projets/orchai
npm install react-router-dom
npm install @tauri-apps/plugin-dialog
- Step 2: Create TypeScript types
Create src/lib/types.ts:
export interface Project {
id: string;
name: string;
path: string;
cloned_from: string | null;
base_branch: string;
created_at: string;
}
- Step 3: Create API wrapper
Create src/lib/api.ts:
import { invoke } from "@tauri-apps/api/core";
import type { Project } from "./types";
export async function createProject(
name: string,
pathOrUrl: string,
baseBranch: string
): Promise<Project> {
return invoke("create_project", {
name,
pathOrUrl,
baseBranch,
});
}
export async function listProjects(): Promise<Project[]> {
return invoke("list_projects");
}
export async function getProject(id: string): Promise<Project> {
return invoke("get_project", { id });
}
export async function updateProject(
id: string,
name: string,
baseBranch: string
): Promise<void> {
return invoke("update_project", { id, name, baseBranch });
}
export async function deleteProject(id: string): Promise<void> {
return invoke("delete_project", { id });
}
- Step 4: Commit
git add -A
git commit -m "feat: TypeScript types and Tauri API wrappers for project CRUD"
Task 7: React app shell with router + layout
Files:
-
Modify:
src/App.tsx -
Create:
src/components/layout/AppLayout.tsx -
Create:
src/components/layout/Sidebar.tsx -
Step 1: Create Sidebar component
Create directory structure:
mkdir -p src/components/layout src/components/projects
Create src/components/layout/Sidebar.tsx:
import { useEffect, useState } from "react";
import { Link, useParams } from "react-router-dom";
import { listProjects } from "../../lib/api";
import type { Project } from "../../lib/types";
export default function Sidebar() {
const [projects, setProjects] = useState<Project[]>([]);
const { projectId } = useParams();
useEffect(() => {
listProjects().then(setProjects);
}, []);
// Expose a refresh function via custom event
useEffect(() => {
const handler = () => {
listProjects().then(setProjects);
};
window.addEventListener("orchai:refresh-projects", handler);
return () => window.removeEventListener("orchai:refresh-projects", handler);
}, []);
return (
<aside className="w-64 bg-gray-900 text-gray-100 flex flex-col h-screen">
<div className="p-4 border-b border-gray-700">
<h1 className="text-lg font-bold">Orchai</h1>
</div>
<nav className="flex-1 overflow-y-auto p-2">
<div className="flex items-center justify-between px-2 py-1 mb-1">
<span className="text-xs font-semibold text-gray-400 uppercase tracking-wider">
Projects
</span>
<Link
to="/projects/new"
className="text-gray-400 hover:text-white text-lg leading-none"
title="Add project"
>
+
</Link>
</div>
{projects.map((project) => (
<Link
key={project.id}
to={`/projects/${project.id}`}
className={`block px-3 py-2 rounded text-sm ${
projectId === project.id
? "bg-gray-700 text-white"
: "text-gray-300 hover:bg-gray-800 hover:text-white"
}`}
>
{project.name}
</Link>
))}
{projects.length === 0 && (
<p className="px-3 py-2 text-sm text-gray-500">No projects yet</p>
)}
</nav>
</aside>
);
}
- Step 2: Create AppLayout component
Create src/components/layout/AppLayout.tsx:
import { Outlet } from "react-router-dom";
import Sidebar from "./Sidebar";
export default function AppLayout() {
return (
<div className="flex h-screen">
<Sidebar />
<main className="flex-1 overflow-y-auto bg-gray-50">
<Outlet />
</main>
</div>
);
}
- Step 3: Set up router in App.tsx
Replace src/App.tsx:
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
import AppLayout from "./components/layout/AppLayout";
function EmptyState() {
return (
<div className="flex items-center justify-center h-full text-gray-400">
<p>Select a project or create a new one</p>
</div>
);
}
function App() {
return (
<BrowserRouter>
<Routes>
<Route element={<AppLayout />}>
<Route index element={<EmptyState />} />
<Route path="/projects/new" element={<div className="p-8">Create project (coming next)</div>} />
<Route path="/projects/:projectId" element={<div className="p-8">Project dashboard (coming next)</div>} />
<Route path="/projects/:projectId/edit" element={<div className="p-8">Edit project (coming next)</div>} />
<Route path="*" element={<Navigate to="/" replace />} />
</Route>
</Routes>
</BrowserRouter>
);
}
export default App;
- Step 4: Verify the shell renders
npm run tauri dev
Expected: window opens with dark sidebar on the left showing "Orchai" header, "Projects" section with "No projects yet" message, and a "+" button. Main area shows "Select a project or create a new one". Close after verifying.
- Step 5: Commit
git add -A
git commit -m "feat: React app shell with router, sidebar layout"
Task 8: Project list + create/edit form
Files:
-
Create:
src/components/projects/ProjectForm.tsx -
Create:
src/components/projects/ProjectDashboard.tsx -
Modify:
src/App.tsx -
Step 1: Create ProjectForm component
Create src/components/projects/ProjectForm.tsx:
import { useState, useEffect } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { open } from "@tauri-apps/plugin-dialog";
import { createProject, getProject, updateProject } from "../../lib/api";
export default function ProjectForm() {
const navigate = useNavigate();
const { projectId } = useParams();
const isEditing = Boolean(projectId);
const [name, setName] = useState("");
const [pathOrUrl, setPathOrUrl] = useState("");
const [baseBranch, setBaseBranch] = useState("main");
const [mode, setMode] = useState<"local" | "clone">("local");
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (projectId) {
getProject(projectId).then((project) => {
setName(project.name);
setPathOrUrl(project.path);
setBaseBranch(project.base_branch);
if (project.cloned_from) {
setMode("clone");
}
});
}
}, [projectId]);
async function handleBrowse() {
const selected = await open({ directory: true, multiple: false });
if (selected) {
setPathOrUrl(selected as string);
}
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError(null);
setLoading(true);
try {
if (isEditing && projectId) {
await updateProject(projectId, name, baseBranch);
} else {
await createProject(name, pathOrUrl, baseBranch);
}
window.dispatchEvent(new Event("orchai:refresh-projects"));
navigate("/");
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
setError(message);
} finally {
setLoading(false);
}
}
return (
<div className="max-w-lg mx-auto p-8">
<h2 className="text-xl font-bold mb-6">
{isEditing ? "Edit project" : "New project"}
</h2>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Project name
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
required
className="w-full border border-gray-300 rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
{!isEditing && (
<>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Source
</label>
<div className="flex gap-4">
<label className="flex items-center gap-1 text-sm">
<input
type="radio"
checked={mode === "local"}
onChange={() => setMode("local")}
/>
Local folder
</label>
<label className="flex items-center gap-1 text-sm">
<input
type="radio"
checked={mode === "clone"}
onChange={() => setMode("clone")}
/>
Clone from URL
</label>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
{mode === "local" ? "Folder path" : "Git URL"}
</label>
<div className="flex gap-2">
<input
type="text"
value={pathOrUrl}
onChange={(e) => setPathOrUrl(e.target.value)}
required
placeholder={
mode === "local"
? "/home/user/code/myproject"
: "https://github.com/org/repo.git"
}
className="flex-1 border border-gray-300 rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
{mode === "local" && (
<button
type="button"
onClick={handleBrowse}
className="px-3 py-2 bg-gray-200 rounded text-sm hover:bg-gray-300"
>
Browse
</button>
)}
</div>
</div>
</>
)}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Base branch
</label>
<input
type="text"
value={baseBranch}
onChange={(e) => setBaseBranch(e.target.value)}
required
className="w-full border border-gray-300 rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
{error && (
<div className="text-red-600 text-sm bg-red-50 border border-red-200 rounded p-2">
{error}
</div>
)}
<div className="flex gap-2">
<button
type="submit"
disabled={loading}
className="px-4 py-2 bg-blue-600 text-white rounded text-sm hover:bg-blue-700 disabled:opacity-50"
>
{loading ? "Saving..." : isEditing ? "Save" : "Create"}
</button>
<button
type="button"
onClick={() => navigate(-1)}
className="px-4 py-2 bg-gray-200 rounded text-sm hover:bg-gray-300"
>
Cancel
</button>
</div>
</form>
</div>
);
}
- Step 2: Create ProjectDashboard placeholder
Create src/components/projects/ProjectDashboard.tsx:
import { useEffect, useState } from "react";
import { useParams, Link, useNavigate } from "react-router-dom";
import { getProject, deleteProject } from "../../lib/api";
import type { Project } from "../../lib/types";
export default function ProjectDashboard() {
const { projectId } = useParams();
const navigate = useNavigate();
const [project, setProject] = useState<Project | null>(null);
useEffect(() => {
if (projectId) {
getProject(projectId).then(setProject);
}
}, [projectId]);
async function handleDelete() {
if (!projectId) return;
if (!window.confirm(`Delete project "${project?.name}"?`)) return;
await deleteProject(projectId);
window.dispatchEvent(new Event("orchai:refresh-projects"));
navigate("/");
}
if (!project) {
return <div className="p-8 text-gray-400">Loading...</div>;
}
return (
<div className="p-8">
<div className="flex items-center justify-between mb-6">
<h2 className="text-xl font-bold">{project.name}</h2>
<div className="flex gap-2">
<Link
to={`/projects/${project.id}/edit`}
className="px-3 py-1 bg-gray-200 rounded text-sm hover:bg-gray-300"
>
Edit
</Link>
<button
onClick={handleDelete}
className="px-3 py-1 bg-red-100 text-red-700 rounded text-sm hover:bg-red-200"
>
Delete
</button>
</div>
</div>
<div className="bg-white rounded-lg border border-gray-200 p-4 space-y-3">
<div>
<span className="text-sm text-gray-500">Path:</span>
<span className="ml-2 text-sm font-mono">{project.path}</span>
</div>
{project.cloned_from && (
<div>
<span className="text-sm text-gray-500">Cloned from:</span>
<span className="ml-2 text-sm font-mono">{project.cloned_from}</span>
</div>
)}
<div>
<span className="text-sm text-gray-500">Base branch:</span>
<span className="ml-2 text-sm font-mono">{project.base_branch}</span>
</div>
<div>
<span className="text-sm text-gray-500">Created:</span>
<span className="ml-2 text-sm">{new Date(project.created_at).toLocaleDateString()}</span>
</div>
</div>
<div className="mt-8 text-gray-400 text-sm">
Tracker surveillance and ticket processing will be available in the next update.
</div>
</div>
);
}
- Step 3: Wire up routes in App.tsx
Replace src/App.tsx:
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
import AppLayout from "./components/layout/AppLayout";
import ProjectForm from "./components/projects/ProjectForm";
import ProjectDashboard from "./components/projects/ProjectDashboard";
function EmptyState() {
return (
<div className="flex items-center justify-center h-full text-gray-400">
<p>Select a project or create a new one</p>
</div>
);
}
function App() {
return (
<BrowserRouter>
<Routes>
<Route element={<AppLayout />}>
<Route index element={<EmptyState />} />
<Route path="/projects/new" element={<ProjectForm />} />
<Route path="/projects/:projectId" element={<ProjectDashboard />} />
<Route path="/projects/:projectId/edit" element={<ProjectForm />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Route>
</Routes>
</BrowserRouter>
);
}
export default App;
- Step 4: Verify the full flow in the browser
npm run tauri dev
Test the following:
- Click "+" in the sidebar to navigate to the create form
- Fill in a name, select "Local folder", browse to an existing git repo, set base branch
- Click "Create" -- project appears in sidebar
- Click the project in sidebar -- dashboard shows project details
- Click "Edit" -- form pre-fills with project data
- Click "Delete" -- project removed from sidebar
- Step 5: Commit
git add -A
git commit -m "feat: project create/edit/delete UI with folder picker and git clone"
Task 9: Final verification + cleanup
Files:
-
Verify all tests pass
-
Clean up any scaffold files not needed
-
Step 1: Run all Rust tests
cd /home/leclere/Projets/orchai/src-tauri
cargo test
Expected: all tests pass (8 model tests + 3 db tests = 11 tests).
- Step 2: Run Rust clippy
cd /home/leclere/Projets/orchai/src-tauri
cargo clippy -- -D warnings
Expected: no warnings. If there are warnings, fix them.
- Step 3: Verify frontend builds
cd /home/leclere/Projets/orchai
npm run build
Expected: Vite build succeeds.
- Step 4: Clean up scaffold files
Remove any remaining scaffold assets that are not needed:
-
src/assets/react.svg(if still present) -
Any other default scaffold content
-
Step 5: Final integration test
npm run tauri dev
Test the complete flow one more time:
- Create a project pointing to a local git repo
- Verify it appears in sidebar
- View project dashboard
- Edit project name and base branch
- Delete the project
- Verify sidebar is empty again
- Step 6: Commit
git add -A
git commit -m "cleanup: remove scaffold assets, verify all tests pass"