@young1lin/dsh-ui-gitworkbench 0.1.14 → 0.1.16
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 +23 -0
- package/README.md +29 -3
- package/README_EN.md +1 -1
- package/lib/client.js +1512 -470
- package/lib/dir-listing.js +34 -0
- package/lib/fs-remove.js +5 -36
- package/lib/index.js +346 -117
- package/lib/path-lock.js +54 -0
- package/lib/repo-root.js +60 -0
- package/lib/worktree.js +83 -0
- package/lib/write-checked.js +1 -1
- package/package.json +5 -5
- package/src/client/ChromeGlyph.tsx +5 -0
- package/src/client/CodeEditor.tsx +19 -1
- package/src/client/DiffViews.tsx +116 -121
- package/src/client/FileBrowser.tsx +196 -23
- package/src/client/GitWorkbenchPanel.module.css +1 -0
- package/src/client/GitWorkbenchPanel.tsx +100 -12
- package/src/client/SideRails.tsx +106 -0
- package/src/client/diff-cells.tsx +147 -0
- package/src/client/diff-nav.ts +4 -1
- package/src/client/dir-tree.ts +31 -1
- package/src/client/file-rows.ts +40 -0
- package/src/client/h-rail.ts +70 -0
- package/src/client/ignored-cache.ts +193 -0
- package/src/client/index.ts +22 -3
- package/src/client/locales.ts +12 -2
- package/src/client/row-heights.ts +225 -0
- package/src/client/styles/changes.css +33 -2
- package/src/client/styles/controls.css +5 -0
- package/src/client/styles/files.css +5 -0
- package/src/client/styles/rails.css +72 -0
- package/src/client/use-row-window.ts +7 -3
- package/src/client/use-variable-row-window.ts +210 -0
- package/src/dir-listing.ts +47 -0
- package/src/fs-remove.ts +5 -36
- package/src/index.ts +372 -118
- package/src/path-lock.ts +56 -0
- package/src/repo-root.ts +66 -0
- package/src/types/dsh-shim.d.ts +12 -2
- package/src/worktree.ts +97 -0
- package/src/write-checked.ts +1 -1
package/src/client/locales.ts
CHANGED
|
@@ -21,10 +21,10 @@
|
|
|
21
21
|
export type WorkbenchKey =
|
|
22
22
|
| 'aheadTitle' | 'behindTitle' | 'files'
|
|
23
23
|
| 'filterFiles' | 'filterFilesPlaceholder' | 'filterFilesClear' | 'filesFiltered' | 'filterNoMatch'
|
|
24
|
-
| 'drawerLabel' | 'totalsDim' | 'refresh' | 'close'
|
|
24
|
+
| 'drawerLabel' | 'totalsDim' | 'refresh' | 'close' | 'wrapLines' | 'wrapLinesOff'
|
|
25
25
|
| 'tabsLabel' | 'tabChanges' | 'tabHistory' | 'tabCompare' | 'tabFiles'
|
|
26
26
|
// the Files tab: browse the repository, read a file, blame it, edit it
|
|
27
|
-
| 'fileSearchPlaceholder' | 'filesTruncated' | 'filesEmpty' | 'filesNoMatch' | 'filesPick'
|
|
27
|
+
| 'fileSearchPlaceholder' | 'filesTruncated' | 'filesEmpty' | 'filesNoMatch' | 'filesPick' | 'filesIgnored' | 'filesIgnoredCut' | 'filesIgnoredFailed'
|
|
28
28
|
| 'filesUnsavedAsk' | 'filesDiscardOpen' | 'filesMore' | 'filesVanished' | 'fileReadOnlyCrlf' | 'fileReadOnlyEncoding'
|
|
29
29
|
| 'blameWhileEditing' | 'blameLine' | 'blamePick' | 'blameInHistory'
|
|
30
30
|
| 'imageBroken' | 'imageFit' | 'imageActual' | 'imageTooLarge' | 'imageSource' | 'imagePreview'
|
|
@@ -90,6 +90,11 @@ export const zh: Record<WorkbenchKey, string> = {
|
|
|
90
90
|
tabFiles: '文件',
|
|
91
91
|
fileSearchPlaceholder: '搜索文件…',
|
|
92
92
|
filesTruncated: '文件太多,列表已截断;用搜索找剩下的。',
|
|
93
|
+
filesIgnored: '已被 gitignore',
|
|
94
|
+
wrapLines: '自动换行',
|
|
95
|
+
wrapLinesOff: '取消自动换行',
|
|
96
|
+
filesIgnoredCut: '这个目录条目太多,已截断;搜索找不到没列出的部分。',
|
|
97
|
+
filesIgnoredFailed: '无法列出被忽略的文件。',
|
|
93
98
|
filesEmpty: '这个仓库还没有文件',
|
|
94
99
|
filesNoMatch: '没有匹配的文件',
|
|
95
100
|
filesMore: '还有 {count} 个,用上面的搜索找',
|
|
@@ -332,6 +337,11 @@ export const en: Record<WorkbenchKey, string> = {
|
|
|
332
337
|
tabFiles: 'Files',
|
|
333
338
|
fileSearchPlaceholder: 'Search files…',
|
|
334
339
|
filesTruncated: 'Too many files to list; use the search for the rest.',
|
|
340
|
+
filesIgnored: 'Ignored (gitignore)',
|
|
341
|
+
wrapLines: 'Wrap long lines',
|
|
342
|
+
wrapLinesOff: 'Stop wrapping lines',
|
|
343
|
+
filesIgnoredCut: 'This directory has too many entries; the list is cut, and the search cannot reach the rest.',
|
|
344
|
+
filesIgnoredFailed: 'Could not list the ignored files.',
|
|
335
345
|
filesEmpty: 'This repository has no files yet',
|
|
336
346
|
filesNoMatch: 'No matching files',
|
|
337
347
|
filesMore: 'and {count} more — use the search above',
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Row offsets when the rows are NOT all the same height — what soft wrap makes
|
|
3
|
+
* of a diff pane.
|
|
4
|
+
*
|
|
5
|
+
* `row-window.ts` places row `i` at `i * 20px` with no measuring at all, and
|
|
6
|
+
* says so: the panes guarantee it by writing `white-space: pre`, so nothing
|
|
7
|
+
* ever wraps. Turn wrapping on and that guarantee is gone — one row can be
|
|
8
|
+
* five lines tall — and every number the window computes from it (which rows
|
|
9
|
+
* are visible, how tall the spacers are, where a change sits) is wrong.
|
|
10
|
+
*
|
|
11
|
+
* The replacement keeps the same shape and the same rule: work proportional to
|
|
12
|
+
* the VIEWPORT. Heights are MEASURED, but only for rows that are actually in
|
|
13
|
+
* the DOM, which is the window plus its overscan; every other row carries an
|
|
14
|
+
* estimate computed from its text. So a 20,000-row file costs one array and
|
|
15
|
+
* some arithmetic, not 20,000 measurements.
|
|
16
|
+
*
|
|
17
|
+
* The prefix sums are a Fenwick tree rather than a running array, because both
|
|
18
|
+
* things this is asked for happen on the scroll path and must not be linear:
|
|
19
|
+
* "where does row i start" is a prefix query, "which row is at y" is a search
|
|
20
|
+
* for a prefix, and a measurement that lands changes one row's height. All
|
|
21
|
+
* three are O(log n) here; as a plain array of running totals, the third would
|
|
22
|
+
* rewrite every entry after the row that moved.
|
|
23
|
+
*
|
|
24
|
+
* Pure: no React, no DOM. `tests/row-heights.test.ts` loads it directly.
|
|
25
|
+
*
|
|
26
|
+
* @module @young1lin/dsh-ui-gitworkbench/client/row-heights
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { WINDOW_OVERSCAN, WINDOW_WHOLE_BELOW, type RowWindow } from './row-window.ts'
|
|
30
|
+
|
|
31
|
+
/** A viewport height to assume before the scroller has been measured, matching
|
|
32
|
+
* `row-window.ts`: rendering nothing until the height is known flashes an
|
|
33
|
+
* empty pane, and a screenful is both safe and close. */
|
|
34
|
+
const ASSUMED_VIEWPORT_PX = 1200
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* How many display columns a line occupies.
|
|
38
|
+
*
|
|
39
|
+
* Only for the ESTIMATE of rows nobody has measured, so it does not have to
|
|
40
|
+
* agree with the browser to the character — it has to be close enough that
|
|
41
|
+
* the scrollbar is the right length and a jump to the middle of the file lands
|
|
42
|
+
* near where it should. Tabs advance to the next stop (`tab-size: 4` in the
|
|
43
|
+
* panes' grid) and full-width characters take two columns, which are the two
|
|
44
|
+
* ways a naive `text.length` is badly wrong rather than slightly wrong.
|
|
45
|
+
*
|
|
46
|
+
* @param text - one row's text.
|
|
47
|
+
* @param tabSize - columns a tab advances to the next multiple of.
|
|
48
|
+
*/
|
|
49
|
+
export function displayColumns(text: string, tabSize = 4): number {
|
|
50
|
+
let columns = 0
|
|
51
|
+
for (const char of text) {
|
|
52
|
+
if (char === '\t') {
|
|
53
|
+
columns += tabSize - (columns % tabSize)
|
|
54
|
+
continue
|
|
55
|
+
}
|
|
56
|
+
columns += isWide(char) ? 2 : 1
|
|
57
|
+
}
|
|
58
|
+
return columns
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Whether a character takes two columns in a monospace font: CJK, Hangul,
|
|
63
|
+
* kana, the full-width forms, and the emoji blocks. The ranges rather than a
|
|
64
|
+
* table because this runs over a file's worth of text and the answer only
|
|
65
|
+
* feeds an estimate.
|
|
66
|
+
*/
|
|
67
|
+
function isWide(char: string): boolean {
|
|
68
|
+
const code = char.codePointAt(0) ?? 0
|
|
69
|
+
return (code >= 0x1100 && code <= 0x115f)
|
|
70
|
+
|| (code >= 0x2e80 && code <= 0xa4cf)
|
|
71
|
+
|| (code >= 0xac00 && code <= 0xd7a3)
|
|
72
|
+
|| (code >= 0xf900 && code <= 0xfaff)
|
|
73
|
+
|| (code >= 0xfe30 && code <= 0xfe6f)
|
|
74
|
+
|| (code >= 0xff00 && code <= 0xff60)
|
|
75
|
+
|| (code >= 0xffe0 && code <= 0xffe6)
|
|
76
|
+
|| (code >= 0x1f300 && code <= 0x1f9ff)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The height a row is guessed to have before it has been measured.
|
|
81
|
+
*
|
|
82
|
+
* @param text - the row's text.
|
|
83
|
+
* @param columns - how many columns fit across the pane; 0 or less means the
|
|
84
|
+
* pane has not been measured yet, and one line is assumed.
|
|
85
|
+
* @param rowH - one line's height in px.
|
|
86
|
+
*/
|
|
87
|
+
export function estimateRowHeight(text: string, columns: number, rowH: number): number {
|
|
88
|
+
if (!Number.isFinite(columns) || columns <= 0) return rowH
|
|
89
|
+
return Math.max(1, Math.ceil(displayColumns(text) / columns)) * rowH
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Per-row heights with O(log n) offsets, backed by a Fenwick tree.
|
|
94
|
+
*
|
|
95
|
+
* Bounded by construction: two arrays of `rowCount`, which the panes already
|
|
96
|
+
* cap. {@link measured} reports how much of it is real rather than guessed, so
|
|
97
|
+
* a test can prove the measuring stays viewport-sized instead of trusting it.
|
|
98
|
+
*/
|
|
99
|
+
export class RowHeights {
|
|
100
|
+
/** Each row's height. */
|
|
101
|
+
private readonly heights: Float64Array
|
|
102
|
+
/** Fenwick sums, 1-based; `tree[i]` covers a block ending at row `i - 1`. */
|
|
103
|
+
private readonly tree: Float64Array
|
|
104
|
+
/** Rows whose height came from the DOM rather than from an estimate. */
|
|
105
|
+
private readonly real: Uint8Array
|
|
106
|
+
private realCount = 0
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* @param initial - each row's starting height, normally an estimate.
|
|
110
|
+
*/
|
|
111
|
+
constructor(initial: readonly number[]) {
|
|
112
|
+
const n = initial.length
|
|
113
|
+
this.heights = new Float64Array(n)
|
|
114
|
+
this.tree = new Float64Array(n + 1)
|
|
115
|
+
this.real = new Uint8Array(n)
|
|
116
|
+
for (let i = 0; i < n; i += 1) this.heights[i] = sane(initial[i]!)
|
|
117
|
+
// Build in O(n): each node adds its own total into its parent.
|
|
118
|
+
for (let i = 1; i <= n; i += 1) {
|
|
119
|
+
this.tree[i]! += this.heights[i - 1]!
|
|
120
|
+
const parent = i + (i & -i)
|
|
121
|
+
if (parent <= n) this.tree[parent]! += this.tree[i]!
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** How many rows there are. */
|
|
126
|
+
get count(): number { return this.heights.length }
|
|
127
|
+
|
|
128
|
+
/** How many rows carry a measured height rather than an estimate. */
|
|
129
|
+
measured(): number { return this.realCount }
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Record one row's real height.
|
|
133
|
+
* @returns whether anything moved, so a caller can avoid a re-render.
|
|
134
|
+
*/
|
|
135
|
+
set(index: number, px: number): boolean {
|
|
136
|
+
if (!Number.isInteger(index) || index < 0 || index >= this.count) return false
|
|
137
|
+
const next = sane(px)
|
|
138
|
+
if (this.real[index] === 0) {
|
|
139
|
+
this.real[index] = 1
|
|
140
|
+
this.realCount += 1
|
|
141
|
+
}
|
|
142
|
+
const delta = next - this.heights[index]!
|
|
143
|
+
if (delta === 0) return false
|
|
144
|
+
this.heights[index] = next
|
|
145
|
+
for (let i = index + 1; i <= this.count; i += i & -i) this.tree[i]! += delta
|
|
146
|
+
return true
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** One row's height. */
|
|
150
|
+
heightAt(index: number): number {
|
|
151
|
+
if (index < 0 || index >= this.count) return 0
|
|
152
|
+
return this.heights[index]!
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Where a row starts, measured from the top of the scrolled content. */
|
|
156
|
+
topAt(index: number): number {
|
|
157
|
+
let sum = 0
|
|
158
|
+
for (let i = Math.max(0, Math.min(index, this.count)); i > 0; i -= i & -i) sum += this.tree[i]!
|
|
159
|
+
return sum
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Every row's height together. */
|
|
163
|
+
total(): number { return this.topAt(this.count) }
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* The row that contains `y`, by binary lifting over the tree — the same
|
|
167
|
+
* search a running-totals array would do, without the array.
|
|
168
|
+
*/
|
|
169
|
+
rowAt(y: number): number {
|
|
170
|
+
if (this.count === 0) return 0
|
|
171
|
+
let remaining = Number.isFinite(y) && y > 0 ? y : 0
|
|
172
|
+
let index = 0
|
|
173
|
+
let step = 1
|
|
174
|
+
while (step * 2 <= this.count) step *= 2
|
|
175
|
+
for (; step > 0; step = Math.floor(step / 2)) {
|
|
176
|
+
const probe = index + step
|
|
177
|
+
if (probe <= this.count && this.tree[probe]! <= remaining) {
|
|
178
|
+
remaining -= this.tree[probe]!
|
|
179
|
+
index = probe
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return Math.min(index, this.count - 1)
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** @param value - a number from the DOM, which can be NaN or negative. */
|
|
187
|
+
function sane(value: number): number {
|
|
188
|
+
return Number.isFinite(value) && value > 0 ? value : 0
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* The window of rows to render when the rows are not all one height.
|
|
193
|
+
*
|
|
194
|
+
* Same answer shape as {@link rowWindow}, and the same short-circuit below
|
|
195
|
+
* {@link WINDOW_WHOLE_BELOW}: windowing has costs of its own, and a file that
|
|
196
|
+
* was never slow should render exactly as it did before.
|
|
197
|
+
*
|
|
198
|
+
* @param scrollTop - the scroller's current offset in px.
|
|
199
|
+
* @param viewportH - the scroller's visible height in px; 0 before measuring.
|
|
200
|
+
* @param heights - the rows' heights.
|
|
201
|
+
* @param overscan - rows to keep beyond each edge.
|
|
202
|
+
*/
|
|
203
|
+
export function variableRowWindow(
|
|
204
|
+
scrollTop: number,
|
|
205
|
+
viewportH: number,
|
|
206
|
+
heights: RowHeights,
|
|
207
|
+
overscan: number = WINDOW_OVERSCAN,
|
|
208
|
+
): RowWindow {
|
|
209
|
+
const rows = heights.count
|
|
210
|
+
if (rows <= WINDOW_WHOLE_BELOW) return { start: 0, end: rows, padTop: 0, padBottom: 0 }
|
|
211
|
+
|
|
212
|
+
const top = Number.isFinite(scrollTop) && scrollTop > 0 ? scrollTop : 0
|
|
213
|
+
const view = Number.isFinite(viewportH) && viewportH > 0 ? viewportH : ASSUMED_VIEWPORT_PX
|
|
214
|
+
const pad = Math.max(0, Math.trunc(overscan))
|
|
215
|
+
|
|
216
|
+
const start = Math.max(0, heights.rowAt(top) - pad)
|
|
217
|
+
const end = Math.min(rows, heights.rowAt(top + view) + 1 + pad)
|
|
218
|
+
const safeEnd = Math.max(start, end)
|
|
219
|
+
return {
|
|
220
|
+
start,
|
|
221
|
+
end: safeEnd,
|
|
222
|
+
padTop: heights.topAt(start),
|
|
223
|
+
padBottom: Math.max(0, heights.total() - heights.topAt(safeEnd)),
|
|
224
|
+
}
|
|
225
|
+
}
|
|
@@ -261,6 +261,15 @@
|
|
|
261
261
|
user-select: none;
|
|
262
262
|
}
|
|
263
263
|
.code { flex: 1 0 auto; white-space: pre; padding: 0 16px 0 10px; }
|
|
264
|
+
/* Soft wrap, unified view. Three things have to move together: the stack stops
|
|
265
|
+
being wider than the pane (`width: max-content` is exactly what makes a
|
|
266
|
+
200-column line scroll sideways), the code cell wraps instead of overflowing,
|
|
267
|
+
and the number columns hold the TOP of a row that is now several lines tall
|
|
268
|
+
rather than centring in it. `anywhere` rather than `break-word` because a
|
|
269
|
+
minified line or a base64 blob is one "word" and would not break at all. */
|
|
270
|
+
.diffPreWrap { width: 100%; min-width: 0; }
|
|
271
|
+
.diffPreWrap .line { align-items: flex-start; }
|
|
272
|
+
.diffPreWrap .code { flex: 1 1 0; min-width: 0; white-space: pre-wrap; overflow-wrap: anywhere; }
|
|
264
273
|
|
|
265
274
|
.lineAdd { background: var(--gs-add-line); }
|
|
266
275
|
.lineDel { background: var(--gs-del-line); }
|
|
@@ -306,8 +315,17 @@
|
|
|
306
315
|
.sideScroll { flex: 1 1 0; min-height: 0; overflow-y: auto; overflow-x: hidden; }
|
|
307
316
|
.sideCols { display: flex; align-items: stretch; min-height: 100%; }
|
|
308
317
|
/* `min-width: 0` is what lets a flex column be narrower than its content —
|
|
309
|
-
without it the column refuses to shrink and the divider stops moving.
|
|
310
|
-
|
|
318
|
+
without it the column refuses to shrink and the divider stops moving. The
|
|
319
|
+
column still scrolls sideways — a trackpad swipe or a shift-wheel over the
|
|
320
|
+
code lands here — but draws no scrollbar of its own: it is as tall as the
|
|
321
|
+
file, so that scrollbar lands at the bottom of the diff. rails.css puts a
|
|
322
|
+
real one at the bottom of the PANE and says why. Both spellings, because
|
|
323
|
+
Firefox reads the property and Chromium the pseudo-element. */
|
|
324
|
+
.sideCol {
|
|
325
|
+
flex: 0 0 auto; min-width: 0; overflow-x: auto;
|
|
326
|
+
scrollbar-width: none;
|
|
327
|
+
}
|
|
328
|
+
.sideCol::-webkit-scrollbar { display: none; }
|
|
311
329
|
.sideColRight { flex: 1 1 0; }
|
|
312
330
|
.sideColGrid {
|
|
313
331
|
display: grid;
|
|
@@ -379,6 +397,19 @@
|
|
|
379
397
|
.sideNumAdd { background: var(--gs-add-num, var(--gs-add-line)); }
|
|
380
398
|
.sideNumDel { background: var(--gs-del-num, var(--gs-del-line)); }
|
|
381
399
|
.sideCode { white-space: pre; padding: 0 16px 0 10px; min-height: 20px; }
|
|
400
|
+
/* Soft wrap, side-by-side. The column stops scrolling sideways (there is
|
|
401
|
+
nothing left to scroll to) and the grid stops sizing itself to the widest
|
|
402
|
+
line, which is what let a 200-column line push the pane wider than the pane.
|
|
403
|
+
`anywhere` rather than `break-word`: a minified line is one "word" and would
|
|
404
|
+
not break at all. The two columns are still two grids, so a row's height is
|
|
405
|
+
imposed on both cells from whichever side wrapped further — see
|
|
406
|
+
`use-variable-row-window.ts`; `.sideFlow` is the inner box that is MEASURED,
|
|
407
|
+
and it must not inherit that imposed height or the row could never shrink
|
|
408
|
+
back when the pane is widened. */
|
|
409
|
+
.sideColWrap { overflow-x: hidden; }
|
|
410
|
+
.sideColGridWrap { width: 100%; min-width: 0; }
|
|
411
|
+
.sideColGridWrap .sideCode { white-space: pre-wrap; overflow-wrap: anywhere; min-width: 0; }
|
|
412
|
+
.sideFlow { display: block; height: auto; }
|
|
382
413
|
/* Stands in for the rows outside the window, so the scrollbar is the length of
|
|
383
414
|
the FILE rather than of whatever is currently rendered. Spans every column,
|
|
384
415
|
including the blame gutter's. */
|
|
@@ -107,6 +107,11 @@
|
|
|
107
107
|
come from the vocabulary — only the box changes shape. */
|
|
108
108
|
.btnIcon { width: var(--gs-h-control); padding: 0; }
|
|
109
109
|
.btnIcon svg { display: block; }
|
|
110
|
+
/* An icon button that is a STATE, not an action - wrap is either on or off,
|
|
111
|
+
and the row has to say which without a second glyph. Qualified like
|
|
112
|
+
`.layoutButtonOn` and `.treeIconOn`: the shared button vocabulary sets
|
|
113
|
+
`color` at the same specificity a few rules above. */
|
|
114
|
+
.btnIcon.btnIconOn { color: var(--gs-accent); border-color: var(--gs-accent); }
|
|
110
115
|
/* Close is the one destructive control in the row, and every window on the
|
|
111
116
|
machine says so with red on hover. Quiet until then: a permanently red X in
|
|
112
117
|
a header would read as an error state. */
|
|
@@ -45,6 +45,11 @@
|
|
|
45
45
|
text-overflow: ellipsis;
|
|
46
46
|
}
|
|
47
47
|
.fbRow:hover { background: var(--gs-raise); }
|
|
48
|
+
/* Ignored territory, dimmed so "why does this never show in Changes" has a
|
|
49
|
+
visible answer. Qualified like .fbRowActive (which it must not fight: the
|
|
50
|
+
component never applies both, and the active rule in controls.css is
|
|
51
|
+
imported before this file anyway). */
|
|
52
|
+
.fbRow.fbRowIgnored { opacity: 0.55; }
|
|
48
53
|
/* .fbRowActive: see the shared selected-state rule in controls.css. It is
|
|
49
54
|
written there qualified, because this file is imported after that one and a
|
|
50
55
|
bare modifier declared above its base loses the cascade. */
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where the side-by-side pane's horizontal scrollbars live.
|
|
3
|
+
*
|
|
4
|
+
* The pane scrolls vertically and each column scrolls horizontally on its own,
|
|
5
|
+
* which is what lets the divider mean something: one grid across both sides is
|
|
6
|
+
* sized by the widest line in the file, so dragging it moves nothing. The cost
|
|
7
|
+
* is where the browser then draws a column's scrollbar — at the bottom of the
|
|
8
|
+
* COLUMN, and a column is as tall as the file. On a two-thousand-line diff the
|
|
9
|
+
* only way to reach the control that scrolls sideways was to scroll all the
|
|
10
|
+
* way down first, and then scroll back up to see what it did.
|
|
11
|
+
*
|
|
12
|
+
* So the column's scrollbar is hidden and a rail is stuck to the bottom of the
|
|
13
|
+
* pane in its place. See `h-rail.ts` and `SideRails.tsx`.
|
|
14
|
+
*/
|
|
15
|
+
/* The horizontal scrollbars, one per column, stuck to the bottom of the PANE.
|
|
16
|
+
`position: sticky` inside the pane's vertical scroller is what floats them:
|
|
17
|
+
they ride at the bottom edge of the viewport wherever the reader is in the
|
|
18
|
+
file, the way an editor's scrollbar does.
|
|
19
|
+
|
|
20
|
+
The strip is ALWAYS rendered and collapses to `height: 0` instead of being
|
|
21
|
+
conditionally mounted, so the rails keep their elements and the scroll-sync
|
|
22
|
+
effects keep stable dependencies (SideRails.tsx).
|
|
23
|
+
|
|
24
|
+
The negative margin is why the strip costs no layout height: without it the
|
|
25
|
+
pane would scroll 12px further than the file is long, forever. What it
|
|
26
|
+
overlays is the grid's own 16px bottom padding, not a line of code. */
|
|
27
|
+
.sideRails {
|
|
28
|
+
position: sticky; bottom: 0; z-index: 6;
|
|
29
|
+
display: flex; align-items: stretch;
|
|
30
|
+
height: 0; overflow: hidden;
|
|
31
|
+
}
|
|
32
|
+
/* Opaque, because it OVERLAYS the last rows rather than reserving space below
|
|
33
|
+
them: without a background the code underneath reads straight through the
|
|
34
|
+
strip. */
|
|
35
|
+
.sideRailsOn {
|
|
36
|
+
height: 12px; margin-top: -12px; overflow: visible;
|
|
37
|
+
background: var(--gs-surface);
|
|
38
|
+
}
|
|
39
|
+
/* Each rail is a REAL scroller whose content is exactly as wide as its
|
|
40
|
+
column's, so the two scroll positions map one to one with no arithmetic and
|
|
41
|
+
the thumb is the size the reader expects. */
|
|
42
|
+
.sideRail {
|
|
43
|
+
flex: 0 0 auto; min-width: 0;
|
|
44
|
+
overflow-x: auto; overflow-y: hidden;
|
|
45
|
+
}
|
|
46
|
+
/* Firefox only. Chromium IGNORES its `::-webkit-scrollbar` rules for any
|
|
47
|
+
element that also sets `scrollbar-width`, and what it falls back to on
|
|
48
|
+
Windows is an overlay bar: no layout height, and nothing painted at rest.
|
|
49
|
+
For a strip whose whole job is to be seen and dragged, that is a control
|
|
50
|
+
that is not there — so the standard property is scoped to the engine that
|
|
51
|
+
needs it. */
|
|
52
|
+
@supports (-moz-appearance: none) {
|
|
53
|
+
.sideRail { scrollbar-width: thin; scrollbar-color: var(--gs-fg-fainter) transparent; }
|
|
54
|
+
}
|
|
55
|
+
/* The thumb, drawn explicitly: styling these pseudo-elements is what opts
|
|
56
|
+
Chromium out of overlay scrollbars and back into a classic one that occupies
|
|
57
|
+
its own layout height — which is what the probe measures to prove the bar is
|
|
58
|
+
painted at all. */
|
|
59
|
+
.sideRail::-webkit-scrollbar { height: 10px; }
|
|
60
|
+
.sideRail::-webkit-scrollbar-track { background: transparent; }
|
|
61
|
+
.sideRail::-webkit-scrollbar-thumb {
|
|
62
|
+
background: var(--gs-fg-fainter);
|
|
63
|
+
border: 3px solid transparent;
|
|
64
|
+
background-clip: padding-box;
|
|
65
|
+
border-radius: var(--gs-r-pill);
|
|
66
|
+
}
|
|
67
|
+
.sideRail:hover::-webkit-scrollbar-thumb { background: var(--gs-fg-faint); background-clip: padding-box; }
|
|
68
|
+
.sideRailRight { flex: 1 1 0; }
|
|
69
|
+
/* Mirrors `.paneDivider`'s width in shell.css, so each rail sits under the
|
|
70
|
+
column it scrolls — `tests/css-modules.test.ts` holds the two together. */
|
|
71
|
+
.sideRailGap { flex: none; width: 7px; }
|
|
72
|
+
.sideRailSpan { height: 1px; }
|
|
@@ -18,9 +18,13 @@ import { rowWindow, rowWindowForMount, sameRowWindow, type HeldRowWindow, type R
|
|
|
18
18
|
*
|
|
19
19
|
* @param scrollRef - the element that scrolls the rows.
|
|
20
20
|
* @param rowCount - how many rows the diff has.
|
|
21
|
+
* @param enabled - false while soft wrap is on, when the rows are no longer a
|
|
22
|
+
* fixed height and `use-variable-row-window.ts` answers
|
|
23
|
+
* instead. Attaching both panes' listeners at once would put
|
|
24
|
+
* two readers on the same scroll.
|
|
21
25
|
* @returns the rows to render and the spacer heights standing in for the rest.
|
|
22
26
|
*/
|
|
23
|
-
export function useRowWindow(scrollRef: { current: HTMLElement | null }, rowCount: number, mountKey: string): RowWindow {
|
|
27
|
+
export function useRowWindow(scrollRef: { current: HTMLElement | null }, rowCount: number, mountKey: string, enabled = true): RowWindow {
|
|
24
28
|
const count = Number.isFinite(rowCount) ? Math.max(0, Math.trunc(rowCount)) : 0
|
|
25
29
|
const [held, setHeld] = useState<HeldRowWindow>(() => ({
|
|
26
30
|
mountKey, rowCount: count, win: rowWindow(0, 0, count),
|
|
@@ -31,7 +35,7 @@ export function useRowWindow(scrollRef: { current: HTMLElement | null }, rowCoun
|
|
|
31
35
|
const visible = rowWindowForMount(held, count, mountKey)
|
|
32
36
|
useEffect(() => {
|
|
33
37
|
const el = scrollRef.current
|
|
34
|
-
if (el === null) return
|
|
38
|
+
if (el === null || !enabled) return
|
|
35
39
|
const read = (): void => {
|
|
36
40
|
const next = rowWindow(el.scrollTop, el.clientHeight, count)
|
|
37
41
|
setHeld(prev => prev.mountKey === mountKey && prev.rowCount === count && sameRowWindow(prev.win, next)
|
|
@@ -50,6 +54,6 @@ export function useRowWindow(scrollRef: { current: HTMLElement | null }, rowCoun
|
|
|
50
54
|
el.removeEventListener('scroll', read)
|
|
51
55
|
observer.disconnect()
|
|
52
56
|
}
|
|
53
|
-
}, [scrollRef, count, mountKey])
|
|
57
|
+
}, [scrollRef, count, mountKey, enabled])
|
|
54
58
|
return visible
|
|
55
59
|
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The windowing hook for panes whose rows are not all one height — a diff
|
|
3
|
+
* pane with soft wrap on.
|
|
4
|
+
*
|
|
5
|
+
* The fixed-height hook beside this one (`use-row-window.ts`) needs nothing
|
|
6
|
+
* from the DOM but the scroll offset, because `i * 20px` is the answer. Once
|
|
7
|
+
* lines wrap, only the DOM knows how tall a row came out, so this one measures
|
|
8
|
+
* — and measures ONLY the rows it just rendered, which is the window plus its
|
|
9
|
+
* overscan. Every other row carries an estimate computed from its text, so the
|
|
10
|
+
* scrollbar is close from the first paint instead of growing as the reader
|
|
11
|
+
* scrolls.
|
|
12
|
+
*
|
|
13
|
+
* A measurement that replaces an estimate for a row ABOVE the viewport moves
|
|
14
|
+
* everything below it, including what the reader is looking at. The scroll
|
|
15
|
+
* offset is corrected by the same delta in the same layout pass, so the rows
|
|
16
|
+
* on screen stay under the eye rather than sliding.
|
|
17
|
+
*
|
|
18
|
+
* @module @young1lin/dsh-ui-gitworkbench/client/use-variable-row-window
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { useCallback, useEffect, useLayoutEffect, useMemo, useState } from 'react'
|
|
22
|
+
|
|
23
|
+
import { RowHeights, estimateRowHeight, variableRowWindow } from './row-heights.ts'
|
|
24
|
+
import { DIFF_ROW_H, sameRowWindow, type RowWindow } from './row-window.ts'
|
|
25
|
+
|
|
26
|
+
/** Attribute a pane puts on each measurable element, as `scope:index`. */
|
|
27
|
+
export const ROW_INDEX_ATTR = 'data-gw-row'
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* What a pane writes on an element it wants measured.
|
|
31
|
+
*
|
|
32
|
+
* The scope is half the value rather than a second attribute because two
|
|
33
|
+
* height models can be mounted inside one scroller — the aligned diff and the
|
|
34
|
+
* dense index column beside the editor — and a bare index would let each read
|
|
35
|
+
* the other's rows as its own.
|
|
36
|
+
*
|
|
37
|
+
* @param scope - which height model the element belongs to.
|
|
38
|
+
* @param index - the row's index WITHIN that model.
|
|
39
|
+
*/
|
|
40
|
+
export function rowMark(scope: string, index: number): Record<string, string> {
|
|
41
|
+
return { [ROW_INDEX_ATTR]: `${scope}:${index}` }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Columns that fit across a pane, from its width and one character's advance.
|
|
46
|
+
*
|
|
47
|
+
* Measured off a probe rather than assumed: the panes' font size is a theme
|
|
48
|
+
* variable a reader can change, so a hard-coded character width would put the
|
|
49
|
+
* estimate out by a third on the smallest setting.
|
|
50
|
+
*
|
|
51
|
+
* @param grid - the element the rows are laid out in.
|
|
52
|
+
* @returns columns across, or 0 when the element is not laid out yet.
|
|
53
|
+
*/
|
|
54
|
+
export function columnsAcross(grid: HTMLElement | null): number {
|
|
55
|
+
if (grid === null) return 0
|
|
56
|
+
const width = grid.clientWidth
|
|
57
|
+
if (!Number.isFinite(width) || width <= 0) return 0
|
|
58
|
+
const probe = document.createElement('span')
|
|
59
|
+
probe.textContent = '0'.repeat(100)
|
|
60
|
+
probe.style.cssText = 'position:absolute;visibility:hidden;white-space:pre;pointer-events:none'
|
|
61
|
+
grid.appendChild(probe)
|
|
62
|
+
const advance = probe.getBoundingClientRect().width / 100
|
|
63
|
+
probe.remove()
|
|
64
|
+
if (!Number.isFinite(advance) || advance <= 0) return 0
|
|
65
|
+
return Math.max(1, Math.floor(width / advance))
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** What a pane needs from this hook: the rows to render, where any row starts
|
|
69
|
+
* — the change walk jumps to rows that are not in the DOM, so it cannot ask
|
|
70
|
+
* the DOM where they are — and how tall each one came out. */
|
|
71
|
+
export interface FlowWindow {
|
|
72
|
+
readonly win: RowWindow
|
|
73
|
+
readonly rowTop: (index: number) => number
|
|
74
|
+
/**
|
|
75
|
+
* How tall a row came out — what the side-by-side pane imposes as a
|
|
76
|
+
* `min-height` on BOTH of a row's cells.
|
|
77
|
+
*
|
|
78
|
+
* Two aligned columns are two separate grids, because a single grid's tracks
|
|
79
|
+
* are sized by the widest line in the file and a divider dragged across one
|
|
80
|
+
* would move nothing. Separate grids align only while every row is the same
|
|
81
|
+
* height in both, which soft wrap ends: the left cell can wrap to three lines
|
|
82
|
+
* and the right to one. Giving both the tallest side's height puts the rows
|
|
83
|
+
* back in step without merging the grids.
|
|
84
|
+
*/
|
|
85
|
+
readonly rowHeight: (index: number) => number
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function useVariableRowWindow({
|
|
89
|
+
scrollRef, rowsRef, widthRef, texts, mountKey, scope, enabled, rowH = DIFF_ROW_H,
|
|
90
|
+
}: {
|
|
91
|
+
/** The element that scrolls the rows. */
|
|
92
|
+
scrollRef: { current: HTMLElement | null }
|
|
93
|
+
/** The subtree the measurable rows live in. For an aligned diff this is the
|
|
94
|
+
* element holding BOTH columns, since a row's height is the taller side. */
|
|
95
|
+
rowsRef: { current: HTMLElement | null }
|
|
96
|
+
/** What the wrap width is measured from; defaults to `rowsRef`. Separate
|
|
97
|
+
* because the element holding both columns is twice as wide as the column
|
|
98
|
+
* a line actually wraps inside. */
|
|
99
|
+
widthRef?: { current: HTMLElement | null }
|
|
100
|
+
/** One string per row, for the estimate. Identity matters: a new array
|
|
101
|
+
* rebuilds the heights, so callers must memoize it. */
|
|
102
|
+
texts: readonly string[]
|
|
103
|
+
/** Identifies the mounted diff; a new one starts over. */
|
|
104
|
+
mountKey: string
|
|
105
|
+
/** Which rows in `rowsRef` are this model's — see {@link rowMark}. */
|
|
106
|
+
scope: string
|
|
107
|
+
/** Whether wrapping is on. When it is not, this hook attaches nothing and
|
|
108
|
+
* allocates nothing: the pane uses the fixed-height window beside it, and
|
|
109
|
+
* paying for a Fenwick tree over 20,000 rows to answer `i * 20` would be a
|
|
110
|
+
* regression on the path that was never broken. */
|
|
111
|
+
enabled: boolean
|
|
112
|
+
/** One line's height in px. */
|
|
113
|
+
rowH?: number
|
|
114
|
+
}): FlowWindow {
|
|
115
|
+
/** Columns across the pane. State, because the estimate depends on it and a
|
|
116
|
+
* dragged divider changes it. */
|
|
117
|
+
const [columns, setColumns] = useState(0)
|
|
118
|
+
/**
|
|
119
|
+
* Bumped when a measurement moved a row.
|
|
120
|
+
*
|
|
121
|
+
* The heights live in a mutable structure, so changing one changes no
|
|
122
|
+
* identity React watches — and the side-by-side pane reads them DURING
|
|
123
|
+
* render, to impose each row's height on both of its cells. Without this
|
|
124
|
+
* the first pass would measure correctly and then never re-render to apply
|
|
125
|
+
* what it measured, which is a row that is one line taller on one side than
|
|
126
|
+
* on the other. Bumped only when something actually moved, and imposing a
|
|
127
|
+
* height does not change what is measured (the measured box is inside the
|
|
128
|
+
* cell), so it settles in one extra pass.
|
|
129
|
+
*/
|
|
130
|
+
const [, bumpHeights] = useState(0)
|
|
131
|
+
const [win, setWin] = useState<RowWindow>(() => ({ start: 0, end: texts.length, padTop: 0, padBottom: 0 }))
|
|
132
|
+
|
|
133
|
+
/** Rebuilt only when the diff or the width really changes — never on scroll. */
|
|
134
|
+
const heights = useMemo(
|
|
135
|
+
() => new RowHeights(enabled ? texts.map(text => estimateRowHeight(text, columns, rowH)) : []),
|
|
136
|
+
// `mountKey` is in the list on purpose: two diffs can have identical text
|
|
137
|
+
// arrays by identity only if they are the same diff.
|
|
138
|
+
[texts, columns, rowH, mountKey, enabled],
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
const read = useCallback((): void => {
|
|
142
|
+
const el = scrollRef.current
|
|
143
|
+
if (el === null || heights.count === 0) return
|
|
144
|
+
const next = variableRowWindow(el.scrollTop, el.clientHeight, heights)
|
|
145
|
+
setWin(prev => sameRowWindow(prev, next) ? prev : next)
|
|
146
|
+
}, [scrollRef, heights])
|
|
147
|
+
|
|
148
|
+
// Scroll and resize. Passive: this listener never calls preventDefault, and
|
|
149
|
+
// saying so keeps it off the scroll's critical path.
|
|
150
|
+
useEffect(() => {
|
|
151
|
+
const el = scrollRef.current
|
|
152
|
+
if (el === null || !enabled) return
|
|
153
|
+
read()
|
|
154
|
+
const observer = new ResizeObserver(() => {
|
|
155
|
+
read()
|
|
156
|
+
setColumns(prev => {
|
|
157
|
+
const measured = columnsAcross((widthRef ?? rowsRef).current)
|
|
158
|
+
return measured === 0 || measured === prev ? prev : measured
|
|
159
|
+
})
|
|
160
|
+
})
|
|
161
|
+
el.addEventListener('scroll', read, { passive: true })
|
|
162
|
+
observer.observe(el)
|
|
163
|
+
return () => {
|
|
164
|
+
el.removeEventListener('scroll', read)
|
|
165
|
+
observer.disconnect()
|
|
166
|
+
}
|
|
167
|
+
}, [scrollRef, rowsRef, widthRef, read, enabled])
|
|
168
|
+
|
|
169
|
+
// What the rows actually came out as. Layout effect because the correction
|
|
170
|
+
// below must land in the same frame as the paint that needs it — a scroll
|
|
171
|
+
// offset fixed one frame late is a visible jump.
|
|
172
|
+
useLayoutEffect(() => {
|
|
173
|
+
const root = rowsRef.current
|
|
174
|
+
const scroller = scrollRef.current
|
|
175
|
+
if (!enabled || root === null || scroller === null) return
|
|
176
|
+
const before = heights.topAt(win.start)
|
|
177
|
+
// A row can have several measured elements — the two sides of an aligned
|
|
178
|
+
// diff — and the row is as tall as the tallest of them. Collected first,
|
|
179
|
+
// then written: writing each as it is read would leave the row at whichever
|
|
180
|
+
// side the DOM happened to list last.
|
|
181
|
+
const tallest = new Map<number, number>()
|
|
182
|
+
for (const node of root.querySelectorAll<HTMLElement>(`[${ROW_INDEX_ATTR}^="${scope}:"]`)) {
|
|
183
|
+
const index = Number(node.getAttribute(ROW_INDEX_ATTR)?.slice(scope.length + 1))
|
|
184
|
+
if (!Number.isInteger(index)) continue
|
|
185
|
+
tallest.set(index, Math.max(tallest.get(index) ?? 0, node.getBoundingClientRect().height))
|
|
186
|
+
}
|
|
187
|
+
let moved = false
|
|
188
|
+
for (const [index, height] of tallest) {
|
|
189
|
+
if (heights.set(index, height)) moved = true
|
|
190
|
+
}
|
|
191
|
+
if (!moved) return
|
|
192
|
+
// Rows above the viewport that turned out taller (or shorter) than their
|
|
193
|
+
// estimate move the whole document under the reader. Give the scroller the
|
|
194
|
+
// same delta back, so the row they were looking at stays where it was.
|
|
195
|
+
const after = heights.topAt(win.start)
|
|
196
|
+
if (after !== before) scroller.scrollTop += after - before
|
|
197
|
+
bumpHeights(version => version + 1)
|
|
198
|
+
read()
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
const rowTop = useCallback(
|
|
202
|
+
(index: number) => enabled ? heights.topAt(index) : Math.max(0, Math.trunc(index)) * rowH,
|
|
203
|
+
[enabled, heights, rowH],
|
|
204
|
+
)
|
|
205
|
+
const rowHeight = useCallback(
|
|
206
|
+
(index: number) => enabled ? heights.heightAt(index) : rowH,
|
|
207
|
+
[enabled, heights, rowH],
|
|
208
|
+
)
|
|
209
|
+
return { win, rowTop, rowHeight }
|
|
210
|
+
}
|