repos.go
⎇
Raw
1package views
2
3import (
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.
17const (
18 maxBranchNameLength = 255
19 maxTagNameLength = 255
20 maxTagMessageLength = 500
21)
22
23// sigBadge renders the signature badge, or nothing for an unsigned commit.
24func 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.
40func 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.
100func 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
164func 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
171func 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.
179func 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
241func 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
255func 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.
270func 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
339func 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