repos.go
| 1 | package views |
| 2 | |
| 3 | import ( |
| 4 | "strconv" |
| 5 | |
| 6 | "hearthforge/internal/config" |
| 7 | "hearthforge/internal/db" |
| 8 | "hearthforge/internal/gitcmd" |
| 9 | "hearthforge/internal/highlight" |
| 10 | "hearthforge/internal/util" |
| 11 | |
| 12 | g "maragu.dev/gomponents" |
| 13 | . "maragu.dev/gomponents/html" |
| 14 | ) |
| 15 | |
| 16 | // Ref name and message caps. |
| 17 | const ( |
| 18 | maxBranchNameLength = 255 |
| 19 | maxTagNameLength = 255 |
| 20 | maxTagMessageLength = 500 |
| 21 | ) |
| 22 | |
| 23 | // sigBadge renders the signature badge, or nothing for an unsigned commit. |
| 24 | func sigBadge(status gitcmd.SigStatus) g.Node { |
| 25 | switch status { |
| 26 | case gitcmd.SigGood: |
| 27 | return Span(Class("sig-badge verified"), g.Text("verified")) |
| 28 | case gitcmd.SigBad: |
| 29 | return Span(Class("sig-badge bad"), Title("Signature is invalid or its key is revoked"), |
| 30 | g.Text("bad signature")) |
| 31 | case gitcmd.SigUnverified: |
| 32 | return Span(Class("sig-badge unverified"), Title("Signature could not be verified"), |
| 33 | g.Text("unverified")) |
| 34 | } |
| 35 | return nil |
| 36 | } |
| 37 | |
| 38 | // CommitLog renders the commit history with cursor pagination. |
| 39 | // olderURL and newerURL are empty when that direction has no page. |
| 40 | func CommitLog(cfg *config.Config, user *db.SessionUser, repo *db.Repo, logRef string, |
| 41 | commits []gitcmd.Commit, branches, tags []string, olderURL, newerURL string, |
| 42 | ) g.Node { |
| 43 | return Layout(Page{Title: "Commits — " + repo.Name, User: user, Cfg: cfg}, |
| 44 | Div(Class("container"), |
| 45 | RepoHeader(repo), |
| 46 | RepoNav(cfg, repo, "commits", user), |
| 47 | Div(Class("commits-header"), |
| 48 | H2(Class("section-title"), g.Text("Commits")), |
| 49 | BranchSelector(repo.Name, branches, tags, logRef, "commits", ""), |
| 50 | ), |
| 51 | g.If(len(commits) == 0, P(Class("text-muted"), g.Text("No commits yet."))), |
| 52 | g.If(len(commits) > 0, g.Group{ |
| 53 | P(Class("commit-verify-hint"), |
| 54 | g.Text("Note: To verify signed commits locally, download the "), |
| 55 | A(Href("/allowed_signers"), g.Text("allowed signers file")), |
| 56 | g.Text(" and run: "), |
| 57 | Code(Class("mono"), |
| 58 | g.Text("git -c gpg.format=ssh -c gpg.ssh.allowedSignersFile=allowed_signers verify-commit <hash>")), |
| 59 | ), |
| 60 | Ul(Class("commit-list commit-log"), |
| 61 | g.Map(commits, func(c gitcmd.Commit) g.Node { |
| 62 | short := c.Hash |
| 63 | if len(short) > 7 { |
| 64 | short = short[:7] |
| 65 | } |
| 66 | return Li(Class("commit-item"), |
| 67 | A(Href("/"+repo.Name+"/commit/"+c.Hash), Class("commit-subject"), |
| 68 | g.Text(c.Subject)), |
| 69 | Div(Class("commit-meta"), |
| 70 | Span(Class("commit-author"), g.Text(c.Author)), |
| 71 | Span(Class("commit-meta-right"), |
| 72 | sigBadge(c.SigStatus), |
| 73 | A(Href("/"+repo.Name+"/commit/"+c.Hash), Class("commit-hash mono"), |
| 74 | g.Text(short)), |
| 75 | Time(Class("commit-date"), DateTime(c.Date), |
| 76 | g.Text(util.FormatDateTime(c.Date))), |
| 77 | ), |
| 78 | ), |
| 79 | ) |
| 80 | }), |
| 81 | ), |
| 82 | }), |
| 83 | g.If(newerURL != "" || olderURL != "", |
| 84 | Nav(Class("commit-cursor-nav"), Aria("label", "Commit history navigation"), |
| 85 | Div(Class("commit-cursor-prev"), |
| 86 | g.If(newerURL != "", |
| 87 | A(Href(newerURL), Class("pagination-btn"), g.Text("← Newer"))), |
| 88 | ), |
| 89 | Div(Class("commit-cursor-next"), |
| 90 | g.If(olderURL != "", |
| 91 | A(Href(olderURL), Class("pagination-btn"), g.Text("Older →"))), |
| 92 | ), |
| 93 | )), |
| 94 | ), |
| 95 | ) |
| 96 | } |
| 97 | |
| 98 | // CommitDetail renders one commit with its diff. tooLarge means the diff |
| 99 | // exceeded cfg.MaxRenderBytes and was skipped. |
| 100 | func CommitDetail(cfg *config.Config, user *db.SessionUser, repo *db.Repo, sha string, |
| 101 | meta *gitcmd.CommitMeta, files []highlight.RenderedDiffFile, tooLarge bool, |
| 102 | ) g.Node { |
| 103 | short := sha |
| 104 | if len(short) > 7 { |
| 105 | short = short[:7] |
| 106 | } |
| 107 | subject := meta.Subject |
| 108 | subjectClass := "commit-card-subject" |
| 109 | if subject == "" { |
| 110 | subject = "No commit message" |
| 111 | subjectClass = "commit-card-subject commit-card-subject-empty" |
| 112 | } |
| 113 | differentCommitter := meta.Committer != meta.Author || meta.CommitterEmail != meta.Email |
| 114 | |
| 115 | return Layout(Page{Title: short + " — " + repo.Name, User: user, Cfg: cfg}, |
| 116 | Div(Class("container"), |
| 117 | RepoHeader(repo), |
| 118 | RepoNav(cfg, repo, "commits", user), |
| 119 | Div(Class("commit-page-top"), |
| 120 | A(Href("/"+repo.Name+"/commits/"+EscapePath(repo.DefaultBranch)), Class("btn btn-sm"), |
| 121 | g.Text("← Back to log")), |
| 122 | A(Href("/"+repo.Name+"/tree/"+sha), Class("btn btn-secondary btn-sm"), |
| 123 | Title("Browse tree at "+short), g.Text("@ "+short)), |
| 124 | ), |
| 125 | Div(Class("commit-card"), |
| 126 | H2(Class(subjectClass), g.Text(subject)), |
| 127 | g.If(meta.Body != "", Pre(Class("commit-card-body"), g.Text(meta.Body))), |
| 128 | Div(Class("commit-card-meta"), |
| 129 | metaRow("Author", Span(Class("commit-meta-value"), |
| 130 | g.Text(meta.Author+" <"+meta.Email+">"))), |
| 131 | metaRow("Date", Time(Class("commit-meta-value"), DateTime(meta.Date), |
| 132 | g.Text(util.FormatDateTime(meta.Date)))), |
| 133 | g.If(differentCommitter, g.Group{ |
| 134 | metaRow("Committer", Span(Class("commit-meta-value"), |
| 135 | g.Text(meta.Committer+" <"+meta.CommitterEmail+">"))), |
| 136 | metaRow("Commit date", Time(Class("commit-meta-value"), |
| 137 | DateTime(meta.CommitterDate), g.Text(util.FormatDateTime(meta.CommitterDate)))), |
| 138 | }), |
| 139 | metaRow("Commit", Code(Class("commit-meta-value commit-sha-full mono"), |
| 140 | g.Text(meta.Hash))), |
| 141 | g.If(len(meta.Parents) > 0, metaRow(parentLabel(len(meta.Parents)), |
| 142 | Span(Class("commit-meta-value"), |
| 143 | g.Map(meta.Parents, func(p string) g.Node { |
| 144 | ps := p |
| 145 | if len(ps) > 7 { |
| 146 | ps = ps[:7] |
| 147 | } |
| 148 | return A(Href("/"+repo.Name+"/commit/"+p), Class("commit-hash mono"), |
| 149 | g.Text(ps)) |
| 150 | })))), |
| 151 | g.If(meta.SigStatus != gitcmd.SigNone, metaRow("Signature", |
| 152 | Span(Class("commit-meta-value"), sigBadge(meta.SigStatus)))), |
| 153 | ), |
| 154 | ), |
| 155 | g.If(tooLarge, Div(Class("file-download-notice"), |
| 156 | P(g.Text("Diff is too large to render inline (over "+ |
| 157 | util.FormatBytes(cfg.MaxRenderBytes)+"). Browse individual files in the tree.")), |
| 158 | )), |
| 159 | g.If(!tooLarge, DiffView(files, repo, sha)), |
| 160 | ), |
| 161 | ) |
| 162 | } |
| 163 | |
| 164 | func metaRow(label string, value g.Node) g.Node { |
| 165 | return Div(Class("commit-card-meta-row"), |
| 166 | Span(Class("commit-meta-label"), g.Text(label)), |
| 167 | value, |
| 168 | ) |
| 169 | } |
| 170 | |
| 171 | func parentLabel(n int) string { |
| 172 | if n > 1 { |
| 173 | return "Parents" |
| 174 | } |
| 175 | return "Parent" |
| 176 | } |
| 177 | |
| 178 | // BranchList renders the branch overview with admin actions. |
| 179 | func BranchList(cfg *config.Config, user *db.SessionUser, repo *db.Repo, branches []gitcmd.BranchInfo, |
| 180 | page PageInfo, success, errMsg string, |
| 181 | ) g.Node { |
| 182 | isAdmin := user != nil && user.IsAdmin |
| 183 | return Layout(Page{Title: "Branches — " + repo.Name, User: user, Cfg: cfg}, |
| 184 | Div(Class("container"), |
| 185 | RepoHeader(repo), |
| 186 | RepoNav(cfg, repo, "branches", user), |
| 187 | g.If(success != "", P(Class("form-success"), g.Text(success))), |
| 188 | g.If(errMsg != "", P(Class("form-error"), g.Text(errMsg))), |
| 189 | Div(Class("list-header"), |
| 190 | H2(Class("list-heading"), g.Text("Branches")), |
| 191 | g.If(isAdmin, Details(Class("confirm-details"), |
| 192 | Summary(Class("btn btn-primary btn-sm"), g.Text("New branch")), |
| 193 | Div(Class("confirm-popup confirm-popup-form"), |
| 194 | Form(Method("POST"), Action("/"+repo.Name+"/branches/create"), Class("popup-form"), |
| 195 | Div(Class("form-group"), |
| 196 | Label(For("new-branch-name"), g.Text("Branch name")), |
| 197 | Input(ID("new-branch-name"), Name("name"), Type("text"), Required(), |
| 198 | Placeholder("feature/my-branch"), |
| 199 | MaxLength(strconv.Itoa(maxBranchNameLength))), |
| 200 | ), |
| 201 | Div(Class("form-group"), |
| 202 | Label(For("new-branch-source"), g.Text("From")), |
| 203 | Input(ID("new-branch-source"), Name("source_ref"), Type("text"), Required(), |
| 204 | Value(repo.DefaultBranch), Placeholder("branch, tag, or commit"), |
| 205 | MaxLength(strconv.Itoa(maxBranchNameLength))), |
| 206 | ), |
| 207 | Button(Type("submit"), Class("btn btn-primary btn-sm"), g.Text("Create branch")), |
| 208 | ), |
| 209 | ), |
| 210 | )), |
| 211 | ), |
| 212 | g.If(len(branches) == 0, Div(Class("empty-state"), P(g.Text("No branches yet.")))), |
| 213 | g.If(len(branches) > 0, Ul(Class("ref-list"), |
| 214 | g.Map(branches, func(b gitcmd.BranchInfo) g.Node { |
| 215 | return Li(Class("ref-item"), |
| 216 | Div(Class("ref-name-row"), |
| 217 | A(Href("/"+repo.Name+"/tree/"+EscapePath(b.Name)), Class("ref-name"), g.Text(b.Name)), |
| 218 | g.If(b.Name == repo.DefaultBranch, Span(Class("badge"), g.Text("default"))), |
| 219 | g.If(isAdmin, Span(Class("ref-actions"), |
| 220 | branchRenameDetails(repo, b), |
| 221 | g.If(b.Name != repo.DefaultBranch, branchDeleteDetails(repo, b)), |
| 222 | )), |
| 223 | ), |
| 224 | Div(Class("ref-meta-row"), |
| 225 | g.If(b.AuthorName != "", Span(Class("ref-author"), g.Text(b.AuthorName))), |
| 226 | g.If(b.ShortHash != "", |
| 227 | A(Href("/"+repo.Name+"/commit/"+b.ShortHash), Class("ref-hash mono"), |
| 228 | g.Text(b.ShortHash))), |
| 229 | g.If(b.Subject != "", Span(Class("ref-subject"), g.Text(b.Subject))), |
| 230 | g.If(b.Date != "", Time(Class("ref-date"), DateTime(b.Date), |
| 231 | g.Text(util.FormatDateTime(b.Date)))), |
| 232 | ), |
| 233 | ) |
| 234 | }), |
| 235 | )), |
| 236 | Pagination(page), |
| 237 | ), |
| 238 | ) |
| 239 | } |
| 240 | |
| 241 | func branchRenameDetails(repo *db.Repo, b gitcmd.BranchInfo) g.Node { |
| 242 | return Details(Class("confirm-details confirm-details-inline"), |
| 243 | Summary(Class("btn btn-sm"), g.Text("Rename")), |
| 244 | Div(Class("confirm-popup"), |
| 245 | Form(Method("POST"), Action("/"+repo.Name+"/branches/rename"), Class("popup-form"), |
| 246 | Input(Type("hidden"), Name("old_name"), Value(b.Name)), |
| 247 | Input(Name("new_name"), Type("text"), Required(), Placeholder("new-name"), |
| 248 | Value(b.Name), MaxLength(strconv.Itoa(maxBranchNameLength))), |
| 249 | Button(Type("submit"), Class("btn btn-sm btn-primary"), g.Text("Rename")), |
| 250 | ), |
| 251 | ), |
| 252 | ) |
| 253 | } |
| 254 | |
| 255 | func branchDeleteDetails(repo *db.Repo, b gitcmd.BranchInfo) g.Node { |
| 256 | return Details(Class("confirm-details confirm-details-inline"), |
| 257 | Summary(Class("btn btn-sm btn-danger"), g.Text("Delete")), |
| 258 | Div(Class("confirm-popup"), |
| 259 | g.Text("Delete branch "), Strong(g.Text(b.Name)), g.Text("?"), |
| 260 | Form(Method("POST"), Action("/"+repo.Name+"/branches/delete"), Class("inline-form"), |
| 261 | Input(Type("hidden"), Name("name"), Value(b.Name)), |
| 262 | Button(Type("submit"), Class("btn btn-sm btn-danger"), g.Text("Yes, delete")), |
| 263 | ), |
| 264 | ), |
| 265 | ) |
| 266 | } |
| 267 | |
| 268 | // TagList renders the tag overview. tagReleases maps a tag name to the id of |
| 269 | // the release that references it. |
| 270 | func TagList(cfg *config.Config, user *db.SessionUser, repo *db.Repo, tags []gitcmd.TagInfo, |
| 271 | tagReleases map[string]int64, page PageInfo, success, errMsg string, |
| 272 | ) g.Node { |
| 273 | isAdmin := user != nil && user.IsAdmin |
| 274 | return Layout(Page{Title: "Tags — " + repo.Name, User: user, Cfg: cfg}, |
| 275 | Div(Class("container"), |
| 276 | RepoHeader(repo), |
| 277 | RepoNav(cfg, repo, "tags", user), |
| 278 | g.If(success != "", P(Class("form-success"), g.Text(success))), |
| 279 | g.If(errMsg != "", P(Class("form-error"), g.Text(errMsg))), |
| 280 | Div(Class("list-header"), |
| 281 | H2(Class("list-heading"), g.Text("Tags")), |
| 282 | g.If(isAdmin, Details(Class("confirm-details"), |
| 283 | Summary(Class("btn btn-primary btn-sm"), g.Text("New tag")), |
| 284 | Div(Class("confirm-popup confirm-popup-form"), |
| 285 | Form(Method("POST"), Action("/"+repo.Name+"/tags/create"), Class("popup-form"), |
| 286 | Div(Class("form-group"), |
| 287 | Label(For("new-tag-name"), g.Text("Tag name")), |
| 288 | Input(ID("new-tag-name"), Name("name"), Type("text"), Required(), |
| 289 | Placeholder("v1.0.0"), MaxLength(strconv.Itoa(maxTagNameLength))), |
| 290 | ), |
| 291 | Div(Class("form-group"), |
| 292 | Label(For("new-tag-ref"), g.Text("Target")), |
| 293 | Input(ID("new-tag-ref"), Name("ref"), Type("text"), Required(), |
| 294 | Value(repo.DefaultBranch), Placeholder("branch, tag, or commit"), |
| 295 | MaxLength(strconv.Itoa(maxTagNameLength))), |
| 296 | ), |
| 297 | Div(Class("form-group"), |
| 298 | Label(For("new-tag-message"), g.Text("Message "), |
| 299 | Span(Class("text-muted"), g.Text("(blank = lightweight)"))), |
| 300 | Input(ID("new-tag-message"), Name("message"), Type("text"), |
| 301 | Placeholder("Optional tag message"), |
| 302 | MaxLength(strconv.Itoa(maxTagMessageLength))), |
| 303 | ), |
| 304 | Button(Type("submit"), Class("btn btn-primary btn-sm"), g.Text("Create tag")), |
| 305 | ), |
| 306 | ), |
| 307 | )), |
| 308 | ), |
| 309 | g.If(len(tags) == 0, Div(Class("empty-state"), P(g.Text("No tags yet.")))), |
| 310 | g.If(len(tags) > 0, Ul(Class("ref-list"), |
| 311 | g.Map(tags, func(tag gitcmd.TagInfo) g.Node { |
| 312 | releaseID := tagReleases[tag.Name] |
| 313 | releaseHref := "/" + repo.Name + "/releases/" + strconv.FormatInt(releaseID, 10) |
| 314 | return Li(Class("ref-item"), |
| 315 | Div(Class("ref-name-row"), |
| 316 | A(Href("/"+repo.Name+"/tree/"+EscapePath(tag.Name)), Class("ref-name"), g.Text(tag.Name)), |
| 317 | g.If(releaseID != 0, |
| 318 | A(Href(releaseHref), Class("badge badge-release"), g.Text("release"))), |
| 319 | g.If(isAdmin, Span(Class("ref-actions"), |
| 320 | tagDeleteDetails(repo, tag, releaseID, releaseHref))), |
| 321 | ), |
| 322 | Div(Class("ref-meta-row"), |
| 323 | g.If(tag.TaggerName != "", Span(Class("ref-author"), g.Text(tag.TaggerName))), |
| 324 | g.If(tag.ShortHash != "", |
| 325 | A(Href("/"+repo.Name+"/commit/"+tag.ShortHash), Class("ref-hash mono"), |
| 326 | g.Text(tag.ShortHash))), |
| 327 | g.If(tag.Subject != "", Span(Class("ref-subject"), g.Text(tag.Subject))), |
| 328 | g.If(tag.Date != "", Time(Class("ref-date"), DateTime(tag.Date), |
| 329 | g.Text(util.FormatDateTime(tag.Date)))), |
| 330 | ), |
| 331 | ) |
| 332 | }), |
| 333 | )), |
| 334 | Pagination(page), |
| 335 | ), |
| 336 | ) |
| 337 | } |
| 338 | |
| 339 | func tagDeleteDetails(repo *db.Repo, tag gitcmd.TagInfo, releaseID int64, releaseHref string) g.Node { |
| 340 | return Details(Class("confirm-details confirm-details-inline"), |
| 341 | Summary(Class("btn btn-sm btn-danger"), g.Text("Delete")), |
| 342 | Div(Class("confirm-popup"), |
| 343 | g.If(releaseID != 0, P(Class("confirm-warning"), |
| 344 | g.Text("Tag "), Strong(g.Text(tag.Name)), g.Text(" is linked to "), |
| 345 | A(Href(releaseHref), g.Text("a release")), |
| 346 | g.Text(". Only the git tag will be deleted; the release record will remain."), |
| 347 | )), |
| 348 | g.If(releaseID == 0, g.Group{ |
| 349 | g.Text("Delete tag "), Strong(g.Text(tag.Name)), g.Text("?"), |
| 350 | }), |
| 351 | Form(Method("POST"), Action("/"+repo.Name+"/tags/delete"), Class("inline-form"), |
| 352 | Input(Type("hidden"), Name("name"), Value(tag.Name)), |
| 353 | Button(Type("submit"), Class("btn btn-sm btn-danger"), g.Text("Yes, delete")), |
| 354 | ), |
| 355 | ), |
| 356 | ) |
| 357 | } |
| 358 |