新增: 文件浏览器增强(窗口分离/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

@@ -1,7 +1,7 @@
# DevFlow 批次推进记录
> 记录每个批次的提交 hash、改动内容和交付价值。
> 最后更新: 2026-06-29
> 最后更新: 2026-06-29 | 最新提交: `c6df455`
---
@@ -166,6 +166,29 @@
---
## Batch 13 — 文件浏览器增强(行号/图标/Diff/Git 变更面板/窗口分离)
- **提交**: `c6df455`
- **内容**:
- **行号显示**: 文件预览左侧行号列
- **后缀图标**: 20+ 文件类型彩色 SVG 图标(rs/ts/js/vue/css/html/json/md/sh/sql 等)
- **Diff 视图**: 有 Git 变更的文件可切换内容/Diff 红绿视图
- **Git 变更面板**: 文件树侧栏切换至变更视图,变更文件列表(按目录缩进分组)+提交历史(分页 50 条)
- **提交详情**: 点击提交行展开变更文件列表,点文件查看该文件 diff
- **提交时间显示**: 相对时间(刚刚/N分钟前)+超过 30 天显示具体日期
- **窗口分离**: FileExplorer 可弹出独立 Tauri 窗口
- **中文编码修复**: git 命令注入 LANG/LC_ALL UTF-8 环境变量,修复中文乱码
- **多工程管理 UI**: 工具栏添加工程按钮+弹窗表单
- **文件变更自动刷新**: write_file/patch_file 触发 df-data-changed 事件,AI 写入后自动刷新
- **面包屑不丢预览**: 面包屑导航时保留当前预览文件
- **刷新保留选中文件**: 只清树缓存不清预览
- **自适应布局**: 弹性 flex 布局,窄窗口自适应
- **审批超时 130s->30s**: 按钮级 loading 超时缩短
- **阶段进度条隐藏**: 装饰性元素,待接入真实状态机后启用
- **验证**: cargo check + vue-tsc + vite build 通过
---
## 未纳入批次的其他会话改动
- `src/composables/ai/aiShared.ts` + `src/stores/ai.ts` — 循环依赖修复Batch 6 补丁 `cc3ef5b` 纳入提交)

View File

@@ -1,6 +1,6 @@
# 工程系统设计 — 项目多工程 + Git 状态 + 文件浏览
> 创建: 2026-06-29 | 状态: ✅ Batch 9-12 已落地(工程表+文件浏览器+Git AI 工具)
> 创建: 2026-06-29 | 状态: ✅ Batch 9-13 已落地(工程表+文件浏览器+Git 状态/变更/提交历史)
> 关联: [项目知识图谱与任务队列系统-2026-06-26.md](./项目知识图谱与任务队列系统-2026-06-26.md) / ARCHITECTURE.md
---
@@ -118,23 +118,41 @@ get_module_file_tree(module_id, sub_path?, depth?) → FileTreeResponse
噪音过滤:跳过 node_modules / target / .git / __pycache__ / dist / .vite。
### 5.2 UI 结构
### 5.2 UI 结构(当前)
```
项目详情页 → 文件 Tab
┌────────────────────────────────────────────────┐
文件树 │ 文件内容预览 │
│ 📁 src/ │ 1 use std::...;
main.rs M2 fn main() {
config.rs │ 3 println!("hello");
📁 tests/ 4 }
┌─────────────────────────┬──────────────────────────────────────┐
[📁] [📝 3] 视图切换 │ 文件内容预览
─────────────────────── │
│ 📁 文件树/变更列表 │ 行号 │ 代码(语法高亮 + 行号)
src/ 1 │ use std::...;
main.rs M │ 2 │ fn main() {
config.rs │ 3 println!("hello");
│ tests/ │ 4 │ } │
│ Cargo.toml A │ │
│ │ main.rs · 2.1 KB · 已修改
└────────────────────────────────────────────────┘
│ main.rs · 2.1 KB · 📝 查看变更
└─────────────────────────┴──────────────────────────────────────┘
```
Git 状态标记:M(橙色=已修改) / A(绿色=已新增) / ??(灰色=未跟踪)。
**视图切换**: 左侧栏顶部两个图标按钮:
- 📁 文件树视图(默认)
- 📝 变更视图(带变更数量角标)
**变更视图**:
- 变更文件列表(按状态分组:已修改/已新增/已删除/未跟踪)
- 文件按目录深度缩进(src/components/Foo.vue → 缩进 2 层)
- 点击文件 → 右侧预览区显示 git diff(红删绿增)
- 顶部 Tab 切换至"历史":展示提交历史(每条显示 hash+主题+时间)
- 初始 50 条,支持"加载更多"分页
- 点击提交行 → 展开该提交的变更文件列表
- 点击变更文件 → 展示该文件的 diff
**内容预览增强**:
- 行号列 + highlight.js 语法高亮(按文件后缀自动选语言)
- 按后缀彩色图标(rs/ts/js/vue/css/html/json/md/sh/sql 等)
- Diff 切换按钮(Git 状态不为空时在头部右侧显示 📝)
- 滚动条在预览区域内部,不溢出页面层
### 5.3 单工程退化
@@ -187,3 +205,20 @@ create_project 时自动建一个工程:
| Batch 10 | 文件浏览器 UI(工程选择 + 文件树 + Git 状态标记 + 内容预览) |
| Batch 11 | Git 只读 AI 工具(status/diff/log) |
| Batch 12 | Git 写 AI 工具(commit/branch/merge) |
| Batch 13 | 文件浏览器增强(行号/图标/Diff/变更面板/窗口分离) |
### Batch 13 新增功能
| 功能 | 说明 |
|---|---|
| 行号显示 | 文件预览左侧显示行号列 |
| 后缀图标 | rs/ts/js/vue/css/html 等 20+ 类型彩色 SVG 图标 |
| Diff 视图 | 有 Git 变更的文件可切换内容/Diff 红绿视图 |
| Git 变更面板 | 文件树侧栏可切换至变更视图,显示变更文件列表(按目录缩进分组) |
| 提交历史分页 | 展示最近 50 条提交,支持加载更多(分页) |
| 提交详情 | 点击提交行展开变更文件列表,点文件查看 Diff |
| 提交时间显示 | 相对时间(刚刚/N分钟前)+超过30天显示具体日期 |
| 窗口分离 | 文件浏览器可弹出独立 Tauri 窗口 |
| 中文编码修复 | git 命令注入 LANG/LC_ALL UTF-8 环境变量 |
| 多工程管理 UI | 工具栏添加工程按钮 + 弹窗表单(名称/路径/Git地址) |
| 文件变更自动刷新 | write_file/patch_file 触发 df-data-changed 事件推送 |

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,15 +367,23 @@ fn run_git_status(dir: &str) -> GitStatus {
}
}
// 3) 最近提交:`git log -10 --oneline`(每行 "hash subject")
// 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", "-10", "--oneline"], timeout) {
if let Some(out) = run_git_cmd(path, &["log", "-50", "--format=%h %ct %s"], timeout) {
for line in out.lines() {
// oneline 格式:"<short-hash> <subject>"
if let Some((hash, subject)) = line.split_once(' ') {
// 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: hash.to_string(),
subject: subject.to_string(),
hash,
subject,
timestamp,
});
}
}
@@ -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,

View File

@@ -49,7 +49,7 @@ import AppLayout from './components/layout/AppLayout.vue'
import { aiApi } from '@/api'
const route = useRoute()
const isDetached = computed(() => route.path === '/ai-detached')
const isDetached = computed(() => route.path === '/ai-detached' || route.path === '/file-explorer-detached')
const aiStore = useAiStore()
const appSettings = useAppSettingsStore()

View File

