diff --git a/Batch.md b/Batch.md index 38ebc1c..73c4ab9 100644 --- a/Batch.md +++ b/Batch.md @@ -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` 纳入提交) diff --git a/docs/02-架构设计/专项设计/工程系统设计-2026-06-29.md b/docs/02-架构设计/专项设计/工程系统设计-2026-06-29.md index 927cc3b..366f485 100644 --- a/docs/02-架构设计/专项设计/工程系统设计-2026-06-29.md +++ b/docs/02-架构设计/专项设计/工程系统设计-2026-06-29.md @@ -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 M │ 2 fn main() { │ -│ config.rs │ 3 println!("hello"); │ -│ 📁 tests/ │ 4 } │ -│ Cargo.toml A │ │ -│ │ main.rs · 2.1 KB · 已修改 │ -└──────────────┴──────────────────────────────────┘ +┌─────────────────────────┬──────────────────────────────────────┐ +│ [📁] [📝 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 · 📝 查看变更 │ +└─────────────────────────┴──────────────────────────────────────┘ ``` -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 事件推送 | diff --git a/src-tauri/src/commands/ai/audit/data_change.rs b/src-tauri/src/commands/ai/audit/data_change.rs index 92ee7ff..a06481b 100644 --- a/src-tauri/src/commands/ai/audit/data_change.rs +++ b/src-tauri/src/commands/ai/audit/data_change.rs @@ -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)) diff --git a/src-tauri/src/commands/module.rs b/src-tauri/src/commands/module.rs index 3140ab1..5e00cc5 100644 --- a/src-tauri/src/commands/module.rs +++ b/src-tauri/src/commands/module.rs @@ -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 格式:" " - 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:" " + 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::>(); @@ -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 { + 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 { + 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 (工作树 vs 索引的 unstaged 变更+staged 变更) + // 设置环境变量强制 UTF-8 输出,防中文乱码。 + let cmd_dir = root.clone(); + let path_arg = file_path.replace('\\', "/"); + let diff = std::thread::spawn(move || -> Option { + 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 { @@ -655,3 +804,153 @@ fn collect_git_status_map(dir: &str) -> HashMap { } 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, + limit: Option, +) -> Result { + 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 = std::thread::spawn(move || -> Vec { + 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 = 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 { + 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, String) { + // 1) 获取变更文件列表:`git diff-tree --no-commit-id -r --name-status ` + 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 = Vec::new(); + for line in files_out.lines() { + let line = line.trim(); + if line.is_empty() { continue; } + // format:"\t" + if let Some((status, fpath)) = line.split_once('\t') { + files.push(serde_json::json!({ + "status": status.trim(), + "path": fpath.trim(), + })); + } + } + // 2) 获取全量 diff:`git show ` + 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, + })) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index e0cd978..edd27c3 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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, diff --git a/src/App.vue b/src/App.vue index 2f32179..c106e3c 100644 --- a/src/App.vue +++ b/src/App.vue @@ -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() diff --git a/src/api/module.ts b/src/api/module.ts index cbb6340..89da97a 100644 --- a/src/api/module.ts +++ b/src/api/module.ts @@ -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 { + return invoke('add_project_module', { input }) + }, + /** 列出项目全部工程(按 sort_order ASC)。 */ listProjectModules(projectId: string): Promise { return invoke('list_project_modules', { projectId }) @@ -104,4 +117,30 @@ export const moduleApi = { readModuleFile(moduleId: string, filePath: string): Promise { 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 }) + }, } diff --git a/src/components/project/FileExplorer.vue b/src/components/project/FileExplorer.vue index 4074722..2e9c791 100644 --- a/src/components/project/FileExplorer.vue +++ b/src/components/project/FileExplorer.vue @@ -31,38 +31,101 @@
+ +
- +
- -
- + +
+
+ + +
+ + +
+
+ +
+ +
+ + +
+ +
- +
+
{{ $t('fileExplorer.selectFileHint') }}
+
+
+
+ + + @@ -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([]) const currentModuleId = ref('') 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()) const loadedChildren = reactive(new Map()) @@ -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(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 + } +} + + + diff --git a/src/components/project/FileTree.vue b/src/components/project/FileTree.vue index 46a4f30..9b9feed 100644 --- a/src/components/project/FileTree.vue +++ b/src/components/project/FileTree.vue @@ -50,6 +50,7 @@ > 📄 + {{ entry.name }} (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 = { + // 代码 + rs: '', + ts: 'TS', + tsx: 'TSX', + js: 'JS', + jsx: 'JSX', + vue: '', + // 样式 + css: '', + scss: 'SCSS', + html: '', + // 配置 + json: '{ }', + toml: 'TOML', + yaml: 'YM', + yml: 'YM', + // 文档 + md: '', + // Shell + sh: '', + bash: '', + // 构建 + dockerfile: '', + sql: 'SQL', +} + +/** 文件后缀 → 图标 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 { diff --git a/src/components/project/GitChanges.vue b/src/components/project/GitChanges.vue new file mode 100644 index 0000000..0896be3 --- /dev/null +++ b/src/components/project/GitChanges.vue @@ -0,0 +1,490 @@ + + + + + diff --git a/src/composables/ai/useToolApproval.ts b/src/composables/ai/useToolApproval.ts index 0a563e8..cd40973 100644 --- a/src/composables/ai/useToolApproval.ts +++ b/src/composables/ai/useToolApproval.ts @@ -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([ diff --git a/src/composables/project/useFileExplorerWindow.ts b/src/composables/project/useFileExplorerWindow.ts new file mode 100644 index 0000000..1b68453 --- /dev/null +++ b/src/composables/project/useFileExplorerWindow.ts @@ -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 { + 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 { + 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 { + // 忽略关闭失败 + } + } +} diff --git a/src/i18n/en/fileExplorer.ts b/src/i18n/en/fileExplorer.ts index 0bfeac0..584f4c4 100644 --- a/src/i18n/en/fileExplorer.ts +++ b/src/i18n/en/fileExplorer.ts @@ -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)', }, } diff --git a/src/i18n/en/gitChanges.ts b/src/i18n/en/gitChanges.ts new file mode 100644 index 0000000..66bc18d --- /dev/null +++ b/src/i18n/en/gitChanges.ts @@ -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', + }, +} diff --git a/src/i18n/zh-CN/fileExplorer.ts b/src/i18n/zh-CN/fileExplorer.ts index d12526b..cc623f9 100644 --- a/src/i18n/zh-CN/fileExplorer.ts +++ b/src/i18n/zh-CN/fileExplorer.ts @@ -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: '加载更多', }, } diff --git a/src/i18n/zh-CN/gitChanges.ts b/src/i18n/zh-CN/gitChanges.ts new file mode 100644 index 0000000..da0e733 --- /dev/null +++ b/src/i18n/zh-CN/gitChanges.ts @@ -0,0 +1,17 @@ +export default { + gitChanges: { + changes: '变更', + history: '历史', + changedFiles: '个变更', + noChanges: '无变更(工作区干净)', + noDiff: '无差异内容', + noCommits: '暂无提交记录', + modified: '已修改', + added: '已新增', + deleted: '已删除', + untracked: '未跟踪', + notGitRepo: '非 Git 仓库', + detached: 'HEAD 分离', + loadMore: '加载更多', + }, +} diff --git a/src/router/index.ts b/src/router/index.ts index d519405..0693b83 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -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(.*)*', diff --git a/src/views/FileExplorerDetached.vue b/src/views/FileExplorerDetached.vue new file mode 100644 index 0000000..6068f35 --- /dev/null +++ b/src/views/FileExplorerDetached.vue @@ -0,0 +1,55 @@ + + + + + diff --git a/src/views/ProjectDetail.vue b/src/views/ProjectDetail.vue index 11e84da..0ff91da 100644 --- a/src/views/ProjectDetail.vue +++ b/src/views/ProjectDetail.vue @@ -38,7 +38,8 @@ - + +