@trendr/core 0.2.11 → 0.4.0
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/README.md +284 -24
- package/index.js +6 -2
- package/package.json +29 -34
- package/src/animation.js +25 -12
- package/src/ansi.js +6 -0
- package/src/buffer.js +63 -50
- package/src/button.js +2 -2
- package/src/checkbox.js +2 -2
- package/src/diff-engine.js +313 -0
- package/src/diff-view.js +238 -0
- package/src/diff.js +2 -2
- package/src/dropdown.js +147 -0
- package/src/focus.js +89 -27
- package/src/hooks.js +43 -25
- package/src/hotkey.js +51 -16
- package/src/input.js +211 -72
- package/src/layout.js +34 -13
- package/src/list.js +29 -16
- package/src/markdown.js +177 -0
- package/src/menu.js +13 -6
- package/src/menubar.js +46 -115
- package/src/miller-nav.js +36 -11
- package/src/modal.js +3 -1
- package/src/pick-list.js +16 -17
- package/src/progress.js +3 -3
- package/src/radio.js +16 -5
- package/src/renderer.js +291 -50
- package/src/scheduler.js +28 -10
- package/src/scroll-box.js +12 -11
- package/src/scrollable-text.js +3 -2
- package/src/select.js +65 -173
- package/src/selection.js +92 -0
- package/src/shimmer.js +2 -2
- package/src/signal.js +20 -3
- package/src/table.js +8 -27
- package/src/tabs.js +10 -7
- package/src/task.js +3 -3
- package/src/text-area.js +96 -41
- package/src/text-input.js +70 -28
- package/src/wrap.js +205 -84
package/src/animation.js
CHANGED
|
@@ -27,7 +27,7 @@ function tickAll() {
|
|
|
27
27
|
|
|
28
28
|
batch(() => {
|
|
29
29
|
for (const anim of active) {
|
|
30
|
-
const result = anim.interpolator(anim.current, anim.target, anim.velocity, dt)
|
|
30
|
+
const result = anim.interpolator(anim.current, anim.target, anim.velocity, dt, anim)
|
|
31
31
|
anim.current = result.value
|
|
32
32
|
anim.velocity = result.velocity
|
|
33
33
|
anim.setter(result.value)
|
|
@@ -52,6 +52,7 @@ export function animated(initial, interpolator) {
|
|
|
52
52
|
current: initial,
|
|
53
53
|
target: initial,
|
|
54
54
|
velocity: 0,
|
|
55
|
+
generation: 0,
|
|
55
56
|
interpolator,
|
|
56
57
|
setter: set,
|
|
57
58
|
onTick: null,
|
|
@@ -59,7 +60,12 @@ export function animated(initial, interpolator) {
|
|
|
59
60
|
|
|
60
61
|
function setTarget(target) {
|
|
61
62
|
if (target === anim.target && !active.has(anim)) return
|
|
62
|
-
anim.target
|
|
63
|
+
if (target !== anim.target) {
|
|
64
|
+
anim.target = target
|
|
65
|
+
// retargeting mid-flight starts a fresh timing curve in stateful
|
|
66
|
+
// interpolators like ease() instead of lurching along the old one
|
|
67
|
+
anim.generation++
|
|
68
|
+
}
|
|
63
69
|
active.add(anim)
|
|
64
70
|
startLoop()
|
|
65
71
|
}
|
|
@@ -141,20 +147,27 @@ export function spring({ frequency = 2, damping = 0.3 } = {}) {
|
|
|
141
147
|
}
|
|
142
148
|
|
|
143
149
|
export function ease(durationMs, easingFn = easeOutCubic) {
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
150
|
+
// state is keyed per animation (and per retarget generation) so one ease()
|
|
151
|
+
// instance can safely drive several animated() values
|
|
152
|
+
const states = new WeakMap()
|
|
153
|
+
const fallbackKey = {}
|
|
154
|
+
|
|
155
|
+
return function stepEase(current, target, velocity, dt, anim) {
|
|
156
|
+
const key = anim ?? fallbackKey
|
|
157
|
+
const generation = anim?.generation ?? 0
|
|
158
|
+
let state = states.get(key)
|
|
159
|
+
if (!state || state.generation !== generation) {
|
|
160
|
+
state = { elapsed: 0, startValue: current, generation }
|
|
161
|
+
states.set(key, state)
|
|
162
|
+
}
|
|
149
163
|
|
|
150
|
-
elapsed += dt * 1000
|
|
151
|
-
const t = Math.min(1, elapsed / durationMs)
|
|
164
|
+
state.elapsed += dt * 1000
|
|
165
|
+
const t = Math.min(1, state.elapsed / durationMs)
|
|
152
166
|
const eased = easingFn(t)
|
|
153
|
-
const value = startValue + (target - startValue) * eased
|
|
167
|
+
const value = state.startValue + (target - state.startValue) * eased
|
|
154
168
|
|
|
155
169
|
if (t >= 1) {
|
|
156
|
-
|
|
157
|
-
elapsed = 0
|
|
170
|
+
states.delete(key)
|
|
158
171
|
return { value: target, velocity: 0, done: true }
|
|
159
172
|
}
|
|
160
173
|
|
package/src/ansi.js
CHANGED
|
@@ -17,6 +17,7 @@ export const exitAltScreen = `${ESC}?1049l`
|
|
|
17
17
|
export const sgrReset = `${ESC}0m`
|
|
18
18
|
|
|
19
19
|
export const setTitle = (title) => `\x1b]2;${title}\x07`
|
|
20
|
+
export const osc52Copy = (text) => `\x1b]52;c;${Buffer.from(text, 'utf8').toString('base64')}\x07`
|
|
20
21
|
|
|
21
22
|
export const enableMouse = `${ESC}?1002h${ESC}?1006h`
|
|
22
23
|
export const disableMouse = `${ESC}?1002l${ESC}?1006l`
|
|
@@ -148,6 +149,11 @@ export function parseSgr(params, state) {
|
|
|
148
149
|
return state
|
|
149
150
|
}
|
|
150
151
|
|
|
152
|
+
export function fgSgr(color) {
|
|
153
|
+
const code = parseColor(color, 38)
|
|
154
|
+
return code ? `${ESC}${code}m` : ''
|
|
155
|
+
}
|
|
156
|
+
|
|
151
157
|
export function sgr(fg, bg, attrs) {
|
|
152
158
|
const parts = ['0']
|
|
153
159
|
|
package/src/buffer.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { parseSgr } from './ansi.js'
|
|
2
|
-
import { charWidth } from './wrap.js'
|
|
2
|
+
import { charWidth, ansiSeqEnd } from './wrap.js'
|
|
3
3
|
|
|
4
4
|
const EMPTY = { ch: ' ', fg: null, bg: null, attrs: 0 }
|
|
5
5
|
|
|
@@ -7,12 +7,13 @@ export function createBuffer(width, height) {
|
|
|
7
7
|
const size = width * height
|
|
8
8
|
const cells = new Array(size)
|
|
9
9
|
for (let i = 0; i < size; i++) cells[i] = EMPTY
|
|
10
|
-
return { width, height, cells }
|
|
10
|
+
return { width, height, cells, softWrap: new Uint8Array(height) }
|
|
11
11
|
}
|
|
12
12
|
|
|
13
13
|
export function clearBuffer(buf) {
|
|
14
14
|
const len = buf.cells.length
|
|
15
15
|
for (let i = 0; i < len; i++) buf.cells[i] = EMPTY
|
|
16
|
+
buf.softWrap.fill(0)
|
|
16
17
|
}
|
|
17
18
|
|
|
18
19
|
export function resizeBuffer(buf, width, height) {
|
|
@@ -20,6 +21,7 @@ export function resizeBuffer(buf, width, height) {
|
|
|
20
21
|
buf.height = height
|
|
21
22
|
buf.cells = new Array(width * height)
|
|
22
23
|
for (let i = 0; i < buf.cells.length; i++) buf.cells[i] = EMPTY
|
|
24
|
+
buf.softWrap = new Uint8Array(height)
|
|
23
25
|
}
|
|
24
26
|
|
|
25
27
|
export function setCell(buf, x, y, ch, fg, bg, attrs) {
|
|
@@ -27,69 +29,80 @@ export function setCell(buf, x, y, ch, fg, bg, attrs) {
|
|
|
27
29
|
buf.cells[y * buf.width + x] = { ch, fg: fg ?? null, bg: bg ?? null, attrs: attrs ?? 0 }
|
|
28
30
|
}
|
|
29
31
|
|
|
32
|
+
function putCell(buf, idx, ch, fg, bg, attrs) {
|
|
33
|
+
const prev = buf.cells[idx]
|
|
34
|
+
const transparent = ch === ' ' && !bg && prev.ch !== ' '
|
|
35
|
+
buf.cells[idx] = {
|
|
36
|
+
ch: transparent ? prev.ch : ch,
|
|
37
|
+
fg: transparent ? prev.fg : (fg ?? prev.fg),
|
|
38
|
+
bg: bg ?? prev.bg,
|
|
39
|
+
attrs: transparent ? prev.attrs : (attrs ?? prev.attrs),
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// non-SGR escapes (OSC, cursor CSI, ...) are dropped: cells hold one glyph
|
|
44
|
+
// plus style, there is nothing for them to attach to. zero-width chars fold
|
|
45
|
+
// into the previous cell's glyph. tabs expand to spaces at 8-column stops
|
|
46
|
+
// measured from the start of the write, matching measureText
|
|
30
47
|
export function writeText(buf, x, y, text, fg, bg, attrs, maxWidth) {
|
|
31
48
|
if (y < 0 || y >= buf.height) return
|
|
32
49
|
const max = maxWidth ?? (buf.width - x)
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
let col = 0
|
|
36
|
-
let i = 0
|
|
37
|
-
while (i < text.length && col < max) {
|
|
38
|
-
const code = text.codePointAt(i)
|
|
39
|
-
const len = code > 0xffff ? 2 : 1
|
|
40
|
-
const w = charWidth(code)
|
|
41
|
-
const cx = x + col
|
|
42
|
-
if (cx >= 0 && cx < buf.width) {
|
|
43
|
-
const ch = len === 1 ? text[i] : text.slice(i, i + len)
|
|
44
|
-
const prev = buf.cells[y * buf.width + cx]
|
|
45
|
-
const transparent = ch === ' ' && !bg && prev.ch !== ' '
|
|
46
|
-
buf.cells[y * buf.width + cx] = {
|
|
47
|
-
ch: transparent ? prev.ch : ch,
|
|
48
|
-
fg: transparent ? prev.fg : (fg ?? prev.fg),
|
|
49
|
-
bg: bg ?? prev.bg,
|
|
50
|
-
attrs: transparent ? prev.attrs : (attrs || prev.attrs),
|
|
51
|
-
}
|
|
52
|
-
if (w === 2 && cx + 1 < buf.width) {
|
|
53
|
-
buf.cells[y * buf.width + cx + 1] = { ch: '', fg: fg ?? null, bg: bg ?? null, attrs: attrs ?? 0 }
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
col += w
|
|
57
|
-
i += len
|
|
58
|
-
}
|
|
59
|
-
return
|
|
60
|
-
}
|
|
61
|
-
|
|
50
|
+
const base = y * buf.width
|
|
51
|
+
let ansi = null
|
|
62
52
|
let col = 0
|
|
63
|
-
const ansi = { fg: null, bg: null, attrs: 0 }
|
|
64
53
|
let i = 0
|
|
65
54
|
|
|
66
|
-
while (i < text.length
|
|
67
|
-
if (text
|
|
68
|
-
const end = text
|
|
55
|
+
while (i < text.length) {
|
|
56
|
+
if (text.charCodeAt(i) === 27) {
|
|
57
|
+
const end = ansiSeqEnd(text, i)
|
|
69
58
|
if (end !== -1) {
|
|
70
|
-
|
|
71
|
-
|
|
59
|
+
if (text[i + 1] === '[' && text[end - 1] === 'm') {
|
|
60
|
+
if (ansi === null) ansi = { fg: null, bg: null, attrs: 0 }
|
|
61
|
+
parseSgr(text.slice(i + 2, end - 1), ansi)
|
|
62
|
+
}
|
|
63
|
+
i = end
|
|
72
64
|
continue
|
|
73
65
|
}
|
|
74
66
|
}
|
|
75
67
|
|
|
68
|
+
const efg = ansi === null ? fg : (ansi.fg ?? fg)
|
|
69
|
+
const ebg = ansi === null ? bg : (ansi.bg ?? bg)
|
|
70
|
+
const eattrs = ansi === null || ansi.attrs === 0 ? attrs : ansi.attrs
|
|
71
|
+
|
|
76
72
|
const code = text.codePointAt(i)
|
|
77
73
|
const len = code > 0xffff ? 2 : 1
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
fg: transparent ? prev.fg : (ansi.fg ?? fg ?? prev.fg),
|
|
87
|
-
bg: ansi.bg ?? bg ?? prev.bg,
|
|
88
|
-
attrs: transparent ? prev.attrs : (ansi.attrs || attrs || prev.attrs),
|
|
74
|
+
|
|
75
|
+
if (code === 9) {
|
|
76
|
+
let stop = col + 8 - (col % 8)
|
|
77
|
+
if (stop > max) stop = max
|
|
78
|
+
while (col < stop) {
|
|
79
|
+
const cx = x + col
|
|
80
|
+
if (cx >= 0 && cx < buf.width) putCell(buf, base + cx, ' ', efg, ebg, eattrs)
|
|
81
|
+
col++
|
|
89
82
|
}
|
|
90
|
-
|
|
91
|
-
|
|
83
|
+
i++
|
|
84
|
+
continue
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const w = charWidth(code)
|
|
88
|
+
|
|
89
|
+
if (w === 0) {
|
|
90
|
+
const px = x + col - 1
|
|
91
|
+
if (col > 0 && px >= 0 && px < buf.width) {
|
|
92
|
+
let pi = base + px
|
|
93
|
+
if (buf.cells[pi].ch === '' && px > 0) pi--
|
|
94
|
+
const pc = buf.cells[pi]
|
|
95
|
+
if (pc.ch !== '') buf.cells[pi] = { ch: pc.ch + text.slice(i, i + len), fg: pc.fg, bg: pc.bg, attrs: pc.attrs }
|
|
92
96
|
}
|
|
97
|
+
i += len
|
|
98
|
+
continue
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (col + w > max) break
|
|
102
|
+
const cx = x + col
|
|
103
|
+
if (cx >= 0 && cx + w <= buf.width) {
|
|
104
|
+
putCell(buf, base + cx, len === 1 ? text[i] : text.slice(i, i + len), efg, ebg, eattrs)
|
|
105
|
+
if (w === 2) buf.cells[base + cx + 1] = { ch: '', fg: efg ?? null, bg: ebg ?? null, attrs: eattrs ?? 0 }
|
|
93
106
|
}
|
|
94
107
|
col += w
|
|
95
108
|
i += len
|
package/src/button.js
CHANGED
|
@@ -2,7 +2,7 @@ import { jsx } from '../jsx-runtime.js'
|
|
|
2
2
|
import { useInput, useMouse, useLayout, useTheme } from './hooks.js'
|
|
3
3
|
|
|
4
4
|
export function Button({ label, onPress, focused = false, variant }) {
|
|
5
|
-
const { accent = 'cyan' } = useTheme()
|
|
5
|
+
const { accent = 'cyan', accentText = 'black', muted = 'gray' } = useTheme()
|
|
6
6
|
const layout = useLayout()
|
|
7
7
|
|
|
8
8
|
useInput((event) => {
|
|
@@ -26,7 +26,7 @@ export function Button({ label, onPress, focused = false, variant }) {
|
|
|
26
26
|
return jsx('text', {
|
|
27
27
|
style: {
|
|
28
28
|
bg: focused ? accent : null,
|
|
29
|
-
color: focused ?
|
|
29
|
+
color: focused ? accentText : (dim ? muted : null),
|
|
30
30
|
bold: focused,
|
|
31
31
|
dim: !focused && dim,
|
|
32
32
|
},
|
package/src/checkbox.js
CHANGED
|
@@ -2,7 +2,7 @@ import { jsx, jsxs } from '../jsx-runtime.js'
|
|
|
2
2
|
import { useInput, useMouse, useLayout, useTheme } from './hooks.js'
|
|
3
3
|
|
|
4
4
|
export function Checkbox({ checked = false, label, onChange, focused = false, checkedIcon = '[x]', uncheckedIcon = '[ ]' }) {
|
|
5
|
-
const { accent = 'cyan' } = useTheme()
|
|
5
|
+
const { accent = 'cyan', accentText = 'black' } = useTheme()
|
|
6
6
|
const layout = useLayout()
|
|
7
7
|
|
|
8
8
|
useInput((event) => {
|
|
@@ -23,7 +23,7 @@ export function Checkbox({ checked = false, label, onChange, focused = false, ch
|
|
|
23
23
|
|
|
24
24
|
const icon = checked ? checkedIcon : uncheckedIcon
|
|
25
25
|
const bg = focused ? accent : null
|
|
26
|
-
const color = focused ?
|
|
26
|
+
const color = focused ? accentText : null
|
|
27
27
|
|
|
28
28
|
const children = [
|
|
29
29
|
jsx('text', { style: { color, bold: focused }, children: icon }),
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
// turns before/after text, a unified patch string, or structured hunks into a
|
|
2
|
+
// single normalized row model the Diff component renders. each row carries
|
|
3
|
+
// line numbers, a type, and optional intra-line change ranges (word-level)
|
|
4
|
+
|
|
5
|
+
const LCS_CELL_LIMIT = 4_000_000
|
|
6
|
+
|
|
7
|
+
function splitLines(text) {
|
|
8
|
+
if (text == null || text === '') return []
|
|
9
|
+
const lines = String(text).split('\n')
|
|
10
|
+
if (lines.length > 1 && lines[lines.length - 1] === '') lines.pop()
|
|
11
|
+
return lines
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// classic LCS edit script over arrays, with common prefix/suffix trimming so a
|
|
15
|
+
// small edit inside a large file stays cheap. returns ops in sequence order
|
|
16
|
+
function diffSequences(a, b, eq = (x, y) => x === y) {
|
|
17
|
+
const ops = []
|
|
18
|
+
let lo = 0
|
|
19
|
+
let aHi = a.length
|
|
20
|
+
let bHi = b.length
|
|
21
|
+
|
|
22
|
+
while (lo < aHi && lo < bHi && eq(a[lo], b[lo])) {
|
|
23
|
+
ops.push({ type: 'equal', a: lo, b: lo })
|
|
24
|
+
lo++
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const tail = []
|
|
28
|
+
while (aHi > lo && bHi > lo && eq(a[aHi - 1], b[bHi - 1])) {
|
|
29
|
+
aHi--
|
|
30
|
+
bHi--
|
|
31
|
+
tail.push({ type: 'equal', a: aHi, b: bHi })
|
|
32
|
+
}
|
|
33
|
+
tail.reverse()
|
|
34
|
+
|
|
35
|
+
const n = aHi - lo
|
|
36
|
+
const m = bHi - lo
|
|
37
|
+
|
|
38
|
+
if (n === 0 || m === 0 || (n + 1) * (m + 1) > LCS_CELL_LIMIT) {
|
|
39
|
+
for (let i = lo; i < aHi; i++) ops.push({ type: 'del', a: i })
|
|
40
|
+
for (let j = lo; j < bHi; j++) ops.push({ type: 'add', b: j })
|
|
41
|
+
return ops.concat(tail)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const W = m + 1
|
|
45
|
+
const dp = new Int32Array((n + 1) * W)
|
|
46
|
+
for (let i = n - 1; i >= 0; i--) {
|
|
47
|
+
for (let j = m - 1; j >= 0; j--) {
|
|
48
|
+
dp[i * W + j] = eq(a[lo + i], b[lo + j])
|
|
49
|
+
? dp[(i + 1) * W + (j + 1)] + 1
|
|
50
|
+
: Math.max(dp[(i + 1) * W + j], dp[i * W + (j + 1)])
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
let i = 0
|
|
55
|
+
let j = 0
|
|
56
|
+
while (i < n && j < m) {
|
|
57
|
+
if (eq(a[lo + i], b[lo + j])) {
|
|
58
|
+
ops.push({ type: 'equal', a: lo + i, b: lo + j })
|
|
59
|
+
i++
|
|
60
|
+
j++
|
|
61
|
+
} else if (dp[(i + 1) * W + j] >= dp[i * W + (j + 1)]) {
|
|
62
|
+
ops.push({ type: 'del', a: lo + i })
|
|
63
|
+
i++
|
|
64
|
+
} else {
|
|
65
|
+
ops.push({ type: 'add', b: lo + j })
|
|
66
|
+
j++
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
while (i < n) ops.push({ type: 'del', a: lo + i++ })
|
|
70
|
+
while (j < m) ops.push({ type: 'add', b: lo + j++ })
|
|
71
|
+
|
|
72
|
+
return ops.concat(tail)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// codepoint-aware tokenizer so intra ranges line up with the renderer's
|
|
76
|
+
// visible-character slicing. runs of word chars stay whole, everything else
|
|
77
|
+
// splits per character
|
|
78
|
+
function tokenize(line) {
|
|
79
|
+
const chars = Array.from(line)
|
|
80
|
+
const tokens = []
|
|
81
|
+
let i = 0
|
|
82
|
+
while (i < chars.length) {
|
|
83
|
+
if (/\w/.test(chars[i])) {
|
|
84
|
+
const start = i
|
|
85
|
+
while (i < chars.length && /\w/.test(chars[i])) i++
|
|
86
|
+
tokens.push({ text: chars.slice(start, i).join(''), start, end: i })
|
|
87
|
+
} else {
|
|
88
|
+
tokens.push({ text: chars[i], start: i, end: i + 1 })
|
|
89
|
+
i++
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return tokens
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function mergeRanges(ranges) {
|
|
96
|
+
if (ranges.length === 0) return null
|
|
97
|
+
ranges.sort((x, y) => x[0] - y[0])
|
|
98
|
+
const merged = [ranges[0].slice()]
|
|
99
|
+
for (let k = 1; k < ranges.length; k++) {
|
|
100
|
+
const last = merged[merged.length - 1]
|
|
101
|
+
if (ranges[k][0] <= last[1]) last[1] = Math.max(last[1], ranges[k][1])
|
|
102
|
+
else merged.push(ranges[k].slice())
|
|
103
|
+
}
|
|
104
|
+
return merged
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// word-level diff of two lines. returns the changed codepoint ranges on each
|
|
108
|
+
// side, or null when the lines are too dissimilar for sub-line highlighting to
|
|
109
|
+
// read as anything but noise
|
|
110
|
+
function diffWords(oldLine, newLine, { minSimilarity = 0.25 } = {}) {
|
|
111
|
+
const aTok = tokenize(oldLine)
|
|
112
|
+
const bTok = tokenize(newLine)
|
|
113
|
+
const ops = diffSequences(aTok.map(t => t.text), bTok.map(t => t.text))
|
|
114
|
+
|
|
115
|
+
let common = 0
|
|
116
|
+
for (const op of ops) if (op.type === 'equal') common += aTok[op.a].end - aTok[op.a].start
|
|
117
|
+
const total = Array.from(oldLine).length + Array.from(newLine).length
|
|
118
|
+
if (total > 0 && (2 * common) / total < minSimilarity) return { old: null, new: null }
|
|
119
|
+
|
|
120
|
+
const oldRanges = []
|
|
121
|
+
const newRanges = []
|
|
122
|
+
for (const op of ops) {
|
|
123
|
+
if (op.type === 'del') oldRanges.push([aTok[op.a].start, aTok[op.a].end])
|
|
124
|
+
else if (op.type === 'add') newRanges.push([bTok[op.b].start, bTok[op.b].end])
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return { old: mergeRanges(oldRanges), new: mergeRanges(newRanges) }
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// for every block of deletions immediately followed by additions, pair the
|
|
131
|
+
// lines index-wise and compute their intra ranges
|
|
132
|
+
function annotateIntra(rows, wordDiff) {
|
|
133
|
+
if (!wordDiff) return rows
|
|
134
|
+
let k = 0
|
|
135
|
+
while (k < rows.length) {
|
|
136
|
+
if (rows[k].type !== 'del') { k++; continue }
|
|
137
|
+
let delEnd = k
|
|
138
|
+
while (delEnd < rows.length && rows[delEnd].type === 'del') delEnd++
|
|
139
|
+
let addEnd = delEnd
|
|
140
|
+
while (addEnd < rows.length && rows[addEnd].type === 'add') addEnd++
|
|
141
|
+
|
|
142
|
+
const dels = delEnd - k
|
|
143
|
+
const adds = addEnd - delEnd
|
|
144
|
+
const pairs = Math.min(dels, adds)
|
|
145
|
+
for (let p = 0; p < pairs; p++) {
|
|
146
|
+
const delRow = rows[k + p]
|
|
147
|
+
const addRow = rows[delEnd + p]
|
|
148
|
+
const { old, new: nw } = diffWords(delRow.text, addRow.text)
|
|
149
|
+
delRow.intra = old
|
|
150
|
+
addRow.intra = nw
|
|
151
|
+
}
|
|
152
|
+
k = addEnd > k ? addEnd : k + 1
|
|
153
|
+
}
|
|
154
|
+
return rows
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function rowsFromOps(ops, aLines, bLines) {
|
|
158
|
+
const rows = []
|
|
159
|
+
for (const op of ops) {
|
|
160
|
+
if (op.type === 'equal') {
|
|
161
|
+
rows.push({ type: 'context', oldNo: op.a + 1, newNo: op.b + 1, text: aLines[op.a], intra: null })
|
|
162
|
+
} else if (op.type === 'del') {
|
|
163
|
+
rows.push({ type: 'del', oldNo: op.a + 1, newNo: null, text: aLines[op.a], intra: null })
|
|
164
|
+
} else {
|
|
165
|
+
rows.push({ type: 'add', oldNo: null, newNo: op.b + 1, text: bLines[op.b], intra: null })
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return rows
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function rowsFromBeforeAfter(before, after, wordDiff) {
|
|
172
|
+
const aLines = splitLines(before)
|
|
173
|
+
const bLines = splitLines(after)
|
|
174
|
+
const ops = diffSequences(aLines, bLines)
|
|
175
|
+
return annotateIntra(rowsFromOps(ops, aLines, bLines), wordDiff)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const HUNK_RE = /^@@+ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@@*(.*)$/
|
|
179
|
+
|
|
180
|
+
function rowsFromPatch(patch, wordDiff) {
|
|
181
|
+
const rows = []
|
|
182
|
+
let oldNo = 0
|
|
183
|
+
let newNo = 0
|
|
184
|
+
let oldRemain = 0
|
|
185
|
+
let newRemain = 0
|
|
186
|
+
|
|
187
|
+
for (const raw of String(patch).split('\n')) {
|
|
188
|
+
if (raw.startsWith('\\')) {
|
|
189
|
+
const prev = rows[rows.length - 1]
|
|
190
|
+
if (prev && (prev.type === 'add' || prev.type === 'del' || prev.type === 'context')) prev.noNewline = true
|
|
191
|
+
continue
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (oldRemain <= 0 && newRemain <= 0) {
|
|
195
|
+
const hunk = HUNK_RE.exec(raw)
|
|
196
|
+
if (hunk) {
|
|
197
|
+
oldNo = parseInt(hunk[1], 10)
|
|
198
|
+
newNo = parseInt(hunk[3], 10)
|
|
199
|
+
oldRemain = hunk[2] != null ? parseInt(hunk[2], 10) : 1
|
|
200
|
+
newRemain = hunk[4] != null ? parseInt(hunk[4], 10) : 1
|
|
201
|
+
rows.push({ type: 'hunk', oldNo: null, newNo: null, text: raw, intra: null })
|
|
202
|
+
continue
|
|
203
|
+
}
|
|
204
|
+
if (raw !== '' && (raw.startsWith('diff ') || raw.startsWith('index ') ||
|
|
205
|
+
raw.startsWith('--- ') || raw.startsWith('+++ ') || raw.startsWith('old mode') ||
|
|
206
|
+
raw.startsWith('new mode') || raw.startsWith('similarity ') ||
|
|
207
|
+
raw.startsWith('rename ') || raw.startsWith('new file') ||
|
|
208
|
+
raw.startsWith('deleted file') || raw.startsWith('Binary '))) {
|
|
209
|
+
rows.push({ type: 'meta', oldNo: null, newNo: null, text: raw, intra: null })
|
|
210
|
+
}
|
|
211
|
+
continue
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const marker = raw[0]
|
|
215
|
+
const text = raw.slice(1)
|
|
216
|
+
if (marker === '+') {
|
|
217
|
+
rows.push({ type: 'add', oldNo: null, newNo: newNo++, text, intra: null })
|
|
218
|
+
newRemain--
|
|
219
|
+
} else if (marker === '-') {
|
|
220
|
+
rows.push({ type: 'del', oldNo: oldNo++, newNo: null, text, intra: null })
|
|
221
|
+
oldRemain--
|
|
222
|
+
} else {
|
|
223
|
+
rows.push({ type: 'context', oldNo: oldNo++, newNo: newNo++, text, intra: null })
|
|
224
|
+
oldRemain--
|
|
225
|
+
newRemain--
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return annotateIntra(rows, wordDiff)
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function rowsFromHunks(hunks, wordDiff) {
|
|
233
|
+
const rows = []
|
|
234
|
+
for (const hunk of hunks) {
|
|
235
|
+
let oldNo = hunk.oldStart ?? 1
|
|
236
|
+
let newNo = hunk.newStart ?? 1
|
|
237
|
+
if (hunk.header !== false) {
|
|
238
|
+
rows.push({ type: 'hunk', oldNo: null, newNo: null, text: hunk.header ?? hunkHeader(hunk), intra: null })
|
|
239
|
+
}
|
|
240
|
+
for (const line of hunk.lines ?? []) {
|
|
241
|
+
const type = line.type ?? 'context'
|
|
242
|
+
const text = line.content ?? line.text ?? ''
|
|
243
|
+
if (type === 'add') rows.push({ type: 'add', oldNo: null, newNo: newNo++, text, intra: null })
|
|
244
|
+
else if (type === 'del') rows.push({ type: 'del', oldNo: oldNo++, newNo: null, text, intra: null })
|
|
245
|
+
else rows.push({ type: 'context', oldNo: oldNo++, newNo: newNo++, text, intra: null })
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
return annotateIntra(rows, wordDiff)
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function hunkHeader(hunk) {
|
|
252
|
+
let oldCount = 0
|
|
253
|
+
let newCount = 0
|
|
254
|
+
for (const line of hunk.lines ?? []) {
|
|
255
|
+
const type = line.type ?? 'context'
|
|
256
|
+
if (type !== 'add') oldCount++
|
|
257
|
+
if (type !== 'del') newCount++
|
|
258
|
+
}
|
|
259
|
+
return `@@ -${hunk.oldStart ?? 1},${oldCount} +${hunk.newStart ?? 1},${newCount} @@`
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// collapses runs of unchanged context longer than 2*context into a fold row, so
|
|
263
|
+
// a small edit in a large file shows just the neighborhood of each change
|
|
264
|
+
function foldContext(rows, context) {
|
|
265
|
+
if (!Number.isFinite(context)) return rows
|
|
266
|
+
const keep = new Array(rows.length).fill(false)
|
|
267
|
+
for (let i = 0; i < rows.length; i++) {
|
|
268
|
+
if (rows[i].type === 'context') continue
|
|
269
|
+
for (let d = -context; d <= context; d++) {
|
|
270
|
+
const j = i + d
|
|
271
|
+
if (j >= 0 && j < rows.length) keep[j] = true
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const out = []
|
|
276
|
+
let i = 0
|
|
277
|
+
while (i < rows.length) {
|
|
278
|
+
if (keep[i] || rows[i].type !== 'context') {
|
|
279
|
+
out.push(rows[i])
|
|
280
|
+
i++
|
|
281
|
+
continue
|
|
282
|
+
}
|
|
283
|
+
let j = i
|
|
284
|
+
while (j < rows.length && !keep[j] && rows[j].type === 'context') j++
|
|
285
|
+
const count = j - i
|
|
286
|
+
if (count > 0) out.push({ type: 'fold', oldNo: null, newNo: null, text: `${count} unchanged ${count === 1 ? 'line' : 'lines'}`, count, intra: null })
|
|
287
|
+
i = j
|
|
288
|
+
}
|
|
289
|
+
return out
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function countStats(rows) {
|
|
293
|
+
let additions = 0
|
|
294
|
+
let deletions = 0
|
|
295
|
+
for (const row of rows) {
|
|
296
|
+
if (row.type === 'add') additions++
|
|
297
|
+
else if (row.type === 'del') deletions++
|
|
298
|
+
}
|
|
299
|
+
return { additions, deletions }
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
export function computeDiff({ before, after, patch, hunks, wordDiff = true, context = Infinity } = {}) {
|
|
303
|
+
let rows
|
|
304
|
+
if (hunks != null) rows = rowsFromHunks(hunks, wordDiff)
|
|
305
|
+
else if (patch != null) rows = rowsFromPatch(patch, wordDiff)
|
|
306
|
+
else rows = rowsFromBeforeAfter(before ?? '', after ?? '', wordDiff)
|
|
307
|
+
|
|
308
|
+
const stats = countStats(rows)
|
|
309
|
+
rows = foldContext(rows, context)
|
|
310
|
+
return { rows, stats }
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
export { diffSequences, diffWords, tokenize, splitLines }
|