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.
@@ -1,113 +1,225 @@
1
1
  /**
2
2
  * Git center — the IDE-style management panel for one session's repository.
3
3
  *
4
- * Phase 1 ships the Changes view: grouped file lists (staged / unstaged /
5
- * untracked) with per-file and bulk stage / unstage / discard actions, and a
6
- * commit box (message + optional selected paths). The panel is a platform
7
- * `Modal` (headless, width overridden) so it never participates in header
8
- * layout; operation results ride back through the controller and the view
9
- * updates from the returned snapshot.
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
- * Discard is destructive (tracked changes only in Phase 1) and therefore
12
- * two-step: the button arms itself and requires a second click within 3s.
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 { GitAction, GitActionResult, GitChange, GitSnapshot } from '../host/types.ts'
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 Feedback = { readonly kind: 'error'; readonly text: string } | null
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
- /** Change row with checkbox (commit selection) and per-file actions. */
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. Rendered by GitPill inside the platform Modal; the
87
- * snapshot prop comes from the live controller view, so every successful
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 [selected, setSelected] = useState<ReadonlySet<string>>(new Set())
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
- const staged = useMemo(() => snapshot.changes.filter((c) => c.staged), [snapshot])
108
- const unstaged = useMemo(() => snapshot.changes.filter((c) => !c.staged && c.status !== 'untracked'), [snapshot])
109
- const untracked = useMemo(() => snapshot.changes.filter((c) => c.status === 'untracked'), [snapshot])
110
- const hasTrackedChanges = staged.length + unstaged.length > 0
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
- const doRun = async (action: GitAction, successText: string): Promise<void> => {
122
- if (busy) return
123
- setBusy(true)
124
- setFeedback(null)
125
- const result = await run(action)
126
- setBusy(false)
127
- setArmed(null)
128
- if (result.ok) {
129
- setSelected(new Set())
130
- setMessage('')
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 armDiscard = (target: string | 'all'): void => {
140
- setArmed((prev) => (prev === target ? null : target))
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 discardTarget = (path: string): void => {
144
- if (armed === path) void doRun({ kind: 'discard', paths: [path] }, t('center.done'))
145
- else armDiscard(path)
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
- const discardAll = (): void => {
149
- if (armed === 'all') void doRun({ kind: 'discard-all' }, t('center.done'))
150
- else armDiscard('all')
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 doRun({ kind: 'commit', message: text, ...(paths === undefined ? {} : { paths }) }, t('center.done'))
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 actions: RowActions = {
304
+ const rowActions = {
161
305
  onToggle: toggle,
162
- onStage: (path) => void doRun({ kind: 'stage', paths: [path] }, t('center.done')),
163
- onUnstage: (path) => void doRun({ kind: 'unstage', paths: [path] }, t('center.done')),
164
- onDiscard: discardTarget,
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
- return (
168
- <Modal open={open} onClose={onClose} title={t('center.title')} closeLabel={t('popup.refresh')} headless className="dsh-git-ui__center">
169
- <div style={css.centerShell}>
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
- <div style={css.centerBody}>
176
- {feedback !== null && (
177
- <div style={css.feedbackError} role="alert">
178
- <span style={{ flex: 1 }}>{feedback.text}</span>
179
- <button type="button" style={css.feedbackClose} onClick={() => setFeedback(null)} aria-label={t('popup.refresh')}>✕</button>
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
- <div style={css.toolRow}>
184
- <Button size="sm" disabled={busy || (unstaged.length === 0 && untracked.length === 0)} onClick={() => void doRun({ kind: 'stage-all' }, t('center.done'))}>
185
- {t('center.stageAll')}
186
- </Button>
187
- <Button size="sm" disabled={busy || staged.length === 0} onClick={() => void doRun({ kind: 'unstage-all' }, t('center.done'))}>
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
- {staged.length > 0 && <GroupedList title={t('center.staged')} changes={staged} checked={selected} busy={busy} actions={actions} t={t} />}
200
- {unstaged.length > 0 && <GroupedList title={t('center.unstaged')} changes={unstaged} checked={selected} busy={busy} actions={actions} t={t} />}
201
- {untracked.length > 0 && <GroupedList title={t('center.untracked')} changes={untracked} checked={selected} busy={busy} actions={actions} t={t} />}
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
- <span style={css.commitHint}>
220
- {selected.size > 0 ? t('center.commitSelected').replace('{count}', String(selected.size)) : t('center.commitHint')}
221
- </span>
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
- {toast !== null && (
229
- <Toast key={toast.seq} text={toast.text} onDone={() => setToast(null)} />
230
- )}
231
- </Modal>
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
- /** One group of change rows (staged / unstaged / untracked). */
236
- function GroupedList({
237
- title, changes, checked, busy, actions, t,
493
+ /**
494
+ * IDEA 式分组头:粘性吸顶——组级全选(含半选态)+ 折叠箭头 + 名称 + 计数。
495
+ * 复选框与折叠按钮为独立控件,均可键盘操作。
496
+ */
497
+ function ChangeGroupHeader({
498
+ label, count, closed, allChecked, someChecked, onToggleClosed, onSelectAll, t,
238
499
  }: {
239
- title: string
240
- changes: readonly GitChange[]
241
- checked: ReadonlySet<string>
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
- actions: RowActions
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.groupTitle}>{title}</div>
249
- {changes.map((change) => (
250
- <ChangeRow
251
- key={change.path}
252
- change={change}
253
- checked={checked.has(change.path)}
254
- busy={busy}
255
- actions={actions}
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
+