@shiyuphan/follow-me 0.1.0 → 0.2.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.
@@ -4,23 +4,44 @@
4
4
 
5
5
  /**
6
6
  * @typedef {{ op: " ", text: string } | { op: "+" | "-", text: string, change: number }} HunkLine
7
- * @typedef {{ kind: "hunk", id: string, path: string, body: HunkLine[] }
8
- * | { kind: "file" | "mode", id: string, path: string }} Unit
7
+ * @typedef {{ mode: string, blob: string }} Entry
8
+ * @typedef {{ kind: "hunk", id: string, path: string, oldStart?: number, body: HunkLine[] }
9
+ * | { kind: "file", id: string, path: string, reason: string, before: Entry | null, after: Entry | null }
10
+ * | { kind: "mode", id: string, path: string, from: string, to: string }} Unit
9
11
  * @typedef {{ kind: "rest" } | { kind: "lines", changes: number[] }} Selection
10
- * @typedef {{ unit: string, select: Selection, narration?: string }} Ref
11
- * @typedef {{ index: number, message: string, narration?: string, refs: Ref[] }} Commit
12
- * @typedef {{ kind: "commit", commit: number, say: string }
13
- * | { kind: "unit", commit: number, unit: string, say: string | null }
14
- * | { kind: "line", commit: number, unit: string, line: number, change: number, op: "+" | "-" }} Step
15
- * @typedef {"hidden" | "shown" | "gone"} LineState
12
+ * @typedef {{ unit: string, select: Selection, narration?: string, diagram?: string }} Ref
13
+ * @typedef {{ index: number, message: string, narration?: string, diagram?: string, refs: Ref[] }} Commit
14
+ *
15
+ * A beat is one thing the reviewer attends to: a commit's intro, one chunk of its change, or the
16
+ * held finished commit. lines are indices into the hunk body, removals the subset that are "-"
17
+ * lines; f_ and m_ chunks have neither. added counts the non-blank "+" lines.
18
+ * @typedef {{ kind: "intro", commit: number, message: string, narration: string | null }
19
+ * | { kind: "chunk", commit: number, unit: string, lines: number[], narration: string | null,
20
+ * removals: number[], label: string, added: number }
21
+ * | { kind: "outro", commit: number, next: string | null }} Beat
22
+ *
23
+ * A step is a timed slice of a beat. "say" shows the narration card and speaks it, lasting ms or
24
+ * until speech ends plus tail, whichever is later. "show" reveals a chunk's lines. "rest" is the
25
+ * pause after a chunk, "hold" the finished commit.
26
+ * @typedef {{ kind: "say", beat: number, text: string, ms: number, tail: number }
27
+ * | { kind: "show" | "rest" | "hold", beat: number, ms: number }} Step
28
+ *
29
+ * @typedef {"hidden" | "shown" | "struck"} LineState
16
30
  * @typedef {{ name: string, lang: string, default: boolean }} VoiceInfo
17
31
  */
18
32
 
19
- export const SPEEDS = [0.5, 0.75, 1, 1.25, 1.5, 2, 3];
33
+ export const SPEEDS = [0.75, 1, 1.5, 2];
20
34
 
21
- const LINES_PER_SECOND = 4;
22
- const COMMIT_BEAT_MS = 700;
23
- const UNIT_BEAT_MS = 350;
35
+ /** Context lines that may sit between two change lines of one chunk. */
36
+ const MAX_GAP = 3;
37
+ /** A longer run is cut at the first blank or closing line once it has this many lines. */
38
+ const SPLIT_AFTER = 8;
39
+ const MAX_CHUNK = 12;
40
+
41
+ const BEAT_MS = 600;
42
+ const HOLD_MS = 2500;
43
+ const INTRO_TAIL_MS = 500;
44
+ const MAX_SPEECH_RATE = 1.5;
24
45
  const WORDS_PER_SECOND = 2.5;
25
46
 
