@reviewflow/ui 0.0.0-stage → 6.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/LICENSE +15 -0
  2. package/README.md +95 -2
  3. package/package.json +102 -4
  4. package/src/board/bucket-section.tsx +94 -0
  5. package/src/board/dataSource.ts +44 -0
  6. package/src/board/facts.ts +183 -0
  7. package/src/board/index.ts +12 -0
  8. package/src/board/pull-request-board.tsx +239 -0
  9. package/src/board/pull-request-row.tsx +181 -0
  10. package/src/components/branch-diff/diff-themes.ts +6 -0
  11. package/src/components/branch-diff/diff-worker-pool.tsx +12 -0
  12. package/src/components/branch-diff/diff-worker-pool.web.tsx +56 -0
  13. package/src/components/branch-diff/diff-worker.ts +7 -0
  14. package/src/components/branch-diff/use-collapsed-files.ts +49 -0
  15. package/src/components/branch-diff/use-diff-settings.tsx +97 -0
  16. package/src/components/branch-diff/whitespace-button.tsx +32 -0
  17. package/src/components/copyable-code-block.tsx +101 -0
  18. package/src/components/label-pill.tsx +84 -0
  19. package/src/components/link-in-row.ts +6 -0
  20. package/src/components/markdown-suggestion.tsx +60 -0
  21. package/src/components/markdown-text.tsx +1067 -0
  22. package/src/components/pull-request-actions-merge.tsx +341 -0
  23. package/src/components/pull-request-actions-review.tsx +218 -0
  24. package/src/components/pull-request-checks.tsx +86 -0
  25. package/src/components/pull-request-commit-files.ts +143 -0
  26. package/src/components/pull-request-commits.tsx +221 -0
  27. package/src/components/pull-request-conversation-card.tsx +220 -0
  28. package/src/components/pull-request-conversation-edit.tsx +147 -0
  29. package/src/components/pull-request-conversation-event.tsx +267 -0
  30. package/src/components/pull-request-conversation-person.tsx +28 -0
  31. package/src/components/pull-request-conversation-thread.tsx +158 -0
  32. package/src/components/pull-request-diff-annotations.tsx +443 -0
  33. package/src/components/pull-request-diff-collapse.ts +43 -0
  34. package/src/components/pull-request-diff-data.ts +174 -0
  35. package/src/components/pull-request-diff-impacts-summary.tsx +97 -0
  36. package/src/components/pull-request-diff-impacts.ts +45 -0
  37. package/src/components/pull-request-diff-jump.ts +87 -0
  38. package/src/components/pull-request-diff-lines.ts +103 -0
  39. package/src/components/pull-request-diff-overview.tsx +416 -0
  40. package/src/components/pull-request-diff-view.tsx +438 -0
  41. package/src/components/pull-request-diff-viewer.tsx +1374 -0
  42. package/src/components/pull-request-files.tsx +533 -0
  43. package/src/components/pull-request-markdown-input.tsx +91 -0
  44. package/src/components/pull-request-panel.tsx +1270 -0
  45. package/src/components/pull-request-part-notice.tsx +82 -0
  46. package/src/components/pull-request-reactions.tsx +178 -0
  47. package/src/components/pull-request-review.tsx +164 -0
  48. package/src/components/pull-request-revisions.tsx +273 -0
  49. package/src/components/pull-request-screen.tsx +299 -0
  50. package/src/components/pull-request-suggestion-actions.tsx +201 -0
  51. package/src/components/pull-request-timeline.tsx +270 -0
  52. package/src/components/repository-attachment-banner.tsx +42 -0
  53. package/src/components/scroll-parent.ts +50 -0
  54. package/src/css.d.ts +1 -0
  55. package/src/dataSource.ts +242 -0
  56. package/src/editor/alerts.ts +105 -0
  57. package/src/editor/code-editor.native.tsx +25 -0
  58. package/src/editor/code-editor.tsx +112 -0
  59. package/src/editor/codeEditorExtensions.ts +65 -0
  60. package/src/editor/codeHighlight.ts +71 -0
  61. package/src/editor/commands.ts +175 -0
  62. package/src/editor/decorations.ts +425 -0
  63. package/src/editor/editable-file.tsx +224 -0
  64. package/src/editor/emojiCompletion.ts +110 -0
  65. package/src/editor/extensions.ts +149 -0
  66. package/src/editor/frontmatter.ts +76 -0
  67. package/src/editor/inlineFormat.ts +114 -0
  68. package/src/editor/lineTypes.ts +55 -0
  69. package/src/editor/markdown-editor.css +511 -0
  70. package/src/editor/markdown-editor.native.tsx +29 -0
  71. package/src/editor/markdown-editor.tsx +295 -0
  72. package/src/editor/nodeClasses.ts +69 -0
  73. package/src/index.ts +75 -0
  74. package/src/markdown-github/flatten.ts +49 -0
  75. package/src/markdown-github/html-tokens.ts +93 -0
  76. package/src/markdown-github/pair-html.ts +354 -0
  77. package/src/markdown-github/safe-url.ts +15 -0
  78. package/src/markdown-github/table-columns.ts +23 -0
  79. package/src/markdown-github/toggle-task.ts +164 -0
  80. package/src/nativewind-env.d.ts +1 -0
  81. package/src/prStatus.ts +90 -0
  82. package/src/services/PullRequestDraftsProvider.tsx +179 -0
  83. package/src/services/PullRequestReviewDraftProvider.tsx +178 -0
  84. package/src/services/PullRequestSuggestionBatchProvider.tsx +112 -0
  85. package/src/services/emojify.ts +13 -0
  86. package/src/services/errorToMessage.ts +6 -0
  87. package/src/services/expiringDraftStore.ts +102 -0
  88. package/src/services/formatDataAge.ts +12 -0
  89. package/src/services/formatRelativeDate.ts +56 -0
  90. package/src/services/githubImages.tsx +33 -0
  91. package/src/services/isViewerAuthor.ts +7 -0
  92. package/src/slots.tsx +34 -0
