@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/src/client/highlight.ts
CHANGED
|
@@ -23,6 +23,7 @@ import solarizedDark from 'shiki/themes/solarized-dark.mjs'
|
|
|
23
23
|
import solarizedLight from 'shiki/themes/solarized-light.mjs'
|
|
24
24
|
import nord from 'shiki/themes/nord.mjs'
|
|
25
25
|
import synthwave84 from 'shiki/themes/synthwave-84.mjs'
|
|
26
|
+
import { ChunkedTokens, LineTokens, docKey, type ChunkTokenizer } from './token-cache.ts'
|
|
26
27
|
import type { HighlighterCore } from 'shiki/core'
|
|
27
28
|
import type { Row } from './diff-model.ts'
|
|
28
29
|
|
|
@@ -116,6 +117,25 @@ function highlighter(): HighlighterCore {
|
|
|
116
117
|
return singleton
|
|
117
118
|
}
|
|
118
119
|
|
|
120
|
+
/**
|
|
121
|
+
* Everything this module has already tokenized.
|
|
122
|
+
*
|
|
123
|
+
* Both are bounded (see `token-cache.ts`): the drawer can be left open on a
|
|
124
|
+
* repository all day, and what these hold is capped in lines, not in files
|
|
125
|
+
* visited. Keys carry the language and the theme, so a palette change does not
|
|
126
|
+
* serve the last theme's colours - it just misses.
|
|
127
|
+
*/
|
|
128
|
+
const chunks = new ChunkedTokens()
|
|
129
|
+
const solo = new LineTokens()
|
|
130
|
+
|
|
131
|
+
/** Drop every cached token. Nothing in the drawer needs this today - the keys
|
|
132
|
+
* already separate languages and themes - but a cache with no way to empty it
|
|
133
|
+
* is a cache you cannot reason about. */
|
|
134
|
+
export function forgetTokens(): void {
|
|
135
|
+
chunks.clear()
|
|
136
|
+
solo.clear()
|
|
137
|
+
}
|
|
138
|
+
|
|
119
139
|
const requested = new Set<string>()
|
|
120
140
|
const listeners = new Set<() => void>()
|
|
121
141
|
let loadCount = 0
|
|
@@ -170,15 +190,17 @@ export function shikiThemeOf(palette: string): string {
|
|
|
170
190
|
}
|
|
171
191
|
|
|
172
192
|
/**
|
|
173
|
-
* Tokenize a whole file into per-line runs
|
|
174
|
-
*
|
|
193
|
+
* Tokenize a whole file into per-line runs, the way a DIFF needs it.
|
|
194
|
+
*
|
|
195
|
+
* A diff reconstruction is not a real file: a hunk often starts inside
|
|
196
|
+
* `export default {` or an unclosed block comment, and Shiki then paints the
|
|
197
|
+
* following added statements as object keys or comment text - keywords go
|
|
198
|
+
* missing. Comment lines keep the file-level pass (JSDoc continuation lines);
|
|
199
|
+
* every other line is re-lexed on its own so `async` / `function` / `const`
|
|
200
|
+
* colour as they would at the top level.
|
|
175
201
|
*
|
|
176
|
-
*
|
|
177
|
-
*
|
|
178
|
-
* added statements as object keys or comments — keywords go missing. Comment
|
|
179
|
-
* lines keep the file-level pass (JSDoc ` * `); every other line is re-lexed
|
|
180
|
-
* on its own so `async` / `function` / `const` colour as they would at the
|
|
181
|
-
* top level.
|
|
202
|
+
* Asks {@link highlightWindow} for the whole range, so the two-pass rule and
|
|
203
|
+
* the caching have exactly one implementation.
|
|
182
204
|
* @param lines - source lines, no leading +/-.
|
|
183
205
|
* @param lang - from {@link shikiLangOf}.
|
|
184
206
|
* @param theme - from {@link shikiThemeOf}.
|
|
@@ -188,64 +210,60 @@ export function highlightFile(
|
|
|
188
210
|
lang: string | undefined,
|
|
189
211
|
theme = 'github-dark-default',
|
|
190
212
|
): HighlightRun[][] | undefined {
|
|
191
|
-
const
|
|
192
|
-
if (
|
|
193
|
-
return lines.map((line, i) => {
|
|
194
|
-
const together = fileTok[i] ?? [{ text: line, color: undefined }]
|
|
195
|
-
if (looksLikeCommentLine(line)) return together
|
|
196
|
-
const solo = tokenizeLines([line], lang, theme)?.[0]
|
|
197
|
-
return solo !== undefined && solo.length > 0 ? solo : together
|
|
198
|
-
})
|
|
213
|
+
const windowed = highlightWindow(lines, lang, theme, 0, lines.length)
|
|
214
|
+
if (windowed === undefined) return undefined
|
|
215
|
+
return lines.map((line, i) => windowed[i] ?? [{ text: line, color: undefined }])
|
|
199
216
|
}
|
|
200
217
|
|
|
201
218
|
/**
|
|
202
|
-
*
|
|
219
|
+
* File-quality runs for a range of a file that really IS one.
|
|
203
220
|
*
|
|
204
|
-
*
|
|
205
|
-
*
|
|
206
|
-
*
|
|
207
|
-
*
|
|
208
|
-
* that knows about multi-line strings, block comments and template literals.
|
|
221
|
+
* The editor's buffer and the file browser's text are whole files, so the
|
|
222
|
+
* per-line re-lex {@link highlightFile} performs is both wrong for them and
|
|
223
|
+
* expensive; the file pass is the whole answer, and it is the only pass that
|
|
224
|
+
* knows about multi-line strings, block comments and template literals.
|
|
209
225
|
*
|
|
210
|
-
*
|
|
211
|
-
*
|
|
212
|
-
*
|
|
226
|
+
* Only the range is tokenized, and only once: this is what a viewport asks for
|
|
227
|
+
* as the reader scrolls. Measured on 1,837 lines of real TypeScript, the whole
|
|
228
|
+
* file in one pass cost 1,637ms - the freeze the Files tab took on every click,
|
|
229
|
+
* and the reason files past 2,000 lines were left uncoloured altogether rather
|
|
230
|
+
* than made to wait for it.
|
|
213
231
|
*
|
|
232
|
+
* @param key - identity of the document. Must change when the text does; a
|
|
233
|
+
* path is usually right, because the chunk stamps catch edits within it.
|
|
214
234
|
* @param lines - the file's lines, complete and in order.
|
|
215
235
|
* @param lang - from {@link shikiLangOf}.
|
|
216
236
|
* @param theme - from {@link shikiThemeOf}.
|
|
237
|
+
* @param from - first line wanted.
|
|
238
|
+
* @param to - one past the last line wanted.
|
|
239
|
+
* @returns an array indexed by line, filled only inside the range, or
|
|
240
|
+
* undefined when no grammar applies.
|
|
217
241
|
*/
|
|
218
|
-
export function
|
|
242
|
+
export function highlightRange(
|
|
243
|
+
key: string,
|
|
219
244
|
lines: readonly string[],
|
|
220
245
|
lang: string | undefined,
|
|
221
|
-
theme
|
|
222
|
-
|
|
223
|
-
|
|
246
|
+
theme: string,
|
|
247
|
+
from: number,
|
|
248
|
+
to: number,
|
|
249
|
+
): (HighlightRun[] | undefined)[] | undefined {
|
|
250
|
+
return chunks.runs(key + '|' + (lang ?? '') + '|' + theme, lines, from, to, chunkTokenizer(lang, theme))
|
|
224
251
|
}
|
|
225
252
|
|
|
226
|
-
/**
|
|
227
|
-
* How many lines above the window are tokenized for context.
|
|
228
|
-
*
|
|
229
|
-
* Shiki lexes a string from its start, so a slice beginning inside a block
|
|
230
|
-
* comment or a template literal would colour as if it were code. Reading a
|
|
231
|
-
* lead-in restores that state for everything but a construct longer than this,
|
|
232
|
-
* at a fraction of the cost of the file: at 4,000 lines the whole-file pass was
|
|
233
|
-
* the entire remaining freeze once the DOM was bounded.
|
|
234
|
-
*/
|
|
235
|
-
export const HIGHLIGHT_LEAD_IN = 240
|
|
236
|
-
|
|
237
253
|
/**
|
|
238
254
|
* Runs for the rows in a window, and nothing outside it.
|
|
239
255
|
*
|
|
240
|
-
* This is the pane's whole highlighting cost
|
|
241
|
-
* viewport rather than to the file
|
|
242
|
-
*
|
|
243
|
-
*
|
|
244
|
-
*
|
|
256
|
+
* This is the pane's whole highlighting cost, and it is proportional to the
|
|
257
|
+
* viewport rather than to the file - and then only the FIRST time those lines
|
|
258
|
+
* are read. Both passes go through `token-cache.ts`: the file pass by chunk,
|
|
259
|
+
* continuing from the grammar state of the chunk before it, and the per-line
|
|
260
|
+
* re-lex by the line's own text. Scrolling back over a file costs nothing;
|
|
261
|
+
* measured cold, ten screenfuls of real TypeScript cost 3,021ms before this and
|
|
262
|
+
* are one tokenizing pass per new line after it.
|
|
245
263
|
*
|
|
246
|
-
* Both passes
|
|
247
|
-
* multi-line constructs
|
|
248
|
-
*
|
|
264
|
+
* Both passes are still here, because a diff reconstruction needs both: the
|
|
265
|
+
* file pass, which knows about multi-line constructs, and the per-line re-lex
|
|
266
|
+
* that makes a hunk colour as top-level code.
|
|
249
267
|
*
|
|
250
268
|
* @param lines - source lines, no leading +/-.
|
|
251
269
|
* @param lang - from {@link shikiLangOf}.
|
|
@@ -265,20 +283,40 @@ export function highlightWindow(
|
|
|
265
283
|
const first = Math.max(0, Math.trunc(from))
|
|
266
284
|
const last = Math.min(lines.length, Math.trunc(to))
|
|
267
285
|
if (last <= first) return undefined
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
286
|
+
// Keyed on the ARRAY, which the panes memoise per file - see `docKey`. A
|
|
287
|
+
// caller that rebuilds an equal array every render gets no caching from it.
|
|
288
|
+
const fileTok = highlightRange(docKey(lines), lines, lang, theme, first, last)
|
|
289
|
+
if (fileTok === undefined) return undefined
|
|
271
290
|
const out: (HighlightRun[] | undefined)[] = new Array<HighlightRun[] | undefined>(lines.length)
|
|
272
291
|
for (let i = first; i < last; i += 1) {
|
|
273
292
|
const line = lines[i]!
|
|
274
|
-
const together =
|
|
293
|
+
const together = fileTok[i] ?? [{ text: line, color: undefined }]
|
|
275
294
|
if (looksLikeCommentLine(line)) { out[i] = together; continue }
|
|
276
|
-
const
|
|
277
|
-
out[i] =
|
|
295
|
+
const alone = soloRuns(line, lang, theme)
|
|
296
|
+
out[i] = alone !== undefined && alone.length > 0 ? alone : together
|
|
278
297
|
}
|
|
279
298
|
return out
|
|
280
299
|
}
|
|
281
300
|
|
|
301
|
+
/** One line lexed on its own, from the cache when it has been seen before -
|
|
302
|
+
* which, scrolling back over a file, it usually has. */
|
|
303
|
+
function soloRuns(line: string, lang: string | undefined, theme: string): HighlightRun[] | undefined {
|
|
304
|
+
if (lang === undefined) return undefined
|
|
305
|
+
return solo.get(lang + '|' + theme + '|' + line, () => tokenizeLines([line], lang, theme)?.[0])
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** Bind the engine for {@link ChunkedTokens}: tokenize a chunk continuing from
|
|
309
|
+
* the grammar state the chunk before it ended in, which Shiki hands back with
|
|
310
|
+
* the tokens and which makes the continuation exact rather than guessed. */
|
|
311
|
+
function chunkTokenizer(lang: string | undefined, theme: string): ChunkTokenizer {
|
|
312
|
+
return (text, state) => {
|
|
313
|
+
if (lang === undefined) return undefined
|
|
314
|
+
if (!ensureGrammar(lang)) return undefined
|
|
315
|
+
const got = highlighter().codeToTokens(text, { lang, theme, grammarState: state as never })
|
|
316
|
+
return { runs: runsOf(got.tokens, text.split('\n')), state: got.grammarState }
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
282
320
|
function looksLikeCommentLine(text: string): boolean {
|
|
283
321
|
const t = text.trimStart()
|
|
284
322
|
return t.startsWith('//') || t.startsWith('/*') || t.startsWith('*') || t.startsWith('#')
|
|
@@ -292,6 +330,14 @@ function tokenizeLines(
|
|
|
292
330
|
if (lang === undefined || lines.length === 0) return undefined
|
|
293
331
|
if (!ensureGrammar(lang)) return undefined
|
|
294
332
|
const { tokens } = highlighter().codeToTokens(lines.join('\n'), { lang, theme })
|
|
333
|
+
return runsOf(tokens, lines)
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** Shiki's tokens as runs, one entry per line of the text that produced them. */
|
|
337
|
+
function runsOf(
|
|
338
|
+
tokens: ReadonlyArray<ReadonlyArray<{ content: string; color?: string; fontStyle?: number }>>,
|
|
339
|
+
lines: readonly string[],
|
|
340
|
+
): HighlightRun[][] {
|
|
295
341
|
const last = tokens[tokens.length - 1]
|
|
296
342
|
const rows = tokens.length > 1 && last !== undefined && last.length === 0
|
|
297
343
|
? tokens.slice(0, -1)
|
|
@@ -317,6 +363,39 @@ export function highlightForRows(
|
|
|
317
363
|
lang: string | undefined,
|
|
318
364
|
theme = 'github-dark-default',
|
|
319
365
|
): HighlightRun[][] {
|
|
366
|
+
// One implementation, asked for the whole range. The panes all window now;
|
|
367
|
+
// this shape is what a caller wants when it really does need every row.
|
|
368
|
+
return highlightForRowsWindow(rows, lang, theme, 0, rows.length)
|
|
369
|
+
.map((runs, i) => runs ?? [{ text: rows[i]!.text, color: undefined }])
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* {@link highlightForRows} for the rows in a window, and nothing outside it.
|
|
374
|
+
*
|
|
375
|
+
* Same reason as {@link highlightWindow}, in the view History and Compare use:
|
|
376
|
+
* a unified diff of a long file re-lexed every one of its rows, so opening one
|
|
377
|
+
* froze the pane exactly as the side-by-side view did.
|
|
378
|
+
*
|
|
379
|
+
* The mapping from rows to the two sides' line arrays is built over ALL rows —
|
|
380
|
+
* it is array bookkeeping with no Shiki in it, and a row's position on its side
|
|
381
|
+
* depends on every row before it. Only the tokenizing is windowed, and because
|
|
382
|
+
* a window of rows is contiguous, so is the span of lines it needs from each
|
|
383
|
+
* side.
|
|
384
|
+
*
|
|
385
|
+
* @param rows - parsed unified-diff rows.
|
|
386
|
+
* @param lang - from {@link shikiLangOf}.
|
|
387
|
+
* @param theme - from {@link shikiThemeOf}.
|
|
388
|
+
* @param from - first row in the window.
|
|
389
|
+
* @param to - one past the last row in the window.
|
|
390
|
+
* @returns an array indexed by ROW, filled only inside the window.
|
|
391
|
+
*/
|
|
392
|
+
export function highlightForRowsWindow(
|
|
393
|
+
rows: readonly Row[],
|
|
394
|
+
lang: string | undefined,
|
|
395
|
+
theme: string,
|
|
396
|
+
from: number,
|
|
397
|
+
to: number,
|
|
398
|
+
): (HighlightRun[] | undefined)[] {
|
|
320
399
|
const oldLines: string[] = []
|
|
321
400
|
const newLines: string[] = []
|
|
322
401
|
const oldAt: number[] = []
|
|
@@ -340,11 +419,29 @@ export function highlightForRows(
|
|
|
340
419
|
newAt.push(-1)
|
|
341
420
|
}
|
|
342
421
|
}
|
|
343
|
-
const
|
|
344
|
-
const
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
422
|
+
const first = Math.max(0, Math.trunc(from))
|
|
423
|
+
const last = Math.min(rows.length, Math.trunc(to))
|
|
424
|
+
const span = (at: readonly number[]): { from: number; to: number } => {
|
|
425
|
+
let lo = -1
|
|
426
|
+
let hi = -1
|
|
427
|
+
for (let i = first; i < last; i += 1) {
|
|
428
|
+
const j = at[i]!
|
|
429
|
+
if (j < 0) continue
|
|
430
|
+
if (lo < 0) lo = j
|
|
431
|
+
hi = j
|
|
432
|
+
}
|
|
433
|
+
return lo < 0 ? { from: 0, to: 0 } : { from: lo, to: hi + 1 }
|
|
434
|
+
}
|
|
435
|
+
const oldSpan = span(oldAt)
|
|
436
|
+
const newSpan = span(newAt)
|
|
437
|
+
const oldTok = highlightWindow(oldLines, lang, theme, oldSpan.from, oldSpan.to)
|
|
438
|
+
const newTok = highlightWindow(newLines, lang, theme, newSpan.from, newSpan.to)
|
|
439
|
+
const out: (HighlightRun[] | undefined)[] = new Array<HighlightRun[] | undefined>(rows.length)
|
|
440
|
+
for (let i = first; i < last; i += 1) {
|
|
441
|
+
const row = rows[i]!
|
|
442
|
+
if (row.kind === 'del') out[i] = oldTok?.[oldAt[i]!] ?? [{ text: row.text, color: undefined }]
|
|
443
|
+
else if (row.kind === 'add' || row.kind === 'context') out[i] = newTok?.[newAt[i]!] ?? [{ text: row.text, color: undefined }]
|
|
444
|
+
else out[i] = [{ text: row.text, color: undefined }]
|
|
445
|
+
}
|
|
446
|
+
return out
|
|
350
447
|
}
|
package/src/client/idle-value.ts
CHANGED
|
@@ -30,24 +30,3 @@ export function useIdleValue<T>(value: T, ms: number): T {
|
|
|
30
30
|
}, [value, ms, held])
|
|
31
31
|
return held
|
|
32
32
|
}
|
|
33
|
-
|
|
34
|
-
/** How long typing must pause before the highlight is recomputed. Long enough
|
|
35
|
-
* that a burst of typing costs one repaint, short enough that the pause after
|
|
36
|
-
* a word is already over by the time the eye gets back to the line. */
|
|
37
|
-
export const HIGHLIGHT_IDLE_MS = 180
|
|
38
|
-
|
|
39
|
-
/**
|
|
40
|
-
* Lines past which a file is shown uncoloured.
|
|
41
|
-
*
|
|
42
|
-
* Shiki costs about 0.3ms a line, and that is the whole-file pass alone — the
|
|
43
|
-
* floor, not an implementation detail we can tune away. Measured: 155ms at 500
|
|
44
|
-
* lines, 620ms at 2000, 1537ms at 5000. Debouncing keeps a burst of typing to
|
|
45
|
-
* one repaint, but it cannot make one repaint cheap, and a 1.5-second freeze
|
|
46
|
-
* every time the reader pauses is worse than plain text.
|
|
47
|
-
*
|
|
48
|
-
* So above this the file renders without colour and says so. The honest fix is
|
|
49
|
-
* to highlight only the viewport, which needs the tokens to be computed where
|
|
50
|
-
* the scroll position is known rather than in the pane; this cap is what holds
|
|
51
|
-
* until then.
|
|
52
|
-
*/
|
|
53
|
-
export const HIGHLIGHT_LINE_CAP = 2_000
|
package/src/client/locales.ts
CHANGED
|
@@ -26,7 +26,7 @@ export type WorkbenchKey =
|
|
|
26
26
|
// the Files tab: browse the repository, read a file, blame it, edit it
|
|
27
27
|
| 'fileSearchPlaceholder' | 'filesTruncated' | 'filesEmpty' | 'filesNoMatch' | 'filesPick'
|
|
28
28
|
| 'filesUnsavedAsk' | 'filesDiscardOpen' | 'filesMore' | 'filesVanished' | 'fileReadOnlyCrlf' | 'fileReadOnlyEncoding'
|
|
29
|
-
| 'blameWhileEditing' | 'blameLine' | 'blamePick' | 'blameInHistory'
|
|
29
|
+
| 'blameWhileEditing' | 'blameLine' | 'blamePick' | 'blameInHistory'
|
|
30
30
|
| 'imageBroken' | 'imageFit' | 'imageActual' | 'imageTooLarge' | 'imageSource' | 'imagePreview'
|
|
31
31
|
| 'prevChange' | 'nextChange' | 'prevChangeHint' | 'nextChangeHint' | 'changeCount'
|
|
32
32
|
| 'sourceLabel' | 'workingTree'
|
|
@@ -102,7 +102,6 @@ export const zh: Record<WorkbenchKey, string> = {
|
|
|
102
102
|
fileReadOnlyCrlf: '这个文件的行尾是 CRLF,只能查看不能编辑(编辑器会把行尾统一成 LF,保存时整份文件都会被改写)。',
|
|
103
103
|
fileReadOnlyEncoding: '这个文件不是 UTF-8 编码,只能查看不能编辑:页面上的文字是一次有损解码,保存回去会改写每一个非 ASCII 字节。',
|
|
104
104
|
blameLine: '第 {line} 行',
|
|
105
|
-
paintTooLarge: '文件较长,已关闭语法上色(整文件上色每次要一秒以上,每次停手都卡一下比纯文本更难用)。编辑、保存、追溯不受影响。',
|
|
106
105
|
blameInHistory: '他在本文件的提交',
|
|
107
106
|
blamePick: '点左边的名字,看那一行是哪个提交改的',
|
|
108
107
|
blameWhileEditing: '正在编辑,追溯已暂时收起:改过的行号已经对不上它背后的提交。保存或放弃后会重新出现。',
|
|
@@ -340,7 +339,6 @@ export const en: Record<WorkbenchKey, string> = {
|
|
|
340
339
|
fileReadOnlyCrlf: 'This file uses CRLF line endings, so it opens read-only (the editor normalises them to LF, and a save would rewrite every line).',
|
|
341
340
|
fileReadOnlyEncoding: 'This file is not UTF-8, so it opens read-only: the text shown is a lossy decode, and saving it back would rewrite every non-ASCII byte.',
|
|
342
341
|
blameLine: 'Line {line}',
|
|
343
|
-
paintTooLarge: 'This file is long, so syntax colouring is off (a full repaint costs over a second, and a freeze on every pause is worse than plain text). Editing, saving and blame are unaffected.',
|
|
344
342
|
blameInHistory: 'Their commits on this file',
|
|
345
343
|
blamePick: 'Click a name to see which commit changed that line',
|
|
346
344
|
blameWhileEditing: 'Blame is hidden while you type: edited line numbers no longer match the commits behind them. It returns once you save or revert.',
|
|
Binary file
|