dev-manager-tauri/src-tauri/src/commands/devtools.rs
lanrtop 0065866f6b fix: 消除 Windows 子进程黑框闪烁,修复 DB 外键迁移
- 新增 silent_cmd() 辅助函数,统一添加 CREATE_NO_WINDOW 标志,全量替换 commands/ 中的裸 std::process::Command::new 调用
- 补充 project_tools / activity_log / project_env_tools 外键从 projects 迁移到 project_workspaces 的 migrate 逻辑
- CONVENTIONS.md v1.4.2:补充 files 回填规则和批量执行完整性检查规则
2026-04-08 01:13:53 +09:00

322 lines
16 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 rusqlite::params;
use serde::{Deserialize, Serialize};
// ── 数据结构 ──────────────────────────────────────────────────────────────────
#[derive(Debug, Serialize, Clone)]
pub struct ScannedTool {
pub id: String,
pub name: String,
pub category: String, // editor / runtime / pkg_manager / version_manager / cli / database
pub cmd: String,
pub path: Option<String>,
pub version: Option<String>,
pub found: bool,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct DevToolRecord {
pub id: String,
pub name: String,
pub category: String,
pub cmd: Option<String>,
pub path: Option<String>,
pub version: Option<String>,
pub note: Option<String>,
pub env: String,
pub space_id: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct SaveDevToolInput {
pub id: String,
pub name: String,
pub category: String,
pub cmd: Option<String>,
pub path: Option<String>,
pub version: Option<String>,
pub note: Option<String>,
pub env: Option<String>,
pub space_id: Option<String>,
}
// ── 内置工具特征库 ─────────────────────────────────────────────────────────────
struct ToolSpec {
id: &'static str,
name: &'static str,
category: &'static str,
cmd: &'static str,
version_flag: &'static str,
/// Windows 常见安装路径fallback当 PATH 里找不到时)
fallback_paths: &'static [&'static str],
}
fn tool_specs() -> Vec<ToolSpec> {
vec![
// 编辑器 / IDE
ToolSpec { id: "cursor", name: "Cursor", category: "editor", cmd: "cursor", version_flag: "--version", fallback_paths: &[] },
ToolSpec { id: "vscode", name: "VS Code", category: "editor", cmd: "code", version_flag: "--version", fallback_paths: &[] },
ToolSpec { id: "windsurf", name: "Windsurf", category: "editor", cmd: "windsurf", version_flag: "--version", fallback_paths: &[] },
ToolSpec { id: "vim", name: "Vim", category: "editor", cmd: "vim", version_flag: "--version", fallback_paths: &[] },
ToolSpec { id: "nvim", name: "Neovim", category: "editor", cmd: "nvim", version_flag: "--version", fallback_paths: &[] },
// 语言运行时
ToolSpec { id: "node", name: "Node.js", category: "runtime", cmd: "node", version_flag: "--version", fallback_paths: &[] },
ToolSpec { id: "python", name: "Python", category: "runtime", cmd: "python", version_flag: "--version", fallback_paths: &[] },
ToolSpec { id: "python3", name: "Python 3", category: "runtime", cmd: "python3", version_flag: "--version", fallback_paths: &[] },
ToolSpec { id: "go", name: "Go", category: "runtime", cmd: "go", version_flag: "version", fallback_paths: &["C:\\Program Files\\Go\\bin\\go.exe"] },
ToolSpec { id: "rustc", name: "Rust (rustc)", category: "runtime", cmd: "rustc", version_flag: "--version", fallback_paths: &[] },
ToolSpec { id: "java", name: "Java", category: "runtime", cmd: "java", version_flag: "-version", fallback_paths: &[] },
ToolSpec { id: "ruby", name: "Ruby", category: "runtime", cmd: "ruby", version_flag: "--version", fallback_paths: &[] },
ToolSpec { id: "deno", name: "Deno", category: "runtime", cmd: "deno", version_flag: "--version", fallback_paths: &[] },
ToolSpec { id: "bun", name: "Bun", category: "runtime", cmd: "bun", version_flag: "--version", fallback_paths: &[] },
// 包管理器
ToolSpec { id: "pnpm", name: "pnpm", category: "pkg_manager", cmd: "pnpm", version_flag: "--version", fallback_paths: &[] },
ToolSpec { id: "npm", name: "npm", category: "pkg_manager", cmd: "npm", version_flag: "--version", fallback_paths: &[] },
ToolSpec { id: "yarn", name: "Yarn", category: "pkg_manager", cmd: "yarn", version_flag: "--version", fallback_paths: &[] },
ToolSpec { id: "cargo", name: "Cargo", category: "pkg_manager", cmd: "cargo", version_flag: "--version", fallback_paths: &[] },
ToolSpec { id: "pip", name: "pip", category: "pkg_manager", cmd: "pip", version_flag: "--version", fallback_paths: &[] },
ToolSpec { id: "pip3", name: "pip3", category: "pkg_manager", cmd: "pip3", version_flag: "--version", fallback_paths: &[] },
ToolSpec { id: "composer", name: "Composer", category: "pkg_manager", cmd: "composer", version_flag: "--version", fallback_paths: &[] },
// 版本管理器
ToolSpec { id: "rustup", name: "rustup", category: "version_manager", cmd: "rustup", version_flag: "--version", fallback_paths: &[] },
ToolSpec { id: "fnm", name: "fnm", category: "version_manager", cmd: "fnm", version_flag: "--version", fallback_paths: &[] },
ToolSpec { id: "volta", name: "Volta", category: "version_manager", cmd: "volta", version_flag: "--version", fallback_paths: &[] },
// CLI 工具
ToolSpec { id: "git", name: "Git", category: "cli", cmd: "git", version_flag: "--version", fallback_paths: &["C:\\Program Files\\Git\\bin\\git.exe"] },
ToolSpec { id: "claude", name: "Claude CLI", category: "cli", cmd: "claude", version_flag: "--version", fallback_paths: &[] },
ToolSpec { id: "docker", name: "Docker", category: "cli", cmd: "docker", version_flag: "--version", fallback_paths: &[] },
ToolSpec { id: "gh", name: "GitHub CLI", category: "cli", cmd: "gh", version_flag: "--version", fallback_paths: &[] },
ToolSpec { id: "kubectl", name: "kubectl", category: "cli", cmd: "kubectl", version_flag: "version", fallback_paths: &[] },
ToolSpec { id: "terraform", name: "Terraform", category: "cli", cmd: "terraform", version_flag: "version", fallback_paths: &[] },
ToolSpec { id: "make", name: "Make", category: "cli", cmd: "make", version_flag: "--version", fallback_paths: &[] },
ToolSpec { id: "az", name: "Azure CLI", category: "cli", cmd: "az", version_flag: "--version", fallback_paths: &[] },
ToolSpec { id: "aws", name: "AWS CLI", category: "cli", cmd: "aws", version_flag: "--version", fallback_paths: &[] },
// 数据库客户端
ToolSpec { id: "mysql", name: "MySQL CLI", category: "database", cmd: "mysql", version_flag: "--version", fallback_paths: &[] },
ToolSpec { id: "psql", name: "PostgreSQL CLI",category: "database", cmd: "psql", version_flag: "--version", fallback_paths: &[] },
ToolSpec { id: "redis-cli", name: "Redis CLI", category: "database", cmd: "redis-cli", version_flag: "--version", fallback_paths: &[] },
ToolSpec { id: "sqlite3", name: "SQLite", category: "database", cmd: "sqlite3", version_flag: "--version", fallback_paths: &[] },
ToolSpec { id: "mongosh", name: "MongoDB Shell", category: "database", cmd: "mongosh", version_flag: "--version", fallback_paths: &[] },
]
}
// ── 辅助函数 ──────────────────────────────────────────────────────────────────
/// 在 Windows PATH 里查找可执行文件路径
fn find_in_path(cmd: &str) -> Option<String> {
#[cfg(target_os = "windows")]
let output = crate::silent_cmd("where").arg(cmd).output().ok()?;
#[cfg(not(target_os = "windows"))]
let output = crate::silent_cmd("which").arg(cmd).output().ok()?;
if output.status.success() {
let path = String::from_utf8_lossy(&output.stdout)
.lines()
.next()
.map(|s| s.trim().to_string())?;
if path.is_empty() { None } else { Some(path) }
} else {
None
}
}
/// 尝试获取 Windows 工具版本号
fn get_version(cmd: &str, flag: &str) -> Option<String> {
let output = crate::silent_cmd(cmd).arg(flag).output().ok()?;
let raw = if output.stdout.is_empty() {
String::from_utf8_lossy(&output.stderr).to_string()
} else {
String::from_utf8_lossy(&output.stdout).to_string()
};
let line = raw.lines().next()?.trim().to_string();
if line.is_empty() { None } else { Some(line.chars().take(80).collect()) }
}
/// 在 WSL 中查找可执行文件路径(使用登录 shell确保 nvm 等 PATH 已加载)
fn find_in_wsl(cmd: &str) -> Option<String> {
let output = crate::silent_cmd("wsl")
.args(["bash", "-l", "-c", &format!("which {cmd}")])
.output()
.ok()?;
if output.status.success() {
let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
if path.is_empty() { None } else { Some(path) }
} else {
None
}
}
/// 尝试获取 WSL 工具版本号(使用登录 shell
fn get_wsl_version(cmd: &str, flag: &str) -> Option<String> {
let output = crate::silent_cmd("wsl")
.args(["bash", "-l", "-c", &format!("{cmd} {flag}")])
.output()
.ok()?;
let raw = if output.stdout.is_empty() {
String::from_utf8_lossy(&output.stderr).to_string()
} else {
String::from_utf8_lossy(&output.stdout).to_string()
};
let line = raw.lines().next()?.trim().to_string();
if line.is_empty() { None } else { Some(line.chars().take(80).collect()) }
}
// ── Tauri 命令 ────────────────────────────────────────────────────────────────
/// 扫描当前系统中已安装的开发工具
#[tauri::command]
pub fn scan_dev_tools() -> Vec<ScannedTool> {
let specs = tool_specs();
let mut result = Vec::with_capacity(specs.len());
for spec in &specs {
let path = find_in_path(spec.cmd)
.or_else(|| {
// fallback: 检查固定路径
spec.fallback_paths.iter().find_map(|p| {
if std::path::Path::new(p).exists() {
Some(p.to_string())
} else {
None
}
})
});
let found = path.is_some();
let version = if found {
get_version(spec.cmd, spec.version_flag)
} else {
None
};
result.push(ScannedTool {
id: spec.id.to_string(),
name: spec.name.to_string(),
category: spec.category.to_string(),
cmd: spec.cmd.to_string(),
path,
version,
found,
});
}
result
}
/// 获取已保存的工具列表(按 env + space_id 过滤)
#[tauri::command]
pub fn get_dev_tools(env: String, space_id: Option<String>) -> Result<Vec<DevToolRecord>, String> {
let conn = pool().get().map_err(|e| e.to_string())?;
let rows = match &space_id {
Some(sid) => {
let mut stmt = conn
.prepare("SELECT id, name, category, cmd, path, version, note, env, space_id FROM dev_tools WHERE env = ?1 AND space_id = ?2 ORDER BY category, name")
.map_err(|e| e.to_string())?;
let x = stmt
.query_map(params![env, sid], |row| {
Ok(DevToolRecord {
id: row.get(0)?, name: row.get(1)?, category: row.get(2)?,
cmd: row.get(3)?, path: row.get(4)?, version: row.get(5)?,
note: row.get(6)?, env: row.get(7)?, space_id: row.get(8)?,
})
})
.map_err(|e| e.to_string())?
.collect::<Result<Vec<_>, _>>()
.map_err(|e| e.to_string())?; x
}
None => {
let mut stmt = conn
.prepare("SELECT id, name, category, cmd, path, version, note, env, space_id FROM dev_tools WHERE env = ?1 AND space_id IS NULL ORDER BY category, name")
.map_err(|e| e.to_string())?;
let x = stmt
.query_map(params![env], |row| {
Ok(DevToolRecord {
id: row.get(0)?, name: row.get(1)?, category: row.get(2)?,
cmd: row.get(3)?, path: row.get(4)?, version: row.get(5)?,
note: row.get(6)?, env: row.get(7)?, space_id: row.get(8)?,
})
})
.map_err(|e| e.to_string())?
.collect::<Result<Vec<_>, _>>()
.map_err(|e| e.to_string())?; x
}
};
Ok(rows)
}
/// 批量保存选中的工具(已存在则更新)
/// 当 space_id 存在时DB id 前缀为 "{space_id}:{original_id}",保证跨空间不冲突
#[tauri::command]
pub fn save_dev_tools(tools: Vec<SaveDevToolInput>) -> Result<(), String> {
let conn = pool().get().map_err(|e| e.to_string())?;
for t in tools {
let env = t.env.clone().unwrap_or_else(|| "windows".to_string());
let db_id = match &t.space_id {
Some(sid) => format!("{}:{}", sid, t.id),
None => t.id.clone(),
};
conn.execute(
"INSERT INTO dev_tools (id, name, category, cmd, path, version, note, env, space_id)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
ON CONFLICT(id) DO UPDATE SET
name = excluded.name,
category = excluded.category,
cmd = excluded.cmd,
path = excluded.path,
version = excluded.version,
note = excluded.note,
env = excluded.env,
space_id = excluded.space_id",
params![db_id, t.name, t.category, t.cmd, t.path, t.version, t.note, env, t.space_id],
)
.map_err(|e| e.to_string())?;
}
Ok(())
}
/// 删除已保存的工具
#[tauri::command]
pub fn delete_dev_tool(id: String) -> Result<(), String> {
let conn = pool().get().map_err(|e| e.to_string())?;
conn.execute("DELETE FROM dev_tools WHERE id = ?1", params![id])
.map_err(|e| e.to_string())?;
Ok(())
}
/// 手动添加或更新单个工具
#[tauri::command]
pub fn upsert_dev_tool(tool: SaveDevToolInput) -> Result<(), String> {
save_dev_tools(vec![tool])
}
/// 扫描 WSL 环境中已安装的开发工具
#[tauri::command]
pub fn scan_wsl_dev_tools() -> Vec<ScannedTool> {
let specs = tool_specs();
let mut result = Vec::with_capacity(specs.len());
for spec in &specs {
let path = find_in_wsl(spec.cmd);
let found = path.is_some();
let version = if found {
get_wsl_version(spec.cmd, spec.version_flag)
} else {
None
};
result.push(ScannedTool {
id: format!("{}-wsl", spec.id),
name: spec.name.to_string(),
category: spec.category.to_string(),
cmd: spec.cmd.to_string(),
path,
version,
found,
});
}
result
}