package views import ( "strconv" "hearthforge/internal/config" "hearthforge/internal/db" "hearthforge/internal/gitcmd" "hearthforge/internal/highlight" "hearthforge/internal/util" g "maragu.dev/gomponents" . "maragu.dev/gomponents/html" ) // Ref name and message caps. const ( maxBranchNameLength = 255 maxTagNameLength = 255 maxTagMessageLength = 500 ) // sigBadge renders the signature badge, or nothing for an unsigned commit. func sigBadge(status gitcmd.SigStatus) g.Node { switch status { case gitcmd.SigGood: return Span(Class("sig-badge verified"), g.Text("verified")) case gitcmd.SigBad: return Span(Class("sig-badge unverified"), g.Text("unverified")) } return nil } // CommitLog renders the commit history with cursor pagination. // olderURL and newerURL are empty when that direction has no page. func CommitLog(cfg *config.Config, user *db.SessionUser, repo *db.Repo, logRef string, commits []gitcmd.Commit, branches, tags []string, olderURL, newerURL string, ) g.Node { return Layout(Page{Title: "Commits — " + repo.Name, User: user, Cfg: cfg}, Div(Class("container"), RepoHeader(repo), RepoNav(repo, "commits", user), Div(Class("commits-header"), H2(Class("section-title"), g.Text("Commits")), BranchSelector(repo.Name, branches, tags, logRef, "commits", ""), ), g.If(len(commits) == 0, P(Class("text-muted"), g.Text("No commits yet."))), g.If(len(commits) > 0, g.Group{ P(Class("commit-verify-hint"), g.Text("Note: To verify signed commits locally, download the "), A(Href("/allowed_signers"), g.Text("allowed signers file")), g.Text(" and run: "), Code(Class("mono"), g.Text("git -c gpg.format=ssh -c gpg.ssh.allowedSignersFile=allowed_signers verify-commit ")), ), Ul(Class("commit-list commit-log"), g.Map(commits, func(c gitcmd.Commit) g.Node { short := c.Hash if len(short) > 7 { short = short[:7] } return Li(Class("commit-item"), A(Href("/"+repo.Name+"/commit/"+c.Hash), Class("commit-subject"), g.Text(c.Subject)), Div(Class("commit-meta"), Span(Class("commit-author"), g.Text(c.Author)), Span(Class("commit-meta-right"), sigBadge(c.SigStatus), A(Href("/"+repo.Name+"/commit/"+c.Hash), Class("commit-hash mono"), g.Text(short)), Time(Class("commit-date"), DateTime(c.Date), g.Text(util.FormatDateTime(c.Date))), ), ), ) }), ), }), g.If(newerURL != "" || olderURL != "", Nav(Class("commit-cursor-nav"), Aria("label", "Commit history navigation"), Div(Class("commit-cursor-prev"), g.If(newerURL != "", A(Href(newerURL), Class("pagination-btn"), g.Text("← Newer"))), ), Div(Class("commit-cursor-next"), g.If(olderURL != "", A(Href(olderURL), Class("pagination-btn"), g.Text("Older →"))), ), )), ), ) } // CommitDetail renders one commit with its diff. tooLarge is the raw diff size // when the diff was skipped, and 0 when the diff was rendered. func CommitDetail(cfg *config.Config, user *db.SessionUser, repo *db.Repo, sha string, meta *gitcmd.CommitMeta, files []highlight.RenderedDiffFile, tooLarge int, ) g.Node { short := sha if len(short) > 7 { short = short[:7] } subject := meta.Subject subjectClass := "commit-card-subject" if subject == "" { subject = "No commit message" subjectClass = "commit-card-subject commit-card-subject-empty" } differentCommitter := meta.Committer != meta.Author || meta.CommitterEmail != meta.Email return Layout(Page{Title: short + " — " + repo.Name, User: user, Cfg: cfg}, Div(Class("container"), RepoHeader(repo), RepoNav(repo, "commits", user), Div(Class("commit-page-top"), A(Href("/"+repo.Name+"/commits/"+EscapePath(repo.DefaultBranch)), Class("btn btn-sm"), g.Text("← Back to log")), A(Href("/"+repo.Name+"/tree/"+sha), Class("btn btn-secondary btn-sm"), Title("Browse tree at "+short), g.Text("@ "+short)), ), Div(Class("commit-card"), H2(Class(subjectClass), g.Text(subject)), g.If(meta.Body != "", Pre(Class("commit-card-body"), g.Text(meta.Body))), Div(Class("commit-card-meta"), metaRow("Author", Span(Class("commit-meta-value"), g.Text(meta.Author+" <"+meta.Email+">"))), metaRow("Date", Time(Class("commit-meta-value"), DateTime(meta.Date), g.Text(util.FormatDateTime(meta.Date)))), g.If(differentCommitter, g.Group{ metaRow("Committer", Span(Class("commit-meta-value"), g.Text(meta.Committer+" <"+meta.CommitterEmail+">"))), metaRow("Commit date", Time(Class("commit-meta-value"), DateTime(meta.CommitterDate), g.Text(util.FormatDateTime(meta.CommitterDate)))), }), metaRow("Commit", Code(Class("commit-meta-value commit-sha-full mono"), g.Text(meta.Hash))), g.If(len(meta.Parents) > 0, metaRow(parentLabel(len(meta.Parents)), Span(Class("commit-meta-value"), g.Map(meta.Parents, func(p string) g.Node { ps := p if len(ps) > 7 { ps = ps[:7] } return A(Href("/"+repo.Name+"/commit/"+p), Class("commit-hash mono"), g.Text(ps)) })))), g.If(meta.SigStatus != gitcmd.SigNone, metaRow("Signature", Span(Class("commit-meta-value"), sigBadge(meta.SigStatus)))), ), ), g.If(tooLarge > 0, Div(Class("file-download-notice"), P(g.Text("Diff is too large to render inline ("+ formatMB(tooLarge)+" MB). Browse individual files at the tree below.")), )), g.If(tooLarge == 0, DiffView(files, repo, sha)), ), ) } func metaRow(label string, value g.Node) g.Node { return Div(Class("commit-card-meta-row"), Span(Class("commit-meta-label"), g.Text(label)), value, ) } func parentLabel(n int) string { if n > 1 { return "Parents" } return "Parent" } // formatMB renders a byte count as megabytes with one decimal. func formatMB(bytes int) string { return strconv.FormatFloat(float64(bytes)/1024/1024, 'f', 1, 64) } // BranchList renders the branch overview with admin actions. func BranchList(cfg *config.Config, user *db.SessionUser, repo *db.Repo, branches []gitcmd.BranchInfo, page PageInfo, success, errMsg string, ) g.Node { isAdmin := user != nil && user.IsAdmin return Layout(Page{Title: "Branches — " + repo.Name, User: user, Cfg: cfg}, Div(Class("container"), RepoHeader(repo), RepoNav(repo, "branches", user), g.If(success != "", P(Class("form-success"), g.Text(success))), g.If(errMsg != "", P(Class("form-error"), g.Text(errMsg))), Div(Class("list-header"), H2(Class("list-heading"), g.Text("Branches")), g.If(isAdmin, Details(Class("confirm-details"), Summary(Class("btn btn-primary btn-sm"), g.Text("New branch")), Div(Class("confirm-popup confirm-popup-form"), Form(Method("POST"), Action("/"+repo.Name+"/branches/create"), Class("popup-form"), Div(Class("form-group"), Label(For("new-branch-name"), g.Text("Branch name")), Input(ID("new-branch-name"), Name("name"), Type("text"), Required(), Placeholder("feature/my-branch"), MaxLength(strconv.Itoa(maxBranchNameLength))), ), Div(Class("form-group"), Label(For("new-branch-source"), g.Text("From")), Input(ID("new-branch-source"), Name("source_ref"), Type("text"), Required(), Value(repo.DefaultBranch), Placeholder("branch, tag, or commit"), MaxLength(strconv.Itoa(maxBranchNameLength))), ), Button(Type("submit"), Class("btn btn-primary btn-sm"), g.Text("Create branch")), ), ), )), ), g.If(len(branches) == 0, Div(Class("empty-state"), P(g.Text("No branches yet.")))), g.If(len(branches) > 0, Ul(Class("ref-list"), g.Map(branches, func(b gitcmd.BranchInfo) g.Node { return Li(Class("ref-item"), Div(Class("ref-name-row"), A(Href("/"+repo.Name+"/tree/"+EscapePath(b.Name)), Class("ref-name"), g.Text(b.Name)), g.If(b.Name == repo.DefaultBranch, Span(Class("badge"), g.Text("default"))), g.If(isAdmin, Span(Class("ref-actions"), branchRenameDetails(repo, b), g.If(b.Name != repo.DefaultBranch, branchDeleteDetails(repo, b)), )), ), Div(Class("ref-meta-row"), g.If(b.AuthorName != "", Span(Class("ref-author"), g.Text(b.AuthorName))), g.If(b.ShortHash != "", A(Href("/"+repo.Name+"/commit/"+b.ShortHash), Class("ref-hash mono"), g.Text(b.ShortHash))), g.If(b.Subject != "", Span(Class("ref-subject"), g.Text(b.Subject))), g.If(b.Date != "", Time(Class("ref-date"), DateTime(b.Date), g.Text(util.FormatDateTime(b.Date)))), ), ) }), )), Pagination(page), ), ) } func branchRenameDetails(repo *db.Repo, b gitcmd.BranchInfo) g.Node { return Details(Class("confirm-details confirm-details-inline"), Summary(Class("btn btn-sm"), g.Text("Rename")), Div(Class("confirm-popup"), Form(Method("POST"), Action("/"+repo.Name+"/branches/rename"), Class("popup-form"), Input(Type("hidden"), Name("old_name"), Value(b.Name)), Input(Name("new_name"), Type("text"), Required(), Placeholder("new-name"), Value(b.Name), MaxLength(strconv.Itoa(maxBranchNameLength))), Button(Type("submit"), Class("btn btn-sm btn-primary"), g.Text("Rename")), ), ), ) } func branchDeleteDetails(repo *db.Repo, b gitcmd.BranchInfo) g.Node { return Details(Class("confirm-details confirm-details-inline"), Summary(Class("btn btn-sm btn-danger"), g.Text("Delete")), Div(Class("confirm-popup"), g.Text("Delete branch "), Strong(g.Text(b.Name)), g.Text("?"), Form(Method("POST"), Action("/"+repo.Name+"/branches/delete"), Class("inline-form"), Input(Type("hidden"), Name("name"), Value(b.Name)), Button(Type("submit"), Class("btn btn-sm btn-danger"), g.Text("Yes, delete")), ), ), ) } // TagList renders the tag overview. tagReleases maps a tag name to the id of // the release that references it. func TagList(cfg *config.Config, user *db.SessionUser, repo *db.Repo, tags []gitcmd.TagInfo, tagReleases map[string]int64, page PageInfo, success, errMsg string, ) g.Node { isAdmin := user != nil && user.IsAdmin return Layout(Page{Title: "Tags — " + repo.Name, User: user, Cfg: cfg}, Div(Class("container"), RepoHeader(repo), RepoNav(repo, "tags", user), g.If(success != "", P(Class("form-success"), g.Text(success))), g.If(errMsg != "", P(Class("form-error"), g.Text(errMsg))), Div(Class("list-header"), H2(Class("list-heading"), g.Text("Tags")), g.If(isAdmin, Details(Class("confirm-details"), Summary(Class("btn btn-primary btn-sm"), g.Text("New tag")), Div(Class("confirm-popup confirm-popup-form"), Form(Method("POST"), Action("/"+repo.Name+"/tags/create"), Class("popup-form"), Div(Class("form-group"), Label(For("new-tag-name"), g.Text("Tag name")), Input(ID("new-tag-name"), Name("name"), Type("text"), Required(), Placeholder("v1.0.0"), MaxLength(strconv.Itoa(maxTagNameLength))), ), Div(Class("form-group"), Label(For("new-tag-ref"), g.Text("Target")), Input(ID("new-tag-ref"), Name("ref"), Type("text"), Required(), Value(repo.DefaultBranch), Placeholder("branch, tag, or commit"), MaxLength(strconv.Itoa(maxTagNameLength))), ), Div(Class("form-group"), Label(For("new-tag-message"), g.Text("Message "), Span(Class("text-muted"), g.Text("(blank = lightweight)"))), Input(ID("new-tag-message"), Name("message"), Type("text"), Placeholder("Optional tag message"), MaxLength(strconv.Itoa(maxTagMessageLength))), ), Button(Type("submit"), Class("btn btn-primary btn-sm"), g.Text("Create tag")), ), ), )), ), g.If(len(tags) == 0, Div(Class("empty-state"), P(g.Text("No tags yet.")))), g.If(len(tags) > 0, Ul(Class("ref-list"), g.Map(tags, func(tag gitcmd.TagInfo) g.Node { releaseID := tagReleases[tag.Name] releaseHref := "/" + repo.Name + "/releases/" + strconv.FormatInt(releaseID, 10) return Li(Class("ref-item"), Div(Class("ref-name-row"), A(Href("/"+repo.Name+"/tree/"+EscapePath(tag.Name)), Class("ref-name"), g.Text(tag.Name)), g.If(releaseID != 0, A(Href(releaseHref), Class("badge badge-release"), g.Text("release"))), g.If(isAdmin, Span(Class("ref-actions"), tagDeleteDetails(repo, tag, releaseID, releaseHref))), ), Div(Class("ref-meta-row"), g.If(tag.TaggerName != "", Span(Class("ref-author"), g.Text(tag.TaggerName))), g.If(tag.ShortHash != "", A(Href("/"+repo.Name+"/commit/"+tag.ShortHash), Class("ref-hash mono"), g.Text(tag.ShortHash))), g.If(tag.Subject != "", Span(Class("ref-subject"), g.Text(tag.Subject))), g.If(tag.Date != "", Time(Class("ref-date"), DateTime(tag.Date), g.Text(util.FormatDateTime(tag.Date)))), ), ) }), )), Pagination(page), ), ) } func tagDeleteDetails(repo *db.Repo, tag gitcmd.TagInfo, releaseID int64, releaseHref string) g.Node { return Details(Class("confirm-details confirm-details-inline"), Summary(Class("btn btn-sm btn-danger"), g.Text("Delete")), Div(Class("confirm-popup"), g.If(releaseID != 0, P(Class("confirm-warning"), g.Text("Tag "), Strong(g.Text(tag.Name)), g.Text(" is linked to "), A(Href(releaseHref), g.Text("a release")), g.Text(". Only the git tag will be deleted; the release record will remain."), )), g.If(releaseID == 0, g.Group{ g.Text("Delete tag "), Strong(g.Text(tag.Name)), g.Text("?"), }), Form(Method("POST"), Action("/"+repo.Name+"/tags/delete"), Class("inline-form"), Input(Type("hidden"), Name("name"), Value(tag.Name)), Button(Type("submit"), Class("btn btn-sm btn-danger"), g.Text("Yes, delete")), ), ), ) }