新增: 项目文件浏览器(文件树+内容预览+Git 状态标记)

项目详情页新增文件标签页,用户可在 DevFlow 内浏览项目
文件,查看文件内容和 Git 改动状态,无需切换外部编辑器。

后端接口:
- 文件树查询:列工程目录,合并 Git 状态到每个文件
  (噪音过滤 node_modules/target/.git 等,路径穿越防御)
- 文件读取:支持文本/图片/二进制检测,1MB 上限

前端组件:
- 文件树:递归树形展示,懒加载子目录,Git 状态标记
  (橙色=已修改/绿色=已新增/灰色=未跟踪)
- 文件预览:代码文本/图片/二进制三分支
- 主容器:工程选择(单工程隐藏)+ 面包屑导航 + 刷新

界面文案中英文同步补齐。
This commit is contained in:
2026-06-29 01:48:52 +08:00
parent b72df78462
commit 50aad375eb
12 changed files with 1550 additions and 3 deletions

View File

@@ -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
}

View File

@@ -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,