@@ -0,0 +1,416 @@
1
+ import type {
2
+ DiffGroup,
3
+ DiffImpactsResponse,
4
+ DiffOmittedFile,
5
+ DiffReadingOrderResponse,
6
+ } from "@reviewflow/sdk";
7
+ import { isAttachablePath, subjectsOf } from "@reviewflow/sdk";
8
+ import { Icon, PressableBox, Text, View } from "alouette";
9
+ import { FileRegularIcon } from "alouette-icons/phosphor-icons/File";
10
+ import { FileTsRegularIcon } from "alouette-icons/phosphor-icons/FileTs";
11
+ import { PackageDuotoneIcon } from "alouette-icons/phosphor-icons/Package";
12
+ import { TestTubeRegularIcon } from "alouette-icons/phosphor-icons/TestTube";
13
+ import type { ReactNode } from "react";
14
+ import { PullRequestDiffImpactsSummary } from "./pull-request-diff-impacts-summary.tsx";
15
+ import type { ImpactsStatus } from "./pull-request-diff-impacts.ts";
16
+ import { pluralOf } from "./pull-request-diff-impacts.ts";
17
+
18
+ /** Past this many names over the range, the rows carry their counts only. */
19
+ export const maxOverviewNames = 50;
20
+
21
+ /** `none`: no checkout, or a source that cannot order. */
22
+ export type OverviewReadingOrderStatus =
23
+ | "failed"
24
+ | "none"
25
+ | "reading"
26
+ | "ready";
27
+
28
+ interface OverviewName {
29
+ path: string;
30
+ entry: boolean;
31
+ /** in the patch, so the viewer renders it: false for an omitted file */
32
+ shown: boolean;
33
+ /** the file a test covers, listed above it; null for any other name */
34
+ subject: string | null;
35
+ }
36
+
37
+ interface OverviewWorkspace {
38
+ name: string;
39
+ files: number;
40
+ tests: number;
41
+ names: OverviewName[];
42
+ }
43
+
44
+ /**
45
+ * The workspace of a path the groups do not list (an omitted file): the
46
+ * longest group directory holding it, else its first directory, as the diff
47
+ * groups a repository without checkout.
48
+ */
49
+ const workspaceOf = (path: string, names: readonly string[]): string => {
50
+ const holding = names
51
+ .filter((name) => name !== "" && path.startsWith(`${name}/`))
52
+ .toSorted((left, right) => right.length - left.length)[0];
53
+ if (holding !== undefined) return holding;
54
+ if (names.includes("")) return "";
55
+ const slash = path.indexOf("/");
56
+ return slash === -1 ? "" : path.slice(0, slash);
57
+ };
58
+
59
+ interface SummarizeInput {
60
+ groups: readonly DiffGroup[];
61
+ omitted: readonly DiffOmittedFile[];
62
+ readingOrder: DiffReadingOrderResponse | null;
63
+ displayOrder: DiffReadingOrderResponse | null;
64
+ /** every file rather than the roots */
65
+ every: boolean;
66
+ }
67
+
68
+ /**
69
+ * The paths as the viewer lays them out: by the reading order when it orders
70
+ * the files, its tests below each file; by the groups otherwise. A path it
71
+ * does not name comes after, as the viewer appends it.
72
+ */
73
+ const displayedPaths = (
74
+ groups: readonly DiffGroup[],
75
+ displayOrder: DiffReadingOrderResponse | null,
76
+ ): string[] => [
77
+ ...new Set([
78
+ ...(displayOrder?.groups ?? []).flatMap(({ files }) =>
79
+ files.flatMap(({ path, attached }) => [path, ...attached]),
80
+ ),
81
+ ...groups.flatMap(({ paths }) => paths),
82
+ ]),
83
+ ];
84
+
85
+ interface WithTestsInput {
86
+ paths: readonly string[];
87
+ code: readonly string[];
88
+ /** a test's subject as the reading order attached it */
89
+ readSubjects: ReadonlyMap<string, string>;
90
+ byPosition: (left: string, right: string) => number;
91
+ nameOf: (path: string, subject: string | null) => OverviewName;
92
+ }
93
+
94
+ /**
95
+ * Every file of a workspace, each followed by its tests, whatever the order
96
+ * of the diff: the reading order's subject, else one named like it
97
+ * (`subjectsOf`). A test covering no changed file comes last.
98
+ */
99
+ const withTests = ({
100
+ paths,
101
+ code,
102
+ readSubjects,
103
+ byPosition,
104
+ nameOf,
105
+ }: WithTestsInput): OverviewName[] => {
106
+ const codeSet = new Set(code);
107
+ const testsOf = new Map<string, string[]>();
108
+ const uncovered: string[] = [];
109
+ for (const test of paths.filter(isAttachablePath)) {
110
+ const subject =
111
+ readSubjects.get(test) ??
112
+ subjectsOf(test).find((candidate) => codeSet.has(candidate));
113
+ if (subject === undefined || !codeSet.has(subject)) uncovered.push(test);
114
+ else testsOf.set(subject, [...(testsOf.get(subject) ?? []), test]);
115
+ }
116
+ return [
117
+ ...code
118
+ .toSorted(byPosition)
119
+ .flatMap((path) => [
120
+ nameOf(path, null),
121
+ ...(testsOf.get(path) ?? [])
122
+ .toSorted(byPosition)
123
+ .map((test) => nameOf(test, path)),
124
+ ]),
125
+ ...uncovered.toSorted(byPosition).map((test) => nameOf(test, null)),
126
+ ];
127
+ };
128
+
129
+ const summarize = ({
130
+ groups,
131
+ omitted,
132
+ readingOrder,
133
+ displayOrder,
134
+ every,
135
+ }: SummarizeInput): OverviewWorkspace[] => {
136
+ const groupNames = [
137
+ ...new Set([
138
+ ...(displayOrder?.groups ?? []).map(({ name }) => name),
139
+ ...groups.map(({ name }) => name),
140
+ ]),
141
+ ];
142
+ const pathsByWorkspace = new Map<string, string[]>(
143
+ groupNames.map((name) => [name, []]),
144
+ );
145
+ for (const { name, paths } of groups) {
146
+ pathsByWorkspace.get(name)!.push(...paths);
147
+ }
148
+ for (const { path } of omitted) {
149
+ const name = workspaceOf(path, groupNames);
150
+ pathsByWorkspace.set(name, [...(pathsByWorkspace.get(name) ?? []), path]);
151
+ }
152
+ const readingFiles = new Map(
153
+ (readingOrder?.groups ?? []).map(({ name, files }) => [name, files]),
154
+ );
155
+ const position = new Map(
156
+ displayedPaths(groups, displayOrder).map((path, index) => [path, index]),
157
+ );
158
+ const byPosition = (left: string, right: string): number =>
159
+ (position.get(left) ?? Infinity) - (position.get(right) ?? Infinity) || 0;
160
+ const entries = new Set(
161
+ (readingOrder?.groups ?? []).flatMap(({ files }) =>
162
+ files.filter(({ entry }) => entry).map(({ path }) => path),
163
+ ),
164
+ );
165
+ const readSubjects = new Map(
166
+ (readingOrder?.groups ?? []).flatMap(({ files }) =>
167
+ files.flatMap(({ path, attached }) =>
168
+ attached.map((test) => [test, path] as const),
169
+ ),
170
+ ),
171
+ );
172
+ const nameOf = (path: string, subject: string | null): OverviewName => ({
173
+ path,
174
+ entry: entries.has(path),
175
+ shown: position.has(path),
176
+ subject,
177
+ });
178
+
179
+ return [...pathsByWorkspace]
180
+ .filter(([, paths]) => paths.length > 0)
181
+ .map(([name, paths]) => {
182
+ const code = paths.filter((path) => !isAttachablePath(path));
183
+ const roots = (readingFiles.get(name) ?? []).filter(({ root }) => root);
184
+ const names = ((): OverviewName[] => {
185
+ if (every) {
186
+ return withTests({ paths, code, readSubjects, byPosition, nameOf });
187
+ }
188
+ const listed = roots.length > 0 ? roots.map(({ path }) => path) : code;
189
+ return listed.toSorted(byPosition).map((path) => nameOf(path, null));
190
+ })();
191
+ return {
192
+ name,
193
+ files: code.length,
194
+ tests: paths.length - code.length,
195
+ names,
196
+ };
197
+ });
198
+ };
199
+
200
+ const relativeTo = (workspace: string, path: string): string =>
201
+ workspace !== "" && path.startsWith(`${workspace}/`)
202
+ ? path.slice(workspace.length + 1)
203
+ : path;
204
+
205
+ /** A test listed under its subject is named from the subject's directory. */
206
+ const labelOf = (
207
+ workspace: string,
208
+ path: string,
209
+ subject: string | null,
210
+ ): string => {
211
+ if (subject === null) return relativeTo(workspace, path);
212
+ const directory = subject.slice(0, subject.lastIndexOf("/"));
213
+ return relativeTo(directory, path);
214
+ };
215
+
216
+ const typescriptFile = /\.[cm]?tsx?$/;
217
+
218
+ function FileIcon({ path, test }: { path: string; test: boolean }): ReactNode {
219
+ if (test) {
220
+ return (
221
+ <Icon icon={<TestTubeRegularIcon />} size={16} className="text-muted" />
222
+ );
223
+ }
224
+ if (typescriptFile.test(path)) {
225
+ return (
226
+ <Icon icon={<FileTsRegularIcon />} size={16} className="text-muted" />
227
+ );
228
+ }
229
+ return <Icon icon={<FileRegularIcon />} size={16} className="text-muted" />;
230
+ }
231
+
232
+ interface OverviewFileProps {
233
+ path: string;
234
+ /** what is shown of the path: from its workspace, or its subject's directory */
235
+ label: string;
236
+ entry: boolean;
237
+ test: boolean;
238
+ indented: boolean;
239
+ /** null: not in the diff below (an omitted file), nothing to go to */
240
+ onPress: (() => void) | null;
241
+ }
242
+
243
+ /** The file's name first, its directory under it, so the names line up. */
244
+ function OverviewFile({
245
+ path,
246
+ label,
247
+ entry,
248
+ test,
249
+ indented,
250
+ onPress,
251
+ }: OverviewFileProps): ReactNode {
252
+ const slash = label.lastIndexOf("/");
253
+ const directory = label.slice(0, Math.max(slash, 0));
254
+ const content = (
255
+ <View className="flex-row items-start gap-xs">
256
+ <View className="pt-[2px]">
257
+ <FileIcon path={path} test={test} />
258
+ </View>
259
+ <View className="min-w-0 shrink gap-[1px]">
260
+ {/* one Text, so "entry" sits on the name's baseline */}
261
+ <Text
262
+ className={
263
+ test ? "text-sm text-muted" : "font-body-bold text-sm text-sharp"
264
+ }
265
+ >
266
+ {label.slice(slash + 1)}
267
+ {entry ? (
268
+ <Text className="font-body text-xs text-muted"> · entry</Text>
269
+ ) : null}
270
+ </Text>
271
+ {directory === "" ? null : (
272
+ <Text className="text-xs text-muted">{directory}</Text>
273
+ )}
274
+ </View>
275
+ </View>
276
+ );
277
+ const frame = indented
278
+ ? "ml-l rounded-sm px-xs py-xxs"
279
+ : "rounded-sm px-xs py-xxs";
280
+ if (onPress === null) return <View className={frame}>{content}</View>;
281
+ return (
282
+ <PressableBox
283
+ variant="soft"
284
+ accent="neutral"
285
+ aria-label={`Go to ${path}`}
286
+ className={frame}
287
+ onPress={onPress}
288
+ >
289
+ {content}
290
+ </PressableBox>
291
+ );
292
+ }
293
+
294
+ const statusNotes: Record<OverviewReadingOrderStatus, string | null> = {
295
+ ready: null,
296
+ none: null,
297
+ reading: "Reading the entry points: files are listed meanwhile.",
298
+ failed: "The entry points could not be read: files are listed instead.",
299
+ };
300
+
301
+ export interface PullRequestDiffOverviewProps {
302
+ /** `PullRequestDiffResponse.groups` of the range shown */
303
+ groups: DiffGroup[];
304
+ /** `PullRequestDiffResponse.omitted`: counted in their workspace */
305
+ omitted: DiffOmittedFile[];
306
+ /** where the roots and entry points come from; null until it is read */
307
+ readingOrder: DiffReadingOrderResponse | null;
308
+ readingOrderStatus: OverviewReadingOrderStatus;
309
+ /** what the touched exports are used by outside the diff; null until it is read */
310
+ impacts?: DiffImpactsResponse | null;
311
+ impactsStatus?: ImpactsStatus;
312
+ /** the reading order when the viewer orders the files by it, null in package order */
313
+ displayOrder: DiffReadingOrderResponse | null;
314
+ /** brings the file into view in the diff below */
315
+ onFilePress: (path: string) => void;
316
+ /**
317
+ * In a column beside the diff, which scrolls on its own: every file, one a
318
+ * line, its tests indented below it, with no cap on their number.
319
+ */
320
+ aside: boolean;
321
+ }
322
+
323
+ /**
324
+ * What the range touches, by workspace: how many files (tests apart), then
325
+ * the names to start reading from, its roots, else its files (every file
326
+ * aside the diff), in the order the diff below shows them, each a link to its
327
+ * file. Static content on the screen ground, with no surface of its own.
328
+ */
329
+ export function PullRequestDiffOverview({
330
+ groups,
331
+ omitted,
332
+ readingOrder,
333
+ readingOrderStatus,
334
+ impacts = null,
335
+ impactsStatus = "none",
336
+ displayOrder,
337
+ onFilePress,
338
+ aside,
339
+ }: PullRequestDiffOverviewProps): ReactNode {
340
+ const workspaces = summarize({
341
+ groups,
342
+ omitted,
343
+ readingOrder,
344
+ displayOrder,
345
+ every: aside,
346
+ });
347
+ if (workspaces.length === 0) return null;
348
+ const totalNames = workspaces.reduce(
349
+ (total, { names }) => total + names.length,
350
+ 0,
351
+ );
352
+ const showNames = aside || totalNames <= maxOverviewNames;
353
+ const note = statusNotes[readingOrderStatus];
354
+
355
+ return (
356
+ <View className="gap-l py-xs" role="region" aria-label="Changes overview">
357
+ <View className="flex-row items-baseline gap-xs">
358
+ <Text className="font-heading-bold text-lg text-sharp">Changes</Text>
359
+ <Text className="text-sm text-muted">
360
+ {`in ${pluralOf(workspaces.length, "workspace")}`}
361
+ </Text>
362
+ </View>
363
+ {workspaces.map(({ name, files, tests, names }) => (
364
+ <View key={name} className="gap-sm">
365
+ <View className="gap-xxs">
366
+ <View className="flex-row items-center gap-xs">
367
+ <Icon
368
+ icon={<PackageDuotoneIcon />}
369
+ size={20}
370
+ className="text-accent"
371
+ />
372
+ <Text className="shrink font-heading-bold text-base text-sharp">
373
+ {name === "" ? "Root" : name}
374
+ </Text>
375
+ </View>
376
+ <Text className="pl-[28px] text-xs text-muted">
377
+ {tests === 0
378
+ ? pluralOf(files, "file")
379
+ : `${pluralOf(files, "file")} · ${pluralOf(tests, "test")}`}
380
+ </Text>
381
+ </View>
382
+ {showNames && names.length > 0 ? (
383
+ <View className={aside ? "gap-xxs" : "flex-row flex-wrap gap-xs"}>
384
+ {names.map(({ path, entry, shown, subject }) => (
385
+ <OverviewFile
386
+ key={path}
387
+ path={path}
388
+ label={labelOf(name, path, subject)}
389
+ entry={entry}
390
+ test={isAttachablePath(path)}
391
+ indented={subject !== null}
392
+ onPress={
393
+ shown
394
+ ? () => {
395
+ onFilePress(path);
396
+ }
397
+ : null
398
+ }
399
+ />
400
+ ))}
401
+ </View>
402
+ ) : null}
403
+ </View>
404
+ ))}
405
+ {showNames ? null : (
406
+ <Text className="text-xs text-muted">
407
+ {`Names are left out past ${String(maxOverviewNames)}.`}
408
+ </Text>
409
+ )}
410
+ {note === null ? null : (
411
+ <Text className="text-xs text-muted">{note}</Text>
412
+ )}
413
+ <PullRequestDiffImpactsSummary impacts={impacts} status={impactsStatus} />
414
+ </View>
415
+ );
416
+ }