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 | 81 | | `POST /{owner}/{repo}/pulls/{n}/reviews` | RequireUser | |
| 82 | 82 | | `POST /{owner}/{repo}/pulls/{n}/reviews/{rid}/dismiss` | RequireUser | |
| 83 | 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 | 87 | The settings/branches handler now also accepts |
| 86 | 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 | 93 | `pull:merge` and `pull:close` decisions. Public viewers who can read a |
| 92 | 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 | 102 | ## Required-review gate |
| 95 | 103 | |
| 96 | 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 | 84 | // KindPRMerge in the audit remediation sprint.) |
| 85 | 85 | func (h *Handlers) MountPulls(r chi.Router) { |
| 86 | 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 | 89 | r.Get("/{owner}/{repo}/pulls/{number}", h.pullView) |
| 88 | 90 | r.Get("/{owner}/{repo}/pulls/{number}/files", h.pullFiles) |
| 89 | 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 | 539 | "CanEditIssueAssignees": policy.Can(r.Context(), pdeps, actor, policy.ActionIssueAssign, repoRef).Allow, |
| 538 | 540 | "CanEditIssueMilestone": policy.Can(r.Context(), pdeps, actor, policy.ActionIssueLabel, repoRef).Allow, |
| 539 | 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 | 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 | 742 | // pullChecks renders the Checks tab. Loads suites + runs grouped by |
| 705 | 743 | // suite for the PR's head_oid, plus the markdown-rendered output.summary |
| 706 | 744 | // for each run. |
internal/web/static/css/shithub.cssmodified@@ -8371,6 +8371,346 @@ button.shithub-repo-action { | ||
| 8371 | 8371 | padding: 0.4rem; border: 1px solid var(--border-default); border-radius: 6px; font: inherit; |
| 8372 | 8372 | } |
| 8373 | 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 | 8714 | .shithub-button-compact { min-height: 32px; padding: 0.25rem 0.7rem; } |
| 8375 | 8715 | .shithub-pull-files { margin-top: 0.25rem; } |
| 8376 | 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 | 36 | <link rel="stylesheet" href="/static/css/shithub.css"> |
| 37 | 37 | <link rel="stylesheet" href="/static/css/chroma.css"> |
| 38 | 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 | 40 | </head> |
| 40 | 41 | <body class="shithub-body"> |
| 41 | 42 | {{ template "nav" . }} |
internal/web/templates/repo/pull_view.htmlmodified@@ -250,16 +250,81 @@ | ||
| 250 | 250 | </section> |
| 251 | 251 | |
| 252 | 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 | + <a class="shithub-comment-composer-avatar" href="/{{ .Viewer.Username }}" aria-label="@{{ .Viewer.Username }}"> | |
| 255 | + <img src="{{ .ViewerAvatarURL }}" alt="" width="40" height="40"> | |
| 256 | + </a> | |
| 257 | + <form method="post" action="/{{ .Owner }}/{{ .Repo.Name }}/issues/{{ .PR.INumber }}/comments" class="shithub-comment-form shithub-pull-comment-form shithub-comment-composer-form"> | |
| 254 | 258 | <input type="hidden" name="csrf_token" value="{{ .CSRFToken }}"> |
| 255 | - <label> | |
| 256 | - <span>Add a comment</span> | |
| 257 | - <textarea name="body" rows="6" maxlength="65535" placeholder="Add your comment here..."></textarea> | |
| 259 | + <script type="application/json" data-comment-editor-config>{{ jsField . "CommentEditorConfig" }}</script> | |
| 260 | + <label class="shithub-comment-composer-title" for="pull-comment-body">Add a comment</label> | |
| 261 | + <div class="shithub-comment-editor-box"> | |
| 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> | |
| 258 | 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> | |
| 259 | 310 | <div class="shithub-form-actions"> |
| 260 | - <button type="submit" class="shithub-button shithub-button-primary">Comment</button> | |
| 311 | + <button type="submit" class="shithub-button shithub-button-primary" data-comment-submit>Comment</button> | |
| 261 | 312 | </div> |
| 262 | 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 | 328 | {{ else if .Viewer.ID }} |
| 264 | 329 | {{ if .PR.ILocked }}<p class="shithub-issue-signedout">This conversation is locked.</p>{{ end }} |
| 265 | 330 | {{ else }} |