@shiyuphan/follow-me 0.1.1 → 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.
- package/README.md +127 -66
- package/dist/src/anthropic.js +41 -9
- package/dist/src/cli.js +632 -81
- package/dist/src/comments.js +300 -0
- package/dist/src/git.js +20 -3
- package/dist/src/host.js +92 -6
- package/dist/src/install.js +1 -1
- package/dist/src/naive.js +26 -0
- package/dist/src/plan.js +281 -25
- package/dist/src/prompt.js +391 -82
- package/dist/src/server.js +461 -137
- package/dist/src/session.js +104 -104
- package/dist/src/syntax.js +199 -0
- package/dist/src/types.js +3 -1
- package/dist/ui/app.js +1676 -272
- package/dist/ui/concerns.js +165 -0
- package/dist/ui/highlight.js +221 -0
- package/dist/ui/index.html +61 -7
- package/dist/ui/live.js +184 -0
- package/dist/ui/playback.js +480 -76
- package/dist/ui/questions.js +189 -0
- package/dist/ui/style.css +955 -90
- package/dist/ui/summary.js +45 -0
- package/package.json +4 -2
- package/skills/follow-me/SKILL.md +34 -6
package/dist/ui/playback.js
CHANGED
|
@@ -4,23 +4,44 @@
|
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* @typedef {{ op: " ", text: string } | { op: "+" | "-", text: string, change: number }} HunkLine
|
|
7
|
-
* @typedef {{
|
|
8
|
-
*
|
|
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
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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.
|
|
33
|
+
export const SPEEDS = [0.75, 1, 1.5, 2];
|
|
20
34
|
|
|
21
|
-
|
|
22
|
-
const
|
|
23
|
-
|
|
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
|
-
*
|
|
37
|
-
* @param {
|
|
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
|
|
40
|
-
|
|
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
|
-
*
|
|
47
|
-
*
|
|
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 {
|
|
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 {
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
|
|
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
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
83
|
-
*
|
|
84
|
-
* @param {
|
|
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
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
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
|
|
107
|
-
* @param {readonly Step[]}
|
|
108
|
-
* @param {number}
|
|
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
|
|
111
|
-
const
|
|
112
|
-
if (
|
|
113
|
-
return
|
|
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
|
|
118
|
-
* @param {readonly Step[]}
|
|
119
|
-
* @param {number}
|
|
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
|
|
122
|
-
|
|
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
|
|
127
|
-
*
|
|
128
|
-
*
|
|
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>,
|
|
361
|
+
* @returns {{ states: Map<string, LineState>, upcoming: Map<string, number>, current: number | null }}
|
|
132
362
|
*/
|
|
133
|
-
export function lineStates(
|
|
363
|
+
export function lineStates(beats, steps, position, commit) {
|
|
134
364
|
/** @type {Map<string, LineState>} */
|
|
135
365
|
const states = new Map();
|
|
136
|
-
/** @type {string
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
const
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
if (
|
|
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
|
-
|
|
147
|
-
|
|
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
|
/**
|