Commit
Show commit diffs in the snapshot, keep the deploy target out of the repo
commit
492bf51
11 changed files with +345 and −54
Jump to a changed file
- DEPLOY.md +2 −2
- deploy.sh +7 −1
- docker-compose.yml +5 −0
- src/app.js +71 −0
- src/public/app.js +43 −1
- src/public/styles.css +83 −12
- src/views/partials/diff-file.ejs +43 −0
- src/views/partials/diff-list.ejs +32 −0
- src/views/repository.ejs +26 −38
- tests/commits.test.js +32 −0
- tests/issues-pulls.test.js +1 −0
modified DEPLOY.md +2 −2
| @@ -10,8 +10,8 @@Kolm sammu, millest ainult esimest ja kolmandat pead ise tegema. | ||
| 10 | 10 | |
| 11 | 11 | ## 1. DNS ja GitHubi app (10 min, sinu teha) |
| 12 | 12 | |
| 13 | -**DNS.** Lisa A-kirje `code.rasmusj.com` -> `YOUR_SERVER_IP`, sama IP mis | |
| 14 | -ülejäänud. Oota, kuni `nslookup code.rasmusj.com` vastab. | |
| 13 | +**DNS.** Lisa A-kirje `code.rasmusj.com` -> sinu serveri IP, sama mis | |
| 14 | +ülejäänud saitidel. Oota, kuni `nslookup code.rasmusj.com` vastab. | |
| 15 | 15 | |
| 16 | 16 | **GitHubi app.** Sul on app juba olemas ja töötab lokaalselt. Vaja on ainult |
| 17 | 17 | kolm aadressi ümber suunata, sest need peavad olema samal origin'il kui |
modified deploy.sh +7 −1
| @@ -16,7 +16,13 @@ | ||
| 16 | 16 | # The SQLite database is in a named Docker volume, so it survives every rebuild. |
| 17 | 17 | set -euo pipefail |
| 18 | 18 | |
| 19 | -VPS=user@host | |
| 19 | +# Deploy target. Kept out of the repository on purpose: this code is shared | |
| 20 | +# read-only with people outside the project, and the server address and | |
| 21 | +# login are not theirs to have. Set it once in your shell: | |
| 22 | +# | |
| 23 | +# export VPS=user@host | |
| 24 | +# | |
| 25 | +VPS=${VPS:?set VPS=user@host before deploying} | |
| 20 | 26 | DEST=/opt/projects/profileshare |
| 21 | 27 | |
| 22 | 28 | cd "$(dirname "$0")" |
modified docker-compose.yml +5 −0
| @@ -6,6 +6,11 @@ | ||
| 6 | 6 | # |
| 7 | 7 | # The domain lives in the Caddyfile alone. Changing it never touches this file. |
| 8 | 8 | |
| 9 | +# Named explicitly because every project on this host is deployed into a | |
| 10 | +# directory called `app`, and Compose would otherwise derive the same project | |
| 11 | +# name for all of them and treat the neighbours' containers as orphans. | |
| 12 | +name: profileshare | |
| 13 | + | |
| 9 | 14 | services: |
| 10 | 15 | profileshare: |
| 11 | 16 | build: . |
modified src/app.js +71 −0
| @@ -39,6 +39,75 @@function isMarkdownFile(path) { | ||
| 39 | 39 | return /\.(?:md|markdown|mdown|mkdn)$/i.test(path); |
| 40 | 40 | } |
| 41 | 41 | |
| 42 | +const HUNK_HEADER = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@(.*)$/; | |
| 43 | + | |
| 44 | +/** | |
| 45 | + * Turns a unified patch into hunks with per-line old and new numbers. | |
| 46 | + * | |
| 47 | + * The marker character stays at the front of the line text so every line keeps | |
| 48 | + * the same one-character indent and the columns line up in the rendered table. | |
| 49 | + */ | |
| 50 | +function parsePatch(patch) { | |
| 51 | + const hunks = []; | |
| 52 | + let current; | |
| 53 | + let oldNumber = 1; | |
| 54 | + let newNumber = 1; | |
| 55 | + for (const raw of String(patch || "").replace(/\n$/, "").split("\n")) { | |
| 56 | + const header = HUNK_HEADER.exec(raw); | |
| 57 | + if (header) { | |
| 58 | + oldNumber = Number(header[1]); | |
| 59 | + newNumber = Number(header[2]); | |
| 60 | + current = { | |
| 61 | + range: raw.slice(0, raw.indexOf("@@", 2) + 2), | |
| 62 | + heading: header[3].trim(), | |
| 63 | + lines: [], | |
| 64 | + }; | |
| 65 | + hunks.push(current); | |
| 66 | + continue; | |
| 67 | + } | |
| 68 | + if (!current) { | |
| 69 | + // Nothing before the first hunk carries line information, and GitHub only | |
| 70 | + // sends it for a patch that was assembled outside the commit endpoints. | |
| 71 | + if (/^(?:diff |index |--- |\+\+\+ |old mode|new mode|new file|deleted file|similarity|rename |Binary )/.test(raw)) continue; | |
| 72 | + if (!raw) continue; | |
| 73 | + current = { range: "", heading: "", lines: [] }; | |
| 74 | + hunks.push(current); | |
| 75 | + } | |
| 76 | + if (raw.startsWith("\\")) { | |
| 77 | + current.lines.push({ type: "note", text: raw.replace(/^\\\s*/, ""), oldNumber: null, newNumber: null }); | |
| 78 | + } else if (raw.startsWith("+")) { | |
| 79 | + current.lines.push({ type: "addition", text: raw, oldNumber: null, newNumber }); | |
| 80 | + newNumber += 1; | |
| 81 | + } else if (raw.startsWith("-")) { | |
| 82 | + current.lines.push({ type: "deletion", text: raw, oldNumber, newNumber: null }); | |
| 83 | + oldNumber += 1; | |
| 84 | + } else { | |
| 85 | + current.lines.push({ type: "context", text: raw, oldNumber, newNumber }); | |
| 86 | + oldNumber += 1; | |
| 87 | + newNumber += 1; | |
| 88 | + } | |
| 89 | + } | |
| 90 | + return hunks; | |
| 91 | +} | |
| 92 | + | |
| 93 | +function diffBlocks(additions = 0, deletions = 0) { | |
| 94 | + const total = additions + deletions; | |
| 95 | + if (!total) return Array.from({ length: 5 }, () => "neutral"); | |
| 96 | + let added = Math.round((additions / total) * 5); | |
| 97 | + let removed = Math.round((deletions / total) * 5); | |
| 98 | + if (additions && !added) added = 1; | |
| 99 | + if (deletions && !removed) removed = 1; | |
| 100 | + if (added + removed > 5) { | |
| 101 | + if (added >= removed) added = 5 - removed; | |
| 102 | + else removed = 5 - added; | |
| 103 | + } | |
| 104 | + return [ | |
| 105 | + ...Array.from({ length: added }, () => "added"), | |
| 106 | + ...Array.from({ length: removed }, () => "removed"), | |
| 107 | + ...Array.from({ length: Math.max(0, 5 - added - removed) }, () => "neutral"), | |
| 108 | + ]; | |
| 109 | +} | |
| 110 | + | |
| 42 | 111 | function repositoryReferences(repository) { |
| 43 | 112 | const branches = Array.isArray(repository.branches) && repository.branches.length |
| 44 | 113 | ? repository.branches |
| @@ -209,6 +278,8 @@export function createApp({ config, store, github, now = () => new Date() }) { | ||
| 209 | 278 | timeStyle: "short", |
| 210 | 279 | }).format(new Date(value)); |
| 211 | 280 | app.locals.encodeURIComponent = encodeURIComponent; |
| 281 | + app.locals.parseDiff = parsePatch; | |
| 282 | + app.locals.diffBlocks = diffBlocks; | |
| 212 | 283 | app.locals.renderMarkdown = (value, options = {}) => { |
| 213 | 284 | const html = marked.parse(value || "", { gfm: true, breaks: false }); |
| 214 | 285 | return sanitizeHtml(html, { |
modified src/public/app.js +43 −1
| @@ -19,7 +19,49 @@copyButton?.addEventListener("click", () => { | ||
| 19 | 19 | }); |
| 20 | 20 | |
| 21 | 21 | document.querySelectorAll("[data-copy-text]").forEach((button) => { |
| 22 | - button.addEventListener("click", () => copyText(button, button.dataset.copyText)); | |
| 22 | + button.addEventListener("click", (event) => { | |
| 23 | + // Some copy buttons sit inside a <summary>, where a plain click would also | |
| 24 | + // collapse the file the reader just asked about. | |
| 25 | + event.preventDefault(); | |
| 26 | + copyText(button, button.dataset.copyText); | |
| 27 | + }); | |
| 28 | +}); | |
| 29 | + | |
| 30 | +const WRAP_STORAGE_KEY = "profileshare-diff-wrap"; | |
| 31 | + | |
| 32 | +function readStoredWrap() { | |
| 33 | + try { | |
| 34 | + return window.localStorage.getItem(WRAP_STORAGE_KEY) === "on"; | |
| 35 | + } catch { | |
| 36 | + return false; | |
| 37 | + } | |
| 38 | +} | |
| 39 | + | |
| 40 | +document.querySelectorAll("[data-diff-wrap]").forEach((button) => { | |
| 41 | + function apply(wrapped) { | |
| 42 | + document.body.classList.toggle("diff-wrapped", wrapped); | |
| 43 | + button.setAttribute("aria-pressed", String(wrapped)); | |
| 44 | + button.textContent = wrapped ? "No wrap" : "Wrap lines"; | |
| 45 | + } | |
| 46 | + apply(readStoredWrap()); | |
| 47 | + button.addEventListener("click", () => { | |
| 48 | + const wrapped = !document.body.classList.contains("diff-wrapped"); | |
| 49 | + apply(wrapped); | |
| 50 | + try { | |
| 51 | + window.localStorage.setItem(WRAP_STORAGE_KEY, wrapped ? "on" : "off"); | |
| 52 | + } catch { | |
| 53 | + // A blocked storage quota is not a reason to lose the toggle itself. | |
| 54 | + } | |
| 55 | + }); | |
| 56 | +}); | |
| 57 | + | |
| 58 | +document.querySelectorAll("[data-diff-toggle-all]").forEach((button) => { | |
| 59 | + const files = [...document.querySelectorAll("details.diff-file")]; | |
| 60 | + button.addEventListener("click", () => { | |
| 61 | + const collapse = files.some((file) => file.open); | |
| 62 | + files.forEach((file) => { file.open = !collapse; }); | |
| 63 | + button.textContent = collapse ? "Expand all" : "Collapse all"; | |
| 64 | + }); | |
| 23 | 65 | }); |
| 24 | 66 | |
| 25 | 67 | document.addEventListener("keydown", (event) => { |
modified src/public/styles.css +83 −12
| @@ -317,19 +317,79 @@a:focus-visible, button:focus-visible, input:focus-visible { outline: 3px solid | ||
| 317 | 317 | .code-table tr:first-child th, .code-table tr:first-child td { padding-top: 18px; } |
| 318 | 318 | .code-table tr:last-child th, .code-table tr:last-child td { padding-bottom: 18px; } |
| 319 | 319 | .code-table tr:target th, .code-table tr:target td { background: #fff8c5; } |
| 320 | -.commit-detail-heading { min-height: 112px; display: flex; align-items: center; justify-content: space-between; padding: 22px 24px; border-bottom: 1px solid #d8dee4; background: #f6f8fa; } | |
| 321 | -.commit-detail-heading h2 { margin: 4px 0 7px; font: 650 22px/1.3 "Segoe UI", Arial, sans-serif; } | |
| 320 | +.commit-detail-heading { min-height: 112px; display: flex; align-items: flex-start; justify-content: space-between; gap: 24px; padding: 20px 22px; border-bottom: 1px solid #d8dee4; background: #f6f8fa; } | |
| 321 | +.commit-detail-main { min-width: 0; } | |
| 322 | +.commit-detail-heading h2 { margin: 2px 0 10px; font: 650 22px/1.3 "Segoe UI", Arial, sans-serif; overflow-wrap: anywhere; } | |
| 322 | 323 | .commit-detail-heading p { margin: 0; color: #636c76; font-size: 12px; } |
| 323 | -.diff-total { display: flex; gap: 12px; font: 700 13px Consolas, monospace; } | |
| 324 | -.diff-total span:first-child { color: #1a7f37; } | |
| 325 | -.diff-total span:last-child { color: #cf222e; } | |
| 326 | -.diff-file { margin: 20px 22px; border: 1px solid #d0d7de; border-radius: 6px; overflow: hidden; } | |
| 327 | -.diff-file header { display: flex; justify-content: space-between; padding: 10px 13px; background: #f6f8fa; font-size: 12px; } | |
| 328 | -.diff-file header span { color: #636c76; } | |
| 329 | -.diff-file pre { margin: 0; overflow: auto; background: #fff; font: 12px/1.55 Consolas, monospace; } | |
| 330 | -.diff-file pre span { display: block; min-height: 19px; padding: 0 12px; white-space: pre; } | |
| 331 | -.diff-file .addition { background: #dafbe1; color: #116329; } | |
| 332 | -.diff-file .deletion { background: #ffebe9; color: #82071e; } | |
| 324 | +.commit-message-body { max-width: 760px; margin: 0 0 14px; padding: 12px 14px; border: 1px solid #d8dee4; border-radius: 6px; background: #fff; color: #4d5661; font: 12px/1.65 Consolas, monospace; white-space: pre-wrap; overflow-wrap: anywhere; } | |
| 325 | +.commit-detail-byline { display: flex; align-items: center; gap: 8px; } | |
| 326 | +.commit-detail-byline strong { color: #1f2328; } | |
| 327 | +.commit-detail-side { display: flex; flex: 0 0 auto; align-items: center; gap: 8px; } | |
| 328 | +.commit-sha { display: inline-flex; align-items: center; gap: 7px; padding: 5px 10px; border: 1px solid #d0d7de; border-radius: 6px; background: #fff; color: #636c76; font-size: 11px; } | |
| 329 | +.commit-sha code { color: #0969da; font: 12px Consolas, monospace; } | |
| 330 | +.commit-detail-side .quiet-button { cursor: pointer; } | |
| 331 | + | |
| 332 | +.diff-summary { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 12px 20px; padding: 14px 22px; border-bottom: 1px solid #d8dee4; } | |
| 333 | +.diff-summary p { margin: 0; color: #636c76; font-size: 13px; } | |
| 334 | +.diff-summary p strong { color: #1f2328; } | |
| 335 | +.diff-added { color: #1a7f37; font: 650 12px Consolas, monospace; } | |
| 336 | +.diff-removed { color: #cf222e; font: 650 12px Consolas, monospace; } | |
| 337 | +.diff-actions { display: flex; gap: 8px; } | |
| 338 | +.diff-actions .quiet-button { cursor: pointer; } | |
| 339 | +.diff-actions .quiet-button[aria-pressed="true"] { border-color: #0969da; background: #ddf4ff; color: #0550ae; } | |
| 340 | +.diff-index { margin: 16px 22px 0; border: 1px solid #d0d7de; border-radius: 6px; background: #fff; } | |
| 341 | +.diff-index > summary { min-height: 38px; display: flex; align-items: center; padding: 0 13px; color: #57606a; font-size: 12px; font-weight: 600; cursor: pointer; list-style: none; } | |
| 342 | +.diff-index > summary::-webkit-details-marker { display: none; } | |
| 343 | +.diff-index > summary:before { margin-right: 8px; color: #8c959f; content: "\25B8"; } | |
| 344 | +.diff-index[open] > summary { border-bottom: 1px solid #eaeef2; } | |
| 345 | +.diff-index[open] > summary:before { content: "\25BE"; } | |
| 346 | +.diff-index ol { margin: 0; padding: 6px 0 10px; list-style: none; } | |
| 347 | +.diff-index li { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 14px; padding: 3px 13px; } | |
| 348 | +.diff-index a { overflow: hidden; color: #0969da; font: 12px Consolas, monospace; text-decoration: none; text-overflow: ellipsis; white-space: nowrap; } | |
| 349 | +.diff-index a:hover { text-decoration: underline; } | |
| 350 | + | |
| 351 | +.diff-file { margin: 16px 22px; overflow: hidden; border: 1px solid #d0d7de; border-radius: 6px; background: #fff; } | |
| 352 | +.diff-file > summary { display: grid; grid-template-columns: 12px auto minmax(0, 1fr) auto auto; gap: 10px; align-items: center; padding: 9px 12px; background: #f6f8fa; font-size: 12px; cursor: pointer; list-style: none; } | |
| 353 | +.diff-file > summary::-webkit-details-marker { display: none; } | |
| 354 | +.diff-file > summary:hover { background: #eef1f5; } | |
| 355 | +.diff-file[open] > summary { border-bottom: 1px solid #d0d7de; } | |
| 356 | +.diff-chevron { fill: #636c76; } | |
| 357 | +.diff-file[open] .diff-chevron { transform: rotate(90deg); } | |
| 358 | +.diff-status { padding: 1px 8px; border: 1px solid #d0d7de; border-radius: 20px; color: #636c76; font-size: 10px; font-weight: 650; text-transform: capitalize; } | |
| 359 | +.diff-status-added { border-color: #aceebb; background: #dafbe1; color: #116329; } | |
| 360 | +.diff-status-removed { border-color: #ffcecb; background: #ffebe9; color: #a40e26; } | |
| 361 | +.diff-status-renamed, .diff-status-copied { border-color: #b6d7f7; background: #ddf4ff; color: #0550ae; } | |
| 362 | +.diff-status-modified, .diff-status-changed { border-color: #eedfa6; background: #fff8c5; color: #7d4e00; } | |
| 363 | +.diff-file-name { min-width: 0; display: flex; align-items: center; gap: 6px; overflow: hidden; color: #1f2328; font: 600 12px Consolas, monospace; text-overflow: ellipsis; white-space: nowrap; } | |
| 364 | +.diff-file-path { overflow: hidden; text-overflow: ellipsis; } | |
| 365 | +.diff-rename-from { overflow: hidden; color: #8c959f; text-overflow: ellipsis; } | |
| 366 | +.diff-rename-arrow { color: #8c959f; } | |
| 367 | +.diff-file-stat { display: flex; align-items: center; gap: 8px; } | |
| 368 | +.diff-blocks { display: flex; gap: 2px; } | |
| 369 | +.diff-blocks i { width: 8px; height: 8px; border-radius: 2px; background: #d8dee4; } | |
| 370 | +.diff-blocks i.added { background: #1f883d; } | |
| 371 | +.diff-blocks i.removed { background: #cf222e; } | |
| 372 | +.diff-copy { height: 26px; padding: 0 9px; border: 1px solid #d0d7de; border-radius: 5px; background: #fff; color: #24292f; font-size: 11px; font-weight: 600; cursor: pointer; } | |
| 373 | +.diff-copy:hover { background: #f3f4f6; } | |
| 374 | +.diff-body { overflow-x: auto; background: #fff; } | |
| 375 | +.diff-table { width: max-content; min-width: 100%; border-collapse: separate; border-spacing: 0; color: #1f2328; font: 12px/1.7 Consolas, monospace; tab-size: 2; } | |
| 376 | +.diff-col-num { width: 54px; } | |
| 377 | +.diff-gutter { position: sticky; z-index: 1; width: 54px; min-width: 54px; padding: 0 8px; border-right: 1px solid #eaeef2; background: #fff; color: #8c959f; font-size: 11px; text-align: right; vertical-align: top; white-space: nowrap; user-select: none; } | |
| 378 | +.diff-gutter:first-child { left: 0; } | |
| 379 | +.diff-gutter:nth-child(2) { left: 54px; } | |
| 380 | +.diff-code { width: 100%; padding: 0 16px 0 12px; vertical-align: top; white-space: pre; } | |
| 381 | +.diff-row-addition .diff-code { background: #e6ffec; } | |
| 382 | +.diff-row-addition .diff-gutter { background: #ccffd8; color: #24292f; } | |
| 383 | +.diff-row-deletion .diff-code { background: #ffebe9; } | |
| 384 | +.diff-row-deletion .diff-gutter { background: #ffd7d5; color: #24292f; } | |
| 385 | +.diff-row-hunk .diff-code, .diff-row-hunk .diff-gutter { background: #f1f6fc; color: #57606a; } | |
| 386 | +.diff-row-hunk .diff-code { border-top: 1px solid #eaeef2; border-bottom: 1px solid #eaeef2; } | |
| 387 | +.diff-row-hunk .diff-gutter { border-top: 1px solid #eaeef2; border-bottom: 1px solid #eaeef2; } | |
| 388 | +.diff-row:first-child .diff-code, .diff-row:first-child .diff-gutter { border-top: 0; } | |
| 389 | +.hunk-heading { margin-left: 16px; color: #8c959f; } | |
| 390 | +.diff-row-note .diff-code { background: #f6f8fa; color: #8c959f; font-style: italic; } | |
| 391 | +body.diff-wrapped .diff-table { width: 100%; } | |
| 392 | +body.diff-wrapped .diff-code { white-space: pre-wrap; overflow-wrap: anywhere; } | |
| 333 | 393 | .empty-state { margin: 0; padding: 32px; color: var(--ink-soft); text-align: center; } |
| 334 | 394 | |
| 335 | 395 | .work-list-page, .discussion-page { color: #1f2328; } |
| @@ -386,6 +446,8 @@a:focus-visible, button:focus-visible, input:focus-visible { outline: 3px solid | ||
| 386 | 446 | .pull-files { margin-top: 24px; } |
| 387 | 447 | .pull-files > h3 { display: flex; align-items: center; gap: 7px; margin: 0 0 12px; font-size: 16px; } |
| 388 | 448 | .pull-files > h3 span { min-width: 22px; padding: 1px 6px; border-radius: 20px; background: #eaeef2; font-size: 11px; text-align: center; } |
| 449 | +.pull-files .diff-summary { padding: 0 0 12px; border-bottom: 0; } | |
| 450 | +.pull-files .diff-index { margin: 0 0 16px; } | |
| 389 | 451 | .pull-files .diff-file { margin: 0 0 16px; } |
| 390 | 452 | .release-list-page, .release-detail { color: #1f2328; } |
| 391 | 453 | .release-list-page { overflow: hidden; border: 1px solid #d0d7de; border-radius: 7px; background: #fff; } |
| @@ -559,7 +621,16 @@a:focus-visible, button:focus-visible, input:focus-visible { outline: 3px solid | ||
| 559 | 621 | .file-view-actions { flex-wrap: wrap; } |
| 560 | 622 | .commit-feed li { grid-template-columns: 28px minmax(0, 1fr) auto; } |
| 561 | 623 | .commit-feed .commit-change { display: none; } |
| 624 | + .commit-detail-side { width: 100%; justify-content: space-between; } | |
| 625 | + .diff-summary { padding: 12px 15px; } | |
| 626 | + .diff-index { margin: 12px 12px 0; } | |
| 562 | 627 | .diff-file { margin: 12px; } |
| 628 | + .diff-file > summary { grid-template-columns: 12px auto minmax(0, 1fr); row-gap: 8px; } | |
| 629 | + .diff-file-stat { grid-column: 2 / -1; } | |
| 630 | + .diff-copy { display: none; } | |
| 631 | + .diff-col-num { width: 42px; } | |
| 632 | + .diff-gutter { width: 42px; min-width: 42px; padding: 0 6px; } | |
| 633 | + .diff-gutter:nth-child(2) { left: 42px; } | |
| 563 | 634 | .work-list-header nav { width: 100%; overflow-x: auto; } |
| 564 | 635 | .work-list-header nav a { flex: 0 0 auto; } |
| 565 | 636 | .work-list article { grid-template-columns: 22px minmax(0, 1fr); } |
added src/views/partials/diff-file.ejs +43 −0
| @@ -0,0 +1,43 @@ | ||
| 1 | +<details class="diff-file" id="diff-<%= diffIndex %>" open> | |
| 2 | + <summary class="diff-file-header"> | |
| 3 | + <svg class="diff-chevron" width="12" height="12" viewBox="0 0 12 12" aria-hidden="true"><path d="M4 2.2 7.8 6 4 9.8Z"/></svg> | |
| 4 | + <span class="diff-status diff-status-<%= changed.status %>"><%= changed.status %></span> | |
| 5 | + <span class="diff-file-name"> | |
| 6 | + <% if (changed.previousFilename) { %><span class="diff-rename-from"><%= changed.previousFilename %></span><span class="diff-rename-arrow" aria-hidden="true">→</span><% } %> | |
| 7 | + <span class="diff-file-path"><%= changed.filename %></span> | |
| 8 | + </span> | |
| 9 | + <span class="diff-file-stat"> | |
| 10 | + <span class="diff-added">+<%= changed.additions %></span> | |
| 11 | + <span class="diff-removed">−<%= changed.deletions %></span> | |
| 12 | + <span class="diff-blocks" aria-hidden="true"><% diffBlocks(changed.additions, changed.deletions).forEach((block) => { %><i class="<%= block %>"></i><% }) %></span> | |
| 13 | + </span> | |
| 14 | + <button class="diff-copy" type="button" data-copy-text="<%= changed.filename %>">Copy path</button> | |
| 15 | + </summary> | |
| 16 | + <% if (!changed.patch) { %> | |
| 17 | + <p class="empty-state">Line changes are not available for this file.</p> | |
| 18 | + <% } else { %> | |
| 19 | + <div class="diff-body"> | |
| 20 | + <table class="diff-table" aria-label="Changed lines in <%= changed.filename %>"> | |
| 21 | + <colgroup><col class="diff-col-num"><col class="diff-col-num"><col></colgroup> | |
| 22 | + <tbody> | |
| 23 | + <% parseDiff(changed.patch).forEach((hunk) => { %> | |
| 24 | + <% if (hunk.range) { %> | |
| 25 | + <tr class="diff-row diff-row-hunk"> | |
| 26 | + <td class="diff-gutter"></td> | |
| 27 | + <td class="diff-gutter"></td> | |
| 28 | + <td class="diff-code"><span class="hunk-range"><%= hunk.range %></span><% if (hunk.heading) { %><span class="hunk-heading"><%= hunk.heading %></span><% } %></td> | |
| 29 | + </tr> | |
| 30 | + <% } %> | |
| 31 | + <% hunk.lines.forEach((line) => { %> | |
| 32 | + <tr class="diff-row diff-row-<%= line.type %>"> | |
| 33 | + <td class="diff-gutter"><%= line.oldNumber || "" %></td> | |
| 34 | + <td class="diff-gutter"><%= line.newNumber || "" %></td> | |
| 35 | + <td class="diff-code"><%= line.text %></td> | |
| 36 | + </tr> | |
| 37 | + <% }) %> | |
| 38 | + <% }) %> | |
| 39 | + </tbody> | |
| 40 | + </table> | |
| 41 | + </div> | |
| 42 | + <% } %> | |
| 43 | +</details> |
added src/views/partials/diff-list.ejs +32 −0
| @@ -0,0 +1,32 @@ | ||
| 1 | +<div class="diff-summary"> | |
| 2 | + <p> | |
| 3 | + <strong><%= files.length %></strong> changed <%= files.length === 1 ? "file" : "files" %> | |
| 4 | + with <strong class="diff-added">+<%= diffAdditions %></strong> | |
| 5 | + and <strong class="diff-removed">−<%= diffDeletions %></strong> | |
| 6 | + </p> | |
| 7 | + <% if (files.length) { %> | |
| 8 | + <div class="diff-actions"> | |
| 9 | + <button class="quiet-button" type="button" data-diff-wrap aria-pressed="false">Wrap lines</button> | |
| 10 | + <button class="quiet-button" type="button" data-diff-toggle-all>Collapse all</button> | |
| 11 | + </div> | |
| 12 | + <% } %> | |
| 13 | +</div> | |
| 14 | +<% if (files.length > 1) { %> | |
| 15 | + <details class="diff-index"> | |
| 16 | + <summary>Jump to a changed file</summary> | |
| 17 | + <ol> | |
| 18 | + <% files.forEach((changed, index) => { %> | |
| 19 | + <li> | |
| 20 | + <a href="#diff-<%= index %>"><%= changed.filename %></a> | |
| 21 | + <span><span class="diff-added">+<%= changed.additions %></span> <span class="diff-removed">−<%= changed.deletions %></span></span> | |
| 22 | + </li> | |
| 23 | + <% }) %> | |
| 24 | + </ol> | |
| 25 | + </details> | |
| 26 | +<% } %> | |
| 27 | +<% files.forEach((changed, index) => { %> | |
| 28 | + <%- include("diff-file", { changed, diffIndex: index }) %> | |
| 29 | +<% }) %> | |
| 30 | +<% if (!files.length) { %> | |
| 31 | + <p class="empty-state"><%= emptyMessage %></p> | |
| 32 | +<% } %> |
modified src/views/repository.ejs +26 −38
| @@ -79,33 +79,30 @@ | ||
| 79 | 79 | </nav> |
| 80 | 80 | <section class="commit-detail-card"> |
| 81 | 81 | <header class="commit-detail-heading"> |
| 82 | - <div> | |
| 83 | - <p class="section-kicker">Commit <code><%= commit.sha.slice(0, 7) %></code></p> | |
| 82 | + <div class="commit-detail-main"> | |
| 83 | + <p class="section-kicker">Commit</p> | |
| 84 | 84 | <h2><%= commit.message.split("\n")[0] %></h2> |
| 85 | - <p><strong><%= commit.author %></strong> committed <%= formatDate(commit.date) %></p> | |
| 85 | + <% const commitBody = commit.message.split("\n").slice(1).join("\n").trim(); %> | |
| 86 | + <% if (commitBody) { %><pre class="commit-message-body"><%= commitBody %></pre><% } %> | |
| 87 | + <p class="commit-detail-byline"> | |
| 88 | + <span class="commit-avatar" aria-hidden="true"><%= commit.author.slice(0, 1).toUpperCase() %></span> | |
| 89 | + <strong><%= commit.author %></strong> committed <%= formatDate(commit.date) %> | |
| 90 | + </p> | |
| 86 | 91 | </div> |
| 87 | - <div class="diff-total" aria-label="Commit changes"> | |
| 88 | - <span>+<%= commit.additions %></span> | |
| 89 | - <span>−<%= commit.deletions %></span> | |
| 92 | + <div class="commit-detail-side"> | |
| 93 | + <span class="commit-sha"> | |
| 94 | + <span>commit</span> | |
| 95 | + <code><%= commit.sha.slice(0, 7) %></code> | |
| 96 | + </span> | |
| 97 | + <button class="quiet-button" type="button" data-copy-text="<%= commit.sha %>">Copy SHA</button> | |
| 90 | 98 | </div> |
| 91 | 99 | </header> |
| 92 | - <% commit.files.forEach((changed) => { %> | |
| 93 | - <section class="diff-file"> | |
| 94 | - <header> | |
| 95 | - <strong><%= changed.filename %></strong> | |
| 96 | - <span><%= changed.status %> · +<%= changed.additions %> −<%= changed.deletions %></span> | |
| 97 | - </header> | |
| 98 | - <% if (!changed.patch) { %> | |
| 99 | - <p class="empty-state">Line changes are not available for this file.</p> | |
| 100 | - <% } else { %> | |
| 101 | - <pre><% changed.patch.split("\n").forEach((line) => { let kind = line.startsWith("+") && !line.startsWith("+++") ? "addition" : line.startsWith("-") && !line.startsWith("---") ? "deletion" : ""; %><span class="<%= kind %>"><%= line %></span> | |
| 102 | -<% }) %></pre> | |
| 103 | - <% } %> | |
| 104 | - </section> | |
| 105 | - <% }) %> | |
| 106 | - <% if (!commit.files.length) { %> | |
| 107 | - <p class="empty-state">No changed files are available for this commit.</p> | |
| 108 | - <% } %> | |
| 100 | + <%- include("partials/diff-list", { | |
| 101 | + files: commit.files, | |
| 102 | + diffAdditions: commit.additions, | |
| 103 | + diffDeletions: commit.deletions, | |
| 104 | + emptyMessage: "No changed files are available for this commit." | |
| 105 | + }) %> | |
| 109 | 106 | </section> |
| 110 | 107 | <% } else if (issue) { %> |
| 111 | 108 | <nav class="repo-breadcrumbs" aria-label="Issue navigation"> |
| @@ -219,21 +216,12 @@ | ||
| 219 | 216 | <% }) %> |
| 220 | 217 | <section class="pull-files"> |
| 221 | 218 | <h3>Files changed <span><%= pullRequest.files.length %></span></h3> |
| 222 | - <% pullRequest.files.forEach((changed) => { %> | |
| 223 | - <section class="diff-file"> | |
| 224 | - <header> | |
| 225 | - <strong><%= changed.filename %></strong> | |
| 226 | - <span><%= changed.status %> · +<%= changed.additions %> −<%= changed.deletions %></span> | |
| 227 | - </header> | |
| 228 | - <% if (!changed.patch) { %> | |
| 229 | - <p class="empty-state">Line changes are not available for this file.</p> | |
| 230 | - <% } else { %> | |
| 231 | - <pre><% changed.patch.split("\n").forEach((line) => { let kind = line.startsWith("+") && !line.startsWith("+++") ? "addition" : line.startsWith("-") && !line.startsWith("---") ? "deletion" : ""; %><span class="<%= kind %>"><%= line %></span> | |
| 232 | -<% }) %></pre> | |
| 233 | - <% } %> | |
| 234 | - </section> | |
| 235 | - <% }) %> | |
| 236 | - <% if (!pullRequest.files.length) { %><p class="empty-state">No changed files are included in this snapshot.</p><% } %> | |
| 219 | + <%- include("partials/diff-list", { | |
| 220 | + files: pullRequest.files, | |
| 221 | + diffAdditions: pullRequest.additions, | |
| 222 | + diffDeletions: pullRequest.deletions, | |
| 223 | + emptyMessage: "No changed files are included in this snapshot." | |
| 224 | + }) %> | |
| 237 | 225 | </section> |
| 238 | 226 | </div> |
| 239 | 227 | <aside class="work-sidebar"> |
modified tests/commits.test.js +32 −0
| @@ -18,4 +18,36 @@describe("commits", () => { | ||
| 18 | 18 | const empty = await harness.agent.get(`/s/${id}/repositories/101?commit=${emptySha}`).expect(200); |
| 19 | 19 | expect(empty.text).toContain("No changed files are available for this commit."); |
| 20 | 20 | }); |
| 21 | + | |
| 22 | + it("renders the diff as a numbered table with hunk headers and a change summary", async () => { | |
| 23 | + harness = makeHarness(); | |
| 24 | + const { id } = await createShare(harness); | |
| 25 | + const sha = "101".padEnd(40, "a"); | |
| 26 | + const page = await harness.agent.get(`/s/${id}/repositories/101?commit=${sha}`).expect(200); | |
| 27 | + | |
| 28 | + expect(page.text).toContain("<strong>2</strong> changed files"); | |
| 29 | + expect(page.text).toContain('<span class="hunk-range">@@ -1 +1 @@</span>'); | |
| 30 | + expect(page.text).toMatch( | |
| 31 | + /diff-row-deletion[\s\S]*?diff-gutter">1<\/td>[\s\S]*?diff-gutter"><\/td>[\s\S]*?-false/, | |
| 32 | + ); | |
| 33 | + expect(page.text).toMatch( | |
| 34 | + /diff-row-addition[\s\S]*?diff-gutter"><\/td>[\s\S]*?diff-gutter">1<\/td>[\s\S]*?\+true/, | |
| 35 | + ); | |
| 36 | + expect(page.text).toContain("data-diff-wrap"); | |
| 37 | + expect(page.text).toContain("Jump to a changed file"); | |
| 38 | + }); | |
| 39 | + | |
| 40 | + it("numbers a patch that has no hunk header", async () => { | |
| 41 | + harness = makeHarness(); | |
| 42 | + const { id } = await createShare(harness); | |
| 43 | + const branchSha = "102".padEnd(40, "c"); | |
| 44 | + const page = await harness.agent | |
| 45 | + .get(`/s/${id}/repositories/101?ref=review-notes&refType=branch&commit=${branchSha}`) | |
| 46 | + .expect(200); | |
| 47 | + expect(page.text).toMatch(/<strong>1<\/strong> changed file\s/); | |
| 48 | + expect(page.text).toMatch( | |
| 49 | + /diff-row-addition[\s\S]*?diff-gutter">1<\/td>[\s\S]*?\+export const branch/, | |
| 50 | + ); | |
| 51 | + expect(page.text).not.toContain("hunk-range"); | |
| 52 | + }); | |
| 21 | 53 | }); |
modified tests/issues-pulls.test.js +1 −0
| @@ -41,6 +41,7 @@describe("issues and pull requests", () => { | ||
| 41 | 41 | expect(detail.text).toContain("Files changed"); |
| 42 | 42 | expect(detail.text).toContain("src/index.js"); |
| 43 | 43 | expect(detail.text).toContain("export const keyboard = true;"); |
| 44 | + expect(detail.text).toMatch(/diff-row-addition[\s\S]*?diff-gutter">2<\/td>/); | |
| 44 | 45 | }); |
| 45 | 46 | |
| 46 | 47 | it("finds issues and pull requests through snapshot search", async () => { |