- 新增发布到GitHub功能(scan/init/commit/push/register全流程) - 新增WSL发行版检测与UNC路径自动转换,路径存储改为Windows可访问格式 - 修复libgit2访问WSL UNC路径的owner验证问题(启动时全局禁用) - ProjectCard新增本地项目/本地仓库/远程仓库三灯状态指示 - ProjectModal重构为基本信息+部署信息双标签页,固定高度稳定切换 - 添加项目支持顶部快速选择文件夹并自动填充ID和名称 - 设置页WSL区块改为下拉检测选择发行版 - Dialog组件支持fixedHeight固定高度模式
396 lines
15 KiB
Rust
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(())
|
|
}
|