@profullstack/g1tz 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/dist/pulse.js ADDED
@@ -0,0 +1,348 @@
1
+ /**
2
+ * Pulse: what moved in this repository over a period, read from git.
3
+ *
4
+ * The same idea as GitHub's Insights > Pulse tab, worked out locally so it is
5
+ * there offline and for repositories that are not on GitHub at all: commits
6
+ * and authors on the current branch and across every branch, the net change
7
+ * to the tree since the period began, the branches that saw commits and the
8
+ * tags that were created. GitHub's own side (pull requests, issues, stars and
9
+ * releases) is an optional, separate read in github.ts; views and clones come
10
+ * from a gh-pulse report when one exists (traffic.ts).
11
+ *
12
+ * Everything here is read-only, which is why the file list can afford
13
+ * display-grade path handling: a munged path on this screen cannot touch the
14
+ * working tree, unlike one in the Files pane.
15
+ */
16
+ import { git } from "./git.js";
17
+ export const RANGE_KEYS = ["day", "week", "month", "quarter", "year", "all"];
18
+ const DAY_MS = 86_400_000;
19
+ export const RANGE_MS = {
20
+ day: DAY_MS,
21
+ week: 7 * DAY_MS,
22
+ month: 30 * DAY_MS,
23
+ quarter: 91 * DAY_MS,
24
+ year: 365 * DAY_MS,
25
+ all: Number.POSITIVE_INFINITY,
26
+ };
27
+ export const RANGE_LABEL = {
28
+ day: "last 24 hours",
29
+ week: "last week",
30
+ month: "last month",
31
+ quarter: "last quarter",
32
+ year: "last year",
33
+ all: "all time",
34
+ };
35
+ export const RANGE_HOTKEY = { day: "d", week: "w", month: "m", quarter: "q", year: "y", all: "a" };
36
+ const RANGE_ALIASES = {
37
+ "24h": "day", "1d": "day", today: "day",
38
+ "7d": "week", "1w": "week",
39
+ "30d": "month", "1m": "month",
40
+ "90d": "quarter", "3m": "quarter",
41
+ "365d": "year", "1y": "year", "12m": "year",
42
+ ever: "all", forever: "all",
43
+ };
44
+ export function parseRangeKey(text) {
45
+ const key = text.trim().toLowerCase();
46
+ if (RANGE_KEYS.includes(key))
47
+ return key;
48
+ return RANGE_ALIASES[key] ?? null;
49
+ }
50
+ export function rangeForHotkey(key) {
51
+ return RANGE_KEYS.find((k) => RANGE_HOTKEY[k] === key) ?? null;
52
+ }
53
+ /** The start of a range, or null for all time. */
54
+ export function sinceFor(range, now) {
55
+ return range === "all" ? null : new Date(now.getTime() - RANGE_MS[range]);
56
+ }
57
+ // ---------------------------------------------------------------- parsers
58
+ const LOG_FORMAT = "%H%x1f%h%x1f%an%x1f%ae%x1f%cI%x1f%P%x1f%s";
59
+ /** `git log --format=<fields>%x00`: one NUL-terminated record per commit, fields separated by 0x1f. */
60
+ export function parsePulseLog(out) {
61
+ const commits = [];
62
+ for (const raw of out.split("\0")) {
63
+ // A newline between records is separator noise, not part of the hash.
64
+ const record = raw.replace(/^\n/, "");
65
+ if (record === "")
66
+ continue;
67
+ const [hash, short, author, email, at, parents, subject] = record.split("\x1f");
68
+ if (!hash)
69
+ continue;
70
+ commits.push({
71
+ hash,
72
+ short: short ?? "",
73
+ author: author ?? "",
74
+ email: email ?? "",
75
+ at: at ?? "",
76
+ subject: subject ?? "",
77
+ merge: (parents ?? "").split(" ").filter(Boolean).length > 1,
78
+ });
79
+ }
80
+ return commits;
81
+ }
82
+ /**
83
+ * `git diff --numstat -z`: `added TAB deleted TAB path NUL`. A rename is
84
+ * `added TAB deleted TAB NUL old NUL new NUL`, and a binary file has `-` for
85
+ * both counts. Paths are raw, so one with a newline in it still parses.
86
+ */
87
+ export function parseNumstat(out) {
88
+ const files = [];
89
+ const tokens = out.split("\0");
90
+ for (let i = 0; i < tokens.length; i++) {
91
+ const token = tokens[i];
92
+ if (token === "")
93
+ continue;
94
+ const m = /^(\d+|-)\t(\d+|-)\t([^]*)$/.exec(token);
95
+ if (!m)
96
+ continue;
97
+ const binary = m[1] === "-";
98
+ const added = binary ? 0 : Number(m[1]);
99
+ const deleted = binary ? 0 : Number(m[2]);
100
+ if (m[3] === "") {
101
+ // A rename: the two paths follow as their own records.
102
+ const from = tokens[++i] ?? "";
103
+ const path = tokens[++i] ?? "";
104
+ files.push({ path, from, added, deleted, binary });
105
+ }
106
+ else {
107
+ files.push({ path: m[3], added, deleted, binary });
108
+ }
109
+ }
110
+ return files;
111
+ }
112
+ /** `git diff --shortstat`: " 3 files changed, 12 insertions(+), 4 deletions(-)", any part of which may be missing. */
113
+ export function parseShortstat(out) {
114
+ const files = /(\d+) files? changed/.exec(out);
115
+ const ins = /(\d+) insertions?\(\+\)/.exec(out);
116
+ const del = /(\d+) deletions?\(-\)/.exec(out);
117
+ return {
118
+ filesChanged: files ? Number(files[1]) : 0,
119
+ additions: ins ? Number(ins[1]) : 0,
120
+ deletions: del ? Number(del[1]) : 0,
121
+ };
122
+ }
123
+ /** `git for-each-ref --format=%(refname:short)%1f%(<date>:iso-strict)`, one ref per line. */
124
+ export function parseRefDates(out) {
125
+ const refs = [];
126
+ for (const line of out.split("\n")) {
127
+ if (line.trim() === "")
128
+ continue;
129
+ const [name, at] = line.split("\x1f");
130
+ if (name)
131
+ refs.push({ name, at: at ?? "" });
132
+ }
133
+ return refs;
134
+ }
135
+ export function refsSince(refs, since) {
136
+ if (!since)
137
+ return refs;
138
+ return refs.filter((r) => Date.parse(r.at) >= since.getTime());
139
+ }
140
+ /** Merges excluded, as GitHub counts them; busiest first, then by name. */
141
+ export function countAuthors(commits) {
142
+ const counts = new Map();
143
+ for (const c of commits) {
144
+ if (c.merge)
145
+ continue;
146
+ counts.set(c.author, (counts.get(c.author) ?? 0) + 1);
147
+ }
148
+ return [...counts]
149
+ .map(([name, n]) => ({ name, commits: n }))
150
+ .sort((a, b) => b.commits - a.commits || a.name.localeCompare(b.name));
151
+ }
152
+ // ---------------------------------------------------------------- buckets
153
+ export function unitFor(range) {
154
+ switch (range) {
155
+ case "day": return "hour";
156
+ case "week":
157
+ case "month": return "day";
158
+ case "quarter":
159
+ case "year": return "week";
160
+ default: return "month";
161
+ }
162
+ }
163
+ /** The start of the bucket holding `time`, in UTC. Weeks start on Monday. */
164
+ function floorTo(time, unit) {
165
+ const t = new Date(time);
166
+ t.setUTCMinutes(0, 0, 0);
167
+ if (unit === "hour")
168
+ return t;
169
+ t.setUTCHours(0);
170
+ if (unit === "day")
171
+ return t;
172
+ if (unit === "week") {
173
+ t.setUTCDate(t.getUTCDate() - ((t.getUTCDay() + 6) % 7));
174
+ return t;
175
+ }
176
+ t.setUTCDate(1);
177
+ return t;
178
+ }
179
+ function next(t, unit) {
180
+ const n = new Date(t.getTime());
181
+ if (unit === "hour")
182
+ n.setUTCHours(n.getUTCHours() + 1);
183
+ else if (unit === "day")
184
+ n.setUTCDate(n.getUTCDate() + 1);
185
+ else if (unit === "week")
186
+ n.setUTCDate(n.getUTCDate() + 7);
187
+ else
188
+ n.setUTCMonth(n.getUTCMonth() + 1);
189
+ return n;
190
+ }
191
+ function labelFor(t, unit) {
192
+ const iso = t.toISOString();
193
+ if (unit === "hour")
194
+ return `${iso.slice(11, 13)}:00`;
195
+ if (unit === "month")
196
+ return iso.slice(0, 7);
197
+ return iso.slice(5, 10);
198
+ }
199
+ /** More buckets than this and the range is not one anybody can read; the newest ones win. */
200
+ export const BUCKET_CAP = 2000;
201
+ /**
202
+ * Contiguous buckets from the start of the period (or the first commit, for
203
+ * all time) to now, with the commits counted into them. Every bucket is
204
+ * present even when empty, so a quiet week shows as a gap rather than
205
+ * vanishing.
206
+ */
207
+ export function bucketCommits(commits, since, until, unit) {
208
+ const times = [];
209
+ let earliest = Number.POSITIVE_INFINITY;
210
+ for (const c of commits) {
211
+ const t = Date.parse(c.at);
212
+ if (!Number.isFinite(t))
213
+ continue;
214
+ times.push(t);
215
+ if (t < earliest)
216
+ earliest = t;
217
+ }
218
+ const from = since ? since.getTime() : times.length ? earliest : until.getTime();
219
+ const buckets = [];
220
+ const starts = [];
221
+ for (let t = floorTo(from, unit); t.getTime() <= until.getTime() && buckets.length < BUCKET_CAP; t = next(t, unit)) {
222
+ buckets.push({ label: labelFor(t, unit), start: t.toISOString(), count: 0 });
223
+ starts.push(t.getTime());
224
+ }
225
+ for (const time of times) {
226
+ // The last bucket starting at or before this commit; a commit from the
227
+ // future (a wrong clock) lands in the newest one.
228
+ let lo = 0;
229
+ let hi = starts.length - 1;
230
+ let idx = -1;
231
+ while (lo <= hi) {
232
+ const mid = (lo + hi) >> 1;
233
+ if (starts[mid] <= time) {
234
+ idx = mid;
235
+ lo = mid + 1;
236
+ }
237
+ else
238
+ hi = mid - 1;
239
+ }
240
+ if (idx >= 0)
241
+ buckets[idx].count += 1;
242
+ }
243
+ return buckets;
244
+ }
245
+ /** At most `max` buckets, merging neighbours; a group takes the label of its first. */
246
+ export function foldBuckets(buckets, max) {
247
+ if (max < 1 || buckets.length <= max)
248
+ return [...buckets];
249
+ const per = Math.ceil(buckets.length / max);
250
+ const out = [];
251
+ for (let i = 0; i < buckets.length; i += per) {
252
+ const group = buckets.slice(i, i + per);
253
+ const first = group[0];
254
+ out.push({ label: first.label, start: first.start, count: group.reduce((t, b) => t + b.count, 0) });
255
+ }
256
+ return out;
257
+ }
258
+ // ---------------------------------------------------------------- reading
259
+ /** The hash of the empty tree, so the diff for all time (or from a root commit) has something to diff against. */
260
+ export const EMPTY_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
261
+ export const COMMIT_CAP = 50_000;
262
+ export const FILE_CAP = 500;
263
+ const churn = (f) => f.added + f.deleted;
264
+ export function readPulse(root, range, now = new Date()) {
265
+ const since = sinceFor(range, now);
266
+ const sinceArgs = since ? [`--since=${since.toISOString()}`] : [];
267
+ const errors = [];
268
+ const run = (args) => {
269
+ const out = git(root, args);
270
+ if (out === null) {
271
+ errors.push({ command: `git ${args[0]}`, message: "failed" });
272
+ return "";
273
+ }
274
+ return out;
275
+ };
276
+ const branch = git(root, ["symbolic-ref", "--quiet", "--short", "HEAD"])?.trim() ?? "";
277
+ // An unborn HEAD (a repository with no commits yet) fails every read below;
278
+ // the pulse is then simply empty rather than a list of errors.
279
+ const hasHead = git(root, ["rev-parse", "--verify", "--quiet", "HEAD"]) !== null;
280
+ // "--" after HEAD everywhere: a file named HEAD in the root would otherwise
281
+ // make the revision ambiguous. --no-show-signature: with log.showSignature
282
+ // set, git writes the verification text on stdout ahead of every record.
283
+ const commits = hasHead
284
+ ? parsePulseLog(run(["log", "--no-show-signature", `--format=${LOG_FORMAT}%x00`, `--max-count=${COMMIT_CAP}`, ...sinceArgs, "HEAD", "--"]))
285
+ : [];
286
+ // Not gated on HEAD: an orphan branch being born has no HEAD commit while the other branches still moved.
287
+ const allBranchCommits = Number(run(["rev-list", "--branches", "--no-merges", "--count", ...sinceArgs]).trim()) || 0;
288
+ // The tree at the start of the period is the first parent of the oldest
289
+ // first-parent commit in range: what the branch pointed at before any of
290
+ // this landed, whether it arrived by merge, rebase or fast-forward. All
291
+ // time, and a root commit inside the period, diff from the empty tree. A
292
+ // shallow clone's grafted commit has no parent either, but that is not a
293
+ // root: the tree before the period is simply not in this clone.
294
+ let boundary = null;
295
+ if (hasHead) {
296
+ if (!since)
297
+ boundary = EMPTY_TREE;
298
+ else {
299
+ const oldest = run(["rev-list", "--first-parent", ...sinceArgs, "HEAD", "--"]).trim().split("\n").filter(Boolean).at(-1);
300
+ if (oldest) {
301
+ const parent = git(root, ["rev-parse", "--verify", "--quiet", `${oldest}^`])?.trim();
302
+ if (parent)
303
+ boundary = parent;
304
+ else if (git(root, ["rev-parse", "--is-shallow-repository"])?.trim() === "true") {
305
+ errors.push({ command: "git diff", message: "shallow clone: the tree before the period is not available" });
306
+ }
307
+ else
308
+ boundary = EMPTY_TREE;
309
+ }
310
+ }
311
+ }
312
+ let filesChanged = 0;
313
+ let additions = 0;
314
+ let deletions = 0;
315
+ let files = [];
316
+ if (boundary) {
317
+ ({ filesChanged, additions, deletions } = parseShortstat(run(["diff", "--shortstat", "-M", boundary, "HEAD", "--"])));
318
+ files = parseNumstat(run(["diff", "--numstat", "-z", "-M", boundary, "HEAD", "--"]))
319
+ .sort((a, b) => churn(b) - churn(a) || a.path.localeCompare(b.path))
320
+ .slice(0, FILE_CAP);
321
+ }
322
+ const branches = refsSince(parseRefDates(run([
323
+ "for-each-ref", "--sort=-committerdate", "--format=%(refname:short)%1f%(committerdate:iso-strict)", "refs/heads",
324
+ ])), since);
325
+ const tags = refsSince(parseRefDates(run([
326
+ "for-each-ref", "--sort=-creatordate", "--format=%(refname:short)%1f%(creatordate:iso-strict)", "refs/tags",
327
+ ])), since);
328
+ const unit = unitFor(range);
329
+ return {
330
+ range,
331
+ since: since ? since.toISOString() : null,
332
+ until: now.toISOString(),
333
+ branch,
334
+ commits,
335
+ commitsTruncated: commits.length >= COMMIT_CAP,
336
+ allBranchCommits,
337
+ authors: countAuthors(commits),
338
+ filesChanged,
339
+ additions,
340
+ deletions,
341
+ files,
342
+ unit,
343
+ buckets: bucketCommits(commits, since, now, unit),
344
+ branches,
345
+ tags,
346
+ errors,
347
+ };
348
+ }
@@ -0,0 +1,37 @@
1
+ export interface TrafficDay {
2
+ day: string;
3
+ count: number;
4
+ uniques: number;
5
+ }
6
+ export interface TrafficReferrer {
7
+ referrer: string;
8
+ count: number;
9
+ uniques: number;
10
+ }
11
+ export interface TrafficPath {
12
+ path: string;
13
+ count: number;
14
+ uniques: number;
15
+ }
16
+ export interface Traffic {
17
+ repo: string;
18
+ url: string;
19
+ /** When the report was written. */
20
+ reportAt: string;
21
+ stars: number;
22
+ forks: number;
23
+ views14d: TrafficDay[];
24
+ clones14d: TrafficDay[];
25
+ referrers: TrafficReferrer[];
26
+ paths: TrafficPath[];
27
+ }
28
+ export declare function ghPulseDataDir(env?: NodeJS.ProcessEnv): string;
29
+ export declare function ghPulseReportPath(dir?: string): string;
30
+ /**
31
+ * The repository's row out of a gh-pulse report. The report only carries the
32
+ * repositories that moved, so a quiet one is null even when the report is
33
+ * fresh, and every field is read defensively: the report is another tool's
34
+ * output, not this one's.
35
+ */
36
+ export declare function trafficFromReport(report: unknown, repo: string): Traffic | null;
37
+ export declare function readTraffic(repo: string, dir?: string): Traffic | null;
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Traffic for this repository, from a gh-pulse report.
3
+ *
4
+ * GitHub only serves a repository's views and clones through its traffic API,
5
+ * and only the last fourteen days of them. gh-pulse (profullstack/cli-tools)
6
+ * reads that every day for every repository the login can see and writes a
7
+ * report under ~/.local/share/gh-pulse; when one is there, the Pulse screen
8
+ * draws this repository's series from it. Nothing is fetched here: it is a
9
+ * file read, and a missing file is not an error.
10
+ */
11
+ import { existsSync, readFileSync } from "node:fs";
12
+ import { homedir } from "node:os";
13
+ import { join } from "node:path";
14
+ export function ghPulseDataDir(env = process.env) {
15
+ return env["GH_PULSE_DATA"] ?? join(homedir(), ".local", "share", "gh-pulse");
16
+ }
17
+ export function ghPulseReportPath(dir = ghPulseDataDir()) {
18
+ return join(dir, "out", "latest.json");
19
+ }
20
+ const num = (v) => (typeof v === "number" && Number.isFinite(v) ? v : 0);
21
+ const str = (v) => (typeof v === "string" ? v : "");
22
+ const isRecord = (v) => !!v && typeof v === "object";
23
+ function days(v) {
24
+ if (!Array.isArray(v))
25
+ return [];
26
+ return v.filter(isRecord).map((d) => ({ day: str(d["day"]), count: num(d["count"]), uniques: num(d["uniques"]) }));
27
+ }
28
+ /**
29
+ * The repository's row out of a gh-pulse report. The report only carries the
30
+ * repositories that moved, so a quiet one is null even when the report is
31
+ * fresh, and every field is read defensively: the report is another tool's
32
+ * output, not this one's.
33
+ */
34
+ export function trafficFromReport(report, repo) {
35
+ if (!isRecord(report) || !Array.isArray(report["movers"]))
36
+ return null;
37
+ const want = repo.toLowerCase();
38
+ const mover = report["movers"].find((m) => isRecord(m) && str(m["repo"]).toLowerCase() === want);
39
+ if (!isRecord(mover))
40
+ return null;
41
+ const name = str(mover["repo"]);
42
+ return {
43
+ repo: name,
44
+ url: str(mover["url"]) || `https://github.com/${name}`,
45
+ reportAt: str(report["at"]),
46
+ stars: num(mover["stars"]),
47
+ forks: num(mover["forks"]),
48
+ views14d: days(mover["views14d"]),
49
+ clones14d: days(mover["clones14d"]),
50
+ referrers: Array.isArray(mover["referrers"])
51
+ ? mover["referrers"].filter(isRecord).map((r) => ({ referrer: str(r["referrer"]), count: num(r["count"]), uniques: num(r["uniques"]) }))
52
+ : [],
53
+ paths: Array.isArray(mover["paths"])
54
+ ? mover["paths"].filter(isRecord).map((p) => ({ path: str(p["path"]), count: num(p["count"]), uniques: num(p["uniques"]) }))
55
+ : [],
56
+ };
57
+ }
58
+ export function readTraffic(repo, dir = ghPulseDataDir()) {
59
+ const file = ghPulseReportPath(dir);
60
+ if (!existsSync(file))
61
+ return null;
62
+ try {
63
+ return trafficFromReport(JSON.parse(readFileSync(file, "utf8")), repo);
64
+ }
65
+ catch {
66
+ return null;
67
+ }
68
+ }
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "@profullstack/g1tz",
3
+ "version": "0.2.0",
4
+ "description": "A git TUI that shows you the repository, not a menu of git commands.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "homepage": "https://github.com/profullstack/g1tz",
8
+ "bin": {
9
+ "g1tz": "./bin/g1tz.mjs"
10
+ },
11
+ "files": [
12
+ "dist",
13
+ "bin",
14
+ "src",
15
+ "README.md",
16
+ "LICENSE"
17
+ ],
18
+ "engines": {
19
+ "bun": ">=1.1",
20
+ "node": ">=22.6"
21
+ },
22
+ "scripts": {
23
+ "start": "bun src/main.ts",
24
+ "build": "bun x tsc -p tsconfig.json",
25
+ "typecheck": "bun x tsc -p tsconfig.json --noEmit",
26
+ "test": "bun test test",
27
+ "prepublishOnly": "bun run build"
28
+ },
29
+ "dependencies": {
30
+ "@profullstack/hqtui": "^0.6.0"
31
+ },
32
+ "devDependencies": {
33
+ "@types/node": "^26",
34
+ "typescript": "^7.0.2"
35
+ },
36
+ "keywords": [
37
+ "git",
38
+ "tui",
39
+ "terminal",
40
+ "lazygit",
41
+ "hqtui",
42
+ "pulse",
43
+ "github",
44
+ "activity"
45
+ ],
46
+ "repository": {
47
+ "type": "git",
48
+ "url": "git+https://github.com/profullstack/g1tz.git"
49
+ },
50
+ "publishConfig": {
51
+ "access": "public"
52
+ }
53
+ }
package/src/diff.ts ADDED
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Diff rendering, as spans.
3
+ *
4
+ * A whole line coloured green tells you it changed. Colouring only the words
5
+ * that actually differ tells you *what* changed, which is the thing you were
6
+ * squinting at the line to work out.
7
+ */
8
+ import type { SpanLine } from "@profullstack/hqtui";
9
+ import { diffLineKind } from "./git.ts";
10
+
11
+ export interface DiffPalette {
12
+ add: number;
13
+ remove: number;
14
+ hunk: number;
15
+ meta: number;
16
+ context: number;
17
+ /** Background emphasis for the words that actually differ. */
18
+ addEmphasis: number;
19
+ removeEmphasis: number;
20
+ }
21
+
22
+ /** Split into words and the separators between them, both kept. */
23
+ export function words(line: string): string[] {
24
+ return line.split(/(\W)/).filter((part) => part !== "");
25
+ }
26
+
27
+ export interface Segment {
28
+ text: string;
29
+ changed: boolean;
30
+ }
31
+
32
+ /**
33
+ * The differing middle of two lines, found by trimming the common prefix and
34
+ * suffix. Not a full Myers diff: for the single-line case a prefix/suffix trim
35
+ * is what people actually read, and it cannot produce the confetti that a
36
+ * token-level LCS gives you on a reformatted line.
37
+ */
38
+ export function intraLine(before: string, after: string): { before: Segment[]; after: Segment[] } {
39
+ const a = words(before);
40
+ const b = words(after);
41
+
42
+ let head = 0;
43
+ while (head < a.length && head < b.length && a[head] === b[head]) head++;
44
+
45
+ let tail = 0;
46
+ while (
47
+ tail < a.length - head &&
48
+ tail < b.length - head &&
49
+ a[a.length - 1 - tail] === b[b.length - 1 - tail]
50
+ ) tail++;
51
+
52
+ const build = (parts: string[]): Segment[] => {
53
+ const middle = parts.slice(head, parts.length - tail).join("");
54
+ const out: Segment[] = [];
55
+ const prefix = parts.slice(0, head).join("");
56
+ const suffix = parts.slice(parts.length - tail).join("");
57
+ if (prefix) out.push({ text: prefix, changed: false });
58
+ if (middle) out.push({ text: middle, changed: true });
59
+ if (suffix) out.push({ text: suffix, changed: false });
60
+ return out;
61
+ };
62
+
63
+ return { before: build(a), after: build(b) };
64
+ }
65
+
66
+ /**
67
+ * Pair each removed line with the added line that replaced it.
68
+ *
69
+ * Only runs of equal length are paired. An unequal run is a genuine insertion
70
+ * or deletion rather than an edit, and pretending otherwise produces word
71
+ * highlighting that points at the wrong thing.
72
+ */
73
+ export function pairRuns(lines: string[]): Map<number, number> {
74
+ const pairs = new Map<number, number>();
75
+ let i = 0;
76
+ while (i < lines.length) {
77
+ if (!isRemoval(lines[i] as string)) { i++; continue; }
78
+ let removals = 0;
79
+ while (i + removals < lines.length && isRemoval(lines[i + removals] as string)) removals++;
80
+ let additions = 0;
81
+ while (
82
+ i + removals + additions < lines.length &&
83
+ isAddition(lines[i + removals + additions] as string)
84
+ ) additions++;
85
+ if (removals === additions && removals > 0) {
86
+ for (let k = 0; k < removals; k++) pairs.set(i + k, i + removals + k);
87
+ }
88
+ i += removals + additions;
89
+ }
90
+ return pairs;
91
+ }
92
+
93
+ function isRemoval(line: string): boolean {
94
+ return line.startsWith("-") && !line.startsWith("---");
95
+ }
96
+
97
+ function isAddition(line: string): boolean {
98
+ return line.startsWith("+") && !line.startsWith("+++");
99
+ }
100
+
101
+ /** A whole diff as span lines, with the changed words emphasised. */
102
+ export function highlightDiff(lines: string[], palette: DiffPalette): SpanLine[] {
103
+ const pairs = pairRuns(lines);
104
+ const partnerOf = new Map<number, number>();
105
+ for (const [from, to] of pairs) partnerOf.set(to, from);
106
+
107
+ const colorFor = (line: string): number => {
108
+ switch (diffLineKind(line)) {
109
+ case "success": return palette.add;
110
+ case "danger": return palette.remove;
111
+ case "accent": return palette.hunk;
112
+ case "muted": return palette.meta;
113
+ default: return palette.context;
114
+ }
115
+ };
116
+
117
+ return lines.map((line, index) => {
118
+ const base = colorFor(line);
119
+
120
+ const partner = pairs.has(index)
121
+ ? (lines[pairs.get(index) as number] as string)
122
+ : partnerOf.has(index)
123
+ ? (lines[partnerOf.get(index) as number] as string)
124
+ : undefined;
125
+
126
+ if (partner === undefined) {
127
+ return line === "" ? [] : [{ text: line, fg: base }];
128
+ }
129
+
130
+ const removed = isRemoval(line);
131
+ const { before, after } = intraLine(
132
+ (removed ? line : partner).slice(1),
133
+ (removed ? partner : line).slice(1),
134
+ );
135
+ const segments = removed ? before : after;
136
+ const emphasis = removed ? palette.removeEmphasis : palette.addEmphasis;
137
+
138
+ return [
139
+ { text: line.slice(0, 1), fg: base },
140
+ ...segments.map((segment) => ({
141
+ text: segment.text,
142
+ fg: base,
143
+ ...(segment.changed ? { bg: emphasis, bold: true } : {}),
144
+ })),
145
+ ];
146
+ });
147
+ }