@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,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
+ }