From 0fa0d4fae7c040d6e246f0525445c54a19f99400 Mon Sep 17 00:00:00 2001 From: dawn Date: Thu, 30 Jul 2026 16:15:13 +0300 Subject: [PATCH] web: add the commit log and commit pages Signed-off-by: dawn --- web/src/lib/api/rawdiff.test.ts | 353 ++++++++++++++++++ web/src/lib/api/repo.test.ts | 64 ++++ web/src/lib/api/repo.ts | 71 +++- .../repo/CommitHeader.stories.svelte | 116 ++++++ .../lib/components/repo/CommitHeader.svelte | 116 ++++++ .../repo/CommitLogView.stories.svelte | 77 ++++ .../lib/components/repo/CommitLogView.svelte | 218 +++++++++++ .../components/repo/CommitView.stories.svelte | 100 +++++ web/src/lib/components/repo/CommitView.svelte | 60 +++ .../repo/DiffFileCard.stories.svelte | 137 +++++++ .../lib/components/repo/DiffFileCard.svelte | 100 +++++ .../repo/DiffFileList.stories.svelte | 57 +++ .../lib/components/repo/DiffFileList.svelte | 84 +++++ .../repo/DiffStatPill.stories.svelte | 37 ++ .../lib/components/repo/DiffStatPill.svelte | 31 ++ .../components/repo/DiffTopbar.stories.svelte | 96 +++++ web/src/lib/components/repo/DiffTopbar.svelte | 118 ++++++ .../components/repo/DiffView.stories.svelte | 139 +++++++ web/src/lib/components/repo/DiffView.svelte | 117 ++++++ web/src/lib/format.ts | 14 + web/src/params/rawCommit.ts | 6 + .../routes/[handle]/[repo]/+layout@.svelte | 6 +- .../[handle]/[repo]/commit/[ref]/+page.svelte | 28 ++ .../[handle]/[repo]/commit/[ref]/+page.ts | 54 +++ .../[repo]/commit/[spec=rawCommit]/+server.ts | 37 ++ .../[repo]/commits/[ref]/+page.svelte | 15 + .../[handle]/[repo]/commits/[ref]/+page.ts | 53 +++ 27 files changed, 2302 insertions(+), 2 deletions(-) create mode 100644 web/src/lib/api/rawdiff.test.ts create mode 100644 web/src/lib/components/repo/CommitHeader.stories.svelte create mode 100644 web/src/lib/components/repo/CommitHeader.svelte create mode 100644 web/src/lib/components/repo/CommitLogView.stories.svelte create mode 100644 web/src/lib/components/repo/CommitLogView.svelte create mode 100644 web/src/lib/components/repo/CommitView.stories.svelte create mode 100644 web/src/lib/components/repo/CommitView.svelte create mode 100644 web/src/lib/components/repo/DiffFileCard.stories.svelte create mode 100644 web/src/lib/components/repo/DiffFileCard.svelte create mode 100644 web/src/lib/components/repo/DiffFileList.stories.svelte create mode 100644 web/src/lib/components/repo/DiffFileList.svelte create mode 100644 web/src/lib/components/repo/DiffStatPill.stories.svelte create mode 100644 web/src/lib/components/repo/DiffStatPill.svelte create mode 100644 web/src/lib/components/repo/DiffTopbar.stories.svelte create mode 100644 web/src/lib/components/repo/DiffTopbar.svelte create mode 100644 web/src/lib/components/repo/DiffView.stories.svelte create mode 100644 web/src/lib/components/repo/DiffView.svelte create mode 100644 web/src/params/rawCommit.ts create mode 100644 web/src/routes/[handle]/[repo]/commit/[ref]/+page.svelte create mode 100644 web/src/routes/[handle]/[repo]/commit/[ref]/+page.ts create mode 100644 web/src/routes/[handle]/[repo]/commit/[spec=rawCommit]/+server.ts create mode 100644 web/src/routes/[handle]/[repo]/commits/[ref]/+page.svelte create mode 100644 web/src/routes/[handle]/[repo]/commits/[ref]/+page.ts diff --git a/web/src/lib/api/rawdiff.test.ts b/web/src/lib/api/rawdiff.test.ts new file mode 100644 index 00000000..f6c0fcae --- /dev/null +++ b/web/src/lib/api/rawdiff.test.ts @@ -0,0 +1,353 @@ +import { describe, expect, it } from "vitest"; +import { LineOp, type NiceDiff } from "./diff"; +import { renderFormatPatch, renderUnifiedDiff } from "./rawdiff"; + +const modifiedFile: NiceDiff = { + diff: [ + { + name: { old: "foo.go", new: "foo.go" }, + text_fragments: [ + { + Comment: "func main()", + OldPosition: 1, + OldLines: 3, + NewPosition: 1, + NewLines: 3, + Lines: [ + { Op: LineOp.Context, Line: "package main\n" }, + { Op: LineOp.Delete, Line: "// old comment\n" }, + { Op: LineOp.Add, Line: "// new comment\n" }, + { Op: LineOp.Context, Line: "func main() {}\n" } + ] + } + ] + } + ] +}; + +describe("renderUnifiedDiff", () => { + it("renders nothing for a missing diff", () => { + expect(renderUnifiedDiff(null)).toBe(""); + expect(renderUnifiedDiff(undefined)).toBe(""); + }); + + it("renders a modified file with the hunk comment", () => { + expect(renderUnifiedDiff(modifiedFile)).toBe( + "diff --git a/foo.go b/foo.go\n" + + "--- a/foo.go\n" + + "+++ b/foo.go\n" + + "@@ -1,3 +1,3 @@ func main()\n" + + " package main\n" + + "-// old comment\n" + + "+// new comment\n" + + " func main() {}\n" + ); + }); + + it("renders a new file", () => { + const got = renderUnifiedDiff({ + diff: [ + { + name: { old: "", new: "new.go" }, + is_new: true, + text_fragments: [ + { + OldPosition: 0, + OldLines: 0, + NewPosition: 1, + NewLines: 2, + Lines: [ + { Op: LineOp.Add, Line: "package main\n" }, + { Op: LineOp.Add, Line: "func main() {}\n" } + ] + } + ] + } + ] + }); + expect(got).toBe( + "diff --git a/new.go b/new.go\n" + + "new file mode 100644\n" + + "--- /dev/null\n" + + "+++ b/new.go\n" + + "@@ -0,0 +1,2 @@\n" + + "+package main\n" + + "+func main() {}\n" + ); + }); + + it("renders a deleted file", () => { + const got = renderUnifiedDiff({ + diff: [ + { + name: { old: "old.go", new: "" }, + is_delete: true, + text_fragments: [ + { + OldPosition: 1, + OldLines: 2, + NewPosition: 0, + NewLines: 0, + Lines: [ + { Op: LineOp.Delete, Line: "package main\n" }, + { Op: LineOp.Delete, Line: "func main() {}\n" } + ] + } + ] + } + ] + }); + expect(got).toBe( + "diff --git a/old.go b/old.go\n" + + "deleted file mode 100644\n" + + "--- a/old.go\n" + + "+++ /dev/null\n" + + "@@ -1,2 +0,0 @@\n" + + "-package main\n" + + "-func main() {}\n" + ); + }); + + it("renders a renamed file with multiple fragments", () => { + const got = renderUnifiedDiff({ + diff: [ + { + name: { old: "old.go", new: "renamed.go" }, + is_rename: true, + text_fragments: [ + { + OldPosition: 1, + OldLines: 2, + NewPosition: 1, + NewLines: 2, + Lines: [ + { Op: LineOp.Context, Line: "package main\n" }, + { Op: LineOp.Delete, Line: "func old() {}\n" }, + { Op: LineOp.Add, Line: "func renamed() {}\n" } + ] + }, + { + Comment: "func init()", + OldPosition: 10, + OldLines: 1, + NewPosition: 10, + NewLines: 1, + Lines: [{ Op: LineOp.Context, Line: "var x = 1\n" }] + } + ] + } + ] + }); + expect(got).toBe( + "diff --git a/old.go b/renamed.go\n" + + "rename from old.go\n" + + "rename to renamed.go\n" + + "--- a/old.go\n" + + "+++ b/renamed.go\n" + + "@@ -1,2 +1,2 @@\n" + + " package main\n" + + "-func old() {}\n" + + "+func renamed() {}\n" + + "@@ -10,1 +10,1 @@ func init()\n" + + " var x = 1\n" + ); + }); + + it("renders multiple files", () => { + const file = (name: string, oldLine: string, newLine: string) => ({ + name: { old: name, new: name }, + text_fragments: [ + { + OldPosition: 1, + OldLines: 1, + NewPosition: 1, + NewLines: 1, + Lines: [ + { Op: LineOp.Delete, Line: `${oldLine}\n` }, + { Op: LineOp.Add, Line: `${newLine}\n` } + ] + } + ] + }); + const got = renderUnifiedDiff({ + diff: [file("a.go", "old a", "new a"), file("b.go", "old b", "new b")] + }); + expect(got).toBe( + "diff --git a/a.go b/a.go\n" + + "--- a/a.go\n" + + "+++ b/a.go\n" + + "@@ -1,1 +1,1 @@\n" + + "-old a\n" + + "+new a\n" + + "diff --git a/b.go b/b.go\n" + + "--- a/b.go\n" + + "+++ b/b.go\n" + + "@@ -1,1 +1,1 @@\n" + + "-old b\n" + + "+new b\n" + ); + }); + + it("marks a missing trailing newline on delete and context lines", () => { + const got = renderUnifiedDiff({ + diff: [ + { + name: { old: "foo.go", new: "foo.go" }, + text_fragments: [ + { + OldPosition: 1, + OldLines: 2, + NewPosition: 1, + NewLines: 2, + Lines: [ + { Op: LineOp.Delete, Line: "old" }, + { Op: LineOp.Add, Line: "new\n" }, + { Op: LineOp.Context, Line: "tail" } + ] + } + ] + } + ] + }); + expect(got).toBe( + "diff --git a/foo.go b/foo.go\n" + + "--- a/foo.go\n" + + "+++ b/foo.go\n" + + "@@ -1,2 +1,2 @@\n" + + "-old\n" + + "\\ No newline at end of file\n" + + "+new\n" + + " tail\n" + + "\\ No newline at end of file\n" + ); + }); +}); + +describe("renderFormatPatch", () => { + it("renders nothing for a missing diff", () => { + expect(renderFormatPatch(null)).toBe(""); + expect(renderFormatPatch(undefined)).toBe(""); + }); + + it("renders the full patch for a rename and a delete", () => { + const got = renderFormatPatch({ + commit: { + hash: [0xab, 0xc1, 0x23, 0x45, 0x67, 0x89, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + message: "Fix the bug\n\nThis patch resolves the long-standing issue.\n\n\n", + author: { Name: "Alice Dev", Email: "alice@example.com", When: "2024-03-15T10:30:00Z" } + }, + stat: { files_changed: 2, insertions: 1, deletions: 3 }, + diff: [ + { + name: { old: "old.go", new: "renamed.go" }, + is_rename: true, + text_fragments: [ + { + OldPosition: 1, + OldLines: 2, + NewPosition: 1, + NewLines: 2, + LinesAdded: 1, + LinesDeleted: 1, + Lines: [ + { Op: LineOp.Context, Line: "package main\n" }, + { Op: LineOp.Delete, Line: "func old() {}\n" }, + { Op: LineOp.Add, Line: "func renamed() {}\n" } + ] + } + ] + }, + { + name: { old: "gone.go", new: "" }, + is_delete: true, + text_fragments: [ + { + OldPosition: 1, + OldLines: 2, + NewPosition: 0, + NewLines: 0, + LinesAdded: 0, + LinesDeleted: 2, + Lines: [ + { Op: LineOp.Delete, Line: "package main\n" }, + { Op: LineOp.Delete, Line: "func main() {}\n" } + ] + } + ] + } + ] + }); + expect(got).toBe( + "From abc1234567890000000000000000000000000000 Mon Sep 17 00:00:00 2001\n" + + "From: Alice Dev \n" + + "Date: Fri, 15 Mar 2024 10:30:00 +0000\n" + + "Subject: [PATCH] Fix the bug\n" + + "\n" + + "This patch resolves the long-standing issue.\n" + + "---\n" + + " renamed.go | 2 +-\n" + + " gone.go | 2 --\n" + + " 2 file(s) changed, 1 insertion(s)(+), 3 deletion(s)(-)\n" + + "\n" + + "diff --git a/old.go b/renamed.go\n" + + "rename from old.go\n" + + "rename to renamed.go\n" + + "--- a/old.go\n" + + "+++ b/renamed.go\n" + + "@@ -1,2 +1,2 @@\n" + + " package main\n" + + "-func old() {}\n" + + "+func renamed() {}\n" + + "diff --git a/gone.go b/gone.go\n" + + "deleted file mode 100644\n" + + "--- a/gone.go\n" + + "+++ /dev/null\n" + + "@@ -1,2 +0,0 @@\n" + + "-package main\n" + + "-func main() {}\n" + + "\n--\ntangled.sh\n" + ); + }); + + it("omits the body for a single-line message and uses the zero time for a missing When", () => { + const got = renderFormatPatch({ + commit: { + message: "Single line commit", + author: { Name: "", Email: "", When: "" } + } + }); + expect(got).toBe( + "From Mon Sep 17 00:00:00 2001\n" + + "From: <>\n" + + "Date: Mon, 01 Jan 0001 00:00:00 +0000\n" + + "Subject: [PATCH] Single line commit\n" + + "\n" + + "---\n" + + " 0 file(s) changed, 0 insertion(s)(+), 0 deletion(s)(-)\n" + + "\n" + + "\n--\ntangled.sh\n" + ); + }); + + it("uses the zero time for an unparseable When", () => { + const got = renderFormatPatch({ + commit: { + this: "abc123", + message: "x", + author: { Name: "A", Email: "a@b.c", When: "not a date" } + } + }); + expect(got).toContain("Date: Mon, 01 Jan 0001 00:00:00 +0000\n"); + }); + + it("pads the year to four digits", () => { + const got = renderFormatPatch({ + commit: { + this: "abc123", + message: "x", + author: { Name: "A", Email: "a@b.c", When: "0999-06-15T10:30:00Z" } + } + }); + expect(got).toContain("Date: Sat, 15 Jun 0999 10:30:00 +0000\n"); + }); +}); diff --git a/web/src/lib/api/repo.test.ts b/web/src/lib/api/repo.test.ts index 381645d1..8ef1311d 100644 --- a/web/src/lib/api/repo.test.ts +++ b/web/src/lib/api/repo.test.ts @@ -1,10 +1,12 @@ import { describe, expect, it, vi } from "vitest"; import { + coAuthorsFrom, logFor, repoNameOf, resolveRepoByName, sortTreeEntries, toBranchSummary, + toCommitDetail, toCommitSummary, toTagSummary, toTreeEntrySummary, @@ -15,6 +17,7 @@ import { type TreeEntrySummary } from "./repo"; import { ClientResponseError, createBobbinClient, type BobbinContext } from "./client"; +import type { NiceCommit } from "./diff"; import type { RecordView, RepoRecord } from "./records"; const jsonResponse = (body: unknown): Response => @@ -99,6 +102,67 @@ describe("toCommitSummary", () => { }); }); +describe("toCommitDetail", () => { + const commit: NiceCommit = { + this: "0c4d0e9b07940033721395a434b5873f0fb9e6c8", + author: { Name: "Ada", Email: "ada@example.com", When: "2026-07-01T10:00:00Z" }, + committer: { Name: "Grace", Email: "grace@example.com", When: "2026-07-02T10:00:00Z" }, + message: + "web: add commit page\n\nA longer body.\n\nCo-authored-by: Alan Turing \n" + }; + + it("carries both signatures and stamps co-authors with the committer's When", () => { + expect(toCommitDetail(commit)).toEqual({ + hash: "0c4d0e9b07940033721395a434b5873f0fb9e6c8", + shortHash: "0c4d0e9b", + subject: "web: add commit page", + body: "A longer body.\n\nCo-authored-by: Alan Turing ", + authorName: "Ada", + authorEmail: "ada@example.com", + authorWhen: "2026-07-01T10:00:00Z", + committerName: "Grace", + committerEmail: "grace@example.com", + committerWhen: "2026-07-02T10:00:00Z", + coAuthors: [{ name: "Alan Turing", email: "alan@example.com" }] + }); + }); + + it("fills empty strings for a bare commit", () => { + expect(toCommitDetail({})).toEqual({ + hash: "", + shortHash: "", + subject: "", + body: "", + authorName: "", + authorEmail: "", + authorWhen: "", + committerName: "", + committerEmail: "", + committerWhen: "", + coAuthors: [] + }); + }); +}); + +describe("coAuthorsFrom", () => { + it("stamps every co-author with the passed When, like go's Commit.CoAuthors", () => { + const message = + "subject\n\nCo-authored-by: Ada Lovelace \nCo-authored-by: Alan Turing \n"; + expect(coAuthorsFrom(message, "2026-07-02T10:00:00Z")).toEqual([ + { Name: "Ada Lovelace", Email: "ada@example.com", When: "2026-07-02T10:00:00Z" }, + { Name: "Alan Turing", Email: "alan@example.com", When: "2026-07-02T10:00:00Z" } + ]); + }); + + it("dedupes by email and matches the trailer case-insensitively", () => { + const message = + "subject\n\nco-authored-by: Ada \nCo-Authored-By: Ada Again \n"; + expect(coAuthorsFrom(message, "w")).toEqual([ + { Name: "Ada", Email: "ada@example.com", When: "w" } + ]); + }); +}); + describe("logFor", () => { it("passes the cursor through to the knot", async () => { const fetchMock = vi diff --git a/web/src/lib/api/repo.ts b/web/src/lib/api/repo.ts index 9de27359..77aab92d 100644 --- a/web/src/lib/api/repo.ts +++ b/web/src/lib/api/repo.ts @@ -1,4 +1,5 @@ import { ClientResponseError, type BobbinContext, type XrpcRequestInit } from "./client"; +import type { NiceCommit } from "./diff"; import { getRepoByName, type RecordView, type RepoRecord } from "./records"; import { branches as knotBranches, log as knotLog, tag as knotTag, tags as knotTags } from "./knot"; import { httpStatusFor } from "./load"; @@ -15,6 +16,19 @@ export interface GitSignature { When: string; } +// go-git's IsHash accepts uppercase hex too (hex.DecodeString), both sha1 +// and sha256 match case-insensitively +export const FULL_HASH_RE = /^([0-9a-fA-F]{40}|[0-9a-fA-F]{64})$/; + +export const parseRawCommit = (spec: string): { ref: string; format: "patch" | "diff" } | null => { + const dot = spec.lastIndexOf("."); + if (dot === -1) return null; + const format = spec.slice(dot + 1); + if (format !== "patch" && format !== "diff") return null; + const ref = spec.slice(0, dot); + return FULL_HASH_RE.test(ref) ? { ref, format } : null; +}; + export interface GitCommit { Author?: GitSignature; Committer?: GitSignature; @@ -106,7 +120,7 @@ export interface CommitSummary { changeId?: string; } -const splitMessage = (message: string): [string, string] => { +export const splitMessage = (message: string): [string, string] => { const separator = message.indexOf("\n\n"); if (separator === -1) return [message.trim(), ""]; return [message.slice(0, separator).trim(), message.slice(separator + 2).trim()]; @@ -115,6 +129,23 @@ const splitMessage = (message: string): [string, string] => { /** the subject is the first paragraph, not the first line */ export const subjectOf = (message: string): string => splitMessage(message)[0]; +const coAuthorPattern = /^Co-authored-by:\s*(.+?)\s*<([^>]+)>/gim; + +// trailers carry no date of their own, like types/commit.go every +// co-author gets stamped with the committer's When +export const coAuthorsFrom = (message: string, when: string): GitSignature[] => { + const seen = new Set(); + const coAuthors: GitSignature[] = []; + for (const match of message.matchAll(coAuthorPattern)) { + const name = match[1].trim(); + const email = match[2].trim(); + if (seen.has(email)) continue; + seen.add(email); + coAuthors.push({ Name: name, Email: email, When: when }); + } + return coAuthors; +}; + export const toCommitSummary = (commit: LogCommit): CommitSummary => { const [subject, body] = splitMessage(commit.message ?? ""); const hash = commit.this ?? ""; @@ -130,6 +161,44 @@ export const toCommitSummary = (commit: LogCommit): CommitSummary => { }; }; +export interface CommitDetail { + hash: string; + shortHash: string; + subject: string; + body: string; + authorName: string; + authorEmail: string; + authorWhen: string; + committerName: string; + committerEmail: string; + committerWhen: string; + coAuthors: { name: string; email: string }[]; +} + +// the diff endpoint's commit carries both signatures, unlike the log's +// summary shape +export const toCommitDetail = (commit: NiceCommit): CommitDetail => { + const [subject, body] = splitMessage(commit.message ?? ""); + const hash = commit.this ?? ""; + const committerWhen = commit.committer?.When ?? ""; + return { + hash, + shortHash: hash.slice(0, 8), + subject, + body, + authorName: commit.author?.Name ?? "", + authorEmail: commit.author?.Email ?? "", + authorWhen: commit.author?.When ?? "", + committerName: commit.committer?.Name ?? "", + committerEmail: commit.committer?.Email ?? "", + committerWhen, + coAuthors: coAuthorsFrom(commit.message ?? "", committerWhen).map(({ Name, Email }) => ({ + name: Name, + email: Email + })) + }; +}; + // the tree endpoint uses lexicon casing where the log ones pass through go field // names, so this cannot share `toCommitSummary` export const toTreeCommitSummary = (commit: { diff --git a/web/src/lib/components/repo/CommitHeader.stories.svelte b/web/src/lib/components/repo/CommitHeader.stories.svelte new file mode 100644 index 00000000..b1cede40 --- /dev/null +++ b/web/src/lib/components/repo/CommitHeader.stories.svelte @@ -0,0 +1,116 @@ + + + + + + + diff --git a/web/src/lib/components/repo/CommitHeader.svelte b/web/src/lib/components/repo/CommitHeader.svelte new file mode 100644 index 00000000..b4d6c391 --- /dev/null +++ b/web/src/lib/components/repo/CommitHeader.svelte @@ -0,0 +1,116 @@ + + +
+
+

{commit.subject}

+ {#if commit.body} +

+ {commit.body} +

+ {/if} +
+ + {#snippet attribution(label: string, name: string, email: string)} + + {label} + + + + {#if email} + {name} + {:else} + {name} + {/if} + + + {/snippet} + +
+
+ {@render attribution("author", commit.authorName, commit.authorEmail)} + + {#each commit.coAuthors as coAuthor (coAuthor.email)} + {@render attribution("co-author", coAuthor.name, coAuthor.email)} + {/each} + + {#if showCommitter} + {@render attribution("committer", commit.committerName, commit.committerEmail)} + {/if} + + {#if commit.committerWhen} + + date + + + {#if absoluteTime} + ({absoluteTime}) + {/if} + + + {/if} + + {#if commit.hash} + + commit + + {commit.shortHash} + + + + {/if} + + {#if parent} + + parent + + {parentShort} + + + + {/if} + + {#if changeId} + + change-id + + {changeIdShort} + + + + {/if} +
+
+
diff --git a/web/src/lib/components/repo/CommitLogView.stories.svelte b/web/src/lib/components/repo/CommitLogView.stories.svelte new file mode 100644 index 00000000..2f07c0d0 --- /dev/null +++ b/web/src/lib/components/repo/CommitLogView.stories.svelte @@ -0,0 +1,77 @@ + + + + + + diff --git a/web/src/lib/components/repo/CommitLogView.svelte b/web/src/lib/components/repo/CommitLogView.svelte new file mode 100644 index 00000000..5c064ae2 --- /dev/null +++ b/web/src/lib/components/repo/CommitLogView.svelte @@ -0,0 +1,218 @@ + + +{#snippet authorCell(commit: CommitSummary)} + + + + {#if commit.authorEmail} + + {commit.authorName} + + {:else} + {commit.authorName} + {/if} + +{/snippet} + +{#snippet shaChip(commit: CommitSummary, mobile: boolean)} + + {commit.shortHash} + +{/snippet} + +{#snippet treeLink(commit: CommitSummary)} + + +{/snippet} + +{#snippet copyButton(commit: CommitSummary)} + +{/snippet} + +{#snippet messageCell(commit: CommitSummary)} +
+ + {commit.subject} + + {#if commit.body} + + {/if} + {#each tagsByCommit[commit.hash] ?? [] as name (name)} + {name} + {/each} +
+ {#if commit.body && expanded[commit.hash]} +

+ {commit.body} +

+ {/if} +{/snippet} + +
+

Commits

+ + {#if commits.length === 0} +

No commits at {ref}.

+ {:else} + + +
+ {#each commits as commit, index (commit.hash)} +
+
+
+ {@render messageCell(commit)} +
+ {@render treeLink(commit)} +
+
+ + {@render shaChip(commit, true)} + + + {@render authorCell(commit)} + {#if commit.when} + + + {/if} +
+
+ {/each} +
+ {/if} +
+ +{#if hasPrev || hasNext} +
+ {#if hasPrev} + + {/if} + {#if hasNext} + + {/if} +
+{/if} diff --git a/web/src/lib/components/repo/CommitView.stories.svelte b/web/src/lib/components/repo/CommitView.stories.svelte new file mode 100644 index 00000000..2881a668 --- /dev/null +++ b/web/src/lib/components/repo/CommitView.stories.svelte @@ -0,0 +1,100 @@ + + + + + + + diff --git a/web/src/lib/components/repo/CommitView.svelte b/web/src/lib/components/repo/CommitView.svelte new file mode 100644 index 00000000..2be385e8 --- /dev/null +++ b/web/src/lib/components/repo/CommitView.svelte @@ -0,0 +1,60 @@ + + +
+ + + + +
diff --git a/web/src/lib/components/repo/DiffFileCard.stories.svelte b/web/src/lib/components/repo/DiffFileCard.stories.svelte new file mode 100644 index 00000000..c2c12d14 --- /dev/null +++ b/web/src/lib/components/repo/DiffFileCard.stories.svelte @@ -0,0 +1,137 @@ + + + { + await waitFor(() => expect(shadowText(canvasElement)).toContain("oklch(0.98 0 0)")); + }} +/> + + { + const card = canvasElement.querySelector("details")!; + await expect(card.open).toBe(true); + await userEvent.click(card.querySelector("summary")!); + await expect(card.open).toBe(false); + await userEvent.click(card.querySelector("summary")!); + await expect(card.open).toBe(true); + await expect(canvas.getByText("src/colors.ts")).toBeInTheDocument(); + }} +/> + + { + // both names live in the same flex row, match the joined text + const header = canvasElement.querySelector("summary")!; + await expect(header.textContent).toContain("src/old-name.ts"); + await expect(header.textContent).toContain("src/new-name.ts"); + }} +/> + + { + await expect( + canvas.getByText("This is a binary file and will not be displayed.") + ).toBeInTheDocument(); + }} +/> + + { + await expect(canvas.getByRole("link", { name: /view file/i })).toHaveAttribute( + "href", + "/dawn/tangled/blob/master/src/colors.ts" + ); + }} +/> diff --git a/web/src/lib/components/repo/DiffFileCard.svelte b/web/src/lib/components/repo/DiffFileCard.svelte new file mode 100644 index 00000000..7f867372 --- /dev/null +++ b/web/src/lib/components/repo/DiffFileCard.svelte @@ -0,0 +1,100 @@ + + + +
+ +
+
+ {#if open} + + {:else} + + {/if} + + +
+ {#if file.name.old && file.name.new && file.name.old !== file.name.new} + {file.name.old} + + {file.name.new} + {:else} + {name} + {/if} +
+
+
+ {#if blobUrl} + + {/if} + {@render headerActions?.(file)} +
+
+
+ + {#if file.is_binary} +

+ This is a binary file and will not be displayed. +

+ {:else} + + {/if} +
diff --git a/web/src/lib/components/repo/DiffFileList.stories.svelte b/web/src/lib/components/repo/DiffFileList.stories.svelte new file mode 100644 index 00000000..4c67508a --- /dev/null +++ b/web/src/lib/components/repo/DiffFileList.stories.svelte @@ -0,0 +1,57 @@ + + + + + + + diff --git a/web/src/lib/components/repo/DiffFileList.svelte b/web/src/lib/components/repo/DiffFileList.svelte new file mode 100644 index 00000000..791fe6bd --- /dev/null +++ b/web/src/lib/components/repo/DiffFileList.svelte @@ -0,0 +1,84 @@ + + + + +{#snippet nodes(list: TreeNode[])} + {#each list as node (`${node.type}:${node.name}`)} + {#if node.type === "dir"} +
+ + + + + +
+ {@render nodes(node.children)} +
+
+ {:else} + + {/if} + {/each} +{/snippet} + +{@render nodes(tree)} diff --git a/web/src/lib/components/repo/DiffStatPill.stories.svelte b/web/src/lib/components/repo/DiffStatPill.stories.svelte new file mode 100644 index 00000000..6e3ae65f --- /dev/null +++ b/web/src/lib/components/repo/DiffStatPill.stories.svelte @@ -0,0 +1,37 @@ + + + + + + + + + diff --git a/web/src/lib/components/repo/DiffStatPill.svelte b/web/src/lib/components/repo/DiffStatPill.svelte new file mode 100644 index 00000000..800c8dc0 --- /dev/null +++ b/web/src/lib/components/repo/DiffStatPill.svelte @@ -0,0 +1,31 @@ + + +{#if stat.insertions > 0 || stat.deletions > 0} +
+ {#if stat.insertions > 0 && stat.deletions > 0} + + +{stat.insertions} + + + -{stat.deletions} + + {:else if stat.insertions > 0} + + +{stat.insertions} + + {:else} + + -{stat.deletions} + + {/if} +
+{/if} diff --git a/web/src/lib/components/repo/DiffTopbar.stories.svelte b/web/src/lib/components/repo/DiffTopbar.stories.svelte new file mode 100644 index 00000000..27cbbdc3 --- /dev/null +++ b/web/src/lib/components/repo/DiffTopbar.stories.svelte @@ -0,0 +1,96 @@ + + + + + { + await expect(canvas.getByRole("link", { name: ".patch" })).toHaveAttribute( + "href", + expect.stringContaining(".patch") + ); + await expect(canvas.getByRole("link", { name: ".diff" })).toHaveAttribute( + "href", + expect.stringContaining(".diff") + ); + // reloads on purpose, the client router would shadow the endpoint with the + // commit page's [ref] route and 404 + for (const link of canvas.getAllByRole("link", { name: /^\./ })) { + await expect(link).toHaveAttribute("data-sveltekit-reload"); + } + }} +/> + + { + await userEvent.click(canvas.getByTitle("Toggle file list")); + await expect(onToggleFiles).toHaveBeenCalledOnce(); + + await userEvent.click(canvas.getByRole("button", { name: /collapse all/i })); + await expect(onSetAllOpen).toHaveBeenCalledWith(false); + }} +/> + + { + await expect(canvas.getByRole("button", { name: /expand all/i })).toBeInTheDocument(); + }} +/> + + { + await expect(canvas.getByTitle("Unified diff")).toHaveAttribute("href", "?diff=unified"); + await expect(canvas.getByTitle("Split diff")).toHaveAttribute("href", "?diff=split"); + // replacestate navigation, no noscroll means it jumps to top + for (const link of [canvas.getByTitle("Unified diff"), canvas.getByTitle("Split diff")]) { + await expect(link).toHaveAttribute("data-sveltekit-noscroll"); + } + }} +/> + + + + {#snippet center()} + Round #2 + {/snippet} + {#snippet actions()} + review panel + {/snippet} + + diff --git a/web/src/lib/components/repo/DiffTopbar.svelte b/web/src/lib/components/repo/DiffTopbar.svelte new file mode 100644 index 00000000..70fda677 --- /dev/null +++ b/web/src/lib/components/repo/DiffTopbar.svelte @@ -0,0 +1,118 @@ + + +
+ {#if onToggleFiles} + + + + {/if} + + {#if onSetAllOpen} + + {/if} + + +
diff --git a/web/src/lib/components/repo/DiffView.stories.svelte b/web/src/lib/components/repo/DiffView.stories.svelte new file mode 100644 index 00000000..976398b2 --- /dev/null +++ b/web/src/lib/components/repo/DiffView.stories.svelte @@ -0,0 +1,139 @@ + + + + + + + + + diff --git a/web/src/lib/components/repo/DiffView.svelte b/web/src/lib/components/repo/DiffView.svelte new file mode 100644 index 00000000..d0c6adb1 --- /dev/null +++ b/web/src/lib/components/repo/DiffView.svelte @@ -0,0 +1,117 @@ + + +
+ 0 ? () => (filesOpen = !filesOpen) : undefined} + {allOpen} + onSetAllOpen={setAll} + center={topbarCenter} + actions={topbarActions} + /> + +
+ {#if rows.length > 0 && filesOpen} + + {/if} + +
+ {#if rows.length === 0} +
+

No differences found between the selected revisions.

+
+ {/if} + {#each rows as row (row.key)} + isOpen(row.key), (open) => (openStates[row.key] = open)} + headerActions={fileHeaderActions} + /> + {/each} +
+
+
diff --git a/web/src/lib/format.ts b/web/src/lib/format.ts index ecdc5230..47e2b548 100644 --- a/web/src/lib/format.ts +++ b/web/src/lib/format.ts @@ -10,6 +10,15 @@ const DIVISIONS: { amount: number; unit: Intl.RelativeTimeFormatUnit }[] = [ const rtf = new Intl.RelativeTimeFormat("en", { numeric: "auto" }); const dtf = new Intl.DateTimeFormat("en", { year: "numeric", month: "short", day: "numeric" }); +// the appview's longTimeFmt ("Jan 2, 2006, 3:04 PM MST") +const dtfFull = new Intl.DateTimeFormat("en", { + year: "numeric", + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + timeZoneName: "short" +}); // "3 days ago", "in 2 hours", etc. export const relativeTime = (input: string | Date, now: Date = new Date()): string => { @@ -48,3 +57,8 @@ export const formatDate = (input: string | Date): string => { const date = typeof input === "string" ? new Date(input) : input; return Number.isNaN(date.getTime()) ? "" : dtf.format(date); }; + +export const formatDateTime = (input: string | Date): string => { + const date = typeof input === "string" ? new Date(input) : input; + return Number.isNaN(date.getTime()) ? "" : dtfFull.format(date); +}; diff --git a/web/src/params/rawCommit.ts b/web/src/params/rawCommit.ts new file mode 100644 index 00000000..ac33c804 --- /dev/null +++ b/web/src/params/rawCommit.ts @@ -0,0 +1,6 @@ +import type { ParamMatcher } from "@sveltejs/kit"; +import { parseRawCommit } from "$lib/api/repo"; + +// requiring the hash here means refs that are not hashes fall through to the +// commit page +export const match: ParamMatcher = (param) => parseRawCommit(param) !== null; diff --git a/web/src/routes/[handle]/[repo]/+layout@.svelte b/web/src/routes/[handle]/[repo]/+layout@.svelte index 78c206fa..93354754 100644 --- a/web/src/routes/[handle]/[repo]/+layout@.svelte +++ b/web/src/routes/[handle]/[repo]/+layout@.svelte @@ -15,6 +15,10 @@ const segment = page.route.id?.split("/")[3] ?? ""; return ["issues", "pulls", "pipelines", "settings"].includes(segment) ? segment : "overview"; }); + + // commit pages break out of the reading column, everything else stays + // capped + const fullWidth = $derived((page.route.id ?? "").includes("/commit/")); @@ -36,7 +40,7 @@ /> -
+
{@render children()} diff --git a/web/src/routes/[handle]/[repo]/commit/[ref]/+page.svelte b/web/src/routes/[handle]/[repo]/commit/[ref]/+page.svelte new file mode 100644 index 00000000..46647de9 --- /dev/null +++ b/web/src/routes/[handle]/[repo]/commit/[ref]/+page.svelte @@ -0,0 +1,28 @@ + + + diff --git a/web/src/routes/[handle]/[repo]/commit/[ref]/+page.ts b/web/src/routes/[handle]/[repo]/commit/[ref]/+page.ts new file mode 100644 index 00000000..8c6c3cb4 --- /dev/null +++ b/web/src/routes/[handle]/[repo]/commit/[ref]/+page.ts @@ -0,0 +1,54 @@ +import { error } from "@sveltejs/kit"; +import { browser } from "$app/environment"; +import { ClientResponseError, createBobbinClient } from "$lib/api/client"; +import { diffFor, type DiffFile } from "$lib/api/diff"; +import { toHttpError } from "$lib/api/load"; +import { diffRowKey, toFileDiffMetadata } from "$lib/components/repo/fileDiff"; +import { pierreDiffOptions, type DiffStyle } from "$lib/components/repo/pierre"; +import type { PageLoad } from "./$types"; + +// the first paint ships highlighted: prerender each file's shadow dom on +// the server. skipped on client navigations, pierre paints those itself +const prerenderDiffs = async ( + files: DiffFile[], + style: DiffStyle +): Promise | undefined> => { + if (browser) return undefined; + const { preloadFileDiff } = await import("@pierre/diffs/ssr"); + const options = pierreDiffOptions(style); + const entries = await Promise.all( + files + .filter((file) => !file.is_binary) + .map(async (file) => { + const { prerenderedHTML } = await preloadFileDiff({ + fileDiff: toFileDiffMetadata(file), + options + }); + return [diffRowKey(file), prerenderedHTML] as const; + }) + ); + return Object.fromEntries(entries); +}; + +// the ref is a single encoded segment, `feature/x` arrives intact +export const load: PageLoad = async (event) => { + const parent = await event.parent(); + const ctx = createBobbinClient({ + serviceUrl: parent.publicConfig.bobbinUrl, + fetch: event.fetch + }); + const repo = parent.repo.uri; + + const result = await diffFor(ctx, repo, event.params.ref).catch((cause: unknown): never => { + if (cause instanceof ClientResponseError && cause.status === 404) { + error(404, "Commit not found"); + } + return toHttpError(cause, "Could not load commit"); + }); + if (!result?.diff?.commit) error(404, "Commit not found"); + + const style = event.url.searchParams.get("diff") === "split" ? "split" : "unified"; + const prerendered = await prerenderDiffs(result.diff.diff ?? [], style); + + return { ref: event.params.ref, commitDiff: result.diff, prerendered }; +}; diff --git a/web/src/routes/[handle]/[repo]/commit/[spec=rawCommit]/+server.ts b/web/src/routes/[handle]/[repo]/commit/[spec=rawCommit]/+server.ts new file mode 100644 index 00000000..0a57ea7a --- /dev/null +++ b/web/src/routes/[handle]/[repo]/commit/[spec=rawCommit]/+server.ts @@ -0,0 +1,37 @@ +import { error } from "@sveltejs/kit"; +import { toHttpError } from "$lib/api/load"; +import { diffFor } from "$lib/api/diff"; +import { renderFormatPatch, renderUnifiedDiff } from "$lib/api/rawdiff"; +import { parseRawCommit } from "$lib/api/repo"; +import { resolveRepoFromParams } from "$lib/server/repo"; +import type { RequestHandler } from "./$types"; + +export const GET: RequestHandler = async (event) => { + // the rawCommit matcher already guarantees this shape + const spec = parseRawCommit(event.params.spec); + if (!spec) error(404, "Not found"); +// the knot compares hashes case-sensitively, normalize like go-git does + const ref = spec.ref.toLowerCase(); + + const { ctx, view } = await resolveRepoFromParams(event); + + const result = await diffFor(ctx, view.uri, ref, { signal: event.request.signal }).catch( + (cause) => toHttpError(cause, "Could not load commit") + ); + + // an empty body is a valid empty diff (--allow-empty, some merges), only a + // missing diff means the commit is absent + if (result.diff === null || result.diff === undefined) { + error(404, `${ref} does not exist in this repository`); + } + + const body = + spec.format === "patch" ? renderFormatPatch(result.diff) : renderUnifiedDiff(result.diff); + + return new Response(body, { + headers: { + "content-type": "text/plain; charset=utf-8", + "content-disposition": `inline; filename="${ref.slice(0, 7)}.${spec.format}"` + } + }); +}; diff --git a/web/src/routes/[handle]/[repo]/commits/[ref]/+page.svelte b/web/src/routes/[handle]/[repo]/commits/[ref]/+page.svelte new file mode 100644 index 00000000..07506ec6 --- /dev/null +++ b/web/src/routes/[handle]/[repo]/commits/[ref]/+page.svelte @@ -0,0 +1,15 @@ + + + diff --git a/web/src/routes/[handle]/[repo]/commits/[ref]/+page.ts b/web/src/routes/[handle]/[repo]/commits/[ref]/+page.ts new file mode 100644 index 00000000..9290d867 --- /dev/null +++ b/web/src/routes/[handle]/[repo]/commits/[ref]/+page.ts @@ -0,0 +1,53 @@ +import { parallel } from "$lib/api/load"; +import { branches, gitTarget, log, tags } from "$lib/api/gitclient"; +import { REF_LIMIT } from "$lib/api/repoIndex"; +import { tagsByCommitHash, toBranchSummary, toCommitSummary, toTagSummary } from "$lib/api/repo"; +import type { PageLoad } from "./$types"; + +// same page size as the appview's log +const COMMIT_LIMIT = 60; + +export const load: PageLoad = async (event) => { + const parent = await event.parent(); + const git = gitTarget(parent.publicConfig, parent.repo, event.fetch); + const ref = event.params.ref; + + // Number accepts hex/exponents/whitespace that the appview's Atoi rejects, + // gate first + const rawPage = event.url.searchParams.get("page") ?? ""; + const parsed = /^\d+$/.test(rawPage) ? Number(rawPage) : 1; + const page = parsed >= 1 ? parsed : 1; + // the log cursor is a numeric offset + const cursor = page > 1 ? String((page - 1) * COMMIT_LIMIT) : undefined; + + const results = await parallel({ + log: log(git, { ref, limit: COMMIT_LIMIT, cursor }), + tags: tags(git, REF_LIMIT), + branches: branches(git, REF_LIMIT) + }); + + const commits = (results.log.commits ?? []).map(toCommitSummary); + const totalCommits = results.log.total ?? 0; + // knot2 answers an exact total when it can, without one a full page hints + // at another + const pageCount = + totalCommits > 0 + ? Math.ceil(totalCommits / COMMIT_LIMIT) + : page + (commits.length === COMMIT_LIMIT ? 1 : 0); + + const tagsByCommit = tagsByCommitHash(commits, (results.tags.tags ?? []).map(toTagSummary)); + // branch tips go into the same badge map as tags + const shown = new Set(commits.map((commit) => commit.hash)); + for (const branch of (results.branches.branches ?? []).map(toBranchSummary)) { + if (shown.has(branch.hash)) (tagsByCommit[branch.hash] ??= []).push(branch.name); + } + + return { + ref, + page, + pageCount, + commits, + totalCommits, + tagsByCommit + }; +}; -- 2.51.2