@tanishqkancharla/diffmap 0.1.5 → 0.3.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.
Files changed (42) hide show
  1. package/README.md +64 -57
  2. package/index.html +18 -0
  3. package/package.json +7 -5
  4. package/src/annotations.ts +36 -29
  5. package/src/changes.ts +235 -0
  6. package/src/cli.ts +1 -0
  7. package/src/errors.ts +5 -0
  8. package/src/gist/GistApp.tsx +38 -17
  9. package/src/gist/fetchGist.ts +47 -0
  10. package/src/git.test.ts +402 -0
  11. package/src/git.ts +498 -0
  12. package/src/github/GitHubApp.tsx +39 -22
  13. package/src/github/fetchRepo.test.ts +59 -1
  14. package/src/github/fetchRepo.ts +99 -43
  15. package/src/github/patch.test.ts +18 -9
  16. package/src/github/patch.ts +30 -19
  17. package/src/host/HostHome.tsx +20 -7
  18. package/src/host/files.ts +5 -1
  19. package/src/host/home.md +17 -55
  20. package/src/host/home.test.ts +4 -3
  21. package/src/index.ts +1 -0
  22. package/src/parseFence.ts +6 -6
  23. package/src/parseViewer.test.ts +140 -0
  24. package/src/parseViewer.ts +104 -78
  25. package/src/serve.ts +34 -1
  26. package/src/share.ts +62 -1
  27. package/src/viewer/CallStackDiff.tsx +15 -3
  28. package/src/viewer/ChangesCard.tsx +267 -0
  29. package/src/viewer/ComarkView.tsx +83 -15
  30. package/src/viewer/Fence.tsx +9 -1
  31. package/src/viewer/MermaidBlock.tsx +99 -11
  32. package/src/viewer/RangePicker.tsx +526 -0
  33. package/src/viewer/SourceDiffPanel.tsx +254 -116
  34. package/src/viewer/TableOfContents.tsx +177 -93
  35. package/src/viewer/ViewerApp.tsx +34 -114
  36. package/src/viewer/changesSource.ts +91 -0
  37. package/src/viewer/sourceHeader.test.ts +2 -87
  38. package/src/viewer/sourceHeader.ts +4 -63
  39. package/src/viewer/sourceNavigation.ts +0 -43
  40. package/vite.config.ts +1 -0
  41. package/vite.gist.config.ts +57 -0
  42. package/src/viewer/TocButton.tsx +0 -27
