@young1lin/dsh-ui-gitworkbench 0.1.11 → 0.1.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +1 -1
- package/CHANGELOG.md +28 -0
- package/CHANGELOG_EN.md +28 -0
- package/README.md +100 -50
- package/README_EN.md +2 -1
- package/lib/client.js +6902 -6256
- package/package.json +1 -1
- package/src/client/ChangesFileTree.tsx +550 -0
- package/src/client/ChromeGlyph.tsx +27 -0
- package/src/client/CodeEditor.tsx +129 -3
- package/src/client/CommitHistory.tsx +1001 -0
- package/src/client/DiffViews.tsx +1070 -0
- package/src/client/GitWorkbenchPanel.module.css +15 -2513
- package/src/client/GitWorkbenchPanel.tsx +37 -3959
- package/src/client/PaneDivider.tsx +74 -0
- package/src/client/WorkbenchControls.tsx +1047 -0
- package/src/client/WorktreeGlyph.tsx +24 -0
- package/src/client/cm-search-theme.ts +250 -0
- package/src/client/diff-nav.ts +59 -0
- package/src/client/git-workbench-types.ts +252 -0
- package/src/client/locales.ts +14 -8
- package/src/client/row-window.ts +23 -0
- package/src/client/search-count.ts +125 -0
- package/src/client/side-rows.ts +66 -0
- package/src/client/styles/changes.css +505 -0
- package/src/client/styles/controls.css +236 -0
- package/src/client/styles/environment.css +89 -0
- package/src/client/styles/files.css +113 -0
- package/src/client/styles/history-filters.css +400 -0
- package/src/client/styles/history.css +276 -0
- package/src/client/styles/image.css +67 -0
- package/src/client/styles/operations.css +179 -0
- package/src/client/styles/shell.css +431 -0
- package/src/client/styles/themes.css +224 -0
- package/src/client/use-change-nav.ts +6 -5
- package/src/client/use-row-window.ts +55 -0
|
@@ -47,304 +47,50 @@
|
|
|
47
47
|
* All copy resolves through the app's locale runtime (`t`), so the panel follows
|
|
48
48
|
* the user's language preference.
|
|
49
49
|
*/
|
|
50
|
-
import {
|
|
51
|
-
import { createPortal } from 'react-dom'
|
|
50
|
+
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from 'react'
|
|
52
51
|
import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
|
53
52
|
import {
|
|
54
|
-
|
|
55
|
-
|
|
53
|
+
DEFAULT_APPEARANCE, EMPTY_SETTINGS, DSH_DARK_ATTR, effectiveBackground, effectiveCss, hostSchemeDark,
|
|
54
|
+
isAppearance, resolveTheme, withScope,
|
|
56
55
|
type Appearance, type ColorMode, type StyleEntry, type StyleScope, type StyleSettings, type ThemeFamily,
|
|
57
56
|
} from './themes.ts'
|
|
58
|
-
import { attachWordRanges, gutterSides, overlayRanges, parseRows, type Row, type RowWithRanges } from './diff-model.ts'
|
|
59
|
-
import { parsePatch } from '../patch-model.ts'
|
|
60
|
-
import { alignRows, blockCount, blockIsWholeFile, blockLines, blockTally, sideBodyState, type SideCell, type SideRow } from './side-rows.ts'
|
|
61
|
-
import { blockTopsFromRows, countBlocks, unifiedBlocks } from './diff-nav.ts'
|
|
62
57
|
import { clampPane, neighbourWidth } from './pane-size.ts'
|
|
63
|
-
import { DIFF_GRID_PAD_TOP, DIFF_ROW_H, rowWindow, type RowWindow } from './row-window.ts'
|
|
64
58
|
import { COMMIT_ROW_H, DEFAULT_HISTORY_LAYOUT, isHistoryLayout, type HistoryLayout } from './history-layout.ts'
|
|
65
|
-
import { useChangeNav } from './use-change-nav.ts'
|
|
66
59
|
import {
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
type EditState, type LeaveGuard, type WriteResult,
|
|
60
|
+
gateLeave, LEAVE_GUARD_CLEAR, leaveAnswered, leaveAsked, paneDirtyReport,
|
|
61
|
+
type LeaveGuard, type WriteResult,
|
|
70
62
|
} from './side-edit.ts'
|
|
71
63
|
import { FileBrowser } from './FileBrowser.tsx'
|
|
64
|
+
import { ColumnsGlyph, CommitList, LayoutButton, StackedGlyph } from './CommitHistory.tsx'
|
|
65
|
+
import { FileTree } from './ChangesFileTree.tsx'
|
|
66
|
+
import { DiffView, LeaveEditsConfirm, SideBySideView } from './DiffViews.tsx'
|
|
67
|
+
import { PaneDivider, usePaneDrag } from './PaneDivider.tsx'
|
|
68
|
+
import { ChromeGlyph } from './ChromeGlyph.tsx'
|
|
69
|
+
import { WorktreeGlyph } from './WorktreeGlyph.tsx'
|
|
70
|
+
import {
|
|
71
|
+
blockDiscardBodyText, branchLabel, CommitBox, CompareBar, DiscardConfirm, discardBodyText, Elided,
|
|
72
|
+
opMessage, RefPicker, SettingsMenu, SourceChip, SyncBar,
|
|
73
|
+
} from './WorkbenchControls.tsx'
|
|
72
74
|
import { decodePlaces, encodePlaces, placeAt, withPlace, type FilesPlace, type FilesPlaces } from './files-place.ts'
|
|
73
75
|
import { useIdleValue } from './idle-value.ts'
|
|
74
|
-
import {
|
|
75
|
-
import { detectIndent } from './indent.ts'
|
|
76
|
-
import { CodeEditor, type PaintFn } from './CodeEditor.tsx'
|
|
77
|
-
import { layoutGraph, type GraphRow } from './commit-graph.ts'
|
|
78
|
-
import { formatCommitDate } from './commit-filter.ts'
|
|
79
|
-
import { chipsFromFilter, emptyQueryFilter, parseLogQuery, removeChip, serializeLogQuery } from './log-filter-query.ts'
|
|
80
|
-
import { buildDirTree, searchPaths, type DirEntry } from './dir-tree.ts'
|
|
76
|
+
import { emptyQueryFilter, parseLogQuery, serializeLogQuery } from './log-filter-query.ts'
|
|
81
77
|
import { nextAfterPlan, type DiscardAnswer, type DiscardPreview } from './discard-flow.ts'
|
|
82
|
-
import { filterFiles } from './file-filter.ts'
|
|
83
|
-
import { addPath, buildIndex, checkedState, isCovered, removePath } from './path-select.ts'
|
|
84
|
-
import { inCalRange, localTodayIso, monthGrid, weekdayLabels } from './calendar.ts'
|
|
85
78
|
import { NO_PATHS, preferredFile } from './active-file.ts'
|
|
86
79
|
import type { LogFilter } from '../log-filter.ts'
|
|
87
80
|
import type { AuthorEntry } from '../shortlog.ts'
|
|
88
81
|
import {
|
|
89
|
-
|
|
90
|
-
type
|
|
82
|
+
nextAction, nextBatch, pathsFor, settledTicks, withPendingTicks,
|
|
83
|
+
type Tick, type TickAction,
|
|
91
84
|
} from './stage-tree.ts'
|
|
92
|
-
import {
|
|
93
|
-
import { badgeRepeatsBranch, bindingChanged, branchOfWorktree, pathKey, probesClosedBinding, samePath, showsPending, splitPath, turnSettled, viewedPath } from './worktree-view.ts'
|
|
94
|
-
import { BUSY_DELAY_MS, BUSY_HOLD_MS, holdRemaining, quietlyDisabled } from './op-feedback.ts'
|
|
95
|
-
import type { WorkbenchKey } from './locales.ts'
|
|
85
|
+
import { badgeRepeatsBranch, bindingChanged, branchOfWorktree, pathKey, probesClosedBinding, samePath, showsPending, turnSettled, viewedPath } from './worktree-view.ts'
|
|
96
86
|
import css from './GitWorkbenchPanel.module.css'
|
|
97
87
|
|
|
98
|
-
export type
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
readonly deletedLines: number
|
|
105
|
-
readonly binary: boolean
|
|
106
|
-
readonly previousPath?: string
|
|
107
|
-
/**
|
|
108
|
-
* Which side of the index this file's change is on. Both can be true — a file
|
|
109
|
-
* staged and then edited again. Absent outside the working-tree view: a
|
|
110
|
-
* commit's files were staged long ago and the question is meaningless.
|
|
111
|
-
*/
|
|
112
|
-
readonly staged?: boolean
|
|
113
|
-
readonly unstaged?: boolean
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
export interface GitCommit {
|
|
117
|
-
readonly hash: string
|
|
118
|
-
readonly subject: string
|
|
119
|
-
readonly when: string
|
|
120
|
-
/** Everything after the subject. Empty string when the commit has none. */
|
|
121
|
-
readonly body: string
|
|
122
|
-
/** Author name (`%an`). Optional only because a pre-0.1.4 host half sends none. */
|
|
123
|
-
readonly authorName?: string
|
|
124
|
-
/** Committer name (`%cn`); equals the author except on rebases and patches a maintainer applied. */
|
|
125
|
-
readonly committerName?: string
|
|
126
|
-
/** Committer date, strict ISO 8601 (`%cI`) — the exact moment `when` summarizes. */
|
|
127
|
-
readonly dateIso?: string
|
|
128
|
-
/** Abbreviated parent hashes, first parent first — the graph's edges. */
|
|
129
|
-
readonly parents?: readonly string[]
|
|
130
|
-
/** Branch and tag names pointing here, already stripped of git's decoration syntax. */
|
|
131
|
-
readonly refs?: readonly string[]
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
export interface WorkbenchStats {
|
|
135
|
-
readonly worktreePath: string
|
|
136
|
-
readonly branch: string
|
|
137
|
-
readonly ahead: number
|
|
138
|
-
readonly behind: number
|
|
139
|
-
readonly detached: boolean
|
|
140
|
-
readonly addedLines: number
|
|
141
|
-
readonly deletedLines: number
|
|
142
|
-
readonly addedFiles: number
|
|
143
|
-
readonly deletedFiles: number
|
|
144
|
-
readonly modifiedFiles: number
|
|
145
|
-
readonly files: readonly GitFile[]
|
|
146
|
-
readonly diff: string
|
|
147
|
-
/**
|
|
148
|
-
* Commits this view is about: the single commit for a commit view, the range's
|
|
149
|
-
* commits for a comparison. Empty for the working tree — the history list
|
|
150
|
-
* loads its own pages so it can follow a ref of its own.
|
|
151
|
-
*/
|
|
152
|
-
readonly commits: readonly GitCommit[]
|
|
153
|
-
readonly error?: string
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
/** One worktree of the repository, as `git worktree list --porcelain` reports it. */
|
|
157
|
-
export interface WorktreeEntry {
|
|
158
|
-
readonly path: string
|
|
159
|
-
readonly head: string
|
|
160
|
-
readonly branch: string
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
/** The session's worktree binding, as persisted by the worktree tools. */
|
|
164
|
-
export interface WorktreeBinding {
|
|
165
|
-
readonly repoRoot: string
|
|
166
|
-
readonly worktreePath: string
|
|
167
|
-
readonly name: string
|
|
168
|
-
readonly enteredAt: string
|
|
169
|
-
readonly baseCommit?: string
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
/**
|
|
173
|
-
* `gitWorkbench/worktreeStatus`: the session's binding (null when unbound) plus every
|
|
174
|
-
* worktree of the surrounding repository. Git allows at most one worktree per
|
|
175
|
-
* branch, so this one list is both the worktree picker and the branch picker.
|
|
176
|
-
*/
|
|
177
|
-
export interface WorktreeStatus {
|
|
178
|
-
readonly binding: WorktreeBinding | null
|
|
179
|
-
readonly worktrees: readonly WorktreeEntry[]
|
|
180
|
-
/**
|
|
181
|
-
* Every local branch, most-recently-committed first. Distinct from
|
|
182
|
-
* {@link worktrees} on purpose: a branch without a worktree has no directory
|
|
183
|
-
* to read, so it can be browsed or compared but not viewed as a working tree.
|
|
184
|
-
*/
|
|
185
|
-
readonly branches: readonly string[]
|
|
186
|
-
/** Whether the host cut {@link branches} short at its cap. */
|
|
187
|
-
readonly branchesTruncated: boolean
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
/**
|
|
191
|
-
* `gitWorkbench/syncStatus`: where the current branch stands against its upstream.
|
|
192
|
-
*
|
|
193
|
-
* `upstream: null` and "level with the upstream" are different states and the
|
|
194
|
-
* drawer treats them differently — the first is what makes the first push pass
|
|
195
|
-
* `--set-upstream`, and both otherwise read as zero ahead and zero behind.
|
|
196
|
-
*/
|
|
197
|
-
export interface SyncStatus {
|
|
198
|
-
readonly branch: string
|
|
199
|
-
readonly upstream: string | null
|
|
200
|
-
readonly ahead: number
|
|
201
|
-
readonly behind: number
|
|
202
|
-
readonly detached: boolean
|
|
203
|
-
/** Whether the repository has any remote at all. No remote, no sync bar. */
|
|
204
|
-
readonly hasRemote: boolean
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
/** Why a write operation failed, in terms the drawer can explain. `stale` is
|
|
208
|
-
* a sha the host re-derived and refused; `invalid` an argument combination
|
|
209
|
-
* the host rejected before running anything. */
|
|
210
|
-
export type GitOpFailure =
|
|
211
|
-
| 'auth' | 'network' | 'no-upstream' | 'diverged' | 'conflict'
|
|
212
|
-
| 'nothing-to-commit' | 'dirty' | 'stale' | 'invalid' | 'unknown'
|
|
213
|
-
|
|
214
|
-
export interface GitOpResult {
|
|
215
|
-
readonly ok: boolean
|
|
216
|
-
readonly failure?: GitOpFailure
|
|
217
|
-
/** git's own message on failure. Shown verbatim: a classification is a hint. */
|
|
218
|
-
readonly error?: string
|
|
219
|
-
readonly output?: string
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
/** The host endpoints under `gitWorkbench/` that change something. */
|
|
223
|
-
export type GitOpName = 'stage' | 'unstage' | 'commit' | 'fetch' | 'pull' | 'push' | 'discardFile' | 'applyBlocks'
|
|
224
|
-
|
|
225
|
-
/** Extra arguments an operation needs beyond the worktree path. */
|
|
226
|
-
export interface GitOpPayload {
|
|
227
|
-
readonly paths?: readonly string[]
|
|
228
|
-
readonly message?: string
|
|
229
|
-
readonly amend?: boolean
|
|
230
|
-
/** `pull` picks how to integrate; `applyBlocks` which block mutation. One
|
|
231
|
-
* field serves both because the payload is a flat bag keyed by op — the
|
|
232
|
-
* host narrows and validates it per endpoint. */
|
|
233
|
-
readonly mode?: 'ff-only' | 'rebase' | 'merge' | BlockMode
|
|
234
|
-
/** `discardFile` and `applyBlocks`, and deliberately singular: the one
|
|
235
|
-
* irreversible thing the drawer does takes one file per call, so a mistaken
|
|
236
|
-
* click costs one file. */
|
|
237
|
-
readonly path?: string
|
|
238
|
-
/** `discardFile` only: the effect the confirmation stated. The host refuses
|
|
239
|
-
* if the file changed underneath the dialog and now means something else. */
|
|
240
|
-
readonly expectedEffect?: string
|
|
241
|
-
/** `applyBlocks` only: the layer whose diff the `diffSha` is over, and the
|
|
242
|
-
* block's hunk-line indices (`side-rows.blockLines`). The host re-fetches
|
|
243
|
-
* that layer's diff and refuses unless the sha still matches. */
|
|
244
|
-
readonly layer?: SideLayer
|
|
245
|
-
readonly diffSha?: string
|
|
246
|
-
readonly lines?: readonly number[]
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
export type { DiscardAnswer, DiscardNext, DiscardPreview } from './discard-flow.ts'
|
|
250
|
-
export type { WriteResult } from './side-edit.ts'
|
|
251
|
-
|
|
252
|
-
/** Which side of the index a side-by-side pane shows: `unstaged` is
|
|
253
|
-
* index→worktree (the editable side), `staged` is HEAD→index (read-only). */
|
|
254
|
-
export type SideLayer = 'unstaged' | 'staged'
|
|
255
|
-
|
|
256
|
-
/** A block mutation the side pane's buttons request: `stage` and `discard` act
|
|
257
|
-
* on the unstaged layer, `unstage` on the staged one. The host enforces the
|
|
258
|
-
* same matrix. */
|
|
259
|
-
export type BlockMode = 'stage' | 'unstage' | 'discard'
|
|
260
|
-
|
|
261
|
-
/**
|
|
262
|
-
* What one block action acts on, snapshotted from the diff the pane had
|
|
263
|
-
* rendered when the click (or its confirmation) happened.
|
|
264
|
-
*
|
|
265
|
-
* The snapshot is the point: `diffSha` proves the file has not changed since
|
|
266
|
-
* the pane rendered it, and `lines` — the block's hunk-line indices — only
|
|
267
|
-
* mean anything against exactly that diff. A confirmed roll-back carries the
|
|
268
|
-
* ask it opened with, so the answer cannot drift under the dialog.
|
|
269
|
-
*/
|
|
270
|
-
export interface BlockAsk {
|
|
271
|
-
readonly path: string
|
|
272
|
-
readonly layer: SideLayer
|
|
273
|
-
readonly diffSha: string
|
|
274
|
-
readonly lines: readonly number[]
|
|
275
|
-
/** The block's line tallies, for the roll-back confirmation's wording. */
|
|
276
|
-
readonly added: number
|
|
277
|
-
readonly deleted: number
|
|
278
|
-
/** Whether the block is the file's entire content — the untracked case,
|
|
279
|
-
* whose roll-back DELETES the file and whose confirmation says so. */
|
|
280
|
-
readonly wholeFile: boolean
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
/**
|
|
284
|
-
* `gitWorkbench/fileSides`: one layer of one file for the side-by-side pane.
|
|
285
|
-
* Mirrors the host's `FileSides` (the client re-declares host shapes rather
|
|
286
|
-
* than importing the host module, which pulls node and the RPC decorators).
|
|
287
|
-
*/
|
|
288
|
-
export interface FileSides {
|
|
289
|
-
/** Unified diff at full context; '' when the layer has no change. */
|
|
290
|
-
readonly diff: string
|
|
291
|
-
/** sha1 of `diff`, echoed back by mutations to prove the same snapshot. */
|
|
292
|
-
readonly diffSha: string
|
|
293
|
-
/** Whole right-hand text, the editor's initial buffer. */
|
|
294
|
-
readonly targetText: string
|
|
295
|
-
/** Blob sha of the right-hand side; '' when it does not exist. */
|
|
296
|
-
readonly targetSha: string
|
|
297
|
-
readonly binary: boolean
|
|
298
|
-
/** True when the file is past the size guard; the client shows the old view. */
|
|
299
|
-
readonly tooLarge: boolean
|
|
300
|
-
/** True when the working-tree file is not valid UTF-8; the pane shows the
|
|
301
|
-
* diff but withholds the editor. Optional so an older host half reads as
|
|
302
|
-
* "fine" rather than as a refusal this client cannot explain. */
|
|
303
|
-
readonly lossyEncoding?: boolean
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
/**
|
|
307
|
-
* `gitWorkbench/fileImage`: one working-tree file's bytes, when the host's
|
|
308
|
-
* signature check confirms they are an image a browser can draw.
|
|
309
|
-
*
|
|
310
|
-
* Every field is present in both outcomes — an image and a refusal — because
|
|
311
|
-
* the gateway's payloads carry no `undefined`. `reason` is '' exactly when
|
|
312
|
-
* `ok`, and names the refusal otherwise: 'notImage', 'tooLarge', 'missing'.
|
|
313
|
-
*/
|
|
314
|
-
export interface FileImage {
|
|
315
|
-
readonly ok: boolean
|
|
316
|
-
/** MIME type to label the blob with; '' when declined. */
|
|
317
|
-
readonly mime: string
|
|
318
|
-
/** Short label for the caption — 'PNG', 'WebP', 'SVG'; '' when declined. */
|
|
319
|
-
readonly kind: string
|
|
320
|
-
/** The whole file, base64; '' when declined. */
|
|
321
|
-
readonly base64: string
|
|
322
|
-
/** The file's size in bytes, reported either way. */
|
|
323
|
-
readonly bytes: number
|
|
324
|
-
readonly reason: string
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
/** One line's provenance, as `gitWorkbench/blame` reports it. */
|
|
328
|
-
export interface BlameLine {
|
|
329
|
-
/** Full commit sha; all zeros for a line not committed yet. */
|
|
330
|
-
readonly hash: string
|
|
331
|
-
readonly author: string
|
|
332
|
-
/** Author time, unix seconds; 0 when git did not say. */
|
|
333
|
-
readonly time: number
|
|
334
|
-
readonly summary: string
|
|
335
|
-
readonly uncommitted: boolean
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
/** `gitWorkbench/blame`'s answer. `error` is present only on failure. */
|
|
339
|
-
export interface BlameAnswer {
|
|
340
|
-
readonly lines: readonly BlameLine[]
|
|
341
|
-
/** Whether the file was longer than the gutter's cap. */
|
|
342
|
-
readonly truncated: boolean
|
|
343
|
-
readonly error?: string
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
/** Translate a key of this plugin's namespace, with optional `{name}` params. */
|
|
347
|
-
export type Translate = (key: string, params?: Record<string, string | number>) => string
|
|
88
|
+
export type * from './git-workbench-types.ts'
|
|
89
|
+
import type {
|
|
90
|
+
BlameAnswer, BlockAsk, BlockMode, FileImage, FileSides, GitCommit, GitFile,
|
|
91
|
+
GitOpName, GitOpPayload, GitOpResult, SideLayer, SyncStatus, Translate, WorkbenchStats,
|
|
92
|
+
WorktreeBinding, WorktreeEntry, WorktreeStatus,
|
|
93
|
+
} from './git-workbench-types.ts'
|
|
348
94
|
|
|
349
95
|
type Props = PropsRuntime<'conversation.session.header.actions'> & {
|
|
350
96
|
readonly t: Translate
|
|
@@ -404,13 +150,7 @@ const MIN_DIFF_WIDTH = 300
|
|
|
404
150
|
const MIN_COMMITS_HEIGHT = 90
|
|
405
151
|
const MIN_STACKED_LOWER = 200
|
|
406
152
|
|
|
407
|
-
|
|
408
|
-
* this the file grows fast while a blurred backdrop gains nothing. */
|
|
409
|
-
const IMAGE_MAX_EDGE = 2560
|
|
410
|
-
/** JPEG quality for that resample. */
|
|
411
|
-
const IMAGE_QUALITY = 0.82
|
|
412
|
-
/** Refuse an image whose encoded data URL exceeds this; matches the host's cap. */
|
|
413
|
-
const IMAGE_MAX_BYTES = 3_000_000
|
|
153
|
+
|
|
414
154
|
|
|
415
155
|
/** Element carrying the user's custom stylesheet. One per document. */
|
|
416
156
|
const CUSTOM_STYLE_ID = 'dsh-ui-gitworkbench-custom-css'
|
|
@@ -499,82 +239,7 @@ function writeStored(key: string, value: unknown): void {
|
|
|
499
239
|
}
|
|
500
240
|
}
|
|
501
241
|
|
|
502
|
-
/**
|
|
503
|
-
* One pointer-captured horizontal drag, shared by the drawer's leading edge and
|
|
504
|
-
* both pane dividers.
|
|
505
|
-
*
|
|
506
|
-
* Pointer capture is what keeps a drag alive over every pane and past the window
|
|
507
|
-
* edge; a plain mousemove listener on the handle loses it as soon as the pointer
|
|
508
|
-
* crosses a child that stops propagation.
|
|
509
|
-
* @returns the active flag for styling, and the pointerdown handler to attach.
|
|
510
|
-
*/
|
|
511
|
-
function usePaneDrag(axis: 'x' | 'y' = 'x'): {
|
|
512
|
-
dragging: boolean
|
|
513
|
-
start: (event: ReactPointerEvent<HTMLElement>, onDrag: (position: number, done: boolean) => void) => void
|
|
514
|
-
} {
|
|
515
|
-
const along = (event: { clientX: number; clientY: number }): number =>
|
|
516
|
-
axis === 'y' ? event.clientY : event.clientX
|
|
517
|
-
const [dragging, setDragging] = useState(false)
|
|
518
|
-
const start = (event: ReactPointerEvent<HTMLElement>, onDrag: (position: number, done: boolean) => void): void => {
|
|
519
|
-
event.preventDefault()
|
|
520
|
-
const handle = event.currentTarget
|
|
521
|
-
handle.setPointerCapture(event.pointerId)
|
|
522
|
-
setDragging(true)
|
|
523
|
-
const onMove = (move: PointerEvent): void => { onDrag(along(move), false) }
|
|
524
|
-
// `pointercancel` ends a drag the browser took over (a touch became a
|
|
525
|
-
// gesture, the window lost focus). It releases capture itself, so only the
|
|
526
|
-
// pointerup path releases — and both must detach, or the next drag stacks a
|
|
527
|
-
// second set of listeners on the same handle.
|
|
528
|
-
const finish = (end: PointerEvent): void => {
|
|
529
|
-
if (end.type === 'pointerup') {
|
|
530
|
-
onDrag(along(end), true)
|
|
531
|
-
handle.releasePointerCapture(end.pointerId)
|
|
532
|
-
}
|
|
533
|
-
handle.removeEventListener('pointermove', onMove)
|
|
534
|
-
handle.removeEventListener('pointerup', finish)
|
|
535
|
-
handle.removeEventListener('pointercancel', finish)
|
|
536
|
-
setDragging(false)
|
|
537
|
-
}
|
|
538
|
-
handle.addEventListener('pointermove', onMove)
|
|
539
|
-
handle.addEventListener('pointerup', finish)
|
|
540
|
-
handle.addEventListener('pointercancel', finish)
|
|
541
|
-
}
|
|
542
|
-
return { dragging, start }
|
|
543
|
-
}
|
|
544
|
-
|
|
545
|
-
/**
|
|
546
|
-
* Drag handle between two panes.
|
|
547
|
-
* @param label - accessible name.
|
|
548
|
-
* @param onDrag - receives the pointer's x and whether the drag just ended.
|
|
549
|
-
* @returns the divider element.
|
|
550
|
-
*/
|
|
551
|
-
function PaneDivider({ label, onDrag, axis = 'x' }: {
|
|
552
|
-
label: string
|
|
553
|
-
/** clientX for an 'x' handle, clientY for a 'y' one. */
|
|
554
|
-
onDrag: (position: number, done: boolean) => void
|
|
555
|
-
/** Which way the handle moves. 'y' is the History tab's stacked split. */
|
|
556
|
-
axis?: 'x' | 'y'
|
|
557
|
-
}): ReactNode {
|
|
558
|
-
const { dragging, start } = usePaneDrag(axis)
|
|
559
|
-
const base = axis === 'y' ? `${css.paneDivider} ${css.paneDividerY}` : css.paneDivider
|
|
560
|
-
return (
|
|
561
|
-
<div
|
|
562
|
-
className={dragging ? `${base} ${css.paneDividerActive}` : base}
|
|
563
|
-
role="separator"
|
|
564
|
-
// The orientation a separator reports is the axis it SEPARATES along,
|
|
565
|
-
// which is the opposite of the one it slides on.
|
|
566
|
-
aria-orientation={axis === 'y' ? 'horizontal' : 'vertical'}
|
|
567
|
-
aria-label={label}
|
|
568
|
-
onPointerDown={event => start(event, onDrag)}
|
|
569
|
-
/>
|
|
570
|
-
)
|
|
571
|
-
}
|
|
572
242
|
|
|
573
|
-
/** How narrow either side-by-side column may be dragged. A column pulled to
|
|
574
|
-
* nothing reads as a broken pane, and nothing on screen offers to pull it
|
|
575
|
-
* back out. */
|
|
576
|
-
const SPLIT_MIN = 0.15
|
|
577
|
-
const SPLIT_MAX = 0.85
|
|
578
243
|
|
|
579
244
|
/** Commit change sets kept in the browser before the least recently used is dropped. */
|
|
580
245
|
const COMMIT_CACHE_CAPACITY = 24
|
|
@@ -629,10 +294,7 @@ function defaultBase(branches: readonly string[], head: string): string {
|
|
|
629
294
|
return branches.find(branch => branch !== head) ?? ''
|
|
630
295
|
}
|
|
631
296
|
|
|
632
|
-
|
|
633
|
-
added: css.stAdded, untracked: css.stUntracked, modified: css.stModified,
|
|
634
|
-
renamed: css.stRenamed, deleted: css.stDeleted,
|
|
635
|
-
}
|
|
297
|
+
|
|
636
298
|
|
|
637
299
|
export function GitWorkbenchPanel({ sessionId, useSessions, t, fetchStats, fetchFileDiff, fetchFileSides, writeChecked, fetchBlame, fetchFileImage, fetchWorktreeStatus, fetchSessionBinding, fetchCommitStats, fetchCommits, fetchAuthors, fetchRepoTree, fetchCompare, fetchStyle, saveStyle, fetchSync, runGitOp, fetchDiscardPlan }: Props) {
|
|
638
300
|
const worktreePath = useSessions((state: { byId?: Record<string, { cwd?: string } | undefined> }) =>
|
|
@@ -1600,73 +1262,11 @@ export function GitWorkbenchPanel({ sessionId, useSessions, t, fetchStats, fetch
|
|
|
1600
1262
|
|
|
1601
1263
|
/* ---------- header environment card ---------- */
|
|
1602
1264
|
|
|
1603
|
-
/**
|
|
1604
|
-
* The drawer's window controls, as glyphs.
|
|
1605
|
-
*
|
|
1606
|
-
* Four words in four identical pills read as a paragraph, not as controls — and
|
|
1607
|
-
* three of these four are the actions every window on the machine already spells
|
|
1608
|
-
* with a picture. Bootstrap Icons (MIT), one 16 viewBox, one fill, so the row
|
|
1609
|
-
* reads as a set rather than four drawings that happen to sit together.
|
|
1610
|
-
*/
|
|
1611
|
-
const CHROME_GLYPH = {
|
|
1612
|
-
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',
|
|
1613
|
-
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',
|
|
1614
|
-
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',
|
|
1615
|
-
// Single arrow, deliberately: the sync bar's Fetch is the two-arrow circle,
|
|
1616
|
-
// and at 14px the only thing telling them apart is the arrow count.
|
|
1617
|
-
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',
|
|
1618
|
-
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',
|
|
1619
|
-
} as const
|
|
1620
1265
|
|
|
1621
|
-
function ChromeGlyph({ of }: { of: keyof typeof CHROME_GLYPH }): ReactNode {
|
|
1622
|
-
return (
|
|
1623
|
-
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
|
|
1624
|
-
<path d={CHROME_GLYPH[of]} />
|
|
1625
|
-
</svg>
|
|
1626
|
-
)
|
|
1627
|
-
}
|
|
1628
1266
|
|
|
1629
|
-
/**
|
|
1630
|
-
* Change-to-change navigation, as two chevrons.
|
|
1631
|
-
*
|
|
1632
|
-
* Bootstrap Icons again, at the same 16 viewBox — a pair of arrows is what
|
|
1633
|
-
* every editor spells this with, and the words would be longer than the
|
|
1634
|
-
* controls beside them.
|
|
1635
|
-
*/
|
|
1636
|
-
const NAV_GLYPH = {
|
|
1637
|
-
prev: 'M7.646 4.646a.5.5 0 0 1 .708 0l6 6a.5.5 0 0 1-.708.708L8 5.707l-5.646 5.647a.5.5 0 0 1-.708-.708l6-6z',
|
|
1638
|
-
next: 'M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z',
|
|
1639
|
-
} as const
|
|
1640
1267
|
|
|
1641
|
-
function NavGlyph({ of }: { of: keyof typeof NAV_GLYPH }): ReactNode {
|
|
1642
|
-
return (
|
|
1643
|
-
<svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
|
|
1644
|
-
<path d={NAV_GLYPH[of]} />
|
|
1645
|
-
</svg>
|
|
1646
|
-
)
|
|
1647
|
-
}
|
|
1648
1268
|
|
|
1649
|
-
|
|
1650
|
-
* Tree glyph: a root with two working copies hanging off it.
|
|
1651
|
-
*
|
|
1652
|
-
* This was git's fork glyph — the three-dot branch symbol — which named the
|
|
1653
|
-
* wrong thing. A worktree is not a branch; the picker beside it is already full
|
|
1654
|
-
* of branch names, and the two ideas need to stay tellable apart at 12px. A
|
|
1655
|
-
* hierarchy reads as "one repository, several directories", which is what a
|
|
1656
|
-
* worktree list is.
|
|
1657
|
-
*/
|
|
1658
|
-
function WorktreeGlyph(): ReactNode {
|
|
1659
|
-
return (
|
|
1660
|
-
<svg className={css.cardGlyph} width="12" height="12" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
|
|
1661
|
-
{/* Trunk down from the root, and the two limbs it puts out. */}
|
|
1662
|
-
<path d="M2.25 2.75h1.5v10.5h-1.5zM3 6.75h7.25v1.5H3zM3 11.75h7.25v1.5H3z" />
|
|
1663
|
-
{/* The root, then the worktrees. */}
|
|
1664
|
-
<circle cx="3" cy="2.75" r="1.75" />
|
|
1665
|
-
<circle cx="12" cy="7.5" r="1.75" />
|
|
1666
|
-
<circle cx="12" cy="12.5" r="1.75" />
|
|
1667
|
-
</svg>
|
|
1668
|
-
)
|
|
1669
|
-
}
|
|
1269
|
+
|
|
1670
1270
|
|
|
1671
1271
|
interface EnvCardProps { stats: WorkbenchStats; t: Translate; wtName: string | null; title: string; onClick: () => void }
|
|
1672
1272
|
|
|
@@ -2514,3512 +2114,23 @@ function Drawer({ stats, shown, tab, onSwitchTab, commits, commitHash, onSelectC
|
|
|
2514
2114
|
)
|
|
2515
2115
|
}
|
|
2516
2116
|
|
|
2517
|
-
/**
|
|
2518
|
-
* The one dialog in this drawer, because this is the one act it cannot undo.
|
|
2519
|
-
*
|
|
2520
|
-
* It never asks a generic "are you sure": the caller hands it a body that names
|
|
2521
|
-
* the file and states which consequence is about to happen — the whole-file
|
|
2522
|
-
* roll-back's wording derived from the host's own reading of that file, the
|
|
2523
|
-
* block roll-back's from the pane's rows. Cancel holds the initial focus and
|
|
2524
|
-
* Escape closes, because the default answer to an irreversible question is no.
|
|
2525
|
-
*
|
|
2526
|
-
* There is deliberately no "don't ask again". This is the only path in the
|
|
2527
|
-
* drawer with nothing behind it, and a checkbox whose whole function is to
|
|
2528
|
-
* switch off the last guard is a feature that eventually gets clicked.
|
|
2529
|
-
*/
|
|
2530
|
-
function DiscardConfirm({ t, body, onCancel, onConfirm }: {
|
|
2531
|
-
t: Translate
|
|
2532
|
-
body: string
|
|
2533
|
-
onCancel: () => void
|
|
2534
|
-
onConfirm: () => void
|
|
2535
|
-
}): ReactNode {
|
|
2536
|
-
const cancelRef = useRef<HTMLButtonElement>(null)
|
|
2537
|
-
useEffect(() => { cancelRef.current?.focus() }, [])
|
|
2538
|
-
useEffect(() => {
|
|
2539
|
-
// Capture phase: while this question is open, Escape belongs to it alone
|
|
2540
|
-
// — consumed here, before it can reach the page's other Escape handlers
|
|
2541
|
-
// (an open picker's dismiss, the commit box's undo).
|
|
2542
|
-
const onKey = (event: KeyboardEvent): void => {
|
|
2543
|
-
if (event.key !== 'Escape') return
|
|
2544
|
-
event.stopPropagation()
|
|
2545
|
-
onCancel()
|
|
2546
|
-
}
|
|
2547
|
-
window.addEventListener('keydown', onKey, true)
|
|
2548
|
-
return () => { window.removeEventListener('keydown', onKey, true) }
|
|
2549
|
-
}, [onCancel])
|
|
2550
|
-
|
|
2551
|
-
return (
|
|
2552
|
-
<div className={css.confirmScrim} onClick={onCancel}>
|
|
2553
|
-
<div
|
|
2554
|
-
className={css.confirmBox}
|
|
2555
|
-
role="alertdialog"
|
|
2556
|
-
aria-modal="true"
|
|
2557
|
-
aria-label={t('discardTitle')}
|
|
2558
|
-
onClick={event => event.stopPropagation()}
|
|
2559
|
-
>
|
|
2560
|
-
<div className={css.confirmTitle}>{t('discardTitle')}</div>
|
|
2561
|
-
<div className={css.confirmBody}>{body}</div>
|
|
2562
|
-
<div className={css.confirmActions}>
|
|
2563
|
-
<button ref={cancelRef} type="button" className={css.btn} onClick={onCancel}>{t('discardCancel')}</button>
|
|
2564
|
-
<button type="button" className={`${css.btn} ${css.btnDanger}`} onClick={onConfirm}>{t('discardConfirm')}</button>
|
|
2565
|
-
</div>
|
|
2566
|
-
</div>
|
|
2567
|
-
</div>
|
|
2568
|
-
)
|
|
2569
|
-
}
|
|
2570
|
-
|
|
2571
|
-
/**
|
|
2572
|
-
* The whole-file roll-back's consequence, in the host's own fresh reading of
|
|
2573
|
-
* the file — the difference between "goes back to its committed content" and
|
|
2574
|
-
* "leaves the disk and cannot come back" is the entire question the dialog
|
|
2575
|
-
* asks, and it is exactly what a stale row gets wrong.
|
|
2576
|
-
*/
|
|
2577
|
-
function discardBodyText(t: Translate, file: GitFile, plan: DiscardPreview): string {
|
|
2578
|
-
if (plan.effect === 'delete') return t('discardBodyDelete', { path: file.path })
|
|
2579
|
-
if (plan.effect === 'unrename') return t('discardBodyUnrename', { path: file.path, previousPath: plan.previousPath ?? '' })
|
|
2580
|
-
return t('discardBodyRestore', { path: file.path, added: file.addedLines, deleted: file.deletedLines })
|
|
2581
|
-
}
|
|
2582
|
-
|
|
2583
|
-
/**
|
|
2584
|
-
* The BLOCK roll-back's consequence, in the pane's own rows.
|
|
2585
|
-
*
|
|
2586
|
-
* One case outranks the tally wording: an untracked file's whole content is
|
|
2587
|
-
* the one block, and rolling THAT block back reverse-applies the new-file
|
|
2588
|
-
* patch — which deletes the file from the working tree, not rewrites it. The
|
|
2589
|
-
* file row's status is the gate (a tracked file whose every line changed has
|
|
2590
|
-
* the same block shape and only rewrites), which is why the ask's shape alone
|
|
2591
|
-
* is not enough.
|
|
2592
|
-
*/
|
|
2593
|
-
function blockDiscardBodyText(t: Translate, ask: BlockAsk, file: GitFile | undefined): string {
|
|
2594
|
-
if (file !== undefined && file.status === 'untracked' && ask.wholeFile) {
|
|
2595
|
-
return t('blockDiscardBodyDelete', { path: ask.path })
|
|
2596
|
-
}
|
|
2597
|
-
return t('blockDiscardBody', { path: ask.path, added: ask.added, deleted: ask.deleted })
|
|
2598
|
-
}
|
|
2599
|
-
|
|
2600
|
-
/**
|
|
2601
|
-
* A slash-separated name that gives up its HEAD, never its tail.
|
|
2602
|
-
*
|
|
2603
|
-
* Paths and branches have the same shape and the same problem: the leaf is what
|
|
2604
|
-
* distinguishes siblings, and ordinary `text-overflow: ellipsis` eats exactly
|
|
2605
|
-
* that. `…/worktrees/fixture-07` and `…/worktrees/fixture-14` become the same
|
|
2606
|
-
* string; so do `feature/nested/deep/parser` and `feature/nested/deep/lexer`.
|
|
2607
|
-
* Splitting in two and letting only the head shrink keeps the half that
|
|
2608
|
-
* answers "which one".
|
|
2609
|
-
*
|
|
2610
|
-
* Truncating from the other end with `direction: rtl` was the one-line version
|
|
2611
|
-
* and the wrong one: it reorders the backslashes in a Windows path.
|
|
2612
|
-
*
|
|
2613
|
-
* When the name has no head to give — a bare `some-very-long-branch-name` — the
|
|
2614
|
-
* tail ellipsises after all rather than overflowing its row; the stylesheet
|
|
2615
|
-
* weights the shrink so that only happens once the head is gone.
|
|
2616
|
-
*/
|
|
2617
|
-
function Elided({ text, className, title }: {
|
|
2618
|
-
text: string
|
|
2619
|
-
className: string
|
|
2620
|
-
/** Set only where the row does not already carry the full text itself. */
|
|
2621
|
-
title?: string
|
|
2622
|
-
}): ReactNode {
|
|
2623
|
-
if (text.length === 0) return null
|
|
2624
|
-
const { head, tail } = splitPath(text)
|
|
2625
|
-
return (
|
|
2626
|
-
<span className={`${css.elide} ${className}`} {...title === undefined ? {} : { title }}>
|
|
2627
|
-
{head.length > 0 ? <span className={css.elideHead}>{head}</span> : null}
|
|
2628
|
-
<span className={css.elideTail}>{tail}</span>
|
|
2629
|
-
</span>
|
|
2630
|
-
)
|
|
2631
|
-
}
|
|
2632
|
-
|
|
2633
|
-
/**
|
|
2634
|
-
* Which worktree the drawer is reading — the header's first control.
|
|
2635
|
-
*
|
|
2636
|
-
* Git allows at most one worktree per branch, so the repository's worktree list
|
|
2637
|
-
* IS the branch list and one control covers both. This used to be a row of its
|
|
2638
|
-
* own under the tabs, restating the branch the header had already named one
|
|
2639
|
-
* line above; now it IS that name, and clicking it changes the view.
|
|
2640
|
-
*
|
|
2641
|
-
* A repository with a single worktree has nothing to choose, so it renders as
|
|
2642
|
-
* the same chip without the menu — the identity still has to be stated, and a
|
|
2643
|
-
* control that opens an empty list is worse than none.
|
|
2644
|
-
*/
|
|
2645
|
-
function SourceChip({ t, worktrees, boundPath, sessionPath, statsPath, fallbackBranch, onSwitch }: {
|
|
2646
|
-
t: Translate
|
|
2647
|
-
worktrees: readonly WorktreeEntry[]
|
|
2648
|
-
boundPath: string | null
|
|
2649
|
-
sessionPath: string | undefined
|
|
2650
|
-
statsPath: string | undefined
|
|
2651
|
-
/** What to name when the worktree list does not cover the active path — the
|
|
2652
|
-
* branch the stats themselves report. */
|
|
2653
|
-
fallbackBranch: string
|
|
2654
|
-
onSwitch: (next: string) => void
|
|
2655
|
-
}): ReactNode {
|
|
2656
|
-
if (worktrees.length < 2) {
|
|
2657
|
-
return (
|
|
2658
|
-
<span className={css.headerBranch}>
|
|
2659
|
-
<WorktreeGlyph />
|
|
2660
|
-
<Elided text={branchLabel(fallbackBranch, t('noBranch'))} className={css.refValue} />
|
|
2661
|
-
</span>
|
|
2662
|
-
)
|
|
2663
|
-
}
|
|
2664
|
-
return (
|
|
2665
|
-
<WorktreePicker
|
|
2666
|
-
t={t} worktrees={worktrees} boundPath={boundPath}
|
|
2667
|
-
sessionPath={sessionPath} statsPath={statsPath}
|
|
2668
|
-
fallbackBranch={fallbackBranch} onSwitch={onSwitch}
|
|
2669
|
-
/>
|
|
2670
|
-
)
|
|
2671
|
-
}
|
|
2672
|
-
|
|
2673
|
-
/**
|
|
2674
|
-
* The worktree menu: the ref picker's scaffold — button, filter box, scrolling
|
|
2675
|
-
* list — over worktree rows, wearing the header chip's accent so it reads as
|
|
2676
|
-
* the subject of the drawer rather than one more grey button.
|
|
2677
|
-
*
|
|
2678
|
-
* Rows carry the tree glyph when the session is bound there and a dot when it
|
|
2679
|
-
* is the session's own; Enter takes the first match.
|
|
2680
|
-
*
|
|
2681
|
-
* The row gives its whole width to the branch. A dim path used to ride on the
|
|
2682
|
-
* right to tell same-named branches in different repositories apart, but it
|
|
2683
|
-
* cost half the row to earn that, and the half it took was the half that
|
|
2684
|
-
* mattered: `wt/fixture-03` truncated to `wt/fixtur…` beside a path whose tail
|
|
2685
|
-
* was repeating the name anyway. The full path stays one hover away on `title`,
|
|
2686
|
-
* and the header spells it out the moment a row is picked.
|
|
2687
|
-
*/
|
|
2688
|
-
function WorktreePicker({ t, worktrees, boundPath, sessionPath, statsPath, fallbackBranch, onSwitch }: {
|
|
2689
|
-
t: Translate
|
|
2690
|
-
worktrees: readonly WorktreeEntry[]
|
|
2691
|
-
boundPath: string | null
|
|
2692
|
-
sessionPath: string | undefined
|
|
2693
|
-
statsPath: string | undefined
|
|
2694
|
-
fallbackBranch: string
|
|
2695
|
-
onSwitch: (next: string) => void
|
|
2696
|
-
}): ReactNode {
|
|
2697
|
-
const [open, setOpen] = useState(false)
|
|
2698
|
-
const [query, setQuery] = useState('')
|
|
2699
|
-
const rootRef = useDismissable(open, setOpen)
|
|
2700
|
-
|
|
2701
|
-
const needle = query.trim().toLowerCase()
|
|
2702
|
-
const matched = needle.length === 0 ? worktrees : worktrees.filter(entry =>
|
|
2703
|
-
entry.branch.toLowerCase().includes(needle) || entry.path.toLowerCase().includes(needle))
|
|
2704
|
-
const first = matched[0]
|
|
2705
|
-
const current = worktrees.find(entry => samePath(entry.path, statsPath))
|
|
2706
|
-
|
|
2707
|
-
const choose = (entry: WorktreeEntry): void => {
|
|
2708
|
-
onSwitch(entry.path)
|
|
2709
|
-
setOpen(false)
|
|
2710
|
-
setQuery('')
|
|
2711
|
-
}
|
|
2712
|
-
|
|
2713
|
-
return (
|
|
2714
|
-
<div className={css.refPicker} ref={rootRef}>
|
|
2715
|
-
<button
|
|
2716
|
-
type="button"
|
|
2717
|
-
className={`${css.refButton} ${css.headerPicker}`}
|
|
2718
|
-
aria-expanded={open}
|
|
2719
|
-
aria-label={t('sourceLabel')}
|
|
2720
|
-
title={current?.path ?? statsPath}
|
|
2721
|
-
onClick={() => setOpen(isOpen => !isOpen)}
|
|
2722
|
-
>
|
|
2723
|
-
<WorktreeGlyph />
|
|
2724
|
-
<Elided text={branchLabel(current?.branch ?? fallbackBranch, t('noBranch'))} className={css.refValue} />
|
|
2725
|
-
<span className={css.refCaret}>▾</span>
|
|
2726
|
-
</button>
|
|
2727
|
-
{open ? (
|
|
2728
|
-
<div className={css.refPop}>
|
|
2729
|
-
<input
|
|
2730
|
-
className={css.refSearch}
|
|
2731
|
-
autoFocus
|
|
2732
|
-
value={query}
|
|
2733
|
-
placeholder={t('refSearch')}
|
|
2734
|
-
onChange={event => setQuery(event.target.value)}
|
|
2735
|
-
onKeyDown={event => { if (event.key === 'Enter' && first !== undefined) choose(first) }}
|
|
2736
|
-
/>
|
|
2737
|
-
<div className={css.refList} role="listbox" aria-label={t('sourceLabel')}>
|
|
2738
|
-
{matched.map(entry => {
|
|
2739
|
-
const active = samePath(entry.path, statsPath)
|
|
2740
|
-
return (
|
|
2741
|
-
<button
|
|
2742
|
-
key={entry.path}
|
|
2743
|
-
type="button"
|
|
2744
|
-
role="option"
|
|
2745
|
-
aria-selected={active}
|
|
2746
|
-
className={active ? `${css.refRow} ${css.refRowActive}` : css.refRow}
|
|
2747
|
-
title={entry.path}
|
|
2748
|
-
onClick={() => choose(entry)}
|
|
2749
|
-
>
|
|
2750
|
-
{samePath(boundPath, entry.path) ? <WorktreeGlyph /> : <span className={css.refRowSpacer} />}
|
|
2751
|
-
<Elided text={branchLabel(entry.branch, t('noBranch'))} className={css.refRowName} />
|
|
2752
|
-
{samePath(sessionPath, entry.path) ? <span className={css.wtCurrent}>●</span> : null}
|
|
2753
|
-
</button>
|
|
2754
|
-
)
|
|
2755
|
-
})}
|
|
2756
|
-
{matched.length === 0 ? <div className={css.refEmpty}>{t('refNone')}</div> : null}
|
|
2757
|
-
</div>
|
|
2758
|
-
<div className={css.refFoot}>{t('refCount', { shown: matched.length, total: worktrees.length })}</div>
|
|
2759
|
-
</div>
|
|
2760
|
-
) : null}
|
|
2761
|
-
</div>
|
|
2762
|
-
)
|
|
2763
|
-
}
|
|
2764
2117
|
|
|
2765
|
-
/**
|
|
2766
|
-
* Settings: colour mode, palette, background image and custom CSS.
|
|
2767
|
-
*
|
|
2768
|
-
* Mode defaults to `system`, which follows dsh (`body[data-ds-dark-theme]`);
|
|
2769
|
-
* light and dark pin the drawer even when the host is the other scheme. The
|
|
2770
|
-
* palette is a pure token swap — every drawer colour resolves through the same
|
|
2771
|
-
* names, so nothing but the values differ between families.
|
|
2772
|
-
*
|
|
2773
|
-
* The background and the stylesheet are per-scope, and the scope switch is the
|
|
2774
|
-
* only control in here that changes what an edit WRITES rather than what it
|
|
2775
|
-
* looks like, so it sits at the top of that section rather than beside a field.
|
|
2776
|
-
*
|
|
2777
|
-
* This was a companion card portalled into the overlay to the LEFT of the
|
|
2778
|
-
* drawer, so a palette could be previewed against the diff without covering it.
|
|
2779
|
-
* It is a popover now, hung under its own gear: a card floating out in the page
|
|
2780
|
-
* beside the drawer read as a second window rather than as this drawer's
|
|
2781
|
-
* settings, and it was the one menu here that did not behave like the rest.
|
|
2782
|
-
* The preview still works — the popover covers the top of the diff, not all of
|
|
2783
|
-
* it, and the drawer repaints live underneath.
|
|
2784
|
-
*/
|
|
2785
|
-
function SettingsMenu({ t, mode, family, onMode, onFamily, settings, onStyle }: {
|
|
2786
|
-
t: Translate
|
|
2787
|
-
mode: ColorMode
|
|
2788
|
-
family: ThemeFamily
|
|
2789
|
-
onMode: (next: ColorMode) => void
|
|
2790
|
-
onFamily: (next: ThemeFamily) => void
|
|
2791
|
-
settings: StyleSettings
|
|
2792
|
-
onStyle: (scope: StyleScope, entry: StyleEntry, persist: boolean) => Promise<{ ok: boolean; error?: string }>
|
|
2793
|
-
}): ReactNode {
|
|
2794
|
-
const [open, setOpen] = useState(false)
|
|
2795
|
-
const [scope, setScope] = useState<StyleScope>('project')
|
|
2796
|
-
/** Editor buffer for the stylesheet, so typing does not restyle on every key. */
|
|
2797
|
-
const [draft, setDraft] = useState<string | null>(null)
|
|
2798
|
-
const [note, setNote] = useState('')
|
|
2799
|
-
const rootRef = useDismissable(open, setOpen)
|
|
2800
|
-
const imageFileRef = useRef<HTMLInputElement>(null)
|
|
2801
|
-
const cssFileRef = useRef<HTMLInputElement>(null)
|
|
2802
2118
|
|
|
2803
|
-
// The buffer belongs to one scope; switching scope must show that scope's
|
|
2804
|
-
// stylesheet rather than carry the other one's text across.
|
|
2805
|
-
useEffect(() => { setDraft(null); setNote('') }, [scope])
|
|
2806
2119
|
|
|
2807
|
-
// The default scope is `project`, chosen before the host has said whether
|
|
2808
|
-
// there IS one. Outside a repository it has nothing to key by, so the menu
|
|
2809
|
-
// falls back rather than pointing every control at a scope that refuses
|
|
2810
|
-
// every write.
|
|
2811
|
-
useEffect(() => {
|
|
2812
|
-
if (settings.repoRoot === null) setScope('global')
|
|
2813
|
-
}, [settings.repoRoot])
|
|
2814
2120
|
|
|
2815
|
-
const entry = entryFor(settings, scope)
|
|
2816
|
-
const cssText = draft ?? entry.css
|
|
2817
2121
|
|
|
2818
|
-
/**
|
|
2819
|
-
* Apply a change to the scope being edited.
|
|
2820
|
-
* @param patch - the fields that changed.
|
|
2821
|
-
* @param persist - whether to store it; false previews without a file write.
|
|
2822
|
-
*/
|
|
2823
|
-
const write = (patch: Partial<StyleEntry>, persist = true): void => {
|
|
2824
|
-
setNote('')
|
|
2825
|
-
void onStyle(scope, { ...entry, ...patch }, persist).then(result => {
|
|
2826
|
-
if (!result.ok) setNote(result.error ?? t('styleFailed'))
|
|
2827
|
-
})
|
|
2828
|
-
}
|
|
2829
2122
|
|
|
2830
|
-
/**
|
|
2831
|
-
* Resample a chosen image and store it.
|
|
2832
|
-
*
|
|
2833
|
-
* Downscaling in the browser is what keeps this practical: a phone photograph
|
|
2834
|
-
* is 4-6MB, far past what is worth carrying on every drawer open, and none of
|
|
2835
|
-
* that detail survives a blur anyway.
|
|
2836
|
-
* @param file - the picked file.
|
|
2837
|
-
*/
|
|
2838
|
-
const takeImage = async (file: File): Promise<void> => {
|
|
2839
|
-
setNote(t('bgWorking'))
|
|
2840
|
-
try {
|
|
2841
|
-
const bitmap = await createImageBitmap(file)
|
|
2842
|
-
const scale = Math.min(1, IMAGE_MAX_EDGE / Math.max(bitmap.width, bitmap.height))
|
|
2843
|
-
const canvas = document.createElement('canvas')
|
|
2844
|
-
canvas.width = Math.round(bitmap.width * scale)
|
|
2845
|
-
canvas.height = Math.round(bitmap.height * scale)
|
|
2846
|
-
const context = canvas.getContext('2d')
|
|
2847
|
-
if (context === null) { setNote(t('bgFailed')); return }
|
|
2848
|
-
context.drawImage(bitmap, 0, 0, canvas.width, canvas.height)
|
|
2849
|
-
bitmap.close()
|
|
2850
|
-
const url = canvas.toDataURL('image/jpeg', IMAGE_QUALITY)
|
|
2851
|
-
if (url.length > IMAGE_MAX_BYTES) { setNote(t('bgTooBig')); return }
|
|
2852
|
-
setNote('')
|
|
2853
|
-
write({ image: url })
|
|
2854
|
-
} catch {
|
|
2855
|
-
// A file the decoder refuses (corrupt, or an image codec this browser
|
|
2856
|
-
// lacks) is a user mistake, not a fault worth propagating.
|
|
2857
|
-
setNote(t('bgFailed'))
|
|
2858
|
-
}
|
|
2859
|
-
}
|
|
2860
2123
|
|
|
2861
|
-
|
|
2862
|
-
|
|
2863
|
-
|
|
2864
|
-
|
|
2865
|
-
|
|
2866
|
-
|
|
2867
|
-
|
|
2868
|
-
|
|
2124
|
+
/** Split a combined `git diff` into path -> its segment text. */
|
|
2125
|
+
function splitDiff(diff: string): Map<string, string> {
|
|
2126
|
+
const out = new Map<string, string>()
|
|
2127
|
+
if (diff.length === 0) return out
|
|
2128
|
+
for (const part of diff.split(/(?=^diff --git )/m)) {
|
|
2129
|
+
if (part.length === 0) continue
|
|
2130
|
+
const path = extractDiffPath(part)
|
|
2131
|
+
if (path.length > 0) out.set(path, part)
|
|
2869
2132
|
}
|
|
2870
|
-
|
|
2871
|
-
|
|
2872
|
-
return (
|
|
2873
|
-
<div className={css.theme} ref={rootRef}>
|
|
2874
|
-
<button
|
|
2875
|
-
type="button"
|
|
2876
|
-
className={`${css.btn} ${css.btnIcon}`}
|
|
2877
|
-
aria-expanded={open}
|
|
2878
|
-
aria-label={t('settings')} title={t('settings')}
|
|
2879
|
-
onClick={() => setOpen(value => !value)}
|
|
2880
|
-
><ChromeGlyph of="settings" /></button>
|
|
2881
|
-
{open ? (
|
|
2882
|
-
<div className={`${css.refPop} ${css.settingsPop}`} data-gs-part="settings">
|
|
2883
|
-
{/* The popover positions and clips; this is the padded body that
|
|
2884
|
-
scrolls inside it. Collapsing the two put every section flush
|
|
2885
|
-
against the card's edge. */}
|
|
2886
|
-
<div className={css.themeRail} data-gs-part="theme-rail">
|
|
2887
|
-
<div className={css.themeGroup}>
|
|
2888
|
-
<span className={css.themeLabel}>{t('themeMode')}</span>
|
|
2889
|
-
<div className={css.segmented} role="group" aria-label={t('themeMode')}>
|
|
2890
|
-
{COLOR_MODES.map(option => (
|
|
2891
|
-
<button
|
|
2892
|
-
key={option}
|
|
2893
|
-
type="button"
|
|
2894
|
-
aria-pressed={mode === option}
|
|
2895
|
-
className={mode === option ? `${css.segment} ${css.segmentActive}` : css.segment}
|
|
2896
|
-
onClick={() => onMode(option)}
|
|
2897
|
-
>
|
|
2898
|
-
<span className={`${css.segmentChip} ${modeChip[option]}`} aria-hidden="true" />
|
|
2899
|
-
{modeLabel[option]}
|
|
2900
|
-
</button>
|
|
2901
|
-
))}
|
|
2902
|
-
</div>
|
|
2903
|
-
</div>
|
|
2904
|
-
|
|
2905
|
-
<div className={css.themeGroup}>
|
|
2906
|
-
<span className={css.themeLabel}>{t('themePalette')}</span>
|
|
2907
|
-
{THEME_FAMILIES.map(option => (
|
|
2908
|
-
<button
|
|
2909
|
-
key={option.id}
|
|
2910
|
-
type="button"
|
|
2911
|
-
aria-pressed={family === option.id}
|
|
2912
|
-
className={family === option.id ? `${css.paletteRow} ${css.paletteRowActive}` : css.paletteRow}
|
|
2913
|
-
onClick={() => onFamily(option.id)}
|
|
2914
|
-
>
|
|
2915
|
-
<span className={css.swatch} aria-hidden="true">
|
|
2916
|
-
{option.swatch.map(color => <span key={color} style={{ background: color }} />)}
|
|
2917
|
-
</span>
|
|
2918
|
-
{option.label}
|
|
2919
|
-
</button>
|
|
2920
|
-
))}
|
|
2921
|
-
</div>
|
|
2922
|
-
|
|
2923
|
-
<div className={css.themeGroup}>
|
|
2924
|
-
<span className={css.themeLabel}>{t('themeScope')}</span>
|
|
2925
|
-
<div className={css.scopeRow} role="group" aria-label={t('themeScope')}>
|
|
2926
|
-
{STYLE_SCOPES.map(option => (
|
|
2927
|
-
<button
|
|
2928
|
-
key={option}
|
|
2929
|
-
type="button"
|
|
2930
|
-
aria-pressed={scope === option}
|
|
2931
|
-
disabled={option === 'project' && !projectAvailable}
|
|
2932
|
-
className={scope === option ? `${css.scopeBtn} ${css.scopeBtnActive}` : css.scopeBtn}
|
|
2933
|
-
onClick={() => setScope(option)}
|
|
2934
|
-
>{scopeLabel[option]}</button>
|
|
2935
|
-
))}
|
|
2936
|
-
</div>
|
|
2937
|
-
<span className={css.scopeHint}>
|
|
2938
|
-
{scope === 'global' ? t('scopeGlobalHint')
|
|
2939
|
-
: projectAvailable ? settings.repoRoot
|
|
2940
|
-
: t('scopeNoRepo')}
|
|
2941
|
-
</span>
|
|
2942
|
-
</div>
|
|
2943
|
-
|
|
2944
|
-
<div className={css.themeGroup}>
|
|
2945
|
-
<span className={css.themeLabel}>{t('themeBackground')}</span>
|
|
2946
|
-
<div
|
|
2947
|
-
className={entry.image.length > 0 ? css.bgPreview : `${css.bgPreview} ${css.bgEmpty}`}
|
|
2948
|
-
style={entry.image.length > 0 ? { backgroundImage: `url("${entry.image}")` } : undefined}
|
|
2949
|
-
>{entry.image.length > 0 ? null : t('bgNone')}</div>
|
|
2950
|
-
<div className={css.themeRowSplit}>
|
|
2951
|
-
<button type="button" className={css.miniBtn} onClick={() => imageFileRef.current?.click()}>{t('bgChoose')}</button>
|
|
2952
|
-
{entry.image.length > 0
|
|
2953
|
-
? <button type="button" className={css.miniBtn} onClick={() => write({ image: '' })}>{t('bgClear')}</button>
|
|
2954
|
-
: null}
|
|
2955
|
-
</div>
|
|
2956
|
-
<input
|
|
2957
|
-
ref={imageFileRef}
|
|
2958
|
-
type="file"
|
|
2959
|
-
accept="image/*"
|
|
2960
|
-
hidden
|
|
2961
|
-
onChange={event => {
|
|
2962
|
-
const file = event.target.files?.[0]
|
|
2963
|
-
// Clearing the input is what lets the same file be picked twice
|
|
2964
|
-
// after a failure; a change event fires only on a NEW value.
|
|
2965
|
-
event.target.value = ''
|
|
2966
|
-
if (file !== undefined) void takeImage(file)
|
|
2967
|
-
}}
|
|
2968
|
-
/>
|
|
2969
|
-
{entry.image.length > 0 ? (
|
|
2970
|
-
<>
|
|
2971
|
-
<label className={css.sliderRow}>
|
|
2972
|
-
{t('bgBlur')}
|
|
2973
|
-
<input
|
|
2974
|
-
type="range" min={0} max={STYLE_BLUR_MAX} step={1} value={entry.blur}
|
|
2975
|
-
onChange={event => write({ blur: Number(event.target.value) }, false)}
|
|
2976
|
-
onPointerUp={() => write({})}
|
|
2977
|
-
onKeyUp={() => write({})}
|
|
2978
|
-
/>
|
|
2979
|
-
<span className={css.sliderValue}>{entry.blur}px</span>
|
|
2980
|
-
</label>
|
|
2981
|
-
<label className={css.sliderRow}>
|
|
2982
|
-
{t('bgVeil')}
|
|
2983
|
-
<input
|
|
2984
|
-
type="range" min={0} max={100} step={1} value={entry.veil}
|
|
2985
|
-
onChange={event => write({ veil: Number(event.target.value) }, false)}
|
|
2986
|
-
onPointerUp={() => write({})}
|
|
2987
|
-
onKeyUp={() => write({})}
|
|
2988
|
-
/>
|
|
2989
|
-
<span className={css.sliderValue}>{entry.veil}%</span>
|
|
2990
|
-
</label>
|
|
2991
|
-
</>
|
|
2992
|
-
) : null}
|
|
2993
|
-
</div>
|
|
2994
|
-
|
|
2995
|
-
<div className={css.themeGroup}>
|
|
2996
|
-
<span className={css.themeLabel}>{t('themeCss')}</span>
|
|
2997
|
-
<textarea
|
|
2998
|
-
className={css.cssArea}
|
|
2999
|
-
spellCheck={false}
|
|
3000
|
-
placeholder={t('cssPlaceholder')}
|
|
3001
|
-
value={cssText}
|
|
3002
|
-
onChange={event => setDraft(event.target.value)}
|
|
3003
|
-
/>
|
|
3004
|
-
<div className={css.themeRowSplit}>
|
|
3005
|
-
<button type="button" className={css.miniBtn} onClick={() => cssFileRef.current?.click()}>{t('cssImport')}</button>
|
|
3006
|
-
<button
|
|
3007
|
-
type="button"
|
|
3008
|
-
className={`${css.miniBtn} ${css.miniBtnPrimary}`}
|
|
3009
|
-
disabled={draft === null}
|
|
3010
|
-
onClick={() => { write({ css: cssText }); setDraft(null) }}
|
|
3011
|
-
>{t('cssApply')}</button>
|
|
3012
|
-
</div>
|
|
3013
|
-
<input
|
|
3014
|
-
ref={cssFileRef}
|
|
3015
|
-
type="file"
|
|
3016
|
-
accept=".css,text/css"
|
|
3017
|
-
hidden
|
|
3018
|
-
onChange={event => {
|
|
3019
|
-
const file = event.target.files?.[0]
|
|
3020
|
-
event.target.value = ''
|
|
3021
|
-
if (file !== undefined) void file.text().then(text => setDraft(text))
|
|
3022
|
-
}}
|
|
3023
|
-
/>
|
|
3024
|
-
{draft === null ? null : <span className={css.themeDirty}>{t('cssUnapplied')}</span>}
|
|
3025
|
-
</div>
|
|
3026
|
-
|
|
3027
|
-
{note.length > 0 ? <span className={css.themeNote}>{note}</span> : null}
|
|
3028
|
-
</div>
|
|
3029
|
-
</div>
|
|
3030
|
-
) : null}
|
|
3031
|
-
</div>
|
|
3032
|
-
)
|
|
3033
|
-
}
|
|
3034
|
-
|
|
3035
|
-
/**
|
|
3036
|
-
* Close a popover on the two gestures every user already expects: a click
|
|
3037
|
-
* outside it, and Escape. Both arrive on `document` rather than on the
|
|
3038
|
-
* popover's own subtree, so neither can be a handler on the element.
|
|
3039
|
-
* @param open - whether the popover is showing; nothing is bound while closed.
|
|
3040
|
-
* @param setOpen - the state setter, stable, so the effect binds once per open.
|
|
3041
|
-
* @returns the ref to put on the element that counts as "inside".
|
|
3042
|
-
*/
|
|
3043
|
-
function useDismissable(open: boolean, setOpen: Dispatch<SetStateAction<boolean>>): Ref<HTMLDivElement> {
|
|
3044
|
-
const rootRef = useRef<HTMLDivElement>(null)
|
|
3045
|
-
useEffect(() => {
|
|
3046
|
-
if (!open) return
|
|
3047
|
-
const onDown = (event: MouseEvent): void => {
|
|
3048
|
-
if (rootRef.current !== null && !rootRef.current.contains(event.target as Node)) setOpen(false)
|
|
3049
|
-
}
|
|
3050
|
-
const onKey = (event: KeyboardEvent): void => { if (event.key === 'Escape') setOpen(false) }
|
|
3051
|
-
// Bound on the next tick: the click that opened the popover is still
|
|
3052
|
-
// travelling, and would otherwise close it again immediately.
|
|
3053
|
-
const id = window.setTimeout(() => document.addEventListener('mousedown', onDown), 0)
|
|
3054
|
-
document.addEventListener('keydown', onKey)
|
|
3055
|
-
return () => {
|
|
3056
|
-
window.clearTimeout(id)
|
|
3057
|
-
document.removeEventListener('mousedown', onDown)
|
|
3058
|
-
document.removeEventListener('keydown', onKey)
|
|
3059
|
-
}
|
|
3060
|
-
}, [open, setOpen])
|
|
3061
|
-
return rootRef
|
|
3062
|
-
}
|
|
3063
|
-
|
|
3064
|
-
/**
|
|
3065
|
-
* Ref picker built for a repository with hundreds of branches.
|
|
3066
|
-
*
|
|
3067
|
-
* A chip row cannot do this job — it grows without bound and gives every branch
|
|
3068
|
-
* the same weight — and a native select is no better once the list is long
|
|
3069
|
-
* enough to scroll past what anyone will read. This is the control git tooling
|
|
3070
|
-
* converges on instead: one button showing the current ref, opening a filter box
|
|
3071
|
-
* over a scrolling list.
|
|
3072
|
-
*
|
|
3073
|
-
* Two things make it useful before a character is typed. Branches arrive
|
|
3074
|
-
* most-recently-committed first, so the handful actually being worked on are at
|
|
3075
|
-
* the top; and those that have a worktree are grouped above the rest, because a
|
|
3076
|
-
* checked-out branch is the likeliest thing to want. Enter takes the first
|
|
3077
|
-
* match, so a distinctive substring plus Enter reaches any branch in the list.
|
|
3078
|
-
*/
|
|
3079
|
-
/** Sentinel ref meaning "walk every ref" — same string the host special-cases
|
|
3080
|
-
* into `--all`. A real ref cannot begin with a dash, so it collides with
|
|
3081
|
-
* nothing; defined separately on both halves (client bundles import no host
|
|
3082
|
-
* values), tied by this comment and the probe. */
|
|
3083
|
-
const ALL_REFS = '--all'
|
|
3084
|
-
|
|
3085
|
-
function RefPicker({ t, label, value, branches, worktreeBranches, truncated, onPick, allLabel }: {
|
|
3086
|
-
t: Translate
|
|
3087
|
-
label: string
|
|
3088
|
-
value: string
|
|
3089
|
-
branches: readonly string[]
|
|
3090
|
-
/** Branches that have a worktree — grouped first and marked. */
|
|
3091
|
-
worktreeBranches: readonly string[]
|
|
3092
|
-
/** Whether the host cut the branch list short. */
|
|
3093
|
-
truncated: boolean
|
|
3094
|
-
onPick: (ref: string) => void
|
|
3095
|
-
/** When set, an "all branches" entry is offered above the list and shown for
|
|
3096
|
-
* the {@link ALL_REFS} sentinel — the history picker's answer to "search
|
|
3097
|
-
* must not require knowing which branch holds the commit". */
|
|
3098
|
-
allLabel?: string
|
|
3099
|
-
}): ReactNode {
|
|
3100
|
-
const [open, setOpen] = useState(false)
|
|
3101
|
-
const [query, setQuery] = useState('')
|
|
3102
|
-
const rootRef = useDismissable(open, setOpen)
|
|
3103
|
-
|
|
3104
|
-
const needle = query.trim().toLowerCase()
|
|
3105
|
-
const matched = needle.length === 0 ? branches : branches.filter(ref => ref.toLowerCase().includes(needle))
|
|
3106
|
-
const checkedOut = matched.filter(ref => worktreeBranches.includes(ref))
|
|
3107
|
-
const rest = matched.filter(ref => !worktreeBranches.includes(ref))
|
|
3108
|
-
const first = checkedOut[0] ?? rest[0]
|
|
3109
|
-
|
|
3110
|
-
const choose = (ref: string): void => {
|
|
3111
|
-
onPick(ref)
|
|
3112
|
-
setOpen(false)
|
|
3113
|
-
setQuery('')
|
|
3114
|
-
}
|
|
3115
|
-
|
|
3116
|
-
const row = (ref: string, inWorktree: boolean): ReactNode => (
|
|
3117
|
-
<button
|
|
3118
|
-
key={ref}
|
|
3119
|
-
type="button"
|
|
3120
|
-
role="option"
|
|
3121
|
-
aria-selected={ref === value}
|
|
3122
|
-
className={ref === value ? `${css.refRow} ${css.refRowActive}` : css.refRow}
|
|
3123
|
-
title={ref}
|
|
3124
|
-
onClick={() => choose(ref)}
|
|
3125
|
-
>
|
|
3126
|
-
{inWorktree ? <WorktreeGlyph /> : <span className={css.refRowSpacer} />}
|
|
3127
|
-
<Elided text={ref} className={css.refRowName} />
|
|
3128
|
-
</button>
|
|
3129
|
-
)
|
|
3130
|
-
|
|
3131
|
-
return (
|
|
3132
|
-
<div className={css.refPicker} ref={rootRef}>
|
|
3133
|
-
<span className={css.refLabel}>{label}</span>
|
|
3134
|
-
<button
|
|
3135
|
-
type="button"
|
|
3136
|
-
className={css.refButton}
|
|
3137
|
-
aria-expanded={open}
|
|
3138
|
-
title={value.length > 0 ? value : undefined}
|
|
3139
|
-
onClick={() => setOpen(isOpen => !isOpen)}
|
|
3140
|
-
>
|
|
3141
|
-
<Elided text={value === ALL_REFS && allLabel !== undefined ? allLabel : (value.length > 0 ? value : '—')} className={css.refValue} />
|
|
3142
|
-
<span className={css.refCaret}>▾</span>
|
|
3143
|
-
</button>
|
|
3144
|
-
{open ? (
|
|
3145
|
-
<div className={css.refPop}>
|
|
3146
|
-
<input
|
|
3147
|
-
className={css.refSearch}
|
|
3148
|
-
autoFocus
|
|
3149
|
-
value={query}
|
|
3150
|
-
placeholder={t('refSearch')}
|
|
3151
|
-
onChange={event => setQuery(event.target.value)}
|
|
3152
|
-
onKeyDown={event => { if (event.key === 'Enter' && first !== undefined) choose(first) }}
|
|
3153
|
-
/>
|
|
3154
|
-
<div className={css.refList} role="listbox" aria-label={label}>
|
|
3155
|
-
{allLabel !== undefined && (needle.length === 0 || allLabel.toLowerCase().includes(needle)) ? (
|
|
3156
|
-
<button
|
|
3157
|
-
type="button"
|
|
3158
|
-
role="option"
|
|
3159
|
-
aria-selected={value === ALL_REFS}
|
|
3160
|
-
className={value === ALL_REFS ? `${css.refRow} ${css.refRowActive}` : css.refRow}
|
|
3161
|
-
title={allLabel}
|
|
3162
|
-
onClick={() => choose(ALL_REFS)}
|
|
3163
|
-
>
|
|
3164
|
-
<span className={css.refRowSpacer} />
|
|
3165
|
-
<Elided text={allLabel} className={css.refRowName} />
|
|
3166
|
-
</button>
|
|
3167
|
-
) : null}
|
|
3168
|
-
{checkedOut.length > 0 && rest.length > 0 ? <div className={css.refGroup}>{t('refWorktrees')}</div> : null}
|
|
3169
|
-
{checkedOut.map(ref => row(ref, true))}
|
|
3170
|
-
{checkedOut.length > 0 && rest.length > 0 ? <div className={css.refGroup}>{t('refBranches')}</div> : null}
|
|
3171
|
-
{rest.map(ref => row(ref, false))}
|
|
3172
|
-
{matched.length === 0 && !(allLabel !== undefined && needle.length > 0 && allLabel.toLowerCase().includes(needle)) ? <div className={css.refEmpty}>{t('refNone')}</div> : null}
|
|
3173
|
-
</div>
|
|
3174
|
-
<div className={css.refFoot}>
|
|
3175
|
-
{t('refCount', { shown: matched.length, total: branches.length })}
|
|
3176
|
-
{truncated ? ` · ${t('refTruncated')}` : ''}
|
|
3177
|
-
</div>
|
|
3178
|
-
</div>
|
|
3179
|
-
) : null}
|
|
3180
|
-
</div>
|
|
3181
|
-
)
|
|
3182
|
-
}
|
|
3183
|
-
|
|
3184
|
-
/**
|
|
3185
|
-
* Compare tab controls: the two refs, in reading order.
|
|
3186
|
-
*
|
|
3187
|
-
* Both sides list the same refs — comparing a branch against itself is possible
|
|
3188
|
-
* to express and simply reports nothing, which is clearer than hiding it.
|
|
3189
|
-
*/
|
|
3190
|
-
function CompareBar({ t, branches, worktreeBranches, truncated, baseRef, headRef, onBaseRef, onHeadRef }: {
|
|
3191
|
-
t: Translate
|
|
3192
|
-
branches: readonly string[]
|
|
3193
|
-
worktreeBranches: readonly string[]
|
|
3194
|
-
truncated: boolean
|
|
3195
|
-
baseRef: string
|
|
3196
|
-
headRef: string
|
|
3197
|
-
onBaseRef: (ref: string) => void
|
|
3198
|
-
onHeadRef: (ref: string) => void
|
|
3199
|
-
}): ReactNode {
|
|
3200
|
-
if (branches.length === 0) return <div className={css.compareBar}>{t('noBranches')}</div>
|
|
3201
|
-
return (
|
|
3202
|
-
<div className={css.compareBar}>
|
|
3203
|
-
<RefPicker
|
|
3204
|
-
t={t} label={t('compareBase')} value={baseRef}
|
|
3205
|
-
branches={branches} worktreeBranches={worktreeBranches} truncated={truncated}
|
|
3206
|
-
onPick={onBaseRef}
|
|
3207
|
-
/>
|
|
3208
|
-
<span className={css.compareArrow}>→</span>
|
|
3209
|
-
<RefPicker
|
|
3210
|
-
t={t} label={t('compareHead')} value={headRef}
|
|
3211
|
-
branches={branches} worktreeBranches={worktreeBranches} truncated={truncated}
|
|
3212
|
-
onPick={onHeadRef}
|
|
3213
|
-
/>
|
|
3214
|
-
</div>
|
|
3215
|
-
)
|
|
3216
|
-
}
|
|
3217
|
-
|
|
3218
|
-
/* ---------- write operations ---------- */
|
|
3219
|
-
|
|
3220
|
-
/**
|
|
3221
|
-
* Failures whose sentence is the whole story. Everything else shows git's own
|
|
3222
|
-
* text underneath, because the classification is a hint about what to do next
|
|
3223
|
-
* and the raw message is the evidence for it — when the hint is `unknown` it is
|
|
3224
|
-
* the only thing left that helps at all.
|
|
3225
|
-
*
|
|
3226
|
-
* These two are excluded because their detail is never informative and is often
|
|
3227
|
-
* actively misleading: git says nothing useful about an empty index, so what
|
|
3228
|
-
* lands in stderr is whatever a hook wrapper happened to print. A user reading
|
|
3229
|
-
* "nothing staged" followed by a lefthook config warning learns only that
|
|
3230
|
-
* something else is broken, which is not true.
|
|
3231
|
-
*/
|
|
3232
|
-
const SELF_EXPLANATORY: ReadonlySet<GitOpFailure> = new Set(['nothing-to-commit', 'no-upstream'])
|
|
3233
|
-
|
|
3234
|
-
/** What to tell the user about a finished operation. */
|
|
3235
|
-
function opMessage(t: Translate, op: GitOpName, result: GitOpResult): string {
|
|
3236
|
-
if (result.ok) return t(`op.ok.${op}`)
|
|
3237
|
-
const failure = result.failure ?? 'unknown'
|
|
3238
|
-
const reason = t(`op.fail.${failure}`)
|
|
3239
|
-
if (SELF_EXPLANATORY.has(failure)) return reason
|
|
3240
|
-
const detail = (result.error ?? '').trim()
|
|
3241
|
-
return detail.length > 0 ? `${reason}\n${detail}` : reason
|
|
3242
|
-
}
|
|
3243
|
-
|
|
3244
|
-
/**
|
|
3245
|
-
* Glyphs for the three network actions, on Primer's 16px grid.
|
|
3246
|
-
*
|
|
3247
|
-
* They sit BESIDE the labels rather than replacing them. The complaint that
|
|
3248
|
-
* started this was that Fetch/Pull/Push do not say what they do — icon-only
|
|
3249
|
-
* would answer it by removing the half that is unambiguous. What an icon adds
|
|
3250
|
-
* is recognition at a glance: down is work arriving, up is work leaving, and
|
|
3251
|
-
* the ring is the one that only reads a remote without changing anything here.
|
|
3252
|
-
*/
|
|
3253
|
-
const SYNC_GLYPH = {
|
|
3254
|
-
// Circular arrows: VS Code's and IDEA's shared sign for "refresh what I know
|
|
3255
|
-
// about the remote". Nothing in the working tree moves.
|
|
3256
|
-
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',
|
|
3257
|
-
// Down into a floor line: commits arriving from the remote onto this branch.
|
|
3258
|
-
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',
|
|
3259
|
-
// Up off a floor line: the same arrow mirrored, because the pair only reads
|
|
3260
|
-
// as a direction if it is the same arrow.
|
|
3261
|
-
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',
|
|
3262
|
-
} as const
|
|
3263
|
-
|
|
3264
|
-
function SyncGlyph({ of }: { of: keyof typeof SYNC_GLYPH }): ReactNode {
|
|
3265
|
-
return (
|
|
3266
|
-
<svg width="13" height="13" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
|
|
3267
|
-
<path d={SYNC_GLYPH[of]} />
|
|
3268
|
-
</svg>
|
|
3269
|
-
)
|
|
3270
|
-
}
|
|
3271
|
-
|
|
3272
|
-
/**
|
|
3273
|
-
* Filter this list: a magnifier, not the funnel above the commit list. The two
|
|
3274
|
-
* are deliberately different glyphs because they do different things — the
|
|
3275
|
-
* funnel asks git for a different set of commits, this only hides rows already
|
|
3276
|
-
* on screen — and the drawer shows both at once.
|
|
3277
|
-
*/
|
|
3278
|
-
function FilterGlyph(): ReactNode {
|
|
3279
|
-
return (
|
|
3280
|
-
<svg
|
|
3281
|
-
width="13" height="13" viewBox="0 0 16 16"
|
|
3282
|
-
fill="none" stroke="currentColor" strokeWidth="1.25"
|
|
3283
|
-
strokeLinecap="round" strokeLinejoin="round"
|
|
3284
|
-
aria-hidden="true"
|
|
3285
|
-
>
|
|
3286
|
-
<circle cx="7" cy="7" r="4" />
|
|
3287
|
-
<path d="M10 10l3.5 3.5" />
|
|
3288
|
-
</svg>
|
|
3289
|
-
)
|
|
3290
|
-
}
|
|
3291
|
-
|
|
3292
|
-
/** Nothing folded. A constant so the filtered tree does not allocate a new Set
|
|
3293
|
-
* on every render and re-run `TreeChildren`'s memo. */
|
|
3294
|
-
const EMPTY_COLLAPSED: ReadonlySet<string> = new Set<string>()
|
|
3295
|
-
|
|
3296
|
-
/**
|
|
3297
|
-
* Roll back: the counter-clockwise arc every editor and VCS uses for undo,
|
|
3298
|
-
* drawn in the same New UI idiom as the node glyphs beside it — 16px grid,
|
|
3299
|
-
* 1px stroke, no fill — so the row does not mix an outlined file icon with a
|
|
3300
|
-
* solid action icon.
|
|
3301
|
-
*/
|
|
3302
|
-
function RollbackGlyph(): ReactNode {
|
|
3303
|
-
return (
|
|
3304
|
-
<svg
|
|
3305
|
-
width="14" height="14" viewBox="0 0 16 16"
|
|
3306
|
-
fill="none" stroke="currentColor" strokeWidth="1.25"
|
|
3307
|
-
strokeLinecap="round" strokeLinejoin="round"
|
|
3308
|
-
aria-hidden="true"
|
|
3309
|
-
>
|
|
3310
|
-
{/* The arc, open at the upper left where the head goes. */}
|
|
3311
|
-
<path d="M3.5 6.5a5 5 0 1 0 1.9-2.2" />
|
|
3312
|
-
{/* The head: a corner, not a triangle — a filled arrowhead this small
|
|
3313
|
-
turns into a dot at 1x. */}
|
|
3314
|
-
<path d="M2.6 3.2v3.4h3.4" />
|
|
3315
|
-
</svg>
|
|
3316
|
-
)
|
|
3317
|
-
}
|
|
3318
|
-
|
|
3319
|
-
const PULL_MODES = ['ff-only', 'rebase', 'merge'] as const
|
|
3320
|
-
type PullMode = typeof PULL_MODES[number]
|
|
3321
|
-
|
|
3322
|
-
/** Each strategy's label key, so the trigger and the menu cannot disagree. */
|
|
3323
|
-
const PULL_MODE_KEY: Record<PullMode, WorkbenchKey> = {
|
|
3324
|
-
'ff-only': 'pullFf',
|
|
3325
|
-
rebase: 'pullRebase',
|
|
3326
|
-
merge: 'pullMerge',
|
|
3327
|
-
}
|
|
3328
|
-
|
|
3329
|
-
/**
|
|
3330
|
-
* Pull strategy, in the drawer's own menu idiom.
|
|
3331
|
-
*
|
|
3332
|
-
* This was a native `<select>`, justified as "a three-way choice used rarely".
|
|
3333
|
-
* The cost was not the frequency: a native popup paints in the OS palette, so
|
|
3334
|
-
* it was the one control in the drawer that ignored `data-gs-theme` — system
|
|
3335
|
-
* blue over Solarized, square corners in a row of pills. Reusing the ref
|
|
3336
|
-
* picker's button and popover makes it the same idiom as the drawer's other
|
|
3337
|
-
* menu rather than a second one.
|
|
3338
|
-
*/
|
|
3339
|
-
function SyncModePicker({ t, value, disabled, quiet, onPick }: {
|
|
3340
|
-
t: Translate
|
|
3341
|
-
value: PullMode
|
|
3342
|
-
disabled: boolean
|
|
3343
|
-
/** Disabled only by an operation too young to report: refuse, but do not dim. */
|
|
3344
|
-
quiet: boolean
|
|
3345
|
-
onPick: (mode: PullMode) => void
|
|
3346
|
-
}): ReactNode {
|
|
3347
|
-
const [open, setOpen] = useState(false)
|
|
3348
|
-
const rootRef = useDismissable(open, setOpen)
|
|
3349
|
-
|
|
3350
|
-
return (
|
|
3351
|
-
<div className={css.refPicker} ref={rootRef}>
|
|
3352
|
-
<button
|
|
3353
|
-
type="button"
|
|
3354
|
-
className={css.refButton}
|
|
3355
|
-
aria-expanded={open}
|
|
3356
|
-
aria-label={t('pullModeLabel')}
|
|
3357
|
-
disabled={disabled}
|
|
3358
|
-
data-quiet={quiet ? '' : undefined}
|
|
3359
|
-
onClick={() => setOpen(isOpen => !isOpen)}
|
|
3360
|
-
>
|
|
3361
|
-
<span className={`${css.elide} ${css.refValue}`}><span className={css.elideTail}>{t(PULL_MODE_KEY[value])}</span></span>
|
|
3362
|
-
<span className={css.refCaret}>▾</span>
|
|
3363
|
-
</button>
|
|
3364
|
-
{open ? (
|
|
3365
|
-
<div className={`${css.refPop} ${css.menuPop}`} role="listbox" aria-label={t('pullModeLabel')}>
|
|
3366
|
-
{PULL_MODES.map(pullMode => (
|
|
3367
|
-
<button
|
|
3368
|
-
key={pullMode}
|
|
3369
|
-
type="button"
|
|
3370
|
-
role="option"
|
|
3371
|
-
aria-selected={pullMode === value}
|
|
3372
|
-
className={pullMode === value ? `${css.refRow} ${css.refRowActive}` : css.refRow}
|
|
3373
|
-
onClick={() => { onPick(pullMode); setOpen(false) }}
|
|
3374
|
-
>{t(PULL_MODE_KEY[pullMode])}</button>
|
|
3375
|
-
))}
|
|
3376
|
-
</div>
|
|
3377
|
-
) : null}
|
|
3378
|
-
</div>
|
|
3379
|
-
)
|
|
3380
|
-
}
|
|
3381
|
-
|
|
3382
|
-
/**
|
|
3383
|
-
* Whether an in-flight operation has run long enough to be worth showing.
|
|
3384
|
-
*
|
|
3385
|
-
* True only after `active` has held for `delay`, and then for at least `hold`
|
|
3386
|
-
* however quickly it ends — so a fast operation never paints, and a slow one
|
|
3387
|
-
* never blinks. See `op-feedback.ts` for why the appearance is paced and the
|
|
3388
|
-
* guard is not.
|
|
3389
|
-
*/
|
|
3390
|
-
function useSustained(active: boolean, delay = BUSY_DELAY_MS, hold = BUSY_HOLD_MS): boolean {
|
|
3391
|
-
const [shown, setShown] = useState(false)
|
|
3392
|
-
const shownAt = useRef(0)
|
|
3393
|
-
useEffect(() => {
|
|
3394
|
-
if (active === shown) return undefined
|
|
3395
|
-
const wait = active ? delay : holdRemaining(shownAt.current, Date.now(), hold)
|
|
3396
|
-
const id = setTimeout(() => {
|
|
3397
|
-
if (active) shownAt.current = Date.now()
|
|
3398
|
-
setShown(active)
|
|
3399
|
-
}, wait)
|
|
3400
|
-
return () => { clearTimeout(id) }
|
|
3401
|
-
}, [active, shown, delay, hold])
|
|
3402
|
-
return shown
|
|
3403
|
-
}
|
|
3404
|
-
|
|
3405
|
-
/**
|
|
3406
|
-
* Fetch / pull / push, with the divergence they act on.
|
|
3407
|
-
*
|
|
3408
|
-
* Hidden entirely when the repository has no remote: three buttons that can only
|
|
3409
|
-
* fail are worse than no buttons. Pull carries its own strategy picker rather
|
|
3410
|
-
* than reading `pull.rebase`, so the button's label is what actually runs.
|
|
3411
|
-
*
|
|
3412
|
-
* The three used to be one grey pill each, distinguished by their word and a
|
|
3413
|
-
* 13px glyph — indistinguishable at a glance because they carried the same
|
|
3414
|
-
* amount of information, which was none. The counts have moved off the
|
|
3415
|
-
* divergence pills and INTO the two buttons that act on them, so the control
|
|
3416
|
-
* that can do something about the drift is also the one that reports it. Fetch
|
|
3417
|
-
* stays quiet in every state: it writes nothing, so it never has news.
|
|
3418
|
-
*/
|
|
3419
|
-
function SyncBar({ t, sync, busy, onOp }: {
|
|
3420
|
-
t: Translate
|
|
3421
|
-
sync: SyncStatus
|
|
3422
|
-
busy: GitOpName | null
|
|
3423
|
-
onOp: (op: GitOpName, payload?: GitOpPayload) => void
|
|
3424
|
-
}): ReactNode {
|
|
3425
|
-
const [mode, setMode] = useState<PullMode>('ff-only')
|
|
3426
|
-
const running = busy !== null
|
|
3427
|
-
const noUpstream = sync.upstream === null
|
|
3428
|
-
// Every tick stages through git, so this bar was fading out and back on each
|
|
3429
|
-
// one. The buttons still refuse the click from the first frame; only saying
|
|
3430
|
-
// so waits until there is something worth saying.
|
|
3431
|
-
const sustained = useSustained(running)
|
|
3432
|
-
const quiet = quietlyDisabled(running, sustained, false)
|
|
3433
|
-
|
|
3434
|
-
/** Push is the branch's first — the one case where it is the whole point of
|
|
3435
|
-
* the bar, so it is the one case that gets the solid fill. */
|
|
3436
|
-
const pushClass = noUpstream ? `${css.btn} ${css.btnPrimary}`
|
|
3437
|
-
: sync.ahead > 0 ? `${css.btn} ${css.btnAhead}`
|
|
3438
|
-
: css.btn
|
|
3439
|
-
|
|
3440
|
-
return (
|
|
3441
|
-
<div className={css.syncBar} role="group" aria-label={t('syncLabel')}>
|
|
3442
|
-
<span className={css.syncUpstream} title={sync.upstream ?? undefined}>
|
|
3443
|
-
{noUpstream ? t('noUpstream') : sync.upstream}
|
|
3444
|
-
</span>
|
|
3445
|
-
{sync.behind === 0 && sync.ahead === 0 && !noUpstream
|
|
3446
|
-
? <span className={css.syncLevel}>{t('upToDate')}</span>
|
|
3447
|
-
: null}
|
|
3448
|
-
|
|
3449
|
-
<span className={css.syncSpacer} />
|
|
3450
|
-
|
|
3451
|
-
<button
|
|
3452
|
-
type="button" className={css.btn} disabled={running} data-quiet={quiet ? '' : undefined}
|
|
3453
|
-
onClick={() => onOp('fetch')}
|
|
3454
|
-
><SyncGlyph of="fetch" />{busy === 'fetch' ? t('opRunning') : t('fetch')}</button>
|
|
3455
|
-
|
|
3456
|
-
{/* The strategy is Pull's own argument, so it is welded to Pull. Loose
|
|
3457
|
-
between Fetch and Pull it read as a third peer action. */}
|
|
3458
|
-
<span className={css.pullGroup}>
|
|
3459
|
-
<SyncModePicker t={t} value={mode} disabled={running} quiet={quiet} onPick={setMode} />
|
|
3460
|
-
<button
|
|
3461
|
-
type="button"
|
|
3462
|
-
className={sync.behind > 0 ? `${css.btn} ${css.btnBehind}` : css.btn}
|
|
3463
|
-
disabled={running || noUpstream}
|
|
3464
|
-
// No upstream is a reason of Pull's own, so that dim stays put.
|
|
3465
|
-
data-quiet={quietlyDisabled(running, sustained, noUpstream) ? '' : undefined}
|
|
3466
|
-
title={noUpstream ? t('noUpstreamHint') : undefined}
|
|
3467
|
-
onClick={() => onOp('pull', { mode })}
|
|
3468
|
-
>
|
|
3469
|
-
<SyncGlyph of="pull" />
|
|
3470
|
-
{busy === 'pull' ? t('opRunning') : t('pull')}
|
|
3471
|
-
{sync.behind > 0 ? <span className={css.btnCount}>{sync.behind}</span> : null}
|
|
3472
|
-
</button>
|
|
3473
|
-
</span>
|
|
3474
|
-
|
|
3475
|
-
<button
|
|
3476
|
-
type="button" className={pushClass} disabled={running} data-quiet={quiet ? '' : undefined}
|
|
3477
|
-
// The first push of a branch has no upstream yet — that is the case
|
|
3478
|
-
// `--set-upstream` exists for, so it must not be disabled here.
|
|
3479
|
-
title={noUpstream ? t('pushSetUpstream') : undefined}
|
|
3480
|
-
onClick={() => onOp('push')}
|
|
3481
|
-
>
|
|
3482
|
-
<SyncGlyph of="push" />
|
|
3483
|
-
{busy === 'push' ? t('opRunning') : noUpstream ? t('publish') : t('push')}
|
|
3484
|
-
{sync.ahead > 0 && !noUpstream ? <span className={css.btnCount}>{sync.ahead}</span> : null}
|
|
3485
|
-
</button>
|
|
3486
|
-
</div>
|
|
3487
|
-
)
|
|
3488
|
-
}
|
|
3489
|
-
|
|
3490
|
-
/**
|
|
3491
|
-
* The commit box: a message, and what it would commit.
|
|
3492
|
-
*
|
|
3493
|
-
* Commit is disabled with nothing staged rather than quietly falling back to
|
|
3494
|
-
* committing the whole worktree. The drawer shows a staging area, and a button
|
|
3495
|
-
* that ignores it would make that display a lie.
|
|
3496
|
-
*/
|
|
3497
|
-
function CommitBox({ t, files, busy, onOp, message, onMessage, amend, onAmend }: {
|
|
3498
|
-
t: Translate
|
|
3499
|
-
files: readonly GitFile[]
|
|
3500
|
-
busy: GitOpName | null
|
|
3501
|
-
onOp: (op: GitOpName, payload?: GitOpPayload) => Promise<GitOpResult>
|
|
3502
|
-
/** Lifted to the panel: this box unmounts on a tab switch, the draft must not. */
|
|
3503
|
-
message: string
|
|
3504
|
-
onMessage: (next: string) => void
|
|
3505
|
-
amend: boolean
|
|
3506
|
-
onAmend: (next: boolean) => void
|
|
3507
|
-
}): ReactNode {
|
|
3508
|
-
const setMessage = onMessage
|
|
3509
|
-
const setAmend = onAmend
|
|
3510
|
-
const stagedCount = files.filter(file => file.staged === true).length
|
|
3511
|
-
const running = busy !== null
|
|
3512
|
-
// Amending re-uses the previous commit, so it is the one case where an empty
|
|
3513
|
-
// index is still a legitimate commit (a message-only reword).
|
|
3514
|
-
const needsStaged = stagedCount === 0 && !amend
|
|
3515
|
-
const needsMessage = message.trim().length === 0
|
|
3516
|
-
const canCommit = !needsMessage && !needsStaged && !running
|
|
3517
|
-
// A disabled button that does not say why reads as broken; the staging half of
|
|
3518
|
-
// that is stated permanently by the lead line above, so only the message case
|
|
3519
|
-
// needs the title.
|
|
3520
|
-
const blocked = needsMessage ? t('commitNeedMessage') : undefined
|
|
3521
|
-
|
|
3522
|
-
const commit = (): void => {
|
|
3523
|
-
if (!canCommit) return
|
|
3524
|
-
void onOp('commit', { message, amend }).then(result => {
|
|
3525
|
-
// Keep the message on failure: it is the user's text, and retyping a
|
|
3526
|
-
// commit message because the index was empty is a bad way to learn that.
|
|
3527
|
-
if (result.ok) { setMessage(''); setAmend(false) }
|
|
3528
|
-
})
|
|
3529
|
-
}
|
|
3530
|
-
|
|
3531
|
-
return (
|
|
3532
|
-
<div className={css.commitBox}>
|
|
3533
|
-
{/* The one instruction the tick model needs, stated once where the action
|
|
3534
|
-
lives. A blocker that appears only when the index is empty reads as an
|
|
3535
|
-
error and arrives after the confusion it explains. */}
|
|
3536
|
-
<p className={css.commitLead} data-gs-part="commit-lead">{t('commitLead')}</p>
|
|
3537
|
-
<textarea
|
|
3538
|
-
className={css.commitMessage}
|
|
3539
|
-
value={message}
|
|
3540
|
-
rows={2}
|
|
3541
|
-
placeholder={t('commitPlaceholder')}
|
|
3542
|
-
aria-label={t('commitPlaceholder')}
|
|
3543
|
-
disabled={running}
|
|
3544
|
-
onChange={event => setMessage(event.target.value)}
|
|
3545
|
-
onKeyDown={event => {
|
|
3546
|
-
// Ctrl/Cmd+Enter commits, the shortcut every git client shares. Plain
|
|
3547
|
-
// Enter stays a newline: a commit body is normal and losing it to a
|
|
3548
|
-
// stray keystroke is not recoverable from the UI.
|
|
3549
|
-
if ((event.ctrlKey || event.metaKey) && event.key === 'Enter') { event.preventDefault(); commit() }
|
|
3550
|
-
}}
|
|
3551
|
-
/>
|
|
3552
|
-
<div className={css.commitRow}>
|
|
3553
|
-
<label className={css.commitAmend}>
|
|
3554
|
-
<input
|
|
3555
|
-
type="checkbox" checked={amend} disabled={running}
|
|
3556
|
-
onChange={event => setAmend(event.target.checked)}
|
|
3557
|
-
/>
|
|
3558
|
-
{t('amend')}
|
|
3559
|
-
</label>
|
|
3560
|
-
<span className={css.commitStaged}>{t('stagedCount', { count: stagedCount })}</span>
|
|
3561
|
-
<button
|
|
3562
|
-
type="button"
|
|
3563
|
-
className={css.commitBtn}
|
|
3564
|
-
disabled={!canCommit}
|
|
3565
|
-
title={running ? undefined : blocked}
|
|
3566
|
-
onClick={commit}
|
|
3567
|
-
>{busy === 'commit' ? t('opRunning') : t('commit')}</button>
|
|
3568
|
-
</div>
|
|
3569
|
-
</div>
|
|
3570
|
-
)
|
|
3571
|
-
}
|
|
3572
|
-
|
|
3573
|
-
/** Subject plus body, the text `git log` would print for `%B` without the trailing newline. */
|
|
3574
|
-
function commitMessageText(commit: GitCommit): string {
|
|
3575
|
-
const body = commit.body ?? ''
|
|
3576
|
-
return body.length > 0 ? `${commit.subject}\n\n${body}` : commit.subject
|
|
3577
|
-
}
|
|
3578
|
-
|
|
3579
|
-
function CopyCommitButton({ t, text }: { t: Translate; text: string }): ReactNode {
|
|
3580
|
-
const [copied, setCopied] = useState(false)
|
|
3581
|
-
useEffect(() => {
|
|
3582
|
-
if (!copied) return
|
|
3583
|
-
const id = window.setTimeout(() => setCopied(false), 1400)
|
|
3584
|
-
return () => { window.clearTimeout(id) }
|
|
3585
|
-
}, [copied])
|
|
3586
|
-
return (
|
|
3587
|
-
<button
|
|
3588
|
-
type="button"
|
|
3589
|
-
className={css.commitCopy}
|
|
3590
|
-
onMouseDown={event => event.preventDefault()}
|
|
3591
|
-
onClick={event => {
|
|
3592
|
-
event.stopPropagation()
|
|
3593
|
-
void navigator.clipboard.writeText(text).then(() => setCopied(true), () => setCopied(false))
|
|
3594
|
-
}}
|
|
3595
|
-
>{copied ? t('copiedCommit') : t('copyCommit')}</button>
|
|
3596
|
-
)
|
|
3597
|
-
}
|
|
3598
|
-
|
|
3599
|
-
/**
|
|
3600
|
-
* One row in the history list. The subject stays one truncated line so the list
|
|
3601
|
-
* stays scannable; hovering opens a card with the full message, including a
|
|
3602
|
-
* multi-line body, which can be copied without selecting the commit.
|
|
3603
|
-
*/
|
|
3604
|
-
/* ---------- commit graph ---------- */
|
|
3605
|
-
|
|
3606
|
-
/** Horizontal distance between lanes. */
|
|
3607
|
-
const GRAPH_LANE_W = 14
|
|
3608
|
-
/** Ref chips shown inline before the subject; the rest collapse into a "+N". */
|
|
3609
|
-
const COMMIT_REF_CHIPS = 2
|
|
3610
|
-
/** Lanes past this are not drawn. A repository can braid arbitrarily wide, and
|
|
3611
|
-
* the diff is worth more than the twelfth simultaneous branch. */
|
|
3612
|
-
const GRAPH_MAX_LANES = 6
|
|
3613
|
-
|
|
3614
|
-
const laneX = (lane: number): number => lane * GRAPH_LANE_W + GRAPH_LANE_W / 2
|
|
3615
|
-
|
|
3616
|
-
/**
|
|
3617
|
-
* One row's slice of the commit graph.
|
|
3618
|
-
*
|
|
3619
|
-
* Drawn as an SVG exactly as tall as the row, so consecutive rows butt together
|
|
3620
|
-
* and a lane reads as one unbroken line down the list. The dot sits at the
|
|
3621
|
-
* vertical centre; edges leave the top edge, the dot, or the bottom edge, and a
|
|
3622
|
-
* cubic with its control points at the quarter heights gives the S-curve every
|
|
3623
|
-
* git client draws for a branch or a merge.
|
|
3624
|
-
*
|
|
3625
|
-
* The height is passed in rather than read from a constant here: the two
|
|
3626
|
-
* History arrangements want differently shaped rows, and the segment and the
|
|
3627
|
-
* row it belongs to must come from the same entry of `COMMIT_ROW_H` or the
|
|
3628
|
-
* lanes stop meeting across the seam between rows.
|
|
3629
|
-
*/
|
|
3630
|
-
function GraphCell({ row, width, active, rowH }: { row: GraphRow; width: number; active: boolean; rowH: number }): ReactNode {
|
|
3631
|
-
const lanes = Math.min(width, GRAPH_MAX_LANES)
|
|
3632
|
-
const w = lanes * GRAPH_LANE_W
|
|
3633
|
-
const mid = rowH / 2
|
|
3634
|
-
const visible = (lane: number): boolean => lane < GRAPH_MAX_LANES
|
|
3635
|
-
const stroke = (lane: number): string => `var(--gs-graph-${lane % 6})`
|
|
3636
|
-
|
|
3637
|
-
const paths: ReactNode[] = []
|
|
3638
|
-
for (const lane of row.through) {
|
|
3639
|
-
if (!visible(lane)) continue
|
|
3640
|
-
paths.push(<path key={`t${lane}`} d={`M ${laneX(lane)} 0 V ${rowH}`} stroke={stroke(lane)} />)
|
|
3641
|
-
}
|
|
3642
|
-
for (const lane of row.into) {
|
|
3643
|
-
if (!visible(lane) || !visible(row.lane)) continue
|
|
3644
|
-
paths.push(lane === row.lane
|
|
3645
|
-
? <path key={`i${lane}`} d={`M ${laneX(lane)} 0 V ${mid}`} stroke={stroke(lane)} />
|
|
3646
|
-
: (
|
|
3647
|
-
<path
|
|
3648
|
-
key={`i${lane}`}
|
|
3649
|
-
d={`M ${laneX(lane)} 0 C ${laneX(lane)} ${mid / 2}, ${laneX(row.lane)} ${mid / 2}, ${laneX(row.lane)} ${mid}`}
|
|
3650
|
-
stroke={stroke(lane)}
|
|
3651
|
-
/>
|
|
3652
|
-
))
|
|
3653
|
-
}
|
|
3654
|
-
for (const lane of row.outOf) {
|
|
3655
|
-
if (!visible(lane) || !visible(row.lane)) continue
|
|
3656
|
-
paths.push(lane === row.lane
|
|
3657
|
-
? <path key={`o${lane}`} d={`M ${laneX(lane)} ${mid} V ${rowH}`} stroke={stroke(lane)} />
|
|
3658
|
-
: (
|
|
3659
|
-
<path
|
|
3660
|
-
key={`o${lane}`}
|
|
3661
|
-
d={`M ${laneX(row.lane)} ${mid} C ${laneX(row.lane)} ${mid + mid / 2}, ${laneX(lane)} ${mid + mid / 2}, ${laneX(lane)} ${rowH}`}
|
|
3662
|
-
stroke={stroke(lane)}
|
|
3663
|
-
/>
|
|
3664
|
-
))
|
|
3665
|
-
}
|
|
3666
|
-
|
|
3667
|
-
return (
|
|
3668
|
-
<svg
|
|
3669
|
-
className={css.graphCell}
|
|
3670
|
-
width={w}
|
|
3671
|
-
height={rowH}
|
|
3672
|
-
viewBox={`0 0 ${w} ${rowH}`}
|
|
3673
|
-
aria-hidden="true"
|
|
3674
|
-
focusable="false"
|
|
3675
|
-
>
|
|
3676
|
-
<g fill="none" strokeWidth="1.6" strokeLinecap="round">{paths}</g>
|
|
3677
|
-
{visible(row.lane) ? (
|
|
3678
|
-
<circle
|
|
3679
|
-
cx={laneX(row.lane)}
|
|
3680
|
-
cy={mid}
|
|
3681
|
-
r={row.isMerge ? 4.5 : 3.5}
|
|
3682
|
-
// A merge is hollow, the way every git client distinguishes it: it is
|
|
3683
|
-
// a joining of lines rather than a change of its own.
|
|
3684
|
-
fill={row.isMerge ? 'var(--gs-panel)' : stroke(row.lane)}
|
|
3685
|
-
stroke={stroke(row.lane)}
|
|
3686
|
-
strokeWidth={row.isMerge ? 2 : active ? 3 : 0}
|
|
3687
|
-
/>
|
|
3688
|
-
) : null}
|
|
3689
|
-
</svg>
|
|
3690
|
-
)
|
|
3691
|
-
}
|
|
3692
|
-
|
|
3693
|
-
function CommitRow({ t, commit, active, onSelect, graphRow, graphWidth, layout }: {
|
|
3694
|
-
t: Translate
|
|
3695
|
-
commit: GitCommit
|
|
3696
|
-
active: boolean
|
|
3697
|
-
onSelect: (hash: string) => void
|
|
3698
|
-
/** This commit's lane geometry; absent while the graph is still empty. */
|
|
3699
|
-
graphRow?: GraphRow
|
|
3700
|
-
graphWidth: number
|
|
3701
|
-
/** Which arrangement the list is in, which decides the row's shape. */
|
|
3702
|
-
layout: HistoryLayout
|
|
3703
|
-
}): ReactNode {
|
|
3704
|
-
const rowRef = useRef<HTMLButtonElement>(null)
|
|
3705
|
-
const [open, setOpen] = useState(false)
|
|
3706
|
-
const [box, setBox] = useState<{ top: number; left: number; maxHeight: number } | null>(null)
|
|
3707
|
-
const enterTimer = useRef(0)
|
|
3708
|
-
const leaveTimer = useRef(0)
|
|
3709
|
-
const body = commit.body ?? ''
|
|
3710
|
-
const authorName = commit.authorName ?? ''
|
|
3711
|
-
const committerName = commit.committerName ?? ''
|
|
3712
|
-
// The viewer's own locale and timezone — that is the whole point of the line.
|
|
3713
|
-
const exactDate = formatCommitDate(commit.dateIso ?? '')
|
|
3714
|
-
|
|
3715
|
-
const cancel = (): void => {
|
|
3716
|
-
window.clearTimeout(enterTimer.current)
|
|
3717
|
-
window.clearTimeout(leaveTimer.current)
|
|
3718
|
-
}
|
|
3719
|
-
const show = (): void => {
|
|
3720
|
-
cancel()
|
|
3721
|
-
enterTimer.current = window.setTimeout(() => setOpen(true), 360)
|
|
3722
|
-
}
|
|
3723
|
-
const hide = (): void => {
|
|
3724
|
-
cancel()
|
|
3725
|
-
leaveTimer.current = window.setTimeout(() => setOpen(false), 160)
|
|
3726
|
-
}
|
|
3727
|
-
|
|
3728
|
-
useEffect(() => () => { cancel() }, [])
|
|
3729
|
-
|
|
3730
|
-
useEffect(() => {
|
|
3731
|
-
if (!open) { setBox(null); return }
|
|
3732
|
-
const row = rowRef.current
|
|
3733
|
-
if (row === null) return
|
|
3734
|
-
const rect = row.getBoundingClientRect()
|
|
3735
|
-
const width = 380
|
|
3736
|
-
const left = Math.min(rect.right + 10, window.innerWidth - width - 12)
|
|
3737
|
-
const top = Math.max(12, Math.min(rect.top, window.innerHeight - 220))
|
|
3738
|
-
setBox({ top, left: Math.max(12, left), maxHeight: window.innerHeight - top - 16 })
|
|
3739
|
-
}, [open])
|
|
3740
|
-
|
|
3741
|
-
const host = rowRef.current?.closest('[data-gs-part="overlay"]') ?? (typeof document === 'undefined' ? null : document.body)
|
|
3742
|
-
|
|
3743
|
-
const refs = commit.refs ?? []
|
|
3744
|
-
|
|
3745
|
-
/* The part both shapes share, and the only part either is really for. */
|
|
3746
|
-
const subjectRow = (
|
|
3747
|
-
<span className={css.commitSubjectRow}>
|
|
3748
|
-
{/* Capped at two. A release commit can carry six refs, and the
|
|
3749
|
-
subject is what the row is actually for — the rest are counted
|
|
3750
|
-
and named in the title rather than crowding it out. */}
|
|
3751
|
-
{refs.slice(0, COMMIT_REF_CHIPS).map(ref => (
|
|
3752
|
-
<span key={ref} className={css.commitRef} title={ref}>{ref}</span>
|
|
3753
|
-
))}
|
|
3754
|
-
{refs.length > COMMIT_REF_CHIPS ? (
|
|
3755
|
-
<span className={css.commitRefMore} title={refs.slice(COMMIT_REF_CHIPS).join('\n')}>
|
|
3756
|
-
+{refs.length - COMMIT_REF_CHIPS}
|
|
3757
|
-
</span>
|
|
3758
|
-
) : null}
|
|
3759
|
-
<span className={css.commitSubject}>{commit.subject}</span>
|
|
3760
|
-
{body.length > 0 ? <span className={css.commitHasBody} aria-hidden="true">···</span> : null}
|
|
3761
|
-
</span>
|
|
3762
|
-
)
|
|
3763
|
-
|
|
3764
|
-
return (
|
|
3765
|
-
<>
|
|
3766
|
-
{/* The graph is a SIBLING of the row button, spanning the line's full
|
|
3767
|
-
height with no margin of its own — that is what lets a lane run
|
|
3768
|
-
unbroken from one row into the next while the button itself keeps its
|
|
3769
|
-
inset and its rounded corners. */}
|
|
3770
|
-
<div className={css.commitLine}>
|
|
3771
|
-
{graphRow !== undefined
|
|
3772
|
-
? <GraphCell row={graphRow} width={graphWidth} active={active} rowH={COMMIT_ROW_H[layout]} />
|
|
3773
|
-
: null}
|
|
3774
|
-
<button
|
|
3775
|
-
ref={rowRef}
|
|
3776
|
-
type="button"
|
|
3777
|
-
role="option"
|
|
3778
|
-
aria-selected={active}
|
|
3779
|
-
className={active ? `${css.commit} ${css.commitActive}` : css.commit}
|
|
3780
|
-
onClick={() => onSelect(commit.hash)}
|
|
3781
|
-
onMouseEnter={show}
|
|
3782
|
-
onMouseLeave={hide}
|
|
3783
|
-
>
|
|
3784
|
-
{layout === 'stacked' ? (
|
|
3785
|
-
<>
|
|
3786
|
-
{/* One line, in git log --oneline's order: a fixed-width hash,
|
|
3787
|
-
then the subject, then who and when pushed to the right. The
|
|
3788
|
-
hash being fixed width is what aligns every subject into a
|
|
3789
|
-
column the eye can run down — leading with the author's name
|
|
3790
|
-
instead would start each subject at a different place. */}
|
|
3791
|
-
<code className={css.commitHash}>{commit.hash}</code>
|
|
3792
|
-
{subjectRow}
|
|
3793
|
-
<span className={css.commitMeta}>
|
|
3794
|
-
{authorName.length > 0 ? <span className={css.commitAuthor}>{authorName}</span> : null}
|
|
3795
|
-
<span className={css.commitWhen}>{commit.when}</span>
|
|
3796
|
-
</span>
|
|
3797
|
-
</>
|
|
3798
|
-
) : (
|
|
3799
|
-
<>
|
|
3800
|
-
{/* Two lines, because a pane beside the diff has no width to
|
|
3801
|
-
spare: everything but the subject goes above it, and the
|
|
3802
|
-
subject then gets the column to itself. */}
|
|
3803
|
-
<span className={css.commitTop}>
|
|
3804
|
-
<code className={css.commitHash}>{commit.hash}</code>
|
|
3805
|
-
{authorName.length > 0 ? <span className={css.commitAuthor}>{authorName}</span> : null}
|
|
3806
|
-
<span className={css.commitWhen}>{commit.when}</span>
|
|
3807
|
-
</span>
|
|
3808
|
-
{subjectRow}
|
|
3809
|
-
</>
|
|
3810
|
-
)}
|
|
3811
|
-
</button>
|
|
3812
|
-
</div>
|
|
3813
|
-
{open && box !== null && host !== null ? createPortal(
|
|
3814
|
-
<div
|
|
3815
|
-
className={css.commitPop}
|
|
3816
|
-
style={{ top: box.top, left: box.left, maxHeight: box.maxHeight }}
|
|
3817
|
-
onMouseEnter={() => { cancel(); setOpen(true) }}
|
|
3818
|
-
onMouseLeave={hide}
|
|
3819
|
-
onClick={event => event.stopPropagation()}
|
|
3820
|
-
>
|
|
3821
|
-
<div className={css.commitPopTop}>
|
|
3822
|
-
<code className={css.commitHash}>{commit.hash}</code>
|
|
3823
|
-
<span className={css.commitWhen}>{commit.when}</span>
|
|
3824
|
-
<CopyCommitButton t={t} text={commitMessageText(commit)} />
|
|
3825
|
-
</div>
|
|
3826
|
-
{/* Who and exactly when. The row summarizes ("3 weeks ago"); the
|
|
3827
|
-
hover card is where the precise question gets a precise answer —
|
|
3828
|
-
full date in the VIEWER's timezone, author, and the committer
|
|
3829
|
-
whenever git recorded someone other than the author. */}
|
|
3830
|
-
{authorName.length > 0 || committerName.length > 0 || exactDate.length > 0 ? (
|
|
3831
|
-
<div className={css.commitPopMeta}>
|
|
3832
|
-
{authorName.length > 0 ? <span>{t('commitAuthor')}: {authorName}</span> : null}
|
|
3833
|
-
{committerName.length > 0 && committerName !== authorName ? (
|
|
3834
|
-
<span>{t('commitCommitter')}: {committerName}</span>
|
|
3835
|
-
) : null}
|
|
3836
|
-
{exactDate.length > 0 ? <span>{t('commitDate')}: {exactDate}</span> : null}
|
|
3837
|
-
</div>
|
|
3838
|
-
) : null}
|
|
3839
|
-
<div className={css.commitPopSubject}>{commit.subject}</div>
|
|
3840
|
-
{body.length > 0 ? <pre className={css.commitPopBody}>{body}</pre> : null}
|
|
3841
|
-
</div>,
|
|
3842
|
-
host,
|
|
3843
|
-
) : null}
|
|
3844
|
-
</>
|
|
3845
|
-
)
|
|
3846
|
-
}
|
|
3847
|
-
|
|
3848
|
-
/** The filter's own calendar — a hand-rolled 6×7 Monday-first grid (pure
|
|
3849
|
-
* arithmetic in `calendar.ts`), because the native date input renders as the
|
|
3850
|
-
* platform's bare widget and the bundle's purity gate forbids pulling in a
|
|
3851
|
-
* library. Picking a day hands `yyyy-mm-dd` to the bound the segmented
|
|
3852
|
-
* control armed; the host expands it to the whole day. */
|
|
3853
|
-
function FilterCalendar({ year, month, after, before, locale, onPick, onShift }: {
|
|
3854
|
-
year: number
|
|
3855
|
-
month: number
|
|
3856
|
-
/** Current bounds, to mark the picked days (approxidate text never matches
|
|
3857
|
-
* an iso, so a preset like "1 week ago" simply marks nothing). */
|
|
3858
|
-
after: string
|
|
3859
|
-
before: string
|
|
3860
|
-
/** BCP-47 tag from the drawer's own dictionary (`filterLocale`), NOT the
|
|
3861
|
-
* browser's — those disagree the moment the UI language is not the OS one,
|
|
3862
|
-
* and the grid printed its month in the other language. */
|
|
3863
|
-
locale: string
|
|
3864
|
-
onPick: (iso: string) => void
|
|
3865
|
-
onShift: (deltaMonths: number) => void
|
|
3866
|
-
}): ReactNode {
|
|
3867
|
-
const grid = monthGrid(year, month, localTodayIso())
|
|
3868
|
-
const title = new Intl.DateTimeFormat(locale, { year: 'numeric', month: 'long' }).format(new Date(year, month, 1))
|
|
3869
|
-
return (
|
|
3870
|
-
<div className={css.cal}>
|
|
3871
|
-
<div className={css.calHead}>
|
|
3872
|
-
<button type="button" className={css.calNav} aria-label="‹" onClick={() => onShift(-1)}>‹</button>
|
|
3873
|
-
<span className={css.calTitle}>{title}</span>
|
|
3874
|
-
<button type="button" className={css.calNav} aria-label="›" onClick={() => onShift(1)}>›</button>
|
|
3875
|
-
</div>
|
|
3876
|
-
<div className={css.calWeek}>
|
|
3877
|
-
{weekdayLabels(locale).map((label, index) => <span key={index}>{label}</span>)}
|
|
3878
|
-
</div>
|
|
3879
|
-
<div className={css.calGrid}>
|
|
3880
|
-
{grid.flat().map(cell => cell === null ? null : (
|
|
3881
|
-
<button
|
|
3882
|
-
key={cell.iso}
|
|
3883
|
-
type="button"
|
|
3884
|
-
aria-label={cell.iso}
|
|
3885
|
-
title={cell.iso}
|
|
3886
|
-
className={[
|
|
3887
|
-
cell.inMonth ? '' : css.calOut,
|
|
3888
|
-
cell.isToday ? css.calToday : '',
|
|
3889
|
-
// Between the bounds, not one of them: the two endpoints alone
|
|
3890
|
-
// never showed which days the filter actually admits. Both
|
|
3891
|
-
// bounds are iso here or the comparison is simply false, which
|
|
3892
|
-
// is what an approxidate preset should render as.
|
|
3893
|
-
inCalRange(cell.iso, after, before) ? css.calIn : '',
|
|
3894
|
-
cell.iso === after || cell.iso === before ? css.calMark : '',
|
|
3895
|
-
].filter(cls => cls.length > 0).join(' ')}
|
|
3896
|
-
onClick={() => onPick(cell.iso)}
|
|
3897
|
-
><span>{cell.day}</span></button>
|
|
3898
|
-
))}
|
|
3899
|
-
</div>
|
|
3900
|
-
</div>
|
|
3901
|
-
)
|
|
3902
|
-
}
|
|
3903
|
-
|
|
3904
|
-
/** Files shown per expanded directory. The search box is the way to a file in
|
|
3905
|
-
* a crowded directory; the tree shows enough to browse without flooding the
|
|
3906
|
-
* list, and says so when it cut the tail. */
|
|
3907
|
-
const PATH_FILES_SHOWN = 100
|
|
3908
|
-
|
|
3909
|
-
/** Horizontal step per nesting level in the path picker. The whole indent now
|
|
3910
|
-
* comes from this one number: `.pathChildren` used to add a margin and a rail
|
|
3911
|
-
* of its own on top of it, so every level cost 29px and a 320px popover ran
|
|
3912
|
-
* out of width three directories deep. */
|
|
3913
|
-
const PATH_INDENT = 14
|
|
3914
|
-
|
|
3915
|
-
/** One level of the path picker's directory tree — directories (chevron,
|
|
3916
|
-
* subtree count) then their files (doc glyph, leaf rows). Collapsed subtrees
|
|
3917
|
-
* are not in the DOM at all, so a monorepo costs only what the reader has
|
|
3918
|
-
* opened. */
|
|
3919
|
-
/** A checkbox that also carries the tree's third state — `indeterminate` is a
|
|
3920
|
-
* DOM property, not an attribute, so it is set through the ref. */
|
|
3921
|
-
function TriStateCheckbox({ state, onChange, ariaLabel }: {
|
|
3922
|
-
state: 'on' | 'off' | 'partial'
|
|
3923
|
-
onChange: () => void
|
|
3924
|
-
ariaLabel: string
|
|
3925
|
-
}): ReactNode {
|
|
3926
|
-
return (
|
|
3927
|
-
<input
|
|
3928
|
-
type="checkbox"
|
|
3929
|
-
aria-label={ariaLabel}
|
|
3930
|
-
checked={state === 'on'}
|
|
3931
|
-
ref={el => { if (el !== null) el.indeterminate = state === 'partial' }}
|
|
3932
|
-
onChange={onChange}
|
|
3933
|
-
/>
|
|
3934
|
-
)
|
|
3935
|
-
}
|
|
3936
|
-
|
|
3937
|
-
function PathTreeRows({ dirs, depth, expanded, stateOf, onToggleOpen, onTogglePath }: {
|
|
3938
|
-
dirs: readonly DirEntry[]
|
|
3939
|
-
depth: number
|
|
3940
|
-
expanded: readonly string[]
|
|
3941
|
-
/** Derived on/partial/off for any row path — the single source of truth. */
|
|
3942
|
-
stateOf: (path: string) => 'on' | 'off' | 'partial'
|
|
3943
|
-
onToggleOpen: (path: string) => void
|
|
3944
|
-
onTogglePath: (path: string) => void
|
|
3945
|
-
}): ReactNode {
|
|
3946
|
-
return (
|
|
3947
|
-
<>
|
|
3948
|
-
{dirs.map(dir => {
|
|
3949
|
-
const open = expanded.includes(dir.path)
|
|
3950
|
-
const expandable = dir.children.length > 0 || dir.files.length > 0
|
|
3951
|
-
const shown = dir.files.slice(0, PATH_FILES_SHOWN)
|
|
3952
|
-
return (
|
|
3953
|
-
<div key={dir.path} className={css.pathNode}>
|
|
3954
|
-
<div className={css.funnelRow} style={{ paddingLeft: depth * PATH_INDENT + 4 }}>
|
|
3955
|
-
<button
|
|
3956
|
-
type="button"
|
|
3957
|
-
className={css.funnelChevron}
|
|
3958
|
-
disabled={!expandable}
|
|
3959
|
-
aria-expanded={open}
|
|
3960
|
-
onClick={() => onToggleOpen(dir.path)}
|
|
3961
|
-
>{expandable ? (open ? '▾' : '▸') : ''}</button>
|
|
3962
|
-
<TriStateCheckbox state={stateOf(dir.path)} ariaLabel={dir.path} onChange={() => onTogglePath(dir.path)} />
|
|
3963
|
-
<PathDirGlyph />
|
|
3964
|
-
<span className={css.funnelName} title={dir.path}>{dir.name}</span>
|
|
3965
|
-
<span className={css.funnelCount}>{dir.fileCount}</span>
|
|
3966
|
-
</div>
|
|
3967
|
-
{open ? (
|
|
3968
|
-
<div className={css.pathChildren}>
|
|
3969
|
-
<PathTreeRows
|
|
3970
|
-
dirs={dir.children}
|
|
3971
|
-
depth={depth + 1}
|
|
3972
|
-
expanded={expanded}
|
|
3973
|
-
stateOf={stateOf}
|
|
3974
|
-
onToggleOpen={onToggleOpen}
|
|
3975
|
-
onTogglePath={onTogglePath}
|
|
3976
|
-
/>
|
|
3977
|
-
{shown.map(file => (
|
|
3978
|
-
<label key={file} className={css.funnelRow} style={{ paddingLeft: (depth + 1) * PATH_INDENT + 4 }}>
|
|
3979
|
-
<span className={css.funnelChevron} aria-hidden="true" />
|
|
3980
|
-
<TriStateCheckbox state={stateOf(`${dir.path}/${file}`)} ariaLabel={`${dir.path}/${file}`} onChange={() => onTogglePath(`${dir.path}/${file}`)} />
|
|
3981
|
-
<PathFileGlyph path={`${dir.path}/${file}`} />
|
|
3982
|
-
<span className={css.funnelName} title={`${dir.path}/${file}`}>{file}</span>
|
|
3983
|
-
</label>
|
|
3984
|
-
))}
|
|
3985
|
-
{dir.files.length > PATH_FILES_SHOWN ? (
|
|
3986
|
-
<div className={css.funnelMore}>+{dir.files.length - PATH_FILES_SHOWN}</div>
|
|
3987
|
-
) : null}
|
|
3988
|
-
</div>
|
|
3989
|
-
) : null}
|
|
3990
|
-
</div>
|
|
3991
|
-
)
|
|
3992
|
-
})}
|
|
3993
|
-
</>
|
|
3994
|
-
)
|
|
3995
|
-
}
|
|
3996
|
-
|
|
3997
|
-
/**
|
|
3998
|
-
* The two arrangements, drawn in the same 16px/1px idiom as the drawer's other
|
|
3999
|
-
* glyphs: a pane and its neighbour, either side by side or one over the other.
|
|
4000
|
-
* The filled half is the list, so the picture says which pane moves.
|
|
4001
|
-
*/
|
|
4002
|
-
function ColumnsGlyph(): ReactNode {
|
|
4003
|
-
return (
|
|
4004
|
-
<svg
|
|
4005
|
-
className={css.layoutGlyph}
|
|
4006
|
-
width="16" height="16" viewBox="0 0 16 16"
|
|
4007
|
-
fill="none" stroke="currentColor" strokeWidth="1"
|
|
4008
|
-
strokeLinejoin="round" aria-hidden="true"
|
|
4009
|
-
>
|
|
4010
|
-
<rect x="1.5" y="2.5" width="13" height="11" rx="1.5" />
|
|
4011
|
-
<rect x="1.5" y="2.5" width="5" height="11" rx="1.5" fill="currentColor" stroke="none" opacity="0.55" />
|
|
4012
|
-
<path d="M6.5 2.5 V13.5" />
|
|
4013
|
-
</svg>
|
|
4014
|
-
)
|
|
4015
|
-
}
|
|
4016
|
-
|
|
4017
|
-
function StackedGlyph(): ReactNode {
|
|
4018
|
-
return (
|
|
4019
|
-
<svg
|
|
4020
|
-
className={css.layoutGlyph}
|
|
4021
|
-
width="16" height="16" viewBox="0 0 16 16"
|
|
4022
|
-
fill="none" stroke="currentColor" strokeWidth="1"
|
|
4023
|
-
strokeLinejoin="round" aria-hidden="true"
|
|
4024
|
-
>
|
|
4025
|
-
<rect x="1.5" y="2.5" width="13" height="11" rx="1.5" />
|
|
4026
|
-
<rect x="1.5" y="2.5" width="13" height="4" rx="1.5" fill="currentColor" stroke="none" opacity="0.55" />
|
|
4027
|
-
<path d="M1.5 6.5 H14.5" />
|
|
4028
|
-
</svg>
|
|
4029
|
-
)
|
|
4030
|
-
}
|
|
4031
|
-
|
|
4032
|
-
/**
|
|
4033
|
-
* One end of the arrangement switch.
|
|
4034
|
-
*
|
|
4035
|
-
* `aria-pressed` rather than a radio group: these are two states of one view
|
|
4036
|
-
* control, not a value being submitted, and a screen reader then reads the
|
|
4037
|
-
* arrangement in force without the group needing a name per option.
|
|
4038
|
-
* @param glyph - the arrangement, drawn.
|
|
4039
|
-
* @param label - accessible name, also the tooltip.
|
|
4040
|
-
* @param on - whether this arrangement is the one in force.
|
|
4041
|
-
* @param onPick - switch to it.
|
|
4042
|
-
*/
|
|
4043
|
-
function LayoutButton({ glyph, label, on, onPick }: {
|
|
4044
|
-
glyph: ReactNode
|
|
4045
|
-
label: string
|
|
4046
|
-
on: boolean
|
|
4047
|
-
onPick: () => void
|
|
4048
|
-
}): ReactNode {
|
|
4049
|
-
return (
|
|
4050
|
-
<button
|
|
4051
|
-
type="button"
|
|
4052
|
-
className={on ? `${css.layoutButton} ${css.layoutButtonOn}` : css.layoutButton}
|
|
4053
|
-
aria-pressed={on}
|
|
4054
|
-
aria-label={label}
|
|
4055
|
-
title={label}
|
|
4056
|
-
onClick={onPick}
|
|
4057
|
-
>{glyph}</button>
|
|
4058
|
-
)
|
|
4059
|
-
}
|
|
4060
|
-
|
|
4061
|
-
/**
|
|
4062
|
-
* The commit log as its own pane, in whichever arrangement the reader picked.
|
|
4063
|
-
*
|
|
4064
|
-
* Beside the file tree it is a peer pane the way GitHub Desktop, the JetBrains
|
|
4065
|
-
* git log and GitKraken all draw it — list and selected commit's files side by
|
|
4066
|
-
* side, each with its own scrollbar. Across the top it is IDEA's git log
|
|
4067
|
-
* instead, which is the arrangement that stops a long subject being cut; see
|
|
4068
|
-
* `history-layout.ts` for what each costs. The switch that picks between them
|
|
4069
|
-
* is in the toolbar row above, not in this pane's head — the head is the first
|
|
4070
|
-
* thing to run out of room when the pane is dragged narrow, which is exactly
|
|
4071
|
-
* when a reader reaches for the switch. Either way there is nothing to
|
|
4072
|
-
* collapse and nothing to discover.
|
|
4073
|
-
*
|
|
4074
|
-
* Pages load by scrolling. A button at the end of a growing list is the worst
|
|
4075
|
-
* of both worlds — it retreats every time it is used, and it asks the reader to
|
|
4076
|
-
* confirm an intention that scrolling toward the end already stated. A sentinel
|
|
4077
|
-
* below the last row requests the next page as it comes into view, which is
|
|
4078
|
-
* what GitHub and GitLens do. The observer is rebuilt whenever the list grows,
|
|
4079
|
-
* so a page too short to fill the pane immediately triggers the next one.
|
|
4080
|
-
*/
|
|
4081
|
-
function CommitList({ paneRef, style, layout, t, loading, commits, active, onSelect, hasMore, loadingMore, onLoadMore, query, onQueryChange, error, statsPath, refName, fetchAuthors, fetchRepoTree }: {
|
|
4082
|
-
/** The pane element, which the divider beside it measures from. Not named
|
|
4083
|
-
* `ref`: React reserves that on a function component, so it would be stripped
|
|
4084
|
-
* from props and never reach this element. */
|
|
4085
|
-
paneRef: Ref<HTMLDivElement>
|
|
4086
|
-
/** Dragged size, when the divider has been used: a width beside the diff, a
|
|
4087
|
-
* height above it. */
|
|
4088
|
-
style: CSSProperties | undefined
|
|
4089
|
-
/** The arrangement in force, which decides the row's shape as well as the
|
|
4090
|
-
* pane's. The control that CHANGES it is not in here — see the toolbar row
|
|
4091
|
-
* above the panes. */
|
|
4092
|
-
layout: HistoryLayout
|
|
4093
|
-
t: Translate
|
|
4094
|
-
/** First page in flight — the pane says "loading", not "no history", which
|
|
4095
|
-
* would be a claim about the repository the data has not made. */
|
|
4096
|
-
loading: boolean
|
|
4097
|
-
commits: readonly GitCommit[]
|
|
4098
|
-
active: string | null
|
|
4099
|
-
onSelect: (hash: string) => void
|
|
4100
|
-
hasMore: boolean
|
|
4101
|
-
loadingMore: boolean
|
|
4102
|
-
onLoadMore: () => void
|
|
4103
|
-
/** The filter box's text. Parsed here for chips; the parent debounces the
|
|
4104
|
-
* same parse into the server-side fetch. */
|
|
4105
|
-
query: string
|
|
4106
|
-
onQueryChange: (query: string) => void
|
|
4107
|
-
/** git's complaint when the log itself failed (bad pattern/date), verbatim. */
|
|
4108
|
-
error: string | null
|
|
4109
|
-
/** Which tree the author roster counts — the drawer's current source. */
|
|
4110
|
-
statsPath: string | undefined
|
|
4111
|
-
/** Which ref the roster and the list both walk — the picker's people are the
|
|
4112
|
-
* list's people, so a tick can never name someone with nothing to show. */
|
|
4113
|
-
refName: string
|
|
4114
|
-
fetchAuthors: (worktreePath: string | undefined, ref: string, signal: AbortSignal) => Promise<{ authors: readonly AuthorEntry[]; truncated: boolean } | null>
|
|
4115
|
-
fetchRepoTree: (worktreePath: string | undefined, signal: AbortSignal) => Promise<{ paths: string[]; truncated: boolean } | null>
|
|
4116
|
-
}): ReactNode {
|
|
4117
|
-
const scrollRef = useRef<HTMLDivElement>(null)
|
|
4118
|
-
const sentinelRef = useRef<HTMLDivElement>(null)
|
|
4119
|
-
// One grammar, one filter: chips are the parsed criteria, and removing one
|
|
4120
|
-
// rewrites the box through that same grammar.
|
|
4121
|
-
const filterModel = useMemo(() => parseLogQuery(query), [query])
|
|
4122
|
-
const chips = chipsFromFilter(filterModel)
|
|
4123
|
-
// What the panel is currently asking git for. Each tab shows its own share
|
|
4124
|
-
// so the two sections nobody is looking at still say they hold something,
|
|
4125
|
-
// and the footer shows the total — the chip row that used to be the only
|
|
4126
|
-
// feedback sits BEHIND the popup, so the ticks looked inert until it closed.
|
|
4127
|
-
// A date bound counts as one criterion each; free text is the box's, not
|
|
4128
|
-
// the popup's, so it stays out of both.
|
|
4129
|
-
const dateCount = (filterModel.after.length > 0 ? 1 : 0) + (filterModel.before.length > 0 ? 1 : 0)
|
|
4130
|
-
const selectedCount = filterModel.users.length + filterModel.paths.length + dateCount
|
|
4131
|
-
|
|
4132
|
-
// ---- funnel popup: user picker + date bounds + path tree --------------
|
|
4133
|
-
const [funnelOpen, setFunnelOpen] = useState(false)
|
|
4134
|
-
const [authors, setAuthors] = useState<{ authors: readonly AuthorEntry[]; truncated: boolean } | null>(null)
|
|
4135
|
-
const [authorsQuery, setAuthorsQuery] = useState('')
|
|
4136
|
-
const [pathTree, setPathTree] = useState<{ dirs: readonly DirEntry[]; paths: readonly string[]; truncated: boolean } | null>(null)
|
|
4137
|
-
const [expandedDirs, setExpandedDirs] = useState<readonly string[]>([])
|
|
4138
|
-
const [pathsQuery, setPathsQuery] = useState('')
|
|
4139
|
-
// The popup shows ONE section at a time (tabs), so a roster of dozens
|
|
4140
|
-
// cannot grow the panel past the paths section — every section is reachable
|
|
4141
|
-
// in one click whatever the others hold.
|
|
4142
|
-
const [funnelSection, setFunnelSection] = useState<'users' | 'date' | 'paths'>('users')
|
|
4143
|
-
// The calendar's displayed month, and which bound a picked day lands in.
|
|
4144
|
-
const [calMonth, setCalMonth] = useState(() => { const now = new Date(); return { year: now.getFullYear(), month: now.getMonth() } })
|
|
4145
|
-
const [calBound, setCalBound] = useState<'after' | 'before'>('after')
|
|
4146
|
-
|
|
4147
|
-
// The panel is PORTALLED to the drawer overlay (position: fixed, clamped to
|
|
4148
|
-
// the viewport — the commits pane can be narrower than the panel, and an
|
|
4149
|
-
// absolute panel anchored at its right edge runs off-screen). Dismissal
|
|
4150
|
-
// therefore checks TWO refs: the anchor button and the panel itself; a
|
|
4151
|
-
// single useDismissable root would see every click inside the portalled
|
|
4152
|
-
// panel as "outside" and close it out from under the click.
|
|
4153
|
-
const funnelAnchorRef = useRef<HTMLDivElement>(null)
|
|
4154
|
-
const funnelPanelRef = useRef<HTMLDivElement>(null)
|
|
4155
|
-
const [funnelBox, setFunnelBox] = useState<{ top: number; left: number; maxHeight: number } | null>(null)
|
|
4156
|
-
|
|
4157
|
-
useEffect(() => {
|
|
4158
|
-
if (!funnelOpen) { setFunnelBox(null); return }
|
|
4159
|
-
const onDown = (event: MouseEvent): void => {
|
|
4160
|
-
const target = event.target as Node
|
|
4161
|
-
if (funnelAnchorRef.current?.contains(target) === true) return
|
|
4162
|
-
if (funnelPanelRef.current?.contains(target) === true) return
|
|
4163
|
-
setFunnelOpen(false)
|
|
4164
|
-
}
|
|
4165
|
-
const onKey = (event: KeyboardEvent): void => { if (event.key === 'Escape') setFunnelOpen(false) }
|
|
4166
|
-
// Bound on the next tick: the opening click is still travelling.
|
|
4167
|
-
const id = window.setTimeout(() => document.addEventListener('mousedown', onDown), 0)
|
|
4168
|
-
document.addEventListener('keydown', onKey)
|
|
4169
|
-
return () => {
|
|
4170
|
-
window.clearTimeout(id)
|
|
4171
|
-
document.removeEventListener('mousedown', onDown)
|
|
4172
|
-
document.removeEventListener('keydown', onKey)
|
|
4173
|
-
}
|
|
4174
|
-
}, [funnelOpen])
|
|
4175
|
-
|
|
4176
|
-
useEffect(() => {
|
|
4177
|
-
if (!funnelOpen) return
|
|
4178
|
-
const rect = funnelAnchorRef.current?.getBoundingClientRect()
|
|
4179
|
-
if (rect === undefined) return
|
|
4180
|
-
const width = 300
|
|
4181
|
-
const left = Math.max(12, Math.min(rect.left + rect.width - width, window.innerWidth - width - 12))
|
|
4182
|
-
const top = rect.bottom + 4
|
|
4183
|
-
setFunnelBox({ top, left, maxHeight: Math.max(160, window.innerHeight - top - 16) })
|
|
4184
|
-
}, [funnelOpen])
|
|
4185
|
-
|
|
4186
|
-
// The roster and the tree are fetched when the funnel OPENS (not when the
|
|
4187
|
-
// pane mounts — most visits never filter) and again when the source or the
|
|
4188
|
-
// ref moves: the roster counts the very history the list walks, so the two
|
|
4189
|
-
// can never disagree about who has commits.
|
|
4190
|
-
useEffect(() => {
|
|
4191
|
-
if (!funnelOpen) return
|
|
4192
|
-
const ctrl = new AbortController()
|
|
4193
|
-
setAuthors(null)
|
|
4194
|
-
setPathTree(null)
|
|
4195
|
-
fetchAuthors(statsPath, refName, ctrl.signal).then(roster => {
|
|
4196
|
-
if (!ctrl.signal.aborted) setAuthors(roster)
|
|
4197
|
-
}).catch(() => {})
|
|
4198
|
-
fetchRepoTree(statsPath, ctrl.signal).then(tree => {
|
|
4199
|
-
if (!ctrl.signal.aborted && tree !== null) {
|
|
4200
|
-
setPathTree({ dirs: buildDirTree(tree.paths), paths: tree.paths, truncated: tree.truncated })
|
|
4201
|
-
}
|
|
4202
|
-
}).catch(() => {})
|
|
4203
|
-
return () => { ctrl.abort() }
|
|
4204
|
-
}, [funnelOpen, statsPath, refName, fetchAuthors, fetchRepoTree])
|
|
4205
|
-
|
|
4206
|
-
// Where the popover mounts: the drawer's overlay layer when there is one,
|
|
4207
|
-
// the body otherwise. Same resolution the commit-row popover does, and the
|
|
4208
|
-
// render below skips the portal when neither exists rather than handing
|
|
4209
|
-
// createPortal a null container.
|
|
4210
|
-
const funnelHost = funnelAnchorRef.current?.closest('[data-gs-part="overlay"]') ?? (typeof document === 'undefined' ? null : document.body)
|
|
4211
|
-
|
|
4212
|
-
/** Every funnel interaction writes the filter through the box's grammar, so
|
|
4213
|
-
* the box, the chips and the fetch can never disagree about the query. */
|
|
4214
|
-
const applyFilter = (next: LogFilter): void => { onQueryChange(serializeLogQuery(next)) }
|
|
4215
|
-
const toggleUser = (name: string): void => {
|
|
4216
|
-
const has = filterModel.users.includes(name)
|
|
4217
|
-
applyFilter({
|
|
4218
|
-
...filterModel,
|
|
4219
|
-
users: has ? filterModel.users.filter(user => user !== name) : [...filterModel.users, name],
|
|
4220
|
-
})
|
|
4221
|
-
}
|
|
4222
|
-
// Checkbox-tree semantics: ticking a folder covers its subtree (and absorbs
|
|
4223
|
-
// the files already ticked inside it); unticking a file under a checked
|
|
4224
|
-
// folder cascades out. Rows DERIVE their state — on/partial/off — from the
|
|
4225
|
-
// set, so a folder tick visibly checks everything under it.
|
|
4226
|
-
const pathIndex = useMemo(
|
|
4227
|
-
() => (pathTree === null ? null : buildIndex(pathTree.paths)),
|
|
4228
|
-
[pathTree],
|
|
4229
|
-
)
|
|
4230
|
-
const pathState = (path: string): 'on' | 'off' | 'partial' =>
|
|
4231
|
-
pathIndex === null ? 'off' : checkedState(filterModel.paths, path, pathIndex)
|
|
4232
|
-
const togglePath = (path: string): void => {
|
|
4233
|
-
if (pathIndex === null) return
|
|
4234
|
-
applyFilter({
|
|
4235
|
-
...filterModel,
|
|
4236
|
-
paths: isCovered(filterModel.paths, path)
|
|
4237
|
-
? removePath(filterModel.paths, path, pathIndex)
|
|
4238
|
-
: addPath(filterModel.paths, path),
|
|
4239
|
-
})
|
|
4240
|
-
}
|
|
4241
|
-
const toggleDirOpen = (path: string): void => {
|
|
4242
|
-
setExpandedDirs(prev => prev.includes(path) ? prev.filter(p => p !== path) : [...prev, path])
|
|
4243
|
-
}
|
|
4244
|
-
const needle = authorsQuery.trim().toLowerCase()
|
|
4245
|
-
const matchedAuthors = authors === null
|
|
4246
|
-
? []
|
|
4247
|
-
: needle.length === 0
|
|
4248
|
-
? authors.authors
|
|
4249
|
-
: authors.authors.filter(entry =>
|
|
4250
|
-
entry.name.toLowerCase().includes(needle) || entry.email.toLowerCase().includes(needle))
|
|
4251
|
-
const DATE_PRESETS: readonly { key: WorkbenchKey; value: string }[] = [
|
|
4252
|
-
{ key: 'filterToday', value: 'midnight' },
|
|
4253
|
-
{ key: 'filterLast7', value: '1 week ago' },
|
|
4254
|
-
{ key: 'filterLast30', value: '30 days ago' },
|
|
4255
|
-
]
|
|
4256
|
-
// Recomputed only when a page lands. The layout is a single pass over the
|
|
4257
|
-
// loaded prefix, and every row's geometry depends on the rows above it, so
|
|
4258
|
-
// there is nothing finer to memoise than the whole list.
|
|
4259
|
-
//
|
|
4260
|
-
// Filtering does not suspend the graph: the server returns one contiguous
|
|
4261
|
-
// walk of the FILTERED log, so lanes stay truthful — unlike a client-side
|
|
4262
|
-
// filter, which would break the very walk it draws from.
|
|
4263
|
-
const graph = useMemo(
|
|
4264
|
-
() => layoutGraph(commits.map(commit => ({ hash: commit.hash, parents: commit.parents ?? [] }))),
|
|
4265
|
-
[commits],
|
|
4266
|
-
)
|
|
4267
|
-
|
|
4268
|
-
useEffect(() => {
|
|
4269
|
-
const root = scrollRef.current
|
|
4270
|
-
const sentinel = sentinelRef.current
|
|
4271
|
-
if (root === null || sentinel === null || !hasMore || loadingMore) return
|
|
4272
|
-
const observer = new IntersectionObserver(
|
|
4273
|
-
entries => { if (entries.some(entry => entry.isIntersecting)) onLoadMore() },
|
|
4274
|
-
// Start the fetch before the sentinel is actually reached, so the next
|
|
4275
|
-
// page is usually there by the time the reader arrives.
|
|
4276
|
-
{ root, rootMargin: '300px' },
|
|
4277
|
-
)
|
|
4278
|
-
observer.observe(sentinel)
|
|
4279
|
-
return () => { observer.disconnect() }
|
|
4280
|
-
}, [hasMore, loadingMore, commits.length, onLoadMore])
|
|
4281
|
-
|
|
4282
|
-
return (
|
|
4283
|
-
<div ref={paneRef} className={css.commitsPane} style={style} data-layout={layout} data-gs-part="commits">
|
|
4284
|
-
{/* No count: the only number available is how many pages have been loaded,
|
|
4285
|
-
which is not how many commits exist. A number that cannot be right is
|
|
4286
|
-
worse than none. */}
|
|
4287
|
-
<div className={css.paneHead}>
|
|
4288
|
-
<span className={css.paneTitle}>{t('historyLabel')}</span>
|
|
4289
|
-
<div className={css.funnel} ref={funnelAnchorRef}>
|
|
4290
|
-
<button
|
|
4291
|
-
type="button"
|
|
4292
|
-
className={funnelOpen || chips.length > 0 ? `${css.funnelButton} ${css.funnelButtonActive}` : css.funnelButton}
|
|
4293
|
-
aria-expanded={funnelOpen}
|
|
4294
|
-
onClick={() => setFunnelOpen(isOpen => !isOpen)}
|
|
4295
|
-
>{t('filterBy')} ▾</button>
|
|
4296
|
-
</div>
|
|
4297
|
-
<input
|
|
4298
|
-
className={css.commitFilter}
|
|
4299
|
-
type="search"
|
|
4300
|
-
value={query}
|
|
4301
|
-
onChange={event => onQueryChange(event.target.value)}
|
|
4302
|
-
placeholder={t('historyFilterPlaceholder')}
|
|
4303
|
-
aria-label={t('historyFilterPlaceholder')}
|
|
4304
|
-
spellCheck={false}
|
|
4305
|
-
/>
|
|
4306
|
-
</div>
|
|
4307
|
-
{funnelOpen && funnelBox !== null && funnelHost !== null ? createPortal(
|
|
4308
|
-
<div
|
|
4309
|
-
ref={funnelPanelRef}
|
|
4310
|
-
className={css.funnelPop}
|
|
4311
|
-
style={funnelBox}
|
|
4312
|
-
role="dialog"
|
|
4313
|
-
aria-label={t('filterBy')}
|
|
4314
|
-
>
|
|
4315
|
-
{/* One section at a time: a roster of dozens cannot grow the panel
|
|
4316
|
-
past the other sections, and each tab carries its own active
|
|
4317
|
-
count so the criteria are visible without visiting the tab. */}
|
|
4318
|
-
<div className={css.funnelTabs} role="tablist">
|
|
4319
|
-
<button
|
|
4320
|
-
type="button" role="tab" aria-selected={funnelSection === 'users'}
|
|
4321
|
-
className={funnelSection === 'users' ? `${css.funnelTab} ${css.funnelTabActive}` : css.funnelTab}
|
|
4322
|
-
onClick={() => setFunnelSection('users')}
|
|
4323
|
-
>
|
|
4324
|
-
{t('filterUsers')}
|
|
4325
|
-
{filterModel.users.length > 0 ? <span className={css.funnelTabCount}>{filterModel.users.length}</span> : null}
|
|
4326
|
-
</button>
|
|
4327
|
-
<button
|
|
4328
|
-
type="button" role="tab" aria-selected={funnelSection === 'date'}
|
|
4329
|
-
className={funnelSection === 'date' ? `${css.funnelTab} ${css.funnelTabActive}` : css.funnelTab}
|
|
4330
|
-
onClick={() => setFunnelSection('date')}
|
|
4331
|
-
>
|
|
4332
|
-
{t('filterDate')}
|
|
4333
|
-
{dateCount > 0 ? <span className={css.funnelTabCount}>{dateCount}</span> : null}
|
|
4334
|
-
</button>
|
|
4335
|
-
<button
|
|
4336
|
-
type="button" role="tab" aria-selected={funnelSection === 'paths'}
|
|
4337
|
-
className={funnelSection === 'paths' ? `${css.funnelTab} ${css.funnelTabActive}` : css.funnelTab}
|
|
4338
|
-
onClick={() => setFunnelSection('paths')}
|
|
4339
|
-
>
|
|
4340
|
-
{t('filterPaths')}
|
|
4341
|
-
{filterModel.paths.length > 0 ? <span className={css.funnelTabCount}>{filterModel.paths.length}</span> : null}
|
|
4342
|
-
</button>
|
|
4343
|
-
</div>
|
|
4344
|
-
{funnelSection === 'users' ? (
|
|
4345
|
-
<div className={css.funnelPane}>
|
|
4346
|
-
<input
|
|
4347
|
-
className={css.funnelSearch}
|
|
4348
|
-
type="search"
|
|
4349
|
-
value={authorsQuery}
|
|
4350
|
-
onChange={event => setAuthorsQuery(event.target.value)}
|
|
4351
|
-
placeholder={t('filterUserSearch')}
|
|
4352
|
-
aria-label={t('filterUserSearch')}
|
|
4353
|
-
spellCheck={false}
|
|
4354
|
-
/>
|
|
4355
|
-
<div className={css.funnelList}>
|
|
4356
|
-
{authors === null ? (
|
|
4357
|
-
<div className={css.funnelMore}>{t('loading')}</div>
|
|
4358
|
-
) : matchedAuthors.length === 0 ? (
|
|
4359
|
-
<div className={css.funnelMore}>{authors.authors.length === 0 ? t('noCommits') : t('historyNoMatch')}</div>
|
|
4360
|
-
) : matchedAuthors.map(entry => (
|
|
4361
|
-
<label key={`${entry.name}\x1f${entry.email}`} className={css.funnelRow}>
|
|
4362
|
-
<input
|
|
4363
|
-
type="checkbox"
|
|
4364
|
-
checked={filterModel.users.includes(entry.name)}
|
|
4365
|
-
onChange={() => toggleUser(entry.name)}
|
|
4366
|
-
/>
|
|
4367
|
-
<span className={css.funnelName} title={`${entry.name} <${entry.email}>`}>{entry.name}</span>
|
|
4368
|
-
<span className={css.funnelCount}>{entry.count}</span>
|
|
4369
|
-
</label>
|
|
4370
|
-
))}
|
|
4371
|
-
{authors?.truncated === true ? (
|
|
4372
|
-
<div className={css.funnelMore}>{t('filterAuthorsMore')}</div>
|
|
4373
|
-
) : null}
|
|
4374
|
-
</div>
|
|
4375
|
-
</div>
|
|
4376
|
-
) : null}
|
|
4377
|
-
{funnelSection === 'date' ? (
|
|
4378
|
-
<div className={css.funnelPane}>
|
|
4379
|
-
<div className={css.funnelPresets}>
|
|
4380
|
-
{DATE_PRESETS.map(preset => (
|
|
4381
|
-
<button
|
|
4382
|
-
key={preset.key}
|
|
4383
|
-
type="button"
|
|
4384
|
-
className={filterModel.after === preset.value ? `${css.funnelPreset} ${css.funnelPresetActive}` : css.funnelPreset}
|
|
4385
|
-
onClick={() => applyFilter({ ...filterModel, after: filterModel.after === preset.value ? '' : preset.value })}
|
|
4386
|
-
>{t(preset.key)}</button>
|
|
4387
|
-
))}
|
|
4388
|
-
</div>
|
|
4389
|
-
{/* Which bound a picked day lands in — the calendar is one, the
|
|
4390
|
-
range is two picks apart. Captioned, and shaped as a rect
|
|
4391
|
-
track rather than the tab strip's pills: two identical pill
|
|
4392
|
-
rows six pixels apart never said they meant different
|
|
4393
|
-
things. */}
|
|
4394
|
-
<span className={css.funnelCaption}>{t('filterCalendarSets')}</span>
|
|
4395
|
-
<div className={css.funnelBounds} role="group" aria-label={t('filterCalendarSets')}>
|
|
4396
|
-
<button
|
|
4397
|
-
type="button"
|
|
4398
|
-
aria-pressed={calBound === 'after'}
|
|
4399
|
-
className={calBound === 'after' ? `${css.funnelBoundBtn} ${css.funnelBoundBtnActive}` : css.funnelBoundBtn}
|
|
4400
|
-
onClick={() => setCalBound('after')}
|
|
4401
|
-
>{t('filterAfter')}</button>
|
|
4402
|
-
<button
|
|
4403
|
-
type="button"
|
|
4404
|
-
aria-pressed={calBound === 'before'}
|
|
4405
|
-
className={calBound === 'before' ? `${css.funnelBoundBtn} ${css.funnelBoundBtnActive}` : css.funnelBoundBtn}
|
|
4406
|
-
onClick={() => setCalBound('before')}
|
|
4407
|
-
>{t('filterBefore')}</button>
|
|
4408
|
-
</div>
|
|
4409
|
-
<FilterCalendar
|
|
4410
|
-
year={calMonth.year}
|
|
4411
|
-
month={calMonth.month}
|
|
4412
|
-
after={filterModel.after}
|
|
4413
|
-
before={filterModel.before}
|
|
4414
|
-
locale={t('filterLocale')}
|
|
4415
|
-
onPick={iso => applyFilter({ ...filterModel, [calBound]: iso })}
|
|
4416
|
-
onShift={delta => setCalMonth(current => {
|
|
4417
|
-
const next = new Date(current.year, current.month + delta, 1)
|
|
4418
|
-
return { year: next.getFullYear(), month: next.getMonth() }
|
|
4419
|
-
})}
|
|
4420
|
-
/>
|
|
4421
|
-
<div className={css.funnelBoundRows}>
|
|
4422
|
-
<span className={css.funnelBoundRow}>
|
|
4423
|
-
<span className={css.funnelBoundKey}>{t('filterAfter')}</span>
|
|
4424
|
-
<span className={filterModel.after.length > 0 ? `${css.funnelBoundVal} ${css.funnelBoundValSet}` : css.funnelBoundVal}>
|
|
4425
|
-
{filterModel.after.length > 0 ? filterModel.after : '—'}
|
|
4426
|
-
</span>
|
|
4427
|
-
{filterModel.after.length > 0 ? (
|
|
4428
|
-
<button type="button" className={css.funnelBoundClear} aria-label={t('filterAfter')} onClick={() => applyFilter({ ...filterModel, after: '' })}>×</button>
|
|
4429
|
-
) : null}
|
|
4430
|
-
</span>
|
|
4431
|
-
<span className={css.funnelBoundRow}>
|
|
4432
|
-
<span className={css.funnelBoundKey}>{t('filterBefore')}</span>
|
|
4433
|
-
<span className={filterModel.before.length > 0 ? `${css.funnelBoundVal} ${css.funnelBoundValSet}` : css.funnelBoundVal}>
|
|
4434
|
-
{filterModel.before.length > 0 ? filterModel.before : '—'}
|
|
4435
|
-
</span>
|
|
4436
|
-
{filterModel.before.length > 0 ? (
|
|
4437
|
-
<button type="button" className={css.funnelBoundClear} aria-label={t('filterBefore')} onClick={() => applyFilter({ ...filterModel, before: '' })}>×</button>
|
|
4438
|
-
) : null}
|
|
4439
|
-
</span>
|
|
4440
|
-
</div>
|
|
4441
|
-
</div>
|
|
4442
|
-
) : null}
|
|
4443
|
-
{funnelSection === 'paths' ? (
|
|
4444
|
-
<div className={css.funnelPane}>
|
|
4445
|
-
<input
|
|
4446
|
-
className={css.funnelSearch}
|
|
4447
|
-
type="search"
|
|
4448
|
-
value={pathsQuery}
|
|
4449
|
-
onChange={event => setPathsQuery(event.target.value)}
|
|
4450
|
-
placeholder={t('filterPathSearch')}
|
|
4451
|
-
aria-label={t('filterPathSearch')}
|
|
4452
|
-
spellCheck={false}
|
|
4453
|
-
/>
|
|
4454
|
-
<div className={css.funnelList}>
|
|
4455
|
-
{pathTree === null ? (
|
|
4456
|
-
<div className={css.funnelMore}>{t('loading')}</div>
|
|
4457
|
-
) : pathsQuery.trim().length > 0 ? (
|
|
4458
|
-
/* Search results are FLAT — the honest shape for hits (same
|
|
4459
|
-
argument as the filtered commit list), each row ticking a
|
|
4460
|
-
pathspec directly: files first, then directories. */
|
|
4461
|
-
(() => {
|
|
4462
|
-
const hits = searchPaths(pathTree.paths, pathsQuery).slice(0, 200)
|
|
4463
|
-
if (hits.length === 0) return <div className={css.funnelMore}>{t('historyNoMatch')}</div>
|
|
4464
|
-
return (
|
|
4465
|
-
<>
|
|
4466
|
-
{hits.map(hit => (
|
|
4467
|
-
<label key={hit.path} className={css.funnelRow}>
|
|
4468
|
-
<TriStateCheckbox state={pathState(hit.path)} ariaLabel={hit.path} onChange={() => togglePath(hit.path)} />
|
|
4469
|
-
{hit.isFile ? <PathFileGlyph path={hit.path} /> : <PathDirGlyph />}
|
|
4470
|
-
<span className={css.funnelName} title={hit.path}>{hit.path}</span>
|
|
4471
|
-
</label>
|
|
4472
|
-
))}
|
|
4473
|
-
{searchPaths(pathTree.paths, pathsQuery).length > 200 ? (
|
|
4474
|
-
<div className={css.funnelMore}>{t('filterPathsMore')}</div>
|
|
4475
|
-
) : null}
|
|
4476
|
-
</>
|
|
4477
|
-
)
|
|
4478
|
-
})()
|
|
4479
|
-
) : pathTree.dirs.length === 0 ? (
|
|
4480
|
-
<div className={css.funnelMore}>{t('noCommits')}</div>
|
|
4481
|
-
) : (
|
|
4482
|
-
<PathTreeRows
|
|
4483
|
-
dirs={pathTree.dirs}
|
|
4484
|
-
depth={0}
|
|
4485
|
-
expanded={expandedDirs}
|
|
4486
|
-
stateOf={pathState}
|
|
4487
|
-
onToggleOpen={toggleDirOpen}
|
|
4488
|
-
onTogglePath={togglePath}
|
|
4489
|
-
/>
|
|
4490
|
-
)}
|
|
4491
|
-
{pathTree?.truncated === true ? (
|
|
4492
|
-
<div className={css.funnelMore}>{t('filterPathsMore')}</div>
|
|
4493
|
-
) : null}
|
|
4494
|
-
</div>
|
|
4495
|
-
</div>
|
|
4496
|
-
) : null}
|
|
4497
|
-
{/* The panel's own readout. Clearing goes through the box's grammar
|
|
4498
|
-
like every other funnel interaction, so one query string stays
|
|
4499
|
-
the single source of truth. */}
|
|
4500
|
-
<div className={css.funnelFoot}>
|
|
4501
|
-
<span className={selectedCount > 0 ? `${css.funnelFootCount} ${css.funnelFootCountOn}` : css.funnelFootCount}>
|
|
4502
|
-
{t('filterSelected', { count: selectedCount })}
|
|
4503
|
-
</span>
|
|
4504
|
-
<button
|
|
4505
|
-
type="button"
|
|
4506
|
-
className={css.funnelFootClear}
|
|
4507
|
-
disabled={selectedCount === 0}
|
|
4508
|
-
onClick={() => onQueryChange('')}
|
|
4509
|
-
>{t('filterClearAll')}</button>
|
|
4510
|
-
</div>
|
|
4511
|
-
</div>,
|
|
4512
|
-
funnelHost,
|
|
4513
|
-
) : null}
|
|
4514
|
-
{chips.length > 0 ? (
|
|
4515
|
-
<div className={css.filterChips}>
|
|
4516
|
-
{chips.map(chip => (
|
|
4517
|
-
<span key={`${chip.kind}\x1f${chip.value}`} className={css.filterChip}>
|
|
4518
|
-
<span className={css.filterChipLabel}>{chip.kind}:{chip.value}</span>
|
|
4519
|
-
<button
|
|
4520
|
-
type="button"
|
|
4521
|
-
className={css.filterChipRemove}
|
|
4522
|
-
aria-label={`${chip.kind} ${chip.value}`}
|
|
4523
|
-
onClick={() => onQueryChange(serializeLogQuery(removeChip(filterModel, chip.kind, chip.value)))}
|
|
4524
|
-
>×</button>
|
|
4525
|
-
</span>
|
|
4526
|
-
))}
|
|
4527
|
-
<button type="button" className={css.filterClear} onClick={() => onQueryChange('')}>{t('filterClearAll')}</button>
|
|
4528
|
-
</div>
|
|
4529
|
-
) : null}
|
|
4530
|
-
{commits.length === 0 ? (
|
|
4531
|
-
<div className={css.empty}>
|
|
4532
|
-
{loading ? t('loading') : error !== null ? error : chips.length > 0 ? t('historyNoMatch') : t('noCommits')}
|
|
4533
|
-
</div>
|
|
4534
|
-
) : (
|
|
4535
|
-
<div className={css.commits} role="listbox" aria-label={t('historyLabel')} ref={scrollRef}>
|
|
4536
|
-
{commits.map((commit, index) => (
|
|
4537
|
-
<CommitRow
|
|
4538
|
-
key={commit.hash}
|
|
4539
|
-
t={t}
|
|
4540
|
-
commit={commit}
|
|
4541
|
-
active={commit.hash === active}
|
|
4542
|
-
onSelect={onSelect}
|
|
4543
|
-
graphRow={graph.rows[index]}
|
|
4544
|
-
graphWidth={graph.width}
|
|
4545
|
-
layout={layout}
|
|
4546
|
-
/>
|
|
4547
|
-
))}
|
|
4548
|
-
<div ref={sentinelRef} className={css.commitsSentinel} />
|
|
4549
|
-
<div className={css.commitsFoot}>
|
|
4550
|
-
{loadingMore ? t('loading') : hasMore ? '' : t('historyEnd')}
|
|
4551
|
-
</div>
|
|
4552
|
-
</div>
|
|
4553
|
-
)}
|
|
4554
|
-
</div>
|
|
4555
|
-
)
|
|
4556
|
-
}
|
|
4557
|
-
|
|
4558
|
-
/* ---------- file tree ---------- */
|
|
4559
|
-
|
|
4560
|
-
/** Horizontal step per nesting level. */
|
|
4561
|
-
const TREE_INDENT = 14
|
|
4562
|
-
/** The gutter every row starts at. Equals `--gs-gutter-pane`, so depth-0 ticks
|
|
4563
|
-
* line up with the toolbar's own content — and everything interactive stays
|
|
4564
|
-
* clear of the 10px resizer the drawer paints over its left edge. */
|
|
4565
|
-
const TREE_BASE_INDENT = 12
|
|
4566
|
-
/** Chevron width plus its gap. A file row adds this so its status badge starts at
|
|
4567
|
-
* the directory NAME's column rather than under the directory's chevron. */
|
|
4568
|
-
const TREE_LEAF_OFFSET = 16
|
|
4569
|
-
/** Where a level's indent guide sits: inside the chevron, so it points at the
|
|
4570
|
-
* rows it groups. */
|
|
4571
|
-
const TREE_RAIL_OFFSET = 12
|
|
4572
|
-
/** The tick's own width. Must match `.checkBox` — the row's content starts after
|
|
4573
|
-
* it, and the indent guides are positioned from it. */
|
|
4574
|
-
const TREE_CHECK_W = 22
|
|
4575
|
-
/** Custom property the stylesheet reads to place one level's indent guide. */
|
|
4576
|
-
const RAIL_VAR = '--gs-rail'
|
|
4577
|
-
|
|
4578
|
-
interface DirNode {
|
|
4579
|
-
readonly name: string
|
|
4580
|
-
readonly path: string
|
|
4581
|
-
readonly dirs: Map<string, DirNode>
|
|
4582
|
-
readonly files: GitFile[]
|
|
4583
|
-
fileCount: number
|
|
4584
|
-
added: number
|
|
4585
|
-
deleted: number
|
|
4586
|
-
/** Every descendant's tick, rolled up. Computed once with the other totals. */
|
|
4587
|
-
check: CheckState
|
|
4588
|
-
}
|
|
4589
|
-
|
|
4590
|
-
function buildTree(files: readonly GitFile[]): DirNode {
|
|
4591
|
-
const root: DirNode = { name: '', path: '', dirs: new Map(), files: [], fileCount: 0, added: 0, deleted: 0, check: 'off' }
|
|
4592
|
-
for (const file of files) {
|
|
4593
|
-
let node = root
|
|
4594
|
-
const parts = file.path.split('/')
|
|
4595
|
-
for (let i = 0; i < parts.length - 1; i += 1) {
|
|
4596
|
-
const name = parts[i]
|
|
4597
|
-
let child = node.dirs.get(name)
|
|
4598
|
-
if (child === undefined) {
|
|
4599
|
-
child = { name, path: parts.slice(0, i + 1).join('/'), dirs: new Map(), files: [], fileCount: 0, added: 0, deleted: 0, check: 'off' }
|
|
4600
|
-
node.dirs.set(name, child)
|
|
4601
|
-
}
|
|
4602
|
-
node = child
|
|
4603
|
-
}
|
|
4604
|
-
node.files.push(file)
|
|
4605
|
-
}
|
|
4606
|
-
const aggregate = (node: DirNode): void => {
|
|
4607
|
-
node.fileCount = node.files.length
|
|
4608
|
-
node.added = node.files.reduce((sum, f) => sum + f.addedLines, 0)
|
|
4609
|
-
node.deleted = node.files.reduce((sum, f) => sum + f.deletedLines, 0)
|
|
4610
|
-
const ticks: CheckState[] = node.files.map(fileCheckState)
|
|
4611
|
-
for (const child of node.dirs.values()) {
|
|
4612
|
-
aggregate(child)
|
|
4613
|
-
node.fileCount += child.fileCount
|
|
4614
|
-
node.added += child.added
|
|
4615
|
-
node.deleted += child.deleted
|
|
4616
|
-
ticks.push(child.check)
|
|
4617
|
-
}
|
|
4618
|
-
node.check = rollUp(ticks)
|
|
4619
|
-
}
|
|
4620
|
-
aggregate(root)
|
|
4621
|
-
return compactChains(root)
|
|
4622
|
-
}
|
|
4623
|
-
|
|
4624
|
-
/**
|
|
4625
|
-
* Merge every directory that holds nothing but one subdirectory into that child.
|
|
4626
|
-
*
|
|
4627
|
-
* `docs/superpowers/specs/design.md` otherwise costs three rows and three indent
|
|
4628
|
-
* levels to reach one file, and none of those three rows carries a choice — each
|
|
4629
|
-
* has exactly one way down. Merging them into a single `docs/superpowers/specs`
|
|
4630
|
-
* row is what VS Code calls compact folders, and it makes indentation depth mean
|
|
4631
|
-
* "where the tree branches" rather than "how long the path is".
|
|
4632
|
-
*
|
|
4633
|
-
* The merged node keeps the DEEPEST path, so it stays the one the collapse set
|
|
4634
|
-
* and the reveal-the-active-file walk already address.
|
|
4635
|
-
* @param node - directory whose descendants are compacted.
|
|
4636
|
-
* @returns the node with compacted children.
|
|
4637
|
-
*/
|
|
4638
|
-
function compactChains(node: DirNode): DirNode {
|
|
4639
|
-
const dirs = new Map<string, DirNode>()
|
|
4640
|
-
for (const child of node.dirs.values()) {
|
|
4641
|
-
let merged = compactChains(child)
|
|
4642
|
-
while (merged.files.length === 0 && merged.dirs.size === 1) {
|
|
4643
|
-
const only = merged.dirs.values().next().value as DirNode
|
|
4644
|
-
merged = { ...only, name: `${merged.name}/${only.name}` }
|
|
4645
|
-
}
|
|
4646
|
-
dirs.set(merged.name, merged)
|
|
4647
|
-
}
|
|
4648
|
-
return { ...node, dirs }
|
|
4649
|
-
}
|
|
4650
|
-
|
|
4651
|
-
interface FileTreeProps {
|
|
4652
|
-
t: Translate
|
|
4653
|
-
/** Whether the view has nothing to show yet — already resolved by the caller
|
|
4654
|
-
* via {@link showsPending}, NOT the raw in-flight flag. This pane used to
|
|
4655
|
-
* re-derive it from `loading && files.length === 0`, and that second copy of
|
|
4656
|
-
* the rule is precisely what the header then got wrong. */
|
|
4657
|
-
loading?: boolean
|
|
4658
|
-
/** What the list holds, prepended to the count — the working-tree view says
|
|
4659
|
-
* which worktree it is reading; commit views are already named by history. */
|
|
4660
|
-
lead?: string
|
|
4661
|
-
files: readonly GitFile[]
|
|
4662
|
-
active: string | null
|
|
4663
|
-
onSelect: (path: string) => void
|
|
4664
|
-
/** Undefined until the user interacts: then it shows defaults. Lifted to the
|
|
4665
|
-
* panel so background polls (new `files` identity) and drawer close/reopen
|
|
4666
|
-
* never reset the user's expansion choices. */
|
|
4667
|
-
collapsed: Set<string> | undefined
|
|
4668
|
-
onCollapsedChange: (next: Set<string>) => void
|
|
4669
|
-
/** Add or remove files from the commit set. Undefined outside the working-tree
|
|
4670
|
-
* view, where what a commit contains was decided long ago. */
|
|
4671
|
-
onCheck?: (files: readonly GitFile[], state: CheckState) => void
|
|
4672
|
-
/** Roll one file back to HEAD; working-tree view only. */
|
|
4673
|
-
onDiscard?: (file: GitFile) => void
|
|
4674
|
-
/** Rendered under the tree in the working-tree view only. */
|
|
4675
|
-
footer?: ReactNode
|
|
4676
|
-
/** Names what this list is OF — the working tree, or one commit, or one
|
|
4677
|
-
* comparison. The filter clears when it changes: a query typed against a
|
|
4678
|
-
* 140-file commit would otherwise carry over to the next commit and hide
|
|
4679
|
-
* most of it, with nothing on screen saying why. */
|
|
4680
|
-
scopeKey: string
|
|
4681
|
-
}
|
|
4682
|
-
|
|
4683
|
-
function FileTree({ t, loading, lead, files, active, onSelect, collapsed, onCollapsedChange, onCheck, onDiscard, footer, scopeKey }: FileTreeProps): ReactNode {
|
|
4684
|
-
/**
|
|
4685
|
-
* The filter over this list. Local, because it describes a way of LOOKING at
|
|
4686
|
-
* the pane rather than anything the drawer stores: closing and reopening on
|
|
4687
|
-
* an unfiltered list is what someone expects, and a query kept in the panel
|
|
4688
|
-
* would have to be cleared from four places instead of one.
|
|
4689
|
-
*/
|
|
4690
|
-
const [query, setQuery] = useState('')
|
|
4691
|
-
const [filterOpen, setFilterOpen] = useState(false)
|
|
4692
|
-
const filterRef = useRef<HTMLInputElement>(null)
|
|
4693
|
-
useEffect(() => { setQuery(''); setFilterOpen(false) }, [scopeKey])
|
|
4694
|
-
|
|
4695
|
-
const shownFiles = useMemo(() => filterFiles(files, query), [files, query])
|
|
4696
|
-
const filtering = shownFiles !== files
|
|
4697
|
-
const tree = useMemo(() => buildTree(shownFiles), [shownFiles])
|
|
4698
|
-
/** Default: a dir collapses when it holds more than 12 files anywhere below it. */
|
|
4699
|
-
const effective = collapsed ?? defaultCollapsed(tree)
|
|
4700
|
-
|
|
4701
|
-
// Reveal the active file by expanding its ancestor chain — ONLY when the
|
|
4702
|
-
// selection itself changes. Listening to `collapsed` here would instantly
|
|
4703
|
-
// revert manual folds of any directory containing the active file.
|
|
4704
|
-
useEffect(() => {
|
|
4705
|
-
if (active === null) return
|
|
4706
|
-
const parts = active.split('/')
|
|
4707
|
-
let touched = false
|
|
4708
|
-
const next = new Set(collapsed ?? defaultCollapsed(tree))
|
|
4709
|
-
for (let i = 1; i < parts.length; i += 1) {
|
|
4710
|
-
const dir = parts.slice(0, i).join('/')
|
|
4711
|
-
if (next.delete(dir)) touched = true
|
|
4712
|
-
}
|
|
4713
|
-
if (touched) onCollapsedChange(next)
|
|
4714
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps -- reveal is a selection-change event, not an invariant over `collapsed`
|
|
4715
|
-
}, [active])
|
|
4716
|
-
|
|
4717
|
-
const setAll = (open: boolean): void => {
|
|
4718
|
-
onCollapsedChange(open ? new Set() : allDirs(tree))
|
|
4719
|
-
}
|
|
4720
|
-
|
|
4721
|
-
const toggleOne = (path: string): void => {
|
|
4722
|
-
const next = new Set(effective)
|
|
4723
|
-
if (next.has(path)) next.delete(path)
|
|
4724
|
-
else next.add(path)
|
|
4725
|
-
onCollapsedChange(next)
|
|
4726
|
-
}
|
|
4727
|
-
|
|
4728
|
-
const stageLabels = { stage: t('stage'), unstage: t('unstage') }
|
|
4729
|
-
|
|
4730
|
-
return (
|
|
4731
|
-
<div className={css.treeWrap}>
|
|
4732
|
-
<div className={css.treeTools}>
|
|
4733
|
-
{/* The root tick replaces the Stage all / Unstage all pair: it says the
|
|
4734
|
-
same two things in the column the rows already read down, and gives
|
|
4735
|
-
the toolbar back the room those two buttons needed. The toolbar's own
|
|
4736
|
-
pane gutter is its indent, so it lines up with the depth-0 rows. */}
|
|
4737
|
-
<div className={css.treeLead}>
|
|
4738
|
-
{onCheck !== undefined ? (
|
|
4739
|
-
<CheckBox
|
|
4740
|
-
state={tree.check}
|
|
4741
|
-
label={tree.check === 'on' ? t('unstageAll') : t('stageAll')}
|
|
4742
|
-
indent={0}
|
|
4743
|
-
// `shownFiles`, not `files`: a tick IS a git call, and the root
|
|
4744
|
-
// one must stage exactly the rows it sits above. Reaching past a
|
|
4745
|
-
// filter into files the pane is hiding is how "stage all" ends up
|
|
4746
|
-
// meaning something the reader never saw.
|
|
4747
|
-
onToggle={() => onCheck(shownFiles, tree.check)}
|
|
4748
|
-
/>
|
|
4749
|
-
) : null}
|
|
4750
|
-
<span className={css.treeLabel}>
|
|
4751
|
-
{loading === true
|
|
4752
|
-
? t('loading')
|
|
4753
|
-
: `${lead !== undefined ? `${lead} · ` : ''}${filtering
|
|
4754
|
-
? t('filesFiltered', { shown: shownFiles.length, count: files.length })
|
|
4755
|
-
: t('files', { count: files.length })}`}
|
|
4756
|
-
</span>
|
|
4757
|
-
</div>
|
|
4758
|
-
<div className={css.treeActions} data-gs-part="tree-actions">
|
|
4759
|
-
{/* Filtering is about the list, so it sits with the list's own two
|
|
4760
|
-
controls rather than in the drawer chrome — and it stays lit while
|
|
4761
|
-
a query is set, because a pane showing 6 of 140 files with no
|
|
4762
|
-
visible reason is the one way this feature can mislead. */}
|
|
4763
|
-
<button
|
|
4764
|
-
type="button"
|
|
4765
|
-
className={filterOpen || filtering ? `${css.treeIcon} ${css.treeIconOn}` : css.treeIcon}
|
|
4766
|
-
data-gs-part="filter-files"
|
|
4767
|
-
title={t('filterFiles')} aria-label={t('filterFiles')}
|
|
4768
|
-
aria-pressed={filterOpen}
|
|
4769
|
-
onClick={() => {
|
|
4770
|
-
// Closing is also clearing. A hidden box still holding a query
|
|
4771
|
-
// would leave the pane filtered with its only explanation
|
|
4772
|
-
// folded away.
|
|
4773
|
-
if (filterOpen) { setQuery(''); setFilterOpen(false); return }
|
|
4774
|
-
setFilterOpen(true)
|
|
4775
|
-
window.setTimeout(() => filterRef.current?.focus(), 0)
|
|
4776
|
-
}}
|
|
4777
|
-
><FilterGlyph /></button>
|
|
4778
|
-
{/* Icon-only, with the label on `title`/`aria-label`: the glyph is the
|
|
4779
|
-
same one the rows carry, so each button previews its own result. */}
|
|
4780
|
-
<button
|
|
4781
|
-
type="button" className={css.treeIcon} data-gs-part="expand-all"
|
|
4782
|
-
title={t('expandAll')} aria-label={t('expandAll')}
|
|
4783
|
-
onClick={() => setAll(true)}
|
|
4784
|
-
><span className={`${css.treeIconGlyph} ${css.treeIconDown}`}>▸</span></button>
|
|
4785
|
-
<button
|
|
4786
|
-
type="button" className={css.treeIcon} data-gs-part="collapse-all"
|
|
4787
|
-
title={t('collapseAll')} aria-label={t('collapseAll')}
|
|
4788
|
-
onClick={() => setAll(false)}
|
|
4789
|
-
><span className={css.treeIconGlyph}>▸</span></button>
|
|
4790
|
-
</div>
|
|
4791
|
-
</div>
|
|
4792
|
-
{filterOpen ? (
|
|
4793
|
-
<div className={css.treeFilter}>
|
|
4794
|
-
<input
|
|
4795
|
-
ref={filterRef}
|
|
4796
|
-
className={css.treeFilterInput}
|
|
4797
|
-
type="text"
|
|
4798
|
-
value={query}
|
|
4799
|
-
placeholder={t('filterFilesPlaceholder')}
|
|
4800
|
-
aria-label={t('filterFiles')}
|
|
4801
|
-
spellCheck={false}
|
|
4802
|
-
onChange={event => setQuery(event.target.value)}
|
|
4803
|
-
onKeyDown={event => {
|
|
4804
|
-
if (event.key !== 'Escape') return
|
|
4805
|
-
// Escape belongs to the box while it has something to undo;
|
|
4806
|
-
// only an already-empty box lets it through to close the drawer.
|
|
4807
|
-
if (query.length > 0) { event.stopPropagation(); setQuery(''); return }
|
|
4808
|
-
event.stopPropagation()
|
|
4809
|
-
setFilterOpen(false)
|
|
4810
|
-
}}
|
|
4811
|
-
/>
|
|
4812
|
-
{query.length > 0 ? (
|
|
4813
|
-
<button
|
|
4814
|
-
type="button" className={css.treeFilterClear}
|
|
4815
|
-
title={t('filterFilesClear')} aria-label={t('filterFilesClear')}
|
|
4816
|
-
onClick={() => { setQuery(''); filterRef.current?.focus() }}
|
|
4817
|
-
>×</button>
|
|
4818
|
-
) : null}
|
|
4819
|
-
</div>
|
|
4820
|
-
) : null}
|
|
4821
|
-
{loading === true ? (
|
|
4822
|
-
<div className={css.treeEmpty} data-gs-part="tree-loading">{t('loading')}</div>
|
|
4823
|
-
) : filtering && shownFiles.length === 0 ? (
|
|
4824
|
-
<div className={css.treeEmpty} data-gs-part="tree-no-match">{t('filterNoMatch')}</div>
|
|
4825
|
-
) : (
|
|
4826
|
-
<ul className={css.tree}>
|
|
4827
|
-
{/* A filtered tree ignores the fold state entirely: the reader asked
|
|
4828
|
-
for these files, and leaving them behind a directory they
|
|
4829
|
-
collapsed twenty minutes ago reads as "no matches". */}
|
|
4830
|
-
<TreeChildren
|
|
4831
|
-
node={tree} depth={0} active={active} collapsed={filtering ? EMPTY_COLLAPSED : effective}
|
|
4832
|
-
onToggle={toggleOne} onSelect={onSelect} onCheck={onCheck} onDiscard={onDiscard} stageLabels={stageLabels} discardLabel={t('discardAction')}
|
|
4833
|
-
/>
|
|
4834
|
-
</ul>
|
|
4835
|
-
)}
|
|
4836
|
-
{footer}
|
|
4837
|
-
</div>
|
|
4838
|
-
)
|
|
4839
|
-
}
|
|
4840
|
-
|
|
4841
|
-
function defaultCollapsed(root: DirNode): Set<string> {
|
|
4842
|
-
const out = new Set<string>()
|
|
4843
|
-
const walk = (node: DirNode): void => {
|
|
4844
|
-
for (const child of node.dirs.values()) {
|
|
4845
|
-
if (child.fileCount > 12) out.add(child.path)
|
|
4846
|
-
walk(child)
|
|
4847
|
-
}
|
|
4848
|
-
}
|
|
4849
|
-
walk(root)
|
|
4850
|
-
return out
|
|
4851
|
-
}
|
|
4852
|
-
|
|
4853
|
-
function allDirs(node: DirNode): Set<string> {
|
|
4854
|
-
const out = new Set<string>()
|
|
4855
|
-
const walk = (n: DirNode): void => {
|
|
4856
|
-
for (const child of n.dirs.values()) { out.add(child.path); walk(child) }
|
|
4857
|
-
}
|
|
4858
|
-
walk(node)
|
|
4859
|
-
return out
|
|
4860
|
-
}
|
|
4861
|
-
|
|
4862
|
-
/**
|
|
4863
|
-
* One tick. A sibling of the row it belongs to rather than a child of it: a
|
|
4864
|
-
* button inside a button is invalid HTML, and the two clicks mean different
|
|
4865
|
-
* things — this one changes the commit set, the row opens the diff.
|
|
4866
|
-
*
|
|
4867
|
-
* The tick carries its row's own indent and stands at the node it includes,
|
|
4868
|
-
* IDEA-style, rather than in a column pinned to the pane edge. A pinned column
|
|
4869
|
-
* reads at a glance, but it detaches each tick from its node — and its first
|
|
4870
|
-
* 10px sat underneath the drawer's edge resizer, so a click on the left half of
|
|
4871
|
-
* a depth-0 tick dragged the drawer instead of staging anything.
|
|
4872
|
-
*/
|
|
4873
|
-
function CheckBox({ state, label, indent, onToggle }: {
|
|
4874
|
-
state: CheckState
|
|
4875
|
-
label: string
|
|
4876
|
-
/** The row's left edge, carried here so the tick stands at its own node. */
|
|
4877
|
-
indent: number
|
|
4878
|
-
onToggle: () => void
|
|
4879
|
-
}): ReactNode {
|
|
4880
|
-
const mark = state === 'on' ? css.checkMarkOn : state === 'partial' ? css.checkMarkPartial : ''
|
|
4881
|
-
return (
|
|
4882
|
-
<button
|
|
4883
|
-
type="button"
|
|
4884
|
-
role="checkbox"
|
|
4885
|
-
aria-checked={state === 'partial' ? 'mixed' : state === 'on'}
|
|
4886
|
-
className={css.checkBox}
|
|
4887
|
-
style={{ marginLeft: indent }}
|
|
4888
|
-
title={label}
|
|
4889
|
-
aria-label={label}
|
|
4890
|
-
onClick={onToggle}
|
|
4891
|
-
>
|
|
4892
|
-
<span className={`${css.checkMark} ${mark}`} aria-hidden="true">
|
|
4893
|
-
{state === 'on' ? '✓' : state === 'partial' ? '–' : ''}
|
|
4894
|
-
</span>
|
|
4895
|
-
</button>
|
|
4896
|
-
)
|
|
4897
|
-
}
|
|
4898
|
-
|
|
4899
|
-
/** Every file at or under a node, for a tick that acts on a whole directory. */
|
|
4900
|
-
function filesUnder(node: DirNode): GitFile[] {
|
|
4901
|
-
const out = [...node.files]
|
|
4902
|
-
for (const child of node.dirs.values()) out.push(...filesUnder(child))
|
|
4903
|
-
return out
|
|
4904
|
-
}
|
|
4905
|
-
|
|
4906
|
-
interface TreeChildrenProps {
|
|
4907
|
-
node: DirNode
|
|
4908
|
-
depth: number
|
|
4909
|
-
active: string | null
|
|
4910
|
-
/** Read-only: a filtered tree is handed a shared empty set rather than a copy. */
|
|
4911
|
-
collapsed: ReadonlySet<string>
|
|
4912
|
-
onToggle: (path: string) => void
|
|
4913
|
-
onSelect: (path: string) => void
|
|
4914
|
-
/** Add or remove files from the commit set. Undefined outside the working-tree
|
|
4915
|
-
* view, where what a commit contains was decided long ago. */
|
|
4916
|
-
onCheck?: (files: readonly GitFile[], state: CheckState) => void
|
|
4917
|
-
/** Roll one file back to HEAD. Undefined outside the working-tree view for
|
|
4918
|
-
* the same reason `onCheck` is: a commit's files are history, and there is
|
|
4919
|
-
* nothing there to roll back. Directories never offer it — the irreversible
|
|
4920
|
-
* action does not get a gesture that takes a subtree with it. */
|
|
4921
|
-
onDiscard?: (file: GitFile) => void
|
|
4922
|
-
/** Pre-translated, so the row does not have to carry `t` for two strings. */
|
|
4923
|
-
stageLabels: { stage: string; unstage: string }
|
|
4924
|
-
/** Label for the roll-back action, pre-translated like `stageLabels`. */
|
|
4925
|
-
discardLabel?: string
|
|
4926
|
-
}
|
|
4927
|
-
|
|
4928
|
-
function TreeChildren({ node, depth, active, collapsed, onToggle, onSelect, onCheck, onDiscard, stageLabels, discardLabel }: TreeChildrenProps): ReactNode {
|
|
4929
|
-
const dirNodes = [...node.dirs.values()].sort((a, b) => a.name.localeCompare(b.name))
|
|
4930
|
-
const fileNodes = [...node.files].sort((a, b) => basePart(a.path).localeCompare(basePart(b.path)))
|
|
4931
|
-
const checkColumn = onCheck !== undefined ? TREE_CHECK_W : 0
|
|
4932
|
-
// With ticks, the tick carries this indent and the button starts at 0; without
|
|
4933
|
-
// them (history, compare) the button carries it, as it always did.
|
|
4934
|
-
const indent = TREE_BASE_INDENT + depth * TREE_INDENT
|
|
4935
|
-
return (
|
|
4936
|
-
<>
|
|
4937
|
-
{dirNodes.map(dir => {
|
|
4938
|
-
const open = !collapsed.has(dir.path)
|
|
4939
|
-
const containsActive = active !== null && active.startsWith(`${dir.path}/`)
|
|
4940
|
-
return (
|
|
4941
|
-
<li key={dir.path} className={css.treeDirLi}>
|
|
4942
|
-
<div className={css.treeRow}>
|
|
4943
|
-
{onCheck !== undefined ? (
|
|
4944
|
-
<CheckBox
|
|
4945
|
-
state={dir.check}
|
|
4946
|
-
label={dir.check === 'on' ? stageLabels.unstage : stageLabels.stage}
|
|
4947
|
-
indent={indent}
|
|
4948
|
-
onToggle={() => onCheck(filesUnder(dir), dir.check)}
|
|
4949
|
-
/>
|
|
4950
|
-
) : null}
|
|
4951
|
-
<button
|
|
4952
|
-
type="button"
|
|
4953
|
-
className={`${css.treeDir} ${containsActive ? css.treeDirActive : ''}`}
|
|
4954
|
-
style={{ paddingLeft: onCheck !== undefined ? 0 : indent }}
|
|
4955
|
-
onClick={() => onToggle(dir.path)}
|
|
4956
|
-
title={dir.path}
|
|
4957
|
-
>
|
|
4958
|
-
<span className={`${css.chevron} ${open ? css.chevronOpen : ''}`}>▸</span>
|
|
4959
|
-
<PathDirGlyph />
|
|
4960
|
-
<span className={css.treeDirName}>{dir.name}</span>
|
|
4961
|
-
<span className={css.treeDirCount}>{dir.fileCount}</span>
|
|
4962
|
-
<span className={css.treeDirCounts}>
|
|
4963
|
-
{dir.added > 0 ? <span className={css.fileCountAdd}>+{dir.added}</span> : null}
|
|
4964
|
-
{dir.deleted > 0 ? <span className={css.fileCountDel}>−{dir.deleted}</span> : null}
|
|
4965
|
-
</span>
|
|
4966
|
-
</button>
|
|
4967
|
-
</div>
|
|
4968
|
-
{open ? (
|
|
4969
|
-
<ul
|
|
4970
|
-
className={css.treeSub}
|
|
4971
|
-
// The rail hangs off the parent's chevron, which sits after the
|
|
4972
|
-
// row's tick — so the tick's width is part of the offset.
|
|
4973
|
-
style={{ [RAIL_VAR]: `${checkColumn + TREE_BASE_INDENT + depth * TREE_INDENT + TREE_RAIL_OFFSET}px` } as CSSProperties}
|
|
4974
|
-
>
|
|
4975
|
-
<TreeChildren node={dir} depth={depth + 1} active={active} collapsed={collapsed} onToggle={onToggle} onSelect={onSelect} onCheck={onCheck} onDiscard={onDiscard} stageLabels={stageLabels} discardLabel={discardLabel} />
|
|
4976
|
-
</ul>
|
|
4977
|
-
) : null}
|
|
4978
|
-
</li>
|
|
4979
|
-
)
|
|
4980
|
-
})}
|
|
4981
|
-
{fileNodes.map(file => {
|
|
4982
|
-
const check = fileCheckState(file)
|
|
4983
|
-
return (
|
|
4984
|
-
<li key={file.path} className={css.fileLi}>
|
|
4985
|
-
{onCheck !== undefined ? (
|
|
4986
|
-
<CheckBox
|
|
4987
|
-
state={check}
|
|
4988
|
-
label={check === 'on' ? stageLabels.unstage : stageLabels.stage}
|
|
4989
|
-
indent={indent}
|
|
4990
|
-
onToggle={() => onCheck([file], check)}
|
|
4991
|
-
/>
|
|
4992
|
-
) : null}
|
|
4993
|
-
<button
|
|
4994
|
-
type="button"
|
|
4995
|
-
className={active === file.path ? `${css.file} ${css.fileActive}` : css.file}
|
|
4996
|
-
style={{ paddingLeft: (onCheck !== undefined ? 0 : indent) + TREE_LEAF_OFFSET }}
|
|
4997
|
-
onClick={() => onSelect(file.path)}
|
|
4998
|
-
title={file.previousPath !== undefined ? `${file.previousPath} → ${file.path}` : file.path}
|
|
4999
|
-
>
|
|
5000
|
-
{/* Icon then name, status on the right with the line counts.
|
|
5001
|
-
The badge used to lead, which put two glyphs side by side the
|
|
5002
|
-
moment the row gained a file icon; both IDEA and VS Code read
|
|
5003
|
-
left-to-right as "what this is, then what happened to it",
|
|
5004
|
-
and the badge still lands in an aligned column — `.filePath`
|
|
5005
|
-
is the only flexible child. */}
|
|
5006
|
-
<PathFileGlyph path={file.path} />
|
|
5007
|
-
<span className={css.filePath}>{basePart(file.path)}</span>
|
|
5008
|
-
{file.binary ? <span className={css.fileBinary}>BIN</span> : (
|
|
5009
|
-
<span className={css.fileCounts}>
|
|
5010
|
-
<span className={css.fileCountAdd}>{file.addedLines > 0 ? `+${file.addedLines}` : ''}</span>{' '}
|
|
5011
|
-
<span className={css.fileCountDel}>{file.deletedLines > 0 ? `−${file.deletedLines}` : ''}</span>
|
|
5012
|
-
</span>
|
|
5013
|
-
)}
|
|
5014
|
-
<span className={`${css.fileStatus} ${STATUS_BADGE[file.status]}`}>{statusGlyph(file.status)}</span>
|
|
5015
|
-
</button>
|
|
5016
|
-
{onDiscard !== undefined ? (
|
|
5017
|
-
/* Outside the row button, not inside it: a button in a button is
|
|
5018
|
-
invalid, and clicking roll-back must not also select the file. */
|
|
5019
|
-
<button
|
|
5020
|
-
type="button"
|
|
5021
|
-
className={css.fileDiscard}
|
|
5022
|
-
title={discardLabel}
|
|
5023
|
-
aria-label={`${discardLabel ?? ''} ${file.path}`}
|
|
5024
|
-
onClick={event => { event.stopPropagation(); onDiscard(file) }}
|
|
5025
|
-
><RollbackGlyph /></button>
|
|
5026
|
-
) : null}
|
|
5027
|
-
</li>
|
|
5028
|
-
)
|
|
5029
|
-
})}
|
|
5030
|
-
</>
|
|
5031
|
-
)
|
|
5032
|
-
}
|
|
5033
|
-
|
|
5034
|
-
/* ---------- diff rendering: rows, word-level ranges, syntax pass ---------- */
|
|
5035
|
-
|
|
5036
|
-
/**
|
|
5037
|
-
* Render one file's unified-diff segment with word-level highlights and Shiki.
|
|
5038
|
-
*
|
|
5039
|
-
* This is what History and Compare show, and — unlike the side-by-side pane —
|
|
5040
|
-
* it has no row model carrying block ids, because nothing here acts on a block:
|
|
5041
|
-
* a commit's contents were decided long ago, so there is no staging and no
|
|
5042
|
-
* roll-back. The walk still needs them, so the runs are read off the row kinds
|
|
5043
|
-
* by `unifiedBlocks` and marked on the rows that scroll.
|
|
5044
|
-
*
|
|
5045
|
-
* The scroller is this component's own rather than the pane's. A bar that
|
|
5046
|
-
* scrolls away is not a control, and the pane scrolls in BOTH directions —
|
|
5047
|
-
* `sticky` fixes the vertical half and nothing fixes the horizontal one, since
|
|
5048
|
-
* a block child of a scroller is only ever as wide as the scrollport. A header
|
|
5049
|
-
* outside the scrolled box has neither problem.
|
|
5050
|
-
*/
|
|
5051
|
-
function DiffView({ segment, path, palette, t }: {
|
|
5052
|
-
segment: string
|
|
5053
|
-
path: string
|
|
5054
|
-
palette: string
|
|
5055
|
-
t: Translate
|
|
5056
|
-
}): ReactNode {
|
|
5057
|
-
const lang = shikiLangOf(path)
|
|
5058
|
-
const shikiTheme = shikiThemeOf(palette)
|
|
5059
|
-
const grammarGen = useSyncExternalStore(subscribeGrammarLoaded, grammarLoadCount)
|
|
5060
|
-
const rowsWithWords = useMemo(() => attachWordRanges(parseRows(segment)), [segment])
|
|
5061
|
-
const sides = useMemo(() => gutterSides(rowsWithWords), [rowsWithWords])
|
|
5062
|
-
const scrollRef = useRef<HTMLDivElement>(null)
|
|
5063
|
-
// Windowed for the same reason the side-by-side pane is: a unified diff of a
|
|
5064
|
-
// long file put every row in the DOM and re-lexed every one of them, so
|
|
5065
|
-
// opening one froze the pane in exactly the same way.
|
|
5066
|
-
const win = useRowWindow(scrollRef, rowsWithWords.length)
|
|
5067
|
-
const syntax = useMemo(
|
|
5068
|
-
() => highlightForRowsWindow(rowsWithWords, lang, shikiTheme, win.start, win.end),
|
|
5069
|
-
[rowsWithWords, lang, shikiTheme, win.start, win.end, grammarGen],
|
|
5070
|
-
)
|
|
5071
|
-
const blocks = useMemo(() => unifiedBlocks(rowsWithWords.map(row => row.kind)), [rowsWithWords])
|
|
5072
|
-
const changes = useMemo(() => countBlocks(blocks), [blocks])
|
|
5073
|
-
// Derived rather than measured, because a windowed pane has no element for
|
|
5074
|
-
// the block being walked to.
|
|
5075
|
-
const blocksForNav = useRef<readonly number[]>(blocks)
|
|
5076
|
-
blocksForNav.current = blocks
|
|
5077
|
-
const { goToChange } = useChangeNav(
|
|
5078
|
-
scrollRef,
|
|
5079
|
-
useCallback(() => blockTopsFromRows(blocksForNav.current, DIFF_ROW_H, DIFF_GRID_PAD_TOP), []),
|
|
5080
|
-
)
|
|
5081
|
-
// Read by the key listener below, which is attached once. `goToChange` only
|
|
5082
|
-
// ever touches refs, but pinning it here says so rather than relying on it.
|
|
5083
|
-
const walk = useRef(goToChange)
|
|
5084
|
-
walk.current = goToChange
|
|
5085
|
-
|
|
5086
|
-
// F7 / Shift+F7, the spelling IDEA's diff viewer taught, on the same element
|
|
5087
|
-
// that scrolls — so the key and the buttons cannot disagree about which pane
|
|
5088
|
-
// they move. `tabIndex` is what makes it able to receive the key at all:
|
|
5089
|
-
// diff text is not focusable, and a click on it would otherwise leave focus
|
|
5090
|
-
// on the document body.
|
|
5091
|
-
useEffect(() => {
|
|
5092
|
-
const scroller = scrollRef.current
|
|
5093
|
-
if (scroller === null) return
|
|
5094
|
-
const onKey = (event: KeyboardEvent): void => {
|
|
5095
|
-
if (event.key !== 'F7') return
|
|
5096
|
-
event.preventDefault()
|
|
5097
|
-
walk.current(event.shiftKey ? -1 : 1)
|
|
5098
|
-
}
|
|
5099
|
-
scroller.addEventListener('keydown', onKey)
|
|
5100
|
-
return () => { scroller.removeEventListener('keydown', onKey) }
|
|
5101
|
-
}, [])
|
|
5102
|
-
|
|
5103
|
-
return (
|
|
5104
|
-
<div className={css.diffWrap}>
|
|
5105
|
-
{changes > 0 ? (
|
|
5106
|
-
<div className={css.diffNav}>
|
|
5107
|
-
<button
|
|
5108
|
-
type="button"
|
|
5109
|
-
className={css.blockBtn}
|
|
5110
|
-
title={t('prevChangeHint')}
|
|
5111
|
-
aria-label={t('prevChange')}
|
|
5112
|
-
onClick={() => { goToChange(-1) }}
|
|
5113
|
-
><NavGlyph of="prev" /></button>
|
|
5114
|
-
<button
|
|
5115
|
-
type="button"
|
|
5116
|
-
className={css.blockBtn}
|
|
5117
|
-
title={t('nextChangeHint')}
|
|
5118
|
-
aria-label={t('nextChange')}
|
|
5119
|
-
onClick={() => { goToChange(1) }}
|
|
5120
|
-
><NavGlyph of="next" /></button>
|
|
5121
|
-
<span className={css.sideNavCount}>{t('changeCount', { n: changes })}</span>
|
|
5122
|
-
</div>
|
|
5123
|
-
) : null}
|
|
5124
|
-
<div ref={scrollRef} className={css.diffScroll} tabIndex={-1}>
|
|
5125
|
-
<pre className={css.diffPre}>
|
|
5126
|
-
{win.padTop > 0 ? <div className={css.diffSpacer} style={{ height: `${win.padTop}px` }} aria-hidden="true" /> : null}
|
|
5127
|
-
{rowsWithWords.slice(win.start, win.end).map((row, k) => {
|
|
5128
|
-
const i = win.start + k
|
|
5129
|
-
return (
|
|
5130
|
-
<div key={i} className={`${css.line} ${rowClass(row.kind)}`} data-block={blocks[i]! >= 0 ? blocks[i] : undefined}>
|
|
5131
|
-
{sides.old ? <span className={css.lnOld}>{row.kind === 'add' || row.kind === 'hunk' ? '' : row.oldL}</span> : null}
|
|
5132
|
-
{sides.new ? <span className={css.lnNew}>{row.kind === 'del' || row.kind === 'hunk' ? '' : row.newL}</span> : null}
|
|
5133
|
-
<span className={`${css.gutter} ${row.kind === 'add' ? css.signAdd : row.kind === 'del' ? css.signDel : ''}`}>
|
|
5134
|
-
{row.kind === 'add' ? '+' : row.kind === 'del' ? '−' : ''}
|
|
5135
|
-
</span>
|
|
5136
|
-
<span className={css.code}>{renderCode(row, syntax[i] ?? [])}</span>
|
|
5137
|
-
</div>
|
|
5138
|
-
)
|
|
5139
|
-
})}
|
|
5140
|
-
{win.padBottom > 0 ? <div className={css.diffSpacer} style={{ height: `${win.padBottom}px` }} aria-hidden="true" /> : null}
|
|
5141
|
-
</pre>
|
|
5142
|
-
</div>
|
|
5143
|
-
</div>
|
|
5144
|
-
)
|
|
5145
|
-
}
|
|
5146
|
-
|
|
5147
|
-
function rowClass(kind: Row['kind']): string {
|
|
5148
|
-
switch (kind) {
|
|
5149
|
-
case 'add': return css.lineAdd
|
|
5150
|
-
case 'del': return css.lineDel
|
|
5151
|
-
case 'hunk': return css.lineHunk
|
|
5152
|
-
default: return css.lineContext
|
|
5153
|
-
}
|
|
5154
|
-
}
|
|
5155
|
-
|
|
5156
|
-
function renderCode(row: RowWithRanges, tokens: readonly HighlightRun[]): ReactNode {
|
|
5157
|
-
if (row.kind === 'hunk') return row.text
|
|
5158
|
-
const painted = overlayRanges(tokens.length > 0 ? tokens : [{ text: row.text }], row.ranges ?? [])
|
|
5159
|
-
if (painted.length === 1 && painted[0]!.color === undefined && !painted[0]!.mark) return row.text
|
|
5160
|
-
return painted.map((tok, i) => (
|
|
5161
|
-
<span
|
|
5162
|
-
key={i}
|
|
5163
|
-
className={tok.mark ? (row.kind === 'add' ? css.wordAdd : css.wordDel) : undefined}
|
|
5164
|
-
style={tok.color === undefined && !tok.italic ? undefined : { color: tok.color, fontStyle: tok.italic ? 'italic' : undefined }}
|
|
5165
|
-
>{tok.text}</span>
|
|
5166
|
-
))
|
|
5167
|
-
}
|
|
5168
|
-
|
|
5169
|
-
/* ---------- side-by-side diff rendering (working tree only) ---------- */
|
|
5170
|
-
|
|
5171
|
-
/**
|
|
5172
|
-
* The working tree's per-file diff as IDEA shows it: one tab per layer of the
|
|
5173
|
-
* index, two columns with the whole file, aligned row by row.
|
|
5174
|
-
*
|
|
5175
|
-
* The rows come from `side-rows.ts` over the layer's full-context diff, so the
|
|
5176
|
-
* alignment is read off the diff rather than computed. A change block — a
|
|
5177
|
-
* maximal run of changed rows — carries its own actions: hovering any of its
|
|
5178
|
-
* cells outlines the whole block and floats its buttons (stage + roll back on
|
|
5179
|
-
* the unstaged tab, unstage on the staged one). The click carries the block's
|
|
5180
|
-
* hunk-line indices and the rendered diff's sha, so the host can prove the
|
|
5181
|
-
* file has not changed since the pane drew it.
|
|
5182
|
-
*
|
|
5183
|
-
* The unstaged tab's right column is also EDITABLE (the staged one is not, by
|
|
5184
|
-
* design: editing the index would mean writing a blob with no file behind it).
|
|
5185
|
-
* Editing arms explicitly — never per keystroke — and the buffer's whole life
|
|
5186
|
-
* against the file and the poll is `side-edit.ts`'s to decide: a refresh over
|
|
5187
|
-
* a dirty buffer keeps the buffer, a file that moved underneath raises the
|
|
5188
|
-
* reload-or-overwrite banner, and the one save path carries the sha the buffer
|
|
5189
|
-
* is based on so the host can refuse a stale write. While editing, the layout
|
|
5190
|
-
* trades the diff's hole-aligned grid for a dense editor column (same
|
|
5191
|
-
* metrics, same gutter rhythm): one grid cannot stay diff-aligned AND hold a
|
|
5192
|
-
* dense buffer whenever deletions outrun additions, and re-diffing per
|
|
5193
|
-
* keystroke is exactly the editor-library work the first cut declines.
|
|
5194
|
-
*
|
|
5195
|
-
* `tooLarge` and `binary` fall back to the unified view the pane already had
|
|
5196
|
-
* (history and compare keep it unconditionally), with a notice — a silently
|
|
5197
|
-
* different view reads as a broken one, not a guarded one.
|
|
5198
|
-
*/
|
|
5199
|
-
function SideBySideView({ t, path, palette, statsPath, fetchSides, writeChecked, scopeKey, gen, fallbackSegment, fallbackLoading, onBlockAction, onSaved, onDirtyChange }: {
|
|
5200
|
-
t: Translate
|
|
5201
|
-
path: string
|
|
5202
|
-
palette: string
|
|
5203
|
-
statsPath: string | undefined
|
|
5204
|
-
fetchSides: (worktreePath: string | undefined, path: string, layer: SideLayer, signal: AbortSignal) => Promise<FileSides | null>
|
|
5205
|
-
/** Save the editor buffer; the host refuses a stale sha and nothing is written. */
|
|
5206
|
-
writeChecked: (worktreePath: string | undefined, path: string, text: string, expectedSha: string, signal: AbortSignal) => Promise<WriteResult | null>
|
|
5207
|
-
/** Names the view the fetch belongs to, as `viewKey` does for the diff cache. */
|
|
5208
|
-
scopeKey: string
|
|
5209
|
-
/** Refresh generation: a new one means the tree was re-read, so refetch. */
|
|
5210
|
-
gen: number
|
|
5211
|
-
/** The drawer's polled view of this file's HEAD-diff; a CHANGE in it means
|
|
5212
|
-
* the drawer noticed the file move, so the pane refetches even between
|
|
5213
|
-
* refresh generations — this is how the poll reaches a dirty buffer. */
|
|
5214
|
-
fallbackSegment: string
|
|
5215
|
-
fallbackLoading: boolean
|
|
5216
|
-
/** Run one block action; a discard routes to the drawer's confirmation. */
|
|
5217
|
-
onBlockAction: (mode: BlockMode, ask: BlockAsk) => Promise<GitOpResult>
|
|
5218
|
-
/** After a successful save: refresh the tree and the pane together. */
|
|
5219
|
-
onSaved: () => void
|
|
5220
|
-
/** Reports the buffer's dirty flag outward: the drawer guards every
|
|
5221
|
-
* gesture that would drop the buffer (file selection, close, main tab)
|
|
5222
|
-
* on it, so it must live where those gestures are handled. */
|
|
5223
|
-
onDirtyChange: (dirty: boolean) => void
|
|
5224
|
-
}): ReactNode {
|
|
5225
|
-
const [layer, setLayer] = useState<SideLayer>('unstaged')
|
|
5226
|
-
// How much of the pane the left column gets. Lives here rather than in the
|
|
5227
|
-
// drawer so it is one setting for the pane, and survives a file switch —
|
|
5228
|
-
// the reader sized the columns for how they read, not for one file.
|
|
5229
|
-
const [split, setSplit] = useState(0.5)
|
|
5230
|
-
const colsRef = useRef<HTMLDivElement>(null)
|
|
5231
|
-
/** The pane's one vertical scroller — what "next change" moves. */
|
|
5232
|
-
const scrollRef = useRef<HTMLDivElement>(null)
|
|
5233
|
-
/** Where the rows are, filled in below once they exist. A ref, because the
|
|
5234
|
-
* walk is set up here and the rows are decided further down; reading it
|
|
5235
|
-
* only when a key is pressed is what lets the two live apart. */
|
|
5236
|
-
const rowsForNav = useRef<readonly number[]>([])
|
|
5237
|
-
const { goToChange } = useChangeNav(
|
|
5238
|
-
scrollRef,
|
|
5239
|
-
// Derived, not measured: the pane renders only the rows near the viewport
|
|
5240
|
-
// now, so the block being walked to usually has no element at all.
|
|
5241
|
-
useCallback(() => blockTopsFromRows(rowsForNav.current, DIFF_ROW_H, DIFF_GRID_PAD_TOP), []),
|
|
5242
|
-
)
|
|
5243
|
-
|
|
5244
|
-
const [sides, setSides] = useState<FileSides | null>(null)
|
|
5245
|
-
// Set when the RPC itself failed — most plausibly a host half older than
|
|
5246
|
-
// this client (the two halves reload on different cycles). The unified view
|
|
5247
|
-
// still renders, so an old host costs the new pane, not the diff.
|
|
5248
|
-
const [failed, setFailed] = useState(false)
|
|
5249
|
-
// The block under the pointer, or null over context rows and gutters. Hover
|
|
5250
|
-
// names the BLOCK, not the cell: the outline and the buttons belong to a
|
|
5251
|
-
// whole run of rows, and a per-cell affordance would scatter them.
|
|
5252
|
-
const [hotBlock, setHotBlock] = useState<number | null>(null)
|
|
5253
|
-
// The block whose stage/unstage call is in flight, disabling its buttons.
|
|
5254
|
-
const [pendingBlock, setPendingBlock] = useState<number | null>(null)
|
|
5255
|
-
// The editable right column's state and its one save path. `saving` disables
|
|
5256
|
-
// the controls for the call's duration; `saveFailed` carries a non-stale
|
|
5257
|
-
// failure's sentence (the stale case is `edit.conflict`'s banner instead).
|
|
5258
|
-
const [edit, setEdit] = useState<EditState>(DISARMED)
|
|
5259
|
-
const [saving, setSaving] = useState(false)
|
|
5260
|
-
const [saveFailed, setSaveFailed] = useState<{ title: string; detail: string } | null>(null)
|
|
5261
|
-
// The layer a dirty-buffer tab switch is waiting on the reader to confirm.
|
|
5262
|
-
const [pendingLayer, setPendingLayer] = useState<SideLayer | null>(null)
|
|
5263
|
-
// Internal fetch generation: a stale save or the banner's reload refetch
|
|
5264
|
-
// without waiting for the drawer's next refresh.
|
|
5265
|
-
const [refetch, setRefetch] = useState(0)
|
|
5266
|
-
// Which edit session the fetched payload belongs to, and what a landing
|
|
5267
|
-
// payload may do with the buffer. The ref is read inside the fetch callback
|
|
5268
|
-
// (which closes over a render that may be several states old by the time the
|
|
5269
|
-
// answer arrives), and the adopt mode is consumed once by the next run.
|
|
5270
|
-
const idRef = useRef('')
|
|
5271
|
-
const adoptRef = useRef<'auto' | 'reload'>('auto')
|
|
5272
|
-
const editRef = useRef(edit)
|
|
5273
|
-
editRef.current = edit
|
|
5274
|
-
|
|
5275
|
-
// Switching tabs refetches: the two layers are different diffs of the same
|
|
5276
|
-
// file, and neither is a transform of the other client-side. A change in the
|
|
5277
|
-
// drawer's polled segment for this file refetches too — the poll's way of
|
|
5278
|
-
// saying the file moved — which is what lets a change under a DIRTY buffer
|
|
5279
|
-
// raise the banner within one poll interval instead of at the next refresh.
|
|
5280
|
-
//
|
|
5281
|
-
// Only a NEW file/layer/scope may blank the pane and disarm the editor; a
|
|
5282
|
-
// refetch of the same identity keeps the current payload on screen until the
|
|
5283
|
-
// answer lands, because blanking it would unmount the editor mid-keystroke.
|
|
5284
|
-
useEffect(() => {
|
|
5285
|
-
const id = `${scopeKey}\x1f${path}\x1f${layer}`
|
|
5286
|
-
const identityChanged = idRef.current !== id
|
|
5287
|
-
if (identityChanged) idRef.current = id
|
|
5288
|
-
const adopt = identityChanged ? 'reset' : adoptRef.current
|
|
5289
|
-
adoptRef.current = 'auto'
|
|
5290
|
-
const ctrl = new AbortController()
|
|
5291
|
-
let alive = true
|
|
5292
|
-
if (identityChanged) {
|
|
5293
|
-
setSides(null)
|
|
5294
|
-
setSaveFailed(null)
|
|
5295
|
-
setPendingLayer(null)
|
|
5296
|
-
}
|
|
5297
|
-
if (identityChanged || !editRef.current.armed) setFailed(false)
|
|
5298
|
-
fetchSides(statsPath, path, layer, ctrl.signal)
|
|
5299
|
-
.then(value => {
|
|
5300
|
-
if (!alive) return
|
|
5301
|
-
// While armed, a failed background refetch keeps the pane as it is:
|
|
5302
|
-
// dropping the editor over a transient RPC failure would cost the
|
|
5303
|
-
// buffer's DOM (focus, IME composition) for no reader benefit.
|
|
5304
|
-
if (value === null) {
|
|
5305
|
-
if (!editRef.current.armed) setFailed(true)
|
|
5306
|
-
return
|
|
5307
|
-
}
|
|
5308
|
-
setSides(value)
|
|
5309
|
-
setEdit(prev => adopt === 'reset' ? resetSides(prev, value)
|
|
5310
|
-
: adopt === 'reload' ? reloadSides(prev, value)
|
|
5311
|
-
: applySides(prev, value))
|
|
5312
|
-
})
|
|
5313
|
-
.catch(() => { if (alive && !editRef.current.armed) setFailed(true) })
|
|
5314
|
-
return () => { alive = false; ctrl.abort() }
|
|
5315
|
-
}, [fetchSides, statsPath, path, layer, scopeKey, gen, fallbackSegment, refetch])
|
|
5316
|
-
|
|
5317
|
-
const lang = shikiLangOf(path)
|
|
5318
|
-
const shikiTheme = shikiThemeOf(palette)
|
|
5319
|
-
const grammarGen = useSyncExternalStore(subscribeGrammarLoaded, grammarLoadCount)
|
|
5320
|
-
const rows = useMemo(() => {
|
|
5321
|
-
if (sides === null || sides.diff.length === 0) return []
|
|
5322
|
-
const file = parsePatch(sides.diff)
|
|
5323
|
-
// A diff with no hunk (mode-only change, or text patch-model cannot parse)
|
|
5324
|
-
// has no rows to align; the no-change treatment below is the honest view.
|
|
5325
|
-
return file === null ? [] : alignRows(file)
|
|
5326
|
-
}, [sides])
|
|
5327
|
-
// Highlight each column as one file — a row is not a program, and lexing
|
|
5328
|
-
// fragments is what made the unified view paint keywords as plain text.
|
|
5329
|
-
//
|
|
5330
|
-
// These are UNDEFINED until a lazy grammar loads, and stay undefined for a
|
|
5331
|
-
// file whose extension has no grammar at all (`go.mod`, `Dockerfile`), so
|
|
5332
|
-
// every read below is optional-chained. `renderSideCode` already takes
|
|
5333
|
-
// `undefined` and renders the plain text for it; what crashes is indexing
|
|
5334
|
-
// the array itself, and `strict` is off in tsconfig, so the compiler will
|
|
5335
|
-
// not say so.
|
|
5336
|
-
// Only the rows the reader can see reach the DOM, and only they are re-lexed
|
|
5337
|
-
// line by line. Declared here because both the render and the highlighting
|
|
5338
|
-
// below are bounded by it.
|
|
5339
|
-
const win = useRowWindow(scrollRef, rows.length)
|
|
5340
|
-
//
|
|
5341
|
-
// Two passes with two lifetimes. The whole-file pass runs once per file and
|
|
5342
|
-
// is what knows about block comments and template literals; the per-line
|
|
5343
|
-
// re-lex — one Shiki call each, and the reason a 4,000-line file froze the
|
|
5344
|
-
// pane for 2.8 seconds — runs only over the rows in the window, and so again
|
|
5345
|
-
// whenever the reader scrolls.
|
|
5346
|
-
const leftLines = useMemo(() => rows.map(row => row.left === null ? '' : row.left.text), [rows])
|
|
5347
|
-
const rightLines = useMemo(() => rows.map(row => row.right === null ? '' : row.right.text), [rows])
|
|
5348
|
-
const leftSyntax = useMemo(
|
|
5349
|
-
() => highlightWindow(leftLines, lang, shikiTheme, win.start, win.end),
|
|
5350
|
-
[leftLines, lang, shikiTheme, win.start, win.end, grammarGen],
|
|
5351
|
-
)
|
|
5352
|
-
const rightSyntax = useMemo(
|
|
5353
|
-
() => highlightWindow(rightLines, lang, shikiTheme, win.start, win.end),
|
|
5354
|
-
[rightLines, lang, shikiTheme, win.start, win.end, grammarGen],
|
|
5355
|
-
)
|
|
5356
|
-
|
|
5357
|
-
/** The editor half of the pane, present only on the unstaged layer. */
|
|
5358
|
-
const editable = layer === 'unstaged' && edit.armed
|
|
5359
|
-
const dirty = isDirty(edit)
|
|
5360
|
-
// Whether this payload may enter the editor at all: text carrying \r would
|
|
5361
|
-
// be normalised to \n by the textarea the moment it landed, and the next
|
|
5362
|
-
// save would rewrite every line ending in the file. The gate lives in
|
|
5363
|
-
// `side-edit.ts` with the rest of the buffer's rules.
|
|
5364
|
-
const armable = sides !== null && editableSides(sides)
|
|
5365
|
-
// Which sentence the withheld editor gets: CRLF and a non-UTF-8 encoding are
|
|
5366
|
-
// different problems, and one message for both leaves the reader guessing
|
|
5367
|
-
// whether converting line endings would help.
|
|
5368
|
-
const refusal = sides === null ? null : armRefusal(sides)
|
|
5369
|
-
|
|
5370
|
-
|
|
5371
|
-
// The drawer guards every gesture that would drop the buffer — selecting
|
|
5372
|
-
// another file, closing, switching the main tab — so it needs the flag as
|
|
5373
|
-
// it changes, not at click time from a stale render. Reported on the FLAG
|
|
5374
|
-
// (not the buffer) so it fires on the transitions that matter; the cleanup
|
|
5375
|
-
// clears it when this pane unmounts, so no orphaned flag prompts later.
|
|
5376
|
-
useEffect(() => {
|
|
5377
|
-
onDirtyChange(dirty)
|
|
5378
|
-
return () => { onDirtyChange(false) }
|
|
5379
|
-
}, [dirty, onDirtyChange])
|
|
5380
|
-
// The buffer's lines and their highlight, for the editor's underlay: the
|
|
5381
|
-
// visible text under the transparent textarea, which is what keeps syntax
|
|
5382
|
-
// coloring and the caret on the same grid while typing.
|
|
5383
|
-
const bufferLines = useMemo(() => edit.buffer.split('\n'), [edit.buffer])
|
|
5384
|
-
// What Tab inserts, learned from the file rather than configured. Keyed on
|
|
5385
|
-
// the BASE text, not the buffer: re-detecting mid-edit would let a couple of
|
|
5386
|
-
// freshly typed lines redefine the unit under the reader's hands.
|
|
5387
|
-
const indentOfBuffer = useMemo(() => detectIndent(edit.baseText), [edit.baseText])
|
|
5388
|
-
// The index side as one text, which is what the editor tints against while
|
|
5389
|
-
// the reader types. It is the diff's own left column joined back up — every
|
|
5390
|
-
// row of a full-context diff carries a left cell unless the line is an
|
|
5391
|
-
// addition, which by definition is not on that side.
|
|
5392
|
-
const indexText = useMemo(() => {
|
|
5393
|
-
const left = rows.filter(row => row.left !== null).map(row => row.left!.text)
|
|
5394
|
-
return left.length === 0 ? '' : left.join('\n') + '\n'
|
|
5395
|
-
}, [rows])
|
|
5396
|
-
// The editor's buffer is a whole file, so it takes the file pass rather than
|
|
5397
|
-
// the diff's per-line re-lex — but only over the lines it is showing. The
|
|
5398
|
-
// editor asks for a range as it scrolls, and `token-cache.ts` remembers what
|
|
5399
|
-
// came back; a pass over the whole buffer was 1,637ms at 1,837 lines, paid
|
|
5400
|
-
// again every time the reader stopped typing.
|
|
5401
|
-
const editPaint = useMemo<PaintFn | null>(() => {
|
|
5402
|
-
if (lang === undefined) return null
|
|
5403
|
-
const key = 'buffer:' + statsPath + ':' + path
|
|
5404
|
-
return (lines, from, to) => highlightRange(key, lines, lang, shikiTheme, from, to)
|
|
5405
|
-
// `grammarGen` changes nothing computed here; the new identity is what
|
|
5406
|
-
// makes the editor repaint once a lazy grammar has landed.
|
|
5407
|
-
}, [lang, shikiTheme, statsPath, path, grammarGen])
|
|
5408
|
-
// The left column while editing renders dense — one row per INDEX line, no
|
|
5409
|
-
// holes — because the right column is now the dense buffer; a hole-aligned
|
|
5410
|
-
// left beside a dense right is the alignment the diff view owes, not the
|
|
5411
|
-
// editor. Each entry keeps its index into `rows` for its syntax tokens.
|
|
5412
|
-
const leftRows = useMemo(() => rows.map((row, i) => ({ row, i })).filter(entry => entry.row.left !== null), [rows])
|
|
5413
|
-
|
|
5414
|
-
// Only the rows the reader can see reach the DOM. Two windows because the
|
|
5415
|
-
// two columns render two different row lists while the editor is armed: the
|
|
5416
|
-
// right side is a buffer, and the left side is then the index side DENSE,
|
|
5417
|
-
// one row per index line rather than one per aligned row.
|
|
5418
|
-
const leftWin = useRowWindow(scrollRef, leftRows.length)
|
|
5419
|
-
// Kept current for the change walk set up at the top of this component.
|
|
5420
|
-
rowsForNav.current = useMemo(() => rows.map(row => row.block), [rows])
|
|
5421
|
-
|
|
5422
|
-
// Arming drops the caret straight into the buffer: the click that armed the
|
|
5423
|
-
// editor said "I want to type here", and a second click to focus is a tax.
|
|
5424
|
-
|
|
5425
|
-
/** Arm the editor from the payload on screen; the unstaged tab, and only
|
|
5426
|
-
* for a payload `editableSides` accepts — armEdit itself refuses the rest,
|
|
5427
|
-
* so even a stray call cannot put CRLF text into the buffer. */
|
|
5428
|
-
const arm = (): void => {
|
|
5429
|
-
if (sides === null || layer !== 'unstaged' || !editableSides(sides)) return
|
|
5430
|
-
setEdit(prev => armEdit(prev, sides))
|
|
5431
|
-
}
|
|
5432
|
-
|
|
5433
|
-
/**
|
|
5434
|
-
* The one save path, shared by the Save button, Ctrl/Cmd+S and the banner's
|
|
5435
|
-
* overwrite action — they differ only in WHICH sha the host is asked to
|
|
5436
|
-
* check: the buffer's basis for a save, the file as it stands NOW for an
|
|
5437
|
-
* explicit overwrite of a concurrent writer's version.
|
|
5438
|
-
*
|
|
5439
|
-
* On success the basis moves to the sha the host read back and the drawer
|
|
5440
|
-
* refreshes (tree and pane together). On `stale` the banner goes up and the
|
|
5441
|
-
* pane refetches WITHOUT touching the buffer, so the banner's reload and
|
|
5442
|
-
* overwrite actions read the file's true current state. Everything else is
|
|
5443
|
-
* a failed save with a sentence.
|
|
5444
|
-
*/
|
|
5445
|
-
const runSave = async (expectedSha: string): Promise<void> => {
|
|
5446
|
-
if (sides === null || !dirty || saving) return
|
|
5447
|
-
const savedText = edit.buffer
|
|
5448
|
-
// The edit session this save belongs to. A slow RPC can outlive a file or
|
|
5449
|
-
// layer switch, and applying THIS save's outcome to the NEXT file's edit
|
|
5450
|
-
// state would re-base that buffer onto text it never held — so every
|
|
5451
|
-
// pane-local effect below is gated on the session still being current.
|
|
5452
|
-
// The tree refresh on success is not: the file on disk did move.
|
|
5453
|
-
const session = idRef.current
|
|
5454
|
-
setSaving(true)
|
|
5455
|
-
try {
|
|
5456
|
-
const result = await writeChecked(statsPath, path, savedText, expectedSha, new AbortController().signal)
|
|
5457
|
-
const stillHere = idRef.current === session
|
|
5458
|
-
if (result === null) {
|
|
5459
|
-
if (stillHere) setSaveFailed({ title: t('saveUnavailable'), detail: '' })
|
|
5460
|
-
} else if (result.ok) {
|
|
5461
|
-
if (stillHere) {
|
|
5462
|
-
setSaveFailed(null)
|
|
5463
|
-
setEdit(prev => applySaveOk(prev, savedText, result.sha ?? ''))
|
|
5464
|
-
}
|
|
5465
|
-
onSaved()
|
|
5466
|
-
} else if (result.failure === 'stale') {
|
|
5467
|
-
if (stillHere) {
|
|
5468
|
-
setEdit(prev => markConflict(prev))
|
|
5469
|
-
setRefetch(n => n + 1)
|
|
5470
|
-
}
|
|
5471
|
-
} else {
|
|
5472
|
-
if (stillHere) setSaveFailed({ title: t('saveFailed'), detail: (result.error ?? '').trim() })
|
|
5473
|
-
}
|
|
5474
|
-
} finally {
|
|
5475
|
-
setSaving(false)
|
|
5476
|
-
}
|
|
5477
|
-
}
|
|
5478
|
-
|
|
5479
|
-
/**
|
|
5480
|
-
* The banner's two answers to a file that moved underneath. Overwrite may
|
|
5481
|
-
* only run once the post-refusal refetch has landed (the fresh targetSha is
|
|
5482
|
-
* what the host checks the overwrite against); until then the button waits,
|
|
5483
|
-
* because re-sending the refused sha would just refuse again.
|
|
5484
|
-
*/
|
|
5485
|
-
const canOverwrite = dirty && edit.conflict && sides !== null && sides.targetSha !== edit.baseSha
|
|
5486
|
-
const overwrite = (): Promise<void> => sides === null ? Promise.resolve() : runSave(sides.targetSha)
|
|
5487
|
-
/** Reload: the reader chose the file over the buffer; drop the edits. */
|
|
5488
|
-
const reload = (): void => {
|
|
5489
|
-
setSaveFailed(null)
|
|
5490
|
-
adoptRef.current = 'reload'
|
|
5491
|
-
setRefetch(n => n + 1)
|
|
5492
|
-
}
|
|
5493
|
-
/** Revert the buffer to its basis, in place; the conflict flag stands. */
|
|
5494
|
-
const revert = (): void => {
|
|
5495
|
-
setSaveFailed(null)
|
|
5496
|
-
setEdit(prev => ({ ...prev, buffer: prev.baseText }))
|
|
5497
|
-
}
|
|
5498
|
-
|
|
5499
|
-
/**
|
|
5500
|
-
* A layer tab is one click away from dropping the buffer: with unsaved
|
|
5501
|
-
* edits the click asks first, and only the dialog's answer switches.
|
|
5502
|
-
*/
|
|
5503
|
-
const switchLayer = (next: SideLayer): void => {
|
|
5504
|
-
if (next === layer) return
|
|
5505
|
-
if (dirty) {
|
|
5506
|
-
setPendingLayer(next)
|
|
5507
|
-
return
|
|
5508
|
-
}
|
|
5509
|
-
setLayer(next)
|
|
5510
|
-
}
|
|
5511
|
-
const confirmLeave = (): void => {
|
|
5512
|
-
const next = pendingLayer
|
|
5513
|
-
setPendingLayer(null)
|
|
5514
|
-
if (next !== null) setLayer(next)
|
|
5515
|
-
}
|
|
5516
|
-
|
|
5517
|
-
/** Ctrl/Cmd+S inside the pane: the editor's other save affordance. */
|
|
5518
|
-
const onPaneKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>): void => {
|
|
5519
|
-
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 's') {
|
|
5520
|
-
event.preventDefault()
|
|
5521
|
-
if (dirty && !saving) void runSave(edit.baseSha)
|
|
5522
|
-
}
|
|
5523
|
-
// F7 and Shift+F7, the spelling IDEA's diff viewer taught. Chosen over
|
|
5524
|
-
// Alt+Arrow because CodeMirror's default keymap binds those to moving a
|
|
5525
|
-
// line, and the armed editor lives inside this same pane.
|
|
5526
|
-
if (event.key === 'F7') {
|
|
5527
|
-
event.preventDefault()
|
|
5528
|
-
goToChange(event.shiftKey ? -1 : 1)
|
|
5529
|
-
}
|
|
5530
|
-
}
|
|
5531
|
-
|
|
5532
|
-
/**
|
|
5533
|
-
* One bubbling hover listener turns the cell under the pointer into its
|
|
5534
|
-
* block id: every changed row's code cells carry `data-block`, so `closest`
|
|
5535
|
-
* reads the block off whatever the pointer is over — no handler per cell,
|
|
5536
|
-
* and a pointer over context or a gutter simply clears the hot block.
|
|
5537
|
-
*/
|
|
5538
|
-
/**
|
|
5539
|
-
* The divider: a ratio, not a pixel width, so the columns keep their
|
|
5540
|
-
* proportion when the drawer itself is resized.
|
|
5541
|
-
*
|
|
5542
|
-
* Clamped well short of either edge — a column dragged to nothing looks
|
|
5543
|
-
* like a broken pane, and there is no affordance to drag it back out of.
|
|
5544
|
-
*/
|
|
5545
|
-
const onSplitDrag = (clientX: number): void => {
|
|
5546
|
-
const box = colsRef.current?.getBoundingClientRect()
|
|
5547
|
-
if (box === undefined || box.width === 0) return
|
|
5548
|
-
const ratio = (clientX - box.left) / box.width
|
|
5549
|
-
setSplit(Math.min(SPLIT_MAX, Math.max(SPLIT_MIN, ratio)))
|
|
5550
|
-
}
|
|
5551
|
-
|
|
5552
|
-
const onBodyHover = (event: ReactMouseEvent<HTMLDivElement>): void => {
|
|
5553
|
-
const hit = (event.target as Element).closest('[data-block]')
|
|
5554
|
-
const id = hit === null ? null : Number(hit.getAttribute('data-block'))
|
|
5555
|
-
setHotBlock(prev => (prev === id ? prev : id))
|
|
5556
|
-
}
|
|
5557
|
-
|
|
5558
|
-
/**
|
|
5559
|
-
* Run one block action with the coordinates of the diff on screen.
|
|
5560
|
-
*
|
|
5561
|
-
* Discard never acts from the click — the drawer opens the confirmation,
|
|
5562
|
-
* and the confirmed call carries this same snapshot, so a file that moved
|
|
5563
|
-
* underneath the dialog is refused host-side rather than re-derived from
|
|
5564
|
-
* whatever the poll has fetched since. Stage and unstage run now; the
|
|
5565
|
-
* clicked block's buttons stay disabled until the answer lands, and the
|
|
5566
|
-
* drawer's op lock refuses any other block click meanwhile.
|
|
5567
|
-
*/
|
|
5568
|
-
const runBlock = async (mode: BlockMode, block: number): Promise<void> => {
|
|
5569
|
-
if (sides === null) return
|
|
5570
|
-
const ask: BlockAsk = {
|
|
5571
|
-
path, layer, diffSha: sides.diffSha,
|
|
5572
|
-
lines: blockLines(rows, block),
|
|
5573
|
-
...blockTally(rows, block),
|
|
5574
|
-
wholeFile: blockIsWholeFile(rows, block),
|
|
5575
|
-
}
|
|
5576
|
-
if (mode === 'discard') {
|
|
5577
|
-
void onBlockAction(mode, ask)
|
|
5578
|
-
return
|
|
5579
|
-
}
|
|
5580
|
-
setPendingBlock(block)
|
|
5581
|
-
try {
|
|
5582
|
-
await onBlockAction(mode, ask)
|
|
5583
|
-
} finally {
|
|
5584
|
-
setPendingBlock(null)
|
|
5585
|
-
}
|
|
5586
|
-
}
|
|
5587
|
-
|
|
5588
|
-
/** The pane the drawer had before this view existed, notice included. */
|
|
5589
|
-
const unifiedFallback = (): ReactNode => fallbackSegment.length > 0
|
|
5590
|
-
? <DiffView segment={fallbackSegment} path={path} palette={palette} t={t} />
|
|
5591
|
-
: <div className={css.empty}>{fallbackLoading ? t('loadingDiff') : t('noTextDiff')}</div>
|
|
5592
|
-
|
|
5593
|
-
if (failed) return unifiedFallback()
|
|
5594
|
-
if (sides === null) return <div className={css.empty}>{t('loadingDiff')}</div>
|
|
5595
|
-
if (sides.binary) return <div className={css.empty}>{t('binaryFile')}</div>
|
|
5596
|
-
if (sides.tooLarge) {
|
|
5597
|
-
return (
|
|
5598
|
-
<>
|
|
5599
|
-
<div className={css.sideNotice}>{t('diffTooLarge')}</div>
|
|
5600
|
-
{unifiedFallback()}
|
|
5601
|
-
</>
|
|
5602
|
-
)
|
|
5603
|
-
}
|
|
5604
|
-
// The tabs are the pane's, not one layer's: an empty diff here (a fully
|
|
5605
|
-
// staged file's unstaged side, a file with nothing staged) is one click from
|
|
5606
|
-
// the other layer, and the Edit button still arms — the working tree is the
|
|
5607
|
-
// edit target even when every change in it is already staged. So the
|
|
5608
|
-
// no-change treatment below is a state of the BODY (`sideBodyState`), never
|
|
5609
|
-
// an early return for the pane: returning here is what used to blank the
|
|
5610
|
-
// tabs for exactly these files.
|
|
5611
|
-
const bodyState = sideBodyState(rows, editable)
|
|
5612
|
-
/**
|
|
5613
|
-
* How many separate places this file changed — the count the nav walks.
|
|
5614
|
-
*
|
|
5615
|
-
* Offered only for the read view. While armed, the left column is the index
|
|
5616
|
-
* side rendered DENSE and the right one is a buffer whose line count has
|
|
5617
|
-
* already diverged from it, so scrolling to a block would put the change at
|
|
5618
|
-
* the top of one column and unrelated code at the top of the other.
|
|
5619
|
-
*/
|
|
5620
|
-
const changes = bodyState.kind === 'rows' ? blockCount(rows) : 0
|
|
5621
|
-
// The hovered block's first row hosts the action bar; a del-only block has
|
|
5622
|
-
// no right cell, so its bar rides the left one instead. In the editor
|
|
5623
|
-
// layout the left column is dense, so the bar rides its first left row.
|
|
5624
|
-
const hotFirst = hotBlock === null ? -1 : rows.findIndex(row => row.block === hotBlock)
|
|
5625
|
-
const hotFirstLeft = hotBlock === null ? -1 : leftRows.findIndex(entry => entry.row.block === hotBlock)
|
|
5626
|
-
// Dirty buffer, no block actions: a patch computed from the loaded diff
|
|
5627
|
-
// would land on top of edits the patch knows nothing about.
|
|
5628
|
-
const barDisabled = pendingBlock !== null || dirty
|
|
5629
|
-
const blockBar = (block: number): ReactNode => (
|
|
5630
|
-
<span className={css.blockBar}>
|
|
5631
|
-
{layer === 'staged' ? (
|
|
5632
|
-
<button
|
|
5633
|
-
type="button"
|
|
5634
|
-
className={css.blockBtn}
|
|
5635
|
-
disabled={barDisabled}
|
|
5636
|
-
onClick={() => { void runBlock('unstage', block) }}
|
|
5637
|
-
>{t('blockUnstage')}</button>
|
|
5638
|
-
) : (
|
|
5639
|
-
<>
|
|
5640
|
-
<button
|
|
5641
|
-
type="button"
|
|
5642
|
-
className={css.blockBtn}
|
|
5643
|
-
disabled={barDisabled}
|
|
5644
|
-
onClick={() => { void runBlock('stage', block) }}
|
|
5645
|
-
>{t('blockStage')}</button>
|
|
5646
|
-
<button
|
|
5647
|
-
type="button"
|
|
5648
|
-
className={`${css.blockBtn} ${css.blockBtnDanger}`}
|
|
5649
|
-
disabled={barDisabled}
|
|
5650
|
-
onClick={() => { void runBlock('discard', block) }}
|
|
5651
|
-
>{t('blockDiscard')}</button>
|
|
5652
|
-
</>
|
|
5653
|
-
)}
|
|
5654
|
-
</span>
|
|
5655
|
-
)
|
|
5656
|
-
/** Clicking the working-tree column is the arm gesture readers will try
|
|
5657
|
-
* first — unless the click was really a text selection, or landed on a
|
|
5658
|
-
* block button, in which case it keeps its own meaning. */
|
|
5659
|
-
const armFromCell = (event: ReactMouseEvent<HTMLSpanElement>): void => {
|
|
5660
|
-
if (edit.armed) return
|
|
5661
|
-
if ((event.target as Element).closest('button') !== null) return
|
|
5662
|
-
const selection = window.getSelection()
|
|
5663
|
-
if (selection !== null && !selection.isCollapsed) return
|
|
5664
|
-
arm()
|
|
5665
|
-
}
|
|
5666
|
-
return (
|
|
5667
|
-
/* `tabIndex={-1}` is what makes F7 reachable. The handler below is on
|
|
5668
|
-
this element, so it only sees keys whose target is inside it — and
|
|
5669
|
-
clicking diff text, which is not focusable, otherwise leaves focus on
|
|
5670
|
-
the document body and the key never arrives. A negative index keeps the
|
|
5671
|
-
pane out of the tab order while letting a click land focus here. */
|
|
5672
|
-
<div className={css.sidePane} tabIndex={-1} onKeyDown={onPaneKeyDown}>
|
|
5673
|
-
<div className={css.sideTabs}>
|
|
5674
|
-
<button
|
|
5675
|
-
type="button"
|
|
5676
|
-
aria-pressed={layer === 'unstaged'}
|
|
5677
|
-
className={layer === 'unstaged' ? `${css.sideTab} ${css.sideTabActive}` : css.sideTab}
|
|
5678
|
-
onClick={() => switchLayer('unstaged')}
|
|
5679
|
-
>{t('tabUnstaged')}</button>
|
|
5680
|
-
<button
|
|
5681
|
-
type="button"
|
|
5682
|
-
aria-pressed={layer === 'staged'}
|
|
5683
|
-
className={layer === 'staged' ? `${css.sideTab} ${css.sideTabActive}` : css.sideTab}
|
|
5684
|
-
onClick={() => switchLayer('staged')}
|
|
5685
|
-
>{t('tabStaged')}</button>
|
|
5686
|
-
{/* A file whose whole delta is one line is unfindable by scrolling:
|
|
5687
|
-
the tint only shows once you are already looking at it. The row
|
|
5688
|
-
model knows where every change is, so these two say so. The count
|
|
5689
|
-
is the other half of the answer — "there is one place to look" is
|
|
5690
|
-
what stops the hunt. */}
|
|
5691
|
-
{changes > 0 ? (
|
|
5692
|
-
<span className={css.sideNav}>
|
|
5693
|
-
<button
|
|
5694
|
-
type="button"
|
|
5695
|
-
className={css.blockBtn}
|
|
5696
|
-
title={t('prevChangeHint')}
|
|
5697
|
-
aria-label={t('prevChange')}
|
|
5698
|
-
onClick={() => { goToChange(-1) }}
|
|
5699
|
-
><NavGlyph of="prev" /></button>
|
|
5700
|
-
<button
|
|
5701
|
-
type="button"
|
|
5702
|
-
className={css.blockBtn}
|
|
5703
|
-
title={t('nextChangeHint')}
|
|
5704
|
-
aria-label={t('nextChange')}
|
|
5705
|
-
onClick={() => { goToChange(1) }}
|
|
5706
|
-
><NavGlyph of="next" /></button>
|
|
5707
|
-
<span className={css.sideNavCount}>{t('changeCount', { n: changes })}</span>
|
|
5708
|
-
</span>
|
|
5709
|
-
) : null}
|
|
5710
|
-
{/* Editing arms explicitly and saves explicitly — the two halves of
|
|
5711
|
-
"never per keystroke". Save enables only while dirty; Revert drops
|
|
5712
|
-
the buffer back onto its basis without touching the file. A
|
|
5713
|
-
payload the CRLF gate refuses offers no Edit button at all — the
|
|
5714
|
-
notice below says why rather than leaving a button that does
|
|
5715
|
-
nothing. */}
|
|
5716
|
-
{layer === 'unstaged' ? (
|
|
5717
|
-
<span className={css.sideActions}>
|
|
5718
|
-
{edit.armed ? (
|
|
5719
|
-
<>
|
|
5720
|
-
<button
|
|
5721
|
-
type="button"
|
|
5722
|
-
className={`${css.blockBtn}${dirty ? ` ${css.sideSaveReady}` : ''}`}
|
|
5723
|
-
disabled={!dirty || saving}
|
|
5724
|
-
onClick={() => { void runSave(edit.baseSha) }}
|
|
5725
|
-
>{t('fileSave')}</button>
|
|
5726
|
-
<button
|
|
5727
|
-
type="button"
|
|
5728
|
-
className={css.blockBtn}
|
|
5729
|
-
disabled={!dirty || saving}
|
|
5730
|
-
onClick={revert}
|
|
5731
|
-
>{t('fileRevert')}</button>
|
|
5732
|
-
</>
|
|
5733
|
-
) : armable ? (
|
|
5734
|
-
<button type="button" className={css.blockBtn} onClick={arm}>{t('editFile')}</button>
|
|
5735
|
-
) : null}
|
|
5736
|
-
</span>
|
|
5737
|
-
) : null}
|
|
5738
|
-
</div>
|
|
5739
|
-
{dirty ? <div className={css.sideNotice}>{t('editingNotice')}</div> : null}
|
|
5740
|
-
{layer === 'unstaged' && refusal !== null && !edit.armed ? <div className={css.sideNotice}>{t(refusal === 'encoding' ? 'encodingNotice' : 'crlfNotice')}</div> : null}
|
|
5741
|
-
{/* §4's row: the file moved underneath a dirty buffer — by the poll's
|
|
5742
|
-
notice or by a refused save — and the reader chooses which version
|
|
5743
|
-
survives. Overwrite waits for the refetch the refusal triggered, so
|
|
5744
|
-
it is checked against the file as it truly stands. */}
|
|
5745
|
-
{dirty && edit.conflict ? (
|
|
5746
|
-
<div className={css.sideBanner} role="alert">
|
|
5747
|
-
<span className={css.sideBannerTitle}>{t('staleTitle')}</span>
|
|
5748
|
-
<span>{t('staleBody')}</span>
|
|
5749
|
-
<span className={css.sideBannerActs}>
|
|
5750
|
-
<button type="button" className={`${css.blockBtn} ${css.blockBtnDanger}`} disabled={saving} onClick={reload}>{t('staleReload')}</button>
|
|
5751
|
-
<button type="button" className={css.blockBtn} disabled={!canOverwrite || saving} onClick={() => { void overwrite() }}>{t('staleOverwrite')}</button>
|
|
5752
|
-
</span>
|
|
5753
|
-
</div>
|
|
5754
|
-
) : null}
|
|
5755
|
-
{saveFailed !== null ? (
|
|
5756
|
-
<div className={css.sideBanner} role="alert">
|
|
5757
|
-
<span className={css.sideBannerTitle}>{saveFailed.title}</span>
|
|
5758
|
-
{saveFailed.detail.length > 0 ? <span>{saveFailed.detail}</span> : null}
|
|
5759
|
-
<span className={css.sideBannerActs}>
|
|
5760
|
-
<button type="button" className={css.blockBtn} disabled={!dirty || saving} onClick={() => { void runSave(edit.baseSha) }}>{t('saveRetry')}</button>
|
|
5761
|
-
</span>
|
|
5762
|
-
</div>
|
|
5763
|
-
) : null}
|
|
5764
|
-
{/* Two columns that scroll sideways independently, with a divider the
|
|
5765
|
-
reader can drag. One grid spanning both sides could not do this: its
|
|
5766
|
-
tracks are sized by the widest line in the file, so a drag moved
|
|
5767
|
-
nothing on exactly the wide files where the space matters. Vertical
|
|
5768
|
-
alignment survives the split because both columns render one row per
|
|
5769
|
-
aligned row at the same line height — the diff decides the rows, the
|
|
5770
|
-
layout only decides how much width each side gets. */}
|
|
5771
|
-
<div ref={scrollRef} className={css.sideScroll}>
|
|
5772
|
-
{bodyState.kind === 'empty' ? (
|
|
5773
|
-
<div className={css.empty}>{t('noTextDiff')}</div>
|
|
5774
|
-
) : (
|
|
5775
|
-
<div
|
|
5776
|
-
ref={colsRef}
|
|
5777
|
-
className={css.sideCols}
|
|
5778
|
-
onMouseOver={onBodyHover}
|
|
5779
|
-
onMouseLeave={() => { setHotBlock(null) }}
|
|
5780
|
-
>
|
|
5781
|
-
<div className={css.sideCol} style={{ flexBasis: `${split * 100}%` }}>
|
|
5782
|
-
<div className={css.sideColGrid}>
|
|
5783
|
-
{bodyState.kind === 'editor' ? (
|
|
5784
|
-
/* While armed the left column renders the index side DENSE —
|
|
5785
|
-
one row per index line, no diff holes — because the right
|
|
5786
|
-
column is a buffer whose line count diverges from the diff
|
|
5787
|
-
the moment a keystroke lands. */
|
|
5788
|
-
<>
|
|
5789
|
-
<RowSpacer height={leftWin.padTop} />
|
|
5790
|
-
{leftRows.slice(leftWin.start, leftWin.end).map((entry, kk) => {
|
|
5791
|
-
const k = leftWin.start + kk
|
|
5792
|
-
const { row, i } = entry
|
|
5793
|
-
const hot = hotBlock !== null && row.block === hotBlock
|
|
5794
|
-
const hotClass = hot ? ` ${css.sideBlockHot}` : ''
|
|
5795
|
-
return (
|
|
5796
|
-
<Fragment key={`l${i}`}>
|
|
5797
|
-
<span className={`${sideNumClass(row, 'left')}${hotClass}`}>{row.left!.line}</span>
|
|
5798
|
-
<span className={`${css.sideCode} ${sideCodeClass(row, 'left')}${hotClass}`} data-block={row.block >= 0 ? row.block : undefined}>
|
|
5799
|
-
{renderSideCode(row.left, leftSyntax?.[i])}
|
|
5800
|
-
{hot && k === hotFirstLeft ? blockBar(row.block) : null}
|
|
5801
|
-
</span>
|
|
5802
|
-
</Fragment>
|
|
5803
|
-
)
|
|
5804
|
-
})}
|
|
5805
|
-
<RowSpacer height={leftWin.padBottom} />
|
|
5806
|
-
</>
|
|
5807
|
-
) : (
|
|
5808
|
-
<>
|
|
5809
|
-
<RowSpacer height={win.padTop} />
|
|
5810
|
-
{rows.slice(win.start, win.end).map((row, k) => {
|
|
5811
|
-
const i = win.start + k
|
|
5812
|
-
const hot = hotBlock !== null && row.block === hotBlock
|
|
5813
|
-
const hotClass = hot ? ` ${css.sideBlockHot}` : ''
|
|
5814
|
-
// The block's action bar rides in this column only for a row
|
|
5815
|
-
// with no right-hand side — a pure deletion, where the right
|
|
5816
|
-
// column has no cell to hang it on.
|
|
5817
|
-
const bar = hot && i === hotFirst && row.right === null ? blockBar(row.block) : null
|
|
5818
|
-
return (
|
|
5819
|
-
<Fragment key={i}>
|
|
5820
|
-
<span className={`${sideNumClass(row, 'left')}${hotClass}`}>{row.left === null ? '' : row.left.line}</span>
|
|
5821
|
-
<span className={`${css.sideCode} ${sideCodeClass(row, 'left')}${hotClass}`} data-block={row.block >= 0 ? row.block : undefined}>
|
|
5822
|
-
{renderSideCode(row.left, leftSyntax?.[i])}
|
|
5823
|
-
{bar}
|
|
5824
|
-
</span>
|
|
5825
|
-
</Fragment>
|
|
5826
|
-
)
|
|
5827
|
-
})}
|
|
5828
|
-
<RowSpacer height={win.padBottom} />
|
|
5829
|
-
</>
|
|
5830
|
-
)}
|
|
5831
|
-
</div>
|
|
5832
|
-
</div>
|
|
5833
|
-
<PaneDivider label={t('resizeSides')} onDrag={onSplitDrag} />
|
|
5834
|
-
<div className={`${css.sideCol} ${css.sideColRight}`}>
|
|
5835
|
-
{bodyState.kind === 'editor' ? (
|
|
5836
|
-
<CodeEditor
|
|
5837
|
-
value={edit.buffer}
|
|
5838
|
-
original={indexText}
|
|
5839
|
-
onChange={next => { setEdit(prev => ({ ...prev, buffer: next })) }}
|
|
5840
|
-
paint={editPaint}
|
|
5841
|
-
indent={indentOfBuffer}
|
|
5842
|
-
ariaLabel={path}
|
|
5843
|
-
onSave={() => { if (dirty && !saving) void runSave(edit.baseSha) }}
|
|
5844
|
-
/>
|
|
5845
|
-
) : (
|
|
5846
|
-
<div className={css.sideColGrid}>
|
|
5847
|
-
<RowSpacer height={win.padTop} />
|
|
5848
|
-
{rows.slice(win.start, win.end).map((row, k) => {
|
|
5849
|
-
const i = win.start + k
|
|
5850
|
-
const hot = hotBlock !== null && row.block === hotBlock
|
|
5851
|
-
const hotClass = hot ? ` ${css.sideBlockHot}` : ''
|
|
5852
|
-
const bar = hot && i === hotFirst && row.right !== null ? blockBar(row.block) : null
|
|
5853
|
-
return (
|
|
5854
|
-
<Fragment key={i}>
|
|
5855
|
-
<span className={`${sideNumClass(row, 'right')}${hotClass}`}>{row.right === null ? '' : row.right.line}</span>
|
|
5856
|
-
<span
|
|
5857
|
-
className={`${css.sideCode} ${sideCodeClass(row, 'right')}${hotClass}${layer === 'unstaged' && armable ? ` ${css.sideArmable}` : ''}`}
|
|
5858
|
-
data-block={row.block >= 0 ? row.block : undefined}
|
|
5859
|
-
onClick={layer === 'unstaged' && armable ? armFromCell : undefined}
|
|
5860
|
-
>
|
|
5861
|
-
{renderSideCode(row.right, rightSyntax?.[i])}
|
|
5862
|
-
{bar}
|
|
5863
|
-
</span>
|
|
5864
|
-
</Fragment>
|
|
5865
|
-
)
|
|
5866
|
-
})}
|
|
5867
|
-
<RowSpacer height={win.padBottom} />
|
|
5868
|
-
</div>
|
|
5869
|
-
)}
|
|
5870
|
-
</div>
|
|
5871
|
-
</div>
|
|
5872
|
-
)}
|
|
5873
|
-
</div>
|
|
5874
|
-
{pendingLayer !== null ? (
|
|
5875
|
-
<LeaveEditsConfirm t={t} path={path} onCancel={() => { setPendingLayer(null) }} onConfirm={confirmLeave} />
|
|
5876
|
-
) : null}
|
|
5877
|
-
</div>
|
|
5878
|
-
)
|
|
5879
|
-
}
|
|
5880
|
-
|
|
5881
|
-
/**
|
|
5882
|
-
* The unsaved-edits guard, rendered at both sites that defer a gesture on the
|
|
5883
|
-
* buffer's answer: the pane's layer-tab switch, and the drawer level (file
|
|
5884
|
-
* selection, main tab, source switch, close) for every gesture that would
|
|
5885
|
-
* drop the buffer. Same reason as the roll-back confirmation — the click it
|
|
5886
|
-
* answers to is one gesture away from losing work. Cancel holds the initial
|
|
5887
|
-
* focus and Escape closes, because the default answer to losing edits is no.
|
|
5888
|
-
*/
|
|
5889
|
-
function LeaveEditsConfirm({ t, path, onCancel, onConfirm }: {
|
|
5890
|
-
t: Translate
|
|
5891
|
-
path: string
|
|
5892
|
-
onCancel: () => void
|
|
5893
|
-
onConfirm: () => void
|
|
5894
|
-
}): ReactNode {
|
|
5895
|
-
const stayRef = useRef<HTMLButtonElement>(null)
|
|
5896
|
-
useEffect(() => { stayRef.current?.focus() }, [])
|
|
5897
|
-
useEffect(() => {
|
|
5898
|
-
// Capture phase, like the roll-back dialog: while a question about edits
|
|
5899
|
-
// is open, Escape answers it and nothing else — consumed here, before it
|
|
5900
|
-
// can reach the page's other Escape handlers (an open picker's dismiss,
|
|
5901
|
-
// the commit box's undo).
|
|
5902
|
-
const onKey = (event: KeyboardEvent): void => {
|
|
5903
|
-
if (event.key !== 'Escape') return
|
|
5904
|
-
event.stopPropagation()
|
|
5905
|
-
onCancel()
|
|
5906
|
-
}
|
|
5907
|
-
window.addEventListener('keydown', onKey, true)
|
|
5908
|
-
return () => { window.removeEventListener('keydown', onKey, true) }
|
|
5909
|
-
}, [onCancel])
|
|
5910
|
-
return (
|
|
5911
|
-
<div className={css.confirmScrim} onClick={onCancel}>
|
|
5912
|
-
<div
|
|
5913
|
-
className={css.confirmBox}
|
|
5914
|
-
role="alertdialog"
|
|
5915
|
-
aria-modal="true"
|
|
5916
|
-
aria-label={t('unsavedTitle')}
|
|
5917
|
-
onClick={event => event.stopPropagation()}
|
|
5918
|
-
>
|
|
5919
|
-
<div className={css.confirmTitle}>{t('unsavedTitle')}</div>
|
|
5920
|
-
<div className={css.confirmBody}>{t('unsavedBody', { path })}</div>
|
|
5921
|
-
<div className={css.confirmActions}>
|
|
5922
|
-
<button ref={stayRef} type="button" className={css.btn} onClick={onCancel}>{t('unsavedStay')}</button>
|
|
5923
|
-
<button type="button" className={`${css.btn} ${css.btnDanger}`} onClick={onConfirm}>{t('unsavedLeave')}</button>
|
|
5924
|
-
</div>
|
|
5925
|
-
</div>
|
|
5926
|
-
</div>
|
|
5927
|
-
)
|
|
5928
|
-
}
|
|
5929
|
-
|
|
5930
|
-
/** Line-number cell class: a PRESENT cell of a changed row carries its side's
|
|
5931
|
-
* tint into the gutter; an absent one stays blank, the way a split diff shows
|
|
5932
|
-
* a one-sided change with an empty opposite pane rather than a tinted void. */
|
|
5933
|
-
function sideNumClass(row: SideRow, side: 'left' | 'right'): string {
|
|
5934
|
-
const cell = side === 'left' ? row.left : row.right
|
|
5935
|
-
if (cell === null || row.kind === 'same') return css.sideNum
|
|
5936
|
-
return `${css.sideNum} ${side === 'left' ? css.sideNumDel : css.sideNumAdd}`
|
|
5937
|
-
}
|
|
5938
|
-
|
|
5939
|
-
/** Code cell class: deletions tint left, additions right, context stays quiet. */
|
|
5940
|
-
function sideCodeClass(row: SideRow, side: 'left' | 'right'): string {
|
|
5941
|
-
const cell = side === 'left' ? row.left : row.right
|
|
5942
|
-
if (cell === null || row.kind === 'same') return css.sideCodeSame
|
|
5943
|
-
return `${side === 'left' ? css.sideCodeDel : css.sideCodeAdd} ${css.sideCellBlock}`
|
|
5944
|
-
}
|
|
5945
|
-
|
|
5946
|
-
/** One cell's Shiki runs, or its plain text when no tokens exist. */
|
|
5947
|
-
function renderSideCode(cell: SideCell | null, tokens: readonly HighlightRun[] | undefined): ReactNode {
|
|
5948
|
-
if (cell === null) return ''
|
|
5949
|
-
if (tokens === undefined || tokens.length === 0) return cell.text
|
|
5950
|
-
if (tokens.length === 1 && tokens[0]!.color === undefined && !tokens[0]!.italic) return cell.text
|
|
5951
|
-
return tokens.map((tok, i) => (
|
|
5952
|
-
<span
|
|
5953
|
-
key={i}
|
|
5954
|
-
style={tok.color === undefined && !tok.italic ? undefined : { color: tok.color, fontStyle: tok.italic ? 'italic' : undefined }}
|
|
5955
|
-
>{tok.text}</span>
|
|
5956
|
-
))
|
|
5957
|
-
}
|
|
5958
|
-
|
|
5959
|
-
/* ---------- shared helpers ---------- */
|
|
5960
|
-
|
|
5961
|
-
/**
|
|
5962
|
-
* The rows a diff actually has to put in the DOM, tracked against its scroller.
|
|
5963
|
-
*
|
|
5964
|
-
* Rendering a whole file cost the pane 3.9 seconds of main thread and a
|
|
5965
|
-
* 3.6-second frozen frame at 4,000 lines — for a one-line change, because the
|
|
5966
|
-
* side-by-side view draws every line of the file whether it changed or not.
|
|
5967
|
-
* This is the fix that removes the length from the cost rather than capping it.
|
|
5968
|
-
*
|
|
5969
|
-
* The window is held in state rather than the raw scroll offset so a scroll
|
|
5970
|
-
* that does not move it renders nothing: `start` only changes once a whole row
|
|
5971
|
-
* has passed under the viewport's edge.
|
|
5972
|
-
*
|
|
5973
|
-
* @param scrollRef - the element that scrolls the rows.
|
|
5974
|
-
* @param rowCount - how many rows the diff has.
|
|
5975
|
-
* @returns the rows to render and the spacer heights standing in for the rest.
|
|
5976
|
-
*/
|
|
5977
|
-
function useRowWindow(scrollRef: { current: HTMLElement | null }, rowCount: number): RowWindow {
|
|
5978
|
-
const [win, setWin] = useState<RowWindow>(() => rowWindow(0, 0, rowCount))
|
|
5979
|
-
useEffect(() => {
|
|
5980
|
-
const el = scrollRef.current
|
|
5981
|
-
if (el === null) return
|
|
5982
|
-
const read = (): void => {
|
|
5983
|
-
const next = rowWindow(el.scrollTop, el.clientHeight, rowCount)
|
|
5984
|
-
setWin(prev => prev.start === next.start && prev.end === next.end ? prev : next)
|
|
5985
|
-
}
|
|
5986
|
-
read()
|
|
5987
|
-
// Passive: this listener never calls preventDefault, and saying so keeps
|
|
5988
|
-
// it off the scroll's critical path.
|
|
5989
|
-
el.addEventListener('scroll', read, { passive: true })
|
|
5990
|
-
// The drawer resizes without the page doing so — a dragged edge, the
|
|
5991
|
-
// maximize button — and a taller pane needs more rows.
|
|
5992
|
-
const observer = new ResizeObserver(read)
|
|
5993
|
-
observer.observe(el)
|
|
5994
|
-
return () => {
|
|
5995
|
-
el.removeEventListener('scroll', read)
|
|
5996
|
-
observer.disconnect()
|
|
5997
|
-
}
|
|
5998
|
-
}, [scrollRef, rowCount])
|
|
5999
|
-
return win
|
|
6000
|
-
}
|
|
6001
|
-
|
|
6002
|
-
/**
|
|
6003
|
-
* The spacer standing in for the rows above or below the window.
|
|
6004
|
-
*
|
|
6005
|
-
* It spans every column of the grid, so a blame gutter does not change it.
|
|
6006
|
-
* @param height - px of rows it stands in for; nothing is rendered for 0.
|
|
6007
|
-
*/
|
|
6008
|
-
function RowSpacer({ height }: { height: number }): ReactNode {
|
|
6009
|
-
if (height <= 0) return null
|
|
6010
|
-
return <span className={css.sideSpacer} style={{ height: `${height}px` }} aria-hidden="true" />
|
|
6011
|
-
}
|
|
6012
|
-
|
|
6013
|
-
/** Split a combined `git diff` into path -> its segment text. */
|
|
6014
|
-
function splitDiff(diff: string): Map<string, string> {
|
|
6015
|
-
const out = new Map<string, string>()
|
|
6016
|
-
if (diff.length === 0) return out
|
|
6017
|
-
for (const part of diff.split(/(?=^diff --git )/m)) {
|
|
6018
|
-
if (part.length === 0) continue
|
|
6019
|
-
const path = extractDiffPath(part)
|
|
6020
|
-
if (path.length > 0) out.set(path, part)
|
|
6021
|
-
}
|
|
6022
|
-
return out
|
|
2133
|
+
return out
|
|
6023
2134
|
}
|
|
6024
2135
|
|
|
6025
2136
|
function extractDiffPath(part: string): string {
|
|
@@ -6031,36 +2142,3 @@ function extractDiffPath(part: string): string {
|
|
|
6031
2142
|
const del = /\ba\/(.+)$/.exec(firstLine)
|
|
6032
2143
|
return del !== null ? del[1] : ''
|
|
6033
2144
|
}
|
|
6034
|
-
|
|
6035
|
-
/**
|
|
6036
|
-
* A branch name, or the stand-in when there is none.
|
|
6037
|
-
*
|
|
6038
|
-
* This used to also cut the name to 21 characters and append an ellipsis, which
|
|
6039
|
-
* is how `feature/nested/deep/some-fix` reached the header as
|
|
6040
|
-
* `feature/nested/deep/s…` — the truncation was in JS, so it happened at the
|
|
6041
|
-
* same 21 characters whether the drawer was 400px or maximised, and it cut off
|
|
6042
|
-
* the only end that says which branch this is. Width is the stylesheet's
|
|
6043
|
-
* question; {@link Elided} answers it, from the correct end, only when there is
|
|
6044
|
-
* genuinely not enough room.
|
|
6045
|
-
*
|
|
6046
|
-
* @param branch - branch name, empty when the repo has none yet.
|
|
6047
|
-
* @param empty - already-translated stand-in for the empty case.
|
|
6048
|
-
*/
|
|
6049
|
-
function branchLabel(branch: string, empty: string): string {
|
|
6050
|
-
return branch.length === 0 ? empty : branch
|
|
6051
|
-
}
|
|
6052
|
-
|
|
6053
|
-
function statusGlyph(status: GitFileStatus): string {
|
|
6054
|
-
switch (status) {
|
|
6055
|
-
case 'added': return 'A'
|
|
6056
|
-
case 'untracked': return 'U'
|
|
6057
|
-
case 'modified': return 'M'
|
|
6058
|
-
case 'renamed': return 'R'
|
|
6059
|
-
case 'deleted': return 'D'
|
|
6060
|
-
}
|
|
6061
|
-
}
|
|
6062
|
-
|
|
6063
|
-
function basePart(path: string): string {
|
|
6064
|
-
const cut = path.lastIndexOf('/')
|
|
6065
|
-
return cut >= 0 ? path.slice(cut + 1) : path
|
|
6066
|
-
}
|