1660 lines
44 KiB
Markdown
1660 lines
44 KiB
Markdown
# 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 (`- [x]`) 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/`
|
|
|
|
- [x] **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
|
|
```
|
|
|
|
- [x] **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`
|
|
|
|
- [x] **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
|
|
```
|
|
|
|
- [x] **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.
|
|
|
|
- [x] **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.
|
|
|
|
- [x] **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`
|
|
|
|
- [x] **Step 1: Install Tailwind**
|
|
|
|
```bash
|
|
cd /home/leclere/Projets/orchai
|
|
npm install -D tailwindcss @tailwindcss/vite
|
|
```
|
|
|
|
- [x] **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/**"],
|
|
},
|
|
},
|
|
}));
|
|
```
|
|
|
|
- [x] **Step 3: Replace index.css with Tailwind directives**
|
|
|
|
Replace the contents of `src/index.css`:
|
|
|
|
```css
|
|
@import "tailwindcss";
|
|
```
|
|
|
|
- [x] **Step 4: Delete App.css and clean up App.tsx**
|
|
|
|
Delete `src/App.css`.
|
|
|
|
Replace `src/App.tsx`:
|
|
|
|
```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;
|
|
```
|
|
|
|
- [x] **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
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
- [x] **Step 6: Verify Tailwind works**
|
|
|
|
```bash
|
|
npm run tauri dev
|
|
```
|
|
|
|
Expected: window opens, "Orchai" heading rendered with Tailwind styling (bold, large). Close after verifying.
|
|
|
|
- [x] **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`
|
|
|
|
- [x] **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<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");
|
|
}
|
|
}
|
|
```
|
|
|
|
- [x] **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`.
|
|
|
|
- [x] **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'))
|
|
);
|
|
```
|
|
|
|
- [x] **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<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(())
|
|
}
|
|
```
|
|
|
|
- [x] **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.
|
|
|
|
- [x] **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<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)
|
|
}
|
|
}
|
|
```
|
|
|
|
- [x] **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<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");
|
|
}
|
|
```
|
|
|
|
- [x] **Step 8: Verify it compiles and runs**
|
|
|
|
```bash
|
|
cd /home/leclere/Projets/orchai/src-tauri
|
|
cargo build
|
|
```
|
|
|
|
Expected: compiles with no errors.
|
|
|
|
- [x] **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`)
|
|
|
|
- [x] **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<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;`).
|
|
|
|
- [x] **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`.
|
|
|
|
- [x] **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<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:
|
|
|
|
```rust
|
|
use chrono;
|
|
```
|
|
|
|
- [x] **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.
|
|
|
|
- [x] **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)
|
|
|
|
- [x] **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"
|
|
```
|
|
|
|
- [x] **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<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(())
|
|
}
|
|
```
|
|
|
|
- [x] **Step 3: Add `dirs` dependency**
|
|
|
|
Add to `src-tauri/Cargo.toml` under `[dependencies]`:
|
|
|
|
```toml
|
|
dirs = "5"
|
|
```
|
|
|
|
- [x] **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<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");
|
|
}
|
|
```
|
|
|
|
- [x] **Step 5: Verify it compiles**
|
|
|
|
```bash
|
|
cd /home/leclere/Projets/orchai/src-tauri
|
|
cargo build
|
|
```
|
|
|
|
Expected: compiles with no errors.
|
|
|
|
- [x] **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`
|
|
|
|
- [x] **Step 1: Install frontend dependencies**
|
|
|
|
```bash
|
|
cd /home/leclere/Projets/orchai
|
|
npm install react-router-dom
|
|
npm install @tauri-apps/plugin-dialog
|
|
```
|
|
|
|
- [x] **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;
|
|
}
|
|
```
|
|
|
|
- [x] **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<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 });
|
|
}
|
|
```
|
|
|
|
- [x] **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`
|
|
|
|
- [x] **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<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>
|
|
);
|
|
}
|
|
```
|
|
|
|
- [x] **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 (
|
|
<div className="flex h-screen">
|
|
<Sidebar />
|
|
<main className="flex-1 overflow-y-auto bg-gray-50">
|
|
<Outlet />
|
|
</main>
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
- [x] **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 (
|
|
<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;
|
|
```
|
|
|
|
- [x] **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.
|
|
|
|
- [x] **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`
|
|
|
|
- [x] **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<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>
|
|
);
|
|
}
|
|
```
|
|
|
|
- [x] **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<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>
|
|
);
|
|
}
|
|
```
|
|
|
|
- [x] **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 (
|
|
<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;
|
|
```
|
|
|
|
- [x] **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
|
|
|
|
- [x] **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
|
|
|
|
- [x] **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).
|
|
|
|
- [x] **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.
|
|
|
|
- [x] **Step 3: Verify frontend builds**
|
|
|
|
```bash
|
|
cd /home/leclere/Projets/orchai
|
|
npm run build
|
|
```
|
|
|
|
Expected: Vite build succeeds.
|
|
|
|
- [x] **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
|
|
|
|
- [x] **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
|
|
|
|
- [x] **Step 6: Commit**
|
|
|
|
```bash
|
|
git add -A
|
|
git commit -m "cleanup: remove scaffold assets, verify all tests pass"
|
|
```
|