git-reviewer 2.1.0 → 2.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/public/index.html CHANGED
@@ -12,12 +12,35 @@
12
12
  <div class="header-content">
13
13
  <h1>Code Reviewer</h1>
14
14
  <div class="header-actions">
15
- <input
16
- type="text"
17
- id="repoPath"
18
- placeholder="Enter local repository path..."
19
- value="">
20
- <button id="loadBtn" onclick="loadRepo()">Load Repository</button>
15
+ <button id="repoButton" class="repo-button" onclick="showPicker()">
16
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
17
+ <path d="M1.75 1.5a.25.25 0 00-.25.25v11.5c0 .138.112.25.25.25h12.5a.25.25 0 00.25-.25v-8.5a.25.25 0 00-.25-.25H7.5a.75.75 0 01-.6-.3L5.55 2.4a.25.25 0 00-.2-.1H1.75z"></path>
18
+ </svg>
19
+ <span id="repoButtonLabel">Open a repository</span>
20
+ </button>
21
+
22
+ <button
23
+ id="pathToggle"
24
+ class="icon-button"
25
+ onclick="togglePathEntry()"
26
+ title="Type or paste a path instead"
27
+ aria-label="Type or paste a path instead"
28
+ aria-expanded="false">
29
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
30
+ <path d="M1.75 3h12.5c.966 0 1.75.784 1.75 1.75v6.5A1.75 1.75 0 0114.25 13H1.75A1.75 1.75 0 010 11.25v-6.5C0 3.784.784 3 1.75 3zm0 1.5a.25.25 0 00-.25.25v6.5c0 .138.112.25.25.25h12.5a.25.25 0 00.25-.25v-6.5a.25.25 0 00-.25-.25H1.75zM3 6h1.5v1.5H3V6zm2.75 0h1.5v1.5h-1.5V6zM8.5 6H10v1.5H8.5V6zm2.75 0h1.5v1.5h-1.5V6zM3 8.75h1.5v1.5H3v-1.5zm2.75 0h4.5v1.5h-4.5v-1.5zm5.75 0H13v1.5h-1.5v-1.5z"></path>
31
+ </svg>
32
+ </button>
33
+
34
+ <div id="pathEntry" class="path-entry hidden">
35
+ <input
36
+ type="text"
37
+ id="repoPath"
38
+ placeholder="/path/to/repository"
39
+ spellcheck="false"
40
+ autocomplete="off"
41
+ value="">
42
+ <button id="loadBtn" onclick="loadRepo()">Load</button>
43
+ </div>
21
44
  <button id="submitReviewBtn" onclick="submitReview()" class="submit-btn hidden">
22
45
  Submit Review
23
46
  </button>
@@ -27,6 +50,37 @@
27
50
  </header>
28
51
 
29
52
  <div class="main-container">
53
+ <section id="picker" class="picker">
54
+ <div class="picker-card">
55
+ <div class="picker-heading">
56
+ <h2>Open a repository</h2>
57
+ <button id="pickerClose" class="icon-button hidden" onclick="closePicker()"
58
+ title="Keep the review open" aria-label="Keep the review open">
59
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
60
+ <path d="M3.72 3.72a.75.75 0 011.06 0L8 6.94l3.22-3.22a.75.75 0 111.06 1.06L9.06 8l3.22 3.22a.75.75 0 11-1.06 1.06L8 9.06l-3.22 3.22a.75.75 0 01-1.06-1.06L6.94 8 3.72 4.78a.75.75 0 010-1.06z"></path>
61
+ </svg>
62
+ </button>
63
+ </div>
64
+
65
+ <section id="recentSection" class="picker-section hidden">
66
+ <h3>Recent</h3>
67
+ <div id="recentList" class="picker-list"></div>
68
+ </section>
69
+
70
+ <section class="picker-section picker-browse">
71
+ <h3>Browse</h3>
72
+ <div class="picker-toolbar">
73
+ <button id="browseUpBtn" class="picker-up" onclick="browseUp()">Up</button>
74
+ <span id="browsePath" class="picker-current"></span>
75
+ <button id="browseOpenBtn" class="picker-open hidden" onclick="openBrowsed()">
76
+ Open this repository
77
+ </button>
78
+ </div>
79
+ <div id="browseList" class="picker-list picker-list-scroll"></div>
80
+ </section>
81
+ </div>
82
+ </section>
83
+
30
84
  <aside id="sidebar" class="sidebar hidden">
