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/README.md +44 -12
- package/bin/reviewer.js +33 -3
- package/lib/agent.js +62 -6
- package/lib/browse.js +129 -0
- package/lib/cli.js +17 -3
- package/lib/paths.js +25 -0
- package/lib/recents.js +123 -0
- package/lib/repo.js +53 -0
- package/package.json +4 -3
- package/public/app.js +458 -62
- package/public/index.html +60 -6
- package/public/style.css +333 -2
- package/server.js +118 -7
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
|
-
<
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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:
|
|
47
|
+
gap: 8px;
|
|
48
48
|
flex: 1;
|
|
49
|
-
|
|
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
|
-
|
|
183
|
-
|
|
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(
|
|
208
|
-
console.log(`Loaded repository: ${
|
|
294
|
+
const repoId = sessions.create(root, mode);
|
|
295
|
+
console.log(`Loaded repository: ${root} (mode: ${mode})`);
|
|
209
296
|
|
|
210
|
-
|
|
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
|
-
|
|
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 });
|