@young1lin/dsh-ui-gitworkbench 0.1.4 → 0.1.6
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/CHANGELOG.md +42 -0
- package/CHANGELOG_EN.md +42 -0
- package/lib/apply-blocks.js +159 -0
- package/lib/atomic-json.js +23 -5
- package/lib/blame.js +83 -0
- package/lib/client.js +34811 -11818
- package/lib/fs-remove.js +73 -0
- package/lib/git-ops.js +25 -0
- package/lib/image-sniff.js +197 -0
- package/lib/index.js +404 -32
- package/lib/patch-model.js +223 -0
- package/lib/side-guard.js +55 -0
- package/lib/write-checked.js +164 -0
- package/package.json +7 -1
- package/src/apply-blocks.ts +215 -0
- package/src/atomic-json.ts +29 -5
- package/src/blame.ts +94 -0
- package/src/client/CodeEditor.tsx +317 -0
- package/src/client/FileBrowser.tsx +657 -0
- package/src/client/GitWorkbenchPanel.module.css +453 -7
- package/src/client/GitWorkbenchPanel.tsx +1465 -166
- package/src/client/ImageView.tsx +120 -0
- package/src/client/blame-gutter.ts +108 -0
- package/src/client/blame-view.ts +104 -0
- package/src/client/cm-diff.ts +108 -0
- package/src/client/cm-tokens.ts +79 -0
- package/src/client/diff-nav.ts +198 -0
- package/src/client/discard-flow.ts +82 -0
- package/src/client/file-icon.ts +190 -0
- package/src/client/file-rows.ts +184 -0
- package/src/client/files-place.ts +178 -0
- package/src/client/glyphs.tsx +86 -0
- package/src/client/highlight.ts +25 -0
- package/src/client/idle-value.ts +53 -0
- package/src/client/image-view.ts +106 -0
- package/src/client/indent.ts +74 -0
- package/src/client/index.ts +76 -9
- package/src/client/locales.ts +171 -4
- package/src/client/pane-size.ts +71 -0
- package/src/client/side-edit.ts +244 -0
- package/src/client/side-rows.ts +258 -0
- package/src/client/stable-list.ts +31 -0
- package/src/client/use-change-nav.ts +83 -0
- package/src/client/worktree-view.ts +11 -1
- package/src/fs-remove.ts +76 -0
- package/src/git-ops.ts +36 -1
- package/src/image-sniff.ts +204 -0
- package/src/index.ts +450 -32
- package/src/patch-model.ts +267 -0
- package/src/side-guard.ts +58 -0
- 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
|
+
}
|