From ff08461151c77b31b1c5956da8cb1456b55e0ccb Mon Sep 17 00:00:00 2001 From: thibaud-leclere Date: Mon, 13 Apr 2026 09:13:05 +0200 Subject: [PATCH] plan: Phase 1 foundation implementation plan 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) --- .../2026-04-13-orchai-phase1-foundation.md | 1660 +++++++++++++++++ 1 file changed, 1660 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-13-orchai-phase1-foundation.md diff --git a/docs/superpowers/plans/2026-04-13-orchai-phase1-foundation.md b/docs/superpowers/plans/2026-04-13-orchai-phase1-foundation.md new file mode 100644 index 0000000..4bbe356 --- /dev/null +++ b/docs/superpowers/plans/2026-04-13-orchai-phase1-foundation.md @@ -0,0 +1,1660 @@ +# 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** + +```bash +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** + +```bash +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** + +```bash +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** + +```bash +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** + +```bash +npm run tauri dev +``` + +Expected: Tauri window opens with the default React starter page. Close it after verifying. + +- [ ] **Step 6: Commit** + +```bash +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** + +```bash +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`: + +```typescript +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`: + +```css +@import "tailwindcss"; +``` + +- [ ] **Step 4: Delete App.css and clean up App.tsx** + +Delete `src/App.css`. + +Replace `src/App.tsx`: + +```tsx +function App() { + return ( +
+

Orchai

+
+ ); +} + +export default App; +``` + +- [ ] **Step 5: Update Tauri config** + +In `src-tauri/tauri.conf.json`, update the `app` section: + +```json +{ + "$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** + +```bash +npm run tauri dev +``` + +Expected: window opens, "Orchai" heading rendered with Tailwind styling (bold, large). Close after verifying. + +- [ ] **Step 7: Commit** + +```bash +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]`: + +```toml +rusqlite = { version = "0.31", features = ["bundled"] } +uuid = { version = "1", features = ["v4", "serde"] } +chrono = { version = "0.4", features = ["serde"] } +``` + +Create `src-tauri/src/db.rs`: + +```rust +use rusqlite::{Connection, Result}; +use std::path::Path; + +pub fn init(db_path: &Path) -> Result { + todo!() +} + +pub fn init_in_memory() -> Result { + 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 = 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::, _>>() + .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** + +```bash +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`: + +```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`: + +```rust +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 { + let conn = Connection::open(db_path)?; + configure(&conn)?; + migrate(&conn)?; + Ok(conn) +} + +pub fn init_in_memory() -> Result { + 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** + +```bash +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: + +```rust + #[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`: + +```rust +use serde::Serialize; + +#[derive(Debug, Serialize)] +pub struct AppError { + pub message: String, +} + +impl From for AppError { + fn from(e: rusqlite::Error) -> Self { + AppError { + message: e.to_string(), + } + } +} + +impl From for AppError { + fn from(e: std::io::Error) -> Self { + AppError { + message: e.to_string(), + } + } +} + +impl From 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`: + +```rust +mod db; +mod error; + +use std::sync::Mutex; + +pub struct AppState { + pub db: Mutex, +} + +#[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** + +```bash +cd /home/leclere/Projets/orchai/src-tauri +cargo build +``` + +Expected: compiles with no errors. + +- [ ] **Step 9: Commit** + +```bash +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` (add `mod models`) + +- [ ] **Step 1: Write failing tests for Project CRUD** + +Create `src-tauri/src/models/mod.rs`: + +```rust +pub mod project; +``` + +Create `src-tauri/src/models/project.rs`: + +```rust +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, + 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 { + todo!() + } + + pub fn list(conn: &Connection) -> Result> { + todo!() + } + + pub fn get_by_id(conn: &Connection, id: &str) -> Result { + 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** + +```bash +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`: + +```rust + pub fn insert( + conn: &Connection, + name: &str, + path: &str, + cloned_from: Option<&str>, + base_branch: &str, + ) -> Result { + 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> { + 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 { + 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: + +```rust +use chrono; +``` + +- [ ] **Step 4: Run tests to verify they pass** + +```bash +cd /home/leclere/Projets/orchai/src-tauri +cargo test models::project::tests +``` + +Expected: 8 tests pass. + +- [ ] **Step 5: Commit** + +```bash +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]`: + +```toml +tauri-plugin-dialog = "2" +``` + +Add to the `capabilities/default.json` permissions array: + +```json +"dialog:default" +``` + +- [ ] **Step 2: Create commands module** + +Create `src-tauri/src/commands/mod.rs`: + +```rust +pub mod project; +``` + +Create `src-tauri/src/commands/project.rs`: + +```rust +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 { + 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, 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 { + 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 `dirs` dependency** + +Add to `src-tauri/Cargo.toml` under `[dependencies]`: + +```toml +dirs = "5" +``` + +- [ ] **Step 4: Wire up commands in lib.rs** + +Replace `src-tauri/src/lib.rs`: + +```rust +mod commands; +mod db; +mod error; +mod models; + +use std::sync::Mutex; + +pub struct AppState { + pub db: Mutex, +} + +#[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** + +```bash +cd /home/leclere/Projets/orchai/src-tauri +cargo build +``` + +Expected: compiles with no errors. + +- [ ] **Step 6: Commit** + +```bash +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** + +```bash +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`: + +```typescript +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`: + +```typescript +import { invoke } from "@tauri-apps/api/core"; +import type { Project } from "./types"; + +export async function createProject( + name: string, + pathOrUrl: string, + baseBranch: string +): Promise { + return invoke("create_project", { + name, + pathOrUrl, + baseBranch, + }); +} + +export async function listProjects(): Promise { + return invoke("list_projects"); +} + +export async function getProject(id: string): Promise { + return invoke("get_project", { id }); +} + +export async function updateProject( + id: string, + name: string, + baseBranch: string +): Promise { + return invoke("update_project", { id, name, baseBranch }); +} + +export async function deleteProject(id: string): Promise { + return invoke("delete_project", { id }); +} +``` + +- [ ] **Step 4: Commit** + +```bash +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: + +```bash +mkdir -p src/components/layout src/components/projects +``` + +Create `src/components/layout/Sidebar.tsx`: + +```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([]); + 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 ( + + ); +} +``` + +- [ ] **Step 2: Create AppLayout component** + +Create `src/components/layout/AppLayout.tsx`: + +```tsx +import { Outlet } from "react-router-dom"; +import Sidebar from "./Sidebar"; + +export default function AppLayout() { + return ( +
+ +
+ +
+
+ ); +} +``` + +- [ ] **Step 3: Set up router in App.tsx** + +Replace `src/App.tsx`: + +```tsx +import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom"; +import AppLayout from "./components/layout/AppLayout"; + +function EmptyState() { + return ( +
+