package/src/git.ts ADDED
@@ -0,0 +1,498 @@
1
+ import { parseDiffFromFile } from "@pierre/diffs";
2
+ import { execFile } from "node:child_process";
3
+ import fs from "node:fs/promises";
4
+ import path from "node:path";
5
+ import { promisify } from "node:util";
6
+ import {
7
+ ALL,
8
+ STAGED,
9
+ UNSTAGED,
10
+ WORKING,
11
+ isCommitRev,
12
+ isRev,
13
+ type Delta,
14
+ type ChangedFile,
15
+ type Changes,
16
+ type Rev,
17
+ } from "./changes.js";
18
+ import { DiffmapGitError } from "./errors.js";
19
+ import type { ViewerDocument } from "./parseViewer.js";
20
+
21
+ const execFileAsync = promisify(execFile);
22
+
23
+ /** `git hash-object -t tree /dev/null`: the parent of a root commit. */
24
+ const EMPTY_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
25
+
26
+ /** One end of a diff: a commit (or the empty tree), the index, or the worktree. */
27
+ type Side = string | typeof INDEX | typeof WORKTREE;
28
+ const INDEX = Symbol("index");
29
+ const WORKTREE = Symbol("worktree");
30
+
31
+ /** Reads the files changed by a rev, with full old and new contents. */
32
+ export async function readChanges(
33
+ root: string,
34
+ rev: Rev,
35
+ ): Promise<Changes | DiffmapGitError> {
36
+ if (!isRev(rev))
37
+ return new DiffmapGitError({
38
+ reason: `"${rev}" is not a commit SHA, a SHA range, or one of working, staged, unstaged, all.`,
39
+ });
40
+ const window = await resolveRev(root, rev);
41
+ if (window instanceof Error) return window;
42
+ const files = await diffSides(root, window.base, window.head);
43
+ if (files instanceof Error) return files;
44
+ return { rev, title: window.title, files };
45
+ }
46
+
47
+ async function resolveRev(
48
+ root: string,
49
+ rev: Rev,
50
+ ): Promise<{ base: Side; head: Side; title: string } | DiffmapGitError> {
51
+ if (rev === WORKING)
52
+ return { base: "HEAD", head: WORKTREE, title: "Uncommitted changes" };
53
+ if (rev === STAGED)
54
+ return { base: "HEAD", head: INDEX, title: "Staged changes" };
55
+ if (rev === UNSTAGED)
56
+ return { base: INDEX, head: WORKTREE, title: "Unstaged changes" };
57
+ if (rev === ALL) {
58
+ const deltas = await listDeltas(root);
59
+ if (deltas instanceof Error) return deltas;
60
+ const oldest = deltas.at(-1);
61
+ return {
62
+ base: oldest?.kind === "commit" ? oldest.parent : "HEAD",
63
+ head: WORKTREE,
64
+ title: "All commits",
65
+ };
66
+ }
67
+ const [first, second] = rev.split("..");
68
+ if (second === undefined) {
69
+ const head = await resolveCommit(root, first!);
70
+ if (head instanceof Error) return head;
71
+ const title = await git(root, ["log", "-1", "--format=%s", head]);
72
+ if (title instanceof Error) return title;
73
+ return { base: await parentOf(root, head), head, title: title.trim() };
74
+ }
75
+ const base = await resolveCommit(root, first!);
76
+ if (base instanceof Error) return base;
77
+ const head =
78
+ second === STAGED
79
+ ? INDEX
80
+ : second === WORKING
81
+ ? WORKTREE
82
+ : await resolveCommit(root, second);
83
+ if (head instanceof Error) return head;
84
+ const count = await git(root, [
85
+ "rev-list",
86
+ "--count",
87
+ `${base}..${typeof head === "string" ? head : "HEAD"}`,
88
+ ]);
89
+ if (count instanceof Error) return count;
90
+ const commits = `${count.trim()} ${count.trim() === "1" ? "commit" : "commits"}`;
91
+ const tail =
92
+ head === INDEX ? " + staged" : head === WORKTREE ? " + uncommitted" : "";
93
+ return {
94
+ base,
95
+ head,
96
+ title: `${shortSha(base)}..${typeof head === "string" ? shortSha(head) : second} · ${commits}${tail}`,
97
+ };
98
+ }
99
+
100
+ async function diffSides(root: string, base: Side, head: Side) {
101
+ // `git diff A B`, `git diff --cached A`, `git diff A`, and `git diff` cover commit→commit, →index, →worktree, and index→worktree.
102
+ const range =
103
+ typeof head === "string"
104
+ ? [base as string, head]
105
+ : head === INDEX
106
+ ? ["--cached", base as string]
107
+ : base === INDEX
108
+ ? []
109
+ : [base as string];
110
+ const numstat = await git(root, [
111
+ "diff",
112
+ "-M",
113
+ "--numstat",
114
+ "-z",
115
+ // Paths relative to root, which may sit below the repository's top level.
116
+ "--relative",
117
+ ...range,
118
+ ]);
119
+ if (numstat instanceof Error) return numstat;
120
+ const read = (side: Side, file: string) =>
121
+ side === WORKTREE
122
+ ? readWorktree(root, file)
123
+ : showBlob(root, side === INDEX ? "" : side, file);
124
+ const tracked = parseNumstat(numstat).map((entry) =>
125
+ withContents(
126
+ entry,
127
+ read(base, entry.prevPath ?? entry.path),
128
+ read(head, entry.path),
129
+ ),
130
+ );
131
+ if (head !== WORKTREE) return Promise.all(tracked);
132
+ const untracked = await git(root, [
133
+ "ls-files",
134
+ "--others",
135
+ "--exclude-standard",
136
+ "-z",
137
+ ]);
138
+ if (untracked instanceof Error) return untracked;
139
+ const files = await Promise.all(tracked);
140
+ for (const file of untracked.split("\0").filter((name) => name.length > 0)) {
141
+ const contents = await read(WORKTREE, file);
142
+ const binary = contents === undefined || contents.includes("\0");
143
+ // A path removed from the index but kept on disk is both a tracked deletion and untracked.
144
+ const index = files.findIndex((entry) => entry.path === file);
145
+ const old = index === -1 ? undefined : files[index]!.contents?.old;
146
+ if (index !== -1) files.splice(index, 1);
147
+ if (old === contents) continue;
148
+ files.push({
149
+ path: file,
150
+ status: old === undefined ? "added" : "modified",
151
+ ...(binary
152
+ ? { additions: 0, deletions: 0, binary }
153
+ : { ...lineCounts(old, contents), binary }),
154
+ ...(binary ? {} : { contents: { old, new: contents } }),
155
+ });
156
+ }
157
+ return files;
158
+ }
159
+
160
+ /** Lines added and removed between two versions, for files git does not diff itself. */
161
+ function lineCounts(old: string | undefined, next: string) {
162
+ if (old === undefined) return { additions: countLines(next), deletions: 0 };
163
+ return parseDiffFromFile(
164
+ { name: "old", contents: old },
165
+ { name: "new", contents: next },
166
+ ).hunks.reduce(
167
+ (sum, hunk) => ({
168
+ additions: sum.additions + hunk.additionLines,
169
+ deletions: sum.deletions + hunk.deletionLines,
170
+ }),
171
+ { additions: 0, deletions: 0 },
172
+ );
173
+ }
174
+
175
+ /** A symlink reads as its target path, as git stores it, never the file it points to. */
176
+ async function readWorktree(root: string, file: string) {
177
+ const full = path.join(root, file);
178
+ const stat = await fs.lstat(full).catch(() => undefined);
179
+ if (stat === undefined) return undefined;
180
+ if (stat.isSymbolicLink()) return fs.readlink(full);
181
+ return fs.readFile(full, "utf8").catch(() => undefined);
182
+ }
183
+
184
+ type NumstatEntry = {
185
+ path: string;
186
+ prevPath?: string;
187
+ additions: number;
188
+ deletions: number;
189
+ binary: boolean;
190
+ };
191
+
192
+ /** Parses `git diff --numstat -z`, where renames put both paths after the counts. */
193
+ export function parseNumstat(output: string): NumstatEntry[] {
194
+ const tokens = output.split("\0");
195
+ const entries: NumstatEntry[] = [];
196
+ for (let index = 0; index < tokens.length; index++) {
197
+ const match = /^(-|\d+)\t(-|\d+)\t(.*)$/.exec(tokens[index]!);
198
+ if (match === null) continue;
199
+ const binary = match[1] === "-";
200
+ const counts = {
201
+ additions: binary ? 0 : Number(match[1]),
202
+ deletions: binary ? 0 : Number(match[2]),
203
+ binary,
204
+ };
205
+ if (match[3] !== "") {
206
+ entries.push({ path: match[3]!, ...counts });
207
+ continue;
208
+ }
209
+ entries.push({
210
+ prevPath: tokens[index + 1]!,
211
+ path: tokens[index + 2]!,
212
+ ...counts,
213
+ });
214
+ index += 2;
215
+ }
216
+ return entries;
217
+ }
218
+
219
+ async function withContents(
220
+ entry: NumstatEntry,
221
+ readOld: Promise<string | undefined>,
222
+ readNew: Promise<string | undefined>,
223
+ ): Promise<ChangedFile> {
224
+ const [oldContents, newContents] = await Promise.all([readOld, readNew]);
225
+ const status =
226
+ oldContents === undefined
227
+ ? "added"
228
+ : newContents === undefined
229
+ ? "deleted"
230
+ : entry.prevPath === undefined
231
+ ? "modified"
232
+ : "renamed";
233
+ return {
234
+ ...entry,
235
+ status,
236
+ ...(entry.binary
237
+ ? {}
238
+ : { contents: { old: oldContents, new: newContents } }),
239
+ };
240
+ }
241
+
242
+ /** Returns undefined when the path does not exist there. An empty commit reads the index. */
243
+ async function showBlob(root: string, commit: string, file: string) {
244
+ if (commit === EMPTY_TREE) return undefined;
245
+ const contents = await git(root, ["show", `${commit}:./${file}`]);
246
+ return contents instanceof Error ? undefined : contents;
247
+ }
248
+
249
+ async function resolveCommit(root: string, sha: string) {
250
+ if (sha === EMPTY_TREE) return sha;
251
+ const resolved = await git(root, [
252
+ "rev-parse",
253
+ "--verify",
254
+ "--quiet",
255
+ `${sha}^{commit}`,
256
+ ]);
257
+ if (resolved instanceof Error)
258
+ return new DiffmapGitError({
259
+ reason: `Commit ${sha} is not in this repository.`,
260
+ });
261
+ return resolved.trim();
262
+ }
263
+
264
+ async function parentOf(root: string, commit: string) {
265
+ const parent = await git(root, [
266
+ "rev-parse",
267
+ "--verify",
268
+ "--quiet",
269
+ `${commit}^`,
270
+ ]);
271
+ return parent instanceof Error ? EMPTY_TREE : parent.trim();
272
+ }
273
+
274
+ async function git(root: string, args: string[]) {
275
+ try {
276
+ const result = await execFileAsync("git", args, {
277
+ cwd: root,
278
+ encoding: "utf8",
279
+ maxBuffer: 64_000_000,
280
+ });
281
+ return result.stdout;
282
+ } catch (cause) {
283
+ return new DiffmapGitError({
284
+ reason: `git ${args[0]} failed in ${root}`,
285
+ cause,
286
+ });
287
+ }
288
+ }
289
+
290
+ function shortSha(sha: string) {
291
+ return sha.slice(0, 7);
292
+ }
293
+
294
+ function countLines(contents: string) {
295
+ if (contents.length === 0) return 0;
296
+ return contents.split("\n").length - (contents.endsWith("\n") ? 1 : 0);
297
+ }
298
+
299
+ /**
300
+ * The range picker's rows, newest first: unstaged and staged changes, then
301
+ * this branch's commits since it left the default branch. On the default
302
+ * branch itself, the last 20 commits.
303
+ */
304
+ export async function listDeltas(
305
+ root: string,
306
+ /** The open rev; the list reaches back far enough to show it. */
307
+ include?: Rev,
308
+ ): Promise<Delta[] | DiffmapGitError> {
309
+ const [unstaged, staged, untracked] = await Promise.all([
310
+ git(root, ["diff", "--numstat", "-z", "--relative"]),
311
+ git(root, ["diff", "--cached", "--numstat", "-z", "--relative"]),
312
+ git(root, ["ls-files", "--others", "--exclude-standard", "-z"]),
313
+ ]);
314
+ if (unstaged instanceof Error) return unstaged;
315
+ if (staged instanceof Error) return staged;
316
+ if (untracked instanceof Error) return untracked;
317
+ const untrackedLines = await Promise.all(
318
+ untracked
319
+ .split("\0")
320
+ .filter((file) => file.length > 0)
321
+ .map(async (file) => {
322
+ const contents = await readWorktree(root, file);
323
+ return contents === undefined || contents.includes("\0")
324
+ ? 0
325
+ : countLines(contents);
326
+ }),
327
+ );
328
+ const unstagedTotals = totals(parseNumstat(unstaged));
329
+ const deltas: Delta[] = [
330
+ {
331
+ kind: "unstaged",
332
+ additions:
333
+ unstagedTotals.additions +
334
+ untrackedLines.reduce((sum, n) => sum + n, 0),
335
+ deletions: unstagedTotals.deletions,
336
+ },
337
+ { kind: "staged", ...totals(parseNumstat(staged)) },
338
+ ];
339
+ const branch = await defaultBranch(root);
340
+ const base =
341
+ branch === undefined
342
+ ? undefined
343
+ : await git(root, ["merge-base", "HEAD", branch]);
344
+ const branchCommits =
345
+ base === undefined || base instanceof Error
346
+ ? []
347
+ : await logCommits(root, ["-n", "100", `${base.trim()}..HEAD`]);
348
+ if (branchCommits instanceof Error) return branchCommits;
349
+ const commits =
350
+ branchCommits.length > 0
351
+ ? branchCommits
352
+ : await logCommits(root, ["-n", "20", "HEAD"]);
353
+ // A repository without commits has nothing to log.
354
+ if (commits instanceof Error) return deltas;
355
+ const reached = await reachRev(root, commits, include);
356
+ return [...deltas, ...reached];
357
+ }
358
+
359
+ /**
360
+ * Extends the commit rows down to an older rev, so the picker can always show
361
+ * what is open. A commit off HEAD's first-parent line gets its own last row.
362
+ */
363
+ async function reachRev(root: string, commits: Delta[], rev: Rev | undefined) {
364
+ if (rev === undefined || !/^[0-9a-f]{7,40}/.test(rev)) return commits;
365
+ const [first = "", second] = rev.split("..");
366
+ // A range starts after its base; a single commit is its own oldest row.
367
+ const target =
368
+ second === undefined
369
+ ? commits.find(
370
+ (delta) => delta.kind === "commit" && delta.sha.startsWith(first),
371
+ )
372
+ : commits.find(
373
+ (delta) => delta.kind === "commit" && delta.parent.startsWith(first),
374
+ );
375
+ if (target !== undefined) return commits;
376
+ const ancestor = await git(root, [
377
+ "merge-base",
378
+ "--is-ancestor",
379
+ first,
380
+ "HEAD",
381
+ ]);
382
+ const count = await git(root, [
383
+ "rev-list",
384
+ "--first-parent",
385
+ "--count",
386
+ `${first}..HEAD`,
387
+ ]);
388
+ if (!(ancestor instanceof Error) && !(count instanceof Error)) {
389
+ const needed = Number(count.trim()) + (second === undefined ? 1 : 0);
390
+ if (needed > commits.length && needed <= 1000) {
391
+ const longer = await logCommits(root, ["-n", String(needed), "HEAD"]);
392
+ if (longer instanceof Error) return commits;
393
+ const found = longer.some(
394
+ (delta) =>
395
+ delta.kind === "commit" &&
396
+ (second === undefined
397
+ ? delta.sha.startsWith(first)
398
+ : delta.parent.startsWith(first)),
399
+ );
400
+ if (found) return longer;
401
+ }
402
+ }
403
+ if (second !== undefined) return commits;
404
+ const single = await logCommits(root, ["-1", first]);
405
+ return single instanceof Error ? commits : [...commits, ...single];
406
+ }
407
+
408
+ async function defaultBranch(root: string) {
409
+ const remote = await git(root, ["rev-parse", "--abbrev-ref", "origin/HEAD"]);
410
+ if (!(remote instanceof Error) && remote.trim() !== "origin/HEAD")
411
+ return remote.trim();
412
+ for (const name of ["main", "master"]) {
413
+ const local = await git(root, ["rev-parse", "--verify", "--quiet", name]);
414
+ if (!(local instanceof Error)) return name;
415
+ }
416
+ return undefined;
417
+ }
418
+
419
+ async function logCommits(root: string, args: string[]) {
420
+ const log = await git(root, [
421
+ "log",
422
+ // The rail is one line of history, so windows are contiguous.
423
+ "--first-parent",
424
+ "--numstat",
425
+ "--relative",
426
+ "--format=%x1e%H%x1f%P%x1f%s",
427
+ ...args,
428
+ ]);
429
+ if (log instanceof Error) return log;
430
+ return log
431
+ .split("\x1e")
432
+ .filter((record) => record.trim().length > 0)
433
+ .map((record): Delta => {
434
+ const [header = "", ...stats] = record.split("\n");
435
+ const [sha = "", parents = "", subject = ""] = header.split("\x1f");
436
+ const counts = totals(
437
+ parseNumstat(stats.filter((line) => line.length > 0).join("\0")),
438
+ );
439
+ return {
440
+ kind: "commit",
441
+ sha,
442
+ parent: parents.split(" ")[0] || EMPTY_TREE,
443
+ subject,
444
+ ...counts,
445
+ };
446
+ });
447
+ }
448
+
449
+ function totals(entries: NumstatEntry[]) {
450
+ return entries.reduce(
451
+ (sum, entry) => ({
452
+ additions: sum.additions + entry.additions,
453
+ deletions: sum.deletions + entry.deletions,
454
+ }),
455
+ { additions: 0, deletions: 0 },
456
+ );
457
+ }
458
+
459
+ /**
460
+ * Loads every commit rev a document uses and checks that each commit
461
+ * reference names a changed file and a range inside its new version.
462
+ */
463
+ export async function readDocumentChanges(
464
+ root: string,
465
+ document: Pick<ViewerDocument, "references" | "changeRevs">,
466
+ ): Promise<Map<Rev, Changes> | DiffmapGitError> {
467
+ // Uncommitted revs are read live; only commits are checked and snapshotted.
468
+ const revs = new Set<Rev>(document.changeRevs.filter(isCommitRev));
469
+ for (const reference of document.references) {
470
+ if (reference.kind === "changes" || reference.rev !== undefined)
471
+ revs.add(reference.rev!);
472
+ }
473
+ const loaded = new Map<Rev, Changes>();
474
+ for (const rev of revs) {
475
+ const changes = await readChanges(root, rev);
476
+ if (changes instanceof Error) return changes;
477
+ loaded.set(rev, changes);
478
+ }
479
+ for (const reference of document.references) {
480
+ if (reference.kind !== "file" || reference.rev === undefined) continue;
481
+ const file = loaded
482
+ .get(reference.rev)!
483
+ .files.find((item) => item.path === reference.path);
484
+ const label = `[[${reference.rev}:${reference.path}]]`;
485
+ if (file === undefined)
486
+ return new DiffmapGitError({
487
+ reason: `${label}: ${reference.path} is not changed in ${reference.rev}.`,
488
+ });
489
+ if (reference.end === undefined) continue;
490
+ const lines =
491
+ file.contents?.new === undefined ? 0 : countLines(file.contents.new);
492
+ if (reference.end > lines)
493
+ return new DiffmapGitError({
494
+ reason: `${label}: line ${String(reference.end)} is past the end of ${reference.path} (${String(lines)} lines) after ${reference.rev}.`,
495
+ });
496
+ }
497
+ return loaded;
498
+ }
@@ -5,7 +5,15 @@ import { parseViewerDocument } from "../parseViewer.ts";
5
5
  import type { ViewerDocument } from "../parseViewer.ts";
