profileShare

rasmusjy / profileshare

Read-only snapshot

No repository description.

main default branch 54 files Expires Sep 13, 2026, 9:06 AM

Commit

Show commit diffs in the snapshot, keep the deploy target out of the repo

A commit now opens its own diff, file by file, rendered from the same partials
the file browser uses. The deploy target is no longer written into deploy.sh:
this code is shared read-only with people outside the project, and the server
address and login are not theirs to have. Set VPS=user@host in your shell
before running deploy.sh.
commit 492bf51

11 changed files with +345 and −54

Jump to a changed file
  1. DEPLOY.md +2 −2
  2. deploy.sh +7 −1
  3. docker-compose.yml +5 −0
  4. src/app.js +71 −0
  5. src/public/app.js +43 −1
  6. src/public/styles.css +83 −12
  7. src/views/partials/diff-file.ejs +43 −0
  8. src/views/partials/diff-list.ejs +32 −0
  9. src/views/repository.ejs +26 −38
  10. tests/commits.test.js +32 −0
  11. 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">&rarr;</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">&minus;<%= 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">&minus;<%= 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">&minus;<%= 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>&minus;<%= 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 %> &middot; +<%= changed.additions %> &minus;<%= 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 %> &minus;<%= 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 () => {