@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.
- package/LICENSE +15 -0
- package/README.md +95 -2
- package/package.json +101 -3
- 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,1374 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
DiffLineAnnotation,
|
|
3
|
+
FileDiffContentsLoader,
|
|
4
|
+
FileDiffMetadata,
|
|
5
|
+
SelectedLineRange,
|
|
6
|
+
} from "@pierre/diffs";
|
|
7
|
+
import { parsePatchFiles } from "@pierre/diffs";
|
|
8
|
+
import { FileDiff } from "@pierre/diffs/react";
|
|
9
|
+
import type {
|
|
10
|
+
DiffFileImpacts,
|
|
11
|
+
DiffGroup,
|
|
12
|
+
DiffImpactsResponse,
|
|
13
|
+
DiffReadingOrderResponse,
|
|
14
|
+
DiffWhitespace,
|
|
15
|
+
PullRequestDiffResponse,
|
|
16
|
+
PullRequestFile,
|
|
17
|
+
PullRequestFileViewed,
|
|
18
|
+
PullRequestRef,
|
|
19
|
+
PullRequestThread,
|
|
20
|
+
} from "@reviewflow/sdk";
|
|
21
|
+
import {
|
|
22
|
+
AccentScope,
|
|
23
|
+
Badge,
|
|
24
|
+
Box,
|
|
25
|
+
Button,
|
|
26
|
+
Checkbox,
|
|
27
|
+
Code,
|
|
28
|
+
Icon,
|
|
29
|
+
IconButton,
|
|
30
|
+
Popover,
|
|
31
|
+
PressableBox,
|
|
32
|
+
Text,
|
|
33
|
+
View,
|
|
34
|
+
} from "alouette";
|
|
35
|
+
import { CaretDownRegularIcon } from "alouette-icons/phosphor-icons/CaretDown";
|
|
36
|
+
import { CaretRightRegularIcon } from "alouette-icons/phosphor-icons/CaretRight";
|
|
37
|
+
import { ShareNetworkRegularIcon } from "alouette-icons/phosphor-icons/ShareNetwork";
|
|
38
|
+
import type { ComponentRef, ReactNode } from "react";
|
|
39
|
+
import {
|
|
40
|
+
memo,
|
|
41
|
+
useCallback,
|
|
42
|
+
useLayoutEffect,
|
|
43
|
+
useMemo,
|
|
44
|
+
useRef,
|
|
45
|
+
useState,
|
|
46
|
+
} from "react";
|
|
47
|
+
import type { PullRequestDataSource } from "../dataSource.ts";
|
|
48
|
+
import { usePullRequestDataSource } from "../dataSource.ts";
|
|
49
|
+
import type { PendingComment } from "../services/PullRequestReviewDraftProvider.tsx";
|
|
50
|
+
import { usePullRequestReviewDraft } from "../services/PullRequestReviewDraftProvider.tsx";
|
|
51
|
+
import type { DiffTheme } from "./branch-diff/diff-themes.ts";
|
|
52
|
+
import { diffThemeNames } from "./branch-diff/diff-themes.ts";
|
|
53
|
+
// metro picks the .web.tsx variant only for a specifier without extension
|
|
54
|
+
/* eslint-disable-next-line import-x/no-unresolved */ // oxlint-disable-next-line import/extensions
|
|
55
|
+
import { DiffWorkerPool } from "./branch-diff/diff-worker-pool";
|
|
56
|
+
import type { CommitFileAnchoring } from "./pull-request-commit-files.ts";
|
|
57
|
+
import type {
|
|
58
|
+
AnnotationGroup,
|
|
59
|
+
AnnotationItem,
|
|
60
|
+
EditPullRequest,
|
|
61
|
+
ReviewDraftActions,
|
|
62
|
+
} from "./pull-request-diff-annotations.tsx";
|
|
63
|
+
import {
|
|
64
|
+
ComposerAnnotation,
|
|
65
|
+
PendingAnnotation,
|
|
66
|
+
ThreadAnnotation,
|
|
67
|
+
} from "./pull-request-diff-annotations.tsx";
|
|
68
|
+
import type { PullRequestCollapse } from "./pull-request-diff-collapse.ts";
|
|
69
|
+
import type { PullRequestMovedOn } from "./pull-request-diff-data.ts";
|
|
70
|
+
import {
|
|
71
|
+
countImpacts,
|
|
72
|
+
maxImporterNames,
|
|
73
|
+
pluralOf,
|
|
74
|
+
usageLabel,
|
|
75
|
+
} from "./pull-request-diff-impacts.ts";
|
|
76
|
+
import type { DiffFileHighlight } from "./pull-request-diff-jump.ts";
|
|
77
|
+
import {
|
|
78
|
+
useDiffFileElements,
|
|
79
|
+
useDiffFileHighlight,
|
|
80
|
+
} from "./pull-request-diff-jump.ts";
|
|
81
|
+
import type { Composer } from "./pull-request-diff-lines.ts";
|
|
82
|
+
import {
|
|
83
|
+
anchorOfRange,
|
|
84
|
+
annotationSideOf,
|
|
85
|
+
isLineInPatch,
|
|
86
|
+
rightSideLines,
|
|
87
|
+
} from "./pull-request-diff-lines.ts";
|
|
88
|
+
import { revealTopOf } from "./scroll-parent.ts";
|
|
89
|
+
|
|
90
|
+
export type DiffStyle = "split" | "unified";
|
|
91
|
+
|
|
92
|
+
const fileKeyOf = (file: FileDiffMetadata): string => file.name;
|
|
93
|
+
|
|
94
|
+
interface FileReview {
|
|
95
|
+
viewed: PullRequestFileViewed;
|
|
96
|
+
onViewed: (viewed: boolean) => void;
|
|
97
|
+
threads: PullRequestThread[];
|
|
98
|
+
outdated: PullRequestThread[];
|
|
99
|
+
/** on lines changed since the diff's head: listed, not placed */
|
|
100
|
+
changedSince: PullRequestThread[];
|
|
101
|
+
/** on lines the patch does not carry once whitespace is hidden: listed, not placed */
|
|
102
|
+
hiddenThreads: PullRequestThread[];
|
|
103
|
+
hiddenPending: PendingComment[];
|
|
104
|
+
/** changed since the diff's head: a mark would be set on a version not shown */
|
|
105
|
+
viewedLocked: boolean;
|
|
106
|
+
pending: PendingComment[];
|
|
107
|
+
commentable: boolean;
|
|
108
|
+
/** the base of this diff is not the pull request's: only the new side's line numbers mean the same */
|
|
109
|
+
rightOnly: boolean;
|
|
110
|
+
/** the commit a new comment is anchored on */
|
|
111
|
+
commitId: string;
|
|
112
|
+
/** false on a single commit: no viewed mark, no pending review */
|
|
113
|
+
ofPullRequest: boolean;
|
|
114
|
+
/** false on the viewer's own pull request: no review to start */
|
|
115
|
+
reviewable: boolean;
|
|
116
|
+
/** on a commit read in its order; null otherwise */
|
|
117
|
+
anchoring: CommitFileAnchoring | null;
|
|
118
|
+
composer: Composer | null;
|
|
119
|
+
onOpenComposer: (composer: Composer) => void;
|
|
120
|
+
onCloseComposer: () => void;
|
|
121
|
+
onEdit: EditPullRequest;
|
|
122
|
+
draft: ReviewDraftActions;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const buildAnnotations = (
|
|
126
|
+
file: FileDiffMetadata,
|
|
127
|
+
review: FileReview | undefined,
|
|
128
|
+
): DiffLineAnnotation<AnnotationGroup>[] => {
|
|
129
|
+
if (review === undefined) return [];
|
|
130
|
+
const groups = new Map<string, DiffLineAnnotation<AnnotationGroup>>();
|
|
131
|
+
const place = (
|
|
132
|
+
side: "LEFT" | "RIGHT",
|
|
133
|
+
lineNumber: number,
|
|
134
|
+
item: AnnotationItem,
|
|
135
|
+
): void => {
|
|
136
|
+
const annotationSide = annotationSideOf(side);
|
|
137
|
+
const key = `${annotationSide}:${String(lineNumber)}`;
|
|
138
|
+
const existing = groups.get(key);
|
|
139
|
+
if (existing === undefined) {
|
|
140
|
+
groups.set(key, {
|
|
141
|
+
side: annotationSide,
|
|
142
|
+
lineNumber,
|
|
143
|
+
metadata: { items: [item] },
|
|
144
|
+
});
|
|
145
|
+
} else {
|
|
146
|
+
existing.metadata.items.push(item);
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
for (const thread of review.threads) {
|
|
151
|
+
if (thread.line !== null) {
|
|
152
|
+
place(thread.side, thread.line, { kind: "thread", thread });
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
for (const comment of review.pending) {
|
|
156
|
+
place(comment.side, comment.line, { kind: "pending", comment });
|
|
157
|
+
}
|
|
158
|
+
const { composer } = review;
|
|
159
|
+
if (composer?.fileKey === fileKeyOf(file)) {
|
|
160
|
+
place(composer.anchor.side, composer.anchor.line, {
|
|
161
|
+
kind: "composer",
|
|
162
|
+
anchor: composer.anchor,
|
|
163
|
+
suggestionLines: rightSideLines(file, composer.anchor),
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
return [...groups.values()];
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
const sameItem = (
|
|
170
|
+
a: AnnotationItem,
|
|
171
|
+
b: AnnotationItem | undefined,
|
|
172
|
+
): boolean => {
|
|
173
|
+
if (a.kind === "thread") return b?.kind === "thread" && a.thread === b.thread;
|
|
174
|
+
if (a.kind === "pending") {
|
|
175
|
+
return b?.kind === "pending" && a.comment === b.comment;
|
|
176
|
+
}
|
|
177
|
+
return b?.kind === "composer" && a.anchor === b.anchor;
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
const sameAnnotations = (
|
|
181
|
+
a: readonly DiffLineAnnotation<AnnotationGroup>[],
|
|
182
|
+
b: readonly DiffLineAnnotation<AnnotationGroup>[],
|
|
183
|
+
): boolean =>
|
|
184
|
+
a.length === b.length &&
|
|
185
|
+
a.every((group, index) => {
|
|
186
|
+
const other = b[index];
|
|
187
|
+
return (
|
|
188
|
+
other !== undefined &&
|
|
189
|
+
group.side === other.side &&
|
|
190
|
+
group.lineNumber === other.lineNumber &&
|
|
191
|
+
group.metadata.items.length === other.metadata.items.length &&
|
|
192
|
+
group.metadata.items.every((item, itemIndex) =>
|
|
193
|
+
sameItem(item, other.metadata.items[itemIndex]),
|
|
194
|
+
)
|
|
195
|
+
);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* The previous annotations while they place the same threads, comments and
|
|
200
|
+
* composer: the per-file review is rebuilt at every render of the files, and
|
|
201
|
+
* pierre redraws a whole file for an annotations array it has not seen.
|
|
202
|
+
*/
|
|
203
|
+
const useStableAnnotations = (
|
|
204
|
+
file: FileDiffMetadata,
|
|
205
|
+
next: DiffLineAnnotation<AnnotationGroup>[],
|
|
206
|
+
): DiffLineAnnotation<AnnotationGroup>[] => {
|
|
207
|
+
const [stable, setStable] = useState({ file, annotations: next });
|
|
208
|
+
const reused =
|
|
209
|
+
stable.file === file && sameAnnotations(stable.annotations, next);
|
|
210
|
+
if (!reused) setStable({ file, annotations: next });
|
|
211
|
+
return reused ? stable.annotations : next;
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
/** Why a thread is listed above its file rather than placed on a line. */
|
|
215
|
+
type UnplacedKind = "changedSince" | "outdated" | "whitespace";
|
|
216
|
+
|
|
217
|
+
const commentsText = (count: number): string =>
|
|
218
|
+
`${String(count)} comment${count === 1 ? "" : "s"}`;
|
|
219
|
+
|
|
220
|
+
const unplacedLabel = (kind: UnplacedKind, count: number): string => {
|
|
221
|
+
if (kind === "changedSince") {
|
|
222
|
+
return `${commentsText(count)} on code changed since your review started`;
|
|
223
|
+
}
|
|
224
|
+
if (kind === "whitespace") {
|
|
225
|
+
return `${commentsText(count)} on lines hidden with whitespace changes`;
|
|
226
|
+
}
|
|
227
|
+
return `${String(count)} outdated comment${count === 1 ? "" : "s"}`;
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
const unplacedLineText = (
|
|
231
|
+
kind: UnplacedKind,
|
|
232
|
+
{ path, line, originalLine }: PullRequestThread,
|
|
233
|
+
): string => {
|
|
234
|
+
if (kind === "changedSince" && line !== null) {
|
|
235
|
+
return `On line ${String(line)} of the latest version`;
|
|
236
|
+
}
|
|
237
|
+
if (kind === "whitespace" && line !== null) {
|
|
238
|
+
return `On line ${String(line)} of ${path}`;
|
|
239
|
+
}
|
|
240
|
+
if (originalLine === null) return "Line no longer in the diff";
|
|
241
|
+
return `Was on line ${String(originalLine)}`;
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
interface UnplacedThreadsProps {
|
|
245
|
+
kind: UnplacedKind;
|
|
246
|
+
threads: PullRequestThread[];
|
|
247
|
+
onEdit: EditPullRequest;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function UnplacedThreads({
|
|
251
|
+
kind,
|
|
252
|
+
threads,
|
|
253
|
+
onEdit,
|
|
254
|
+
}: UnplacedThreadsProps): ReactNode {
|
|
255
|
+
const [open, setOpen] = useState(false);
|
|
256
|
+
if (threads.length === 0) return null;
|
|
257
|
+
const count = threads.reduce(
|
|
258
|
+
(total, thread) => total + thread.commentsTotal,
|
|
259
|
+
0,
|
|
260
|
+
);
|
|
261
|
+
return (
|
|
262
|
+
<View className="gap-xs">
|
|
263
|
+
<View className="flex-row items-center gap-xs">
|
|
264
|
+
<Button
|
|
265
|
+
size="sm"
|
|
266
|
+
accent="neutral"
|
|
267
|
+
variant="soft"
|
|
268
|
+
text={unplacedLabel(kind, count)}
|
|
269
|
+
onPress={() => {
|
|
270
|
+
setOpen(!open);
|
|
271
|
+
}}
|
|
272
|
+
/>
|
|
273
|
+
</View>
|
|
274
|
+
{open
|
|
275
|
+
? threads.map((thread) => (
|
|
276
|
+
<View key={thread.id} className="gap-xxs">
|
|
277
|
+
<Text className="text-xs text-muted">
|
|
278
|
+
{unplacedLineText(kind, thread)}
|
|
279
|
+
</Text>
|
|
280
|
+
<ThreadAnnotation thread={thread} onEdit={onEdit} />
|
|
281
|
+
</View>
|
|
282
|
+
))
|
|
283
|
+
: null}
|
|
284
|
+
</View>
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
interface HiddenByWhitespaceProps {
|
|
289
|
+
threads: PullRequestThread[];
|
|
290
|
+
pending: PendingComment[];
|
|
291
|
+
onEdit: EditPullRequest;
|
|
292
|
+
draft: ReviewDraftActions;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* What hiding whitespace took the line of. A pending comment is always open:
|
|
297
|
+
* it goes out with the review, so it is never out of sight.
|
|
298
|
+
*/
|
|
299
|
+
function HiddenByWhitespace({
|
|
300
|
+
threads,
|
|
301
|
+
pending,
|
|
302
|
+
onEdit,
|
|
303
|
+
draft,
|
|
304
|
+
}: HiddenByWhitespaceProps): ReactNode {
|
|
305
|
+
return (
|
|
306
|
+
<>
|
|
307
|
+
<UnplacedThreads kind="whitespace" threads={threads} onEdit={onEdit} />
|
|
308
|
+
{pending.map((comment) => (
|
|
309
|
+
<View key={comment.key} className="gap-xxs">
|
|
310
|
+
<Text className="text-xs text-muted">
|
|
311
|
+
{`Pending, on line ${String(comment.line)} of ${comment.path}, hidden with whitespace changes`}
|
|
312
|
+
</Text>
|
|
313
|
+
<PendingAnnotation comment={comment} draft={draft} />
|
|
314
|
+
</View>
|
|
315
|
+
))}
|
|
316
|
+
</>
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
interface ViewedBadgeProps {
|
|
321
|
+
viewed: PullRequestFileViewed;
|
|
322
|
+
locked: boolean;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** Also says why the viewed checkbox is disabled, which alouette's Checkbox cannot. */
|
|
326
|
+
interface HeaderBadgeProps {
|
|
327
|
+
accent: "info" | "warning";
|
|
328
|
+
children: string;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** Wrapped: a Badge aligns itself to the start, so its own box is what the header row centers. */
|
|
332
|
+
function HeaderBadge({ accent, children }: HeaderBadgeProps): ReactNode {
|
|
333
|
+
return (
|
|
334
|
+
<View>
|
|
335
|
+
<Badge accent={accent} size="sm">
|
|
336
|
+
{children}
|
|
337
|
+
</Badge>
|
|
338
|
+
</View>
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/** Why a file of a commit read in its order has fewer review gestures. */
|
|
343
|
+
function AnchoringBadge({
|
|
344
|
+
anchoring,
|
|
345
|
+
}: {
|
|
346
|
+
anchoring: CommitFileAnchoring | null;
|
|
347
|
+
}): ReactNode {
|
|
348
|
+
if (anchoring === "commit") {
|
|
349
|
+
return (
|
|
350
|
+
<HeaderBadge accent="info">Changed again in a later commit</HeaderBadge>
|
|
351
|
+
);
|
|
352
|
+
}
|
|
353
|
+
if (anchoring === "head") {
|
|
354
|
+
return (
|
|
355
|
+
<HeaderBadge accent="info">Also changed in an earlier commit</HeaderBadge>
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
return null;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function ViewedBadge({ viewed, locked }: ViewedBadgeProps): ReactNode {
|
|
362
|
+
if (locked) {
|
|
363
|
+
return (
|
|
364
|
+
<HeaderBadge accent="warning">
|
|
365
|
+
Changed since your review started
|
|
366
|
+
</HeaderBadge>
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
if (viewed === "dismissed") {
|
|
370
|
+
return <HeaderBadge accent="warning">Changed since viewed</HeaderBadge>;
|
|
371
|
+
}
|
|
372
|
+
return null;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
type DiffSides = NonNullable<PullRequestDiffResponse["sides"]>;
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* What lets pierre unfold the unchanged lines between hunks: a patch carries
|
|
379
|
+
* only its hunks, and the whole of both sides is read on the first unfold.
|
|
380
|
+
*/
|
|
381
|
+
const fileLoaderOf =
|
|
382
|
+
(
|
|
383
|
+
ref: PullRequestRef,
|
|
384
|
+
sides: DiffSides,
|
|
385
|
+
readFileAtSha: PullRequestDataSource["readFileAtSha"],
|
|
386
|
+
): FileDiffContentsLoader =>
|
|
387
|
+
async (file) => {
|
|
388
|
+
const oldName = file.prevName ?? file.name;
|
|
389
|
+
const [oldContents, newContents] = await Promise.all([
|
|
390
|
+
file.type === "rename-pure"
|
|
391
|
+
? null
|
|
392
|
+
: readFileAtSha(ref, sides.old, oldName),
|
|
393
|
+
readFileAtSha(ref, sides.new, file.name),
|
|
394
|
+
]);
|
|
395
|
+
const newFile = { name: file.name, contents: newContents };
|
|
396
|
+
if (oldContents === null) return { oldFile: null, newFile };
|
|
397
|
+
return { oldFile: { name: oldName, contents: oldContents }, newFile };
|
|
398
|
+
};
|
|
399
|
+
|
|
400
|
+
/** Pierre's own rule pins a file header at `top: 0`, under the diff's toolbar. */
|
|
401
|
+
const stickyHeaderBelow = (top: number): string =>
|
|
402
|
+
`[data-diffs-header][data-sticky] { top: ${String(top)}px; }`;
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* alouette's global scrollbar-width and scrollbar-color are inherited through
|
|
406
|
+
* the shadow root, and a browser then ignores pierre's ::-webkit-scrollbar
|
|
407
|
+
* rules: the vertical gutter pierre zeroes would show as a strip beside the
|
|
408
|
+
* code, the horizontal one as alouette's opaque track under it. Firefox has no
|
|
409
|
+
* such rules and gets pierre's own scrollbar-color, so it is left alone.
|
|
410
|
+
*/
|
|
411
|
+
/** The caret lined up with the unfold buttons below it, inset as pierre insets its separators. */
|
|
412
|
+
const headerInset =
|
|
413
|
+
"[data-diffs-header=default] { padding-inline: var(--diffs-gap-inline, var(--diffs-gap-fallback)); }";
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* alouette's `shadow-s`, the one its surfaces carry: Tailwind inlines
|
|
417
|
+
* `--shadow-s` into the utility, so only its colors exist as variables here.
|
|
418
|
+
*/
|
|
419
|
+
const surfaceShadow =
|
|
420
|
+
"inset 0 1px 2px var(--color-shadow-highlight, #ffffff40), 0 1px 2px var(--color-dark-shadow, #00000040), 0 2px 4px var(--color-soft-shadow, #00000025)";
|
|
421
|
+
|
|
422
|
+
/** alouette's `rounded-sm`, the radius of its cards (`surface`). */
|
|
423
|
+
const fileRadius = "1rem";
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* Rounded and raised as an alouette card. `clip` rather than `hidden`: it
|
|
427
|
+
* makes no scroll container, which would stop the header sticking under the
|
|
428
|
+
* toolbar.
|
|
429
|
+
*/
|
|
430
|
+
const roundedFile = `:host { border-radius: ${fileRadius}; overflow: clip; box-shadow: ${surfaceShadow}; }`;
|
|
431
|
+
|
|
432
|
+
const pierreScrollbars =
|
|
433
|
+
"@supports not (-moz-appearance: none) { [data-code] { scrollbar-width: auto; scrollbar-color: auto; } }";
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* Closing a file read down to its end would leave the reader on the files
|
|
437
|
+
* after it, some of them never seen: its top is brought back under the
|
|
438
|
+
* toolbar. Done before closing, since a file's top does not move when it
|
|
439
|
+
* closes, so it does not depend on when pierre applies the change.
|
|
440
|
+
*/
|
|
441
|
+
const revealFileTop = (file: HTMLElement | null, stickyTop: number): void => {
|
|
442
|
+
if (file !== null) revealTopOf(file, stickyTop);
|
|
443
|
+
};
|
|
444
|
+
|
|
445
|
+
interface DiffFileProps {
|
|
446
|
+
file: FileDiffMetadata;
|
|
447
|
+
diffStyle: DiffStyle;
|
|
448
|
+
theme: DiffTheme;
|
|
449
|
+
themeType: "dark" | "light";
|
|
450
|
+
wordWrap: boolean;
|
|
451
|
+
stickyTop: number;
|
|
452
|
+
collapsed: boolean;
|
|
453
|
+
onToggleCollapsed: () => void;
|
|
454
|
+
loadFiles: FileDiffContentsLoader | undefined;
|
|
455
|
+
/** where the reading order starts a flow */
|
|
456
|
+
entry: boolean;
|
|
457
|
+
/** absent: not analyzed, or no touched export used outside the diff */
|
|
458
|
+
impacts?: DiffFileImpacts;
|
|
459
|
+
review?: FileReview;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
interface FileImpactsListProps {
|
|
463
|
+
impacts: DiffFileImpacts;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* Each touched export of the file and the files outside the diff that use it;
|
|
468
|
+
* past `maxImporterNames` files in all, each export's counts only.
|
|
469
|
+
*/
|
|
470
|
+
function FileImpactsList({ impacts }: FileImpactsListProps): ReactNode {
|
|
471
|
+
const counts = countImpacts([impacts]);
|
|
472
|
+
const showNames = counts.files + counts.tests <= maxImporterNames;
|
|
473
|
+
return (
|
|
474
|
+
<View className="gap-sm p-xs">
|
|
475
|
+
<Text className="font-body-bold text-sm text-sharp">
|
|
476
|
+
{`${pluralOf(counts.exports, "touched export")} used outside the diff`}
|
|
477
|
+
</Text>
|
|
478
|
+
<View className="gap-sm">
|
|
479
|
+
{impacts.exports.map((impact) => (
|
|
480
|
+
<View key={impact.name} className="gap-xxs">
|
|
481
|
+
<View className="flex-row flex-wrap items-center gap-xs">
|
|
482
|
+
<Code className="text-sm">{impact.name}</Code>
|
|
483
|
+
{impact.removed ? (
|
|
484
|
+
<Badge size="sm" accent="warning">
|
|
485
|
+
removed
|
|
486
|
+
</Badge>
|
|
487
|
+
) : null}
|
|
488
|
+
{showNames ? null : (
|
|
489
|
+
<Text className="text-xs text-muted">
|
|
490
|
+
{`used in ${usageLabel(countImpacts([{ path: impacts.path, exports: [impact] }]))}`}
|
|
491
|
+
</Text>
|
|
492
|
+
)}
|
|
493
|
+
</View>
|
|
494
|
+
{showNames
|
|
495
|
+
? impact.importers.map(({ path, test }) => (
|
|
496
|
+
<Text key={path} className="pl-m text-xs">
|
|
497
|
+
{path}
|
|
498
|
+
{test ? (
|
|
499
|
+
<Text className="text-xs text-muted"> · test</Text>
|
|
500
|
+
) : null}
|
|
501
|
+
</Text>
|
|
502
|
+
))
|
|
503
|
+
: null}
|
|
504
|
+
</View>
|
|
505
|
+
))}
|
|
506
|
+
{showNames ? null : (
|
|
507
|
+
<Text className="text-xs text-muted">
|
|
508
|
+
{`Files are left out past ${String(maxImporterNames)}.`}
|
|
509
|
+
</Text>
|
|
510
|
+
)}
|
|
511
|
+
</View>
|
|
512
|
+
</View>
|
|
513
|
+
);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
interface FileHeaderMetadataProps {
|
|
517
|
+
entry: boolean;
|
|
518
|
+
/** what the touched exports of the file are used by; undefined: none known, or none touched */
|
|
519
|
+
impacts: DiffFileImpacts | undefined;
|
|
520
|
+
review: FileReview | undefined;
|
|
521
|
+
onViewedPress: (viewed: boolean) => void;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/**
|
|
525
|
+
* The header's "Used in" count, opening the touched exports and their users:
|
|
526
|
+
* pierre has no slot between the header and the diff, and above or below the
|
|
527
|
+
* file it reads as another file's.
|
|
528
|
+
*/
|
|
529
|
+
function FileImpactsButton({
|
|
530
|
+
impacts,
|
|
531
|
+
}: {
|
|
532
|
+
impacts: DiffFileImpacts;
|
|
533
|
+
}): ReactNode {
|
|
534
|
+
const anchorRef = useRef<ComponentRef<typeof View>>(null);
|
|
535
|
+
const [open, setOpen] = useState(false);
|
|
536
|
+
const counts = countImpacts([impacts]);
|
|
537
|
+
return (
|
|
538
|
+
<>
|
|
539
|
+
<View ref={anchorRef}>
|
|
540
|
+
<PressableBox
|
|
541
|
+
variant="soft"
|
|
542
|
+
accent="info"
|
|
543
|
+
aria-haspopup="dialog"
|
|
544
|
+
aria-expanded={open}
|
|
545
|
+
className="flex-row items-center gap-xxs rounded-full border border-border-muted px-xs py-xxs"
|
|
546
|
+
onPress={() => {
|
|
547
|
+
setOpen(!open);
|
|
548
|
+
}}
|
|
549
|
+
>
|
|
550
|
+
<AccentScope accent="info">
|
|
551
|
+
<Icon
|
|
552
|
+
icon={<ShareNetworkRegularIcon />}
|
|
553
|
+
size={12}
|
|
554
|
+
className="text-accent"
|
|
555
|
+
/>
|
|
556
|
+
<Text className="font-body-bold text-xs text-accent">
|
|
557
|
+
{`Used in ${usageLabel(counts)}`}
|
|
558
|
+
</Text>
|
|
559
|
+
</AccentScope>
|
|
560
|
+
</PressableBox>
|
|
561
|
+
</View>
|
|
562
|
+
<Popover
|
|
563
|
+
open={open}
|
|
564
|
+
anchorRef={anchorRef}
|
|
565
|
+
align="end"
|
|
566
|
+
width="content"
|
|
567
|
+
aria-label={`Users of ${impacts.path}`}
|
|
568
|
+
onClose={() => {
|
|
569
|
+
setOpen(false);
|
|
570
|
+
}}
|
|
571
|
+
>
|
|
572
|
+
{/* Popover takes no offset: the gap keeps the panel off the badge */}
|
|
573
|
+
<View className="pt-xs">
|
|
574
|
+
<Box className="surface-popover max-h-[60vh] max-w-[640px] overflow-y-auto">
|
|
575
|
+
<FileImpactsList impacts={impacts} />
|
|
576
|
+
</Box>
|
|
577
|
+
</View>
|
|
578
|
+
</Popover>
|
|
579
|
+
</>
|
|
580
|
+
);
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
function FileHeaderMetadata({
|
|
584
|
+
entry,
|
|
585
|
+
impacts,
|
|
586
|
+
review,
|
|
587
|
+
onViewedPress,
|
|
588
|
+
}: FileHeaderMetadataProps): ReactNode {
|
|
589
|
+
return (
|
|
590
|
+
<View className="flex-row items-center gap-xs">
|
|
591
|
+
{entry ? <HeaderBadge accent="info">Entry point</HeaderBadge> : null}
|
|
592
|
+
{impacts === undefined ? null : <FileImpactsButton impacts={impacts} />}
|
|
593
|
+
{impacts?.exports.some(({ removed }) => removed) === true ? (
|
|
594
|
+
<HeaderBadge accent="warning">Removed export</HeaderBadge>
|
|
595
|
+
) : null}
|
|
596
|
+
<AnchoringBadge anchoring={review?.anchoring ?? null} />
|
|
597
|
+
{review?.ofPullRequest === true ? (
|
|
598
|
+
<>
|
|
599
|
+
<ViewedBadge viewed={review.viewed} locked={review.viewedLocked} />
|
|
600
|
+
<Checkbox
|
|
601
|
+
label="Viewed"
|
|
602
|
+
checked={review.viewed === "viewed"}
|
|
603
|
+
disabled={review.viewedLocked}
|
|
604
|
+
onValueChange={onViewedPress}
|
|
605
|
+
/>
|
|
606
|
+
</>
|
|
607
|
+
) : null}
|
|
608
|
+
</View>
|
|
609
|
+
);
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/**
|
|
613
|
+
* The header of the file the reader just went to from the overview, outlined
|
|
614
|
+
* and tinted so the eye finds it, open or closed, then faded. The header only:
|
|
615
|
+
* an outline over the whole file would recolor the diff's change bars. Inset
|
|
616
|
+
* shadows, since they paint over pierre's opaque sticky ground and can
|
|
617
|
+
* transition, where a background image cannot.
|
|
618
|
+
*/
|
|
619
|
+
const headerHighlight = (
|
|
620
|
+
highlight: DiffFileHighlight | null,
|
|
621
|
+
collapsed: boolean,
|
|
622
|
+
): string => {
|
|
623
|
+
if (highlight === null) return "";
|
|
624
|
+
const shadows =
|
|
625
|
+
highlight.phase === "shown"
|
|
626
|
+
? `inset 0 0 0 2px ${highlight.color}, inset 0 0 0 999px color-mix(in srgb, ${highlight.color} 14%, transparent)`
|
|
627
|
+
: "inset 0 0 0 2px transparent, inset 0 0 0 999px transparent";
|
|
628
|
+
// the file's own rounding (`roundedFile`): the whole file when closed, its top when open
|
|
629
|
+
const radius = collapsed ? fileRadius : `${fileRadius} ${fileRadius} 0 0`;
|
|
630
|
+
return `[data-diffs-header] { border-radius: ${radius}; box-shadow: ${shadows}; transition: box-shadow var(--motion-duration-collapse) ease-out; }`;
|
|
631
|
+
};
|
|
632
|
+
|
|
633
|
+
function DiffFile({
|
|
634
|
+
file,
|
|
635
|
+
diffStyle,
|
|
636
|
+
theme,
|
|
637
|
+
themeType,
|
|
638
|
+
wordWrap,
|
|
639
|
+
stickyTop,
|
|
640
|
+
collapsed,
|
|
641
|
+
onToggleCollapsed,
|
|
642
|
+
loadFiles,
|
|
643
|
+
entry,
|
|
644
|
+
impacts,
|
|
645
|
+
review,
|
|
646
|
+
}: DiffFileProps): ReactNode {
|
|
647
|
+
const fileKey = fileKeyOf(file);
|
|
648
|
+
const lineAnnotations = useStableAnnotations(
|
|
649
|
+
file,
|
|
650
|
+
buildAnnotations(file, review),
|
|
651
|
+
);
|
|
652
|
+
const commentable = review?.commentable === true;
|
|
653
|
+
// pierre compares its options with ===: a handler made at each render would
|
|
654
|
+
// redraw the file every time
|
|
655
|
+
const openComposerAt = useRef<(range: SelectedLineRange) => void>(() => {});
|
|
656
|
+
useLayoutEffect(() => {
|
|
657
|
+
openComposerAt.current = (range) => {
|
|
658
|
+
if (review === undefined) return;
|
|
659
|
+
const anchor = anchorOfRange(file.name, range);
|
|
660
|
+
const touchesOldSide =
|
|
661
|
+
anchor.side === "LEFT" || anchor.startSide === "LEFT";
|
|
662
|
+
if (review.rightOnly && touchesOldSide) return;
|
|
663
|
+
review.onOpenComposer({ fileKey, anchor, range });
|
|
664
|
+
};
|
|
665
|
+
});
|
|
666
|
+
const onGutterUtilityClick = useCallback((range: SelectedLineRange) => {
|
|
667
|
+
openComposerAt.current(range);
|
|
668
|
+
}, []);
|
|
669
|
+
const fileRef = useRef<ComponentRef<typeof View>>(null);
|
|
670
|
+
const fileElements = useDiffFileElements();
|
|
671
|
+
const highlight = useDiffFileHighlight(fileKey);
|
|
672
|
+
const setFileRef = useCallback(
|
|
673
|
+
(node: ComponentRef<typeof View>) => {
|
|
674
|
+
fileRef.current = node;
|
|
675
|
+
const unregister = fileElements?.register(
|
|
676
|
+
fileKey,
|
|
677
|
+
node as unknown as HTMLElement,
|
|
678
|
+
);
|
|
679
|
+
return () => {
|
|
680
|
+
fileRef.current = null;
|
|
681
|
+
unregister?.();
|
|
682
|
+
};
|
|
683
|
+
},
|
|
684
|
+
[fileElements, fileKey],
|
|
685
|
+
);
|
|
686
|
+
const revealBeforeClosing = (): void => {
|
|
687
|
+
if (collapsed) return;
|
|
688
|
+
revealFileTop(fileRef.current as unknown as HTMLElement | null, stickyTop);
|
|
689
|
+
};
|
|
690
|
+
|
|
691
|
+
return (
|
|
692
|
+
<View ref={setFileRef} className="gap-xs">
|
|
693
|
+
{review === undefined ? null : (
|
|
694
|
+
<>
|
|
695
|
+
<UnplacedThreads
|
|
696
|
+
kind="outdated"
|
|
697
|
+
threads={review.outdated}
|
|
698
|
+
onEdit={review.onEdit}
|
|
699
|
+
/>
|
|
700
|
+
<UnplacedThreads
|
|
701
|
+
kind="changedSince"
|
|
702
|
+
threads={review.changedSince}
|
|
703
|
+
onEdit={review.onEdit}
|
|
704
|
+
/>
|
|
705
|
+
<HiddenByWhitespace
|
|
706
|
+
threads={review.hiddenThreads}
|
|
707
|
+
pending={review.hiddenPending}
|
|
708
|
+
draft={review.draft}
|
|
709
|
+
onEdit={review.onEdit}
|
|
710
|
+
/>
|
|
711
|
+
</>
|
|
712
|
+
)}
|
|
713
|
+
<FileDiff<AnnotationGroup>
|
|
714
|
+
fileDiff={file}
|
|
715
|
+
lineAnnotations={lineAnnotations}
|
|
716
|
+
selectedLines={
|
|
717
|
+
review?.composer?.fileKey === fileKey ? review.composer.range : null
|
|
718
|
+
}
|
|
719
|
+
options={{
|
|
720
|
+
diffStyle,
|
|
721
|
+
theme: diffThemeNames(theme),
|
|
722
|
+
themeType,
|
|
723
|
+
overflow: wordWrap ? "wrap" : "scroll",
|
|
724
|
+
collapsed,
|
|
725
|
+
stickyHeader: true,
|
|
726
|
+
unsafeCSS: `${stickyHeaderBelow(stickyTop)} ${headerInset} ${roundedFile} ${pierreScrollbars} ${headerHighlight(highlight, collapsed)}`,
|
|
727
|
+
loadDiffFiles: loadFiles,
|
|
728
|
+
enableGutterUtility: commentable,
|
|
729
|
+
onGutterUtilityClick: commentable ? onGutterUtilityClick : undefined,
|
|
730
|
+
}}
|
|
731
|
+
renderHeaderPrefix={() => (
|
|
732
|
+
<IconButton
|
|
733
|
+
icon={
|
|
734
|
+
collapsed ? <CaretRightRegularIcon /> : <CaretDownRegularIcon />
|
|
735
|
+
}
|
|
736
|
+
size="sm"
|
|
737
|
+
variant="soft"
|
|
738
|
+
accent="neutral"
|
|
739
|
+
aria-label={collapsed ? `Open ${file.name}` : `Close ${file.name}`}
|
|
740
|
+
aria-expanded={!collapsed}
|
|
741
|
+
onPress={() => {
|
|
742
|
+
revealBeforeClosing();
|
|
743
|
+
onToggleCollapsed();
|
|
744
|
+
}}
|
|
745
|
+
/>
|
|
746
|
+
)}
|
|
747
|
+
renderHeaderMetadata={
|
|
748
|
+
!entry &&
|
|
749
|
+
impacts === undefined &&
|
|
750
|
+
review?.ofPullRequest !== true &&
|
|
751
|
+
(review?.anchoring ?? null) === null
|
|
752
|
+
? undefined
|
|
753
|
+
: () => (
|
|
754
|
+
<FileHeaderMetadata
|
|
755
|
+
entry={entry}
|
|
756
|
+
impacts={impacts}
|
|
757
|
+
review={review}
|
|
758
|
+
onViewedPress={(viewed) => {
|
|
759
|
+
if (viewed) revealBeforeClosing();
|
|
760
|
+
review?.onViewed(viewed);
|
|
761
|
+
}}
|
|
762
|
+
/>
|
|
763
|
+
)
|
|
764
|
+
}
|
|
765
|
+
renderAnnotation={({ metadata }) => (
|
|
766
|
+
<View>
|
|
767
|
+
{metadata.items.map((item) => {
|
|
768
|
+
if (review === undefined) return null;
|
|
769
|
+
if (item.kind === "thread") {
|
|
770
|
+
return (
|
|
771
|
+
<ThreadAnnotation
|
|
772
|
+
key={item.thread.id}
|
|
773
|
+
thread={item.thread}
|
|
774
|
+
onEdit={review.onEdit}
|
|
775
|
+
/>
|
|
776
|
+
);
|
|
777
|
+
}
|
|
778
|
+
if (item.kind === "pending") {
|
|
779
|
+
return (
|
|
780
|
+
<PendingAnnotation
|
|
781
|
+
key={item.comment.key}
|
|
782
|
+
comment={item.comment}
|
|
783
|
+
draft={review.draft}
|
|
784
|
+
/>
|
|
785
|
+
);
|
|
786
|
+
}
|
|
787
|
+
return (
|
|
788
|
+
<ComposerAnnotation
|
|
789
|
+
key="composer"
|
|
790
|
+
anchor={item.anchor}
|
|
791
|
+
suggestionLines={item.suggestionLines}
|
|
792
|
+
commitId={review.commitId}
|
|
793
|
+
draft={
|
|
794
|
+
review.ofPullRequest && review.reviewable
|
|
795
|
+
? review.draft
|
|
796
|
+
: null
|
|
797
|
+
}
|
|
798
|
+
onEdit={review.onEdit}
|
|
799
|
+
onClose={review.onCloseComposer}
|
|
800
|
+
/>
|
|
801
|
+
);
|
|
802
|
+
})}
|
|
803
|
+
</View>
|
|
804
|
+
)}
|
|
805
|
+
/>
|
|
806
|
+
</View>
|
|
807
|
+
);
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
interface FileItem {
|
|
811
|
+
file: FileDiffMetadata;
|
|
812
|
+
entry: boolean;
|
|
813
|
+
/** its tests and stories, read below it and closed until opened */
|
|
814
|
+
attached: FileDiffMetadata[];
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
interface FileGroup {
|
|
818
|
+
name: string;
|
|
819
|
+
items: FileItem[];
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
/** How a file is shown: `attached` below the file it covers. */
|
|
823
|
+
interface FilePlacement {
|
|
824
|
+
entry: boolean;
|
|
825
|
+
attached: boolean;
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
const plainItem = (file: FileDiffMetadata): FileItem => ({
|
|
829
|
+
file,
|
|
830
|
+
entry: false,
|
|
831
|
+
attached: [],
|
|
832
|
+
});
|
|
833
|
+
|
|
834
|
+
/** The daemon's groups, in its order; a file it did not group goes under the root. */
|
|
835
|
+
const groupFiles = (
|
|
836
|
+
files: FileDiffMetadata[],
|
|
837
|
+
groups: DiffGroup[],
|
|
838
|
+
): FileGroup[] => {
|
|
839
|
+
const groupOf = new Map<string, string>();
|
|
840
|
+
for (const { name, paths } of groups) {
|
|
841
|
+
for (const path of paths) groupOf.set(path, name);
|
|
842
|
+
}
|
|
843
|
+
const grouped = new Map<string, FileDiffMetadata[]>(
|
|
844
|
+
groups.map(({ name }) => [name, []]),
|
|
845
|
+
);
|
|
846
|
+
for (const file of files) {
|
|
847
|
+
const name = groupOf.get(file.name) ?? "";
|
|
848
|
+
const group = grouped.get(name);
|
|
849
|
+
if (group === undefined) grouped.set(name, [file]);
|
|
850
|
+
else group.push(file);
|
|
851
|
+
}
|
|
852
|
+
return [...grouped]
|
|
853
|
+
.filter(([, groupFilesList]) => groupFilesList.length > 0)
|
|
854
|
+
.map(([name, groupFilesList]) => ({
|
|
855
|
+
name,
|
|
856
|
+
items: groupFilesList.map(plainItem),
|
|
857
|
+
}));
|
|
858
|
+
};
|
|
859
|
+
|
|
860
|
+
/**
|
|
861
|
+
* The reading order's groups and files; a file of the patch it does not name
|
|
862
|
+
* (read on another range) goes after them, in its package's group.
|
|
863
|
+
*/
|
|
864
|
+
const orderFiles = (
|
|
865
|
+
files: FileDiffMetadata[],
|
|
866
|
+
groups: DiffGroup[],
|
|
867
|
+
readingOrder: DiffReadingOrderResponse,
|
|
868
|
+
): FileGroup[] => {
|
|
869
|
+
const byName = new Map(files.map((file) => [file.name, file]));
|
|
870
|
+
const placed = new Set<string>();
|
|
871
|
+
const take = (path: string): FileDiffMetadata[] => {
|
|
872
|
+
const file = byName.get(path);
|
|
873
|
+
if (file === undefined || placed.has(path)) return [];
|
|
874
|
+
placed.add(path);
|
|
875
|
+
return [file];
|
|
876
|
+
};
|
|
877
|
+
const ordered: FileGroup[] = readingOrder.groups.map(
|
|
878
|
+
({ name, files: paths }) => ({
|
|
879
|
+
name,
|
|
880
|
+
items: paths.flatMap(({ path, entry, attached }) =>
|
|
881
|
+
take(path).map((file) => ({
|
|
882
|
+
file,
|
|
883
|
+
entry,
|
|
884
|
+
attached: attached.flatMap(take),
|
|
885
|
+
})),
|
|
886
|
+
),
|
|
887
|
+
}),
|
|
888
|
+
);
|
|
889
|
+
for (const rest of groupFiles(
|
|
890
|
+
files.filter((file) => !placed.has(file.name)),
|
|
891
|
+
groups,
|
|
892
|
+
)) {
|
|
893
|
+
const group = ordered.find(({ name }) => name === rest.name);
|
|
894
|
+
if (group === undefined) ordered.push(rest);
|
|
895
|
+
else group.items.push(...rest.items);
|
|
896
|
+
}
|
|
897
|
+
return ordered.filter(({ items }) => items.length > 0);
|
|
898
|
+
};
|
|
899
|
+
|
|
900
|
+
const filesOf = ({ file, attached }: FileItem): FileDiffMetadata[] => [
|
|
901
|
+
file,
|
|
902
|
+
...attached,
|
|
903
|
+
];
|
|
904
|
+
|
|
905
|
+
interface FileGroupsProps {
|
|
906
|
+
groups: FileGroup[];
|
|
907
|
+
renderFile: (file: FileDiffMetadata, placement: FilePlacement) => ReactNode;
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
function FileGroups({ groups, renderFile }: FileGroupsProps): ReactNode {
|
|
911
|
+
return (
|
|
912
|
+
<View className="gap-xxl">
|
|
913
|
+
{groups.map((group) => {
|
|
914
|
+
const count = group.items.flatMap(filesOf).length;
|
|
915
|
+
return (
|
|
916
|
+
<View key={group.name} className="gap-sm">
|
|
917
|
+
<View className="flex-row items-baseline gap-sm">
|
|
918
|
+
<Text
|
|
919
|
+
role="heading"
|
|
920
|
+
aria-level={2}
|
|
921
|
+
className="font-heading-bold text-xl"
|
|
922
|
+
>
|
|
923
|
+
{group.name === "" ? "Root" : group.name}
|
|
924
|
+
</Text>
|
|
925
|
+
<Text className="text-sm text-muted">
|
|
926
|
+
{count === 1
|
|
927
|
+
? "1 changed file"
|
|
928
|
+
: `${String(count)} changed files`}
|
|
929
|
+
</Text>
|
|
930
|
+
</View>
|
|
931
|
+
<View className="gap-m">
|
|
932
|
+
{group.items.map(({ file, entry, attached }) => (
|
|
933
|
+
<View key={fileKeyOf(file)} className="gap-xs">
|
|
934
|
+
{renderFile(file, { entry, attached: false })}
|
|
935
|
+
{attached.length === 0 ? null : (
|
|
936
|
+
<View className="ml-l gap-xs">
|
|
937
|
+
{attached.map((test) => (
|
|
938
|
+
<View key={fileKeyOf(test)}>
|
|
939
|
+
{renderFile(test, { entry: false, attached: true })}
|
|
940
|
+
</View>
|
|
941
|
+
))}
|
|
942
|
+
</View>
|
|
943
|
+
)}
|
|
944
|
+
</View>
|
|
945
|
+
))}
|
|
946
|
+
</View>
|
|
947
|
+
</View>
|
|
948
|
+
);
|
|
949
|
+
})}
|
|
950
|
+
</View>
|
|
951
|
+
);
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
export interface PullRequestDiffReview {
|
|
955
|
+
/** the head a comment is anchored on: the diff's, not the header's last read */
|
|
956
|
+
headSha: string;
|
|
957
|
+
/** the pull request's threads on the code, null while they are read */
|
|
958
|
+
threads: PullRequestThread[] | null;
|
|
959
|
+
/** the files with the viewer's viewed marks, null while they are read */
|
|
960
|
+
files: PullRequestFile[] | null;
|
|
961
|
+
/** the diff's head is the pull request's: a comment can be anchored on it */
|
|
962
|
+
commentable: boolean;
|
|
963
|
+
rightOnly: boolean;
|
|
964
|
+
/**
|
|
965
|
+
* Set on one commit's own diff: comments are anchored on it, alone (a
|
|
966
|
+
* review is sent on the head), on the new side only; the pull request's
|
|
967
|
+
* threads, pending comments and viewed marks are not shown.
|
|
968
|
+
*/
|
|
969
|
+
commitOid?: string;
|
|
970
|
+
/**
|
|
971
|
+
* Set on one commit of the pull request read in its order: each of its
|
|
972
|
+
* paths with how its lines relate to the pull request's diff. A file is
|
|
973
|
+
* reviewed as on `commitOid`'s diff, on the new side only, or as on the
|
|
974
|
+
* pull request's diff.
|
|
975
|
+
*/
|
|
976
|
+
commitFiles?: {
|
|
977
|
+
oid: string;
|
|
978
|
+
anchorings: ReadonlyMap<string, CommitFileAnchoring>;
|
|
979
|
+
};
|
|
980
|
+
/**
|
|
981
|
+
* false on the viewer's own pull request: GitHub lets its author comment
|
|
982
|
+
* but not approve or request changes, so a comment goes out alone and there
|
|
983
|
+
* is no review to start. Unset, reviewable.
|
|
984
|
+
*/
|
|
985
|
+
reviewable?: boolean;
|
|
986
|
+
/** set while the diff shows a range the pull request has left */
|
|
987
|
+
movedOn?: PullRequestMovedOn | null;
|
|
988
|
+
onEdit: EditPullRequest;
|
|
989
|
+
/** resolves once GitHub answered and the screen read again; never rejects */
|
|
990
|
+
onViewed: (path: string, viewed: boolean) => Promise<void>;
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
const changedSinceShown = (
|
|
994
|
+
movedOn: PullRequestMovedOn | null | undefined,
|
|
995
|
+
path: string,
|
|
996
|
+
): boolean =>
|
|
997
|
+
movedOn != null && (movedOn.paths === null || movedOn.paths.has(path));
|
|
998
|
+
|
|
999
|
+
/** Its line, read on the pull request's range, may name other code here. */
|
|
1000
|
+
const isDisplaced = (
|
|
1001
|
+
movedOn: PullRequestMovedOn | null | undefined,
|
|
1002
|
+
thread: PullRequestThread,
|
|
1003
|
+
): boolean =>
|
|
1004
|
+
changedSinceShown(movedOn, thread.path) ||
|
|
1005
|
+
(movedOn?.baseMoved === true && thread.side === "LEFT");
|
|
1006
|
+
|
|
1007
|
+
const viewedStateOf = (
|
|
1008
|
+
pressed: boolean | undefined,
|
|
1009
|
+
known: PullRequestFileViewed | undefined,
|
|
1010
|
+
): PullRequestFileViewed => {
|
|
1011
|
+
if (pressed === undefined) return known ?? "unviewed";
|
|
1012
|
+
return pressed ? "viewed" : "unviewed";
|
|
1013
|
+
};
|
|
1014
|
+
|
|
1015
|
+
interface ReviewedFilesProps {
|
|
1016
|
+
groups: FileGroup[];
|
|
1017
|
+
review: PullRequestDiffReview;
|
|
1018
|
+
whitespace: DiffWhitespace;
|
|
1019
|
+
diffStyle: DiffStyle;
|
|
1020
|
+
theme: DiffTheme;
|
|
1021
|
+
themeType: "dark" | "light";
|
|
1022
|
+
wordWrap: boolean;
|
|
1023
|
+
stickyTop: number;
|
|
1024
|
+
collapse: PullRequestCollapse;
|
|
1025
|
+
loadFiles: FileDiffContentsLoader | undefined;
|
|
1026
|
+
impactsOf: (path: string) => DiffFileImpacts | undefined;
|
|
1027
|
+
commitComposer: Composer | null;
|
|
1028
|
+
onCommitComposerChange: (composer: Composer | null) => void;
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
function ReviewedFiles({
|
|
1032
|
+
groups,
|
|
1033
|
+
review,
|
|
1034
|
+
whitespace,
|
|
1035
|
+
diffStyle,
|
|
1036
|
+
theme,
|
|
1037
|
+
themeType,
|
|
1038
|
+
wordWrap,
|
|
1039
|
+
stickyTop,
|
|
1040
|
+
collapse,
|
|
1041
|
+
loadFiles,
|
|
1042
|
+
impactsOf,
|
|
1043
|
+
commitComposer,
|
|
1044
|
+
onCommitComposerChange,
|
|
1045
|
+
}: ReviewedFilesProps): ReactNode {
|
|
1046
|
+
const draft = usePullRequestReviewDraft();
|
|
1047
|
+
const anchoringOf = (path: string): CommitFileAnchoring => {
|
|
1048
|
+
if (review.commitOid !== undefined) return "commit";
|
|
1049
|
+
if (review.commitFiles === undefined) return "pullRequest";
|
|
1050
|
+
return review.commitFiles.anchorings.get(path) ?? "commit";
|
|
1051
|
+
};
|
|
1052
|
+
const isOfPullRequest = (path: string): boolean =>
|
|
1053
|
+
anchoringOf(path) !== "commit";
|
|
1054
|
+
const isRightOnly = (path: string): boolean =>
|
|
1055
|
+
review.rightOnly || anchoringOf(path) !== "pullRequest";
|
|
1056
|
+
const openComposerOf =
|
|
1057
|
+
(ofPullRequest: boolean) =>
|
|
1058
|
+
(next: Composer): void => {
|
|
1059
|
+
if (ofPullRequest) {
|
|
1060
|
+
onCommitComposerChange(null);
|
|
1061
|
+
draft.openComposer(next);
|
|
1062
|
+
return;
|
|
1063
|
+
}
|
|
1064
|
+
// one comment written at a time on a commit read in its order
|
|
1065
|
+
if (review.commitFiles !== undefined) draft.closeComposer();
|
|
1066
|
+
onCommitComposerChange(next);
|
|
1067
|
+
};
|
|
1068
|
+
const closeComposerOf = (ofPullRequest: boolean) =>
|
|
1069
|
+
ofPullRequest
|
|
1070
|
+
? draft.closeComposer
|
|
1071
|
+
: () => {
|
|
1072
|
+
onCommitComposerChange(null);
|
|
1073
|
+
};
|
|
1074
|
+
const [optimistic, setOptimistic] = useState<Record<string, boolean>>({});
|
|
1075
|
+
|
|
1076
|
+
const threads = review.threads ?? [];
|
|
1077
|
+
const viewedOf = new Map(
|
|
1078
|
+
(review.files ?? []).map(({ path, viewed }) => [path, viewed]),
|
|
1079
|
+
);
|
|
1080
|
+
|
|
1081
|
+
const draftActions: ReviewDraftActions = {
|
|
1082
|
+
hasPending: draft.comments.length > 0,
|
|
1083
|
+
add: draft.add,
|
|
1084
|
+
update: draft.update,
|
|
1085
|
+
remove: draft.remove,
|
|
1086
|
+
};
|
|
1087
|
+
|
|
1088
|
+
const onSide = ({
|
|
1089
|
+
path,
|
|
1090
|
+
side,
|
|
1091
|
+
}: {
|
|
1092
|
+
path: string;
|
|
1093
|
+
side: "LEFT" | "RIGHT";
|
|
1094
|
+
}): boolean => !isRightOnly(path) || side === "RIGHT";
|
|
1095
|
+
const pendingOfPullRequest = draft.comments.filter(
|
|
1096
|
+
(comment) => isOfPullRequest(comment.path) && onSide(comment),
|
|
1097
|
+
);
|
|
1098
|
+
/**
|
|
1099
|
+
* `git diff -w` drops a file whose every change is whitespace, so what is
|
|
1100
|
+
* anchored in it has no file to be listed above.
|
|
1101
|
+
*/
|
|
1102
|
+
const shownPaths = new Set(
|
|
1103
|
+
groups.flatMap(({ items }) =>
|
|
1104
|
+
items.flatMap(filesOf).map(({ name }) => name),
|
|
1105
|
+
),
|
|
1106
|
+
);
|
|
1107
|
+
const inHiddenFile = ({ path }: { path: string }): boolean =>
|
|
1108
|
+
whitespace === "hide" && !shownPaths.has(path);
|
|
1109
|
+
const isHiddenLine = (
|
|
1110
|
+
file: FileDiffMetadata,
|
|
1111
|
+
{ side, line }: { side: "LEFT" | "RIGHT"; line: number | null },
|
|
1112
|
+
): boolean =>
|
|
1113
|
+
whitespace === "hide" && line !== null && !isLineInPatch(file, side, line);
|
|
1114
|
+
|
|
1115
|
+
const markViewed = (path: string, viewed: boolean): void => {
|
|
1116
|
+
collapse.set(path, viewed);
|
|
1117
|
+
setOptimistic((previous) => ({ ...previous, [path]: viewed }));
|
|
1118
|
+
review
|
|
1119
|
+
.onViewed(path, viewed)
|
|
1120
|
+
.then(() => {
|
|
1121
|
+
setOptimistic((previous) => {
|
|
1122
|
+
const { [path]: _settled, ...rest } = previous;
|
|
1123
|
+
return rest;
|
|
1124
|
+
});
|
|
1125
|
+
})
|
|
1126
|
+
.catch(() => {
|
|
1127
|
+
// onViewed reports its own failure and does not reject
|
|
1128
|
+
});
|
|
1129
|
+
};
|
|
1130
|
+
|
|
1131
|
+
return (
|
|
1132
|
+
<View className="gap-m">
|
|
1133
|
+
<HiddenByWhitespace
|
|
1134
|
+
threads={threads.filter(
|
|
1135
|
+
(thread) =>
|
|
1136
|
+
isOfPullRequest(thread.path) &&
|
|
1137
|
+
!thread.outdated &&
|
|
1138
|
+
thread.line !== null &&
|
|
1139
|
+
onSide(thread) &&
|
|
1140
|
+
inHiddenFile(thread),
|
|
1141
|
+
)}
|
|
1142
|
+
pending={pendingOfPullRequest.filter(inHiddenFile)}
|
|
1143
|
+
draft={draftActions}
|
|
1144
|
+
onEdit={review.onEdit}
|
|
1145
|
+
/>
|
|
1146
|
+
<FileGroups
|
|
1147
|
+
groups={groups}
|
|
1148
|
+
renderFile={(file, { entry, attached }) => {
|
|
1149
|
+
const path = file.name;
|
|
1150
|
+
const anchoring = anchoringOf(path);
|
|
1151
|
+
const ofPullRequest = anchoring !== "commit";
|
|
1152
|
+
const rightOnly = isRightOnly(path);
|
|
1153
|
+
const pressed = optimistic[path];
|
|
1154
|
+
const viewed = ofPullRequest
|
|
1155
|
+
? viewedStateOf(pressed, viewedOf.get(path))
|
|
1156
|
+
: "unviewed";
|
|
1157
|
+
const closedByDefault = attached || viewed === "viewed";
|
|
1158
|
+
const ofFile = threads.filter((thread) => thread.path === path);
|
|
1159
|
+
const onLine = ofFile.filter(
|
|
1160
|
+
(thread) =>
|
|
1161
|
+
ofPullRequest &&
|
|
1162
|
+
!thread.outdated &&
|
|
1163
|
+
thread.line !== null &&
|
|
1164
|
+
onSide(thread),
|
|
1165
|
+
);
|
|
1166
|
+
const settled = onLine.filter(
|
|
1167
|
+
(thread) => !isDisplaced(review.movedOn, thread),
|
|
1168
|
+
);
|
|
1169
|
+
const pendingOfFile = pendingOfPullRequest.filter(
|
|
1170
|
+
(comment) => comment.path === path,
|
|
1171
|
+
);
|
|
1172
|
+
return (
|
|
1173
|
+
<DiffFile
|
|
1174
|
+
file={file}
|
|
1175
|
+
diffStyle={diffStyle}
|
|
1176
|
+
theme={theme}
|
|
1177
|
+
themeType={themeType}
|
|
1178
|
+
wordWrap={wordWrap}
|
|
1179
|
+
stickyTop={stickyTop}
|
|
1180
|
+
loadFiles={loadFiles}
|
|
1181
|
+
entry={entry}
|
|
1182
|
+
impacts={impactsOf(path)}
|
|
1183
|
+
collapsed={collapse.isCollapsed(path, closedByDefault)}
|
|
1184
|
+
review={{
|
|
1185
|
+
viewed,
|
|
1186
|
+
onViewed: (next) => {
|
|
1187
|
+
markViewed(path, next);
|
|
1188
|
+
},
|
|
1189
|
+
threads: settled.filter(
|
|
1190
|
+
(thread) => !isHiddenLine(file, thread),
|
|
1191
|
+
),
|
|
1192
|
+
outdated: rightOnly
|
|
1193
|
+
? []
|
|
1194
|
+
: ofFile.filter((thread) => thread.outdated),
|
|
1195
|
+
changedSince: onLine.filter((thread) =>
|
|
1196
|
+
isDisplaced(review.movedOn, thread),
|
|
1197
|
+
),
|
|
1198
|
+
hiddenThreads: settled.filter((thread) =>
|
|
1199
|
+
isHiddenLine(file, thread),
|
|
1200
|
+
),
|
|
1201
|
+
hiddenPending: pendingOfFile.filter((comment) =>
|
|
1202
|
+
isHiddenLine(file, comment),
|
|
1203
|
+
),
|
|
1204
|
+
viewedLocked:
|
|
1205
|
+
ofPullRequest && changedSinceShown(review.movedOn, path),
|
|
1206
|
+
pending: pendingOfFile.filter(
|
|
1207
|
+
(comment) => !isHiddenLine(file, comment),
|
|
1208
|
+
),
|
|
1209
|
+
commentable: review.commentable,
|
|
1210
|
+
rightOnly,
|
|
1211
|
+
commitId: ofPullRequest
|
|
1212
|
+
? review.headSha
|
|
1213
|
+
: (review.commitOid ??
|
|
1214
|
+
review.commitFiles?.oid ??
|
|
1215
|
+
review.headSha),
|
|
1216
|
+
ofPullRequest,
|
|
1217
|
+
reviewable: review.reviewable !== false,
|
|
1218
|
+
anchoring: review.commitFiles === undefined ? null : anchoring,
|
|
1219
|
+
composer: ofPullRequest ? draft.composer : commitComposer,
|
|
1220
|
+
onOpenComposer: openComposerOf(ofPullRequest),
|
|
1221
|
+
onCloseComposer: closeComposerOf(ofPullRequest),
|
|
1222
|
+
onEdit: review.onEdit,
|
|
1223
|
+
draft: draftActions,
|
|
1224
|
+
}}
|
|
1225
|
+
onToggleCollapsed={() => {
|
|
1226
|
+
collapse.set(
|
|
1227
|
+
path,
|
|
1228
|
+
!collapse.isCollapsed(path, closedByDefault),
|
|
1229
|
+
);
|
|
1230
|
+
}}
|
|
1231
|
+
/>
|
|
1232
|
+
);
|
|
1233
|
+
}}
|
|
1234
|
+
/>
|
|
1235
|
+
</View>
|
|
1236
|
+
);
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
interface PullRequestDiffViewerProps {
|
|
1240
|
+
pullRequest: PullRequestRef;
|
|
1241
|
+
patch: string;
|
|
1242
|
+
groups: DiffGroup[];
|
|
1243
|
+
/** null: the files by package, in patch order */
|
|
1244
|
+
readingOrder: DiffReadingOrderResponse | null;
|
|
1245
|
+
/** null: not read, still reading or failed: no file says anything of its usage */
|
|
1246
|
+
impacts: DiffImpactsResponse | null;
|
|
1247
|
+
sides: PullRequestDiffResponse["sides"];
|
|
1248
|
+
/** what the patch did with whitespace, which decides where a thread can be placed */
|
|
1249
|
+
whitespace: DiffWhitespace;
|
|
1250
|
+
diffStyle: DiffStyle;
|
|
1251
|
+
theme: DiffTheme;
|
|
1252
|
+
themeType: "dark" | "light";
|
|
1253
|
+
wordWrap: boolean;
|
|
1254
|
+
/** where a file header sticks: the height of the toolbar stuck above it */
|
|
1255
|
+
stickyTop: number;
|
|
1256
|
+
collapse: PullRequestCollapse;
|
|
1257
|
+
/** absent: a read-only diff, a commit's own */
|
|
1258
|
+
review?: PullRequestDiffReview;
|
|
1259
|
+
/** held by the view, whose toolbar must know a comment is being written */
|
|
1260
|
+
commitComposer: Composer | null;
|
|
1261
|
+
onCommitComposerChange: (composer: Composer | null) => void;
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
/**
|
|
1265
|
+
* The only module here importing `@pierre/diffs`, loaded lazily (shiki has no
|
|
1266
|
+
* place in the bundle every board loads). A line gets its annotations through
|
|
1267
|
+
* `lineAnnotations` (one per side and line, so threads, pending comments and the
|
|
1268
|
+
* composer anchored together share it), rendered in light DOM slots by
|
|
1269
|
+
* `renderAnnotation`. Commenting starts from pierre's gutter utility, whose
|
|
1270
|
+
* drag selects a range: `onGutterUtilityClick` receives it.
|
|
1271
|
+
*
|
|
1272
|
+
* Memoized, so a toolbar press that changes none of its props does not render
|
|
1273
|
+
* every file again before the press shows.
|
|
1274
|
+
*/
|
|
1275
|
+
function PullRequestDiffViewer({
|
|
1276
|
+
pullRequest,
|
|
1277
|
+
patch,
|
|
1278
|
+
groups,
|
|
1279
|
+
readingOrder,
|
|
1280
|
+
impacts,
|
|
1281
|
+
sides,
|
|
1282
|
+
whitespace,
|
|
1283
|
+
diffStyle,
|
|
1284
|
+
theme,
|
|
1285
|
+
themeType,
|
|
1286
|
+
wordWrap,
|
|
1287
|
+
stickyTop,
|
|
1288
|
+
collapse,
|
|
1289
|
+
review,
|
|
1290
|
+
commitComposer,
|
|
1291
|
+
onCommitComposerChange,
|
|
1292
|
+
}: PullRequestDiffViewerProps): ReactNode {
|
|
1293
|
+
const files = useMemo(
|
|
1294
|
+
() => parsePatchFiles(patch).flatMap((parsed) => parsed.files),
|
|
1295
|
+
[patch],
|
|
1296
|
+
);
|
|
1297
|
+
const fileGroups = useMemo(
|
|
1298
|
+
() =>
|
|
1299
|
+
readingOrder === null
|
|
1300
|
+
? groupFiles(files, groups)
|
|
1301
|
+
: orderFiles(files, groups, readingOrder),
|
|
1302
|
+
[files, groups, readingOrder],
|
|
1303
|
+
);
|
|
1304
|
+
const impactsByPath = useMemo(
|
|
1305
|
+
() =>
|
|
1306
|
+
new Map(
|
|
1307
|
+
impacts === null || impacts.tooLarge
|
|
1308
|
+
? []
|
|
1309
|
+
: impacts.files
|
|
1310
|
+
.filter(({ exports }) => exports.length > 0)
|
|
1311
|
+
.map((file) => [file.path, file]),
|
|
1312
|
+
),
|
|
1313
|
+
[impacts],
|
|
1314
|
+
);
|
|
1315
|
+
const impactsOf = (path: string): DiffFileImpacts | undefined =>
|
|
1316
|
+
impactsByPath.get(path);
|
|
1317
|
+
const { ownerRepo, number } = pullRequest;
|
|
1318
|
+
const { readFileAtSha } = usePullRequestDataSource();
|
|
1319
|
+
const loadFiles = useMemo(
|
|
1320
|
+
() =>
|
|
1321
|
+
sides === null
|
|
1322
|
+
? undefined
|
|
1323
|
+
: fileLoaderOf({ ownerRepo, number }, sides, readFileAtSha),
|
|
1324
|
+
[ownerRepo, number, sides, readFileAtSha],
|
|
1325
|
+
);
|
|
1326
|
+
|
|
1327
|
+
return (
|
|
1328
|
+
<DiffWorkerPool theme={theme}>
|
|
1329
|
+
{review === undefined ? (
|
|
1330
|
+
<FileGroups
|
|
1331
|
+
groups={fileGroups}
|
|
1332
|
+
renderFile={(file, { entry, attached }) => (
|
|
1333
|
+
<DiffFile
|
|
1334
|
+
file={file}
|
|
1335
|
+
diffStyle={diffStyle}
|
|
1336
|
+
theme={theme}
|
|
1337
|
+
themeType={themeType}
|
|
1338
|
+
wordWrap={wordWrap}
|
|
1339
|
+
stickyTop={stickyTop}
|
|
1340
|
+
loadFiles={loadFiles}
|
|
1341
|
+
entry={entry}
|
|
1342
|
+
impacts={impactsOf(file.name)}
|
|
1343
|
+
collapsed={collapse.isCollapsed(file.name, attached)}
|
|
1344
|
+
onToggleCollapsed={() => {
|
|
1345
|
+
collapse.set(
|
|
1346
|
+
file.name,
|
|
1347
|
+
!collapse.isCollapsed(file.name, attached),
|
|
1348
|
+
);
|
|
1349
|
+
}}
|
|
1350
|
+
/>
|
|
1351
|
+
)}
|
|
1352
|
+
/>
|
|
1353
|
+
) : (
|
|
1354
|
+
<ReviewedFiles
|
|
1355
|
+
groups={fileGroups}
|
|
1356
|
+
review={review}
|
|
1357
|
+
whitespace={whitespace}
|
|
1358
|
+
diffStyle={diffStyle}
|
|
1359
|
+
theme={theme}
|
|
1360
|
+
themeType={themeType}
|
|
1361
|
+
wordWrap={wordWrap}
|
|
1362
|
+
stickyTop={stickyTop}
|
|
1363
|
+
collapse={collapse}
|
|
1364
|
+
loadFiles={loadFiles}
|
|
1365
|
+
impactsOf={impactsOf}
|
|
1366
|
+
commitComposer={commitComposer}
|
|
1367
|
+
onCommitComposerChange={onCommitComposerChange}
|
|
1368
|
+
/>
|
|
1369
|
+
)}
|
|
1370
|
+
</DiffWorkerPool>
|
|
1371
|
+
);
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
export default memo(PullRequestDiffViewer);
|