@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,443 @@
1
+ import type {
2
+ PullRequestComment,
3
+ PullRequestDraftComment,
4
+ PullRequestEdit,
5
+ PullRequestThread,
6
+ } from "@reviewflow/sdk";
7
+ import { suggestionOriginalLines } from "@reviewflow/sdk";
8
+ import {
9
+ ActionButton,
10
+ Badge,
11
+ Button,
12
+ ExternalLinkText,
13
+ Text,
14
+ Tooltip,
15
+ View,
16
+ } from "alouette";
17
+ import type { ReactNode } from "react";
18
+ import { useMemo, useState } from "react";
19
+ import {
20
+ useDraftText,
21
+ useHasDraftText,
22
+ } from "../services/PullRequestDraftsProvider.tsx";
23
+ import type { PendingComment } from "../services/PullRequestReviewDraftProvider.tsx";
24
+ import { errorToMessage } from "../services/errorToMessage.ts";
25
+ import { formatDataAge } from "../services/formatDataAge.ts";
26
+ import { SuggestionOriginalContext } from "./markdown-suggestion.tsx";
27
+ import { MarkdownText } from "./markdown-text.tsx";
28
+ import { PersonAvatar } from "./pull-request-conversation-person.tsx";
29
+ import type { LineAnchor } from "./pull-request-diff-lines.ts";
30
+ import { suggestionBlock } from "./pull-request-diff-lines.ts";
31
+ import { PullRequestMarkdownInput } from "./pull-request-markdown-input.tsx";
32
+ import { PullRequestReactions } from "./pull-request-reactions.tsx";
33
+ import { SuggestionActions } from "./pull-request-suggestion-actions.tsx";
34
+
35
+ export type AnnotationItem =
36
+ | { kind: "composer"; anchor: LineAnchor; suggestionLines: string[] | null }
37
+ | { kind: "pending"; comment: PendingComment }
38
+ | { kind: "thread"; thread: PullRequestThread };
39
+
40
+ /** One diff line holds one slot, so everything anchored there shares an annotation. */
41
+ export interface AnnotationGroup {
42
+ items: AnnotationItem[];
43
+ }
44
+
45
+ export type EditPullRequest = (edit: PullRequestEdit) => Promise<void>;
46
+
47
+ export interface ReviewDraftActions {
48
+ hasPending: boolean;
49
+ add: (comment: PullRequestDraftComment) => void;
50
+ update: (key: string, body: string) => void;
51
+ remove: (key: string) => void;
52
+ }
53
+
54
+ /** Flat: it sits inside the file's card, which is already raised. */
55
+ function Frame({ children }: { children: ReactNode }): ReactNode {
56
+ return (
57
+ <View className="p-xs">
58
+ <View className="min-w-0 gap-sm rounded-sm border border-border-muted p-m">
59
+ {children}
60
+ </View>
61
+ </View>
62
+ );
63
+ }
64
+
65
+ interface CommentViewProps {
66
+ thread: PullRequestThread;
67
+ comment: PullRequestComment;
68
+ onEdit: EditPullRequest;
69
+ }
70
+
71
+ function CommentView({ thread, comment, onEdit }: CommentViewProps): ReactNode {
72
+ return (
73
+ <View className="gap-xs">
74
+ <View className="flex-row flex-wrap items-center gap-xs">
75
+ <PersonAvatar person={comment.author} />
76
+ <Text className="text-sm font-body-bold">{comment.author.login}</Text>
77
+ <ExternalLinkText
78
+ size="sm"
79
+ href={comment.url}
80
+ text={formatDataAge(comment.createdAt)}
81
+ />
82
+ </View>
83
+ {comment.body.trim() === "" ? null : (
84
+ <MarkdownText github text={comment.body} />
85
+ )}
86
+ <SuggestionActions thread={thread} comment={comment} />
87
+ <PullRequestReactions subject={comment} onEdit={onEdit} />
88
+ </View>
89
+ );
90
+ }
91
+
92
+ interface ThreadAnnotationProps {
93
+ thread: PullRequestThread;
94
+ onEdit: EditPullRequest;
95
+ }
96
+
97
+ /** Resolved threads start folded, as on GitHub. */
98
+ export function ThreadAnnotation({
99
+ thread,
100
+ onEdit,
101
+ }: ThreadAnnotationProps): ReactNode {
102
+ const [open, setOpen] = useState(!thread.resolved);
103
+ // shared with the conversation tab's reply box of the same thread
104
+ const reply = useDraftText(`reply:${thread.id}`);
105
+ const first = thread.comments[0];
106
+ const unlisted = thread.commentsTotal - thread.comments.length;
107
+ const original = useMemo(() => suggestionOriginalLines(thread), [thread]);
108
+
109
+ if (!open) {
110
+ return (
111
+ <Frame>
112
+ <View className="flex-row flex-wrap items-center gap-xs">
113
+ <Badge accent="success" size="sm">
114
+ Resolved
115
+ </Badge>
116
+ <Text className="text-sm text-muted">
117
+ {`${first?.author.login ?? "thread"}, ${String(thread.commentsTotal)} comment${thread.commentsTotal === 1 ? "" : "s"}`}
118
+ </Text>
119
+ <Button
120
+ size="sm"
121
+ accent="neutral"
122
+ variant="soft"
123
+ text="Show"
124
+ onPress={() => {
125
+ setOpen(true);
126
+ }}
127
+ />
128
+ </View>
129
+ </Frame>
130
+ );
131
+ }
132
+
133
+ return (
134
+ <Frame>
135
+ {thread.resolved ? (
136
+ <View className="flex-row items-center gap-xs">
137
+ <Badge accent="success" size="sm">
138
+ Resolved
139
+ </Badge>
140
+ <Button
141
+ size="sm"
142
+ accent="neutral"
143
+ variant="soft"
144
+ text="Hide"
145
+ onPress={() => {
146
+ setOpen(false);
147
+ }}
148
+ />
149
+ </View>
150
+ ) : null}
151
+ <SuggestionOriginalContext value={original}>
152
+ {thread.comments.map((comment) => (
153
+ <CommentView
154
+ key={comment.id}
155
+ thread={thread}
156
+ comment={comment}
157
+ onEdit={onEdit}
158
+ />
159
+ ))}
160
+ </SuggestionOriginalContext>
161
+ {unlisted > 0 ? (
162
+ <Text className="text-xs text-muted">
163
+ {`${String(unlisted)} more comment${unlisted === 1 ? "" : "s"} on GitHub`}
164
+ </Text>
165
+ ) : null}
166
+ {thread.viewerCanReply && first !== undefined ? (
167
+ <View className="gap-xs">
168
+ <PullRequestMarkdownInput
169
+ aria-label="Reply"
170
+ value={reply.text}
171
+ onChange={reply.setText}
172
+ />
173
+ <View className="flex-row flex-wrap items-center justify-end gap-xs">
174
+ {thread.resolved && thread.viewerCanUnresolve ? (
175
+ <Tooltip
176
+ content="Open the thread again, here and on GitHub"
177
+ render={(triggerProps) => (
178
+ <ActionButton
179
+ {...triggerProps}
180
+ size="sm"
181
+ accent="neutral"
182
+ variant="soft"
183
+ text="Unresolve"
184
+ errorMessageVariant="flat"
185
+ errorToMessage={errorToMessage}
186
+ onPress={async () => {
187
+ await onEdit({
188
+ kind: "resolveThread",
189
+ threadId: thread.id,
190
+ resolved: false,
191
+ });
192
+ }}
193
+ />
194
+ )}
195
+ />
196
+ ) : null}
197
+ {!thread.resolved && thread.viewerCanResolve ? (
198
+ <Tooltip
199
+ content="Mark the thread done: it folds, here and on GitHub"
200
+ render={(triggerProps) => (
201
+ <ActionButton
202
+ {...triggerProps}
203
+ size="sm"
204
+ accent="neutral"
205
+ variant="soft"
206
+ text="Resolve"
207
+ errorMessageVariant="flat"
208
+ errorToMessage={errorToMessage}
209
+ onPress={async () => {
210
+ await onEdit({
211
+ kind: "resolveThread",
212
+ threadId: thread.id,
213
+ resolved: true,
214
+ });
215
+ }}
216
+ />
217
+ )}
218
+ />
219
+ ) : null}
220
+ <ActionButton
221
+ size="sm"
222
+ text="Reply"
223
+ errorMessageVariant="flat"
224
+ errorToMessage={errorToMessage}
225
+ onPress={async () => {
226
+ const sent = reply.text;
227
+ if (sent.trim() === "") return;
228
+ await onEdit({
229
+ kind: "reply",
230
+ inReplyTo: first.databaseId,
231
+ body: sent,
232
+ });
233
+ reply.discard(sent);
234
+ }}
235
+ />
236
+ </View>
237
+ </View>
238
+ ) : null}
239
+ </Frame>
240
+ );
241
+ }
242
+
243
+ interface PendingAnnotationProps {
244
+ comment: PendingComment;
245
+ draft: ReviewDraftActions;
246
+ }
247
+
248
+ export function PendingAnnotation({
249
+ comment,
250
+ draft,
251
+ }: PendingAnnotationProps): ReactNode {
252
+ const draftKey = `pending:${comment.key}`;
253
+ const hasDraft = useHasDraftText(draftKey);
254
+ const [editing, setEditing] = useState(hasDraft);
255
+ const body = useDraftText(draftKey, comment.body);
256
+
257
+ return (
258
+ <Frame>
259
+ <View className="flex-row flex-wrap items-center gap-xs">
260
+ <Badge accent="warning" size="sm">
261
+ Pending
262
+ </Badge>
263
+ <Text className="text-sm text-muted">sent with your review</Text>
264
+ </View>
265
+ {editing ? (
266
+ <View className="gap-xs">
267
+ <PullRequestMarkdownInput
268
+ aria-label="Pending comment"
269
+ value={body.text}
270
+ onChange={body.setText}
271
+ />
272
+ <View className="flex-row justify-end gap-xs">
273
+ <Button
274
+ size="sm"
275
+ accent="neutral"
276
+ variant="soft"
277
+ text="Cancel"
278
+ onPress={() => {
279
+ body.discard();
280
+ setEditing(false);
281
+ }}
282
+ />
283
+ <Button
284
+ size="sm"
285
+ text="Save"
286
+ onPress={() => {
287
+ if (body.text.trim() === "") return;
288
+ draft.update(comment.key, body.text);
289
+ body.discard();
290
+ setEditing(false);
291
+ }}
292
+ />
293
+ </View>
294
+ </View>
295
+ ) : (
296
+ <>
297
+ <MarkdownText github text={comment.body} />
298
+ <View className="flex-row gap-xs">
299
+ <Button
300
+ size="sm"
301
+ accent="neutral"
302
+ text="Edit"
303
+ onPress={() => {
304
+ setEditing(true);
305
+ }}
306
+ />
307
+ <Button
308
+ size="sm"
309
+ accent="danger"
310
+ variant="soft"
311
+ text="Delete"
312
+ onPress={() => {
313
+ draft.remove(comment.key);
314
+ }}
315
+ />
316
+ </View>
317
+ </>
318
+ )}
319
+ </Frame>
320
+ );
321
+ }
322
+
323
+ interface ComposerAnnotationProps {
324
+ anchor: LineAnchor;
325
+ suggestionLines: string[] | null;
326
+ commitId: string;
327
+ /**
328
+ * null on a single commit's diff: a review is sent on the pull request's
329
+ * head, so a comment anchored on another commit goes out alone.
330
+ */
331
+ draft: ReviewDraftActions | null;
332
+ onEdit: EditPullRequest;
333
+ onClose: () => void;
334
+ }
335
+
336
+ export function ComposerAnnotation({
337
+ anchor,
338
+ suggestionLines,
339
+ commitId,
340
+ draft,
341
+ onEdit,
342
+ onClose,
343
+ }: ComposerAnnotationProps): ReactNode {
344
+ const body = useDraftText(
345
+ `inline:${commitId}:${anchor.path}:${anchor.side}:${String(anchor.startLine ?? "")}-${String(anchor.line)}`,
346
+ );
347
+ const close = (): void => {
348
+ body.discard();
349
+ onClose();
350
+ };
351
+ const range =
352
+ anchor.startLine === null
353
+ ? `line ${String(anchor.line)}`
354
+ : `lines ${String(anchor.startLine)} to ${String(anchor.line)}`;
355
+ const comment = {
356
+ path: anchor.path,
357
+ side: anchor.side,
358
+ line: anchor.line,
359
+ startLine: anchor.startLine,
360
+ startSide: anchor.startSide,
361
+ body: body.text,
362
+ };
363
+
364
+ return (
365
+ <Frame>
366
+ <Text className="text-sm text-muted">{`Comment on ${range}`}</Text>
367
+ <PullRequestMarkdownInput
368
+ // oxlint-disable-next-line jsx-a11y/no-autofocus -- the composer is opened by pressing a line, the reader expects to type
369
+ autoFocus
370
+ aria-label="Comment"
371
+ value={body.text}
372
+ onChange={body.setText}
373
+ />
374
+ <View className="flex-row flex-wrap items-center gap-xs">
375
+ {suggestionLines === null ? null : (
376
+ <Tooltip
377
+ content="Insert the selected lines as a suggested change, which can then be committed to the branch, from here or GitHub"
378
+ render={(triggerProps) => (
379
+ <Button
380
+ {...triggerProps}
381
+ size="sm"
382
+ accent="neutral"
383
+ text="Suggestion"
384
+ onPress={() => {
385
+ body.setText(
386
+ `${body.text}${body.text === "" ? "" : "\n"}${suggestionBlock(suggestionLines)}`,
387
+ );
388
+ }}
389
+ />
390
+ )}
391
+ />
392
+ )}
393
+ <View className="ml-auto flex-row flex-wrap items-center justify-end gap-xs">
394
+ <Button
395
+ size="sm"
396
+ accent="neutral"
397
+ variant="soft"
398
+ text="Cancel"
399
+ onPress={close}
400
+ />
401
+ {draft === null ? null : (
402
+ <Tooltip
403
+ content="Keep the comment pending: it is sent with your review, from Review changes"
404
+ render={(triggerProps) => (
405
+ <Button
406
+ {...triggerProps}
407
+ size="sm"
408
+ accent="neutral"
409
+ text={
410
+ draft.hasPending ? "Add review comment" : "Start a review"
411
+ }
412
+ onPress={() => {
413
+ if (body.text.trim() === "") return;
414
+ draft.add(comment);
415
+ close();
416
+ }}
417
+ />
418
+ )}
419
+ />
420
+ )}
421
+ <Tooltip
422
+ content="Post this comment now, on its own, outside any review"
423
+ render={(triggerProps) => (
424
+ <ActionButton
425
+ {...triggerProps}
426
+ size="sm"
427
+ text="Add single comment"
428
+ errorMessageVariant="flat"
429
+ errorToMessage={errorToMessage}
430
+ onPress={async () => {
431
+ if (comment.body.trim() === "") return;
432
+ await onEdit({ kind: "inlineComment", commitId, comment });
433
+ body.discard(comment.body);
434
+ onClose();
435
+ }}
436
+ />
437
+ )}
438
+ />
439
+ </View>
440
+ </View>
441
+ </Frame>
442
+ );
443
+ }
@@ -0,0 +1,43 @@
1
+ import { useMemo, useState } from "react";
2
+
3
+ export interface PullRequestCollapse {
4
+ /** `byDefault`: what the file does until the reader chose, e.g. viewed files start closed */
5
+ isCollapsed: (fileKey: string, byDefault: boolean) => boolean;
6
+ set: (fileKey: string, collapsed: boolean) => void;
7
+ expandAll: () => void;
8
+ collapseAll: () => void;
9
+ }
10
+
11
+ interface CollapseState {
12
+ /** null: no "all" pressed, every file follows its own default */
13
+ all: boolean | null;
14
+ overrides: Readonly<Record<string, boolean>>;
15
+ }
16
+
17
+ export const usePullRequestCollapse = (): PullRequestCollapse => {
18
+ const [state, setState] = useState<CollapseState>({
19
+ all: null,
20
+ overrides: {},
21
+ });
22
+
23
+ // stable until a press, so the memoized viewer is not rendered again for nothing
24
+ return useMemo(
25
+ () => ({
26
+ isCollapsed: (fileKey, byDefault) =>
27
+ state.overrides[fileKey] ?? state.all ?? byDefault,
28
+ set: (fileKey, collapsed) => {
29
+ setState((previous) => ({
30
+ all: previous.all,
31
+ overrides: { ...previous.overrides, [fileKey]: collapsed },
32
+ }));
33
+ },
34
+ expandAll: () => {
35
+ setState({ all: false, overrides: {} });
36
+ },
37
+ collapseAll: () => {
38
+ setState({ all: true, overrides: {} });
39
+ },
40
+ }),
41
+ [state],
42
+ );
43
+ };
@@ -0,0 +1,174 @@
1
+ import type {
2
+ DiffStep,
3
+ DiffWhitespace,
4
+ PullRequestDiffResponse,
5
+ PullRequestRef,
6
+ } from "@reviewflow/sdk";
7
+ import type {
8
+ PullRequestDiffRefusal,
9
+ PullRequestImpactsState,
10
+ PullRequestReadingOrderState,
11
+ PullRequestShas,
12
+ RepositoryAttachment,
13
+ } from "../dataSource.ts";
14
+ import { usePullRequestDataSource } from "../dataSource.ts";
15
+
16
+ /** A pull request and the two shas its diff is read between. */
17
+ export interface PullRequestDiffTarget extends PullRequestRef {
18
+ head: string;
19
+ base: string;
20
+ }
21
+
22
+ interface DiffState {
23
+ diff: PullRequestDiffResponse | null;
24
+ error: string | null;
25
+ loading: boolean;
26
+ /** what a read in progress is waiting on, when the source says */
27
+ step: DiffStep | null;
28
+ /** the source would not read: no checkout it may use */
29
+ refused: PullRequestDiffRefusal | null;
30
+ }
31
+
32
+ /**
33
+ * Keyed by the source on the range, which two shas name for good: a diff once
34
+ * read is not read again, and the previous diff stays while the next loads.
35
+ */
36
+ export const usePullRequestDiff = (
37
+ { ownerRepo, number, head, base }: PullRequestDiffTarget,
38
+ whitespace: DiffWhitespace,
39
+ ): DiffState => {
40
+ const { useDiff } = usePullRequestDataSource();
41
+ const { value, error, reading, step, refused } = useDiff(
42
+ { ownerRepo, number },
43
+ { head, base, whitespace },
44
+ );
45
+ return { diff: value, error, loading: reading, step, refused };
46
+ };
47
+
48
+ const useAlwaysAttached = (): RepositoryAttachment => ({
49
+ status: "attached",
50
+ detail: "",
51
+ });
52
+
53
+ /**
54
+ * Why the diff's options that read every file or every commit are off, or
55
+ * null when the repository has a local checkout: from GitHub they would cost
56
+ * a request a commit, and the reading order is read from git only. A source
57
+ * without `useRepositoryAttachment` always has its checkouts; an attachment
58
+ * not known yet, or not readable, counts as none.
59
+ */
60
+ export const useMissingCheckoutReason = (
61
+ ref: PullRequestRef,
62
+ ): string | null => {
63
+ const { useRepositoryAttachment } = usePullRequestDataSource();
64
+ const attachment = (useRepositoryAttachment ?? useAlwaysAttached)(ref);
65
+ if (attachment === null) {
66
+ return "Whether the repository has a local checkout is not known";
67
+ }
68
+ if (attachment.status === "attached") return null;
69
+ return `Needs a local checkout: ${attachment.detail}`;
70
+ };
71
+
72
+ const notOrdered: PullRequestReadingOrderState = {
73
+ value: null,
74
+ cached: false,
75
+ reading: false,
76
+ error: null,
77
+ refused: null,
78
+ };
79
+
80
+ const useNoReadingOrder = (): PullRequestReadingOrderState => notOrdered;
81
+
82
+ interface ReadingOrderState extends PullRequestReadingOrderState {
83
+ /** the source can order the files for reading */
84
+ available: boolean;
85
+ }
86
+
87
+ /** Read only while `wanted`; never read again once read for a range. */
88
+ export const usePullRequestReadingOrder = (
89
+ { ownerRepo, number, head, base }: PullRequestDiffTarget,
90
+ wanted: boolean,
91
+ ): ReadingOrderState => {
92
+ const { useReadingOrder } = usePullRequestDataSource();
93
+ const read = (useReadingOrder ?? useNoReadingOrder)(
94
+ { ownerRepo, number },
95
+ wanted ? { head, base } : null,
96
+ );
97
+ return { ...read, available: useReadingOrder !== undefined };
98
+ };
99
+
100
+ const notAnalyzed: PullRequestImpactsState = {
101
+ value: null,
102
+ cached: false,
103
+ reading: false,
104
+ error: null,
105
+ refused: null,
106
+ };
107
+
108
+ const useNoImpacts = (): PullRequestImpactsState => notAnalyzed;
109
+
110
+ /** Read only while `wanted`; never read again once read for a range. */
111
+ export const usePullRequestImpacts = (
112
+ { ownerRepo, number, head, base }: PullRequestDiffTarget,
113
+ wanted: boolean,
114
+ ): PullRequestImpactsState => {
115
+ const { useImpacts } = usePullRequestDataSource();
116
+ return (useImpacts ?? useNoImpacts)(
117
+ { ownerRepo, number },
118
+ wanted ? { head, base } : null,
119
+ );
120
+ };
121
+
122
+ /**
123
+ * The diff shows a range the pull request has left (a review in progress
124
+ * pinned it). Threads and viewed marks are read on the pull request's own
125
+ * range, so on a file changed since then a thread's line names other code and a
126
+ * viewed mark would be set on a version not shown.
127
+ */
128
+ export interface PullRequestMovedOn {
129
+ /** every path changed since the diff's head, renames by both names; null until known */
130
+ paths: ReadonlySet<string> | null;
131
+ /** the old side's lines name another base */
132
+ baseMoved: boolean;
133
+ }
134
+
135
+ const renameSourcePattern = /^rename from (.+)$/gmu;
136
+
137
+ export const changedPathsOf = (diff: PullRequestDiffResponse): Set<string> =>
138
+ new Set([
139
+ ...diff.groups.flatMap(({ paths }) => paths),
140
+ ...diff.omitted.map(({ path }) => path),
141
+ ...[...diff.patch.matchAll(renameSourcePattern)].flatMap(([, from]) =>
142
+ from === undefined ? [] : [from],
143
+ ),
144
+ ]);
145
+
146
+ /**
147
+ * Unknown counts as every file changed: a misplaced thread is worse than a
148
+ * listed one. Whitespace is shown whatever the diff hides: a line reindented
149
+ * since the review started still moved what a thread's number names.
150
+ */
151
+ export const usePullRequestMovedOn = (
152
+ ref: PullRequestRef,
153
+ shown: PullRequestShas | null,
154
+ latest: PullRequestShas | null,
155
+ ): PullRequestMovedOn | null => {
156
+ const headMoved =
157
+ shown !== null && latest !== null && shown.head !== latest.head;
158
+ const { useDiff } = usePullRequestDataSource();
159
+ const { value: data, cached } = useDiff(
160
+ { ownerRepo: ref.ownerRepo, number: ref.number },
161
+ headMoved
162
+ ? { base: shown.head, head: latest.head, whitespace: "show" }
163
+ : null,
164
+ );
165
+ if (shown === null || latest === null) return null;
166
+ const baseMoved = shown.base !== latest.base;
167
+ if (!headMoved && !baseMoved) return null;
168
+ if (!headMoved) return { paths: new Set(), baseMoved };
169
+ // a cached value is the previous range's, kept while this one loads
170
+ return {
171
+ paths: data === null || cached ? null : changedPathsOf(data),
172
+ baseMoved,
173
+ };
174
+ };