@young1lin/dsh-ui-gitworkbench 0.1.10 → 0.1.12

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.
@@ -0,0 +1,24 @@
1
+ import type { ReactNode } from 'react'
2
+ import css from './GitWorkbenchPanel.module.css'
3
+
4
+ /**
5
+ * Tree glyph: a root with two working copies hanging off it.
6
+ *
7
+ * This was git's fork glyph — the three-dot branch symbol — which named the
8
+ * wrong thing. A worktree is not a branch; the picker beside it is already full
9
+ * of branch names, and the two ideas need to stay tellable apart at 12px. A
10
+ * hierarchy reads as "one repository, several directories", which is what a
11
+ * worktree list is.
12
+ */
13
+ export function WorktreeGlyph(): ReactNode {
14
+ return (
15
+ <svg className={css.cardGlyph} width="12" height="12" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
16
+ {/* Trunk down from the root, and the two limbs it puts out. */}
17
+ <path d="M2.25 2.75h1.5v10.5h-1.5zM3 6.75h7.25v1.5H3zM3 11.75h7.25v1.5H3z" />
18
+ {/* The root, then the worktrees. */}
19
+ <circle cx="3" cy="2.75" r="1.75" />
20
+ <circle cx="12" cy="7.5" r="1.75" />
21
+ <circle cx="12" cy="12.5" r="1.75" />
22
+ </svg>
23
+ )
24
+ }
@@ -58,6 +58,20 @@ export interface BlockTop {
58
58
  readonly top: number
59
59
  }
60
60
 
