@young1lin/dsh-ui-gitworkbench 0.1.5 → 0.1.7

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 (49) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/CHANGELOG_EN.md +43 -0
  3. package/lib/apply-blocks.js +159 -0
  4. package/lib/atomic-json.js +23 -5
  5. package/lib/blame.js +83 -0
  6. package/lib/client.js +34960 -11826
  7. package/lib/git-ops.js +25 -0
  8. package/lib/image-sniff.js +197 -0
  9. package/lib/index.js +401 -7
  10. package/lib/patch-model.js +223 -0
  11. package/lib/side-guard.js +55 -0
  12. package/lib/write-checked.js +164 -0
  13. package/package.json +7 -1
  14. package/src/apply-blocks.ts +215 -0
  15. package/src/atomic-json.ts +29 -5
  16. package/src/blame.ts +94 -0
  17. package/src/client/CodeEditor.tsx +317 -0
  18. package/src/client/FileBrowser.tsx +657 -0
  19. package/src/client/GitWorkbenchPanel.module.css +491 -12
  20. package/src/client/GitWorkbenchPanel.tsx +1655 -190
  21. package/src/client/ImageView.tsx +120 -0
  22. package/src/client/blame-gutter.ts +108 -0
  23. package/src/client/blame-view.ts +104 -0
  24. package/src/client/cm-diff.ts +108 -0
  25. package/src/client/cm-tokens.ts +79 -0
  26. package/src/client/diff-nav.ts +198 -0
  27. package/src/client/file-icon.ts +190 -0
  28. package/src/client/file-rows.ts +184 -0
  29. package/src/client/files-place.ts +178 -0
  30. package/src/client/glyphs.tsx +86 -0
  31. package/src/client/highlight.ts +25 -0
  32. package/src/client/history-layout.ts +52 -0
  33. package/src/client/idle-value.ts +53 -0
  34. package/src/client/image-view.ts +106 -0
  35. package/src/client/indent.ts +74 -0
  36. package/src/client/index.ts +59 -0
  37. package/src/client/locales.ts +179 -4
  38. package/src/client/pane-size.ts +71 -0
  39. package/src/client/side-edit.ts +244 -0
  40. package/src/client/side-rows.ts +258 -0
  41. package/src/client/stable-list.ts +31 -0
  42. package/src/client/use-change-nav.ts +83 -0
  43. package/src/client/worktree-view.ts +11 -1
  44. package/src/git-ops.ts +36 -1
  45. package/src/image-sniff.ts +204 -0
  46. package/src/index.ts +447 -7
  47. package/src/patch-model.ts +267 -0
  48. package/src/side-guard.ts +58 -0
  49. package/src/write-checked.ts +223 -0
