@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/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 = 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
- let elapsed = 0
145
- let startValue = null
146
-
147
- return function stepEase(current, target, velocity, dt) {
148
- if (startValue === null) startValue = current
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
- startValue = null
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
- if (text.indexOf('\x1b') === -1) {
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 && col < max) {
67
- if (text[i] === '\x1b' && text[i + 1] === '[') {
68
- const end = text.indexOf('m', i + 2)
55
+ while (i < text.length) {
56
+ if (text.charCodeAt(i) === 27) {
57
+ const end = ansiSeqEnd(text, i)
69
58
  if (end !== -1) {
70
- parseSgr(text.slice(i + 2, end), ansi)
71
- i = end + 1
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
- const w = charWidth(code)
79
- const cx = x + col
80
- if (cx >= 0 && cx < buf.width) {
81
- const ch = len === 1 ? text[i] : text.slice(i, i + len)
82
- const prev = buf.cells[y * buf.width + cx]
83
- const transparent = ch === ' ' && !bg && prev.ch !== ' '
84
- buf.cells[y * buf.width + cx] = {
85
- ch: transparent ? prev.ch : ch,
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
- if (w === 2 && cx + 1 < buf.width) {
91
- buf.cells[y * buf.width + cx + 1] = { ch: '', fg: ansi.fg ?? fg ?? null, bg: ansi.bg ?? bg ?? null, attrs: ansi.attrs || attrs || 0 }
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 ? 'black' : (dim ? 'gray' : null),
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 ? 'black' : null
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 }