repos_shell.go
| 1 | package views |
| 2 | |
| 3 | import ( |
| 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. |
| 16 | func 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. |
| 24 | func 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. |
| 82 | func 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. |
| 118 | func 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. |
| 129 | func 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. |
| 159 | func 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 | |
| 190 | func 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. |
| 201 | func 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. |
| 213 | func 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 | |
| 281 | func 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 | |
| 307 | func 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 | |
| 338 | func 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 |