diff --git a/packages/insomnia/src/sync/vcs/vcs.ts b/packages/insomnia/src/sync/vcs/vcs.ts index 1f0c0300d..0f10c9610 100644 --- a/packages/insomnia/src/sync/vcs/vcs.ts +++ b/packages/insomnia/src/sync/vcs/vcs.ts @@ -346,13 +346,13 @@ export class VCS { throw new Error(`Failed to find snapshot by id ${snapshotId}`); } - const potentialNewState: SnapshotState = candidates.map(candidate => ({ + const currentState: SnapshotState = candidates.map(candidate => ({ key: candidate.key, blob: hashDocument(candidate.document).hash, name: candidate.name, })); - const delta = stateDelta(potentialNewState, rollbackSnapshot.state); + const delta = stateDelta(currentState, rollbackSnapshot.state); // We need to treat removals of candidates differently because they may not yet have been stored as blobs. const remove: StatusCandidate[] = []; @@ -378,7 +378,7 @@ export class VCS { async getHistoryCount(branchName?: string) { const branch = branchName ? await this._getBranch(branchName) : await this._getCurrentBranch(); - return branch?.snapshots.length; + return branch?.snapshots.length || 0; } async getHistory(count = 0) { diff --git a/packages/insomnia/src/ui/components/base/indeterminate-checkbox.tsx b/packages/insomnia/src/ui/components/base/indeterminate-checkbox.tsx index a524d58b2..205edebe6 100644 --- a/packages/insomnia/src/ui/components/base/indeterminate-checkbox.tsx +++ b/packages/insomnia/src/ui/components/base/indeterminate-checkbox.tsx @@ -1,40 +1,25 @@ -import { autoBindMethodsForReact } from 'class-autobind-decorator'; -import React, { HTMLAttributes, PureComponent } from 'react'; - -import { AUTOBIND_CFG } from '../../../common/constants'; +import React, { FC, HTMLAttributes, useEffect, useRef } from 'react'; interface Props extends HTMLAttributes { indeterminate: boolean; checked: boolean; } -@autoBindMethodsForReact(AUTOBIND_CFG) -export class IndeterminateCheckbox extends PureComponent { - input: HTMLInputElement | null = null; +export const IndeterminateCheckbox: FC = ({ checked, indeterminate, ...otherProps }) => { + const checkRef = useRef(null); - _setRef(input: HTMLInputElement) { - this.input = input; - } - - _update() { - if (this.input) { - this.input.indeterminate = this.props.indeterminate; + useEffect(() => { + if (checkRef.current) { + checkRef.current.checked = checked; + checkRef.current.indeterminate = indeterminate; } - } + }, [checked, indeterminate]); - componentDidMount() { - this._update(); - } - - componentDidUpdate() { - this._update(); - } - - render() { - const { - indeterminate, - ...otherProps - } = this.props; - return ; - } -} + return ( + + ); +}; diff --git a/packages/insomnia/src/ui/components/base/modal.tsx b/packages/insomnia/src/ui/components/base/modal.tsx index 8fb8e85db..f6e57c219 100644 --- a/packages/insomnia/src/ui/components/base/modal.tsx +++ b/packages/insomnia/src/ui/components/base/modal.tsx @@ -19,7 +19,7 @@ export interface ModalProps { } export interface ModalHandle { - show: (options?: { onHide: () => void }) => void; + show: (options?: { onHide?: () => void }) => void; hide: () => void; toggle: () => void; isOpen: () => boolean; @@ -49,8 +49,12 @@ export const Modal = forwardRef(({ const hide = useCallback(() => { setOpen(false); - onHideProp?.(); - onHideArgument?.(); + if (typeof onHideProp === 'function') { + onHideProp(); + } + if (typeof onHideArgument === 'function') { + onHideArgument(); + } }, [onHideProp, onHideArgument]); useImperativeHandle(ref, () => ({ diff --git a/packages/insomnia/src/ui/components/base/prompt-button.tsx b/packages/insomnia/src/ui/components/base/prompt-button.tsx index 88e13b291..d95aff0e2 100644 --- a/packages/insomnia/src/ui/components/base/prompt-button.tsx +++ b/packages/insomnia/src/ui/components/base/prompt-button.tsx @@ -46,50 +46,36 @@ export const PromptButton = ({ }; }, []); - const handleConfirm = (event: MouseEvent) => { - if (triggerTimeout.current !== null) { - // Clear existing timeouts - clearTimeout(triggerTimeout.current); + const handleClick = (event: MouseEvent) => { + if (state === 'default') { + // Prevent events (ex. won't close dropdown if it's in one) + event.preventDefault(); + event.stopPropagation(); + // Toggle the confirmation notice + setState('ask'); + // Set a timeout to hide the confirmation + // using global.setTimeout to force use of the Node timeout rather than DOM timeout + triggerTimeout.current = global.setTimeout(() => { + setState('default'); + }, 2000); } - - // Fire the click handler - onClick?.(event); - - // Set the state to done (but delay a bit to not alarm user) - // using global.setTimeout to force use of the Node timeout rather than DOM timeout - doneTimeout.current = global.setTimeout(() => { - setState('done'); - }, 100); - // Set a timeout to hide the confirmation - // using global.setTimeout to force use of the Node timeout rather than DOM timeout - triggerTimeout.current = global.setTimeout(() => { - setState('default'); - + if (state === 'ask') { + if (triggerTimeout.current !== null) { + // Clear existing timeouts + clearTimeout(triggerTimeout.current); + } // Fire the click handler onClick?.(event); - }, 2000); - }; - - const handleAsk = (event: MouseEvent) => { - // Prevent events (ex. won't close dropdown if it's in one) - event.preventDefault(); - event.stopPropagation(); - - // Toggle the confirmation notice - setState('ask'); - - // Set a timeout to hide the confirmation - // using global.setTimeout to force use of the Node timeout rather than DOM timeout - triggerTimeout.current = global.setTimeout(() => { - setState('default'); - }, 2000); - }; - - const handleClick = (event: MouseEvent) => { - if (state === 'ask') { - handleConfirm(event); - } else if (state === 'default') { - handleAsk(event); + // Set the state to done (but delay a bit to not alarm user) + // using global.setTimeout to force use of the Node timeout rather than DOM timeout + doneTimeout.current = global.setTimeout(() => { + setState('done'); + }, 100); + // Set a timeout to hide the confirmation + // using global.setTimeout to force use of the Node timeout rather than DOM timeout + triggerTimeout.current = global.setTimeout(() => { + setState('default'); + }, 2000); } }; diff --git a/packages/insomnia/src/ui/components/dropdowns/sync-dropdown.tsx b/packages/insomnia/src/ui/components/dropdowns/sync-dropdown.tsx index 2695ac314..d5670e102 100644 --- a/packages/insomnia/src/ui/components/dropdowns/sync-dropdown.tsx +++ b/packages/insomnia/src/ui/components/dropdowns/sync-dropdown.tsx @@ -1,25 +1,23 @@ -import { autoBindMethodsForReact } from 'class-autobind-decorator'; import classnames from 'classnames'; -import React, { Fragment, PureComponent } from 'react'; -import { connect } from 'react-redux'; -import { AnyAction, bindActionCreators, Dispatch } from 'redux'; +import React, { FC, Fragment, useCallback, useEffect, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { useInterval, useMount } from 'react-use'; import * as session from '../../../account/session'; -import { AUTOBIND_CFG, DEFAULT_BRANCH_NAME } from '../../../common/constants'; +import { DEFAULT_BRANCH_NAME } from '../../../common/constants'; import { database as db } from '../../../common/database'; import { docsVersionControl } from '../../../common/documentation'; import { strings } from '../../../common/strings'; import * as models from '../../../models'; import { isRemoteProject, Project } from '../../../models/project'; import type { Workspace } from '../../../models/workspace'; -import { Snapshot, Status, StatusCandidate } from '../../../sync/types'; +import { Status } from '../../../sync/types'; import { pushSnapshotOnInitialize } from '../../../sync/vcs/initialize-backend-project'; import { logCollectionMovedToProject } from '../../../sync/vcs/migrate-collections'; import { BackendProjectWithTeam } from '../../../sync/vcs/normalize-backend-project-team'; import { pullBackendProject } from '../../../sync/vcs/pull-backend-project'; import { interceptAccessError } from '../../../sync/vcs/util'; import { VCS } from '../../../sync/vcs/vcs'; -import { RootState } from '../../redux/modules'; import { activateWorkspace } from '../../redux/modules/workspace'; import { selectActiveWorkspaceMeta, selectRemoteProjects, selectSyncItems } from '../../redux/selectors'; import { Dropdown } from '../base/dropdown/dropdown'; @@ -37,32 +35,15 @@ import { SyncHistoryModal } from '../modals/sync-history-modal'; import { SyncStagingModal } from '../modals/sync-staging-modal'; import { Tooltip } from '../tooltip'; -// Stop refreshing if user hasn't been active in this long -const REFRESH_USER_ACTIVITY = 1000 * 60 * 10; +// TODO: handle refetching logic in one place not here in a component + // Refresh dropdown periodically const REFRESH_PERIOD = 1000 * 60 * 1; -type ReduxProps = ReturnType & ReturnType; - -const mapStateToProps = (state: RootState) => ({ - remoteProjects: selectRemoteProjects(state), - syncItems: selectSyncItems(state), - workspaceMeta: selectActiveWorkspaceMeta(state), -}); - -const mapDispatchToProps = (dispatch: Dispatch) => { - const bound = bindActionCreators({ activateWorkspace }, dispatch); - return { - handleActivateWorkspace: bound.activateWorkspace, - }; -}; - -interface Props extends ReduxProps { +interface Props { workspace: Workspace; project: Project; vcs: VCS; - syncItems: StatusCandidate[]; - className?: string; } interface State { @@ -80,14 +61,8 @@ interface State { loadingPush: boolean; remoteBackendProjects: BackendProjectWithTeam[]; } - -@autoBindMethodsForReact(AUTOBIND_CFG) -class UnconnectedSyncDropdown extends PureComponent { - checkInterval: NodeJS.Timeout | null = null; - refreshOnNextSyncItems = false; - lastUserActivity = Date.now(); - - state: State = { +export const SyncDropdown: FC = ({ vcs, workspace, project }) => { + const [state, setState] = useState({ localBranches: [], currentBranch: '', compare: { @@ -105,133 +80,113 @@ class UnconnectedSyncDropdown extends PureComponent { unstaged: {}, }, remoteBackendProjects: [], - }; + }); + const dispatch = useDispatch(); + const remoteProjects = useSelector(selectRemoteProjects); + const syncItems = useSelector(selectSyncItems); + const workspaceMeta = useSelector(selectActiveWorkspaceMeta); - async refreshMainAttributes(extraState: Partial = {}) { - const { vcs, syncItems, workspace, project } = this.props; - - if (!vcs.hasBackendProject() && isRemoteProject(project)) { - const remoteBackendProjects = await vcs.remoteBackendProjectsInAnyTeam(); - const matchedBackendProjects = remoteBackendProjects.filter(p => p.rootDocumentId === workspace._id); - this.setState({ - remoteBackendProjects: matchedBackendProjects, - }); - return; - } - - const localBranches = (await vcs.getBranches()).sort(); - const currentBranch = await vcs.getBranch(); - const historyCount = await vcs.getHistoryCount(); - const status = await vcs.status(syncItems, {}); - const newState: Partial = { - status, - historyCount, - localBranches, - currentBranch, - ...extraState, - }; - - // Do the remote stuff + const refetchRemoteBranch = useCallback(async () => { if (session.isLoggedIn()) { try { - newState.compare = await vcs.compareRemoteBranch(); + const compare = await vcs.compareRemoteBranch(); + setState(state => ({ + ...state, + compare, + })); } catch (err) { console.log('Failed to compare remote branches', err.message); } } + }, [vcs]); - this.setState(prevState => ({ ...prevState, ...newState })); - } + const refreshVCSAndRefetchRemote = useCallback(async () => { + if (!vcs.hasBackendProject() && isRemoteProject(project)) { + const remoteBackendProjects = await vcs.remoteBackendProjectsInAnyTeam(); + const matchedBackendProjects = remoteBackendProjects.filter(p => p.rootDocumentId === workspace._id); + setState(state => ({ + ...state, + remoteBackendProjects: matchedBackendProjects, + })); + return; + } + const localBranches = (await vcs.getBranches()).sort(); + const currentBranch = await vcs.getBranch(); + const historyCount = await vcs.getHistoryCount(); + const status = await vcs.status(syncItems, {}); + setState(state => ({ + ...state, + status, + historyCount, + localBranches, + currentBranch, + })); + // Do the remote stuff + refetchRemoteBranch(); + }, [project, refetchRemoteBranch, syncItems, vcs, workspace._id]); - async componentDidMount() { - this.setState({ + useInterval(() => { + refetchRemoteBranch(); + }, REFRESH_PERIOD); + + useMount(async () => { + setState(state => ({ + ...state, initializing: true, - }); - - const { vcs, workspace, workspaceMeta, project } = this.props; + })); try { + // NOTE pushes the first snapshot automatically await pushSnapshotOnInitialize({ vcs, workspace, workspaceMeta, project }); - await this.refreshMainAttributes(); + await refreshVCSAndRefetchRemote(); } catch (err) { console.log('[sync_menu] Error refreshing sync state', err); } finally { - this.setState({ + setState(state => ({ + ...state, initializing: false, + })); + } + }); + + // Update if new sync items + useEffect(() => { + if (vcs.hasBackendProject()) { + vcs.status(syncItems, {}).then(status => { + setState(state => ({ + ...state, + status, + })); }); } - - // Refresh but only if the user has been active in the last n minutes - this.checkInterval = setInterval(async () => { - if (Date.now() - this.lastUserActivity < REFRESH_USER_ACTIVITY) { - await this.refreshMainAttributes(); - } - }, REFRESH_PERIOD); - document.addEventListener('mousemove', this._handleUserActivity); - } - - componentWillUnmount() { - if (this.checkInterval !== null) { - clearInterval(this.checkInterval); + }, [syncItems, vcs]); + async function handleSetProject(backendProject: BackendProjectWithTeam) { + setState(state => ({ + ...state, + loadingProjectPull: true, + })); + const pulledIntoProject = await pullBackendProject({ vcs, backendProject, remoteProjects }); + if (pulledIntoProject._id !== project._id) { + // If pulled into a different project, reactivate the workspace + await dispatch(activateWorkspace({ workspaceId: workspace._id })); + logCollectionMovedToProject(workspace, pulledIntoProject); } - document.removeEventListener('mousemove', this._handleUserActivity); + await refreshVCSAndRefetchRemote(); + setState(state => ({ + ...state, + loadingProjectPull: false, + })); } - - componentDidUpdate(prevProps: Props) { - const { vcs, syncItems } = this.props; - - // Update if new sync items - if (syncItems !== prevProps.syncItems) { - if (vcs.hasBackendProject()) { - vcs.status(syncItems, {}).then(status => { - this.setState({ - status, - }); - }); - } - - if (this.refreshOnNextSyncItems) { - this.refreshMainAttributes(); - this.refreshOnNextSyncItems = false; - } - } - } - - _handleUserActivity() { - this.lastUserActivity = Date.now(); - } - - _handleShowBranchesModal() { - showModal(SyncBranchesModal, { - onHide: this.refreshMainAttributes, - }); - } - - _handleShowStagingModal() { - showModal(SyncStagingModal, { - onSnapshot: async () => { - await this.refreshMainAttributes(); - }, - handlePush: async () => { - await this._handlePushChanges(); - }, - }); - } - - static _handleShowLoginModal() { - showModal(LoginModalHandle); - } - - async _handlePushChanges() { - const { vcs, project: { remoteId } } = this.props; - this.setState({ + async function handlePush() { + setState(state => ({ + ...state, loadingPush: true, - }); + })); try { const branch = await vcs.getBranch(); await interceptAccessError({ - callback: async () => await vcs.push(remoteId), + callback: () => vcs.push(project.remoteId), action: 'push', resourceName: branch, resourceType: 'branch', @@ -244,28 +199,29 @@ class UnconnectedSyncDropdown extends PureComponent { }); } - await this.refreshMainAttributes({ + await refreshVCSAndRefetchRemote(); + setState(state => ({ + ...state, loadingPush: false, - }); + })); } - async _handlePullChanges() { - const { vcs, syncItems, project: { remoteId } } = this.props; - this.setState({ + async function handlePull() { + setState(state => ({ + ...state, loadingPull: true, - }); + })); try { const branch = await vcs.getBranch(); const delta = await interceptAccessError({ - callback: async () => await vcs.pull(syncItems, remoteId), + callback: () => vcs.pull(syncItems, project.remoteId), action: 'pull', resourceName: branch, resourceType: 'branch', }); // @ts-expect-error -- TSCONVERSION await db.batchModifyDocs(delta); - this.refreshOnNextSyncItems = true; } catch (err) { showError({ title: 'Pull Error', @@ -274,22 +230,13 @@ class UnconnectedSyncDropdown extends PureComponent { }); } - this.setState({ + setState(state => ({ + ...state, loadingPull: false, - }); + })); } - async _handleRollback(snapshot: Snapshot) { - const { vcs, syncItems } = this.props; - const delta = await vcs.rollback(snapshot.id, syncItems); - // @ts-expect-error -- TSCONVERSION - await db.batchModifyDocs(delta); - this.refreshOnNextSyncItems = true; - } - - async _handleRevert() { - const { vcs, syncItems } = this.props; - + async function handleRevert() { try { const delta = await vcs.rollbackToLatest(syncItems); // @ts-expect-error -- TSCONVERSION @@ -303,61 +250,11 @@ class UnconnectedSyncDropdown extends PureComponent { } } - _handleShowHistoryModal() { - showModal(SyncHistoryModal, { - handleRollback: this._handleRollback, - }); - } - - async _handleOpen() { - await this.refreshMainAttributes(); - } - - async _handleEnableSync() { - this.setState({ - loadingProjectPull: true, - }); - const { vcs, workspace } = this.props; - await vcs.switchAndCreateBackendProjectIfNotExist(workspace._id, workspace.name); - await this.refreshMainAttributes({ - loadingProjectPull: false, - }); - } - - _handleShowDeleteModal() { - showModal(SyncDeleteModal, { - onHide: this.refreshMainAttributes, - }); - } - - async _handleSetProject(backendProject: BackendProjectWithTeam) { - const { vcs, remoteProjects, project, workspace, handleActivateWorkspace } = this.props; - this.setState({ - loadingProjectPull: true, - }); - - const pulledIntoProject = await pullBackendProject({ vcs, backendProject, remoteProjects }); - if (pulledIntoProject._id !== project._id) { - // If pulled into a different project, reactivate the workspace - await handleActivateWorkspace({ workspaceId: workspace._id }); - logCollectionMovedToProject(workspace, pulledIntoProject); - } - - await this.refreshMainAttributes({ - loadingProjectPull: false, - }); - } - - async _handleSwitchBranch(branch: string) { - const { vcs, syncItems } = this.props; - + async function handleSwitchBranch(branch: string) { try { const delta = await vcs.checkout(syncItems, branch); - if (branch === DEFAULT_BRANCH_NAME) { - const { historyCount } = this.state; const defaultBranchHistoryCount = await vcs.getHistoryCount(DEFAULT_BRANCH_NAME); - // If the default branch has no snapshots, but the current branch does // It will result in the workspace getting deleted // So we filter out the workspace from the delta to prevent this @@ -365,7 +262,6 @@ class UnconnectedSyncDropdown extends PureComponent { delta.remove = delta.remove.filter(e => e?.type !== models.workspace.type); } } - // @ts-expect-error -- TSCONVERSION await db.batchModifyDocs(delta); } catch (err) { @@ -375,257 +271,248 @@ class UnconnectedSyncDropdown extends PureComponent { error: err, }); } - // We can't refresh now because we won't yet have the new syncItems - this.refreshOnNextSyncItems = true; // Still need to do this in case sync items don't change - this.setState({ + setState(state => ({ + ...state, currentBranch: branch, - }); + })); } - renderBranch(branch: string) { - const { currentBranch } = this.state; - const icon = - branch === currentBranch ? : ; - const isCurrentBranch = branch === currentBranch; + if (!session.isLoggedIn()) { + return null; + } + + const { + localBranches, + currentBranch, + status, + historyCount, + loadingPull, + loadingPush, + loadingProjectPull, + compare: { ahead, behind }, + initializing, + remoteBackendProjects, + } = state; + const canCreateSnapshot = + Object.keys(status.stage).length > 0 || Object.keys(status.unstaged).length > 0; + const visibleBranches = localBranches.filter(b => !b.match(/\.hidden$/)); + const syncMenuHeader = ( + + Insomnia Sync{' '} + + Sync and collaborate on workspaces{' '} + + +
+ Documentation +
+ +
+
+ ); + + if (loadingProjectPull) { return ( - this._handleSwitchBranch(branch)} - className={classnames({ - bold: isCurrentBranch, - })} - title={isCurrentBranch ? '' : `Switch to "${branch}"`} - > - {icon} - {branch} - +
+ +
); } - renderButton() { - const { - currentBranch, - compare: { ahead, behind }, - initializing, - status, - loadingPull, - loadingPush, - } = this.state; - const canPush = ahead > 0; - const canPull = behind > 0; - const canCreateSnapshot = - Object.keys(status.stage).length > 0 || Object.keys(status.unstaged).length > 0; - const loadIcon = ; - const pullToolTipMsg = canPull - ? `There ${behind === 1 ? 'is' : 'are'} ${behind} snapshot${behind === 1 ? '' : 's'} to pull` - : 'No changes to pull'; - const pushToolTipMsg = canPush - ? `There ${ahead === 1 ? 'is' : 'are'} ${ahead} snapshot${ahead === 1 ? '' : 's'} to push` - : 'No changes to push'; - const snapshotToolTipMsg = canCreateSnapshot ? 'Local changes made' : 'No local changes made'; - - if (currentBranch === null) { - return Sync; - } - + if (!vcs.hasBackendProject()) { return ( - -
- {' '} - {initializing ? 'Initializing...' : currentBranch} -
-
- - - - - {/* Only show cloud icons if logged in */} - {session.isLoggedIn() && ( - - {loadingPull ? ( - loadIcon - ) : ( - - - - )} - - {loadingPush ? ( - loadIcon - ) : ( - - - - )} - - )} -
-
- ); - } - - render() { - if (!session.isLoggedIn()) { - return null; - } - - const { className, vcs } = this.props; - const { - localBranches, - currentBranch, - status, - historyCount, - loadingPull, - loadingPush, - loadingProjectPull, - remoteBackendProjects, - compare: { ahead, behind }, - } = this.state; - const canCreateSnapshot = - Object.keys(status.stage).length > 0 || Object.keys(status.unstaged).length > 0; - const visibleBranches = localBranches.filter(b => !b.match(/\.hidden$/)); - const syncMenuHeader = ( - - Insomnia Sync{' '} - - Sync and collaborate on workspaces{' '} - - -
- Documentation -
- -
-
- ); - - if (loadingProjectPull) { - return ( -
- -
- ); - } - - if (!vcs.hasBackendProject()) { - return ( -
- - - Setup Sync - - {syncMenuHeader} - {remoteBackendProjects.length === 0 && ( - - Create Locally - - )} - {remoteBackendProjects.map(p => ( - this._handleSetProject(p)}> - Pull {p.name} - - ))} - -
- ); - } - - return ( -
- - {this.renderButton()} - +
+ refreshVCSAndRefetchRemote()}> + + Setup Sync + {syncMenuHeader} - - {!session.isLoggedIn() && ( - - Log In + {remoteBackendProjects.length === 0 && ( + { + setState(state => ({ + ...state, + loadingProjectPull: true, + })); + await vcs.switchAndCreateBackendProjectIfNotExist(workspace._id, workspace.name); + await refreshVCSAndRefetchRemote(); + setState(state => ({ + ...state, + loadingProjectPull: false, + })); + }} + > + Create Locally )} - - - - Branches - - - - - Delete {strings.collection.singular} - - - Local Branches - {visibleBranches.map(this.renderBranch)} - - {currentBranch} - - - - History - - - - - Revert Changes - - - - - Create Snapshot - - - - {loadingPull ? ( - - Pulling Snapshots... - - ) : ( - - Pull {behind || ''} Snapshot - {behind === 1 ? '' : 's'} - - )} - - - - {loadingPush ? ( - - Pushing Snapshots... - - ) : ( - - Push {ahead || ''} Snapshot - {ahead === 1 ? '' : 's'} - - )} - + {remoteBackendProjects.map(p => ( + handleSetProject(p)}> + Pull {p.name} + + ))}
); } -} + const canPush = ahead > 0; + const canPull = behind > 0; + const loadIcon = ; + const pullToolTipMsg = canPull + ? `There ${behind === 1 ? 'is' : 'are'} ${behind} snapshot${behind === 1 ? '' : 's'} to pull` + : 'No changes to pull'; + const pushToolTipMsg = canPush + ? `There ${ahead === 1 ? 'is' : 'are'} ${ahead} snapshot${ahead === 1 ? '' : 's'} to push` + : 'No changes to push'; + const snapshotToolTipMsg = canCreateSnapshot ? 'Local changes made' : 'No local changes made'; -export const SyncDropdown = connect(mapStateToProps, mapDispatchToProps)(UnconnectedSyncDropdown); + return ( +
+ refreshVCSAndRefetchRemote()}> + {currentBranch === null ? + Sync : + +
+ {' '} + {initializing ? 'Initializing...' : currentBranch} +
+
+ + + + + {/* Only show cloud icons if logged in */} + {session.isLoggedIn() && ( + + {loadingPull ? ( + loadIcon + ) : ( + + + + )} + + {loadingPush ? ( + loadIcon + ) : ( + + + + )} + + )} +
+
} + + {syncMenuHeader} + + {!session.isLoggedIn() && ( + showModal(LoginModalHandle)}> + Log In + + )} + + showModal(SyncBranchesModal, { onHide: refreshVCSAndRefetchRemote })}> + + Branches + + + showModal(SyncDeleteModal, { onHide: refreshVCSAndRefetchRemote })} disabled={historyCount === 0}> + + Delete {strings.collection.singular} + + + Local Branches + {visibleBranches.map(branch => { + const icon = branch === currentBranch ? : ; + const isCurrentBranch = branch === currentBranch; + return handleSwitchBranch(branch)} + className={classnames({ + bold: isCurrentBranch, + })} + title={isCurrentBranch ? '' : `Switch to "${branch}"`} + > + {icon} + {branch} + ; + })} + + {currentBranch} + + showModal(SyncHistoryModal)} disabled={historyCount === 0}> + + History + + + + + Revert Changes + + + + showModal(SyncStagingModal, { + onSnapshot: refreshVCSAndRefetchRemote, + handlePush, + }) + } + disabled={!canCreateSnapshot} + > + + Create Snapshot + + + + {loadingPull ? ( + + Pulling Snapshots... + + ) : ( + + Pull {behind || ''} Snapshot + {behind === 1 ? '' : 's'} + + )} + + + + {loadingPush ? ( + + Pushing Snapshots... + + ) : ( + + Push {ahead || ''} Snapshot + {ahead === 1 ? '' : 's'} + + )} + +
+
+ ); +}; diff --git a/packages/insomnia/src/ui/components/modals/request-settings-modal.tsx b/packages/insomnia/src/ui/components/modals/request-settings-modal.tsx index b03c041d3..7d376f824 100644 --- a/packages/insomnia/src/ui/components/modals/request-settings-modal.tsx +++ b/packages/insomnia/src/ui/components/modals/request-settings-modal.tsx @@ -89,15 +89,15 @@ export const RequestSettingsModal = forwardRef ({ ...state, justMoved: true, - }); + })); setTimeout(() => { - setState({ + setState(state => ({ ...state, justMoved: false, - }); + })); }, 2000); } @@ -119,15 +119,15 @@ export const RequestSettingsModal = forwardRef ({ ...state, justCopied: true, - }); + })); setTimeout(() => { - setState({ + setState(state => ({ ...state, justCopied: false, - }); + })); }, 2000); models.stats.incrementCreatedRequests(); } @@ -139,9 +139,9 @@ export const RequestSettingsModal = forwardRef ({ ...state, request: updated, - }); + })); }; return ( @@ -195,7 +195,7 @@ export const RequestSettingsModal = forwardRef ) : ( - {error} -

- )} -
-
-
- -
-
- -
+ const handleCreate = async (event: React.SyntheticEvent) => { + event.preventDefault(); + try { + // Create new branch + const { newBranchName } = state; + await vcs.fork(newBranchName); + // Checkout new branch + const delta = await vcs.checkout(syncItems, newBranchName); + // @ts-expect-error -- TSCONVERSION + await db.batchModifyDocs(delta); + // Clear branch name and refresh things + await refreshState(); + setState(state => ({ + ...state, + newBranchName: '', + })); + } catch (err) { + console.log('Failed to create', err.stack); + setState(state => ({ + ...state, + error: err.message, + })); + } + }; + const { branches, remoteBranches, currentBranch, newBranchName, error } = state; + + return ( + + Branches + + {error && ( +

+ + {error} +

+ )} + +
+
+
- +
+ +
+
+ +
+ + + + + + + + + {branches.map(name => ( + + + + + ))} + +
Branches 
+ + {name} + + {name === currentBranch ? ( + (current) + ) : null} + {name === 'master' && } + + handleMerge(name)} + > + Merge + + handleDelete(name)} + > + Delete + + +
+
+ + {remoteBranches.length > 0 && (
- + - {branches.map(name => ( + {remoteBranches.map(name => ( ))}
BranchesRemote Branches  
- - {name} - - {name === currentBranch ? ( - (current) - ) : null} + {name} {name === 'master' && } - handleRemoteDelete(name)} + > + Delete + + )} + this._handleMerge(name)} + vcs={vcs} > - Merge - - this._handleDelete(name)} - > - Delete - - + Fetch +
- - {remoteBranches.length > 0 && ( -
- - - - - - - - - {remoteBranches.map(name => ( - - - - - ))} - -
Remote Branches 
- {name} - {name === 'master' && } - - {name !== 'master' && ( - this._handleRemoteDelete(name)} - > - Delete - - )} - - Fetch - -
-
- )} -
-
- ); - } -} - -const mapStateToProps = (state: RootState) => ({ - syncItems: selectSyncItems(state), + )} + + + ); }); - -export const SyncBranchesModal = connect( - mapStateToProps, - null, - null, - { forwardRef: true }, -)(UnconnectedSyncBranchesModal); +SyncBranchesModal.displayName = 'SyncBranchesModal'; diff --git a/packages/insomnia/src/ui/components/modals/sync-delete-modal.tsx b/packages/insomnia/src/ui/components/modals/sync-delete-modal.tsx index 10fb74dc6..1ebbe041b 100644 --- a/packages/insomnia/src/ui/components/modals/sync-delete-modal.tsx +++ b/packages/insomnia/src/ui/components/modals/sync-delete-modal.tsx @@ -1,141 +1,88 @@ -import { autoBindMethodsForReact } from 'class-autobind-decorator'; -import React, { PureComponent } from 'react'; -import { connect } from 'react-redux'; +import React, { forwardRef, useImperativeHandle, useRef, useState } from 'react'; +import { useSelector } from 'react-redux'; -import { AUTOBIND_CFG } from '../../../common/constants'; import { strings } from '../../../common/strings'; import { interceptAccessError } from '../../../sync/vcs/util'; import { VCS } from '../../../sync/vcs/vcs'; -import { RootState } from '../../redux/modules'; +import { Button } from '../../components/themed-button'; import { selectActiveWorkspace } from '../../redux/selectors'; -import { type ModalHandle, Modal } from '../base/modal'; +import { type ModalHandle, Modal, ModalProps } from '../base/modal'; import { ModalBody } from '../base/modal-body'; import { ModalHeader } from '../base/modal-header'; -import { Button } from '../themed-button'; - -type ReduxProps = ReturnType; - -interface Props extends ReduxProps { +type Props = ModalProps & { vcs: VCS; -} - -interface State { +}; +export interface SyncDeleteModalOptions { error: string; workspaceName: string; + onHide?: () => void; } +export interface SyncDeleteModalHandle { + show: (options: SyncDeleteModalOptions) => void; + hide: () => void; +} +export const SyncDeleteModal = forwardRef(({ vcs }, ref) => { + const modalRef = useRef(null); + const [state, setState] = useState({ + error: '', + workspaceName: '', + }); -const INITIAL_STATE: State = { - error: '', - workspaceName: '', -}; - -@autoBindMethodsForReact(AUTOBIND_CFG) -export class UnconnectedSyncDeleteModal extends PureComponent { - modal: ModalHandle | null = null; - input: HTMLInputElement | null = null; - - constructor(props: Props) { - super(props); - this.state = INITIAL_STATE; - } - - _setModalRef(modal: ModalHandle) { - this.modal = modal; - } - - _setInputRef(input: HTMLInputElement) { - this.input = input; - } - - _updateWorkspaceName(event: React.SyntheticEvent) { - this.setState({ - workspaceName: event.currentTarget.value, - }); - } - - async _handleDelete(event: React.SyntheticEvent) { + useImperativeHandle(ref, () => ({ + hide: () => modalRef.current?.hide(), + show: ({ onHide }) => { + setState({ + error: '', + workspaceName: '', + onHide, + }); + modalRef.current?.show({ onHide }); + }, + }), []); + const activeWorkspace = useSelector(selectActiveWorkspace); + const onSubmit = async (event: React.SyntheticEvent) => { event.preventDefault(); - const { vcs } = this.props; - const { workspaceName } = this.state; - try { await interceptAccessError({ action: 'delete', callback: () => vcs.archiveProject(), - resourceName: workspaceName, + resourceName: state.workspaceName, resourceType: strings.collection.singular.toLowerCase(), }); - this.hide(); + modalRef.current?.hide(); } catch (err) { - this.setState({ + setState(state => ({ + ...state, error: err.message, - }); + })); } - } + }; + const { error, workspaceName } = state; - async show() { - this.modal && this.modal.show(); - // Reset state - this.setState(INITIAL_STATE); - // Focus input when modal shows - setTimeout(() => { - this.input?.focus(); - }, 100); - } - - hide() { - this.modal?.hide(); - } - - render() { - const { error, workspaceName } = this.state; - const { activeWorkspace } = this.props; - const workspaceNameElement = ( - - {activeWorkspace?.name} - - ); - return ( - - Delete {strings.collection.singular} - - {error &&

{error}

} -

- This will permanently delete the {workspaceNameElement}{' '} - {strings.collection.singular.toLowerCase()} remotely. -

-

Please type {workspaceNameElement} to confirm.

- -
-
- - -
-
-
-
- ); - } -} - -const mapStateToProps = (state: RootState) => ({ - activeWorkspace: selectActiveWorkspace(state), + return ( + + Delete {strings.collection.singular} + + {error &&

{error}

} +

+ This will permanently delete the {{activeWorkspace?.name}}{' '} + {strings.collection.singular.toLowerCase()} remotely. +

+

Please type {{activeWorkspace?.name}} to confirm.

+
+
+ setState(state => ({ ...state, workspaceName: event.target.value }))} + value={workspaceName} + /> + +
+
+
+
+ ); }); - -export const SyncDeleteModal = connect( - mapStateToProps, - null, - null, - { forwardRef: true }, -)(UnconnectedSyncDeleteModal); +SyncDeleteModal.displayName = 'SyncDeleteModal'; diff --git a/packages/insomnia/src/ui/components/modals/sync-history-modal.tsx b/packages/insomnia/src/ui/components/modals/sync-history-modal.tsx index bec6a1917..63813b8b5 100644 --- a/packages/insomnia/src/ui/components/modals/sync-history-modal.tsx +++ b/packages/insomnia/src/ui/components/modals/sync-history-modal.tsx @@ -1,11 +1,11 @@ -import { autoBindMethodsForReact } from 'class-autobind-decorator'; -import React, { Fragment, PureComponent } from 'react'; +import React, { forwardRef, useImperativeHandle, useRef, useState } from 'react'; +import { useSelector } from 'react-redux'; import * as session from '../../../account/session'; -import { AUTOBIND_CFG } from '../../../common/constants'; import type { Snapshot } from '../../../sync/types'; import { VCS } from '../../../sync/vcs/vcs'; -import { type ModalHandle, Modal } from '../base/modal'; +import { selectSyncItems } from '../../redux/selectors'; +import { type ModalHandle, Modal, ModalProps } from '../base/modal'; import { ModalBody } from '../base/modal-body'; import { ModalHeader } from '../base/modal-header'; import { PromptButton } from '../base/prompt-button'; @@ -13,143 +13,119 @@ import { HelpTooltip } from '../help-tooltip'; import { TimeFromNow } from '../time-from-now'; import { Tooltip } from '../tooltip'; -interface Props { +type Props = ModalProps & { vcs: VCS; -} - +}; interface State { branch: string; history: Snapshot[]; } - -@autoBindMethodsForReact(AUTOBIND_CFG) -export class SyncHistoryModal extends PureComponent { - modal: ModalHandle | null = null; - handleRollback?: (arg0: Snapshot) => Promise; - - state: State = { +export interface SyncHistoryModalHandle { + show: () => void; + hide: () => void; +} +export const SyncHistoryModal = forwardRef(({ vcs }, ref) => { + const modalRef = useRef(null); + const [state, setState] = useState({ branch: '', history: [], - }; - - _setModalRef(modal: ModalHandle) { - this.modal = modal; - } - - async _handleClickRollback(snapshot: Snapshot) { - // TODO: unsound non-null assertion - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - await this.handleRollback!(snapshot); - await this.refreshState(); - } - - async refreshState(newState?: Record) { - const { vcs } = this.props; - const branch = await vcs.getBranch(); - const history = await vcs.getHistory(); - this.setState({ - branch, - history: history.sort((a, b) => (a.created < b.created ? 1 : -1)), - ...newState, - }); - } - - hide() { - this.modal?.hide(); - } - - async show(options: { handleRollback: (arg0: Snapshot) => Promise }) { - this.modal?.show(); - this.handleRollback = options.handleRollback; - await this.refreshState(); - } - - static renderAuthorName(snapshot: Snapshot) { - let name = ''; - let email = ''; + }); + const syncItems = useSelector(selectSyncItems); + useImperativeHandle(ref, () => ({ + hide: () => { + modalRef.current?.hide(); + }, + show: async () => { + const branch = await vcs.getBranch(); + const history = await vcs.getHistory(); + setState({ + branch, + history: history.sort((a, b) => (a.created < b.created ? 1 : -1)), + }); + modalRef.current?.show(); + }, + }), [vcs]); + const authorName = (snapshot: Snapshot) => { + let fullName = ''; if (snapshot.authorAccount) { const { firstName, lastName } = snapshot.authorAccount; - name += `${firstName} ${lastName}`; - email = snapshot.authorAccount.email; + fullName += `${firstName} ${lastName}`; } - if (snapshot.author === session.getAccountId()) { - name += ' (you)'; + fullName += ' (you)'; } - if (name) { - return ( - - {name}{' '} - - {email} - - - ); - } else { - return '--'; - } - } - - render() { - const { branch, history } = this.state; - return ( - - - Branch History: {branch} - - - - - - - - - - - - - - {history.map(snapshot => ( - - - - - - + + + + ))} + +
MessageWhenAuthorObjects - Restore - - This will revert the workspace to that state stored in the snapshot - -
- - {snapshot.name} - - - - {SyncHistoryModal.renderAuthorName(snapshot)}{snapshot.state.length} - this._handleClickRollback(snapshot)} + return fullName; + }; + const { branch, history } = state; + return ( + + + Branch History: {branch} + + + + + + + + + + + + + + {history.map(snapshot => ( + + + + - - ))} - -
MessageWhenAuthorObjects + Restore + + This will revert the workspace to that state stored in the snapshot + +
+ + {snapshot.name} + + + + {Boolean(authorName(snapshot)) ? ( + <> + {authorName(snapshot)}{' '} + - Restore - -
-
-
- ); - } -} + {snapshot.authorAccount?.email || ''} + + + ) : '--'}
{snapshot.state.length} + { + const delta = await vcs.rollback(snapshot.id, syncItems); + // @ts-expect-error -- TSCONVERSION + await db.batchModifyDocs(delta); + }} + > + Restore + +
+
+
+ ); +}); +SyncHistoryModal.displayName = 'SyncHistoryModal'; diff --git a/packages/insomnia/src/ui/components/modals/sync-merge-modal.tsx b/packages/insomnia/src/ui/components/modals/sync-merge-modal.tsx index 67f4523e5..7a1bb2803 100644 --- a/packages/insomnia/src/ui/components/modals/sync-merge-modal.tsx +++ b/packages/insomnia/src/ui/components/modals/sync-merge-modal.tsx @@ -1,143 +1,103 @@ -import { autoBindMethodsForReact } from 'class-autobind-decorator'; -import React, { PureComponent } from 'react'; -import { connect } from 'react-redux'; +import React, { forwardRef, useImperativeHandle, useRef, useState } from 'react'; -import { AUTOBIND_CFG } from '../../../common/constants'; -import type { DocumentKey, MergeConflict } from '../../../sync/types'; -import { VCS } from '../../../sync/vcs/vcs'; -import { RootState } from '../../redux/modules'; -import { selectSyncItems } from '../../redux/selectors'; -import { type ModalHandle, Modal } from '../base/modal'; +import type { MergeConflict } from '../../../sync/types'; +import { type ModalHandle, Modal, ModalProps } from '../base/modal'; import { ModalBody } from '../base/modal-body'; import { ModalFooter } from '../base/modal-footer'; import { ModalHeader } from '../base/modal-header'; - -type ReduxProps = ReturnType; - -interface Props extends ReduxProps { - vcs: VCS; +export interface SyncMergeModalOptions { + conflicts?: MergeConflict[]; + handleDone?: (conflicts?: MergeConflict[]) => void; } - -interface State { - conflicts: MergeConflict[]; +export interface SyncMergeModalHandle { + show: (options: SyncMergeModalOptions) => void; + hide: () => void; } - -@autoBindMethodsForReact(AUTOBIND_CFG) -export class UnconnectedSyncMergeModal extends PureComponent { - modal: ModalHandle | null = null; - _handleDone?: (arg0: MergeConflict[]) => void; - - state: State = { +export const SyncMergeModal = forwardRef((_, ref) => { + const modalRef = useRef(null); + const [state, setState] = useState({ conflicts: [], - }; + }); - _setModalRef(modal: ModalHandle) { - this.modal = modal; - } + useImperativeHandle(ref, () => ({ + hide: () => modalRef.current?.hide(), + show: ({ conflicts, handleDone }) => { + setState({ + conflicts, + handleDone, + }); + modalRef.current?.show(); + }, + }), []); - _handleOk() { - // TODO: unsound non-null assertion - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - this._handleDone!(this.state.conflicts); + const { conflicts, handleDone } = state; - this.hide(); - } - - _handleToggleSelect(key: DocumentKey, event: React.SyntheticEvent) { - const conflicts = this.state.conflicts.map(c => { - if (c.key !== key) { - return c; - } - - return { ...c, choose: event.currentTarget.value || null }; - }); - this.setState({ - conflicts, - }); - } - - async show(options: { - conflicts: MergeConflict[]; - handleDone: (arg0: MergeConflict[]) => void; - }) { - this.modal?.show(); - this._handleDone = options.handleDone; - this.setState({ - conflicts: options.conflicts, - }); - } - - hide() { - this.modal?.hide(); - } - - render() { - const { conflicts } = this.state; - return ( - - Resolve Conflicts - - - - - - - + return ( + + Resolve Conflicts + +
NameDescription - Choose -
+ + + + + + + + + {conflicts?.length && conflicts.map(conflict => ( + + + + - - - {conflicts.map(conflict => ( - - - - - - ))} - -
NameDescription + Choose +
{conflict.name}{conflict.message} + + +
{conflict.name}{conflict.message} - - -
-
- - - -
- ); - } -} - -const mapStateToProps = (state: RootState) => ({ - syncItems: selectSyncItems(state), + ))} + + + + + + + + ); }); - -export const SyncMergeModal = connect( - mapStateToProps, - null, - null, - { forwardRef: true }, -)(UnconnectedSyncMergeModal); +SyncMergeModal.displayName = 'SyncMergeModal'; diff --git a/packages/insomnia/src/ui/components/modals/sync-staging-modal.tsx b/packages/insomnia/src/ui/components/modals/sync-staging-modal.tsx index bf7099869..66313996b 100644 --- a/packages/insomnia/src/ui/components/modals/sync-staging-modal.tsx +++ b/packages/insomnia/src/ui/components/modals/sync-staging-modal.tsx @@ -1,28 +1,23 @@ -import { autoBindMethodsForReact } from 'class-autobind-decorator'; -import React, { Fragment, PureComponent, ReactNode } from 'react'; -import { connect } from 'react-redux'; +import React, { forwardRef, useCallback, useImperativeHandle, useRef, useState } from 'react'; +import { useSelector } from 'react-redux'; -import { AUTOBIND_CFG } from '../../../common/constants'; import { strings } from '../../../common/strings'; import * as models from '../../../models'; import { BaseModel } from '../../../models'; import type { DocumentKey, Stage, StageEntry, Status } from '../../../sync/types'; import { describeChanges } from '../../../sync/vcs/util'; import { VCS } from '../../../sync/vcs/vcs'; -import { RootState } from '../../redux/modules'; import { selectSyncItems } from '../../redux/selectors'; import { IndeterminateCheckbox } from '../base/indeterminate-checkbox'; -import { type ModalHandle, Modal } from '../base/modal'; +import { type ModalHandle, Modal, ModalProps } from '../base/modal'; import { ModalBody } from '../base/modal-body'; import { ModalFooter } from '../base/modal-footer'; import { ModalHeader } from '../base/modal-header'; import { Tooltip } from '../tooltip'; -type ReduxProps = ReturnType; - -interface Props extends ReduxProps { +type Props = ModalProps & { vcs: VCS; -} +}; type LookupMap = Record; -interface State { +export interface SyncStagingModalOptions { status: Status; message: string; error: string; branch: string; lookupMap: LookupMap; + onSnapshot: () => Promise; + handlePush: () => Promise; } -const _initialState: State = { - status: { - stage: {}, - unstaged: {}, - key: '', - }, - branch: '', - error: '', - message: '', - lookupMap: {}, -}; +export interface SyncStagingModalHandle { + show: (options: SyncStagingModalOptions) => void; + hide: () => void; +} -@autoBindMethodsForReact(AUTOBIND_CFG) -export class UnconnectedSyncStagingModal extends PureComponent { - modal: ModalHandle | null = null; - _onSnapshot: (() => void) | null = null; - _handlePush: (() => Promise) | null = null; - textarea: HTMLTextAreaElement | null = null; - state = _initialState; +export const SyncStagingModal = forwardRef(({ vcs }, ref) => { + const modalRef = useRef(null); + const syncItems = useSelector(selectSyncItems); + const [state, setState] = useState({ + status: { + stage: {}, + unstaged: {}, + key: '', + }, + branch: '', + error: '', + message: '', + lookupMap: {}, + onSnapshot: async () => { }, + handlePush: async () => { }, + }); - _setModalRef(modal: ModalHandle) { - this.modal = modal; - } + const refreshVCS = useCallback(async (newStage: Stage = {}) => { + const branch = await vcs.getBranch(); + const status = await vcs.status(syncItems, newStage); + const lookupMap: LookupMap = {}; + const allKeys = [...Object.keys(status.stage), ...Object.keys(status.unstaged)]; + for (const key of allKeys) { + const lastSnapshot: BaseModel | null = await vcs.blobFromLastSnapshot(key); + const document = syncItems.find(si => si.key === key)?.document; + const docOrLastSnapshot = document || lastSnapshot; + const entry = status.stage[key] || status.unstaged[key]; + const hasStagingChangeAndDoc = entry && docOrLastSnapshot; + const hasDocAndLastSnapshot = document && lastSnapshot; + if (hasStagingChangeAndDoc) { + lookupMap[key] = { + changes: hasDocAndLastSnapshot ? describeChanges(document, lastSnapshot) : null, + entry: entry, + type: models.getModelName(docOrLastSnapshot.type), + checked: !!status.stage[key], + }; + } + } + setState(state => ({ + ...state, + status, + branch, + lookupMap, + error: '', + })); + }, [syncItems, vcs]); - _setTextAreaRef(textarea: HTMLTextAreaElement) { - this.textarea = textarea; - } + useImperativeHandle(ref, () => ({ + hide: () => { + modalRef.current?.hide(); + }, + show: async ({ onSnapshot, handlePush }) => { + modalRef.current?.show(); + // Reset state + setState({ + status: { + stage: {}, + unstaged: {}, + key: '', + }, + branch: '', + error: '', + message: '', + lookupMap: {}, + onSnapshot, + handlePush, + }); + // Add everything to stage by default except new items + const status: Status = await vcs.status(syncItems, {}); + const toStage: StageEntry[] = []; + for (const key of Object.keys(status.unstaged)) { + if ('added' in status.unstaged[key]) { + // Don't automatically stage added resources + continue; + } + toStage.push(status.unstaged[key]); + } + const stage = await vcs.stage(status.stage, toStage); + await refreshVCS(stage); + }, + }), [refreshVCS, syncItems, vcs]); - _handleClearError() { - this.setState({ error: '' }); - } - - _handleMessageChange(event: React.ChangeEvent) { - this.setState({ message: event.currentTarget.value }); - } - - async _handleStageToggle(event: React.SyntheticEvent) { - const { vcs } = this.props; - const { status } = this.state; + const handleStageToggle = async (event: React.SyntheticEvent) => { + const { status } = state; const id = event.currentTarget.name; const isStaged = !!status.stage[id]; const newStage = isStaged ? await vcs.unstage(status.stage, [status.stage[id]]) : await vcs.stage(status.stage, [status.unstaged[id]]); - await this.refreshMainAttributes({}, newStage); - } + await refreshVCS(newStage); + }; - async _handleAllToggle(keys: DocumentKey[], doStage: boolean) { - const { vcs } = this.props; - const { status } = this.state; + const handleAllToggle = async (keys: DocumentKey[], doStage: boolean) => { + const { status } = state; let stage; - if (doStage) { const entries: StageEntry[] = []; - for (const k of Object.keys(status.unstaged)) { if (keys.includes(k)) { entries.push(status.unstaged[k]); } } - stage = await vcs.stage(status.stage, entries); } else { const entries: StageEntry[] = []; - for (const k of Object.keys(status.stage)) { if (keys.includes(k)) { entries.push(status.stage[k]); } } - stage = await vcs.unstage(status.stage, entries); } + await refreshVCS(stage); + }; - await this.refreshMainAttributes({}, stage); - } - - async _handleTakeSnapshotAndPush() { - const success = await this._handleTakeSnapshot(); - + const handleTakeSnapshotAndPush = async () => { + const success = await handleTakeSnapshot(); if (success) { - this._handlePush?.(); + state.handlePush?.(); } - } + }; - async _handleTakeSnapshot() { - const { vcs } = this.props; + const handleTakeSnapshot = async () => { const { message, status: { stage }, - } = this.state; - + onSnapshot, + } = state; try { await vcs.takeSnapshot(stage, message); } catch (err) { - this.setState({ + setState(state => ({ + ...state, error: err.message, - }); + })); return false; } - - this._onSnapshot?.(); - await this.refreshMainAttributes({ - message: '', - error: '', - }); - this.hide(); + onSnapshot?.(); + await refreshVCS(); + setState(state => ({ ...state, message: '', error: '' })); + modalRef.current?.hide(); return true; - } + }; - async refreshMainAttributes(newState: Partial = {}, newStage: Stage = {}) { - const { vcs, syncItems } = this.props; - const branch = await vcs.getBranch(); - const status = await vcs.status(syncItems, newStage); - const lookupMap: LookupMap = {}; - const allKeys = [...Object.keys(status.stage), ...Object.keys(status.unstaged)]; + const { status, message, error, branch } = state; + const allMap = { ...status.stage, ...status.unstaged }; + const addedKeys: string[] = Object.entries(allMap) + .filter(([, value]) => 'added' in value) + .map(([key]) => key); + const nonAddedKeys: string[] = Object.entries(allMap) + .filter(([, value]) => !('added' in value)) + .map(([key]) => key); - for (const key of allKeys) { - const item = syncItems.find(si => si.key === key); - const oldDoc: BaseModel | null = await vcs.blobFromLastSnapshot(key); - const doc = (item && item.document) || oldDoc; - const entry = status.stage[key] || status.unstaged[key]; - - if (!entry || !doc) { - continue; - } - - let changes: string[] | null = null; - - if (item && item.document && oldDoc) { - changes = describeChanges(item.document, oldDoc); - } - - lookupMap[key] = { - changes, - entry: entry, - type: models.getModelName(doc.type), - checked: !!status.stage[key], - }; - } - - // @ts-expect-error -- TSCONVERSION - this.setState({ - status, - branch, - lookupMap, - error: '', - ...newState, - }); - } - - hide() { - this.modal?.hide(); - } - - async show(options: { onSnapshot?: () => any; handlePush: () => Promise }) { - const { vcs, syncItems } = this.props; - this.modal?.show(); - // @ts-expect-error -- TSCONVERSION - this._onSnapshot = options.onSnapshot; - this._handlePush = options.handlePush; - // Reset state - this.setState(_initialState); - // Add everything to stage by default except new items - const status: Status = await vcs.status(syncItems, {}); - const toStage: StageEntry[] = []; - - for (const key of Object.keys(status.unstaged)) { - // @ts-expect-error -- TSCONVERSION - if (status.unstaged[key].added) { - // Don't automatically stage added resources - continue; - } - - toStage.push(status.unstaged[key]); - } - - const stage = await vcs.stage(status.stage, toStage); - await this.refreshMainAttributes({}, stage); - this.textarea?.focus(); - } - - static renderOperation(entry: StageEntry, type: string, changes: string[]) { - let child: JSX.Element | null = null; - let message = ''; - - // @ts-expect-error -- TSCONVERSION type narrowing - if (entry.added) { - child = ; - message = 'Added'; - // @ts-expect-error -- TSCONVERSION type narrowing - } else if (entry.modified) { - child = ; - message = `Modified (${changes.join(', ')})`; - // @ts-expect-error -- TSCONVERSION type narrowing - } else if (entry.deleted) { - child = ; - message = 'Deleted'; - } else { - child = ; - message = 'Unknown'; - } - - if (type === models.workspace.type) { - type = strings.collection.singular; - } - - return ( - - - {child} {type} - - - ); - } - - renderTable(keys: DocumentKey[], title: ReactNode) { - const { status, lookupMap } = this.state; - - if (keys.length === 0) { - return null; - } - - let allUnChecked = true; - let allChecked = true; - - for (const key of keys.sort()) { - if (!status.stage[key]) { - allChecked = false; - } - - if (!status.unstaged[key]) { - allUnChecked = false; - } - } - - const indeterminate = !allChecked && !allUnChecked; - return ( -
- {title} - - - - - - - - - - {keys.map(key => { - if (!lookupMap[key]) { - return null; - } - - const { entry, type, checked, changes } = lookupMap[key]; - return ( - - - - - - ); - })} - -
- - ChangesDescription
- - {changes ? changes.join(', ') : '--'} - {SyncStagingModal.renderOperation(entry, type, changes || [])} -
-
- ); - } - - render() { - const { status, message, error, branch } = this.state; - const nonAddedKeys: string[] = []; - const addedKeys: string[] = []; - const allMap = { ...status.stage, ...status.unstaged }; - const allKeys = Object.keys(allMap); - - for (const key of allKeys) { - // @ts-expect-error -- TSCONVERSION - if (allMap[key].added) { - addedKeys.push(key); - } else { - nonAddedKeys.push(key); - } - } - - return ( - - Create Snapshot - - {error && ( -

- - {error} -

- )} - -
-
-