package views import ( "strconv" "hearthforge/internal/config" "hearthforge/internal/db" "hearthforge/internal/gitcmd" "hearthforge/internal/highlight" "hearthforge/internal/util" g "maragu.dev/gomponents" . "maragu.dev/gomponents/html" ) // PatchList renders the patch index with status tabs, label filter and pages. func PatchList(cfg *config.Config, user *db.SessionUser, repo *db.Repo, patches []db.Patch, status string, counts map[string]int, page PageInfo, repoLabels []db.Label, selectedLabelIDs []int64, labelsByPatchID map[int64][]db.Label, ) g.Node { labelsParam := LabelsQueryParam(selectedLabelIDs) tab := func(key, label string) g.Node { class := "list-tab" if status == key { class += " active" } return A(Href("/"+repo.Name+"/patches?status="+key+labelsParam), Class(class), g.Text(label+" "), g.If(counts[key] > 0, Span(Class("tab-count"), g.Text(strconv.Itoa(counts[key])))), ) } selected := make(map[int64]bool, len(selectedLabelIDs)) for _, id := range selectedLabelIDs { selected[id] = true } return Layout(Page{Title: "Patches — " + repo.Name, User: user, Cfg: cfg}, Div(Class("container"), RepoHeader(repo), RepoNav(cfg, repo, "patches", user), Div(Class("list-header"), Div(Class("list-header-tabs"), tab("open", "Open"), tab("merged", "Merged"), tab("closed", "Closed"), ), Div(Class("list-header-actions"), g.If(len(repoLabels) > 0, Details(Class("label-filter"), Summary(Class("btn btn-secondary btn-sm"), g.Text("Filter by label"), g.If(len(selectedLabelIDs) > 0, Span(Class("tab-count"), g.Text(strconv.Itoa(len(selectedLabelIDs))))), ), Div(Class("label-filter-popup"), Form(Method("GET"), Action("/"+repo.Name+"/patches"), Input(Type("hidden"), Name("status"), Value(status)), Div(Class("label-filter-list"), g.Map(repoLabels, func(label db.Label) g.Node { return Label(Class("label-filter-item"), Input(Type("checkbox"), Name("labels"), Value(strconv.FormatInt(label.ID, 10)), g.If(selected[label.ID], Checked())), labelBadge(label), ) }), ), Div(Class("label-filter-actions"), Button(Type("submit"), Class("btn btn-primary btn-sm"), g.Text("Apply")), A(Href("/"+repo.Name+"/patches?status="+status), Class("btn btn-secondary btn-sm"), g.Text("Clear")), ), ), ), )), g.If(user != nil, A(Href("/"+repo.Name+"/patches/new"), Class("btn btn-primary btn-sm"), g.Text("Upload patch"))), ), ), g.If(len(patches) == 0, Div(Class("empty-state"), P(g.Text("No "+status+" patches.")))), g.If(len(patches) > 0, Ul(Class("issue-list"), g.Map(patches, func(patch db.Patch) g.Node { labels := labelsByPatchID[patch.ID] return Li(Class("issue-item"), Div(Class("issue-main"), Span(Class("patch-status-dot "+patch.Status)), A(Href("/"+repo.Name+"/patches/"+strconv.FormatInt(patch.Number, 10)), Class("issue-title"), g.Text(patch.Title)), Span(Class("issue-number"), g.Text("#"+strconv.FormatInt(patch.Number, 10))), ), g.If(len(labels) > 0, Div(Class("issue-labels"), g.Map(labels, labelBadge))), Div(Class("issue-meta"), Avatar(patch.AuthorID, patch.AuthorAvatarVersion, 20), Span(g.Text("by "+util.DisplayName(deref(patch.AuthorUsername), cfg.OwnerDisplayName))), g.El("time", DateTime(patch.CreatedAt), g.Text(util.FormatDate(patch.CreatedAt))), ), ) }), )), Pagination(page), ), ) } // NewPatch renders the patch upload form. func NewPatch(cfg *config.Config, user *db.SessionUser, repo *db.Repo, errMsg, template string, labels []db.Label, ) g.Node { showLabels := len(labels) > 0 && (user.IsAdmin || repo.AllowUserLabels) return Layout(Page{Title: "New patch — " + repo.Name, User: user, Cfg: cfg}, Div(Class("container"), RepoHeader(repo), RepoNav(cfg, repo, "patches", user), H2(Class("section-title"), g.Text("Upload patch")), g.If(errMsg != "", P(Class("form-error"), g.Text(errMsg))), Form(Method("POST"), Action("/"+repo.Name+"/patches"), EncType("multipart/form-data"), Class("form-card"), Div(Class("form-group"), Label(For("title"), g.Text("Title")), Input(ID("title"), Name("title"), Type("text"), Required(), MaxLength(strconv.Itoa(cfg.MaxTitleBytes)), Placeholder("What does this patch do?")), ), Div(Class("form-group"), Label(For("description"), g.Text("Description "), Span(Class("text-muted"), g.Text("(Markdown supported, optional)"))), Textarea(ID("description"), Name("description"), Rows("5"), MaxLength(strconv.Itoa(cfg.MaxTextBodyBytes)), g.Text(template)), ), Div(Class("form-group"), Label(For("patch_file"), g.Text("Patch file "), Span(Class("text-muted"), g.Text("(.patch)"))), Input(ID("patch_file"), Name("patch_file"), Type("file"), Accept(".patch"), Required()), ), g.If(showLabels, Div(Class("form-group"), Span(Class("form-group-label"), g.Text("Labels")), Div(Class("label-checkbox-list"), g.Map(labels, func(label db.Label) g.Node { return Label(Class("label-checkbox-item"), Input(Type("checkbox"), Name("label_ids"), Value(strconv.FormatInt(label.ID, 10))), labelBadge(label), ) }), ), )), Div(Class("form-actions"), Button(Type("submit"), Class("btn btn-primary"), g.Text("Upload patch")), A(Href("/"+repo.Name+"/patches"), Class("btn"), g.Text("Cancel")), ), ), ), ) } // PatchDetail renders one patch: conversation tab or changes tab. // applyResult is nil when no apply check ran. func PatchDetail(cfg *config.Config, user *db.SessionUser, repo *db.Repo, patch *db.Patch, descriptionHTML string, applyResult *gitcmd.ApplyResult, files []highlight.RenderedDiffFile, tab string, meta gitcmd.PatchMeta, comments []ThreadComment, reactions []ReactionCount, commentReactions map[int64][]ReactionCount, patchLabels, repoLabels []db.Label, ) g.Node { isAuthor := user != nil && patch.AuthorID != nil && user.ID == *patch.AuthorID canEdit := user != nil && (user.IsAdmin || (isAuthor && patch.Status == "open")) canManageLabels := user != nil && (user.IsAdmin || (repo.AllowUserLabels && isAuthor)) isAdmin := user != nil && user.IsAdmin baseURL := "/" + repo.Name + "/patches/" + strconv.FormatInt(patch.Number, 10) closeLabel := "Reopen patch" if patch.Status == "open" { closeLabel = "Close patch" } return Layout(Page{Title: patch.Title + " — " + repo.Name, User: user, Cfg: cfg}, Div(Class("container"), RepoHeader(repo), RepoNav(cfg, repo, "patches", user), Div(Class("issue-detail"), Div(Class("issue-detail-header"), Span(Class("issue-number"), g.Text("#"+strconv.FormatInt(patch.Number, 10))), EditableTitle(cfg, patch.Title, canEdit, baseURL+"/edit", "edit_description", patch.Description), Span(Class("patch-badge "+patch.Status), g.Text(patch.Status)), g.If(canEdit, Div(Class("issue-detail-meta-actions"), g.If(isAdmin && patch.Status == "open" && applyResult != nil && applyResult.Status == "clean", Form(Method("POST"), Action(baseURL+"/merge"), Class("inline-form"), Input(Type("hidden"), Name("version"), Value(patch.Version)), Button(Type("submit"), Class("btn btn-sm btn-primary"), g.Text("Merge patch")), )), g.If(patch.Status == "open", Details(Class("confirm-details"), Summary(Class("btn btn-sm"), g.Text("Update patch file")), Div(Class("confirm-popup"), Form(Method("POST"), Action(baseURL+"/upload"), EncType("multipart/form-data"), Class("upload-patch-form"), Input(Type("file"), Name("patch_file"), Accept(".patch"), Required(), Class("form-input")), Button(Type("submit"), Class("btn btn-sm btn-primary"), g.Text("Upload")), ), ), )), g.If(isAdmin && patch.Status != "merged", Form(Method("POST"), Action(baseURL+"/close"), Class("inline-form"), Button(Type("submit"), Class("btn btn-sm"), g.Text(closeLabel)), )), Details(Class("confirm-details"), Summary(Class("btn btn-sm btn-danger"), g.Text("Delete")), Div(Class("confirm-popup"), g.Text("Permanently delete this patch?"), Form(Method("POST"), Action(baseURL+"/delete"), Class("inline-form"), Button(Type("submit"), Class("btn btn-sm btn-danger"), g.Text("Yes, delete")), ), ), ), )), ), LabelBadges(patchLabels, repoLabels, canManageLabels, baseURL), ), Div(Class("patch-tab-nav"), A(Href(baseURL), Class(activeTabClass(tab == "conversation")), g.Text("Conversation "), g.If(len(comments) > 0, Span(Class("tab-count"), g.Text(strconv.Itoa(len(comments))))), ), A(Href(baseURL+"?tab=changes"), Class(activeTabClass(tab == "changes")), g.Text("Changes "), g.If(len(files) > 0, Span(Class("tab-count"), g.Text(strconv.Itoa(len(files))))), ), ), g.If(tab == "conversation", Div(Class("issue-detail"), Div(Class("timeline-item"), Div(Class("timeline-author"), Avatar(patch.AuthorID, patch.AuthorAvatarVersion, 24), Strong(g.Text(util.DisplayName(deref(patch.AuthorUsername), cfg.OwnerDisplayName))), Div(Class("timeline-author-right"), g.If(canEdit, Details(Class("inline-edit-details"), Summary(Class("btn btn-xs"), Span(Class("when-closed"), g.Text("Edit")), Span(Class("when-open"), g.Text("Stop editing")), ), )), DateWithEdited(patch.CreatedAt, patch.EditedAt), ), ), g.If(canEdit, Div(Class("inline-edit-form-area"), Form(Method("POST"), Action(baseURL+"/edit"), Class("inline-edit-form"), Input(Type("hidden"), Name("title"), Value(patch.Title)), Div(Class("form-group"), Textarea(Class("form-input"), ID("edit-patch-desc"), Name("edit_description"), Rows("6"), MaxLength(strconv.Itoa(cfg.MaxTextBodyBytes)), g.Text(patch.Description)), ), Div(Class("form-actions"), Button(Type("submit"), Class("btn btn-sm btn-primary"), g.Text("Save")), ), ), )), // The markdown renderer already sanitised this HTML. Div(Class("timeline-body markdown-body"), g.If(descriptionHTML != "", g.Raw(descriptionHTML)), g.If(descriptionHTML == "", Em(Class("text-muted"), g.Text("No description provided."))), ), ReactionBar(reactions, baseURL+"/react", 0, user), ), g.Iff(applyResult != nil, func() g.Node { return applyStatus(applyResult) }), CommentThread(cfg, user, comments, commentReactions, baseURL, patch.Status), )), g.If(tab == "changes", Div( Div(Class("commit-card"), H2(Class(commitSubjectClass(meta.Subject)), g.Text(commitSubject(meta.Subject))), g.If(meta.Body != "", Pre(Class("commit-card-body"), g.Text(meta.Body))), Div(Class("commit-card-meta"), Div(Class("commit-card-meta-row"), Span(Class("commit-meta-label"), g.Text("Author")), Span(Class("commit-meta-value"), g.Text(meta.Author+" <"+meta.Email+">")), ), Div(Class("commit-card-meta-row"), Span(Class("commit-meta-label"), g.Text("Date")), g.El("time", Class("commit-meta-value"), DateTime(meta.Date), g.Text(util.FormatDateTime(meta.Date))), ), ), ), DiffView(files, repo, ""), )), ), ) } // applyStatus renders the "applies cleanly" or "has conflicts" banner. func applyStatus(result *gitcmd.ApplyResult) g.Node { icon, text := "✗", "Has conflicts" if result.Status == "clean" { icon, text = "✓", "Applies cleanly" } return Div(Class("timeline-item"), Div(Class("apply-result apply-"+result.Status), Div( Span(Class("apply-icon"), g.Text(icon)), Span(g.Text(text)), ), g.If(result.Output != "", Pre(Class("apply-output"), g.Text(result.Output))), ), ) } func activeTabClass(active bool) string { if active { return "repo-tab active" } return "repo-tab" } func commitSubjectClass(subject string) string { if subject == "" { return "commit-card-subject commit-card-subject-empty" } return "commit-card-subject" } func commitSubject(subject string) string { if subject == "" { return "No commit message" } return subject }