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/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. |
| 17 | func 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. |
| 25 | func 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. |
| 83 | func 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. |
| 124 | func 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. |
| 135 | func 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. |
| 169 | func 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 | |
| 200 | func 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. |
| 211 | func 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. |
| 223 | func 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 | |
| 292 | const 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. |
| 295 | func 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 | |
| 305 | func 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 | |
| 313 | func 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 | |
| 329 | func 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 | |
| 355 | func 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 | |
| 386 | func 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 |