Align compare and new PR flow
Authored by
mfwolffe <wolffemf@dukes.jmu.edu>
- SHA
50a1a7e683c9fe0ee1ebcf90652069cbf28e9b63- Parents
-
f1e40ac - Tree
1b07b5c
50a1a7e
50a1a7e683c9fe0ee1ebcf90652069cbf28e9b63f1e40ac
1b07b5cdocs/internal/branch-protection.mdmodified@@ -10,6 +10,7 @@ hook. | ||
| 10 | 10 | | ------------------------------------------------------- | ----------------------------- | |
| 11 | 11 | | `GET /{owner}/{repo}/branches?filter=active|stale|` | `branchesList` | |
| 12 | 12 | | `GET /{owner}/{repo}/tags` | `tagsList` | |
| 13 | +| `GET /{owner}/{repo}/compare` | `compareView` | | |
| 13 | 14 | | `GET /{owner}/{repo}/compare/{base}...{head}` | `compareView` | |
| 14 | 15 | | `GET /{owner}/{repo}/settings/branches` | `settingsBranches` (auth-gated) | |
| 15 | 16 | | `POST /{owner}/{repo}/settings/branches` | upsert rule | |
@@ -50,10 +51,14 @@ first-class releases ship post-MVP. | ||
| 50 | 51 | ## Compare view |
| 51 | 52 | |
| 52 | 53 | 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`. | |
| 57 | 62 | - The commits-list (head-side only) via |
| 58 | 63 | `repogit.CommitsBetween(base, head, 250)`. |
| 59 | 64 | - 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. | ||
| 52 | 52 | | `POST /{owner}/{repo}/pulls/{number}/ready` | RequireUser | |
| 53 | 53 | | `POST /{owner}/{repo}/pulls/{number}/merge` | RequireUser | |
| 54 | 54 | |
| 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. | |
| 57 | 61 | |
| 58 | 62 | ## Auto-synchronize on head push |
| 59 | 63 | |
@@ -148,6 +152,16 @@ noreply emails are post-MVP. | ||
| 148 | 152 | |
| 149 | 153 | ## Web UI |
| 150 | 154 | |
| 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. | |
| 151 | 165 | - Tabbed view at `/pulls/{number}` switches between Conversation, |
| 152 | 166 | Commits, Files, Checks via the `Tab` field on the template data. |
| 153 | 167 | - Conversation follows GitHub's PageHeader + tab strip shape: state |
internal/web/handlers/repo/branches.gomodified@@ -22,6 +22,7 @@ import ( | ||
| 22 | 22 | func (h *Handlers) MountRefs(r chi.Router) { |
| 23 | 23 | r.Get("/{owner}/{repo}/branches", h.branchesList) |
| 24 | 24 | r.Get("/{owner}/{repo}/tags", h.tagsList) |
| 25 | + r.Get("/{owner}/{repo}/compare", h.compareView) | |
| 25 | 26 | // Compare uses `...` as the base/head separator (matches GitHub). |
| 26 | 27 | // chi can't represent the literal `...` in a route param so we use |
| 27 | 28 | // a wildcard and parse server-side. |
@@ -209,15 +210,16 @@ func (h *Handlers) compareView(w http.ResponseWriter, r *http.Request) { | ||
| 209 | 210 | return |
| 210 | 211 | } |
| 211 | 212 | 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 { | |
| 218 | 220 | // 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 { | |
| 221 | 223 | head = rest |
| 222 | 224 | base = row.DefaultBranch |
| 223 | 225 | } |
@@ -225,43 +227,43 @@ func (h *Handlers) compareView(w http.ResponseWriter, r *http.Request) { | ||
| 225 | 227 | if base == "" { |
| 226 | 228 | base = row.DefaultBranch |
| 227 | 229 | } |
| 230 | + if head == "" { | |
| 231 | + head = row.DefaultBranch | |
| 232 | + } | |
| 233 | + } | |
| 228 | 234 | |
| 229 | 235 | // Strip cross-repo "fork:branch" prefix for the local path; full |
| 230 | 236 | // cross-repo lookup lands in S22+S27. |
| 231 | 237 | base = stripCrossRepoPrefix(base) |
| 232 | 238 | head = stripCrossRepoPrefix(head) |
| 233 | 239 | |
| 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{ | |
| 250 | 244 | "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 | + )) | |
| 265 | 267 | } |
| 266 | 268 | |
| 267 | 269 | // 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) { | ||
| 190 | 190 | base = row.DefaultBranch |
| 191 | 191 | } |
| 192 | 192 | 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, | |
| 201 | 200 | }) |
| 202 | 201 | } |
| 203 | 202 | |
| 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 | + | |
| 204 | 274 | // pullCreate handles POST /{owner}/{repo}/pulls. |
| 205 | 275 | func (h *Handlers) pullCreate(w http.ResponseWriter, r *http.Request) { |
| 206 | 276 | row, owner, ok := h.loadRepoAndAuthorize(w, r, policy.ActionPullCreate) |
@@ -266,18 +336,13 @@ func (h *Handlers) handlePullCreateError(w http.ResponseWriter, r *http.Request, | ||
| 266 | 336 | case errors.Is(err, issues.ErrBodyTooLong): |
| 267 | 337 | msg = "Body is too long." |
| 268 | 338 | } |
| 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, | |
| 281 | 346 | }) |
| 282 | 347 | } |
| 283 | 348 | |
internal/web/static/css/shithub.cssmodified@@ -7877,9 +7877,414 @@ button.shithub-repo-action { | ||
| 7877 | 7877 | border-bottom: 1px solid var(--border-default); |
| 7878 | 7878 | } |
| 7879 | 7879 | .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 | +} | |
| 7883 | 8288 | .shithub-settings-branches form label { display: block; margin: 0.5rem 0; } |
| 7884 | 8289 | .shithub-settings-branches form input[type=text], |
| 7885 | 8290 | .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/_layout.htmlmodified@@ -36,6 +36,7 @@ | ||
| 36 | 36 | <link rel="stylesheet" href="/static/css/shithub.css"> |
| 37 | 37 | <link rel="stylesheet" href="/static/css/chroma.css"> |
| 38 | 38 | {{ 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 }} | |
| 39 | 40 | {{ if flag . "UseCommentEditor" }}<script src="/static/js/comment-editor.js" defer></script>{{ end }} |
| 40 | 41 | </head> |
| 41 | 42 | <body class="shithub-body"> |
internal/web/templates/repo/compare.htmlmodified@@ -1,37 +1,102 @@ | ||
| 1 | 1 | {{ 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" . }} | |
| 10 | 4 | |
| 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. | |
| 13 | 11 | {{ 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 }}&head={{ .Head }}" class="shithub-button shithub-button-primary">Create pull request</a> | |
| 12 | + Compare changes across branches, commits, tags, and more below. | |
| 22 | 13 | {{ end }} |
| 23 | 14 | </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> | |
| 24 | 89 | |
| 25 | 90 | {{ if .Commits }} |
| 26 | 91 | <section class="shithub-compare-commits"> |
| 27 | - <h2>Commits in <code>{{ .Head }}</code></h2> | |
| 92 | + <h2>Commits on {{ .Head }}</h2> | |
| 28 | 93 | <ul class="shithub-commits-list"> |
| 29 | 94 | {{ range .Commits }} |
| 30 | 95 | <li class="shithub-commits-row"> |
| 31 | 96 | <div class="shithub-commits-meta"> |
| 32 | 97 | <a class="shithub-commits-subject" href="/{{ $.Owner }}/{{ $.Repo.Name }}/commit/{{ .OID }}">{{ .Subject }}</a> |
| 33 | 98 | <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> | |
| 35 | 100 | </div> |
| 36 | 101 | </li> |
| 37 | 102 | {{ end }} |
@@ -45,5 +110,6 @@ | ||
| 45 | 110 | </section> |
| 46 | 111 | {{ end }} |
| 47 | 112 | {{ end }} |
| 113 | + </div> | |
| 48 | 114 | </section> |
| 49 | 115 | {{- end }} |
internal/web/templates/repo/pull_new.htmlmodified@@ -1,46 +1,207 @@ | ||
| 1 | 1 | {{ 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> | |
| 11 | 9 | </header> |
| 12 | 10 | |
| 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 | + | |
| 13 | 23 | {{ 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 }} | |
| 14 | 41 | |
| 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"> | |
| 16 | 44 | <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> | |
| 30 | 54 | <input type="text" name="title" maxlength="256" required value="{{ .FormTitle }}" autofocus> |
| 31 | 55 | </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> | |
| 39 | 84 | </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 }} | |
| 42 | 127 | <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 }} | |
| 43 | 144 | </div> |
| 44 | 145 | </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> | |
| 45 | 206 | </section> |
| 46 | 207 | {{- end }} |
internal/web/templates/repo/pulls_list.htmlmodified@@ -5,7 +5,7 @@ | ||
| 5 | 5 | <header class="shithub-issues-head"> |
| 6 | 6 | <h1>Pull requests</h1> |
| 7 | 7 | <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> | |
| 9 | 9 | </div> |
| 10 | 10 | </header> |
| 11 | 11 | |