26
47
  /**
@@ -32,63 +53,248 @@ export function lineKey(unit, line) {
32
53
  return `${unit}:${line}`;
33
54
  }
34
55
 
56
+ /** @param {string} message */
57
+ export function titleOf(message) {
58
+ return message.split("\n")[0] ?? "";
59
+ }
60
+
61
+ /** @param {string} text */
62
+ function wordCount(text) {
63
+ return text.split(/\s+/).filter(Boolean).length;
64
+ }
65
+
35
66
  /**
36
- * What is spoken when a commit starts: its narration, else its title.
37
- * @param {Commit} commit
67
+ * Milliseconds to read a text at 1x: about 200 words a minute plus a second to settle.
68
+ * @param {string} text
38
69
  */
39
- export function commitNarration(commit) {
40
- const narration = commit.narration?.trim();
41
- if (narration) return narration;
42
- return commit.message.split("\n")[0] ?? "";
70
+ export function readMs(text) {
71
+ return Math.max(2000, 1000 + 300 * wordCount(text));
43
72
  }
44
73
 
45
74
  /**
46
- * Every step of the playback in order: a commit begins, each of its units begins, and each of
47
- * the commit's own change lines appears (+) or fades out (-) in hunk order.
75
+ * Milliseconds to take in a chunk at 1x. New code costs more to read than removed code.
76
+ * @param {number} added non-blank added lines
77
+ * @param {number} removed
78
+ */
79
+ export function codeMs(added, removed) {
80
+ return Math.min(9000, Math.max(2000, 1500 + 400 * added + 200 * removed));
81
+ }
82
+
83
+ /** @param {string | undefined} text */
84
+ function present(text) {
85
+ const trimmed = text?.trim();
86
+ return trimmed ? trimmed : null;
87
+ }
88
+
89
+ /** @param {readonly Commit[]} commits */
90
+ export function hasNarration(commits) {
91
+ return commits.some(
92
+ (c) => present(c.narration) !== null || c.refs.some((r) => present(r.narration) !== null),
93
+ );
94
+ }
95
+
96
+ /** @param {HunkLine | undefined} line */
97
+ function textOf(line) {
98
+ return (line?.text ?? "").replace(/\r?\n$/, "");
99
+ }
100
+
101
+ /**
102
+ * Runs of a ref's own change lines with at most MAX_GAP other lines between them, each cut to at
103
+ * most MAX_CHUNK lines, preferably right after a blank or closing line.
104
+ * @param {readonly HunkLine[]} body
105
+ * @param {ReadonlySet<number>} mine
106
+ */
107
+ function runsOf(body, mine) {
108
+ /** @type {number[][]} */
109
+ const runs = [];
110
+ let gap = Number.POSITIVE_INFINITY;
111
+ body.forEach((line, i) => {
112
+ if (line.op === " " || !mine.has(line.change)) {
113
+ gap += 1;
114
+ return;
115
+ }
116
+ const last = runs.at(-1);
117
+ if (last !== undefined && gap <= MAX_GAP) last.push(i);
118
+ else runs.push([i]);
119
+ gap = 0;
120
+ });
121
+ return runs.flatMap((run) => {
122
+ /** @type {number[][]} */
123
+ const pieces = [];
124
+ let rest = run;
125
+ while (rest.length > MAX_CHUNK) {
126
+ const found = rest.findIndex(
127
+ (i, k) => k >= SPLIT_AFTER && k < MAX_CHUNK && /^\s*[}\])]*[;,]?\s*$/.test(textOf(body[i])),
128
+ );
129
+ const cut = found === -1 ? MAX_CHUNK : found + 1;
130
+ pieces.push(rest.slice(0, cut));
131
+ rest = rest.slice(cut);
132
+ }
133
+ if (rest.length > 0) pieces.push(rest);
134
+ return pieces;
135
+ });
136
+ }
137
+
138
+ const DECLARATION =
139
+ /^\s*(?:export\s+)?(?:default\s+)?(?:async\s+)?(?:function\*?|class|def|fn|func|interface|struct|enum|impl|trait|module)\s+([A-Za-z_$][\w$]*)/;
140
+
141
+ /**
142
+ * The function or class a hunk line sits in: the nearest declaration at or above it. Lines removed in the hunk are skipped; they no longer exist at the tip.
143
+ * @param {readonly HunkLine[]} body
144
+ * @param {number} last
145
+ */
146
+ export function scopeOf(body, last) {
147
+ for (let i = last; i >= 0; i -= 1) {
148
+ const line = body[i];
149
+ if (line === undefined || line.op === "-") continue;
150
+ const name = DECLARATION.exec(textOf(line))?.[1];
151
+ if (name !== undefined) return name;
152
+ }
153
+ return null;
154
+ }
155
+
156
+ /** @param {number} n @param {string} word */
157
+ function count(n, word) {
158
+ return `${n} ${word}${n === 1 ? "" : "s"}`;
159
+ }
160
+
161
+ /**
162
+ * @param {string} path
163
+ * @param {number} added
164
+ * @param {number} removed
165
+ * @param {string | null} name
166
+ */
167
+ function hunkLabel(path, added, removed, name) {
168
+ const what =
169
+ removed === 0
170
+ ? `adds ${count(added, "line")}`
171
+ : added === 0
172
+ ? `removes ${count(removed, "line")}`
173
+ : `adds ${count(added, "line")}, removes ${removed}`;
174
+ return `${path} · ${what}${name === null ? "" : ` in ${name}`}`;
175
+ }
176
+
177
+ /** @param {Exclude<Unit, { kind: "hunk" }>} unit */
178
+ function wholeLabel(unit) {
179
+ if (unit.kind === "mode") return `${unit.path} · mode ${unit.from} → ${unit.to}`;
180
+ const change = unit.before === null ? "added" : unit.after === null ? "deleted" : "changed";
181
+ return `${unit.path} · ${unit.reason} file ${change}`;
182
+ }
183
+
184
+ /**
185
+ * The beats of the whole plan: per commit an intro, one chunk per run of its own change lines
186
+ * (one per whole-file or mode unit), and an outro. A unit's narration goes on its first chunk.
48
187
  * @param {readonly Commit[]} commits
49
188
  * @param {readonly Unit[]} units
50
- * @returns {Step[]}
189
+ * @returns {Beat[]}
51
190
  */
