@reviewflow/ui 0.0.0-stage → 6.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/LICENSE +15 -0
  2. package/README.md +95 -2
  3. package/package.json +101 -3
  4. package/src/board/bucket-section.tsx +94 -0
  5. package/src/board/dataSource.ts +44 -0
  6. package/src/board/facts.ts +183 -0
  7. package/src/board/index.ts +12 -0
  8. package/src/board/pull-request-board.tsx +239 -0
  9. package/src/board/pull-request-row.tsx +181 -0
  10. package/src/components/branch-diff/diff-themes.ts +6 -0
  11. package/src/components/branch-diff/diff-worker-pool.tsx +12 -0
  12. package/src/components/branch-diff/diff-worker-pool.web.tsx +56 -0
  13. package/src/components/branch-diff/diff-worker.ts +7 -0
  14. package/src/components/branch-diff/use-collapsed-files.ts +49 -0
  15. package/src/components/branch-diff/use-diff-settings.tsx +97 -0
  16. package/src/components/branch-diff/whitespace-button.tsx +32 -0
  17. package/src/components/copyable-code-block.tsx +101 -0
  18. package/src/components/label-pill.tsx +84 -0
  19. package/src/components/link-in-row.ts +6 -0
  20. package/src/components/markdown-suggestion.tsx +60 -0
  21. package/src/components/markdown-text.tsx +1067 -0
  22. package/src/components/pull-request-actions-merge.tsx +341 -0
  23. package/src/components/pull-request-actions-review.tsx +218 -0
  24. package/src/components/pull-request-checks.tsx +86 -0
  25. package/src/components/pull-request-commit-files.ts +143 -0
  26. package/src/components/pull-request-commits.tsx +221 -0
  27. package/src/components/pull-request-conversation-card.tsx +220 -0
  28. package/src/components/pull-request-conversation-edit.tsx +147 -0
  29. package/src/components/pull-request-conversation-event.tsx +267 -0
  30. package/src/components/pull-request-conversation-person.tsx +28 -0
  31. package/src/components/pull-request-conversation-thread.tsx +158 -0
  32. package/src/components/pull-request-diff-annotations.tsx +443 -0
  33. package/src/components/pull-request-diff-collapse.ts +43 -0
  34. package/src/components/pull-request-diff-data.ts +174 -0
  35. package/src/components/pull-request-diff-impacts-summary.tsx +97 -0
  36. package/src/components/pull-request-diff-impacts.ts +45 -0
  37. package/src/components/pull-request-diff-jump.ts +87 -0
  38. package/src/components/pull-request-diff-lines.ts +103 -0
  39. package/src/components/pull-request-diff-overview.tsx +416 -0
  40. package/src/components/pull-request-diff-view.tsx +438 -0
  41. package/src/components/pull-request-diff-viewer.tsx +1374 -0
  42. package/src/components/pull-request-files.tsx +533 -0
  43. package/src/components/pull-request-markdown-input.tsx +91 -0
  44. package/src/components/pull-request-panel.tsx +1270 -0
  45. package/src/components/pull-request-part-notice.tsx +82 -0
  46. package/src/components/pull-request-reactions.tsx +178 -0
  47. package/src/components/pull-request-review.tsx +164 -0
  48. package/src/components/pull-request-revisions.tsx +273 -0
  49. package/src/components/pull-request-screen.tsx +299 -0
  50. package/src/components/pull-request-suggestion-actions.tsx +201 -0
  51. package/src/components/pull-request-timeline.tsx +270 -0
  52. package/src/components/repository-attachment-banner.tsx +42 -0
  53. package/src/components/scroll-parent.ts +50 -0
  54. package/src/css.d.ts +1 -0
  55. package/src/dataSource.ts +242 -0
  56. package/src/editor/alerts.ts +105 -0
  57. package/src/editor/code-editor.native.tsx +25 -0
  58. package/src/editor/code-editor.tsx +112 -0
  59. package/src/editor/codeEditorExtensions.ts +65 -0
  60. package/src/editor/codeHighlight.ts +71 -0
  61. package/src/editor/commands.ts +175 -0
  62. package/src/editor/decorations.ts +425 -0
  63. package/src/editor/editable-file.tsx +224 -0
  64. package/src/editor/emojiCompletion.ts +110 -0
  65. package/src/editor/extensions.ts +149 -0
  66. package/src/editor/frontmatter.ts +76 -0
  67. package/src/editor/inlineFormat.ts +114 -0
  68. package/src/editor/lineTypes.ts +55 -0
  69. package/src/editor/markdown-editor.css +511 -0
  70. package/src/editor/markdown-editor.native.tsx +29 -0
  71. package/src/editor/markdown-editor.tsx +295 -0
  72. package/src/editor/nodeClasses.ts +69 -0
  73. package/src/index.ts +75 -0
  74. package/src/markdown-github/flatten.ts +49 -0
  75. package/src/markdown-github/html-tokens.ts +93 -0
  76. package/src/markdown-github/pair-html.ts +354 -0
  77. package/src/markdown-github/safe-url.ts +15 -0
  78. package/src/markdown-github/table-columns.ts +23 -0
  79. package/src/markdown-github/toggle-task.ts +164 -0
  80. package/src/nativewind-env.d.ts +1 -0
  81. package/src/prStatus.ts +90 -0
  82. package/src/services/PullRequestDraftsProvider.tsx +179 -0
  83. package/src/services/PullRequestReviewDraftProvider.tsx +178 -0
  84. package/src/services/PullRequestSuggestionBatchProvider.tsx +112 -0
  85. package/src/services/emojify.ts +13 -0
  86. package/src/services/errorToMessage.ts +6 -0
  87. package/src/services/expiringDraftStore.ts +102 -0
  88. package/src/services/formatDataAge.ts +12 -0
  89. package/src/services/formatRelativeDate.ts +56 -0
  90. package/src/services/githubImages.tsx +33 -0
  91. package/src/services/isViewerAuthor.ts +7 -0
  92. package/src/slots.tsx +34 -0
