insomnia/packages/insomnia-app/app/ui/components/dropdowns/sync-dropdown.js
2019-06-07 11:25:21 -04:00

539 lines
15 KiB
JavaScript

// @flow
import * as React from 'react';
import autobind from 'autobind-decorator';
import classnames from 'classnames';
import { Dropdown, DropdownButton, DropdownDivider, DropdownItem } from '../base/dropdown';
import type { Workspace } from '../../../models/workspace';
import { showAlert, showModal } from '../modals';
import SyncStagingModal from '../modals/sync-staging-modal';
import HelpTooltip from '../help-tooltip';
import Link from '../base/link';
import SyncHistoryModal from '../modals/sync-history-modal';
import SyncShareModal from '../modals/sync-share-modal';
import SyncBranchesModal from '../modals/sync-branches-modal';
import VCS from '../../../sync/vcs';
import type { Project, Snapshot, Status, StatusCandidate } from '../../../sync/types';
import ErrorModal from '../modals/error-modal';
import Tooltip from '../tooltip';
import LoginModal from '../modals/login-modal';
import * as session from '../../../account/session';
import PromptButton from '../base/prompt-button';
import * as db from '../../../common/database';
// Stop refreshing if user hasn't been active in this long
const REFRESH_USER_ACTIVITY = 1000 * 60 * 10;
// Refresh dropdown periodically
const REFRESH_PERIOD = 1000 * 60 * 1;
type Props = {
workspace: Workspace,
vcs: VCS,
syncItems: Array<StatusCandidate>,
// Optional
className?: string,
};
type State = {
currentBranch: string,
localBranches: Array<string>,
compare: {
ahead: number,
behind: number,
},
status: Status,
initializing: boolean,
historyCount: number,
loadingPull: boolean,
loadingProjectPull: boolean,
loadingPush: boolean,
remoteProjects: Array<Project>,
};
@autobind
class SyncDropdown extends React.PureComponent<Props, State> {
checkInterval: IntervalID;
refreshOnNextSyncItems = false;
lastUserActivity = Date.now();
constructor(props: Props) {
super(props);
this.state = {
localBranches: [],
currentBranch: '',
compare: {
ahead: 0,
behind: 0,
},
historyCount: 0,
initializing: true,
loadingPull: false,
loadingPush: false,
loadingProjectPull: false,
status: {
key: 'n/a',
stage: {},
unstaged: {},
},
remoteProjects: [],
};
}
async refreshMainAttributes(extraState?: Object = {}) {
const { vcs, syncItems, workspace } = this.props;
if (!vcs.hasProject()) {
const remoteProjects = await vcs.remoteProjects();
const matchedProjects = remoteProjects.filter(p => p.rootDocumentId === workspace._id);
this.setState({ remoteProjects: matchedProjects });
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 = {
status,
historyCount,
localBranches,
currentBranch,
...extraState,
};
// Do the remote stuff
if (session.isLoggedIn()) {
try {
newState.compare = await vcs.compareRemoteBranch();
} catch (err) {
console.log('Failed to compare remote branches', err.message);
}
}
this.setState(newState);
}
componentDidMount() {
this.setState({ initializing: true });
this.refreshMainAttributes()
.catch(err => console.log('[sync_menu] Error refreshing sync state', err))
.finally(() => this.setState({ initializing: false }));
// 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() {
clearInterval(this.checkInterval);
document.removeEventListener('mousemove', this._handleUserActivity);
}
componentDidUpdate(prevProps: Props) {
const { vcs, syncItems } = this.props;
// Update if new sync items
if (syncItems !== prevProps.syncItems) {
if (vcs.hasProject()) {
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 _handleShowSharingModal() {
showModal(SyncShareModal);
}
static _handleShowLoginModal() {
showModal(LoginModal);
}
async _handlePushChanges() {
const { vcs } = this.props;
this.setState({ loadingPush: true });
try {
await vcs.push();
} catch (err) {
showModal(ErrorModal, {
title: 'Push Error',
message: err.message,
});
}
await this.refreshMainAttributes({ loadingPush: false });
}
async _handlePullChanges() {
const { vcs, syncItems } = this.props;
this.setState({ loadingPull: true });
try {
const delta = await vcs.pull(syncItems);
await db.batchModifyDocs(delta);
this.refreshOnNextSyncItems = true;
} catch (err) {
showModal(ErrorModal, {
title: 'Pull Error',
message: err.message,
});
}
this.setState({ loadingPull: false });
}
async _handleRollback(snapshot: Snapshot) {
const { vcs, syncItems } = this.props;
const delta = await vcs.rollback(snapshot.id, syncItems);
await db.batchModifyDocs(delta);
this.refreshOnNextSyncItems = true;
}
async _handleRevert() {
const { vcs, syncItems } = this.props;
try {
const delta = await vcs.rollbackToLatest(syncItems);
await db.batchModifyDocs(delta);
} catch (err) {
showModal(ErrorModal, {
title: 'Revert Error',
message: err.message,
});
}
}
_handleShowHistoryModal() {
showModal(SyncHistoryModal, {
handleRollback: this._handleRollback,
});
}
async _handleOpen() {
await this.refreshMainAttributes();
}
async _handleEnableSync() {
const { vcs, workspace } = this.props;
await vcs.switchAndCreateProjectIfNotExist(workspace._id, workspace.name);
}
async _handleSetProject(p: Project) {
const { vcs } = this.props;
this.setState({ loadingProjectPull: true });
await vcs.setProject(p);
await vcs.checkout([], 'master');
// Pull changes
await vcs.pull([]); // There won't be any existing docs since it's a new pull
const flushId = await db.bufferChanges();
for (const doc of await vcs.allDocuments()) {
await db.upsert(doc);
}
await db.flushChanges(flushId);
await this.refreshMainAttributes({ loadingProjectPull: false });
}
async _handleSwitchBranch(branch: string) {
const { vcs, syncItems } = this.props;
try {
const delta = await vcs.checkout(syncItems, branch);
await db.batchModifyDocs(delta);
} catch (err) {
showAlert({
title: 'Branch Switch Error',
message: err.message,
});
}
// 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({ currentBranch: branch });
}
renderBranch(branch: string) {
const { currentBranch } = this.state;
const icon =
branch === currentBranch ? <i className="fa fa-tag" /> : <i className="fa fa-empty" />;
const isCurrentBranch = branch === currentBranch;
return (
<DropdownItem
key={branch}
onClick={isCurrentBranch ? null : () => this._handleSwitchBranch(branch)}
className={classnames({ bold: isCurrentBranch })}
title={isCurrentBranch ? null : `Switch to "${branch}"`}>
{icon}
{branch}
</DropdownItem>
);
}
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 = <i className="fa fa-spin fa-refresh fa--fixed-width" />;
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 <React.Fragment>Sync</React.Fragment>;
}
return (
<DropdownButton className="btn btn--compact wide text-left overflow-hidden row-spaced">
<div className="ellipsis">
<i className="fa fa-code-fork space-right" />{' '}
{initializing ? 'Initializing...' : currentBranch}
</div>
<div className="space-left">
<Tooltip message={snapshotToolTipMsg} delay={800}>
<i
className={classnames('icon fa fa-cube fa--fixed-width', {
'super-duper-faint': !canCreateSnapshot,
})}
/>
</Tooltip>
{/* Only show cloud icons if logged in */}
{session.isLoggedIn() && (
<React.Fragment>
{loadingPull ? (
loadIcon
) : (
<Tooltip message={pullToolTipMsg} delay={800}>
<i
className={classnames('fa fa-cloud-download fa--fixed-width', {
'super-duper-faint': !canPull,
})}
/>
</Tooltip>
)}
{loadingPush ? (
loadIcon
) : (
<Tooltip message={pushToolTipMsg} delay={800}>
<i
className={classnames('fa fa-cloud-upload fa--fixed-width', {
'super-duper-faint': !canPush,
})}
/>
</Tooltip>
)}
</React.Fragment>
)}
</div>
</DropdownButton>
);
}
render() {
if (!session.isLoggedIn()) {
return null;
}
const { className, vcs } = this.props;
const {
localBranches,
currentBranch,
status,
historyCount,
loadingPull,
loadingPush,
loadingProjectPull,
remoteProjects,
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 = (
<DropdownDivider>
Insomnia Sync{' '}
<HelpTooltip>
Sync and collaborate on workspaces{' '}
<Link href="https://support.insomnia.rest/article/67-version-control">
<span className="no-wrap">
<br />
Documentation <i className="fa fa-external-link" />
</span>
</Link>
</HelpTooltip>
</DropdownDivider>
);
if (loadingProjectPull) {
return (
<div className={className}>
<button className="btn btn--compact wide">
<i className="fa fa-refresh fa-spin" /> Initializing
</button>
</div>
);
}
if (!vcs.hasProject()) {
return (
<div className={className}>
<Dropdown className="wide tall" onOpen={this._handleOpen}>
<DropdownButton className="btn btn--compact wide">
<i className="fa fa-code-fork " /> Setup Sync
</DropdownButton>
{syncMenuHeader}
{remoteProjects.length === 0 && (
<DropdownItem onClick={this._handleEnableSync}>
<i className="fa fa-plus-circle" /> Create Local Project
</DropdownItem>
)}
{remoteProjects.map(p => (
<DropdownItem key={p.id} onClick={() => this._handleSetProject(p)}>
<i className="fa fa-cloud-download" /> Pull <strong>{p.name}</strong>
</DropdownItem>
))}
</Dropdown>
</div>
);
}
return (
<div className={className}>
<Dropdown className="wide tall" onOpen={this._handleOpen}>
{this.renderButton()}
{syncMenuHeader}
{!session.isLoggedIn() && (
<DropdownItem onClick={SyncDropdown._handleShowLoginModal}>
<i className="fa fa-sign-in" /> Log In
</DropdownItem>
)}
<DropdownItem onClick={SyncDropdown._handleShowSharingModal}>
<i className="fa fa-users" />
Share Settings
</DropdownItem>
<DropdownItem onClick={this._handleShowBranchesModal}>
<i className="fa fa-code-fork" />
Branches
</DropdownItem>
<DropdownDivider>Local Branches</DropdownDivider>
{visibleBranches.map(this.renderBranch)}
<DropdownDivider>{currentBranch}</DropdownDivider>
<DropdownItem onClick={this._handleShowHistoryModal} disabled={historyCount === 0}>
<i className="fa fa-clock-o" />
History
</DropdownItem>
<DropdownItem
onClick={this._handleRevert}
buttonClass={PromptButton}
stayOpenAfterClick
disabled={!canCreateSnapshot || historyCount === 0}>
<i className="fa fa-undo" />
Revert Changes
</DropdownItem>
<DropdownItem onClick={this._handleShowStagingModal} disabled={!canCreateSnapshot}>
<i className="fa fa-cube" />
Create Snapshot
</DropdownItem>
<DropdownItem onClick={this._handlePullChanges} disabled={behind === 0 || loadingPull}>
{loadingPull ? (
<React.Fragment>
<i className="fa fa-spin fa-refresh" /> Pulling Snapshots...
</React.Fragment>
) : (
<React.Fragment>
<i className="fa fa-cloud-upload" /> Pull {behind || ''} Snapshot
{behind === 1 ? '' : 's'}
</React.Fragment>
)}
</DropdownItem>
<DropdownItem onClick={this._handlePushChanges} disabled={ahead === 0 || loadingPush}>
{loadingPush ? (
<React.Fragment>
<i className="fa fa-spin fa-refresh" /> Pushing Snapshots...
</React.Fragment>
) : (
<React.Fragment>
<i className="fa fa-cloud-upload" /> Push {ahead || ''} Snapshot
{ahead === 1 ? '' : 's'}
</React.Fragment>
)}
</DropdownItem>
</Dropdown>
</div>
);
}
}
export default SyncDropdown;