Select a project or create a new one

+
+ ); +} + +function App() { + return ( + + + }> + } /> + Create project (coming next)} /> + Project dashboard (coming next)} /> + Edit project (coming next)} /> + } /> + + + + ); +} + +export default App; +``` + +- [ ] **Step 4: Verify the shell renders** + +```bash +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** + +```bash +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`: + +```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(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 ( +
+

+ {isEditing ? "Edit project" : "New project"} +

+ +
+
+ + 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" + /> +
+ + {!isEditing && ( + <> +
+ +
+ + +
+
+ +
+ +
+ 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" && ( + + )} +
+
+ + )} + +
+ + 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" + /> +
+ + {error && ( +
+ {error} +
+ )} + +
+ + +
+
+
+ ); +} +``` + +- [ ] **Step 2: Create ProjectDashboard placeholder** + +Create `src/components/projects/ProjectDashboard.tsx`: + +```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(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
Loading...
; + } + + return ( +
+
+

{project.name}

+
+ + Edit + + +
+
+ +
+
+ Path: + {project.path} +
+ {project.cloned_from && ( +
+ Cloned from: + {project.cloned_from} +
+ )} +
+ Base branch: + {project.base_branch} +
+
+ Created: + {new Date(project.created_at).toLocaleDateString()} +
+
+ +
+ Tracker surveillance and ticket processing will be available in the next update. +
+
+ ); +} +``` + +- [ ] **Step 3: Wire up routes in App.tsx** + +Replace `src/App.tsx`: + +```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 ( +
+

Select a project or create a new one

+
+ ); +} + +function App() { + return ( + + + }> + } /> + } /> + } /> + } /> + } /> + + + + ); +} + +export default App; +``` + +- [ ] **Step 4: Verify the full flow in the browser** + +```bash +npm run tauri dev +``` + +Test the following: +1. Click "+" in the sidebar to navigate to the create form +2. Fill in a name, select "Local folder", browse to an existing git repo, set base branch +3. Click "Create" -- project appears in sidebar +4. Click the project in sidebar -- dashboard shows project details +5. Click "Edit" -- form pre-fills with project data +6. Click "Delete" -- project removed from sidebar + +- [ ] **Step 5: Commit** + +```bash +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** + +```bash +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** + +```bash +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** + +```bash +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** + +```bash +npm run tauri dev +``` + +Test the complete flow one more time: +1. Create a project pointing to a local git repo +2. Verify it appears in sidebar +3. View project dashboard +4. Edit project name and base branch +5. Delete the project +6. Verify sidebar is empty again + +- [ ] **Step 6: Commit** + +```bash +git add -A +git commit -m "cleanup: remove scaffold assets, verify all tests pass" +```