dsh-git-ui 0.0.2 → 0.1.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.
- package/README.md +27 -20
- package/README.zh.md +28 -21
- package/cordis.patch.yml +1 -1
- package/lib/client.js +36 -35
- package/lib/client.js.map +4 -4
- package/lib/host/actions.d.ts +22 -2
- package/lib/host/core.d.ts +7 -1
- package/lib/host/index.d.ts +12 -10
- package/lib/host/index.js +366 -21
- package/lib/host/index.js.map +4 -4
- package/lib/host/parser.d.ts +37 -1
- package/lib/host/queries.d.ts +17 -0
- package/lib/host/types.d.ts +131 -1
- package/package.json +1 -1
- package/src/client/GitCenter.tsx +1411 -148
- package/src/client/GitPill.tsx +280 -55
- package/src/client/changes-diff.ts +63 -0
- package/src/client/controller.ts +36 -1
- package/src/client/error-text.ts +21 -0
- package/src/client/file-tree.ts +101 -0
- package/src/client/git-graph.ts +188 -0
- package/src/client/icons.tsx +292 -0
- package/src/client/index.ts +2 -1
- package/src/client/locales.ts +124 -0
- package/src/client/popup-close.ts +19 -0
- package/src/client/remote.ts +83 -1
- package/src/client/select-menu.tsx +113 -0
- package/src/client/side-by-side.ts +150 -0
- package/src/client/styles.ts +1375 -86
- package/src/client/time-format.ts +32 -0
- package/src/host/actions.ts +66 -15
- package/src/host/core.ts +9 -2
- package/src/host/index.ts +21 -10
- package/src/host/parser.ts +155 -12
- package/src/host/queries.ts +289 -0
- package/src/host/types.ts +104 -0
package/src/client/GitCenter.tsx
CHANGED
|
@@ -1,113 +1,225 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Git center — the IDE-style management panel for one session's repository.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
4
|
+
* Three tabs on a system-styled tab bar:
|
|
5
|
+
* Changes — grouped lists (staged / unstaged / untracked) with per-file
|
|
6
|
+
* and bulk stage / unstage / discard, a commit box, and an
|
|
7
|
+
* inline diff preview for the selected file.
|
|
8
|
+
* History — paginated commit list (50/page + load more) with a detail
|
|
9
|
+
* pane (metadata + file stats) and per-file commit diffs.
|
|
10
|
+
* Branches — local/remote branch lists, create-and-switch, switch, safe
|
|
11
|
+
* delete (two-step confirm).
|
|
10
12
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
+
* Feedback: successes are transient system Toasts; errors are a dismissible
|
|
14
|
+
* in-panel banner. Discard/delete are destructive and require a second click
|
|
15
|
+
* within 3s.
|
|
13
16
|
*/
|
|
14
|
-
import { useEffect, useMemo, useState } from 'react'
|
|
15
|
-
import type { JSX } from 'react'
|
|
17
|
+
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
18
|
+
import type { CSSProperties, JSX, MouseEvent as ReactMouseEvent } from 'react'
|
|
16
19
|
import { Button, Modal, Toast } from '@deepseek-ai/dsh-client-ui-primitives'
|
|
17
|
-
import type {
|
|
20
|
+
import type {
|
|
21
|
+
GitAction, GitActionResult, GitBranch, GitChange, GitFileStat,
|
|
22
|
+
GitQueryRequest, GitSnapshot,
|
|
23
|
+
} from '../host/types.ts'
|
|
24
|
+
import type { GraphCommit, GitRef } from '../host/types.ts'
|
|
25
|
+
import type { GitQueryOutcome } from './controller.ts'
|
|
26
|
+
import { createGraphBuilder, graphWidth, markFilterEnds, GRAPH_COLORS, type GraphRow, type GraphRowMarker } from './git-graph.ts'
|
|
27
|
+
import { buildFileTree, splitChangePath, type FileTreeNode } from './file-tree.ts'
|
|
28
|
+
import { formatWhen } from './time-format.ts'
|
|
29
|
+
import { buildSideBySide, capSideBySideRows, isBinaryDiff, summarizeChanges, type SideCell } from './side-by-side.ts'
|
|
30
|
+
import { diffBaseOf, reconcileDiffSelection, stepDiffSelection, type DiffSelection } from './changes-diff.ts'
|
|
31
|
+
import { BranchIcon, ChevronIcon, CloseIcon, CollapseAllIcon, DiffIcon, ExpandAllIcon, FileIcon, fileIconForPath, FolderIcon, NextIcon, PrevIcon, RollbackIcon, StageIcon, StarIcon, TagIcon, UnstageIcon } from './icons.tsx'
|
|
18
32
|
import type { GitKey } from './locales.ts'
|
|
33
|
+
import { errorText } from './error-text.ts'
|
|
34
|
+
import { SelectMenu } from './select-menu.tsx'
|
|
19
35
|
import * as css from './styles.ts'
|
|
20
36
|
|
|
21
|
-
/** One change-row action callback; all are disabled while busy. */
|
|
22
|
-
interface RowActions {
|
|
23
|
-
readonly onToggle: (path: string) => void
|
|
24
|
-
readonly onStage: (path: string) => void
|
|
25
|
-
readonly onUnstage: (path: string) => void
|
|
26
|
-
readonly onDiscard: (path: string) => void
|
|
27
|
-
}
|
|
28
|
-
|
|
29
37
|
export interface GitCenterProps {
|
|
30
38
|
readonly open: boolean
|
|
31
39
|
readonly onClose: () => void
|
|
32
40
|
readonly snapshot: GitSnapshot
|
|
33
41
|
readonly run: (action: GitAction) => Promise<GitActionResult>
|
|
42
|
+
readonly query: (query: GitQueryRequest['query']) => Promise<GitQueryOutcome>
|
|
34
43
|
readonly t: (key: GitKey) => string
|
|
44
|
+
/** 打开定位:从 pill 点击变更文件而来——切到 changes 标签并打开该文件对照。 */
|
|
45
|
+
readonly openRequest?: { readonly path: string; readonly base: 'worktree' | 'staged' } | null
|
|
35
46
|
}
|
|
36
47
|
|
|
37
|
-
type
|
|
48
|
+
type TabKey = 'changes' | 'history'
|
|
49
|
+
|
|
50
|
+
/** 反馈条:text 为展示文案(业务错误经 i18n 友好化);detail 保留原始信息供 title。 */
|
|
51
|
+
type Feedback = { readonly text: string; readonly detail?: string } | null
|
|
38
52
|
|
|
39
|
-
/** One transient success toast (keyed by seq so repeats restart the cycle). */
|
|
40
53
|
interface ToastState {
|
|
41
54
|
readonly text: string
|
|
42
55
|
readonly seq: number
|
|
43
56
|
}
|
|
44
57
|
|
|
45
|
-
|
|
46
|
-
function ChangeRow({
|
|
47
|
-
change, checked, busy, actions, t,
|
|
48
|
-
}: {
|
|
49
|
-
change: GitChange
|
|
50
|
-
checked: boolean
|
|
51
|
-
busy: boolean
|
|
52
|
-
actions: RowActions
|
|
53
|
-
t: (key: GitKey) => string
|
|
54
|
-
}): JSX.Element {
|
|
55
|
-
const untracked = change.status === 'untracked'
|
|
56
|
-
return (
|
|
57
|
-
<div className="dsh-git-ui__row" style={css.centerRow}>
|
|
58
|
-
<input
|
|
59
|
-
type="checkbox"
|
|
60
|
-
style={css.changeCheckbox}
|
|
61
|
-
checked={checked}
|
|
62
|
-
disabled={busy}
|
|
63
|
-
onChange={() => { actions.onToggle(change.path) }}
|
|
64
|
-
aria-label={change.path}
|
|
65
|
-
/>
|
|
66
|
-
<span style={css.changeChip} title={change.status}>
|
|
67
|
-
{CHIP_LETTERS[change.status] ?? '•'}
|
|
68
|
-
</span>
|
|
69
|
-
<span style={css.changePathText} title={change.path}>{change.path}</span>
|
|
70
|
-
{change.staged
|
|
71
|
-
? <Button size="sm" disabled={busy} onClick={() => actions.onUnstage(change.path)}>{t('center.unstage')}</Button>
|
|
72
|
-
: <Button size="sm" disabled={busy} onClick={() => actions.onStage(change.path)}>{t('center.stage')}</Button>}
|
|
73
|
-
{!untracked && (
|
|
74
|
-
<Button size="sm" disabled={busy} onClick={() => actions.onDiscard(change.path)}>{t('center.discard')}</Button>
|
|
75
|
-
)}
|
|
76
|
-
</div>
|
|
77
|
-
)
|
|
78
|
-
}
|
|
58
|
+
const HISTORY_PAGE = 1000
|
|
79
59
|
|
|
80
60
|
const CHIP_LETTERS: Record<string, string> = {
|
|
81
61
|
added: 'A', modified: 'M', deleted: 'D', renamed: 'R',
|
|
82
62
|
untracked: '?', conflicted: '!', typechange: 'T',
|
|
83
63
|
}
|
|
84
64
|
|
|
65
|
+
/** IDEA 式时间:不足 60 分钟「x 分钟前」、今天/昨天 HH:mm,其余 Y/M/D HH:mm。 */
|
|
66
|
+
function timeAgo(iso: string, now: number, t: (key: GitKey) => string): string {
|
|
67
|
+
return formatWhen(iso, now, {
|
|
68
|
+
minutesAgo: (n) => t('time.minutesAgo').replace('{n}', String(n)),
|
|
69
|
+
today: t('time.today'),
|
|
70
|
+
yesterday: t('time.yesterday'),
|
|
71
|
+
})
|
|
72
|
+
}
|
|
73
|
+
|
|
85
74
|
/**
|
|
86
|
-
* The management panel.
|
|
87
|
-
*
|
|
88
|
-
* operation re-renders this component with fresh state.
|
|
75
|
+
* The management panel. `snapshot` comes from the live controller view, so
|
|
76
|
+
* every successful operation re-renders this component with fresh state.
|
|
89
77
|
*/
|
|
90
78
|
export function GitCenter({
|
|
91
|
-
open, onClose, snapshot, run, t,
|
|
79
|
+
open, onClose, snapshot, run, query, t, openRequest = null,
|
|
92
80
|
}: GitCenterProps): JSX.Element | null {
|
|
93
|
-
const [
|
|
94
|
-
const [message, setMessage] = useState('')
|
|
81
|
+
const [tab, setTab] = useState<TabKey>('changes')
|
|
95
82
|
const [busy, setBusy] = useState(false)
|
|
96
83
|
const [feedback, setFeedback] = useState<Feedback>(null)
|
|
97
84
|
const [toast, setToast] = useState<ToastState | null>(null)
|
|
85
|
+
|
|
86
|
+
// 打开定位请求(pill 点击变更文件):切到 changes 标签,由 ChangesTab
|
|
87
|
+
// 响应 openRequest 打开该文件对照。openRequest 对象引用变化即再次定位。
|
|
88
|
+
useEffect(() => {
|
|
89
|
+
if (openRequest !== null) setTab('changes')
|
|
90
|
+
}, [openRequest])
|
|
91
|
+
|
|
92
|
+
/** Execute a management action with shared busy/feedback/toast handling. */
|
|
93
|
+
const execute = async (action: GitAction, successText: string): Promise<boolean> => {
|
|
94
|
+
if (busy) return false
|
|
95
|
+
setBusy(true)
|
|
96
|
+
setFeedback(null)
|
|
97
|
+
const result = await run(action)
|
|
98
|
+
setBusy(false)
|
|
99
|
+
if (result.ok) {
|
|
100
|
+
setToast({ text: successText, seq: Date.now() })
|
|
101
|
+
return true
|
|
102
|
+
}
|
|
103
|
+
setFeedback({
|
|
104
|
+
text: errorText(result.error.code, result.error.message, t),
|
|
105
|
+
...(result.error.message === undefined ? {} : { detail: result.error.message }),
|
|
106
|
+
})
|
|
107
|
+
return false
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const tabs: Array<{ key: TabKey; label: string }> = [
|
|
111
|
+
{ key: 'changes', label: t('center.changes') },
|
|
112
|
+
{ key: 'history', label: t('center.history') },
|
|
113
|
+
]
|
|
114
|
+
|
|
115
|
+
return (
|
|
116
|
+
<Modal open={open} onClose={onClose} title={t('center.title')} closeLabel={t('center.close')} headless className="dsh-git-ui__center">
|
|
117
|
+
<div style={css.centerShell}>
|
|
118
|
+
<div style={css.centerHeader}>
|
|
119
|
+
<h2 style={css.centerTitle} title={snapshot.root}>{snapshot.branch ?? '(detached)'} — {t('center.title')}</h2>
|
|
120
|
+
<Button size="sm" onClick={onClose} aria-label={t('center.close')}>✕</Button>
|
|
121
|
+
</div>
|
|
122
|
+
|
|
123
|
+
<div style={css.tabs} role="tablist">
|
|
124
|
+
{tabs.map(({ key, label }) => (
|
|
125
|
+
<button
|
|
126
|
+
key={key}
|
|
127
|
+
type="button"
|
|
128
|
+
role="tab"
|
|
129
|
+
aria-selected={tab === key}
|
|
130
|
+
className={`dsh-git-ui__tab${tab === key ? ' dsh-git-ui__tab--active' : ''}`}
|
|
131
|
+
style={tab === key ? { ...css.tab, ...css.tabActive } : css.tab}
|
|
132
|
+
onClick={() => setTab(key)}
|
|
133
|
+
>
|
|
134
|
+
{label}
|
|
135
|
+
</button>
|
|
136
|
+
))}
|
|
137
|
+
</div>
|
|
138
|
+
|
|
139
|
+
<div style={css.centerBody}>
|
|
140
|
+
{feedback !== null && (
|
|
141
|
+
<div style={css.feedbackError} role="alert" title={feedback.detail}>
|
|
142
|
+
<span style={{ flex: 1 }}>{feedback.text}</span>
|
|
143
|
+
<button type="button" style={css.feedbackClose} onClick={() => setFeedback(null)} aria-label={t('center.close')}>✕</button>
|
|
144
|
+
</div>
|
|
145
|
+
)}
|
|
146
|
+
|
|
147
|
+
{/* 三标签保持挂载、display 切换:保留各自状态(选中/分页/分支列表),与 IDE 行为一致。 */}
|
|
148
|
+
<div style={tab === 'changes' ? { display: 'contents' } : { display: 'none' }}>
|
|
149
|
+
<ChangesTab snapshot={snapshot} busy={busy} execute={execute} query={query} t={t} openRequest={openRequest} />
|
|
150
|
+
</div>
|
|
151
|
+
<div style={tab === 'history' ? { display: 'contents' } : { display: 'none' }}>
|
|
152
|
+
<HistoryTab query={query} run={run} t={t} />
|
|
153
|
+
</div>
|
|
154
|
+
</div>
|
|
155
|
+
</div>
|
|
156
|
+
{toast !== null && (
|
|
157
|
+
<Toast key={toast.seq} text={toast.text} onDone={() => setToast(null)} />
|
|
158
|
+
)}
|
|
159
|
+
</Modal>
|
|
160
|
+
)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ── Changes tab ───────────────────────────────────────────────────────────
|
|
164
|
+
|
|
165
|
+
/** Changes 分组键(IDEA 式三段:已暂存更改 / 更改 / 未版本控制的文件)。 */
|
|
166
|
+
type ChangeGroupKey = 'staged' | 'unstaged' | 'untracked'
|
|
167
|
+
|
|
168
|
+
interface ChangeGroup {
|
|
169
|
+
readonly key: ChangeGroupKey
|
|
170
|
+
readonly labelKey: GitKey
|
|
171
|
+
readonly items: readonly GitChange[]
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** 组内按路径字母序(IDEA 行为)。 */
|
|
175
|
+
function byPath(a: GitChange, b: GitChange): number {
|
|
176
|
+
return a.path.localeCompare(b.path)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** 一条变更所属的分组键(IDEA 三段:已暂存/更改/未版本控制)。 */
|
|
180
|
+
function groupKeyOfChange(c: GitChange): ChangeGroupKey {
|
|
181
|
+
if (c.status === 'untracked') return 'untracked'
|
|
182
|
+
return c.staged ? 'staged' : 'unstaged'
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function ChangesTab({
|
|
186
|
+
snapshot, busy, execute, query, t, openRequest = null,
|
|
187
|
+
}: {
|
|
188
|
+
snapshot: GitSnapshot
|
|
189
|
+
busy: boolean
|
|
190
|
+
execute: (action: GitAction, successText: string) => Promise<boolean>
|
|
191
|
+
query: (query: GitQueryRequest['query']) => Promise<GitQueryOutcome>
|
|
192
|
+
t: (key: GitKey) => string
|
|
193
|
+
openRequest: { path: string; base: 'worktree' | 'staged' } | null
|
|
194
|
+
}): JSX.Element {
|
|
195
|
+
const [selected, setSelected] = useState<ReadonlySet<string>>(new Set())
|
|
196
|
+
const [message, setMessage] = useState('')
|
|
98
197
|
const [armed, setArmed] = useState<string | 'all' | null>(null)
|
|
198
|
+
/** 折叠的分组键。 */
|
|
199
|
+
const [closedGroups, setClosedGroups] = useState<ReadonlySet<ChangeGroupKey>>(new Set())
|
|
200
|
+
/** 左栏宽度(IDEA 式自由拖拽)。 */
|
|
201
|
+
const [leftW, setLeftW] = useState(360)
|
|
202
|
+
/** 当前对照查看的文件(base 取决于暂存态)。 */
|
|
203
|
+
const [diffSel, setDiffSel] = useState<DiffSelection | null>(null)
|
|
204
|
+
const [diffText, setDiffText] = useState<string | null>(null)
|
|
205
|
+
const [diffLoading, setDiffLoading] = useState(false)
|
|
206
|
+
const diffSeq = useRef(0)
|
|
99
207
|
|
|
100
|
-
// Auto-disarm the destructive confirm after 3s.
|
|
101
208
|
useEffect(() => {
|
|
102
209
|
if (armed === null) return
|
|
103
210
|
const timer = setTimeout(() => setArmed(null), 3000)
|
|
104
211
|
return () => clearTimeout(timer)
|
|
105
212
|
}, [armed])
|
|
106
213
|
|
|
107
|
-
|
|
108
|
-
const
|
|
109
|
-
const
|
|
110
|
-
const
|
|
214
|
+
// IDEA 式三段分组:混合态(MM)双条目天然分列两组;组内路径字母序。
|
|
215
|
+
const stagedItems = useMemo(() => snapshot.changes.filter((c) => c.staged).sort(byPath), [snapshot])
|
|
216
|
+
const unstagedItems = useMemo(() => snapshot.changes.filter((c) => !c.staged && c.status !== 'untracked').sort(byPath), [snapshot])
|
|
217
|
+
const untrackedItems = useMemo(() => snapshot.changes.filter((c) => c.status === 'untracked').sort(byPath), [snapshot])
|
|
218
|
+
const groups: readonly ChangeGroup[] = [
|
|
219
|
+
{ key: 'staged' as const, labelKey: 'changes.groupStaged' as const, items: stagedItems },
|
|
220
|
+
{ key: 'unstaged' as const, labelKey: 'changes.groupUnstaged' as const, items: unstagedItems },
|
|
221
|
+
{ key: 'untracked' as const, labelKey: 'changes.groupUnversioned' as const, items: untrackedItems },
|
|
222
|
+
].filter((g) => g.items.length > 0)
|
|
111
223
|
|
|
112
224
|
const toggle = (path: string): void => {
|
|
113
225
|
setSelected((prev) => {
|
|
@@ -118,91 +230,172 @@ export function GitCenter({
|
|
|
118
230
|
})
|
|
119
231
|
}
|
|
120
232
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
// Transient system toast (holds ~3s, fades, unmounts itself).
|
|
132
|
-
setToast({ text: successText, seq: Date.now() })
|
|
133
|
-
} else {
|
|
134
|
-
// Persistent panel-level error banner with a dismiss button.
|
|
135
|
-
setFeedback({ kind: 'error', text: result.error.message ?? result.error.code })
|
|
136
|
-
}
|
|
233
|
+
/** 组级全选 / 全消选(半选态由视图按 some/all 推导)。 */
|
|
234
|
+
const selectGroup = (items: readonly GitChange[], check: boolean): void => {
|
|
235
|
+
setSelected((prev) => {
|
|
236
|
+
const next = new Set(prev)
|
|
237
|
+
for (const c of items) {
|
|
238
|
+
if (check) next.add(c.path)
|
|
239
|
+
else next.delete(c.path)
|
|
240
|
+
}
|
|
241
|
+
return next
|
|
242
|
+
})
|
|
137
243
|
}
|
|
138
244
|
|
|
139
|
-
const
|
|
140
|
-
|
|
245
|
+
const toggleGroupClosed = (key: ChangeGroupKey): void => {
|
|
246
|
+
setClosedGroups((prev) => {
|
|
247
|
+
const next = new Set(prev)
|
|
248
|
+
if (next.has(key)) next.delete(key)
|
|
249
|
+
else next.add(key)
|
|
250
|
+
return next
|
|
251
|
+
})
|
|
141
252
|
}
|
|
142
253
|
|
|
143
|
-
const
|
|
144
|
-
|
|
145
|
-
|
|
254
|
+
const showDiff = async (path: string, base: 'worktree' | 'staged'): Promise<void> => {
|
|
255
|
+
const seq = ++diffSeq.current
|
|
256
|
+
setDiffSel({ path, base })
|
|
257
|
+
setDiffText(null)
|
|
258
|
+
setDiffLoading(true)
|
|
259
|
+
const outcome = await query({ kind: 'diff', path, base })
|
|
260
|
+
if (seq !== diffSeq.current) return
|
|
261
|
+
setDiffLoading(false)
|
|
262
|
+
setDiffText(outcome.ok && outcome.value.kind === 'diff' ? outcome.value.text : null)
|
|
146
263
|
}
|
|
147
264
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
265
|
+
// 打开定位请求(pill 点击变更文件):展开文件所在分组并打开对照。
|
|
266
|
+
// 仅随 openRequest 对象引用变化触发;snapshot 轮询由下方 reconcile effect 专门处理。
|
|
267
|
+
useEffect(() => {
|
|
268
|
+
if (openRequest === null) return
|
|
269
|
+
setClosedGroups((prev) => {
|
|
270
|
+
const keys = snapshot.changes.filter((c) => c.path === openRequest.path).map(groupKeyOfChange)
|
|
271
|
+
const need = [...new Set(keys)].filter((k) => prev.has(k))
|
|
272
|
+
if (need.length === 0) return prev
|
|
273
|
+
const next = new Set(prev)
|
|
274
|
+
for (const k of need) next.delete(k)
|
|
275
|
+
return next
|
|
276
|
+
})
|
|
277
|
+
void showDiff(openRequest.path, openRequest.base)
|
|
278
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- 仅响应 openRequest 引用变化;showDiff 每次渲染重建、snapshot 由 reconcile 协调
|
|
279
|
+
}, [openRequest])
|
|
280
|
+
|
|
281
|
+
// 差异视图跟随快照:管理操作成功/轮询刷新后,文件消失 → 关闭对照;
|
|
282
|
+
// 暂存侧迁移(MM 双条目)→ 按新基线重取;内容也可能随操作变化 → 一律重取。
|
|
283
|
+
useEffect(() => {
|
|
284
|
+
if (diffSel === null) return
|
|
285
|
+
const desired = reconcileDiffSelection(diffSel, snapshot.changes)
|
|
286
|
+
if (desired === null) {
|
|
287
|
+
diffSeq.current += 1
|
|
288
|
+
setDiffSel(null)
|
|
289
|
+
setDiffText(null)
|
|
290
|
+
} else {
|
|
291
|
+
void showDiff(desired.path, desired.base)
|
|
292
|
+
}
|
|
293
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- 快照驱动的差异协调;showDiff 经 diffSeq 防竞态
|
|
294
|
+
}, [snapshot])
|
|
152
295
|
|
|
153
296
|
const commit = (): void => {
|
|
154
297
|
const text = message.trim()
|
|
155
298
|
if (text === '' || busy) return
|
|
156
299
|
const paths = selected.size > 0 ? [...selected] : undefined
|
|
157
|
-
void
|
|
300
|
+
void execute({ kind: 'commit', message: text, ...(paths === undefined ? {} : { paths }) }, t('center.done'))
|
|
301
|
+
.then((ok) => { if (ok) { setSelected(new Set()); setMessage('') } })
|
|
158
302
|
}
|
|
159
303
|
|
|
160
|
-
const
|
|
304
|
+
const rowActions = {
|
|
161
305
|
onToggle: toggle,
|
|
162
|
-
onStage: (path) => void
|
|
163
|
-
onUnstage: (path) => void
|
|
164
|
-
onDiscard:
|
|
306
|
+
onStage: (path: string) => void execute({ kind: 'stage', paths: [path] }, t('center.done')),
|
|
307
|
+
onUnstage: (path: string) => void execute({ kind: 'unstage', paths: [path] }, t('center.done')),
|
|
308
|
+
onDiscard: (path: string) => {
|
|
309
|
+
if (armed === path) {
|
|
310
|
+
void execute({ kind: 'discard', paths: [path] }, t('center.done'))
|
|
311
|
+
} else {
|
|
312
|
+
setArmed((prev) => (prev === path ? null : path))
|
|
313
|
+
}
|
|
314
|
+
},
|
|
165
315
|
}
|
|
166
316
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
<div style={css.centerHeader}>
|
|
171
|
-
<h2 style={css.centerTitle} title={snapshot.root}>{snapshot.branch ?? '(detached)'} — {t('center.title')}</h2>
|
|
172
|
-
<Button size="sm" onClick={onClose} aria-label={t('popup.refresh')}>✕</Button>
|
|
173
|
-
</div>
|
|
317
|
+
/** 差异前后导航序列:三段分组顺序(已暂存 → 更改 → 未版本控制),
|
|
318
|
+
* 排除目录条目(目录无 diff 语义,不应进入对照导航)。 */
|
|
319
|
+
const navEntries = useMemo(() => groups.flatMap((g) => g.items).filter((c) => !c.isDirectory), [groups])
|
|
174
320
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
</div>
|
|
181
|
-
)}
|
|
321
|
+
/** 上一个/下一个更改(循环遍历);未打开对照时定位第一条。 */
|
|
322
|
+
const navigateDiff = (delta: number): void => {
|
|
323
|
+
const next = stepDiffSelection(navEntries, diffSel, delta)
|
|
324
|
+
if (next !== null) void showDiff(next.path, next.base)
|
|
325
|
+
}
|
|
182
326
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
{t('center.unstageAll')}
|
|
189
|
-
</Button>
|
|
190
|
-
<Button size="sm" disabled={busy || !hasTrackedChanges} onClick={discardAll}>
|
|
191
|
-
{armed === 'all' ? t('center.confirmDiscard') : t('center.discardAll')}
|
|
192
|
-
</Button>
|
|
193
|
-
</div>
|
|
327
|
+
/** 差异增删摘要(由 diffText 派生;空/无对照时为 null)。 */
|
|
328
|
+
const diffSummary = useMemo(
|
|
329
|
+
() => (diffText === null || diffText === '' ? null : summarizeChanges(buildSideBySide(diffText))),
|
|
330
|
+
[diffText],
|
|
331
|
+
)
|
|
194
332
|
|
|
333
|
+
return (
|
|
334
|
+
<div style={css.changesLayout}>
|
|
335
|
+
<div style={{ ...css.changesLeft, width: leftW }}>
|
|
336
|
+
<div style={css.toolRow}>
|
|
337
|
+
<Button size="sm" disabled={busy || (unstagedItems.length === 0 && untrackedItems.length === 0)} onClick={() => void execute({ kind: 'stage-all' }, t('center.done'))}>
|
|
338
|
+
{t('center.stageAll')}
|
|
339
|
+
</Button>
|
|
340
|
+
<Button size="sm" disabled={busy || stagedItems.length === 0} onClick={() => void execute({ kind: 'unstage-all' }, t('center.done'))}>
|
|
341
|
+
{t('center.unstageAll')}
|
|
342
|
+
</Button>
|
|
343
|
+
<Button
|
|
344
|
+
size="sm"
|
|
345
|
+
disabled={busy || stagedItems.length + unstagedItems.length === 0}
|
|
346
|
+
onClick={() => {
|
|
347
|
+
if (armed === 'all') {
|
|
348
|
+
void execute({ kind: 'discard-all' }, t('center.done'))
|
|
349
|
+
} else {
|
|
350
|
+
setArmed((prev) => (prev === 'all' ? null : 'all'))
|
|
351
|
+
}
|
|
352
|
+
}}
|
|
353
|
+
>
|
|
354
|
+
{armed === 'all' ? t('center.confirmDiscard') : t('center.discardAll')}
|
|
355
|
+
</Button>
|
|
356
|
+
</div>
|
|
357
|
+
<div style={css.changesList}>
|
|
195
358
|
{snapshot.changes.length === 0
|
|
196
359
|
? <div style={css.emptyNote}>{t('center.empty')}</div>
|
|
197
360
|
: (
|
|
198
361
|
<>
|
|
199
|
-
{
|
|
200
|
-
|
|
201
|
-
|
|
362
|
+
{groups.map((group) => (
|
|
363
|
+
<div key={group.key}>
|
|
364
|
+
<ChangeGroupHeader
|
|
365
|
+
label={t(group.labelKey)}
|
|
366
|
+
count={group.items.length}
|
|
367
|
+
closed={closedGroups.has(group.key)}
|
|
368
|
+
allChecked={group.items.length > 0 && group.items.every((c) => selected.has(c.path))}
|
|
369
|
+
someChecked={group.items.some((c) => selected.has(c.path))}
|
|
370
|
+
onToggleClosed={() => toggleGroupClosed(group.key)}
|
|
371
|
+
onSelectAll={(check) => selectGroup(group.items, check)}
|
|
372
|
+
t={t}
|
|
373
|
+
/>
|
|
374
|
+
{!closedGroups.has(group.key) && group.items.map((change) => (
|
|
375
|
+
<ChangeRow
|
|
376
|
+
key={change.path}
|
|
377
|
+
change={change}
|
|
378
|
+
checked={selected.has(change.path)}
|
|
379
|
+
busy={busy}
|
|
380
|
+
armed={armed}
|
|
381
|
+
diffActive={diffSel !== null && diffSel.path === change.path && diffSel.base === diffBaseOf(change)}
|
|
382
|
+
rowActions={rowActions}
|
|
383
|
+
onShowDiff={(p, b) => void showDiff(p, b)}
|
|
384
|
+
t={t}
|
|
385
|
+
/>
|
|
386
|
+
))}
|
|
387
|
+
</div>
|
|
388
|
+
))}
|
|
389
|
+
{snapshot.truncated && (
|
|
390
|
+
<div style={css.emptyNote}>
|
|
391
|
+
{t('changes.listTruncated')
|
|
392
|
+
.replace('{count}', String(snapshot.changes.length))
|
|
393
|
+
.replace('{total}', String(snapshot.staged + snapshot.modified + snapshot.untracked))}
|
|
394
|
+
</div>
|
|
395
|
+
)}
|
|
202
396
|
</>
|
|
203
397
|
)}
|
|
204
398
|
</div>
|
|
205
|
-
|
|
206
399
|
<div style={css.commitBox}>
|
|
207
400
|
<textarea
|
|
208
401
|
className="dsh-git-ui__commit-input"
|
|
@@ -216,46 +409,1116 @@ export function GitCenter({
|
|
|
216
409
|
}}
|
|
217
410
|
/>
|
|
218
411
|
<div style={css.commitFooter}>
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
412
|
+
{selected.size > 0 && (
|
|
413
|
+
<span style={css.commitHint}>
|
|
414
|
+
{t('center.commitSelected').replace('{count}', String(selected.size))}
|
|
415
|
+
</span>
|
|
416
|
+
)}
|
|
417
|
+
<span style={{ flex: 1 }} />
|
|
418
|
+
<span style={css.commitKbd} aria-hidden="true">⌘/Ctrl + ↵</span>
|
|
222
419
|
<Button variant="primary" size="sm" disabled={busy || message.trim() === ''} onClick={commit}>
|
|
223
420
|
{busy ? t('center.busy') : t('center.commit')}
|
|
224
421
|
</Button>
|
|
225
422
|
</div>
|
|
226
423
|
</div>
|
|
227
424
|
</div>
|
|
228
|
-
{
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
425
|
+
<Splitter kind="col" onDrag={(dx) => setLeftW((w) => clampNum(w + dx, 280, 520))} />
|
|
426
|
+
<div style={css.changesRight}>
|
|
427
|
+
{diffSel === null
|
|
428
|
+
? <div style={css.rightEmptyZone}>{t('center.selectFileDiff')}</div>
|
|
429
|
+
: (
|
|
430
|
+
<>
|
|
431
|
+
<div style={css.diffToolbar}>
|
|
432
|
+
<span style={css.diffBaseBadge}>
|
|
433
|
+
{diffSel.base === 'staged' ? t('diff.baseStaged') : t('diff.baseWorktree')}
|
|
434
|
+
</span>
|
|
435
|
+
{(() => {
|
|
436
|
+
const { name, dir } = splitChangePath(diffSel.path)
|
|
437
|
+
return (
|
|
438
|
+
<>
|
|
439
|
+
{dir !== '' && <span style={css.diffPathDir} title={diffSel.path}>{dir}</span>}
|
|
440
|
+
<span style={css.diffPathName}>{name}</span>
|
|
441
|
+
</>
|
|
442
|
+
)
|
|
443
|
+
})()}
|
|
444
|
+
{diffSummary !== null && (diffSummary.add > 0 || diffSummary.del > 0) && (
|
|
445
|
+
<span style={css.diffSummary}>
|
|
446
|
+
{diffSummary.add > 0 && <span style={css.diffSummaryAdd}>+{diffSummary.add}</span>}
|
|
447
|
+
{diffSummary.del > 0 && <span style={css.diffSummaryDel}>−{diffSummary.del}</span>}
|
|
448
|
+
</span>
|
|
449
|
+
)}
|
|
450
|
+
<button
|
|
451
|
+
type="button"
|
|
452
|
+
className="dsh-git-ui__icon-btn"
|
|
453
|
+
style={css.rowIconButton}
|
|
454
|
+
title={t('diff.prev')}
|
|
455
|
+
aria-label={t('diff.prev')}
|
|
456
|
+
disabled={busy || navEntries.length === 0}
|
|
457
|
+
onClick={() => navigateDiff(-1)}
|
|
458
|
+
>
|
|
459
|
+
<PrevIcon />
|
|
460
|
+
</button>
|
|
461
|
+
<button
|
|
462
|
+
type="button"
|
|
463
|
+
className="dsh-git-ui__icon-btn"
|
|
464
|
+
style={css.rowIconButton}
|
|
465
|
+
title={t('diff.next')}
|
|
466
|
+
aria-label={t('diff.next')}
|
|
467
|
+
disabled={busy || navEntries.length === 0}
|
|
468
|
+
onClick={() => navigateDiff(1)}
|
|
469
|
+
>
|
|
470
|
+
<NextIcon />
|
|
471
|
+
</button>
|
|
472
|
+
<button
|
|
473
|
+
type="button"
|
|
474
|
+
className="dsh-git-ui__icon-btn"
|
|
475
|
+
style={css.rowIconButton}
|
|
476
|
+
title={t('center.close')}
|
|
477
|
+
aria-label={t('center.close')}
|
|
478
|
+
onClick={() => { diffSeq.current += 1; setDiffSel(null); setDiffText(null) }}
|
|
479
|
+
>
|
|
480
|
+
<CloseIcon />
|
|
481
|
+
</button>
|
|
482
|
+
</div>
|
|
483
|
+
{diffLoading
|
|
484
|
+
? <div style={css.emptyNote}>{t('center.loading')}</div>
|
|
485
|
+
: <DiffSideBySide text={diffText ?? ''} t={t} />}
|
|
486
|
+
</>
|
|
487
|
+
)}
|
|
488
|
+
</div>
|
|
489
|
+
</div>
|
|
232
490
|
)
|
|
233
491
|
}
|
|
234
492
|
|
|
235
|
-
/**
|
|
236
|
-
|
|
237
|
-
|
|
493
|
+
/**
|
|
494
|
+
* IDEA 式分组头:粘性吸顶——组级全选(含半选态)+ 折叠箭头 + 名称 + 计数。
|
|
495
|
+
* 复选框与折叠按钮为独立控件,均可键盘操作。
|
|
496
|
+
*/
|
|
497
|
+
function ChangeGroupHeader({
|
|
498
|
+
label, count, closed, allChecked, someChecked, onToggleClosed, onSelectAll, t,
|
|
238
499
|
}: {
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
500
|
+
label: string
|
|
501
|
+
count: number
|
|
502
|
+
closed: boolean
|
|
503
|
+
allChecked: boolean
|
|
504
|
+
someChecked: boolean
|
|
505
|
+
onToggleClosed: () => void
|
|
506
|
+
onSelectAll: (check: boolean) => void
|
|
507
|
+
t: (key: GitKey) => string
|
|
508
|
+
}): JSX.Element {
|
|
509
|
+
return (
|
|
510
|
+
<div style={css.groupHeader}>
|
|
511
|
+
<input
|
|
512
|
+
type="checkbox"
|
|
513
|
+
style={css.changeCheckbox}
|
|
514
|
+
checked={allChecked}
|
|
515
|
+
ref={(el) => { if (el !== null) el.indeterminate = someChecked && !allChecked }}
|
|
516
|
+
onChange={(e) => onSelectAll(e.target.checked)}
|
|
517
|
+
aria-label={`${label} ${t('changes.selectAll')}`}
|
|
518
|
+
/>
|
|
519
|
+
<button type="button" style={css.groupHeaderToggle} onClick={onToggleClosed} aria-expanded={!closed}>
|
|
520
|
+
<ChevronIcon open={!closed} />
|
|
521
|
+
<span>{label}</span>
|
|
522
|
+
<span style={css.groupHeaderCount}>{count}</span>
|
|
523
|
+
</button>
|
|
524
|
+
</div>
|
|
525
|
+
)
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/**
|
|
529
|
+
* IDEA 式变更行:复选框 + 文件图标 + 状态着色文件名 + 弱化目录 + 行尾状态字母
|
|
530
|
+
* + 悬停操作(对照 / 暂存|取消暂存 / 丢弃)。操作图标仅在悬停或键盘聚焦时显现,
|
|
531
|
+
* 定宽槽位常驻占位,杜绝显现时的布局跳动;点击文件名打开对照(基线由条目暂存侧决定)。
|
|
532
|
+
*
|
|
533
|
+
* 选择按路径归并:混合态双条目共享同一复选框状态——提交以路径为限,
|
|
534
|
+
* 勾选任一侧即整文件入提交,联动为有意设计(与 aria-label 仅标注路径一致)。
|
|
535
|
+
*/
|
|
536
|
+
function ChangeRow({
|
|
537
|
+
change, checked, busy, armed, diffActive, rowActions, onShowDiff, t,
|
|
538
|
+
}: {
|
|
539
|
+
change: GitChange
|
|
540
|
+
checked: boolean
|
|
242
541
|
busy: boolean
|
|
243
|
-
|
|
542
|
+
armed: string | 'all' | null
|
|
543
|
+
diffActive: boolean
|
|
544
|
+
rowActions: {
|
|
545
|
+
onToggle: (path: string) => void
|
|
546
|
+
onStage: (path: string) => void
|
|
547
|
+
onUnstage: (path: string) => void
|
|
548
|
+
onDiscard: (path: string) => void
|
|
549
|
+
}
|
|
550
|
+
onShowDiff: (path: string, base: 'worktree' | 'staged') => void
|
|
551
|
+
t: (key: GitKey) => string
|
|
552
|
+
}): JSX.Element {
|
|
553
|
+
const untracked = change.status === 'untracked'
|
|
554
|
+
const { name, dir, isDir } = splitChangePath(change.path, change.isDirectory)
|
|
555
|
+
const base = diffBaseOf(change)
|
|
556
|
+
const armedHere = armed === change.path
|
|
557
|
+
const statusColor = css.statusTextColor[change.status] ?? 'var(--dsw-alias-label-primary)'
|
|
558
|
+
return (
|
|
559
|
+
<div className="dsh-git-ui__row" style={diffActive ? { ...css.centerRow, ...css.centerRowActive } : css.centerRow}>
|
|
560
|
+
<input
|
|
561
|
+
type="checkbox"
|
|
562
|
+
style={css.changeCheckbox}
|
|
563
|
+
checked={checked}
|
|
564
|
+
disabled={busy}
|
|
565
|
+
onChange={() => rowActions.onToggle(change.path)}
|
|
566
|
+
aria-label={change.path}
|
|
567
|
+
/>
|
|
568
|
+
<span style={css.rowFileIcon} aria-hidden="true">
|
|
569
|
+
{isDir ? <FolderIcon /> : fileIconForPath(change.path)}
|
|
570
|
+
</span>
|
|
571
|
+
<button
|
|
572
|
+
type="button"
|
|
573
|
+
style={isDir ? { ...css.changeName, color: statusColor, cursor: 'default' } : { ...css.changeName, color: statusColor }}
|
|
574
|
+
title={isDir ? `${change.path} (${t('changes.dir')})` : change.path}
|
|
575
|
+
disabled={isDir}
|
|
576
|
+
onClick={() => onShowDiff(change.path, base)}
|
|
577
|
+
>
|
|
578
|
+
{name}
|
|
579
|
+
</button>
|
|
580
|
+
{dir !== '' ? <span style={css.changeDir}>{dir}</span> : <span style={{ flex: 1 }} />}
|
|
581
|
+
<span style={{ ...css.statusLetter, color: statusColor }} aria-hidden="true">
|
|
582
|
+
{CHIP_LETTERS[change.status] ?? '•'}
|
|
583
|
+
</span>
|
|
584
|
+
<span className="dsh-git-ui__row-actions" style={css.rowActions}>
|
|
585
|
+
<button
|
|
586
|
+
type="button"
|
|
587
|
+
className="dsh-git-ui__icon-btn"
|
|
588
|
+
style={css.rowIconButton}
|
|
589
|
+
title={isDir ? t('changes.dir') : t('changes.actionDiff')}
|
|
590
|
+
aria-label={isDir ? t('changes.dir') : t('changes.actionDiff')}
|
|
591
|
+
disabled={busy || isDir}
|
|
592
|
+
onClick={() => onShowDiff(change.path, base)}
|
|
593
|
+
>
|
|
594
|
+
<DiffIcon />
|
|
595
|
+
</button>
|
|
596
|
+
{untracked ? (
|
|
597
|
+
<button
|
|
598
|
+
type="button"
|
|
599
|
+
className="dsh-git-ui__icon-btn"
|
|
600
|
+
style={css.rowIconButton}
|
|
601
|
+
title={t('center.stage')}
|
|
602
|
+
aria-label={t('center.stage')}
|
|
603
|
+
disabled={busy}
|
|
604
|
+
onClick={() => rowActions.onStage(change.path)}
|
|
605
|
+
>
|
|
606
|
+
<StageIcon />
|
|
607
|
+
</button>
|
|
608
|
+
) : change.staged ? (
|
|
609
|
+
<button
|
|
610
|
+
type="button"
|
|
611
|
+
className="dsh-git-ui__icon-btn"
|
|
612
|
+
style={css.rowIconButton}
|
|
613
|
+
title={t('center.unstage')}
|
|
614
|
+
aria-label={t('center.unstage')}
|
|
615
|
+
disabled={busy}
|
|
616
|
+
onClick={() => rowActions.onUnstage(change.path)}
|
|
617
|
+
>
|
|
618
|
+
<UnstageIcon />
|
|
619
|
+
</button>
|
|
620
|
+
) : (
|
|
621
|
+
<>
|
|
622
|
+
<button
|
|
623
|
+
type="button"
|
|
624
|
+
className="dsh-git-ui__icon-btn"
|
|
625
|
+
style={css.rowIconButton}
|
|
626
|
+
title={t('center.stage')}
|
|
627
|
+
aria-label={t('center.stage')}
|
|
628
|
+
disabled={busy}
|
|
629
|
+
onClick={() => rowActions.onStage(change.path)}
|
|
630
|
+
>
|
|
631
|
+
<StageIcon />
|
|
632
|
+
</button>
|
|
633
|
+
<button
|
|
634
|
+
type="button"
|
|
635
|
+
className="dsh-git-ui__icon-btn"
|
|
636
|
+
style={armedHere ? { ...css.rowIconButton, color: 'var(--dsw-alias-state-error-primary)' } : css.rowIconButton}
|
|
637
|
+
title={armedHere ? t('center.confirmDiscard') : t('center.discard')}
|
|
638
|
+
aria-label={armedHere ? t('center.confirmDiscard') : t('center.discard')}
|
|
639
|
+
disabled={busy}
|
|
640
|
+
onClick={() => rowActions.onDiscard(change.path)}
|
|
641
|
+
>
|
|
642
|
+
<RollbackIcon />
|
|
643
|
+
</button>
|
|
644
|
+
</>
|
|
645
|
+
)}
|
|
646
|
+
</span>
|
|
647
|
+
</div>
|
|
648
|
+
)
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
/** 并排差异对照查看器(IDEA 式:左变更前/右变更后,行号 + 状态着色)。
|
|
652
|
+
* 超大差异仅渲染前 MAX_DIFF_ROWS 行,防止万行级 diff 卡死渲染。 */
|
|
653
|
+
const MAX_DIFF_ROWS = 2000
|
|
654
|
+
|
|
655
|
+
function DiffSideBySide({ text, t }: { text: string; t: (key: GitKey) => string }): JSX.Element {
|
|
656
|
+
const rows = useMemo(() => buildSideBySide(text), [text])
|
|
657
|
+
const capped = useMemo(() => capSideBySideRows(rows, MAX_DIFF_ROWS), [rows])
|
|
658
|
+
if (isBinaryDiff(text)) return <div style={css.emptyNote}>{t('diff.binary')}</div>
|
|
659
|
+
if (rows.length === 0) return <div style={css.emptyNote}>{t('center.diffEmpty')}</div>
|
|
660
|
+
|
|
661
|
+
const colorOf = (kind: SideCell['kind']): CSSProperties =>
|
|
662
|
+
kind === 'del' ? css.sbsDel : kind === 'add' ? css.sbsAdd : kind === 'empty' ? css.sbsEmpty : {}
|
|
663
|
+
|
|
664
|
+
const renderCell = (cell: SideCell, key: string): JSX.Element => (
|
|
665
|
+
<div key={key} style={{ ...css.sbsCell, ...colorOf(cell.kind) }}>
|
|
666
|
+
<span style={css.sbsNum}>{cell.num ?? ''}</span>
|
|
667
|
+
<span style={css.sbsCode}>{cell.text}</span>
|
|
668
|
+
</div>
|
|
669
|
+
)
|
|
670
|
+
|
|
671
|
+
// 全量平铺文档(不折叠上下文):每列各渲染一份完整行序列。
|
|
672
|
+
// 双列独立横向滚动 + 容器统一纵向滚动(长文档可上下滚动浏览)。
|
|
673
|
+
const renderColumn = (side: 'left' | 'right'): readonly JSX.Element[] =>
|
|
674
|
+
capped.map((row, i) => renderCell(side === 'left' ? row.left : row.right, String(i)))
|
|
675
|
+
|
|
676
|
+
return (
|
|
677
|
+
<>
|
|
678
|
+
<div style={css.sbsContainer}>
|
|
679
|
+
<div style={css.sbsCol}>
|
|
680
|
+
<div style={css.sbsColInner}>{renderColumn('left')}</div>
|
|
681
|
+
</div>
|
|
682
|
+
<div style={{ ...css.sbsCol, ...css.sbsColRight }}>
|
|
683
|
+
<div style={css.sbsColInner}>{renderColumn('right')}</div>
|
|
684
|
+
</div>
|
|
685
|
+
</div>
|
|
686
|
+
{rows.length > MAX_DIFF_ROWS && (
|
|
687
|
+
<div style={css.emptyNote}>{t('diff.truncated').replace('{count}', String(MAX_DIFF_ROWS))}</div>
|
|
688
|
+
)}
|
|
689
|
+
</>
|
|
690
|
+
)
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
// ── Graph constants ──────────────────────────────────────────────────────
|
|
694
|
+
|
|
695
|
+
/** 每车道理想像素宽(收紧贴近 IDE 密度;超宽图时按 GRAPH_MAX_TRACK_W 压缩)。 */
|
|
696
|
+
const GRAPH_COL_W = 16
|
|
697
|
+
/** 图轨道最大像素宽:超宽分支图压缩车道宽以适配,防线条挤压右侧提交信息。 */
|
|
698
|
+
const GRAPH_MAX_TRACK_W = 192
|
|
699
|
+
/** 车道宽下限(再宽也不小于此,避免线条/节点重叠到不可读)。 */
|
|
700
|
+
const GRAPH_LANE_MIN_W = 8
|
|
701
|
+
/** 节点圆半径。 */
|
|
702
|
+
const GRAPH_NODE_R = 4
|
|
703
|
+
/** 节点圆半径下限(车道压缩时同步缩小)。 */
|
|
704
|
+
const GRAPH_NODE_MIN_R = 2
|
|
705
|
+
|
|
706
|
+
// ── History tab ───────────────────────────────────────────────────────────
|
|
707
|
+
|
|
708
|
+
function HistoryTab({
|
|
709
|
+
query, run, t,
|
|
710
|
+
}: {
|
|
711
|
+
query: GitCenterProps['query']
|
|
712
|
+
run: GitCenterProps['run']
|
|
244
713
|
t: (key: GitKey) => string
|
|
245
714
|
}): JSX.Element {
|
|
715
|
+
const [commits, setCommits] = useState<readonly GraphCommit[]>([])
|
|
716
|
+
const [total, setTotal] = useState(0)
|
|
717
|
+
const [loading, setLoading] = useState(false)
|
|
718
|
+
const [selected, setSelected] = useState<GraphCommit | null>(null)
|
|
719
|
+
const [detail, setDetail] = useState<{ commit: GraphCommit; body: string; stats: readonly GitFileStat[] } | null>(null)
|
|
720
|
+
/** 组合过滤条件(左树 ref + 工具栏搜索/用户/日期);任一变化重载。 */
|
|
721
|
+
const [filter, setFilter] = useState<{ ref: string | null; search: string; author: string; since: string }>({ ref: null, search: '', author: '', since: '' })
|
|
722
|
+
/** 工具栏搜索输入(防抖 300ms 落地到 filter)。 */
|
|
723
|
+
const [searchInput, setSearchInput] = useState('')
|
|
724
|
+
const [authors, setAuthors] = useState<readonly string[]>([])
|
|
725
|
+
const [tree, setTree] = useState<{
|
|
726
|
+
current: string | null
|
|
727
|
+
defaultBranch: string | null
|
|
728
|
+
local: readonly GitBranch[]
|
|
729
|
+
remote: readonly GitBranch[]
|
|
730
|
+
tags: readonly GitBranch[]
|
|
731
|
+
} | null>(null)
|
|
732
|
+
/** 左树折叠的分组:标签默认收起(仓库可能标签很多,一屏铺满不美观),点击展开。 */
|
|
733
|
+
const [closedSections, setClosedSections] = useState<ReadonlySet<string>>(new Set(['tags']))
|
|
734
|
+
/** 文件树折叠的目录路径集合。 */
|
|
735
|
+
const [collapsed, setCollapsed] = useState<ReadonlySet<string>>(new Set())
|
|
736
|
+
/** 三栏可拖拽尺寸:左宽/右宽/右栏上区比例。 */
|
|
737
|
+
const [leftW, setLeftW] = useState(170)
|
|
738
|
+
const [rightW, setRightW] = useState(360)
|
|
739
|
+
const [rightTopPct, setRightTopPct] = useState(58)
|
|
740
|
+
const rightBodyRef = useRef<HTMLDivElement>(null)
|
|
741
|
+
/** now 随提交批次稳定,避免行 memo 因时间戳失效。 */
|
|
742
|
+
const now = useMemo(() => Date.now(), [commits])
|
|
743
|
+
/** 列表滚动容器与单航守卫(无限滚动)。 */
|
|
744
|
+
const listRef = useRef<HTMLDivElement>(null)
|
|
745
|
+
const inflightSkip = useRef<number | null>(null)
|
|
746
|
+
/** 按过滤组合的历史首页缓存(上限 10,切回瞬显,减缓“闪烁”与加载延迟)。 */
|
|
747
|
+
const historyCache = useRef(new Map<string, { commits: readonly GraphCommit[]; total: number }>())
|
|
748
|
+
const cacheKey = (f: { ref: string | null; search: string; author: string; since: string }): string =>
|
|
749
|
+
JSON.stringify([f.ref, f.search, f.author, f.since])
|
|
750
|
+
const writeHistoryCache = (f: { ref: string | null; search: string; author: string; since: string }, commits: readonly GraphCommit[], total: number): void => {
|
|
751
|
+
const cache = historyCache.current
|
|
752
|
+
const key = cacheKey(f)
|
|
753
|
+
cache.delete(key)
|
|
754
|
+
cache.set(key, { commits, total })
|
|
755
|
+
while (cache.size > 10) {
|
|
756
|
+
const first = cache.keys().next().value
|
|
757
|
+
if (first === undefined) break
|
|
758
|
+
cache.delete(first)
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
/**
|
|
763
|
+
* 增量图构建:提交集合只增时仅模拟新增段并追加行,既有行对象引用保持不变
|
|
764
|
+
* (CommitRow memo 命中,避免逐批追加触发全表重渲染);集合整体替换
|
|
765
|
+
* (过滤切换/缓存恢复)时新建 builder 从头构建。
|
|
766
|
+
* 搜索条件下不分析提交关系、不渲染分支图——结果仅是跨引用的匹配条目,
|
|
767
|
+
* 图几何清空,只平铺条目。
|
|
768
|
+
*/
|
|
769
|
+
const searching = filter.search !== ''
|
|
770
|
+
const builderRef = useRef(createGraphBuilder())
|
|
771
|
+
const prevCommitsRef = useRef<readonly GraphCommit[]>([])
|
|
772
|
+
const [graphRows, setGraphRows] = useState<readonly GraphRow[]>([])
|
|
773
|
+
useEffect(() => {
|
|
774
|
+
if (searching) {
|
|
775
|
+
builderRef.current = createGraphBuilder()
|
|
776
|
+
prevCommitsRef.current = commits
|
|
777
|
+
setGraphRows([])
|
|
778
|
+
return
|
|
779
|
+
}
|
|
780
|
+
const prev = prevCommitsRef.current
|
|
781
|
+
const isExtension = prev.length <= commits.length && prev.every((c, i) => c.hash === commits[i]?.hash)
|
|
782
|
+
if (!isExtension) {
|
|
783
|
+
builderRef.current = createGraphBuilder()
|
|
784
|
+
setGraphRows(builderRef.current.append(commits))
|
|
785
|
+
} else if (commits.length > prev.length) {
|
|
786
|
+
const newRows = builderRef.current.append(commits.slice(prev.length))
|
|
787
|
+
if (newRows.length > 0) setGraphRows((existing) => [...existing, ...newRows])
|
|
788
|
+
}
|
|
789
|
+
prevCommitsRef.current = commits
|
|
790
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- 仅随提交集合/搜索态变化喂入 builder
|
|
791
|
+
}, [commits, searching])
|
|
792
|
+
|
|
793
|
+
const graphCols = useMemo(() => graphWidth(graphRows), [graphRows])
|
|
794
|
+
/** 自适应车道宽:图宽超过 GRAPH_MAX_TRACK_W 时压缩车道,保全部车道可见、轨道有界、不挤压主题列。 */
|
|
795
|
+
const laneW = useMemo(() => {
|
|
796
|
+
if (graphCols === 0) return GRAPH_COL_W
|
|
797
|
+
return Math.max(GRAPH_LANE_MIN_W, Math.min(GRAPH_COL_W, GRAPH_MAX_TRACK_W / graphCols))
|
|
798
|
+
}, [graphCols])
|
|
799
|
+
const graphTrack = searching ? 0 : Math.ceil(graphCols * laneW)
|
|
800
|
+
/** 过滤(搜索/作者/日期)生效时,结果集不含部分父节点——延续线永久悬垂,标为端头。 */
|
|
801
|
+
const hasContentFilter = filter.search !== '' || filter.author !== '' || filter.since !== ''
|
|
802
|
+
const loadedHashes = useMemo(() => new Set(commits.map((c) => c.hash)), [commits])
|
|
803
|
+
const graphMarked = useMemo(
|
|
804
|
+
() => markFilterEnds(graphRows, loadedHashes, hasContentFilter),
|
|
805
|
+
[graphRows, loadedHashes, hasContentFilter],
|
|
806
|
+
)
|
|
807
|
+
/** 表格列模板:图 | 提交(refs+主题) | 哈希 | 作者 | 时间;行与表头共用。
|
|
808
|
+
* 主题列 minmax(96px,1fr) 保证宽图/加载回流时内容不被压缩到不可读。
|
|
809
|
+
* 搜索条件下用装饰圆点列替代图列(28px 居中圆点),条目不紧贴左侧。 */
|
|
810
|
+
const gridTpl = searching
|
|
811
|
+
? '28px minmax(96px,1fr) 72px 110px 110px'
|
|
812
|
+
: `${graphTrack}px minmax(96px,1fr) 72px 110px 110px`
|
|
813
|
+
/** 行序列:非搜索=带图几何的行(graphMarked);搜索=无图几何的纯条目行(showGraph=false)。 */
|
|
814
|
+
const listRows = useMemo<readonly GraphRowMarker[]>(
|
|
815
|
+
() => searching
|
|
816
|
+
? commits.map((commit) => ({ commit, column: 0, verticals: [], joins: [], nodeFromTop: false, nodeContinues: false, edges: [] } as GraphRowMarker))
|
|
817
|
+
: graphMarked,
|
|
818
|
+
[searching, commits, graphMarked],
|
|
819
|
+
)
|
|
820
|
+
/** 右栏文件目录树(随选中提交的 stats 重算)。 */
|
|
821
|
+
const fileTree = useMemo(() => (detail === null ? [] : buildFileTree(detail.stats)), [detail])
|
|
822
|
+
|
|
823
|
+
const loadPage = async (skip: number, f: { ref: string | null; search: string; author: string; since: string }): Promise<void> => {
|
|
824
|
+
if (inflightSkip.current !== null) return
|
|
825
|
+
inflightSkip.current = skip
|
|
826
|
+
setLoading(true)
|
|
827
|
+
const outcome = await query({
|
|
828
|
+
kind: 'history',
|
|
829
|
+
limit: HISTORY_PAGE,
|
|
830
|
+
skip,
|
|
831
|
+
...(f.ref !== null ? { ref: f.ref } : {}),
|
|
832
|
+
...(f.search !== '' ? { search: f.search } : {}),
|
|
833
|
+
...(f.author !== '' ? { author: f.author } : {}),
|
|
834
|
+
...(f.since !== '' ? { since: f.since } : {}),
|
|
835
|
+
})
|
|
836
|
+
setLoading(false)
|
|
837
|
+
inflightSkip.current = null
|
|
838
|
+
if (!outcome.ok) return
|
|
839
|
+
if (outcome.value.kind !== 'history') return
|
|
840
|
+
const page = outcome.value.commits
|
|
841
|
+
const next = skip === 0 ? page : [...commits, ...page]
|
|
842
|
+
setCommits(next)
|
|
843
|
+
setTotal(outcome.value.total)
|
|
844
|
+
writeHistoryCache(f, next, outcome.value.total)
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
/** 无限滚动:接近底部 240px 自动加载下一批。 */
|
|
848
|
+
const onScroll = (): void => {
|
|
849
|
+
const el = listRef.current
|
|
850
|
+
if (el === null || loading || commits.length >= total) return
|
|
851
|
+
if (el.scrollTop + el.clientHeight >= el.scrollHeight - 240) void loadPage(commits.length, filter)
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
// 加载过滤树(分支 + 标签 + 作者);首次激活与 fetch 后复用。
|
|
855
|
+
const loadTree = useCallback(async (): Promise<void> => {
|
|
856
|
+
const [branches, tags, authorsOutcome] = await Promise.all([query({ kind: 'branches' }), query({ kind: 'tags' }), query({ kind: 'authors' })])
|
|
857
|
+
setAuthors(authorsOutcome.ok && authorsOutcome.value.kind === 'authors' ? authorsOutcome.value.authors : [])
|
|
858
|
+
setTree({
|
|
859
|
+
current: branches.ok && branches.value.kind === 'branches' ? branches.value.current : null,
|
|
860
|
+
defaultBranch: branches.ok && branches.value.kind === 'branches' ? branches.value.defaultBranch : null,
|
|
861
|
+
local: branches.ok && branches.value.kind === 'branches' ? branches.value.local : [],
|
|
862
|
+
remote: branches.ok && branches.value.kind === 'branches' ? branches.value.remote : [],
|
|
863
|
+
tags: tags.ok && tags.value.kind === 'tags' ? tags.value.tags : [],
|
|
864
|
+
})
|
|
865
|
+
}, [query])
|
|
866
|
+
|
|
867
|
+
/** fetch 远程引用后重载过滤树(刷新 ahead/behind + 远程分支列表)。 */
|
|
868
|
+
const [fetching, setFetching] = useState(false)
|
|
869
|
+
/** fetch 结果提示:成功=已同步远程;失败=错误信息。 */
|
|
870
|
+
const [fetchNote, setFetchNote] = useState<string | null>(null)
|
|
871
|
+
const onFetch = useCallback(async (): Promise<void> => {
|
|
872
|
+
if (fetching) return
|
|
873
|
+
setFetching(true)
|
|
874
|
+
setFetchNote(null)
|
|
875
|
+
const result = await run({ kind: 'fetch' })
|
|
876
|
+
await loadTree()
|
|
877
|
+
setFetching(false)
|
|
878
|
+
setFetchNote(result.ok ? t('center.fetchDone') : result.error.message ?? result.error.code)
|
|
879
|
+
}, [fetching, run, loadTree, t])
|
|
880
|
+
|
|
881
|
+
// 首次激活:加载过滤树。
|
|
882
|
+
useEffect(() => {
|
|
883
|
+
void loadTree()
|
|
884
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- first activation only
|
|
885
|
+
}, [])
|
|
886
|
+
|
|
887
|
+
// 过滤变化:缓存命中瞬显;不清空旧数据,新数据就位后整体替换旧行,避免空白“闪烁”。
|
|
888
|
+
useEffect(() => {
|
|
889
|
+
setSelected(null)
|
|
890
|
+
setDetail(null)
|
|
891
|
+
const cached = historyCache.current.get(cacheKey(filter))
|
|
892
|
+
if (cached !== undefined) {
|
|
893
|
+
setCommits(cached.commits)
|
|
894
|
+
setTotal(cached.total)
|
|
895
|
+
}
|
|
896
|
+
void loadPage(0, filter)
|
|
897
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- filter-driven reload
|
|
898
|
+
}, [filter])
|
|
899
|
+
|
|
900
|
+
// 搜索防抖:停止输入 300ms 后才落地为过滤条件。
|
|
901
|
+
useEffect(() => {
|
|
902
|
+
const timer = setTimeout(() => {
|
|
903
|
+
setFilter((prev) => (prev.search === searchInput ? prev : { ...prev, search: searchInput }))
|
|
904
|
+
}, 300)
|
|
905
|
+
return () => clearTimeout(timer)
|
|
906
|
+
}, [searchInput])
|
|
907
|
+
|
|
908
|
+
const select = useCallback(async (commit: GraphCommit): Promise<void> => {
|
|
909
|
+
setSelected(commit)
|
|
910
|
+
setDetail(null)
|
|
911
|
+
const outcome = await query({ kind: 'show', ref: commit.hash })
|
|
912
|
+
if (outcome.ok && outcome.value.kind === 'show' && outcome.value.commit !== null) {
|
|
913
|
+
setDetail({ commit: outcome.value.commit as GraphCommit, body: outcome.value.body, stats: outcome.value.stats })
|
|
914
|
+
}
|
|
915
|
+
}, [query])
|
|
916
|
+
|
|
917
|
+
const toggleDir = (path: string): void => {
|
|
918
|
+
setCollapsed((prev) => {
|
|
919
|
+
const next = new Set(prev)
|
|
920
|
+
if (next.has(path)) next.delete(path)
|
|
921
|
+
else next.add(path)
|
|
922
|
+
return next
|
|
923
|
+
})
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
const toggleSection = (section: string): void => {
|
|
927
|
+
setClosedSections((prev) => {
|
|
928
|
+
const next = new Set(prev)
|
|
929
|
+
if (next.has(section)) next.delete(section)
|
|
930
|
+
else next.add(section)
|
|
931
|
+
return next
|
|
932
|
+
})
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
/** 右栏头带:收起全部目录。 */
|
|
936
|
+
const collapseAllDirs = (): void => {
|
|
937
|
+
const paths: string[] = []
|
|
938
|
+
const walk = (nodes: readonly FileTreeNode[]): void => {
|
|
939
|
+
for (const n of nodes) {
|
|
940
|
+
if (!n.dir) continue
|
|
941
|
+
paths.push(n.path)
|
|
942
|
+
walk(n.children)
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
walk(fileTree)
|
|
946
|
+
setCollapsed(new Set(paths))
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
return (
|
|
950
|
+
<div style={css.historyLayout}>
|
|
951
|
+
<div style={{ ...css.paneSide, width: leftW, borderRight: '1px solid var(--dsw-alias-border-l2)', borderRadius: '12px 0 0 12px' }}>
|
|
952
|
+
<HistoryFilterTree
|
|
953
|
+
tree={tree}
|
|
954
|
+
filter={filter.ref === null ? { kind: 'all' } : { kind: 'ref', name: filter.ref }}
|
|
955
|
+
onFilter={(f) => setFilter((prev) => ({ ...prev, ref: f.kind === 'all' ? null : f.name }))}
|
|
956
|
+
closed={closedSections}
|
|
957
|
+
onToggleSection={toggleSection}
|
|
958
|
+
onFetch={onFetch}
|
|
959
|
+
fetching={fetching}
|
|
960
|
+
fetchNote={fetchNote}
|
|
961
|
+
t={t}
|
|
962
|
+
/>
|
|
963
|
+
</div>
|
|
964
|
+
<Splitter kind="col" onDrag={(dx) => setLeftW((w) => clampNum(w + dx, 140, 320))} />
|
|
965
|
+
<div style={css.historyColumn}>
|
|
966
|
+
<div style={css.historyToolbar}>
|
|
967
|
+
<input
|
|
968
|
+
className="dsh-git-ui__branch-input"
|
|
969
|
+
style={css.toolbarSearch}
|
|
970
|
+
placeholder={t('history.search')}
|
|
971
|
+
value={searchInput}
|
|
972
|
+
onChange={(e) => setSearchInput(e.target.value)}
|
|
973
|
+
aria-label={t('history.search')}
|
|
974
|
+
/>
|
|
975
|
+
<SelectMenu
|
|
976
|
+
ariaLabel={t('history.branch')}
|
|
977
|
+
value={filter.ref ?? ''}
|
|
978
|
+
options={[
|
|
979
|
+
{ value: '', label: t('history.allBranches') },
|
|
980
|
+
...(tree?.local.map((b) => ({ value: b.name, label: b.name })) ?? []),
|
|
981
|
+
...(tree?.remote.map((b) => ({ value: b.name, label: b.name })) ?? []),
|
|
982
|
+
]}
|
|
983
|
+
onSelect={(value) => setFilter((prev) => ({ ...prev, ref: value === '' ? null : value }))}
|
|
984
|
+
/>
|
|
985
|
+
<SelectMenu
|
|
986
|
+
ariaLabel={t('history.allUsers')}
|
|
987
|
+
value={filter.author}
|
|
988
|
+
options={[
|
|
989
|
+
{ value: '', label: t('history.allUsers') },
|
|
990
|
+
...authors.map((name) => ({ value: name, label: name })),
|
|
991
|
+
]}
|
|
992
|
+
onSelect={(value) => setFilter((prev) => ({ ...prev, author: value }))}
|
|
993
|
+
/>
|
|
994
|
+
<SelectMenu
|
|
995
|
+
ariaLabel={t('history.allTime')}
|
|
996
|
+
value={filter.since}
|
|
997
|
+
options={[
|
|
998
|
+
{ value: '', label: t('history.allTime') },
|
|
999
|
+
{ value: '1 day ago', label: t('history.today') },
|
|
1000
|
+
{ value: '7 days ago', label: t('history.last7d') },
|
|
1001
|
+
{ value: '30 days ago', label: t('history.last30d') },
|
|
1002
|
+
{ value: '90 days ago', label: t('history.last90d') },
|
|
1003
|
+
]}
|
|
1004
|
+
onSelect={(value) => setFilter((prev) => ({ ...prev, since: value }))}
|
|
1005
|
+
/>
|
|
1006
|
+
</div>
|
|
1007
|
+
<div
|
|
1008
|
+
style={{
|
|
1009
|
+
...css.historyList,
|
|
1010
|
+
opacity: loading && commits.length > 0 ? 0.55 : 1,
|
|
1011
|
+
transition: 'opacity var(--ds-transition-duration) var(--ds-ease-in-out)',
|
|
1012
|
+
}}
|
|
1013
|
+
ref={listRef}
|
|
1014
|
+
onScroll={onScroll}
|
|
1015
|
+
>
|
|
1016
|
+
{loading && commits.length === 0 && (
|
|
1017
|
+
<div style={css.centeredEmpty}>{t('center.loading')}</div>
|
|
1018
|
+
)}
|
|
1019
|
+
{!loading && commits.length === 0 && (
|
|
1020
|
+
<div style={css.centeredEmpty}>{t('history.noResults')}</div>
|
|
1021
|
+
)}
|
|
1022
|
+
{commits.length > 0 && (
|
|
1023
|
+
<div style={{ ...css.historyHead, gridTemplateColumns: gridTpl }} aria-hidden="true">
|
|
1024
|
+
<span />
|
|
1025
|
+
<span>{t('history.commit')}</span>
|
|
1026
|
+
<span>{t('history.hash')}</span>
|
|
1027
|
+
<span>{t('history.author')}</span>
|
|
1028
|
+
<span>{t('history.time')}</span>
|
|
1029
|
+
</div>
|
|
1030
|
+
)}
|
|
1031
|
+
{listRows.map((row) => (
|
|
1032
|
+
<CommitRow
|
|
1033
|
+
key={row.commit.hash}
|
|
1034
|
+
row={row}
|
|
1035
|
+
cols={graphCols}
|
|
1036
|
+
laneW={laneW}
|
|
1037
|
+
gridTpl={gridTpl}
|
|
1038
|
+
isSelected={selected?.hash === row.commit.hash}
|
|
1039
|
+
now={now}
|
|
1040
|
+
onSelect={select}
|
|
1041
|
+
showGraph={!searching}
|
|
1042
|
+
t={t}
|
|
1043
|
+
/>
|
|
1044
|
+
))}
|
|
1045
|
+
{commits.length < total && (
|
|
1046
|
+
<div style={css.loadSentinel}>{loading ? t('center.loading') : ''}</div>
|
|
1047
|
+
)}
|
|
1048
|
+
</div>
|
|
1049
|
+
</div>
|
|
1050
|
+
<Splitter kind="col" onDrag={(dx) => setRightW((w) => clampNum(w - dx, 260, 560))} />
|
|
1051
|
+
<div style={{ ...css.paneSide, width: rightW, borderLeft: '1px solid var(--dsw-alias-border-l2)', borderRadius: '0 12px 12px 0' }}>
|
|
1052
|
+
<div style={css.paneHead}>
|
|
1053
|
+
<span style={css.commitHint}>{t('right.files')}</span>
|
|
1054
|
+
<span style={{ flex: 1 }} />
|
|
1055
|
+
<button
|
|
1056
|
+
type="button"
|
|
1057
|
+
style={css.paneHeadButton}
|
|
1058
|
+
className="dsh-git-ui__refresh"
|
|
1059
|
+
aria-label={t('right.expandAll')}
|
|
1060
|
+
title={t('right.expandAll')}
|
|
1061
|
+
onClick={() => setCollapsed(new Set())}
|
|
1062
|
+
>
|
|
1063
|
+
<ExpandAllIcon />
|
|
1064
|
+
</button>
|
|
1065
|
+
<button
|
|
1066
|
+
type="button"
|
|
1067
|
+
style={css.paneHeadButton}
|
|
1068
|
+
className="dsh-git-ui__refresh"
|
|
1069
|
+
aria-label={t('right.collapseAll')}
|
|
1070
|
+
title={t('right.collapseAll')}
|
|
1071
|
+
onClick={collapseAllDirs}
|
|
1072
|
+
>
|
|
1073
|
+
<CollapseAllIcon />
|
|
1074
|
+
</button>
|
|
1075
|
+
</div>
|
|
1076
|
+
<div style={css.historyRight} ref={rightBodyRef}>
|
|
1077
|
+
{selected === null
|
|
1078
|
+
? (
|
|
1079
|
+
<>
|
|
1080
|
+
<div style={css.rightEmptyZone}>{t('right.selectCommit')}</div>
|
|
1081
|
+
<div style={{ ...css.rightEmptyZone, ...css.rightEmptyZoneBottom }}>{t('right.commitDetails')}</div>
|
|
1082
|
+
</>
|
|
1083
|
+
)
|
|
1084
|
+
: (
|
|
1085
|
+
<>
|
|
1086
|
+
<div style={{ ...css.rightFiles, flex: 'none', height: `${rightTopPct}%` }}>
|
|
1087
|
+
{detail === null
|
|
1088
|
+
? <div style={css.centeredEmpty}>{t('center.loading')}</div>
|
|
1089
|
+
: detail.stats.length === 0
|
|
1090
|
+
? <div style={css.centeredEmpty}>{t('center.diffEmpty')}</div>
|
|
1091
|
+
: (
|
|
1092
|
+
<FileTreeNodes
|
|
1093
|
+
nodes={fileTree}
|
|
1094
|
+
collapsed={collapsed}
|
|
1095
|
+
onToggle={toggleDir}
|
|
1096
|
+
/>
|
|
1097
|
+
)}
|
|
1098
|
+
</div>
|
|
1099
|
+
<Splitter
|
|
1100
|
+
kind="row"
|
|
1101
|
+
onDrag={(dy) => {
|
|
1102
|
+
const h = rightBodyRef.current?.clientHeight ?? 1
|
|
1103
|
+
setRightTopPct((p) => clampNum(p + (dy / h) * 100, 25, 75))
|
|
1104
|
+
}}
|
|
1105
|
+
/>
|
|
1106
|
+
<div style={css.rightMsg}>
|
|
1107
|
+
<div style={css.commitDetailHeader}>
|
|
1108
|
+
<span style={css.commitDetailSubject}>{selected.subject}</span>
|
|
1109
|
+
<span style={css.commitDetailMeta}>
|
|
1110
|
+
{selected.shortHash} · {selected.author} · {timeAgo(selected.dateIso, now, t)}
|
|
1111
|
+
</span>
|
|
1112
|
+
</div>
|
|
1113
|
+
{detail !== null && detail.body !== ''
|
|
1114
|
+
? <pre style={css.msgBody}>{detail.body}</pre>
|
|
1115
|
+
: <div style={css.centeredEmpty}>{t('right.noMessage')}</div>}
|
|
1116
|
+
</div>
|
|
1117
|
+
</>
|
|
1118
|
+
)}
|
|
1119
|
+
</div>
|
|
1120
|
+
</div>
|
|
1121
|
+
</div>
|
|
1122
|
+
)
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
// ── 左栏过滤树与右栏文件树 ─────────────────────────────────────────────
|
|
1126
|
+
|
|
1127
|
+
/** 左栏:全部分支入口 + 本地/远程/标签可折叠分组,点击过滤历史。
|
|
1128
|
+
* 图标语义(IDEA 式):默认分支=星形、当前检出=橙色签出标、普通=灰色分支、标签=标签形。 */
|
|
1129
|
+
function HistoryFilterTree({
|
|
1130
|
+
tree, filter, onFilter, closed, onToggleSection, onFetch, fetching, fetchNote, t,
|
|
1131
|
+
}: {
|
|
1132
|
+
tree: {
|
|
1133
|
+
current: string | null
|
|
1134
|
+
defaultBranch: string | null
|
|
1135
|
+
local: readonly GitBranch[]
|
|
1136
|
+
remote: readonly GitBranch[]
|
|
1137
|
+
tags: readonly GitBranch[]
|
|
1138
|
+
} | null
|
|
1139
|
+
filter: { kind: 'all' } | { kind: 'ref'; name: string }
|
|
1140
|
+
onFilter: (filter: { kind: 'all' } | { kind: 'ref'; name: string }) => void
|
|
1141
|
+
closed: ReadonlySet<string>
|
|
1142
|
+
onToggleSection: (section: string) => void
|
|
1143
|
+
onFetch: () => Promise<void>
|
|
1144
|
+
fetching: boolean
|
|
1145
|
+
fetchNote: string | null
|
|
1146
|
+
t: (key: GitKey) => string
|
|
1147
|
+
}): JSX.Element {
|
|
1148
|
+
/** 搜索(分支或标签):匹配行高亮,搜索时平铺展示并忽略折叠态。 */
|
|
1149
|
+
const [search, setSearch] = useState('')
|
|
1150
|
+
const q = search.trim().toLowerCase()
|
|
1151
|
+
const searching = q !== ''
|
|
1152
|
+
const matches = (name: string): boolean => !searching || name.toLowerCase().includes(q)
|
|
1153
|
+
const highlight = (name: string): JSX.Element | string => {
|
|
1154
|
+
if (!searching) return name
|
|
1155
|
+
const idx = name.toLowerCase().indexOf(q)
|
|
1156
|
+
if (idx === -1) return name
|
|
1157
|
+
return (
|
|
1158
|
+
<>
|
|
1159
|
+
{name.slice(0, idx)}
|
|
1160
|
+
<span style={css.treeMatch}>{name.slice(idx, idx + q.length)}</span>
|
|
1161
|
+
{name.slice(idx + q.length)}
|
|
1162
|
+
</>
|
|
1163
|
+
)
|
|
1164
|
+
}
|
|
1165
|
+
const amber = 'var(--dsw-alias-state-warn-primary)'
|
|
1166
|
+
/** 分支图标与着色:当前检出 > 默认分支 > 普通。 */
|
|
1167
|
+
const branchFace = (name: string, bare: string): { icon: JSX.Element; color?: string } => {
|
|
1168
|
+
if (tree !== null && name === tree.current) return { icon: <TagIcon />, color: amber }
|
|
1169
|
+
if (tree !== null && tree.defaultBranch !== null && bare === tree.defaultBranch) return { icon: <StarIcon />, color: amber }
|
|
1170
|
+
return { icon: <BranchIcon /> }
|
|
1171
|
+
}
|
|
1172
|
+
const row = (name: string, bare: string, active: boolean, mark: boolean, indent: number, branch?: GitBranch): JSX.Element => {
|
|
1173
|
+
const face = branchFace(name, bare)
|
|
1174
|
+
const hasSync = branch !== undefined && ((branch.ahead ?? 0) > 0 || (branch.behind ?? 0) > 0)
|
|
1175
|
+
return (
|
|
1176
|
+
<button
|
|
1177
|
+
type="button"
|
|
1178
|
+
className="dsh-git-ui__row"
|
|
1179
|
+
style={{ ...(active ? { ...css.treeRow, ...css.treeRowActive } : css.treeRow), paddingLeft: indent }}
|
|
1180
|
+
onClick={() => onFilter({ kind: 'ref', name })}
|
|
1181
|
+
title={name}
|
|
1182
|
+
>
|
|
1183
|
+
<span style={face.color === undefined ? css.treeIcon : { ...css.treeIcon, color: face.color }} aria-hidden="true">{face.icon}</span>
|
|
1184
|
+
<span style={mark ? { ...css.treeName, ...css.treeNameCurrent } : css.treeName}>{highlight(name)}</span>
|
|
1185
|
+
{hasSync && (
|
|
1186
|
+
<span style={css.treeSyncBadge}>
|
|
1187
|
+
{(branch!.ahead ?? 0) > 0 && `↑${branch!.ahead}`}
|
|
1188
|
+
{(branch!.ahead ?? 0) > 0 && (branch!.behind ?? 0) > 0 && ' '}
|
|
1189
|
+
{(branch!.behind ?? 0) > 0 && `↓${branch!.behind}`}
|
|
1190
|
+
</span>
|
|
1191
|
+
)}
|
|
1192
|
+
{mark && <span style={css.branchMark}>✓</span>}
|
|
1193
|
+
</button>
|
|
1194
|
+
)
|
|
1195
|
+
}
|
|
1196
|
+
const tagRow = (name: string): JSX.Element => (
|
|
1197
|
+
<button
|
|
1198
|
+
key={`t-${name}`}
|
|
1199
|
+
type="button"
|
|
1200
|
+
className="dsh-git-ui__row"
|
|
1201
|
+
style={{ ...(filter.kind === 'ref' && filter.name === name ? { ...css.treeRow, ...css.treeRowActive } : css.treeRow), paddingLeft: 24 }}
|
|
1202
|
+
onClick={() => onFilter({ kind: 'ref', name })}
|
|
1203
|
+
title={name}
|
|
1204
|
+
>
|
|
1205
|
+
<span style={css.treeIcon} aria-hidden="true"><TagIcon /></span>
|
|
1206
|
+
<span style={css.treeName}>{highlight(name)}</span>
|
|
1207
|
+
</button>
|
|
1208
|
+
)
|
|
1209
|
+
const sectionHead = (key: string, label: string): JSX.Element => (
|
|
1210
|
+
<button type="button" style={css.treeSectionHead} onClick={() => onToggleSection(key)} aria-expanded={!closed.has(key)}>
|
|
1211
|
+
<ChevronIcon open={!closed.has(key)} />
|
|
1212
|
+
<span>{label}</span>
|
|
1213
|
+
</button>
|
|
1214
|
+
)
|
|
1215
|
+
// 远程按远程名分组为文件夹节点(IDEA 式 origin 文件夹)。
|
|
1216
|
+
const remoteGroups: Array<[string, readonly GitBranch[]]> = []
|
|
1217
|
+
if (tree !== null) {
|
|
1218
|
+
const map = new Map<string, GitBranch[]>()
|
|
1219
|
+
for (const b of tree.remote) {
|
|
1220
|
+
const slash = b.name.indexOf('/')
|
|
1221
|
+
const remoteName = slash === -1 ? b.name : b.name.slice(0, slash)
|
|
1222
|
+
const list = map.get(remoteName)
|
|
1223
|
+
if (list === undefined) map.set(remoteName, [b])
|
|
1224
|
+
else list.push(b)
|
|
1225
|
+
}
|
|
1226
|
+
remoteGroups.push(...map.entries())
|
|
1227
|
+
}
|
|
1228
|
+
const bareOf = (name: string): string => name.slice(name.indexOf('/') + 1)
|
|
246
1229
|
return (
|
|
247
1230
|
<>
|
|
248
|
-
<div style={css.
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
t={t}
|
|
1231
|
+
<div style={css.paneHead}>
|
|
1232
|
+
<input
|
|
1233
|
+
className="dsh-git-ui__branch-input"
|
|
1234
|
+
style={css.treeSearch}
|
|
1235
|
+
placeholder={t('history.searchTree')}
|
|
1236
|
+
value={search}
|
|
1237
|
+
onChange={(e) => setSearch(e.target.value)}
|
|
1238
|
+
aria-label={t('history.searchTree')}
|
|
257
1239
|
/>
|
|
1240
|
+
<button
|
|
1241
|
+
type="button"
|
|
1242
|
+
className="dsh-git-ui__refresh"
|
|
1243
|
+
style={css.treeFetchBtn}
|
|
1244
|
+
onClick={() => void onFetch()}
|
|
1245
|
+
disabled={fetching}
|
|
1246
|
+
aria-label={t('center.fetch')}
|
|
1247
|
+
title={t('center.fetch')}
|
|
1248
|
+
>
|
|
1249
|
+
{fetching ? t('center.fetching') : t('center.fetch')}
|
|
1250
|
+
</button>
|
|
1251
|
+
</div>
|
|
1252
|
+
{fetchNote !== null && <div style={css.treeFetchNote}>{fetchNote}</div>}
|
|
1253
|
+
<div style={css.historyTree}>
|
|
1254
|
+
<button
|
|
1255
|
+
type="button"
|
|
1256
|
+
className="dsh-git-ui__row"
|
|
1257
|
+
style={filter.kind === 'all' ? { ...css.treeRow, ...css.treeRowActive } : css.treeRow}
|
|
1258
|
+
onClick={() => onFilter({ kind: 'all' })}
|
|
1259
|
+
>
|
|
1260
|
+
<span style={css.treeIcon} aria-hidden="true"><BranchIcon /></span>
|
|
1261
|
+
<span style={css.treeName}>{t('history.allBranches')}</span>
|
|
1262
|
+
</button>
|
|
1263
|
+
{tree !== null && (searching ? (
|
|
1264
|
+
// 搜索态:匹配行平铺(本地→远程→标签),忽略折叠。
|
|
1265
|
+
<>
|
|
1266
|
+
{tree.local.filter((b) => matches(b.name)).map((b) => row(b.name, b.name, filter.kind === 'ref' && filter.name === b.name, b.name === tree.current, 24, b))}
|
|
1267
|
+
{tree.remote.filter((b) => matches(b.name)).map((b) => row(b.name, bareOf(b.name), filter.kind === 'ref' && filter.name === b.name, false, 24))}
|
|
1268
|
+
{tree.tags.filter((b) => matches(b.name)).map((b) => tagRow(b.name))}
|
|
1269
|
+
</>
|
|
1270
|
+
) : (
|
|
1271
|
+
<>
|
|
1272
|
+
{sectionHead('local', t('center.localBranches'))}
|
|
1273
|
+
{!closed.has('local') && tree.local.map((b) => row(b.name, b.name, filter.kind === 'ref' && filter.name === b.name, b.name === tree.current, 24, b))}
|
|
1274
|
+
{tree.remote.length > 0 && sectionHead('remote', t('center.remoteBranches'))}
|
|
1275
|
+
{!closed.has('remote') && remoteGroups.map(([remoteName, branches]) => (
|
|
1276
|
+
<div key={`g-${remoteName}`}>
|
|
1277
|
+
<button
|
|
1278
|
+
type="button"
|
|
1279
|
+
className="dsh-git-ui__row"
|
|
1280
|
+
style={{ ...css.treeRow, paddingLeft: 24 }}
|
|
1281
|
+
onClick={() => onToggleSection(`remote:${remoteName}`)}
|
|
1282
|
+
aria-expanded={!closed.has(`remote:${remoteName}`)}
|
|
1283
|
+
>
|
|
1284
|
+
<span style={css.treeCaret}><ChevronIcon open={!closed.has(`remote:${remoteName}`)} /></span>
|
|
1285
|
+
<span style={css.treeFolderIcon}><FolderIcon /></span>
|
|
1286
|
+
<span style={css.treeName}>{remoteName}</span>
|
|
1287
|
+
</button>
|
|
1288
|
+
{!closed.has(`remote:${remoteName}`) && branches.map((b) => row(b.name, bareOf(b.name), filter.kind === 'ref' && filter.name === b.name, false, 44))}
|
|
1289
|
+
</div>
|
|
1290
|
+
))}
|
|
1291
|
+
{tree.tags.length > 0 && sectionHead('tags', t('history.tags'))}
|
|
1292
|
+
{!closed.has('tags') && tree.tags.map((b) => tagRow(b.name))}
|
|
1293
|
+
</>
|
|
1294
|
+
))}
|
|
1295
|
+
</div>
|
|
1296
|
+
</>
|
|
1297
|
+
)
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
/** 右栏文件目录树:引导线缩进、文件夹/文件图标、目录文件计数、可折叠。
|
|
1301
|
+
* 文件仅展示变更清单(按状态着色),点击查看差异已按定位移除。 */
|
|
1302
|
+
function FileTreeNodes({
|
|
1303
|
+
nodes, collapsed, onToggle,
|
|
1304
|
+
}: {
|
|
1305
|
+
nodes: readonly FileTreeNode[]
|
|
1306
|
+
collapsed: ReadonlySet<string>
|
|
1307
|
+
onToggle: (path: string) => void
|
|
1308
|
+
}): JSX.Element {
|
|
1309
|
+
return (
|
|
1310
|
+
<>
|
|
1311
|
+
{nodes.map((node) => node.dir ? (
|
|
1312
|
+
<div key={node.path}>
|
|
1313
|
+
<button
|
|
1314
|
+
type="button"
|
|
1315
|
+
className="dsh-git-ui__row"
|
|
1316
|
+
style={css.treeRow}
|
|
1317
|
+
onClick={() => onToggle(node.path)}
|
|
1318
|
+
aria-expanded={!collapsed.has(node.path)}
|
|
1319
|
+
>
|
|
1320
|
+
<span style={css.treeCaret}><ChevronIcon open={!collapsed.has(node.path)} /></span>
|
|
1321
|
+
<span style={css.treeFolderIcon}><FolderIcon /></span>
|
|
1322
|
+
<span style={css.treeName}>{node.name}</span>
|
|
1323
|
+
</button>
|
|
1324
|
+
{!collapsed.has(node.path) && (
|
|
1325
|
+
<div style={css.treeChildren}>
|
|
1326
|
+
<FileTreeNodes
|
|
1327
|
+
nodes={node.children}
|
|
1328
|
+
collapsed={collapsed}
|
|
1329
|
+
onToggle={onToggle}
|
|
1330
|
+
/>
|
|
1331
|
+
</div>
|
|
1332
|
+
)}
|
|
1333
|
+
</div>
|
|
1334
|
+
) : (
|
|
1335
|
+
<div key={node.path} className="dsh-git-ui__row" style={css.treeRow}>
|
|
1336
|
+
<span style={{ ...css.treeCaret, visibility: 'hidden' }} aria-hidden="true"><ChevronIcon open={false} /></span>
|
|
1337
|
+
<span style={{ ...css.treeFolderIcon, color: css.statusTextColor[node.status ?? 'modified'] }}><FileIcon /></span>
|
|
1338
|
+
<span style={{ ...css.treeName, color: css.statusTextColor[node.status ?? 'modified'] }} title={node.path}>{node.name}</span>
|
|
1339
|
+
</div>
|
|
258
1340
|
))}
|
|
259
1341
|
</>
|
|
260
1342
|
)
|
|
261
1343
|
}
|
|
1344
|
+
|
|
1345
|
+
/** 数值夹取。 */
|
|
1346
|
+
function clampNum(v: number, lo: number, hi: number): number {
|
|
1347
|
+
return Math.min(hi, Math.max(lo, v))
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
/** 拖拽分割条:col/row 两向;拖动期间 window mousemove 累加 delta。 */
|
|
1351
|
+
function Splitter({ kind, onDrag }: { kind: 'col' | 'row'; onDrag: (delta: number) => void }): JSX.Element {
|
|
1352
|
+
const onMouseDown = (e: ReactMouseEvent): void => {
|
|
1353
|
+
e.preventDefault()
|
|
1354
|
+
let lastX = e.clientX
|
|
1355
|
+
let lastY = e.clientY
|
|
1356
|
+
const move = (ev: MouseEvent): void => {
|
|
1357
|
+
onDrag(kind === 'col' ? ev.clientX - lastX : ev.clientY - lastY)
|
|
1358
|
+
lastX = ev.clientX
|
|
1359
|
+
lastY = ev.clientY
|
|
1360
|
+
}
|
|
1361
|
+
const up = (): void => {
|
|
1362
|
+
window.removeEventListener('mousemove', move)
|
|
1363
|
+
window.removeEventListener('mouseup', up)
|
|
1364
|
+
}
|
|
1365
|
+
window.addEventListener('mousemove', move)
|
|
1366
|
+
window.addEventListener('mouseup', up)
|
|
1367
|
+
}
|
|
1368
|
+
return (
|
|
1369
|
+
<div
|
|
1370
|
+
className="dsh-git-ui__splitter"
|
|
1371
|
+
style={kind === 'col' ? css.splitter : css.splitterRow}
|
|
1372
|
+
role="separator"
|
|
1373
|
+
aria-orientation={kind === 'col' ? 'vertical' : 'horizontal'}
|
|
1374
|
+
onMouseDown={onMouseDown}
|
|
1375
|
+
/>
|
|
1376
|
+
)
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
// ── 提交行(memo)与自绘下拉 ────────────────────────────────────────
|
|
1380
|
+
|
|
1381
|
+
/** 搜索条目装饰圆点取色:按提交 hash 字符码累加取模,稳定多彩(与分支图同一调色板)。 */
|
|
1382
|
+
function dotColorOf(hash: string): string {
|
|
1383
|
+
let sum = 0
|
|
1384
|
+
for (let i = 0; i < hash.length; i += 1) sum += hash.charCodeAt(i)
|
|
1385
|
+
return GRAPH_COLORS[sum % GRAPH_COLORS.length]!
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
/** 提交行:memo 化保证千条级加载下过滤/选中变更仅重渲染受影响行。 */
|
|
1389
|
+
const CommitRow = memo(function CommitRow({
|
|
1390
|
+
row, cols, laneW, gridTpl, isSelected, now, onSelect, showGraph, t,
|
|
1391
|
+
}: {
|
|
1392
|
+
row: GraphRowMarker
|
|
1393
|
+
cols: number
|
|
1394
|
+
laneW: number
|
|
1395
|
+
gridTpl: string
|
|
1396
|
+
isSelected: boolean
|
|
1397
|
+
now: number
|
|
1398
|
+
onSelect: (commit: GraphCommit) => void
|
|
1399
|
+
showGraph: boolean
|
|
1400
|
+
t: (key: GitKey) => string
|
|
1401
|
+
}): JSX.Element {
|
|
1402
|
+
return (
|
|
1403
|
+
<button
|
|
1404
|
+
type="button"
|
|
1405
|
+
className="dsh-git-ui__commit-row"
|
|
1406
|
+
style={{
|
|
1407
|
+
...(isSelected ? { ...css.historyRow, ...css.historyRowSelected } : css.historyRow),
|
|
1408
|
+
gridTemplateColumns: gridTpl,
|
|
1409
|
+
}}
|
|
1410
|
+
onClick={() => onSelect(row.commit)}
|
|
1411
|
+
>
|
|
1412
|
+
{showGraph ? (
|
|
1413
|
+
<GraphStrip row={row} cols={cols} laneW={laneW} endOpen={row.endOpen} />
|
|
1414
|
+
) : (
|
|
1415
|
+
<span style={css.searchDot} aria-hidden="true">
|
|
1416
|
+
<span style={{ ...css.searchDotInner, background: dotColorOf(row.commit.hash) }} />
|
|
1417
|
+
</span>
|
|
1418
|
+
)}
|
|
1419
|
+
<span style={css.historySubjectCell}>
|
|
1420
|
+
<RefPills refs={row.commit.refs} />
|
|
1421
|
+
<span style={css.commitSubjectLine} title={row.commit.subject}>{row.commit.subject}</span>
|
|
1422
|
+
</span>
|
|
1423
|
+
<span style={css.historyHash} title={row.commit.hash}>{row.commit.shortHash}</span>
|
|
1424
|
+
<span style={css.historyAuthor} title={row.commit.author}>{row.commit.author}</span>
|
|
1425
|
+
<span style={css.historyTime}>{timeAgo(row.commit.dateIso, now, t)}</span>
|
|
1426
|
+
</button>
|
|
1427
|
+
)
|
|
1428
|
+
})
|
|
1429
|
+
|
|
1430
|
+
|
|
1431
|
+
|
|
1432
|
+
// ── refs 胶囊 ───────────────────────────────────────────────────────────
|
|
1433
|
+
|
|
1434
|
+
/**
|
|
1435
|
+
* 提交行内的分支/标签胶囊(IDEA 风格):当前分支成功色、
|
|
1436
|
+
* 本地分支中性、远程弱化、标签警示色。最多展示 3 个,其余折叠为 +n。
|
|
1437
|
+
*/
|
|
1438
|
+
function RefPills({ refs }: { refs: readonly GitRef[] }): JSX.Element | null {
|
|
1439
|
+
if (refs.length === 0) return null
|
|
1440
|
+
const shown = refs.slice(0, 3)
|
|
1441
|
+
const rest = refs.length - shown.length
|
|
1442
|
+
const variant = (ref: GitRef): CSSProperties => {
|
|
1443
|
+
if (ref.head) return css.refPillHead
|
|
1444
|
+
switch (ref.kind) {
|
|
1445
|
+
case 'tag': return css.refPillTag
|
|
1446
|
+
case 'remote': return css.refPillRemote
|
|
1447
|
+
default: return css.refPillBranch
|
|
1448
|
+
}
|
|
1449
|
+
}
|
|
1450
|
+
return (
|
|
1451
|
+
<span style={{ display: 'inline-flex', gap: 4, flex: 'none', minWidth: 0 }} title={refs.map((r) => r.name).join(', ')}>
|
|
1452
|
+
{shown.map((ref) => (
|
|
1453
|
+
<span key={`${ref.kind}-${ref.name}`} style={{ ...css.refPill, ...variant(ref) }}>
|
|
1454
|
+
{ref.name}
|
|
1455
|
+
</span>
|
|
1456
|
+
))}
|
|
1457
|
+
{rest > 0 && <span style={{ ...css.refPill, ...css.refPillRemote }}>+{rest}</span>}
|
|
1458
|
+
</span>
|
|
1459
|
+
)
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1462
|
+
// ── SVG graph strip ────────────────────────────────────────────────────────
|
|
1463
|
+
|
|
1464
|
+
/**
|
|
1465
|
+
* 一行的分支图:条带高度 = HISTORY_ROW_H(与行高同一常量),行间线条连续。
|
|
1466
|
+
* 竖线贯穿活跃车道;节点车道按 nodeFromTop / nodeContinues 画上下半段;
|
|
1467
|
+
* 分叉经 joins 水平连接汇入节点;merge 分裂为贝塞尔曲线(节点→行底)。
|
|
1468
|
+
* 宽度 = 全图车道数 × laneW(自适应车道宽,超宽图压缩以适配有界轨道、
|
|
1469
|
+
* 不挤压右侧提交信息)。
|
|
1470
|
+
*/
|
|
1471
|
+
function GraphStrip({ row, cols, laneW, endOpen }: { row: GraphRow; cols: number; laneW: number; endOpen?: boolean }): JSX.Element {
|
|
1472
|
+
const w = Math.max(cols, 1) * laneW
|
|
1473
|
+
const h = css.HISTORY_ROW_H
|
|
1474
|
+
const x = (col: number): number => col * laneW + laneW / 2
|
|
1475
|
+
const cy = h / 2
|
|
1476
|
+
const nodeR = Math.max(GRAPH_NODE_MIN_R, Math.min(GRAPH_NODE_R, laneW / 3))
|
|
1477
|
+
const color = (col: number): string => GRAPH_COLORS[col % GRAPH_COLORS.length]!
|
|
1478
|
+
return (
|
|
1479
|
+
<svg width={w} height={h} style={{ display: 'block', flexShrink: 0 }} aria-hidden="true">
|
|
1480
|
+
{row.verticals.map((col) => (
|
|
1481
|
+
<line key={`v-${col}`} x1={x(col)} y1={0} x2={x(col)} y2={h} stroke={color(col)} strokeWidth={1.5} strokeLinecap="round" />
|
|
1482
|
+
))}
|
|
1483
|
+
{row.nodeFromTop && (
|
|
1484
|
+
<line x1={x(row.column)} y1={0} x2={x(row.column)} y2={cy} stroke={color(row.column)} strokeWidth={1.5} strokeLinecap="round" />
|
|
1485
|
+
)}
|
|
1486
|
+
{row.joins.map((join) => (
|
|
1487
|
+
<g key={`j-${join}`}>
|
|
1488
|
+
{/* 汇聚车道:自上方竖线到节点高度,再水平连接线汇入节点(锚定父节点行)。 */}
|
|
1489
|
+
<line x1={x(join)} y1={0} x2={x(join)} y2={cy} stroke={color(join)} strokeWidth={1.5} strokeLinecap="round" />
|
|
1490
|
+
<line x1={x(join)} y1={cy} x2={x(row.column)} y2={cy} stroke={color(join)} strokeWidth={1.5} strokeLinecap="round" />
|
|
1491
|
+
</g>
|
|
1492
|
+
))}
|
|
1493
|
+
{row.nodeContinues && (endOpen === true ? (
|
|
1494
|
+
<>
|
|
1495
|
+
{/* 悬垂端头:父提交不在已加载集合(被过滤/边界),虚线 + 端止横杠,诚实提示上游未载入。 */}
|
|
1496
|
+
<line x1={x(row.column)} y1={cy} x2={x(row.column)} y2={h - 5} stroke={color(row.column)} strokeWidth={1.5} strokeDasharray="3 3" strokeLinecap="round" />
|
|
1497
|
+
<line x1={x(row.column) - 4} y1={h - 5} x2={x(row.column) + 4} y2={h - 5} stroke={color(row.column)} strokeWidth={1.5} strokeLinecap="round" />
|
|
1498
|
+
</>
|
|
1499
|
+
) : (
|
|
1500
|
+
<line x1={x(row.column)} y1={cy} x2={x(row.column)} y2={h} stroke={color(row.column)} strokeWidth={1.5} strokeLinecap="round" />
|
|
1501
|
+
))}
|
|
1502
|
+
{row.edges.map((edge, i) => (
|
|
1503
|
+
<path
|
|
1504
|
+
key={`e-${i}`}
|
|
1505
|
+
d={`M ${x(edge.from)} ${cy} C ${x(edge.from)} ${(cy + h) / 2}, ${x(edge.to)} ${(cy + h) / 2}, ${x(edge.to)} ${h}`}
|
|
1506
|
+
fill="none"
|
|
1507
|
+
stroke={color(edge.to)}
|
|
1508
|
+
strokeWidth={1.5}
|
|
1509
|
+
strokeLinecap="round"
|
|
1510
|
+
strokeLinejoin="round"
|
|
1511
|
+
/>
|
|
1512
|
+
))}
|
|
1513
|
+
<circle
|
|
1514
|
+
cx={x(row.column)}
|
|
1515
|
+
cy={cy}
|
|
1516
|
+
r={nodeR}
|
|
1517
|
+
fill={color(row.column)}
|
|
1518
|
+
stroke="var(--dsw-alias-bg-layer-2)"
|
|
1519
|
+
strokeWidth={1.5}
|
|
1520
|
+
/>
|
|
1521
|
+
</svg>
|
|
1522
|
+
)
|
|
1523
|
+
}
|
|
1524
|
+
|