@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
@@ -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
+ }
@@ -0,0 +1,250 @@
1
+ /**
2
+ * The Ctrl/Cmd+F panel, restated in the drawer's own controls.
3
+ *
4
+ * `@codemirror/search` ships the panel with the library's stock chrome, and
5
+ * none of it follows a palette: the strip is `#f5f5f5` with black text (or
6
+ * `#333338` with white), the buttons carry a grey `linear-gradient` and a 1px
7
+ * radius, the fields are `1px solid silver`, the ticks are whatever the
8
+ * platform draws, and a match is `#ffff0054`. Worse, form controls do not
9
+ * inherit a font, and the library asks for `font-size: 70%` without naming a
10
+ * family — so the panel renders in the browser's default face at a size no
11
+ * other control in the drawer uses. Over a Nord or a cyberpunk drawer the
12
+ * strip stays light grey with black text on it.
13
+ *
14
+ * So the panel is dressed here, and ONLY through `--gs-*` tokens: every
15
+ * palette then clothes it for free, exactly as it clothes the pane around it.
16
+ * None of the shapes are invented for this panel — each is the one already on
17
+ * screen beside it:
18
+ *
19
+ * - the strip follows `.blockBar`, the drawer's other float over code, where
20
+ * `--gs-surface-2` plus a soft shadow are what keep it readable.
21
+ * - the buttons follow the shared compact control (`.treeIcon`,
22
+ * `.funnelPreset`, `.layoutButton`): `--gs-h-compact`, `--gs-pad-compact`,
23
+ * `--gs-r-control`, `--gs-t-dense`, and the same hover.
24
+ * - the fields follow the tree's own filter box, `.fbSearch`.
25
+ * - the ticks follow `.funnelRow input[type=checkbox]`: `appearance: none`
26
+ * and a rotated border for the mark, so nothing ships as an asset and every
27
+ * state recolors with the theme.
28
+ *
29
+ * Layout is the other half, and it stays in INLINE FLOW on purpose. The
30
+ * obvious answer — make the panel a wrapping flex row with one gap — costs
31
+ * the find/replace break: the library separates the two halves with a `<br>`,
32
+ * and Blink gives a `<br>` no box of its own inside a flex container. Neither
33
+ * `flex-basis: 100%`, `width: 100%` nor `min-width: 100%` on it makes the
34
+ * replace field start a line (measured on the running app; all four left it
35
+ * beside `by word`). So the controls stay inline-level atoms instead, and
36
+ * carry the rhythm the library asked for with `.2em .6em .2em 0` in a single
37
+ * margin. Wrapping still works when the pane is narrow: an atomic inline is a
38
+ * line-break opportunity, which is what lets the strip reflow in the 190px
39
+ * the Changes column drags down to.
40
+ *
41
+ * Kept out of `CodeEditor.tsx` so a test can read it: that file pulls React
42
+ * and a CSS Module, and neither loads under vitest.
43
+ *
44
+ * @module @young1lin/dsh-ui-gitworkbench/client/cm-search-theme
45
+ */
46
+
47
+ /** A CodeMirror theme spec, narrowed to what this module writes: one flat
48
+ * declaration block per selector, no nesting and no at-rules. */
49
+ export type ThemeSpec = Readonly<Record<string, Readonly<Record<string, string>>>>
50
+
51
+ /** Selector prefix for everything inside the panel. Spelled out per rule
52
+ * rather than nested, because CodeMirror's style builder only nests under a
53
+ * `&`, and a flat key is what a test can read back. */
54
+ const PANEL = '.cm-panel.cm-search'
55
+
56
+ /** Between two controls on a row, and between the find row and the replace
57
+ * row. The row gap is carried as a bottom margin per control, because inline
58
+ * flow has no `gap`; the panel's bottom padding is short by exactly this. */
59
+ const GAP = '6px'
60
+ const ROW_GAP = '6px'
61
+
62
+ export const SEARCH_PANEL_THEME: ThemeSpec = {
63
+ /* The strip. `.cm-panels` is the element the library paints, so the
64
+ override belongs there rather than on the panel inside it. The shadow is
65
+ `.blockBar`'s: this is a sticky bar with code scrolling under it, and a
66
+ border alone leaves the two planes touching. */
67
+ '.cm-panels': {
68
+ backgroundColor: 'var(--gs-surface-2)',
69
+ color: 'var(--gs-fg)',
70
+ boxShadow: '0 4px 14px var(--gs-shadow)',
71
+ },
72
+ '.cm-panels-top': { borderBottom: '1px solid var(--gs-border)' },
73
+ '.cm-panels-bottom': { borderTop: '1px solid var(--gs-border)' },
74
+
75
+ [PANEL]: {
76
+ position: 'relative',
77
+ /* The right gutter is the close button's: it is positioned over it, so
78
+ nothing may wrap underneath. The bottom is short by ROW_GAP, which every
79
+ control carries below itself; the two add back up to the top. */
80
+ padding: '8px 32px 2px 10px',
81
+ lineHeight: '1',
82
+ },
83
+ /* One rhythm for the whole strip, replacing the library's `.2em .6em .2em 0`.
84
+ `vertical-align: top` with one height is what makes a row of controls a
85
+ row rather than four boxes on a shared baseline. */
86
+ [`${PANEL} input, ${PANEL} button, ${PANEL} label`]: {
87
+ boxSizing: 'border-box',
88
+ verticalAlign: 'top',
89
+ margin: `0 ${GAP} ${ROW_GAP} 0`,
90
+ },
91
+
92
+ [`${PANEL} .cm-textfield`]: {
93
+ /* A column rather than a share of the row: find and replace are read as a
94
+ pair, and a pair that does not start and end at the same x is noise. */
95
+ width: '220px',
96
+ maxWidth: '100%',
97
+ boxSizing: 'border-box',
98
+ height: 'var(--gs-h-compact)',
99
+ padding: '0 8px',
100
+ font: 'inherit',
101
+ fontSize: 'var(--gs-t-dense)',
102
+ color: 'var(--gs-fg)',
103
+ backgroundColor: 'var(--gs-surface)',
104
+ border: '1px solid var(--gs-border)',
105
+ borderRadius: 'var(--gs-r-control)',
106
+ },
107
+ [`${PANEL} .cm-textfield::placeholder`]: { color: 'var(--gs-fg-faint)' },
108
+ [`${PANEL} .cm-textfield:focus`]: { outline: 'none', borderColor: 'var(--gs-accent)' },
109
+
110
+ /* `3/128`. A span rather than a control, so it takes none of the shared
111
+ rule above and states its own box; the meta size and the faint ink keep it
112
+ a readout beside the field rather than a fourth thing to click. */
113
+ [`${PANEL} .cm-gwSearchCount`]: {
114
+ display: 'inline-flex',
115
+ alignItems: 'center',
116
+ boxSizing: 'border-box',
117
+ height: 'var(--gs-h-compact)',
118
+ margin: `0 ${GAP} ${ROW_GAP} 0`,
119
+ verticalAlign: 'top',
120
+ font: 'inherit',
121
+ fontSize: 'var(--gs-t-meta)',
122
+ lineHeight: '1',
123
+ whiteSpace: 'nowrap',
124
+ fontVariantNumeric: 'tabular-nums',
125
+ color: 'var(--gs-fg-faint)',
126
+ },
127
+
128
+ [`${PANEL} .cm-button`]: {
129
+ display: 'inline-flex',
130
+ alignItems: 'center',
131
+ justifyContent: 'center',
132
+ boxSizing: 'border-box',
133
+ height: 'var(--gs-h-compact)',
134
+ padding: 'var(--gs-pad-compact)',
135
+ font: 'inherit',
136
+ fontSize: 'var(--gs-t-dense)',
137
+ lineHeight: '1',
138
+ whiteSpace: 'nowrap',
139
+ color: 'var(--gs-fg-dim)',
140
+ /* The gradient is the library's, and it is on `background-image`: a
141
+ background COLOUR alone would leave it painted on top. */
142
+ backgroundColor: 'transparent',
143
+ backgroundImage: 'none',
144
+ border: '1px solid var(--gs-border)',
145
+ borderRadius: 'var(--gs-r-control)',
146
+ cursor: 'pointer',
147
+ transition: 'background 120ms ease, color 120ms ease, border-color 120ms ease',
148
+ },
149
+ [`${PANEL} .cm-button:hover`]: {
150
+ color: 'var(--gs-fg)',
151
+ backgroundColor: 'var(--gs-raise)',
152
+ backgroundImage: 'none',
153
+ borderColor: 'var(--gs-fg-fainter)',
154
+ },
155
+ /* The library keeps a second gradient for the pressed state. */
156
+ [`${PANEL} .cm-button:active`]: {
157
+ backgroundColor: 'var(--gs-raise)',
158
+ backgroundImage: 'none',
159
+ },
160
+ [`${PANEL} .cm-button:focus-visible`]: { outline: '2px solid var(--gs-accent)', outlineOffset: '1px' },
161
+
162
+ [`${PANEL} label`]: {
163
+ display: 'inline-flex',
164
+ alignItems: 'center',
165
+ boxSizing: 'border-box',
166
+ height: 'var(--gs-h-compact)',
167
+ gap: '5px',
168
+ font: 'inherit',
169
+ fontSize: 'var(--gs-t-meta)',
170
+ lineHeight: '1',
171
+ whiteSpace: 'nowrap',
172
+ color: 'var(--gs-fg-dim)',
173
+ cursor: 'pointer',
174
+ WebkitUserSelect: 'none',
175
+ userSelect: 'none',
176
+ },
177
+
178
+ [`${PANEL} input[type=checkbox]`]: {
179
+ WebkitAppearance: 'none',
180
+ appearance: 'none',
181
+ position: 'relative',
182
+ boxSizing: 'border-box',
183
+ width: '14px',
184
+ height: '14px',
185
+ margin: '0',
186
+ backgroundColor: 'transparent',
187
+ border: '1px solid var(--gs-fg-fainter)',
188
+ borderRadius: '3px',
189
+ cursor: 'pointer',
190
+ transition: 'background 120ms ease, border-color 120ms ease',
191
+ },
192
+ [`${PANEL} input[type=checkbox]:hover`]: { borderColor: 'var(--gs-fg-dim)' },
193
+ [`${PANEL} input[type=checkbox]:checked`]: {
194
+ backgroundColor: 'var(--gs-accent)',
195
+ borderColor: 'var(--gs-accent)',
196
+ },
197
+ [`${PANEL} input[type=checkbox]:focus-visible`]: { outline: '2px solid var(--gs-accent)', outlineOffset: '1px' },
198
+ [`${PANEL} input[type=checkbox]:checked::after`]: {
199
+ content: '""',
200
+ position: 'absolute',
201
+ left: '4px',
202
+ top: '1px',
203
+ width: '3px',
204
+ height: '7px',
205
+ border: 'solid var(--gs-on-accent)',
206
+ borderWidth: '0 1.5px 1.5px 0',
207
+ transform: 'rotate(42deg)',
208
+ },
209
+
210
+ /* Close. Square and quiet — it dismisses a panel, so it is not the drawer's
211
+ red header close. `background-color` is restated because the library sets
212
+ it to `inherit`, which would take the strip's fill. */
213
+ [`${PANEL} [name=close]`]: {
214
+ position: 'absolute',
215
+ top: '8px',
216
+ right: '8px',
217
+ display: 'inline-flex',
218
+ alignItems: 'center',
219
+ justifyContent: 'center',
220
+ width: '20px',
221
+ height: '20px',
222
+ padding: '0',
223
+ margin: '0',
224
+ font: 'inherit',
225
+ fontSize: 'var(--gs-t-ui)',
226
+ lineHeight: '1',
227
+ color: 'var(--gs-fg-faint)',
228
+ backgroundColor: 'transparent',
229
+ border: '1px solid transparent',
230
+ borderRadius: 'var(--gs-r-control)',
231
+ cursor: 'pointer',
232
+ },
233
+ [`${PANEL} [name=close]:hover`]: { color: 'var(--gs-fg)', backgroundColor: 'var(--gs-raise)' },
234
+ [`${PANEL} [name=close]:focus-visible`]: { outline: '2px solid var(--gs-accent)', outlineOffset: '1px' },
235
+
236
+ /* A hit, and the hit the caret is on. Amber is what an editor uses to say
237
+ "found", and `--gs-warn` is the only warm token every palette defines;
238
+ the current one takes the drawer's single selected idiom —
239
+ `--gs-accent-bg` inside `--gs-accent-border` — rather than a second
240
+ highlighter colour. Declared after the plain match: both classes sit on
241
+ the same span at the same specificity, so source order decides. */
242
+ '.cm-searchMatch': {
243
+ backgroundColor: 'color-mix(in srgb, var(--gs-warn) 30%, transparent)',
244
+ borderRadius: '2px',
245
+ },
246
+ '.cm-searchMatch-selected': {
247
+ backgroundColor: 'color-mix(in srgb, var(--gs-accent) 34%, transparent)',
248
+ boxShadow: 'inset 0 0 0 1px var(--gs-accent)',
249
+ },
250
+ }
@@ -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 —