@civilization/dsh-git-workbench 0.0.0-stage → 0.4.1
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.
- package/LICENSE +201 -0
- package/README.md +119 -2
- package/cordis.patch.yml +3 -0
- package/icon.svg +26 -0
- package/lib/client.js +2943 -0
- package/lib/client.js.map +1 -0
- package/lib/index.js +1371 -0
- package/package.json +91 -4
- package/src/client/GitBody.module.css +1283 -0
- package/src/client/GitBody.tsx +1496 -0
- package/src/client/GitBranchPill.module.css +292 -0
- package/src/client/GitBranchPill.tsx +368 -0
- package/src/client/GitTitle.tsx +27 -0
- package/src/client/definition.tsx +63 -0
- package/src/client/git-helpers.ts +351 -0
- package/src/client/index.ts +221 -0
- package/src/client/locales.ts +183 -0
- package/src/css.d.ts +4 -0
- package/src/git-executor.ts +1025 -0
- package/src/index.ts +304 -0
- package/src/tool.ts +299 -0
- package/src/types.ts +187 -0
|
@@ -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
|
+
})
|