优化: 工程实体化(description/stack/status)+ 概览工程列表
- project_modules 加 description/status 列(V40 迁移,stack V34 已有)+ models/repo/INSERT/UPDATE/SELECT 映射 - FileExplorer 工程 CRUD 弹窗加 description(textarea)/stack(input)/status(select)输入,工程有完整身份 - ProjectDetail 概览工程列表升级:name+status badge+path + description 段(2行省略)+ stack tag - 老工程兼容(None=active),update 部分更新语义,status 归一(active/archived)
This commit is contained in:
@@ -21,6 +21,8 @@ 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;
|
||||
|
||||
@@ -43,6 +45,12 @@ pub struct AddProjectModuleInput {
|
||||
/// 技术栈 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)。
|
||||
@@ -57,6 +65,12 @@ pub struct UpdateProjectModuleInput {
|
||||
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>,
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
@@ -67,6 +81,32 @@ 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
|
||||
// ============================================================
|
||||
@@ -109,6 +149,9 @@ pub async fn add_project_module(
|
||||
// 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
|
||||
@@ -142,8 +185,10 @@ pub async fn update_project_module(
|
||||
&& 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)".to_string());
|
||||
return Err("至少提供一个待更新字段 (name/path/git_url/stack/description/status)".to_string());
|
||||
}
|
||||
|
||||
// 先校验存在(404 友好错误),再整体更新(保留不可变字段)
|
||||
@@ -168,6 +213,14 @@ pub async fn update_project_module(
|
||||
}
|
||||
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
|
||||
@@ -229,6 +282,8 @@ pub async fn list_project_modules(
|
||||
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);
|
||||
@@ -559,9 +614,9 @@ pub async fn get_module_file_tree(
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty());
|
||||
|
||||
// 路径穿越防御:`..` 一律拒(规范化后 canonicalize 再兜底校验仍在工程根子树)。
|
||||
// 路径穿越防御:`..` 段一律拒(规范化后 canonicalize 再兜底校验仍在工程根子树)。
|
||||
if let Some(ref s) = sub {
|
||||
if s.contains("..") {
|
||||
if has_path_traversal(s) {
|
||||
return Err("sub_path 不允许包含 ..".to_string());
|
||||
}
|
||||
}
|
||||
@@ -686,8 +741,8 @@ pub async fn read_module_file(
|
||||
if file_path.is_empty() {
|
||||
return Err("file_path 不能为空".to_string());
|
||||
}
|
||||
// 路径穿越防御:`..` 一律拒。
|
||||
if file_path.contains("..") {
|
||||
// 路径穿越防御:`..` 段一律拒。
|
||||
if has_path_traversal(&file_path) {
|
||||
return Err("file_path 不允许包含 ..".to_string());
|
||||
}
|
||||
|
||||
@@ -760,7 +815,7 @@ pub async fn get_module_file_meta(
|
||||
if file_path.is_empty() {
|
||||
return Err("file_path 不能为空".to_string());
|
||||
}
|
||||
if file_path.contains("..") {
|
||||
if has_path_traversal(&file_path) {
|
||||
return Err("file_path 不允许包含 ..".to_string());
|
||||
}
|
||||
let module = state
|
||||
@@ -805,7 +860,7 @@ pub async fn get_module_file_diff(
|
||||
if file_path.is_empty() {
|
||||
return Err("file_path 不能为空".to_string());
|
||||
}
|
||||
if file_path.contains("..") {
|
||||
if has_path_traversal(&file_path) {
|
||||
return Err("file_path 不允许包含 ..".to_string());
|
||||
}
|
||||
let module = state
|
||||
@@ -838,8 +893,11 @@ pub async fn get_module_file_diff(
|
||||
}))
|
||||
}
|
||||
|
||||
/// 扫描项目绑定目录下的子仓库(含 .git 的直接子目录),自动创建工程记录。
|
||||
/// 返回新创建的工程数量。幂等:已存在的路径不重复创建。
|
||||
/// 扫描项目绑定目录下的一级子目录,自动创建工程记录。
|
||||
///
|
||||
/// 判定为工程的信号:① 含 .git(独立仓库)② detect_stack 命中(有 package.json / go.mod /
|
||||
/// Cargo.toml 等工程标志文件)。两者满足其一即识别;两者都无(纯普通文件夹)忽略。
|
||||
/// 无 .git 的工程 git_url 留空。返回新创建的工程数量;幂等(已存在路径不重复创建)。
|
||||
#[tauri::command]
|
||||
pub async fn scan_project_modules(
|
||||
state: State<'_, AppState>,
|
||||
@@ -885,23 +943,42 @@ pub async fn scan_project_modules(
|
||||
continue;
|
||||
}
|
||||
let child_path = entry.path();
|
||||
// 必须含 .git(独立仓库)
|
||||
if !child_path.join(".git").exists() { continue; }
|
||||
let child_path_str = child_path.to_string_lossy().replace("\\", "/");
|
||||
if existing_paths.contains(&child_path_str.to_lowercase()) { continue; }
|
||||
// 获取远程地址(失败忽略);spawn_blocking + run_git_cmd(10s 超时,防 git 卡死阻塞 runtime)
|
||||
let url_dir = child_path.clone();
|
||||
let git_url = 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())
|
||||
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(),
|
||||
@@ -909,11 +986,13 @@ pub async fn scan_project_modules(
|
||||
name: name.clone(),
|
||||
path: child_path.to_string_lossy().to_string(),
|
||||
git_url,
|
||||
stack: None,
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user