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