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(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 tabClass := func(key string) string {
141 if active == key {
142 return "repo-tab active"
143 }
144 return "repo-tab"
145 }
146 return Div(Class("repo-nav-bar"),
147 Nav(Class("repo-tabs"),
148 g.Map(tabs, func(t struct{ key, label, href string }) g.Node {
149 return A(Href(t.href), Class(tabClass(t.key)), g.Text(t.label))
150 }),
151 g.If(user != nil && user.IsAdmin,
152 A(Href("/"+repo.Name+"/settings"), Class(tabClass("settings")), g.Text("Settings"))),
153 ),
154 )
155}
156
157// BranchSelector renders the ref switcher. view is tree, commits or blob.
158// path is the subpath for tree and the file path for blob.
159func BranchSelector(repoName string, branches, tags []string, currentRef, view, path string) g.Node {
160 if len(branches) == 0 && len(tags) == 0 {
161 return nil
162 }
163 isDetached := !slicesContains(branches, currentRef) && !slicesContains(tags, currentRef)
164 shortRef := currentRef
165 if isDetached && len(currentRef) > 8 {
166 shortRef = currentRef[:8]
167 }
168 return Form(Method("GET"), Action("/"+repoName+"/branch-switch"), Class("branch-selector"),
169 Input(Type("hidden"), Name("view"), Value(view)),
170 g.If(path != "", Input(Type("hidden"), Name("path"), Value(path))),
171 Span(Class("branch-selector-icon"), g.Text("⎇")),
172 Select(Name("rev"), Class("branch-select"), g.Attr("data-autosubmit"),
173 g.If(isDetached,
174 Option(Value(currentRef), Selected(), g.Text(shortRef+" (detached)"))),
175 OptGroup(LabelAttr("Branches"),
176 g.Map(branches, func(b string) g.Node {
177 return Option(Value(b), g.If(b == currentRef, Selected()), g.Text(b))
178 }),
179 ),
180 g.If(len(tags) > 0, OptGroup(LabelAttr("Tags"),
181 g.Map(tags, func(t string) g.Node {
182 return Option(Value(t), g.If(t == currentRef, Selected()), g.Text(t))
183 }),
184 )),
185 ),
186 NoScript(Button(Type("submit"), Class("btn btn-sm"), g.Text("Go"))),
187 )
188}
189
190func slicesContains(list []string, want string) bool {
191 for _, v := range list {
192 if v == want {
193 return true
194 }
195 }
196 return false
197}
198
199// escapeRegexp escapes the characters the HTML pattern attribute treats as
200// regular expression syntax.
201func escapeRegexp(s string) string {
202 var b strings.Builder
203 for _, r := range s {
204 if strings.ContainsRune(`.*+?^${}()|[]\`, r) {
205 b.WriteRune('\\')
206 }
207 b.WriteRune(r)
208 }
209 return b.String()
210}
211
212// RepoSettings renders the repository settings page.
213func RepoSettings(cfg *config.Config, user *db.SessionUser, repo *db.Repo, branches []string,
214 labels []db.Label, secrets []db.CiSecret, success, errMsg string,
215) g.Node {
216 return Layout(Page{Title: "Settings — " + repo.Name, User: user, Cfg: cfg},
217 Div(Class("container"),
218 RepoHeader(repo),
219 RepoNav(repo, "settings", user),
220 g.If(success != "", P(Class("form-success"), g.Text(success))),
221 g.If(errMsg != "", P(Class("form-error"), g.Text(errMsg))),
222 Form(Method("POST"), Action("/"+repo.Name+"/settings"), Class("form-card"),
223 Div(Class("form-group"),
224 Label(For("description"), g.Text("Description")),
225 Input(ID("description"), Name("description"), Type("text"),
226 Value(deref(repo.Description)), Placeholder("A short description")),
227 ),
228 Div(Class("form-group"),
229 Label(For("default_branch"), g.Text("Default branch")),
230 g.If(len(branches) > 0,
231 Select(ID("default_branch"), Name("default_branch"), Class("branch-select"),
232 g.Map(branches, func(b string) g.Node {
233 return Option(Value(b), g.If(b == repo.DefaultBranch, Selected()), g.Text(b))
234 }),
235 )),
236 g.If(len(branches) == 0,
237 P(Class("form-hint"),
238 g.Text("No branches yet — push your first commit to set the default branch."))),
239 ),
240 Div(Class("form-group"),
241 Label(Class("checkbox-label"),
242 Input(Type("checkbox"), Name("is_private"), Value("1"),
243 g.If(repo.IsPrivate, Checked())),
244 g.Text("Private repository (only visible to you)")),
245 ),
246 Div(Class("form-group"),
247 Label(Class("checkbox-label"),
248 Input(Type("checkbox"), Name("is_pinned"), Value("1"),
249 g.If(repo.IsPinned, Checked())),
250 g.Text("Pin this repository (always shown at the top of the list)")),
251 ),
252 Div(Class("form-group"),
253 Label(Class("checkbox-label"),
254 Input(Type("checkbox"), Name("allow_user_labels"), Value("1"),
255 g.If(repo.AllowUserLabels, Checked())),
256 g.Text("Allow users to add/remove labels on their own issues and patches")),
257 ),
258 Div(Class("form-group"),
259 Label(For("issue_template"), g.Text("Issue template "),
260 Span(Class("text-muted"), g.Text("(Markdown, prefilled when opening a new issue)"))),
261 Textarea(ID("issue_template"), Name("issue_template"), Rows("8"),
262 Placeholder("## Description\n\n## Steps to reproduce\n\n## Expected behavior"),
263 g.Text(deref(repo.IssueTemplate))),
264 ),
265 Div(Class("form-group"),
266 Label(For("patch_template"), g.Text("Patch template "),
267 Span(Class("text-muted"), g.Text("(Markdown, prefilled when submitting a new patch)"))),
268 Textarea(ID("patch_template"), Name("patch_template"), Rows("8"),
269 Placeholder("## Summary\n\n## Testing"),
270 g.Text(deref(repo.PatchTemplate))),
271 ),
272 Button(Type("submit"), Class("btn btn-primary"), g.Text("Save settings")),
273 ),
274 repoLabelsCard(repo, labels),
275 repoSecretsCard(repo, secrets),
276 repoDangerZone(repo),
277 ),
278 )
279}
280
281func repoLabelsCard(repo *db.Repo, labels []db.Label) g.Node {
282 return Div(Class("form-card"),
283 H2(Class("section-title"), g.Text("Labels")),
284 g.If(len(labels) > 0, Div(Class("label-settings-list"),
285 g.Map(labels, func(label db.Label) g.Node {
286 return Div(Class("label-settings-item"),
287 Span(Class("label-settings-swatch"), Style("background:"+label.Color)),
288 Span(Class("label-settings-name"), g.Text(label.Name)),
289 Form(Method("POST"), Action("/"+repo.Name+"/settings/labels/delete"),
290 Input(Type("hidden"), Name("id"), Value(strconv.FormatInt(label.ID, 10))),
291 Button(Class("btn btn-danger btn-sm"), Type("submit"), g.Text("Delete")),
292 ),
293 )
294 }),
295 )),
296 Form(Method("POST"), Action("/"+repo.Name+"/settings/labels"), Class("label-add-form"),
297 Input(Class("form-input label-name-input"), Type("text"), Name("name"),
298 Placeholder("Label name"), MaxLength("50"), Required()),
299 Label(Class("label-color-swatch-label"), Title("Pick a color"),
300 Input(Type("color"), Name("color"), Value("#808080"), Class("label-color-input")),
301 ),
302 Button(Type("submit"), Class("btn btn-secondary btn-sm"), g.Text("Add label")),
303 ),
304 )
305}
306
307func repoSecretsCard(repo *db.Repo, secrets []db.CiSecret) g.Node {
308 return Div(Class("form-card"),
309 H2(Class("section-title"), g.Text("CI Secrets")),
310 P(Class("text-muted"), Style("font-size: var(--text-sm); margin-bottom: var(--space-3)"),
311 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.")),
312 g.If(len(secrets) > 0, Div(Class("label-settings-list"),
313 g.Map(secrets, func(secret db.CiSecret) g.Node {
314 return Div(Class("label-settings-item"),
315 Span(Class("label-settings-name"), Code(g.Text(secret.Name))),
316 g.If(deref(secret.Description) != "",
317 Span(Class("text-muted"), g.Text(deref(secret.Description)))),
318 Span(Class("text-muted"), g.Text("●●●●●●")),
319 Form(Method("POST"), Action("/"+repo.Name+"/settings/ci-secrets/delete"),
320 Input(Type("hidden"), Name("id"), Value(strconv.FormatInt(secret.ID, 10))),
321 Button(Class("btn btn-danger btn-sm"), Type("submit"), g.Text("Delete")),
322 ),
323 )
324 }),
325 )),
326 Form(Method("POST"), Action("/"+repo.Name+"/settings/ci-secrets"), Class("label-add-form"),
327 Input(Class("form-input label-name-input"), Type("text"), Name("name"),
328 Placeholder("SECRET_NAME"), Pattern("[A-Za-z_][A-Za-z0-9_]*"), Required()),
329 Input(Class("form-input label-name-input"), Type("password"), Name("value"),
330 Placeholder("Value"), AutoComplete("off"), Required()),
331 Input(Class("form-input label-name-input"), Type("text"), Name("description"),
332 Placeholder("Description (optional)")),
333 Button(Type("submit"), Class("btn btn-secondary btn-sm"), g.Text("Save secret")),
334 ),
335 )
336}
337
338func repoDangerZone(repo *db.Repo) g.Node {
339 return Div(Class("danger-zone"),
340 H2(Class("section-title danger-title"), g.Text("Danger zone")),
341 Div(Class("form-card danger-card"),
342 Div(Class("danger-item"),
343 Div(Class("danger-item-info"),
344 Strong(g.Text("Rename this repository")),
345 P(Class("text-muted"),
346 g.Text("Changing the name breaks existing clone URLs and links to this repo. Collaborators will need to update their remotes.")),
347 ),
348 Form(Method("POST"), Action("/"+repo.Name+"/settings/rename"), Class("danger-form"),
349 Div(Class("form-group"),
350 Label(For("new_name"), g.Text("New name")),
351 Input(ID("new_name"), Name("new_name"), Type("text"), Required(),
352 Pattern(`[A-Za-z0-9._\-]+`), AutoComplete("off")),
353 ),
354 Details(Class("confirm-details"),
355 Summary(Class("btn btn-danger"), g.Text("Rename repository")),
356 Div(Class("confirm-popup"),
357 g.Text("Rename "), Span(g.Text(repo.Name)), g.Text("?"),
358 Button(Type("submit"), Class("btn btn-danger"), g.Text("Yes, rename")),
359 ),
360 ),
361 ),
362 ),
363 Div(Class("danger-item"),
364 Div(Class("danger-item-info"),
365 Strong(g.Text("Delete this repository")),
366 P(Class("text-muted"), g.Text("Once deleted, there is no going back.")),
367 ),
368 Form(Method("POST"), Action("/"+repo.Name+"/settings/delete"), Class("danger-form"),
369 Div(Class("form-group"),
370 Label(For("confirm_delete"),
371 g.Text("Type "), Code(g.Text(repo.Name)), g.Text(" to confirm deletion")),
372 Input(ID("confirm_delete"), Name("confirm_name"), Type("text"), Required(),
373 Pattern(escapeRegexp(repo.Name)), AutoComplete("off")),
374 ),
375 Details(Class("confirm-details"),
376 Summary(Class("btn btn-danger"), g.Text("Delete repository")),
377 Div(Class("confirm-popup"),
378 g.Text("Delete "), Span(g.Text(repo.Name)), g.Text("? This cannot be undone."),
379 Button(Type("submit"), Class("btn btn-danger"), g.Text("Yes, delete")),
380 ),
381 ),
382 ),
383 ),
384 ),
385 )
386}
387