52
191
  export function buildScript(commits, units) {
53
192
  const byId = new Map(units.map((u) => [u.id, u]));
54
- /** @type {Step[]} */
55
- const steps = [];
56
- for (const commit of commits) {
57
- steps.push({ kind: "commit", commit: commit.index, say: commitNarration(commit) });
193
+ /** @type {Beat[]} */
194
+ const beats = [];
195
+ commits.forEach((commit, k) => {
196
+ beats.push({
197
+ kind: "intro",
198
+ commit: commit.index,
199
+ message: commit.message,
200
+ narration: present(commit.narration),
201
+ });
58
202
  for (const ref of commit.refs) {
59
203
  const unit = byId.get(ref.unit);
60
204
  if (unit === undefined) continue;
61
- const say = ref.narration?.trim() || null;
62
- steps.push({ kind: "unit", commit: commit.index, unit: unit.id, say });
63
- if (unit.kind !== "hunk" || ref.select.kind !== "lines") continue;
64
- const mine = new Set(ref.select.changes);
65
- unit.body.forEach((line, i) => {
66
- if (line.op === " " || !mine.has(line.change)) return;
67
- steps.push({
68
- kind: "line",
205
+ const narration = present(ref.narration);
206
+ if (unit.kind !== "hunk") {
207
+ beats.push({
208
+ kind: "chunk",
209
+ commit: commit.index,
210
+ unit: unit.id,
211
+ lines: [],
212
+ removals: [],
213
+ narration,
214
+ label: wholeLabel(unit),
215
+ added: 0,
216
+ });
217
+ continue;
218
+ }
219
+ const mine = new Set(
220
+ ref.select.kind === "lines"
221
+ ? ref.select.changes
222
+ : unit.body.flatMap((l) => (l.op === " " ? [] : [l.change])),
223
+ );
224
+ runsOf(unit.body, mine).forEach((lines, n) => {
225
+ const additions = lines.filter((i) => unit.body[i]?.op === "+");
226
+ const removals = lines.filter((i) => unit.body[i]?.op === "-");
227
+ const name = scopeOf(unit.body, lines.at(-1) ?? 0);
228
+ beats.push({
229
+ kind: "chunk",
69
230
  commit: commit.index,
70
231
  unit: unit.id,
71
- line: i,
72
- change: line.change,
73
- op: line.op,
232
+ lines,
233
+ removals,
234
+ narration: n === 0 ? narration : null,
235
+ label: hunkLabel(unit.path, additions.length, removals.length, name),
236
+ added: additions.filter((i) => textOf(unit.body[i]).trim() !== "").length,
74
237
  });
75
238
  });
76
239
  }
77
- }
78
- return steps;
240
+ const next = commits[k + 1];
241
+ beats.push({
242
+ kind: "outro",
243
+ commit: commit.index,
244
+ next: next === undefined ? null : titleOf(next.message),
245
+ });
246
+ });
247
+ return beats;
79
248
  }
80
249
 
81
250
  /**
82
- * How long to rest after a step at the given speed. Lines arrive a few per second; removals
83
- * take half as long because nothing new needs reading.
84
- * @param {Step} step
251
+ * The timed steps of the beats at a speed, which divides every dwell, beat and hold. A commit
252
+ * without narration speaks its title and stays up long enough to read its whole message.
253
+ * @param {readonly Beat[]} beats
85
254
  * @param {number} speed
255
+ * @returns {Step[]}
256
+ */
257
+ export function schedule(beats, speed) {
258
+ /** @type {(ms: number) => number} */
259
+ const at = (ms) => Math.round(ms / speed);
260
+ return beats.flatMap((beat, i) => {
261
+ if (beat.kind === "intro") {
262
+ const text = beat.narration ?? titleOf(beat.message);
263
+ const ms = at(readMs(beat.narration ?? beat.message));
264
+ return [{ kind: "say", beat: i, text, ms, tail: at(INTRO_TAIL_MS) }];
265
+ }
266
+ if (beat.kind === "outro") return [{ kind: "hold", beat: i, ms: at(HOLD_MS) }];
267
+ /** @type {Step[]} */
268
+ const steps = [];
269
+ if (beat.narration !== null) {
270
+ steps.push({
271
+ kind: "say",
272
+ beat: i,
273
+ text: beat.narration,
274
+ ms: at(readMs(beat.narration)),
275
+ tail: 0,
276
+ });
277
+ }
278
+ steps.push({ kind: "show", beat: i, ms: at(codeMs(beat.added, beat.removals.length)) });
279
+ steps.push({ kind: "rest", beat: i, ms: at(BEAT_MS) });
280
+ return steps;
281
+ });
282
+ }
283
+
284
+ /**
285
+ * Silent playing time of the steps, the floor of what playback takes with speech on.
286
+ * @param {readonly Step[]} steps
86
287
  */
87
- export function stepDelay(step, speed) {
88
- if (step.kind === "commit") return COMMIT_BEAT_MS / speed;
89
- if (step.kind === "unit") return UNIT_BEAT_MS / speed;
90
- const line = 1000 / (LINES_PER_SECOND * speed);
91
- return step.op === "+" ? line : line / 2;
288
+ export function duration(steps) {
289
+ return steps.reduce((sum, s) => sum + s.ms, 0);
290
+ }
291
+
292
+ /**
293
+ * Synthetic voices are hard to follow above 1.5x, so speech caps there while dwells keep scaling.
294
+ * @param {number} speed
295
+ */
296
+ export function speechRate(speed) {
297
+ return Math.min(speed, MAX_SPEECH_RATE);
92
298
  }
93
299
 
94
300
  /**
@@ -98,53 +304,251 @@ export function stepDelay(step, speed) {
98
304
  * @param {number} speed
99
305
  */
100
306
  export function speechTimeout(text, speed) {
101
- const words = text.split(/\s+/).filter(Boolean).length;
102
- return (2 * 1000 * words) / (WORDS_PER_SECOND * speed) + 2000;
307
+ return (2 * 1000 * wordCount(text)) / (WORDS_PER_SECOND * speechRate(speed)) + 2000;
103
308
  }
104
309
 
105
310
  /**
106
- * The first step of a commit, clamped to the script. Seeking past the end lands on the end.
107
- * @param {readonly Step[]} script
108
- * @param {number} commit
311
+ * The beat on screen at a step position; past the last step that is beats.length, the end.
312
+ * @param {readonly Step[]} steps
313
+ * @param {number} position
109
314
  */
110
- export function commitStart(script, commit) {
111
- const at = script.findIndex((s) => s.kind === "commit" && s.commit === commit);
112
- if (at !== -1) return at;
113
- return commit < 0 ? 0 : script.length;
315
+ export function beatAt(steps, position) {
316
+ const step = steps[position];
317
+ if (step !== undefined) return step.beat;
318
+ return (steps.at(-1)?.beat ?? -1) + 1;
114
319
  }
115
320
 
116
321
  /**
117
- * The commit on screen at a position: the one whose step was performed last.
118
- * @param {readonly Step[]} script
119
- * @param {number} position
322
+ * The first step of a beat, clamped to the steps. A beat past the end lands on the end.
323
+ * @param {readonly Step[]} steps
324
+ * @param {number} beat
325
+ */
326
+ export function beatStart(steps, beat) {
327
+ if (beat < 0) return 0;
328
+ const at = steps.findIndex((s) => s.beat === beat);
329
+ return at === -1 ? steps.length : at;
330
+ }
331
+
332
+ /**
333
+ * The commit a beat belongs to; the end belongs to the last commit.
334
+ * @param {readonly Beat[]} beats
335
+ * @param {number} beat
336
+ */
337
+ export function commitOf(beats, beat) {
338
+ return (beats[Math.min(beat, beats.length - 1)] ?? beats.at(-1))?.commit ?? 0;
339
+ }
340
+
341
+ /**
342
+ * The intro beat of a commit, clamped: before the first is 0, after the last is the end.
343
+ * @param {readonly Beat[]} beats
344
+ * @param {number} commit
120
345
  */
121
- export function commitAt(script, position) {
122
- return script[Math.max(0, Math.min(position, script.length) - 1)]?.commit ?? 0;
346
+ export function introOf(beats, commit) {
347
+ if (commit < 0) return 0;
348
+ const at = beats.findIndex((b) => b.kind === "intro" && b.commit === commit);
349
+ return at === -1 ? beats.length : at;
123
350
  }
124
351
 
125
352
  /**
126
- * How each change line of a commit looks after `position` steps, and which line was written
127
- * last. Added lines are hidden until typed; removed lines stay shown until their step.
128
- * @param {readonly Step[]} script
353
+ * How each change line of a commit looks at a step position. Lines are hidden until their chunk
354
+ * shows them; removals then stay as struck lines. Chunks not yet shown, the current one during
355
+ * its narration included, report a placeholder at their first line with their line count.
356
+ * current is the chunk beat on screen, or null during the intro and outro.
357
+ * @param {readonly Beat[]} beats
358
+ * @param {readonly Step[]} steps
129
359
  * @param {number} position
130
360
  * @param {number} commit
131
- * @returns {{ states: Map<string, LineState>, current: string | null }}
361
+ * @returns {{ states: Map<string, LineState>, upcoming: Map<string, number>, current: number | null }}
132
362
  */
133
- export function lineStates(script, position, commit) {
363
+ export function lineStates(beats, steps, position, commit) {
134
364
  /** @type {Map<string, LineState>} */
135
365
  const states = new Map();
136
- /** @type {string | null} */
137
- let current = null;
138
- script.forEach((step, i) => {
139
- if (step.kind !== "line" || step.commit !== commit) return;
140
- const key = lineKey(step.unit, step.line);
141
- const done = i < position;
142
- if (step.op === "+") states.set(key, done ? "shown" : "hidden");
143
- else states.set(key, done ? "gone" : "shown");
144
- if (done) current = key;
366
+ /** @type {Map<string, number>} */
367
+ const upcoming = new Map();
368
+ const now = beatAt(steps, position);
369
+ beats.forEach((beat, i) => {
370
+ if (beat.kind !== "chunk" || beat.commit !== commit || beat.lines.length === 0) return;
371
+ const show = steps.findIndex((s) => s.kind === "show" && s.beat === i);
372
+ const shown = position >= steps.length || (show !== -1 && show <= position);
373
+ const first = beat.lines[0];
374
+ if (!shown && first !== undefined) upcoming.set(lineKey(beat.unit, first), beat.lines.length);
375
+ const removals = new Set(beat.removals);
376
+ for (const line of beat.lines) {
377
+ const look = !shown ? "hidden" : removals.has(line) ? "struck" : "shown";
378
+ states.set(lineKey(beat.unit, line), look);
379
+ }
380
+ });
381
+ const beat = beats[now];
382
+ const current = beat?.kind === "chunk" && beat.commit === commit ? now : null;
383
+ return { states, upcoming, current };
384
+ }
385
+
386
+ /**
387
+ * How a hunk reads in one commit. The commit's own lines are the change. Lines earlier commits
388
+ * added are now part of the file and read as context; lines they removed are gone. Lines later
389
+ * commits remove are still there as context, and lines later commits add fold into one row per
390
+ * run and owner.
391
+ * @param {readonly HunkLine[]} body
392
+ * @param {readonly (number | undefined)[]} owners owning commit per change index
393
+ * @param {number} commit
394
+ * @returns {({ kind: "line", line: number, mine: boolean } | { kind: "fold", lines: number[], commit: number })[]}
395
+ */
396
+ export function rowsOf(body, owners, commit) {
397
+ /** @type {ReturnType<typeof rowsOf>} */
398
+ const rows = [];
399
+ body.forEach((line, i) => {
400
+ const owner = line.op === " " ? undefined : owners[line.change];
401
+ if (line.op === " " || owner === undefined || owner === commit) {
402
+ rows.push({ kind: "line", line: i, mine: owner === commit });
403
+ return;
404
+ }
405
+ const later = owner > commit;
406
+ if (line.op === "-" ? later : !later) {
407
+ rows.push({ kind: "line", line: i, mine: false });
408
+ return;
409
+ }
410
+ if (line.op === "-") return;
411
+ const last = rows.at(-1);
412
+ if (last?.kind === "fold" && last.commit === owner) last.lines.push(i);
413
+ else rows.push({ kind: "fold", lines: [i], commit: owner });
145
414
  });
146
- if (commitAt(script, position) !== commit) current = null;
147
- return { states, current };
415
+ return rows;
416
+ }
417
+
418
+ /**
419
+ * The diagram beside the code: while a chunk of a unit with a diagram plays, that unit's; else
420
+ * the commit's; while browsing a commit without one, its first unit's. null when none applies.
421
+ * @param {Commit | undefined} commit
422
+ * @param {Beat | undefined} beat the beat on screen, undefined while browsing
423
+ * @returns {{ source: string, unit: string | null } | null}
424
+ */
425
+ export function diagramAt(commit, beat) {
426
+ if (commit === undefined) return null;
427
+ const refs = commit.refs.filter((r) => present(r.diagram) !== null);
428
+ const playing = beat?.kind === "chunk" ? refs.find((r) => r.unit === beat.unit) : undefined;
429
+ const own = present(commit.diagram);
430
+ const ref = playing ?? (own === null && beat === undefined ? refs[0] : undefined);
431
+ if (ref?.diagram !== undefined) return { source: ref.diagram, unit: ref.unit };
432
+ return own === null ? null : { source: own, unit: null };
433
+ }
434
+
435
+ /** @param {readonly Commit[]} commits */
436
+ export function hasDiagrams(commits) {
437
+ return commits.some(
438
+ (c) => present(c.diagram) !== null || c.refs.some((r) => present(r.diagram) !== null),
439
+ );
440
+ }
441
+
442
+ /**
443
+ * File line numbers of every hunk line, 1-based: old in the base file, new in the branch tip. A
444
+ * removed line has no new number and an added line no old one. A hunk's tip start is its base
445
+ * start shifted by the lines earlier hunks of the same file add or remove.
446
+ * @param {readonly Unit[]} units
447
+ * @returns {Map<string, { old: number | null, new: number | null }[]>}
448
+ */
449
+ export function lineNumbers(units) {
450
+ /** @type {Map<string, { old: number | null, new: number | null }[]>} */
451
+ const numbers = new Map();
452
+ const hunks = units.flatMap((u) => (u.kind === "hunk" ? [u] : []));
453
+ const sorted = [...hunks].sort((a, b) => (a.oldStart ?? 0) - (b.oldStart ?? 0));
454
+ /** @type {Map<string, number>} */
455
+ const shift = new Map();
456
+ for (const hunk of sorted) {
457
+ const start = hunk.oldStart ?? 0;
458
+ let oldLine = start + 1;
459
+ let newLine = start + 1 + (shift.get(hunk.path) ?? 0);
460
+ numbers.set(
461
+ hunk.id,
462
+ hunk.body.map((line) => {
463
+ if (line.op === "-") return { old: oldLine++, new: null };
464
+ if (line.op === "+") return { old: null, new: newLine++ };
465
+ return { old: oldLine++, new: newLine++ };
466
+ }),
467
+ );
468
+ const delta = hunk.body.reduce((d, l) => d + (l.op === "+" ? 1 : l.op === "-" ? -1 : 0), 0);
469
+ shift.set(hunk.path, (shift.get(hunk.path) ?? 0) + delta);
470
+ }
471
+ return numbers;
472
+ }
473
+
474
+ /**
475
+ * How a comment names the lines it is on: tip line numbers, with removed lines by their old
476
+ * number, runs collapsed. "line 4", "lines 4-5", "lines 4-5, old 3".
477
+ * @param {readonly HunkLine[]} body
478
+ * @param {readonly { old: number | null, new: number | null }[]} numbers
479
+ * @param {readonly number[]} changes
480
+ */
481
+ export function describeLines(body, numbers, changes) {
482
+ const picked = new Set(changes);
483
+ /** @type {{ side: "new" | "old", n: number }[]} */
484
+ const marks = [];
485
+ body.forEach((line, i) => {
486
+ if (line.op === " " || !picked.has(line.change)) return;
487
+ const side = line.op === "+" ? "new" : "old";
488
+ const n = numbers[i]?.[side];
489
+ if (n !== null && n !== undefined) marks.push({ side, n });
490
+ });
491
+ /** @param {"new" | "old"} side */
492
+ const ranges = (side) => {
493
+ const ns = marks.filter((m) => m.side === side).map((m) => m.n);
494
+ /** @type {string[]} */
495
+ const parts = [];
496
+ ns.forEach((n, i) => {
497
+ if (i > 0 && n === (ns[i - 1] ?? 0) + 1) {
498
+ const last = parts.pop() ?? "";
499
+ parts.push(`${last.split("-")[0]}-${n}`);
500
+ } else parts.push(String(n));
501
+ });
502
+ return parts;
503
+ };
504
+ const added = ranges("new");
505
+ const removed = ranges("old");
506
+ const many = marks.length > 1;
507
+ const text = [
508
+ ...(added.length > 0 ? [`${many ? "lines" : "line"} ${added.join(", ")}`] : []),
509
+ ...(removed.length > 0
510
+ ? [`${added.length > 0 ? "old" : many ? "old lines" : "old line"} ${removed.join(", ")}`]
511
+ : []),
512
+ ];
513
+ return text.join(", ");
514
+ }
515
+
516
+ /**
517
+ * One progress segment per commit, weighted by its chunk count, filled by the share of its
518
+ * beats already behind the current one.
519
+ * @param {readonly Beat[]} beats
520
+ * @param {number} beat
521
+ * @returns {{ commit: number, weight: number, filled: number }[]}
522
+ */
523
+ export function progress(beats, beat) {
524
+ /** @type {Map<number, { chunks: number, total: number, behind: number }>} */
525
+ const tally = new Map();
526
+ beats.forEach((b, i) => {
527
+ const t = tally.get(b.commit) ?? { chunks: 0, total: 0, behind: 0 };
528
+ tally.set(b.commit, {
529
+ chunks: t.chunks + (b.kind === "chunk" ? 1 : 0),
530
+ total: t.total + 1,
531
+ behind: t.behind + (i < beat ? 1 : 0),
532
+ });
533
+ });
534
+ return [...tally].map(([commit, t]) => ({
535
+ commit,
536
+ weight: Math.max(1, t.chunks),
537
+ filled: t.behind / t.total,
538
+ }));
539
+ }
540
+
541
+ /**
542
+ * Which chunk of its commit a beat is, 1-based, or null for an intro, outro or the end.
543
+ * @param {readonly Beat[]} beats
544
+ * @param {number} beat
545
+ * @returns {{ index: number, count: number } | null}
546
+ */
547
+ export function chunkOf(beats, beat) {
548
+ const here = beats[beat];
549
+ if (here?.kind !== "chunk") return null;
550
+ const mine = beats.flatMap((b, i) => (b.kind === "chunk" && b.commit === here.commit ? [i] : []));
551
+ return { index: mine.indexOf(beat) + 1, count: mine.length };
148
552
  }
149
553
 
150
554
  /**