@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,1047 @@
1
+ import { useEffect, useRef, useState, type Dispatch, type ReactNode, type Ref, type SetStateAction } from 'react'
2
+
3
+ import {
4
+ COLOR_MODES, STYLE_BLUR_MAX, STYLE_SCOPES, THEME_FAMILIES, entryFor,
5
+ type ColorMode, type StyleEntry, type StyleScope, type StyleSettings, type ThemeFamily,
6
+ } from './themes.ts'
7
+ import type { DiscardPreview } from './discard-flow.ts'
8
+ import { samePath, splitPath } from './worktree-view.ts'
9
+ import { BUSY_DELAY_MS, BUSY_HOLD_MS, holdRemaining, quietlyDisabled } from './op-feedback.ts'
10
+ import { ChromeGlyph } from './ChromeGlyph.tsx'
11
+ import { WorktreeGlyph } from './WorktreeGlyph.tsx'
12
+ import type { WorkbenchKey } from './locales.ts'
13
+ import type { BlockAsk, GitFile, GitOpFailure, GitOpName, GitOpPayload, GitOpResult, SyncStatus, Translate, WorktreeEntry } from './git-workbench-types.ts'
14
+ import css from './GitWorkbenchPanel.module.css'
15
+
16
+ const IMAGE_MAX_EDGE = 2560
17
+ const IMAGE_QUALITY = 0.82
18
+ const IMAGE_MAX_BYTES = 3_000_000
19
+
20
+ /**
21
+ * The one dialog in this drawer, because this is the one act it cannot undo.
22
+ *
23
+ * It never asks a generic "are you sure": the caller hands it a body that names
24
+ * the file and states which consequence is about to happen — the whole-file
25
+ * roll-back's wording derived from the host's own reading of that file, the
26
+ * block roll-back's from the pane's rows. Cancel holds the initial focus and
27
+ * Escape closes, because the default answer to an irreversible question is no.
28
+ *
29
+ * There is deliberately no "don't ask again". This is the only path in the
30
+ * drawer with nothing behind it, and a checkbox whose whole function is to
31
+ * switch off the last guard is a feature that eventually gets clicked.
32
+ */
33
+ export function DiscardConfirm({ t, body, onCancel, onConfirm }: {
34
+ t: Translate
35
+ body: string
36
+ onCancel: () => void
37
+ onConfirm: () => void
38
+ }): ReactNode {
39
+ const cancelRef = useRef<HTMLButtonElement>(null)
40
+ useEffect(() => { cancelRef.current?.focus() }, [])
41
+ useEffect(() => {
42
+ // Capture phase: while this question is open, Escape belongs to it alone
43
+ // — consumed here, before it can reach the page's other Escape handlers
44
+ // (an open picker's dismiss, the commit box's undo).
45
+ const onKey = (event: KeyboardEvent): void => {
46
+ if (event.key !== 'Escape') return
47
+ event.stopPropagation()
48
+ onCancel()
49
+ }
50
+ window.addEventListener('keydown', onKey, true)
51
+ return () => { window.removeEventListener('keydown', onKey, true) }
52
+ }, [onCancel])
53
+
54
+ return (
55
+ <div className={css.confirmScrim} onClick={onCancel}>
56
+ <div
57
+ className={css.confirmBox}
58
+ role="alertdialog"
59
+ aria-modal="true"
60
+ aria-label={t('discardTitle')}
61
+ onClick={event => event.stopPropagation()}
62
+ >
63
+ <div className={css.confirmTitle}>{t('discardTitle')}</div>
64
+ <div className={css.confirmBody}>{body}</div>
65
+ <div className={css.confirmActions}>
66
+ <button ref={cancelRef} type="button" className={css.btn} onClick={onCancel}>{t('discardCancel')}</button>
67
+ <button type="button" className={`${css.btn} ${css.btnDanger}`} onClick={onConfirm}>{t('discardConfirm')}</button>
68
+ </div>
69
+ </div>
70
+ </div>
71
+ )
72
+ }
73
+
74
+ /**
75
+ * The whole-file roll-back's consequence, in the host's own fresh reading of
76
+ * the file — the difference between "goes back to its committed content" and
77
+ * "leaves the disk and cannot come back" is the entire question the dialog
78
+ * asks, and it is exactly what a stale row gets wrong.
79
+ */
80
+ export function discardBodyText(t: Translate, file: GitFile, plan: DiscardPreview): string {
81
+ if (plan.effect === 'delete') return t('discardBodyDelete', { path: file.path })
82
+ if (plan.effect === 'unrename') return t('discardBodyUnrename', { path: file.path, previousPath: plan.previousPath ?? '' })
83
+ return t('discardBodyRestore', { path: file.path, added: file.addedLines, deleted: file.deletedLines })
84
+ }
85
+
86
+ /**
87
+ * The BLOCK roll-back's consequence, in the pane's own rows.
88
+ *
89
+ * One case outranks the tally wording: an untracked file's whole content is
90
+ * the one block, and rolling THAT block back reverse-applies the new-file
91
+ * patch — which deletes the file from the working tree, not rewrites it. The
92
+ * file row's status is the gate (a tracked file whose every line changed has
93
+ * the same block shape and only rewrites), which is why the ask's shape alone
94
+ * is not enough.
95
+ */
96
+ export function blockDiscardBodyText(t: Translate, ask: BlockAsk, file: GitFile | undefined): string {
97
+ if (file !== undefined && file.status === 'untracked' && ask.wholeFile) {
98
+ return t('blockDiscardBodyDelete', { path: ask.path })
99
+ }
100
+ return t('blockDiscardBody', { path: ask.path, added: ask.added, deleted: ask.deleted })
101
+ }
102
+
103
+ /**
104
+ * A slash-separated name that gives up its HEAD, never its tail.
105
+ *
106
+ * Paths and branches have the same shape and the same problem: the leaf is what
107
+ * distinguishes siblings, and ordinary `text-overflow: ellipsis` eats exactly
108
+ * that. `…/worktrees/fixture-07` and `…/worktrees/fixture-14` become the same
109
+ * string; so do `feature/nested/deep/parser` and `feature/nested/deep/lexer`.
110
+ * Splitting in two and letting only the head shrink keeps the half that
111
+ * answers "which one".
112
+ *
113
+ * Truncating from the other end with `direction: rtl` was the one-line version
114
+ * and the wrong one: it reorders the backslashes in a Windows path.
115
+ *
116
+ * When the name has no head to give — a bare `some-very-long-branch-name` — the
117
+ * tail ellipsises after all rather than overflowing its row; the stylesheet
118
+ * weights the shrink so that only happens once the head is gone.
119
+ */
120
+ export function Elided({ text, className, title }: {
121
+ text: string
122
+ className: string
123
+ /** Set only where the row does not already carry the full text itself. */
124
+ title?: string
125
+ }): ReactNode {
126
+ if (text.length === 0) return null
127
+ const { head, tail } = splitPath(text)
128
+ return (
129
+ <span className={`${css.elide} ${className}`} {...title === undefined ? {} : { title }}>
130
+ {head.length > 0 ? <span className={css.elideHead}>{head}</span> : null}
131
+ <span className={css.elideTail}>{tail}</span>
132
+ </span>
133
+ )
134
+ }
135
+
136
+ /**
137
+ * Which worktree the drawer is reading — the header's first control.
138
+ *
139
+ * Git allows at most one worktree per branch, so the repository's worktree list
140
+ * IS the branch list and one control covers both. This used to be a row of its
141
+ * own under the tabs, restating the branch the header had already named one
142
+ * line above; now it IS that name, and clicking it changes the view.
143
+ *
144
+ * A repository with a single worktree has nothing to choose, so it renders as
145
+ * the same chip without the menu — the identity still has to be stated, and a
146
+ * control that opens an empty list is worse than none.
147
+ */
148
+ export function SourceChip({ t, worktrees, boundPath, sessionPath, statsPath, fallbackBranch, onSwitch }: {
149
+ t: Translate
150
+ worktrees: readonly WorktreeEntry[]
151
+ boundPath: string | null
152
+ sessionPath: string | undefined
153
+ statsPath: string | undefined
154
+ /** What to name when the worktree list does not cover the active path — the
155
+ * branch the stats themselves report. */
156
+ fallbackBranch: string
157
+ onSwitch: (next: string) => void
158
+ }): ReactNode {
159
+ if (worktrees.length < 2) {
160
+ return (
161
+ <span className={css.headerBranch}>
162
+ <WorktreeGlyph />
163
+ <Elided text={branchLabel(fallbackBranch, t('noBranch'))} className={css.refValue} />
164
+ </span>
165
+ )
166
+ }
167
+ return (
168
+ <WorktreePicker
169
+ t={t} worktrees={worktrees} boundPath={boundPath}
170
+ sessionPath={sessionPath} statsPath={statsPath}
171
+ fallbackBranch={fallbackBranch} onSwitch={onSwitch}
172
+ />
173
+ )
174
+ }
175
+
176
+ /**
177
+ * The worktree menu: the ref picker's scaffold — button, filter box, scrolling
178
+ * list — over worktree rows, wearing the header chip's accent so it reads as
179
+ * the subject of the drawer rather than one more grey button.
180
+ *
181
+ * Rows carry the tree glyph when the session is bound there and a dot when it
182
+ * is the session's own; Enter takes the first match.
183
+ *
184
+ * The row gives its whole width to the branch. A dim path used to ride on the
185
+ * right to tell same-named branches in different repositories apart, but it
186
+ * cost half the row to earn that, and the half it took was the half that
187
+ * mattered: `wt/fixture-03` truncated to `wt/fixtur…` beside a path whose tail
188
+ * was repeating the name anyway. The full path stays one hover away on `title`,
189
+ * and the header spells it out the moment a row is picked.
190
+ */
191
+ function WorktreePicker({ t, worktrees, boundPath, sessionPath, statsPath, fallbackBranch, onSwitch }: {
192
+ t: Translate
193
+ worktrees: readonly WorktreeEntry[]
194
+ boundPath: string | null
195
+ sessionPath: string | undefined
196
+ statsPath: string | undefined
197
+ fallbackBranch: string
198
+ onSwitch: (next: string) => void
199
+ }): ReactNode {
200
+ const [open, setOpen] = useState(false)
201
+ const [query, setQuery] = useState('')
202
+ const rootRef = useDismissable(open, setOpen)
203
+
204
+ const needle = query.trim().toLowerCase()
205
+ const matched = needle.length === 0 ? worktrees : worktrees.filter(entry =>
206
+ entry.branch.toLowerCase().includes(needle) || entry.path.toLowerCase().includes(needle))
207
+ const first = matched[0]
208
+ const current = worktrees.find(entry => samePath(entry.path, statsPath))
209
+
210
+ const choose = (entry: WorktreeEntry): void => {
211
+ onSwitch(entry.path)
212
+ setOpen(false)
213
+ setQuery('')
214
+ }
215
+
216
+ return (
217
+ <div className={css.refPicker} ref={rootRef}>
218
+ <button
219
+ type="button"
220
+ className={`${css.refButton} ${css.headerPicker}`}
221
+ aria-expanded={open}
222
+ aria-label={t('sourceLabel')}
223
+ title={current?.path ?? statsPath}
224
+ onClick={() => setOpen(isOpen => !isOpen)}
225
+ >
226
+ <WorktreeGlyph />
227
+ <Elided text={branchLabel(current?.branch ?? fallbackBranch, t('noBranch'))} className={css.refValue} />
228
+ <span className={css.refCaret}>▾</span>
229
+ </button>
230
+ {open ? (
231
+ <div className={css.refPop}>
232
+ <input
233
+ className={css.refSearch}
234
+ autoFocus
235
+ value={query}
236
+ placeholder={t('refSearch')}
237
+ onChange={event => setQuery(event.target.value)}
238
+ onKeyDown={event => { if (event.key === 'Enter' && first !== undefined) choose(first) }}
239
+ />
240
+ <div className={css.refList} role="listbox" aria-label={t('sourceLabel')}>
241
+ {matched.map(entry => {
242
+ const active = samePath(entry.path, statsPath)
243
+ return (
244
+ <button
245
+ key={entry.path}
246
+ type="button"
247
+ role="option"
248
+ aria-selected={active}
249
+ className={active ? `${css.refRow} ${css.refRowActive}` : css.refRow}
250
+ title={entry.path}
251
+ onClick={() => choose(entry)}
252
+ >
253
+ {samePath(boundPath, entry.path) ? <WorktreeGlyph /> : <span className={css.refRowSpacer} />}
254
+ <Elided text={branchLabel(entry.branch, t('noBranch'))} className={css.refRowName} />
255
+ {samePath(sessionPath, entry.path) ? <span className={css.wtCurrent}>●</span> : null}
256
+ </button>
257
+ )
258
+ })}
259
+ {matched.length === 0 ? <div className={css.refEmpty}>{t('refNone')}</div> : null}
260
+ </div>
261
+ <div className={css.refFoot}>{t('refCount', { shown: matched.length, total: worktrees.length })}</div>
262
+ </div>
263
+ ) : null}
264
+ </div>
265
+ )
266
+ }
267
+
268
+ /**
269
+ * Settings: colour mode, palette, background image and custom CSS.
270
+ *
271
+ * Mode defaults to `system`, which follows dsh (`body[data-ds-dark-theme]`);
272
+ * light and dark pin the drawer even when the host is the other scheme. The
273
+ * palette is a pure token swap — every drawer colour resolves through the same
274
+ * names, so nothing but the values differ between families.
275
+ *
276
+ * The background and the stylesheet are per-scope, and the scope switch is the
277
+ * only control in here that changes what an edit WRITES rather than what it
278
+ * looks like, so it sits at the top of that section rather than beside a field.
279
+ *
280
+ * This was a companion card portalled into the overlay to the LEFT of the
281
+ * drawer, so a palette could be previewed against the diff without covering it.
282
+ * It is a popover now, hung under its own gear: a card floating out in the page
283
+ * beside the drawer read as a second window rather than as this drawer's
284
+ * settings, and it was the one menu here that did not behave like the rest.
285
+ * The preview still works — the popover covers the top of the diff, not all of
286
+ * it, and the drawer repaints live underneath.
287
+ */
288
+ export function SettingsMenu({ t, mode, family, onMode, onFamily, settings, onStyle }: {
289
+ t: Translate
290
+ mode: ColorMode
291
+ family: ThemeFamily
292
+ onMode: (next: ColorMode) => void
293
+ onFamily: (next: ThemeFamily) => void
294
+ settings: StyleSettings
295
+ onStyle: (scope: StyleScope, entry: StyleEntry, persist: boolean) => Promise<{ ok: boolean; error?: string }>
296
+ }): ReactNode {
297
+ const [open, setOpen] = useState(false)
298
+ const [scope, setScope] = useState<StyleScope>('project')
299
+ /** Editor buffer for the stylesheet, so typing does not restyle on every key. */
300
+ const [draft, setDraft] = useState<string | null>(null)
301
+ const [note, setNote] = useState('')
302
+ const rootRef = useDismissable(open, setOpen)
303
+ const imageFileRef = useRef<HTMLInputElement>(null)
304
+ const cssFileRef = useRef<HTMLInputElement>(null)
305
+
306
+ // The buffer belongs to one scope; switching scope must show that scope's
307
+ // stylesheet rather than carry the other one's text across.
308
+ useEffect(() => { setDraft(null); setNote('') }, [scope])
309
+
310
+ // The default scope is `project`, chosen before the host has said whether
311
+ // there IS one. Outside a repository it has nothing to key by, so the menu
312
+ // falls back rather than pointing every control at a scope that refuses
313
+ // every write.
314
+ useEffect(() => {
315
+ if (settings.repoRoot === null) setScope('global')
316
+ }, [settings.repoRoot])
317
+
318
+ const entry = entryFor(settings, scope)
319
+ const cssText = draft ?? entry.css
320
+
321
+ /**
322
+ * Apply a change to the scope being edited.
323
+ * @param patch - the fields that changed.
324
+ * @param persist - whether to store it; false previews without a file write.
325
+ */
326
+ const write = (patch: Partial<StyleEntry>, persist = true): void => {
327
+ setNote('')
328
+ void onStyle(scope, { ...entry, ...patch }, persist).then(result => {
329
+ if (!result.ok) setNote(result.error ?? t('styleFailed'))
330
+ })
331
+ }
332
+
333
+ /**
334
+ * Resample a chosen image and store it.
335
+ *
336
+ * Downscaling in the browser is what keeps this practical: a phone photograph
337
+ * is 4-6MB, far past what is worth carrying on every drawer open, and none of
338
+ * that detail survives a blur anyway.
339
+ * @param file - the picked file.
340
+ */
341
+ const takeImage = async (file: File): Promise<void> => {
342
+ setNote(t('bgWorking'))
343
+ try {
344
+ const bitmap = await createImageBitmap(file)
345
+ const scale = Math.min(1, IMAGE_MAX_EDGE / Math.max(bitmap.width, bitmap.height))
346
+ const canvas = document.createElement('canvas')
347
+ canvas.width = Math.round(bitmap.width * scale)
348
+ canvas.height = Math.round(bitmap.height * scale)
349
+ const context = canvas.getContext('2d')
350
+ if (context === null) { setNote(t('bgFailed')); return }
351
+ context.drawImage(bitmap, 0, 0, canvas.width, canvas.height)
352
+ bitmap.close()
353
+ const url = canvas.toDataURL('image/jpeg', IMAGE_QUALITY)
354
+ if (url.length > IMAGE_MAX_BYTES) { setNote(t('bgTooBig')); return }
355
+ setNote('')
356
+ write({ image: url })
357
+ } catch {
358
+ // A file the decoder refuses (corrupt, or an image codec this browser
359
+ // lacks) is a user mistake, not a fault worth propagating.
360
+ setNote(t('bgFailed'))
361
+ }
362
+ }
363
+
364
+ const modeLabel: Record<ColorMode, string> = {
365
+ system: t('modeSystem'), light: t('modeLight'), dark: t('modeDark'),
366
+ }
367
+ const modeChip: Record<ColorMode, string> = {
368
+ system: css.chipSystem, light: css.chipLight, dark: css.chipDark,
369
+ }
370
+ const scopeLabel: Record<StyleScope, string> = {
371
+ project: t('scopeProject'), global: t('scopeGlobal'),
372
+ }
373
+ const projectAvailable = settings.repoRoot !== null
374
+
375
+ return (
376
+ <div className={css.theme} ref={rootRef}>
377
+ <button
378
+ type="button"
379
+ className={`${css.btn} ${css.btnIcon}`}
380
+ aria-expanded={open}
381
+ aria-label={t('settings')} title={t('settings')}
382
+ onClick={() => setOpen(value => !value)}
383
+ ><ChromeGlyph of="settings" /></button>
384
+ {open ? (
385
+ <div className={`${css.refPop} ${css.settingsPop}`} data-gs-part="settings">
386
+ {/* The popover positions and clips; this is the padded body that
387
+ scrolls inside it. Collapsing the two put every section flush
388
+ against the card's edge. */}
389
+ <div className={css.themeRail} data-gs-part="theme-rail">
390
+ <div className={css.themeGroup}>
391
+ <span className={css.themeLabel}>{t('themeMode')}</span>
392
+ <div className={css.segmented} role="group" aria-label={t('themeMode')}>
393
+ {COLOR_MODES.map(option => (
394
+ <button
395
+ key={option}
396
+ type="button"
397
+ aria-pressed={mode === option}
398
+ className={mode === option ? `${css.segment} ${css.segmentActive}` : css.segment}
399
+ onClick={() => onMode(option)}
400
+ >
401
+ <span className={`${css.segmentChip} ${modeChip[option]}`} aria-hidden="true" />
402
+ {modeLabel[option]}
403
+ </button>
404
+ ))}
405
+ </div>
406
+ </div>
407
+
408
+ <div className={css.themeGroup}>
409
+ <span className={css.themeLabel}>{t('themePalette')}</span>
410
+ {THEME_FAMILIES.map(option => (
411
+ <button
412
+ key={option.id}
413
+ type="button"
414
+ aria-pressed={family === option.id}
415
+ className={family === option.id ? `${css.paletteRow} ${css.paletteRowActive}` : css.paletteRow}
416
+ onClick={() => onFamily(option.id)}
417
+ >
418
+ <span className={css.swatch} aria-hidden="true">
419
+ {option.swatch.map(color => <span key={color} style={{ background: color }} />)}
420
+ </span>
421
+ {option.label}
422
+ </button>
423
+ ))}
424
+ </div>
425
+
426
+ <div className={css.themeGroup}>
427
+ <span className={css.themeLabel}>{t('themeScope')}</span>
428
+ <div className={css.scopeRow} role="group" aria-label={t('themeScope')}>
429
+ {STYLE_SCOPES.map(option => (
430
+ <button
431
+ key={option}
432
+ type="button"
433
+ aria-pressed={scope === option}
434
+ disabled={option === 'project' && !projectAvailable}
435
+ className={scope === option ? `${css.scopeBtn} ${css.scopeBtnActive}` : css.scopeBtn}
436
+ onClick={() => setScope(option)}
437
+ >{scopeLabel[option]}</button>
438
+ ))}
439
+ </div>
440
+ <span className={css.scopeHint}>
441
+ {scope === 'global' ? t('scopeGlobalHint')
442
+ : projectAvailable ? settings.repoRoot
443
+ : t('scopeNoRepo')}
444
+ </span>
445
+ </div>
446
+
447
+ <div className={css.themeGroup}>
448
+ <span className={css.themeLabel}>{t('themeBackground')}</span>
449
+ <div
450
+ className={entry.image.length > 0 ? css.bgPreview : `${css.bgPreview} ${css.bgEmpty}`}
451
+ style={entry.image.length > 0 ? { backgroundImage: `url("${entry.image}")` } : undefined}
452
+ >{entry.image.length > 0 ? null : t('bgNone')}</div>
453
+ <div className={css.themeRowSplit}>
454
+ <button type="button" className={css.miniBtn} onClick={() => imageFileRef.current?.click()}>{t('bgChoose')}</button>
455
+ {entry.image.length > 0
456
+ ? <button type="button" className={css.miniBtn} onClick={() => write({ image: '' })}>{t('bgClear')}</button>
457
+ : null}
458
+ </div>
459
+ <input
460
+ ref={imageFileRef}
461
+ type="file"
462
+ accept="image/*"
463
+ hidden
464
+ onChange={event => {
465
+ const file = event.target.files?.[0]
466
+ // Clearing the input is what lets the same file be picked twice
467
+ // after a failure; a change event fires only on a NEW value.
468
+ event.target.value = ''
469
+ if (file !== undefined) void takeImage(file)
470
+ }}
471
+ />
472
+ {entry.image.length > 0 ? (
473
+ <>
474
+ <label className={css.sliderRow}>
475
+ {t('bgBlur')}
476
+ <input
477
+ type="range" min={0} max={STYLE_BLUR_MAX} step={1} value={entry.blur}
478
+ onChange={event => write({ blur: Number(event.target.value) }, false)}
479
+ onPointerUp={() => write({})}
480
+ onKeyUp={() => write({})}
481
+ />
482
+ <span className={css.sliderValue}>{entry.blur}px</span>
483
+ </label>
484
+ <label className={css.sliderRow}>
485
+ {t('bgVeil')}
486
+ <input
487
+ type="range" min={0} max={100} step={1} value={entry.veil}
488
+ onChange={event => write({ veil: Number(event.target.value) }, false)}
489
+ onPointerUp={() => write({})}
490
+ onKeyUp={() => write({})}
491
+ />
492
+ <span className={css.sliderValue}>{entry.veil}%</span>
493
+ </label>
494
+ </>
495
+ ) : null}
496
+ </div>
497
+
498
+ <div className={css.themeGroup}>
499
+ <span className={css.themeLabel}>{t('themeCss')}</span>
500
+ <textarea
501
+ className={css.cssArea}
502
+ spellCheck={false}
503
+ placeholder={t('cssPlaceholder')}
504
+ value={cssText}
505
+ onChange={event => setDraft(event.target.value)}
506
+ />
507
+ <div className={css.themeRowSplit}>
508
+ <button type="button" className={css.miniBtn} onClick={() => cssFileRef.current?.click()}>{t('cssImport')}</button>
509
+ <button
510
+ type="button"
511
+ className={`${css.miniBtn} ${css.miniBtnPrimary}`}
512
+ disabled={draft === null}
513
+ onClick={() => { write({ css: cssText }); setDraft(null) }}
514
+ >{t('cssApply')}</button>
515
+ </div>
516
+ <input
517
+ ref={cssFileRef}
518
+ type="file"
519
+ accept=".css,text/css"
520
+ hidden
521
+ onChange={event => {
522
+ const file = event.target.files?.[0]
523
+ event.target.value = ''
524
+ if (file !== undefined) void file.text().then(text => setDraft(text))
525
+ }}
526
+ />
527
+ {draft === null ? null : <span className={css.themeDirty}>{t('cssUnapplied')}</span>}
528
+ </div>
529
+
530
+ {note.length > 0 ? <span className={css.themeNote}>{note}</span> : null}
531
+ </div>
532
+ </div>
533
+ ) : null}
534
+ </div>
535
+ )
536
+ }
537
+
538
+ /**
539
+ * Close a popover on the two gestures every user already expects: a click
540
+ * outside it, and Escape. Both arrive on `document` rather than on the
541
+ * popover's own subtree, so neither can be a handler on the element.
542
+ * @param open - whether the popover is showing; nothing is bound while closed.
543
+ * @param setOpen - the state setter, stable, so the effect binds once per open.
544
+ * @returns the ref to put on the element that counts as "inside".
545
+ */
546
+ function useDismissable(open: boolean, setOpen: Dispatch<SetStateAction<boolean>>): Ref<HTMLDivElement> {
547
+ const rootRef = useRef<HTMLDivElement>(null)
548
+ useEffect(() => {
549
+ if (!open) return
550
+ const onDown = (event: MouseEvent): void => {
551
+ if (rootRef.current !== null && !rootRef.current.contains(event.target as Node)) setOpen(false)
552
+ }
553
+ const onKey = (event: KeyboardEvent): void => { if (event.key === 'Escape') setOpen(false) }
554
+ // Bound on the next tick: the click that opened the popover is still
555
+ // travelling, and would otherwise close it again immediately.
556
+ const id = window.setTimeout(() => document.addEventListener('mousedown', onDown), 0)
557
+ document.addEventListener('keydown', onKey)
558
+ return () => {
559
+ window.clearTimeout(id)
560
+ document.removeEventListener('mousedown', onDown)
561
+ document.removeEventListener('keydown', onKey)
562
+ }
563
+ }, [open, setOpen])
564
+ return rootRef
565
+ }
566
+
567
+ /**
568
+ * Ref picker built for a repository with hundreds of branches.
569
+ *
570
+ * A chip row cannot do this job — it grows without bound and gives every branch
571
+ * the same weight — and a native select is no better once the list is long
572
+ * enough to scroll past what anyone will read. This is the control git tooling
573
+ * converges on instead: one button showing the current ref, opening a filter box
574
+ * over a scrolling list.
575
+ *
576
+ * Two things make it useful before a character is typed. Branches arrive
577
+ * most-recently-committed first, so the handful actually being worked on are at
578
+ * the top; and those that have a worktree are grouped above the rest, because a
579
+ * checked-out branch is the likeliest thing to want. Enter takes the first
580
+ * match, so a distinctive substring plus Enter reaches any branch in the list.
581
+ */
582
+ /** Sentinel ref meaning "walk every ref" — same string the host special-cases
583
+ * into `--all`. A real ref cannot begin with a dash, so it collides with
584
+ * nothing; defined separately on both halves (client bundles import no host
585
+ * values), tied by this comment and the probe. */
586
+ const ALL_REFS = '--all'
587
+
588
+ export function RefPicker({ t, label, value, branches, worktreeBranches, truncated, onPick, allLabel }: {
589
+ t: Translate
590
+ label: string
591
+ value: string
592
+ branches: readonly string[]
593
+ /** Branches that have a worktree — grouped first and marked. */
594
+ worktreeBranches: readonly string[]
595
+ /** Whether the host cut the branch list short. */
596
+ truncated: boolean
597
+ onPick: (ref: string) => void
598
+ /** When set, an "all branches" entry is offered above the list and shown for
599
+ * the {@link ALL_REFS} sentinel — the history picker's answer to "search
600
+ * must not require knowing which branch holds the commit". */
601
+ allLabel?: string
602
+ }): ReactNode {
603
+ const [open, setOpen] = useState(false)
604
+ const [query, setQuery] = useState('')
605
+ const rootRef = useDismissable(open, setOpen)
606
+
607
+ const needle = query.trim().toLowerCase()
608
+ const matched = needle.length === 0 ? branches : branches.filter(ref => ref.toLowerCase().includes(needle))
609
+ const checkedOut = matched.filter(ref => worktreeBranches.includes(ref))
610
+ const rest = matched.filter(ref => !worktreeBranches.includes(ref))
611
+ const first = checkedOut[0] ?? rest[0]
612
+
613
+ const choose = (ref: string): void => {
614
+ onPick(ref)
615
+ setOpen(false)
616
+ setQuery('')
617
+ }
618
+
619
+ const row = (ref: string, inWorktree: boolean): ReactNode => (
620
+ <button
621
+ key={ref}
622
+ type="button"
623
+ role="option"
624
+ aria-selected={ref === value}
625
+ className={ref === value ? `${css.refRow} ${css.refRowActive}` : css.refRow}
626
+ title={ref}
627
+ onClick={() => choose(ref)}
628
+ >
629
+ {inWorktree ? <WorktreeGlyph /> : <span className={css.refRowSpacer} />}
630
+ <Elided text={ref} className={css.refRowName} />
631
+ </button>
632
+ )
633
+
634
+ return (
635
+ <div className={css.refPicker} ref={rootRef}>
636
+ <span className={css.refLabel}>{label}</span>
637
+ <button
638
+ type="button"
639
+ className={css.refButton}
640
+ aria-expanded={open}
641
+ title={value.length > 0 ? value : undefined}
642
+ onClick={() => setOpen(isOpen => !isOpen)}
643
+ >
644
+ <Elided text={value === ALL_REFS && allLabel !== undefined ? allLabel : (value.length > 0 ? value : '—')} className={css.refValue} />
645
+ <span className={css.refCaret}>▾</span>
646
+ </button>
647
+ {open ? (
648
+ <div className={css.refPop}>
649
+ <input
650
+ className={css.refSearch}
651
+ autoFocus
652
+ value={query}
653
+ placeholder={t('refSearch')}
654
+ onChange={event => setQuery(event.target.value)}
655
+ onKeyDown={event => { if (event.key === 'Enter' && first !== undefined) choose(first) }}
656
+ />
657
+ <div className={css.refList} role="listbox" aria-label={label}>
658
+ {allLabel !== undefined && (needle.length === 0 || allLabel.toLowerCase().includes(needle)) ? (
659
+ <button
660
+ type="button"
661
+ role="option"
662
+ aria-selected={value === ALL_REFS}
663
+ className={value === ALL_REFS ? `${css.refRow} ${css.refRowActive}` : css.refRow}
664
+ title={allLabel}
665
+ onClick={() => choose(ALL_REFS)}
666
+ >
667
+ <span className={css.refRowSpacer} />
668
+ <Elided text={allLabel} className={css.refRowName} />
669
+ </button>
670
+ ) : null}
671
+ {checkedOut.length > 0 && rest.length > 0 ? <div className={css.refGroup}>{t('refWorktrees')}</div> : null}
672
+ {checkedOut.map(ref => row(ref, true))}
673
+ {checkedOut.length > 0 && rest.length > 0 ? <div className={css.refGroup}>{t('refBranches')}</div> : null}
674
+ {rest.map(ref => row(ref, false))}
675
+ {matched.length === 0 && !(allLabel !== undefined && needle.length > 0 && allLabel.toLowerCase().includes(needle)) ? <div className={css.refEmpty}>{t('refNone')}</div> : null}
676
+ </div>
677
+ <div className={css.refFoot}>
678
+ {t('refCount', { shown: matched.length, total: branches.length })}
679
+ {truncated ? ` · ${t('refTruncated')}` : ''}
680
+ </div>
681
+ </div>
682
+ ) : null}
683
+ </div>
684
+ )
685
+ }
686
+
687
+ /**
688
+ * Compare tab controls: the two refs, in reading order.
689
+ *
690
+ * Both sides list the same refs — comparing a branch against itself is possible
691
+ * to express and simply reports nothing, which is clearer than hiding it.
692
+ */
693
+ export function CompareBar({ t, branches, worktreeBranches, truncated, baseRef, headRef, onBaseRef, onHeadRef }: {
694
+ t: Translate
695
+ branches: readonly string[]
696
+ worktreeBranches: readonly string[]
697
+ truncated: boolean
698
+ baseRef: string
699
+ headRef: string
700
+ onBaseRef: (ref: string) => void
701
+ onHeadRef: (ref: string) => void
702
+ }): ReactNode {
703
+ if (branches.length === 0) return <div className={css.compareBar}>{t('noBranches')}</div>
704
+ return (
705
+ <div className={css.compareBar}>
706
+ <RefPicker
707
+ t={t} label={t('compareBase')} value={baseRef}
708
+ branches={branches} worktreeBranches={worktreeBranches} truncated={truncated}
709
+ onPick={onBaseRef}
710
+ />
711
+ <span className={css.compareArrow}>→</span>
712
+ <RefPicker
713
+ t={t} label={t('compareHead')} value={headRef}
714
+ branches={branches} worktreeBranches={worktreeBranches} truncated={truncated}
715
+ onPick={onHeadRef}
716
+ />
717
+ </div>
718
+ )
719
+ }
720
+
721
+ /* ---------- write operations ---------- */
722
+
723
+ /**
724
+ * Failures whose sentence is the whole story. Everything else shows git's own
725
+ * text underneath, because the classification is a hint about what to do next
726
+ * and the raw message is the evidence for it — when the hint is `unknown` it is
727
+ * the only thing left that helps at all.
728
+ *
729
+ * These two are excluded because their detail is never informative and is often
730
+ * actively misleading: git says nothing useful about an empty index, so what
731
+ * lands in stderr is whatever a hook wrapper happened to print. A user reading
732
+ * "nothing staged" followed by a lefthook config warning learns only that
733
+ * something else is broken, which is not true.
734
+ */
735
+ const SELF_EXPLANATORY: ReadonlySet<GitOpFailure> = new Set(['nothing-to-commit', 'no-upstream'])
736
+
737
+ /** What to tell the user about a finished operation. */
738
+ export function opMessage(t: Translate, op: GitOpName, result: GitOpResult): string {
739
+ if (result.ok) return t(`op.ok.${op}`)
740
+ const failure = result.failure ?? 'unknown'
741
+ const reason = t(`op.fail.${failure}`)
742
+ if (SELF_EXPLANATORY.has(failure)) return reason
743
+ const detail = (result.error ?? '').trim()
744
+ return detail.length > 0 ? `${reason}\n${detail}` : reason
745
+ }
746
+
747
+ /**
748
+ * Glyphs for the three network actions, on Primer's 16px grid.
749
+ *
750
+ * They sit BESIDE the labels rather than replacing them. The complaint that
751
+ * started this was that Fetch/Pull/Push do not say what they do — icon-only
752
+ * would answer it by removing the half that is unambiguous. What an icon adds
753
+ * is recognition at a glance: down is work arriving, up is work leaving, and
754
+ * the ring is the one that only reads a remote without changing anything here.
755
+ */
756
+ const SYNC_GLYPH = {
757
+ // Circular arrows: VS Code's and IDEA's shared sign for "refresh what I know
758
+ // about the remote". Nothing in the working tree moves.
759
+ fetch: 'M8 2.5a5.5 5.5 0 0 0-4.9 3 .75.75 0 0 1-1.34-.68A7 7 0 0 1 13.5 5.2V3.75a.75.75 0 0 1 1.5 0v3.5a.75.75 0 0 1-.75.75h-3.5a.75.75 0 0 1 0-1.5h1.86A5.5 5.5 0 0 0 8 2.5Zm-6.25 6a.75.75 0 0 1 .75.75v3.5a.75.75 0 0 1-1.5 0v-3.5a.75.75 0 0 1 .75-.75Zm.75 1.5h3.5a.75.75 0 0 1 0 1.5H4.14A5.5 5.5 0 0 0 12.9 10.5a.75.75 0 0 1 1.34.68A7 7 0 0 1 2.5 10.8v-.05a.75.75 0 0 1 0-.75Z',
760
+ // Down into a floor line: commits arriving from the remote onto this branch.
761
+ pull: 'M8 1.75a.75.75 0 0 1 .75.75v6.44l2.22-2.22a.75.75 0 1 1 1.06 1.06l-3.5 3.5a.75.75 0 0 1-1.06 0l-3.5-3.5a.75.75 0 0 1 1.06-1.06l2.22 2.22V2.5A.75.75 0 0 1 8 1.75ZM2.75 12.5h10.5a.75.75 0 0 1 0 1.5H2.75a.75.75 0 0 1 0-1.5Z',
762
+ // Up off a floor line: the same arrow mirrored, because the pair only reads
763
+ // as a direction if it is the same arrow.
764
+ push: 'M7.47 1.97a.75.75 0 0 1 1.06 0l3.5 3.5a.75.75 0 0 1-1.06 1.06L8.75 4.31v6.44a.75.75 0 0 1-1.5 0V4.31L5.03 6.53a.75.75 0 0 1-1.06-1.06l3.5-3.5ZM2.75 12.5h10.5a.75.75 0 0 1 0 1.5H2.75a.75.75 0 0 1 0-1.5Z',
765
+ } as const
766
+
767
+ function SyncGlyph({ of }: { of: keyof typeof SYNC_GLYPH }): ReactNode {
768
+ return (
769
+ <svg width="13" height="13" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
770
+ <path d={SYNC_GLYPH[of]} />
771
+ </svg>
772
+ )
773
+ }
774
+
775
+
776
+
777
+ const PULL_MODES = ['ff-only', 'rebase', 'merge'] as const
778
+ type PullMode = typeof PULL_MODES[number]
779
+
780
+ /** Each strategy's label key, so the trigger and the menu cannot disagree. */
781
+ const PULL_MODE_KEY: Record<PullMode, WorkbenchKey> = {
782
+ 'ff-only': 'pullFf',
783
+ rebase: 'pullRebase',
784
+ merge: 'pullMerge',
785
+ }
786
+
787
+ /**
788
+ * Pull strategy, in the drawer's own menu idiom.
789
+ *
790
+ * This was a native `<select>`, justified as "a three-way choice used rarely".
791
+ * The cost was not the frequency: a native popup paints in the OS palette, so
792
+ * it was the one control in the drawer that ignored `data-gs-theme` — system
793
+ * blue over Solarized, square corners in a row of pills. Reusing the ref
794
+ * picker's button and popover makes it the same idiom as the drawer's other
795
+ * menu rather than a second one.
796
+ */
797
+ function SyncModePicker({ t, value, disabled, quiet, onPick }: {
798
+ t: Translate
799
+ value: PullMode
800
+ disabled: boolean
801
+ /** Disabled only by an operation too young to report: refuse, but do not dim. */
802
+ quiet: boolean
803
+ onPick: (mode: PullMode) => void
804
+ }): ReactNode {
805
+ const [open, setOpen] = useState(false)
806
+ const rootRef = useDismissable(open, setOpen)
807
+
808
+ return (
809
+ <div className={css.refPicker} ref={rootRef}>
810
+ <button
811
+ type="button"
812
+ className={css.refButton}
813
+ aria-expanded={open}
814
+ aria-label={t('pullModeLabel')}
815
+ disabled={disabled}
816
+ data-quiet={quiet ? '' : undefined}
817
+ onClick={() => setOpen(isOpen => !isOpen)}
818
+ >
819
+ <span className={`${css.elide} ${css.refValue}`}><span className={css.elideTail}>{t(PULL_MODE_KEY[value])}</span></span>
820
+ <span className={css.refCaret}>▾</span>
821
+ </button>
822
+ {open ? (
823
+ <div className={`${css.refPop} ${css.menuPop}`} role="listbox" aria-label={t('pullModeLabel')}>
824
+ {PULL_MODES.map(pullMode => (
825
+ <button
826
+ key={pullMode}
827
+ type="button"
828
+ role="option"
829
+ aria-selected={pullMode === value}
830
+ className={pullMode === value ? `${css.refRow} ${css.refRowActive}` : css.refRow}
831
+ onClick={() => { onPick(pullMode); setOpen(false) }}
832
+ >{t(PULL_MODE_KEY[pullMode])}</button>
833
+ ))}
834
+ </div>
835
+ ) : null}
836
+ </div>
837
+ )
838
+ }
839
+
840
+ /**
841
+ * Whether an in-flight operation has run long enough to be worth showing.
842
+ *
843
+ * True only after `active` has held for `delay`, and then for at least `hold`
844
+ * however quickly it ends — so a fast operation never paints, and a slow one
845
+ * never blinks. See `op-feedback.ts` for why the appearance is paced and the
846
+ * guard is not.
847
+ */
848
+ function useSustained(active: boolean, delay = BUSY_DELAY_MS, hold = BUSY_HOLD_MS): boolean {
849
+ const [shown, setShown] = useState(false)
850
+ const shownAt = useRef(0)
851
+ useEffect(() => {
852
+ if (active === shown) return undefined
853
+ const wait = active ? delay : holdRemaining(shownAt.current, Date.now(), hold)
854
+ const id = setTimeout(() => {
855
+ if (active) shownAt.current = Date.now()
856
+ setShown(active)
857
+ }, wait)
858
+ return () => { clearTimeout(id) }
859
+ }, [active, shown, delay, hold])
860
+ return shown
861
+ }
862
+
863
+ /**
864
+ * Fetch / pull / push, with the divergence they act on.
865
+ *
866
+ * Hidden entirely when the repository has no remote: three buttons that can only
867
+ * fail are worse than no buttons. Pull carries its own strategy picker rather
868
+ * than reading `pull.rebase`, so the button's label is what actually runs.
869
+ *
870
+ * The three used to be one grey pill each, distinguished by their word and a
871
+ * 13px glyph — indistinguishable at a glance because they carried the same
872
+ * amount of information, which was none. The counts have moved off the
873
+ * divergence pills and INTO the two buttons that act on them, so the control
874
+ * that can do something about the drift is also the one that reports it. Fetch
875
+ * stays quiet in every state: it writes nothing, so it never has news.
876
+ */
877
+ export function SyncBar({ t, sync, busy, onOp }: {
878
+ t: Translate
879
+ sync: SyncStatus
880
+ busy: GitOpName | null
881
+ onOp: (op: GitOpName, payload?: GitOpPayload) => void
882
+ }): ReactNode {
883
+ const [mode, setMode] = useState<PullMode>('ff-only')
884
+ const running = busy !== null
885
+ const noUpstream = sync.upstream === null
886
+ // Every tick stages through git, so this bar was fading out and back on each
887
+ // one. The buttons still refuse the click from the first frame; only saying
888
+ // so waits until there is something worth saying.
889
+ const sustained = useSustained(running)
890
+ const quiet = quietlyDisabled(running, sustained, false)
891
+
892
+ /** Push is the branch's first — the one case where it is the whole point of
893
+ * the bar, so it is the one case that gets the solid fill. */
894
+ const pushClass = noUpstream ? `${css.btn} ${css.btnPrimary}`
895
+ : sync.ahead > 0 ? `${css.btn} ${css.btnAhead}`
896
+ : css.btn
897
+
898
+ return (
899
+ <div className={css.syncBar} role="group" aria-label={t('syncLabel')}>
900
+ <span className={css.syncUpstream} title={sync.upstream ?? undefined}>
901
+ {noUpstream ? t('noUpstream') : sync.upstream}
902
+ </span>
903
+ {sync.behind === 0 && sync.ahead === 0 && !noUpstream
904
+ ? <span className={css.syncLevel}>{t('upToDate')}</span>
905
+ : null}
906
+
907
+ <span className={css.syncSpacer} />
908
+
909
+ <button
910
+ type="button" className={css.btn} disabled={running} data-quiet={quiet ? '' : undefined}
911
+ onClick={() => onOp('fetch')}
912
+ ><SyncGlyph of="fetch" />{busy === 'fetch' ? t('opRunning') : t('fetch')}</button>
913
+
914
+ {/* The strategy is Pull's own argument, so it is welded to Pull. Loose
915
+ between Fetch and Pull it read as a third peer action. */}
916
+ <span className={css.pullGroup}>
917
+ <SyncModePicker t={t} value={mode} disabled={running} quiet={quiet} onPick={setMode} />
918
+ <button
919
+ type="button"
920
+ className={sync.behind > 0 ? `${css.btn} ${css.btnBehind}` : css.btn}
921
+ disabled={running || noUpstream}
922
+ // No upstream is a reason of Pull's own, so that dim stays put.
923
+ data-quiet={quietlyDisabled(running, sustained, noUpstream) ? '' : undefined}
924
+ title={noUpstream ? t('noUpstreamHint') : undefined}
925
+ onClick={() => onOp('pull', { mode })}
926
+ >
927
+ <SyncGlyph of="pull" />
928
+ {busy === 'pull' ? t('opRunning') : t('pull')}
929
+ {sync.behind > 0 ? <span className={css.btnCount}>{sync.behind}</span> : null}
930
+ </button>
931
+ </span>
932
+
933
+ <button
934
+ type="button" className={pushClass} disabled={running} data-quiet={quiet ? '' : undefined}
935
+ // The first push of a branch has no upstream yet — that is the case
936
+ // `--set-upstream` exists for, so it must not be disabled here.
937
+ title={noUpstream ? t('pushSetUpstream') : undefined}
938
+ onClick={() => onOp('push')}
939
+ >
940
+ <SyncGlyph of="push" />
941
+ {busy === 'push' ? t('opRunning') : noUpstream ? t('publish') : t('push')}
942
+ {sync.ahead > 0 && !noUpstream ? <span className={css.btnCount}>{sync.ahead}</span> : null}
943
+ </button>
944
+ </div>
945
+ )
946
+ }
947
+
948
+ /**
949
+ * The commit box: a message, and what it would commit.
950
+ *
951
+ * Commit is disabled with nothing staged rather than quietly falling back to
952
+ * committing the whole worktree. The drawer shows a staging area, and a button
953
+ * that ignores it would make that display a lie.
954
+ */
955
+ export function CommitBox({ t, files, busy, onOp, message, onMessage, amend, onAmend }: {
956
+ t: Translate
957
+ files: readonly GitFile[]
958
+ busy: GitOpName | null
959
+ onOp: (op: GitOpName, payload?: GitOpPayload) => Promise<GitOpResult>
960
+ /** Lifted to the panel: this box unmounts on a tab switch, the draft must not. */
961
+ message: string
962
+ onMessage: (next: string) => void
963
+ amend: boolean
964
+ onAmend: (next: boolean) => void
965
+ }): ReactNode {
966
+ const setMessage = onMessage
967
+ const setAmend = onAmend
968
+ const stagedCount = files.filter(file => file.staged === true).length
969
+ const running = busy !== null
970
+ // Amending re-uses the previous commit, so it is the one case where an empty
971
+ // index is still a legitimate commit (a message-only reword).
972
+ const needsStaged = stagedCount === 0 && !amend
973
+ const needsMessage = message.trim().length === 0
974
+ const canCommit = !needsMessage && !needsStaged && !running
975
+ // A disabled button that does not say why reads as broken; the staging half of
976
+ // that is stated permanently by the lead line above, so only the message case
977
+ // needs the title.
978
+ const blocked = needsMessage ? t('commitNeedMessage') : undefined
979
+
980
+ const commit = (): void => {
981
+ if (!canCommit) return
982
+ void onOp('commit', { message, amend }).then(result => {
983
+ // Keep the message on failure: it is the user's text, and retyping a
984
+ // commit message because the index was empty is a bad way to learn that.
985
+ if (result.ok) { setMessage(''); setAmend(false) }
986
+ })
987
+ }
988
+
989
+ return (
990
+ <div className={css.commitBox}>
991
+ {/* The one instruction the tick model needs, stated once where the action
992
+ lives. A blocker that appears only when the index is empty reads as an
993
+ error and arrives after the confusion it explains. */}
994
+ <p className={css.commitLead} data-gs-part="commit-lead">{t('commitLead')}</p>
995
+ <textarea
996
+ className={css.commitMessage}
997
+ value={message}
998
+ rows={2}
999
+ placeholder={t('commitPlaceholder')}
1000
+ aria-label={t('commitPlaceholder')}
1001
+ disabled={running}
1002
+ onChange={event => setMessage(event.target.value)}
1003
+ onKeyDown={event => {
1004
+ // Ctrl/Cmd+Enter commits, the shortcut every git client shares. Plain
1005
+ // Enter stays a newline: a commit body is normal and losing it to a
1006
+ // stray keystroke is not recoverable from the UI.
1007
+ if ((event.ctrlKey || event.metaKey) && event.key === 'Enter') { event.preventDefault(); commit() }
1008
+ }}
1009
+ />
1010
+ <div className={css.commitRow}>
1011
+ <label className={css.commitAmend}>
1012
+ <input
1013
+ type="checkbox" checked={amend} disabled={running}
1014
+ onChange={event => setAmend(event.target.checked)}
1015
+ />
1016
+ {t('amend')}
1017
+ </label>
1018
+ <span className={css.commitStaged}>{t('stagedCount', { count: stagedCount })}</span>
1019
+ <button
1020
+ type="button"
1021
+ className={css.commitBtn}
1022
+ disabled={!canCommit}
1023
+ title={running ? undefined : blocked}
1024
+ onClick={commit}
1025
+ >{busy === 'commit' ? t('opRunning') : t('commit')}</button>
1026
+ </div>
1027
+ </div>
1028
+ )
1029
+ }
1030
+
1031
+ /**
1032
+ * A branch name, or the stand-in when there is none.
1033
+ *
1034
+ * This used to also cut the name to 21 characters and append an ellipsis, which
1035
+ * is how `feature/nested/deep/some-fix` reached the header as
1036
+ * `feature/nested/deep/s…` — the truncation was in JS, so it happened at the
1037
+ * same 21 characters whether the drawer was 400px or maximised, and it cut off
1038
+ * the only end that says which branch this is. Width is the stylesheet's
1039
+ * question; {@link Elided} answers it, from the correct end, only when there is
1040
+ * genuinely not enough room.
1041
+ *
1042
+ * @param branch - branch name, empty when the repo has none yet.
1043
+ * @param empty - already-translated stand-in for the empty case.
1044
+ */
1045
+ export function branchLabel(branch: string, empty: string): string {
1046
+ return branch.length === 0 ? empty : branch
1047
+ }