repos_shell.go
⎇
Raw
1package views
2
3import (
4 "strconv"
5 "strings"
6
7 "hearthforge/internal/config"
8 "hearthforge/internal/db"
9 "hearthforge/internal/gitcmd"
10 "hearthforge/internal/util"
11
12 g "maragu.dev/gomponents"
13 . "maragu.dev/gomponents/html"
14)
15
16// deref returns the string a pointer holds, or "" for nil.
17func deref(s *string) string {
18 if s == nil {
19 return ""
20 }
21 return *s
22}
23
24// RepoList renders the repository index with search, sort and pagination.
25func RepoList(cfg *config.Config, user *db.SessionUser, repos []db.Repo,
26 search, sort string, page PageInfo,
27) g.Node {
28 return Layout(Page{Title: "Repositories", User: user, Cfg: cfg},
29 Div(Class("container"),
30 Div(Class("page-header"),
31 H1(Class("page-title"), g.Text("Repositories")),
32 g.If(user != nil && user.IsAdmin,
33 A(Href("/new"), Class("btn btn-primary"), g.Text("New repository"))),
34 ),
35 Div(Class("search-row"),
36 Form(Method("GET"), Action("/"), Class("search-form"),
37 Div(Class("search-input-wrap"),
38 Input(Type("search"), Name("q"), Value(search),
39 Placeholder("Search repositories…"), Class("search-input"),
40 AutoComplete("off")),
41 Button(Type("submit"), Class("btn btn-sm"), g.Text("Search")),
42 ),
43 ),
44 Form(Method("POST"), Action("/sort"),
45 Select(Name("sort"), Class("repo-sort-select"), g.Attr("data-autosubmit"),
46 Option(Value("created"), g.If(sort == "created", Selected()),
47 g.Text("Newest first")),
48 Option(Value("name"), g.If(sort == "name", Selected()),
49 g.Text("Name (A–Z)")),
50 ),
51 NoScript(Button(Type("submit"), Class("btn btn-sm"), g.Text("Go"))),
52 ),
53 ),
54 g.If(len(repos) == 0, Div(Class("empty-state"),
55 g.If(search != "", P(g.Text("No repositories match "), Strong(g.Text(search)), g.Text("."))),
56 g.If(search == "", P(g.Text("No repositories yet."))),
57 )),
58 g.If(len(repos) > 0, Ul(Class("repo-list"),
59 g.Map(repos, func(repo db.Repo) g.Node {
60 return Li(Class("repo-card"),
61 Div(Class("repo-card-main"),
62 Div(Class("repo-card-title"),
63 A(Href("/"+repo.Name), Class("repo-name"), g.Text(repo.Name)),
64 g.If(repo.IsPinned, Span(Class("badge badge-pinned"), g.Text("Pinned"))),
65 g.If(repo.IsPrivate, Span(Class("badge badge-private"), g.Text("Private"))),
66 ),
67 g.If(deref(repo.Description) != "",
68 P(Class("repo-description"), g.Text(deref(repo.Description)))),
69 ),
70 Div(Class("repo-card-meta"),
71 g.El("time", Class("repo-date"), DateTime(repo.CreatedAt),
72 g.Text(util.FormatDate(repo.CreatedAt))),
73 ),
74 )
75 }),
76 )),
77 Pagination(page),
78 ),
79 )
80}
81
82// NewRepo renders the repository creation form.
83func NewRepo(cfg *config.Config, user *db.SessionUser, errMsg string) g.Node {
84 return Layout(Page{Title: "New repository", User: user, Cfg: cfg},
85 Div(Class("container container-narrow"),
86 H1(Class("page-title"), g.Text("Create new repository")),
87 g.If(errMsg != "", P(Class("form-error"), g.Text(errMsg))),
88 Form(Method("POST"), Action("/new"), Class("form-card"),
89 Div(Class("form-group"),
90 Label(For("name"), g.Text("Repository name")),
91 Input(ID("name"), Name("name"), Type("text"), Required(),
92 Pattern("[a-zA-Z0-9._-]+"),
93 Title("Letters, numbers, dots, hyphens, underscores"),
94 Placeholder("my-project")),
95 ),
96 Div(Class("form-group"),
97 Label(For("description"), g.Text("Description "),
98 Span(Class("text-muted"), g.Text("(optional)"))),
99 Input(ID("description"), Name("description"), Type("text"),
100 Placeholder("A short description")),
101 ),
102 Div(Class("form-group"),
103 Label(For("default_branch"), g.Text("Default branch")),
104 Input(ID("default_branch"), Name("default_branch"), Type("text"),
105 Value("main"), Placeholder("main")),
106 ),
107 Div(Class("form-group"),
108 Label(For("object_format"), g.Text("Hash format")),
109 objectFormatSelect(""),
110 P(Class("form-hint"), g.Text(objectFormatHint)),
111 ),
112 Div(Class("form-group"),
113 Label(Class("checkbox-label"),
114 Input(Type("checkbox"), Name("is_private"), Value("1")),
115 g.Text("Private repository (only visible to you)")),
116 ),
117 Button(Type("submit"), Class("btn btn-primary"), g.Text("Create repository")),
118 ),
119 ),
120 )
121}
122
123// RepoHeader renders the repository name and its private badge.
124func RepoHeader(repo *db.Repo) g.Node {
125 return Div(Class("repo-header"),
126 Div(Class("repo-title-row"),
127 H1(Class("page-title"), A(Href("/"+repo.Name), g.Text(repo.Name))),
128 g.If(repo.IsPrivate, Span(Class("badge badge-private"), g.Text("Private"))),
129 ),
130 )
131}
132
133// RepoNav renders the repository tab bar. active is the key of the current tab,
134// one of code, branches, tags, commits, issues, patches, releases, ci, settings.
135func RepoNav(cfg *config.Config, repo *db.Repo, active string, user *db.SessionUser) g.Node {
136 tabs := []struct{ key, label, href string }{
137 {"code", "Code", "/" + repo.Name},
138 {"branches", "Branches", "/" + repo.Name + "/branches"},
139 {"tags", "Tags", "/" + repo.Name + "/tags"},
140 {"commits", "Commits", "/" + repo.Name + "/commits/" + EscapePath(repo.DefaultBranch)},
141 {"issues", "Issues", "/" + repo.Name + "/issues"},
142 {"patches", "Patches", "/" + repo.Name + "/patches"},
143 {"releases", "Releases", "/" + repo.Name + "/releases"},
144 {"ci", "Pipelines", "/" + repo.Name + "/ci"},
145 }
146 // The Images tab follows the registry pull rules.
147 if cfg.CanPullImages(repo.IsPrivate, user != nil, user != nil && user.IsAdmin) {
148 tabs = append(tabs, struct{ key, label, href string }{"images", "Images", "/" + repo.Name + "/images"})
149 }
150 tabClass := func(key string) string {
151 if active == key {
152 return "repo-tab active"
153 }
154 return "repo-tab"
155 }
156 return Div(Class("repo-nav-bar"),
157 Nav(Class("repo-tabs"),
158 g.Map(tabs, func(t struct{ key, label, href string }) g.Node {
159 return A(Href(t.href), Class(tabClass(t.key)), g.Text(t.label))
160 }),
161 g.If(user != nil && user.IsAdmin,
162 A(Href("/"+repo.Name+"/settings"), Class(tabClass("settings")), g.Text("Settings"))),
163 ),
164 )
165}
166
167// BranchSelector renders the ref switcher. view is tree, commits or blob.
168// path is the subpath for tree and the file path for blob.
169func BranchSelector(repoName string, branches, tags []string, currentRef, view, path string) g.Node {
170 if len(branches) == 0 && len(tags) == 0 {
171 return nil
172 }
173 isDetached := !slicesContains(branches, currentRef) && !slicesContains(tags, currentRef)
174 shortRef := currentRef
175 if isDetached && len(currentRef) > 8 {
176 shortRef = currentRef[:8]
177 }
178 return Form(Method("GET"), Action("/"+repoName+"/branch-switch"), Class("branch-selector"),
179 Input(Type("hidden"), Name("view"), Value(view)),
180 g.If(path != "", Input(Type("hidden"), Name("path"), Value(path))),
181 Span(Class("branch-selector-icon"), g.Text("⎇")),
182 Select(Name("rev"), Class("branch-select"), g.Attr("data-autosubmit"),
183 g.If(isDetached,
184 Option(Value(currentRef), Selected(), g.Text(shortRef+" (detached)"))),
185 OptGroup(LabelAttr("Branches"),
186 g.Map(branches, func(b string) g.Node {
187 return Option(Value(b), g.If(b == currentRef, Selected()), g.Text(b))
188 }),
189 ),
190 g.If(len(tags) > 0, OptGroup(LabelAttr("Tags"),
191 g.Map(tags, func(t string) g.Node {
192 return Option(Value(t), g.If(t == currentRef, Selected()), g.Text(t))
193 }),
194 )),
195 ),
196 NoScript(Button(Type("submit"), Class("btn btn-sm"), g.Text("Go"))),
197 )
198}
199
200func slicesContains(list []string, want string) bool {
201 for _, v := range list {
202 if v == want {
203 return true
204 }
205 }
206 return false
207}
208
209// escapeRegexp escapes the characters the HTML pattern attribute treats as
210// regular expression syntax.
211func escapeRegexp(s string) string {
212 var b strings.Builder
213 for _, r := range s {
214 if strings.ContainsRune(`.*+?^${}()|[]\`, r) {
215 b.WriteRune('\\')
216 }
217 b.WriteRune(r)
218 }
219 return b.String()
220}
221
222// RepoSettings renders the repository settings page.
223func RepoSettings(cfg *config.Config, user *db.SessionUser, repo *db.Repo, branches []string,
224 objectFormat string, labels []db.Label, secrets []db.CiSecret, success, errMsg string,
225) g.Node {
226 return Layout(Page{Title: "Settings — " + repo.Name, User: user, Cfg: cfg},
227 Div(Class("container"),
228 RepoHeader(repo),
229 RepoNav(cfg, repo, "settings", user),
230 g.If(success != "", P(Class("form-success"), g.Text(success))),
231 g.If(errMsg != "", P(Class("form-error"), g.Text(errMsg))),
232 Form(Method("POST"), Action("/"+repo.Name+"/settings"), Class("form-card"),
233 Div(Class("form-group"),
234 Label(For("description"), g.Text("Description")),
235 Input(ID("description"), Name("description"), Type("text"),
236 Value(deref(repo.Description)), Placeholder("A short description")),
237 ),
238 Div(Class("form-group"),
239 Label(For("default_branch"), g.Text("Default branch")),
240 g.If(len(branches) > 0,
241 Select(ID("default_branch"), Name("default_branch"), Class("branch-select"),
242 g.Map(branches, func(b string) g.Node {
243 return Option(Value(b), g.If(b == repo.DefaultBranch, Selected()), g.Text(b))
244 }),
245 )),
246 g.If(len(branches) == 0,
247 P(Class("form-hint"),
248 g.Text("No branches yet — push your first commit to set the default branch."))),
249 ),
250 Div(Class("form-group"),
251 Label(Class("checkbox-label"),
252 Input(Type("checkbox"), Name("is_private"), Value("1"),
253 g.If(repo.IsPrivate, Checked())),
254 g.Text("Private repository (only visible to you)")),
255 ),
256 Div(Class("form-group"),
257 Label(Class("checkbox-label"),
258 Input(Type("checkbox"), Name("is_pinned"), Value("1"),
259 g.If(repo.IsPinned, Checked())),
260 g.Text("Pin this repository (always shown at the top of the list)")),
261 ),
262 Div(Class("form-group"),
263 Label(Class("checkbox-label"),
264 Input(Type("checkbox"), Name("allow_user_labels"), Value("1"),
265 g.If(repo.AllowUserLabels, Checked())),
266 g.Text("Allow users to add/remove labels on their own issues and patches")),
267 ),
268 Div(Class("form-group"),
269 Label(For("issue_template"), g.Text("Issue template "),
270 Span(Class("text-muted"), g.Text("(Markdown, prefilled when opening a new issue)"))),
271 Textarea(ID("issue_template"), Name("issue_template"), Rows("8"),
272 Placeholder("## Description\n\n## Steps to reproduce\n\n## Expected behavior"),
273 g.Text(deref(repo.IssueTemplate))),
274 ),
275 Div(Class("form-group"),
276 Label(For("patch_template"), g.Text("Patch template "),
277 Span(Class("text-muted"), g.Text("(Markdown, prefilled when submitting a new patch)"))),
278 Textarea(ID("patch_template"), Name("patch_template"), Rows("8"),
279 Placeholder("## Summary\n\n## Testing"),
280 g.Text(deref(repo.PatchTemplate))),
281 ),
282 Button(Type("submit"), Class("btn btn-primary"), g.Text("Save settings")),
283 ),
284 repoObjectFormatCard(repo, objectFormat, len(branches) == 0),
285 repoLabelsCard(repo, labels),
286 repoSecretsCard(repo, secrets),
287 repoDangerZone(repo),
288 ),
289 )
290}
291
292const objectFormatHint = "Your local repository must use the same format to push. SHA-256 needs git 2.29 or newer."
293
294// ObjectFormatLabel is the display name of a git hash algorithm.
295func ObjectFormatLabel(format string) string {
296 switch format {
297 case "sha1":
298 return "SHA-1"
299 case "sha256":
300 return "SHA-256"
301 }
302 return format
303}
304
305func objectFormatSelect(current string) g.Node {
306 return Select(ID("object_format"), Name("object_format"),
307 g.Map(gitcmd.ObjectFormats, func(f string) g.Node {
308 return Option(Value(f), g.If(f == current, Selected()), g.Text(ObjectFormatLabel(f)))
309 }),
310 )
311}
312
313func repoObjectFormatCard(repo *db.Repo, objectFormat string, empty bool) g.Node {
314 return Div(Class("form-card"),
315 H2(Class("section-title"), g.Text("Hash format")),
316 g.If(!empty, P(g.Text("This repository uses "+ObjectFormatLabel(objectFormat)+
317 ". The format can only change while the repository is empty."))),
318 g.If(empty, Form(Method("POST"), Action("/"+repo.Name+"/settings/object-format"),
319 Div(Class("form-group"),
320 Label(For("object_format"), g.Text("Format")),
321 objectFormatSelect(objectFormat),
322 P(Class("form-hint"), g.Text(objectFormatHint)),
323 ),
324 Button(Type("submit"), Class("btn btn-secondary"), g.Text("Change hash format")),
325 )),
326 )
327}
328
329func repoLabelsCard(repo *db.Repo, labels []db.Label) g.Node {
330 return Div(Class("form-card"),
331 H2(Class("section-title"), g.Text("Labels")),
332 g.If(len(labels) > 0, Div(Class("label-settings-list"),
333 g.Map(labels, func(label db.Label) g.Node {
334 return Div(Class("label-settings-item"),
335 Span(Class("label-settings-swatch"), Style("background:"+label.Color)),
336 Span(Class("label-settings-name"), g.Text(label.Name)),
337 Form(Method("POST"), Action("/"+repo.Name+"/settings/labels/delete"),
338 Input(Type("hidden"), Name("id"), Value(strconv.FormatInt(label.ID, 10))),
339 Button(Class("btn btn-danger btn-sm"), Type("submit"), g.Text("Delete")),
340 ),
341 )
342 }),
343 )),
344 Form(Method("POST"), Action("/"+repo.Name+"/settings/labels"), Class("label-add-form"),
345 Input(Class("form-input label-name-input"), Type("text"), Name("name"),
346 Placeholder("Label name"), MaxLength("50"), Required()),
347 Label(Class("label-color-swatch-label"), Title("Pick a color"),
348 Input(Type("color"), Name("color"), Value("#808080"), Class("label-color-input")),
349 ),
350 Button(Type("submit"), Class("btn btn-secondary btn-sm"), g.Text("Add label")),
351 ),
352 )
353}
354
355func repoSecretsCard(repo *db.Repo, secrets []db.CiSecret) g.Node {
356 return Div(Class("form-card"),
357 H2(Class("section-title"), g.Text("CI Secrets")),
358 P(Class("text-muted"), Style("font-size: var(--text-sm); margin-bottom: var(--space-3)"),
359 g.Text("Secrets are injected as environment variables into pipeline runs and masked in logs. Values are write-only — they cannot be retrieved after saving.")),
360 g.If(len(secrets) > 0, Div(Class("label-settings-list"),
361 g.Map(secrets, func(secret db.CiSecret) g.Node {
362 return Div(Class("label-settings-item"),
363 Span(Class("label-settings-name"), Code(g.Text(secret.Name))),
364 g.If(deref(secret.Description) != "",
365 Span(Class("text-muted"), g.Text(deref(secret.Description)))),
366 Span(Class("text-muted"), g.Text("●●●●●●")),
367 Form(Method("POST"), Action("/"+repo.Name+"/settings/ci-secrets/delete"),
368 Input(Type("hidden"), Name("id"), Value(strconv.FormatInt(secret.ID, 10))),
369 Button(Class("btn btn-danger btn-sm"), Type("submit"), g.Text("Delete")),
370 ),
371 )
372 }),
373 )),
374 Form(Method("POST"), Action("/"+repo.Name+"/settings/ci-secrets"), Class("label-add-form"),
375 Input(Class("form-input label-name-input"), Type("text"), Name("name"),
376 Placeholder("SECRET_NAME"), Pattern("[A-Za-z_][A-Za-z0-9_]*"), Required()),
377 Input(Class("form-input label-name-input"), Type("password"), Name("value"),
378 Placeholder("Value"), AutoComplete("off"), Required()),
379 Input(Class("form-input label-name-input"), Type("text"), Name("description"),
380 Placeholder("Description (optional)")),
381 Button(Type("submit"), Class("btn btn-secondary btn-sm"), g.Text("Save secret")),
382 ),
383 )
384}
385
386func repoDangerZone(repo *db.Repo) g.Node {
387 return Div(Class("danger-zone"),
388 H2(Class("section-title danger-title"), g.Text("Danger zone")),
389 Div(Class("form-card danger-card"),
390 Div(Class("danger-item"),
391 Div(Class("danger-item-info"),
392 Strong(g.Text("Rename this repository")),
393 P(Class("text-muted"),
394 g.Text("Changing the name breaks existing clone URLs and links to this repo. Collaborators will need to update their remotes.")),
395 ),
396 Form(Method("POST"), Action("/"+repo.Name+"/settings/rename"), Class("danger-form"),
397 Div(Class("form-group"),
398 Label(For("new_name"), g.Text("New name")),
399 Input(ID("new_name"), Name("new_name"), Type("text"), Required(),
400 Pattern(`[A-Za-z0-9._\-]+`), AutoComplete("off")),
401 ),
402 Details(Class("confirm-details"),
403 Summary(Class("btn btn-danger"), g.Text("Rename repository")),
404 Div(Class("confirm-popup"),
405 g.Text("Rename "), Span(g.Text(repo.Name)), g.Text("?"),
406 Button(Type("submit"), Class("btn btn-danger"), g.Text("Yes, rename")),
407 ),
408 ),
409 ),
410 ),
411 Div(Class("danger-item"),
412 Div(Class("danger-item-info"),
413 Strong(g.Text("Delete this repository")),
414 P(Class("text-muted"), g.Text("Once deleted, there is no going back.")),
415 ),
416 Form(Method("POST"), Action("/"+repo.Name+"/settings/delete"), Class("danger-form"),
417 Div(Class("form-group"),
418 Label(For("confirm_delete"),
419 g.Text("Type "), Code(g.Text(repo.Name)), g.Text(" to confirm deletion")),
420 Input(ID("confirm_delete"), Name("confirm_name"), Type("text"), Required(),
421 Pattern(escapeRegexp(repo.Name)), AutoComplete("off")),
422 ),
423 Details(Class("confirm-details"),
424 Summary(Class("btn btn-danger"), g.Text("Delete repository")),
425 Div(Class("confirm-popup"),
426 g.Text("Delete "), Span(g.Text(repo.Name)), g.Text("? This cannot be undone."),
427 Button(Type("submit"), Class("btn btn-danger"), g.Text("Yes, delete")),
428 ),
429 ),
430 ),
431 ),
432 ),
433 )
434}
435