@young1lin/dsh-ui-gitworkbench 0.1.11 → 0.1.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +1 -1
- package/CHANGELOG.md +28 -0
- package/CHANGELOG_EN.md +28 -0
- package/README.md +100 -50
- package/README_EN.md +2 -1
- package/lib/client.js +6902 -6256
- package/package.json +1 -1
- package/src/client/ChangesFileTree.tsx +550 -0
- package/src/client/ChromeGlyph.tsx +27 -0
- package/src/client/CodeEditor.tsx +129 -3
- package/src/client/CommitHistory.tsx +1001 -0
- package/src/client/DiffViews.tsx +1070 -0
- package/src/client/GitWorkbenchPanel.module.css +15 -2513
- package/src/client/GitWorkbenchPanel.tsx +37 -3959
- package/src/client/PaneDivider.tsx +74 -0
- package/src/client/WorkbenchControls.tsx +1047 -0
- package/src/client/WorktreeGlyph.tsx +24 -0
- package/src/client/cm-search-theme.ts +250 -0
- package/src/client/diff-nav.ts +59 -0
- package/src/client/git-workbench-types.ts +252 -0
- package/src/client/locales.ts +14 -8
- package/src/client/row-window.ts +23 -0
- package/src/client/search-count.ts +125 -0
- package/src/client/side-rows.ts +66 -0
- package/src/client/styles/changes.css +505 -0
- package/src/client/styles/controls.css +236 -0
- package/src/client/styles/environment.css +89 -0
- package/src/client/styles/files.css +113 -0
- package/src/client/styles/history-filters.css +400 -0
- package/src/client/styles/history.css +276 -0
- package/src/client/styles/image.css +67 -0
- package/src/client/styles/operations.css +179 -0
- package/src/client/styles/shell.css +431 -0
- package/src/client/styles/themes.css +224 -0
- package/src/client/use-change-nav.ts +6 -5
- package/src/client/use-row-window.ts +55 -0
|
@@ -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
|
+
}
|
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 —
|