From 151479212f490b30275f60e5999fec668e18d2fa Mon Sep 17 00:00:00 2001 From: Davy <95214375+thedavidweng@users.noreply.github.com> Date: Sat, 4 Jul 2026 22:31:05 -0700 Subject: [PATCH 1/4] feat: add project concept for grouping generations (#65) Add a projects table (migration 006) with CRUD operations, CLI `project` subcommand, `--project` flag on `run` and `list`, Tauri commands, frontend store slice, ProjectSelector sidebar component, and per-item project assignment dropdown. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- src-tauri/migrations/006_add_projects.sql | 13 + src-tauri/src/cli/list.rs | 32 ++- src-tauri/src/cli/mod.rs | 4 + src-tauri/src/cli/project.rs | 183 +++++++++++++ src-tauri/src/cli/run.rs | 39 +++ src-tauri/src/cli/spec.rs | 47 ++++ src-tauri/src/commands/mod.rs | 1 + src-tauri/src/commands/projects.rs | 47 ++++ src-tauri/src/lib.rs | 5 + src-tauri/src/models/generation.rs | 2 + src-tauri/src/models/mod.rs | 1 + src-tauri/src/models/project.rs | 25 ++ src-tauri/src/services/db.rs | 255 +++++++++++++++++- src-tauri/src/services/generation_task.rs | 1 + src-tauri/src/services/history.rs | 1 + src-tauri/tests/cli_contract.rs | 1 + src/app/components/history/HistorySidebar.tsx | 67 ++++- .../components/history/ProjectSelector.tsx | 146 ++++++++++ src/app/lib/api.ts | 27 ++ src/app/lib/store/index.ts | 2 + src/app/lib/store/slices/projects.ts | 59 ++++ src/app/lib/store/slices/settings.ts | 3 + src/app/lib/store/types.ts | 9 + src/app/lib/types.ts | 8 + src/locales/en.json | 13 + src/locales/zh-CN.json | 13 + tests/unit/history-failed-runs.test.tsx | 3 + tests/unit/history-sidebar.test.tsx | 3 + tests/unit/model-slice.test.ts | 2 + tests/unit/settings-slice.test.ts | 1 + tests/unit/store.test.ts | 1 + 31 files changed, 1009 insertions(+), 5 deletions(-) create mode 100644 src-tauri/migrations/006_add_projects.sql create mode 100644 src-tauri/src/cli/project.rs create mode 100644 src-tauri/src/commands/projects.rs create mode 100644 src-tauri/src/models/project.rs create mode 100644 src/app/components/history/ProjectSelector.tsx create mode 100644 src/app/lib/store/slices/projects.ts diff --git a/src-tauri/migrations/006_add_projects.sql b/src-tauri/migrations/006_add_projects.sql new file mode 100644 index 0000000..0c1dbde --- /dev/null +++ b/src-tauri/migrations/006_add_projects.sql @@ -0,0 +1,13 @@ +CREATE TABLE IF NOT EXISTS projects ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_projects_name ON projects(name); +CREATE INDEX IF NOT EXISTS idx_projects_created_at ON projects(created_at DESC); + +ALTER TABLE generations ADD COLUMN project_id TEXT REFERENCES projects(id) ON DELETE SET NULL; + +CREATE INDEX IF NOT EXISTS idx_generations_project_id ON generations(project_id) WHERE project_id IS NOT NULL; diff --git a/src-tauri/src/cli/list.rs b/src-tauri/src/cli/list.rs index 6efd233..4292608 100644 --- a/src-tauri/src/cli/list.rs +++ b/src-tauri/src/cli/list.rs @@ -5,7 +5,15 @@ use super::AppState; pub fn execute(state: &AppState, json: bool, args: ListArgs) -> AppResult<()> { let limit = args.limit; - let records = state.db.list_generations(None, Some(limit as u32))?; + let records = match args.project.as_deref() { + Some(prefix) => { + let project_id = resolve_project_by_prefix(&state.db, prefix)?; + state + .db + .list_generations_by_project(&project_id, Some(limit as u32))? + } + None => state.db.list_generations(None, Some(limit as u32))?, + }; if json { let json_output = serde_json::to_string_pretty(&records) @@ -49,3 +57,25 @@ pub fn execute(state: &AppState, json: bool, args: ListArgs) -> AppResult<()> { Ok(()) } + +fn resolve_project_by_prefix( + db: &crate::services::db::Database, + prefix: &str, +) -> crate::models::errors::AppResult { + let projects = db.list_projects()?; + let matches: Vec<_> = projects + .iter() + .filter(|p| p.id.starts_with(prefix) || p.name == prefix) + .collect(); + match matches.len() { + 0 => Err(crate::models::errors::AppError::not_found( + "Project", + format!("No project matches '{prefix}'"), + )), + 1 => Ok(matches[0].id.clone()), + _ => Err(crate::models::errors::AppError::validation_failed(format!( + "Ambiguous project prefix '{prefix}' matched {} projects", + matches.len() + ))), + } +} diff --git a/src-tauri/src/cli/mod.rs b/src-tauri/src/cli/mod.rs index c251e03..f2cca96 100644 --- a/src-tauri/src/cli/mod.rs +++ b/src-tauri/src/cli/mod.rs @@ -9,6 +9,7 @@ mod files; mod generation; mod list; mod models; +mod project; mod ps; mod pull; mod run; @@ -69,6 +70,9 @@ fn run_inner(args: Vec) -> AppResult<()> { generation::execute(&state, json, command) } spec::Commands::List(args) => list::execute(&state, json, args), + spec::Commands::Project { command } => { + project::execute(&state, json, command) + } spec::Commands::Delete(args) => delete::execute(&state, json, args), spec::Commands::Clear(args) => clear::execute(&state, json, args), spec::Commands::Ps => ps::execute(&state, json), diff --git a/src-tauri/src/cli/project.rs b/src-tauri/src/cli/project.rs new file mode 100644 index 0000000..f60d2f4 --- /dev/null +++ b/src-tauri/src/cli/project.rs @@ -0,0 +1,183 @@ +use crate::{ + cli::{cli_error, human_output, json_output, spec::ProjectCommand}, + models::errors::{AppError, AppResult}, +}; + +use super::AppState; + +pub fn execute(state: &AppState, json: bool, command: ProjectCommand) -> AppResult<()> { + match command { + ProjectCommand::List => cmd_list(state, json), + ProjectCommand::Create { name } => cmd_create(state, json, &name), + ProjectCommand::Rename { id, name } => cmd_rename(state, json, &id, &name), + ProjectCommand::Delete { id, yes } => cmd_delete(state, json, &id, yes), + ProjectCommand::Assign { generation, project } => { + cmd_assign(state, json, &generation, project.as_deref()) + } + } +} + +fn cmd_list(state: &AppState, json: bool) -> AppResult<()> { + let projects = state.db.list_projects()?; + + if json { + let output = + serde_json::to_string_pretty(&projects).map_err(|e| cli_error(e.to_string()))?; + json_output(&output); + } else { + if projects.is_empty() { + human_output("No projects."); + return Ok(()); + } + println!("{:<12} {:<28} Created", "ID", "Name"); + println!("{}", "-".repeat(60)); + for project in &projects { + let short_id = &project.id[..8.min(project.id.len())]; + let name = if project.name.len() > 26 { + format!("{}…", &project.name[..25]) + } else { + project.name.clone() + }; + let created = project + .created_at + .split('T') + .next() + .unwrap_or(&project.created_at); + println!("{:<12} {:<28} {}", short_id, name, created); + } + } + Ok(()) +} + +fn cmd_create(state: &AppState, json: bool, name: &str) -> AppResult<()> { + let project = state.db.create_project(name)?; + if json { + let output = + serde_json::to_string_pretty(&project).map_err(|e| cli_error(e.to_string()))?; + json_output(&output); + } else { + human_output(&format!( + "Created project '{}' (id: {})", + project.name, project.id + )); + } + Ok(()) +} + +fn cmd_rename(state: &AppState, json: bool, id: &str, name: &str) -> AppResult<()> { + let resolved = resolve_project_by_prefix(&state.db, id)?; + let project = state.db.rename_project(&resolved, name)?; + if json { + let output = + serde_json::to_string_pretty(&project).map_err(|e| cli_error(e.to_string()))?; + json_output(&output); + } else { + human_output(&format!("Renamed project {} to '{}'", resolved, name)); + } + Ok(()) +} + +fn cmd_delete(state: &AppState, json: bool, id: &str, yes: bool) -> AppResult<()> { + let resolved = resolve_project_by_prefix(&state.db, id)?; + + if !yes { + use std::io::Write; + print!( + "Delete project '{}'? Generations will be unassigned, not deleted. [y/N] ", + resolved + ); + std::io::stdout().flush().ok(); + let mut input = String::new(); + std::io::stdin() + .read_line(&mut input) + .map_err(|e| cli_error(e.to_string()))?; + if !["y\n", "Y\n", "yes\n", "Yes\n"].contains(&input.as_str()) { + human_output("Cancelled."); + return Ok(()); + } + } + + state.db.delete_project(&resolved)?; + if json { + json_output(&format!("{{\"deleted\":\"{}\"}}", resolved)); + } else { + human_output(&format!("Deleted project {}", resolved)); + } + Ok(()) +} + +fn cmd_assign( + state: &AppState, + json: bool, + generation: &str, + project: Option<&str>, +) -> AppResult<()> { + let generation_id = resolve_generation_by_prefix(state, generation)?; + + let project_id = match project { + Some(value) => Some(resolve_project_by_prefix(&state.db, value)?), + None => None, + }; + + state + .db + .set_generation_project(&generation_id, project_id.as_deref())?; + + if json { + json_output(&format!( + "{{\"generationId\":\"{}\",\"projectId\":{}}}", + generation_id, + project_id + .as_ref() + .map(|id| format!("\"{}\"", id)) + .unwrap_or_else(|| "null".to_string()) + )); + } else { + match project_id { + Some(id) => human_output(&format!( + "Assigned generation {} to project {}", + generation_id, id + )), + None => human_output(&format!("Unassigned generation {} from project", generation_id)), + } + } + Ok(()) +} + +fn resolve_project_by_prefix( + db: &crate::services::db::Database, + prefix: &str, +) -> AppResult { + let projects = db.list_projects()?; + let matches: Vec<_> = projects + .iter() + .filter(|p| p.id.starts_with(prefix) || p.name == prefix) + .collect(); + match matches.len() { + 0 => Err(AppError::not_found( + "Project", + format!("No project matches '{prefix}'"), + )), + 1 => Ok(matches[0].id.clone()), + _ => Err(AppError::validation_failed(format!( + "Ambiguous project prefix '{prefix}' matched {} projects", + matches.len() + ))), + } +} + +fn resolve_generation_by_prefix(state: &AppState, prefix: &str) -> AppResult { + let records = state.db.list_generations(None, None)?; + let matches: Vec<_> = records.iter().filter(|r| r.id.starts_with(prefix)).collect(); + match matches.len() { + 0 => Err(AppError::not_found( + "Generation record", + format!("No generation record matches '{prefix}'"), + )), + 1 => Ok(matches[0].id.clone()), + _ => Err(AppError::validation_failed(format!( + "Ambiguous generation prefix '{prefix}' matched {} records", + matches.len() + ))), + } +} diff --git a/src-tauri/src/cli/run.rs b/src-tauri/src/cli/run.rs index 6e5b196..d286112 100644 --- a/src-tauri/src/cli/run.rs +++ b/src-tauri/src/cli/run.rs @@ -237,6 +237,19 @@ pub fn execute(state: &AppState, json: bool, mut args: RunArgs) -> AppResult<()> } } + // Assign generations to a project if --project was specified + if let Some(project_ref) = args.project.as_deref() { + let project_id = resolve_project_ref(&state.db, project_ref)?; + for record in &result.records { + state + .db + .set_generation_project(&record.id, Some(&project_id))?; + } + if !json { + human_output(&format!("✓ Assigned {} generation(s) to project.", result.records.len())); + } + } + // Detach the backend so it keeps running after this CLI command exits if let Ok(mut backend) = state.lock_backend() { backend.detach(); @@ -394,3 +407,29 @@ fn map_model_to_variant(model_name: Option<&str>) -> Option { _ => None, } } + +/// Resolve a project reference (ID prefix or exact name) to a project ID. +/// Creates the project if it does not exist (by name match). +fn resolve_project_ref( + db: &crate::services::db::Database, + reference: &str, +) -> crate::models::errors::AppResult { + let projects = db.list_projects()?; + let by_id: Vec<_> = projects.iter().filter(|p| p.id.starts_with(reference)).collect(); + if by_id.len() == 1 { + return Ok(by_id[0].id.clone()); + } + if by_id.len() > 1 { + return Err(crate::models::errors::AppError::validation_failed(format!( + "Ambiguous project prefix '{reference}' matched {} projects", + by_id.len() + ))); + } + let by_name: Vec<_> = projects.iter().filter(|p| p.name == reference).collect(); + if let Some(project) = by_name.first() { + return Ok(project.id.clone()); + } + // Create a new project by name + let project = db.create_project(reference)?; + Ok(project.id) +} diff --git a/src-tauri/src/cli/spec.rs b/src-tauri/src/cli/spec.rs index e6fb439..4252793 100644 --- a/src-tauri/src/cli/spec.rs +++ b/src-tauri/src/cli/spec.rs @@ -51,6 +51,11 @@ pub enum Commands { }, /// Show generation history List(ListArgs), + /// Manage projects (named groups of generations) + Project { + #[command(subcommand)] + command: ProjectCommand, + }, /// Delete a generation record Delete(DeleteArgs), /// Clear all generation history @@ -141,6 +146,10 @@ pub struct RunArgs { /// Replay a previous generation by ID #[arg(long)] pub from_history: Option, + + /// Assign the generation to a project (by name or ID prefix) + #[arg(long)] + pub project: Option, } #[derive(Args)] @@ -300,6 +309,40 @@ pub enum GenerationCommand { }, } +#[derive(Subcommand)] +pub enum ProjectCommand { + /// List all projects + List, + /// Create a new project + Create { + /// Project name + name: String, + }, + /// Rename an existing project + Rename { + /// Project ID prefix + id: String, + /// New project name + name: String, + }, + /// Delete a project (generations are unassigned, not deleted) + Delete { + /// Project ID prefix + id: String, + /// Skip confirmation + #[arg(long)] + yes: bool, + }, + /// Assign a generation to a project + Assign { + /// Generation ID prefix + generation: String, + /// Project ID prefix (omit to unassign) + #[arg(long)] + project: Option, + }, +} + // --------------------------------------------------------------------------- // Leaf commands // --------------------------------------------------------------------------- @@ -309,6 +352,10 @@ pub struct ListArgs { /// Number of records to show #[arg(long, default_value = "20")] pub limit: usize, + + /// Filter by project (by name or ID prefix) + #[arg(long)] + pub project: Option, } #[derive(Args)] diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 5b91343..767757e 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -6,6 +6,7 @@ pub mod history; pub mod logs; pub mod models; pub mod network; +pub mod projects; pub mod provisioner; pub mod settings; pub mod support; diff --git a/src-tauri/src/commands/projects.rs b/src-tauri/src/commands/projects.rs new file mode 100644 index 0000000..6f755dc --- /dev/null +++ b/src-tauri/src/commands/projects.rs @@ -0,0 +1,47 @@ +use tauri::State; + +use crate::{ + models::{ + errors::AppResult, + project::{CreateProjectRequest, Project, RenameProjectRequest}, + }, + AppState, +}; + +#[tauri::command] +pub fn list_projects(state: State<'_, AppState>) -> AppResult> { + state.db.list_projects() +} + +#[tauri::command] +pub fn create_project( + state: State<'_, AppState>, + request: CreateProjectRequest, +) -> AppResult { + state.db.create_project(&request.name) +} + +#[tauri::command] +pub fn rename_project( + state: State<'_, AppState>, + id: String, + request: RenameProjectRequest, +) -> AppResult { + state.db.rename_project(&id, &request.name) +} + +#[tauri::command] +pub fn delete_project(state: State<'_, AppState>, id: String) -> AppResult<()> { + state.db.delete_project(&id) +} + +#[tauri::command] +pub fn assign_generation_to_project( + state: State<'_, AppState>, + generation_id: String, + project_id: Option, +) -> AppResult<()> { + state + .db + .set_generation_project(&generation_id, project_id.as_deref()) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index bc06b53..5c69c06 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -77,6 +77,11 @@ pub fn run() { commands::history::list_failed_runs, commands::history::clear_failed_runs, commands::history::delete_failed_run, + commands::projects::list_projects, + commands::projects::create_project, + commands::projects::rename_project, + commands::projects::delete_project, + commands::projects::assign_generation_to_project, commands::generation::insert_generation, commands::generation::generate_music, commands::generation::cancel_generation, diff --git a/src-tauri/src/models/generation.rs b/src-tauri/src/models/generation.rs index 1de96f7..21e5db6 100644 --- a/src-tauri/src/models/generation.rs +++ b/src-tauri/src/models/generation.rs @@ -67,6 +67,8 @@ pub struct GenerationRecord { pub generation_info: Option, #[serde(default)] pub is_favorite: bool, + #[serde(default)] + pub project_id: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/src-tauri/src/models/mod.rs b/src-tauri/src/models/mod.rs index 4c6d524..dbf2fe3 100644 --- a/src-tauri/src/models/mod.rs +++ b/src-tauri/src/models/mod.rs @@ -1,4 +1,5 @@ pub mod backend; pub mod errors; pub mod generation; +pub mod project; pub mod settings; diff --git a/src-tauri/src/models/project.rs b/src-tauri/src/models/project.rs new file mode 100644 index 0000000..10f3131 --- /dev/null +++ b/src-tauri/src/models/project.rs @@ -0,0 +1,25 @@ +use serde::{Deserialize, Serialize}; + +/// A named group that aggregates multiple generations. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Project { + pub id: String, + pub name: String, + pub created_at: String, + pub updated_at: String, +} + +/// Payload for creating a project (name only; id and timestamps are generated). +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateProjectRequest { + pub name: String, +} + +/// Payload for renaming a project. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RenameProjectRequest { + pub name: String, +} diff --git a/src-tauri/src/services/db.rs b/src-tauri/src/services/db.rs index 88f5d1a..162aa92 100644 --- a/src-tauri/src/services/db.rs +++ b/src-tauri/src/services/db.rs @@ -10,6 +10,7 @@ use serde_json::Value; use crate::models::{ errors::{AppError, AppResult}, generation::{ActiveGenerationTask, FailedRun, GenerationRecord, GenerationRequest}, + project::Project, settings::{AppSettings, SettingKey}, }; @@ -49,6 +50,7 @@ impl Database { include_str!("../../migrations/003_add_favorite.sql"), include_str!("../../migrations/004_add_failed_runs.sql"), include_str!("../../migrations/005_history_indexes.sql"), + include_str!("../../migrations/006_add_projects.sql"), ] { let _ = connection.execute_batch(sql); } @@ -136,7 +138,7 @@ impl Database { let query = query.map(str::trim).filter(|value| !value.is_empty()); let limit_i64: Option = limit.map(i64::from); - const SELECT: &str = "SELECT id, created_at, prompt, lyrics, vocal_language, duration_seconds, bpm, key_scale, time_signature, model, lm_model, thinking, inference_steps, guidance_scale, use_random_seed, seed, audio_format, output_path, status, error_message, generation_info, is_favorite FROM generations WHERE status = 'completed' AND COALESCE(output_path, '') <> ''"; + const SELECT: &str = "SELECT id, created_at, prompt, lyrics, vocal_language, duration_seconds, bpm, key_scale, time_signature, model, lm_model, thinking, inference_steps, guidance_scale, use_random_seed, seed, audio_format, output_path, status, error_message, generation_info, is_favorite, project_id FROM generations WHERE status = 'completed' AND COALESCE(output_path, '') <> ''"; const ORDER: &str = " ORDER BY is_favorite DESC, created_at DESC"; match (query, limit_i64) { @@ -201,7 +203,7 @@ impl Database { let connection = self.connection()?; connection .query_row( - "SELECT id, created_at, prompt, lyrics, vocal_language, duration_seconds, bpm, key_scale, time_signature, model, lm_model, thinking, inference_steps, guidance_scale, use_random_seed, seed, audio_format, output_path, status, error_message, generation_info, is_favorite FROM generations WHERE id = ?1", + "SELECT id, created_at, prompt, lyrics, vocal_language, duration_seconds, bpm, key_scale, time_signature, model, lm_model, thinking, inference_steps, guidance_scale, use_random_seed, seed, audio_format, output_path, status, error_message, generation_info, is_favorite, project_id FROM generations WHERE id = ?1", [id], Self::map_generation_row, ) @@ -213,7 +215,7 @@ impl Database { let connection = self.connection()?; connection .execute( - "INSERT INTO generations (id, created_at, prompt, lyrics, vocal_language, duration_seconds, bpm, key_scale, time_signature, model, lm_model, thinking, inference_steps, guidance_scale, use_random_seed, seed, audio_format, output_path, status, error_message, generation_info, is_favorite) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22) ON CONFLICT(id) DO UPDATE SET created_at = excluded.created_at, prompt = excluded.prompt, lyrics = excluded.lyrics, vocal_language = excluded.vocal_language, duration_seconds = excluded.duration_seconds, bpm = excluded.bpm, key_scale = excluded.key_scale, time_signature = excluded.time_signature, model = excluded.model, lm_model = excluded.lm_model, thinking = excluded.thinking, inference_steps = excluded.inference_steps, guidance_scale = excluded.guidance_scale, use_random_seed = excluded.use_random_seed, seed = excluded.seed, audio_format = excluded.audio_format, output_path = excluded.output_path, status = excluded.status, error_message = excluded.error_message, generation_info = excluded.generation_info, is_favorite = excluded.is_favorite", + "INSERT INTO generations (id, created_at, prompt, lyrics, vocal_language, duration_seconds, bpm, key_scale, time_signature, model, lm_model, thinking, inference_steps, guidance_scale, use_random_seed, seed, audio_format, output_path, status, error_message, generation_info, is_favorite, project_id) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23) ON CONFLICT(id) DO UPDATE SET created_at = excluded.created_at, prompt = excluded.prompt, lyrics = excluded.lyrics, vocal_language = excluded.vocal_language, duration_seconds = excluded.duration_seconds, bpm = excluded.bpm, key_scale = excluded.key_scale, time_signature = excluded.time_signature, model = excluded.model, lm_model = excluded.lm_model, thinking = excluded.thinking, inference_steps = excluded.inference_steps, guidance_scale = excluded.guidance_scale, use_random_seed = excluded.use_random_seed, seed = excluded.seed, audio_format = excluded.audio_format, output_path = excluded.output_path, status = excluded.status, error_message = excluded.error_message, generation_info = excluded.generation_info, is_favorite = excluded.is_favorite, project_id = excluded.project_id", params![ record.id, record.created_at, @@ -237,6 +239,7 @@ impl Database { record.error_message, record.generation_info, record.is_favorite, + record.project_id, ], ) .map_err(|error| AppError::db_write_failed(error.to_string()))?; @@ -285,6 +288,145 @@ impl Database { Ok(()) } + // ----------------------------------------------------------------- + // Projects + // ----------------------------------------------------------------- + + pub fn list_projects(&self) -> AppResult> { + let connection = self.connection()?; + let mut statement = connection + .prepare("SELECT id, name, created_at, updated_at FROM projects ORDER BY updated_at DESC") + .map_err(|error| AppError::db_read_failed(error.to_string()))?; + let mapped = statement + .query_map([], |row| { + Ok(Project { + id: row.get(0)?, + name: row.get(1)?, + created_at: row.get(2)?, + updated_at: row.get(3)?, + }) + }) + .map_err(|error| AppError::db_read_failed(error.to_string()))?; + mapped + .into_iter() + .map(|row| row.map_err(|error| AppError::db_read_failed(error.to_string()))) + .collect() + } + + pub fn create_project(&self, name: &str) -> AppResult { + let connection = self.connection()?; + let id = uuid::Uuid::new_v4().to_string(); + let now = Utc::now().to_rfc3339(); + connection + .execute( + "INSERT INTO projects (id, name, created_at, updated_at) VALUES (?1, ?2, ?3, ?4)", + params![id, name, now, now], + ) + .map_err(|error| AppError::db_write_failed(error.to_string()))?; + Ok(Project { + id, + name: name.to_string(), + created_at: now.clone(), + updated_at: now, + }) + } + + pub fn rename_project(&self, id: &str, name: &str) -> AppResult { + let connection = self.connection()?; + let now = Utc::now().to_rfc3339(); + let updated = connection + .execute( + "UPDATE projects SET name = ?1, updated_at = ?2 WHERE id = ?3", + params![name, now, id], + ) + .map_err(|error| AppError::db_write_failed(error.to_string()))?; + if updated == 0 { + return Err(AppError::not_found( + "Project", + format!("No project exists for id {id}"), + )); + } + Ok(Project { + id: id.to_string(), + name: name.to_string(), + created_at: now.clone(), + updated_at: now, + }) + } + + pub fn delete_project(&self, id: &str) -> AppResult<()> { + let connection = self.connection()?; + let deleted = connection + .execute("DELETE FROM projects WHERE id = ?1", [id]) + .map_err(|error| AppError::db_write_failed(error.to_string()))?; + if deleted == 0 { + return Err(AppError::not_found( + "Project", + format!("No project exists for id {id}"), + )); + } + Ok(()) + } + + pub fn set_generation_project( + &self, + generation_id: &str, + project_id: Option<&str>, + ) -> AppResult<()> { + let connection = self.connection()?; + let updated = connection + .execute( + "UPDATE generations SET project_id = ?1 WHERE id = ?2", + params![project_id, generation_id], + ) + .map_err(|error| AppError::db_write_failed(error.to_string()))?; + if updated == 0 { + return Err(AppError::not_found( + "Generation record", + format!("No generation record exists for id {generation_id}"), + )); + } + Ok(()) + } + + pub fn list_generations_by_project( + &self, + project_id: &str, + limit: Option, + ) -> AppResult> { + let connection = self.connection()?; + let limit_i64: Option = limit.map(i64::from); + const SELECT: &str = "SELECT id, created_at, prompt, lyrics, vocal_language, duration_seconds, bpm, key_scale, time_signature, model, lm_model, thinking, inference_steps, guidance_scale, use_random_seed, seed, audio_format, output_path, status, error_message, generation_info, is_favorite, project_id FROM generations WHERE status = 'completed' AND COALESCE(output_path, '') <> '' AND project_id = ?1"; + const ORDER: &str = " ORDER BY is_favorite DESC, created_at DESC"; + + match limit_i64 { + Some(limit_i64) => { + let mut statement = connection + .prepare(&format!("{SELECT}{ORDER} LIMIT ?2")) + .map_err(|error| AppError::db_read_failed(error.to_string()))?; + let mapped = statement + .query_map(params![project_id, limit_i64], Self::map_generation_row) + .map_err(|error| AppError::db_read_failed(error.to_string()))?; + mapped + .into_iter() + .map(|row| row.map_err(|error| AppError::db_read_failed(error.to_string()))) + .collect() + } + None => { + let mut statement = connection + .prepare(&format!("{SELECT}{ORDER}")) + .map_err(|error| AppError::db_read_failed(error.to_string()))?; + let mapped = statement + .query_map(params![project_id], Self::map_generation_row) + .map_err(|error| AppError::db_read_failed(error.to_string()))?; + mapped + .into_iter() + .map(|row| row.map_err(|error| AppError::db_read_failed(error.to_string()))) + .collect() + } + } + } + pub fn upsert_active_generation_task( &self, task: &ActiveGenerationTask, @@ -410,6 +552,7 @@ impl Database { let seed: Option = row.get(15)?; let is_favorite_int: i32 = row.get(21)?; + let project_id: Option = row.get(22)?; Ok(GenerationRecord { id: row.get(0)?, @@ -438,6 +581,7 @@ impl Database { error_message: row.get(19)?, generation_info: row.get(20)?, is_favorite: is_favorite_int != 0, + project_id, }) } @@ -512,6 +656,7 @@ mod tests { error_message: None, generation_info: Some("ok".to_owned()), is_favorite: false, + project_id: None, } } @@ -785,4 +930,108 @@ mod tests { "v1 task should survive migration" ); } + + #[test] + fn project_crud_works_end_to_end() { + let temp_dir = tempfile::tempdir().expect("temp dir"); + let database = Database::new(temp_dir.path()).expect("database should init"); + + // Initially no projects + assert!(database.list_projects().expect("list").is_empty()); + + // Create + let project = database + .create_project("Album A") + .expect("create project"); + assert_eq!(project.name, "Album A"); + + // List + let projects = database.list_projects().expect("list"); + assert_eq!(projects.len(), 1); + assert_eq!(projects[0].id, project.id); + + // Rename + let renamed = database + .rename_project(&project.id, "Album B") + .expect("rename"); + assert_eq!(renamed.name, "Album B"); + + // Delete + database.delete_project(&project.id).expect("delete"); + assert!(database.list_projects().expect("list").is_empty()); + } + + #[test] + fn assign_generation_to_project_and_query_by_project() { + let temp_dir = tempfile::tempdir().expect("temp dir"); + let database = Database::new(temp_dir.path()).expect("database should init"); + + let project = database + .create_project("Soundtrack") + .expect("create project"); + + let record = sample_record(); + database.insert_generation(&record).expect("insert"); + + // Assign + database + .set_generation_project(&record.id, Some(&project.id)) + .expect("assign"); + + // Verify via get_generation + let fetched = database + .get_generation(&record.id) + .expect("get") + .expect("record"); + assert_eq!(fetched.project_id, Some(project.id.clone())); + + // Query by project + let in_project = database + .list_generations_by_project(&project.id, None) + .expect("list by project"); + assert_eq!(in_project.len(), 1); + assert_eq!(in_project[0].id, record.id); + + // Unassign + database + .set_generation_project(&record.id, None) + .expect("unassign"); + let unassigned = database + .get_generation(&record.id) + .expect("get") + .expect("record"); + assert!(unassigned.project_id.is_none()); + + // Query by project returns empty + let empty = database + .list_generations_by_project(&project.id, None) + .expect("list by project"); + assert!(empty.is_empty()); + } + + #[test] + fn delete_project_unassigns_generations_not_deletes_them() { + let temp_dir = tempfile::tempdir().expect("temp dir"); + let database = Database::new(temp_dir.path()).expect("database should init"); + + let project = database + .create_project("Ephemeral") + .expect("create project"); + + let record = sample_record(); + database.insert_generation(&record).expect("insert"); + database + .set_generation_project(&record.id, Some(&project.id)) + .expect("assign"); + + // Delete project + database.delete_project(&project.id).expect("delete"); + + // Generation still exists but project_id is null (ON DELETE SET NULL) + let fetched = database + .get_generation(&record.id) + .expect("get") + .expect("record"); + assert!(fetched.project_id.is_none()); + } } diff --git a/src-tauri/src/services/generation_task.rs b/src-tauri/src/services/generation_task.rs index 0fa0f8e..a349cb9 100644 --- a/src-tauri/src/services/generation_task.rs +++ b/src-tauri/src/services/generation_task.rs @@ -371,6 +371,7 @@ pub fn build_generation_record( error_message, generation_info, is_favorite: false, + project_id: None, } } diff --git a/src-tauri/src/services/history.rs b/src-tauri/src/services/history.rs index fa39243..6e18fdf 100644 --- a/src-tauri/src/services/history.rs +++ b/src-tauri/src/services/history.rs @@ -123,6 +123,7 @@ mod tests { error_message: None, generation_info: None, is_favorite: false, + project_id: None, } } diff --git a/src-tauri/tests/cli_contract.rs b/src-tauri/tests/cli_contract.rs index 5b85c6c..3b0bf1a 100644 --- a/src-tauri/tests/cli_contract.rs +++ b/src-tauri/tests/cli_contract.rs @@ -41,6 +41,7 @@ fn sample_record(id: &str, output_path: Option) -> GenerationRecord { error_message: None, generation_info: None, is_favorite: false, + project_id: None, } } diff --git a/src/app/components/history/HistorySidebar.tsx b/src/app/components/history/HistorySidebar.tsx index a0f1b4a..ea8f1b8 100644 --- a/src/app/components/history/HistorySidebar.tsx +++ b/src/app/components/history/HistorySidebar.tsx @@ -6,6 +6,7 @@ import { Clipboard, Clock3, FileDown, + FolderInput, Play, Repeat, RotateCcw, @@ -17,6 +18,7 @@ import { import { useVirtualizer } from "@tanstack/react-virtual"; import { useTranslation } from "react-i18next"; import { SearchBox } from "@/app/components/history/SearchBox"; +import { ProjectSelector } from "@/app/components/history/ProjectSelector"; import { useGenerationStore } from "@/app/lib/store"; import { Tooltip } from "@/app/components/overlay/Tooltip"; import { useToast } from "@/app/components/overlay/Toast"; @@ -46,22 +48,29 @@ export function HistorySidebar() { const exitCompareMode = useGenerationStore((state) => state.exitCompareMode); const compareModeActive = useGenerationStore((state) => state.compareModeActive); const compareGenerationId = useGenerationStore((state) => state.compareGenerationId); + const activeProjectId = useGenerationStore((state) => state.activeProjectId); + const projects = useGenerationStore((state) => state.projects); + const assignGenerationToProject = useGenerationStore((state) => state.assignGenerationToProject); const { addToast } = useToast(); const [deleteTargetId, setDeleteTargetId] = useState(null); const [clearConfirmOpen, setClearConfirmOpen] = useState(false); const [batchDeleteConfirmOpen, setBatchDeleteConfirmOpen] = useState(false); const [batchExportOpen, setBatchExportOpen] = useState(false); + const [projectAssignTargetId, setProjectAssignTargetId] = useState(null); const filteredHistory = useMemo(() => { const query = historyQuery.trim().toLowerCase(); return history.filter((record) => { + if (activeProjectId !== null && record.projectId !== activeProjectId) { + return false; + } if (!query) { return true; } return `${record.prompt} ${record.lyrics}`.toLowerCase().includes(query); }); - }, [history, historyQuery]); + }, [history, historyQuery, activeProjectId]); // O(1) membership lookups for per-row checks in the virtualized render loop. const selectedHistoryIdSet = useMemo(() => new Set(selectedHistoryIds), [selectedHistoryIds]); @@ -105,6 +114,10 @@ export function HistorySidebar() { +
+ +
+
@@ -358,6 +371,58 @@ export function HistorySidebar() { + {/* Assign to project */} + {projects.length > 0 && ( +
+ + + + {projectAssignTargetId === item.id && ( +
+ + {projects.map((project) => ( + + ))} +
+ )} +
+ )} {/* Delete button */} +
+ + {creating && ( +
+ setNewName(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") handleCreate(); + if (e.key === "Escape") { + setCreating(false); + setNewName(""); + } + }} + placeholder={t("projects.namePlaceholder")} + autoFocus + className="min-w-0 flex-1 rounded-md border border-[var(--color-border-light)] bg-[var(--color-surface)] px-2 py-1 text-[11px] text-[var(--color-text)] outline-none focus:border-[var(--color-accent)]" + /> + +
+ )} + + + + {projects.map((project) => ( +
+ + +
+ ))} + + {creating && ( + + )} +
+ ); +} diff --git a/src/app/lib/api.ts b/src/app/lib/api.ts index 72902a6..2b4e3d7 100644 --- a/src/app/lib/api.ts +++ b/src/app/lib/api.ts @@ -16,6 +16,7 @@ import type { GenerationRecord, GenerationRequest, ModelVariant, + Project, PromptEnhancementResult, WindowShellStateSnapshot, } from "@/app/lib/types"; @@ -264,6 +265,32 @@ export function deleteFailedRun(id: string): Promise { return invoke("delete_failed_run", { id }); } +export function listProjects(): Promise { + return invoke("list_projects"); +} + +export function createProject(name: string): Promise { + return invoke("create_project", { request: { name } }); +} + +export function renameProject(id: string, name: string): Promise { + return invoke("rename_project", { id, request: { name } }); +} + +export function deleteProject(id: string): Promise { + return invoke("delete_project", { id }); +} + +export function assignGenerationToProject( + generationId: string, + projectId: string | null, +): Promise { + return invoke("assign_generation_to_project", { + generationId, + projectId, + }); +} + export function exportGenerationsToFolder(ids: string[], destination: string): Promise { return invoke("export_generations_to_folder", { ids, destination }); } diff --git a/src/app/lib/store/index.ts b/src/app/lib/store/index.ts index 091d554..0393920 100644 --- a/src/app/lib/store/index.ts +++ b/src/app/lib/store/index.ts @@ -4,6 +4,7 @@ import { createUISlice } from "@/app/lib/store/slices/ui"; import { createModelSlice } from "@/app/lib/store/slices/model"; import { createGenerationSlice } from "@/app/lib/store/slices/generation"; import { createHistorySlice } from "@/app/lib/store/slices/history"; +import { createProjectsSlice } from "@/app/lib/store/slices/projects"; import { createSettingsSlice } from "@/app/lib/store/slices/settings"; export const useGenerationStore = create((set, get) => ({ @@ -11,5 +12,6 @@ export const useGenerationStore = create((set, get) => ({ ...createModelSlice(set, get), ...createGenerationSlice(set, get), ...createHistorySlice(set, get), + ...createProjectsSlice(set, get), ...createSettingsSlice(set, get), })); diff --git a/src/app/lib/store/slices/projects.ts b/src/app/lib/store/slices/projects.ts new file mode 100644 index 0000000..a82e39c --- /dev/null +++ b/src/app/lib/store/slices/projects.ts @@ -0,0 +1,59 @@ +import type { GenerationStore } from "@/app/lib/store/types"; +import type { StoreApi } from "zustand"; +import * as api from "@/app/lib/api"; + +export function createProjectsSlice( + set: StoreApi["setState"], + _get: StoreApi["getState"], +) { + return { + projects: [] as GenerationStore["projects"], + activeProjectId: null as string | null, + + refreshProjects: async () => { + if (!api.isTauriRuntime()) return; + const projects = await api.listProjects(); + set({ projects }); + }, + + createProject: async (name: string) => { + if (!api.isTauriRuntime()) return; + const project = await api.createProject(name); + set((state) => ({ projects: [project, ...state.projects] })); + }, + + renameProject: async (id: string, name: string) => { + if (!api.isTauriRuntime()) return; + const updated = await api.renameProject(id, name); + set((state) => ({ + projects: state.projects.map((p) => (p.id === id ? updated : p)), + })); + }, + + deleteProject: async (id: string) => { + if (!api.isTauriRuntime()) return; + await api.deleteProject(id); + set((state) => ({ + projects: state.projects.filter((p) => p.id !== id), + activeProjectId: state.activeProjectId === id ? null : state.activeProjectId, + history: state.history.map((r) => + r.projectId === id ? { ...r, projectId: null } : r, + ), + })); + }, + + setActiveProject: (id: string | null) => { + set({ activeProjectId: id }); + }, + + assignGenerationToProject: async (generationId: string, projectId: string | null) => { + if (!api.isTauriRuntime()) return; + await api.assignGenerationToProject(generationId, projectId); + set((state) => ({ + history: state.history.map((r) => + r.id === generationId ? { ...r, projectId } : r, + ), + })); + }, + }; +} diff --git a/src/app/lib/store/slices/settings.ts b/src/app/lib/store/slices/settings.ts index 3f60c5c..42db2dd 100644 --- a/src/app/lib/store/slices/settings.ts +++ b/src/app/lib/store/slices/settings.ts @@ -129,6 +129,7 @@ export function createSettingsSlice( modelCatalog, rawModelStatuses, activeTasks, + projects, ] = await Promise.all([ api.getSettings(), api.listGenerations(), @@ -136,6 +137,7 @@ export function createSettingsSlice( api.listModelCatalog(), api.getModelStatus(), api.listActiveGenerationTasks(), + api.listProjects(), ]); const profile = persistedSettings.firstRunCompleted @@ -170,6 +172,7 @@ export function createSettingsSlice( history: persistedHistory, favoriteRecordIds, activeTasks, + projects, currentGeneration: persistedHistory[0] ?? null, }); await get().refreshBootstrapStatus(); diff --git a/src/app/lib/store/types.ts b/src/app/lib/store/types.ts index 4f2b039..6d8d142 100644 --- a/src/app/lib/store/types.ts +++ b/src/app/lib/store/types.ts @@ -12,6 +12,7 @@ import type { ModelStatusSnapshot, ModelVariant, ModelBootstrapStatus, + Project, ValidationErrors, } from "@/app/lib/types"; @@ -45,6 +46,8 @@ export interface GenerationStore { selectedHistoryIds: string[]; compareModeActive: boolean; compareGenerationId: string | null; + projects: Project[]; + activeProjectId: string | null; applyGenerationEvent: (event: GenerationEvent) => void; completeSetup: () => Promise; @@ -104,4 +107,10 @@ export interface GenerationStore { enterCompareMode: (id: string) => void; exitCompareMode: () => void; toggleCompareTarget: () => void; + refreshProjects: () => Promise; + createProject: (name: string) => Promise; + renameProject: (id: string, name: string) => Promise; + deleteProject: (id: string) => Promise; + setActiveProject: (id: string | null) => void; + assignGenerationToProject: (generationId: string, projectId: string | null) => Promise; } diff --git a/src/app/lib/types.ts b/src/app/lib/types.ts index 62860a0..2527186 100644 --- a/src/app/lib/types.ts +++ b/src/app/lib/types.ts @@ -135,6 +135,14 @@ export type GenerationRecord = { errorMessage: string | null; generationInfo?: string; isFavorite: boolean; + projectId?: string | null; +}; + +export type Project = { + id: string; + name: string; + createdAt: string; + updatedAt: string; }; export type GenerationRunResult = { diff --git a/src/locales/en.json b/src/locales/en.json index 25fa217..251e77e 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -328,6 +328,19 @@ "failedRunRemoved": "Failed run removed.", "failedRunCleared": "Failed runs cleared." }, + "projects": { + "label": "Projects", + "allProjects": "All projects", + "create": "Create project", + "created": "Project \"{{name}}\" created.", + "createFailed": "Could not create project.", + "delete": "Delete project", + "deleteConfirm": "Delete project \"{{name}}\"? Generations will be unassigned, not deleted.", + "deleted": "Project \"{{name}}\" deleted.", + "deleteFailed": "Could not delete project.", + "namePlaceholder": "Project name", + "add": "Add" + }, "player": { "noGeneration": "No generation selected", "loop": "Loop", diff --git a/src/locales/zh-CN.json b/src/locales/zh-CN.json index b1b283a..1bab306 100644 --- a/src/locales/zh-CN.json +++ b/src/locales/zh-CN.json @@ -328,6 +328,19 @@ "failedRunRemoved": "失败任务已移除。", "failedRunCleared": "失败任务已清空。" }, + "projects": { + "label": "项目", + "allProjects": "全部项目", + "create": "创建项目", + "created": "项目\"{{name}}\"已创建。", + "createFailed": "无法创建项目。", + "delete": "删除项目", + "deleteConfirm": "删除项目\"{{name}}\"?生成记录将取消关联,不会被删除。", + "deleted": "项目\"{{name}}\"已删除。", + "deleteFailed": "无法删除项目。", + "namePlaceholder": "项目名称", + "add": "添加" + }, "player": { "noGeneration": "未选择生成记录", "loop": "循环播放", diff --git a/tests/unit/history-failed-runs.test.tsx b/tests/unit/history-failed-runs.test.tsx index 43c595f..27f2d15 100644 --- a/tests/unit/history-failed-runs.test.tsx +++ b/tests/unit/history-failed-runs.test.tsx @@ -84,6 +84,9 @@ describe("HistorySidebar failed runs", () => { favoriteRecordIds: [], compareModeActive: false, compareGenerationId: null, + projects: [], + activeProjectId: null, + setActiveProject: vi.fn(), deleteGenerationRecord: vi.fn(), toggleFavoriteRecord: vi.fn(), restoreLastDeletedRecord: vi.fn(), diff --git a/tests/unit/history-sidebar.test.tsx b/tests/unit/history-sidebar.test.tsx index 874b999..0d181f7 100644 --- a/tests/unit/history-sidebar.test.tsx +++ b/tests/unit/history-sidebar.test.tsx @@ -144,6 +144,9 @@ function makeStoreOverrides(overrides: Record = {}) { favoriteRecordIds: overrides.favoriteRecordIds ?? [], compareModeActive: overrides.compareModeActive ?? false, compareGenerationId: overrides.compareGenerationId ?? null, + projects: overrides.projects ?? [], + activeProjectId: overrides.activeProjectId ?? null, + setActiveProject: vi.fn(), deleteGenerationRecord: mockDeleteGenerationRecord, toggleFavoriteRecord: mockToggleFavoriteRecord, restoreLastDeletedRecord: mockRestoreLastDeletedRecord, diff --git a/tests/unit/model-slice.test.ts b/tests/unit/model-slice.test.ts index 7bbf9b6..f4d5fdb 100644 --- a/tests/unit/model-slice.test.ts +++ b/tests/unit/model-slice.test.ts @@ -39,6 +39,7 @@ const { createUISlice } = await import("@/app/lib/store/slices/ui"); const { createSettingsSlice } = await import("@/app/lib/store/slices/settings"); const { createHistorySlice } = await import("@/app/lib/store/slices/history"); const { createGenerationSlice } = await import("@/app/lib/store/slices/generation"); +const { createProjectsSlice } = await import("@/app/lib/store/slices/projects"); const { create } = await import("zustand"); @@ -52,6 +53,7 @@ function createStore() { ...createModelSlice(set, get), ...createGenerationSlice(set, get), ...createHistorySlice(set, get), + ...createProjectsSlice(set, get), ...createSettingsSlice(set, get), })); } diff --git a/tests/unit/settings-slice.test.ts b/tests/unit/settings-slice.test.ts index 201be66..b170b95 100644 --- a/tests/unit/settings-slice.test.ts +++ b/tests/unit/settings-slice.test.ts @@ -29,6 +29,7 @@ vi.mock("@/app/lib/api", () => ({ listModelCatalog: vi.fn(() => Promise.resolve([])), getModelStatus: vi.fn(() => Promise.resolve([])), listActiveGenerationTasks: vi.fn(() => Promise.resolve([])), + listProjects: vi.fn(() => Promise.resolve([])), })); vi.mock("@/app/lib/errors", () => ({ diff --git a/tests/unit/store.test.ts b/tests/unit/store.test.ts index d42f829..e301117 100644 --- a/tests/unit/store.test.ts +++ b/tests/unit/store.test.ts @@ -16,6 +16,7 @@ vi.mock("@/app/lib/api", () => ({ listModelCatalog: vi.fn(), getModelStatus: vi.fn(), listActiveGenerationTasks: vi.fn(), + listProjects: vi.fn(() => Promise.resolve([])), listenToGenerationEvents: vi.fn(), listenToModelDownloadEvents: vi.fn(), deleteGenerationFileAndRecord: (id: string) => deleteGenerationFileAndRecord(id), From 3c27f2a6082b06494c3eb29beaf24531177d8b9d Mon Sep 17 00:00:00 2001 From: Davy <95214375+thedavidweng@users.noreply.github.com> Date: Sun, 5 Jul 2026 00:43:35 -0700 Subject: [PATCH 2/4] fix: address Greptile review on PR #140 - rename_project preserves original created_at instead of overwriting - delete confirmation uses trim() for cross-platform newline handling - delete confirmation shows project name instead of UUID - extract resolve_project_by_prefix to shared cli::mod (deduplicate) - add find_generation_ids_by_prefix DB query for O(log n) prefix lookup Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- src-tauri/src/cli/list.rs | 24 +-------------- src-tauri/src/cli/mod.rs | 23 +++++++++++++++ src-tauri/src/cli/project.rs | 40 ++++++++----------------- src-tauri/src/services/db.rs | 57 +++++++++++++++++++++++++++++++++++- 4 files changed, 92 insertions(+), 52 deletions(-) diff --git a/src-tauri/src/cli/list.rs b/src-tauri/src/cli/list.rs index 4292608..bddc653 100644 --- a/src-tauri/src/cli/list.rs +++ b/src-tauri/src/cli/list.rs @@ -1,4 +1,4 @@ -use crate::{cli::human_output, cli::spec::ListArgs, models::errors::AppResult}; +use crate::{cli::human_output, cli::resolve_project_by_prefix, cli::spec::ListArgs, models::errors::AppResult}; use super::AppState; @@ -57,25 +57,3 @@ pub fn execute(state: &AppState, json: bool, args: ListArgs) -> AppResult<()> { Ok(()) } - -fn resolve_project_by_prefix( - db: &crate::services::db::Database, - prefix: &str, -) -> crate::models::errors::AppResult { - let projects = db.list_projects()?; - let matches: Vec<_> = projects - .iter() - .filter(|p| p.id.starts_with(prefix) || p.name == prefix) - .collect(); - match matches.len() { - 0 => Err(crate::models::errors::AppError::not_found( - "Project", - format!("No project matches '{prefix}'"), - )), - 1 => Ok(matches[0].id.clone()), - _ => Err(crate::models::errors::AppError::validation_failed(format!( - "Ambiguous project prefix '{prefix}' matched {} projects", - matches.len() - ))), - } -} diff --git a/src-tauri/src/cli/mod.rs b/src-tauri/src/cli/mod.rs index f2cca96..67a3056 100644 --- a/src-tauri/src/cli/mod.rs +++ b/src-tauri/src/cli/mod.rs @@ -103,3 +103,26 @@ pub fn human_output(line: &str) { pub fn human_error(line: &str) { eprintln!("\x1b[31m✗\x1b[0m Error: {line}"); } + +/// Resolve a project reference (ID prefix or exact name) to a project ID. +pub fn resolve_project_by_prefix( + db: &crate::services::db::Database, + prefix: &str, +) -> crate::models::errors::AppResult { + let projects = db.list_projects()?; + let matches: Vec<_> = projects + .iter() + .filter(|p| p.id.starts_with(prefix) || p.name == prefix) + .collect(); + match matches.len() { + 0 => Err(crate::models::errors::AppError::not_found( + "Project", + format!("No project matches '{prefix}'"), + )), + 1 => Ok(matches[0].id.clone()), + _ => Err(crate::models::errors::AppError::validation_failed(format!( + "Ambiguous project prefix '{prefix}' matched {} projects", + matches.len() + ))), + } +} diff --git a/src-tauri/src/cli/project.rs b/src-tauri/src/cli/project.rs index f60d2f4..9e960bb 100644 --- a/src-tauri/src/cli/project.rs +++ b/src-tauri/src/cli/project.rs @@ -1,5 +1,5 @@ use crate::{ - cli::{cli_error, human_output, json_output, spec::ProjectCommand}, + cli::{cli_error, human_output, json_output, resolve_project_by_prefix, spec::ProjectCommand}, models::errors::{AppError, AppResult}, }; @@ -82,16 +82,23 @@ fn cmd_delete(state: &AppState, json: bool, id: &str, yes: bool) -> AppResult<() if !yes { use std::io::Write; + let projects = state.db.list_projects()?; + let display_name = projects + .iter() + .find(|p| p.id == resolved) + .map(|p| p.name.as_str()) + .unwrap_or(&resolved); print!( "Delete project '{}'? Generations will be unassigned, not deleted. [y/N] ", - resolved + display_name ); std::io::stdout().flush().ok(); let mut input = String::new(); std::io::stdin() .read_line(&mut input) .map_err(|e| cli_error(e.to_string()))?; - if !["y\n", "Y\n", "yes\n", "Yes\n"].contains(&input.as_str()) { + let trimmed = input.trim(); + if !["y", "Y", "yes", "Yes"].contains(&trimmed) { human_output("Cancelled."); return Ok(()); } @@ -144,37 +151,14 @@ fn cmd_assign( Ok(()) } -fn resolve_project_by_prefix( - db: &crate::services::db::Database, - prefix: &str, -) -> AppResult { - let projects = db.list_projects()?; - let matches: Vec<_> = projects - .iter() - .filter(|p| p.id.starts_with(prefix) || p.name == prefix) - .collect(); - match matches.len() { - 0 => Err(AppError::not_found( - "Project", - format!("No project matches '{prefix}'"), - )), - 1 => Ok(matches[0].id.clone()), - _ => Err(AppError::validation_failed(format!( - "Ambiguous project prefix '{prefix}' matched {} projects", - matches.len() - ))), - } -} - fn resolve_generation_by_prefix(state: &AppState, prefix: &str) -> AppResult { - let records = state.db.list_generations(None, None)?; - let matches: Vec<_> = records.iter().filter(|r| r.id.starts_with(prefix)).collect(); + let matches = state.db.find_generation_ids_by_prefix(prefix)?; match matches.len() { 0 => Err(AppError::not_found( "Generation record", format!("No generation record matches '{prefix}'"), )), - 1 => Ok(matches[0].id.clone()), + 1 => Ok(matches[0].clone()), _ => Err(AppError::validation_failed(format!( "Ambiguous generation prefix '{prefix}' matched {} records", matches.len() diff --git a/src-tauri/src/services/db.rs b/src-tauri/src/services/db.rs index 162aa92..9b3a9c6 100644 --- a/src-tauri/src/services/db.rs +++ b/src-tauri/src/services/db.rs @@ -346,10 +346,17 @@ impl Database { format!("No project exists for id {id}"), )); } + let created_at: String = connection + .query_row( + "SELECT created_at FROM projects WHERE id = ?1", + [id], + |row| row.get(0), + ) + .map_err(|error| AppError::db_read_failed(error.to_string()))?; Ok(Project { id: id.to_string(), name: name.to_string(), - created_at: now.clone(), + created_at, updated_at: now, }) } @@ -389,6 +396,22 @@ impl Database { Ok(()) } + /// Find generation IDs matching a prefix. Returns up to 2 matches for ambiguity detection. + pub fn find_generation_ids_by_prefix(&self, prefix: &str) -> AppResult> { + let connection = self.connection()?; + let pattern = format!("{prefix}%"); + let mut statement = connection + .prepare("SELECT id FROM generations WHERE id LIKE ?1 LIMIT 2") + .map_err(|error| AppError::db_read_failed(error.to_string()))?; + let mapped = statement + .query_map(params![pattern], |row| row.get::<_, String>(0)) + .map_err(|error| AppError::db_read_failed(error.to_string()))?; + mapped + .into_iter() + .map(|row| row.map_err(|error| AppError::db_read_failed(error.to_string()))) + .collect() + } + pub fn list_generations_by_project( &self, project_id: &str, @@ -1034,4 +1057,36 @@ mod tests { .expect("record"); assert!(fetched.project_id.is_none()); } + + #[test] + fn find_generation_ids_by_prefix_returns_targeted_matches() { + let temp_dir = tempfile::tempdir().expect("temp dir"); + let database = Database::new(temp_dir.path()).expect("database should init"); + + let mut record = sample_record(); + record.id = "gen_abc123".to_owned(); + database.insert_generation(&record).expect("insert"); + + let mut other = sample_record(); + other.id = "gen_xyz789".to_owned(); + database.insert_generation(&other).expect("insert"); + + // Prefix match + let matches = database + .find_generation_ids_by_prefix("gen_abc") + .expect("prefix search"); + assert_eq!(matches, vec!["gen_abc123".to_owned()]); + + // No match + let none = database + .find_generation_ids_by_prefix("zzz") + .expect("prefix search"); + assert!(none.is_empty()); + + // Ambiguous prefix (both start with "gen_") + let ambiguous = database + .find_generation_ids_by_prefix("gen_") + .expect("prefix search"); + assert_eq!(ambiguous.len(), 2); + } } From 3cde86c398cc281c67235f85bc4d43c83e59ef93 Mon Sep 17 00:00:00 2001 From: Davy <95214375+thedavidweng@users.noreply.github.com> Date: Sun, 5 Jul 2026 15:00:29 -0700 Subject: [PATCH 3/4] fix: handle project assignment errors in HistorySidebar The unassign and assign onClick handlers fired assignGenerationToProject without awaiting or catching, so a backend failure produced an unhandled promise rejection and the UI silently kept the stale project association. Attach .catch handlers that log the failure, matching the existing warn-on-error pattern in this file. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- src/app/components/history/HistorySidebar.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/app/components/history/HistorySidebar.tsx b/src/app/components/history/HistorySidebar.tsx index ea8f1b8..13914f7 100644 --- a/src/app/components/history/HistorySidebar.tsx +++ b/src/app/components/history/HistorySidebar.tsx @@ -394,7 +394,9 @@ export function HistorySidebar() { type="button" onClick={(e) => { e.stopPropagation(); - assignGenerationToProject(item.id, null); + void assignGenerationToProject(item.id, null).catch((error) => { + console.warn("Failed to unassign generation:", error); + }); setProjectAssignTargetId(null); }} className="block w-full px-3 py-1 text-left text-[11px] text-[var(--color-text-dim)] hover:bg-[var(--color-surface-muted)]" @@ -407,7 +409,9 @@ export function HistorySidebar() { type="button" onClick={(e) => { e.stopPropagation(); - assignGenerationToProject(item.id, project.id); + void assignGenerationToProject(item.id, project.id).catch((error) => { + console.warn("Failed to assign generation:", error); + }); setProjectAssignTargetId(null); }} className={`block w-full px-3 py-1 text-left text-[11px] hover:bg-[var(--color-surface-muted)] ${ From 242203342ca56f0ef50ac5d0c802f12453bfef71 Mon Sep 17 00:00:00 2001 From: Davy <95214375+thedavidweng@users.noreply.github.com> Date: Tue, 7 Jul 2026 21:48:00 -0700 Subject: [PATCH 4/4] style: apply cargo fmt and oxfmt after rebase onto main Pick up the auto-format changes from the Dependabot merges and apply rustfmt/oxfmt to the new project CLI and store slice code. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- src-tauri/src/cli/list.rs | 5 ++++- src-tauri/src/cli/mod.rs | 4 +--- src-tauri/src/cli/project.rs | 12 ++++++++---- src-tauri/src/cli/run.rs | 10 ++++++++-- src-tauri/src/services/db.rs | 8 ++++---- src/app/components/history/HistorySidebar.tsx | 16 ++++++++++------ src/app/lib/store/slices/projects.ts | 8 ++------ 7 files changed, 37 insertions(+), 26 deletions(-) diff --git a/src-tauri/src/cli/list.rs b/src-tauri/src/cli/list.rs index bddc653..1ef293d 100644 --- a/src-tauri/src/cli/list.rs +++ b/src-tauri/src/cli/list.rs @@ -1,4 +1,7 @@ -use crate::{cli::human_output, cli::resolve_project_by_prefix, cli::spec::ListArgs, models::errors::AppResult}; +use crate::{ + cli::human_output, cli::resolve_project_by_prefix, cli::spec::ListArgs, + models::errors::AppResult, +}; use super::AppState; diff --git a/src-tauri/src/cli/mod.rs b/src-tauri/src/cli/mod.rs index 67a3056..e99fd45 100644 --- a/src-tauri/src/cli/mod.rs +++ b/src-tauri/src/cli/mod.rs @@ -70,9 +70,7 @@ fn run_inner(args: Vec) -> AppResult<()> { generation::execute(&state, json, command) } spec::Commands::List(args) => list::execute(&state, json, args), - spec::Commands::Project { command } => { - project::execute(&state, json, command) - } + spec::Commands::Project { command } => project::execute(&state, json, command), spec::Commands::Delete(args) => delete::execute(&state, json, args), spec::Commands::Clear(args) => clear::execute(&state, json, args), spec::Commands::Ps => ps::execute(&state, json), diff --git a/src-tauri/src/cli/project.rs b/src-tauri/src/cli/project.rs index 9e960bb..4cedf68 100644 --- a/src-tauri/src/cli/project.rs +++ b/src-tauri/src/cli/project.rs @@ -11,9 +11,10 @@ pub fn execute(state: &AppState, json: bool, command: ProjectCommand) -> AppResu ProjectCommand::Create { name } => cmd_create(state, json, &name), ProjectCommand::Rename { id, name } => cmd_rename(state, json, &id, &name), ProjectCommand::Delete { id, yes } => cmd_delete(state, json, &id, yes), - ProjectCommand::Assign { generation, project } => { - cmd_assign(state, json, &generation, project.as_deref()) - } + ProjectCommand::Assign { + generation, + project, + } => cmd_assign(state, json, &generation, project.as_deref()), } } @@ -145,7 +146,10 @@ fn cmd_assign( "Assigned generation {} to project {}", generation_id, id )), - None => human_output(&format!("Unassigned generation {} from project", generation_id)), + None => human_output(&format!( + "Unassigned generation {} from project", + generation_id + )), } } Ok(()) diff --git a/src-tauri/src/cli/run.rs b/src-tauri/src/cli/run.rs index d286112..8d1add9 100644 --- a/src-tauri/src/cli/run.rs +++ b/src-tauri/src/cli/run.rs @@ -246,7 +246,10 @@ pub fn execute(state: &AppState, json: bool, mut args: RunArgs) -> AppResult<()> .set_generation_project(&record.id, Some(&project_id))?; } if !json { - human_output(&format!("✓ Assigned {} generation(s) to project.", result.records.len())); + human_output(&format!( + "✓ Assigned {} generation(s) to project.", + result.records.len() + )); } } @@ -415,7 +418,10 @@ fn resolve_project_ref( reference: &str, ) -> crate::models::errors::AppResult { let projects = db.list_projects()?; - let by_id: Vec<_> = projects.iter().filter(|p| p.id.starts_with(reference)).collect(); + let by_id: Vec<_> = projects + .iter() + .filter(|p| p.id.starts_with(reference)) + .collect(); if by_id.len() == 1 { return Ok(by_id[0].id.clone()); } diff --git a/src-tauri/src/services/db.rs b/src-tauri/src/services/db.rs index 9b3a9c6..7c447dd 100644 --- a/src-tauri/src/services/db.rs +++ b/src-tauri/src/services/db.rs @@ -295,7 +295,9 @@ impl Database { pub fn list_projects(&self) -> AppResult> { let connection = self.connection()?; let mut statement = connection - .prepare("SELECT id, name, created_at, updated_at FROM projects ORDER BY updated_at DESC") + .prepare( + "SELECT id, name, created_at, updated_at FROM projects ORDER BY updated_at DESC", + ) .map_err(|error| AppError::db_read_failed(error.to_string()))?; let mapped = statement .query_map([], |row| { @@ -963,9 +965,7 @@ mod tests { assert!(database.list_projects().expect("list").is_empty()); // Create - let project = database - .create_project("Album A") - .expect("create project"); + let project = database.create_project("Album A").expect("create project"); assert_eq!(project.name, "Album A"); // List diff --git a/src/app/components/history/HistorySidebar.tsx b/src/app/components/history/HistorySidebar.tsx index 13914f7..404af7e 100644 --- a/src/app/components/history/HistorySidebar.tsx +++ b/src/app/components/history/HistorySidebar.tsx @@ -394,9 +394,11 @@ export function HistorySidebar() { type="button" onClick={(e) => { e.stopPropagation(); - void assignGenerationToProject(item.id, null).catch((error) => { - console.warn("Failed to unassign generation:", error); - }); + void assignGenerationToProject(item.id, null).catch( + (error) => { + console.warn("Failed to unassign generation:", error); + }, + ); setProjectAssignTargetId(null); }} className="block w-full px-3 py-1 text-left text-[11px] text-[var(--color-text-dim)] hover:bg-[var(--color-surface-muted)]" @@ -409,9 +411,11 @@ export function HistorySidebar() { type="button" onClick={(e) => { e.stopPropagation(); - void assignGenerationToProject(item.id, project.id).catch((error) => { - console.warn("Failed to assign generation:", error); - }); + void assignGenerationToProject(item.id, project.id).catch( + (error) => { + console.warn("Failed to assign generation:", error); + }, + ); setProjectAssignTargetId(null); }} className={`block w-full px-3 py-1 text-left text-[11px] hover:bg-[var(--color-surface-muted)] ${ diff --git a/src/app/lib/store/slices/projects.ts b/src/app/lib/store/slices/projects.ts index a82e39c..15a47c2 100644 --- a/src/app/lib/store/slices/projects.ts +++ b/src/app/lib/store/slices/projects.ts @@ -36,9 +36,7 @@ export function createProjectsSlice( set((state) => ({ projects: state.projects.filter((p) => p.id !== id), activeProjectId: state.activeProjectId === id ? null : state.activeProjectId, - history: state.history.map((r) => - r.projectId === id ? { ...r, projectId: null } : r, - ), + history: state.history.map((r) => (r.projectId === id ? { ...r, projectId: null } : r)), })); }, @@ -50,9 +48,7 @@ export function createProjectsSlice( if (!api.isTauriRuntime()) return; await api.assignGenerationToProject(generationId, projectId); set((state) => ({ - history: state.history.map((r) => - r.id === generationId ? { ...r, projectId } : r, - ), + history: state.history.map((r) => (r.id === generationId ? { ...r, projectId } : r)), })); }, };