新增: 项目文件浏览器(文件树+内容预览+Git 状态标记)
项目详情页新增文件标签页,用户可在 DevFlow 内浏览项目 文件,查看文件内容和 Git 改动状态,无需切换外部编辑器。 后端接口: - 文件树查询:列工程目录,合并 Git 状态到每个文件 (噪音过滤 node_modules/target/.git 等,路径穿越防御) - 文件读取:支持文本/图片/二进制检测,1MB 上限 前端组件: - 文件树:递归树形展示,懒加载子目录,Git 状态标记 (橙色=已修改/绿色=已新增/灰色=未跟踪) - 文件预览:代码文本/图片/二进制三分支 - 主容器:工程选择(单工程隐藏)+ 面包屑导航 + 刷新 界面文案中英文同步补齐。
This commit is contained in:
@@ -7,8 +7,13 @@
|
||||
//! - **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 serde::{Deserialize, Serialize};
|
||||
@@ -374,3 +379,279 @@ fn run_git_cmd(cwd: &std::path::Path, args: &[&str], timeout: std::time::Duratio
|
||||
_ => 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 s.contains("..") {
|
||||
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 || collect_git_status_map(&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 file_path.contains("..") {
|
||||
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,
|
||||
}))
|
||||
}
|
||||
|
||||
/// 采集工程目录 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
|
||||
}
|
||||
|
||||
@@ -325,6 +325,9 @@ pub fn run() {
|
||||
commands::module::remove_project_module,
|
||||
commands::module::list_project_modules,
|
||||
commands::module::get_module_git_status,
|
||||
// 工程文件浏览(Batch 10):文件树 + 单文件预览
|
||||
commands::module::get_module_file_tree,
|
||||
commands::module::read_module_file,
|
||||
// 灵感
|
||||
commands::idea::list_ideas,
|
||||
commands::idea::create_idea,
|
||||
|
||||
Reference in New Issue
Block a user