@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.
- package/AGENTS.md +2 -2
- package/CHANGELOG.md +19 -0
- package/CHANGELOG_EN.md +19 -0
- package/README.md +52 -50
- package/README_EN.md +2 -1
- package/lib/client.js +6505 -6253
- package/package.json +21 -1
- package/src/client/ChangesFileTree.tsx +550 -0
- package/src/client/ChromeGlyph.tsx +27 -0
- package/src/client/CommitHistory.tsx +1001 -0
- package/src/client/DiffViews.tsx +1070 -0
- package/src/client/GitWorkbenchPanel.module.css +15 -2513
- package/src/client/GitWorkbenchPanel.tsx +37 -3959
- package/src/client/PaneDivider.tsx +74 -0
- package/src/client/WorkbenchControls.tsx +1047 -0
- package/src/client/WorktreeGlyph.tsx +24 -0
- package/src/client/diff-nav.ts +59 -0
- package/src/client/git-workbench-types.ts +252 -0
- package/src/client/locales.ts +14 -8
- package/src/client/row-window.ts +23 -0
- package/src/client/side-rows.ts +66 -0
- package/src/client/styles/changes.css +504 -0
- package/src/client/styles/controls.css +225 -0
- package/src/client/styles/environment.css +89 -0
- package/src/client/styles/files.css +111 -0
- package/src/client/styles/history-filters.css +410 -0
- package/src/client/styles/history.css +276 -0
- package/src/client/styles/image.css +67 -0
- package/src/client/styles/operations.css +179 -0
- package/src/client/styles/shell.css +430 -0
- package/src/client/styles/themes.css +224 -0
- package/src/client/use-change-nav.ts +6 -5
- package/src/client/use-row-window.ts +55 -0
|
@@ -0,0 +1,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
|
+
}
|
package/src/client/diff-nav.ts
CHANGED
|
@@ -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
|
package/src/client/locales.ts
CHANGED
|
@@ -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
|
|
493
|
-
blockDiscard: '
|
|
494
|
-
blockUnstage: 'Unstage
|
|
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 —
|
package/src/client/row-window.ts
CHANGED
|
@@ -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.
|
package/src/client/side-rows.ts
CHANGED
|
@@ -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
|
+
}
|