tenseleyflow/shithub / 50a1a7e

Browse files

Align compare and new PR flow

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
50a1a7e683c9fe0ee1ebcf90652069cbf28e9b63
Parents
f1e40ac
Tree
1b07b5c

13 changed files

StatusFile+-
M docs/internal/branch-protection.md 9 4
M docs/internal/pull-requests.md 16 2
M internal/web/handlers/repo/branches.go 46 44
A internal/web/handlers/repo/compare_ui.go 361 0
A internal/web/handlers/repo/compare_ui_test.go 65 0
M internal/web/handlers/repo/pulls.go 85 20
M internal/web/static/css/shithub.css 408 3
A internal/web/static/js/compare.js 60 0
A internal/web/templates/_compare_ref_menu.html 44 0
M internal/web/templates/_layout.html 1 0
M internal/web/templates/repo/compare.html 108 42
M internal/web/templates/repo/pull_new.html 202 41
M internal/web/templates/repo/pulls_list.html 1 1
docs/internal/branch-protection.mdmodified
@@ -10,6 +10,7 @@ hook.
1010
 | ------------------------------------------------------- | ----------------------------- |
1111
 | `GET /{owner}/{repo}/branches?filter=active|stale|`       | `branchesList`                |
1212
 | `GET /{owner}/{repo}/tags`                              | `tagsList`                    |
13
+| `GET /{owner}/{repo}/compare`                           | `compareView`                 |
1314
 | `GET /{owner}/{repo}/compare/{base}...{head}`           | `compareView`                 |
1415
 | `GET /{owner}/{repo}/settings/branches`                 | `settingsBranches` (auth-gated) |
1516
 | `POST /{owner}/{repo}/settings/branches`                | upsert rule                   |
@@ -50,10 +51,14 @@ first-class releases ship post-MVP.
5051
 ## Compare view
5152
 
5253
 Inputs: `base` and `head` (defaults to `repo.default_branch` when
53
-empty). Renders:
54
-
55
-- A summary line: ahead/behind counts plus a "Create pull request"
56
-  button when `head` has commits not on `base`.
54
+empty). The bare `/compare` route renders GitHub's branch/tag picker
55
+blank slate instead of redirecting to `default...default`. Renders:
56
+
57
+- Base/head dropdowns listing local branches and tags with live
58
+  filtering. Cross-repo `fork:branch` input is still normalized to a
59
+  local ref until fork PRs ship.
60
+- A mergeability/status line plus a "Create pull request" button when
61
+  `head` has commits not on `base`.
5762
 - The commits-list (head-side only) via
5863
   `repogit.CommitsBetween(base, head, 250)`.
5964
 - The three-dot diff via S19's renderer fed from
docs/internal/pull-requests.mdmodified
@@ -52,8 +52,12 @@ so a self-merge can't be opened. Cross-fork PRs land in S27.
5252
 | `POST /{owner}/{repo}/pulls/{number}/ready`           | RequireUser   |
5353
 | `POST /{owner}/{repo}/pulls/{number}/merge`           | RequireUser   |
5454
 
55
-The compare view (S20) links into `/pulls/new?base=...&head=...` so
56
-the entry point matches GitHub's flow.
55
+The pull-request list's "New pull request" button starts at
56
+`/{owner}/{repo}/compare`, where the user picks base/head refs. Once
57
+the head is ahead of base, compare links into
58
+`/pulls/new?base=...&head=...`. `/pulls/new` redirects back to
59
+compare when no head ref is supplied so the GitHub-style branch picker
60
+remains the canonical entry point.
5761
 
5862
 ## Auto-synchronize on head push
5963
 
@@ -148,6 +152,16 @@ noreply emails are post-MVP.
148152
 
149153
 ## Web UI
150154
 
155
+- Compare/new-PR entry follows GitHub's range editor: base and head
156
+  dropdowns list branches and tags with live filtering, preserve the
157
+  opposite side of the comparison, and render compare URLs with the
158
+  `base...head` shape.
159
+- The open-PR page reuses the compare state: ahead/behind counts,
160
+  mergeability probe, commits, and three-dot diff all render before
161
+  submission. The form posts the selected refs as hidden fields.
162
+- The new PR description uses the shared GitHub-like Markdown editor
163
+  (write/preview, toolbar, mentions/references/saved replies shell).
164
+  Copilot suggestions are intentionally omitted.
151165
 - Tabbed view at `/pulls/{number}` switches between Conversation,
152166
   Commits, Files, Checks via the `Tab` field on the template data.
153167
 - Conversation follows GitHub's PageHeader + tab strip shape: state
