use crate::db::pool; use crate::mcp_inject; use crate::models::{ActivityLog, Project}; use rusqlite::params; use serde::{Deserialize, Serialize}; use serde_json::Value; /// JOIN 视图 SQL:project_workspaces + project_profiles + git_repos(可选) /// git_repos 数据优先于 profile(COALESCE),部署信息来自 git_repos const PROJECT_VIEW_SQL: &str = " SELECT pw.id, pw.id AS workspace_id, pp.id AS profile_id, COALESCE( pw.repo_id, (SELECT id FROM git_repos WHERE repo_url = pp.repo_url LIMIT 1) ) AS repo_id, COALESCE(gr.name, pp.name) AS name, COALESCE(gr.description, pp.description) AS description, pp.type, COALESCE(gr.status, pp.status) AS status, COALESCE(gr.priority, pp.priority) AS priority, COALESCE(gr.tech_stack, pp.tech_stack) AS tech_stack, pp.tags AS tags, COALESCE(gr.repo_url, pp.repo_url) AS repo_url, COALESCE(gr.upstream_url, pp.upstream_url) AS upstream_url, COALESCE(gr.default_branch, pp.default_branch) AS default_branch, COALESCE(gr.staging_url, pp.staging_url) AS staging_url, COALESCE(gr.prod_url, pp.prod_url) AS prod_url, COALESCE(gr.server_note, pp.server_note) AS server_note, pw.space_id, pw.wsl_path, pw.win_path, pw.platform, pw.recent_focus, pw.startup_commands, pw.last_push_at, pw.updated_at FROM project_workspaces pw JOIN project_profiles pp ON pp.id = pw.profile_id LEFT JOIN git_repos gr ON gr.id = pw.repo_id "; #[derive(Debug, Deserialize)] pub struct CreateProjectInput { pub id: String, pub name: String, pub description: Option, #[serde(rename = "type")] pub type_: Option, pub status: Option, pub priority: Option, // profile 字段(共享) pub tech_stack: Option, pub repo_url: Option, pub upstream_url: Option, pub default_branch: Option, pub staging_url: Option, pub prod_url: Option, pub server_note: Option, // workspace 字段(每空间独立) pub space_id: Option, pub wsl_path: Option, pub win_path: Option, pub platform: Option, pub recent_focus: Option, pub startup_commands: Option, /// 若提供则跳过建 profile,直接为已有 profile 新增 workspace pub profile_id: Option, } #[derive(Debug, Deserialize)] pub struct UpdateProjectInput { // profile 字段(共享) pub name: Option, pub description: Option, #[serde(rename = "type")] pub type_: Option, pub status: Option, pub priority: Option, pub tech_stack: Option, pub tags: Option, pub staging_url: Option, pub prod_url: Option, pub server_note: Option, // workspace 字段(每空间独立) pub wsl_path: Option, pub win_path: Option, pub platform: Option, pub recent_focus: Option, pub startup_commands: Option, } #[derive(Debug, Serialize)] pub struct ProjectDetail { #[serde(flatten)] pub project: Project, pub recent_activities: Vec, } #[tauri::command] pub fn get_projects(status: Option, space_id: Option) -> Result, String> { let conn = pool().get().map_err(|e| e.to_string())?; let projects: Vec = match (&status, &space_id) { (Some(s), Some(sid)) => { let sql = format!("{PROJECT_VIEW_SQL} WHERE pp.status = ?1 AND pw.space_id = ?2 ORDER BY pw.updated_at DESC"); let mut stmt = conn.prepare(&sql).map_err(|e| e.to_string())?; let x = stmt.query_map(params![s, sid], Project::from_row) .map_err(|e| e.to_string())? .map(|r| r.map_err(|e| e.to_string())) .collect::, _>>()?; x } (Some(s), None) => { let sql = format!("{PROJECT_VIEW_SQL} WHERE pp.status = ?1 ORDER BY pw.updated_at DESC"); let mut stmt = conn.prepare(&sql).map_err(|e| e.to_string())?; let x = stmt.query_map(params![s], Project::from_row) .map_err(|e| e.to_string())? .map(|r| r.map_err(|e| e.to_string())) .collect::, _>>()?; x } (None, Some(sid)) => { let sql = format!("{PROJECT_VIEW_SQL} WHERE pw.space_id = ?1 ORDER BY pw.updated_at DESC"); let mut stmt = conn.prepare(&sql).map_err(|e| e.to_string())?; let x = stmt.query_map(params![sid], Project::from_row) .map_err(|e| e.to_string())? .map(|r| r.map_err(|e| e.to_string())) .collect::, _>>()?; x } (None, None) => { let sql = format!("{PROJECT_VIEW_SQL} ORDER BY pw.updated_at DESC"); let mut stmt = conn.prepare(&sql).map_err(|e| e.to_string())?; let x = stmt.query_map([], Project::from_row) .map_err(|e| e.to_string())? .map(|r| r.map_err(|e| e.to_string())) .collect::, _>>()?; x } }; Ok(projects) } #[tauri::command] pub fn get_project(id: String) -> Result { let conn = pool().get().map_err(|e| e.to_string())?; let sql = format!("{PROJECT_VIEW_SQL} WHERE pw.id = ?1"); let project = conn .query_row(&sql, params![id], Project::from_row) .map_err(|_| format!("Project not found: {id}"))?; let mut stmt = conn .prepare("SELECT * FROM activity_log WHERE project_id = ?1 ORDER BY created_at DESC LIMIT 5") .map_err(|e| e.to_string())?; let activities: Vec = stmt .query_map(params![id], ActivityLog::from_row) .map_err(|e| e.to_string())? .collect::, _>>() .map_err(|e| e.to_string())?; drop(stmt); Ok(ProjectDetail { project, recent_activities: activities }) } #[tauri::command] pub fn create_project(input: CreateProjectInput) -> Result<(), String> { let conn = pool().get().map_err(|e| e.to_string())?; let profile_id = if let Some(ref pid) = input.profile_id { // 快速路径:profile 已存在,直接新建 workspace pid.clone() } else { // 标准路径:先建 profile let pid = format!("prof-{}", input.id); conn.execute( "INSERT OR IGNORE INTO project_profiles (id, name, description, type, status, priority, tech_stack, repo_url, upstream_url, default_branch, staging_url, prod_url, server_note, updated_at) VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,datetime('now'))", params![ pid, input.name, input.description, input.type_, input.status.as_deref().unwrap_or("active"), input.priority.as_deref().unwrap_or("mid"), input.tech_stack, input.repo_url, input.upstream_url, input.default_branch, input.staging_url, input.prod_url, input.server_note, ], ).map_err(|e| e.to_string())?; pid }; conn.execute( "INSERT OR IGNORE INTO project_workspaces (id, profile_id, space_id, wsl_path, win_path, platform, recent_focus, startup_commands, updated_at) VALUES (?1,?2,?3,?4,?5,?6,?7,?8,datetime('now'))", params![ input.id, profile_id, input.space_id, input.wsl_path, input.win_path, input.platform.as_deref().unwrap_or("windows"), input.recent_focus, input.startup_commands, ], ).map_err(|e| { if e.to_string().contains("UNIQUE") { "Project id already exists".into() } else { e.to_string() } })?; Ok(()) } #[tauri::command] pub fn update_project(id: String, input: UpdateProjectInput) -> Result<(), String> { let conn = pool().get().map_err(|e| e.to_string())?; // 1. 更新 profile(通过 workspace.id 定位) conn.execute( "UPDATE project_profiles SET name = COALESCE(?1, name), description = COALESCE(?2, description), type = COALESCE(?3, type), status = COALESCE(?4, status), priority = COALESCE(?5, priority), tech_stack = COALESCE(?6, tech_stack), tags = COALESCE(?7, tags), staging_url = ?8, prod_url = ?9, server_note = ?10, updated_at = datetime('now') WHERE id = (SELECT profile_id FROM project_workspaces WHERE id = ?11)", params![ input.name, input.description, input.type_, input.status, input.priority, input.tech_stack, input.tags, input.staging_url, input.prod_url, input.server_note, id ], ).map_err(|e| e.to_string())?; // 2. 更新 workspace conn.execute( "UPDATE project_workspaces SET wsl_path = COALESCE(?1, wsl_path), win_path = COALESCE(?2, win_path), platform = COALESCE(?3, platform), recent_focus = COALESCE(?4, recent_focus), startup_commands = COALESCE(?5, startup_commands), updated_at = datetime('now') WHERE id = ?6", params![ input.wsl_path, input.win_path, input.platform, input.recent_focus, input.startup_commands, id ], ).map_err(|e| e.to_string())?; Ok(()) } #[tauri::command] pub fn update_project_tags(id: String, tags: String) -> Result<(), String> { let conn = pool().get().map_err(|e| e.to_string())?; conn.execute( "UPDATE project_profiles SET tags = ?1, updated_at = datetime('now') WHERE id = (SELECT profile_id FROM project_workspaces WHERE id = ?2)", params![if tags.is_empty() { None } else { Some(tags) }, id], ).map_err(|e| e.to_string())?; Ok(()) } #[tauri::command] pub fn delete_project(id: String, delete_local: Option) -> Result<(), String> { let conn = pool().get().map_err(|e| e.to_string())?; // 删除前先读取路径和 profile_id let (profile_id, win_path, wsl_path): (Option, Option, Option) = conn .query_row( "SELECT profile_id, win_path, wsl_path FROM project_workspaces WHERE id = ?1", params![id], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), ) .unwrap_or((None, None, None)); // 清理炼境 MCP 配置(在删除 project_workspaces 记录之前,需查项目路径) if let Err(e) = mcp_inject::remove_project_mcp(&id) { eprintln!("[MCP remove] 删除项目时清除失败 project={}: {}", id, e); } // 清理关联数据(旧 FK 指向 projects 表,需手动清理) for table in &["activity_log", "project_tools", "project_group_map", "project_env_tools", "deploy_commands"] { conn.execute( &format!("DELETE FROM {table} WHERE project_id = ?1"), params![id], ).map_err(|e| e.to_string())?; } conn.execute( "DELETE FROM relations WHERE from_project = ?1 OR to_project = ?1", params![id], ).map_err(|e| e.to_string())?; // 删除 workspace conn.execute("DELETE FROM project_workspaces WHERE id = ?1", params![id]) .map_err(|e| e.to_string())?; // 若 profile 已无 workspace,同步删除 profile if let Some(pid) = profile_id { let remaining: i64 = conn.query_row( "SELECT COUNT(*) FROM project_workspaces WHERE profile_id = ?1", params![pid], |row| row.get(0), ).unwrap_or(0); if remaining == 0 { conn.execute("DELETE FROM project_profiles WHERE id = ?1", params![pid]) .map_err(|e| e.to_string())?; } } // 删除本地文件夹(仅在明确要求时执行) if delete_local.unwrap_or(false) { // Windows 路径(直接用 std::fs) if let Some(path) = win_path.filter(|p| !p.is_empty()) { let p = std::path::Path::new(&path); if p.exists() { std::fs::remove_dir_all(p) .map_err(|e| format!("删除本地文件夹失败: {e}"))?; } } // WSL 路径(通过 wsl.exe 删除) if let Some(path) = wsl_path.filter(|p| !p.is_empty()) { let _ = crate::silent_cmd("wsl") .args(["--", "rm", "-rf", &path]) .output(); } } Ok(()) } /// 扫描指定目录,返回所有子文件夹信息 [{name, path}] #[tauri::command] pub fn scan_subfolders(dir: String) -> Result, String> { use std::fs; let entries = fs::read_dir(&dir).map_err(|e| format!("无法读取目录 \"{dir}\": {e}"))?; let mut result = Vec::new(); for entry in entries.flatten() { let path = entry.path(); if path.is_dir() { let name = entry.file_name().to_string_lossy().into_owned(); if name.starts_with('.') { continue; } let mut obj = serde_json::Map::new(); obj.insert("name".into(), Value::String(name)); obj.insert("path".into(), Value::String(path.to_string_lossy().into_owned())); result.push(Value::Object(obj)); } } result.sort_by(|a, b| { a.get("name").and_then(|v| v.as_str()) .cmp(&b.get("name").and_then(|v| v.as_str())) }); Ok(result) } #[derive(Debug, Deserialize)] pub struct BatchProjectInput { pub id: String, pub name: String, pub win_path: Option, pub wsl_path: Option, pub status: String, pub priority: String, pub space_id: Option, pub platform: Option, } /// 批量创建项目(已存在的 workspace id 跳过) #[tauri::command] pub fn batch_create_projects(items: Vec) -> Result, String> { let conn = pool().get().map_err(|e| e.to_string())?; let mut skipped = Vec::new(); for item in items { let exists: bool = conn .query_row("SELECT 1 FROM project_workspaces WHERE id = ?1", params![item.id], |_| Ok(true)) .unwrap_or(false); if exists { skipped.push(item.id); continue; } let profile_id = format!("prof-{}", item.id); conn.execute( "INSERT OR IGNORE INTO project_profiles (id, name, status, priority, updated_at) VALUES (?1, ?2, ?3, ?4, datetime('now'))", params![profile_id, item.name, item.status, item.priority], ).map_err(|e| e.to_string())?; conn.execute( "INSERT INTO project_workspaces (id, profile_id, win_path, wsl_path, space_id, platform, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, datetime('now'))", params![ item.id, profile_id, item.win_path, item.wsl_path, item.space_id, item.platform, ], ).map_err(|e| e.to_string())?; } Ok(skipped) }