Commit 929f2440bf
Verified · cmc ci/build: success ci/test: success
.gitbay/wiki/Parity.org +17
| @@ -52,6 +52,7 @@ browser-only and the iOS build screen unable to say more than the log. | ||
| 52 | 52 | | filter by label | yes | yes | no | |
| 53 | 53 | | request a review | yes | yes | yes | |
| 54 | 54 | | choose body markup | yes | yes | yes | |
| 55 | | preview body markup | n/a | yes | no | | |
| 55 | 56 | | stacked merge requests | yes | yes | yes | |
| 56 | 57 | | revisions | yes | yes | yes | |
| 57 | 58 | | range-diff | yes | no | yes | |
| @@ -114,6 +115,7 @@ reviews, since an approval was of the diff against the old branch. | ||
| 114 | 115 | | milestone list | yes | yes | yes | |
| 115 | 116 | | labels: list, colour | yes | yes | yes | |
| 116 | 117 | | choose body markup | yes | yes | yes | |
| 118 | | preview body markup | n/a | yes | no | | |
| 117 | 119 | | issue templates | yes | yes | yes | |
| 118 | 120 | | milestone create, close, reopen | yes | no | yes | |
| 119 | 121 | | org labels: set, list, remove | yes | list | yes | |
| @@ -144,6 +146,18 @@ body was stored in, since starting elsewhere would silently reinterpret | ||
| 144 | 146 | it on the next save. Diff-line comments have no format column and are |
| 145 | 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 | 161 | * Repositories |
| 148 | 162 | |
| 149 | 163 | | capability | cli | web | ios | |
| @@ -160,6 +174,7 @@ always markdown. | ||
| 160 | 174 | | wiki (read) | yes | yes | yes | |
| 161 | 175 | | download an archive | yes | yes | n/a | |
| 162 | 176 | | edit a file | yes | yes | yes | |
| 177 | | preview an edited markup file | n/a | yes | no | | |
| 163 | 178 | | create | yes | yes | yes | |
| 164 | 179 | | fork | yes | yes | yes | |
| 165 | 180 | | pin | yes | yes | yes | |
| @@ -183,6 +198,7 @@ always markdown. | ||
| 183 | 198 | | release list, show | yes | yes | yes | |
| 184 | 199 | | atom feeds | n/a | yes | n/a | |
| 185 | 200 | | release create, edit | yes | yes | yes | |
| 201 | | preview release notes | n/a | yes | no | | |
| 186 | 202 | | build list | yes | yes | yes | |
| 187 | 203 | | build list filters (ref, status, job) | yes | yes | yes | |
| 188 | 204 | | build show (one build) | yes | yes | yes | |
| @@ -312,6 +328,7 @@ client has no use for one (krz/gitbay#57). | ||
| 312 | 328 | | API token mint | yes | no | no | |
| 313 | 329 | | account export bundle | yes | yes | n/a | |
| 314 | 330 | | profile set | yes | yes | yes | |
| 331 | | preview profile about | n/a | yes | no | | |
| 315 | 332 | | request a login link | n/a | yes | n/a | |
| 316 | 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 | 12 | account becomes active: an invite redeemed, or an open-mode signup |
| 13 | 13 | that verified its address. Off by default, requires =[mail]=, and |
| 14 | 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 | 21 | - Links inside running text are underlined: the meta lines on issues, |
| 17 | 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 | 34 | // accountForm renders the account's own settings: keys, addresses, and the |
| 35 | 35 | // commands for everything that stays on SSH. |
| 36 | 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 | 43 | var keys []accountKey |
| 38 | 44 | if list, err := s.st.ListSSHKeys(u.ID); err == nil { |
| 39 | 45 | for _, k := range list { |
| @@ -75,8 +81,9 @@ func (s *Server) accountForm(w http.ResponseWriter, r *http.Request, u store.Use | ||
| 75 | 81 | MailOn bool |
| 76 | 82 | WatchOn bool |
| 77 | 83 | ThemeSetting string // system, light or dark: the form's selected option |
| 84 | Draft *draft | |
| 78 | 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 | 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 | 247 | back("", "notification preferences saved") |
| 241 | 248 | case "profile": |
| 242 | format := r.FormValue("format") | |
| 243 | if format != "org" { | |
| 244 | format = "md" | |
| 249 | format := bodyFormat(r) | |
| 250 | if wantsPreview(r) { | |
| 251 | s.accountPage(w, r, u, s.draftWith(r, "about", format, r.FormValue("about"), ugcHTML)) | |
| 252 | return | |
| 245 | 253 | } |
| 246 | 254 | argv := []string{"profile", "set", |
| 247 | 255 | "--description", r.FormValue("description"), |
internal/httpd/accounts.go +64 −5
| @@ -2,8 +2,10 @@ package httpd | ||
| 2 | 2 | |
| 3 | 3 | import ( |
| 4 | 4 | "fmt" |
| 5 | "html/template" | |
| 5 | 6 | "log" |
| 6 | 7 | "net/http" |
| 8 | "path" | |
| 7 | 9 | "slices" |
| 8 | 10 | "strconv" |
| 9 | 11 | "strings" |
| @@ -341,13 +343,30 @@ func (s *Server) signupSubmit(w http.ResponseWriter, r *http.Request) { | ||
| 341 | 343 | } |
| 342 | 344 | |
| 343 | 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 | 349 | func (s *Server) issueCreateForm(w http.ResponseWriter, r *http.Request, u store.User) { |
| 346 | 350 | p, ok := s.repoFor(w, r, "") |
| 347 | 351 | if !ok { |
| 348 | 352 | return |
| 349 | 353 | } |
| 350 | 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 | 370 | templates := control.IssueTemplates(p.Dir, p.Repo.DefaultBranch) |
| 352 | 371 | body, tplName := "", "" |
| 353 | 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 | 393 | repoPage |
| 375 | 394 | Body string |
| 376 | 395 | Format string |
| 396 | Title string | |
| 397 | Labels string | |
| 377 | 398 | Template string |
| 378 | 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 | 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 | 408 | func (s *Server) issueCreateSubmit(w http.ResponseWriter, r *http.Request, u store.User) { |
| 387 | 409 | repoPath := r.PathValue("owner") + "/" + r.PathValue("repo") |
| 388 | 410 | title := strings.TrimSpace(r.FormValue("title")) |
| 389 | format := r.FormValue("format") | |
| 390 | if format != "org" { | |
| 391 | format = "md" | |
| 411 | format := bodyFormat(r) | |
| 412 | if wantsPreview(r) { | |
| 413 | s.issueCreateForm(w, r, u) | |
| 414 | return | |
| 392 | 415 | } |
| 393 | 416 | var created control.Created |
| 394 | 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 | 434 | func (s *Server) issueEditSubmit(w http.ResponseWriter, r *http.Request, u store.User) { |
| 412 | 435 | repoPath := r.PathValue("owner") + "/" + r.PathValue("repo") |
| 413 | 436 | n := r.PathValue("n") |
| 437 | if wantsPreview(r) { | |
| 438 | s.issuePage(w, r, "edit") | |
| 439 | return | |
| 440 | } | |
| 414 | 441 | title := strings.TrimSpace(r.FormValue("title")) |
| 415 | 442 | code, msg := s.dispatchJSON(u, []string{"issue", "edit", repoPath, n, "--title", title, "--file", "-"}, r.FormValue("body")) |
| 416 | 443 | if code != protocol.ExitOK { |
| @@ -444,6 +471,10 @@ func (s *Server) issueEditSubmit(w http.ResponseWriter, r *http.Request, u store | ||
| 444 | 471 | func (s *Server) mrEditSubmit(w http.ResponseWriter, r *http.Request, u store.User) { |
| 445 | 472 | repoPath := r.PathValue("owner") + "/" + r.PathValue("repo") |
| 446 | 473 | n := r.PathValue("n") |
| 474 | if wantsPreview(r) { | |
| 475 | s.mrPage(w, r, "edit") | |
| 476 | return | |
| 477 | } | |
| 447 | 478 | title := strings.TrimSpace(r.FormValue("title")) |
| 448 | 479 | code, msg := s.dispatchJSON(u, []string{"mr", "edit", repoPath, n, "--title", title, "--file", "-"}, r.FormValue("body")) |
| 449 | 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 | 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 | 492 | s.commentSubmit(w, r, u, "issue", "issues") |
| 458 | 493 | } |
| 459 | 494 | |
| 460 | 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 | 500 | s.commentSubmit(w, r, u, "mr", "mrs") |
| 462 | 501 | } |
| 463 | 502 | |
| @@ -482,6 +521,10 @@ type editPage struct { | ||
| 482 | 521 | Blocked string |
| 483 | 522 | // Creating marks a path the branch does not have yet. |
| 484 | 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 | 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 | 562 | s.render(w, "edit.html", editPage{ |
| 520 | 563 | basePage: s.baseFor(u), Repo: repo, |
| 521 | 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 | 574 | ref := r.PathValue("ref") |
| 531 | 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 | 591 | // Editing is a control command; the web supplies the form and lets |
| 534 | 592 | // the registry enforce the rules — signed-commit policy, verified |
| 535 | 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 | 599 | s.render(w, "edit.html", editPage{ |
| 542 | 600 | basePage: s.baseFor(u), Repo: repo, |
| 543 | 601 | Ref: ref, Path: filePath, Content: r.FormValue("content"), Error: msg, |
| 602 | Markup: markupFile(filePath), | |
| 544 | 603 | }) |
| 545 | 604 | return |
| 546 | 605 | } |
internal/httpd/mractions.go +13 −3
| @@ -184,6 +184,7 @@ type mrNewPage struct { | ||
| 184 | 184 | Body string |
| 185 | 185 | Format string |
| 186 | 186 | Notice string |
| 187 | Draft *draft | |
| 187 | 188 | } |
| 188 | 189 | |
| 189 | 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 | 248 | target := strings.TrimSpace(r.FormValue("target")) |
| 248 | 249 | title := strings.TrimSpace(r.FormValue("title")) |
| 249 | 250 | body := strings.TrimSpace(r.FormValue("body")) |
| 250 | format := r.FormValue("format") | |
| 251 | if format != "org" { | |
| 252 | format = "md" | |
| 251 | format := bodyFormat(r) | |
| 252 | ||
| 253 | // Preview: the same page back with the draft intact, nothing opened. | |
| 254 | if wantsPreview(r) { | |
| 255 | p.Tab = "merge requests" | |
| 256 | branches, _ := gitutil.Refs(p.Dir, "heads") | |
| 257 | s.render(w, "mrnew.html", mrNewPage{ | |
| 258 | repoPage: p, Branches: branches, Sources: s.mrSources(u, p), | |
| 259 | Source: source, Target: target, Title: title, Body: body, Format: format, | |
| 260 | Draft: s.draftFor(r, p.Repo, "body", "body", format), | |
| 261 | }) | |
| 262 | return | |
| 253 | 263 | } |
| 254 | 264 | |
| 255 | 265 | back := func(msg string) { |
internal/httpd/mrpage_test.go +1
| @@ -37,6 +37,7 @@ type mrPageData struct { | ||
| 37 | 37 | HeadMerged bool |
| 38 | 38 | HeadPruned bool |
| 39 | 39 | Base string |
| 40 | Draft *draft | |
| 40 | 41 | } |
| 41 | 42 | |
| 42 | 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 | 27 | s.backTo(w, r, "releases", "pick a tag") |
| 28 | 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 | 41 | back := func(w http.ResponseWriter, r *http.Request, msg string) { s.backTo(w, r, "releases", msg) } |
| 31 | 42 | // The CLI's --yes guards against a mistyped tag; here the tag comes |
| 32 | 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 | 578 | // Markdown and org render like a README, with the source one click |
| 579 | 579 | // away; ?view=source shows the text instead. |
| 580 | renderable := false | |
| 581 | switch path.Ext(strings.ToLower(filePath)) { | |
| 582 | case ".md", ".markdown", ".org": | |
| 583 | renderable = !binary | |
| 584 | } | |
| 580 | renderable := markupFile(filePath) && !binary | |
| 585 | 581 | var renderedHTML template.HTML |
| 586 | 582 | rendered := renderable && r.URL.Query().Get("view") != "source" |
| 587 | 583 | if rendered { |
| @@ -628,6 +624,13 @@ func (s *Server) blob(w http.ResponseWriter, r *http.Request) { | ||
| 628 | 624 | |
| 629 | 625 | // releases lists tag-anchored releases with notes and assets. |
| 630 | 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 | 634 | p, ok := s.repoFor(w, r, "") |
| 632 | 635 | if !ok { |
| 633 | 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 | 682 | s.render(w, "releases.html", struct { |
| 666 | 683 | repoPage |
| 667 | 684 | Releases []relView |
| 668 | 685 | FreeTags []string |
| 669 | 686 | CanWrite bool |
| 670 | 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 | 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 | 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 | 1763 | p, ok := s.repoFor(w, r, "") |
| 1738 | 1764 | if !ok { |
| 1739 | 1765 | return |
| @@ -1755,6 +1781,17 @@ func (s *Server) issue(w http.ResponseWriter, r *http.Request) { | ||
| 1755 | 1781 | return |
| 1756 | 1782 | } |
| 1757 | 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 | 1795 | // nil readable: the picker lists titles, never the progress counts. |
| 1759 | 1796 | milestones, _ := s.st.ListMilestones(p.Repo, "open", nil) |
| 1760 | 1797 | s.render(w, "issue.html", struct { |
| @@ -1767,9 +1804,10 @@ func (s *Server) issue(w http.ResponseWriter, r *http.Request) { | ||
| 1767 | 1804 | Milestones []store.Milestone |
| 1768 | 1805 | Notice string |
| 1769 | 1806 | LabelColors map[string]template.CSS |
| 1807 | Draft *draft | |
| 1770 | 1808 | }{p, iss, md(iss.Body, iss.BodyFormat), renderComments(comments, md), |
| 1771 | 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 | 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 | 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 | 1924 | p, ok := s.repoFor(w, r, "") |
| 1880 | 1925 | if !ok { |
| 1881 | 1926 | return |
| @@ -2006,6 +2051,16 @@ func (s *Server) mr(w http.ResponseWriter, r *http.Request) { | ||
| 2006 | 2051 | // The merge requests this one superseded when it was closed, so the |
| 2007 | 2052 | // page it points to can also say what it supersedes. |
| 2008 | 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 | 2064 | s.render(w, "mr.html", struct { |
| 2010 | 2065 | repoPage |
| 2011 | 2066 | MR store.MR |
| @@ -2036,10 +2091,11 @@ func (s *Server) mr(w http.ResponseWriter, r *http.Request) { | ||
| 2036 | 2091 | HeadPruned bool |
| 2037 | 2092 | Base string |
| 2038 | 2093 | LabelColors map[string]template.CSS |
| 2094 | Draft *draft | |
| 2039 | 2095 | }{p, m, view, md(m.Body, m.BodyFormat), checks, combined, renderComments(comments, md), |
| 2040 | 2096 | reviewRows, files, diffTruncated, stat, commits, commitsTotal, branches, s.canEditItem(r, p.Repo, m.Author), |
| 2041 | 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 | 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 | 651 | .editform textarea { width: 100%; } |
| 652 | 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 | 676 | form.commentform { margin: var(--sp-4) 0; max-width: 48rem; } |
| 655 | 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 | 8 | <h2>Profile</h2> |
| 9 | 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 | 11 | <form method="post" action="/settings" class="setform stack"> |
| 11 | 12 | <input type="hidden" name="field" value="profile"> |
| 12 | 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 | 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 | 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 | 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 | {{template "formatpicker" .Profile.AboutFormat}} | |
| 21 | <button type="submit" class="btn">Save profile</button> | |
| 20 | <textarea id="p-about" name="about" rows="8" placeholder="longer, shown below your repositories">{{.Draft.Or "about" "about" .Profile.About}}</textarea> | |
| 21 | {{template "formatpicker" (.Draft.Or "about" "format" .Profile.AboutFormat)}} | |
| 22 | <span class="btngroup"><button type="submit" class="btn">Save profile</button>{{template "previewbtn"}}</span> | |
| 22 | 23 | </form> |
| 23 | 24 | |
| 24 | 25 | <h2>SSH keys</h2> |
internal/web/templates/edit.html +3 −2
| @@ -5,10 +5,11 @@ | ||
| 5 | 5 | {{if .Error}}<p class="error" role="alert">{{.Error}}</p>{{end}} |
| 6 | 6 | {{if .Blocked}}<p class="empty-note">{{.Blocked}}</p>{{else}} |
| 7 | 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 | 9 | <form method="post" action="/{{.Repo.OwnerName}}/{{.Repo.Name}}/edit/{{.Ref}}/{{.Path}}" class="editform"> |
| 9 | 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 | <button type="submit">Commit to {{.Ref}}</button></p> | |
| 11 | <p><input name="message" aria-label="Commit message" placeholder="commit message" value="{{.Draft.Or "content" "message" ""}}"> | |
| 12 | <button type="submit">Commit to {{.Ref}}</button>{{if .Markup}} {{template "previewbtn"}}{{end}}</p> | |
| 12 | 13 | <p class="crumbs">this commit will be unsigned and authored as {{.Viewer}}</p> |
| 13 | 14 | </form> |
| 14 | 15 | {{end}} |
internal/web/templates/issue.html +9 −7
| @@ -10,12 +10,13 @@ | ||
| 10 | 10 | |
| 11 | 11 | <div class="withaside"> |
| 12 | 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 | 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><textarea name="body" aria-label="Description" rows="8">{{.Issue.Body}}</textarea></p> | |
| 17 | <p><input type="text" name="labels" aria-label="Labels" value="{{range $i, $l := .Issue.Labels}}{{if $i}} {{end}}{{$l}}{{end}}" placeholder="labels, space-separated (write access)"></p> | |
| 18 | <p><button type="submit" class="btn">Save</button></p> | |
| 16 | <p><input type="text" name="title" aria-label="Title" value="{{.Draft.Or "edit" "title" .Issue.Title}}" required></p> | |
| 17 | <p><textarea name="body" aria-label="Description" rows="8">{{.Draft.Or "edit" "body" .Issue.Body}}</textarea></p> | |
| 18 | <p><input type="text" name="labels" aria-label="Labels" value="{{$.Draft.Or "edit" "labels" (join .Issue.Labels " ")}}" placeholder="labels, space-separated (write access)"></p> | |
| 19 | <p><button type="submit" class="btn">Save</button> {{template "previewbtn"}}</p> | |
| 19 | 20 | </form></details>{{end}} |
| 20 | 21 | {{if .BodyHTML}}<article class="comment"> |
| 21 | 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 | 30 | </article>{{end}} |
| 30 | 31 | {{end}} |
| 31 | 32 | {{if .Viewer}} |
| 33 | {{if .Draft.Is "comment"}}{{template "previewblock" .Draft.HTML}}{{end}} | |
| 32 | 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> | |
| 34 | <p><button type="submit">Comment</button></p> | |
| 35 | <p><textarea name="body" aria-label="Comment" rows="4" placeholder="Comment as {{.Viewer}}">{{.Draft.Or "comment" "body" ""}}</textarea></p> | |
| 36 | <p><button type="submit">Comment</button> {{template "previewbtn"}}</p> | |
| 35 | 37 | </form> |
| 36 | 38 | {{end}} |
| 37 | 39 | </div> |
internal/web/templates/issuenew.html +4 −3
| @@ -4,11 +4,12 @@ | ||
| 4 | 4 | <h1>New issue</h1> |
| 5 | 5 | {{if gt (len .Templates) 1}}<p class="meta">template: |
| 6 | 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 | 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 | 10 | <p><textarea name="body" aria-label="Description" rows="12">{{.Body}}</textarea></p> |
| 10 | 11 | {{template "formatpicker" .Format}} |
| 11 | <p><input type="text" name="labels" aria-label="Labels" placeholder="labels, space-separated (write access)"></p> | |
| 12 | <p><button type="submit">Open issue</button></p> | |
| 12 | <p><input type="text" name="labels" aria-label="Labels" placeholder="labels, space-separated (write access)" value="{{.Labels}}"></p> | |
| 13 | <p><button type="submit">Open issue</button> {{template "previewbtn"}}</p> | |
| 13 | 14 | </form> |
| 14 | 15 | {{end}} |
internal/web/templates/layout.html +12
| @@ -133,6 +133,18 @@ | ||
| 133 | 133 | </select> |
| 134 | 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 | 148 | {{/* confirmfield is the typed-name check beside a destructive control. |
| 137 | 149 | The argument is the text the person must type. */}} |
| 138 | 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 | 27 | {{if eq .View "conversation"}} |
| 28 | 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 | 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><textarea name="body" aria-label="Description" rows="8">{{.MR.Body}}</textarea></p> | |
| 33 | <p><button type="submit" class="btn">Save</button></p> | |
| 32 | <p><input type="text" name="title" aria-label="Title" value="{{.Draft.Or "edit" "title" .MR.Title}}" required></p> | |
| 33 | <p><textarea name="body" aria-label="Description" rows="8">{{.Draft.Or "edit" "body" .MR.Body}}</textarea></p> | |
| 34 | <p><button type="submit" class="btn">Save</button> {{template "previewbtn"}}</p> | |
| 34 | 35 | </form></details>{{end}} |
| 35 | 36 | {{if .BodyHTML}}<article class="comment"> |
| 36 | 37 | <header class="commenthead"><strong><a href="/{{.MR.Author}}">{{.MR.Author}}</a></strong></header> |
| @@ -46,9 +47,10 @@ | ||
| 46 | 47 | {{if .DetachedThreads}}<h2>Threads on earlier revisions</h2> |
| 47 | 48 | {{range .DetachedThreads}}{{template "thread" dict "T" . "Base" $base "Viewer" $.Viewer "Class" "stale"}}{{end}}{{end}} |
| 48 | 49 | {{if .Viewer}} |
| 50 | {{if .Draft.Is "comment"}}{{template "previewblock" .Draft.HTML}}{{end}} | |
| 49 | 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> | |
| 51 | <p><button type="submit">Comment</button></p> | |
| 52 | <p><textarea name="body" aria-label="Comment" rows="4" placeholder="Comment as {{.Viewer}}">{{.Draft.Or "comment" "body" ""}}</textarea></p> | |
| 53 | <p><button type="submit">Comment</button> {{template "previewbtn"}}</p> | |
| 52 | 54 | </form> |
| 53 | 55 | {{end}} |
| 54 | 56 | </div> |
internal/web/templates/mrnew.html +2 −1
| @@ -3,6 +3,7 @@ | ||
| 3 | 3 | {{define "content"}} |
| 4 | 4 | <h1>New merge request</h1> |
| 5 | 5 | {{if .Notice}}<p class="error" role="alert">{{.Notice}}</p>{{end}} |
| 6 | {{if .Draft.Is "body"}}{{template "previewblock" .Draft.HTML}}{{end}} | |
| 6 | 7 | <form method="post" action="/{{.Repo.OwnerName}}/{{.Repo.Name}}/mrs/new" class="commentform"> |
| 7 | 8 | <p class="branchpick"> |
| 8 | 9 | <label for="source">Merge</label> |
| @@ -18,7 +19,7 @@ | ||
| 18 | 19 | <p><input type="text" name="title" aria-label="Title" placeholder="title" value="{{.Title}}" required></p> |
| 19 | 20 | <p><textarea name="body" aria-label="Description" rows="10" placeholder="what changes, and why">{{.Body}}</textarea></p> |
| 20 | 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 | 23 | </form> |
| 23 | 24 | <p class="meta">Branches of a fork you can push to are offered as |
| 24 | 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 | 3 | <h1>Releases</h1> |
| 4 | 4 | {{if .Notice}}<p class="error" role="alert">{{.Notice}}</p>{{end}} |
| 5 | 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 | 9 | <form method="post" action="/{{.Repo.OwnerName}}/{{.Repo.Name}}/releases" class="commentform"> |
| 8 | 10 | <p class="branchpick"> |
| 9 | 11 | <label for="tag">Tag</label> |
| 10 | 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 | 14 | </select> |
| 13 | 15 | </p> |
| 14 | <p><input type="text" name="title" aria-label="Title" placeholder="title (defaults to the tag)"></p> | |
| 15 | <p><textarea name="notes" aria-label="Notes" rows="6" placeholder="what changed"></textarea></p> | |
| 16 | <p><button type="submit" class="btn">Create release</button></p> | |
| 16 | <p><input type="text" name="title" aria-label="Title" placeholder="title (defaults to the tag)" value="{{.Draft.Or "release" "title" ""}}"></p> | |
| 17 | <p><textarea name="notes" aria-label="Notes" rows="6" placeholder="what changed">{{.Draft.Or "release" "notes" ""}}</textarea></p> | |
| 18 | <p><button type="submit" class="btn">Create release</button> {{template "previewbtn"}}</p> | |
| 17 | 19 | </form> |
| 18 | 20 | </details> |
| 19 | 21 | {{else if .CanWrite}}<p class="meta">Push a tag to create a release from it.</p>{{end}} |
| @@ -24,13 +26,15 @@ | ||
| 24 | 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 | 27 | </header> |
| 26 | 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 | 32 | <form method="post" action="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/releases" class="commentform"> |
| 29 | 33 | <input type="hidden" name="action" value="edit"> |
| 30 | 34 | <input type="hidden" name="tag" value="{{$rel.Tag}}"> |
| 31 | <p><input type="text" name="title" aria-label="Title" value="{{$rel.Title}}"></p> | |
| 32 | <p><textarea name="notes" aria-label="Notes" rows="6">{{$rel.Notes}}</textarea></p> | |
| 33 | <p><button type="submit" class="btn">Save</button></p> | |
| 35 | <p><input type="text" name="title" aria-label="Title" value="{{$.Draft.Or $form "title" $rel.Title}}"></p> | |
| 36 | <p><textarea name="notes" aria-label="Notes" rows="6">{{$.Draft.Or $form "notes" $rel.Notes}}</textarea></p> | |
| 37 | <p><button type="submit" class="btn">Save</button> {{template "previewbtn"}}</p> | |
| 34 | 38 | </form> |
| 35 | 39 | {{if $.CanAdmin}}<form method="post" action="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/releases" class="commentform"> |
| 36 | 40 | <input type="hidden" name="action" value="delete"> |