PatchDetail.tsx
⎇
Raw
1import type {
2 PatchCommentRow,
3 PatchRow,
4 RepositoryRow,
5} from "../../db/index.ts";
6import { displayName } from "../../lib/users.ts";
7import type { SessionUser } from "../../middleware/session.ts";
8import type { RenderedDiffFile } from "../../services/diffHighlight.ts";
9import type { ApplyResult } from "../../services/patchCache.ts";
10import { Avatar } from "../Avatar.tsx";
11import { DateWithEdited } from "../DateWithEdited.tsx";
12import { DiffView } from "../DiffView.tsx";
13import { Layout } from "../layout.tsx";
14import { ReactionBar, type ReactionCount } from "../ReactionBar.tsx";
15import { RepoHeader } from "../repos/RepoHeader.tsx";
16import { RepoNav } from "../repos/RepoNav.tsx";
17
18interface PatchDetailProps {
19 user: SessionUser | null;
20 repo: RepositoryRow;
21 patch: PatchRow & {
22 author_username: string;
23 author_avatar_version: number | null;
24 };
25 descriptionHtml: string;
26 applyResult: ApplyResult | null;
27 files: RenderedDiffFile[];
28 tab: "conversation" | "changes";
29 comments: (PatchCommentRow & {
30 author_username: string;
31 author_avatar_version: number | null;
32 bodyHtml: string;
33 })[];
34 reactions: ReactionCount[];
35 commentReactions: Map<number, ReactionCount[]>;
36}
37
38export function PatchDetail({
39 user,
40 repo,
41 patch,
42 descriptionHtml,
43 applyResult,
44 files,
45 tab,
46 comments,
47 reactions,
48 commentReactions,
49}: PatchDetailProps) {
50 const canEdit =
51 user != null &&
52 (user.isAdmin ||
53 (user.id === patch.author_id && patch.status === "open"));
54 const canEditComment = (c: PatchCommentRow) =>
55 user != null &&
56 (user.isAdmin || (user.id === c.author_id && patch.status === "open"));
57
58 const baseUrl = `/${repo.name}/patches/${patch.number}`;
59 const reactUrl = `${baseUrl}/react`;
60
61 return (
62 <Layout user={user} title={`${patch.title} — ${repo.name}`}>
63 <div class="container">
64 <RepoHeader repo={repo} />
65 <RepoNav repo={repo} active="patches" user={user} />
66
67 <div class="issue-detail">
68 <div class="issue-detail-header">
69 <span class="issue-number">#{patch.number}</span>
70 <div class="title-with-edit">
71 <h2 class="issue-detail-title">{patch.title}</h2>
72 {canEdit && (
73 <>
74 <div class="title-edit-form-area">
75 <form
76 method="POST"
77 action={`${baseUrl}/edit`}
78 class="title-edit-form"
79 >
80 <input
81 class="form-input"
82 name="title"
83 value={patch.title}
84 required
85 />
86 <input
87 type="hidden"
88 name="edit_description"
89 value={patch.description}
90 />
91 <div class="title-edit-actions">
92 <button
93 type="submit"
94 class="btn btn-sm btn-primary"
95 >
96 Save
97 </button>
98 <button
99 type="button"
100 class="btn btn-sm"
101 onclick="this.closest('.title-with-edit').querySelector('details').removeAttribute('open')"
102 >
103 Cancel
104 </button>
105 </div>
106 </form>
107 </div>
108 <details class="title-edit-details">
109 <summary class="btn btn-xs btn-ghost">
110 Edit title
111 </summary>
112 </details>
113 </>
114 )}
115 </div>
116 <span class={`patch-badge ${patch.status}`}>
117 {patch.status}
118 </span>
119 {canEdit && (
120 <div class="issue-detail-meta-actions">
121 {user?.isAdmin &&
122 patch.status === "open" &&
123 applyResult?.status === "clean" && (
124 <form
125 method="POST"
126 action={`${baseUrl}/merge`}
127 class="inline-form"
128 >
129 <button
130 type="submit"
131 class="btn btn-sm btn-primary"
132 >
133 Merge patch
134 </button>
135 </form>
136 )}
137 {user?.isAdmin && patch.status !== "merged" && (
138 <form
139 method="POST"
140 action={`${baseUrl}/close`}
141 class="inline-form"
142 >
143 <button
144 type="submit"
145 class="btn btn-sm"
146 >
147 {patch.status === "open"
148 ? "Close patch"
149 : "Reopen patch"}
150 </button>
151 </form>
152 )}
153 <details class="confirm-details">
154 <summary class="btn btn-sm btn-danger">
155 Delete
156 </summary>
157 <div class="confirm-popup">
158 Permanently delete this patch?
159 <form
160 method="POST"
161 action={`${baseUrl}/delete`}
162 class="inline-form"
163 >
164 <button
165 type="submit"
166 class="btn btn-sm btn-danger"
167 >
168 Yes, delete
169 </button>
170 </form>
171 </div>
172 </details>
173 </div>
174 )}
175 </div>
176 </div>
177
178 {/* Subview tabs */}
179 <div class="patch-tab-nav">
180 <a
181 href={baseUrl}
182 class={`repo-tab${tab === "conversation" ? " active" : ""}`}
183 >
184 Conversation{" "}
185 {comments.length > 0 && (
186 <span class="tab-count">{comments.length}</span>
187 )}
188 </a>
189 <a
190 href={`${baseUrl}?tab=changes`}
191 class={`repo-tab${tab === "changes" ? " active" : ""}`}
192 >
193 Changes{" "}
194 {files.length > 0 && (
195 <span class="tab-count">{files.length}</span>
196 )}
197 </a>
198 </div>
199
200 {tab === "conversation" ? (
201 <div class="issue-detail">
202 {/* Patch description */}
203 <div class="timeline-item">
204 <div class="timeline-author">
205 <Avatar
206 userId={patch.author_id}
207 version={patch.author_avatar_version}
208 size={24}
209 />
210 <strong>
211 {displayName(patch.author_username)}
212 </strong>
213 <div class="timeline-author-right">
214 {canEdit && (
215 <details class="inline-edit-details">
216 <summary class="btn btn-xs btn-ghost">
217 <span class="when-closed">
218 Edit
219 </span>
220 <span class="when-open">
221 Stop editing
222 </span>
223 </summary>
224 </details>
225 )}
226 <DateWithEdited
227 date={patch.created_at}
228 editedAt={patch.edited_at}
229 />
230 </div>
231 </div>
232 {canEdit && (
233 <div class="inline-edit-form-area">
234 <form
235 method="POST"
236 action={`${baseUrl}/edit`}
237 class="inline-edit-form"
238 >
239 <input
240 type="hidden"
241 name="title"
242 value={patch.title}
243 />
244 <div class="form-group">
245 <textarea
246 class="form-input"
247 id="edit-patch-desc"
248 name="edit_description"
249 rows="6"
250 >
251 {patch.description}
252 </textarea>
253 </div>
254 <div class="form-actions">
255 <button
256 type="submit"
257 class="btn btn-sm btn-primary"
258 >
259 Save
260 </button>
261 </div>
262 </form>
263 </div>
264 )}
265 <div class="timeline-body markdown-body">
266 {descriptionHtml || (
267 <em class="text-muted">
268 No description provided.
269 </em>
270 )}
271 </div>
272 <ReactionBar
273 reactions={reactions}
274 postUrl={reactUrl}
275 user={user}
276 />
277 </div>
278
279 {/* Apply status */}
280 {applyResult && (
281 <div class="timeline-item">
282 <div
283 class={`apply-result apply-${applyResult.status}`}
284 >
285 <div>
286 <span class="apply-icon">
287 {applyResult.status === "clean"
288 ? "✓"
289 : "✗"}
290 </span>
291 <span>
292 {applyResult.status === "clean"
293 ? "Applies cleanly"
294 : "Has conflicts"}
295 </span>
296 </div>
297 {applyResult.output && (
298 <pre class="apply-output">
299 {applyResult.output}
300 </pre>
301 )}
302 </div>
303 </div>
304 )}
305
306 {/* Comments */}
307 {comments.map((comment) => (
308 <div class="timeline-item">
309 <div class="timeline-author">
310 <Avatar
311 userId={comment.author_id}
312 version={comment.author_avatar_version}
313 size={24}
314 />
315 <strong>
316 {displayName(comment.author_username)}
317 </strong>
318 <div class="timeline-author-right">
319 {canEditComment(comment) && (
320 <details class="inline-edit-details">
321 <summary class="btn btn-xs btn-ghost">
322 <span class="when-closed">
323 Edit
324 </span>
325 <span class="when-open">
326 Stop editing
327 </span>
328 </summary>
329 </details>
330 )}
331 <DateWithEdited
332 date={comment.created_at}
333 editedAt={comment.edited_at}
334 />
335 </div>
336 </div>
337 {canEditComment(comment) && (
338 <div class="inline-edit-form-area">
339 <form
340 method="POST"
341 action={`${baseUrl}/comments/${comment.id}/edit`}
342 class="inline-edit-form"
343 >
344 <div class="form-group">
345 <textarea
346 class="form-input"
347 name="edit_body"
348 rows="6"
349 >
350 {comment.body}
351 </textarea>
352 </div>
353 <div class="form-actions">
354 <button
355 type="submit"
356 class="btn btn-sm btn-primary"
357 >
358 Save
359 </button>
360 </div>
361 </form>
362 </div>
363 )}
364 <div class="timeline-body markdown-body">
365 {comment.bodyHtml}
366 </div>
367 <ReactionBar
368 reactions={
369 commentReactions.get(comment.id) ?? []
370 }
371 postUrl={reactUrl}
372 commentId={comment.id}
373 user={user}
374 />
375 </div>
376 ))}
377
378 {user && (
379 <div class="timeline-item timeline-item-new">
380 <h3 class="section-title">Add a comment</h3>
381 <form
382 method="POST"
383 action={`${baseUrl}/comments`}
384 >
385 <div class="form-group">
386 <textarea
387 name="body"
388 rows="6"
389 placeholder="Leave a comment (Markdown supported)"
390 required
391 />
392 </div>
393 <div class="form-actions">
394 <button
395 type="submit"
396 class="btn btn-primary"
397 >
398 Comment
399 </button>
400 </div>
401 </form>
402 </div>
403 )}
404 {!user && (
405 <p class="text-muted">
406 <a href="/login">Sign in</a> to leave a comment.
407 </p>
408 )}
409 </div>
410 ) : (
411 <DiffView files={files} repo={repo} />
412 )}
413 </div>
414 </Layout>
415 );
416}
417