//! 工程系统命令(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, /// 技术栈 JSON 字符串(前端检测后传入),可选 #[serde(default)] pub stack: Option, /// 工程职责描述(如"前端 web 工程"),可选。V40 加。 #[serde(default)] pub description: Option, /// 工程状态 active/archived,可选(默认 active)。V40 加。 #[serde(default)] pub status: Option, } /// 更新工程入参(部分更新语义,仅传入字段被覆盖;对标设计 §五 update_project_module)。 #[derive(Debug, Deserialize)] pub struct UpdateProjectModuleInput { pub id: String, #[serde(default)] pub name: Option, #[serde(default)] pub path: Option, #[serde(default)] pub git_url: Option, #[serde(default)] pub stack: Option, /// 工程职责描述,V40 加。空串归一为 None(trim_opt)。 #[serde(default)] pub description: Option, /// 工程状态 active/archived,V40 加。 #[serde(default)] pub status: Option, } // ============================================================ // 辅助:Optional 字段 trim + 空串归一为 None // ============================================================ fn trim_opt(s: Option) -> Option { 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) -> Option { 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 { 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 { 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, 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)>>> = 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 { // 非 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, /// 最近 10 条提交 recent_commits: Vec, /// 当前 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 { 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 { 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) { // 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 = 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:" "(作者不含空格) 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::().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 { 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::>(); 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, } /// 列出工程目录(可钻入子目录)的文件树(单层 + 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, ) -> Result { 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, String> { let read = std::fs::read_dir(&target_dir_clone) .map_err(|e| format!("读取目录失败: {e}"))?; let mut items: Vec = 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 { 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, 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 { 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 { 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 (工作树 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 { 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 { 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 = 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 { 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, limit: Option, ) -> Result { 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, i64) = tokio::task::spawn_blocking(move || -> (Vec, 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 = 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::().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 { 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, String, Vec, 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 ` 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 = Vec::new(); for line in files_out.lines() { let line = line.trim(); if line.is_empty() { continue; } // format:"\t" if let Some((status, fpath)) = line.split_once('\t') { files.push(serde_json::json!({ "status": status.trim(), "path": fpath.trim(), })); } } // 2) 获取全量 diff:`git show `(仅 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::>().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, label: Option, ) -> Result { 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, 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, 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> = 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 = std::collections::HashMap::new(); let mut cycle_nodes: std::collections::HashSet = std::collections::HashSet::new(); fn dfs( node: &str, adj: &std::collections::HashMap>, color: &mut std::collections::HashMap, cycle_nodes: &mut std::collections::HashSet, path: &mut Vec, ) { 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 = adj.keys().cloned().collect(); let mut path: Vec = 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()); } }