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