新增: 文件浏览器增强(窗口分离/Git 变更/行号/Diff/分页提交历史)

- 窗口分离: FileExplorer 可弹出独立 Tauri 窗口
- 行号显示: 文件预览左侧显示行号列
- 按后缀图标: rs/ts/js/vue/css/html 等 20+ 类型彩色图标
- Diff 视图: 有 Git 变更的文件可切换 diff 红绿视图
- Git 变更面板: 变更文件列表(按目录缩进分组)+提交历史(分页加载)
- 提交详情: 点击提交查看变更文件列表及文件级 diff
- 时间显示: 提交历史显示相对时间/具体日期
- 面包屑导航不丢预览: 导航时不改变当前预览文件
- 刷新保留选中文件: 只清树缓存不清预览
- 中文编码修复: git 命令注入 LANG/LC_ALL 环境变量
- 自适应布局: 弹性 flex 布局,窄窗口适配
- 滚动条优化: 内容区内部滚动,不溢出页面层
- 多工程管理: 工具栏添加工程按钮+弹窗表单
- 审批加载超时缩短: 130s 降至 30s
- 文件变更自动刷新: write_file/patch_file 触发 df-data-changed
This commit is contained in:
2026-06-29 19:25:30 +08:00
parent 63936e3016
commit dfed588b54
20 changed files with 1812 additions and 159 deletions

View File

@@ -30,6 +30,7 @@ pub(super) fn data_change_for_tool(name: &str) -> Option<(&'static str, &'static
"delete_idea" => ("idea", "delete"),
"restore_project" => ("project", "update"),
"bind_directory" => ("project", "update"),
"write_file" | "patch_file" => ("file", "update"),
_ => return None,
};
Some((entity, action))

View File

