dev-manager-tauri/src-tauri/src/commands/project_scan.rs
lanrtop 0baa304fbe feat: WSL路径支持、项目发布到GitHub、三灯状态指示
- 新增发布到GitHub功能(scan/init/commit/push/register全流程)
- 新增WSL发行版检测与UNC路径自动转换,路径存储改为Windows可访问格式
- 修复libgit2访问WSL UNC路径的owner验证问题(启动时全局禁用)
- ProjectCard新增本地项目/本地仓库/远程仓库三灯状态指示
- ProjectModal重构为基本信息+部署信息双标签页,固定高度稳定切换
- 添加项目支持顶部快速选择文件夹并自动填充ID和名称
- 设置页WSL区块改为下拉检测选择发行版
- Dialog组件支持fixedHeight固定高度模式
2026-04-01 01:36:21 +09:00

396 lines
15 KiB
Rust

use crate::db::pool;
use rusqlite::params;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
// ── 数据结构 ──────────────────────────────────────────────────────────────────
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct EnvToolItem {
pub id: String,
pub project_id: String,
pub layer: u8,
pub name: String,
pub source: Option<String>,
pub version: Option<String>,
pub note: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct SaveEnvToolInput {
pub id: String,
pub layer: u8,
pub name: String,
pub source: Option<String>,
pub version: Option<String>,
pub note: Option<String>,
}
// ── 内部扫描结构 ──────────────────────────────────────────────────────────────
struct RawItem {
source: Option<String>,
version: Option<String>,
note: Option<String>,
}
// Key = (layer, name),用于去重
type DetectedMap = BTreeMap<(u8, String), RawItem>;
/// 插入(已存在则不覆盖)
fn insert(
map: &mut DetectedMap,
layer: u8,
name: &str,
source: Option<&str>,
version: Option<String>,
note: Option<&str>,
) {
map.entry((layer, name.to_string())).or_insert(RawItem {
source: source.map(|s| s.to_string()),
version,
note: note.map(|s| s.to_string()),
});
}
/// 插入或补充(已存在时填充空字段)
fn upsert(
map: &mut DetectedMap,
layer: u8,
name: &str,
source: Option<&str>,
version: Option<String>,
note: Option<&str>,
) {
let entry = map
.entry((layer, name.to_string()))
.or_insert(RawItem { source: None, version: None, note: None });
if source.is_some() && entry.source.is_none() {
entry.source = source.map(|s| s.to_string());
}
if version.is_some() && entry.version.is_none() {
entry.version = version;
}
if note.is_some() && entry.note.is_none() {
entry.note = note.map(|s| s.to_string());
}
}
// ── 文件读取工具 ──────────────────────────────────────────────────────────────
fn read_first_line(path: &Path) -> Option<String> {
std::fs::read_to_string(path)
.ok()
.and_then(|s| s.lines().next().map(|l| l.trim().to_string()))
.filter(|s| !s.is_empty())
}
fn read_all(path: &Path) -> Option<String> {
std::fs::read_to_string(path).ok()
}
// ── 目录扫描核心 ──────────────────────────────────────────────────────────────
fn scan_dir_files(dir: &Path) -> DetectedMap {
let mut map: DetectedMap = BTreeMap::new();
// ── Node.js 生态 ──────────────────────────────────────────────────────────
// .nvmrc → nvm(layer0) + Node.js(layer1, source=nvm)
let nvmrc = dir.join(".nvmrc");
if nvmrc.exists() {
let ver = read_first_line(&nvmrc);
insert(&mut map, 0, "nvm", None, None, Some(".nvmrc"));
upsert(&mut map, 1, "Node.js", Some("nvm"), ver, None);
}
// .node-version → fnm(layer0) + Node.js(layer1, source=fnm)
let node_version_file = dir.join(".node-version");
if node_version_file.exists() {
let ver = read_first_line(&node_version_file);
insert(&mut map, 0, "fnm", None, None, Some(".node-version"));
upsert(&mut map, 1, "Node.js", Some("fnm"), ver, None);
}
// package.json → Node.js(layer1),检查 volta 字段
let pkg_json = dir.join("package.json");
if pkg_json.exists() {
upsert(&mut map, 1, "Node.js", None, None, None);
if let Some(content) = read_all(&pkg_json) {
if content.contains("\"volta\"") {
insert(&mut map, 0, "Volta", None, None, Some("package.json#volta"));
let key = (1u8, "Node.js".to_string());
if let Some(item) = map.get_mut(&key) {
if item.source.is_none() {
item.source = Some("volta".to_string());
}
}
}
}
}
// 包管理器 lock 文件 → layer2
if dir.join("pnpm-lock.yaml").exists() {
insert(&mut map, 2, "pnpm", Some("direct"), None, None);
}
if dir.join("package-lock.json").exists() {
insert(&mut map, 2, "npm", Some("direct"), None, None);
}
if dir.join("yarn.lock").exists() {
insert(&mut map, 2, "Yarn", Some("direct"), None, None);
}
if dir.join("bun.lockb").exists() || dir.join("bun.lock").exists() {
insert(&mut map, 2, "Bun", Some("direct"), None, None);
}
// tsconfig.json → TypeScript(layer3, local)
if dir.join("tsconfig.json").exists() {
insert(&mut map, 3, "TypeScript", Some("local"), None, Some("tsconfig.json"));
}
// ── Rust 生态 ─────────────────────────────────────────────────────────────
let toolchain_toml = dir.join("rust-toolchain.toml");
let toolchain_plain = dir.join("rust-toolchain");
if toolchain_toml.exists() || toolchain_plain.exists() {
let file = if toolchain_toml.exists() { toolchain_toml } else { toolchain_plain };
let channel = read_all(&file).and_then(|content| {
content
.lines()
.find(|l| l.trim_start().starts_with("channel"))
.and_then(|l| l.splitn(2, '=').nth(1).map(|v| v.trim().trim_matches('"').to_string()))
});
insert(&mut map, 0, "rustup", None, channel.clone(), Some("rust-toolchain.toml"));
upsert(&mut map, 1, "Rust", Some("rustup"), channel, None);
upsert(&mut map, 2, "Cargo", Some("direct"), None, None);
} else if dir.join("Cargo.toml").exists() {
upsert(&mut map, 1, "Rust", Some("rustup"), None, None);
insert(&mut map, 2, "Cargo", Some("direct"), None, None);
}
// ── Go 生态 ───────────────────────────────────────────────────────────────
let go_mod = dir.join("go.mod");
if go_mod.exists() {
let ver = read_all(&go_mod).and_then(|content| {
content
.lines()
.find(|l| l.trim_start().starts_with("go "))
.map(|l| l.trim().to_string())
});
insert(&mut map, 1, "Go", Some("direct"), ver, None);
}
// ── Python 生态 ───────────────────────────────────────────────────────────
let python_version_file = dir.join(".python-version");
if python_version_file.exists() {
let ver = read_first_line(&python_version_file);
insert(&mut map, 0, "pyenv", None, None, Some(".python-version"));
upsert(&mut map, 1, "Python", Some("pyenv"), ver, None);
}
if dir.join("poetry.lock").exists() {
upsert(&mut map, 1, "Python", None, None, None);
insert(&mut map, 2, "poetry", Some("direct"), None, None);
}
if dir.join("Pipfile").exists() {
upsert(&mut map, 1, "Python", None, None, None);
insert(&mut map, 2, "pipenv", Some("direct"), None, None);
}
if dir.join("requirements.txt").exists() || dir.join("setup.py").exists() {
upsert(&mut map, 1, "Python", Some("direct"), None, None);
}
if dir.join("pyproject.toml").exists() {
upsert(&mut map, 1, "Python", None, None, None);
if let Some(content) = read_all(&dir.join("pyproject.toml")) {
if content.contains("[tool.poetry]") {
upsert(&mut map, 2, "poetry", Some("direct"), None, None);
}
}
}
if dir.join(".venv").exists() || dir.join("venv").exists() {
upsert(&mut map, 1, "Python", None, None, Some("venv"));
}
// ── Java 生态 ─────────────────────────────────────────────────────────────
if dir.join("pom.xml").exists() {
insert(&mut map, 1, "Java", None, None, Some("pom.xml"));
insert(&mut map, 2, "Maven", Some("direct"), None, None);
}
if dir.join("build.gradle").exists() || dir.join("build.gradle.kts").exists() {
upsert(&mut map, 1, "Java", None, None, Some("build.gradle"));
insert(&mut map, 2, "Gradle", Some("direct"), None, None);
}
// ── Ruby 生态 ─────────────────────────────────────────────────────────────
let ruby_version_file = dir.join(".ruby-version");
if ruby_version_file.exists() {
let ver = read_first_line(&ruby_version_file);
insert(&mut map, 0, "rbenv", None, ver.clone(), Some(".ruby-version"));
upsert(&mut map, 1, "Ruby", Some("rbenv"), ver, None);
} else if dir.join("Gemfile").exists() {
insert(&mut map, 1, "Ruby", Some("direct"), None, None);
}
if dir.join("Gemfile").exists() {
insert(&mut map, 2, "Bundler", Some("direct"), None, None);
}
// ── 基础设施工具 ──────────────────────────────────────────────────────────
if dir.join(".gitignore").exists() || dir.join(".git").exists() {
insert(&mut map, 2, "Git", Some("direct"), None, None);
}
if dir.join("Dockerfile").exists()
|| dir.join("docker-compose.yml").exists()
|| dir.join("docker-compose.yaml").exists()
|| dir.join("compose.yml").exists()
{
insert(&mut map, 2, "Docker", Some("direct"), None, None);
}
// Claude CLI
if dir.join(".claude").exists() {
insert(&mut map, 2, "Claude CLI", Some("direct"), None, Some(".claude/"));
}
map
}
// ── WSL 路径转换 ──────────────────────────────────────────────────────────────
fn get_wsl_distro() -> String {
pool()
.get()
.ok()
.and_then(|c| {
c.query_row(
"SELECT value FROM settings WHERE key = 'wsl_distro'",
[],
|row| row.get::<_, String>(0),
)
.ok()
})
.unwrap_or_else(|| "Ubuntu".to_string())
}
fn wsl_to_unc(wsl_path: &str, distro: &str) -> PathBuf {
// /home/user/project → \\wsl$\Ubuntu\home\user\project
let stripped = wsl_path.trim_start_matches('/');
PathBuf::from(format!(
"\\\\wsl$\\{}\\{}",
distro,
stripped.replace('/', "\\")
))
}
// ── Tauri 命令 ────────────────────────────────────────────────────────────────
/// 扫描项目目录的环境工具(基于配置文件指纹)
#[tauri::command]
pub fn scan_project_env(project_id: String, env: String) -> Result<Vec<EnvToolItem>, String> {
let conn = pool().get().map_err(|e| e.to_string())?;
let (wsl_path, win_path): (Option<String>, Option<String>) = conn
.query_row(
"SELECT wsl_path, win_path FROM project_workspaces WHERE id = ?1",
params![project_id],
|row| Ok((row.get(0)?, row.get(1)?)),
)
.map_err(|_| format!("Project not found: {project_id}"))?;
let dir: PathBuf = match env.as_str() {
"wsl" => {
let path = wsl_path.ok_or("该项目没有配置 WSL 路径")?;
let distro = get_wsl_distro();
wsl_to_unc(&path, &distro)
}
_ => PathBuf::from(win_path.ok_or("该项目没有配置 Windows 路径")?),
};
if !dir.exists() {
return Err(format!("目录不存在: {}", dir.display()));
}
let detected = scan_dir_files(&dir);
let items: Vec<EnvToolItem> = detected
.into_iter()
.enumerate()
.map(|(i, ((layer, name), raw))| EnvToolItem {
id: format!("{}-{}-{}-{}", project_id, env, layer, i),
project_id: project_id.clone(),
layer,
name,
source: raw.source,
version: raw.version,
note: raw.note,
})
.collect();
Ok(items)
}
/// 获取已保存的项目环境工具
#[tauri::command]
pub fn get_project_env_tools(project_id: String) -> Result<Vec<EnvToolItem>, String> {
let conn = pool().get().map_err(|e| e.to_string())?;
let mut stmt = conn
.prepare(
"SELECT id, project_id, layer, name, source, version, note
FROM project_env_tools
WHERE project_id = ?1
ORDER BY layer, name",
)
.map_err(|e| e.to_string())?;
let rows = stmt
.query_map(params![project_id], |row| {
Ok(EnvToolItem {
id: row.get(0)?,
project_id: row.get(1)?,
layer: row.get(2)?,
name: row.get(3)?,
source: row.get(4)?,
version: row.get(5)?,
note: row.get(6)?,
})
})
.map_err(|e| e.to_string())?
.collect::<Result<Vec<_>, _>>()
.map_err(|e| e.to_string())?;
Ok(rows)
}
/// 保存项目环境工具(先清空再写入)
#[tauri::command]
pub fn save_project_env_tools(
project_id: String,
items: Vec<SaveEnvToolInput>,
) -> Result<(), String> {
let conn = pool().get().map_err(|e| e.to_string())?;
conn.execute(
"DELETE FROM project_env_tools WHERE project_id = ?1",
params![project_id],
)
.map_err(|e| e.to_string())?;
for item in items {
conn.execute(
"INSERT INTO project_env_tools (id, project_id, layer, name, source, version, note, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, datetime('now'))",
params![
item.id, project_id, item.layer, item.name,
item.source, item.version, item.note,
],
)
.map_err(|e| e.to_string())?;
}
Ok(())
}