收尾对抗审查指出:只读扫描 ~/.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>
499 lines
17 KiB
Rust
499 lines
17 KiB
Rust
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)
|
||
}
|