@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,82 @@
|
|
|
1
|
+
import {
|
|
2
|
+
IndeterminateCircularProgress,
|
|
3
|
+
IndeterminateLinearProgress,
|
|
4
|
+
Text,
|
|
5
|
+
View,
|
|
6
|
+
WarningMessage,
|
|
7
|
+
} from "alouette";
|
|
8
|
+
import type { ReactNode } from "react";
|
|
9
|
+
import { createContext, use, useCallback, useEffect, useState } from "react";
|
|
10
|
+
|
|
11
|
+
/** Marks one part as being read; returns how to unmark it. */
|
|
12
|
+
type ReportReading = () => () => void;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Where the {@link PullRequestPartReading} below report to, for a single
|
|
16
|
+
* progress bar at the top of the screen instead of one per tab.
|
|
17
|
+
*/
|
|
18
|
+
export const PartReadingScope = createContext<ReportReading | null>(null);
|
|
19
|
+
|
|
20
|
+
/** How many parts below a {@link PartReadingScope} given `report` are being read. */
|
|
21
|
+
export const usePartReadingCount = (): {
|
|
22
|
+
count: number;
|
|
23
|
+
report: ReportReading;
|
|
24
|
+
} => {
|
|
25
|
+
const [count, setCount] = useState(0);
|
|
26
|
+
const report = useCallback(() => {
|
|
27
|
+
setCount((n) => n + 1);
|
|
28
|
+
return () => {
|
|
29
|
+
setCount((n) => n - 1);
|
|
30
|
+
};
|
|
31
|
+
}, []);
|
|
32
|
+
return { count, report };
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
interface PullRequestPartPendingProps {
|
|
36
|
+
error: string | null;
|
|
37
|
+
/** what is read, as the sentence names it: "the timeline", "the checks" */
|
|
38
|
+
what: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** In place of a part GitHub has not answered yet: its failure, else the wait. */
|
|
42
|
+
export function PullRequestPartPending({
|
|
43
|
+
error,
|
|
44
|
+
what,
|
|
45
|
+
}: PullRequestPartPendingProps): ReactNode {
|
|
46
|
+
if (error !== null) return <WarningMessage>{error}</WarningMessage>;
|
|
47
|
+
return (
|
|
48
|
+
<View className="flex-row items-center gap-xs py-sm">
|
|
49
|
+
<IndeterminateCircularProgress loading size="xs" />
|
|
50
|
+
<Text className="text-sm text-muted">{`Reading ${what} from GitHub…`}</Text>
|
|
51
|
+
</View>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Above a part already shown — the daemon's last read, or an answer being
|
|
57
|
+
* read again after an edit — while GitHub is read. Inside the screen, it is
|
|
58
|
+
* shown by the screen's bar instead.
|
|
59
|
+
*/
|
|
60
|
+
export function PullRequestPartReading({
|
|
61
|
+
reading,
|
|
62
|
+
}: {
|
|
63
|
+
reading: boolean;
|
|
64
|
+
}): ReactNode {
|
|
65
|
+
const report = use(PartReadingScope);
|
|
66
|
+
useEffect(() => {
|
|
67
|
+
if (!reading || report === null) return undefined;
|
|
68
|
+
return report();
|
|
69
|
+
}, [reading, report]);
|
|
70
|
+
if (report !== null) return null;
|
|
71
|
+
return <IndeterminateLinearProgress loading={reading} size="xs" />;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** A failed read, above the last answer, which stays. */
|
|
75
|
+
export function PullRequestPartError({
|
|
76
|
+
error,
|
|
77
|
+
}: {
|
|
78
|
+
error: string | null;
|
|
79
|
+
}): ReactNode {
|
|
80
|
+
if (error === null) return null;
|
|
81
|
+
return <WarningMessage>{error}</WarningMessage>;
|
|
82
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { pullRequestReactionContents } from "@reviewflow/sdk";
|
|
2
|
+
import type {
|
|
3
|
+
PullRequestEdit,
|
|
4
|
+
PullRequestReactable,
|
|
5
|
+
PullRequestReaction,
|
|
6
|
+
PullRequestReactionContent,
|
|
7
|
+
} from "@reviewflow/sdk";
|
|
8
|
+
import {
|
|
9
|
+
ActionButton,
|
|
10
|
+
ErrorMessage,
|
|
11
|
+
IconButton,
|
|
12
|
+
Menu,
|
|
13
|
+
MenuItem,
|
|
14
|
+
View,
|
|
15
|
+
} from "alouette";
|
|
16
|
+
import { SmileyRegularIcon } from "alouette-icons/phosphor-icons/Smiley";
|
|
17
|
+
import type { ReactNode } from "react";
|
|
18
|
+
import { useState } from "react";
|
|
19
|
+
import { errorToMessage } from "../services/errorToMessage.ts";
|
|
20
|
+
|
|
21
|
+
const reactionEmoji: Record<PullRequestReactionContent, string> = {
|
|
22
|
+
thumbsUp: "👍",
|
|
23
|
+
thumbsDown: "👎",
|
|
24
|
+
laugh: "😄",
|
|
25
|
+
hooray: "🎉",
|
|
26
|
+
confused: "😕",
|
|
27
|
+
heart: "❤️",
|
|
28
|
+
rocket: "🚀",
|
|
29
|
+
eyes: "👀",
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/** GitHub's own names, the ones its picker shows. */
|
|
33
|
+
const reactionName: Record<PullRequestReactionContent, string> = {
|
|
34
|
+
thumbsUp: "+1",
|
|
35
|
+
thumbsDown: "-1",
|
|
36
|
+
laugh: "laugh",
|
|
37
|
+
hooray: "hooray",
|
|
38
|
+
confused: "confused",
|
|
39
|
+
heart: "heart",
|
|
40
|
+
rocket: "rocket",
|
|
41
|
+
eyes: "eyes",
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
type ToggleReaction = (
|
|
45
|
+
content: PullRequestReactionContent,
|
|
46
|
+
reacted: boolean,
|
|
47
|
+
) => Promise<void>;
|
|
48
|
+
|
|
49
|
+
interface ReactionButtonProps {
|
|
50
|
+
reaction: PullRequestReaction;
|
|
51
|
+
viewerCanReact: boolean;
|
|
52
|
+
toggle: ToggleReaction;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function ReactionButton({
|
|
56
|
+
reaction,
|
|
57
|
+
viewerCanReact,
|
|
58
|
+
toggle,
|
|
59
|
+
}: ReactionButtonProps): ReactNode {
|
|
60
|
+
const { content, count, viewerHasReacted } = reaction;
|
|
61
|
+
const people = count === 1 ? "1 person" : `${String(count)} people`;
|
|
62
|
+
return (
|
|
63
|
+
<ActionButton
|
|
64
|
+
text={`${reactionEmoji[content]} ${String(count)}`}
|
|
65
|
+
aria-label={`${reactionName[content]}: ${people}${viewerHasReacted ? ", you included" : ""}`}
|
|
66
|
+
accent={viewerHasReacted ? "brand" : "neutral"}
|
|
67
|
+
variant="tonal"
|
|
68
|
+
size="sm"
|
|
69
|
+
disabledReason={
|
|
70
|
+
viewerCanReact ? null : "GitHub does not let you react here"
|
|
71
|
+
}
|
|
72
|
+
errorMessageVariant="flat"
|
|
73
|
+
errorToMessage={errorToMessage}
|
|
74
|
+
onPress={async () => {
|
|
75
|
+
await toggle(content, !viewerHasReacted);
|
|
76
|
+
}}
|
|
77
|
+
/>
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
interface ReactionPickerProps {
|
|
82
|
+
reactions: PullRequestReaction[];
|
|
83
|
+
toggle: ToggleReaction;
|
|
84
|
+
onError: (message: string | null) => void;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function ReactionPicker({
|
|
88
|
+
reactions,
|
|
89
|
+
toggle,
|
|
90
|
+
onError,
|
|
91
|
+
}: ReactionPickerProps): ReactNode {
|
|
92
|
+
return (
|
|
93
|
+
<Menu
|
|
94
|
+
label="Reactions"
|
|
95
|
+
// The trigger ends a row that starts at the card's edge: hanging the
|
|
96
|
+
// panel from its end edge would push it off the left of the screen.
|
|
97
|
+
align="start"
|
|
98
|
+
render={(triggerProps) => (
|
|
99
|
+
<IconButton
|
|
100
|
+
icon={<SmileyRegularIcon />}
|
|
101
|
+
aria-label="Add a reaction"
|
|
102
|
+
accent="neutral"
|
|
103
|
+
variant="soft"
|
|
104
|
+
size="sm"
|
|
105
|
+
{...triggerProps}
|
|
106
|
+
/>
|
|
107
|
+
)}
|
|
108
|
+
>
|
|
109
|
+
{pullRequestReactionContents.map((content) => {
|
|
110
|
+
const reacted = reactions.some(
|
|
111
|
+
(reaction) =>
|
|
112
|
+
reaction.content === content && reaction.viewerHasReacted,
|
|
113
|
+
);
|
|
114
|
+
return (
|
|
115
|
+
<MenuItem
|
|
116
|
+
key={content}
|
|
117
|
+
label={`${reactionEmoji[content]} ${reactionName[content]}`}
|
|
118
|
+
description={reacted ? "Yours: press to remove it" : undefined}
|
|
119
|
+
accent={reacted ? "brand" : undefined}
|
|
120
|
+
onPress={() => {
|
|
121
|
+
onError(null);
|
|
122
|
+
toggle(content, !reacted).catch((error: unknown) => {
|
|
123
|
+
onError(errorToMessage(error));
|
|
124
|
+
});
|
|
125
|
+
}}
|
|
126
|
+
/>
|
|
127
|
+
);
|
|
128
|
+
})}
|
|
129
|
+
</Menu>
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
interface PullRequestReactionsProps {
|
|
134
|
+
subject: PullRequestReactable;
|
|
135
|
+
onEdit: (edit: PullRequestEdit) => Promise<void>;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* GitHub's reactions under a description, a comment or a review: a count per
|
|
140
|
+
* emoji someone gave, pressed to add or take back the viewer's own, and the
|
|
141
|
+
* picker for the others. Written on the press, then read back from GitHub.
|
|
142
|
+
*/
|
|
143
|
+
export function PullRequestReactions({
|
|
144
|
+
subject,
|
|
145
|
+
onEdit,
|
|
146
|
+
}: PullRequestReactionsProps): ReactNode {
|
|
147
|
+
const [pickerError, setPickerError] = useState<string | null>(null);
|
|
148
|
+
const { id, reactions, viewerCanReact } = subject;
|
|
149
|
+
if (reactions.length === 0 && !viewerCanReact) return null;
|
|
150
|
+
|
|
151
|
+
const toggle: ToggleReaction = (content, reacted) =>
|
|
152
|
+
onEdit({ kind: "reaction", subjectId: id, content, reacted });
|
|
153
|
+
|
|
154
|
+
return (
|
|
155
|
+
<View className="gap-xs">
|
|
156
|
+
<View className="flex-row flex-wrap items-center gap-xs">
|
|
157
|
+
{reactions.map((reaction) => (
|
|
158
|
+
<ReactionButton
|
|
159
|
+
key={reaction.content}
|
|
160
|
+
reaction={reaction}
|
|
161
|
+
viewerCanReact={viewerCanReact}
|
|
162
|
+
toggle={toggle}
|
|
163
|
+
/>
|
|
164
|
+
))}
|
|
165
|
+
{viewerCanReact ? (
|
|
166
|
+
<ReactionPicker
|
|
167
|
+
reactions={reactions}
|
|
168
|
+
toggle={toggle}
|
|
169
|
+
onError={setPickerError}
|
|
170
|
+
/>
|
|
171
|
+
) : null}
|
|
172
|
+
</View>
|
|
173
|
+
{pickerError === null ? null : (
|
|
174
|
+
<ErrorMessage variant="flat">{pickerError}</ErrorMessage>
|
|
175
|
+
)}
|
|
176
|
+
</View>
|
|
177
|
+
);
|
|
178
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
PullRequestCheck,
|
|
3
|
+
PullRequestDetail,
|
|
4
|
+
PullRequestDetailResponse,
|
|
5
|
+
PullRequestEdit,
|
|
6
|
+
PullRequestRef,
|
|
7
|
+
ReviewflowPrDetail,
|
|
8
|
+
ReviewflowPrResponse,
|
|
9
|
+
} from "@reviewflow/sdk";
|
|
10
|
+
import { checksFromReviewflow } from "@reviewflow/sdk";
|
|
11
|
+
import {
|
|
12
|
+
Disclosure,
|
|
13
|
+
ErrorMessage,
|
|
14
|
+
IndeterminateCircularProgress,
|
|
15
|
+
Text,
|
|
16
|
+
View,
|
|
17
|
+
WarningMessage,
|
|
18
|
+
} from "alouette";
|
|
19
|
+
import type { ReactNode } from "react";
|
|
20
|
+
import type { PullRequestReadState } from "../dataSource.ts";
|
|
21
|
+
import { usePullRequestDataSource } from "../dataSource.ts";
|
|
22
|
+
import { CheckLine } from "./pull-request-checks.tsx";
|
|
23
|
+
import { PullRequestFiles } from "./pull-request-files.tsx";
|
|
24
|
+
import {
|
|
25
|
+
PullRequestDescription,
|
|
26
|
+
PullRequestTimelineEntries,
|
|
27
|
+
conversationCount,
|
|
28
|
+
} from "./pull-request-timeline.tsx";
|
|
29
|
+
|
|
30
|
+
/** A data source without reviewflow judges no check. */
|
|
31
|
+
const useNoReviewflowPr = (
|
|
32
|
+
_ref: PullRequestRef,
|
|
33
|
+
): PullRequestReadState<ReviewflowPrResponse> | null => null;
|
|
34
|
+
|
|
35
|
+
const countText = (count: number, what: string): string =>
|
|
36
|
+
`${String(count)} ${what}`;
|
|
37
|
+
|
|
38
|
+
const blockingSummary = (failed: number, pending: number): string => {
|
|
39
|
+
if (failed === 0) return countText(pending, "pending");
|
|
40
|
+
if (pending === 0) return countText(failed, "failing");
|
|
41
|
+
return `${countText(failed, "failing")}, ${countText(pending, "pending")}`;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/** reviewflow names its failures; their link and description are its stored runs'. */
|
|
45
|
+
const failedChecks = (
|
|
46
|
+
{ checks }: ReviewflowPrDetail,
|
|
47
|
+
at: string,
|
|
48
|
+
): PullRequestCheck[] => {
|
|
49
|
+
const stored =
|
|
50
|
+
checks.checkRuns === null || checks.statuses === null
|
|
51
|
+
? []
|
|
52
|
+
: checksFromReviewflow(
|
|
53
|
+
{ checkRuns: checks.checkRuns, statuses: checks.statuses },
|
|
54
|
+
at,
|
|
55
|
+
).checks;
|
|
56
|
+
return checks.failedNames.map(
|
|
57
|
+
(name) =>
|
|
58
|
+
stored.find((check) => check.name === name) ?? {
|
|
59
|
+
name,
|
|
60
|
+
outcome: "failed",
|
|
61
|
+
running: false,
|
|
62
|
+
description: null,
|
|
63
|
+
url: null,
|
|
64
|
+
},
|
|
65
|
+
);
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* reviewflow's verdict on the checks, not GitHub's raw list: it leaves out
|
|
70
|
+
* the checks allowed to fail, the jobs on hold and its own check, which waits
|
|
71
|
+
* on this very review. Said, not enforced.
|
|
72
|
+
*/
|
|
73
|
+
function ChecksBlocker({
|
|
74
|
+
pullRequest,
|
|
75
|
+
}: {
|
|
76
|
+
pullRequest: PullRequestDetail;
|
|
77
|
+
}): ReactNode {
|
|
78
|
+
const dataSource = usePullRequestDataSource();
|
|
79
|
+
const read = (dataSource.useReviewflowPr ?? useNoReviewflowPr)(pullRequest);
|
|
80
|
+
if (read === null) return null;
|
|
81
|
+
if (read.value === null) {
|
|
82
|
+
if (read.error !== null) {
|
|
83
|
+
return (
|
|
84
|
+
<WarningMessage>
|
|
85
|
+
{`reviewflow could not be read: ${read.error}`}
|
|
86
|
+
</WarningMessage>
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
return (
|
|
90
|
+
<View className="flex-row items-center gap-xs">
|
|
91
|
+
<IndeterminateCircularProgress loading size="xs" />
|
|
92
|
+
<Text className="text-sm text-muted">
|
|
93
|
+
Reading the checks from reviewflow…
|
|
94
|
+
</Text>
|
|
95
|
+
</View>
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
const { pr } = read.value;
|
|
99
|
+
if (pr === null) {
|
|
100
|
+
return (
|
|
101
|
+
<WarningMessage>
|
|
102
|
+
reviewflow holds no copy of this pull request: the checks are not judged
|
|
103
|
+
here.
|
|
104
|
+
</WarningMessage>
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
const { conclusion, running } = pr.checks;
|
|
108
|
+
if (conclusion !== "failed" && conclusion !== "inProgress") return null;
|
|
109
|
+
const failed = failedChecks(pr, pr.updatedAt);
|
|
110
|
+
return (
|
|
111
|
+
<ErrorMessage>
|
|
112
|
+
<View className="gap-xs">
|
|
113
|
+
<Text className="font-body-bold">
|
|
114
|
+
{`The checks block this review: ${blockingSummary(failed.length, running)}.`}
|
|
115
|
+
</Text>
|
|
116
|
+
{failed.map((check) => (
|
|
117
|
+
<CheckLine key={check.name} check={check} />
|
|
118
|
+
))}
|
|
119
|
+
</View>
|
|
120
|
+
</ErrorMessage>
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const useConversationLabel = (pullRequest: PullRequestDetail): string => {
|
|
125
|
+
const { value } = usePullRequestDataSource().usePart(pullRequest, "timeline");
|
|
126
|
+
return value === null
|
|
127
|
+
? "Comments and reviews"
|
|
128
|
+
: `Comments and reviews (${String(conversationCount(value.timeline))})`;
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
export interface PullRequestReviewProps {
|
|
132
|
+
detail: PullRequestDetailResponse;
|
|
133
|
+
/** reads the pull request again once GitHub took a change */
|
|
134
|
+
onEdited: (edit: PullRequestEdit) => Promise<void>;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* The pull request as a reviewer reads it, top to bottom: what the checks
|
|
139
|
+
* still say against it, the description, the conversation folded away, then
|
|
140
|
+
* the files to review.
|
|
141
|
+
*/
|
|
142
|
+
export function PullRequestReview({
|
|
143
|
+
detail,
|
|
144
|
+
onEdited,
|
|
145
|
+
}: PullRequestReviewProps): ReactNode {
|
|
146
|
+
const { pullRequest } = detail;
|
|
147
|
+
const conversationLabel = useConversationLabel(pullRequest);
|
|
148
|
+
return (
|
|
149
|
+
<View className="gap-l">
|
|
150
|
+
<ChecksBlocker pullRequest={pullRequest} />
|
|
151
|
+
<PullRequestDescription detail={detail} onEdited={onEdited} />
|
|
152
|
+
<Disclosure label={conversationLabel}>
|
|
153
|
+
<View className="gap-m">
|
|
154
|
+
<PullRequestTimelineEntries
|
|
155
|
+
conversationOnly
|
|
156
|
+
detail={detail}
|
|
157
|
+
onEdited={onEdited}
|
|
158
|
+
/>
|
|
159
|
+
</View>
|
|
160
|
+
</Disclosure>
|
|
161
|
+
<PullRequestFiles detail={detail} onEdited={onEdited} />
|
|
162
|
+
</View>
|
|
163
|
+
);
|
|
164
|
+
}
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
PullRequestRef,
|
|
3
|
+
PullRequestRevision,
|
|
4
|
+
PullRequestTextLine,
|
|
5
|
+
} from "@reviewflow/sdk";
|
|
6
|
+
import {
|
|
7
|
+
Box,
|
|
8
|
+
Button,
|
|
9
|
+
ExternalLinkText,
|
|
10
|
+
LinkText,
|
|
11
|
+
Modal,
|
|
12
|
+
Text,
|
|
13
|
+
Tooltip,
|
|
14
|
+
View,
|
|
15
|
+
} from "alouette";
|
|
16
|
+
import type { ReactNode } from "react";
|
|
17
|
+
import { useState } from "react";
|
|
18
|
+
import { usePullRequestDataSource } from "../dataSource.ts";
|
|
19
|
+
import { formatDataAge } from "../services/formatDataAge.ts";
|
|
20
|
+
import { linkInRow } from "./link-in-row.ts";
|
|
21
|
+
import { MarkdownText } from "./markdown-text.tsx";
|
|
22
|
+
import { PersonAvatar } from "./pull-request-conversation-person.tsx";
|
|
23
|
+
import { PullRequestPartPending } from "./pull-request-part-notice.tsx";
|
|
24
|
+
|
|
25
|
+
/** Unchanged lines kept on each side of a change; the rest fold into a count. */
|
|
26
|
+
const contextKept = 3;
|
|
27
|
+
|
|
28
|
+
type ChangeRun =
|
|
29
|
+
| { kind: "folded"; count: number; from: number }
|
|
30
|
+
| { kind: PullRequestTextLine["kind"]; text: string; from: number };
|
|
31
|
+
|
|
32
|
+
/** The marker is in the text, so the meaning survives without the colour. */
|
|
33
|
+
const linePrefix = (kind: PullRequestTextLine["kind"]): string => {
|
|
34
|
+
if (kind === "added") return "+ ";
|
|
35
|
+
if (kind === "removed") return "- ";
|
|
36
|
+
return " ";
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const toRuns = (changes: PullRequestTextLine[]): ChangeRun[] => {
|
|
40
|
+
const groups: {
|
|
41
|
+
kind: PullRequestTextLine["kind"];
|
|
42
|
+
lines: string[];
|
|
43
|
+
from: number;
|
|
44
|
+
}[] = [];
|
|
45
|
+
for (const [at, line] of changes.entries()) {
|
|
46
|
+
const open = groups.at(-1);
|
|
47
|
+
if (open?.kind === line.kind) {
|
|
48
|
+
open.lines.push(line.text);
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
groups.push({ kind: line.kind, lines: [line.text], from: at });
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const textOf = (kind: PullRequestTextLine["kind"], lines: string[]): string =>
|
|
55
|
+
lines.map((line) => linePrefix(kind) + line).join("\n");
|
|
56
|
+
|
|
57
|
+
return groups.flatMap((group, index): ChangeRun[] => {
|
|
58
|
+
const { kind, lines, from } = group;
|
|
59
|
+
if (kind !== "context") return [{ kind, text: textOf(kind, lines), from }];
|
|
60
|
+
const head = index === 0 ? 0 : contextKept;
|
|
61
|
+
const tail = index === groups.length - 1 ? 0 : contextKept;
|
|
62
|
+
if (lines.length <= head + tail + 1) {
|
|
63
|
+
return [{ kind, text: textOf(kind, lines), from }];
|
|
64
|
+
}
|
|
65
|
+
const runs: ChangeRun[] = [];
|
|
66
|
+
if (head > 0) {
|
|
67
|
+
runs.push({ kind, text: textOf(kind, lines.slice(0, head)), from });
|
|
68
|
+
}
|
|
69
|
+
runs.push({
|
|
70
|
+
kind: "folded",
|
|
71
|
+
count: lines.length - head - tail,
|
|
72
|
+
from: from + head,
|
|
73
|
+
});
|
|
74
|
+
if (tail > 0) {
|
|
75
|
+
runs.push({
|
|
76
|
+
kind,
|
|
77
|
+
text: textOf(kind, lines.slice(-tail)),
|
|
78
|
+
from: from + lines.length - tail,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
return runs;
|
|
82
|
+
});
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
function ChangeRunText({ run }: { run: ChangeRun }): ReactNode {
|
|
86
|
+
if (run.kind === "folded") {
|
|
87
|
+
return (
|
|
88
|
+
<Text className="py-xxs text-xs text-muted">
|
|
89
|
+
{`⋯ ${String(run.count)} unchanged ${run.count === 1 ? "line" : "lines"}`}
|
|
90
|
+
</Text>
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
if (run.kind === "added") {
|
|
94
|
+
return (
|
|
95
|
+
<Text
|
|
96
|
+
accent="success"
|
|
97
|
+
className="select-auto font-mono text-xs text-accent web:whitespace-pre-wrap"
|
|
98
|
+
>
|
|
99
|
+
{run.text}
|
|
100
|
+
</Text>
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
if (run.kind === "removed") {
|
|
104
|
+
return (
|
|
105
|
+
<Text
|
|
106
|
+
accent="danger"
|
|
107
|
+
className="select-auto font-mono text-xs text-accent web:whitespace-pre-wrap"
|
|
108
|
+
>
|
|
109
|
+
{run.text}
|
|
110
|
+
</Text>
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
return (
|
|
114
|
+
<Text className="select-auto font-mono text-xs text-sharp web:whitespace-pre-wrap">
|
|
115
|
+
{run.text}
|
|
116
|
+
</Text>
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Wrapped rather than scrolled: a description is prose, one paragraph a line. */
|
|
121
|
+
function ChangesBlock({
|
|
122
|
+
changes,
|
|
123
|
+
}: {
|
|
124
|
+
changes: PullRequestTextLine[];
|
|
125
|
+
}): ReactNode {
|
|
126
|
+
return (
|
|
127
|
+
<Box className="surface surface-sm lowered">
|
|
128
|
+
{toRuns(changes).map((run) => (
|
|
129
|
+
<ChangeRunText key={run.from} run={run} />
|
|
130
|
+
))}
|
|
131
|
+
</Box>
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function RevisionContent({
|
|
136
|
+
revision,
|
|
137
|
+
}: {
|
|
138
|
+
revision: PullRequestRevision;
|
|
139
|
+
}): ReactNode {
|
|
140
|
+
if (revision.body === null) {
|
|
141
|
+
return (
|
|
142
|
+
<Text className="text-sm text-muted">
|
|
143
|
+
{`Deleted from the history by ${revision.deletedBy ?? "ghost"}.`}
|
|
144
|
+
</Text>
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
if (revision.changes !== null) {
|
|
148
|
+
return <ChangesBlock changes={revision.changes} />;
|
|
149
|
+
}
|
|
150
|
+
if (revision.body.trim() === "") {
|
|
151
|
+
return <Text className="text-sm text-muted">Empty.</Text>;
|
|
152
|
+
}
|
|
153
|
+
return <MarkdownText github text={revision.body} />;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function RevisionView({
|
|
157
|
+
revision,
|
|
158
|
+
}: {
|
|
159
|
+
revision: PullRequestRevision;
|
|
160
|
+
}): ReactNode {
|
|
161
|
+
return (
|
|
162
|
+
<View className="gap-xs">
|
|
163
|
+
<View className="flex-row flex-wrap items-center gap-xs">
|
|
164
|
+
<PersonAvatar person={revision.editor} />
|
|
165
|
+
<Text className="text-sm font-body-bold">
|
|
166
|
+
{revision.editor?.login ?? "ghost"}
|
|
167
|
+
</Text>
|
|
168
|
+
<Text className="text-sm text-muted">
|
|
169
|
+
{`${revision.original ? "created" : "edited"} ${formatDataAge(revision.editedAt)}`}
|
|
170
|
+
</Text>
|
|
171
|
+
</View>
|
|
172
|
+
<RevisionContent revision={revision} />
|
|
173
|
+
</View>
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
interface RevisionListProps {
|
|
178
|
+
pullRequest: PullRequestRef;
|
|
179
|
+
subjectId: string;
|
|
180
|
+
url: string;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function RevisionList({
|
|
184
|
+
pullRequest,
|
|
185
|
+
subjectId,
|
|
186
|
+
url,
|
|
187
|
+
}: RevisionListProps): ReactNode {
|
|
188
|
+
const { value: revisions, error } = usePullRequestDataSource().useRevisions(
|
|
189
|
+
pullRequest,
|
|
190
|
+
subjectId,
|
|
191
|
+
);
|
|
192
|
+
if (revisions === null) {
|
|
193
|
+
return <PullRequestPartPending error={error} what="the edit history" />;
|
|
194
|
+
}
|
|
195
|
+
const unlisted = revisions.revisionsTotal - revisions.revisions.length;
|
|
196
|
+
return (
|
|
197
|
+
<View className="gap-l">
|
|
198
|
+
{revisions.revisions.map((revision, index) => (
|
|
199
|
+
<RevisionView
|
|
200
|
+
key={`${revision.editedAt}:${String(index)}`}
|
|
201
|
+
revision={revision}
|
|
202
|
+
/>
|
|
203
|
+
))}
|
|
204
|
+
{unlisted > 0 ? (
|
|
205
|
+
<ExternalLinkText
|
|
206
|
+
size="sm"
|
|
207
|
+
href={url}
|
|
208
|
+
text={`${String(unlisted)} older ${unlisted === 1 ? "revision is" : "revisions are"} only on GitHub`}
|
|
209
|
+
/>
|
|
210
|
+
) : null}
|
|
211
|
+
</View>
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
interface EditedLinkProps {
|
|
216
|
+
pullRequest: PullRequestRef;
|
|
217
|
+
/** the node id of the description, comment or review */
|
|
218
|
+
subjectId: string;
|
|
219
|
+
lastEditedAt: string | null;
|
|
220
|
+
/** where GitHub shows it, for the revisions past the first page */
|
|
221
|
+
url: string;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* GitHub's "edited" beside a date: nothing when the text was never edited,
|
|
226
|
+
* else a link opening its history, read from GitHub only once opened.
|
|
227
|
+
*/
|
|
228
|
+
export function EditedLink({
|
|
229
|
+
pullRequest,
|
|
230
|
+
subjectId,
|
|
231
|
+
lastEditedAt,
|
|
232
|
+
url,
|
|
233
|
+
}: EditedLinkProps): ReactNode {
|
|
234
|
+
const [open, setOpen] = useState(false);
|
|
235
|
+
if (lastEditedAt === null) return null;
|
|
236
|
+
const close = (): void => {
|
|
237
|
+
setOpen(false);
|
|
238
|
+
};
|
|
239
|
+
return (
|
|
240
|
+
<>
|
|
241
|
+
<Tooltip
|
|
242
|
+
content={`Edited ${formatDataAge(lastEditedAt)} — show the history`}
|
|
243
|
+
render={(triggerProps) => (
|
|
244
|
+
<LinkText
|
|
245
|
+
{...triggerProps}
|
|
246
|
+
size="sm"
|
|
247
|
+
accent="neutral"
|
|
248
|
+
text="edited"
|
|
249
|
+
className={linkInRow}
|
|
250
|
+
onPress={() => {
|
|
251
|
+
setOpen(true);
|
|
252
|
+
}}
|
|
253
|
+
/>
|
|
254
|
+
)}
|
|
255
|
+
/>
|
|
256
|
+
<Modal
|
|
257
|
+
visible={open}
|
|
258
|
+
size="lg"
|
|
259
|
+
title="Edit history"
|
|
260
|
+
footer={<Button text="Close" onPress={close} />}
|
|
261
|
+
onClose={close}
|
|
262
|
+
>
|
|
263
|
+
{open ? (
|
|
264
|
+
<RevisionList
|
|
265
|
+
pullRequest={pullRequest}
|
|
266
|
+
subjectId={subjectId}
|
|
267
|
+
url={url}
|
|
268
|
+
/>
|
|
269
|
+
) : null}
|
|
270
|
+
</Modal>
|
|
271
|
+
</>
|
|
272
|
+
);
|
|
273
|
+
}
|