@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.
- package/AGENTS.md +1 -1
- package/CHANGELOG.md +28 -0
- package/CHANGELOG_EN.md +28 -0
- package/README.md +100 -50
- package/README_EN.md +2 -1
- package/lib/client.js +6902 -6256
- package/package.json +1 -1
- package/src/client/ChangesFileTree.tsx +550 -0
- package/src/client/ChromeGlyph.tsx +27 -0
- package/src/client/CodeEditor.tsx +129 -3
- package/src/client/CommitHistory.tsx +1001 -0
- package/src/client/DiffViews.tsx +1070 -0
- package/src/client/GitWorkbenchPanel.module.css +15 -2513
- package/src/client/GitWorkbenchPanel.tsx +37 -3959
- package/src/client/PaneDivider.tsx +74 -0
- package/src/client/WorkbenchControls.tsx +1047 -0
- package/src/client/WorktreeGlyph.tsx +24 -0
- package/src/client/cm-search-theme.ts +250 -0
- package/src/client/diff-nav.ts +59 -0
- package/src/client/git-workbench-types.ts +252 -0
- package/src/client/locales.ts +14 -8
- package/src/client/row-window.ts +23 -0
- package/src/client/search-count.ts +125 -0
- package/src/client/side-rows.ts +66 -0
- package/src/client/styles/changes.css +505 -0
- package/src/client/styles/controls.css +236 -0
- package/src/client/styles/environment.css +89 -0
- package/src/client/styles/files.css +113 -0
- package/src/client/styles/history-filters.css +400 -0
- package/src/client/styles/history.css +276 -0
- package/src/client/styles/image.css +67 -0
- package/src/client/styles/operations.css +179 -0
- package/src/client/styles/shell.css +431 -0
- package/src/client/styles/themes.css +224 -0
- package/src/client/use-change-nav.ts +6 -5
- 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
|
+
}
|