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