31
85
  <div class="sidebar-header">
32
86
  <h2>Changed Files</h2>
package/public/style.css CHANGED
@@ -44,9 +44,9 @@ body {
44
44
  .header-actions {
45
45
  display: flex;
46
46
  align-items: center;
47
- gap: 12px;
47
+ gap: 8px;
48
48
  flex: 1;
49
- max-width: 800px;
49
+ min-width: 0;
50
50
  }
51
51
 
52
52
  #repoPath {
@@ -988,3 +988,334 @@ button:disabled {
988
988
  color: #24292e;
989
989
  border: 1px solid #e1e4e8;
990
990
  }
991
+
992
+ /* A binary file has no lines to render; say so rather than showing a
993
+ lossy UTF-8 decode of its bytes. See #23. */
994
+ .binary-notice {
995
+ padding: 28px 24px;
996
+ color: #57606a;
997
+ font-size: 14px;
998
+ line-height: 1.6;
999
+ }
1000
+
1001
+ .binary-notice span {
1002
+ display: block;
1003
+ margin-top: 6px;
1004
+ font-size: 13px;
1005
+ opacity: 0.8;
1006
+ }
1007
+
1008
+ /* Repository picker
1009
+ *
1010
+ * What the page shows before a repository is open. It used to show nothing at
1011
+ * all but an empty path box in the header, which asked you to already know the
1012
+ * path you were looking for.
1013
+ */
1014
+ .picker {
1015
+ flex: 1;
1016
+ overflow-y: auto;
1017
+ padding: 32px 24px;
1018
+ display: flex;
1019
+ justify-content: center;
1020
+ }
1021
+
1022
+ .picker-card {
1023
+ width: 100%;
1024
+ max-width: 720px;
1025
+ }
1026
+
1027
+ .picker-card h2 {
1028
+ font-size: 18px;
1029
+ font-weight: 600;
1030
+ color: #24292e;
1031
+ margin-bottom: 20px;
1032
+ }
1033
+
1034
+ .picker-section {
1035
+ margin-bottom: 28px;
1036
+ }
1037
+
1038
+ .picker-section h3 {
1039
+ font-size: 12px;
1040
+ font-weight: 600;
1041
+ text-transform: uppercase;
1042
+ letter-spacing: 0.6px;
1043
+ color: #6a737d;
1044
+ margin-bottom: 10px;
1045
+ }
1046
+
1047
+ .picker-list {
1048
+ background: white;
1049
+ border: 1px solid #e1e4e8;
1050
+ border-radius: 6px;
1051
+ overflow: hidden;
1052
+ }
1053
+
1054
+ .picker-list-scroll {
1055
+ max-height: 340px;
1056
+ overflow-y: auto;
1057
+ }
1058
+
1059
+ .picker-empty {
1060
+ padding: 16px;
1061
+ font-size: 13px;
1062
+ color: #6a737d;
1063
+ }
1064
+
1065
+ .picker-row {
1066
+ display: flex;
1067
+ align-items: center;
1068
+ gap: 10px;
1069
+ width: 100%;
1070
+ padding: 10px 14px;
1071
+ border: none;
1072
+ border-bottom: 1px solid #f0f2f4;
1073
+ background: white;
1074
+ font: inherit;
1075
+ font-size: 13px;
1076
+ color: #24292e;
1077
+ text-align: left;
1078
+ cursor: pointer;
1079
+ }
1080
+
1081
+ .picker-row:last-child {
1082
+ border-bottom: none;
1083
+ }
1084
+
1085
+ .picker-row:hover {
1086
+ background: #f6f8fa;
1087
+ }
1088
+
1089
+ .picker-row-icon {
1090
+ flex: none;
1091
+ width: 16px;
1092
+ color: #6a737d;
1093
+ }
1094
+
1095
+ .picker-row-name {
1096
+ font-weight: 500;
1097
+ white-space: nowrap;
1098
+ }
1099
+
1100
+ .picker-row-path {
1101
+ flex: 1;
1102
+ min-width: 0;
1103
+ color: #6a737d;
1104
+ font-size: 12px;
1105
+ overflow: hidden;
1106
+ text-overflow: ellipsis;
1107
+ white-space: nowrap;
1108
+ /* Truncate the front of a long path, not the end -- the last few segments
1109
+ * are the ones that identify it. `direction: rtl` is what puts the ellipsis
1110
+ * on the left; `unicode-bidi: plaintext` is what stops it also relocating
1111
+ * the leading slash to the far end, which spells the path wrong. */
1112
+ direction: rtl;
1113
+ unicode-bidi: plaintext;
1114
+ text-align: left;
1115
+ }
1116
+
1117
+ .picker-row-meta {
1118
+ flex: none;
1119
+ color: #6a737d;
1120
+ font-size: 12px;
1121
+ }
1122
+
1123
+ .picker-badge {
1124
+ flex: none;
1125
+ padding: 1px 6px;
1126
+ border-radius: 20px;
1127
+ background: #ddf4e4;
1128
+ color: #1a7f37;
1129
+ font-size: 11px;
1130
+ font-weight: 600;
1131
+ }
1132
+
1133
+ /* A repository that has been moved or deleted is still shown, because seeing
1134
+ * the path you remember is a better answer than a list that quietly shrinks. */
1135
+ .picker-row.missing {
1136
+ cursor: not-allowed;
1137
+ }
1138
+
1139
+ .picker-row.missing .picker-row-name,
1140
+ .picker-row.missing .picker-row-path {
1141
+ color: #a0a6ad;
1142
+ }
1143
+
1144
+ .picker-toolbar {
1145
+ display: flex;
1146
+ align-items: center;
1147
+ gap: 10px;
1148
+ margin-bottom: 10px;
1149
+ }
1150
+
1151
+ .picker-up {
1152
+ flex: none;
1153
+ padding: 5px 12px;
1154
+ border: 1px solid #d1d5da;
1155
+ border-radius: 6px;
1156
+ background: white;
1157
+ font: inherit;
1158
+ font-size: 12px;
1159
+ color: #24292e;
1160
+ cursor: pointer;
1161
+ }
1162
+
1163
+ .picker-up:hover:not(:disabled) {
1164
+ background: #f6f8fa;
1165
+ }
1166
+
1167
+ .picker-up:disabled {
1168
+ color: #a0a6ad;
1169
+ cursor: default;
1170
+ }
1171
+
1172
+ .picker-current {
1173
+ flex: 1;
1174
+ min-width: 0;
1175
+ font-size: 12px;
1176
+ color: #6a737d;
1177
+ overflow: hidden;
1178
+ text-overflow: ellipsis;
1179
+ white-space: nowrap;
1180
+ direction: rtl;
1181
+ unicode-bidi: plaintext;
1182
+ text-align: left;
1183
+ }
1184
+
1185
+ .picker-open {
1186
+ flex: none;
1187
+ padding: 5px 12px;
1188
+ border: none;
1189
+ border-radius: 6px;
1190
+ background: #0366d6;
1191
+ color: white;
1192
+ font: inherit;
1193
+ font-size: 12px;
1194
+ font-weight: 500;
1195
+ cursor: pointer;
1196
+ }
1197
+
1198
+ .picker-open:hover {
1199
+ background: #0256b9;
1200
+ }
1201
+
1202
+ /* The repository control
1203
+ *
1204
+ * The header used to be a text box saying "Enter local repository path...",
1205
+ * which asked the one thing a person opening this tool is least able to
1206
+ * supply: the exact path, typed correctly, from memory. It is a button now.
1207
+ * Typing a path is still there, behind the icon beside it, because pasting one
1208
+ * is a real thing to want and a navigator is slow when you already know.
1209
+ */
1210
+ .repo-button {
1211
+ display: flex;
1212
+ align-items: center;
1213
+ gap: 8px;
1214
+ max-width: 380px;
1215
+ padding: 7px 12px;
1216
+ border: 1px solid #d1d5da;
1217
+ border-radius: 6px;
1218
+ background: white;
1219
+ font: inherit;
1220
+ font-size: 13px;
1221
+ font-weight: 500;
1222
+ color: #24292e;
1223
+ cursor: pointer;
1224
+ }
1225
+
1226
+ .repo-button:hover {
1227
+ background: #f6f8fa;
1228
+ border-color: #c6cbd1;
1229
+ }
1230
+
1231
+ .repo-button svg {
1232
+ flex: none;
1233
+ color: #6a737d;
1234
+ }
1235
+
1236
+ .repo-button span {
1237
+ overflow: hidden;
1238
+ text-overflow: ellipsis;
1239
+ white-space: nowrap;
1240
+ }
1241
+
1242
+ .icon-button {
1243
+ display: flex;
1244
+ align-items: center;
1245
+ justify-content: center;
1246
+ flex: none;
1247
+ width: 30px;
1248
+ height: 30px;
1249
+ padding: 0;
1250
+ border: 1px solid transparent;
1251
+ border-radius: 6px;
1252
+ background: transparent;
1253
+ color: #6a737d;
1254
+ cursor: pointer;
1255
+ }
1256
+
1257
+ .icon-button:hover {
1258
+ background: #f0f2f4;
1259
+ color: #24292e;
1260
+ }
1261
+
1262
+ .icon-button[aria-expanded='true'] {
1263
+ background: #eaeef2;
1264
+ border-color: #d1d5da;
1265
+ color: #24292e;
1266
+ }
1267
+
1268
+ .path-entry {
1269
+ display: flex;
1270
+ align-items: center;
1271
+ gap: 8px;
1272
+ flex: 1;
1273
+ min-width: 0;
1274
+ }
1275
+
1276
+ .path-entry input {
1277
+ flex: 1;
1278
+ min-width: 0;
1279
+ }
1280
+
1281
+ /* Pushed to the far end, so the review controls are not mixed in with the
1282
+ * controls for choosing what you are reviewing. */
1283
+ .submit-btn {
1284
+ margin-left: auto;
1285
+ }
1286
+
1287
+ .picker-heading {
1288
+ display: flex;
1289
+ align-items: center;
1290
+ justify-content: space-between;
1291
+ gap: 12px;
1292
+ margin-bottom: 20px;
1293
+ }
1294
+
1295
+ .picker-heading h2 {
1296
+ margin-bottom: 0;
1297
+ }
1298
+
1299
+ /* Over the review rather than instead of it: switching repositories with a
1300
+ * review open should not throw the review off the screen to ask. */
1301
+ .picker.as-overlay {
1302
+ position: fixed;
1303
+ top: 0;
1304
+ left: 0;
1305
+ width: 100%;
1306
+ height: 100%;
1307
+ z-index: 1000;
1308
+ background: rgba(0, 0, 0, 0.45);
1309
+ padding: 56px 24px;
1310
+ align-items: flex-start;
1311
+ }
1312
+
1313
+ .picker.as-overlay .picker-card {
1314
+ max-width: 640px;
1315
+ padding: 24px;
1316
+ background: white;
1317
+ border-radius: 8px;
1318
+ box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3);
1319
+ max-height: calc(100vh - 112px);
1320
+ overflow-y: auto;
1321
+ }
package/server.js CHANGED
@@ -18,6 +18,10 @@ const { normalizeComments } = require('./lib/comments');
18
18
  const { formatReview, reviewFilename, commentsFilename } = require('./lib/review');