@@ -206,11 +206,44 @@ pub async fn list_project_modules(
if project_id.is_empty() {
return Err("project_id 不能为空".to_string());
}
state
let modules = state
.project_modules
.list_by_project(&project_id)
.await
.map_err(err_str)
.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,
};
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)
}
// ============================================================
@@ -231,6 +264,8 @@ struct GitChangedFile {
struct GitRecentCommit {
hash: String,
subject: String,
/// Unix 时间戳(秒),供前端格式化显示。
timestamp: i64,
}
/// Git 状态返回结构(无 .git 时各字段空,前端据此显示"非 Git 仓库")。
@@ -332,19 +367,27 @@ fn run_git_status(dir: &str) -> GitStatus {
}
}
// 3) 最近提交:`git log -10 --oneline`(每行 "hash subject")
let mut recent_commits = Vec::new();
if let Some(out) = run_git_cmd(path, &["log", "-10", "--oneline"], timeout) {
for line in out.lines() {
// oneline 格式:"<short-hash> <subject>"
if let Some((hash, subject)) = line.split_once(' ') {
recent_commits.push(GitRecentCommit {
hash: hash.to_string(),
subject: subject.to_string(),
});
// 3) 最近提交:`git log -50 --format="%h %ct %s"`(hash + unix 时间戳 + subject)
let mut recent_commits = Vec::new();
if let Some(out) = run_git_cmd(path, &["log", "-50", "--format=%h %ct %s"], timeout) {
for line in out.lines() {
// format:"<hash> <unix_ts> <subject>"
let line = line.trim();
if line.is_empty() { continue; }
let mut parts = line.splitn(3, ' ');
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 subject = parts.next().unwrap_or("").to_string();
if !hash.is_empty() {
recent_commits.push(GitRecentCommit {
hash,
subject,
timestamp,
});
}
}
}
}
GitStatus {
branch,
@@ -361,6 +404,7 @@ fn run_git_cmd(cwd: &std::path::Path, args: &[&str], timeout: std::time::Duratio
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::<Vec<_>>();
@@ -368,6 +412,9 @@ fn run_git_cmd(cwd: &std::path::Path, args: &[&str], timeout: std::time::Duratio
let out = Command::new("git")
.args(&args)
.current_dir(&cwd)
.env("LANG", "en_US.UTF-8")
.env("LC_ALL", "en_US.UTF-8")
.env("GIT_PAGER", "cat")
.output();
let _ = tx.send(out);
});
@@ -619,6 +666,108 @@ pub async fn read_module_file(
}))
}
/// 查询工程内文件元信息(不读内容,轻量检测变化用)。
/// 返回 { 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<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 = 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<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 = std::path::PathBuf::from(&module.path);
if !root.join(".git").exists() {
return Ok(serde_json::json!({"path": file_path, "diff": ""}));
}
// 跑 git diff <path>(工作树 vs 索引的 unstaged 变更+staged 变更)
// 设置环境变量强制 UTF-8 输出,防中文乱码。
let cmd_dir = root.clone();
let path_arg = file_path.replace('\\', "/");
let diff = std::thread::spawn(move || -> Option<String> {
let out = std::process::Command::new("git")
.args(["diff", "--", &path_arg])
.current_dir(&cmd_dir)
.env("LANG", "en_US.UTF-8")
.env("LC_ALL", "en_US.UTF-8")
.env("GIT_PAGER", "cat")
.output()
.ok()?;
if out.status.success() && !out.stdout.is_empty() {
Some(String::from_utf8_lossy(&out.stdout).to_string())
} else {
None
}
})
.join()
.map_err(|_| "git diff 线程 Join 失败".to_string())?
.unwrap_or_default();
Ok(serde_json::json!({
"path": file_path.replace('\\', "/"),
"diff": diff,
}))
}
/// 采集工程目录 git status --porcelain 的 {posix_rel_path: status} 映射。
/// 非 git 仓库/命令失败 → 空映射(不阻断文件树展示,仅 git_status 字段全 None)。
fn collect_git_status_map(dir: &str) -> HashMap<String, String> {
@@ -655,3 +804,153 @@ fn collect_git_status_map(dir: &str) -> HashMap<String, String> {
}
map
}
/// 查询工程 Git 提交历史(分页,按时间倒序)。
/// 返回 { commits: [{ hash, subject, timestamp }], has_more: bool }。
#[tauri::command]
pub async fn get_module_commits(
state: State<'_, AppState>,
module_id: String,
skip: Option<u32>,
limit: Option<u32>,
) -> 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 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 }));
}
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();
let commits: Vec<serde_json::Value> = std::thread::spawn(move || -> Vec<serde_json::Value> {
let out = std::process::Command::new("git")
.args([
"log",
&format!("--skip={}", skip),
&format!("-{}", fetch_plus),
"--format=%h %ct %s",
])
.current_dir(&dir_for_git)
.env("LANG", "en_US.UTF-8")
.env("LC_ALL", "en_US.UTF-8")
.output()
.ok()
.and_then(|o| if o.status.success() { Some(o.stdout) } else { None })
.map(|b| String::from_utf8_lossy(&b).to_string())
.unwrap_or_default();
let mut commits: Vec<serde_json::Value> = 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(3, ' ');
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 subject = parts.next().unwrap_or("").to_string();
if !hash.is_empty() {
commits.push(serde_json::json!({
"hash": hash,
"subject": subject,
"timestamp": timestamp,
}));
}
}
commits
})
.join()
.map_err(|_| "提交历史查询线程 Join 失败".to_string())?;
// 判断 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,
}))
}
/// 查询某次提交的变更文件列表及全量 diff。
/// 返回 { files: [{ status, path }], diff: string }。
#[tauri::command]
pub async fn get_commit_detail(
state: State<'_, AppState>,
module_id: String,
commit_hash: String,
) -> Result<serde_json::Value, String> {
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();
let (files, diff) = std::thread::spawn(move || -> (Vec<serde_json::Value>, String) {
// 1) 获取变更文件列表:`git diff-tree --no-commit-id -r --name-status <hash>`
let files_out = std::process::Command::new("git")
.args(["diff-tree", "--no-commit-id", "-r", "--name-status", &commit_hash])
.current_dir(&dir)
.env("LANG", "en_US.UTF-8")
.env("LC_ALL", "en_US.UTF-8")
.output()
.ok()
.and_then(|o| if o.status.success() { Some(String::from_utf8_lossy(&o.stdout).to_string()) } else { None })
.unwrap_or_default();
let mut files: Vec<serde_json::Value> = Vec::new();
for line in files_out.lines() {
let line = line.trim();
if line.is_empty() { continue; }
// format:"<status>\t<path>"
if let Some((status, fpath)) = line.split_once('\t') {
files.push(serde_json::json!({
"status": status.trim(),
"path": fpath.trim(),
}));
}
}
// 2) 获取全量 diff:`git show <hash>`
let diff = std::process::Command::new("git")
.args(["show", "--format=", &commit_hash])
.current_dir(&dir)
.env("LANG", "en_US.UTF-8")
.env("LC_ALL", "en_US.UTF-8")
.output()
.ok()
.and_then(|o| if o.status.success() { Some(String::from_utf8_lossy(&o.stdout).to_string()) } else { None })
.unwrap_or_default();
(files, diff)
})
.join()
.map_err(|_| "提交详情查询线程 Join 失败".to_string())?;
Ok(serde_json::json!({
"files": files,
"diff": diff,
}))
}

View File

@@ -328,6 +328,10 @@ pub fn run() {
// 工程文件浏览(Batch 10):文件树 + 单文件预览
commands::module::get_module_file_tree,
commands::module::read_module_file,
commands::module::get_module_file_meta,
commands::module::get_module_file_diff,
commands::module::get_module_commits,
commands::module::get_commit_detail,
// 灵感
commands::idea::list_ideas,
commands::idea::create_idea,