@@ -0,0 +1,120 @@
1
+ /**
2
+ * The picture, once the host has confirmed the bytes are one.
3
+ *
4
+ * Rendering goes through `<img>` pointed at a blob URL, and that choice is
5
+ * doing two jobs at once.
6
+ *
7
+ * It is the SAFETY boundary. The HTML specification defines the document
8
+ * inside an `<img>` as a non-scripted context: script elements do not run,
9
+ * event-handler attributes do not fire, external references do not load. That
10
+ * is what lets an SVG — which is an XML document and can contain all three —
11
+ * be shown here without a sanitiser standing in front of it. Inlining the same
12
+ * markup into the page would give up every one of those guarantees, so it is
13
+ * never done, however convenient it would be for styling.
14
+ *
15
+ * It is also the second half of the VERIFICATION. The host's signature check
16
+ * says the bytes claim to be a PNG; the browser's decoder is the only thing
17
+ * that can say they really are one. A file that passes the first gate and
18
+ * fails the second lands on `onError`, and the view says so plainly rather
19
+ * than leaving an empty frame that reads as a slow load.
20
+ *
21
+ * A blob URL rather than a `data:` URI because the DOM then holds a short
22
+ * string instead of a megabytes-long attribute — and because a blob URL can be
23
+ * revoked, which is what keeps switching between fifty screenshots from
24
+ * retaining all fifty.
25
+ *
26
+ * @module @young1lin/dsh-ui-gitworkbench/client/ImageView
27
+ */
28
+
29
+ import { useEffect, useState, type ReactNode } from 'react'
30
+
31
+ import css from './GitWorkbenchPanel.module.css'
32
+ import { imageCaption } from './image-view.ts'
33
+ import type { Translate } from './GitWorkbenchPanel.tsx'
34
+
35
+ /** A verified picture, from wherever its bytes came from: the host's own read
36
+ * for a binary file, or the text already in hand for an SVG. */
37
+ export interface Picture {
38
+ readonly bytes: Uint8Array<ArrayBuffer>
39
+ /** MIME type to label the blob with. */
40
+ readonly mime: string
41
+ /** Short label for the caption — 'PNG', 'SVG'. */
42
+ readonly kind: string
43
+ }
44
+
45
+ /** What the browser measured once it had decoded the file. */
46
+ interface Natural {
47
+ readonly width: number
48
+ readonly height: number
49
+ }
50
+
51
+ export function ImageView({ picture, path, t }: {
52
+ picture: Picture
53
+ /** Repo-relative path, used as the alt text: a screen reader hearing the
54
+ * file's own name is told more than it would be by "image". */
55
+ path: string
56
+ t: Translate
57
+ }): ReactNode {
58
+ const [url, setUrl] = useState<string | null>(null)
59
+ const [natural, setNatural] = useState<Natural | null>(null)
60
+ /** The browser refused the bytes the host accepted. */
61
+ const [broken, setBroken] = useState(false)
62
+ /** Showing pixel-for-pixel rather than scaled down to fit the pane. */
63
+ const [actual, setActual] = useState(false)
64
+
65
+ const { bytes, mime } = picture
66
+ useEffect(() => {
67
+ setNatural(null)
68
+ setBroken(false)
69
+ setActual(false)
70
+ let made: string | null = null
71
+ try {
72
+ made = URL.createObjectURL(new Blob([bytes], { type: mime }))
73
+ } catch {
74
+ // Nothing to point at, so the frame below says so instead of hanging
75
+ // on a load event that will never fire.
76
+ setBroken(true)
77
+ }
78
+ setUrl(made)
79
+ // Revoked on the way out, not merely dropped: an unrevoked blob URL pins
80
+ // its bytes for the lifetime of the document, so browsing a directory of
81
+ // screenshots would retain every one of them.
82
+ return () => { if (made !== null) URL.revokeObjectURL(made) }
83
+ }, [bytes, mime])
84
+
85
+ if (broken || url === null) {
86
+ return <div className={css.empty}>{t('imageBroken')}</div>
87
+ }
88
+ return (
89
+ <div className={css.imgPane}>
90
+ <div className={css.imgStage} data-actual={actual ? '' : undefined}>
91
+ <img
92
+ className={css.imgShot}
93
+ src={url}
94
+ alt={path}
95
+ onLoad={event => {
96
+ setNatural({
97
+ width: event.currentTarget.naturalWidth,
98
+ height: event.currentTarget.naturalHeight,
99
+ })
100
+ }}
101
+ onError={() => { setBroken(true) }}
102
+ />
103
+ </div>
104
+ <div className={css.imgCaption}>
105
+ <span>{imageCaption(picture.kind, picture.bytes.length, natural)}</span>
106
+ {/* Offered only when the two views would differ. A toggle that does
107
+ nothing visible is a toggle the reader presses twice and then
108
+ distrusts. */}
109
+ {natural !== null && natural.width > 0 ? (
110
+ <button
111
+ type="button"
112
+ className={css.blockBtn}
113
+ aria-pressed={actual}
114
+ onClick={() => { setActual(on => !on) }}
115
+ >{t(actual ? 'imageFit' : 'imageActual')}</button>
116
+ ) : null}
117
+ </div>
118
+ </div>
119
+ )
120
+ }
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Blame as a CodeMirror gutter.
3
+ *
4
+ * The side-by-side pane could render blame as one more column of its CSS grid,
5
+ * because there the file is a grid of rows the pane itself lays out. In the
6
+ * file browser the file IS the editor — CodeMirror owns the lines, renders
7
+ * only the viewport, and re-renders it on every scroll — so the annotation has
8
+ * to live where the line numbers live: in a gutter.
9
+ *
10
+ * The wording is {@link blameLabel} and {@link blameTitle}, the same pure
11
+ * functions the diff pane used, so both views abbreviate a sha and format a
12
+ * date identically and only one of them is worth testing.
13
+ *
14
+ * The gutter sits in a {@link blameCompartment} so it can be added and removed
15
+ * from a live view: an always-installed gutter returning no markers still
16
+ * reserves its column, and a blank strip beside the code is exactly what the
17
+ * toggle exists to avoid.
18
+ *
19
+ * @module @young1lin/dsh-ui-gitworkbench/client/blame-gutter
20
+ */
21
+
22
+ import { Compartment, StateEffect, StateField, type Extension } from '@codemirror/state'
23
+ import { gutter, GutterMarker } from '@codemirror/view'
24
+
25
+ import { blameLabel, blameRunStart, blameTitle } from './blame-view.ts'
26
+ import type { BlameLine } from './GitWorkbenchPanel.tsx'
27
+
28
+ /** Carries a fetched blame — or null for "not showing" — into the view. */
29
+ export const setBlame = StateEffect.define<readonly BlameLine[] | null>()
30
+
31
+ /**
32
+ * The blame the gutter reads. Held in state rather than in a closure so a
33
+ * marker can be computed from a transaction alone, which is what CodeMirror
34
+ * hands the gutter when it re-renders a scrolled viewport.
35
+ */
36
+ export const blameField = StateField.define<readonly BlameLine[] | null>({
37
+ create: () => null,
38
+ update(held, tr) {
39
+ for (const effect of tr.effects) {
40
+ if (effect.is(setBlame)) return effect.value
41
+ }
42
+ return held
43
+ },
44
+ })
45
+
46
+ /** Swaps the gutter itself in and out; the field above stays installed. */
47
+ export const blameCompartment = new Compartment()
48
+
49
+ /** One line's annotation. */
50
+ class BlameMarker extends GutterMarker {
51
+ constructor(private readonly text: string, private readonly hover: string) {
52
+ super()
53
+ }
54
+
55
+ /** CodeMirror reuses a marker's DOM when this says the two are the same. */
56
+ override eq(other: BlameMarker): boolean {
57
+ return other.text === this.text && other.hover === this.hover
58
+ }
59
+
60
+ override toDOM(): HTMLElement {
61
+ const span = document.createElement('span')
62
+ span.className = 'cm-gwBlameCell'
63
+ span.textContent = this.text
64
+ if (this.hover.length > 0) span.title = this.hover
65
+ return span
66
+ }
67
+ }
68
+
69
+ /** Reserves the column's width so the code does not shift as lines scroll by.
70
+ * Names have no fixed width, so this is a plausible one and the cell's CSS
71
+ * ellipsises anything longer — a gutter that resized itself as the viewport
72
+ * scrolled past a long name would shift the code under the reader. */
73
+ const SPACER = new BlameMarker('nnnnnnnnnnnn', '')
74
+
75
+ /**
76
+ * The gutter extension.
77
+ * @param notCommitted - the drawer's own wording for a line with no commit
78
+ * behind it; git's English is never passed through.
79
+ * @param onPick - called with the 1-based line number the reader clicked.
80
+ */
81
+ export function blameGutter(notCommitted: string, onPick: (line: number) => void): Extension {
82
+ return gutter({
83
+ class: 'cm-gwBlameGutter',
84
+ domEventHandlers: {
85
+ // Picking a line is how the commit behind it is reached: the gutter
86
+ // shows the person, and the hash, the timestamp and the subject belong
87
+ // to the moment the reader has decided this is the line they care about.
88
+ click(view, line) {
89
+ onPick(view.state.doc.lineAt(line.from).number)
90
+ return true
91
+ },
92
+ },
93
+ lineMarker(view, line) {
94
+ const lines = view.state.field(blameField, false)
95
+ if (lines === null || lines === undefined) return null
96
+ const number = view.state.doc.lineAt(line.from).number
97
+ const entry = lines[number - 1]
98
+ // A marker for EVERY line, even where it shows nothing: CodeMirror does
99
+ // not create a gutter element for a line with no marker, and a run's
100
+ // unlabelled lines have to stay clickable and hoverable. The label
101
+ // appears only at the run's top; the title is on all of them, so
102
+ // hovering the fortieth line of a run still names its commit.
103
+ const text = blameRunStart(lines, number) ? blameLabel(entry, notCommitted) : ''
104
+ return new BlameMarker(text, blameTitle(entry, notCommitted))
105
+ },
106
+ initialSpacer: () => SPACER,
107
+ })
108
+ }
@@ -0,0 +1,104 @@
1
+ /**
2
+ * How one blame entry reads in the gutter, and in the detail strip a click
3
+ * opens.
4
+ *
5
+ * The gutter carries the PERSON. A commit hash is an identifier, not an
6
+ * answer: reading down a file, the question is who wrote this, and a column of
7
+ * hex says nothing until you look each one up. So the gutter is names, and the
8
+ * commit — its hash, its full timestamp, what it said it was doing — appears
9
+ * when the reader picks a line, which is the point at which they have decided
10
+ * this line is the one they care about.
11
+ *
12
+ * Dates are rendered from the parts of a local `Date` rather than through
13
+ * `toLocaleDateString`, because the drawer's language is its own setting and
14
+ * not the browser's: the same drawer showing English must not switch to the
15
+ * machine's date order. `YYYY-MM-DD` reads the same in both dictionaries.
16
+ *
17
+ * Pure: no React, no DOM, no git. `tests/blame-view.test.ts` loads it directly.
18
+ *
19
+ * @module @young1lin/dsh-ui-gitworkbench/client/blame-view
20
+ */
21
+
22
+ import type { BlameLine } from './GitWorkbenchPanel.tsx'
23
+
24
+ /** How many hex digits of a sha the gutter shows. git's own default. */
25
+ export const SHORT_HASH = 7
26
+
27
+ /** The gutter's abbreviation of a commit; '' when there is no commit. */
28
+ export function shortHash(hash: string): string {
29
+ return /^[0-9a-f]{7,}$/.test(hash) ? hash.slice(0, SHORT_HASH) : ''
30
+ }
31
+
32
+ /**
33
+ * `YYYY-MM-DD` in the reader's own timezone, or '' when git gave no time.
34
+ * @param time - unix seconds.
35
+ */
36
+ export function blameDate(time: number): string {
37
+ if (!Number.isFinite(time) || time <= 0) return ''
38
+ const at = new Date(time * 1000)
39
+ const pad = (value: number): string => String(value).padStart(2, '0')
40
+ return `${at.getFullYear()}-${pad(at.getMonth() + 1)}-${pad(at.getDate())}`
41
+ }
42
+
43
+ /**
44
+ * The one line the gutter shows: who last changed it.
45
+ *
46
+ * @param entry - the line's provenance, or undefined for a line the blame did
47
+ * not cover (a truncated file, a line added since the fetch).
48
+ * @param notCommitted - the drawer's own wording for a line with no commit
49
+ * behind it; git's English is not passed through.
50
+ * @returns the author's name, '' when there is nothing to say.
51
+ */
52
+ export function blameLabel(entry: BlameLine | undefined, notCommitted: string): string {
53
+ if (entry === undefined) return ''
54
+ if (entry.uncommitted) return notCommitted
55
+ return entry.author
56
+ }
57
+
58
+ /**
59
+ * `YYYY-MM-DD HH:MM` in the reader's own timezone, or '' when git gave no
60
+ * time. The detail strip shows the clock time as well as the day: two commits
61
+ * on one afternoon are a common thing to be telling apart.
62
+ * @param time - unix seconds.
63
+ */
64
+ export function blameWhen(time: number): string {
65
+ const date = blameDate(time)
66
+ if (date === '') return ''
67
+ const at = new Date(time * 1000)
68
+ const pad = (value: number): string => String(value).padStart(2, '0')
69
+ return `${date} ${pad(at.getHours())}:${pad(at.getMinutes())}`
70
+ }
71
+
72
+ /**
73
+ * Whether this line STARTS a run — the first line of a stretch that one commit
74
+ * is responsible for.
75
+ *
76
+ * A gutter that repeats the same name down forty lines is forty copies of one
77
+ * fact, and it buries the boundaries, which are the only thing the column is
78
+ * really reporting: where authorship changes. IDEA labels a run once, at its
79
+ * top, and leaves the rest blank. Runs are keyed on the COMMIT rather than the
80
+ * author, so two commits by one person still read as two runs.
81
+ *
82
+ * @param lines - the whole file's provenance, indexed from 0.
83
+ * @param number - 1-based line number.
84
+ */
85
+ export function blameRunStart(lines: readonly BlameLine[], number: number): boolean {
86
+ const entry = lines[number - 1]
87
+ if (entry === undefined) return false
88
+ const previous = lines[number - 2]
89
+ // Line 1 always starts a run; so does the first line after a gap the blame
90
+ // did not cover, which is a boundary whether or not the commits match.
91
+ if (previous === undefined) return true
92
+ return previous.hash !== entry.hash
93
+ }
94
+
95
+ /**
96
+ * The hover text: who, when, and what the commit said it was doing.
97
+ * Empty when there is nothing more than the gutter already shows.
98
+ */
99
+ export function blameTitle(entry: BlameLine | undefined, notCommitted: string): string {
100
+ if (entry === undefined) return ''
101
+ if (entry.uncommitted) return notCommitted
102
+ const parts = [entry.author, blameDate(entry.time), entry.summary].filter(part => part.length > 0)
103
+ return parts.join(' · ')
104
+ }
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Which lines of the editor's buffer differ from the side it is being edited
3
+ * against — recomputed as the reader types.
4
+ *
5
+ * Arming the editor used to take the diff colours away: the buffer rendered as
6
+ * one undifferentiated block, because the pane's add/delete tints come from
7
+ * git's diff and git has not seen a keystroke. That is the wrong trade. The
8
+ * reason to edit inside a diff view at all is to watch the change take shape,
9
+ * and a view that goes blank the moment you touch it is a view you have to
10
+ * leave to check your work.
11
+ *
12
+ * So the tint is recomputed client-side from the text on both sides. This is
13
+ * NOT the diff any git operation uses — the block actions still send line
14
+ * indices against the host's own `diffSha`-stamped patch, and nothing here
15
+ * reaches git. It is a reading aid, and it is allowed to be approximate at the
16
+ * exact moment a keystroke lands.
17
+ *
18
+ * The diff itself is CodeMirror's (`presentableDiff`, already aligned to line
19
+ * boundaries); this module is the arithmetic from its character offsets to
20
+ * line numbers.
21
+ *
22
+ * Pure: no React, no DOM, no git. `tests/cm-diff.test.ts` loads it directly.
23
+ *
24
+ * @module @young1lin/dsh-ui-gitworkbench/client/cm-diff
25
+ */
26
+
27
+ import { presentableDiff } from '@codemirror/merge'
28
+
29
+ /** Which of the buffer's lines the reader has changed, 1-based as an editor
30
+ * counts them. */
31
+ export interface BufferDiff {
32
+ /** Lines carrying text that is not on the other side: added or rewritten. */
33
+ readonly changed: readonly number[]
34
+ /** Lines that text was removed just BEFORE. A pure deletion leaves nothing
35
+ * in the buffer to tint, so the marker goes on the line that closed over
36
+ * the gap — which is where a reader looks for what went missing. */
37
+ readonly deletedBefore: readonly number[]
38
+ }
39
+
40
+ /** Offsets at which each line of `text` starts. */
41
+ function lineStarts(text: string): number[] {
42
+ const starts = [0]
43
+ for (let at = text.indexOf('\n'); at !== -1; at = text.indexOf('\n', at + 1)) {
44
+ starts.push(at + 1)
45
+ }
46
+ return starts
47
+ }
48
+
49
+ /** 1-based line number containing `offset`, by binary search. */
50
+ function lineAt(starts: readonly number[], offset: number): number {
51
+ let low = 0
52
+ let high = starts.length - 1
53
+ while (low < high) {
54
+ const mid = (low + high + 1) >> 1
55
+ if (starts[mid]! <= offset) low = mid
56
+ else high = mid - 1
57
+ }
58
+ return low + 1
59
+ }
60
+
61
+ /**
62
+ * Compare the buffer against the side it is edited against.
63
+ *
64
+ * @param original - the other side's whole text (the index side, for the
65
+ * unstaged layer this editor lives on).
66
+ * @param doc - the editor's buffer.
67
+ * @returns the buffer's changed lines and its deletion points. Both are
68
+ * ascending and free of duplicates, which is what a decoration set
69
+ * wants and what makes two calls comparable.
70
+ */
71
+ export function bufferDiff(original: string, doc: string): BufferDiff {
72
+ if (original === doc) return { changed: [], deletedBefore: [] }
73
+ const lines = doc.split('\n')
74
+ const starts = lineStarts(doc)
75
+ const changed = new Set<number>()
76
+ const deletedBefore = new Set<number>()
77
+
78
+ for (const change of presentableDiff(original, doc)) {
79
+ if (change.fromB === change.toB) {
80
+ // Nothing was INSERTED — but that does not mean a line disappeared. A
81
+ // line the reader shortened (`example.com/taskqueue` to `example.com`)
82
+ // is a pure deletion too, and it is still on screen, changed. Reading
83
+ // every empty insertion as a vanished line is what left a shortened
84
+ // line untinted until the next keystroke happened to add a character.
85
+ const line = lineAt(starts, change.fromB)
86
+ const removed = original.slice(change.fromA, change.toA)
87
+ // Whole lines went only if the removed text both starts at a line
88
+ // boundary and takes its newline with it.
89
+ const wholeLines = removed.endsWith('\n')
90
+ && (change.fromA === 0 || original[change.fromA - 1] === '\n')
91
+ // An emptied line has nothing left to tint either way, so it reads as a
92
+ // gap rather than as a coloured blank.
93
+ if (wholeLines || (lines[line - 1] ?? '').length === 0) deletedBefore.add(line)
94
+ else changed.add(line)
95
+ continue
96
+ }
97
+ const first = lineAt(starts, change.fromB)
98
+ // `toB` is exclusive. A change ending exactly at a line start stopped at
99
+ // the previous line's newline and does not reach into the line after it.
100
+ const last = lineAt(starts, Math.max(change.fromB, change.toB - 1))
101
+ for (let line = first; line <= last; line += 1) changed.add(line)
102
+ }
103
+
104
+ return {
105
+ changed: [...changed].sort((a, b) => a - b),
106
+ deletedBefore: [...deletedBefore].filter(line => !changed.has(line)).sort((a, b) => a - b),
107
+ }
108
+ }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Shiki's per-line tokens, restated as absolute document ranges CodeMirror can
3
+ * decorate.
4
+ *
5
+ * The editor does not carry a second syntax engine. `highlightFile` already
6
+ * lexes this file for the diff columns, and re-lexing the same text with a
7
+ * different grammar would cost another engine in the bundle AND paint the
8
+ * editor in colours the diff beside it does not use. So the editor borrows the
9
+ * tokens the pane already computed; this module is the arithmetic between the
10
+ * two — per-line runs in, absolute offsets out.
11
+ *
12
+ * Only runs that actually PAINT something become ranges. A plain run inherits
13
+ * the editor's own colour, and a decoration that changes nothing still costs a
14
+ * DOM element per token, which for a whole file is most of them.
15
+ *
16
+ * Pure: no CodeMirror, no React, no DOM. `tests/cm-tokens.test.ts` loads it
17
+ * directly, and the CodeMirror glue turns these into `Decoration.mark`s.
18
+ *
19
+ * @module @young1lin/dsh-ui-gitworkbench/client/cm-tokens
20
+ */
21
+
22
+ import type { HighlightRun } from './highlight.ts'
23
+
24
+ /** One painted span, in absolute offsets from the start of the document. */
25
+ export interface TokenRange {
26
+ readonly from: number
27
+ readonly to: number
28
+ readonly color?: string
29
+ readonly italic?: boolean
30
+ }
31
+
32
+ /** Whether a run paints anything the editor's own style does not already. */
33
+ function paints(run: HighlightRun): boolean {
34
+ return run.color !== undefined || run.italic === true
35
+ }
36
+
37
+ /**
38
+ * Turn per-line highlight runs into document ranges.
39
+ *
40
+ * @param lines - the document's lines, exactly as it was split on LF. Their
41
+ * lengths are what convert a line-relative offset to an
42
+ * absolute one, so they must be the SAME lines the runs
43
+ * describe.
44
+ * @param runs - `highlightFile`'s answer, or undefined while a grammar is
45
+ * still loading — which yields no ranges rather than throwing,
46
+ * the same contract the diff columns read it under.
47
+ * @returns painted ranges in document order, ready to sort into a range set.
48
+ * A run whose text runs past the end of its line is clipped there: a
49
+ * decoration crossing a line boundary is an error CodeMirror throws
50
+ * on, and the honest answer to disagreeing inputs is less paint, not
51
+ * a crash in the editor.
52
+ */
53
+ export function tokenRanges(
54
+ lines: readonly string[],
55
+ runs: readonly (readonly HighlightRun[])[] | undefined,
56
+ ): TokenRange[] {
57
+ if (runs === undefined) return []
58
+ const out: TokenRange[] = []
59
+ let lineStart = 0
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
+ }
74
+ // +1 for the LF that `split('\n')` removed. The last line has none, but
75
+ // nothing reads past it either.
76
+ lineStart += line.length + 1
77
+ })
78
+ return out
79
+ }