addons/isl/src/CommitCloud.tsxblame
View source
b69ab311/**
b69ab312 * Copyright (c) Meta Platforms, Inc. and affiliates.
b69ab313 *
b69ab314 * This source code is licensed under the MIT license found in the
b69ab315 * LICENSE file in the root directory of this source tree.
b69ab316 */
b69ab317
b69ab318import type {CommitCloudSyncState, Hash, Result} from './types';
b69ab319
b69ab3110import {Button} from 'isl-components/Button';
b69ab3111import {Dropdown} from 'isl-components/Dropdown';
b69ab3112import {ErrorNotice, InlineErrorBadge} from 'isl-components/ErrorNotice';
b69ab3113import {Icon} from 'isl-components/Icon';
b69ab3114import {Subtle} from 'isl-components/Subtle';
b69ab3115import {TextField} from 'isl-components/TextField';
b69ab3116import {Tooltip} from 'isl-components/Tooltip';
b69ab3117import {atom, useAtom, useAtomValue} from 'jotai';
b69ab3118import {useCallback, useEffect, useRef, useState} from 'react';
b69ab3119import {notEmpty} from 'shared/utils';
b69ab3120import serverAPI from './ClientToServerAPI';
b69ab3121import {Commit} from './Commit';
b69ab3122import {FlexSpacer} from './ComponentUtils';
b69ab3123import {EducationInfoTip} from './Education';
b69ab3124import {T, t} from './i18n';
b69ab3125import {writeAtom} from './jotaiUtils';
b69ab3126import {CommitCloudChangeWorkspaceOperation} from './operations/CommitCloudChangeWorkspaceOperation';
b69ab3127import {CommitCloudCreateWorkspaceOperation} from './operations/CommitCloudCreateWorkspaceOperation';
b69ab3128import {CommitCloudSyncOperation} from './operations/CommitCloudSyncOperation';
b69ab3129import {useRunOperation} from './operationsState';
b69ab3130import {CommitPreview, dagWithPreviews, useMostRecentPendingOperation} from './previews';
b69ab3131import {RelativeDate} from './relativeDate';
b69ab3132import {repoRootAndCwd} from './repositoryData';
b69ab3133import {CommitCloudBackupStatus} from './types';
b69ab3134import {registerDisposable} from './utils';
b69ab3135
b69ab3136import './CommitCloud.css';
b69ab3137
b69ab3138export const commitCloudEnabledAtom = atom(async (get): Promise<boolean> => {
b69ab3139 // depend on the cwd so we recheck when the cwd changes
b69ab3140 get(repoRootAndCwd);
b69ab3141
b69ab3142 serverAPI.postMessage({
b69ab3143 type: 'getConfig',
b69ab3144 name: 'extensions.commitcloud',
b69ab3145 });
b69ab3146 const message = await serverAPI.nextMessageMatching('gotConfig', message => {
b69ab3147 return message.name === 'extensions.commitcloud';
b69ab3148 });
b69ab3149 const enabled = message.value != null && message.value !== '!';
b69ab3150 return enabled;
b69ab3151});
b69ab3152
b69ab3153const cloudSyncStateAtom = atom<Result<CommitCloudSyncState> | null>(null);
b69ab3154
b69ab3155registerDisposable(
b69ab3156 cloudSyncStateAtom,
b69ab3157 serverAPI.onMessageOfType('fetchedCommitCloudState', event => {
b69ab3158 writeAtom(cloudSyncStateAtom, event.state);
b69ab3159 }),
b69ab3160 import.meta.hot,
b69ab3161);
b69ab3162
b69ab3163const REFRESH_INTERVAL = 30 * 1000;
b69ab3164
b69ab3165export function CommitCloudInfo() {
b69ab3166 const [cloudSyncState, setCloudSyncState] = useAtom(cloudSyncStateAtom);
b69ab3167 const runOperation = useRunOperation();
b69ab3168 const pendingOperation = useMostRecentPendingOperation();
b69ab3169 const isRunningSync = pendingOperation?.trackEventName === 'CommitCloudSyncOperation';
b69ab3170 const isLoading = cloudSyncState?.value?.isFetching === true;
b69ab3171 const [enteredWorkspaceName, setEnteredWorkspaceName] = useState<null | string>(null);
b69ab3172
b69ab3173 const refreshCommitCloudStatus = useCallback(() => {
b69ab3174 setCloudSyncState(old =>
b69ab3175 old?.value != null ? {value: {...old.value, isFetching: true}} : old,
b69ab3176 );
b69ab3177 serverAPI.postMessage({
b69ab3178 type: 'fetchCommitCloudState',
b69ab3179 });
b69ab3180 }, [setCloudSyncState]);
b69ab3181
b69ab3182 useEffect(() => {
b69ab3183 const interval = setInterval(refreshCommitCloudStatus, REFRESH_INTERVAL);
b69ab3184 // also call immediately on mount
b69ab3185 refreshCommitCloudStatus();
b69ab3186 return () => clearInterval(interval);
b69ab3187 }, [refreshCommitCloudStatus]);
b69ab3188
b69ab3189 const isMakingWorkspace = enteredWorkspaceName != null;
b69ab3190 const newWorkspaceNameRef = useRef(null);
b69ab3191 useEffect(() => {
b69ab3192 if (isMakingWorkspace && newWorkspaceNameRef.current != null) {
b69ab3193 (newWorkspaceNameRef.current as HTMLInputElement).focus();
b69ab3194 }
b69ab3195 }, [newWorkspaceNameRef, isMakingWorkspace]);
b69ab3196
b69ab3197 return (
b69ab3198 <div className="commit-cloud-info">
b69ab3199 <div className="dropdown-fields-header commit-cloud-header">
b69ab31100 <Icon icon="cloud" size="M" />
b69ab31101 <strong role="heading">{<T>Commit Cloud</T>}</strong>
b69ab31102 <EducationInfoTip>
b69ab31103 <T>Commit Cloud backs up your draft commits automatically across all your devices.</T>
b69ab31104 </EducationInfoTip>
b69ab31105 {isLoading && <Icon icon="loading" />}
b69ab31106 </div>
b69ab31107
b69ab31108 {cloudSyncState?.value?.isDisabled !== true ? null : (
b69ab31109 <div className="commit-cloud-row">
b69ab31110 <Subtle>
b69ab31111 <T>Commit Cloud is disabled in this repository</T>
b69ab31112 </Subtle>
b69ab31113 </div>
b69ab31114 )}
b69ab31115
b69ab31116 {cloudSyncState?.value?.syncError == null ? null : (
b69ab31117 <div className="commit-cloud-row">
b69ab31118 <InlineErrorBadge error={cloudSyncState?.value?.syncError}>
b69ab31119 <T>Failed to fetch commit cloud backup statuses</T>
b69ab31120 </InlineErrorBadge>
b69ab31121 </div>
b69ab31122 )}
b69ab31123 {cloudSyncState?.value?.workspaceError == null ? null : (
b69ab31124 <div className="commit-cloud-row">
b69ab31125 <InlineErrorBadge error={cloudSyncState?.value?.workspaceError}>
b69ab31126 <T>Failed to fetch commit cloud status</T>
b69ab31127 </InlineErrorBadge>
b69ab31128 </div>
b69ab31129 )}
b69ab31130 <div className="commit-cloud-row">
b69ab31131 {cloudSyncState == null ? (
b69ab31132 <Icon icon="loading" />
b69ab31133 ) : cloudSyncState.error != null ? (
b69ab31134 <ErrorNotice
b69ab31135 error={cloudSyncState.error}
b69ab31136 title={t('Failed to check Commit Cloud state')}
b69ab31137 />
b69ab31138 ) : cloudSyncState.value.lastBackup == null ? null : (
b69ab31139 <>
b69ab31140 <Subtle>
b69ab31141 <T
b69ab31142 replace={{
b69ab31143 $relTimeAgo: (
b69ab31144 <Tooltip title={cloudSyncState.value.lastBackup.toLocaleString()}>
b69ab31145 <RelativeDate date={cloudSyncState.value.lastBackup} />
b69ab31146 </Tooltip>
b69ab31147 ),
b69ab31148 }}>
b69ab31149 Last meaningful sync: $relTimeAgo
b69ab31150 </T>
b69ab31151 </Subtle>
b69ab31152 <FlexSpacer />
b69ab31153 <Button
b69ab31154 onClick={() => {
b69ab31155 runOperation(new CommitCloudSyncOperation()).then(() => {
b69ab31156 refreshCommitCloudStatus();
b69ab31157 });
b69ab31158 }}
b69ab31159 disabled={isRunningSync}
b69ab31160 icon>
b69ab31161 {isRunningSync ? (
b69ab31162 <Icon icon="loading" slot="start" />
b69ab31163 ) : (
b69ab31164 <Icon icon="sync" slot="start" />
b69ab31165 )}
b69ab31166 <T>Sync now</T>
b69ab31167 </Button>
b69ab31168 </>
b69ab31169 )}
b69ab31170 </div>
b69ab31171
b69ab31172 {cloudSyncState?.value?.commitStatuses == null ? null : (
b69ab31173 <CommitCloudSyncStatusBadge statuses={cloudSyncState?.value?.commitStatuses} />
b69ab31174 )}
b69ab31175
b69ab31176 <div className="commit-cloud-row">
b69ab31177 {cloudSyncState?.value?.currentWorkspace == null ? null : (
b69ab31178 <div className="commit-cloud-dropdown-container">
b69ab31179 <label htmlFor="stack-file-dropdown">
b69ab31180 <T>Commit Cloud Workspace</T>
b69ab31181 </label>
b69ab31182 <div className="commit-cloud-workspace-actions">
b69ab31183 <Dropdown
b69ab31184 value={cloudSyncState?.value.currentWorkspace}
b69ab31185 disabled={
b69ab31186 pendingOperation?.trackEventName === 'CommitCloudChangeWorkspaceOperation' ||
b69ab31187 pendingOperation?.trackEventName === 'CommitCloudCreateWorkspaceOperation'
b69ab31188 }
b69ab31189 onChange={event => {
b69ab31190 const newChoice = event.currentTarget.value;
b69ab31191 runOperation(new CommitCloudChangeWorkspaceOperation(newChoice)).then(() => {
b69ab31192 refreshCommitCloudStatus();
b69ab31193 });
b69ab31194 if (cloudSyncState?.value) {
b69ab31195 // optimistically set the workspace choice
b69ab31196 setCloudSyncState({
b69ab31197 value: {...cloudSyncState?.value, currentWorkspace: newChoice},
b69ab31198 });
b69ab31199 }
b69ab31200 }}
b69ab31201 options={cloudSyncState?.value.workspaceChoices ?? []}
b69ab31202 />
b69ab31203 {enteredWorkspaceName == null ? (
b69ab31204 <Button
b69ab31205 icon
b69ab31206 onClick={e => {
b69ab31207 setEnteredWorkspaceName('');
b69ab31208 e.preventDefault();
b69ab31209 e.stopPropagation();
b69ab31210 }}>
b69ab31211 <Icon icon="plus" slot="start" />
b69ab31212 <T>Add Workspace</T>
b69ab31213 </Button>
b69ab31214 ) : (
b69ab31215 <div className="commit-cloud-new-workspace-input">
b69ab31216 <TextField
b69ab31217 ref={newWorkspaceNameRef as React.MutableRefObject<null>}
b69ab31218 onInput={e => setEnteredWorkspaceName((e.target as HTMLInputElement).value)}>
b69ab31219 <T>New Workspace Name</T>
b69ab31220 </TextField>
b69ab31221 <Button
b69ab31222 onClick={e => {
b69ab31223 setEnteredWorkspaceName(null);
b69ab31224 e.preventDefault();
b69ab31225 e.stopPropagation();
b69ab31226 }}>
b69ab31227 <T>Cancel</T>
b69ab31228 </Button>
b69ab31229 <Button
b69ab31230 primary
b69ab31231 disabled={!enteredWorkspaceName}
b69ab31232 onClick={e => {
b69ab31233 if (!enteredWorkspaceName) {
b69ab31234 return;
b69ab31235 }
b69ab31236 const name = enteredWorkspaceName.trim().replace(' ', '_');
b69ab31237 // optimistically update the dropdown
b69ab31238 setCloudSyncState(old =>
b69ab31239 old?.value != null
b69ab31240 ? {
b69ab31241 value: {
b69ab31242 ...old.value,
b69ab31243 workspaceChoices: [...(old.value.workspaceChoices ?? []), name],
b69ab31244 currentWorkspace: name,
b69ab31245 },
b69ab31246 }
b69ab31247 : old,
b69ab31248 );
b69ab31249 runOperation(new CommitCloudCreateWorkspaceOperation(name)).then(() => {
b69ab31250 refreshCommitCloudStatus();
b69ab31251 });
b69ab31252 setEnteredWorkspaceName(null);
b69ab31253 e.preventDefault();
b69ab31254 e.stopPropagation();
b69ab31255 }}>
b69ab31256 <T>Create</T>
b69ab31257 </Button>
b69ab31258 </div>
b69ab31259 )}
b69ab31260 </div>
b69ab31261 </div>
b69ab31262 )}
b69ab31263 </div>
b69ab31264 </div>
b69ab31265 );
b69ab31266}
b69ab31267
b69ab31268function CommitCloudSyncStatusBadge({statuses}: {statuses: Map<Hash, CommitCloudBackupStatus>}) {
b69ab31269 const statusValues = [...statuses.entries()];
b69ab31270 const pending = statusValues.filter(
b69ab31271 ([_hash, status]) =>
b69ab31272 status === CommitCloudBackupStatus.Pending || status === CommitCloudBackupStatus.InProgress,
b69ab31273 );
b69ab31274 const failed = statusValues.filter(
b69ab31275 ([_hash, status]) => status === CommitCloudBackupStatus.Failed,
b69ab31276 );
b69ab31277
b69ab31278 let icon;
b69ab31279 let content;
b69ab31280 let renderTooltip;
b69ab31281 if (pending.length > 0) {
b69ab31282 icon = 'sync';
b69ab31283 content = <T count={pending.length}>commitsBeingBackedUp</T>;
b69ab31284 renderTooltip = () => <BackupList commits={pending.map(([hash]) => hash)} />;
b69ab31285 } else if (failed.length > 0) {
b69ab31286 icon = 'sync';
b69ab31287 content = (
b69ab31288 <div className="inline-error-badge">
b69ab31289 <span>
b69ab31290 <Icon icon="error" slot="start" />
b69ab31291 <T count={failed.length}>commitsFailedBackingUp</T>
b69ab31292 </span>
b69ab31293 </div>
b69ab31294 );
b69ab31295 renderTooltip = () => <BackupList commits={failed.map(([hash]) => hash)} />;
b69ab31296 } else {
b69ab31297 // Empty means all commits were backed up, since we don't fetch successfully backed up hashes.
b69ab31298 // 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.
b69ab31299 icon = 'check';
b69ab31300 content = <T>All commits backed up</T>;
b69ab31301 }
b69ab31302
b69ab31303 return (
b69ab31304 <div className="commit-cloud-row commit-cloud-sync-status-badge">
b69ab31305 {renderTooltip == null ? (
b69ab31306 <div>
b69ab31307 <Icon icon={icon} />
b69ab31308 {content}
b69ab31309 </div>
b69ab31310 ) : (
b69ab31311 <Tooltip component={renderTooltip}>
b69ab31312 <Icon icon={icon} />
b69ab31313 {content}
b69ab31314 </Tooltip>
b69ab31315 )}
b69ab31316 </div>
b69ab31317 );
b69ab31318}
b69ab31319
b69ab31320function BackupList({commits}: {commits: Array<Hash>}) {
b69ab31321 const dag = useAtomValue(dagWithPreviews);
b69ab31322 const infos = commits.map(hash => dag.get(hash)).filter(notEmpty);
b69ab31323 return (
b69ab31324 <div className="commit-cloud-backup-list">
b69ab31325 {infos.map(commit =>
b69ab31326 typeof commit === 'string' ? (
b69ab31327 <div>{commit}</div>
b69ab31328 ) : (
b69ab31329 <Commit
b69ab31330 commit={commit}
b69ab31331 key={commit.hash}
b69ab31332 hasChildren={false}
b69ab31333 previewType={CommitPreview.NON_ACTIONABLE_COMMIT}
b69ab31334 />
b69ab31335 ),
b69ab31336 )}
b69ab31337 </div>
b69ab31338 );
b69ab31339}