- 知识创建失败不再误关弹窗弹成功提示(补返回值检查) - AuditLog 翻页补请求序号守卫防慢响应覆盖新页 - TaskDrawer 切任务慢响应校验 taskId 才写入 - GitChanges 竞态序号+moduleId 双校验,三处静默失败补错误提示 - 回收站恢复/彻底删除操作中禁用防双击重复提交 - 灵感状态切换失败补错误反馈(原 unhandled rejection) - FileExplorer 三处失败仅 console 补 Message 提示 - Dashboard 消费 store.error 故障不再伪装空数据
827 lines
30 KiB
Vue
827 lines
30 KiB
Vue
<template>
|
|
<div class="git-changes">
|
|
<!-- 分支信息(变更 + 历史 Tab 均显示当前分支) -->
|
|
<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>
|
|
<span v-else class="branch-count">{{ totalCommits }} {{ $t('gitChanges.commitsUnit') }}</span>
|
|
<button v-if="!showHistory" class="branch-switch-btn" @click="onSwitchBranch" :title="$t('gitChanges.switchBranch')">
|
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="6" y1="3" x2="6" y2="15"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/></svg>
|
|
</button>
|
|
</div>
|
|
|
|
<!-- 分支列表弹窗(只读查看,切换走 AI 工具) -->
|
|
<div v-if="showBranchPicker" class="gc-branch-picker">
|
|
<div class="gc-branch-picker-header">
|
|
<span>{{ $t('gitChanges.branches') }}</span>
|
|
<button class="gc-branch-picker-close" @click="closeBranchPicker">✕</button>
|
|
</div>
|
|
<div v-if="branchLoading" class="gc-loading"><span class="spinner"></span></div>
|
|
<div v-else class="gc-branch-list">
|
|
<div
|
|
v-for="b in branchList"
|
|
:key="b.name"
|
|
class="gc-branch-item"
|
|
:class="{ current: b.is_current }"
|
|
>
|
|
<span class="gc-branch-icon">{{ b.is_current ? '●' : '○' }}</span>
|
|
<span class="gc-branch-name">{{ b.name }}</span>
|
|
<span v-if="b.is_current" class="gc-branch-tag">HEAD</span>
|
|
</div>
|
|
<div v-if="branchList.length === 0" class="gc-empty">{{ $t('gitChanges.noBranches') }}</div>
|
|
</div>
|
|
<div class="gc-branch-picker-hint">{{ $t('gitChanges.branchSwitchHint') }}</div>
|
|
</div>
|
|
|
|
<!-- 行内错误提示(loadMore/selectFile 失败,不挤掉列表) -->
|
|
<div v-if="actionError" class="gc-error-strip">⚠ {{ actionError }}</div>
|
|
|
|
<div v-if="loading && !gitStatus" class="gc-skeleton">
|
|
<div class="gc-skeleton-row" v-for="i in 6" :key="i"></div>
|
|
</div>
|
|
|
|
<!-- 加载失败(gitStatus 为空,不再伪装空白) -->
|
|
<div v-else-if="loadError" class="gc-empty gc-error">⚠ {{ loadError }}</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">
|
|
<!-- 提交搜索框(前端过滤已加载提交:subject/author) -->
|
|
<div class="gc-search">
|
|
<svg class="gc-search-icon" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
|
<input
|
|
v-model="commitSearch"
|
|
class="gc-search-input"
|
|
:placeholder="$t('gitChanges.searchPlaceholder')"
|
|
type="text"
|
|
/>
|
|
<button v-if="commitSearch" class="gc-search-clear" @click="commitSearch = ''">✕</button>
|
|
</div>
|
|
|
|
<!-- 提交列表(始终可见,双行布局) -->
|
|
<div class="gc-history-list">
|
|
<div v-if="filteredCommits.length === 0" class="gc-empty gc-empty-inline">
|
|
{{ commitSearch ? $t('gitChanges.noSearchResult') : $t('gitChanges.noCommits') }}
|
|
</div>
|
|
<div
|
|
v-for="c in filteredCommits"
|
|
:key="c.hash"
|
|
class="gc-commit-row"
|
|
:class="{ selected: selectedCommit?.hash === c.hash }"
|
|
@click="selectCommit(c)"
|
|
>
|
|
<div class="gc-commit-line1">
|
|
<span class="gc-commit-hash">{{ c.hash.slice(0, 8) }}</span>
|
|
<span class="gc-commit-author">{{ c.author }}</span>
|
|
<span class="gc-commit-time">{{ formatTime(c.timestamp) }}</span>
|
|
</div>
|
|
<div class="gc-commit-msg">{{ c.subject }}</div>
|
|
</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>
|
|
|
|
<!-- ====== 提交详情(从底部/侧边滑出,列表始终可见) ====== -->
|
|
<transition name="gc-detail-slide">
|
|
<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-author">{{ selectedCommit.author }}</span>
|
|
<span class="gc-commit-detail-time">{{ formatTime(selectedCommit.timestamp) }}</span>
|
|
<span class="gc-commit-detail-msg">{{ selectedCommit.subject }}</span>
|
|
<button class="gc-detail-close" @click="closeCommitDetail">✕</button>
|
|
</div>
|
|
<div v-if="commitDetailLoading" class="gc-loading"><span class="spinner"></span></div>
|
|
<template v-else-if="commitDetail">
|
|
<!-- 变更文件列表(点击文件 → diff 渲染到右侧宽预览区,见 emit select-file) -->
|
|
<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>
|
|
</template>
|
|
</div>
|
|
</transition>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
/**
|
|
* Git 变更查看器 — 变更文件列表(按目录树分组) + 提交历史(分页/搜索) + 文件 diff 预览。
|
|
*
|
|
* 布局:变更 Tab 点文件 / 提交历史点文件 → emit select-file,把该文件 diff 注入右侧 FilePreview
|
|
* 宽预览区渲染;本组件窄栏只保留文件列表与提交详情(文件清单),不再内嵌 diff —— 几百行 diff
|
|
* 在 30% 窄栏内渲染会被挤压/截断,故 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
|
|
/** B2[PD-P1-5]:外部(FileExplorer df-data-changed)自增刷新信号,变化时重拉 git 状态。 */
|
|
refreshKey?: number
|
|
}>()
|
|
|
|
const emit = defineEmits<{
|
|
/** 选择文件 → 在右侧宽预览区展示其 diff(第二个参数为可选注入的 diff 内容)。 */
|
|
(e: 'select-file', path: string, diff?: 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)
|
|
// 行内错误提示:loadError 主区展示(loadStatus 失败),actionError 分支条下方展示(loadMore/selectFile 失败)
|
|
const loadError = ref('')
|
|
const actionError = ref('')
|
|
// 请求序号守卫:moduleId 切换与 refreshKey 刷新双触发源并发时,旧响应晚到不覆盖新模块数据
|
|
// (对齐 ProjectDetail taskReqSeq 模式)
|
|
let statusReqSeq = 0
|
|
|
|
/** 单条提交记录形状(对齐 getModuleCommits / GitRecentCommit)。 */
|
|
interface CommitItem {
|
|
hash: string
|
|
subject: string
|
|
timestamp: number
|
|
author: string
|
|
}
|
|
|
|
// 提交历史(分页)
|
|
const commits = ref<CommitItem[]>([])
|
|
const totalCommits = ref(0)
|
|
const hasMoreCommits = ref(false)
|
|
const loadingMore = ref(false)
|
|
const commitSkip = ref(0)
|
|
const COMMIT_PAGE_SIZE = 50
|
|
|
|
// 提交搜索(前端过滤已加载提交:按 subject 关键词或作者)
|
|
const commitSearch = ref('')
|
|
|
|
/** 已加载提交按搜索词过滤(subject 包含或 author 包含)。 */
|
|
const filteredCommits = computed<CommitItem[]>(() => {
|
|
const q = commitSearch.value.trim().toLowerCase()
|
|
if (!q) return commits.value
|
|
return commits.value.filter(
|
|
(c) => c.subject.toLowerCase().includes(q) || c.author.toLowerCase().includes(q),
|
|
)
|
|
})
|
|
|
|
// 已选提交详情
|
|
const selectedCommit = ref<CommitItem | null>(null)
|
|
const commitDetail = ref<{ files: { status: string; path: string }[]; diff: string } | null>(null)
|
|
const commitDetailLoading = ref(false)
|
|
const commitSelectedFile = 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)
|
|
})
|
|
|
|
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
|
|
}
|
|
|
|
/**
|
|
* 从一次提交的全量 diff 文本中,精确提取指定 path 对应的那一块。
|
|
*
|
|
* 按 `diff --git a/<path> b/<path>` 头切块,避免子串误配
|
|
* (例:`line.includes("src/app.rs")` 会误匹配 `src/app.rs.bak`)。
|
|
* 解析 a/ 与 b/ 路径,任一等于目标 path 即命中;切块持续到下一个 `diff --git` 或文本末尾。
|
|
*/
|
|
function extractFileDiff(allDiff: string, targetPath: string): string {
|
|
if (!allDiff) return ''
|
|
const lines = allDiff.split('\n')
|
|
const chunks: string[][] = []
|
|
const chunkPaths: string[] = []
|
|
// 第一行若不是 diff 头(罕见:全局元信息),归入一个无 path 的前置块,不参与匹配。
|
|
let current: string[] | null = null
|
|
|
|
for (const line of lines) {
|
|
if (line.startsWith('diff --git ')) {
|
|
// 起新块
|
|
if (current) chunks.push(current)
|
|
current = [line]
|
|
chunkPaths.push(parseDiffGitPath(line))
|
|
} else {
|
|
if (!current) current = [] // 兜底:头之前的行
|
|
current.push(line)
|
|
}
|
|
}
|
|
if (current) chunks.push(current)
|
|
|
|
// 定位目标块:精确匹配 a/ 或 b/ 路径(支持重命名,两边任一命中即取该块)
|
|
for (let i = 0; i < chunks.length; i++) {
|
|
const p = chunkPaths[i]
|
|
if (p && (p === targetPath || pathEqualsAnySide(p, targetPath))) {
|
|
return chunks[i].join('\n')
|
|
}
|
|
}
|
|
return ''
|
|
}
|
|
|
|
/** 解析 `diff --git a/<path> b/<path>` 头,返回 "a/path|b/path" 复合串(用于后续精确比对)。 */
|
|
function parseDiffGitPath(header: string): string {
|
|
// 形如 "diff --git a/foo/bar.rs b/foo/bar.rs",空格分隔;path 内不含空格时成立。
|
|
// 取第 3、4 段(a/.. b/..),用 "|" 拼接便于 pathEqualsAnySide 判定。
|
|
const parts = header.split(/\s+/)
|
|
const a = parts[2] || ''
|
|
const b = parts[3] || ''
|
|
return `${a}|${b}`
|
|
}
|
|
|
|
/** 给定 parseDiffGitPath 产物与目标 path,判断目标是否等于 a/ 或 b/ 路径。 */
|
|
function pathEqualsAnySide(parsed: string, targetPath: string): boolean {
|
|
const [a, b] = parsed.split('|')
|
|
const aPath = a.startsWith('a/') ? a.slice(2) : a
|
|
const bPath = b.startsWith('b/') ? b.slice(2) : b
|
|
return aPath === targetPath || bPath === targetPath
|
|
}
|
|
|
|
async function loadStatus() {
|
|
const mid = props.moduleId
|
|
if (!mid) return
|
|
const seq = ++statusReqSeq
|
|
loading.value = true
|
|
loadError.value = ''
|
|
actionError.value = ''
|
|
try {
|
|
const res = await moduleApi.getModuleGitStatus(mid)
|
|
// 序号 + moduleId 双校验:响应回来时仍是最新请求且模块未切换才写入
|
|
if (seq !== statusReqSeq || mid !== props.moduleId) return
|
|
gitStatus.value = res
|
|
// 复用 getModuleGitStatus 内部已返回的 recent_commits(避免再发一次 getModuleCommits
|
|
// 造成首次 git log -50 重复请求 + recent_commits 被丢弃)。
|
|
const recent = res?.recent_commits ?? []
|
|
commits.value = recent.map((c) => ({
|
|
hash: c.hash,
|
|
subject: c.subject,
|
|
timestamp: c.timestamp,
|
|
author: c.author,
|
|
}))
|
|
// 全量提交数读后端 total_commits(git rev-list --count HEAD),非 commits.length(后者受分页限制)。
|
|
const backendTotal = res?.total_commits ?? 0
|
|
totalCommits.value = backendTotal
|
|
commitSkip.value = commits.value.length
|
|
// has_more:后端无 total 时(commits 仍可能未全载)用页大小推断;有 total 时按已载 < total 判定。
|
|
hasMoreCommits.value =
|
|
backendTotal > 0
|
|
? commits.value.length < backendTotal
|
|
: commits.value.length >= COMMIT_PAGE_SIZE
|
|
} catch (e) {
|
|
if (seq !== statusReqSeq || mid !== props.moduleId) return // 旧请求的失败不污染当前视图
|
|
gitStatus.value = null
|
|
commits.value = []
|
|
loadError.value = String(e)
|
|
} finally {
|
|
if (seq === statusReqSeq) loading.value = false
|
|
}
|
|
}
|
|
|
|
/** 占位:分支切换后续接入(需拉取分支列表 + checkout IPC)。 */
|
|
const showBranchPicker = ref(false)
|
|
const branchList = ref<{ name: string; is_current: boolean }[]>([])
|
|
const branchLoading = ref(false)
|
|
const currentBranch = ref('')
|
|
|
|
async function onSwitchBranch() {
|
|
if (showBranchPicker.value) {
|
|
showBranchPicker.value = false
|
|
return
|
|
}
|
|
showBranchPicker.value = true
|
|
branchLoading.value = true
|
|
try {
|
|
const res = await moduleApi.listBranches(props.moduleId)
|
|
branchList.value = res.branches
|
|
currentBranch.value = res.current
|
|
} catch {
|
|
branchList.value = []
|
|
} finally {
|
|
branchLoading.value = false
|
|
}
|
|
}
|
|
|
|
function closeBranchPicker() {
|
|
showBranchPicker.value = false
|
|
}
|
|
|
|
async function loadMoreCommits() {
|
|
if (loadingMore.value || !hasMoreCommits.value) return
|
|
loadingMore.value = true
|
|
actionError.value = ''
|
|
try {
|
|
const res = await moduleApi.getModuleCommits(props.moduleId, commitSkip.value, COMMIT_PAGE_SIZE)
|
|
// 按哈希去重(防止分页边界与 recent_commits 重叠)
|
|
const exist = new Set(commits.value.map((c) => c.hash))
|
|
for (const c of res.commits) {
|
|
if (!exist.has(c.hash)) commits.value.push(c)
|
|
}
|
|
hasMoreCommits.value = res.has_more
|
|
commitSkip.value += res.commits.length
|
|
// 全量提交数读后端 total(git rev-list --count HEAD),非 commits.length(后者受分页限制)。
|
|
totalCommits.value = res.total
|
|
} catch (e) {
|
|
actionError.value = String(e)
|
|
} finally {
|
|
loadingMore.value = false
|
|
}
|
|
}
|
|
|
|
async function switchToHistory() {
|
|
showHistory.value = true
|
|
// recent_commits 已填首页,无需在此补页(原 < 50 补页逻辑已无意义,留 loadMore 手动触发)
|
|
}
|
|
|
|
async function selectFile(path: string) {
|
|
selectedFile.value = path
|
|
// 先切预览到该文件(内容视图),diff 拉到后回填,点文件即展示变化
|
|
emit('select-file', path)
|
|
diffContent.value = ''
|
|
actionError.value = ''
|
|
diffLoading.value = true
|
|
try {
|
|
const res = await moduleApi.getModuleFileDiff(props.moduleId, path)
|
|
diffContent.value = res.diff || ''
|
|
} catch (e) {
|
|
diffContent.value = ''
|
|
actionError.value = String(e)
|
|
} finally {
|
|
diffLoading.value = false
|
|
}
|
|
// 仅当仍是当前选中文件时才回填 diff,防快速切换时旧响应覆盖新文件
|
|
if (selectedFile.value === path) {
|
|
emit('select-file', path, diffContent.value)
|
|
}
|
|
}
|
|
|
|
/** 点击提交行 → 加载该提交的变更文件列表 */
|
|
async function selectCommit(c: CommitItem) {
|
|
// 点击已选提交 → 收起详情(二次点击切换)
|
|
if (selectedCommit.value?.hash === c.hash) {
|
|
closeCommitDetail()
|
|
return
|
|
}
|
|
selectedCommit.value = c
|
|
commitDetail.value = null
|
|
commitSelectedFile.value = ''
|
|
// 切换提交 → 清空右侧宽预览,避免残留上一个提交文件的 diff 与当前提交错位
|
|
emit('select-file', '', '')
|
|
commitDetailLoading.value = true
|
|
try {
|
|
const res = await moduleApi.getCommitDetail(props.moduleId, c.hash)
|
|
commitDetail.value = res
|
|
} catch {
|
|
commitDetail.value = null
|
|
} finally {
|
|
commitDetailLoading.value = false
|
|
}
|
|
}
|
|
|
|
function closeCommitDetail() {
|
|
selectedCommit.value = null
|
|
commitDetail.value = null
|
|
commitSelectedFile.value = ''
|
|
emit('select-file', '', '')
|
|
}
|
|
|
|
/** 在提交详情中点击文件 → 精确提取该文件的 diff 块(按 diff --git 头切块),路由到右侧宽预览区渲染 */
|
|
function showCommitFileDiff(path: string) {
|
|
commitSelectedFile.value = path
|
|
if (!commitDetail.value) {
|
|
emit('select-file', path, '')
|
|
return
|
|
}
|
|
emit('select-file', path, extractFileDiff(commitDetail.value.diff, path))
|
|
}
|
|
|
|
watch(() => props.moduleId, loadStatus, { immediate: true })
|
|
|
|
// B2[PD-P1-5]:外部刷新信号(AI 写文件后 FileExplorer 自增)变化 → 重拉 git 状态。
|
|
// 需保留当前历史 Tab/详情展示(只刷 gitStatus/commits,不动 showHistory/selectedCommit)。
|
|
watch(() => props.refreshKey, () => {
|
|
if (props.refreshKey === undefined) return
|
|
loadStatus()
|
|
})
|
|
</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); }
|
|
.branch-switch-btn {
|
|
background: none;
|
|
border: none;
|
|
color: var(--df-text-dim);
|
|
cursor: pointer;
|
|
padding: 2px 6px;
|
|
border-radius: 4px;
|
|
display: flex;
|
|
align-items: center;
|
|
transition: all 0.15s;
|
|
}
|
|
.branch-switch-btn:hover {
|
|
color: var(--df-text);
|
|
background: rgba(255,255,255,0.05);
|
|
}
|
|
|
|
/* 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 预览样式已随窄栏内 diff 面板移除(diff 统一在右侧 FilePreview 宽区渲染) */
|
|
|
|
/* ====== 提交历史 ====== */
|
|
.gc-history { display: flex; flex-direction: column; min-height: 0; }
|
|
|
|
/* 搜索框 */
|
|
.gc-search {
|
|
display: flex; align-items: center; gap: 6px;
|
|
padding: 6px 10px; border-bottom: 0.5px solid var(--df-border);
|
|
background: var(--df-bg-card); flex-shrink: 0;
|
|
}
|
|
.gc-search-icon { color: var(--df-text-dim); flex-shrink: 0; }
|
|
.gc-search-input {
|
|
flex: 1; min-width: 0;
|
|
background: rgba(255,255,255,0.04); border: 0.5px solid var(--df-border);
|
|
border-radius: 4px; padding: 4px 8px;
|
|
color: var(--df-text); font-size: 12px; outline: none;
|
|
}
|
|
.gc-search-input:focus { border-color: var(--df-accent); }
|
|
.gc-search-input::placeholder { color: var(--df-text-dim); }
|
|
.gc-search-clear {
|
|
background: none; border: none; color: var(--df-text-dim);
|
|
cursor: pointer; font-size: 12px; padding: 0 4px;
|
|
}
|
|
.gc-search-clear:hover { color: var(--df-text); }
|
|
|
|
/* 提交列表 */
|
|
.gc-history-list { flex: 1; min-height: 0; overflow-y: auto; scrollbar-width: thin; }
|
|
.gc-commit-row {
|
|
display: flex; flex-direction: column; gap: 2px;
|
|
padding: 6px 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-line1 {
|
|
display: flex; align-items: center; gap: 8px;
|
|
font-size: 11px; color: var(--df-text-dim);
|
|
}
|
|
.gc-commit-hash {
|
|
font-family: var(--df-font-mono, Consolas, monospace);
|
|
font-size: 11px; color: var(--df-accent); flex-shrink: 0;
|
|
}
|
|
.gc-commit-author {
|
|
font-size: 11px; color: var(--df-text-dim);
|
|
flex-shrink: 0; max-width: 140px;
|
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
}
|
|
.gc-commit-time { margin-left: auto; font-size: 11px; color: var(--df-text-dim); flex-shrink: 0; }
|
|
.gc-commit-msg {
|
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px;
|
|
color: var(--df-text);
|
|
}
|
|
|
|
/* ====== 提交详情(从底部滑出,列表始终可见) ====== */
|
|
.gc-commit-detail {
|
|
flex-shrink: 0;
|
|
border-top: 0.5px solid var(--df-border);
|
|
background: var(--df-bg-card);
|
|
max-height: 55%;
|
|
display: flex; flex-direction: column;
|
|
box-shadow: 0 -4px 12px rgba(0,0,0,0.18);
|
|
}
|
|
.gc-commit-detail-header {
|
|
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
|
|
padding: 8px 14px; background: rgba(255,255,255,0.03);
|
|
border-bottom: 0.5px solid var(--df-border);
|
|
}
|
|
.gc-commit-detail-hash {
|
|
font-family: var(--df-font-mono, Consolas, monospace);
|
|
font-size: 11px; color: var(--df-accent); flex-shrink: 0;
|
|
}
|
|
.gc-commit-detail-author {
|
|
font-size: 11px; color: var(--df-text-dim); flex-shrink: 0;
|
|
}
|
|
.gc-commit-detail-time { font-size: 11px; color: var(--df-text-dim); flex-shrink: 0; }
|
|
.gc-commit-detail-msg {
|
|
flex: 1; min-width: 0;
|
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px;
|
|
}
|
|
.gc-detail-close {
|
|
background: none; border: none; color: var(--df-text-dim);
|
|
cursor: pointer; font-size: 14px; padding: 0 4px; flex-shrink: 0;
|
|
}
|
|
.gc-detail-close:hover { color: var(--df-text); }
|
|
|
|
/* 提交的文件列表 */
|
|
.gc-commit-files {
|
|
border-bottom: 0.5px solid var(--df-border);
|
|
overflow-y: auto; max-height: 140px; scrollbar-width: thin; flex-shrink: 0;
|
|
}
|
|
.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-detail-slide-enter-active, .gc-detail-slide-leave-active {
|
|
transition: transform 0.18s ease, opacity 0.18s ease;
|
|
}
|
|
.gc-detail-slide-enter-from, .gc-detail-slide-leave-to {
|
|
transform: translateY(12px); opacity: 0;
|
|
}
|
|
|
|
.gc-file-status { width: 20px; text-align: center; font-size: 11px; font-weight: 500; flex-shrink: 0; }
|
|
.gc-st-modified { color: var(--df-git-modified); }
|
|
.gc-st-added { color: var(--df-git-added); }
|
|
.gc-st-deleted { color: var(--df-git-deleted); }
|
|
.gc-st-untracked { color: var(--df-git-untracked); }
|
|
.gc-file-path {
|
|
flex: 1; min-width: 0;
|
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
font-family: var(--df-font-mono, Consolas, monospace); font-size: 12px;
|
|
}
|
|
.gc-file-name {
|
|
flex: 1; min-width: 0;
|
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
font-family: var(--df-font-mono, Consolas, monospace); font-size: 12px;
|
|
}
|
|
.gc-file-row {
|
|
display: flex; align-items: center; gap: 8px;
|
|
cursor: pointer; transition: background 0.1s;
|
|
}
|
|
.gc-file-row:hover { background: rgba(255,255,255,0.04); }
|
|
.gc-file-row.selected { background: rgba(255,255,255,0.06); }
|
|
|
|
/* 加载更多 */
|
|
.gc-load-more {
|
|
text-align: center; padding: 12px; cursor: pointer;
|
|
color: var(--df-accent); font-size: 12px;
|
|
transition: background 0.1s; flex-shrink: 0;
|
|
}
|
|
.gc-load-more:hover { background: rgba(255,255,255,0.04); }
|
|
|
|
/* 分支列表弹窗 */
|
|
.gc-branch-picker {
|
|
border-bottom: 0.5px solid var(--df-border);
|
|
background: var(--df-bg);
|
|
}
|
|
.gc-branch-picker-header {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: 6px 14px;
|
|
font-size: 11px;
|
|
font-weight: 600;
|
|
color: var(--df-text-dim);
|
|
text-transform: uppercase;
|
|
}
|
|
.gc-branch-picker-close {
|
|
background: none; border: none; color: var(--df-text-dim);
|
|
cursor: pointer; font-size: 14px; padding: 0 4px;
|
|
}
|
|
.gc-branch-list {
|
|
max-height: 200px;
|
|
overflow-y: auto;
|
|
scrollbar-width: thin;
|
|
}
|
|
.gc-branch-item {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
padding: 5px 14px;
|
|
font-size: 12px;
|
|
cursor: default;
|
|
}
|
|
.gc-branch-item.current { color: var(--df-accent); }
|
|
.gc-branch-icon { font-size: 10px; flex-shrink: 0; }
|
|
.gc-branch-name { font-family: var(--df-font-mono, Consolas, monospace); }
|
|
.gc-branch-tag {
|
|
font-size: 9px;
|
|
padding: 1px 5px;
|
|
border-radius: 4px;
|
|
background: var(--df-accent-soft);
|
|
color: var(--df-accent);
|
|
}
|
|
.gc-branch-picker-hint {
|
|
padding: 6px 14px;
|
|
font-size: 11px;
|
|
color: var(--df-text-dim);
|
|
border-top: 0.5px solid var(--df-border);
|
|
}
|
|
|
|
/* 骨架屏 */
|
|
.gc-skeleton {
|
|
padding: 14px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 10px;
|
|
}
|
|
.gc-skeleton-row {
|
|
height: 24px;
|
|
border-radius: 4px;
|
|
background: linear-gradient(90deg, rgba(255,255,255,0.04) 0%, rgba(255,255,255,0.08) 50%, rgba(255,255,255,0.04) 100%);
|
|
background-size: 200% 100%;
|
|
animation: gc-shimmer 1.5s infinite linear;
|
|
}
|
|
@keyframes gc-shimmer {
|
|
0% { background-position: 200% 0; }
|
|
100% { background-position: -200% 0; }
|
|
}
|
|
|
|
/* 通用 */
|
|
.gc-loading, .gc-empty {
|
|
display: flex; justify-content: center; align-items: center;
|
|
padding: 40px 0; color: var(--df-text-dim); font-size: 13px;
|
|
}
|
|
.gc-empty-inline { padding: 24px 0; }
|
|
.gc-error { color: var(--df-danger); }
|
|
.gc-error-strip {
|
|
padding: 6px 14px; font-size: 12px; color: var(--df-danger);
|
|
background: var(--df-danger-bg); border-bottom: 0.5px solid var(--df-border);
|
|
flex-shrink: 0; word-break: break-all;
|
|
}
|
|
.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); } }
|
|
|
|
/* 滚动条对齐全局(变更/历史提交列表与全局统一样式) */
|
|
.git-changes ::-webkit-scrollbar {
|
|
width: 5px;
|
|
height: 5px;
|
|
}
|
|
|
|
.git-changes ::-webkit-scrollbar-thumb {
|
|
background: rgba(255, 255, 255, 0.08);
|
|
border-radius: var(--df-radius-xs);
|
|
}
|
|
|
|
.git-changes ::-webkit-scrollbar-thumb:hover {
|
|
background: rgba(255, 255, 255, 0.14);
|
|
}
|
|
|
|
.git-changes ::-webkit-scrollbar-track {
|
|
background: transparent;
|
|
}
|
|
</style>
|