@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,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
|
+
}
|