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