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 <button
137 type="submit"
138 class="btn btn-sm btn-primary"
139 >
140 Merge patch
141 </button>
142 </form>
143 )}
144 {user?.isAdmin && patch.status !== "merged" && (
145 <form
146 method="POST"
147 action={`${baseUrl}/close`}
148 class="inline-form"
149 >
150 <button
151 type="submit"
152 class="btn btn-sm"
153 >
154 {patch.status === "open"
155 ? "Close patch"
156 : "Reopen patch"}
157 </button>
158 </form>
159 )}
160 <details class="confirm-details">
161 <summary class="btn btn-sm btn-danger">
162 Delete
163 </summary>
164 <div class="confirm-popup">
165 Permanently delete this patch?
166 <form
167 method="POST"
168 action={`${baseUrl}/delete`}
169 class="inline-form"
170 >
171 <button
172 type="submit"
173 class="btn btn-sm btn-danger"
174 >
175 Yes, delete
176 </button>
177 </form>
178 </div>
179 </details>
180 </div>
181 )}
182 </div>
183 <div class="patch-author-meta">
184 <span class="patch-author-label">git author:</span>
185 <span class="patch-author-identity">
186 {escapeHtml(
187 `${patch.author_name} <${patch.author_email}>`,
188 )}
189 </span>
190 </div>
191 </div>
192
193 {/* Subview tabs */}
194 <div class="patch-tab-nav">
195 <a
196 href={baseUrl}
197 class={`repo-tab${tab === "conversation" ? " active" : ""}`}
198 >
199 Conversation{" "}
200 {comments.length > 0 && (
201 <span class="tab-count">{comments.length}</span>
202 )}
203 </a>
204 <a
205 href={`${baseUrl}?tab=changes`}
206 class={`repo-tab${tab === "changes" ? " active" : ""}`}
207 >
208 Changes{" "}
209 {files.length > 0 && (
210 <span class="tab-count">{files.length}</span>
211 )}
212 </a>
213 </div>
214
215 {tab === "conversation" ? (
216 <div class="issue-detail">
217 {/* Patch description */}
218 <div class="timeline-item">
219 <div class="timeline-author">
220 <Avatar
221 userId={patch.author_id}
222 version={patch.author_avatar_version}
223 size={24}
224 />
225 <strong>
226 {displayName(patch.author_username)}
227 </strong>
228 <div class="timeline-author-right">
229 {canEdit && (
230 <details class="inline-edit-details">
231 <summary class="btn btn-xs btn-ghost">
232 <span class="when-closed">
233 Edit
234 </span>
235 <span class="when-open">
236 Stop editing
237 </span>
238 </summary>
239 </details>
240 )}
241 <DateWithEdited
242 date={patch.created_at}
243 editedAt={patch.edited_at}
244 />
245 </div>
246 </div>
247 {canEdit && (
248 <div class="inline-edit-form-area">
249 <form
250 method="POST"
251 action={`${baseUrl}/edit`}
252 class="inline-edit-form"
253 >
254 <input
255 type="hidden"
256 name="title"
257 value={patch.title}
258 />
259 <div class="form-group">
260 <textarea
261 class="form-input"
262 id="edit-patch-desc"
263 name="edit_description"
264 rows="6"
265 >
266 {patch.description}
267 </textarea>
268 </div>
269 <div class="form-actions">
270 <button
271 type="submit"
272 class="btn btn-sm btn-primary"
273 >
274 Save
275 </button>
276 </div>
277 </form>
278 </div>
279 )}
280 <div class="timeline-body markdown-body">
281 {descriptionHtml || (
282 <em class="text-muted">
283 No description provided.
284 </em>
285 )}
286 </div>
287 <ReactionBar
288 reactions={reactions}
289 postUrl={reactUrl}
290 user={user}
291 />
292 </div>
293
294 {/* Apply status */}
295 {applyResult && (
296 <div class="timeline-item">
297 <div
298 class={`apply-result apply-${applyResult.status}`}
299 >
300 <div>
301 <span class="apply-icon">
302 {applyResult.status === "clean"
303 ? "✓"
304 : "✗"}
305 </span>
306 <span>
307 {applyResult.status === "clean"
308 ? "Applies cleanly"
309 : "Has conflicts"}
310 </span>
311 </div>
312 {applyResult.output && (
313 <pre class="apply-output">
314 {applyResult.output}
315 </pre>
316 )}
317 </div>
318 </div>
319 )}
320
321 {/* Comments */}
322 {comments.map((comment) => (
323 <div class="timeline-item">
324 <div class="timeline-author">
325 <Avatar
326 userId={comment.author_id}
327 version={comment.author_avatar_version}
328 size={24}
329 />
330 <strong>
331 {displayName(comment.author_username)}
332 </strong>
333 <div class="timeline-author-right">
334 {canEditComment(comment) && (
335 <details class="inline-edit-details">
336 <summary class="btn btn-xs btn-ghost">
337 <span class="when-closed">
338 Edit
339 </span>
340 <span class="when-open">
341 Stop editing
342 </span>
343 </summary>
344 </details>
345 )}
346 <DateWithEdited
347 date={comment.created_at}
348 editedAt={comment.edited_at}
349 />
350 </div>
351 </div>
352 {canEditComment(comment) && (
353 <div class="inline-edit-form-area">
354 <form
355 method="POST"
356 action={`${baseUrl}/comments/${comment.id}/edit`}
357 class="inline-edit-form"
358 >
359 <div class="form-group">
360 <textarea
361 class="form-input"
362 name="edit_body"
363 rows="6"
364 >
365 {comment.body}
366 </textarea>
367 </div>
368 <div class="form-actions">
369 <button
370 type="submit"
371 class="btn btn-sm btn-primary"
372 >
373 Save
374 </button>
375 </div>
376 </form>
377 </div>
378 )}
379 <div class="timeline-body markdown-body">
380 {comment.bodyHtml}
381 </div>
382 <ReactionBar
383 reactions={
384 commentReactions.get(comment.id) ?? []
385 }
386 postUrl={reactUrl}
387 commentId={comment.id}
388 user={user}
389 />
390 </div>
391 ))}
392
393 {user && (
394 <div class="timeline-item timeline-item-new">
395 <h3 class="section-title">Add a comment</h3>
396 <form
397 method="POST"
398 action={`${baseUrl}/comments`}
399 >
400 <div class="form-group">
401 <textarea
402 name="body"
403 rows="6"
404 placeholder="Leave a comment (Markdown supported)"
405 required
406 />
407 </div>
408 <div class="form-actions">
409 <button
410 type="submit"
411 class="btn btn-primary"
412 >
413 Comment
414 </button>
415 </div>
416 </form>
417 </div>
418 )}
419 {!user && (
420 <p class="text-muted">
421 <a href="/login">Sign in</a> to leave a comment.
422 </p>
423 )}
424 </div>
425 ) : (
426 <div>
427 <div class="commit-card">
428 <h2 class="commit-card-subject">
429 {escapeHtml(patchMeta.subject)}
430 </h2>
431 {patchMeta.body && (
432 <pre class="commit-card-body">
433 {escapeHtml(patchMeta.body)}
434 </pre>
435 )}
436 <div class="commit-card-meta">
437 <div class="commit-card-meta-row">
438 <span class="commit-meta-label">
439 Author
440 </span>
441 <span class="commit-meta-value">
442 {escapeHtml(
443 `${patchMeta.author} <${patchMeta.email}>`,
444 )}
445 </span>
446 </div>
447 <div class="commit-card-meta-row">
448 <span class="commit-meta-label">Date</span>
449 <time
450 class="commit-meta-value"
451 datetime={patchMeta.date}
452 >
453 {formatDateTime(patchMeta.date)}
454 </time>
455 </div>
456 </div>
457 </div>
458 <DiffView files={files} repo={repo} />
459 </div>
460 )}
461 </div>
462 </Layout>
463 );
464}
465