@civilization/dsh-git-workbench 0.0.0-stage → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1496 @@
1
+ /**
2
+ * IntelliJ IDEA-Grade Git Tool Window Component.
3
+ * Supports DAG commit topology graph visualization, working tree staging, commit console,
4
+ * branch switcher, and unified diff inspector.
5
+ */
6
+
7
+ import { memo, useCallback, useEffect, useMemo, useState } from 'react'
8
+ import type { KeyboardEvent, ReactElement } from 'react'
9
+ import type {
10
+ GitBranchesSummary,
11
+ GitCommitDetail,
12
+ GitCommitItem,
13
+ GitDiffSummary,
14
+ GitStatusSummary,
15
+ GitTagItem,
16
+ } from '../types.ts'
17
+ import {
18
+ computeDagGraph,
19
+ formatFullDate,
20
+ getAuthorAvatar,
21
+ parseConventionalCommit,
22
+ } from './git-helpers.ts'
23
+ import type {} from './locales.ts'
24
+ import css from './GitBody.module.css'
25
+
26
+ function getTypeBadgeClass(type?: string): string {
27
+ switch (type) {
28
+ case 'feat': return css.typeFeat ?? ''
29
+ case 'fix': return css.typeFix ?? ''
30
+ case 'style': return css.typeStyle ?? ''
31
+ case 'refactor': return css.typeRefactor ?? ''
32
+ case 'release': return css.typeRelease ?? ''
33
+ case 'merge': return css.typeMerge ?? ''
34
+ case 'test': return css.typeTest ?? ''
35
+ case 'docs': return css.typeDocs ?? ''
36
+ case 'revert': return css.typeRevert ?? ''
37
+ case 'chore':
38
+ default:
39
+ return css.typeChore ?? ''
40
+ }
41
+ }
42
+
43
+
44
+ /** Injected Git RPC methods */
45
+ export interface GitInjected {
46
+ readonly getStatus: (sessionId?: string) => Promise<GitStatusSummary>
47
+ readonly getLog: (sessionId?: string, maxCount?: number) => Promise<readonly GitCommitItem[]>
48
+ readonly getCommitDetails: (sessionId?: string, commit?: string) => Promise<GitCommitDetail | undefined>
49
+ readonly getDiff: (sessionId?: string, options?: { filePath?: string; staged?: boolean; commit?: string }) => Promise<GitDiffSummary>
50
+ readonly getBranches: (sessionId?: string) => Promise<GitBranchesSummary>
51
+ readonly checkout: (sessionId?: string, branch?: string, create?: boolean) => Promise<{ success: boolean; message?: string }>
52
+ readonly stage: (sessionId?: string, paths?: string[] | 'all') => Promise<{ success: boolean; message?: string }>
53
+ readonly unstage: (sessionId?: string, paths?: string[] | 'all') => Promise<{ success: boolean; message?: string }>
54
+ readonly discard: (sessionId?: string, paths?: string[]) => Promise<{ success: boolean; message?: string }>
55
+ readonly commit: (sessionId?: string, message?: string) => Promise<{ success: boolean; message?: string }>
56
+ readonly fetch: (sessionId?: string) => Promise<{ success: boolean; message?: string }>
57
+ readonly pull: (sessionId?: string) => Promise<{ success: boolean; message?: string }>
58
+ readonly push: (sessionId?: string) => Promise<{ success: boolean; message?: string }>
59
+ readonly getTags: (sessionId?: string) => Promise<readonly GitTagItem[]>
60
+ readonly createTag: (
61
+ sessionId?: string,
62
+ name?: string,
63
+ commit?: string,
64
+ message?: string,
65
+ ) => Promise<{ success: boolean; message?: string }>
66
+ readonly deleteTag: (sessionId?: string, name?: string) => Promise<{ success: boolean; message?: string }>
67
+ }
68
+
69
+ export interface GitBodyProps extends GitInjected {
70
+ readonly sessionId?: string
71
+ readonly t: (key: string, ...args: any[]) => string
72
+ }
73
+
74
+ type ActiveTab = 'changes' | 'tree' | 'branches' | 'tags'
75
+
76
+ /**
77
+ * Render color-coded unified diff lines.
78
+ */
79
+ function DiffViewer({ diffText }: { readonly diffText: string }): ReactElement {
80
+ if (!diffText.trim()) {
81
+ return <div className={css.diffViewer}>无代码内容变动</div>
82
+ }
83
+
84
+ const lines = diffText.split('\n')
85
+ return (
86
+ <div className={css.diffViewer}>
87
+ {lines.map((line, idx) => {
88
+ let lineClass = css.diffLine
89
+ if (line.startsWith('+') && !line.startsWith('+++')) lineClass = `${css.diffLine} ${css.diffAdd}`
90
+ else if (line.startsWith('-') && !line.startsWith('---')) lineClass = `${css.diffLine} ${css.diffDel}`
91
+ else if (line.startsWith('@@')) lineClass = `${css.diffLine} ${css.diffHunk}`
92
+
93
+ return (
94
+ <div key={idx} className={lineClass}>
95
+ {line || ' '}
96
+ </div>
97
+ )
98
+ })}
99
+ </div>
100
+ )
101
+ }
102
+
103
+
104
+ /**
105
+ * Main Git Body Component.
106
+ */
107
+ export const GitBody = memo(function GitBody(props: GitBodyProps) {
108
+ const {
109
+ t,
110
+ sessionId,
111
+ getStatus,
112
+ getLog,
113
+ getCommitDetails,
114
+ getDiff,
115
+ getBranches,
116
+ getTags,
117
+ createTag,
118
+ deleteTag,
119
+ checkout,
120
+ stage,
121
+ unstage,
122
+ discard,
123
+ commit,
124
+ fetch: fetchGit,
125
+ pull,
126
+ push,
127
+ } = props
128
+
129
+ const [activeTab, setActiveTab] = useState<ActiveTab>('changes')
130
+ const [loading, setLoading] = useState(false)
131
+
132
+ const [status, setStatus] = useState<GitStatusSummary | null>(null)
133
+ const [commits, setCommits] = useState<readonly GitCommitItem[]>([])
134
+ const [branches, setBranches] = useState<GitBranchesSummary | null>(null)
135
+ const [tags, setTags] = useState<readonly GitTagItem[]>([])
136
+
137
+ // Commit Console State
138
+ const [commitMessage, setCommitMessage] = useState('')
139
+ const [committing, setCommitting] = useState(false)
140
+
141
+ // Diff Preview State
142
+ const [selectedFileDiff, setSelectedFileDiff] = useState<{ path: string; staged: boolean; diff: string } | null>(null)
143
+
144
+ // Tree Selected Commit State
145
+ const [selectedCommitHash, setSelectedCommitHash] = useState<string | null>(null)
146
+ const [commitDetail, setCommitDetail] = useState<GitCommitDetail | null>(null)
147
+
148
+ // Locate in DAG Tree State
149
+ const [highlightedCommitHash, setHighlightedCommitHash] = useState<string | null>(null)
150
+ const [locatedNotice, setLocatedNotice] = useState<string | null>(null)
151
+
152
+ // Tree Filter & Search State
153
+ const [treeSearch, setTreeSearch] = useState('')
154
+ const [filterMode, setFilterMode] = useState<'all' | 'mainline'>('all')
155
+
156
+ // Branch Filter State
157
+ const [branchFilter, setBranchFilter] = useState('')
158
+ const [newBranchName, setNewBranchName] = useState('')
159
+ const [creatingBranch, setCreatingBranch] = useState(false)
160
+
161
+ // Tag Management State
162
+ const [tagFilter, setTagFilter] = useState('')
163
+ const [creatingTag, setCreatingTag] = useState(false)
164
+ const [newTagName, setNewTagName] = useState('')
165
+ const [newTagCommit, setNewTagCommit] = useState('')
166
+ const [newTagMessage, setNewTagMessage] = useState('')
167
+
168
+ // Fetch full repository data
169
+ const refresh = useCallback(async () => {
170
+ setLoading(true)
171
+ try {
172
+ const [statusRes, logRes, branchRes, tagRes] = await Promise.all([
173
+ getStatus(sessionId).catch(() => null),
174
+ getLog(sessionId, 50).catch(() => []),
175
+ getBranches(sessionId).catch(() => null),
176
+ getTags(sessionId).catch(() => []),
177
+ ])
178
+ if (statusRes) setStatus(statusRes)
179
+ setCommits(logRes)
180
+ if (branchRes) setBranches(branchRes)
181
+ setTags(tagRes)
182
+ } finally {
183
+ setLoading(false)
184
+ }
185
+ }, [getBranches, getLog, getStatus, getTags, sessionId])
186
+
187
+ useEffect(() => {
188
+ void refresh()
189
+ }, [refresh])
190
+
191
+ // Inspect commit details when a tree node is clicked
192
+ const handleSelectCommit = useCallback(async (hash: string) => {
193
+ if (selectedCommitHash === hash) {
194
+ setSelectedCommitHash(null)
195
+ setCommitDetail(null)
196
+ return
197
+ }
198
+ setSelectedCommitHash(hash)
199
+ try {
200
+ const details = await getCommitDetails(sessionId, hash)
201
+ setCommitDetail(details ?? null)
202
+ } catch {
203
+ setCommitDetail(null)
204
+ }
205
+ }, [getCommitDetails, selectedCommitHash, sessionId])
206
+
207
+ // Inspect file diff
208
+ const handleViewDiff = useCallback(async (path: string, staged: boolean, commitHash?: string) => {
209
+ try {
210
+ const res = await getDiff(sessionId, {
211
+ filePath: path,
212
+ staged,
213
+ ...commitHash !== undefined ? { commit: commitHash } : {},
214
+ })
215
+ setSelectedFileDiff({
216
+ path,
217
+ staged,
218
+ diff: res.rawDiff,
219
+ })
220
+ } catch {
221
+ setSelectedFileDiff(null)
222
+ }
223
+ }, [getDiff, sessionId])
224
+
225
+ // Stage / Unstage / Discard
226
+ const handleStage = useCallback(async (paths: string[] | 'all') => {
227
+ await stage(sessionId, paths)
228
+ await refresh()
229
+ }, [refresh, sessionId, stage])
230
+
231
+ const handleUnstage = useCallback(async (paths: string[] | 'all') => {
232
+ await unstage(sessionId, paths)
233
+ await refresh()
234
+ }, [refresh, sessionId, unstage])
235
+
236
+ const handleDiscard = useCallback(async (paths: string[]) => {
237
+ if (confirm(`确认放弃对 ${paths.length} 个文件的所有更改吗?此操作无法撤销。`)) {
238
+ await discard(sessionId, paths)
239
+ await refresh()
240
+ }
241
+ }, [discard, refresh, sessionId])
242
+
243
+ // Commit
244
+ const handleCommit = useCallback(async (andPush = false) => {
245
+ const trimmed = commitMessage.trim()
246
+ if (!trimmed) {
247
+ alert(t('commit.emptyWarning'))
248
+ return
249
+ }
250
+
251
+ setCommitting(true)
252
+ try {
253
+ const res = await commit(sessionId, trimmed)
254
+ if (res.success) {
255
+ setCommitMessage('')
256
+ if (andPush) {
257
+ await push(sessionId)
258
+ }
259
+ await refresh()
260
+ } else {
261
+ alert(res.message || '提交失败')
262
+ }
263
+ } finally {
264
+ setCommitting(false)
265
+ }
266
+ }, [commit, commitMessage, push, refresh, sessionId, t])
267
+
268
+ const handleCommitKeyDown = useCallback((e: KeyboardEvent<HTMLTextAreaElement>) => {
269
+ if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
270
+ e.preventDefault()
271
+ void handleCommit(false)
272
+ }
273
+ }, [handleCommit])
274
+
275
+ // Branch Switch
276
+ const handleCheckoutBranch = useCallback(async (bName: string, create = false) => {
277
+ const res = await checkout(sessionId, bName, create)
278
+ if (res.success) {
279
+ setCreatingBranch(false)
280
+ setNewBranchName('')
281
+ await refresh()
282
+ } else {
283
+ alert(res.message || '检出失败')
284
+ }
285
+ }, [checkout, refresh, sessionId])
286
+
287
+
288
+ // Locate commit in DAG tree and pulse glow
289
+ const handleLocateCommit = useCallback(async (commitHash: string, tagName?: string) => {
290
+ setActiveTab('tree')
291
+ let found = commits.find(c => c.hash.startsWith(commitHash) || commitHash.startsWith(c.hash))
292
+ if (!found) {
293
+ try {
294
+ const deepLogs = await getLog(sessionId, 200)
295
+ setCommits(deepLogs)
296
+ found = deepLogs.find(c => c.hash.startsWith(commitHash) || commitHash.startsWith(c.hash))
297
+ } catch {
298
+ // Continue with current list
299
+ }
300
+ }
301
+
302
+ if (found) {
303
+ setSelectedCommitHash(found.hash)
304
+ setHighlightedCommitHash(found.hash)
305
+ if (tagName) {
306
+ setLocatedNotice(t('tag.locatedNotice', { name: tagName }))
307
+ setTimeout(() => { setLocatedNotice(null) }, 3000)
308
+ }
309
+ void handleSelectCommit(found.hash)
310
+ setTimeout(() => {
311
+ const el = document.getElementById(`git-commit-${found.hash}`)
312
+ el?.scrollIntoView({ behavior: 'smooth', block: 'center' })
313
+ }, 50)
314
+ setTimeout(() => {
315
+ setHighlightedCommitHash(null)
316
+ }, 3500)
317
+ }
318
+ }, [commits, getLog, handleSelectCommit, sessionId, t])
319
+
320
+ // Create Tag
321
+ const handleCreateTag = useCallback(async () => {
322
+ const name = newTagName.trim()
323
+ if (!name) {
324
+ alert('请输入标签名称')
325
+ return
326
+ }
327
+ const targetCommit = newTagCommit.trim() || undefined
328
+ const message = newTagMessage.trim() || undefined
329
+ const res = await createTag(sessionId, name, targetCommit, message)
330
+ if (res.success) {
331
+ setCreatingTag(false)
332
+ setNewTagName('')
333
+ setNewTagCommit('')
334
+ setNewTagMessage('')
335
+ await refresh()
336
+ } else {
337
+ alert(res.message || '创建标签失败')
338
+ }
339
+ }, [createTag, newTagCommit, newTagMessage, newTagName, refresh, sessionId])
340
+
341
+ // Delete Tag
342
+ const handleDeleteTag = useCallback(async (tagName: string) => {
343
+ if (confirm(t('tag.deleteConfirm', { name: tagName }))) {
344
+ const res = await deleteTag(sessionId, tagName)
345
+ if (res.success) {
346
+ await refresh()
347
+ } else {
348
+ alert(res.message || '删除标签失败')
349
+ }
350
+ }
351
+ }, [deleteTag, refresh, sessionId, t])
352
+
353
+ // Filtered branches for branch tab
354
+ const filteredLocalBranches = useMemo(() => {
355
+ if (!branches?.local) return []
356
+ const q = branchFilter.trim().toLowerCase()
357
+ return q ? branches.local.filter(b => b.name.toLowerCase().includes(q)) : branches.local
358
+ }, [branches?.local, branchFilter])
359
+
360
+ const filteredRemoteBranches = useMemo(() => {
361
+ if (!branches?.remote) return []
362
+ const q = branchFilter.trim().toLowerCase()
363
+ return q ? branches.remote.filter(b => b.name.toLowerCase().includes(q)) : branches.remote
364
+ }, [branches?.remote, branchFilter])
365
+
366
+ // Filtered tags for tags tab
367
+ const filteredTags = useMemo(() => {
368
+ const q = tagFilter.trim().toLowerCase()
369
+ if (!q) return tags
370
+ return tags.filter(item =>
371
+ item.name.toLowerCase().includes(q)
372
+ || item.shortHash.toLowerCase().includes(q)
373
+ || item.commitHash.toLowerCase().includes(q)
374
+ || item.message.toLowerCase().includes(q)
375
+ || item.author.toLowerCase().includes(q),
376
+ )
377
+ }, [tags, tagFilter])
378
+
379
+ // Filtered commits for tree tab
380
+ const filteredCommits = useMemo(() => {
381
+ let list = commits
382
+ if (filterMode === 'mainline') {
383
+ list = list.filter(c => (c.lane ?? 0) === 0)
384
+ }
385
+ const q = treeSearch.trim().toLowerCase()
386
+ if (q) {
387
+ list = list.filter(c =>
388
+ c.subject.toLowerCase().includes(q)
389
+ || c.hash.toLowerCase().includes(q)
390
+ || c.shortHash.toLowerCase().includes(q)
391
+ || c.author.toLowerCase().includes(q)
392
+ || (c.refs && c.refs.some(r => r.toLowerCase().includes(q))),
393
+ )
394
+ }
395
+ return list
396
+ }, [commits, filterMode, treeSearch])
397
+
398
+ // Load more commits
399
+ const handleLoadMore = useCallback(async () => {
400
+ setLoading(true)
401
+ try {
402
+ const nextCount = commits.length + 50
403
+ const more = await getLog(sessionId, nextCount)
404
+ setCommits(more)
405
+ } finally {
406
+ setLoading(false)
407
+ }
408
+ }, [commits.length, getLog, sessionId])
409
+
410
+ // Dynamic graph column width and DAG topology model
411
+ const dagGraph = useMemo(() => computeDagGraph(filteredCommits), [filteredCommits])
412
+ const graphColWidth = dagGraph.graphWidth
413
+
414
+ if (!status || !status.isRepo) {
415
+ return (
416
+ <div className={css.root}>
417
+ <div className={css.emptyState}>
418
+ <svg viewBox="0 0 16 16" fill="currentColor">
419
+ <path d="M2 2.5A2.5 2.5 0 0 1 4.5 0h8.75a.75.75 0 0 1 .75.75v12.5a.75.75 0 0 1-.75.75h-2.5a.75.75 0 0 1 0-1.5h1.75v-2h-8a1 1 0 0 0-.714 1.7.75.75 0 1 1-1.072 1.05A2.495 2.495 0 0 1 2 11.5v-9zm10.5-1h-8a1 1 0 0 0-1 1v6.708A2.486 2.486 0 0 1 4.5 9h8V1.5z" />
420
+ </svg>
421
+ <div>{t('status.notRepo')}</div>
422
+ </div>
423
+ </div>
424
+ )
425
+ }
426
+
427
+ const stagedCount = status.staged.length
428
+ const unstagedCount = status.unstaged.length
429
+ const untrackedCount = status.untracked.length
430
+ const totalChanges = status.totalChanges
431
+
432
+ return (
433
+ <div className={css.root}>
434
+ {/* 1. Header Banner */}
435
+ <div className={css.header}>
436
+ <div className={css.branchInfo}>
437
+ <span className={css.branchBadge} title={`当前分支: ${status.branch}`}>
438
+ <svg className={css.branchIcon} width="12" height="12" viewBox="0 0 16 16" fill="currentColor">
439
+ <path d="M11.75 2.5a1.75 1.75 0 1 0-2.45 1.606V6.5a1.5 1.5 0 0 1-1.5 1.5H5.8A2.75 2.75 0 0 0 3.5 6.25V4.106a1.75 1.75 0 1 0-1.5 0v7.788a1.75 1.75 0 1 0 1.5 0V8.25A1.25 1.25 0 0 1 4.75 7h3a3 3 0 0 0 3-3V4.106a1.75 1.75 0 0 0 1-1.606z" />
440
+ </svg>
441
+ {status.branch || 'HEAD'}
442
+ </span>
443
+ {(status.ahead > 0 || status.behind > 0) && (
444
+ <div className={css.statusAheadBehind}>
445
+ {status.ahead > 0 && <span className={css.aheadText}>↑{status.ahead}</span>}
446
+ {status.behind > 0 && <span className={css.behindText}>↓{status.behind}</span>}
447
+ </div>
448
+ )}
449
+ </div>
450
+
451
+ <div className={css.headerActions}>
452
+ <button
453
+ type="button"
454
+ className={css.iconButton}
455
+ title={t('actions.fetch')}
456
+ onClick={() => { void fetchGit(sessionId).then(() => refresh()) }}
457
+ >
458
+ <svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor">
459
+ <path d="M1.75 3a.75.75 0 0 0 0 1.5h8.5a3.25 3.25 0 0 1 3.25 3.25v2.44l1.72-1.72a.75.75 0 1 1 1.06 1.06l-3 3a.75.75 0 0 1-1.06 0l-3-3a.75.75 0 1 1 1.06-1.06l1.72 1.72V7.75A1.75 1.75 0 0 0 10.25 6H1.75a.75.75 0 0 0 0-1.5z" />
460
+ </svg>
461
+ </button>
462
+ <button
463
+ type="button"
464
+ className={css.iconButton}
465
+ title="拉取 (Pull)"
466
+ onClick={() => { void pull(sessionId).then(() => refresh()) }}
467
+ >
468
+
469
+ <svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor">
470
+ <path d="M13.03 8.22a.75.75 0 0 1 0 1.06l-4.5 4.5a.75.75 0 0 1-1.06 0l-4.5-4.5a.75.75 0 0 1 1.06-1.06L7.5 11.69V2.75a.75.75 0 0 1 1.5 0v8.94l3.47-3.47a.75.75 0 0 1 1.06 0z" />
471
+ </svg>
472
+ </button>
473
+ <button
474
+ type="button"
475
+ className={css.iconButton}
476
+ title="推送 (Push)"
477
+ onClick={() => { void push(sessionId).then(() => refresh()) }}
478
+ >
479
+ <svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor">
480
+ <path d="M2.97 7.78a.75.75 0 0 1 0-1.06l4.5-4.5a.75.75 0 0 1 1.06 0l4.5 4.5a.75.75 0 1 1-1.06 1.06L8.5 4.31v8.94a.75.75 0 0 1-1.5 0V4.31L3.53 7.78a.75.75 0 0 1-1.06 0z" />
481
+ </svg>
482
+ </button>
483
+ <button
484
+ type="button"
485
+ className={`${css.iconButton} ${loading ? css.spinning : ''}`}
486
+ title="刷新"
487
+ onClick={() => { void refresh() }}
488
+ >
489
+ <svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor">
490
+ <path d="M1.705 8.005a.75.75 0 0 1 .834.656 5.5 5.5 0 0 0 9.592 2.97l-1.204-1.204a.25.25 0 0 1 .177-.427h3.646a.25.25 0 0 1 .25.25v3.646a.25.25 0 0 1-.427.177l-1.38-1.38A7.002 7.002 0 0 1 1.05 8.84a.75.75 0 0 1 .655-.835zm12.59-1.01a.75.75 0 0 1-.834-.656 5.5 5.5 0 0 0-9.592-2.97l1.204 1.204a.25.25 0 0 1-.177.427H1.25a.25.25 0 0 1-.25-.25V1.104a.25.25 0 0 1 .427-.177l1.38 1.38A7.002 7.002 0 0 1 14.95 7.16a.75.75 0 0 1-.655.835z" />
491
+ </svg>
492
+ </button>
493
+ </div>
494
+ </div>
495
+
496
+ {/* 2. Tabs Bar */}
497
+ <div className={css.tabs}>
498
+ <button
499
+ type="button"
500
+ className={`${css.tab} ${activeTab === 'changes' ? css.tabActive : ''}`}
501
+ onClick={() => { setActiveTab('changes') }}
502
+ >
503
+ {t('tabs.changes')}
504
+ {totalChanges > 0 && <span className={css.tabCount}>{totalChanges}</span>}
505
+ </button>
506
+ <button
507
+ type="button"
508
+ className={`${css.tab} ${activeTab === 'tree' ? css.tabActive : ''}`}
509
+ onClick={() => { setActiveTab('tree') }}
510
+ >
511
+ {t('tabs.tree')}
512
+ <span className={css.tabCount}>{commits.length}</span>
513
+ </button>
514
+ <button
515
+ type="button"
516
+ className={`${css.tab} ${activeTab === 'branches' ? css.tabActive : ''}`}
517
+ onClick={() => { setActiveTab('branches') }}
518
+ >
519
+ {t('tabs.branches')}
520
+ </button>
521
+ <button
522
+ type="button"
523
+ className={`${css.tab} ${activeTab === 'tags' ? css.tabActive : ''}`}
524
+ onClick={() => { setActiveTab('tags') }}
525
+ >
526
+ {t('tabs.tags')}
527
+ {tags.length > 0 && <span className={css.tabCount}>{tags.length}</span>}
528
+ </button>
529
+ </div>
530
+
531
+ {locatedNotice && (
532
+ <div style={{
533
+ padding: '6px 12px',
534
+ background: 'color-mix(in srgb, var(--dsw-alias-state-business-primary) 18%, transparent)',
535
+ color: 'var(--dsw-alias-state-business-primary)',
536
+ fontSize: '11px',
537
+ fontWeight: 600,
538
+ display: 'flex',
539
+ alignItems: 'center',
540
+ gap: '6px',
541
+ borderBottom: '0.5px solid var(--dsw-alias-border-l2)',
542
+ }}>
543
+ <span>🎯</span>
544
+ <span>{locatedNotice}</span>
545
+ </div>
546
+ )}
547
+
548
+ {/* 3. Main Content Panes */}
549
+ <div className={css.content}>
550
+ {/* TAB 1: Local Changes */}
551
+ {activeTab === 'changes' && (
552
+ <>
553
+ {totalChanges === 0 ? (
554
+ <div className={css.emptyState}>
555
+ <svg viewBox="0 0 16 16" fill="currentColor">
556
+ <path d="M13.78 4.22a.75.75 0 0 1 0 1.06l-7.25 7.25a.75.75 0 0 1-1.06 0L2.22 9.28a.751.751 0 0 1 .018-1.042.751.751 0 0 1 1.042-.018L6 10.94l6.72-6.72a.75.75 0 0 1 1.06 0z" />
557
+ </svg>
558
+ <div>{t('status.clean')}</div>
559
+ </div>
560
+ ) : (
561
+ <>
562
+ {/* 1.1 Staged Changes */}
563
+ {stagedCount > 0 && (
564
+ <div className={css.section}>
565
+ <div className={css.sectionHeader}>
566
+ <div className={css.sectionTitle}>
567
+ <span className={`${css.statusBadge} ${css.statusA}`}>✓</span>
568
+ <span>{t('status.staged')} ({stagedCount})</span>
569
+ </div>
570
+ <div className={css.sectionActions}>
571
+ <button
572
+ type="button"
573
+ className={css.actionTextBtn}
574
+ title={t('actions.unstageAll')}
575
+ onClick={() => { void handleUnstage('all') }}
576
+ >
577
+ 全部取消暂存
578
+ </button>
579
+ </div>
580
+ </div>
581
+ <div className={css.fileList}>
582
+ {status.staged.map(f => (
583
+ <div
584
+ key={`staged-${f.path}`}
585
+ className={css.fileItem}
586
+ onClick={() => { void handleViewDiff(f.path, true) }}
587
+ >
588
+ <div className={css.fileLeft}>
589
+ <span className={`${css.statusBadge} ${css.statusA}`}>
590
+ {f.status[0]?.toUpperCase() ?? 'M'}
591
+ </span>
592
+ <span className={css.fileName}>{f.path}</span>
593
+ </div>
594
+ <div className={css.fileItemActions}>
595
+ <button
596
+ type="button"
597
+ className={css.fileMiniBtn}
598
+ title={t('actions.unstage')}
599
+ onClick={(e) => {
600
+ e.stopPropagation()
601
+ void handleUnstage([f.path])
602
+ }}
603
+ >
604
+ -
605
+ </button>
606
+ </div>
607
+ </div>
608
+ ))}
609
+ </div>
610
+ </div>
611
+ )}
612
+
613
+ {/* 1.2 Unstaged Changes */}
614
+ {unstagedCount > 0 && (
615
+ <div className={css.section}>
616
+ <div className={css.sectionHeader}>
617
+ <div className={css.sectionTitle}>
618
+ <span className={`${css.statusBadge} ${css.statusM}`}>M</span>
619
+ <span>{t('status.unstaged')} ({unstagedCount})</span>
620
+ </div>
621
+ <div className={css.sectionActions}>
622
+ <button
623
+ type="button"
624
+ className={css.actionTextBtn}
625
+ title={t('actions.stageAll')}
626
+ onClick={() => { void handleStage('all') }}
627
+ >
628
+ 全部暂存
629
+ </button>
630
+ </div>
631
+ </div>
632
+ <div className={css.fileList}>
633
+ {status.unstaged.map(f => (
634
+ <div
635
+ key={`unstaged-${f.path}`}
636
+ className={css.fileItem}
637
+ onClick={() => { void handleViewDiff(f.path, false) }}
638
+ >
639
+ <div className={css.fileLeft}>
640
+ <span className={`${css.statusBadge} ${css.statusM}`}>
641
+ {f.status[0]?.toUpperCase() ?? 'M'}
642
+ </span>
643
+ <span className={css.fileName}>{f.path}</span>
644
+ </div>
645
+ <div className={css.fileItemActions}>
646
+ <button
647
+ type="button"
648
+ className={css.fileMiniBtn}
649
+ title={t('actions.stage')}
650
+ onClick={(e) => {
651
+ e.stopPropagation()
652
+ void handleStage([f.path])
653
+ }}
654
+ >
655
+ +
656
+ </button>
657
+ <button
658
+ type="button"
659
+ className={css.fileMiniBtn}
660
+ title={t('actions.discard')}
661
+ onClick={(e) => {
662
+ e.stopPropagation()
663
+ void handleDiscard([f.path])
664
+ }}
665
+ >
666
+ ↺
667
+ </button>
668
+ </div>
669
+ </div>
670
+ ))}
671
+ </div>
672
+ </div>
673
+ )}
674
+
675
+ {/* 1.3 Untracked Files */}
676
+ {untrackedCount > 0 && (
677
+ <div className={css.section}>
678
+ <div className={css.sectionHeader}>
679
+ <div className={css.sectionTitle}>
680
+ <span className={`${css.statusBadge} ${css.statusU}`}>?</span>
681
+ <span>{t('status.untracked')} ({untrackedCount})</span>
682
+ </div>
683
+ </div>
684
+ <div className={css.fileList}>
685
+ {status.untracked.map(f => (
686
+ <div
687
+ key={`untracked-${f.path}`}
688
+ className={css.fileItem}
689
+ onClick={() => { void handleViewDiff(f.path, false) }}
690
+ >
691
+ <div className={css.fileLeft}>
692
+ <span className={`${css.statusBadge} ${css.statusU}`}>?</span>
693
+ <span className={css.fileName}>{f.path}</span>
694
+ </div>
695
+ <div className={css.fileItemActions}>
696
+ <button
697
+ type="button"
698
+ className={css.fileMiniBtn}
699
+ title={t('actions.stage')}
700
+ onClick={(e) => {
701
+ e.stopPropagation()
702
+ void handleStage([f.path])
703
+ }}
704
+ >
705
+ +
706
+ </button>
707
+ </div>
708
+ </div>
709
+ ))}
710
+ </div>
711
+ </div>
712
+ )}
713
+
714
+ {/* 1.4 Commit Console (IDEA Style) */}
715
+ <div className={css.commitBox}>
716
+ <textarea
717
+ className={css.commitTextarea}
718
+ placeholder={t('commit.placeholder')}
719
+ value={commitMessage}
720
+ onChange={(e) => { setCommitMessage(e.target.value) }}
721
+ onKeyDown={handleCommitKeyDown}
722
+ />
723
+ <div className={css.commitButtonRow}>
724
+ <button
725
+ type="button"
726
+ className={css.commitPushBtn}
727
+ disabled={committing || stagedCount === 0}
728
+ onClick={() => { void handleCommit(true) }}
729
+ >
730
+ {t('actions.commitAndPush')}
731
+ </button>
732
+ <button
733
+ type="button"
734
+ className={css.commitBtn}
735
+ disabled={committing || stagedCount === 0}
736
+ onClick={() => { void handleCommit(false) }}
737
+ >
738
+ {t('actions.commit')}
739
+ </button>
740
+ </div>
741
+ </div>
742
+ </>
743
+ )}
744
+
745
+ {/* Active File Diff Inspector */}
746
+ {selectedFileDiff && (
747
+ <div style={{ marginTop: '8px' }}>
748
+ <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '4px' }}>
749
+ <span style={{ fontSize: '11px', fontWeight: 600 }}>差异对比: {selectedFileDiff.path}</span>
750
+ <button
751
+ type="button"
752
+ className={css.actionTextBtn}
753
+ onClick={() => { setSelectedFileDiff(null) }}
754
+ >
755
+ 关闭
756
+ </button>
757
+ </div>
758
+ <DiffViewer diffText={selectedFileDiff.diff} />
759
+ </div>
760
+ )}
761
+ </>
762
+ )}
763
+
764
+ {/* TAB 2: DAG Topology Tree & Log (Workstation Grade) */}
765
+ {activeTab === 'tree' && (
766
+ <div style={{
767
+ display: 'flex',
768
+ flexDirection: 'column',
769
+ flex: '1 1 auto',
770
+ minHeight: 0,
771
+ overflow: 'hidden',
772
+ }}>
773
+ {/* Top Filter and Mainline Toggle Bar */}
774
+ <div className={css.treeFilterRow}>
775
+ <input
776
+ className={css.treeSearchInput}
777
+ placeholder={t('tree.searchPlaceholder')}
778
+ value={treeSearch}
779
+ onChange={(e) => { setTreeSearch(e.target.value) }}
780
+ />
781
+ <div className={css.mainlineToggleGroup}>
782
+ <button
783
+ type="button"
784
+ className={`${css.mainlineToggleBtn} ${filterMode === 'all' ? css.mainlineToggleBtnActive : ''}`}
785
+ onClick={() => { setFilterMode('all') }}
786
+ >
787
+ {t('tree.filterAll')}
788
+ </button>
789
+ <button
790
+ type="button"
791
+ className={`${css.mainlineToggleBtn} ${filterMode === 'mainline' ? css.mainlineToggleBtnActive : ''}`}
792
+ onClick={() => { setFilterMode('mainline') }}
793
+ >
794
+ {t('tree.filterMainline')}
795
+ </button>
796
+ </div>
797
+ </div>
798
+
799
+ {/* Main Workstation Layout: Table on left, Details on right */}
800
+ <div
801
+ className={css.workstationLayout}
802
+ style={{ '--git-graph-col-width': `${graphColWidth}px` } as Record<string, string>}
803
+ >
804
+ <div className={css.tablePane}>
805
+ {/* Columnar Grid Table Header */}
806
+ <div className={css.treeTableHeader}>
807
+ <span className={css.colHeaderTitle}>{t('tree.colGraph')}</span>
808
+ <span className={css.colHeaderTitle}>{t('tree.colSubject')}</span>
809
+ <span className={css.colHeaderTitle}>{t('tree.colAuthor')}</span>
810
+ <span className={css.colHeaderTitle}>{t('tree.colTime')}</span>
811
+ <span className={css.colHeaderTitle}>{t('tree.colSha')}</span>
812
+ </div>
813
+
814
+ {/* Rows List */}
815
+ <div className={css.treeContainer} style={{ position: 'relative' }}>
816
+ {/* Global Continuous SVG Graph Layer (IntelliJ IDEA Grade) */}
817
+ <svg
818
+ className={css.graphOverlaySvg}
819
+ style={{
820
+ width: `${graphColWidth}px`,
821
+ height: `${filteredCommits.length * 28}px`,
822
+ }}
823
+ >
824
+ {/* Continuous Edges (Trunk & Branch Bezier Rails) */}
825
+ {dagGraph.edges.map(e => (
826
+ <path
827
+ key={e.key}
828
+ d={e.d}
829
+ stroke={e.color}
830
+ strokeWidth={2}
831
+ fill="none"
832
+ strokeLinecap="round"
833
+ />
834
+ ))}
835
+
836
+ {/* Nodes (Centered on (x, y)) */}
837
+ {dagGraph.nodes.map((n) => {
838
+ const isHighlighted = highlightedCommitHash === n.hash
839
+ return (
840
+ <g key={n.hash}>
841
+ {isHighlighted && (
842
+ <circle
843
+ cx={n.x}
844
+ cy={n.y}
845
+ r={8.5}
846
+ className={css.locateGlowRing}
847
+ />
848
+ )}
849
+
850
+ {n.hasTag && (
851
+ <circle
852
+ cx={n.x}
853
+ cy={n.y}
854
+ r={6.5}
855
+ stroke="#f0883e"
856
+ strokeWidth={1.5}
857
+ fill="none"
858
+ opacity={0.9}
859
+ />
860
+ )}
861
+
862
+ {n.isHead && !n.hasTag && (
863
+ <circle
864
+ cx={n.x}
865
+ cy={n.y}
866
+ r={6.5}
867
+ stroke={n.color}
868
+ strokeWidth={1.5}
869
+ fill="none"
870
+ opacity={0.8}
871
+ />
872
+ )}
873
+
874
+ <circle
875
+ cx={n.x}
876
+ cy={n.y}
877
+ r={4}
878
+ fill={n.color}
879
+ />
880
+ </g>
881
+ )
882
+ })}
883
+ </svg>
884
+
885
+ {filteredCommits.map((c) => {
886
+ const isSelected = selectedCommitHash === c.hash
887
+ const isHighlighted = highlightedCommitHash === c.hash
888
+ const parsed = parseConventionalCommit(c.subject)
889
+ const avatar = getAuthorAvatar(c.author)
890
+
891
+ return (
892
+ <div
893
+ key={c.hash}
894
+ id={`git-commit-${c.hash}`}
895
+ className={[
896
+ css.treeRowGrid,
897
+ isSelected ? css.treeRowGridSelected : '',
898
+ isHighlighted ? css.treeRowHighlighted : '',
899
+ ].filter(Boolean).join(' ')}
900
+ onClick={() => { void handleSelectCommit(c.hash) }}
901
+ >
902
+ {/* Col 1: Empty Graph Column Spacer */}
903
+ <div style={{ width: `${graphColWidth}px`, height: 28, flexShrink: 0 }} />
904
+
905
+ {/* Col 2: Subject & Ref Badges & Conventional Commits */}
906
+ <div className={css.subjectCol}>
907
+ {c.refs?.map((ref, idx) => {
908
+ let refClass = css.refBranch
909
+ let refText = ref
910
+ if (ref.startsWith('tag: ')) {
911
+ refClass = css.refTag
912
+ refText = `🏷️ ${ref.slice(5)}`
913
+ } else if (ref.includes('HEAD')) {
914
+ refClass = css.refHead
915
+ } else if (ref.startsWith('origin/')) {
916
+ refClass = css.refRemote
917
+ }
918
+ return (
919
+ <span key={idx} className={`${css.refBadge} ${refClass}`} title={ref}>
920
+ {refText}
921
+ </span>
922
+ )
923
+ })}
924
+ {parsed.type && (
925
+ <span className={`${css.typeBadge} ${getTypeBadgeClass(parsed.type)}`}>
926
+ {parsed.type}
927
+ </span>
928
+ )}
929
+ <span className={css.treeSubject} title={c.subject}>
930
+ {c.subject}
931
+ </span>
932
+
933
+ {/* Hover Actions */}
934
+ <div className={css.treeRowActions} onClick={(e) => { e.stopPropagation() }}>
935
+ <button
936
+ type="button"
937
+ className={css.treeActionBtn}
938
+ title="复制完整 SHA"
939
+ onClick={() => {
940
+ void navigator.clipboard.writeText(c.hash)
941
+ }}
942
+ >
943
+ SHA
944
+ </button>
945
+ <button
946
+ type="button"
947
+ className={css.treeActionBtn}
948
+ title="在此提交新建 Tag"
949
+ onClick={() => {
950
+ setNewTagCommit(c.shortHash)
951
+ setCreatingTag(true)
952
+ setActiveTab('tags')
953
+ }}
954
+ >
955
+ 🏷️
956
+ </button>
957
+ <button
958
+ type="button"
959
+ className={css.treeActionBtn}
960
+ title="在此提交新建分支"
961
+ onClick={() => {
962
+ setCreatingBranch(true)
963
+ setActiveTab('branches')
964
+ }}
965
+ >
966
+ ⑂
967
+ </button>
968
+ </div>
969
+ </div>
970
+
971
+ {/* Col 3: Author with avatar initial */}
972
+ <div className={css.authorCol} title={c.author}>
973
+ <span className={css.authorAvatar} style={{ background: avatar.color }}>
974
+ {avatar.initial}
975
+ </span>
976
+ <span className={css.authorNameText}>{c.author}</span>
977
+ </div>
978
+
979
+ {/* Col 4: Time */}
980
+ <span className={css.timeCol} title={c.date}>
981
+ {c.relativeDate}
982
+ </span>
983
+
984
+ {/* Col 5: Commit Short Hash */}
985
+ <span className={css.shaCol} title={c.hash}>
986
+ {c.shortHash}
987
+ </span>
988
+ </div>
989
+ )
990
+ })}
991
+ </div>
992
+ </div>
993
+
994
+ {/* Right Details Panel: Commit Details & Changed Files (Matching Reference Image) */}
995
+ {commitDetail && (
996
+ <div className={css.detailsPane}>
997
+ <div className={css.detailsHeader}>
998
+ <span>{t('detail.title')}</span>
999
+ <button
1000
+ type="button"
1001
+ className={css.iconButton}
1002
+ title="关闭详情"
1003
+ onClick={() => {
1004
+ setSelectedCommitHash(null)
1005
+ setCommitDetail(null)
1006
+ }}
1007
+ >
1008
+ ✕
1009
+ </button>
1010
+ </div>
1011
+
1012
+ <div className={css.detailsContent}>
1013
+ {/* Commit Subject Title */}
1014
+ <div className={css.detailsSubject}>
1015
+ {commitDetail.commit.subject}
1016
+ </div>
1017
+
1018
+ {/* Commit Body Description */}
1019
+ {commitDetail.body && (
1020
+ <div className={css.detailsBody}>
1021
+ {commitDetail.body}
1022
+ </div>
1023
+ )}
1024
+
1025
+ {/* References */}
1026
+ {commitDetail.commit.refs && commitDetail.commit.refs.length > 0 && (
1027
+ <div>
1028
+ <div className={css.detailsSectionTitle}>{t('detail.references')}</div>
1029
+ <div style={{ display: 'flex', flexWrap: 'wrap', gap: '4px' }}>
1030
+ {commitDetail.commit.refs.map((ref, idx) => {
1031
+ let refClass = css.refBranch
1032
+ let refText = ref
1033
+ if (ref.startsWith('tag: ')) {
1034
+ refClass = css.refTag
1035
+ refText = `🏷️ ${ref.slice(5)}`
1036
+ } else if (ref.includes('HEAD')) {
1037
+ refClass = css.refHead
1038
+ } else if (ref.startsWith('origin/')) {
1039
+ refClass = css.refRemote
1040
+ }
1041
+ return (
1042
+ <span key={idx} className={`${css.refBadge} ${refClass}`}>
1043
+ {refText}
1044
+ </span>
1045
+ )
1046
+ })}
1047
+ </div>
1048
+ </div>
1049
+ )}
1050
+
1051
+ {/* Meta Properties Grid */}
1052
+ <div className={css.detailsMetaGrid}>
1053
+ <div className={css.detailsMetaRow}>
1054
+ <span className={css.detailsMetaLabel}>{t('detail.author')}</span>
1055
+ <span className={css.authorCol} style={{ maxWidth: '180px' }}>
1056
+ {(() => {
1057
+ const av = getAuthorAvatar(commitDetail.commit.author)
1058
+ return (
1059
+ <>
1060
+ <span className={css.authorAvatar} style={{ background: av.color }}>
1061
+ {av.initial}
1062
+ </span>
1063
+ <span className={css.authorNameText}>
1064
+ {commitDetail.commit.author}
1065
+ </span>
1066
+ </>
1067
+ )
1068
+ })()}
1069
+ </span>
1070
+ </div>
1071
+ <div className={css.detailsMetaRow}>
1072
+ <span className={css.detailsMetaLabel}>{t('detail.date')}</span>
1073
+ <span className={css.detailsMetaVal}>
1074
+ {formatFullDate(commitDetail.commit.date)}
1075
+ </span>
1076
+ </div>
1077
+ <div className={css.detailsMetaRow}>
1078
+ <span className={css.detailsMetaLabel}>Commit SHA</span>
1079
+ <span
1080
+ className={css.tagShaPill}
1081
+ title="点击复制完整 SHA"
1082
+ onClick={() => {
1083
+ void navigator.clipboard.writeText(commitDetail.commit.hash)
1084
+ }}
1085
+ >
1086
+ {commitDetail.commit.hash.slice(0, 13)}
1087
+ </span>
1088
+ </div>
1089
+ {commitDetail.parents && commitDetail.parents.length > 0 && (
1090
+ <div className={css.detailsMetaRow}>
1091
+ <span className={css.detailsMetaLabel}>{t('detail.parent')}</span>
1092
+ <div style={{ display: 'flex', gap: '4px' }}>
1093
+ {commitDetail.parents.map(p => (
1094
+ <span
1095
+ key={p}
1096
+ className={css.detailsParentLink}
1097
+ title={`定位到父提交: ${p}`}
1098
+ onClick={() => { void handleLocateCommit(p) }}
1099
+ >
1100
+ {p.slice(0, 10)}
1101
+ </span>
1102
+ ))}
1103
+ </div>
1104
+ </div>
1105
+ )}
1106
+ </div>
1107
+
1108
+ {/* Changed Files */}
1109
+ <div>
1110
+ <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '6px' }}>
1111
+ <span style={{ fontSize: '11px', fontWeight: 600 }}>
1112
+ 变更文件 ({commitDetail.files.length} 个文件)
1113
+ </span>
1114
+ <span className={css.numstat}>
1115
+ <span className={css.addStat}>+{commitDetail.totalAdditions}</span>
1116
+ <span className={css.delStat}>-{commitDetail.totalDeletions}</span>
1117
+ </span>
1118
+ </div>
1119
+ <div className={css.detailFilesList}>
1120
+ {commitDetail.files.map(f => (
1121
+ <div
1122
+ key={f.path}
1123
+ className={css.detailFileItem}
1124
+ onClick={() => { void handleViewDiff(f.path, false, commitDetail.commit.hash) }}
1125
+ >
1126
+ <div className={css.fileLeft}>
1127
+ <span
1128
+ className={`${css.statusBadge} ${
1129
+ f.status === 'added' ? css.statusA : f.status === 'deleted' ? css.statusD : css.statusM
1130
+ }`}
1131
+ >
1132
+ {f.status[0]?.toUpperCase() ?? 'M'}
1133
+ </span>
1134
+ <span className={css.fileName}>{f.path}</span>
1135
+ </div>
1136
+ <span className={css.numstat}>
1137
+ <span className={css.addStat}>+{f.additions}</span>
1138
+ <span className={css.delStat}>-{f.deletions}</span>
1139
+ </span>
1140
+ </div>
1141
+ ))}
1142
+ </div>
1143
+ </div>
1144
+
1145
+ {/* Diff Preview if selected file */}
1146
+ {selectedFileDiff && (
1147
+ <div style={{ marginTop: '6px' }}>
1148
+ <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '4px' }}>
1149
+ <span style={{ fontSize: '11px', fontWeight: 600 }}>差异: {selectedFileDiff.path}</span>
1150
+ <button
1151
+ type="button"
1152
+ className={css.actionTextBtn}
1153
+ onClick={() => { setSelectedFileDiff(null) }}
1154
+ >
1155
+ 关闭
1156
+ </button>
1157
+ </div>
1158
+ <DiffViewer diffText={selectedFileDiff.diff} />
1159
+ </div>
1160
+ )}
1161
+
1162
+ {/* Bottom Actions */}
1163
+ <div className={css.detailsBottomActions}>
1164
+ <button
1165
+ type="button"
1166
+ className={css.actionTextBtn}
1167
+ style={{ flex: 1, padding: '5px' }}
1168
+ onClick={() => {
1169
+ void handleCheckoutBranch(commitDetail.commit.shortHash)
1170
+ }}
1171
+ >
1172
+ {t('detail.checkout')}
1173
+ </button>
1174
+ <button
1175
+ type="button"
1176
+ className={css.commitBtn}
1177
+ style={{ flex: 1, padding: '5px' }}
1178
+ onClick={() => {
1179
+ setNewTagCommit(commitDetail.commit.shortHash)
1180
+ setCreatingTag(true)
1181
+ setActiveTab('tags')
1182
+ }}
1183
+ >
1184
+ {t('detail.createBranchOrTag')}
1185
+ </button>
1186
+ </div>
1187
+ </div>
1188
+ </div>
1189
+ )}
1190
+ </div>
1191
+
1192
+ {/* Bottom Status Footer (Sync Status & Commit Stats) */}
1193
+ <div className={css.treeStatusFooter}>
1194
+ <div className={css.footerSyncStatus}>
1195
+ <span className={css.syncDot} />
1196
+ <span>
1197
+ {status.ahead === 0 && status.behind === 0
1198
+ ? t('footer.syncNormal')
1199
+ : status.ahead > 0
1200
+ ? t('footer.ahead', { count: status.ahead })
1201
+ : t('footer.behind', { count: status.behind })}
1202
+ </span>
1203
+ </div>
1204
+ <div className={css.footerStats}>
1205
+ <span>
1206
+ {t('footer.showingCount', {
1207
+ count: filteredCommits.length,
1208
+ total: status.totalCommitsCount ?? commits.length,
1209
+ })}
1210
+ </span>
1211
+ {status.totalCommitsCount && commits.length < status.totalCommitsCount && (
1212
+ <span className={css.loadMoreLink} onClick={() => { void handleLoadMore() }}>
1213
+ {t('footer.loadMore')}
1214
+ </span>
1215
+ )}
1216
+ </div>
1217
+ </div>
1218
+ </div>
1219
+ )}
1220
+
1221
+ {/* TAB 3: Branches */}
1222
+ {activeTab === 'branches' && (
1223
+ <div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
1224
+ {/* Search and New Branch bar */}
1225
+ <div style={{ display: 'flex', gap: '6px' }}>
1226
+ <input
1227
+ className={css.commitTextarea}
1228
+ style={{ minHeight: '30px', height: '30px', padding: '4px 8px' }}
1229
+ placeholder={t('branch.filter')}
1230
+ value={branchFilter}
1231
+ onChange={(e) => { setBranchFilter(e.target.value) }}
1232
+ />
1233
+ <button
1234
+ type="button"
1235
+ className={css.actionTextBtn}
1236
+ style={{ flexShrink: 0 }}
1237
+ onClick={() => { setCreatingBranch(prev => !prev) }}
1238
+ >
1239
+ + 新建分支
1240
+ </button>
1241
+ </div>
1242
+
1243
+ {creatingBranch && (
1244
+ <div style={{ display: 'flex', gap: '6px' }}>
1245
+ <input
1246
+ autoFocus
1247
+ className={css.commitTextarea}
1248
+ style={{ minHeight: '30px', height: '30px', padding: '4px 8px' }}
1249
+ placeholder="新分支名称..."
1250
+ value={newBranchName}
1251
+ onChange={(e) => { setNewBranchName(e.target.value) }}
1252
+ onKeyDown={(e) => {
1253
+ if (e.key === 'Enter') {
1254
+ const trimmed = newBranchName.trim()
1255
+ if (trimmed) void handleCheckoutBranch(trimmed, true)
1256
+ }
1257
+ }}
1258
+ />
1259
+ <button
1260
+ type="button"
1261
+ className={css.commitBtn}
1262
+ onClick={() => {
1263
+ const trimmed = newBranchName.trim()
1264
+ if (trimmed) void handleCheckoutBranch(trimmed, true)
1265
+ }}
1266
+ >
1267
+ 创建并检出
1268
+ </button>
1269
+ </div>
1270
+ )}
1271
+
1272
+ {/* Local Branches */}
1273
+ <div className={css.section}>
1274
+ <div className={css.sectionHeader}>
1275
+ <span>本地分支 ({filteredLocalBranches.length})</span>
1276
+ </div>
1277
+ <div className={css.fileList}>
1278
+ {filteredLocalBranches.map(b => (
1279
+ <div
1280
+ key={b.name}
1281
+ className={css.fileItem}
1282
+ style={{ background: b.isCurrent ? 'rgba(56, 139, 253, 0.15)' : undefined }}
1283
+ onClick={() => {
1284
+ if (!b.isCurrent) void handleCheckoutBranch(b.name)
1285
+ }}
1286
+ >
1287
+ <div className={css.fileLeft}>
1288
+ <span style={{ color: b.isCurrent ? '#388bfd' : 'inherit' }}>
1289
+ {b.isCurrent ? '●' : '○'}
1290
+ </span>
1291
+ <span style={{ fontWeight: b.isCurrent ? 600 : 400 }}>{b.name}</span>
1292
+ </div>
1293
+ {b.isCurrent && <span style={{ fontSize: '11px', color: '#58a6ff' }}>当前检出</span>}
1294
+ </div>
1295
+ ))}
1296
+ </div>
1297
+ </div>
1298
+
1299
+ {/* Remote Branches */}
1300
+ {filteredRemoteBranches.length > 0 && (
1301
+ <div className={css.section}>
1302
+ <div className={css.sectionHeader}>
1303
+ <span>远程分支 ({filteredRemoteBranches.length})</span>
1304
+ </div>
1305
+ <div className={css.fileList}>
1306
+ {filteredRemoteBranches.map(b => (
1307
+ <div
1308
+ key={b.name}
1309
+ className={css.fileItem}
1310
+ onClick={() => { void handleCheckoutBranch(b.name) }}
1311
+ >
1312
+ <div className={css.fileLeft}>
1313
+ <span style={{ opacity: 0.6 }}>☁</span>
1314
+ <span>{b.name}</span>
1315
+ </div>
1316
+ </div>
1317
+ ))}
1318
+ </div>
1319
+ </div>
1320
+ )}
1321
+ </div>
1322
+ )}
1323
+
1324
+ {/* TAB 4: Tags (GitHub Releases/Tags Style) */}
1325
+ {activeTab === 'tags' && (
1326
+ <div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
1327
+ {/* Search and New Tag bar */}
1328
+ <div style={{ display: 'flex', gap: '6px' }}>
1329
+ <input
1330
+ className={css.commitTextarea}
1331
+ style={{ minHeight: '30px', height: '30px', padding: '4px 8px' }}
1332
+ placeholder={t('tag.filter')}
1333
+ value={tagFilter}
1334
+ onChange={(e) => { setTagFilter(e.target.value) }}
1335
+ />
1336
+ <button
1337
+ type="button"
1338
+ className={css.actionTextBtn}
1339
+ style={{ flexShrink: 0 }}
1340
+ onClick={() => { setCreatingTag(prev => !prev) }}
1341
+ >
1342
+ + {t('tag.newPrompt')}
1343
+ </button>
1344
+ </div>
1345
+
1346
+ {/* Create Tag Form */}
1347
+ {creatingTag && (
1348
+ <div className={css.tagForm}>
1349
+ <div className={css.tagFormTitle}>{t('tag.newPrompt')}</div>
1350
+ <div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
1351
+ <label style={{ fontSize: '11px', color: 'var(--dsw-alias-label-secondary)' }}>
1352
+ {t('tag.nameLabel')} *
1353
+ </label>
1354
+ <input
1355
+ autoFocus
1356
+ className={css.commitTextarea}
1357
+ style={{ minHeight: '28px', height: '28px', padding: '4px 8px' }}
1358
+ placeholder={t('tag.namePlaceholder')}
1359
+ value={newTagName}
1360
+ onChange={(e) => { setNewTagName(e.target.value) }}
1361
+ />
1362
+ </div>
1363
+ <div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
1364
+ <label style={{ fontSize: '11px', color: 'var(--dsw-alias-label-secondary)' }}>
1365
+ {t('tag.targetCommitLabel')}
1366
+ </label>
1367
+ <input
1368
+ className={css.commitTextarea}
1369
+ style={{ minHeight: '28px', height: '28px', padding: '4px 8px' }}
1370
+ placeholder="例如 687f282 或留空为 HEAD"
1371
+ value={newTagCommit}
1372
+ onChange={(e) => { setNewTagCommit(e.target.value) }}
1373
+ />
1374
+ </div>
1375
+ <div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
1376
+ <label style={{ fontSize: '11px', color: 'var(--dsw-alias-label-secondary)' }}>
1377
+ {t('tag.messageLabel')}
1378
+ </label>
1379
+ <input
1380
+ className={css.commitTextarea}
1381
+ style={{ minHeight: '28px', height: '28px', padding: '4px 8px' }}
1382
+ placeholder={t('tag.messagePrompt')}
1383
+ value={newTagMessage}
1384
+ onChange={(e) => { setNewTagMessage(e.target.value) }}
1385
+ />
1386
+ </div>
1387
+ <div className={css.tagFormActions}>
1388
+ <button
1389
+ type="button"
1390
+ className={css.actionTextBtn}
1391
+ onClick={() => { setCreatingTag(false) }}
1392
+ >
1393
+ {t('tag.cancelBtn')}
1394
+ </button>
1395
+ <button
1396
+ type="button"
1397
+ className={css.commitBtn}
1398
+ onClick={() => { void handleCreateTag() }}
1399
+ >
1400
+ {t('tag.createBtn')}
1401
+ </button>
1402
+ </div>
1403
+ </div>
1404
+ )}
1405
+
1406
+ {/* Tags Cards List */}
1407
+ {filteredTags.length === 0 ? (
1408
+ <div className={css.emptyState}>
1409
+ <svg viewBox="0 0 16 16" fill="currentColor">
1410
+ <path d="M1 7.775V2.75C1 1.784 1.784 1 2.75 1h5.025c.464 0 .91.184 1.238.513l6.25 6.25a1.75 1.75 0 0 1 0 2.474l-5.026 5.026a1.75 1.75 0 0 1-2.474 0l-6.25-6.25A1.752 1.752 0 0 1 1 7.775zm1.5 0c0 .066.026.13.073.177l6.25 6.25a.25.25 0 0 0 .354 0l5.025-5.025a.25.25 0 0 0 0-.354l-6.25-6.25a.25.25 0 0 0-.177-.073H2.75a.25.25 0 0 0-.25.25v5.025zM6 4.5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0z" />
1411
+ </svg>
1412
+ <div>{t('tag.empty')}</div>
1413
+ </div>
1414
+ ) : (
1415
+ <div className={css.tagList}>
1416
+ {filteredTags.map(tag => (
1417
+ <div key={tag.name} className={css.tagCard}>
1418
+ <div className={css.tagCardHeader}>
1419
+ <div className={css.tagTitleRow}>
1420
+ <span style={{ fontSize: '13px' }}>🏷️</span>
1421
+ <span
1422
+ className={css.tagName}
1423
+ title="点击在图谱中定位"
1424
+ onClick={() => { void handleLocateCommit(tag.commitHash, tag.name) }}
1425
+ >
1426
+ {tag.name}
1427
+ </span>
1428
+ <span
1429
+ className={css.tagShaPill}
1430
+ title={`Commit: ${tag.commitHash} (点击在图谱中定位)`}
1431
+ onClick={() => { void handleLocateCommit(tag.commitHash, tag.name) }}
1432
+ >
1433
+ {tag.shortHash}
1434
+ </span>
1435
+ {tag.isAnnotated && (
1436
+ <span className={css.tagAnnotatedBadge}>{t('tag.annotated')}</span>
1437
+ )}
1438
+ </div>
1439
+ <div className={css.tagActions}>
1440
+ <button
1441
+ type="button"
1442
+ className={css.tagLocateBtn}
1443
+ title="在图谱中定位该提交"
1444
+ onClick={() => { void handleLocateCommit(tag.commitHash, tag.name) }}
1445
+ >
1446
+ <span>🎯</span>
1447
+ <span>{t('tag.locateBtn')}</span>
1448
+ </button>
1449
+ <button
1450
+ type="button"
1451
+ className={css.fileMiniBtn}
1452
+ title={t('tag.copyName')}
1453
+ onClick={() => {
1454
+ void navigator.clipboard.writeText(tag.name)
1455
+ }}
1456
+ >
1457
+ 📋
1458
+ </button>
1459
+ <button
1460
+ type="button"
1461
+ className={css.fileMiniBtn}
1462
+ title={t('tag.deleteBtn')}
1463
+ onClick={() => { void handleDeleteTag(tag.name) }}
1464
+ >
1465
+ 🗑️
1466
+ </button>
1467
+ </div>
1468
+ </div>
1469
+
1470
+ {/* Commit Message or Tag Annotation */}
1471
+ {tag.message && (
1472
+ <div className={css.tagMessage} title={tag.message}>
1473
+ {tag.message}
1474
+ </div>
1475
+ )}
1476
+
1477
+ {/* Footer with author and date */}
1478
+ <div className={css.tagMetaFooter}>
1479
+ <span className={css.tagAuthor}>
1480
+ <span>👤</span>
1481
+ <span>{tag.author}</span>
1482
+ </span>
1483
+ <span title={tag.date}>
1484
+ {tag.relativeDate}
1485
+ </span>
1486
+ </div>
1487
+ </div>
1488
+ ))}
1489
+ </div>
1490
+ )}
1491
+ </div>
1492
+ )}
1493
+ </div>
1494
+ </div>
1495
+ )
1496
+ })