Align PR comment composer with GitHub
Authored by
mfwolffe <wolffemf@dukes.jmu.edu>
- SHA
09fc23c6fef261efd0f9c966fad84abbcf1cad98- Parents
-
bb6c35c - Tree
0ba710a
09fc23c
09fc23c6fef261efd0f9c966fad84abbcf1cad98bb6c35c
0ba710a| Status | File | + | - |
|---|---|---|---|
| M |
docs/internal/pr-review.md
|
8 | 0 |
| A |
internal/web/handlers/repo/comment_editor.go
|
182 | 0 |
| A |
internal/web/handlers/repo/comment_editor_test.go
|
35 | 0 |
| M |
internal/web/handlers/repo/pulls.go
|
38 | 0 |
| M |
internal/web/static/css/shithub.css
|
340 | 0 |
| A |
internal/web/static/js/comment-editor.js
|
509 | 0 |
| M |
internal/web/templates/_layout.html
|
1 | 0 |
| M |
internal/web/templates/repo/pull_view.html
|
75 | 10 |
docs/internal/pr-review.mdmodified@@ -81,6 +81,8 @@ without starting a review" path) is `pending=false` from the start, | |||
| 81 | | `POST /{owner}/{repo}/pulls/{n}/reviews` | RequireUser | | 81 | | `POST /{owner}/{repo}/pulls/{n}/reviews` | RequireUser | |
| 82 | | `POST /{owner}/{repo}/pulls/{n}/reviews/{rid}/dismiss` | RequireUser | | 82 | | `POST /{owner}/{repo}/pulls/{n}/reviews/{rid}/dismiss` | RequireUser | |
| 83 | | `POST /{owner}/{repo}/pulls/{n}/reviewers` | RequireUser | | 83 | | `POST /{owner}/{repo}/pulls/{n}/reviewers` | RequireUser | |
| 84 | +| `GET /{owner}/{repo}/pulls/{n}.diff` | Public read | | ||
| 85 | +| `GET /{owner}/{repo}/pulls/{n}.patch` | Public read | | ||
| 84 | 86 | ||
| 85 | The settings/branches handler now also accepts | 87 | The settings/branches handler now also accepts |
| 86 | `required_review_count` and `dismiss_stale_reviews_on_push`. | 88 | `required_review_count` and `dismiss_stale_reviews_on_push`. |
@@ -91,6 +93,12 @@ the viewer. Merge and close controls are similarly driven by | |||
| 91 | `pull:merge` and `pull:close` decisions. Public viewers who can read a | 93 | `pull:merge` and `pull:close` decisions. Public viewers who can read a |
| 92 | PR should not see forms that only lead to 403s. | 94 | PR should not see forms that only lead to 403s. |
| 93 | 95 | ||
| 96 | +The Conversation tab uses the same markdown renderer as repo web-edit | ||
| 97 | +previews for comment preview. The composer suggestion payload is | ||
| 98 | +server-rendered from known PR participants, assignees, reviewers, and | ||
| 99 | +recent same-repo issues/PRs. Copilot users or actions are intentionally | ||
| 100 | +not included. | ||
| 101 | + | ||
| 94 | ## Required-review gate | 102 | ## Required-review gate |
| 95 | 103 | ||
| 96 | `internal/pulls/review/required.go::Evaluate` is the authoritative | 104 | `internal/pulls/review/required.go::Evaluate` is the authoritative |
internal/web/handlers/repo/comment_editor.goadded@@ -0,0 +1,182 @@ | |||
| 1 | +// SPDX-License-Identifier: AGPL-3.0-or-later | ||
| 2 | + | ||
| 3 | +package repo | ||
| 4 | + | ||
| 5 | +import ( | ||
| 6 | + "context" | ||
| 7 | + "encoding/json" | ||
| 8 | + "html/template" | ||
| 9 | + "net/url" | ||
| 10 | + "sort" | ||
| 11 | + "strings" | ||
| 12 | + | ||
| 13 | + "github.com/jackc/pgx/v5/pgtype" | ||
| 14 | + | ||
| 15 | + issuesdb "github.com/tenseleyFlow/shithub/internal/issues/sqlc" | ||
| 16 | + pullsdb "github.com/tenseleyFlow/shithub/internal/pulls/sqlc" | ||
| 17 | + reposdb "github.com/tenseleyFlow/shithub/internal/repos/sqlc" | ||
| 18 | + "github.com/tenseleyFlow/shithub/internal/web/middleware" | ||
| 19 | +) | ||
| 20 | + | ||
| 21 | +type commentEditorConfig struct { | ||
| 22 | + Mentions []commentEditorMention `json:"mentions"` | ||
| 23 | + References []commentEditorReference `json:"references"` | ||
| 24 | +} | ||
| 25 | + | ||
| 26 | +type commentEditorMention struct { | ||
| 27 | + Username string `json:"username"` | ||
| 28 | + DisplayName string `json:"displayName,omitempty"` | ||
| 29 | + AvatarURL string `json:"avatarUrl,omitempty"` | ||
| 30 | +} | ||
| 31 | + | ||
| 32 | +type commentEditorReference struct { | ||
| 33 | + Number int64 `json:"number"` | ||
| 34 | + Title string `json:"title"` | ||
| 35 | + Kind string `json:"kind"` | ||
| 36 | + State string `json:"state"` | ||
| 37 | +} | ||
| 38 | + | ||
| 39 | +func commentEditorConfigJSON(config commentEditorConfig) template.JS { | ||
| 40 | + body, err := json.Marshal(config) | ||
| 41 | + if err != nil { | ||
| 42 | + return template.JS(`{"mentions":[],"references":[]}`) //nolint:gosec // constant fallback | ||
| 43 | + } | ||
| 44 | + return template.JS(body) //nolint:gosec // json.Marshal escapes script-breaking characters | ||
| 45 | +} | ||
| 46 | + | ||
| 47 | +func commentEditorAvatarURL(username string) string { | ||
| 48 | + if strings.TrimSpace(username) == "" { | ||
| 49 | + return "" | ||
| 50 | + } | ||
| 51 | + return "/avatars/" + url.PathEscape(username) | ||
| 52 | +} | ||
| 53 | + | ||
| 54 | +func (h *Handlers) pullCommentEditorConfig( | ||
| 55 | + ctx context.Context, | ||
| 56 | + row reposdb.Repo, | ||
| 57 | + pr pullsdb.GetPullRequestByRepoAndNumberRow, | ||
| 58 | + viewer middleware.CurrentUser, | ||
| 59 | + comments []issuesdb.IssueComment, | ||
| 60 | + assignees []issuesdb.ListIssueAssigneesRow, | ||
| 61 | + reviews []pullsdb.PrReview, | ||
| 62 | + requests []pullsdb.PrReviewRequest, | ||
| 63 | +) commentEditorConfig { | ||
| 64 | + config := commentEditorConfig{} | ||
| 65 | + mentions := map[string]commentEditorMention{} | ||
| 66 | + | ||
| 67 | + addUserID := func(id int64) { | ||
| 68 | + if id == 0 { | ||
| 69 | + return | ||
| 70 | + } | ||
| 71 | + u, err := h.uq.GetUserByID(ctx, h.d.Pool, id) | ||
| 72 | + if err != nil || strings.EqualFold(u.Username, "copilot") { | ||
| 73 | + return | ||
| 74 | + } | ||
| 75 | + mentions[strings.ToLower(u.Username)] = commentEditorMention{ | ||
| 76 | + Username: u.Username, | ||
| 77 | + DisplayName: u.DisplayName, | ||
| 78 | + AvatarURL: commentEditorAvatarURL(u.Username), | ||
| 79 | + } | ||
| 80 | + } | ||
| 81 | + addUsername := func(username, displayName string) { | ||
| 82 | + username = strings.TrimSpace(username) | ||
| 83 | + if username == "" || strings.EqualFold(username, "copilot") { | ||
| 84 | + return | ||
| 85 | + } | ||
| 86 | + key := strings.ToLower(username) | ||
| 87 | + if _, ok := mentions[key]; ok { | ||
| 88 | + return | ||
| 89 | + } | ||
| 90 | + mentions[key] = commentEditorMention{ | ||
| 91 | + Username: username, | ||
| 92 | + DisplayName: displayName, | ||
| 93 | + AvatarURL: commentEditorAvatarURL(username), | ||
| 94 | + } | ||
| 95 | + } | ||
| 96 | + | ||
| 97 | + addUsername(viewer.Username, "") | ||
| 98 | + if pr.IAuthorUserID.Valid { | ||
| 99 | + addUserID(pr.IAuthorUserID.Int64) | ||
| 100 | + } | ||
| 101 | + if pr.MergedByUserID.Valid { | ||
| 102 | + addUserID(pr.MergedByUserID.Int64) | ||
| 103 | + } | ||
| 104 | + for _, comment := range comments { | ||
| 105 | + if comment.AuthorUserID.Valid { | ||
| 106 | + addUserID(comment.AuthorUserID.Int64) | ||
| 107 | + } | ||
| 108 | + } | ||
| 109 | + for _, assignee := range assignees { | ||
| 110 | + addUsername(assignee.Username, assignee.DisplayName) | ||
| 111 | + } | ||
| 112 | + for _, review := range reviews { | ||
| 113 | + if review.AuthorUserID.Valid { | ||
| 114 | + addUserID(review.AuthorUserID.Int64) | ||
| 115 | + } | ||
| 116 | + } | ||
| 117 | + for _, request := range requests { | ||
| 118 | + if request.RequestedUserID.Valid { | ||
| 119 | + addUserID(request.RequestedUserID.Int64) | ||
| 120 | + } | ||
| 121 | + if request.RequestedByUserID.Valid { | ||
| 122 | + addUserID(request.RequestedByUserID.Int64) | ||
| 123 | + } | ||
| 124 | + } | ||
| 125 | + | ||
| 126 | + config.Mentions = make([]commentEditorMention, 0, len(mentions)) | ||
| 127 | + for _, mention := range mentions { | ||
| 128 | + config.Mentions = append(config.Mentions, mention) | ||
| 129 | + } | ||
| 130 | + sort.SliceStable(config.Mentions, func(i, j int) bool { | ||
| 131 | + if strings.EqualFold(config.Mentions[i].Username, viewer.Username) { | ||
| 132 | + return true | ||
| 133 | + } | ||
| 134 | + if strings.EqualFold(config.Mentions[j].Username, viewer.Username) { | ||
| 135 | + return false | ||
| 136 | + } | ||
| 137 | + return strings.ToLower(config.Mentions[i].Username) < strings.ToLower(config.Mentions[j].Username) | ||
| 138 | + }) | ||
| 139 | + | ||
| 140 | + seenRefs := map[int64]struct{}{} | ||
| 141 | + addRef := func(number int64, title, kind, state string) { | ||
| 142 | + if number == 0 { | ||
| 143 | + return | ||
| 144 | + } | ||
| 145 | + if _, ok := seenRefs[number]; ok { | ||
| 146 | + return | ||
| 147 | + } | ||
| 148 | + seenRefs[number] = struct{}{} | ||
| 149 | + config.References = append(config.References, commentEditorReference{ | ||
| 150 | + Number: number, | ||
| 151 | + Title: title, | ||
| 152 | + Kind: kind, | ||
| 153 | + State: state, | ||
| 154 | + }) | ||
| 155 | + } | ||
| 156 | + addRef(pr.INumber, pr.ITitle, "pull request", string(pr.IState)) | ||
| 157 | + if prs, err := h.pq.ListPullRequestsByRepo(ctx, h.d.Pool, pullsdb.ListPullRequestsByRepoParams{ | ||
| 158 | + RepoID: row.ID, | ||
| 159 | + Limit: 8, | ||
| 160 | + StateFilter: pgtype.Text{}, | ||
| 161 | + Draft: pgtype.Bool{}, | ||
| 162 | + }); err == nil { | ||
| 163 | + for _, item := range prs { | ||
| 164 | + addRef(item.Number, item.Title, "pull request", string(item.State)) | ||
| 165 | + } | ||
| 166 | + } | ||
| 167 | + if issues, err := h.iq.ListIssues(ctx, h.d.Pool, issuesdb.ListIssuesParams{ | ||
| 168 | + RepoID: row.ID, | ||
| 169 | + Limit: 8, | ||
| 170 | + StateFilter: pgtype.Text{}, | ||
| 171 | + Kind: issuesdb.NullIssueKind{IssueKind: issuesdb.IssueKindIssue, Valid: true}, | ||
| 172 | + }); err == nil { | ||
| 173 | + for _, item := range issues { | ||
| 174 | + addRef(item.Number, item.Title, "issue", string(item.State)) | ||
| 175 | + } | ||
| 176 | + } | ||
| 177 | + if len(config.References) > 10 { | ||
| 178 | + config.References = config.References[:10] | ||
| 179 | + } | ||
| 180 | + | ||
| 181 | + return config | ||
| 182 | +} | ||
internal/web/handlers/repo/comment_editor_test.goadded@@ -0,0 +1,35 @@ | |||
| 1 | +// SPDX-License-Identifier: AGPL-3.0-or-later | ||
| 2 | + | ||
| 3 | +package repo | ||
| 4 | + | ||
| 5 | +import ( | ||
| 6 | + "strings" | ||
| 7 | + "testing" | ||
| 8 | +) | ||
| 9 | + | ||
| 10 | +func TestCommentEditorConfigJSONEscapesScriptBreakout(t *testing.T) { | ||
| 11 | + t.Parallel() | ||
| 12 | + | ||
| 13 | + got := string(commentEditorConfigJSON(commentEditorConfig{ | ||
| 14 | + Mentions: []commentEditorMention{{ | ||
| 15 | + Username: "alice", | ||
| 16 | + DisplayName: `</script><script>alert(1)</script>`, | ||
| 17 | + }}, | ||
| 18 | + })) | ||
| 19 | + | ||
| 20 | + if strings.Contains(got, "</script>") { | ||
| 21 | + t.Fatalf("config JSON contains raw script terminator: %s", got) | ||
| 22 | + } | ||
| 23 | + if !strings.Contains(got, `\u003c/script\u003e`) { | ||
| 24 | + t.Fatalf("config JSON did not preserve escaped display name: %s", got) | ||
| 25 | + } | ||
| 26 | +} | ||
| 27 | + | ||
| 28 | +func TestCommentEditorAvatarURLPathEscapesUsername(t *testing.T) { | ||
| 29 | + t.Parallel() | ||
| 30 | + | ||
| 31 | + got := commentEditorAvatarURL("team/user") | ||
| 32 | + if got != "/avatars/team%2Fuser" { | ||
| 33 | + t.Fatalf("avatar URL = %q, want escaped path segment", got) | ||
| 34 | + } | ||
| 35 | +} | ||
internal/web/handlers/repo/pulls.gomodified@@ -84,6 +84,8 @@ type pullCommitGroup struct { | |||
| 84 | // KindPRMerge in the audit remediation sprint.) | 84 | // KindPRMerge in the audit remediation sprint.) |
| 85 | func (h *Handlers) MountPulls(r chi.Router) { | 85 | func (h *Handlers) MountPulls(r chi.Router) { |
| 86 | r.Get("/{owner}/{repo}/pulls", h.pullsList) | 86 | r.Get("/{owner}/{repo}/pulls", h.pullsList) |
| 87 | + r.Get("/{owner}/{repo}/pulls/{number}.diff", h.pullRawDiff) | ||
| 88 | + r.Get("/{owner}/{repo}/pulls/{number}.patch", h.pullRawDiff) | ||
| 87 | r.Get("/{owner}/{repo}/pulls/{number}", h.pullView) | 89 | r.Get("/{owner}/{repo}/pulls/{number}", h.pullView) |
| 88 | r.Get("/{owner}/{repo}/pulls/{number}/files", h.pullFiles) | 90 | r.Get("/{owner}/{repo}/pulls/{number}/files", h.pullFiles) |
| 89 | r.Get("/{owner}/{repo}/pulls/{number}/commits", h.pullCommits) | 91 | r.Get("/{owner}/{repo}/pulls/{number}/commits", h.pullCommits) |
@@ -537,6 +539,9 @@ func (h *Handlers) pullView(w http.ResponseWriter, r *http.Request) { | |||
| 537 | "CanEditIssueAssignees": policy.Can(r.Context(), pdeps, actor, policy.ActionIssueAssign, repoRef).Allow, | 539 | "CanEditIssueAssignees": policy.Can(r.Context(), pdeps, actor, policy.ActionIssueAssign, repoRef).Allow, |
| 538 | "CanEditIssueMilestone": policy.Can(r.Context(), pdeps, actor, policy.ActionIssueLabel, repoRef).Allow, | 540 | "CanEditIssueMilestone": policy.Can(r.Context(), pdeps, actor, policy.ActionIssueLabel, repoRef).Allow, |
| 539 | "CanLockIssue": policy.Can(r.Context(), pdeps, actor, policy.ActionIssueClose, repoRef).Allow, | 541 | "CanLockIssue": policy.Can(r.Context(), pdeps, actor, policy.ActionIssueClose, repoRef).Allow, |
| 542 | + "UseCommentEditor": true, | ||
| 543 | + "ViewerAvatarURL": commentEditorAvatarURL(viewer.Username), | ||
| 544 | + "CommentEditorConfig": commentEditorConfigJSON(h.pullCommentEditorConfig(r.Context(), row, pr, viewer, comments, assignees, reviews, requests)), | ||
| 540 | }) | 545 | }) |
| 541 | } | 546 | } |
| 542 | 547 | ||
@@ -701,6 +706,39 @@ func pullFileAnchor(p string) string { | |||
| 701 | return b.String() | 706 | return b.String() |
| 702 | } | 707 | } |
| 703 | 708 | ||
| 709 | +func (h *Handlers) pullRawDiff(w http.ResponseWriter, r *http.Request) { | ||
| 710 | + row, owner, ok := h.loadRepoAndAuthorize(w, r, policy.ActionPullRead) | ||
| 711 | + if !ok { | ||
| 712 | + return | ||
| 713 | + } | ||
| 714 | + pr, ok := h.loadPullByNumber(w, r, row.ID) | ||
| 715 | + if !ok { | ||
| 716 | + return | ||
| 717 | + } | ||
| 718 | + if pr.BaseOid == "" || pr.HeadOid == "" { | ||
| 719 | + h.d.Render.HTTPError(w, r, http.StatusNotFound, "") | ||
| 720 | + return | ||
| 721 | + } | ||
| 722 | + gitDir, err := h.d.RepoFS.RepoPath(owner.Username, row.Name) | ||
| 723 | + if err != nil { | ||
| 724 | + h.d.Render.HTTPError(w, r, http.StatusNotFound, "") | ||
| 725 | + return | ||
| 726 | + } | ||
| 727 | + patch, err := compareSourceMergeBase(r, gitDir, pr.BaseOid, pr.HeadOid) | ||
| 728 | + if err != nil { | ||
| 729 | + h.d.Logger.WarnContext(r.Context(), "pulls: raw diff", "error", err, "repo_id", row.ID, "pr", pr.INumber) | ||
| 730 | + h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "") | ||
| 731 | + return | ||
| 732 | + } | ||
| 733 | + ext := ".diff" | ||
| 734 | + if strings.HasSuffix(r.URL.Path, ".patch") { | ||
| 735 | + ext = ".patch" | ||
| 736 | + } | ||
| 737 | + w.Header().Set("Content-Type", "text/plain; charset=utf-8") | ||
| 738 | + w.Header().Set("Content-Disposition", "inline; filename=\""+row.Name+"-"+strconv.FormatInt(pr.INumber, 10)+ext+"\"") | ||
| 739 | + _, _ = w.Write(patch) | ||
| 740 | +} | ||
| 741 | + | ||
| 704 | // pullChecks renders the Checks tab. Loads suites + runs grouped by | 742 | // pullChecks renders the Checks tab. Loads suites + runs grouped by |
| 705 | // suite for the PR's head_oid, plus the markdown-rendered output.summary | 743 | // suite for the PR's head_oid, plus the markdown-rendered output.summary |
| 706 | // for each run. | 744 | // for each run. |
internal/web/static/css/shithub.cssmodified@@ -8371,6 +8371,346 @@ button.shithub-repo-action { | |||
| 8371 | padding: 0.4rem; border: 1px solid var(--border-default); border-radius: 6px; font: inherit; | 8371 | padding: 0.4rem; border: 1px solid var(--border-default); border-radius: 6px; font: inherit; |
| 8372 | } | 8372 | } |
| 8373 | .shithub-pull-comment-form { margin-top: 1.25rem; } | 8373 | .shithub-pull-comment-form { margin-top: 1.25rem; } |
| 8374 | +.shithub-comment-composer-row { | ||
| 8375 | + display: grid; | ||
| 8376 | + grid-template-columns: 40px minmax(0, 1fr); | ||
| 8377 | + gap: 1rem; | ||
| 8378 | + margin-top: 1.25rem; | ||
| 8379 | +} | ||
| 8380 | +.shithub-comment-composer-avatar, | ||
| 8381 | +.shithub-comment-composer-avatar img { | ||
| 8382 | + display: block; | ||
| 8383 | + width: 40px; | ||
| 8384 | + height: 40px; | ||
| 8385 | + border-radius: 50%; | ||
| 8386 | +} | ||
| 8387 | +.shithub-comment-composer-avatar img { | ||
| 8388 | + border: 1px solid var(--border-muted); | ||
| 8389 | + background: var(--canvas-subtle); | ||
| 8390 | +} | ||
| 8391 | +.shithub-comment-composer-form { | ||
| 8392 | + min-width: 0; | ||
| 8393 | + margin-top: 0; | ||
| 8394 | +} | ||
| 8395 | +.shithub-comment-composer-title { | ||
| 8396 | + display: block; | ||
| 8397 | + margin: 0 0 0.5rem; | ||
| 8398 | + color: var(--fg-default); | ||
| 8399 | + font-weight: 600; | ||
| 8400 | +} | ||
| 8401 | +.shithub-comment-editor-box { | ||
| 8402 | + position: relative; | ||
| 8403 | + border: 1px solid var(--border-default); | ||
| 8404 | + border-radius: 6px; | ||
| 8405 | + background: var(--canvas-default); | ||
| 8406 | +} | ||
| 8407 | +.shithub-comment-editor-head { | ||
| 8408 | + display: flex; | ||
| 8409 | + align-items: center; | ||
| 8410 | + justify-content: space-between; | ||
| 8411 | + gap: 0.75rem; | ||
| 8412 | + min-height: 44px; | ||
| 8413 | + border-bottom: 1px solid var(--border-default); | ||
| 8414 | + background: var(--canvas-subtle); | ||
| 8415 | +} | ||
| 8416 | +.shithub-comment-editor-tabs { | ||
| 8417 | + display: flex; | ||
| 8418 | + align-self: stretch; | ||
| 8419 | +} | ||
| 8420 | +.shithub-comment-editor-tabs button { | ||
| 8421 | + min-width: 68px; | ||
| 8422 | + padding: 0 1rem; | ||
| 8423 | + border: 0; | ||
| 8424 | + border-right: 1px solid var(--border-default); | ||
| 8425 | + color: var(--fg-muted); | ||
| 8426 | + background: transparent; | ||
| 8427 | + cursor: pointer; | ||
| 8428 | + font: inherit; | ||
| 8429 | + font-weight: 500; | ||
| 8430 | +} | ||
| 8431 | +.shithub-comment-editor-tabs button.is-active { | ||
| 8432 | + margin-bottom: -1px; | ||
| 8433 | + color: var(--fg-default); | ||
| 8434 | + background: var(--canvas-default); | ||
| 8435 | +} | ||
| 8436 | +.shithub-comment-toolbar { | ||
| 8437 | + display: flex; | ||
| 8438 | + align-items: center; | ||
| 8439 | + justify-content: flex-end; | ||
| 8440 | + gap: 0.15rem; | ||
| 8441 | + min-width: 0; | ||
| 8442 | + padding: 0.35rem 0.45rem; | ||
| 8443 | + color: var(--fg-muted); | ||
| 8444 | + flex-wrap: wrap; | ||
| 8445 | +} | ||
| 8446 | +.shithub-comment-tool { | ||
| 8447 | + position: relative; | ||
| 8448 | + display: inline-flex; | ||
| 8449 | + align-items: center; | ||
| 8450 | + justify-content: center; | ||
| 8451 | + width: 30px; | ||
| 8452 | + height: 30px; | ||
| 8453 | + padding: 0; | ||
| 8454 | + border: 0; | ||
| 8455 | + border-radius: 6px; | ||
| 8456 | + color: var(--fg-muted); | ||
| 8457 | + background: transparent; | ||
| 8458 | + cursor: pointer; | ||
| 8459 | + font: inherit; | ||
| 8460 | +} | ||
| 8461 | +.shithub-comment-tool:hover, | ||
| 8462 | +.shithub-comment-tool:focus-visible, | ||
| 8463 | +.shithub-comment-tool.is-active { | ||
| 8464 | + color: var(--fg-default); | ||
| 8465 | + background: var(--button-default-hover-bg); | ||
| 8466 | + outline: none; | ||
| 8467 | +} | ||
| 8468 | +.shithub-comment-tool input[type="file"] { | ||
| 8469 | + position: absolute; | ||
| 8470 | + inset: 0; | ||
| 8471 | + width: 100%; | ||
| 8472 | + height: 100%; | ||
| 8473 | + opacity: 0; | ||
| 8474 | + cursor: pointer; | ||
| 8475 | +} | ||
| 8476 | +.shithub-comment-tool-text { | ||
| 8477 | + font-weight: 700; | ||
| 8478 | + font-size: 0.9rem; | ||
| 8479 | +} | ||
| 8480 | +.shithub-comment-tool-text.is-italic { | ||
| 8481 | + font-style: italic; | ||
| 8482 | +} | ||
| 8483 | +.shithub-comment-toolbar-separator { | ||
| 8484 | + width: 1px; | ||
| 8485 | + height: 20px; | ||
| 8486 | + margin: 0 0.25rem; | ||
| 8487 | + background: var(--border-default); | ||
| 8488 | +} | ||
| 8489 | +.shithub-comment-editor-write { | ||
| 8490 | + position: relative; | ||
| 8491 | +} | ||
| 8492 | +.shithub-comment-editor-write textarea { | ||
| 8493 | + display: block; | ||
| 8494 | + width: 100%; | ||
| 8495 | + min-height: 120px; | ||
| 8496 | + padding: 0.8rem; | ||
| 8497 | + border: 0; | ||
| 8498 | + border-radius: 0; | ||
| 8499 | + resize: vertical; | ||
| 8500 | + background: var(--canvas-default); | ||
| 8501 | + color: var(--fg-default); | ||
| 8502 | + font: inherit; | ||
| 8503 | + line-height: 1.5; | ||
| 8504 | +} | ||
| 8505 | +.shithub-comment-editor-write textarea:focus { | ||
| 8506 | + box-shadow: inset 0 0 0 2px var(--accent-emphasis); | ||
| 8507 | + outline: none; | ||
| 8508 | +} | ||
| 8509 | +.shithub-comment-editor-preview { | ||
| 8510 | + min-height: 120px; | ||
| 8511 | + padding: 0.8rem; | ||
| 8512 | +} | ||
| 8513 | +.shithub-comment-editor-footer { | ||
| 8514 | + display: flex; | ||
| 8515 | + align-items: center; | ||
| 8516 | + flex-wrap: wrap; | ||
| 8517 | + gap: 0.75rem; | ||
| 8518 | + min-height: 36px; | ||
| 8519 | + padding: 0.45rem 0.75rem; | ||
| 8520 | + border-top: 1px solid var(--border-default); | ||
| 8521 | + border-radius: 0 0 6px 6px; | ||
| 8522 | + color: var(--fg-muted); | ||
| 8523 | + background: var(--canvas-subtle); | ||
| 8524 | + font-size: 0.78rem; | ||
| 8525 | + font-weight: 600; | ||
| 8526 | +} | ||
| 8527 | +.shithub-comment-editor-footer span { | ||
| 8528 | + display: inline-flex; | ||
| 8529 | + align-items: center; | ||
| 8530 | + gap: 0.3rem; | ||
| 8531 | +} | ||
| 8532 | +.shithub-comment-file-list { | ||
| 8533 | + max-width: 100%; | ||
| 8534 | + overflow: hidden; | ||
| 8535 | + text-overflow: ellipsis; | ||
| 8536 | + white-space: nowrap; | ||
| 8537 | +} | ||
| 8538 | +.shithub-comment-policy-note, | ||
| 8539 | +.shithub-comment-protip { | ||
| 8540 | + margin: 0.75rem 0 0; | ||
| 8541 | + color: var(--fg-muted); | ||
| 8542 | + font-size: 0.8rem; | ||
| 8543 | +} | ||
| 8544 | +.shithub-comment-policy-note svg, | ||
| 8545 | +.shithub-comment-protip svg { | ||
| 8546 | + vertical-align: text-bottom; | ||
| 8547 | +} | ||
| 8548 | +.shithub-comment-suggestions { | ||
| 8549 | + position: absolute; | ||
| 8550 | + z-index: 60; | ||
| 8551 | + left: 0.75rem; | ||
| 8552 | + top: 2.8rem; | ||
| 8553 | + width: min(22rem, calc(100% - 1.5rem)); | ||
| 8554 | + max-height: 18rem; | ||
| 8555 | + overflow: auto; | ||
| 8556 | + border: 1px solid var(--border-default); | ||
| 8557 | + border-radius: 6px; | ||
| 8558 | + background: var(--canvas-overlay, var(--canvas-default)); | ||
| 8559 | + box-shadow: 0 8px 24px rgba(1, 4, 9, 0.35); | ||
| 8560 | +} | ||
| 8561 | +.shithub-comment-suggestions.is-slash { | ||
| 8562 | + width: min(20rem, calc(100% - 1.5rem)); | ||
| 8563 | +} | ||
| 8564 | +.shithub-comment-suggestion-section { | ||
| 8565 | + padding: 0.45rem 0.55rem; | ||
| 8566 | + border-bottom: 1px solid var(--border-default); | ||
| 8567 | + color: var(--fg-muted); | ||
| 8568 | + font-size: 0.75rem; | ||
| 8569 | + font-weight: 600; | ||
| 8570 | +} | ||
| 8571 | +.shithub-comment-suggestion-item { | ||
| 8572 | + display: grid; | ||
| 8573 | + grid-template-columns: 24px minmax(0, 1fr); | ||
| 8574 | + gap: 0.5rem; | ||
| 8575 | + align-items: center; | ||
| 8576 | + width: 100%; | ||
| 8577 | + min-height: 36px; | ||
| 8578 | + padding: 0.35rem 0.55rem; | ||
| 8579 | + border: 0; | ||
| 8580 | + border-bottom: 1px solid var(--border-muted); | ||
| 8581 | + color: var(--fg-default); | ||
| 8582 | + background: transparent; | ||
| 8583 | + cursor: pointer; | ||
| 8584 | + font: inherit; | ||
| 8585 | + text-align: left; | ||
| 8586 | +} | ||
| 8587 | +.shithub-comment-suggestion-item:last-child { | ||
| 8588 | + border-bottom: 0; | ||
| 8589 | +} | ||
| 8590 | +.shithub-comment-suggestion-item:hover, | ||
| 8591 | +.shithub-comment-suggestion-item.is-active { | ||
| 8592 | + color: #fff; | ||
| 8593 | + background: var(--accent-emphasis, #0969da); | ||
| 8594 | +} | ||
| 8595 | +.shithub-comment-suggestion-item img { | ||
| 8596 | + width: 20px; | ||
| 8597 | + height: 20px; | ||
| 8598 | + border-radius: 50%; | ||
| 8599 | +} | ||
| 8600 | +.shithub-comment-suggestion-item strong, | ||
| 8601 | +.shithub-comment-suggestion-item span { | ||
| 8602 | + overflow: hidden; | ||
| 8603 | + text-overflow: ellipsis; | ||
| 8604 | + white-space: nowrap; | ||
| 8605 | +} | ||
| 8606 | +.shithub-comment-suggestion-item small { | ||
| 8607 | + color: inherit; | ||
| 8608 | + opacity: 0.8; | ||
| 8609 | +} | ||
| 8610 | +.shithub-comment-suggestion-copy { | ||
| 8611 | + min-width: 0; | ||
| 8612 | + display: flex; | ||
| 8613 | + flex-direction: column; | ||
| 8614 | + gap: 0.1rem; | ||
| 8615 | +} | ||
| 8616 | +.shithub-comment-saved-dialog { | ||
| 8617 | + width: min(32rem, calc(100vw - 2rem)); | ||
| 8618 | + padding: 0; | ||
| 8619 | + border: 1px solid var(--border-default); | ||
| 8620 | + border-radius: 8px; | ||
| 8621 | + color: var(--fg-default); | ||
| 8622 | + background: var(--canvas-overlay, var(--canvas-default)); | ||
| 8623 | + box-shadow: 0 16px 48px rgba(1, 4, 9, 0.45); | ||
| 8624 | +} | ||
| 8625 | +.shithub-comment-saved-dialog::backdrop { | ||
| 8626 | + background: rgba(1, 4, 9, 0.45); | ||
| 8627 | +} | ||
| 8628 | +.shithub-comment-saved-head { | ||
| 8629 | + display: flex; | ||
| 8630 | + align-items: center; | ||
| 8631 | + justify-content: space-between; | ||
| 8632 | + padding: 0.75rem 0.9rem; | ||
| 8633 | +} | ||
| 8634 | +.shithub-comment-saved-dialog input[type="search"] { | ||
| 8635 | + width: calc(100% - 1.5rem); | ||
| 8636 | + margin: 0 0.75rem 0.75rem; | ||
| 8637 | + padding: 0.45rem 0.6rem; | ||
| 8638 | + border-radius: 6px; | ||
| 8639 | +} | ||
| 8640 | +.shithub-comment-saved-item, | ||
| 8641 | +.shithub-comment-saved-create { | ||
| 8642 | + width: 100%; | ||
| 8643 | + border: 0; | ||
| 8644 | + border-top: 1px solid var(--border-default); | ||
| 8645 | + color: var(--fg-default); | ||
| 8646 | + background: transparent; | ||
| 8647 | + cursor: pointer; | ||
| 8648 | + font: inherit; | ||
| 8649 | + text-align: left; | ||
| 8650 | +} | ||
| 8651 | +.shithub-comment-saved-item { | ||
| 8652 | + position: relative; | ||
| 8653 | + display: flex; | ||
| 8654 | + flex-direction: column; | ||
| 8655 | + gap: 0.2rem; | ||
| 8656 | + padding: 0.75rem 4rem 0.75rem 0.9rem; | ||
| 8657 | +} | ||
| 8658 | +.shithub-comment-saved-item:hover, | ||
| 8659 | +.shithub-comment-saved-create:hover { | ||
| 8660 | + background: var(--button-default-hover-bg); | ||
| 8661 | +} | ||
| 8662 | +.shithub-comment-saved-item span { | ||
| 8663 | + color: var(--fg-muted); | ||
| 8664 | + font-size: 0.82rem; | ||
| 8665 | +} | ||
| 8666 | +.shithub-comment-saved-item kbd { | ||
| 8667 | + position: absolute; | ||
| 8668 | + right: 0.9rem; | ||
| 8669 | + top: 0.75rem; | ||
| 8670 | + padding: 0.05rem 0.35rem; | ||
| 8671 | + border: 1px solid var(--border-default); | ||
| 8672 | + border-radius: 999px; | ||
| 8673 | + color: var(--fg-muted); | ||
| 8674 | + background: var(--canvas-subtle); | ||
| 8675 | + font: 0.72rem ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; | ||
| 8676 | +} | ||
| 8677 | +.shithub-comment-saved-create { | ||
| 8678 | + display: flex; | ||
| 8679 | + align-items: center; | ||
| 8680 | + gap: 0.4rem; | ||
| 8681 | + margin: 0.6rem; | ||
| 8682 | + width: calc(100% - 1.2rem); | ||
| 8683 | + padding: 0.55rem 0.7rem; | ||
| 8684 | + border-radius: 6px; | ||
| 8685 | + background: var(--canvas-subtle); | ||
| 8686 | + font-weight: 600; | ||
| 8687 | +} | ||
| 8688 | +.shithub-comment-editor-fullscreen { | ||
| 8689 | + position: fixed; | ||
| 8690 | + inset: 5vh 5vw; | ||
| 8691 | + z-index: 100; | ||
| 8692 | + display: flex; | ||
| 8693 | + flex-direction: column; | ||
| 8694 | + border: 1px solid var(--border-default); | ||
| 8695 | + border-radius: 8px; | ||
| 8696 | + background: var(--canvas-default); | ||
| 8697 | + box-shadow: 0 16px 48px rgba(1, 4, 9, 0.45); | ||
| 8698 | +} | ||
| 8699 | +.shithub-comment-editor-fullscreen .shithub-comment-editor-write, | ||
| 8700 | +.shithub-comment-editor-fullscreen .shithub-comment-editor-preview { | ||
| 8701 | + flex: 1 1 auto; | ||
| 8702 | +} | ||
| 8703 | +.shithub-comment-editor-fullscreen textarea { | ||
| 8704 | + min-height: 55vh; | ||
| 8705 | + resize: none; | ||
| 8706 | +} | ||
| 8707 | +.shithub-comment-composer-row:has(.shithub-comment-editor-fullscreen)::before { | ||
| 8708 | + content: ""; | ||
| 8709 | + position: fixed; | ||
| 8710 | + inset: 0; | ||
| 8711 | + z-index: 99; | ||
| 8712 | + background: rgba(1, 4, 9, 0.48); | ||
| 8713 | +} | ||
| 8374 | .shithub-button-compact { min-height: 32px; padding: 0.25rem 0.7rem; } | 8714 | .shithub-button-compact { min-height: 32px; padding: 0.25rem 0.7rem; } |
| 8375 | .shithub-pull-files { margin-top: 0.25rem; } | 8715 | .shithub-pull-files { margin-top: 0.25rem; } |
| 8376 | .shithub-pull-files-toolbar { | 8716 | .shithub-pull-files-toolbar { |
internal/web/static/js/comment-editor.jsadded@@ -0,0 +1,509 @@ | |||
| 1 | +// SPDX-License-Identifier: AGPL-3.0-or-later | ||
| 2 | + | ||
| 3 | +(function () { | ||
| 4 | + "use strict"; | ||
| 5 | + | ||
| 6 | + const slashCommands = [ | ||
| 7 | + { | ||
| 8 | + label: "Alerts", | ||
| 9 | + description: "Add a markdown alert to emphasize important information", | ||
| 10 | + snippet: "> [!NOTE]\n> Important information\n" | ||
| 11 | + }, | ||
| 12 | + { | ||
| 13 | + label: "Code block", | ||
| 14 | + description: "Insert a code block formatted for a chosen syntax", | ||
| 15 | + snippet: "```\ncode\n```\n" | ||
| 16 | + }, | ||
| 17 | + { | ||
| 18 | + label: "Details", | ||
| 19 | + description: "Add a details tag to hide content behind a visible heading", | ||
| 20 | + snippet: "<details>\n<summary>Summary</summary>\n\nDetails\n</details>\n" | ||
| 21 | + }, | ||
| 22 | + { | ||
| 23 | + label: "Saved replies", | ||
| 24 | + description: "Insert one of your saved replies", | ||
| 25 | + action: "saved" | ||
| 26 | + }, | ||
| 27 | + { | ||
| 28 | + label: "Table", | ||
| 29 | + description: "Add markdown table", | ||
| 30 | + snippet: "| Column | Value |\n| --- | --- |\n| Item | Value |\n" | ||
| 31 | + } | ||
| 32 | + ]; | ||
| 33 | + | ||
| 34 | + function initEditor(root) { | ||
| 35 | + const form = root.querySelector("form"); | ||
| 36 | + const box = root.querySelector(".shithub-comment-editor-box"); | ||
| 37 | + const textarea = root.querySelector("[data-comment-textarea]"); | ||
| 38 | + const writePane = root.querySelector("[data-comment-write-pane]"); | ||
| 39 | + const previewPane = root.querySelector("[data-comment-preview-pane]"); | ||
| 40 | + const suggestions = root.querySelector("[data-comment-suggestions]"); | ||
| 41 | + const submit = root.querySelector("[data-comment-submit]"); | ||
| 42 | + const savedDialog = root.querySelector("[data-comment-saved-dialog]"); | ||
| 43 | + const fileInput = root.querySelector("[data-comment-file-input]"); | ||
| 44 | + const fileList = root.querySelector("[data-comment-file-list]"); | ||
| 45 | + const config = readConfig(root); | ||
| 46 | + let activeToken = null; | ||
| 47 | + let previewDirty = true; | ||
| 48 | + | ||
| 49 | + if (!form || !box || !textarea) return; | ||
| 50 | + | ||
| 51 | + function setTab(tab) { | ||
| 52 | + root.querySelectorAll("[data-comment-tab]").forEach(function (button) { | ||
| 53 | + const active = button.dataset.commentTab === tab; | ||
| 54 | + button.classList.toggle("is-active", active); | ||
| 55 | + button.setAttribute("aria-selected", active ? "true" : "false"); | ||
| 56 | + }); | ||
| 57 | + if (writePane) writePane.hidden = tab !== "write"; | ||
| 58 | + if (previewPane) previewPane.hidden = tab !== "preview"; | ||
| 59 | + if (tab === "preview") renderPreview(); | ||
| 60 | + if (tab === "write") textarea.focus(); | ||
| 61 | + } | ||
| 62 | + | ||
| 63 | + function updateSubmit() { | ||
| 64 | + if (submit) submit.disabled = textarea.value.trim() === ""; | ||
| 65 | + } | ||
| 66 | + | ||
| 67 | + async function renderPreview() { | ||
| 68 | + if (!previewPane || !previewDirty) return; | ||
| 69 | + if (textarea.value.trim() === "") { | ||
| 70 | + previewPane.innerHTML = "<p class=\"shithub-editor-preview-empty\">Nothing to preview.</p>"; | ||
| 71 | + previewDirty = false; | ||
| 72 | + return; | ||
| 73 | + } | ||
| 74 | + const csrf = form.querySelector("input[name='csrf_token']"); | ||
| 75 | + const body = new URLSearchParams(); | ||
| 76 | + body.set("csrf_token", csrf ? csrf.value : ""); | ||
| 77 | + body.set("content", textarea.value); | ||
| 78 | + body.set("ref", root.dataset.previewRef || ""); | ||
| 79 | + body.set("path", "comment.md"); | ||
| 80 | + previewPane.innerHTML = "<p class=\"shithub-editor-preview-empty\">Rendering preview...</p>"; | ||
| 81 | + try { | ||
| 82 | + const response = await fetch(root.dataset.previewUrl || "", { | ||
| 83 | + method: "POST", | ||
| 84 | + headers: { | ||
| 85 | + "Content-Type": "application/x-www-form-urlencoded", | ||
| 86 | + "X-Requested-With": "XMLHttpRequest" | ||
| 87 | + }, | ||
| 88 | + body: body.toString() | ||
| 89 | + }); | ||
| 90 | + previewPane.innerHTML = response.ok ? await response.text() : "<p class=\"shithub-editor-preview-empty\">Preview failed.</p>"; | ||
| 91 | + } catch (error) { | ||
| 92 | + previewPane.innerHTML = "<p class=\"shithub-editor-preview-empty\">Preview failed.</p>"; | ||
| 93 | + } | ||
| 94 | + previewDirty = false; | ||
| 95 | + } | ||
| 96 | + | ||
| 97 | + function replaceSelection(before, after, fallback) { | ||
| 98 | + const start = textarea.selectionStart; | ||
| 99 | + const end = textarea.selectionEnd; | ||
| 100 | + const selected = textarea.value.slice(start, end) || fallback; | ||
| 101 | + const replacement = before + selected + after; | ||
| 102 | + textarea.setRangeText(replacement, start, end, "select"); | ||
| 103 | + textarea.selectionStart = start + before.length; | ||
| 104 | + textarea.selectionEnd = start + before.length + selected.length; | ||
| 105 | + afterEdit(); | ||
| 106 | + } | ||
| 107 | + | ||
| 108 | + function prefixSelection(prefix) { | ||
| 109 | + const start = textarea.selectionStart; | ||
| 110 | + const end = textarea.selectionEnd; | ||
| 111 | + const selected = textarea.value.slice(start, end) || ""; | ||
| 112 | + const source = selected || currentLineText(); | ||
| 113 | + const replacement = source.split("\n").map(function (line) { | ||
| 114 | + return line.trim() === "" ? prefix.trimEnd() : prefix + line; | ||
| 115 | + }).join("\n"); | ||
| 116 | + if (selected) { | ||
| 117 | + textarea.setRangeText(replacement, start, end, "end"); | ||
| 118 | + } else { | ||
| 119 | + const line = currentLineRange(); | ||
| 120 | + textarea.setRangeText(replacement, line.start, line.end, "end"); | ||
| 121 | + } | ||
| 122 | + afterEdit(); | ||
| 123 | + } | ||
| 124 | + | ||
| 125 | + function currentLineRange() { | ||
| 126 | + const pos = textarea.selectionStart; | ||
| 127 | + const before = textarea.value.slice(0, pos); | ||
| 128 | + const after = textarea.value.slice(pos); | ||
| 129 | + const lineStart = before.lastIndexOf("\n") + 1; | ||
| 130 | + let lineEnd = after.indexOf("\n"); | ||
| 131 | + lineEnd = lineEnd === -1 ? textarea.value.length : pos + lineEnd; | ||
| 132 | + return { start: lineStart, end: lineEnd }; | ||
| 133 | + } | ||
| 134 | + | ||
| 135 | + function currentLineText() { | ||
| 136 | + const line = currentLineRange(); | ||
| 137 | + return textarea.value.slice(line.start, line.end); | ||
| 138 | + } | ||
| 139 | + | ||
| 140 | + function insertText(text) { | ||
| 141 | + const start = textarea.selectionStart; | ||
| 142 | + const end = textarea.selectionEnd; | ||
| 143 | + textarea.setRangeText(text, start, end, "end"); | ||
| 144 | + afterEdit(); | ||
| 145 | + } | ||
| 146 | + | ||
| 147 | + function afterEdit() { | ||
| 148 | + previewDirty = true; | ||
| 149 | + updateSubmit(); | ||
| 150 | + hideSuggestions(); | ||
| 151 | + textarea.focus(); | ||
| 152 | + } | ||
| 153 | + | ||
| 154 | + function runAction(action) { | ||
| 155 | + switch (action) { | ||
| 156 | + case "heading": | ||
| 157 | + prefixSelection("### "); | ||
| 158 | + break; | ||
| 159 | + case "bold": | ||
| 160 | + replaceSelection("**", "**", "bold text"); | ||
| 161 | + break; | ||
| 162 | + case "italic": | ||
| 163 | + replaceSelection("*", "*", "italic text"); | ||
| 164 | + break; | ||
| 165 | + case "quote": | ||
| 166 | + prefixSelection("> "); | ||
| 167 | + break; | ||
| 168 | + case "code": | ||
| 169 | + if (textarea.value.slice(textarea.selectionStart, textarea.selectionEnd).includes("\n")) { | ||
| 170 | + replaceSelection("```\n", "\n```", "code"); | ||
| 171 | + } else { | ||
| 172 | + replaceSelection("`", "`", "code"); | ||
| 173 | + } | ||
| 174 | + break; | ||
| 175 | + case "link": | ||
| 176 | + replaceSelection("[", "](url)", "link text"); | ||
| 177 | + break; | ||
| 178 | + case "list": | ||
| 179 | + prefixSelection("- "); | ||
| 180 | + break; | ||
| 181 | + case "ordered-list": | ||
| 182 | + prefixSelection("1. "); | ||
| 183 | + break; | ||
| 184 | + case "task-list": | ||
| 185 | + prefixSelection("- [ ] "); | ||
| 186 | + break; | ||
| 187 | + case "mention": | ||
| 188 | + showSuggestions("mention", ""); | ||
| 189 | + break; | ||
| 190 | + case "reference": | ||
| 191 | + showSuggestions("reference", ""); | ||
| 192 | + break; | ||
| 193 | + case "fullscreen": | ||
| 194 | + box.classList.toggle("shithub-comment-editor-fullscreen"); | ||
| 195 | + root.querySelector("[data-comment-action='fullscreen']")?.classList.toggle("is-active", box.classList.contains("shithub-comment-editor-fullscreen")); | ||
| 196 | + textarea.focus(); | ||
| 197 | + break; | ||
| 198 | + } | ||
| 199 | + } | ||
| 200 | + | ||
| 201 | + function detectToken() { | ||
| 202 | + const pos = textarea.selectionStart; | ||
| 203 | + const before = textarea.value.slice(0, pos); | ||
| 204 | + const lineStart = before.lastIndexOf("\n") + 1; | ||
| 205 | + const line = before.slice(lineStart); | ||
| 206 | + const slash = line.match(/^\/([A-Za-z-]*)$/); | ||
| 207 | + if (slash) { | ||
| 208 | + return { kind: "slash", query: slash[1].toLowerCase(), start: lineStart, end: pos }; | ||
| 209 | + } | ||
| 210 | + const match = before.match(/(^|[\s([])([@#])([A-Za-z0-9_.-]*)$/); | ||
| 211 | + if (!match) return null; | ||
| 212 | + const marker = match[2]; | ||
| 213 | + return { | ||
| 214 | + kind: marker === "@" ? "mention" : "reference", | ||
| 215 | + query: match[3].toLowerCase(), | ||
| 216 | + start: pos - marker.length - match[3].length, | ||
| 217 | + end: pos | ||
| 218 | + }; | ||
| 219 | + } | ||
| 220 | + | ||
| 221 | + function showDetectedSuggestions() { | ||
| 222 | + const token = detectToken(); | ||
| 223 | + if (!token) { | ||
| 224 | + hideSuggestions(); | ||
| 225 | + return; | ||
| 226 | + } | ||
| 227 | + showSuggestions(token.kind, token.query, token); | ||
| 228 | + } | ||
| 229 | + | ||
| 230 | + function showSuggestions(kind, query, token) { | ||
| 231 | + if (!suggestions) return; | ||
| 232 | + activeToken = token || { | ||
| 233 | + kind: kind, | ||
| 234 | + query: query || "", | ||
| 235 | + start: textarea.selectionStart, | ||
| 236 | + end: textarea.selectionEnd | ||
| 237 | + }; | ||
| 238 | + let html = ""; | ||
| 239 | + let items = []; | ||
| 240 | + suggestions.classList.toggle("is-slash", kind === "slash"); | ||
| 241 | + if (kind === "mention") { | ||
| 242 | + items = config.mentions.filter(function (item) { | ||
| 243 | + return item.username.toLowerCase().includes(query) || (item.displayName || "").toLowerCase().includes(query); | ||
| 244 | + }).slice(0, 8); | ||
| 245 | + html = "<div class=\"shithub-comment-suggestion-section\">Suggestions</div>" + items.map(mentionHTML).join(""); | ||
| 246 | + } else if (kind === "reference") { | ||
| 247 | + items = config.references.filter(function (item) { | ||
| 248 | + const needle = query.replace(/^#/, ""); | ||
| 249 | + return String(item.number).includes(needle) || item.title.toLowerCase().includes(needle); | ||
| 250 | + }).slice(0, 8); | ||
| 251 | + html = "<div class=\"shithub-comment-suggestion-section\">Issues and pull requests</div>" + items.map(referenceHTML).join(""); | ||
| 252 | + } else { | ||
| 253 | + items = slashCommands.filter(function (item) { | ||
| 254 | + return item.label.toLowerCase().includes(query); | ||
| 255 | + }); | ||
| 256 | + html = "<div class=\"shithub-comment-suggestion-section\">Slash commands <span class=\"shithub-pill\">Preview</span></div>" + items.map(slashHTML).join(""); | ||
| 257 | + } | ||
| 258 | + if (items.length === 0) { | ||
| 259 | + hideSuggestions(); | ||
| 260 | + return; | ||
| 261 | + } | ||
| 262 | + suggestions.innerHTML = html; | ||
| 263 | + suggestions.hidden = false; | ||
| 264 | + suggestions.querySelector(".shithub-comment-suggestion-item")?.classList.add("is-active"); | ||
| 265 | + } | ||
| 266 | + | ||
| 267 | + function mentionHTML(item, index) { | ||
| 268 | + const display = item.displayName ? " <small>" + escapeHTML(item.displayName) + "</small>" : ""; | ||
| 269 | + return "<button type=\"button\" class=\"shithub-comment-suggestion-item\" data-suggestion-kind=\"mention\" data-suggestion-index=\"" + index + "\"><img src=\"" + escapeAttr(item.avatarUrl || "") + "\" alt=\"\"><span><strong>" + escapeHTML(item.username) + "</strong>" + display + "</span></button>"; | ||
| 270 | + } | ||
| 271 | + | ||
| 272 | + function referenceHTML(item, index) { | ||
| 273 | + return "<button type=\"button\" class=\"shithub-comment-suggestion-item\" data-suggestion-kind=\"reference\" data-suggestion-index=\"" + index + "\"><span aria-hidden=\"true\">#</span><span class=\"shithub-comment-suggestion-copy\"><strong>#" + item.number + " " + escapeHTML(item.title) + "</strong><small>" + escapeHTML(item.kind) + " " + escapeHTML(item.state) + "</small></span></button>"; | ||
| 274 | + } | ||
| 275 | + | ||
| 276 | + function slashHTML(item, index) { | ||
| 277 | + return "<button type=\"button\" class=\"shithub-comment-suggestion-item\" data-suggestion-kind=\"slash\" data-suggestion-index=\"" + index + "\"><span aria-hidden=\"true\">" + (item.action === "saved" ? "@" : "/") + "</span><span class=\"shithub-comment-suggestion-copy\"><strong>" + escapeHTML(item.label) + "</strong><small>" + escapeHTML(item.description) + "</small></span></button>"; | ||
| 278 | + } | ||
| 279 | + | ||
| 280 | + function currentSuggestionItems(kind) { | ||
| 281 | + const token = activeToken || { query: "" }; | ||
| 282 | + if (kind === "mention") { | ||
| 283 | + return config.mentions.filter(function (item) { | ||
| 284 | + return item.username.toLowerCase().includes(token.query) || (item.displayName || "").toLowerCase().includes(token.query); | ||
| 285 | + }).slice(0, 8); | ||
| 286 | + } | ||
| 287 | + if (kind === "reference") { | ||
| 288 | + return config.references.filter(function (item) { | ||
| 289 | + const needle = token.query.replace(/^#/, ""); | ||
| 290 | + return String(item.number).includes(needle) || item.title.toLowerCase().includes(needle); | ||
| 291 | + }).slice(0, 8); | ||
| 292 | + } | ||
| 293 | + return slashCommands.filter(function (item) { | ||
| 294 | + return item.label.toLowerCase().includes(token.query); | ||
| 295 | + }); | ||
| 296 | + } | ||
| 297 | + | ||
| 298 | + function chooseSuggestion(button) { | ||
| 299 | + const kind = button.dataset.suggestionKind; | ||
| 300 | + const index = Number(button.dataset.suggestionIndex || "0"); | ||
| 301 | + const item = currentSuggestionItems(kind)[index]; | ||
| 302 | + if (!item) return; | ||
| 303 | + if (kind === "mention") { | ||
| 304 | + replaceToken("@" + item.username + " "); | ||
| 305 | + } else if (kind === "reference") { | ||
| 306 | + replaceToken("#" + item.number + " "); | ||
| 307 | + } else if (item.action === "saved") { | ||
| 308 | + hideSuggestions(); | ||
| 309 | + openSavedDialog(); | ||
| 310 | + } else { | ||
| 311 | + replaceToken(item.snippet || ""); | ||
| 312 | + } | ||
| 313 | + } | ||
| 314 | + | ||
| 315 | + function replaceToken(text) { | ||
| 316 | + const token = activeToken || { start: textarea.selectionStart, end: textarea.selectionEnd }; | ||
| 317 | + textarea.setRangeText(text, token.start, token.end, "end"); | ||
| 318 | + afterEdit(); | ||
| 319 | + } | ||
| 320 | + | ||
| 321 | + function hideSuggestions() { | ||
| 322 | + if (!suggestions) return; | ||
| 323 | + suggestions.hidden = true; | ||
| 324 | + suggestions.innerHTML = ""; | ||
| 325 | + activeToken = null; | ||
| 326 | + } | ||
| 327 | + | ||
| 328 | + function moveActiveSuggestion(delta) { | ||
| 329 | + if (!suggestions || suggestions.hidden) return; | ||
| 330 | + const items = Array.from(suggestions.querySelectorAll(".shithub-comment-suggestion-item")); | ||
| 331 | + if (items.length === 0) return; | ||
| 332 | + let index = items.findIndex(function (item) { return item.classList.contains("is-active"); }); | ||
| 333 | + index = index < 0 ? 0 : index + delta; | ||
| 334 | + if (index < 0) index = items.length - 1; | ||
| 335 | + if (index >= items.length) index = 0; | ||
| 336 | + items.forEach(function (item, i) { | ||
| 337 | + item.classList.toggle("is-active", i === index); | ||
| 338 | + }); | ||
| 339 | + items[index].scrollIntoView({ block: "nearest" }); | ||
| 340 | + } | ||
| 341 | + | ||
| 342 | + function openSavedDialog() { | ||
| 343 | + if (!savedDialog) return; | ||
| 344 | + try { | ||
| 345 | + if (savedDialog.showModal) { | ||
| 346 | + savedDialog.showModal(); | ||
| 347 | + } else { | ||
| 348 | + savedDialog.setAttribute("open", ""); | ||
| 349 | + } | ||
| 350 | + } catch (error) { | ||
| 351 | + savedDialog.setAttribute("open", ""); | ||
| 352 | + } | ||
| 353 | + savedDialog.querySelector("[data-comment-saved-filter]")?.focus(); | ||
| 354 | + } | ||
| 355 | + | ||
| 356 | + function closeSavedDialog() { | ||
| 357 | + if (!savedDialog) return; | ||
| 358 | + if (savedDialog.close) { | ||
| 359 | + savedDialog.close(); | ||
| 360 | + } else { | ||
| 361 | + savedDialog.removeAttribute("open"); | ||
| 362 | + } | ||
| 363 | + textarea.focus(); | ||
| 364 | + } | ||
| 365 | + | ||
| 366 | + function updateFiles(files) { | ||
| 367 | + if (!fileList || !files || files.length === 0) return; | ||
| 368 | + const names = Array.from(files).map(function (file) { return file.name; }).slice(0, 4); | ||
| 369 | + fileList.textContent = names.join(", "); | ||
| 370 | + fileList.hidden = false; | ||
| 371 | + } | ||
| 372 | + | ||
| 373 | + root.addEventListener("click", function (event) { | ||
| 374 | + const tab = event.target.closest("[data-comment-tab]"); | ||
| 375 | + if (tab) { | ||
| 376 | + setTab(tab.dataset.commentTab); | ||
| 377 | + return; | ||
| 378 | + } | ||
| 379 | + const action = event.target.closest("[data-comment-action]"); | ||
| 380 | + if (action) { | ||
| 381 | + runAction(action.dataset.commentAction); | ||
| 382 | + return; | ||
| 383 | + } | ||
| 384 | + if (event.target.closest("[data-comment-saved-replies-open]")) { | ||
| 385 | + openSavedDialog(); | ||
| 386 | + return; | ||
| 387 | + } | ||
| 388 | + const suggestion = event.target.closest(".shithub-comment-suggestion-item"); | ||
| 389 | + if (suggestion) { | ||
| 390 | + chooseSuggestion(suggestion); | ||
| 391 | + } | ||
| 392 | + }); | ||
| 393 | + | ||
| 394 | + if (suggestions) { | ||
| 395 | + suggestions.addEventListener("mousedown", function (event) { | ||
| 396 | + event.preventDefault(); | ||
| 397 | + }); | ||
| 398 | + } | ||
| 399 | + | ||
| 400 | + textarea.addEventListener("input", function () { | ||
| 401 | + previewDirty = true; | ||
| 402 | + updateSubmit(); | ||
| 403 | + showDetectedSuggestions(); | ||
| 404 | + }); | ||
| 405 | + textarea.addEventListener("keyup", function (event) { | ||
| 406 | + if (["ArrowUp", "ArrowDown", "Enter", "Escape"].includes(event.key)) return; | ||
| 407 | + showDetectedSuggestions(); | ||
| 408 | + }); | ||
| 409 | + textarea.addEventListener("keydown", function (event) { | ||
| 410 | + if (!suggestions || suggestions.hidden) return; | ||
| 411 | + if (event.key === "Escape") { | ||
| 412 | + event.preventDefault(); | ||
| 413 | + hideSuggestions(); | ||
| 414 | + } else if (event.key === "ArrowDown") { | ||
| 415 | + event.preventDefault(); | ||
| 416 | + moveActiveSuggestion(1); | ||
| 417 | + } else if (event.key === "ArrowUp") { | ||
| 418 | + event.preventDefault(); | ||
| 419 | + moveActiveSuggestion(-1); | ||
| 420 | + } else if (event.key === "Enter") { | ||
| 421 | + const active = suggestions.querySelector(".shithub-comment-suggestion-item.is-active"); | ||
| 422 | + if (active) { | ||
| 423 | + event.preventDefault(); | ||
| 424 | + chooseSuggestion(active); | ||
| 425 | + } | ||
| 426 | + } | ||
| 427 | + }); | ||
| 428 | + textarea.addEventListener("blur", function () { | ||
| 429 | + window.setTimeout(hideSuggestions, 120); | ||
| 430 | + }); | ||
| 431 | + | ||
| 432 | + if (fileInput) { | ||
| 433 | + fileInput.addEventListener("change", function () { | ||
| 434 | + updateFiles(fileInput.files); | ||
| 435 | + }); | ||
| 436 | + } | ||
| 437 | + box.addEventListener("dragover", function (event) { | ||
| 438 | + if (event.dataTransfer && event.dataTransfer.files.length > 0) event.preventDefault(); | ||
| 439 | + }); | ||
| 440 | + box.addEventListener("drop", function (event) { | ||
| 441 | + if (event.dataTransfer && event.dataTransfer.files.length > 0) { | ||
| 442 | + event.preventDefault(); | ||
| 443 | + updateFiles(event.dataTransfer.files); | ||
| 444 | + } | ||
| 445 | + }); | ||
| 446 | + | ||
| 447 | + if (savedDialog) { | ||
| 448 | + savedDialog.addEventListener("click", function (event) { | ||
| 449 | + if (event.target.closest("[data-comment-saved-close]")) { | ||
| 450 | + closeSavedDialog(); | ||
| 451 | + return; | ||
| 452 | + } | ||
| 453 | + const insert = event.target.closest("[data-comment-saved-insert]"); | ||
| 454 | + if (insert) { | ||
| 455 | + closeSavedDialog(); | ||
| 456 | + insertText(insert.dataset.commentSavedInsert || ""); | ||
| 457 | + return; | ||
| 458 | + } | ||
| 459 | + if (event.target.closest(".shithub-comment-saved-create")) { | ||
| 460 | + closeSavedDialog(); | ||
| 461 | + } | ||
| 462 | + }); | ||
| 463 | + savedDialog.querySelector("[data-comment-saved-filter]")?.addEventListener("input", function (event) { | ||
| 464 | + const query = event.target.value.trim().toLowerCase(); | ||
| 465 | + savedDialog.querySelectorAll("[data-comment-saved-insert]").forEach(function (button) { | ||
| 466 | + button.hidden = query !== "" && !button.textContent.toLowerCase().includes(query); | ||
| 467 | + }); | ||
| 468 | + }); | ||
| 469 | + } | ||
| 470 | + | ||
| 471 | + document.addEventListener("click", function (event) { | ||
| 472 | + if (!root.contains(event.target)) hideSuggestions(); | ||
| 473 | + }); | ||
| 474 | + | ||
| 475 | + updateSubmit(); | ||
| 476 | + } | ||
| 477 | + | ||
| 478 | + function readConfig(root) { | ||
| 479 | + const fallback = { mentions: [], references: [] }; | ||
| 480 | + const script = root.querySelector("[data-comment-editor-config]"); | ||
| 481 | + if (!script) return fallback; | ||
| 482 | + try { | ||
| 483 | + const parsed = JSON.parse(script.textContent || "{}"); | ||
| 484 | + return { | ||
| 485 | + mentions: Array.isArray(parsed.mentions) ? parsed.mentions.filter(function (item) { | ||
| 486 | + return item && item.username && item.username.toLowerCase() !== "copilot"; | ||
| 487 | + }) : [], | ||
| 488 | + references: Array.isArray(parsed.references) ? parsed.references : [] | ||
| 489 | + }; | ||
| 490 | + } catch (error) { | ||
| 491 | + return fallback; | ||
| 492 | + } | ||
| 493 | + } | ||
| 494 | + | ||
| 495 | + function escapeHTML(value) { | ||
| 496 | + return String(value || "") | ||
| 497 | + .replace(/&/g, "&") | ||
| 498 | + .replace(/</g, "<") | ||
| 499 | + .replace(/>/g, ">") | ||
| 500 | + .replace(/"/g, """) | ||
| 501 | + .replace(/'/g, "'"); | ||
| 502 | + } | ||
| 503 | + | ||
| 504 | + function escapeAttr(value) { | ||
| 505 | + return escapeHTML(value).replace(/`/g, "`"); | ||
| 506 | + } | ||
| 507 | + | ||
| 508 | + document.querySelectorAll("[data-comment-editor]").forEach(initEditor); | ||
| 509 | +})(); | ||
internal/web/templates/_layout.htmlmodified@@ -36,6 +36,7 @@ | |||
| 36 | <link rel="stylesheet" href="/static/css/shithub.css"> | 36 | <link rel="stylesheet" href="/static/css/shithub.css"> |
| 37 | <link rel="stylesheet" href="/static/css/chroma.css"> | 37 | <link rel="stylesheet" href="/static/css/chroma.css"> |
| 38 | {{ if flag . "UseHTMX" }}<script src="/static/vendor/htmx/htmx.min.js" defer></script>{{ end }} | 38 | {{ if flag . "UseHTMX" }}<script src="/static/vendor/htmx/htmx.min.js" defer></script>{{ end }} |
| 39 | + {{ if flag . "UseCommentEditor" }}<script src="/static/js/comment-editor.js" defer></script>{{ end }} | ||
| 39 | </head> | 40 | </head> |
| 40 | <body class="shithub-body"> | 41 | <body class="shithub-body"> |
| 41 | {{ template "nav" . }} | 42 | {{ template "nav" . }} |
internal/web/templates/repo/pull_view.htmlmodified@@ -250,16 +250,81 @@ | |||
| 250 | </section> | 250 | </section> |
| 251 | 251 | ||
| 252 | {{ if .CanComment }} | 252 | {{ if .CanComment }} |
| 253 | - <form method="post" action="/{{ .Owner }}/{{ .Repo.Name }}/issues/{{ .PR.INumber }}/comments" class="shithub-comment-form shithub-pull-comment-form"> | 253 | + <div class="shithub-comment-composer-row" data-comment-editor data-preview-url="/{{ .Owner }}/{{ .Repo.Name }}/markdown-preview" data-preview-ref="{{ .Repo.DefaultBranch }}"> |
| 254 | - <input type="hidden" name="csrf_token" value="{{ .CSRFToken }}"> | 254 | + <a class="shithub-comment-composer-avatar" href="/{{ .Viewer.Username }}" aria-label="@{{ .Viewer.Username }}"> |
| 255 | - <label> | 255 | + <img src="{{ .ViewerAvatarURL }}" alt="" width="40" height="40"> |
| 256 | - <span>Add a comment</span> | 256 | + </a> |
| 257 | - <textarea name="body" rows="6" maxlength="65535" placeholder="Add your comment here..."></textarea> | 257 | + <form method="post" action="/{{ .Owner }}/{{ .Repo.Name }}/issues/{{ .PR.INumber }}/comments" class="shithub-comment-form shithub-pull-comment-form shithub-comment-composer-form"> |
| 258 | - </label> | 258 | + <input type="hidden" name="csrf_token" value="{{ .CSRFToken }}"> |
| 259 | - <div class="shithub-form-actions"> | 259 | + <script type="application/json" data-comment-editor-config>{{ jsField . "CommentEditorConfig" }}</script> |
| 260 | - <button type="submit" class="shithub-button shithub-button-primary">Comment</button> | 260 | + <label class="shithub-comment-composer-title" for="pull-comment-body">Add a comment</label> |
| 261 | - </div> | 261 | + <div class="shithub-comment-editor-box"> |
| 262 | - </form> | 262 | + <div class="shithub-comment-editor-head"> |
| 263 | + <div class="shithub-comment-editor-tabs" role="tablist" aria-label="Comment editor tabs"> | ||
| 264 | + <button type="button" class="is-active" role="tab" aria-selected="true" data-comment-tab="write">Write</button> | ||
| 265 | + <button type="button" role="tab" aria-selected="false" data-comment-tab="preview">Preview</button> | ||
| 266 | + </div> | ||
| 267 | + <div class="shithub-comment-toolbar" aria-label="Formatting tools"> | ||
| 268 | + <button type="button" class="shithub-comment-tool" data-comment-action="mention" title="Mention a user">{{ octicon "people" }}</button> | ||
| 269 | + <span class="shithub-comment-toolbar-separator" aria-hidden="true"></span> | ||
| 270 | + <button type="button" class="shithub-comment-tool shithub-comment-tool-text" data-comment-action="heading" title="Add heading">H</button> | ||
| 271 | + <button type="button" class="shithub-comment-tool shithub-comment-tool-text" data-comment-action="bold" title="Add bold text">B</button> | ||
| 272 | + <button type="button" class="shithub-comment-tool shithub-comment-tool-text is-italic" data-comment-action="italic" title="Add italic text">I</button> | ||
| 273 | + <button type="button" class="shithub-comment-tool" data-comment-action="quote" title="Quote text">{{ octicon "comment" }}</button> | ||
| 274 | + <button type="button" class="shithub-comment-tool" data-comment-action="code" title="Add code">{{ octicon "code" }}</button> | ||
| 275 | + <button type="button" class="shithub-comment-tool" data-comment-action="link" title="Add link">{{ octicon "link" }}</button> | ||
| 276 | + <span class="shithub-comment-toolbar-separator" aria-hidden="true"></span> | ||
| 277 | + <button type="button" class="shithub-comment-tool" data-comment-action="list" title="Add unordered list">{{ octicon "list-unordered" }}</button> | ||
| 278 | + <button type="button" class="shithub-comment-tool shithub-comment-tool-text" data-comment-action="ordered-list" title="Add ordered list">1.</button> | ||
| 279 | + <button type="button" class="shithub-comment-tool" data-comment-action="task-list" title="Add task list">{{ octicon "checklist" }}</button> | ||
| 280 | + <span class="shithub-comment-toolbar-separator" aria-hidden="true"></span> | ||
| 281 | + <label class="shithub-comment-tool" title="Attach files"> | ||
| 282 | + {{ octicon "upload" }} | ||
| 283 | + <input type="file" multiple data-comment-file-input> | ||
| 284 | + </label> | ||
| 285 | + <button type="button" class="shithub-comment-tool shithub-comment-tool-text" data-comment-action="reference" title="Reference an issue or pull request">#</button> | ||
| 286 | + <button type="button" class="shithub-comment-tool" data-comment-saved-replies-open title="Saved replies">{{ octicon "comment-discussion" }}</button> | ||
| 287 | + <button type="button" class="shithub-comment-tool" data-comment-action="fullscreen" title="Fullscreen editor">{{ octicon "screen-full" }}</button> | ||
| 288 | + </div> | ||
| 289 | + </div> | ||
| 290 | + <div class="shithub-comment-editor-write" data-comment-write-pane> | ||
| 291 | + <textarea id="pull-comment-body" name="body" rows="6" maxlength="65535" placeholder="Add your comment here..." data-comment-textarea></textarea> | ||
| 292 | + <div class="shithub-comment-suggestions" data-comment-suggestions hidden></div> | ||
| 293 | + </div> | ||
| 294 | + <div class="shithub-comment-editor-preview markdown-body" data-comment-preview-pane hidden> | ||
| 295 | + <p class="shithub-editor-preview-empty">Nothing to preview.</p> | ||
| 296 | + </div> | ||
| 297 | + <div class="shithub-comment-editor-footer"> | ||
| 298 | + <span>{{ octicon "code-square" }} Markdown is supported</span> | ||
| 299 | + <span data-comment-attachment-copy>{{ octicon "file" }} Paste, drop, or click to add files</span> | ||
| 300 | + <span class="shithub-comment-file-list" data-comment-file-list hidden></span> | ||
| 301 | + </div> | ||
| 302 | + </div> | ||
| 303 | + <p class="shithub-comment-policy-note"> | ||
| 304 | + {{ octicon "alert" }} Remember, contributions to this repository should follow its | ||
| 305 | + <a href="/{{ .Owner }}/{{ .Repo.Name }}/blob/{{ .Repo.DefaultBranch }}/CONTRIBUTING.md">contributing guidelines</a>, | ||
| 306 | + <a href="/{{ .Owner }}/{{ .Repo.Name }}/blob/{{ .Repo.DefaultBranch }}/SECURITY.md">security policy</a>, and | ||
| 307 | + <a href="/{{ .Owner }}/{{ .Repo.Name }}/blob/{{ .Repo.DefaultBranch }}/CODE_OF_CONDUCT.md">code of conduct</a>. | ||
| 308 | + </p> | ||
| 309 | + <p class="shithub-comment-protip">{{ octicon "light-bulb" }} <strong>ProTip!</strong> Add <a href="/{{ .Owner }}/{{ .Repo.Name }}/pulls/{{ .PR.INumber }}.patch">.patch</a> or <a href="/{{ .Owner }}/{{ .Repo.Name }}/pulls/{{ .PR.INumber }}.diff">.diff</a> to the end of URLs for Git's plaintext views.</p> | ||
| 310 | + <div class="shithub-form-actions"> | ||
| 311 | + <button type="submit" class="shithub-button shithub-button-primary" data-comment-submit>Comment</button> | ||
| 312 | + </div> | ||
| 313 | + </form> | ||
| 314 | + <dialog class="shithub-comment-saved-dialog" data-comment-saved-dialog> | ||
| 315 | + <div class="shithub-comment-saved-head"> | ||
| 316 | + <strong>Select a reply</strong> | ||
| 317 | + <button type="button" class="shithub-icon-button" aria-label="Close" data-comment-saved-close>{{ octicon "x" }}</button> | ||
| 318 | + </div> | ||
| 319 | + <input type="search" placeholder="Search saved replies" data-comment-saved-filter> | ||
| 320 | + <button type="button" class="shithub-comment-saved-item" data-comment-saved-insert="Duplicate of #"> | ||
| 321 | + <strong>Duplicate pull request</strong> | ||
| 322 | + <span>Duplicate of #</span> | ||
| 323 | + <kbd>ctrl 1</kbd> | ||
| 324 | + </button> | ||
| 325 | + <button type="button" class="shithub-comment-saved-create">{{ octicon "plus" }} Create a new saved reply</button> | ||
| 326 | + </dialog> | ||
| 327 | + </div> | ||
| 263 | {{ else if .Viewer.ID }} | 328 | {{ else if .Viewer.ID }} |
| 264 | {{ if .PR.ILocked }}<p class="shithub-issue-signedout">This conversation is locked.</p>{{ end }} | 329 | {{ if .PR.ILocked }}<p class="shithub-issue-signedout">This conversation is locked.</p>{{ end }} |
| 265 | {{ else }} | 330 | {{ else }} |