@young1lin/dsh-ui-gitworkbench 0.1.11 → 0.1.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +28 -0
  3. package/CHANGELOG_EN.md +28 -0
  4. package/README.md +100 -50
  5. package/README_EN.md +2 -1
  6. package/lib/client.js +6902 -6256
  7. package/package.json +1 -1
  8. package/src/client/ChangesFileTree.tsx +550 -0
  9. package/src/client/ChromeGlyph.tsx +27 -0
  10. package/src/client/CodeEditor.tsx +129 -3
  11. package/src/client/CommitHistory.tsx +1001 -0
  12. package/src/client/DiffViews.tsx +1070 -0
  13. package/src/client/GitWorkbenchPanel.module.css +15 -2513
  14. package/src/client/GitWorkbenchPanel.tsx +37 -3959
  15. package/src/client/PaneDivider.tsx +74 -0
  16. package/src/client/WorkbenchControls.tsx +1047 -0
  17. package/src/client/WorktreeGlyph.tsx +24 -0
  18. package/src/client/cm-search-theme.ts +250 -0
  19. package/src/client/diff-nav.ts +59 -0
  20. package/src/client/git-workbench-types.ts +252 -0
  21. package/src/client/locales.ts +14 -8
  22. package/src/client/row-window.ts +23 -0
  23. package/src/client/search-count.ts +125 -0
  24. package/src/client/side-rows.ts +66 -0
  25. package/src/client/styles/changes.css +505 -0
  26. package/src/client/styles/controls.css +236 -0
  27. package/src/client/styles/environment.css +89 -0
  28. package/src/client/styles/files.css +113 -0
  29. package/src/client/styles/history-filters.css +400 -0
  30. package/src/client/styles/history.css +276 -0
  31. package/src/client/styles/image.css +67 -0
  32. package/src/client/styles/operations.css +179 -0
  33. package/src/client/styles/shell.css +431 -0
  34. package/src/client/styles/themes.css +224 -0
  35. package/src/client/use-change-nav.ts +6 -5
  36. package/src/client/use-row-window.ts +55 -0
@@ -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 { Fragment, useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore, type CSSProperties, type Dispatch, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, type ReactNode, type Ref, type SetStateAction } from 'react'
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
- COLOR_MODES, DEFAULT_APPEARANCE, EMPTY_SETTINGS, STYLE_BLUR_MAX, STYLE_SCOPES, THEME_FAMILIES,
55
- DSH_DARK_ATTR, effectiveBackground, effectiveCss, entryFor, hostSchemeDark, isAppearance, resolveTheme, withScope,
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
- applySaveOk, applySides, armEdit, armRefusal, DISARMED, editableSides, gateLeave, isDirty,
68
- LEAVE_GUARD_CLEAR, leaveAnswered, leaveAsked, markConflict, paneDirtyReport, reloadSides, resetSides,
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 { PathDirGlyph, PathFileGlyph } from './glyphs.tsx'
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
- fileCheckState, nextAction, nextBatch, pathsFor, rollUp, settledTicks, withPendingTicks,
90
- type CheckState, type Tick, type TickAction,
82
+ nextAction, nextBatch, pathsFor, settledTicks, withPendingTicks,
83
+ type Tick, type TickAction,
91
84
  } from './stage-tree.ts'
92
- import { grammarLoadCount, highlightForRowsWindow, highlightRange, highlightWindow, shikiLangOf, shikiThemeOf, subscribeGrammarLoaded, type HighlightRun } from './highlight.ts'
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 GitFileStatus = 'added' | 'deleted' | 'modified' | 'renamed' | 'untracked'
99
-
100
- export interface GitFile {
101
- readonly path: string
102
- readonly status: GitFileStatus
103
- readonly addedLines: number
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
- /** Longest edge a chosen background image is resampled to before storage. Past
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
- const STATUS_BADGE: Record<GitFileStatus, string> = {
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
- const modeLabel: Record<ColorMode, string> = {
2862
- system: t('modeSystem'), light: t('modeLight'), dark: t('modeDark'),
2863
- }
2864
- const modeChip: Record<ColorMode, string> = {
2865
- system: css.chipSystem, light: css.chipLight, dark: css.chipDark,
2866
- }
2867
- const scopeLabel: Record<StyleScope, string> = {
2868
- project: t('scopeProject'), global: t('scopeGlobal'),
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
- const projectAvailable = settings.repoRoot !== null
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
- }