git-reviewer 2.2.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 {
@@ -1004,3 +1004,318 @@ button:disabled {
1004
1004
  font-size: 13px;
1005
1005
  opacity: 0.8;
1006
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';
@@ -187,6 +191,61 @@ function createApp(options = {}) {
187
191
  res.json({ status: 'ok', sessions: sessions.size });
188
192
  });
189
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
+
190
249
  /** Open a repository and list what changed. */
191
250
  app.post('/api/load-repo', async (req, res) => {
192
251
  try {
@@ -202,11 +261,16 @@ function createApp(options = {}) {
202
261
  return res.status(400).json({ error: 'Path does not exist' });
203
262
  }
204
263
 
205
- const git = gitFactory(repoPath);
206
- 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) {
207
269
  return res.status(400).json({ error: 'Not a valid git repository' });
208
270
  }
209
271
 
272
+ const git = gitFactory(root);
273
+
210
274
  let files = collectWorkingChanges(await git.status());
211
275
  let mode = 'working';
212
276
  let message = `Found ${files.length} changed file(s)`;
@@ -227,10 +291,19 @@ function createApp(options = {}) {
227
291
  }
228
292
  }
229
293
 
230
- const repoId = sessions.create(repoPath, mode);
231
- console.log(`Loaded repository: ${repoPath} (mode: ${mode})`);
294
+ const repoId = sessions.create(root, mode);
295
+ console.log(`Loaded repository: ${root} (mode: ${mode})`);
296
+
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
+ }
232
303
 
233
- res.json({ repoId, files, repoPath, mode, message });
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 });
234
307
  } catch (error) {
235
308
  console.error('Load repo error:', error);
236
309
  res.status(500).json({ error: error.message });