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">{escapeHtml(`${patch.author_name} <${patch.author_email}>`)}</span>
182 </div>
183 </div>
184
185 {/* Subview tabs */}
186 <div class="patch-tab-nav">
187 <a
188 href={baseUrl}
189 class={`repo-tab${tab === "conversation" ? " active" : ""}`}
190 >
191 Conversation{" "}
192 {comments.length > 0 && (
193 <span class="tab-count">{comments.length}</span>
194 )}
195 </a>
196 <a
197 href={`${baseUrl}?tab=changes`}
198 class={`repo-tab${tab === "changes" ? " active" : ""}`}
199 >
200 Changes{" "}
201 {files.length > 0 && (
202 <span class="tab-count">{files.length}</span>
203 )}
204 </a>
205 </div>
206
207 {tab === "conversation" ? (
208 <div class="issue-detail">
209 {/* Patch description */}
210 <div class="timeline-item">
211 <div class="timeline-author">
212 <Avatar
213 userId={patch.author_id}
214 version={patch.author_avatar_version}
215 size={24}
216 />
217 <strong>
218 {displayName(patch.author_username)}
219 </strong>
220 <div class="timeline-author-right">
221 {canEdit && (
222 <details class="inline-edit-details">
223 <summary class="btn btn-xs btn-ghost">
224 <span class="when-closed">
225 Edit
226 </span>
227 <span class="when-open">
228 Stop editing
229 </span>
230 </summary>
231 </details>
232 )}
233 <DateWithEdited
234 date={patch.created_at}
235 editedAt={patch.edited_at}
236 />
237 </div>
238 </div>
239 {canEdit && (
240 <div class="inline-edit-form-area">
241 <form
242 method="POST"
243 action={`${baseUrl}/edit`}
244 class="inline-edit-form"
245 >
246 <input
247 type="hidden"
248 name="title"
249 value={patch.title}
250 />
251 <div class="form-group">
252 <textarea
253 class="form-input"
254 id="edit-patch-desc"
255 name="edit_description"
256 rows="6"
257 >
258 {patch.description}
259 </textarea>
260 </div>
261 <div class="form-actions">
262 <button
263 type="submit"
264 class="btn btn-sm btn-primary"
265 >
266 Save
267 </button>
268 </div>
269 </form>
270 </div>
271 )}
272 <div class="timeline-body markdown-body">
273 {descriptionHtml || (
274 <em class="text-muted">
275 No description provided.
276 </em>
277 )}
278 </div>
279 <ReactionBar
280 reactions={reactions}
281 postUrl={reactUrl}
282 user={user}
283 />
284 </div>
285
286 {/* Apply status */}
287 {applyResult && (
288 <div class="timeline-item">
289 <div
290 class={`apply-result apply-${applyResult.status}`}
291 >
292 <div>
293 <span class="apply-icon">
294 {applyResult.status === "clean"
295 ? "✓"
296 : "✗"}
297 </span>
298 <span>
299 {applyResult.status === "clean"
300 ? "Applies cleanly"
301 : "Has conflicts"}
302 </span>
303 </div>
304 {applyResult.output && (
305 <pre class="apply-output">
306 {applyResult.output}
307 </pre>
308 )}
309 </div>
310 </div>
311 )}
312
313 {/* Comments */}
314 {comments.map((comment) => (
315 <div class="timeline-item">
316 <div class="timeline-author">
317 <Avatar
318 userId={comment.author_id}
319 version={comment.author_avatar_version}
320 size={24}
321 />
322 <strong>
323 {displayName(comment.author_username)}
324 </strong>
325 <div class="timeline-author-right">
326 {canEditComment(comment) && (
327 <details class="inline-edit-details">
328 <summary class="btn btn-xs btn-ghost">
329 <span class="when-closed">
330 Edit
331 </span>
332 <span class="when-open">
333 Stop editing
334 </span>
335 </summary>
336 </details>
337 )}
338 <DateWithEdited
339 date={comment.created_at}
340 editedAt={comment.edited_at}
341 />
342 </div>
343 </div>
344 {canEditComment(comment) && (
345 <div class="inline-edit-form-area">
346 <form
347 method="POST"
348 action={`${baseUrl}/comments/${comment.id}/edit`}
349 class="inline-edit-form"
350 >
351 <div class="form-group">
352 <textarea
353 class="form-input"
354 name="edit_body"
355 rows="6"
356 >
357 {comment.body}
358 </textarea>
359 </div>
360 <div class="form-actions">
361 <button
362 type="submit"
363 class="btn btn-sm btn-primary"
364 >
365 Save
366 </button>
367 </div>
368 </form>
369 </div>
370 )}
371 <div class="timeline-body markdown-body">
372 {comment.bodyHtml}
373 </div>
374 <ReactionBar
375 reactions={
376 commentReactions.get(comment.id) ?? []
377 }
378 postUrl={reactUrl}
379 commentId={comment.id}
380 user={user}
381 />
382 </div>
383 ))}
384
385 {user && (
386 <div class="timeline-item timeline-item-new">
387 <h3 class="section-title">Add a comment</h3>
388 <form
389 method="POST"
390 action={`${baseUrl}/comments`}
391 >
392 <div class="form-group">
393 <textarea
394 name="body"
395 rows="6"
396 placeholder="Leave a comment (Markdown supported)"
397 required
398 />
399 </div>
400 <div class="form-actions">
401 <button
402 type="submit"
403 class="btn btn-primary"
404 >
405 Comment
406 </button>
407 </div>
408 </form>
409 </div>
410 )}
411 {!user && (
412 <p class="text-muted">
413 <a href="/login">Sign in</a> to leave a comment.
414 </p>
415 )}
416 </div>
417 ) : (
418 <DiffView files={files} repo={repo} />
419 )}
420 </div>
421 </Layout>
422 );
423}
424