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