@@ -0,0 +1,341 @@
1
+ import type {
2
+ PullRequestCommittingMergeMethod,
3
+ PullRequestDetail,
4
+ PullRequestMergeCommitMessage,
5
+ PullRequestMergeMethod,
6
+ PullRequestMergeState,
7
+ } from "@reviewflow/sdk";
8
+ import {
9
+ ActionButton,
10
+ Button,
11
+ FormItem,
12
+ InputText,
13
+ QuestionAlertDialog,
14
+ SplitButton,
15
+ SplitButtonOption,
16
+ Text,
17
+ TextArea,
18
+ View,
19
+ } from "alouette";
20
+ import type { ReactNode } from "react";
21
+ import { useState } from "react";
22
+ import { errorToMessage } from "../services/errorToMessage.ts";
23
+ import type { PullRequestActionProps } from "./pull-request-actions-review.tsx";
24
+
25
+ const isMergeMethod = (value: string): value is PullRequestMergeMethod =>
26
+ value === "merge" || value === "squash" || value === "rebase";
27
+
28
+ const mergeMethodLabel = (method: PullRequestMergeMethod): string => {
29
+ if (method === "squash") return "Squash and merge";
30
+ if (method === "rebase") return "Rebase and merge";
31
+ return "Create a merge commit";
32
+ };
33
+
34
+ /** null: GitHub would take the merge. `unstable` is non-required checks failing. */
35
+ const mergeBlockReason = (state: PullRequestMergeState): string | null => {
36
+ switch (state) {
37
+ case "clean":
38
+ case "hasHooks":
39
+ case "unstable":
40
+ return null;
41
+ case "behind":
42
+ return "The branch is behind its base: update it first.";
43
+ case "blocked":
44
+ return "Blocked: required reviews or checks are missing.";
45
+ case "dirty":
46
+ return "The branch has conflicts that must be resolved.";
47
+ case "draft":
48
+ return "A draft cannot be merged.";
49
+ case "unknown":
50
+ return "GitHub is still computing mergeability: refresh in a moment.";
51
+ default: {
52
+ const unhandled: never = state;
53
+ return unhandled;
54
+ }
55
+ }
56
+ };
57
+
58
+ interface MergeButtonProps {
59
+ mergeMethods: readonly PullRequestMergeMethod[];
60
+ method: PullRequestMergeMethod | undefined;
61
+ blockReason: string | null;
62
+ onMethodChange: (method: PullRequestMergeMethod) => void;
63
+ onPress: () => void;
64
+ }
65
+
66
+ /**
67
+ * `SplitButton` has no `disabledReason`: the Status says why anyway, so only
68
+ * the single-method `Button` carries it.
69
+ */
70
+ function MergeButton({
71
+ mergeMethods,
72
+ method,
73
+ blockReason,
74
+ onMethodChange,
75
+ onPress,
76
+ }: MergeButtonProps): ReactNode {
77
+ if (method === undefined || mergeMethods.length < 2) {
78
+ return (
79
+ <Button
80
+ text={method === undefined ? "Merge" : mergeMethodLabel(method)}
81
+ accent="success"
82
+ variant="tonal"
83
+ size="sm"
84
+ disabledReason={blockReason}
85
+ onPress={onPress}
86
+ />
87
+ );
88
+ }
89
+
90
+ return (
91
+ <SplitButton
92
+ menuLabel="Merge method"
93
+ value={method}
94
+ accent="success"
95
+ variant="tonal"
96
+ size="sm"
97
+ disabled={blockReason !== null}
98
+ onValueChange={(next) => {
99
+ if (isMergeMethod(next)) onMethodChange(next);
100
+ }}
101
+ onPress={onPress}
102
+ >
103
+ {mergeMethods.includes("merge") ? (
104
+ <SplitButtonOption value="merge" label={mergeMethodLabel("merge")} />
105
+ ) : null}
106
+ {mergeMethods.includes("squash") ? (
107
+ <SplitButtonOption value="squash" label={mergeMethodLabel("squash")} />
108
+ ) : null}
109
+ {mergeMethods.includes("rebase") ? (
110
+ <SplitButtonOption value="rebase" label={mergeMethodLabel("rebase")} />
111
+ ) : null}
112
+ </SplitButton>
113
+ );
114
+ }
115
+
116
+ const pullRequestBlockReason = (
117
+ pullRequest: PullRequestDetail,
118
+ ): string | null =>
119
+ pullRequest.mergeMethods.length === 0
120
+ ? "The repository allows no merge method."
121
+ : mergeBlockReason(pullRequest.mergeState);
122
+
123
+ /**
124
+ * GitHub's merge box, the part read rather than pressed, for the Status: why
125
+ * the merge is refused, and Update branch when the base moved. `blocked` is
126
+ * left out: the checks and conversations listed beside it are the reason.
127
+ */
128
+ export function PullRequestMergeStatus({
129
+ detail,
130
+ onEdit,
131
+ }: PullRequestActionProps): ReactNode {
132
+ const { pullRequest } = detail;
133
+ if (pullRequest.state !== "open") return null;
134
+
135
+ const blockReason =
136
+ pullRequest.mergeState === "blocked"
137
+ ? null
138
+ : pullRequestBlockReason(pullRequest);
139
+ const unstable = pullRequest.mergeState === "unstable";
140
+ const canUpdateBranch =
141
+ pullRequest.viewerCanUpdateBranch || pullRequest.mergeState === "behind";
142
+ if (blockReason === null && !unstable && !canUpdateBranch) return null;
143
+
144
+ return (
145
+ <View className="items-start gap-xs">
146
+ {blockReason === null ? null : (
147
+ <Text className="text-xs text-muted">{blockReason}</Text>
148
+ )}
149
+ {unstable ? (
150
+ <Text className="text-xs text-muted">
151
+ Some checks that are not required are failing.
152
+ </Text>
153
+ ) : null}
154
+ {canUpdateBranch ? (
155
+ <ActionButton
156
+ text="Update branch"
157
+ accent="neutral"
158
+ variant="tonal"
159
+ size="sm"
160
+ errorToMessage={errorToMessage}
161
+ onPress={async () => {
162
+ await onEdit({
163
+ kind: "updateBranch",
164
+ expectedHeadSha: pullRequest.headSha,
165
+ });
166
+ }}
167
+ />
168
+ ) : null}
169
+ </View>
170
+ );
171
+ }
172
+
173
+ const isCommittingMergeMethod = (
174
+ method: PullRequestMergeMethod,
175
+ ): method is PullRequestCommittingMergeMethod => method !== "rebase";
176
+
177
+ interface CommitMessageDraft {
178
+ prefilled: PullRequestMergeCommitMessage;
179
+ draft: PullRequestMergeCommitMessage;
180
+ }
181
+
182
+ const editedCommitMessage = (
183
+ commit: CommitMessageDraft | null,
184
+ ): PullRequestMergeCommitMessage | undefined => {
185
+ if (commit === null) return undefined;
186
+ const { prefilled, draft } = commit;
187
+ if (draft.title === prefilled.title && draft.body === prefilled.body) {
188
+ return undefined;
189
+ }
190
+ return draft;
191
+ };
192
+
193
+ interface MergeCommitMessageFieldsProps {
194
+ value: PullRequestMergeCommitMessage;
195
+ onChange: (value: PullRequestMergeCommitMessage) => void;
196
+ }
197
+
198
+ function MergeCommitMessageFields({
199
+ value,
200
+ onChange,
201
+ }: MergeCommitMessageFieldsProps): ReactNode {
202
+ return (
203
+ <View className="gap-m">
204
+ <FormItem
205
+ label="Commit message"
206
+ render={({ labelId, describedBy, invalid, required }) => (
207
+ <InputText
208
+ aria-labelledby={labelId}
209
+ aria-describedby={describedBy}
210
+ aria-required={required}
211
+ invalid={invalid}
212
+ value={value.title}
213
+ onChangeText={(title) => {
214
+ onChange({ ...value, title });
215
+ }}
216
+ />
217
+ )}
218
+ />
219
+ <FormItem
220
+ label="Extended description"
221
+ render={({ labelId, describedBy, invalid, required }) => (
222
+ <TextArea
223
+ aria-labelledby={labelId}
224
+ aria-describedby={describedBy}
225
+ aria-required={required}
226
+ invalid={invalid}
227
+ value={value.body}
228
+ numberOfLines={6}
229
+ onChangeText={(body) => {
230
+ onChange({ ...value, body });
231
+ }}
232
+ />
233
+ )}
234
+ />
235
+ </View>
236
+ );
237
+ }
238
+
239
+ /**
240
+ * The merge itself: a method among what the repository allows, behind a
241
+ * confirmation. The button is an affordance read from a state that may be
242
+ * stale; GitHub's refusal, with the head sha the screen saw, is the guard.
243
+ * The confirmation shows the commit GitHub would write, sent only when edited
244
+ * so that an untouched one stays GitHub's own.
245
+ */
246
+ export function PullRequestMergeButton({
247
+ detail,
248
+ onEdit,
249
+ }: PullRequestActionProps): ReactNode {
250
+ const { pullRequest } = detail;
251
+ const [chosen, setChosen] = useState<PullRequestMergeMethod | null>(null);
252
+ const [confirming, setConfirming] = useState(false);
253
+ const [commit, setCommit] = useState<CommitMessageDraft | null>(null);
254
+ if (pullRequest.state !== "open") return null;
255
+
256
+ const { mergeMethods } = pullRequest;
257
+ const method =
258
+ chosen !== null && mergeMethods.includes(chosen) ? chosen : mergeMethods[0];
259
+
260
+ return (
261
+ <>
262
+ <MergeButton
263
+ mergeMethods={mergeMethods}
264
+ method={method}
265
+ blockReason={pullRequestBlockReason(pullRequest)}
266
+ onMethodChange={setChosen}
267
+ onPress={() => {
268
+ if (method === undefined) return;
269
+ const prefilled = isCommittingMergeMethod(method)
270
+ ? pullRequest.mergeCommitMessages[method]
271
+ : null;
272
+ setCommit(
273
+ prefilled === null ? null : { prefilled, draft: prefilled },
274
+ );
275
+ setConfirming(true);
276
+ }}
277
+ />
278
+ {method === undefined ? null : (
279
+ <QuestionAlertDialog
280
+ visible={confirming}
281
+ accent="success"
282
+ title="Merge this pull request?"
283
+ confirmText={mergeMethodLabel(method)}
284
+ confirmDisabled={commit !== null && commit.draft.title.trim() === ""}
285
+ errorToMessage={errorToMessage}
286
+ onConfirm={async () => {
287
+ const commitMessage = editedCommitMessage(commit);
288
+ await onEdit({
289
+ kind: "merge",
290
+ method,
291
+ expectedHeadSha: pullRequest.headSha,
292
+ ...(commitMessage === undefined ? {} : { commitMessage }),
293
+ });
294
+ setConfirming(false);
295
+ }}
296
+ onCancel={() => {
297
+ setConfirming(false);
298
+ }}
299
+ >
300
+ <View className="gap-m">
301
+ <Text>
302
+ {`#${String(pullRequest.number)} goes into ${pullRequest.baseRef} on GitHub, under your login.`}
303
+ </Text>
304
+ {commit === null ? null : (
305
+ <MergeCommitMessageFields
306
+ value={commit.draft}
307
+ onChange={(draft) => {
308
+ setCommit({ ...commit, draft });
309
+ }}
310
+ />
311
+ )}
312
+ </View>
313
+ </QuestionAlertDialog>
314
+ )}
315
+ </>
316
+ );
317
+ }
318
+
319
+ /** Closing is under the comment box, since it requires saying why. */
320
+ export function PullRequestReopen({
321
+ detail,
322
+ onEdit,
323
+ }: PullRequestActionProps): ReactNode {
324
+ const { pullRequest } = detail;
325
+ if (!pullRequest.viewerCanUpdate || pullRequest.state !== "closed") {
326
+ return null;
327
+ }
328
+
329
+ return (
330
+ <ActionButton
331
+ text="Reopen pull request"
332
+ accent="neutral"
333
+ variant="tonal"
334
+ size="sm"
335
+ errorToMessage={errorToMessage}
336
+ onPress={async () => {
337
+ await onEdit({ kind: "state", state: "open" });
338
+ }}
339
+ />
340
+ );
341
+ }
@@ -0,0 +1,218 @@
1
+ import type {
2
+ PullRequestDetailResponse,
3
+ PullRequestEdit,
4
+ PullRequestReviewEvent,
5
+ } from "@reviewflow/sdk";
6
+ import {
7
+ ActionButton,
8
+ Button,
9
+ Code,
10
+ IconButton,
11
+ Modal,
12
+ Radio,
13
+ RadioGroup,
14
+ Text,
15
+ View,
16
+ } from "alouette";
17
+ import { XRegularIcon } from "alouette-icons/phosphor-icons/X";
18
+ import type { ReactNode } from "react";
19
+ import { useState } from "react";
20
+ import {
21
+ useDraftText,
22
+ usePullRequestDrafts,
23
+ } from "../services/PullRequestDraftsProvider.tsx";
24
+ import { usePullRequestReviewDraft } from "../services/PullRequestReviewDraftProvider.tsx";
25
+ import { errorToMessage } from "../services/errorToMessage.ts";
26
+ import { PullRequestMarkdownInput } from "./pull-request-markdown-input.tsx";
27
+
28
+ export interface PullRequestActionProps {
29
+ detail: PullRequestDetailResponse;
30
+ /** sends one edit, then reads the pull request again */
31
+ onEdit: (edit: PullRequestEdit) => Promise<void>;
32
+ }
33
+
34
+ const isReviewEvent = (value: string): value is PullRequestReviewEvent =>
35
+ value === "comment" || value === "approve" || value === "requestChanges";
36
+
37
+ const firstLine = (body: string): string => body.split("\n")[0] ?? "";
38
+
39
+ /** What GitHub would refuse, said before the press rather than after. */
40
+ const submitBlocker = (
41
+ event: PullRequestReviewEvent,
42
+ body: string,
43
+ commentCount: number,
44
+ ): string | null => {
45
+ const empty = body.trim() === "";
46
+ if (event === "requestChanges" && empty) {
47
+ return "Requesting changes needs a summary.";
48
+ }
49
+ if (event === "comment" && empty && commentCount === 0) {
50
+ return "A comment review needs a summary or at least one inline comment.";
51
+ }
52
+ return null;
53
+ };
54
+
55
+ /**
56
+ * "Review changes", ending the Files changed tab's sticky toolbar: the verdict, a
57
+ * summary and the inline comments the diff collected, sent as one review.
58
+ * Brand while comments are pending, so an unsent review is not forgotten. Never
59
+ * offered to the author, whom GitHub lets comment but not review. A review
60
+ * pinned to a head since replaced only comments: a verdict is given on the
61
+ * code as it stands, which the server checks too.
62
+ */
63
+ export function PullRequestReviewButton({
64
+ detail,
65
+ onEdit,
66
+ }: PullRequestActionProps): ReactNode {
67
+ const { pullRequest } = detail;
68
+ const { comments, remove, clear, shas, latest, follow } =
69
+ usePullRequestReviewDraft();
70
+ const [open, setOpen] = useState(false);
71
+ const [chosen, setChosen] = useState<PullRequestReviewEvent>("comment");
72
+ const drafts = usePullRequestDrafts();
73
+ const summary = useDraftText("review:body");
74
+ const body = summary.text;
75
+ const headMoved =
76
+ shas !== null && latest !== null && shas.head !== latest.head;
77
+ const event = headMoved ? "comment" : chosen;
78
+ const blocker = submitBlocker(event, body, comments.length);
79
+
80
+ return (
81
+ <>
82
+ <Button
83
+ text={
84
+ comments.length === 0
85
+ ? "Review changes"
86
+ : `Review changes (${String(comments.length)})`
87
+ }
88
+ accent={comments.length === 0 ? "neutral" : "brand"}
89
+ variant="tonal"
90
+ size="sm"
91
+ onPress={() => {
92
+ setOpen(true);
93
+ }}
94
+ />
95
+ <Modal
96
+ visible={open}
97
+ title="Review changes"
98
+ size="lg"
99
+ accent="brand"
100
+ footer={
101
+ <>
102
+ <Button
103
+ text="Cancel"
104
+ accent="neutral"
105
+ variant="soft"
106
+ onPress={() => {
107
+ setOpen(false);
108
+ }}
109
+ />
110
+ <ActionButton
111
+ text="Submit review"
112
+ accent="brand"
113
+ variant="tonal"
114
+ disabledReason={blocker}
115
+ errorMessageVariant="flat"
116
+ errorToMessage={errorToMessage}
117
+ onPress={async () => {
118
+ await onEdit({
119
+ kind: "review",
120
+ event,
121
+ body,
122
+ commitId: shas?.head ?? pullRequest.headSha,
123
+ comments: comments.map(
124
+ ({ key: _key, ...comment }) => comment,
125
+ ),
126
+ });
127
+ clear();
128
+ summary.discard(body);
129
+ setChosen("comment");
130
+ setOpen(false);
131
+ }}
132
+ />
133
+ </>
134
+ }
135
+ onClose={() => {
136
+ setOpen(false);
137
+ }}
138
+ >
139
+ <View className="gap-m">
140
+ <PullRequestMarkdownInput
141
+ aria-label="Review summary"
142
+ value={body}
143
+ onChange={summary.setText}
144
+ />
145
+ {/* TODO: rows stretch to the full width once alouette allows it: https://github.com/christophehurpeau/alouette/issues/397 */}
146
+ <RadioGroup
147
+ aria-label="Verdict"
148
+ value={event}
149
+ onValueChange={(next) => {
150
+ if (isReviewEvent(next)) setChosen(next);
151
+ }}
152
+ >
153
+ <Radio value="comment" label="Comment" />
154
+ <Radio value="approve" label="Approve" disabled={headMoved} />
155
+ <Radio
156
+ value="requestChanges"
157
+ label="Request changes"
158
+ disabled={headMoved}
159
+ />
160
+ </RadioGroup>
161
+ {headMoved && follow === null ? (
162
+ <Text className="text-sm text-muted">
163
+ New commits were pushed since this review started: it can only
164
+ comment. Approve or request changes once it is sent, on the latest
165
+ version.
166
+ </Text>
167
+ ) : null}
168
+ {headMoved && follow !== null ? (
169
+ <Text className="text-sm text-muted">
170
+ New commits were pushed since this diff was shown: this review can
171
+ only comment. Show the latest changes to approve or request
172
+ changes.
173
+ </Text>
174
+ ) : null}
175
+ {blocker === null ? null : (
176
+ <Text className="text-sm text-muted">{blocker}</Text>
177
+ )}
178
+ <View className="gap-xs">
179
+ <Text className="font-body-bold text-sm">
180
+ {`Inline comments (${String(comments.length)})`}
181
+ </Text>
182
+ {comments.length === 0 ? (
183
+ <Text className="text-sm text-muted">
184
+ None. Add them from the Files changed tab.
185
+ </Text>
186
+ ) : (
187
+ comments.map((comment) => (
188
+ <View
189
+ key={comment.key}
190
+ className="flex-row items-center gap-xs"
191
+ >
192
+ <Code className="text-sm">{`${comment.path}:${String(comment.line)}`}</Code>
193
+ <Text className="min-w-0 flex-1 text-sm" numberOfLines={1}>
194
+ {firstLine(comment.body)}
195
+ </Text>
196
+ <IconButton
197
+ icon={<XRegularIcon />}
198
+ aria-label={`Remove the comment on ${comment.path}:${String(comment.line)}`}
199
+ variant="soft"
200
+ size="sm"
201
+ onPress={() => {
202
+ remove(comment.key);
203
+ }}
204
+ />
205
+ </View>
206
+ ))
207
+ )}
208
+ </View>
209
+ <Text className="text-xs text-muted">
210
+ {drafts === null
211
+ ? "Pending comments live in memory until the review is sent: reloading or leaving this pull request drops them."
212
+ : `Pending comments and the summary are kept on this device until the review is sent, for ${String(drafts.retentionDays)} days after their last change.`}
213
+ </Text>
214
+ </View>
215
+ </Modal>
216
+ </>
217
+ );
218
+ }
@@ -0,0 +1,86 @@
1
+ import type { PullRequestCheck } from "@reviewflow/sdk";
2
+ import type { Accent } from "alouette";
3
+ import { AccentScope, ExternalLinkText, Icon, Text, View } from "alouette";
4
+ import { CheckCircleRegularIcon } from "alouette-icons/phosphor-icons/CheckCircle";
5
+ import { CircleNotchRegularIcon } from "alouette-icons/phosphor-icons/CircleNotch";
6
+ import { ClockRegularIcon } from "alouette-icons/phosphor-icons/Clock";
7
+ import { XCircleRegularIcon } from "alouette-icons/phosphor-icons/XCircle";
8
+ import type { ReactNode } from "react";
9
+
10
+ const checkAccent = (outcome: PullRequestCheck["outcome"]): Accent => {
11
+ if (outcome === "failed") return "danger";
12
+ if (outcome === "pending") return "warning";
13
+ return "success";
14
+ };
15
+
16
+ const checkLabel = (check: PullRequestCheck): string => {
17
+ if (check.outcome === "failed") return "failing";
18
+ if (check.running) return "running";
19
+ if (check.outcome === "pending") return "pending";
20
+ return "passing";
21
+ };
22
+
23
+ function CheckGlyph({ check }: { check: PullRequestCheck }): ReactNode {
24
+ if (check.outcome === "failed") {
25
+ return (
26
+ <Icon icon={<XCircleRegularIcon />} size={16} className="text-accent" />
27
+ );
28
+ }
29
+ if (check.running) {
30
+ return (
31
+ <View className="motion-safe:animate-spin">
32
+ <Icon
33
+ icon={<CircleNotchRegularIcon />}
34
+ size={16}
35
+ className="text-accent"
36
+ />
37
+ </View>
38
+ );
39
+ }
40
+ if (check.outcome === "pending") {
41
+ return (
42
+ <Icon icon={<ClockRegularIcon />} size={16} className="text-accent" />
43
+ );
44
+ }
45
+ return (
46
+ <Icon icon={<CheckCircleRegularIcon />} size={16} className="text-accent" />
47
+ );
48
+ }
49
+
50
+ /**
51
+ * A glyph in a fixed column rather than a word badge: the sidebar is narrow,
52
+ * and the name and what the check says about itself wrap beside it instead of
53
+ * under it. The description of a passing check is left out — it says it passed.
54
+ */
55
+ export function CheckLine({ check }: { check: PullRequestCheck }): ReactNode {
56
+ return (
57
+ <View className="flex-row items-start gap-xs">
58
+ <AccentScope accent={checkAccent(check.outcome)}>
59
+ <View
60
+ role="img"
61
+ aria-label={checkLabel(check)}
62
+ className="h-[20px] justify-center"
63
+ >
64
+ <CheckGlyph check={check} />
65
+ </View>
66
+ </AccentScope>
67
+ <View className="min-w-0 flex-1">
68
+ {check.url === null ? (
69
+ <Text className="text-sm">{check.name}</Text>
70
+ ) : (
71
+ <ExternalLinkText
72
+ size="sm"
73
+ icon={null}
74
+ accent="neutral"
75
+ href={check.url}
76
+ text={check.name}
77
+ aria-label={`Open ${check.name}`}
78
+ />
79
+ )}
80
+ {check.description !== null && check.outcome !== "passed" ? (
81
+ <Text className="text-xs text-muted">{check.description}</Text>
82
+ ) : null}
83
+ </View>
84
+ </View>
85
+ );
86
+ }