web: preview markup before writing it !433
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 | |||
| 144 | it on the next save. Diff-line comments have no format column and are | 146 | it on the next save. Diff-line comments have no format column and are |
| 145 | always markdown. | 147 | always markdown. |
| 146 | 148 | ||
| 149 | Every web form that takes markup has a Preview button beside its own | ||
| 150 | submit: issue and merge request create, their edit and comment boxes, | ||
| 151 | release create and edit, the profile about text, and the file editor on | ||
| 152 | a path the forge renders. It posts to the form's own action, which | ||
| 153 | renders the draft and hands the page back without writing, so what you | ||
| 154 | see is the rendering the thread will show, autolinks included. It is a | ||
| 155 | round trip rather than a live preview because the instance serves no | ||
| 156 | JavaScript, the same way the blob page's rendered/source toggle works. | ||
| 157 | The 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 | ||
| 159 | sit 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 @@ | |||
| 1 | package e2e | ||
| 2 | |||
| 3 | import ( | ||
| 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. | ||
| 15 | func 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. |
| 36 | func (s *Server) accountForm(w http.ResponseWriter, r *http.Request, u store.User) { | 36 | func (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). | ||
| 42 | func (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 | ||
| 3 | import ( | 3 | import ( |
| 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). | ||
| 345 | func (s *Server) issueCreateForm(w http.ResponseWriter, r *http.Request, u store.User) { | 349 | func (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 | |||
| 386 | func (s *Server) issueCreateSubmit(w http.ResponseWriter, r *http.Request, u store.User) { | 408 | func (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 | |||
| 411 | func (s *Server) issueEditSubmit(w http.ResponseWriter, r *http.Request, u store.User) { | 434 | func (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 | |||
| 444 | func (s *Server) mrEditSubmit(w http.ResponseWriter, r *http.Request, u store.User) { | 471 | func (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 | ||
| 456 | func (s *Server) issueCommentSubmit(w http.ResponseWriter, r *http.Request, u store.User) { | 487 | func (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 | ||
| 460 | func (s *Server) mrCommentSubmit(w http.ResponseWriter, r *http.Request, u store.User) { | 495 | func (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 | ||
| 487 | func (s *Server) editForm(w http.ResponseWriter, r *http.Request, u store.User) { | 530 | func (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 @@ | |||
| 1 | package httpd | ||
| 2 | |||
| 3 | import ( | ||
| 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. | ||
| 24 | type 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. | ||
| 36 | func (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. | ||
| 41 | func (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. | ||
| 50 | func 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. | ||
| 57 | func (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. | ||
| 64 | func (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. | ||
| 72 | func 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. | ||
| 82 | func 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. |
| 630 | func (s *Server) releases(w http.ResponseWriter, r *http.Request) { | 626 | func (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). | ||
| 633 | func (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 | ||
| 1736 | func (s *Server) issue(w http.ResponseWriter, r *http.Request) { | 1754 | func (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). | ||
| 1762 | func (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 | ||
| 1878 | func (s *Server) mr(w http.ResponseWriter, r *http.Request) { | 1916 | func (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). | ||
| 1923 | func (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 | |||
| 654 | form.commentform { margin: var(--sp-4) 0; max-width: 48rem; } | 676 | form.commentform { margin: var(--sp-4) 0; max-width: 48rem; } |
| 655 | form.commentform p:last-child { display: flex; align-items: center; gap: var(--sp-3); margin-bottom: 0; } | 677 | form.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"> |