12.2 KB340 lines
Blame
1/**
2 * Copyright (c) Meta Platforms, Inc. and affiliates.
3 *
4 * This source code is licensed under the MIT license found in the
5 * LICENSE file in the root directory of this source tree.
6 */
7
8import type {CommitCloudSyncState, Hash, Result} from './types';
9
10import {Button} from 'isl-components/Button';
11import {Dropdown} from 'isl-components/Dropdown';
12import {ErrorNotice, InlineErrorBadge} from 'isl-components/ErrorNotice';
13import {Icon} from 'isl-components/Icon';
14import {Subtle} from 'isl-components/Subtle';
15import {TextField} from 'isl-components/TextField';
16import {Tooltip} from 'isl-components/Tooltip';
17import {atom, useAtom, useAtomValue} from 'jotai';
18import {useCallback, useEffect, useRef, useState} from 'react';
19import {notEmpty} from 'shared/utils';
20import serverAPI from './ClientToServerAPI';
21import {Commit} from './Commit';
22import {FlexSpacer} from './ComponentUtils';
23import {EducationInfoTip} from './Education';
24import {T, t} from './i18n';
25import {writeAtom} from './jotaiUtils';
26import {CommitCloudChangeWorkspaceOperation} from './operations/CommitCloudChangeWorkspaceOperation';
27import {CommitCloudCreateWorkspaceOperation} from './operations/CommitCloudCreateWorkspaceOperation';
28import {CommitCloudSyncOperation} from './operations/CommitCloudSyncOperation';
29import {useRunOperation} from './operationsState';
30import {CommitPreview, dagWithPreviews, useMostRecentPendingOperation} from './previews';
31import {RelativeDate} from './relativeDate';
32import {repoRootAndCwd} from './repositoryData';
33import {CommitCloudBackupStatus} from './types';
34import {registerDisposable} from './utils';
35
36import './CommitCloud.css';
37
38export const commitCloudEnabledAtom = atom(async (get): Promise<boolean> => {
39 // depend on the cwd so we recheck when the cwd changes
40 get(repoRootAndCwd);
41
42 serverAPI.postMessage({
43 type: 'getConfig',
44 name: 'extensions.commitcloud',
45 });
46 const message = await serverAPI.nextMessageMatching('gotConfig', message => {
47 return message.name === 'extensions.commitcloud';
48 });
49 const enabled = message.value != null && message.value !== '!';
50 return enabled;
51});
52
53const cloudSyncStateAtom = atom<Result<CommitCloudSyncState> | null>(null);
54
55registerDisposable(
56 cloudSyncStateAtom,
57 serverAPI.onMessageOfType('fetchedCommitCloudState', event => {
58 writeAtom(cloudSyncStateAtom, event.state);
59 }),
60 import.meta.hot,
61);
62
63const REFRESH_INTERVAL = 30 * 1000;
64
65export function CommitCloudInfo() {
66 const [cloudSyncState, setCloudSyncState] = useAtom(cloudSyncStateAtom);
67 const runOperation = useRunOperation();
68 const pendingOperation = useMostRecentPendingOperation();
69 const isRunningSync = pendingOperation?.trackEventName === 'CommitCloudSyncOperation';
70 const isLoading = cloudSyncState?.value?.isFetching === true;
71 const [enteredWorkspaceName, setEnteredWorkspaceName] = useState<null | string>(null);
72
73 const refreshCommitCloudStatus = useCallback(() => {
74 setCloudSyncState(old =>
75 old?.value != null ? {value: {...old.value, isFetching: true}} : old,
76 );
77 serverAPI.postMessage({
78 type: 'fetchCommitCloudState',
79 });
80 }, [setCloudSyncState]);
81
82 useEffect(() => {
83 const interval = setInterval(refreshCommitCloudStatus, REFRESH_INTERVAL);
84 // also call immediately on mount
85 refreshCommitCloudStatus();
86 return () => clearInterval(interval);
87 }, [refreshCommitCloudStatus]);
88
89 const isMakingWorkspace = enteredWorkspaceName != null;
90 const newWorkspaceNameRef = useRef(null);
91 useEffect(() => {
92 if (isMakingWorkspace && newWorkspaceNameRef.current != null) {
93 (newWorkspaceNameRef.current as HTMLInputElement).focus();
94 }
95 }, [newWorkspaceNameRef, isMakingWorkspace]);
96
97 return (
98 <div className="commit-cloud-info">
99 <div className="dropdown-fields-header commit-cloud-header">
100 <Icon icon="cloud" size="M" />
101 <strong role="heading">{<T>Commit Cloud</T>}</strong>
102 <EducationInfoTip>
103 <T>Commit Cloud backs up your draft commits automatically across all your devices.</T>
104 </EducationInfoTip>
105 {isLoading && <Icon icon="loading" />}
106 </div>
107
108 {cloudSyncState?.value?.isDisabled !== true ? null : (
109 <div className="commit-cloud-row">
110 <Subtle>
111 <T>Commit Cloud is disabled in this repository</T>
112 </Subtle>
113 </div>
114 )}
115
116 {cloudSyncState?.value?.syncError == null ? null : (
117 <div className="commit-cloud-row">
118 <InlineErrorBadge error={cloudSyncState?.value?.syncError}>
119 <T>Failed to fetch commit cloud backup statuses</T>
120 </InlineErrorBadge>
121 </div>
122 )}
123 {cloudSyncState?.value?.workspaceError == null ? null : (
124 <div className="commit-cloud-row">
125 <InlineErrorBadge error={cloudSyncState?.value?.workspaceError}>
126 <T>Failed to fetch commit cloud status</T>
127 </InlineErrorBadge>
128 </div>
129 )}
130 <div className="commit-cloud-row">
131 {cloudSyncState == null ? (
132 <Icon icon="loading" />
133 ) : cloudSyncState.error != null ? (
134 <ErrorNotice
135 error={cloudSyncState.error}
136 title={t('Failed to check Commit Cloud state')}
137 />
138 ) : cloudSyncState.value.lastBackup == null ? null : (
139 <>
140 <Subtle>
141 <T
142 replace={{
143 $relTimeAgo: (
144 <Tooltip title={cloudSyncState.value.lastBackup.toLocaleString()}>
145 <RelativeDate date={cloudSyncState.value.lastBackup} />
146 </Tooltip>
147 ),
148 }}>
149 Last meaningful sync: $relTimeAgo
150 </T>
151 </Subtle>
152 <FlexSpacer />
153 <Button
154 onClick={() => {
155 runOperation(new CommitCloudSyncOperation()).then(() => {
156 refreshCommitCloudStatus();
157 });
158 }}
159 disabled={isRunningSync}
160 icon>
161 {isRunningSync ? (
162 <Icon icon="loading" slot="start" />
163 ) : (
164 <Icon icon="sync" slot="start" />
165 )}
166 <T>Sync now</T>
167 </Button>
168 </>
169 )}
170 </div>
171
172 {cloudSyncState?.value?.commitStatuses == null ? null : (
173 <CommitCloudSyncStatusBadge statuses={cloudSyncState?.value?.commitStatuses} />
174 )}
175
176 <div className="commit-cloud-row">
177 {cloudSyncState?.value?.currentWorkspace == null ? null : (
178 <div className="commit-cloud-dropdown-container">
179 <label htmlFor="stack-file-dropdown">
180 <T>Commit Cloud Workspace</T>
181 </label>
182 <div className="commit-cloud-workspace-actions">
183 <Dropdown
184 value={cloudSyncState?.value.currentWorkspace}
185 disabled={
186 pendingOperation?.trackEventName === 'CommitCloudChangeWorkspaceOperation' ||
187 pendingOperation?.trackEventName === 'CommitCloudCreateWorkspaceOperation'
188 }
189 onChange={event => {
190 const newChoice = event.currentTarget.value;
191 runOperation(new CommitCloudChangeWorkspaceOperation(newChoice)).then(() => {
192 refreshCommitCloudStatus();
193 });
194 if (cloudSyncState?.value) {
195 // optimistically set the workspace choice
196 setCloudSyncState({
197 value: {...cloudSyncState?.value, currentWorkspace: newChoice},
198 });
199 }
200 }}
201 options={cloudSyncState?.value.workspaceChoices ?? []}
202 />
203 {enteredWorkspaceName == null ? (
204 <Button
205 icon
206 onClick={e => {
207 setEnteredWorkspaceName('');
208 e.preventDefault();
209 e.stopPropagation();
210 }}>
211 <Icon icon="plus" slot="start" />
212 <T>Add Workspace</T>
213 </Button>
214 ) : (
215 <div className="commit-cloud-new-workspace-input">
216 <TextField
217 ref={newWorkspaceNameRef as React.MutableRefObject<null>}
218 onInput={e => setEnteredWorkspaceName((e.target as HTMLInputElement).value)}>
219 <T>New Workspace Name</T>
220 </TextField>
221 <Button
222 onClick={e => {
223 setEnteredWorkspaceName(null);
224 e.preventDefault();
225 e.stopPropagation();
226 }}>
227 <T>Cancel</T>
228 </Button>
229 <Button
230 primary
231 disabled={!enteredWorkspaceName}
232 onClick={e => {
233 if (!enteredWorkspaceName) {
234 return;
235 }
236 const name = enteredWorkspaceName.trim().replace(' ', '_');
237 // optimistically update the dropdown
238 setCloudSyncState(old =>
239 old?.value != null
240 ? {
241 value: {
242 ...old.value,
243 workspaceChoices: [...(old.value.workspaceChoices ?? []), name],
244 currentWorkspace: name,
245 },
246 }
247 : old,
248 );
249 runOperation(new CommitCloudCreateWorkspaceOperation(name)).then(() => {
250 refreshCommitCloudStatus();
251 });
252 setEnteredWorkspaceName(null);
253 e.preventDefault();
254 e.stopPropagation();
255 }}>
256 <T>Create</T>
257 </Button>
258 </div>
259 )}
260 </div>
261 </div>
262 )}
263 </div>
264 </div>
265 );
266}
267
268function CommitCloudSyncStatusBadge({statuses}: {statuses: Map<Hash, CommitCloudBackupStatus>}) {
269 const statusValues = [...statuses.entries()];
270 const pending = statusValues.filter(
271 ([_hash, status]) =>
272 status === CommitCloudBackupStatus.Pending || status === CommitCloudBackupStatus.InProgress,
273 );
274 const failed = statusValues.filter(
275 ([_hash, status]) => status === CommitCloudBackupStatus.Failed,
276 );
277
278 let icon;
279 let content;
280 let renderTooltip;
281 if (pending.length > 0) {
282 icon = 'sync';
283 content = <T count={pending.length}>commitsBeingBackedUp</T>;
284 renderTooltip = () => <BackupList commits={pending.map(([hash]) => hash)} />;
285 } else if (failed.length > 0) {
286 icon = 'sync';
287 content = (
288 <div className="inline-error-badge">
289 <span>
290 <Icon icon="error" slot="start" />
291 <T count={failed.length}>commitsFailedBackingUp</T>
292 </span>
293 </div>
294 );
295 renderTooltip = () => <BackupList commits={failed.map(([hash]) => hash)} />;
296 } else {
297 // Empty means all commits were backed up, since we don't fetch successfully backed up hashes.
298 // Note: this does mean we can't tell the difference between a commit we don't know about and a commit that is backed up.
299 icon = 'check';
300 content = <T>All commits backed up</T>;
301 }
302
303 return (
304 <div className="commit-cloud-row commit-cloud-sync-status-badge">
305 {renderTooltip == null ? (
306 <div>
307 <Icon icon={icon} />
308 {content}
309 </div>
310 ) : (
311 <Tooltip component={renderTooltip}>
312 <Icon icon={icon} />
313 {content}
314 </Tooltip>
315 )}
316 </div>
317 );
318}
319
320function BackupList({commits}: {commits: Array<Hash>}) {
321 const dag = useAtomValue(dagWithPreviews);
322 const infos = commits.map(hash => dag.get(hash)).filter(notEmpty);
323 return (
324 <div className="commit-cloud-backup-list">
325 {infos.map(commit =>
326 typeof commit === 'string' ? (
327 <div>{commit}</div>
328 ) : (
329 <Commit
330 commit={commit}
331 key={commit.hash}
332 hasChildren={false}
333 previewType={CommitPreview.NON_ACTIONABLE_COMMIT}
334 />
335 ),
336 )}
337 </div>
338 );
339}
340