@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.
- package/README.md +64 -57
- package/index.html +18 -0
- package/package.json +7 -5
- package/src/annotations.ts +36 -29
- package/src/changes.ts +235 -0
- package/src/cli.ts +1 -0
- package/src/errors.ts +5 -0
- package/src/gist/GistApp.tsx +38 -17
- package/src/gist/fetchGist.ts +47 -0
- package/src/git.test.ts +402 -0
- package/src/git.ts +498 -0
- package/src/github/GitHubApp.tsx +39 -22
- package/src/github/fetchRepo.test.ts +59 -1
- package/src/github/fetchRepo.ts +99 -43
- package/src/github/patch.test.ts +18 -9
- package/src/github/patch.ts +30 -19
- package/src/host/HostHome.tsx +20 -7
- package/src/host/files.ts +5 -1
- package/src/host/home.md +17 -55
- package/src/host/home.test.ts +4 -3
- package/src/index.ts +1 -0
- package/src/parseFence.ts +6 -6
- package/src/parseViewer.test.ts +140 -0
- package/src/parseViewer.ts +104 -78
- package/src/serve.ts +34 -1
- package/src/share.ts +62 -1
- package/src/viewer/CallStackDiff.tsx +15 -3
- package/src/viewer/ChangesCard.tsx +267 -0
- package/src/viewer/ComarkView.tsx +83 -15
- package/src/viewer/Fence.tsx +9 -1
- package/src/viewer/MermaidBlock.tsx +99 -11
- package/src/viewer/RangePicker.tsx +526 -0
- package/src/viewer/SourceDiffPanel.tsx +254 -116
- package/src/viewer/TableOfContents.tsx +177 -93
- package/src/viewer/ViewerApp.tsx +34 -114
- package/src/viewer/changesSource.ts +91 -0
- package/src/viewer/sourceHeader.test.ts +2 -87
- package/src/viewer/sourceHeader.ts +4 -63
- package/src/viewer/sourceNavigation.ts +0 -43
- package/vite.config.ts +1 -0
- package/vite.gist.config.ts +57 -0
- package/src/viewer/TocButton.tsx +0 -27
package/src/gist/GistApp.tsx
CHANGED
|
@@ -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
|
-
<
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
|
package/src/gist/fetchGist.ts
CHANGED
|
@@ -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") {
|
package/src/git.test.ts
ADDED
|
@@ -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
|
+
});
|