@young1lin/dsh-ui-gitworkbench 0.1.11 → 0.1.13

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.
Files changed (36) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +28 -0
  3. package/CHANGELOG_EN.md +28 -0
  4. package/README.md +100 -50
  5. package/README_EN.md +2 -1
  6. package/lib/client.js +6902 -6256
  7. package/package.json +1 -1
  8. package/src/client/ChangesFileTree.tsx +550 -0
  9. package/src/client/ChromeGlyph.tsx +27 -0
  10. package/src/client/CodeEditor.tsx +129 -3
  11. package/src/client/CommitHistory.tsx +1001 -0
  12. package/src/client/DiffViews.tsx +1070 -0
  13. package/src/client/GitWorkbenchPanel.module.css +15 -2513
  14. package/src/client/GitWorkbenchPanel.tsx +37 -3959
  15. package/src/client/PaneDivider.tsx +74 -0
  16. package/src/client/WorkbenchControls.tsx +1047 -0
  17. package/src/client/WorktreeGlyph.tsx +24 -0
  18. package/src/client/cm-search-theme.ts +250 -0
  19. package/src/client/diff-nav.ts +59 -0
  20. package/src/client/git-workbench-types.ts +252 -0
  21. package/src/client/locales.ts +14 -8
  22. package/src/client/row-window.ts +23 -0
  23. package/src/client/search-count.ts +125 -0
  24. package/src/client/side-rows.ts +66 -0
  25. package/src/client/styles/changes.css +505 -0
  26. package/src/client/styles/controls.css +236 -0
  27. package/src/client/styles/environment.css +89 -0
  28. package/src/client/styles/files.css +113 -0
  29. package/src/client/styles/history-filters.css +400 -0
  30. package/src/client/styles/history.css +276 -0
  31. package/src/client/styles/image.css +67 -0
  32. package/src/client/styles/operations.css +179 -0
  33. package/src/client/styles/shell.css +431 -0
  34. package/src/client/styles/themes.css +224 -0
  35. package/src/client/use-change-nav.ts +6 -5
  36. package/src/client/use-row-window.ts +55 -0
