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