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}`}>
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>
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 >
272 {patch.description}
273 </textarea>
274 </div>
275 <div class="form-actions">
276 <button
277 type="submit"
278 class="btn btn-sm btn-primary"
279 >
280 Save
281 </button>
282 </div>
283 </form>
284 </div>
285 )}
286 <div class="timeline-body markdown-body">
287 {descriptionHtml || (
288 <em class="text-muted">
289 No description provided.
290 </em>
291 )}
292 </div>
293 <ReactionBar
294 reactions={reactions}
295 postUrl={reactUrl}
296 user={user}
297 />
298 </div>
299
300 {/* Apply status */}
301 {applyResult && (
302 <div class="timeline-item">
303 <div
304 class={`apply-result apply-${applyResult.status}`}
305 >
306 <div>
307 <span class="apply-icon">
308 {applyResult.status === "clean"
309 ? "✓"
310 : "✗"}
311 </span>
312 <span>
313 {applyResult.status === "clean"
314 ? "Applies cleanly"
315 : "Has conflicts"}
316 </span>
317 </div>
318 {applyResult.output && (
319 <pre class="apply-output">
320 {applyResult.output}
321 </pre>
322 )}
323 </div>
324 </div>
325 )}
326
327 <CommentThread
328 user={user}
329 comments={comments}
330 commentReactions={commentReactions}
331 baseUrl={baseUrl}
332 parentStatus={patch.status}
333 />
334 </div>
335 ) : (
336 <div>
337 <div class="commit-card">
338 <h2
339 class={`commit-card-subject${patchMeta.subject ? "" : " commit-card-subject-empty"}`}
340 >
341 {patchMeta.subject
342 ? escapeHtml(patchMeta.subject)
343 : "No commit message"}
344 </h2>
345 {patchMeta.body && (
346 <pre class="commit-card-body">
347 {escapeHtml(patchMeta.body)}
348 </pre>
349 )}
350 <div class="commit-card-meta">
351 <div class="commit-card-meta-row">
352 <span class="commit-meta-label">
353 Author
354 </span>
355 <span class="commit-meta-value">
356 {escapeHtml(
357 `${patchMeta.author} <${patchMeta.email}>`,
358 )}
359 </span>
360 </div>
361 <div class="commit-card-meta-row">
362 <span class="commit-meta-label">Date</span>
363 <time
364 class="commit-meta-value"
365 datetime={patchMeta.date}
366 >
367 {formatDateTime(patchMeta.date)}
368 </time>
369 </div>
370 </div>
371 </div>
372 <DiffView files={files} repo={repo} />
373 </div>
374 )}
375 </div>
376 </Layout>
377 );
378}
379