修复: UX扩展审查P0批(后端git/路径确定性bug + 前端UI + 编译阻断)
后端(src-tauri): - project.rs: 4处空if目录校验补return Err(import/relocate/scan_directory/scan_project_with_ai),目录不存在不再静默放行 - ai/tools/git.rs: exec_git改返(String,bool)治git_commit·git_branch create/switch假成功 + git_merge补失败分支 + 加CREATE_NO_WINDOW - module.rs: run_git_cmd加CREATE_NO_WINDOW,治Windows cmd黑窗闪烁(原问题9) 前端: - DependencyGraph.vue: 环检测高亮注入renderGraph,原被fromJSON重建抹除致功能失效 - FileExplorer.vue: 工程下拉closeDropdown加closest判定,原打不开 附(预存编译阻断顺带修): - df-nodes ai_node_helpers.rs: extract_first_json_object临时值借用悬垂E0716 - MessageList.vue: 删isLastUser死代码(vue-tsc TS6133) 审查产出: docs/05-代码审查/UIUX扩展审查-2026-08-02.md(80条发现,8高grep核验全属实)+ docs/todo.md批次段
This commit is contained in:
@@ -24,7 +24,11 @@ use df_storage::db::Database;
|
||||
// Git 私有 helper(原 tool_registry.rs 986-1080 行原样搬入,仅本模块使用)
|
||||
// ============================================================
|
||||
|
||||
/// 在指定目录执行 git 命令(10s 超时,返回 stdout)。失败/超时返回空字符串(非崩溃)。
|
||||
/// 在指定目录执行 git 命令(10s 超时,返回 (stdout, success))。
|
||||
///
|
||||
/// 返回 (stdout, success):
|
||||
/// - success = child 退出码为 0(status.success());
|
||||
/// - 失败时 stdout 含 stderr 内容(便于上层拼 reason);超时/启动失败 → (空串, false)。
|
||||
///
|
||||
/// BUG-2026-07-18: 原实现 spawn_blocking 内裸 std::process::Command::output() 无 timeout
|
||||
/// (注释谎称"10s 超时")。git 在 OneDrive/网盘/挂载盘/lfs/大仓库场景会卡数十秒到无限,
|
||||
@@ -33,26 +37,50 @@ use df_storage::db::Database;
|
||||
///
|
||||
/// 改用 tokio::process + tokio::time::timeout(10s) + kill_on_drop:超时 drop 时 child 进程
|
||||
/// 被 kill,不泄漏线程/进程,对齐 shell.rs execute 同源封装。
|
||||
async fn exec_git(working_dir: &str, args: &[&str]) -> String {
|
||||
///
|
||||
/// BUG-2026-08-02: 原签名返 String,失败/超时统一吞成空串,使 create/switch/commit 三个写操作
|
||||
/// 恒返成功假象(commit 失败 HEAD 不动,log -1 仍返上次 hash → committed:true)。
|
||||
/// 改返 (String, bool),上层据 success 判定真实成败。
|
||||
async fn exec_git(working_dir: &str, args: &[&str]) -> (String, bool) {
|
||||
let mut cmd = tokio::process::Command::new("git");
|
||||
cmd.args(args)
|
||||
.current_dir(working_dir)
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::piped()) // 捕获 stderr 拼 reason(失败时)
|
||||
.kill_on_drop(true);
|
||||
// Windows 屏蔽控制台窗口弹出。tokio::process::Command 在 Windows 上有 inherent
|
||||
// creation_flags(无需 std::os::windows::process::CommandExt trait,故不 use)。
|
||||
#[cfg(windows)]
|
||||
{
|
||||
cmd.creation_flags(0x0800_0000); // CREATE_NO_WINDOW
|
||||
}
|
||||
match tokio::time::timeout(std::time::Duration::from_secs(10), cmd.output()).await {
|
||||
Ok(Ok(out)) => String::from_utf8_lossy(&out.stdout).to_string(),
|
||||
Ok(Err(_)) => String::new(),
|
||||
Err(_elapsed) => String::new(), // 超时:child 被 kill_on_drop 终止
|
||||
Ok(Ok(out)) => {
|
||||
let success = out.status.success();
|
||||
// 失败时把 stderr 拼进 stdout 返回(上层据 success=false 读 reason)
|
||||
let stdout = String::from_utf8_lossy(&out.stdout).to_string();
|
||||
if success {
|
||||
(stdout, true)
|
||||
} else {
|
||||
let stderr = String::from_utf8_lossy(&out.stderr).to_string();
|
||||
if stderr.is_empty() {
|
||||
(stdout, false)
|
||||
} else {
|
||||
(stderr, false)
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Err(_)) => (String::new(), false),
|
||||
Err(_elapsed) => (String::new(), false), // 超时:child 被 kill_on_drop 终止
|
||||
}
|
||||
}
|
||||
|
||||
/// git status --porcelain 解析为结构化文件列表。
|
||||
/// 返回 (当前分支, 改动文件列表 [{path, status}])
|
||||
async fn run_git_status(working_dir: &str) -> (String, Vec<serde_json::Value>) {
|
||||
let branch = exec_git(working_dir, &["branch", "--show-current"]).await;
|
||||
let branch = exec_git(working_dir, &["branch", "--show-current"]).await.0;
|
||||
let branch = branch.trim().to_string();
|
||||
let raw = exec_git(working_dir, &["status", "--porcelain"]).await;
|
||||
let raw = exec_git(working_dir, &["status", "--porcelain"]).await.0;
|
||||
let files: Vec<serde_json::Value> = raw
|
||||
.lines()
|
||||
.filter(|l| !l.is_empty())
|
||||
@@ -76,12 +104,12 @@ async fn run_git_status(working_dir: &str) -> (String, Vec<serde_json::Value>) {
|
||||
async fn run_git_diff(working_dir: &str, staged: bool) -> serde_json::Value {
|
||||
let mut args = vec!["diff", "--stat"];
|
||||
if staged { args.push("--cached"); }
|
||||
let stat_raw = exec_git(working_dir, &args).await;
|
||||
let stat_raw = exec_git(working_dir, &args).await.0;
|
||||
|
||||
// 每文件 patch(截断防 token 爆)
|
||||
let mut patch_args = vec!["diff" ];
|
||||
if staged { patch_args.push("--cached"); }
|
||||
let patch_raw = exec_git(working_dir, &patch_args).await;
|
||||
let patch_raw = exec_git(working_dir, &patch_args).await.0;
|
||||
// 截断到 8000 字符(防大体量 diff)
|
||||
let patch_truncated = if patch_raw.len() > 8000 {
|
||||
format!("{}\n... (diff 截断,共 {} 字符)", &patch_raw[..8000], patch_raw.len())
|
||||
@@ -99,7 +127,7 @@ async fn run_git_diff(working_dir: &str, staged: bool) -> serde_json::Value {
|
||||
async fn run_git_log(working_dir: &str, limit: usize) -> Vec<serde_json::Value> {
|
||||
let format = "%H|%an|%ad|%s";
|
||||
let limit_str = format!("-{}", limit);
|
||||
let raw = exec_git(working_dir, &["log", "--oneline", &format!("--format={}", format), &limit_str, "--date=short"]).await;
|
||||
let raw = exec_git(working_dir, &["log", "--oneline", &format!("--format={}", format), &limit_str, "--date=short"]).await.0;
|
||||
raw.lines()
|
||||
.filter(|l| !l.is_empty())
|
||||
.filter_map(|line| {
|
||||
@@ -223,7 +251,7 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc<Database>) {
|
||||
let module = repo.get_by_id(module_id).await?
|
||||
.ok_or_else(|| anyhow::anyhow!("工程不存在: {}", module_id))?;
|
||||
// 敏感文件检查:提交前查看待提交文件列表,如有 .env/.key/.pem 则拒绝
|
||||
let status_raw = exec_git(&module.path, &["status", "--porcelain"]).await;
|
||||
let status_raw = exec_git(&module.path, &["status", "--porcelain"]).await.0;
|
||||
let sensitive_patterns = [".env", ".key", ".pem", "id_rsa", ".htpasswd"];
|
||||
for line in status_raw.lines() {
|
||||
let path = line.get(3..).unwrap_or("").trim().to_lowercase();
|
||||
@@ -235,15 +263,24 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc<Database>) {
|
||||
if add_all {
|
||||
let _ = exec_git(&module.path, &["add", "-A"]).await;
|
||||
}
|
||||
// git commit
|
||||
let commit_out = exec_git(&module.path, &["commit", "-m", message]).await;
|
||||
// 提交后取最新 commit hash
|
||||
let hash = exec_git(&module.path, &["log", "-1", "--format=%H"]).await.trim().to_string();
|
||||
// git commit(用 commit 命令的 success 判定 committed,根治假成功)
|
||||
let (commit_out, commit_ok) = exec_git(&module.path, &["commit", "-m", message]).await;
|
||||
// 提交后取最新 commit hash(失败时仍取,留作参考;committed 只认 commit_ok)
|
||||
let hash = exec_git(&module.path, &["log", "-1", "--format=%H"]).await.0.trim().to_string();
|
||||
// 无可提交内容时 commit 退出码非 0 + stderr 含 "nothing to commit"
|
||||
let reason = if !commit_ok && commit_out.contains("nothing to commit") {
|
||||
"nothing to commit, working tree clean".to_string()
|
||||
} else if !commit_ok {
|
||||
commit_out.trim().to_string()
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
Ok(serde_json::json!({
|
||||
"committed": !hash.is_empty(),
|
||||
"committed": commit_ok,
|
||||
"hash": hash,
|
||||
"message": message,
|
||||
"output": commit_out,
|
||||
"reason": reason,
|
||||
"output": if commit_ok { String::new() } else { commit_out },
|
||||
}))
|
||||
}
|
||||
);
|
||||
@@ -271,8 +308,8 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc<Database>) {
|
||||
.ok_or_else(|| anyhow::anyhow!("工程不存在: {}", module_id))?;
|
||||
match action {
|
||||
"list" => {
|
||||
let raw = exec_git(&module.path, &["branch", "--list"]).await;
|
||||
let current = exec_git(&module.path, &["branch", "--show-current"]).await.trim().to_string();
|
||||
let raw = exec_git(&module.path, &["branch", "--list"]).await.0;
|
||||
let current = exec_git(&module.path, &["branch", "--show-current"]).await.0.trim().to_string();
|
||||
let branches: Vec<String> = raw.lines()
|
||||
.map(|l| l.trim_start_matches("* ").trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
@@ -281,14 +318,38 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc<Database>) {
|
||||
}
|
||||
"create" => {
|
||||
if name.is_empty() { anyhow::bail!("create 需要 name 参数"); }
|
||||
let _ = exec_git(&module.path, &["branch", name]).await;
|
||||
Ok(serde_json::json!({ "created": name }))
|
||||
let (msg, ok) = exec_git(&module.path, &["branch", name]).await;
|
||||
if ok {
|
||||
Ok(serde_json::json!({ "created": true, "name": name }))
|
||||
} else {
|
||||
Ok(serde_json::json!({
|
||||
"created": false,
|
||||
"name": name,
|
||||
"reason": msg.trim(),
|
||||
}))
|
||||
}
|
||||
}
|
||||
"switch" => {
|
||||
if name.is_empty() { anyhow::bail!("switch 需要 name 参数"); }
|
||||
let _ = exec_git(&module.path, &["checkout", name]).await;
|
||||
let current = exec_git(&module.path, &["branch", "--show-current"]).await.trim().to_string();
|
||||
Ok(serde_json::json!({ "switched_to": current }))
|
||||
let (_, checkout_ok) = exec_git(&module.path, &["checkout", name]).await;
|
||||
let current = exec_git(&module.path, &["branch", "--show-current"]).await.0.trim().to_string();
|
||||
if checkout_ok && current == name {
|
||||
Ok(serde_json::json!({
|
||||
"switched": true,
|
||||
"current": current,
|
||||
}))
|
||||
} else {
|
||||
Ok(serde_json::json!({
|
||||
"switched": false,
|
||||
"current": current,
|
||||
"name": name,
|
||||
"reason": if current == name {
|
||||
"checkout 未改变分支".to_string()
|
||||
} else {
|
||||
format!("切换失败,当前仍在 {}", if current.is_empty() { "(空)" } else { ¤t })
|
||||
},
|
||||
}))
|
||||
}
|
||||
}
|
||||
_ => anyhow::bail!("未知 action: {},合法值: list/create/switch", action),
|
||||
}
|
||||
@@ -315,10 +376,10 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc<Database>) {
|
||||
let repo = df_storage::crud::ProjectModuleRepo::new(&db);
|
||||
let module = repo.get_by_id(module_id).await?
|
||||
.ok_or_else(|| anyhow::anyhow!("工程不存在: {}", module_id))?;
|
||||
let merge_out = exec_git(&module.path, &["merge", branch]).await;
|
||||
let (merge_out, merge_ok) = exec_git(&module.path, &["merge", branch]).await;
|
||||
let has_conflict = merge_out.contains("CONFLICT") || merge_out.contains("Merge conflict");
|
||||
if has_conflict {
|
||||
let status_raw = exec_git(&module.path, &["status", "--porcelain"]).await;
|
||||
let status_raw = exec_git(&module.path, &["status", "--porcelain"]).await.0;
|
||||
let conflicts: Vec<String> = status_raw.lines()
|
||||
.filter(|l| l.starts_with("UU") || l.starts_with("AA") || l.starts_with("DD"))
|
||||
.map(|l| l.get(3..).unwrap_or("").trim().to_string())
|
||||
@@ -328,12 +389,19 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc<Database>) {
|
||||
"conflicts": conflicts,
|
||||
"message": "合并冲突,需手动解决",
|
||||
}))
|
||||
} else {
|
||||
} else if merge_ok {
|
||||
Ok(serde_json::json!({
|
||||
"merged": true,
|
||||
"branch": branch,
|
||||
"output": merge_out,
|
||||
}))
|
||||
} else {
|
||||
// 非冲突但 merge 退出码非 0(如 fast-forward 失败/被中断),如实暴露失败
|
||||
Ok(serde_json::json!({
|
||||
"merged": false,
|
||||
"branch": branch,
|
||||
"reason": merge_out.trim(),
|
||||
}))
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
@@ -467,13 +467,20 @@ fn run_git_cmd(cwd: &std::path::Path, args: &[&str], timeout: std::time::Duratio
|
||||
let cwd = cwd.to_path_buf();
|
||||
let args = args.iter().map(|s| s.to_string()).collect::<Vec<_>>();
|
||||
std::thread::spawn(move || {
|
||||
let out = Command::new("git")
|
||||
.args(&args)
|
||||
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")
|
||||
.output();
|
||||
.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);
|
||||
});
|
||||
|
||||
|
||||
@@ -248,6 +248,7 @@ pub async fn import_project(
|
||||
return Err("导入路径不能为空".to_string());
|
||||
}
|
||||
if !Path::new(&path).is_dir() {
|
||||
return Err(format!("目录不存在: {}", path));
|
||||
}
|
||||
|
||||
// 解析 name/desc/stack(入参优先,缺省时从目录探测/读 README)。
|
||||
@@ -408,6 +409,7 @@ pub async fn relocate_project_path(
|
||||
new_path: String,
|
||||
) -> Result<ProjectRecord, String> {
|
||||
if !Path::new(&new_path).is_dir() {
|
||||
return Err(format!("目录不存在: {}", new_path));
|
||||
}
|
||||
if let Some(conflict) = find_binding_conflict(&state, &new_path, Some(&id)).await? {
|
||||
return Err(format!("目录已被项目「{}」绑定", conflict.name));
|
||||
@@ -471,6 +473,7 @@ pub async fn scan_directory_for_projects(
|
||||
) -> Result<Vec<ScannedProjectItem>, String> {
|
||||
let root = Path::new(&root_path);
|
||||
if !root.is_dir() {
|
||||
return Err(format!("目录不存在: {}", root_path));
|
||||
}
|
||||
|
||||
// 1. 规则发现(spawn_blocking 防 IO 阻塞 tokio runtime)
|
||||
@@ -706,6 +709,7 @@ pub async fn scan_project_with_ai(
|
||||
) -> Result<AiScanResult, String> {
|
||||
let root = Path::new(&path);
|
||||
if !root.is_dir() {
|
||||
return Err(format!("目录不存在: {}", path));
|
||||
}
|
||||
|
||||
// 1. 规则探测(兜底)+ 采样(纯 IO 轻量,spawn_blocking 防 IO 阻塞 tokio runtime)
|
||||
|
||||
Reference in New Issue
Block a user