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