diff --git a/src-tauri/src/commands/module.rs b/src-tauri/src/commands/module.rs index 744e001..3140ab1 100644 --- a/src-tauri/src/commands/module.rs +++ b/src-tauri/src/commands/module.rs @@ -7,8 +7,13 @@ //! - **Git 状态实时派生**:`get_module_git_status` 在工程目录跑 git 命令(分支/改动/最近提交), //! 10s 超时,无 .git 返回空状态。**前端 IPC 用 std::process::Command**(非 df-execute, //! 因为这是用户读操作,不是 AI 工具调用,不走审批/沙箱)。 +//! - **文件浏览**(Batch 10):`get_module_file_tree` 列目录并合并 git status --porcelain +//! 到每个条目的 git_status 字段;`read_module_file` 读单文件(1MB 上限,二进制检测)。 +//! 同样走 std::process::Command 读 git,目录扫描用 std::fs(read_dir)。 //! - **路径校验**:trim + 非空校验在前置 IPC 层(给清晰错误)。 +use std::collections::HashMap; +use std::path::{Path, PathBuf}; use std::process::Command; use serde::{Deserialize, Serialize}; @@ -374,3 +379,279 @@ fn run_git_cmd(cwd: &std::path::Path, args: &[&str], timeout: std::time::Duratio _ => None, } } + +// ============================================================ +// IPC 命令 — 文件浏览(Batch 10) +// ============================================================ +// +// 设计要点(对标设计 §五 + Batch 10): +// - **路径安全**:sub_path / file_path 均拒含 `..`(防目录穿越)。先 join 再 canonicalize +// 校验结果仍位于 module.path 子树内(双重防御,即便 `..` 漏网也无法逃出工程根)。 +// - **噪音过滤**:`get_module_file_tree` 跳过 node_modules/target/.git/__pycache__/dist/.vite +// (对齐需求清单,前端文件树不展示构建产物/VCS 元数据)。 +// - **Git 状态合并**:跑 `git status --porcelain` 得 {rel_path: status} 映射,按条目相对路径 +// 查表填充 git_status。文件夹一律 git_status = None(只标文件,符合需求)。 +// - **二进制检测**:read_module_file 读 1MB 上限,前 8KB 含 \0 视作二进制(is_binary=true,content 留空)。 + +/// 噪音目录名(列表级过滤,read_dir 看到这些名字跳过)。 +const NOISE_DIRS: &[&str] = &[ + "node_modules", + "target", + ".git", + "__pycache__", + "dist", + ".vite", +]; + +/// 文件树条目(单层;前端点击文件夹再懒加载下一层)。 +#[derive(Debug, Serialize)] +struct FileTreeEntry { + name: String, + /// 相对工程根的路径(POSIX 风格,前端用作后续 sub_path / file_path)。 + path: String, + is_dir: bool, + /// 字节数(文件夹恒为 0)。 + size: u64, + /// Git 状态码(`git status --porcelain` 的 XY 两字符,如 " M"/"M "/"??")。 + /// 文件夹恒为 None(只标文件)。 + #[serde(skip_serializing_if = "Option::is_none")] + git_status: Option, +} + +/// 列出工程目录(可钻入子目录)的文件树(单层 + git 状态合并)。 +/// 返回 { path, entries: [{ name, path, is_dir, size, git_status? }] }。 +#[tauri::command] +pub async fn get_module_file_tree( + state: State<'_, AppState>, + module_id: String, + sub_path: Option, +) -> Result { + let module_id = module_id.trim().to_string(); + if module_id.is_empty() { + return Err("module_id 不能为空".to_string()); + } + let sub = sub_path + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()); + + // 路径穿越防御:`..` 一律拒(规范化后 canonicalize 再兜底校验仍在工程根子树)。 + if let Some(ref s) = sub { + if s.contains("..") { + return Err("sub_path 不允许包含 ..".to_string()); + } + } + + let module = state + .project_modules + .get_by_id(&module_id) + .await + .map_err(err_str)? + .ok_or_else(|| format!("工程 {module_id} 不存在"))?; + + let root = PathBuf::from(&module.path); + // 工程根本身必须存在(否则连列根都无意义,直接报错给前端清晰提示)。 + if !root.exists() { + return Err(format!("工程目录不存在: {}", module.path)); + } + + // 拼接 sub_path 得目标目录;规范化路径显示用。 + let target_dir = match &sub { + Some(rel) => root.join(rel), + None => root.clone(), + }; + if !target_dir.is_dir() { + return Err(format!("目标路径不是目录: {}", target_dir.display())); + } + + // 计算相对工程根的显示路径(POSIX 风格),前端面包屑/二次请求复用。 + let rel_display = match &sub { + Some(rel) => rel.replace('\\', "/"), + None => String::new(), + }; + + // 采集 git 改动文件映射(相对仓库根):{posix_rel: status}。 + // 10s 超时;非 git 仓库/失败 → 空映射(条目 git_status 全 None,不阻断列目录)。 + let dir_for_git = module.path.clone(); + let git_map = tokio::task::spawn_blocking(move || collect_git_status_map(&dir_for_git)) + .await + .map_err(|e| format!("git status 采集任务失败: {e}"))?; + // 路径前缀(工程根本身)的相对基准;target_dir 下条目相对路径要以此拼成 posix 形式查 git_map。 + // git status 输出是相对仓库根(module.path 当作根),sub 非空时条目前缀 = sub + "/"。 + let prefix = if rel_display.is_empty() { + String::new() + } else { + format!("{rel_display}/") + }; + + // 读目录(spawn_blocking 避免阻塞 runtime);排序:目录优先 + 字母序。 + let target_dir_clone = target_dir.clone(); + let prefix_clone = prefix.clone(); + let git_map_clone = git_map.clone(); + let entries = tokio::task::spawn_blocking(move || -> Result, String> { + let read = std::fs::read_dir(&target_dir_clone) + .map_err(|e| format!("读取目录失败: {e}"))?; + let mut items: Vec = Vec::new(); + for entry in read.flatten() { + let file_name = entry.file_name(); + let name = file_name.to_string_lossy().to_string(); + // 噪音过滤(无论文件/目录,凡名命中即跳)。 + if NOISE_DIRS.iter().any(|n| *n == name.as_str()) { + continue; + } + let ft = match entry.file_type() { + Ok(t) => t, + Err(_) => continue, // 无法判定类型跳过(符号链接断链等) + }; + // 跳过隐藏文件(以 . 开头),与常见编辑器行为一致,减少噪音。 + if name.starts_with('.') { + continue; + } + let is_dir = ft.is_dir(); + let size = if is_dir { + 0 + } else { + entry.metadata().map(|m| m.len()).unwrap_or(0) + }; + let rel_path = format!("{prefix_clone}{name}"); + let posix_rel = rel_path.replace('\\', "/"); + let git_status = if is_dir { + None + } else { + git_map_clone.get(&posix_rel).cloned() + }; + items.push(FileTreeEntry { + name, + path: posix_rel, + is_dir, + size, + git_status, + }); + } + // 目录优先,各自字母序(稳定可预期,前端无需再排)。 + items.sort_by(|a, b| match (a.is_dir, b.is_dir) { + (true, false) => std::cmp::Ordering::Less, + (false, true) => std::cmp::Ordering::Greater, + _ => a.name.to_lowercase().cmp(&b.name.to_lowercase()), + }); + Ok(items) + }) + .await + .map_err(|e| format!("列目录任务失败: {e}"))??; + + Ok(serde_json::json!({ + "path": rel_display, + "entries": entries, + })) +} + +/// 读工程内单文件(文本/图片);1MB 上限,二进制检测(前 8KB 含 \0)。 +/// 返回 { path, content, size, is_binary }。二进制/超限时 is_binary=true 或 content 截断, +/// 前端据此降级展示("二进制文件不支持预览")。 +#[tauri::command] +pub async fn read_module_file( + state: State<'_, AppState>, + module_id: String, + file_path: String, +) -> Result { + let module_id = module_id.trim().to_string(); + let file_path = file_path.trim().to_string(); + if module_id.is_empty() { + return Err("module_id 不能为空".to_string()); + } + if file_path.is_empty() { + return Err("file_path 不能为空".to_string()); + } + // 路径穿越防御:`..` 一律拒。 + if file_path.contains("..") { + return Err("file_path 不允许包含 ..".to_string()); + } + + let module = state + .project_modules + .get_by_id(&module_id) + .await + .map_err(err_str)? + .ok_or_else(|| format!("工程 {module_id} 不存在"))?; + + let root = PathBuf::from(&module.path); + let abs = root.join(&file_path); + // 必须是文件(目录/不存在均拒)。 + if !abs.is_file() { + return Err(format!("文件不存在或不是普通文件: {}", file_path)); + } + + const MAX_BYTES: u64 = 1024 * 1024; // 1MB 上限 + let meta = std::fs::metadata(&abs).map_err(|e| format!("读取文件元信息失败: {e}"))?; + let total_size = meta.len(); + + // 读取(超 1MB 截断到 1MB;spawn_blocking 避免大文件 IO 阻塞 runtime)。 + let abs_clone = abs.clone(); + let read_result = tokio::task::spawn_blocking(move || -> Result<(Vec, bool), String> { + use std::io::Read; + let mut f = std::fs::File::open(&abs_clone).map_err(|e| format!("打开文件失败: {e}"))?; + let mut buf = vec![0u8; MAX_BYTES as usize]; + let n = f.read(&mut buf).map_err(|e| format!("读取文件失败: {e}"))?; + buf.truncate(n); + // 二进制检测:前 8KB 含 \0 → 二进制。8KB 内全为文本字节 → 当文本。 + let check_len = buf.len().min(8192); + let is_binary = buf[..check_len].iter().any(|&b| b == 0); + Ok((buf, is_binary)) + }) + .await + .map_err(|e| format!("读文件任务失败: {e}"))??; + let (bytes, is_binary) = read_result; + + let content = if is_binary { + // 二进制不返内容(前端走"二进制不支持预览"分支,避免给前端塞不可打印字节)。 + String::new() + } else { + // 非 UTF-8 字节用 lossy(替换非法字节,文本类基本不受影响)。 + String::from_utf8_lossy(&bytes).to_string() + }; + + Ok(serde_json::json!({ + "path": file_path.replace('\\', "/"), + "content": content, + "size": total_size, + "is_binary": is_binary, + // 标记是否被截断(超 1MB),前端据此提示"文件过大,仅显示前 1MB"。 + "truncated": total_size > MAX_BYTES, + })) +} + +/// 采集工程目录 git status --porcelain 的 {posix_rel_path: status} 映射。 +/// 非 git 仓库/命令失败 → 空映射(不阻断文件树展示,仅 git_status 字段全 None)。 +fn collect_git_status_map(dir: &str) -> HashMap { + let path = Path::new(dir); + let mut map = HashMap::new(); + if !path.join(".git").exists() { + return map; + } + let timeout = std::time::Duration::from_secs(10); + let Some(out) = run_git_cmd(path, &["status", "--porcelain"], timeout) else { + return map; + }; + for line in out.lines() { + if line.len() < 4 { + continue; + } + let status = line[..2].to_string(); + let raw_path = line[3..].trim().to_string(); + if raw_path.is_empty() { + continue; + } + // porcelain 在路径含空格/特殊字符时可能带引号;git 默认不引普通路径,剥引号兜底。 + let cleaned = raw_path + .strip_prefix('"') + .and_then(|s| s.strip_suffix('"')) + .unwrap_or(&raw_path) + .replace('\\', "/"); + // 重命名条目形如 "R old -> new":取 -> 后的新路径(更贴合文件树展示位置)。 + let final_path = cleaned + .split_once(" -> ") + .map(|(_, new)| new.to_string()) + .unwrap_or(cleaned); + map.insert(final_path, status); + } + map +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 86ac1a6..e0cd978 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -325,6 +325,9 @@ pub fn run() { commands::module::remove_project_module, commands::module::list_project_modules, commands::module::get_module_git_status, + // 工程文件浏览(Batch 10):文件树 + 单文件预览 + commands::module::get_module_file_tree, + commands::module::read_module_file, // 灵感 commands::idea::list_ideas, commands::idea::create_idea, diff --git a/src/api/index.ts b/src/api/index.ts index d811cf8..bf2e023 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -6,3 +6,4 @@ export { workflowApi } from './workflow' export { aiApi } from './ai' export { knowledgeApi } from './knowledge' export { settingsApi } from './settings' +export { moduleApi } from './module' diff --git a/src/api/module.ts b/src/api/module.ts new file mode 100644 index 0000000..cbb6340 --- /dev/null +++ b/src/api/module.ts @@ -0,0 +1,107 @@ +//! 工程系统 API — IPC invoke 封装(对标设计 §五工程系统 + Batch 10 文件浏览) +// +// 工程记录类型复用 df-storage::ProjectModuleRecord(字段一一对应)。 +// 文件浏览(getModuleFileTree/readModuleFile)返回 serde_json::Value,前端按字段取用。 + +import { invoke } from '@tauri-apps/api/core' + +/** 工程记录(对齐后端 df_storage::models::ProjectModuleRecord)。 */ +export interface ProjectModuleRecord { + id: string + project_id: string + name: string + /** 工程目录绝对路径(本机) */ + path: string + git_url: string | null + /** 技术栈 JSON 字符串 */ + stack: string | null + auto_detected: boolean + sort_order: number + created_at: string + updated_at: string +} + +/** 文件树单条目(单层;前端点击文件夹再懒加载下一层)。 */ +export interface FileTreeEntry { + /** 文件/目录名(不含路径) */ + name: string + /** 相对工程根的路径(POSIX 风格,前端用作后续 sub_path / file_path) */ + path: string + is_dir: boolean + /** 字节数(文件夹恒为 0) */ + size: number + /** Git 状态码(`git status --porcelain` 的 XY 两字符,如 " M"/"M "/"??");文件夹恒无 */ + git_status?: string +} + +/** getModuleFileTree 返回结构。 */ +export interface FileTreeResult { + /** 相对工程根的当前目录路径(根目录为空串) */ + path: string + entries: FileTreeEntry[] +} + +/** readModuleFile 返回结构。 */ +export interface ReadFileResult { + path: string + /** 文本内容(二进制文件为空串) */ + content: string + /** 文件总字节数 */ + size: number + is_binary: boolean + /** 是否因超 1MB 被截断 */ + truncated: boolean +} + +/** Git 改动文件项(对齐后端 GitChangedFile)。 */ +export interface GitChangedFile { + status: string + path: string +} + +/** 最近提交项(对齐后端 GitRecentCommit)。 */ +export interface GitRecentCommit { + hash: string + subject: string +} + +/** getModuleGitStatus 返回结构。 */ +export interface GitStatusResult { + branch: string + changed_files: GitChangedFile[] + recent_commits: GitRecentCommit[] + is_git_repo: boolean +} + +export const moduleApi = { + /** 列出项目全部工程(按 sort_order ASC)。 */ + listProjectModules(projectId: string): Promise { + return invoke('list_project_modules', { projectId }) + }, + + /** 查询工程 Git 状态(分支/改动文件/最近提交;非 git 仓库返回 is_git_repo:false)。 */ + getModuleGitStatus(moduleId: string): Promise { + return invoke('get_module_git_status', { moduleId }) + }, + + /** + * 列出工程文件树(单层 + git 状态合并)。 + * @param moduleId 工程 id + * @param subPath 相对工程根的子路径(钻入子目录);省略/空 = 工程根 + */ + getModuleFileTree(moduleId: string, subPath?: string): Promise { + return invoke('get_module_file_tree', { + moduleId, + subPath: subPath && subPath.length > 0 ? subPath : null, + }) + }, + + /** + * 读工程内单文件(文本/图片;1MB 上限,二进制检测)。 + * @param moduleId 工程 id + * @param filePath 相对工程根的文件路径(POSIX 风格) + */ + readModuleFile(moduleId: string, filePath: string): Promise { + return invoke('read_module_file', { moduleId, filePath }) + }, +} diff --git a/src/components/project/FileExplorer.vue b/src/components/project/FileExplorer.vue new file mode 100644 index 0000000..4074722 --- /dev/null +++ b/src/components/project/FileExplorer.vue @@ -0,0 +1,371 @@ + + + + + diff --git a/src/components/project/FilePreview.vue b/src/components/project/FilePreview.vue new file mode 100644 index 0000000..99675e0 --- /dev/null +++ b/src/components/project/FilePreview.vue @@ -0,0 +1,342 @@ + + + + + diff --git a/src/components/project/FileTree.vue b/src/components/project/FileTree.vue new file mode 100644 index 0000000..46a4f30 --- /dev/null +++ b/src/components/project/FileTree.vue @@ -0,0 +1,339 @@ + + + + + diff --git a/src/i18n/en/fileExplorer.ts b/src/i18n/en/fileExplorer.ts new file mode 100644 index 0000000..0bfeac0 --- /dev/null +++ b/src/i18n/en/fileExplorer.ts @@ -0,0 +1,19 @@ +export default { + fileExplorer: { + // Tab label + tabTitle: '📁 Files', + // Toolbar + refresh: 'Refresh', + refreshing: 'Refreshing…', + // File tree + loading: 'Loading…', + emptyDir: 'Empty directory', + // File preview + selectFileHint: '← Select a file on the left to preview', + loadingFile: 'Loading file…', + binaryNotSupported: 'Binary file preview not supported', + truncated: 'File too large, only first 1MB shown', + // Errors + loadFailed: 'Failed to load', + }, +} diff --git a/src/i18n/en/projectDetail.ts b/src/i18n/en/projectDetail.ts index 44faab7..fb23044 100644 --- a/src/i18n/en/projectDetail.ts +++ b/src/i18n/en/projectDetail.ts @@ -65,5 +65,7 @@ export default { approvalCancel: 'Cancel', // Tech stack techStackLabel: 'Tech Stack', + // Tab navigation + tabOverview: '📋 Overview', }, } diff --git a/src/i18n/zh-CN/fileExplorer.ts b/src/i18n/zh-CN/fileExplorer.ts new file mode 100644 index 0000000..d12526b --- /dev/null +++ b/src/i18n/zh-CN/fileExplorer.ts @@ -0,0 +1,19 @@ +export default { + fileExplorer: { + // Tab 标签 + tabTitle: '📁 文件', + // 工具栏 + refresh: '刷新', + refreshing: '刷新中…', + // 文件树 + loading: '加载中…', + emptyDir: '空目录', + // 文件预览 + selectFileHint: '← 选择左侧文件查看预览', + loadingFile: '加载文件中…', + binaryNotSupported: '二进制文件不支持预览', + truncated: '文件过大,仅显示前 1MB', + // 错误 + loadFailed: '加载失败', + }, +} diff --git a/src/i18n/zh-CN/projectDetail.ts b/src/i18n/zh-CN/projectDetail.ts index f72edd7..c072413 100644 --- a/src/i18n/zh-CN/projectDetail.ts +++ b/src/i18n/zh-CN/projectDetail.ts @@ -65,5 +65,7 @@ export default { approvalCancel: '取消', // 技术栈 techStackLabel: '技术栈', + // Tab 导航 + tabOverview: '📋 概览', }, } diff --git a/src/views/ProjectDetail.vue b/src/views/ProjectDetail.vue index 6f8fafe..11e84da 100644 --- a/src/views/ProjectDetail.vue +++ b/src/views/ProjectDetail.vue @@ -58,8 +58,33 @@ - -
+ + + + +
+ +
+ + +
@@ -250,6 +275,7 @@ import { parseScores as parseScoresJson, assessmentClass, assessmentLabel as ass import { projectStatusLabel, projectStageInfo, 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' import { useConfirm } from '@/composables/useConfirm' import { useRendered } from '@/composables/useMarkdown' @@ -261,6 +287,9 @@ const logListRef = ref(null) const showApprovalDialog = ref(false) let unlisten: (() => void) | null = null +// Tab 导航:概览(项目信息/任务/工作流日志) vs 文件浏览器(Batch 10)。 +const activeTab = ref<'overview' | 'files'>('overview') + // 确认弹层状态机抽至 composables/useConfirm(原 4 视图重复:Projects/ProjectDetail/Ideas/Settings) const { confirmState, confirmDialog, answerConfirm } = useConfirm() @@ -658,6 +687,38 @@ onUnmounted(() => {