@@ -0,0 +1,1001 @@
1
+ import { useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode, type Ref } from 'react'
2
+ import { createPortal } from 'react-dom'
3
+
4
+ import { COMMIT_ROW_H, type HistoryLayout } from './history-layout.ts'
5
+ import { layoutGraph, type GraphRow } from './commit-graph.ts'
6
+ import { formatCommitDate } from './commit-filter.ts'
7
+ import { chipsFromFilter, parseLogQuery, removeChip, serializeLogQuery } from './log-filter-query.ts'
8
+ import { buildDirTree, searchPaths, type DirEntry } from './dir-tree.ts'
9
+ import { addPath, buildIndex, checkedState, isCovered, removePath } from './path-select.ts'
10
+ import { inCalRange, localTodayIso, monthGrid, weekdayLabels } from './calendar.ts'
11
+ import { PathDirGlyph, PathFileGlyph } from './glyphs.tsx'
12
+ import type { LogFilter } from '../log-filter.ts'
13
+ import type { AuthorEntry } from '../shortlog.ts'
14
+ import type { GitCommit, Translate } from './git-workbench-types.ts'
15
+ import type { WorkbenchKey } from './locales.ts'
16
+ import css from './GitWorkbenchPanel.module.css'
17
+
18
+ /** Subject plus body, the text `git log` would print for `%B` without the trailing newline. */
19
+ function commitMessageText(commit: GitCommit): string {
20
+ const body = commit.body ?? ''
21
+ return body.length > 0 ? `${commit.subject}\n\n${body}` : commit.subject
22
+ }
23
+
24
+ function CopyCommitButton({ t, text }: { t: Translate; text: string }): ReactNode {
25
+ const [copied, setCopied] = useState(false)
26
+ useEffect(() => {
27
+ if (!copied) return
28
+ const id = window.setTimeout(() => setCopied(false), 1400)
29
+ return () => { window.clearTimeout(id) }
30
+ }, [copied])
31
+ return (
32
+ <button
33
+ type="button"
34
+ className={css.commitCopy}
35
+ onMouseDown={event => event.preventDefault()}
36
+ onClick={event => {
37
+ event.stopPropagation()
38
+ void navigator.clipboard.writeText(text).then(() => setCopied(true), () => setCopied(false))
39
+ }}
40
+ >{copied ? t('copiedCommit') : t('copyCommit')}</button>
41
+ )
42
+ }
43
+
44
+ /**
45
+ * One row in the history list. The subject stays one truncated line so the list
46
+ * stays scannable; hovering opens a card with the full message, including a
47
+ * multi-line body, which can be copied without selecting the commit.
48
+ */
49
+ /* ---------- commit graph ---------- */
50
+
51
+ /** Horizontal distance between lanes. */
52
+ const GRAPH_LANE_W = 14
53
+ /** Ref chips shown inline before the subject; the rest collapse into a "+N". */
54
+ const COMMIT_REF_CHIPS = 2
55
+ /** Lanes past this are not drawn. A repository can braid arbitrarily wide, and
56
+ * the diff is worth more than the twelfth simultaneous branch. */
57
+ const GRAPH_MAX_LANES = 6
58
+
59
+ const laneX = (lane: number): number => lane * GRAPH_LANE_W + GRAPH_LANE_W / 2
60
+
61
+ /**
62
+ * One row's slice of the commit graph.
63
+ *
64
+ * Drawn as an SVG exactly as tall as the row, so consecutive rows butt together
65
+ * and a lane reads as one unbroken line down the list. The dot sits at the
66
+ * vertical centre; edges leave the top edge, the dot, or the bottom edge, and a
67
+ * cubic with its control points at the quarter heights gives the S-curve every
68
+ * git client draws for a branch or a merge.
69
+ *
70
+ * The height is passed in rather than read from a constant here: the two
71
+ * History arrangements want differently shaped rows, and the segment and the
72
+ * row it belongs to must come from the same entry of `COMMIT_ROW_H` or the
73
+ * lanes stop meeting across the seam between rows.
74
+ */
75
+ function GraphCell({ row, width, active, rowH }: { row: GraphRow; width: number; active: boolean; rowH: number }): ReactNode {
76
+ const lanes = Math.min(width, GRAPH_MAX_LANES)
77
+ const w = lanes * GRAPH_LANE_W
78
+ const mid = rowH / 2
79
+ const visible = (lane: number): boolean => lane < GRAPH_MAX_LANES
80
+ const stroke = (lane: number): string => `var(--gs-graph-${lane % 6})`
81
+
82
+ const paths: ReactNode[] = []
83
+ for (const lane of row.through) {
84
+ if (!visible(lane)) continue
85
+ paths.push(<path key={`t${lane}`} d={`M ${laneX(lane)} 0 V ${rowH}`} stroke={stroke(lane)} />)
86
+ }
87
+ for (const lane of row.into) {
88
+ if (!visible(lane) || !visible(row.lane)) continue
89
+ paths.push(lane === row.lane
90
+ ? <path key={`i${lane}`} d={`M ${laneX(lane)} 0 V ${mid}`} stroke={stroke(lane)} />
91
+ : (
92
+ <path
93
+ key={`i${lane}`}
94
+ d={`M ${laneX(lane)} 0 C ${laneX(lane)} ${mid / 2}, ${laneX(row.lane)} ${mid / 2}, ${laneX(row.lane)} ${mid}`}
95
+ stroke={stroke(lane)}
96
+ />
97
+ ))
98
+ }
99
+ for (const lane of row.outOf) {
100
+ if (!visible(lane) || !visible(row.lane)) continue
101
+ paths.push(lane === row.lane
102
+ ? <path key={`o${lane}`} d={`M ${laneX(lane)} ${mid} V ${rowH}`} stroke={stroke(lane)} />
103
+ : (
104
+ <path
105
+ key={`o${lane}`}
106
+ d={`M ${laneX(row.lane)} ${mid} C ${laneX(row.lane)} ${mid + mid / 2}, ${laneX(lane)} ${mid + mid / 2}, ${laneX(lane)} ${rowH}`}
107
+ stroke={stroke(lane)}
108
+ />
109
+ ))
110
+ }
111
+
112
+ return (
113
+ <svg
114
+ className={css.graphCell}
115
+ width={w}
116
+ height={rowH}
117
+ viewBox={`0 0 ${w} ${rowH}`}
118
+ aria-hidden="true"
119
+ focusable="false"
120
+ >
121
+ <g fill="none" strokeWidth="1.6" strokeLinecap="round">{paths}</g>
122
+ {visible(row.lane) ? (
123
+ <circle
124
+ cx={laneX(row.lane)}
125
+ cy={mid}
126
+ r={row.isMerge ? 4.5 : 3.5}
127
+ // A merge is hollow, the way every git client distinguishes it: it is
128
+ // a joining of lines rather than a change of its own.
129
+ fill={row.isMerge ? 'var(--gs-panel)' : stroke(row.lane)}
130
+ stroke={stroke(row.lane)}
131
+ strokeWidth={row.isMerge ? 2 : active ? 3 : 0}
132
+ />
133
+ ) : null}
134
+ </svg>
135
+ )
136
+ }
137
+
138
+ function CommitRow({ t, commit, active, onSelect, graphRow, graphWidth, layout }: {
139
+ t: Translate
140
+ commit: GitCommit
141
+ active: boolean
142
+ onSelect: (hash: string) => void
143
+ /** This commit's lane geometry; absent while the graph is still empty. */
144
+ graphRow?: GraphRow
145
+ graphWidth: number
146
+ /** Which arrangement the list is in, which decides the row's shape. */
147
+ layout: HistoryLayout
148
+ }): ReactNode {
149
+ const rowRef = useRef<HTMLButtonElement>(null)
150
+ const [open, setOpen] = useState(false)
151
+ const [box, setBox] = useState<{ top: number; left: number; maxHeight: number } | null>(null)
152
+ const enterTimer = useRef(0)
153
+ const leaveTimer = useRef(0)
154
+ const body = commit.body ?? ''
155
+ const authorName = commit.authorName ?? ''
156
+ const committerName = commit.committerName ?? ''
157
+ // The viewer's own locale and timezone — that is the whole point of the line.
158
+ const exactDate = formatCommitDate(commit.dateIso ?? '')
159
+
160
+ const cancel = (): void => {
161
+ window.clearTimeout(enterTimer.current)
162
+ window.clearTimeout(leaveTimer.current)
163
+ }
164
+ const show = (): void => {
165
+ cancel()
166
+ enterTimer.current = window.setTimeout(() => setOpen(true), 360)
167
+ }
168
+ const hide = (): void => {
169
+ cancel()
170
+ leaveTimer.current = window.setTimeout(() => setOpen(false), 160)
171
+ }
172
+
173
+ useEffect(() => () => { cancel() }, [])
174
+
175
+ useEffect(() => {
176
+ if (!open) { setBox(null); return }
177
+ const row = rowRef.current
178
+ if (row === null) return
179
+ const rect = row.getBoundingClientRect()
180
+ const width = 380
181
+ const left = Math.min(rect.right + 10, window.innerWidth - width - 12)
182
+ const top = Math.max(12, Math.min(rect.top, window.innerHeight - 220))
183
+ setBox({ top, left: Math.max(12, left), maxHeight: window.innerHeight - top - 16 })
184
+ }, [open])
185
+
186
+ const host = rowRef.current?.closest('[data-gs-part="overlay"]') ?? (typeof document === 'undefined' ? null : document.body)
187
+
188
+ const refs = commit.refs ?? []
189
+
190
+ /* The part both shapes share, and the only part either is really for. */
191
+ const subjectRow = (
192
+ <span className={css.commitSubjectRow}>
193
+ {/* Capped at two. A release commit can carry six refs, and the
194
+ subject is what the row is actually for — the rest are counted
195
+ and named in the title rather than crowding it out. */}
196
+ {refs.slice(0, COMMIT_REF_CHIPS).map(ref => (
197
+ <span key={ref} className={css.commitRef} title={ref}>{ref}</span>
198
+ ))}
199
+ {refs.length > COMMIT_REF_CHIPS ? (
200
+ <span className={css.commitRefMore} title={refs.slice(COMMIT_REF_CHIPS).join('\n')}>
201
+ +{refs.length - COMMIT_REF_CHIPS}
202
+ </span>
203
+ ) : null}
204
+ <span className={css.commitSubject}>{commit.subject}</span>
205
+ {body.length > 0 ? <span className={css.commitHasBody} aria-hidden="true">···</span> : null}
206
+ </span>
207
+ )
208
+
209
+ return (
210
+ <>
211
+ {/* The graph is a SIBLING of the row button, spanning the line's full
212
+ height with no margin of its own — that is what lets a lane run
213
+ unbroken from one row into the next while the button itself keeps its
214
+ inset and its rounded corners. */}
215
+ <div className={css.commitLine}>
216
+ {graphRow !== undefined
217
+ ? <GraphCell row={graphRow} width={graphWidth} active={active} rowH={COMMIT_ROW_H[layout]} />
218
+ : null}
219
+ <button
220
+ ref={rowRef}
221
+ type="button"
222
+ role="option"
223
+ aria-selected={active}
224
+ className={active ? `${css.commit} ${css.commitActive}` : css.commit}
225
+ onClick={() => onSelect(commit.hash)}
226
+ onMouseEnter={show}
227
+ onMouseLeave={hide}
228
+ >
229
+ {layout === 'stacked' ? (
230
+ <>
231
+ {/* One line, in git log --oneline's order: a fixed-width hash,
232
+ then the subject, then who and when pushed to the right. The
233
+ hash being fixed width is what aligns every subject into a
234
+ column the eye can run down — leading with the author's name
235
+ instead would start each subject at a different place. */}
236
+ <code className={css.commitHash}>{commit.hash}</code>
237
+ {subjectRow}
238
+ <span className={css.commitMeta}>
239
+ {authorName.length > 0 ? <span className={css.commitAuthor}>{authorName}</span> : null}
240
+ <span className={css.commitWhen}>{commit.when}</span>
241
+ </span>
242
+ </>
243
+ ) : (
244
+ <>
245
+ {/* Two lines, because a pane beside the diff has no width to
246
+ spare: everything but the subject goes above it, and the
247
+ subject then gets the column to itself. */}
248
+ <span className={css.commitTop}>
249
+ <code className={css.commitHash}>{commit.hash}</code>
250
+ {authorName.length > 0 ? <span className={css.commitAuthor}>{authorName}</span> : null}
251
+ <span className={css.commitWhen}>{commit.when}</span>
252
+ </span>
253
+ {subjectRow}
254
+ </>
255
+ )}
256
+ </button>
257
+ </div>
258
+ {open && box !== null && host !== null ? createPortal(
259
+ <div
260
+ className={css.commitPop}
261
+ style={{ top: box.top, left: box.left, maxHeight: box.maxHeight }}
262
+ onMouseEnter={() => { cancel(); setOpen(true) }}
263
+ onMouseLeave={hide}
264
+ onClick={event => event.stopPropagation()}
265
+ >
266
+ <div className={css.commitPopTop}>
267
+ <code className={css.commitHash}>{commit.hash}</code>
268
+ <span className={css.commitWhen}>{commit.when}</span>
269
+ <CopyCommitButton t={t} text={commitMessageText(commit)} />
270
+ </div>
271
+ {/* Who and exactly when. The row summarizes ("3 weeks ago"); the
272
+ hover card is where the precise question gets a precise answer —
273
+ full date in the VIEWER's timezone, author, and the committer
274
+ whenever git recorded someone other than the author. */}
275
+ {authorName.length > 0 || committerName.length > 0 || exactDate.length > 0 ? (
276
+ <div className={css.commitPopMeta}>
277
+ {authorName.length > 0 ? <span>{t('commitAuthor')}: {authorName}</span> : null}
278
+ {committerName.length > 0 && committerName !== authorName ? (
279
+ <span>{t('commitCommitter')}: {committerName}</span>
280
+ ) : null}
281
+ {exactDate.length > 0 ? <span>{t('commitDate')}: {exactDate}</span> : null}
282
+ </div>
283
+ ) : null}
284
+ <div className={css.commitPopSubject}>{commit.subject}</div>
285
+ {body.length > 0 ? <pre className={css.commitPopBody}>{body}</pre> : null}
286
+ </div>,
287
+ host,
288
+ ) : null}
289
+ </>
290
+ )
291
+ }
292
+
293
+ /** The filter's own calendar — a hand-rolled 6×7 Monday-first grid (pure
294
+ * arithmetic in `calendar.ts`), because the native date input renders as the
295
+ * platform's bare widget and the bundle's purity gate forbids pulling in a
296
+ * library. Picking a day hands `yyyy-mm-dd` to the bound the segmented
297
+ * control armed; the host expands it to the whole day. */
298
+ function FilterCalendar({ year, month, after, before, locale, onPick, onShift }: {
299
+ year: number
300
+ month: number
301
+ /** Current bounds, to mark the picked days (approxidate text never matches
302
+ * an iso, so a preset like "1 week ago" simply marks nothing). */
303
+ after: string
304
+ before: string
305
+ /** BCP-47 tag from the drawer's own dictionary (`filterLocale`), NOT the
306
+ * browser's — those disagree the moment the UI language is not the OS one,
307
+ * and the grid printed its month in the other language. */
308
+ locale: string
309
+ onPick: (iso: string) => void
310
+ onShift: (deltaMonths: number) => void
311
+ }): ReactNode {
312
+ const grid = monthGrid(year, month, localTodayIso())
313
+ const title = new Intl.DateTimeFormat(locale, { year: 'numeric', month: 'long' }).format(new Date(year, month, 1))
314
+ return (
315
+ <div className={css.cal}>
316
+ <div className={css.calHead}>
317
+ <button type="button" className={css.calNav} aria-label="‹" onClick={() => onShift(-1)}>‹</button>
318
+ <span className={css.calTitle}>{title}</span>
319
+ <button type="button" className={css.calNav} aria-label="›" onClick={() => onShift(1)}>›</button>
320
+ </div>
321
+ <div className={css.calWeek}>
322
+ {weekdayLabels(locale).map((label, index) => <span key={index}>{label}</span>)}
323
+ </div>
324
+ <div className={css.calGrid}>
325
+ {grid.flat().map(cell => cell === null ? null : (
326
+ <button
327
+ key={cell.iso}
328
+ type="button"
329
+ aria-label={cell.iso}
330
+ title={cell.iso}
331
+ className={[
332
+ cell.inMonth ? '' : css.calOut,
333
+ cell.isToday ? css.calToday : '',
334
+ // Between the bounds, not one of them: the two endpoints alone
335
+ // never showed which days the filter actually admits. Both
336
+ // bounds are iso here or the comparison is simply false, which
337
+ // is what an approxidate preset should render as.
338
+ inCalRange(cell.iso, after, before) ? css.calIn : '',
339
+ cell.iso === after || cell.iso === before ? css.calMark : '',
340
+ ].filter(cls => cls.length > 0).join(' ')}
341
+ onClick={() => onPick(cell.iso)}
342
+ ><span>{cell.day}</span></button>
343
+ ))}
344
+ </div>
345
+ </div>
346
+ )
347
+ }
348
+
349
+ /** Files shown per expanded directory. The search box is the way to a file in
350
+ * a crowded directory; the tree shows enough to browse without flooding the
351
+ * list, and says so when it cut the tail. */
352
+ const PATH_FILES_SHOWN = 100
353
+
354
+ /** Horizontal step per nesting level in the path picker. The whole indent now
355
+ * comes from this one number: `.pathChildren` used to add a margin and a rail
356
+ * of its own on top of it, so every level cost 29px and a 320px popover ran
357
+ * out of width three directories deep. */
358
+ const PATH_INDENT = 14
359
+
360
+ /** One level of the path picker's directory tree — directories (chevron,
361
+ * subtree count) then their files (doc glyph, leaf rows). Collapsed subtrees
362
+ * are not in the DOM at all, so a monorepo costs only what the reader has
363
+ * opened. */
364
+ /** A checkbox that also carries the tree's third state — `indeterminate` is a
365
+ * DOM property, not an attribute, so it is set through the ref. */
366
+ function TriStateCheckbox({ state, onChange, ariaLabel }: {
367
+ state: 'on' | 'off' | 'partial'
368
+ onChange: () => void
369
+ ariaLabel: string
370
+ }): ReactNode {
371
+ return (
372
+ <input
373
+ type="checkbox"
374
+ aria-label={ariaLabel}
375
+ checked={state === 'on'}
376
+ ref={el => { if (el !== null) el.indeterminate = state === 'partial' }}
377
+ onChange={onChange}
378
+ />
379
+ )
380
+ }
381
+
382
+ function PathTreeRows({ dirs, depth, expanded, stateOf, onToggleOpen, onTogglePath }: {
383
+ dirs: readonly DirEntry[]
384
+ depth: number
385
+ expanded: readonly string[]
386
+ /** Derived on/partial/off for any row path — the single source of truth. */
387
+ stateOf: (path: string) => 'on' | 'off' | 'partial'
388
+ onToggleOpen: (path: string) => void
389
+ onTogglePath: (path: string) => void
390
+ }): ReactNode {
391
+ return (
392
+ <>
393
+ {dirs.map(dir => {
394
+ const open = expanded.includes(dir.path)
395
+ const expandable = dir.children.length > 0 || dir.files.length > 0
396
+ const shown = dir.files.slice(0, PATH_FILES_SHOWN)
397
+ return (
398
+ <div key={dir.path} className={css.pathNode}>
399
+ <div className={css.funnelRow} style={{ paddingLeft: depth * PATH_INDENT + 4 }}>
400
+ <button
401
+ type="button"
402
+ className={css.funnelChevron}
403
+ disabled={!expandable}
404
+ aria-expanded={open}
405
+ onClick={() => onToggleOpen(dir.path)}
406
+ >{expandable ? (open ? '▾' : '▸') : ''}</button>
407
+ <TriStateCheckbox state={stateOf(dir.path)} ariaLabel={dir.path} onChange={() => onTogglePath(dir.path)} />
408
+ <PathDirGlyph />
409
+ <span className={css.funnelName} title={dir.path}>{dir.name}</span>
410
+ <span className={css.funnelCount}>{dir.fileCount}</span>
411
+ </div>
412
+ {open ? (
413
+ <div className={css.pathChildren}>
414
+ <PathTreeRows
415
+ dirs={dir.children}
416
+ depth={depth + 1}
417
+ expanded={expanded}
418
+ stateOf={stateOf}
419
+ onToggleOpen={onToggleOpen}
420
+ onTogglePath={onTogglePath}
421
+ />
422
+ {shown.map(file => (
423
+ <label key={file} className={css.funnelRow} style={{ paddingLeft: (depth + 1) * PATH_INDENT + 4 }}>
424
+ <span className={css.funnelChevron} aria-hidden="true" />
425
+ <TriStateCheckbox state={stateOf(`${dir.path}/${file}`)} ariaLabel={`${dir.path}/${file}`} onChange={() => onTogglePath(`${dir.path}/${file}`)} />
426
+ <PathFileGlyph path={`${dir.path}/${file}`} />
427
+ <span className={css.funnelName} title={`${dir.path}/${file}`}>{file}</span>
428
+ </label>
429
+ ))}
430
+ {dir.files.length > PATH_FILES_SHOWN ? (
431
+ <div className={css.funnelMore}>+{dir.files.length - PATH_FILES_SHOWN}</div>
432
+ ) : null}
433
+ </div>
434
+ ) : null}
435
+ </div>
436
+ )
437
+ })}
438
+ </>
439
+ )
440
+ }
441
+
442
+ /**
443
+ * The two arrangements, drawn in the same 16px/1px idiom as the drawer's other
444
+ * glyphs: a pane and its neighbour, either side by side or one over the other.
445
+ * The filled half is the list, so the picture says which pane moves.
446
+ */
447
+ export function ColumnsGlyph(): ReactNode {
448
+ return (
449
+ <svg
450
+ className={css.layoutGlyph}
451
+ width="16" height="16" viewBox="0 0 16 16"
452
+ fill="none" stroke="currentColor" strokeWidth="1"
453
+ strokeLinejoin="round" aria-hidden="true"
454
+ >
455
+ <rect x="1.5" y="2.5" width="13" height="11" rx="1.5" />
456
+ <rect x="1.5" y="2.5" width="5" height="11" rx="1.5" fill="currentColor" stroke="none" opacity="0.55" />
457
+ <path d="M6.5 2.5 V13.5" />
458
+ </svg>
459
+ )
460
+ }
461
+
462
+ export function StackedGlyph(): ReactNode {
463
+ return (
464
+ <svg
465
+ className={css.layoutGlyph}
466
+ width="16" height="16" viewBox="0 0 16 16"
467
+ fill="none" stroke="currentColor" strokeWidth="1"
468
+ strokeLinejoin="round" aria-hidden="true"
469
+ >
470
+ <rect x="1.5" y="2.5" width="13" height="11" rx="1.5" />
471
+ <rect x="1.5" y="2.5" width="13" height="4" rx="1.5" fill="currentColor" stroke="none" opacity="0.55" />
472
+ <path d="M1.5 6.5 H14.5" />
473
+ </svg>
474
+ )
475
+ }
476
+
477
+ /**
478
+ * One end of the arrangement switch.
479
+ *
480
+ * `aria-pressed` rather than a radio group: these are two states of one view
481
+ * control, not a value being submitted, and a screen reader then reads the
482
+ * arrangement in force without the group needing a name per option.
483
+ * @param glyph - the arrangement, drawn.
484
+ * @param label - accessible name, also the tooltip.
485
+ * @param on - whether this arrangement is the one in force.
486
+ * @param onPick - switch to it.
487
+ */
488
+ export function LayoutButton({ glyph, label, on, onPick }: {
489
+ glyph: ReactNode
490
+ label: string
491
+ on: boolean
492
+ onPick: () => void
493
+ }): ReactNode {
494
+ return (
495
+ <button
496
+ type="button"
497
+ className={on ? `${css.layoutButton} ${css.layoutButtonOn}` : css.layoutButton}
498
+ aria-pressed={on}
499
+ aria-label={label}
500
+ title={label}
501
+ onClick={onPick}
502
+ >{glyph}</button>
503
+ )
504
+ }
505
+
506
+ /**
507
+ * The commit log as its own pane, in whichever arrangement the reader picked.
508
+ *
509
+ * Beside the file tree it is a peer pane the way GitHub Desktop, the JetBrains
510
+ * git log and GitKraken all draw it — list and selected commit's files side by
511
+ * side, each with its own scrollbar. Across the top it is IDEA's git log
512
+ * instead, which is the arrangement that stops a long subject being cut; see
513
+ * `history-layout.ts` for what each costs. The switch that picks between them
514
+ * is in the toolbar row above, not in this pane's head — the head is the first
515
+ * thing to run out of room when the pane is dragged narrow, which is exactly
516
+ * when a reader reaches for the switch. Either way there is nothing to
517
+ * collapse and nothing to discover.
518
+ *
519
+ * Pages load by scrolling. A button at the end of a growing list is the worst
520
+ * of both worlds — it retreats every time it is used, and it asks the reader to
521
+ * confirm an intention that scrolling toward the end already stated. A sentinel
522
+ * below the last row requests the next page as it comes into view, which is
523
+ * what GitHub and GitLens do. The observer is rebuilt whenever the list grows,
524
+ * so a page too short to fill the pane immediately triggers the next one.
525
+ */
526
+ export function CommitList({ paneRef, style, layout, t, loading, commits, active, onSelect, hasMore, loadingMore, onLoadMore, query, onQueryChange, error, statsPath, refName, fetchAuthors, fetchRepoTree }: {
527
+ /** The pane element, which the divider beside it measures from. Not named
528
+ * `ref`: React reserves that on a function component, so it would be stripped
529
+ * from props and never reach this element. */
530
+ paneRef: Ref<HTMLDivElement>
531
+ /** Dragged size, when the divider has been used: a width beside the diff, a
532
+ * height above it. */
533
+ style: CSSProperties | undefined
534
+ /** The arrangement in force, which decides the row's shape as well as the
535
+ * pane's. The control that CHANGES it is not in here — see the toolbar row
536
+ * above the panes. */
537
+ layout: HistoryLayout
538
+ t: Translate
539
+ /** First page in flight — the pane says "loading", not "no history", which
540
+ * would be a claim about the repository the data has not made. */
541
+ loading: boolean
542
+ commits: readonly GitCommit[]
543
+ active: string | null
544
+ onSelect: (hash: string) => void
545
+ hasMore: boolean
546
+ loadingMore: boolean
547
+ onLoadMore: () => void
548
+ /** The filter box's text. Parsed here for chips; the parent debounces the
549
+ * same parse into the server-side fetch. */
550
+ query: string
551
+ onQueryChange: (query: string) => void
552
+ /** git's complaint when the log itself failed (bad pattern/date), verbatim. */
553
+ error: string | null
554
+ /** Which tree the author roster counts — the drawer's current source. */
555
+ statsPath: string | undefined
556
+ /** Which ref the roster and the list both walk — the picker's people are the
557
+ * list's people, so a tick can never name someone with nothing to show. */
558
+ refName: string
559
+ fetchAuthors: (worktreePath: string | undefined, ref: string, signal: AbortSignal) => Promise<{ authors: readonly AuthorEntry[]; truncated: boolean } | null>
560
+ fetchRepoTree: (worktreePath: string | undefined, signal: AbortSignal) => Promise<{ paths: string[]; truncated: boolean } | null>
561
+ }): ReactNode {
562
+ const scrollRef = useRef<HTMLDivElement>(null)
563
+ const sentinelRef = useRef<HTMLDivElement>(null)
564
+ // One grammar, one filter: chips are the parsed criteria, and removing one
565
+ // rewrites the box through that same grammar.
566
+ const filterModel = useMemo(() => parseLogQuery(query), [query])
567
+ const chips = chipsFromFilter(filterModel)
568
+ // What the panel is currently asking git for. Each tab shows its own share
569
+ // so the two sections nobody is looking at still say they hold something,
570
+ // and the footer shows the total — the chip row that used to be the only
571
+ // feedback sits BEHIND the popup, so the ticks looked inert until it closed.
572
+ // A date bound counts as one criterion each; free text is the box's, not
573
+ // the popup's, so it stays out of both.
574
+ const dateCount = (filterModel.after.length > 0 ? 1 : 0) + (filterModel.before.length > 0 ? 1 : 0)
575
+ const selectedCount = filterModel.users.length + filterModel.paths.length + dateCount
576
+
577
+ // ---- funnel popup: user picker + date bounds + path tree --------------
578
+ const [funnelOpen, setFunnelOpen] = useState(false)
579
+ const [authors, setAuthors] = useState<{ authors: readonly AuthorEntry[]; truncated: boolean } | null>(null)
580
+ const [authorsQuery, setAuthorsQuery] = useState('')
581
+ const [pathTree, setPathTree] = useState<{ dirs: readonly DirEntry[]; paths: readonly string[]; truncated: boolean } | null>(null)
582
+ const [expandedDirs, setExpandedDirs] = useState<readonly string[]>([])
583
+ const [pathsQuery, setPathsQuery] = useState('')
584
+ // The popup shows ONE section at a time (tabs), so a roster of dozens
585
+ // cannot grow the panel past the paths section — every section is reachable
586
+ // in one click whatever the others hold.
587
+ const [funnelSection, setFunnelSection] = useState<'users' | 'date' | 'paths'>('users')
588
+ // The calendar's displayed month, and which bound a picked day lands in.
589
+ const [calMonth, setCalMonth] = useState(() => { const now = new Date(); return { year: now.getFullYear(), month: now.getMonth() } })
590
+ const [calBound, setCalBound] = useState<'after' | 'before'>('after')
591
+
592
+ // The panel is PORTALLED to the drawer overlay (position: fixed, clamped to
593
+ // the viewport — the commits pane can be narrower than the panel, and an
594
+ // absolute panel anchored at its right edge runs off-screen). Dismissal
595
+ // therefore checks TWO refs: the anchor button and the panel itself; a
596
+ // single useDismissable root would see every click inside the portalled
597
+ // panel as "outside" and close it out from under the click.
598
+ const funnelAnchorRef = useRef<HTMLDivElement>(null)
599
+ const funnelPanelRef = useRef<HTMLDivElement>(null)
600
+ const [funnelBox, setFunnelBox] = useState<{ top: number; left: number; maxHeight: number } | null>(null)
601
+
602
+ useEffect(() => {
603
+ if (!funnelOpen) { setFunnelBox(null); return }
604
+ const onDown = (event: MouseEvent): void => {
605
+ const target = event.target as Node
606
+ if (funnelAnchorRef.current?.contains(target) === true) return
607
+ if (funnelPanelRef.current?.contains(target) === true) return
608
+ setFunnelOpen(false)
609
+ }
610
+ const onKey = (event: KeyboardEvent): void => { if (event.key === 'Escape') setFunnelOpen(false) }
611
+ // Bound on the next tick: the opening click is still travelling.
612
+ const id = window.setTimeout(() => document.addEventListener('mousedown', onDown), 0)
613
+ document.addEventListener('keydown', onKey)
614
+ return () => {
615
+ window.clearTimeout(id)
616
+ document.removeEventListener('mousedown', onDown)
617
+ document.removeEventListener('keydown', onKey)
618
+ }
619
+ }, [funnelOpen])
620
+
621
+ useEffect(() => {
622
+ if (!funnelOpen) return
623
+ const rect = funnelAnchorRef.current?.getBoundingClientRect()
624
+ if (rect === undefined) return
625
+ const width = 300
626
+ const left = Math.max(12, Math.min(rect.left + rect.width - width, window.innerWidth - width - 12))
627
+ const top = rect.bottom + 4
628
+ setFunnelBox({ top, left, maxHeight: Math.max(160, window.innerHeight - top - 16) })
629
+ }, [funnelOpen])
630
+
631
+ // The roster and the tree are fetched when the funnel OPENS (not when the
632
+ // pane mounts — most visits never filter) and again when the source or the
633
+ // ref moves: the roster counts the very history the list walks, so the two
634
+ // can never disagree about who has commits.
635
+ useEffect(() => {
636
+ if (!funnelOpen) return
637
+ const ctrl = new AbortController()
638
+ setAuthors(null)
639
+ setPathTree(null)
640
+ fetchAuthors(statsPath, refName, ctrl.signal).then(roster => {
641
+ if (!ctrl.signal.aborted) setAuthors(roster)
642
+ }).catch(() => {})
643
+ fetchRepoTree(statsPath, ctrl.signal).then(tree => {
644
+ if (!ctrl.signal.aborted && tree !== null) {
645
+ setPathTree({ dirs: buildDirTree(tree.paths), paths: tree.paths, truncated: tree.truncated })
646
+ }
647
+ }).catch(() => {})
648
+ return () => { ctrl.abort() }
649
+ }, [funnelOpen, statsPath, refName, fetchAuthors, fetchRepoTree])
650
+
651
+ // Where the popover mounts: the drawer's overlay layer when there is one,
652
+ // the body otherwise. Same resolution the commit-row popover does, and the
653
+ // render below skips the portal when neither exists rather than handing
654
+ // createPortal a null container.
655
+ const funnelHost = funnelAnchorRef.current?.closest('[data-gs-part="overlay"]') ?? (typeof document === 'undefined' ? null : document.body)
656
+
657
+ /** Every funnel interaction writes the filter through the box's grammar, so
658
+ * the box, the chips and the fetch can never disagree about the query. */
659
+ const applyFilter = (next: LogFilter): void => { onQueryChange(serializeLogQuery(next)) }
660
+ const toggleUser = (name: string): void => {
661
+ const has = filterModel.users.includes(name)
662
+ applyFilter({
663
+ ...filterModel,
664
+ users: has ? filterModel.users.filter(user => user !== name) : [...filterModel.users, name],
665
+ })
666
+ }
667
+ // Checkbox-tree semantics: ticking a folder covers its subtree (and absorbs
668
+ // the files already ticked inside it); unticking a file under a checked
669
+ // folder cascades out. Rows DERIVE their state — on/partial/off — from the
670
+ // set, so a folder tick visibly checks everything under it.
671
+ const pathIndex = useMemo(
672
+ () => (pathTree === null ? null : buildIndex(pathTree.paths)),
673
+ [pathTree],
674
+ )
675
+ const pathState = (path: string): 'on' | 'off' | 'partial' =>
676
+ pathIndex === null ? 'off' : checkedState(filterModel.paths, path, pathIndex)
677
+ const togglePath = (path: string): void => {
678
+ if (pathIndex === null) return
679
+ applyFilter({
680
+ ...filterModel,
681
+ paths: isCovered(filterModel.paths, path)
682
+ ? removePath(filterModel.paths, path, pathIndex)
683
+ : addPath(filterModel.paths, path),
684
+ })
685
+ }
686
+ const toggleDirOpen = (path: string): void => {
687
+ setExpandedDirs(prev => prev.includes(path) ? prev.filter(p => p !== path) : [...prev, path])
688
+ }
689
+ const needle = authorsQuery.trim().toLowerCase()
690
+ const matchedAuthors = authors === null
691
+ ? []
692
+ : needle.length === 0
693
+ ? authors.authors
694
+ : authors.authors.filter(entry =>
695
+ entry.name.toLowerCase().includes(needle) || entry.email.toLowerCase().includes(needle))
696
+ const DATE_PRESETS: readonly { key: WorkbenchKey; value: string }[] = [
697
+ { key: 'filterToday', value: 'midnight' },
698
+ { key: 'filterLast7', value: '1 week ago' },
699
+ { key: 'filterLast30', value: '30 days ago' },
700
+ ]
701
+ // Recomputed only when a page lands. The layout is a single pass over the
702
+ // loaded prefix, and every row's geometry depends on the rows above it, so
703
+ // there is nothing finer to memoise than the whole list.
704
+ //
705
+ // Filtering does not suspend the graph: the server returns one contiguous
706
+ // walk of the FILTERED log, so lanes stay truthful — unlike a client-side
707
+ // filter, which would break the very walk it draws from.
708
+ const graph = useMemo(
709
+ () => layoutGraph(commits.map(commit => ({ hash: commit.hash, parents: commit.parents ?? [] }))),
710
+ [commits],
711
+ )
712
+
713
+ useEffect(() => {
714
+ const root = scrollRef.current
715
+ const sentinel = sentinelRef.current
716
+ if (root === null || sentinel === null || !hasMore || loadingMore) return
717
+ const observer = new IntersectionObserver(
718
+ entries => { if (entries.some(entry => entry.isIntersecting)) onLoadMore() },
719
+ // Start the fetch before the sentinel is actually reached, so the next
720
+ // page is usually there by the time the reader arrives.
721
+ { root, rootMargin: '300px' },
722
+ )
723
+ observer.observe(sentinel)
724
+ return () => { observer.disconnect() }
725
+ }, [hasMore, loadingMore, commits.length, onLoadMore])
726
+
727
+ return (
728
+ <div ref={paneRef} className={css.commitsPane} style={style} data-layout={layout} data-gs-part="commits">
729
+ {/* No count: the only number available is how many pages have been loaded,
730
+ which is not how many commits exist. A number that cannot be right is
731
+ worse than none. */}
732
+ <div className={css.paneHead}>
733
+ <span className={css.paneTitle}>{t('historyLabel')}</span>
734
+ <div className={css.funnel} ref={funnelAnchorRef}>
735
+ <button
736
+ type="button"
737
+ className={funnelOpen || chips.length > 0 ? `${css.funnelButton} ${css.funnelButtonActive}` : css.funnelButton}
738
+ aria-expanded={funnelOpen}
739
+ onClick={() => setFunnelOpen(isOpen => !isOpen)}
740
+ >{t('filterBy')} ▾</button>
741
+ </div>
742
+ <input
743
+ className={css.commitFilter}
744
+ type="search"
745
+ value={query}
746
+ onChange={event => onQueryChange(event.target.value)}
747
+ placeholder={t('historyFilterPlaceholder')}
748
+ aria-label={t('historyFilterPlaceholder')}
749
+ spellCheck={false}
750
+ />
751
+ </div>
752
+ {funnelOpen && funnelBox !== null && funnelHost !== null ? createPortal(
753
+ <div
754
+ ref={funnelPanelRef}
755
+ className={css.funnelPop}
756
+ style={funnelBox}
757
+ role="dialog"
758
+ aria-label={t('filterBy')}
759
+ >
760
+ {/* One section at a time: a roster of dozens cannot grow the panel
761
+ past the other sections, and each tab carries its own active
762
+ count so the criteria are visible without visiting the tab. */}
763
+ <div className={css.funnelTabs} role="tablist">
764
+ <button
765
+ type="button" role="tab" aria-selected={funnelSection === 'users'}
766
+ className={funnelSection === 'users' ? `${css.funnelTab} ${css.funnelTabActive}` : css.funnelTab}
767
+ onClick={() => setFunnelSection('users')}
768
+ >
769
+ {t('filterUsers')}
770
+ {filterModel.users.length > 0 ? <span className={css.funnelTabCount}>{filterModel.users.length}</span> : null}
771
+ </button>
772
+ <button
773
+ type="button" role="tab" aria-selected={funnelSection === 'date'}
774
+ className={funnelSection === 'date' ? `${css.funnelTab} ${css.funnelTabActive}` : css.funnelTab}
775
+ onClick={() => setFunnelSection('date')}
776
+ >
777
+ {t('filterDate')}
778
+ {dateCount > 0 ? <span className={css.funnelTabCount}>{dateCount}</span> : null}
779
+ </button>
780
+ <button
781
+ type="button" role="tab" aria-selected={funnelSection === 'paths'}
782
+ className={funnelSection === 'paths' ? `${css.funnelTab} ${css.funnelTabActive}` : css.funnelTab}
783
+ onClick={() => setFunnelSection('paths')}
784
+ >
785
+ {t('filterPaths')}
786
+ {filterModel.paths.length > 0 ? <span className={css.funnelTabCount}>{filterModel.paths.length}</span> : null}
787
+ </button>
788
+ </div>
789
+ {funnelSection === 'users' ? (
790
+ <div className={css.funnelPane}>
791
+ <input
792
+ className={css.funnelSearch}
793
+ type="search"
794
+ value={authorsQuery}
795
+ onChange={event => setAuthorsQuery(event.target.value)}
796
+ placeholder={t('filterUserSearch')}
797
+ aria-label={t('filterUserSearch')}
798
+ spellCheck={false}
799
+ />
800
+ <div className={css.funnelList}>
801
+ {authors === null ? (
802
+ <div className={css.funnelMore}>{t('loading')}</div>
803
+ ) : matchedAuthors.length === 0 ? (
804
+ <div className={css.funnelMore}>{authors.authors.length === 0 ? t('noCommits') : t('historyNoMatch')}</div>
805
+ ) : matchedAuthors.map(entry => (
806
+ <label key={`${entry.name}\x1f${entry.email}`} className={css.funnelRow}>
807
+ <input
808
+ type="checkbox"
809
+ checked={filterModel.users.includes(entry.name)}
810
+ onChange={() => toggleUser(entry.name)}
811
+ />
812
+ <span className={css.funnelName} title={`${entry.name} <${entry.email}>`}>{entry.name}</span>
813
+ <span className={css.funnelCount}>{entry.count}</span>
814
+ </label>
815
+ ))}
816
+ {authors?.truncated === true ? (
817
+ <div className={css.funnelMore}>{t('filterAuthorsMore')}</div>
818
+ ) : null}
819
+ </div>
820
+ </div>
821
+ ) : null}
822
+ {funnelSection === 'date' ? (
823
+ <div className={css.funnelPane}>
824
+ <div className={css.funnelPresets}>
825
+ {DATE_PRESETS.map(preset => (
826
+ <button
827
+ key={preset.key}
828
+ type="button"
829
+ className={filterModel.after === preset.value ? `${css.funnelPreset} ${css.funnelPresetActive}` : css.funnelPreset}
830
+ onClick={() => applyFilter({ ...filterModel, after: filterModel.after === preset.value ? '' : preset.value })}
831
+ >{t(preset.key)}</button>
832
+ ))}
833
+ </div>
834
+ {/* Which bound a picked day lands in — the calendar is one, the
835
+ range is two picks apart. Captioned, and shaped as a rect
836
+ track rather than the tab strip's pills: two identical pill
837
+ rows six pixels apart never said they meant different
838
+ things. */}
839
+ <span className={css.funnelCaption}>{t('filterCalendarSets')}</span>
840
+ <div className={css.funnelBounds} role="group" aria-label={t('filterCalendarSets')}>
841
+ <button
842
+ type="button"
843
+ aria-pressed={calBound === 'after'}
844
+ className={calBound === 'after' ? `${css.funnelBoundBtn} ${css.funnelBoundBtnActive}` : css.funnelBoundBtn}
845
+ onClick={() => setCalBound('after')}
846
+ >{t('filterAfter')}</button>
847
+ <button
848
+ type="button"
849
+ aria-pressed={calBound === 'before'}
850
+ className={calBound === 'before' ? `${css.funnelBoundBtn} ${css.funnelBoundBtnActive}` : css.funnelBoundBtn}
851
+ onClick={() => setCalBound('before')}
852
+ >{t('filterBefore')}</button>
853
+ </div>
854
+ <FilterCalendar
855
+ year={calMonth.year}
856
+ month={calMonth.month}
857
+ after={filterModel.after}
858
+ before={filterModel.before}
859
+ locale={t('filterLocale')}
860
+ onPick={iso => applyFilter({ ...filterModel, [calBound]: iso })}
861
+ onShift={delta => setCalMonth(current => {
862
+ const next = new Date(current.year, current.month + delta, 1)
863
+ return { year: next.getFullYear(), month: next.getMonth() }
864
+ })}
865
+ />
866
+ <div className={css.funnelBoundRows}>
867
+ <span className={css.funnelBoundRow}>
868
+ <span className={css.funnelBoundKey}>{t('filterAfter')}</span>
869
+ <span className={filterModel.after.length > 0 ? `${css.funnelBoundVal} ${css.funnelBoundValSet}` : css.funnelBoundVal}>
870
+ {filterModel.after.length > 0 ? filterModel.after : '—'}
871
+ </span>
872
+ {filterModel.after.length > 0 ? (
873
+ <button type="button" className={css.funnelBoundClear} aria-label={t('filterAfter')} onClick={() => applyFilter({ ...filterModel, after: '' })}>×</button>
874
+ ) : null}
875
+ </span>
876
+ <span className={css.funnelBoundRow}>
877
+ <span className={css.funnelBoundKey}>{t('filterBefore')}</span>
878
+ <span className={filterModel.before.length > 0 ? `${css.funnelBoundVal} ${css.funnelBoundValSet}` : css.funnelBoundVal}>
879
+ {filterModel.before.length > 0 ? filterModel.before : '—'}
880
+ </span>
881
+ {filterModel.before.length > 0 ? (
882
+ <button type="button" className={css.funnelBoundClear} aria-label={t('filterBefore')} onClick={() => applyFilter({ ...filterModel, before: '' })}>×</button>
883
+ ) : null}
884
+ </span>
885
+ </div>
886
+ </div>
887
+ ) : null}
888
+ {funnelSection === 'paths' ? (
889
+ <div className={css.funnelPane}>
890
+ <input
891
+ className={css.funnelSearch}
892
+ type="search"
893
+ value={pathsQuery}
894
+ onChange={event => setPathsQuery(event.target.value)}
895
+ placeholder={t('filterPathSearch')}
896
+ aria-label={t('filterPathSearch')}
897
+ spellCheck={false}
898
+ />
899
+ <div className={css.funnelList}>
900
+ {pathTree === null ? (
901
+ <div className={css.funnelMore}>{t('loading')}</div>
902
+ ) : pathsQuery.trim().length > 0 ? (
903
+ /* Search results are FLAT — the honest shape for hits (same
904
+ argument as the filtered commit list), each row ticking a
905
+ pathspec directly: files first, then directories. */
906
+ (() => {
907
+ const hits = searchPaths(pathTree.paths, pathsQuery).slice(0, 200)
908
+ if (hits.length === 0) return <div className={css.funnelMore}>{t('historyNoMatch')}</div>
909
+ return (
910
+ <>
911
+ {hits.map(hit => (
912
+ <label key={hit.path} className={css.funnelRow}>
913
+ <TriStateCheckbox state={pathState(hit.path)} ariaLabel={hit.path} onChange={() => togglePath(hit.path)} />
914
+ {hit.isFile ? <PathFileGlyph path={hit.path} /> : <PathDirGlyph />}
915
+ <span className={css.funnelName} title={hit.path}>{hit.path}</span>
916
+ </label>
917
+ ))}
918
+ {searchPaths(pathTree.paths, pathsQuery).length > 200 ? (
919
+ <div className={css.funnelMore}>{t('filterPathsMore')}</div>
920
+ ) : null}
921
+ </>
922
+ )
923
+ })()
924
+ ) : pathTree.dirs.length === 0 ? (
925
+ <div className={css.funnelMore}>{t('noCommits')}</div>
926
+ ) : (
927
+ <PathTreeRows
928
+ dirs={pathTree.dirs}
929
+ depth={0}
930
+ expanded={expandedDirs}
931
+ stateOf={pathState}
932
+ onToggleOpen={toggleDirOpen}
933
+ onTogglePath={togglePath}
934
+ />
935
+ )}
936
+ {pathTree?.truncated === true ? (
937
+ <div className={css.funnelMore}>{t('filterPathsMore')}</div>
938
+ ) : null}
939
+ </div>
940
+ </div>
941
+ ) : null}
942
+ {/* The panel's own readout. Clearing goes through the box's grammar
943
+ like every other funnel interaction, so one query string stays
944
+ the single source of truth. */}
945
+ <div className={css.funnelFoot}>
946
+ <span className={selectedCount > 0 ? `${css.funnelFootCount} ${css.funnelFootCountOn}` : css.funnelFootCount}>
947
+ {t('filterSelected', { count: selectedCount })}
948
+ </span>
949
+ <button
950
+ type="button"
951
+ className={css.funnelFootClear}
952
+ disabled={selectedCount === 0}
953
+ onClick={() => onQueryChange('')}
954
+ >{t('filterClearAll')}</button>
955
+ </div>
956
+ </div>,
957
+ funnelHost,
958
+ ) : null}
959
+ {chips.length > 0 ? (
960
+ <div className={css.filterChips}>
961
+ {chips.map(chip => (
962
+ <span key={`${chip.kind}\x1f${chip.value}`} className={css.filterChip}>
963
+ <span className={css.filterChipLabel}>{chip.kind}:{chip.value}</span>
964
+ <button
965
+ type="button"
966
+ className={css.filterChipRemove}
967
+ aria-label={`${chip.kind} ${chip.value}`}
968
+ onClick={() => onQueryChange(serializeLogQuery(removeChip(filterModel, chip.kind, chip.value)))}
969
+ >×</button>
970
+ </span>
971
+ ))}
972
+ <button type="button" className={css.filterClear} onClick={() => onQueryChange('')}>{t('filterClearAll')}</button>
973
+ </div>
974
+ ) : null}
975
+ {commits.length === 0 ? (
976
+ <div className={css.empty}>
977
+ {loading ? t('loading') : error !== null ? error : chips.length > 0 ? t('historyNoMatch') : t('noCommits')}
978
+ </div>
979
+ ) : (
980
+ <div className={css.commits} role="listbox" aria-label={t('historyLabel')} ref={scrollRef}>
981
+ {commits.map((commit, index) => (
982
+ <CommitRow
983
+ key={commit.hash}
984
+ t={t}
985
+ commit={commit}
986
+ active={commit.hash === active}
987
+ onSelect={onSelect}
988
+ graphRow={graph.rows[index]}
989
+ graphWidth={graph.width}
990
+ layout={layout}
991
+ />
992
+ ))}
993
+ <div ref={sentinelRef} className={css.commitsSentinel} />
994
+ <div className={css.commitsFoot}>
995
+ {loadingMore ? t('loading') : hasMore ? '' : t('historyEnd')}
996
+ </div>
997
+ </div>
998
+ )}
999
+ </div>
1000
+ )
1001
+ }