61
+ /** The change nearest a viewport position, used to retain the reader's place
62
+ * when the right diff grid becomes a dense editor. */
63
+ export function blockNearestTo(anchors: readonly BlockTop[], top: number): BlockTop | null {
64
+ let nearest: BlockTop | null = null
65
+ let distance = Number.POSITIVE_INFINITY
66
+ for (const anchor of anchors) {
67
+ const next = Math.abs(anchor.top - top)
68
+ if (next >= distance) continue
69
+ nearest = anchor
70
+ distance = next
71
+ }
72
+ return nearest
73
+ }
74
+
61
75
  /**
62
76
  * The content position that counts as "where the reader is".
63
77
  *
@@ -116,6 +130,13 @@ export function anchorFrom(
116
130
  return anchorFor(scrollTop)
117
131
  }
118
132
 
133
+ /** The adjacent explicit editor selection, wrapping at both ends. */
134
+ export function stepBlockIndex(totalBlocks: number, currentBlock: number, direction: 1 | -1): number | null {
135
+ if (totalBlocks <= 0) return null
136
+ const current = Number.isInteger(currentBlock) && currentBlock >= 0 && currentBlock < totalBlocks ? currentBlock : 0
137
+ return (current + direction + totalBlocks) % totalBlocks
138
+ }
139
+
119
140
  /**
120
141
  * The next or previous change block, wrapping at the ends.
121
142
  *
@@ -224,3 +245,41 @@ export function blockTopsFromRows(
224
245
  }
225
246
  return tops
226
247
  }
248
+
249
+ /**
250
+ * Place change blocks in one dense side of an aligned diff.
251
+ *
252
+ * Edit mode removes alignment holes: the working-tree CodeMirror has one row
253
+ * per real right-side line. A deletion-only block has no line on that side, so
254
+ * it sits at the following line's insertion point, or just after the final
255
+ * present line when the deletion reaches EOF.
256
+ */
257
+ export function blockTopsFromSideRows(
258
+ rows: readonly import('./side-rows.ts').SideRow[],
259
+ side: 'left' | 'right',
260
+ rowH: number,
261
+ offset = 0,
262
+ ): readonly BlockTop[] {
263
+ const nextLine: Array<number | undefined> = new Array(rows.length)
264
+ let following: number | undefined
265
+ for (let i = rows.length - 1; i >= 0; i -= 1) {
266
+ const cell = rows[i]![side]
267
+ if (cell !== null) following = cell.line
268
+ nextLine[i] = following
269
+ }
270
+
271
+ const tops: BlockTop[] = []
272
+ const seen = new Set<number>()
273
+ let previous = 0
274
+ for (let i = 0; i < rows.length; i += 1) {
275
+ const row = rows[i]!
276
+ const cell = row[side]
277
+ if (row.block >= 0 && !seen.has(row.block)) {
278
+ seen.add(row.block)
279
+ const line = cell?.line ?? nextLine[i] ?? previous + 1
280
+ tops.push({ block: row.block, top: offset + Math.max(0, line - 1) * rowH })
281
+ }
282
+ if (cell !== null) previous = cell.line
283
+ }
284
+ return tops
285
+ }
@@ -0,0 +1,252 @@
1
+ /** Shared JSON-safe contracts between the panel, feature views, and RPC adapters. */
2
+
3
+ export type GitFileStatus = 'added' | 'deleted' | 'modified' | 'renamed' | 'untracked'
4
+
5
+ export interface GitFile {
6
+ readonly path: string
7
+ readonly status: GitFileStatus
8
+ readonly addedLines: number
9
+ readonly deletedLines: number
10
+ readonly binary: boolean
11
+ readonly previousPath?: string
12
+ /**
13
+ * Which side of the index this file's change is on. Both can be true — a file
14
+ * staged and then edited again. Absent outside the working-tree view: a
15
+ * commit's files were staged long ago and the question is meaningless.
16
+ */
17
+ readonly staged?: boolean
18
+ readonly unstaged?: boolean
19
+ }
20
+
21
+ export interface GitCommit {
22
+ readonly hash: string
23
+ readonly subject: string
24
+ readonly when: string
25
+ /** Everything after the subject. Empty string when the commit has none. */
26
+ readonly body: string
27
+ /** Author name (`%an`). Optional only because a pre-0.1.4 host half sends none. */
28
+ readonly authorName?: string
29
+ /** Committer name (`%cn`); equals the author except on rebases and patches a maintainer applied. */
30
+ readonly committerName?: string
31
+ /** Committer date, strict ISO 8601 (`%cI`) — the exact moment `when` summarizes. */
32
+ readonly dateIso?: string
33
+ /** Abbreviated parent hashes, first parent first — the graph's edges. */
34
+ readonly parents?: readonly string[]
35
+ /** Branch and tag names pointing here, already stripped of git's decoration syntax. */
36
+ readonly refs?: readonly string[]
37
+ }
38
+
39
+ export interface WorkbenchStats {
40
+ readonly worktreePath: string
41
+ readonly branch: string
42
+ readonly ahead: number
43
+ readonly behind: number
44
+ readonly detached: boolean
45
+ readonly addedLines: number
46
+ readonly deletedLines: number
47
+ readonly addedFiles: number
48
+ readonly deletedFiles: number
49
+ readonly modifiedFiles: number
50
+ readonly files: readonly GitFile[]
51
+ readonly diff: string
52
+ /**
53
+ * Commits this view is about: the single commit for a commit view, the range's
54
+ * commits for a comparison. Empty for the working tree — the history list
55
+ * loads its own pages so it can follow a ref of its own.
56
+ */
57
+ readonly commits: readonly GitCommit[]
58
+ readonly error?: string
59
+ }
60
+
61
+ /** One worktree of the repository, as `git worktree list --porcelain` reports it. */
62
+ export interface WorktreeEntry {
63
+ readonly path: string
64
+ readonly head: string
65
+ readonly branch: string
66
+ }
67
+
68
+ /** The session's worktree binding, as persisted by the worktree tools. */
69
+ export interface WorktreeBinding {
70
+ readonly repoRoot: string
71
+ readonly worktreePath: string
72
+ readonly name: string
73
+ readonly enteredAt: string
74
+ readonly baseCommit?: string
75
+ }
76
+
77
+ /**
78
+ * `gitWorkbench/worktreeStatus`: the session's binding (null when unbound) plus every
79
+ * worktree of the surrounding repository. Git allows at most one worktree per
80
+ * branch, so this one list is both the worktree picker and the branch picker.
81
+ */
82
+ export interface WorktreeStatus {
83
+ readonly binding: WorktreeBinding | null
84
+ readonly worktrees: readonly WorktreeEntry[]
85
+ /**
86
+ * Every local branch, most-recently-committed first. Distinct from
87
+ * {@link worktrees} on purpose: a branch without a worktree has no directory
88
+ * to read, so it can be browsed or compared but not viewed as a working tree.
89
+ */
90
+ readonly branches: readonly string[]
91
+ /** Whether the host cut {@link branches} short at its cap. */
92
+ readonly branchesTruncated: boolean
93
+ }
94
+
95
+ /**
96
+ * `gitWorkbench/syncStatus`: where the current branch stands against its upstream.
97
+ *
98
+ * `upstream: null` and "level with the upstream" are different states and the
99
+ * drawer treats them differently — the first is what makes the first push pass
100
+ * `--set-upstream`, and both otherwise read as zero ahead and zero behind.
101
+ */
102
+ export interface SyncStatus {
103
+ readonly branch: string
104
+ readonly upstream: string | null
105
+ readonly ahead: number
106
+ readonly behind: number
107
+ readonly detached: boolean
108
+ /** Whether the repository has any remote at all. No remote, no sync bar. */
109
+ readonly hasRemote: boolean
110
+ }
111
+
112
+ /** Why a write operation failed, in terms the drawer can explain. `stale` is
113
+ * a sha the host re-derived and refused; `invalid` an argument combination
114
+ * the host rejected before running anything. */
115
+ export type GitOpFailure =
116
+ | 'auth' | 'network' | 'no-upstream' | 'diverged' | 'conflict'
117
+ | 'nothing-to-commit' | 'dirty' | 'stale' | 'invalid' | 'unknown'
118
+
119
+ export interface GitOpResult {
120
+ readonly ok: boolean
121
+ readonly failure?: GitOpFailure
122
+ /** git's own message on failure. Shown verbatim: a classification is a hint. */
123
+ readonly error?: string
124
+ readonly output?: string
125
+ }
126
+
127
+ /** The host endpoints under `gitWorkbench/` that change something. */
128
+ export type GitOpName = 'stage' | 'unstage' | 'commit' | 'fetch' | 'pull' | 'push' | 'discardFile' | 'applyBlocks'
129
+
130
+ /** Extra arguments an operation needs beyond the worktree path. */
131
+ export interface GitOpPayload {
132
+ readonly paths?: readonly string[]
133
+ readonly message?: string
134
+ readonly amend?: boolean
135
+ /** `pull` picks how to integrate; `applyBlocks` which block mutation. One
136
+ * field serves both because the payload is a flat bag keyed by op — the
137
+ * host narrows and validates it per endpoint. */
138
+ readonly mode?: 'ff-only' | 'rebase' | 'merge' | BlockMode
139
+ /** `discardFile` and `applyBlocks`, and deliberately singular: the one
140
+ * irreversible thing the drawer does takes one file per call, so a mistaken
141
+ * click costs one file. */
142
+ readonly path?: string
143
+ /** `discardFile` only: the effect the confirmation stated. The host refuses
144
+ * if the file changed underneath the dialog and now means something else. */
145
+ readonly expectedEffect?: string
146
+ /** `applyBlocks` only: the layer whose diff the `diffSha` is over, and the
147
+ * block's hunk-line indices (`side-rows.blockLines`). The host re-fetches
148
+ * that layer's diff and refuses unless the sha still matches. */
149
+ readonly layer?: SideLayer
150
+ readonly diffSha?: string
151
+ readonly lines?: readonly number[]
152
+ }
153
+
154
+ export type { DiscardAnswer, DiscardNext, DiscardPreview } from './discard-flow.ts'
155
+ export type { WriteResult } from './side-edit.ts'
156
+
157
+ /** Which side of the index a side-by-side pane shows: `unstaged` is
158
+ * index→worktree (the editable side), `staged` is HEAD→index (read-only). */
159
+ export type SideLayer = 'unstaged' | 'staged'
160
+
161
+ /** A block mutation the side pane's buttons request: `stage` and `discard` act
162
+ * on the unstaged layer, `unstage` on the staged one. The host enforces the
163
+ * same matrix. */
164
+ export type BlockMode = 'stage' | 'unstage' | 'discard'
165
+
166
+ /**
167
+ * What one block action acts on, snapshotted from the diff the pane had
168
+ * rendered when the click (or its confirmation) happened.
169
+ *
170
+ * The snapshot is the point: `diffSha` proves the file has not changed since
171
+ * the pane rendered it, and `lines` — the block's hunk-line indices — only
172
+ * mean anything against exactly that diff. A confirmed roll-back carries the
173
+ * ask it opened with, so the answer cannot drift under the dialog.
174
+ */
175
+ export interface BlockAsk {
176
+ readonly path: string
177
+ readonly layer: SideLayer
178
+ readonly diffSha: string
179
+ readonly lines: readonly number[]
180
+ /** The block's line tallies, for the roll-back confirmation's wording. */
181
+ readonly added: number
182
+ readonly deleted: number
183
+ /** Whether the block is the file's entire content — the untracked case,
184
+ * whose roll-back DELETES the file and whose confirmation says so. */
185
+ readonly wholeFile: boolean
186
+ }
187
+
188
+ /**
189
+ * `gitWorkbench/fileSides`: one layer of one file for the side-by-side pane.
190
+ * Mirrors the host's `FileSides` (the client re-declares host shapes rather
191
+ * than importing the host module, which pulls node and the RPC decorators).
192
+ */
193
+ export interface FileSides {
194
+ /** Unified diff at full context; '' when the layer has no change. */
195
+ readonly diff: string
196
+ /** sha1 of `diff`, echoed back by mutations to prove the same snapshot. */
197
+ readonly diffSha: string
198
+ /** Whole right-hand text, the editor's initial buffer. */
199
+ readonly targetText: string
200
+ /** Blob sha of the right-hand side; '' when it does not exist. */
201
+ readonly targetSha: string
202
+ readonly binary: boolean
203
+ /** True when the file is past the size guard; the client shows the old view. */
204
+ readonly tooLarge: boolean
205
+ /** True when the working-tree file is not valid UTF-8; the pane shows the
206
+ * diff but withholds the editor. Optional so an older host half reads as
207
+ * "fine" rather than as a refusal this client cannot explain. */
208
+ readonly lossyEncoding?: boolean
209
+ }
210
+
211
+ /**
212
+ * `gitWorkbench/fileImage`: one working-tree file's bytes, when the host's
213
+ * signature check confirms they are an image a browser can draw.
214
+ *
215
+ * Every field is present in both outcomes — an image and a refusal — because
216
+ * the gateway's payloads carry no `undefined`. `reason` is '' exactly when
217
+ * `ok`, and names the refusal otherwise: 'notImage', 'tooLarge', 'missing'.
218
+ */
219
+ export interface FileImage {
220
+ readonly ok: boolean
221
+ /** MIME type to label the blob with; '' when declined. */
222
+ readonly mime: string
223
+ /** Short label for the caption — 'PNG', 'WebP', 'SVG'; '' when declined. */
224
+ readonly kind: string
225
+ /** The whole file, base64; '' when declined. */
226
+ readonly base64: string
227
+ /** The file's size in bytes, reported either way. */
228
+ readonly bytes: number
229
+ readonly reason: string
230
+ }
231
+
232
+ /** One line's provenance, as `gitWorkbench/blame` reports it. */
233
+ export interface BlameLine {
234
+ /** Full commit sha; all zeros for a line not committed yet. */
235
+ readonly hash: string
236
+ readonly author: string
237
+ /** Author time, unix seconds; 0 when git did not say. */
238
+ readonly time: number
239
+ readonly summary: string
240
+ readonly uncommitted: boolean
241
+ }
242
+
243
+ /** `gitWorkbench/blame`'s answer. `error` is present only on failure. */
244
+ export interface BlameAnswer {
245
+ readonly lines: readonly BlameLine[]
246
+ /** Whether the file was longer than the gutter's cap. */
247
+ readonly truncated: boolean
248
+ readonly error?: string
249
+ }
250
+
251
+ /** Translate a key of this plugin's namespace, with optional `{name}` params. */
252
+ export type Translate = (key: string, params?: Record<string, string | number>) => string
@@ -28,7 +28,7 @@ export type WorkbenchKey =
28
28
  | 'filesUnsavedAsk' | 'filesDiscardOpen' | 'filesMore' | 'filesVanished' | 'fileReadOnlyCrlf' | 'fileReadOnlyEncoding'
