tenseleyflow/shithub / e243e55

Browse files

Add pull files navigator

Authored by espadonne
SHA
e243e551ce738f324805d953d7628d82add1b09d
Parents
dc31eb4
Tree
fe7e8cf

5 changed files

StatusFile+-
M internal/repos/diff/render/render.go 25 5
M internal/web/handlers/repo/pulls.go 48 1
M internal/web/render/octicons.go 2 0
M internal/web/static/css/shithub.css 118 2
M internal/web/templates/repo/pull_view.html 117 57
internal/repos/diff/render/render.gomodified
@@ -97,7 +97,8 @@ func Diff(d *parse.Diff, opts Options) string {
9797
 func RenderFile(f *parse.File, opts Options) string {
9898
 	opts = defaults(opts)
9999
 	var buf bytes.Buffer
100
-	buf.WriteString(`<section class="shithub-diff-file">`)
100
+	label, _ := fileLabelAction(f)
101
+	fmt.Fprintf(&buf, `<section id="%s" class="shithub-diff-file">`, html.EscapeString(diffFileAnchor(label)))
101102
 	buf.WriteString(renderHeader(f))
102103
 	switch {
103104
 	case f.IsBinary:
@@ -114,6 +115,14 @@ func RenderFile(f *parse.File, opts Options) string {
114115
 }
115116
 
116117
 func renderHeader(f *parse.File) string {
118
+	label, action := fileLabelAction(f)
119
+	return fmt.Sprintf(
120
+		`<header class="shithub-diff-file-head"><code>%s</code><span class="shithub-diff-file-action">%s</span></header>`,
121
+		html.EscapeString(label), html.EscapeString(action),
122
+	)
123
+}
124
+
125
+func fileLabelAction(f *parse.File) (string, string) {
117126
 	var label, action string
118127
 	switch {
119128
 	case f.IsRename:
@@ -135,10 +144,21 @@ func renderHeader(f *parse.File) string {
135144
 		}
136145
 		action = "modified"
137146
 	}
138
-	return fmt.Sprintf(
139
-		`<header class="shithub-diff-file-head"><code>%s</code><span class="shithub-diff-file-action">%s</span></header>`,
140
-		html.EscapeString(label), html.EscapeString(action),
141
-	)
147
+	return label, action
148
+}
149
+
150
+func diffFileAnchor(p string) string {
151
+	var b strings.Builder
152
+	b.WriteString("diff-")
153
+	for _, r := range p {
154
+		switch {
155
+		case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9':
156
+			b.WriteRune(r)
157
+		default:
158
+			b.WriteByte('-')
159
+		}
160
+	}
161
+	return b.String()
142162
 }
143163
 
144164
 func renderBinary(f *parse.File) string {
internal/web/handlers/repo/pulls.gomodified
@@ -51,6 +51,13 @@ type pullCheckSuiteView struct {
5151
 	Runs  []pullCheckRunView
5252
 }
5353
 
54
+type pullFileView struct {
55
+	F      pullsdb.PullRequestFile
56
+	Anchor string
57
+	Dir    string
58
+	Name   string
59
+}
60
+
5461
 // MountPulls registers /{owner}/{repo}/pulls* routes. Reads are
5562
 // public (subject to policy.Can(ActionPullRead)); writes require auth.
5663
 // The merge route runs synchronously inside the request: pulls.Merge
@@ -553,6 +560,17 @@ func (h *Handlers) pullFiles(w http.ResponseWriter, r *http.Request) {
553560
 		return
554561
 	}
555562
 	files, _ := pullsdb.New().ListPullRequestFiles(r.Context(), h.d.Pool, pr.IID)
563
+	fileViews := make([]pullFileView, 0, len(files))
564
+	for _, f := range files {
565
+		label := pullFileLabel(f)
566
+		dir, name := splitPullFilePath(f.Path)
567
+		fileViews = append(fileViews, pullFileView{
568
+			F:      f,
569
+			Anchor: pullFileAnchor(label),
570
+			Dir:    dir,
571
+			Name:   name,
572
+		})
573
+	}
556574
 	diffHTML := ""
557575
 	if pr.BaseOid != "" && pr.HeadOid != "" {
558576
 		patch, perr := compareSourceMergeBase(r, gitDir, pr.BaseOid, pr.HeadOid)
@@ -587,12 +605,41 @@ func (h *Handlers) pullFiles(w http.ResponseWriter, r *http.Request) {
587605
 		threadsByFile[f.Path] = out
588606
 	}
589607
 	h.renderPullPage(w, r, "files", map[string]any{
590
-		"Files":         files,
608
+		"Files":         fileViews,
591609
 		"DiffHTML":      diffHTML,
592610
 		"ThreadsByFile": threadsByFile,
593611
 	})
594612
 }
595613
 
614
+func pullFileLabel(f pullsdb.PullRequestFile) string {
615
+	if f.OldPath.Valid && f.OldPath.String != "" && f.OldPath.String != f.Path {
616
+		return f.OldPath.String + " → " + f.Path
617
+	}
618
+	return f.Path
619
+}
620
+
621
+func splitPullFilePath(p string) (string, string) {
622
+	idx := strings.LastIndex(p, "/")
623
+	if idx < 0 {
624
+		return "", p
625
+	}
626
+	return p[:idx], p[idx+1:]
627
+}
628
+
629
+func pullFileAnchor(p string) string {
630
+	var b strings.Builder
631
+	b.WriteString("diff-")
632
+	for _, r := range p {
633
+		switch {
634
+		case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9':
635
+			b.WriteRune(r)
636
+		default:
637
+			b.WriteByte('-')
638
+		}
639
+	}
640
+	return b.String()
641
+}
642
+
596643
 // pullChecks renders the Checks tab. Loads suites + runs grouped by
597644
 // suite for the PR's head_oid, plus the markdown-rendered output.summary
598645
 // for each run.
internal/web/render/octicons.gomodified
@@ -51,6 +51,8 @@ func BuiltinOcticons() OcticonResolver {
5151
 			`><path d="M11.93 8.5a4.002 4.002 0 0 1-7.86 0H.75a.75.75 0 0 1 0-1.5h3.32a4.002 4.002 0 0 1 7.86 0h3.32a.75.75 0 0 1 0 1.5Zm-1.43-.75a2.5 2.5 0 1 0-5 0 2.5 2.5 0 0 0 5 0Z"/></svg>`),
5252
 		"file-diff": trustedSVG(`<svg xmlns="http://www.w3.org/2000/svg" ` + cls +
5353
 			`><path d="M1 1.75C1 .784 1.784 0 2.75 0h7.586c.464 0 .909.184 1.237.513l2.914 2.914c.329.328.513.773.513 1.237v9.586A1.75 1.75 0 0 1 13.25 16H2.75A1.75 1.75 0 0 1 1 14.25Zm1.75-.25a.25.25 0 0 0-.25.25v12.5c0 .138.112.25.25.25h10.5a.25.25 0 0 0 .25-.25V4.664a.25.25 0 0 0-.073-.177l-2.914-2.914a.25.25 0 0 0-.177-.073ZM8 3.25a.75.75 0 0 1 .75.75v1.5h1.5a.75.75 0 0 1 0 1.5h-1.5v1.5a.75.75 0 0 1-1.5 0V7h-1.5a.75.75 0 0 1 0-1.5h1.5V4A.75.75 0 0 1 8 3.25Zm-3 8a.75.75 0 0 1 .75-.75h4.5a.75.75 0 0 1 0 1.5h-4.5a.75.75 0 0 1-.75-.75Z"/></svg>`),
54
+		"diff": trustedSVG(`<svg xmlns="http://www.w3.org/2000/svg" ` + cls +
55
+			`><path d="M5.75 3.5a.75.75 0 0 1 0 1.5H3.5v2.25a.75.75 0 0 1-1.5 0V5H.75a.75.75 0 0 1 0-1.5H2V1.25a.75.75 0 0 1 1.5 0V3.5Zm4.5 8.5a.75.75 0 0 1 0-1.5h5a.75.75 0 0 1 0 1.5ZM8.5 4.25a.75.75 0 0 1 .75-.75h6a.75.75 0 0 1 0 1.5h-6a.75.75 0 0 1-.75-.75Zm-6 7a.75.75 0 0 1 .75-.75h3a.75.75 0 0 1 0 1.5h-3a.75.75 0 0 1-.75-.75Z"/></svg>`),
5456
 		"comment-discussion": trustedSVG(`<svg xmlns="http://www.w3.org/2000/svg" ` + cls +
5557
 			`><path d="M1.75 1h8.5c.966 0 1.75.784 1.75 1.75v5.5A1.75 1.75 0 0 1 10.25 10H7.061l-2.574 2.573A1.458 1.458 0 0 1 2 11.543V10h-.25A1.75 1.75 0 0 1 0 8.25v-5.5C0 1.784.784 1 1.75 1ZM1.5 2.75v5.5c0 .138.112.25.25.25h1a.75.75 0 0 1 .75.75v2.19l2.72-2.72a.749.749 0 0 1 .53-.22h3.5a.25.25 0 0 0 .25-.25v-5.5a.25.25 0 0 0-.25-.25h-8.5a.25.25 0 0 0-.25.25Zm13 2a.25.25 0 0 0-.25-.25h-.5a.75.75 0 0 1 0-1.5h.5c.966 0 1.75.784 1.75 1.75v5.5A1.75 1.75 0 0 1 14.25 12H14v1.543a1.458 1.458 0 0 1-2.487 1.03L9.22 12.28a.75.75 0 0 1 1.06-1.06l2.22 2.22v-2.19a.75.75 0 0 1 .75-.75h1a.25.25 0 0 0 .25-.25Z"/></svg>`),
5658
 		"checklist": trustedSVG(`<svg xmlns="http://www.w3.org/2000/svg" ` + cls +
internal/web/static/css/shithub.cssmodified
@@ -2957,7 +2957,7 @@ button.shithub-repo-action {
29572957
   font-size: 0.85rem;
29582958
   vertical-align: baseline;
29592959
 }
2960
-.shithub-icon-button {
2960
+.shithub-pull-summary .shithub-icon-button {
29612961
   display: inline-flex;
29622962
   align-items: center;
29632963
   justify-content: center;
@@ -2970,7 +2970,7 @@ button.shithub-repo-action {
29702970
   color: var(--fg-muted);
29712971
   cursor: pointer;
29722972
 }
2973
-.shithub-icon-button:hover { background: var(--canvas-subtle); color: var(--fg-default); }
2973
+.shithub-pull-summary .shithub-icon-button:hover { background: var(--canvas-subtle); color: var(--fg-default); }
29742974
 .shithub-pull-tabs {
29752975
   display: flex;
29762976
   gap: 0.25rem;
@@ -3121,6 +3121,122 @@ button.shithub-repo-action {
31213121
   padding: 0.4rem; border: 1px solid var(--border-default); border-radius: 6px; font: inherit;
31223122
 }
31233123
 .shithub-pull-comment-form { margin-top: 1.25rem; }
3124
+.shithub-button-compact { min-height: 32px; padding: 0.25rem 0.7rem; }
3125
+.shithub-pull-files { margin-top: 0.25rem; }
3126
+.shithub-pull-files-toolbar {
3127
+  position: sticky;
3128
+  top: 0;
3129
+  z-index: 5;
3130
+  display: flex;
3131
+  justify-content: space-between;
3132
+  align-items: center;
3133
+  gap: 1rem;
3134
+  padding: 0.7rem 0;
3135
+  border-bottom: 1px solid var(--border-default);
3136
+  background: var(--canvas-default);
3137
+}
3138
+.shithub-pull-files-toolbar-left,
3139
+.shithub-pull-files-toolbar-right {
3140
+  display: flex;
3141
+  align-items: center;
3142
+  gap: 0.5rem;
3143
+  flex-wrap: wrap;
3144
+}
3145
+.shithub-pull-submit-review-menu { position: relative; }
3146
+.shithub-pull-submit-review-menu > summary { list-style: none; }
3147
+.shithub-pull-submit-review-menu > summary::-webkit-details-marker { display: none; }
3148
+.shithub-pull-review-popover {
3149
+  right: 0;
3150
+  top: calc(100% + 0.4rem);
3151
+  min-width: 20rem;
3152
+}
3153
+.shithub-pull-review-popover textarea {
3154
+  width: 100%;
3155
+  padding: 0.45rem 0.5rem;
3156
+  border: 1px solid var(--border-default);
3157
+  border-radius: 6px;
3158
+  background: var(--canvas-default);
3159
+  color: var(--fg-default);
3160
+  font: inherit;
3161
+}
3162
+.shithub-pull-files-layout {
3163
+  display: grid;
3164
+  grid-template-columns: 16rem minmax(0, 1fr);
3165
+  gap: 1rem;
3166
+  margin-top: 1rem;
3167
+}
3168
+.shithub-pull-file-nav {
3169
+  position: sticky;
3170
+  top: 3.75rem;
3171
+  align-self: start;
3172
+  max-height: calc(100vh - 5rem);
3173
+  overflow: auto;
3174
+  border: 1px solid var(--border-default);
3175
+  border-radius: 6px;
3176
+  background: var(--canvas-default);
3177
+}
3178
+.shithub-pull-file-filter {
3179
+  display: flex;
3180
+  align-items: center;
3181
+  gap: 0.35rem;
3182
+  padding: 0.5rem;
3183
+  border-bottom: 1px solid var(--border-default);
3184
+}
3185
+.shithub-pull-file-filter svg { color: var(--fg-muted); flex: 0 0 auto; }
3186
+.shithub-pull-file-filter input {
3187
+  min-width: 0;
3188
+  width: 100%;
3189
+  border: 0;
3190
+  background: transparent;
3191
+  color: var(--fg-default);
3192
+  font: inherit;
3193
+  outline: none;
3194
+}
3195
+.shithub-pull-file-nav ul {
3196
+  list-style: none;
3197
+  margin: 0;
3198
+  padding: 0.4rem 0;
3199
+}
3200
+.shithub-pull-file-nav li a {
3201
+  display: grid;
3202
+  grid-template-columns: 1.4rem minmax(0, 1fr);
3203
+  gap: 0.45rem;
3204
+  align-items: start;
3205
+  padding: 0.45rem 0.65rem;
3206
+  color: var(--fg-default);
3207
+  text-decoration: none;
3208
+  font-size: 0.86rem;
3209
+}
3210
+.shithub-pull-file-nav li a:hover { background: var(--canvas-subtle); }
3211
+.shithub-pull-file-nav small {
3212
+  display: block;
3213
+  color: var(--fg-muted);
3214
+  font-size: 0.75rem;
3215
+  overflow: hidden;
3216
+  text-overflow: ellipsis;
3217
+  white-space: nowrap;
3218
+}
3219
+.shithub-pull-file-status {
3220
+  display: inline-flex;
3221
+  align-items: center;
3222
+  justify-content: center;
3223
+  width: 1.1rem;
3224
+  height: 1.1rem;
3225
+  border-radius: 4px;
3226
+  font-size: 0.68rem;
3227
+  font-weight: 700;
3228
+}
3229
+.shithub-pull-file-status-added { color: #1a7f37; border: 1px solid rgba(26, 127, 55, 0.45); }
3230
+.shithub-pull-file-status-deleted { color: #cf222e; border: 1px solid rgba(207, 34, 46, 0.45); }
3231
+.shithub-pull-file-status-renamed { color: #8250df; border: 1px solid rgba(130, 80, 223, 0.45); }
3232
+.shithub-pull-file-status-modified { color: #bf8700; border: 1px solid rgba(154, 103, 0, 0.45); }
3233
+.shithub-pull-file-lines { margin-top: 0.1rem; }
3234
+.shithub-pull-files-main { min-width: 0; }
3235
+@media (max-width: 900px) {
3236
+  .shithub-pull-files-toolbar { position: static; align-items: flex-start; flex-direction: column; }
3237
+  .shithub-pull-files-layout { grid-template-columns: 1fr; }
3238
+  .shithub-pull-file-nav { position: static; max-height: none; }
3239
+}
31243240
 .shithub-pull-threads { margin-top: 1.5rem; padding: 0.75rem; border: 1px solid var(--border-default); border-radius: 6px; }
31253241
 .shithub-pull-thread-file { padding: 0.4rem 0; border-bottom: 1px solid var(--border-default); }
31263242
 .shithub-pull-thread-file:last-child { border-bottom: none; }
internal/web/templates/repo/pull_view.htmlmodified
@@ -480,67 +480,127 @@
480480
         {{ end }}
481481
       </ul>
482482
     {{ else if eq .Tab "files" }}
483
-      {{ if .DiffHTML }}
484
-        <div class="shithub-diff">{{ safeHTML .DiffHTML }}</div>
485
-      {{ else }}
486
-        <p class="shithub-muted">No diff available.</p>
487
-      {{ end }}
483
+      <section class="shithub-pull-files">
484
+        <div class="shithub-pull-files-toolbar">
485
+          <div class="shithub-pull-files-toolbar-left">
486
+            <button type="button" class="shithub-button shithub-button-compact">{{ octicon "diff" }} All commits</button>
487
+            <span class="shithub-muted">{{ .PullStats.Files }} file{{ if ne .PullStats.Files 1 }}s{{ end }} changed</span>
488
+          </div>
489
+          <div class="shithub-pull-files-toolbar-right">
490
+            <span class="shithub-muted">0 / {{ .PullStats.Files }} viewed</span>
491
+            {{ if .CanReviewPull }}
492
+            <details class="shithub-pull-submit-review shithub-pull-submit-review-menu">
493
+              <summary class="shithub-button shithub-button-primary">Submit review</summary>
494
+              <form method="post" action="/{{ .Owner }}/{{ .Repo.Name }}/pulls/{{ .PR.INumber }}/reviews" class="shithub-popover shithub-pull-review-popover">
495
+                <strong>Finish your review</strong>
496
+                <input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
497
+                <textarea name="body" rows="4" placeholder="Leave a review comment (optional)"></textarea>
498
+                <select name="state">
499
+                  <option value="comment">Comment</option>
500
+                  {{ if not (and .PR.IAuthorUserID.Valid (eq .PR.IAuthorUserID.Int64 .Viewer.ID)) }}
501
+                  <option value="approve">Approve</option>
502
+                  <option value="request_changes">Request changes</option>
503
+                  {{ end }}
504
+                </select>
505
+                <button type="submit" class="shithub-button shithub-button-primary">Submit review</button>
506
+              </form>
507
+            </details>
508
+            {{ end }}
509
+            <button type="button" class="shithub-icon-button" aria-label="Files changed settings">{{ octicon "gear" }}</button>
510
+          </div>
511
+        </div>
488512
 
489
-      {{ if .ThreadsByFile }}
490
-      <section class="shithub-pull-threads">
491
-        <h3>Inline comments</h3>
492
-        {{ range $path, $threads := .ThreadsByFile }}
493
-          {{ if $threads }}
494
-          <details class="shithub-pull-thread-file" open>
495
-            <summary><code>{{ $path }}</code> · {{ len $threads }}</summary>
496
-            {{ range $threads }}
497
-              <div class="shithub-pull-thread{{ if not .C.CurrentPosition.Valid }} shithub-pull-thread-outdated{{ end }}{{ if .C.ResolvedAt.Valid }} shithub-pull-thread-resolved{{ end }}">
498
-                <div class="shithub-comment-head">
499
-                  {{ if .AuthorName }}<a href="/{{ .AuthorName }}">{{ .AuthorName }}</a>{{ end }}
500
-                  line {{ .C.OriginalLine }}
501
-                  <time datetime="{{ .C.CreatedAt.Time.Format "2006-01-02T15:04:05Z" }}">{{ relativeTime .C.CreatedAt.Time }}</time>
502
-                  {{ if not .C.CurrentPosition.Valid }}<span class="shithub-pill">outdated</span>{{ end }}
503
-                  {{ if .C.ResolvedAt.Valid }}<span class="shithub-pill">resolved</span>{{ end }}
504
-                </div>
505
-                <div class="shithub-comment-body markdown-body">
506
-                  {{ if .C.BodyHtmlCached.Valid }}{{ safeHTML .C.BodyHtmlCached.String }}{{ else }}<p>{{ .C.Body }}</p>{{ end }}
507
-                </div>
508
-                {{ if $.CanReviewPull }}
509
-                <div class="shithub-pull-thread-actions">
510
-                  <form method="post" action="/{{ $.Owner }}/{{ $.Repo.Name }}/pulls/{{ $.PR.INumber }}/review-comments/{{ .C.ID }}/reply">
511
-                    <input type="hidden" name="csrf_token" value="{{ $.CSRFToken }}">
512
-                    <textarea name="body" rows="2" placeholder="Reply..."></textarea>
513
-                    <button type="submit" class="shithub-button">Reply</button>
514
-                  </form>
515
-                  <form method="post" action="/{{ $.Owner }}/{{ $.Repo.Name }}/pulls/{{ $.PR.INumber }}/review-comments/{{ .C.ID }}/{{ if .C.ResolvedAt.Valid }}reopen{{ else }}resolve{{ end }}">
516
-                    <input type="hidden" name="csrf_token" value="{{ $.CSRFToken }}">
517
-                    <button type="submit" class="shithub-button">{{ if .C.ResolvedAt.Valid }}Reopen{{ else }}Resolve{{ end }}</button>
518
-                  </form>
519
-                </div>
520
-                {{ end }}
521
-              </div>
513
+        <div class="shithub-pull-files-layout">
514
+          <aside class="shithub-pull-file-nav" aria-label="Changed files">
515
+            <label class="shithub-pull-file-filter">
516
+              {{ octicon "search" }}
517
+              <input type="search" placeholder="Filter files..." aria-label="Filter files">
518
+            </label>
519
+            <ul>
520
+              {{ range .Files }}
521
+              <li>
522
+                <a href="#{{ .Anchor }}">
523
+                  {{ if eq (printf "%s" .F.Status) "added" }}<span class="shithub-pull-file-status shithub-pull-file-status-added">A</span>
524
+                  {{ else if eq (printf "%s" .F.Status) "deleted" }}<span class="shithub-pull-file-status shithub-pull-file-status-deleted">D</span>
525
+                  {{ else if eq (printf "%s" .F.Status) "renamed" }}<span class="shithub-pull-file-status shithub-pull-file-status-renamed">R</span>
526
+                  {{ else }}<span class="shithub-pull-file-status shithub-pull-file-status-modified">M</span>{{ end }}
527
+                  <span>
528
+                    {{ if .Dir }}<small>{{ .Dir }}/</small>{{ end }}{{ .Name }}
529
+                    <small class="shithub-pull-file-lines">+{{ .F.Additions }} −{{ .F.Deletions }}</small>
530
+                  </span>
531
+                </a>
532
+              </li>
533
+              {{ else }}
534
+              <li class="shithub-muted">No files changed.</li>
535
+              {{ end }}
536
+            </ul>
537
+          </aside>
538
+
539
+          <div class="shithub-pull-files-main">
540
+            {{ if .DiffHTML }}
541
+              <div class="shithub-diff">{{ safeHTML .DiffHTML }}</div>
542
+            {{ else }}
543
+              <p class="shithub-muted">No diff available.</p>
522544
             {{ end }}
523
-          </details>
524
-          {{ end }}
525
-        {{ end }}
526545
 
527
-        {{ if .CanReviewPull }}
528
-        <details class="shithub-pull-add-comment">
529
-          <summary class="shithub-button">Add inline comment</summary>
530
-          <form method="post" action="/{{ .Owner }}/{{ .Repo.Name }}/pulls/{{ .PR.INumber }}/review-comments">
531
-            <input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
532
-            <input type="text" name="file_path" placeholder="path" required>
533
-            <input type="number" name="line" placeholder="line" required>
534
-            <input type="number" name="position" placeholder="position" required>
535
-            <input type="hidden" name="commit_sha" value="{{ .PR.HeadOid }}">
536
-            <input type="hidden" name="side" value="right">
537
-            <textarea name="body" rows="3" required></textarea>
538
-            <button type="submit" class="shithub-button">Add comment</button>
539
-          </form>
540
-        </details>
541
-        {{ end }}
546
+            {{ if .ThreadsByFile }}
547
+            <section class="shithub-pull-threads">
548
+              <h3>Inline comments</h3>
549
+              {{ range $path, $threads := .ThreadsByFile }}
550
+                {{ if $threads }}
551
+                <details class="shithub-pull-thread-file" open>
552
+                  <summary><code>{{ $path }}</code> · {{ len $threads }}</summary>
553
+                  {{ range $threads }}
554
+                    <div class="shithub-pull-thread{{ if not .C.CurrentPosition.Valid }} shithub-pull-thread-outdated{{ end }}{{ if .C.ResolvedAt.Valid }} shithub-pull-thread-resolved{{ end }}">
555
+                      <div class="shithub-comment-head">
556
+                        {{ if .AuthorName }}<a href="/{{ .AuthorName }}">{{ .AuthorName }}</a>{{ end }}
557
+                        line {{ .C.OriginalLine }}
558
+                        <time datetime="{{ .C.CreatedAt.Time.Format "2006-01-02T15:04:05Z" }}">{{ relativeTime .C.CreatedAt.Time }}</time>
559
+                        {{ if not .C.CurrentPosition.Valid }}<span class="shithub-pill">outdated</span>{{ end }}
560
+                        {{ if .C.ResolvedAt.Valid }}<span class="shithub-pill">resolved</span>{{ end }}
561
+                      </div>
562
+                      <div class="shithub-comment-body markdown-body">
563
+                        {{ if .C.BodyHtmlCached.Valid }}{{ safeHTML .C.BodyHtmlCached.String }}{{ else }}<p>{{ .C.Body }}</p>{{ end }}
564
+                      </div>
565
+                      {{ if $.CanReviewPull }}
566
+                      <div class="shithub-pull-thread-actions">
567
+                        <form method="post" action="/{{ $.Owner }}/{{ $.Repo.Name }}/pulls/{{ $.PR.INumber }}/review-comments/{{ .C.ID }}/reply">
568
+                          <input type="hidden" name="csrf_token" value="{{ $.CSRFToken }}">
569
+                          <textarea name="body" rows="2" placeholder="Reply..."></textarea>
570
+                          <button type="submit" class="shithub-button">Reply</button>
571
+                        </form>
572
+                        <form method="post" action="/{{ $.Owner }}/{{ $.Repo.Name }}/pulls/{{ $.PR.INumber }}/review-comments/{{ .C.ID }}/{{ if .C.ResolvedAt.Valid }}reopen{{ else }}resolve{{ end }}">
573
+                          <input type="hidden" name="csrf_token" value="{{ $.CSRFToken }}">
574
+                          <button type="submit" class="shithub-button">{{ if .C.ResolvedAt.Valid }}Reopen{{ else }}Resolve{{ end }}</button>
575
+                        </form>
576
+                      </div>
577
+                      {{ end }}
578
+                    </div>
579
+                  {{ end }}
580
+                </details>
581
+                {{ end }}
582
+              {{ end }}
583
+
584
+              {{ if .CanReviewPull }}
585
+              <details class="shithub-pull-add-comment">
586
+                <summary class="shithub-button">Add inline comment</summary>
587
+                <form method="post" action="/{{ .Owner }}/{{ .Repo.Name }}/pulls/{{ .PR.INumber }}/review-comments">
588
+                  <input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
589
+                  <input type="text" name="file_path" placeholder="path" required>
590
+                  <input type="number" name="line" placeholder="line" required>
591
+                  <input type="number" name="position" placeholder="position" required>
592
+                  <input type="hidden" name="commit_sha" value="{{ .PR.HeadOid }}">
593
+                  <input type="hidden" name="side" value="right">
594
+                  <textarea name="body" rows="3" required></textarea>
595
+                  <button type="submit" class="shithub-button">Add comment</button>
596
+                </form>
597
+              </details>
598
+              {{ end }}
599
+            </section>
600
+            {{ end }}
601
+          </div>
602
+        </div>
542603
       </section>
543
-      {{ end }}
544604
     {{ else if eq .Tab "checks" }}
545605
       {{ if .CheckGroups }}
546606
       <section class="shithub-pull-checks">