@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
@@ -4,10 +4,16 @@ import { style, useStyles } from "purse-styles";
4
4
  import { init } from "md4x/standalone";
5
5
  import { parseViewerDocument } from "../parseViewer.ts";
6
6
  import type { ViewerDocument } from "../parseViewer.ts";
7
+ import { WORKING } from "../changes.ts";
8
+ import {
9
+ ChangesLoaderContext,
10
+ type ChangesLoader,
11
+ } from "../viewer/changesSource.ts";
7
12
  import { ViewerApp } from "../viewer/ViewerApp.tsx";
8
13
  import { loadGistMarkdown, type GistDocument } from "./fetchGist.ts";
9
14
  import { GistPinContext } from "./pin.ts";
10
15
  import { HostHome } from "../host/HostHome.tsx";
16
+ import { HostFilesContext } from "../host/files.ts";
11
17
  import { GistLoading, GistStatus } from "./GistStatus.tsx";
12
18
  import {
13
19
  gistPath,
@@ -41,6 +47,7 @@ type GistView =
41
47
  gist: GistDocument;
42
48
  file: string;
43
49
  document: ViewerDocument;
50
+ loadChanges: ChangesLoader;
44
51
  };
45
52
 
46
53
  export function GistApp() {
@@ -160,24 +167,28 @@ export function GistApp() {
160
167
  htmlUrl: view.gist.htmlUrl,
161
168
  }}
162
169
  >
163
- <ViewerApp
164
- key={`${view.gist.id}:${view.file}`}
165
- document={view.document}
166
- mode="gist"
167
- headerActions={
168
- <GistHeader
169
- gistId={view.gist.id}
170
- htmlUrl={view.gist.htmlUrl}
171
- files={view.gist.files}
172
- current={view.file}
173
- onSelect={(file) => {
174
- const next = gistPath(view.gist.id, file);
175
- window.history.pushState({}, "", next);
176
- setRoute(parseGistPath(next));
177
- }}
170
+ <HostFilesContext.Provider value={view.gist.sourceFiles}>
171
+ <ChangesLoaderContext.Provider value={view.loadChanges}>
172
+ <ViewerApp
173
+ key={`${view.gist.id}:${view.file}`}
174
+ document={view.document}
175
+ mode="gist"
176
+ headerActions={
177
+ <GistHeader
178
+ gistId={view.gist.id}
179
+ htmlUrl={view.gist.htmlUrl}
180
+ files={view.gist.files}
181
+ current={view.file}
182
+ onSelect={(file) => {
183
+ const next = gistPath(view.gist.id, file);
184
+ window.history.pushState({}, "", next);
185
+ setRoute(parseGistPath(next));
186
+ }}
187
+ />
188
+ }
178
189
  />
179
- }
180
- />
190
+ </ChangesLoaderContext.Provider>
191
+ </HostFilesContext.Provider>
181
192
  </GistPinContext.Provider>
182
193
  );
183
194
  }
@@ -218,11 +229,21 @@ async function loadView(
218
229
  message: parsed.message,
219
230
  };
220
231
  }
232
+ const snapshot = loaded.gist.changes;
221
233
  return {
222
234
  kind: "ready",
223
235
  gist: loaded.gist,
224
236
  file: loaded.gist.chosen.name,
225
237
  document: parsed,
238
+ loadChanges: (rev) =>
239
+ Promise.resolve(
240
+ snapshot[rev] ??
241
+ new Error(
242
+ rev === WORKING
243
+ ? "Uncommitted changes are only available in a local viewer."
244
+ : `This gist has no snapshot of ${rev}. Share the spec again with diffmap share.`,
245
+ ),
246
+ ),
226
247
  };
227
248
  }
228
249
 
@@ -1,3 +1,10 @@
1
+ import {
2
+ GIST_CHANGES_FILE,
3
+ GIST_FILES_FILE,
4
+ type Changes,
5
+ type Rev,
6
+ } from "../changes.ts";
7
+ import type { HostFiles } from "../host/files.ts";
1
8
  import { githubGistUrl, markdownFileNames, pickMarkdownFile } from "./route.ts";
