git-reviewer 2.10.0 → 2.12.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 +47 -3
- package/lib/agent.js +22 -2
- package/lib/changes.js +73 -4
- package/package.json +1 -1
- package/public/app.js +352 -45
- package/public/index.html +35 -15
- package/public/style.css +166 -29
- package/server.js +207 -25
package/README.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
[](https://github.com/dheerajjha/reviewer/actions/workflows/ci.yml)
|
|
4
4
|
[](LICENSE)
|
|
5
5
|
[](package.json)
|
|
6
|
-
[](test/)
|
|
7
7
|
[](package.json)
|
|
8
8
|
|
|
9
9
|
When an agent writes the code, reading it becomes the bottleneck — and the tool
|
|
@@ -141,6 +141,47 @@ is nothing to configure:
|
|
|
141
141
|
| Working directory clean | The last commit against its parent |
|
|
142
142
|
| No commits yet | Every tracked and untracked file, as wholly new |
|
|
143
143
|
|
|
144
|
+
### Comparing branches
|
|
145
|
+
|
|
146
|
+
Those defaults cover the common case with nothing to set up, but a branch you
|
|
147
|
+
have worked on for a day is several commits deep, and the default scope only
|
|
148
|
+
ever shows the most recent one.
|
|
149
|
+
|
|
150
|
+
**Compare branches** in the header opens the comparison your pull request would
|
|
151
|
+
show. If the repository has an obvious base — `origin/main`, then `main`, then
|
|
152
|
+
`master` or `develop` — it compares your current branch against it straight
|
|
153
|
+
away; otherwise it asks. Remote branches come first because a local `main` that
|
|
154
|
+
has not been pulled in a week is a quietly wrong base.
|
|
155
|
+
|
|
156
|
+
Both ends are pickers: **Base** and **Compare**, the same two words a pull
|
|
157
|
+
request uses. Each lists branches, remote branches, tags and recent commits, so
|
|
158
|
+
you do not have to go and find a SHA. **Other…** at the bottom of either takes
|
|
159
|
+
anything else git understands — `HEAD~5`, a tag, a SHA from somewhere else. The
|
|
160
|
+
⇄ button swaps them, which answers the other question: what has `main` got that
|
|
161
|
+
my branch has not?
|
|
162
|
+
|
|
163
|
+
**It shows what the branch changes, not everything that differs.** If `main`
|
|
164
|
+
has moved on since your branch was cut, a plain two-ended diff would include
|
|
165
|
+
`main`'s newer work *reversed*, as though your branch had undone it. The
|
|
166
|
+
comparison starts from where the two diverged instead, exactly as a pull
|
|
167
|
+
request does, and says what it left out:
|
|
168
|
+
|
|
169
|
+
```
|
|
170
|
+
main → feature/auth 3 commits · 2 files changed
|
|
171
|
+
|
|
172
|
+
main has 1 newer commit that is not on feature/auth. It is left out: this shows
|
|
173
|
+
only what feature/auth changes since the two diverged, as a pull request would.
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
An empty comparison explains itself rather than showing a blank list. Two
|
|
177
|
+
commits that add a file and then delete it produce no diff between the ends —
|
|
178
|
+
correctly — and that reads as *"2 commits undo each other"*, not as *"nothing
|
|
179
|
+
to review"*.
|
|
180
|
+
|
|
181
|
+
Comments are kept when you switch between comparisons. One on a file that is
|
|
182
|
+
not in the current view stays in the comments list, marked **Not in this
|
|
183
|
+
view**, and is back in place whenever you open a view that includes the file.
|
|
184
|
+
|
|
144
185
|
Modified, added, deleted, renamed, and binary files are all listed, each marked
|
|
145
186
|
with its git status letter. Deleted lines are commentable too — the most useful
|
|
146
187
|
review note is often about the code someone removed. A file deleted outright is
|
|
@@ -264,7 +305,10 @@ The UI is a client of this; nothing is hidden from you.
|
|
|
264
305
|
| Endpoint | Purpose |
|
|
265
306
|
|---|---|
|
|
266
307
|
| `GET /api/health` | Liveness, plus the number of open sessions |
|
|
267
|
-
| `POST /api/load-repo` | Open a repository; returns a `repoId` and the changed files |
|
|
308
|
+
| `POST /api/load-repo` | Open a repository; returns a `repoId` and the changed files. Pass `base` (and optionally `head`) to compare two refs from their merge base |
|
|
309
|
+
| `GET /api/refs/:repoId` | Branches, remote branches, tags and recent commits, for choosing what to compare |
|
|
310
|
+
| `GET /api/commits/:repoId` | Recent commits only; kept for callers of 2.11 |
|
|
311
|
+
| `GET /api/alive` | Held open by the page; when the last one closes, a terminal run stops |
|
|
268
312
|
| `GET /api/file/:repoId/:path` | The file's diff, as structured lines |
|
|
269
313
|
| `GET /api/file-full/:repoId/:path` | Every line of the file, for context |
|
|
270
314
|
| `POST /api/save-comments` | Persist the current comments |
|
|
@@ -294,7 +338,7 @@ rather than a public issue.
|
|
|
294
338
|
|
|
295
339
|
```bash
|
|
296
340
|
npm install # 3 dependencies, no build step, ~6MB
|
|
297
|
-
npm test #
|
|
341
|
+
npm test # 335 tests
|
|
298
342
|
npm run test:watch
|
|
299
343
|
npm run test:coverage
|
|
300
344
|
```
|
package/lib/agent.js
CHANGED
|
@@ -210,8 +210,28 @@ function describeScope({ repository, mode, range }) {
|
|
|
210
210
|
// distinguishes "one commit of work" from "nine" once the diff has been
|
|
211
211
|
// flattened -- and an agent reading a flattened diff of nine commits
|
|
212
212
|
// should know that is what it is holding.
|
|
213
|
-
const
|
|
214
|
-
|
|
213
|
+
const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`;
|
|
214
|
+
const commits = plural(range.commits, 'commit');
|
|
215
|
+
|
|
216
|
+
// Ranges saved before 2.12 carry neither names nor a merge base. Say what
|
|
217
|
+
// they can support and no more.
|
|
218
|
+
if (!range.from || !range.baseName || !range.headName) {
|
|
219
|
+
return `Reviewed: everything between ${range.base} and ${range.head} (${commits}), as a single combined diff.`;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const ends = `${range.baseName} \u2192 ${range.headName}`;
|
|
223
|
+
if (range.from === range.base) {
|
|
224
|
+
return `Reviewed: ${ends} (${range.base}..${range.head}), ${commits} as a single combined diff.`;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Diverged. The agent needs the left-out half as much as the reviewed
|
|
228
|
+
// half: code on the base that differs from the head is not something the
|
|
229
|
+
// reviewer asked anyone to change, and an agent that "fixes" it has
|
|
230
|
+
// reverted someone else's work.
|
|
231
|
+
const behind = range.behind > 0
|
|
232
|
+
? ` ${range.baseName} has ${plural(range.behind, 'newer commit')} that ${range.behind === 1 ? 'is' : 'are'} deliberately not part of this review; leave that code alone.`
|
|
233
|
+
: '';
|
|
234
|
+
return `Reviewed: what ${range.headName} changes since it diverged from ${range.baseName} (merge base ${range.from}, head ${range.head}), ${commits} as a single combined diff.${behind}`;
|
|
215
235
|
}
|
|
216
236
|
|
|
217
237
|
if (mode === 'lastCommit') {
|
package/lib/changes.js
CHANGED
|
@@ -191,12 +191,80 @@ function classifyCommitFile(file) {
|
|
|
191
191
|
* @param {import('simple-git').DiffResult} diffSummary
|
|
192
192
|
* @returns {ChangedFile[]}
|
|
193
193
|
*/
|
|
194
|
-
|
|
194
|
+
/**
|
|
195
|
+
* Read `git diff --name-status -z` into what git itself says happened to
|
|
196
|
+
* each path.
|
|
197
|
+
*
|
|
198
|
+
* This exists because `classifyCommitFile` above has to guess from line
|
|
199
|
+
* counts, and the guess is wrong for a large share of ordinary edits: a
|
|
200
|
+
* change that only adds lines to an existing file reads as `A`, and one that
|
|
201
|
+
* only removes lines reads as `D` -- a reviewer shown "deleted" on a file
|
|
202
|
+
* that lost one line has been told something false by the badge meant to
|
|
203
|
+
* orient them. Git knows the answer; ask it.
|
|
204
|
+
*
|
|
205
|
+
* `-z` rather than the default output because the default quotes and
|
|
206
|
+
* octal-escapes non-ASCII paths the same way `--stat` does, and those were
|
|
207
|
+
* #20's bug the first time. With `-z` every field is NUL-terminated and the
|
|
208
|
+
* path is raw bytes, so there is nothing to unquote.
|
|
209
|
+
*
|
|
210
|
+
* Renames and copies (`R100`, `C75`) carry two paths; the second is the one
|
|
211
|
+
* the file now lives at, which is the one the rest of the app keys on.
|
|
212
|
+
*
|
|
213
|
+
* @param {string} raw output of `git diff --name-status -z -M <from> <to>`
|
|
214
|
+
* @returns {Map<string, 'A'|'M'|'D'|'R'>} path -> status
|
|
215
|
+
*/
|
|
216
|
+
function parseNameStatus(raw) {
|
|
217
|
+
const statuses = new Map();
|
|
218
|
+
const fields = String(raw ?? '').split('\0');
|
|
219
|
+
|
|
220
|
+
for (let i = 0; i < fields.length; ) {
|
|
221
|
+
const code = fields[i];
|
|
222
|
+
if (!code) { i += 1; continue; }
|
|
223
|
+
|
|
224
|
+
const letter = code[0];
|
|
225
|
+
if (letter === 'R' || letter === 'C') {
|
|
226
|
+
const to = fields[i + 2];
|
|
227
|
+
// A copy is a new file that happens to resemble an old one; from the
|
|
228
|
+
// reviewer's side it is an addition.
|
|
229
|
+
if (to) statuses.set(to, letter === 'R' ? 'R' : 'A');
|
|
230
|
+
i += 3;
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const path = fields[i + 1];
|
|
235
|
+
if (path) {
|
|
236
|
+
// T is a type change (a file became a symlink, or the reverse). The
|
|
237
|
+
// path still exists on both sides, so for review purposes it is a
|
|
238
|
+
// modification.
|
|
239
|
+
statuses.set(path, letter === 'A' || letter === 'D' ? letter : 'M');
|
|
240
|
+
}
|
|
241
|
+
i += 2;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return statuses;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* The flat file list for a commit or range review.
|
|
249
|
+
*
|
|
250
|
+
* Paths still come from `diffSummary`, whose rename and quoting handling was
|
|
251
|
+
* earned the hard way (#20, #25, #64). Only the status letter comes from
|
|
252
|
+
* `statuses` when it is given -- which every caller in the server does. The
|
|
253
|
+
* count-based guess remains as the fallback for a caller that has nothing
|
|
254
|
+
* better, not as the answer.
|
|
255
|
+
*
|
|
256
|
+
* @param {import('simple-git').DiffResult} diffSummary
|
|
257
|
+
* @param {Map<string, string>} [statuses] from `parseNameStatus`
|
|
258
|
+
*/
|
|
259
|
+
function collectCommitChanges(diffSummary, statuses) {
|
|
195
260
|
const files = diffSummary?.files ?? [];
|
|
196
261
|
return files.map(file => {
|
|
197
262
|
const { path, isRename } = parseCommitFilePath(file?.file);
|
|
198
|
-
|
|
199
|
-
return { path, status };
|
|
263
|
+
if (isRename) return { path, status: 'R' };
|
|
264
|
+
if (file?.binary) return { path, status: 'B' };
|
|
265
|
+
|
|
266
|
+
const known = statuses?.get(path);
|
|
267
|
+
return { path, status: known ?? classifyCommitFile(file) };
|
|
200
268
|
});
|
|
201
269
|
}
|
|
202
270
|
|
|
@@ -205,5 +273,6 @@ module.exports = {
|
|
|
205
273
|
collectCommitChanges,
|
|
206
274
|
classifyCommitFile,
|
|
207
275
|
unquoteGitPath,
|
|
208
|
-
parseCommitFilePath
|
|
276
|
+
parseCommitFilePath,
|
|
277
|
+
parseNameStatus
|
|
209
278
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "git-reviewer",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.12.0",
|
|
4
4
|
"description": "Review your coding agent's changes like a pull request — locally, with inline comments and threaded follow-ups, then hand the marked-up review back to the agent",
|
|
5
5
|
"author": "Dheeraj Jha",
|
|
6
6
|
"license": "MIT",
|
package/public/app.js
CHANGED
|
@@ -137,63 +137,307 @@ function shortSha(sha) {
|
|
|
137
137
|
return typeof sha === 'string' ? sha.slice(0, 8) : '';
|
|
138
138
|
}
|
|
139
139
|
|
|
140
|
+
/** A full sha is shortened for display; a branch or tag name is left alone. */
|
|
141
|
+
function displayRef(ref) {
|
|
142
|
+
return /^[0-9a-f]{40}$/i.test(ref ?? '') ? shortSha(ref) : (ref ?? '');
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** `n thing` or `n things`, instead of the `thing(s)` that reads like a form. */
|
|
146
|
+
function plural(n, word) {
|
|
147
|
+
return `${n} ${word}${n === 1 ? '' : 's'}`;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// What the compare pickers can offer, from /api/refs. Refetched on every
|
|
151
|
+
// load, because a comparison opens a new session and branches move.
|
|
152
|
+
let currentRefs = null;
|
|
153
|
+
|
|
154
|
+
// The "type something else" choice at the foot of either picker.
|
|
155
|
+
const OTHER_REF = '__other__';
|
|
156
|
+
|
|
157
|
+
const PICKERS = {
|
|
158
|
+
base: { select: 'compareBaseSelect', other: 'compareBaseOther' },
|
|
159
|
+
head: { select: 'compareHeadSelect', other: 'compareHeadOther' }
|
|
160
|
+
};
|
|
161
|
+
|
|
140
162
|
/**
|
|
141
|
-
*
|
|
163
|
+
* Fetch the branches, tags and commits the pickers offer, and fill both.
|
|
142
164
|
*
|
|
143
|
-
*
|
|
144
|
-
*
|
|
145
|
-
*
|
|
146
|
-
|
|
147
|
-
|
|
165
|
+
* A failure leaves both pickers with only "Other…" in them, which still
|
|
166
|
+
* works; a repository whose refs will not list is not a reason to refuse to
|
|
167
|
+
* compare it.
|
|
168
|
+
*/
|
|
169
|
+
async function loadRefs(repoId) {
|
|
170
|
+
try {
|
|
171
|
+
const response = await fetch(`${API_BASE}/refs/${repoId}`);
|
|
172
|
+
currentRefs = response.ok ? await response.json() : null;
|
|
173
|
+
} catch {
|
|
174
|
+
currentRefs = null;
|
|
175
|
+
}
|
|
176
|
+
fillPicker('base');
|
|
177
|
+
fillPicker('head');
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Fill one picker, grouped the way people look for things.
|
|
148
182
|
*
|
|
149
|
-
*
|
|
183
|
+
* Branches first, because "my branch against main" is the comparison people
|
|
184
|
+
* reach for. Every label goes in through `new Option`, which sets text, not
|
|
185
|
+
* markup: branch names and commit subjects come from whatever repository is
|
|
186
|
+
* open, and a ref name may legally contain `<` and `>`.
|
|
150
187
|
*/
|
|
151
|
-
function
|
|
152
|
-
const
|
|
153
|
-
const
|
|
154
|
-
|
|
188
|
+
function fillPicker(end) {
|
|
189
|
+
const select = document.getElementById(PICKERS[end].select);
|
|
190
|
+
const refs = currentRefs;
|
|
191
|
+
select.innerHTML = '';
|
|
192
|
+
|
|
193
|
+
const placeholder = new Option(end === 'base' ? 'Choose a base…' : 'Choose what to compare…', '');
|
|
194
|
+
placeholder.disabled = true;
|
|
195
|
+
select.appendChild(placeholder);
|
|
196
|
+
|
|
197
|
+
const group = (label, options) => {
|
|
198
|
+
if (options.length === 0) return;
|
|
199
|
+
const optgroup = document.createElement('optgroup');
|
|
200
|
+
optgroup.label = label;
|
|
201
|
+
options.forEach(option => optgroup.appendChild(option));
|
|
202
|
+
select.appendChild(optgroup);
|
|
203
|
+
};
|
|
155
204
|
|
|
156
|
-
|
|
157
|
-
|
|
205
|
+
if (refs) {
|
|
206
|
+
group('Branches', refs.branches.map(branch => new Option(
|
|
207
|
+
`${branch.name}${branch.name === refs.current ? ' (current)' : ''} · ${branch.when}`,
|
|
208
|
+
branch.name
|
|
209
|
+
)));
|
|
210
|
+
group('Remote branches', refs.remotes.map(branch => new Option(`${branch.name} · ${branch.when}`, branch.name)));
|
|
211
|
+
group('Tags', refs.tags.map(tag => new Option(`${tag.name} · ${tag.when}`, tag.name)));
|
|
212
|
+
group(
|
|
213
|
+
refs.current ? `Recent commits on ${refs.current}` : 'Recent commits',
|
|
214
|
+
refs.commits.map(commit => new Option(`${commit.short} · ${commit.subject} · ${commit.when}`, commit.sha))
|
|
215
|
+
);
|
|
216
|
+
}
|
|
158
217
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
218
|
+
select.appendChild(new Option('Other: a tag, HEAD~5, a sha…', OTHER_REF));
|
|
219
|
+
select.value = '';
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Point a picker at a ref, by name if it is offered, by sha if the name is
|
|
224
|
+
* not but the commit is, and otherwise through "Other…" with the text shown.
|
|
225
|
+
*
|
|
226
|
+
* A range opened by typing `HEAD~2` resolves to a commit that may well be in
|
|
227
|
+
* the list; showing it selected there is more useful than a picker stuck on
|
|
228
|
+
* "Other" around a value that is also sitting three rows down.
|
|
229
|
+
*/
|
|
230
|
+
function selectRef(end, name, sha) {
|
|
231
|
+
const select = document.getElementById(PICKERS[end].select);
|
|
232
|
+
const other = document.getElementById(PICKERS[end].other);
|
|
233
|
+
const offered = value => [...select.options].some(option => option.value === value);
|
|
234
|
+
|
|
235
|
+
if (name && offered(name)) {
|
|
236
|
+
select.value = name;
|
|
237
|
+
} else if (sha && offered(sha)) {
|
|
238
|
+
select.value = sha;
|
|
239
|
+
} else if (name || sha) {
|
|
240
|
+
select.value = OTHER_REF;
|
|
241
|
+
other.value = name || displayRef(sha);
|
|
242
|
+
other.classList.remove('hidden');
|
|
165
243
|
return;
|
|
244
|
+
} else {
|
|
245
|
+
select.value = '';
|
|
246
|
+
}
|
|
247
|
+
other.classList.add('hidden');
|
|
248
|
+
other.value = '';
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** The ref one end of the comparison currently names, or '' if none yet. */
|
|
252
|
+
function pickedRef(end) {
|
|
253
|
+
const select = document.getElementById(PICKERS[end].select);
|
|
254
|
+
return select.value === OTHER_REF
|
|
255
|
+
? document.getElementById(PICKERS[end].other).value.trim()
|
|
256
|
+
: select.value;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Either picker changed: compare as soon as both ends are known. */
|
|
260
|
+
function onPickerChange(end) {
|
|
261
|
+
const select = document.getElementById(PICKERS[end].select);
|
|
262
|
+
const other = document.getElementById(PICKERS[end].other);
|
|
263
|
+
|
|
264
|
+
if (select.value === OTHER_REF) {
|
|
265
|
+
other.classList.remove('hidden');
|
|
266
|
+
other.focus();
|
|
267
|
+
return; // Enter in the box compares; see the listeners in DOMContentLoaded.
|
|
166
268
|
}
|
|
167
269
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
: 'Reviewing uncommitted changes';
|
|
171
|
-
document.getElementById('compareBase').value = '';
|
|
270
|
+
other.classList.add('hidden');
|
|
271
|
+
compareNow();
|
|
172
272
|
}
|
|
173
273
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
const
|
|
274
|
+
function compareNow() {
|
|
275
|
+
const base = pickedRef('base');
|
|
276
|
+
const head = pickedRef('head');
|
|
277
|
+
if (base && head) loadRepo({ base, head });
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** Reverse the comparison: "what does main have that my branch does not". */
|
|
281
|
+
function swapCompare() {
|
|
282
|
+
const base = pickedRef('base');
|
|
283
|
+
const head = pickedRef('head');
|
|
284
|
+
if (base && head) loadRepo({ base: head, head: base });
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* The base most people mean, if the repository makes it obvious.
|
|
289
|
+
*
|
|
290
|
+
* The remote comes first: a local `main` that has not been pulled in a week
|
|
291
|
+
* is a quietly wrong base, and `origin/main` is what the pull request will be
|
|
292
|
+
* measured against. Never the branch you are on -- comparing a branch with
|
|
293
|
+
* itself is always empty, and offering it as a default would make the first
|
|
294
|
+
* click of the feature look broken.
|
|
295
|
+
*/
|
|
296
|
+
function defaultBase() {
|
|
297
|
+
const refs = currentRefs;
|
|
298
|
+
if (!refs) return null;
|
|
299
|
+
|
|
300
|
+
const known = new Set([...refs.branches, ...refs.remotes].map(ref => ref.name));
|
|
301
|
+
const candidates = ['origin/main', 'main', 'origin/master', 'master', 'origin/develop', 'develop'];
|
|
302
|
+
return candidates.find(name => known.has(name) && name !== refs.current) ?? null;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Open the compare row. With an obvious base, compare straight away: one
|
|
307
|
+
* click to the view a pull request would give you. Without one, show the
|
|
308
|
+
* pickers and wait -- a guessed comparison nobody asked for is worse than
|
|
309
|
+
* asking.
|
|
310
|
+
*/
|
|
311
|
+
function openCompare() {
|
|
312
|
+
document.getElementById('compareRow').classList.remove('hidden');
|
|
313
|
+
const head = currentRefs?.current ?? 'HEAD';
|
|
314
|
+
const base = defaultBase();
|
|
177
315
|
|
|
178
|
-
if (
|
|
179
|
-
|
|
316
|
+
if (base) {
|
|
317
|
+
loadRepo({ base, head });
|
|
180
318
|
return;
|
|
181
319
|
}
|
|
182
320
|
|
|
183
|
-
|
|
321
|
+
selectRef('base', null, null);
|
|
322
|
+
selectRef('head', head, null);
|
|
323
|
+
document.getElementById('compareToggle').classList.add('hidden');
|
|
324
|
+
document.getElementById('scopeResetBtn').classList.remove('hidden');
|
|
325
|
+
document.getElementById(PICKERS.base.select).focus();
|
|
184
326
|
}
|
|
185
327
|
|
|
186
328
|
/** Back to the default scope: uncommitted changes, or the last commit. */
|
|
187
329
|
function resetScope() {
|
|
188
|
-
document.getElementById('
|
|
330
|
+
document.getElementById('compareRow').classList.add('hidden');
|
|
189
331
|
loadRepo();
|
|
190
332
|
}
|
|
191
333
|
|
|
334
|
+
/**
|
|
335
|
+
* Say what the review is of, in the names the reviewer used.
|
|
336
|
+
*
|
|
337
|
+
* Built from DOM nodes rather than markup: branch names are chosen by
|
|
338
|
+
* whoever made the repository, and one may legally contain `<`.
|
|
339
|
+
*
|
|
340
|
+
* @param {{mode: string, range: ?object, files: object[]}} data
|
|
341
|
+
*/
|
|
342
|
+
function renderScope(data) {
|
|
343
|
+
const bar = document.getElementById('scopeBar');
|
|
344
|
+
const label = document.getElementById('scopeLabel');
|
|
345
|
+
const meta = document.getElementById('scopeMeta');
|
|
346
|
+
const note = document.getElementById('scopeNote');
|
|
347
|
+
const row = document.getElementById('compareRow');
|
|
348
|
+
const toggle = document.getElementById('compareToggle');
|
|
349
|
+
const reset = document.getElementById('scopeResetBtn');
|
|
350
|
+
|
|
351
|
+
const strong = text => {
|
|
352
|
+
const el = document.createElement('strong');
|
|
353
|
+
el.textContent = text;
|
|
354
|
+
return el;
|
|
355
|
+
};
|
|
356
|
+
const setNote = text => {
|
|
357
|
+
note.textContent = text ?? '';
|
|
358
|
+
note.classList.toggle('hidden', !text);
|
|
359
|
+
};
|
|
360
|
+
|
|
361
|
+
bar.classList.remove('hidden');
|
|
362
|
+
label.replaceChildren();
|
|
363
|
+
const branch = currentRefs?.current;
|
|
364
|
+
|
|
365
|
+
if (data.mode === 'range' && data.range) {
|
|
366
|
+
const range = data.range;
|
|
367
|
+
const base = displayRef(range.baseName ?? range.base);
|
|
368
|
+
const head = displayRef(range.headName ?? range.head);
|
|
369
|
+
const files = data.files.length;
|
|
370
|
+
|
|
371
|
+
label.append(strong(base), ' → ', strong(head));
|
|
372
|
+
meta.textContent = files > 0
|
|
373
|
+
? `${plural(range.commits, 'commit')} · ${plural(files, 'file')} changed`
|
|
374
|
+
: `${plural(range.commits, 'commit')} · no files differ`;
|
|
375
|
+
|
|
376
|
+
// The empty cases first: they are the ones that need explaining, and an
|
|
377
|
+
// empty file list with no explanation reads as the tool having failed.
|
|
378
|
+
if (files === 0 && range.commits === 0) {
|
|
379
|
+
setNote(range.behind > 0
|
|
380
|
+
? `${head} has nothing that ${base} does not already have. Try swapping them to see what ${base} adds.`
|
|
381
|
+
: `${base} and ${head} are the same commit, so there is nothing between them.`);
|
|
382
|
+
} else if (files === 0) {
|
|
383
|
+
setNote(`Those ${plural(range.commits, 'commit')} undo each other: every change in them was reverted again before ${head}, so there is no difference left to review.`);
|
|
384
|
+
} else if (range.behind > 0) {
|
|
385
|
+
// The merge-base diff leaves these out on purpose, the way a pull
|
|
386
|
+
// request does. Say so, or someone goes looking for main's change and
|
|
387
|
+
// concludes the tool lost it.
|
|
388
|
+
setNote(`${base} has ${plural(range.behind, 'newer commit')} that ${range.behind === 1 ? 'is' : 'are'} not on ${head}. ${range.behind === 1 ? 'It is' : 'They are'} left out: this shows only what ${head} changes since the two diverged, as a pull request would.`);
|
|
389
|
+
} else {
|
|
390
|
+
setNote(null);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
row.classList.remove('hidden');
|
|
394
|
+
selectRef('base', range.baseName, range.base);
|
|
395
|
+
selectRef('head', range.headName, range.head);
|
|
396
|
+
toggle.classList.add('hidden');
|
|
397
|
+
reset.classList.remove('hidden');
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
if (data.mode === 'lastCommit') {
|
|
402
|
+
label.append('Last commit');
|
|
403
|
+
if (branch) label.append(' on ', strong(branch));
|
|
404
|
+
const tip = currentRefs?.commits?.[0];
|
|
405
|
+
meta.textContent = tip ? `${tip.subject} · ${tip.short}` : '';
|
|
406
|
+
} else {
|
|
407
|
+
label.append('Uncommitted changes');
|
|
408
|
+
if (branch) label.append(' on ', strong(branch));
|
|
409
|
+
meta.textContent = plural(data.files.length, 'file') + ' changed';
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
setNote(null);
|
|
413
|
+
row.classList.add('hidden');
|
|
414
|
+
toggle.classList.remove('hidden');
|
|
415
|
+
reset.classList.add('hidden');
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/** Empty the diff pane, so a finished comparison cannot leave the last one's file on screen. */
|
|
419
|
+
function clearCodePane() {
|
|
420
|
+
currentFile = null;
|
|
421
|
+
currentDiffLines = [];
|
|
422
|
+
document.getElementById('codeSection').classList.add('hidden');
|
|
423
|
+
document.getElementById('currentFile').textContent = '';
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/** What the file list says when a comparison has no files in it. */
|
|
427
|
+
function showEmptyFileList(message) {
|
|
428
|
+
const filesList = document.getElementById('filesList');
|
|
429
|
+
const empty = document.createElement('div');
|
|
430
|
+
empty.className = 'files-empty';
|
|
431
|
+
empty.textContent = message;
|
|
432
|
+
filesList.replaceChildren(empty);
|
|
433
|
+
}
|
|
434
|
+
|
|
192
435
|
/**
|
|
193
436
|
* Load a repository, optionally as a range between two commits.
|
|
194
437
|
*
|
|
195
|
-
* @param {{base?: string}} [scope] omit for the default:
|
|
196
|
-
* falling back to the last commit on a clean tree.
|
|
438
|
+
* @param {{base?: string, head?: string}} [scope] omit for the default:
|
|
439
|
+
* uncommitted changes, falling back to the last commit on a clean tree.
|
|
440
|
+
* `head` defaults to HEAD on the server.
|
|
197
441
|
*/
|
|
198
442
|
async function loadRepo(scope = {}) {
|
|
199
443
|
const repoPath = document.getElementById('repoPath').value.trim();
|
|
@@ -210,7 +454,9 @@ async function loadRepo(scope = {}) {
|
|
|
210
454
|
const response = await fetch(`${API_BASE}/load-repo`, {
|
|
211
455
|
method: 'POST',
|
|
212
456
|
headers: { 'Content-Type': 'application/json' },
|
|
213
|
-
body: JSON.stringify(scope.base
|
|
457
|
+
body: JSON.stringify(scope.base
|
|
458
|
+
? { repoPath, base: scope.base, ...(scope.head ? { head: scope.head } : {}) }
|
|
459
|
+
: { repoPath })
|
|
214
460
|
});
|
|
215
461
|
|
|
216
462
|
const data = await response.json();
|
|
@@ -230,6 +476,9 @@ async function loadRepo(scope = {}) {
|
|
|
230
476
|
setRepoButton(data.repoPath);
|
|
231
477
|
}
|
|
232
478
|
|
|
479
|
+
// Before renderScope, which needs the options present to select the
|
|
480
|
+
// current ends among them, and the current branch to name it.
|
|
481
|
+
await loadRefs(data.repoId);
|
|
233
482
|
renderScope(data);
|
|
234
483
|
|
|
235
484
|
if (data.files.length === 0) {
|
|
@@ -239,9 +488,16 @@ async function loadRepo(scope = {}) {
|
|
|
239
488
|
// that covers -- so this is information, not an error, and it must not
|
|
240
489
|
// be dressed as one.
|
|
241
490
|
if (data.mode === 'range') {
|
|
242
|
-
|
|
491
|
+
// The scope bar is already explaining this; a status line repeating
|
|
492
|
+
// it underneath is two bars saying one thing.
|
|
493
|
+
clearStatus();
|
|
243
494
|
hidePicker();
|
|
244
495
|
displayFiles([]);
|
|
496
|
+
showEmptyFileList('No files differ between these two.');
|
|
497
|
+
updateCommentsSidebar();
|
|
498
|
+
// Or the previous comparison's file stays on screen under a bar that
|
|
499
|
+
// says there are no files, and reads as part of this one.
|
|
500
|
+
clearCodePane();
|
|
245
501
|
document.getElementById('loadBtn').disabled = false;
|
|
246
502
|
return;
|
|
247
503
|
}
|
|
@@ -251,9 +507,15 @@ async function loadRepo(scope = {}) {
|
|
|
251
507
|
return;
|
|
252
508
|
}
|
|
253
509
|
|
|
254
|
-
|
|
510
|
+
// The scope bar above says what was loaded, in more useful terms than
|
|
511
|
+
// this line did ("Found 3 changed file(s)"), so there is nothing left
|
|
512
|
+
// for the status line to add on success.
|
|
513
|
+
clearStatus();
|
|
255
514
|
hidePicker();
|
|
256
515
|
displayFiles(data.files);
|
|
516
|
+
// Which comments are in view depends on which files are, so the sidebar
|
|
517
|
+
// is redrawn for every new scope, not only when saved comments arrive.
|
|
518
|
+
updateCommentsSidebar();
|
|
257
519
|
|
|
258
520
|
// Load saved comments
|
|
259
521
|
const savedComments = await loadCommentsFromBackend();
|
|
@@ -273,7 +535,7 @@ async function loadRepo(scope = {}) {
|
|
|
273
535
|
// Show comments sidebar immediately
|
|
274
536
|
updateCommentsSidebar();
|
|
275
537
|
|
|
276
|
-
showStatus(
|
|
538
|
+
showStatus(`Picked up ${plural(savedComments.length, 'saved comment')} from last time.`, 'success');
|
|
277
539
|
}
|
|
278
540
|
|
|
279
541
|
// Open something. A loaded repository used to sit behind an empty pane
|
|
@@ -300,6 +562,15 @@ async function loadRepo(scope = {}) {
|
|
|
300
562
|
}
|
|
301
563
|
|
|
302
564
|
// Display list of changed files
|
|
565
|
+
/** What each status letter means, for the badge's tooltip and screen readers. */
|
|
566
|
+
const FILE_STATUS_NAMES = {
|
|
567
|
+
A: 'Added',
|
|
568
|
+
M: 'Modified',
|
|
569
|
+
D: 'Deleted',
|
|
570
|
+
R: 'Renamed',
|
|
571
|
+
B: 'Binary'
|
|
572
|
+
};
|
|
573
|
+
|
|
303
574
|
function fileStatusClass(status) {
|
|
304
575
|
switch (status) {
|
|
305
576
|
case 'A': return 'file-status-added';
|
|
@@ -334,7 +605,12 @@ function displayFiles(files) {
|
|
|
334
605
|
? `${escapeHtml(path)}/<span class="filename">${escapeHtml(filename)}</span>`
|
|
335
606
|
: `<span class="filename">${escapeHtml(filename)}</span>`;
|
|
336
607
|
const statusClass = fileStatusClass(status);
|
|
337
|
-
|
|
608
|
+
// One letter is git's vocabulary, not everyone's. The name rides along as
|
|
609
|
+
// a tooltip and as the accessible label, so "D" is never the only thing
|
|
610
|
+
// saying that a file is gone. `status` is one of five letters the server
|
|
611
|
+
// produces, and escaped anyway: it is data that ends up in markup.
|
|
612
|
+
const statusName = FILE_STATUS_NAMES[status] ?? 'Changed';
|
|
613
|
+
const statusBadge = `<span class="file-status ${statusClass}" title="${statusName}" aria-label="${statusName}">${escapeHtml(status)}</span>`;
|
|
338
614
|
|
|
339
615
|
// The row used to carry the file name itself, inside a string literal
|
|
340
616
|
// inside the attribute the browser evaluates as JavaScript. A name with a
|
|
@@ -1325,10 +1601,19 @@ function resetApp() {
|
|
|
1325
1601
|
// Show status message
|
|
1326
1602
|
function showStatus(message, type) {
|
|
1327
1603
|
const statusEl = document.getElementById('status');
|
|
1328
|
-
|
|
1604
|
+
// No "Status:" prefix. The element is role="status", which is what tells a
|
|
1605
|
+
// screen reader what it is; sighted readers could already see that.
|
|
1606
|
+
statusEl.textContent = message;
|
|
1329
1607
|
statusEl.className = `status ${type}`;
|
|
1330
1608
|
}
|
|
1331
1609
|
|
|
1610
|
+
/** Hide the status line, when something else on screen is already saying it. */
|
|
1611
|
+
function clearStatus() {
|
|
1612
|
+
const statusEl = document.getElementById('status');
|
|
1613
|
+
statusEl.textContent = '';
|
|
1614
|
+
statusEl.className = 'status hidden';
|
|
1615
|
+
}
|
|
1616
|
+
|
|
1332
1617
|
// Track if we're in full context mode
|
|
1333
1618
|
let isFullContextMode = false;
|
|
1334
1619
|
let storedFullFileLines = null;
|
|
@@ -1639,14 +1924,24 @@ function updateCommentsSidebar() {
|
|
|
1639
1924
|
// anyway. A comment's identity here is its position in `comments`,
|
|
1640
1925
|
// and that is a number. See #46.
|
|
1641
1926
|
const commentIndex = comments.indexOf(comment);
|
|
1927
|
+
// A comment is kept across comparisons -- switch from main -> feature
|
|
1928
|
+
// back to the last commit and a note on a file only the branch
|
|
1929
|
+
// touched is still yours. It just is not on screen, and a sidebar
|
|
1930
|
+
// entry that looks attached but does nothing when clicked reads as
|
|
1931
|
+
// lost. So it says where it stands.
|
|
1932
|
+
const elsewhere = !currentFiles.some(f => f.path === file);
|
|
1933
|
+
const elsewhereHtml = elsewhere
|
|
1934
|
+
? '<div class="comment-item-elsewhere-note">Not in this view</div>'
|
|
1935
|
+
: '';
|
|
1642
1936
|
html += `
|
|
1643
|
-
<div class="comment-item">
|
|
1937
|
+
<div class="comment-item${elsewhere ? ' comment-item-elsewhere' : ''}">
|
|
1644
1938
|
<button class="comment-item-delete" onclick="event.stopPropagation(); deleteCommentFromSidebar(${Number(commentIndex)})" title="Delete comment">×</button>
|
|
1645
1939
|
<div class="comment-item-content" onclick="jumpToComment(${Number(commentIndex)})">
|
|
1646
1940
|
<div class="comment-item-file">${escapeHtml(file)}</div>
|
|
1647
1941
|
<div class="comment-item-line">Line ${comment.line}</div>
|
|
1648
1942
|
<div class="comment-item-text">${escapeHtml(comment.text)}</div>
|
|
1649
1943
|
${selectedHtml}
|
|
1944
|
+
${elsewhereHtml}
|
|
1650
1945
|
</div>
|
|
1651
1946
|
</div>
|
|
1652
1947
|
`;
|
|
@@ -1686,7 +1981,17 @@ function jumpToComment(commentIndex) {
|
|
|
1686
1981
|
|
|
1687
1982
|
// Find the file index
|
|
1688
1983
|
const fileIndex = currentFiles.findIndex(f => f.path === file);
|
|
1689
|
-
if (fileIndex === -1)
|
|
1984
|
+
if (fileIndex === -1) {
|
|
1985
|
+
// This used to return without a word, so clicking a comment did nothing
|
|
1986
|
+
// and looked broken. The comment is safe; the file is just not part of
|
|
1987
|
+
// what is being compared right now.
|
|
1988
|
+
showStatus(
|
|
1989
|
+
`${file} is not in the files you are looking at now, so this comment cannot be shown in place. ` +
|
|
1990
|
+
'It is kept, and will be back in place in any view that includes that file.',
|
|
1991
|
+
'loading'
|
|
1992
|
+
);
|
|
1993
|
+
return;
|
|
1994
|
+
}
|
|
1690
1995
|
|
|
1691
1996
|
// Load the file
|
|
1692
1997
|
loadFile(file, fileIndex).then(() => {
|
|
@@ -1827,11 +2132,13 @@ document.addEventListener('DOMContentLoaded', () => {
|
|
|
1827
2132
|
}
|
|
1828
2133
|
});
|
|
1829
2134
|
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
2135
|
+
for (const end of ['base', 'head']) {
|
|
2136
|
+
document.getElementById(PICKERS[end].other).addEventListener('keypress', (e) => {
|
|
2137
|
+
if (e.key === 'Enter') {
|
|
2138
|
+
compareNow();
|
|
2139
|
+
}
|
|
2140
|
+
});
|
|
2141
|
+
}
|
|
1835
2142
|
|
|
1836
2143
|
// Initialize resize handle
|
|
1837
2144
|
initResizeHandle();
|
package/public/index.html
CHANGED
|
@@ -53,22 +53,42 @@
|
|
|
53
53
|
</button>
|
|
54
54
|
</div>
|
|
55
55
|
</div>
|
|
56
|
-
<div id="status" class="status"></div>
|
|
57
|
-
|
|
58
|
-
<div id="scopeBar" class="scope-bar hidden">
|
|
59
|
-
<
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
56
|
+
<div id="status" class="status hidden" role="status" aria-live="polite"></div>
|
|
57
|
+
|
|
58
|
+
<div id="scopeBar" class="scope-bar hidden" role="region" aria-label="What is being reviewed">
|
|
59
|
+
<div class="scope-summary">
|
|
60
|
+
<div class="scope-text">
|
|
61
|
+
<span id="scopeLabel" class="scope-label"></span>
|
|
62
|
+
<span id="scopeMeta" class="scope-meta"></span>
|
|
63
|
+
</div>
|
|
64
|
+
<div class="scope-actions">
|
|
65
|
+
<button id="compareToggle" class="scope-action" onclick="openCompare()">Compare branches</button>
|
|
66
|
+
<button id="scopeResetBtn" class="scope-action hidden" onclick="resetScope()">Stop comparing</button>
|
|
67
|
+
</div>
|
|
68
|
+
</div>
|
|
69
|
+
|
|
70
|
+
<!-- GitHub's vocabulary, deliberately: "base" and "compare" are what
|
|
71
|
+
the people using this already call the two ends of a pull request. -->
|
|
72
|
+
<div id="compareRow" class="compare-row hidden">
|
|
73
|
+
<div class="compare-end">
|
|
74
|
+
<label for="compareBaseSelect">Base</label>
|
|
75
|
+
<select id="compareBaseSelect" onchange="onPickerChange('base')"></select>
|
|
76
|
+
<input id="compareBaseOther" class="compare-other hidden" type="text"
|
|
77
|
+
placeholder="tag, HEAD~5 or sha" spellcheck="false" autocomplete="off"
|
|
78
|
+
aria-label="Base ref">
|
|
79
|
+
</div>
|
|
80
|
+
<button id="compareSwap" class="compare-swap" onclick="swapCompare()"
|
|
81
|
+
title="Swap base and compare" aria-label="Swap base and compare">⇄</button>
|
|
82
|
+
<div class="compare-end">
|
|
83
|
+
<label for="compareHeadSelect">Compare</label>
|
|
84
|
+
<select id="compareHeadSelect" onchange="onPickerChange('head')"></select>
|
|
85
|
+
<input id="compareHeadOther" class="compare-other hidden" type="text"
|
|
86
|
+
placeholder="tag, HEAD~5 or sha" spellcheck="false" autocomplete="off"
|
|
87
|
+
aria-label="Compare ref">
|
|
88
|
+
</div>
|
|
71
89
|
</div>
|
|
90
|
+
|
|
91
|
+
<p id="scopeNote" class="scope-note hidden"></p>
|
|
72
92
|
</div>
|
|
73
93
|
</header>
|
|
74
94
|
|
package/public/style.css
CHANGED
|
@@ -1878,60 +1878,197 @@ button:disabled {
|
|
|
1878
1878
|
text-align: center;
|
|
1879
1879
|
}
|
|
1880
1880
|
|
|
1881
|
-
/*
|
|
1881
|
+
/* ---------------------------------------------------------------------------
|
|
1882
|
+
* What the review is of, and how to change it.
|
|
1882
1883
|
*
|
|
1883
|
-
* Its own
|
|
1884
|
-
* controls: choosing a repository and choosing which of
|
|
1885
|
-
*
|
|
1886
|
-
* one long form.
|
|
1884
|
+
* Its own block under the status line rather than beside the repository
|
|
1885
|
+
* controls: choosing a repository and choosing which two points of it to look
|
|
1886
|
+
* between are different decisions, and crowding them into one row made the
|
|
1887
|
+
* header read as one long form.
|
|
1888
|
+
* ------------------------------------------------------------------------ */
|
|
1889
|
+
|
|
1887
1890
|
.scope-bar {
|
|
1891
|
+
margin-top: 8px;
|
|
1892
|
+
padding: 10px 12px;
|
|
1893
|
+
border-radius: 6px;
|
|
1894
|
+
background: #f6f8fa;
|
|
1895
|
+
border: 1px solid #e1e4e8;
|
|
1896
|
+
font-size: 13px;
|
|
1897
|
+
color: #24292e;
|
|
1898
|
+
}
|
|
1899
|
+
|
|
1900
|
+
.scope-summary {
|
|
1888
1901
|
display: flex;
|
|
1889
1902
|
align-items: center;
|
|
1890
1903
|
justify-content: space-between;
|
|
1891
1904
|
flex-wrap: wrap;
|
|
1892
1905
|
gap: 8px 16px;
|
|
1893
|
-
margin-top: 8px;
|
|
1894
|
-
padding: 8px 12px;
|
|
1895
|
-
border-radius: 6px;
|
|
1896
|
-
background: #f3f4f6;
|
|
1897
|
-
border-left: 3px solid #6a737d;
|
|
1898
|
-
font-size: 13px;
|
|
1899
1906
|
}
|
|
1900
1907
|
|
|
1901
|
-
/* The scope reads as a sentence
|
|
1902
|
-
*
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1908
|
+
/* The scope reads as a sentence -- "main → feature/auth" -- rather than as a
|
|
1909
|
+
* labelled field, because the names are the information. */
|
|
1910
|
+
.scope-text {
|
|
1911
|
+
display: flex;
|
|
1912
|
+
align-items: baseline;
|
|
1913
|
+
flex-wrap: wrap;
|
|
1914
|
+
gap: 4px 12px;
|
|
1906
1915
|
min-width: 0;
|
|
1907
1916
|
}
|
|
1908
1917
|
|
|
1909
|
-
.scope-
|
|
1918
|
+
.scope-label {
|
|
1919
|
+
font-size: 14px;
|
|
1920
|
+
overflow-wrap: anywhere;
|
|
1921
|
+
}
|
|
1922
|
+
|
|
1923
|
+
.scope-label strong {
|
|
1924
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
1925
|
+
font-size: 13px;
|
|
1926
|
+
font-weight: 600;
|
|
1927
|
+
padding: 1px 6px;
|
|
1928
|
+
border-radius: 4px;
|
|
1929
|
+
background: #fff;
|
|
1930
|
+
border: 1px solid #d1d5da;
|
|
1931
|
+
}
|
|
1932
|
+
|
|
1933
|
+
.scope-meta {
|
|
1934
|
+
color: #586069;
|
|
1935
|
+
}
|
|
1936
|
+
|
|
1937
|
+
.scope-actions {
|
|
1910
1938
|
display: flex;
|
|
1911
|
-
align-items: center;
|
|
1912
1939
|
gap: 8px;
|
|
1913
1940
|
flex-shrink: 0;
|
|
1914
1941
|
}
|
|
1915
1942
|
|
|
1916
|
-
.
|
|
1917
|
-
|
|
1918
|
-
|
|
1943
|
+
/* Secondary buttons. The :hover rule is not optional: `button:hover` sets the
|
|
1944
|
+
* primary blue and outranks a single class on specificity, so a secondary
|
|
1945
|
+
* button without its own hover rule turns into dark text on dark blue the
|
|
1946
|
+
* moment the pointer reaches it. That shipped once, on "Back to latest". */
|
|
1947
|
+
.scope-action,
|
|
1948
|
+
.compare-swap {
|
|
1949
|
+
background: #fff;
|
|
1950
|
+
color: #24292e;
|
|
1951
|
+
border: 1px solid #d1d5da;
|
|
1952
|
+
padding: 5px 12px;
|
|
1953
|
+
font-size: 13px;
|
|
1919
1954
|
}
|
|
1920
1955
|
|
|
1921
|
-
.scope-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1956
|
+
.scope-action:hover,
|
|
1957
|
+
.compare-swap:hover {
|
|
1958
|
+
background: #f3f4f6;
|
|
1959
|
+
border-color: #b8bec6;
|
|
1925
1960
|
}
|
|
1926
1961
|
|
|
1927
|
-
.scope-
|
|
1928
|
-
|
|
1962
|
+
.scope-action:focus-visible,
|
|
1963
|
+
.compare-swap:focus-visible,
|
|
1964
|
+
.compare-end select:focus-visible,
|
|
1965
|
+
.compare-other:focus-visible {
|
|
1966
|
+
outline: 2px solid #0366d6;
|
|
1967
|
+
outline-offset: 1px;
|
|
1968
|
+
}
|
|
1969
|
+
|
|
1970
|
+
.compare-row {
|
|
1971
|
+
display: flex;
|
|
1972
|
+
align-items: flex-end;
|
|
1973
|
+
flex-wrap: wrap;
|
|
1974
|
+
gap: 8px 10px;
|
|
1975
|
+
margin-top: 10px;
|
|
1976
|
+
padding-top: 10px;
|
|
1977
|
+
border-top: 1px solid #e1e4e8;
|
|
1978
|
+
}
|
|
1979
|
+
|
|
1980
|
+
.compare-end {
|
|
1981
|
+
display: flex;
|
|
1982
|
+
flex-direction: column;
|
|
1983
|
+
gap: 4px;
|
|
1984
|
+
flex: 1 1 240px;
|
|
1985
|
+
min-width: 0;
|
|
1986
|
+
}
|
|
1987
|
+
|
|
1988
|
+
.compare-end label {
|
|
1989
|
+
font-size: 11px;
|
|
1990
|
+
font-weight: 600;
|
|
1991
|
+
text-transform: uppercase;
|
|
1992
|
+
letter-spacing: 0.04em;
|
|
1993
|
+
color: #586069;
|
|
1994
|
+
}
|
|
1995
|
+
|
|
1996
|
+
.compare-end select,
|
|
1997
|
+
.compare-other {
|
|
1998
|
+
width: 100%;
|
|
1999
|
+
min-width: 0;
|
|
2000
|
+
font-size: 13px;
|
|
2001
|
+
padding: 6px 8px;
|
|
1929
2002
|
border: 1px solid #d1d5da;
|
|
2003
|
+
border-radius: 6px;
|
|
2004
|
+
background: #fff;
|
|
1930
2005
|
color: #24292e;
|
|
1931
2006
|
}
|
|
1932
2007
|
|
|
2008
|
+
.compare-other {
|
|
2009
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
2010
|
+
font-size: 12px;
|
|
2011
|
+
}
|
|
2012
|
+
|
|
2013
|
+
.compare-swap {
|
|
2014
|
+
flex: 0 0 auto;
|
|
2015
|
+
font-size: 16px;
|
|
2016
|
+
line-height: 1;
|
|
2017
|
+
padding: 7px 10px;
|
|
2018
|
+
}
|
|
2019
|
+
|
|
2020
|
+
/* Why the file list looks the way it does. Its own line, in the same quiet
|
|
2021
|
+
* voice as the meta text: this is explanation, not a warning. An empty
|
|
2022
|
+
* comparison is a real answer, and it must not be dressed as an error. */
|
|
2023
|
+
.scope-note {
|
|
2024
|
+
margin: 10px 0 0;
|
|
2025
|
+
color: #586069;
|
|
2026
|
+
line-height: 1.45;
|
|
2027
|
+
}
|
|
2028
|
+
|
|
2029
|
+
.files-empty {
|
|
2030
|
+
padding: 16px 12px;
|
|
2031
|
+
color: #586069;
|
|
2032
|
+
font-size: 13px;
|
|
2033
|
+
line-height: 1.45;
|
|
2034
|
+
}
|
|
2035
|
+
|
|
2036
|
+
/* Narrow screens: the two ends stack, and the swap button sits between them
|
|
2037
|
+
* rather than overflowing the bar -- which the single-row version did, at
|
|
2038
|
+
* 760px, with the Compare button hanging off the right edge. */
|
|
1933
2039
|
@media (max-width: 720px) {
|
|
1934
|
-
.
|
|
1935
|
-
|
|
2040
|
+
.compare-row {
|
|
2041
|
+
flex-direction: column;
|
|
2042
|
+
align-items: stretch;
|
|
1936
2043
|
}
|
|
2044
|
+
|
|
2045
|
+
.compare-end {
|
|
2046
|
+
flex: 1 1 auto;
|
|
2047
|
+
}
|
|
2048
|
+
|
|
2049
|
+
.compare-swap {
|
|
2050
|
+
align-self: center;
|
|
2051
|
+
transform: rotate(90deg);
|
|
2052
|
+
}
|
|
2053
|
+
|
|
2054
|
+
.scope-actions {
|
|
2055
|
+
width: 100%;
|
|
2056
|
+
}
|
|
2057
|
+
}
|
|
2058
|
+
|
|
2059
|
+
/* A comment on a file that is not in the current comparison. Kept, not
|
|
2060
|
+
* lost -- dimmed so it reads as "elsewhere", with a line saying so, because a
|
|
2061
|
+
* sidebar entry that looks attached but goes nowhere when clicked reads as a
|
|
2062
|
+
* bug. */
|
|
2063
|
+
.comment-item-elsewhere {
|
|
2064
|
+
opacity: 0.72;
|
|
2065
|
+
}
|
|
2066
|
+
|
|
2067
|
+
.comment-item-elsewhere-note {
|
|
2068
|
+
margin-top: 6px;
|
|
2069
|
+
font-size: 11px;
|
|
2070
|
+
font-weight: 600;
|
|
2071
|
+
text-transform: uppercase;
|
|
2072
|
+
letter-spacing: 0.04em;
|
|
2073
|
+
color: #6a737d;
|
|
1937
2074
|
}
|
package/server.js
CHANGED
|
@@ -12,7 +12,7 @@ const {
|
|
|
12
12
|
resolveRepoFile,
|
|
13
13
|
PathEscapeError
|
|
14
14
|
} = require('./lib/paths');
|
|
15
|
-
const { collectWorkingChanges, collectCommitChanges } = require('./lib/changes');
|
|
15
|
+
const { collectWorkingChanges, collectCommitChanges, parseNameStatus } = require('./lib/changes');
|
|
16
16
|
const { SessionStore } = require('./lib/sessions');
|
|
17
17
|
const { normalizeComments } = require('./lib/comments');
|
|
18
18
|
const { formatReview, reviewFilename, commentsFilename } = require('./lib/review');
|
|
@@ -62,6 +62,17 @@ class FileNotFoundError extends Error {
|
|
|
62
62
|
* leaves before deciding it is not coming back.
|
|
63
63
|
* @returns {import('express').Express}
|
|
64
64
|
*/
|
|
65
|
+
// How far back the range picker offers to go. Long enough to cover the work
|
|
66
|
+
// someone is plausibly still reviewing, short enough that the list is a list
|
|
67
|
+
// rather than the whole history -- anything older is still reachable by
|
|
68
|
+
// typing a ref, which is what the text box is for.
|
|
69
|
+
const COMMIT_LIST_LIMIT = 30;
|
|
70
|
+
|
|
71
|
+
// Branches and tags offered per group. A repository with two hundred stale
|
|
72
|
+
// branches should still produce a list someone can scan; the ones past the
|
|
73
|
+
// cut are the least recently touched and are still reachable by typing.
|
|
74
|
+
const REF_LIST_LIMIT = 40;
|
|
75
|
+
|
|
65
76
|
function createApp(options = {}) {
|
|
66
77
|
const {
|
|
67
78
|
reviewsDir = resolveReviewsDir(),
|
|
@@ -122,10 +133,18 @@ function createApp(options = {}) {
|
|
|
122
133
|
const absolutePath = resolveRepoFile(session.repoPath, filePath);
|
|
123
134
|
|
|
124
135
|
if (session.range) {
|
|
136
|
+
// A range is two commits, and the working tree is neither of them. It
|
|
137
|
+
// used to be the fallback here, which was merely odd while the head of
|
|
138
|
+
// every range was HEAD and is wrong now that it can be another branch:
|
|
139
|
+
// Full Context on feature/billing, opened from a checkout of
|
|
140
|
+
// feature/auth, would show auth's copy of the file under billing's name.
|
|
141
|
+
//
|
|
142
|
+
// A path missing at the head was deleted inside the range, and the text
|
|
143
|
+
// worth showing is the text that was deleted.
|
|
125
144
|
try {
|
|
126
145
|
return await git.show([`${session.range.head}:${filePath}`]);
|
|
127
146
|
} catch {
|
|
128
|
-
return
|
|
147
|
+
return git.show([`${session.range.from}:${filePath}`]).catch(() => '');
|
|
129
148
|
}
|
|
130
149
|
}
|
|
131
150
|
|
|
@@ -199,7 +218,7 @@ function createApp(options = {}) {
|
|
|
199
218
|
|
|
200
219
|
async function diffForFile(git, session, filePath) {
|
|
201
220
|
if (session.range) {
|
|
202
|
-
return git.diff([session.range.
|
|
221
|
+
return git.diff([session.range.from, session.range.head, '--', filePath]);
|
|
203
222
|
}
|
|
204
223
|
if (!(await hasCommits(git))) {
|
|
205
224
|
return '';
|
|
@@ -245,7 +264,7 @@ function createApp(options = {}) {
|
|
|
245
264
|
}
|
|
246
265
|
}
|
|
247
266
|
|
|
248
|
-
const resolve = async
|
|
267
|
+
const resolve = async ref => {
|
|
249
268
|
try {
|
|
250
269
|
return (await git.revparse([`${ref}^{commit}`])).trim();
|
|
251
270
|
} catch {
|
|
@@ -253,19 +272,67 @@ function createApp(options = {}) {
|
|
|
253
272
|
}
|
|
254
273
|
};
|
|
255
274
|
|
|
256
|
-
const baseSha = await resolve(
|
|
257
|
-
const headSha = await resolve(
|
|
258
|
-
|
|
259
|
-
//
|
|
260
|
-
//
|
|
261
|
-
//
|
|
262
|
-
//
|
|
263
|
-
//
|
|
264
|
-
|
|
265
|
-
|
|
275
|
+
const baseSha = await resolve(base);
|
|
276
|
+
const headSha = await resolve(head);
|
|
277
|
+
|
|
278
|
+
// The diff starts at the merge base, not at `base` itself -- what a pull
|
|
279
|
+
// request shows, and for the same reason. Compare feature/auth against a
|
|
280
|
+
// main that has moved on since the branch was cut, and a plain
|
|
281
|
+
// `git diff main feature/auth` includes main's newer commits *reversed*:
|
|
282
|
+
// the review then shows the feature author "undoing" a change they never
|
|
283
|
+
// touched, and a reviewer comments on it. On straight-line history the
|
|
284
|
+
// merge base of an ancestor and its descendant is the ancestor, so this
|
|
285
|
+
// changes nothing for x1..x5.
|
|
286
|
+
//
|
|
287
|
+
// Unrelated histories have no merge base; the plain two-ended diff is the
|
|
288
|
+
// only answer there, and it is the one given.
|
|
289
|
+
const from = await git.raw(['merge-base', baseSha, headSha])
|
|
290
|
+
.then(out => out.trim() || baseSha)
|
|
291
|
+
.catch(() => baseSha);
|
|
292
|
+
|
|
293
|
+
// `base..head` is what head has and base does not -- exactly the commits
|
|
294
|
+
// whose changes are in the diff. `head..base` is the other direction: what
|
|
295
|
+
// base has moved on by, which the merge-base diff deliberately leaves out
|
|
296
|
+
// and which the page has to say it left out, or someone will go looking
|
|
297
|
+
// for main's change and conclude the tool lost it.
|
|
298
|
+
const count = async range => Number(
|
|
299
|
+
(await git.raw(['rev-list', '--count', range])).trim()
|
|
266
300
|
);
|
|
301
|
+
const commits = await count(`${baseSha}..${headSha}`);
|
|
302
|
+
const behind = await count(`${headSha}..${baseSha}`);
|
|
303
|
+
|
|
304
|
+
// What the person asked for, kept alongside the SHAs. Nobody chose
|
|
305
|
+
// `3eb57959`; they chose `main`, and a scope bar that answers in SHAs has
|
|
306
|
+
// translated their question into one they did not ask.
|
|
307
|
+
const nameOf = async ref => {
|
|
308
|
+
if (ref !== 'HEAD') return ref;
|
|
309
|
+
const branch = await git.revparse(['--abbrev-ref', 'HEAD']).then(b => b.trim()).catch(() => 'HEAD');
|
|
310
|
+
return branch === 'HEAD' ? headSha.slice(0, 8) : branch;
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
return {
|
|
314
|
+
base: baseSha,
|
|
315
|
+
head: headSha,
|
|
316
|
+
from,
|
|
317
|
+
commits: Number.isFinite(commits) ? commits : 0,
|
|
318
|
+
behind: Number.isFinite(behind) ? behind : 0,
|
|
319
|
+
baseName: await nameOf(base),
|
|
320
|
+
headName: await nameOf(head)
|
|
321
|
+
};
|
|
322
|
+
}
|
|
267
323
|
|
|
268
|
-
|
|
324
|
+
/**
|
|
325
|
+
* The changed files of a range, with the status git gives each one.
|
|
326
|
+
*
|
|
327
|
+
* @param {import('simple-git').SimpleGit} git
|
|
328
|
+
* @param {{from: string, head: string}} range
|
|
329
|
+
*/
|
|
330
|
+
async function collectRangeChanges(git, range) {
|
|
331
|
+
const [summary, nameStatus] = await Promise.all([
|
|
332
|
+
git.diffSummary([range.from, range.head]),
|
|
333
|
+
git.raw(['diff', '--name-status', '-z', '-M', range.from, range.head])
|
|
334
|
+
]);
|
|
335
|
+
return collectCommitChanges(summary, parseNameStatus(nameStatus));
|
|
269
336
|
}
|
|
270
337
|
|
|
271
338
|
/**
|
|
@@ -282,17 +349,134 @@ function createApp(options = {}) {
|
|
|
282
349
|
* @returns {string}
|
|
283
350
|
*/
|
|
284
351
|
function describeRange(range, fileCount) {
|
|
285
|
-
const
|
|
352
|
+
const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`;
|
|
353
|
+
const ends = `${range.baseName ?? range.base.slice(0, 8)} \u2192 ${range.headName ?? range.head.slice(0, 8)}`;
|
|
354
|
+
|
|
355
|
+
if (fileCount > 0) {
|
|
356
|
+
return `${ends}: ${plural(range.commits, 'commit')}, ${plural(fileCount, 'changed file')}`;
|
|
357
|
+
}
|
|
286
358
|
|
|
287
|
-
if (
|
|
288
|
-
return range.
|
|
289
|
-
?
|
|
290
|
-
: `${
|
|
359
|
+
if (range.commits === 0) {
|
|
360
|
+
return range.behind === 0
|
|
361
|
+
? `${ends}: both point at the same commit, so there is nothing between them.`
|
|
362
|
+
: `${ends}: ${range.headName ?? 'the head'} has no commits that ${range.baseName ?? 'the base'} does not already have.`;
|
|
291
363
|
}
|
|
292
364
|
|
|
293
|
-
return `${
|
|
365
|
+
return `${ends}: ${plural(range.commits, 'commit')}, and no net change \u2014 every change in them was undone again inside the range.`;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Recent commits, so a range can be picked instead of typed.
|
|
370
|
+
*
|
|
371
|
+
* Nobody knows the SHA of the commit they want to review back to, and
|
|
372
|
+
* asking for one is asking the reviewer to go and run `git log` in another
|
|
373
|
+
* window to use a tool whose whole point is not having to.
|
|
374
|
+
*
|
|
375
|
+
* Each entry is what a person recognises a commit by -- the subject line
|
|
376
|
+
* and when it landed -- with the SHA carried alongside rather than being
|
|
377
|
+
* the thing they have to read.
|
|
378
|
+
*
|
|
379
|
+
* `%x00` separates the fields because a commit subject can contain
|
|
380
|
+
* anything a person can type, including whatever delimiter looked safe.
|
|
381
|
+
*
|
|
382
|
+
* @param {import('simple-git').SimpleGit} git
|
|
383
|
+
* @param {number} limit
|
|
384
|
+
*/
|
|
385
|
+
async function recentCommits(git, limit) {
|
|
386
|
+
const raw = await git.raw([
|
|
387
|
+
'log', `--max-count=${limit}`, '--format=%H%x00%h%x00%s%x00%cr'
|
|
388
|
+
]);
|
|
389
|
+
|
|
390
|
+
return raw
|
|
391
|
+
.split('\n')
|
|
392
|
+
.filter(line => line.trim() !== '')
|
|
393
|
+
.map(line => {
|
|
394
|
+
const [sha, short, subject, when] = line.split('\u0000');
|
|
395
|
+
return { sha, short, subject, when };
|
|
396
|
+
});
|
|
294
397
|
}
|
|
295
398
|
|
|
399
|
+
/**
|
|
400
|
+
* The commits of an open repository, newest first.
|
|
401
|
+
*
|
|
402
|
+
* Behind a session id like the file endpoints rather than taking a path:
|
|
403
|
+
* this reads history out of a repository, and the session is what says
|
|
404
|
+
* which repository the caller has already been granted.
|
|
405
|
+
*/
|
|
406
|
+
/**
|
|
407
|
+
* Everything the compare pickers offer: branches, tags and recent commits.
|
|
408
|
+
*
|
|
409
|
+
* Branches come first in the page because comparing branches is what people
|
|
410
|
+
* reach for -- "what does my branch change against main" -- and a picker
|
|
411
|
+
* that only lists commits of the current branch makes that the one
|
|
412
|
+
* comparison you cannot pick. Remote-tracking branches are included because
|
|
413
|
+
* `origin/main` is the base most reviews actually want, and a local `main`
|
|
414
|
+
* that has not been pulled in a week is a quietly wrong one.
|
|
415
|
+
*
|
|
416
|
+
* Sorted by most recent activity, so the branch you are working on and the
|
|
417
|
+
* one you branched from are near the top rather than alphabetised into the
|
|
418
|
+
* middle of forty others.
|
|
419
|
+
*/
|
|
420
|
+
app.get('/api/refs/:repoId', async (req, res) => {
|
|
421
|
+
const session = requireSession(res, req.params.repoId);
|
|
422
|
+
if (!session) return;
|
|
423
|
+
|
|
424
|
+
const git = gitFactory(session.repoPath);
|
|
425
|
+
const refList = async (pattern, limit) => {
|
|
426
|
+
try {
|
|
427
|
+
const raw = await git.raw([
|
|
428
|
+
'for-each-ref', `--count=${limit}`, '--sort=-committerdate',
|
|
429
|
+
'--format=%(refname:short)%00%(objectname)%00%(committerdate:relative)%00%(subject)%00%(symref)',
|
|
430
|
+
pattern
|
|
431
|
+
]);
|
|
432
|
+
return raw.split('\n').filter(Boolean).map(line => {
|
|
433
|
+
const [name, sha, when, subject, symref] = line.split('\u0000');
|
|
434
|
+
return { name, sha, when, subject, symref };
|
|
435
|
+
})
|
|
436
|
+
// A symbolic ref points at another ref rather than being one --
|
|
437
|
+
// `origin/HEAD` in every clone. It cannot be filtered by name:
|
|
438
|
+
// `refname:short` abbreviates it to plain `origin`, which is how it
|
|
439
|
+
// first slipped into the list as a "branch".
|
|
440
|
+
.filter(ref => !ref.symref)
|
|
441
|
+
.map(({ symref, ...ref }) => ref);
|
|
442
|
+
} catch {
|
|
443
|
+
return [];
|
|
444
|
+
}
|
|
445
|
+
};
|
|
446
|
+
|
|
447
|
+
const current = await git.revparse(['--abbrev-ref', 'HEAD']).then(b => b.trim()).catch(() => null);
|
|
448
|
+
const [branches, remotes, tags, commits] = await Promise.all([
|
|
449
|
+
refList('refs/heads', REF_LIST_LIMIT),
|
|
450
|
+
refList('refs/remotes', REF_LIST_LIMIT),
|
|
451
|
+
refList('refs/tags', REF_LIST_LIMIT),
|
|
452
|
+
recentCommits(git, COMMIT_LIST_LIMIT).catch(() => [])
|
|
453
|
+
]);
|
|
454
|
+
|
|
455
|
+
res.json({
|
|
456
|
+
// `HEAD` from --abbrev-ref means detached; say so rather than pretend
|
|
457
|
+
// there is a branch called HEAD.
|
|
458
|
+
current: current === 'HEAD' ? null : current,
|
|
459
|
+
branches,
|
|
460
|
+
remotes,
|
|
461
|
+
tags,
|
|
462
|
+
commits
|
|
463
|
+
});
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
app.get('/api/commits/:repoId', async (req, res) => {
|
|
467
|
+
try {
|
|
468
|
+
const session = requireSession(res, req.params.repoId);
|
|
469
|
+
if (!session) return;
|
|
470
|
+
|
|
471
|
+
const commits = await recentCommits(gitFactory(session.repoPath), COMMIT_LIST_LIMIT);
|
|
472
|
+
res.json({ commits });
|
|
473
|
+
} catch (error) {
|
|
474
|
+
// A repository with no commits is not an error, it is an empty list.
|
|
475
|
+
console.error('Commit list error:', error.message);
|
|
476
|
+
res.json({ commits: [] });
|
|
477
|
+
}
|
|
478
|
+
});
|
|
479
|
+
|
|
296
480
|
/** Liveness probe, and a count of how many repositories are open. */
|
|
297
481
|
app.get('/api/health', (req, res) => {
|
|
298
482
|
res.json({ status: 'ok', sessions: sessions.size });
|
|
@@ -447,7 +631,7 @@ function createApp(options = {}) {
|
|
|
447
631
|
return res.status(400).json({ error: error.message });
|
|
448
632
|
}
|
|
449
633
|
|
|
450
|
-
files =
|
|
634
|
+
files = await collectRangeChanges(git, range);
|
|
451
635
|
mode = 'range';
|
|
452
636
|
message = describeRange(range, files.length);
|
|
453
637
|
} else {
|
|
@@ -459,9 +643,7 @@ function createApp(options = {}) {
|
|
|
459
643
|
if (files.length === 0) {
|
|
460
644
|
try {
|
|
461
645
|
const lastCommit = await resolveRange(git, 'HEAD~1', 'HEAD');
|
|
462
|
-
const commitFiles =
|
|
463
|
-
await git.diffSummary([lastCommit.base, lastCommit.head])
|
|
464
|
-
);
|
|
646
|
+
const commitFiles = await collectRangeChanges(git, lastCommit);
|
|
465
647
|
if (commitFiles.length > 0) {
|
|
466
648
|
files = commitFiles;
|
|
467
649
|
mode = 'lastCommit';
|