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