2
9
 
3
10
  export type GistMarkdownFile = {
@@ -10,6 +17,10 @@ export type GistDocument = {
10
17
  htmlUrl: string;
11
18
  files: GistMarkdownFile[];
12
19
  chosen: GistMarkdownFile;
20
+ /** Snapshot `diffmap share` saved for each commit and changes card the spec references. */
21
+ changes: Record<Rev, Changes>;
22
+ /** Files excerpts and references without a commit read, as they were when shared. */
23
+ sourceFiles: HostFiles;
13
24
  };
14
25
 
15
26
  export type GistLoadResult =
@@ -95,6 +106,23 @@ export async function loadGistMarkdown(
95
106
  return { status: "missing-file", htmlUrl, files };
96
107
  }
97
108
 
109
+ // SAFETY: diffmap share writes this file from readDocumentChanges.
110
+ const changes = (await readSnapshot(apiFiles, GIST_CHANGES_FILE, signal)) as
111
+ | Record<Rev, Changes>
112
+ | Error;
113
+ if (changes instanceof Error) {
114
+ return { status: "error", message: changes.message };
115
+ }
116
+ // SAFETY: diffmap share writes this file as path to contents.
117
+ const sourceFiles = (await readSnapshot(
118
+ apiFiles,
119
+ GIST_FILES_FILE,
120
+ signal,
121
+ )) as HostFiles | Error;
122
+ if (sourceFiles instanceof Error) {
123
+ return { status: "error", message: sourceFiles.message };
124
+ }
125
+
98
126
  return {
99
127
  status: "ok",
100
128
  gist: {
@@ -102,10 +130,29 @@ export async function loadGistMarkdown(
102
130
  htmlUrl,
103
131
  files,
104
132
  chosen,
133
+ changes,
134
+ sourceFiles,
105
135
  },
106
136
  };
107
137
  }
108
138
 
139
+ /** A JSON file `diffmap share` wrote, or `{}` when the gist predates it. */
140
+ async function readSnapshot(
141
+ apiFiles: Record<string, GistApiFile>,
142
+ name: string,
143
+ signal?: AbortSignal,
144
+ ): Promise<unknown> {
145
+ const file = apiFiles[name];
146
+ if (file === undefined) return {};
147
+ const text = await readGistFile(file, signal);
148
+ if (text instanceof Error) return text;
149
+ try {
150
+ return JSON.parse(text) as unknown;
151
+ } catch {
152
+ return new Error(`${name} is not valid JSON.`);
153
+ }
154
+ }
155
+
109
156
  async function readGistFile(file: GistApiFile, signal?: AbortSignal) {
110
157
  const name = file.filename ?? "file";
111
158
  if (file.truncated !== true && typeof file.content === "string") {
@@ -0,0 +1,402 @@
1
+ import assert from "node:assert/strict";
2
+ import { execFile } from "node:child_process";
3
+ import fs from "node:fs/promises";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import { promisify } from "node:util";
7
+ import test from "node:test";
8
+ import {
9
+ changedFileItem,
10
+ revWindow,
11
+ windowRev,
12
+ type Changes,
13
+ type DeltaWindow,
14
+ } from "./changes.js";
15
+ import { listDeltas, readChanges, readDocumentChanges } from "./git.js";
16
+
17
+ const execFileAsync = promisify(execFile);
18
+
19
+ async function withRepo(
20
+ run: (
21
+ root: string,
22
+ git: (...args: string[]) => Promise<string>,
23
+ ) => Promise<void>,
24
+ ) {
25
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "diffmap-git-"));
26
+ const git = async (...args: string[]) =>
27
+ (await execFileAsync("git", args, { cwd: root })).stdout.trim();
28
+ const write = (file: string, contents: string) =>
29
+ fs.writeFile(path.join(root, file), contents);
30
+ try {
31
+ await git("init", "-q");
32
+ await git("config", "user.email", "test@example.com");
33
+ await git("config", "user.name", "Test");
34
+ await write("keep.ts", "one\ntwo\nthree\n");
35
+ await write("old-name.ts", "export const a = 1;\nexport const b = 2;\n");
36
+ await write("gone.ts", "bye\n");
37
+ await git("add", ".");
38
+ await git("commit", "-qm", "Initial");
39
+ await run(root, git);
40
+ } finally {
41
+ await fs.rm(root, { recursive: true, force: true });
42
+ }
43
+ }
44
+
45
+ /** Compares what the viewer receives over JSON. */
46
+ function summary(changes: Changes) {
47
+ return (JSON.parse(JSON.stringify(changes)) as Changes).files
48
+ .map((file) => ({
49
+ path: file.path,
50
+ prevPath: file.prevPath,
51
+ status: file.status,
52
+ additions: file.additions,
53
+ deletions: file.deletions,
54
+ contents: file.contents,
55
+ }))
56
+ .toSorted((a, b) => a.path.localeCompare(b.path));
57
+ }
58
+
59
+ test("readChanges reads a commit's added, modified, renamed, and deleted files", async () => {
60
+ await withRepo(async (root, git) => {
61
+ const initial = await git("rev-parse", "HEAD");
62
+ await fs.writeFile(path.join(root, "keep.ts"), "one\nTWO\nthree\nfour\n");
63
+ await git("mv", "old-name.ts", "new-name.ts");
64
+ await git("rm", "-q", "gone.ts");
65
+ await fs.writeFile(path.join(root, "added.ts"), "new\n");
66
+ await git("add", ".");
67
+ await git("commit", "-qm", "Change things");
68
+ const head = await git("rev-parse", "HEAD");
69
+
70
+ const changes = await readChanges(root, head.slice(0, 7));
71
+ assert.equal(changes instanceof Error, false);
72
+ if (changes instanceof Error) return;
73
+ assert.equal(changes.title, "Change things");
74
+ assert.deepEqual(summary(changes), [
75
+ {
76
+ path: "added.ts",
77
+ prevPath: undefined,
78
+ status: "added",
79
+ additions: 1,
80
+ deletions: 0,
81
+ contents: { new: "new\n" },
82
+ },
83
+ {
84
+ path: "gone.ts",
85
+ prevPath: undefined,
86
+ status: "deleted",
87
+ additions: 0,
88
+ deletions: 1,
89
+ contents: { old: "bye\n" },
90
+ },
91
+ {
92
+ path: "keep.ts",
93
+ prevPath: undefined,
94
+ status: "modified",
95
+ additions: 2,
96
+ deletions: 1,
97
+ contents: { old: "one\ntwo\nthree\n", new: "one\nTWO\nthree\nfour\n" },
98
+ },
99
+ {
100
+ path: "new-name.ts",
101
+ prevPath: "old-name.ts",
102
+ status: "renamed",
103
+ additions: 0,
104
+ deletions: 0,
105
+ contents: {
106
+ old: "export const a = 1;\nexport const b = 2;\n",
107
+ new: "export const a = 1;\nexport const b = 2;\n",
108
+ },
109
+ },
110
+ ]);
111
+
112
+ // The root commit diffs against the empty tree.
113
+ const rootCommit = await readChanges(root, initial);
114
+ assert.equal(rootCommit instanceof Error, false);
115
+ if (rootCommit instanceof Error) return;
116
+ assert.deepEqual(
117
+ summary(rootCommit).map((file) => [file.path, file.status]),
118
+ [
119
+ ["gone.ts", "added"],
120
+ ["keep.ts", "added"],
121
+ ["old-name.ts", "added"],
122
+ ],
123
+ );
124
+
125
+ const range = await readChanges(
126
+ root,
127
+ `${initial.slice(0, 7)}..${head.slice(0, 7)}`,
128
+ );
129
+ assert.equal(range instanceof Error, false);
130
+ if (range instanceof Error) return;
131
+ assert.equal(
132
+ range.title,
133
+ `${initial.slice(0, 7)}..${head.slice(0, 7)} · 1 commit`,
134
+ );
135
+ assert.equal(range.files.length, 4);
136
+
137
+ assert.equal((await readChanges(root, "abcdef1")) instanceof Error, true);
138
+ assert.equal((await readChanges(root, "HEAD")) instanceof Error, true);
139
+ });
140
+ });
141
+
142
+ test("readChanges working includes staged, unstaged, deleted, and untracked files", async () => {
143
+ await withRepo(async (root, git) => {
144
+ await fs.writeFile(path.join(root, "keep.ts"), "one\ntwo\nthree\nstaged\n");
145
+ await git("add", "keep.ts");
146
+ await fs.writeFile(
147
+ path.join(root, "keep.ts"),
148
+ "one\ntwo\nthree\nstaged\nunstaged\n",
149
+ );
150
+ await fs.rm(path.join(root, "gone.ts"));
151
+ await fs.writeFile(path.join(root, "fresh.ts"), "a\nb\n");
152
+
153
+ const changes = await readChanges(root, "working");
154
+ assert.equal(changes instanceof Error, false);
155
+ if (changes instanceof Error) return;
156
+ assert.equal(changes.title, "Uncommitted changes");
157
+ assert.deepEqual(
158
+ summary(changes).map((file) => [
159
+ file.path,
160
+ file.status,
161
+ file.additions,
162
+ file.deletions,
163
+ file.contents?.new,
164
+ ]),
165
+ [
166
+ ["fresh.ts", "added", 2, 0, "a\nb\n"],
167
+ ["gone.ts", "deleted", 0, 1, undefined],
168
+ ["keep.ts", "modified", 2, 0, "one\ntwo\nthree\nstaged\nunstaged\n"],
169
+ ],
170
+ );
171
+ });
172
+ });
173
+
174
+ test("readChanges working shows a path removed from the index but kept on disk once", async () => {
175
+ await withRepo(async (root, git) => {
176
+ // keep.ts is unchanged on disk, so it is not a change at all.
177
+ await git("rm", "-q", "--cached", "keep.ts", "gone.ts");
178
+ await fs.writeFile(path.join(root, "gone.ts"), "bye\nagain\n");
179
+
180
+ const changes = await readChanges(root, "working");
181
+ assert.equal(changes instanceof Error, false, String(changes));
182
+ if (changes instanceof Error) return;
183
+ assert.deepEqual(
184
+ summary(changes).map((file) => [
185
+ file.path,
186
+ file.status,
187
+ file.additions,
188
+ file.deletions,
189
+ file.contents,
190
+ ]),
191
+ [["gone.ts", "modified", 1, 0, { old: "bye\n", new: "bye\nagain\n" }]],
192
+ );
193
+ });
194
+ });
195
+
196
+ test("readChanges reads a symlink as its target path, not the file it points to", async () => {
197
+ await withRepo(async (root) => {
198
+ const outside = await fs.mkdtemp(path.join(os.tmpdir(), "diffmap-secret-"));
199
+ try {
200
+ await fs.writeFile(path.join(outside, "secret.txt"), "secret\n");
201
+ await fs.symlink(
202
+ path.join(outside, "secret.txt"),
203
+ path.join(root, "link.txt"),
204
+ );
205
+ const changes = await readChanges(root, "working");
206
+ assert.equal(changes instanceof Error, false, String(changes));
207
+ if (changes instanceof Error) return;
208
+ assert.deepEqual(
209
+ changes.files.map((file) => [file.path, file.contents?.new]),
210
+ [["link.txt", path.join(outside, "secret.txt")]],
211
+ );
212
+ } finally {
213
+ await fs.rm(outside, { recursive: true, force: true });
214
+ }
215
+ });
216
+ });
217
+
218
+ test("readChanges names files relative to a root below the repository's top level", async () => {
219
+ await withRepo(async (root, git) => {
220
+ await fs.mkdir(path.join(root, "pkg"));
221
+ await fs.writeFile(path.join(root, "pkg", "a.ts"), "a\n");
222
+ await git("add", ".");
223
+ await git("commit", "-qm", "Add a");
224
+ const sha = await git("rev-parse", "HEAD");
225
+ await fs.writeFile(path.join(root, "pkg", "a.ts"), "a\nb\n");
226
+ await fs.writeFile(path.join(root, "keep.ts"), "outside\n");
227
+
228
+ const pkg = path.join(root, "pkg");
229
+ const commit = await readChanges(pkg, sha);
230
+ const working = await readChanges(pkg, "working");
231
+ assert.equal(commit instanceof Error, false, String(commit));
232
+ assert.equal(working instanceof Error, false, String(working));
233
+ if (commit instanceof Error || working instanceof Error) return;
234
+ assert.deepEqual(
235
+ summary(commit).map((file) => [file.path, file.status]),
236
+ [["a.ts", "added"]],
237
+ );
238
+ assert.deepEqual(
239
+ summary(working).map((file) => [file.path, file.status, file.contents]),
240
+ [["a.ts", "modified", { old: "a\n", new: "a\nb\n" }]],
241
+ );
242
+ });
243
+ });
244
+
245
+ test("readDocumentChanges rejects unchanged files and ranges past the end", async () => {
246
+ await withRepo(async (root, git) => {
247
+ await fs.writeFile(path.join(root, "keep.ts"), "one\ntwo\nthree\nfour\n");
248
+ await git("commit", "-qam", "Add four");
249
+ const sha = (await git("rev-parse", "HEAD")).slice(0, 7);
250
+ const check = (reference: { path: string; start?: number; end?: number }) =>
251
+ readDocumentChanges(root, {
252
+ changeRevs: [],
253
+ references: [{ kind: "file", rev: sha, ...reference }],
254
+ });
255
+
256
+ const ok = await check({ path: "keep.ts", start: 4, end: 4 });
257
+ assert.equal(ok instanceof Error, false);
258
+ if (ok instanceof Error) return;
259
+ assert.deepEqual([...ok.keys()], [sha]);
260
+
261
+ const pastEnd = await check({ path: "keep.ts", start: 4, end: 5 });
262
+ assert.match(
263
+ pastEnd instanceof Error ? pastEnd.message : "",
264
+ /past the end of keep\.ts \(4 lines\)/,
265
+ );
266
+
267
+ const unchanged = await check({ path: "gone.ts" });
268
+ assert.match(
269
+ unchanged instanceof Error ? unchanged.message : "",
270
+ /gone\.ts is not changed/,
271
+ );
272
+ });
273
+ });
274
+
275
+ test("listDeltas lists a branch's commits, and picked windows read the right files", async () => {
276
+ await withRepo(async (root, git) => {
277
+ const write = (file: string, contents: string) =>
278
+ fs.writeFile(path.join(root, file), contents);
279
+ await git("branch", "-M", "main");
280
+ await git("checkout", "-qb", "feature");
281
+ await write("first.ts", "1\n");
282
+ await git("add", ".");
283
+ await git("commit", "-qm", "First");
284
+ await write("second.ts", "2\n");
285
+ await git("add", ".");
286
+ await git("commit", "-qm", "Second");
287
+ await write("staged.ts", "s\n");
288
+ await git("add", "staged.ts");
289
+ await write("keep.ts", "one\ntwo\nthree\nunstaged\n");
290
+
291
+ const deltas = await listDeltas(root);
292
+ assert.equal(deltas instanceof Error, false);
293
+ if (deltas instanceof Error) return;
294
+ // The initial commit is on main, so the list stops at the branch point.
295
+ assert.deepEqual(
296
+ deltas.map((delta) =>
297
+ delta.kind === "commit" ? delta.subject : delta.kind,
298
+ ),
299
+ ["unstaged", "staged", "Second", "First"],
300
+ );
301
+ assert.deepEqual(
302
+ deltas.slice(0, 2).map((delta) => [delta.additions, delta.deletions]),
303
+ [
304
+ [1, 0],
305
+ [1, 0],
306
+ ],
307
+ );
308
+
309
+ // An open commit before the branch point extends the list down to it.
310
+ const initial = await git("rev-parse", "main");
311
+ const reaching = await listDeltas(root, initial.slice(0, 7));
312
+ assert.equal(reaching instanceof Error, false);
313
+ if (reaching instanceof Error) return;
314
+ assert.deepEqual(
315
+ reaching.map((delta) =>
316
+ delta.kind === "commit" ? delta.subject : delta.kind,
317
+ ),
318
+ ["unstaged", "staged", "Second", "First", "Initial"],
319
+ );
320
+ assert.deepEqual(revWindow(reaching, initial.slice(0, 7)), {
321
+ newest: 4,
322
+ oldest: 4,
323
+ });
324
+ // Picking every row of the extended list keeps its oldest commit.
325
+ const everything = { newest: 0, oldest: reaching.length - 1 };
326
+ const whole = windowRev(reaching, everything);
327
+ assert.deepEqual(revWindow(reaching, whole), everything);
328
+ const wholeChanges = await readChanges(root, whole);
329
+ assert.equal(wholeChanges instanceof Error, false, String(wholeChanges));
330
+ if (wholeChanges instanceof Error) return;
331
+ assert.equal(
332
+ wholeChanges.files.some((file) => file.path === "gone.ts"),
333
+ true,
334
+ );
335
+
336
+ const filesFor = async (window: DeltaWindow) => {
337
+ const rev = windowRev(deltas, window);
338
+ // Every window maps back to the same rows, so the picker can show what a rev covers.
339
+ assert.deepEqual(revWindow(deltas, rev), window);
340
+ const changes = await readChanges(root, rev);
341
+ assert.equal(changes instanceof Error, false, String(changes));
342
+ if (changes instanceof Error) return [];
343
+ return changes.files.map((file) => file.path).toSorted();
344
+ };
345
+ assert.deepEqual(await filesFor({ newest: 2, oldest: 3 }), [
346
+ "first.ts",
347
+ "second.ts",
348
+ ]);
349
+ assert.deepEqual(await filesFor({ newest: 3, oldest: 3 }), ["first.ts"]);
350
+ assert.deepEqual(await filesFor({ newest: 1, oldest: 3 }), [
351
+ "first.ts",
352
+ "second.ts",
353
+ "staged.ts",
354
+ ]);
355
+ assert.deepEqual(await filesFor({ newest: 1, oldest: 1 }), ["staged.ts"]);
356
+ assert.deepEqual(await filesFor({ newest: 0, oldest: 0 }), ["keep.ts"]);
357
+ assert.deepEqual(await filesFor({ newest: 0, oldest: 1 }), [
358
+ "keep.ts",
359
+ "staged.ts",
360
+ ]);
361
+ assert.deepEqual(await filesFor({ newest: 0, oldest: 3 }), [
362
+ "first.ts",
363
+ "keep.ts",
364
+ "second.ts",
365
+ "staged.ts",
366
+ ]);
367
+ });
368
+ });
369
+
370
+ /** Whether a diff item shows a new-side line inside a hunk. */
371
+ function covers(item: ReturnType<typeof changedFileItem>, line: number) {
372
+ return (
373
+ item?.type === "diff" &&
374
+ item.fileDiff.hunks.some(
375
+ (hunk) =>
376
+ line >= hunk.additionStart &&
377
+ line < hunk.additionStart + hunk.additionCount,
378
+ )
379
+ );
380
+ }
381
+
382
+ test("changedFileItem shows a focused range that sits in unchanged code", () => {
383
+ const lines = Array.from(
384
+ { length: 100 },
385
+ (_, index) => `line ${String(index + 1)}`,
386
+ );
387
+ const old = `${lines.join("\n")}\n`;
388
+ const next = old.replace("line 90\n", "line ninety\n");
389
+ const file = {
390
+ path: "a.ts",
391
+ status: "modified" as const,
392
+ additions: 1,
393
+ deletions: 1,
394
+ binary: false,
395
+ contents: { old, new: next },
396
+ };
397
+ assert.equal(covers(changedFileItem(file), 10), false);
398
+ const focused = changedFileItem(file, { start: 10, end: 12 });
399
+ assert.equal(covers(focused, 10) && covers(focused, 12), true);
400
+ // A new version makes Pierre reread the widened item.
401
+ assert.notEqual(focused?.version, changedFileItem(file)?.version);
402
+ });