6
6
  import { ViewerApp } from "../viewer/ViewerApp.tsx";
7
7
  import { GistLoading, GistStatus } from "../gist/GistStatus.tsx";
8
- import { loadPinnedSpec, type PinnedSpec } from "./fetchRepo.ts";
8
+ import {
9
+ ChangesLoaderContext,
10
+ type ChangesLoader,
11
+ } from "../viewer/changesSource.ts";
12
+ import {
13
+ fetchGitHubChanges,
14
+ loadPinnedSpec,
15
+ type PinnedSpec,
16
+ } from "./fetchRepo.ts";
9
17
  import { GitHubPinContext } from "./pin.ts";
10
18
  import {
11
19
  githubBlobUrl,
@@ -27,7 +35,12 @@ type GitHubView =
27
35
  | { kind: "forbidden" }
28
36
  | { kind: "error"; message: string }
29
37
  | { kind: "parse-error"; htmlUrl: string; message: string }
30
- | { kind: "ready"; spec: PinnedSpec; document: ViewerDocument };
38
+ | {
39
+ kind: "ready";
40
+ spec: PinnedSpec;
41
+ document: ViewerDocument;
42
+ loadChanges: ChangesLoader;
43
+ };
31
44
 
32
45
  export function GitHubApp() {
33
46
  const [route, setRoute] = useState(readGitHubRoute);
@@ -131,21 +144,24 @@ export function GitHubApp() {
131
144
  const spec = view.spec;
132
145
  return (
133
146
  <GitHubPinContext.Provider value={spec}>
134
- <ViewerApp
135
- key={`${spec.owner}/${spec.repo}@${spec.sha}:${spec.path}`}
136
- document={view.document}
137
- mode="github"
138
- headerActions={
139
- <GitHubHeader
140
- owner={spec.owner}
141
- repo={spec.repo}
142
- sha={spec.sha}
143
- path={spec.path}
144
- pullNumber={spec.pullNumber}
145
- pullUrl={spec.pullUrl}
146
- />
147
- }
148
- />
147
+ <ChangesLoaderContext.Provider value={view.loadChanges}>
148
+ <ViewerApp
149
+ defaultRev={spec.pullRev}
150
+ key={`${spec.owner}/${spec.repo}@${spec.sha}:${spec.path}`}
151
+ document={view.document}
152
+ mode="github"
153
+ headerActions={
154
+ <GitHubHeader
155
+ owner={spec.owner}
156
+ repo={spec.repo}
157
+ sha={spec.sha}
158
+ path={spec.path}
159
+ pullNumber={spec.pullNumber}
160
+ pullUrl={spec.pullUrl}
161
+ />
162
+ }
163
+ />
164
+ </ChangesLoaderContext.Provider>
149
165
  </GitHubPinContext.Provider>
150
166
  );
151
167
  }
@@ -185,11 +201,12 @@ async function loadView(
185
201
  message: parsed.message,
186
202
  };
187
203
  }
188
- const document =
189
- parsed.sourceDiffs.length > 0
190
- ? parsed
191
- : { ...parsed, sourceDiffs: spec.pullDiffs };
192
- return { kind: "ready", spec, document };
204
+ return {
205
+ kind: "ready",
206
+ spec,
207
+ document: parsed,
208
+ loadChanges: (rev) => fetchGitHubChanges(spec, rev),
209
+ };
193
210
  }