19
19
  const { buildReviewDocument } = require('./lib/agent');
20
20
  const { readSavedComments, ReviewOwnershipError } = require('./lib/store');
21
+ const { repoRoot } = require('./lib/repo');
22
+ const { browse } = require('./lib/browse');
23
+ const { recordRecent, describeRecents } = require('./lib/recents');
24
+ const { listReviews } = require('./lib/export');
21
25
 
22
26
  const DEFAULT_PORT = 4500;
23
27
  const DEFAULT_HOST = '127.0.0.1';
@@ -149,6 +153,29 @@ function createApp(options = {}) {
149
153
  * @param {string} filePath
150
154
  * @returns {Promise<string>}
151
155
  */
156
+ /**
157
+ * Whether git considers this file binary.
158
+ *
159
+ * Asking git rather than sniffing the bytes ourselves, because git's
160
+ * answer is the one that produced the diff we are about to render, and a
161
+ * second opinion that disagreed would be worse than no opinion. It says
162
+ * so in the diff itself: `Binary files a/x and b/x differ` for the
163
+ * default format, `GIT binary patch` when binary patches are enabled.
164
+ *
165
+ * This matters beyond the mojibake. `lib/changes.js` already classifies
166
+ * these as `B` and the sidebar renders that letter, but the file endpoint
167
+ * never consulted it -- so a 2 KB PNG came back as ten `add` lines that
168
+ * were ~45% U+FFFD, and a comment left on one of them exported an
169
+ * `anchor` that can never match the file. docs/agent-format.md tells a
170
+ * consumer to locate comments by that anchor.
171
+ *
172
+ * @param {string} diffText
173
+ * @returns {boolean}
174
+ */
175
+ function isBinaryDiff(diffText) {
176
+ return /^Binary files .* differ$/m.test(diffText) || /^GIT binary patch$/m.test(diffText);
177
+ }
178
+
152
179
  async function diffForFile(git, session, filePath) {
153
180
  if (session.mode === 'lastCommit') {
154
181
  return git.diff(['HEAD~1', 'HEAD', '--', filePath]);
@@ -164,6 +191,61 @@ function createApp(options = {}) {
164
191
  res.json({ status: 'ok', sessions: sessions.size });
165
192
  });
166
193
 
194
+ /**
195
+ * Refuse a request a browser has marked as coming from somewhere else.
196
+ *
197
+ * `cors()` above is open, deliberately, and that is a settled decision this
198
+ * does not reopen. It is a rule for the two endpoints below, which differ
199
+ * from every other one in kind: they enumerate directories and name
200
+ * projects, rather than answering about a repository whose path the caller
201
+ * already had. A browser sends `Origin` on exactly the requests where that
202
+ * distinction matters and omits it on same-origin GETs, so the page keeps
203
+ * working and a page on another site does not. Anything that is not a
204
+ * browser can read these directories without asking this server anyway.
205
+ */
206
+ function sameOriginOnly(req, res, next) {
207
+ const origin = req.get('origin');
208
+
209
+ if (origin && origin !== `http://${req.headers.host}`) {
210
+ return res.status(403).json({ error: 'Not available to another origin' });
211
+ }
212
+
213
+ next();
214
+ }
215
+
216
+ /** List the directories inside one, so a repository can be found by looking. */
217
+ app.get('/api/browse', sameOriginOnly, async (req, res) => {
218
+ try {
219
+ res.json(await browse(req.query.path));
220
+ } catch (error) {
221
+ if (error.code === 'ENOENT' || error.code === 'ENOTDIR') {
222
+ return res.status(400).json({ error: 'No such directory' });
223
+ }
224
+ if (error.code === 'EACCES' || error.code === 'EPERM') {
225
+ return res.status(403).json({ error: 'Not allowed to read that directory' });
226
+ }
227
+
228
+ console.error('Browse error:', error);
229
+ res.status(500).json({ error: error.message });
230
+ }
231
+ });
232
+
233
+ /** Repositories opened before now, newest first. */
234
+ app.get('/api/recent', sameOriginOnly, async (req, res) => {
235
+ try {
236
+ // Annotated with what is saved against each, because "3 comments" is
237
+ // what tells you which of two checkouts you were in the middle of.
238
+ const counts = new Map(
239
+ (await listReviews(reviewsDir)).map(review => [review.repoPath, review.comments])
240
+ );
241
+
242
+ res.json({ projects: await describeRecents(reviewsDir, counts) });
243
+ } catch (error) {
244
+ console.error('Recent projects error:', error);
245
+ res.status(500).json({ error: error.message });
246
+ }
247
+ });
248
+
167
249
  /** Open a repository and list what changed. */
168
250
  app.post('/api/load-repo', async (req, res) => {
169
251
  try {
@@ -179,11 +261,16 @@ function createApp(options = {}) {
179
261
  return res.status(400).json({ error: 'Path does not exist' });
180
262
  }
181
263
 
182
- const git = gitFactory(repoPath);
183
- if (!(await git.checkIsRepo())) {
264
+ // Resolve to the root of the working tree before anything is keyed on
265
+ // this path. A subdirectory passes `checkIsRepo()` quite happily and
266
+ // then produces a file list whose diffs are all empty; see lib/repo.js.
267
+ const root = await repoRoot(repoPath, gitFactory);
268
+ if (!root) {
184
269
  return res.status(400).json({ error: 'Not a valid git repository' });
185
270
  }
186
271
 
272
+ const git = gitFactory(root);
273
+
187
274
  let files = collectWorkingChanges(await git.status());
188
275
  let mode = 'working';
189
276
  let message = `Found ${files.length} changed file(s)`;
@@ -204,10 +291,19 @@ function createApp(options = {}) {
204
291
  }
205
292
  }
206
293
 
207
- const repoId = sessions.create(repoPath, mode);
208
- console.log(`Loaded repository: ${repoPath} (mode: ${mode})`);
294
+ const repoId = sessions.create(root, mode);
295
+ console.log(`Loaded repository: ${root} (mode: ${mode})`);
209
296
 
210
- res.json({ repoId, files, repoPath, mode, message });
297
+ try {
298
+ await recordRecent(reviewsDir, root);
299
+ } catch (error) {
300
+ // Being unable to remember this must not stop you opening it.
301
+ console.error('Could not record recent project:', error.message);
302
+ }
303
+
304
+ // `repoPath` in the response is the resolved root, not what was asked
305
+ // for, so the page can show which repository it actually opened.
306
+ res.json({ repoId, files, repoPath: root, mode, message });
211
307
  } catch (error) {
212
308
  console.error('Load repo error:', error);
213
309
  res.status(500).json({ error: error.message });
@@ -221,11 +317,26 @@ function createApp(options = {}) {
221
317
  const session = requireSession(res, repoId);
222
318
  if (!session) return;
223
319
 
320
+ // Confine the path first, explicitly. It used to be confined as a
321
+ // side effect of `readFileForMode` calling `resolveRepoFile`, so
322
+ // moving the read below the binary check silently turned a 400 into a
323
+ // 500 and let the traversal reach git. A containment check that only
324
+ // works because of statement order is not a containment check.
325
+ resolveRepoFile(session.repoPath, filePath);
326
+
224
327
  const git = gitFactory(session.repoPath);
225
- const content = await readFileForMode(session, filePath);
226
328
  const diffText = await diffForFile(git, session, filePath);
227
329
 
228
- res.json({ filePath, diffLines: parseDiff(diffText, content) });
330
+ // Answered before the file is read: decoding a PNG as UTF-8 to then
331
+ // throw the result away is how the mojibake got served in the first
332
+ // place.
333
+ if (isBinaryDiff(diffText)) {
334
+ return res.json({ filePath, binary: true, diffLines: [] });
335
+ }
336
+
337
+ const content = await readFileForMode(session, filePath);
338
+
339
+ res.json({ filePath, binary: false, diffLines: parseDiff(diffText, content) });
229
340
  } catch (error) {
230
341
  if (error instanceof PathEscapeError) {
231
342
  return res.status(400).json({ error: error.message });