@young1lin/dsh-ui-gitworkbench 0.1.8 → 0.1.10

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@young1lin/dsh-ui-gitworkbench",
3
- "version": "0.1.8",
3
+ "version": "0.1.10",
4
4
  "description": "Out-of-tree dsh web UI plugin: a session-header git workbench chip opening a drawer with the file tree, per-file diff, history, compare, staging, commit, and sync (fetch/pull/push).",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -9,10 +9,11 @@
9
9
  * anyone who has used an editor, and the pane was asking people to edit.
10
10
  *
11
11
  * CodeMirror is here for the EDITING only. Highlighting still comes from
12
- * shiki, through {@link tokenRanges}: the diff columns beside this editor are
12
+ * shiki, through {@link PaintFn}: the diff columns beside this editor are
13
13
  * painted by shiki, and a second grammar engine would cost another megabyte of
14
- * bundle to render the same file in slightly different colours. So the tokens
15
- * the pane already computed are handed over as decorations.
14
+ * bundle to render the same file in slightly different colours. The pane hands
15
+ * over a function rather than a file of tokens, and this view calls it for the
16
+ * lines it is about to show - see {@link PaintLayer}.
16
17
  *
17
18
  * The document is CONTROLLED by the pane, not owned here: `value` is the
18
19
  * pane's buffer, and every change is reported back through `onChange`. The
@@ -24,8 +25,8 @@
24
25
  */
25
26
 
26
27
  import { useEffect, useRef, type ReactNode } from 'react'
27
- import { EditorState, StateEffect, StateField, type Extension } from '@codemirror/state'
28
- import { EditorView, keymap, lineNumbers, highlightActiveLine, Decoration, type DecorationSet } from '@codemirror/view'
28
+ import { Compartment, EditorState, Facet, StateEffect, StateField, type Extension } from '@codemirror/state'
29
+ import { EditorView, ViewPlugin, keymap, lineNumbers, highlightActiveLine, Decoration, type DecorationSet, type PluginValue, type ViewUpdate } from '@codemirror/view'
29
30
  import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands'
30
31
  import { indentUnit } from '@codemirror/language'
31
32
  import { search, searchKeymap } from '@codemirror/search'
@@ -33,28 +34,46 @@ import { search, searchKeymap } from '@codemirror/search'
33
34
  import css from './GitWorkbenchPanel.module.css'
34
35
  import { blameCompartment, blameField, blameGutter, setBlame } from './blame-gutter.ts'
35
36
  import { bufferDiff } from './cm-diff.ts'
36
- import { tokenRanges } from './cm-tokens.ts'
37
+ import { lineTokenRanges } from './cm-tokens.ts'
37
38
  import type { HighlightRun } from './highlight.ts'
38
39
  import type { BlameLine } from './GitWorkbenchPanel.tsx'
39
40
 
40
- /** Carries a fresh set of shiki-derived decorations into the view. */
41
- const setPaint = StateEffect.define<DecorationSet>()
42
-
43
41
  /**
44
- * The decoration layer. It maps through document changes so the colours stay
45
- * on their text between repaints — without that, a keystroke would smear every
46
- * token after the caret until the next highlight pass landed.
42
+ * Tokens for the lines in a range of the buffer.
43
+ *
44
+ * The editor asks for what it is about to show, never for the file. Painting a
45
+ * whole buffer cost 1,637ms on 1,837 lines of real TypeScript — a freeze on
46
+ * every click, and the reason files past 2,000 lines used to be shown with no
47
+ * colour at all rather than made to wait for it.
48
+ *
49
+ * @param lines - the buffer's lines, complete and in order.
50
+ * @param from - first line wanted, 0-based.
51
+ * @param to - one past the last line wanted.
52
+ * @returns runs indexed by line, filled inside the range; undefined for "no
53
+ * grammar", which renders as plain text.
47
54
  */