194
211
 
195
212
  function readGitHubRoute(): GitHubRoute {
@@ -1,6 +1,6 @@
1
1
  import assert from "node:assert/strict";
2
2
  import test from "node:test";
3
- import { findSymbolLineRange } from "./fetchRepo.ts";
3
+ import { fetchGitHubChanges, findSymbolLineRange } from "./fetchRepo.ts";
4
4
 
5
5
  test("findSymbolLineRange highlights a function declaration", () => {
6
6
  const source = ["const x = 1;", "export function startServer() {", "}"].join(
@@ -15,3 +15,61 @@ test("findSymbolLineRange highlights a function declaration", () => {
15
15
  end: 2,
16
16
  });
17
17
  });
18
+
19
+ /** `count` modified files, as the GitHub API lists them. */
20
+ function files(prefix: string, count: number) {
21
+ return Array.from({ length: count }, (_, index) => ({
22
+ filename: `${prefix}${String(index)}.ts`,
23
+ status: "modified",
24
+ additions: 1,
25
+ }));
26
+ }
27
+
28
+ test("fetchGitHubChanges follows a commit's file pages and marks a capped compare", async (t) => {
29
+ const requested: string[] = [];
30
+ t.mock.method(globalThis, "fetch", (url: string) => {
31
+ const { pathname, search } = new URL(url);
32
+ requested.push(`${pathname}${search}`);
33
+ if (pathname.endsWith("/commits/aaaaaaa")) {
34
+ const next = search === "";
35
+ return Promise.resolve(
36
+ Response.json(
37
+ {
38
+ commit: { message: "Big change\n\nBody" },
39
+ files: next ? files("first", 300) : files("second", 2),
40
+ },
41
+ next
42
+ ? {
43
+ headers: {
44
+ link: '<https://api.github.com/repositories/1/commits/aaaaaaa?page=2>; rel="next"',
45
+ },
46
+ }
47
+ : {},
48
+ ),
49
+ );
50
+ }
51
+ return Promise.resolve(
52
+ Response.json({ total_commits: 4, files: files("compare", 300) }),
53
+ );
54
+ });
55
+
56
+ const commit = await fetchGitHubChanges({ owner: "o", repo: "r" }, "aaaaaaa");
57
+ assert.equal(commit instanceof Error, false);
58
+ if (commit instanceof Error) return;
59
+ assert.equal(commit.title, "Big change");
60
+ assert.equal(commit.files.length, 302);
61
+ assert.equal(commit.truncated, undefined);
62
+ assert.deepEqual(requested, [
63
+ "/repos/o/r/commits/aaaaaaa",
64
+ "/repositories/1/commits/aaaaaaa?page=2",
65
+ ]);
66
+
67
+ const compare = await fetchGitHubChanges(
68
+ { owner: "o", repo: "r" },
69
+ "bbbbbbb..ccccccc",
70
+ );
71
+ assert.equal(compare instanceof Error, false);
72
+ if (compare instanceof Error) return;
73
+ assert.equal(compare.files.length, 300);
74
+ assert.equal(compare.truncated, true);
75
+ });