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