dev-manager-tauri/src-tauri/src/commands/projects.rs
lanrtop 0a92a5b695 fix(mcp/parity): 收尾审查修复——Claude 现状以 claude mcp get 为准,接入命令移出界面线程
收尾对抗审查指出:只读扫描 ~/.claude.json 的结论不能代表 Claude 在项目目录实际生效的条目
(共享项目键、worktree、目录迁移下会误删他项目条目或漏写),且同步 CLI 调用会阻塞界面线程。

- 快路径只认「项目根或其祖先目录键下」的一致托管条目;其余一律在项目目录执行
  `claude mcp get lian-jing` 取实际生效条目,按归属决策:本项目托管条目 → 刷新/移除
  (写入失败用原条目回滚);他项目托管条目或手写条目 → 不碰并记录;无 local 条目 → 写入
- 输出无法识别、CLI 出错或目录不存在时一律不写,只记 warn
- 全局迁移需 claude CLI 与 git-bash 均可用才置完成标记;批量重注入的成员复查失败计入错误
- 删组/移出组先删成员关系再清 MCP 配置,避免并发重注入把项目加回;调 CLI 前释放数据库连接
- delete_group / add_group_member / remove_group_member / delete_project / update_settings /
  apply_blueprint_buff 改为 #[tauri::command(async)],在线程池执行
- 新增 parse_claude_mcp_get / lianjing_project_of / key_covers_root 单元测试

实测(enterprise):稳态注入 174ms 不起 CLI;条目缺失 → 写入;过期 helper → 刷新;
手写条目 → 保留;三种情况 claude mcp get 均为 Local 且 Connected;经 shim 零凭据探针认证通过。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 16:55:28 +09:00

499 lines
17 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 视图 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<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(路径或平台变化时,先记下旧根目录,供客户端侧配置迁移)
let root_may_change =
input.win_path.is_some() || input.wsl_path.is_some() || input.platform.is_some();
let old_root = root_may_change
.then(|| crate::mcp_inject::project_root_with_conn(&conn, &id).ok())
.flatten();
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())?;
// 根目录变化:Claude local 条目按目录挂载、DSH 白名单按根目录匹配,都要迁移到新根。
// 会调用 claude CLI,放到后台线程,避免同步命令卡住界面。
if root_may_change {
let new_root = crate::mcp_inject::project_root_with_conn(&conn, &id).ok();
if crate::mcp_inject::project_root_changed(old_root.as_ref(), new_root.as_ref()) {
drop(conn);
std::thread::spawn(move || crate::mcp_inject::on_project_root_changed(&id, old_root));
}
}
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(())
}
// 会经 claude CLI 清理客户端配置:在线程池执行,不占界面线程
#[tauri::command(async)]
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));
crate::commands::dev_variables::purge_project(
&conn,
&crate::credential_store::WindowsCredentialStore,
&id,
)?;
// 清理炼境 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)
}