package views import ( "strconv" "strings" "hearthforge/internal/config" "hearthforge/internal/db" "hearthforge/internal/util" g "maragu.dev/gomponents" . "maragu.dev/gomponents/html" ) // deref returns the string a pointer holds, or "" for nil. func deref(s *string) string { if s == nil { return "" } return *s } // RepoList renders the repository index with search, sort and pagination. func RepoList(cfg *config.Config, user *db.SessionUser, repos []db.Repo, search, sort string, page PageInfo, ) g.Node { return Layout(Page{Title: "Repositories", User: user, Cfg: cfg}, Div(Class("container"), Div(Class("page-header"), H1(Class("page-title"), g.Text("Repositories")), g.If(user != nil && user.IsAdmin, A(Href("/new"), Class("btn btn-primary"), g.Text("New repository"))), ), Div(Class("search-row"), Form(Method("GET"), Action("/"), Class("search-form"), Div(Class("search-input-wrap"), Input(Type("search"), Name("q"), Value(search), Placeholder("Search repositories…"), Class("search-input"), AutoComplete("off")), Button(Type("submit"), Class("btn btn-sm"), g.Text("Search")), ), ), Form(Method("POST"), Action("/sort"), Select(Name("sort"), Class("repo-sort-select"), g.Attr("data-autosubmit"), Option(Value("created"), g.If(sort == "created", Selected()), g.Text("Newest first")), Option(Value("name"), g.If(sort == "name", Selected()), g.Text("Name (A–Z)")), ), NoScript(Button(Type("submit"), Class("btn btn-sm"), g.Text("Go"))), ), ), g.If(len(repos) == 0, Div(Class("empty-state"), g.If(search != "", P(g.Text("No repositories match "), Strong(g.Text(search)), g.Text("."))), g.If(search == "", P(g.Text("No repositories yet."))), )), g.If(len(repos) > 0, Ul(Class("repo-list"), g.Map(repos, func(repo db.Repo) g.Node { return Li(Class("repo-card"), Div(Class("repo-card-main"), Div(Class("repo-card-title"), A(Href("/"+repo.Name), Class("repo-name"), g.Text(repo.Name)), g.If(repo.IsPinned, Span(Class("badge badge-pinned"), g.Text("Pinned"))), g.If(repo.IsPrivate, Span(Class("badge badge-private"), g.Text("Private"))), ), g.If(deref(repo.Description) != "", P(Class("repo-description"), g.Text(deref(repo.Description)))), ), Div(Class("repo-card-meta"), g.El("time", Class("repo-date"), DateTime(repo.CreatedAt), g.Text(util.FormatDate(repo.CreatedAt))), ), ) }), )), Pagination(page), ), ) } // NewRepo renders the repository creation form. func NewRepo(cfg *config.Config, user *db.SessionUser, errMsg string) g.Node { return Layout(Page{Title: "New repository", User: user, Cfg: cfg}, Div(Class("container container-narrow"), H1(Class("page-title"), g.Text("Create new repository")), g.If(errMsg != "", P(Class("form-error"), g.Text(errMsg))), Form(Method("POST"), Action("/new"), Class("form-card"), Div(Class("form-group"), Label(For("name"), g.Text("Repository name")), Input(ID("name"), Name("name"), Type("text"), Required(), Pattern("[a-zA-Z0-9._-]+"), Title("Letters, numbers, dots, hyphens, underscores"), Placeholder("my-project")), ), Div(Class("form-group"), Label(For("description"), g.Text("Description "), Span(Class("text-muted"), g.Text("(optional)"))), Input(ID("description"), Name("description"), Type("text"), Placeholder("A short description")), ), Div(Class("form-group"), Label(For("default_branch"), g.Text("Default branch")), Input(ID("default_branch"), Name("default_branch"), Type("text"), Value("main"), Placeholder("main")), ), Div(Class("form-group"), Label(Class("checkbox-label"), Input(Type("checkbox"), Name("is_private"), Value("1")), g.Text("Private repository (only visible to you)")), ), Button(Type("submit"), Class("btn btn-primary"), g.Text("Create repository")), ), ), ) } // RepoHeader renders the repository name and its private badge. func RepoHeader(repo *db.Repo) g.Node { return Div(Class("repo-header"), Div(Class("repo-title-row"), H1(Class("page-title"), A(Href("/"+repo.Name), g.Text(repo.Name))), g.If(repo.IsPrivate, Span(Class("badge badge-private"), g.Text("Private"))), ), ) } // RepoNav renders the repository tab bar. active is the key of the current tab, // one of code, branches, tags, commits, issues, patches, releases, ci, settings. func RepoNav(repo *db.Repo, active string, user *db.SessionUser) g.Node { tabs := []struct{ key, label, href string }{ {"code", "Code", "/" + repo.Name}, {"branches", "Branches", "/" + repo.Name + "/branches"}, {"tags", "Tags", "/" + repo.Name + "/tags"}, {"commits", "Commits", "/" + repo.Name + "/commits/" + EscapePath(repo.DefaultBranch)}, {"issues", "Issues", "/" + repo.Name + "/issues"}, {"patches", "Patches", "/" + repo.Name + "/patches"}, {"releases", "Releases", "/" + repo.Name + "/releases"}, {"ci", "Pipelines", "/" + repo.Name + "/ci"}, } tabClass := func(key string) string { if active == key { return "repo-tab active" } return "repo-tab" } return Div(Class("repo-nav-bar"), Nav(Class("repo-tabs"), g.Map(tabs, func(t struct{ key, label, href string }) g.Node { return A(Href(t.href), Class(tabClass(t.key)), g.Text(t.label)) }), g.If(user != nil && user.IsAdmin, A(Href("/"+repo.Name+"/settings"), Class(tabClass("settings")), g.Text("Settings"))), ), ) } // BranchSelector renders the ref switcher. view is tree, commits or blob. // path is the subpath for tree and the file path for blob. func BranchSelector(repoName string, branches, tags []string, currentRef, view, path string) g.Node { if len(branches) == 0 && len(tags) == 0 { return nil } isDetached := !slicesContains(branches, currentRef) && !slicesContains(tags, currentRef) shortRef := currentRef if isDetached && len(currentRef) > 8 { shortRef = currentRef[:8] } return Form(Method("GET"), Action("/"+repoName+"/branch-switch"), Class("branch-selector"), Input(Type("hidden"), Name("view"), Value(view)), g.If(path != "", Input(Type("hidden"), Name("path"), Value(path))), Span(Class("branch-selector-icon"), g.Text("⎇")), Select(Name("rev"), Class("branch-select"), g.Attr("data-autosubmit"), g.If(isDetached, Option(Value(currentRef), Selected(), g.Text(shortRef+" (detached)"))), OptGroup(LabelAttr("Branches"), g.Map(branches, func(b string) g.Node { return Option(Value(b), g.If(b == currentRef, Selected()), g.Text(b)) }), ), g.If(len(tags) > 0, OptGroup(LabelAttr("Tags"), g.Map(tags, func(t string) g.Node { return Option(Value(t), g.If(t == currentRef, Selected()), g.Text(t)) }), )), ), NoScript(Button(Type("submit"), Class("btn btn-sm"), g.Text("Go"))), ) } func slicesContains(list []string, want string) bool { for _, v := range list { if v == want { return true } } return false } // escapeRegexp escapes the characters the HTML pattern attribute treats as // regular expression syntax. func escapeRegexp(s string) string { var b strings.Builder for _, r := range s { if strings.ContainsRune(`.*+?^${}()|[]\`, r) { b.WriteRune('\\') } b.WriteRune(r) } return b.String() } // RepoSettings renders the repository settings page. func RepoSettings(cfg *config.Config, user *db.SessionUser, repo *db.Repo, branches []string, labels []db.Label, secrets []db.CiSecret, success, errMsg string, ) g.Node { return Layout(Page{Title: "Settings — " + repo.Name, User: user, Cfg: cfg}, Div(Class("container"), RepoHeader(repo), RepoNav(repo, "settings", user), g.If(success != "", P(Class("form-success"), g.Text(success))), g.If(errMsg != "", P(Class("form-error"), g.Text(errMsg))), Form(Method("POST"), Action("/"+repo.Name+"/settings"), Class("form-card"), Div(Class("form-group"), Label(For("description"), g.Text("Description")), Input(ID("description"), Name("description"), Type("text"), Value(deref(repo.Description)), Placeholder("A short description")), ), Div(Class("form-group"), Label(For("default_branch"), g.Text("Default branch")), g.If(len(branches) > 0, Select(ID("default_branch"), Name("default_branch"), Class("branch-select"), g.Map(branches, func(b string) g.Node { return Option(Value(b), g.If(b == repo.DefaultBranch, Selected()), g.Text(b)) }), )), g.If(len(branches) == 0, P(Class("form-hint"), g.Text("No branches yet — push your first commit to set the default branch."))), ), Div(Class("form-group"), Label(Class("checkbox-label"), Input(Type("checkbox"), Name("is_private"), Value("1"), g.If(repo.IsPrivate, Checked())), g.Text("Private repository (only visible to you)")), ), Div(Class("form-group"), Label(Class("checkbox-label"), Input(Type("checkbox"), Name("is_pinned"), Value("1"), g.If(repo.IsPinned, Checked())), g.Text("Pin this repository (always shown at the top of the list)")), ), Div(Class("form-group"), Label(Class("checkbox-label"), Input(Type("checkbox"), Name("allow_user_labels"), Value("1"), g.If(repo.AllowUserLabels, Checked())), g.Text("Allow users to add/remove labels on their own issues and patches")), ), Div(Class("form-group"), Label(For("issue_template"), g.Text("Issue template "), Span(Class("text-muted"), g.Text("(Markdown, prefilled when opening a new issue)"))), Textarea(ID("issue_template"), Name("issue_template"), Rows("8"), Placeholder("## Description\n\n## Steps to reproduce\n\n## Expected behavior"), g.Text(deref(repo.IssueTemplate))), ), Div(Class("form-group"), Label(For("patch_template"), g.Text("Patch template "), Span(Class("text-muted"), g.Text("(Markdown, prefilled when submitting a new patch)"))), Textarea(ID("patch_template"), Name("patch_template"), Rows("8"), Placeholder("## Summary\n\n## Testing"), g.Text(deref(repo.PatchTemplate))), ), Button(Type("submit"), Class("btn btn-primary"), g.Text("Save settings")), ), repoLabelsCard(repo, labels), repoSecretsCard(repo, secrets), repoDangerZone(repo), ), ) } func repoLabelsCard(repo *db.Repo, labels []db.Label) g.Node { return Div(Class("form-card"), H2(Class("section-title"), g.Text("Labels")), g.If(len(labels) > 0, Div(Class("label-settings-list"), g.Map(labels, func(label db.Label) g.Node { return Div(Class("label-settings-item"), Span(Class("label-settings-swatch"), Style("background:"+label.Color)), Span(Class("label-settings-name"), g.Text(label.Name)), Form(Method("POST"), Action("/"+repo.Name+"/settings/labels/delete"), Input(Type("hidden"), Name("id"), Value(strconv.FormatInt(label.ID, 10))), Button(Class("btn btn-danger btn-sm"), Type("submit"), g.Text("Delete")), ), ) }), )), Form(Method("POST"), Action("/"+repo.Name+"/settings/labels"), Class("label-add-form"), Input(Class("form-input label-name-input"), Type("text"), Name("name"), Placeholder("Label name"), MaxLength("50"), Required()), Label(Class("label-color-swatch-label"), Title("Pick a color"), Input(Type("color"), Name("color"), Value("#808080"), Class("label-color-input")), ), Button(Type("submit"), Class("btn btn-secondary btn-sm"), g.Text("Add label")), ), ) } func repoSecretsCard(repo *db.Repo, secrets []db.CiSecret) g.Node { return Div(Class("form-card"), H2(Class("section-title"), g.Text("CI Secrets")), P(Class("text-muted"), Style("font-size: var(--text-sm); margin-bottom: var(--space-3)"), 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.")), g.If(len(secrets) > 0, Div(Class("label-settings-list"), g.Map(secrets, func(secret db.CiSecret) g.Node { return Div(Class("label-settings-item"), Span(Class("label-settings-name"), Code(g.Text(secret.Name))), g.If(deref(secret.Description) != "", Span(Class("text-muted"), g.Text(deref(secret.Description)))), Span(Class("text-muted"), g.Text("●●●●●●")), Form(Method("POST"), Action("/"+repo.Name+"/settings/ci-secrets/delete"), Input(Type("hidden"), Name("id"), Value(strconv.FormatInt(secret.ID, 10))), Button(Class("btn btn-danger btn-sm"), Type("submit"), g.Text("Delete")), ), ) }), )), Form(Method("POST"), Action("/"+repo.Name+"/settings/ci-secrets"), Class("label-add-form"), Input(Class("form-input label-name-input"), Type("text"), Name("name"), Placeholder("SECRET_NAME"), Pattern("[A-Za-z_][A-Za-z0-9_]*"), Required()), Input(Class("form-input label-name-input"), Type("password"), Name("value"), Placeholder("Value"), AutoComplete("off"), Required()), Input(Class("form-input label-name-input"), Type("text"), Name("description"), Placeholder("Description (optional)")), Button(Type("submit"), Class("btn btn-secondary btn-sm"), g.Text("Save secret")), ), ) } func repoDangerZone(repo *db.Repo) g.Node { return Div(Class("danger-zone"), H2(Class("section-title danger-title"), g.Text("Danger zone")), Div(Class("form-card danger-card"), Div(Class("danger-item"), Div(Class("danger-item-info"), Strong(g.Text("Rename this repository")), P(Class("text-muted"), g.Text("Changing the name breaks existing clone URLs and links to this repo. Collaborators will need to update their remotes.")), ), Form(Method("POST"), Action("/"+repo.Name+"/settings/rename"), Class("danger-form"), Div(Class("form-group"), Label(For("new_name"), g.Text("New name")), Input(ID("new_name"), Name("new_name"), Type("text"), Required(), Pattern(`[A-Za-z0-9._\-]+`), AutoComplete("off")), ), Details(Class("confirm-details"), Summary(Class("btn btn-danger"), g.Text("Rename repository")), Div(Class("confirm-popup"), g.Text("Rename "), Span(g.Text(repo.Name)), g.Text("?"), Button(Type("submit"), Class("btn btn-danger"), g.Text("Yes, rename")), ), ), ), ), Div(Class("danger-item"), Div(Class("danger-item-info"), Strong(g.Text("Delete this repository")), P(Class("text-muted"), g.Text("Once deleted, there is no going back.")), ), Form(Method("POST"), Action("/"+repo.Name+"/settings/delete"), Class("danger-form"), Div(Class("form-group"), Label(For("confirm_delete"), g.Text("Type "), Code(g.Text(repo.Name)), g.Text(" to confirm deletion")), Input(ID("confirm_delete"), Name("confirm_name"), Type("text"), Required(), Pattern(escapeRegexp(repo.Name)), AutoComplete("off")), ), Details(Class("confirm-details"), Summary(Class("btn btn-danger"), g.Text("Delete repository")), Div(Class("confirm-popup"), g.Text("Delete "), Span(g.Text(repo.Name)), g.Text("? This cannot be undone."), Button(Type("submit"), Class("btn btn-danger"), g.Text("Yes, delete")), ), ), ), ), ), ) }