48
- const paintField = StateField.define<DecorationSet>({
49
- create: () => Decoration.none,
50
- update(paint, tr) {
51
- for (const effect of tr.effects) {
52
- if (effect.is(setPaint)) return effect.value
53
- }
54
- return tr.docChanged ? paint.map(tr.changes) : paint
55
- },
56
- provide: field => EditorView.decorations.from(field),
55
+ export type PaintFn = (
56
+ lines: readonly string[],
57
+ from: number,
58
+ to: number,
59
+ ) => readonly (readonly HighlightRun[] | undefined)[] | undefined
60
+
61
+ /** Where the view reads its painter from. Reconfigured — through
62
+ * {@link paintCompartment} — when the file, the language or the theme moves. */
63
+ const paintFacet = Facet.define<PaintFn | null, PaintFn | null>({
64
+ combine: values => values.length > 0 ? values[0]! : null,
57
65
  })
66
+ const paintCompartment = new Compartment()
67
+
68
+ /**
69
+ * How long after the last keystroke the editor recomputes what it paints.
70
+ *
71
+ * Both layers below are proportional to what they are asked for, and typing
72
+ * asks again on every character. The decorations map through the change in the
73
+ * meantime, so the colours and the tint ride along with the text and only the
74
+ * recomputation waits.
75
+ */
76
+ const REPAINT_IDLE_MS = 180
58
77
 
59
78
  /** One span's inline style, from the shiki run that produced it. */
60
79
  function styleFor(color: string | undefined, italic: boolean | undefined): string {
@@ -62,17 +81,110 @@ function styleFor(color: string | undefined, italic: boolean | undefined): strin
62
81
  return italic === true ? paint + 'font-style:italic;' : paint
63
82
  }
64
83
 
65
- /** Build the decoration set for one buffer's worth of shiki runs. */
66
- function paintFor(text: string, syntax: readonly (readonly HighlightRun[])[] | undefined): DecorationSet {
67
- const ranges = tokenRanges(text.split('\n'), syntax)
68
- return Decoration.set(
69
- ranges.map(range => Decoration
70
- .mark({ attributes: { style: styleFor(range.color, range.italic) } })
71
- .range(range.from, range.to)),
72
- true,
73
- )
84
+ /**
85
+ * A decoration layer that is recomputed when the view moves and DEFERRED when
86
+ * the reader types.
87
+ *
88
+ * The two layers below — syntax colour and the live diff tint — differ only in
89
+ * what they build. Both must be bounded by the viewport, both must survive a
90
+ * keystroke without being rebuilt, and both must not leave a timer behind when
91
+ * the view goes away.
92
+ */
93
+ abstract class IdleLayer implements PluginValue {
94
+ decorations: DecorationSet = Decoration.none
95
+ private timer: ReturnType<typeof setTimeout> | undefined
96
+
97
+ constructor(protected readonly view: EditorView) {
98
+ this.decorations = this.build(view)
99
+ }
100
+
101
+ update(update: ViewUpdate): void {
102
+ // Carry what is already painted across the edit, whatever happens next:
103
+ // without the map, every token after the caret would smear until the
104
+ // rebuild landed.
105
+ if (update.docChanged) this.decorations = this.decorations.map(update.changes)
106
+ const why = this.reason(update)
107
+ if (why === 'now') this.decorations = this.build(update.view)
108
+ else if (why === 'later') this.defer()
109
+ }
110
+
111
+ /** Clears the pending rebuild. A view is destroyed on every file switch, and
112
+ * a timer that outlives its view is a leak that also paints a dead editor. */
113
+ destroy(): void {
114
+ if (this.timer !== undefined) clearTimeout(this.timer)
115
+ this.timer = undefined
116
+ }
117
+
118
+ private defer(): void {
119
+ if (this.timer !== undefined) clearTimeout(this.timer)
120
+ this.timer = setTimeout(() => {
121
+ this.timer = undefined
122
+ this.decorations = this.build(this.view)
123
+ // An empty transaction is how a plugin that computed something outside
124
+ // an update cycle asks the view to read it.
125
+ this.view.dispatch({})
126
+ }, REPAINT_IDLE_MS)
127
+ }
128
+
129
+ /**
130
+ * Whether this update calls for a rebuild, and how soon.
131
+ *
132
+ * `now` is for a change the reader would see as missing paint — scrolling
133
+ * into lines nothing has coloured yet. `later` is for anything that will
134
+ * settle: a keystroke, or a file switch, which reaches the view as two
135
+ * transactions and is INCONSISTENT between them. Rebuilding on the first of
136
+ * that pair is what made opening a second file cost nine seconds.
137
+ */
138
+ protected abstract reason(update: ViewUpdate): 'now' | 'later' | 'no'
139
+
140
+ protected abstract build(view: EditorView): DecorationSet
141
+ }
142
+
143
+ /** The lines the view is about to show, 0-based and half-open. */
144
+ function viewportLines(view: EditorView): { from: number; to: number } {
145
+ const doc = view.state.doc
146
+ return {
147
+ from: doc.lineAt(view.viewport.from).number - 1,
148
+ to: doc.lineAt(view.viewport.to).number,
149
+ }
150
+ }
151
+
152
+ /** Syntax colour for the viewport, from whatever painter the pane configured. */
153
+ class PaintLayer extends IdleLayer {
154
+ protected build(view: EditorView): DecorationSet {
155
+ const paint = view.state.facet(paintFacet)
156
+ if (paint === null) return Decoration.none
157
+ const doc = view.state.doc
158
+ const lines = doc.toString().split('\n')
159
+ const { from, to } = viewportLines(view)
160
+ const runs = paint(lines, from, to)
161
+ if (runs === undefined) return Decoration.none
162
+ const marks: Array<ReturnType<typeof Decoration.mark>> = []
163
+ const at: number[] = []
164
+ for (let line = from; line < to && line < lines.length; line += 1) {
165
+ // `doc.line` is 1-based, and its `from` is the absolute offset the
166
+ // decorations need.
167
+ const start = doc.line(line + 1).from
168
+ for (const range of lineTokenRanges(lines[line] ?? '', start, runs[line])) {
169
+ marks.push(Decoration.mark({ attributes: { style: styleFor(range.color, range.italic) } }))
170
+ at.push(range.from, range.to)
171
+ }
172
+ }
173
+ return Decoration.set(marks.map((mark, i) => mark.range(at[i * 2]!, at[i * 2 + 1]!)), true)
174
+ }
175
+
176
+ protected reason(update: ViewUpdate): 'now' | 'later' | 'no' {
177
+ // Scrolling must paint at once: the lines are already on screen.
178
+ if (update.viewportChanged) return 'now'
179
+ // A new painter — another file, another theme, a grammar that finished
180
+ // loading. The buffer it describes may not have arrived yet, so it waits.
181
+ if (update.state.facet(paintFacet) !== update.startState.facet(paintFacet)) return 'later'
182
+ return update.docChanged ? 'later' : 'no'
183
+ }
74
184
  }
75
185
 
186
+ const painter = ViewPlugin.fromClass(PaintLayer, { decorations: layer => layer.decorations })
187
+
76
188
  /** The other side's text, kept in state so the diff layer can recompute from
77
189
  * a transaction alone rather than from a closure over some past render. */
78
190
  const setOriginal = StateEffect.define<string>()
@@ -87,24 +199,35 @@ const originalText = StateField.define<string>({
87
199
  })
88
200
 
89
201
  /**
90
- * The add/delete tint, recomputed on every keystroke.
202
+ * The add/delete tint, recomputed once the typing stops.
91
203
  *
92
204
  * Arming the editor used to take the diff colours away, because the pane's
93
205
  * tints come from git's diff and git has not seen a keystroke. Watching the
94
206
  * change take shape is the reason to edit inside a diff view at all, so the
95
207
  * tint is recomputed here from the text on both sides instead.
96
208
  *
209
+ * It is a whole-document diff, and it used to run on every transaction: 55ms
210
+ * per keystroke at 800 lines, 709ms at 4,000. So it inherits {@link IdleLayer}
211
+ * — the tint maps through the edit and catches up when the reader pauses.
212
+ *
97
213
  * This diff is a READING AID. No git operation uses it: the block actions
98
214
  * still send line indices against the host's own `diffSha`-stamped patch.
99
215
  */
100
- const diffField = StateField.define<DecorationSet>({
101
- create: state => diffDecorations(state),
102
- update(deco, tr) {
103
- const reset = tr.effects.some(effect => effect.is(setOriginal))
104
- return tr.docChanged || reset ? diffDecorations(tr.state) : deco
105
- },
106
- provide: field => EditorView.decorations.from(field),
107
- })
216
+ class TintLayer extends IdleLayer {
217
+ protected build(view: EditorView): DecorationSet {
218
+ return diffDecorations(view.state)
219
+ }
220
+
221
+ protected reason(update: ViewUpdate): 'now' | 'later' | 'no' {
222
+ // The tint is line decorations over the whole document, so scrolling needs
223
+ // nothing from it. What it must never do is run between the two
224
+ // transactions a file switch arrives in.
225
+ const sideMoved = update.transactions.some(tr => tr.effects.some(effect => effect.is(setOriginal)))
226
+ return update.docChanged || sideMoved ? 'later' : 'no'
227
+ }
228
+ }
229
+
230
+ const tinter = ViewPlugin.fromClass(TintLayer, { decorations: layer => layer.decorations })
108
231
 
109
232
  function diffDecorations(state: EditorState): DecorationSet {
110
233
  const original = state.field(originalText, false) ?? ''
@@ -174,15 +297,16 @@ const paneTheme = EditorView.theme({
174
297
  },
175
298
  })
176
299
 
177
- export function CodeEditor({ value, original, onChange, syntax, indent, ariaLabel, onSave, blame, notCommitted, readOnly, onBlameClick }: {
300
+ export function CodeEditor({ value, original, onChange, paint, indent, ariaLabel, onSave, blame, notCommitted, readOnly, onBlameClick }: {
178
301
  /** The pane's buffer. The view is written to only when this really differs. */
179
302
  value: string
180
303
  /** The other side's whole text — the index side, for the unstaged layer this
181
304
  * editor lives on. What the live tint is computed against. */
182
305
  original: string
183
306
  onChange: (next: string) => void
184
- /** `highlightFile`'s runs for this buffer; undefined while a grammar loads. */
185
- syntax: readonly (readonly HighlightRun[])[] | undefined
307
+ /** Tokens for a range of the buffer; see {@link PaintFn}. Null paints
308
+ * nothing, which is what a file with no grammar gets. */
309
+ paint: PaintFn | null
186
310
  /** One indent level, from `detectIndent` — what Tab inserts. */
187
311
  indent: string
188
312
  ariaLabel: string
@@ -235,12 +359,13 @@ export function CodeEditor({ value, original, onChange, syntax, indent, ariaLabe
235
359
  history(),
236
360
  search({ top: true }),
237
361
  highlightActiveLine(),
238
- paintField,
362
+ paintCompartment.of(paintFacet.of(paint)),
363
+ painter,
239
364
  blameField,
240
365
  blameCompartment.of([]),
241
366
  EditorState.readOnly.of(!editable),
242
367
  originalText.init(() => original),
243
- diffField,
368
+ tinter,
244
369
  paneTheme,
245
370
  keymap.of([
246
371
  { key: 'Mod-s', preventDefault: true, run: () => { latest.current.onSave(); return true } },
@@ -305,13 +430,14 @@ export function CodeEditor({ value, original, onChange, syntax, indent, ariaLabe
305
430
  current.dispatch({ changes: { from: 0, to: held.length, insert: value } })
306
431
  }, [value])
307
432
 
308
- // Repaint. Depends on `value` as well as `syntax` so it runs after the write
309
- // above, against the text the view now actually holds.
433
+ // A new painter another file, another theme, a grammar that finished
434
+ // loading. The plugin repaints itself from the viewport; all this has to do
435
+ // is put the new function where it reads it from.
310
436
  useEffect(() => {
311
437
  const current = view.current
312
438
  if (current === null) return
313
- current.dispatch({ effects: setPaint.of(paintFor(current.state.doc.toString(), syntax)) })
314
- }, [syntax, value])
439
+ current.dispatch({ effects: paintCompartment.reconfigure(paintFacet.of(paint)) })
440
+ }, [paint])
315
441
 
316
442
  return <div ref={host} className={css.cmHost} data-editable={editable ? '' : undefined} />
317
443
  }
@@ -37,8 +37,8 @@ import { decodeBase64, formatBytes, looksLikeImagePath, shouldAskForImage } from
37
37
  import { IMAGE_BYTE_CAP, sniffImage } from '../image-sniff.ts'
38
38
  import { blameWhen, shortHash } from './blame-view.ts'
39
39
  import { emptyQueryFilter, serializeLogQuery } from './log-filter-query.ts'
40
- import { highlightWholeFile, shikiLangOf, shikiThemeOf, subscribeGrammarLoaded } from './highlight.ts'
41
- import { HIGHLIGHT_IDLE_MS, HIGHLIGHT_LINE_CAP, useIdleValue } from './idle-value.ts'
40
+ import { highlightRange, shikiLangOf, shikiThemeOf, subscribeGrammarLoaded } from './highlight.ts'
41
+ import type { PaintFn } from './CodeEditor.tsx'
42
42
  import { detectIndent } from './indent.ts'
43
43
  import {
44
44
  DISARMED, applySaveOk, applySides, armEdit, armRefusal, isDirty, markConflict,
@@ -46,14 +46,6 @@ import {
46
46
  } from './side-edit.ts'
47
47
  import type { BlameAnswer, BlameLine, FileImage, FileSides, SideLayer, Translate } from './GitWorkbenchPanel.tsx'
48
48
 
49
- /** Count lines without allocating the split — the buffer can be megabytes and
50
- * this runs on a timer while somebody is typing. */
51
- function countLines(text: string): number {
52
- let n = 1
53
- for (let i = text.indexOf(String.fromCharCode(10)); i !== -1; i = text.indexOf(String.fromCharCode(10), i + 1)) n += 1
54
- return n
55
- }
56
-
57
49
  /** Most search hits rendered at once — a one-letter query must not paint a
58
50
  * whole repository into the DOM. */
59
51
  const SEARCH_CAP = 300
@@ -152,7 +144,7 @@ export function FileBrowser({
152
144
  const shownRef = useRef<Set<string>>(new Set())
153
145
  // A grammar loads asynchronously the first time a language is seen; this
154
146
  // counter re-renders the highlight once it lands.
155
- const [, setGrammarTick] = useState(0)
147
+ const [grammarTick, setGrammarTick] = useState(0)
156
148
 
157
149
  const dirty = isDirty(edit)
158
150
 
@@ -339,18 +331,29 @@ export function FileBrowser({
339
331
  const showBlame = blameOn && !dirty && !showingImage
340
332
  const buffer = edit.buffer
341
333
 
342
- // Highlighting lags the buffer: a repaint is a Shiki pass over the whole
343
- // file, and paying that per keystroke is what makes a large file unusable.
344
- // CodeMirror maps the decorations it already has through each change, so
345
- // the colours ride along with the text until this catches up.
346
- const painted = useIdleValue(buffer, HIGHLIGHT_IDLE_MS)
347
- const tooBigToPaint = useMemo(() => countLines(painted) > HIGHLIGHT_LINE_CAP, [painted])
348
- const syntax = useMemo(
349
- () => tooBigToPaint
350
- ? undefined
351
- : highlightWholeFile(painted.split('\n'), shikiLangOf(open ?? ''), shikiThemeOf(palette)),
352
- [tooBigToPaint, painted, open, palette],
353
- )
334
+ /**
335
+ * What the editor paints, asked for a range at a time.
336
+ *
337
+ * It used to be a Shiki pass over the WHOLE buffer, debounced so the reader
338
+ * only felt it when they paused — 1,637ms on 1,837 lines of real TypeScript,
339
+ * and files past 2,000 lines were given no colour at all rather than made to
340
+ * wait for it. The editor now asks for the lines it is about to show, and
341
+ * `token-cache.ts` remembers them, so length stopped being the question and
342
+ * the cap could go.
343
+ *
344
+ * Keyed on the path: an edit inside the file invalidates the chunks it
345
+ * actually moved, which is what the chunk stamps are for.
346
+ */
347
+ const paint = useMemo<PaintFn | null>(() => {
348
+ const lang = shikiLangOf(open ?? '')
349
+ if (lang === undefined) return null
350
+ const theme = shikiThemeOf(palette)
351
+ const key = 'files:' + (open ?? '')
352
+ return (lines, from, to) => highlightRange(key, lines, lang, theme, from, to)
353
+ // `grammarTick` moves when a lazy grammar finishes loading. It changes
354
+ // nothing this function computes; a NEW identity is the point, because that
355
+ // is what tells the editor to repaint lines it had to render plain.
356
+ }, [open, palette, grammarTick])
354
357
  const indent = useMemo(() => detectIndent(edit.baseText), [edit.baseText])
355
358
 
356
359
  /** Open a file, revealing it in the tree — and asking first if the buffer
@@ -565,7 +568,6 @@ export function FileBrowser({
565
568
  <div className={css.sideNotice}>{t(refusal === 'encoding' ? 'fileReadOnlyEncoding' : 'fileReadOnlyCrlf')}</div>
566
569
  ) : null}
567
570
  {saveFailed !== null ? <div className={css.sideNotice}>{saveFailed}</div> : null}
568
- {tooBigToPaint ? <div className={css.sideNotice}>{t('paintTooLarge')}</div> : null}
569
571
  {blameOn && dirty ? <div className={css.sideNotice}>{t('blameWhileEditing')}</div> : null}
570
572
  {showBlame && (blameFailed || (blame !== null && blame.error !== undefined))
571
573
  ? <div className={css.sideNotice}>{t('blameFailed')}</div> : null}
@@ -638,7 +640,7 @@ export function FileBrowser({
638
640
  value={edit.buffer}
639
641
  original={edit.baseText}
640
642
  onChange={next => { setEdit(prev => ({ ...prev, buffer: next })) }}
641
- syntax={syntax}
643
+ paint={paint}
642
644
  indent={indent}
643
645
  ariaLabel={open}
644
646
  onSave={() => { void save() }}
@@ -1810,6 +1810,9 @@
1810
1810
  the FILE rather than of whatever is currently rendered. Spans every column,
1811
1811
  including the blame gutter's. */
1812
1812
  .sideSpacer { grid-column: 1 / -1; }
1813
+ /* The same, for the unified view's flex column. `flex: none` so it is exactly
1814
+ the height it claims rather than one stretched by its siblings. */
1815
+ .diffSpacer { flex: none; }
1813
1816
  .sideCodeSame { color: var(--gs-fg-muted); }
1814
1817
  .sideCodeAdd { background: var(--gs-add-line); }
1815
1818
  .sideCodeDel { background: var(--gs-del-line); }
@@ -70,10 +70,10 @@ import {
70
70
  } from './side-edit.ts'
71
71
  import { FileBrowser } from './FileBrowser.tsx'
72
72
  import { decodePlaces, encodePlaces, placeAt, withPlace, type FilesPlace, type FilesPlaces } from './files-place.ts'
73
- import { HIGHLIGHT_IDLE_MS, HIGHLIGHT_LINE_CAP, useIdleValue } from './idle-value.ts'
73
+ import { useIdleValue } from './idle-value.ts'
74
74
  import { PathDirGlyph, PathFileGlyph } from './glyphs.tsx'
75
75
  import { detectIndent } from './indent.ts'
76
- import { CodeEditor } from './CodeEditor.tsx'
76
+ import { CodeEditor, type PaintFn } from './CodeEditor.tsx'
77
77
  import { layoutGraph, type GraphRow } from './commit-graph.ts'
78
78
  import { formatCommitDate } from './commit-filter.ts'
79
79
  import { chipsFromFilter, emptyQueryFilter, parseLogQuery, removeChip, serializeLogQuery } from './log-filter-query.ts'
@@ -89,7 +89,7 @@ import {
89
89
  fileCheckState, nextAction, nextBatch, pathsFor, rollUp, settledTicks, withPendingTicks,
90
90
  type CheckState, type Tick, type TickAction,
91
91
  } from './stage-tree.ts'
92
- import { grammarLoadCount, highlightFile, highlightForRows, highlightWholeFile, highlightWindow, shikiLangOf, shikiThemeOf, subscribeGrammarLoaded, type HighlightRun } from './highlight.ts'
92
+ import { grammarLoadCount, highlightForRowsWindow, highlightRange, highlightWindow, shikiLangOf, shikiThemeOf, subscribeGrammarLoaded, type HighlightRun } from './highlight.ts'
93
93
  import { badgeRepeatsBranch, bindingChanged, branchOfWorktree, pathKey, probesClosedBinding, samePath, showsPending, splitPath, turnSettled, viewedPath } from './worktree-view.ts'
94
94
  import { BUSY_DELAY_MS, BUSY_HOLD_MS, holdRemaining, quietlyDisabled } from './op-feedback.ts'
95
95
  import type { WorkbenchKey } from './locales.ts'
@@ -5059,14 +5059,25 @@ function DiffView({ segment, path, palette, t }: {
5059
5059
  const grammarGen = useSyncExternalStore(subscribeGrammarLoaded, grammarLoadCount)
5060
5060
  const rowsWithWords = useMemo(() => attachWordRanges(parseRows(segment)), [segment])
5061
5061
  const sides = useMemo(() => gutterSides(rowsWithWords), [rowsWithWords])
5062
+ const scrollRef = useRef<HTMLDivElement>(null)
5063
+ // Windowed for the same reason the side-by-side pane is: a unified diff of a
5064
+ // long file put every row in the DOM and re-lexed every one of them, so
5065
+ // opening one froze the pane in exactly the same way.
5066
+ const win = useRowWindow(scrollRef, rowsWithWords.length)
5062
5067
  const syntax = useMemo(
5063
- () => highlightForRows(rowsWithWords, lang, shikiTheme),
5064
- [rowsWithWords, lang, shikiTheme, grammarGen],
5068
+ () => highlightForRowsWindow(rowsWithWords, lang, shikiTheme, win.start, win.end),
5069
+ [rowsWithWords, lang, shikiTheme, win.start, win.end, grammarGen],
5065
5070
  )
5066
5071
  const blocks = useMemo(() => unifiedBlocks(rowsWithWords.map(row => row.kind)), [rowsWithWords])
5067
5072
  const changes = useMemo(() => countBlocks(blocks), [blocks])
5068
- const scrollRef = useRef<HTMLDivElement>(null)
5069
- const { goToChange } = useChangeNav(scrollRef)
5073
+ // Derived rather than measured, because a windowed pane has no element for
5074
+ // the block being walked to.
5075
+ const blocksForNav = useRef<readonly number[]>(blocks)
5076
+ blocksForNav.current = blocks
5077
+ const { goToChange } = useChangeNav(
5078
+ scrollRef,
5079
+ useCallback(() => blockTopsFromRows(blocksForNav.current, DIFF_ROW_H, DIFF_GRID_PAD_TOP), []),
5080
+ )
5070
5081
  // Read by the key listener below, which is attached once. `goToChange` only
5071
5082
  // ever touches refs, but pinning it here says so rather than relying on it.
5072
5083
  const walk = useRef(goToChange)
@@ -5112,7 +5123,10 @@ function DiffView({ segment, path, palette, t }: {
5112
5123
  ) : null}
5113
5124
  <div ref={scrollRef} className={css.diffScroll} tabIndex={-1}>
5114
5125
  <pre className={css.diffPre}>
5115
- {rowsWithWords.map((row, i) => (
5126
+ {win.padTop > 0 ? <div className={css.diffSpacer} style={{ height: `${win.padTop}px` }} aria-hidden="true" /> : null}
5127
+ {rowsWithWords.slice(win.start, win.end).map((row, k) => {
5128
+ const i = win.start + k
5129
+ return (
5116
5130
  <div key={i} className={`${css.line} ${rowClass(row.kind)}`} data-block={blocks[i]! >= 0 ? blocks[i] : undefined}>
5117
5131
  {sides.old ? <span className={css.lnOld}>{row.kind === 'add' || row.kind === 'hunk' ? '' : row.oldL}</span> : null}
5118
5132
  {sides.new ? <span className={css.lnNew}>{row.kind === 'del' || row.kind === 'hunk' ? '' : row.newL}</span> : null}
@@ -5121,7 +5135,9 @@ function DiffView({ segment, path, palette, t }: {
5121
5135
  </span>
5122
5136
  <span className={css.code}>{renderCode(row, syntax[i] ?? [])}</span>
5123
5137
  </div>
5124
- ))}
5138
+ )
5139
+ })}
5140
+ {win.padBottom > 0 ? <div className={css.diffSpacer} style={{ height: `${win.padBottom}px` }} aria-hidden="true" /> : null}
5125
5141
  </pre>
5126
5142
  </div>
5127
5143
  </div>
@@ -5377,16 +5393,18 @@ function SideBySideView({ t, path, palette, statsPath, fetchSides, writeChecked,
5377
5393
  const left = rows.filter(row => row.left !== null).map(row => row.left!.text)
5378
5394
  return left.length === 0 ? '' : left.join('\n') + '\n'
5379
5395
  }, [rows])
5380
- // The editor's buffer is a whole file, so it takes the whole-file pass
5381
- // and it lags the typing, for the same reason the browser's does: a Shiki
5382
- // pass per keystroke is what makes a large file unusable to edit.
5383
- const paintedLines = useIdleValue(bufferLines, HIGHLIGHT_IDLE_MS)
5384
- const editSyntax = useMemo(
5385
- () => edit.armed && paintedLines.length <= HIGHLIGHT_LINE_CAP
5386
- ? highlightWholeFile(paintedLines, lang, shikiTheme)
5387
- : [],
5388
- [edit.armed, paintedLines, lang, shikiTheme, grammarGen],
5389
- )
5396
+ // The editor's buffer is a whole file, so it takes the file pass rather than
5397
+ // the diff's per-line re-lex but only over the lines it is showing. The
5398
+ // editor asks for a range as it scrolls, and `token-cache.ts` remembers what
5399
+ // came back; a pass over the whole buffer was 1,637ms at 1,837 lines, paid
5400
+ // again every time the reader stopped typing.
5401
+ const editPaint = useMemo<PaintFn | null>(() => {
5402
+ if (lang === undefined) return null
5403
+ const key = 'buffer:' + statsPath + ':' + path
5404
+ return (lines, from, to) => highlightRange(key, lines, lang, shikiTheme, from, to)
5405
+ // `grammarGen` changes nothing computed here; the new identity is what
5406
+ // makes the editor repaint once a lazy grammar has landed.
5407
+ }, [lang, shikiTheme, statsPath, path, grammarGen])
5390
5408
  // The left column while editing renders dense — one row per INDEX line, no
5391
5409
  // holes — because the right column is now the dense buffer; a hole-aligned
5392
5410
  // left beside a dense right is the alignment the diff view owes, not the
@@ -5819,7 +5837,7 @@ function SideBySideView({ t, path, palette, statsPath, fetchSides, writeChecked,
5819
5837
  value={edit.buffer}
5820
5838
  original={indexText}
5821
5839
  onChange={next => { setEdit(prev => ({ ...prev, buffer: next })) }}
5822
- syntax={editSyntax}
5840
+ paint={editPaint}
5823
5841
  indent={indentOfBuffer}
5824
5842
  ariaLabel={path}
5825
5843
  onSave={() => { if (dirty && !saving) void runSave(edit.baseSha) }}
@@ -17,7 +17,18 @@
17
17
  *
18
18
  * The diff itself is CodeMirror's (`presentableDiff`, already aligned to line
19
19
  * boundaries); this module is the arithmetic from its character offsets to
20
- * line numbers.
20
+ * line numbers - and the BOUND on it.
21
+ *
22
+ * Unbounded, that diff is quadratic on inputs that are not versions of each
23
+ * other, and the editor can be handed such a pair for one render: opening
24
+ * another file replaces the buffer and the side it is compared against in two
25
+ * separate transactions, so for an instant the new file's text sits opposite
26
+ * the old file's. Profiled on a real switch between two source files, that one
27
+ * instant cost NINE SECONDS inside `findDiff`, and it is the whole of the
28
+ * reported "clicking a big file after a small one nearly freezes". The editor
29
+ * no longer diffs an inconsistent pair (see `CodeEditor.tsx`), and this is the
30
+ * second half of the answer: no input, consistent or not, may cost more than a
31
+ * moment.
21
32
  *
22
33
  * Pure: no React, no DOM, no git. `tests/cm-diff.test.ts` loads it directly.
23
34
  *
@@ -37,6 +48,17 @@ export interface BufferDiff {
37
48
  readonly deletedBefore: readonly number[]
38
49
  }
39
50
 
51
+ /**
52
+ * What the tint is allowed to spend.
53
+ *
54
+ * `scanLimit` is CodeMirror's own guard against quadratic behaviour - its merge
55
+ * view sets 500 - and the timeout is the ceiling in wall time. Past either, the
56
+ * algorithm falls back to a coarser answer, which is the right trade for a
57
+ * reading aid: an approximate tint that appears is worth more than an exact one
58
+ * that arrives after the reader has given up. No git operation reads this.
59
+ */
60
+ const DIFF_BOUND = { scanLimit: 500, timeout: 100 }
61
+
40
62
  /** Offsets at which each line of `text` starts. */
41
63
  function lineStarts(text: string): number[] {
42
64
  const starts = [0]
@@ -75,7 +97,7 @@ export function bufferDiff(original: string, doc: string): BufferDiff {
75
97
  const changed = new Set<number>()
76
98
  const deletedBefore = new Set<number>()
77
99
 
78
- for (const change of presentableDiff(original, doc)) {
100
+ for (const change of presentableDiff(original, doc, DIFF_BOUND)) {
79
101
  if (change.fromB === change.toB) {
80
102
  // Nothing was INSERTED — but that does not mean a line disappeared. A
81
103
  // line the reader shortened (`example.com/taskqueue` to `example.com`)
@@ -52,28 +52,48 @@ function paints(run: HighlightRun): boolean {
52
52
  */
53
53
  export function tokenRanges(
54
54
  lines: readonly string[],
55
- runs: readonly (readonly HighlightRun[])[] | undefined,
55
+ runs: readonly (readonly HighlightRun[] | undefined)[] | undefined,
56
56
  ): TokenRange[] {
57
57
  if (runs === undefined) return []
58
58
  const out: TokenRange[] = []
59
59
  let lineStart = 0
60
60
  lines.forEach((line, index) => {
61
- const lineRuns = runs[index]
62
- if (lineRuns !== undefined) {
63
- let at = 0
64
- for (const run of lineRuns) {
65
- const from = lineStart + at
66
- at += run.text.length
67
- const to = Math.min(lineStart + at, lineStart + line.length)
68
- if (to > from && paints(run)) {
69
- out.push({ from, to, color: run.color, italic: run.italic })
70
- }
71
- if (at >= line.length) break
72
- }
73
- }
61
+ for (const range of lineTokenRanges(line, lineStart, runs[index])) out.push(range)
74
62
  // +1 for the LF that `split('\n')` removed. The last line has none, but
75
63
  // nothing reads past it either.
76
64
  lineStart += line.length + 1
77
65
  })
78
66
  return out
79
67
  }
68
+
69
+ /**
70
+ * One line's painted ranges, in absolute offsets.
71
+ *
72
+ * The unit the viewport painter works in: it knows each visible line's own
73
+ * offset from CodeMirror and never wants the arithmetic for the lines above it.
74
+ * {@link tokenRanges} is this function walked down a whole document.
75
+ *
76
+ * @param text - the line, as the document holds it.
77
+ * @param start - the line's absolute offset in the document.
78
+ * @param runs - that line's highlight runs, or undefined for "not painted",
79
+ * which yields no ranges rather than throwing.
80
+ */
81
+ export function lineTokenRanges(
82
+ text: string,
83
+ start: number,
84
+ runs: readonly HighlightRun[] | undefined,
85
+ ): TokenRange[] {
86
+ if (runs === undefined) return []
87
+ const out: TokenRange[] = []
88
+ let at = 0
89
+ for (const run of runs) {
90
+ const from = start + at
91
+ at += run.text.length
92
+ const to = Math.min(start + at, start + text.length)
93
+ if (to > from && paints(run)) {
94
+ out.push({ from, to, color: run.color, italic: run.italic })
95
+ }
96
+ if (at >= text.length) break
97
+ }
98
+ return out
99
+ }