@young1lin/dsh-ui-gitworkbench 0.1.15 → 0.1.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/CHANGELOG_EN.md +26 -0
  3. package/README.md +30 -5
  4. package/README_EN.md +1 -1
  5. package/lib/client.js +1600 -519
  6. package/lib/dir-listing.js +34 -0
  7. package/lib/fs-remove.js +5 -36
  8. package/lib/index.js +233 -52
  9. package/lib/path-lock.js +54 -0
  10. package/lib/worktree.js +133 -0
  11. package/lib/write-checked.js +1 -1
  12. package/package.json +1 -1
  13. package/src/client/ChromeGlyph.tsx +5 -0
  14. package/src/client/CodeEditor.tsx +19 -1
  15. package/src/client/DiffViews.tsx +122 -123
  16. package/src/client/FileBrowser.tsx +196 -23
  17. package/src/client/GitWorkbenchPanel.module.css +1 -0
  18. package/src/client/GitWorkbenchPanel.tsx +114 -12
  19. package/src/client/SideRails.tsx +106 -0
  20. package/src/client/diff-cells.tsx +147 -0
  21. package/src/client/diff-model.ts +20 -0
  22. package/src/client/diff-nav.ts +4 -1
  23. package/src/client/dir-tree.ts +31 -1
  24. package/src/client/file-rows.ts +40 -0
  25. package/src/client/h-rail.ts +70 -0
  26. package/src/client/ignored-cache.ts +193 -0
  27. package/src/client/index.ts +22 -3
  28. package/src/client/locales.ts +18 -4
  29. package/src/client/row-heights.ts +225 -0
  30. package/src/client/styles/changes.css +33 -2
  31. package/src/client/styles/controls.css +5 -0
  32. package/src/client/styles/files.css +5 -0
  33. package/src/client/styles/rails.css +72 -0
  34. package/src/client/use-row-window.ts +7 -3
  35. package/src/client/use-variable-row-window.ts +210 -0
  36. package/src/dir-listing.ts +47 -0
  37. package/src/fs-remove.ts +5 -36
  38. package/src/index.ts +257 -55
  39. package/src/path-lock.ts +56 -0
  40. package/src/types/dsh-shim.d.ts +12 -2
  41. package/src/worktree.ts +153 -0
  42. package/src/write-checked.ts +1 -1
@@ -181,16 +181,35 @@ export function apply(ctx: ClientContext): void {
181
181
  ) as { ok: true; value: { authors: AuthorEntry[]; truncated: boolean } } | { ok: false; error: { message?: string } }
182
182
  return result.ok ? result.value : null
183
183
  },
184
- // Every path on HEAD — the path picker's raw material.
185
- fetchRepoTree: async (worktreePath: string | undefined, signal: AbortSignal): Promise<{ paths: string[]; truncated: boolean } | null> => {
184
+ // Every path on HEAD — the path picker's raw material — plus the
185
+ // ignored entries the Files tab browses. The ignored fields are
186
+ // optional because a host half older than this client does not send
187
+ // them: the browser then simply has no ignored rows to show.
188
+ fetchRepoTree: async (worktreePath: string | undefined, signal: AbortSignal): Promise<{ paths: string[]; truncated: boolean; ignored?: string[]; ignoredTruncated?: boolean; ignoredError?: string } | null> => {
186
189
  const result = await connection.rpc.call(
187
190
  '/api',
188
191
  'gitWorkbench/repoTree',
189
192
  { args: { worktreePath: worktreePath ?? '' } },
190
193
  signal,
191
- ) as { ok: true; value: { paths: string[]; truncated: boolean } } | { ok: false; error: { message?: string } }
194
+ ) as { ok: true; value: { paths: string[]; truncated: boolean; ignored?: string[]; ignoredTruncated?: boolean; ignoredError?: string } } | { ok: false; error: { message?: string } }
192
195
  return result.ok ? result.value : null
193
196
  },
197
+ // One level of one ignored directory, read from the filesystem: git
198
+ // collapses ignored directories by design, so the only way into
199
+ // `node_modules/` is to ask the disk directly (see the host method).
200
+ fetchIgnoredDir: async (worktreePath: string | undefined, dir: string, signal: AbortSignal): Promise<{ entries: { name: string; dir: boolean }[]; truncated: boolean } | null> => {
201
+ try {
202
+ const result = await connection.rpc.call(
203
+ '/api',
204
+ 'gitWorkbench/ignoredDir',
205
+ { args: { worktreePath: worktreePath ?? '', dir } },
206
+ signal,
207
+ ) as { ok: true; value: { entries: { name: string; dir: boolean }[]; truncated: boolean } } | { ok: false; error: { message?: string } }
208
+ return result.ok ? result.value : null
209
+ } catch {
210
+ return null
211
+ }
212
+ },
194
213
  // Two refs compared as `base...head`, in the same shape as every other
195
214
  // view, so the drawer's tree and diff panes render it unchanged.
