1457 lines
56 KiB
Rust
1457 lines
56 KiB
Rust
//! 工程系统命令(V34,project_modules 表,项目多工程,每个工程独立代码仓库)
|
|
//!
|
|
//! 对标 [`services`](super::services) 的 IPC 模式:CRUD 返回完整记录,前端拿回 id 直接用。
|
|
//!
|
|
//! 关键设计(对标设计 §五工程系统 + V34 迁移注释):
|
|
//! - **工程元数据 CRUD**:路径/Git 地址/技术栈存表,create/update 返回完整记录。
|
|
//! - **Git 状态实时派生**:`get_module_git_status` 在工程目录跑 git 命令(分支/改动/最近提交),
|
|
//! 10s 超时,无 .git 返回空状态。**前端 IPC 用 std::process::Command**(非 df-execute,
|
|
//! 因为这是用户读操作,不是 AI 工具调用,不走审批/沙箱)。
|
|
//! - **文件浏览**(Batch 10):`get_module_file_tree` 列目录并合并 git status --porcelain
|
|
//! 到每个条目的 git_status 字段;`read_module_file` 读单文件(1MB 上限,二进制检测)。
|
|
//! 同样走 std::process::Command 读 git,目录扫描用 std::fs(read_dir)。
|
|
//! - **路径校验**:trim + 非空校验在前置 IPC 层(给清晰错误)。
|
|
|
|
use std::collections::HashMap;
|
|
use std::path::{Path, PathBuf};
|
|
use std::process::Command;
|
|
use std::sync::{LazyLock, Mutex};
|
|
use std::time::{Duration, Instant};
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
use tauri::State;
|
|
|
|
use df_storage::models::{ModuleDependencyRecord, ProjectModuleRecord};
|
|
use df_types::types::new_id;
|
|
// 工程扫描技术栈探测(scan_project_modules 自动填 stack 字段)
|
|
use df_project::scan::detect_stack;
|
|
|
|
use crate::state::AppState;
|
|
|
|
use super::{err_str, now_millis};
|
|
|
|
// ============================================================
|
|
// 入参 / 出参结构
|
|
// ============================================================
|
|
|
|
/// 新增工程入参(对标设计 §五 add_project_module)。
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct AddProjectModuleInput {
|
|
pub project_id: String,
|
|
pub name: String,
|
|
/// 工程目录绝对路径(本机)
|
|
pub path: String,
|
|
/// 远程仓库地址,可选(单仓库本地工程可为空)
|
|
#[serde(default)]
|
|
pub git_url: Option<String>,
|
|
/// 技术栈 JSON 字符串(前端检测后传入),可选
|
|
#[serde(default)]
|
|
pub stack: Option<String>,
|
|
/// 工程职责描述(如"前端 web 工程"),可选。V40 加。
|
|
#[serde(default)]
|
|
pub description: Option<String>,
|
|
/// 工程状态 active/archived,可选(默认 active)。V40 加。
|
|
#[serde(default)]
|
|
pub status: Option<String>,
|
|
}
|
|
|
|
/// 更新工程入参(部分更新语义,仅传入字段被覆盖;对标设计 §五 update_project_module)。
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct UpdateProjectModuleInput {
|
|
pub id: String,
|
|
#[serde(default)]
|
|
pub name: Option<String>,
|
|
#[serde(default)]
|
|
pub path: Option<String>,
|
|
#[serde(default)]
|
|
pub git_url: Option<String>,
|
|
#[serde(default)]
|
|
pub stack: Option<String>,
|
|
/// 工程职责描述,V40 加。空串归一为 None(trim_opt)。
|
|
#[serde(default)]
|
|
pub description: Option<String>,
|
|
/// 工程状态 active/archived,V40 加。
|
|
#[serde(default)]
|
|
pub status: Option<String>,
|
|
}
|
|
|
|
// ============================================================
|
|
// 辅助:Optional 字段 trim + 空串归一为 None
|
|
// ============================================================
|
|
|
|
fn trim_opt(s: Option<String>) -> Option<String> {
|
|
s.map(|v| v.trim().to_string()).filter(|v| !v.is_empty())
|
|
}
|
|
|
|
/// 路径穿越防御:分段匹配 `..` 段(split '/' 或 '\' 后 any 段 == "..")。
|
|
///
|
|
/// 与裸 `contains("..")` 的区别:不误伤合法文件名/目录名(如 `a..b.rs`),
|
|
/// 且能识别路径中真正独立的 `..` 段(如 `src/../lib` 的 `..`)。sub_path / file_path
|
|
/// 约定 POSIX 风格('/' 分隔),反斜杠一并分段以兼容 Windows 传入路径。
|
|
fn has_path_traversal(p: &str) -> bool {
|
|
p.split(|c| c == '/' || c == '\\').any(|seg| seg == "..")
|
|
}
|
|
|
|
/// 工程状态归一:仅接受 active/archived(大小写不敏感),空/未传 → None(应用层视 None 为 active),
|
|
/// 非法值退化 None(应用层归一 active)。返回 None 表示"使用默认 active 语义"。
|
|
fn normalize_status(s: Option<String>) -> Option<String> {
|
|
match trim_opt(s) {
|
|
Some(v) => {
|
|
let lower = v.to_lowercase();
|
|
if lower == "active" || lower == "archived" {
|
|
Some(lower)
|
|
} else {
|
|
// 非法值 → 退化为 None(应用层/前端视 None 为 active)
|
|
None
|
|
}
|
|
}
|
|
None => None,
|
|
}
|
|
}
|
|
|
|
// ============================================================
|
|
// IPC 命令 — 工程 CRUD
|
|
// ============================================================
|
|
|
|
/// 新增工程(对标设计 §五 add_project_module)。返回完整记录。
|
|
#[tauri::command]
|
|
pub async fn add_project_module(
|
|
state: State<'_, AppState>,
|
|
input: AddProjectModuleInput,
|
|
) -> Result<ProjectModuleRecord, String> {
|
|
let project_id = input.project_id.trim().to_string();
|
|
let name = input.name.trim().to_string();
|
|
let path = input.path.trim().to_string();
|
|
if project_id.is_empty() {
|
|
return Err("project_id 不能为空".to_string());
|
|
}
|
|
if name.is_empty() {
|
|
return Err("name 不能为空".to_string());
|
|
}
|
|
if path.is_empty() {
|
|
return Err("path 不能为空".to_string());
|
|
}
|
|
// sort_order 取当前项目下工程数(末尾追加,新工程排末位)
|
|
let existing = state
|
|
.project_modules
|
|
.list_by_project(&project_id)
|
|
.await
|
|
.map_err(err_str)?;
|
|
let sort_order = existing.len() as i32;
|
|
|
|
let record = ProjectModuleRecord {
|
|
id: new_id(),
|
|
project_id,
|
|
name,
|
|
path,
|
|
git_url: trim_opt(input.git_url),
|
|
stack: trim_opt(input.stack),
|
|
auto_detected: false,
|
|
sort_order,
|
|
// created_at/updated_at 由 Repo 内部覆盖,此处占位
|
|
created_at: now_millis(),
|
|
updated_at: now_millis(),
|
|
description: trim_opt(input.description),
|
|
// status 默认 active(未传/空串归一),仅接受 active/archived 两值,其余退化 active。
|
|
status: normalize_status(input.status),
|
|
};
|
|
let record_id = record.id.clone();
|
|
let ok = state
|
|
.project_modules
|
|
.insert(record)
|
|
.await
|
|
.map_err(err_str)?;
|
|
if !ok {
|
|
return Err("插入工程记录失败".to_string());
|
|
}
|
|
state
|
|
.project_modules
|
|
.get_by_id(&record_id)
|
|
.await
|
|
.map_err(err_str)?
|
|
.ok_or_else(|| "插入后回读失败".to_string())
|
|
}
|
|
|
|
/// 更新工程(部分更新语义:仅传入字段被覆盖,其余保留)。返回完整记录。
|
|
#[tauri::command]
|
|
pub async fn update_project_module(
|
|
state: State<'_, AppState>,
|
|
input: UpdateProjectModuleInput,
|
|
) -> Result<ProjectModuleRecord, String> {
|
|
let id = input.id.trim().to_string();
|
|
if id.is_empty() {
|
|
return Err("id 不能为空".to_string());
|
|
}
|
|
// 至少一个可更新字段
|
|
if input.name.is_none()
|
|
&& input.path.is_none()
|
|
&& input.git_url.is_none()
|
|
&& input.stack.is_none()
|
|
&& input.description.is_none()
|
|
&& input.status.is_none()
|
|
{
|
|
return Err("至少提供一个待更新字段 (name/path/git_url/stack/description/status)".to_string());
|
|
}
|
|
|
|
// 先校验存在(404 友好错误),再整体更新(保留不可变字段)
|
|
let mut existing = state
|
|
.project_modules
|
|
.get_by_id(&id)
|
|
.await
|
|
.map_err(err_str)?
|
|
.ok_or_else(|| format!("工程 {id} 不存在"))?;
|
|
|
|
if let Some(name) = input.name.map(|s| s.trim().to_string()) {
|
|
if name.is_empty() {
|
|
return Err("name 不能为空".to_string());
|
|
}
|
|
existing.name = name;
|
|
}
|
|
if let Some(path) = input.path.map(|s| s.trim().to_string()) {
|
|
if path.is_empty() {
|
|
return Err("path 不能为空".to_string());
|
|
}
|
|
// path 变更 → 清旧路径的 git status 缓存(避免后续同路径复用脏状态)
|
|
if existing.path != path {
|
|
invalidate_git_status_cache(&existing.path);
|
|
}
|
|
existing.path = path;
|
|
}
|
|
existing.git_url = trim_opt(input.git_url);
|
|
existing.stack = trim_opt(input.stack);
|
|
// description 仅在传入时覆盖(部分更新语义);None 表示"未提供字段"。
|
|
if let Some(d) = input.description {
|
|
existing.description = trim_opt(Some(d));
|
|
}
|
|
// status 仅在传入时覆盖,非 active/archived 退化 active。
|
|
if let Some(s) = input.status {
|
|
existing.status = normalize_status(Some(s));
|
|
}
|
|
|
|
let hit = state
|
|
.project_modules
|
|
.update_full(&existing)
|
|
.await
|
|
.map_err(err_str)?;
|
|
if !hit {
|
|
return Err(format!("工程 {id} 不存在(更新未命中)"));
|
|
}
|
|
state
|
|
.project_modules
|
|
.get_by_id(&id)
|
|
.await
|
|
.map_err(err_str)?
|
|
.ok_or_else(|| "更新后回读失败".to_string())
|
|
}
|
|
|
|
/// 删除工程(按 id 硬删,工程不需要软删审计追溯)。
|
|
#[tauri::command]
|
|
pub async fn remove_project_module(state: State<'_, AppState>, id: String) -> Result<(), String> {
|
|
let id = id.trim().to_string();
|
|
if id.is_empty() {
|
|
return Err("id 不能为空".to_string());
|
|
}
|
|
// 删除前取 path,删除后清 git status 缓存(避免同路径复用脏状态)
|
|
if let Ok(Some(module)) = state.project_modules.get_by_id(&id).await {
|
|
invalidate_git_status_cache(&module.path);
|
|
}
|
|
state.project_modules.delete(&id).await.map_err(err_str)?;
|
|
Ok(())
|
|
}
|
|
|
|
/// 查询项目全部工程(按 sort_order ASC 返回,前端工程列表稳定顺序)。
|
|
#[tauri::command]
|
|
pub async fn list_project_modules(
|
|
state: State<'_, AppState>,
|
|
project_id: String,
|
|
) -> Result<Vec<ProjectModuleRecord>, String> {
|
|
let project_id = project_id.trim().to_string();
|
|
if project_id.is_empty() {
|
|
return Err("project_id 不能为空".to_string());
|
|
}
|
|
let modules = state
|
|
.project_modules
|
|
.list_by_project(&project_id)
|
|
.await
|
|
.map_err(err_str)?;
|
|
// 老项目兼容:V34 迁移前创建的项目无工程记录(project_modules 表为空)。
|
|
// 自动从 projects.path 补建一个工程,使文件浏览器直接可用(单仓库退化场景)。
|
|
if modules.is_empty() {
|
|
if let Ok(Some(project)) = state.projects.get_by_id(&project_id).await {
|
|
if let Some(ref path) = project.path {
|
|
if !path.trim().is_empty() {
|
|
let now_str = df_types::now_millis().to_string();
|
|
let module = ProjectModuleRecord {
|
|
id: df_types::types::new_id(),
|
|
project_id: project_id.clone(),
|
|
name: project.name.clone(),
|
|
path: path.clone(),
|
|
git_url: None,
|
|
stack: project.stack.clone(),
|
|
auto_detected: true,
|
|
sort_order: 0,
|
|
created_at: now_str.clone(),
|
|
updated_at: now_str,
|
|
description: None,
|
|
status: Some("active".to_string()),
|
|
};
|
|
if let Err(e) = state.project_modules.insert(module.clone()).await {
|
|
tracing::warn!("老项目自动补建工程失败(非阻断): {}", e);
|
|
}
|
|
// 补建后重查返回(确保前端拿到刚建的工程)
|
|
return state
|
|
.project_modules
|
|
.list_by_project(&project_id)
|
|
.await
|
|
.map_err(err_str);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Ok(modules)
|
|
}
|
|
|
|
// ============================================================
|
|
// 仓库级 git status 缓存(P1-f:cmd 闪烁 N² 修复)
|
|
// ============================================================
|
|
|
|
/// git status 缓存 TTL(5 秒)。
|
|
///
|
|
/// 背景:`get_module_file_tree` 展开新目录 + `get_module_git_status` 切换变更视图都会在仓库根
|
|
/// 跑整仓 `git status --porcelain`;前端 `loadedChildren` 只对「同目录展开」去重,跨目录展开
|
|
/// N 次 = N 次整仓扫描(cmd 闪烁 N²)。git status 天然幂等(输出反映当前工作区),TTL 秒级失效
|
|
/// 可接受(用户改文件后刷新触发重扫兜底),不做实时 watcher(过度设计)。
|
|
const GIT_STATUS_CACHE_TTL: Duration = Duration::from_secs(5);
|
|
|
|
/// 仓库根路径 → (采集时间戳, git status --porcelain 输出 map)。
|
|
/// 键 = module.path(仓库根);值 = (Instant, {posix_rel: status})。
|
|
/// 进程级共享(spawn_blocking 内 std Mutex 访问),跨 IPC 请求复用。
|
|
static GIT_STATUS_CACHE: LazyLock<Mutex<HashMap<String, (Instant, HashMap<String, String>)>>> =
|
|
LazyLock::new(|| Mutex::new(HashMap::new()));
|
|
|
|
/// 判断缓存条目是否新鲜(TTL 内)。抽出纯函数便于单测。
|
|
fn is_git_status_cache_fresh(collected_at: Instant, now: Instant) -> bool {
|
|
now.duration_since(collected_at) < GIT_STATUS_CACHE_TTL
|
|
}
|
|
|
|
/// 取仓库 git status map:TTL 内命中直接复用缓存,未命中/过期才跑 git status 并更新缓存。
|
|
/// 非 git 仓库 → 空 map 且不入缓存(.git 判断廉价,且避免「git init 后 TTL 内误返空」)。
|
|
fn git_status_map_cached(dir: &str) -> HashMap<String, String> {
|
|
// 非 git 仓库快速返回(不缓存:git init 后 TTL 内重扫才拿得到真实状态)
|
|
if !Path::new(dir).join(".git").exists() {
|
|
return HashMap::new();
|
|
}
|
|
let now = Instant::now();
|
|
// 命中 TTL 内缓存 → 直接复用(锁内只查表,不跑 git,快速返回)
|
|
{
|
|
let cache = GIT_STATUS_CACHE.lock().unwrap_or_else(|e| e.into_inner());
|
|
if let Some((ts, map)) = cache.get(dir) {
|
|
if is_git_status_cache_fresh(*ts, now) {
|
|
return map.clone();
|
|
}
|
|
}
|
|
}
|
|
// 未命中/过期 → 跑 git(锁外执行,避免持锁阻塞其他目录的缓存命中;完成后更新缓存)
|
|
let map = collect_git_status_map(dir);
|
|
let mut cache = GIT_STATUS_CACHE.lock().unwrap_or_else(|e| e.into_inner());
|
|
cache.insert(dir.to_string(), (now, map.clone()));
|
|
map
|
|
}
|
|
|
|
/// 清空指定仓库根的 git status 缓存条目(工程删除 / path 变更时防脏数据复用)。
|
|
fn invalidate_git_status_cache(dir: &str) {
|
|
GIT_STATUS_CACHE
|
|
.lock()
|
|
.unwrap_or_else(|e| e.into_inner())
|
|
.remove(dir);
|
|
}
|
|
|
|
// ============================================================
|
|
// IPC 命令 — Git 状态查询(实时派生,不进表)
|
|
// ============================================================
|
|
|
|
/// Git 改动文件项(状态码 + 相对路径)。
|
|
#[derive(Debug, Serialize)]
|
|
struct GitChangedFile {
|
|
/// `git status --porcelain` 的状态码(xy 两字符,如 " M"、"M "、"??")
|
|
status: String,
|
|
/// 相对仓库根的路径
|
|
path: String,
|
|
}
|
|
|
|
/// 最近提交项(简短哈希 + subject)。
|
|
#[derive(Debug, Serialize)]
|
|
struct GitRecentCommit {
|
|
hash: String,
|
|
subject: String,
|
|
/// Unix 时间戳(秒),供前端格式化显示。
|
|
timestamp: i64,
|
|
/// 作者名(git an)
|
|
author: String,
|
|
}
|
|
|
|
/// Git 状态返回结构(无 .git 时各字段空,前端据此显示"非 Git 仓库")。
|
|
#[derive(Debug, Serialize)]
|
|
struct GitStatus {
|
|
/// 当前分支名(HEAD 处于分离状态时为空)
|
|
branch: String,
|
|
/// 改动文件列表(未提交)
|
|
changed_files: Vec<GitChangedFile>,
|
|
/// 最近 10 条提交
|
|
recent_commits: Vec<GitRecentCommit>,
|
|
/// 当前 HEAD 的全量提交数(`git rev-list --count HEAD`)。
|
|
/// 前端历史 Tab 徽标 / 分支栏计数用它,而非 recent_commits.len()(后者受分页限制)。
|
|
total_commits: i64,
|
|
/// 该目录是否为 Git 仓库(无 .git 时 false,其余字段空)
|
|
is_git_repo: bool,
|
|
}
|
|
|
|
/// 默认空状态(无 .git / git 不可用时返回此)。
|
|
fn empty_status() -> GitStatus {
|
|
GitStatus {
|
|
branch: String::new(),
|
|
changed_files: Vec::new(),
|
|
recent_commits: Vec::new(),
|
|
total_commits: 0,
|
|
is_git_repo: false,
|
|
}
|
|
}
|
|
|
|
/// 查询工程目录的 Git 状态(分支/改动文件/最近提交)。
|
|
///
|
|
/// 实现选择:**前端 IPC 用 `std::process::Command`**(非 df-execute,因为这是用户读操作,
|
|
/// 不是 AI 工具调用,不走审批/沙箱)。10s 超时,无 .git 返回空状态(`is_git_repo: false`)。
|
|
///
|
|
/// 返回 `serde_json::Value` 以便前端灵活取字段(分支/改动/提交三段)。
|
|
#[tauri::command]
|
|
pub async fn get_module_git_status(
|
|
state: State<'_, AppState>,
|
|
module_id: String,
|
|
) -> Result<serde_json::Value, String> {
|
|
let module_id = module_id.trim().to_string();
|
|
if module_id.is_empty() {
|
|
return Err("module_id 不能为空".to_string());
|
|
}
|
|
// 取工程记录拿 path
|
|
let module = state
|
|
.project_modules
|
|
.get_by_id(&module_id)
|
|
.await
|
|
.map_err(err_str)?
|
|
.ok_or_else(|| format!("工程 {module_id} 不存在"))?;
|
|
|
|
let path = std::path::Path::new(&module.path);
|
|
// 无 .git → 返回空状态
|
|
if !path.join(".git").exists() {
|
|
return Ok(serde_json::to_value(empty_status()).map_err(err_str)?);
|
|
}
|
|
|
|
// git 命令在 spawn_blocking 中执行(阻塞 IO 不污染 async runtime),10s 超时
|
|
let dir = module.path.clone();
|
|
let status = tokio::task::spawn_blocking(move || run_git_status(&dir))
|
|
.await
|
|
.map_err(|e| format!("git 状态查询任务失败: {e}"))?;
|
|
|
|
Ok(serde_json::to_value(status).map_err(err_str)?)
|
|
}
|
|
|
|
/// 列出工程本地分支(只读)。返回 { current, branches: [{ name, is_current }] }。
|
|
#[tauri::command]
|
|
pub async fn list_branches(
|
|
state: State<'_, AppState>,
|
|
module_id: String,
|
|
) -> Result<serde_json::Value, String> {
|
|
let module_id = module_id.trim().to_string();
|
|
if module_id.is_empty() {
|
|
return Err("module_id 不能为空".to_string());
|
|
}
|
|
let module = state
|
|
.project_modules
|
|
.get_by_id(&module_id)
|
|
.await
|
|
.map_err(err_str)?
|
|
.ok_or_else(|| format!("工程 {module_id} 不存在"))?;
|
|
let path = std::path::Path::new(&module.path);
|
|
if !path.join(".git").exists() {
|
|
return Ok(serde_json::json!({ "current": "", "branches": [] }));
|
|
}
|
|
let dir = module.path.clone();
|
|
// git 命令在 spawn_blocking 中执行(阻塞 IO 不污染 async runtime),10s 超时
|
|
let result = tokio::task::spawn_blocking(move || -> (String, Vec<serde_json::Value>) {
|
|
// git branch --format="%(HEAD)%00%(refname:short)"
|
|
let out = run_git_cmd(
|
|
std::path::Path::new(&dir),
|
|
&["branch", "--format=%(HEAD)%00%(refname:short)"],
|
|
std::time::Duration::from_secs(10),
|
|
)
|
|
.unwrap_or_default();
|
|
let mut current = String::new();
|
|
let mut branches = Vec::new();
|
|
for line in out.lines() {
|
|
let line = line.trim();
|
|
if line.is_empty() { continue; }
|
|
// 格式: "*\0branch_name" 或 "\0branch_name"
|
|
let (head, name) = line.split_once('\u{0}').unwrap_or(("", line));
|
|
let is_current = head.contains('*');
|
|
let name = name.trim().to_string();
|
|
if name.is_empty() { continue; }
|
|
if is_current { current = name.clone(); }
|
|
branches.push(serde_json::json!({ "name": name, "is_current": is_current }));
|
|
}
|
|
(current, branches)
|
|
})
|
|
.await
|
|
.map_err(|e| format!("分支列表查询任务失败: {e}"))?;
|
|
|
|
Ok(serde_json::json!({
|
|
"current": result.0,
|
|
"branches": result.1,
|
|
}))
|
|
}
|
|
|
|
/// 在指定目录跑 git 命令采集状态(当前分支 / 改动文件 / 最近提交)。
|
|
///
|
|
/// 超时:每个 git 子进程 10s(用户读操作,卡死不应阻塞 UI)。任一命令失败时返回已采集部分。
|
|
fn run_git_status(dir: &str) -> GitStatus {
|
|
let path = std::path::Path::new(dir);
|
|
if !path.join(".git").exists() {
|
|
return empty_status();
|
|
}
|
|
|
|
let timeout = Duration::from_secs(10);
|
|
|
|
// 1) 当前分支:`git rev-parse --abbrev-ref HEAD`(分离 HEAD 时返回 "HEAD")
|
|
let branch = run_git_cmd(path, &["rev-parse", "--abbrev-ref", "HEAD"], timeout)
|
|
.map(|s| s.trim().to_string())
|
|
.filter(|s| !s.is_empty() && s != "HEAD")
|
|
.unwrap_or_default();
|
|
|
|
// 2) 改动文件:`git status --porcelain`(每行 "XY path",XY 为两字符状态码)。
|
|
// 走仓库级缓存(与文件树共用同一份扫描,TTL 内展开/切换不再整仓重扫)。
|
|
let mut changed_files: Vec<GitChangedFile> = git_status_map_cached(dir)
|
|
.into_iter()
|
|
.map(|(path, status)| GitChangedFile { status, path })
|
|
.collect();
|
|
// map 无序,按路径排序保证前端展示顺序稳定(git status 输出本身即按路径有序)。
|
|
changed_files.sort_by(|a, b| a.path.cmp(&b.path));
|
|
|
|
// 3) 最近提交:`git log -50 --format="%h %ct %an %s"`(hash + 时间戳 + 作者 + subject)
|
|
let mut recent_commits = Vec::new();
|
|
if let Some(out) = run_git_cmd(path, &["log", "-50", "--format=%h %ct %an %s"], timeout) {
|
|
for line in out.lines() {
|
|
// format:"<hash> <unix_ts> <author> <subject>"(作者不含空格)
|
|
let line = line.trim();
|
|
if line.is_empty() { continue; }
|
|
let mut parts = line.splitn(4, ' ');
|
|
let hash = parts.next().unwrap_or("").to_string();
|
|
let ts_str = parts.next().unwrap_or("0");
|
|
let timestamp: i64 = ts_str.parse().unwrap_or(0);
|
|
let author = parts.next().unwrap_or("").to_string();
|
|
let subject = parts.next().unwrap_or("").to_string();
|
|
if !hash.is_empty() {
|
|
recent_commits.push(GitRecentCommit {
|
|
hash,
|
|
subject,
|
|
timestamp,
|
|
author,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// 4) 全量提交计数:`git rev-list --count HEAD`(供前端历史 Tab 徽标真实总数,
|
|
// 非 recent_commits.len() 后者上限 50)。命令失败 → 退化为 recent_commits 长度。
|
|
let total_commits = run_git_cmd(path, &["rev-list", "--count", "HEAD"], timeout)
|
|
.map(|s| s.trim().to_string())
|
|
.filter(|s| !s.is_empty())
|
|
.and_then(|s| s.parse::<i64>().ok())
|
|
.unwrap_or(recent_commits.len() as i64);
|
|
|
|
GitStatus {
|
|
branch,
|
|
changed_files,
|
|
recent_commits,
|
|
total_commits,
|
|
is_git_repo: true,
|
|
}
|
|
}
|
|
|
|
/// 跑单个 git 子命令,捕获 stdout(成功)或 None(失败/超时)。
|
|
///
|
|
/// 用 `wait_timeout` 实现 10s 超时(标准库 `Command::output` 无超时,需手动 wait)。
|
|
fn run_git_cmd(cwd: &std::path::Path, args: &[&str], timeout: std::time::Duration) -> Option<String> {
|
|
use std::sync::mpsc;
|
|
|
|
// 在独立线程跑子进程,主线程 select 超时,避免标准库无超时 API 的痛点。
|
|
// 编码:设置环境变量强制 git 输出 UTF-8(Windows 系统编码可能是 GBK,直接读会乱码)。
|
|
let (tx, rx) = mpsc::channel();
|
|
let cwd = cwd.to_path_buf();
|
|
let args = args.iter().map(|s| s.to_string()).collect::<Vec<_>>();
|
|
std::thread::spawn(move || {
|
|
let mut cmd = Command::new("git");
|
|
cmd.args(&args)
|
|
.current_dir(&cwd)
|
|
.env("LANG", "en_US.UTF-8")
|
|
.env("LC_ALL", "en_US.UTF-8")
|
|
.env("GIT_PAGER", "cat");
|
|
// Windows 下 spawn 子进程默认会弹 cmd 黑窗,加 CREATE_NO_WINDOW 抑制闪烁
|
|
// (GitChanges 挂载并发跑 rev-parse/status/log,不抑制会疯狂闪)。
|
|
#[cfg(windows)]
|
|
{
|
|
use std::os::windows::process::CommandExt;
|
|
cmd.creation_flags(0x0800_0000); // CREATE_NO_WINDOW
|
|
}
|
|
let out = cmd.output();
|
|
let _ = tx.send(out);
|
|
});
|
|
|
|
match rx.recv_timeout(timeout) {
|
|
Ok(Ok(output)) if output.status.success() => {
|
|
Some(String::from_utf8_lossy(&output.stdout).to_string())
|
|
}
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
// ============================================================
|
|
// IPC 命令 — 文件浏览(Batch 10)
|
|
// ============================================================
|
|
//
|
|
// 设计要点(对标设计 §五 + Batch 10):
|
|
// - **路径安全**:sub_path / file_path 均拒含 `..`(防目录穿越)。先 join 再 canonicalize
|
|
// 校验结果仍位于 module.path 子树内(双重防御,即便 `..` 漏网也无法逃出工程根)。
|
|
// - **噪音过滤**:`get_module_file_tree` 跳过 node_modules/target/.git/__pycache__/dist/.vite
|
|
// (对齐需求清单,前端文件树不展示构建产物/VCS 元数据)。
|
|
// - **Git 状态合并**:跑 `git status --porcelain` 得 {rel_path: status} 映射,按条目相对路径
|
|
// 查表填充 git_status。文件夹一律 git_status = None(只标文件,符合需求)。
|
|
// - **二进制检测**:read_module_file 读 1MB 上限,前 8KB 含 \0 视作二进制(is_binary=true,content 留空)。
|
|
|
|
/// 噪音目录名(列表级过滤,read_dir 看到这些名字跳过)。
|
|
const NOISE_DIRS: &[&str] = &[
|
|
"node_modules",
|
|
"target",
|
|
".git",
|
|
"__pycache__",
|
|
"dist",
|
|
".vite",
|
|
];
|
|
|
|
/// 文件树条目(单层;前端点击文件夹再懒加载下一层)。
|
|
#[derive(Debug, Serialize)]
|
|
struct FileTreeEntry {
|
|
name: String,
|
|
/// 相对工程根的路径(POSIX 风格,前端用作后续 sub_path / file_path)。
|
|
path: String,
|
|
is_dir: bool,
|
|
/// 字节数(文件夹恒为 0)。
|
|
size: u64,
|
|
/// Git 状态码(`git status --porcelain` 的 XY 两字符,如 " M"/"M "/"??")。
|
|
/// 文件夹恒为 None(只标文件)。
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
git_status: Option<String>,
|
|
}
|
|
|
|
/// 列出工程目录(可钻入子目录)的文件树(单层 + git 状态合并)。
|
|
/// 返回 { path, entries: [{ name, path, is_dir, size, git_status? }] }。
|
|
#[tauri::command]
|
|
pub async fn get_module_file_tree(
|
|
state: State<'_, AppState>,
|
|
module_id: String,
|
|
sub_path: Option<String>,
|
|
) -> Result<serde_json::Value, String> {
|
|
let module_id = module_id.trim().to_string();
|
|
if module_id.is_empty() {
|
|
return Err("module_id 不能为空".to_string());
|
|
}
|
|
let sub = sub_path
|
|
.map(|s| s.trim().to_string())
|
|
.filter(|s| !s.is_empty());
|
|
|
|
// 路径穿越防御:`..` 段一律拒(规范化后 canonicalize 再兜底校验仍在工程根子树)。
|
|
if let Some(ref s) = sub {
|
|
if has_path_traversal(s) {
|
|
return Err("sub_path 不允许包含 ..".to_string());
|
|
}
|
|
}
|
|
|
|
let module = state
|
|
.project_modules
|
|
.get_by_id(&module_id)
|
|
.await
|
|
.map_err(err_str)?
|
|
.ok_or_else(|| format!("工程 {module_id} 不存在"))?;
|
|
|
|
let root = PathBuf::from(&module.path);
|
|
// 工程根本身必须存在(否则连列根都无意义,直接报错给前端清晰提示)。
|
|
if !root.exists() {
|
|
return Err(format!("工程目录不存在: {}", module.path));
|
|
}
|
|
|
|
// 拼接 sub_path 得目标目录;规范化路径显示用。
|
|
let target_dir = match &sub {
|
|
Some(rel) => root.join(rel),
|
|
None => root.clone(),
|
|
};
|
|
if !target_dir.is_dir() {
|
|
return Err(format!("目标路径不是目录: {}", target_dir.display()));
|
|
}
|
|
|
|
// 计算相对工程根的显示路径(POSIX 风格),前端面包屑/二次请求复用。
|
|
let rel_display = match &sub {
|
|
Some(rel) => rel.replace('\\', "/"),
|
|
None => String::new(),
|
|
};
|
|
|
|
// 采集 git 改动文件映射(相对仓库根):{posix_rel: status}。
|
|
// 10s 超时;非 git 仓库/失败 → 空映射(条目 git_status 全 None,不阻断列目录)。
|
|
let dir_for_git = module.path.clone();
|
|
let git_map = tokio::task::spawn_blocking(move || git_status_map_cached(&dir_for_git))
|
|
.await
|
|
.map_err(|e| format!("git status 采集任务失败: {e}"))?;
|
|
// 路径前缀(工程根本身)的相对基准;target_dir 下条目相对路径要以此拼成 posix 形式查 git_map。
|
|
// git status 输出是相对仓库根(module.path 当作根),sub 非空时条目前缀 = sub + "/"。
|
|
let prefix = if rel_display.is_empty() {
|
|
String::new()
|
|
} else {
|
|
format!("{rel_display}/")
|
|
};
|
|
|
|
// 读目录(spawn_blocking 避免阻塞 runtime);排序:目录优先 + 字母序。
|
|
let target_dir_clone = target_dir.clone();
|
|
let prefix_clone = prefix.clone();
|
|
let git_map_clone = git_map.clone();
|
|
let entries = tokio::task::spawn_blocking(move || -> Result<Vec<FileTreeEntry>, String> {
|
|
let read = std::fs::read_dir(&target_dir_clone)
|
|
.map_err(|e| format!("读取目录失败: {e}"))?;
|
|
let mut items: Vec<FileTreeEntry> = Vec::new();
|
|
for entry in read.flatten() {
|
|
let file_name = entry.file_name();
|
|
let name = file_name.to_string_lossy().to_string();
|
|
// 噪音过滤(无论文件/目录,凡名命中即跳)。
|
|
if NOISE_DIRS.iter().any(|n| *n == name.as_str()) {
|
|
continue;
|
|
}
|
|
let ft = match entry.file_type() {
|
|
Ok(t) => t,
|
|
Err(_) => continue, // 无法判定类型跳过(符号链接断链等)
|
|
};
|
|
// 跳过隐藏文件(以 . 开头),与常见编辑器行为一致,减少噪音。
|
|
if name.starts_with('.') {
|
|
continue;
|
|
}
|
|
let is_dir = ft.is_dir();
|
|
let size = if is_dir {
|
|
0
|
|
} else {
|
|
entry.metadata().map(|m| m.len()).unwrap_or(0)
|
|
};
|
|
let rel_path = format!("{prefix_clone}{name}");
|
|
let posix_rel = rel_path.replace('\\', "/");
|
|
let git_status = if is_dir {
|
|
None
|
|
} else {
|
|
git_map_clone.get(&posix_rel).cloned()
|
|
};
|
|
items.push(FileTreeEntry {
|
|
name,
|
|
path: posix_rel,
|
|
is_dir,
|
|
size,
|
|
git_status,
|
|
});
|
|
}
|
|
// 目录优先,各自字母序(稳定可预期,前端无需再排)。
|
|
items.sort_by(|a, b| match (a.is_dir, b.is_dir) {
|
|
(true, false) => std::cmp::Ordering::Less,
|
|
(false, true) => std::cmp::Ordering::Greater,
|
|
_ => a.name.to_lowercase().cmp(&b.name.to_lowercase()),
|
|
});
|
|
Ok(items)
|
|
})
|
|
.await
|
|
.map_err(|e| format!("列目录任务失败: {e}"))??;
|
|
|
|
Ok(serde_json::json!({
|
|
"path": rel_display,
|
|
"entries": entries,
|
|
}))
|
|
}
|
|
|
|
/// 读工程内单文件(文本/图片);1MB 上限,二进制检测(前 8KB 含 \0)。
|
|
/// 返回 { path, content, size, is_binary }。二进制/超限时 is_binary=true 或 content 截断,
|
|
/// 前端据此降级展示("二进制文件不支持预览")。
|
|
#[tauri::command]
|
|
pub async fn read_module_file(
|
|
state: State<'_, AppState>,
|
|
module_id: String,
|
|
file_path: String,
|
|
) -> Result<serde_json::Value, String> {
|
|
let module_id = module_id.trim().to_string();
|
|
let file_path = file_path.trim().to_string();
|
|
if module_id.is_empty() {
|
|
return Err("module_id 不能为空".to_string());
|
|
}
|
|
if file_path.is_empty() {
|
|
return Err("file_path 不能为空".to_string());
|
|
}
|
|
// 路径穿越防御:`..` 段一律拒。
|
|
if has_path_traversal(&file_path) {
|
|
return Err("file_path 不允许包含 ..".to_string());
|
|
}
|
|
|
|
let module = state
|
|
.project_modules
|
|
.get_by_id(&module_id)
|
|
.await
|
|
.map_err(err_str)?
|
|
.ok_or_else(|| format!("工程 {module_id} 不存在"))?;
|
|
|
|
let root = PathBuf::from(&module.path);
|
|
let abs = root.join(&file_path);
|
|
// 必须是文件(目录/不存在均拒)。
|
|
if !abs.is_file() {
|
|
return Err(format!("文件不存在或不是普通文件: {}", file_path));
|
|
}
|
|
|
|
const MAX_BYTES: u64 = 1024 * 1024; // 1MB 上限
|
|
let meta = std::fs::metadata(&abs).map_err(|e| format!("读取文件元信息失败: {e}"))?;
|
|
let total_size = meta.len();
|
|
|
|
// 读取(超 1MB 截断到 1MB;spawn_blocking 避免大文件 IO 阻塞 runtime)。
|
|
let abs_clone = abs.clone();
|
|
let read_result = tokio::task::spawn_blocking(move || -> Result<(Vec<u8>, bool), String> {
|
|
use std::io::Read;
|
|
let mut f = std::fs::File::open(&abs_clone).map_err(|e| format!("打开文件失败: {e}"))?;
|
|
let mut buf = vec![0u8; MAX_BYTES as usize];
|
|
let n = f.read(&mut buf).map_err(|e| format!("读取文件失败: {e}"))?;
|
|
buf.truncate(n);
|
|
// 二进制检测:前 8KB 含 \0 → 二进制。8KB 内全为文本字节 → 当文本。
|
|
let check_len = buf.len().min(8192);
|
|
let is_binary = buf[..check_len].iter().any(|&b| b == 0);
|
|
Ok((buf, is_binary))
|
|
})
|
|
.await
|
|
.map_err(|e| format!("读文件任务失败: {e}"))??;
|
|
let (bytes, is_binary) = read_result;
|
|
|
|
let content = if is_binary {
|
|
// 二进制不返内容(前端走"二进制不支持预览"分支,避免给前端塞不可打印字节)。
|
|
String::new()
|
|
} else {
|
|
// 非 UTF-8 字节用 lossy(替换非法字节,文本类基本不受影响)。
|
|
String::from_utf8_lossy(&bytes).to_string()
|
|
};
|
|
|
|
Ok(serde_json::json!({
|
|
"path": file_path.replace('\\', "/"),
|
|
"content": content,
|
|
"size": total_size,
|
|
"is_binary": is_binary,
|
|
// 标记是否被截断(超 1MB),前端据此提示"文件过大,仅显示前 1MB"。
|
|
"truncated": total_size > MAX_BYTES,
|
|
}))
|
|
}
|
|
|
|
/// 查询工程内文件元信息(不读内容,轻量检测变化用)。
|
|
/// 返回 { path, size, modified_at }。modified_at 是毫秒级时间戳。
|
|
#[tauri::command]
|
|
pub async fn get_module_file_meta(
|
|
state: State<'_, AppState>,
|
|
module_id: String,
|
|
file_path: String,
|
|
) -> Result<serde_json::Value, String> {
|
|
let module_id = module_id.trim().to_string();
|
|
let file_path = file_path.trim().to_string();
|
|
if module_id.is_empty() {
|
|
return Err("module_id 不能为空".to_string());
|
|
}
|
|
if file_path.is_empty() {
|
|
return Err("file_path 不能为空".to_string());
|
|
}
|
|
if has_path_traversal(&file_path) {
|
|
return Err("file_path 不允许包含 ..".to_string());
|
|
}
|
|
let module = state
|
|
.project_modules
|
|
.get_by_id(&module_id)
|
|
.await
|
|
.map_err(err_str)?
|
|
.ok_or_else(|| format!("工程 {module_id} 不存在"))?;
|
|
let root = std::path::PathBuf::from(&module.path);
|
|
let abs = root.join(&file_path);
|
|
if !abs.is_file() {
|
|
return Err(format!("文件不存在或不是普通文件: {}", file_path));
|
|
}
|
|
let meta = std::fs::metadata(&abs).map_err(|e| format!("读取文件元信息失败: {e}"))?;
|
|
// 系统级 modified_at 需跨平台转换;用 std::time::UNIX_EPOCH 算毫秒
|
|
let modified_ms = meta
|
|
.modified()
|
|
.ok()
|
|
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
|
|
.map(|d| d.as_millis() as u64)
|
|
.unwrap_or(0);
|
|
Ok(serde_json::json!({
|
|
"path": file_path.replace('\\', "/"),
|
|
"size": meta.len(),
|
|
"modified_at": modified_ms,
|
|
}))
|
|
}
|
|
|
|
/// 查询工程内文件的 git diff(相对 staged 或 working tree 的变更)。
|
|
/// 返回 { path, diff }。diff 为空串表示无变更。
|
|
#[tauri::command]
|
|
pub async fn get_module_file_diff(
|
|
state: State<'_, AppState>,
|
|
module_id: String,
|
|
file_path: String,
|
|
) -> Result<serde_json::Value, String> {
|
|
let module_id = module_id.trim().to_string();
|
|
let file_path = file_path.trim().to_string();
|
|
if module_id.is_empty() {
|
|
return Err("module_id 不能为空".to_string());
|
|
}
|
|
if file_path.is_empty() {
|
|
return Err("file_path 不能为空".to_string());
|
|
}
|
|
if has_path_traversal(&file_path) {
|
|
return Err("file_path 不允许包含 ..".to_string());
|
|
}
|
|
let module = state
|
|
.project_modules
|
|
.get_by_id(&module_id)
|
|
.await
|
|
.map_err(err_str)?
|
|
.ok_or_else(|| format!("工程 {module_id} 不存在"))?;
|
|
let root = std::path::PathBuf::from(&module.path);
|
|
if !root.join(".git").exists() {
|
|
return Ok(serde_json::json!({"path": file_path, "diff": ""}));
|
|
}
|
|
// 跑 git diff <path>(工作树 vs 索引的 unstaged 变更+staged 变更)
|
|
// spawn_blocking + run_git_cmd(10s 超时,防 git 卡死阻塞 runtime)。
|
|
let cmd_dir = root.clone();
|
|
let path_arg = file_path.replace('\\', "/");
|
|
let diff = tokio::task::spawn_blocking(move || -> Option<String> {
|
|
run_git_cmd(
|
|
&cmd_dir,
|
|
&["diff", "--", &path_arg],
|
|
std::time::Duration::from_secs(10),
|
|
)
|
|
})
|
|
.await
|
|
.map_err(|e| format!("git diff 任务失败: {e}"))?
|
|
.unwrap_or_default();
|
|
Ok(serde_json::json!({
|
|
"path": file_path.replace('\\', "/"),
|
|
"diff": diff,
|
|
}))
|
|
}
|
|
|
|
/// 扫描项目绑定目录下的一级子目录,自动创建工程记录。
|
|
///
|
|
/// 判定为工程的信号:① 含 .git(独立仓库)② detect_stack 命中(有 package.json / go.mod /
|
|
/// Cargo.toml 等工程标志文件)。两者满足其一即识别;两者都无(纯普通文件夹)忽略。
|
|
/// 无 .git 的工程 git_url 留空。返回新创建的工程数量;幂等(已存在路径不重复创建)。
|
|
#[tauri::command]
|
|
pub async fn scan_project_modules(
|
|
state: State<'_, AppState>,
|
|
project_id: String,
|
|
) -> Result<i64, String> {
|
|
let project_id = project_id.trim().to_string();
|
|
if project_id.is_empty() {
|
|
return Err("project_id 不能为空".to_string());
|
|
}
|
|
// 取项目记录拿 path
|
|
let project = state
|
|
.projects
|
|
.get_by_id(&project_id)
|
|
.await
|
|
.map_err(err_str)?
|
|
.ok_or_else(|| format!("项目 {project_id} 不存在"))?;
|
|
let root_path = project
|
|
.path
|
|
.as_ref()
|
|
.filter(|p| !p.trim().is_empty())
|
|
.ok_or_else(|| "项目未绑定目录".to_string())?;
|
|
let root = PathBuf::from(root_path);
|
|
// 取已有工程路径集(避免重复创建)
|
|
let existing = state
|
|
.project_modules
|
|
.list_by_project(&project_id)
|
|
.await
|
|
.map_err(err_str)?;
|
|
let existing_paths: std::collections::HashSet<String> = existing
|
|
.iter()
|
|
.map(|m| m.path.to_lowercase().replace("\\", "/"))
|
|
.collect();
|
|
// 扫描一级子目录(含 .git)
|
|
let mut new_count = 0i64;
|
|
let entries = std::fs::read_dir(&root)
|
|
.map_err(|e| format!("读取项目目录失败: {e}"))?;
|
|
for entry in entries.flatten() {
|
|
let ft = match entry.file_type() { Ok(t) => t, Err(_) => continue };
|
|
if !ft.is_dir() { continue; }
|
|
let name = entry.file_name().to_string_lossy().to_string();
|
|
// 跳过隐藏目录和噪音目录
|
|
if name.starts_with('.') || name == "node_modules" || name == "target" || name == "__pycache__" || name == "dist" {
|
|
continue;
|
|
}
|
|
let child_path = entry.path();
|
|
let child_path_str = child_path.to_string_lossy().replace("\\", "/");
|
|
if existing_paths.contains(&child_path_str.to_lowercase()) { continue; }
|
|
let has_git = child_path.join(".git").exists();
|
|
// 探测技术栈:既是「是否工程」的判定信号,也填 stack 字段
|
|
let stack_dir = child_path.clone();
|
|
let stack_json = tokio::task::spawn_blocking(move || {
|
|
detect_stack(&stack_dir)
|
|
.ok()
|
|
.filter(|v| !v.is_empty())
|
|
.and_then(|v| serde_json::to_string(&v).ok())
|
|
})
|
|
.await
|
|
.unwrap_or(None);
|
|
// 合理判定:有 .git(独立仓库)或 detect_stack 命中(工程标志,如 package.json /
|
|
// go.mod / Cargo.toml 等)→ 识别为工程;两者都无 → 纯普通文件夹,忽略。
|
|
// 相比原「仅 .git」,覆盖无独立 git 但有工程标志的子包(monorepo npm workspace 等)。
|
|
if !has_git && stack_json.is_none() {
|
|
continue;
|
|
}
|
|
// 获取远程地址(仅独立 git 仓库有 remote;无 .git 的工程 git_url 留空)
|
|
let url_dir = child_path.clone();
|
|
let git_url = if has_git {
|
|
tokio::task::spawn_blocking(move || {
|
|
run_git_cmd(
|
|
&url_dir,
|
|
&["remote", "get-url", "origin"],
|
|
std::time::Duration::from_secs(10),
|
|
)
|
|
.map(|s| s.trim().to_string())
|
|
.filter(|s| !s.is_empty())
|
|
})
|
|
.await
|
|
.unwrap_or(None)
|
|
} else {
|
|
None
|
|
};
|
|
let now_str = now_millis();
|
|
let record = ProjectModuleRecord {
|
|
id: new_id(),
|
|
project_id: project_id.clone(),
|
|
name: name.clone(),
|
|
path: child_path.to_string_lossy().to_string(),
|
|
git_url,
|
|
stack: stack_json,
|
|
auto_detected: true,
|
|
sort_order: (existing.len() + new_count as usize) as i32,
|
|
created_at: now_str.clone(),
|
|
updated_at: now_str,
|
|
description: None,
|
|
status: Some("active".to_string()),
|
|
};
|
|
if let Err(e) = state.project_modules.insert(record).await {
|
|
tracing::warn!("自动探测工程 {} 失败: {}", child_path_str, e);
|
|
continue;
|
|
}
|
|
new_count += 1;
|
|
}
|
|
Ok(new_count)
|
|
}
|
|
|
|
/// 采集工程目录 git status --porcelain 的 {posix_rel_path: status} 映射。
|
|
/// 非 git 仓库/命令失败 → 空映射(不阻断文件树展示,仅 git_status 字段全 None)。
|
|
fn collect_git_status_map(dir: &str) -> HashMap<String, String> {
|
|
let path = Path::new(dir);
|
|
let mut map = HashMap::new();
|
|
if !path.join(".git").exists() {
|
|
return map;
|
|
}
|
|
let timeout = std::time::Duration::from_secs(10);
|
|
let Some(out) = run_git_cmd(path, &["status", "--porcelain"], timeout) else {
|
|
return map;
|
|
};
|
|
for line in out.lines() {
|
|
if line.len() < 4 {
|
|
continue;
|
|
}
|
|
let status = line[..2].to_string();
|
|
let raw_path = line[3..].trim().to_string();
|
|
if raw_path.is_empty() {
|
|
continue;
|
|
}
|
|
// porcelain 在路径含空格/特殊字符时可能带引号;git 默认不引普通路径,剥引号兜底。
|
|
let cleaned = raw_path
|
|
.strip_prefix('"')
|
|
.and_then(|s| s.strip_suffix('"'))
|
|
.unwrap_or(&raw_path)
|
|
.replace('\\', "/");
|
|
// 重命名条目形如 "R old -> new":取 -> 后的新路径(更贴合文件树展示位置)。
|
|
let final_path = cleaned
|
|
.split_once(" -> ")
|
|
.map(|(_, new)| new.to_string())
|
|
.unwrap_or(cleaned);
|
|
map.insert(final_path, status);
|
|
}
|
|
map
|
|
}
|
|
|
|
/// 查询工程 Git 提交历史(分页,按时间倒序)。
|
|
/// 返回 { commits: [{ hash, subject, timestamp, author }], has_more: bool, total: i64 }。
|
|
/// total = `git rev-list --count HEAD` 的全量提交数,供前端徽标真实总数(commits.len() 受分页限制)。
|
|
#[tauri::command]
|
|
pub async fn get_module_commits(
|
|
state: State<'_, AppState>,
|
|
module_id: String,
|
|
skip: Option<u32>,
|
|
limit: Option<u32>,
|
|
) -> Result<serde_json::Value, String> {
|
|
let module_id = module_id.trim().to_string();
|
|
if module_id.is_empty() {
|
|
return Err("module_id 不能为空".to_string());
|
|
}
|
|
let module = state
|
|
.project_modules
|
|
.get_by_id(&module_id)
|
|
.await
|
|
.map_err(err_str)?
|
|
.ok_or_else(|| format!("工程 {module_id} 不存在"))?;
|
|
let path = std::path::Path::new(&module.path);
|
|
if !path.join(".git").exists() {
|
|
return Ok(serde_json::json!({ "commits": [], "has_more": false, "total": 0 }));
|
|
}
|
|
let skip = skip.unwrap_or(0);
|
|
let fetch = limit.unwrap_or(50);
|
|
// 多取一条以判断 has_more
|
|
let fetch_plus = fetch + 1;
|
|
let dir = module.path.clone();
|
|
let dir_for_git = dir.clone();
|
|
// git 命令在 spawn_blocking 中执行(阻塞 IO 不污染 async runtime),10s 超时
|
|
let (commits, total): (Vec<serde_json::Value>, i64) = tokio::task::spawn_blocking(move || -> (Vec<serde_json::Value>, i64) {
|
|
let path = std::path::Path::new(&dir_for_git);
|
|
let timeout = std::time::Duration::from_secs(10);
|
|
let out = run_git_cmd(
|
|
path,
|
|
&[
|
|
"log",
|
|
&format!("--skip={}", skip),
|
|
&format!("-{}", fetch_plus),
|
|
"--format=%h %ct %an %s",
|
|
],
|
|
timeout,
|
|
)
|
|
.unwrap_or_default();
|
|
let mut commits: Vec<serde_json::Value> = Vec::new();
|
|
for line in out.lines() {
|
|
let line = line.trim();
|
|
if line.is_empty() { continue; }
|
|
if commits.len() >= fetch as usize { break; }
|
|
let mut parts = line.splitn(4, ' ');
|
|
let hash = parts.next().unwrap_or("").to_string();
|
|
let ts_str = parts.next().unwrap_or("0");
|
|
let timestamp: i64 = ts_str.parse().unwrap_or(0);
|
|
let author = parts.next().unwrap_or("").to_string();
|
|
let subject = parts.next().unwrap_or("").to_string();
|
|
if !hash.is_empty() {
|
|
commits.push(serde_json::json!({
|
|
"hash": hash,
|
|
"subject": subject,
|
|
"timestamp": timestamp,
|
|
"author": author,
|
|
}));
|
|
}
|
|
}
|
|
// 全量提交计数(`git rev-list --count HEAD`):前端历史 Tab 徽标真实总数。
|
|
// 命令失败 → 退化为 0(前端会显示 0,但 has_more 仍可驱动分页)。
|
|
let total = run_git_cmd(path, &["rev-list", "--count", "HEAD"], timeout)
|
|
.map(|s| s.trim().to_string())
|
|
.filter(|s| !s.is_empty())
|
|
.and_then(|s| s.parse::<i64>().ok())
|
|
.unwrap_or(0);
|
|
(commits, total)
|
|
})
|
|
.await
|
|
.map_err(|e| format!("提交历史查询任务失败: {e}"))?;
|
|
|
|
// 判断 has_more:取了 N+1 条但只返回 N 条,说明有更多
|
|
let total_fetched = commits.len();
|
|
let has_more = total_fetched == fetch_plus as usize;
|
|
let returned: Vec<_> = commits.into_iter().take(fetch as usize).collect();
|
|
|
|
Ok(serde_json::json!({
|
|
"commits": returned,
|
|
"has_more": has_more,
|
|
"total": total,
|
|
}))
|
|
}
|
|
|
|
/// 查询某次提交的变更文件列表及全量 diff。
|
|
/// 返回 { files: [{ status, path }], diff: string }。
|
|
#[tauri::command]
|
|
pub async fn get_commit_detail(
|
|
state: State<'_, AppState>,
|
|
module_id: String,
|
|
commit_hash: String,
|
|
) -> Result<serde_json::Value, String> {
|
|
let module_id = module_id.trim().to_string();
|
|
let commit_hash = commit_hash.trim().to_string();
|
|
if module_id.is_empty() {
|
|
return Err("module_id 不能为空".to_string());
|
|
}
|
|
if commit_hash.is_empty() {
|
|
return Err("commit_hash 不能为空".to_string());
|
|
}
|
|
let module = state
|
|
.project_modules
|
|
.get_by_id(&module_id)
|
|
.await
|
|
.map_err(err_str)?
|
|
.ok_or_else(|| format!("工程 {module_id} 不存在"))?;
|
|
let path = std::path::Path::new(&module.path);
|
|
if !path.join(".git").exists() {
|
|
return Ok(serde_json::json!({ "files": [], "diff": "" }));
|
|
}
|
|
let dir = module.path.clone();
|
|
// git 命令在 spawn_blocking 中执行(阻塞 IO 不污染 async runtime),10s 超时
|
|
let (files, diff, parents, author, date, full_message) = tokio::task::spawn_blocking(move || -> (Vec<serde_json::Value>, String, Vec<String>, String, String, String) {
|
|
let dir_path = std::path::Path::new(&dir);
|
|
let timeout = std::time::Duration::from_secs(10);
|
|
// 1) 获取变更文件列表:`git diff-tree --no-commit-id -r --name-status <hash>`
|
|
let files_out = run_git_cmd(
|
|
dir_path,
|
|
&["diff-tree", "--no-commit-id", "-r", "--name-status", &commit_hash],
|
|
timeout,
|
|
)
|
|
.unwrap_or_default();
|
|
let mut files: Vec<serde_json::Value> = Vec::new();
|
|
for line in files_out.lines() {
|
|
let line = line.trim();
|
|
if line.is_empty() { continue; }
|
|
// format:"<status>\t<path>"
|
|
if let Some((status, fpath)) = line.split_once('\t') {
|
|
files.push(serde_json::json!({
|
|
"status": status.trim(),
|
|
"path": fpath.trim(),
|
|
}));
|
|
}
|
|
}
|
|
// 2) 获取全量 diff:`git show <hash>`(仅 diff 部分)
|
|
let diff = run_git_cmd(
|
|
dir_path,
|
|
&["show", "--format=", &commit_hash],
|
|
timeout,
|
|
)
|
|
.unwrap_or_default();
|
|
// 3) 获取提交元信息(父提交/作者/日期/完整消息):
|
|
// 用 printf 自定义格式,%P=父哈希(空格分隔多个)\t%an=作者\t%ad=日期\t%B=完整消息
|
|
let meta = run_git_cmd(
|
|
dir_path,
|
|
&["show", "-s", "--format=%P\t%an\t%ad\t%B", &commit_hash],
|
|
timeout,
|
|
)
|
|
.unwrap_or_default();
|
|
let mut parents = Vec::new();
|
|
let mut author = String::new();
|
|
let mut date = String::new();
|
|
let mut full_message = String::new();
|
|
if let Some(first_line) = meta.lines().next() {
|
|
let parts: Vec<&str> = first_line.splitn(4, '\t').collect();
|
|
if parts.len() >= 1 {
|
|
parents = parts[0].split_whitespace().map(|s| s.to_string()).collect();
|
|
}
|
|
if parts.len() >= 2 { author = parts[1].to_string(); }
|
|
if parts.len() >= 3 { date = parts[2].to_string(); }
|
|
if parts.len() >= 4 { full_message = parts[3].to_string(); }
|
|
}
|
|
// multi-line message: 补尾部行(meta.lines().next() 只取首行,需拼回)
|
|
if !full_message.is_empty() {
|
|
let rest = meta.lines().skip(1).collect::<Vec<_>>().join("\n");
|
|
if !rest.is_empty() {
|
|
full_message = format!("{}\n{}", full_message, rest);
|
|
}
|
|
}
|
|
(files, diff, parents, author, date, full_message)
|
|
})
|
|
.await
|
|
.map_err(|e| format!("提交详情查询任务失败: {e}"))?;
|
|
|
|
Ok(serde_json::json!({
|
|
"files": files,
|
|
"diff": diff,
|
|
"parents": parents,
|
|
"author": author,
|
|
"date": date,
|
|
"full_message": full_message,
|
|
}))
|
|
}
|
|
|
|
// ============================================================
|
|
// IPC 命令 — 工程依赖关系(module_dependencies 表)
|
|
// ============================================================
|
|
|
|
/// 新增工程依赖。返回完整记录。
|
|
#[tauri::command]
|
|
pub async fn add_module_dependency(
|
|
state: State<'_, AppState>,
|
|
project_id: String,
|
|
from_module_id: String,
|
|
to_module_id: String,
|
|
dep_type: Option<String>,
|
|
label: Option<String>,
|
|
) -> Result<ModuleDependencyRecord, String> {
|
|
let project_id = project_id.trim().to_string();
|
|
let from_module_id = from_module_id.trim().to_string();
|
|
let to_module_id = to_module_id.trim().to_string();
|
|
if project_id.is_empty() || from_module_id.is_empty() || to_module_id.is_empty() {
|
|
return Err("project_id / from_module_id / to_module_id 不能为空".to_string());
|
|
}
|
|
let dep_type = dep_type.unwrap_or_else(|| "library".to_string());
|
|
let record = ModuleDependencyRecord {
|
|
id: new_id(),
|
|
project_id,
|
|
from_module_id,
|
|
to_module_id,
|
|
dep_type,
|
|
label: trim_opt(label),
|
|
created_at: now_millis(),
|
|
};
|
|
state.module_dependencies.insert(record.clone()).await.map_err(err_str)?;
|
|
Ok(record)
|
|
}
|
|
|
|
/// 删除工程依赖。
|
|
#[tauri::command]
|
|
pub async fn remove_module_dependency(
|
|
state: State<'_, AppState>,
|
|
id: String,
|
|
) -> Result<(), String> {
|
|
let id = id.trim().to_string();
|
|
if id.is_empty() {
|
|
return Err("id 不能为空".to_string());
|
|
}
|
|
state.module_dependencies.delete(&id).await.map_err(err_str)?;
|
|
Ok(())
|
|
}
|
|
|
|
/// 列出项目的全部工程依赖。
|
|
#[tauri::command]
|
|
pub async fn list_module_dependencies(
|
|
state: State<'_, AppState>,
|
|
project_id: String,
|
|
) -> Result<Vec<ModuleDependencyRecord>, String> {
|
|
let project_id = project_id.trim().to_string();
|
|
if project_id.is_empty() {
|
|
return Err("project_id 不能为空".to_string());
|
|
}
|
|
state
|
|
.module_dependencies
|
|
.list_by_field("project_id", &project_id)
|
|
.await
|
|
.map_err(err_str)
|
|
}
|
|
|
|
/// 检测工程依赖图中的环形依赖(DFS 三色标记法)。
|
|
/// 返回参与环的 module_id 列表(空 = 无环)。
|
|
#[tauri::command]
|
|
pub async fn detect_module_cycles(
|
|
state: State<'_, AppState>,
|
|
project_id: String,
|
|
) -> Result<Vec<String>, String> {
|
|
let project_id = project_id.trim().to_string();
|
|
if project_id.is_empty() {
|
|
return Err("project_id 不能为空".to_string());
|
|
}
|
|
let deps = state
|
|
.module_dependencies
|
|
.list_by_field("project_id", &project_id)
|
|
.await
|
|
.map_err(err_str)?;
|
|
// 构建邻接表
|
|
let mut adj: std::collections::HashMap<String, Vec<String>> = std::collections::HashMap::new();
|
|
for d in &deps {
|
|
adj.entry(d.from_module_id.clone())
|
|
.or_default()
|
|
.push(d.to_module_id.clone());
|
|
adj.entry(d.to_module_id.clone()).or_default();
|
|
}
|
|
// DFS 三色:0=白(未访问) 1=灰(栈中) 2=黑(完成)
|
|
let mut color: std::collections::HashMap<String, u8> = std::collections::HashMap::new();
|
|
let mut cycle_nodes: std::collections::HashSet<String> = std::collections::HashSet::new();
|
|
fn dfs(
|
|
node: &str,
|
|
adj: &std::collections::HashMap<String, Vec<String>>,
|
|
color: &mut std::collections::HashMap<String, u8>,
|
|
cycle_nodes: &mut std::collections::HashSet<String>,
|
|
path: &mut Vec<String>,
|
|
) {
|
|
let c = color.get(node).copied().unwrap_or(0);
|
|
if c == 1 {
|
|
// 发现环:path 中从当前节点开始的都参与环
|
|
let in_cycle = path.iter().position(|n| n == node);
|
|
if let Some(start) = in_cycle {
|
|
for n in &path[start..] {
|
|
cycle_nodes.insert(n.clone());
|
|
}
|
|
}
|
|
cycle_nodes.insert(node.to_string());
|
|
return;
|
|
}
|
|
if c == 2 {
|
|
return;
|
|
}
|
|
color.insert(node.to_string(), 1);
|
|
path.push(node.to_string());
|
|
if let Some(neighbors) = adj.get(node) {
|
|
for next in neighbors {
|
|
dfs(next, adj, color, cycle_nodes, path);
|
|
}
|
|
}
|
|
path.pop();
|
|
color.insert(node.to_string(), 2);
|
|
}
|
|
let nodes: Vec<String> = adj.keys().cloned().collect();
|
|
let mut path: Vec<String> = Vec::new();
|
|
for n in &nodes {
|
|
if color.get(n).copied().unwrap_or(0) == 0 {
|
|
dfs(n, &adj, &mut color, &mut cycle_nodes, &mut path);
|
|
}
|
|
}
|
|
Ok(cycle_nodes.into_iter().collect())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use std::time::Duration;
|
|
|
|
/// P1-f:git status 缓存 TTL 判定 — 5s 内新鲜,超时失效。
|
|
#[test]
|
|
fn test_is_git_status_cache_fresh() {
|
|
let now = Instant::now();
|
|
// TTL 内 → 新鲜
|
|
assert!(is_git_status_cache_fresh(now - Duration::from_millis(4000), now));
|
|
// 恰好 TTL → 不新鲜(duration_since < TTL 严格小于)
|
|
assert!(!is_git_status_cache_fresh(now - Duration::from_secs(5), now));
|
|
// 超过 TTL → 失效
|
|
assert!(!is_git_status_cache_fresh(now - Duration::from_secs(6), now));
|
|
}
|
|
|
|
/// P1-f:git status 缓存同一仓库路径复用同一 map 条目、不同路径独立(纯逻辑,不经 git)。
|
|
#[test]
|
|
fn test_git_status_cache_shared_per_repo() {
|
|
let mut cache = GIT_STATUS_CACHE.lock().unwrap_or_else(|e| e.into_inner());
|
|
let now = Instant::now();
|
|
let mut map_a = HashMap::new();
|
|
map_a.insert("src/main.rs".to_string(), " M".to_string());
|
|
let mut map_b = HashMap::new();
|
|
map_b.insert("README.md".to_string(), "??".to_string());
|
|
cache.insert("repoA".to_string(), (now, map_a.clone()));
|
|
cache.insert("repoB".to_string(), (now, map_b.clone()));
|
|
// 同路径命中同一份缓存,不同路径互不串扰
|
|
let (_, cached_a) = cache.get("repoA").unwrap();
|
|
assert_eq!(cached_a, &map_a);
|
|
assert_ne!(cached_a, &map_b);
|
|
// 清理后不再命中
|
|
cache.remove("repoA");
|
|
assert!(cache.get("repoA").is_none());
|
|
}
|
|
}
|
|
|