Commit 929f2440bf

929f2440bf01a9d002a68266214d135384645cc4

parent: 98cd521512

Verified · cmc ci/build: success ci/test: success

cmc <hello@cleberg.net> · 2026-09-19 19:55 UTC

web: preview markup before writing it

Every form that takes markup gets a Preview submit beside its own:
issue and merge request create, their edit and comment boxes, release
create and edit, the profile about text, and the file editor on a path
the forge renders. It posts to the form's own action, which renders the
draft and hands the page back without writing, so the preview is the
rendering the thread will show, autolinks included. There is no
JavaScript to do it live; this is the round trip the blob page's
rendered/source toggle already uses.

The draft type in internal/httpd/preview.go carries the markup, its
format and the rest of the submitted form. Is names the textarea it
belongs to, so a page with two forms previews only the one submitted,
and Or refills a field from the draft or from what is stored. The
format is the one the write would have used: the picker on create, the
item's stored format on edit, markdown for a comment.

Diff-line comments are left out: they sit inside the diff, where a
page-level preview has nowhere to go.

Closes #235
.gitbay/wiki/Parity.org +17
@@ -52,6 +52,7 @@ browser-only and the iOS build screen unable to say more than the log.
5252| filter by label | yes | yes | no |
5353| request a review | yes | yes | yes |
5454| choose body markup | yes | yes | yes |
55| preview body markup | n/a | yes | no |
5556| stacked merge requests | yes | yes | yes |
5657| revisions | yes | yes | yes |
5758| range-diff | yes | no | yes |
@@ -114,6 +115,7 @@ reviews, since an approval was of the diff against the old branch.
114115| milestone list | yes | yes | yes |
115116| labels: list, colour | yes | yes | yes |
116117| choose body markup | yes | yes | yes |
118| preview body markup | n/a | yes | no |
117119| issue templates | yes | yes | yes |
118120| milestone create, close, reopen | yes | no | yes |
119121| org labels: set, list, remove | yes | list | yes |
@@ -144,6 +146,18 @@ body was stored in, since starting elsewhere would silently reinterpret
144146it on the next save. Diff-line comments have no format column and are
145147always markdown.
146148
149Every web form that takes markup has a Preview button beside its own
150submit: issue and merge request create, their edit and comment boxes,
151release create and edit, the profile about text, and the file editor on
152a path the forge renders. It posts to the form's own action, which
153renders the draft and hands the page back without writing, so what you
154see is the rendering the thread will show, autolinks included. It is a
155round trip rather than a live preview because the instance serves no
156JavaScript, the same way the blob page's rendered/source toggle works.
157The row is =n/a= on the CLI: a terminal has no form to preview, and
158=issue show= already renders. Diff-line comments are left out — they
159sit inside the diff, where a page-level preview has nowhere to go.
160
147161* Repositories
148162
149163| capability | cli | web | ios |
@@ -160,6 +174,7 @@ always markdown.
160174| wiki (read) | yes | yes | yes |
161175| download an archive | yes | yes | n/a |
162176| edit a file | yes | yes | yes |
177| preview an edited markup file | n/a | yes | no |
163178| create | yes | yes | yes |
164179| fork | yes | yes | yes |
165180| pin | yes | yes | yes |
@@ -183,6 +198,7 @@ always markdown.
183198| release list, show | yes | yes | yes |
184199| atom feeds | n/a | yes | n/a |
185200| release create, edit | yes | yes | yes |
201| preview release notes | n/a | yes | no |
186202| build list | yes | yes | yes |
187203| build list filters (ref, status, job) | yes | yes | yes |
188204| build show (one build) | yes | yes | yes |
@@ -312,6 +328,7 @@ client has no use for one (krz/gitbay#57).
312328| API token mint | yes | no | no |
313329| account export bundle | yes | yes | n/a |
314330| profile set | yes | yes | yes |
331| preview profile about | n/a | yes | no |
315332| request a login link | n/a | yes | n/a |
316333| account import bundle | yes | no | n/a |
317334
CHANGELOG.org +5
@@ -12,6 +12,11 @@ The web findings from the forge comparison (#232).
1212 account becomes active: an invite redeemed, or an open-mode signup
1313 that verified its address. Off by default, requires =[mail]=, and
1414 queued like any other notice (#234).
15- Every web form that takes markup has a Preview button beside its own
16 submit: issue and merge request create, their edit and comment boxes,
17 release create and edit, the profile about text, and the file editor
18 on a path the forge renders. It renders the draft above the textarea
19 and writes nothing (#235).
1520
1621- Links inside running text are underlined: the meta lines on issues,
1722 merge requests and releases no longer tell a link apart by colour
e2e/previewweb_test.go added +165
@@ -0,0 +1,165 @@
1package e2e
2
3import (
4 "net/url"
5 "os"
6 "path/filepath"
7 "strings"
8 "testing"
9)
10
11// Every markup form has a Preview button that renders the draft above
12// the textarea and writes nothing (#235). The forms are: issue and MR
13// create, their edit and comment boxes, release create and edit, the
14// profile about text, and the file editor on a path the forge renders.
15func TestMarkupPreviewWeb(t *testing.T) {
16 inst := startInstanceWith(t, "[web]\nmode = \"accounts\"\n")
17 aliceKey := inst.newKey(t, "alice")
18 inst.admin(t, "admin", "user", "create", "alice",
19 "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified")
20 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 {
21 t.Fatalf("repo create: %s", errOut)
22 }
23 env := inst.gitEnv(aliceKey)
24 work := t.TempDir()
25 mustGit(t, work, env, "clone", inst.sshURL("alice/app"), "w")
26 dir := filepath.Join(work, "w")
27 mustGit(t, dir, env, "checkout", "-q", "-b", "main")
28 os.WriteFile(filepath.Join(dir, "README.md"), []byte("# hi\n"), 0o644)
29 os.WriteFile(filepath.Join(dir, "a.txt"), []byte("a\n"), 0o644)
30 mustGit(t, dir, env, "add", ".")
31 mustGit(t, dir, env, "commit", "-q", "-m", "base")
32 mustGit(t, dir, env, "push", "-q", "origin", "main")
33 mustGit(t, dir, env, "checkout", "-q", "-b", "topic")
34 os.WriteFile(filepath.Join(dir, "b.txt"), []byte("b\n"), 0o644)
35 mustGit(t, dir, env, "add", ".")
36 mustGit(t, dir, env, "commit", "-q", "-m", "topic")
37 mustGit(t, dir, env, "push", "-q", "origin", "topic")
38 mustGit(t, dir, env, "tag", "v1")
39 mustGit(t, dir, env, "push", "-q", "origin", "v1")
40
41 alice := inst.login(t, aliceKey)
42 base := inst.base() + "/alice/app"
43
44 post := func(where string, v url.Values) string {
45 t.Helper()
46 status, body := browserPost(t, alice, where, v)
47 if status != 200 {
48 t.Fatalf("post %s %v: %d", where, v, status)
49 }
50 return body
51 }
52 // previewed asserts the rendering is on the page and the draft came
53 // back with it.
54 previewed := func(what, body, want, kept string) {
55 t.Helper()
56 if !strings.Contains(body, `<p class="previewmark">Preview</p>`) {
57 t.Fatalf("%s: no preview block:\n%s", what, body)
58 }
59 if !strings.Contains(body, want) {
60 t.Fatalf("%s: preview does not contain %q:\n%s", what, want, body)
61 }
62 if kept != "" && !strings.Contains(body, kept) {
63 t.Fatalf("%s: form lost %q:\n%s", what, kept, body)
64 }
65 }
66
67 // Issue create: markdown renders, and the title and labels survive.
68 b := post(base+"/issues/new", url.Values{
69 "title": {"draft title"}, "body": {"# heading\n"}, "labels": {"bug"},
70 "format": {"md"}, "preview": {"1"},
71 })
72 previewed("issue create", b, "<h1", `value="draft title"`)
73 if !strings.Contains(b, `value="bug"`) {
74 t.Fatalf("issue create preview lost the labels:\n%s", b)
75 }
76 // Nothing was opened.
77 if out, _, _ := inst.ssh(t, aliceKey, "", "issue", "list", "alice/app", "--json"); strings.Contains(out, "draft title") {
78 t.Fatalf("preview created an issue: %s", out)
79 }
80
81 // The org choice previews as org, not markdown.
82 b = post(base+"/issues/new", url.Values{
83 "title": {"t"}, "body": {"Some /emphasis/ here.\n"}, "format": {"org"}, "preview": {"1"},
84 })
85 previewed("issue create org", b, "<em>emphasis</em>", `<option value="org" selected>`)
86
87 // A real issue, then its comment and edit boxes.
88 if _, errOut, code := inst.ssh(t, aliceKey, "", "issue", "create", "alice/app", "--title", "real", "--body", "body"); code != 0 {
89 t.Fatalf("issue create: %s", errOut)
90 }
91 b = post(base+"/issues/1/comment", url.Values{"body": {"a **bold** remark"}, "preview": {"1"}})
92 previewed("issue comment", b, "<strong>bold</strong>", "a **bold** remark")
93 b = post(base+"/issues/1/edit", url.Values{
94 "title": {"real"}, "body": {"## edited"}, "preview": {"1"},
95 })
96 previewed("issue edit", b, "<h2", "## edited")
97 if !strings.Contains(b, `<details class="editbox" open>`) {
98 t.Fatalf("issue edit preview left the box shut:\n%s", b)
99 }
100 // Neither wrote: one comment would show in the thread, an edit in the body.
101 if out, _, _ := inst.ssh(t, aliceKey, "", "issue", "show", "alice/app", "1", "--json"); strings.Contains(out, "bold") || strings.Contains(out, "edited") {
102 t.Fatalf("preview wrote to the issue: %s", out)
103 }
104
105 // MR create, comment and edit.
106 b = post(base+"/mrs/new", url.Values{
107 "source": {"topic"}, "target": {"main"}, "title": {"mr draft"},
108 "body": {"# mr heading"}, "format": {"md"}, "preview": {"1"},
109 })
110 previewed("mr create", b, "<h1", `value="mr draft"`)
111 if out, _, _ := inst.ssh(t, aliceKey, "", "mr", "list", "alice/app", "--json"); strings.Contains(out, "mr draft") {
112 t.Fatalf("preview opened a merge request: %s", out)
113 }
114 if _, errOut, code := inst.ssh(t, aliceKey, "", "mr", "create", "alice/app",
115 "--source", "topic", "--target", "main", "--title", "'real mr'"); code != 0 {
116 t.Fatalf("mr create: %s", errOut)
117 }
118 b = post(base+"/mrs/1/comment", url.Values{"body": {"a `code` remark"}, "preview": {"1"}})
119 previewed("mr comment", b, "<code>code</code>", "a `code` remark")
120 b = post(base+"/mrs/1/edit", url.Values{"title": {"real mr"}, "body": {"## mr edited"}, "preview": {"1"}})
121 previewed("mr edit", b, "<h2", "## mr edited")
122
123 // Release create, then edit.
124 b = post(base+"/releases", url.Values{
125 "tag": {"v1"}, "title": {"first"}, "notes": {"* a bullet"}, "preview": {"1"},
126 })
127 previewed("release create", b, "<li>", "* a bullet")
128 if out, _, _ := inst.ssh(t, aliceKey, "", "release", "list", "alice/app", "--json"); strings.Contains(out, "first") {
129 t.Fatalf("preview created a release: %s", out)
130 }
131 if _, errOut, code := inst.ssh(t, aliceKey, "", "release", "create", "alice/app", "v1", "--title", "first"); code != 0 {
132 t.Fatalf("release create: %s", errOut)
133 }
134 b = post(base+"/releases", url.Values{
135 "action": {"edit"}, "tag": {"v1"}, "title": {"first"},
136 "notes": {"## release notes"}, "preview": {"1"},
137 })
138 previewed("release edit", b, "<h2", "## release notes")
139
140 // The profile's about text.
141 b = post(inst.base()+"/settings", url.Values{
142 "field": {"profile"}, "about": {"# about me"}, "format": {"md"},
143 "description": {"kept"}, "preview": {"1"},
144 })
145 previewed("profile about", b, "<h1", `value="kept"`)
146 if out, _, _ := inst.ssh(t, aliceKey, "", "profile", "show", "--json"); strings.Contains(out, "about me") {
147 t.Fatalf("preview saved the profile: %s", out)
148 }
149
150 // The file editor previews a rendered path and offers nothing on one
151 // it does not render.
152 b = post(base+"/edit/main/README.md", url.Values{
153 "content": {"# from the editor"}, "message": {"kept message"}, "preview": {"1"},
154 })
155 previewed("file editor", b, "from the editor", `value="kept message"`)
156 if _, body := browserGet(t, alice, base+"/blob/main/README.md"); strings.Contains(body, "from the editor") {
157 t.Fatalf("preview committed the file:\n%s", body)
158 }
159 if _, body := browserGet(t, alice, base+"/edit/main/a.txt"); strings.Contains(body, `name="preview"`) {
160 t.Fatalf("a plain text file offers a preview:\n%s", body)
161 }
162 if _, body := browserGet(t, alice, base+"/edit/main/README.md"); !strings.Contains(body, `name="preview"`) {
163 t.Fatalf("no preview button on a markdown file:\n%s", body)
164 }
165}
internal/httpd/account.go +12 −4
@@ -34,6 +34,12 @@ type accountPGP struct {
3434// accountForm renders the account's own settings: keys, addresses, and the
3535// commands for everything that stays on SSH.
3636func (s *Server) accountForm(w http.ResponseWriter, r *http.Request, u store.User) {
37 s.accountPage(w, r, u, nil)
38}
39
40// accountPage renders the settings page. d is non-nil when the profile
41// form asked to see its about text rather than save it (#235).
42func (s *Server) accountPage(w http.ResponseWriter, r *http.Request, u store.User, d *draft) {
3743 var keys []accountKey
3844 if list, err := s.st.ListSSHKeys(u.ID); err == nil {
3945 for _, k := range list {
@@ -75,8 +81,9 @@ func (s *Server) accountForm(w http.ResponseWriter, r *http.Request, u store.Use
7581 MailOn bool
7682 WatchOn bool
7783 ThemeSetting string // system, light or dark: the form's selected option
84 Draft *draft
7885 }{s.baseFor(u), "account", keys, pgp, emails, profile, profileLinksText(profile.Links), s.cfg.SiteHost(),
79 s.takeFlash(w, r), r.URL.Query().Get("m"), mailOn, watchOn, theme})
86 s.takeFlash(w, r), r.URL.Query().Get("m"), mailOn, watchOn, theme, d})
8087}
8188
8289// accountExport hands the browser the same bundle `account export`
@@ -239,9 +246,10 @@ func (s *Server) accountSubmit(w http.ResponseWriter, r *http.Request, u store.U
239246 }
240247 back("", "notification preferences saved")
241248 case "profile":
242 format := r.FormValue("format")
243 if format != "org" {
244 format = "md"
249 format := bodyFormat(r)
250 if wantsPreview(r) {
251 s.accountPage(w, r, u, s.draftWith(r, "about", format, r.FormValue("about"), ugcHTML))
252 return
245253 }
246254 argv := []string{"profile", "set",
247255 "--description", r.FormValue("description"),
internal/httpd/accounts.go +64 −5
@@ -2,8 +2,10 @@ package httpd
22
33import (
44 "fmt"
5 "html/template"
56 "log"
67 "net/http"
8 "path"
79 "slices"
810 "strconv"
911 "strings"
@@ -341,13 +343,30 @@ func (s *Server) signupSubmit(w http.ResponseWriter, r *http.Request) {
341343}
342344
343345// issueCreateForm renders the new-issue form, prefilled from the repo's
344// default issue template when one exists.
346// default issue template when one exists. A Preview submit comes back
347// here with the draft in the form, so the page returns with everything
348// still typed and the rendering above the textarea (#235).
345349func (s *Server) issueCreateForm(w http.ResponseWriter, r *http.Request, u store.User) {
346350 p, ok := s.repoFor(w, r, "")
347351 if !ok {
348352 return
349353 }
350354 p.Tab = "issues"
355 if wantsPreview(r) {
356 d := s.draftFor(r, p.Repo, "body", "body", bodyFormat(r))
357 s.render(w, "issuenew.html", struct {
358 repoPage
359 Body string
360 Format string
361 Title string
362 Labels string
363 Template string
364 Templates []control.IssueTemplate
365 Draft *draft
366 }{p, d.Body, d.Format, r.FormValue("title"), r.FormValue("labels"),
367 "", control.IssueTemplates(p.Dir, p.Repo.DefaultBranch), d})
368 return
369 }
351370 templates := control.IssueTemplates(p.Dir, p.Repo.DefaultBranch)
352371 body, tplName := "", ""
353372 if want := r.URL.Query().Get("template"); want != "" {
@@ -374,9 +393,12 @@ func (s *Server) issueCreateForm(w http.ResponseWriter, r *http.Request, u store
374393 repoPage
375394 Body string
376395 Format string
396 Title string
397 Labels string
377398 Template string
378399 Templates []control.IssueTemplate
379 }{p, body, format, tplName, templates})
400 Draft *draft
401 }{p, body, format, "", "", tplName, templates, nil})
380402}
381403
382404// Issue and merge request writes run the command the CLI runs, so the
@@ -386,9 +408,10 @@ func (s *Server) issueCreateForm(w http.ResponseWriter, r *http.Request, u store
386408func (s *Server) issueCreateSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
387409 repoPath := r.PathValue("owner") + "/" + r.PathValue("repo")
388410 title := strings.TrimSpace(r.FormValue("title"))
389 format := r.FormValue("format")
390 if format != "org" {
391 format = "md"
411 format := bodyFormat(r)
412 if wantsPreview(r) {
413 s.issueCreateForm(w, r, u)
414 return
392415 }
393416 var created control.Created
394417 argv := []string{"issue", "create", repoPath, "--title", title, "--format", format, "--file", "-"}
@@ -411,6 +434,10 @@ func (s *Server) issueCreateSubmit(w http.ResponseWriter, r *http.Request, u sto
411434func (s *Server) issueEditSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
412435 repoPath := r.PathValue("owner") + "/" + r.PathValue("repo")
413436 n := r.PathValue("n")
437 if wantsPreview(r) {
438 s.issuePage(w, r, "edit")
439 return
440 }
414441 title := strings.TrimSpace(r.FormValue("title"))
415442 code, msg := s.dispatchJSON(u, []string{"issue", "edit", repoPath, n, "--title", title, "--file", "-"}, r.FormValue("body"))
416443 if code != protocol.ExitOK {
@@ -444,6 +471,10 @@ func (s *Server) issueEditSubmit(w http.ResponseWriter, r *http.Request, u store
444471func (s *Server) mrEditSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
445472 repoPath := r.PathValue("owner") + "/" + r.PathValue("repo")
446473 n := r.PathValue("n")
474 if wantsPreview(r) {
475 s.mrPage(w, r, "edit")
476 return
477 }
447478 title := strings.TrimSpace(r.FormValue("title"))
448479 code, msg := s.dispatchJSON(u, []string{"mr", "edit", repoPath, n, "--title", title, "--file", "-"}, r.FormValue("body"))
449480 if code != protocol.ExitOK {
@@ -454,10 +485,18 @@ func (s *Server) mrEditSubmit(w http.ResponseWriter, r *http.Request, u store.Us
454485}
455486
456487func (s *Server) issueCommentSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
488 if wantsPreview(r) {
489 s.issuePage(w, r, "comment")
490 return
491 }
457492 s.commentSubmit(w, r, u, "issue", "issues")
458493}
459494
460495func (s *Server) mrCommentSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
496 if wantsPreview(r) {
497 s.mrPage(w, r, "comment")
498 return
499 }
461500 s.commentSubmit(w, r, u, "mr", "mrs")
462501}
463502
@@ -482,6 +521,10 @@ type editPage struct {
482521 Blocked string
483522 // Creating marks a path the branch does not have yet.
484523 Creating bool
524 // Markup is set for a path the forge renders, which is where a
525 // Preview button makes sense; Draft holds one when asked for (#235).
526 Markup bool
527 Draft *draft
485528}
486529
487530func (s *Server) editForm(w http.ResponseWriter, r *http.Request, u store.User) {
@@ -519,6 +562,7 @@ func (s *Server) editForm(w http.ResponseWriter, r *http.Request, u store.User)
519562 s.render(w, "edit.html", editPage{
520563 basePage: s.baseFor(u), Repo: repo,
521564 Ref: ref, Path: filePath, Content: string(content), Blocked: blocked, Creating: creating,
565 Markup: markupFile(filePath),
522566 })
523567}
524568
@@ -530,6 +574,20 @@ func (s *Server) editSubmit(w http.ResponseWriter, r *http.Request, u store.User
530574 ref := r.PathValue("ref")
531575 filePath := strings.Trim(r.PathValue("path"), "/")
532576
577 // Preview: the file as the blob page will render it, above the
578 // editor, with nothing committed. Only for paths the forge renders.
579 if wantsPreview(r) && markupFile(filePath) {
580 content := r.FormValue("content")
581 d := s.draftWith(r, "content", "", content, func(raw, _ string) template.HTML {
582 return renderReadme(path.Base(filePath), []byte(raw))
583 })
584 s.render(w, "edit.html", editPage{
585 basePage: s.baseFor(u), Repo: repo,
586 Ref: ref, Path: filePath, Content: content, Markup: true, Draft: d,
587 })
588 return
589 }
590
533591 // Editing is a control command; the web supplies the form and lets
534592 // the registry enforce the rules — signed-commit policy, verified
535593 // identity, archived repositories — so every surface agrees on them.
@@ -541,6 +599,7 @@ func (s *Server) editSubmit(w http.ResponseWriter, r *http.Request, u store.User
541599 s.render(w, "edit.html", editPage{
542600 basePage: s.baseFor(u), Repo: repo,
543601 Ref: ref, Path: filePath, Content: r.FormValue("content"), Error: msg,
602 Markup: markupFile(filePath),
544603 })
545604 return
546605 }
internal/httpd/mractions.go +13 −3
@@ -184,6 +184,7 @@ type mrNewPage struct {
184184 Body string
185185 Format string
186186 Notice string
187 Draft *draft
187188}
188189
189190// mrSources lists the branches a merge request may be opened from, in the
@@ -247,9 +248,18 @@ func (s *Server) mrCreateSubmit(w http.ResponseWriter, r *http.Request, u store.
247248 target := strings.TrimSpace(r.FormValue("target"))
248249 title := strings.TrimSpace(r.FormValue("title"))
249250 body := strings.TrimSpace(r.FormValue("body"))
250 format := r.FormValue("format")
251 if format != "org" {
252 format = "md"
251 format := bodyFormat(r)
252
253 // Preview: the same page back with the draft intact, nothing opened.
254 if wantsPreview(r) {
255 p.Tab = "merge requests"
256 branches, _ := gitutil.Refs(p.Dir, "heads")
257 s.render(w, "mrnew.html", mrNewPage{
258 repoPage: p, Branches: branches, Sources: s.mrSources(u, p),
259 Source: source, Target: target, Title: title, Body: body, Format: format,
260 Draft: s.draftFor(r, p.Repo, "body", "body", format),
261 })
262 return
253263 }
254264
255265 back := func(msg string) {
internal/httpd/mrpage_test.go +1
@@ -37,6 +37,7 @@ type mrPageData struct {
3737 HeadMerged bool
3838 HeadPruned bool
3939 Base string
40 Draft *draft
4041}
4142
4243// A pruned head has no diff to show; the page must say the head is gone
internal/httpd/preview.go added +87
@@ -0,0 +1,87 @@
1package httpd
2
3import (
4 "html/template"
5 "net/http"
6 "net/url"
7 "path"
8 "strings"
9
10 "gitbay.org/gitbay/internal/store"
11)
12
13// A markup form's Preview button posts to the form's own action with
14// preview set. The handler renders the body and hands back the page it
15// came from instead of writing anything, so what appears above the
16// textarea is the rendering the thread will show. There is no
17// JavaScript on this site (script-src 'none'), so a preview is a round
18// trip, the way the blob page's rendered/source toggle is (#235).
19//
20// Form names the textarea the draft belongs to, because a page can
21// carry more than one: an issue has both an edit form and a compose
22// box, and only the one that was submitted gets the preview. The other
23// fields of that form ride along in vals so the page can put them back.
24type draft struct {
25 Form string
26 Body string
27 Format string
28 HTML template.HTML
29 vals url.Values
30}
31
32// Is reports whether this draft belongs to the named form. Templates
33// call it to place the preview and open the right box; a nil draft
34// answers false, so a page renders unchanged when nothing was
35// previewed.
36func (d *draft) Is(form string) bool { return d != nil && d.Form == form }
37
38// Or is what a field of the named form should show: what was typed when
39// this draft is that form's, and the stored value otherwise. It is how
40// a previewed form comes back with everything still in it.
41func (d *draft) Or(form, field, fallback string) string {
42 if !d.Is(form) {
43 return fallback
44 }
45 return d.vals.Get(field)
46}
47
48// wantsPreview reports whether the submission asked to see the markup
49// rather than save it.
50func wantsPreview(r *http.Request) bool { return r.FormValue("preview") != "" }
51
52// draftFor renders a repository-scoped body the way the thread renders
53// it, autolinks and all. The caller supplies the format rather than the
54// helper reading the picker, because only the create forms have one: an
55// edit keeps the item's stored format, and a comment is markdown, which
56// is what the command does with no --format.
57func (s *Server) draftFor(r *http.Request, repo store.Repo, form, field, format string) *draft {
58 return s.draftWith(r, form, format, r.FormValue(field), s.ugcFor(r, repo))
59}
60
61// draftWith is draftFor for text with no repository behind it: a
62// profile's about, or a file in the editor. The renderer is the one
63// that surface uses, so the preview matches where the text will land.
64func (s *Server) draftWith(r *http.Request, form, format, body string, render ugcRenderer) *draft {
65 r.ParseForm() // Or reads r.Form; a handler may not have touched it yet.
66 return &draft{Form: form, Body: body, Format: format,
67 HTML: render(strings.TrimSpace(body), format), vals: r.Form}
68}
69
70// markupFile reports whether a path is one the forge renders: the blob
71// page's rendered view and the editor's preview take the same set.
72func markupFile(filePath string) bool {
73 switch path.Ext(strings.ToLower(filePath)) {
74 case ".md", ".markdown", ".org":
75 return true
76 }
77 return false
78}
79
80// bodyFormat reads the format picker, defaulting to markdown the way
81// every submit handler does.
82func bodyFormat(r *http.Request) string {
83 if r.FormValue("format") == "org" {
84 return "org"
85 }
86 return "md"
87}
internal/httpd/releaseactions.go +11
@@ -27,6 +27,17 @@ func (s *Server) releaseSubmit(w http.ResponseWriter, r *http.Request, u store.U
2727 s.backTo(w, r, "releases", "pick a tag")
2828 return
2929 }
30 // Preview: the page back with the notes rendered, nothing written.
31 // The form name carries the tag, because every release on the page
32 // has an edit box and only the one submitted shows its draft.
33 if wantsPreview(r) {
34 form := "release"
35 if r.FormValue("action") == "edit" {
36 form = "release:" + tag
37 }
38 s.releasesPage(w, r, form)
39 return
40 }
3041 back := func(w http.ResponseWriter, r *http.Request, msg string) { s.backTo(w, r, "releases", msg) }
3142 // The CLI's --yes guards against a mistyped tag; here the tag comes
3243 // from the page, so the browser's own confirm field stands in.
internal/httpd/web.go +64 −8
@@ -577,11 +577,7 @@ func (s *Server) blob(w http.ResponseWriter, r *http.Request) {
577577 }
578578 // Markdown and org render like a README, with the source one click
579579 // away; ?view=source shows the text instead.
580 renderable := false
581 switch path.Ext(strings.ToLower(filePath)) {
582 case ".md", ".markdown", ".org":
583 renderable = !binary
584 }
580 renderable := markupFile(filePath) && !binary
585581 var renderedHTML template.HTML
586582 rendered := renderable && r.URL.Query().Get("view") != "source"
587583 if rendered {
@@ -628,6 +624,13 @@ func (s *Server) blob(w http.ResponseWriter, r *http.Request) {
628624
629625// releases lists tag-anchored releases with notes and assets.
630626func (s *Server) releases(w http.ResponseWriter, r *http.Request) {
627 s.releasesPage(w, r, "")
628}
629
630// releasesPage lists releases. previewForm is "release" when the create
631// form asked to see its notes, or "release:<tag>" when that release's
632// edit form did (#235).
633func (s *Server) releasesPage(w http.ResponseWriter, r *http.Request, previewForm string) {
631634 p, ok := s.repoFor(w, r, "")
632635 if !ok {
633636 return
@@ -662,13 +665,28 @@ func (s *Server) releases(w http.ResponseWriter, r *http.Request) {
662665 }
663666 }
664667 }
668 // An edit keeps the release's stored format; a new release has no
669 // picker and is markdown, as release create stores with no --format.
670 var d *draft
671 if previewForm != "" {
672 format := "md"
673 if tag, ok := strings.CutPrefix(previewForm, "release:"); ok {
674 for _, v := range views {
675 if v.Tag == tag {
676 format = v.NotesFormat
677 }
678 }
679 }
680 d = s.draftFor(r, p.Repo, previewForm, "notes", format)
681 }
665682 s.render(w, "releases.html", struct {
666683 repoPage
667684 Releases []relView
668685 FreeTags []string
669686 CanWrite bool
670687 Notice string
671 }{p, views, freeTags, s.canWriteRepo(r, p.Repo), s.takeFlash(w, r)})
688 Draft *draft
689 }{p, views, freeTags, s.canWriteRepo(r, p.Repo), s.takeFlash(w, r), d})
672690}
673691
674692// releaseAsset streams one uploaded asset. Tags containing '/' are not
@@ -1734,6 +1752,14 @@ func (s *Server) issues(w http.ResponseWriter, r *http.Request) {
17341752}
17351753
17361754func (s *Server) issue(w http.ResponseWriter, r *http.Request) {
1755 s.issuePage(w, r, "")
1756}
1757
1758// issuePage renders an issue. previewForm names the form that asked to
1759// see its markup rather than save it — "edit" or "comment", "" for a
1760// plain read — and the page renders that draft above the form it came
1761// from, in the format the write would have stored (#235).
1762func (s *Server) issuePage(w http.ResponseWriter, r *http.Request, previewForm string) {
17371763 p, ok := s.repoFor(w, r, "")
17381764 if !ok {
17391765 return
@@ -1755,6 +1781,17 @@ func (s *Server) issue(w http.ResponseWriter, r *http.Request) {
17551781 return
17561782 }
17571783 md := s.ugcFor(r, p.Repo)
1784 // An edit keeps the issue's stored format; a comment has no picker
1785 // and is markdown, which is what issue comment stores with no
1786 // --format.
1787 var d *draft
1788 if previewForm != "" {
1789 format := iss.BodyFormat
1790 if previewForm == "comment" {
1791 format = "md"
1792 }
1793 d = s.draftFor(r, p.Repo, previewForm, "body", format)
1794 }
17581795 // nil readable: the picker lists titles, never the progress counts.
17591796 milestones, _ := s.st.ListMilestones(p.Repo, "open", nil)
17601797 s.render(w, "issue.html", struct {
@@ -1767,9 +1804,10 @@ func (s *Server) issue(w http.ResponseWriter, r *http.Request) {
17671804 Milestones []store.Milestone
17681805 Notice string
17691806 LabelColors map[string]template.CSS
1807 Draft *draft
17701808 }{p, iss, md(iss.Body, iss.BodyFormat), renderComments(comments, md),
17711809 s.canEditItem(r, p.Repo, iss.Author), s.canWriteRepo(r, p.Repo),
1772 milestones, s.takeFlash(w, r), s.labelColors(p.Repo)})
1810 milestones, s.takeFlash(w, r), s.labelColors(p.Repo), d})
17731811}
17741812
17751813// canEditItem: the author or anyone with write access may edit.
@@ -1876,6 +1914,13 @@ func (s *Server) mrs(w http.ResponseWriter, r *http.Request) {
18761914}
18771915
18781916func (s *Server) mr(w http.ResponseWriter, r *http.Request) {
1917 s.mrPage(w, r, "")
1918}
1919
1920// mrPage renders a merge request. previewForm names the form that asked
1921// to see its markup rather than save it — "edit" or "comment", "" for a
1922// plain read (#235).
1923func (s *Server) mrPage(w http.ResponseWriter, r *http.Request, previewForm string) {
18791924 p, ok := s.repoFor(w, r, "")
18801925 if !ok {
18811926 return
@@ -2006,6 +2051,16 @@ func (s *Server) mr(w http.ResponseWriter, r *http.Request) {
20062051 // The merge requests this one superseded when it was closed, so the
20072052 // page it points to can also say what it supersedes.
20082053 supersedes, _ := s.st.MRsSuperseding(p.Repo.ID, m.Number)
2054 // An edit keeps the merge request's stored format; a comment has no
2055 // picker and is markdown, as mr comment stores with no --format.
2056 var d *draft
2057 if previewForm != "" {
2058 format := m.BodyFormat
2059 if previewForm == "comment" {
2060 format = "md"
2061 }
2062 d = s.draftFor(r, p.Repo, previewForm, "body", format)
2063 }
20092064 s.render(w, "mr.html", struct {
20102065 repoPage
20112066 MR store.MR
@@ -2036,10 +2091,11 @@ func (s *Server) mr(w http.ResponseWriter, r *http.Request) {
20362091 HeadPruned bool
20372092 Base string
20382093 LabelColors map[string]template.CSS
2094 Draft *draft
20392095 }{p, m, view, md(m.Body, m.BodyFormat), checks, combined, renderComments(comments, md),
20402096 reviewRows, files, diffTruncated, stat, commits, commitsTotal, branches, s.canEditItem(r, p.Repo, m.Author),
20412097 canWrite, unresolved, revisions, s.takeFlash(w, r), detachedThreads, stackedOn, stacked, supersedes, gates,
2042 sourceGone(p, m), headMerged, headPruned, base, s.labelColors(p.Repo)})
2098 sourceGone(p, m), headMerged, headPruned, base, s.labelColors(p.Repo), d})
20432099}
20442100
20452101// sourceGone reports whether an MR's source branch no longer exists: the
internal/web/static/style.css +22
@@ -651,6 +651,28 @@ form.signupform label, form.editform label { margin-bottom: 4px; }
651651.editform textarea { width: 100%; }
652652.editform input[name="message"] { width: 100%; }
653653
654/* A previewed draft, above the textarea it came from. The orange edge is
655 "where you are and what wants you": this is not saved yet. */
656.preview {
657 margin: var(--sp-4) 0;
658 max-width: 48rem;
659 background: var(--surface);
660 border: 1px solid var(--line);
661 border-left: 3px solid var(--mark);
662 border-radius: var(--r-card);
663}
664.preview > .previewmark {
665 margin: 0;
666 padding: var(--sp-2) var(--sp-4);
667 border-bottom: 1px solid var(--line);
668 color: var(--warn);
669 font-size: var(--fs-1);
670}
671.preview > .rendered { padding: var(--sp-3) var(--sp-4); }
672.preview > .rendered > :first-child { margin-top: 0; }
673.preview > .rendered > :last-child { margin-bottom: 0; }
674.preview > .rendered:empty::before { content: "nothing to preview"; color: var(--muted); }
675
654676form.commentform { margin: var(--sp-4) 0; max-width: 48rem; }
655677form.commentform p:last-child { display: flex; align-items: center; gap: var(--sp-3); margin-bottom: 0; }
656678
internal/web/templates/account.html +7 −6
@@ -7,18 +7,19 @@
77
88<h2>Profile</h2>
99<p class="meta">Shown on your profile page, <a href="/{{.Viewer}}">/{{.Viewer}}</a>.</p>
10{{if .Draft.Is "about"}}{{template "previewblock" .Draft.HTML}}{{end}}
1011<form method="post" action="/settings" class="setform stack">
1112 <input type="hidden" name="field" value="profile">
1213 <label for="p-description">Description</label>
13 <input type="text" id="p-description" name="description" value="{{.Profile.Description}}" placeholder="one line, shown in listings">
14 <input type="text" id="p-description" name="description" value="{{.Draft.Or "about" "description" .Profile.Description}}" placeholder="one line, shown in listings">
1415 <label for="p-website">Website</label>
15 <input type="text" id="p-website" name="website" value="{{.Profile.Website}}" placeholder="https://example.org">
16 <input type="text" id="p-website" name="website" value="{{.Draft.Or "about" "website" .Profile.Website}}" placeholder="https://example.org">
1617 <label for="p-links">Links</label>
17 <textarea id="p-links" name="links" rows="3" placeholder="one per line: label|https://... or a bare https://... (at most 5)">{{.LinksText}}</textarea>
18 <textarea id="p-links" name="links" rows="3" placeholder="one per line: label|https://... or a bare https://... (at most 5)">{{.Draft.Or "about" "links" .LinksText}}</textarea>
1819 <label for="p-about">About</label>
19 <textarea id="p-about" name="about" rows="8" placeholder="longer, shown below your repositories">{{.Profile.About}}</textarea>
20 {{template "formatpicker" .Profile.AboutFormat}}
21 <button type="submit" class="btn">Save profile</button>
20 <textarea id="p-about" name="about" rows="8" placeholder="longer, shown below your repositories">{{.Draft.Or "about" "about" .Profile.About}}</textarea>
21 {{template "formatpicker" (.Draft.Or "about" "format" .Profile.AboutFormat)}}
22 <span class="btngroup"><button type="submit" class="btn">Save profile</button>{{template "previewbtn"}}</span>
2223</form>
2324
2425<h2>SSH keys</h2>
internal/web/templates/edit.html +3 −2
@@ -5,10 +5,11 @@
55{{if .Error}}<p class="error" role="alert">{{.Error}}</p>{{end}}
66{{if .Blocked}}<p class="empty-note">{{.Blocked}}</p>{{else}}
77{{if .Creating}}<p class="meta"><code>{{.Path}}</code> does not exist on {{.Ref}}; committing creates it.</p>{{end}}
8{{if .Draft.Is "content"}}{{template "previewblock" .Draft.HTML}}{{end}}
89<form method="post" action="/{{.Repo.OwnerName}}/{{.Repo.Name}}/edit/{{.Ref}}/{{.Path}}" class="editform">
910<p><textarea name="content" aria-label="File contents" rows="24" spellcheck="false">{{.Content}}</textarea></p>
10<p><input name="message" aria-label="Commit message" placeholder="commit message">
11<button type="submit">Commit to {{.Ref}}</button></p>
11<p><input name="message" aria-label="Commit message" placeholder="commit message" value="{{.Draft.Or "content" "message" ""}}">
12<button type="submit">Commit to {{.Ref}}</button>{{if .Markup}} {{template "previewbtn"}}{{end}}</p>
1213<p class="crumbs">this commit will be unsigned and authored as {{.Viewer}}</p>
1314</form>
1415{{end}}
internal/web/templates/issue.html +9 −7
@@ -10,12 +10,13 @@
1010
1111<div class="withaside">
1212<div class="mainside">
13{{if .CanEdit}}<details class="editbox"><summary>edit</summary>
13{{if .CanEdit}}<details class="editbox"{{if .Draft.Is "edit"}} open{{end}}><summary>edit</summary>
14{{if .Draft.Is "edit"}}{{template "previewblock" .Draft.HTML}}{{end}}
1415<form method="post" action="/{{.Repo.OwnerName}}/{{.Repo.Name}}/issues/{{.Issue.Number}}/edit" class="commentform">
15<p><input type="text" name="title" aria-label="Title" value="{{.Issue.Title}}" required></p>
16<p><textarea name="body" aria-label="Description" rows="8">{{.Issue.Body}}</textarea></p>
17<p><input type="text" name="labels" aria-label="Labels" value="{{range $i, $l := .Issue.Labels}}{{if $i}} {{end}}{{$l}}{{end}}" placeholder="labels, space-separated (write access)"></p>
18<p><button type="submit" class="btn">Save</button></p>
16<p><input type="text" name="title" aria-label="Title" value="{{.Draft.Or "edit" "title" .Issue.Title}}" required></p>
17<p><textarea name="body" aria-label="Description" rows="8">{{.Draft.Or "edit" "body" .Issue.Body}}</textarea></p>
18<p><input type="text" name="labels" aria-label="Labels" value="{{$.Draft.Or "edit" "labels" (join .Issue.Labels " ")}}" placeholder="labels, space-separated (write access)"></p>
19<p><button type="submit" class="btn">Save</button> {{template "previewbtn"}}</p>
1920</form></details>{{end}}
2021{{if .BodyHTML}}<article class="comment">
2122 <header class="commenthead"><strong><a href="/{{.Issue.Author}}">{{.Issue.Author}}</a></strong> <span class="when">{{when .Issue.CreatedAt}}</span></header>
@@ -29,9 +30,10 @@
2930</article>{{end}}
3031{{end}}
3132{{if .Viewer}}
33{{if .Draft.Is "comment"}}{{template "previewblock" .Draft.HTML}}{{end}}
3234<form method="post" action="/{{.Repo.OwnerName}}/{{.Repo.Name}}/issues/{{.Issue.Number}}/comment" class="commentform">
33<p><textarea name="body" aria-label="Comment" rows="4" placeholder="Comment as {{.Viewer}}"></textarea></p>
34<p><button type="submit">Comment</button></p>
35<p><textarea name="body" aria-label="Comment" rows="4" placeholder="Comment as {{.Viewer}}">{{.Draft.Or "comment" "body" ""}}</textarea></p>
36<p><button type="submit">Comment</button> {{template "previewbtn"}}</p>
3537</form>
3638{{end}}
3739</div>
internal/web/templates/issuenew.html +4 −3
@@ -4,11 +4,12 @@
44<h1>New issue</h1>
55{{if gt (len .Templates) 1}}<p class="meta">template:
66{{range .Templates}}{{if eq .Name $.Template}}<strong>{{.Name}}</strong>{{else}}<a href="?template={{.Name}}">{{.Name}}</a>{{end}} {{end}}</p>{{end}}
7{{if .Draft.Is "body"}}{{template "previewblock" .Draft.HTML}}{{end}}
78<form method="post" action="/{{.Repo.OwnerName}}/{{.Repo.Name}}/issues/new" class="commentform">
8<p><input type="text" name="title" aria-label="Title" placeholder="title" required></p>
9<p><input type="text" name="title" aria-label="Title" placeholder="title" value="{{.Title}}" required></p>
910<p><textarea name="body" aria-label="Description" rows="12">{{.Body}}</textarea></p>
1011{{template "formatpicker" .Format}}
11<p><input type="text" name="labels" aria-label="Labels" placeholder="labels, space-separated (write access)"></p>
12<p><button type="submit">Open issue</button></p>
12<p><input type="text" name="labels" aria-label="Labels" placeholder="labels, space-separated (write access)" value="{{.Labels}}"></p>
13<p><button type="submit">Open issue</button> {{template "previewbtn"}}</p>
1314</form>
1415{{end}}
internal/web/templates/layout.html +12
@@ -133,6 +133,18 @@
133133 </select>
134134</p>{{end}}
135135
136{{/* previewbtn is the Preview submit beside a markup form's own submit.
137 It posts the form to its usual action, which renders the body and
138 hands the page back instead of writing (#235). */}}
139{{define "previewbtn"}}<button type="submit" name="preview" value="1" class="btn">Preview</button>{{end}}
140
141{{/* previewblock shows a draft as it will read. The argument is the
142 rendered HTML; it sits above the textarea it came from. */}}
143{{define "previewblock"}}<div class="preview">
144 <p class="previewmark">Preview</p>
145 <div class="rendered">{{.}}</div>
146</div>{{end}}
147
136148{{/* confirmfield is the typed-name check beside a destructive control.
137149 The argument is the text the person must type. */}}
138150{{define "confirmfield"}}<input type="text" name="confirm" aria-label="Type {{.}} to confirm" placeholder="type {{.}} to confirm" size="{{len .}}" autocomplete="off">{{end}}
internal/web/templates/mr.html +8 −6
@@ -26,11 +26,12 @@
2626
2727{{if eq .View "conversation"}}
2828<div class="prose">
29{{if .CanEdit}}<details class="editbox"><summary>Edit</summary>
29{{if .CanEdit}}<details class="editbox"{{if .Draft.Is "edit"}} open{{end}}><summary>Edit</summary>
30{{if .Draft.Is "edit"}}{{template "previewblock" .Draft.HTML}}{{end}}
3031<form method="post" action="{{$base}}/edit" class="commentform">
31<p><input type="text" name="title" aria-label="Title" value="{{.MR.Title}}" required></p>
32<p><textarea name="body" aria-label="Description" rows="8">{{.MR.Body}}</textarea></p>
33<p><button type="submit" class="btn">Save</button></p>
32<p><input type="text" name="title" aria-label="Title" value="{{.Draft.Or "edit" "title" .MR.Title}}" required></p>
33<p><textarea name="body" aria-label="Description" rows="8">{{.Draft.Or "edit" "body" .MR.Body}}</textarea></p>
34<p><button type="submit" class="btn">Save</button> {{template "previewbtn"}}</p>
3435</form></details>{{end}}
3536{{if .BodyHTML}}<article class="comment">
3637 <header class="commenthead"><strong><a href="/{{.MR.Author}}">{{.MR.Author}}</a></strong></header>
@@ -46,9 +47,10 @@
4647{{if .DetachedThreads}}<h2>Threads on earlier revisions</h2>
4748{{range .DetachedThreads}}{{template "thread" dict "T" . "Base" $base "Viewer" $.Viewer "Class" "stale"}}{{end}}{{end}}
4849{{if .Viewer}}
50{{if .Draft.Is "comment"}}{{template "previewblock" .Draft.HTML}}{{end}}
4951<form method="post" action="{{$base}}/comment" class="commentform">
50<p><textarea name="body" aria-label="Comment" rows="4" placeholder="Comment as {{.Viewer}}"></textarea></p>
51<p><button type="submit">Comment</button></p>
52<p><textarea name="body" aria-label="Comment" rows="4" placeholder="Comment as {{.Viewer}}">{{.Draft.Or "comment" "body" ""}}</textarea></p>
53<p><button type="submit">Comment</button> {{template "previewbtn"}}</p>
5254</form>
5355{{end}}
5456</div>
internal/web/templates/mrnew.html +2 −1
@@ -3,6 +3,7 @@
33{{define "content"}}
44<h1>New merge request</h1>
55{{if .Notice}}<p class="error" role="alert">{{.Notice}}</p>{{end}}
6{{if .Draft.Is "body"}}{{template "previewblock" .Draft.HTML}}{{end}}
67<form method="post" action="/{{.Repo.OwnerName}}/{{.Repo.Name}}/mrs/new" class="commentform">
78<p class="branchpick">
89 <label for="source">Merge</label>
@@ -18,7 +19,7 @@
1819<p><input type="text" name="title" aria-label="Title" placeholder="title" value="{{.Title}}" required></p>
1920<p><textarea name="body" aria-label="Description" rows="10" placeholder="what changes, and why">{{.Body}}</textarea></p>
2021{{template "formatpicker" .Format}}
21<p><button type="submit">Open merge request</button></p>
22<p><button type="submit">Open merge request</button> {{template "previewbtn"}}</p>
2223</form>
2324<p class="meta">Branches of a fork you can push to are offered as
2425<code>owner/name:branch</code>. Fork this repository to propose a change
internal/web/templates/releases.html +13 −9
@@ -3,17 +3,19 @@
33<h1>Releases</h1>
44{{if .Notice}}<p class="error" role="alert">{{.Notice}}</p>{{end}}
55{{if and .CanWrite .FreeTags}}
6<details class="editbox"><summary>New release</summary>
6{{$want := .Draft.Or "release" "tag" ""}}
7<details class="editbox"{{if .Draft.Is "release"}} open{{end}}><summary>New release</summary>
8{{if .Draft.Is "release"}}{{template "previewblock" .Draft.HTML}}{{end}}
79<form method="post" action="/{{.Repo.OwnerName}}/{{.Repo.Name}}/releases" class="commentform">
810 <p class="branchpick">
911 <label for="tag">Tag</label>
1012 <select id="tag" name="tag" required>
11 {{range .FreeTags}}<option value="{{.}}">{{.}}</option>{{end}}
13 {{range .FreeTags}}<option value="{{.}}"{{if eq . $want}} selected{{end}}>{{.}}</option>{{end}}
1214 </select>
1315 </p>
14 <p><input type="text" name="title" aria-label="Title" placeholder="title (defaults to the tag)"></p>
15 <p><textarea name="notes" aria-label="Notes" rows="6" placeholder="what changed"></textarea></p>
16 <p><button type="submit" class="btn">Create release</button></p>
16 <p><input type="text" name="title" aria-label="Title" placeholder="title (defaults to the tag)" value="{{.Draft.Or "release" "title" ""}}"></p>
17 <p><textarea name="notes" aria-label="Notes" rows="6" placeholder="what changed">{{.Draft.Or "release" "notes" ""}}</textarea></p>
18 <p><button type="submit" class="btn">Create release</button> {{template "previewbtn"}}</p>
1719</form>
1820</details>
1921{{else if .CanWrite}}<p class="meta">Push a tag to create a release from it.</p>{{end}}
@@ -24,13 +26,15 @@
2426 <p class="meta"><span class="refchip">{{$rel.Tag}}</span> {{if $rel.Author}}{{$rel.Author}} · {{end}}{{when $rel.CreatedAt}} · <a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/archive/{{$rel.Tag}}.tar.gz">source tar.gz</a></p>
2527 </header>
2628 {{if $rel.NotesHTML}}<div class="rendered">{{$rel.NotesHTML}}</div>{{end}}
27 {{if $.CanWrite}}<details class="editbox"><summary>Edit</summary>
29 {{$form := printf "release:%s" $rel.Tag}}
30 {{if $.CanWrite}}<details class="editbox"{{if $.Draft.Is $form}} open{{end}}><summary>Edit</summary>
31 {{if $.Draft.Is $form}}{{template "previewblock" $.Draft.HTML}}{{end}}
2832 <form method="post" action="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/releases" class="commentform">
2933 <input type="hidden" name="action" value="edit">
3034 <input type="hidden" name="tag" value="{{$rel.Tag}}">
31 <p><input type="text" name="title" aria-label="Title" value="{{$rel.Title}}"></p>
32 <p><textarea name="notes" aria-label="Notes" rows="6">{{$rel.Notes}}</textarea></p>
33 <p><button type="submit" class="btn">Save</button></p>
35 <p><input type="text" name="title" aria-label="Title" value="{{$.Draft.Or $form "title" $rel.Title}}"></p>
36 <p><textarea name="notes" aria-label="Notes" rows="6">{{$.Draft.Or $form "notes" $rel.Notes}}</textarea></p>
37 <p><button type="submit" class="btn">Save</button> {{template "previewbtn"}}</p>
3438 </form>
3539 {{if $.CanAdmin}}<form method="post" action="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/releases" class="commentform">
3640 <input type="hidden" name="action" value="delete">