@reviewflow/ui 0.0.0-stage → 6.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/LICENSE +15 -0
  2. package/README.md +95 -2
  3. package/package.json +101 -3
  4. package/src/board/bucket-section.tsx +94 -0
  5. package/src/board/dataSource.ts +44 -0
  6. package/src/board/facts.ts +183 -0
  7. package/src/board/index.ts +12 -0
  8. package/src/board/pull-request-board.tsx +239 -0
  9. package/src/board/pull-request-row.tsx +181 -0
  10. package/src/components/branch-diff/diff-themes.ts +6 -0
  11. package/src/components/branch-diff/diff-worker-pool.tsx +12 -0
  12. package/src/components/branch-diff/diff-worker-pool.web.tsx +56 -0
  13. package/src/components/branch-diff/diff-worker.ts +7 -0
  14. package/src/components/branch-diff/use-collapsed-files.ts +49 -0
  15. package/src/components/branch-diff/use-diff-settings.tsx +97 -0
  16. package/src/components/branch-diff/whitespace-button.tsx +32 -0
  17. package/src/components/copyable-code-block.tsx +101 -0
  18. package/src/components/label-pill.tsx +84 -0
  19. package/src/components/link-in-row.ts +6 -0
  20. package/src/components/markdown-suggestion.tsx +60 -0
  21. package/src/components/markdown-text.tsx +1067 -0
  22. package/src/components/pull-request-actions-merge.tsx +341 -0
  23. package/src/components/pull-request-actions-review.tsx +218 -0
  24. package/src/components/pull-request-checks.tsx +86 -0
  25. package/src/components/pull-request-commit-files.ts +143 -0
  26. package/src/components/pull-request-commits.tsx +221 -0
  27. package/src/components/pull-request-conversation-card.tsx +220 -0
  28. package/src/components/pull-request-conversation-edit.tsx +147 -0
  29. package/src/components/pull-request-conversation-event.tsx +267 -0
  30. package/src/components/pull-request-conversation-person.tsx +28 -0
  31. package/src/components/pull-request-conversation-thread.tsx +158 -0
  32. package/src/components/pull-request-diff-annotations.tsx +443 -0
  33. package/src/components/pull-request-diff-collapse.ts +43 -0
  34. package/src/components/pull-request-diff-data.ts +174 -0
  35. package/src/components/pull-request-diff-impacts-summary.tsx +97 -0
  36. package/src/components/pull-request-diff-impacts.ts +45 -0
  37. package/src/components/pull-request-diff-jump.ts +87 -0
  38. package/src/components/pull-request-diff-lines.ts +103 -0
  39. package/src/components/pull-request-diff-overview.tsx +416 -0
  40. package/src/components/pull-request-diff-view.tsx +438 -0
  41. package/src/components/pull-request-diff-viewer.tsx +1374 -0
  42. package/src/components/pull-request-files.tsx +533 -0
  43. package/src/components/pull-request-markdown-input.tsx +91 -0
  44. package/src/components/pull-request-panel.tsx +1270 -0
  45. package/src/components/pull-request-part-notice.tsx +82 -0
  46. package/src/components/pull-request-reactions.tsx +178 -0
  47. package/src/components/pull-request-review.tsx +164 -0
  48. package/src/components/pull-request-revisions.tsx +273 -0
  49. package/src/components/pull-request-screen.tsx +299 -0
  50. package/src/components/pull-request-suggestion-actions.tsx +201 -0
  51. package/src/components/pull-request-timeline.tsx +270 -0
  52. package/src/components/repository-attachment-banner.tsx +42 -0
  53. package/src/components/scroll-parent.ts +50 -0
  54. package/src/css.d.ts +1 -0
  55. package/src/dataSource.ts +242 -0
  56. package/src/editor/alerts.ts +105 -0
  57. package/src/editor/code-editor.native.tsx +25 -0
  58. package/src/editor/code-editor.tsx +112 -0
  59. package/src/editor/codeEditorExtensions.ts +65 -0
  60. package/src/editor/codeHighlight.ts +71 -0
  61. package/src/editor/commands.ts +175 -0
  62. package/src/editor/decorations.ts +425 -0
  63. package/src/editor/editable-file.tsx +224 -0
  64. package/src/editor/emojiCompletion.ts +110 -0
  65. package/src/editor/extensions.ts +149 -0
  66. package/src/editor/frontmatter.ts +76 -0
  67. package/src/editor/inlineFormat.ts +114 -0
  68. package/src/editor/lineTypes.ts +55 -0
  69. package/src/editor/markdown-editor.css +511 -0
  70. package/src/editor/markdown-editor.native.tsx +29 -0
  71. package/src/editor/markdown-editor.tsx +295 -0
  72. package/src/editor/nodeClasses.ts +69 -0
  73. package/src/index.ts +75 -0
  74. package/src/markdown-github/flatten.ts +49 -0
  75. package/src/markdown-github/html-tokens.ts +93 -0
  76. package/src/markdown-github/pair-html.ts +354 -0
  77. package/src/markdown-github/safe-url.ts +15 -0
  78. package/src/markdown-github/table-columns.ts +23 -0
  79. package/src/markdown-github/toggle-task.ts +164 -0
  80. package/src/nativewind-env.d.ts +1 -0
  81. package/src/prStatus.ts +90 -0
  82. package/src/services/PullRequestDraftsProvider.tsx +179 -0
  83. package/src/services/PullRequestReviewDraftProvider.tsx +178 -0
  84. package/src/services/PullRequestSuggestionBatchProvider.tsx +112 -0
  85. package/src/services/emojify.ts +13 -0
  86. package/src/services/errorToMessage.ts +6 -0
  87. package/src/services/expiringDraftStore.ts +102 -0
  88. package/src/services/formatDataAge.ts +12 -0
  89. package/src/services/formatRelativeDate.ts +56 -0
  90. package/src/services/githubImages.tsx +33 -0
  91. package/src/services/isViewerAuthor.ts +7 -0
  92. package/src/slots.tsx +34 -0
