@reviewflow/ui 6.25.0 → 6.27.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.
@@ -0,0 +1,172 @@
1
+ import type { PullRequestCommit } from "@reviewflow/sdk";
2
+ import { Button, Code, PressableListItem, Text, View } from "alouette";
3
+ import { ArrowLeftRegularIcon } from "alouette-icons/phosphor-icons/ArrowLeft";
4
+ import { CaretDownRegularIcon } from "alouette-icons/phosphor-icons/CaretDown";
5
+ import { CaretUpRegularIcon } from "alouette-icons/phosphor-icons/CaretUp";
6
+ import type { ReactNode } from "react";
7
+ import { formatDataAge } from "../services/formatDataAge.ts";
8
+ import { PersonAvatar } from "./pull-request-conversation-person.tsx";
9
+ import { TooltipIconButton } from "./tooltip-icon-button.tsx";
10
+
11
+ const shortSha = (oid: string): string => oid.slice(0, 7);
12
+
13
+ const dayOf = (commit: PullRequestCommit): string =>
14
+ new Date(commit.committedAt).toLocaleDateString([], {
15
+ year: "numeric",
16
+ month: "long",
17
+ day: "numeric",
18
+ });
19
+
20
+ interface CommitRowProps<Commit extends PullRequestCommit> {
21
+ commit: Commit;
22
+ onOpen: () => void;
23
+ extra: ReactNode;
24
+ }
25
+
26
+ /** A link, as a board row is: it opens the commit's own diff. */
27
+ function CommitRow<Commit extends PullRequestCommit>({
28
+ commit,
29
+ onOpen,
30
+ extra,
31
+ }: CommitRowProps<Commit>): ReactNode {
32
+ const login = commit.author?.login ?? "unknown";
33
+
34
+ return (
35
+ <PressableListItem
36
+ role="link"
37
+ aria-label={`Show the changes of ${commit.headline}`}
38
+ onPress={onOpen}
39
+ >
40
+ <View className="flex-row items-center gap-sm">
41
+ <PersonAvatar person={commit.author} />
42
+ <View className="min-w-0 flex-1">
43
+ <Text className="text-sm font-body-bold" numberOfLines={1}>
44
+ {commit.headline}
45
+ </Text>
46
+ <Text className="text-xs text-muted" numberOfLines={1}>
47
+ {`${login} committed ${formatDataAge(commit.committedAt)}`}
48
+ </Text>
49
+ </View>
50
+ {extra}
51
+ <Code className="text-xs">{shortSha(commit.oid)}</Code>
52
+ </View>
53
+ </PressableListItem>
54
+ );
55
+ }
56
+
57
+ export interface CommitListProps<Commit extends PullRequestCommit> {
58
+ /** in the order to show them; a day heads each run of commits made that day */
59
+ commits: Commit[];
60
+ onOpen: (oid: string) => void;
61
+ /** beside the commit's short sha, e.g. a badge */
62
+ renderExtra?: (commit: Commit) => ReactNode;
63
+ }
64
+
65
+ /** Commits grouped by day as on GitHub; pressing one opens its own diff. */
66
+ export function CommitList<Commit extends PullRequestCommit>({
67
+ commits,
68
+ onOpen,
69
+ renderExtra,
70
+ }: CommitListProps<Commit>): ReactNode {
71
+ const days = new Map<string, Commit[]>();
72
+ for (const commit of commits) {
73
+ const day = dayOf(commit);
74
+ const ofDay = days.get(day);
75
+ if (ofDay === undefined) days.set(day, [commit]);
76
+ else ofDay.push(commit);
77
+ }
78
+
79
+ return (
80
+ <View className="gap-m">
81
+ {[...days].map(([day, ofDay]) => (
82
+ <View key={day} className="gap-xs">
83
+ <Text className="text-xs font-body-bold text-muted">{day}</Text>
84
+ {ofDay.map((commit) => (
85
+ <CommitRow
86
+ key={commit.oid}
87
+ commit={commit}
88
+ extra={renderExtra?.(commit)}
89
+ onOpen={() => {
90
+ onOpen(commit.oid);
91
+ }}
92
+ />
93
+ ))}
94
+ </View>
95
+ ))}
96
+ </View>
97
+ );
98
+ }
99
+
100
+ /** Steps to the neighbouring commits of the list; null at its end. */
101
+ export interface CommitSteps {
102
+ onNewer: (() => void) | null;
103
+ onOlder: (() => void) | null;
104
+ }
105
+
106
+ function CommitStepButtons({ onNewer, onOlder }: CommitSteps): ReactNode {
107
+ return (
108
+ <View className="flex-row items-center gap-xxs">
109
+ <TooltipIconButton
110
+ icon={<CaretUpRegularIcon />}
111
+ size="sm"
112
+ aria-label="Newer commit"
113
+ disabledReason={onNewer === null ? "This is the newest commit" : null}
114
+ onPress={() => {
115
+ onNewer?.();
116
+ }}
117
+ />
118
+ <TooltipIconButton
119
+ icon={<CaretDownRegularIcon />}
120
+ size="sm"
121
+ aria-label="Older commit"
122
+ disabledReason={onOlder === null ? "This is the oldest commit" : null}
123
+ onPress={() => {
124
+ onOlder?.();
125
+ }}
126
+ />
127
+ </View>
128
+ );
129
+ }
130
+
131
+ interface CommitHeaderProps {
132
+ commit: PullRequestCommit;
133
+ onBack: () => void;
134
+ /** absent: the commit cannot be stepped from */
135
+ steps?: CommitSteps;
136
+ /** beside the commit's short sha, e.g. a badge */
137
+ extra?: ReactNode;
138
+ }
139
+
140
+ /** Above a commit's own diff: the way back to the list, its message and its sha. */
141
+ export function CommitHeader({
142
+ commit,
143
+ onBack,
144
+ steps,
145
+ extra,
146
+ }: CommitHeaderProps): ReactNode {
147
+ return (
148
+ <View className="gap-m">
149
+ <View className="flex-row flex-wrap items-center gap-sm">
150
+ <Button
151
+ text="Back to commits"
152
+ icon={<ArrowLeftRegularIcon />}
153
+ accent="neutral"
154
+ variant="tonal"
155
+ size="sm"
156
+ onPress={onBack}
157
+ />
158
+ <Text className="min-w-0 flex-1 text-sm font-body-bold">
159
+ {commit.headline}
160
+ </Text>
161
+ {extra}
162
+ <Code className="text-xs">{shortSha(commit.oid)}</Code>
163
+ {steps === undefined ? null : <CommitStepButtons {...steps} />}
164
+ </View>
165
+ {commit.body.trim() === "" ? null : (
166
+ <Text className="select-auto font-mono text-xs text-muted">
167
+ {commit.body}
168
+ </Text>
169
+ )}
170
+ </View>
171
+ );
172
+ }
@@ -0,0 +1 @@
1
+ export { DiffViewer as default } from "./pull-request-diff-viewer.tsx";
@@ -0,0 +1,32 @@
1
+ import type { DiffOmittedFile } from "@reviewflow/sdk";
2
+ import { Code, Text, View } from "alouette";
3
+ import type { ReactNode } from "react";
4
+
5
+ const omissionLabel = (reason: DiffOmittedFile["reason"]): string => {
6
+ if (reason === "binary") return "binary";
7
+ if (reason === "tooLarge") return "too large";
8
+ if (reason === "whitespace") return "only whitespace changed";
9
+ return "over the diff budget";
10
+ };
11
+
12
+ interface OmittedFilesProps {
13
+ omitted: DiffOmittedFile[];
14
+ }
15
+
16
+ /** The files a diff left out and why; nothing when it left none out. */
17
+ export function OmittedFiles({ omitted }: OmittedFilesProps): ReactNode {
18
+ if (omitted.length === 0) return null;
19
+ return (
20
+ <View className="gap-xxs">
21
+ <Text className="text-xs font-body-bold text-muted">
22
+ {`Left out (${String(omitted.length)})`}
23
+ </Text>
24
+ {omitted.map(({ path, reason }) => (
25
+ <View key={path} className="flex-row flex-wrap items-center gap-xs">
26
+ <Code className="text-xs">{path}</Code>
27
+ <Text className="text-xs text-muted">{omissionLabel(reason)}</Text>
28
+ </View>
29
+ ))}
30
+ </View>
31
+ );
32
+ }
@@ -4,20 +4,11 @@ import type {
4
4
  PullRequestEdit,
5
5
  } from "@reviewflow/sdk";
