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 btn-ghost">
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 class="patch-author-meta">
218 <span class="patch-author-label">git author:</span>
219 <span class="patch-author-identity">
220 {escapeHtml(
221 `${patch.author_name} <${patch.author_email}>`,
222 )}
223 </span>
224 </div>
225 </div>
226
227 {/* Subview tabs */}
228 <div class="patch-tab-nav">
229 <a
230 href={baseUrl}
231 class={`repo-tab${tab === "conversation" ? " active" : ""}`}
232 >
233 Conversation{" "}
234 {comments.length > 0 && (
235 <span class="tab-count">{comments.length}</span>
236 )}
237 </a>
238 <a
239 href={`${baseUrl}?tab=changes`}
240 class={`repo-tab${tab === "changes" ? " active" : ""}`}
241 >
242 Changes{" "}
243 {files.length > 0 && (
244 <span class="tab-count">{files.length}</span>
245 )}
246 </a>
247 </div>
248
249 {tab === "conversation" ? (
250 <div class="issue-detail">
251 {/* Patch description */}
252 <div class="timeline-item">
253 <div class="timeline-author">
254 <Avatar
255 userId={patch.author_id}
256 version={patch.author_avatar_version}
257 size={24}
258 />
259 <strong>
260 {displayName(patch.author_username)}
261 </strong>
262 <div class="timeline-author-right">
263 {canEdit && (
264 <details class="inline-edit-details">
265 <summary class="btn btn-xs btn-ghost">
266 <span class="when-closed">
267 Edit
268 </span>
269 <span class="when-open">
270 Stop editing
271 </span>
272 </summary>
273 </details>
274 )}
275 <DateWithEdited
276 date={patch.created_at}
277 editedAt={patch.edited_at}
278 />
279 </div>
280 </div>
281 {canEdit && (
282 <div class="inline-edit-form-area">
283 <form
284 method="POST"
285 action={`${baseUrl}/edit`}
286 class="inline-edit-form"
287 >
288 <input
289 type="hidden"
290 name="title"
291 value={patch.title}
292 />
293 <div class="form-group">
294 <textarea
295 class="form-input"
296 id="edit-patch-desc"
297 name="edit_description"
298 rows="6"
299 >
300 {patch.description}
301 </textarea>
302 </div>
303 <div class="form-actions">
304 <button
305 type="submit"
306 class="btn btn-sm btn-primary"
307 >
308 Save
309 </button>
310 </div>
311 </form>
312 </div>
313 )}
314 <div class="timeline-body markdown-body">
315 {descriptionHtml || (
316 <em class="text-muted">
317 No description provided.
318 </em>
319 )}
320 </div>
321 <ReactionBar
322 reactions={reactions}
323 postUrl={reactUrl}
324 user={user}
325 />
326 </div>
327
328 {/* Apply status */}
329 {applyResult && (
330 <div class="timeline-item">
331 <div
332 class={`apply-result apply-${applyResult.status}`}
333 >
334 <div>
335 <span class="apply-icon">
336 {applyResult.status === "clean"
337 ? "✓"
338 : "✗"}
339 </span>
340 <span>
341 {applyResult.status === "clean"
342 ? "Applies cleanly"
343 : "Has conflicts"}
344 </span>
345 </div>
346 {applyResult.output && (
347 <pre class="apply-output">
348 {applyResult.output}
349 </pre>
350 )}
351 </div>
352 </div>
353 )}
354
355 {/* Comments */}
356 {comments.map((comment) => (
357 <div class="timeline-item">
358 <div class="timeline-author">
359 <Avatar
360 userId={comment.author_id}
361 version={comment.author_avatar_version}
362 size={24}
363 />
364 <strong>
365 {displayName(comment.author_username)}
366 </strong>
367 <div class="timeline-author-right">
368 {canEditComment(comment) && (
369 <details class="inline-edit-details">
370 <summary class="btn btn-xs btn-ghost">
371 <span class="when-closed">
372 Edit
373 </span>
374 <span class="when-open">
375 Stop editing
376 </span>
377 </summary>
378 </details>
379 )}
380 <DateWithEdited
381 date={comment.created_at}
382 editedAt={comment.edited_at}
383 />
384 </div>
385 </div>
386 {canEditComment(comment) && (
387 <div class="inline-edit-form-area">
388 <form
389 method="POST"
390 action={`${baseUrl}/comments/${comment.id}/edit`}
391 class="inline-edit-form"
392 >
393 <div class="form-group">
394 <textarea
395 class="form-input"
396 name="edit_body"
397 rows="6"
398 >
399 {comment.body}
400 </textarea>
401 </div>
402 <div class="form-actions">
403 <button
404 type="submit"
405 class="btn btn-sm btn-primary"
406 >
407 Save
408 </button>
409 </div>
410 </form>
411 </div>
412 )}
413 <div class="timeline-body markdown-body">
414 {comment.bodyHtml}
415 </div>
416 <ReactionBar
417 reactions={
418 commentReactions.get(comment.id) ?? []
419 }
420 postUrl={reactUrl}
421 commentId={comment.id}
422 user={user}
423 />
424 </div>
425 ))}
426
427 {user && (
428 <div class="timeline-item timeline-item-new">
429 <h3 class="section-title">Add a comment</h3>
430 <form
431 method="POST"
432 action={`${baseUrl}/comments`}
433 >
434 <div class="form-group">
435 <textarea
436 name="body"
437 rows="6"
438 placeholder="Leave a comment (Markdown supported)"
439 required
440 />
441 </div>
442 <div class="form-actions">
443 <button
444 type="submit"
445 class="btn btn-primary"
446 >
447 Comment
448 </button>
449 </div>
450 </form>
451 </div>
452 )}
453 {!user && (
454 <p class="text-muted">
455 <a href="/login">Sign in</a> to leave a comment.
456 </p>
457 )}
458 </div>
459 ) : (
460 <div>
461 <div class="commit-card">
462 <h2 class="commit-card-subject">
463 {escapeHtml(patchMeta.subject)}
464 </h2>
465 {patchMeta.body && (
466 <pre class="commit-card-body">
467 {escapeHtml(patchMeta.body)}
468 </pre>
469 )}
470 <div class="commit-card-meta">
471 <div class="commit-card-meta-row">
472 <span class="commit-meta-label">
473 Author
474 </span>
475 <span class="commit-meta-value">
476 {escapeHtml(
477 `${patchMeta.author} <${patchMeta.email}>`,
478 )}
479 </span>
480 </div>
481 <div class="commit-card-meta-row">
482 <span class="commit-meta-label">Date</span>
483 <time
484 class="commit-meta-value"
485 datetime={patchMeta.date}
486 >
487 {formatDateTime(patchMeta.date)}
488 </time>
489 </div>
490 </div>
491 </div>
492 <DiffView files={files} repo={repo} />
493 </div>
494 )}
495 </div>
496 </Layout>
497 );
498}
499