This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

web: add the commit log and commit pages

Signed-off-by: dawn <dawn@tangled.org>

author
dawn
date (Aug 1, 2026, 2:21 AM +0300) commit 0fa0d4fa parent 19e6e414 change-id mlnmrwzn
+2302 -2
+353
web/src/lib/api/rawdiff.test.ts
··· 1 + import { describe, expect, it } from "vitest"; 2 + import { LineOp, type NiceDiff } from "./diff"; 3 + import { renderFormatPatch, renderUnifiedDiff } from "./rawdiff"; 4 + 5 + const modifiedFile: NiceDiff = { 6 + diff: [ 7 + { 8 + name: { old: "foo.go", new: "foo.go" }, 9 + text_fragments: [ 10 + { 11 + Comment: "func main()", 12 + OldPosition: 1, 13 + OldLines: 3, 14 + NewPosition: 1, 15 + NewLines: 3, 16 + Lines: [ 17 + { Op: LineOp.Context, Line: "package main\n" }, 18 + { Op: LineOp.Delete, Line: "// old comment\n" }, 19 + { Op: LineOp.Add, Line: "// new comment\n" }, 20 + { Op: LineOp.Context, Line: "func main() {}\n" } 21 + ] 22 + } 23 + ] 24 + } 25 + ] 26 + }; 27 + 28 + describe("renderUnifiedDiff", () => { 29 + it("renders nothing for a missing diff", () => { 30 + expect(renderUnifiedDiff(null)).toBe(""); 31 + expect(renderUnifiedDiff(undefined)).toBe(""); 32 + }); 33 + 34 + it("renders a modified file with the hunk comment", () => { 35 + expect(renderUnifiedDiff(modifiedFile)).toBe( 36 + "diff --git a/foo.go b/foo.go\n" + 37 + "--- a/foo.go\n" + 38 + "+++ b/foo.go\n" + 39 + "@@ -1,3 +1,3 @@ func main()\n" + 40 + " package main\n" + 41 + "-// old comment\n" + 42 + "+// new comment\n" + 43 + " func main() {}\n" 44 + ); 45 + }); 46 + 47 + it("renders a new file", () => { 48 + const got = renderUnifiedDiff({ 49 + diff: [ 50 + { 51 + name: { old: "", new: "new.go" }, 52 + is_new: true, 53 + text_fragments: [ 54 + { 55 + OldPosition: 0, 56 + OldLines: 0, 57 + NewPosition: 1, 58 + NewLines: 2, 59 + Lines: [ 60 + { Op: LineOp.Add, Line: "package main\n" }, 61 + { Op: LineOp.Add, Line: "func main() {}\n" } 62 + ] 63 + } 64 + ] 65 + } 66 + ] 67 + }); 68 + expect(got).toBe( 69 + "diff --git a/new.go b/new.go\n" + 70 + "new file mode 100644\n" + 71 + "--- /dev/null\n" + 72 + "+++ b/new.go\n" + 73 + "@@ -0,0 +1,2 @@\n" + 74 + "+package main\n" + 75 + "+func main() {}\n" 76 + ); 77 + }); 78 + 79 + it("renders a deleted file", () => { 80 + const got = renderUnifiedDiff({ 81 + diff: [ 82 + { 83 + name: { old: "old.go", new: "" }, 84 + is_delete: true, 85 + text_fragments: [ 86 + { 87 + OldPosition: 1, 88 + OldLines: 2, 89 + NewPosition: 0, 90 + NewLines: 0, 91 + Lines: [ 92 + { Op: LineOp.Delete, Line: "package main\n" }, 93 + { Op: LineOp.Delete, Line: "func main() {}\n" } 94 + ] 95 + } 96 + ] 97 + } 98 + ] 99 + }); 100 + expect(got).toBe( 101 + "diff --git a/old.go b/old.go\n" + 102 + "deleted file mode 100644\n" + 103 + "--- a/old.go\n" + 104 + "+++ /dev/null\n" + 105 + "@@ -1,2 +0,0 @@\n" + 106 + "-package main\n" + 107 + "-func main() {}\n" 108 + ); 109 + }); 110 + 111 + it("renders a renamed file with multiple fragments", () => { 112 + const got = renderUnifiedDiff({ 113 + diff: [ 114 + { 115 + name: { old: "old.go", new: "renamed.go" }, 116 + is_rename: true, 117 + text_fragments: [ 118 + { 119 + OldPosition: 1, 120 + OldLines: 2, 121 + NewPosition: 1, 122 + NewLines: 2, 123 + Lines: [ 124 + { Op: LineOp.Context, Line: "package main\n" }, 125 + { Op: LineOp.Delete, Line: "func old() {}\n" }, 126 + { Op: LineOp.Add, Line: "func renamed() {}\n" } 127 + ] 128 + }, 129 + { 130 + Comment: "func init()", 131 + OldPosition: 10, 132 + OldLines: 1, 133 + NewPosition: 10, 134 + NewLines: 1, 135 + Lines: [{ Op: LineOp.Context, Line: "var x = 1\n" }] 136 + } 137 + ] 138 + } 139 + ] 140 + }); 141 + expect(got).toBe( 142 + "diff --git a/old.go b/renamed.go\n" + 143 + "rename from old.go\n" + 144 + "rename to renamed.go\n" + 145 + "--- a/old.go\n" + 146 + "+++ b/renamed.go\n" + 147 + "@@ -1,2 +1,2 @@\n" + 148 + " package main\n" + 149 + "-func old() {}\n" + 150 + "+func renamed() {}\n" + 151 + "@@ -10,1 +10,1 @@ func init()\n" + 152 + " var x = 1\n" 153 + ); 154 + }); 155 + 156 + it("renders multiple files", () => { 157 + const file = (name: string, oldLine: string, newLine: string) => ({ 158 + name: { old: name, new: name }, 159 + text_fragments: [ 160 + { 161 + OldPosition: 1, 162 + OldLines: 1, 163 + NewPosition: 1, 164 + NewLines: 1, 165 + Lines: [ 166 + { Op: LineOp.Delete, Line: `${oldLine}\n` }, 167 + { Op: LineOp.Add, Line: `${newLine}\n` } 168 + ] 169 + } 170 + ] 171 + }); 172 + const got = renderUnifiedDiff({ 173 + diff: [file("a.go", "old a", "new a"), file("b.go", "old b", "new b")] 174 + }); 175 + expect(got).toBe( 176 + "diff --git a/a.go b/a.go\n" + 177 + "--- a/a.go\n" + 178 + "+++ b/a.go\n" + 179 + "@@ -1,1 +1,1 @@\n" + 180 + "-old a\n" + 181 + "+new a\n" + 182 + "diff --git a/b.go b/b.go\n" + 183 + "--- a/b.go\n" + 184 + "+++ b/b.go\n" + 185 + "@@ -1,1 +1,1 @@\n" + 186 + "-old b\n" + 187 + "+new b\n" 188 + ); 189 + }); 190 + 191 + it("marks a missing trailing newline on delete and context lines", () => { 192 + const got = renderUnifiedDiff({ 193 + diff: [ 194 + { 195 + name: { old: "foo.go", new: "foo.go" }, 196 + text_fragments: [ 197 + { 198 + OldPosition: 1, 199 + OldLines: 2, 200 + NewPosition: 1, 201 + NewLines: 2, 202 + Lines: [ 203 + { Op: LineOp.Delete, Line: "old" }, 204 + { Op: LineOp.Add, Line: "new\n" }, 205 + { Op: LineOp.Context, Line: "tail" } 206 + ] 207 + } 208 + ] 209 + } 210 + ] 211 + }); 212 + expect(got).toBe( 213 + "diff --git a/foo.go b/foo.go\n" + 214 + "--- a/foo.go\n" + 215 + "+++ b/foo.go\n" + 216 + "@@ -1,2 +1,2 @@\n" + 217 + "-old\n" + 218 + "\\ No newline at end of file\n" + 219 + "+new\n" + 220 + " tail\n" + 221 + "\\ No newline at end of file\n" 222 + ); 223 + }); 224 + }); 225 + 226 + describe("renderFormatPatch", () => { 227 + it("renders nothing for a missing diff", () => { 228 + expect(renderFormatPatch(null)).toBe(""); 229 + expect(renderFormatPatch(undefined)).toBe(""); 230 + }); 231 + 232 + it("renders the full patch for a rename and a delete", () => { 233 + const got = renderFormatPatch({ 234 + commit: { 235 + hash: [0xab, 0xc1, 0x23, 0x45, 0x67, 0x89, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], 236 + message: "Fix the bug\n\nThis patch resolves the long-standing issue.\n\n\n", 237 + author: { Name: "Alice Dev", Email: "alice@example.com", When: "2024-03-15T10:30:00Z" } 238 + }, 239 + stat: { files_changed: 2, insertions: 1, deletions: 3 }, 240 + diff: [ 241 + { 242 + name: { old: "old.go", new: "renamed.go" }, 243 + is_rename: true, 244 + text_fragments: [ 245 + { 246 + OldPosition: 1, 247 + OldLines: 2, 248 + NewPosition: 1, 249 + NewLines: 2, 250 + LinesAdded: 1, 251 + LinesDeleted: 1, 252 + Lines: [ 253 + { Op: LineOp.Context, Line: "package main\n" }, 254 + { Op: LineOp.Delete, Line: "func old() {}\n" }, 255 + { Op: LineOp.Add, Line: "func renamed() {}\n" } 256 + ] 257 + } 258 + ] 259 + }, 260 + { 261 + name: { old: "gone.go", new: "" }, 262 + is_delete: true, 263 + text_fragments: [ 264 + { 265 + OldPosition: 1, 266 + OldLines: 2, 267 + NewPosition: 0, 268 + NewLines: 0, 269 + LinesAdded: 0, 270 + LinesDeleted: 2, 271 + Lines: [ 272 + { Op: LineOp.Delete, Line: "package main\n" }, 273 + { Op: LineOp.Delete, Line: "func main() {}\n" } 274 + ] 275 + } 276 + ] 277 + } 278 + ] 279 + }); 280 + expect(got).toBe( 281 + "From abc1234567890000000000000000000000000000 Mon Sep 17 00:00:00 2001\n" + 282 + "From: Alice Dev <alice@example.com>\n" + 283 + "Date: Fri, 15 Mar 2024 10:30:00 +0000\n" + 284 + "Subject: [PATCH] Fix the bug\n" + 285 + "\n" + 286 + "This patch resolves the long-standing issue.\n" + 287 + "---\n" + 288 + " renamed.go | 2 +-\n" + 289 + " gone.go | 2 --\n" + 290 + " 2 file(s) changed, 1 insertion(s)(+), 3 deletion(s)(-)\n" + 291 + "\n" + 292 + "diff --git a/old.go b/renamed.go\n" + 293 + "rename from old.go\n" + 294 + "rename to renamed.go\n" + 295 + "--- a/old.go\n" + 296 + "+++ b/renamed.go\n" + 297 + "@@ -1,2 +1,2 @@\n" + 298 + " package main\n" + 299 + "-func old() {}\n" + 300 + "+func renamed() {}\n" + 301 + "diff --git a/gone.go b/gone.go\n" + 302 + "deleted file mode 100644\n" + 303 + "--- a/gone.go\n" + 304 + "+++ /dev/null\n" + 305 + "@@ -1,2 +0,0 @@\n" + 306 + "-package main\n" + 307 + "-func main() {}\n" + 308 + "\n--\ntangled.sh\n" 309 + ); 310 + }); 311 + 312 + it("omits the body for a single-line message and uses the zero time for a missing When", () => { 313 + const got = renderFormatPatch({ 314 + commit: { 315 + message: "Single line commit", 316 + author: { Name: "", Email: "", When: "" } 317 + } 318 + }); 319 + expect(got).toBe( 320 + "From Mon Sep 17 00:00:00 2001\n" + 321 + "From: <>\n" + 322 + "Date: Mon, 01 Jan 0001 00:00:00 +0000\n" + 323 + "Subject: [PATCH] Single line commit\n" + 324 + "\n" + 325 + "---\n" + 326 + " 0 file(s) changed, 0 insertion(s)(+), 0 deletion(s)(-)\n" + 327 + "\n" + 328 + "\n--\ntangled.sh\n" 329 + ); 330 + }); 331 + 332 + it("uses the zero time for an unparseable When", () => { 333 + const got = renderFormatPatch({ 334 + commit: { 335 + this: "abc123", 336 + message: "x", 337 + author: { Name: "A", Email: "a@b.c", When: "not a date" } 338 + } 339 + }); 340 + expect(got).toContain("Date: Mon, 01 Jan 0001 00:00:00 +0000\n"); 341 + }); 342 + 343 + it("pads the year to four digits", () => { 344 + const got = renderFormatPatch({ 345 + commit: { 346 + this: "abc123", 347 + message: "x", 348 + author: { Name: "A", Email: "a@b.c", When: "0999-06-15T10:30:00Z" } 349 + } 350 + }); 351 + expect(got).toContain("Date: Sat, 15 Jun 0999 10:30:00 +0000\n"); 352 + }); 353 + });
+64
web/src/lib/api/repo.test.ts
··· 1 1 import { describe, expect, it, vi } from "vitest"; 2 2 import { 3 + coAuthorsFrom, 3 4 logFor, 4 5 repoNameOf, 5 6 resolveRepoByName, 6 7 sortTreeEntries, 7 8 toBranchSummary, 9 + toCommitDetail, 8 10 toCommitSummary, 9 11 toTagSummary, 10 12 toTreeEntrySummary, ··· 15 17 type TreeEntrySummary 16 18 } from "./repo"; 17 19 import { ClientResponseError, createBobbinClient, type BobbinContext } from "./client"; 20 + import type { NiceCommit } from "./diff"; 18 21 import type { RecordView, RepoRecord } from "./records"; 19 22 20 23 const jsonResponse = (body: unknown): Response => ··· 96 99 const summary = toCommitSummary({ ...commit, message: "one liner\n" }); 97 100 expect(summary.body).toBe(""); 98 101 expect(summary.subject).toBe("one liner"); 102 + }); 103 + }); 104 + 105 + describe("toCommitDetail", () => { 106 + const commit: NiceCommit = { 107 + this: "0c4d0e9b07940033721395a434b5873f0fb9e6c8", 108 + author: { Name: "Ada", Email: "ada@example.com", When: "2026-07-01T10:00:00Z" }, 109 + committer: { Name: "Grace", Email: "grace@example.com", When: "2026-07-02T10:00:00Z" }, 110 + message: 111 + "web: add commit page\n\nA longer body.\n\nCo-authored-by: Alan Turing <alan@example.com>\n" 112 + }; 113 + 114 + it("carries both signatures and stamps co-authors with the committer's When", () => { 115 + expect(toCommitDetail(commit)).toEqual({ 116 + hash: "0c4d0e9b07940033721395a434b5873f0fb9e6c8", 117 + shortHash: "0c4d0e9b", 118 + subject: "web: add commit page", 119 + body: "A longer body.\n\nCo-authored-by: Alan Turing <alan@example.com>", 120 + authorName: "Ada", 121 + authorEmail: "ada@example.com", 122 + authorWhen: "2026-07-01T10:00:00Z", 123 + committerName: "Grace", 124 + committerEmail: "grace@example.com", 125 + committerWhen: "2026-07-02T10:00:00Z", 126 + coAuthors: [{ name: "Alan Turing", email: "alan@example.com" }] 127 + }); 128 + }); 129 + 130 + it("fills empty strings for a bare commit", () => { 131 + expect(toCommitDetail({})).toEqual({ 132 + hash: "", 133 + shortHash: "", 134 + subject: "", 135 + body: "", 136 + authorName: "", 137 + authorEmail: "", 138 + authorWhen: "", 139 + committerName: "", 140 + committerEmail: "", 141 + committerWhen: "", 142 + coAuthors: [] 143 + }); 144 + }); 145 + }); 146 + 147 + describe("coAuthorsFrom", () => { 148 + it("stamps every co-author with the passed When, like go's Commit.CoAuthors", () => { 149 + const message = 150 + "subject\n\nCo-authored-by: Ada Lovelace <ada@example.com>\nCo-authored-by: Alan Turing <alan@example.com>\n"; 151 + expect(coAuthorsFrom(message, "2026-07-02T10:00:00Z")).toEqual([ 152 + { Name: "Ada Lovelace", Email: "ada@example.com", When: "2026-07-02T10:00:00Z" }, 153 + { Name: "Alan Turing", Email: "alan@example.com", When: "2026-07-02T10:00:00Z" } 154 + ]); 155 + }); 156 + 157 + it("dedupes by email and matches the trailer case-insensitively", () => { 158 + const message = 159 + "subject\n\nco-authored-by: Ada <ada@example.com>\nCo-Authored-By: Ada Again <ada@example.com>\n"; 160 + expect(coAuthorsFrom(message, "w")).toEqual([ 161 + { Name: "Ada", Email: "ada@example.com", When: "w" } 162 + ]); 99 163 }); 100 164 }); 101 165
+70 -1
web/src/lib/api/repo.ts
··· 1 1 import { ClientResponseError, type BobbinContext, type XrpcRequestInit } from "./client"; 2 + import type { NiceCommit } from "./diff"; 2 3 import { getRepoByName, type RecordView, type RepoRecord } from "./records"; 3 4 import { branches as knotBranches, log as knotLog, tag as knotTag, tags as knotTags } from "./knot"; 4 5 import { httpStatusFor } from "./load"; ··· 14 15 Email: string; 15 16 When: string; 16 17 } 18 + 19 + // go-git's IsHash accepts uppercase hex too (hex.DecodeString), both sha1 20 + // and sha256 match case-insensitively 21 + export const FULL_HASH_RE = /^([0-9a-fA-F]{40}|[0-9a-fA-F]{64})$/; 22 + 23 + export const parseRawCommit = (spec: string): { ref: string; format: "patch" | "diff" } | null => { 24 + const dot = spec.lastIndexOf("."); 25 + if (dot === -1) return null; 26 + const format = spec.slice(dot + 1); 27 + if (format !== "patch" && format !== "diff") return null; 28 + const ref = spec.slice(0, dot); 29 + return FULL_HASH_RE.test(ref) ? { ref, format } : null; 30 + }; 17 31 18 32 export interface GitCommit { 19 33 Author?: GitSignature; ··· 106 120 changeId?: string; 107 121 } 108 122 109 - const splitMessage = (message: string): [string, string] => { 123 + export const splitMessage = (message: string): [string, string] => { 110 124 const separator = message.indexOf("\n\n"); 111 125 if (separator === -1) return [message.trim(), ""]; 112 126 return [message.slice(0, separator).trim(), message.slice(separator + 2).trim()]; ··· 115 129 /** the subject is the first paragraph, not the first line */ 116 130 export const subjectOf = (message: string): string => splitMessage(message)[0]; 117 131 132 + const coAuthorPattern = /^Co-authored-by:\s*(.+?)\s*<([^>]+)>/gim; 133 + 134 + // trailers carry no date of their own, like types/commit.go every 135 + // co-author gets stamped with the committer's When 136 + export const coAuthorsFrom = (message: string, when: string): GitSignature[] => { 137 + const seen = new Set<string>(); 138 + const coAuthors: GitSignature[] = []; 139 + for (const match of message.matchAll(coAuthorPattern)) { 140 + const name = match[1].trim(); 141 + const email = match[2].trim(); 142 + if (seen.has(email)) continue; 143 + seen.add(email); 144 + coAuthors.push({ Name: name, Email: email, When: when }); 145 + } 146 + return coAuthors; 147 + }; 148 + 118 149 export const toCommitSummary = (commit: LogCommit): CommitSummary => { 119 150 const [subject, body] = splitMessage(commit.message ?? ""); 120 151 const hash = commit.this ?? ""; ··· 127 158 authorEmail: commit.author?.Email ?? "", 128 159 when: commit.committer?.When ?? commit.author?.When ?? "", 129 160 changeId: commit.change_id 161 + }; 162 + }; 163 + 164 + export interface CommitDetail { 165 + hash: string; 166 + shortHash: string; 167 + subject: string; 168 + body: string; 169 + authorName: string; 170 + authorEmail: string; 171 + authorWhen: string; 172 + committerName: string; 173 + committerEmail: string; 174 + committerWhen: string; 175 + coAuthors: { name: string; email: string }[]; 176 + } 177 + 178 + // the diff endpoint's commit carries both signatures, unlike the log's 179 + // summary shape 180 + export const toCommitDetail = (commit: NiceCommit): CommitDetail => { 181 + const [subject, body] = splitMessage(commit.message ?? ""); 182 + const hash = commit.this ?? ""; 183 + const committerWhen = commit.committer?.When ?? ""; 184 + return { 185 + hash, 186 + shortHash: hash.slice(0, 8), 187 + subject, 188 + body, 189 + authorName: commit.author?.Name ?? "", 190 + authorEmail: commit.author?.Email ?? "", 191 + authorWhen: commit.author?.When ?? "", 192 + committerName: commit.committer?.Name ?? "", 193 + committerEmail: commit.committer?.Email ?? "", 194 + committerWhen, 195 + coAuthors: coAuthorsFrom(commit.message ?? "", committerWhen).map(({ Name, Email }) => ({ 196 + name: Name, 197 + email: Email 198 + })) 130 199 }; 131 200 }; 132 201
+116
web/src/lib/components/repo/CommitHeader.stories.svelte
··· 1 + <script module lang="ts"> 2 + import { defineMeta, type StoryContext } from "@storybook/addon-svelte-csf"; 3 + import { expect } from "storybook/test"; 4 + import type { CommitDetail } from "$lib/api/repo"; 5 + import CommitHeader from "./CommitHeader.svelte"; 6 + 7 + const fullCommit: CommitDetail = { 8 + hash: "0123456789abcdef0123456789abcdef01234567", 9 + shortHash: "01234567", 10 + subject: "add a storybook fixture", 11 + body: "the appview shows the body directly, no expander.\n\nit keeps line breaks too.", 12 + authorName: "dawn", 13 + authorEmail: "dawn@example.test", 14 + authorWhen: "2026-07-28T09:00:00Z", 15 + committerName: "cheri", 16 + committerEmail: "cheri@example.test", 17 + committerWhen: "2026-07-28T10:00:00Z", 18 + // the api layer already dedupes repeated trailers 19 + coAuthors: [{ name: "riley", email: "riley@example.test" }] 20 + }; 21 + 22 + const minimalCommit: CommitDetail = { 23 + hash: "abcdef0123456789abcdef0123456789abcdef01", 24 + shortHash: "abcdef01", 25 + subject: "document the repository view", 26 + body: "", 27 + authorName: "dawn", 28 + authorEmail: "dawn@example.test", 29 + authorWhen: "2026-07-27T09:00:00Z", 30 + committerName: "dawn", 31 + committerEmail: "dawn@example.test", 32 + committerWhen: "2026-07-27T09:00:00Z", 33 + coAuthors: [] 34 + }; 35 + 36 + const noEmails: CommitDetail = { 37 + ...minimalCommit, 38 + subject: "signed off without an address", 39 + authorEmail: "", 40 + committerEmail: "" 41 + }; 42 + 43 + type PlayContext = Pick< 44 + StoryContext<Record<string, unknown>>, 45 + "canvas" | "canvasElement" | "userEvent" 46 + >; 47 + 48 + const fullMetadata = async ({ canvas, canvasElement }: PlayContext) => { 49 + await expect(canvas.queryByRole("button")).toBeNull(); 50 + await expect(canvas.getByText(/the appview shows the body directly/)).toBeVisible(); 51 + await expect(canvas.getByText(/it keeps line breaks too/)).toBeVisible(); 52 + 53 + const author = canvas.getByRole("link", { name: "dawn" }); 54 + await expect(author).toHaveAttribute("href", "mailto:dawn@example.test"); 55 + await expect(canvasElement.querySelector("svg")).not.toBeNull(); 56 + 57 + await expect(canvas.getAllByText("co-author")).toHaveLength(1); 58 + await expect(canvas.getByRole("link", { name: "riley" })).toHaveAttribute( 59 + "href", 60 + "mailto:riley@example.test" 61 + ); 62 + await expect(canvas.getByText("committer")).toBeVisible(); 63 + await expect(canvas.getByText("cheri")).toBeVisible(); 64 + 65 + await expect(canvas.getByText(/\(.*2026.*\)/)).toBeInTheDocument(); 66 + 67 + await expect(canvas.getByText("change-id")).toBeVisible(); 68 + 69 + // anchored so the commit's own hash doesn't match too 70 + const parent = canvas.getByRole("link", { name: /^abcdef01/ }); 71 + await expect(parent).toHaveAttribute( 72 + "href", 73 + "/dawn/tangled/commit/abcdef0123456789abcdef0123456789abcdef01" 74 + ); 75 + }; 76 + 77 + const hidesAbsentRows = async ({ canvas }: PlayContext) => { 78 + await expect(canvas.getByText("author")).toBeVisible(); 79 + await expect(canvas.queryByText("committer")).toBeNull(); 80 + await expect(canvas.queryByText("parent")).toBeNull(); 81 + await expect(canvas.queryByText("change-id")).toBeNull(); 82 + await expect(canvas.queryByRole("button")).toBeNull(); 83 + }; 84 + 85 + const namesStayWithoutMailto = async ({ canvas }: PlayContext) => { 86 + await expect(canvas.getByText("dawn")).toBeVisible(); 87 + await expect(canvas.queryByRole("link", { name: "dawn" })).toBeNull(); 88 + }; 89 + 90 + const { Story } = defineMeta({ 91 + title: "Repo/CommitHeader", 92 + component: CommitHeader, 93 + tags: ["autodocs"], 94 + args: { 95 + ownerHandle: "dawn", 96 + repoName: "tangled", 97 + commit: fullCommit, 98 + parent: "abcdef0123456789abcdef0123456789abcdef01", 99 + changeId: "kqpuwoxzrnvs" 100 + } 101 + }); 102 + </script> 103 + 104 + <Story name="Full metadata" play={fullMetadata} /> 105 + 106 + <Story 107 + name="Minimal commit" 108 + args={{ commit: minimalCommit, parent: "", changeId: "" }} 109 + play={hidesAbsentRows} 110 + /> 111 + 112 + <Story 113 + name="No emails" 114 + args={{ commit: noEmails, parent: "", changeId: "" }} 115 + play={namesStayWithoutMailto} 116 + />
+116
web/src/lib/components/repo/CommitHeader.svelte
··· 1 + <script lang="ts"> 2 + import { resolve } from "$app/paths"; 3 + import type { CommitDetail } from "$lib/api/repo"; 4 + import Avatar from "$lib/components/ui/Avatar.svelte"; 5 + import TimeAgo from "$lib/components/ui/TimeAgo.svelte"; 6 + import { formatDateTime } from "$lib/format"; 7 + 8 + interface Props { 9 + ownerHandle: string; 10 + repoName: string; 11 + commit: CommitDetail; 12 + // empty for a root commit 13 + parent?: string; 14 + changeId?: string; 15 + } 16 + 17 + let { ownerHandle, repoName, commit, parent = "", changeId = "" }: Props = $props(); 18 + 19 + const base = $derived(`/${ownerHandle}/${repoName}`); 20 + 21 + const showCommitter = $derived( 22 + commit.committerEmail !== "" && commit.committerEmail !== commit.authorEmail 23 + ); 24 + const parentShort = $derived(parent.slice(0, 8)); 25 + const changeIdShort = $derived(changeId.slice(0, 8)); 26 + 27 + const absoluteTime = $derived(formatDateTime(commit.committerWhen)); 28 + </script> 29 + 30 + <section class="text-foreground-default"> 31 + <div> 32 + <p class="pb-2 typography-paragraph-large">{commit.subject}</p> 33 + {#if commit.body} 34 + <p class="mt-1 cursor-text pb-2 typography-paragraph-regular whitespace-pre-wrap"> 35 + {commit.body} 36 + </p> 37 + {/if} 38 + </div> 39 + 40 + {#snippet attribution(label: string, name: string, email: string)} 41 + <span class="inline-flex flex-wrap items-center"> 42 + <span class="w-24 text-foreground-subtle select-none">{label}</span> 43 + <!-- no email -> did mapping here, always the fallback form --> 44 + <span class="flex items-center gap-1"> 45 + <Avatar size="size-6" /> 46 + {#if email} 47 + <a href="mailto:{email}" class="no-underline hover:underline">{name}</a> 48 + {:else} 49 + <span>{name}</span> 50 + {/if} 51 + </span> 52 + </span> 53 + {/snippet} 54 + 55 + <div class="flex flex-col gap-2 pt-4 typography-paragraph-regular"> 56 + <div class="flex flex-col gap-1 font-mono text-foreground-muted"> 57 + {@render attribution("author", commit.authorName, commit.authorEmail)} 58 + 59 + {#each commit.coAuthors as coAuthor (coAuthor.email)} 60 + {@render attribution("co-author", coAuthor.name, coAuthor.email)} 61 + {/each} 62 + 63 + {#if showCommitter} 64 + {@render attribution("committer", commit.committerName, commit.committerEmail)} 65 + {/if} 66 + 67 + {#if commit.committerWhen} 68 + <span class="inline-flex flex-wrap items-center"> 69 + <span class="w-24 text-foreground-subtle select-none">date</span> 70 + <span class="inline-flex flex-wrap items-center gap-1"> 71 + <TimeAgo value={commit.committerWhen} variant="full" /> 72 + {#if absoluteTime} 73 + <span>({absoluteTime})</span> 74 + {/if} 75 + </span> 76 + </span> 77 + {/if} 78 + 79 + {#if commit.hash} 80 + <span class="inline-flex flex-wrap items-center"> 81 + <span class="w-24 text-foreground-subtle select-none">commit</span> 82 + <a 83 + href={resolve(`${base}/commit/${commit.hash}` as "/")} 84 + class="break-all no-underline hover:underline" 85 + > 86 + <span class="md:hidden">{commit.shortHash}</span> 87 + <span class="hidden md:inline">{commit.hash}</span> 88 + </a> 89 + </span> 90 + {/if} 91 + 92 + {#if parent} 93 + <span class="inline-flex flex-wrap items-center"> 94 + <span class="w-24 text-foreground-subtle select-none">parent</span> 95 + <a 96 + href={resolve(`${base}/commit/${parent}` as "/")} 97 + class="break-all no-underline hover:underline" 98 + > 99 + <span class="md:hidden">{parentShort}</span> 100 + <span class="hidden md:inline">{parent}</span> 101 + </a> 102 + </span> 103 + {/if} 104 + 105 + {#if changeId} 106 + <span class="inline-flex flex-wrap items-center"> 107 + <span class="w-24 text-foreground-subtle select-none">change-id</span> 108 + <span class="break-all"> 109 + <span class="md:hidden">{changeIdShort}</span> 110 + <span class="hidden md:inline">{changeId}</span> 111 + </span> 112 + </span> 113 + {/if} 114 + </div> 115 + </div> 116 + </section>
+77
web/src/lib/components/repo/CommitLogView.stories.svelte
··· 1 + <script module lang="ts"> 2 + import { defineMeta, type StoryContext } from "@storybook/addon-svelte-csf"; 3 + import { expect, userEvent } from "storybook/test"; 4 + import CommitLogView from "./CommitLogView.svelte"; 5 + import type { CommitSummary } from "./types"; 6 + 7 + const commit = (index: number): CommitSummary => ({ 8 + hash: `${String(index).padStart(8, "0")}56789abcdef0123456789abcdef01234567`.slice(0, 40), 9 + shortHash: String(index).padStart(8, "0"), 10 + subject: `commit number ${index}`, 11 + body: index === 1 ? "a longer explanation hidden behind the disclosure." : "", 12 + authorName: "dawn", 13 + authorEmail: "dawn@tangled.org", 14 + when: "2026-07-28T09:00:00Z" 15 + }); 16 + const commits = Array.from({ length: 12 }, (_, index) => commit(index + 1)); 17 + type PlayContext = Pick<StoryContext<Record<string, unknown>>, "canvas">; 18 + 19 + const firstPage = async ({ canvas }: PlayContext) => { 20 + await expect(canvas.queryByRole("link", { name: "Previous" })).toBeNull(); 21 + const next = canvas.getByRole("link", { name: "Next" }); 22 + await expect(next).toHaveAttribute("href", "/dawn/tangled/commits/main?page=2"); 23 + 24 + await expect(canvas.getAllByTitle("Copy SHA")).toHaveLength(commits.length); 25 + await expect(canvas.getAllByTitle("Browse repository at this commit")).toHaveLength( 26 + commits.length * 2 27 + ); 28 + 29 + const chip = canvas.getAllByRole("link", { name: commits[0].shortHash })[0]; 30 + await expect(chip).toHaveAttribute("href", `/dawn/tangled/commit/${commits[0].hash}`); 31 + const tree = canvas.getAllByTitle("Browse repository at this commit")[0]; 32 + await expect(tree).toHaveAttribute("href", `/dawn/tangled/tree/${commits[0].hash}`); 33 + }; 34 + 35 + const middlePage = async ({ canvas }: PlayContext) => { 36 + const prev = canvas.getByRole("link", { name: "Previous" }); 37 + const next = canvas.getByRole("link", { name: "Next" }); 38 + await expect(prev).toHaveAttribute("href", "/dawn/tangled/commits/main?page=3"); 39 + await expect(next).toHaveAttribute("href", "/dawn/tangled/commits/main?page=5"); 40 + }; 41 + 42 + const bodyExpand = async ({ canvas }: PlayContext) => { 43 + // desktop and mobile rows each render a toggle 44 + const toggle = canvas.getAllByRole("button", { name: "Toggle commit body" })[0]; 45 + await expect(canvas.queryByText(/a longer explanation/)).toBeNull(); 46 + await userEvent.click(toggle); 47 + await expect(toggle).toHaveAttribute("aria-expanded", "true"); 48 + await expect(canvas.getAllByText(/a longer explanation/).length).toBeGreaterThan(0); 49 + }; 50 + 51 + const emptyLog = async ({ canvas }: PlayContext) => { 52 + await expect(canvas.getByText("No commits at main.")).toBeVisible(); 53 + await expect(canvas.queryByRole("link", { name: "Next" })).toBeNull(); 54 + }; 55 + 56 + const { Story } = defineMeta({ 57 + title: "Repo/CommitLogView", 58 + component: CommitLogView, 59 + tags: ["autodocs"], 60 + args: { 61 + ownerHandle: "dawn", 62 + repoName: "tangled", 63 + ref: "main", 64 + commits, 65 + tagsByCommit: { 66 + [commits[0].hash]: ["v1.0.0"] 67 + }, 68 + page: 1, 69 + pageCount: 5 70 + } 71 + }); 72 + </script> 73 + 74 + <Story name="First page" play={firstPage} /> 75 + <Story name="Middle page" args={{ page: 4 }} play={middlePage} /> 76 + <Story name="Expandable body" play={bodyExpand} /> 77 + <Story name="Empty log" args={{ commits: [], pageCount: 1 }} play={emptyLog} />
+218
web/src/lib/components/repo/CommitLogView.svelte
··· 1 + <script lang="ts"> 2 + import { resolve } from "$app/paths"; 3 + import ChevronLeft from "$icon/chevron-left"; 4 + import ChevronRight from "$icon/chevron-right"; 5 + import Copy from "$icon/copy"; 6 + import CopyCheck from "$icon/copy-check"; 7 + import Ellipsis from "$icon/ellipsis"; 8 + import FolderCode from "$icon/folder-code"; 9 + import Avatar from "$lib/components/ui/Avatar.svelte"; 10 + import Button from "$lib/components/ui/Button.svelte"; 11 + import Tag from "$lib/components/ui/Tag.svelte"; 12 + import TimeAgo from "$lib/components/ui/TimeAgo.svelte"; 13 + import { createCopyFeedback } from "$lib/copy.svelte"; 14 + import type { CommitSummary } from "./types"; 15 + 16 + interface Props { 17 + ownerHandle: string; 18 + repoName: string; 19 + ref: string; 20 + commits: CommitSummary[]; 21 + tagsByCommit?: Record<string, string[]>; 22 + page: number; 23 + pageCount: number; 24 + } 25 + 26 + let { ownerHandle, repoName, ref, commits, tagsByCommit = {}, page, pageCount }: Props = $props(); 27 + 28 + const base = $derived(`/${ownerHandle}/${repoName}`); 29 + const encodedRef = $derived(encodeURIComponent(ref)); 30 + 31 + const hasPrev = $derived(page > 1); 32 + const hasNext = $derived(page < pageCount); 33 + const pageHref = (next: number) => 34 + resolve(`${base}/commits/${encodedRef}${next > 1 ? `?page=${next}` : ""}` as "/"); 35 + 36 + let expanded = $state<Record<string, boolean>>({}); 37 + const copyFeedback = createCopyFeedback(); 38 + </script> 39 + 40 + {#snippet authorCell(commit: CommitSummary)} 41 + <span class="flex items-center gap-1"> 42 + <!-- no did/handle on a commit summary, always the placeholder --> 43 + <Avatar size="size-6" /> 44 + {#if commit.authorEmail} 45 + <a href="mailto:{commit.authorEmail}" class="no-underline hover:underline"> 46 + {commit.authorName} 47 + </a> 48 + {:else} 49 + <span>{commit.authorName}</span> 50 + {/if} 51 + </span> 52 + {/snippet} 53 + 54 + {#snippet shaChip(commit: CommitSummary, mobile: boolean)} 55 + <a 56 + href={resolve(`${base}/commit/${commit.hash}` as "/")} 57 + title={commit.changeId ? `jj change id: ${commit.changeId}` : undefined} 58 + class="flex items-center gap-2 rounded bg-background-inset px-2 {mobile 59 + ? 'py-1' 60 + : 'py-0.5'} text-foreground-muted no-underline hover:underline" 61 + > 62 + {commit.shortHash} 63 + </a> 64 + {/snippet} 65 + 66 + {#snippet treeLink(commit: CommitSummary)} 67 + <a 68 + href={resolve(`${base}/tree/${commit.hash}` as "/")} 69 + class="rounded p-1 hover:bg-background-inset" 70 + title="Browse repository at this commit" 71 + aria-label="Browse repository at this commit" 72 + > 73 + <FolderCode class="size-4" aria-hidden="true" /> 74 + </a> 75 + {/snippet} 76 + 77 + {#snippet copyButton(commit: CommitSummary)} 78 + <button 79 + type="button" 80 + class="cursor-pointer rounded p-1 hover:bg-background-inset" 81 + title="Copy SHA" 82 + aria-label="Copy SHA" 83 + onclick={() => void copyFeedback.copy(commit.hash)} 84 + > 85 + {#if copyFeedback.copied === commit.hash} 86 + <CopyCheck class="size-4" aria-hidden="true" /> 87 + {:else} 88 + <Copy class="size-4" aria-hidden="true" /> 89 + {/if} 90 + </button> 91 + {/snippet} 92 + 93 + {#snippet messageCell(commit: CommitSummary)} 94 + <div> 95 + <a 96 + href={resolve(`${base}/commit/${commit.hash}` as "/")} 97 + class="text-foreground-default no-underline hover:underline" 98 + > 99 + {commit.subject} 100 + </a> 101 + {#if commit.body} 102 + <button 103 + type="button" 104 + class="cursor-pointer rounded bg-background-inset px-1 py-0.5 hover:bg-background-muted" 105 + aria-expanded={expanded[commit.hash] === true} 106 + aria-label="Toggle commit body" 107 + onclick={() => (expanded[commit.hash] = !expanded[commit.hash])} 108 + > 109 + <Ellipsis class="size-3" aria-hidden="true" /> 110 + </button> 111 + {/if} 112 + {#each tagsByCommit[commit.hash] ?? [] as name (name)} 113 + <Tag color="gray" class="ml-2 font-mono">{name}</Tag> 114 + {/each} 115 + </div> 116 + {#if commit.body && expanded[commit.hash]} 117 + <p class="mt-1 typography-paragraph-regular whitespace-pre-wrap text-foreground-muted"> 118 + {commit.body} 119 + </p> 120 + {/if} 121 + {/snippet} 122 + 123 + <section id="commit-table" class="overflow-x-auto rounded bg-background-default px-6 py-4"> 124 + <h2 class="mb-4 typography-paragraph-regular font-bold">Commits</h2> 125 + 126 + {#if commits.length === 0} 127 + <p class="py-6 text-center text-foreground-subtle">No commits at {ref}.</p> 128 + {:else} 129 + <div class="hidden divide-y divide-border-default md:flex md:flex-col"> 130 + <div class="grid grid-cols-14 gap-4"> 131 + <div 132 + class="col-span-3 py-2 text-left typography-paragraph-regular font-bold text-foreground-muted" 133 + > 134 + Author 135 + </div> 136 + <div 137 + class="col-span-3 py-2 text-left typography-paragraph-regular font-bold text-foreground-muted" 138 + > 139 + Commit 140 + </div> 141 + <div 142 + class="col-span-6 py-2 text-left typography-paragraph-regular font-bold text-foreground-muted" 143 + > 144 + Message 145 + </div> 146 + <div 147 + class="col-span-2 justify-self-end py-2 text-left typography-paragraph-regular font-bold text-foreground-muted" 148 + > 149 + Date 150 + </div> 151 + </div> 152 + {#each commits as commit (commit.hash)} 153 + <div class="grid grid-cols-14 gap-4 py-3"> 154 + <div class="col-span-3 align-top"> 155 + {@render authorCell(commit)} 156 + </div> 157 + <div class="col-span-3 flex items-start align-top font-mono"> 158 + {@render shaChip(commit, false)} 159 + <!-- TODO: unverified rows indent the actions by a verified-shield 160 + width, but no shield renders yet. drop the ml-6 or render the 161 + shield once commit verification lands --> 162 + <div class="ml-6 inline-flex"> 163 + {@render copyButton(commit)} 164 + {@render treeLink(commit)} 165 + </div> 166 + </div> 167 + <div class="col-span-6 align-top"> 168 + {@render messageCell(commit)} 169 + </div> 170 + <div class="col-span-2 justify-self-end align-top text-foreground-muted"> 171 + <TimeAgo value={commit.when} /> 172 + </div> 173 + </div> 174 + {/each} 175 + </div> 176 + 177 + <div class="md:hidden"> 178 + {#each commits as commit, index (commit.hash)} 179 + <div 180 + class="relative mb-2 p-2 {index < commits.length - 1 181 + ? 'border-b border-border-default' 182 + : ''}" 183 + > 184 + <div class="flex items-center justify-between"> 185 + <div class="flex-1"> 186 + {@render messageCell(commit)} 187 + </div> 188 + {@render treeLink(commit)} 189 + </div> 190 + <div 191 + class="mt-2 flex items-center gap-1 typography-paragraph-small text-foreground-muted" 192 + > 193 + <span class="font-mono"> 194 + {@render shaChip(commit, true)} 195 + </span> 196 + <span aria-hidden="true">&middot;</span> 197 + {@render authorCell(commit)} 198 + {#if commit.when} 199 + <span aria-hidden="true">&middot;</span> 200 + <TimeAgo value={commit.when} /> 201 + {/if} 202 + </div> 203 + </div> 204 + {/each} 205 + </div> 206 + {/if} 207 + </section> 208 + 209 + {#if hasPrev || hasNext} 210 + <div class="mt-4 flex justify-end gap-2"> 211 + {#if hasPrev} 212 + <Button href={pageHref(page - 1)} icon={ChevronLeft} size="sm">Previous</Button> 213 + {/if} 214 + {#if hasNext} 215 + <Button href={pageHref(page + 1)} icon={ChevronRight} iconSide="right" size="sm">Next</Button> 216 + {/if} 217 + </div> 218 + {/if}
+100
web/src/lib/components/repo/CommitView.stories.svelte
··· 1 + <script module lang="ts"> 2 + import { defineMeta, type StoryContext } from "@storybook/addon-svelte-csf"; 3 + import { expect, waitFor } from "storybook/test"; 4 + import type { NiceDiff } from "$lib/api/diff"; 5 + import { toCommitDetail } from "$lib/api/repo"; 6 + import CommitView from "./CommitView.svelte"; 7 + 8 + const diff: NiceDiff = { 9 + commit: { 10 + this: "0123456789abcdef0123456789abcdef01234567", 11 + parent: "abcdef0123456789abcdef0123456789abcdef01", 12 + author: { Name: "dawn", Email: "dawn@example.test", When: "2026-07-28T09:00:00Z" }, 13 + committer: { Name: "cheri", Email: "cheri@example.test", When: "2026-07-28T10:00:00Z" }, 14 + message: 15 + "add a storybook fixture\n\nthis body is hidden until the disclosure button is pressed.\n\nCo-authored-by: riley <riley@example.test>", 16 + change_id: "kqpuwoxzrnvs" 17 + }, 18 + stat: { insertions: 2, deletions: 1, files_changed: 2 }, 19 + diff: [ 20 + { 21 + name: { old: "src/colors.ts", new: "src/colors.ts" }, 22 + text_fragments: [ 23 + { 24 + OldPosition: 1, 25 + OldLines: 3, 26 + NewPosition: 1, 27 + NewLines: 4, 28 + LinesAdded: 2, 29 + LinesDeleted: 1, 30 + Lines: [ 31 + { Op: 0, Line: "export const palette = {\n" }, 32 + { Op: 1, Line: '\tbackground: "white",\n' }, 33 + { Op: 2, Line: '\tbackground: "oklch(0.98 0 0)",\n' }, 34 + { Op: 2, Line: '\tforeground: "oklch(0.2 0 0)",\n' }, 35 + { Op: 0, Line: "};\n" } 36 + ] 37 + } 38 + ] 39 + }, 40 + { 41 + name: { old: "assets/logo.png", new: "assets/logo.png" }, 42 + is_binary: true 43 + } 44 + ] 45 + }; 46 + 47 + type PlayContext = Pick<StoryContext<Record<string, unknown>>, "canvas" | "canvasElement">; 48 + 49 + const shadowText = (canvasElement: HTMLElement) => 50 + [...canvasElement.querySelectorAll("diffs-container")] 51 + .map((host) => host.shadowRoot?.textContent ?? "") 52 + .join("\n"); 53 + 54 + const fullCommit = async ({ canvas, canvasElement }: PlayContext) => { 55 + await expect(canvas.getByText("add a storybook fixture")).toBeVisible(); 56 + await expect(canvas.getByText("co-author")).toBeVisible(); 57 + await expect(canvas.getByText("committer")).toBeVisible(); 58 + 59 + await expect(canvas.getByRole("link", { name: ".patch" })).toHaveAttribute( 60 + "href", 61 + "/dawn/tangled/commit/0123456789abcdef0123456789abcdef01234567.patch" 62 + ); 63 + 64 + // the filename is in the light-dom card header, the diff body in the 65 + // shadow root 66 + await expect(canvas.getByText("src/colors.ts")).toBeVisible(); 67 + await waitFor(() => expect(shadowText(canvasElement)).toContain("palette")); 68 + await expect( 69 + canvas.getByText("This is a binary file and will not be displayed.") 70 + ).toBeVisible(); 71 + }; 72 + 73 + const hidesDownloadsForShortRef = async ({ canvas }: PlayContext) => { 74 + await expect(canvas.getByText("add a storybook fixture")).toBeVisible(); 75 + await expect(canvas.queryByRole("link", { name: ".patch" })).toBeNull(); 76 + await expect(canvas.queryByRole("link", { name: ".diff" })).toBeNull(); 77 + }; 78 + 79 + const { Story } = defineMeta({ 80 + title: "Repo/CommitView", 81 + component: CommitView, 82 + tags: ["autodocs"], 83 + args: { 84 + ownerHandle: "dawn", 85 + repoName: "tangled", 86 + ref: "0123456789abcdef0123456789abcdef01234567", 87 + commit: toCommitDetail(diff.commit!), 88 + parent: diff.commit?.parent ?? "", 89 + changeId: diff.commit?.change_id ?? "", 90 + files: diff.diff ?? [], 91 + stat: diff.stat 92 + } 93 + }); 94 + </script> 95 + 96 + <Story name="Full commit" play={fullCommit} /> 97 + 98 + <Story name="Split" args={{ diffStyle: "split" }} /> 99 + 100 + <Story name="Branch ref" args={{ ref: "main" }} play={hidesDownloadsForShortRef} />
+60
web/src/lib/components/repo/CommitView.svelte
··· 1 + <script lang="ts"> 2 + import { resolve } from "$app/paths"; 3 + import type { DiffFile, DiffStat } from "$lib/api/diff"; 4 + import type { CommitDetail } from "$lib/api/repo"; 5 + import { FULL_HASH_RE } from "$lib/api/repo"; 6 + import CommitHeader from "./CommitHeader.svelte"; 7 + import DiffView from "./DiffView.svelte"; 8 + import type { DiffStyle } from "./pierre"; 9 + import TabPanel from "$lib/components/ui/TabPanel.svelte"; 10 + 11 + interface Props { 12 + ownerHandle: string; 13 + repoName: string; 14 + // the ref the page was loaded with. raw downloads only work for full hashes 15 + ref: string; 16 + commit: CommitDetail; 17 + // hex of the first parent, empty for a root commit 18 + parent?: string; 19 + changeId?: string; 20 + files: DiffFile[]; 21 + stat?: DiffStat; 22 + // side-by-side or stacked 23 + diffStyle?: DiffStyle; 24 + // server-prerendered shadow dom per row key, only for the initial ssr 25 + prerendered?: Record<string, string>; 26 + } 27 + 28 + let { 29 + ownerHandle, 30 + repoName, 31 + ref, 32 + commit, 33 + parent = "", 34 + changeId = "", 35 + files, 36 + stat, 37 + diffStyle = "unified", 38 + prerendered 39 + }: Props = $props(); 40 + 41 + const base = $derived(`/${ownerHandle}/${repoName}`); 42 + 43 + // the raw commit route only accepts full sha1/sha256 hex, anything shorter 404s 44 + const downloadUrls = $derived( 45 + FULL_HASH_RE.test(ref) 46 + ? { 47 + patch: resolve(`${base}/commit/${ref}.patch` as "/"), 48 + diff: resolve(`${base}/commit/${ref}.diff` as "/") 49 + } 50 + : undefined 51 + ); 52 + const blobBase = $derived(resolve(`${base}/blob/${encodeURIComponent(ref)}` as "/")); 53 + </script> 54 + 55 + <div class="flex flex-col gap-4"> 56 + <TabPanel> 57 + <CommitHeader {ownerHandle} {repoName} {commit} {parent} {changeId} /> 58 + </TabPanel> 59 + <DiffView {files} {stat} {diffStyle} {downloadUrls} {blobBase} {prerendered} /> 60 + </div>
+137
web/src/lib/components/repo/DiffFileCard.stories.svelte
··· 1 + <script module lang="ts"> 2 + import { defineMeta, type StoryContext } from "@storybook/addon-svelte-csf"; 3 + import { expect, waitFor } from "storybook/test"; 4 + import type { DiffFile } from "$lib/api/diff"; 5 + import { toFileDiffMetadata } from "./fileDiff"; 6 + import DiffFileCard from "./DiffFileCard.svelte"; 7 + 8 + const textFile: DiffFile = { 9 + name: { old: "src/colors.ts", new: "src/colors.ts" }, 10 + text_fragments: [ 11 + { 12 + OldPosition: 1, 13 + OldLines: 3, 14 + NewPosition: 1, 15 + NewLines: 4, 16 + LinesAdded: 2, 17 + LinesDeleted: 1, 18 + Lines: [ 19 + { Op: 0, Line: "export const palette = {\n" }, 20 + { Op: 1, Line: '\tbackground: "white",\n' }, 21 + { Op: 2, Line: '\tbackground: "oklch(0.98 0 0)",\n' }, 22 + { Op: 2, Line: '\tforeground: "oklch(0.2 0 0)",\n' }, 23 + { Op: 0, Line: "};\n" } 24 + ] 25 + } 26 + ] 27 + }; 28 + 29 + const renamedFile: DiffFile = { 30 + name: { old: "src/old-name.ts", new: "src/new-name.ts" }, 31 + text_fragments: [ 32 + { 33 + OldPosition: 1, 34 + OldLines: 1, 35 + NewPosition: 1, 36 + NewLines: 1, 37 + LinesAdded: 1, 38 + LinesDeleted: 1, 39 + Lines: [ 40 + { Op: 1, Line: "export const old = true;\n" }, 41 + { Op: 2, Line: "export const fresh = true;\n" } 42 + ] 43 + } 44 + ] 45 + }; 46 + 47 + const binaryFile: DiffFile = { 48 + name: { old: "assets/logo.png", new: "assets/logo.png" }, 49 + is_binary: true 50 + }; 51 + 52 + const diffFor = (file: DiffFile) => toFileDiffMetadata(file); 53 + 54 + // pierre renders inside a shadow root, invisible to canvas queries 55 + const shadowText = (canvasElement: HTMLElement) => 56 + [...canvasElement.querySelectorAll("diffs-container")] 57 + .map((host) => host.shadowRoot?.textContent ?? "") 58 + .join("\n"); 59 + 60 + type PlayContext = Pick< 61 + StoryContext<Record<string, unknown>>, 62 + "canvas" | "canvasElement" | "userEvent" 63 + >; 64 + 65 + const { Story } = defineMeta({ 66 + title: "Repo/DiffFileCard", 67 + component: DiffFileCard, 68 + tags: ["autodocs"], 69 + args: { 70 + file: textFile, 71 + name: "src/colors.ts", 72 + stat: { insertions: 2, deletions: 1, files_changed: 1 }, 73 + fileDiff: diffFor(textFile) 74 + } 75 + }); 76 + </script> 77 + 78 + <Story 79 + name="Default" 80 + play={async ({ canvasElement }: PlayContext) => { 81 + await waitFor(() => expect(shadowText(canvasElement)).toContain("oklch(0.98 0 0)")); 82 + }} 83 + /> 84 + 85 + <Story 86 + name="Collapse toggle" 87 + play={async ({ canvas, canvasElement, userEvent }: PlayContext) => { 88 + const card = canvasElement.querySelector("details")!; 89 + await expect(card.open).toBe(true); 90 + await userEvent.click(card.querySelector("summary")!); 91 + await expect(card.open).toBe(false); 92 + await userEvent.click(card.querySelector("summary")!); 93 + await expect(card.open).toBe(true); 94 + await expect(canvas.getByText("src/colors.ts")).toBeInTheDocument(); 95 + }} 96 + /> 97 + 98 + <Story 99 + name="Renamed file" 100 + args={{ 101 + file: renamedFile, 102 + name: "src/new-name.ts", 103 + fileDiff: diffFor(renamedFile) 104 + }} 105 + play={async ({ canvasElement }: PlayContext) => { 106 + // both names live in the same flex row, match the joined text 107 + const header = canvasElement.querySelector("summary")!; 108 + await expect(header.textContent).toContain("src/old-name.ts"); 109 + await expect(header.textContent).toContain("src/new-name.ts"); 110 + }} 111 + /> 112 + 113 + <Story 114 + name="Binary file" 115 + args={{ 116 + file: binaryFile, 117 + name: "assets/logo.png", 118 + stat: { insertions: 0, deletions: 0, files_changed: 1 }, 119 + fileDiff: undefined 120 + }} 121 + play={async ({ canvas }: PlayContext) => { 122 + await expect( 123 + canvas.getByText("This is a binary file and will not be displayed.") 124 + ).toBeInTheDocument(); 125 + }} 126 + /> 127 + 128 + <Story 129 + name="View file link" 130 + args={{ blobUrl: "/dawn/tangled/blob/master/src/colors.ts" }} 131 + play={async ({ canvas }: PlayContext) => { 132 + await expect(canvas.getByRole("link", { name: /view file/i })).toHaveAttribute( 133 + "href", 134 + "/dawn/tangled/blob/master/src/colors.ts" 135 + ); 136 + }} 137 + />
+100
web/src/lib/components/repo/DiffFileCard.svelte
··· 1 + <script lang="ts"> 2 + import ArrowRight from "$icon/arrow-right"; 3 + import ChevronDown from "$icon/chevron-down"; 4 + import ChevronRight from "$icon/chevron-right"; 5 + import Eye from "$icon/eye"; 6 + import type { DiffFile, DiffStat } from "$lib/api/diff"; 7 + import type { FileDiffMetadata } from "@pierre/diffs"; 8 + import Button from "$lib/components/ui/Button.svelte"; 9 + import type { Snippet } from "svelte"; 10 + import DiffStatPill from "./DiffStatPill.svelte"; 11 + import PierreDiff from "./PierreDiff.svelte"; 12 + import type { DiffStyle } from "./pierre"; 13 + 14 + interface Props { 15 + file: DiffFile; 16 + // resolved display path (new name, falling back to old) 17 + name: string; 18 + stat: DiffStat; 19 + // pierre's structured diff, absent for binaries 20 + fileDiff?: FileDiffMetadata; 21 + // server-prerendered shadow dom for fileDiff 22 + prerenderedHTML?: string; 23 + // side-by-side or stacked 24 + diffStyle?: DiffStyle; 25 + // blob route for the "view file" link, hidden when absent 26 + blobUrl?: string; 27 + open?: boolean; 28 + // review-button slot on the file header (pull pages) 29 + headerActions?: Snippet<[DiffFile]>; 30 + } 31 + 32 + let { 33 + file, 34 + name, 35 + stat, 36 + fileDiff, 37 + prerenderedHTML, 38 + diffStyle = "unified", 39 + blobUrl, 40 + open = $bindable(true), 41 + headerActions 42 + }: Props = $props(); 43 + </script> 44 + 45 + <!-- the summary sticks below the site header + topbar and needs a solid bg 46 + or the diff shows through. z-[5] keeps it under the z-10 topbar it 47 + shares its sticky band with --> 48 + <details 49 + bind:open 50 + id="file-{name}" 51 + class="group mx-auto w-full rounded border border-border-default bg-background-default" 52 + > 53 + <summary 54 + class="sticky top-[calc(5.75rem+env(safe-area-inset-top))] z-5 cursor-pointer list-none rounded bg-background-default group-open:rounded-b-none group-open:border-b group-open:border-border-default" 55 + > 56 + <div class="flex cursor-pointer justify-between"> 57 + <div class="flex items-center gap-2 overflow-x-auto p-2"> 58 + {#if open} 59 + <ChevronDown class="size-4" /> 60 + {:else} 61 + <ChevronRight class="size-4" /> 62 + {/if} 63 + <DiffStatPill {stat} /> 64 + 65 + <div class="flex items-center gap-2 overflow-x-auto"> 66 + {#if file.name.old && file.name.new && file.name.old !== file.name.new} 67 + {file.name.old} 68 + <ArrowRight class="size-4" /> 69 + {file.name.new} 70 + {:else} 71 + {name} 72 + {/if} 73 + </div> 74 + </div> 75 + <div class="flex items-center pr-1"> 76 + {#if blobUrl} 77 + <Button 78 + href={blobUrl as "/"} 79 + variant="ghost" 80 + size="sm" 81 + icon={Eye} 82 + class="hidden md:inline-flex" 83 + onclick={(event) => event.stopPropagation()} 84 + > 85 + View file 86 + </Button> 87 + {/if} 88 + {@render headerActions?.(file)} 89 + </div> 90 + </div> 91 + </summary> 92 + 93 + {#if file.is_binary} 94 + <p class="p-4 text-center text-foreground-placeholder"> 95 + This is a binary file and will not be displayed. 96 + </p> 97 + {:else} 98 + <PierreDiff files={fileDiff ? [fileDiff] : []} {diffStyle} prerenderedHTML={[prerenderedHTML]} /> 99 + {/if} 100 + </details>
+57
web/src/lib/components/repo/DiffFileList.stories.svelte
··· 1 + <script module lang="ts"> 2 + import { defineMeta, type StoryContext } from "@storybook/addon-svelte-csf"; 3 + import { expect } from "storybook/test"; 4 + import DiffFileList from "./DiffFileList.svelte"; 5 + 6 + const nestedFiles = [ 7 + { name: "src/lib/components/repo/DiffView.svelte" }, 8 + { name: "src/lib/components/repo/DiffStatPill.svelte" }, 9 + { name: "src/lib/api/diff.ts" }, 10 + { name: "package.json" } 11 + ]; 12 + 13 + type PlayContext = Pick< 14 + StoryContext<Record<string, unknown>>, 15 + "canvas" | "canvasElement" | "userEvent" 16 + >; 17 + 18 + const nestedTree = async ({ canvas, canvasElement, userEvent }: PlayContext) => { 19 + await expect(canvas.getByRole("link", { name: "DiffView.svelte" })).toHaveAttribute( 20 + "href", 21 + "#file-src/lib/components/repo/DiffView.svelte" 22 + ); 23 + await expect(canvas.getByRole("link", { name: "package.json" })).toHaveAttribute( 24 + "href", 25 + "#file-package.json" 26 + ); 27 + await expect(canvas.getByText("repo")).toBeVisible(); 28 + expect(canvasElement.querySelectorAll("details").length).toBe(5); 29 + 30 + const firstDir = canvasElement.querySelector("details")!; 31 + expect(firstDir.open).toBe(true); 32 + await userEvent.click(firstDir.querySelector("summary")!); 33 + expect(firstDir.open).toBe(false); 34 + }; 35 + 36 + const flatList = async ({ canvas, canvasElement }: PlayContext) => { 37 + expect(canvasElement.querySelector("details")).toBeNull(); 38 + await expect(canvas.getAllByRole("link")).toHaveLength(2); 39 + }; 40 + 41 + const { Story } = defineMeta({ 42 + title: "Repo/DiffFileList", 43 + component: DiffFileList, 44 + tags: ["autodocs"], 45 + args: { files: nestedFiles } 46 + }); 47 + </script> 48 + 49 + <Story name="Nested directories" play={nestedTree} /> 50 + 51 + <Story 52 + name="Flat files" 53 + args={{ files: [{ name: "README.md" }, { name: "package.json" }] }} 54 + play={flatList} 55 + /> 56 + 57 + <Story name="Empty" args={{ files: [] }} />
+84
web/src/lib/components/repo/DiffFileList.svelte
··· 1 + <script module lang="ts"> 2 + interface TreeDir { 3 + type: "dir"; 4 + name: string; 5 + children: TreeNode[]; 6 + } 7 + 8 + interface TreeFile { 9 + type: "file"; 10 + name: string; 11 + path: string; 12 + } 13 + 14 + type TreeNode = TreeDir | TreeFile; 15 + 16 + // first-seen order (the diff's file order), no re-sorting 17 + const buildTree = (paths: string[]): TreeNode[] => { 18 + const roots: TreeNode[] = []; 19 + const dirs: Record<string, TreeDir> = Object.create(null); 20 + for (const path of paths) { 21 + const parts = path.split("/"); 22 + let siblings = roots; 23 + let prefix = ""; 24 + for (const dir of parts.slice(0, -1)) { 25 + prefix = prefix ? `${prefix}/${dir}` : dir; 26 + let node = dirs[prefix]; 27 + if (!node) { 28 + node = { type: "dir", name: dir, children: [] }; 29 + dirs[prefix] = node; 30 + siblings.push(node); 31 + } 32 + siblings = node.children; 33 + } 34 + siblings.push({ type: "file", name: parts.at(-1) ?? path, path }); 35 + } 36 + return roots; 37 + }; 38 + </script> 39 + 40 + <script lang="ts"> 41 + import File from "$icon/file"; 42 + import Folder from "$icon/folder"; 43 + import FolderOpen from "$icon/folder-open"; 44 + 45 + interface Props { 46 + // full slash-separated paths. anchors point at `#file-<path>` 47 + files: { name: string }[]; 48 + } 49 + 50 + let { files }: Props = $props(); 51 + 52 + const tree = $derived(buildTree(files.map((file) => file.name))); 53 + </script> 54 + 55 + {#snippet nodes(list: TreeNode[])} 56 + {#each list as node (`${node.type}:${node.name}`)} 57 + {#if node.type === "dir"} 58 + <details open class="group"> 59 + <summary class="cursor-pointer list-none pt-1"> 60 + <span class="inline-flex items-center gap-2"> 61 + <Folder class="size-4 shrink-0 group-open:hidden" /> 62 + <FolderOpen class="hidden size-4 shrink-0 group-open:block" /> 63 + <span class="truncate text-foreground-default">{node.name}</span> 64 + </span> 65 + </summary> 66 + <div class="ml-1 border-l border-border-default pl-2"> 67 + {@render nodes(node.children)} 68 + </div> 69 + </details> 70 + {:else} 71 + <div class="flex items-center gap-2 pt-1"> 72 + <File class="size-4 shrink-0" /> 73 + <a 74 + href="#file-{node.path}" 75 + class="truncate text-foreground-default no-underline hover:underline" 76 + > 77 + {node.name} 78 + </a> 79 + </div> 80 + {/if} 81 + {/each} 82 + {/snippet} 83 + 84 + {@render nodes(tree)}
+37
web/src/lib/components/repo/DiffStatPill.stories.svelte
··· 1 + <script module lang="ts"> 2 + import { defineMeta, type StoryContext } from "@storybook/addon-svelte-csf"; 3 + import { expect } from "storybook/test"; 4 + import DiffStatPill from "./DiffStatPill.svelte"; 5 + 6 + type PlayContext = Pick<StoryContext<Record<string, unknown>>, "canvas" | "canvasElement">; 7 + 8 + const showsBoth = async ({ canvas }: PlayContext) => { 9 + await expect(canvas.getByText("+12")).toBeVisible(); 10 + await expect(canvas.getByText("-4")).toBeVisible(); 11 + }; 12 + 13 + const rendersNothing = async ({ canvasElement }: PlayContext) => { 14 + await expect(canvasElement.textContent?.trim()).toBe(""); 15 + }; 16 + 17 + const { Story } = defineMeta({ 18 + title: "Repo/DiffStatPill", 19 + component: DiffStatPill, 20 + tags: ["autodocs"], 21 + args: { 22 + stat: { insertions: 12, deletions: 4, files_changed: 3 } 23 + } 24 + }); 25 + </script> 26 + 27 + <Story name="Insertions and deletions" play={showsBoth} /> 28 + 29 + <Story name="Insertions only" args={{ stat: { insertions: 12, deletions: 0, files_changed: 1 } }} /> 30 + 31 + <Story name="Deletions only" args={{ stat: { insertions: 0, deletions: 4, files_changed: 1 } }} /> 32 + 33 + <Story 34 + name="Empty" 35 + args={{ stat: { insertions: 0, deletions: 0, files_changed: 0 } }} 36 + play={rendersNothing} 37 + />
+31
web/src/lib/components/repo/DiffStatPill.svelte
··· 1 + <script lang="ts"> 2 + import type { DiffStat } from "$lib/api/diff"; 3 + 4 + interface Props { 5 + stat: DiffStat; 6 + class?: string; 7 + } 8 + 9 + let { stat, class: className = "" }: Props = $props(); 10 + </script> 11 + 12 + {#if stat.insertions > 0 || stat.deletions > 0} 13 + <div class="flex items-center font-mono typography-monospace-regular {className}"> 14 + {#if stat.insertions > 0 && stat.deletions > 0} 15 + <span class="rounded-l bg-background-success-subtle p-1 text-foreground-success select-none"> 16 + +{stat.insertions} 17 + </span> 18 + <span class="rounded-r bg-background-danger-subtle p-1 text-foreground-danger select-none"> 19 + -{stat.deletions} 20 + </span> 21 + {:else if stat.insertions > 0} 22 + <span class="rounded bg-background-success-subtle p-1 text-foreground-success select-none"> 23 + +{stat.insertions} 24 + </span> 25 + {:else} 26 + <span class="rounded bg-background-danger-subtle p-1 text-foreground-danger select-none"> 27 + -{stat.deletions} 28 + </span> 29 + {/if} 30 + </div> 31 + {/if}
+96
web/src/lib/components/repo/DiffTopbar.stories.svelte
··· 1 + <script module lang="ts"> 2 + import { defineMeta, type StoryContext } from "@storybook/addon-svelte-csf"; 3 + import { expect, fn } from "storybook/test"; 4 + import DiffTopbar from "./DiffTopbar.svelte"; 5 + 6 + type PlayContext = Pick<StoryContext<Record<string, unknown>>, "canvas" | "userEvent">; 7 + 8 + const onToggleFiles = fn(); 9 + const onSetAllOpen = fn(); 10 + 11 + const { Story } = defineMeta({ 12 + title: "Repo/DiffTopbar", 13 + component: DiffTopbar, 14 + tags: ["autodocs"], 15 + args: { 16 + stat: { insertions: 19, deletions: 11, files_changed: 1 }, 17 + fileCount: 1 18 + } 19 + }); 20 + </script> 21 + 22 + <Story name="Default" /> 23 + 24 + <Story 25 + name="Downloads" 26 + args={{ 27 + downloadUrls: { 28 + patch: "/dawn/tangled/commit/0123456789abcdef0123456789abcdef01234567.patch", 29 + diff: "/dawn/tangled/commit/0123456789abcdef0123456789abcdef01234567.diff" 30 + } 31 + }} 32 + play={async ({ canvas }: PlayContext) => { 33 + await expect(canvas.getByRole("link", { name: ".patch" })).toHaveAttribute( 34 + "href", 35 + expect.stringContaining(".patch") 36 + ); 37 + await expect(canvas.getByRole("link", { name: ".diff" })).toHaveAttribute( 38 + "href", 39 + expect.stringContaining(".diff") 40 + ); 41 + // reloads on purpose, the client router would shadow the endpoint with the 42 + // commit page's [ref] route and 404 43 + for (const link of canvas.getAllByRole("link", { name: /^\./ })) { 44 + await expect(link).toHaveAttribute("data-sveltekit-reload"); 45 + } 46 + }} 47 + /> 48 + 49 + <Story 50 + name="Toggles" 51 + args={{ 52 + onToggleFiles, 53 + onSetAllOpen, 54 + allOpen: true, 55 + filesOpen: true 56 + }} 57 + play={async ({ canvas, userEvent }: PlayContext) => { 58 + await userEvent.click(canvas.getByTitle("Toggle file list")); 59 + await expect(onToggleFiles).toHaveBeenCalledOnce(); 60 + 61 + await userEvent.click(canvas.getByRole("button", { name: /collapse all/i })); 62 + await expect(onSetAllOpen).toHaveBeenCalledWith(false); 63 + }} 64 + /> 65 + 66 + <Story 67 + name="Collapsed" 68 + args={{ allOpen: false, onSetAllOpen: fn() }} 69 + play={async ({ canvas }: PlayContext) => { 70 + await expect(canvas.getByRole("button", { name: /expand all/i })).toBeInTheDocument(); 71 + }} 72 + /> 73 + 74 + <Story 75 + name="Split mode" 76 + args={{ diffStyle: "split" }} 77 + play={async ({ canvas }: PlayContext) => { 78 + await expect(canvas.getByTitle("Unified diff")).toHaveAttribute("href", "?diff=unified"); 79 + await expect(canvas.getByTitle("Split diff")).toHaveAttribute("href", "?diff=split"); 80 + // replacestate navigation, no noscroll means it jumps to top 81 + for (const link of [canvas.getByTitle("Unified diff"), canvas.getByTitle("Split diff")]) { 82 + await expect(link).toHaveAttribute("data-sveltekit-noscroll"); 83 + } 84 + }} 85 + /> 86 + 87 + <Story name="Topbar slots" asChild> 88 + <DiffTopbar stat={{ insertions: 19, deletions: 11, files_changed: 1 }} fileCount={1}> 89 + {#snippet center()} 90 + <span class="font-mono typography-monospace-small text-foreground-muted">Round #2</span> 91 + {/snippet} 92 + {#snippet actions()} 93 + <span class="typography-paragraph-small text-foreground-muted">review panel</span> 94 + {/snippet} 95 + </DiffTopbar> 96 + </Story>
+118
web/src/lib/components/repo/DiffTopbar.svelte
··· 1 + <script lang="ts"> 2 + import Download from "$icon/download"; 3 + import FoldVertical from "$icon/fold-vertical"; 4 + import PanelLeft from "$icon/panel-left"; 5 + import SquareSplitHorizontal from "$icon/square-split-horizontal"; 6 + import SquareSplitVertical from "$icon/square-split-vertical"; 7 + import UnfoldVertical from "$icon/unfold-vertical"; 8 + import type { DiffStat } from "$lib/api/diff"; 9 + import type { DiffStyle } from "./pierre"; 10 + import Button from "$lib/components/ui/Button.svelte"; 11 + import ButtonGroup from "$lib/components/ui/ButtonGroup.svelte"; 12 + import type { Snippet } from "svelte"; 13 + import DiffStatPill from "./DiffStatPill.svelte"; 14 + 15 + interface Props { 16 + stat?: DiffStat; 17 + fileCount: number; 18 + // side-by-side or stacked, persisted by the route in ?diff= 19 + diffStyle?: DiffStyle; 20 + // raw .patch/.diff targets, only present for full-hash refs 21 + downloadUrls?: { patch: string; diff: string }; 22 + // the sidebar can be hidden to give diffs the room 23 + filesOpen?: boolean; 24 + onToggleFiles?: () => void; 25 + allOpen?: boolean; 26 + onSetAllOpen?: (open: boolean) => void; 27 + // round/interdiff indicator slot, sits after the stats 28 + center?: Snippet; 29 + // subscription-toggle slot, pinned to the far end (pull review panel) 30 + actions?: Snippet; 31 + } 32 + 33 + let { 34 + stat, 35 + fileCount, 36 + diffStyle = "unified", 37 + downloadUrls, 38 + filesOpen = true, 39 + onToggleFiles, 40 + allOpen = true, 41 + onSetAllOpen, 42 + center, 43 + actions 44 + }: Props = $props(); 45 + </script> 46 + 47 + <div 48 + class="sticky top-[calc(2.75rem+env(safe-area-inset-top))] z-10 flex h-12 items-center gap-2 bg-background-canvas p-2" 49 + > 50 + {#if onToggleFiles} 51 + <Button 52 + size="sm" 53 + icon={PanelLeft} 54 + onclick={onToggleFiles} 55 + aria-expanded={filesOpen} 56 + title="Toggle file list" 57 + class="hidden md:inline-flex" 58 + /> 59 + {/if} 60 + 61 + {#if stat} 62 + <DiffStatPill {stat} /> 63 + {/if} 64 + <span class="hidden typography-paragraph-small text-foreground-muted md:inline-flex"> 65 + {fileCount} changed file{fileCount === 1 ? "" : "s"} 66 + </span> 67 + 68 + {@render center?.()} 69 + 70 + <div class="grow"></div> 71 + 72 + {#if downloadUrls} 73 + <!-- below md these crowd the topbar into page-level horizontal scroll --> 74 + <ButtonGroup spaced class="hidden md:inline-flex"> 75 + <Button href={downloadUrls.patch as "/"} data-sveltekit-reload size="sm" icon={Download} 76 + >.patch</Button 77 + > 78 + <Button href={downloadUrls.diff as "/"} data-sveltekit-reload size="sm" icon={Download} 79 + >.diff</Button 80 + > 81 + </ButtonGroup> 82 + {/if} 83 + 84 + {#if onSetAllOpen} 85 + <Button 86 + size="sm" 87 + icon={allOpen ? FoldVertical : UnfoldVertical} 88 + onclick={() => onSetAllOpen(!allOpen)} 89 + > 90 + <span class="hidden md:inline">{allOpen ? "Collapse all" : "Expand all"}</span> 91 + </Button> 92 + {/if} 93 + 94 + <ButtonGroup> 95 + <Button 96 + href={"?diff=unified" as "/"} 97 + data-sveltekit-replacestate 98 + data-sveltekit-noscroll 99 + size="sm" 100 + variant={diffStyle === "split" ? "default" : "primary"} 101 + icon={SquareSplitHorizontal} 102 + aria-label="Unified diff" 103 + title="Unified diff" 104 + /> 105 + <Button 106 + href={"?diff=split" as "/"} 107 + data-sveltekit-replacestate 108 + data-sveltekit-noscroll 109 + size="sm" 110 + variant={diffStyle === "split" ? "primary" : "default"} 111 + icon={SquareSplitVertical} 112 + aria-label="Split diff" 113 + title="Split diff" 114 + /> 115 + </ButtonGroup> 116 + 117 + {@render actions?.()} 118 + </div>
+139
web/src/lib/components/repo/DiffView.stories.svelte
··· 1 + <script module lang="ts"> 2 + import { defineMeta, type StoryContext } from "@storybook/addon-svelte-csf"; 3 + import { expect, waitFor } from "storybook/test"; 4 + import type { DiffFile } from "$lib/api/diff"; 5 + import DiffView from "./DiffView.svelte"; 6 + 7 + const textFile: DiffFile = { 8 + name: { old: "src/colors.ts", new: "src/colors.ts" }, 9 + text_fragments: [ 10 + { 11 + OldPosition: 1, 12 + OldLines: 3, 13 + NewPosition: 1, 14 + NewLines: 4, 15 + LinesAdded: 2, 16 + LinesDeleted: 1, 17 + Lines: [ 18 + { Op: 0, Line: "export const palette = {\n" }, 19 + { Op: 1, Line: '\tbackground: "white",\n' }, 20 + { Op: 2, Line: '\tbackground: "oklch(0.98 0 0)",\n' }, 21 + { Op: 2, Line: '\tforeground: "oklch(0.2 0 0)",\n' }, 22 + { Op: 0, Line: "};\n" } 23 + ] 24 + } 25 + ] 26 + }; 27 + 28 + const binaryFile: DiffFile = { 29 + name: { old: "assets/logo.png", new: "assets/logo.png" }, 30 + is_binary: true 31 + }; 32 + 33 + const downloadUrls = { 34 + patch: "/dawn/tangled/commit/0123456789abcdef0123456789abcdef01234567.patch", 35 + diff: "/dawn/tangled/commit/0123456789abcdef0123456789abcdef01234567.diff" 36 + }; 37 + 38 + const blobBase = "/dawn/tangled/blob/0123456789abcdef0123456789abcdef01234567"; 39 + 40 + type PlayContext = Pick< 41 + StoryContext<Record<string, unknown>>, 42 + "canvas" | "canvasElement" | "userEvent" 43 + >; 44 + 45 + // pierre renders inside a shadow root, diff content is invisible to canvas 46 + const shadowText = (canvasElement: HTMLElement) => 47 + [...canvasElement.querySelectorAll("diffs-container")] 48 + .map((host) => host.shadowRoot?.textContent ?? "") 49 + .join("\n"); 50 + 51 + const hasSplitColumns = (canvasElement: HTMLElement) => 52 + [...canvasElement.querySelectorAll("diffs-container")].some( 53 + (host) => 54 + host.shadowRoot?.querySelector("[data-deletions]") && 55 + host.shadowRoot?.querySelector("[data-additions]") 56 + ); 57 + 58 + // the per-file cards, excluding the file tree's directory <details> 59 + const fileCards = (canvasElement: HTMLElement) => [ 60 + ...canvasElement.querySelectorAll<HTMLDetailsElement>("details[id^='file-']") 61 + ]; 62 + 63 + const mixedFiles = async ({ canvas, canvasElement, userEvent }: PlayContext) => { 64 + await expect(canvas.getAllByText("+2")).toHaveLength(2); 65 + await expect(canvas.getAllByText("-1")).toHaveLength(2); 66 + await expect(canvas.getByText("2 changed files")).toBeVisible(); 67 + 68 + await expect(canvas.getByText("src/colors.ts")).toBeVisible(); 69 + await waitFor(() => expect(shadowText(canvasElement)).toContain("palette")); 70 + expect(shadowText(canvasElement)).not.toContain("src/colors.ts"); 71 + expect(hasSplitColumns(canvasElement)).toBe(false); 72 + 73 + await expect(canvas.getByText("assets/logo.png")).toBeVisible(); 74 + await expect( 75 + canvas.getByText("This is a binary file and will not be displayed.") 76 + ).toBeVisible(); 77 + expect(shadowText(canvasElement)).not.toContain("assets/logo.png"); 78 + 79 + await expect(canvas.getByRole("link", { name: "colors.ts" })).toHaveAttribute( 80 + "href", 81 + "#file-src/colors.ts" 82 + ); 83 + 84 + await expect(canvas.getByRole("link", { name: ".patch" })).toHaveAttribute( 85 + "href", 86 + downloadUrls.patch 87 + ); 88 + await expect(canvas.getByRole("link", { name: "Split diff" })).toHaveAttribute( 89 + "href", 90 + "?diff=split" 91 + ); 92 + await expect(canvas.getByRole("link", { name: "Unified diff" })).toHaveAttribute( 93 + "href", 94 + "?diff=unified" 95 + ); 96 + 97 + expect(fileCards(canvasElement).every((card) => card.open)).toBe(true); 98 + const firstCard = fileCards(canvasElement)[0]; 99 + await userEvent.click(firstCard.querySelector("summary")!); 100 + expect(firstCard.open).toBe(false); 101 + expect(fileCards(canvasElement)[1].open).toBe(true); 102 + 103 + await userEvent.click(canvas.getByRole("button", { name: "Expand all" })); 104 + expect(fileCards(canvasElement).every((card) => card.open)).toBe(true); 105 + await userEvent.click(canvas.getByRole("button", { name: "Collapse all" })); 106 + expect(fileCards(canvasElement).every((card) => !card.open)).toBe(true); 107 + }; 108 + 109 + const splitView = async ({ canvasElement }: PlayContext) => { 110 + await waitFor(() => expect(shadowText(canvasElement)).toContain("palette")); 111 + await waitFor(() => expect(hasSplitColumns(canvasElement)).toBe(true)); 112 + }; 113 + 114 + const viewFileLinks = async ({ canvas }: PlayContext) => { 115 + const links = canvas.getAllByRole("link", { name: "View file" }); 116 + await expect(links).toHaveLength(2); 117 + await expect(links[0]).toHaveAttribute("href", `${blobBase}/src/colors.ts`); 118 + await expect(links[1]).toHaveAttribute("href", `${blobBase}/assets/logo.png`); 119 + }; 120 + 121 + const { Story } = defineMeta({ 122 + title: "Repo/DiffView", 123 + component: DiffView, 124 + tags: ["autodocs"], 125 + args: { 126 + files: [textFile, binaryFile], 127 + stat: { insertions: 2, deletions: 1, files_changed: 2 }, 128 + downloadUrls 129 + } 130 + }); 131 + </script> 132 + 133 + <Story name="Mixed text and binary" play={mixedFiles} /> 134 + 135 + <Story name="Split" args={{ diffStyle: "split" }} play={splitView} /> 136 + 137 + <Story name="With blob base" args={{ blobBase }} play={viewFileLinks} /> 138 + 139 + <Story name="No downloads" args={{ downloadUrls: undefined }} />
+117
web/src/lib/components/repo/DiffView.svelte
··· 1 + <script lang="ts"> 2 + import { diffFileName, type DiffFile, type DiffStat } from "$lib/api/diff"; 3 + import { fileStats } from "$lib/api/rawdiff"; 4 + import type { Snippet } from "svelte"; 5 + import DiffFileCard from "./DiffFileCard.svelte"; 6 + import DiffFileList from "./DiffFileList.svelte"; 7 + import DiffTopbar from "./DiffTopbar.svelte"; 8 + import { diffRowKey, toFileDiffMetadata } from "./fileDiff"; 9 + import type { DiffStyle } from "./pierre"; 10 + import { encodePathSegments } from "./urls"; 11 + 12 + interface Props { 13 + files: DiffFile[]; 14 + stat?: DiffStat; 15 + // side-by-side or stacked, persisted by the route in ?diff= 16 + diffStyle?: DiffStyle; 17 + // raw .patch/.diff targets, only present for full-hash refs 18 + downloadUrls?: { patch: string; diff: string }; 19 + // blob route prefix for the per-file "view file" links, hidden when absent 20 + blobBase?: string; 21 + // server-prerendered shadow dom per row key, only for the initial ssr 22 + prerendered?: Record<string, string>; 23 + // round/interdiff indicator slot in the topbar (pull pages) 24 + topbarCenter?: Snippet; 25 + // subscription-toggle slot at the topbar's far end (pull review panel) 26 + topbarActions?: Snippet; 27 + // review-button slot on each file header (pull pages) 28 + fileHeaderActions?: Snippet<[DiffFile]>; 29 + } 30 + 31 + let { 32 + files, 33 + stat, 34 + diffStyle = "unified", 35 + downloadUrls, 36 + blobBase, 37 + prerendered, 38 + topbarCenter, 39 + topbarActions, 40 + fileHeaderActions 41 + }: Props = $props(); 42 + 43 + // one structured diff per text file, binary notes stay 44 + // interleaved in the original diff order 45 + const rows = $derived( 46 + files.map((file) => ({ 47 + file, 48 + name: diffFileName(file), 49 + key: diffRowKey(file), 50 + stat: { ...fileStats(file), files_changed: 1 }, 51 + fileDiff: file.is_binary ? undefined : toFileDiffMetadata(file), 52 + prerenderedHTML: file.is_binary ? undefined : prerendered?.[diffRowKey(file)], 53 + blobUrl: 54 + blobBase && file.name.new ? `${blobBase}/${encodePathSegments(file.name.new)}` : undefined 55 + })) 56 + ); 57 + 58 + // cards default to open 59 + let openStates = $state<Record<string, boolean>>({}); 60 + const isOpen = (key: string) => openStates[key] ?? true; 61 + const allOpen = $derived(rows.every((row) => isOpen(row.key))); 62 + const setAll = (open: boolean) => { 63 + openStates = Object.fromEntries(rows.map((row) => [row.key, open])); 64 + }; 65 + 66 + let filesOpen = $state(true); 67 + </script> 68 + 69 + <div class="flex flex-col"> 70 + <DiffTopbar 71 + {stat} 72 + fileCount={files.length} 73 + {diffStyle} 74 + {downloadUrls} 75 + {filesOpen} 76 + onToggleFiles={rows.length > 0 ? () => (filesOpen = !filesOpen) : undefined} 77 + {allOpen} 78 + onSetAllOpen={setAll} 79 + center={topbarCenter} 80 + actions={topbarActions} 81 + /> 82 + 83 + <div class="flex grow gap-4"> 84 + {#if rows.length > 0 && filesOpen} 85 + <aside 86 + class="sticky top-[calc(5.75rem+env(safe-area-inset-top))] hidden max-h-[calc(100vh-5.75rem-env(safe-area-inset-top))] w-fit max-w-60 shrink-0 overflow-y-auto md:block" 87 + > 88 + <section 89 + class="mx-auto min-h-full w-full overflow-x-auto rounded border border-border-default bg-background-default px-6 py-2 typography-paragraph-regular" 90 + > 91 + <DiffFileList files={rows.map((row) => ({ name: row.name }))} /> 92 + </section> 93 + </aside> 94 + {/if} 95 + 96 + <div class="flex min-w-0 flex-1 flex-col gap-4"> 97 + {#if rows.length === 0} 98 + <div class="py-8 text-center text-foreground-muted"> 99 + <p>No differences found between the selected revisions.</p> 100 + </div> 101 + {/if} 102 + {#each rows as row (row.key)} 103 + <DiffFileCard 104 + file={row.file} 105 + name={row.name} 106 + stat={row.stat} 107 + fileDiff={row.fileDiff} 108 + prerenderedHTML={row.prerenderedHTML} 109 + {diffStyle} 110 + blobUrl={row.blobUrl} 111 + bind:open={() => isOpen(row.key), (open) => (openStates[row.key] = open)} 112 + headerActions={fileHeaderActions} 113 + /> 114 + {/each} 115 + </div> 116 + </div> 117 + </div>
+14
web/src/lib/format.ts
··· 10 10 11 11 const rtf = new Intl.RelativeTimeFormat("en", { numeric: "auto" }); 12 12 const dtf = new Intl.DateTimeFormat("en", { year: "numeric", month: "short", day: "numeric" }); 13 + // the appview's longTimeFmt ("Jan 2, 2006, 3:04 PM MST") 14 + const dtfFull = new Intl.DateTimeFormat("en", { 15 + year: "numeric", 16 + month: "short", 17 + day: "numeric", 18 + hour: "numeric", 19 + minute: "2-digit", 20 + timeZoneName: "short" 21 + }); 13 22 14 23 // "3 days ago", "in 2 hours", etc. 15 24 export const relativeTime = (input: string | Date, now: Date = new Date()): string => { ··· 48 57 const date = typeof input === "string" ? new Date(input) : input; 49 58 return Number.isNaN(date.getTime()) ? "" : dtf.format(date); 50 59 }; 60 + 61 + export const formatDateTime = (input: string | Date): string => { 62 + const date = typeof input === "string" ? new Date(input) : input; 63 + return Number.isNaN(date.getTime()) ? "" : dtfFull.format(date); 64 + };
+6
web/src/params/rawCommit.ts
··· 1 + import type { ParamMatcher } from "@sveltejs/kit"; 2 + import { parseRawCommit } from "$lib/api/repo"; 3 + 4 + // requiring the hash here means refs that are not hashes fall through to the 5 + // commit page 6 + export const match: ParamMatcher = (param) => parseRawCommit(param) !== null;
+5 -1
web/src/routes/[handle]/[repo]/+layout@.svelte
··· 15 15 const segment = page.route.id?.split("/")[3] ?? ""; 16 16 return ["issues", "pulls", "pipelines", "settings"].includes(segment) ? segment : "overview"; 17 17 }); 18 + 19 + // commit pages break out of the reading column, everything else stays 20 + // capped 21 + const fullWidth = $derived((page.route.id ?? "").includes("/commit/")); 18 22 </script> 19 23 20 24 <!-- todo: og and twitter card tags, the appview has repo/fragments/og.html --> ··· 36 40 /> 37 41 </svelte:head> 38 42 39 - <section class="mx-auto w-full max-w-screen-lg py-6"> 43 + <section class={fullWidth ? "w-full px-2 py-6 sm:px-4" : "mx-auto w-full max-w-screen-lg py-6"}> 40 44 <RepoHeader repo={data.repo} counts={data.counts} viewerStarRkey={data.viewerStarRkey} /> 41 45 <RepoTabs repo={data.repo} counts={data.counts} active={activeTab} /> 42 46 {@render children()}
+28
web/src/routes/[handle]/[repo]/commit/[ref]/+page.svelte
··· 1 + <script lang="ts"> 2 + import { page } from "$app/state"; 3 + import { toCommitDetail } from "$lib/api/repo"; 4 + import CommitView from "$lib/components/repo/CommitView.svelte"; 5 + import type { DiffStyle } from "$lib/components/repo/pierre"; 6 + 7 + let { data } = $props(); 8 + 9 + const diffStyle = $derived<DiffStyle>( 10 + page.url.searchParams.get("diff") === "split" ? "split" : "unified" 11 + ); 12 + // the load guarantees a commit, the fallback only satisfies the type 13 + const wire = $derived(data.commitDiff.commit ?? {}); 14 + const commit = $derived(toCommitDetail(wire)); 15 + </script> 16 + 17 + <CommitView 18 + ownerHandle={data.repo.ownerHandle} 19 + repoName={data.repo.name} 20 + ref={data.ref} 21 + {commit} 22 + parent={wire.parent ?? ""} 23 + changeId={wire.change_id ?? ""} 24 + files={data.commitDiff.diff ?? []} 25 + stat={data.commitDiff.stat} 26 + {diffStyle} 27 + prerendered={data.prerendered} 28 + />
+54
web/src/routes/[handle]/[repo]/commit/[ref]/+page.ts
··· 1 + import { error } from "@sveltejs/kit"; 2 + import { browser } from "$app/environment"; 3 + import { ClientResponseError, createBobbinClient } from "$lib/api/client"; 4 + import { diffFor, type DiffFile } from "$lib/api/diff"; 5 + import { toHttpError } from "$lib/api/load"; 6 + import { diffRowKey, toFileDiffMetadata } from "$lib/components/repo/fileDiff"; 7 + import { pierreDiffOptions, type DiffStyle } from "$lib/components/repo/pierre"; 8 + import type { PageLoad } from "./$types"; 9 + 10 + // the first paint ships highlighted: prerender each file's shadow dom on 11 + // the server. skipped on client navigations, pierre paints those itself 12 + const prerenderDiffs = async ( 13 + files: DiffFile[], 14 + style: DiffStyle 15 + ): Promise<Record<string, string> | undefined> => { 16 + if (browser) return undefined; 17 + const { preloadFileDiff } = await import("@pierre/diffs/ssr"); 18 + const options = pierreDiffOptions(style); 19 + const entries = await Promise.all( 20 + files 21 + .filter((file) => !file.is_binary) 22 + .map(async (file) => { 23 + const { prerenderedHTML } = await preloadFileDiff({ 24 + fileDiff: toFileDiffMetadata(file), 25 + options 26 + }); 27 + return [diffRowKey(file), prerenderedHTML] as const; 28 + }) 29 + ); 30 + return Object.fromEntries(entries); 31 + }; 32 + 33 + // the ref is a single encoded segment, `feature/x` arrives intact 34 + export const load: PageLoad = async (event) => { 35 + const parent = await event.parent(); 36 + const ctx = createBobbinClient({ 37 + serviceUrl: parent.publicConfig.bobbinUrl, 38 + fetch: event.fetch 39 + }); 40 + const repo = parent.repo.uri; 41 + 42 + const result = await diffFor(ctx, repo, event.params.ref).catch((cause: unknown): never => { 43 + if (cause instanceof ClientResponseError && cause.status === 404) { 44 + error(404, "Commit not found"); 45 + } 46 + return toHttpError(cause, "Could not load commit"); 47 + }); 48 + if (!result?.diff?.commit) error(404, "Commit not found"); 49 + 50 + const style = event.url.searchParams.get("diff") === "split" ? "split" : "unified"; 51 + const prerendered = await prerenderDiffs(result.diff.diff ?? [], style); 52 + 53 + return { ref: event.params.ref, commitDiff: result.diff, prerendered }; 54 + };
+37
web/src/routes/[handle]/[repo]/commit/[spec=rawCommit]/+server.ts
··· 1 + import { error } from "@sveltejs/kit"; 2 + import { toHttpError } from "$lib/api/load"; 3 + import { diffFor } from "$lib/api/diff"; 4 + import { renderFormatPatch, renderUnifiedDiff } from "$lib/api/rawdiff"; 5 + import { parseRawCommit } from "$lib/api/repo"; 6 + import { resolveRepoFromParams } from "$lib/server/repo"; 7 + import type { RequestHandler } from "./$types"; 8 + 9 + export const GET: RequestHandler = async (event) => { 10 + // the rawCommit matcher already guarantees this shape 11 + const spec = parseRawCommit(event.params.spec); 12 + if (!spec) error(404, "Not found"); 13 + // the knot compares hashes case-sensitively, normalize like go-git does 14 + const ref = spec.ref.toLowerCase(); 15 + 16 + const { ctx, view } = await resolveRepoFromParams(event); 17 + 18 + const result = await diffFor(ctx, view.uri, ref, { signal: event.request.signal }).catch( 19 + (cause) => toHttpError(cause, "Could not load commit") 20 + ); 21 + 22 + // an empty body is a valid empty diff (--allow-empty, some merges), only a 23 + // missing diff means the commit is absent 24 + if (result.diff === null || result.diff === undefined) { 25 + error(404, `${ref} does not exist in this repository`); 26 + } 27 + 28 + const body = 29 + spec.format === "patch" ? renderFormatPatch(result.diff) : renderUnifiedDiff(result.diff); 30 + 31 + return new Response(body, { 32 + headers: { 33 + "content-type": "text/plain; charset=utf-8", 34 + "content-disposition": `inline; filename="${ref.slice(0, 7)}.${spec.format}"` 35 + } 36 + }); 37 + };
+15
web/src/routes/[handle]/[repo]/commits/[ref]/+page.svelte
··· 1 + <script lang="ts"> 2 + import CommitLogView from "$lib/components/repo/CommitLogView.svelte"; 3 + 4 + let { data } = $props(); 5 + </script> 6 + 7 + <CommitLogView 8 + ownerHandle={data.repo.ownerHandle} 9 + repoName={data.repo.name} 10 + ref={data.ref} 11 + commits={data.commits} 12 + tagsByCommit={data.tagsByCommit} 13 + page={data.page} 14 + pageCount={data.pageCount} 15 + />
+53
web/src/routes/[handle]/[repo]/commits/[ref]/+page.ts
··· 1 + import { parallel } from "$lib/api/load"; 2 + import { branches, gitTarget, log, tags } from "$lib/api/gitclient"; 3 + import { REF_LIMIT } from "$lib/api/repoIndex"; 4 + import { tagsByCommitHash, toBranchSummary, toCommitSummary, toTagSummary } from "$lib/api/repo"; 5 + import type { PageLoad } from "./$types"; 6 + 7 + // same page size as the appview's log 8 + const COMMIT_LIMIT = 60; 9 + 10 + export const load: PageLoad = async (event) => { 11 + const parent = await event.parent(); 12 + const git = gitTarget(parent.publicConfig, parent.repo, event.fetch); 13 + const ref = event.params.ref; 14 + 15 + // Number accepts hex/exponents/whitespace that the appview's Atoi rejects, 16 + // gate first 17 + const rawPage = event.url.searchParams.get("page") ?? ""; 18 + const parsed = /^\d+$/.test(rawPage) ? Number(rawPage) : 1; 19 + const page = parsed >= 1 ? parsed : 1; 20 + // the log cursor is a numeric offset 21 + const cursor = page > 1 ? String((page - 1) * COMMIT_LIMIT) : undefined; 22 + 23 + const results = await parallel({ 24 + log: log(git, { ref, limit: COMMIT_LIMIT, cursor }), 25 + tags: tags(git, REF_LIMIT), 26 + branches: branches(git, REF_LIMIT) 27 + }); 28 + 29 + const commits = (results.log.commits ?? []).map(toCommitSummary); 30 + const totalCommits = results.log.total ?? 0; 31 + // knot2 answers an exact total when it can, without one a full page hints 32 + // at another 33 + const pageCount = 34 + totalCommits > 0 35 + ? Math.ceil(totalCommits / COMMIT_LIMIT) 36 + : page + (commits.length === COMMIT_LIMIT ? 1 : 0); 37 + 38 + const tagsByCommit = tagsByCommitHash(commits, (results.tags.tags ?? []).map(toTagSummary)); 39 + // branch tips go into the same badge map as tags 40 + const shown = new Set(commits.map((commit) => commit.hash)); 41 + for (const branch of (results.branches.branches ?? []).map(toBranchSummary)) { 42 + if (shown.has(branch.hash)) (tagsByCommit[branch.hash] ??= []).push(branch.name); 43 + } 44 + 45 + return { 46 + ref, 47 + page, 48 + pageCount, 49 + commits, 50 + totalCommits, 51 + tagsByCommit 52 + }; 53 + };