Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

vole
Clone

Read only, no credentials:

https://rodent.software/git/vole.git

Write over SSH with a registered key:

git clone ssh://git@0.0.0.0:2222/vole.git

A free and open-source forge that lives in your Git repo.

Build out change review and threaded discussions

f6f4b1db24292d1df877680714952a9c568e2b78 · Keenan Nemetz · 15d ago · parent 564acd4b

Build out change review and threaded discussions

- Add Conversation, Files and Runs tabs to change requests. Files uses the
  code-browser shape with a changed-file tree and one selected-file diff;
  Runs lists the head commit's workflow results.
- Show anchored review comments beneath the line, with a collapsed excerpt
  read from the exact blob that was reviewed, and return posted anchors to
  their line note.
- Add comment threads: any comment can be replied to, the UI flattens the
  descendants under one root, reply context links back to the parent, and
  reply targets feed a single composer at the foot of the discussion.
- Collapse reply groups while keeping the root visible, and scroll to the
  composer when Reply is selected.
- Add comment context menus with Reply, Copy text, Copy link, Edit and
  Delete. Add web edit/delete routes with author or admin checks; deletion
  leaves a tombstone so replies keep their thread.
- Support --reply-to in the issue and change CLI commands, and treat a
  missing parent as a tombstone rather than an invalid tree.
- Fix the diff comment gutter, action button colours, reply labels, and
  keep the Files tree and diff pane aligned.

Update metadata validation, web templates, CSS, JavaScript, tests, and the
CLI/change-request/issue docs.

server/web/static/vole.css +365−19

