@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.
- package/package.json +6 -2
- package/src/board/pull-request-row.tsx +57 -18
- package/src/components/branch-diff/diff-change-mark.tsx +86 -0
- package/src/components/branch-diff/use-collapsed-files.ts +3 -0
- package/src/components/checkout-sync-buttons.tsx +76 -0
- package/src/components/checkout-view.tsx +789 -0
- package/src/components/commit-list.tsx +172 -0
- package/src/components/diff-viewer.tsx +1 -0
- package/src/components/omitted-files.tsx +32 -0
- package/src/components/pull-request-commits.tsx +4 -91
- package/src/components/pull-request-diff-view.tsx +1 -25
- package/src/components/pull-request-diff-viewer.tsx +145 -37
- package/src/components/pull-request-panel.tsx +46 -23
- package/src/components/pull-request-repo-name.tsx +27 -0
- package/src/components/pull-request-screen.tsx +34 -26
- package/src/components/remove-worktree-button.tsx +233 -0
- package/src/components/repo-overview.tsx +615 -0
- package/src/index.ts +44 -0
- package/src/prStatus.ts +8 -1
- package/src/services/checkoutSummary.ts +32 -0
- package/src/slots.tsx +8 -0
|
@@ -0,0 +1,789 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
BranchDiffBase,
|
|
3
|
+
BranchDiffScope,
|
|
4
|
+
CheckoutBranchDiffResponse,
|
|
5
|
+
CheckoutCommit,
|
|
6
|
+
CheckoutCommitsResponse,
|
|
7
|
+
CheckoutStatus,
|
|
8
|
+
CommitDiffResponse,
|
|
9
|
+
DiffWhitespace,
|
|
10
|
+
} from "@reviewflow/sdk";
|
|
11
|
+
import { pluralOf } from "@reviewflow/sdk";
|
|
12
|
+
import {
|
|
13
|
+
AccentScope,
|
|
14
|
+
Badge,
|
|
15
|
+
Code,
|
|
16
|
+
Disclosure,
|
|
17
|
+
EmptyState,
|
|
18
|
+
Icon,
|
|
19
|
+
IndeterminateCircularProgress,
|
|
20
|
+
IndeterminateLinearProgress,
|
|
21
|
+
ScrollView,
|
|
22
|
+
SectionTitle,
|
|
23
|
+
Tab,
|
|
24
|
+
Tabs,
|
|
25
|
+
Text,
|
|
26
|
+
View,
|
|
27
|
+
WarningMessage,
|
|
28
|
+
useCurrentBreakpointNameFiltered,
|
|
29
|
+
useCurrentMode,
|
|
30
|
+
} from "alouette";
|
|
31
|
+
import { ArrowsClockwiseRegularIcon } from "alouette-icons/phosphor-icons/ArrowsClockwise";
|
|
32
|
+
import { ArrowsInLineVerticalRegularIcon } from "alouette-icons/phosphor-icons/ArrowsInLineVertical";
|
|
33
|
+
import { ArrowsOutLineVerticalRegularIcon } from "alouette-icons/phosphor-icons/ArrowsOutLineVertical";
|
|
34
|
+
import { CheckCircleRegularIcon } from "alouette-icons/phosphor-icons/CheckCircle";
|
|
35
|
+
import { ClockCounterClockwiseRegularIcon } from "alouette-icons/phosphor-icons/ClockCounterClockwise";
|
|
36
|
+
import { ColumnsRegularIcon } from "alouette-icons/phosphor-icons/Columns";
|
|
37
|
+
import { FolderSimpleRegularIcon } from "alouette-icons/phosphor-icons/FolderSimple";
|
|
38
|
+
import { GitBranchRegularIcon } from "alouette-icons/phosphor-icons/GitBranch";
|
|
39
|
+
import { GitCommitRegularIcon } from "alouette-icons/phosphor-icons/GitCommit";
|
|
40
|
+
import { RowsRegularIcon } from "alouette-icons/phosphor-icons/Rows";
|
|
41
|
+
import type { ComponentRef, ReactNode } from "react";
|
|
42
|
+
import {
|
|
43
|
+
Suspense,
|
|
44
|
+
lazy,
|
|
45
|
+
useDeferredValue,
|
|
46
|
+
useMemo,
|
|
47
|
+
useRef,
|
|
48
|
+
useState,
|
|
49
|
+
} from "react";
|
|
50
|
+
import { Platform } from "react-native";
|
|
51
|
+
import { useDiffSettings } from "./branch-diff/use-diff-settings.tsx";
|
|
52
|
+
import { WhitespaceButton } from "./branch-diff/whitespace-button.tsx";
|
|
53
|
+
import type { CommitSteps } from "./commit-list.tsx";
|
|
54
|
+
import { CommitHeader, CommitList } from "./commit-list.tsx";
|
|
55
|
+
import { OmittedFiles } from "./omitted-files.tsx";
|
|
56
|
+
import type { PullRequestCollapse } from "./pull-request-diff-collapse.ts";
|
|
57
|
+
import { usePullRequestCollapse } from "./pull-request-diff-collapse.ts";
|
|
58
|
+
import {
|
|
59
|
+
DiffFileElementsContext,
|
|
60
|
+
createDiffFileElements,
|
|
61
|
+
} from "./pull-request-diff-jump.ts";
|
|
62
|
+
import { diffTreeFilesOf } from "./pull-request-diff-tree-files.ts";
|
|
63
|
+
import type { DiffStyle } from "./pull-request-diff-viewer.tsx";
|
|
64
|
+
import { cssVariableOf, scrollToTopOf } from "./scroll-parent.ts";
|
|
65
|
+
import { TooltipIconButton } from "./tooltip-icon-button.tsx";
|
|
66
|
+
|
|
67
|
+
const DiffViewer = lazy(() => import("./diff-viewer.tsx"));
|
|
68
|
+
const PullRequestDiffTree = lazy(() => import("./pull-request-diff-tree.tsx"));
|
|
69
|
+
|
|
70
|
+
/** the tree renders DOM in a shadow root */
|
|
71
|
+
const diffTreeAvailable = Platform.OS === "web";
|
|
72
|
+
|
|
73
|
+
const noViewedPaths: readonly string[] = [];
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* `uncommitted`: the working tree against `HEAD`. `branch`: the commits since
|
|
77
|
+
* the branch left its base, as its pull request shows them. `history`: the
|
|
78
|
+
* last commits of `HEAD`, each opening its own diff.
|
|
79
|
+
*/
|
|
80
|
+
export type CheckoutTab = "branch" | "history" | "uncommitted";
|
|
81
|
+
|
|
82
|
+
const isCheckoutTab = (value: string): value is CheckoutTab =>
|
|
83
|
+
value === "uncommitted" || value === "branch" || value === "history";
|
|
84
|
+
|
|
85
|
+
const shortSha = (sha: string): string => sha.slice(0, 7);
|
|
86
|
+
|
|
87
|
+
/** The first read, and the viewer's own chunk loading, before anything shows. */
|
|
88
|
+
function Loading(): ReactNode {
|
|
89
|
+
return (
|
|
90
|
+
<View className="items-center py-xl">
|
|
91
|
+
<IndeterminateCircularProgress loading />
|
|
92
|
+
</View>
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** What a diff body draws: a scope's diff and a commit's both carry it. */
|
|
97
|
+
type DiffPatch = Pick<
|
|
98
|
+
CheckoutBranchDiffResponse,
|
|
99
|
+
"groups" | "omitted" | "patch"
|
|
100
|
+
>;
|
|
101
|
+
|
|
102
|
+
interface DiffDisplay {
|
|
103
|
+
diffStyle: DiffStyle;
|
|
104
|
+
collapse: PullRequestCollapse;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
interface DiffBodyProps extends DiffDisplay {
|
|
108
|
+
diff: DiffPatch | null;
|
|
109
|
+
error: string | null;
|
|
110
|
+
/** shown in place of the files when the patch is empty */
|
|
111
|
+
empty: ReactNode;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* The files beside the diff, as the pull request's diff lists them: pressing
|
|
116
|
+
* one brings it into view and outlines it a moment. On a wide web screen only.
|
|
117
|
+
*/
|
|
118
|
+
function DiffBody({
|
|
119
|
+
diff,
|
|
120
|
+
error,
|
|
121
|
+
empty,
|
|
122
|
+
diffStyle,
|
|
123
|
+
collapse,
|
|
124
|
+
}: DiffBodyProps): ReactNode {
|
|
125
|
+
const mode = useCurrentMode();
|
|
126
|
+
const { wordWrap, theme, fontFamily } = useDiffSettings();
|
|
127
|
+
const fileElements = useMemo(createDiffFileElements, []);
|
|
128
|
+
const highlightProbe = useRef<ComponentRef<typeof View>>(null);
|
|
129
|
+
const wide = useCurrentBreakpointNameFiltered(["base", "wide"]) === "wide";
|
|
130
|
+
const treeFiles = useMemo(
|
|
131
|
+
() => (diff === null ? [] : diffTreeFilesOf(diff)),
|
|
132
|
+
[diff],
|
|
133
|
+
);
|
|
134
|
+
if (diff === null) {
|
|
135
|
+
if (error !== null) return <WarningMessage>{error}</WarningMessage>;
|
|
136
|
+
return <Loading />;
|
|
137
|
+
}
|
|
138
|
+
const hasFiles = diff.patch !== "";
|
|
139
|
+
const aside = wide && diffTreeAvailable && treeFiles.length > 0;
|
|
140
|
+
|
|
141
|
+
const goToFile = (path: string): void => {
|
|
142
|
+
const element = fileElements.elementOf(path);
|
|
143
|
+
if (element === undefined) return;
|
|
144
|
+
scrollToTopOf(element, 0);
|
|
145
|
+
const color = cssVariableOf(highlightProbe.current, "--color-enabled");
|
|
146
|
+
if (color !== "") fileElements.highlight(path, color);
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const files = (
|
|
150
|
+
<View className="min-w-0 flex-1 gap-m">
|
|
151
|
+
{error === null ? null : <WarningMessage>{error}</WarningMessage>}
|
|
152
|
+
<OmittedFiles omitted={diff.omitted} />
|
|
153
|
+
{hasFiles ? (
|
|
154
|
+
<Suspense fallback={<Loading />}>
|
|
155
|
+
<DiffFileElementsContext value={fileElements}>
|
|
156
|
+
<DiffViewer
|
|
157
|
+
patch={diff.patch}
|
|
158
|
+
groups={diff.groups}
|
|
159
|
+
diffStyle={diffStyle}
|
|
160
|
+
theme={theme}
|
|
161
|
+
themeType={mode}
|
|
162
|
+
fontFamily={fontFamily}
|
|
163
|
+
wordWrap={wordWrap}
|
|
164
|
+
stickyTop={0}
|
|
165
|
+
collapse={collapse}
|
|
166
|
+
/>
|
|
167
|
+
</DiffFileElementsContext>
|
|
168
|
+
</Suspense>
|
|
169
|
+
) : null}
|
|
170
|
+
{hasFiles || diff.omitted.length > 0 ? null : empty}
|
|
171
|
+
</View>
|
|
172
|
+
);
|
|
173
|
+
if (!aside) return files;
|
|
174
|
+
|
|
175
|
+
return (
|
|
176
|
+
<View className="flex-row items-start gap-xl">
|
|
177
|
+
<View className="w-[280px] shrink-0 web:sticky web:top-0 web:h-[calc(100vh-16rem)]">
|
|
178
|
+
<Suspense fallback={<Loading />}>
|
|
179
|
+
<PullRequestDiffTree
|
|
180
|
+
files={treeFiles}
|
|
181
|
+
viewedPaths={noViewedPaths}
|
|
182
|
+
theme={theme}
|
|
183
|
+
onFilePress={goToFile}
|
|
184
|
+
/>
|
|
185
|
+
</Suspense>
|
|
186
|
+
{/* the file header is pierre's shadow DOM, which no accent class
|
|
187
|
+
reaches: the color is read here and handed to it */}
|
|
188
|
+
<AccentScope accent="info">
|
|
189
|
+
<View ref={highlightProbe} className="hidden" />
|
|
190
|
+
</AccentScope>
|
|
191
|
+
</View>
|
|
192
|
+
{files}
|
|
193
|
+
</View>
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
interface UpstreamStateProps {
|
|
198
|
+
status: CheckoutStatus;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Where the branch stands against the remote branch it tracks, as of the last fetch. */
|
|
202
|
+
function UpstreamState({ status }: UpstreamStateProps): ReactNode {
|
|
203
|
+
const { branch, upstream, ahead, behind } = status;
|
|
204
|
+
if (branch === null) return null;
|
|
205
|
+
if (upstream === null) {
|
|
206
|
+
return <Text className="text-sm text-muted">not published</Text>;
|
|
207
|
+
}
|
|
208
|
+
if (ahead === null || behind === null) {
|
|
209
|
+
return <Text className="text-sm text-muted">{`${upstream} is gone`}</Text>;
|
|
210
|
+
}
|
|
211
|
+
if (ahead === 0 && behind === 0) {
|
|
212
|
+
return (
|
|
213
|
+
<Text className="text-sm text-muted">{`up to date with ${upstream}`}</Text>
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
const moves = [
|
|
217
|
+
ahead === 0 ? null : `${String(ahead)} to push`,
|
|
218
|
+
behind === 0 ? null : `${String(behind)} to pull`,
|
|
219
|
+
].filter((move) => move !== null);
|
|
220
|
+
return (
|
|
221
|
+
<Text className="text-sm text-muted">
|
|
222
|
+
{`${moves.join(", ")} against ${upstream}`}
|
|
223
|
+
</Text>
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
interface CheckoutContextProps {
|
|
228
|
+
status: CheckoutStatus | null;
|
|
229
|
+
base: BranchDiffBase | null;
|
|
230
|
+
renderBaseExtra: ((base: BranchDiffBase) => ReactNode) | undefined;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* The branch, its remote, where it is on disk and its base. The base is named with its sha since
|
|
235
|
+
* nothing is fetched before a diff: `origin/main` is as fresh as the last fetch.
|
|
236
|
+
*/
|
|
237
|
+
function CheckoutContext({
|
|
238
|
+
status,
|
|
239
|
+
base,
|
|
240
|
+
renderBaseExtra,
|
|
241
|
+
}: CheckoutContextProps): ReactNode {
|
|
242
|
+
if (status === null) return null;
|
|
243
|
+
const showsBase = base !== null && base.name !== status.branch;
|
|
244
|
+
return (
|
|
245
|
+
<View className="flex-row flex-wrap items-center gap-x-m gap-y-xs">
|
|
246
|
+
<View className="flex-row items-center gap-xxs">
|
|
247
|
+
<Icon icon={<GitBranchRegularIcon />} size={14} />
|
|
248
|
+
<Code className="text-sm">{status.branch ?? "detached HEAD"}</Code>
|
|
249
|
+
</View>
|
|
250
|
+
<UpstreamState status={status} />
|
|
251
|
+
<View className="flex-row items-center gap-xxs">
|
|
252
|
+
<Icon icon={<FolderSimpleRegularIcon />} size={14} />
|
|
253
|
+
<Text className="select-auto font-mono text-xs text-muted">
|
|
254
|
+
{status.path}
|
|
255
|
+
</Text>
|
|
256
|
+
</View>
|
|
257
|
+
{showsBase ? (
|
|
258
|
+
<View className="flex-row items-center gap-xs">
|
|
259
|
+
<Text className="text-sm text-muted">from</Text>
|
|
260
|
+
<Code className="text-sm">
|
|
261
|
+
{`${base.ref} @ ${shortSha(base.sha)}`}
|
|
262
|
+
</Code>
|
|
263
|
+
{renderBaseExtra?.(base)}
|
|
264
|
+
</View>
|
|
265
|
+
) : null}
|
|
266
|
+
</View>
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const withCount = (label: string, count: number | null): string =>
|
|
271
|
+
count === null || count === 0 ? label : `${label} (${String(count)})`;
|
|
272
|
+
|
|
273
|
+
const uncommittedCountOf = (status: CheckoutStatus | null): number | null =>
|
|
274
|
+
status === null ? null : status.modified + status.untracked;
|
|
275
|
+
|
|
276
|
+
interface DiffToolsProps {
|
|
277
|
+
diffStyle: DiffStyle;
|
|
278
|
+
onDiffStyleChange: (style: DiffStyle) => void;
|
|
279
|
+
whitespace: DiffWhitespace;
|
|
280
|
+
onWhitespaceToggle: () => void;
|
|
281
|
+
collapse: PullRequestCollapse;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function DiffTools({
|
|
285
|
+
diffStyle,
|
|
286
|
+
onDiffStyleChange,
|
|
287
|
+
whitespace,
|
|
288
|
+
onWhitespaceToggle,
|
|
289
|
+
collapse,
|
|
290
|
+
}: DiffToolsProps): ReactNode {
|
|
291
|
+
return (
|
|
292
|
+
<View className="flex-row items-center gap-xs">
|
|
293
|
+
<TooltipIconButton
|
|
294
|
+
icon={
|
|
295
|
+
diffStyle === "split" ? <RowsRegularIcon /> : <ColumnsRegularIcon />
|
|
296
|
+
}
|
|
297
|
+
size="sm"
|
|
298
|
+
aria-label={
|
|
299
|
+
diffStyle === "split" ? "Show unified diff" : "Show split diff"
|
|
300
|
+
}
|
|
301
|
+
onPress={() => {
|
|
302
|
+
onDiffStyleChange(diffStyle === "split" ? "unified" : "split");
|
|
303
|
+
}}
|
|
304
|
+
/>
|
|
305
|
+
<WhitespaceButton whitespace={whitespace} onToggle={onWhitespaceToggle} />
|
|
306
|
+
<TooltipIconButton
|
|
307
|
+
icon={<ArrowsOutLineVerticalRegularIcon />}
|
|
308
|
+
size="sm"
|
|
309
|
+
aria-label="Open every file"
|
|
310
|
+
onPress={collapse.expandAll}
|
|
311
|
+
/>
|
|
312
|
+
<TooltipIconButton
|
|
313
|
+
icon={<ArrowsInLineVerticalRegularIcon />}
|
|
314
|
+
size="sm"
|
|
315
|
+
aria-label="Close every file"
|
|
316
|
+
onPress={collapse.collapseAll}
|
|
317
|
+
/>
|
|
318
|
+
</View>
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
interface NotPushedBadgeProps {
|
|
323
|
+
commit: CheckoutCommit;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function NotPushedBadge({ commit }: NotPushedBadgeProps): ReactNode {
|
|
327
|
+
if (commit.pushed !== false) return null;
|
|
328
|
+
return (
|
|
329
|
+
<Badge accent="warning" variant="outlined">
|
|
330
|
+
not pushed
|
|
331
|
+
</Badge>
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const renderNotPushed = (commit: CheckoutCommit): ReactNode => (
|
|
336
|
+
<NotPushedBadge commit={commit} />
|
|
337
|
+
);
|
|
338
|
+
|
|
339
|
+
/** A diff and the reads behind it, as a query gives them. */
|
|
340
|
+
export interface CheckoutRead<Value> {
|
|
341
|
+
/** the last answer; null before the first */
|
|
342
|
+
value: Value | null;
|
|
343
|
+
error: string | null;
|
|
344
|
+
/** a read is running; the answer on screen stays until it settles */
|
|
345
|
+
loading: boolean;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
export interface CheckoutHistory {
|
|
349
|
+
commits: CheckoutRead<CheckoutCommitsResponse>;
|
|
350
|
+
/** the commit whose diff is open; null shows the list */
|
|
351
|
+
openSha: string | null;
|
|
352
|
+
onOpenChange: (sha: string | null) => void;
|
|
353
|
+
/** the open commit's diff */
|
|
354
|
+
diff: CheckoutRead<CommitDiffResponse>;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
interface UncommittedBodyProps extends DiffDisplay {
|
|
358
|
+
diff: CheckoutRead<CheckoutBranchDiffResponse>;
|
|
359
|
+
shownDiff: CheckoutBranchDiffResponse | null;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function UncommittedBody({
|
|
363
|
+
diff,
|
|
364
|
+
shownDiff,
|
|
365
|
+
...display
|
|
366
|
+
}: UncommittedBodyProps): ReactNode {
|
|
367
|
+
return (
|
|
368
|
+
<DiffBody
|
|
369
|
+
diff={shownDiff}
|
|
370
|
+
error={diff.error}
|
|
371
|
+
empty={
|
|
372
|
+
<EmptyState
|
|
373
|
+
icon={<CheckCircleRegularIcon />}
|
|
374
|
+
title="Nothing uncommitted"
|
|
375
|
+
>
|
|
376
|
+
The working tree matches the last commit.
|
|
377
|
+
</EmptyState>
|
|
378
|
+
}
|
|
379
|
+
{...display}
|
|
380
|
+
/>
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
interface BranchCommitsProps {
|
|
385
|
+
commits: CheckoutCommitsResponse;
|
|
386
|
+
onOpen: (sha: string) => void;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** The commits the branch's diff is made of, folded above it. */
|
|
390
|
+
function BranchCommits({ commits, onOpen }: BranchCommitsProps): ReactNode {
|
|
391
|
+
const { base, onBranchCount } = commits;
|
|
392
|
+
if (base === null || onBranchCount === null || onBranchCount === 0) {
|
|
393
|
+
return null;
|
|
394
|
+
}
|
|
395
|
+
const listed = commits.commits.filter(({ onBranch }) => onBranch);
|
|
396
|
+
const unlisted = onBranchCount - listed.length;
|
|
397
|
+
return (
|
|
398
|
+
<Disclosure
|
|
399
|
+
guide
|
|
400
|
+
label={`${pluralOf(onBranchCount, "commit")} since ${base.ref}`}
|
|
401
|
+
>
|
|
402
|
+
<View className="gap-xs">
|
|
403
|
+
<CommitList
|
|
404
|
+
commits={listed}
|
|
405
|
+
renderExtra={renderNotPushed}
|
|
406
|
+
onOpen={onOpen}
|
|
407
|
+
/>
|
|
408
|
+
{unlisted > 0 ? (
|
|
409
|
+
<Text className="text-xs text-muted">
|
|
410
|
+
{`${pluralOf(unlisted, "older commit")} not listed`}
|
|
411
|
+
</Text>
|
|
412
|
+
) : null}
|
|
413
|
+
</View>
|
|
414
|
+
</Disclosure>
|
|
415
|
+
);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
interface BranchBodyProps extends DiffDisplay {
|
|
419
|
+
diff: CheckoutRead<CheckoutBranchDiffResponse>;
|
|
420
|
+
shownDiff: CheckoutBranchDiffResponse | null;
|
|
421
|
+
commits: CheckoutCommitsResponse | null;
|
|
422
|
+
uncommittedCount: number | null;
|
|
423
|
+
/** absent: no history tab to open a commit in, so the commits are not listed */
|
|
424
|
+
onOpenCommit: ((sha: string) => void) | undefined;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function BranchBody({
|
|
428
|
+
diff,
|
|
429
|
+
shownDiff,
|
|
430
|
+
commits,
|
|
431
|
+
uncommittedCount,
|
|
432
|
+
onOpenCommit,
|
|
433
|
+
...display
|
|
434
|
+
}: BranchBodyProps): ReactNode {
|
|
435
|
+
const base = shownDiff?.base ?? commits?.base ?? null;
|
|
436
|
+
const branch = shownDiff?.branch ?? commits?.branch ?? null;
|
|
437
|
+
return (
|
|
438
|
+
<View className="gap-m">
|
|
439
|
+
{uncommittedCount === null || uncommittedCount === 0 ? null : (
|
|
440
|
+
<Text className="text-sm text-muted">
|
|
441
|
+
{`Not included: ${pluralOf(uncommittedCount, "uncommitted change")}, under Uncommitted.`}
|
|
442
|
+
</Text>
|
|
443
|
+
)}
|
|
444
|
+
{commits === null || onOpenCommit === undefined ? null : (
|
|
445
|
+
<BranchCommits commits={commits} onOpen={onOpenCommit} />
|
|
446
|
+
)}
|
|
447
|
+
<DiffBody
|
|
448
|
+
diff={shownDiff}
|
|
449
|
+
error={diff.error}
|
|
450
|
+
empty={
|
|
451
|
+
<EmptyState icon={<GitBranchRegularIcon />} title="No branch changes">
|
|
452
|
+
{base === null
|
|
453
|
+
? "The branch has no commit of its own."
|
|
454
|
+
: `${branch ?? "HEAD"} has no commit that ${base.ref} does not have.`}
|
|
455
|
+
</EmptyState>
|
|
456
|
+
}
|
|
457
|
+
{...display}
|
|
458
|
+
/>
|
|
459
|
+
</View>
|
|
460
|
+
);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
const stepsOf = (
|
|
464
|
+
commits: CheckoutCommit[],
|
|
465
|
+
sha: string,
|
|
466
|
+
onOpen: (sha: string) => void,
|
|
467
|
+
): CommitSteps => {
|
|
468
|
+
const index = commits.findIndex(({ oid }) => oid === sha);
|
|
469
|
+
const newer = index > 0 ? commits[index - 1] : undefined;
|
|
470
|
+
const older = index === -1 ? undefined : commits[index + 1];
|
|
471
|
+
return {
|
|
472
|
+
onNewer:
|
|
473
|
+
newer === undefined
|
|
474
|
+
? null
|
|
475
|
+
: () => {
|
|
476
|
+
onOpen(newer.oid);
|
|
477
|
+
},
|
|
478
|
+
onOlder:
|
|
479
|
+
older === undefined
|
|
480
|
+
? null
|
|
481
|
+
: () => {
|
|
482
|
+
onOpen(older.oid);
|
|
483
|
+
},
|
|
484
|
+
};
|
|
485
|
+
};
|
|
486
|
+
|
|
487
|
+
interface OpenCommitProps extends DiffDisplay {
|
|
488
|
+
history: CheckoutHistory;
|
|
489
|
+
sha: string;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
function OpenCommit({ history, sha, ...display }: OpenCommitProps): ReactNode {
|
|
493
|
+
const shownDiff = useDeferredValue(history.diff.value);
|
|
494
|
+
const listed = history.commits.value?.commits ?? [];
|
|
495
|
+
const commit = listed.find(({ oid }) => oid === sha);
|
|
496
|
+
return (
|
|
497
|
+
<View className="gap-m">
|
|
498
|
+
{commit === undefined ? null : (
|
|
499
|
+
<CommitHeader
|
|
500
|
+
commit={commit}
|
|
501
|
+
steps={stepsOf(listed, sha, history.onOpenChange)}
|
|
502
|
+
extra={<NotPushedBadge commit={commit} />}
|
|
503
|
+
onBack={() => {
|
|
504
|
+
history.onOpenChange(null);
|
|
505
|
+
}}
|
|
506
|
+
/>
|
|
507
|
+
)}
|
|
508
|
+
<DiffBody
|
|
509
|
+
diff={shownDiff?.sha === sha ? shownDiff : null}
|
|
510
|
+
error={history.diff.error}
|
|
511
|
+
empty={
|
|
512
|
+
<EmptyState icon={<GitCommitRegularIcon />} title="No changes">
|
|
513
|
+
This commit changes no file.
|
|
514
|
+
</EmptyState>
|
|
515
|
+
}
|
|
516
|
+
{...display}
|
|
517
|
+
/>
|
|
518
|
+
</View>
|
|
519
|
+
);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
interface CommitSectionsProps {
|
|
523
|
+
commits: CheckoutCommitsResponse;
|
|
524
|
+
onOpen: (sha: string) => void;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/** The branch's own commits apart from the ones it shares with its base. */
|
|
528
|
+
function CommitSections({ commits, onOpen }: CommitSectionsProps): ReactNode {
|
|
529
|
+
const { base, branch, onBranchCount } = commits;
|
|
530
|
+
const own = commits.commits.filter(({ onBranch }) => onBranch);
|
|
531
|
+
const shared = commits.commits.filter(({ onBranch }) => !onBranch);
|
|
532
|
+
if (base === null || own.length === 0 || shared.length === 0) {
|
|
533
|
+
return (
|
|
534
|
+
<CommitList
|
|
535
|
+
commits={commits.commits}
|
|
536
|
+
renderExtra={renderNotPushed}
|
|
537
|
+
onOpen={onOpen}
|
|
538
|
+
/>
|
|
539
|
+
);
|
|
540
|
+
}
|
|
541
|
+
const unlisted = (onBranchCount ?? own.length) - own.length;
|
|
542
|
+
return (
|
|
543
|
+
<View className="gap-xl">
|
|
544
|
+
<View className="gap-sm">
|
|
545
|
+
<SectionTitle icon={<GitBranchRegularIcon />}>
|
|
546
|
+
{`On ${branch ?? "HEAD"}`}
|
|
547
|
+
</SectionTitle>
|
|
548
|
+
<CommitList
|
|
549
|
+
commits={own}
|
|
550
|
+
renderExtra={renderNotPushed}
|
|
551
|
+
onOpen={onOpen}
|
|
552
|
+
/>
|
|
553
|
+
{unlisted > 0 ? (
|
|
554
|
+
<Text className="text-xs text-muted">
|
|
555
|
+
{`${pluralOf(unlisted, "more commit")} not listed`}
|
|
556
|
+
</Text>
|
|
557
|
+
) : null}
|
|
558
|
+
</View>
|
|
559
|
+
<View className="gap-sm">
|
|
560
|
+
<SectionTitle icon={<ClockCounterClockwiseRegularIcon />}>
|
|
561
|
+
{`On ${base.ref}`}
|
|
562
|
+
</SectionTitle>
|
|
563
|
+
<CommitList
|
|
564
|
+
commits={shared}
|
|
565
|
+
renderExtra={renderNotPushed}
|
|
566
|
+
onOpen={onOpen}
|
|
567
|
+
/>
|
|
568
|
+
</View>
|
|
569
|
+
</View>
|
|
570
|
+
);
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
interface HistoryBodyProps extends DiffDisplay {
|
|
574
|
+
history: CheckoutHistory;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
function HistoryBody({ history, ...display }: HistoryBodyProps): ReactNode {
|
|
578
|
+
const { commits, openSha, onOpenChange } = history;
|
|
579
|
+
if (openSha !== null) {
|
|
580
|
+
return <OpenCommit history={history} sha={openSha} {...display} />;
|
|
581
|
+
}
|
|
582
|
+
if (commits.value === null) {
|
|
583
|
+
if (commits.error !== null) {
|
|
584
|
+
return <WarningMessage>{commits.error}</WarningMessage>;
|
|
585
|
+
}
|
|
586
|
+
return <Loading />;
|
|
587
|
+
}
|
|
588
|
+
return (
|
|
589
|
+
<View className="gap-m">
|
|
590
|
+
{commits.error === null ? null : (
|
|
591
|
+
<WarningMessage>{commits.error}</WarningMessage>
|
|
592
|
+
)}
|
|
593
|
+
{commits.value.commits.length === 0 ? (
|
|
594
|
+
<EmptyState icon={<GitCommitRegularIcon />} title="No commits yet">
|
|
595
|
+
This repository has no commit.
|
|
596
|
+
</EmptyState>
|
|
597
|
+
) : (
|
|
598
|
+
<CommitSections commits={commits.value} onOpen={onOpenChange} />
|
|
599
|
+
)}
|
|
600
|
+
</View>
|
|
601
|
+
);
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
export interface CheckoutViewProps {
|
|
605
|
+
title: string;
|
|
606
|
+
/** for a dialog's `aria-labelledby` */
|
|
607
|
+
titleId?: string;
|
|
608
|
+
/** the branch, its upstream and what is uncommitted; null before the first read */
|
|
609
|
+
status: CheckoutStatus | null;
|
|
610
|
+
/** null until the caller knows which tab to open first */
|
|
611
|
+
tab: CheckoutTab | null;
|
|
612
|
+
onTabChange: (tab: CheckoutTab) => void;
|
|
613
|
+
/** the open tab's diff: `uncommitted` or `branch`; ignored under `history` */
|
|
614
|
+
diff: CheckoutRead<CheckoutBranchDiffResponse>;
|
|
615
|
+
history: CheckoutHistory;
|
|
616
|
+
/** reads again what the screen shows */
|
|
617
|
+
onReload: () => void;
|
|
618
|
+
whitespace: DiffWhitespace;
|
|
619
|
+
onWhitespaceToggle: () => void;
|
|
620
|
+
/** why the whitespace toggle could not be stored, shown under the toolbar */
|
|
621
|
+
whitespaceError?: string | null;
|
|
622
|
+
/** under the toolbar, e.g. a failed fetch */
|
|
623
|
+
notices?: ReactNode;
|
|
624
|
+
/** before the title, e.g. a back button */
|
|
625
|
+
headerStart?: ReactNode;
|
|
626
|
+
/** before the reload button, e.g. pull and push */
|
|
627
|
+
headerEnd?: ReactNode;
|
|
628
|
+
/** the tabs offered; all three unless set */
|
|
629
|
+
tabs?: CheckoutTab[];
|
|
630
|
+
/** after the base's ref, e.g. where the base was taken from */
|
|
631
|
+
renderBaseExtra?: (base: BranchDiffBase) => ReactNode;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
const allTabs: CheckoutTab[] = ["uncommitted", "branch", "history"];
|
|
635
|
+
|
|
636
|
+
/**
|
|
637
|
+
* A checkout: what is uncommitted, what its branch changed since it left its
|
|
638
|
+
* base, and its history, each a tab under the branch and where it stands. It
|
|
639
|
+
* fills its parent, the toolbar fixed and the content scrolling below it, so a
|
|
640
|
+
* dialog and a page both give it a bounded height. Theme, font and word wrap
|
|
641
|
+
* come from `DiffSettingsProvider`; the tab, the open commit and the whitespace
|
|
642
|
+
* are the caller's, since they decide what it reads.
|
|
643
|
+
*/
|
|
644
|
+
export function CheckoutView({
|
|
645
|
+
title,
|
|
646
|
+
titleId,
|
|
647
|
+
status,
|
|
648
|
+
tab,
|
|
649
|
+
onTabChange,
|
|
650
|
+
diff,
|
|
651
|
+
history,
|
|
652
|
+
onReload,
|
|
653
|
+
whitespace,
|
|
654
|
+
onWhitespaceToggle,
|
|
655
|
+
whitespaceError = null,
|
|
656
|
+
notices,
|
|
657
|
+
headerStart,
|
|
658
|
+
headerEnd,
|
|
659
|
+
tabs = allTabs,
|
|
660
|
+
renderBaseExtra,
|
|
661
|
+
}: CheckoutViewProps): ReactNode {
|
|
662
|
+
const [diffStyle, setDiffStyle] = useState<DiffStyle>("split");
|
|
663
|
+
// the viewer is memoized: a press commits at once, the next patch renders after
|
|
664
|
+
const shownDiff = useDeferredValue(diff.value);
|
|
665
|
+
const collapse = usePullRequestCollapse();
|
|
666
|
+
const commits = history.commits.value;
|
|
667
|
+
const uncommittedCount = uncommittedCountOf(status);
|
|
668
|
+
const showsDiff =
|
|
669
|
+
tab === "uncommitted" ||
|
|
670
|
+
tab === "branch" ||
|
|
671
|
+
(tab === "history" && history.openSha !== null);
|
|
672
|
+
const loading =
|
|
673
|
+
tab === "history"
|
|
674
|
+
? history.commits.loading || history.diff.loading
|
|
675
|
+
: diff.loading || shownDiff !== diff.value;
|
|
676
|
+
const display: DiffDisplay = { diffStyle, collapse };
|
|
677
|
+
|
|
678
|
+
// a tab switch shows the last tab's answer for a render: never under the new tab's name
|
|
679
|
+
const diffOf = (scope: BranchDiffScope): CheckoutBranchDiffResponse | null =>
|
|
680
|
+
shownDiff?.scope === scope ? shownDiff : null;
|
|
681
|
+
|
|
682
|
+
const openCommit = (sha: string): void => {
|
|
683
|
+
onTabChange("history");
|
|
684
|
+
history.onOpenChange(sha);
|
|
685
|
+
};
|
|
686
|
+
|
|
687
|
+
const body = ((): ReactNode => {
|
|
688
|
+
if (tab === null) return <Loading />;
|
|
689
|
+
if (tab === "uncommitted") {
|
|
690
|
+
return (
|
|
691
|
+
<UncommittedBody
|
|
692
|
+
diff={diff}
|
|
693
|
+
shownDiff={diffOf("uncommitted")}
|
|
694
|
+
{...display}
|
|
695
|
+
/>
|
|
696
|
+
);
|
|
697
|
+
}
|
|
698
|
+
if (tab === "branch") {
|
|
699
|
+
return (
|
|
700
|
+
<BranchBody
|
|
701
|
+
diff={diff}
|
|
702
|
+
shownDiff={diffOf("committed")}
|
|
703
|
+
commits={commits}
|
|
704
|
+
uncommittedCount={uncommittedCount}
|
|
705
|
+
onOpenCommit={tabs.includes("history") ? openCommit : undefined}
|
|
706
|
+
{...display}
|
|
707
|
+
/>
|
|
708
|
+
);
|
|
709
|
+
}
|
|
710
|
+
return <HistoryBody history={history} {...display} />;
|
|
711
|
+
})();
|
|
712
|
+
|
|
713
|
+
return (
|
|
714
|
+
<View className="min-h-0 flex-1">
|
|
715
|
+
<View className="gap-m border-b border-muted p-m">
|
|
716
|
+
<View className="flex-row flex-wrap items-center gap-m">
|
|
717
|
+
{headerStart}
|
|
718
|
+
<Text
|
|
719
|
+
id={titleId}
|
|
720
|
+
role="heading"
|
|
721
|
+
aria-level={1}
|
|
722
|
+
className="min-w-0 flex-1 font-heading-extrabold text-2xl"
|
|
723
|
+
>
|
|
724
|
+
{title}
|
|
725
|
+
</Text>
|
|
726
|
+
<View className="flex-row flex-wrap items-center gap-xs">
|
|
727
|
+
{headerEnd}
|
|
728
|
+
<TooltipIconButton
|
|
729
|
+
icon={<ArrowsClockwiseRegularIcon />}
|
|
730
|
+
size="sm"
|
|
731
|
+
variant="soft"
|
|
732
|
+
accent="neutral"
|
|
733
|
+
aria-label="Read the checkout again"
|
|
734
|
+
onPress={onReload}
|
|
735
|
+
/>
|
|
736
|
+
</View>
|
|
737
|
+
</View>
|
|
738
|
+
<CheckoutContext
|
|
739
|
+
status={status}
|
|
740
|
+
base={commits?.base ?? null}
|
|
741
|
+
renderBaseExtra={renderBaseExtra}
|
|
742
|
+
/>
|
|
743
|
+
<View className="flex-row flex-wrap items-center justify-between gap-m">
|
|
744
|
+
<Tabs
|
|
745
|
+
aria-label="Checkout view"
|
|
746
|
+
value={tab ?? ""}
|
|
747
|
+
onValueChange={(next) => {
|
|
748
|
+
if (isCheckoutTab(next)) onTabChange(next);
|
|
749
|
+
}}
|
|
750
|
+
>
|
|
751
|
+
{tabs.includes("uncommitted") ? (
|
|
752
|
+
<Tab
|
|
753
|
+
value="uncommitted"
|
|
754
|
+
label={withCount("Uncommitted", uncommittedCount)}
|
|
755
|
+
/>
|
|
756
|
+
) : null}
|
|
757
|
+
{tabs.includes("branch") ? (
|
|
758
|
+
<Tab
|
|
759
|
+
value="branch"
|
|
760
|
+
label={withCount("Branch", commits?.onBranchCount ?? null)}
|
|
761
|
+
/>
|
|
762
|
+
) : null}
|
|
763
|
+
{tabs.includes("history") ? (
|
|
764
|
+
<Tab value="history" label="History" />
|
|
765
|
+
) : null}
|
|
766
|
+
</Tabs>
|
|
767
|
+
{showsDiff ? (
|
|
768
|
+
<DiffTools
|
|
769
|
+
diffStyle={diffStyle}
|
|
770
|
+
whitespace={whitespace}
|
|
771
|
+
collapse={collapse}
|
|
772
|
+
onDiffStyleChange={setDiffStyle}
|
|
773
|
+
onWhitespaceToggle={onWhitespaceToggle}
|
|
774
|
+
/>
|
|
775
|
+
) : null}
|
|
776
|
+
</View>
|
|
777
|
+
{whitespaceError === null ? null : (
|
|
778
|
+
<WarningMessage>{whitespaceError}</WarningMessage>
|
|
779
|
+
)}
|
|
780
|
+
{notices}
|
|
781
|
+
</View>
|
|
782
|
+
{/* what is on screen stays while the next read runs, so the bar says it is being replaced */}
|
|
783
|
+
<IndeterminateLinearProgress loading={loading} size="xs" />
|
|
784
|
+
<ScrollView className="flex-1" contentContainerClassName="p-m">
|
|
785
|
+
{body}
|
|
786
|
+
</ScrollView>
|
|
787
|
+
</View>
|
|
788
|
+
);
|
|
789
|
+
}
|