dev-manager-tauri/src-tauri/src/commands/projects.rs
lanrtop a41161f7d1
All checks were successful
Push & PR Check / check (push) Successful in 1m4s
feat(mcp): 单一全局 lian-jing 服务器——组级挂载收敛(T1-T3+T5)
T1 服务器层:新增全局 /mcp + /mcp/messages 路由,process_rpc 组上下文
Option 化;旧 /mcp/group/:gid 路由保留过渡,T6 移除。驻军测试 2 条。
T2 工具层:resolve_project_root 按 project_id 直查 project_workspaces,
删除 3 处组归属校验(实测无隔离价值);list_group_projects 改显式传参。
T3 注入层:settings.json 收敛为单 "lian-jing" key(URL /mcp),注入时
清理旧 lian-jing:group-* key;liangjing.json 主动删除 group_id;
启动时一次性迁移(重注入全量 + 清理旧全局注册两种命名格式,标记位防重)。
T5 同步:git-workflow 文档/模板、retro-catchup 技能 Python 兜底、
设置页 URL 展示、mcp-config-inject 模块取代注记。

收益:MCP 客户端配置成本 O(N组)→O(1),授权一次永久生效;
group_id 缺失/过期/移组同步一整类问题从根上消失。
2026-07-06 19:30:01 +09:00

401 lines
15 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 视图 SQLproject_workspaces + project_profiles + git_repos(可选)
/// git_repos 数据优先于 profileCOALESCE部署信息来自 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<String>,
#[serde(rename = "type")]
pub type_: Option<String>,
pub status: Option<String>,
pub priority: Option<String>,
// profile 字段(共享)
pub tech_stack: Option<String>,
pub repo_url: Option<String>,
pub upstream_url: Option<String>,
pub default_branch: Option<String>,
pub staging_url: Option<String>,
pub prod_url: Option<String>,
pub server_note: Option<String>,
// workspace 字段(每空间独立)
pub space_id: Option<String>,
pub wsl_path: Option<String>,
pub win_path: Option<String>,
pub platform: Option<String>,
pub recent_focus: Option<String>,
pub startup_commands: Option<String>,
/// 若提供则跳过建 profile直接为已有 profile 新增 workspace
pub profile_id: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct UpdateProjectInput {
// profile 字段(共享)
pub name: Option<String>,
pub description: Option<String>,
#[serde(rename = "type")]
pub type_: Option<String>,
pub status: Option<String>,
pub priority: Option<String>,
pub tech_stack: Option<String>,
pub tags: Option<String>,
pub staging_url: Option<String>,
pub prod_url: Option<String>,
pub server_note: Option<String>,
// workspace 字段(每空间独立)
pub wsl_path: Option<String>,
pub win_path: Option<String>,
pub platform: Option<String>,
pub recent_focus: Option<String>,
pub startup_commands: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct ProjectDetail {
#[serde(flatten)]
pub project: Project,
pub recent_activities: Vec<ActivityLog>,
}
#[tauri::command]
pub fn get_projects(status: Option<String>, space_id: Option<String>) -> Result<Vec<Project>, String> {
let conn = pool().get().map_err(|e| e.to_string())?;
let projects: Vec<Project> = 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::<Result<Vec<_>, _>>()?; 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::<Result<Vec<_>, _>>()?; 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::<Result<Vec<_>, _>>()?; 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::<Result<Vec<_>, _>>()?; x
}
};
Ok(projects)
}
#[tauri::command]
pub fn get_project(id: String) -> Result<ProjectDetail, String> {
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<ActivityLog> = stmt
.query_map(params![id], ActivityLog::from_row)
.map_err(|e| e.to_string())?
.collect::<Result<Vec<_>, _>>()
.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<bool>) -> Result<(), String> {
let conn = pool().get().map_err(|e| e.to_string())?;
// 删除前先读取路径和 profile_id
let (profile_id, win_path, wsl_path): (Option<String>, Option<String>, Option<String>) = 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<Vec<Value>, 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<String>,
pub wsl_path: Option<String>,
pub status: String,
pub priority: String,
pub space_id: Option<String>,
pub platform: Option<String>,
}
/// 批量创建项目(已存在的 workspace id 跳过)
#[tauri::command]
pub fn batch_create_projects(items: Vec<BatchProjectInput>) -> Result<Vec<String>, 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)
}