@@ -63,6 +63,8 @@ export interface GitChangedFile {
export interface GitRecentCommit {
hash: string
subject: string
/** Unix 时间戳(秒) */
timestamp: number
}
/** getModuleGitStatus 返回结构。 */
@@ -74,6 +76,17 @@ export interface GitStatusResult {
}
export const moduleApi = {
/** 新增工程(返回完整记录)。 */
addProjectModule(input: {
projectId: string
name: string
path: string
gitUrl?: string | null
stack?: string | null
}): Promise<ProjectModuleRecord> {
return invoke('add_project_module', { input })
},
/** 列出项目全部工程(按 sort_order ASC)。 */
listProjectModules(projectId: string): Promise<ProjectModuleRecord[]> {
return invoke('list_project_modules', { projectId })
@@ -104,4 +117,30 @@ export const moduleApi = {
readModuleFile(moduleId: string, filePath: string): Promise<ReadFileResult> {
return invoke('read_module_file', { moduleId, filePath })
},
/** 查询工程内文件元信息(不读内容,前端检测外部变化用)。返回 { path, size, modified_at }。 */
getModuleFileMeta(moduleId: string, filePath: string): Promise<{ path: string; size: number; modified_at: number }> {
return invoke('get_module_file_meta', { moduleId, filePath })
},
/** 查询工程内文件的 git diff(轻量,不读文件内容)。返回 { path, diff }。 */
getModuleFileDiff(moduleId: string, filePath: string): Promise<{ path: string; diff: string }> {
return invoke('get_module_file_diff', { moduleId, filePath })
},
/** 分页查询工程 Git 提交历史。返回 { commits, has_more }。 */
getModuleCommits(moduleId: string, skip?: number, limit?: number): Promise<{
commits: { hash: string; subject: string; timestamp: number }[]
has_more: boolean
}> {
return invoke('get_module_commits', { moduleId, skip: skip ?? 0, limit: limit ?? 50 })
},
/** 查询某次提交的变更文件列表及 diff。返回 { files: [{ status, path }], diff: string }。 */
getCommitDetail(moduleId: string, commitHash: string): Promise<{
files: { status: string; path: string }[]
diff: string
}> {
return invoke('get_commit_detail', { moduleId, commitHash })
},
}

View File

@@ -31,18 +31,44 @@
</div>
<div class="toolbar-right">
<button class="btn btn-ghost btn-sm" type="button" title="添加工程" @click="showAddModule = true">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
</button>
<button class="btn btn-ghost btn-sm" type="button" :disabled="refreshing" @click="refresh">
<span v-if="refreshing" class="spinner"></span>
{{ refreshing ? $t('fileExplorer.refreshing') : $t('fileExplorer.refresh') }}
</button>
<button class="btn btn-ghost btn-sm" type="button" title="弹出到独立窗口" @click="onDetach">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M15 3h6v6"/>
<path d="M10 14L21 3"/>
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
</svg>
</button>
</div>
</div>
<!-- 主体:文件树 + 预览 -->
<!-- 主体:文件树/变更列表 + 预览 -->
<div class="explorer-body">
<!-- :文件树 -->
<div class="explorer-tree">
<!-- :视图切换按钮(文件树 / 变更) -->
<div class="explorer-sidebar">
<div class="explorer-view-tabs">
<button class="view-tab" :class="{ active: viewMode === 'tree' }" @click="viewMode = 'tree'">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
</button>
<button class="view-tab" :class="{ active: viewMode === 'changes' }" @click="switchToChanges">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>
<span v-if="changedCount > 0" class="changed-badge">{{ changedCount }}</span>
</button>
</div>
<!-- 文件树视图 -->
<div v-show="viewMode === 'tree'" class="explorer-tree">
<div v-if="!currentModuleId" class="tree-loading">
<span class="spinner"></span>
</div>
<FileTree
v-else
:module-id="currentModuleId"
sub-path=""
:indent="12"
@@ -55,14 +81,51 @@
/>
</div>
<!-- :文件预览 -->
<!-- Git 变更视图 -->
<div v-show="viewMode === 'changes'" class="explorer-changes">
<GitChanges
v-if="currentModuleId"
:module-id="currentModuleId"
@select-file="onChangeFileSelect"
/>
</div>
</div>
<!-- :文件预览(moduleId 为空时不渲染,避免空 moduleId IPC 调用报错) -->
<div class="explorer-preview">
<FilePreview
v-if="currentModuleId"
:module-id="currentModuleId"
:file-path="selectedFilePath"
:module-root-path="currentModule?.path ?? ''"
:git-status="selectedFileGitStatus"
/>
<div v-else class="preview-placeholder-inline">{{ $t('fileExplorer.selectFileHint') }}</div>
</div>
</div>
</div>
<!-- 新增工程弹窗 -->
<div v-if="showAddModule" class="modal-overlay" @click.self="showAddModule = false">
<div class="modal-box modal-box--sm">
<h3 class="modal-title">{{ $t('fileExplorer.addModule') }}</h3>
<div class="modal-field">
<label>{{ $t('fileExplorer.moduleName') }}</label>
<input v-model="newModuleName" :placeholder="$t('fileExplorer.moduleNamePlaceholder')" />
</div>
<div class="modal-field">
<label>{{ $t('fileExplorer.modulePath') }}</label>
<input v-model="newModulePath" :placeholder="$t('fileExplorer.modulePathPlaceholder')" />
</div>
<div class="modal-field">
<label>{{ $t('fileExplorer.moduleGitUrl') }} <span class="opt-label">{{ $t('common.optional') }}</span></label>
<input v-model="newModuleGitUrl" :placeholder="$t('fileExplorer.moduleGitUrlPlaceholder')" />
</div>
<div class="modal-actions">
<button class="btn btn-ghost" @click="showAddModule = false">{{ $t('common.cancel') }}</button>
<button class="btn btn-primary" :disabled="addingModule || !newModuleName.trim() || !newModulePath.trim()" @click="onAddModule">
{{ addingModule ? $t('fileExplorer.adding') : $t('fileExplorer.addModule') }}
</button>
</div>
</div>
</div>
@@ -81,18 +144,30 @@
* 单工程自动选中;多工程默认选首个 + 提供下拉切换。
*/
import { ref, computed, watch, reactive } from 'vue'
import { moduleApi, type ProjectModuleRecord, type FileTreeEntry } from '@/api/module'
import { useI18n } from 'vue-i18n'
import { moduleApi, type ProjectModuleRecord, type FileTreeEntry, type GitStatusResult } from '@/api/module'
import FileTree from './FileTree.vue'
import FilePreview from './FilePreview.vue'
import GitChanges from './GitChanges.vue'
import { detachFileExplorer } from '@/composables/project/useFileExplorerWindow'
const props = defineProps<{
projectId: string
}>()
const { t } = useI18n()
const modules = ref<ProjectModuleRecord[]>([])
const currentModuleId = ref<string>('')
const refreshing = ref(false)
// 新增工程弹窗
const showAddModule = ref(false)
const addingModule = ref(false)
const newModuleName = ref('')
const newModulePath = ref('')
const newModuleGitUrl = ref('')
// 树状态(顶层持有,递归子树共享 props)。
const expandedPaths = reactive(new Set<string>())
const loadedChildren = reactive(new Map<string, FileTreeEntry[]>())
@@ -133,7 +208,7 @@ async function loadModules() {
}
}
/** 重置树状态(切工程/刷新时调用)。 */
/** 重置树状态(切工程时调用,清展开/缓存/选中文件;刷新时保留选中文件由 refresh 单独处理)。 */
function resetTreeState() {
expandedPaths.clear()
loadedChildren.clear()
@@ -178,27 +253,25 @@ function onLoadChildren(_path: string) {
// 占位:FileTree 内部已自拉并缓存到 loadedChildren;此处保留事件入口便于未来扩展(如统一节流)。
}
/** 刷新根目录(清缓存 + FileTree 因 loadedChildren 失效自重拉)。 */
/** 刷新根目录(清展开/缓存,保留已打开的文件预览不变)。 */
async function refresh() {
refreshing.value = true
resetTreeState()
// 仅清树状态(展开目录 + 缓存条目),不清 selectedFilePath/selectedFileGitStatus,
// 让用户刷新的同时仍能看到正在查看的文件内容。
expandedPaths.clear()
loadedChildren.clear()
// 等一个 tick 让 FileTree watch 触发重拉;实际拉取在子组件,这里只做状态清空。
await new Promise((r) => setTimeout(r, 50))
refreshing.value = false
}
/** 面包屑 → 根。 */
/** 面包屑 → 根(仅清展开状态,保留当前打开的预览文件)。 */
function goRoot() {
selectedFilePath.value = null
selectedFileGitStatus.value = undefined
expandedPaths.clear()
}
/** 面包屑 → 某段(展开父链 + 选中该目录)。 */
/** 面包屑 → 某段(展开父链使该目录可见,不改变当前预览文件)。 */
function goBreadcrumb(path: string) {
// 选中该路径作为"焦点"(文件树保持展开,仅清当前文件选中,面包屑切到此目录)。
selectedFilePath.value = path
selectedFileGitStatus.value = undefined
// 确保父链展开(否则面包屑跳转后看不到该目录)。
const parts = path.split('/').filter(Boolean)
let acc = ''
@@ -208,7 +281,58 @@ function goBreadcrumb(path: string) {
}
}
/** 弹出文件浏览器到独立窗口。 */
function onDetach() {
detachFileExplorer(props.projectId, currentModule.value?.name || t('fileExplorer.detachTitle'))
}
// ── 视图模式切换:文件树 / Git 变更 ──
const viewMode = ref<'tree' | 'changes'>('tree')
const gitStatusData = ref<GitStatusResult | null>(null)
const changedCount = computed(() => gitStatusData.value?.changed_files.length ?? 0)
async function switchToChanges() {
viewMode.value = 'changes'
if (!currentModuleId.value || gitStatusData.value) return
try {
gitStatusData.value = await moduleApi.getModuleGitStatus(currentModuleId.value)
} catch {
gitStatusData.value = null
}
}
/** Git 变更视图中选择文件 → 在右侧预览显示 diff。 */
function onChangeFileSelect(path: string) {
selectedFilePath.value = path
selectedFileGitStatus.value = gitStatusData.value?.changed_files.find(f => f.path === path)?.status
}
watch(() => props.projectId, loadModules, { immediate: true })
/** 提交新增工程。 */
async function onAddModule() {
if (addingModule.value) return
addingModule.value = true
try {
await moduleApi.addProjectModule({
projectId: props.projectId,
name: newModuleName.value.trim(),
path: newModulePath.value.trim(),
gitUrl: newModuleGitUrl.value.trim() || null,
})
showAddModule.value = false
newModuleName.value = ''
newModulePath.value = ''
newModuleGitUrl.value = ''
// 刷新工程列表
await loadModules()
} catch (e) {
console.error('[FileExplorer] 添加工程失败:', e)
} finally {
addingModule.value = false
}
}
</script>
<style scoped>
@@ -216,7 +340,6 @@ watch(() => props.projectId, loadModules, { immediate: true })
display: flex;
flex-direction: column;
height: 100%;
min-height: 480px;
background: var(--df-bg);
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius-lg, 8px);
@@ -330,26 +453,164 @@ watch(() => props.projectId, loadModules, { immediate: true })
font-size: 11px;
}
/* 主体两栏 */
/* 主体两栏 — 自适应比例 30% / 70% */
.explorer-body {
flex: 1;
display: flex;
overflow: hidden;
}
.explorer-tree {
width: 320px;
min-width: 240px;
max-width: 480px;
overflow: auto;
padding: 8px 4px 8px 0;
/* 左侧边栏(视图切换 + 内容) */
.explorer-sidebar {
width: 30%;
min-width: 200px;
max-width: 420px;
display: flex;
flex-direction: column;
border-right: 0.5px solid var(--df-border);
flex-shrink: 0;
overflow: hidden;
}
/* 视图切换 Tab */
.explorer-view-tabs {
display: flex;
border-bottom: 0.5px solid var(--df-border);
flex-shrink: 0;
}
.view-tab {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 6px 0;
background: none;
border: none;
border-bottom: 2px solid transparent;
color: var(--df-text-dim);
cursor: pointer;
font-size: 11px;
transition: all 0.15s;
position: relative;
}
.view-tab:hover {
color: var(--df-text);
background: rgba(255,255,255,0.03);
}
.view-tab.active {
color: var(--df-text);
border-bottom-color: var(--df-accent);
}
.changed-badge {
position: absolute;
top: 3px;
right: 8px;
min-width: 14px;
height: 14px;
padding: 0 4px;
border-radius: 7px;
background: var(--df-accent);
color: #fff;
font-size: 9px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
}
.explorer-tree {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
padding: 8px 4px 8px 0;
scrollbar-width: thin;
}
.explorer-changes {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
min-height: 0;
scrollbar-width: thin;
}
.explorer-changes .gc-body {
overflow-y: auto;
overflow-x: hidden;
scrollbar-width: thin;
}
.tree-loading {
display: flex;
justify-content: center;
align-items: center;
height: 100%;
}
.tree-loading .spinner {
width: 20px;
height: 20px;
border: 2px solid var(--df-border, #444);
border-top-color: var(--df-accent, #3a8);
border-radius: 50%;
animation: df-spin 0.8s linear infinite;
}
.explorer-preview {
flex: 1;
min-width: 0;
overflow: hidden;
scrollbar-width: thin;
}
.preview-placeholder-inline {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: var(--df-text-dim);
font-size: 13px;
min-height: 200px;
}
/* 全局滚动条样式(薄) */
.explorer-sidebar ::-webkit-scrollbar {
width: 4px;
height: 4px;
}
.explorer-sidebar ::-webkit-scrollbar-thumb {
background: var(--df-border);
border-radius: 2px;
}
.explorer-sidebar ::-webkit-scrollbar-track {
background: transparent;
}
/* 窄窗口自适应 */
@media (max-width: 900px) {
.explorer-sidebar {
width: 240px;
min-width: 160px;
max-width: 280px;
}
}
@media (max-width: 600px) {
.explorer-toolbar {
flex-wrap: wrap;
gap: 6px;
}
.explorer-sidebar {
width: 180px;
min-width: 120px;
}
}
/* spinner(刷新按钮内) */

View File

@@ -1,11 +1,10 @@
<template>
<!--
文件内容预览(Batch 10)
- 文本/代码:<pre><code> 展示(暂不做语法高亮,后续可接 highlight.js / monaco)
- 图片:<img> 展示( tauri:// 或 convertFileSrc)
文件内容预览
- 文本/代码: highlight.js 语法高亮(按文件后缀自动选语言),git 变更可切 diff 视图
- 图片: <img> 展示( convertFileSrc)
- 二进制: 提示"二进制文件不支持预览"
- 顶部:文件路径 + 大小 + Git 状态
- 加载态:spinner
- 顶部: 文件路径 + 大小 + Git 状态 + Diff 切换按钮
-->
<div class="file-preview">
<!-- 顶部元信息 -->
@@ -19,6 +18,16 @@
<div class="preview-meta-right">
<span v-if="fileSize !== null" class="preview-size">{{ formatSize(fileSize) }}</span>
<span v-if="truncated" class="preview-truncated"> {{ $t('fileExplorer.truncated') }}</span>
<!-- Diff 切换按钮( git 有变更时显示) -->
<button
v-if="gitStatus"
class="diff-toggle-btn"
:class="{ active: showDiff }"
@click="toggleDiff"
:title="showDiff ? $t('fileExplorer.showContent') : $t('fileExplorer.showDiff')"
>
{{ showDiff ? '📄' : '📝' }}
</button>
</div>
</div>
@@ -49,39 +58,49 @@
<img :src="imageUrl ?? ''" :alt="filePath ?? ''" />
</div>
<!-- 文本/代码 -->
<pre v-else class="preview-code"><code>{{ content }}</code></pre>
<!-- Markdown 渲染(marked + DOMPurify + 代码高亮) -->
<div v-else-if="isMarkdown" class="preview-md ai-md" v-html="renderedMd"></div>
<!-- Diff 视图(切到 diff 模式时显示) -->
<div v-else-if="showDiff && diffContent" class="preview-diff">
<div v-for="(ln, idx) in diffLines" :key="idx" class="diff-line" :class="'diff-' + ln.type">
<span class="diff-line-num">{{ ln.oldNum || '' }}</span>
<span class="diff-line-num">{{ ln.newNum || '' }}</span>
<span class="diff-line-prefix">{{ ln.prefix }}</span>
<span class="diff-line-text">{{ ln.text }}</span>
</div>
<div v-if="diffLoading" class="diff-loading">
<span class="spinner"></span>
</div>
</div>
<!-- 文本/代码(highlight.js 语法高亮 + 行号; diff 模式时显示) -->
<div v-else class="preview-code-wrap">
<div class="preview-line-numbers" ref="lineNumRef">
<div v-for="n in lineCount" :key="n" class="preview-line-num" :class="{ 'is-active': n === activeLine }">{{ n }}</div>
</div>
<pre class="preview-code"><code :class="hljsClass" v-html="htmlContent"></code></pre>
</div>
</div>
</div>
</template>
<script setup lang="ts">
/**
* 文件预览组件。
*
* 调用 readModuleFile IPC 拉内容,按返回的 is_binary / 路径后缀决定渲染分支:
* - is_binary=true → 二进制提示分支
* - 后缀命中图片白名单 → convertFileSrc 走 tauri 资源协议直显(避免大图 base64 撑爆内存)
* - 其余 → 文本/代码分支(<pre><code> 保留空白,等宽字体)
*
* convertFileSrc 用法:把本机绝对文件路径转成 tauri://localhost/... 资源 URL,
* 前端 <img> 可直接加载(Tauri 配置需允许该路径;默认 dev/release 均放行应用数据目录,
* 工程目录通常不在白名单 → 见下方注意事项,降级用 readModuleFile 拿字节 + dataURL)。
*/
import { ref, watch, computed, onUnmounted } from 'vue'
import { moduleApi } from '@/api/module'
import hljs from 'highlight.js/lib/common'
import { useMarkdown, useRendered } from '@/composables/useMarkdown'
const props = defineProps<{
moduleId: string
/** 相对工程根的文件路径;空/null = 未选文件(显示占位)。 */
filePath: string | null
/** 工程根绝对路径(用于图片 convertFileSrc 拼绝对路径)。 */
moduleRootPath: string
/** 该文件的 git 状态(来自文件树条目,可选)。 */
gitStatus?: string
}>()
const { } = useMarkdown()
const content = ref('')
const htmlContent = ref('')
const loading = ref(false)
const error = ref<string | null>(null)
const isBinary = ref(false)
@@ -89,25 +108,123 @@ const fileSize = ref<number | null>(null)
const truncated = ref(false)
const imageUrl = ref<string | null>(null)
/** 图片后缀白名单(命中即走图片分支)。 */
/** 行号显示 — 根据 highlight.js 输出的 HTML 行数计算。 */
const lineCount = computed(() => {
if (!htmlContent.value) return 0
// highlight.js 用 \n 分隔行,计算行数
const lines = htmlContent.value.split('\n')
return lines.length
})
/** 当前激活行(预留:后续可点击行号跳转/选中高亮)。 */
const activeLine = ref<number | null>(null)
const lineNumRef = ref<HTMLElement | null>(null)
/** Diff 显示控制 */
const showDiff = ref(false)
const diffContent = ref('')
const diffLoading = ref(false)
interface DiffLine {
type: 'add' | 'del' | 'ctx' | 'hdr'
prefix: string
text: string
oldNum: string
newNum: string
}
const diffLines = computed<DiffLine[]>(() => {
if (!diffContent.value) return []
const lines: DiffLine[] = []
for (const raw of diffContent.value.split('\n')) {
if (raw.startsWith('@@')) {
lines.push({ type: 'hdr', prefix: '', text: raw, oldNum: '', newNum: '' })
} else if (raw.startsWith('+')) {
lines.push({ type: 'add', prefix: '+', text: raw.slice(1), oldNum: '', newNum: '' })
} else if (raw.startsWith('-')) {
lines.push({ type: 'del', prefix: '-', text: raw.slice(1), oldNum: '', newNum: '' })
} else if (raw.startsWith('\\')) {
// No newline at end of file 等元信息
lines.push({ type: 'ctx', prefix: '', text: raw, oldNum: '', newNum: '' })
} else {
lines.push({ type: 'ctx', prefix: ' ', text: raw, oldNum: '', newNum: '' })
}
}
return lines
})
function toggleDiff() {
showDiff.value = !showDiff.value
if (showDiff.value && !diffContent.value) {
loadDiff()
}
}
async function loadDiff() {
if (!props.moduleId || !props.filePath) return
diffLoading.value = true
try {
const res = await moduleApi.getModuleFileDiff(props.moduleId, props.filePath)
diffContent.value = res.diff || ''
} catch {
diffContent.value = ''
} finally {
diffLoading.value = false
}
}
/** 外部文件变更 — 通过 df-data-changed 事件接收 AI 工具写入通知,被动触发重载。
* 无轮询,零性能开销。外部编辑器修改需用户点击刷新按钮。 */
/** 文件后缀 → highlight.js 语言名映射。 */
const EXT_LANG: Record<string, string> = {
rs: 'rust', ts: 'typescript', tsx: 'tsx', js: 'javascript', jsx: 'jsx',
vue: 'xml', html: 'xml', xml: 'xml', css: 'css', scss: 'scss',
json: 'json', toml: 'ini', yaml: 'yaml', yml: 'yaml',
md: 'markdown', py: 'python', go: 'go', java: 'java',
sh: 'bash', sql: 'sql',
}
/** 从文件路径推断 highlight.js 语言 class。 */
const hljsClass = computed(() => {
if (!props.filePath) return 'language-plaintext'
const ext = props.filePath.split('.').pop()?.toLowerCase() ?? ''
const lang = EXT_LANG[ext] || 'plaintext'
return `language-${lang}`
})
/** 图片后缀白名单。 */
const IMAGE_EXT = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.svg', '.ico']
/** Markdown 后缀判定。 */
const isMarkdown = computed(() => {
if (!props.filePath) return false
return props.filePath.toLowerCase().endsWith('.md')
})
/** Markdown 渲染(复用 useRendered,含 marked + DOMPurify + 代码高亮)。 */
const { rendered: renderedMd, ensureLoaded: ensureMdLoaded } = useRendered(() => content.value)
const isImage = computed(() => {
if (!props.filePath) return false
const lower = props.filePath.toLowerCase()
return IMAGE_EXT.some((ext) => lower.endsWith(ext))
})
/** 拉文件内容。图片走 blob URL(避免 convertFileSrc 受路径白名单限制)。 */
/** 拉文件内容 + 高亮渲染。 */
async function loadFile() {
if (!props.filePath) {
content.value = ''
htmlContent.value = ''
imageUrl.value = null
return
}
loading.value = true
error.value = null
// 释放上一张图的 blob URL(防内存泄漏)。
// Markdown 文件触发预热(确保 marked 就绪,useRendered computed 据此渲染)
if (props.filePath.toLowerCase().endsWith('.md')) {
ensureMdLoaded()
}
if (imageUrl.value) {
URL.revokeObjectURL(imageUrl.value)
imageUrl.value = null
@@ -119,17 +236,14 @@ async function loadFile() {
isBinary.value = res.is_binary
if (res.is_binary) {
content.value = ''
htmlContent.value = ''
return
}
if (isImage.value) {
// 图片:把文本内容(其实后端对图片也走 lossy 转 string,这里不可靠)→ 改走
// convertFileSrc 拿到本机资源 URL。失败时 fallback 到文本分支(至少不崩)。
// 注:Tauri 默认放行 app data dir;工程目录若不在白名单会显示 broken image,
// 这是已知降级,后续可通过 tauri.conf.json 的 assetProtocol 放行工程目录。
content.value = ''
htmlContent.value = ''
try {
const { convertFileSrc } = await import('@tauri-apps/api/core')
// 拼绝对路径:moduleRootPath + filePath(POSIX → 平台分隔符)。
const abs = joinPath(props.moduleRootPath, props.filePath)
imageUrl.value = convertFileSrc(abs)
} catch {
@@ -137,6 +251,19 @@ async function loadFile() {
}
} else {
content.value = res.content
// highlight.js 语法高亮:已知语言精确高亮,未知语言自动检测
const ext = props.filePath.split('.').pop()?.toLowerCase() ?? ''
const lang = EXT_LANG[ext]
try {
if (lang && hljs.getLanguage(lang)) {
htmlContent.value = hljs.highlight(res.content, { language: lang }).value
} else {
htmlContent.value = hljs.highlightAuto(res.content).value
}
} catch {
// 高亮失败 → 转义纯文本(防 XSS)
htmlContent.value = escapeHtml(res.content)
}
}
} catch (e) {
error.value = e instanceof Error ? e.message : String(e)
@@ -145,7 +272,10 @@ async function loadFile() {
}
}
/** 拼接工程根 + 相对路径为绝对路径(平台分隔符)。 */
function escapeHtml(s: string): string {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
}
function joinPath(root: string, rel: string): string {
const sep = root.includes('\\') ? '\\' : '/'
const normRel = rel.split('/').join(sep)
@@ -153,7 +283,6 @@ function joinPath(root: string, rel: string): string {
return root + sep + normRel
}
/** 字节数 → 人类可读大小。 */
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
@@ -178,7 +307,11 @@ function gitStatusLabel(status: string): string {
return x.charAt(0) || '?'
}
watch(() => [props.moduleId, props.filePath], loadFile, { immediate: true })
watch(() => [props.moduleId, props.filePath], () => {
showDiff.value = false
diffContent.value = ''
loadFile()
}, { immediate: true })
onUnmounted(() => {
if (imageUrl.value) URL.revokeObjectURL(imageUrl.value)
@@ -222,15 +355,8 @@ onUnmounted(() => {
max-width: 460px;
}
.preview-size {
color: var(--df-text-dim);
flex-shrink: 0;
}
.preview-truncated {
color: #f0a020;
flex-shrink: 0;
}
.preview-size { color: var(--df-text-dim); flex-shrink: 0; }
.preview-truncated { color: #f0a020; flex-shrink: 0; }
.git-badge {
flex-shrink: 0;
@@ -242,31 +368,19 @@ onUnmounted(() => {
text-align: center;
}
.git-modified {
background: rgba(255, 165, 0, 0.18);
color: #f0a020;
}
.git-added {
background: rgba(60, 180, 80, 0.18);
color: #4caf50;
}
.git-untracked {
background: rgba(150, 150, 150, 0.18);
color: #999;
}
.git-deleted {
background: rgba(220, 60, 60, 0.18);
color: #e05050;
}
.git-other {
background: rgba(100, 150, 220, 0.18);
color: #6a9adc;
}
.git-modified { background: rgba(255, 165, 0, 0.18); color: #f0a020; }
.git-added { background: rgba(60, 180, 80, 0.18); color: #4caf50; }
.git-untracked { background: rgba(150, 150, 150, 0.18); color: #999; }
.git-deleted { background: rgba(220, 60, 60, 0.18); color: #e05050; }
.git-other { background: rgba(100, 150, 220, 0.18); color: #6a9adc; }
.preview-body {
flex: 1;
overflow: auto;
overflow: hidden;
padding: 0;
min-height: 0;
display: flex;
flex-direction: column;
}
.preview-placeholder,
@@ -284,14 +398,8 @@ onUnmounted(() => {
min-height: 200px;
}
.preview-error {
color: #e05050;
}
.binary-icon {
font-size: 32px;
opacity: 0.5;
}
.preview-error { color: #e05050; }
.binary-icon { font-size: 32px; opacity: 0.5; }
.preview-image {
display: flex;
@@ -314,14 +422,59 @@ onUnmounted(() => {
font-family: var(--df-font-mono, 'Cascadia Code', Consolas, 'Courier New', monospace);
font-size: 12.5px;
line-height: 1.55;
color: var(--df-text);
white-space: pre;
overflow: auto;
tab-size: 2;
flex: 1;
min-width: 0;
min-height: 0;
}
.preview-code code {
font-family: inherit;
.preview-code code { font-family: inherit; }
/* 行号 + 代码并排容器 */
.preview-code-wrap {
display: flex;
overflow: hidden;
min-height: 0;
flex: 1 1 0;
}
/* 行号列 */
.preview-line-numbers {
flex-shrink: 0;
padding: 14px 0;
min-width: 44px;
text-align: right;
background: rgba(255, 255, 255, 0.03);
border-right: 0.5px solid var(--df-border);
user-select: none;
overflow: hidden;
}
.preview-line-num {
padding: 0 10px 0 8px;
font-family: var(--df-font-mono, 'Cascadia Code', Consolas, monospace);
font-size: 12px;
line-height: 1.55;
color: var(--df-text-dim);
opacity: 0.5;
}
.preview-line-num.is-active {
color: var(--df-accent);
opacity: 1;
background: rgba(255, 255, 255, 0.05);
}
/* Diff 视图 — 在自身区域滚动,不溢出到父级 */
.preview-diff {
font-family: var(--df-font-mono, 'Cascadia Code', Consolas, monospace);
font-size: 12px;
line-height: 1.55;
overflow: auto;
min-height: 0;
flex: 1;
}
.spinner {
@@ -334,9 +487,94 @@ onUnmounted(() => {
display: inline-block;
}
@keyframes df-spin {
to {
transform: rotate(360deg);
/* Diff 切换条 */
.diff-toggle-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
font-size: 11px;
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius-sm, 4px);
background: transparent;
color: var(--df-text-secondary);
cursor: pointer;
transition: all 0.15s;
white-space: nowrap;
}
.diff-toggle-btn:hover {
background: var(--df-bg);
color: var(--df-text);
}
.diff-toggle-btn.active {
background: var(--df-accent);
color: #fff;
border-color: var(--df-accent);
}
/* Diff 视图 */
.preview-diff {
font-family: var(--df-font-mono, 'Cascadia Code', Consolas, monospace);
font-size: 12px;
line-height: 1.55;
overflow: auto;
height: 100%;
}
.diff-line {
display: flex;
padding: 0 8px;
}
.diff-line-num {
width: 36px;
flex-shrink: 0;
text-align: right;
padding-right: 8px;
color: var(--df-text-dim);
opacity: 0.4;
user-select: none;
}
.diff-line-prefix {
width: 16px;
flex-shrink: 0;
text-align: center;
user-select: none;
}
.diff-line-text {
flex: 1;
white-space: pre;
overflow: hidden;
}
.diff-add {
background: rgba(40, 160, 70, 0.12);
}
.diff-add .diff-line-prefix { color: #4caf50; }
.diff-del {
background: rgba(220, 60, 60, 0.12);
}
.diff-del .diff-line-prefix { color: #e05050; }
.diff-hdr {
background: rgba(60, 140, 220, 0.08);
color: var(--df-text-dim);
font-weight: 500;
}
.diff-ctx {
color: var(--df-text);
}
@keyframes df-spin { to { transform: rotate(360deg); } }
</style>
<!-- highlight.js github-dark 主题(全局, scoped) -->
<style src="highlight.js/styles/github-dark.css"></style>

View File

@@ -50,6 +50,7 @@
>
<span class="expand-icon placeholder"></span>
<span class="file-icon">📄</span>
<span class="file-icon file-icon-ext" v-html="fileIcon(entry.name)"></span>
<span class="file-name">{{ entry.name }}</span>
<!-- Git 状态标记(仅文件):M / A 绿 / ?? -->
<span
@@ -127,6 +128,8 @@ const error = ref<string | null>(null)
/** 拉取当前 subPath 的条目列表。 */
async function loadEntries() {
// moduleId 为空时不发起请求(工程列表还在加载中)
if (!props.moduleId) return
loading.value = true
error.value = null
try {
@@ -144,6 +147,8 @@ async function loadEntries() {
/** 点击文件夹:切换展开态。 */
async function toggleDir(entry: FileTreeEntry) {
if (!entry.is_dir) return
// moduleId 为空时不发起请求(工程列表还在加载中)
if (!props.moduleId) return
if (props.expandedPaths.has(entry.path)) {
// 收起:仅移除展开标记(loadedChildren 缓存保留,下次展开秒开)。
emit('toggle-dir', entry.path, [])
@@ -188,6 +193,40 @@ function gitStatusLabel(status: string): string {
return x.charAt(0) || '?'
}
/** 文件后缀 → SVG 图标映射(纯文本,安全注入 v-html)。 */
const FILE_ICONS: Record<string, string> = {
// 代码
rs: '<svg viewBox="0 0 24 24" fill="none"><rect x="3" y="3" width="18" height="18" rx="3" fill="#DE5842"/><path d="M8 8h8M8 12h8M8 16h5" stroke="#fff" stroke-width="1.5" stroke-linecap="round"/></svg>',
ts: '<svg viewBox="0 0 24 24" fill="none"><rect x="3" y="3" width="18" height="18" rx="3" fill="#3178C6"/><text x="12" y="16" text-anchor="middle" fill="#fff" font-size="11" font-weight="bold">TS</text></svg>',
tsx: '<svg viewBox="0 0 24 24" fill="none"><rect x="3" y="3" width="18" height="18" rx="3" fill="#3178C6"/><text x="12" y="16" text-anchor="middle" fill="#fff" font-size="10" font-weight="bold">TSX</text></svg>',
js: '<svg viewBox="0 0 24 24" fill="none"><rect x="3" y="3" width="18" height="18" rx="3" fill="#F7DF1E"/><text x="12" y="16" text-anchor="middle" fill="#000" font-size="11" font-weight="bold">JS</text></svg>',
jsx: '<svg viewBox="0 0 24 24" fill="none"><rect x="3" y="3" width="18" height="18" rx="3" fill="#F7DF1E"/><text x="12" y="16" text-anchor="middle" fill="#000" font-size="10" font-weight="bold">JSX</text></svg>',
vue: '<svg viewBox="0 0 24 24" fill="none"><rect x="3" y="3" width="18" height="18" rx="3" fill="#42B883"/><path d="M7 7l5 10 5-10h-3l-2 4-2-4H7z" fill="#fff"/></svg>',
// 样式
css: '<svg viewBox="0 0 24 24" fill="none"><rect x="3" y="3" width="18" height="18" rx="3" fill="#2965F1"/><path d="M7 7l1 10 4 1.5 4-1.5 1-10H7z" fill="#fff" opacity="0.9"/></svg>',
scss: '<svg viewBox="0 0 24 24" fill="none"><rect x="3" y="3" width="18" height="18" rx="3" fill="#C6538C"/><text x="12" y="16" text-anchor="middle" fill="#fff" font-size="9" font-weight="bold">SCSS</text></svg>',
html: '<svg viewBox="0 0 24 24" fill="none"><rect x="3" y="3" width="18" height="18" rx="3" fill="#E34F26"/><path d="M8 7l-.5 5.5h8.5l-.5 4-3.5 1-3.5-1" stroke="#fff" stroke-width="1.2" fill="none"/><path d="M12 12h3l-.5-3H12" stroke="#fff" stroke-width="1.2" fill="none"/></svg>',
// 配置
json: '<svg viewBox="0 0 24 24" fill="none"><rect x="3" y="3" width="18" height="18" rx="3" fill="#5C5C5C"/><text x="12" y="16" text-anchor="middle" fill="#fff" font-size="8" font-weight="bold">{ }</text></svg>',
toml: '<svg viewBox="0 0 24 24" fill="none"><rect x="3" y="3" width="18" height="18" rx="3" fill="#8B5CF6"/><text x="12" y="16" text-anchor="middle" fill="#fff" font-size="9" font-weight="bold">TOML</text></svg>',
yaml: '<svg viewBox="0 0 24 24" fill="none"><rect x="3" y="3" width="18" height="18" rx="3" fill="#6BB5A0"/><text x="12" y="16" text-anchor="middle" fill="#fff" font-size="9" font-weight="bold">YM</text></svg>',
yml: '<svg viewBox="0 0 24 24" fill="none"><rect x="3" y="3" width="18" height="18" rx="3" fill="#6BB5A0"/><text x="12" y="16" text-anchor="middle" fill="#fff" font-size="9" font-weight="bold">YM</text></svg>',
// 文档
md: '<svg viewBox="0 0 24 24" fill="none"><rect x="3" y="3" width="18" height="18" rx="3" fill="#083FA1"/><path d="M7 15V9l2.5 3L12 9v6" stroke="#fff" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" fill="none"/></svg>',
// Shell
sh: '<svg viewBox="0 0 24 24" fill="none"><rect x="3" y="3" width="18" height="18" rx="3" fill="#4EAA25"/><path d="M8 9l3 3-3 3M13 15h3" stroke="#fff" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" fill="none"/></svg>',
bash: '<svg viewBox="0 0 24 24" fill="none"><rect x="3" y="3" width="18" height="18" rx="3" fill="#4EAA25"/><path d="M8 9l3 3-3 3M13 15h3" stroke="#fff" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" fill="none"/></svg>',
// 构建
dockerfile: '<svg viewBox="0 0 24 24" fill="none"><rect x="3" y="3" width="18" height="18" rx="3" fill="#2496ED"/><path d="M7 12h1M9 12h1M11 12h1M13 12h1M15 12h1M17 10h-2v2h2v-2zM10 9H8v2h2V9z" stroke="#fff" stroke-width="1" fill="none"/></svg>',
sql: '<svg viewBox="0 0 24 24" fill="none"><rect x="3" y="3" width="18" height="18" rx="3" fill="#E38C00"/><text x="12" y="16" text-anchor="middle" fill="#fff" font-size="8" font-weight="bold">SQL</text></svg>',
}
/** 文件后缀 → 图标 HTML(v-html 安全注入)。 */
function fileIcon(name: string): string {
const ext = name.split('.').pop()?.toLowerCase() ?? ''
return FILE_ICONS[ext] || ''
}
onMounted(loadEntries)
// moduleId 变化(切换工程)时重拉。
@@ -198,6 +237,8 @@ watch(
() => props.loadedChildren.has(props.subPath || ''),
(has) => {
if (!has && entries.value.length > 0) {
// moduleId 为空时不发起请求
if (!props.moduleId) return
loadEntries()
}
},
@@ -260,6 +301,22 @@ watch(
.file-icon {
font-size: 14px;
line-height: 1;
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
flex-shrink: 0;
}
.file-icon-ext {
margin-left: -18px;
}
.file-icon-ext svg {
width: 18px;
height: 18px;
display: block;
}
.file-name {

View File

@@ -0,0 +1,490 @@
<template>
<div class="git-changes">
<!-- 分支信息 -->
<div v-if="branchName" class="git-branch-bar">
<span class="branch-icon"></span>
<span class="branch-name">{{ branchName }}</span>
<span v-if="!showHistory" class="branch-count">{{ gitStatus?.changed_files.length ?? 0 }} {{ $t('gitChanges.changedFiles') }}</span>
</div>
<div v-if="loading && !gitStatus" class="gc-loading"><span class="spinner"></span></div>
<!-- Git 仓库 -->
<div v-else-if="gitStatus && !gitStatus.is_git_repo" class="gc-empty">
{{ $t('gitChanges.notGitRepo') }}
</div>
<div v-else-if="gitStatus" class="gc-body">
<!-- Tab: 变更 / 历史 -->
<nav class="gc-tabs">
<button class="gc-tab" :class="{ active: !showHistory }" @click="showHistory = false">
{{ $t('gitChanges.changes') }} ({{ gitStatus.changed_files.length }})
</button>
<button class="gc-tab" :class="{ active: showHistory }" @click="switchToHistory">
{{ $t('gitChanges.history') }} ({{ totalCommits }})
</button>
</nav>
<!-- ====== 变更列表 ====== -->
<div v-if="!showHistory" class="gc-changes">
<template v-if="gitStatus.changed_files.length === 0">
<div class="gc-empty">{{ $t('gitChanges.noChanges') }}</div>
</template>
<!-- 按目录树分组的变更文件 -->
<div v-for="group in groupedChanges" :key="group.label" class="gc-group">
<div class="gc-group-header">{{ group.label }} ({{ group.files.length }})</div>
<div v-for="f in group.files" :key="f.path"
class="gc-file-row"
:class="{ selected: selectedFile === f.path }"
:style="{ paddingLeft: filePathDepth(f.path) * 16 + 14 + 'px' }"
@click="selectFile(f.path)"
>
<span class="gc-file-status" :class="'gc-st-' + group.key">{{ statusLabel(f.status) }}</span>
<span class="gc-file-name">{{ filePathLeaf(f.path) }}</span>
</div>
</div>
</div>
<!-- ====== 提交历史 ====== -->
<div v-else class="gc-history-wrapper">
<!-- 已选提交的详情 -->
<div v-if="selectedCommit" class="gc-commit-detail">
<div class="gc-commit-detail-header">
<span class="gc-commit-detail-hash">{{ selectedCommit.hash }}</span>
<span class="gc-commit-detail-msg">{{ selectedCommit.subject }}</span>
<span class="gc-commit-detail-time">{{ formatTime(selectedCommit.timestamp) }}</span>
<button class="gc-detail-close" @click="selectedCommit = null; commitDetail = null"></button>
</div>
<div v-if="commitDetailLoading" class="gc-loading"><span class="spinner"></span></div>
<template v-else-if="commitDetail">
<!-- 变更文件列表 -->
<div class="gc-commit-files">
<div
v-for="f in commitDetail.files"
:key="f.path"
class="gc-commit-file-row"
:class="{ active: commitSelectedFile === f.path }"
@click="showCommitFileDiff(f.path)"
>
<span class="gc-file-status" :class="'gc-st-' + statusKey(f.status)">{{ statusLabel(f.status) }}</span>
<span class="gc-file-path">{{ f.path }}</span>
</div>
</div>
<!-- Diff 预览 -->
<div v-if="commitSelectedFile && commitDiff" class="gc-diff-content">
<div v-for="(ln, idx) in commitDiffLines" :key="idx" class="diff-line" :class="'diff-' + ln.type">
<span class="diff-hdr-text" v-if="ln.type === 'hdr'">{{ ln.text }}</span>
<template v-else>
<span class="diff-line-prefix">{{ ln.prefix }}</span>
<span class="diff-line-text">{{ ln.text }}</span>
</template>
</div>
</div>
</template>
</div>
<!-- 提交列表 -->
<div class="gc-history-list">
<div
v-for="c in commits"
:key="c.hash"
class="gc-commit-row"
:class="{ selected: selectedCommit?.hash === c.hash }"
@click="selectCommit(c)"
>
<span class="gc-commit-hash">{{ c.hash.slice(0, 8) }}</span>
<span class="gc-commit-msg">{{ c.subject }}</span>
<span class="gc-commit-time">{{ formatTime(c.timestamp) }}</span>
</div>
</div>
<!-- 加载更多 -->
<div v-if="hasMoreCommits && !loadingMore" class="gc-load-more" @click="loadMoreCommits">
{{ $t('gitChanges.loadMore') }}
</div>
<div v-if="loadingMore" class="gc-loading"><span class="spinner"></span></div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
/**
* Git 变更查看器 — 变更文件列表(按目录树分组) + 提交历史(分页) + 文件 diff 预览。
*/
import { ref, computed, watch } from 'vue'
import { moduleApi, type GitStatusResult } from '@/api/module'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const props = defineProps<{
moduleId: string
}>()
const emit = defineEmits<{
(e: 'select-file', path: string): void
}>()
const loading = ref(false)
const gitStatus = ref<GitStatusResult | null>(null)
const showHistory = ref(false)
const selectedFile = ref('')
const diffContent = ref('')
const diffLoading = ref(false)
// 提交历史(分页)
const commits = ref<{ hash: string; subject: string; timestamp: number }[]>([])
const totalCommits = ref(0)
const hasMoreCommits = ref(false)
const loadingMore = ref(false)
const commitSkip = ref(0)
const COMMIT_PAGE_SIZE = 50
// 已选提交详情
const selectedCommit = ref<{ hash: string; subject: string; timestamp: number } | null>(null)
const commitDetail = ref<{ files: { status: string; path: string }[]; diff: string } | null>(null)
const commitDetailLoading = ref(false)
const commitSelectedFile = ref('')
const commitDiff = ref('')
const branchName = computed(() => gitStatus.value?.branch || '')
interface FileGroup {
key: string
label: string
files: { path: string; status: string }[]
}
function statusKey(s: string): string {
const x = s.trim()
if (x.startsWith('M') || x === 'R') return 'modified'
if (x.startsWith('A')) return 'added'
if (x.startsWith('D')) return 'deleted'
if (x === '??') return 'untracked'
return 'modified'
}
/** 按状态分组 */
const groupedChanges = computed<FileGroup[]>(() => {
if (!gitStatus.value) return []
const groups: FileGroup[] = [
{ key: 'modified', label: t('gitChanges.modified'), files: [] },
{ key: 'added', label: t('gitChanges.added'), files: [] },
{ key: 'deleted', label: t('gitChanges.deleted'), files: [] },
{ key: 'untracked', label: t('gitChanges.untracked'), files: [] },
]
for (const f of gitStatus.value.changed_files) {
const s = f.status.trim()
if (s.startsWith('M') || s === 'R') groups[0].files.push(f)
else if (s.startsWith('A')) groups[1].files.push(f)
else if (s.startsWith('D')) groups[2].files.push(f)
else if (s === '??') groups[3].files.push(f)
else groups[0].files.push(f)
}
return groups.filter(g => g.files.length > 0)
})
interface DiffLine {
type: 'add' | 'del' | 'ctx' | 'hdr'
prefix: string
text: string
}
const commitDiffLines = computed<DiffLine[]>(() => parseDiff(commitDiff.value))
function parseDiff(text: string): DiffLine[] {
if (!text) return []
const lines: DiffLine[] = []
for (const raw of text.split('\n')) {
if (raw.startsWith('@@')) {
lines.push({ type: 'hdr', prefix: '', text: raw })
} else if (raw.startsWith('+')) {
lines.push({ type: 'add', prefix: '+', text: raw.slice(1) })
} else if (raw.startsWith('-')) {
lines.push({ type: 'del', prefix: '-', text: raw.slice(1) })
} else if (raw.startsWith('\\')) {
continue
} else {
lines.push({ type: 'ctx', prefix: ' ', text: raw })
}
}
return lines
}
function statusLabel(s: string): string {
const x = s.trim()
if (x === '??') return 'U'
if (x.startsWith('A')) return 'A'
if (x.startsWith('M')) return 'M'
if (x.startsWith('D')) return 'D'
if (x.startsWith('R')) return 'R'
return x.charAt(0) || '?'
}
/** 格式化 Unix 时间戳为相对时间或具体时间 */
function formatTime(ts: number): string {
if (!ts) return ''
const now = Math.floor(Date.now() / 1000)
const diffSec = now - ts
if (diffSec < 60) return t('common.justNow')
if (diffSec < 3600) return t('common.minutesAgo', { n: Math.floor(diffSec / 60) })
if (diffSec < 86400) return t('common.hoursAgo', { n: Math.floor(diffSec / 3600) })
if (diffSec < 172800) return t('common.yesterday')
if (diffSec < 2592000) return t('common.dayAgo', { n: Math.floor(diffSec / 86400) })
// 超过 30 天显示具体日期
const d = new Date(ts * 1000)
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}
/** 计算文件路径的目录深度(用于缩进)。root 级文件返回 0。 */
function filePathDepth(path: string): number {
const segs = path.split('/').filter(Boolean)
return Math.max(0, segs.length - 1)
}
/** 提取文件名(最后一段)。 */
function filePathLeaf(path: string): string {
const segs = path.split('/').filter(Boolean)
return segs[segs.length - 1] || path
}
async function loadStatus() {
if (!props.moduleId) return
loading.value = true
try {
gitStatus.value = await moduleApi.getModuleGitStatus(props.moduleId)
// 同时初始化前 50 条提交
const res = await moduleApi.getModuleCommits(props.moduleId, 0, COMMIT_PAGE_SIZE)
commits.value = res.commits
hasMoreCommits.value = res.has_more
totalCommits.value = res.commits.length
commitSkip.value = res.commits.length
} catch {
gitStatus.value = null
commits.value = []
} finally {
loading.value = false
}
}
async function loadMoreCommits() {
if (loadingMore.value || !hasMoreCommits.value) return
loadingMore.value = true
try {
const res = await moduleApi.getModuleCommits(props.moduleId, commitSkip.value, COMMIT_PAGE_SIZE)
commits.value.push(...res.commits)
hasMoreCommits.value = res.has_more
commitSkip.value = commits.value.length
totalCommits.value = commits.value.length
} catch {
// 静默失败
} finally {
loadingMore.value = false
}
}
async function switchToHistory() {
showHistory.value = true
// 如果只加载了初始数量,尝试补页
if (hasMoreCommits.value && commits.value.length < 50) {
await loadMoreCommits()
}
}
async function selectFile(path: string) {
selectedFile.value = path
emit('select-file', path)
diffContent.value = ''
diffLoading.value = true
try {
const res = await moduleApi.getModuleFileDiff(props.moduleId, path)
diffContent.value = res.diff || ''
} catch {
diffContent.value = ''
} finally {
diffLoading.value = false
}
}
/** 点击提交行 → 加载该提交的变更文件列表 */
async function selectCommit(c: { hash: string; subject: string; timestamp: number }) {
selectedCommit.value = c
commitDetail.value = null
commitSelectedFile.value = ''
commitDiff.value = ''
commitDetailLoading.value = true
try {
const res = await moduleApi.getCommitDetail(props.moduleId, c.hash)
commitDetail.value = res
} catch {
commitDetail.value = null
} finally {
commitDetailLoading.value = false
}
}
/** 在提交详情中点击文件 → 过滤出该文件的 diff */
function showCommitFileDiff(path: string) {
commitSelectedFile.value = path
if (!commitDetail.value) return
// 从全量 diff 中提取该文件的块
const diff = commitDetail.value.diff
const lines = diff.split('\n')
let inTarget = false
const targetLines: string[] = []
for (const line of lines) {
if (line.startsWith('diff --git')) {
inTarget = line.includes(path)
}
if (inTarget) {
targetLines.push(line)
}
}
commitDiff.value = targetLines.join('\n')
}
watch(() => props.moduleId, loadStatus, { immediate: true })
</script>
<style scoped>
.git-changes {
display: flex;
flex-direction: column;
height: 100%;
font-size: 13px;
color: var(--df-text);
}
.git-branch-bar {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
border-bottom: 0.5px solid var(--df-border);
background: var(--df-bg-card);
flex-shrink: 0;
}
.branch-icon { font-size: 16px; }
.branch-name { font-weight: 600; color: var(--df-accent); }
.branch-count { margin-left: auto; font-size: 12px; color: var(--df-text-dim); }
/* Tabs */
.gc-tabs { display: flex; border-bottom: 0.5px solid var(--df-border); flex-shrink: 0; }
.gc-tab {
flex: 1; padding: 8px; background: none; border: none;
border-bottom: 2px solid transparent; color: var(--df-text-dim);
cursor: pointer; font-size: 12px; transition: all 0.15s;
}
.gc-tab.active { color: var(--df-text); border-bottom-color: var(--df-accent); }
.gc-tab:hover { color: var(--df-text); }
.gc-body { flex: 1; overflow-y: auto; overflow-x: hidden; min-height: 0; scrollbar-width: thin; }
.gc-changes { min-height: 0; }
/* 分组 */
.gc-group-header {
padding: 6px 14px; font-size: 11px; font-weight: 600;
color: var(--df-text-dim); text-transform: uppercase; letter-spacing: 0.5px;
background: rgba(255,255,255,0.02); border-bottom: 0.5px solid var(--df-border);
position: sticky; top: 0; z-index: 1;
}
/* Diff 预览 */
.gc-diff-content {
font-family: var(--df-font-mono, Consolas, monospace);
font-size: 12px; line-height: 1.5; overflow: auto; scrollbar-width: thin;
}
.diff-line { display: flex; padding: 0 14px; }
.diff-line-prefix { width: 16px; flex-shrink: 0; text-align: center; user-select: none; }
.diff-line-text { flex: 1; white-space: pre; overflow: hidden; }
.diff-hdr-text {
padding: 4px 14px; background: rgba(60,140,220,0.08);
color: var(--df-text-dim); font-weight: 500; font-size: 11px; display: block;
}
.diff-add { background: rgba(40,160,70,0.12); }
.diff-add .diff-line-prefix { color: #4caf50; }
.diff-del { background: rgba(220,60,60,0.12); }
.diff-del .diff-line-prefix { color: #e05050; }
.diff-ctx { color: var(--df-text); }
/* 提交历史 */
.gc-history-wrapper { display: flex; flex-direction: column; }
.gc-commit-row {
display: flex; align-items: center; gap: 10px;
padding: 8px 14px; border-bottom: 0.5px solid rgba(255,255,255,0.03);
cursor: pointer; transition: background 0.1s;
}
.gc-commit-row:hover { background: rgba(255,255,255,0.04); }
.gc-commit-row.selected { background: rgba(255,255,255,0.06); }
.gc-commit-hash {
font-family: var(--df-font-mono, Consolas, monospace);
font-size: 11px; color: var(--df-accent); flex-shrink: 0;
}
.gc-commit-msg { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; }
.gc-commit-time { font-size: 11px; color: var(--df-text-dim); flex-shrink: 0; }
/* 提交详情 */
.gc-commit-detail { border-bottom: 0.5px solid var(--df-border); }
.gc-commit-detail-header {
display: flex; align-items: center; gap: 10px;
padding: 8px 14px; background: rgba(255,255,255,0.03);
position: sticky; top: 0; z-index: 1;
}
.gc-commit-detail-hash {
font-family: var(--df-font-mono, Consolas, monospace);
font-size: 11px; color: var(--df-accent);
}
.gc-commit-detail-msg { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; }
.gc-commit-detail-time { font-size: 11px; color: var(--df-text-dim); flex-shrink: 0; }
.gc-detail-close {
background: none; border: none; color: var(--df-text-dim);
cursor: pointer; font-size: 14px; padding: 0 4px;
}
/* 提交的文件列表 */
.gc-commit-files { border-bottom: 0.5px solid var(--df-border); }
.gc-commit-file-row {
display: flex; align-items: center; gap: 8px;
padding: 5px 14px 5px 28px; cursor: pointer; transition: background 0.1s;
border-bottom: 0.5px solid rgba(255,255,255,0.02);
}
.gc-commit-file-row:hover { background: rgba(255,255,255,0.04); }
.gc-commit-file-row.active { background: rgba(255,255,255,0.06); }
.gc-file-status { width: 20px; text-align: center; font-size: 11px; font-weight: 700; flex-shrink: 0; }
.gc-st-modified { color: #f0a020; }
.gc-st-added { color: #4caf50; }
.gc-st-deleted { color: #e05050; }
.gc-st-untracked { color: #999; }
.gc-file-path {
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
font-family: var(--df-font-mono, Consolas, monospace); font-size: 12px;
}
.gc-file-name {
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
font-family: var(--df-font-mono, Consolas, monospace); font-size: 12px;
}
/* 加载更多 */
.gc-load-more {
text-align: center; padding: 12px; cursor: pointer;
color: var(--df-accent); font-size: 12px;
transition: background 0.1s;
}
.gc-load-more:hover { background: rgba(255,255,255,0.04); }
/* 通用 */
.gc-loading, .gc-empty {
display: flex; justify-content: center; align-items: center;
padding: 40px 0; color: var(--df-text-dim); font-size: 13px;
}
.gc-diff-loading { display: flex; justify-content: center; padding: 20px; }
.gc-diff-empty { padding: 20px 14px; color: var(--df-text-dim); font-size: 12px; text-align: center; }
.spinner {
width: 16px; height: 16px;
border: 2px solid var(--df-border); border-top-color: var(--df-accent);
border-radius: 50%; animation: df-spin 0.8s linear infinite; display: inline-block;
}
@keyframes df-spin { to { transform: rotate(360deg); } }
</style>

View File

@@ -10,15 +10,15 @@
import { ref, reactive, computed, watch, onBeforeUnmount, type Ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useConfirm } from '@/composables/useConfirm'
import { STREAM_TIMEOUT_MS } from '@/composables/ai/useAiStream'
import type { AiToolCallInfo } from '@/api/types'
/**
* 审批按钮 loading 超时兜底(B-260616-08)。
* 后端回事件使 tc.status 离开 pending_approval → watch 复位。
* 兜底计时到点复位 + toast 提示(对齐全局看门狗 STREAM_TIMEOUT_MS,后端无回执时给用户重试入口)。
* 兜底计时到点复位 + toast 提示(后端 IPC 应在 1~3s 内返回;30s 兜底远足超时后允许用户重试)。
* 与 stream 看门狗(130s)解耦:审批是按钮级交互,不是后台流,不需要对齐后端 120s 空闲超时。
*/
const APPROVE_LOADING_TIMEOUT_MS = STREAM_TIMEOUT_MS
const APPROVE_LOADING_TIMEOUT_MS = 30_000
/** AE-2025-05:High 风险工具白名单(后端 tool_registry.rs RiskLevel::High 对齐) */
const HIGH_RISK_TOOLS = new Set<string>([

View File

@@ -0,0 +1,76 @@
/**
* 文件浏览器窗口分离 — 弹出/分离/附加 FileExplorer 独立窗口。
*
* 对标 useAiWindow 的 detach/reattach 模式,复用 WebviewWindow API。
* 分离窗口以独立 Tauri 窗口打开 FileExplorerDetached 视图,
* 通过 URL query `?projectId=xxx` 传参。
*/
const DETACHED_LABEL_PREFIX = 'fe-detached-'
function detachedLabel(projectId: string): string {
return DETACHED_LABEL_PREFIX + projectId
}
/**
* 将文件浏览器弹出到独立窗口。
* 若该项目的窗口已存在则聚焦,不重复创建。
*
* @param projectId 项目 id(传给分离窗口加载工程列表)
* @param title 窗口标题(可选,默认"文件浏览器")
*/
export async function detachFileExplorer(
projectId: string,
title?: string,
): Promise<void> {
if (!projectId) return
const { WebviewWindow } = await import('@tauri-apps/api/webviewWindow')
const label = detachedLabel(projectId)
// 已存在则聚焦
const existing = await WebviewWindow.getByLabel(label)
if (existing) {
await existing.setFocus()
return
}
const url =
window.location.origin +
window.location.pathname +
`#/file-explorer-detached?projectId=${encodeURIComponent(projectId)}`
const win = new WebviewWindow(label, {
url,
title: title || '文件浏览器',
width: 900,
height: 600,
minWidth: 640,
minHeight: 400,
center: true,
decorations: true,
})
win.once('tauri://created', () => {
console.log('[FileExplorer] 分离窗口已创建:', label)
})
win.once('tauri://error', (e) => {
console.error('[FileExplorer] 窗口创建失败:', e)
})
}
/**
* 关闭指定项目的文件浏览器分离窗口。
*/
export async function reattachFileExplorer(projectId: string): Promise<void> {
if (!projectId) return
const { WebviewWindow } = await import('@tauri-apps/api/webviewWindow')
const label = detachedLabel(projectId)
const existing = await WebviewWindow.getByLabel(label)
if (existing) {
try {
await existing.close()
} catch {
// 忽略关闭失败
}
}
}

View File

@@ -5,6 +5,8 @@ export default {
// Toolbar
refresh: 'Refresh',
refreshing: 'Refreshing…',
// Detach window
detachTitle: 'File Explorer',
// File tree
loading: 'Loading…',
emptyDir: 'Empty directory',
@@ -15,5 +17,17 @@ export default {
truncated: 'File too large, only first 1MB shown',
// Errors
loadFailed: 'Failed to load',
// Diff
showDiff: 'Show Diff',
showContent: 'Show Content',
// Module management
addModule: 'Add Module',
adding: 'Adding…',
moduleName: 'Module Name',
moduleNamePlaceholder: 'e.g. frontend, backend',
modulePath: 'Directory Path',
modulePathPlaceholder: 'e.g. D:/projects/my-app/frontend',
moduleGitUrl: 'Git URL',
moduleGitUrlPlaceholder: 'https://github.com/… (optional)',
},
}

17
src/i18n/en/gitChanges.ts Normal file
View File

@@ -0,0 +1,17 @@
export default {
gitChanges: {
changes: 'Changes',
history: 'History',
changedFiles: 'changed',
noChanges: 'No changes (clean working tree)',
noDiff: 'No diff available',
noCommits: 'No commits yet',
modified: 'Modified',
added: 'Added',
deleted: 'Deleted',
untracked: 'Untracked',
notGitRepo: 'Not a git repository',
detached: 'HEAD detached',
loadMore: 'Load more',
},
}

View File

@@ -5,6 +5,8 @@ export default {
// 工具栏
refresh: '刷新',
refreshing: '刷新中…',
// 分离窗口
detachTitle: '文件浏览器',
// 文件树
loading: '加载中…',
emptyDir: '空目录',
@@ -15,5 +17,19 @@ export default {
truncated: '文件过大,仅显示前 1MB',
// 错误
loadFailed: '加载失败',
// Diff
showDiff: '查看变更',
showContent: '查看内容',
// 工程管理
addModule: '添加工程',
adding: '添加中…',
moduleName: '工程名称',
moduleNamePlaceholder: '例如 frontend、backend',
modulePath: '目录路径',
modulePathPlaceholder: '例如 D:/projects/my-app/frontend',
moduleGitUrl: 'Git 地址',
moduleGitUrlPlaceholder: 'https://github.com/…(可选)',
// Git Changes tab
loadMore: '加载更多',
},
}

View File

@@ -0,0 +1,17 @@
export default {
gitChanges: {
changes: '变更',
history: '历史',
changedFiles: '个变更',
noChanges: '无变更(工作区干净)',
noDiff: '无差异内容',
noCommits: '暂无提交记录',
modified: '已修改',
added: '已新增',
deleted: '已删除',
untracked: '未跟踪',
notGitRepo: '非 Git 仓库',
detached: 'HEAD 分离',
loadMore: '加载更多',
},
}

View File

@@ -77,6 +77,12 @@ const routes = [
component: () => import('../views/AiDetached.vue'),
meta: { title: 'AI Chat' },
},
{
path: '/file-explorer-detached',
name: 'FileExplorerDetached',
component: () => import('../views/FileExplorerDetached.vue'),
meta: { title: '文件浏览器' },
},
// catch-all:无效路由重定向首页(防白屏)
{
path: '/:pathMatch(.*)*',

View File

@@ -0,0 +1,55 @@
<template>
<div class="fe-detached-shell">
<!-- 等待 projectId 参数加载 -->
<div v-if="!projectId" class="fe-detached-loading">
<span class="spinner"></span>
</div>
<FileExplorer v-else :project-id="projectId" />
</div>
</template>
<script setup lang="ts">
/**
* 文件浏览器分离窗口视图(Batch 12)。
* 作为独立 Tauri 窗口的全屏内容,通过 URL query `?projectId=xxx` 接收参数。
*/
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import FileExplorer from '@/components/project/FileExplorer.vue'
const route = useRoute()
const projectId = computed(() => (route.query.projectId as string) || '')
</script>
<style scoped>
.fe-detached-shell {
width: 100vw;
height: 100vh;
overflow: hidden;
background: var(--df-bg);
}
.fe-detached-loading {
display: flex;
justify-content: center;
align-items: center;
height: 100%;
color: var(--df-text-dim);
font-size: 14px;
}
.spinner {
width: 20px;
height: 20px;
border: 2px solid var(--df-border, #444);
border-top-color: var(--df-accent, #3a8);
border-radius: 50%;
animation: df-spin 0.8s linear infinite;
display: inline-block;
margin-right: 8px;
}
@keyframes df-spin {
to { transform: rotate(360deg); }
}
</style>

View File

@@ -38,7 +38,8 @@
</div>
</div>
<!-- 阶段进度条 -->
<!-- 阶段进度条(暂时隐藏:阶段流转逻辑未接通,后续接入真实状态机后启用) -->
<!--
<div class="stage-pipeline">
<div
v-for="(stage, idx) in stages"
@@ -57,6 +58,7 @@
<div v-if="idx < stages.length - 1" class="stage-connector" :class="{ 'connector-done': idx < currentStageIndex }"></div>
</div>
</div>
-->
<!-- Tab 导航(概览 / 文件浏览器) -->
<nav class="detail-tabs">
@@ -272,7 +274,7 @@ import { projectApi } from '@/api'
import { formatDate } from '@/utils/time'
import { parseStack } from '@/utils/project'
import { parseScores as parseScoresJson, assessmentClass, assessmentLabel as assessmentLabelI18n } from '@/utils/ideaEval'
import { projectStatusLabel, projectStageInfo, taskStatusLabel, taskStatusClass } from '../constants/project'
import { projectStatusLabel, taskStatusLabel, taskStatusClass } from '../constants/project'
import ConfirmDialog from '@/components/ConfirmDialog.vue'
import ApprovalDialog from '@/components/project/ApprovalDialog.vue'
import FileExplorer from '@/components/project/FileExplorer.vue'
@@ -293,14 +295,14 @@ const activeTab = ref<'overview' | 'files'>('overview')
// 确认弹层状态机抽至 composables/useConfirm(原 4 视图重复:Projects/ProjectDetail/Ideas/Settings)
const { confirmState, confirmDialog, answerConfirm } = useConfirm()
// ── 阶段定义(labelKey 对应 i18n projectDetail.stage*) ──
const stages = [
{ key: 'idea', labelKey: 'stageIdea' },
{ key: 'requirement', labelKey: 'stageRequirement' },
{ key: 'coding', labelKey: 'stageCoding' },
{ key: 'testing', labelKey: 'stageTesting' },
{ key: 'release', labelKey: 'stageRelease' },
]
// ── 阶段定义(暂时隐藏,阶段进度条模板已注释,后续接入真实状态机后启用) ──
// const stages = [
// { key: 'idea', labelKey: 'stageIdea' },
// { key: 'requirement', labelKey: 'stageRequirement' },
// { key: 'coding', labelKey: 'stageCoding' },
// { key: 'testing', labelKey: 'stageTesting' },
// { key: 'release', labelKey: 'stageRelease' },
// ]
// ── 当前项目 ──
// 状态文案/阶段进度统一走 ../constants/project(与 Projects/Tasks/Dashboard 一致,
@@ -309,6 +311,8 @@ const projectId = computed(() => route.params.id as string)
const currentProject = computed(() =>
store.projects.find(p => p.id === projectId.value)
)
// 状态文案/阶段进度统一走 ../constants/project
// B-260615-25:项目描述 Markdown 渲染(复用 AiChat/TaskDetail 同款渲染器,模块级单例),
// useRendered 封装 computed(读 mdReady 触发响应式 + renderMd)+ ensureLoaded(幂等预热)
const { rendered: renderedDesc, ensureLoaded } = useRendered(
@@ -316,7 +320,7 @@ const { rendered: renderedDesc, ensureLoaded } = useRendered(
)
const stageKey = computed(() => currentProject.value?.status ?? 'planning')
const statusLabel = computed(() => projectStatusLabel(stageKey.value))
const currentStageIndex = computed(() => projectStageInfo(stageKey.value).stepIndex)
// const currentStageIndex = computed(() => projectStageInfo(stageKey.value).stepIndex)
// ── 来源灵感(晋升携带评估结论回溯)──
// ProjectRecord.idea_id 存灵感 id(promote_idea 写入)。
@@ -685,7 +689,7 @@ onUnmounted(() => {
</style>
<style scoped>
.project-detail { padding: 16px 20px 20px; }
.project-detail { padding: 16px 20px 20px; display: flex; flex-direction: column; min-height: 0; flex: 1; }
/* ===== Tab 导航(Batch 10 文件浏览器) ===== */
.detail-tabs {
@@ -713,10 +717,11 @@ onUnmounted(() => {
border-bottom-color: var(--df-accent);
}
/* 文件浏览器容器(占满高度,内部 FileExplorer 自管布局) */
/* 文件浏览器容器(弹性填充,内部 FileExplorer 自管布局) */
.file-explorer-wrap {
height: calc(100vh - 340px);
min-height: 480px;
flex: 1;
min-height: 0;
overflow: hidden;
}
.page-header {