@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/AGENTS.md +39 -0
- package/CHANGELOG.md +26 -0
- package/CHANGELOG_EN.md +26 -0
- package/lib/client.js +874 -533
- package/package.json +1 -1
- package/src/client/CodeEditor.tsx +174 -48
- package/src/client/FileBrowser.tsx +27 -25
- package/src/client/GitWorkbenchPanel.module.css +3 -0
- package/src/client/GitWorkbenchPanel.tsx +38 -20
- package/src/client/cm-diff.ts +24 -2
- package/src/client/cm-tokens.ts +34 -14
- package/src/client/highlight.ts +158 -61
- package/src/client/idle-value.ts +0 -21
- package/src/client/locales.ts +1 -3
- package/src/client/token-cache.ts +0 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@young1lin/dsh-ui-gitworkbench",
|
|
3
|
-
"version": "0.1.
|
|
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
|
|
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.
|
|
15
|
-
*
|
|
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 {
|
|
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
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
},
|
|
56
|
-
|
|
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
|
-
/**
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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,
|
|
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
|
-
/**
|
|
185
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
309
|
-
//
|
|
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:
|
|
314
|
-
}, [
|
|
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 {
|
|
41
|
-
import {
|
|
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
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
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
|
-
|
|
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 {
|
|
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,
|
|
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
|
-
() =>
|
|
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
|
-
|
|
5069
|
-
|
|
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
|
-
{
|
|
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
|
|
5381
|
-
//
|
|
5382
|
-
//
|
|
5383
|
-
|
|
5384
|
-
|
|
5385
|
-
|
|
5386
|
-
|
|
5387
|
-
|
|
5388
|
-
|
|
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
|
-
|
|
5840
|
+
paint={editPaint}
|
|
5823
5841
|
indent={indentOfBuffer}
|
|
5824
5842
|
ariaLabel={path}
|
|
5825
5843
|
onSave={() => { if (dirty && !saving) void runSave(edit.baseSha) }}
|
package/src/client/cm-diff.ts
CHANGED
|
@@ -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`)
|
package/src/client/cm-tokens.ts
CHANGED
|
@@ -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
|
|
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
|
+
}
|