diff --git a/docs/code-standards.md b/docs/code-standards.md index bca9a29..82aa01b 100644 --- a/docs/code-standards.md +++ b/docs/code-standards.md @@ -64,11 +64,12 @@ export function useMyFeature() { ``` **Rules:** -- No async logic in stores — hooks own async + error handling +- Prefer hooks for UI orchestration and cross-store flows +- Domain stores may expose async CRUD actions when they call typed Tauri wrappers and own state updates - Always catch and log errors (never silent failures) - Memoize expensive deps with `useMemo` / `useCallback` if needed -### Tauri IPC Wrapper (src/lib/tauri.ts) +### Tauri IPC Wrappers (`src/lib/tauri-*.ts` + `src/lib/tauri.ts` barrel) ```ts // Typed wrappers, not raw invoke() @@ -82,9 +83,10 @@ export async function spawnCcs(args: { ``` **Rules:** +- Group wrappers by domain (`tauri-project`, `tauri-task`, `tauri-settings`, etc.) and re-export via `src/lib/tauri.ts` - Every wrapper has typed args + return - Always `Promise` return type -- Error handling at call site (try/catch in component/hook) +- Error handling at call site (try/catch in component/hook/store action) - No `any` argument types --- @@ -110,9 +112,10 @@ export const useProjectStore = create((set) => ({ **Rules:** - One store per domain (project, task, brainstorm, worktree, etc.) - All actions defined inside `create()` callback -- No async logic — stores hold state only +- Keep stores focused on state + domain actions; avoid cross-domain orchestration inside store actions +- Async CRUD actions are allowed in domain stores when backed by typed Tauri wrappers - No circular dependencies between stores -- Custom hooks subscribe and invoke actions +- Custom hooks subscribe and orchestrate multi-store flows --- diff --git a/docs/development-roadmap.md b/docs/development-roadmap.md index 10e2247..c8fd20b 100644 --- a/docs/development-roadmap.md +++ b/docs/development-roadmap.md @@ -15,19 +15,31 @@ Core mechanic: Rust spawns PTY `ccs [profile]`, streamed via xterm.js. UI hides | Route | Module | Status | |-------|--------|--------| -| `/onboarding` | Onboarding | ⬜ Planned | -| `/` | Dashboard | ⬜ Planned | -| `/decks` | Project Deck | ⬜ Planned | -| `/brainstorm` | Brainstorm | ⬜ Planned | -| `/brainstorm/:id` | Brainstorm Report | ⬜ Planned | -| `/generate-plan` | Generate Plan | ⬜ Planned | -| `/plans` | Plans | ⬜ Planned | -| `/plans/:id` | Plan Review | ⬜ Planned | -| `/tasks` | Tasks | ⬜ Planned | -| `/cook/:taskId` | Cook Standalone | ⬜ Planned | -| `/worktrees` | Worktrees | ⬜ Planned | -| `/settings` | Settings | ⬜ Planned | -| `/new-project` | New Project | ⬜ Planned | +| `/onboarding` | Onboarding | 🟡 UI prototype | +| `/` | Dashboard | 🟡 UI prototype | +| `/decks` | Project Deck | 🟡 UI prototype | +| `/brainstorm` | Brainstorm | 🟡 UI prototype | +| `/brainstorm/:id` | Brainstorm Report | 🟡 UI prototype | +| `/generate-plan` | Generate Plan | 🟡 UI prototype | +| `/plans` | Plans | 🟡 UI prototype | +| `/plans/:id` | Plan Review | 🟡 UI prototype | +| `/tasks` | Tasks | 🟡 UI prototype | +| `/cook/:taskId` | Cook Standalone | 🟡 UI prototype | +| `/worktrees` | Worktrees | 🟡 UI prototype | +| `/settings` | Settings | 🟡 UI prototype | +| `/new-project` | New Project | 🟡 UI prototype | + +### Completion Status + +| Layer | Status | +|-------|--------| +| UI Components | 🟡 ~80% (routes/components largely present; some flows still prototype/hardcoded) | +| CCS PTY Integration | ✅ Production-ready (ai.rs) | +| SQLite Persistence | ✅ M1 foundation implemented (r2d2 pool + v1 migration + SQLite schema) | +| Git Operations (git2) | ❌ Stubs only | +| Worktree CRUD | 🟡 Persistence CRUD implemented (`worktree_cmd`); git lifecycle wiring still partial | +| Store → IPC Integration | 🟡 Core stores wired to Tauri IPC (project/deck/task/plan/brainstorm/worktree/settings); some UI consumers still prototype | +| i18n Keys | 🟡 Structure ready, keys incomplete | --- @@ -54,118 +66,53 @@ Core mechanic: Rust spawns PTY `ccs [profile]`, streamed via xterm.js. UI hides --- -## Phase 1 — Foundation +## Milestones -**Goal:** App shell, routing, shared layout, SQLite schema, CCS detection. +### M1 — Data Foundation ✅ Complete (Wave 5 gate passed) +**Goal:** SQLite persistence + Rust CRUD commands + stores wired to IPC. +**Ref:** `plans/260224-1104-m1-data-foundation/plan.md` -- [ ] AppLayout: sidebar (collapsible) + header + outlet -- [ ] AppSidebar: project switcher, main nav, bottom nav, collapsed mode -- [ ] AppHeader: AI status indicator, theme toggle, notifications -- [ ] Routing: all 13 routes wired -- [ ] SQLite schema: all models migrated -- [ ] CCS detection: `ccs detect` → parse accounts, save to DB -- [ ] i18n setup: `react-i18next`, `en.json` + `vi.json` base keys -- [ ] Zustand stores: project, deck, task, worktree, settings +- [x] SQLite schema: versioned migration for all 9 models (v1 migration + schema_version) +- [x] Rust CRUD commands: project, deck, task, plan, phase, brainstorm, worktree, key-insight, settings +- [x] Tauri IPC wrappers: typed invoke functions for all CRUD ops +- [x] Zustand stores refactor: replace mock data with IPC calls +- [x] App initialization: DB setup on first launch, load active project ---- - -## Phase 2 — Onboarding - -**Goal:** First-run wizard. User exits with git repo + CCS detected + first project created. - -- [ ] 4-step wizard UI (progress sidebar) -- [ ] Step 1: Welcome screen -- [ ] Step 2: Git setup — local repo picker (Browse) or clone from GitHub URL -- [ ] Step 3: AI Tools detection — `ccs detect` with spinner → accounts display -- [ ] Step 4: Project setup — name + description + summary -- [ ] Navigate to `/` on completion - ---- - -## Phase 3 — Dashboard & Decks - -**Goal:** Project overview + deck management. - -- [ ] Dashboard: stats cards (tasks by status, worktree count) -- [ ] Dashboard: quick actions grid → all major routes -- [ ] Decks: deck list with active state toggle -- [ ] Decks: Create Deck dialog (name, description, based-on insight) -- [ ] New Project: `/new-project` form (git local/clone + name) - ---- - -## Phase 4 — Brainstorm - -**Goal:** AI ideation via CCS PTY terminal, save insights, generate plan. - -- [ ] Brainstorm: deck context bar + key insights dialog -- [ ] Brainstorm: PTY terminal panel (idle → running → completed states) -- [ ] Brainstorm: input area (Enter triggers session) -- [ ] Brainstorm: post-completion actions (View Report, Create Plan, Save Insight) -- [ ] Report Preview Dialog: article layout, prose typography -- [ ] Brainstorm Report page (`/brainstorm/:id`): key insights grid + action items -- [ ] Key Insights Dialog: list, continue session, delete - ---- +**Wave 5 quality gates:** Test gate PASS (`cargo check`, `npm run build`), review gate PASS (no release blocker). +**Post-gate follow-up:** High-priority settings lost-update race was fixed in Task #17 (`settings-store` mutation queue + safer patch merge). -## Phase 5 — Plans +### M2 — Core Workflows ⬜ Not Started +**Goal:** Wire real CCS PTY into brainstorm/cook. Plan generation + phase tracking. +**Depends on:** M1 -**Goal:** Plan generation from brainstorm output, phase tracking, markdown preview. +- [ ] Brainstorm terminal → real CCS session → save report to disk + DB +- [ ] Plan generation via CCS → parse phases → persist to DB +- [ ] Task auto-generation from plan phases +- [ ] Cook terminal → real CCS execution per task +- [ ] Cook progress: real output streaming (replace mock progress) -- [ ] Generate Plan (`/generate-plan`): phase indicator + xterm.js terminal simulation -- [ ] Plans list (`/plans`): plan cards with phase progress bar -- [ ] Plan Review (`/plans/:id`): phases checklist + markdown preview toggle -- [ ] Plan Review: related tasks section (`?new=true` loading state) -- [ ] Cook Sheet (right panel): xterm.js terminal executing plan via `ccs [profile]` +### M3 — Git & Worktree Integration ⬜ Not Started +**Goal:** git2 operations: status, commit, diff, worktree lifecycle. +**Depends on:** M1, partially M2 ---- - -## Phase 6 — Tasks - -**Goal:** Task CRUD, list + kanban views, cook integration. - -- [ ] Tasks: toolbar (view toggle, search, status filters, add task) -- [ ] Tasks: list view with status/priority badges + Cook button -- [ ] Tasks: kanban view (4 columns: Backlog, Todo, In Progress, Done) -- [ ] Add Task dialog: name, description, priority -- [ ] Cook Sheet (right panel): PTY terminal, changed files summary, Merge/Discard -- [ ] Cook standalone (`/cook/:taskId`): progress bar, status steps, preview changes dialog - ---- - -## Phase 7 — Worktrees - -**Goal:** Git worktree lifecycle management via UI. - -- [ ] Worktrees: list grouped by status (Active, Ready to Merge, Merged) -- [ ] Worktrees: Active card actions (View Files, Pause, Stop) -- [ ] Merge Dialog: strategy selector (merge/squash/rebase) + options (run tests, delete after) -- [ ] Rust: `worktree_create`, `worktree_list`, `worktree_merge`, `worktree_cleanup` commands - ---- - -## Phase 8 — Settings - -**Goal:** App config — language, CCS provider mapping, git defaults, editor prefs. - -- [ ] Settings: 4 tabs (General, AI & Commands, Git, Editor) -- [ ] General: language selector (en/vi) -- [ ] AI & Commands: CCS accounts list + command→provider mapping table -- [ ] Git: default branch + worktrees directory inputs -- [ ] Editor: theme, auto-save toggle, font size - ---- +- [ ] git2: `git_status`, `git_diff`, `git_commit` +- [ ] git2: `worktree_create`, `worktree_list`, `worktree_remove` +- [ ] git2: `worktree_merge` (merge/squash/rebase strategies) +- [ ] Wire worktree UI to real git2 commands +- [ ] Cook flow: create worktree → execute → show diff → merge -## Phase 9 — Polish & Release +### M4 — Onboarding + Settings + Polish ⬜ Not Started +**Goal:** First-run wizard, settings persistence, error handling, release prep. +**Depends on:** M1-M3 +- [ ] Onboarding wizard: real CCS detect + git repo picker + project creation +- [ ] Settings: persist to DB, load on app start - [ ] Error boundaries on all routes - [ ] Empty states for all list views - [ ] Toast notifications system-wide -- [ ] Offline resilience (CCS not installed, git not configured) -- [ ] CCS not installed → install guide deep-link +- [ ] Offline resilience (CCS/git not installed → guide) - [ ] Cross-platform testing (macOS, Windows, Linux) -- [ ] App icon + metadata (Tauri `tauri.conf.json`) -- [ ] Build pipeline (GitHub Actions) +- [ ] App icon + metadata + build pipeline --- diff --git a/docs/project-changelog.md b/docs/project-changelog.md new file mode 100644 index 0000000..d36a315 --- /dev/null +++ b/docs/project-changelog.md @@ -0,0 +1,30 @@ +# Project Changelog — VividKit Desktop + +## 2026-02-24 + +### Added +- M1 Data Foundation backend persistence layer: + - SQLite DB bootstrap on app startup via `db::init_db(app_data_dir)`. + - Connection pool with PRAGMA initialization (`foreign_keys=ON`, `journal_mode=WAL`). + - Versioned migration baseline (`schema_version`, v1 schema, seed row for `app_settings`). +- New Rust command modules for M1 data domains: + - `project`, `deck`, `task`, `plan`, `brainstorm`, `worktree_cmd`, `settings`, `ccs_profile`. +- Dynamic CCS profile listing command registration: + - `list_ccs_profiles` backend command is available in invoke handler. +- TypeScript domain IPC wrapper split under `src/lib/tauri-*.ts` and barrel export via `src/lib/tauri.ts`. + +### Changed +- Zustand stores were refactored from mock/in-memory flows to real IPC-backed CRUD for core M1 domains: + - `project-store`, `deck-store`, `task-store`, `plan-store`, `brainstorm-store`, `worktree-store`, `settings-store`. +- App initialization now loads persisted settings/projects and restores `lastActiveProjectId` when available. +- Follow-up hardening after Wave 5 review: `settings-store` now serializes settings writes with a mutation queue and applies safer patch merge logic to avoid lost updates under concurrent writes (Task #17 completed). +- Settings CCS Test Console now loads profile options from backend `list_ccs_profiles` instead of a hardcoded profile list, with fallback to `default` when discovery is unavailable. + +### Quality Gates +- Wave 5 test gate: PASS (`cargo check`, `npm run build`). +- Wave 5 code review gate: PASS (no release blocker for M1 Data Foundation). +- Review follow-up closure: High settings lost-update finding resolved in Task #17. + +### Known Issues / Follow-up +- **M4 scope (prototype):** Onboarding AI Tools detection still uses simulated/hardcoded UI states and account badges. +- **M4 follow-up (partial):** Onboarding Git Setup Browse now uses Tauri dialog plugin API; broader onboarding real detect/clone workflow is still prototype. diff --git a/docs/system-architecture.md b/docs/system-architecture.md index 6640eff..9af7177 100644 --- a/docs/system-architecture.md +++ b/docs/system-architecture.md @@ -73,6 +73,18 @@ CookView component | Tasks | Task CRUD, Kanban state | project context | | Cook | PTY terminal sessions, file tree | project path, worktrees | +## M1 Data Foundation Snapshot (2026-02-24) + +- SQLite foundation is now initialized at app startup via `db::init_db(app_data_dir)` in `src-tauri/src/lib.rs`. +- DB runtime uses `r2d2_sqlite` pool with per-connection PRAGMA (`foreign_keys=ON`, `journal_mode=WAL`) in `src-tauri/src/db/mod.rs`. +- Versioned migration baseline exists (`schema_version` + v1 schema for projects, decks, plans/phases, tasks, brainstorm, worktrees, settings) in `src-tauri/src/db/migrations.rs`. +- CRUD command surface is registered for M1 domains in `src-tauri/src/lib.rs` (`project`, `deck`, `task`, `plan`, `brainstorm`, `worktree_cmd`, `settings`). +- Frontend now consumes domain IPC wrappers (`src/lib/tauri-*.ts`) and persists state via Zustand domain stores (`src/stores/*-store.ts`) instead of purely mock/in-memory data. + +### Known Non-Blocking Follow-up + +- Wave 5 review identified a high-priority settings lost-update race under concurrent writes. Follow-up Task #17 fixed the frontend side in `src/stores/settings-store.ts` using a mutation queue + safer patch merge. M1 remains non-blocked and docs now reflect the resolved follow-up. + ## Key Technology Choices | Decision | Rationale | diff --git a/package-lock.json b/package-lock.json index d44b519..617bde5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@dnd-kit/utilities": "^3.2.2", "@monaco-editor/react": "^4.7.0", "@tauri-apps/api": "^2", + "@tauri-apps/plugin-dialog": "^2.6.0", "@tauri-apps/plugin-opener": "^2", "@xterm/addon-fit": "^0.11.0", "@xterm/addon-web-links": "^0.12.0", @@ -4081,6 +4082,15 @@ "node": ">= 10" } }, + "node_modules/@tauri-apps/plugin-dialog": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.6.0.tgz", + "integrity": "sha512-q4Uq3eY87TdcYzXACiYSPhmpBA76shgmQswGkSVio4C82Sz2W4iehe9TnKYwbq7weHiL88Yw19XZm7v28+Micg==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.8.0" + } + }, "node_modules/@tauri-apps/plugin-opener": { "version": "2.5.3", "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.5.3.tgz", diff --git a/package.json b/package.json index 197c346..d681137 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "@dnd-kit/utilities": "^3.2.2", "@monaco-editor/react": "^4.7.0", "@tauri-apps/api": "^2", + "@tauri-apps/plugin-dialog": "^2.6.0", "@tauri-apps/plugin-opener": "^2", "@xterm/addon-fit": "^0.11.0", "@xterm/addon-web-links": "^0.12.0", diff --git a/plans/260224-1104-m1-data-foundation/phase-01-db-module-and-schema.md b/plans/260224-1104-m1-data-foundation/phase-01-db-module-and-schema.md new file mode 100644 index 0000000..4b4c860 --- /dev/null +++ b/plans/260224-1104-m1-data-foundation/phase-01-db-module-and-schema.md @@ -0,0 +1,223 @@ +# Phase 1 — DB Module + Schema Migration + +## Context +- Brainstorm: `plans/reports/brainstorm-260224-1104-mvp-completion-strategy.md` +- Roadmap: `docs/development-roadmap.md` +- Existing models: `src-tauri/src/models/` (project.rs, task.rs, config.rs — incomplete) + +## Overview +- **Priority:** P1 (blocks all other phases) +- **Status:** Pending +- **Description:** Create rusqlite DB module with connection management, versioned migrations, and full schema for all models. + +## Requirements +- SQLite file at `app_data_dir()/vividkit.db` +- WAL journal mode for concurrent reads +- Versioned migration system (simple `schema_version` table) +- All 10 tables created: projects, ccs_accounts, decks, key_insights, plans, phases, tasks, worktrees, brainstorm_sessions, app_settings + +## Files to Create +- `src-tauri/src/db/mod.rs` — DB state struct, init function, connection pool +- `src-tauri/src/db/migrations.rs` — Migration runner + V1 schema SQL + +## Files to Modify +- `src-tauri/src/lib.rs` — Add DB init in setup, register as managed state +- `Cargo.toml` — Add `uuid`, `r2d2`, `r2d2_sqlite` dependencies + + + + +## Implementation Steps + + +1. Create `src-tauri/src/db/mod.rs`: + - Use `r2d2::Pool` instead of `Mutex` + - `DbState` wraps `r2d2::Pool` + - `init_db(app_data_dir: PathBuf) -> Result` — open/create DB, set WAL mode, run migrations + - Pool allows concurrent readers without lock contention + + + - Set `PRAGMA foreign_keys = ON` via pool's `connection_customizer` callback (not once at init), so every connection from the pool has FK enforcement: + ```rust + #[derive(Debug)] + struct ForeignKeyCustomizer; + impl r2d2::CustomizeConnection for ForeignKeyCustomizer { + fn on_acquire(&self, conn: &mut rusqlite::Connection) -> Result<(), rusqlite::Error> { + conn.execute_batch("PRAGMA foreign_keys = ON; PRAGMA journal_mode = WAL;")?; + Ok(()) + } + } + // Use: SqliteConnectionManager::file(path).with_init(...) + ``` + + +2. Create `src-tauri/src/db/migrations.rs`: + - `schema_version` table: `CREATE TABLE IF NOT EXISTS schema_version (version INTEGER)` + - `run_migrations(conn: &Connection) -> Result<(), String>` + - Migration runner pseudo-code: + ``` + let current_version = SELECT version FROM schema_version LIMIT 1 (default 0 if empty) + if current_version >= TARGET_VERSION: return Ok(()) + if current_version > CURRENT_APP_VERSION: return Err("DB version newer than app — upgrade required") + BEGIN EXCLUSIVE TRANSACTION + run V1 migration SQL + INSERT OR IGNORE INTO app_settings (id) VALUES (1) + UPDATE schema_version SET version = 1 (or INSERT if empty) + COMMIT + ``` + - Wrap entire V1 migration in `BEGIN EXCLUSIVE TRANSACTION / COMMIT` + - V1 migration with all 10 tables: + +```sql +-- projects +CREATE TABLE projects ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + description TEXT, + git_path TEXT NOT NULL, + ccs_connected INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL +); + +-- ccs_accounts +CREATE TABLE ccs_accounts ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + provider TEXT NOT NULL, + email TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'active' +); + +-- decks +CREATE TABLE decks ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + name TEXT NOT NULL, + description TEXT, + is_active INTEGER NOT NULL DEFAULT 0, + based_on_insight_id TEXT, -- soft reference, no FK (circular dep with key_insights) + created_at TEXT NOT NULL +); + +-- key_insights +CREATE TABLE key_insights ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + deck_id TEXT NOT NULL REFERENCES decks(id) ON DELETE CASCADE, + title TEXT NOT NULL, + report_path TEXT NOT NULL, + created_at TEXT NOT NULL +); + +-- plans +CREATE TABLE plans ( + id TEXT PRIMARY KEY, + deck_id TEXT NOT NULL REFERENCES decks(id) ON DELETE CASCADE, + name TEXT NOT NULL, + report_path TEXT, + plan_path TEXT, + created_at TEXT NOT NULL +); + +-- phases +CREATE TABLE phases ( + id TEXT PRIMARY KEY, + plan_id TEXT NOT NULL REFERENCES plans(id) ON DELETE CASCADE, + name TEXT NOT NULL, + description TEXT, + file_path TEXT, + sort_order INTEGER NOT NULL, + status TEXT NOT NULL DEFAULT 'pending' +); + +-- tasks +CREATE TABLE tasks ( + id TEXT PRIMARY KEY, + deck_id TEXT NOT NULL REFERENCES decks(id) ON DELETE CASCADE, + type TEXT NOT NULL DEFAULT 'custom', + name TEXT NOT NULL, + description TEXT, + status TEXT NOT NULL DEFAULT 'backlog', + priority TEXT NOT NULL DEFAULT 'medium', + plan_id TEXT REFERENCES plans(id), + phase_id TEXT REFERENCES phases(id), + worktree_name TEXT +); + +-- worktrees (files_changed omitted — computed at runtime via git2) +-- Red Team: files_changed drop from schema — 2026-02-24 +CREATE TABLE worktrees ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + task_id TEXT NOT NULL REFERENCES tasks(id), + branch TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'active', + merged_at TEXT, + created_at TEXT NOT NULL +); + +-- brainstorm_sessions +CREATE TABLE brainstorm_sessions ( + id TEXT PRIMARY KEY, + deck_id TEXT NOT NULL REFERENCES decks(id) ON DELETE CASCADE, + prompt TEXT NOT NULL, + report_path TEXT, + status TEXT NOT NULL DEFAULT 'idle', + created_at TEXT NOT NULL +); + +-- app_settings (single row) +-- Red Team: activeProjectId in DB only — 2026-02-24 +CREATE TABLE app_settings ( + id INTEGER PRIMARY KEY CHECK (id = 1), + language TEXT NOT NULL DEFAULT 'en', + theme TEXT NOT NULL DEFAULT 'dark', + auto_save INTEGER NOT NULL DEFAULT 1, + font_size INTEGER NOT NULL DEFAULT 14, + default_branch TEXT NOT NULL DEFAULT 'main', + worktrees_dir TEXT NOT NULL DEFAULT '.worktrees', + command_providers TEXT NOT NULL DEFAULT '{}', + last_active_project_id TEXT +); + +-- FK Indexes +-- Red Team: FK indexes — 2026-02-24 +CREATE INDEX idx_ccs_accounts_project ON ccs_accounts(project_id); +CREATE INDEX idx_decks_project ON decks(project_id); +CREATE INDEX idx_tasks_deck ON tasks(deck_id); +CREATE INDEX idx_plans_deck ON plans(deck_id); +CREATE INDEX idx_phases_plan ON phases(plan_id); +CREATE INDEX idx_brainstorm_deck ON brainstorm_sessions(deck_id); + +-- Seed default settings row (INSERT OR IGNORE — safe to re-run) +INSERT OR IGNORE INTO app_settings (id) VALUES (1); +``` + +3. Update `src-tauri/src/lib.rs`: + - `use db::DbState;` + - In `setup()`: resolve `app_data_dir`, call `db::init_db()`, `app.manage(db_state)` + - **Do NOT set PRAGMA here** — handled per-connection via pool customizer (see step 1) + + +4. Add to `Cargo.toml`: + ```toml + uuid = { version = "1", features = ["v4"] } + r2d2 = "0.8" + r2d2_sqlite = "0.22" + ``` + +## Todo +- [ ] Create `db/mod.rs` with `DbState` (r2d2 pool) and `init_db` +- [ ] Create `db/migrations.rs` with V1 schema (EXCLUSIVE TRANSACTION) +- [ ] Update `lib.rs` — DB init + managed state (no PRAGMA in lib.rs) +- [ ] Add `uuid`, `r2d2`, `r2d2_sqlite` to Cargo.toml +- [ ] Verify app launches with DB created + +## Success Criteria +- App starts → creates `vividkit.db` in app data dir +- All 10 tables exist with correct schema +- `schema_version` tracks version = 1 +- `app_settings` has default row (INSERT OR IGNORE is idempotent) +- WAL mode enabled, foreign keys ON per-connection via pool customizer +- Migration is idempotent (safe to re-run) +- DB version > app version → returns error instead of running migration diff --git a/plans/260224-1104-m1-data-foundation/phase-02-core-entity-commands.md b/plans/260224-1104-m1-data-foundation/phase-02-core-entity-commands.md new file mode 100644 index 0000000..b072cbb --- /dev/null +++ b/plans/260224-1104-m1-data-foundation/phase-02-core-entity-commands.md @@ -0,0 +1,146 @@ +# Phase 2 — Core Entity Commands + +## Context +- Phase 1: `phase-01-db-module-and-schema.md` (must complete first) +- Existing TS types: `src/types/` — source of truth for field names +- Existing Rust models: `src-tauri/src/models/` — need realignment + +## Overview +- **Priority:** P1 +- **Status:** Pending +- **Description:** Rust CRUD commands + updated models for Project, CcsAccount, Deck, AppSettings. + +## Key Insights +- Existing `models/project.rs` and `models/config.rs` need rewrite to match TS types +- Each command file < 200 lines → split by entity +- Use `State<'_, DbState>` pattern from `spawn_ccs` as reference +- Return `Result` consistently + +- Use Rust enums with `#[derive(Serialize, Deserialize)]` for all discriminated string fields (status, priority, provider) — never bare `String` for domain-constrained values + +- Commands modifying multiple rows MUST use explicit transactions + +## Files to Create +- `src-tauri/src/commands/project.rs` — project + ccs_account CRUD +- `src-tauri/src/commands/deck.rs` — deck CRUD +- `src-tauri/src/commands/settings.rs` — get/update settings + +## Files to Modify +- `src-tauri/src/models/project.rs` — Align with TS `Project` + `CcsAccount` +- `src-tauri/src/models/config.rs` → rename to `settings.rs` — Align with TS `AppSettings` +- `src-tauri/src/models/mod.rs` — Add new model exports +- `src-tauri/src/lib.rs` — Register new commands + +## Files to Create (Models) +- `src-tauri/src/models/deck.rs` — Deck struct +- `src-tauri/src/models/ccs_account.rs` — CcsAccount struct + +## Implementation Steps + +### Models (align Rust ↔ TS) + + +1. Define shared enums in `src-tauri/src/models/enums.rs`: +```rust +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "snake_case")] +pub enum CcsAccountStatus { Active, Paused, Exhausted } + +// NOTE: CcsProvider removed — provider field is String (dynamic profiles from ~/.ccs/) +// Updated: Validation Session 1 - CCS profiles are user-configured, not fixed enum +``` + +2. Rewrite `models/project.rs`: +```rust +#[derive(Debug, Serialize, Deserialize)] +pub struct Project { + pub id: String, + pub name: String, + pub description: Option, + pub git_path: String, + pub ccs_connected: bool, + pub ccs_accounts: Vec, // populated via join — see list_projects + pub created_at: String, +} +``` + +3. Create `models/ccs_account.rs`: +```rust +pub struct CcsAccount { + pub id: String, + pub project_id: String, + pub provider: String, // dynamic profile name from ~/.ccs/ — not enum + pub email: String, + pub status: CcsAccountStatus, // enum, not String +} +``` + +4. Create `models/deck.rs`, rename `config.rs` → `settings.rs` + +### Commands + + +5. `commands/project.rs` — 5 commands: + - `create_project(name, description, git_path) -> Project` + - **`git_path` validation (MANDATORY):** canonicalize path via `std::fs::canonicalize()`, verify it is a git repo (check `.git` dir exists or use `git2::Repository::open()`), return `Err` if invalid before any DB write + - `list_projects() -> Vec` — see N+1 rule below + - `get_project(id) -> Project` + - `update_project(id, name?, description?) -> Project` + - `delete_project(id) -> ()` + + + **`list_projects` query strategy — NO N+1:** + - Query 1: `SELECT * FROM projects` + - Query 2: `SELECT * FROM ccs_accounts` (all accounts, no per-project loop) + - Group accounts by `project_id` in Rust using a `HashMap>` + - Merge into `Vec` in memory + - Never loop over projects and query accounts individually + + +6. `commands/deck.rs` — 5 commands: + - `create_deck(project_id, name, description?, based_on_insight_id?) -> Deck` + - `list_decks(project_id) -> Vec` + - `set_active_deck(id) -> Deck` + - **MUST use `BEGIN IMMEDIATE TRANSACTION`:** + 1. `UPDATE decks SET is_active = 0 WHERE project_id = (SELECT project_id FROM decks WHERE id = ?)` + 2. `UPDATE decks SET is_active = 1 WHERE id = ?` + 3. `COMMIT` + - Both UPDATEs inside single transaction — no partial state + - `update_deck(id, name?, description?) -> Deck` + - `delete_deck(id) -> ()` + +7. `commands/settings.rs` — 2 commands: + - `get_settings() -> AppSettings` + - `update_settings(settings: AppSettings) -> AppSettings` + + +8. `commands/ccs_profile.rs` — 1 command: + - `list_ccs_profiles() -> Vec` — scan `~/.ccs/` directory: + - Read `config.yaml` for `profiles` section + - Scan `*.settings.json` files for file-based profiles + - Check `config.accounts` for OAuth profiles in `instances/` + - Return merged list: `CcsProfile { name: String, profile_type: String }` + - **NOTE:** UUID + timestamps generated in Rust backend (not frontend) + +9. Update `lib.rs` — register all 13 new commands + +## Todo +- [ ] Create `models/enums.rs` with typed enums for status (no CcsProvider enum — provider is String) +- [ ] Rewrite `models/project.rs` aligned with TS types +- [ ] Create `models/ccs_account.rs`, `models/deck.rs` +- [ ] Rename `models/config.rs` → `models/settings.rs`, rewrite +- [ ] Create `commands/project.rs` (5 CRUD commands, git_path validation, no N+1) +- [ ] Create `commands/deck.rs` (5 CRUD commands, set_active_deck with IMMEDIATE TRANSACTION) +- [ ] Create `commands/settings.rs` (2 commands) +- [ ] Create `commands/ccs_profile.rs` (1 command — scan ~/.ccs/) +- [ ] Register all 13 commands in `lib.rs` +- [ ] Compile check passes + +## Success Criteria +- All 12 commands registered and callable via `invoke()` +- `git_path` rejected if path is not a valid git repo +- Project CRUD works: create, list (with accounts, no N+1), get, update, delete +- Deck CRUD works with active-state toggling inside transaction +- Settings get/update persists between app restarts +- Cascade delete: deleting project removes its accounts + decks +- Enum values serialize/deserialize correctly to/from JSON strings diff --git a/plans/260224-1104-m1-data-foundation/phase-03-content-entity-commands.md b/plans/260224-1104-m1-data-foundation/phase-03-content-entity-commands.md new file mode 100644 index 0000000..477b1d1 --- /dev/null +++ b/plans/260224-1104-m1-data-foundation/phase-03-content-entity-commands.md @@ -0,0 +1,168 @@ +# Phase 3 — Content Entity Commands + +## Context +- Phase 2: `phase-02-core-entity-commands.md` (reuses same patterns) +- TS types: `src/types/` — field alignment source + +## Overview +- **Priority:** P1 +- **Status:** Pending +- **Description:** Rust CRUD commands for Task, Plan, Phase, BrainstormSession, KeyInsight, Worktree. + +## Files to Create +- `src-tauri/src/commands/task.rs` — task CRUD + status transitions +- `src-tauri/src/commands/plan.rs` — plan + phase CRUD +- `src-tauri/src/commands/brainstorm.rs` — session + key insight CRUD +- `src-tauri/src/commands/worktree_cmd.rs` — worktree CRUD (not git ops, just DB records) + +## Files to Create (Models) +- `src-tauri/src/models/task.rs` — Rewrite with full fields +- `src-tauri/src/models/plan.rs` — Plan + Phase structs +- `src-tauri/src/models/brainstorm.rs` — BrainstormSession + KeyInsight +- `src-tauri/src/models/worktree.rs` — Worktree struct + +## Files to Modify +- `src-tauri/src/models/mod.rs` — Export new models +- `src-tauri/src/lib.rs` — Register commands + +## Implementation Steps + +### Models + + +1. Add enums to `models/enums.rs` (extend from Phase 2): +```rust +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "snake_case")] +pub enum TaskStatus { Backlog, Todo, InProgress, Done } + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "snake_case")] +pub enum TaskPriority { Low, Medium, High } + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "snake_case")] +pub enum TaskType { Generated, Custom } + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "snake_case")] +pub enum PhaseStatus { Pending, InProgress, Completed, Blocked } + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "snake_case")] +pub enum BrainstormStatus { Idle, Running, Done, Failed } + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "snake_case")] +pub enum WorktreeStatus { Active, Merged, Abandoned } +``` + +2. Rewrite `models/task.rs`: +```rust +pub struct Task { + pub id: String, + pub deck_id: String, + pub r#type: TaskType, // enum + pub name: String, + pub description: Option, + pub status: TaskStatus, // enum + pub priority: TaskPriority, // enum + pub plan_id: Option, + pub phase_id: Option, + pub worktree_name: Option, +} +``` + +3. Create `models/plan.rs` (Plan + Phase), `models/brainstorm.rs` (Session + Insight), `models/worktree.rs` + - `Worktree` struct: no `files_changed` field — computed at runtime via git2 + +### Commands — MVP Audit + + +**MVP-required vs deferred:** + +| Command | MVP? | Store Consumer | +|---------|------|----------------| +| `create_task` | YES | `task-store.ts` → `addTask` | +| `list_tasks` | YES | `task-store.ts` → `loadTasks` | +| `get_task` | YES | `task-store.ts` → detail view | +| `update_task` | YES | `task-store.ts` → `updateTask` | +| `update_task_status` | YES | `task-store.ts` → `updateTaskStatus` | +| `delete_task` | YES | `task-store.ts` → `removeTask` | +| `create_plan` | YES | `plan-store.ts` → `addPlan` | +| `list_plans` | YES | `plan-store.ts` → `loadPlans` | +| `get_plan` | YES | `plan-store.ts` → detail view | +| `delete_plan` | YES | `plan-store.ts` → `removePlan` | +| `create_phase` | YES | `plan-store.ts` → `addPhase` | +| `update_phase_status` | YES | `plan-store.ts` → `updatePhaseStatus` | +| `delete_phase` | YES | `plan-store.ts` → `removePhase` | +| `create_brainstorm_session` | YES | `brainstorm-store.ts` → `addSession` | +| `list_brainstorm_sessions` | YES | `brainstorm-store.ts` → `loadSessions` | +| `update_brainstorm_session` | YES | `brainstorm-store.ts` → `updateSession` | +| `create_key_insight` | YES | `brainstorm-store.ts` → `addInsight` | +| `list_key_insights` | YES | `brainstorm-store.ts` → `loadInsights` | +| `delete_key_insight` | YES | `brainstorm-store.ts` → `removeInsight` | +| `create_worktree_record` | YES | `worktree-store.ts` → `addWorktree` | +| `list_worktree_records` | YES | `worktree-store.ts` → `loadWorktrees` | +| `update_worktree_record` | YES | `worktree-store.ts` → `updateWorktree` | +| `delete_worktree_record` | YES | `worktree-store.ts` → `removeWorktree` | + +**Deferred (not wired to any store in MVP):** none identified — all 23 are consumed. +`update_plan` (rename plan) — DEFERRED, no UI for it in MVP scope. Do not implement. + +**Total MVP commands this phase: 23** + +### Command Implementations + +4. `commands/task.rs` — 6 commands: + - `create_task(deck_id, name, description?, priority?, type?) -> Task` + - `list_tasks(deck_id) -> Vec` + - `get_task(id) -> Task` + - `update_task(id, name?, description?, priority?, status?) -> Task` + - `update_task_status(id, status: TaskStatus) -> Task` — dedicated status transition; validate enum at type level + - `delete_task(id) -> ()` + +5. `commands/plan.rs` — 7 commands: + - `create_plan(deck_id, name, report_path?, plan_path?) -> Plan` + - `list_plans(deck_id) -> Vec` (with phases loaded — 2 queries, group in Rust) + - `get_plan(id) -> Plan` (with phases) + - `delete_plan(id) -> ()` + - `create_phase(plan_id, name, description?, file_path?, order) -> Phase` + - `update_phase_status(id, status: PhaseStatus) -> Phase` + - `delete_phase(id) -> ()` + +6. `commands/brainstorm.rs` — 6 commands: + - `create_brainstorm_session(deck_id, prompt) -> BrainstormSession` + - `list_brainstorm_sessions(deck_id) -> Vec` + - `update_brainstorm_session(id, status?: BrainstormStatus, report_path?) -> BrainstormSession` + - `create_key_insight(project_id, deck_id, title, report_path) -> KeyInsight` + - `list_key_insights(deck_id) -> Vec` + - `delete_key_insight(id) -> ()` + +7. `commands/worktree_cmd.rs` — 4 commands (DB records only, not git ops): + - `create_worktree_record(project_id, task_id, branch) -> Worktree` + - `list_worktree_records(project_id) -> Vec` + - `update_worktree_record(id, status?: WorktreeStatus, merged_at?) -> Worktree` + - No `files_changed` param — field dropped from schema (computed via git2 at display time) + - `delete_worktree_record(id) -> ()` + +8. Register all 23 commands in `lib.rs` + +## Todo +- [ ] Extend `models/enums.rs` with task/phase/brainstorm/worktree enums +- [ ] Create all 4 model files (task, plan, brainstorm, worktree) +- [ ] Create `commands/task.rs` (6 commands) +- [ ] Create `commands/plan.rs` (7 commands) +- [ ] Create `commands/brainstorm.rs` (6 commands) +- [ ] Create `commands/worktree_cmd.rs` (4 commands, no files_changed) +- [ ] Register all commands in `lib.rs` +- [ ] Compile check passes + +## Success Criteria +- All 23 MVP commands registered and callable +- Enum types enforce valid status/priority/type values at compile time +- Task status transitions validate via enum (no invalid strings) +- Plan queries include nested phases (2-query strategy, no N+1) +- Cascade deletes work (plan → phases, deck → tasks) +- No deferred commands implemented (YAGNI) +- Total command count after P2+P3: 35 (12 + 23) diff --git a/plans/260224-1104-m1-data-foundation/phase-04-typescript-ipc-wrappers.md b/plans/260224-1104-m1-data-foundation/phase-04-typescript-ipc-wrappers.md new file mode 100644 index 0000000..bf4a0b9 --- /dev/null +++ b/plans/260224-1104-m1-data-foundation/phase-04-typescript-ipc-wrappers.md @@ -0,0 +1,91 @@ +# Phase 4 — TypeScript IPC Wrappers + +## Context +- Phases 2-3: Rust commands define signatures +- Phase 3 audit: 35 MVP commands total (12 from P2, 23 from P3) +- Existing: `src/lib/tauri.ts` has 5 wrappers (CCS + git + fs) +- Pattern: `invoke(commandName, args)` with typed return + +## Overview +- **Priority:** P1 +- **Status:** Pending +- **Description:** Add typed invoke wrappers for all 35 MVP CRUD commands. Split into focused modules. + +## Key Insights +- Current `tauri.ts` = 50 lines. Adding 35 commands → exceeds 200-line limit +- Split by domain: `tauri-project.ts`, `tauri-task.ts`, etc. +- Re-export from `tauri.ts` index for backward compat + +- **Only create wrappers for commands that Phase 5 stores actually call** — no speculative wrappers for deferred commands + +## Files to Create +- `src/lib/tauri-project.ts` — project + ccs_account wrappers +- `src/lib/tauri-deck.ts` — deck wrappers +- `src/lib/tauri-task.ts` — task wrappers +- `src/lib/tauri-plan.ts` — plan + phase wrappers +- `src/lib/tauri-brainstorm.ts` — session + insight wrappers +- `src/lib/tauri-worktree.ts` — worktree record wrappers +- `src/lib/tauri-settings.ts` — settings wrappers + +## Files to Modify +- `src/lib/tauri.ts` — Convert to barrel export (re-export all modules) + +## Store Consumer Mapping + +Wrappers to create, aligned with Phase 5 stores: + +| Wrapper file | Functions | Consumed by store | +|---|---|---| +| `tauri-project.ts` | createProject, listProjects, getProject, updateProject, deleteProject | `project-store.ts` | +| `tauri-deck.ts` | createDeck, listDecks, setActiveDeck, updateDeck, deleteDeck | `deck-store.ts` | +| `tauri-task.ts` | createTask, listTasks, getTask, updateTask, updateTaskStatus, deleteTask | `task-store.ts` | +| `tauri-plan.ts` | createPlan, listPlans, getPlan, deletePlan, createPhase, updatePhaseStatus, deletePhase | `plan-store.ts` | +| `tauri-brainstorm.ts` | createBrainstormSession, listBrainstormSessions, updateBrainstormSession, createKeyInsight, listKeyInsights, deleteKeyInsight | `brainstorm-store.ts` | +| `tauri-worktree.ts` | createWorktreeRecord, listWorktreeRecords, updateWorktreeRecord, deleteWorktreeRecord | `worktree-store.ts` | +| `tauri-settings.ts` | getSettings, updateSettings | `settings-store.ts` | + +## Implementation Steps + +1. Create each domain file with typed wrappers. Pattern: +```typescript +import { invoke } from '@tauri-apps/api/core'; +import type { Project } from '@/types'; + +export async function createProject(args: { + name: string; + description?: string; + gitPath: string; +}): Promise { + return invoke('create_project', args); +} +``` + +2. **CRITICAL:** Arg names in `invoke()` must match Rust `#[tauri::command]` param names exactly. Rust uses snake_case → Tauri auto-converts camelCase. So TS `gitPath` maps to Rust `git_path`. + +3. Each file covers one entity's CRUD — see Store Consumer Mapping table above for exact function list. + +4. Update `tauri.ts` → barrel export: +```typescript +export * from './tauri-project'; +export * from './tauri-deck'; +export * from './tauri-task'; +export * from './tauri-plan'; +export * from './tauri-brainstorm'; +export * from './tauri-worktree'; +export * from './tauri-settings'; +export * from './tauri-ccs'; // rename existing CCS wrappers +``` + +## Todo +- [ ] Create 7 domain-specific IPC wrapper files (only MVP-required commands) +- [ ] Refactor existing `tauri.ts` CCS wrappers into `tauri-ccs.ts` +- [ ] Convert `tauri.ts` to barrel export +- [ ] Verify TS types match Rust command signatures +- [ ] TypeScript compile check passes + +## Success Criteria +- All 35 MVP commands have typed wrappers +- No wrappers for deferred/unused commands +- No file exceeds 200 lines +- Existing CCS wrappers still work (no breaking change) +- TypeScript strict mode passes diff --git a/plans/260224-1104-m1-data-foundation/phase-05-zustand-stores-refactor.md b/plans/260224-1104-m1-data-foundation/phase-05-zustand-stores-refactor.md new file mode 100644 index 0000000..077ea6b --- /dev/null +++ b/plans/260224-1104-m1-data-foundation/phase-05-zustand-stores-refactor.md @@ -0,0 +1,127 @@ +# Phase 5 — Zustand Stores Refactor + +## Context +- Phase 4: `phase-04-typescript-ipc-wrappers.md` (provides typed IPC functions) +- Existing stores: `src/stores/` — 7 files, all in-memory mock data + +## Overview +- **Priority:** P1 +- **Status:** Pending +- **Description:** Refactor all 7 Zustand stores from in-memory mock data to IPC-backed persistence. + +## Key Insights +- Current stores only have `add` actions — need `load`, `update`, `remove` + +- **Update pattern is pessimistic:** `await IPC call → on success, update store`. Never update store before IPC resolves. On error, store stays unchanged — no rollback needed. +- `load*` actions called on app init or route mount +- Keep store as cache layer — DB is source of truth + + +- Every async store action MUST have try/catch + toast error notification (use **shadcn/ui toast** component — `npx shadcn@latest add toast` if not already installed) +- Every store interface MUST include `error: string | null` field + +## Files to Modify +- `src/stores/project-store.ts` — Add load, update, remove + IPC calls +- `src/stores/deck-store.ts` — Add load, setActive, update, remove + IPC +- `src/stores/task-store.ts` — Add load, update, updateStatus, remove + IPC +- `src/stores/plan-store.ts` — Add load, remove + phase actions + IPC +- `src/stores/brainstorm-store.ts` — Add load, update session, insight CRUD + IPC +- `src/stores/worktree-store.ts` — Add load, update, remove + IPC +- `src/stores/settings-store.ts` — Add load, save + IPC + +## Implementation Steps + + + +1. **Pattern for each store (pessimistic + error handling):** +```typescript +import { create } from 'zustand'; +import { listProjects, createProject, deleteProject } from '@/lib/tauri'; +import { toast } from '@/components/ui/toast'; // or project's toast util + +interface ProjectStore { + projects: Project[]; + activeProjectId: string | null; + loading: boolean; + error: string | null; // MANDATORY on every store + loadProjects: () => Promise; + addProject: (args: CreateProjectArgs) => Promise; + removeProject: (id: string) => Promise; + setActiveProject: (id: string) => void; +} + +export const useProjectStore = create((set, get) => ({ + projects: [], + activeProjectId: null, + loading: false, + error: null, + loadProjects: async () => { + set({ loading: true, error: null }); + try { + const projects = await listProjects(); + set({ projects, loading: false }); + } catch (e) { + const msg = String(e); + set({ loading: false, error: msg }); + toast.error(msg); + } + }, + addProject: async (args) => { + try { + const project = await createProject(args); + // Pessimistic: update store only after IPC success + set((s) => ({ projects: [...s.projects, project] })); + return project; + } catch (e) { + const msg = String(e); + set({ error: msg }); + toast.error(msg); + return null; + } + }, + removeProject: async (id) => { + try { + await deleteProject(id); + // Pessimistic: remove from store only after IPC success + set((s) => ({ projects: s.projects.filter((p) => p.id !== id) })); + } catch (e) { + const msg = String(e); + set({ error: msg }); + toast.error(msg); + } + }, +})); +``` + +2. Refactor each store following same pattern: + - Add `loading: boolean` and `error: string | null` state + - `load*` async action — fetches from DB, replaces local state on success + - `add*` action — calls IPC create, appends to local state only on success + - `update*` action — calls IPC update, patches local state only on success + - `remove*` action — calls IPC delete, filters local state only on success + - Every async action: try/catch + `set({ error: msg })` + `toast.error(msg)` + - Remove hardcoded mock data arrays + +3. **Settings store** — special: load once on app init, save on change +4. **Plan store** — `loadPlans` includes nested phases from IPC response + +## Todo +- [ ] Refactor `project-store.ts` — pessimistic IPC-backed CRUD + error/toast +- [ ] Refactor `deck-store.ts` — pessimistic IPC-backed CRUD + setActive + error/toast +- [ ] Refactor `task-store.ts` — pessimistic IPC-backed CRUD + status transitions + error/toast +- [ ] Refactor `plan-store.ts` — pessimistic IPC-backed with nested phases + error/toast +- [ ] Refactor `brainstorm-store.ts` — sessions + insights IPC + error/toast +- [ ] Refactor `worktree-store.ts` — pessimistic IPC-backed CRUD + error/toast +- [ ] Refactor `settings-store.ts` — load/save IPC + error/toast +- [ ] Remove all mock/hardcoded data +- [ ] Verify `error: string | null` present in every store interface + +## Success Criteria +- All stores call IPC functions (no hardcoded data) +- All async actions are pessimistic (store updated only after IPC success) +- All async actions have try/catch + toast error notification +- All store interfaces include `error: string | null` +- Data persists between app restarts +- `loading` state used by UI for loading indicators +- Stores stay under 200 lines each +- Existing UI components render without errors (may show empty states) diff --git a/plans/260224-1104-m1-data-foundation/phase-06-app-init-and-integration.md b/plans/260224-1104-m1-data-foundation/phase-06-app-init-and-integration.md new file mode 100644 index 0000000..1cf7a0e --- /dev/null +++ b/plans/260224-1104-m1-data-foundation/phase-06-app-init-and-integration.md @@ -0,0 +1,100 @@ +# Phase 6 — App Initialization + Integration + +## Context +- Phases 1-5 complete: DB, commands, IPC, stores all wired +- Need: app boot sequence, first-launch detection, smoke testing + +## Overview +- **Priority:** P2 +- **Status:** Pending +- **Description:** Wire app initialization flow — DB setup, load settings + active project on boot. Handle first-launch → redirect to onboarding. + +## Files to Modify +- `src/App.tsx` (or root component) — Add init hook +- `src/stores/project-store.ts` — Add `initialized` flag + +## Files to Create +- `src/hooks/use-app-init.ts` — Orchestrates boot sequence + +## Implementation Steps + + +1. Create `use-app-init.ts` hook with error state and error screen support: +```typescript +export function useAppInit() { + const loadSettings = useSettingsStore((s) => s.loadSettings); + const loadProjects = useProjectStore((s) => s.loadProjects); + const [ready, setReady] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + async function init() { + try { + await loadSettings(); + await loadProjects(); + setReady(true); + } catch (e) { + setError(String(e)); + // Do NOT set ready=true — keep app in error state + } + } + init(); + }, []); + + return { ready, error }; +} +``` + +2. In root component: check `error` first → render error screen; else check `ready` → show splash; else render router: +```typescript +const { ready, error } = useAppInit(); + +if (error) return ; // NOT a blank/broken app +if (!ready) return ; +return ; +``` + +3. First-launch detection: if `projects.length === 0` after load → redirect to `/onboarding` + + +4. Active project restore — **use `app_settings.last_active_project_id` only. No localStorage.** + - On boot: read `last_active_project_id` from settings (already loaded in step 1) + - Validate the project still exists: `projects.find(p => p.id === last_active_project_id)` + - If found → `setActiveProject(last_active_project_id)` + - If not found (project deleted) → fall back to first project or null; clear stale value + - On project switch: call `updateSettings({ last_active_project_id: id })` to persist + +5. Integration smoke test checklist: + - App launches → DB created ✓ + - Settings loaded (default theme/language) ✓ + - Empty project list → onboarding redirect ✓ + - Create project via UI → persists in DB ✓ + - Create deck → persists ✓ + - Restart app → data still there ✓ + - DB init failure → error screen shown (not blank app) ✓ + - `last_active_project_id` restored on restart ✓ + - Stale `last_active_project_id` (project deleted) → graceful fallback ✓ + +## Todo +- [ ] Create `use-app-init.ts` hook with `error` state +- [ ] Wire init hook into root component (error screen + splash + router) +- [ ] Implement first-launch → onboarding redirect +- [ ] Active project restore from `app_settings.last_active_project_id` (validate exists) +- [ ] Persist active project on switch via `updateSettings` +- [ ] Remove any localStorage usage for active project +- [ ] Manual smoke test: full create→restart→verify cycle +- [ ] Verify all routes render (may show empty states, that's OK) + +## Success Criteria +- App boots cleanly: DB init → settings load → projects load → render +- First launch (no DB) → creates DB → redirects to onboarding +- Subsequent launches → loads existing data → goes to dashboard +- **DB init failure → renders error screen, not blank/broken app** +- Active project ID persisted in DB (not localStorage), validated on restore +- No console errors related to DB/IPC on startup +- CCS PTY features still work (no regression) + +## Risk Assessment +- **Boot time:** DB init should be <100ms (SQLite is fast) +- **Error handling:** If DB init fails → show error screen, not blank app (enforced via `error` state) +- **Migration:** Future schema changes need V2 migration (not in scope, but design supports it) diff --git a/plans/260224-1104-m1-data-foundation/plan.md b/plans/260224-1104-m1-data-foundation/plan.md new file mode 100644 index 0000000..fe8a02e --- /dev/null +++ b/plans/260224-1104-m1-data-foundation/plan.md @@ -0,0 +1,140 @@ +--- +title: "M1 — Data Foundation" +description: "SQLite persistence + Rust CRUD commands + Zustand stores wired to IPC" +status: pending +priority: P1 +effort: 16h +branch: mvp +tags: [backend, database, infrastructure] +created: 2026-02-24 +--- + +# M1 — Data Foundation + +## Overview + +Replace all mock/in-memory data with SQLite persistence via rusqlite. Create Rust CRUD commands for all 9 data models + settings. Wire Zustand stores to Tauri IPC. + +**Current state:** UI prototype with mock data. Only real backend = CCS PTY (ai.rs). No DB code exists despite `rusqlite` in Cargo.toml. + +## Key Decisions + +- **DB location:** `app_data_dir()` via `tauri::Manager` — standard Tauri approach +- **CCS accounts:** Separate table (normalized), FK to project +- **Migrations:** Inline SQL in Rust (KISS — no external .sql files) +- **Settings:** Single row in SQLite `app_settings` table +- **IDs:** UUID v4 strings (consistent with existing TS types) +- **Timestamps:** ISO 8601 strings (SQLite TEXT, no chrono dependency needed) + +## Phases + +| # | Phase | Status | Effort | Link | +|---|-------|--------|--------|------| +| 1 | DB module + schema migration | Pending | 3h | [phase-01](./phase-01-db-module-and-schema.md) | +| 2 | Core entity commands (project, ccs_account, deck, settings) | Pending | 3h | [phase-02](./phase-02-core-entity-commands.md) | +| 3 | Content entity commands (task, plan, phase, brainstorm, insight, worktree) | Pending | 3h | [phase-03](./phase-03-content-entity-commands.md) | +| 4 | TypeScript IPC wrappers | Pending | 2h | [phase-04](./phase-04-typescript-ipc-wrappers.md) | +| 5 | Zustand stores refactor | Pending | 3h | [phase-05](./phase-05-zustand-stores-refactor.md) | +| 6 | App initialization + integration | Pending | 2h | [phase-06](./phase-06-app-init-and-integration.md) | + +## Dependencies + +- Phase 1 → all others depend on DB module +- Phase 2-3 → can be done sequentially (shared DB patterns) +- Phase 4 → depends on 2-3 (needs Rust command signatures) +- Phase 5 → depends on 4 (needs TS wrappers) +- Phase 6 → depends on all + +## Architecture + +``` +React Store → invoke() → Rust Command → rusqlite → SQLite file + ↑ │ + └──────────── Result ◄────────────────────┘ +``` + +## Risks + +| Risk | Mitigation | +|------|------------| +| Schema mismatch TS ↔ Rust | Align models with existing TS types first | +| DB file corruption | WAL mode + proper connection handling | +| Migration ordering | Single versioned migration table | + +## Red Team Review + + + +### Session — 2026-02-24 +**Findings:** 15 (14 accepted, 1 rejected) +**Severity:** 5 Critical, 7 High, 3 Medium + +| # | Finding | Severity | Disposition | Applied To | +|---|---------|----------|-------------|------------| +| 1 | Mutex→connection pool | Critical | Accept | Phase 1 | +| 2 | PRAGMA FK per-connection | Critical | Accept | Phase 1 | +| 3 | Migration transaction+INSERT OR IGNORE | Critical | Accept | Phase 1 | +| 4 | git_path validation | Critical | Accept | Phase 2 | +| 5 | report_path sandboxing | Critical | Reject | — | +| 6 | 35 commands YAGNI audit | High | Accept | Phase 3 | +| 7 | Rust enums for status/priority | High | Accept | Phase 2, 3 | +| 8 | N+1 query list_projects | High | Accept | Phase 2 | +| 9 | set_active_deck transaction | High | Accept | Phase 2 | +| 10 | useAppInit error handling | High | Accept | Phase 6 | +| 11 | Store error handling | High | Accept | Phase 5 | +| 12 | activeProjectId in DB only | High | Accept | Phase 1, 6 | +| 13 | FK indexes | Medium | Accept | Phase 1 | +| 14 | files_changed drop from schema | Medium | Accept | Phase 1 | +| 15 | Optimistic→pessimistic clarify | Medium | Accept | Phase 5 | + +## Validation Log + +### Session 1 — 2026-02-24 +**Trigger:** Initial plan validation before implementation +**Questions asked:** 5 + +#### Questions & Answers + +1. **[Architecture]** UUID generation: Rust backend hay Frontend TS tạo ID trước khi gửi qua IPC? + - Options: Rust backend tạo UUID | Frontend tạo UUID trước | Hybrid + - **Answer:** Rust backend tạo UUID + - **Rationale:** Backend is single source of truth. Consistent with pessimistic pattern — frontend sends data, backend creates ID + timestamp. + +2. **[Scope]** CcsProvider enum: danh sách providers có đủ không? + - Options: Giữ 4+OpenRouter | Match TS types | String thay enum + - **Answer:** Dynamic scan ~/.ccs/ at runtime + - **Custom input:** Analyze cách lấy profiles tại happy-ccs.mjs — profiles discovered from ~/.ccs/config.yaml + *.settings.json + instances/ + - **Rationale:** CCS profiles are user-configured, not fixed. Hardcoded enum would break for custom profiles. `CcsAccount.provider` stays as String (profile name). Add `list_ccs_profiles` Rust command that scans ~/.ccs/ directory. + +3. **[Testing]** M1 không có automated tests. Thêm Rust integration tests? + - Options: Manual smoke test đủ | Basic Rust tests | Full test suite + - **Answer:** Manual smoke test là đủ cho M1 + - **Rationale:** M1 focus on foundation. Tests added in later milestones. + +4. **[Dependencies]** Toast notification system cho Phase 5 error handling? + - Options: shadcn/ui toast | sonner | Custom + - **Answer:** Dùng shadcn/ui toast + - **Rationale:** Consistent with existing design system. No extra dependency. + +5. **[Scope]** CCS profiles dynamic vs hardcoded enum? + - Options: Dynamic scan | Hardcoded + fallback | Hardcoded only + - **Answer:** Dynamic scan ~/.ccs/ at runtime + - **Rationale:** Profiles are user-configured via config.yaml + settings files. Enum would limit flexibility. + +#### Confirmed Decisions +- UUID: Rust backend generates (via `uuid::Uuid::new_v4()`) +- CCS profiles: Dynamic discovery, `provider` field stays String (not enum) +- Testing: Manual smoke test for M1 +- Toast: shadcn/ui toast component +- Timestamps: Rust backend generates ISO 8601 strings + +#### Action Items +- [ ] Remove `CcsProvider` enum from Phase 2 — use String for provider field +- [ ] Add `list_ccs_profiles` command to Phase 2 — scans ~/.ccs/ for profiles +- [ ] Add shadcn/ui toast setup note to Phase 5 +- [ ] Clarify in Phase 2+3: backend generates UUID + timestamp, not frontend + +#### Impact on Phases +- Phase 2: Remove CcsProvider enum, keep CcsAccountStatus enum. Add list_ccs_profiles command. Clarify UUID generation in backend. +- Phase 3: No change (enums for task/phase status still valid) +- Phase 5: Add note about shadcn/ui toast dependency for error handling diff --git a/plans/260224-1104-m1-data-foundation/reports/code-reviewer-260224-1219-m1-data-foundation-plan-review.md b/plans/260224-1104-m1-data-foundation/reports/code-reviewer-260224-1219-m1-data-foundation-plan-review.md new file mode 100644 index 0000000..76fcf2e --- /dev/null +++ b/plans/260224-1104-m1-data-foundation/reports/code-reviewer-260224-1219-m1-data-foundation-plan-review.md @@ -0,0 +1,89 @@ +# Plan Review — M1 Data Foundation +**Reviewer:** code-reviewer (Scope & Complexity Critic — YAGNI enforcer) +**Date:** 2026-02-24 +**Plan:** `/plans/260224-1104-m1-data-foundation/` + +--- + +## Finding 1: Mutex Sẽ Tạo Bottleneck Ngay Từ Đầu + +- **Severity:** High +- **Location:** Phase 1, section "Implementation Steps — DbState struct" +- **Flaw:** Plan dùng `Mutex` (single connection, serialized access). Mọi Tauri command phải acquire lock trước khi query. Với 35 commands được đăng ký, bất kỳ command nào đang chạy sẽ block tất cả command khác. +- **Failure scenario:** User mở app, `useAppInit` gọi `loadSettings()` và `loadProjects()` tuần tự (Phase 6, step 1). `loadProjects` lại cần JOIN với `ccs_accounts`. Trong khi đó nếu có thêm một async store nào trigger thêm `invoke()`, toàn bộ sẽ queue sau Mutex. WAL mode giúp được với đọc đồng thời từ nhiều process, không giúp được single-connection Mutex trong cùng process. +- **Evidence:** Phase 1: "DbState struct wrapping `Mutex`". Phase 6 init gọi loadSettings + loadProjects tuần tự nhưng không đề cập connection pooling. +- **Suggested fix:** Dùng `r2d2` + `r2d2_sqlite` (connection pool) hoặc chấp nhận single connection nhưng phải ghi rõ trong plan rằng mọi command là serialized và đây là design decision có chủ ý — không phải lỗi ngầm. + +--- + +## Finding 2: 35 Commands Cho MVP Là Quá Nhiều — Không Áp Dụng YAGNI + +- **Severity:** High +- **Location:** Phase 2 + Phase 3, section "Commands" +- **Flaw:** Plan tạo full CRUD cho 9 entities (35 commands). MVP scope theo CLAUDE.md là "5 modules". Nhiều commands này serve data mà UI prototype chưa có: `get_plan(id)`, `get_task(id)`, `update_brainstorm_session`, `create_key_insight`, `list_key_insights`, `delete_key_insight`, `delete_phase` — không có UI flow nào trong MVP dùng các lệnh này. +- **Failure scenario:** Implementer dành 2-3 ngày viết, test, đăng ký 35 commands. Sau đó Phase 5 chỉ cần wire `list_*` và `create_*` cho các màn hình hiện tại. `get_task(id)`, `update_phase_status`, v.v. nằm im không dùng — dead code ngay từ M1. +- **Evidence:** Phase 3: "Total command count after P2+P3: 35 commands". Phase 3 list `delete_key_insight`, `update_brainstorm_session(id, status?, report_path?)` — không có UI component nào trong plans/260224-1104 yêu cầu các command này. +- **Suggested fix:** Chỉ implement commands mà UI component hiện tại đang gọi. Audit từng command: nếu không có store action nào gọi nó trong Phase 5 → defer. Có thể cắt xuống ~20 commands mà không mất tính năng MVP. + +--- + +## Finding 3: Schema Có Circular Dependency Tiềm Ẩn — key_insights FK Chưa Đúng + +- **Severity:** High +- **Location:** Phase 1, section "V1 migration SQL — key_insights table" +- **Flaw:** `key_insights` có `deck_id REFERENCES decks(id) ON DELETE CASCADE`. `decks` có `based_on_insight_id TEXT` (không có FK constraint). Nếu sau này thêm FK `decks.based_on_insight_id REFERENCES key_insights(id)`, sẽ có circular reference không thể resolve bằng CASCADE. Hiện tại chưa có FK nhưng plan không ghi rõ đây là intentional omission. +- **Failure scenario:** Developer sau thấy `based_on_insight_id` không có FK, thêm vào như "fix". SQLite không enforce circular FK lúc schema creation nhưng DELETE operations sẽ fail hoặc hành xử không nhất quán. Nếu delete một deck có insight mà insight đó là `based_on` của deck khác — undefined behavior. +- **Evidence:** Phase 1 schema: `decks.based_on_insight_id TEXT` (no FK), `key_insights.deck_id REFERENCES decks(id) ON DELETE CASCADE`. Plan không giải thích tại sao `based_on_insight_id` không có FK constraint. +- **Suggested fix:** Ghi rõ trong plan: `based_on_insight_id` là soft reference (no FK) vì circular dependency. Hoặc tách `key_insights` khỏi `decks` cascade — insight thuộc `project`, không phải `deck`. + +--- + +## Finding 4: Phase 6 Dùng localStorage Cho activeProjectId — Vi Phạm "Local-First DB" Principle + +- **Severity:** Medium +- **Location:** Phase 6, section "Implementation Steps — step 4" +- **Flaw:** Plan viết: "save `activeProjectId` to settings or localStorage". `localStorage` trong Tauri là WebView storage — volatile, không đồng bộ với SQLite DB. Đây là hai source-of-truth cho cùng một piece of state. +- **Failure scenario:** User tắt app khi `activeProjectId` đã lưu vào SQLite settings nhưng localStorage chưa sync (hoặc ngược lại sau một bug). App restart đọc từ localStorage (nhanh hơn) nhưng project đó đã bị delete khỏi SQLite. App render với `activeProjectId` trỏ đến null project → crash hoặc blank UI. +- **Evidence:** Phase 6, step 4: "save `activeProjectId` to settings or localStorage, restore on boot" — "or" là ambiguous, implementation sẽ pick một trong hai không nhất quán. +- **Suggested fix:** Bỏ hoàn toàn localStorage option. Lưu `active_project_id` vào `app_settings` table (thêm column) hoặc dùng Zustand persist với Tauri store plugin. Một source of truth. + +--- + +## Finding 5: "Optimistic Update" Pattern Được Describe Sai — Thực Ra Là Pessimistic + +- **Severity:** Medium +- **Location:** Phase 5, section "Key Insights" +- **Flaw:** Plan ghi: "Optimistic updates: update store first, rollback on IPC error (KISS: just reload on error)". Sau đó code example lại implement ngược lại: `addProject` await IPC trước, rồi mới update store. Đây là pessimistic update (wait for server), không phải optimistic. +- **Failure scenario:** Không phải failure per se, nhưng khi implementer đọc "optimistic updates" và sau đó thấy code example await trước, họ sẽ bị confused về pattern thực sự. Một implementer khác có thể implement đúng optimistic (update store trước, gọi IPC sau) — dẫn đến inconsistent patterns across 7 stores. +- **Evidence:** Phase 5, Key Insights: "Optimistic updates: update store first...". Code example ngay bên dưới: `const project = await createProject(args); set(...)` — IPC được await trước. +- **Suggested fix:** Bỏ mention "optimistic updates" nếu không implement. Hoặc implement đúng: update store trước, gọi IPC sau, rollback nếu fail. Chọn một, document rõ. + +--- + +## Finding 6: `command_providers TEXT DEFAULT '{}'` Trong app_settings — Gold Plating + +- **Severity:** Medium +- **Location:** Phase 1, section "app_settings schema" +- **Flaw:** `command_providers TEXT NOT NULL DEFAULT '{}'` — lưu JSON blob trong SQLite TEXT column. MVP theo CLAUDE.md chỉ cần chọn CCS profile (claude, gemini, etc.) từ UI. "command_providers" không có trong bất kỳ phase nào khác của plan. Không có TS type nào define structure của JSON này. +- **Failure scenario:** Implementer tạo column nhưng không có code nào đọc hay ghi vào nó trong toàn bộ M1. Column tồn tại trong schema nhưng là dead weight. Khi cần dùng thực sự ở milestone sau, structure của JSON đã bị lock bởi migration V1 — không thể thay đổi mà không tạo V2 migration. +- **Evidence:** Phase 1 schema có `command_providers TEXT NOT NULL DEFAULT '{}'`. Không có mention nào trong Phase 2-6 về việc đọc/ghi column này. +- **Suggested fix:** Bỏ column này khỏi V1 schema. Add ở migration V2 khi có feature thực sự cần nó. + +--- + +## Finding 7: `files_changed INTEGER` Trong worktrees — DB Sẽ Stale Ngay Lập Tức + +- **Severity:** Medium +- **Location:** Phase 1, section "worktrees schema" + Phase 3, section "worktree_cmd.rs commands" +- **Flaw:** `files_changed INTEGER NOT NULL DEFAULT 0` được lưu trong DB và cập nhật qua `update_worktree_record(id, status?, files_changed?, merged_at?)`. Số lượng files changed là live git state — nó thay đổi liên tục khi developer commit. DB value sẽ stale ngay sau lần commit đầu tiên. +- **Failure scenario:** User tạo worktree, DB lưu `files_changed = 0`. User commit 5 files. UI hiển thị `files_changed = 0` (stale) vì không có mechanism nào update DB khi git state thay đổi. Update thủ công qua `update_worktree_record` cũng không help vì ai sẽ trigger nó? +- **Evidence:** Phase 1 schema: `files_changed INTEGER NOT NULL DEFAULT 0`. Phase 3: `update_worktree_record(id, status?, files_changed?, merged_at?)` — caller-driven update, không có event trigger. +- **Suggested fix:** Bỏ `files_changed` khỏi DB schema. Tính real-time từ git khi cần display (đã có git2 dependency). Chỉ persist fields có giá trị lâu dài: `branch`, `status`, `merged_at`. + +--- + +## Unresolved Questions + +1. `app_settings.font_size` và `auto_save` — UI nào trong MVP dùng các settings này? Nếu không có UI component, đây là thêm gold plating trong schema. +2. Phase 3 có `create_brainstorm_session(deck_id, prompt)` nhưng Brainstorm module trong CLAUDE.md là "AI-assisted ideation" — brainstorm session liệu có chạy qua CCS PTY hay qua DB command? Nếu qua PTY thì DB record chỉ là metadata, cần clarify trigger point. +3. Effort estimate 16h cho 35 Rust commands + 7 stores refactor + DB setup có vẻ thấp. Phase 2+3 một mình = 6h để viết, compile-check và test 35 commands. Realistic không? diff --git a/plans/260224-1916-phase-2-auto-fix-in-scope-m1/plan.md b/plans/260224-1916-phase-2-auto-fix-in-scope-m1/plan.md new file mode 100644 index 0000000..8b7f9fa --- /dev/null +++ b/plans/260224-1916-phase-2-auto-fix-in-scope-m1/plan.md @@ -0,0 +1,77 @@ +--- +title: "Phase 2: Auto-fix in-scope M1 (CCS profile list wiring)" +description: "Wire Settings CCS Test Console to backend list_ccs_profiles with minimal M1-only changes." +status: completed +priority: P2 +effort: 1.5h +branch: mvp +tags: [m1, phase-2, settings, ccs, tauri, ui] +created: 2026-02-24 +--- + +# Phase 2 (Auto-fix in-scope M1) — Short Implementation Plan + +## Scope (strict M1 only) +- Fix known M1 follow-up: **Settings CCS Test Console profile picker still hardcoded**. +- Use existing backend command `list_ccs_profiles` (already implemented + registered). +- No changes to M2/M3/M4 mock/runtime issues. + +## Minimal code changes (file-level) +1. **`/Users/thieunv/projects/solo-builder/vividkit-workspace/vividkit-app/src/lib/tauri-ccs.ts`** + - Add TS type for backend response (e.g. `CcsProfile { name, profileType }` matching camelCase serde). + - Add `listCcsProfiles()` invoke wrapper for `list_ccs_profiles`. + - Keep existing `spawnCcs/stopCcs/sendCcsInput` unchanged. + +2. **`/Users/thieunv/projects/solo-builder/vividkit-workspace/vividkit-app/src/components/settings/ccs-test-console.tsx`** + - Replace hardcoded `PROFILES` as primary source. + - Load profile list from `listCcsProfiles()` on mount (or same init effect area). + - Map backend response to Select items (`profile.name`). + - Keep a small fallback list (existing hardcoded array) only when command fails or returns empty (UX-safe for M1). + - Preserve current run/stop terminal behavior; no PTY flow changes. + - Ensure selected `profile` remains valid after async load (fallback to first available if needed). + +## Out of scope (explicit) +- Onboarding/profile picker wiring (unless user expands scope) +- Backend `list_ccs_profiles` logic changes +- Broader mock-runtime cleanup in M2/M3/M4 +- i18n/string cleanup in this screen (follow-up only if already touching labels becomes necessary) + +## Validation steps (typecheck / build / test) +1. **Typecheck + frontend build** + - Run `npm run build` (includes `tsc && vite build`). +2. **Lint (targeted quality check)** + - Run `npm run lint` (or at minimum verify touched files compile clean with no TS errors). +3. **Rust compile sanity (only if Rust files accidentally touched)** + - Run `cargo check` in `src-tauri/`. +4. **Manual smoke test (required)** + - Open Settings → CCS Test Console. + - Verify profile dropdown loads dynamic profiles from local `~/.ccs` (including custom profile if present). + - Verify fallback list appears if backend returns empty/error. + - Run a test command and confirm run/stop behavior unchanged. + +## DONE criteria +- CCS Test Console profile dropdown no longer depends on hardcoded list as canonical source. +- UI calls backend `list_ccs_profiles` through TS wrapper successfully. +- Custom CCS profiles (in `~/.ccs`) are selectable in Settings test console. +- `npm run build` passes. +- No regression to existing run/stop terminal flow in manual smoke test. + +## Risks + simple rollback +### Risks +- **Response shape mismatch** (`profile_type` vs `profileType`) causing empty render/undefined values. +- **Async state race** may reset selected profile unexpectedly. +- **Empty profile list UX** if backend returns no profiles and no fallback applied. + +### Mitigation +- Use typed wrapper + explicit mapping from `name` only. +- Keep fallback list and defensive profile selection logic. +- Limit changes to 2 files; do not touch PTY runtime code. + +### Rollback (simple) +- Revert only the two touched files: + - `src/lib/tauri-ccs.ts` + - `src/components/settings/ccs-test-console.tsx` +- Restore hardcoded profile list behavior if dynamic loading causes regression. + +## Unresolved questions +- Có cần mở rộng cùng fix này sang onboarding/profile picker trong cùng Phase 2 không (hiện plan giữ ngoài scope để tránh scope creep)? diff --git a/plans/260224-1944-onboarding-git-browse-dialog-bugfix/plan.md b/plans/260224-1944-onboarding-git-browse-dialog-bugfix/plan.md new file mode 100644 index 0000000..6a7377b --- /dev/null +++ b/plans/260224-1944-onboarding-git-browse-dialog-bugfix/plan.md @@ -0,0 +1,66 @@ +--- +title: "Fix onboarding Git browse dialog command mismatch" +description: "Replace missing IPC command usage in Onboarding Git Setup with a safe folder picker path using Tauri dialog plugin API." +status: completed +priority: P1 +effort: 1h +branch: mvp +tags: [bugfix, onboarding, tauri, dialog] +created: 2026-02-24 +--- + +# Plan overview + +## Goal +Fix Browse folder button in Onboarding Git Setup (macOS-first, cross-platform safe) with minimal change. Current bug: frontend calls non-existent Tauri command `open_directory_dialog`. + +## Decision (low-risk path) +**Choose frontend Tauri dialog plugin API** (`@tauri-apps/plugin-dialog`) instead of adding a Rust command wrapper. + +### Why this is lower risk +- Root cause is frontend/IPC mismatch; fix at callsite removes mismatch directly. +- Backend already initializes `tauri_plugin_dialog` and capability includes `dialog:default`. +- Fewer moving parts than adding Rust command + registration + IPC contract. +- Avoids creating/maintaining a one-off command wrapper for a built-in plugin capability. + +### Rejected option (for this bugfix) +- **Rust command wrapper (`open_directory_dialog`)**: works, but adds backend surface area and extra IPC plumbing for a simple folder picker. + +## Scope (minimal) +- Replace `invoke('open_directory_dialog')` in Onboarding Git Setup browse handler with dialog plugin folder picker call. +- Keep cancel dialog as non-error path. +- Keep existing state patch behavior (`patch({ gitPath })`) unchanged. +- No platform-specific frontend branching. + +## Files to modify +- `/Users/thieunv/projects/solo-builder/vividkit-workspace/vividkit-app/src/components/onboarding/step-git-setup.tsx` +- `/Users/thieunv/projects/solo-builder/vividkit-workspace/vividkit-app/package.json` (only if `@tauri-apps/plugin-dialog` dependency is missing) +- Lockfile if dependency add is required (repo-managed lockfile) + +## Validation plan +1. **Static check (TS/build)** + - `npm run build` (or at least `tsc`) passes. + - No import/type errors for dialog plugin API. +2. **Manual smoke (macOS)** + - Open Onboarding → Git Setup. + - Click Browse in both modes: + - Local Repository → Project Path + - Clone Repository → Destination Path + - Select folder → input updates with chosen path. + - Cancel dialog → no crash, no invalid path overwrite. +3. **Cross-platform sanity (non-functional)** + - No hardcoded path separators or platform checks introduced in frontend. + +## DONE criteria +- Browse button opens folder picker instead of silently failing. +- Selected folder path is written to the correct input field (`gitPath`). +- Cancel remains safe/no-op. +- No new Rust command added. +- Build/typecheck passes after change. + +## Risks / notes +- If JS package `@tauri-apps/plugin-dialog` is not installed, adding dependency may touch lockfile (acceptable, still low risk). +- Dialog API return type may be union (`string | string[] | null`); implementation should defensively handle only single-folder string for this flow. + +## Unresolved questions +- None for this minimal bugfix plan. diff --git a/plans/reports/autofix-260224-1903-m1-runtime-mock-and-git-browse.md b/plans/reports/autofix-260224-1903-m1-runtime-mock-and-git-browse.md new file mode 100644 index 0000000..16cd964 --- /dev/null +++ b/plans/reports/autofix-260224-1903-m1-runtime-mock-and-git-browse.md @@ -0,0 +1,85 @@ +# M1 Auto-fix Report — Runtime mock audit + Git Setup browse (macOS) + +## 1) Executive summary +**PASS WITH ISSUES** + +- Đã audit toàn bộ runtime path theo yêu cầu. +- Đã auto-fix toàn bộ issue **in-scope M1** xác định được trong đợt này. +- Vẫn còn issue **out-of-scope M1** (thuộc M4 prototype/polish), đã ghi rõ evidence và phân loại. + +--- + +## 2) Bảng issue đã fix (in-scope) + +| file_path:line_number | Root cause | Fix applied | Impact | +|---|---|---|---| +| `src/components/settings/ccs-test-console.tsx:13,26,47-59,143` + `src/lib/tauri-ccs.ts:18-21,39-41` | Settings CCS Test Console dùng profile list hardcoded runtime, không dùng backend `list_ccs_profiles` dù backend đã có command. | Thêm typed wrapper `listCcsProfiles()` + `CcsProfile` ở `src/lib/tauri-ccs.ts`; đổi dropdown profile sang load dynamic từ backend, fallback an toàn `['default']` khi discovery fail/rỗng. | Loại bỏ hardcoded profile runtime ở Settings console, hỗ trợ profile thực từ `~/.ccs` theo đúng hướng M1 follow-up. | +| `src/components/settings/ccs-test-console.tsx:52` | Edge case dữ liệu profile có whitespace-only có thể lọt vào dropdown. | Sanitize bằng `trim()` + lọc `name.length > 0`. | Tránh option profile rỗng/không hợp lệ, giảm lỗi runtime khi spawn CCS. | + +--- + +## 3) Kết luận riêng theo yêu cầu + +### A. Onboarding mock suspicion +**Kết luận: CÓ (một phần), nhưng không nằm ở data foundation path chính của M1.** + +Evidence mock/prototype: +- `src/components/onboarding/step-ai-tools.tsx:16-20` hardcoded `CCS_ACCOUNTS`. +- `src/components/onboarding/step-ai-tools.tsx:26-33` detect bằng `setTimeout` (simulated). + +Evidence path dữ liệu thật (local repo create) đã đi DB: +- `src/components/onboarding/onboarding-wizard.tsx:41-52` gọi `addProject(...)`. +- `src/stores/project-store.ts:84-94` gọi `createProjectCommand(...)`. +- `src/lib/tauri-project.ts:16-18` invoke `create_project`. +- `src-tauri/src/commands/project.rs:93-112` insert SQLite thật. + +Scope verdict: +- Theo roadmap, Onboarding real detect/picker thuộc M4 (`docs/development-roadmap.md:104-109`) => **out-of-scope M1**. + +### B. Git Setup browse folder local (macOS) +**Kết luận: BUG thật, nhưng out-of-scope M1.** + +Evidence bug: +- `src/components/onboarding/step-git-setup.tsx:22` gọi `invoke('open_directory_dialog')`. +- `src-tauri/src/lib.rs:22-69` không register command `open_directory_dialog`. +- `src/components/onboarding/step-git-setup.tsx:24-26` nuốt lỗi silent `catch {}` => UX “bấm Browse không có gì xảy ra”. + +Scope verdict: +- M1 chỉ data foundation (`docs/development-roadmap.md:71-80`, plan M1). +- Git repo picker/onboarding polish thuộc M4 (`docs/development-roadmap.md:104-109`) => **out-of-scope M1**. + +--- + +## 4) Test / compile / review results + +### Validation commands +- `npm run build` → **PASS** +- `npm run lint` → **PASS** +- `npm run test` → **N/A** (không có `test` script trong `package.json`) + +### Code review +- Review pass, **không còn finding high/critical** sau patch sanitize profile name. +- Moderate finding whitespace profile đã được xử lý (`ccs-test-console.tsx:52`). + +--- + +## 5) Docs impact +**minor** + +Đã update: +- `docs/project-changelog.md:21` (ghi nhận fix dynamic profiles ở Settings CCS Test Console) +- `docs/project-changelog.md:29-30` (ghi nhận known follow-up out-of-scope M1: Onboarding mock detect + Git Setup browse bug) + +--- + +## 6) Out-of-scope issues (không fix trong đợt M1 strict) + +- Onboarding AI Tools vẫn simulated/hardcoded (`step-ai-tools.tsx`). +- Onboarding Git Setup browse gọi missing IPC command + silent catch (`step-git-setup.tsx`). +- Một số runtime prototype khác thuộc M2/M3/M4 (Generate Plan/Cook mock flow) đã được audit và phân loại out-of-scope M1. + +--- + +## 7) Unresolved questions +1. Có muốn mở rộng ngay sang M4 hotfix cho `open_directory_dialog` (Browse folder) không? +2. Có muốn chuyển Onboarding AI Tools từ simulated sang detect thật ngay (dùng `list_ccs_profiles` + CLI checks) không? diff --git a/plans/reports/brainstorm-260224-1104-mvp-completion-strategy.md b/plans/reports/brainstorm-260224-1104-mvp-completion-strategy.md new file mode 100644 index 0000000..6d57570 --- /dev/null +++ b/plans/reports/brainstorm-260224-1104-mvp-completion-strategy.md @@ -0,0 +1,60 @@ +# Brainstorm: MVP Completion Strategy + +## Problem Statement +VividKit Desktop has ~80% UI prototype with mock data. Need backend integration for production-ready MVP. +Only real backend: CCS PTY spawning (ai.rs). All other operations (DB, git, worktree, fs) are stubs. + +## Current State +- **Working:** CCS PTY spawn/stream/stop, xterm.js integration, UI components for all modules +- **Missing:** SQLite persistence, git2 operations, worktree CRUD, real data flow through stores + +## Evaluated Approaches + +### A. Monolithic plan (all at once) +- Pros: Single context, holistic view +- Cons: Context overflow, hard to track, too many files touched simultaneously +- **Rejected** + +### B. Sequential milestones (chosen) +- Pros: Clear dependencies, manageable scope, incremental value +- Cons: Slower overall, need to plan each milestone +- **Selected** — each milestone ~4-6 phases, fits context window + +## MVP Milestones + +### M1 — Data Foundation (Critical Path) +SQLite schema + migrations. Rust CRUD commands for all 8 models. Stores refactored to use IPC. +- **Why first:** Everything depends on persistence. No other feature works without DB. +- **Scope:** ~20 Rust files + 7 store refactors + schema + +### M2 — Core Workflows (Brainstorm → Plan → Task) +Wire CCS PTY into brainstorm/cook terminals. Plan generation + phase tracking. Task auto-gen. +- **Depends on:** M1 (needs DB to persist sessions, plans, tasks) +- **Scope:** ~15 files + +### M3 — Git & Worktree Integration +git2 crate: status, commit, diff, worktree create/list/merge/cleanup. +- **Depends on:** M1 (worktree records in DB), partially M2 (cook flow) +- **Scope:** ~10 files + +### M4 — Onboarding + Settings + Polish +First-run wizard, CCS detect, settings persistence, error boundaries, empty states, toasts. +- **Depends on:** M1-M3 functional +- **Scope:** ~10 files + QA + +## Decisions +- **Git strategy:** git2 crate (pure Rust, cross-platform) +- **DB:** rusqlite with versioned migrations +- **Plan approach:** One detailed plan per milestone, sequential execution + +## Risks +| Risk | Mitigation | +|------|------------| +| git2 complexity | Start simple (status/commit), worktree later | +| Schema changes mid-dev | Version migrations from day 1 | +| Context overflow | Max 6 phases per milestone plan | + +## Next Steps +1. Sync development-roadmap.md to milestone structure +2. Create detailed plan for Milestone 1 — Data Foundation +3. Execute M1, then plan M2 diff --git a/plans/reports/debugger-260224-1851-git-setup-folder-browse-macos.md b/plans/reports/debugger-260224-1851-git-setup-folder-browse-macos.md new file mode 100644 index 0000000..8a36ff7 --- /dev/null +++ b/plans/reports/debugger-260224-1851-git-setup-folder-browse-macos.md @@ -0,0 +1,110 @@ +# Debug Report — Git Setup folder browse issue (macOS) + +## Executive summary +- Symptom: click Browse in Git Setup, user cannot select local folder. +- Root cause most likely: frontend invokes non-existent Tauri command `open_directory_dialog`; error swallowed. +- Severity: medium (onboarding UX blocked for users who rely on picker). +- Scope status: real bug in current code, but not M1 milestone blocker (M1 is data foundation; onboarding real picker is M4 scope). + +## Trace map (required path) +1) UI event → React handler +- `/Users/thieunv/projects/solo-builder/vividkit-workspace/vividkit-app/src/components/onboarding/step-git-setup.tsx:71` + - Browse button `onClick={browse}` +- `/Users/thieunv/projects/solo-builder/vividkit-workspace/vividkit-app/src/components/onboarding/step-git-setup.tsx:19-29` + - `browse()` calls `invoke('open_directory_dialog')` + +2) React state/store path +- `/Users/thieunv/projects/solo-builder/vividkit-workspace/vividkit-app/src/components/onboarding/step-git-setup.tsx:23` + - On success, only patches local wizard state `patch({ gitPath: selected })` +- `/Users/thieunv/projects/solo-builder/vividkit-workspace/vividkit-app/src/components/onboarding/onboarding-wizard.tsx:34-36` + - `patch` updates component-local `useState` +- `/Users/thieunv/projects/solo-builder/vividkit-workspace/vividkit-app/src/components/onboarding/onboarding-wizard.tsx:41-52` + - Store path (`addProject`) happens only at finish, not during browse. + +3) lib/tauri invoke wrapper +- No wrapper used for folder browse. +- Direct invoke from UI in `step-git-setup.tsx:22`. +- Contrasts with wrapped style in `/Users/thieunv/projects/solo-builder/vividkit-workspace/vividkit-app/src/lib/tauri-project.ts:16-34`. + +4) Tauri command/plugin path +- Command invoked by UI: `open_directory_dialog`. +- Registered commands list has no `open_directory_dialog`: + - `/Users/thieunv/projects/solo-builder/vividkit-workspace/vividkit-app/src-tauri/src/lib.rs:22-69` +- Grep confirms only callsite exists: + - `src/components/onboarding/step-git-setup.tsx:22` +- Dialog plugin is initialized: + - `/Users/thieunv/projects/solo-builder/vividkit-workspace/vividkit-app/src-tauri/src/lib.rs:12` + +5) Capability/permission config +- Dialog permission exists: + - `/Users/thieunv/projects/solo-builder/vividkit-workspace/vividkit-app/src-tauri/capabilities/default.json:12` (`dialog:default`) + +6) Error handling/user-facing message +- Browse catch block is silent: + - `/Users/thieunv/projects/solo-builder/vividkit-workspace/vividkit-app/src/components/onboarding/step-git-setup.tsx:24-26` + - Comment says cancelled or failed, no toast/log → user sees nothing. + +## Root cause hypothesis + evidence +### Hypothesis A (primary, high confidence) +Frontend calls a command that does not exist in backend command registry. + +Evidence: +- Call: `invoke('open_directory_dialog')` at `step-git-setup.tsx:22`. +- `open_directory_dialog` missing from `generate_handler![]` in `src-tauri/src/lib.rs:22-69`. +- No Rust function with that name in `src-tauri/src/**` (grep no matches). +- Silent catch hides invoke error from user. + +Expected runtime behavior: +- Tauri returns command-not-found error for invoke. +- catch consumes error; button appears to do nothing. + +### Hypothesis B (secondary) +Feature was scaffolded as UI prototype and never wired to real dialog API/command. + +Evidence: +- Roadmap marks onboarding route as UI prototype and real git picker under M4: + - `/Users/thieunv/projects/solo-builder/vividkit-workspace/vividkit-app/docs/development-roadmap.md:18` + - `/Users/thieunv/projects/solo-builder/vividkit-workspace/vividkit-app/docs/development-roadmap.md:108` +- Yet phase doc earlier specified browse should invoke dialog: + - `/Users/thieunv/projects/solo-builder/vividkit-workspace/vividkit-app/plans/260222-2244-screen-implementation/phase-02-onboarding.md:43` + +## Bug vs out-of-scope conclusion +Conclusion: **real bug in current behavior** and **also outside strict M1 objectives**. + +Why bug: +- Code path is present in UI now; user action triggers broken invoke call. +- This is not just “missing enhancement”; it is an invalid IPC contract. + +Why out-of-scope for M1: +- M1 target is SQLite persistence + CRUD wiring, not onboarding completion. +- Roadmap says onboarding real picker belongs to M4, and onboarding overall still prototype. + +Practical prioritization: +- Not M1 release blocker. +- Should still fix soon because onboarding entry flow is user-facing. + +## Minimal in-scope fix plan (no edits yet) +Option 1 (prefer, smallest code change + aligns Tauri v2 plugin model): +1. Frontend replace `invoke('open_directory_dialog')` with dialog plugin API (`@tauri-apps/plugin-dialog` open/select directory). +2. Keep return type `string | null`; set `gitPath` when selected. +3. Add visible error feedback in catch (existing toast event pattern). +4. Keep cancellation non-error path. + +Option 2: +1. Add Rust `#[tauri::command] open_directory_dialog` wrapping dialog plugin. +2. Register command in `generate_handler![]`. +3. Keep current frontend invoke call. + +Recommendation: +- Choose Option 1 for KISS/DRY (remove unnecessary custom IPC). + +## Risks / side effects +- If plugin import/API signature differs from expected, TypeScript compile errors until adjusted. +- Must ensure capability allows dialog (already present). +- Adding error toast may expose technical messages; should map to friendly i18n key later. +- For clone flow, path semantics still weak (`cloneUrl` may be passed to create_project in some paths) — separate issue. + +## Unresolved questions +1. Should onboarding in current branch be treated as production flow now, or still prototype-only for internal testing? +2. Team preference: plugin-dialog direct frontend use vs custom Rust command for policy centralization? +3. Do we want browse action available in both `/onboarding` and `/new-project` immediately (shared component implies yes)? diff --git a/plans/reports/debugger-260224-1906-git-setup-folder-browse-macos-audit.md b/plans/reports/debugger-260224-1906-git-setup-folder-browse-macos-audit.md new file mode 100644 index 0000000..58fc4c3 --- /dev/null +++ b/plans/reports/debugger-260224-1906-git-setup-folder-browse-macos-audit.md @@ -0,0 +1,97 @@ +# Debugger Audit — Git Setup local folder browse trên macOS + +## Executive summary +- Kết luận: có bug thật trong code hiện tại. +- Root cause chính: UI gọi IPC command không tồn tại `open_directory_dialog`, lỗi bị nuốt trong `catch` nên user thấy nút Browse “không làm gì”. +- Scope verdict: bug này **không thuộc M1 Data Foundation**, thuộc onboarding/settings polish (M4). + +## Trace bắt buộc (end-to-end) + +### 1) UI event +- Browse button ở Git Setup: + - `/Users/thieunv/projects/solo-builder/vividkit-workspace/vividkit-app/src/components/onboarding/step-git-setup.tsx:71` + - `onClick={browse}` + +### 2) hook/store path +- Hàm `browse()` gọi invoke: + - `/Users/thieunv/projects/solo-builder/vividkit-workspace/vividkit-app/src/components/onboarding/step-git-setup.tsx:19-23` +- Nếu chọn được path thì patch state local wizard (`patch({ gitPath: selected })`): + - `/Users/thieunv/projects/solo-builder/vividkit-workspace/vividkit-app/src/components/onboarding/step-git-setup.tsx:23` +- `patch` cập nhật `useState` trong wizard, chưa qua store tại bước browse: + - `/Users/thieunv/projects/solo-builder/vividkit-workspace/vividkit-app/src/components/onboarding/onboarding-wizard.tsx:34-36` +- Store (`addProject`) chỉ gọi ở bước finish: + - `/Users/thieunv/projects/solo-builder/vividkit-workspace/vividkit-app/src/components/onboarding/onboarding-wizard.tsx:41-52` + +### 3) invoke wrapper +- Không có wrapper riêng cho folder picker. +- UI gọi trực tiếp `invoke('open_directory_dialog')`: + - `/Users/thieunv/projects/solo-builder/vividkit-workspace/vividkit-app/src/components/onboarding/step-git-setup.tsx:22` +- Wrapper pattern có tồn tại cho project CRUD (để đối chiếu): + - `/Users/thieunv/projects/solo-builder/vividkit-workspace/vividkit-app/src/lib/tauri-project.ts:16-34` + +### 4) tauri command/dialog API +- Call hiện tại: `invoke('open_directory_dialog')`: + - `/Users/thieunv/projects/solo-builder/vividkit-workspace/vividkit-app/src/components/onboarding/step-git-setup.tsx:22` +- Backend command registry **không có** `open_directory_dialog` trong `generate_handler![]`: + - `/Users/thieunv/projects/solo-builder/vividkit-workspace/vividkit-app/src-tauri/src/lib.rs:22-69` +- Dialog plugin đã init ở backend: + - `/Users/thieunv/projects/solo-builder/vividkit-workspace/vividkit-app/src-tauri/src/lib.rs:12` +- Không tìm thấy Rust command/function tên `open_directory_dialog` trong `src-tauri/src/**` (grep only callsite ở TS). + +### 5) capability/permission config +- Capability default đã cấp quyền dialog và fs: + - `/Users/thieunv/projects/solo-builder/vividkit-workspace/vividkit-app/src-tauri/capabilities/default.json:12-13` + - `dialog:default`, `fs:default` +- `tauri.conf.json` không chặn dialog; chỉ có shell plugin config: + - `/Users/thieunv/projects/solo-builder/vividkit-workspace/vividkit-app/src-tauri/tauri.conf.json:35-39` + +### 6) error handling -> user-visible behavior +- `catch` silent, không toast/log: + - `/Users/thieunv/projects/solo-builder/vividkit-workspace/vividkit-app/src/components/onboarding/step-git-setup.tsx:24-26` +- Hành vi user thấy: click Browse, spinner có thể chạy ngắn rồi không có dialog, không báo lỗi. + +## Code path liên quan browse/chọn folder local + +### Có browse thực sự +1. Onboarding Git Setup (route `/onboarding`): + - Component: `/Users/thieunv/projects/solo-builder/vividkit-workspace/vividkit-app/src/components/onboarding/step-git-setup.tsx` +2. New Project page dùng lại cùng component: + - `/Users/thieunv/projects/solo-builder/vividkit-workspace/vividkit-app/src/pages/new-project.tsx:39` + +### Không có browse (chỉ nhập text) +3. Settings Git path (`worktreesDir`) chỉ là Input + `onBlur updateSettings`, không mở dialog: + - `/Users/thieunv/projects/solo-builder/vividkit-workspace/vividkit-app/src/components/settings/settings-git.tsx:37-43` +4. Settings store + IPC chỉ lưu settings: + - `/Users/thieunv/projects/solo-builder/vividkit-workspace/vividkit-app/src/stores/settings-store.ts:89-106` + - `/Users/thieunv/projects/solo-builder/vividkit-workspace/vividkit-app/src/lib/tauri-settings.ts:4-9` + +## Root cause verdict +- Root cause chính (high confidence): **IPC contract mismatch**. + - Frontend gọi command không tồn tại: `open_directory_dialog`. + - Backend không expose command này. + - Lỗi bị nuốt -> UX im lặng. + +## M1 scope đối chiếu +- M1 theo roadmap: Data foundation (SQLite + CRUD + IPC wiring), đã complete: + - `/Users/thieunv/projects/solo-builder/vividkit-workspace/vividkit-app/docs/development-roadmap.md:71-80` +- Onboarding real git repo picker thuộc M4, chưa started: + - `/Users/thieunv/projects/solo-builder/vividkit-workspace/vividkit-app/docs/development-roadmap.md:104-109` +- Plan M1 cũng chỉ mô tả DB/CRUD/store integration, không có onboarding folder dialog: + - `/Users/thieunv/projects/solo-builder/vividkit-workspace/vividkit-app/plans/260224-1104-m1-data-foundation/plan.md:16-17,31-39` + +## Final classification +- **Bug status:** YES (defect trong code path hiện hữu). +- **M1 scope:** OUT-OF-SCOPE (không phải blocker của M1). +- **Ưu tiên xử lý:** nên xử lý sớm vì tác động UX onboarding/new-project. + +## Actionable next step (không sửa code ở audit này) +1. Chọn 1 trong 2 hướng triển khai: + - Hướng A (khuyên dùng): dùng trực tiếp Tauri dialog plugin API ở frontend. + - Hướng B: thêm Rust command `open_directory_dialog` rồi register vào `generate_handler!`. +2. Bắt buộc bổ sung hiển thị lỗi user-facing (toast/i18n key), không silent catch. +3. Giữ cancel dialog là non-error path. + +## Unresolved questions +1. Team muốn chuẩn hóa picker qua frontend plugin API hay luôn đi qua Rust command để centralize policy? +2. Với branch `mvp`, onboarding/new-project hiện được coi production-ready hay vẫn prototype-only? +3. Có yêu cầu đồng bộ behavior browse path giữa onboarding và settings (worktreesDir) trong cùng milestone không? \ No newline at end of file diff --git a/plans/reports/reviewer-260224-1644-wave5-code-review-gate-m1-data-foundation.md b/plans/reports/reviewer-260224-1644-wave5-code-review-gate-m1-data-foundation.md new file mode 100644 index 0000000..dc222d0 --- /dev/null +++ b/plans/reports/reviewer-260224-1644-wave5-code-review-gate-m1-data-foundation.md @@ -0,0 +1,145 @@ +## Code Review Summary + +### Scope +- Files: M1 Data Foundation changes (Rust DB/commands/models, TS IPC wrappers/stores, startup init) + relevant consumers (`src/components/settings/ccs-test-console.tsx`) +- LOC: `git diff --stat` ~1335 insertions / 252 deletions (21 tracked files modified + many new files) +- Focus: Wave 5 review gate after test gate PASS (#12) +- Scout findings: xác nhận 1 integration gap (`list_ccs_profiles`), 1 persistence edge case (`merged_at` clear), 1 nhóm resilience edge cases (dirty DB enum/JSON values) + +### Overall Assessment +- **Tổng thể tốt, kiến trúc phù hợp M1**: DB foundation + migration + CRUD commands + IPC + Zustand + startup flow đã khớp nhau và bám sát plan. +- **Data contract TS↔Rust (status literals, lastActiveProjectId, snake_case/camelCase serialization)** nhìn chung **nhất quán**. +- **Security/safety cơ bản tốt**: SQL dùng params, `git_path` có canonicalize + git repo validation. +- **Kết luận gate:** **no release blocker** cho M1 Data Foundation. +- Có **1 issue High** cần ưu tiên xử lý sớm vì có thể gây **silent lost update** trong settings persistence khi có concurrent writes. + +### Critical Issues +- Không phát hiện critical blocker. + +### High Priority + +#### 1) Race condition / lost update trong `settings` persistence (full-row overwrite + concurrent patch writes) +- **Evidence** + - `src/stores/settings-store.ts:55-60` (`saveSettings` gửi toàn bộ `AppSettings`) + - `src/stores/settings-store.ts:68-70` (`updateSettings` merge patch trên snapshot local rồi save full object) + - `src-tauri/src/commands/settings.rs:61-83` (`UPDATE app_settings SET ...` ghi đè toàn bộ cột) + - `src/hooks/use-app-init.ts:107-108` (fire-and-forget `updateSettings({ lastActiveProjectId })` khi đổi project) + - `src/components/settings/command-provider-row.tsx:18-22` (UI settings cũng gọi `updateSettings` patch) +- **Impact** + - Hai cập nhật settings chạy gần nhau (ví dụ đổi `activeProjectId` + đổi `commandProviders/theme`) có thể cùng merge từ **snapshot cũ** rồi ghi đè nhau theo kiểu **last write wins**. + - Có thể gây **mất dữ liệu settings âm thầm** (e.g. `commandProviders` bị revert hoặc `lastActiveProjectId` bị rollback). +- **Recommendation** + - Ưu tiên 1 trong các hướng (KISS): + 1. **Serialize settings writes** trong `settings-store` (queue/lock theo store), luôn chờ write trước xong mới write tiếp. + 2. Tách command backend **partial update** (ví dụ `update_last_active_project_id`) để tránh full-row overwrite cho auto-persist. + 3. (Nếu giữ full-row) re-read latest settings trước mỗi write và chống overlap bằng in-flight promise chain. + +### Medium Priority + +#### 2) `list_ccs_profiles` đã register ở backend nhưng chưa được wire end-to-end (UI vẫn hardcoded profiles) +- **Evidence** + - `src-tauri/src/commands/ccs_profile.rs:99-146` (đã implement dynamic scan `~/.ccs/`) + - `src-tauri/src/lib.rs:29` (đã register command) + - `src/components/settings/ccs-test-console.tsx:13` (hardcoded `PROFILES = ['default', 'glm', 'gemini', 'kimi', 'codex']`) + - `src/components/settings/ccs-test-console.tsx:127-130` (Select render từ hardcoded list) + - `src/lib/tauri-ccs.ts:1-41` (chưa có wrapper `listCcsProfiles`) +- **Impact** + - Quyết định plan/validation về **dynamic CCS profile discovery** mới hoàn thành ở backend, chưa đến UI consumer. + - User có custom profile trong `~/.ccs/` sẽ **không thấy** trong test console/profile picker hiện tại. +- **Recommendation** + - Thêm wrapper `listCcsProfiles()` ở `src/lib/tauri-ccs.ts`, load vào `ccs-test-console` (và onboarding nếu scope cho phép). + - Có thể giữ hardcoded list làm fallback khi command lỗi/empty, nhưng source canonical nên là backend scan. + +#### 3) Resilience edge case: dữ liệu DB “bẩn/legacy” có thể làm fail cả read flow (enum/JSON parse fail-hard) +- **Evidence** + - Settings JSON parse fail-hard: `src-tauri/src/commands/settings.rs:8-11`, `src-tauri/src/commands/settings.rs:40` + - Enum parse fail-hard ví dụ: + - `src-tauri/src/models/project.rs:11-20` (`CcsAccountStatus::from_db`) + - `src-tauri/src/models/task.rs:35-44`, `63-70`, `88-95` (task status/priority/type) + - `src-tauri/src/models/plan.rs:20-27` (phase status) + - `src-tauri/src/models/brainstorm.rs:20-27` (brainstorm status) + - `src-tauri/src/models/worktree.rs:20-27` (worktree status) +- **Impact** + - Một row có giá trị ngoài tập cho phép (do DB cũ/manual edit/corruption) có thể làm **toàn bộ API đọc danh sách** trả lỗi. + - `get_settings` fail sẽ kéo theo startup error screen (`src/hooks/use-app-init.ts:45-49`, `src/App.tsx:38-46`). +- **Recommendation** + - Xác định policy rõ ràng: + - **Fail-closed** (current behavior, an toàn) nếu muốn strict schema. + - Hoặc **degrade gracefully** cho local app UX: fallback `command_providers={}` và/hoặc skip row invalid + log warning. + - Nếu giữ strict, nên log rõ hơn + có migration/repair path sau này. + +### Low Priority + +#### 4) `merged_at` không thể clear về `NULL` trong `update_worktree_record` +- **Evidence** + - `src-tauri/src/commands/worktree_cmd.rs:109-115` (`merged_at.or(current.merged_at)`) + - `src/stores/worktree-store.ts:81-87` (status update giữ `mergedAt` cũ khi không phải `merged`) +- **Impact** + - Nếu sau này có flow rollback/unmerge, metadata `mergedAt` có thể bị kẹt/stale. +- **Recommendation** + - Làm rõ semantics: `mergedAt` là immutable audit timestamp hay nullable state field. + - Nếu cần clear, dùng patch API phân biệt `omit` vs `explicit null`. + +#### 5) Duplicate model `AppSettings` (DRY/canonical-source confusion) +- **Evidence** + - `src-tauri/src/models/config.rs:1-16` + - `src-tauri/src/models/settings.rs:1-16` + - `src-tauri/src/models/mod.rs:3`, `src-tauri/src/models/mod.rs:8` +- **Impact** + - Dễ drift contract về sau, khó xác định source canonical cho settings model. +- **Recommendation** + - Giữ một model canonical (`models/settings.rs`), xoá/retire alias không dùng. + +#### 6) `ccs_profile` home-dir resolution chưa theo project convention `dirs` crate +- **Evidence** + - `src-tauri/src/commands/ccs_profile.rs:12-18` +- **Impact** + - Không phải bug ngay, nhưng giảm độ nhất quán cross-platform so với rule trong `CLAUDE.md`. +- **Recommendation** + - Chuyển sang `dirs` (hoặc Tauri path API nếu muốn inject app handle) để thống nhất convention. + +#### 7) Plan progress docs chưa phản ánh trạng thái triển khai (process conformance) +- **Evidence** + - `plans/260224-1104-m1-data-foundation/plan.md:4` (`status: pending`) + - `plans/260224-1104-m1-data-foundation/plan.md:33-38` (phase table vẫn Pending) +- **Impact** + - Ảnh hưởng tracking/traceability, không ảnh hưởng runtime. +- **Recommendation** + - Để task docs impact (#14) cập nhật phase/status + checklist completion. + +### Edge Cases Found by Scout +- `list_ccs_profiles` integration gap (backend có, UI chưa dùng) — **confirmed**. +- Dirty/legacy enum values gây fail hard khi đọc DB — **confirmed**. +- `worktree.merged_at` không clear được — **confirmed**. +- `runId` camelCase invoke arg mismatch — **không xác nhận là lỗi blocker** (pattern Tauri hiện dùng rộng trong codebase; không có bằng chứng runtime fail từ scope review này). + +### Positive Observations +- **DB init/migration chắc tay**: + - Pool `r2d2` + per-connection PRAGMA FK/WAL (`src-tauri/src/db/mod.rs:33-41`, `50-58`) + - Versioned migration + EXCLUSIVE transaction + rollback (`src-tauri/src/db/migrations.rs:121-147`) +- **Security/safety cơ bản tốt**: + - SQL đều dùng parameterized queries (nhiều command files) + - `git_path` có canonicalize + `git2::Repository::open()` validation trước DB write (`src-tauri/src/commands/project.rs:20-28`, `103-109`) +- **Data contract TS↔Rust nhìn chung khớp**: + - Project/task/plan/brainstorm/worktree status literals align giữa TS unions và Rust enums (`src/types/*.ts`, `src-tauri/src/models/*.rs`) + - `lastActiveProjectId` persist path align TS↔Rust↔DB (`src/types/settings.ts:1-10`, `src-tauri/src/models/settings.rs:5-16`, `src-tauri/src/commands/settings.rs:13-43`) +- **Startup flow có error screen rõ ràng**, không để blank app (`src/hooks/use-app-init.ts:36-93`, `src/App.tsx:19-49`) +- **`list_ccs_profiles` command registration canonical** ở backend là đúng (module export + invoke registration): `src-tauri/src/commands/mod.rs:1-12`, `src-tauri/src/lib.rs:22-30` + +### Recommended Actions +1. **Fix high-priority settings write race** (serialize writes hoặc tách partial command cho `lastActiveProjectId`). +2. Wire `list_ccs_profiles` end-to-end (TS wrapper + `ccs-test-console` consumer; onboarding follow-up nếu trong scope). +3. Chốt policy cho dirty DB resilience (strict fail vs graceful fallback) và implement nhất quán. +4. Làm rõ/chuẩn hóa semantics `worktree.mergedAt` (immutable vs nullable state). +5. Cleanup canonical models (`config.rs` vs `settings.rs`) + cập nhật plan/docs status trong task #14. + +### Metrics +- Type Coverage: N/A (không đo trong review gate) +- Test Coverage: N/A (không có coverage run trong scope) +- Linting Issues: N/A (không chạy lint trong review gate) +- Build/Test gate reference: **Task #12 PASS** (`cargo check` + `npm run build` theo context team lead) + +### Unresolved Questions +- `worktree.mergedAt` có chủ đích là timestamp lịch sử (không clear) hay là state-derived field phải null khi status != `merged`? +- `list_ccs_profiles` có được xem là yêu cầu runtime của M1 UI (settings console/onboarding) hay chỉ backend capability cho phase sau? +- Với local-first app, team muốn policy strict hay graceful khi gặp DB enum/JSON values không hợp lệ? diff --git a/plans/reports/tester-260224-1553-phase3-task7-readonly-validation.md b/plans/reports/tester-260224-1553-phase3-task7-readonly-validation.md new file mode 100644 index 0000000..3bd1b06 --- /dev/null +++ b/plans/reports/tester-260224-1553-phase3-task7-readonly-validation.md @@ -0,0 +1,39 @@ +# Task #7 Phase 3 Backend Content - Read-only Validation + +## Scope +- cargo check cho `src-tauri/Cargo.toml` +- rustfmt --check cho 8 file ownership +- Verify đủ 23 command fn MVP trong 4 command files, không có `update_plan` +- Verify worktree model/commands không persist `files_changed` từ DB + +## Results + +### 1) `cargo check --manifest-path src-tauri/Cargo.toml` +- **PASS** +- Build hoàn tất: `Finished 'dev' profile ...` +- Có warning `dead_code`/`unused` (không block build) + +### 2) `rustfmt --check` (8 files) +- **PASS** +- Command chạy không trả format error + +### 3) 23 command fn MVP trong 4 command files, không có `update_plan` +- **PASS** +- Count `#[tauri::command]`: + - `src-tauri/src/commands/task.rs`: 6 + - `src-tauri/src/commands/plan.rs`: 7 + - `src-tauri/src/commands/brainstorm.rs`: 6 + - `src-tauri/src/commands/worktree_cmd.rs`: 4 + - **Total: 23** +- Search `update_plan` trong `src-tauri/src/commands/*.rs`: **No matches** + +### 4) Worktree không persist `files_changed` từ DB +- **PASS** +- `Worktree` model có field `files_changed`, default/computed (`files_changed: 0`) +- SQL trong `worktree_cmd.rs`: + - INSERT không có cột `files_changed` + - UPDATE không set `files_changed` + - Mapping row set `files_changed: 0` + +## Unresolved questions +- None. diff --git a/plans/reports/tester-260224-1644-wave5-test-gate-m1-data-foundation.md b/plans/reports/tester-260224-1644-wave5-test-gate-m1-data-foundation.md new file mode 100644 index 0000000..6d07649 --- /dev/null +++ b/plans/reports/tester-260224-1644-wave5-test-gate-m1-data-foundation.md @@ -0,0 +1,109 @@ +# Wave 5 Test Gate Report (M1 Data Foundation) + +- Role: tester +- Timestamp tag: 260224-1644 +- Scope: Wave 5 gate after tasks #4-#11 + follow-ups #15/#16 +- Work context: `/Users/thieunv/projects/solo-builder/vividkit-workspace/vividkit-app` + +## Files changed +- `plans/reports/tester-260224-1644-wave5-test-gate-m1-data-foundation.md` (this report only) + +## Checklist +- [x] `TaskGet #12` scope reviewed +- [x] Rust gate: `cargo check --manifest-path src-tauri/Cargo.toml` +- [x] Frontend gate: `npm run build` (via `npm --prefix ... run build`) +- [x] Quick test-suite discovery (frontend + Rust) +- [x] Consolidated pass/fail summary for M1 + +## Test Results Overview +- Total gates executed: 2 required gates +- Passed: 2 +- Failed: 0 +- Skipped: 0 (required gates) +- Additional tests executed: 0 (no project test suites detected/configured in current repo scope) + +## Gate Results (Sequential) + +### 1) Rust gate — PASS +Command: +```bash +cargo check --manifest-path "/Users/thieunv/projects/solo-builder/vividkit-workspace/vividkit-app/src-tauri/Cargo.toml" +``` + +Result: +- PASS (`Finished 'dev' profile`) +- Compile completed in ~0.67s (from cargo output) + +Warnings (non-blocking, backend owner): +- `src-tauri/src/models/ccs_account.rs:1:9` unused import `super::project::CcsAccount` +- `src-tauri/src/models/enums.rs:1:9` unused import `super::project::CcsAccountStatus` +- `src-tauri/src/db/mod.rs:21:12` method `pool` never used +- `src-tauri/src/models/config.rs:7:12` struct `AppSettings` never constructed + +### 2) Frontend gate — PASS +Command: +```bash +npm --prefix "/Users/thieunv/projects/solo-builder/vividkit-workspace/vividkit-app" run build +``` + +Underlying script (`package.json`): +```bash +tsc && vite build +``` + +Result: +- PASS (TypeScript compile + Vite production build successful) +- Vite build completed in ~2.00s (from output) +- Output bundles generated under `dist/` + +## Test Suite Discovery (quick signal) + +### Frontend tests +- `package.json` has no `test` / `test:coverage` script. +- Search in project `src/**` found no app-level `*.test.*` / `*.spec.*` files. +- Note: test files were found under `node_modules/**` only (ignored; dependency internals). + +### Rust tests +- Search in `src-tauri/**` found no `#[test]` / `#[tokio::test]` matches. +- No `src-tauri/tests/**` integration test files found. + +Conclusion: +- No runnable project test suites currently present for extra quick execution beyond build/compile gates. + +## Coverage Metrics +- Line coverage: N/A (no test runner / coverage script configured) +- Branch coverage: N/A +- Function coverage: N/A + +## Failed Tests +- None + +## Performance Metrics +- `cargo check`: ~0.67s +- `npm run build` (tsc + vite build): Vite reported ~2.00s (tsc included in command, no separate duration line) +- Slow tests: N/A (no tests executed) + +## Build Status +- Rust backend compile gate: PASS (warnings only) +- Frontend TS + production build gate: PASS +- Overall Wave 5 test gate (M1): PASS + +## Critical Issues +- None blocking #13 code review gate. + +## Recommendations +1. Backend cleanup (non-blocking): remove/justify unused re-exports and dead code warnings to keep cargo output clean. +2. Add minimal smoke tests next iteration: + - Rust: 1-2 DB/command unit tests (schema init, simple CRUD path) + - Frontend: 1 store/action test or IPC wrapper type-safe smoke +3. Add `npm test` / coverage script once test framework selected (Vitest likely fit for Vite/React). + +## Next Steps +1. Open Task #13 (Wave 5 code review gate). +2. Optionally batch-fix Rust warnings before merge/release hardening. + +## Blockers +- None + +## Unresolved questions +- None diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 83e7518..0ccf988 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -8,18 +8,6 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" -[[package]] -name = "ahash" -version = "0.8.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" -dependencies = [ - "cfg-if", - "once_cell", - "version_check", - "zerocopy", -] - [[package]] name = "aho-corasick" version = "1.1.4" @@ -977,6 +965,12 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + [[package]] name = "foreign-types" version = "0.3.2" @@ -1484,22 +1478,13 @@ version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" -[[package]] -name = "hashbrown" -version = "0.14.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" -dependencies = [ - "ahash", -] - [[package]] name = "hashbrown" version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ - "foldhash", + "foldhash 0.1.5", ] [[package]] @@ -1507,14 +1492,17 @@ name = "hashbrown" version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "foldhash 0.2.0", +] [[package]] name = "hashlink" -version = "0.9.1" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +checksum = "ea0b22561a9c04a7cb1a302c013e0259cd3b4bb619f145b32f72b8b4bcbed230" dependencies = [ - "hashbrown 0.14.5", + "hashbrown 0.16.1", ] [[package]] @@ -2130,9 +2118,9 @@ dependencies = [ [[package]] name = "libsqlite3-sys" -version = "0.30.1" +version = "0.36.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +checksum = "95b4103cffefa72eb8428cb6b47d6627161e51c2739fc5e3b734584157bc642a" dependencies = [ "cc", "pkg-config", @@ -3124,6 +3112,28 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "r2d2" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51de85fb3fb6524929c8a2eb85e6b6d363de4e8c48f9e2c2eac4944abc181c93" +dependencies = [ + "log", + "parking_lot", + "scheduled-thread-pool", +] + +[[package]] +name = "r2d2_sqlite" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2ebd03c29250cdf191da93a35118b4567c2ef0eacab54f65e058d6f4c9965f6" +dependencies = [ + "r2d2", + "rusqlite", + "uuid", +] + [[package]] name = "rand" version = "0.7.3" @@ -3149,6 +3159,16 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + [[package]] name = "rand_chacha" version = "0.2.2" @@ -3169,6 +3189,16 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + [[package]] name = "rand_core" version = "0.5.1" @@ -3187,6 +3217,15 @@ dependencies = [ "getrandom 0.2.17", ] +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + [[package]] name = "rand_hc" version = "0.2.0" @@ -3404,11 +3443,21 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rsqlite-vfs" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8a1f2315036ef6b1fbacd1972e8ee7688030b0a2121edfc2a6550febd41574d" +dependencies = [ + "hashbrown 0.16.1", + "thiserror 2.0.18", +] + [[package]] name = "rusqlite" -version = "0.32.1" +version = "0.38.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" +checksum = "f1c93dd1c9683b438c392c492109cb702b8090b2bfc8fed6f6e4eb4523f17af3" dependencies = [ "bitflags 2.11.0", "fallible-iterator", @@ -3416,6 +3465,7 @@ dependencies = [ "hashlink", "libsqlite3-sys", "smallvec", + "sqlite-wasm-rs", ] [[package]] @@ -3503,6 +3553,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "scheduled-thread-pool" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cbc66816425a074528352f5789333ecff06ca41b36b0b0efdfbb29edc391a19" +dependencies = [ + "parking_lot", +] + [[package]] name = "schemars" version = "0.8.22" @@ -3928,6 +3987,18 @@ dependencies = [ "system-deps", ] +[[package]] +name = "sqlite-wasm-rs" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4206ed3a67690b9c29b77d728f6acc3ce78f16bf846d83c94f76400320181b" +dependencies = [ + "cc", + "js-sys", + "rsqlite-vfs", + "wasm-bindgen", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -4172,6 +4243,8 @@ version = "0.1.0" dependencies = [ "git2", "notify", + "r2d2", + "r2d2_sqlite", "reqwest 0.12.28", "rusqlite", "serde", @@ -4183,6 +4256,7 @@ dependencies = [ "tauri-plugin-opener", "tauri-plugin-shell", "tokio", + "uuid", ] [[package]] @@ -4948,6 +5022,7 @@ checksum = "b672338555252d43fd2240c714dc444b8c6fb0a5c5335e65a07bba7742735ddb" dependencies = [ "getrandom 0.4.1", "js-sys", + "rand 0.9.2", "serde_core", "wasm-bindgen", ] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 9970afe..60e1656 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -20,10 +20,13 @@ tauri-plugin-opener = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" git2 = { version = "0.20", features = ["vendored-libgit2"] } -rusqlite = { version = "0.32", features = ["bundled"] } +rusqlite = { version = "0.38", features = ["bundled"] } tokio = { version = "1", features = ["full"] } notify = "7" tauri-plugin-shell = "2" tauri-plugin-dialog = "2" tauri-plugin-fs = "2" reqwest = { version = "0.12", features = ["json", "stream"] } +uuid = { version = "1", features = ["v4"] } +r2d2 = "0.8" +r2d2_sqlite = "0.32" diff --git a/src-tauri/src/commands/ai.rs b/src-tauri/src/commands/ai.rs index 3f4a483..5726164 100644 --- a/src-tauri/src/commands/ai.rs +++ b/src-tauri/src/commands/ai.rs @@ -188,5 +188,3 @@ pub async fn send_ccs_input( child.write(data.as_bytes()).map_err(|e| e.to_string()) } -#[tauri::command] -pub async fn list_ccs_profiles() -> Result, String> { Err("Not implemented".to_string()) } diff --git a/src-tauri/src/commands/brainstorm.rs b/src-tauri/src/commands/brainstorm.rs new file mode 100644 index 0000000..219434a --- /dev/null +++ b/src-tauri/src/commands/brainstorm.rs @@ -0,0 +1,191 @@ +use rusqlite::{params, Connection, OptionalExtension}; +use tauri::State; +use uuid::Uuid; + +use crate::db::DbState; +use crate::models::brainstorm::{ + to_insight, to_session, BrainstormSession, BrainstormStatus, KeyInsight, +}; + +fn now_iso8601(conn: &Connection) -> Result { + conn.query_row("SELECT strftime('%Y-%m-%dT%H:%M:%fZ','now')", [], |row| { + row.get(0) + }) + .map_err(|e| e.to_string()) +} + +fn fetch_session(conn: &Connection, id: &str) -> Result { + let row = conn + .query_row( + "SELECT id, deck_id, prompt, report_path, status, created_at + FROM brainstorm_sessions WHERE id = ?1", + [id], + |row| { + Ok(( + row.get(0)?, + row.get(1)?, + row.get(2)?, + row.get(3)?, + row.get(4)?, + row.get(5)?, + )) + }, + ) + .optional() + .map_err(|e| e.to_string())?; + + let row = row.ok_or_else(|| format!("Brainstorm session not found: {id}"))?; + to_session(row) +} + +#[tauri::command] +pub fn create_brainstorm_session( + db: State<'_, DbState>, + deck_id: String, + prompt: String, +) -> Result { + let conn = db.get_conn()?; + let id = Uuid::new_v4().to_string(); + let created_at = now_iso8601(&conn)?; + + conn.execute( + "INSERT INTO brainstorm_sessions (id, deck_id, prompt, report_path, status, created_at) + VALUES (?1, ?2, ?3, NULL, ?4, ?5)", + params![ + id, + deck_id, + prompt, + BrainstormStatus::Idle.as_db_str(), + created_at, + ], + ) + .map_err(|e| e.to_string())?; + + fetch_session(&conn, &id) +} + +#[tauri::command] +pub fn list_brainstorm_sessions( + db: State<'_, DbState>, + deck_id: String, +) -> Result, String> { + let conn = db.get_conn()?; + let mut stmt = conn + .prepare( + "SELECT id, deck_id, prompt, report_path, status, created_at + FROM brainstorm_sessions WHERE deck_id = ?1 ORDER BY created_at DESC", + ) + .map_err(|e| e.to_string())?; + + let rows = stmt + .query_map([deck_id], |row| { + Ok(( + row.get(0)?, + row.get(1)?, + row.get(2)?, + row.get(3)?, + row.get(4)?, + row.get(5)?, + )) + }) + .map_err(|e| e.to_string())?; + + rows.map(|row| row.map_err(|e| e.to_string()).and_then(to_session)) + .collect::, _>>() +} + +#[tauri::command] +pub fn update_brainstorm_session( + db: State<'_, DbState>, + id: String, + status: Option, + report_path: Option, +) -> Result { + let conn = db.get_conn()?; + let current = fetch_session(&conn, &id)?; + + conn.execute( + "UPDATE brainstorm_sessions SET status = ?1, report_path = ?2 WHERE id = ?3", + params![ + status.unwrap_or(current.status).as_db_str(), + report_path.or(current.report_path), + &id, + ], + ) + .map_err(|e| e.to_string())?; + + fetch_session(&conn, &id) +} + +#[tauri::command] +pub fn create_key_insight( + db: State<'_, DbState>, + project_id: String, + deck_id: String, + title: String, + report_path: String, +) -> Result { + let conn = db.get_conn()?; + let id = Uuid::new_v4().to_string(); + let created_at = now_iso8601(&conn)?; + + conn.execute( + "INSERT INTO key_insights (id, project_id, deck_id, title, report_path, created_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + params![id, project_id, deck_id, title, report_path, created_at], + ) + .map_err(|e| e.to_string())?; + + Ok(to_insight(( + id, + project_id, + deck_id, + title, + report_path, + created_at, + ))) +} + +#[tauri::command] +pub fn list_key_insights( + db: State<'_, DbState>, + deck_id: String, +) -> Result, String> { + let conn = db.get_conn()?; + let mut stmt = conn + .prepare( + "SELECT id, project_id, deck_id, title, report_path, created_at + FROM key_insights WHERE deck_id = ?1 ORDER BY created_at DESC", + ) + .map_err(|e| e.to_string())?; + + let rows = stmt + .query_map([deck_id], |row| { + Ok(( + row.get(0)?, + row.get(1)?, + row.get(2)?, + row.get(3)?, + row.get(4)?, + row.get(5)?, + )) + }) + .map_err(|e| e.to_string())?; + + rows.map(|row| row.map(to_insight).map_err(|e| e.to_string())) + .collect::, _>>() +} + +#[tauri::command] +pub fn delete_key_insight(db: State<'_, DbState>, id: String) -> Result<(), String> { + let conn = db.get_conn()?; + let deleted = conn + .execute("DELETE FROM key_insights WHERE id = ?1", [id.clone()]) + .map_err(|e| e.to_string())?; + + if deleted == 0 { + return Err(format!("Key insight not found: {id}")); + } + + Ok(()) +} diff --git a/src-tauri/src/commands/ccs_profile.rs b/src-tauri/src/commands/ccs_profile.rs new file mode 100644 index 0000000..cca7b26 --- /dev/null +++ b/src-tauri/src/commands/ccs_profile.rs @@ -0,0 +1,146 @@ +use std::path::PathBuf; + +use serde::Serialize; + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CcsProfile { + pub name: String, + pub profile_type: String, +} + +fn ccs_root_dir() -> Result { + std::env::var_os("HOME") + .or_else(|| std::env::var_os("USERPROFILE")) + .map(PathBuf::from) + .map(|home| home.join(".ccs")) + .ok_or_else(|| "Unable to resolve home directory".to_string()) +} + +fn parse_yaml_profiles(config_yaml: &str) -> Vec { + let mut names = Vec::new(); + let mut in_profiles = false; + let mut profiles_indent = 0usize; + let mut child_indent: Option = None; + + for line in config_yaml.lines() { + let trimmed_end = line.trim_end(); + if trimmed_end.trim().is_empty() || trimmed_end.trim_start().starts_with('#') { + continue; + } + + let indent = trimmed_end + .chars() + .take_while(|c| c.is_whitespace()) + .count(); + let trimmed = trimmed_end.trim_start(); + + if !in_profiles { + if trimmed.starts_with("profiles:") { + in_profiles = true; + profiles_indent = indent; + } + continue; + } + + if indent <= profiles_indent { + break; + } + + if child_indent.is_none() && trimmed.ends_with(':') { + child_indent = Some(indent); + } + + if Some(indent) != child_indent || !trimmed.ends_with(':') { + continue; + } + + if let Some((key, _)) = trimmed.split_once(':') { + let key_trimmed = key.trim(); + if !key_trimmed.is_empty() { + names.push(key_trimmed.to_string()); + } + } + } + + names +} + +fn profile_name_from_settings_file(path: &PathBuf) -> Option { + let file_name = path.file_name()?.to_str()?; + file_name + .strip_suffix(".settings.json") + .map(|s| s.to_string()) + .filter(|s| !s.is_empty()) +} + +fn profile_name_from_instance(path: &PathBuf) -> Option { + if path.is_file() { + let file_name = path.file_name()?.to_str()?; + if file_name.ends_with(".json") { + return file_name + .strip_suffix(".json") + .map(|s| s.to_string()) + .filter(|s| !s.is_empty()); + } + } + + if path.is_dir() { + return path + .file_name() + .and_then(|n| n.to_str()) + .map(|s| s.to_string()) + .filter(|s| !s.is_empty()); + } + + None +} + +#[tauri::command] +pub fn list_ccs_profiles() -> Result, String> { + let root = ccs_root_dir()?; + if !root.exists() { + return Ok(Vec::new()); + } + + let mut profiles = std::collections::HashMap::::new(); + + let config_yaml_path = root.join("config.yaml"); + if config_yaml_path.is_file() { + let content = std::fs::read_to_string(config_yaml_path).map_err(|e| e.to_string())?; + for name in parse_yaml_profiles(&content) { + profiles.entry(name).or_insert_with(|| "yaml".to_string()); + } + } + + let root_entries = std::fs::read_dir(&root).map_err(|e| e.to_string())?; + for entry in root_entries { + let entry = entry.map_err(|e| e.to_string())?; + let path = entry.path(); + if let Some(name) = profile_name_from_settings_file(&path) { + profiles + .entry(name) + .or_insert_with(|| "settings".to_string()); + } + } + + let instances_dir = root.join("instances"); + if instances_dir.is_dir() { + let instance_entries = std::fs::read_dir(instances_dir).map_err(|e| e.to_string())?; + for entry in instance_entries { + let entry = entry.map_err(|e| e.to_string())?; + let path = entry.path(); + if let Some(name) = profile_name_from_instance(&path) { + profiles.entry(name).or_insert_with(|| "oauth".to_string()); + } + } + } + + let mut result: Vec = profiles + .into_iter() + .map(|(name, profile_type)| CcsProfile { name, profile_type }) + .collect(); + result.sort_by(|a, b| a.name.cmp(&b.name)); + + Ok(result) +} diff --git a/src-tauri/src/commands/deck.rs b/src-tauri/src/commands/deck.rs new file mode 100644 index 0000000..56e8c0f --- /dev/null +++ b/src-tauri/src/commands/deck.rs @@ -0,0 +1,152 @@ +use rusqlite::{params, OptionalExtension}; +use tauri::State; +use uuid::Uuid; + +use crate::{db::DbState, models::deck::Deck}; + +fn now_iso8601(conn: &rusqlite::Connection) -> Result { + conn.query_row("SELECT strftime('%Y-%m-%dT%H:%M:%fZ','now')", [], |row| { + row.get(0) + }) + .map_err(|e| e.to_string()) +} + +fn load_deck(conn: &rusqlite::Connection, id: &str) -> Result { + conn.query_row( + "SELECT id, project_id, name, description, is_active, based_on_insight_id, created_at FROM decks WHERE id = ?1", + params![id], + |row| { + let is_active: i64 = row.get(4)?; + Ok(Deck { + id: row.get(0)?, + project_id: row.get(1)?, + name: row.get(2)?, + description: row.get(3)?, + is_active: is_active != 0, + based_on_insight_id: row.get(5)?, + created_at: row.get(6)?, + }) + }, + ) + .optional() + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("Deck not found: {id}")) +} + +#[tauri::command] +pub fn create_deck( + state: State<'_, DbState>, + project_id: String, + name: String, + description: Option, + based_on_insight_id: Option, +) -> Result { + let conn = state.get_conn()?; + let id = Uuid::new_v4().to_string(); + let created_at = now_iso8601(&conn)?; + conn.execute( + "INSERT INTO decks (id, project_id, name, description, is_active, based_on_insight_id, created_at) VALUES (?1, ?2, ?3, ?4, 0, ?5, ?6)", + params![id, project_id, name, description, based_on_insight_id, created_at], + ) + .map_err(|e| e.to_string())?; + load_deck(&conn, &id) +} + +#[tauri::command] +pub fn list_decks(state: State<'_, DbState>, project_id: String) -> Result, String> { + let conn = state.get_conn()?; + let mut stmt = conn + .prepare( + "SELECT id, project_id, name, description, is_active, based_on_insight_id, created_at FROM decks WHERE project_id = ?1 ORDER BY created_at DESC", + ) + .map_err(|e| e.to_string())?; + let mut rows = stmt.query(params![project_id]).map_err(|e| e.to_string())?; + let mut decks = Vec::new(); + while let Some(row) = rows.next().map_err(|e| e.to_string())? { + let is_active: i64 = row.get(4).map_err(|e| e.to_string())?; + decks.push(Deck { + id: row.get(0).map_err(|e| e.to_string())?, + project_id: row.get(1).map_err(|e| e.to_string())?, + name: row.get(2).map_err(|e| e.to_string())?, + description: row.get(3).map_err(|e| e.to_string())?, + is_active: is_active != 0, + based_on_insight_id: row.get(5).map_err(|e| e.to_string())?, + created_at: row.get(6).map_err(|e| e.to_string())?, + }); + } + Ok(decks) +} + +#[tauri::command] +pub fn set_active_deck(state: State<'_, DbState>, id: String) -> Result { + let conn = state.get_conn()?; + conn.execute_batch("BEGIN IMMEDIATE TRANSACTION;") + .map_err(|e| e.to_string())?; + + let result = (|| { + let exists = conn + .query_row( + "SELECT project_id FROM decks WHERE id = ?1", + params![&id], + |row| row.get::<_, String>(0), + ) + .optional() + .map_err(|e| e.to_string())?; + if exists.is_none() { + return Err(format!("Deck not found: {id}")); + } + + conn.execute( + "UPDATE decks SET is_active = 0 WHERE project_id = (SELECT project_id FROM decks WHERE id = ?1)", + params![&id], + ) + .map_err(|e| e.to_string())?; + conn.execute("UPDATE decks SET is_active = 1 WHERE id = ?1", params![&id]) + .map_err(|e| e.to_string())?; + load_deck(&conn, &id) + })(); + + match result { + Ok(deck) => { + conn.execute_batch("COMMIT;").map_err(|e| e.to_string())?; + Ok(deck) + } + Err(error) => { + let _ = conn.execute_batch("ROLLBACK;"); + Err(error) + } + } +} + +#[tauri::command] +pub fn update_deck( + state: State<'_, DbState>, + id: String, + name: Option, + description: Option, +) -> Result { + let conn = state.get_conn()?; + let current = load_deck(&conn, &id)?; + conn.execute( + "UPDATE decks SET name = ?1, description = ?2 WHERE id = ?3", + params![ + name.unwrap_or(current.name), + description.or(current.description), + &id + ], + ) + .map_err(|e| e.to_string())?; + load_deck(&conn, &id) +} + +#[tauri::command] +pub fn delete_deck(state: State<'_, DbState>, id: String) -> Result<(), String> { + let conn = state.get_conn()?; + let affected = conn + .execute("DELETE FROM decks WHERE id = ?1", params![id]) + .map_err(|e| e.to_string())?; + if affected == 0 { + return Err("Deck not found".to_string()); + } + Ok(()) +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 62188f2..17361f7 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -1,4 +1,12 @@ pub mod ai; +pub mod brainstorm; +pub mod ccs_profile; +pub mod deck; pub mod fs; pub mod git; +pub mod plan; +pub mod project; +pub mod settings; +pub mod task; pub mod worktree; +pub mod worktree_cmd; diff --git a/src-tauri/src/commands/plan.rs b/src-tauri/src/commands/plan.rs new file mode 100644 index 0000000..891e18c --- /dev/null +++ b/src-tauri/src/commands/plan.rs @@ -0,0 +1,232 @@ +use std::collections::HashMap; + +use rusqlite::{params, Connection, OptionalExtension}; +use tauri::State; +use uuid::Uuid; + +use crate::db::DbState; +use crate::models::plan::{to_phase, Phase, PhaseStatus, Plan}; + +fn now_iso8601(conn: &Connection) -> Result { + conn.query_row("SELECT strftime('%Y-%m-%dT%H:%M:%fZ','now')", [], |row| { + row.get(0) + }) + .map_err(|e| e.to_string()) +} + +fn fetch_phase(conn: &Connection, id: &str) -> Result { + let row = conn + .query_row( + "SELECT id, plan_id, name, description, file_path, sort_order, status FROM phases WHERE id = ?1", + [id], + |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?, r.get(5)?, r.get(6)?)), + ) + .optional() + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("Phase not found: {id}"))?; + to_phase(row) +} + +fn load_plan(conn: &Connection, id: &str) -> Result { + let plan_row = conn + .query_row( + "SELECT id, deck_id, name, report_path, plan_path, created_at FROM plans WHERE id = ?1", + [id], + |r| { + Ok(( + r.get(0)?, + r.get(1)?, + r.get(2)?, + r.get(3)?, + r.get(4)?, + r.get(5)?, + )) + }, + ) + .optional() + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("Plan not found: {id}"))?; + + let mut stmt = conn + .prepare( + "SELECT id, plan_id, name, description, file_path, sort_order, status + FROM phases WHERE plan_id = ?1 ORDER BY sort_order ASC, rowid ASC", + ) + .map_err(|e| e.to_string())?; + let phase_rows = stmt + .query_map([id], |r| { + Ok(( + r.get(0)?, + r.get(1)?, + r.get(2)?, + r.get(3)?, + r.get(4)?, + r.get(5)?, + r.get(6)?, + )) + }) + .map_err(|e| e.to_string())?; + + let phases = phase_rows + .map(|row| row.map_err(|e| e.to_string()).and_then(to_phase)) + .collect::, _>>()?; + + Ok(Plan { + id: plan_row.0, + deck_id: plan_row.1, + name: plan_row.2, + report_path: plan_row.3, + plan_path: plan_row.4, + phases, + created_at: plan_row.5, + }) +} + +#[tauri::command] +pub fn create_plan( + db: State<'_, DbState>, + deck_id: String, + name: String, + report_path: Option, + plan_path: Option, +) -> Result { + let conn = db.get_conn()?; + let id = Uuid::new_v4().to_string(); + conn.execute( + "INSERT INTO plans (id, deck_id, name, report_path, plan_path, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + params![id, deck_id, name, report_path, plan_path, now_iso8601(&conn)?], + ) + .map_err(|e| e.to_string())?; + load_plan(&conn, &id) +} + +#[tauri::command] +pub fn list_plans(db: State<'_, DbState>, deck_id: String) -> Result, String> { + let conn = db.get_conn()?; + let mut plans = Vec::new(); + + let mut plan_stmt = conn + .prepare("SELECT id, deck_id, name, report_path, plan_path, created_at FROM plans WHERE deck_id = ?1 ORDER BY created_at DESC") + .map_err(|e| e.to_string())?; + let mut plan_rows = plan_stmt.query([&deck_id]).map_err(|e| e.to_string())?; + + while let Some(r) = plan_rows.next().map_err(|e| e.to_string())? { + plans.push(Plan { + id: r.get(0).map_err(|e| e.to_string())?, + deck_id: r.get(1).map_err(|e| e.to_string())?, + name: r.get(2).map_err(|e| e.to_string())?, + report_path: r.get(3).map_err(|e| e.to_string())?, + plan_path: r.get(4).map_err(|e| e.to_string())?, + phases: Vec::new(), + created_at: r.get(5).map_err(|e| e.to_string())?, + }); + } + + let mut grouped: HashMap> = HashMap::new(); + let mut phase_stmt = conn + .prepare( + "SELECT ph.id, ph.plan_id, ph.name, ph.description, ph.file_path, ph.sort_order, ph.status + FROM phases ph INNER JOIN plans p ON p.id = ph.plan_id + WHERE p.deck_id = ?1 ORDER BY ph.sort_order ASC, ph.rowid ASC", + ) + .map_err(|e| e.to_string())?; + + let phase_rows = phase_stmt + .query_map([&deck_id], |r| { + Ok(( + r.get(0)?, + r.get(1)?, + r.get(2)?, + r.get(3)?, + r.get(4)?, + r.get(5)?, + r.get(6)?, + )) + }) + .map_err(|e| e.to_string())?; + + for row in phase_rows { + let phase = to_phase(row.map_err(|e| e.to_string())?)?; + grouped + .entry(phase.plan_id.clone()) + .or_default() + .push(phase); + } + + for plan in &mut plans { + plan.phases = grouped.remove(&plan.id).unwrap_or_default(); + } + + Ok(plans) +} + +#[tauri::command] +pub fn get_plan(db: State<'_, DbState>, id: String) -> Result { + let conn = db.get_conn()?; + load_plan(&conn, &id) +} + +#[tauri::command] +pub fn delete_plan(db: State<'_, DbState>, id: String) -> Result<(), String> { + let conn = db.get_conn()?; + if conn + .execute("DELETE FROM plans WHERE id = ?1", [id.clone()]) + .map_err(|e| e.to_string())? + == 0 + { + return Err(format!("Plan not found: {id}")); + } + Ok(()) +} + +#[tauri::command] +pub fn create_phase( + db: State<'_, DbState>, + plan_id: String, + name: String, + description: Option, + file_path: Option, + order: i64, +) -> Result { + let conn = db.get_conn()?; + let id = Uuid::new_v4().to_string(); + conn.execute( + "INSERT INTO phases (id, plan_id, name, description, file_path, sort_order, status) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", + params![id, plan_id, name, description, file_path, order, PhaseStatus::Pending.as_db_str()], + ) + .map_err(|e| e.to_string())?; + fetch_phase(&conn, &id) +} + +#[tauri::command] +pub fn update_phase_status( + db: State<'_, DbState>, + id: String, + status: PhaseStatus, +) -> Result { + let conn = db.get_conn()?; + if conn + .execute( + "UPDATE phases SET status = ?1 WHERE id = ?2", + params![status.as_db_str(), &id], + ) + .map_err(|e| e.to_string())? + == 0 + { + return Err(format!("Phase not found: {id}")); + } + fetch_phase(&conn, &id) +} + +#[tauri::command] +pub fn delete_phase(db: State<'_, DbState>, id: String) -> Result<(), String> { + let conn = db.get_conn()?; + if conn + .execute("DELETE FROM phases WHERE id = ?1", [id.clone()]) + .map_err(|e| e.to_string())? + == 0 + { + return Err(format!("Phase not found: {id}")); + } + Ok(()) +} diff --git a/src-tauri/src/commands/project.rs b/src-tauri/src/commands/project.rs new file mode 100644 index 0000000..a41ebe6 --- /dev/null +++ b/src-tauri/src/commands/project.rs @@ -0,0 +1,188 @@ +use std::{collections::HashMap, path::PathBuf}; + +use git2::Repository; +use rusqlite::{params, OptionalExtension}; +use tauri::State; +use uuid::Uuid; + +use crate::{ + db::DbState, + models::project::{CcsAccount, CcsAccountStatus, Project}, +}; + +fn now_iso8601(conn: &rusqlite::Connection) -> Result { + conn.query_row("SELECT strftime('%Y-%m-%dT%H:%M:%fZ','now')", [], |row| { + row.get(0) + }) + .map_err(|e| e.to_string()) +} + +fn canonical_git_repo_path(git_path: &str) -> Result { + let canonical = std::fs::canonicalize(PathBuf::from(git_path)) + .map_err(|e| format!("Invalid git_path: {e}"))?; + Repository::open(&canonical).map_err(|e| format!("git_path is not a git repository: {e}"))?; + canonical + .to_str() + .map(|s| s.to_string()) + .ok_or_else(|| "git_path contains invalid UTF-8".to_string()) +} + +fn load_accounts( + conn: &rusqlite::Connection, + project_filter: Option<&str>, +) -> Result, String> { + let mut accounts = Vec::new(); + let sql = if project_filter.is_some() { + "SELECT id, project_id, provider, email, status FROM ccs_accounts WHERE project_id = ?1" + } else { + "SELECT id, project_id, provider, email, status FROM ccs_accounts" + }; + let mut stmt = conn.prepare(sql).map_err(|e| e.to_string())?; + let mut rows = if let Some(project_id) = project_filter { + stmt.query(params![project_id]).map_err(|e| e.to_string())? + } else { + stmt.query([]).map_err(|e| e.to_string())? + }; + + while let Some(row) = rows.next().map_err(|e| e.to_string())? { + let status_raw: String = row.get(4).map_err(|e| e.to_string())?; + accounts.push(CcsAccount { + id: row.get(0).map_err(|e| e.to_string())?, + project_id: row.get(1).map_err(|e| e.to_string())?, + provider: row.get(2).map_err(|e| e.to_string())?, + email: row.get(3).map_err(|e| e.to_string())?, + status: CcsAccountStatus::from_db(&status_raw)?, + }); + } + + Ok(accounts) +} + +fn load_project(conn: &rusqlite::Connection, id: &str) -> Result { + let project = conn + .query_row( + "SELECT id, name, description, git_path, ccs_connected, created_at FROM projects WHERE id = ?1", + params![id], + |row| { + let ccs_connected: i64 = row.get(4)?; + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, Option>(2)?, + row.get::<_, String>(3)?, + ccs_connected != 0, + row.get::<_, String>(5)?, + )) + }, + ) + .optional() + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("Project not found: {id}"))?; + + Ok(Project { + id: project.0, + name: project.1, + description: project.2, + git_path: project.3, + ccs_connected: project.4, + ccs_accounts: load_accounts(conn, Some(id))?, + created_at: project.5, + }) +} + +#[tauri::command] +pub fn create_project( + state: State<'_, DbState>, + name: String, + description: Option, + git_path: String, +) -> Result { + let conn = state.get_conn()?; + let project_id = Uuid::new_v4().to_string(); + let created_at = now_iso8601(&conn)?; + let canonical_path = canonical_git_repo_path(&git_path)?; + + conn.execute( + "INSERT INTO projects (id, name, description, git_path, ccs_connected, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + params![project_id, name, description, canonical_path, 0_i64, created_at], + ) + .map_err(|e| e.to_string())?; + + load_project(&conn, &project_id) +} + +#[tauri::command] +pub fn list_projects(state: State<'_, DbState>) -> Result, String> { + let conn = state.get_conn()?; + let mut projects = Vec::new(); + + let mut stmt = conn + .prepare("SELECT id, name, description, git_path, ccs_connected, created_at FROM projects ORDER BY created_at DESC") + .map_err(|e| e.to_string())?; + let mut rows = stmt.query([]).map_err(|e| e.to_string())?; + while let Some(row) = rows.next().map_err(|e| e.to_string())? { + let ccs_connected: i64 = row.get(4).map_err(|e| e.to_string())?; + projects.push(Project { + id: row.get(0).map_err(|e| e.to_string())?, + name: row.get(1).map_err(|e| e.to_string())?, + description: row.get(2).map_err(|e| e.to_string())?, + git_path: row.get(3).map_err(|e| e.to_string())?, + ccs_connected: ccs_connected != 0, + ccs_accounts: Vec::new(), + created_at: row.get(5).map_err(|e| e.to_string())?, + }); + } + + let mut grouped_accounts: HashMap> = HashMap::new(); + for account in load_accounts(&conn, None)? { + grouped_accounts + .entry(account.project_id.clone()) + .or_default() + .push(account); + } + + for project in &mut projects { + project.ccs_accounts = grouped_accounts.remove(&project.id).unwrap_or_default(); + } + + Ok(projects) +} + +#[tauri::command] +pub fn get_project(state: State<'_, DbState>, id: String) -> Result { + let conn = state.get_conn()?; + load_project(&conn, &id) +} + +#[tauri::command] +pub fn update_project( + state: State<'_, DbState>, + id: String, + name: Option, + description: Option, +) -> Result { + let conn = state.get_conn()?; + let current = load_project(&conn, &id)?; + let next_name = name.unwrap_or(current.name); + let next_description = description.or(current.description); + + conn.execute( + "UPDATE projects SET name = ?1, description = ?2 WHERE id = ?3", + params![next_name, next_description, id], + ) + .map_err(|e| e.to_string())?; + + load_project(&conn, &id) +} + +#[tauri::command] +pub fn delete_project(state: State<'_, DbState>, id: String) -> Result<(), String> { + let conn = state.get_conn()?; + let affected = conn + .execute("DELETE FROM projects WHERE id = ?1", params![id]) + .map_err(|e| e.to_string())?; + if affected == 0 { + return Err("Project not found".to_string()); + } + Ok(()) +} diff --git a/src-tauri/src/commands/settings.rs b/src-tauri/src/commands/settings.rs new file mode 100644 index 0000000..fb22034 --- /dev/null +++ b/src-tauri/src/commands/settings.rs @@ -0,0 +1,86 @@ +use std::collections::HashMap; + +use rusqlite::params; +use tauri::State; + +use crate::{db::DbState, models::settings::AppSettings}; + +fn parse_command_providers(raw: String) -> Result, String> { + serde_json::from_str(&raw) + .map_err(|e| format!("Invalid app_settings.command_providers JSON: {e}")) +} + +fn load_settings(conn: &rusqlite::Connection) -> Result { + conn.query_row( + "SELECT language, theme, auto_save, font_size, default_branch, worktrees_dir, command_providers, last_active_project_id FROM app_settings WHERE id = 1", + [], + |row| { + let auto_save: i64 = row.get(2)?; + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + auto_save != 0, + row.get::<_, i64>(3)?, + row.get::<_, String>(4)?, + row.get::<_, String>(5)?, + row.get::<_, String>(6)?, + row.get::<_, Option>(7)?, + )) + }, + ) + .map_err(|e| e.to_string()) + .and_then(|tuple| { + Ok(AppSettings { + language: tuple.0, + theme: tuple.1, + auto_save: tuple.2, + font_size: tuple.3, + default_branch: tuple.4, + worktrees_dir: tuple.5, + command_providers: parse_command_providers(tuple.6)?, + last_active_project_id: tuple.7, + }) + }) +} + +#[tauri::command] +pub fn get_settings(state: State<'_, DbState>) -> Result { + let conn = state.get_conn()?; + load_settings(&conn) +} + +#[tauri::command] +pub fn update_settings( + state: State<'_, DbState>, + settings: AppSettings, +) -> Result { + let conn = state.get_conn()?; + let command_providers_json = + serde_json::to_string(&settings.command_providers).map_err(|e| e.to_string())?; + + conn.execute( + "UPDATE app_settings + SET language = ?1, + theme = ?2, + auto_save = ?3, + font_size = ?4, + default_branch = ?5, + worktrees_dir = ?6, + command_providers = ?7, + last_active_project_id = ?8 + WHERE id = 1", + params![ + settings.language, + settings.theme, + if settings.auto_save { 1_i64 } else { 0_i64 }, + settings.font_size, + settings.default_branch, + settings.worktrees_dir, + command_providers_json, + settings.last_active_project_id, + ], + ) + .map_err(|e| e.to_string())?; + + load_settings(&conn) +} diff --git a/src-tauri/src/commands/task.rs b/src-tauri/src/commands/task.rs new file mode 100644 index 0000000..c908992 --- /dev/null +++ b/src-tauri/src/commands/task.rs @@ -0,0 +1,162 @@ +use rusqlite::{params, Connection, OptionalExtension}; +use tauri::State; +use uuid::Uuid; + +use crate::db::DbState; +use crate::models::task::{to_task, Task, TaskPriority, TaskRow, TaskStatus, TaskType}; + +fn fetch_task(conn: &Connection, id: &str) -> Result { + let row = conn + .query_row( + "SELECT id, deck_id, type, name, description, status, priority, plan_id, phase_id, worktree_name + FROM tasks WHERE id = ?1", + [id], + |row| { + Ok(( + row.get(0)?, + row.get(1)?, + row.get(2)?, + row.get(3)?, + row.get(4)?, + row.get(5)?, + row.get(6)?, + row.get(7)?, + row.get(8)?, + row.get(9)?, + )) + }, + ) + .optional() + .map_err(|e| e.to_string())?; + + to_task(row.ok_or_else(|| format!("Task not found: {id}"))?) +} + +#[tauri::command] +pub fn create_task( + db: State<'_, DbState>, + deck_id: String, + name: String, + description: Option, + priority: Option, + r#type: Option, +) -> Result { + let conn = db.get_conn()?; + let id = Uuid::new_v4().to_string(); + + conn.execute( + "INSERT INTO tasks (id, deck_id, type, name, description, status, priority, plan_id, phase_id, worktree_name) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, NULL, NULL, NULL)", + params![ + id, + deck_id, + r#type.unwrap_or(TaskType::Custom).as_db_str(), + name, + description, + TaskStatus::Backlog.as_db_str(), + priority.unwrap_or(TaskPriority::Medium).as_db_str(), + ], + ) + .map_err(|e| e.to_string())?; + + fetch_task(&conn, &id) +} + +#[tauri::command] +pub fn list_tasks(db: State<'_, DbState>, deck_id: String) -> Result, String> { + let conn = db.get_conn()?; + let mut stmt = conn + .prepare( + "SELECT id, deck_id, type, name, description, status, priority, plan_id, phase_id, worktree_name + FROM tasks WHERE deck_id = ?1 ORDER BY rowid DESC", + ) + .map_err(|e| e.to_string())?; + + let rows = stmt + .query_map([deck_id], |row| { + Ok::(( + row.get(0)?, + row.get(1)?, + row.get(2)?, + row.get(3)?, + row.get(4)?, + row.get(5)?, + row.get(6)?, + row.get(7)?, + row.get(8)?, + row.get(9)?, + )) + }) + .map_err(|e| e.to_string())?; + + rows.map(|row| row.map_err(|e| e.to_string()).and_then(to_task)) + .collect::, _>>() +} + +#[tauri::command] +pub fn get_task(db: State<'_, DbState>, id: String) -> Result { + let conn = db.get_conn()?; + fetch_task(&conn, &id) +} + +#[tauri::command] +pub fn update_task( + db: State<'_, DbState>, + id: String, + name: Option, + description: Option, + priority: Option, + status: Option, +) -> Result { + let conn = db.get_conn()?; + let current = fetch_task(&conn, &id)?; + + conn.execute( + "UPDATE tasks SET name = ?1, description = ?2, priority = ?3, status = ?4 WHERE id = ?5", + params![ + name.unwrap_or(current.name), + description.or(current.description), + priority.unwrap_or(current.priority).as_db_str(), + status.unwrap_or(current.status).as_db_str(), + id, + ], + ) + .map_err(|e| e.to_string())?; + + fetch_task(&conn, &id) +} + +#[tauri::command] +pub fn update_task_status( + db: State<'_, DbState>, + id: String, + status: TaskStatus, +) -> Result { + let conn = db.get_conn()?; + + if conn + .execute( + "UPDATE tasks SET status = ?1 WHERE id = ?2", + params![status.as_db_str(), &id], + ) + .map_err(|e| e.to_string())? + == 0 + { + return Err(format!("Task not found: {id}")); + } + + fetch_task(&conn, &id) +} + +#[tauri::command] +pub fn delete_task(db: State<'_, DbState>, id: String) -> Result<(), String> { + let conn = db.get_conn()?; + if conn + .execute("DELETE FROM tasks WHERE id = ?1", [id.clone()]) + .map_err(|e| e.to_string())? + == 0 + { + return Err(format!("Task not found: {id}")); + } + Ok(()) +} diff --git a/src-tauri/src/commands/worktree_cmd.rs b/src-tauri/src/commands/worktree_cmd.rs new file mode 100644 index 0000000..5593846 --- /dev/null +++ b/src-tauri/src/commands/worktree_cmd.rs @@ -0,0 +1,134 @@ +use rusqlite::{params, Connection, OptionalExtension}; +use tauri::State; +use uuid::Uuid; + +use crate::db::DbState; +use crate::models::worktree::{to_worktree, Worktree, WorktreeStatus}; + +fn now_iso8601(conn: &Connection) -> Result { + conn.query_row("SELECT strftime('%Y-%m-%dT%H:%M:%fZ','now')", [], |row| { + row.get(0) + }) + .map_err(|e| e.to_string()) +} + +fn fetch_worktree(conn: &Connection, id: &str) -> Result { + let row = conn + .query_row( + "SELECT id, project_id, task_id, branch, status, merged_at, created_at + FROM worktrees WHERE id = ?1", + [id], + |row| { + Ok(( + row.get(0)?, + row.get(1)?, + row.get(2)?, + row.get(3)?, + row.get(4)?, + row.get(5)?, + row.get(6)?, + )) + }, + ) + .optional() + .map_err(|e| e.to_string())?; + + let row = row.ok_or_else(|| format!("Worktree record not found: {id}"))?; + to_worktree(row) +} + +#[tauri::command] +pub fn create_worktree_record( + db: State<'_, DbState>, + project_id: String, + task_id: String, + branch: String, +) -> Result { + let conn = db.get_conn()?; + let id = Uuid::new_v4().to_string(); + let created_at = now_iso8601(&conn)?; + + conn.execute( + "INSERT INTO worktrees (id, project_id, task_id, branch, status, merged_at, created_at) + VALUES (?1, ?2, ?3, ?4, ?5, NULL, ?6)", + params![ + id, + project_id, + task_id, + branch, + WorktreeStatus::Active.as_db_str(), + created_at, + ], + ) + .map_err(|e| e.to_string())?; + + fetch_worktree(&conn, &id) +} + +#[tauri::command] +pub fn list_worktree_records( + db: State<'_, DbState>, + project_id: String, +) -> Result, String> { + let conn = db.get_conn()?; + let mut stmt = conn + .prepare( + "SELECT id, project_id, task_id, branch, status, merged_at, created_at + FROM worktrees WHERE project_id = ?1 ORDER BY created_at DESC", + ) + .map_err(|e| e.to_string())?; + + let rows = stmt + .query_map([project_id], |row| { + Ok(( + row.get(0)?, + row.get(1)?, + row.get(2)?, + row.get(3)?, + row.get(4)?, + row.get(5)?, + row.get(6)?, + )) + }) + .map_err(|e| e.to_string())?; + + rows.map(|row| row.map_err(|e| e.to_string()).and_then(to_worktree)) + .collect::, _>>() +} + +#[tauri::command] +pub fn update_worktree_record( + db: State<'_, DbState>, + id: String, + status: Option, + merged_at: Option, +) -> Result { + let conn = db.get_conn()?; + let current = fetch_worktree(&conn, &id)?; + + conn.execute( + "UPDATE worktrees SET status = ?1, merged_at = ?2 WHERE id = ?3", + params![ + status.unwrap_or(current.status).as_db_str(), + merged_at.or(current.merged_at), + &id, + ], + ) + .map_err(|e| e.to_string())?; + + fetch_worktree(&conn, &id) +} + +#[tauri::command] +pub fn delete_worktree_record(db: State<'_, DbState>, id: String) -> Result<(), String> { + let conn = db.get_conn()?; + let deleted = conn + .execute("DELETE FROM worktrees WHERE id = ?1", [id.clone()]) + .map_err(|e| e.to_string())?; + + if deleted == 0 { + return Err(format!("Worktree record not found: {id}")); + } + + Ok(()) +} diff --git a/src-tauri/src/db/migrations.rs b/src-tauri/src/db/migrations.rs new file mode 100644 index 0000000..54e0f1b --- /dev/null +++ b/src-tauri/src/db/migrations.rs @@ -0,0 +1,181 @@ +use rusqlite::{Connection, OptionalExtension}; + +const TARGET_SCHEMA_VERSION: i64 = 1; + +const V1_SCHEMA_SQL: &str = r#" +CREATE TABLE IF NOT EXISTS projects ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + description TEXT, + git_path TEXT NOT NULL, + ccs_connected INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS ccs_accounts ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + provider TEXT NOT NULL, + email TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'active' +); + +CREATE TABLE IF NOT EXISTS decks ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + name TEXT NOT NULL, + description TEXT, + is_active INTEGER NOT NULL DEFAULT 0, + based_on_insight_id TEXT, + created_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS key_insights ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + deck_id TEXT NOT NULL REFERENCES decks(id) ON DELETE CASCADE, + title TEXT NOT NULL, + report_path TEXT NOT NULL, + created_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS plans ( + id TEXT PRIMARY KEY, + deck_id TEXT NOT NULL REFERENCES decks(id) ON DELETE CASCADE, + name TEXT NOT NULL, + report_path TEXT, + plan_path TEXT, + created_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS phases ( + id TEXT PRIMARY KEY, + plan_id TEXT NOT NULL REFERENCES plans(id) ON DELETE CASCADE, + name TEXT NOT NULL, + description TEXT, + file_path TEXT, + sort_order INTEGER NOT NULL, + status TEXT NOT NULL DEFAULT 'pending' +); + +CREATE TABLE IF NOT EXISTS tasks ( + id TEXT PRIMARY KEY, + deck_id TEXT NOT NULL REFERENCES decks(id) ON DELETE CASCADE, + type TEXT NOT NULL DEFAULT 'custom', + name TEXT NOT NULL, + description TEXT, + status TEXT NOT NULL DEFAULT 'backlog', + priority TEXT NOT NULL DEFAULT 'medium', + plan_id TEXT REFERENCES plans(id), + phase_id TEXT REFERENCES phases(id), + worktree_name TEXT +); + +CREATE TABLE IF NOT EXISTS worktrees ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + task_id TEXT NOT NULL REFERENCES tasks(id), + branch TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'active', + merged_at TEXT, + created_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS brainstorm_sessions ( + id TEXT PRIMARY KEY, + deck_id TEXT NOT NULL REFERENCES decks(id) ON DELETE CASCADE, + prompt TEXT NOT NULL, + report_path TEXT, + status TEXT NOT NULL DEFAULT 'idle', + created_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS app_settings ( + id INTEGER PRIMARY KEY CHECK (id = 1), + language TEXT NOT NULL DEFAULT 'en', + theme TEXT NOT NULL DEFAULT 'dark', + auto_save INTEGER NOT NULL DEFAULT 1, + font_size INTEGER NOT NULL DEFAULT 14, + default_branch TEXT NOT NULL DEFAULT 'main', + worktrees_dir TEXT NOT NULL DEFAULT '.worktrees', + command_providers TEXT NOT NULL DEFAULT '{}', + last_active_project_id TEXT +); + +CREATE INDEX IF NOT EXISTS idx_ccs_accounts_project ON ccs_accounts(project_id); +CREATE INDEX IF NOT EXISTS idx_decks_project ON decks(project_id); +CREATE INDEX IF NOT EXISTS idx_key_insights_project ON key_insights(project_id); +CREATE INDEX IF NOT EXISTS idx_key_insights_deck ON key_insights(deck_id); +CREATE INDEX IF NOT EXISTS idx_tasks_deck ON tasks(deck_id); +CREATE INDEX IF NOT EXISTS idx_tasks_plan ON tasks(plan_id); +CREATE INDEX IF NOT EXISTS idx_tasks_phase ON tasks(phase_id); +CREATE INDEX IF NOT EXISTS idx_plans_deck ON plans(deck_id); +CREATE INDEX IF NOT EXISTS idx_phases_plan ON phases(plan_id); +CREATE INDEX IF NOT EXISTS idx_worktrees_project ON worktrees(project_id); +CREATE INDEX IF NOT EXISTS idx_worktrees_task ON worktrees(task_id); +CREATE INDEX IF NOT EXISTS idx_brainstorm_deck ON brainstorm_sessions(deck_id); + +INSERT OR IGNORE INTO app_settings (id) VALUES (1); +"#; + +pub fn run_migrations(conn: &Connection) -> Result<(), String> { + ensure_schema_version_table(conn)?; + + let current_version = current_schema_version(conn)?; + if current_version > TARGET_SCHEMA_VERSION { + return Err(format!( + "Database schema version {current_version} is newer than app supported version {TARGET_SCHEMA_VERSION}." + )); + } + + if current_version >= TARGET_SCHEMA_VERSION { + return Ok(()); + } + + conn.execute_batch("BEGIN EXCLUSIVE TRANSACTION;") + .map_err(|e| e.to_string())?; + + if let Err(error) = apply_v1(conn).and_then(|_| set_schema_version(conn, TARGET_SCHEMA_VERSION)) { + let _ = conn.execute_batch("ROLLBACK;"); + return Err(error); + } + + conn.execute_batch("COMMIT;").map_err(|e| { + let _ = conn.execute_batch("ROLLBACK;"); + e.to_string() + }) +} + +fn ensure_schema_version_table(conn: &Connection) -> Result<(), String> { + conn.execute_batch( + "CREATE TABLE IF NOT EXISTS schema_version ( + id INTEGER PRIMARY KEY CHECK (id = 1), + version INTEGER NOT NULL + );", + ) + .map_err(|e| e.to_string()) +} + +fn current_schema_version(conn: &Connection) -> Result { + conn.query_row("SELECT version FROM schema_version WHERE id = 1", [], |row| { + row.get(0) + }) + .optional() + .map_err(|e| e.to_string()) + .map(|version| version.unwrap_or(0)) +} + +fn set_schema_version(conn: &Connection, version: i64) -> Result<(), String> { + conn.execute( + "INSERT INTO schema_version (id, version) VALUES (1, ?1) + ON CONFLICT(id) DO UPDATE SET version = excluded.version", + [version], + ) + .map_err(|e| e.to_string())?; + + Ok(()) +} + +fn apply_v1(conn: &Connection) -> Result<(), String> { + conn.execute_batch(V1_SCHEMA_SQL).map_err(|e| e.to_string()) +} diff --git a/src-tauri/src/db/mod.rs b/src-tauri/src/db/mod.rs new file mode 100644 index 0000000..5845396 --- /dev/null +++ b/src-tauri/src/db/mod.rs @@ -0,0 +1,60 @@ +use std::path::PathBuf; + +use r2d2::{CustomizeConnection, Pool, PooledConnection}; +use r2d2_sqlite::SqliteConnectionManager; +use rusqlite::Connection; + +pub mod migrations; + +const DB_FILE_NAME: &str = "vividkit.db"; + +#[derive(Clone)] +pub struct DbState { + pool: Pool, +} + +impl DbState { + pub fn new(pool: Pool) -> Self { + Self { pool } + } + + pub fn pool(&self) -> &Pool { + &self.pool + } + + pub fn get_conn(&self) -> Result, String> { + self.pool.get().map_err(|e| e.to_string()) + } +} + +#[derive(Debug)] +struct SqlitePragmaCustomizer; + +impl CustomizeConnection for SqlitePragmaCustomizer { + fn on_acquire(&self, conn: &mut Connection) -> Result<(), rusqlite::Error> { + conn.execute_batch( + "PRAGMA foreign_keys = ON; + PRAGMA journal_mode = WAL;", + )?; + + Ok(()) + } +} + +pub fn init_db(app_data_dir: PathBuf) -> Result { + std::fs::create_dir_all(&app_data_dir).map_err(|e| e.to_string())?; + + let db_path = app_data_dir.join(DB_FILE_NAME); + let manager = SqliteConnectionManager::file(db_path); + + let pool = Pool::builder() + .max_size(8) + .connection_customizer(Box::new(SqlitePragmaCustomizer)) + .build(manager) + .map_err(|e| e.to_string())?; + + let conn = pool.get().map_err(|e| e.to_string())?; + migrations::run_migrations(&conn)?; + + Ok(DbState::new(pool)) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 65b7070..2c197d2 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,6 +1,9 @@ mod commands; +mod db; mod models; +use tauri::Manager; + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() @@ -9,6 +12,13 @@ pub fn run() { .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_fs::init()) .manage(commands::ai::CcsProcessRegistry::default()) + .setup(|app| { + let app_data_dir = app.path().app_data_dir()?; + let db_state = db::init_db(app_data_dir) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?; + app.manage(db_state); + Ok(()) + }) .invoke_handler(tauri::generate_handler![ commands::git::git_status, commands::git::git_commit, @@ -16,11 +26,46 @@ pub fn run() { commands::ai::spawn_ccs, commands::ai::stop_ccs, commands::ai::send_ccs_input, - commands::ai::list_ccs_profiles, + commands::ccs_profile::list_ccs_profiles, commands::fs::list_directory, commands::fs::resolve_home_path, commands::worktree::worktree_create, commands::worktree::worktree_cleanup, + commands::project::create_project, + commands::project::list_projects, + commands::project::get_project, + commands::project::update_project, + commands::project::delete_project, + commands::deck::create_deck, + commands::deck::list_decks, + commands::deck::set_active_deck, + commands::deck::update_deck, + commands::deck::delete_deck, + commands::settings::get_settings, + commands::settings::update_settings, + commands::plan::create_plan, + commands::plan::list_plans, + commands::plan::get_plan, + commands::plan::delete_plan, + commands::plan::create_phase, + commands::plan::update_phase_status, + commands::plan::delete_phase, + commands::task::create_task, + commands::task::list_tasks, + commands::task::get_task, + commands::task::update_task, + commands::task::update_task_status, + commands::task::delete_task, + commands::brainstorm::create_brainstorm_session, + commands::brainstorm::list_brainstorm_sessions, + commands::brainstorm::update_brainstorm_session, + commands::brainstorm::create_key_insight, + commands::brainstorm::list_key_insights, + commands::brainstorm::delete_key_insight, + commands::worktree_cmd::create_worktree_record, + commands::worktree_cmd::list_worktree_records, + commands::worktree_cmd::update_worktree_record, + commands::worktree_cmd::delete_worktree_record, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/models/brainstorm.rs b/src-tauri/src/models/brainstorm.rs new file mode 100644 index 0000000..b962011 --- /dev/null +++ b/src-tauri/src/models/brainstorm.rs @@ -0,0 +1,74 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum BrainstormStatus { + Idle, + Running, + Completed, +} + +impl BrainstormStatus { + pub fn as_db_str(&self) -> &'static str { + match self { + Self::Idle => "idle", + Self::Running => "running", + Self::Completed => "completed", + } + } + + pub fn from_db_str(value: &str) -> Result { + match value { + "idle" => Ok(Self::Idle), + "running" => Ok(Self::Running), + "completed" => Ok(Self::Completed), + _ => Err(format!("Invalid brainstorm status: {value}")), + } + } +} + +pub fn to_session( + row: (String, String, String, Option, String, String), +) -> Result { + Ok(BrainstormSession { + id: row.0, + deck_id: row.1, + prompt: row.2, + report_path: row.3, + status: BrainstormStatus::from_db_str(&row.4)?, + created_at: row.5, + }) +} + +pub fn to_insight(row: (String, String, String, String, String, String)) -> KeyInsight { + KeyInsight { + id: row.0, + project_id: row.1, + deck_id: row.2, + title: row.3, + report_path: row.4, + created_at: row.5, + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BrainstormSession { + pub id: String, + pub deck_id: String, + pub prompt: String, + pub report_path: Option, + pub status: BrainstormStatus, + pub created_at: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct KeyInsight { + pub id: String, + pub project_id: String, + pub deck_id: String, + pub title: String, + pub report_path: String, + pub created_at: String, +} diff --git a/src-tauri/src/models/ccs_account.rs b/src-tauri/src/models/ccs_account.rs new file mode 100644 index 0000000..7ef5dd3 --- /dev/null +++ b/src-tauri/src/models/ccs_account.rs @@ -0,0 +1 @@ +pub use super::project::CcsAccount; diff --git a/src-tauri/src/models/config.rs b/src-tauri/src/models/config.rs index f34cc45..189d7da 100644 --- a/src-tauri/src/models/config.rs +++ b/src-tauri/src/models/config.rs @@ -1,8 +1,16 @@ +use std::collections::HashMap; + use serde::{Deserialize, Serialize}; -#[derive(Serialize, Deserialize)] -pub struct AppConfig { - pub ai_provider: String, - pub api_key: String, +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AppSettings { + pub language: String, pub theme: String, + pub auto_save: bool, + pub font_size: i64, + pub default_branch: String, + pub worktrees_dir: String, + pub command_providers: HashMap, + pub last_active_project_id: Option, } diff --git a/src-tauri/src/models/deck.rs b/src-tauri/src/models/deck.rs new file mode 100644 index 0000000..cf5a603 --- /dev/null +++ b/src-tauri/src/models/deck.rs @@ -0,0 +1,13 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Deck { + pub id: String, + pub project_id: String, + pub name: String, + pub description: Option, + pub is_active: bool, + pub based_on_insight_id: Option, + pub created_at: String, +} diff --git a/src-tauri/src/models/enums.rs b/src-tauri/src/models/enums.rs new file mode 100644 index 0000000..c0c6acb --- /dev/null +++ b/src-tauri/src/models/enums.rs @@ -0,0 +1 @@ +pub use super::project::CcsAccountStatus; diff --git a/src-tauri/src/models/mod.rs b/src-tauri/src/models/mod.rs index 790aff0..6501a2f 100644 --- a/src-tauri/src/models/mod.rs +++ b/src-tauri/src/models/mod.rs @@ -1,3 +1,10 @@ +pub mod brainstorm; +pub mod ccs_account; pub mod config; +pub mod deck; +pub mod enums; +pub mod plan; pub mod project; +pub mod settings; pub mod task; +pub mod worktree; diff --git a/src-tauri/src/models/plan.rs b/src-tauri/src/models/plan.rs new file mode 100644 index 0000000..87a0131 --- /dev/null +++ b/src-tauri/src/models/plan.rs @@ -0,0 +1,74 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PhaseStatus { + Pending, + InProgress, + Done, +} + +impl PhaseStatus { + pub fn as_db_str(&self) -> &'static str { + match self { + Self::Pending => "pending", + Self::InProgress => "in_progress", + Self::Done => "done", + } + } + + pub fn from_db_str(value: &str) -> Result { + match value { + "pending" => Ok(Self::Pending), + "in_progress" => Ok(Self::InProgress), + "done" => Ok(Self::Done), + _ => Err(format!("Invalid phase status: {value}")), + } + } +} + +pub fn to_phase( + row: ( + String, + String, + String, + Option, + Option, + i64, + String, + ), +) -> Result { + Ok(Phase { + id: row.0, + plan_id: row.1, + name: row.2, + description: row.3, + file_path: row.4, + order: row.5, + status: PhaseStatus::from_db_str(&row.6)?, + }) +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Phase { + pub id: String, + pub plan_id: String, + pub name: String, + pub description: Option, + pub file_path: Option, + pub order: i64, + pub status: PhaseStatus, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Plan { + pub id: String, + pub deck_id: String, + pub name: String, + pub report_path: Option, + pub plan_path: Option, + pub phases: Vec, + pub created_at: String, +} diff --git a/src-tauri/src/models/project.rs b/src-tauri/src/models/project.rs index b228d5c..d022e7a 100644 --- a/src-tauri/src/models/project.rs +++ b/src-tauri/src/models/project.rs @@ -1,9 +1,42 @@ use serde::{Deserialize, Serialize}; -#[derive(Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum CcsAccountStatus { + Active, + Paused, + Exhausted, +} + +impl CcsAccountStatus { + pub fn from_db(value: &str) -> Result { + match value { + "active" => Ok(Self::Active), + "paused" => Ok(Self::Paused), + "exhausted" => Ok(Self::Exhausted), + _ => Err(format!("Invalid ccs account status: {value}")), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CcsAccount { + pub id: String, + pub project_id: String, + pub provider: String, + pub email: String, + pub status: CcsAccountStatus, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] pub struct Project { pub id: String, pub name: String, - pub path: String, + pub description: Option, + pub git_path: String, + pub ccs_connected: bool, + pub ccs_accounts: Vec, pub created_at: String, } diff --git a/src-tauri/src/models/settings.rs b/src-tauri/src/models/settings.rs new file mode 100644 index 0000000..189d7da --- /dev/null +++ b/src-tauri/src/models/settings.rs @@ -0,0 +1,16 @@ +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AppSettings { + pub language: String, + pub theme: String, + pub auto_save: bool, + pub font_size: i64, + pub default_branch: String, + pub worktrees_dir: String, + pub command_providers: HashMap, + pub last_active_project_id: Option, +} diff --git a/src-tauri/src/models/task.rs b/src-tauri/src/models/task.rs index 3032e53..b24639d 100644 --- a/src-tauri/src/models/task.rs +++ b/src-tauri/src/models/task.rs @@ -1,9 +1,125 @@ use serde::{Deserialize, Serialize}; -#[derive(Serialize, Deserialize)] +pub type TaskRow = ( + String, + String, + String, + String, + Option, + String, + String, + Option, + Option, + Option, +); + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TaskStatus { + Backlog, + Todo, + InProgress, + Done, +} + +impl TaskStatus { + pub fn as_db_str(&self) -> &'static str { + match self { + Self::Backlog => "backlog", + Self::Todo => "todo", + Self::InProgress => "in_progress", + Self::Done => "done", + } + } + + pub fn from_db_str(value: &str) -> Result { + match value { + "backlog" => Ok(Self::Backlog), + "todo" => Ok(Self::Todo), + "in_progress" => Ok(Self::InProgress), + "done" => Ok(Self::Done), + _ => Err(format!("Invalid task status: {value}")), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TaskPriority { + Low, + Medium, + High, +} + +impl TaskPriority { + pub fn as_db_str(&self) -> &'static str { + match self { + Self::Low => "low", + Self::Medium => "medium", + Self::High => "high", + } + } + + pub fn from_db_str(value: &str) -> Result { + match value { + "low" => Ok(Self::Low), + "medium" => Ok(Self::Medium), + "high" => Ok(Self::High), + _ => Err(format!("Invalid task priority: {value}")), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TaskType { + Generated, + Custom, +} + +impl TaskType { + pub fn as_db_str(&self) -> &'static str { + match self { + Self::Generated => "generated", + Self::Custom => "custom", + } + } + + pub fn from_db_str(value: &str) -> Result { + match value { + "generated" => Ok(Self::Generated), + "custom" => Ok(Self::Custom), + _ => Err(format!("Invalid task type: {value}")), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] pub struct Task { pub id: String, - pub title: String, - pub status: String, - pub project_id: String, + pub deck_id: String, + pub r#type: TaskType, + pub name: String, + pub description: Option, + pub status: TaskStatus, + pub priority: TaskPriority, + pub plan_id: Option, + pub phase_id: Option, + pub worktree_name: Option, +} + +pub fn to_task(row: TaskRow) -> Result { + Ok(Task { + id: row.0, + deck_id: row.1, + r#type: TaskType::from_db_str(&row.2)?, + name: row.3, + description: row.4, + status: TaskStatus::from_db_str(&row.5)?, + priority: TaskPriority::from_db_str(&row.6)?, + plan_id: row.7, + phase_id: row.8, + worktree_name: row.9, + }) } diff --git a/src-tauri/src/models/worktree.rs b/src-tauri/src/models/worktree.rs new file mode 100644 index 0000000..ec1b722 --- /dev/null +++ b/src-tauri/src/models/worktree.rs @@ -0,0 +1,64 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum WorktreeStatus { + Active, + Ready, + Merged, +} + +impl WorktreeStatus { + pub fn as_db_str(&self) -> &'static str { + match self { + Self::Active => "active", + Self::Ready => "ready", + Self::Merged => "merged", + } + } + + pub fn from_db_str(value: &str) -> Result { + match value { + "active" => Ok(Self::Active), + "ready" => Ok(Self::Ready), + "merged" => Ok(Self::Merged), + _ => Err(format!("Invalid worktree status: {value}")), + } + } +} + +pub fn to_worktree( + row: ( + String, + String, + String, + String, + String, + Option, + String, + ), +) -> Result { + Ok(Worktree { + id: row.0, + project_id: row.1, + task_id: row.2, + branch: row.3, + status: WorktreeStatus::from_db_str(&row.4)?, + files_changed: 0, + merged_at: row.5, + created_at: row.6, + }) +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Worktree { + pub id: String, + pub project_id: String, + pub task_id: String, + pub branch: String, + pub status: WorktreeStatus, + pub files_changed: i64, + pub merged_at: Option, + pub created_at: String, +} diff --git a/src/App.tsx b/src/App.tsx index 387e234..415207e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,11 +1,49 @@ +import { lazy, Suspense } from 'react' import { ThemeProvider } from '@/components/layout' -import { AppRouter } from './router' +import { useAppInit } from '@/hooks/use-app-init' import './App.css' +const AppRouter = lazy(async () => { + const mod = await import('./router') + return { default: mod.AppRouter } +}) + +function AppBootLoadingScreen() { + return ( +
+ Initializing VividKit… +
+ ) +} + +function AppBootErrorScreen({ message }: { message: string }) { + return ( +
+
+

Startup failed

+

Unable to initialize local data.

+
+          {message}
+        
+
+
+ ) +} + export default function App() { + const { ready, error } = useAppInit() + return ( - + {error ? ( + + ) : ready ? ( + }> + + + ) : ( + + )} ) } diff --git a/src/components/onboarding/step-git-setup.tsx b/src/components/onboarding/step-git-setup.tsx index f71fa4a..c38a4a4 100644 --- a/src/components/onboarding/step-git-setup.tsx +++ b/src/components/onboarding/step-git-setup.tsx @@ -2,10 +2,12 @@ import { useState } from 'react' import { FolderOpen } from 'lucide-react' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' -import { invoke } from '@tauri-apps/api/core' +import { open } from '@tauri-apps/plugin-dialog' import { cn } from '@/lib/utils' import type { OnboardingState, GitMethod } from './onboarding-wizard' +const OPEN_FOLDER_PICKER_FAILED_KEY = 'onboarding.git_setup.open_folder_picker_failed' + interface StepGitSetupProps { state: OnboardingState patch: (u: Partial) => void @@ -19,10 +21,17 @@ export function StepGitSetup({ state, patch, onNext, onBack }: StepGitSetupProps async function browse() { setBrowsing(true) try { - const selected = await invoke('open_directory_dialog') - if (selected) patch({ gitPath: selected }) - } catch { - // dialog cancelled or failed + const selected = await open({ directory: true, multiple: false }) + if (typeof selected === 'string' && selected.length > 0) { + patch({ gitPath: selected }) + } + } catch (error) { + console.error('Onboarding folder browse failed:', error) + if (typeof window !== 'undefined') { + window.dispatchEvent(new CustomEvent('vividkit:toast', { + detail: { type: 'error', message: OPEN_FOLDER_PICKER_FAILED_KEY }, + })) + } } finally { setBrowsing(false) } diff --git a/src/components/settings/ccs-test-console.tsx b/src/components/settings/ccs-test-console.tsx index 7f8356f..cea18ae 100644 --- a/src/components/settings/ccs-test-console.tsx +++ b/src/components/settings/ccs-test-console.tsx @@ -8,9 +8,8 @@ import { Input } from '@/components/ui/input' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { Badge } from '@/components/ui/badge' import { getTerminalTheme } from '@/lib/utils' -import { resolveHomePath, sendCcsInput, spawnCcs, stopCcs, type CcsRunEventPayload } from '@/lib/tauri' +import { listCcsProfiles, resolveHomePath, sendCcsInput, spawnCcs, stopCcs, type CcsRunEventPayload } from '@/lib/tauri' -const PROFILES = ['default', 'glm', 'gemini', 'kimi', 'codex'] const DEFAULT_COMMAND = '/brainstorm write a todo app' const DEFAULT_RELATIVE_CWD = 'projects/solo-builder/vividkit-workspace/vividkit-testing' @@ -24,6 +23,7 @@ export function CcsTestConsole() { const writeQueueRef = useRef([]) const flushingRef = useRef(false) const pendingEventsRef = useRef([]) + const [profiles, setProfiles] = useState(['default']) const [profile, setProfile] = useState('default') const [command, setCommand] = useState(DEFAULT_COMMAND) const [cwd, setCwd] = useState('') @@ -44,6 +44,20 @@ export function CcsTestConsole() { useEffect(() => { resolveHomePath(DEFAULT_RELATIVE_CWD).then(setCwd).catch(() => {}) }, []) + useEffect(() => { + let active = true + void listCcsProfiles() + .then((items) => { + if (!active) return + const nextProfiles = Array.from(new Set(items.map((item) => item.name.trim()).filter((name) => name.length > 0))) + if (nextProfiles.length === 0) return + setProfiles(nextProfiles) + setProfile((current) => (nextProfiles.includes(current) ? current : nextProfiles[0])) + }) + .catch(() => {}) + return () => { active = false } + }, []) + useEffect(() => { if (!containerRef.current) return mountedRef.current = true @@ -126,7 +140,7 @@ export function CcsTestConsole() {
diff --git a/src/hooks/use-app-init.ts b/src/hooks/use-app-init.ts new file mode 100644 index 0000000..6e3e4b6 --- /dev/null +++ b/src/hooks/use-app-init.ts @@ -0,0 +1,112 @@ +import { useEffect, useRef, useState } from 'react' +import { useProjectStore } from '@/stores/project-store' +import { useSettingsStore } from '@/stores/settings-store' + +interface AppInitState { + ready: boolean + error: string | null +} + +function toErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} + +function redirectToOnboardingIfNeeded(projectCount: number): void { + if (typeof window === 'undefined' || projectCount > 0) { + return + } + + if (window.location.pathname !== '/onboarding') { + window.history.replaceState(window.history.state, '', '/onboarding') + } +} + +export function useAppInit(): AppInitState { + const loadSettings = useSettingsStore((state) => state.loadSettings) + const updateSettings = useSettingsStore((state) => state.updateSettings) + const loadProjects = useProjectStore((state) => state.loadProjects) + const setActiveProject = useProjectStore((state) => state.setActiveProject) + const activeProjectId = useProjectStore((state) => state.activeProjectId) + + const [ready, setReady] = useState(false) + const [error, setError] = useState(null) + const isBootingRef = useRef(true) + const lastPersistAttemptRef = useRef(null) + + useEffect(() => { + let cancelled = false + + async function init(): Promise { + setReady(false) + setError(null) + isBootingRef.current = true + + try { + const settings = await loadSettings() + if (!settings) { + throw new Error(useSettingsStore.getState().error ?? 'Failed to load settings') + } + + await loadProjects() + const projectState = useProjectStore.getState() + if (projectState.error) { + throw new Error(projectState.error) + } + + const savedProjectId = settings.lastActiveProjectId ?? null + const matchedProject = savedProjectId + ? projectState.projects.find((project) => project.id === savedProjectId) + : null + const fallbackProjectId = projectState.projects[0]?.id ?? null + + if (matchedProject) { + setActiveProject(matchedProject.id) + } + + if (savedProjectId && !matchedProject) { + await updateSettings({ lastActiveProjectId: fallbackProjectId }) + } + + if (cancelled) { + return + } + + lastPersistAttemptRef.current = useSettingsStore.getState().settings.lastActiveProjectId ?? null + redirectToOnboardingIfNeeded(projectState.projects.length) + isBootingRef.current = false + setReady(true) + } catch (initError) { + if (cancelled) { + return + } + + isBootingRef.current = false + setError(toErrorMessage(initError)) + } + } + + void init() + + return () => { + cancelled = true + } + }, [loadProjects, loadSettings, setActiveProject, updateSettings]) + + useEffect(() => { + if (!ready || isBootingRef.current) { + return + } + + const nextProjectId = activeProjectId ?? null + const persistedProjectId = useSettingsStore.getState().settings.lastActiveProjectId ?? null + + if (nextProjectId === persistedProjectId || nextProjectId === lastPersistAttemptRef.current) { + return + } + + lastPersistAttemptRef.current = nextProjectId + void updateSettings({ lastActiveProjectId: nextProjectId }) + }, [activeProjectId, ready, updateSettings]) + + return { ready, error } +} diff --git a/src/lib/tauri-brainstorm.ts b/src/lib/tauri-brainstorm.ts new file mode 100644 index 0000000..3f069dd --- /dev/null +++ b/src/lib/tauri-brainstorm.ts @@ -0,0 +1,44 @@ +import { invoke } from '@tauri-apps/api/core' +import type { BrainstormSession, KeyInsight } from '@/types' + +export interface CreateBrainstormSessionArgs extends Record { + deckId: string + prompt: string +} + +export interface UpdateBrainstormSessionArgs extends Record { + id: string + status?: BrainstormSession['status'] + reportPath?: string +} + +export interface CreateKeyInsightArgs extends Record { + projectId: string + deckId: string + title: string + reportPath: string +} + +export async function createBrainstormSession(args: CreateBrainstormSessionArgs): Promise { + return invoke('create_brainstorm_session', args) +} + +export async function listBrainstormSessions(deckId: string): Promise { + return invoke('list_brainstorm_sessions', { deckId }) +} + +export async function updateBrainstormSession(args: UpdateBrainstormSessionArgs): Promise { + return invoke('update_brainstorm_session', args) +} + +export async function createKeyInsight(args: CreateKeyInsightArgs): Promise { + return invoke('create_key_insight', args) +} + +export async function listKeyInsights(deckId: string): Promise { + return invoke('list_key_insights', { deckId }) +} + +export async function deleteKeyInsight(id: string): Promise { + return invoke('delete_key_insight', { id }) +} diff --git a/src/lib/tauri-ccs.ts b/src/lib/tauri-ccs.ts new file mode 100644 index 0000000..8eac2fa --- /dev/null +++ b/src/lib/tauri-ccs.ts @@ -0,0 +1,49 @@ +import { invoke } from '@tauri-apps/api/core' + +export type CcsRunEventKind = 'stdout' | 'stderr' | 'terminated' | 'error' + +export interface CcsRunEventPayload { + run_id: string + kind: CcsRunEventKind + chunk?: string + code?: number + message?: string +} + +export interface SpawnCcsResult { + run_id: string + pid: number | null +} + +export interface CcsProfile { + name: string + profileType: string +} + +export interface StopCcsResult { + run_id: string + stopped: boolean + already_stopped: boolean +} + +export interface SpawnCcsArgs extends Record { + profile: string + command: string + cwd: string +} + +export async function spawnCcs(args: SpawnCcsArgs): Promise { + return invoke('spawn_ccs', args) +} + +export async function listCcsProfiles(): Promise { + return invoke('list_ccs_profiles') +} + +export async function stopCcs(runId: string): Promise { + return invoke('stop_ccs', { runId }) +} + +export async function sendCcsInput(runId: string, data: string): Promise { + return invoke('send_ccs_input', { runId, data }) +} diff --git a/src/lib/tauri-deck.ts b/src/lib/tauri-deck.ts new file mode 100644 index 0000000..2bf04a1 --- /dev/null +++ b/src/lib/tauri-deck.ts @@ -0,0 +1,35 @@ +import { invoke } from '@tauri-apps/api/core' +import type { Deck } from '@/types' + +export interface CreateDeckArgs extends Record { + projectId: string + name: string + description?: string + basedOnInsightId?: string +} + +export interface UpdateDeckArgs extends Record { + id: string + name?: string + description?: string +} + +export async function createDeck(args: CreateDeckArgs): Promise { + return invoke('create_deck', args) +} + +export async function listDecks(projectId: string): Promise { + return invoke('list_decks', { projectId }) +} + +export async function setActiveDeck(id: string): Promise { + return invoke('set_active_deck', { id }) +} + +export async function updateDeck(args: UpdateDeckArgs): Promise { + return invoke('update_deck', args) +} + +export async function deleteDeck(id: string): Promise { + return invoke('delete_deck', { id }) +} diff --git a/src/lib/tauri-plan.ts b/src/lib/tauri-plan.ts new file mode 100644 index 0000000..58c80d5 --- /dev/null +++ b/src/lib/tauri-plan.ts @@ -0,0 +1,45 @@ +import { invoke } from '@tauri-apps/api/core' +import type { Phase, PhaseStatus, Plan } from '@/types' + +export interface CreatePlanArgs extends Record { + deckId: string + name: string + reportPath?: string + planPath?: string +} + +export interface CreatePhaseArgs extends Record { + planId: string + name: string + description?: string + filePath?: string + order: number +} + +export async function createPlan(args: CreatePlanArgs): Promise { + return invoke('create_plan', args) +} + +export async function listPlans(deckId: string): Promise { + return invoke('list_plans', { deckId }) +} + +export async function getPlan(id: string): Promise { + return invoke('get_plan', { id }) +} + +export async function deletePlan(id: string): Promise { + return invoke('delete_plan', { id }) +} + +export async function createPhase(args: CreatePhaseArgs): Promise { + return invoke('create_phase', args) +} + +export async function updatePhaseStatus(id: string, status: PhaseStatus): Promise { + return invoke('update_phase_status', { id, status }) +} + +export async function deletePhase(id: string): Promise { + return invoke('delete_phase', { id }) +} diff --git a/src/lib/tauri-project.ts b/src/lib/tauri-project.ts new file mode 100644 index 0000000..b08beb1 --- /dev/null +++ b/src/lib/tauri-project.ts @@ -0,0 +1,34 @@ +import { invoke } from '@tauri-apps/api/core' +import type { Project } from '@/types' + +export interface CreateProjectArgs extends Record { + name: string + description?: string + gitPath: string +} + +export interface UpdateProjectArgs extends Record { + id: string + name?: string + description?: string +} + +export async function createProject(args: CreateProjectArgs): Promise { + return invoke('create_project', args) +} + +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(args: UpdateProjectArgs): Promise { + return invoke('update_project', args) +} + +export async function deleteProject(id: string): Promise { + return invoke('delete_project', { id }) +} diff --git a/src/lib/tauri-settings.ts b/src/lib/tauri-settings.ts new file mode 100644 index 0000000..bcea24c --- /dev/null +++ b/src/lib/tauri-settings.ts @@ -0,0 +1,10 @@ +import { invoke } from '@tauri-apps/api/core' +import type { AppSettings } from '@/types' + +export async function getSettings(): Promise { + return invoke('get_settings') +} + +export async function updateSettings(settings: AppSettings): Promise { + return invoke('update_settings', { settings }) +} diff --git a/src/lib/tauri-system.ts b/src/lib/tauri-system.ts new file mode 100644 index 0000000..c68dd3c --- /dev/null +++ b/src/lib/tauri-system.ts @@ -0,0 +1,9 @@ +import { invoke } from '@tauri-apps/api/core' + +export async function gitStatus(path: string): Promise { + return invoke('git_status', { path }) +} + +export async function resolveHomePath(relative: string): Promise { + return invoke('resolve_home_path', { relative }) +} diff --git a/src/lib/tauri-task.ts b/src/lib/tauri-task.ts new file mode 100644 index 0000000..ec8ceb8 --- /dev/null +++ b/src/lib/tauri-task.ts @@ -0,0 +1,42 @@ +import { invoke } from '@tauri-apps/api/core' +import type { Task, TaskPriority, TaskStatus, TaskType } from '@/types' + +export interface CreateTaskArgs extends Record { + deckId: string + name: string + description?: string + priority?: TaskPriority + type?: TaskType +} + +export interface UpdateTaskArgs extends Record { + id: string + name?: string + description?: string + priority?: TaskPriority + status?: TaskStatus +} + +export async function createTask(args: CreateTaskArgs): Promise { + return invoke('create_task', args) +} + +export async function listTasks(deckId: string): Promise { + return invoke('list_tasks', { deckId }) +} + +export async function getTask(id: string): Promise { + return invoke('get_task', { id }) +} + +export async function updateTask(args: UpdateTaskArgs): Promise { + return invoke('update_task', args) +} + +export async function updateTaskStatus(id: string, status: TaskStatus): Promise { + return invoke('update_task_status', { id, status }) +} + +export async function deleteTask(id: string): Promise { + return invoke('delete_task', { id }) +} diff --git a/src/lib/tauri-worktree.ts b/src/lib/tauri-worktree.ts new file mode 100644 index 0000000..2fd1797 --- /dev/null +++ b/src/lib/tauri-worktree.ts @@ -0,0 +1,30 @@ +import { invoke } from '@tauri-apps/api/core' +import type { Worktree, WorktreeStatus } from '@/types' + +export interface CreateWorktreeRecordArgs extends Record { + projectId: string + taskId: string + branch: string +} + +export interface UpdateWorktreeRecordArgs extends Record { + id: string + status?: WorktreeStatus + mergedAt?: string +} + +export async function createWorktreeRecord(args: CreateWorktreeRecordArgs): Promise { + return invoke('create_worktree_record', args) +} + +export async function listWorktreeRecords(projectId: string): Promise { + return invoke('list_worktree_records', { projectId }) +} + +export async function updateWorktreeRecord(args: UpdateWorktreeRecordArgs): Promise { + return invoke('update_worktree_record', args) +} + +export async function deleteWorktreeRecord(id: string): Promise { + return invoke('delete_worktree_record', { id }) +} diff --git a/src/lib/tauri.ts b/src/lib/tauri.ts index 80dcd16..ccea799 100644 --- a/src/lib/tauri.ts +++ b/src/lib/tauri.ts @@ -1,49 +1,9 @@ -import { invoke } from '@tauri-apps/api/core' - -export type CcsRunEventKind = 'stdout' | 'stderr' | 'terminated' | 'error' - -export interface CcsRunEventPayload { - run_id: string - kind: CcsRunEventKind - chunk?: string - code?: number - message?: string -} - -export interface SpawnCcsResult { - run_id: string - pid: number | null -} - -export interface StopCcsResult { - run_id: string - stopped: boolean - already_stopped: boolean -} - -export interface SpawnCcsArgs { - [key: string]: unknown - profile: string - command: string - cwd: string -} - -export async function gitStatus(path: string): Promise { - return invoke('git_status', { path }) -} - -export async function resolveHomePath(relative: string): Promise { - return invoke('resolve_home_path', { relative }) -} - -export async function spawnCcs(args: SpawnCcsArgs): Promise { - return invoke('spawn_ccs', args) -} - -export async function stopCcs(runId: string): Promise { - return invoke('stop_ccs', { runId }) -} - -export async function sendCcsInput(runId: string, data: string): Promise { - return invoke('send_ccs_input', { runId, data }) -} +export * from './tauri-system' +export * from './tauri-ccs' +export * from './tauri-project' +export * from './tauri-deck' +export * from './tauri-task' +export * from './tauri-plan' +export * from './tauri-brainstorm' +export * from './tauri-worktree' +export * from './tauri-settings' diff --git a/src/stores/brainstorm-store.ts b/src/stores/brainstorm-store.ts index ffcefc8..953d33e 100644 --- a/src/stores/brainstorm-store.ts +++ b/src/stores/brainstorm-store.ts @@ -1,16 +1,153 @@ import { create } from 'zustand' +import { + createBrainstormSession as createBrainstormSessionCommand, + createKeyInsight as createKeyInsightCommand, + deleteKeyInsight as deleteKeyInsightCommand, + listBrainstormSessions, + listKeyInsights, + updateBrainstormSession as updateBrainstormSessionCommand, +} from '@/lib/tauri' import type { BrainstormSession, KeyInsight } from '@/types' +type SessionInput = Pick & Partial> +type InsightInput = Pick + interface BrainstormStore { sessions: BrainstormSession[] insights: KeyInsight[] - addSession: (session: BrainstormSession) => void - addInsight: (insight: KeyInsight) => void + loading: boolean + initialized: boolean + error: string | null + loadSessions: (deckId: string) => Promise + loadInsights: (deckId: string) => Promise + addSession: (input: SessionInput | BrainstormSession) => Promise + updateSession: (id: string, patch: Pick) => Promise + addInsight: (input: InsightInput | KeyInsight) => Promise + removeInsight: (id: string) => Promise + clearError: () => void +} + +function toErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} + +function notifyError(message: string): void { + if (typeof window !== 'undefined') { + window.dispatchEvent(new CustomEvent('vividkit:toast', { detail: { type: 'error', message } })) + } +} + +function upsertSession(sessions: BrainstormSession[], next: BrainstormSession): BrainstormSession[] { + const exists = sessions.some((session) => session.id === next.id) + if (exists) { + return sessions.map((session) => (session.id === next.id ? next : session)) + } + return [next, ...sessions] +} + +function upsertInsight(insights: KeyInsight[], next: KeyInsight): KeyInsight[] { + const exists = insights.some((insight) => insight.id === next.id) + if (exists) { + return insights.map((insight) => (insight.id === next.id ? next : insight)) + } + return [next, ...insights] } -export const useBrainstormStore = create((set) => ({ +export const useBrainstormStore = create((set, get) => ({ sessions: [], insights: [], - addSession: (session) => set((s) => ({ sessions: [...s.sessions, session] })), - addInsight: (insight) => set((s) => ({ insights: [...s.insights, insight] })), + loading: false, + initialized: false, + error: null, + loadSessions: async (deckId) => { + set({ loading: true, error: null }) + try { + const sessions = await listBrainstormSessions(deckId) + set({ sessions, loading: false, initialized: true }) + } catch (error) { + const message = toErrorMessage(error) + set({ loading: false, initialized: true, error: message }) + notifyError(message) + } + }, + loadInsights: async (deckId) => { + set({ loading: true, error: null }) + try { + const insights = await listKeyInsights(deckId) + set({ insights, loading: false, initialized: true }) + } catch (error) { + const message = toErrorMessage(error) + set({ loading: false, initialized: true, error: message }) + notifyError(message) + } + }, + addSession: async (input) => { + set({ loading: true, error: null }) + try { + let session = await createBrainstormSessionCommand({ deckId: input.deckId, prompt: input.prompt }) + if (input.status !== undefined || input.reportPath !== undefined) { + session = await updateBrainstormSessionCommand({ + id: session.id, + status: input.status, + reportPath: input.reportPath, + }) + } + set((state) => ({ sessions: upsertSession(state.sessions, session), loading: false })) + return session + } catch (error) { + const message = toErrorMessage(error) + set({ loading: false, error: message }) + notifyError(message) + return null + } + }, + updateSession: async (id, patch) => { + set({ loading: true, error: null }) + try { + const session = await updateBrainstormSessionCommand({ id, ...patch }) + set((state) => ({ sessions: upsertSession(state.sessions, session), loading: false })) + return session + } catch (error) { + const message = toErrorMessage(error) + set({ loading: false, error: message }) + notifyError(message) + return null + } + }, + addInsight: async (input) => { + set({ loading: true, error: null }) + try { + const insight = await createKeyInsightCommand({ + projectId: input.projectId, + deckId: input.deckId, + title: input.title, + reportPath: input.reportPath, + }) + set((state) => ({ insights: upsertInsight(state.insights, insight), loading: false })) + return insight + } catch (error) { + const message = toErrorMessage(error) + set({ loading: false, error: message }) + notifyError(message) + return null + } + }, + removeInsight: async (id) => { + set({ loading: true, error: null }) + try { + await deleteKeyInsightCommand(id) + set((state) => ({ insights: state.insights.filter((insight) => insight.id !== id), loading: false })) + return true + } catch (error) { + const message = toErrorMessage(error) + set({ loading: false, error: message }) + notifyError(message) + return false + } + }, + clearError: () => { + if (get().error) { + set({ error: null }) + } + }, })) diff --git a/src/stores/deck-store.ts b/src/stores/deck-store.ts index b7a8c35..ae4dcaf 100644 --- a/src/stores/deck-store.ts +++ b/src/stores/deck-store.ts @@ -1,19 +1,161 @@ import { create } from 'zustand' +import { + createDeck as createDeckCommand, + deleteDeck as deleteDeckCommand, + listDecks, + setActiveDeck as setActiveDeckCommand, + updateDeck as updateDeckCommand, + type CreateDeckArgs, + type UpdateDeckArgs, +} from '@/lib/tauri' import type { Deck } from '@/types' +type DeckInput = Pick & Partial> + interface DeckStore { decks: Deck[] activeDeckId: string | null - setActiveDeck: (id: string) => void - addDeck: (deck: Deck) => void + loading: boolean + initialized: boolean + error: string | null + loadDecks: (projectId: string) => Promise + setActiveDeck: (id: string) => Promise + addDeck: (input: DeckInput | Deck) => Promise + updateDeck: (id: string, patch: Pick) => Promise + removeDeck: (id: string) => Promise + clearError: () => void +} + +function toErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} + +function notifyError(message: string): void { + if (typeof window !== 'undefined') { + window.dispatchEvent(new CustomEvent('vividkit:toast', { detail: { type: 'error', message } })) + } +} + +function toCreateDeckArgs(input: DeckInput | Deck): CreateDeckArgs { + return { + projectId: input.projectId, + name: input.name, + description: input.description, + basedOnInsightId: input.basedOnInsightId, + } +} + +function resolveActiveDeckId(decks: Deck[], currentId: string | null): string | null { + const dbActive = decks.find((deck) => deck.isActive) + if (dbActive) { + return dbActive.id + } + if (currentId && decks.some((deck) => deck.id === currentId)) { + return currentId + } + return decks[0]?.id ?? null +} + +function upsertDeck(decks: Deck[], next: Deck): Deck[] { + const exists = decks.some((deck) => deck.id === next.id) + if (exists) { + return decks.map((deck) => (deck.id === next.id ? next : deck)) + } + return [next, ...decks] } -export const useDeckStore = create((set) => ({ +export const useDeckStore = create((set, get) => ({ decks: [], activeDeckId: null, - setActiveDeck: (id) => set((s) => ({ - activeDeckId: id, - decks: s.decks.map((d) => ({ ...d, isActive: d.id === id })), - })), - addDeck: (deck) => set((s) => ({ decks: [...s.decks, deck] })), + loading: false, + initialized: false, + error: null, + loadDecks: async (projectId) => { + set({ loading: true, error: null }) + try { + const decks = await listDecks(projectId) + set((state) => ({ + decks, + activeDeckId: resolveActiveDeckId(decks, state.activeDeckId), + loading: false, + initialized: true, + })) + } catch (error) { + const message = toErrorMessage(error) + set({ loading: false, initialized: true, error: message }) + notifyError(message) + } + }, + setActiveDeck: async (id) => { + set({ loading: true, error: null }) + try { + const activeDeck = await setActiveDeckCommand(id) + set((state) => ({ + activeDeckId: activeDeck.id, + decks: state.decks.map((deck) => ({ ...deck, isActive: deck.id === activeDeck.id })), + loading: false, + })) + return activeDeck + } catch (error) { + const message = toErrorMessage(error) + set({ loading: false, error: message }) + notifyError(message) + return null + } + }, + addDeck: async (input) => { + set({ loading: true, error: null }) + try { + const created = await createDeckCommand(toCreateDeckArgs(input)) + set((state) => ({ + decks: upsertDeck(state.decks, created), + activeDeckId: state.activeDeckId ?? created.id, + loading: false, + })) + return created + } catch (error) { + const message = toErrorMessage(error) + set({ loading: false, error: message }) + notifyError(message) + return null + } + }, + updateDeck: async (id, patch) => { + set({ loading: true, error: null }) + try { + const updated = await updateDeckCommand({ id, ...patch }) + set((state) => ({ decks: upsertDeck(state.decks, updated), loading: false })) + return updated + } catch (error) { + const message = toErrorMessage(error) + set({ loading: false, error: message }) + notifyError(message) + return null + } + }, + removeDeck: async (id) => { + set({ loading: true, error: null }) + try { + await deleteDeckCommand(id) + set((state) => { + const decks = state.decks.filter((deck) => deck.id !== id) + return { + decks, + activeDeckId: resolveActiveDeckId(decks, state.activeDeckId === id ? null : state.activeDeckId), + loading: false, + } + }) + return true + } catch (error) { + const message = toErrorMessage(error) + set({ loading: false, error: message }) + notifyError(message) + return false + } + }, + clearError: () => { + if (get().error) { + set({ error: null }) + } + }, })) diff --git a/src/stores/plan-store.ts b/src/stores/plan-store.ts index 83bcdc1..1931fa9 100644 --- a/src/stores/plan-store.ts +++ b/src/stores/plan-store.ts @@ -1,21 +1,190 @@ import { create } from 'zustand' -import type { Plan, PhaseStatus } from '@/types' +import { + createPhase as createPhaseCommand, + createPlan as createPlanCommand, + deletePhase as deletePhaseCommand, + deletePlan as deletePlanCommand, + getPlan as getPlanCommand, + listPlans, + updatePhaseStatus as updatePhaseStatusCommand, + type CreatePhaseArgs, + type CreatePlanArgs, +} from '@/lib/tauri' +import type { Phase, PhaseStatus, Plan } from '@/types' + +type PlanInput = Pick & Partial> + +type PhaseInput = Pick & Partial> interface PlanStore { plans: Plan[] - addPlan: (plan: Plan) => void - updatePhaseStatus: (planId: string, phaseId: string, status: PhaseStatus) => void + loading: boolean + initialized: boolean + error: string | null + loadPlans: (deckId: string) => Promise + addPlan: (input: PlanInput | Plan) => Promise + removePlan: (id: string) => Promise + addPhase: (planId: string, input: PhaseInput) => Promise + updatePhaseStatus: (planId: string, phaseId: string, status: PhaseStatus) => Promise + removePhase: (planId: string, phaseId: string) => Promise + clearError: () => void +} + +function toErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} + +function notifyError(message: string): void { + if (typeof window !== 'undefined') { + window.dispatchEvent(new CustomEvent('vividkit:toast', { detail: { type: 'error', message } })) + } +} + +function toCreatePlanArgs(input: PlanInput | Plan): CreatePlanArgs { + return { + deckId: input.deckId, + name: input.name, + reportPath: input.reportPath, + planPath: input.planPath, + } +} + +function getInputPhases(input: PlanInput | Plan): Phase[] { + return Array.isArray(input.phases) ? input.phases : [] } -export const usePlanStore = create((set) => ({ +function upsertPlan(plans: Plan[], next: Plan): Plan[] { + const hasExisting = plans.some((plan) => plan.id === next.id) + if (hasExisting) { + return plans.map((plan) => (plan.id === next.id ? next : plan)) + } + return [next, ...plans] +} + +export const usePlanStore = create((set, get) => ({ plans: [], - addPlan: (plan) => set((s) => ({ plans: [...s.plans, plan] })), - updatePhaseStatus: (planId, phaseId, status) => - set((s) => ({ - plans: s.plans.map((p) => - p.id === planId - ? { ...p, phases: p.phases.map((ph) => (ph.id === phaseId ? { ...ph, status } : ph)) } - : p - ), - })), + loading: false, + initialized: false, + error: null, + loadPlans: async (deckId) => { + set({ loading: true, error: null }) + try { + const plans = await listPlans(deckId) + set({ plans, loading: false, initialized: true }) + } catch (error) { + const message = toErrorMessage(error) + set({ loading: false, initialized: true, error: message }) + notifyError(message) + } + }, + addPlan: async (input) => { + set({ loading: true, error: null }) + try { + const createdPlan = await createPlanCommand(toCreatePlanArgs(input)) + const inputPhases = [...getInputPhases(input)].sort((a, b) => a.order - b.order) + + for (const phase of inputPhases) { + const createdPhase = await createPhaseCommand({ + planId: createdPlan.id, + name: phase.name, + description: phase.description, + filePath: phase.filePath, + order: phase.order, + }) + if (phase.status !== 'pending') { + await updatePhaseStatusCommand(createdPhase.id, phase.status) + } + } + + const finalPlan = inputPhases.length > 0 ? await getPlanCommand(createdPlan.id) : createdPlan + set((state) => ({ plans: upsertPlan(state.plans, finalPlan), loading: false })) + return finalPlan + } catch (error) { + const message = toErrorMessage(error) + set({ loading: false, error: message }) + notifyError(message) + return null + } + }, + removePlan: async (id) => { + set({ loading: true, error: null }) + try { + await deletePlanCommand(id) + set((state) => ({ plans: state.plans.filter((plan) => plan.id !== id), loading: false })) + return true + } catch (error) { + const message = toErrorMessage(error) + set({ loading: false, error: message }) + notifyError(message) + return false + } + }, + addPhase: async (planId, input) => { + set({ loading: true, error: null }) + try { + const phase = await createPhaseCommand({ planId, ...input }) + set((state) => ({ + plans: state.plans.map((plan) => + plan.id === planId + ? { ...plan, phases: [...plan.phases, phase].sort((a, b) => a.order - b.order) } + : plan, + ), + loading: false, + })) + return phase + } catch (error) { + const message = toErrorMessage(error) + set({ loading: false, error: message }) + notifyError(message) + return null + } + }, + updatePhaseStatus: async (planId, phaseId, status) => { + set({ loading: true, error: null }) + try { + const phase = await updatePhaseStatusCommand(phaseId, status) + set((state) => ({ + plans: state.plans.map((plan) => + plan.id === planId + ? { + ...plan, + phases: plan.phases.map((item) => (item.id === phaseId ? phase : item)), + } + : plan, + ), + loading: false, + })) + return phase + } catch (error) { + const message = toErrorMessage(error) + set({ loading: false, error: message }) + notifyError(message) + return null + } + }, + removePhase: async (planId, phaseId) => { + set({ loading: true, error: null }) + try { + await deletePhaseCommand(phaseId) + set((state) => ({ + plans: state.plans.map((plan) => + plan.id === planId + ? { ...plan, phases: plan.phases.filter((phase) => phase.id !== phaseId) } + : plan, + ), + loading: false, + })) + return true + } catch (error) { + const message = toErrorMessage(error) + set({ loading: false, error: message }) + notifyError(message) + return false + } + }, + clearError: () => { + if (get().error) { + set({ error: null }) + } + }, })) diff --git a/src/stores/project-store.ts b/src/stores/project-store.ts index 7e3e9d4..e75dcb3 100644 --- a/src/stores/project-store.ts +++ b/src/stores/project-store.ts @@ -1,16 +1,139 @@ import { create } from 'zustand' +import { + createProject as createProjectCommand, + deleteProject as deleteProjectCommand, + listProjects, + updateProject as updateProjectCommand, + type CreateProjectArgs, + type UpdateProjectArgs, +} from '@/lib/tauri' import type { Project } from '@/types' +type ProjectInput = Pick & Partial> + interface ProjectStore { projects: Project[] activeProjectId: string | null + loading: boolean + initialized: boolean + error: string | null + loadProjects: () => Promise setActiveProject: (id: string) => void - addProject: (project: Project) => void + addProject: (input: ProjectInput | Project) => Promise + updateProject: (id: string, patch: Pick) => Promise + removeProject: (id: string) => Promise + clearError: () => void +} + +function toErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} + +function notifyError(message: string): void { + if (typeof window !== 'undefined') { + window.dispatchEvent(new CustomEvent('vividkit:toast', { detail: { type: 'error', message } })) + } +} + +function toCreateProjectArgs(input: ProjectInput | Project): CreateProjectArgs { + return { + name: input.name, + description: input.description, + gitPath: input.gitPath, + } +} + +function resolveActiveProjectId(projects: Project[], currentId: string | null): string | null { + if (currentId && projects.some((project) => project.id === currentId)) { + return currentId + } + return projects[0]?.id ?? null +} + +function upsertProject(projects: Project[], next: Project): Project[] { + const exists = projects.some((project) => project.id === next.id) + if (exists) { + return projects.map((project) => (project.id === next.id ? next : project)) + } + return [next, ...projects] } -export const useProjectStore = create((set) => ({ +export const useProjectStore = create((set, get) => ({ projects: [], activeProjectId: null, + loading: false, + initialized: false, + error: null, + loadProjects: async () => { + set({ loading: true, error: null }) + try { + const projects = await listProjects() + set((state) => ({ + projects, + activeProjectId: resolveActiveProjectId(projects, state.activeProjectId), + loading: false, + initialized: true, + })) + } catch (error) { + const message = toErrorMessage(error) + set({ loading: false, initialized: true, error: message }) + notifyError(message) + } + }, setActiveProject: (id) => set({ activeProjectId: id }), - addProject: (project) => set((s) => ({ projects: [...s.projects, project] })), + addProject: async (input) => { + set({ loading: true, error: null }) + try { + const created = await createProjectCommand(toCreateProjectArgs(input)) + set((state) => ({ + projects: upsertProject(state.projects, created), + activeProjectId: state.activeProjectId ?? created.id, + loading: false, + })) + return created + } catch (error) { + const message = toErrorMessage(error) + set({ loading: false, error: message }) + notifyError(message) + return null + } + }, + updateProject: async (id, patch) => { + set({ loading: true, error: null }) + try { + const updated = await updateProjectCommand({ id, ...patch }) + set((state) => ({ projects: upsertProject(state.projects, updated), loading: false })) + return updated + } catch (error) { + const message = toErrorMessage(error) + set({ loading: false, error: message }) + notifyError(message) + return null + } + }, + removeProject: async (id) => { + set({ loading: true, error: null }) + try { + await deleteProjectCommand(id) + set((state) => { + const projects = state.projects.filter((project) => project.id !== id) + return { + projects, + activeProjectId: resolveActiveProjectId(projects, state.activeProjectId === id ? null : state.activeProjectId), + loading: false, + } + }) + return true + } catch (error) { + const message = toErrorMessage(error) + set({ loading: false, error: message }) + notifyError(message) + return false + } + }, + clearError: () => { + if (get().error) { + set({ error: null }) + } + }, })) diff --git a/src/stores/settings-store.ts b/src/stores/settings-store.ts index 0469792..14774a8 100644 --- a/src/stores/settings-store.ts +++ b/src/stores/settings-store.ts @@ -1,9 +1,18 @@ import { create } from 'zustand' +import { getSettings as getSettingsCommand, updateSettings as updateSettingsCommand } from '@/lib/tauri' import type { AppSettings } from '@/types' +type SettingsPatch = Partial + interface SettingsStore { settings: AppSettings - updateSettings: (patch: Partial) => void + loading: boolean + initialized: boolean + error: string | null + loadSettings: () => Promise + saveSettings: (settings: AppSettings) => Promise + updateSettings: (patch: SettingsPatch) => Promise + clearError: () => void } const defaults: AppSettings = { @@ -14,9 +23,90 @@ const defaults: AppSettings = { defaultBranch: 'main', worktreesDir: '.worktrees', commandProviders: {}, + lastActiveProjectId: null, +} + +let settingsMutationQueue: Promise = Promise.resolve(null) + +function toErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} + +function applySettingsPatch(current: AppSettings, base: AppSettings, patch: SettingsPatch): AppSettings { + const next: AppSettings = { ...current, ...patch } + + if (patch.commandProviders) { + const commandProviderDelta = Object.fromEntries( + Object.entries(patch.commandProviders).filter(([command, provider]) => base.commandProviders[command] !== provider), + ) as Record + + next.commandProviders = { ...current.commandProviders, ...commandProviderDelta } + } + + return next +} + +function notifyError(message: string): void { + if (typeof window !== 'undefined') { + window.dispatchEvent(new CustomEvent('vividkit:toast', { detail: { type: 'error', message } })) + } } -export const useSettingsStore = create((set) => ({ +export const useSettingsStore = create((set, get) => ({ settings: defaults, - updateSettings: (patch) => set((s) => ({ settings: { ...s.settings, ...patch } })), + loading: false, + initialized: false, + error: null, + loadSettings: async () => { + set({ loading: true, error: null }) + try { + const settings = await getSettingsCommand() + set({ settings, loading: false, initialized: true }) + return settings + } catch (error) { + const message = toErrorMessage(error) + set({ loading: false, initialized: true, error: message }) + notifyError(message) + return null + } + }, + saveSettings: async (settings) => { + settingsMutationQueue = settingsMutationQueue.then(async () => { + set({ loading: true, error: null }) + try { + const saved = await updateSettingsCommand(settings) + set({ settings: saved, loading: false, initialized: true }) + return saved + } catch (error) { + const message = toErrorMessage(error) + set({ loading: false, error: message }) + notifyError(message) + return null + } + }) + return settingsMutationQueue + }, + updateSettings: async (patch) => { + const base = get().settings + settingsMutationQueue = settingsMutationQueue.then(async () => { + set({ loading: true, error: null }) + try { + const next = applySettingsPatch(get().settings, base, patch) + const saved = await updateSettingsCommand(next) + set({ settings: saved, loading: false, initialized: true }) + return saved + } catch (error) { + const message = toErrorMessage(error) + set({ loading: false, error: message }) + notifyError(message) + return null + } + }) + return settingsMutationQueue + }, + clearError: () => { + if (get().error) { + set({ error: null }) + } + }, })) diff --git a/src/stores/task-store.ts b/src/stores/task-store.ts index 883bcf0..ac817e7 100644 --- a/src/stores/task-store.ts +++ b/src/stores/task-store.ts @@ -1,15 +1,132 @@ import { create } from 'zustand' +import { + createTask as createTaskCommand, + deleteTask as deleteTaskCommand, + listTasks, + updateTask as updateTaskCommand, + updateTaskStatus as updateTaskStatusCommand, + type CreateTaskArgs, + type UpdateTaskArgs, +} from '@/lib/tauri' import type { Task, TaskStatus } from '@/types' +type TaskInput = Pick & Partial> + interface TaskStore { tasks: Task[] - addTask: (task: Task) => void - updateStatus: (id: string, status: TaskStatus) => void + loading: boolean + initialized: boolean + error: string | null + loadTasks: (deckId: string) => Promise + addTask: (input: TaskInput | Task) => Promise + updateStatus: (id: string, status: TaskStatus) => Promise + updateTask: (id: string, patch: Pick) => Promise + removeTask: (id: string) => Promise + clearError: () => void +} + +function toErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} + +function notifyError(message: string): void { + if (typeof window !== 'undefined') { + window.dispatchEvent(new CustomEvent('vividkit:toast', { detail: { type: 'error', message } })) + } +} + +function toCreateTaskArgs(input: TaskInput | Task): CreateTaskArgs { + return { + deckId: input.deckId, + name: input.name, + description: input.description, + priority: input.priority, + type: input.type, + } +} + +function upsertTask(tasks: Task[], next: Task): Task[] { + const exists = tasks.some((task) => task.id === next.id) + if (exists) { + return tasks.map((task) => (task.id === next.id ? next : task)) + } + return [next, ...tasks] } -export const useTaskStore = create((set) => ({ +export const useTaskStore = create((set, get) => ({ tasks: [], - addTask: (task) => set((s) => ({ tasks: [...s.tasks, task] })), - updateStatus: (id, status) => - set((s) => ({ tasks: s.tasks.map((t) => (t.id === id ? { ...t, status } : t)) })), + loading: false, + initialized: false, + error: null, + loadTasks: async (deckId) => { + set({ loading: true, error: null }) + try { + const tasks = await listTasks(deckId) + set({ tasks, loading: false, initialized: true }) + } catch (error) { + const message = toErrorMessage(error) + set({ loading: false, initialized: true, error: message }) + notifyError(message) + } + }, + addTask: async (input) => { + set({ loading: true, error: null }) + try { + let created = await createTaskCommand(toCreateTaskArgs(input)) + if (input.status && input.status !== created.status) { + created = await updateTaskStatusCommand(created.id, input.status) + } + set((state) => ({ tasks: upsertTask(state.tasks, created), loading: false })) + return created + } catch (error) { + const message = toErrorMessage(error) + set({ loading: false, error: message }) + notifyError(message) + return null + } + }, + updateStatus: async (id, status) => { + set({ loading: true, error: null }) + try { + const updated = await updateTaskStatusCommand(id, status) + set((state) => ({ tasks: upsertTask(state.tasks, updated), loading: false })) + return updated + } catch (error) { + const message = toErrorMessage(error) + set({ loading: false, error: message }) + notifyError(message) + return null + } + }, + updateTask: async (id, patch) => { + set({ loading: true, error: null }) + try { + const updated = await updateTaskCommand({ id, ...patch }) + set((state) => ({ tasks: upsertTask(state.tasks, updated), loading: false })) + return updated + } catch (error) { + const message = toErrorMessage(error) + set({ loading: false, error: message }) + notifyError(message) + return null + } + }, + removeTask: async (id) => { + set({ loading: true, error: null }) + try { + await deleteTaskCommand(id) + set((state) => ({ tasks: state.tasks.filter((task) => task.id !== id), loading: false })) + return true + } catch (error) { + const message = toErrorMessage(error) + set({ loading: false, error: message }) + notifyError(message) + return false + } + }, + clearError: () => { + if (get().error) { + set({ error: null }) + } + }, })) diff --git a/src/stores/worktree-store.ts b/src/stores/worktree-store.ts index 09149d6..55bce46 100644 --- a/src/stores/worktree-store.ts +++ b/src/stores/worktree-store.ts @@ -1,15 +1,119 @@ import { create } from 'zustand' +import { + createWorktreeRecord as createWorktreeRecordCommand, + deleteWorktreeRecord as deleteWorktreeRecordCommand, + listWorktreeRecords, + updateWorktreeRecord as updateWorktreeRecordCommand, +} from '@/lib/tauri' import type { Worktree, WorktreeStatus } from '@/types' +type WorktreeInput = Pick + interface WorktreeStore { worktrees: Worktree[] - addWorktree: (wt: Worktree) => void - updateStatus: (id: string, status: WorktreeStatus) => void + loading: boolean + initialized: boolean + error: string | null + loadWorktrees: (projectId: string) => Promise + addWorktree: (input: WorktreeInput | Worktree) => Promise + updateStatus: (id: string, status: WorktreeStatus) => Promise + updateWorktree: (id: string, patch: { status?: WorktreeStatus; mergedAt?: string }) => Promise + removeWorktree: (id: string) => Promise + clearError: () => void +} + +function toErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} + +function notifyError(message: string): void { + if (typeof window !== 'undefined') { + window.dispatchEvent(new CustomEvent('vividkit:toast', { detail: { type: 'error', message } })) + } +} + +function upsertWorktree(worktrees: Worktree[], next: Worktree): Worktree[] { + const exists = worktrees.some((worktree) => worktree.id === next.id) + if (exists) { + return worktrees.map((worktree) => (worktree.id === next.id ? next : worktree)) + } + return [next, ...worktrees] } -export const useWorktreeStore = create((set) => ({ +export const useWorktreeStore = create((set, get) => ({ worktrees: [], - addWorktree: (wt) => set((s) => ({ worktrees: [...s.worktrees, wt] })), - updateStatus: (id, status) => - set((s) => ({ worktrees: s.worktrees.map((w) => (w.id === id ? { ...w, status } : w)) })), + loading: false, + initialized: false, + error: null, + loadWorktrees: async (projectId) => { + set({ loading: true, error: null }) + try { + const worktrees = await listWorktreeRecords(projectId) + set({ worktrees, loading: false, initialized: true }) + } catch (error) { + const message = toErrorMessage(error) + set({ loading: false, initialized: true, error: message }) + notifyError(message) + } + }, + addWorktree: async (input) => { + const existing = 'id' in input ? get().worktrees.find((worktree) => worktree.id === input.id) : undefined + if (existing) { + return existing + } + + set({ loading: true, error: null }) + try { + const worktree = await createWorktreeRecordCommand({ + projectId: input.projectId, + taskId: input.taskId, + branch: input.branch, + }) + set((state) => ({ worktrees: upsertWorktree(state.worktrees, worktree), loading: false })) + return worktree + } catch (error) { + const message = toErrorMessage(error) + set({ loading: false, error: message }) + notifyError(message) + return null + } + }, + updateStatus: async (id, status) => { + const current = get().worktrees.find((worktree) => worktree.id === id) + return get().updateWorktree(id, { + status, + mergedAt: status === 'merged' ? current?.mergedAt ?? new Date().toISOString() : current?.mergedAt, + }) + }, + updateWorktree: async (id, patch) => { + set({ loading: true, error: null }) + try { + const worktree = await updateWorktreeRecordCommand({ id, ...patch }) + set((state) => ({ worktrees: upsertWorktree(state.worktrees, worktree), loading: false })) + return worktree + } catch (error) { + const message = toErrorMessage(error) + set({ loading: false, error: message }) + notifyError(message) + return null + } + }, + removeWorktree: async (id) => { + set({ loading: true, error: null }) + try { + await deleteWorktreeRecordCommand(id) + set((state) => ({ worktrees: state.worktrees.filter((worktree) => worktree.id !== id), loading: false })) + return true + } catch (error) { + const message = toErrorMessage(error) + set({ loading: false, error: message }) + notifyError(message) + return false + } + }, + clearError: () => { + if (get().error) { + set({ error: null }) + } + }, })) diff --git a/src/types/project.ts b/src/types/project.ts index 9678eb6..dcc5cd6 100644 --- a/src/types/project.ts +++ b/src/types/project.ts @@ -9,7 +9,7 @@ export interface Project { } export interface CcsAccount { - provider: 'claude' | 'gemini' | 'copilot' | 'openrouter' + provider: string email: string status: 'active' | 'paused' | 'exhausted' } diff --git a/src/types/settings.ts b/src/types/settings.ts index 779ebc8..2a38996 100644 --- a/src/types/settings.ts +++ b/src/types/settings.ts @@ -6,4 +6,5 @@ export interface AppSettings { defaultBranch: string worktreesDir: string commandProviders: Record + lastActiveProjectId?: string | null }