patches.go
⎇
Raw
1package views
2
3import (
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.
17func 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.
106func 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.
159func 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 := CanEdit(user, patch.AuthorID, patch.Status)
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.
304func 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
320func activeTabClass(active bool) string {
321 if active {
322 return "repo-tab active"
323 }
324 return "repo-tab"
325}
326
327func commitSubjectClass(subject string) string {
328 if subject == "" {
329 return "commit-card-subject commit-card-subject-empty"
330 }
331 return "commit-card-subject"
332}
333
334func commitSubject(subject string) string {
335 if subject == "" {
336 return "No commit message"
337 }
338 return subject
339}
340