web: preview markup before writing it !433

merged merged by cmc on 2026-09-19 20:07 UTC · krz/gitbay:markup-preview into main

19 files changed, +519 −54

Layout: unified · split

.gitbay/wiki/Parity.org +17
@@ -52,6 +52,7 @@ browser-only and the iOS build screen unable to say more than the log.
52| filter by label | yes | yes | no | 52| filter by label | yes | yes | no |
53| request a review | yes | yes | yes | 53| request a review | yes | yes | yes |
54| choose body markup | yes | yes | yes | 54| choose body markup | yes | yes | yes |
55| preview body markup | n/a | yes | no |
55| stacked merge requests | yes | yes | yes | 56| stacked merge requests | yes | yes | yes |
56| revisions | yes | yes | yes | 57| revisions | yes | yes | yes |
57| range-diff | yes | no | yes | 58| range-diff | yes | no | yes |
@@ -114,6 +115,7 @@ reviews, since an approval was of the diff against the old branch.
114| milestone list | yes | yes | yes | 115| milestone list | yes | yes | yes |
115| labels: list, colour | yes | yes | yes | 116| labels: list, colour | yes | yes | yes |
116| choose body markup | yes | yes | yes | 117| choose body markup | yes | yes | yes |
118| preview body markup | n/a | yes | no |
117| issue templates | yes | yes | yes | 119| issue templates | yes | yes | yes |
118| milestone create, close, reopen | yes | no | yes | 120| milestone create, close, reopen | yes | no | yes |
119| org labels: set, list, remove | yes | list | yes | 121| org labels: set, list, remove | yes | list | yes |
@@ -144,6 +146,18 @@ body was stored in, since starting elsewhere would silently reinterpret
144it on the next save. Diff-line comments have no format column and are 146it on the next save. Diff-line comments have no format column and are
145always markdown. 147always markdown.
146 148
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
147* Repositories 161* Repositories
148 162
149| capability | cli | web | ios | 163| capability | cli | web | ios |
@@ -160,6 +174,7 @@ always markdown.
160| wiki (read) | yes | yes | yes | 174| wiki (read) | yes | yes | yes |
161| download an archive | yes | yes | n/a | 175| download an archive | yes | yes | n/a |
162| edit a file | yes | yes | yes | 176| edit a file | yes | yes | yes |
177| preview an edited markup file | n/a | yes | no |
163| create | yes | yes | yes | 178| create | yes | yes | yes |
164| fork | yes | yes | yes | 179| fork | yes | yes | yes |
165| pin | yes | yes | yes | 180| pin | yes | yes | yes |
@@ -183,6 +198,7 @@ always markdown.
183| release list, show | yes | yes | yes | 198| release list, show | yes | yes | yes |
184| atom feeds | n/a | yes | n/a | 199| atom feeds | n/a | yes | n/a |
185| release create, edit | yes | yes | yes | 200| release create, edit | yes | yes | yes |
201| preview release notes | n/a | yes | no |
186| build list | yes | yes | yes | 202| build list | yes | yes | yes |
187| build list filters (ref, status, job) | yes | yes | yes | 203| build list filters (ref, status, job) | yes | yes | yes |
188| build show (one build) | yes | yes | yes | 204| build show (one build) | yes | yes | yes |
@@ -312,6 +328,7 @@ client has no use for one (krz/gitbay#57).
312| API token mint | yes | no | no | 328| API token mint | yes | no | no |
313| account export bundle | yes | yes | n/a | 329| account export bundle | yes | yes | n/a |
314| profile set | yes | yes | yes | 330| profile set | yes | yes | yes |
331| preview profile about | n/a | yes | no |
315| request a login link | n/a | yes | n/a | 332| request a login link | n/a | yes | n/a |
316| account import bundle | yes | no | n/a | 333| account import bundle | yes | no | n/a |
317 334
CHANGELOG.org +5
@@ -12,6 +12,11 @@ The web findings from the forge comparison (#232).
12 account becomes active: an invite redeemed, or an open-mode signup 12 account becomes active: an invite redeemed, or an open-mode signup
13 that verified its address. Off by default, requires =[mail]=, and 13 that verified its address. Off by default, requires =[mail]=, and
14 queued like any other notice (#234). 14 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).
15 20
16- Links inside running text are underlined: the meta lines on issues, 21- Links inside running text are underlined: the meta lines on issues,
17 merge requests and releases no longer tell a link apart by colour 22 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 {
34// accountForm renders the account's own settings: keys, addresses, and the 34// accountForm renders the account's own settings: keys, addresses, and the
35// commands for everything that stays on SSH. 35// commands for everything that stays on SSH.
36func (s *Server) accountForm(w http.ResponseWriter, r *http.Request, u store.User) { 36func (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) {
37 var keys []accountKey 43 var keys []accountKey
38 if list, err := s.st.ListSSHKeys(u.ID); err == nil { 44 if list, err := s.st.ListSSHKeys(u.ID); err == nil {
39 for _, k := range list { 45 for _, k := range list {
@@ -75,8 +81,9 @@ func (s *Server) accountForm(w http.ResponseWriter, r *http.Request, u store.Use
75 MailOn bool 81 MailOn bool
76 WatchOn bool 82 WatchOn bool
77 ThemeSetting string // system, light or dark: the form's selected option 83 ThemeSetting string // system, light or dark: the form's selected option
84 Draft *draft
78 }{s.baseFor(u), "account", keys, pgp, emails, profile, profileLinksText(profile.Links), s.cfg.SiteHost(), 85 }{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})
80} 87}
81 88
82// accountExport hands the browser the same bundle `account export` 89// 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
239 } 246 }
240 back("", "notification preferences saved") 247 back("", "notification preferences saved")
241 case "profile": 248 case "profile":
242 format := r.FormValue("format") 249 format := bodyFormat(r)
243 if format != "org" { 250 if wantsPreview(r) {
244 format = "md" 251 s.accountPage(w, r, u, s.draftWith(r, "about", format, r.FormValue("about"), ugcHTML))
252 return
245 } 253 }
246 argv := []string{"profile", "set", 254 argv := []string{"profile", "set",
247 "--description", r.FormValue("description"), 255 "--description", r.FormValue("description"),
internal/httpd/accounts.go +64 −5
@@ -2,8 +2,10 @@ package httpd
2 2
3import ( 3import (
4 "fmt" 4 "fmt"
5 "html/template"
5 "log" 6 "log"
6 "net/http" 7 "net/http"
8 "path"
7 "slices" 9 "slices"
8 "strconv" 10 "strconv"
9 "strings" 11 "strings"
@@ -341,13 +343,30 @@ func (s *Server) signupSubmit(w http.ResponseWriter, r *http.Request) {
341} 343}
342 344
343// issueCreateForm renders the new-issue form, prefilled from the repo's 345// 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).
345func (s *Server) issueCreateForm(w http.ResponseWriter, r *http.Request, u store.User) { 349func (s *Server) issueCreateForm(w http.ResponseWriter, r *http.Request, u store.User) {
346 p, ok := s.repoFor(w, r, "") 350 p, ok := s.repoFor(w, r, "")
347 if !ok { 351 if !ok {
348 return 352 return
349 } 353 }
350 p.Tab = "issues" 354 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 }
351 templates := control.IssueTemplates(p.Dir, p.Repo.DefaultBranch) 370 templates := control.IssueTemplates(p.Dir, p.Repo.DefaultBranch)
352 body, tplName := "", "" 371 body, tplName := "", ""
353 if want := r.URL.Query().Get("template"); want != "" { 372 if want := r.URL.Query().Get("template"); want != "" {
@@ -374,9 +393,12 @@ func (s *Server) issueCreateForm(w http.ResponseWriter, r *http.Request, u store
374 repoPage 393 repoPage
375 Body string 394 Body string
376 Format string 395 Format string
396 Title string
397 Labels string
377 Template string 398 Template string
378 Templates []control.IssueTemplate 399 Templates []control.IssueTemplate
379 }{p, body, format, tplName, templates}) 400 Draft *draft
401 }{p, body, format, "", "", tplName, templates, nil})
380} 402}
381 403
382// Issue and merge request writes run the command the CLI runs, so the 404// 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
386func (s *Server) issueCreateSubmit(w http.ResponseWriter, r *http.Request, u store.User) { 408func (s *Server) issueCreateSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
387 repoPath := r.PathValue("owner") + "/" + r.PathValue("repo") 409 repoPath := r.PathValue("owner") + "/" + r.PathValue("repo")
388 title := strings.TrimSpace(r.FormValue("title")) 410 title := strings.TrimSpace(r.FormValue("title"))
389 format := r.FormValue("format") 411 format := bodyFormat(r)
390 if format != "org" { 412 if wantsPreview(r) {
391 format = "md" 413 s.issueCreateForm(w, r, u)
414 return
392 } 415 }
393 var created control.Created 416 var created control.Created
394 argv := []string{"issue", "create", repoPath, "--title", title, "--format", format, "--file", "-"} 417 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
411func (s *Server) issueEditSubmit(w http.ResponseWriter, r *http.Request, u store.User) { 434func (s *Server) issueEditSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
412 repoPath := r.PathValue("owner") + "/" + r.PathValue("repo") 435 repoPath := r.PathValue("owner") + "/" + r.PathValue("repo")
413 n := r.PathValue("n") 436 n := r.PathValue("n")
437 if wantsPreview(r) {
438 s.issuePage(w, r, "edit")
439 return
440 }
414 title := strings.TrimSpace(r.FormValue("title")) 441 title := strings.TrimSpace(r.FormValue("title"))
415 code, msg := s.dispatchJSON(u, []string{"issue", "edit", repoPath, n, "--title", title, "--file", "-"}, r.FormValue("body")) 442 code, msg := s.dispatchJSON(u, []string{"issue", "edit", repoPath, n, "--title", title, "--file", "-"}, r.FormValue("body"))
416 if code != protocol.ExitOK { 443 if code != protocol.ExitOK {
@@ -444,6 +471,10 @@ func (s *Server) issueEditSubmit(w http.ResponseWriter, r *http.Request, u store
444func (s *Server) mrEditSubmit(w http.ResponseWriter, r *http.Request, u store.User) { 471func (s *Server) mrEditSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
445 repoPath := r.PathValue("owner") + "/" + r.PathValue("repo") 472 repoPath := r.PathValue("owner") + "/" + r.PathValue("repo")
446 n := r.PathValue("n") 473 n := r.PathValue("n")
474 if wantsPreview(r) {
475 s.mrPage(w, r, "edit")
476 return
477 }
447 title := strings.TrimSpace(r.FormValue("title")) 478 title := strings.TrimSpace(r.FormValue("title"))
448 code, msg := s.dispatchJSON(u, []string{"mr", "edit", repoPath, n, "--title", title, "--file", "-"}, r.FormValue("body")) 479 code, msg := s.dispatchJSON(u, []string{"mr", "edit", repoPath, n, "--title", title, "--file", "-"}, r.FormValue("body"))
449 if code != protocol.ExitOK { 480 if code != protocol.ExitOK {
@@ -454,10 +485,18 @@ func (s *Server) mrEditSubmit(w http.ResponseWriter, r *http.Request, u store.Us
454} 485}
455 486
456func (s *Server) issueCommentSubmit(w http.ResponseWriter, r *http.Request, u store.User) { 487func (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 }
457 s.commentSubmit(w, r, u, "issue", "issues") 492 s.commentSubmit(w, r, u, "issue", "issues")
458} 493}
459 494
460func (s *Server) mrCommentSubmit(w http.ResponseWriter, r *http.Request, u store.User) { 495func (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 }
461 s.commentSubmit(w, r, u, "mr", "mrs") 500 s.commentSubmit(w, r, u, "mr", "mrs")
462} 501}
463 502
@@ -482,6 +521,10 @@ type editPage struct {
482 Blocked string 521 Blocked string
483 // Creating marks a path the branch does not have yet. 522 // Creating marks a path the branch does not have yet.
484 Creating bool 523 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
485} 528}
486 529
487func (s *Server) editForm(w http.ResponseWriter, r *http.Request, u store.User) { 530func (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)
519 s.render(w, "edit.html", editPage{ 562 s.render(w, "edit.html", editPage{
520 basePage: s.baseFor(u), Repo: repo, 563 basePage: s.baseFor(u), Repo: repo,
521 Ref: ref, Path: filePath, Content: string(content), Blocked: blocked, Creating: creating, 564 Ref: ref, Path: filePath, Content: string(content), Blocked: blocked, Creating: creating,
565 Markup: markupFile(filePath),
522 }) 566 })
523} 567}
524 568
@@ -530,6 +574,20 @@ func (s *Server) editSubmit(w http.ResponseWriter, r *http.Request, u store.User
530 ref := r.PathValue("ref") 574 ref := r.PathValue("ref")
531 filePath := strings.Trim(r.PathValue("path"), "/") 575 filePath := strings.Trim(r.PathValue("path"), "/")
532 576
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
533 // Editing is a control command; the web supplies the form and lets 591 // Editing is a control command; the web supplies the form and lets
534 // the registry enforce the rules — signed-commit policy, verified 592 // the registry enforce the rules — signed-commit policy, verified
535 // identity, archived repositories — so every surface agrees on them. 593 // 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
541 s.render(w, "edit.html", editPage{ 599 s.render(w, "edit.html", editPage{
542 basePage: s.baseFor(u), Repo: repo, 600 basePage: s.baseFor(u), Repo: repo,
543 Ref: ref, Path: filePath, Content: r.FormValue("content"), Error: msg, 601 Ref: ref, Path: filePath, Content: r.FormValue("content"), Error: msg,
602 Markup: markupFile(filePath),
544 }) 603 })
545 return 604 return
546 } 605 }
internal/httpd/mractions.go +13 −3
@@ -184,6 +184,7 @@ type mrNewPage struct {
184 Body string 184 Body string
185 Format string 185 Format string
186 Notice string 186 Notice string
187 Draft *draft
187} 188}
188 189
189// mrSources lists the branches a merge request may be opened from, in the 190// 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.
247 target := strings.TrimSpace(r.FormValue("target")) 248 target := strings.TrimSpace(r.FormValue("target"))
248 title := strings.TrimSpace(r.FormValue("title")) 249 title := strings.TrimSpace(r.FormValue("title"))
249 body := strings.TrimSpace(r.FormValue("body")) 250 body := strings.TrimSpace(r.FormValue("body"))
250 format := r.FormValue("format") 251 format := bodyFormat(r)
251 if format != "org" { 252
252 format = "md" 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
253 } 263 }
254 264
255 back := func(msg string) { 265 back := func(msg string) {
internal/httpd/mrpage_test.go +1
@@ -37,6 +37,7 @@ type mrPageData struct {
37 HeadMerged bool 37 HeadMerged bool
38 HeadPruned bool 38 HeadPruned bool
39 Base string 39 Base string
40 Draft *draft
40} 41}
41 42
42// A pruned head has no diff to show; the page must say the head is gone 43// 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
27 s.backTo(w, r, "releases", "pick a tag") 27 s.backTo(w, r, "releases", "pick a tag")
28 return 28 return
29 } 29 }
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 }
30 back := func(w http.ResponseWriter, r *http.Request, msg string) { s.backTo(w, r, "releases", msg) } 41 back := func(w http.ResponseWriter, r *http.Request, msg string) { s.backTo(w, r, "releases", msg) }
31 // The CLI's --yes guards against a mistyped tag; here the tag comes 42 // The CLI's --yes guards against a mistyped tag; here the tag comes
32 // from the page, so the browser's own confirm field stands in. 43 // 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) {
577 } 577 }
578 // Markdown and org render like a README, with the source one click 578 // Markdown and org render like a README, with the source one click
579 // away; ?view=source shows the text instead. 579 // away; ?view=source shows the text instead.
580 renderable := false 580 renderable := markupFile(filePath) && !binary
581 switch path.Ext(strings.ToLower(filePath)) {
582 case ".md", ".markdown", ".org":
583 renderable = !binary
584 }
585 var renderedHTML template.HTML 581 var renderedHTML template.HTML
586 rendered := renderable && r.URL.Query().Get("view") != "source" 582 rendered := renderable && r.URL.Query().Get("view") != "source"
587 if rendered { 583 if rendered {
@@ -628,6 +624,13 @@ func (s *Server) blob(w http.ResponseWriter, r *http.Request) {
628 624
629// releases lists tag-anchored releases with notes and assets. 625// releases lists tag-anchored releases with notes and assets.
630func (s *Server) releases(w http.ResponseWriter, r *http.Request) { 626func (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) {
631 p, ok := s.repoFor(w, r, "") 634 p, ok := s.repoFor(w, r, "")
632 if !ok { 635 if !ok {
633 return 636 return
@@ -662,13 +665,28 @@ func (s *Server) releases(w http.ResponseWriter, r *http.Request) {
662 } 665 }
663 } 666 }
664 } 667 }
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 }
665 s.render(w, "releases.html", struct { 682 s.render(w, "releases.html", struct {
666 repoPage 683 repoPage
667 Releases []relView 684 Releases []relView
668 FreeTags []string 685 FreeTags []string
669 CanWrite bool 686 CanWrite bool
670 Notice string 687 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})
672} 690}
673 691
674// releaseAsset streams one uploaded asset. Tags containing '/' are not 692// releaseAsset streams one uploaded asset. Tags containing '/' are not
@@ -1734,6 +1752,14 @@ func (s *Server) issues(w http.ResponseWriter, r *http.Request) {
1734} 1752}
1735 1753
1736func (s *Server) issue(w http.ResponseWriter, r *http.Request) { 1754func (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) {
1737 p, ok := s.repoFor(w, r, "") 1763 p, ok := s.repoFor(w, r, "")
1738 if !ok { 1764 if !ok {
1739 return 1765 return
@@ -1755,6 +1781,17 @@ func (s *Server) issue(w http.ResponseWriter, r *http.Request) {
1755 return 1781 return
1756 } 1782 }
1757 md := s.ugcFor(r, p.Repo) 1783 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 }
1758 // nil readable: the picker lists titles, never the progress counts. 1795 // nil readable: the picker lists titles, never the progress counts.
1759 milestones, _ := s.st.ListMilestones(p.Repo, "open", nil) 1796 milestones, _ := s.st.ListMilestones(p.Repo, "open", nil)
1760 s.render(w, "issue.html", struct { 1797 s.render(w, "issue.html", struct {
@@ -1767,9 +1804,10 @@ func (s *Server) issue(w http.ResponseWriter, r *http.Request) {
1767 Milestones []store.Milestone 1804 Milestones []store.Milestone
1768 Notice string 1805 Notice string
1769 LabelColors map[string]template.CSS 1806 LabelColors map[string]template.CSS
1807 Draft *draft
1770 }{p, iss, md(iss.Body, iss.BodyFormat), renderComments(comments, md), 1808 }{p, iss, md(iss.Body, iss.BodyFormat), renderComments(comments, md),
1771 s.canEditItem(r, p.Repo, iss.Author), s.canWriteRepo(r, p.Repo), 1809 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})
1773} 1811}
1774 1812
1775// canEditItem: the author or anyone with write access may edit. 1813// canEditItem: the author or anyone with write access may edit.
@@ -1876,6 +1914,13 @@ func (s *Server) mrs(w http.ResponseWriter, r *http.Request) {
1876} 1914}
1877 1915
1878func (s *Server) mr(w http.ResponseWriter, r *http.Request) { 1916func (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) {
1879 p, ok := s.repoFor(w, r, "") 1924 p, ok := s.repoFor(w, r, "")
1880 if !ok { 1925 if !ok {
1881 return 1926 return
@@ -2006,6 +2051,16 @@ func (s *Server) mr(w http.ResponseWriter, r *http.Request) {
2006 // The merge requests this one superseded when it was closed, so the 2051 // The merge requests this one superseded when it was closed, so the
2007 // page it points to can also say what it supersedes. 2052 // page it points to can also say what it supersedes.
2008 supersedes, _ := s.st.MRsSuperseding(p.Repo.ID, m.Number) 2053 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 }
2009 s.render(w, "mr.html", struct { 2064 s.render(w, "mr.html", struct {
2010 repoPage 2065 repoPage
2011 MR store.MR 2066 MR store.MR
@@ -2036,10 +2091,11 @@ func (s *Server) mr(w http.ResponseWriter, r *http.Request) {
2036 HeadPruned bool 2091 HeadPruned bool
2037 Base string 2092 Base string
2038 LabelColors map[string]template.CSS 2093 LabelColors map[string]template.CSS
2094 Draft *draft
2039 }{p, m, view, md(m.Body, m.BodyFormat), checks, combined, renderComments(comments, md), 2095 }{p, m, view, md(m.Body, m.BodyFormat), checks, combined, renderComments(comments, md),
2040 reviewRows, files, diffTruncated, stat, commits, commitsTotal, branches, s.canEditItem(r, p.Repo, m.Author), 2096 reviewRows, files, diffTruncated, stat, commits, commitsTotal, branches, s.canEditItem(r, p.Repo, m.Author),
2041 canWrite, unresolved, revisions, s.takeFlash(w, r), detachedThreads, stackedOn, stacked, supersedes, gates, 2097 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})
2043} 2099}
2044 2100
2045// sourceGone reports whether an MR's source branch no longer exists: the 2101// 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; }
651.editform textarea { width: 100%; } 651.editform textarea { width: 100%; }
652.editform input[name="message"] { width: 100%; } 652.editform input[name="message"] { width: 100%; }
653 653
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
654form.commentform { margin: var(--sp-4) 0; max-width: 48rem; } 676form.commentform { margin: var(--sp-4) 0; max-width: 48rem; }
655form.commentform p:last-child { display: flex; align-items: center; gap: var(--sp-3); margin-bottom: 0; } 677form.commentform p:last-child { display: flex; align-items: center; gap: var(--sp-3); margin-bottom: 0; }
656 678
internal/web/templates/account.html +7 −6
@@ -7,18 +7,19 @@
7 7
8<h2>Profile</h2> 8<h2>Profile</h2>
9<p class="meta">Shown on your profile page, <a href="/{{.Viewer}}">/{{.Viewer}}</a>.</p> 9<p class="meta">Shown on your profile page, <a href="/{{.Viewer}}">/{{.Viewer}}</a>.</p>
10{{if .Draft.Is "about"}}{{template "previewblock" .Draft.HTML}}{{end}}
10<form method="post" action="/settings" class="setform stack"> 11<form method="post" action="/settings" class="setform stack">
11 <input type="hidden" name="field" value="profile"> 12 <input type="hidden" name="field" value="profile">
12 <label for="p-description">Description</label> 13 <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">
14 <label for="p-website">Website</label> 15 <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">
16 <label for="p-links">Links</label> 17 <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>
18 <label for="p-about">About</label> 19 <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 <textarea id="p-about" name="about" rows="8" placeholder="longer, shown below your repositories">{{.Draft.Or "about" "about" .Profile.About}}</textarea>
20 {{template "formatpicker" .Profile.AboutFormat}} 21 {{template "formatpicker" (.Draft.Or "about" "format" .Profile.AboutFormat)}}
21 <button type="submit" class="btn">Save profile</button> 22 <span class="btngroup"><button type="submit" class="btn">Save profile</button>{{template "previewbtn"}}</span>
22</form> 23</form>
23 24
24<h2>SSH keys</h2> 25<h2>SSH keys</h2>
internal/web/templates/edit.html +3 −2
@@ -5,10 +5,11 @@
5{{if .Error}}<p class="error" role="alert">{{.Error}}</p>{{end}} 5{{if .Error}}<p class="error" role="alert">{{.Error}}</p>{{end}}
6{{if .Blocked}}<p class="empty-note">{{.Blocked}}</p>{{else}} 6{{if .Blocked}}<p class="empty-note">{{.Blocked}}</p>{{else}}
7{{if .Creating}}<p class="meta"><code>{{.Path}}</code> does not exist on {{.Ref}}; committing creates it.</p>{{end}} 7{{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}}
8<form method="post" action="/{{.Repo.OwnerName}}/{{.Repo.Name}}/edit/{{.Ref}}/{{.Path}}" class="editform"> 9<form method="post" action="/{{.Repo.OwnerName}}/{{.Repo.Name}}/edit/{{.Ref}}/{{.Path}}" class="editform">
9<p><textarea name="content" aria-label="File contents" rows="24" spellcheck="false">{{.Content}}</textarea></p> 10<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<p><input name="message" aria-label="Commit message" placeholder="commit message" value="{{.Draft.Or "content" "message" ""}}">
11<button type="submit">Commit to {{.Ref}}</button></p> 12<button type="submit">Commit to {{.Ref}}</button>{{if .Markup}} {{template "previewbtn"}}{{end}}</p>
12<p class="crumbs">this commit will be unsigned and authored as {{.Viewer}}</p> 13<p class="crumbs">this commit will be unsigned and authored as {{.Viewer}}</p>
13</form> 14</form>
14{{end}} 15{{end}}
internal/web/templates/issue.html +9 −7
@@ -10,12 +10,13 @@
10 10
11<div class="withaside"> 11<div class="withaside">
12<div class="mainside"> 12<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}}
14<form method="post" action="/{{.Repo.OwnerName}}/{{.Repo.Name}}/issues/{{.Issue.Number}}/edit" class="commentform"> 15<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><input type="text" name="title" aria-label="Title" value="{{.Draft.Or "edit" "title" .Issue.Title}}" required></p>
16<p><textarea name="body" aria-label="Description" rows="8">{{.Issue.Body}}</textarea></p> 17<p><textarea name="body" aria-label="Description" rows="8">{{.Draft.Or "edit" "body" .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><input type="text" name="labels" aria-label="Labels" value="{{$.Draft.Or "edit" "labels" (join .Issue.Labels " ")}}" placeholder="labels, space-separated (write access)"></p>
18<p><button type="submit" class="btn">Save</button></p> 19<p><button type="submit" class="btn">Save</button> {{template "previewbtn"}}</p>
19</form></details>{{end}} 20</form></details>{{end}}
20{{if .BodyHTML}}<article class="comment"> 21{{if .BodyHTML}}<article class="comment">
21 <header class="commenthead"><strong><a href="/{{.Issue.Author}}">{{.Issue.Author}}</a></strong> <span class="when">{{when .Issue.CreatedAt}}</span></header> 22 <header class="commenthead"><strong><a href="/{{.Issue.Author}}">{{.Issue.Author}}</a></strong> <span class="when">{{when .Issue.CreatedAt}}</span></header>
@@ -29,9 +30,10 @@
29</article>{{end}} 30</article>{{end}}
30{{end}} 31{{end}}
31{{if .Viewer}} 32{{if .Viewer}}
33{{if .Draft.Is "comment"}}{{template "previewblock" .Draft.HTML}}{{end}}
32<form method="post" action="/{{.Repo.OwnerName}}/{{.Repo.Name}}/issues/{{.Issue.Number}}/comment" class="commentform"> 34<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> 35<p><textarea name="body" aria-label="Comment" rows="4" placeholder="Comment as {{.Viewer}}">{{.Draft.Or "comment" "body" ""}}</textarea></p>
34<p><button type="submit">Comment</button></p> 36<p><button type="submit">Comment</button> {{template "previewbtn"}}</p>
35</form> 37</form>
36{{end}} 38{{end}}
37</div> 39</div>
internal/web/templates/issuenew.html +4 −3
@@ -4,11 +4,12 @@
4<h1>New issue</h1> 4<h1>New issue</h1>
5{{if gt (len .Templates) 1}}<p class="meta">template: 5{{if gt (len .Templates) 1}}<p class="meta">template:
6{{range .Templates}}{{if eq .Name $.Template}}<strong>{{.Name}}</strong>{{else}}<a href="?template={{.Name}}">{{.Name}}</a>{{end}} {{end}}</p>{{end}} 6{{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}}
7<form method="post" action="/{{.Repo.OwnerName}}/{{.Repo.Name}}/issues/new" class="commentform"> 8<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>
9<p><textarea name="body" aria-label="Description" rows="12">{{.Body}}</textarea></p> 10<p><textarea name="body" aria-label="Description" rows="12">{{.Body}}</textarea></p>
10{{template "formatpicker" .Format}} 11{{template "formatpicker" .Format}}
11<p><input type="text" name="labels" aria-label="Labels" placeholder="labels, space-separated (write access)"></p> 12<p><input type="text" name="labels" aria-label="Labels" placeholder="labels, space-separated (write access)" value="{{.Labels}}"></p>
12<p><button type="submit">Open issue</button></p> 13<p><button type="submit">Open issue</button> {{template "previewbtn"}}</p>
13</form> 14</form>
14{{end}} 15{{end}}
internal/web/templates/layout.html +12
@@ -133,6 +133,18 @@
133 </select> 133 </select>
134</p>{{end}} 134</p>{{end}}
135 135
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
136{{/* confirmfield is the typed-name check beside a destructive control. 148{{/* confirmfield is the typed-name check beside a destructive control.
137 The argument is the text the person must type. */}} 149 The argument is the text the person must type. */}}
138{{define "confirmfield"}}<input type="text" name="confirm" aria-label="Type {{.}} to confirm" placeholder="type {{.}} to confirm" size="{{len .}}" autocomplete="off">{{end}} 150{{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 @@
26 26
27{{if eq .View "conversation"}} 27{{if eq .View "conversation"}}
28<div class="prose"> 28<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}}
30<form method="post" action="{{$base}}/edit" class="commentform"> 31<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><input type="text" name="title" aria-label="Title" value="{{.Draft.Or "edit" "title" .MR.Title}}" required></p>
32<p><textarea name="body" aria-label="Description" rows="8">{{.MR.Body}}</textarea></p> 33<p><textarea name="body" aria-label="Description" rows="8">{{.Draft.Or "edit" "body" .MR.Body}}</textarea></p>
33<p><button type="submit" class="btn">Save</button></p> 34<p><button type="submit" class="btn">Save</button> {{template "previewbtn"}}</p>
34</form></details>{{end}} 35</form></details>{{end}}
35{{if .BodyHTML}}<article class="comment"> 36{{if .BodyHTML}}<article class="comment">
36 <header class="commenthead"><strong><a href="/{{.MR.Author}}">{{.MR.Author}}</a></strong></header> 37 <header class="commenthead"><strong><a href="/{{.MR.Author}}">{{.MR.Author}}</a></strong></header>
@@ -46,9 +47,10 @@
46{{if .DetachedThreads}}<h2>Threads on earlier revisions</h2> 47{{if .DetachedThreads}}<h2>Threads on earlier revisions</h2>
47{{range .DetachedThreads}}{{template "thread" dict "T" . "Base" $base "Viewer" $.Viewer "Class" "stale"}}{{end}}{{end}} 48{{range .DetachedThreads}}{{template "thread" dict "T" . "Base" $base "Viewer" $.Viewer "Class" "stale"}}{{end}}{{end}}
48{{if .Viewer}} 49{{if .Viewer}}
50{{if .Draft.Is "comment"}}{{template "previewblock" .Draft.HTML}}{{end}}
49<form method="post" action="{{$base}}/comment" class="commentform"> 51<form method="post" action="{{$base}}/comment" class="commentform">
50<p><textarea name="body" aria-label="Comment" rows="4" placeholder="Comment as {{.Viewer}}"></textarea></p> 52<p><textarea name="body" aria-label="Comment" rows="4" placeholder="Comment as {{.Viewer}}">{{.Draft.Or "comment" "body" ""}}</textarea></p>
51<p><button type="submit">Comment</button></p> 53<p><button type="submit">Comment</button> {{template "previewbtn"}}</p>
52</form> 54</form>
53{{end}} 55{{end}}
54</div> 56</div>
internal/web/templates/mrnew.html +2 −1
@@ -3,6 +3,7 @@
3{{define "content"}} 3{{define "content"}}
4<h1>New merge request</h1> 4<h1>New merge request</h1>
5{{if .Notice}}<p class="error" role="alert">{{.Notice}}</p>{{end}} 5{{if .Notice}}<p class="error" role="alert">{{.Notice}}</p>{{end}}
6{{if .Draft.Is "body"}}{{template "previewblock" .Draft.HTML}}{{end}}
6<form method="post" action="/{{.Repo.OwnerName}}/{{.Repo.Name}}/mrs/new" class="commentform"> 7<form method="post" action="/{{.Repo.OwnerName}}/{{.Repo.Name}}/mrs/new" class="commentform">
7<p class="branchpick"> 8<p class="branchpick">
8 <label for="source">Merge</label> 9 <label for="source">Merge</label>
@@ -18,7 +19,7 @@
18<p><input type="text" name="title" aria-label="Title" placeholder="title" value="{{.Title}}" required></p> 19<p><input type="text" name="title" aria-label="Title" placeholder="title" value="{{.Title}}" required></p>
19<p><textarea name="body" aria-label="Description" rows="10" placeholder="what changes, and why">{{.Body}}</textarea></p> 20<p><textarea name="body" aria-label="Description" rows="10" placeholder="what changes, and why">{{.Body}}</textarea></p>
20{{template "formatpicker" .Format}} 21{{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>
22</form> 23</form>
23<p class="meta">Branches of a fork you can push to are offered as 24<p class="meta">Branches of a fork you can push to are offered as
24<code>owner/name:branch</code>. Fork this repository to propose a change 25<code>owner/name:branch</code>. Fork this repository to propose a change
internal/web/templates/releases.html +13 −9
@@ -3,17 +3,19 @@
3<h1>Releases</h1> 3<h1>Releases</h1>
4{{if .Notice}}<p class="error" role="alert">{{.Notice}}</p>{{end}} 4{{if .Notice}}<p class="error" role="alert">{{.Notice}}</p>{{end}}
5{{if and .CanWrite .FreeTags}} 5{{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}}
7<form method="post" action="/{{.Repo.OwnerName}}/{{.Repo.Name}}/releases" class="commentform"> 9<form method="post" action="/{{.Repo.OwnerName}}/{{.Repo.Name}}/releases" class="commentform">
8 <p class="branchpick"> 10 <p class="branchpick">
9 <label for="tag">Tag</label> 11 <label for="tag">Tag</label>
10 <select id="tag" name="tag" required> 12 <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}}
12 </select> 14 </select>
13 </p> 15 </p>
14 <p><input type="text" name="title" aria-label="Title" placeholder="title (defaults to the tag)"></p> 16 <p><input type="text" name="title" aria-label="Title" placeholder="title (defaults to the tag)" value="{{.Draft.Or "release" "title" ""}}"></p>
15 <p><textarea name="notes" aria-label="Notes" rows="6" placeholder="what changed"></textarea></p> 17 <p><textarea name="notes" aria-label="Notes" rows="6" placeholder="what changed">{{.Draft.Or "release" "notes" ""}}</textarea></p>
16 <p><button type="submit" class="btn">Create release</button></p> 18 <p><button type="submit" class="btn">Create release</button> {{template "previewbtn"}}</p>
17</form> 19</form>
18</details> 20</details>
19{{else if .CanWrite}}<p class="meta">Push a tag to create a release from it.</p>{{end}} 21{{else if .CanWrite}}<p class="meta">Push a tag to create a release from it.</p>{{end}}
@@ -24,13 +26,15 @@
24 <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> 26 <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>
25 </header> 27 </header>
26 {{if $rel.NotesHTML}}<div class="rendered">{{$rel.NotesHTML}}</div>{{end}} 28 {{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}}
28 <form method="post" action="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/releases" class="commentform"> 32 <form method="post" action="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/releases" class="commentform">
29 <input type="hidden" name="action" value="edit"> 33 <input type="hidden" name="action" value="edit">
30 <input type="hidden" name="tag" value="{{$rel.Tag}}"> 34 <input type="hidden" name="tag" value="{{$rel.Tag}}">
31 <p><input type="text" name="title" aria-label="Title" value="{{$rel.Title}}"></p> 35 <p><input type="text" name="title" aria-label="Title" value="{{$.Draft.Or $form "title" $rel.Title}}"></p>
32 <p><textarea name="notes" aria-label="Notes" rows="6">{{$rel.Notes}}</textarea></p> 36 <p><textarea name="notes" aria-label="Notes" rows="6">{{$.Draft.Or $form "notes" $rel.Notes}}</textarea></p>
33 <p><button type="submit" class="btn">Save</button></p> 37 <p><button type="submit" class="btn">Save</button> {{template "previewbtn"}}</p>
34 </form> 38 </form>
35 {{if $.CanAdmin}}<form method="post" action="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/releases" class="commentform"> 39 {{if $.CanAdmin}}<form method="post" action="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/releases" class="commentform">
36 <input type="hidden" name="action" value="delete"> 40 <input type="hidden" name="action" value="delete">