196
215
  fetchCompare: async (worktreePath: string | undefined, base: string, head: string, signal: AbortSignal): Promise<WorkbenchStats | null> => {
@@ -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'
@@ -39,7 +39,7 @@ export type WorkbenchKey =
39
39
  | 'filterDate' | 'filterToday' | 'filterLast7' | 'filterLast30' | 'filterAfter' | 'filterBefore'
40
40
  | 'filterPaths' | 'filterPathsMore' | 'allBranches' | 'filterPathSearch'
41
41
  | 'filterCalendarSets' | 'filterSelected' | 'filterLocale'
42
- | 'compareBase' | 'compareHead' | 'comparePick' | 'compareCommits' | 'loadingCompare' | 'noBranches'
42
+ | 'compareBase' | 'compareHead' | 'comparePick' | 'compareCommits' | 'compareEmptyHint' | 'loadingCompare' | 'noBranches'
43
43
  | 'refSearch' | 'refNone' | 'refCount' | 'refTruncated' | 'refWorktrees' | 'refBranches' | 'historyRefLabel'
44
44
  | 'settings' | 'themeMode' | 'themePalette' | 'themeScope' | 'themeBackground' | 'themeCss'
45
45
  | 'modeSystem' | 'modeLight' | 'modeDark'
@@ -66,7 +66,7 @@ export type WorkbenchKey =
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)
69
- | 'editFile' | 'fileSave' | 'fileRevert' | 'editingNotice' | 'crlfNotice' | 'encodingNotice'
69
+ | 'editFile' | 'fileSave' | 'fileRevert' | 'editingNotice' | 'crlfNotice' | 'encodingNotice' | 'phantomNotice'
70
70
  // blame gutter on the working-tree column
71
71
  | 'blameToggle' | 'blameHint' | 'blameUncommitted' | 'blameFailed' | 'blameTruncated'
72
72
  | 'saveFailed' | 'saveUnavailable' | 'saveRetry'
@@ -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} 个,用上面的搜索找',
@@ -113,6 +118,7 @@ export const zh: Record<WorkbenchKey, string> = {
113
118
  compareHead: '对比',
114
119
  comparePick: '选择两个不同的分支进行对比',
115
120
  compareCommits: '{count} 个提交(自共同祖先起)',
121
+ compareEmptyHint: '{base}...{head} 比较的是自分叉点到 {head} 的变化,没有找到差异;想看另一方向的变化请交换两端。',
116
122
  loadingCompare: '加载对比…',
117
123
  noBranches: '没有可对比的分支',
118
124
  refSearch: '筛选分支…',
@@ -294,6 +300,7 @@ export const zh: Record<WorkbenchKey, string> = {
294
300
  blameTruncated: '文件过长,追溯信息只显示了前面一部分。',
295
301
  crlfNotice: '这个文件的行尾是 CRLF,暂不支持在线编辑(编辑框会把行尾统一成 LF,保存时整份文件都会被改写);建议把行尾统一成 LF。差异里的回车已用 ␍ 标出;查看和按块暂存/撤回不受影响。',
296
302
  encodingNotice: '这个文件不是 UTF-8 编码(可能是 GBK、Shift JIS 之类),暂不支持在线编辑:页面上看到的文字是一次有损解码,保存回去会把文件里每一个非 ASCII 字节都改写掉,包括你没动过的行。查看和按块暂存/撤回不受影响。',
303
+ phantomNotice: 'git 把这个文件列为已修改,但行尾归一化(CRLF / autocrlf / eol 属性)之后没有内容差异——这是 Windows 检出常见的「永远已修改」幻影,不是显示故障。统一行尾(建议把行尾统一成 LF)或让 git 重新比对后,这一条目就会消失。',
297
304
  saveFailed: '保存失败',
298
305
  saveUnavailable: '当前宿主还不支持保存(需要重启 dsh web 加载新版宿主端)。',
299
306
  saveRetry: '重试保存',
@@ -332,6 +339,11 @@ export const en: Record<WorkbenchKey, string> = {
332
339
  tabFiles: 'Files',
333
340
  fileSearchPlaceholder: 'Search files…',
334
341
  filesTruncated: 'Too many files to list; use the search for the rest.',
342
+ filesIgnored: 'Ignored (gitignore)',
343
+ wrapLines: 'Wrap long lines',
344
+ wrapLinesOff: 'Stop wrapping lines',
345
+ filesIgnoredCut: 'This directory has too many entries; the list is cut, and the search cannot reach the rest.',
346
+ filesIgnoredFailed: 'Could not list the ignored files.',
335
347
  filesEmpty: 'This repository has no files yet',
336
348
  filesNoMatch: 'No matching files',
337
349
  filesMore: 'and {count} more — use the search above',
@@ -353,6 +365,7 @@ export const en: Record<WorkbenchKey, string> = {
353
365
  compareHead: 'Compare',
354
366
  comparePick: 'Pick two different branches to compare',
355
367
  compareCommits: '{count} commits since they diverged',
368
+ compareEmptyHint: '{base}...{head} compares from the fork point up to {head} and found no differences; to see what changed on the other side, swap the two ends.',
356
369
  loadingCompare: 'Loading comparison…',
357
370
  noBranches: 'No branches to compare',
358
371
  refSearch: 'Filter branches…',
@@ -519,6 +532,7 @@ export const en: Record<WorkbenchKey, string> = {
519
532
  blameTruncated: 'The file is long, so blame is shown for the first part only.',
520
533
  crlfNotice: 'This file has CRLF line endings, which the editor does not support yet (the edit box would turn every ending into LF, so a save rewrites the whole file); normalising the endings to LF is recommended. Carriage returns are marked ␍ in the diff; viewing and block staging/rolling back still work.',
521
534
  encodingNotice: 'This file is not UTF-8 (GBK, Shift JIS or similar), so the editor is unavailable: the text shown is a lossy decode of it, and saving that back would rewrite every non-ASCII byte in the file, including lines you never touched. Viewing and block staging/rolling back still work.',
535
+ phantomNotice: 'git lists this file as modified, but after line-ending normalisation (CRLF / autocrlf / eol attributes) there is no content difference — the classic "modified forever" phantom of a Windows checkout, not a broken view. Once the endings agree (normalising the endings to LF is recommended) the entry disappears.',
522
536
  saveFailed: 'Save failed',
523
537
  saveUnavailable: 'This host does not support saving yet — restart dsh web to load the new host half.',
524
538
  saveRetry: 'Retry save',
@@ -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
- .sideCol { flex: 0 0 auto; min-width: 0; overflow-x: auto; }
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
  }