@@ -27,6 +27,7 @@
27 27 --bad: #eb5757;
28 28 --bad-soft: rgba(235, 87, 87, 0.12);
29 29 --warn: #f2c94c;
30 --warn-soft: rgba(242, 201, 76, 0.14);
30 31
31 32 /* shape and type */
32 33 --radius: 6px;
@@ -239,6 +240,27 @@ button:disabled { opacity: 0.5; cursor: default; }
239 240 }
240 241 .button.secondary:hover { background: var(--bg-hover); }
241 242
243 /* Action colours are outlined rather than filled: the border and label carry
244 the meaning, and hover adds only a soft tint. */
245 .button.success, button.success {
246 background: transparent;
247 color: var(--good);
248 border-color: var(--good);
249 }
250 .button.success:hover, button.success:hover { background: var(--good-soft); }
251 .button.warn, button.warn {
252 background: transparent;
253 color: var(--warn);
254 border-color: var(--warn);
255 }
256 .button.warn:hover, button.warn:hover { background: var(--warn-soft); }
257 .button.danger, button.danger {
258 background: transparent;
259 color: var(--bad);
260 border-color: var(--bad);
261 }
262 .button.danger:hover, button.danger:hover { background: var(--bad-soft); }
263
242 264 button.link, .link {
243 265 background: none;
244 266 border: 0;
@@ -448,7 +470,10 @@ pre code { background: none; border: 0; padding: 0; font-size: inherit; }
448 470 font-family: var(--mono);
449 471 font-size: 11.5px;
450 472 border-collapse: collapse;
473 table-layout: fixed;
451 474 }
475 .diff col.comment-col { width: 2.25rem; }
476 .diff col.ln-col { width: 3.5rem; }
452 477 .diff td {
453 478 border: 0;
454 479 padding: 1px 10px;
@@ -457,7 +482,6 @@ pre code { background: none; border: 0; padding: 0; font-size: inherit; }
457 482 }
458 483 .diff tbody tr:hover { background: none; }
459 484 .diff td.ln {
460 width: 3.5rem;
461 485 text-align: right;
462 486 color: var(--fg-faint);
463 487 user-select: none;
@@ -482,11 +506,68 @@ pre code { background: none; border: 0; padding: 0; font-size: inherit; }
482 506 border-bottom: 1px solid var(--border);
483 507 }
484 508 .diff tr.note td {
485 background: var(--accent-soft);
486 padding: 3px 12px 4px;
487 font-size: 11px;
509 background: var(--bg-panel);
510 padding: 8px 12px;
511 font-size: 12px;
512 }
513 .inline-note {
514 padding: 8px 10px;
515 border: 1px solid var(--border);
516 border-radius: var(--radius);
517 background: var(--bg-raised);
518 scroll-margin-top: 72px;
519 }
520 .inline-note + .inline-note { margin-top: 8px; }
521 .inline-note:target { border-color: var(--accent); }
522 .inline-note:target .inline-note-head .comment-author { color: var(--accent); }
523 .inline-note-head {
524 display: flex;
525 align-items: center;
526 gap: 6px;
527 margin-bottom: 5px;
528 color: var(--fg-muted);
529 font-size: 11.5px;
530 }
531 .inline-note-head .avatar { width: 17px; height: 17px; font-size: 8px; }
532 .inline-note-head .comment-author { color: var(--fg); font-size: 12px; }
533 .inline-note .comment-body {
534 padding: 0;
535 max-width: none;
536 font-size: 12.5px;
537 line-height: 1.55;
538 }
539 .inline-note .comment-body p { margin: 4px 0; }
540 .diff tr.inline-comment td {
541 background: var(--bg-panel);
542 padding: 8px 12px;
543 border-top: 1px solid var(--border);
544 }
545 .inline-comment-form {
546 display: flex;
547 flex-direction: column;
548 gap: 8px;
549 max-width: 52rem;
550 }
551 .inline-comment-card {
552 display: flex;
553 flex-direction: column;
554 gap: 8px;
555 padding: 8px 10px;
556 border: 1px solid var(--border);
557 border-radius: var(--radius);
558 background: var(--bg-raised);
559 }
560 .inline-comment-head {
561 display: flex;
562 align-items: center;
563 justify-content: space-between;
564 gap: 12px;
565 color: var(--fg-muted);
566 font-size: 12px;
488 567 }
489 .diff tr.note a.label { color: var(--accent); border-color: var(--accent); }
568 .inline-comment-head code { color: var(--fg); }
569 .inline-comment-form textarea { min-height: 5.5rem; max-width: none; }
570 .inline-comment-form .form-actions { margin-top: 0; }
490 571 .diff .binary { padding: 10px 12px; color: var(--fg-muted); font-size: 12px; margin: 0; }
491 572
492 573 /* ---------- responsive ---------- */
@@ -771,6 +852,44 @@ details[open] > summary .tree-chevron { transform: rotate(90deg); }
771 852 .tree-note, .tree-empty { margin: 0; padding: 8px 10px; font-size: 11px; }
772 853 .tree-note { border-top: 1px solid var(--border); }
773 854
855 /* Review files: the changed-file tree reuses the code browser's shape, with a
856 status mark in place of the file icon. */
857 .review-tree .tree-file { padding-left: 7px; gap: 7px; }
858 .review-mark {
859 display: inline-grid;
860 place-items: center;
861 flex: 0 0 auto;
862 width: 16px;
863 height: 16px;
864 border-radius: 3px;
865 background: var(--bg-active);
866 color: var(--fg-muted);
867 font: 600 9px/1 var(--mono);
868 }
869 .review-mark[data-status="A"] { color: var(--good); background: var(--good-soft); }
870 .review-mark[data-status="D"] { color: var(--bad); background: var(--bad-soft); }
871 .review-mark[data-status="R"] { color: var(--accent); background: var(--accent-soft); }
872 .review-mark[data-status="M"] { color: var(--warn); background: var(--warn-soft); }
873 .review-main { min-width: 0; scroll-margin-top: 60px; }
874 /* The review pane opens with a bar the same height as the tree header, so both
875 columns start on the same line instead of dropping by a summary paragraph. */
876 .review-main .diffsum {
877 display: flex;
878 align-items: center;
879 height: 36px;
880 margin: 0;
881 padding: 0 10px;
882 background: var(--bg-raised);
883 border: 1px solid var(--border);
884 border-radius: var(--radius-lg) var(--radius-lg) 0 0;
885 font-size: 11px;
886 }
887 .review-main .diff {
888 margin-top: 0;
889 border-top: 0;
890 border-radius: 0 0 var(--radius-lg) var(--radius-lg);
891 }
892
774 893 .code-main .filebody { max-width: none; }
775 894 .code-main .readme > *,
776 895 .code-main .filebody > * { max-width: none; }
@@ -945,6 +1064,28 @@ details[open] > summary .tree-chevron { transform: rotate(90deg); }
945 1064 .page-head-actions .clone-pop { left: auto; right: 0; }
946 1065 .page-sub { color: var(--fg-muted); font-size: 12.5px; margin: 0; }
947 1066
1067 /* Secondary navigation inside a change: conversation and the review files. */
1068 .subtabs {
1069 display: flex;
1070 gap: 2px;
1071 margin: -2px 0 14px;
1072 border-bottom: 1px solid var(--border);
1073 }
1074 .subtabs a {
1075 display: inline-flex;
1076 align-items: center;
1077 gap: 6px;
1078 padding: 7px 10px;
1079 margin-bottom: -1px;
1080 border-bottom: 2px solid transparent;
1081 color: var(--fg-muted);
1082 font-size: 12.5px;
1083 }
1084 .subtabs a:hover { color: var(--fg); text-decoration: none; }
1085 .subtabs a.active { color: var(--fg); border-bottom-color: var(--accent); }
1086 .subtabs .icon { width: 13px; height: 13px; }
1087 .subtabs .chip-count { margin-left: 0; }
1088
948 1089 .state {
949 1090 display: inline-flex;
950 1091 align-items: center;
@@ -1022,8 +1163,10 @@ details[open] > summary .tree-chevron { transform: rotate(90deg); }
1022 1163 }
1023 1164 .thread-main { min-width: 0; }
1024 1165 .thread-side section { padding: 12px 0 14px; border-bottom: 1px solid var(--border); }
1025 .thread-side section:first-child { padding-top: 0; }
1026 .thread-side section:last-child { border-bottom: 0; }
1166 .thread-side > section:first-child { padding-top: 0; }
1167 .thread-side > section:last-child,
1168 .thread-side #request-sidebar > section:last-child { border-bottom: 0; }
1169 .section-link { margin: 9px 0 0; font-size: 12px; }
1027 1170 .thread-side h2 { margin: 0 0 8px; }
1028 1171 .thread-side form { margin: 8px 0 0; }
1029 1172 .thread-side .meta-list { margin-bottom: 0; }
@@ -1033,8 +1176,14 @@ details[open] > summary .tree-chevron { transform: rotate(90deg); }
1033 1176 border: 1px solid var(--border);
1034 1177 border-radius: var(--radius-lg);
1035 1178 margin: 10px 0;
1036 overflow: hidden;
1179 overflow: visible;
1180 scroll-margin-top: 72px;
1181 }
1182 .comment:target {
1183 border-color: var(--accent);
1184 box-shadow: 0 0 0 2px var(--accent-soft);
1037 1185 }
1186 .comment:target .comment-head { background: var(--accent-soft); }
1038 1187 .comment-head {
1039 1188 display: flex;
1040 1189 flex-wrap: wrap;
@@ -1043,14 +1192,199 @@ details[open] > summary .tree-chevron { transform: rotate(90deg); }
1043 1192 padding: 7px 12px;
1044 1193 background: var(--bg-raised);
1045 1194 border-bottom: 1px solid var(--border);
1195 border-top-left-radius: calc(var(--radius-lg) - 1px);
1196 border-top-right-radius: calc(var(--radius-lg) - 1px);
1046 1197 font-size: 12px;
1047 1198 }
1048 1199 .comment-author { font-weight: 550; }
1049 1200 .comment-head .muted { font-size: 11.5px; }
1201 .reply-context { color: var(--fg-muted); font-size: 11.5px; }
1202 .reply-context a { color: var(--fg-muted); }
1203 .reply-context a:hover { color: var(--accent); }
1050 1204 .comment-body { padding: 12px 14px; }
1051 1205 .comment-body > :first-child { margin-top: 0; }
1052 1206 .comment-body > :last-child { margin-bottom: 0; }
1053 1207
1208 .comment.deleted {
1209 border-style: dashed;
1210 background: transparent;
1211 }
1212 .comment-deleted {
1213 display: flex;
1214 align-items: center;
1215 gap: 6px;
1216 padding: 10px 14px;
1217 color: var(--fg-faint);
1218 font-size: 12px;
1219 font-style: italic;
1220 }
1221 .comment-deleted .icon { width: 13px; height: 13px; }
1222
1223 /* Replies are displayed one level deep: a root comment leads, all its
1224 descendants sit in an indented rail, and the reply composer joins the thread. */
1225 .comment-replies {
1226 display: flex;
1227 flex-direction: column;
1228 gap: 8px;
1229 margin: 0 0 12px 26px;
1230 padding-left: 12px;
1231 border-left: 2px solid var(--border);
1232 }
1233 .comment-replies .comment {
1234 margin: 0;
1235 background: var(--bg-raised);
1236 }
1237 .comment-menu { position: relative; margin-left: auto; }
1238 .comment-menu-toggle {
1239 display: inline-flex;
1240 align-items: center;
1241 justify-content: center;
1242 width: 22px;
1243 min-height: 0;
1244 padding: 0;
1245 border: 0;
1246 border-radius: 4px;
1247 background: none;
1248 color: var(--fg-muted);
1249 font: 600 16px/1 var(--font);
1250 cursor: pointer;
1251 }
1252 .comment-menu-toggle:hover { background: var(--bg-hover); color: var(--fg); }
1253 .comment-menu-list {
1254 position: absolute;
1255 z-index: 60;
1256 top: calc(100% + 4px);
1257 right: 0;
1258 min-width: 10rem;
1259 padding: 4px;
1260 border: 1px solid var(--border-strong);
1261 border-radius: var(--radius);
1262 background: var(--bg-raised);
1263 box-shadow: 0 12px 32px rgba(0, 0, 0, 0.4);
1264 }
1265 .comment-menu-list[hidden] { display: none; }
1266 .comment-menu-list button {
1267 display: block;
1268 width: 100%;
1269 min-height: 0;
1270 padding: 6px 9px;
1271 border: 0;
1272 border-radius: 4px;
1273 background: none;
1274 color: var(--fg);
1275 text-align: left;
1276 font: 400 12px/1.4 var(--font);
1277 cursor: pointer;
1278 }
1279 .comment-menu-list button:hover { background: var(--bg-hover); }
1280 .comment-menu-list form { margin: 0; }
1281 .comment-menu-list .menu-danger { color: var(--bad); }
1282 .comment-menu-list .menu-danger:hover { background: var(--bad-soft); }
1283 .comment-edit-form { padding: 10px 14px 12px; }
1284 .comment-edit-form[hidden] { display: none; }
1285 .comment-edit-form textarea { min-height: 6rem; max-width: none; }
1286 .comment-edit-form .form-actions { margin-top: 8px; }
1287 .comment.editing > .comment-body,
1288 .comment.editing > .comment-snippet { display: none; }
1289
1290 /* A thread keeps its root visible and lets the replies fold away. */
1291 .thread-replies { margin: 0 0 10px 26px; }
1292 .thread-replies > summary {
1293 display: inline-flex;
1294 align-items: center;
1295 gap: 6px;
1296 width: max-content;
1297 padding: 2px 0;
1298 color: var(--fg-muted);
1299 font-size: 11.5px;
1300 cursor: pointer;
1301 list-style: none;
1302 }
1303 .thread-replies > summary::-webkit-details-marker { display: none; }
1304 .thread-replies > summary:hover { color: var(--accent); }
1305 .thread-replies[open] > summary { margin-bottom: 6px; }
1306 .thread-chevron { display: inline-flex; color: var(--fg-faint); transition: transform 0.12s ease; }
1307 .thread-chevron .icon { width: 12px; height: 12px; }
1308 .thread-replies[open] .thread-chevron { transform: rotate(90deg); }
1309 .thread-replies .comment-replies { margin: 6px 0 0; }
1310
1311 .discussion-composer { margin: 16px 0 0; }
1312 .discussion-composer textarea {
1313 width: 100%;
1314 max-width: none;
1315 min-height: 7rem;
1316 padding: 12px 14px;
1317 background: var(--bg-panel);
1318 border-color: var(--border);
1319 border-radius: var(--radius-lg);
1320 line-height: 1.55;
1321 }
1322 .discussion-composer .form-actions { margin-top: 8px; }
1323 .reply-target {
1324 display: flex;
1325 align-items: center;
1326 justify-content: space-between;
1327 gap: 8px;
1328 margin: 0 0 8px;
1329 padding: 5px 8px;
1330 border: 1px solid var(--accent);
1331 border-radius: var(--radius);
1332 background: var(--accent-soft);
1333 color: var(--fg-muted);
1334 font-size: 11.5px;
1335 }
1336 .reply-target[hidden] { display: none; }
1337 .reply-target strong { color: var(--fg); }
1338
1339 /* An anchored comment can show the lines it was written about. The details
1340 element keeps the conversation compact; nothing here needs JavaScript. */
1341 .comment-snippet {
1342 margin: 0 14px 12px;
1343 border: 1px solid var(--border);
1344 border-radius: var(--radius);
1345 background: var(--bg-raised);
1346 overflow: hidden;
1347 }
1348 .comment-snippet > summary {
1349 display: flex;
1350 align-items: center;
1351 flex-wrap: wrap;
1352 gap: 4px 8px;
1353 padding: 7px 10px;
1354 color: var(--fg-muted);
1355 font-size: 11.5px;
1356 cursor: pointer;
1357 list-style: none;
1358 }
1359 .comment-snippet > summary::-webkit-details-marker { display: none; }
1360 .comment-snippet > summary:hover { background: var(--bg-hover); }
1361 .comment-snippet code { color: var(--fg); font-family: var(--mono); }
1362 .comment-snippet[open] > summary { border-bottom: 1px solid var(--border); }
1363 .snippet-chevron { display: inline-flex; color: var(--fg-faint); transition: transform 0.12s ease; }
1364 .snippet-chevron .icon { width: 12px; height: 12px; }
1365 .comment-snippet[open] .snippet-chevron { transform: rotate(90deg); }
1366 .snippet-code {
1367 overflow-x: auto;
1368 background: var(--bg-panel);
1369 font: 11.5px/1.55 var(--mono);
1370 }
1371 .snippet-line { display: flex; min-width: max-content; }
1372 .snippet-line.anchor { background: var(--accent-soft); }
1373 .snippet-no {
1374 flex: 0 0 3.2rem;
1375 padding-right: 8px;
1376 text-align: right;
1377 color: var(--fg-faint);
1378 user-select: none;
1379 border-right: 1px solid var(--border);
1380 }
1381 .snippet-text { padding: 0 10px; white-space: pre; color: var(--fg); }
1382 .snippet-actions {
1383 padding: 6px 10px;
1384 border-top: 1px solid var(--border);
1385 font-size: 11.5px;
1386 }
1387
1054 1388 .meta-list {
1055 1389 display: grid;
1056 1390 grid-template-columns: auto minmax(0, 1fr);
@@ -1216,23 +1550,35 @@ h2 .icon { width: 13px; height: 13px; margin-right: 4px; vertical-align: -2px; c
1216 1550 while its request is in flight. */
1217 1551 form.htmx-request .label-tokens { opacity: 0.6; }
1218 1552
1219 /* Line-anchored review comments: a small plus on each line opens the composer. */
1220 .diff td.comment { width: 1.7rem; padding: 0 3px; text-align: center; white-space: nowrap; }
1221 .diff td.comment a {
1553 /* Line-anchored review comments: a small button on each line opens the composer. */
1554 .diff td.comment-cell {
1555 padding: 0 3px;
1556 text-align: center;
1557 white-space: nowrap;
1558 }
1559 .diff td.comment-cell a {
1222 1560 display: inline-flex;
1223 1561 align-items: center;
1224 1562 justify-content: center;
1225 width: 17px;
1226 height: 17px;
1563 width: 18px;
1564 height: 18px;
1565 border: 0;
1566 background: none;
1567 color: var(--fg-muted);
1568 opacity: 0.55;
1569 transition: opacity 0.12s ease, color 0.12s ease;
1570 }
1571 .diff tr:hover td.comment-cell a { opacity: 1; color: var(--accent); }
1572 .diff td.comment-cell a:focus-visible {
1573 opacity: 1;
1574 color: var(--accent);
1575 outline: 2px solid var(--accent);
1576 outline-offset: 1px;
1227 1577 border-radius: 3px;
1228 color: var(--fg-faint);
1229 opacity: 0.3;
1230 1578 }
1231 .diff tr:hover td.comment a,
1232 .diff td.comment a:focus-visible { opacity: 1; background: var(--bg-hover); color: var(--accent); }
1233 .diff td.comment .icon { width: 11px; height: 11px; }
1579 .diff td.comment-cell .icon { width: 12px; height: 12px; }
1234 1580 .anchor-note { margin: 0 0 8px; font-size: 12px; }
1235 @media (max-width: 720px) { .diff td.comment a { opacity: .7; } }
1581 @media (max-width: 720px) { .diff td.comment-cell a { opacity: 0.8; } }
1236 1582
1237 1583 /* Repository activity feed. */
1238 1584 .activity-list {