@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.
- package/LICENSE +15 -0
- package/README.md +95 -2
- package/package.json +102 -4
- package/src/board/bucket-section.tsx +94 -0
- package/src/board/dataSource.ts +44 -0
- package/src/board/facts.ts +183 -0
- package/src/board/index.ts +12 -0
- package/src/board/pull-request-board.tsx +239 -0
- package/src/board/pull-request-row.tsx +181 -0
- package/src/components/branch-diff/diff-themes.ts +6 -0
- package/src/components/branch-diff/diff-worker-pool.tsx +12 -0
- package/src/components/branch-diff/diff-worker-pool.web.tsx +56 -0
- package/src/components/branch-diff/diff-worker.ts +7 -0
- package/src/components/branch-diff/use-collapsed-files.ts +49 -0
- package/src/components/branch-diff/use-diff-settings.tsx +97 -0
- package/src/components/branch-diff/whitespace-button.tsx +32 -0
- package/src/components/copyable-code-block.tsx +101 -0
- package/src/components/label-pill.tsx +84 -0
- package/src/components/link-in-row.ts +6 -0
- package/src/components/markdown-suggestion.tsx +60 -0
- package/src/components/markdown-text.tsx +1067 -0
- package/src/components/pull-request-actions-merge.tsx +341 -0
- package/src/components/pull-request-actions-review.tsx +218 -0
- package/src/components/pull-request-checks.tsx +86 -0
- package/src/components/pull-request-commit-files.ts +143 -0
- package/src/components/pull-request-commits.tsx +221 -0
- package/src/components/pull-request-conversation-card.tsx +220 -0
- package/src/components/pull-request-conversation-edit.tsx +147 -0
- package/src/components/pull-request-conversation-event.tsx +267 -0
- package/src/components/pull-request-conversation-person.tsx +28 -0
- package/src/components/pull-request-conversation-thread.tsx +158 -0
- package/src/components/pull-request-diff-annotations.tsx +443 -0
- package/src/components/pull-request-diff-collapse.ts +43 -0
- package/src/components/pull-request-diff-data.ts +174 -0
- package/src/components/pull-request-diff-impacts-summary.tsx +97 -0
- package/src/components/pull-request-diff-impacts.ts +45 -0
- package/src/components/pull-request-diff-jump.ts +87 -0
- package/src/components/pull-request-diff-lines.ts +103 -0
- package/src/components/pull-request-diff-overview.tsx +416 -0
- package/src/components/pull-request-diff-view.tsx +438 -0
- package/src/components/pull-request-diff-viewer.tsx +1374 -0
- package/src/components/pull-request-files.tsx +533 -0
- package/src/components/pull-request-markdown-input.tsx +91 -0
- package/src/components/pull-request-panel.tsx +1270 -0
- package/src/components/pull-request-part-notice.tsx +82 -0
- package/src/components/pull-request-reactions.tsx +178 -0
- package/src/components/pull-request-review.tsx +164 -0
- package/src/components/pull-request-revisions.tsx +273 -0
- package/src/components/pull-request-screen.tsx +299 -0
- package/src/components/pull-request-suggestion-actions.tsx +201 -0
- package/src/components/pull-request-timeline.tsx +270 -0
- package/src/components/repository-attachment-banner.tsx +42 -0
- package/src/components/scroll-parent.ts +50 -0
- package/src/css.d.ts +1 -0
- package/src/dataSource.ts +242 -0
- package/src/editor/alerts.ts +105 -0
- package/src/editor/code-editor.native.tsx +25 -0
- package/src/editor/code-editor.tsx +112 -0
- package/src/editor/codeEditorExtensions.ts +65 -0
- package/src/editor/codeHighlight.ts +71 -0
- package/src/editor/commands.ts +175 -0
- package/src/editor/decorations.ts +425 -0
- package/src/editor/editable-file.tsx +224 -0
- package/src/editor/emojiCompletion.ts +110 -0
- package/src/editor/extensions.ts +149 -0
- package/src/editor/frontmatter.ts +76 -0
- package/src/editor/inlineFormat.ts +114 -0
- package/src/editor/lineTypes.ts +55 -0
- package/src/editor/markdown-editor.css +511 -0
- package/src/editor/markdown-editor.native.tsx +29 -0
- package/src/editor/markdown-editor.tsx +295 -0
- package/src/editor/nodeClasses.ts +69 -0
- package/src/index.ts +75 -0
- package/src/markdown-github/flatten.ts +49 -0
- package/src/markdown-github/html-tokens.ts +93 -0
- package/src/markdown-github/pair-html.ts +354 -0
- package/src/markdown-github/safe-url.ts +15 -0
- package/src/markdown-github/table-columns.ts +23 -0
- package/src/markdown-github/toggle-task.ts +164 -0
- package/src/nativewind-env.d.ts +1 -0
- package/src/prStatus.ts +90 -0
- package/src/services/PullRequestDraftsProvider.tsx +179 -0
- package/src/services/PullRequestReviewDraftProvider.tsx +178 -0
- package/src/services/PullRequestSuggestionBatchProvider.tsx +112 -0
- package/src/services/emojify.ts +13 -0
- package/src/services/errorToMessage.ts +6 -0
- package/src/services/expiringDraftStore.ts +102 -0
- package/src/services/formatDataAge.ts +12 -0
- package/src/services/formatRelativeDate.ts +56 -0
- package/src/services/githubImages.tsx +33 -0
- package/src/services/isViewerAuthor.ts +7 -0
- 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
|
+
};
|