29
29
  | 'blameWhileEditing' | 'blameLine' | 'blamePick' | 'blameInHistory'
30
30
  | 'imageBroken' | 'imageFit' | 'imageActual' | 'imageTooLarge' | 'imageSource' | 'imagePreview'
31
- | 'prevChange' | 'nextChange' | 'prevChangeHint' | 'nextChangeHint' | 'changeCount'
31
+ | 'prevChange' | 'nextChange' | 'prevChangeHint' | 'nextChangeHint' | 'changeCount' | 'changePosition'
32
32
  | 'sourceLabel' | 'workingTree'
33
33
  | 'loadingCommit' | 'renamedFrom' | 'binaryFile' | 'loadingDiff' | 'noTextDiff'
34
34
  | 'noCommits' | 'historyLabel' | 'historyEnd' | 'loading' | 'maximize' | 'restore'
@@ -62,7 +62,7 @@ export type WorkbenchKey =
62
62
  | 'discardAction' | 'discardTitle' | 'discardConfirm' | 'discardCancel'
63
63
  | 'discardBodyRestore' | 'discardBodyDelete' | 'discardBodyUnrename'
64
64
  // side-by-side block actions: the three buttons and the roll-back confirmation's wording
65
- | 'blockStage' | 'blockDiscard' | 'blockUnstage' | 'blockDiscardBody' | 'blockDiscardBodyDelete'
65
+ | 'blockStage' | 'blockDiscard' | 'blockUnstage' | 'fileUnstage' | 'blockActionsDirty' | 'blockDiscardBody' | 'blockDiscardBodyDelete'
66
66
  // side-by-side editing: arm the editor, save, revert, the stale/conflict
