patches.go
| 1 | package views |
| 2 | |
| 3 | import ( |
| 4 | "strconv" |
| 5 | |
| 6 | "hearthforge/internal/config" |
| 7 | "hearthforge/internal/db" |
| 8 | "hearthforge/internal/gitcmd" |
| 9 | "hearthforge/internal/highlight" |
| 10 | "hearthforge/internal/util" |
| 11 | |
| 12 | g "maragu.dev/gomponents" |
| 13 | . "maragu.dev/gomponents/html" |
| 14 | ) |
| 15 | |
| 16 | // PatchList renders the patch index with status tabs, label filter and pages. |
| 17 | func PatchList(cfg *config.Config, user *db.SessionUser, repo *db.Repo, patches []db.Patch, |
| 18 | status string, counts map[string]int, page PageInfo, repoLabels []db.Label, |
| 19 | selectedLabelIDs []int64, labelsByPatchID map[int64][]db.Label, |
| 20 | ) g.Node { |
| 21 | labelsParam := LabelsQueryParam(selectedLabelIDs) |
| 22 | tab := func(key, label string) g.Node { |
| 23 | class := "list-tab" |
| 24 | if status == key { |
| 25 | class += " active" |
| 26 | } |
| 27 | return A(Href("/"+repo.Name+"/patches?status="+key+labelsParam), Class(class), |
| 28 | g.Text(label+" "), |
| 29 | g.If(counts[key] > 0, Span(Class("tab-count"), g.Text(strconv.Itoa(counts[key])))), |
| 30 | ) |
| 31 | } |
| 32 | selected := make(map[int64]bool, len(selectedLabelIDs)) |
| 33 | for _, id := range selectedLabelIDs { |
| 34 | selected[id] = true |
| 35 | } |
| 36 | return Layout(Page{Title: "Patches — " + repo.Name, User: user, Cfg: cfg}, |
| 37 | Div(Class("container"), |
| 38 | RepoHeader(repo), |
| 39 | RepoNav(cfg, repo, "patches", user), |
| 40 | Div(Class("list-header"), |
| 41 | Div(Class("list-header-tabs"), |
| 42 | tab("open", "Open"), |
| 43 | tab("merged", "Merged"), |
| 44 | tab("closed", "Closed"), |
| 45 | ), |
| 46 | Div(Class("list-header-actions"), |
| 47 | g.If(len(repoLabels) > 0, Details(Class("label-filter"), |
| 48 | Summary(Class("btn btn-secondary btn-sm"), |
| 49 | g.Text("Filter by label"), |
| 50 | g.If(len(selectedLabelIDs) > 0, |
| 51 | Span(Class("tab-count"), g.Text(strconv.Itoa(len(selectedLabelIDs))))), |
| 52 | ), |
| 53 | Div(Class("label-filter-popup"), |
| 54 | Form(Method("GET"), Action("/"+repo.Name+"/patches"), |
| 55 | Input(Type("hidden"), Name("status"), Value(status)), |
| 56 | Div(Class("label-filter-list"), |
| 57 | g.Map(repoLabels, func(label db.Label) g.Node { |
| 58 | return Label(Class("label-filter-item"), |
| 59 | Input(Type("checkbox"), Name("labels"), |
| 60 | Value(strconv.FormatInt(label.ID, 10)), |
| 61 | g.If(selected[label.ID], Checked())), |
| 62 | labelBadge(label), |
| 63 | ) |
| 64 | }), |
| 65 | ), |
| 66 | Div(Class("label-filter-actions"), |
| 67 | Button(Type("submit"), Class("btn btn-primary btn-sm"), g.Text("Apply")), |
| 68 | A(Href("/"+repo.Name+"/patches?status="+status), |
| 69 | Class("btn btn-secondary btn-sm"), g.Text("Clear")), |
| 70 | ), |
| 71 | ), |
| 72 | ), |
| 73 | )), |
| 74 | g.If(user != nil, A(Href("/"+repo.Name+"/patches/new"), |
| 75 | Class("btn btn-primary btn-sm"), g.Text("Upload patch"))), |
| 76 | ), |
| 77 | ), |
| 78 | g.If(len(patches) == 0, Div(Class("empty-state"), |
| 79 | P(g.Text("No "+status+" patches.")))), |
| 80 | g.If(len(patches) > 0, Ul(Class("issue-list"), |
| 81 | g.Map(patches, func(patch db.Patch) g.Node { |
| 82 | labels := labelsByPatchID[patch.ID] |
| 83 | return Li(Class("issue-item"), |
| 84 | Div(Class("issue-main"), |
| 85 | Span(Class("patch-status-dot "+patch.Status)), |
| 86 | A(Href("/"+repo.Name+"/patches/"+strconv.FormatInt(patch.Number, 10)), |
| 87 | Class("issue-title"), g.Text(patch.Title)), |
| 88 | Span(Class("issue-number"), g.Text("#"+strconv.FormatInt(patch.Number, 10))), |
| 89 | ), |
| 90 | g.If(len(labels) > 0, Div(Class("issue-labels"), |
| 91 | g.Map(labels, labelBadge))), |
| 92 | Div(Class("issue-meta"), |
| 93 | Avatar(patch.AuthorID, patch.AuthorAvatarVersion, 20), |
| 94 | Span(g.Text("by "+util.DisplayName(deref(patch.AuthorUsername), cfg.OwnerDisplayName))), |
| 95 | g.El("time", DateTime(patch.CreatedAt), g.Text(util.FormatDate(patch.CreatedAt))), |
| 96 | ), |
| 97 | ) |
| 98 | }), |
| 99 | )), |
| 100 | Pagination(page), |
| 101 | ), |
| 102 | ) |
| 103 | } |
| 104 | |
| 105 | // NewPatch renders the patch upload form. |
| 106 | func NewPatch(cfg *config.Config, user *db.SessionUser, repo *db.Repo, errMsg, template string, |
| 107 | labels []db.Label, |
| 108 | ) g.Node { |
| 109 | showLabels := len(labels) > 0 && (user.IsAdmin || repo.AllowUserLabels) |
| 110 | return Layout(Page{Title: "New patch — " + repo.Name, User: user, Cfg: cfg}, |
| 111 | Div(Class("container"), |
| 112 | RepoHeader(repo), |
| 113 | RepoNav(cfg, repo, "patches", user), |
| 114 | H2(Class("section-title"), g.Text("Upload patch")), |
| 115 | g.If(errMsg != "", P(Class("form-error"), g.Text(errMsg))), |
| 116 | Form(Method("POST"), Action("/"+repo.Name+"/patches"), |
| 117 | EncType("multipart/form-data"), Class("form-card"), |
| 118 | Div(Class("form-group"), |
| 119 | Label(For("title"), g.Text("Title")), |
| 120 | Input(ID("title"), Name("title"), Type("text"), Required(), |
| 121 | MaxLength(strconv.Itoa(cfg.MaxTitleBytes)), |
| 122 | Placeholder("What does this patch do?")), |
| 123 | ), |
| 124 | Div(Class("form-group"), |
| 125 | Label(For("description"), g.Text("Description "), |
| 126 | Span(Class("text-muted"), g.Text("(Markdown supported, optional)"))), |
| 127 | Textarea(ID("description"), Name("description"), Rows("5"), |
| 128 | MaxLength(strconv.Itoa(cfg.MaxTextBodyBytes)), g.Text(template)), |
| 129 | ), |
| 130 | Div(Class("form-group"), |
| 131 | Label(For("patch_file"), g.Text("Patch file "), |
| 132 | Span(Class("text-muted"), g.Text("(.patch)"))), |
| 133 | Input(ID("patch_file"), Name("patch_file"), Type("file"), |
| 134 | Accept(".patch"), Required()), |
| 135 | ), |
| 136 | g.If(showLabels, Div(Class("form-group"), |
| 137 | Span(Class("form-group-label"), g.Text("Labels")), |
| 138 | Div(Class("label-checkbox-list"), |
| 139 | g.Map(labels, func(label db.Label) g.Node { |
| 140 | return Label(Class("label-checkbox-item"), |
| 141 | Input(Type("checkbox"), Name("label_ids"), |
| 142 | Value(strconv.FormatInt(label.ID, 10))), |
| 143 | labelBadge(label), |
| 144 | ) |
| 145 | }), |
| 146 | ), |
| 147 | )), |
| 148 | Div(Class("form-actions"), |
| 149 | Button(Type("submit"), Class("btn btn-primary"), g.Text("Upload patch")), |
| 150 | A(Href("/"+repo.Name+"/patches"), Class("btn"), g.Text("Cancel")), |
| 151 | ), |
| 152 | ), |
| 153 | ), |
| 154 | ) |
| 155 | } |
| 156 | |
| 157 | // PatchDetail renders one patch: conversation tab or changes tab. |
| 158 | // applyResult is nil when no apply check ran. |
| 159 | func PatchDetail(cfg *config.Config, user *db.SessionUser, repo *db.Repo, patch *db.Patch, |
| 160 | descriptionHTML string, applyResult *gitcmd.ApplyResult, files []highlight.RenderedDiffFile, |
| 161 | tab string, meta gitcmd.PatchMeta, comments []ThreadComment, reactions []ReactionCount, |
| 162 | commentReactions map[int64][]ReactionCount, patchLabels, repoLabels []db.Label, |
| 163 | ) g.Node { |
| 164 | isAuthor := user != nil && patch.AuthorID != nil && user.ID == *patch.AuthorID |
| 165 | canEdit := user != nil && (user.IsAdmin || (isAuthor && patch.Status == "open")) |
| 166 | canManageLabels := user != nil && (user.IsAdmin || (repo.AllowUserLabels && isAuthor)) |
| 167 | isAdmin := user != nil && user.IsAdmin |
| 168 | |
| 169 | baseURL := "/" + repo.Name + "/patches/" + strconv.FormatInt(patch.Number, 10) |
| 170 | closeLabel := "Reopen patch" |
| 171 | if patch.Status == "open" { |
| 172 | closeLabel = "Close patch" |
| 173 | } |
| 174 | |
| 175 | return Layout(Page{Title: patch.Title + " — " + repo.Name, User: user, Cfg: cfg}, |
| 176 | Div(Class("container"), |
| 177 | RepoHeader(repo), |
| 178 | RepoNav(cfg, repo, "patches", user), |
| 179 | |
| 180 | Div(Class("issue-detail"), |
| 181 | Div(Class("issue-detail-header"), |
| 182 | Span(Class("issue-number"), g.Text("#"+strconv.FormatInt(patch.Number, 10))), |
| 183 | EditableTitle(cfg, patch.Title, canEdit, baseURL+"/edit", |
| 184 | "edit_description", patch.Description), |
| 185 | Span(Class("patch-badge "+patch.Status), g.Text(patch.Status)), |
| 186 | g.If(canEdit, Div(Class("issue-detail-meta-actions"), |
| 187 | g.If(isAdmin && patch.Status == "open" && |
| 188 | applyResult != nil && applyResult.Status == "clean", |
| 189 | Form(Method("POST"), Action(baseURL+"/merge"), Class("inline-form"), |
| 190 | Input(Type("hidden"), Name("version"), Value(patch.Version)), |
| 191 | Button(Type("submit"), Class("btn btn-sm btn-primary"), |
| 192 | g.Text("Merge patch")), |
| 193 | )), |
| 194 | g.If(patch.Status == "open", Details(Class("confirm-details"), |
| 195 | Summary(Class("btn btn-sm"), g.Text("Update patch file")), |
| 196 | Div(Class("confirm-popup"), |
| 197 | Form(Method("POST"), Action(baseURL+"/upload"), |
| 198 | EncType("multipart/form-data"), Class("upload-patch-form"), |
| 199 | Input(Type("file"), Name("patch_file"), Accept(".patch"), |
| 200 | Required(), Class("form-input")), |
| 201 | Button(Type("submit"), Class("btn btn-sm btn-primary"), |
| 202 | g.Text("Upload")), |
| 203 | ), |
| 204 | ), |
| 205 | )), |
| 206 | g.If(isAdmin && patch.Status != "merged", |
| 207 | Form(Method("POST"), Action(baseURL+"/close"), Class("inline-form"), |
| 208 | Button(Type("submit"), Class("btn btn-sm"), g.Text(closeLabel)), |
| 209 | )), |
| 210 | Details(Class("confirm-details"), |
| 211 | Summary(Class("btn btn-sm btn-danger"), g.Text("Delete")), |
| 212 | Div(Class("confirm-popup"), |
| 213 | g.Text("Permanently delete this patch?"), |
| 214 | Form(Method("POST"), Action(baseURL+"/delete"), Class("inline-form"), |
| 215 | Button(Type("submit"), Class("btn btn-sm btn-danger"), |
| 216 | g.Text("Yes, delete")), |
| 217 | ), |
| 218 | ), |
| 219 | ), |
| 220 | )), |
| 221 | ), |
| 222 | LabelBadges(patchLabels, repoLabels, canManageLabels, baseURL), |
| 223 | ), |
| 224 | |
| 225 | Div(Class("patch-tab-nav"), |
| 226 | A(Href(baseURL), Class(activeTabClass(tab == "conversation")), |
| 227 | g.Text("Conversation "), |
| 228 | g.If(len(comments) > 0, |
| 229 | Span(Class("tab-count"), g.Text(strconv.Itoa(len(comments))))), |
| 230 | ), |
| 231 | A(Href(baseURL+"?tab=changes"), Class(activeTabClass(tab == "changes")), |
| 232 | g.Text("Changes "), |
| 233 | g.If(len(files) > 0, |
| 234 | Span(Class("tab-count"), g.Text(strconv.Itoa(len(files))))), |
| 235 | ), |
| 236 | ), |
| 237 | |
| 238 | g.If(tab == "conversation", Div(Class("issue-detail"), |
| 239 | Div(Class("timeline-item"), |
| 240 | Div(Class("timeline-author"), |
| 241 | Avatar(patch.AuthorID, patch.AuthorAvatarVersion, 24), |
| 242 | Strong(g.Text(util.DisplayName(deref(patch.AuthorUsername), cfg.OwnerDisplayName))), |
| 243 | Div(Class("timeline-author-right"), |
| 244 | g.If(canEdit, Details(Class("inline-edit-details"), |
| 245 | Summary(Class("btn btn-xs"), |
| 246 | Span(Class("when-closed"), g.Text("Edit")), |
| 247 | Span(Class("when-open"), g.Text("Stop editing")), |
| 248 | ), |
| 249 | )), |
| 250 | DateWithEdited(patch.CreatedAt, patch.EditedAt), |
| 251 | ), |
| 252 | ), |
| 253 | g.If(canEdit, Div(Class("inline-edit-form-area"), |
| 254 | Form(Method("POST"), Action(baseURL+"/edit"), Class("inline-edit-form"), |
| 255 | Input(Type("hidden"), Name("title"), Value(patch.Title)), |
| 256 | Div(Class("form-group"), |
| 257 | Textarea(Class("form-input"), ID("edit-patch-desc"), |
| 258 | Name("edit_description"), Rows("6"), |
| 259 | MaxLength(strconv.Itoa(cfg.MaxTextBodyBytes)), |
| 260 | g.Text(patch.Description)), |
| 261 | ), |
| 262 | Div(Class("form-actions"), |
| 263 | Button(Type("submit"), Class("btn btn-sm btn-primary"), g.Text("Save")), |
| 264 | ), |
| 265 | ), |
| 266 | )), |
| 267 | // The markdown renderer already sanitised this HTML. |
| 268 | Div(Class("timeline-body markdown-body"), |
| 269 | g.If(descriptionHTML != "", g.Raw(descriptionHTML)), |
| 270 | g.If(descriptionHTML == "", |
| 271 | Em(Class("text-muted"), g.Text("No description provided."))), |
| 272 | ), |
| 273 | ReactionBar(reactions, baseURL+"/react", 0, user), |
| 274 | ), |
| 275 | |
| 276 | g.Iff(applyResult != nil, func() g.Node { return applyStatus(applyResult) }), |
| 277 | |
| 278 | CommentThread(cfg, user, comments, commentReactions, baseURL, patch.Status), |
| 279 | )), |
| 280 | |
| 281 | g.If(tab == "changes", Div( |
| 282 | Div(Class("commit-card"), |
| 283 | H2(Class(commitSubjectClass(meta.Subject)), g.Text(commitSubject(meta.Subject))), |
| 284 | g.If(meta.Body != "", Pre(Class("commit-card-body"), g.Text(meta.Body))), |
| 285 | Div(Class("commit-card-meta"), |
| 286 | Div(Class("commit-card-meta-row"), |
| 287 | Span(Class("commit-meta-label"), g.Text("Author")), |
| 288 | Span(Class("commit-meta-value"), g.Text(meta.Author+" <"+meta.Email+">")), |
| 289 | ), |
| 290 | Div(Class("commit-card-meta-row"), |
| 291 | Span(Class("commit-meta-label"), g.Text("Date")), |
| 292 | g.El("time", Class("commit-meta-value"), DateTime(meta.Date), |
| 293 | g.Text(util.FormatDateTime(meta.Date))), |
| 294 | ), |
| 295 | ), |
| 296 | ), |
| 297 | DiffView(files, repo, ""), |
| 298 | )), |
| 299 | ), |
| 300 | ) |
| 301 | } |
| 302 | |
| 303 | // applyStatus renders the "applies cleanly" or "has conflicts" banner. |
| 304 | func applyStatus(result *gitcmd.ApplyResult) g.Node { |
| 305 | icon, text := "✗", "Has conflicts" |
| 306 | if result.Status == "clean" { |
| 307 | icon, text = "✓", "Applies cleanly" |
| 308 | } |
| 309 | return Div(Class("timeline-item"), |
| 310 | Div(Class("apply-result apply-"+result.Status), |
| 311 | Div( |
| 312 | Span(Class("apply-icon"), g.Text(icon)), |
| 313 | Span(g.Text(text)), |
| 314 | ), |
| 315 | g.If(result.Output != "", Pre(Class("apply-output"), g.Text(result.Output))), |
| 316 | ), |
| 317 | ) |
| 318 | } |
| 319 | |
| 320 | func activeTabClass(active bool) string { |
| 321 | if active { |
| 322 | return "repo-tab active" |
| 323 | } |
| 324 | return "repo-tab" |
| 325 | } |
| 326 | |
| 327 | func commitSubjectClass(subject string) string { |
| 328 | if subject == "" { |
| 329 | return "commit-card-subject commit-card-subject-empty" |
| 330 | } |
| 331 | return "commit-card-subject" |
| 332 | } |
| 333 | |
| 334 | func commitSubject(subject string) string { |
| 335 | if subject == "" { |
| 336 | return "No commit message" |
| 337 | } |
| 338 | return subject |
| 339 | } |
| 340 |