@reviewflow/ui 0.0.0-stage → 6.21.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 +102 -4
  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,533 @@
1
+ import type {
2
+ PullRequestCommit,
3
+ PullRequestDetail,
4
+ PullRequestDetailResponse,
5
+ PullRequestEdit,
6
+ PullRequestThread,
7
+ PullRequestViewerLastReview,
8
+ } from "@reviewflow/sdk";
9
+ import {
10
+ Button,
11
+ Code,
12
+ IconButton,
13
+ InfoMessage,
14
+ Menu,
15
+ MenuItem,
16
+ Tab,
17
+ Tabs,
18
+ Text,
19
+ View,
20
+ WarningMessage,
21
+ } from "alouette";
22
+ import { CaretLeftRegularIcon } from "alouette-icons/phosphor-icons/CaretLeft";
23
+ import { CaretRightRegularIcon } from "alouette-icons/phosphor-icons/CaretRight";
24
+ import { GitCommitRegularIcon } from "alouette-icons/phosphor-icons/GitCommit";
25
+ import type { ReactNode } from "react";
26
+ import { useMemo, useState } from "react";
27
+ import { usePullRequestDataSource } from "../dataSource.ts";
28
+ import { usePullRequestReviewDraft } from "../services/PullRequestReviewDraftProvider.tsx";
29
+ import { errorToMessage } from "../services/errorToMessage.ts";
30
+ import { formatRelativeDate } from "../services/formatRelativeDate.ts";
31
+ import { isViewerAuthor } from "../services/isViewerAuthor.ts";
32
+ import { PullRequestReviewButton } from "./pull-request-actions-review.tsx";
33
+ import type {
34
+ CommitSummary,
35
+ CommitsToRead,
36
+ } from "./pull-request-commit-files.ts";
37
+ import {
38
+ commitAnchorings,
39
+ commitsToRead,
40
+ firstCommitAfter,
41
+ summarizeCommits,
42
+ touchDifferentFiles,
43
+ } from "./pull-request-commit-files.ts";
44
+ import type { PullRequestMovedOn } from "./pull-request-diff-data.ts";
45
+ import {
46
+ changedPathsOf,
47
+ useMissingCheckoutReason,
48
+ usePullRequestMovedOn,
49
+ } from "./pull-request-diff-data.ts";
50
+ import { PullRequestDiffView } from "./pull-request-diff-view.tsx";
51
+ import type { PullRequestDiffReview } from "./pull-request-diff-viewer.tsx";
52
+ import {
53
+ PullRequestPartError,
54
+ PullRequestPartPending,
55
+ PullRequestPartReading,
56
+ } from "./pull-request-part-notice.tsx";
57
+
58
+ type Scope = "all" | "since";
59
+
60
+ interface MovedOnNoticeProps {
61
+ movedOn: PullRequestMovedOn | null;
62
+ follow: (() => void) | null;
63
+ }
64
+
65
+ function MovedOnNotice({ movedOn, follow }: MovedOnNoticeProps): ReactNode {
66
+ if (movedOn === null) return null;
67
+ if (follow === null) {
68
+ return (
69
+ <InfoMessage>
70
+ The pull request moved on while your review is in progress. The diff
71
+ stays on the commit the review started on until it is sent or no comment
72
+ is pending or being written. Files changed since cannot be marked
73
+ viewed, and their comments are listed above them rather than on a line.
74
+ </InfoMessage>
75
+ );
76
+ }
77
+ return (
78
+ <InfoMessage>
79
+ <View className="gap-xs">
80
+ <Text>
81
+ The pull request moved on since this diff was shown. Files changed
82
+ since cannot be marked viewed, and their comments are listed above
83
+ them rather than on a line.
84
+ </Text>
85
+ <View className="flex-row">
86
+ <Button
87
+ text="Show the latest changes"
88
+ accent="neutral"
89
+ variant="tonal"
90
+ size="sm"
91
+ onPress={follow}
92
+ />
93
+ </View>
94
+ </View>
95
+ </InfoMessage>
96
+ );
97
+ }
98
+
99
+ function LastReviewRange({
100
+ scope,
101
+ lastReview,
102
+ }: {
103
+ scope: Scope;
104
+ lastReview: PullRequestViewerLastReview | null;
105
+ }): ReactNode {
106
+ if (scope !== "since" || lastReview === null) return null;
107
+ return (
108
+ <View className="flex-row items-center gap-xs">
109
+ <Text className="text-sm text-muted">from your review on</Text>
110
+ <Code className="text-xs">{lastReview.oid.slice(0, 7)}</Code>
111
+ {lastReview.submittedAt === null ? null : (
112
+ <Text className="text-sm text-muted">
113
+ {formatRelativeDate(lastReview.submittedAt)}
114
+ </Text>
115
+ )}
116
+ </View>
117
+ );
118
+ }
119
+
120
+ interface ByCommitButtonProps {
121
+ byCommit: boolean;
122
+ disabledReason: string | null;
123
+ onToggle: () => void;
124
+ }
125
+
126
+ /** A toggle: brand while the commits are read one by one, as the whitespace one. */
127
+ function ByCommitButton({
128
+ byCommit,
129
+ disabledReason,
130
+ onToggle,
131
+ }: ByCommitButtonProps): ReactNode {
132
+ return (
133
+ <IconButton
134
+ icon={<GitCommitRegularIcon />}
135
+ size="sm"
136
+ accent={byCommit ? "brand" : "neutral"}
137
+ disabledReason={disabledReason}
138
+ aria-label={
139
+ byCommit ? "Show all the changes at once" : "Read commit by commit"
140
+ }
141
+ onPress={onToggle}
142
+ />
143
+ );
144
+ }
145
+
146
+ const describeCommit = (
147
+ commit: PullRequestCommit,
148
+ summary: CommitSummary | undefined,
149
+ ): string => {
150
+ const oid = commit.oid.slice(0, 7);
151
+ if (summary === undefined) return oid;
152
+ const files = `${String(summary.files)} ${summary.files === 1 ? "file" : "files"}`;
153
+ return [oid, summary.workspaces.join(", "), files]
154
+ .filter((part) => part !== "")
155
+ .join(" · ");
156
+ };
157
+
158
+ interface CommitStepperProps {
159
+ commits: PullRequestCommit[];
160
+ /** aligned with `commits` */
161
+ summaries: CommitSummary[];
162
+ index: number;
163
+ onIndexChange: (index: number) => void;
164
+ }
165
+
166
+ function CommitStepper({
167
+ commits,
168
+ summaries,
169
+ index,
170
+ onIndexChange,
171
+ }: CommitStepperProps): ReactNode {
172
+ const commit = commits[index];
173
+ if (commit === undefined) return null;
174
+ return (
175
+ <View className="min-w-0 shrink flex-row items-center gap-xs">
176
+ <IconButton
177
+ icon={<CaretLeftRegularIcon />}
178
+ size="sm"
179
+ accent="neutral"
180
+ aria-label="Previous commit"
181
+ disabledReason={index === 0 ? "This is the first commit" : null}
182
+ onPress={() => {
183
+ onIndexChange(index - 1);
184
+ }}
185
+ />
186
+ <Menu
187
+ label="Commits"
188
+ render={(triggerProps) => (
189
+ <Button
190
+ text={`${String(index + 1)} / ${String(commits.length)}`}
191
+ accent="neutral"
192
+ variant="soft"
193
+ size="sm"
194
+ {...triggerProps}
195
+ />
196
+ )}
197
+ >
198
+ {commits.map((item, itemIndex) => (
199
+ <MenuItem
200
+ key={item.oid}
201
+ label={item.headline}
202
+ description={describeCommit(item, summaries[itemIndex])}
203
+ checked={itemIndex === index}
204
+ onPress={() => {
205
+ onIndexChange(itemIndex);
206
+ }}
207
+ />
208
+ ))}
209
+ </Menu>
210
+ <IconButton
211
+ icon={<CaretRightRegularIcon />}
212
+ size="sm"
213
+ accent="neutral"
214
+ aria-label="Next commit"
215
+ disabledReason={
216
+ index === commits.length - 1 ? "This is the last commit" : null
217
+ }
218
+ onPress={() => {
219
+ onIndexChange(index + 1);
220
+ }}
221
+ />
222
+ <Text className="min-w-0 shrink text-sm font-body-bold" numberOfLines={1}>
223
+ {commit.headline}
224
+ </Text>
225
+ <Code className="text-xs">{commit.oid.slice(0, 7)}</Code>
226
+ </View>
227
+ );
228
+ }
229
+
230
+ interface CommitByCommitParams {
231
+ pullRequest: PullRequestDetail;
232
+ /** the head the diff shows, null until known */
233
+ head: string | null;
234
+ /** step through the commits after this one only */
235
+ since: string | null;
236
+ }
237
+
238
+ interface CommitByCommit {
239
+ /** null while the commits are read */
240
+ toRead: CommitsToRead | null;
241
+ /** why the reader cannot turn it on */
242
+ disabledReason: string | null;
243
+ on: boolean;
244
+ /** the default is not known yet: decided before the diff shows, so it does not swap under the reader */
245
+ waiting: boolean;
246
+ /** why the commits' files could not be read, once the reader asked for them */
247
+ error: string | null;
248
+ stepped: PullRequestCommit[];
249
+ /** aligned with `stepped` */
250
+ summaries: CommitSummary[];
251
+ index: number;
252
+ setIndex: (index: number) => void;
253
+ shown: {
254
+ commit: PullRequestCommit;
255
+ files: NonNullable<PullRequestDiffReview["commitFiles"]>;
256
+ } | null;
257
+ toggle: () => void;
258
+ }
259
+
260
+ const useCommitByCommit = ({
261
+ pullRequest,
262
+ head,
263
+ since,
264
+ }: CommitByCommitParams): CommitByCommit => {
265
+ const dataSource = usePullRequestDataSource();
266
+ const commits = dataSource.usePart(pullRequest, "commits");
267
+ const missingCheckout = useMissingCheckoutReason(pullRequest);
268
+ const [requested, setRequested] = useState<boolean | null>(null);
269
+ const [requestedIndex, setRequestedIndex] = useState(0);
270
+ const toRead =
271
+ head === null || commits.value === null
272
+ ? null
273
+ : commitsToRead({
274
+ listed: commits.value.commits,
275
+ total: pullRequest.commitsTotal,
276
+ head,
277
+ });
278
+ const readable =
279
+ toRead?.kind === "available" && missingCheckout === null
280
+ ? toRead.commits
281
+ : null;
282
+ const diffs = dataSource.useDiffs(
283
+ pullRequest,
284
+ readable === null || requested === false
285
+ ? null
286
+ : readable.map(({ oid, parentOid }) => ({
287
+ head: oid,
288
+ base: parentOid ?? oid,
289
+ // a file changed by whitespace alone is changed all the same
290
+ whitespace: "show",
291
+ })),
292
+ );
293
+ // a cached value is the previous ranges', kept while these load
294
+ const pathsByCommit = useMemo(
295
+ () => (diffs.cached ? null : (diffs.value?.map(changedPathsOf) ?? null)),
296
+ [diffs.cached, diffs.value],
297
+ );
298
+ const wanted =
299
+ requested ??
300
+ (pathsByCommit === null ? null : touchDifferentFiles(pathsByCommit));
301
+ const failed = diffs.error !== null || diffs.refused !== null;
302
+ const on = readable !== null && pathsByCommit !== null && wanted === true;
303
+ const sinceIndex = readable === null ? 0 : firstCommitAfter(readable, since);
304
+ const stepped = readable?.slice(sinceIndex) ?? [];
305
+ const index = Math.min(requestedIndex, Math.max(stepped.length - 1, 0));
306
+ const commit = on ? stepped[index] : undefined;
307
+ const summaries =
308
+ on && diffs.value !== null && pathsByCommit !== null
309
+ ? summarizeCommits({ diffs: diffs.value, pathsByCommit, sinceIndex })
310
+ : [];
311
+
312
+ return {
313
+ toRead,
314
+ disabledReason:
315
+ toRead?.kind === "unavailable" ? toRead.reason : missingCheckout,
316
+ on,
317
+ waiting:
318
+ readable !== null &&
319
+ wanted !== false &&
320
+ pathsByCommit === null &&
321
+ !failed,
322
+ error: requested === true ? diffs.error : null,
323
+ stepped,
324
+ summaries,
325
+ index,
326
+ setIndex: setRequestedIndex,
327
+ shown:
328
+ commit === undefined || pathsByCommit === null
329
+ ? null
330
+ : {
331
+ commit,
332
+ files: {
333
+ oid: commit.oid,
334
+ anchorings: commitAnchorings(pathsByCommit, sinceIndex + index),
335
+ },
336
+ },
337
+ toggle: () => {
338
+ setRequested(!on);
339
+ },
340
+ };
341
+ };
342
+
343
+ /**
344
+ * The pull request's diff with its review state: viewed marks, threads on the
345
+ * lines and commenting. Scope "since your last review" diffs the head against
346
+ * the head the viewer last reviewed on; GitHub's line numbers for the old side
347
+ * then name the pull request's base, not that commit, so only the new side is
348
+ * annotated or commentable there.
349
+ *
350
+ * Review changes is the one place a review is sent from, so it ends the diff's
351
+ * toolbar, which sticks to the top of the scroll container. The author gets
352
+ * neither it nor Start a review — GitHub lets them comment, one comment at a
353
+ * time, not review.
354
+ *
355
+ * The range is the draft's: a push is announced, not shown, until the reader
356
+ * asks for it, which a review in progress withholds.
357
+ *
358
+ * The commits can be read one by one, which is the default when no two of
359
+ * them change the same file: each file of a commit is then the pull request's
360
+ * own, so it is reviewed as on the whole diff. A file changed by another
361
+ * commit too is reviewed on what it shares with the pull request's diff
362
+ * (`CommitFileAnchoring`). With "since your last review", only the commits
363
+ * pushed since are stepped through. Off without a local checkout: from
364
+ * GitHub, telling the default would cost a request a commit.
365
+ */
366
+ export interface PullRequestFilesTabProps {
367
+ detail: PullRequestDetailResponse;
368
+ /** reads the pull request again once GitHub took a change */
369
+ onEdited: (edit: PullRequestEdit) => Promise<void>;
370
+ }
371
+
372
+ export function PullRequestFiles({
373
+ detail,
374
+ onEdited,
375
+ }: PullRequestFilesTabProps): ReactNode {
376
+ const { pullRequest } = detail;
377
+ const isAuthor = isViewerAuthor(detail);
378
+ const [requested, setRequested] = useState<Scope>("all");
379
+ const [viewedError, setViewedError] = useState<string | null>(null);
380
+ const dataSource = usePullRequestDataSource();
381
+ const files = dataSource.usePart(pullRequest, "files");
382
+ const timeline = dataSource.usePart(pullRequest, "timeline");
383
+ const { shas, latest, follow } = usePullRequestReviewDraft();
384
+ const movedOn = usePullRequestMovedOn(pullRequest, shas, latest);
385
+ const lastReview = files.value?.viewerLastReview ?? null;
386
+ const sinceAvailable = lastReview !== null && lastReview.oid !== shas?.head;
387
+ const scope: Scope = sinceAvailable ? requested : "all";
388
+ const byCommit = useCommitByCommit({
389
+ pullRequest,
390
+ head: shas?.head ?? null,
391
+ since: scope === "since" ? (lastReview?.oid ?? null) : null,
392
+ });
393
+ const shownCommit = byCommit.shown?.commit;
394
+ const commitFiles = byCommit.shown?.files;
395
+ const viewedCount = (files.value?.files ?? []).filter(
396
+ ({ viewed }) => viewed === "viewed",
397
+ ).length;
398
+
399
+ const onEdit = async (edit: PullRequestEdit): Promise<void> => {
400
+ await dataSource.edit(pullRequest, edit);
401
+ await onEdited(edit);
402
+ };
403
+
404
+ return (
405
+ <View className="gap-m">
406
+ {viewedError === null ? null : (
407
+ <WarningMessage
408
+ dismissIconAriaLabel="Dismiss"
409
+ onDismiss={() => {
410
+ setViewedError(null);
411
+ }}
412
+ >
413
+ {viewedError}
414
+ </WarningMessage>
415
+ )}
416
+ <PullRequestPartError error={files.error ?? timeline.error} />
417
+ {files.value === null || timeline.value === null ? (
418
+ <PullRequestPartPending
419
+ error={null}
420
+ what="the viewed marks and threads"
421
+ />
422
+ ) : (
423
+ <PullRequestPartReading reading={files.reading || timeline.reading} />
424
+ )}
425
+ <MovedOnNotice movedOn={movedOn} follow={follow} />
426
+ {byCommit.error === null ? null : (
427
+ <WarningMessage>
428
+ {`The commits' files could not be read: ${byCommit.error}`}
429
+ </WarningMessage>
430
+ )}
431
+ {(() => {
432
+ if (shas === null) {
433
+ return (
434
+ <PullRequestPartPending
435
+ error={null}
436
+ what="the pull request's head"
437
+ />
438
+ );
439
+ }
440
+ if (byCommit.waiting) {
441
+ return (
442
+ <PullRequestPartPending
443
+ error={null}
444
+ what="the files each commit changes"
445
+ />
446
+ );
447
+ }
448
+ return (
449
+ <PullRequestDiffView
450
+ range={{
451
+ ownerRepo: pullRequest.ownerRepo,
452
+ number: pullRequest.number,
453
+ head: shownCommit?.oid ?? shas.head,
454
+ base: (() => {
455
+ if (shownCommit !== undefined) {
456
+ return shownCommit.parentOid ?? shas.base;
457
+ }
458
+ if (scope === "since" && lastReview !== null) {
459
+ return lastReview.oid;
460
+ }
461
+ return shas.base;
462
+ })(),
463
+ }}
464
+ toolbarStart={
465
+ <>
466
+ {byCommit.toRead === null ||
467
+ byCommit.toRead.kind === "none" ? null : (
468
+ <ByCommitButton
469
+ byCommit={byCommit.on}
470
+ disabledReason={byCommit.disabledReason}
471
+ onToggle={byCommit.toggle}
472
+ />
473
+ )}
474
+ {sinceAvailable ? (
475
+ <Tabs
476
+ aria-label="Changes shown"
477
+ value={scope}
478
+ onValueChange={(value) => {
479
+ setRequested(value as Scope);
480
+ byCommit.setIndex(0);
481
+ }}
482
+ >
483
+ <Tab value="all" label="All changes" />
484
+ <Tab value="since" label="Since your last review" />
485
+ </Tabs>
486
+ ) : null}
487
+ <LastReviewRange scope={scope} lastReview={lastReview} />
488
+ <Text className="text-sm text-muted">
489
+ {`${String(viewedCount)} / ${String(pullRequest.changedFiles)} viewed`}
490
+ </Text>
491
+ {byCommit.shown === null ? null : (
492
+ <CommitStepper
493
+ commits={byCommit.stepped}
494
+ summaries={byCommit.summaries}
495
+ index={byCommit.index}
496
+ onIndexChange={byCommit.setIndex}
497
+ />
498
+ )}
499
+ </>
500
+ }
501
+ toolbarEnd={
502
+ isAuthor ? null : (
503
+ <PullRequestReviewButton detail={detail} onEdit={onEdit} />
504
+ )
505
+ }
506
+ review={{
507
+ headSha: shas.head,
508
+ threads:
509
+ timeline.value?.timeline.filter(
510
+ (item): item is PullRequestThread => item.kind === "thread",
511
+ ) ?? null,
512
+ files: files.value?.files ?? null,
513
+ commentable: true,
514
+ rightOnly: scope === "since" && commitFiles === undefined,
515
+ commitFiles,
516
+ reviewable: !isAuthor,
517
+ movedOn,
518
+ onEdit,
519
+ onViewed: async (path, viewed) => {
520
+ try {
521
+ await onEdit({ kind: "viewed", path, viewed });
522
+ setViewedError(null);
523
+ } catch (error) {
524
+ setViewedError(errorToMessage(error));
525
+ }
526
+ },
527
+ }}
528
+ />
529
+ );
530
+ })()}
531
+ </View>
532
+ );
533
+ }
@@ -0,0 +1,91 @@
1
+ import { Box, Tab, Tabs, Text, View } from "alouette";
2
+ import type { ReactNode } from "react";
3
+ import { useState } from "react";
4
+ import { MarkdownEditor } from "../editor/markdown-editor.tsx";
5
+ import type { MarkdownEditorProps } from "../slots.tsx";
6
+ import { usePullRequestSlots } from "../slots.tsx";
7
+ import { MarkdownText } from "./markdown-text.tsx";
8
+
9
+ type Mode = "preview" | "write";
10
+
11
+ interface PullRequestMarkdownInputProps {
12
+ value: string;
13
+ onChange: (value: string) => void;
14
+ "aria-label": string;
15
+ autoFocus?: boolean;
16
+ }
17
+
18
+ function DefaultEditor({
19
+ value,
20
+ onChange,
21
+ "aria-label": ariaLabel,
22
+ autoFocus,
23
+ }: MarkdownEditorProps): ReactNode {
24
+ return (
25
+ <MarkdownEditor
26
+ compact
27
+ emoji
28
+ aria-label={ariaLabel}
29
+ // oxlint-disable-next-line jsx-a11y/no-autofocus -- the caller opens the editor from a press and asks for the focus
30
+ autoFocus={autoFocus}
31
+ value={value}
32
+ onChange={onChange}
33
+ />
34
+ );
35
+ }
36
+
37
+ /** A component of its own, so a slot that uses hooks keeps them across tab switches. */
38
+ function EditorSlot(props: MarkdownEditorProps): ReactNode {
39
+ const { renderMarkdownEditor } = usePullRequestSlots();
40
+ return renderMarkdownEditor === undefined ? (
41
+ <DefaultEditor {...props} />
42
+ ) : (
43
+ renderMarkdownEditor(props)
44
+ );
45
+ }
46
+
47
+ /**
48
+ * A body GitHub renders as markdown, written as GitHub's own box is: the
49
+ * source in the editor slot, and a preview rendered the way the timeline
50
+ * shows it once sent.
51
+ */
52
+ export function PullRequestMarkdownInput({
53
+ value,
54
+ onChange,
55
+ "aria-label": ariaLabel,
56
+ autoFocus,
57
+ }: PullRequestMarkdownInputProps): ReactNode {
58
+ const [mode, setMode] = useState<Mode>("write");
59
+
60
+ return (
61
+ <View className="gap-xs">
62
+ <Tabs
63
+ aria-label={`${ariaLabel}, write or preview`}
64
+ value={mode}
65
+ onValueChange={(next) => {
66
+ setMode(next === "preview" ? "preview" : "write");
67
+ }}
68
+ >
69
+ <Tab value="write" label="Write" />
70
+ <Tab value="preview" label="Preview" />
71
+ </Tabs>
72
+ {mode === "write" ? (
73
+ <EditorSlot
74
+ // oxlint-disable-next-line jsx-a11y/no-autofocus -- forwarded to the editor, see above
75
+ autoFocus={autoFocus}
76
+ aria-label={ariaLabel}
77
+ value={value}
78
+ onChange={onChange}
79
+ />
80
+ ) : (
81
+ <Box className="surface lowered min-h-[96px] p-m">
82
+ {value.trim() === "" ? (
83
+ <Text className="text-sm text-muted">Nothing to preview.</Text>
84
+ ) : (
85
+ <MarkdownText github text={value} />
86
+ )}
87
+ </Box>
88
+ )}
89
+ </View>
90
+ );
91
+ }