新增: Git 能力闭环(Diff 行号解析/分支只读展示/提交详情/写入后自动刷新变更视图)
- Diff 行号修复:解析 @@ -a,b +c,d @@ 头,计算旧/新行号(原恒空字符串) - 提交详情增强:显示父提交哈希/作者/日期/完整消息(git show -s --format=%P\t%an) - 分支只读展示:list_branches IPC + 变更面板顶部下拉查看分支(切换走 AI 工具) - AI 工具写入后自动跳转变更视图:监听 df-data-changed entity=file 切 Tab + 刷新
This commit is contained in:
@@ -331,6 +331,62 @@ pub async fn get_module_git_status(
|
|||||||
Ok(serde_json::to_value(status).map_err(err_str)?)
|
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<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!({ "current": "", "branches": [] }));
|
||||||
|
}
|
||||||
|
let dir = module.path.clone();
|
||||||
|
let result = std::thread::spawn(move || -> (String, Vec<serde_json::Value>) {
|
||||||
|
// 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 命令采集状态(当前分支 / 改动文件 / 最近提交)。
|
||||||
///
|
///
|
||||||
/// 超时:每个 git 子进程 10s(用户读操作,卡死不应阻塞 UI)。任一命令失败时返回已采集部分。
|
/// 超时:每个 git 子进程 10s(用户读操作,卡死不应阻塞 UI)。任一命令失败时返回已采集部分。
|
||||||
@@ -999,7 +1055,7 @@ pub async fn get_commit_detail(
|
|||||||
return Ok(serde_json::json!({ "files": [], "diff": "" }));
|
return Ok(serde_json::json!({ "files": [], "diff": "" }));
|
||||||
}
|
}
|
||||||
let dir = module.path.clone();
|
let dir = module.path.clone();
|
||||||
let (files, diff) = std::thread::spawn(move || -> (Vec<serde_json::Value>, String) {
|
let (files, diff, parents, author, date, full_message) = std::thread::spawn(move || -> (Vec<serde_json::Value>, String, Vec<String>, String, String, String) {
|
||||||
// 1) 获取变更文件列表:`git diff-tree --no-commit-id -r --name-status <hash>`
|
// 1) 获取变更文件列表:`git diff-tree --no-commit-id -r --name-status <hash>`
|
||||||
let files_out = std::process::Command::new("git")
|
let files_out = std::process::Command::new("git")
|
||||||
.args(["diff-tree", "--no-commit-id", "-r", "--name-status", &commit_hash])
|
.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 <hash>`
|
// 2) 获取全量 diff:`git show <hash>`(仅 diff 部分)
|
||||||
let diff = std::process::Command::new("git")
|
let diff = std::process::Command::new("git")
|
||||||
.args(["show", "--format=", &commit_hash])
|
.args(["show", "--format=", &commit_hash])
|
||||||
.current_dir(&dir)
|
.current_dir(&dir)
|
||||||
@@ -1032,7 +1088,38 @@ pub async fn get_commit_detail(
|
|||||||
.ok()
|
.ok()
|
||||||
.and_then(|o| if o.status.success() { Some(String::from_utf8_lossy(&o.stdout).to_string()) } else { None })
|
.and_then(|o| if o.status.success() { Some(String::from_utf8_lossy(&o.stdout).to_string()) } else { None })
|
||||||
.unwrap_or_default();
|
.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::<Vec<_>>().join("\n");
|
||||||
|
if !rest.is_empty() {
|
||||||
|
full_message = format!("{}\n{}", full_message, rest);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(files, diff, parents, author, date, full_message)
|
||||||
})
|
})
|
||||||
.join()
|
.join()
|
||||||
.map_err(|_| "提交详情查询线程 Join 失败".to_string())?;
|
.map_err(|_| "提交详情查询线程 Join 失败".to_string())?;
|
||||||
@@ -1040,5 +1127,9 @@ pub async fn get_commit_detail(
|
|||||||
Ok(serde_json::json!({
|
Ok(serde_json::json!({
|
||||||
"files": files,
|
"files": files,
|
||||||
"diff": diff,
|
"diff": diff,
|
||||||
|
"parents": parents,
|
||||||
|
"author": author,
|
||||||
|
"date": date,
|
||||||
|
"full_message": full_message,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -326,6 +326,7 @@ pub fn run() {
|
|||||||
commands::module::list_project_modules,
|
commands::module::list_project_modules,
|
||||||
commands::module::scan_project_modules,
|
commands::module::scan_project_modules,
|
||||||
commands::module::get_module_git_status,
|
commands::module::get_module_git_status,
|
||||||
|
commands::module::list_branches,
|
||||||
// 工程文件浏览(Batch 10):文件树 + 单文件预览
|
// 工程文件浏览(Batch 10):文件树 + 单文件预览
|
||||||
commands::module::get_module_file_tree,
|
commands::module::get_module_file_tree,
|
||||||
commands::module::read_module_file,
|
commands::module::read_module_file,
|
||||||
|
|||||||
@@ -152,6 +152,11 @@ export const moduleApi = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
/** 分页查询工程 Git 提交历史。返回 { commits, has_more }。 */
|
/** 分页查询工程 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<{
|
getModuleCommits(moduleId: string, skip?: number, limit?: number): Promise<{
|
||||||
commits: { hash: string; subject: string; timestamp: number; author: string }[]
|
commits: { hash: string; subject: string; timestamp: number; author: string }[]
|
||||||
has_more: boolean
|
has_more: boolean
|
||||||
@@ -163,6 +168,10 @@ export const moduleApi = {
|
|||||||
getCommitDetail(moduleId: string, commitHash: string): Promise<{
|
getCommitDetail(moduleId: string, commitHash: string): Promise<{
|
||||||
files: { status: string; path: string }[]
|
files: { status: string; path: string }[]
|
||||||
diff: string
|
diff: string
|
||||||
|
parents: string[]
|
||||||
|
author: string
|
||||||
|
date: string
|
||||||
|
full_message: string
|
||||||
}> {
|
}> {
|
||||||
return invoke('get_commit_detail', { moduleId, commitHash })
|
return invoke('get_commit_detail', { moduleId, commitHash })
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -181,6 +181,7 @@
|
|||||||
*/
|
*/
|
||||||
import { ref, computed, watch, reactive, onMounted, onUnmounted } from 'vue'
|
import { ref, computed, watch, reactive, onMounted, onUnmounted } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
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 { moduleApi, type ProjectModuleRecord, type FileTreeEntry, type GitStatusResult } from '@/api/module'
|
||||||
import FileTree from './FileTree.vue'
|
import FileTree from './FileTree.vue'
|
||||||
import FilePreview from './FilePreview.vue'
|
import FilePreview from './FilePreview.vue'
|
||||||
@@ -364,6 +365,25 @@ function closeDropdown() { moduleDropdownOpen.value = false }
|
|||||||
onMounted(() => document.addEventListener('click', closeDropdown))
|
onMounted(() => document.addEventListener('click', closeDropdown))
|
||||||
onUnmounted(() => document.removeEventListener('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() {
|
async function onScanSubmodules() {
|
||||||
moduleDropdownOpen.value = false
|
moduleDropdownOpen.value = false
|
||||||
|
|||||||
@@ -130,21 +130,36 @@ interface DiffLine {
|
|||||||
newNum: string
|
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<DiffLine[]>(() => {
|
const diffLines = computed<DiffLine[]>(() => {
|
||||||
if (!diffContent.value) return []
|
if (!diffContent.value) return []
|
||||||
const lines: DiffLine[] = []
|
const lines: DiffLine[] = []
|
||||||
|
let oldNum = 0
|
||||||
|
let newNum = 0
|
||||||
for (const raw of diffContent.value.split('\n')) {
|
for (const raw of diffContent.value.split('\n')) {
|
||||||
if (raw.startsWith('@@')) {
|
if (raw.startsWith('@@')) {
|
||||||
|
const h = parseHunkHeader(raw)
|
||||||
|
oldNum = h.oldStart
|
||||||
|
newNum = h.newStart
|
||||||
lines.push({ type: 'hdr', prefix: '', text: raw, oldNum: '', newNum: '' })
|
lines.push({ type: 'hdr', prefix: '', text: raw, oldNum: '', newNum: '' })
|
||||||
} else if (raw.startsWith('+')) {
|
} 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('-')) {
|
} 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('\\')) {
|
} else if (raw.startsWith('\\')) {
|
||||||
// No newline at end of file 等元信息
|
// No newline at end of file 等元信息
|
||||||
lines.push({ type: 'ctx', prefix: '', text: raw, oldNum: '', newNum: '' })
|
lines.push({ type: 'ctx', prefix: '', text: raw, oldNum: '', newNum: '' })
|
||||||
} else {
|
} 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
|
return lines
|
||||||
|
|||||||
Reference in New Issue
Block a user