@young1lin/dsh-ui-gitworkbench 0.1.6 → 0.1.8
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/CHANGELOG.md +23 -0
- package/CHANGELOG_EN.md +24 -0
- package/lib/client.js +836 -413
- package/lib/index.js +48 -28
- package/package.json +1 -1
- package/src/client/GitWorkbenchPanel.module.css +46 -9
- package/src/client/GitWorkbenchPanel.tsx +366 -98
- package/src/client/diff-nav.ts +28 -0
- package/src/client/highlight.ts +56 -0
- package/src/client/history-layout.ts +52 -0
- package/src/client/index.ts +10 -2
- package/src/client/locales.ts +8 -0
- package/src/client/row-window.ts +132 -0
- package/src/client/use-change-nav.ts +19 -9
- package/src/index.ts +46 -25
package/src/client/diff-nav.ts
CHANGED
|
@@ -196,3 +196,31 @@ export function countBlocks(ids: readonly number[]): number {
|
|
|
196
196
|
for (const id of ids) if (id > top) top = id
|
|
197
197
|
return top + 1
|
|
198
198
|
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Where each change block sits, derived from the rows rather than measured.
|
|
202
|
+
*
|
|
203
|
+
* A windowed pane cannot measure: the block being walked to usually has no
|
|
204
|
+
* element, because the whole point is that it is not on screen. The rows are a
|
|
205
|
+
* fixed height there by construction, so the position is arithmetic.
|
|
206
|
+
*
|
|
207
|
+
* @param blocks - each row's block id, `-1` for a row that is not part of one.
|
|
208
|
+
* @param rowH - row height in px.
|
|
209
|
+
* @param offset - px above the first row, e.g. the grid's top padding.
|
|
210
|
+
* @returns the first row of each block, in the order the blocks appear.
|
|
211
|
+
*/
|
|
212
|
+
export function blockTopsFromRows(
|
|
213
|
+
blocks: readonly number[],
|
|
214
|
+
rowH: number,
|
|
215
|
+
offset = 0,
|
|
216
|
+
): readonly BlockTop[] {
|
|
217
|
+
const tops: BlockTop[] = []
|
|
218
|
+
const seen = new Set<number>()
|
|
219
|
+
for (let i = 0; i < blocks.length; i += 1) {
|
|
220
|
+
const block = blocks[i]!
|
|
221
|
+
if (!Number.isInteger(block) || block < 0 || seen.has(block)) continue
|
|
222
|
+
seen.add(block)
|
|
223
|
+
tops.push({ block, top: offset + i * rowH })
|
|
224
|
+
}
|
|
225
|
+
return tops
|
|
226
|
+
}
|
package/src/client/highlight.ts
CHANGED
|
@@ -223,6 +223,62 @@ export function highlightWholeFile(
|
|
|
223
223
|
return tokenizeLines(lines, lang, theme)
|
|
224
224
|
}
|
|
225
225
|
|
|
226
|
+
/**
|
|
227
|
+
* How many lines above the window are tokenized for context.
|
|
228
|
+
*
|
|
229
|
+
* Shiki lexes a string from its start, so a slice beginning inside a block
|
|
230
|
+
* comment or a template literal would colour as if it were code. Reading a
|
|
231
|
+
* lead-in restores that state for everything but a construct longer than this,
|
|
232
|
+
* at a fraction of the cost of the file: at 4,000 lines the whole-file pass was
|
|
233
|
+
* the entire remaining freeze once the DOM was bounded.
|
|
234
|
+
*/
|
|
235
|
+
export const HIGHLIGHT_LEAD_IN = 240
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Runs for the rows in a window, and nothing outside it.
|
|
239
|
+
*
|
|
240
|
+
* This is the pane's whole highlighting cost now, and it is proportional to the
|
|
241
|
+
* viewport rather than to the file. Measured on a 4,000-line file with a
|
|
242
|
+
* one-line change: whole-file passes plus per-line re-lexing froze the pane for
|
|
243
|
+
* 1.4 seconds; the same file behind an extension no grammar claims cost 22ms of
|
|
244
|
+
* script, which is what proved the entire remainder was Shiki.
|
|
245
|
+
*
|
|
246
|
+
* Both passes happen inside the window: the slice pass, which knows about
|
|
247
|
+
* multi-line constructs within its reach, and the per-line re-lex that makes a
|
|
248
|
+
* diff reconstruction colour as top-level code.
|
|
249
|
+
*
|
|
250
|
+
* @param lines - source lines, no leading +/-.
|
|
251
|
+
* @param lang - from {@link shikiLangOf}.
|
|
252
|
+
* @param theme - from {@link shikiThemeOf}.
|
|
253
|
+
* @param from - first row in the window.
|
|
254
|
+
* @param to - one past the last row in the window.
|
|
255
|
+
* @returns an array indexed by ROW, filled only inside the window; undefined
|
|
256
|
+
* when no grammar applies, which the caller already renders as plain text.
|
|
257
|
+
*/
|
|
258
|
+
export function highlightWindow(
|
|
259
|
+
lines: readonly string[],
|
|
260
|
+
lang: string | undefined,
|
|
261
|
+
theme: string,
|
|
262
|
+
from: number,
|
|
263
|
+
to: number,
|
|
264
|
+
): (HighlightRun[] | undefined)[] | undefined {
|
|
265
|
+
const first = Math.max(0, Math.trunc(from))
|
|
266
|
+
const last = Math.min(lines.length, Math.trunc(to))
|
|
267
|
+
if (last <= first) return undefined
|
|
268
|
+
const lead = Math.max(0, first - HIGHLIGHT_LEAD_IN)
|
|
269
|
+
const sliceTok = tokenizeLines(lines.slice(lead, last), lang, theme)
|
|
270
|
+
if (sliceTok === undefined) return undefined
|
|
271
|
+
const out: (HighlightRun[] | undefined)[] = new Array<HighlightRun[] | undefined>(lines.length)
|
|
272
|
+
for (let i = first; i < last; i += 1) {
|
|
273
|
+
const line = lines[i]!
|
|
274
|
+
const together = sliceTok[i - lead] ?? [{ text: line, color: undefined }]
|
|
275
|
+
if (looksLikeCommentLine(line)) { out[i] = together; continue }
|
|
276
|
+
const solo = tokenizeLines([line], lang, theme)?.[0]
|
|
277
|
+
out[i] = solo !== undefined && solo.length > 0 ? solo : together
|
|
278
|
+
}
|
|
279
|
+
return out
|
|
280
|
+
}
|
|
281
|
+
|
|
226
282
|
function looksLikeCommentLine(text: string): boolean {
|
|
227
283
|
const t = text.trimStart()
|
|
228
284
|
return t.startsWith('//') || t.startsWith('/*') || t.startsWith('*') || t.startsWith('#')
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which way the History tab arranges its panes.
|
|
3
|
+
*
|
|
4
|
+
* Two arrangements, kept because they are good at different things and the
|
|
5
|
+
* drawer's width is fixed, so neither wins outright.
|
|
6
|
+
*
|
|
7
|
+
* `columns` puts the commit list beside the tree and the diff. The list is then
|
|
8
|
+
* as tall as the drawer, which is what makes a log scannable — but it is a 26%
|
|
9
|
+
* column, and measured on a 1700px drawer that left about 420px, enough for a
|
|
10
|
+
* subject of roughly forty characters before the ellipsis.
|
|
11
|
+
*
|
|
12
|
+
* `stacked` spans the list across the top instead, the way IDEA's git log does.
|
|
13
|
+
* No subject is cut and thirty rows fit where the column fitted thirteen, at
|
|
14
|
+
* the cost of the height the diff below it would otherwise have had.
|
|
15
|
+
*/
|
|
16
|
+
export type HistoryLayout = 'columns' | 'stacked'
|
|
17
|
+
|
|
18
|
+
/** Every layout, in the order the switch offers them. */
|
|
19
|
+
export const HISTORY_LAYOUTS: readonly HistoryLayout[] = ['columns', 'stacked']
|
|
20
|
+
|
|
21
|
+
/** The arrangement the tab had first, and what a reader who never touches the
|
|
22
|
+
* switch gets. */
|
|
23
|
+
export const DEFAULT_HISTORY_LAYOUT: HistoryLayout = 'columns'
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Narrow a stored value to a layout this build has.
|
|
27
|
+
*
|
|
28
|
+
* Storage is a durable boundary: the value on disk was written by whatever
|
|
29
|
+
* build the reader ran last, and a layout this one has since dropped must fall
|
|
30
|
+
* back rather than reach the stylesheet.
|
|
31
|
+
* @param value - value read back from storage.
|
|
32
|
+
* @returns whether it names a layout.
|
|
33
|
+
*/
|
|
34
|
+
export function isHistoryLayout(value: unknown): value is HistoryLayout {
|
|
35
|
+
return HISTORY_LAYOUTS.some(known => known === value)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* How tall one commit row is, per layout.
|
|
40
|
+
*
|
|
41
|
+
* The two differ because the row itself differs: a column has no width to
|
|
42
|
+
* spend, so the hash, the author and the date take a line of their own above
|
|
43
|
+
* the subject; spanning the drawer they all fit on one line and a 48px row
|
|
44
|
+
* would spend the stacked layout's scarcest axis on padding.
|
|
45
|
+
*
|
|
46
|
+
* This is the ONE home for the number. The panel publishes the active entry as
|
|
47
|
+
* `--gs-commit-row` and the stylesheet sizes the row from it, while the lane
|
|
48
|
+
* graph draws each row's segment exactly that tall — the lanes only meet across
|
|
49
|
+
* the seam between rows if both come from here. Guarded by
|
|
50
|
+
* `tests/commit-row-height.test.ts`.
|
|
51
|
+
*/
|
|
52
|
+
export const COMMIT_ROW_H: Record<HistoryLayout, number> = { columns: 48, stacked: 28 }
|
package/src/client/index.ts
CHANGED
|
@@ -73,11 +73,19 @@ export function apply(ctx: ClientContext): void {
|
|
|
73
73
|
},
|
|
74
74
|
// On-demand single-file diff. With `commit` it is that commit's change to the
|
|
75
75
|
// file; without it, the working tree (tracked: git diff HEAD --; untracked: synthesized).
|
|
76
|
-
fetchFileDiff: async (worktreePath: string | undefined, path: string, commit: string | undefined, signal: AbortSignal): Promise<string> => {
|
|
76
|
+
fetchFileDiff: async (worktreePath: string | undefined, path: string, commit: string | undefined, range: { base: string; head: string } | undefined, signal: AbortSignal): Promise<string> => {
|
|
77
77
|
const result = await connection.rpc.call(
|
|
78
78
|
'/api',
|
|
79
79
|
'gitWorkbench/fileDiff',
|
|
80
|
-
{
|
|
80
|
+
{
|
|
81
|
+
args: {
|
|
82
|
+
worktreePath: worktreePath ?? '', path,
|
|
83
|
+
// Omitted rather than sent as undefined: a JSON payload with an
|
|
84
|
+
// undefined value is not what the gateway reads back.
|
|
85
|
+
...commit === undefined ? {} : { commit },
|
|
86
|
+
...range === undefined ? {} : { base: range.base, head: range.head },
|
|
87
|
+
},
|
|
88
|
+
},
|
|
81
89
|
signal,
|
|
82
90
|
) as { ok: true; value: { diff: string } } | { ok: false; error: { message?: string } }
|
|
83
91
|
return result.ok ? result.value.diff : ''
|
package/src/client/locales.ts
CHANGED
|
@@ -47,6 +47,8 @@ export type WorkbenchKey =
|
|
|
47
47
|
| 'bgNone' | 'bgChoose' | 'bgClear' | 'bgBlur' | 'bgVeil' | 'bgWorking' | 'bgFailed' | 'bgTooBig'
|
|
48
48
|
| 'cssPlaceholder' | 'cssImport' | 'cssApply' | 'cssUnapplied' | 'styleFailed'
|
|
49
49
|
| 'resizeLabel' | 'resizeCommits' | 'resizeTree' | 'resizeSides'
|
|
50
|
+
// History arrangement switch: the commit list beside the diff, or above it
|
|
51
|
+
| 'historyLayout' | 'layoutColumns' | 'layoutStacked'
|
|
50
52
|
| 'expandAll' | 'collapseAll' | 'noBranch'
|
|
51
53
|
| 'copyCommit' | 'copiedCommit'
|
|
52
54
|
// write operations
|
|
@@ -149,6 +151,9 @@ export const zh: Record<WorkbenchKey, string> = {
|
|
|
149
151
|
styleFailed: '保存失败',
|
|
150
152
|
resizeLabel: '拖动调整抽屉宽度',
|
|
151
153
|
resizeCommits: '拖动调整提交列表宽度',
|
|
154
|
+
historyLayout: '提交列表布局',
|
|
155
|
+
layoutColumns: '并排:列表与差异分列',
|
|
156
|
+
layoutStacked: '上下:列表横跨顶部',
|
|
152
157
|
resizeTree: '拖动调整文件树宽度',
|
|
153
158
|
resizeSides: '拖动调整左右两栏宽度',
|
|
154
159
|
sourceLabel: '统计来源切换',
|
|
@@ -384,6 +389,9 @@ export const en: Record<WorkbenchKey, string> = {
|
|
|
384
389
|
styleFailed: 'Could not save',
|
|
385
390
|
resizeLabel: 'Drag to resize the drawer',
|
|
386
391
|
resizeCommits: 'Drag to resize the commit list',
|
|
392
|
+
historyLayout: 'Commit list layout',
|
|
393
|
+
layoutColumns: 'Side by side: list beside the diff',
|
|
394
|
+
layoutStacked: 'Stacked: list across the top',
|
|
387
395
|
resizeTree: 'Drag to resize the file tree',
|
|
388
396
|
resizeSides: 'Drag to resize the two columns',
|
|
389
397
|
sourceLabel: 'Switch stats source',
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which rows a long diff actually has to put in the DOM.
|
|
3
|
+
*
|
|
4
|
+
* The side-by-side pane renders the WHOLE file in two columns, so its cost is
|
|
5
|
+
* linear in the file's length rather than in the size of the change. Measured
|
|
6
|
+
* on two files with one changed line each: 22 lines cost 349ms of main thread
|
|
7
|
+
* and 44 cells; 4,000 lines cost 3,868ms, 8,000 cells and 112,000 token spans,
|
|
8
|
+
* with a single 3.6-second frame during which nothing on the page moved. The
|
|
9
|
+
* guard lets a file through at 20,000 lines, which is five times that again.
|
|
10
|
+
*
|
|
11
|
+
* Windowing is exact here rather than approximate, because the pane's rows are
|
|
12
|
+
* a fixed height by construction: `.sideCode` is `white-space: pre` so no line
|
|
13
|
+
* ever wraps, and `.sideColGrid` sets `line-height` and `align-content: start`,
|
|
14
|
+
* which puts row `i` at `i * rowH` with no measuring at all.
|
|
15
|
+
*
|
|
16
|
+
* Pure: no React, no DOM. `tests/row-window.test.ts` loads it directly.
|
|
17
|
+
*
|
|
18
|
+
* @module @young1lin/dsh-ui-gitworkbench/client/row-window
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** The pane's row height, matching `.sideColGrid`'s `line-height`. */
|
|
22
|
+
export const DIFF_ROW_H = 20
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The grid's own top padding, from `.sideColGrid`.
|
|
26
|
+
*
|
|
27
|
+
* Only the change walk uses it, and only to place a block within the scrolled
|
|
28
|
+
* content. Being a few pixels out there is invisible — the walk deliberately
|
|
29
|
+
* leaves three rows of context above whatever it lands on, so this is well
|
|
30
|
+
* inside the margin it already keeps — which is why it is stated once here
|
|
31
|
+
* rather than published into the stylesheet as a custom property.
|
|
32
|
+
*/
|
|
33
|
+
export const DIFF_GRID_PAD_TOP = 8
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Files at or below this many rows are rendered whole.
|
|
37
|
+
*
|
|
38
|
+
* Windowing has its own costs — a scroll listener, two spacers, and rows that
|
|
39
|
+
* enter and leave the DOM — and none of them buys anything on a file that was
|
|
40
|
+
* never slow. Below the threshold the pane produces exactly the DOM it
|
|
41
|
+
* produced before, so the ordinary case is untouched by this change.
|
|
42
|
+
*/
|
|
43
|
+
export const WINDOW_WHOLE_BELOW = 400
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Rows kept beyond each edge of the viewport.
|
|
47
|
+
*
|
|
48
|
+
* Enough that a flick of the wheel lands on rows that are already there:
|
|
49
|
+
* dropping this to zero makes a fast scroll show blank bands, and raising it
|
|
50
|
+
* far just renders the file again.
|
|
51
|
+
*/
|
|
52
|
+
export const WINDOW_OVERSCAN = 40
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* A viewport height to assume before the scroller has been measured.
|
|
56
|
+
*
|
|
57
|
+
* The first paint happens before any layout effect runs. Rendering nothing
|
|
58
|
+
* until the height is known would flash an empty pane; a screenful is both
|
|
59
|
+
* safe and close.
|
|
60
|
+
*/
|
|
61
|
+
const ASSUMED_VIEWPORT_PX = 1200
|
|
62
|
+
|
|
63
|
+
/** The rows to render, and the empty space standing in for the rest. */
|
|
64
|
+
export interface RowWindow {
|
|
65
|
+
/** First row to render. */
|
|
66
|
+
readonly start: number
|
|
67
|
+
/** One past the last row to render. */
|
|
68
|
+
readonly end: number
|
|
69
|
+
/** Height of the spacer above, in px. */
|
|
70
|
+
readonly padTop: number
|
|
71
|
+
/** Height of the spacer below, in px. */
|
|
72
|
+
readonly padBottom: number
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* @param value - a number from the DOM, which can be NaN or negative.
|
|
77
|
+
* @param fallback - used when it is neither finite nor usable.
|
|
78
|
+
* @returns a finite, non-negative number.
|
|
79
|
+
*/
|
|
80
|
+
function sane(value: number, fallback: number): number {
|
|
81
|
+
return Number.isFinite(value) && value > 0 ? value : fallback
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The window of rows to render for a given scroll position.
|
|
86
|
+
*
|
|
87
|
+
* @param scrollTop - the scroller's current offset in px.
|
|
88
|
+
* @param viewportH - the scroller's visible height in px; 0 before it is measured.
|
|
89
|
+
* @param rowCount - how many rows the file has.
|
|
90
|
+
* @param rowH - row height in px.
|
|
91
|
+
* @param overscan - rows to keep beyond each edge.
|
|
92
|
+
* @returns the rows to render and the spacer heights standing in for the rest.
|
|
93
|
+
*/
|
|
94
|
+
export function rowWindow(
|
|
95
|
+
scrollTop: number,
|
|
96
|
+
viewportH: number,
|
|
97
|
+
rowCount: number,
|
|
98
|
+
rowH: number = DIFF_ROW_H,
|
|
99
|
+
overscan: number = WINDOW_OVERSCAN,
|
|
100
|
+
): RowWindow {
|
|
101
|
+
const rows = Math.max(0, Math.trunc(rowCount))
|
|
102
|
+
const height = sane(rowH, DIFF_ROW_H)
|
|
103
|
+
if (rows <= WINDOW_WHOLE_BELOW) return { start: 0, end: rows, padTop: 0, padBottom: 0 }
|
|
104
|
+
|
|
105
|
+
const top = Number.isFinite(scrollTop) && scrollTop > 0 ? scrollTop : 0
|
|
106
|
+
const view = sane(viewportH, ASSUMED_VIEWPORT_PX)
|
|
107
|
+
const pad = Math.max(0, Math.trunc(overscan))
|
|
108
|
+
|
|
109
|
+
const first = Math.max(0, Math.floor(top / height) - pad)
|
|
110
|
+
const last = Math.min(rows, Math.ceil((top + view) / height) + pad)
|
|
111
|
+
// Scrolled past the end (a file that shrank under a live poll), `last` can
|
|
112
|
+
// land below `first`; an empty window is still a valid answer, and the
|
|
113
|
+
// spacers must add up to the full height either way.
|
|
114
|
+
const start = Math.min(first, rows)
|
|
115
|
+
const end = Math.max(start, last)
|
|
116
|
+
return { start, end, padTop: start * height, padBottom: (rows - end) * height }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Where a row sits inside the scroller, without touching the DOM.
|
|
121
|
+
*
|
|
122
|
+
* The change walk used to measure this off `getBoundingClientRect`, which
|
|
123
|
+
* stops working the moment a row it wants is outside the window — and a
|
|
124
|
+
* windowed pane's next change is very often exactly that.
|
|
125
|
+
*
|
|
126
|
+
* @param index - the row's index.
|
|
127
|
+
* @param rowH - row height in px.
|
|
128
|
+
* @returns the row's offset from the top of the scrolled content.
|
|
129
|
+
*/
|
|
130
|
+
export function rowTop(index: number, rowH: number = DIFF_ROW_H): number {
|
|
131
|
+
return Math.max(0, Math.trunc(index)) * sane(rowH, DIFF_ROW_H)
|
|
132
|
+
}
|
|
@@ -29,8 +29,15 @@ export interface ChangeNav {
|
|
|
29
29
|
/**
|
|
30
30
|
* @param scrollRef - the element that scrolls, and the one whose subtree
|
|
31
31
|
* carries the `data-block` marks.
|
|
32
|
+
* @param tops - where the blocks are, when the caller can say. A WINDOWED pane
|
|
33
|
+
* must supply this: it renders only the rows near the viewport, so the block
|
|
34
|
+
* the reader is walking to usually has no DOM to measure. Omitted, the
|
|
35
|
+
* positions are measured off the `data-block` marks as before.
|
|
32
36
|
*/
|
|
33
|
-
export function useChangeNav(
|
|
37
|
+
export function useChangeNav(
|
|
38
|
+
scrollRef: MutableRefObject<HTMLDivElement | null>,
|
|
39
|
+
tops?: () => readonly BlockTop[],
|
|
40
|
+
): ChangeNav {
|
|
34
41
|
/** What the last press landed on. A ref, not state: it exists to make the
|
|
35
42
|
* NEXT press correct, and nothing on screen reads it. */
|
|
36
43
|
const navMemory = useRef<NavMemory | null>(null)
|
|
@@ -38,12 +45,15 @@ export function useChangeNav(scrollRef: MutableRefObject<HTMLDivElement | null>)
|
|
|
38
45
|
/**
|
|
39
46
|
* Every change block's position inside the scrolled content.
|
|
40
47
|
*
|
|
41
|
-
*
|
|
42
|
-
* which rows changed, not how many
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
48
|
+
* The fallback, for a pane that renders all of its rows. Measuring is the
|
|
49
|
+
* more general answer — a model knows which rows changed, not how many
|
|
50
|
+
* pixels down the page they sit, and the rows are never the only thing in
|
|
51
|
+
* the scroller. One layout read per PRESS is cheap; nothing here runs on
|
|
52
|
+
* scroll.
|
|
53
|
+
*
|
|
54
|
+
* It stops being available the moment a pane renders only part of its rows,
|
|
55
|
+
* which is why `tops` exists: what is not in the DOM cannot be measured, and
|
|
56
|
+
* a walk that skips every change outside the viewport is worse than no walk.
|
|
47
57
|
*
|
|
48
58
|
* `offsetTop` is deliberately not used: it is relative to whichever ancestor
|
|
49
59
|
* happens to be positioned, which no rule in the stylesheet guarantees.
|
|
@@ -69,8 +79,8 @@ export function useChangeNav(scrollRef: MutableRefObject<HTMLDivElement | null>)
|
|
|
69
79
|
const goToChange = (direction: 1 | -1): void => {
|
|
70
80
|
const scroller = scrollRef.current
|
|
71
81
|
if (scroller === null) return
|
|
72
|
-
const
|
|
73
|
-
const target = stepToBlock(
|
|
82
|
+
const measured = tops === undefined ? blockTops() : tops()
|
|
83
|
+
const target = stepToBlock(measured, anchorFrom(measured, scroller.scrollTop, navMemory.current), direction)
|
|
74
84
|
if (target === null) return
|
|
75
85
|
scroller.scrollTop = scrollTopFor(target.top)
|
|
76
86
|
// Read back rather than storing what was asked for: the browser clamps at
|
package/src/index.ts
CHANGED
|
@@ -91,8 +91,7 @@ export type { WriteResult }
|
|
|
91
91
|
const DIFF_CHAR_CAP = 400_000
|
|
92
92
|
/** Untracked files larger than this are listed + counted but never diffed. */
|
|
93
93
|
const UNTRACKED_FILE_BYTE_CAP = 1_000_000
|
|
94
|
-
|
|
95
|
-
const UNTRACKED_TOTAL_CHAR_CAP = 160_000
|
|
94
|
+
|
|
96
95
|
/** Files with a NUL byte in the first 8k are treated as binary. */
|
|
97
96
|
const BINARY_SNIFF_BYTES = 8_000
|
|
98
97
|
/** Context radius that makes `git diff` emit ONE hunk covering the whole file —
|
|
@@ -413,13 +412,21 @@ export class GitWorkbenchService extends TypertRemoteService {
|
|
|
413
412
|
async stats(worktreePath: string | undefined, signal: AbortSignal): Promise<WorkbenchStats> {
|
|
414
413
|
const cwd = typeof worktreePath === 'string' && worktreePath.length > 0 ? worktreePath : process.cwd()
|
|
415
414
|
|
|
416
|
-
//
|
|
415
|
+
// Three independent reads of the same worktree. Running them together is
|
|
417
416
|
// safe: git takes .git/index.lock only to write back a refreshed index and
|
|
418
417
|
// skips that write when it cannot get the lock, so the reports stay correct.
|
|
419
|
-
|
|
418
|
+
//
|
|
419
|
+
// `git diff HEAD` is NOT among them any more. This call is polled — every
|
|
420
|
+
// 3 seconds while an agent is running — and the full patch is the most
|
|
421
|
+
// expensive thing in it by a wide margin: measured on a worktree with
|
|
422
|
+
// 90,000 changed lines it took 595ms and produced 7.43MB, of which the
|
|
423
|
+
// 400,000-character clip below then discarded 94.6% before it ever reached
|
|
424
|
+
// the browser. The tree and the counters need only `status` and
|
|
425
|
+
// `--numstat`, both of which stay around 110-140ms at that size, and the
|
|
426
|
+
// pane already fetches the file it is actually showing through `fileDiff`.
|
|
427
|
+
const [statusInfo, numstat, revInfo] = await Promise.all([
|
|
420
428
|
this.git(cwd, ['status', '--porcelain=v1', '--branch', '--untracked-files=all'], signal),
|
|
421
429
|
this.git(cwd, ['diff', 'HEAD', '--numstat'], signal),
|
|
422
|
-
this.git(cwd, ['diff', 'HEAD'], signal),
|
|
423
430
|
this.git(cwd, ['rev-parse', '--abbrev-ref', 'HEAD'], signal),
|
|
424
431
|
])
|
|
425
432
|
if (statusInfo.exitCode !== 0) {
|
|
@@ -430,25 +437,18 @@ export class GitWorkbenchService extends TypertRemoteService {
|
|
|
430
437
|
const counts = parseNumstat(numstat.stdout)
|
|
431
438
|
const files = parseStatus(statusInfo.stdout, counts)
|
|
432
439
|
|
|
433
|
-
//
|
|
434
|
-
//
|
|
435
|
-
//
|
|
436
|
-
//
|
|
440
|
+
// Every untracked file needs a line count and a binary flag for the tree,
|
|
441
|
+
// and both come off the raw buffer with no utf8 decode. The second pass
|
|
442
|
+
// that used to follow — decoding some of them and synthesizing new-file
|
|
443
|
+
// segments into the payload — is gone with the payload itself; `fileDiff`
|
|
444
|
+
// synthesizes the one segment the reader has actually opened.
|
|
437
445
|
const untracked = files.filter(file => file.status === 'untracked')
|
|
438
446
|
const measured = await mapPooled(untracked, UNTRACKED_READ_CONCURRENCY, file => measureUntracked(cwd, file.path))
|
|
439
447
|
|
|
440
|
-
let budget = UNTRACKED_TOTAL_CHAR_CAP
|
|
441
|
-
let untrackedDiff = ''
|
|
442
448
|
for (const [index, file] of untracked.entries()) {
|
|
443
449
|
const measure = measured[index]
|
|
444
450
|
file.addedLines = measure.lineCount
|
|
445
451
|
file.binary = measure.binary
|
|
446
|
-
if (budget <= 0 || !measure.diffable) continue
|
|
447
|
-
const segment = await untrackedSegment(cwd, file.path)
|
|
448
|
-
if (segment === null) continue
|
|
449
|
-
const text = clipDiff(segment, budget, '…[untracked diff truncated]')
|
|
450
|
-
untrackedDiff += `${text}\n`
|
|
451
|
-
budget -= text.length
|
|
452
452
|
}
|
|
453
453
|
|
|
454
454
|
let addedLines = 0
|
|
@@ -481,14 +481,13 @@ export class GitWorkbenchService extends TypertRemoteService {
|
|
|
481
481
|
}
|
|
482
482
|
const { ahead, behind } = parseBranch(statusInfo.stdout)
|
|
483
483
|
|
|
484
|
-
let combined = diff.stdout
|
|
485
|
-
if (untrackedDiff.length > 0) combined += `\n${untrackedDiff}`
|
|
486
|
-
combined = clipDiff(combined, DIFF_CHAR_CAP, '…[diff truncated]')
|
|
487
484
|
|
|
488
485
|
return {
|
|
489
486
|
worktreePath: cwd, branch, ahead, behind, detached,
|
|
490
487
|
addedLines, deletedLines, addedFiles, deletedFiles, modifiedFiles,
|
|
491
|
-
|
|
488
|
+
// No bundled patch: every per-file diff is fetched on demand. See the
|
|
489
|
+
// reads above for what that saves and why it is affordable.
|
|
490
|
+
files, diff: '',
|
|
492
491
|
// No log here: this call is polled every 15s, and the history list follows
|
|
493
492
|
// a ref this one knows nothing about. `commits` serves it instead.
|
|
494
493
|
commits: [],
|
|
@@ -496,14 +495,36 @@ export class GitWorkbenchService extends TypertRemoteService {
|
|
|
496
495
|
}
|
|
497
496
|
|
|
498
497
|
/**
|
|
499
|
-
* One file's diff on demand
|
|
500
|
-
*
|
|
501
|
-
*
|
|
498
|
+
* One file's diff on demand, for whichever view is asking.
|
|
499
|
+
*
|
|
500
|
+
* Three questions, because the drawer's three tabs are asking three different
|
|
501
|
+
* things about the same path and only the caller knows which:
|
|
502
|
+
*
|
|
503
|
+
* - with `commit`, that commit's change to the file;
|
|
504
|
+
* - with `base` and `head`, what differs between two refs — the Compare
|
|
505
|
+
* tab, which until now had no way to ask at all and showed a file with no
|
|
506
|
+
* detail whenever the bundled payload did not carry it;
|
|
507
|
+
* - with neither, the working tree against HEAD.
|
|
508
|
+
*
|
|
509
|
+
* The range answer is deliberately NOT cached, for the same reason
|
|
510
|
+
* `compareRefs` is not: a ref name is a moving pointer, unlike a commit hash.
|
|
511
|
+
*
|
|
512
|
+
* Plain-identifier params, signal last.
|
|
502
513
|
*/
|
|
503
514
|
@Remote('fileDiff')
|
|
504
|
-
async fileDiff(worktreePath: string, path: string, commit: string | undefined, signal: AbortSignal): Promise<{ readonly diff: string }> {
|
|
515
|
+
async fileDiff(worktreePath: string, path: string, commit: string | undefined, base: string | undefined, head: string | undefined, signal: AbortSignal): Promise<{ readonly diff: string }> {
|
|
505
516
|
const cwd = typeof worktreePath === 'string' && worktreePath.length > 0 ? worktreePath : process.cwd()
|
|
506
517
|
if (typeof path !== 'string' || path.length === 0) return { diff: '' }
|
|
518
|
+
if (typeof base === 'string' && base.length > 0 && typeof head === 'string' && head.length > 0) {
|
|
519
|
+
if (!isRefName(base) || !isRefName(head)) return { diff: '' }
|
|
520
|
+
const ranged = await this.git(cwd, ['diff', '--no-renames', `${base}...${head}`, '--', path], signal)
|
|
521
|
+
if (ranged.exitCode === 0) return { diff: ranged.stdout }
|
|
522
|
+
// Unrelated histories have no merge base for `A...B` to diff from; the
|
|
523
|
+
// two-tip diff still answers what differs, exactly as `compareRefs` does.
|
|
524
|
+
if (!isNoMergeBaseError(ranged.stderr)) return { diff: '' }
|
|
525
|
+
const tips = await this.git(cwd, ['diff', '--no-renames', base, head, '--', path], signal)
|
|
526
|
+
return { diff: tips.exitCode === 0 ? tips.stdout : '' }
|
|
527
|
+
}
|
|
507
528
|
if (typeof commit === 'string' && commit.length > 0) {
|
|
508
529
|
if (!COMMIT_HASH.test(commit)) return { diff: '' }
|
|
509
530
|
const key = cacheKey(cwd, commit, path)
|