6
6
  import { pluralOf } from "@reviewflow/sdk";
7
- import {
8
- Button,
9
- Code,
10
- InfoMessage,
11
- PressableListItem,
12
- Text,
13
- View,
14
- } from "alouette";
15
- import { ArrowLeftRegularIcon } from "alouette-icons/phosphor-icons/ArrowLeft";
7
+ import { InfoMessage, Text, View } from "alouette";
16
8
  import type { ReactNode } from "react";
17
9
  import { useState } from "react";
18
10
  import { usePullRequestDataSource } from "../dataSource.ts";
19
- import { formatDataAge } from "../services/formatDataAge.ts";
20
- import { PersonAvatar } from "./pull-request-conversation-person.tsx";
11
+ import { CommitHeader, CommitList } from "./commit-list.tsx";
21
12
  import { PullRequestDiffView } from "./pull-request-diff-view.tsx";
22
13
  import {
23
14
  PullRequestPartError,
@@ -37,46 +28,6 @@ export interface PullRequestCommitsProps {
37
28
  onOpenCommitChange?: (oid: string | null) => void;
38
29
  }
39
30
 
40
- const shortSha = (oid: string): string => oid.slice(0, 7);
41
-
42
- const dayOf = (commit: PullRequestCommit): string =>
43
- new Date(commit.committedAt).toLocaleDateString([], {
44
- year: "numeric",
45
- month: "long",
46
- day: "numeric",
47
- });
48
-
49
- interface CommitRowProps {
50
- commit: PullRequestCommit;
51
- onOpen: () => void;
52
- }
53
-
54
- /** A link, as a board row is: it opens the commit's own page, which a url keeps. */
55
- function CommitRow({ commit, onOpen }: CommitRowProps): ReactNode {
56
- const login = commit.author?.login ?? "unknown";
57
-
58
- return (
59
- <PressableListItem
60
- role="link"
61
- aria-label={`Show the changes of ${commit.headline}`}
62
- onPress={onOpen}
63
- >
64
- <View className="flex-row items-center gap-sm">
65
- <PersonAvatar person={commit.author} />
66
- <View className="min-w-0 flex-1">
67
- <Text className="text-sm font-body-bold" numberOfLines={1}>
68
- {commit.headline}
69
- </Text>
70
- <Text className="text-xs text-muted" numberOfLines={1}>
71
- {`${login} committed ${formatDataAge(commit.committedAt)}`}
72
- </Text>
73
- </View>
74
- <Code className="text-xs">{shortSha(commit.oid)}</Code>
75
- </View>
76
- </PressableListItem>
77
- );
78
- }
79
-
80
31
  interface CommitDiffProps {
81
32
  detail: PullRequestDetailResponse;
82
33
  commit: PullRequestCommit;
@@ -99,25 +50,7 @@ function CommitDiff({
99
50
  const dataSource = usePullRequestDataSource();
100
51
  return (
101
52
  <View className="gap-m">
102
- <View className="flex-row flex-wrap items-center gap-sm">
103
- <Button
104
- text="Back to commits"
105
- icon={<ArrowLeftRegularIcon />}
106
- accent="neutral"
107
- variant="tonal"
108
- size="sm"
109
- onPress={onBack}
110
- />
111
- <Text className="min-w-0 flex-1 text-sm font-body-bold">
112
- {commit.headline}
113
- </Text>
114
- <Code className="text-xs">{shortSha(commit.oid)}</Code>
115
- </View>
116
- {commit.body.trim() === "" ? null : (
117
- <Text className="select-auto font-mono text-xs text-muted">
118
- {commit.body}
119
- </Text>
120
- )}
53
+ <CommitHeader commit={commit} onBack={onBack} />
121
54
  {commit.parentOid === null ? (
122
55
  <InfoMessage>
123
56
  This is a root commit with no parent: its changes cannot be shown.
@@ -185,33 +118,13 @@ export function PullRequestCommits({
185
118
  );
186
119
  }
187
120
 
188
- const days = new Map<string, PullRequestCommit[]>();
189
- for (const commit of listed) {
190
- const day = dayOf(commit);
191
- const ofDay = days.get(day);
192
- if (ofDay === undefined) days.set(day, [commit]);
193
- else ofDay.push(commit);
194
- }
195
121
  const unlisted = pullRequest.commitsTotal - listed.length;
196
122
 
197
123
  return (
198
124
  <View className="gap-m">
199
125
  <PullRequestPartReading reading={reading} />
200
126
  <PullRequestPartError error={error} />
201
- {[...days].map(([day, commits]) => (
202
- <View key={day} className="gap-xs">
203
- <Text className="text-xs font-body-bold text-muted">{day}</Text>
204
- {commits.map((commit) => (
205
- <CommitRow
206
- key={commit.oid}
207
- commit={commit}
208
- onOpen={() => {
209
- setOpenOid(commit.oid);
210
- }}
211
- />
212
- ))}
213
- </View>
214
- ))}
127
+ <CommitList commits={listed} onOpen={setOpenOid} />
215
128
  {unlisted > 0 ? (
216
129
  <Text className="text-xs text-muted">
217
130
  {`${pluralOf(unlisted, "more commit")} not listed`}
@@ -1,5 +1,4 @@
1
1
  import type {
2
- DiffOmittedFile,
3
2
  DiffStep,
4
3
  DiffWhitespace,
5
4
  PullRequestDiffResponse,
@@ -41,6 +40,7 @@ import {
41
40
  useWhitespaceToggle,
42
41
  } from "./branch-diff/use-diff-settings.tsx";
43
42
  import { WhitespaceButton } from "./branch-diff/whitespace-button.tsx";
43
+ import { OmittedFiles } from "./omitted-files.tsx";
44
44
  import { usePullRequestCollapse } from "./pull-request-diff-collapse.ts";
45
45
  import type { PullRequestDiffTarget } from "./pull-request-diff-data.ts";
46
46
  import {
@@ -77,30 +77,6 @@ const diffTreeAvailable = Platform.OS === "web";
77
77
 
78
78
  type AsideView = "list" | "tree";
79
79
 
80
- const omissionLabel = (reason: DiffOmittedFile["reason"]): string => {
81
- if (reason === "binary") return "binary";
82
- if (reason === "tooLarge") return "too large";
83
- if (reason === "whitespace") return "only whitespace changed";
84
- return "over the diff budget";
85
- };
86
-
87
- function OmittedFiles({ omitted }: { omitted: DiffOmittedFile[] }): ReactNode {
88
- if (omitted.length === 0) return null;
89
- return (
90
- <View className="gap-xxs">
91
- <Text className="text-xs font-body-bold text-muted">
92
- {`Left out (${String(omitted.length)})`}
93
- </Text>
94
- {omitted.map(({ path, reason }) => (
95
- <View key={path} className="flex-row flex-wrap items-center gap-xs">
96
- <Code className="text-xs">{path}</Code>
97
- <Text className="text-xs text-muted">{omissionLabel(reason)}</Text>
98
- </View>
99
- ))}
100
- </View>
101
- );
102
- }
103
-
104
80
  interface DiffSourceProps {
105
81
  diff: PullRequestDiffResponse;
106
82
  asked: DiffWhitespace;
@@ -49,6 +49,10 @@ import type { PullRequestDataSource } from "../dataSource.ts";
49
49
  import { usePullRequestDataSource } from "../dataSource.ts";
50
50
  import type { PendingComment } from "../services/PullRequestReviewDraftProvider.tsx";
51
51
  import { usePullRequestReviewDraft } from "../services/PullRequestReviewDraftProvider.tsx";
52
+ import {
53
+ DiffChangeMark,
54
+ hidePierreChangeIcon,
55
+ } from "./branch-diff/diff-change-mark.tsx";
52
56
  import { diffFontCSS } from "./branch-diff/diff-font.ts";
53
57
  import type { DiffTheme } from "./branch-diff/diff-themes.ts";
54
58
  import {
@@ -736,26 +740,31 @@ function DiffFile({
736
740
  overflow: wordWrap ? "wrap" : "scroll",
737
741
  collapsed,
738
742
  stickyHeader: true,
739
- unsafeCSS: `${stickyHeaderBelow(stickyTop)} ${headerInset} ${roundedFile} ${pierreScrollbars} ${diffFontCSS(fontFamily)} ${selectionCSS(theme, themeType)} ${changeBackgroundCSS(theme)} ${headerHighlight(highlight, collapsed)}`,
743
+ unsafeCSS: `${stickyHeaderBelow(stickyTop)} ${headerInset} ${roundedFile} ${pierreScrollbars} ${hidePierreChangeIcon} ${diffFontCSS(fontFamily)} ${selectionCSS(theme, themeType)} ${changeBackgroundCSS(theme)} ${headerHighlight(highlight, collapsed)}`,
740
744
  loadDiffFiles: loadFiles,
741
745
  enableGutterUtility: commentable,
742
746
  onGutterUtilityClick: commentable ? onGutterUtilityClick : undefined,
743
747
  }}
744
748
  renderHeaderPrefix={() => (
745
- <IconButton
746
- icon={
747
- collapsed ? <CaretRightRegularIcon /> : <CaretDownRegularIcon />
748
- }
749
- size="sm"
750
- variant="soft"
751
- accent="neutral"
752
- aria-label={collapsed ? `Open ${file.name}` : `Close ${file.name}`}
753
- aria-expanded={!collapsed}
754
- onPress={() => {
755
- revealBeforeClosing();
756
- onToggleCollapsed();
757
- }}
758
- />
749
+ <View className="flex-row items-center gap-xxs">
750
+ <IconButton
751
+ icon={
752
+ collapsed ? <CaretRightRegularIcon /> : <CaretDownRegularIcon />
753
+ }
754
+ size="sm"
755
+ variant="soft"
756
+ accent="neutral"
757
+ aria-label={
758
+ collapsed ? `Open ${file.name}` : `Close ${file.name}`
759
+ }
760
+ aria-expanded={!collapsed}
761
+ onPress={() => {
762
+ revealBeforeClosing();
763
+ onToggleCollapsed();
764
+ }}
765
+ />
766
+ <DiffChangeMark type={file.type} />
767
+ </View>
759
768
  )}
760
769
  renderHeaderMetadata={
761
770
  !entry &&
@@ -964,6 +973,56 @@ function FileGroups({ groups, renderFile }: FileGroupsProps): ReactNode {
964
973
  );
965
974
  }
966
975
 
976
+ interface ReadOnlyFilesProps {
977
+ groups: FileGroup[];
978
+ diffStyle: DiffStyle;
979
+ theme: DiffTheme;
980
+ themeType: "dark" | "light";
981
+ fontFamily: string | null;
982
+ wordWrap: boolean;
983
+ stickyTop: number;
984
+ collapse: PullRequestCollapse;
985
+ loadFiles: FileDiffContentsLoader | undefined;
986
+ impactsOf?: (path: string) => DiffFileImpacts | undefined;
987
+ }
988
+
989
+ function ReadOnlyFiles({
990
+ groups,
991
+ diffStyle,
992
+ theme,
993
+ themeType,
994
+ fontFamily,
995
+ wordWrap,
996
+ stickyTop,
997
+ collapse,
998
+ loadFiles,
999
+ impactsOf,
1000
+ }: ReadOnlyFilesProps): ReactNode {
1001
+ return (
1002
+ <FileGroups
1003
+ groups={groups}
1004
+ renderFile={(file, { entry, attached }) => (
1005
+ <DiffFile
1006
+ file={file}
1007
+ diffStyle={diffStyle}
1008
+ theme={theme}
1009
+ themeType={themeType}
1010
+ fontFamily={fontFamily}
1011
+ wordWrap={wordWrap}
1012
+ stickyTop={stickyTop}
1013
+ loadFiles={loadFiles}
1014
+ entry={entry}
1015
+ impacts={impactsOf?.(file.name)}
1016
+ collapsed={collapse.isCollapsed(file.name, attached)}
1017
+ onToggleCollapsed={() => {
1018
+ collapse.set(file.name, !collapse.isCollapsed(file.name, attached));
1019
+ }}
1020
+ />
1021
+ )}
1022
+ />
1023
+ );
1024
+ }
1025
+
967
1026
  export interface PullRequestDiffReview {
968
1027
  /** the head a comment is anchored on: the diff's, not the header's last read */
969
1028
  headSha: string;
@@ -1345,29 +1404,17 @@ function PullRequestDiffViewer({
1345
1404
  return (
1346
1405
  <DiffWorkerPool theme={theme}>
1347
1406
  {review === undefined ? (
1348
- <FileGroups
1407
+ <ReadOnlyFiles
1349
1408
  groups={fileGroups}
1350
- renderFile={(file, { entry, attached }) => (
1351
- <DiffFile
1352
- file={file}
1353
- diffStyle={diffStyle}
1354
- theme={theme}
1355
- themeType={themeType}
1356
- fontFamily={fontFamily}
1357
- wordWrap={wordWrap}
1358
- stickyTop={stickyTop}
1359
- loadFiles={loadFiles}
1360
- entry={entry}
1361
- impacts={impactsOf(file.name)}
1362
- collapsed={collapse.isCollapsed(file.name, attached)}
1363
- onToggleCollapsed={() => {
1364
- collapse.set(
1365
- file.name,
1366
- !collapse.isCollapsed(file.name, attached),
1367
- );
1368
- }}
1369
- />
1370
- )}
1409
+ diffStyle={diffStyle}
1410
+ theme={theme}
1411
+ themeType={themeType}
1412
+ fontFamily={fontFamily}
1413
+ wordWrap={wordWrap}
1414
+ stickyTop={stickyTop}
1415
+ collapse={collapse}
1416
+ loadFiles={loadFiles}
1417
+ impactsOf={impactsOf}
1371
1418
  />
1372
1419
  ) : (
1373
1420
  <ReviewedFiles
@@ -1392,3 +1439,64 @@ function PullRequestDiffViewer({
1392
1439
  }
1393
1440
 
1394
1441
  export default memo(PullRequestDiffViewer);
1442
+
1443
+ export interface DiffViewerProps {
1444
+ patch: string;
1445
+ /** the files by package, in this order; a file no group names goes under the root */
1446
+ groups: DiffGroup[];
1447
+ diffStyle: DiffStyle;
1448
+ theme: DiffTheme;
1449
+ themeType: "dark" | "light";
1450
+ fontFamily: string | null;
1451
+ wordWrap: boolean;
1452
+ /** where a file header sticks: the height of the toolbar stuck above it */
1453
+ stickyTop: number;
1454
+ collapse: PullRequestCollapse;
1455
+ /** absent: the unchanged lines between hunks cannot be unfolded */
1456
+ loadFiles?: FileDiffContentsLoader;
1457
+ }
1458
+
1459
+ /**
1460
+ * A read-only diff of any patch, with the pull request's file chrome and no
1461
+ * pull request: a branch against its base, a working tree. Default export of
1462
+ * `@reviewflow/ui/diff-viewer`, to be loaded lazily like the pull request's.
1463
+ */
1464
+ function DiffViewer({
1465
+ patch,
1466
+ groups,
1467
+ diffStyle,
1468
+ theme,
1469
+ themeType,
1470
+ fontFamily,
1471
+ wordWrap,
1472
+ stickyTop,
1473
+ collapse,
1474
+ loadFiles,
1475
+ }: DiffViewerProps): ReactNode {
1476
+ const fileGroups = useMemo(
1477
+ () =>
1478
+ groupFiles(
1479
+ parsePatchFiles(patch).flatMap((parsed) => parsed.files),
1480
+ groups,
1481
+ ),
1482
+ [patch, groups],
1483
+ );
1484
+ return (
1485
+ <DiffWorkerPool theme={theme}>
1486
+ <ReadOnlyFiles
1487
+ groups={fileGroups}
1488
+ diffStyle={diffStyle}
1489
+ theme={theme}
1490
+ themeType={themeType}
1491
+ fontFamily={fontFamily}
1492
+ wordWrap={wordWrap}
1493
+ stickyTop={stickyTop}
1494
+ collapse={collapse}
1495
+ loadFiles={loadFiles}
1496
+ />
1497
+ </DiffWorkerPool>
1498
+ );
1499
+ }
1500
+
1501
+ const MemoizedDiffViewer = memo(DiffViewer);
1502
+ export { MemoizedDiffViewer as DiffViewer };