@@ -0,0 +1,239 @@
1
+ import type { PullRequestRef, ReviewflowBoardPr } from "@reviewflow/sdk";
2
+ import type { Accent, SVGIconElement } from "alouette";
3
+ import { EmptyState, IconButton, Text, View, WarningMessage } from "alouette";
4
+ import { ArrowsClockwiseRegularIcon } from "alouette-icons/phosphor-icons/ArrowsClockwise";
5
+ import { ConfettiRegularIcon } from "alouette-icons/phosphor-icons/Confetti";
6
+ import { PlugsRegularIcon } from "alouette-icons/phosphor-icons/Plugs";
7
+ import { TrayRegularIcon } from "alouette-icons/phosphor-icons/Tray";
8
+ import type { ReactNode } from "react";
9
+ import { useState } from "react";
10
+ import { errorToMessage } from "../services/errorToMessage.ts";
11
+ import { formatDataAge } from "../services/formatDataAge.ts";
12
+ import { BoardBucketSection } from "./bucket-section.tsx";
13
+ import { useBoardDataSource } from "./dataSource.ts";
14
+ import type { BoardRowInfo } from "./pull-request-row.tsx";
15
+
16
+ /**
17
+ * Pull requests the app lists under reviewflow's own buckets, in the board's
18
+ * layout and row style: those no bucket carries, or all of them while no
19
+ * reviewflow board is stored. A section without rows is not rendered.
20
+ */
21
+ export interface PullRequestBoardExtraSection {
22
+ /** Stable across renders; it is the `bucketKey` {@link BoardRowInfo} reports for the section's rows. */
23
+ key: string;
24
+ title: string;
25
+ rows: ReviewflowBoardPr[];
26
+ /** The title's glyph; a generic pull request glyph without it. */
27
+ icon?: SVGIconElement;
28
+ /** The title's accent; none without it. */
29
+ accent?: Accent;
30
+ }
31
+
32
+ /** The `groupKey` {@link BoardRowInfo} reports for the rows of an extra section. */
33
+ export const extraSectionGroupKey = "extra";
34
+
35
+ export interface PullRequestBoardProps {
36
+ /** Pressing a row; the app routes to its pull request screen. */
37
+ onOpenPullRequest: (ref: PullRequestRef) => void;
38
+ /** Shown when no reviewflow token is configured: the app's "connect reviewflow" action. */
39
+ renderConnect?: () => ReactNode;
40
+ /** Extra content under each row's status line, for what an app knows that reviewflow does not. */
41
+ renderRowExtra?: (row: BoardRowInfo) => ReactNode;
42
+ /**
43
+ * Sections rendered after reviewflow's buckets with the same rows, so
44
+ * `renderRowExtra` and `onOpenPullRequest` apply to them. While any has rows,
45
+ * the board shows them instead of its loading, "No board yet" and "Nothing to
46
+ * review" states, so it also renders with no reviewflow board at all; the
47
+ * "Connect reviewflow" prompt and a read error stay above them.
48
+ */
49
+ extraSections?: PullRequestBoardExtraSection[];
50
+ /** The screen's own actions, at the end of the board's heading whatever the board's state. */
51
+ actions?: ReactNode;
52
+ /** What the refresh button says it does; `Refresh from reviewflow` by default. */
53
+ refreshLabel?: string;
54
+ }
55
+
56
+ function BoardLoading(): ReactNode {
57
+ return (
58
+ <View role="status" aria-label="Loading pull requests" className="gap-xs">
59
+ <View className="skeleton h-[22px] w-[200px]" />
60
+ <View className="skeleton h-[86px]" />
61
+ <View className="skeleton h-[86px]" />
62
+ <View className="skeleton h-[86px]" />
63
+ </View>
64
+ );
65
+ }
66
+
67
+ /**
68
+ * reviewflow's buckets as it sends them — order, titles, totals, a pull request
69
+ * in two buckets shown in both — under their groups, side by side from `lg`.
70
+ * Reads through the {@link BoardDataSource} of its provider and renders no
71
+ * frame: the app's screen owns the page column and the scrolling.
72
+ */
73
+ export function PullRequestBoard({
74
+ onOpenPullRequest,
75
+ renderConnect,
76
+ renderRowExtra,
77
+ extraSections,
78
+ actions,
79
+ refreshLabel = "Refresh from reviewflow",
80
+ }: PullRequestBoardProps): ReactNode {
81
+ const source = useBoardDataSource();
82
+ const board = source.useBoard();
83
+ const status = source.useReviewflowStatus();
84
+ const [refreshing, setRefreshing] = useState(false);
85
+
86
+ const notConfigured = status.value?.configured === false;
87
+ const data = board.value?.board ?? null;
88
+ const busy = refreshing || board.reading;
89
+
90
+ const refresh = (): void => {
91
+ setRefreshing(true);
92
+ source.refresh().finally(() => {
93
+ setRefreshing(false);
94
+ });
95
+ };
96
+
97
+ const groups =
98
+ data?.groups
99
+ .map((group) => ({
100
+ ...group,
101
+ buckets: group.buckets.filter((bucket) => bucket.prs.length > 0),
102
+ }))
103
+ .filter((group) => group.buckets.length > 0) ?? [];
104
+
105
+ const extras = (extraSections ?? []).filter(
106
+ (section) => section.rows.length > 0,
107
+ );
108
+ const extrasBody =
109
+ extras.length === 0 ? null : (
110
+ <View className="gap-l">
111
+ {extras.map((section) => (
112
+ <BoardBucketSection
113
+ key={section.key}
114
+ bucket={{
115
+ key: section.key,
116
+ title: section.title,
117
+ total: section.rows.length,
118
+ prs: section.rows,
119
+ }}
120
+ groupKey={extraSectionGroupKey}
121
+ icon={section.icon}
122
+ accent={section.accent}
123
+ viewer={board.value?.viewer ?? null}
124
+ renderRowExtra={renderRowExtra}
125
+ onOpenPullRequest={onOpenPullRequest}
126
+ />
127
+ ))}
128
+ </View>
129
+ );
130
+
131
+ const body = ((): ReactNode => {
132
+ if (notConfigured) {
133
+ return (
134
+ <View className="gap-xl">
135
+ <View className="items-center gap-m">
136
+ <EmptyState icon={<PlugsRegularIcon />} title="Connect reviewflow">
137
+ Your pull requests are listed here once a reviewflow token is
138
+ saved.
139
+ </EmptyState>
140
+ {renderConnect?.()}
141
+ </View>
142
+ {extrasBody}
143
+ </View>
144
+ );
145
+ }
146
+ if (extrasBody !== null && groups.length === 0) return extrasBody;
147
+ if (board.value === null) {
148
+ if (board.error !== null) return null;
149
+ return <BoardLoading />;
150
+ }
151
+ if (data === null) {
152
+ return (
153
+ <EmptyState icon={<TrayRegularIcon />} title="No board yet">
154
+ reviewflow has not been read yet: refresh to read it.
155
+ </EmptyState>
156
+ );
157
+ }
158
+ if (groups.length === 0) {
159
+ return (
160
+ <EmptyState icon={<ConfettiRegularIcon />} title="Nothing to review">
161
+ Nothing is waiting for you, and none of your pull requests are in
162
+ progress.
163
+ </EmptyState>
164
+ );
165
+ }
166
+ const buckets = (
167
+ <View className="gap-xl lg:flex-row lg:items-start">
168
+ {groups.map((group) => (
169
+ <View key={group.key} className="gap-m lg:min-w-0 lg:flex-1">
170
+ <Text
171
+ role="heading"
172
+ aria-level={2}
173
+ className="font-heading-extrabold text-xl"
174
+ >
175
+ {group.title}
176
+ </Text>
177
+ <View className="gap-l">
178
+ {group.buckets.map((bucket) => (
179
+ <BoardBucketSection
180
+ key={bucket.key}
181
+ bucket={bucket}
182
+ groupKey={group.key}
183
+ viewer={board.value?.viewer ?? null}
184
+ renderRowExtra={renderRowExtra}
185
+ onOpenPullRequest={onOpenPullRequest}
186
+ />
187
+ ))}
188
+ </View>
189
+ </View>
190
+ ))}
191
+ </View>
192
+ );
193
+ return extrasBody === null ? (
194
+ buckets
195
+ ) : (
196
+ <View className="gap-xl">
197
+ {buckets}
198
+ {extrasBody}
199
+ </View>
200
+ );
201
+ })();
202
+
203
+ return (
204
+ <View className="gap-l">
205
+ <View className="flex-row flex-wrap items-center gap-m">
206
+ <Text
207
+ role="heading"
208
+ aria-level={1}
209
+ className="min-w-0 flex-1 font-heading-extrabold text-3xl xl:text-4xl"
210
+ >
211
+ Pull requests
212
+ </Text>
213
+ <View className="flex-row items-center gap-xs">
214
+ {notConfigured || data === null ? null : (
215
+ <Text className="text-xs text-muted">
216
+ {`${board.cached === true ? "Cached, updated" : "Updated"} ${formatDataAge(data.fetchedAt)}`}
217
+ </Text>
218
+ )}
219
+ {notConfigured ? null : (
220
+ <IconButton
221
+ icon={<ArrowsClockwiseRegularIcon />}
222
+ size="sm"
223
+ variant="soft"
224
+ accent="neutral"
225
+ aria-label={refreshLabel}
226
+ disabledReason={busy ? "Refreshing…" : null}
227
+ onPress={refresh}
228
+ />
229
+ )}
230
+ {actions}
231
+ </View>
232
+ </View>
233
+ {notConfigured || board.error === null ? null : (
234
+ <WarningMessage>{errorToMessage(board.error)}</WarningMessage>
235
+ )}
236
+ {body}
237
+ </View>
238
+ );
239
+ }
@@ -0,0 +1,181 @@
1
+ import type { PullRequestRef, ReviewflowBoardPr } from "@reviewflow/sdk";
2
+ import {
3
+ AccentScope,
4
+ ExternalLinkText,
5
+ PressableListItem,
6
+ Text,
7
+ View,
8
+ } from "alouette";
9
+ import { Fragment } from "react";
10
+ import type { ReactNode } from "react";
11
+ import type { GestureResponderEvent } from "react-native";
12
+ import { linkInRow } from "../components/link-in-row.ts";
13
+ import { isSafeUrl } from "../markdown-github/safe-url.ts";
14
+ import {
15
+ formatPrChanges,
16
+ reviewflowPrChangesRequested,
17
+ reviewflowPrFailure,
18
+ reviewflowPrFlowDate,
19
+ reviewflowPrOwners,
20
+ reviewflowPrProgress,
21
+ } from "./facts.ts";
22
+ import type { ReviewflowRowContext } from "./facts.ts";
23
+
24
+ /**
25
+ * Following a link from inside the pressable row must not also press the row.
26
+ */
27
+ const stopRowPress = (event: GestureResponderEvent): void => {
28
+ event.stopPropagation();
29
+ };
30
+
31
+ const metaSeparator = <Text className="text-xs text-muted">·</Text>;
32
+
33
+ const statusSeparator = <Text className="text-sm text-muted">·</Text>;
34
+
35
+ /** What a row's extra slot is told about the row it sits in. */
36
+ export interface BoardRowInfo {
37
+ pr: ReviewflowBoardPr;
38
+ bucketKey: string;
39
+ groupKey: string;
40
+ }
41
+
42
+ export interface PullRequestRowProps {
43
+ row: BoardRowInfo;
44
+ viewer: string | null;
45
+ onOpenPullRequest: (ref: PullRequestRef) => void;
46
+ /** Extra content under the status line (an app's own badges, say). */
47
+ renderRowExtra?: (row: BoardRowInfo) => ReactNode;
48
+ }
49
+
50
+ /**
51
+ * `owner/repo#n`, reviewflow's status links, the size of the change and the
52
+ * flow date. No GitHub link: the row opens the app's pull request screen,
53
+ * which carries Open on GitHub itself.
54
+ */
55
+ function MetaLine({ pr }: { pr: ReviewflowBoardPr }): ReactNode {
56
+ const flowDate = reviewflowPrFlowDate(pr);
57
+
58
+ return (
59
+ <View className="flex-row flex-wrap items-center gap-xs">
60
+ <Text className="font-mono text-xs text-muted">
61
+ {`${pr.ownerRepo}#${String(pr.number)}`}
62
+ </Text>
63
+ {pr.statusLinks.map((link) => (
64
+ <Fragment key={link.name}>
65
+ {metaSeparator}
66
+ {isSafeUrl(link.url) ? (
67
+ <ExternalLinkText
68
+ size="sm"
69
+ icon={null}
70
+ href={link.url}
71
+ text={link.label}
72
+ aria-label={`Open ${link.label}`}
73
+ className={linkInRow}
74
+ onPress={stopRowPress}
75
+ />
76
+ ) : (
77
+ <Text className="text-xs text-muted">{link.label}</Text>
78
+ )}
79
+ </Fragment>
80
+ ))}
81
+ {pr.changes === null ? null : (
82
+ <>
83
+ {metaSeparator}
84
+ <Text className="text-xs text-muted">
85
+ {formatPrChanges(pr.changes)}
86
+ </Text>
87
+ </>
88
+ )}
89
+ {flowDate === null ? null : (
90
+ <>
91
+ {metaSeparator}
92
+ <Text className="text-xs text-muted">{flowDate}</Text>
93
+ </>
94
+ )}
95
+ </View>
96
+ );
97
+ }
98
+
99
+ /**
100
+ * reviewflow's own status line: what blocks the merge in danger, changes
101
+ * requested in warning, the rest muted. Absent when reviewflow has nothing to
102
+ * say, so a quiet pull request keeps a short card.
103
+ */
104
+ function StatusLine({
105
+ pr,
106
+ context,
107
+ }: {
108
+ pr: ReviewflowBoardPr;
109
+ context: ReviewflowRowContext;
110
+ }): ReactNode {
111
+ const failure = reviewflowPrFailure(pr);
112
+ const changesRequested = reviewflowPrChangesRequested(pr, context.viewer);
113
+ const progress = reviewflowPrProgress(pr, context);
114
+ if (failure === null && changesRequested === null && progress === null) {
115
+ return null;
116
+ }
117
+
118
+ return (
119
+ <View className="flex-row flex-wrap items-baseline gap-xs">
120
+ {failure === null ? null : (
121
+ <AccentScope accent="danger">
122
+ <Text className="text-sm font-body-bold text-accent">{failure}</Text>
123
+ </AccentScope>
124
+ )}
125
+ {failure !== null && changesRequested !== null ? statusSeparator : null}
126
+ {changesRequested === null ? null : (
127
+ <AccentScope accent="warning">
128
+ <Text className="text-sm text-accent">{changesRequested}</Text>
129
+ </AccentScope>
130
+ )}
131
+ {(failure !== null || changesRequested !== null) && progress !== null
132
+ ? statusSeparator
133
+ : null}
134
+ {progress === null ? null : (
135
+ <Text className="text-sm text-muted">{progress}</Text>
136
+ )}
137
+ </View>
138
+ );
139
+ }
140
+
141
+ /**
142
+ * One pull request of a reviewflow bucket. Pressing hands its ref to
143
+ * `onOpenPullRequest`; the links of its meta line do not press it.
144
+ */
145
+ export function PullRequestRow({
146
+ row,
147
+ viewer,
148
+ onOpenPullRequest,
149
+ renderRowExtra,
150
+ }: PullRequestRowProps): ReactNode {
151
+ const { pr } = row;
152
+ const context: ReviewflowRowContext = {
153
+ bucketKey: row.bucketKey,
154
+ groupKey: row.groupKey,
155
+ viewer,
156
+ };
157
+ const owners = reviewflowPrOwners(pr, viewer);
158
+ const extra = renderRowExtra?.(row);
159
+
160
+ return (
161
+ <PressableListItem
162
+ role="link"
163
+ aria-label={`Open ${pr.ownerRepo}#${String(pr.number)} ${pr.title}`}
164
+ onPress={() => {
165
+ onOpenPullRequest({ ownerRepo: pr.ownerRepo, number: pr.number });
166
+ }}
167
+ >
168
+ <View className="min-w-0 gap-xxs">
169
+ <MetaLine pr={pr} />
170
+ <View className="flex-row flex-wrap items-baseline gap-xs">
171
+ <Text className="font-body-bold">{pr.title}</Text>
172
+ {owners === null ? null : (
173
+ <Text className="text-sm text-muted">{owners}</Text>
174
+ )}
175
+ </View>
176
+ <StatusLine pr={pr} context={context} />
177
+ {extra === undefined || extra === null ? null : extra}
178
+ </View>
179
+ </PressableListItem>
180
+ );
181
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * The theme list lives in `@reviewflow/sdk`, which a server can import to
3
+ * validate a stored setting; re-exported here so the ui's imports stay put.
4
+ */
5
+ export type { DiffTheme } from "@reviewflow/sdk";
6
+ export { diffThemeNames, diffThemeOptions, diffThemes } from "@reviewflow/sdk";
@@ -0,0 +1,12 @@
1
+ import type { ReactNode } from "react";
2
+ import type { DiffTheme } from "./diff-themes.ts";
3
+
4
+ interface DiffWorkerPoolProps {
5
+ theme: DiffTheme;
6
+ children: ReactNode;
7
+ }
8
+
9
+ /** No web workers outside the web: the diff highlights on the main thread. */
10
+ export function DiffWorkerPool({ children }: DiffWorkerPoolProps): ReactNode {
11
+ return children;
12
+ }
@@ -0,0 +1,56 @@
1
+ import { WorkerPoolContextProvider, useWorkerPool } from "@pierre/diffs/react";
2
+ import type { ReactNode } from "react";
3
+ import { useEffect } from "react";
4
+ import type { DiffTheme } from "./diff-themes.ts";
5
+ import { diffThemeNames } from "./diff-themes.ts";
6
+
7
+ const createDiffWorker = (): Worker =>
8
+ new Worker(new URL("./diff-worker.ts", import.meta.url));
9
+
10
+ interface SyncPoolThemeProps {
11
+ theme: DiffTheme;
12
+ }
13
+
14
+ function SyncPoolTheme({ theme }: SyncPoolThemeProps): null {
15
+ const pool = useWorkerPool();
16
+ useEffect(() => {
17
+ pool
18
+ ?.setRenderOptions({ theme: diffThemeNames(theme) })
19
+ .catch((error: unknown) => {
20
+ console.error("diff worker pool: theme not applied", error);
21
+ });
22
+ }, [pool, theme]);
23
+ return null;
24
+ }
25
+
26
+ interface DiffWorkerPoolProps {
27
+ theme: DiffTheme;
28
+ children: ReactNode;
29
+ }
30
+
31
+ /**
32
+ * Moves shiki highlighting off the main thread, where a large diff froze the
33
+ * page. Metro bundles the worker itself: it rewrites a literal
34
+ * `new Worker(new URL(specifier, import.meta.url))` into a split bundle, so
35
+ * the specifier must stay a string literal in this call.
36
+ *
37
+ * The pool is a module singleton created from the first render's props, and
38
+ * its theme wins over the one a `FileDiff` is given: a theme picked while a
39
+ * diff is open has to be pushed to it. Each file still passes its own theme,
40
+ * which applies if the workers fail and highlighting falls back to the main
41
+ * thread. The last provider to unmount terminates the pool.
42
+ */
43
+ export function DiffWorkerPool({
44
+ theme,
45
+ children,
46
+ }: DiffWorkerPoolProps): ReactNode {
47
+ return (
48
+ <WorkerPoolContextProvider
49
+ poolOptions={{ workerFactory: createDiffWorker }}
50
+ highlighterOptions={{ theme: diffThemeNames(theme) }}
51
+ >
52
+ <SyncPoolTheme theme={theme} />
53
+ {children}
54
+ </WorkerPoolContextProvider>
55
+ );
56
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * The portable entry, which inlines shiki. The plain `worker.js` imports shiki
3
+ * modules the diff viewers import too, and the web export moves those into a
4
+ * shared `__common` chunk that only the page loads: the worker then fails on
5
+ * its first `require`.
6
+ */
7
+ import "@pierre/diffs/worker/worker-portable.js";
@@ -0,0 +1,49 @@
1
+ import { useMemo, useState } from "react";
2
+
3
+ export interface CollapsedFiles {
4
+ isCollapsed: (fileKey: string) => boolean;
5
+ toggle: (fileKey: string) => void;
6
+ expandAll: () => void;
7
+ collapseAll: () => void;
8
+ }
9
+
10
+ interface CollapsedState {
11
+ allCollapsed: boolean;
12
+ /** the files toggled away from `allCollapsed` since it was last set */
13
+ toggled: ReadonlySet<string>;
14
+ }
15
+
16
+ /**
17
+ * Held by the modal, which has the expand-all and collapse-all buttons but
18
+ * not the file list: the viewer parses the patch and is loaded lazily. Stored
19
+ * as a default plus exceptions, so either "all" needs no list of files and a
20
+ * file new to a refreshed diff follows the last "all" pressed.
21
+ */
22
+ export const useCollapsedFiles = (): CollapsedFiles => {
23
+ const [state, setState] = useState<CollapsedState>({
24
+ allCollapsed: false,
25
+ toggled: new Set(),
26
+ });
27
+
28
+ // stable until a press, so the memoized viewer is not rendered again for nothing
29
+ return useMemo(
30
+ () => ({
31
+ isCollapsed: (fileKey) =>
32
+ state.allCollapsed !== state.toggled.has(fileKey),
33
+ toggle: (fileKey) => {
34
+ setState((previous) => {
35
+ const toggled = new Set(previous.toggled);
36
+ if (!toggled.delete(fileKey)) toggled.add(fileKey);
37
+ return { allCollapsed: previous.allCollapsed, toggled };
38
+ });
39
+ },
40
+ expandAll: () => {
41
+ setState({ allCollapsed: false, toggled: new Set() });
42
+ },
43
+ collapseAll: () => {
44
+ setState({ allCollapsed: true, toggled: new Set() });
45
+ },
46
+ }),
47
+ [state],
48
+ );
49
+ };
@@ -0,0 +1,97 @@
1
+ import type { DiffWhitespace } from "@reviewflow/sdk";
2
+ import type { ReactNode } from "react";
3
+ import { createContext, use, useMemo, useState } from "react";
4
+ import type { DiffTheme } from "./diff-themes.ts";
5
+
6
+ /**
7
+ * `packages`: by package, in patch order. `reading` (experimental): packages
8
+ * from the one depending on the others down, files from their entry points
9
+ * down, tests and stories closed below the file they cover.
10
+ */
11
+ export type DiffFileOrder = "packages" | "reading";
12
+
13
+ export const isDiffFileOrder = (value: string): value is DiffFileOrder =>
14
+ value === "packages" || value === "reading";
15
+
16
+ export interface DiffSettings {
17
+ wordWrap: boolean;
18
+ theme: DiffTheme;
19
+ whitespace: DiffWhitespace;
20
+ setWhitespace: (whitespace: DiffWhitespace) => void;
21
+ fileOrder: DiffFileOrder;
22
+ setFileOrder: (fileOrder: DiffFileOrder) => void;
23
+ }
24
+
25
+ const defaults: DiffSettings = {
26
+ wordWrap: true,
27
+ theme: "github",
28
+ whitespace: "show",
29
+ setWhitespace: () => undefined,
30
+ fileOrder: "packages",
31
+ setFileOrder: () => undefined,
32
+ };
33
+
34
+ const DiffSettingsContext = createContext<DiffSettings>(defaults);
35
+
36
+ interface DiffSettingsProviderProps {
37
+ theme?: DiffTheme;
38
+ wordWrap?: boolean;
39
+ /** the whitespace the diff starts with; the toolbar toggle then owns it */
40
+ initialWhitespace?: DiffWhitespace;
41
+ /** the order an app stored; absent, the toolbar toggle owns it for the screen's life */
42
+ fileOrder?: DiffFileOrder;
43
+ /** called by the toolbar toggle, for an app that stores the order */
44
+ onFileOrderChange?: (fileOrder: DiffFileOrder) => void;
45
+ children: ReactNode;
46
+ }
47
+
48
+ /**
49
+ * The diff's display settings. `hide` is `git diff -w`, so it is part of the
50
+ * diff's range and the toolbar toggle writes it here. Held in memory: an app
51
+ * that stores them passes the initial values it loaded.
52
+ */
53
+ export function DiffSettingsProvider({
54
+ theme = defaults.theme,
55
+ wordWrap = defaults.wordWrap,
56
+ initialWhitespace = defaults.whitespace,
57
+ fileOrder: storedFileOrder,
58
+ onFileOrderChange,
59
+ children,
60
+ }: DiffSettingsProviderProps): ReactNode {
61
+ const [whitespace, setWhitespace] = useState(initialWhitespace);
62
+ const [ownFileOrder, setOwnFileOrder] = useState(defaults.fileOrder);
63
+ const fileOrder = storedFileOrder ?? ownFileOrder;
64
+ const value = useMemo(
65
+ () => ({
66
+ theme,
67
+ wordWrap,
68
+ whitespace,
69
+ setWhitespace,
70
+ fileOrder,
71
+ setFileOrder: (next: DiffFileOrder) => {
72
+ setOwnFileOrder(next);
73
+ onFileOrderChange?.(next);
74
+ },
75
+ }),
76
+ [theme, wordWrap, whitespace, fileOrder, onFileOrderChange],
77
+ );
78
+ return <DiffSettingsContext value={value}>{children}</DiffSettingsContext>;
79
+ }
80
+
81
+ export const useDiffSettings = (): DiffSettings => use(DiffSettingsContext);
82
+
83
+ interface WhitespaceToggle {
84
+ whitespace: DiffWhitespace;
85
+ toggle: () => void;
86
+ }
87
+
88
+ /** The diff toolbar's switch between hiding and showing whitespace changes. */
89
+ export const useWhitespaceToggle = (): WhitespaceToggle => {
90
+ const { whitespace, setWhitespace } = useDiffSettings();
91
+ return {
92
+ whitespace,
93
+ toggle: () => {
94
+ setWhitespace(whitespace === "show" ? "hide" : "show");
95
+ },
96
+ };
97
+ };
@@ -0,0 +1,32 @@
1
+ import type { DiffWhitespace } from "@reviewflow/sdk";
2
+ import { IconButton } from "alouette";
3
+ import { ParagraphRegularIcon } from "alouette-icons/phosphor-icons/Paragraph";
4
+ import type { ReactNode } from "react";
5
+
6
+ interface WhitespaceButtonProps {
7
+ whitespace: DiffWhitespace;
8
+ disabledReason?: string | null;
9
+ onToggle: () => void;
10
+ }
11
+
12
+ /** A toggle: brand while whitespace is hidden, neutral like its neighbours otherwise. */
13
+ export function WhitespaceButton({
14
+ whitespace,
15
+ disabledReason,
16
+ onToggle,
17
+ }: WhitespaceButtonProps): ReactNode {
18
+ return (
19
+ <IconButton
20
+ icon={<ParagraphRegularIcon />}
21
+ size="sm"
22
+ accent={whitespace === "hide" ? "brand" : "neutral"}
23
+ disabledReason={disabledReason}
24
+ aria-label={
25
+ whitespace === "hide"
26
+ ? "Show whitespace changes"
27
+ : "Hide whitespace changes"
28
+ }
29
+ onPress={onToggle}
30
+ />
31
+ );
32
+ }