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