67
67
  // banner, the CRLF refusal notice, and the unsaved-changes prompt that
68
68
  // guards every gesture dropping the buffer (tab, file, close)
@@ -171,6 +171,7 @@ export const zh: Record<WorkbenchKey, string> = {
171
171
  prevChangeHint: '上一处变更(Shift+F7)',
172
172
  nextChangeHint: '下一处变更(F7)',
173
173
  changeCount: '变更 {n} 处',
174
+ changePosition: '第 {current} / {total} 处变更',
174
175
  loadingDiff: '加载 diff…',
175
176
  noTextDiff: '无文本差异',
176
177
  // The side-by-side pane's layer tabs: unstaged is index→worktree, staged is
@@ -267,9 +268,11 @@ export const zh: Record<WorkbenchKey, string> = {
267
268
  discardBodyUnrename: '撤销重命名:{path} 改回 {previousPath},改名期间的内容改动一并丢弃。',
268
269
  // One BLOCK, not the whole file: the side pane's roll-back states exactly
269
270
  // which lines leave and that the working-tree file is rewritten to do it.
270
- blockStage: '暂存这块',
271
- blockDiscard: '撤回这块',
272
- blockUnstage: '取消暂存这块',
271
+ blockStage: '暂存此变更块',
272
+ blockDiscard: '恢复此变更块',
273
+ blockUnstage: '取消暂存此变更块',
274
+ fileUnstage: '取消暂存整个文件',
275
+ blockActionsDirty: '请先保存或放弃编辑器修改,再操作当前变更块',
273
276
  blockDiscardBody: '{path} 的这一块改动({added} 行新增、{deleted} 行删除)将被撤回,工作区文件随之改写,无法找回。',
274
277
  // The untracked case: the file's whole content is the one block, so rolling
275
278
  // the block back reverse-applies the new-file patch and DELETES the file —
@@ -408,6 +411,7 @@ export const en: Record<WorkbenchKey, string> = {
408
411
  prevChangeHint: 'Previous change (Shift+F7)',
409
412
  nextChangeHint: 'Next change (F7)',
410
413
  changeCount: 'changes: {n}',
414
+ changePosition: 'change {current} / {total}',
411
415
  loadingDiff: 'Loading diff…',
412
416
  noTextDiff: 'No text changes',
413
417
  tabUnstaged: 'Unstaged',
@@ -489,9 +493,11 @@ export const en: Record<WorkbenchKey, string> = {
489
493
  discardBodyUnrename: 'Undo the rename: {path} goes back to {previousPath}, and content changed along the way is lost.',
490
494
  // One BLOCK, not the whole file: the side pane's roll-back states exactly
491
495
  // which lines leave and that the working-tree file is rewritten to do it.
492
- blockStage: 'Stage block',
493
- blockDiscard: 'Roll back block',
494
- blockUnstage: 'Unstage block',
496
+ blockStage: 'Stage hunk',
497
+ blockDiscard: 'Revert hunk',
498
+ blockUnstage: 'Unstage hunk',
499
+ fileUnstage: 'Unstage file',
500
+ blockActionsDirty: 'Save or discard editor changes before operating on this hunk.',
495
501
  blockDiscardBody: 'This block of {path} ({added} added, {deleted} deleted lines) is rolled back and the working-tree file rewritten to do it. This cannot be undone.',
496
502
  // The untracked case: the file's whole content is the one block, so rolling
497
503
  // the block back reverse-applies the new-file patch and DELETES the file —
@@ -72,6 +72,29 @@ export interface RowWindow {
72
72
  readonly padBottom: number
73
73
  }
74
74
 
75
+ /** Whether two windows paint the same rows AND reserve the same total height.
76
+ * Comparing only start/end leaves a previous file's bottom spacer behind when
77
+ * two long files happen to expose the same viewport-sized row range. */
78
+ export function sameRowWindow(a: RowWindow, b: RowWindow): boolean {
79
+ return a.start === b.start && a.end === b.end
80
+ && a.padTop === b.padTop && a.padBottom === b.padBottom
81
+ }
82
+
83
+ /** A rendered window is reusable only for the exact mounted diff. Equal-length
84
+ * files can be at different scroll positions, so row count alone is not an identity. */
85
+ export interface HeldRowWindow {
86
+ readonly mountKey: string
87
+ readonly rowCount: number
88
+ readonly win: RowWindow
89
+ }
90
+
91
+ /** Return the held window when it belongs to this diff, otherwise a fresh top window. */
92
+ export function rowWindowForMount(held: HeldRowWindow, rowCount: number, mountKey: string): RowWindow {
93
+ return held.rowCount === rowCount && held.mountKey === mountKey
94
+ ? held.win
95
+ : rowWindow(0, 0, rowCount)
96
+ }
97
+
75
98
  /**
76
99
  * @param value - a number from the DOM, which can be NaN or negative.
77
100
  * @param fallback - used when it is neither finite nor usable.
@@ -174,6 +174,17 @@ export function blockLines(rows: readonly SideRow[], block: number): readonly nu
174
174
  return [...indices].sort((a, b) => a - b)
175
175
  }
176
176
 
177
+ /** Every changed hunk-line index, for a whole-layer Stage/Unstage action. */
178
+ export function allBlockLines(rows: readonly SideRow[]): readonly number[] {
179
+ const indices = new Set<number>()
180
+ for (const row of rows) {
181
+ if (row.block < 0) continue
182
+ if (row.leftIndex !== -1) indices.add(row.leftIndex)
183
+ if (row.rightIndex !== -1) indices.add(row.rightIndex)
184
+ }
185
+ return [...indices].sort((a, b) => a - b)
186
+ }
187
+
177
188
  /**
178
189
  * How many change blocks the rows hold.
179
190
  *
@@ -186,6 +197,49 @@ export function blockCount(rows: readonly SideRow[]): number {
186
197
  return max + 1
187
198
  }
188
199
 
200
+ export type BlockEdge = 'single' | 'first' | 'middle' | 'last'
201
+
202
+ /** Where one present side-cell sits on its block's visible perimeter. Absent
203
+ * cells return null: an addition-only block must not draw a blue cage through
204
+ * the empty left pane, and a deletion-only block does the symmetric thing. */
205
+ export function blockEdge(rows: readonly SideRow[], index: number, side: 'left' | 'right'): BlockEdge | null {
206
+ const row = rows[index]
207
+ if (row === undefined || row.block < 0 || row[side] === null) return null
208
+ const before = index > 0 && rows[index - 1]!.block === row.block && rows[index - 1]![side] !== null
209
+ const after = index + 1 < rows.length && rows[index + 1]!.block === row.block && rows[index + 1]![side] !== null
210
+ if (!before && !after) return 'single'
211
+ if (!before) return 'first'
212
+ return after ? 'middle' : 'last'
213
+ }
214
+
215
+ /** The selected Git block exposed by the fixed pane toolbar. Visibility is
216
+ * independent of buffer dirtiness: the component keeps actions mounted and
217
+ * disables unsafe ones. A refreshed diff may carry fewer blocks, so an invalid
218
+ * selection safely falls back to the first one. */
219
+ export function currentActionBlock(totalBlocks: number, actionsVisible: boolean, selectedBlock: number): number | null {
220
+ if (!actionsVisible || totalBlocks <= 0) return null
221
+ return Number.isInteger(selectedBlock) && selectedBlock >= 0 && selectedBlock < totalBlocks ? selectedBlock : 0
222
+ }
223
+
224
+ /** Git hunk operations wait for a clean editor buffer and for the prior Git
225
+ * operation to settle. This rule disables controls; it never hides them. */
226
+ export function blockActionsDisabled(dirty: boolean, pendingBlock: number | null): boolean {
227
+ return dirty || pendingBlock !== null
228
+ }
229
+
230
+ /**
231
+ * Whether the first rendered row is already inside a change block.
232
+ *
233
+ * The block action bar normally floats one row above its first cell. When the
234
+ * file itself starts with a change there is no preceding row, so the columns
235
+ * must reserve that bar's clearance inside their own clipping boxes. This is
236
+ * derived from the full row model rather than hover state, avoiding a layout
237
+ * jump when the pointer enters line 1.
238
+ */
239
+ export function needsFirstBlockClearance(rows: readonly SideRow[]): boolean {
240
+ return rows.length > 0 && rows[0]!.block >= 0
241
+ }
242
+
189
243
  /**
190
244
  * Whether a block is the file's ENTIRE content: the one block of a diff with
191
245
  * no context row at all.
@@ -256,3 +310,15 @@ export function blockTally(rows: readonly SideRow[], block: number): { readonly
256
310
  }
257
311
  return { added, deleted }
258
312
  }
313
+
314
+ /** Whole-layer tallies, paired with {@link allBlockLines}. */
315
+ export function allBlockTally(rows: readonly SideRow[]): { readonly added: number; readonly deleted: number } {
316
+ let added = 0
317
+ let deleted = 0
318
+ for (const row of rows) {
319
+ if (row.block < 0) continue
320
+ if (row.left !== null) deleted += 1
321
+ if (row.right !== null) added += 1
322
+ }
323
+ return { added, deleted }
324
+ }