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