internal/web/handlers/repo/branches.gomodified
@@ -22,6 +22,7 @@ import (
2222
 func (h *Handlers) MountRefs(r chi.Router) {
2323
 	r.Get("/{owner}/{repo}/branches", h.branchesList)
2424
 	r.Get("/{owner}/{repo}/tags", h.tagsList)
25
+	r.Get("/{owner}/{repo}/compare", h.compareView)
2526
 	// Compare uses `...` as the base/head separator (matches GitHub).
2627
 	// chi can't represent the literal `...` in a route param so we use
2728
 	// a wildcard and parse server-side.
@@ -209,15 +210,16 @@ func (h *Handlers) compareView(w http.ResponseWriter, r *http.Request) {
209210
 		return
210211
 	}
211212
 	rest := strings.Trim(chi.URLParam(r, "*"), "/")
212
-	if rest == "" {
213
-		http.Redirect(w, r, "/"+owner.Username+"/"+row.Name+"/compare/"+row.DefaultBranch+"..."+row.DefaultBranch, http.StatusSeeOther)
214
-		return
215
-	}
216
-	base, head, ok := strings.Cut(rest, "...")
217
-	if !ok {
213
+	hasSelection := rest != ""
214
+	base := row.DefaultBranch
215
+	head := row.DefaultBranch
216
+	if hasSelection {
217
+		var found bool
218
+		base, head, found = strings.Cut(rest, "...")
219
+		if !found {
218220
 			// Two-dot shape — accept but treat as three-dot for the diff.
219
-		base, head, ok = strings.Cut(rest, "..")
220
-		if !ok {
221
+			base, head, found = strings.Cut(rest, "..")
222
+			if !found {
221223
 				head = rest
222224
 				base = row.DefaultBranch
223225
 			}
@@ -225,43 +227,43 @@ func (h *Handlers) compareView(w http.ResponseWriter, r *http.Request) {
225227
 		if base == "" {
226228
 			base = row.DefaultBranch
227229
 		}
230
+		if head == "" {
231
+			head = row.DefaultBranch
232
+		}
233
+	}
228234
 
229235
 	// Strip cross-repo "fork:branch" prefix for the local path; full
230236
 	// cross-repo lookup lands in S22+S27.
231237
 	base = stripCrossRepoPrefix(base)
232238
 	head = stripCrossRepoPrefix(head)
233239
 
234
-	commits, cerr := repogit.CommitsBetween(r.Context(), gitDir, base, head, 250)
235
-	ahead, behind, abErr := repogit.AheadBehind(r.Context(), gitDir, base, head)
236
-
237
-	notFound := abErr != nil
238
-
239
-	// Build an inline diff (three-dot via FromMergeBase).
240
-	var diffHTML string
241
-	if !notFound {
242
-		patch, perr := compareSourceMergeBase(r, gitDir, base, head)
243
-		if perr == nil {
244
-			diffHTML = renderCompareDiff(patch)
245
-		}
246
-	}
247
-
248
-	refs, _ := repogit.ListRefs(r.Context(), gitDir)
249
-	h.d.Render.RenderPage(w, r, "repo/compare", map[string]any{
240
+	state := h.buildCompareState(r, owner.Username, row, gitDir, base, head, hasSelection, compareMenuTargetCompare)
241
+	h.d.Render.RenderPage(w, r, "repo/compare", mergePageData(
242
+		h.repoPageChrome(r, owner.Username, row, "code"),
243
+		map[string]any{
250244
 			"Title":        "Compare · " + row.Name,
251
-		"CSRFToken":  middleware.CSRFTokenForRequest(r),
252
-		"Owner":      owner.Username,
253
-		"Repo":       row,
254
-		"Base":       base,
255
-		"Head":       head,
256
-		"Ahead":      ahead,
257
-		"Behind":     behind,
258
-		"Commits":    commits,
259
-		"DiffHTML":   diffHTML,
260
-		"NotFound":   notFound,
261
-		"CommitsErr": cerr != nil,
262
-		"Branches":   refs.Branches,
263
-		"Tags":       refs.Tags,
264
-	})
245
+			"UseCompareJS": true,
246
+			"Compare":      state,
247
+			"Base":         state.Base,
248
+			"Head":         state.Head,
249
+			"HasSelection": state.HasSelection,
250
+			"SameRef":      state.SameRef,
251
+			"NotFound":     state.NotFound,
252
+			"CommitsErr":   state.CommitsErr,
253
+			"NoCommits":    state.NoCommits,
254
+			"Ahead":        state.Ahead,
255
+			"Behind":       state.Behind,
256
+			"Commits":      state.Commits,
257
+			"DiffHTML":     state.DiffHTML,
258
+			"Stats":        state.Stats,
259
+			"MergeState":   state.MergeState,
260
+			"CanOpenPull":  state.CanOpenPull,
261
+			"PullNewHref":  state.PullNewHref,
262
+			"BaseMenu":     state.BaseMenu,
263
+			"HeadMenu":     state.HeadMenu,
264
+			"Examples":     state.Examples,
265
+		},
266
+	))
265267
 }
266268
 
267269
 // stripCrossRepoPrefix turns "fork:branch" into "branch". Local-only
internal/web/handlers/repo/compare_ui.goadded
@@ -0,0 +1,361 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package repo
4
+
5
+import (
6
+	"context"
7
+	"errors"
8
+	"net/http"
9
+	"net/url"
10
+	"strings"
11
+
12
+	repogit "github.com/tenseleyFlow/shithub/internal/repos/git"
13
+	reposdb "github.com/tenseleyFlow/shithub/internal/repos/sqlc"
14
+	"github.com/tenseleyFlow/shithub/internal/web/middleware"
15
+)
16
+
17
+type compareMenuTarget string
18
+
19
+const (
20
+	compareMenuTargetCompare compareMenuTarget = "compare"
21
+	compareMenuTargetPullNew compareMenuTarget = "pull_new"
22
+)
23
+
24
+type compareRefOption struct {
25
+	Name      string
26
+	Href      string
27
+	Current   bool
28
+	IsDefault bool
29
+}
30
+
31
+type compareRefMenu struct {
32
+	ID      string
33
+	Label   string
34
+	Title   string
35
+	Current string
36
+
37
+	Branches []compareRefOption
38
+	Tags     []compareRefOption
39
+}
40
+
41
+type compareExample struct {
42
+	Name string
43
+	Href string
44
+}
45
+
46
+type compareStats struct {
47
+	CommitCount      int
48
+	FileCount        int
49
+	ContributorCount int
50
+}
51
+
52
+type compareMergeState struct {
53
+	State       string
54
+	Label       string
55
+	Description string
56
+}
57
+
58
+type compareState struct {
59
+	Base         string
60
+	Head         string
61
+	HasSelection bool
62
+	SameRef      bool
63
+	NotFound     bool
64
+	CommitsErr   bool
65
+	NoCommits    bool
66
+	Ahead        int
67
+	Behind       int
68
+
69
+	Commits     []repogit.Commit
70
+	DiffHTML    string
71
+	Stats       compareStats
72
+	MergeState  compareMergeState
73
+	CanOpenPull bool
74
+	PullNewHref string
75
+
76
+	BaseMenu compareRefMenu
77
+	HeadMenu compareRefMenu
78
+	Examples []compareExample
79
+}
80
+
81
+func (h *Handlers) repoPageChrome(r *http.Request, owner string, row reposdb.Repo, activeSubnav string) map[string]any {
82
+	return map[string]any{
83
+		"CSRFToken":    middleware.CSRFTokenForRequest(r),
84
+		"Owner":        owner,
85
+		"Repo":         row,
86
+		"RepoActions":  h.repoActions(r, row.ID),
87
+		"RepoCounts":   h.subnavCounts(r.Context(), row.ID, row.ForkCount),
88
+		"CanSettings":  h.canViewSettings(middleware.CurrentUserFromContext(r.Context())),
89
+		"ActiveSubnav": activeSubnav,
90
+	}
91
+}
92
+
93
+func mergePageData(base map[string]any, extra map[string]any) map[string]any {
94
+	out := make(map[string]any, len(base)+len(extra))
95
+	for k, v := range base {
96
+		out[k] = v
97
+	}
98
+	for k, v := range extra {
99
+		out[k] = v
100
+	}
101
+	return out
102
+}
103
+
104
+func (h *Handlers) buildCompareState(r *http.Request, owner string, row reposdb.Repo, gitDir, base, head string, hasSelection bool, target compareMenuTarget) compareState {
105
+	if strings.TrimSpace(base) == "" {
106
+		base = row.DefaultBranch
107
+	}
108
+	if strings.TrimSpace(head) == "" {
109
+		head = row.DefaultBranch
110
+	}
111
+
112
+	refs, _ := repogit.ListRefs(r.Context(), gitDir)
113
+	state := compareState{
114
+		Base:         base,
115
+		Head:         head,
116
+		HasSelection: hasSelection,
117
+		SameRef:      base == head,
118
+		PullNewHref:  pullNewURL(owner, row.Name, base, head),
119
+		MergeState: compareMergeState{
120
+			State:       "pending",
121
+			Label:       "Checking mergeability...",
122
+			Description: "You can still create the pull request while shithub checks these branches.",
123
+		},
124
+	}
125
+	state.BaseMenu, state.HeadMenu = buildCompareMenus(owner, row.Name, row.DefaultBranch, base, head, refs, target)
126
+	state.Examples = buildCompareExamples(owner, row.Name, row.DefaultBranch, refs)
127
+
128
+	if !hasSelection || base == "" || head == "" {
129
+		state.MergeState = compareMergeState{}
130
+		return state
131
+	}
132
+
133
+	commits, cerr := repogit.CommitsBetween(r.Context(), gitDir, base, head, 250)
134
+	if cerr != nil {
135
+		state.CommitsErr = true
136
+	}
137
+	state.Commits = commits
138
+
139
+	ahead, behind, abErr := repogit.AheadBehind(r.Context(), gitDir, base, head)
140
+	if abErr != nil {
141
+		state.NotFound = true
142
+		state.MergeState = compareMergeState{
143
+			State:       "missing",
144
+			Label:       "There was a problem comparing these refs.",
145
+			Description: "One or both refs were not found in this repository.",
146
+		}
147
+		return state
148
+	}
149
+	state.Ahead = ahead
150
+	state.Behind = behind
151
+	state.NoCommits = ahead <= 0
152
+	state.Stats.CommitCount = len(commits)
153
+	state.Stats.ContributorCount = countCommitContributors(commits)
154
+
155
+	if state.SameRef {
156
+		state.MergeState = compareMergeState{}
157
+		return state
158
+	}
159
+	if state.NoCommits {
160
+		state.MergeState = compareMergeState{
161
+			State:       "empty",
162
+			Label:       "There isn't anything to compare.",
163
+			Description: head + " is up to date with " + base + ".",
164
+		}
165
+		return state
166
+	}
167
+
168
+	patch, perr := compareSourceMergeBase(r, gitDir, base, head)
169
+	if perr == nil {
170
+		state.DiffHTML = renderCompareDiff(patch)
171
+		state.Stats.FileCount = countPatchFiles(patch)
172
+	}
173
+	state.CanOpenPull = true
174
+	state.MergeState = probeCompareMerge(r.Context(), gitDir, base, head)
175
+	return state
176
+}
177
+
178
+func buildCompareMenus(owner, repo, defaultBranch, base, head string, refs repogit.RefListing, target compareMenuTarget) (compareRefMenu, compareRefMenu) {
179
+	baseMenu := compareRefMenu{
180
+		ID:      "base",
181
+		Label:   "base:",
182
+		Title:   "Choose a base ref",
183
+		Current: base,
184
+	}
185
+	headMenu := compareRefMenu{
186
+		ID:      "head",
187
+		Label:   "compare:",
188
+		Title:   "Choose a head ref",
189
+		Current: head,
190
+	}
191
+
192
+	baseMenu.Branches = compareRefOptions(owner, repo, defaultBranch, base, head, base, refs.Branches, target, true)
193
+	headMenu.Branches = compareRefOptions(owner, repo, defaultBranch, base, head, head, refs.Branches, target, false)
194
+	baseMenu.Tags = compareRefOptions(owner, repo, defaultBranch, base, head, base, refs.Tags, target, true)
195
+	headMenu.Tags = compareRefOptions(owner, repo, defaultBranch, base, head, head, refs.Tags, target, false)
196
+
197
+	baseMenu.Branches = ensureCompareRefOption(baseMenu.Branches, owner, repo, defaultBranch, base, head, base, target, true)
198
+	headMenu.Branches = ensureCompareRefOption(headMenu.Branches, owner, repo, defaultBranch, base, head, head, target, false)
199
+	return baseMenu, headMenu
200
+}
201
+
202
+func compareRefOptions(owner, repo, defaultBranch, base, head, current string, refs []repogit.RefEntry, target compareMenuTarget, changingBase bool) []compareRefOption {
203
+	options := make([]compareRefOption, 0, len(refs))
204
+	for _, ref := range refs {
205
+		options = append(options, compareRefOption{
206
+			Name:      ref.Name,
207
+			Href:      compareRefHref(owner, repo, base, head, ref.Name, target, changingBase),
208
+			Current:   ref.Name == current,
209
+			IsDefault: ref.Name == defaultBranch,
210
+		})
211
+	}
212
+	return options
213
+}
214
+
215
+func ensureCompareRefOption(options []compareRefOption, owner, repo, defaultBranch, base, head, current string, target compareMenuTarget, changingBase bool) []compareRefOption {
216
+	if current == "" {
217
+		return options
218
+	}
219
+	for _, option := range options {
220
+		if option.Name == current {
221
+			return options
222
+		}
223
+	}
224
+	return append([]compareRefOption{{
225
+		Name:      current,
226
+		Href:      compareRefHref(owner, repo, base, head, current, target, changingBase),
227
+		Current:   true,
228
+		IsDefault: current == defaultBranch,
229
+	}}, options...)
230
+}
231
+
232
+func compareRefHref(owner, repo, base, head, ref string, target compareMenuTarget, changingBase bool) string {
233
+	if changingBase {
234
+		base = ref
235
+	} else {
236
+		head = ref
237
+	}
238
+	if target == compareMenuTargetPullNew {
239
+		return pullNewURL(owner, repo, base, head)
240
+	}
241
+	return compareURL(owner, repo, base, head)
242
+}
243
+
244
+func buildCompareExamples(owner, repo, defaultBranch string, refs repogit.RefListing) []compareExample {
245
+	examples := make([]compareExample, 0, 5)
246
+	for _, branch := range refs.Branches {
247
+		if branch.Name == defaultBranch {
248
+			continue
249
+		}
250
+		examples = append(examples, compareExample{
251
+			Name: branch.Name,
252
+			Href: compareURL(owner, repo, defaultBranch, branch.Name),
253
+		})
254
+		if len(examples) == 5 {
255
+			break
256
+		}
257
+	}
258
+	return examples
259
+}
260
+
261
+func compareURL(owner, repo, base, head string) string {
262
+	return "/" + url.PathEscape(owner) + "/" + url.PathEscape(repo) + "/compare/" + escapePathSegments(base) + "..." + escapePathSegments(head)
263
+}
264
+
265
+func pullNewURL(owner, repo, base, head string) string {
266
+	q := url.Values{}
267
+	q.Set("base", base)
268
+	q.Set("head", head)
269
+	return "/" + url.PathEscape(owner) + "/" + url.PathEscape(repo) + "/pulls/new?" + q.Encode()
270
+}
271
+
272
+func countPatchFiles(patch []byte) int {
273
+	if len(patch) == 0 {
274
+		return 0
275
+	}
276
+	count := 0
277
+	for _, line := range strings.Split(string(patch), "\n") {
278
+		if strings.HasPrefix(line, "diff --git ") {
279
+			count++
280
+		}
281
+	}
282
+	return count
283
+}
284
+
285
+func countCommitContributors(commits []repogit.Commit) int {
286
+	if len(commits) == 0 {
287
+		return 0
288
+	}
289
+	seen := map[string]struct{}{}
290
+	for _, commit := range commits {
291
+		key := strings.ToLower(strings.TrimSpace(commit.AuthorEmail))
292
+		if key == "" {
293
+			key = strings.ToLower(strings.TrimSpace(commit.AuthorName))
294
+		}
295
+		if key != "" {
296
+			seen[key] = struct{}{}
297
+		}
298
+	}
299
+	return len(seen)
300
+}
301
+
302
+func defaultPullTitle(head string, commits []repogit.Commit) string {
303
+	if len(commits) == 1 && strings.TrimSpace(commits[0].Subject) != "" {
304
+		return commits[0].Subject
305
+	}
306
+	if strings.TrimSpace(head) == "" {
307
+		return ""
308
+	}
309
+	return head
310
+}
311
+
312
+func probeCompareMerge(ctx context.Context, gitDir, base, head string) compareMergeState {
313
+	baseOID, berr := repogit.ResolveRefOID(ctx, gitDir, base)
314
+	headOID, herr := repogit.ResolveRefOID(ctx, gitDir, head)
315
+	if berr != nil || herr != nil {
316
+		return compareMergeState{
317
+			State:       "missing",
318
+			Label:       "Unable to check mergeability.",
319
+			Description: "One or both refs could not be resolved.",
320
+		}
321
+	}
322
+	result, err := repogit.ProbeMerge(ctx, gitDir, baseOID, headOID)
323
+	if err != nil {
324
+		if errors.Is(err, repogit.ErrRefNotFound) {
325
+			return compareMergeState{
326
+				State:       "missing",
327
+				Label:       "Unable to check mergeability.",
328
+				Description: "One or both refs could not be resolved.",
329
+			}
330
+		}
331
+		return compareMergeState{
332
+			State:       "unknown",
333
+			Label:       "Mergeability could not be checked.",
334
+			Description: "You can still create the pull request and shithub will retry the check.",
335
+		}
336
+	}
337
+	if result.HasConflict {
338
+		return compareMergeState{
339
+			State:       "conflict",
340
+			Label:       "Cannot automatically merge.",
341
+			Description: "These branches have conflicts that must be resolved.",
342
+		}
343
+	}
344
+	return compareMergeState{
345
+		State:       "clean",
346
+		Label:       "Able to merge.",
347
+		Description: "These branches can be automatically merged.",
348
+	}
349
+}
350
+
351
+func pullNewCommentEditorConfig(viewer middleware.CurrentUser) commentEditorConfig {
352
+	if viewer.IsAnonymous() || strings.EqualFold(viewer.Username, "copilot") {
353
+		return commentEditorConfig{}
354
+	}
355
+	return commentEditorConfig{
356
+		Mentions: []commentEditorMention{{
357
+			Username:  viewer.Username,
358
+			AvatarURL: commentEditorAvatarURL(viewer.Username),
359
+		}},
360
+	}
361
+}
internal/web/handlers/repo/compare_ui_test.goadded
@@ -0,0 +1,65 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package repo
4
+
5
+import (
6
+	"testing"
7
+
8
+	repogit "github.com/tenseleyFlow/shithub/internal/repos/git"
9
+)
10
+
11
+func TestCompareURLsEscapeBranchSegments(t *testing.T) {
12
+	got := compareURL("tenseleyFlow", "shithub", "trunk", "feature/a b")
13
+	want := "/tenseleyFlow/shithub/compare/trunk...feature/a%20b"
14
+	if got != want {
15
+		t.Fatalf("compareURL() = %q, want %q", got, want)
16
+	}
17
+
18
+	got = pullNewURL("tenseleyFlow", "shithub", "trunk", "feature/a b")
19
+	want = "/tenseleyFlow/shithub/pulls/new?base=trunk&head=feature%2Fa+b"
20
+	if got != want {
21
+		t.Fatalf("pullNewURL() = %q, want %q", got, want)
22
+	}
23
+}
24
+
25
+func TestBuildCompareMenusPreservesOtherSide(t *testing.T) {
26
+	refs := repogit.RefListing{
27
+		Branches: []repogit.RefEntry{
28
+			{Name: "trunk"},
29
+			{Name: "scratch"},
30
+		},
31
+		Tags: []repogit.RefEntry{{Name: "v1.0.0"}},
32
+	}
33
+
34
+	baseMenu, headMenu := buildCompareMenus("octo", "demo", "trunk", "trunk", "scratch", refs, compareMenuTargetCompare)
35
+	if baseMenu.Branches[1].Href != "/octo/demo/compare/scratch...scratch" {
36
+		t.Fatalf("base branch href = %q", baseMenu.Branches[1].Href)
37
+	}
38
+	if headMenu.Branches[0].Href != "/octo/demo/compare/trunk...trunk" {
39
+		t.Fatalf("head branch href = %q", headMenu.Branches[0].Href)
40
+	}
41
+	if !baseMenu.Branches[0].IsDefault {
42
+		t.Fatalf("default branch not marked")
43
+	}
44
+
45
+	_, pullHeadMenu := buildCompareMenus("octo", "demo", "trunk", "trunk", "scratch", refs, compareMenuTargetPullNew)
46
+	if pullHeadMenu.Branches[1].Href != "/octo/demo/pulls/new?base=trunk&head=scratch" {
47
+		t.Fatalf("pull new head href = %q", pullHeadMenu.Branches[1].Href)
48
+	}
49
+}
50
+
51
+func TestCountPatchFiles(t *testing.T) {
52
+	patch := []byte(`diff --git a/one.txt b/one.txt
53
+index 1111111..2222222 100644
54
+--- a/one.txt
55
++++ b/one.txt
56
+@@ -1 +1 @@
57
+-old
58
++new
59
+diff --git a/two.txt b/two.txt
60
+new file mode 100644
61
+`)
62
+	if got := countPatchFiles(patch); got != 2 {
63
+		t.Fatalf("countPatchFiles() = %d, want 2", got)
64
+	}
65
+}
internal/web/handlers/repo/pulls.gomodified
@@ -190,17 +190,87 @@ func (h *Handlers) pullNewForm(w http.ResponseWriter, r *http.Request) {
190190
 		base = row.DefaultBranch
191191
 	}
192192
 	head := r.URL.Query().Get("head")
193
-	w.Header().Set("Content-Type", "text/html; charset=utf-8")
194
-	_ = h.d.Render.RenderPage(w, r, "repo/pull_new", map[string]any{
195
-		"Title":     "New pull request · " + row.Name,
196
-		"Owner":     owner.Username,
197
-		"Repo":      row,
198
-		"Base":      base,
199
-		"Head":      head,
200
-		"CSRFToken": middleware.CSRFTokenForRequest(r),
193
+	if strings.TrimSpace(head) == "" {
194
+		http.Redirect(w, r, "/"+owner.Username+"/"+row.Name+"/compare", http.StatusSeeOther)
195
+		return
196
+	}
197
+	h.renderPullNewForm(w, r, owner.Username, row, pullNewFormOptions{
198
+		Base: base,
199
+		Head: head,
201200
 	})
202201
 }
203202
 
203
+type pullNewFormOptions struct {
204
+	Base      string
205
+	Head      string
206
+	FormTitle string
207
+	FormBody  string
208
+	Error     string
209
+	Status    int
210
+}
211
+
212
+func (h *Handlers) renderPullNewForm(w http.ResponseWriter, r *http.Request, owner string, row reposdb.Repo, opts pullNewFormOptions) {
213
+	gitDir, err := h.d.RepoFS.RepoPath(owner, row.Name)
214
+	if err != nil {
215
+		h.d.Render.HTTPError(w, r, http.StatusNotFound, "")
216
+		return
217
+	}
218
+	base := strings.TrimSpace(opts.Base)
219
+	if base == "" {
220
+		base = row.DefaultBranch
221
+	}
222
+	head := strings.TrimSpace(opts.Head)
223
+	if head == "" {
224
+		head = row.DefaultBranch
225
+	}
226
+	state := h.buildCompareState(r, owner, row, gitDir, base, head, true, compareMenuTargetPullNew)
227
+	formTitle := opts.FormTitle
228
+	if strings.TrimSpace(formTitle) == "" && opts.Error == "" {
229
+		formTitle = defaultPullTitle(state.Head, state.Commits)
230
+	}
231
+	viewer := middleware.CurrentUserFromContext(r.Context())
232
+	status := opts.Status
233
+	if status == 0 {
234
+		status = http.StatusOK
235
+	}
236
+	w.Header().Set("Content-Type", "text/html; charset=utf-8")
237
+	if status != http.StatusOK {
238
+		w.WriteHeader(status)
239
+	}
240
+	_ = h.d.Render.RenderPage(w, r, "repo/pull_new", mergePageData(
241
+		h.repoPageChrome(r, owner, row, "pulls"),
242
+		map[string]any{
243
+			"Title":               "Open a pull request · " + row.Name,
244
+			"UseCompareJS":        true,
245
+			"UseCommentEditor":    true,
246
+			"CommentEditorConfig": commentEditorConfigJSON(pullNewCommentEditorConfig(viewer)),
247
+			"Viewer":              viewer,
248
+			"ViewerAvatarURL":     commentEditorAvatarURL(viewer.Username),
249
+			"Error":               opts.Error,
250
+			"FormTitle":           formTitle,
251
+			"FormBody":            opts.FormBody,
252
+			"Base":                state.Base,
253
+			"Head":                state.Head,
254
+			"HasSelection":        state.HasSelection,
255
+			"SameRef":             state.SameRef,
256
+			"NotFound":            state.NotFound,
257
+			"CommitsErr":          state.CommitsErr,
258
+			"NoCommits":           state.NoCommits,
259
+			"Ahead":               state.Ahead,
260
+			"Behind":              state.Behind,
261
+			"Commits":             state.Commits,
262
+			"DiffHTML":            state.DiffHTML,
263
+			"Stats":               state.Stats,
264
+			"MergeState":          state.MergeState,
265
+			"CanOpenPull":         state.CanOpenPull,
266
+			"CanCreatePull":       state.CanOpenPull && !state.NotFound && !state.CommitsErr,
267
+			"PullNewHref":         state.PullNewHref,
268
+			"BaseMenu":            state.BaseMenu,
269
+			"HeadMenu":            state.HeadMenu,
270
+		},
271
+	))
272
+}
273
+
204274
 // pullCreate handles POST /{owner}/{repo}/pulls.
205275
 func (h *Handlers) pullCreate(w http.ResponseWriter, r *http.Request) {
206276
 	row, owner, ok := h.loadRepoAndAuthorize(w, r, policy.ActionPullCreate)
@@ -266,18 +336,13 @@ func (h *Handlers) handlePullCreateError(w http.ResponseWriter, r *http.Request,
266336
 	case errors.Is(err, issues.ErrBodyTooLong):
267337
 		msg = "Body is too long."
268338
 	}
269
-	w.Header().Set("Content-Type", "text/html; charset=utf-8")
270
-	w.WriteHeader(http.StatusBadRequest)
271
-	_ = h.d.Render.RenderPage(w, r, "repo/pull_new", map[string]any{
272
-		"Title":     "New pull request · " + row.Name,
273
-		"Owner":     owner,
274
-		"Repo":      row,
275
-		"Base":      r.PostFormValue("base"),
276
-		"Head":      r.PostFormValue("head"),
277
-		"FormTitle": r.PostFormValue("title"),
278
-		"FormBody":  r.PostFormValue("body"),
279
-		"Error":     msg,
280
-		"CSRFToken": middleware.CSRFTokenForRequest(r),
339
+	h.renderPullNewForm(w, r, owner, row, pullNewFormOptions{
340
+		Base:      r.PostFormValue("base"),
341
+		Head:      r.PostFormValue("head"),
342
+		FormTitle: r.PostFormValue("title"),
343
+		FormBody:  r.PostFormValue("body"),
344
+		Error:     msg,
345
+		Status:    http.StatusBadRequest,
281346
 	})
282347
 }
283348
 
internal/web/static/css/shithub.cssmodified
@@ -7877,9 +7877,414 @@ button.shithub-repo-action {
78777877
   border-bottom: 1px solid var(--border-default);
78787878
 }
78797879
 .shithub-branches-subject { color: var(--fg-default); }
7880
-.shithub-compare-summary { padding: 0.75rem 1rem; background: var(--canvas-subtle); border-radius: 6px; display: flex; gap: 1rem; align-items: center; flex-wrap: wrap; }
7881
-.shithub-compare-empty { padding: 1.5rem; text-align: center; color: var(--fg-muted); border: 1px dashed var(--border-default); border-radius: 6px; }
7882
-.shithub-compare-commits { margin-top: 1.5rem; }
7880
+.shithub-compare-flow {
7881
+  max-width: 62.5rem;
7882
+  margin: 1.5rem auto 3rem;
7883
+  padding: 0 1rem;
7884
+}
7885
+.shithub-compare-subhead {
7886
+  padding-bottom: 0.75rem;
7887
+  margin-bottom: 0.75rem;
7888
+  border-bottom: 1px solid var(--border-default);
7889
+}
7890
+.shithub-compare-subhead h1 {
7891
+  margin: 0 0 0.25rem;
7892
+  font-size: 1.5rem;
7893
+}
7894
+.shithub-compare-subhead p {
7895
+  margin: 0;
7896
+  color: var(--fg-muted);
7897
+}
7898
+.shithub-range-editor {
7899
+  display: flex;
7900
+  align-items: center;
7901
+  gap: 0.5rem;
7902
+  flex-wrap: wrap;
7903
+  padding: 0.75rem 0;
7904
+}
7905
+.shithub-range-separator {
7906
+  color: var(--fg-muted);
7907
+  font-weight: 600;
7908
+}
7909
+.shithub-range-create {
7910
+  margin-left: auto;
7911
+}
7912
+.shithub-compare-ref-menu {
7913
+  position: relative;
7914
+}
7915
+.shithub-compare-ref-summary {
7916
+  display: inline-flex;
7917
+  align-items: center;
7918
+  gap: 0.35rem;
7919
+  list-style: none;
7920
+}
7921
+.shithub-compare-ref-summary::-webkit-details-marker {
7922
+  display: none;
7923
+}
7924
+.shithub-compare-ref-label {
7925
+  color: var(--fg-muted);
7926
+}
7927
+.shithub-compare-ref-current {
7928
+  color: var(--fg-default);
7929
+  font-weight: 600;
7930
+}
7931
+.shithub-compare-ref-panel {
7932
+  position: absolute;
7933
+  z-index: 70;
7934
+  top: calc(100% + 0.25rem);
7935
+  left: 0;
7936
+  width: min(20rem, calc(100vw - 2rem));
7937
+  overflow: hidden;
7938
+  background: var(--canvas-overlay, var(--canvas-default));
7939
+  border: 1px solid var(--border-default);
7940
+  border-radius: 8px;
7941
+  box-shadow: var(--shadow-large, 0 16px 32px rgba(1, 4, 9, 0.35));
7942
+}
7943
+.shithub-compare-ref-panel-head {
7944
+  display: flex;
7945
+  align-items: center;
7946
+  justify-content: space-between;
7947
+  gap: 0.75rem;
7948
+  padding: 0.75rem;
7949
+  border-bottom: 1px solid var(--border-default);
7950
+}
7951
+.shithub-compare-ref-filter {
7952
+  display: flex;
7953
+  align-items: center;
7954
+  gap: 0.4rem;
7955
+  margin: 0.5rem;
7956
+  padding: 0 0.5rem;
7957
+  border: 1px solid var(--border-default);
7958
+  border-radius: 6px;
7959
+  color: var(--fg-muted);
7960
+}
7961
+.shithub-compare-ref-filter input {
7962
+  width: 100%;
7963
+  min-width: 0;
7964
+  padding: 0.45rem 0;
7965
+  color: var(--fg-default);
7966
+  background: transparent;
7967
+  border: 0;
7968
+  outline: 0;
7969
+  font: inherit;
7970
+}
7971
+.shithub-compare-ref-tabs {
7972
+  display: flex;
7973
+  border-top: 1px solid var(--border-default);
7974
+  border-bottom: 1px solid var(--border-default);
7975
+}
7976
+.shithub-compare-ref-tabs button {
7977
+  flex: 1 1 0;
7978
+  padding: 0.55rem 0.75rem;
7979
+  color: var(--fg-muted);
7980
+  background: transparent;
7981
+  border: 0;
7982
+  border-bottom: 2px solid transparent;
7983
+  font: inherit;
7984
+  font-weight: 600;
7985
+  cursor: pointer;
7986
+}
7987
+.shithub-compare-ref-tabs button.is-active {
7988
+  color: var(--fg-default);
7989
+  border-bottom-color: var(--accent-emphasis);
7990
+}
7991
+.shithub-compare-ref-list {
7992
+  max-height: 19rem;
7993
+  overflow: auto;
7994
+}
7995
+.shithub-compare-ref-option {
7996
+  display: grid;
7997
+  grid-template-columns: 1.25rem minmax(0, 1fr) auto;
7998
+  align-items: center;
7999
+  gap: 0.4rem;
8000
+  padding: 0.55rem 0.75rem;
8001
+  color: var(--fg-default);
8002
+  border-bottom: 1px solid var(--border-default);
8003
+}
8004
+.shithub-compare-ref-option:hover,
8005
+.shithub-compare-ref-option.is-current {
8006
+  color: #ffffff;
8007
+  text-decoration: none;
8008
+  background: var(--accent-emphasis);
8009
+}
8010
+.shithub-compare-ref-check {
8011
+  display: inline-flex;
8012
+  color: currentColor;
8013
+}
8014
+.shithub-compare-ref-option-name {
8015
+  overflow: hidden;
8016
+  text-overflow: ellipsis;
8017
+  white-space: nowrap;
8018
+}
8019
+.shithub-compare-ref-default {
8020
+  padding: 0.05rem 0.35rem;
8021
+  color: var(--fg-default);
8022
+  border: 1px solid var(--border-default);
8023
+  border-radius: 999px;
8024
+  font-size: 0.75rem;
8025
+  font-weight: 600;
8026
+}
8027
+.shithub-compare-ref-option:hover .shithub-compare-ref-default,
8028
+.shithub-compare-ref-option.is-current .shithub-compare-ref-default {
8029
+  color: #ffffff;
8030
+  border-color: rgba(255, 255, 255, 0.65);
8031
+}
8032
+.shithub-compare-ref-empty {
8033
+  padding: 1rem;
8034
+  color: var(--fg-muted);
8035
+  text-align: center;
8036
+}
8037
+.shithub-compare-flash {
8038
+  display: flex;
8039
+  align-items: flex-start;
8040
+  gap: 0.75rem;
8041
+  padding: 0.75rem 1rem;
8042
+  margin: 0.5rem 0 1rem;
8043
+  border: 1px solid var(--border-default);
8044
+  border-radius: 6px;
8045
+  background: var(--canvas-subtle);
8046
+}
8047
+.shithub-compare-flash p {
8048
+  margin: 0.2rem 0 0;
8049
+  color: var(--fg-muted);
8050
+}
8051
+.shithub-compare-flash-warning {
8052
+  border-color: rgba(187, 128, 9, 0.45);
8053
+  background: rgba(187, 128, 9, 0.12);
8054
+}
8055
+.shithub-compare-flash-danger,
8056
+.shithub-compare-flash-conflict {
8057
+  border-color: rgba(248, 81, 73, 0.45);
8058
+  background: rgba(248, 81, 73, 0.10);
8059
+}
8060
+.shithub-compare-flash-clean,
8061
+.shithub-range-merge-clean {
8062
+  color: var(--success-fg);
8063
+}
8064
+.shithub-compare-blankslate {
8065
+  display: grid;
8066
+  justify-items: center;
8067
+  gap: 0.35rem;
8068
+  padding: 2.5rem 1rem;
8069
+  color: var(--fg-muted);
8070
+  text-align: center;
8071
+}
8072
+.shithub-compare-blankslate h2 {
8073
+  margin: 0;
8074
+  color: var(--fg-default);
8075
+  font-size: 1.25rem;
8076
+}
8077
+.shithub-compare-examples {
8078
+  width: min(28rem, 100%);
8079
+  margin-top: 0.75rem;
8080
+  overflow: hidden;
8081
+  text-align: left;
8082
+  border: 1px solid var(--border-default);
8083
+  border-radius: 6px;
8084
+}
8085
+.shithub-compare-examples-head,
8086
+.shithub-compare-examples a {
8087
+  display: flex;
8088
+  align-items: center;
8089
+  justify-content: space-between;
8090
+  gap: 0.75rem;
8091
+  padding: 0.55rem 0.75rem;
8092
+  border-bottom: 1px solid var(--border-default);
8093
+}
8094
+.shithub-compare-examples a:last-child {
8095
+  border-bottom: 0;
8096
+}
8097
+.shithub-compare-examples-head {
8098
+  color: var(--fg-muted);
8099
+  background: var(--canvas-subtle);
8100
+  font-weight: 600;
8101
+}
8102
+.shithub-compare-stats {
8103
+  display: grid;
8104
+  grid-template-columns: repeat(3, 1fr);
8105
+  margin: 1rem 0;
8106
+  border: 1px solid var(--border-default);
8107
+  border-radius: 6px;
8108
+  overflow: hidden;
8109
+}
8110
+.shithub-compare-stats span {
8111
+  display: flex;
8112
+  align-items: center;
8113
+  justify-content: center;
8114
+  gap: 0.4rem;
8115
+  padding: 0.75rem;
8116
+  color: var(--fg-muted);
8117
+  border-right: 1px solid var(--border-default);
8118
+}
8119
+.shithub-compare-stats span:last-child {
8120
+  border-right: 0;
8121
+}
8122
+.shithub-compare-commits {
8123
+  margin-top: 1.5rem;
8124
+}
8125
+.shithub-compare-commits h2 {
8126
+  margin: 0 0 0.5rem;
8127
+  font-size: 1rem;
8128
+}
8129
+.shithub-range-merge-state {
8130
+  display: inline-flex;
8131
+  align-items: center;
8132
+  gap: 0.25rem;
8133
+  font-weight: 600;
8134
+}
8135
+.shithub-range-merge-conflict,
8136
+.shithub-range-merge-missing {
8137
+  color: var(--danger-fg, #f85149);
8138
+}
8139
+.shithub-pull-open-flow {
8140
+  max-width: 72rem;
8141
+}
8142
+.shithub-pull-new-layout {
8143
+  display: grid;
8144
+  grid-template-columns: minmax(0, 1fr) 17rem;
8145
+  gap: 1.5rem;
8146
+  align-items: start;
8147
+  margin-top: 0.75rem;
8148
+}
8149
+.shithub-pull-new-form {
8150
+  min-width: 0;
8151
+}
8152
+.shithub-pull-new-title-row {
8153
+  display: grid;
8154
+  grid-template-columns: 2.5rem minmax(0, 1fr);
8155
+  gap: 0.75rem;
8156
+  align-items: start;
8157
+}
8158
+.shithub-pull-new-title,
8159
+.shithub-pull-new-description-label {
8160
+  display: grid;
8161
+  gap: 0.35rem;
8162
+  font-weight: 600;
8163
+}
8164
+.shithub-pull-new-title input {
8165
+  width: 100%;
8166
+  padding: 0.5rem 0.75rem;
8167
+  color: var(--fg-default);
8168
+  background: var(--canvas-default);
8169
+  border: 1px solid var(--border-default);
8170
+  border-radius: 6px;
8171
+  font: inherit;
8172
+}
8173
+.shithub-pull-new-description {
8174
+  margin: 0.75rem 0 0 3.25rem;
8175
+}
8176
+.shithub-pull-new-description .shithub-comment-editor-box {
8177
+  margin-top: 0.35rem;
8178
+}
8179
+.shithub-pull-new-actions {
8180
+  display: flex;
8181
+  justify-content: flex-end;
8182
+  align-items: stretch;
8183
+  gap: 0;
8184
+  margin-top: 0.75rem;
8185
+}
8186
+.shithub-pull-new-actions > .shithub-button-primary {
8187
+  border-top-right-radius: 0;
8188
+  border-bottom-right-radius: 0;
8189
+}
8190
+.shithub-pr-submit-menu {
8191
+  position: relative;
8192
+}
8193
+.shithub-pr-submit-menu > summary {
8194
+  height: 100%;
8195
+  border-top-left-radius: 0;
8196
+  border-bottom-left-radius: 0;
8197
+  list-style: none;
8198
+}
8199
+.shithub-pr-submit-menu > summary::-webkit-details-marker {
8200
+  display: none;
8201
+}
8202
+.shithub-pr-submit-menu-popover {
8203
+  position: absolute;
8204
+  z-index: 65;
8205
+  right: 0;
8206
+  top: calc(100% + 0.25rem);
8207
+  width: min(21rem, calc(100vw - 2rem));
8208
+  overflow: hidden;
8209
+  background: var(--canvas-overlay, var(--canvas-default));
8210
+  border: 1px solid var(--border-default);
8211
+  border-radius: 8px;
8212
+  box-shadow: var(--shadow-large, 0 16px 32px rgba(1, 4, 9, 0.35));
8213
+}
8214
+.shithub-pr-submit-menu-popover button {
8215
+  display: grid;
8216
+  gap: 0.25rem;
8217
+  width: 100%;
8218
+  padding: 0.75rem 1rem;
8219
+  color: var(--fg-default);
8220
+  text-align: left;
8221
+  background: transparent;
8222
+  border: 0;
8223
+  border-bottom: 1px solid var(--border-default);
8224
+  font: inherit;
8225
+  cursor: pointer;
8226
+}
8227
+.shithub-pr-submit-menu-popover button:last-child {
8228
+  border-bottom: 0;
8229
+}
8230
+.shithub-pr-submit-menu-popover button:hover,
8231
+.shithub-pr-submit-menu-popover button.is-active {
8232
+  color: #ffffff;
8233
+  background: var(--accent-emphasis);
8234
+}
8235
+.shithub-pr-submit-menu-popover span {
8236
+  color: inherit;
8237
+  opacity: 0.85;
8238
+  font-size: 0.85rem;
8239
+}
8240
+.shithub-pull-new-sidebar {
8241
+  display: grid;
8242
+  gap: 0;
8243
+  color: var(--fg-muted);
8244
+  font-size: 0.9rem;
8245
+}
8246
+.shithub-pull-new-sidebar section {
8247
+  padding: 0.75rem 0;
8248
+  border-bottom: 1px solid var(--border-default);
8249
+}
8250
+.shithub-pull-new-sidebar h2 {
8251
+  display: flex;
8252
+  align-items: center;
8253
+  justify-content: space-between;
8254
+  gap: 0.5rem;
8255
+  margin: 0 0 0.4rem;
8256
+  color: var(--fg-muted);
8257
+  font-size: 0.85rem;
8258
+}
8259
+.shithub-pull-new-sidebar p {
8260
+  margin: 0;
8261
+}
8262
+@media (max-width: 760px) {
8263
+  .shithub-range-create {
8264
+    margin-left: 0;
8265
+    width: 100%;
8266
+    justify-content: center;
8267
+  }
8268
+  .shithub-compare-stats {
8269
+    grid-template-columns: 1fr;
8270
+  }
8271
+  .shithub-compare-stats span {
8272
+    border-right: 0;
8273
+    border-bottom: 1px solid var(--border-default);
8274
+  }
8275
+  .shithub-compare-stats span:last-child {
8276
+    border-bottom: 0;
8277
+  }
8278
+  .shithub-pull-new-layout {
8279
+    grid-template-columns: 1fr;
8280
+  }
8281
+  .shithub-pull-new-description {
8282
+    margin-left: 0;
8283
+  }
8284
+  .shithub-pull-new-sidebar {
8285
+    order: 2;
8286
+  }
8287
+}
78838288
 .shithub-settings-branches form label { display: block; margin: 0.5rem 0; }
78848289
 .shithub-settings-branches form input[type=text],
78858290
 .shithub-settings-branches form select { font: inherit; padding: 0.4rem 0.6rem; border: 1px solid var(--border-default); border-radius: 6px; min-width: 280px; }
internal/web/static/js/compare.jsadded
@@ -0,0 +1,60 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+(function () {
4
+  function setActivePanel(menu, name) {
5
+    menu.querySelectorAll("[data-ref-tab]").forEach(function (tab) {
6
+      var active = tab.getAttribute("data-ref-tab") === name;
7
+      tab.classList.toggle("is-active", active);
8
+      tab.setAttribute("aria-selected", active ? "true" : "false");
9
+    });
10
+    menu.querySelectorAll("[data-ref-panel]").forEach(function (panel) {
11
+      panel.hidden = panel.getAttribute("data-ref-panel") !== name;
12
+    });
13
+    var input = menu.querySelector("[data-ref-filter]");
14
+    if (input) {
15
+      input.value = "";
16
+      input.setAttribute("placeholder", name === "tags" ? "Find a tag" : "Find a branch");
17
+      input.setAttribute("aria-label", name === "tags" ? "Find a tag" : "Find a branch");
18
+      filterPanel(menu);
19
+      input.focus();
20
+    }
21
+  }
22
+
23
+  function filterPanel(menu) {
24
+    var input = menu.querySelector("[data-ref-filter]");
25
+    var query = input ? input.value.trim().toLowerCase() : "";
26
+    var panel = Array.prototype.find.call(menu.querySelectorAll("[data-ref-panel]"), function (candidate) {
27
+      return !candidate.hidden;
28
+    });
29
+    if (!panel) return;
30
+    var visible = 0;
31
+    panel.querySelectorAll("[data-ref-option]").forEach(function (option) {
32
+      var name = (option.getAttribute("data-ref-name") || option.textContent || "").toLowerCase();
33
+      var match = !query || name.indexOf(query) !== -1;
34
+      option.hidden = !match;
35
+      if (match) visible += 1;
36
+    });
37
+    var empty = panel.querySelector("[data-ref-empty]");
38
+    if (empty) empty.hidden = visible !== 0;
39
+  }
40
+
41
+  document.querySelectorAll("[data-ref-menu]").forEach(function (menu) {
42
+    menu.querySelectorAll("[data-ref-tab]").forEach(function (tab) {
43
+      tab.addEventListener("click", function () {
44
+        setActivePanel(menu, tab.getAttribute("data-ref-tab") || "branches");
45
+      });
46
+    });
47
+    var input = menu.querySelector("[data-ref-filter]");
48
+    if (input) {
49
+      input.addEventListener("input", function () { filterPanel(menu); });
50
+    }
51
+    menu.querySelectorAll("[data-ref-close]").forEach(function (close) {
52
+      close.addEventListener("click", function () { menu.open = false; });
53
+    });
54
+    menu.addEventListener("toggle", function () {
55
+      if (menu.open && input) {
56
+        setTimeout(function () { input.focus(); }, 0);
57
+      }
58
+    });
59
+  });
60
+})();
internal/web/templates/_compare_ref_menu.htmladded
@@ -0,0 +1,44 @@
1
+{{ define "compare-ref-menu" -}}
2
+{{ $menu := .Menu -}}
3
+<details class="shithub-compare-ref-menu" data-ref-menu>
4
+  <summary class="shithub-button shithub-button-small shithub-compare-ref-summary">
5
+    <span class="shithub-compare-ref-label">{{ $menu.Label }}</span>
6
+    <span class="shithub-compare-ref-current">{{ $menu.Current }}</span>
7
+    {{ octicon "triangle-down" }}
8
+  </summary>
9
+  <div class="shithub-compare-ref-panel">
10
+    <div class="shithub-compare-ref-panel-head">
11
+      <strong>{{ $menu.Title }}</strong>
12
+      <button type="button" class="shithub-icon-button shithub-compare-ref-close" aria-label="Close" data-ref-close>{{ octicon "x" }}</button>
13
+    </div>
14
+    <div class="shithub-compare-ref-filter">
15
+      {{ octicon "search" }}
16
+      <input type="search" placeholder="Find a branch" aria-label="Find a branch" data-ref-filter>
17
+    </div>
18
+    <div class="shithub-compare-ref-tabs" role="tablist" aria-label="{{ $menu.Title }}">
19
+      <button type="button" class="is-active" role="tab" aria-selected="true" data-ref-tab="branches">Branches</button>
20
+      <button type="button" role="tab" aria-selected="false" data-ref-tab="tags">Tags</button>
21
+    </div>
22
+    <div class="shithub-compare-ref-list" data-ref-panel="branches">
23
+      {{ range $menu.Branches }}
24
+      <a href="{{ .Href }}" class="shithub-compare-ref-option{{ if .Current }} is-current{{ end }}" data-ref-option data-ref-name="{{ .Name }}">
25
+        <span class="shithub-compare-ref-check">{{ if .Current }}{{ octicon "check" }}{{ end }}</span>
26
+        <span class="shithub-compare-ref-option-name">{{ .Name }}</span>
27
+        {{ if .IsDefault }}<span class="shithub-compare-ref-default">default</span>{{ end }}
28
+      </a>
29
+      {{ end }}
30
+      <div class="shithub-compare-ref-empty" data-ref-empty hidden>No matching branches.</div>
31
+    </div>
32
+    <div class="shithub-compare-ref-list" data-ref-panel="tags" hidden>
33
+      {{ range $menu.Tags }}
34
+      <a href="{{ .Href }}" class="shithub-compare-ref-option{{ if .Current }} is-current{{ end }}" data-ref-option data-ref-name="{{ .Name }}">
35
+        <span class="shithub-compare-ref-check">{{ if .Current }}{{ octicon "check" }}{{ end }}</span>
36
+        <span class="shithub-compare-ref-option-name">{{ .Name }}</span>
37
+        {{ if .IsDefault }}<span class="shithub-compare-ref-default">default</span>{{ end }}
38
+      </a>
39
+      {{ end }}
40
+      <div class="shithub-compare-ref-empty" data-ref-empty hidden>No matching tags.</div>
41
+    </div>
42
+  </div>
43
+</details>
44
+{{- end }}
internal/web/templates/_layout.htmlmodified
@@ -36,6 +36,7 @@
3636
   <link rel="stylesheet" href="/static/css/shithub.css">
3737
   <link rel="stylesheet" href="/static/css/chroma.css">
3838
   {{ if flag . "UseHTMX" }}<script src="/static/vendor/htmx/htmx.min.js" defer></script>{{ end }}
39
+  {{ if flag . "UseCompareJS" }}<script src="/static/js/compare.js" defer></script>{{ end }}
3940
   {{ if flag . "UseCommentEditor" }}<script src="/static/js/comment-editor.js" defer></script>{{ end }}
4041
 </head>
4142
 <body class="shithub-body">
internal/web/templates/repo/compare.htmlmodified
@@ -1,37 +1,102 @@
11
 {{ define "page" -}}
2
-<section class="shithub-compare">
3
-  <header class="shithub-code-head">
4
-    <h1>
5
-      <a href="/{{ .Owner }}/{{ .Repo.Name }}/tree/{{ .Repo.DefaultBranch }}">{{ .Owner }}/{{ .Repo.Name }}</a>
6
-      <span class="shithub-code-sep">/</span>
7
-      Compare <code>{{ .Base }}</code> ... <code>{{ .Head }}</code>
8
-    </h1>
9
-  </header>
2
+<section class="shithub-repo-page">
3
+  {{ template "repo-header" . }}
104
 
11
-  {{ if .NotFound }}
12
-  <div class="shithub-compare-empty">One or both refs were not found in this repository.</div>
5
+  <div class="shithub-compare-flow">
6
+    <header class="shithub-compare-subhead">
7
+      <h1>{{ if .HasSelection }}Comparing changes{{ else }}Compare changes{{ end }}</h1>
8
+      <p>
9
+        {{ if .HasSelection }}
10
+        Choose two branches to see what's changed or to start a new pull request.
1311
         {{ else }}
14
-  <p class="shithub-compare-summary">
15
-    {{ if eq .Base .Head }}
16
-    Base and head are the same — nothing to compare.
17
-    {{ else if le .Ahead 0 }}
18
-    There isn't anything to compare. <code>{{ .Head }}</code> is up to date with <code>{{ .Base }}</code>.
19
-    {{ else }}
20
-    <strong>{{ .Ahead }}</strong> commit{{ if ne .Ahead 1 }}s{{ end }} ahead, <strong>{{ .Behind }}</strong> behind <code>{{ .Base }}</code>.
21
-    <a href="/{{ .Owner }}/{{ .Repo.Name }}/pulls/new?base={{ .Base }}&amp;head={{ .Head }}" class="shithub-button shithub-button-primary">Create pull request</a>
12
+        Compare changes across branches, commits, tags, and more below.
2213
         {{ end }}
2314
       </p>
15
+    </header>
16
+
17
+    <div class="shithub-range-editor" aria-label="Compare branches">
18
+      {{ template "compare-ref-menu" (dict "Menu" .BaseMenu) }}
19
+      <span class="shithub-range-separator" aria-hidden="true">...</span>
20
+      {{ template "compare-ref-menu" (dict "Menu" .HeadMenu) }}
21
+      {{ if .CanOpenPull }}
22
+      <a href="{{ .PullNewHref }}" class="shithub-button shithub-button-primary shithub-range-create">Create pull request</a>
23
+      {{ else }}
24
+      <button type="button" class="shithub-button shithub-button-primary shithub-range-create" disabled>Create pull request</button>
25
+      {{ end }}
26
+    </div>
27
+
28
+    {{ if .NotFound }}
29
+    <div class="shithub-compare-flash shithub-compare-flash-danger" role="alert">
30
+      {{ octicon "x-circle" }}
31
+      <div>
32
+        <strong>There was a problem comparing these refs.</strong>
33
+        <p>One or both refs were not found in this repository.</p>
34
+      </div>
35
+    </div>
36
+    {{ else if not .HasSelection }}
37
+    <div class="shithub-compare-flash shithub-compare-flash-warning">
38
+      {{ octicon "git-pull-request" }}
39
+      <div>Choose different branches or tags above to discuss and review changes.</div>
40
+    </div>
41
+    <section class="shithub-compare-blankslate">
42
+      {{ octicon "git-pull-request" }}
43
+      <h2>Compare and review just about anything</h2>
44
+      <p>Branches, tags, commits, and time ranges in the same repository can be compared from here.</p>
45
+      {{ if .Examples }}
46
+      <div class="shithub-compare-examples">
47
+        <div class="shithub-compare-examples-head">Example comparisons</div>
48
+        {{ range .Examples }}
49
+        <a href="{{ .Href }}">
50
+          <span>{{ octicon "git-branch" }} {{ .Name }}</span>
51
+          <span>compare</span>
52
+        </a>
53
+        {{ end }}
54
+      </div>
55
+      {{ end }}
56
+    </section>
57
+    {{ else if .SameRef }}
58
+    <div class="shithub-compare-flash shithub-compare-flash-warning">
59
+      {{ octicon "git-pull-request" }}
60
+      <div>Choose different branches or tags above to discuss and review changes.</div>
61
+    </div>
62
+    <section class="shithub-compare-blankslate">
63
+      {{ octicon "git-pull-request" }}
64
+      <h2>Compare and review just about anything</h2>
65
+      <p><code>{{ .Base }}</code> and <code>{{ .Head }}</code> are the same ref.</p>
66
+    </section>
67
+    {{ else if .NoCommits }}
68
+    <div class="shithub-compare-flash shithub-compare-flash-warning">
69
+      {{ octicon "check-circle" }}
70
+      <div>
71
+        <strong>There isn't anything to compare.</strong>
72
+        <p><code>{{ .Head }}</code> is up to date with <code>{{ .Base }}</code>.</p>
73
+      </div>
74
+    </div>
75
+    {{ else }}
76
+    <div class="shithub-compare-flash shithub-compare-flash-{{ .MergeState.State }}">
77
+      {{ if eq .MergeState.State "clean" }}{{ octicon "check" }}{{ else if eq .MergeState.State "conflict" }}{{ octicon "x-circle" }}{{ else }}{{ octicon "git-pull-request" }}{{ end }}
78
+      <div>
79
+        <strong>{{ .MergeState.Label }}</strong>
80
+        <span>{{ .MergeState.Description }}</span>
81
+      </div>
82
+    </div>
83
+
84
+    <div class="shithub-compare-stats" aria-label="Comparison summary">
85
+      <span>{{ octicon "git-commit" }} {{ .Stats.CommitCount }} {{ pluralize .Stats.CommitCount "commit" "commits" }}</span>
86
+      <span>{{ octicon "diff" }} {{ .Stats.FileCount }} {{ pluralize .Stats.FileCount "file changed" "files changed" }}</span>
87
+      <span>{{ octicon "person" }} {{ .Stats.ContributorCount }} {{ pluralize .Stats.ContributorCount "contributor" "contributors" }}</span>
88
+    </div>
2489
 
2590
     {{ if .Commits }}
2691
     <section class="shithub-compare-commits">
27
-    <h2>Commits in <code>{{ .Head }}</code></h2>
92
+      <h2>Commits on {{ .Head }}</h2>
2893
       <ul class="shithub-commits-list">
2994
         {{ range .Commits }}
3095
         <li class="shithub-commits-row">
3196
           <div class="shithub-commits-meta">
3297
             <a class="shithub-commits-subject" href="/{{ $.Owner }}/{{ $.Repo.Name }}/commit/{{ .OID }}">{{ .Subject }}</a>
3398
             <code class="shithub-commits-sha">{{ .ShortOID }}</code>
34
-          <small>{{ .AuthorName }} · <time datetime="{{ .AuthorWhen.Format "2006-01-02T15:04:05Z" }}">{{ relativeTime .AuthorWhen }}</time></small>
99
+            <small>{{ .AuthorName }} committed <time datetime="{{ .AuthorWhen.Format "2006-01-02T15:04:05Z" }}">{{ relativeTime .AuthorWhen }}</time></small>
35100
           </div>
36101
         </li>
37102
         {{ end }}
@@ -45,5 +110,6 @@
45110
     </section>
46111
     {{ end }}
47112
     {{ end }}
113
+  </div>
48114
 </section>
49115
 {{- end }}
internal/web/templates/repo/pull_new.htmlmodified
@@ -1,46 +1,207 @@
11
 {{ define "page" -}}
2
-<section class="shithub-issue-new">
3
-  <header class="shithub-issues-head">
4
-    <h1>
5
-      <a href="/{{ .Owner }}/{{ .Repo.Name }}">{{ .Owner }}/{{ .Repo.Name }}</a>
6
-      <span class="shithub-code-sep">/</span>
7
-      <a href="/{{ .Owner }}/{{ .Repo.Name }}/pulls">Pull requests</a>
8
-      <span class="shithub-code-sep">/</span>
9
-      New
10
-    </h1>
2
+<section class="shithub-repo-page">
3
+  {{ template "repo-header" . }}
4
+
5
+  <div class="shithub-compare-flow shithub-pull-open-flow">
6
+    <header class="shithub-compare-subhead">
7
+      <h1>Open a pull request</h1>
8
+      <p>Create a new pull request by comparing changes across two branches.</p>
119
     </header>
1210
 
11
+    <div class="shithub-range-editor" aria-label="Choose pull request branches">
12
+      {{ template "compare-ref-menu" (dict "Menu" .BaseMenu) }}
13
+      <span class="shithub-range-separator" aria-hidden="true">...</span>
14
+      {{ template "compare-ref-menu" (dict "Menu" .HeadMenu) }}
15
+      {{ if .MergeState.Label }}
16
+      <span class="shithub-range-merge-state shithub-range-merge-{{ .MergeState.State }}">
17
+        {{ if eq .MergeState.State "clean" }}{{ octicon "check" }}{{ else if eq .MergeState.State "conflict" }}{{ octicon "x-circle" }}{{ else }}{{ octicon "git-pull-request" }}{{ end }}
18
+        {{ .MergeState.Label }}
19
+      </span>
20
+      {{ end }}
21
+    </div>
22
+
1323
     {{ if .Error }}<div class="shithub-error" role="alert">{{ .Error }}</div>{{ end }}
24
+    {{ if .NotFound }}
25
+    <div class="shithub-compare-flash shithub-compare-flash-danger" role="alert">
26
+      {{ octicon "x-circle" }}
27
+      <div>
28
+        <strong>There was a problem comparing these refs.</strong>
29
+        <p>One or both refs were not found in this repository.</p>
30
+      </div>
31
+    </div>
32
+    {{ else if or .SameRef .NoCommits }}
33
+    <div class="shithub-compare-flash shithub-compare-flash-warning">
34
+      {{ octicon "git-pull-request" }}
35
+      <div>
36
+        <strong>Choose different branches to open a pull request.</strong>
37
+        <p><code>{{ .Head }}</code> has no commits ahead of <code>{{ .Base }}</code>.</p>
38
+      </div>
39
+    </div>
40
+    {{ end }}
1441
 
15
-  <form method="post" action="/{{ .Owner }}/{{ .Repo.Name }}/pulls" class="shithub-issue-form">
42
+    <div class="shithub-pull-new-layout">
43
+      <form method="post" action="/{{ .Owner }}/{{ .Repo.Name }}/pulls" class="shithub-pull-new-form">
1644
         <input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
17
-    <div class="shithub-form-row shithub-pull-refs">
18
-      <label>
19
-        <span>Base</span>
20
-        <input type="text" name="base" value="{{ .Base }}" required>
21
-      </label>
22
-      <span class="shithub-pull-arrow">←</span>
23
-      <label>
24
-        <span>Head</span>
25
-        <input type="text" name="head" value="{{ .Head }}" required>
26
-      </label>
27
-    </div>
28
-    <label class="shithub-form-row">
29
-      <span>Title</span>
45
+        <input type="hidden" name="base" value="{{ .Base }}">
46
+        <input type="hidden" name="head" value="{{ .Head }}">
47
+
48
+        <div class="shithub-pull-new-title-row">
49
+          <a class="shithub-comment-composer-avatar" href="/{{ .Viewer.Username }}" aria-label="@{{ .Viewer.Username }}">
50
+            <img src="{{ .ViewerAvatarURL }}" alt="" width="40" height="40">
51
+          </a>
52
+          <label class="shithub-pull-new-title">
53
+            <span>Add a title <strong>*</strong></span>
3054
             <input type="text" name="title" maxlength="256" required value="{{ .FormTitle }}" autofocus>
3155
           </label>
32
-    <label class="shithub-form-row">
33
-      <span>Body (Markdown)</span>
34
-      <textarea name="body" rows="14" maxlength="65535">{{ .FormBody }}</textarea>
35
-    </label>
36
-    <label class="shithub-form-row">
37
-      <input type="checkbox" name="draft" value="on">
38
-      Open as draft
56
+        </div>
57
+
58
+        <div class="shithub-pull-new-description" data-comment-editor data-preview-url="/{{ .Owner }}/{{ .Repo.Name }}/markdown-preview" data-preview-ref="{{ .Base }}">
59
+          <script type="application/json" data-comment-editor-config>{{ jsField . "CommentEditorConfig" }}</script>
60
+          <label class="shithub-pull-new-description-label" for="pull-new-body">Add a description</label>
61
+          <div class="shithub-comment-editor-box">
62
+            <div class="shithub-comment-editor-head">
63
+              <div class="shithub-comment-editor-tabs" role="tablist" aria-label="Pull request description tabs">
64
+                <button type="button" class="is-active" role="tab" aria-selected="true" data-comment-tab="write">Write</button>
65
+                <button type="button" role="tab" aria-selected="false" data-comment-tab="preview">Preview</button>
66
+              </div>
67
+              <div class="shithub-comment-toolbar" aria-label="Formatting tools">
68
+                <button type="button" class="shithub-comment-tool" data-comment-action="mention" title="Mention a user">{{ octicon "people" }}</button>
69
+                <span class="shithub-comment-toolbar-separator" aria-hidden="true"></span>
70
+                <button type="button" class="shithub-comment-tool shithub-comment-tool-text" data-comment-action="heading" title="Add heading">H</button>
71
+                <button type="button" class="shithub-comment-tool shithub-comment-tool-text" data-comment-action="bold" title="Add bold text">B</button>
72
+                <button type="button" class="shithub-comment-tool shithub-comment-tool-text is-italic" data-comment-action="italic" title="Add italic text">I</button>
73
+                <button type="button" class="shithub-comment-tool" data-comment-action="quote" title="Quote text">{{ octicon "comment" }}</button>
74
+                <button type="button" class="shithub-comment-tool" data-comment-action="code" title="Add code">{{ octicon "code" }}</button>
75
+                <button type="button" class="shithub-comment-tool" data-comment-action="link" title="Add link">{{ octicon "link" }}</button>
76
+                <span class="shithub-comment-toolbar-separator" aria-hidden="true"></span>
77
+                <button type="button" class="shithub-comment-tool" data-comment-action="list" title="Add unordered list">{{ octicon "list-unordered" }}</button>
78
+                <button type="button" class="shithub-comment-tool shithub-comment-tool-text" data-comment-action="ordered-list" title="Add ordered list">1.</button>
79
+                <button type="button" class="shithub-comment-tool" data-comment-action="task-list" title="Add task list">{{ octicon "checklist" }}</button>
80
+                <span class="shithub-comment-toolbar-separator" aria-hidden="true"></span>
81
+                <label class="shithub-comment-tool" title="Attach files">
82
+                  {{ octicon "upload" }}
83
+                  <input type="file" multiple data-comment-file-input>
3984
                 </label>
40
-    <div class="shithub-form-actions">
41
-      <a href="/{{ .Owner }}/{{ .Repo.Name }}/pulls" class="shithub-button">Cancel</a>
85
+                <button type="button" class="shithub-comment-tool shithub-comment-tool-text" data-comment-action="reference" title="Reference an issue or pull request">#</button>
86
+                <button type="button" class="shithub-comment-tool" data-comment-saved-replies-open title="Saved replies">{{ octicon "comment-discussion" }}</button>
87
+                <button type="button" class="shithub-comment-tool" data-comment-action="fullscreen" title="Fullscreen editor">{{ octicon "screen-full" }}</button>
88
+              </div>
89
+            </div>
90
+            <div class="shithub-comment-editor-write" data-comment-write-pane>
91
+              <textarea id="pull-new-body" name="body" rows="10" maxlength="65535" placeholder="Add your description here..." data-comment-textarea>{{ .FormBody }}</textarea>
92
+              <div class="shithub-comment-suggestions" data-comment-suggestions hidden></div>
93
+            </div>
94
+            <div class="shithub-comment-editor-preview markdown-body" data-comment-preview-pane hidden>
95
+              <p class="shithub-editor-preview-empty">Nothing to preview.</p>
96
+            </div>
97
+            <div class="shithub-comment-editor-footer">
98
+              <span>{{ octicon "code-square" }} Markdown is supported</span>
99
+              <span data-comment-attachment-copy>{{ octicon "file" }} Paste, drop, or click to add files</span>
100
+              <span class="shithub-comment-file-list" data-comment-file-list hidden></span>
101
+            </div>
102
+          </div>
103
+          <dialog class="shithub-comment-saved-dialog" data-comment-saved-dialog>
104
+            <div class="shithub-comment-saved-head">
105
+              <strong>Select a reply</strong>
106
+              <button type="button" class="shithub-icon-button" aria-label="Close" data-comment-saved-close>{{ octicon "x" }}</button>
107
+            </div>
108
+            <input type="search" placeholder="Search saved replies" data-comment-saved-filter>
109
+            <button type="button" class="shithub-comment-saved-item" data-comment-saved-insert="Duplicate of #">
110
+              <strong>Duplicate pull request</strong>
111
+              <span>Duplicate of #</span>
112
+              <kbd>ctrl 1</kbd>
113
+            </button>
114
+            <button type="button" class="shithub-comment-saved-create">{{ octicon "plus" }} Create a new saved reply</button>
115
+          </dialog>
116
+        </div>
117
+
118
+        <p class="shithub-comment-policy-note">
119
+          {{ octicon "alert" }} Remember, contributions to this repository should follow its
120
+          <a href="/{{ .Owner }}/{{ .Repo.Name }}/blob/{{ .Repo.DefaultBranch }}/CONTRIBUTING.md">contributing guidelines</a>,
121
+          <a href="/{{ .Owner }}/{{ .Repo.Name }}/blob/{{ .Repo.DefaultBranch }}/SECURITY.md">security policy</a>, and
122
+          <a href="/{{ .Owner }}/{{ .Repo.Name }}/blob/{{ .Repo.DefaultBranch }}/CODE_OF_CONDUCT.md">code of conduct</a>.
123
+        </p>
124
+
125
+        <div class="shithub-pull-new-actions">
126
+          {{ if .CanCreatePull }}
42127
           <button type="submit" class="shithub-button shithub-button-primary">Create pull request</button>
128
+          <details class="shithub-pr-submit-menu">
129
+            <summary class="shithub-button shithub-button-primary shithub-button-icon" aria-label="Create options">{{ octicon "triangle-down" }}</summary>
130
+            <div class="shithub-pr-submit-menu-popover">
131
+              <button type="submit" class="is-active">
132
+                <strong>{{ octicon "check" }} Create pull request</strong>
133
+                <span>Open a pull request that is ready for review</span>
134
+              </button>
135
+              <button type="submit" name="draft" value="on">
136
+                <strong>Create draft pull request</strong>
137
+                <span>Cannot be merged until marked ready for review</span>
138
+              </button>
139
+            </div>
140
+          </details>
141
+          {{ else }}
142
+          <button type="submit" class="shithub-button shithub-button-primary" disabled>Create pull request</button>
143
+          {{ end }}
43144
         </div>
44145
       </form>
146
+
147
+      <aside class="shithub-pull-new-sidebar" aria-label="Pull request metadata">
148
+        <section>
149
+          <h2>Reviewers <button type="button" class="shithub-icon-button" aria-label="Edit reviewers">{{ octicon "gear" }}</button></h2>
150
+          <p>No reviewers</p>
151
+        </section>
152
+        <section>
153
+          <h2>Assignees <button type="button" class="shithub-icon-button" aria-label="Edit assignees">{{ octicon "gear" }}</button></h2>
154
+          <p>No one - <a href="/{{ .Viewer.Username }}">assign yourself</a></p>
155
+        </section>
156
+        <section>
157
+          <h2>Labels <button type="button" class="shithub-icon-button" aria-label="Edit labels">{{ octicon "gear" }}</button></h2>
158
+          <p>None yet</p>
159
+        </section>
160
+        <section>
161
+          <h2>Projects <button type="button" class="shithub-icon-button" aria-label="Edit projects">{{ octicon "gear" }}</button></h2>
162
+          <p>None yet</p>
163
+        </section>
164
+        <section>
165
+          <h2>Milestone <button type="button" class="shithub-icon-button" aria-label="Edit milestone">{{ octicon "gear" }}</button></h2>
166
+          <p>No milestone</p>
167
+        </section>
168
+        <section>
169
+          <h2>Development</h2>
170
+          <p>Use closing keywords in the description to automatically close issues</p>
171
+        </section>
172
+      </aside>
173
+    </div>
174
+
175
+    {{ if and (not .NotFound) (not .SameRef) }}
176
+    <div class="shithub-compare-stats" aria-label="Comparison summary">
177
+      <span>{{ octicon "git-commit" }} {{ .Stats.CommitCount }} {{ pluralize .Stats.CommitCount "commit" "commits" }}</span>
178
+      <span>{{ octicon "diff" }} {{ .Stats.FileCount }} {{ pluralize .Stats.FileCount "file changed" "files changed" }}</span>
179
+      <span>{{ octicon "person" }} {{ .Stats.ContributorCount }} {{ pluralize .Stats.ContributorCount "contributor" "contributors" }}</span>
180
+    </div>
181
+
182
+    {{ if .Commits }}
183
+    <section class="shithub-compare-commits">
184
+      <h2>Commits on {{ .Head }}</h2>
185
+      <ul class="shithub-commits-list">
186
+        {{ range .Commits }}
187
+        <li class="shithub-commits-row">
188
+          <div class="shithub-commits-meta">
189
+            <a class="shithub-commits-subject" href="/{{ $.Owner }}/{{ $.Repo.Name }}/commit/{{ .OID }}">{{ .Subject }}</a>
190
+            <code class="shithub-commits-sha">{{ .ShortOID }}</code>
191
+            <small>{{ .AuthorName }} committed <time datetime="{{ .AuthorWhen.Format "2006-01-02T15:04:05Z" }}">{{ relativeTime .AuthorWhen }}</time></small>
192
+          </div>
193
+        </li>
194
+        {{ end }}
195
+      </ul>
196
+    </section>
197
+    {{ end }}
198
+
199
+    {{ if .DiffHTML }}
200
+    <section class="shithub-diff-body" aria-label="Diff">
201
+      {{ safeHTML .DiffHTML }}
202
+    </section>
203
+    {{ end }}
204
+    {{ end }}
205
+  </div>
45206
 </section>
46207
 {{- end }}
internal/web/templates/repo/pulls_list.htmlmodified
@@ -5,7 +5,7 @@
55
   <header class="shithub-issues-head">
66
     <h1>Pull requests</h1>
77
     <div class="shithub-issues-actions">
8
-      <a href="/{{ .Owner }}/{{ .Repo.Name }}/compare/{{ .Repo.DefaultBranch }}...{{ .Repo.DefaultBranch }}" class="shithub-button shithub-button-primary">New pull request</a>
8
+      <a href="/{{ .Owner }}/{{ .Repo.Name }}/compare" class="shithub-button shithub-button-primary">New pull request</a>
99
     </div>
1010
   </header>
1111