diff --git a/src-tauri/src/commands/module.rs b/src-tauri/src/commands/module.rs index 55cef92..d4b0b64 100644 --- a/src-tauri/src/commands/module.rs +++ b/src-tauri/src/commands/module.rs @@ -331,6 +331,62 @@ pub async fn get_module_git_status( Ok(serde_json::to_value(status).map_err(err_str)?) } +/// 列出工程本地分支(只读)。返回 { current, branches: [{ name, is_current }] }。 +#[tauri::command] +pub async fn list_branches( + state: State<'_, AppState>, + module_id: String, +) -> 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!({ "current": "", "branches": [] })); + } + let dir = module.path.clone(); + let result = std::thread::spawn(move || -> (String, Vec) { + // git branch --format="%(HEAD)%00%(refname:short)" + let out = std::process::Command::new("git") + .args(["branch", "--format=%(HEAD)%00%(refname:short)"]) + .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 current = String::new(); + let mut branches = Vec::new(); + for line in out.lines() { + let line = line.trim(); + if line.is_empty() { continue; } + // 格式: "*\0branch_name" 或 "\0branch_name" + let (head, name) = line.split_once('\u{0}').unwrap_or(("", line)); + let is_current = head.contains('*'); + let name = name.trim().to_string(); + if name.is_empty() { continue; } + if is_current { current = name.clone(); } + branches.push(serde_json::json!({ "name": name, "is_current": is_current })); + } + (current, branches) + }) + .join() + .map_err(|_| "分支列表查询线程 Join 失败".to_string())?; + + Ok(serde_json::json!({ + "current": result.0, + "branches": result.1, + })) +} + /// 在指定目录跑 git 命令采集状态(当前分支 / 改动文件 / 最近提交)。 /// /// 超时:每个 git 子进程 10s(用户读操作,卡死不应阻塞 UI)。任一命令失败时返回已采集部分。 @@ -999,7 +1055,7 @@ pub async fn get_commit_detail( return Ok(serde_json::json!({ "files": [], "diff": "" })); } let dir = module.path.clone(); - let (files, diff) = std::thread::spawn(move || -> (Vec, String) { + let (files, diff, parents, author, date, full_message) = std::thread::spawn(move || -> (Vec, String, Vec, String, String, 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]) @@ -1022,7 +1078,7 @@ pub async fn get_commit_detail( })); } } - // 2) 获取全量 diff:`git show ` + // 2) 获取全量 diff:`git show `(仅 diff 部分) let diff = std::process::Command::new("git") .args(["show", "--format=", &commit_hash]) .current_dir(&dir) @@ -1032,7 +1088,38 @@ pub async fn get_commit_detail( .ok() .and_then(|o| if o.status.success() { Some(String::from_utf8_lossy(&o.stdout).to_string()) } else { None }) .unwrap_or_default(); - (files, diff) + // 3) 获取提交元信息(父提交/作者/日期/完整消息): + // 用 printf 自定义格式,%P=父哈希(空格分隔多个)\t%an=作者\t%ad=日期\t%B=完整消息 + let meta = std::process::Command::new("git") + .args(["show", "-s", "--format=%P\t%an\t%ad\t%B", &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 parents = Vec::new(); + let mut author = String::new(); + let mut date = String::new(); + let mut full_message = String::new(); + if let Some(first_line) = meta.lines().next() { + let parts: Vec<&str> = first_line.splitn(4, '\t').collect(); + if parts.len() >= 1 { + parents = parts[0].split_whitespace().map(|s| s.to_string()).collect(); + } + if parts.len() >= 2 { author = parts[1].to_string(); } + if parts.len() >= 3 { date = parts[2].to_string(); } + if parts.len() >= 4 { full_message = parts[3].to_string(); } + } + // multi-line message: 补尾部行(meta.lines().next() 只取首行,需拼回) + if !full_message.is_empty() { + let rest = meta.lines().skip(1).collect::>().join("\n"); + if !rest.is_empty() { + full_message = format!("{}\n{}", full_message, rest); + } + } + (files, diff, parents, author, date, full_message) }) .join() .map_err(|_| "提交详情查询线程 Join 失败".to_string())?; @@ -1040,5 +1127,9 @@ pub async fn get_commit_detail( Ok(serde_json::json!({ "files": files, "diff": diff, + "parents": parents, + "author": author, + "date": date, + "full_message": full_message, })) } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 943fbf4..08ec5b6 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -326,6 +326,7 @@ pub fn run() { commands::module::list_project_modules, commands::module::scan_project_modules, commands::module::get_module_git_status, + commands::module::list_branches, // 工程文件浏览(Batch 10):文件树 + 单文件预览 commands::module::get_module_file_tree, commands::module::read_module_file, diff --git a/src/api/module.ts b/src/api/module.ts index b4cf997..48c72a7 100644 --- a/src/api/module.ts +++ b/src/api/module.ts @@ -152,6 +152,11 @@ export const moduleApi = { }, /** 分页查询工程 Git 提交历史。返回 { commits, has_more }。 */ + /** 列出工程本地分支(只读)。返回 { current, branches: [{ name, is_current }] }。 */ + listBranches(moduleId: string): Promise<{ current: string; branches: { name: string; is_current: boolean }[] }> { + return invoke('list_branches', { moduleId }) + }, + getModuleCommits(moduleId: string, skip?: number, limit?: number): Promise<{ commits: { hash: string; subject: string; timestamp: number; author: string }[] has_more: boolean @@ -163,6 +168,10 @@ export const moduleApi = { getCommitDetail(moduleId: string, commitHash: string): Promise<{ files: { status: string; path: string }[] diff: string + parents: string[] + author: string + date: string + full_message: string }> { return invoke('get_commit_detail', { moduleId, commitHash }) }, diff --git a/src/components/project/FileExplorer.vue b/src/components/project/FileExplorer.vue index 3e4094c..674bc00 100644 --- a/src/components/project/FileExplorer.vue +++ b/src/components/project/FileExplorer.vue @@ -181,6 +181,7 @@ */ import { ref, computed, watch, reactive, onMounted, onUnmounted } from 'vue' import { useI18n } from 'vue-i18n' +import { listen, type UnlistenFn } from '@tauri-apps/api/event' import { moduleApi, type ProjectModuleRecord, type FileTreeEntry, type GitStatusResult } from '@/api/module' import FileTree from './FileTree.vue' import FilePreview from './FilePreview.vue' @@ -364,6 +365,25 @@ function closeDropdown() { moduleDropdownOpen.value = false } onMounted(() => document.addEventListener('click', closeDropdown)) onUnmounted(() => document.removeEventListener('click', closeDropdown)) +// AI 工具写入文件后(df-data-changed entity=file)自动跳转变更视图 + 刷新 +let _unlistenDataChanged: UnlistenFn | null = null +onMounted(async () => { + _unlistenDataChanged = await listen<{ entity: string; action: string }>('df-data-changed', (e) => { + if (e.payload.entity !== 'file') return + // 刷新 git 状态缓存,使变更计数角标即时更新 + gitStatusData.value = null + if (viewMode.value === 'changes') { + // 已在变更视图,触发 GitChanges 重新拉取(切一次 viewMode 触发 watch) + viewMode.value = 'tree' + viewMode.value = 'changes' + } else { + // 不在变更视图,自动切换到变更视图,让用户看到 AI 写入的内容 + viewMode.value = 'changes' + } + }) +}) +onUnmounted(() => { _unlistenDataChanged?.() }) + // 自动扫描子仓库 async function onScanSubmodules() { moduleDropdownOpen.value = false diff --git a/src/components/project/FilePreview.vue b/src/components/project/FilePreview.vue index e2dfd76..2ee3bb3 100644 --- a/src/components/project/FilePreview.vue +++ b/src/components/project/FilePreview.vue @@ -130,21 +130,36 @@ interface DiffLine { newNum: string } +/** 解析 @@ -a,b +c,d @@ 头,返回旧/新行起始号 */ +function parseHunkHeader(line: string): { oldStart: number; newStart: number } { + // 格式: @@ -10,7 +10,8 @@ + const m = line.match(/@@\s+-(\d+)(?:,\d+)?\s+\+(\d+)(?:,\d+)?\s+@@/) + if (!m) return { oldStart: 1, newStart: 1 } + return { oldStart: parseInt(m[1], 10), newStart: parseInt(m[2], 10) } +} + const diffLines = computed(() => { if (!diffContent.value) return [] const lines: DiffLine[] = [] + let oldNum = 0 + let newNum = 0 for (const raw of diffContent.value.split('\n')) { if (raw.startsWith('@@')) { + const h = parseHunkHeader(raw) + oldNum = h.oldStart + newNum = h.newStart lines.push({ type: 'hdr', prefix: '', text: raw, oldNum: '', newNum: '' }) } else if (raw.startsWith('+')) { - lines.push({ type: 'add', prefix: '+', text: raw.slice(1), oldNum: '', newNum: '' }) + lines.push({ type: 'add', prefix: '+', text: raw.slice(1), oldNum: '', newNum: String(newNum++) }) } else if (raw.startsWith('-')) { - lines.push({ type: 'del', prefix: '-', text: raw.slice(1), oldNum: '', newNum: '' }) + lines.push({ type: 'del', prefix: '-', text: raw.slice(1), oldNum: String(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: '' }) + // 空串(diff 头前的空行)或 context 行(以空格开头) + const text = raw.startsWith(' ') ? raw.slice(1) : raw + lines.push({ type: 'ctx', prefix: ' ', text, oldNum: String(oldNum++), newNum: String(newNum++) }) } } return lines