PatchDetail.tsx
Raw
1import { escapeHtml } from "@kitajs/html";
2import type {
3 LabelRow,
4 PatchCommentRow,
5 PatchRow,
6 RepositoryRow,
7} from "../../db/index.ts";
8import { formatDateTime } from "../../lib/formatDate.ts";
9import { labelTextColor } from "../../lib/labelColor.ts";
10import { displayName } from "../../lib/users.ts";
11import type { SessionUser } from "../../middleware/session.ts";
12import type { RenderedDiffFile } from "../../services/diffHighlight.ts";
13import type { PatchMeta } from "../../services/git.ts";
14import type { ApplyResult } from "../../services/patchCache.ts";
15import { Avatar } from "../Avatar.tsx";
16import { DateWithEdited } from "../DateWithEdited.tsx";
17import { DiffView } from "../DiffView.tsx";
18import { Layout } from "../layout.tsx";
19import { ReactionBar, type ReactionCount } from "../ReactionBar.tsx";
20import { RepoHeader } from "../repos/RepoHeader.tsx";
21import { RepoNav } from "../repos/RepoNav.tsx";
22
23interface PatchDetailProps {
24 user: SessionUser | null;
25 repo: RepositoryRow;
26 patch: PatchRow & {
27 author_username: string;
28 author_avatar_version: number | null;
29 author_name: string;
30 author_email: string;
31 };
32 descriptionHtml: string;
33 applyResult: ApplyResult | null;
34 files: RenderedDiffFile[];
35 tab: "conversation" | "changes";
36 patchMeta: PatchMeta;
37 comments: (PatchCommentRow & {
38 author_username: string;
39 author_avatar_version: number | null;
40 bodyHtml: string;
41 })[];
42 reactions: ReactionCount[];
43 commentReactions: Map<number, ReactionCount[]>;
44 patchLabels: LabelRow[];
45 repoLabels: LabelRow[];
46}
47
48export function PatchDetail({
49 user,
50 repo,
51 patch,
52 descriptionHtml,
53 applyResult,
54 files,
55 tab,
56 patchMeta,
57 comments,
58 reactions,
59 commentReactions,
60 patchLabels,
61 repoLabels,
62}: PatchDetailProps) {
63 const canEdit =
64 user != null &&
65 (user.isAdmin ||
66 (user.id === patch.author_id && patch.status === "open"));
67 const canEditComment = (c: PatchCommentRow) =>
68 user != null &&
69 (user.isAdmin || (user.id === c.author_id && patch.status === "open"));
70
71 const baseUrl = `/${repo.name}/patches/${patch.number}`;
72 const reactUrl = `${baseUrl}/react`;
73
74 return (
75 <Layout user={user} title={`${patch.title} — ${repo.name}`}>
76 <div class="container">
77 <RepoHeader repo={repo} />
78 <RepoNav repo={repo} active="patches" user={user} />
79
80 <div class="issue-detail">
81 <div class="issue-detail-header">
82 <span class="issue-number">#{patch.number}</span>
83 <div class="title-with-edit">
84 <h2 class="issue-detail-title">{patch.title}</h2>
85 {canEdit && (
86 <>
87 <div class="title-edit-form-area">
88 <form
89 method="POST"
90 action={`${baseUrl}/edit`}
91 class="title-edit-form"
92 >
93 <input
94 class="form-input"
95 name="title"
96 value={patch.title}
97 required
98 />
99 <input
100 type="hidden"
101 name="edit_description"
102 value={patch.description}
103 />
104 <div class="title-edit-actions">
105 <button
106 type="submit"
107 class="btn btn-sm btn-primary"
108 >
109 Save
110 </button>
111 <button
112 type="button"
113 class="btn btn-sm"
114 onclick="this.closest('.title-with-edit').querySelector('details').removeAttribute('open')"
115 >
116 Cancel
117 </button>
118 </div>
119 </form>
120 </div>
121 <details class="title-edit-details">
122 <summary class="btn btn-xs">
123 Edit title
124 </summary>
125 </details>
126 </>
127 )}
128 </div>
129 <span class={`patch-badge ${patch.status}`}>
130 {patch.status}
131 </span>
132 {canEdit && (
133 <div class="issue-detail-meta-actions">
134 {user?.isAdmin &&
135 patch.status === "open" &&
136 applyResult?.status === "clean" && (
137 <form
138 method="POST"
139 action={`${baseUrl}/merge`}
140 class="inline-form"
141 >
142 <input
143 type="hidden"
144 name="version"
145 value={patch.version}
146 />
147 <button
148 type="submit"
149 class="btn btn-sm btn-primary"
150 >
151 Merge patch
152 </button>
153 </form>
154 )}
155 {patch.status === "open" && (
156 <details class="confirm-details">
157 <summary class="btn btn-sm">
158 Update patch file
159 </summary>
160 <div class="confirm-popup">
161 <form
162 method="POST"
163 action={`${baseUrl}/upload`}
164 enctype="multipart/form-data"
165 class="upload-patch-form"
166 >
167 <input
168 type="file"
169 name="patch_file"
170 accept=".patch"
171 required
172 class="form-input"
173 />
174 <button
175 type="submit"
176 class="btn btn-sm btn-primary"
177 >
178 Upload
179 </button>
180 </form>
181 </div>
182 </details>
183 )}
184 {user?.isAdmin && patch.status !== "merged" && (
185 <form
186 method="POST"
187 action={`${baseUrl}/close`}
188 class="inline-form"
189 >
190 <button
191 type="submit"
192 class="btn btn-sm"
193 >
194 {patch.status === "open"
195 ? "Close patch"
196 : "Reopen patch"}
197 </button>
198 </form>
199 )}
200 <details class="confirm-details">
201 <summary class="btn btn-sm btn-danger">
202 Delete
203 </summary>
204 <div class="confirm-popup">
205 Permanently delete this patch?
206 <form
207 method="POST"
208 action={`${baseUrl}/delete`}
209 class="inline-form"
210 >
211 <button
212 type="submit"
213 class="btn btn-sm btn-danger"
214 >
215 Yes, delete
216 </button>
217 </form>
218 </div>
219 </details>
220 </div>
221 )}
222 </div>
223 {(patchLabels.length > 0 || user?.isAdmin) && (
224 <div class="issue-labels-row">
225 {patchLabels.map((label) => (
226 <span class="label-badge-wrap">
227 <span
228 class="label-badge"
229 style={`background:${label.color};color:${labelTextColor(label.color)}`}
230 >
231 {label.name}
232 </span>
233 {user?.isAdmin && (
234 <form
235 method="POST"
236 action={`${baseUrl}/labels/remove`}
237 class="label-remove-form"
238 >
239 <input
240 type="hidden"
241 name="label_id"
242 value={String(label.id)}
243 />
244 <button
245 type="submit"
246 class="label-remove-btn"
247 title="Remove label"
248 >
249 ×
250 </button>
251 </form>
252 )}
253 </span>
254 ))}
255 {user?.isAdmin &&
256 repoLabels.filter(
257 (l) =>
258 !patchLabels.some((pl) => pl.id === l.id),
259 ).length > 0 && (
260 <form
261 method="POST"
262 action={`${baseUrl}/labels/add`}
263 class="label-add-inline-form"
264 >
265 <select name="label_id" class="label-select">
266 {repoLabels
267 .filter(
268 (l) =>
269 !patchLabels.some(
270 (pl) => pl.id === l.id,
271 ),
272 )
273 .map((label) => (
274 <option
275 value={String(label.id)}
276 >
277 {label.name}
278 </option>
279 ))}
280 </select>
281 <button
282 type="submit"
283 class="btn btn-sm btn-secondary"
284 >
285 Add label
286 </button>
287 </form>
288 )}
289 </div>
290 )}
291 </div>
292
293 {/* Subview tabs */}
294 <div class="patch-tab-nav">
295 <a
296 href={baseUrl}
297 class={`repo-tab${tab === "conversation" ? " active" : ""}`}
298 >
299 Conversation{" "}
300 {comments.length > 0 && (
301 <span class="tab-count">{comments.length}</span>
302 )}
303 </a>
304 <a
305 href={`${baseUrl}?tab=changes`}
306 class={`repo-tab${tab === "changes" ? " active" : ""}`}
307 >
308 Changes{" "}
309 {files.length > 0 && (
310 <span class="tab-count">{files.length}</span>
311 )}
312 </a>
313 </div>
314
315 {tab === "conversation" ? (
316 <div class="issue-detail">
317 {/* Patch description */}
318 <div class="timeline-item">
319 <div class="timeline-author">
320 <Avatar
321 userId={patch.author_id}
322 version={patch.author_avatar_version}
323 size={24}
324 />
325 <strong>
326 {displayName(patch.author_username)}
327 </strong>
328 <div class="timeline-author-right">
329 {canEdit && (
330 <details class="inline-edit-details">
331 <summary class="btn btn-xs">
332 <span class="when-closed">
333 Edit
334 </span>
335 <span class="when-open">
336 Stop editing
337 </span>
338 </summary>
339 </details>
340 )}
341 <DateWithEdited
342 date={patch.created_at}
343 editedAt={patch.edited_at}
344 />
345 </div>
346 </div>
347 {canEdit && (
348 <div class="inline-edit-form-area">
349 <form
350 method="POST"
351 action={`${baseUrl}/edit`}
352 class="inline-edit-form"
353 >
354 <input
355 type="hidden"
356 name="title"
357 value={patch.title}
358 />
359 <div class="form-group">
360 <textarea
361 class="form-input"
362 id="edit-patch-desc"
363 name="edit_description"
364 rows="6"
365 >
366 {patch.description}
367 </textarea>
368 </div>
369 <div class="form-actions">
370 <button
371 type="submit"
372 class="btn btn-sm btn-primary"
373 >
374 Save
375 </button>
376 </div>
377 </form>
378 </div>
379 )}
380 <div class="timeline-body markdown-body">
381 {descriptionHtml || (
382 <em class="text-muted">
383 No description provided.
384 </em>
385 )}
386 </div>
387 <ReactionBar
388 reactions={reactions}
389 postUrl={reactUrl}
390 user={user}
391 />
392 </div>
393
394 {/* Apply status */}
395 {applyResult && (
396 <div class="timeline-item">
397 <div
398 class={`apply-result apply-${applyResult.status}`}
399 >
400 <div>
401 <span class="apply-icon">
402 {applyResult.status === "clean"
403 ? "✓"
404 : "✗"}
405 </span>
406 <span>
407 {applyResult.status === "clean"
408 ? "Applies cleanly"
409 : "Has conflicts"}
410 </span>
411 </div>
412 {applyResult.output && (
413 <pre class="apply-output">
414 {applyResult.output}
415 </pre>
416 )}
417 </div>
418 </div>
419 )}
420
421 {/* Comments */}
422 {comments.map((comment) => (
423 <div class="timeline-item">
424 <div class="timeline-author">
425 <Avatar
426 userId={comment.author_id}
427 version={comment.author_avatar_version}
428 size={24}
429 />
430 <strong>
431 {displayName(comment.author_username)}
432 </strong>
433 <div class="timeline-author-right">
434 {canEditComment(comment) && (
435 <details class="inline-edit-details">
436 <summary class="btn btn-xs">
437 <span class="when-closed">
438 Edit
439 </span>
440 <span class="when-open">
441 Stop editing
442 </span>
443 </summary>
444 </details>
445 )}
446 <DateWithEdited
447 date={comment.created_at}
448 editedAt={comment.edited_at}
449 />
450 </div>
451 </div>
452 {canEditComment(comment) && (
453 <div class="inline-edit-form-area">
454 <form
455 method="POST"
456 action={`${baseUrl}/comments/${comment.id}/edit`}
457 class="inline-edit-form"
458 >
459 <div class="form-group">
460 <textarea
461 class="form-input"
462 name="edit_body"
463 rows="6"
464 >
465 {comment.body}
466 </textarea>
467 </div>
468 <div class="form-actions">
469 <button
470 type="submit"
471 class="btn btn-sm btn-primary"
472 >
473 Save
474 </button>
475 </div>
476 </form>
477 </div>
478 )}
479 <div class="timeline-body markdown-body">
480 {comment.bodyHtml}
481 </div>
482 <ReactionBar
483 reactions={
484 commentReactions.get(comment.id) ?? []
485 }
486 postUrl={reactUrl}
487 commentId={comment.id}
488 user={user}
489 />
490 </div>
491 ))}
492
493 {user && (
494 <div class="timeline-item timeline-item-new">
495 <h3 class="section-title">Add a comment</h3>
496 <form
497 method="POST"
498 action={`${baseUrl}/comments`}
499 >
500 <div class="form-group">
501 <textarea
502 name="body"
503 rows="6"
504 placeholder="Leave a comment (Markdown supported)"
505 required
506 />
507 </div>
508 <div class="form-actions">
509 <button
510 type="submit"
511 class="btn btn-primary"
512 >
513 Comment
514 </button>
515 </div>
516 </form>
517 </div>
518 )}
519 {!user && (
520 <p class="text-muted">
521 <a href="/login">Sign in</a> to leave a comment.
522 </p>
523 )}
524 </div>
525 ) : (
526 <div>
527 <div class="commit-card">
528 <h2
529 class={`commit-card-subject${patchMeta.subject ? "" : " commit-card-subject-empty"}`}
530 >
531 {patchMeta.subject
532 ? escapeHtml(patchMeta.subject)
533 : "No commit message"}
534 </h2>
535 {patchMeta.body && (
536 <pre class="commit-card-body">
537 {escapeHtml(patchMeta.body)}
538 </pre>
539 )}
540 <div class="commit-card-meta">
541 <div class="commit-card-meta-row">
542 <span class="commit-meta-label">
543 Author
544 </span>
545 <span class="commit-meta-value">
546 {escapeHtml(
547 `${patchMeta.author} <${patchMeta.email}>`,
548 )}
549 </span>
550 </div>
551 <div class="commit-card-meta-row">
552 <span class="commit-meta-label">Date</span>
553 <time
554 class="commit-meta-value"
555 datetime={patchMeta.date}
556 >
557 {formatDateTime(patchMeta.date)}
558 </time>
559 </div>
560 </div>
561 </div>
562 <DiffView files={files} repo={repo} />
563 </div>
564 )}
565 </div>
566 </Layout>
567 );
568}
569