Files
DevFlow/src/components/project/GitChanges.vue
T

843 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 aria-hidden="true" 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 aria-hidden="true" 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 { formatRelative } from '@/utils/time'
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('')
// 行内错误提示:loadError 主区展示(loadStatus 失败),actionError 分支条下方展示(loadMore/selectFile/切分支/提交详情 失败)
const loadError = ref('')
const actionError = ref('')
// 请求序号守卫:moduleId 切换与 refreshKey 刷新双触发源并发时,旧响应晚到不覆盖新模块数据
// (对齐 ProjectDetail taskReqSeq 模式)
let statusReqSeq = 0
// UX走查 P3-4: 点文件 diff 单槽去重缓存 — 重复点击同一文件不再重复发 git diff IPC,
// 直接复用上次结果回填。切模块/refreshKey 重拉状态均经 loadStatus,缓存在彼处失效,
// 防 AI 改文件后旧 diff 被复用(单槽不设 Map,免过期条目堆积)
let lastDiffKey = ''
let lastDiffContent = ''
/** 单条提交记录形状(对齐 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 时间戳为相对时间或具体时间(阶梯走 @/utils/time formatRelative:昨天档 + 30 天截断 ISO 日期) */
function formatTime(ts: number): string {
if (!ts) return ''
return formatRelative(ts, { yesterday: true, absAfterDays: 30, absStyle: 'iso' })
}
/** 计算文件路径的目录深度(用于缩进)。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
// UX走查 P3-4: 状态重拉(切模块/外部 refreshKey,如 AI 改文件)即失效 diff 去重缓存,
// 防改文件后旧 diff 被复用
lastDiffKey = ''
lastDiffContent = ''
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
actionError.value = ''
try {
const res = await moduleApi.listBranches(props.moduleId)
branchList.value = res.branches
currentBranch.value = res.current
} catch (e) {
branchList.value = []
actionError.value = String(e)
} 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 拉到后回填切 diff 视图,点文件即展示变化
emit('select-file', path)
actionError.value = ''
// UX走查 P3-4 同参数去重:重复点击同一文件直接复用缓存 diff,不再发 IPC
const key = `${props.moduleId}::${path}`
if (key === lastDiffKey) {
emit('select-file', path, lastDiffContent)
return
}
// 捕获请求时的状态序号:期间 refreshKey 重拉(缓存已失效)则本次结果不入缓存,防改文件前旧 diff 滞留
const seqAtReq = statusReqSeq
let diff = ''
try {
const res = await moduleApi.getModuleFileDiff(props.moduleId, path)
diff = res.diff || ''
if (seqAtReq === statusReqSeq) {
lastDiffKey = key
lastDiffContent = diff
}
} catch (e) {
// 失败清缓存使下次点击可重试;不回填 diff,预览停在内容视图(错误经 actionError 条就近提示)
lastDiffKey = ''
lastDiffContent = ''
actionError.value = String(e)
return
}
// 仅当仍是当前选中文件时才回填 diff,防快速切换时旧响应覆盖新文件
if (selectedFile.value === path) {
emit('select-file', path, diff)
}
}
/** 点击提交行 → 加载该提交的变更文件列表 */
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
actionError.value = ''
try {
const res = await moduleApi.getCommitDetail(props.moduleId, c.hash)
commitDetail.value = res
} catch (e) {
commitDetail.value = null
actionError.value = String(e)
} 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>