@young1lin/dsh-ui-gitworkbench 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +70 -0
- package/LICENSE +21 -0
- package/README.md +395 -0
- package/README_EN.md +74 -0
- package/cordis.patch.yml +7 -0
- package/lib/atomic-json.js +48 -0
- package/lib/client.js +15297 -0
- package/lib/commit-cache.js +68 -0
- package/lib/git-log.js +79 -0
- package/lib/git-ops.js +409 -0
- package/lib/index.js +1143 -0
- package/lib/style-store.js +123 -0
- package/lib/worktree.js +112 -0
- package/package.json +86 -0
- package/scripts/install.ps1 +240 -0
- package/scripts/install.sh +231 -0
- package/src/atomic-json.ts +55 -0
- package/src/client/GitWorkbenchPanel.module.css +1512 -0
- package/src/client/GitWorkbenchPanel.tsx +3446 -0
- package/src/client/commit-graph.ts +140 -0
- package/src/client/diff-model.ts +193 -0
- package/src/client/highlight.ts +257 -0
- package/src/client/index.ts +198 -0
- package/src/client/locales.ts +270 -0
- package/src/client/op-feedback.ts +65 -0
- package/src/client/stage-tree.ts +178 -0
- package/src/client/themes.ts +181 -0
- package/src/client/worktree-view.ts +193 -0
- package/src/commit-cache.ts +69 -0
- package/src/git-log.ts +92 -0
- package/src/git-ops.ts +490 -0
- package/src/index.ts +1172 -0
- package/src/style-store.ts +144 -0
- package/src/types/dsh-client-shim.d.ts +100 -0
- package/src/types/dsh-shim.d.ts +77 -0
- package/src/worktree.ts +142 -0
|
@@ -0,0 +1,3446 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session-header environment card + Codex-style changes drawer.
|
|
3
|
+
*
|
|
4
|
+
* The header shows a compact environment card — branch (or detached-HEAD sha),
|
|
5
|
+
* ahead/behind against upstream, and +adds/−dels/file count. Clicking opens a
|
|
6
|
+
* right-side drawer holding a collapsible DIRECTORY TREE (aggregated
|
|
7
|
+
* per-directory counts, status-badged files with dim-directory paths) and a
|
|
8
|
+
* per-file diff beside it — word-level highlights inside +/- line tints, over a
|
|
9
|
+
* light per-extension syntax pass. Files whose diff did not fit the bundled
|
|
10
|
+
* payload (or untracked files beyond the budget) fetch theirs on demand through
|
|
11
|
+
* `fetchFileDiff`. Binary files and renames get dedicated presentation. Polls
|
|
12
|
+
* every 15s while open.
|
|
13
|
+
*
|
|
14
|
+
* The drawer has three peer tabs. Changes is the working tree. History
|
|
15
|
+
* puts a commit list left of the same tree and diff panes — three peers, each
|
|
16
|
+
* scrolling on its own, which is what GitHub Desktop and the JetBrains git log
|
|
17
|
+
* do; it pages by scroll sentinel rather than by a button. Compare fills
|
|
18
|
+
* those panes from `base...head` between any two branches.
|
|
19
|
+
*
|
|
20
|
+
* A commit hash addresses content that cannot change, so a visited commit is
|
|
21
|
+
* kept and re-shown with neither a round trip nor a loading flash; the working
|
|
22
|
+
* tree is never cached, and selecting a commit does not refetch it.
|
|
23
|
+
*
|
|
24
|
+
* Source: a session is not confined to one worktree. The picker lists every
|
|
25
|
+
* worktree of the repository (git allows at most one per branch, so that list is
|
|
26
|
+
* also the branch list) and opens on the session's own — the bound worktree when
|
|
27
|
+
* the agent entered one, else the session cwd. Expansion/selection state is NOT
|
|
28
|
+
* reset on switch.
|
|
29
|
+
*
|
|
30
|
+
* Shell: the drawer is a card inset from every viewport edge, with a maximize
|
|
31
|
+
* toggle for full bleed. Three edges drag — the card's own leading edge and the
|
|
32
|
+
* dividers between the panes — each clamped so the diff keeps a readable width.
|
|
33
|
+
*
|
|
34
|
+
* Theme: a colour mode plus a palette family ({@link ./themes.ts}). The mode
|
|
35
|
+
* defaults to `system`, which follows dsh's resolved palette
|
|
36
|
+
* (`body[data-ds-dark-theme]`), not the computer's `prefers-color-scheme`.
|
|
37
|
+
* Both, and the dragged widths, are browser-local preferences and live in
|
|
38
|
+
* localStorage.
|
|
39
|
+
*
|
|
40
|
+
* Styling: a background image and a custom stylesheet, each settable for this
|
|
41
|
+
* project or globally with the project winning. Those are NOT browser-local —
|
|
42
|
+
* a project setting belongs to the project, so the host stores them and the
|
|
43
|
+
* panel reads them per source through `fetchStyle`. The stylesheet is injected
|
|
44
|
+
* as a document-level element, which is why `data-gs-part` attributes exist:
|
|
45
|
+
* CSS-module class names are hashed per build and cannot be targeted.
|
|
46
|
+
*
|
|
47
|
+
* All copy resolves through the app's locale runtime (`t`), so the panel follows
|
|
48
|
+
* the user's language preference.
|
|
49
|
+
*/
|
|
50
|
+
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore, type CSSProperties, type Dispatch, type PointerEvent as ReactPointerEvent, type ReactNode, type Ref, type SetStateAction } from 'react'
|
|
51
|
+
import { createPortal } from 'react-dom'
|
|
52
|
+
import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
|
53
|
+
import {
|
|
54
|
+
COLOR_MODES, DEFAULT_APPEARANCE, EMPTY_SETTINGS, STYLE_BLUR_MAX, STYLE_SCOPES, THEME_FAMILIES,
|
|
55
|
+
DSH_DARK_ATTR, effectiveBackground, effectiveCss, entryFor, hostSchemeDark, isAppearance, resolveTheme, withScope,
|
|
56
|
+
type Appearance, type ColorMode, type StyleEntry, type StyleScope, type StyleSettings, type ThemeFamily,
|
|
57
|
+
} from './themes.ts'
|
|
58
|
+
import { attachWordRanges, gutterSides, overlayRanges, parseRows, type Row, type RowWithRanges } from './diff-model.ts'
|
|
59
|
+
import { layoutGraph, type GraphRow } from './commit-graph.ts'
|
|
60
|
+
import {
|
|
61
|
+
fileCheckState, nextAction, nextBatch, pathsFor, rollUp, settledTicks, withPendingTicks,
|
|
62
|
+
type CheckState, type Tick, type TickAction,
|
|
63
|
+
} from './stage-tree.ts'
|
|
64
|
+
import { grammarLoadCount, highlightForRows, shikiLangOf, shikiThemeOf, subscribeGrammarLoaded, type HighlightRun } from './highlight.ts'
|
|
65
|
+
import { badgeRepeatsBranch, bindingChanged, branchOfWorktree, probesClosedBinding, samePath, showsPending, splitPath, viewedPath } from './worktree-view.ts'
|
|
66
|
+
import { BUSY_DELAY_MS, BUSY_HOLD_MS, holdRemaining, quietlyDisabled } from './op-feedback.ts'
|
|
67
|
+
import type { WorkbenchKey } from './locales.ts'
|
|
68
|
+
import css from './GitWorkbenchPanel.module.css'
|
|
69
|
+
|
|
70
|
+
export type GitFileStatus = 'added' | 'deleted' | 'modified' | 'renamed' | 'untracked'
|
|
71
|
+
|
|
72
|
+
export interface GitFile {
|
|
73
|
+
readonly path: string
|
|
74
|
+
readonly status: GitFileStatus
|
|
75
|
+
readonly addedLines: number
|
|
76
|
+
readonly deletedLines: number
|
|
77
|
+
readonly binary: boolean
|
|
78
|
+
readonly previousPath?: string
|
|
79
|
+
/**
|
|
80
|
+
* Which side of the index this file's change is on. Both can be true — a file
|
|
81
|
+
* staged and then edited again. Absent outside the working-tree view: a
|
|
82
|
+
* commit's files were staged long ago and the question is meaningless.
|
|
83
|
+
*/
|
|
84
|
+
readonly staged?: boolean
|
|
85
|
+
readonly unstaged?: boolean
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface GitCommit {
|
|
89
|
+
readonly hash: string
|
|
90
|
+
readonly subject: string
|
|
91
|
+
readonly when: string
|
|
92
|
+
/** Everything after the subject. Empty string when the commit has none. */
|
|
93
|
+
readonly body: string
|
|
94
|
+
/** Abbreviated parent hashes, first parent first — the graph's edges. */
|
|
95
|
+
readonly parents?: readonly string[]
|
|
96
|
+
/** Branch and tag names pointing here, already stripped of git's decoration syntax. */
|
|
97
|
+
readonly refs?: readonly string[]
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export interface WorkbenchStats {
|
|
101
|
+
readonly worktreePath: string
|
|
102
|
+
readonly branch: string
|
|
103
|
+
readonly ahead: number
|
|
104
|
+
readonly behind: number
|
|
105
|
+
readonly detached: boolean
|
|
106
|
+
readonly addedLines: number
|
|
107
|
+
readonly deletedLines: number
|
|
108
|
+
readonly addedFiles: number
|
|
109
|
+
readonly deletedFiles: number
|
|
110
|
+
readonly modifiedFiles: number
|
|
111
|
+
readonly files: readonly GitFile[]
|
|
112
|
+
readonly diff: string
|
|
113
|
+
/**
|
|
114
|
+
* Commits this view is about: the single commit for a commit view, the range's
|
|
115
|
+
* commits for a comparison. Empty for the working tree — the history list
|
|
116
|
+
* loads its own pages so it can follow a ref of its own.
|
|
117
|
+
*/
|
|
118
|
+
readonly commits: readonly GitCommit[]
|
|
119
|
+
readonly error?: string
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** One worktree of the repository, as `git worktree list --porcelain` reports it. */
|
|
123
|
+
export interface WorktreeEntry {
|
|
124
|
+
readonly path: string
|
|
125
|
+
readonly head: string
|
|
126
|
+
readonly branch: string
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** The session's worktree binding, as persisted by the worktree tools. */
|
|
130
|
+
export interface WorktreeBinding {
|
|
131
|
+
readonly repoRoot: string
|
|
132
|
+
readonly worktreePath: string
|
|
133
|
+
readonly name: string
|
|
134
|
+
readonly enteredAt: string
|
|
135
|
+
readonly baseCommit?: string
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* `gitWorkbench/worktreeStatus`: the session's binding (null when unbound) plus every
|
|
140
|
+
* worktree of the surrounding repository. Git allows at most one worktree per
|
|
141
|
+
* branch, so this one list is both the worktree picker and the branch picker.
|
|
142
|
+
*/
|
|
143
|
+
export interface WorktreeStatus {
|
|
144
|
+
readonly binding: WorktreeBinding | null
|
|
145
|
+
readonly worktrees: readonly WorktreeEntry[]
|
|
146
|
+
/**
|
|
147
|
+
* Every local branch, most-recently-committed first. Distinct from
|
|
148
|
+
* {@link worktrees} on purpose: a branch without a worktree has no directory
|
|
149
|
+
* to read, so it can be browsed or compared but not viewed as a working tree.
|
|
150
|
+
*/
|
|
151
|
+
readonly branches: readonly string[]
|
|
152
|
+
/** Whether the host cut {@link branches} short at its cap. */
|
|
153
|
+
readonly branchesTruncated: boolean
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* `gitWorkbench/syncStatus`: where the current branch stands against its upstream.
|
|
158
|
+
*
|
|
159
|
+
* `upstream: null` and "level with the upstream" are different states and the
|
|
160
|
+
* drawer treats them differently — the first is what makes the first push pass
|
|
161
|
+
* `--set-upstream`, and both otherwise read as zero ahead and zero behind.
|
|
162
|
+
*/
|
|
163
|
+
export interface SyncStatus {
|
|
164
|
+
readonly branch: string
|
|
165
|
+
readonly upstream: string | null
|
|
166
|
+
readonly ahead: number
|
|
167
|
+
readonly behind: number
|
|
168
|
+
readonly detached: boolean
|
|
169
|
+
/** Whether the repository has any remote at all. No remote, no sync bar. */
|
|
170
|
+
readonly hasRemote: boolean
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Why a write operation failed, in terms the drawer can explain. */
|
|
174
|
+
export type GitOpFailure =
|
|
175
|
+
| 'auth' | 'network' | 'no-upstream' | 'diverged' | 'conflict'
|
|
176
|
+
| 'nothing-to-commit' | 'dirty' | 'unknown'
|
|
177
|
+
|
|
178
|
+
export interface GitOpResult {
|
|
179
|
+
readonly ok: boolean
|
|
180
|
+
readonly failure?: GitOpFailure
|
|
181
|
+
/** git's own message on failure. Shown verbatim: a classification is a hint. */
|
|
182
|
+
readonly error?: string
|
|
183
|
+
readonly output?: string
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** The host endpoints under `gitWorkbench/` that change something. */
|
|
187
|
+
export type GitOpName = 'stage' | 'unstage' | 'commit' | 'fetch' | 'pull' | 'push'
|
|
188
|
+
|
|
189
|
+
/** Extra arguments an operation needs beyond the worktree path. */
|
|
190
|
+
export interface GitOpPayload {
|
|
191
|
+
readonly paths?: readonly string[]
|
|
192
|
+
readonly message?: string
|
|
193
|
+
readonly amend?: boolean
|
|
194
|
+
readonly mode?: 'ff-only' | 'rebase' | 'merge'
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Translate a key of this plugin's namespace, with optional `{name}` params. */
|
|
198
|
+
type Translate = (key: string, params?: Record<string, string | number>) => string
|
|
199
|
+
|
|
200
|
+
type Props = PropsRuntime<'conversation.session.header.actions'> & {
|
|
201
|
+
readonly t: Translate
|
|
202
|
+
readonly fetchStats: (worktreePath: string | undefined, signal: AbortSignal) => Promise<WorkbenchStats | null>
|
|
203
|
+
readonly fetchFileDiff: (worktreePath: string | undefined, path: string, commit: string | undefined, signal: AbortSignal) => Promise<string>
|
|
204
|
+
readonly fetchWorktreeStatus: (sessionId: string, repoPath: string | undefined, signal: AbortSignal) => Promise<WorktreeStatus | null>
|
|
205
|
+
/** Binding only, no git — the probe the shut chip can afford to poll. */
|
|
206
|
+
readonly fetchSessionBinding: (sessionId: string, signal: AbortSignal) => Promise<{ worktreePath: string | null; name: string | null } | null>
|
|
207
|
+
readonly fetchCommitStats: (worktreePath: string | undefined, hash: string, signal: AbortSignal) => Promise<WorkbenchStats | null>
|
|
208
|
+
readonly fetchCommits: (worktreePath: string | undefined, ref: string, skip: number, limit: number, signal: AbortSignal) => Promise<{ commits: GitCommit[]; hasMore: boolean } | null>
|
|
209
|
+
readonly fetchCompare: (worktreePath: string | undefined, base: string, head: string, signal: AbortSignal) => Promise<WorkbenchStats | null>
|
|
210
|
+
readonly fetchStyle: (worktreePath: string | undefined, signal: AbortSignal) => Promise<StyleSettings | null>
|
|
211
|
+
readonly saveStyle: (worktreePath: string | undefined, scope: StyleScope, entry: StyleEntry, signal: AbortSignal) => Promise<{ ok: boolean; error?: string }>
|
|
212
|
+
readonly fetchSync: (worktreePath: string | undefined, signal: AbortSignal) => Promise<SyncStatus | null>
|
|
213
|
+
readonly runGitOp: (op: GitOpName, worktreePath: string | undefined, payload: GitOpPayload, signal: AbortSignal) => Promise<GitOpResult>
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Drawer tab. `changes` is the working tree, `history` a commit picked from the
|
|
218
|
+
* log, `compare` two refs diffed against each other — peer surfaces rather than
|
|
219
|
+
* modes with a back action, so returning to the working tree is always one click
|
|
220
|
+
* (the pattern GitHub Desktop, VS Code and the JetBrains git tooling converge on).
|
|
221
|
+
*/
|
|
222
|
+
type Tab = 'changes' | 'history' | 'compare'
|
|
223
|
+
|
|
224
|
+
/** How many further commits one page request loads. */
|
|
225
|
+
const HISTORY_PAGE = 30
|
|
226
|
+
|
|
227
|
+
/** Narrowest the drawer may be dragged: three panes at their minimums, plus a
|
|
228
|
+
* diff column still wide enough to read code in. */
|
|
229
|
+
const MIN_DRAWER_WIDTH = 760
|
|
230
|
+
/** Pane minimums. The diff's is enforced against the drawer rather than on the
|
|
231
|
+
* pane itself: it is the pane with no fallback, since code cannot reflow. */
|
|
232
|
+
const MIN_COMMITS_WIDTH = 190
|
|
233
|
+
const MIN_TREE_WIDTH = 170
|
|
234
|
+
const MIN_DIFF_WIDTH = 300
|
|
235
|
+
|
|
236
|
+
/** Longest edge a chosen background image is resampled to before storage. Past
|
|
237
|
+
* this the file grows fast while a blurred backdrop gains nothing. */
|
|
238
|
+
const IMAGE_MAX_EDGE = 2560
|
|
239
|
+
/** JPEG quality for that resample. */
|
|
240
|
+
const IMAGE_QUALITY = 0.82
|
|
241
|
+
/** Refuse an image whose encoded data URL exceeds this; matches the host's cap. */
|
|
242
|
+
const IMAGE_MAX_BYTES = 3_000_000
|
|
243
|
+
|
|
244
|
+
/** Element carrying the user's custom stylesheet. One per document. */
|
|
245
|
+
const CUSTOM_STYLE_ID = 'dsh-ui-gitworkbench-custom-css'
|
|
246
|
+
|
|
247
|
+
/** localStorage keys. Namespaced, since the whole app shares one origin.
|
|
248
|
+
* Layout and palette live here; the background image and custom CSS do not —
|
|
249
|
+
* they are per-project state the host owns, see `styleGet`/`styleSet`. */
|
|
250
|
+
const STORE_APPEARANCE = 'dsh-ui-gitworkbench:appearance'
|
|
251
|
+
const STORE_WIDTH = 'dsh-ui-gitworkbench:width'
|
|
252
|
+
const STORE_PANES = 'dsh-ui-gitworkbench:panes'
|
|
253
|
+
|
|
254
|
+
/** Dragged pane widths in px; null on either side keeps that pane's CSS default. */
|
|
255
|
+
interface PaneWidths {
|
|
256
|
+
readonly commits: number | null
|
|
257
|
+
readonly tree: number | null
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const DEFAULT_PANES: PaneWidths = { commits: null, tree: null }
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* @param value - value read back from storage.
|
|
264
|
+
* @returns whether it is a pane-width pair this build can use.
|
|
265
|
+
*/
|
|
266
|
+
function isPaneWidths(value: unknown): value is PaneWidths {
|
|
267
|
+
if (typeof value !== 'object' || value === null) return false
|
|
268
|
+
const { commits, tree } = value as Partial<PaneWidths>
|
|
269
|
+
const ok = (v: unknown): boolean => v === null || (typeof v === 'number' && Number.isFinite(v))
|
|
270
|
+
return ok(commits) && ok(tree)
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Read a persisted preference.
|
|
275
|
+
*
|
|
276
|
+
* Storage is a durable boundary holding values an older build wrote, so every
|
|
277
|
+
* read is validated and anything unrecognized falls back rather than propagating.
|
|
278
|
+
* @param key - storage key.
|
|
279
|
+
* @param accept - narrows a parsed value to the expected type.
|
|
280
|
+
* @param fallback - used when the key is absent, unparsable, or rejected.
|
|
281
|
+
* @returns the stored value, or the fallback.
|
|
282
|
+
*/
|
|
283
|
+
function readStored<T>(key: string, accept: (value: unknown) => value is T, fallback: T): T {
|
|
284
|
+
try {
|
|
285
|
+
const raw = localStorage.getItem(key)
|
|
286
|
+
if (raw === null) return fallback
|
|
287
|
+
const parsed: unknown = JSON.parse(raw)
|
|
288
|
+
return accept(parsed) ? parsed : fallback
|
|
289
|
+
} catch {
|
|
290
|
+
// Storage can be disabled outright, and a half-written value can fail to
|
|
291
|
+
// parse; a preference is never worth failing a render over.
|
|
292
|
+
return fallback
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Persist a preference, ignoring a storage that refuses writes.
|
|
298
|
+
* @param key - storage key.
|
|
299
|
+
* @param value - JSON-serializable value.
|
|
300
|
+
*/
|
|
301
|
+
function writeStored(key: string, value: unknown): void {
|
|
302
|
+
try {
|
|
303
|
+
localStorage.setItem(key, JSON.stringify(value))
|
|
304
|
+
} catch {
|
|
305
|
+
// Private mode and a full quota both throw here. The session keeps the
|
|
306
|
+
// choice in memory; only its durability is lost.
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* One pointer-captured horizontal drag, shared by the drawer's leading edge and
|
|
312
|
+
* both pane dividers.
|
|
313
|
+
*
|
|
314
|
+
* Pointer capture is what keeps a drag alive over every pane and past the window
|
|
315
|
+
* edge; a plain mousemove listener on the handle loses it as soon as the pointer
|
|
316
|
+
* crosses a child that stops propagation.
|
|
317
|
+
* @returns the active flag for styling, and the pointerdown handler to attach.
|
|
318
|
+
*/
|
|
319
|
+
function useHorizontalDrag(): {
|
|
320
|
+
dragging: boolean
|
|
321
|
+
start: (event: ReactPointerEvent<HTMLElement>, onDrag: (clientX: number, done: boolean) => void) => void
|
|
322
|
+
} {
|
|
323
|
+
const [dragging, setDragging] = useState(false)
|
|
324
|
+
const start = (event: ReactPointerEvent<HTMLElement>, onDrag: (clientX: number, done: boolean) => void): void => {
|
|
325
|
+
event.preventDefault()
|
|
326
|
+
const handle = event.currentTarget
|
|
327
|
+
handle.setPointerCapture(event.pointerId)
|
|
328
|
+
setDragging(true)
|
|
329
|
+
const onMove = (move: PointerEvent): void => { onDrag(move.clientX, false) }
|
|
330
|
+
// `pointercancel` ends a drag the browser took over (a touch became a
|
|
331
|
+
// gesture, the window lost focus). It releases capture itself, so only the
|
|
332
|
+
// pointerup path releases — and both must detach, or the next drag stacks a
|
|
333
|
+
// second set of listeners on the same handle.
|
|
334
|
+
const finish = (end: PointerEvent): void => {
|
|
335
|
+
if (end.type === 'pointerup') {
|
|
336
|
+
onDrag(end.clientX, true)
|
|
337
|
+
handle.releasePointerCapture(end.pointerId)
|
|
338
|
+
}
|
|
339
|
+
handle.removeEventListener('pointermove', onMove)
|
|
340
|
+
handle.removeEventListener('pointerup', finish)
|
|
341
|
+
handle.removeEventListener('pointercancel', finish)
|
|
342
|
+
setDragging(false)
|
|
343
|
+
}
|
|
344
|
+
handle.addEventListener('pointermove', onMove)
|
|
345
|
+
handle.addEventListener('pointerup', finish)
|
|
346
|
+
handle.addEventListener('pointercancel', finish)
|
|
347
|
+
}
|
|
348
|
+
return { dragging, start }
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Drag handle between two panes.
|
|
353
|
+
* @param label - accessible name.
|
|
354
|
+
* @param onDrag - receives the pointer's x and whether the drag just ended.
|
|
355
|
+
* @returns the divider element.
|
|
356
|
+
*/
|
|
357
|
+
function PaneDivider({ label, onDrag }: {
|
|
358
|
+
label: string
|
|
359
|
+
onDrag: (clientX: number, done: boolean) => void
|
|
360
|
+
}): ReactNode {
|
|
361
|
+
const { dragging, start } = useHorizontalDrag()
|
|
362
|
+
return (
|
|
363
|
+
<div
|
|
364
|
+
className={dragging ? `${css.paneDivider} ${css.paneDividerActive}` : css.paneDivider}
|
|
365
|
+
role="separator"
|
|
366
|
+
aria-orientation="vertical"
|
|
367
|
+
aria-label={label}
|
|
368
|
+
onPointerDown={event => start(event, onDrag)}
|
|
369
|
+
/>
|
|
370
|
+
)
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/** Commit change sets kept in the browser before the least recently used is dropped. */
|
|
374
|
+
const COMMIT_CACHE_CAPACITY = 24
|
|
375
|
+
|
|
376
|
+
/** Stand-in while a commit's change set is in flight — every pane renders empty. */
|
|
377
|
+
const EMPTY_STATS: WorkbenchStats = {
|
|
378
|
+
worktreePath: '', branch: '', ahead: 0, behind: 0, detached: false,
|
|
379
|
+
addedLines: 0, deletedLines: 0, addedFiles: 0, deletedFiles: 0, modifiedFiles: 0,
|
|
380
|
+
files: [], diff: '', commits: [],
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/** The overlay with nothing on it — one instance, so an empty overlay never
|
|
384
|
+
* re-renders the tree that receives it. */
|
|
385
|
+
const EMPTY_TICKS: ReadonlyMap<string, TickAction> = new Map()
|
|
386
|
+
|
|
387
|
+
/** How often a queued tick batch re-checks whether a heavy operation has
|
|
388
|
+
* released the git lock. Short: ticks are clicks someone is watching. */
|
|
389
|
+
const TICK_RETRY_MS = 25
|
|
390
|
+
|
|
391
|
+
/** How often the shut chip re-reads the session's binding while the agent is
|
|
392
|
+
* running. Matched to the open drawer's busy rate — the probe is a JSON read
|
|
393
|
+
* with no git behind it, so the cost that set the 15s idle rate is absent. */
|
|
394
|
+
const BINDING_PROBE_MS = 3_000
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Pick the ref a comparison starts from.
|
|
398
|
+
*
|
|
399
|
+
* The integration branch is what one almost always compares against, so it wins
|
|
400
|
+
* when it exists and is not already the other side; otherwise any other branch
|
|
401
|
+
* beats an empty picker.
|
|
402
|
+
* @param branches - the repository's local branches.
|
|
403
|
+
* @param head - the ref being compared, which must not also be the base.
|
|
404
|
+
* @returns the default base ref, or an empty string when there is no candidate.
|
|
405
|
+
*/
|
|
406
|
+
function defaultBase(branches: readonly string[], head: string): string {
|
|
407
|
+
for (const preferred of ['main', 'master']) {
|
|
408
|
+
if (branches.includes(preferred) && preferred !== head) return preferred
|
|
409
|
+
}
|
|
410
|
+
return branches.find(branch => branch !== head) ?? ''
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
const STATUS_BADGE: Record<GitFileStatus, string> = {
|
|
414
|
+
added: css.stAdded, untracked: css.stUntracked, modified: css.stModified,
|
|
415
|
+
renamed: css.stRenamed, deleted: css.stDeleted,
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
export function GitWorkbenchPanel({ sessionId, useSessions, t, fetchStats, fetchFileDiff, fetchWorktreeStatus, fetchSessionBinding, fetchCommitStats, fetchCommits, fetchCompare, fetchStyle, saveStyle, fetchSync, runGitOp }: Props) {
|
|
419
|
+
const worktreePath = useSessions((state: { byId?: Record<string, { cwd?: string } | undefined> }) =>
|
|
420
|
+
state?.byId?.[sessionId]?.cwd) as string | undefined
|
|
421
|
+
/** Whether the session's agent has a turn in flight — the store mirrors it
|
|
422
|
+
* live, so it is the signal for polling faster while there is something to
|
|
423
|
+
* watch (the agent may be staging, committing, or entering worktrees). */
|
|
424
|
+
const agentRunning = useSessions((state: { byId?: Record<string, { running?: boolean } | undefined> }) =>
|
|
425
|
+
state?.byId?.[sessionId]?.running) as boolean | undefined
|
|
426
|
+
|
|
427
|
+
const [stats, setStats] = useState<WorkbenchStats | null>(null)
|
|
428
|
+
const [open, setOpen] = useState(false)
|
|
429
|
+
const [selected, setSelected] = useState<string | null>(null)
|
|
430
|
+
/** Generation counter — bumped on drawer open, manual refresh and source switch:
|
|
431
|
+
* the events after which working-tree content can genuinely differ. Tab and
|
|
432
|
+
* commit selection do NOT bump it (they change which view is shown, not the
|
|
433
|
+
* working tree), and background polls never touch it. */
|
|
434
|
+
const [gen, setGen] = useState(0)
|
|
435
|
+
/** Tree expansion state, session-lifetime: survives polls, source switches and drawer close/reopen. */
|
|
436
|
+
const [collapsed, setCollapsed] = useState<Set<string> | undefined>(undefined)
|
|
437
|
+
/** Binding + the repository's worktrees, null until the first successful fetch. */
|
|
438
|
+
const [wtStatus, setWtStatus] = useState<WorktreeStatus | null>(null)
|
|
439
|
+
/** Worktree the drawer reads, by absolute path. null = follow the session's own
|
|
440
|
+
* (the bound worktree when one exists, else the session cwd); reset on open. */
|
|
441
|
+
const [sourcePath, setSourcePath] = useState<string | null>(null)
|
|
442
|
+
/** Active drawer tab; the drawer always opens on the working tree. */
|
|
443
|
+
const [tab, setTab] = useState<Tab>('changes')
|
|
444
|
+
/** Commit selected in the history tab; null until one is picked. */
|
|
445
|
+
const [commitHash, setCommitHash] = useState<string | null>(null)
|
|
446
|
+
/** That commit's change set, or null while its fetch is in flight. */
|
|
447
|
+
const [commitStats, setCommitStats] = useState<WorkbenchStats | null>(null)
|
|
448
|
+
/**
|
|
449
|
+
* Change sets already fetched, keyed by worktree + hash. A commit hash
|
|
450
|
+
* addresses content that cannot change, so a hit is served with no round trip
|
|
451
|
+
* AND without the null pass that blanks the panes — re-selecting a commit is
|
|
452
|
+
* immediate rather than a second loading flash. Bounded; a Map's insertion
|
|
453
|
+
* order is its recency order.
|
|
454
|
+
*/
|
|
455
|
+
const commitCache = useRef(new Map<string, WorkbenchStats>())
|
|
456
|
+
/** Ref the history tab walks; null follows the active worktree's own branch.
|
|
457
|
+
* A branch needs no worktree to have a log, so this is how a branch that is
|
|
458
|
+
* checked out nowhere still becomes browsable. */
|
|
459
|
+
const [historyRef, setHistoryRef] = useState<string | null>(null)
|
|
460
|
+
/** Every history page loaded for the current worktree + ref. */
|
|
461
|
+
const [historyCommits, setHistoryCommits] = useState<readonly GitCommit[]>([])
|
|
462
|
+
const [historyHasMore, setHistoryHasMore] = useState(false)
|
|
463
|
+
/** First page of the history list in flight — the pane says "loading", not
|
|
464
|
+
* "no commit history", which is a claim about the repository. */
|
|
465
|
+
const [historyLoading, setHistoryLoading] = useState(false)
|
|
466
|
+
const [loadingMore, setLoadingMore] = useState(false)
|
|
467
|
+
/** In-flight marker for paging, read synchronously — see {@link loadMoreCommits}. */
|
|
468
|
+
const loadingRef = useRef(false)
|
|
469
|
+
/** Drawer occupies the whole viewport. Panel-level state, so the choice holds
|
|
470
|
+
* across tab switches and reopens rather than resetting under the user. */
|
|
471
|
+
const [maximized, setMaximized] = useState(false)
|
|
472
|
+
/** Dragged width in px; null keeps the responsive default. */
|
|
473
|
+
const [width, setWidth] = useState<number | null>(
|
|
474
|
+
() => readStored(STORE_WIDTH, (value): value is number => typeof value === 'number' && Number.isFinite(value), null as number | null),
|
|
475
|
+
)
|
|
476
|
+
const [mode, setMode] = useState<ColorMode>(
|
|
477
|
+
() => readStored(STORE_APPEARANCE, isAppearance, DEFAULT_APPEARANCE).mode,
|
|
478
|
+
)
|
|
479
|
+
const [family, setFamily] = useState<ThemeFamily>(
|
|
480
|
+
() => readStored(STORE_APPEARANCE, isAppearance, DEFAULT_APPEARANCE).family,
|
|
481
|
+
)
|
|
482
|
+
/** Dragged pane widths, persisted so a layout survives a reload. */
|
|
483
|
+
const [panes, setPanes] = useState<PaneWidths>(
|
|
484
|
+
() => readStored(STORE_PANES, isPaneWidths, DEFAULT_PANES),
|
|
485
|
+
)
|
|
486
|
+
/** Per-project and global styling; both scopes, unresolved. */
|
|
487
|
+
const [style, setStyle] = useState<StyleSettings>(EMPTY_SETTINGS)
|
|
488
|
+
/** Whether dsh's resolved palette is currently dark. */
|
|
489
|
+
const [hostDark, setHostDark] = useState(
|
|
490
|
+
() => typeof document !== 'undefined' && hostSchemeDark(document.body),
|
|
491
|
+
)
|
|
492
|
+
|
|
493
|
+
// dsh can flip light/dark from Settings without remounting this panel, so
|
|
494
|
+
// watch the attribute ThemePresenter toggles. A one-time read would leave
|
|
495
|
+
// the drawer stranded in whichever scheme it happened to mount in.
|
|
496
|
+
useEffect(() => {
|
|
497
|
+
const sync = (): void => { setHostDark(hostSchemeDark(document.body)) }
|
|
498
|
+
sync()
|
|
499
|
+
const observer = new MutationObserver(sync)
|
|
500
|
+
observer.observe(document.body, { attributes: true, attributeFilter: [DSH_DARK_ATTR] })
|
|
501
|
+
return () => observer.disconnect()
|
|
502
|
+
}, [])
|
|
503
|
+
/** Compare tab refs; null follows the computed default rather than pinning one,
|
|
504
|
+
* so switching worktree moves the comparison with it. */
|
|
505
|
+
const [compareBase, setCompareBase] = useState<string | null>(null)
|
|
506
|
+
const [compareHead, setCompareHead] = useState<string | null>(null)
|
|
507
|
+
/** The comparison's change set, or null while its fetch is in flight. */
|
|
508
|
+
const [compareStats, setCompareStats] = useState<WorkbenchStats | null>(null)
|
|
509
|
+
/** Divergence from the upstream; null until the first read, or outside a repo. */
|
|
510
|
+
const [sync, setSync] = useState<SyncStatus | null>(null)
|
|
511
|
+
/** The write operation currently running, or null. One at a time on purpose:
|
|
512
|
+
* git takes an index lock, so a second concurrent op fails on the lock rather
|
|
513
|
+
* than queueing, and a disabled button explains that better than an error. */
|
|
514
|
+
const [busy, setBusy] = useState<GitOpName | null>(null)
|
|
515
|
+
/** Outcome of the last write operation, shown until the next one starts. */
|
|
516
|
+
const [opResult, setOpResult] = useState<{ op: GitOpName; result: GitOpResult } | null>(null)
|
|
517
|
+
/** Ticks clicked but not yet confirmed by a payload — the optimistic layer
|
|
518
|
+
* between a click and the git call it queues. Keyed by path; the newest
|
|
519
|
+
* click for a path wins. */
|
|
520
|
+
const [pendingTicks, setPendingTicks] = useState<ReadonlyMap<string, TickAction>>(EMPTY_TICKS)
|
|
521
|
+
/** Clicks waiting for their git call, in the order they arrived. */
|
|
522
|
+
const tickQueueRef = useRef<readonly Tick[]>([])
|
|
523
|
+
/** Whether a drain loop is running — one at a time, so ticks queue up behind
|
|
524
|
+
* a batch in flight instead of racing it for the git lock. */
|
|
525
|
+
const drainingRef = useRef(false)
|
|
526
|
+
/** Bumped when the drawer's source changes: ticks belong to the worktree
|
|
527
|
+
* they were clicked in, and a loop started under one source must not run
|
|
528
|
+
* batches queued under the next. */
|
|
529
|
+
const tickEpochRef = useRef(0)
|
|
530
|
+
/** The newest render's drain loop. A loop outlives the render it started
|
|
531
|
+
* in; everything it must see fresh it reads through a ref, and this is how
|
|
532
|
+
* a retired loop hands the queue to a current one. */
|
|
533
|
+
const drainRef = useRef((): Promise<void> => Promise.resolve())
|
|
534
|
+
/** The git lock, as a ref. `busy` above is state and stays for display; a
|
|
535
|
+
* drain loop issuing calls from one long-lived closure would never see a
|
|
536
|
+
* state value change under it. */
|
|
537
|
+
const busyRef = useRef<GitOpName | null>(null)
|
|
538
|
+
/**
|
|
539
|
+
* The commit message being written, and whether it amends.
|
|
540
|
+
*
|
|
541
|
+
* Held here rather than in the commit box, because the box unmounts whenever
|
|
542
|
+
* the drawer leaves the Changes tab — a glance at the history would otherwise
|
|
543
|
+
* throw away a message the user had already typed, with no way to get it back.
|
|
544
|
+
*/
|
|
545
|
+
const [commitDraft, setCommitDraft] = useState('')
|
|
546
|
+
const [commitAmend, setCommitAmend] = useState(false)
|
|
547
|
+
|
|
548
|
+
const binding = wtStatus?.binding ?? null
|
|
549
|
+
const worktrees = wtStatus?.worktrees ?? []
|
|
550
|
+
const branches = wtStatus?.branches ?? []
|
|
551
|
+
const branchesTruncated = wtStatus?.branchesTruncated ?? false
|
|
552
|
+
/** Branches that have a worktree — what the pickers group to the top. */
|
|
553
|
+
const worktreeBranches = worktrees.map(entry => entry.branch).filter(branch => branch.length > 0)
|
|
554
|
+
/** The session's own worktree: the bound one, else its cwd. The default view. */
|
|
555
|
+
const sessionPath = binding?.worktreePath ?? worktreePath
|
|
556
|
+
/** What everything here is about. The drawer's pin only counts while the
|
|
557
|
+
* drawer is open — see {@link viewedPath} for why that is a rule and not a
|
|
558
|
+
* reset in the close handler. */
|
|
559
|
+
const statsPath = viewedPath(open, sourcePath, sessionPath)
|
|
560
|
+
/** The latest `statsPath`, readable by a response that started under an older
|
|
561
|
+
* one. The 15s poll's in-flight fetch survives a source switch (clearing the
|
|
562
|
+
* interval does not abort it), and without this check it would repaint the
|
|
563
|
+
* tree with the worktree the user just left. */
|
|
564
|
+
const statsPathRef = useRef(statsPath)
|
|
565
|
+
statsPathRef.current = statsPath
|
|
566
|
+
/** Whether the KEYED stats fetch (source switch, refresh, open) is in flight.
|
|
567
|
+
* The tree must say "loading", not render the empty placeholder as "no
|
|
568
|
+
* changes" — those are different sentences and the wrong one reads as data. */
|
|
569
|
+
const [statsLoading, setStatsLoading] = useState(true)
|
|
570
|
+
|
|
571
|
+
// Binding and worktree list keep up with the agent's enter/exit and with
|
|
572
|
+
// worktrees created outside dsh: mount + every drawer open/close.
|
|
573
|
+
useEffect(() => {
|
|
574
|
+
const ctrl = new AbortController()
|
|
575
|
+
fetchWorktreeStatus(sessionId, worktreePath, ctrl.signal)
|
|
576
|
+
.then(value => { if (value !== null) setWtStatus(value) })
|
|
577
|
+
.catch(() => {})
|
|
578
|
+
return () => ctrl.abort()
|
|
579
|
+
}, [sessionId, worktreePath, fetchWorktreeStatus, open])
|
|
580
|
+
|
|
581
|
+
/** The binding on screen, readable by a probe loop that outlives its render.
|
|
582
|
+
* The full status arrives on a timer, so a loop that closed over one would
|
|
583
|
+
* keep comparing the probe against whatever was true when it started. */
|
|
584
|
+
const wtStatusRef = useRef(wtStatus)
|
|
585
|
+
wtStatusRef.current = wtStatus
|
|
586
|
+
|
|
587
|
+
/**
|
|
588
|
+
* Keep the SHUT chip's binding honest while the agent works.
|
|
589
|
+
*
|
|
590
|
+
* The effect above is the only thing that reads the binding, and none of its
|
|
591
|
+
* deps move when `worktree_enter` runs: dsh's `session.header.cwd` is
|
|
592
|
+
* immutable, so the sessions store says nothing, and the 3-15s poll below
|
|
593
|
+
* starts at `if (!open) return`. The chip therefore sat on `main` after the
|
|
594
|
+
* agent had entered a worktree until someone opened the drawer — the one act
|
|
595
|
+
* that flips `open` — which is exactly the wrong thing for an indicator to do.
|
|
596
|
+
*
|
|
597
|
+
* What runs here is a probe, not a fetch: `sessionWorktree` reads the bindings
|
|
598
|
+
* JSON and spawns no git, so the quiet case costs one file read. Only when it
|
|
599
|
+
* disagrees with the chip does one full `worktreeStatus` follow, and that one
|
|
600
|
+
* brings the worktree list and branches the badge and picker need.
|
|
601
|
+
*
|
|
602
|
+
* The window is narrow by construction ({@link probesClosedBinding}): a
|
|
603
|
+
* binding moves only inside a turn, and this panel is mounted in every session
|
|
604
|
+
* header, so an idle session opens no timer at all. The unconditional probe on
|
|
605
|
+
* entry is the backstop for a turn shorter than one interval — the deps carry
|
|
606
|
+
* `agentRunning`, so the end of every turn re-runs this and asks once.
|
|
607
|
+
*/
|
|
608
|
+
useEffect(() => {
|
|
609
|
+
if (open) return
|
|
610
|
+
let alive = true
|
|
611
|
+
const probe = (): void => {
|
|
612
|
+
// Nothing to disagree with until the first full status has landed; the
|
|
613
|
+
// mount fetch above is still in flight and will answer this itself.
|
|
614
|
+
if (wtStatusRef.current === null) return
|
|
615
|
+
const ctrl = new AbortController()
|
|
616
|
+
fetchSessionBinding(sessionId, ctrl.signal)
|
|
617
|
+
.then(value => {
|
|
618
|
+
if (!alive || value === null) return
|
|
619
|
+
if (!bindingChanged(value, wtStatusRef.current?.binding ?? null)) return
|
|
620
|
+
const full = new AbortController()
|
|
621
|
+
fetchWorktreeStatus(sessionId, worktreePath, full.signal)
|
|
622
|
+
.then(status => { if (alive && status !== null) setWtStatus(status) })
|
|
623
|
+
.catch(() => {})
|
|
624
|
+
})
|
|
625
|
+
.catch(() => {})
|
|
626
|
+
}
|
|
627
|
+
probe()
|
|
628
|
+
if (!probesClosedBinding(open, agentRunning)) return () => { alive = false }
|
|
629
|
+
const id = setInterval(probe, BINDING_PROBE_MS)
|
|
630
|
+
return () => { alive = false; clearInterval(id) }
|
|
631
|
+
}, [open, agentRunning, sessionId, worktreePath, fetchSessionBinding, fetchWorktreeStatus])
|
|
632
|
+
|
|
633
|
+
// Stats for the active source: on mount, on source change and on gen bumps
|
|
634
|
+
// (manual refresh / source switch). Cleanup aborts a superseded in-flight fetch.
|
|
635
|
+
useEffect(() => {
|
|
636
|
+
const ctrl = new AbortController()
|
|
637
|
+
let alive = true
|
|
638
|
+
setStatsLoading(true)
|
|
639
|
+
fetchStats(statsPath, ctrl.signal)
|
|
640
|
+
.then(value => {
|
|
641
|
+
if (!alive) return
|
|
642
|
+
setStatsLoading(false)
|
|
643
|
+
if (value !== null && statsPathRef.current === statsPath) setStats(value)
|
|
644
|
+
})
|
|
645
|
+
.catch(() => { if (alive) setStatsLoading(false) })
|
|
646
|
+
return () => { alive = false; ctrl.abort() }
|
|
647
|
+
}, [statsPath, fetchStats, gen])
|
|
648
|
+
|
|
649
|
+
// Divergence from the upstream, refetched with the stats. Cheap (two git
|
|
650
|
+
// reads, no diff), and it has to move in step with the file list: committing
|
|
651
|
+
// changes both, and a stale ahead count beside a fresh tree is worse than none.
|
|
652
|
+
useEffect(() => {
|
|
653
|
+
if (!open) return
|
|
654
|
+
const ctrl = new AbortController()
|
|
655
|
+
fetchSync(statsPath, ctrl.signal).then(value => { if (value !== null) setSync(value) }).catch(() => {})
|
|
656
|
+
return () => ctrl.abort()
|
|
657
|
+
}, [statsPath, fetchSync, gen, open])
|
|
658
|
+
|
|
659
|
+
// Styling follows the source, since the project scope is keyed by repository:
|
|
660
|
+
// switching to a worktree of another repo must bring that repo's background.
|
|
661
|
+
// Not polled — nothing else writes this file while the drawer is open.
|
|
662
|
+
useEffect(() => {
|
|
663
|
+
const ctrl = new AbortController()
|
|
664
|
+
fetchStyle(statsPath, ctrl.signal).then(value => { if (value !== null) setStyle(value) }).catch(() => {})
|
|
665
|
+
return () => ctrl.abort()
|
|
666
|
+
}, [statsPath, fetchStyle, open])
|
|
667
|
+
|
|
668
|
+
const background = effectiveBackground(style)
|
|
669
|
+
const customCss = effectiveCss(style)
|
|
670
|
+
|
|
671
|
+
// The custom stylesheet is a document-level element rather than a <style> in
|
|
672
|
+
// the tree: it must be able to reach the overlay, which React portals aside,
|
|
673
|
+
// and it has to survive the drawer closing so a reopen does not reflow.
|
|
674
|
+
useEffect(() => {
|
|
675
|
+
if (customCss.length === 0) {
|
|
676
|
+
document.getElementById(CUSTOM_STYLE_ID)?.remove()
|
|
677
|
+
return
|
|
678
|
+
}
|
|
679
|
+
let element = document.getElementById(CUSTOM_STYLE_ID)
|
|
680
|
+
if (element === null) {
|
|
681
|
+
element = document.createElement('style')
|
|
682
|
+
element.id = CUSTOM_STYLE_ID
|
|
683
|
+
document.head.append(element)
|
|
684
|
+
}
|
|
685
|
+
element.textContent = customCss
|
|
686
|
+
}, [customCss])
|
|
687
|
+
|
|
688
|
+
// Background poll. A working tree changes under the plugin's feet — an editor
|
|
689
|
+
// saves, a build writes, another shell commits — and none of that reaches the
|
|
690
|
+
// session log, so it cannot be pushed and freshness is bought with polling.
|
|
691
|
+
// (dsh does offer a push channel, `ctx.sessionProjections`, but a projection
|
|
692
|
+
// is a fold over committed session events, which is a different question from
|
|
693
|
+
// "what does `git status` say".) Two rates: while the session's agent is
|
|
694
|
+
// running it may stage, commit or enter worktrees at any moment, and a drawer
|
|
695
|
+
// that claims to show the working tree should keep up with it; idle, 15s is
|
|
696
|
+
// plenty. The stats write is guarded so an in-flight response from a retired
|
|
697
|
+
// source can never repaint the tree.
|
|
698
|
+
const pollMs = agentRunning === true ? 3_000 : 15_000
|
|
699
|
+
useEffect(() => {
|
|
700
|
+
if (!open) return
|
|
701
|
+
const id = setInterval(() => {
|
|
702
|
+
const ctrl = new AbortController()
|
|
703
|
+
fetchStats(statsPath, ctrl.signal)
|
|
704
|
+
.then(value => { if (value !== null && statsPathRef.current === statsPath) setStats(value) })
|
|
705
|
+
.catch(() => {})
|
|
706
|
+
const bctrl = new AbortController()
|
|
707
|
+
fetchWorktreeStatus(sessionId, worktreePath, bctrl.signal).then(value => { if (value !== null) setWtStatus(value) }).catch(() => {})
|
|
708
|
+
}, pollMs)
|
|
709
|
+
return () => clearInterval(id)
|
|
710
|
+
}, [open, pollMs, statsPath, fetchStats, sessionId, worktreePath, fetchWorktreeStatus])
|
|
711
|
+
|
|
712
|
+
/** Ref the history list actually walks. Empty asks the host for the worktree's
|
|
713
|
+
* own HEAD, which is right for a detached checkout too. */
|
|
714
|
+
const effectiveHistoryRef = historyRef ?? stats?.branch ?? ''
|
|
715
|
+
|
|
716
|
+
/** The worktree list, readable by the switch effect below without joining its
|
|
717
|
+
* deps. The poll hands back a fresh array every 3-15s, so a dependency here
|
|
718
|
+
* would blank the tree on a timer. */
|
|
719
|
+
const worktreesRef = useRef(worktrees)
|
|
720
|
+
worktreesRef.current = worktrees
|
|
721
|
+
|
|
722
|
+
// A source switch drops everything that names the worktree the user just
|
|
723
|
+
// left: the ref override, the selection, the divergence, and the file list
|
|
724
|
+
// itself. The list is the one correctness rides on — until the new stats land
|
|
725
|
+
// there is no tree to trust, and a tick clicked in that window would hand
|
|
726
|
+
// paths from the old worktree to `git` in the new one ("pathspec did not
|
|
727
|
+
// match any file(s)"). The placeholder keeps `stats` non-null on purpose: the
|
|
728
|
+
// panel renders nothing at all when it is null, and unmounting the drawer
|
|
729
|
+
// mid-switch would be a bigger disruption than the blank tree.
|
|
730
|
+
//
|
|
731
|
+
// The branch, though, is already known: it came with the worktree list the
|
|
732
|
+
// user just picked from. Leaving it empty made the header claim `(no branch)`
|
|
733
|
+
// for the length of a `git status` — not a slower answer but a wrong one, and
|
|
734
|
+
// on a large repository it sat there for seconds.
|
|
735
|
+
useEffect(() => {
|
|
736
|
+
setHistoryRef(null)
|
|
737
|
+
setSelected(null)
|
|
738
|
+
// The old worktree's ahead/behind would otherwise ride out the switch above
|
|
739
|
+
// a file list that has already been emptied.
|
|
740
|
+
setSync(null)
|
|
741
|
+
// Ticks belong to the worktree they were clicked in. The queue goes with
|
|
742
|
+
// them; the epoch bump retires any drain loop still working through it, so
|
|
743
|
+
// a queued batch can never run `git add` in the worktree the user just
|
|
744
|
+
// left — the pathspec error the switch effect above already guards the
|
|
745
|
+
// click itself against.
|
|
746
|
+
tickQueueRef.current = []
|
|
747
|
+
tickEpochRef.current += 1
|
|
748
|
+
setPendingTicks(EMPTY_TICKS)
|
|
749
|
+
setStats({
|
|
750
|
+
...EMPTY_STATS,
|
|
751
|
+
worktreePath: statsPath,
|
|
752
|
+
branch: branchOfWorktree(statsPath, worktreesRef.current),
|
|
753
|
+
})
|
|
754
|
+
}, [statsPath])
|
|
755
|
+
|
|
756
|
+
// Follow the agent across worktree_enter/exit. When the session's binding
|
|
757
|
+
// moves, the work the drawer exists to show moved with it — a drawer still
|
|
758
|
+
// pointed at the worktree the session just left is describing the past, and
|
|
759
|
+
// the reader has no way to know without clicking the chip themselves. A
|
|
760
|
+
// source the user pinned to some THIRD worktree is a deliberate choice and
|
|
761
|
+
// survives; only the view of the place the session used to be follows.
|
|
762
|
+
const bindingPath = binding?.worktreePath ?? null
|
|
763
|
+
const lastBindingRef = useRef(bindingPath)
|
|
764
|
+
useEffect(() => {
|
|
765
|
+
const prevBinding = lastBindingRef.current
|
|
766
|
+
lastBindingRef.current = bindingPath
|
|
767
|
+
if (prevBinding === bindingPath) return
|
|
768
|
+
const prevSource = prevBinding ?? worktreePath
|
|
769
|
+
if (sourcePath !== null && prevSource.replace(/\\/g, '/') === sourcePath.replace(/\\/g, '/')) {
|
|
770
|
+
setSourcePath(null)
|
|
771
|
+
}
|
|
772
|
+
}, [bindingPath, sourcePath, worktreePath])
|
|
773
|
+
|
|
774
|
+
// First page of the history list, reloaded whenever the worktree or the ref
|
|
775
|
+
// changes. The selection is dropped with it — a hash from another ref's log
|
|
776
|
+
// has no place in this one.
|
|
777
|
+
//
|
|
778
|
+
// Gated on the drawer being open: this panel is mounted in every session
|
|
779
|
+
// header, and a log nobody is looking at is a git spawn nobody asked for.
|
|
780
|
+
useEffect(() => {
|
|
781
|
+
if (!open) return
|
|
782
|
+
const ctrl = new AbortController()
|
|
783
|
+
let alive = true
|
|
784
|
+
setHistoryCommits([])
|
|
785
|
+
setHistoryHasMore(false)
|
|
786
|
+
setCommitHash(null)
|
|
787
|
+
setCommitStats(null)
|
|
788
|
+
setHistoryLoading(true)
|
|
789
|
+
fetchCommits(statsPath, effectiveHistoryRef, 0, HISTORY_PAGE, ctrl.signal)
|
|
790
|
+
.then(page => {
|
|
791
|
+
if (!alive) return
|
|
792
|
+
setHistoryLoading(false)
|
|
793
|
+
if (page === null) return
|
|
794
|
+
setHistoryCommits(page.commits)
|
|
795
|
+
setHistoryHasMore(page.hasMore)
|
|
796
|
+
})
|
|
797
|
+
.catch(() => { if (alive) setHistoryLoading(false) })
|
|
798
|
+
return () => { alive = false; ctrl.abort() }
|
|
799
|
+
}, [open, statsPath, effectiveHistoryRef, fetchCommits, gen])
|
|
800
|
+
|
|
801
|
+
// Never leave the history pane empty: with a list loaded and nothing picked,
|
|
802
|
+
// the newest commit is the selection.
|
|
803
|
+
useEffect(() => {
|
|
804
|
+
if (tab !== 'history' || commitHash !== null) return
|
|
805
|
+
const newest = historyCommits[0]
|
|
806
|
+
if (newest !== undefined) setCommitHash(newest.hash)
|
|
807
|
+
}, [tab, commitHash, historyCommits])
|
|
808
|
+
|
|
809
|
+
useEffect(() => {
|
|
810
|
+
if (commitHash === null) return
|
|
811
|
+
const key = `${statsPath ?? ''}\x1f${commitHash}`
|
|
812
|
+
const hit = commitCache.current.get(key)
|
|
813
|
+
if (hit !== undefined) { setCommitStats(hit); return }
|
|
814
|
+
const ctrl = new AbortController()
|
|
815
|
+
setCommitStats(null)
|
|
816
|
+
fetchCommitStats(statsPath, commitHash, ctrl.signal)
|
|
817
|
+
.then(value => {
|
|
818
|
+
if (value === null) return
|
|
819
|
+
const cache = commitCache.current
|
|
820
|
+
cache.delete(key)
|
|
821
|
+
cache.set(key, value)
|
|
822
|
+
if (cache.size > COMMIT_CACHE_CAPACITY) {
|
|
823
|
+
const oldest = cache.keys().next()
|
|
824
|
+
if (!oldest.done) cache.delete(oldest.value)
|
|
825
|
+
}
|
|
826
|
+
setCommitStats(value)
|
|
827
|
+
})
|
|
828
|
+
.catch(() => {})
|
|
829
|
+
return () => ctrl.abort()
|
|
830
|
+
}, [commitHash, statsPath, fetchCommitStats])
|
|
831
|
+
|
|
832
|
+
/** Refs the compare tab reads. An explicit pick wins; otherwise the session's
|
|
833
|
+
* own branch is compared against the integration branch. */
|
|
834
|
+
const headRef = compareHead ?? stats?.branch ?? ''
|
|
835
|
+
const baseRef = compareBase ?? defaultBase(branches, headRef)
|
|
836
|
+
/** A comparison needs two distinct, named refs; anything else has nothing to show. */
|
|
837
|
+
const comparable = baseRef.length > 0 && headRef.length > 0 && baseRef !== headRef
|
|
838
|
+
|
|
839
|
+
useEffect(() => {
|
|
840
|
+
if (tab !== 'compare' || !comparable) return
|
|
841
|
+
const ctrl = new AbortController()
|
|
842
|
+
setCompareStats(null)
|
|
843
|
+
fetchCompare(statsPath, baseRef, headRef, ctrl.signal)
|
|
844
|
+
.then(value => { if (value !== null) setCompareStats(value) })
|
|
845
|
+
.catch(() => {})
|
|
846
|
+
return () => ctrl.abort()
|
|
847
|
+
}, [tab, comparable, baseRef, headRef, statsPath, fetchCompare])
|
|
848
|
+
|
|
849
|
+
// A tick stays on the overlay only until a payload confirms it. The first
|
|
850
|
+
// fetch after the git call is exactly that confirmation; without this the
|
|
851
|
+
// optimistic layer would sit over the real flags forever. The functional
|
|
852
|
+
// update keeps an all-settled payload from re-rendering the tree for nothing.
|
|
853
|
+
useEffect(() => {
|
|
854
|
+
setPendingTicks(prev => {
|
|
855
|
+
if (prev.size === 0) return prev
|
|
856
|
+
const settled = settledTicks(stats?.files ?? [], prev)
|
|
857
|
+
if (settled.size === 0) return prev
|
|
858
|
+
const next = new Map(prev)
|
|
859
|
+
for (const path of settled.keys()) next.delete(path)
|
|
860
|
+
return next
|
|
861
|
+
})
|
|
862
|
+
}, [stats?.files])
|
|
863
|
+
|
|
864
|
+
/** What the drawer's tree, diff and totals describe. */
|
|
865
|
+
const shown = tab === 'history' ? commitStats : tab === 'compare' ? compareStats : stats
|
|
866
|
+
const segments = useMemo(() => splitDiff(shown?.diff ?? ''), [shown?.diff])
|
|
867
|
+
/** Names the view the per-file diff cache belongs to: one path means different
|
|
868
|
+
* content in the working tree, in each commit, and in each comparison. */
|
|
869
|
+
const viewKey = tab === 'history'
|
|
870
|
+
? `commit:${commitHash ?? ''}`
|
|
871
|
+
: tab === 'compare' ? `compare:${baseRef}...${headRef}` : 'worktree'
|
|
872
|
+
/** Per-file diff fetcher bound to the active view. Stable per view, so the
|
|
873
|
+
* drawer's on-demand effect stops re-running on every render. */
|
|
874
|
+
const fetchDiffForView = useCallback(
|
|
875
|
+
(path: string, signal: AbortSignal): Promise<string> => {
|
|
876
|
+
// A comparison's per-file diff would need the ref range, which `fileDiff`
|
|
877
|
+
// does not take. Answering with the working tree's diff for that path
|
|
878
|
+
// would be plainly wrong, so a file past the payload cap simply has no
|
|
879
|
+
// detail on this tab.
|
|
880
|
+
if (tab === 'compare') return Promise.resolve('')
|
|
881
|
+
return fetchFileDiff(statsPath, path, tab === 'history' ? commitHash ?? undefined : undefined, signal)
|
|
882
|
+
},
|
|
883
|
+
[fetchFileDiff, statsPath, tab, commitHash],
|
|
884
|
+
)
|
|
885
|
+
|
|
886
|
+
// First stats fetch still in flight: render nothing. The cheap binding RPC
|
|
887
|
+
// often resolves before the heavy stats one — without this guard a persisted
|
|
888
|
+
// binding would paint the chip with stats === null and crash EnvCard.
|
|
889
|
+
if (stats === null) return null
|
|
890
|
+
|
|
891
|
+
// Chip discipline: the environment card is the session's branch indicator —
|
|
892
|
+
// it renders whenever the directory is a git repo, clean tree included
|
|
893
|
+
// (branch + ahead/behind + 0-count totals). Only a stats error (not a repo /
|
|
894
|
+
// git unavailable) hides the card. An OPEN drawer always stays mounted, so an
|
|
895
|
+
// empty source can be switched away from.
|
|
896
|
+
if (stats.error !== undefined && !open) return null
|
|
897
|
+
|
|
898
|
+
const refresh = (): void => {
|
|
899
|
+
setGen(g => g + 1)
|
|
900
|
+
const ctrl = new AbortController()
|
|
901
|
+
fetchWorktreeStatus(sessionId, worktreePath, ctrl.signal).then(value => { if (value !== null) setWtStatus(value) }).catch(() => {})
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
/**
|
|
905
|
+
* Run one write operation, then refresh whatever it could have changed.
|
|
906
|
+
*
|
|
907
|
+
* The refresh is unconditional — a FAILED operation can still have changed the
|
|
908
|
+
* tree. A pull that stops on a conflict has already written conflict markers
|
|
909
|
+
* into the files, and a drawer still showing the pre-pull list would be
|
|
910
|
+
* describing a working tree that no longer exists.
|
|
911
|
+
*/
|
|
912
|
+
const runOp = async (op: GitOpName, payload: GitOpPayload = {}): Promise<GitOpResult> => {
|
|
913
|
+
// The lock is the ref, not the `busy` state: a drain loop issues its calls
|
|
914
|
+
// from one long-lived closure, and the state value captured at render
|
|
915
|
+
// never changes under it. `busy` is set below for display only.
|
|
916
|
+
if (busyRef.current !== null) return { ok: false, failure: 'unknown', error: 'another git operation is running' }
|
|
917
|
+
busyRef.current = op
|
|
918
|
+
setBusy(op)
|
|
919
|
+
try {
|
|
920
|
+
const result = await runGitOp(op, statsPath, payload, new AbortController().signal)
|
|
921
|
+
// The banner lives above the body, so every change to it moves the whole
|
|
922
|
+
// pane. Clearing it at the start of an op and re-showing it ~100ms later
|
|
923
|
+
// made the drawer shake on every tick — and a tick's outcome is already
|
|
924
|
+
// visible in place, in the box the user just clicked. So: the old banner
|
|
925
|
+
// stays while the op runs (it still describes the last outcome), a
|
|
926
|
+
// successful stage/unstage clears it rather than replacing it, and only
|
|
927
|
+
// heavy operations and failures announce themselves at all.
|
|
928
|
+
if (result.ok && (op === 'stage' || op === 'unstage')) setOpResult(null)
|
|
929
|
+
else setOpResult({ op, result })
|
|
930
|
+
return result
|
|
931
|
+
} finally {
|
|
932
|
+
busyRef.current = null
|
|
933
|
+
setBusy(null)
|
|
934
|
+
refresh()
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
/** Wait for the git lock, so a queued tick batch waits out a heavy
|
|
939
|
+
* operation instead of being refused by it. */
|
|
940
|
+
const waitNotBusy = async (): Promise<void> => {
|
|
941
|
+
while (busyRef.current !== null) await new Promise(resolve => { setTimeout(resolve, TICK_RETRY_MS) })
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
/**
|
|
945
|
+
* Hand queued ticks to git, one action-homogeneous batch at a time.
|
|
946
|
+
*
|
|
947
|
+
* One call at a time is what keeps a click from being dropped: two ticks
|
|
948
|
+
* 120ms apart used to race for the lock and the loser vanished without even
|
|
949
|
+
* an error. Now the second queues behind the first, and clicks arriving
|
|
950
|
+
* while a batch runs join the next batch as one `git add a b c`.
|
|
951
|
+
*
|
|
952
|
+
* Everything the loop must see fresh it reads through a ref; the queue
|
|
953
|
+
* itself is a ref because clicks arrive between the loop's awaits.
|
|
954
|
+
*/
|
|
955
|
+
const drainTicks = async (): Promise<void> => {
|
|
956
|
+
if (drainingRef.current) return
|
|
957
|
+
drainingRef.current = true
|
|
958
|
+
const epoch = tickEpochRef.current
|
|
959
|
+
try {
|
|
960
|
+
while (tickEpochRef.current === epoch) {
|
|
961
|
+
// Wait for the lock before batching, not after: ticks that arrived
|
|
962
|
+
// while a heavy operation held it then join one batch instead of
|
|
963
|
+
// forming one per click.
|
|
964
|
+
await waitNotBusy()
|
|
965
|
+
if (tickEpochRef.current !== epoch) break
|
|
966
|
+
const batch = nextBatch(tickQueueRef.current)
|
|
967
|
+
if (batch === null) break
|
|
968
|
+
tickQueueRef.current = tickQueueRef.current.filter(
|
|
969
|
+
tick => tick.action !== batch.action || !batch.paths.includes(tick.path),
|
|
970
|
+
)
|
|
971
|
+
const result = await runOp(batch.action, { paths: batch.paths })
|
|
972
|
+
if (!result.ok) {
|
|
973
|
+
// The git call refused or failed. Take the paths back off the
|
|
974
|
+
// overlay so the box shows what git actually did — the banner runOp
|
|
975
|
+
// raised says why — but only where the overlay still carries this
|
|
976
|
+
// action: a later click may already have re-ticked the path.
|
|
977
|
+
setPendingTicks(prev => {
|
|
978
|
+
const next = new Map(prev)
|
|
979
|
+
for (const path of batch.paths) {
|
|
980
|
+
if (next.get(path) === batch.action) next.delete(path)
|
|
981
|
+
}
|
|
982
|
+
return next
|
|
983
|
+
})
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
} finally {
|
|
987
|
+
drainingRef.current = false
|
|
988
|
+
// A click that arrived during the last batch found `draining` set and
|
|
989
|
+
// trusted this loop to come back for it; a source switch retires this
|
|
990
|
+
// loop with the next source's clicks already queued. Either way the
|
|
991
|
+
// queue decides: empty means done, anything else is handed to the
|
|
992
|
+
// current render's loop.
|
|
993
|
+
if (nextBatch(tickQueueRef.current) !== null) void drainRef.current()
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
drainRef.current = drainTicks
|
|
997
|
+
|
|
998
|
+
/**
|
|
999
|
+
* Record ticks the moment they are clicked and hand their git calls to the
|
|
1000
|
+
* queue.
|
|
1001
|
+
*
|
|
1002
|
+
* The overlay update is the part the click is felt by: the box and the "N
|
|
1003
|
+
* ticked" counter move in the same frame as the click, and the refetch that
|
|
1004
|
+
* used to be the click's whole latency becomes a confirmation nobody waits
|
|
1005
|
+
* for.
|
|
1006
|
+
*/
|
|
1007
|
+
const queueTicks = (action: TickAction, paths: readonly string[]): void => {
|
|
1008
|
+
if (paths.length === 0) return
|
|
1009
|
+
setPendingTicks(prev => {
|
|
1010
|
+
const next = new Map(prev)
|
|
1011
|
+
for (const path of paths) next.set(path, action)
|
|
1012
|
+
return next
|
|
1013
|
+
})
|
|
1014
|
+
tickQueueRef.current = [...tickQueueRef.current, ...paths.map(path => ({ path, action }))]
|
|
1015
|
+
void drainRef.current()
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
/** Drawer source switch: the new path flips `statsPath` (the stats effect
|
|
1019
|
+
* refetches); the gen bump clears on-demand diff caches, whose content is
|
|
1020
|
+
* per-source. Picking the session's own worktree clears the override rather
|
|
1021
|
+
* than pinning it, so a later agent enter/exit still moves the default. */
|
|
1022
|
+
const switchSource = (next: string): void => {
|
|
1023
|
+
setSourcePath(next === sessionPath ? null : next)
|
|
1024
|
+
setGen(g => g + 1)
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
const appearance: Appearance = { mode, family }
|
|
1028
|
+
const theme = resolveTheme(appearance, hostDark)
|
|
1029
|
+
|
|
1030
|
+
/** Persist alongside the state update, so the choice survives a reload. */
|
|
1031
|
+
const applyMode = (next: ColorMode): void => {
|
|
1032
|
+
setMode(next)
|
|
1033
|
+
writeStored(STORE_APPEARANCE, { mode: next, family })
|
|
1034
|
+
}
|
|
1035
|
+
const applyFamily = (next: ThemeFamily): void => {
|
|
1036
|
+
setFamily(next)
|
|
1037
|
+
writeStored(STORE_APPEARANCE, { mode, family: next })
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
/**
|
|
1041
|
+
* Apply one scope's styling, and optionally store it.
|
|
1042
|
+
*
|
|
1043
|
+
* The local update always happens first: a background is judged by looking at
|
|
1044
|
+
* it, and a round trip between the control and the change makes that
|
|
1045
|
+
* impossible. `persist` is false while a slider is being dragged — each store
|
|
1046
|
+
* is a file write on the host, and a range input emits one event per pixel.
|
|
1047
|
+
* @param scope - which scope to write.
|
|
1048
|
+
* @param entry - its new value.
|
|
1049
|
+
* @param persist - whether to send it to the host.
|
|
1050
|
+
* @returns the host's verdict, or a bare success when nothing was sent.
|
|
1051
|
+
*/
|
|
1052
|
+
const applyStyle = async (scope: StyleScope, entry: StyleEntry, persist: boolean): Promise<{ ok: boolean; error?: string }> => {
|
|
1053
|
+
setStyle(prev => withScope(prev, scope, entry))
|
|
1054
|
+
if (!persist) return { ok: true }
|
|
1055
|
+
// A refusal leaves the optimistic value on screen but unsaved; re-reading
|
|
1056
|
+
// would silently discard what the user is looking at, so the menu says so.
|
|
1057
|
+
return saveStyle(statsPath, scope, entry, new AbortController().signal)
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
/**
|
|
1061
|
+
* Drag a pane divider.
|
|
1062
|
+
*
|
|
1063
|
+
* The upper bound is what keeps the diff readable: a pane may grow only into
|
|
1064
|
+
* space the other two do not need, so it is derived from the drawer's measured
|
|
1065
|
+
* width minus the neighbour's current width and the diff's minimum.
|
|
1066
|
+
* @param which - the pane being resized.
|
|
1067
|
+
* @param next - width in px the pointer implies.
|
|
1068
|
+
* @param measured - the drawer's inner width and the panes' current widths.
|
|
1069
|
+
* @param persist - whether to store it; false for intermediate drag frames.
|
|
1070
|
+
*/
|
|
1071
|
+
const applyPane = (which: keyof PaneWidths, next: number, measured: { drawer: number; commits: number; tree: number }, persist: boolean): void => {
|
|
1072
|
+
const min = which === 'commits' ? MIN_COMMITS_WIDTH : MIN_TREE_WIDTH
|
|
1073
|
+
const other = which === 'commits' ? measured.tree : measured.commits
|
|
1074
|
+
const max = Math.max(min, measured.drawer - other - MIN_DIFF_WIDTH)
|
|
1075
|
+
const clamped = Math.min(Math.max(next, min), max)
|
|
1076
|
+
setPanes(prev => {
|
|
1077
|
+
const updated = { ...prev, [which]: clamped }
|
|
1078
|
+
if (persist) writeStored(STORE_PANES, updated)
|
|
1079
|
+
return updated
|
|
1080
|
+
})
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
/**
|
|
1084
|
+
* Drag the leading edge. Clamped at both ends — below the minimum the three
|
|
1085
|
+
* panes stop fitting, and past the viewport there is nothing to reveal. The
|
|
1086
|
+
* drag itself measures from the card's own right edge, so the upper clamp
|
|
1087
|
+
* lands on the inset card width without restating the inset here.
|
|
1088
|
+
* Dragging ends maximization, since the user just chose a width.
|
|
1089
|
+
* @param next - width in px the pointer implies.
|
|
1090
|
+
* @param persist - whether to store it. False for every intermediate frame of
|
|
1091
|
+
* a drag: `localStorage` writes synchronously, and one per pointermove would
|
|
1092
|
+
* put a disk write in the middle of the resize.
|
|
1093
|
+
*/
|
|
1094
|
+
const applyWidth = (next: number, persist: boolean): void => {
|
|
1095
|
+
const clamped = Math.min(Math.max(next, MIN_DRAWER_WIDTH), window.innerWidth)
|
|
1096
|
+
setWidth(clamped)
|
|
1097
|
+
setMaximized(false)
|
|
1098
|
+
if (persist) writeStored(STORE_WIDTH, clamped)
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
/** Tab switch. No direction refetches the working tree: `viewKey` already
|
|
1102
|
+
* separates the tabs' per-file diff caches, so bumping `gen` here only cost a
|
|
1103
|
+
* redundant round trip. */
|
|
1104
|
+
const switchTab = (next: Tab): void => {
|
|
1105
|
+
setTab(next)
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
/** Append the next page of the log.
|
|
1109
|
+
*
|
|
1110
|
+
* The guard is a ref, not the `loadingMore` state: the scroll sentinel can
|
|
1111
|
+
* fire again before React has re-rendered with the state set, and two calls
|
|
1112
|
+
* at the same offset would append the same page twice. */
|
|
1113
|
+
const loadMoreCommits = (): void => {
|
|
1114
|
+
if (loadingRef.current || !historyHasMore) return
|
|
1115
|
+
loadingRef.current = true
|
|
1116
|
+
setLoadingMore(true)
|
|
1117
|
+
const ctrl = new AbortController()
|
|
1118
|
+
fetchCommits(statsPath, effectiveHistoryRef, historyCommits.length, HISTORY_PAGE, ctrl.signal)
|
|
1119
|
+
.then(page => {
|
|
1120
|
+
if (page === null) return
|
|
1121
|
+
setHistoryCommits(prev => [...prev, ...page.commits])
|
|
1122
|
+
setHistoryHasMore(page.hasMore)
|
|
1123
|
+
})
|
|
1124
|
+
.catch(() => {})
|
|
1125
|
+
.finally(() => { loadingRef.current = false; setLoadingMore(false) })
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
/** Selecting a commit changes which view is rendered; the working tree it is
|
|
1129
|
+
* shown beside has not moved, so nothing about `stats` is refetched. */
|
|
1130
|
+
const selectCommit = (hash: string): void => {
|
|
1131
|
+
setCommitHash(hash)
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
return (
|
|
1135
|
+
<>
|
|
1136
|
+
<EnvCard
|
|
1137
|
+
stats={stats}
|
|
1138
|
+
t={t}
|
|
1139
|
+
wtName={binding?.name ?? null}
|
|
1140
|
+
title={stats.worktreePath}
|
|
1141
|
+
onClick={() => { setOpen(true); setSourcePath(null); setTab('changes'); setGen(g => g + 1) }}
|
|
1142
|
+
/>
|
|
1143
|
+
{open ? (
|
|
1144
|
+
<Drawer
|
|
1145
|
+
stats={stats}
|
|
1146
|
+
shown={shown}
|
|
1147
|
+
/** Per-tab in-flight flags, so an empty pane can say "loading" instead
|
|
1148
|
+
* of claiming the repository has nothing in it. */
|
|
1149
|
+
treeLoading={tab === 'changes' ? statsLoading
|
|
1150
|
+
: tab === 'history' ? historyLoading || (commitHash !== null && commitStats === null)
|
|
1151
|
+
: comparable && compareStats === null}
|
|
1152
|
+
historyLoading={historyLoading}
|
|
1153
|
+
tab={tab}
|
|
1154
|
+
onSwitchTab={switchTab}
|
|
1155
|
+
commits={historyCommits}
|
|
1156
|
+
commitHash={commitHash}
|
|
1157
|
+
onSelectCommit={selectCommit}
|
|
1158
|
+
hasMoreCommits={historyHasMore}
|
|
1159
|
+
loadingMore={loadingMore}
|
|
1160
|
+
onLoadMoreCommits={loadMoreCommits}
|
|
1161
|
+
historyRef={effectiveHistoryRef}
|
|
1162
|
+
onHistoryRef={setHistoryRef}
|
|
1163
|
+
branches={branches}
|
|
1164
|
+
worktreeBranches={worktreeBranches}
|
|
1165
|
+
branchesTruncated={branchesTruncated}
|
|
1166
|
+
baseRef={baseRef}
|
|
1167
|
+
headRef={headRef}
|
|
1168
|
+
onBaseRef={setCompareBase}
|
|
1169
|
+
onHeadRef={setCompareHead}
|
|
1170
|
+
comparable={comparable}
|
|
1171
|
+
t={t}
|
|
1172
|
+
binding={binding}
|
|
1173
|
+
worktrees={worktrees}
|
|
1174
|
+
sessionPath={sessionPath}
|
|
1175
|
+
statsPath={statsPath}
|
|
1176
|
+
onSwitchSource={switchSource}
|
|
1177
|
+
segments={segments}
|
|
1178
|
+
selected={selected}
|
|
1179
|
+
onSelect={setSelected}
|
|
1180
|
+
maximized={maximized}
|
|
1181
|
+
onToggleMaximized={() => setMaximized(value => !value)}
|
|
1182
|
+
theme={theme}
|
|
1183
|
+
mode={mode}
|
|
1184
|
+
family={family}
|
|
1185
|
+
onMode={applyMode}
|
|
1186
|
+
onFamily={applyFamily}
|
|
1187
|
+
style={style}
|
|
1188
|
+
background={background}
|
|
1189
|
+
onStyle={applyStyle}
|
|
1190
|
+
width={width}
|
|
1191
|
+
onWidth={applyWidth}
|
|
1192
|
+
panes={panes}
|
|
1193
|
+
onPane={applyPane}
|
|
1194
|
+
onClose={() => setOpen(false)}
|
|
1195
|
+
onRefresh={refresh}
|
|
1196
|
+
commitDraft={commitDraft}
|
|
1197
|
+
onCommitDraft={setCommitDraft}
|
|
1198
|
+
commitAmend={commitAmend}
|
|
1199
|
+
onCommitAmend={setCommitAmend}
|
|
1200
|
+
sync={sync}
|
|
1201
|
+
busy={busy}
|
|
1202
|
+
opResult={opResult}
|
|
1203
|
+
runOp={runOp}
|
|
1204
|
+
pendingTicks={pendingTicks}
|
|
1205
|
+
onTick={queueTicks}
|
|
1206
|
+
fetchFileDiff={fetchDiffForView}
|
|
1207
|
+
viewKey={viewKey}
|
|
1208
|
+
gen={gen}
|
|
1209
|
+
collapsed={collapsed}
|
|
1210
|
+
onCollapsedChange={setCollapsed}
|
|
1211
|
+
/>
|
|
1212
|
+
) : null}
|
|
1213
|
+
</>
|
|
1214
|
+
)
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
/* ---------- header environment card ---------- */
|
|
1218
|
+
|
|
1219
|
+
/**
|
|
1220
|
+
* The drawer's window controls, as glyphs.
|
|
1221
|
+
*
|
|
1222
|
+
* Four words in four identical pills read as a paragraph, not as controls — and
|
|
1223
|
+
* three of these four are the actions every window on the machine already spells
|
|
1224
|
+
* with a picture. Bootstrap Icons (MIT), one 16 viewBox, one fill, so the row
|
|
1225
|
+
* reads as a set rather than four drawings that happen to sit together.
|
|
1226
|
+
*/
|
|
1227
|
+
const CHROME_GLYPH = {
|
|
1228
|
+
settings: 'M8 4.754a3.246 3.246 0 1 0 0 6.492 3.246 3.246 0 0 0 0-6.492zM5.754 8a2.246 2.246 0 1 1 4.492 0 2.246 2.246 0 0 1-4.492 0z M9.796 1.343c-.527-1.79-3.065-1.79-3.592 0l-.094.319a.873.873 0 0 1-1.255.52l-.292-.16c-1.64-.892-3.433.902-2.54 2.541l.159.292a.873.873 0 0 1-.52 1.255l-.319.094c-1.79.527-1.79 3.065 0 3.592l.319.094a.873.873 0 0 1 .52 1.255l-.16.292c-.892 1.64.901 3.434 2.541 2.54l.292-.159a.873.873 0 0 1 1.255.52l.094.319c.527 1.79 3.065 1.79 3.592 0l.094-.319a.873.873 0 0 1 1.255-.52l.292.16c1.64.893 3.434-.902 2.54-2.541l-.159-.292a.873.873 0 0 1 .52-1.255l.319-.094c1.79-.527 1.79-3.065 0-3.592l-.319-.094a.873.873 0 0 1-.52-1.255l.16-.292c.893-1.64-.902-3.433-2.541-2.54l-.292.159a.873.873 0 0 1-1.255-.52l-.094-.319zm-2.633.283c.246-.835 1.428-.835 1.674 0l.094.319a1.873 1.873 0 0 0 2.693 1.115l.291-.16c.764-.415 1.6.42 1.184 1.185l-.159.292a1.873 1.873 0 0 0 1.116 2.692l.318.094c.835.246.835 1.428 0 1.674l-.319.094a1.873 1.873 0 0 0-1.115 2.693l.16.291c.415.764-.42 1.6-1.185 1.184l-.291-.159a1.873 1.873 0 0 0-2.693 1.116l-.094.318c-.246.835-1.428.835-1.674 0l-.094-.319a1.873 1.873 0 0 0-2.692-1.115l-.292.16c-.764.415-1.6-.42-1.184-1.185l.159-.291A1.873 1.873 0 0 0 1.945 8.93l-.319-.094c-.835-.246-.835-1.428 0-1.674l.319-.094A1.873 1.873 0 0 0 3.06 4.377l-.16-.292c-.415-.764.42-1.6 1.185-1.184l.292.159a1.873 1.873 0 0 0 2.692-1.115l.094-.319z',
|
|
1229
|
+
maximize: 'M1.5 1a.5.5 0 0 0-.5.5v4a.5.5 0 0 1-1 0v-4A1.5 1.5 0 0 1 1.5 0h4a.5.5 0 0 1 0 1h-4zM10 .5a.5.5 0 0 1 .5-.5h4A1.5 1.5 0 0 1 16 1.5v4a.5.5 0 0 1-1 0v-4a.5.5 0 0 0-.5-.5h-4a.5.5 0 0 1-.5-.5zM.5 10a.5.5 0 0 1 .5.5v4a.5.5 0 0 0 .5.5h4a.5.5 0 0 1 0 1h-4A1.5 1.5 0 0 1 0 14.5v-4a.5.5 0 0 1 .5-.5zm15 0a.5.5 0 0 1 .5.5v4a1.5 1.5 0 0 1-1.5 1.5h-4a.5.5 0 0 1 0-1h4a.5.5 0 0 0 .5-.5v-4a.5.5 0 0 1 .5-.5z',
|
|
1230
|
+
restore: 'M5.5 0a.5.5 0 0 1 .5.5v4A1.5 1.5 0 0 1 4.5 6h-4a.5.5 0 0 1 0-1h4a.5.5 0 0 0 .5-.5v-4a.5.5 0 0 1 .5-.5zm5 0a.5.5 0 0 1 .5.5v4a.5.5 0 0 0 .5.5h4a.5.5 0 0 1 0 1h-4A1.5 1.5 0 0 1 10 4.5v-4a.5.5 0 0 1 .5-.5zM0 10.5a.5.5 0 0 1 .5-.5h4A1.5 1.5 0 0 1 6 11.5v4a.5.5 0 0 1-1 0v-4a.5.5 0 0 0-.5-.5h-4a.5.5 0 0 1-.5-.5zm10 1a1.5 1.5 0 0 1 1.5-1.5h4a.5.5 0 0 1 0 1h-4a.5.5 0 0 0-.5.5v4a.5.5 0 0 1-1 0v-4z',
|
|
1231
|
+
// Single arrow, deliberately: the sync bar's Fetch is the two-arrow circle,
|
|
1232
|
+
// and at 14px the only thing telling them apart is the arrow count.
|
|
1233
|
+
refresh: 'M8 3a5 5 0 1 0 4.546 2.914.5.5 0 0 1 .908-.417A6 6 0 1 1 8 2v1z M8 4.466V.534a.25.25 0 0 1 .41-.192l2.36 1.966c.12.1.12.284 0 .384L8.41 4.658A.25.25 0 0 1 8 4.466z',
|
|
1234
|
+
close: 'M2.146 2.854a.5.5 0 1 1 .708-.708L8 7.293l5.146-5.147a.5.5 0 0 1 .708.708L8.707 8l5.147 5.146a.5.5 0 0 1-.708.708L8 8.707l-5.146 5.147a.5.5 0 0 1-.708-.708L7.293 8 2.146 2.854Z',
|
|
1235
|
+
} as const
|
|
1236
|
+
|
|
1237
|
+
function ChromeGlyph({ of }: { of: keyof typeof CHROME_GLYPH }): ReactNode {
|
|
1238
|
+
return (
|
|
1239
|
+
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
|
|
1240
|
+
<path d={CHROME_GLYPH[of]} />
|
|
1241
|
+
</svg>
|
|
1242
|
+
)
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
/**
|
|
1246
|
+
* Tree glyph: a root with two working copies hanging off it.
|
|
1247
|
+
*
|
|
1248
|
+
* This was git's fork glyph — the three-dot branch symbol — which named the
|
|
1249
|
+
* wrong thing. A worktree is not a branch; the picker beside it is already full
|
|
1250
|
+
* of branch names, and the two ideas need to stay tellable apart at 12px. A
|
|
1251
|
+
* hierarchy reads as "one repository, several directories", which is what a
|
|
1252
|
+
* worktree list is.
|
|
1253
|
+
*/
|
|
1254
|
+
function WorktreeGlyph(): ReactNode {
|
|
1255
|
+
return (
|
|
1256
|
+
<svg className={css.cardGlyph} width="12" height="12" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
|
|
1257
|
+
{/* Trunk down from the root, and the two limbs it puts out. */}
|
|
1258
|
+
<path d="M2.25 2.75h1.5v10.5h-1.5zM3 6.75h7.25v1.5H3zM3 11.75h7.25v1.5H3z" />
|
|
1259
|
+
{/* The root, then the worktrees. */}
|
|
1260
|
+
<circle cx="3" cy="2.75" r="1.75" />
|
|
1261
|
+
<circle cx="12" cy="7.5" r="1.75" />
|
|
1262
|
+
<circle cx="12" cy="12.5" r="1.75" />
|
|
1263
|
+
</svg>
|
|
1264
|
+
)
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
interface EnvCardProps { stats: WorkbenchStats; t: Translate; wtName: string | null; title: string; onClick: () => void }
|
|
1268
|
+
|
|
1269
|
+
function EnvCard({ stats, t, wtName, title, onClick }: EnvCardProps): ReactNode {
|
|
1270
|
+
/* dsh derives the branch from the worktree's name, so for anything it created
|
|
1271
|
+
the badge was a second printing of the chip beside it — `wt/fixture-03`
|
|
1272
|
+
next to `fixture-03`. The glyph still says "this session is in a worktree";
|
|
1273
|
+
only the repeated word goes. Where the two names are independent (a
|
|
1274
|
+
worktree made outside dsh) the badge is the only thing naming the
|
|
1275
|
+
directory, so it stays — and its presence then means something. */
|
|
1276
|
+
const repeats = wtName !== null && badgeRepeatsBranch(stats.branch, wtName)
|
|
1277
|
+
return (
|
|
1278
|
+
<button type="button" className={css.card} title={title} onClick={onClick}>
|
|
1279
|
+
<span className={css.cardBranch}>
|
|
1280
|
+
{repeats ? <WorktreeGlyph /> : null}
|
|
1281
|
+
<Elided text={branchLabel(stats.branch, t('noBranch'))} className={css.cardBranchName} />
|
|
1282
|
+
</span>
|
|
1283
|
+
{wtName !== null && !repeats ? <span className={css.cardWt}><WorktreeGlyph />{wtName}</span> : null}
|
|
1284
|
+
{stats.detached ? <span className={css.cardDetached}>detached</span> : null}
|
|
1285
|
+
{stats.ahead > 0 ? <span className={css.cardAhead} title={t('aheadTitle', { count: stats.ahead })}>↑{stats.ahead}</span> : null}
|
|
1286
|
+
{stats.behind > 0 ? <span className={css.cardBehind} title={t('behindTitle', { count: stats.behind })}>↓{stats.behind}</span> : null}
|
|
1287
|
+
{stats.files.length > 0 ? (
|
|
1288
|
+
<>
|
|
1289
|
+
<span className={css.cardSep} />
|
|
1290
|
+
<span className={css.cardAdded}>+{stats.addedLines}</span>
|
|
1291
|
+
<span className={css.cardDeleted}>−{stats.deletedLines}</span>
|
|
1292
|
+
<span className={css.cardFiles}>{t('files', { count: stats.files.length })}</span>
|
|
1293
|
+
</>
|
|
1294
|
+
) : null}
|
|
1295
|
+
</button>
|
|
1296
|
+
)
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
/* ---------- drawer ---------- */
|
|
1300
|
+
|
|
1301
|
+
interface DrawerProps {
|
|
1302
|
+
/** The working tree — always the commit-list source and the header's branch. */
|
|
1303
|
+
stats: WorkbenchStats
|
|
1304
|
+
/** What the tree/diff/totals describe: the working tree, or the picked commit
|
|
1305
|
+
* (null while its fetch is in flight). */
|
|
1306
|
+
shown: WorkbenchStats | null
|
|
1307
|
+
tab: Tab
|
|
1308
|
+
onSwitchTab: (next: Tab) => void
|
|
1309
|
+
/** The whole history list: the bundled first page plus every page loaded since. */
|
|
1310
|
+
commits: readonly GitCommit[]
|
|
1311
|
+
commitHash: string | null
|
|
1312
|
+
onSelectCommit: (hash: string) => void
|
|
1313
|
+
hasMoreCommits: boolean
|
|
1314
|
+
loadingMore: boolean
|
|
1315
|
+
onLoadMoreCommits: () => void
|
|
1316
|
+
/** Ref the history list walks. */
|
|
1317
|
+
historyRef: string
|
|
1318
|
+
onHistoryRef: (ref: string) => void
|
|
1319
|
+
/** Every local branch — the ref pickers' options, worktree or not. */
|
|
1320
|
+
branches: readonly string[]
|
|
1321
|
+
/** Branches that have a worktree, grouped to the top of every picker. */
|
|
1322
|
+
worktreeBranches: readonly string[]
|
|
1323
|
+
/** Whether the host cut the branch list short. */
|
|
1324
|
+
branchesTruncated: boolean
|
|
1325
|
+
baseRef: string
|
|
1326
|
+
headRef: string
|
|
1327
|
+
onBaseRef: (ref: string) => void
|
|
1328
|
+
onHeadRef: (ref: string) => void
|
|
1329
|
+
/** False when the two refs are missing or identical, which has nothing to show. */
|
|
1330
|
+
comparable: boolean
|
|
1331
|
+
t: Translate
|
|
1332
|
+
binding: WorktreeBinding | null
|
|
1333
|
+
/** Every worktree of the repository — the source picker's options. */
|
|
1334
|
+
worktrees: readonly WorktreeEntry[]
|
|
1335
|
+
/** The session's own worktree (bound one, else its cwd): the default option. */
|
|
1336
|
+
sessionPath: string | undefined
|
|
1337
|
+
/** Worktree currently read; kept at the panel (it owns the fetch path). */
|
|
1338
|
+
statsPath: string | undefined
|
|
1339
|
+
onSwitchSource: (next: string) => void
|
|
1340
|
+
segments: Map<string, string>
|
|
1341
|
+
selected: string | null
|
|
1342
|
+
onSelect: (path: string | null) => void
|
|
1343
|
+
/** Whether the drawer fills the viewport. */
|
|
1344
|
+
maximized: boolean
|
|
1345
|
+
onToggleMaximized: () => void
|
|
1346
|
+
/** Resolved palette name for `data-gs-theme`. */
|
|
1347
|
+
theme: string
|
|
1348
|
+
mode: ColorMode
|
|
1349
|
+
family: ThemeFamily
|
|
1350
|
+
onMode: (next: ColorMode) => void
|
|
1351
|
+
onFamily: (next: ThemeFamily) => void
|
|
1352
|
+
/** Both styling scopes, unresolved — the menu edits them separately. */
|
|
1353
|
+
style: StyleSettings
|
|
1354
|
+
/** The background actually shown, already resolved; null for none. */
|
|
1355
|
+
background: StyleEntry | null
|
|
1356
|
+
/** Applies a styling change; `persist` is false for intermediate slider frames. */
|
|
1357
|
+
onStyle: (scope: StyleScope, entry: StyleEntry, persist: boolean) => Promise<{ ok: boolean; error?: string }>
|
|
1358
|
+
/** Dragged width in px; null keeps the responsive default. */
|
|
1359
|
+
width: number | null
|
|
1360
|
+
/** Applies a dragged width; `persist` is true only for the frame that ends the drag. */
|
|
1361
|
+
onWidth: (next: number, persist: boolean) => void
|
|
1362
|
+
/** Dragged pane widths; null on either side keeps that pane's CSS default. */
|
|
1363
|
+
panes: PaneWidths
|
|
1364
|
+
onPane: (which: keyof PaneWidths, next: number, measured: { drawer: number; commits: number; tree: number }, persist: boolean) => void
|
|
1365
|
+
onClose: () => void
|
|
1366
|
+
onRefresh: () => void
|
|
1367
|
+
/** Commit draft, lifted so a tab switch cannot discard it. */
|
|
1368
|
+
commitDraft: string
|
|
1369
|
+
onCommitDraft: (next: string) => void
|
|
1370
|
+
commitAmend: boolean
|
|
1371
|
+
onCommitAmend: (next: boolean) => void
|
|
1372
|
+
/** Divergence from the upstream; null outside a repo or before the first read. */
|
|
1373
|
+
sync: SyncStatus | null
|
|
1374
|
+
/** Whether the tree's file list is still in flight for the view on screen.
|
|
1375
|
+
* The pane says "loading" rather than rendering the empty stand-in as a
|
|
1376
|
+
* "no changes" claim the data has not made yet. */
|
|
1377
|
+
treeLoading: boolean
|
|
1378
|
+
/** Whether the history list's first page is in flight — same rule. */
|
|
1379
|
+
historyLoading: boolean
|
|
1380
|
+
/** The write operation in flight, or null. Disables the others while set. */
|
|
1381
|
+
busy: GitOpName | null
|
|
1382
|
+
/** The last write operation's outcome, or null once a new one starts. */
|
|
1383
|
+
opResult: { op: GitOpName; result: GitOpResult } | null
|
|
1384
|
+
runOp: (op: GitOpName, payload?: GitOpPayload) => Promise<GitOpResult>
|
|
1385
|
+
/** Ticks awaiting their git call, keyed by path — overlaid over the file
|
|
1386
|
+
* list so the click is on screen before git confirms it. */
|
|
1387
|
+
pendingTicks: ReadonlyMap<string, TickAction>
|
|
1388
|
+
/** Queue the git calls for a tick batch. */
|
|
1389
|
+
onTick: (action: TickAction, paths: readonly string[]) => void
|
|
1390
|
+
fetchFileDiff: (path: string, signal: AbortSignal) => Promise<string>
|
|
1391
|
+
/** Identifies the view the per-file diff cache belongs to (working tree, or one commit). */
|
|
1392
|
+
viewKey: string
|
|
1393
|
+
gen: number
|
|
1394
|
+
collapsed: Set<string> | undefined
|
|
1395
|
+
onCollapsedChange: (next: Set<string>) => void
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
function Drawer({ stats, shown, tab, onSwitchTab, commits, commitHash, onSelectCommit, hasMoreCommits, loadingMore, onLoadMoreCommits, historyRef, onHistoryRef, branches, worktreeBranches, branchesTruncated, baseRef, headRef, onBaseRef, onHeadRef, comparable, t, binding, worktrees, sessionPath, statsPath, onSwitchSource, segments, selected, onSelect, maximized, onToggleMaximized, theme, mode, family, onMode, onFamily, style, background, onStyle, width, onWidth, panes, onPane, onClose, onRefresh, commitDraft, onCommitDraft, commitAmend, onCommitAmend, sync, treeLoading, historyLoading, busy, opResult, runOp, pendingTicks, onTick, fetchFileDiff, viewKey, gen, collapsed, onCollapsedChange }: DrawerProps): ReactNode {
|
|
1399
|
+
// Empty stand-in while a commit's change set loads, so every hook below keeps a
|
|
1400
|
+
// stable shape and the panes simply render nothing.
|
|
1401
|
+
const body = shown ?? EMPTY_STATS
|
|
1402
|
+
/** The file list with ticks still awaiting git laid over them. The tree and
|
|
1403
|
+
* the commit box read this, so a click moves its box and the "N ticked"
|
|
1404
|
+
* counter in the click's own frame rather than a refetch later. Same
|
|
1405
|
+
* reference as `body.files` whenever nothing is pending. */
|
|
1406
|
+
const tickedFiles = withPendingTicks(body.files, pendingTicks)
|
|
1407
|
+
/** Whether this view has nothing to show YET, as opposed to showing good data
|
|
1408
|
+
* while a refresh lands over it. Derived once and handed to both the header
|
|
1409
|
+
* and the tree: spelling it twice is what let the header get it wrong. */
|
|
1410
|
+
const pending = showsPending(treeLoading, body.files.length)
|
|
1411
|
+
// A selection the current source no longer lists (e.g. after a source or tab
|
|
1412
|
+
// switch) falls back to the first file — never a dangling highlight.
|
|
1413
|
+
const active = selected !== null && body.files.some(file => file.path === selected)
|
|
1414
|
+
? selected
|
|
1415
|
+
: body.files[0]?.path ?? null
|
|
1416
|
+
const activeFile = body.files.find(file => file.path === active) ?? null
|
|
1417
|
+
const [fetched, setFetched] = useState<Map<string, string>>(new Map())
|
|
1418
|
+
const [loading, setLoading] = useState(false)
|
|
1419
|
+
const bundled = active === null ? '' : segments.get(active) ?? ''
|
|
1420
|
+
// The cache spans views, so its key names one: the same path holds different
|
|
1421
|
+
// content in the working tree and in every commit.
|
|
1422
|
+
const activeKey = active === null ? null : `${viewKey}\x1f${active}`
|
|
1423
|
+
const segment = bundled.length > 0 ? bundled : activeKey === null ? '' : fetched.get(activeKey) ?? ''
|
|
1424
|
+
|
|
1425
|
+
// On-demand diff for files absent from the bundled payload (cap-truncated
|
|
1426
|
+
// untracked files, oversize paths).
|
|
1427
|
+
useEffect(() => {
|
|
1428
|
+
const path = active
|
|
1429
|
+
if (path === null || activeKey === null || bundled.length > 0) return
|
|
1430
|
+
const file = body.files.find(f => f.path === path)
|
|
1431
|
+
if (file === undefined || file.binary) return
|
|
1432
|
+
if (fetched.has(activeKey)) return
|
|
1433
|
+
const ctrl = new AbortController()
|
|
1434
|
+
setLoading(true)
|
|
1435
|
+
fetchFileDiff(path, ctrl.signal)
|
|
1436
|
+
.then(diff => { setFetched(prev => new Map(prev).set(activeKey, diff)) })
|
|
1437
|
+
.catch(() => {})
|
|
1438
|
+
.finally(() => { setLoading(false) })
|
|
1439
|
+
return () => ctrl.abort()
|
|
1440
|
+
}, [active, activeKey, bundled, body.files, fetched, fetchFileDiff])
|
|
1441
|
+
|
|
1442
|
+
// Reset the on-demand cache when the generation (refresh) advances.
|
|
1443
|
+
useEffect(() => { setFetched(new Map()) }, [gen])
|
|
1444
|
+
|
|
1445
|
+
const selectAndReveal = (path: string): void => onSelect(path)
|
|
1446
|
+
|
|
1447
|
+
const drawerRef = useRef<HTMLDivElement>(null)
|
|
1448
|
+
const commitsRef = useRef<HTMLDivElement>(null)
|
|
1449
|
+
const treeRef = useRef<HTMLDivElement>(null)
|
|
1450
|
+
const edgeDrag = useHorizontalDrag()
|
|
1451
|
+
|
|
1452
|
+
/**
|
|
1453
|
+
* What a pane drag is clamped against: the drawer's inner width and what the
|
|
1454
|
+
* panes currently occupy. Read live, because the drawer itself can have been
|
|
1455
|
+
* resized since the last render.
|
|
1456
|
+
* @returns the three widths in px.
|
|
1457
|
+
*/
|
|
1458
|
+
const measurePanes = (): { drawer: number; commits: number; tree: number } => ({
|
|
1459
|
+
drawer: drawerRef.current?.clientWidth ?? window.innerWidth,
|
|
1460
|
+
commits: commitsRef.current?.getBoundingClientRect().width ?? 0,
|
|
1461
|
+
tree: treeRef.current?.getBoundingClientRect().width ?? 0,
|
|
1462
|
+
})
|
|
1463
|
+
|
|
1464
|
+
/**
|
|
1465
|
+
* @param which - the pane a divider resizes.
|
|
1466
|
+
* @param ref - that pane's element, whose left edge the width is measured from.
|
|
1467
|
+
* @returns a drag handler for {@link PaneDivider}.
|
|
1468
|
+
*/
|
|
1469
|
+
const paneDrag = (which: keyof PaneWidths, ref: { current: HTMLDivElement | null }) =>
|
|
1470
|
+
(clientX: number, done: boolean): void => {
|
|
1471
|
+
const left = ref.current?.getBoundingClientRect().left
|
|
1472
|
+
if (left === undefined) return
|
|
1473
|
+
onPane(which, clientX - left, measurePanes(), done)
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
/** The drawer's leading edge, measured from the card's own right edge — fixed
|
|
1477
|
+
* for the whole drag, so the inset between card and viewport is never
|
|
1478
|
+
* restated in JS. */
|
|
1479
|
+
const edgeDragHandler = (clientX: number, done: boolean): void => {
|
|
1480
|
+
const right = drawerRef.current?.getBoundingClientRect().right ?? window.innerWidth
|
|
1481
|
+
onWidth(right - clientX, done)
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1484
|
+
/** A dragged pane width must beat the stylesheet's `max-width`, which was
|
|
1485
|
+
* written for the undragged default. */
|
|
1486
|
+
const paneStyle = (px: number | null): CSSProperties | undefined =>
|
|
1487
|
+
px === null ? undefined : { width: `${px}px`, maxWidth: 'none' }
|
|
1488
|
+
|
|
1489
|
+
// Width and the background's three tunables are inline because both are live
|
|
1490
|
+
// user values; the stylesheet only says what reads them. The pane floors are
|
|
1491
|
+
// inline for a different reason: they belong to the drag clamp above, and
|
|
1492
|
+
// restating them in CSS would give one fact two homes that can disagree.
|
|
1493
|
+
const cardStyle: CSSProperties = {
|
|
1494
|
+
// `@types/react` 18's CSSProperties has no index signature for custom
|
|
1495
|
+
// properties, so every --gs-* group is asserted rather than declared.
|
|
1496
|
+
...{
|
|
1497
|
+
'--gs-min-commits': `${MIN_COMMITS_WIDTH}px`,
|
|
1498
|
+
'--gs-min-tree': `${MIN_TREE_WIDTH}px`,
|
|
1499
|
+
'--gs-min-diff': `${MIN_DIFF_WIDTH}px`,
|
|
1500
|
+
} as CSSProperties,
|
|
1501
|
+
...maximized || width === null ? {} : { width: `${width}px` },
|
|
1502
|
+
...background === null ? {} : {
|
|
1503
|
+
'--gs-bg-image': `url("${background.image}")`,
|
|
1504
|
+
'--gs-bg-blur': `${background.blur}px`,
|
|
1505
|
+
'--gs-veil': `${background.veil}%`,
|
|
1506
|
+
} as CSSProperties,
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1509
|
+
return (
|
|
1510
|
+
<div
|
|
1511
|
+
className={maximized ? `${css.overlay} ${css.overlayMax}` : css.overlay}
|
|
1512
|
+
data-gs-theme={theme}
|
|
1513
|
+
data-gs-part="overlay"
|
|
1514
|
+
onClick={onClose}
|
|
1515
|
+
>
|
|
1516
|
+
<div
|
|
1517
|
+
ref={drawerRef}
|
|
1518
|
+
className={css.drawer}
|
|
1519
|
+
style={cardStyle}
|
|
1520
|
+
data-gs-part="card"
|
|
1521
|
+
{...background === null ? {} : { 'data-gs-bg': '' }}
|
|
1522
|
+
role="dialog"
|
|
1523
|
+
aria-label={t('drawerLabel')}
|
|
1524
|
+
onClick={event => event.stopPropagation()}
|
|
1525
|
+
>
|
|
1526
|
+
<div
|
|
1527
|
+
className={edgeDrag.dragging ? `${css.resizer} ${css.resizerActive}` : css.resizer}
|
|
1528
|
+
role="separator"
|
|
1529
|
+
aria-orientation="vertical"
|
|
1530
|
+
aria-label={t('resizeLabel')}
|
|
1531
|
+
onPointerDown={event => edgeDrag.start(event, edgeDragHandler)}
|
|
1532
|
+
/>
|
|
1533
|
+
{/* One row for "where am I": the worktree, its path, and — when the view
|
|
1534
|
+
is about something other than the working tree — what that is. The
|
|
1535
|
+
branch used to be stated here AND in a picker row of its own, and
|
|
1536
|
+
the divergence here AND in the sync bar; both now have one home. */}
|
|
1537
|
+
<div className={css.header} data-gs-part="header">
|
|
1538
|
+
<div className={css.headerLeft}>
|
|
1539
|
+
<SourceChip
|
|
1540
|
+
t={t}
|
|
1541
|
+
worktrees={worktrees}
|
|
1542
|
+
boundPath={binding?.worktreePath ?? null}
|
|
1543
|
+
sessionPath={sessionPath}
|
|
1544
|
+
statsPath={statsPath}
|
|
1545
|
+
fallbackBranch={stats.branch}
|
|
1546
|
+
onSwitch={onSwitchSource}
|
|
1547
|
+
/>
|
|
1548
|
+
<Elided text={stats.worktreePath} className={css.headerPathMain} title={stats.worktreePath} />
|
|
1549
|
+
{tab === 'changes' && stats.detached ? <span className={css.headerDetached}>detached HEAD</span> : null}
|
|
1550
|
+
{tab === 'history' && commitHash !== null ? <span className={css.headerView}>{commitHash}</span> : null}
|
|
1551
|
+
{tab === 'compare' ? (
|
|
1552
|
+
<span className={css.headerView}>
|
|
1553
|
+
<Elided text={branchLabel(baseRef, t('noBranch'))} className={css.headerViewRef} />
|
|
1554
|
+
{' → '}
|
|
1555
|
+
<Elided text={branchLabel(headRef, t('noBranch'))} className={css.headerViewRef} />
|
|
1556
|
+
</span>
|
|
1557
|
+
) : null}
|
|
1558
|
+
{/* A confident `+0 −1` for a view with nothing behind it yet is not
|
|
1559
|
+
a slower answer, it is a wrong one — so the totals say "pending".
|
|
1560
|
+
But only then: a refresh landing over numbers already on screen
|
|
1561
|
+
must leave them alone. See {@link showsPending}. */}
|
|
1562
|
+
{pending ? <span className={css.headerTotalsDim}>—</span> : (
|
|
1563
|
+
<>
|
|
1564
|
+
<span className={css.headerTotals}>
|
|
1565
|
+
<span className={css.headerTotalsAdd}>+{body.addedLines}</span>{' '}
|
|
1566
|
+
<span className={css.headerTotalsDel}>−{body.deletedLines}</span>
|
|
1567
|
+
</span>
|
|
1568
|
+
<span className={css.headerTotalsDim}>
|
|
1569
|
+
{t('totalsDim', { added: body.addedFiles, modified: body.modifiedFiles, deleted: body.deletedFiles })}
|
|
1570
|
+
</span>
|
|
1571
|
+
{tab === 'compare' && comparable ? (
|
|
1572
|
+
<span className={css.headerTotalsDim}>
|
|
1573
|
+
{t('compareCommits', { count: shown?.commits.length ?? 0 })}
|
|
1574
|
+
</span>
|
|
1575
|
+
) : null}
|
|
1576
|
+
</>
|
|
1577
|
+
)}
|
|
1578
|
+
</div>
|
|
1579
|
+
{/* Window controls, not sentences. Each keeps its word on `title` and
|
|
1580
|
+
`aria-label`, so nothing is lost to a reader who cannot see the
|
|
1581
|
+
glyph or does not recognise it. */}
|
|
1582
|
+
<div className={css.headerRight}>
|
|
1583
|
+
<SettingsMenu
|
|
1584
|
+
t={t} mode={mode} family={family} onMode={onMode} onFamily={onFamily}
|
|
1585
|
+
settings={style} onStyle={onStyle}
|
|
1586
|
+
/>
|
|
1587
|
+
<button
|
|
1588
|
+
type="button"
|
|
1589
|
+
className={`${css.btn} ${css.btnIcon}`}
|
|
1590
|
+
aria-pressed={maximized}
|
|
1591
|
+
aria-label={maximized ? t('restore') : t('maximize')}
|
|
1592
|
+
title={maximized ? t('restore') : t('maximize')}
|
|
1593
|
+
onClick={onToggleMaximized}
|
|
1594
|
+
><ChromeGlyph of={maximized ? 'restore' : 'maximize'} /></button>
|
|
1595
|
+
<button
|
|
1596
|
+
type="button"
|
|
1597
|
+
className={`${css.btn} ${css.btnIcon}`}
|
|
1598
|
+
aria-label={t('refresh')} title={t('refresh')}
|
|
1599
|
+
onClick={onRefresh}
|
|
1600
|
+
><ChromeGlyph of="refresh" /></button>
|
|
1601
|
+
<button
|
|
1602
|
+
type="button"
|
|
1603
|
+
className={`${css.btn} ${css.btnIcon} ${css.btnClose}`}
|
|
1604
|
+
aria-label={t('close')} title={t('close')}
|
|
1605
|
+
onClick={onClose}
|
|
1606
|
+
><ChromeGlyph of="close" /></button>
|
|
1607
|
+
</div>
|
|
1608
|
+
</div>
|
|
1609
|
+
<div className={css.tabs} role="tablist" aria-label={t('tabsLabel')} data-gs-part="tabs">
|
|
1610
|
+
<button
|
|
1611
|
+
type="button"
|
|
1612
|
+
role="tab"
|
|
1613
|
+
aria-selected={tab === 'changes'}
|
|
1614
|
+
className={tab === 'changes' ? `${css.tab} ${css.tabActive}` : css.tab}
|
|
1615
|
+
onClick={() => onSwitchTab('changes')}
|
|
1616
|
+
>{t('tabChanges')}</button>
|
|
1617
|
+
<button
|
|
1618
|
+
type="button"
|
|
1619
|
+
role="tab"
|
|
1620
|
+
aria-selected={tab === 'history'}
|
|
1621
|
+
className={tab === 'history' ? `${css.tab} ${css.tabActive}` : css.tab}
|
|
1622
|
+
onClick={() => onSwitchTab('history')}
|
|
1623
|
+
>{t('tabHistory')}</button>
|
|
1624
|
+
<button
|
|
1625
|
+
type="button"
|
|
1626
|
+
role="tab"
|
|
1627
|
+
aria-selected={tab === 'compare'}
|
|
1628
|
+
className={tab === 'compare' ? `${css.tab} ${css.tabActive}` : css.tab}
|
|
1629
|
+
onClick={() => onSwitchTab('compare')}
|
|
1630
|
+
>{t('tabCompare')}</button>
|
|
1631
|
+
</div>
|
|
1632
|
+
{tab === 'compare' ? (
|
|
1633
|
+
<CompareBar
|
|
1634
|
+
t={t}
|
|
1635
|
+
branches={branches}
|
|
1636
|
+
worktreeBranches={worktreeBranches}
|
|
1637
|
+
truncated={branchesTruncated}
|
|
1638
|
+
baseRef={baseRef}
|
|
1639
|
+
headRef={headRef}
|
|
1640
|
+
onBaseRef={onBaseRef}
|
|
1641
|
+
onHeadRef={onHeadRef}
|
|
1642
|
+
/>
|
|
1643
|
+
) : null}
|
|
1644
|
+
{tab === 'history' && branches.length > 0 ? (
|
|
1645
|
+
<div className={css.compareBar}>
|
|
1646
|
+
<RefPicker
|
|
1647
|
+
t={t} label={t('historyRefLabel')} value={historyRef}
|
|
1648
|
+
branches={branches} worktreeBranches={worktreeBranches} truncated={branchesTruncated}
|
|
1649
|
+
onPick={onHistoryRef}
|
|
1650
|
+
/>
|
|
1651
|
+
</div>
|
|
1652
|
+
) : null}
|
|
1653
|
+
{/* Write operations act on the working tree, so they belong to the tab
|
|
1654
|
+
that shows it. A commit box under a historical diff would be asking
|
|
1655
|
+
which tree it commits. */}
|
|
1656
|
+
{tab === 'changes' && sync !== null && sync.hasRemote ? (
|
|
1657
|
+
<SyncBar t={t} sync={sync} busy={busy} onOp={(op, payload) => { void runOp(op, payload) }} />
|
|
1658
|
+
) : null}
|
|
1659
|
+
{opResult !== null ? (
|
|
1660
|
+
<div
|
|
1661
|
+
className={opResult.result.ok ? `${css.opBanner} ${css.opBannerOk}` : `${css.opBanner} ${css.opBannerBad}`}
|
|
1662
|
+
role="status"
|
|
1663
|
+
>{opMessage(t, opResult.op, opResult.result)}</div>
|
|
1664
|
+
) : null}
|
|
1665
|
+
<div className={css.body}>
|
|
1666
|
+
{tab === 'history' ? (
|
|
1667
|
+
<>
|
|
1668
|
+
<CommitList
|
|
1669
|
+
paneRef={commitsRef}
|
|
1670
|
+
style={paneStyle(panes.commits)}
|
|
1671
|
+
t={t}
|
|
1672
|
+
loading={historyLoading}
|
|
1673
|
+
commits={commits}
|
|
1674
|
+
active={commitHash}
|
|
1675
|
+
onSelect={onSelectCommit}
|
|
1676
|
+
hasMore={hasMoreCommits}
|
|
1677
|
+
loadingMore={loadingMore}
|
|
1678
|
+
onLoadMore={onLoadMoreCommits}
|
|
1679
|
+
/>
|
|
1680
|
+
<PaneDivider label={t('resizeCommits')} onDrag={paneDrag('commits', commitsRef)} />
|
|
1681
|
+
</>
|
|
1682
|
+
) : null}
|
|
1683
|
+
<div ref={treeRef} className={css.treeCol} style={paneStyle(panes.tree)} data-gs-part="tree">
|
|
1684
|
+
<FileTree
|
|
1685
|
+
t={t}
|
|
1686
|
+
loading={pending}
|
|
1687
|
+
lead={tab === 'changes' ? t('workingTree') : undefined}
|
|
1688
|
+
files={tickedFiles}
|
|
1689
|
+
active={active}
|
|
1690
|
+
onSelect={selectAndReveal}
|
|
1691
|
+
collapsed={collapsed}
|
|
1692
|
+
onCollapsedChange={onCollapsedChange}
|
|
1693
|
+
// Ticks exist only for the working tree. A commit's contents were
|
|
1694
|
+
// decided long ago and a range's never were, so the column is
|
|
1695
|
+
// absent there rather than present and inert.
|
|
1696
|
+
//
|
|
1697
|
+
// A tick IS still the git call — `add` on the way in,
|
|
1698
|
+
// `restore --staged` on the way out, applied now rather than
|
|
1699
|
+
// saved up for Commit — but the call is queued, not raced: the
|
|
1700
|
+
// click paints itself through the overlay, the drain loop batches
|
|
1701
|
+
// the git calls, and a click that lands while another runs waits
|
|
1702
|
+
// its turn instead of being dropped. `checked` carries the
|
|
1703
|
+
// overlaid flags, so a second click reads the state the user is
|
|
1704
|
+
// looking at, not the pre-click payload.
|
|
1705
|
+
onCheck={tab === 'changes' ? (checked, state) => {
|
|
1706
|
+
const action = nextAction(state)
|
|
1707
|
+
const paths = pathsFor(checked, action)
|
|
1708
|
+
if (paths.length > 0) onTick(action, paths)
|
|
1709
|
+
} : undefined}
|
|
1710
|
+
footer={tab === 'changes'
|
|
1711
|
+
? (
|
|
1712
|
+
<CommitBox
|
|
1713
|
+
t={t} files={tickedFiles} busy={busy} onOp={runOp}
|
|
1714
|
+
message={commitDraft} onMessage={onCommitDraft}
|
|
1715
|
+
amend={commitAmend} onAmend={onCommitAmend}
|
|
1716
|
+
/>
|
|
1717
|
+
)
|
|
1718
|
+
: undefined}
|
|
1719
|
+
/>
|
|
1720
|
+
</div>
|
|
1721
|
+
<PaneDivider label={t('resizeTree')} onDrag={paneDrag('tree', treeRef)} />
|
|
1722
|
+
<div className={css.diffPane} data-gs-part="diff">
|
|
1723
|
+
{tab === 'compare' && !comparable ? (
|
|
1724
|
+
<div className={css.empty}>{t('comparePick')}</div>
|
|
1725
|
+
) : shown === null && tab !== 'changes' ? (
|
|
1726
|
+
<div className={css.empty}>{tab === 'compare' ? t('loadingCompare') : t('loadingCommit')}</div>
|
|
1727
|
+
) : activeFile !== null && activeFile.previousPath !== undefined ? (
|
|
1728
|
+
<div className={css.renameLine}>{t('renamedFrom')} <code>{activeFile.previousPath}</code></div>
|
|
1729
|
+
) : null}
|
|
1730
|
+
{(shown === null && tab !== 'changes') || (tab === 'compare' && !comparable) ? null
|
|
1731
|
+
: activeFile !== null && activeFile.binary ? (
|
|
1732
|
+
<div className={css.empty}>{t('binaryFile')}</div>
|
|
1733
|
+
) : loading && segment.length === 0 ? (
|
|
1734
|
+
<div className={css.empty}>{t('loadingDiff')}</div>
|
|
1735
|
+
) : segment.length > 0 ? (
|
|
1736
|
+
<DiffView segment={segment} path={active ?? ''} palette={theme} />
|
|
1737
|
+
) : (
|
|
1738
|
+
<div className={css.empty}>{t('noTextDiff')}</div>
|
|
1739
|
+
)}
|
|
1740
|
+
</div>
|
|
1741
|
+
</div>
|
|
1742
|
+
</div>
|
|
1743
|
+
</div>
|
|
1744
|
+
)
|
|
1745
|
+
}
|
|
1746
|
+
|
|
1747
|
+
/**
|
|
1748
|
+
* A slash-separated name that gives up its HEAD, never its tail.
|
|
1749
|
+
*
|
|
1750
|
+
* Paths and branches have the same shape and the same problem: the leaf is what
|
|
1751
|
+
* distinguishes siblings, and ordinary `text-overflow: ellipsis` eats exactly
|
|
1752
|
+
* that. `…/worktrees/fixture-07` and `…/worktrees/fixture-14` become the same
|
|
1753
|
+
* string; so do `feature/nested/deep/parser` and `feature/nested/deep/lexer`.
|
|
1754
|
+
* Splitting in two and letting only the head shrink keeps the half that
|
|
1755
|
+
* answers "which one".
|
|
1756
|
+
*
|
|
1757
|
+
* Truncating from the other end with `direction: rtl` was the one-line version
|
|
1758
|
+
* and the wrong one: it reorders the backslashes in a Windows path.
|
|
1759
|
+
*
|
|
1760
|
+
* When the name has no head to give — a bare `some-very-long-branch-name` — the
|
|
1761
|
+
* tail ellipsises after all rather than overflowing its row; the stylesheet
|
|
1762
|
+
* weights the shrink so that only happens once the head is gone.
|
|
1763
|
+
*/
|
|
1764
|
+
function Elided({ text, className, title }: {
|
|
1765
|
+
text: string
|
|
1766
|
+
className: string
|
|
1767
|
+
/** Set only where the row does not already carry the full text itself. */
|
|
1768
|
+
title?: string
|
|
1769
|
+
}): ReactNode {
|
|
1770
|
+
if (text.length === 0) return null
|
|
1771
|
+
const { head, tail } = splitPath(text)
|
|
1772
|
+
return (
|
|
1773
|
+
<span className={`${css.elide} ${className}`} {...title === undefined ? {} : { title }}>
|
|
1774
|
+
{head.length > 0 ? <span className={css.elideHead}>{head}</span> : null}
|
|
1775
|
+
<span className={css.elideTail}>{tail}</span>
|
|
1776
|
+
</span>
|
|
1777
|
+
)
|
|
1778
|
+
}
|
|
1779
|
+
|
|
1780
|
+
/**
|
|
1781
|
+
* Which worktree the drawer is reading — the header's first control.
|
|
1782
|
+
*
|
|
1783
|
+
* Git allows at most one worktree per branch, so the repository's worktree list
|
|
1784
|
+
* IS the branch list and one control covers both. This used to be a row of its
|
|
1785
|
+
* own under the tabs, restating the branch the header had already named one
|
|
1786
|
+
* line above; now it IS that name, and clicking it changes the view.
|
|
1787
|
+
*
|
|
1788
|
+
* A repository with a single worktree has nothing to choose, so it renders as
|
|
1789
|
+
* the same chip without the menu — the identity still has to be stated, and a
|
|
1790
|
+
* control that opens an empty list is worse than none.
|
|
1791
|
+
*/
|
|
1792
|
+
function SourceChip({ t, worktrees, boundPath, sessionPath, statsPath, fallbackBranch, onSwitch }: {
|
|
1793
|
+
t: Translate
|
|
1794
|
+
worktrees: readonly WorktreeEntry[]
|
|
1795
|
+
boundPath: string | null
|
|
1796
|
+
sessionPath: string | undefined
|
|
1797
|
+
statsPath: string | undefined
|
|
1798
|
+
/** What to name when the worktree list does not cover the active path — the
|
|
1799
|
+
* branch the stats themselves report. */
|
|
1800
|
+
fallbackBranch: string
|
|
1801
|
+
onSwitch: (next: string) => void
|
|
1802
|
+
}): ReactNode {
|
|
1803
|
+
if (worktrees.length < 2) {
|
|
1804
|
+
return (
|
|
1805
|
+
<span className={css.headerBranch}>
|
|
1806
|
+
<WorktreeGlyph />
|
|
1807
|
+
<Elided text={branchLabel(fallbackBranch, t('noBranch'))} className={css.refValue} />
|
|
1808
|
+
</span>
|
|
1809
|
+
)
|
|
1810
|
+
}
|
|
1811
|
+
return (
|
|
1812
|
+
<WorktreePicker
|
|
1813
|
+
t={t} worktrees={worktrees} boundPath={boundPath}
|
|
1814
|
+
sessionPath={sessionPath} statsPath={statsPath}
|
|
1815
|
+
fallbackBranch={fallbackBranch} onSwitch={onSwitch}
|
|
1816
|
+
/>
|
|
1817
|
+
)
|
|
1818
|
+
}
|
|
1819
|
+
|
|
1820
|
+
/**
|
|
1821
|
+
* The worktree menu: the ref picker's scaffold — button, filter box, scrolling
|
|
1822
|
+
* list — over worktree rows, wearing the header chip's accent so it reads as
|
|
1823
|
+
* the subject of the drawer rather than one more grey button.
|
|
1824
|
+
*
|
|
1825
|
+
* Rows carry the tree glyph when the session is bound there and a dot when it
|
|
1826
|
+
* is the session's own; Enter takes the first match.
|
|
1827
|
+
*
|
|
1828
|
+
* The row gives its whole width to the branch. A dim path used to ride on the
|
|
1829
|
+
* right to tell same-named branches in different repositories apart, but it
|
|
1830
|
+
* cost half the row to earn that, and the half it took was the half that
|
|
1831
|
+
* mattered: `wt/fixture-03` truncated to `wt/fixtur…` beside a path whose tail
|
|
1832
|
+
* was repeating the name anyway. The full path stays one hover away on `title`,
|
|
1833
|
+
* and the header spells it out the moment a row is picked.
|
|
1834
|
+
*/
|
|
1835
|
+
function WorktreePicker({ t, worktrees, boundPath, sessionPath, statsPath, fallbackBranch, onSwitch }: {
|
|
1836
|
+
t: Translate
|
|
1837
|
+
worktrees: readonly WorktreeEntry[]
|
|
1838
|
+
boundPath: string | null
|
|
1839
|
+
sessionPath: string | undefined
|
|
1840
|
+
statsPath: string | undefined
|
|
1841
|
+
fallbackBranch: string
|
|
1842
|
+
onSwitch: (next: string) => void
|
|
1843
|
+
}): ReactNode {
|
|
1844
|
+
const [open, setOpen] = useState(false)
|
|
1845
|
+
const [query, setQuery] = useState('')
|
|
1846
|
+
const rootRef = useDismissable(open, setOpen)
|
|
1847
|
+
|
|
1848
|
+
const needle = query.trim().toLowerCase()
|
|
1849
|
+
const matched = needle.length === 0 ? worktrees : worktrees.filter(entry =>
|
|
1850
|
+
entry.branch.toLowerCase().includes(needle) || entry.path.toLowerCase().includes(needle))
|
|
1851
|
+
const first = matched[0]
|
|
1852
|
+
const current = worktrees.find(entry => samePath(entry.path, statsPath))
|
|
1853
|
+
|
|
1854
|
+
const choose = (entry: WorktreeEntry): void => {
|
|
1855
|
+
onSwitch(entry.path)
|
|
1856
|
+
setOpen(false)
|
|
1857
|
+
setQuery('')
|
|
1858
|
+
}
|
|
1859
|
+
|
|
1860
|
+
return (
|
|
1861
|
+
<div className={css.refPicker} ref={rootRef}>
|
|
1862
|
+
<button
|
|
1863
|
+
type="button"
|
|
1864
|
+
className={`${css.refButton} ${css.headerPicker}`}
|
|
1865
|
+
aria-expanded={open}
|
|
1866
|
+
aria-label={t('sourceLabel')}
|
|
1867
|
+
title={current?.path ?? statsPath}
|
|
1868
|
+
onClick={() => setOpen(isOpen => !isOpen)}
|
|
1869
|
+
>
|
|
1870
|
+
<WorktreeGlyph />
|
|
1871
|
+
<Elided text={branchLabel(current?.branch ?? fallbackBranch, t('noBranch'))} className={css.refValue} />
|
|
1872
|
+
<span className={css.refCaret}>▾</span>
|
|
1873
|
+
</button>
|
|
1874
|
+
{open ? (
|
|
1875
|
+
<div className={css.refPop}>
|
|
1876
|
+
<input
|
|
1877
|
+
className={css.refSearch}
|
|
1878
|
+
autoFocus
|
|
1879
|
+
value={query}
|
|
1880
|
+
placeholder={t('refSearch')}
|
|
1881
|
+
onChange={event => setQuery(event.target.value)}
|
|
1882
|
+
onKeyDown={event => { if (event.key === 'Enter' && first !== undefined) choose(first) }}
|
|
1883
|
+
/>
|
|
1884
|
+
<div className={css.refList} role="listbox" aria-label={t('sourceLabel')}>
|
|
1885
|
+
{matched.map(entry => {
|
|
1886
|
+
const active = samePath(entry.path, statsPath)
|
|
1887
|
+
return (
|
|
1888
|
+
<button
|
|
1889
|
+
key={entry.path}
|
|
1890
|
+
type="button"
|
|
1891
|
+
role="option"
|
|
1892
|
+
aria-selected={active}
|
|
1893
|
+
className={active ? `${css.refRow} ${css.refRowActive}` : css.refRow}
|
|
1894
|
+
title={entry.path}
|
|
1895
|
+
onClick={() => choose(entry)}
|
|
1896
|
+
>
|
|
1897
|
+
{samePath(boundPath, entry.path) ? <WorktreeGlyph /> : <span className={css.refRowSpacer} />}
|
|
1898
|
+
<Elided text={branchLabel(entry.branch, t('noBranch'))} className={css.refRowName} />
|
|
1899
|
+
{samePath(sessionPath, entry.path) ? <span className={css.wtCurrent}>●</span> : null}
|
|
1900
|
+
</button>
|
|
1901
|
+
)
|
|
1902
|
+
})}
|
|
1903
|
+
{matched.length === 0 ? <div className={css.refEmpty}>{t('refNone')}</div> : null}
|
|
1904
|
+
</div>
|
|
1905
|
+
<div className={css.refFoot}>{t('refCount', { shown: matched.length, total: worktrees.length })}</div>
|
|
1906
|
+
</div>
|
|
1907
|
+
) : null}
|
|
1908
|
+
</div>
|
|
1909
|
+
)
|
|
1910
|
+
}
|
|
1911
|
+
|
|
1912
|
+
/**
|
|
1913
|
+
* Settings: colour mode, palette, background image and custom CSS.
|
|
1914
|
+
*
|
|
1915
|
+
* Mode defaults to `system`, which follows dsh (`body[data-ds-dark-theme]`);
|
|
1916
|
+
* light and dark pin the drawer even when the host is the other scheme. The
|
|
1917
|
+
* palette is a pure token swap — every drawer colour resolves through the same
|
|
1918
|
+
* names, so nothing but the values differ between families.
|
|
1919
|
+
*
|
|
1920
|
+
* The background and the stylesheet are per-scope, and the scope switch is the
|
|
1921
|
+
* only control in here that changes what an edit WRITES rather than what it
|
|
1922
|
+
* looks like, so it sits at the top of that section rather than beside a field.
|
|
1923
|
+
*
|
|
1924
|
+
* This was a companion card portalled into the overlay to the LEFT of the
|
|
1925
|
+
* drawer, so a palette could be previewed against the diff without covering it.
|
|
1926
|
+
* It is a popover now, hung under its own gear: a card floating out in the page
|
|
1927
|
+
* beside the drawer read as a second window rather than as this drawer's
|
|
1928
|
+
* settings, and it was the one menu here that did not behave like the rest.
|
|
1929
|
+
* The preview still works — the popover covers the top of the diff, not all of
|
|
1930
|
+
* it, and the drawer repaints live underneath.
|
|
1931
|
+
*/
|
|
1932
|
+
function SettingsMenu({ t, mode, family, onMode, onFamily, settings, onStyle }: {
|
|
1933
|
+
t: Translate
|
|
1934
|
+
mode: ColorMode
|
|
1935
|
+
family: ThemeFamily
|
|
1936
|
+
onMode: (next: ColorMode) => void
|
|
1937
|
+
onFamily: (next: ThemeFamily) => void
|
|
1938
|
+
settings: StyleSettings
|
|
1939
|
+
onStyle: (scope: StyleScope, entry: StyleEntry, persist: boolean) => Promise<{ ok: boolean; error?: string }>
|
|
1940
|
+
}): ReactNode {
|
|
1941
|
+
const [open, setOpen] = useState(false)
|
|
1942
|
+
const [scope, setScope] = useState<StyleScope>('project')
|
|
1943
|
+
/** Editor buffer for the stylesheet, so typing does not restyle on every key. */
|
|
1944
|
+
const [draft, setDraft] = useState<string | null>(null)
|
|
1945
|
+
const [note, setNote] = useState('')
|
|
1946
|
+
const rootRef = useDismissable(open, setOpen)
|
|
1947
|
+
const imageFileRef = useRef<HTMLInputElement>(null)
|
|
1948
|
+
const cssFileRef = useRef<HTMLInputElement>(null)
|
|
1949
|
+
|
|
1950
|
+
// The buffer belongs to one scope; switching scope must show that scope's
|
|
1951
|
+
// stylesheet rather than carry the other one's text across.
|
|
1952
|
+
useEffect(() => { setDraft(null); setNote('') }, [scope])
|
|
1953
|
+
|
|
1954
|
+
// The default scope is `project`, chosen before the host has said whether
|
|
1955
|
+
// there IS one. Outside a repository it has nothing to key by, so the menu
|
|
1956
|
+
// falls back rather than pointing every control at a scope that refuses
|
|
1957
|
+
// every write.
|
|
1958
|
+
useEffect(() => {
|
|
1959
|
+
if (settings.repoRoot === null) setScope('global')
|
|
1960
|
+
}, [settings.repoRoot])
|
|
1961
|
+
|
|
1962
|
+
const entry = entryFor(settings, scope)
|
|
1963
|
+
const cssText = draft ?? entry.css
|
|
1964
|
+
|
|
1965
|
+
/**
|
|
1966
|
+
* Apply a change to the scope being edited.
|
|
1967
|
+
* @param patch - the fields that changed.
|
|
1968
|
+
* @param persist - whether to store it; false previews without a file write.
|
|
1969
|
+
*/
|
|
1970
|
+
const write = (patch: Partial<StyleEntry>, persist = true): void => {
|
|
1971
|
+
setNote('')
|
|
1972
|
+
void onStyle(scope, { ...entry, ...patch }, persist).then(result => {
|
|
1973
|
+
if (!result.ok) setNote(result.error ?? t('styleFailed'))
|
|
1974
|
+
})
|
|
1975
|
+
}
|
|
1976
|
+
|
|
1977
|
+
/**
|
|
1978
|
+
* Resample a chosen image and store it.
|
|
1979
|
+
*
|
|
1980
|
+
* Downscaling in the browser is what keeps this practical: a phone photograph
|
|
1981
|
+
* is 4-6MB, far past what is worth carrying on every drawer open, and none of
|
|
1982
|
+
* that detail survives a blur anyway.
|
|
1983
|
+
* @param file - the picked file.
|
|
1984
|
+
*/
|
|
1985
|
+
const takeImage = async (file: File): Promise<void> => {
|
|
1986
|
+
setNote(t('bgWorking'))
|
|
1987
|
+
try {
|
|
1988
|
+
const bitmap = await createImageBitmap(file)
|
|
1989
|
+
const scale = Math.min(1, IMAGE_MAX_EDGE / Math.max(bitmap.width, bitmap.height))
|
|
1990
|
+
const canvas = document.createElement('canvas')
|
|
1991
|
+
canvas.width = Math.round(bitmap.width * scale)
|
|
1992
|
+
canvas.height = Math.round(bitmap.height * scale)
|
|
1993
|
+
const context = canvas.getContext('2d')
|
|
1994
|
+
if (context === null) { setNote(t('bgFailed')); return }
|
|
1995
|
+
context.drawImage(bitmap, 0, 0, canvas.width, canvas.height)
|
|
1996
|
+
bitmap.close()
|
|
1997
|
+
const url = canvas.toDataURL('image/jpeg', IMAGE_QUALITY)
|
|
1998
|
+
if (url.length > IMAGE_MAX_BYTES) { setNote(t('bgTooBig')); return }
|
|
1999
|
+
setNote('')
|
|
2000
|
+
write({ image: url })
|
|
2001
|
+
} catch {
|
|
2002
|
+
// A file the decoder refuses (corrupt, or an image codec this browser
|
|
2003
|
+
// lacks) is a user mistake, not a fault worth propagating.
|
|
2004
|
+
setNote(t('bgFailed'))
|
|
2005
|
+
}
|
|
2006
|
+
}
|
|
2007
|
+
|
|
2008
|
+
const modeLabel: Record<ColorMode, string> = {
|
|
2009
|
+
system: t('modeSystem'), light: t('modeLight'), dark: t('modeDark'),
|
|
2010
|
+
}
|
|
2011
|
+
const modeChip: Record<ColorMode, string> = {
|
|
2012
|
+
system: css.chipSystem, light: css.chipLight, dark: css.chipDark,
|
|
2013
|
+
}
|
|
2014
|
+
const scopeLabel: Record<StyleScope, string> = {
|
|
2015
|
+
project: t('scopeProject'), global: t('scopeGlobal'),
|
|
2016
|
+
}
|
|
2017
|
+
const projectAvailable = settings.repoRoot !== null
|
|
2018
|
+
|
|
2019
|
+
return (
|
|
2020
|
+
<div className={css.theme} ref={rootRef}>
|
|
2021
|
+
<button
|
|
2022
|
+
type="button"
|
|
2023
|
+
className={`${css.btn} ${css.btnIcon}`}
|
|
2024
|
+
aria-expanded={open}
|
|
2025
|
+
aria-label={t('settings')} title={t('settings')}
|
|
2026
|
+
onClick={() => setOpen(value => !value)}
|
|
2027
|
+
><ChromeGlyph of="settings" /></button>
|
|
2028
|
+
{open ? (
|
|
2029
|
+
<div className={`${css.refPop} ${css.settingsPop}`} data-gs-part="settings">
|
|
2030
|
+
{/* The popover positions and clips; this is the padded body that
|
|
2031
|
+
scrolls inside it. Collapsing the two put every section flush
|
|
2032
|
+
against the card's edge. */}
|
|
2033
|
+
<div className={css.themeRail} data-gs-part="theme-rail">
|
|
2034
|
+
<div className={css.themeGroup}>
|
|
2035
|
+
<span className={css.themeLabel}>{t('themeMode')}</span>
|
|
2036
|
+
<div className={css.segmented} role="group" aria-label={t('themeMode')}>
|
|
2037
|
+
{COLOR_MODES.map(option => (
|
|
2038
|
+
<button
|
|
2039
|
+
key={option}
|
|
2040
|
+
type="button"
|
|
2041
|
+
aria-pressed={mode === option}
|
|
2042
|
+
className={mode === option ? `${css.segment} ${css.segmentActive}` : css.segment}
|
|
2043
|
+
onClick={() => onMode(option)}
|
|
2044
|
+
>
|
|
2045
|
+
<span className={`${css.segmentChip} ${modeChip[option]}`} aria-hidden="true" />
|
|
2046
|
+
{modeLabel[option]}
|
|
2047
|
+
</button>
|
|
2048
|
+
))}
|
|
2049
|
+
</div>
|
|
2050
|
+
</div>
|
|
2051
|
+
|
|
2052
|
+
<div className={css.themeGroup}>
|
|
2053
|
+
<span className={css.themeLabel}>{t('themePalette')}</span>
|
|
2054
|
+
{THEME_FAMILIES.map(option => (
|
|
2055
|
+
<button
|
|
2056
|
+
key={option.id}
|
|
2057
|
+
type="button"
|
|
2058
|
+
aria-pressed={family === option.id}
|
|
2059
|
+
className={family === option.id ? `${css.paletteRow} ${css.paletteRowActive}` : css.paletteRow}
|
|
2060
|
+
onClick={() => onFamily(option.id)}
|
|
2061
|
+
>
|
|
2062
|
+
<span className={css.swatch} aria-hidden="true">
|
|
2063
|
+
{option.swatch.map(color => <span key={color} style={{ background: color }} />)}
|
|
2064
|
+
</span>
|
|
2065
|
+
{option.label}
|
|
2066
|
+
</button>
|
|
2067
|
+
))}
|
|
2068
|
+
</div>
|
|
2069
|
+
|
|
2070
|
+
<div className={css.themeGroup}>
|
|
2071
|
+
<span className={css.themeLabel}>{t('themeScope')}</span>
|
|
2072
|
+
<div className={css.scopeRow} role="group" aria-label={t('themeScope')}>
|
|
2073
|
+
{STYLE_SCOPES.map(option => (
|
|
2074
|
+
<button
|
|
2075
|
+
key={option}
|
|
2076
|
+
type="button"
|
|
2077
|
+
aria-pressed={scope === option}
|
|
2078
|
+
disabled={option === 'project' && !projectAvailable}
|
|
2079
|
+
className={scope === option ? `${css.scopeBtn} ${css.scopeBtnActive}` : css.scopeBtn}
|
|
2080
|
+
onClick={() => setScope(option)}
|
|
2081
|
+
>{scopeLabel[option]}</button>
|
|
2082
|
+
))}
|
|
2083
|
+
</div>
|
|
2084
|
+
<span className={css.scopeHint}>
|
|
2085
|
+
{scope === 'global' ? t('scopeGlobalHint')
|
|
2086
|
+
: projectAvailable ? settings.repoRoot
|
|
2087
|
+
: t('scopeNoRepo')}
|
|
2088
|
+
</span>
|
|
2089
|
+
</div>
|
|
2090
|
+
|
|
2091
|
+
<div className={css.themeGroup}>
|
|
2092
|
+
<span className={css.themeLabel}>{t('themeBackground')}</span>
|
|
2093
|
+
<div
|
|
2094
|
+
className={entry.image.length > 0 ? css.bgPreview : `${css.bgPreview} ${css.bgEmpty}`}
|
|
2095
|
+
style={entry.image.length > 0 ? { backgroundImage: `url("${entry.image}")` } : undefined}
|
|
2096
|
+
>{entry.image.length > 0 ? null : t('bgNone')}</div>
|
|
2097
|
+
<div className={css.themeRowSplit}>
|
|
2098
|
+
<button type="button" className={css.miniBtn} onClick={() => imageFileRef.current?.click()}>{t('bgChoose')}</button>
|
|
2099
|
+
{entry.image.length > 0
|
|
2100
|
+
? <button type="button" className={css.miniBtn} onClick={() => write({ image: '' })}>{t('bgClear')}</button>
|
|
2101
|
+
: null}
|
|
2102
|
+
</div>
|
|
2103
|
+
<input
|
|
2104
|
+
ref={imageFileRef}
|
|
2105
|
+
type="file"
|
|
2106
|
+
accept="image/*"
|
|
2107
|
+
hidden
|
|
2108
|
+
onChange={event => {
|
|
2109
|
+
const file = event.target.files?.[0]
|
|
2110
|
+
// Clearing the input is what lets the same file be picked twice
|
|
2111
|
+
// after a failure; a change event fires only on a NEW value.
|
|
2112
|
+
event.target.value = ''
|
|
2113
|
+
if (file !== undefined) void takeImage(file)
|
|
2114
|
+
}}
|
|
2115
|
+
/>
|
|
2116
|
+
{entry.image.length > 0 ? (
|
|
2117
|
+
<>
|
|
2118
|
+
<label className={css.sliderRow}>
|
|
2119
|
+
{t('bgBlur')}
|
|
2120
|
+
<input
|
|
2121
|
+
type="range" min={0} max={STYLE_BLUR_MAX} step={1} value={entry.blur}
|
|
2122
|
+
onChange={event => write({ blur: Number(event.target.value) }, false)}
|
|
2123
|
+
onPointerUp={() => write({})}
|
|
2124
|
+
onKeyUp={() => write({})}
|
|
2125
|
+
/>
|
|
2126
|
+
<span className={css.sliderValue}>{entry.blur}px</span>
|
|
2127
|
+
</label>
|
|
2128
|
+
<label className={css.sliderRow}>
|
|
2129
|
+
{t('bgVeil')}
|
|
2130
|
+
<input
|
|
2131
|
+
type="range" min={0} max={100} step={1} value={entry.veil}
|
|
2132
|
+
onChange={event => write({ veil: Number(event.target.value) }, false)}
|
|
2133
|
+
onPointerUp={() => write({})}
|
|
2134
|
+
onKeyUp={() => write({})}
|
|
2135
|
+
/>
|
|
2136
|
+
<span className={css.sliderValue}>{entry.veil}%</span>
|
|
2137
|
+
</label>
|
|
2138
|
+
</>
|
|
2139
|
+
) : null}
|
|
2140
|
+
</div>
|
|
2141
|
+
|
|
2142
|
+
<div className={css.themeGroup}>
|
|
2143
|
+
<span className={css.themeLabel}>{t('themeCss')}</span>
|
|
2144
|
+
<textarea
|
|
2145
|
+
className={css.cssArea}
|
|
2146
|
+
spellCheck={false}
|
|
2147
|
+
placeholder={t('cssPlaceholder')}
|
|
2148
|
+
value={cssText}
|
|
2149
|
+
onChange={event => setDraft(event.target.value)}
|
|
2150
|
+
/>
|
|
2151
|
+
<div className={css.themeRowSplit}>
|
|
2152
|
+
<button type="button" className={css.miniBtn} onClick={() => cssFileRef.current?.click()}>{t('cssImport')}</button>
|
|
2153
|
+
<button
|
|
2154
|
+
type="button"
|
|
2155
|
+
className={`${css.miniBtn} ${css.miniBtnPrimary}`}
|
|
2156
|
+
disabled={draft === null}
|
|
2157
|
+
onClick={() => { write({ css: cssText }); setDraft(null) }}
|
|
2158
|
+
>{t('cssApply')}</button>
|
|
2159
|
+
</div>
|
|
2160
|
+
<input
|
|
2161
|
+
ref={cssFileRef}
|
|
2162
|
+
type="file"
|
|
2163
|
+
accept=".css,text/css"
|
|
2164
|
+
hidden
|
|
2165
|
+
onChange={event => {
|
|
2166
|
+
const file = event.target.files?.[0]
|
|
2167
|
+
event.target.value = ''
|
|
2168
|
+
if (file !== undefined) void file.text().then(text => setDraft(text))
|
|
2169
|
+
}}
|
|
2170
|
+
/>
|
|
2171
|
+
{draft === null ? null : <span className={css.themeDirty}>{t('cssUnapplied')}</span>}
|
|
2172
|
+
</div>
|
|
2173
|
+
|
|
2174
|
+
{note.length > 0 ? <span className={css.themeNote}>{note}</span> : null}
|
|
2175
|
+
</div>
|
|
2176
|
+
</div>
|
|
2177
|
+
) : null}
|
|
2178
|
+
</div>
|
|
2179
|
+
)
|
|
2180
|
+
}
|
|
2181
|
+
|
|
2182
|
+
/**
|
|
2183
|
+
* Close a popover on the two gestures every user already expects: a click
|
|
2184
|
+
* outside it, and Escape. Both arrive on `document` rather than on the
|
|
2185
|
+
* popover's own subtree, so neither can be a handler on the element.
|
|
2186
|
+
* @param open - whether the popover is showing; nothing is bound while closed.
|
|
2187
|
+
* @param setOpen - the state setter, stable, so the effect binds once per open.
|
|
2188
|
+
* @returns the ref to put on the element that counts as "inside".
|
|
2189
|
+
*/
|
|
2190
|
+
function useDismissable(open: boolean, setOpen: Dispatch<SetStateAction<boolean>>): Ref<HTMLDivElement> {
|
|
2191
|
+
const rootRef = useRef<HTMLDivElement>(null)
|
|
2192
|
+
useEffect(() => {
|
|
2193
|
+
if (!open) return
|
|
2194
|
+
const onDown = (event: MouseEvent): void => {
|
|
2195
|
+
if (rootRef.current !== null && !rootRef.current.contains(event.target as Node)) setOpen(false)
|
|
2196
|
+
}
|
|
2197
|
+
const onKey = (event: KeyboardEvent): void => { if (event.key === 'Escape') setOpen(false) }
|
|
2198
|
+
// Bound on the next tick: the click that opened the popover is still
|
|
2199
|
+
// travelling, and would otherwise close it again immediately.
|
|
2200
|
+
const id = window.setTimeout(() => document.addEventListener('mousedown', onDown), 0)
|
|
2201
|
+
document.addEventListener('keydown', onKey)
|
|
2202
|
+
return () => {
|
|
2203
|
+
window.clearTimeout(id)
|
|
2204
|
+
document.removeEventListener('mousedown', onDown)
|
|
2205
|
+
document.removeEventListener('keydown', onKey)
|
|
2206
|
+
}
|
|
2207
|
+
}, [open, setOpen])
|
|
2208
|
+
return rootRef
|
|
2209
|
+
}
|
|
2210
|
+
|
|
2211
|
+
/**
|
|
2212
|
+
* Ref picker built for a repository with hundreds of branches.
|
|
2213
|
+
*
|
|
2214
|
+
* A chip row cannot do this job — it grows without bound and gives every branch
|
|
2215
|
+
* the same weight — and a native select is no better once the list is long
|
|
2216
|
+
* enough to scroll past what anyone will read. This is the control git tooling
|
|
2217
|
+
* converges on instead: one button showing the current ref, opening a filter box
|
|
2218
|
+
* over a scrolling list.
|
|
2219
|
+
*
|
|
2220
|
+
* Two things make it useful before a character is typed. Branches arrive
|
|
2221
|
+
* most-recently-committed first, so the handful actually being worked on are at
|
|
2222
|
+
* the top; and those that have a worktree are grouped above the rest, because a
|
|
2223
|
+
* checked-out branch is the likeliest thing to want. Enter takes the first
|
|
2224
|
+
* match, so a distinctive substring plus Enter reaches any branch in the list.
|
|
2225
|
+
*/
|
|
2226
|
+
function RefPicker({ t, label, value, branches, worktreeBranches, truncated, onPick }: {
|
|
2227
|
+
t: Translate
|
|
2228
|
+
label: string
|
|
2229
|
+
value: string
|
|
2230
|
+
branches: readonly string[]
|
|
2231
|
+
/** Branches that have a worktree — grouped first and marked. */
|
|
2232
|
+
worktreeBranches: readonly string[]
|
|
2233
|
+
/** Whether the host cut the branch list short. */
|
|
2234
|
+
truncated: boolean
|
|
2235
|
+
onPick: (ref: string) => void
|
|
2236
|
+
}): ReactNode {
|
|
2237
|
+
const [open, setOpen] = useState(false)
|
|
2238
|
+
const [query, setQuery] = useState('')
|
|
2239
|
+
const rootRef = useDismissable(open, setOpen)
|
|
2240
|
+
|
|
2241
|
+
const needle = query.trim().toLowerCase()
|
|
2242
|
+
const matched = needle.length === 0 ? branches : branches.filter(ref => ref.toLowerCase().includes(needle))
|
|
2243
|
+
const checkedOut = matched.filter(ref => worktreeBranches.includes(ref))
|
|
2244
|
+
const rest = matched.filter(ref => !worktreeBranches.includes(ref))
|
|
2245
|
+
const first = checkedOut[0] ?? rest[0]
|
|
2246
|
+
|
|
2247
|
+
const choose = (ref: string): void => {
|
|
2248
|
+
onPick(ref)
|
|
2249
|
+
setOpen(false)
|
|
2250
|
+
setQuery('')
|
|
2251
|
+
}
|
|
2252
|
+
|
|
2253
|
+
const row = (ref: string, inWorktree: boolean): ReactNode => (
|
|
2254
|
+
<button
|
|
2255
|
+
key={ref}
|
|
2256
|
+
type="button"
|
|
2257
|
+
role="option"
|
|
2258
|
+
aria-selected={ref === value}
|
|
2259
|
+
className={ref === value ? `${css.refRow} ${css.refRowActive}` : css.refRow}
|
|
2260
|
+
title={ref}
|
|
2261
|
+
onClick={() => choose(ref)}
|
|
2262
|
+
>
|
|
2263
|
+
{inWorktree ? <WorktreeGlyph /> : <span className={css.refRowSpacer} />}
|
|
2264
|
+
<Elided text={ref} className={css.refRowName} />
|
|
2265
|
+
</button>
|
|
2266
|
+
)
|
|
2267
|
+
|
|
2268
|
+
return (
|
|
2269
|
+
<div className={css.refPicker} ref={rootRef}>
|
|
2270
|
+
<span className={css.refLabel}>{label}</span>
|
|
2271
|
+
<button
|
|
2272
|
+
type="button"
|
|
2273
|
+
className={css.refButton}
|
|
2274
|
+
aria-expanded={open}
|
|
2275
|
+
title={value.length > 0 ? value : undefined}
|
|
2276
|
+
onClick={() => setOpen(isOpen => !isOpen)}
|
|
2277
|
+
>
|
|
2278
|
+
<Elided text={value.length > 0 ? value : '—'} className={css.refValue} />
|
|
2279
|
+
<span className={css.refCaret}>▾</span>
|
|
2280
|
+
</button>
|
|
2281
|
+
{open ? (
|
|
2282
|
+
<div className={css.refPop}>
|
|
2283
|
+
<input
|
|
2284
|
+
className={css.refSearch}
|
|
2285
|
+
autoFocus
|
|
2286
|
+
value={query}
|
|
2287
|
+
placeholder={t('refSearch')}
|
|
2288
|
+
onChange={event => setQuery(event.target.value)}
|
|
2289
|
+
onKeyDown={event => { if (event.key === 'Enter' && first !== undefined) choose(first) }}
|
|
2290
|
+
/>
|
|
2291
|
+
<div className={css.refList} role="listbox" aria-label={label}>
|
|
2292
|
+
{checkedOut.length > 0 && rest.length > 0 ? <div className={css.refGroup}>{t('refWorktrees')}</div> : null}
|
|
2293
|
+
{checkedOut.map(ref => row(ref, true))}
|
|
2294
|
+
{checkedOut.length > 0 && rest.length > 0 ? <div className={css.refGroup}>{t('refBranches')}</div> : null}
|
|
2295
|
+
{rest.map(ref => row(ref, false))}
|
|
2296
|
+
{matched.length === 0 ? <div className={css.refEmpty}>{t('refNone')}</div> : null}
|
|
2297
|
+
</div>
|
|
2298
|
+
<div className={css.refFoot}>
|
|
2299
|
+
{t('refCount', { shown: matched.length, total: branches.length })}
|
|
2300
|
+
{truncated ? ` · ${t('refTruncated')}` : ''}
|
|
2301
|
+
</div>
|
|
2302
|
+
</div>
|
|
2303
|
+
) : null}
|
|
2304
|
+
</div>
|
|
2305
|
+
)
|
|
2306
|
+
}
|
|
2307
|
+
|
|
2308
|
+
/**
|
|
2309
|
+
* Compare tab controls: the two refs, in reading order.
|
|
2310
|
+
*
|
|
2311
|
+
* Both sides list the same refs — comparing a branch against itself is possible
|
|
2312
|
+
* to express and simply reports nothing, which is clearer than hiding it.
|
|
2313
|
+
*/
|
|
2314
|
+
function CompareBar({ t, branches, worktreeBranches, truncated, baseRef, headRef, onBaseRef, onHeadRef }: {
|
|
2315
|
+
t: Translate
|
|
2316
|
+
branches: readonly string[]
|
|
2317
|
+
worktreeBranches: readonly string[]
|
|
2318
|
+
truncated: boolean
|
|
2319
|
+
baseRef: string
|
|
2320
|
+
headRef: string
|
|
2321
|
+
onBaseRef: (ref: string) => void
|
|
2322
|
+
onHeadRef: (ref: string) => void
|
|
2323
|
+
}): ReactNode {
|
|
2324
|
+
if (branches.length === 0) return <div className={css.compareBar}>{t('noBranches')}</div>
|
|
2325
|
+
return (
|
|
2326
|
+
<div className={css.compareBar}>
|
|
2327
|
+
<RefPicker
|
|
2328
|
+
t={t} label={t('compareBase')} value={baseRef}
|
|
2329
|
+
branches={branches} worktreeBranches={worktreeBranches} truncated={truncated}
|
|
2330
|
+
onPick={onBaseRef}
|
|
2331
|
+
/>
|
|
2332
|
+
<span className={css.compareArrow}>→</span>
|
|
2333
|
+
<RefPicker
|
|
2334
|
+
t={t} label={t('compareHead')} value={headRef}
|
|
2335
|
+
branches={branches} worktreeBranches={worktreeBranches} truncated={truncated}
|
|
2336
|
+
onPick={onHeadRef}
|
|
2337
|
+
/>
|
|
2338
|
+
</div>
|
|
2339
|
+
)
|
|
2340
|
+
}
|
|
2341
|
+
|
|
2342
|
+
/* ---------- write operations ---------- */
|
|
2343
|
+
|
|
2344
|
+
/**
|
|
2345
|
+
* Failures whose sentence is the whole story. Everything else shows git's own
|
|
2346
|
+
* text underneath, because the classification is a hint about what to do next
|
|
2347
|
+
* and the raw message is the evidence for it — when the hint is `unknown` it is
|
|
2348
|
+
* the only thing left that helps at all.
|
|
2349
|
+
*
|
|
2350
|
+
* These two are excluded because their detail is never informative and is often
|
|
2351
|
+
* actively misleading: git says nothing useful about an empty index, so what
|
|
2352
|
+
* lands in stderr is whatever a hook wrapper happened to print. A user reading
|
|
2353
|
+
* "nothing staged" followed by a lefthook config warning learns only that
|
|
2354
|
+
* something else is broken, which is not true.
|
|
2355
|
+
*/
|
|
2356
|
+
const SELF_EXPLANATORY: ReadonlySet<GitOpFailure> = new Set(['nothing-to-commit', 'no-upstream'])
|
|
2357
|
+
|
|
2358
|
+
/** What to tell the user about a finished operation. */
|
|
2359
|
+
function opMessage(t: Translate, op: GitOpName, result: GitOpResult): string {
|
|
2360
|
+
if (result.ok) return t(`op.ok.${op}`)
|
|
2361
|
+
const failure = result.failure ?? 'unknown'
|
|
2362
|
+
const reason = t(`op.fail.${failure}`)
|
|
2363
|
+
if (SELF_EXPLANATORY.has(failure)) return reason
|
|
2364
|
+
const detail = (result.error ?? '').trim()
|
|
2365
|
+
return detail.length > 0 ? `${reason}\n${detail}` : reason
|
|
2366
|
+
}
|
|
2367
|
+
|
|
2368
|
+
/**
|
|
2369
|
+
* Glyphs for the three network actions, on Primer's 16px grid.
|
|
2370
|
+
*
|
|
2371
|
+
* They sit BESIDE the labels rather than replacing them. The complaint that
|
|
2372
|
+
* started this was that Fetch/Pull/Push do not say what they do — icon-only
|
|
2373
|
+
* would answer it by removing the half that is unambiguous. What an icon adds
|
|
2374
|
+
* is recognition at a glance: down is work arriving, up is work leaving, and
|
|
2375
|
+
* the ring is the one that only reads a remote without changing anything here.
|
|
2376
|
+
*/
|
|
2377
|
+
const SYNC_GLYPH = {
|
|
2378
|
+
// Circular arrows: VS Code's and IDEA's shared sign for "refresh what I know
|
|
2379
|
+
// about the remote". Nothing in the working tree moves.
|
|
2380
|
+
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',
|
|
2381
|
+
// Down into a floor line: commits arriving from the remote onto this branch.
|
|
2382
|
+
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',
|
|
2383
|
+
// Up off a floor line: the same arrow mirrored, because the pair only reads
|
|
2384
|
+
// as a direction if it is the same arrow.
|
|
2385
|
+
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',
|
|
2386
|
+
} as const
|
|
2387
|
+
|
|
2388
|
+
function SyncGlyph({ of }: { of: keyof typeof SYNC_GLYPH }): ReactNode {
|
|
2389
|
+
return (
|
|
2390
|
+
<svg width="13" height="13" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
|
|
2391
|
+
<path d={SYNC_GLYPH[of]} />
|
|
2392
|
+
</svg>
|
|
2393
|
+
)
|
|
2394
|
+
}
|
|
2395
|
+
|
|
2396
|
+
const PULL_MODES = ['ff-only', 'rebase', 'merge'] as const
|
|
2397
|
+
type PullMode = typeof PULL_MODES[number]
|
|
2398
|
+
|
|
2399
|
+
/** Each strategy's label key, so the trigger and the menu cannot disagree. */
|
|
2400
|
+
const PULL_MODE_KEY: Record<PullMode, WorkbenchKey> = {
|
|
2401
|
+
'ff-only': 'pullFf',
|
|
2402
|
+
rebase: 'pullRebase',
|
|
2403
|
+
merge: 'pullMerge',
|
|
2404
|
+
}
|
|
2405
|
+
|
|
2406
|
+
/**
|
|
2407
|
+
* Pull strategy, in the drawer's own menu idiom.
|
|
2408
|
+
*
|
|
2409
|
+
* This was a native `<select>`, justified as "a three-way choice used rarely".
|
|
2410
|
+
* The cost was not the frequency: a native popup paints in the OS palette, so
|
|
2411
|
+
* it was the one control in the drawer that ignored `data-gs-theme` — system
|
|
2412
|
+
* blue over Solarized, square corners in a row of pills. Reusing the ref
|
|
2413
|
+
* picker's button and popover makes it the same idiom as the drawer's other
|
|
2414
|
+
* menu rather than a second one.
|
|
2415
|
+
*/
|
|
2416
|
+
function SyncModePicker({ t, value, disabled, quiet, onPick }: {
|
|
2417
|
+
t: Translate
|
|
2418
|
+
value: PullMode
|
|
2419
|
+
disabled: boolean
|
|
2420
|
+
/** Disabled only by an operation too young to report: refuse, but do not dim. */
|
|
2421
|
+
quiet: boolean
|
|
2422
|
+
onPick: (mode: PullMode) => void
|
|
2423
|
+
}): ReactNode {
|
|
2424
|
+
const [open, setOpen] = useState(false)
|
|
2425
|
+
const rootRef = useDismissable(open, setOpen)
|
|
2426
|
+
|
|
2427
|
+
return (
|
|
2428
|
+
<div className={css.refPicker} ref={rootRef}>
|
|
2429
|
+
<button
|
|
2430
|
+
type="button"
|
|
2431
|
+
className={css.refButton}
|
|
2432
|
+
aria-expanded={open}
|
|
2433
|
+
aria-label={t('pullModeLabel')}
|
|
2434
|
+
disabled={disabled}
|
|
2435
|
+
data-quiet={quiet ? '' : undefined}
|
|
2436
|
+
onClick={() => setOpen(isOpen => !isOpen)}
|
|
2437
|
+
>
|
|
2438
|
+
<span className={`${css.elide} ${css.refValue}`}><span className={css.elideTail}>{t(PULL_MODE_KEY[value])}</span></span>
|
|
2439
|
+
<span className={css.refCaret}>▾</span>
|
|
2440
|
+
</button>
|
|
2441
|
+
{open ? (
|
|
2442
|
+
<div className={`${css.refPop} ${css.menuPop}`} role="listbox" aria-label={t('pullModeLabel')}>
|
|
2443
|
+
{PULL_MODES.map(pullMode => (
|
|
2444
|
+
<button
|
|
2445
|
+
key={pullMode}
|
|
2446
|
+
type="button"
|
|
2447
|
+
role="option"
|
|
2448
|
+
aria-selected={pullMode === value}
|
|
2449
|
+
className={pullMode === value ? `${css.refRow} ${css.refRowActive}` : css.refRow}
|
|
2450
|
+
onClick={() => { onPick(pullMode); setOpen(false) }}
|
|
2451
|
+
>{t(PULL_MODE_KEY[pullMode])}</button>
|
|
2452
|
+
))}
|
|
2453
|
+
</div>
|
|
2454
|
+
) : null}
|
|
2455
|
+
</div>
|
|
2456
|
+
)
|
|
2457
|
+
}
|
|
2458
|
+
|
|
2459
|
+
/**
|
|
2460
|
+
* Whether an in-flight operation has run long enough to be worth showing.
|
|
2461
|
+
*
|
|
2462
|
+
* True only after `active` has held for `delay`, and then for at least `hold`
|
|
2463
|
+
* however quickly it ends — so a fast operation never paints, and a slow one
|
|
2464
|
+
* never blinks. See `op-feedback.ts` for why the appearance is paced and the
|
|
2465
|
+
* guard is not.
|
|
2466
|
+
*/
|
|
2467
|
+
function useSustained(active: boolean, delay = BUSY_DELAY_MS, hold = BUSY_HOLD_MS): boolean {
|
|
2468
|
+
const [shown, setShown] = useState(false)
|
|
2469
|
+
const shownAt = useRef(0)
|
|
2470
|
+
useEffect(() => {
|
|
2471
|
+
if (active === shown) return undefined
|
|
2472
|
+
const wait = active ? delay : holdRemaining(shownAt.current, Date.now(), hold)
|
|
2473
|
+
const id = setTimeout(() => {
|
|
2474
|
+
if (active) shownAt.current = Date.now()
|
|
2475
|
+
setShown(active)
|
|
2476
|
+
}, wait)
|
|
2477
|
+
return () => { clearTimeout(id) }
|
|
2478
|
+
}, [active, shown, delay, hold])
|
|
2479
|
+
return shown
|
|
2480
|
+
}
|
|
2481
|
+
|
|
2482
|
+
/**
|
|
2483
|
+
* Fetch / pull / push, with the divergence they act on.
|
|
2484
|
+
*
|
|
2485
|
+
* Hidden entirely when the repository has no remote: three buttons that can only
|
|
2486
|
+
* fail are worse than no buttons. Pull carries its own strategy picker rather
|
|
2487
|
+
* than reading `pull.rebase`, so the button's label is what actually runs.
|
|
2488
|
+
*
|
|
2489
|
+
* The three used to be one grey pill each, distinguished by their word and a
|
|
2490
|
+
* 13px glyph — indistinguishable at a glance because they carried the same
|
|
2491
|
+
* amount of information, which was none. The counts have moved off the
|
|
2492
|
+
* divergence pills and INTO the two buttons that act on them, so the control
|
|
2493
|
+
* that can do something about the drift is also the one that reports it. Fetch
|
|
2494
|
+
* stays quiet in every state: it writes nothing, so it never has news.
|
|
2495
|
+
*/
|
|
2496
|
+
function SyncBar({ t, sync, busy, onOp }: {
|
|
2497
|
+
t: Translate
|
|
2498
|
+
sync: SyncStatus
|
|
2499
|
+
busy: GitOpName | null
|
|
2500
|
+
onOp: (op: GitOpName, payload?: GitOpPayload) => void
|
|
2501
|
+
}): ReactNode {
|
|
2502
|
+
const [mode, setMode] = useState<PullMode>('ff-only')
|
|
2503
|
+
const running = busy !== null
|
|
2504
|
+
const noUpstream = sync.upstream === null
|
|
2505
|
+
// Every tick stages through git, so this bar was fading out and back on each
|
|
2506
|
+
// one. The buttons still refuse the click from the first frame; only saying
|
|
2507
|
+
// so waits until there is something worth saying.
|
|
2508
|
+
const sustained = useSustained(running)
|
|
2509
|
+
const quiet = quietlyDisabled(running, sustained, false)
|
|
2510
|
+
|
|
2511
|
+
/** Push is the branch's first — the one case where it is the whole point of
|
|
2512
|
+
* the bar, so it is the one case that gets the solid fill. */
|
|
2513
|
+
const pushClass = noUpstream ? `${css.btn} ${css.btnPrimary}`
|
|
2514
|
+
: sync.ahead > 0 ? `${css.btn} ${css.btnAhead}`
|
|
2515
|
+
: css.btn
|
|
2516
|
+
|
|
2517
|
+
return (
|
|
2518
|
+
<div className={css.syncBar} role="group" aria-label={t('syncLabel')}>
|
|
2519
|
+
<span className={css.syncUpstream} title={sync.upstream ?? undefined}>
|
|
2520
|
+
{noUpstream ? t('noUpstream') : sync.upstream}
|
|
2521
|
+
</span>
|
|
2522
|
+
{sync.behind === 0 && sync.ahead === 0 && !noUpstream
|
|
2523
|
+
? <span className={css.syncLevel}>{t('upToDate')}</span>
|
|
2524
|
+
: null}
|
|
2525
|
+
|
|
2526
|
+
<span className={css.syncSpacer} />
|
|
2527
|
+
|
|
2528
|
+
<button
|
|
2529
|
+
type="button" className={css.btn} disabled={running} data-quiet={quiet ? '' : undefined}
|
|
2530
|
+
onClick={() => onOp('fetch')}
|
|
2531
|
+
><SyncGlyph of="fetch" />{busy === 'fetch' ? t('opRunning') : t('fetch')}</button>
|
|
2532
|
+
|
|
2533
|
+
{/* The strategy is Pull's own argument, so it is welded to Pull. Loose
|
|
2534
|
+
between Fetch and Pull it read as a third peer action. */}
|
|
2535
|
+
<span className={css.pullGroup}>
|
|
2536
|
+
<SyncModePicker t={t} value={mode} disabled={running} quiet={quiet} onPick={setMode} />
|
|
2537
|
+
<button
|
|
2538
|
+
type="button"
|
|
2539
|
+
className={sync.behind > 0 ? `${css.btn} ${css.btnBehind}` : css.btn}
|
|
2540
|
+
disabled={running || noUpstream}
|
|
2541
|
+
// No upstream is a reason of Pull's own, so that dim stays put.
|
|
2542
|
+
data-quiet={quietlyDisabled(running, sustained, noUpstream) ? '' : undefined}
|
|
2543
|
+
title={noUpstream ? t('noUpstreamHint') : undefined}
|
|
2544
|
+
onClick={() => onOp('pull', { mode })}
|
|
2545
|
+
>
|
|
2546
|
+
<SyncGlyph of="pull" />
|
|
2547
|
+
{busy === 'pull' ? t('opRunning') : t('pull')}
|
|
2548
|
+
{sync.behind > 0 ? <span className={css.btnCount}>{sync.behind}</span> : null}
|
|
2549
|
+
</button>
|
|
2550
|
+
</span>
|
|
2551
|
+
|
|
2552
|
+
<button
|
|
2553
|
+
type="button" className={pushClass} disabled={running} data-quiet={quiet ? '' : undefined}
|
|
2554
|
+
// The first push of a branch has no upstream yet — that is the case
|
|
2555
|
+
// `--set-upstream` exists for, so it must not be disabled here.
|
|
2556
|
+
title={noUpstream ? t('pushSetUpstream') : undefined}
|
|
2557
|
+
onClick={() => onOp('push')}
|
|
2558
|
+
>
|
|
2559
|
+
<SyncGlyph of="push" />
|
|
2560
|
+
{busy === 'push' ? t('opRunning') : noUpstream ? t('publish') : t('push')}
|
|
2561
|
+
{sync.ahead > 0 && !noUpstream ? <span className={css.btnCount}>{sync.ahead}</span> : null}
|
|
2562
|
+
</button>
|
|
2563
|
+
</div>
|
|
2564
|
+
)
|
|
2565
|
+
}
|
|
2566
|
+
|
|
2567
|
+
/**
|
|
2568
|
+
* The commit box: a message, and what it would commit.
|
|
2569
|
+
*
|
|
2570
|
+
* Commit is disabled with nothing staged rather than quietly falling back to
|
|
2571
|
+
* committing the whole worktree. The drawer shows a staging area, and a button
|
|
2572
|
+
* that ignores it would make that display a lie.
|
|
2573
|
+
*/
|
|
2574
|
+
function CommitBox({ t, files, busy, onOp, message, onMessage, amend, onAmend }: {
|
|
2575
|
+
t: Translate
|
|
2576
|
+
files: readonly GitFile[]
|
|
2577
|
+
busy: GitOpName | null
|
|
2578
|
+
onOp: (op: GitOpName, payload?: GitOpPayload) => Promise<GitOpResult>
|
|
2579
|
+
/** Lifted to the panel: this box unmounts on a tab switch, the draft must not. */
|
|
2580
|
+
message: string
|
|
2581
|
+
onMessage: (next: string) => void
|
|
2582
|
+
amend: boolean
|
|
2583
|
+
onAmend: (next: boolean) => void
|
|
2584
|
+
}): ReactNode {
|
|
2585
|
+
const setMessage = onMessage
|
|
2586
|
+
const setAmend = onAmend
|
|
2587
|
+
const stagedCount = files.filter(file => file.staged === true).length
|
|
2588
|
+
const running = busy !== null
|
|
2589
|
+
// Amending re-uses the previous commit, so it is the one case where an empty
|
|
2590
|
+
// index is still a legitimate commit (a message-only reword).
|
|
2591
|
+
const needsStaged = stagedCount === 0 && !amend
|
|
2592
|
+
const needsMessage = message.trim().length === 0
|
|
2593
|
+
const canCommit = !needsMessage && !needsStaged && !running
|
|
2594
|
+
// A disabled button that does not say why reads as broken; the staging half of
|
|
2595
|
+
// that is stated permanently by the lead line above, so only the message case
|
|
2596
|
+
// needs the title.
|
|
2597
|
+
const blocked = needsMessage ? t('commitNeedMessage') : undefined
|
|
2598
|
+
|
|
2599
|
+
const commit = (): void => {
|
|
2600
|
+
if (!canCommit) return
|
|
2601
|
+
void onOp('commit', { message, amend }).then(result => {
|
|
2602
|
+
// Keep the message on failure: it is the user's text, and retyping a
|
|
2603
|
+
// commit message because the index was empty is a bad way to learn that.
|
|
2604
|
+
if (result.ok) { setMessage(''); setAmend(false) }
|
|
2605
|
+
})
|
|
2606
|
+
}
|
|
2607
|
+
|
|
2608
|
+
return (
|
|
2609
|
+
<div className={css.commitBox}>
|
|
2610
|
+
{/* The one instruction the tick model needs, stated once where the action
|
|
2611
|
+
lives. A blocker that appears only when the index is empty reads as an
|
|
2612
|
+
error and arrives after the confusion it explains. */}
|
|
2613
|
+
<p className={css.commitLead} data-gs-part="commit-lead">{t('commitLead')}</p>
|
|
2614
|
+
<textarea
|
|
2615
|
+
className={css.commitMessage}
|
|
2616
|
+
value={message}
|
|
2617
|
+
rows={2}
|
|
2618
|
+
placeholder={t('commitPlaceholder')}
|
|
2619
|
+
aria-label={t('commitPlaceholder')}
|
|
2620
|
+
disabled={running}
|
|
2621
|
+
onChange={event => setMessage(event.target.value)}
|
|
2622
|
+
onKeyDown={event => {
|
|
2623
|
+
// Ctrl/Cmd+Enter commits, the shortcut every git client shares. Plain
|
|
2624
|
+
// Enter stays a newline: a commit body is normal and losing it to a
|
|
2625
|
+
// stray keystroke is not recoverable from the UI.
|
|
2626
|
+
if ((event.ctrlKey || event.metaKey) && event.key === 'Enter') { event.preventDefault(); commit() }
|
|
2627
|
+
}}
|
|
2628
|
+
/>
|
|
2629
|
+
<div className={css.commitRow}>
|
|
2630
|
+
<label className={css.commitAmend}>
|
|
2631
|
+
<input
|
|
2632
|
+
type="checkbox" checked={amend} disabled={running}
|
|
2633
|
+
onChange={event => setAmend(event.target.checked)}
|
|
2634
|
+
/>
|
|
2635
|
+
{t('amend')}
|
|
2636
|
+
</label>
|
|
2637
|
+
<span className={css.commitStaged}>{t('stagedCount', { count: stagedCount })}</span>
|
|
2638
|
+
<button
|
|
2639
|
+
type="button"
|
|
2640
|
+
className={css.commitBtn}
|
|
2641
|
+
disabled={!canCommit}
|
|
2642
|
+
title={running ? undefined : blocked}
|
|
2643
|
+
onClick={commit}
|
|
2644
|
+
>{busy === 'commit' ? t('opRunning') : t('commit')}</button>
|
|
2645
|
+
</div>
|
|
2646
|
+
</div>
|
|
2647
|
+
)
|
|
2648
|
+
}
|
|
2649
|
+
|
|
2650
|
+
/** Subject plus body, the text `git log` would print for `%B` without the trailing newline. */
|
|
2651
|
+
function commitMessageText(commit: GitCommit): string {
|
|
2652
|
+
const body = commit.body ?? ''
|
|
2653
|
+
return body.length > 0 ? `${commit.subject}\n\n${body}` : commit.subject
|
|
2654
|
+
}
|
|
2655
|
+
|
|
2656
|
+
function CopyCommitButton({ t, text }: { t: Translate; text: string }): ReactNode {
|
|
2657
|
+
const [copied, setCopied] = useState(false)
|
|
2658
|
+
useEffect(() => {
|
|
2659
|
+
if (!copied) return
|
|
2660
|
+
const id = window.setTimeout(() => setCopied(false), 1400)
|
|
2661
|
+
return () => { window.clearTimeout(id) }
|
|
2662
|
+
}, [copied])
|
|
2663
|
+
return (
|
|
2664
|
+
<button
|
|
2665
|
+
type="button"
|
|
2666
|
+
className={css.commitCopy}
|
|
2667
|
+
onMouseDown={event => event.preventDefault()}
|
|
2668
|
+
onClick={event => {
|
|
2669
|
+
event.stopPropagation()
|
|
2670
|
+
void navigator.clipboard.writeText(text).then(() => setCopied(true), () => setCopied(false))
|
|
2671
|
+
}}
|
|
2672
|
+
>{copied ? t('copiedCommit') : t('copyCommit')}</button>
|
|
2673
|
+
)
|
|
2674
|
+
}
|
|
2675
|
+
|
|
2676
|
+
/**
|
|
2677
|
+
* One row in the history list. The subject stays one truncated line so the list
|
|
2678
|
+
* stays scannable; hovering opens a card with the full message, including a
|
|
2679
|
+
* multi-line body, which can be copied without selecting the commit.
|
|
2680
|
+
*/
|
|
2681
|
+
/* ---------- commit graph ---------- */
|
|
2682
|
+
|
|
2683
|
+
/** Row height the graph and the list agree on. The lines only join up if every
|
|
2684
|
+
* row is exactly as tall as the segment drawn for it. */
|
|
2685
|
+
const GRAPH_ROW_H = 48
|
|
2686
|
+
/** Horizontal distance between lanes. */
|
|
2687
|
+
const GRAPH_LANE_W = 14
|
|
2688
|
+
/** Ref chips shown inline before the subject; the rest collapse into a "+N". */
|
|
2689
|
+
const COMMIT_REF_CHIPS = 2
|
|
2690
|
+
/** Lanes past this are not drawn. A repository can braid arbitrarily wide, and
|
|
2691
|
+
* the diff is worth more than the twelfth simultaneous branch. */
|
|
2692
|
+
const GRAPH_MAX_LANES = 6
|
|
2693
|
+
|
|
2694
|
+
const laneX = (lane: number): number => lane * GRAPH_LANE_W + GRAPH_LANE_W / 2
|
|
2695
|
+
|
|
2696
|
+
/**
|
|
2697
|
+
* One row's slice of the commit graph.
|
|
2698
|
+
*
|
|
2699
|
+
* Drawn as an SVG of exactly {@link GRAPH_ROW_H} pixels, so consecutive rows
|
|
2700
|
+
* butt together and a lane reads as one unbroken line down the list. The dot
|
|
2701
|
+
* sits at the vertical centre; edges leave the top edge, the dot, or the bottom
|
|
2702
|
+
* edge, and a cubic with its control points at the quarter heights gives the
|
|
2703
|
+
* S-curve every git client draws for a branch or a merge.
|
|
2704
|
+
*/
|
|
2705
|
+
function GraphCell({ row, width, active }: { row: GraphRow; width: number; active: boolean }): ReactNode {
|
|
2706
|
+
const lanes = Math.min(width, GRAPH_MAX_LANES)
|
|
2707
|
+
const w = lanes * GRAPH_LANE_W
|
|
2708
|
+
const mid = GRAPH_ROW_H / 2
|
|
2709
|
+
const visible = (lane: number): boolean => lane < GRAPH_MAX_LANES
|
|
2710
|
+
const stroke = (lane: number): string => `var(--gs-graph-${lane % 6})`
|
|
2711
|
+
|
|
2712
|
+
const paths: ReactNode[] = []
|
|
2713
|
+
for (const lane of row.through) {
|
|
2714
|
+
if (!visible(lane)) continue
|
|
2715
|
+
paths.push(<path key={`t${lane}`} d={`M ${laneX(lane)} 0 V ${GRAPH_ROW_H}`} stroke={stroke(lane)} />)
|
|
2716
|
+
}
|
|
2717
|
+
for (const lane of row.into) {
|
|
2718
|
+
if (!visible(lane) || !visible(row.lane)) continue
|
|
2719
|
+
paths.push(lane === row.lane
|
|
2720
|
+
? <path key={`i${lane}`} d={`M ${laneX(lane)} 0 V ${mid}`} stroke={stroke(lane)} />
|
|
2721
|
+
: (
|
|
2722
|
+
<path
|
|
2723
|
+
key={`i${lane}`}
|
|
2724
|
+
d={`M ${laneX(lane)} 0 C ${laneX(lane)} ${mid / 2}, ${laneX(row.lane)} ${mid / 2}, ${laneX(row.lane)} ${mid}`}
|
|
2725
|
+
stroke={stroke(lane)}
|
|
2726
|
+
/>
|
|
2727
|
+
))
|
|
2728
|
+
}
|
|
2729
|
+
for (const lane of row.outOf) {
|
|
2730
|
+
if (!visible(lane) || !visible(row.lane)) continue
|
|
2731
|
+
paths.push(lane === row.lane
|
|
2732
|
+
? <path key={`o${lane}`} d={`M ${laneX(lane)} ${mid} V ${GRAPH_ROW_H}`} stroke={stroke(lane)} />
|
|
2733
|
+
: (
|
|
2734
|
+
<path
|
|
2735
|
+
key={`o${lane}`}
|
|
2736
|
+
d={`M ${laneX(row.lane)} ${mid} C ${laneX(row.lane)} ${mid + mid / 2}, ${laneX(lane)} ${mid + mid / 2}, ${laneX(lane)} ${GRAPH_ROW_H}`}
|
|
2737
|
+
stroke={stroke(lane)}
|
|
2738
|
+
/>
|
|
2739
|
+
))
|
|
2740
|
+
}
|
|
2741
|
+
|
|
2742
|
+
return (
|
|
2743
|
+
<svg
|
|
2744
|
+
className={css.graphCell}
|
|
2745
|
+
width={w}
|
|
2746
|
+
height={GRAPH_ROW_H}
|
|
2747
|
+
viewBox={`0 0 ${w} ${GRAPH_ROW_H}`}
|
|
2748
|
+
aria-hidden="true"
|
|
2749
|
+
focusable="false"
|
|
2750
|
+
>
|
|
2751
|
+
<g fill="none" strokeWidth="1.6" strokeLinecap="round">{paths}</g>
|
|
2752
|
+
{visible(row.lane) ? (
|
|
2753
|
+
<circle
|
|
2754
|
+
cx={laneX(row.lane)}
|
|
2755
|
+
cy={mid}
|
|
2756
|
+
r={row.isMerge ? 4.5 : 3.5}
|
|
2757
|
+
// A merge is hollow, the way every git client distinguishes it: it is
|
|
2758
|
+
// a joining of lines rather than a change of its own.
|
|
2759
|
+
fill={row.isMerge ? 'var(--gs-panel)' : stroke(row.lane)}
|
|
2760
|
+
stroke={stroke(row.lane)}
|
|
2761
|
+
strokeWidth={row.isMerge ? 2 : active ? 3 : 0}
|
|
2762
|
+
/>
|
|
2763
|
+
) : null}
|
|
2764
|
+
</svg>
|
|
2765
|
+
)
|
|
2766
|
+
}
|
|
2767
|
+
|
|
2768
|
+
function CommitRow({ t, commit, active, onSelect, graphRow, graphWidth }: {
|
|
2769
|
+
t: Translate
|
|
2770
|
+
commit: GitCommit
|
|
2771
|
+
active: boolean
|
|
2772
|
+
onSelect: (hash: string) => void
|
|
2773
|
+
/** This commit's lane geometry; absent while the graph is still empty. */
|
|
2774
|
+
graphRow?: GraphRow
|
|
2775
|
+
graphWidth: number
|
|
2776
|
+
}): ReactNode {
|
|
2777
|
+
const rowRef = useRef<HTMLButtonElement>(null)
|
|
2778
|
+
const [open, setOpen] = useState(false)
|
|
2779
|
+
const [box, setBox] = useState<{ top: number; left: number; maxHeight: number } | null>(null)
|
|
2780
|
+
const enterTimer = useRef(0)
|
|
2781
|
+
const leaveTimer = useRef(0)
|
|
2782
|
+
const body = commit.body ?? ''
|
|
2783
|
+
|
|
2784
|
+
const cancel = (): void => {
|
|
2785
|
+
window.clearTimeout(enterTimer.current)
|
|
2786
|
+
window.clearTimeout(leaveTimer.current)
|
|
2787
|
+
}
|
|
2788
|
+
const show = (): void => {
|
|
2789
|
+
cancel()
|
|
2790
|
+
enterTimer.current = window.setTimeout(() => setOpen(true), 360)
|
|
2791
|
+
}
|
|
2792
|
+
const hide = (): void => {
|
|
2793
|
+
cancel()
|
|
2794
|
+
leaveTimer.current = window.setTimeout(() => setOpen(false), 160)
|
|
2795
|
+
}
|
|
2796
|
+
|
|
2797
|
+
useEffect(() => () => { cancel() }, [])
|
|
2798
|
+
|
|
2799
|
+
useEffect(() => {
|
|
2800
|
+
if (!open) { setBox(null); return }
|
|
2801
|
+
const row = rowRef.current
|
|
2802
|
+
if (row === null) return
|
|
2803
|
+
const rect = row.getBoundingClientRect()
|
|
2804
|
+
const width = 380
|
|
2805
|
+
const left = Math.min(rect.right + 10, window.innerWidth - width - 12)
|
|
2806
|
+
const top = Math.max(12, Math.min(rect.top, window.innerHeight - 220))
|
|
2807
|
+
setBox({ top, left: Math.max(12, left), maxHeight: window.innerHeight - top - 16 })
|
|
2808
|
+
}, [open])
|
|
2809
|
+
|
|
2810
|
+
const host = rowRef.current?.closest('[data-gs-part="overlay"]') ?? (typeof document === 'undefined' ? null : document.body)
|
|
2811
|
+
|
|
2812
|
+
const refs = commit.refs ?? []
|
|
2813
|
+
|
|
2814
|
+
return (
|
|
2815
|
+
<>
|
|
2816
|
+
{/* The graph is a SIBLING of the row button, spanning the line's full
|
|
2817
|
+
height with no margin of its own — that is what lets a lane run
|
|
2818
|
+
unbroken from one row into the next while the button itself keeps its
|
|
2819
|
+
inset and its rounded corners. */}
|
|
2820
|
+
<div className={css.commitLine}>
|
|
2821
|
+
{graphRow !== undefined
|
|
2822
|
+
? <GraphCell row={graphRow} width={graphWidth} active={active} />
|
|
2823
|
+
: null}
|
|
2824
|
+
<button
|
|
2825
|
+
ref={rowRef}
|
|
2826
|
+
type="button"
|
|
2827
|
+
role="option"
|
|
2828
|
+
aria-selected={active}
|
|
2829
|
+
className={active ? `${css.commit} ${css.commitActive}` : css.commit}
|
|
2830
|
+
onClick={() => onSelect(commit.hash)}
|
|
2831
|
+
onMouseEnter={show}
|
|
2832
|
+
onMouseLeave={hide}
|
|
2833
|
+
>
|
|
2834
|
+
<span className={css.commitTop}>
|
|
2835
|
+
<code className={css.commitHash}>{commit.hash}</code>
|
|
2836
|
+
<span className={css.commitWhen}>{commit.when}</span>
|
|
2837
|
+
</span>
|
|
2838
|
+
<span className={css.commitSubjectRow}>
|
|
2839
|
+
{/* Capped at two. A release commit can carry six refs, and the
|
|
2840
|
+
subject is what the row is actually for — the rest are counted
|
|
2841
|
+
and named in the title rather than crowding it out. */}
|
|
2842
|
+
{refs.slice(0, COMMIT_REF_CHIPS).map(ref => (
|
|
2843
|
+
<span key={ref} className={css.commitRef} title={ref}>{ref}</span>
|
|
2844
|
+
))}
|
|
2845
|
+
{refs.length > COMMIT_REF_CHIPS ? (
|
|
2846
|
+
<span className={css.commitRefMore} title={refs.slice(COMMIT_REF_CHIPS).join('\n')}>
|
|
2847
|
+
+{refs.length - COMMIT_REF_CHIPS}
|
|
2848
|
+
</span>
|
|
2849
|
+
) : null}
|
|
2850
|
+
<span className={css.commitSubject}>{commit.subject}</span>
|
|
2851
|
+
{body.length > 0 ? <span className={css.commitHasBody} aria-hidden="true">···</span> : null}
|
|
2852
|
+
</span>
|
|
2853
|
+
</button>
|
|
2854
|
+
</div>
|
|
2855
|
+
{open && box !== null && host !== null ? createPortal(
|
|
2856
|
+
<div
|
|
2857
|
+
className={css.commitPop}
|
|
2858
|
+
style={{ top: box.top, left: box.left, maxHeight: box.maxHeight }}
|
|
2859
|
+
onMouseEnter={() => { cancel(); setOpen(true) }}
|
|
2860
|
+
onMouseLeave={hide}
|
|
2861
|
+
onClick={event => event.stopPropagation()}
|
|
2862
|
+
>
|
|
2863
|
+
<div className={css.commitPopTop}>
|
|
2864
|
+
<code className={css.commitHash}>{commit.hash}</code>
|
|
2865
|
+
<span className={css.commitWhen}>{commit.when}</span>
|
|
2866
|
+
<CopyCommitButton t={t} text={commitMessageText(commit)} />
|
|
2867
|
+
</div>
|
|
2868
|
+
<div className={css.commitPopSubject}>{commit.subject}</div>
|
|
2869
|
+
{body.length > 0 ? <pre className={css.commitPopBody}>{body}</pre> : null}
|
|
2870
|
+
</div>,
|
|
2871
|
+
host,
|
|
2872
|
+
) : null}
|
|
2873
|
+
</>
|
|
2874
|
+
)
|
|
2875
|
+
}
|
|
2876
|
+
|
|
2877
|
+
/**
|
|
2878
|
+
* The commit log as its own full-height pane.
|
|
2879
|
+
*
|
|
2880
|
+
* It sits BESIDE the file tree rather than stacked above it, which is what
|
|
2881
|
+
* GitHub Desktop, the JetBrains git log and GitKraken all do: a commit list and
|
|
2882
|
+
* the selected commit's files are peer panes, each with its own scrollbar. The
|
|
2883
|
+
* earlier stacked layout had to be collapsible because two scrolling lists were
|
|
2884
|
+
* sharing one narrow column — a control that hid the thing you were reading and
|
|
2885
|
+
* that nobody could be expected to discover. Side by side, there is nothing to
|
|
2886
|
+
* collapse and nothing to explain.
|
|
2887
|
+
*
|
|
2888
|
+
* Pages load by scrolling. A button at the end of a growing list is the worst
|
|
2889
|
+
* of both worlds — it retreats every time it is used, and it asks the reader to
|
|
2890
|
+
* confirm an intention that scrolling toward the end already stated. A sentinel
|
|
2891
|
+
* below the last row requests the next page as it comes into view, which is
|
|
2892
|
+
* what GitHub and GitLens do. The observer is rebuilt whenever the list grows,
|
|
2893
|
+
* so a page too short to fill the pane immediately triggers the next one.
|
|
2894
|
+
*/
|
|
2895
|
+
function CommitList({ paneRef, style, t, loading, commits, active, onSelect, hasMore, loadingMore, onLoadMore }: {
|
|
2896
|
+
/** The pane element, which the divider beside it measures from. Not named
|
|
2897
|
+
* `ref`: React reserves that on a function component, so it would be stripped
|
|
2898
|
+
* from props and never reach this element. */
|
|
2899
|
+
paneRef: Ref<HTMLDivElement>
|
|
2900
|
+
/** Dragged width, when the divider has been used. */
|
|
2901
|
+
style: CSSProperties | undefined
|
|
2902
|
+
t: Translate
|
|
2903
|
+
/** First page in flight — the pane says "loading", not "no history", which
|
|
2904
|
+
* would be a claim about the repository the data has not made. */
|
|
2905
|
+
loading: boolean
|
|
2906
|
+
commits: readonly GitCommit[]
|
|
2907
|
+
active: string | null
|
|
2908
|
+
onSelect: (hash: string) => void
|
|
2909
|
+
hasMore: boolean
|
|
2910
|
+
loadingMore: boolean
|
|
2911
|
+
onLoadMore: () => void
|
|
2912
|
+
}): ReactNode {
|
|
2913
|
+
const scrollRef = useRef<HTMLDivElement>(null)
|
|
2914
|
+
const sentinelRef = useRef<HTMLDivElement>(null)
|
|
2915
|
+
// Recomputed only when a page lands. The layout is a single pass over the
|
|
2916
|
+
// loaded prefix, and every row's geometry depends on the rows above it, so
|
|
2917
|
+
// there is nothing finer to memoise than the whole list.
|
|
2918
|
+
const graph = useMemo(
|
|
2919
|
+
() => layoutGraph(commits.map(commit => ({ hash: commit.hash, parents: commit.parents ?? [] }))),
|
|
2920
|
+
[commits],
|
|
2921
|
+
)
|
|
2922
|
+
|
|
2923
|
+
useEffect(() => {
|
|
2924
|
+
const root = scrollRef.current
|
|
2925
|
+
const sentinel = sentinelRef.current
|
|
2926
|
+
if (root === null || sentinel === null || !hasMore || loadingMore) return
|
|
2927
|
+
const observer = new IntersectionObserver(
|
|
2928
|
+
entries => { if (entries.some(entry => entry.isIntersecting)) onLoadMore() },
|
|
2929
|
+
// Start the fetch before the sentinel is actually reached, so the next
|
|
2930
|
+
// page is usually there by the time the reader arrives.
|
|
2931
|
+
{ root, rootMargin: '300px' },
|
|
2932
|
+
)
|
|
2933
|
+
observer.observe(sentinel)
|
|
2934
|
+
return () => { observer.disconnect() }
|
|
2935
|
+
}, [hasMore, loadingMore, commits.length, onLoadMore])
|
|
2936
|
+
|
|
2937
|
+
return (
|
|
2938
|
+
<div ref={paneRef} className={css.commitsPane} style={style} data-gs-part="commits">
|
|
2939
|
+
{/* No count: the only number available is how many pages have been loaded,
|
|
2940
|
+
which is not how many commits exist. A number that cannot be right is
|
|
2941
|
+
worse than none. */}
|
|
2942
|
+
<div className={css.paneHead}>
|
|
2943
|
+
<span className={css.paneTitle}>{t('historyLabel')}</span>
|
|
2944
|
+
</div>
|
|
2945
|
+
{commits.length === 0 ? (
|
|
2946
|
+
<div className={css.empty}>{loading ? t('loading') : t('noCommits')}</div>
|
|
2947
|
+
) : (
|
|
2948
|
+
<div className={css.commits} role="listbox" aria-label={t('historyLabel')} ref={scrollRef}>
|
|
2949
|
+
{commits.map((commit, index) => (
|
|
2950
|
+
<CommitRow
|
|
2951
|
+
key={commit.hash}
|
|
2952
|
+
t={t}
|
|
2953
|
+
commit={commit}
|
|
2954
|
+
active={commit.hash === active}
|
|
2955
|
+
onSelect={onSelect}
|
|
2956
|
+
graphRow={graph.rows[index]}
|
|
2957
|
+
graphWidth={graph.width}
|
|
2958
|
+
/>
|
|
2959
|
+
))}
|
|
2960
|
+
<div ref={sentinelRef} className={css.commitsSentinel} />
|
|
2961
|
+
<div className={css.commitsFoot}>
|
|
2962
|
+
{loadingMore ? t('loading') : hasMore ? '' : t('historyEnd')}
|
|
2963
|
+
</div>
|
|
2964
|
+
</div>
|
|
2965
|
+
)}
|
|
2966
|
+
</div>
|
|
2967
|
+
)
|
|
2968
|
+
}
|
|
2969
|
+
|
|
2970
|
+
/* ---------- file tree ---------- */
|
|
2971
|
+
|
|
2972
|
+
/** Horizontal step per nesting level. */
|
|
2973
|
+
const TREE_INDENT = 14
|
|
2974
|
+
/** The gutter every row starts at. Equals `--gs-gutter-pane`, so depth-0 ticks
|
|
2975
|
+
* line up with the toolbar's own content — and everything interactive stays
|
|
2976
|
+
* clear of the 10px resizer the drawer paints over its left edge. */
|
|
2977
|
+
const TREE_BASE_INDENT = 12
|
|
2978
|
+
/** Chevron width plus its gap. A file row adds this so its status badge starts at
|
|
2979
|
+
* the directory NAME's column rather than under the directory's chevron. */
|
|
2980
|
+
const TREE_LEAF_OFFSET = 16
|
|
2981
|
+
/** Where a level's indent guide sits: inside the chevron, so it points at the
|
|
2982
|
+
* rows it groups. */
|
|
2983
|
+
const TREE_RAIL_OFFSET = 12
|
|
2984
|
+
/** The tick's own width. Must match `.checkBox` — the row's content starts after
|
|
2985
|
+
* it, and the indent guides are positioned from it. */
|
|
2986
|
+
const TREE_CHECK_W = 22
|
|
2987
|
+
/** Custom property the stylesheet reads to place one level's indent guide. */
|
|
2988
|
+
const RAIL_VAR = '--gs-rail'
|
|
2989
|
+
|
|
2990
|
+
interface DirNode {
|
|
2991
|
+
readonly name: string
|
|
2992
|
+
readonly path: string
|
|
2993
|
+
readonly dirs: Map<string, DirNode>
|
|
2994
|
+
readonly files: GitFile[]
|
|
2995
|
+
fileCount: number
|
|
2996
|
+
added: number
|
|
2997
|
+
deleted: number
|
|
2998
|
+
/** Every descendant's tick, rolled up. Computed once with the other totals. */
|
|
2999
|
+
check: CheckState
|
|
3000
|
+
}
|
|
3001
|
+
|
|
3002
|
+
function buildTree(files: readonly GitFile[]): DirNode {
|
|
3003
|
+
const root: DirNode = { name: '', path: '', dirs: new Map(), files: [], fileCount: 0, added: 0, deleted: 0, check: 'off' }
|
|
3004
|
+
for (const file of files) {
|
|
3005
|
+
let node = root
|
|
3006
|
+
const parts = file.path.split('/')
|
|
3007
|
+
for (let i = 0; i < parts.length - 1; i += 1) {
|
|
3008
|
+
const name = parts[i]
|
|
3009
|
+
let child = node.dirs.get(name)
|
|
3010
|
+
if (child === undefined) {
|
|
3011
|
+
child = { name, path: parts.slice(0, i + 1).join('/'), dirs: new Map(), files: [], fileCount: 0, added: 0, deleted: 0, check: 'off' }
|
|
3012
|
+
node.dirs.set(name, child)
|
|
3013
|
+
}
|
|
3014
|
+
node = child
|
|
3015
|
+
}
|
|
3016
|
+
node.files.push(file)
|
|
3017
|
+
}
|
|
3018
|
+
const aggregate = (node: DirNode): void => {
|
|
3019
|
+
node.fileCount = node.files.length
|
|
3020
|
+
node.added = node.files.reduce((sum, f) => sum + f.addedLines, 0)
|
|
3021
|
+
node.deleted = node.files.reduce((sum, f) => sum + f.deletedLines, 0)
|
|
3022
|
+
const ticks: CheckState[] = node.files.map(fileCheckState)
|
|
3023
|
+
for (const child of node.dirs.values()) {
|
|
3024
|
+
aggregate(child)
|
|
3025
|
+
node.fileCount += child.fileCount
|
|
3026
|
+
node.added += child.added
|
|
3027
|
+
node.deleted += child.deleted
|
|
3028
|
+
ticks.push(child.check)
|
|
3029
|
+
}
|
|
3030
|
+
node.check = rollUp(ticks)
|
|
3031
|
+
}
|
|
3032
|
+
aggregate(root)
|
|
3033
|
+
return compactChains(root)
|
|
3034
|
+
}
|
|
3035
|
+
|
|
3036
|
+
/**
|
|
3037
|
+
* Merge every directory that holds nothing but one subdirectory into that child.
|
|
3038
|
+
*
|
|
3039
|
+
* `docs/superpowers/specs/design.md` otherwise costs three rows and three indent
|
|
3040
|
+
* levels to reach one file, and none of those three rows carries a choice — each
|
|
3041
|
+
* has exactly one way down. Merging them into a single `docs/superpowers/specs`
|
|
3042
|
+
* row is what VS Code calls compact folders, and it makes indentation depth mean
|
|
3043
|
+
* "where the tree branches" rather than "how long the path is".
|
|
3044
|
+
*
|
|
3045
|
+
* The merged node keeps the DEEPEST path, so it stays the one the collapse set
|
|
3046
|
+
* and the reveal-the-active-file walk already address.
|
|
3047
|
+
* @param node - directory whose descendants are compacted.
|
|
3048
|
+
* @returns the node with compacted children.
|
|
3049
|
+
*/
|
|
3050
|
+
function compactChains(node: DirNode): DirNode {
|
|
3051
|
+
const dirs = new Map<string, DirNode>()
|
|
3052
|
+
for (const child of node.dirs.values()) {
|
|
3053
|
+
let merged = compactChains(child)
|
|
3054
|
+
while (merged.files.length === 0 && merged.dirs.size === 1) {
|
|
3055
|
+
const only = merged.dirs.values().next().value as DirNode
|
|
3056
|
+
merged = { ...only, name: `${merged.name}/${only.name}` }
|
|
3057
|
+
}
|
|
3058
|
+
dirs.set(merged.name, merged)
|
|
3059
|
+
}
|
|
3060
|
+
return { ...node, dirs }
|
|
3061
|
+
}
|
|
3062
|
+
|
|
3063
|
+
interface FileTreeProps {
|
|
3064
|
+
t: Translate
|
|
3065
|
+
/** Whether the view has nothing to show yet — already resolved by the caller
|
|
3066
|
+
* via {@link showsPending}, NOT the raw in-flight flag. This pane used to
|
|
3067
|
+
* re-derive it from `loading && files.length === 0`, and that second copy of
|
|
3068
|
+
* the rule is precisely what the header then got wrong. */
|
|
3069
|
+
loading?: boolean
|
|
3070
|
+
/** What the list holds, prepended to the count — the working-tree view says
|
|
3071
|
+
* which worktree it is reading; commit views are already named by history. */
|
|
3072
|
+
lead?: string
|
|
3073
|
+
files: readonly GitFile[]
|
|
3074
|
+
active: string | null
|
|
3075
|
+
onSelect: (path: string) => void
|
|
3076
|
+
/** Undefined until the user interacts: then it shows defaults. Lifted to the
|
|
3077
|
+
* panel so background polls (new `files` identity) and drawer close/reopen
|
|
3078
|
+
* never reset the user's expansion choices. */
|
|
3079
|
+
collapsed: Set<string> | undefined
|
|
3080
|
+
onCollapsedChange: (next: Set<string>) => void
|
|
3081
|
+
/** Add or remove files from the commit set. Undefined outside the working-tree
|
|
3082
|
+
* view, where what a commit contains was decided long ago. */
|
|
3083
|
+
onCheck?: (files: readonly GitFile[], state: CheckState) => void
|
|
3084
|
+
/** Rendered under the tree in the working-tree view only. */
|
|
3085
|
+
footer?: ReactNode
|
|
3086
|
+
}
|
|
3087
|
+
|
|
3088
|
+
function FileTree({ t, loading, lead, files, active, onSelect, collapsed, onCollapsedChange, onCheck, footer }: FileTreeProps): ReactNode {
|
|
3089
|
+
const tree = useMemo(() => buildTree(files), [files])
|
|
3090
|
+
/** Default: a dir collapses when it holds more than 12 files anywhere below it. */
|
|
3091
|
+
const effective = collapsed ?? defaultCollapsed(tree)
|
|
3092
|
+
|
|
3093
|
+
// Reveal the active file by expanding its ancestor chain — ONLY when the
|
|
3094
|
+
// selection itself changes. Listening to `collapsed` here would instantly
|
|
3095
|
+
// revert manual folds of any directory containing the active file.
|
|
3096
|
+
useEffect(() => {
|
|
3097
|
+
if (active === null) return
|
|
3098
|
+
const parts = active.split('/')
|
|
3099
|
+
let touched = false
|
|
3100
|
+
const next = new Set(collapsed ?? defaultCollapsed(tree))
|
|
3101
|
+
for (let i = 1; i < parts.length; i += 1) {
|
|
3102
|
+
const dir = parts.slice(0, i).join('/')
|
|
3103
|
+
if (next.delete(dir)) touched = true
|
|
3104
|
+
}
|
|
3105
|
+
if (touched) onCollapsedChange(next)
|
|
3106
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- reveal is a selection-change event, not an invariant over `collapsed`
|
|
3107
|
+
}, [active])
|
|
3108
|
+
|
|
3109
|
+
const setAll = (open: boolean): void => {
|
|
3110
|
+
onCollapsedChange(open ? new Set() : allDirs(tree))
|
|
3111
|
+
}
|
|
3112
|
+
|
|
3113
|
+
const toggleOne = (path: string): void => {
|
|
3114
|
+
const next = new Set(effective)
|
|
3115
|
+
if (next.has(path)) next.delete(path)
|
|
3116
|
+
else next.add(path)
|
|
3117
|
+
onCollapsedChange(next)
|
|
3118
|
+
}
|
|
3119
|
+
|
|
3120
|
+
const stageLabels = { stage: t('stage'), unstage: t('unstage') }
|
|
3121
|
+
|
|
3122
|
+
return (
|
|
3123
|
+
<div className={css.treeWrap}>
|
|
3124
|
+
<div className={css.treeTools}>
|
|
3125
|
+
{/* The root tick replaces the Stage all / Unstage all pair: it says the
|
|
3126
|
+
same two things in the column the rows already read down, and gives
|
|
3127
|
+
the toolbar back the room those two buttons needed. The toolbar's own
|
|
3128
|
+
pane gutter is its indent, so it lines up with the depth-0 rows. */}
|
|
3129
|
+
<div className={css.treeLead}>
|
|
3130
|
+
{onCheck !== undefined ? (
|
|
3131
|
+
<CheckBox
|
|
3132
|
+
state={tree.check}
|
|
3133
|
+
label={tree.check === 'on' ? t('unstageAll') : t('stageAll')}
|
|
3134
|
+
indent={0}
|
|
3135
|
+
onToggle={() => onCheck(files, tree.check)}
|
|
3136
|
+
/>
|
|
3137
|
+
) : null}
|
|
3138
|
+
<span className={css.treeLabel}>
|
|
3139
|
+
{loading === true
|
|
3140
|
+
? t('loading')
|
|
3141
|
+
: `${lead !== undefined ? `${lead} · ` : ''}${t('files', { count: files.length })}`}
|
|
3142
|
+
</span>
|
|
3143
|
+
</div>
|
|
3144
|
+
<div className={css.treeActions} data-gs-part="tree-actions">
|
|
3145
|
+
{/* Icon-only, with the label on `title`/`aria-label`: the glyph is the
|
|
3146
|
+
same one the rows carry, so each button previews its own result. */}
|
|
3147
|
+
<button
|
|
3148
|
+
type="button" className={css.treeIcon} data-gs-part="expand-all"
|
|
3149
|
+
title={t('expandAll')} aria-label={t('expandAll')}
|
|
3150
|
+
onClick={() => setAll(true)}
|
|
3151
|
+
><span className={`${css.treeIconGlyph} ${css.treeIconDown}`}>▸</span></button>
|
|
3152
|
+
<button
|
|
3153
|
+
type="button" className={css.treeIcon} data-gs-part="collapse-all"
|
|
3154
|
+
title={t('collapseAll')} aria-label={t('collapseAll')}
|
|
3155
|
+
onClick={() => setAll(false)}
|
|
3156
|
+
><span className={css.treeIconGlyph}>▸</span></button>
|
|
3157
|
+
</div>
|
|
3158
|
+
</div>
|
|
3159
|
+
{loading === true ? (
|
|
3160
|
+
<div className={css.treeEmpty} data-gs-part="tree-loading">{t('loading')}</div>
|
|
3161
|
+
) : (
|
|
3162
|
+
<ul className={css.tree}>
|
|
3163
|
+
<TreeChildren
|
|
3164
|
+
node={tree} depth={0} active={active} collapsed={effective}
|
|
3165
|
+
onToggle={toggleOne} onSelect={onSelect} onCheck={onCheck} stageLabels={stageLabels}
|
|
3166
|
+
/>
|
|
3167
|
+
</ul>
|
|
3168
|
+
)}
|
|
3169
|
+
{footer}
|
|
3170
|
+
</div>
|
|
3171
|
+
)
|
|
3172
|
+
}
|
|
3173
|
+
|
|
3174
|
+
function defaultCollapsed(root: DirNode): Set<string> {
|
|
3175
|
+
const out = new Set<string>()
|
|
3176
|
+
const walk = (node: DirNode): void => {
|
|
3177
|
+
for (const child of node.dirs.values()) {
|
|
3178
|
+
if (child.fileCount > 12) out.add(child.path)
|
|
3179
|
+
walk(child)
|
|
3180
|
+
}
|
|
3181
|
+
}
|
|
3182
|
+
walk(root)
|
|
3183
|
+
return out
|
|
3184
|
+
}
|
|
3185
|
+
|
|
3186
|
+
function allDirs(node: DirNode): Set<string> {
|
|
3187
|
+
const out = new Set<string>()
|
|
3188
|
+
const walk = (n: DirNode): void => {
|
|
3189
|
+
for (const child of n.dirs.values()) { out.add(child.path); walk(child) }
|
|
3190
|
+
}
|
|
3191
|
+
walk(node)
|
|
3192
|
+
return out
|
|
3193
|
+
}
|
|
3194
|
+
|
|
3195
|
+
/**
|
|
3196
|
+
* One tick. A sibling of the row it belongs to rather than a child of it: a
|
|
3197
|
+
* button inside a button is invalid HTML, and the two clicks mean different
|
|
3198
|
+
* things — this one changes the commit set, the row opens the diff.
|
|
3199
|
+
*
|
|
3200
|
+
* The tick carries its row's own indent and stands at the node it includes,
|
|
3201
|
+
* IDEA-style, rather than in a column pinned to the pane edge. A pinned column
|
|
3202
|
+
* reads at a glance, but it detaches each tick from its node — and its first
|
|
3203
|
+
* 10px sat underneath the drawer's edge resizer, so a click on the left half of
|
|
3204
|
+
* a depth-0 tick dragged the drawer instead of staging anything.
|
|
3205
|
+
*/
|
|
3206
|
+
function CheckBox({ state, label, indent, onToggle }: {
|
|
3207
|
+
state: CheckState
|
|
3208
|
+
label: string
|
|
3209
|
+
/** The row's left edge, carried here so the tick stands at its own node. */
|
|
3210
|
+
indent: number
|
|
3211
|
+
onToggle: () => void
|
|
3212
|
+
}): ReactNode {
|
|
3213
|
+
const mark = state === 'on' ? css.checkMarkOn : state === 'partial' ? css.checkMarkPartial : ''
|
|
3214
|
+
return (
|
|
3215
|
+
<button
|
|
3216
|
+
type="button"
|
|
3217
|
+
role="checkbox"
|
|
3218
|
+
aria-checked={state === 'partial' ? 'mixed' : state === 'on'}
|
|
3219
|
+
className={css.checkBox}
|
|
3220
|
+
style={{ marginLeft: indent }}
|
|
3221
|
+
title={label}
|
|
3222
|
+
aria-label={label}
|
|
3223
|
+
onClick={onToggle}
|
|
3224
|
+
>
|
|
3225
|
+
<span className={`${css.checkMark} ${mark}`} aria-hidden="true">
|
|
3226
|
+
{state === 'on' ? '✓' : state === 'partial' ? '–' : ''}
|
|
3227
|
+
</span>
|
|
3228
|
+
</button>
|
|
3229
|
+
)
|
|
3230
|
+
}
|
|
3231
|
+
|
|
3232
|
+
/** Every file at or under a node, for a tick that acts on a whole directory. */
|
|
3233
|
+
function filesUnder(node: DirNode): GitFile[] {
|
|
3234
|
+
const out = [...node.files]
|
|
3235
|
+
for (const child of node.dirs.values()) out.push(...filesUnder(child))
|
|
3236
|
+
return out
|
|
3237
|
+
}
|
|
3238
|
+
|
|
3239
|
+
interface TreeChildrenProps {
|
|
3240
|
+
node: DirNode
|
|
3241
|
+
depth: number
|
|
3242
|
+
active: string | null
|
|
3243
|
+
collapsed: Set<string>
|
|
3244
|
+
onToggle: (path: string) => void
|
|
3245
|
+
onSelect: (path: string) => void
|
|
3246
|
+
/** Add or remove files from the commit set. Undefined outside the working-tree
|
|
3247
|
+
* view, where what a commit contains was decided long ago. */
|
|
3248
|
+
onCheck?: (files: readonly GitFile[], state: CheckState) => void
|
|
3249
|
+
/** Pre-translated, so the row does not have to carry `t` for two strings. */
|
|
3250
|
+
stageLabels: { stage: string; unstage: string }
|
|
3251
|
+
}
|
|
3252
|
+
|
|
3253
|
+
function TreeChildren({ node, depth, active, collapsed, onToggle, onSelect, onCheck, stageLabels }: TreeChildrenProps): ReactNode {
|
|
3254
|
+
const dirNodes = [...node.dirs.values()].sort((a, b) => a.name.localeCompare(b.name))
|
|
3255
|
+
const fileNodes = [...node.files].sort((a, b) => basePart(a.path).localeCompare(basePart(b.path)))
|
|
3256
|
+
const checkColumn = onCheck !== undefined ? TREE_CHECK_W : 0
|
|
3257
|
+
// With ticks, the tick carries this indent and the button starts at 0; without
|
|
3258
|
+
// them (history, compare) the button carries it, as it always did.
|
|
3259
|
+
const indent = TREE_BASE_INDENT + depth * TREE_INDENT
|
|
3260
|
+
return (
|
|
3261
|
+
<>
|
|
3262
|
+
{dirNodes.map(dir => {
|
|
3263
|
+
const open = !collapsed.has(dir.path)
|
|
3264
|
+
const containsActive = active !== null && active.startsWith(`${dir.path}/`)
|
|
3265
|
+
return (
|
|
3266
|
+
<li key={dir.path} className={css.treeDirLi}>
|
|
3267
|
+
<div className={css.treeRow}>
|
|
3268
|
+
{onCheck !== undefined ? (
|
|
3269
|
+
<CheckBox
|
|
3270
|
+
state={dir.check}
|
|
3271
|
+
label={dir.check === 'on' ? stageLabels.unstage : stageLabels.stage}
|
|
3272
|
+
indent={indent}
|
|
3273
|
+
onToggle={() => onCheck(filesUnder(dir), dir.check)}
|
|
3274
|
+
/>
|
|
3275
|
+
) : null}
|
|
3276
|
+
<button
|
|
3277
|
+
type="button"
|
|
3278
|
+
className={`${css.treeDir} ${containsActive ? css.treeDirActive : ''}`}
|
|
3279
|
+
style={{ paddingLeft: onCheck !== undefined ? 0 : indent }}
|
|
3280
|
+
onClick={() => onToggle(dir.path)}
|
|
3281
|
+
title={dir.path}
|
|
3282
|
+
>
|
|
3283
|
+
<span className={`${css.chevron} ${open ? css.chevronOpen : ''}`}>▸</span>
|
|
3284
|
+
<span className={css.treeDirName}>{dir.name}</span>
|
|
3285
|
+
<span className={css.treeDirCount}>{dir.fileCount}</span>
|
|
3286
|
+
<span className={css.treeDirCounts}>
|
|
3287
|
+
{dir.added > 0 ? <span className={css.fileCountAdd}>+{dir.added}</span> : null}
|
|
3288
|
+
{dir.deleted > 0 ? <span className={css.fileCountDel}>−{dir.deleted}</span> : null}
|
|
3289
|
+
</span>
|
|
3290
|
+
</button>
|
|
3291
|
+
</div>
|
|
3292
|
+
{open ? (
|
|
3293
|
+
<ul
|
|
3294
|
+
className={css.treeSub}
|
|
3295
|
+
// The rail hangs off the parent's chevron, which sits after the
|
|
3296
|
+
// row's tick — so the tick's width is part of the offset.
|
|
3297
|
+
style={{ [RAIL_VAR]: `${checkColumn + TREE_BASE_INDENT + depth * TREE_INDENT + TREE_RAIL_OFFSET}px` } as CSSProperties}
|
|
3298
|
+
>
|
|
3299
|
+
<TreeChildren node={dir} depth={depth + 1} active={active} collapsed={collapsed} onToggle={onToggle} onSelect={onSelect} onCheck={onCheck} stageLabels={stageLabels} />
|
|
3300
|
+
</ul>
|
|
3301
|
+
) : null}
|
|
3302
|
+
</li>
|
|
3303
|
+
)
|
|
3304
|
+
})}
|
|
3305
|
+
{fileNodes.map(file => {
|
|
3306
|
+
const check = fileCheckState(file)
|
|
3307
|
+
return (
|
|
3308
|
+
<li key={file.path} className={css.fileLi}>
|
|
3309
|
+
{onCheck !== undefined ? (
|
|
3310
|
+
<CheckBox
|
|
3311
|
+
state={check}
|
|
3312
|
+
label={check === 'on' ? stageLabels.unstage : stageLabels.stage}
|
|
3313
|
+
indent={indent}
|
|
3314
|
+
onToggle={() => onCheck([file], check)}
|
|
3315
|
+
/>
|
|
3316
|
+
) : null}
|
|
3317
|
+
<button
|
|
3318
|
+
type="button"
|
|
3319
|
+
className={active === file.path ? `${css.file} ${css.fileActive}` : css.file}
|
|
3320
|
+
style={{ paddingLeft: (onCheck !== undefined ? 0 : indent) + TREE_LEAF_OFFSET }}
|
|
3321
|
+
onClick={() => onSelect(file.path)}
|
|
3322
|
+
title={file.previousPath !== undefined ? `${file.previousPath} → ${file.path}` : file.path}
|
|
3323
|
+
>
|
|
3324
|
+
<span className={`${css.fileStatus} ${STATUS_BADGE[file.status]}`}>{statusGlyph(file.status)}</span>
|
|
3325
|
+
<span className={css.filePath}>{basePart(file.path)}</span>
|
|
3326
|
+
{file.binary ? <span className={css.fileBinary}>BIN</span> : (
|
|
3327
|
+
<span className={css.fileCounts}>
|
|
3328
|
+
<span className={css.fileCountAdd}>{file.addedLines > 0 ? `+${file.addedLines}` : ''}</span>{' '}
|
|
3329
|
+
<span className={css.fileCountDel}>{file.deletedLines > 0 ? `−${file.deletedLines}` : ''}</span>
|
|
3330
|
+
</span>
|
|
3331
|
+
)}
|
|
3332
|
+
</button>
|
|
3333
|
+
</li>
|
|
3334
|
+
)
|
|
3335
|
+
})}
|
|
3336
|
+
</>
|
|
3337
|
+
)
|
|
3338
|
+
}
|
|
3339
|
+
|
|
3340
|
+
/* ---------- diff rendering: rows, word-level ranges, syntax pass ---------- */
|
|
3341
|
+
|
|
3342
|
+
/** Render one file's unified-diff segment with word-level highlights and Shiki. */
|
|
3343
|
+
function DiffView({ segment, path, palette }: { segment: string; path: string; palette: string }): ReactNode {
|
|
3344
|
+
const lang = shikiLangOf(path)
|
|
3345
|
+
const shikiTheme = shikiThemeOf(palette)
|
|
3346
|
+
const grammarGen = useSyncExternalStore(subscribeGrammarLoaded, grammarLoadCount)
|
|
3347
|
+
const rowsWithWords = useMemo(() => attachWordRanges(parseRows(segment)), [segment])
|
|
3348
|
+
const sides = useMemo(() => gutterSides(rowsWithWords), [rowsWithWords])
|
|
3349
|
+
const syntax = useMemo(
|
|
3350
|
+
() => highlightForRows(rowsWithWords, lang, shikiTheme),
|
|
3351
|
+
[rowsWithWords, lang, shikiTheme, grammarGen],
|
|
3352
|
+
)
|
|
3353
|
+
return (
|
|
3354
|
+
<pre className={css.diffPre}>
|
|
3355
|
+
{rowsWithWords.map((row, i) => (
|
|
3356
|
+
<div key={i} className={`${css.line} ${rowClass(row.kind)}`}>
|
|
3357
|
+
{sides.old ? <span className={css.lnOld}>{row.kind === 'add' || row.kind === 'hunk' ? '' : row.oldL}</span> : null}
|
|
3358
|
+
{sides.new ? <span className={css.lnNew}>{row.kind === 'del' || row.kind === 'hunk' ? '' : row.newL}</span> : null}
|
|
3359
|
+
<span className={`${css.gutter} ${row.kind === 'add' ? css.signAdd : row.kind === 'del' ? css.signDel : ''}`}>
|
|
3360
|
+
{row.kind === 'add' ? '+' : row.kind === 'del' ? '−' : ''}
|
|
3361
|
+
</span>
|
|
3362
|
+
<span className={css.code}>{renderCode(row, syntax[i] ?? [])}</span>
|
|
3363
|
+
</div>
|
|
3364
|
+
))}
|
|
3365
|
+
</pre>
|
|
3366
|
+
)
|
|
3367
|
+
}
|
|
3368
|
+
|
|
3369
|
+
function rowClass(kind: Row['kind']): string {
|
|
3370
|
+
switch (kind) {
|
|
3371
|
+
case 'add': return css.lineAdd
|
|
3372
|
+
case 'del': return css.lineDel
|
|
3373
|
+
case 'hunk': return css.lineHunk
|
|
3374
|
+
default: return css.lineContext
|
|
3375
|
+
}
|
|
3376
|
+
}
|
|
3377
|
+
|
|
3378
|
+
function renderCode(row: RowWithRanges, tokens: readonly HighlightRun[]): ReactNode {
|
|
3379
|
+
if (row.kind === 'hunk') return row.text
|
|
3380
|
+
const painted = overlayRanges(tokens.length > 0 ? tokens : [{ text: row.text }], row.ranges ?? [])
|
|
3381
|
+
if (painted.length === 1 && painted[0]!.color === undefined && !painted[0]!.mark) return row.text
|
|
3382
|
+
return painted.map((tok, i) => (
|
|
3383
|
+
<span
|
|
3384
|
+
key={i}
|
|
3385
|
+
className={tok.mark ? (row.kind === 'add' ? css.wordAdd : css.wordDel) : undefined}
|
|
3386
|
+
style={tok.color === undefined && !tok.italic ? undefined : { color: tok.color, fontStyle: tok.italic ? 'italic' : undefined }}
|
|
3387
|
+
>{tok.text}</span>
|
|
3388
|
+
))
|
|
3389
|
+
}
|
|
3390
|
+
|
|
3391
|
+
/* ---------- shared helpers ---------- */
|
|
3392
|
+
|
|
3393
|
+
/** Split a combined `git diff` into path -> its segment text. */
|
|
3394
|
+
function splitDiff(diff: string): Map<string, string> {
|
|
3395
|
+
const out = new Map<string, string>()
|
|
3396
|
+
if (diff.length === 0) return out
|
|
3397
|
+
for (const part of diff.split(/(?=^diff --git )/m)) {
|
|
3398
|
+
if (part.length === 0) continue
|
|
3399
|
+
const path = extractDiffPath(part)
|
|
3400
|
+
if (path.length > 0) out.set(path, part)
|
|
3401
|
+
}
|
|
3402
|
+
return out
|
|
3403
|
+
}
|
|
3404
|
+
|
|
3405
|
+
function extractDiffPath(part: string): string {
|
|
3406
|
+
const firstLine = part.split('\n')[0] ?? ''
|
|
3407
|
+
const match = /\sb\/(.+)$/.exec(firstLine)
|
|
3408
|
+
if (match !== null) return match[1]
|
|
3409
|
+
const rename = /^rename to (.+)$/m.exec(part)
|
|
3410
|
+
if (rename !== null) return rename[1]
|
|
3411
|
+
const del = /\ba\/(.+)$/.exec(firstLine)
|
|
3412
|
+
return del !== null ? del[1] : ''
|
|
3413
|
+
}
|
|
3414
|
+
|
|
3415
|
+
/**
|
|
3416
|
+
* A branch name, or the stand-in when there is none.
|
|
3417
|
+
*
|
|
3418
|
+
* This used to also cut the name to 21 characters and append an ellipsis, which
|
|
3419
|
+
* is how `feature/nested/deep/some-fix` reached the header as
|
|
3420
|
+
* `feature/nested/deep/s…` — the truncation was in JS, so it happened at the
|
|
3421
|
+
* same 21 characters whether the drawer was 400px or maximised, and it cut off
|
|
3422
|
+
* the only end that says which branch this is. Width is the stylesheet's
|
|
3423
|
+
* question; {@link Elided} answers it, from the correct end, only when there is
|
|
3424
|
+
* genuinely not enough room.
|
|
3425
|
+
*
|
|
3426
|
+
* @param branch - branch name, empty when the repo has none yet.
|
|
3427
|
+
* @param empty - already-translated stand-in for the empty case.
|
|
3428
|
+
*/
|
|
3429
|
+
function branchLabel(branch: string, empty: string): string {
|
|
3430
|
+
return branch.length === 0 ? empty : branch
|
|
3431
|
+
}
|
|
3432
|
+
|
|
3433
|
+
function statusGlyph(status: GitFileStatus): string {
|
|
3434
|
+
switch (status) {
|
|
3435
|
+
case 'added': return 'A'
|
|
3436
|
+
case 'untracked': return 'U'
|
|
3437
|
+
case 'modified': return 'M'
|
|
3438
|
+
case 'renamed': return 'R'
|
|
3439
|
+
case 'deleted': return 'D'
|
|
3440
|
+
}
|
|
3441
|
+
}
|
|
3442
|
+
|
|
3443
|
+
function basePart(path: string): string {
|
|
3444
|
+
const cut = path.lastIndexOf('/')
|
|
3445
|
+
return cut >= 0 ? path.slice(cut + 1) : path
|
|
3446
|
+
}
|