refactor: transform git modals to fc (#5179)

* transform git modals to fc

* second pass

* add GitRepositorySettingsModal

* fixes

* tweaks

* sit sync dropdown second pass

* git branches second pass

* git staging second pass

* repo settings second pass

* fix types

* fix button color

* fix refresh
This commit is contained in:
Jack Kavanagh authored and GitHub committed 2022-10-24 17:39:14 +02:00
1 parent 4ca4b54e96
commit 1bf7b7bc2b
7 files changed
+782 -1067

No files matched your search

@@ -1,22 +1,20 @@
import { autoBindMethodsForReact } from 'class-autobind-decorator';
import classnames from 'classnames';
import React, { Fragment, PureComponent, ReactNode } from 'react';
import { connect } from 'react-redux';
import { AnyAction, bindActionCreators, Dispatch } from 'redux';
import React, { FC, Fragment, useRef, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useMount } from 'react-use';
import { SegmentEvent, trackSegmentEvent, vcsSegmentEventProperties } from '../../../common/analytics';
import { AUTOBIND_CFG } from '../../../common/constants';
import { database as db } from '../../../common/database';
import { docsGitSync } from '../../../common/documentation';
import { isNotNullOrUndefined } from '../../../common/misc';
import * as models from '../../../models';
import type { GitRepository } from '../../../models/git-repository';
import type { Workspace } from '../../../models/workspace';
import type { GitLogEntry, GitVCS } from '../../../sync/git/git-vcs';
import { MemClient } from '../../../sync/git/mem-client';
import { getOauth2FormatName } from '../../../sync/git/utils';
import { initialize as initializeEntities } from '../../redux/modules/entities';
import * as gitActions from '../../redux/modules/git';
import { selectActiveGitRepository } from '../../redux/selectors';
import { type DropdownHandle, Dropdown } from '../base/dropdown/dropdown';
import { DropdownButton } from '../base/dropdown/dropdown-button';
import { DropdownDivider } from '../base/dropdown/dropdown-divider';
@@ -28,44 +26,37 @@ import { GitBranchesModal } from '../modals/git-branches-modal';
import { GitLogModal } from '../modals/git-log-modal';
import { GitStagingModal } from '../modals/git-staging-modal';
type Props = ReturnType<typeof mapDispatchToProps> & {
interface Props {
workspace: Workspace;
vcs: GitVCS;
gitRepository: GitRepository | null;
className?: string;
renderDropdownButton?: (children: ReactNode) => ReactNode;
};
}
interface State {
initializing: boolean;
loadingPush: boolean;
loadingPull: boolean;
log: GitLogEntry[];
branch: string;
branches: string[];
}
@autoBindMethodsForReact(AUTOBIND_CFG)
class GitSyncDropdown extends PureComponent<Props, State> {
_dropdown: DropdownHandle | null = null;
state: State = {
initializing: false,
export const GitSyncDropdown: FC<Props> = ({ workspace, vcs, className }) => {
const [state, setState] = useState<State>({
loadingPush: false,
loadingPull: false,
log: [],
branch: '',
branches: [],
};
});
const dispatch = useDispatch();
const gitRepository = useSelector(selectActiveGitRepository);
const dropdownRef = useRef<DropdownHandle>(null);
_setDropdownRef(dropdown: DropdownHandle) {
this._dropdown = dropdown;
}
useMount(() => {
refreshState();
});
async _refreshState(otherState?: Record<string, any>) {
const { vcs, workspace } = this.props;
async function refreshState() {
const workspaceMeta = await models.workspaceMeta.getOrCreateByParentId(workspace._id);
// Clear cached items and return if no state
if (!vcs.isInitialized() || !workspaceMeta.gitRepositoryId) {
// Don't update unnecessarily
@@ -74,107 +65,49 @@ class GitSyncDropdown extends PureComponent<Props, State> {
workspaceMeta.cachedGitLastAuthor,
workspaceMeta.cachedGitLastCommitTime,
].some(isNotNullOrUndefined);
if (needsUpdate) {
await models.workspaceMeta.updateByParentId(workspace._id, {
models.workspaceMeta.updateByParentId(workspace._id, {
cachedGitRepositoryBranch: null,
cachedGitLastAuthor: null,
cachedGitLastCommitTime: null,
});
}
return;
}
const branch = await vcs.getBranch();
const branches = await vcs.listBranches();
const log = (await vcs.log()) || [];
this.setState({ ...(otherState || {}), log, branch, branches });
setState(state => ({ ...state, log, branch, branches }));
const author = log[0] ? log[0].commit.author : null;
const cachedGitRepositoryBranch = branch;
const cachedGitLastAuthor = author ? author.name : null;
// NOTE: We're converting timestamp to ms here
const cachedGitLastCommitTime = author ? author.timestamp * 1000 : null;
await models.workspaceMeta.updateByParentId(workspace._id, {
cachedGitRepositoryBranch,
cachedGitLastAuthor,
cachedGitRepositoryBranch: branch,
cachedGitLastAuthor: author?.name || null,
cachedGitLastCommitTime,
});
}
async _handleOpen() {
await this._refreshState();
}
async _handlePull() {
this.setState({
loadingPull: true,
});
const { vcs, gitRepository } = this.props;
async function handlePush({ force }: { force: boolean }) {
if (!gitRepository) {
// Should never happen
throw new Error('Tried to pull without configuring git repo');
return;
}
const bufferId = await db.bufferChanges();
const providerName = getOauth2FormatName(gitRepository.credentials);
try {
await vcs.pull(gitRepository.credentials);
trackSegmentEvent(SegmentEvent.vcsAction, { ...vcsSegmentEventProperties('git', 'pull'), providerName });
} catch (err) {
showError({
title: 'Error Pulling Repository',
error: err,
});
trackSegmentEvent(SegmentEvent.vcsAction, vcsSegmentEventProperties('git', 'pull', err.message));
}
await db.flushChanges(bufferId);
this.setState({
loadingPull: false,
});
}
async _handlePush(_e: unknown, force = false) {
this.setState({
loadingPush: true,
});
const { vcs, gitRepository } = this.props;
if (!gitRepository) {
// Should never happen
throw new Error('Tried to push without configuring git repo');
}
setState(state => ({ ...state, loadingPush: true }));
// Check if there is anything to push
let canPush = false;
try {
canPush = await vcs.canPush(gitRepository.credentials);
} catch (err) {
showError({
title: 'Error Pushing Repository',
error: err,
});
this.setState({
loadingPush: false,
});
showError({ title: 'Error Pushing Repository', error: err });
setState(state => ({ ...state, loadingPush: false }));
return;
}
// If nothing to push, display that to the user
if (!canPush) {
showAlert({
title: 'Push Skipped',
message: 'Everything up-to-date. Nothing was pushed to the remote',
});
this.setState({
loadingPush: false,
});
showAlert({ title: 'Push Skipped', message: 'Everything up-to-date. Nothing was pushed to the remote' });
setState(state => ({ ...state, loadingPush: false }));
return;
}
const bufferId = await db.bufferChanges();
const providerName = getOauth2FormatName(gitRepository.credentials);
try {
@@ -182,236 +115,180 @@ class GitSyncDropdown extends PureComponent<Props, State> {
trackSegmentEvent(SegmentEvent.vcsAction, { ...vcsSegmentEventProperties('git', force ? 'force_push' : 'push'), providerName });
} catch (err) {
if (err.code === 'PushRejectedError') {
this._dropdown?.hide();
dropdownRef.current?.hide();
showAlert({
title: 'Push Rejected',
message: 'Do you want to force push?',
okLabel: 'Force Push',
addCancel: true,
onConfirm: () => {
this._handlePush(null, true);
handlePush({ force: true });
},
});
} else {
showError({
title: 'Error Pushing Repository',
error: err,
});
showError({ title: 'Error Pushing Repository', error: err });
trackSegmentEvent(SegmentEvent.vcsAction, { ...vcsSegmentEventProperties('git', force ? 'force_push' : 'push', err.message), providerName });
}
}
await db.flushChanges(bufferId);
this.setState({
loadingPush: false,
});
setState(state => ({ ...state, loadingPush: false }));
}
_handleConfig() {
const { gitRepository, workspace, updateGitRepository, setupGitRepository } = this.props;
if (gitRepository) {
updateGitRepository({
gitRepository,
});
} else {
setupGitRepository({
workspace,
createFsClient: MemClient.createClient,
});
}
const { log, branches, branch, loadingPull, loadingPush } = state;
let iconClassName = '';
const providerName = getOauth2FormatName(gitRepository?.credentials);
if (providerName === 'github') {
iconClassName = 'fa fa-github';
}
_handleLog() {
showModal(GitLogModal);
if (providerName === 'gitlab') {
iconClassName = 'fa fa-gitlab';
}
return (
<div className={className}>
<Dropdown className="wide tall" ref={dropdownRef}>
{vcs.isInitialized() ?
(<DropdownButton className="btn--clicky-small btn-sync">
{iconClassName && <i className={classnames('space-right', iconClassName)} />}
<div className="ellipsis">{branch}</div>
<i className="fa fa-code-fork space-left" />
</DropdownButton>) :
(<DropdownButton className="btn--clicky-small btn-sync">
<i className="fa fa-code-fork space-right" />
Setup Git Sync
</DropdownButton>)}
async _handleCommit() {
const { gitRepository } = this.props;
showModal(GitStagingModal, {
onCommit: this._refreshState,
gitRepository,
});
}
<DropdownDivider>
Git Sync
<HelpTooltip>
Sync and collaborate with Git{' '}
<Link href={docsGitSync}>
<span className="no-wrap">
<br />
Documentation <i className="fa fa-external-link" />
</span>
</Link>
</HelpTooltip>
</DropdownDivider>
_handleManageBranches() {
showModal(GitBranchesModal, {
onHide: this._refreshState,
});
}
<DropdownItem
onClick={() => {
if (gitRepository) {
dispatch(gitActions.updateGitRepository({
gitRepository,
}));
} else {
dispatch(gitActions.setupGitRepository({
workspace,
createFsClient: MemClient.createClient,
}));
}
}}
>
<i className="fa fa-wrench" /> Repository Settings
</DropdownItem>
async _handleCheckoutBranch(branch: string) {
const { vcs, handleInitializeEntities } = this.props;
const bufferId = await db.bufferChanges();
{vcs.isInitialized() && (
<Fragment>
<DropdownItem onClick={() => showModal(GitBranchesModal, { onCheckout: refreshState })}>
<i className="fa fa-code-fork" /> Branches
</DropdownItem>
</Fragment>
)}
try {
await vcs.checkout(branch);
} catch (err) {
showError({
title: 'Checkout Error',
error: err,
});
}
await db.flushChanges(bufferId, true);
await handleInitializeEntities();
await this._refreshState();
}
_getProviderIconClassName(): string | undefined {
const { gitRepository } = this.props;
const providerName = getOauth2FormatName(gitRepository?.credentials);
if (providerName === 'github') {
return 'fa fa-github';
}
if (providerName === 'gitlab') {
return 'fa fa-gitlab';
}
return;
}
componentDidMount() {
this._refreshState();
}
renderButton() {
const { branch } = this.state;
const { vcs, renderDropdownButton } = this.props;
const renderBtn =
renderDropdownButton ||
(children => (
<DropdownButton className="btn btn--compact wide text-left overflow-hidden row-spaced" name="git-sync-dropdown-btn">
{children}
</DropdownButton>
));
if (!vcs.isInitialized()) {
return renderBtn(
<Fragment>
<i className="fa fa-code-fork space-right" />
Setup Git Sync
</Fragment>,
);
}
const initializing = false;
const iconClassName = this._getProviderIconClassName();
return renderBtn(
<Fragment>
{iconClassName && <i className={classnames('space-right', iconClassName)} />}
<div className="ellipsis">{initializing ? 'Initializing...' : branch}</div>
<i className="fa fa-code-fork space-left" />
</Fragment>,
);
}
renderBranch(branch: string) {
const { branch: 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 ? undefined : () => this._handleCheckoutBranch(branch)}
className={classnames({
bold: isCurrentBranch,
})}
title={isCurrentBranch ? '' : `Switch to "${branch}"`}
>
{icon}
{branch}
</DropdownItem>
);
}
render() {
const { className, vcs } = this.props;
const { log, branches, branch, loadingPull, loadingPush } = this.state;
return (
<div className={className}>
<Dropdown className="wide tall" onOpen={this._handleOpen} ref={this._setDropdownRef}>
{this.renderButton()}
<DropdownDivider>
Git Sync
<HelpTooltip>
Sync and collaborate with Git{' '}
<Link href={docsGitSync}>
<span className="no-wrap">
<br />
Documentation <i className="fa fa-external-link" />
</span>
</Link>
</HelpTooltip>
</DropdownDivider>
<DropdownItem onClick={this._handleConfig}>
<i className="fa fa-wrench" /> Repository Settings
</DropdownItem>
{vcs.isInitialized() && (
<Fragment>
<DropdownItem onClick={this._handleManageBranches}>
<i className="fa fa-code-fork" /> Branches
</DropdownItem>
</Fragment>
)}
{vcs.isInitialized() && (
<Fragment>
<DropdownDivider>Branches</DropdownDivider>
{branches.map(this.renderBranch)}
<DropdownDivider>{branch}</DropdownDivider>
<DropdownItem onClick={this._handleCommit}>
<i className="fa fa-check" /> Commit
</DropdownItem>
{log.length > 0 && (
<DropdownItem onClick={this._handlePush} stayOpenAfterClick>
<i
className={classnames({
fa: true,
'fa-spin fa-refresh': loadingPush,
'fa-cloud-upload': !loadingPush,
})}
/>{' '}
Push
{vcs.isInitialized() && (
<Fragment>
<DropdownDivider>Branches</DropdownDivider>
{branches.map(branch => {
const { branch: currentBranch } = state;
const icon = branch === currentBranch ? <i className="fa fa-tag" /> : <i className="fa fa-empty" />;
const isCurrentBranch = branch === currentBranch;
return (
<DropdownItem
key={branch}
onClick={async () => {
if (isCurrentBranch) {
return;
}
const bufferId = await db.bufferChanges();
try {
await vcs.checkout(branch);
} catch (error) {
showError({ title: 'Checkout Error', error });
}
await db.flushChanges(bufferId, true);
await dispatch(initializeEntities());
refreshState();
}}
className={classnames({ bold: isCurrentBranch })}
title={isCurrentBranch ? '' : `Switch to "${branch}"`}
>
{icon}
{branch}
</DropdownItem>
)}
<DropdownItem onClick={this._handlePull} stayOpenAfterClick>
);
})}
<DropdownDivider>{branch}</DropdownDivider>
<DropdownItem
onClick={() => showModal(GitStagingModal, {
onCommit: refreshState,
gitRepository,
})}
>
<i className="fa fa-check" /> Commit
</DropdownItem>
{log.length > 0 && (
<DropdownItem onClick={() => handlePush({ force: false })} stayOpenAfterClick>
<i
className={classnames({
fa: true,
'fa-spin fa-refresh': loadingPull,
'fa-cloud-download': !loadingPull,
'fa-spin fa-refresh': loadingPush,
'fa-cloud-upload': !loadingPush,
})}
/>{' '}
Pull
Push
</DropdownItem>
<DropdownItem onClick={this._handleLog} disabled={log.length === 0}>
<i className="fa fa-clock-o" /> History ({log.length})
</DropdownItem>
</Fragment>
)}
</Dropdown>
</div>
);
}
}
function mapDispatchToProps(dispatch: Dispatch<AnyAction>) {
const boundGitActions = bindActionCreators({ ...gitActions, initializeEntities }, dispatch);
return {
setupGitRepository: boundGitActions.setupGitRepository,
updateGitRepository: boundGitActions.updateGitRepository,
handleInitializeEntities: boundGitActions.initializeEntities,
};
}
export default connect(null, mapDispatchToProps)(GitSyncDropdown);
)}
<DropdownItem
onClick={async () => {
setState(state => ({ ...state, loadingPull: true }));
if (!gitRepository) {
// Should never happen
throw new Error('Tried to pull without configuring git repo');
}
const bufferId = await db.bufferChanges();
const providerName = getOauth2FormatName(gitRepository.credentials);
try {
await vcs.pull(gitRepository.credentials);
trackSegmentEvent(SegmentEvent.vcsAction, { ...vcsSegmentEventProperties('git', 'pull'), providerName });
} catch (err) {
showError({
title: 'Error Pulling Repository',
error: err,
});
trackSegmentEvent(SegmentEvent.vcsAction, vcsSegmentEventProperties('git', 'pull', err.message));
}
await db.flushChanges(bufferId);
setState(state => ({ ...state, loadingPull: false }));
}}
stayOpenAfterClick
>
<i
className={classnames({
fa: true,
'fa-spin fa-refresh': loadingPull,
'fa-cloud-download': !loadingPull,
})}
/>{' '}
Pull
</DropdownItem>
<DropdownItem onClick={() => showModal(GitLogModal)} disabled={log.length === 0}>
<i className="fa fa-clock-o" /> History ({log.length})
</DropdownItem>
</Fragment>
)}
</Dropdown>
</div>
);
};
@@ -1,318 +1,261 @@
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, { forwardRef, useCallback, useImperativeHandle, useRef, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { SegmentEvent, trackSegmentEvent, vcsSegmentEventProperties } from '../../../common/analytics';
import { AUTOBIND_CFG } from '../../../common/constants';
import { database as db } from '../../../common/database';
import type { GitRepository } from '../../../models/git-repository';
import { GitVCS } from '../../../sync/git/git-vcs';
import { getOauth2FormatName } from '../../../sync/git/utils';
import { initialize as initializeEntities } from '../../redux/modules/entities';
import { type ModalHandle, Modal } from '../base/modal';
import { selectActiveGitRepository } from '../../redux/selectors';
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 { PromptButton } from '../base/prompt-button';
type Props = ReturnType<typeof mapDispatchToProps> & {
type Props = ModalProps & {
vcs: GitVCS;
gitRepository: GitRepository;
};
interface State {
export interface GitBranchesModalOptions {
onCheckout: () => void;
}
export interface State {
error: string;
branches: string[];
remoteBranches: string[];
branch: string;
newBranchName: string;
onCheckout: () => void;
}
@autoBindMethodsForReact(AUTOBIND_CFG)
export class GitBranchesModalClass extends PureComponent<Props, State> {
modal: ModalHandle | null = null;
input: HTMLInputElement | null = null;
_onHide?: (() => void) | null;
state: State = {
export interface GitBranchesModalHandle {
show: (options: GitBranchesModalOptions) => void;
hide: () => void;
}
export const GitBranchesModal = forwardRef<GitBranchesModalHandle, Props>(({ vcs }, ref) => {
const modalRef = useRef<ModalHandle>(null);
const [state, setState] = useState<State>({
error: '',
branch: '??',
branches: [],
remoteBranches: [],
newBranchName: '',
};
_setModalRef(ref: ModalHandle) {
this.modal = ref;
}
_setInputRef(ref: HTMLInputElement) {
this.input = ref;
}
async show(
options: {
onHide?: () => void;
} = {},
) {
await this._refreshState({
newBranchName: '',
});
this._onHide = options.onHide || null;
this.modal?.show();
// Focus input when modal shows
setTimeout(() => {
this.input?.focus();
}, 100);
// Do a fetch of remotes and refresh again. NOTE: we're doing this
// last because it's super slow
const { vcs, gitRepository } = this.props;
await vcs.fetch(false, 1, gitRepository.credentials);
await this._refreshState();
}
async _refreshState(newState?: Record<string, any>) {
const { vcs } = this.props;
onCheckout: () => {},
});
const dispatch = useDispatch();
const gitRepository = useSelector(selectActiveGitRepository);
const refreshState = useCallback(async () => {
const branch = await vcs.getBranch();
const branches = await vcs.listBranches();
const remoteBranches = await vcs.listRemoteBranches();
this.setState({
setState(state => ({
...state,
branch,
branches,
remoteBranches,
...newState,
});
}
}));
}, [vcs]);
_handleClearError() {
this.setState({
error: '',
});
}
useImperativeHandle(ref, () => ({
hide: () => {
modalRef.current?.hide();
},
show: async ({ onCheckout }) => {
setState(state => ({ ...state, newBranchName: '', onCheckout }));
await refreshState();
modalRef.current?.show({ onHide: onCheckout });
// Do a fetch of remotes and refresh again. NOTE: we're doing this
// last because it's super slow
await vcs.fetch(false, 1, gitRepository?.credentials);
await refreshState();
},
}), [refreshState, vcs, gitRepository?.credentials]);
_handleHide() {
if (this._onHide) {
this._onHide();
}
}
async _errorHandler(cb: () => Promise<void>) {
const handleCheckout = async (branch: string) => {
try {
return await cb();
} catch (err) {
this.setState({
error: err.message,
});
}
}
async _handleCreate(event: React.SyntheticEvent<HTMLFormElement>) {
event.preventDefault();
await this._errorHandler(async () => {
const { vcs, gitRepository } = this.props;
const { newBranchName } = this.state;
const providerName = getOauth2FormatName(gitRepository.credentials);
await vcs.checkout(newBranchName);
trackSegmentEvent(SegmentEvent.vcsAction, { ...vcsSegmentEventProperties('git', 'create_branch'), providerName });
await this._refreshState({
newBranchName: '',
});
});
}
async _handleMerge(branch: string) {
await this._errorHandler(async () => {
const { vcs, gitRepository } = this.props;
const providerName = getOauth2FormatName(gitRepository.credentials);
await vcs.merge(branch);
// Apparently merge doesn't update the working dir so need to checkout too
await this._handleCheckout(branch);
trackSegmentEvent(SegmentEvent.vcsAction, { ...vcsSegmentEventProperties('git', 'merge_branch'), providerName });
});
}
async _handleDelete(branch: string) {
await this._errorHandler(async () => {
const { vcs, gitRepository } = this.props;
const providerName = getOauth2FormatName(gitRepository.credentials);
await vcs.deleteBranch(branch);
trackSegmentEvent(SegmentEvent.vcsAction, { ...vcsSegmentEventProperties('git', 'delete_branch'), providerName });
await this._refreshState();
});
}
async _handleRemoteCheckout(branch: string) {
await this._errorHandler(async () => {
const { vcs, gitRepository } = this.props;
// First fetch more history to make sure we have lots
await vcs.fetch(true, 20, gitRepository.credentials);
await this._handleCheckout(branch);
});
}
async _handleCheckout(branch: string) {
await this._errorHandler(async () => {
const { vcs, gitRepository, handleInitializeEntities } = this.props;
const bufferId = await db.bufferChanges();
const providerName = getOauth2FormatName(gitRepository.credentials);
const providerName = getOauth2FormatName(gitRepository?.credentials);
await vcs.checkout(branch);
trackSegmentEvent(SegmentEvent.vcsAction, { ...vcsSegmentEventProperties('git', 'checkout_branch'), providerName });
await db.flushChanges(bufferId, true);
await handleInitializeEntities();
await this._refreshState();
});
}
_updateNewBranchName(event: React.SyntheticEvent<HTMLInputElement>) {
this.setState({
newBranchName: event.currentTarget.value,
});
}
hide() {
this.modal?.hide();
}
render() {
const { branch: currentBranch, branches, remoteBranches, newBranchName, error } = this.state;
const remoteOnlyBranches = remoteBranches.filter(b => !branches.includes(b));
return (
<Modal ref={this._setModalRef} onHide={this._handleHide}>
<ModalHeader>Local Branches</ModalHeader>
<ModalBody className="pad">
{error && (
<p className="notice error margin-bottom-sm no-margin-top">
<button className="pull-right icon" onClick={this._handleClearError}>
<i className="fa fa-times" />
</button>
{error}
</p>
)}
<form onSubmit={this._handleCreate}>
<div className="form-row">
<div className="form-control form-control--outlined">
<label>
New Branch Name
<input
type="text"
ref={this._setInputRef}
onChange={this._updateNewBranchName}
placeholder="testing-branch"
value={newBranchName}
/>
</label>
</div>
<div className="form-control form-control--no-label width-auto">
<button type="submit" className="btn btn--clicky" disabled={!newBranchName}>
Create
</button>
</div>
await dispatch(initializeEntities());
await refreshState();
state.onCheckout?.();
} catch (err) {
setState(state => ({ ...state, error: err.message }));
}
};
const { branch: currentBranch, branches, remoteBranches, newBranchName, error, onCheckout } = state;
const remoteOnlyBranches = remoteBranches.filter(b => !branches.includes(b));
return (
<Modal ref={modalRef}>
<ModalHeader>Local Branches</ModalHeader>
<ModalBody className="pad">
{error && (
<p className="notice error margin-bottom-sm no-margin-top">
<button className="pull-right icon" onClick={() => setState({ ...state, error: '' })}>
<i className="fa fa-times" />
</button>
{error}
</p>
)}
<form
onSubmit={async event => {
event.preventDefault();
try {
const providerName = getOauth2FormatName(gitRepository?.credentials);
await vcs.checkout(state.newBranchName);
trackSegmentEvent(SegmentEvent.vcsAction, { ...vcsSegmentEventProperties('git', 'create_branch'), providerName });
setState({ ...state, newBranchName: '' });
refreshState();
onCheckout?.();
} catch (err) {
setState(state => ({ ...state, error: err.message }));
}
}}
>
<div className="form-row">
<div className="form-control form-control--outlined">
<label>
New Branch Name
<input
type="text"
onChange={event => {
setState({ ...state, newBranchName: event.currentTarget.value });
}}
placeholder="testing-branch"
value={newBranchName}
/>
</label>
</div>
</form>
<div className="form-control form-control--no-label width-auto">
<button type="submit" className="btn btn--clicky" disabled={!newBranchName}>
Create
</button>
</div>
</div>
</form>
<div className="pad-top">
<table className="table--fancy table--outlined">
<thead>
<tr>
<th className="text-left">Branches</th>
<th className="text-right">&nbsp;</th>
</tr>
</thead>
<tbody>
{branches.map(branch => (
<tr key={branch} className="table--no-outline-row">
<td>
<span
className={classnames({
bold: branch === currentBranch,
})}
>
{branch}
</span>
{branch === currentBranch ? (
<span className="txt-sm space-left">(current)</span>
) : null}
</td>
<td className="text-right">
{branch !== currentBranch && (
<>
<PromptButton
className="btn btn--micro btn--outlined space-left"
doneMessage="Merged"
onClick={async () => {
try {
const providerName = getOauth2FormatName(gitRepository?.credentials);
await vcs.merge(branch);
// Apparently merge doesn't update the working dir so need to checkout too
await handleCheckout(branch);
trackSegmentEvent(SegmentEvent.vcsAction, { ...vcsSegmentEventProperties('git', 'merge_branch'), providerName });
} catch (err) {
setState(state => ({ ...state, error: err.message }));
}
}}
>
Merge
</PromptButton>
<PromptButton
className="btn btn--micro btn--outlined space-left"
doneMessage="Deleted"
onClick={async () => {
try {
const providerName = getOauth2FormatName(gitRepository?.credentials);
await vcs.deleteBranch(branch);
trackSegmentEvent(SegmentEvent.vcsAction, { ...vcsSegmentEventProperties('git', 'delete_branch'), providerName });
refreshState();
} catch (err) {
setState(state => ({ ...state, error: err.message }));
}
}}
>
Delete
</PromptButton>
<button
className="btn btn--micro btn--outlined space-left"
onClick={() => handleCheckout(branch)}
>
Checkout
</button>
</>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
{remoteOnlyBranches.length > 0 && (
<div className="pad-top">
<table className="table--fancy table--outlined">
<thead>
<tr>
<th className="text-left">Branches</th>
<th className="text-left">Remote Branches</th>
<th className="text-right">&nbsp;</th>
</tr>
</thead>
<tbody>
{branches.map(name => (
<tr key={name} className="table--no-outline-row">
<td>
<span
className={classnames({
bold: name === currentBranch,
})}
>
{name}
</span>
{name === currentBranch ? (
<span className="txt-sm space-left">(current)</span>
) : null}
</td>
{remoteOnlyBranches.map(branch => (
<tr key={branch} className="table--no-outline-row">
<td>{branch}</td>
<td className="text-right">
{name !== currentBranch && (
<Fragment>
<PromptButton
className="btn btn--micro btn--outlined space-left"
doneMessage="Merged"
onClick={() => this._handleMerge(name)}
>
Merge
</PromptButton>
<PromptButton
className="btn btn--micro btn--outlined space-left"
doneMessage="Deleted"
onClick={() => this._handleDelete(name)}
>
Delete
</PromptButton>
<button
className="btn btn--micro btn--outlined space-left"
onClick={() => this._handleCheckout(name)}
>
Checkout
</button>
</Fragment>
)}
<button
className="btn btn--micro btn--outlined space-left"
onClick={async () => {
try {
// First fetch more history to make sure we have lots
await vcs.fetch(true, 20, gitRepository?.credentials);
handleCheckout(branch);
} catch (err) {
setState(state => ({ ...state, error: err.message }));
}
}}
>
Checkout
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{remoteOnlyBranches.length > 0 && (
<div className="pad-top">
<table className="table--fancy table--outlined">
<thead>
<tr>
<th className="text-left">Remote Branches</th>
<th className="text-right">&nbsp;</th>
</tr>
</thead>
<tbody>
{remoteOnlyBranches.map(name => (
<tr key={name} className="table--no-outline-row">
<td>{name}</td>
<td className="text-right">
<button
className="btn btn--micro btn--outlined space-left"
onClick={() => this._handleRemoteCheckout(name)}
>
Checkout
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</ModalBody>
<ModalFooter>
<div className="margin-left italic txt-sm">
<i className="fa fa-code-fork" /> {currentBranch}
</div>
<div>
<button className="btn" onClick={this.hide}>
Done
</button>
</div>
</ModalFooter>
</Modal>
);
}
}
function mapDispatchToProps(dispatch: Dispatch<AnyAction>) {
const boundGitActions = bindActionCreators({ initializeEntities }, dispatch);
return {
handleInitializeEntities: boundGitActions.initializeEntities,
};
}
export const GitBranchesModal = connect(null, mapDispatchToProps, null, { forwardRef: true })(GitBranchesModalClass);
)}
</ModalBody>
<ModalFooter>
<div className="margin-left italic txt-sm">
<i className="fa fa-code-fork" /> {currentBranch}
</div>
<div>
<button className="btn" onClick={() => modalRef.current?.hide()}>
Done
</button>
</div>
</ModalFooter>
</Modal>
);
});
GitBranchesModal.displayName = 'GitBranchesModal';
@@ -1,104 +1,93 @@
import { autoBindMethodsForReact } from 'class-autobind-decorator';
import React, { PureComponent } from 'react';
import React, { forwardRef, useImperativeHandle, useRef, useState } from 'react';
import { AUTOBIND_CFG } from '../../../common/constants';
import type { GitLogEntry, GitVCS } from '../../../sync/git/git-vcs';
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 { TimeFromNow } from '../time-from-now';
import { Tooltip } from '../tooltip';
interface Props {
type Props = ModalProps & {
vcs: GitVCS;
}
};
interface State {
interface GitLogModalOptions {
logs: GitLogEntry[];
branch: string;
}
@autoBindMethodsForReact(AUTOBIND_CFG)
export class GitLogModal extends PureComponent<Props, State> {
modal: ModalHandle | null = null;
state: State = {
export interface GitLogModalHandle {
show: () => void;
hide: () => void;
}
export const GitLogModal = forwardRef<GitLogModalHandle, Props>(({ vcs }, ref) => {
const modalRef = useRef<ModalHandle>(null);
const [state, setState] = useState<GitLogModalOptions>({
logs: [],
branch: '??',
};
});
_setModalRef(ref: ModalHandle) {
this.modal = ref;
}
useImperativeHandle(ref, () => ({
hide: () => {
modalRef.current?.hide();
},
show: async () => {
const logs = await vcs.log();
const branch = await vcs.getBranch();
setState({ logs, branch });
modalRef.current?.show();
},
}), [vcs]);
async show() {
const { vcs } = this.props;
const logs = await vcs.log();
const branch = await vcs.getBranch();
this.setState({
logs,
branch,
});
this.modal?.show();
}
hide() {
this.modal?.hide();
}
renderLogEntryRow(entry: GitLogEntry) {
const {
commit: { author, message },
oid,
} = entry;
return (
<tr key={oid}>
<td>{message}</td>
<td>
<TimeFromNow
className="no-wrap"
timestamp={author.timestamp * 1000}
intervalSeconds={30}
/>
</td>
<td>
<Tooltip message={`${author.name} <${author.email}>`} delay={800}>
{author.name}
</Tooltip>
</td>
</tr>
);
}
render() {
const { logs, branch } = this.state;
return (
<Modal ref={this._setModalRef}>
<ModalHeader>Git History ({logs.length})</ModalHeader>
<ModalBody className="pad">
<table className="table--fancy table--striped">
<thead>
<tr>
<th className="text-left">Message</th>
<th className="text-left">When</th>
<th className="text-left">Author</th>
const { logs, branch } = state;
return (
<Modal ref={modalRef}>
<ModalHeader>Git History ({logs.length})</ModalHeader>
<ModalBody className="pad">
<table className="table--fancy table--striped">
<thead>
<tr>
<th className="text-left">Message</th>
<th className="text-left">When</th>
<th className="text-left">Author</th>
</tr>
</thead>
<tbody>{logs.map(entry => {
const {
commit: { author, message },
oid,
} = entry;
return (
<tr key={oid}>
<td>{message}</td>
<td>
<TimeFromNow
className="no-wrap"
timestamp={author.timestamp * 1000}
intervalSeconds={30}
/>
</td>
<td>
<Tooltip message={`${author.name} <${author.email}>`} delay={800}>
{author.name}
</Tooltip>
</td>
</tr>
</thead>
<tbody>{logs.map(this.renderLogEntryRow)}</tbody>
</table>
</ModalBody>
<ModalFooter>
<div className="margin-left italic txt-sm">
<i className="fa fa-code-fork" /> {branch}
</div>
<div>
<button className="btn" onClick={this.hide}>
Done
</button>
</div>
</ModalFooter>
</Modal>
);
}
}
);
})}</tbody>
</table>
</ModalBody>
<ModalFooter>
<div className="margin-left italic txt-sm">
<i className="fa fa-code-fork" /> {branch}
</div>
<div>
<button className="btn" onClick={() => modalRef.current?.hide()}>
Done
</button>
</div>
</ModalFooter>
</Modal>
);
});
GitLogModal.displayName = 'GitLogModal';
@@ -1,13 +1,11 @@
import { autoBindMethodsForReact } from 'class-autobind-decorator';
import React, { PureComponent, useState } from 'react';
import React, { forwardRef, useImperativeHandle, useRef, useState } from 'react';
import { Tab, TabList, TabPanel, Tabs } from 'react-tabs';
import { AUTOBIND_CFG } from '../../../../common/constants';
import { docsGitSync } from '../../../../common/documentation';
import type { GitRepository, OauthProviderName } from '../../../../models/git-repository';
import { deleteGitRepository } from '../../../../models/helpers/git-repository-operations';
import { Link } from '../../base/link';
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';
@@ -17,70 +15,49 @@ import { CustomRepositorySettingsFormGroup } from './custom-repository-settings-
import { GitHubRepositorySetupFormGroup } from './github-repository-settings-form-group';
import { GitLabRepositorySetupFormGroup } from './gitlab-repository-settings-form-group';
interface State {
interface GitRepositorySettingsModalOptions {
gitRepository: GitRepository | null;
onSubmitEdits: (gitRepoPatch: Partial<GitRepository>) => void;
}
@autoBindMethodsForReact(AUTOBIND_CFG)
export class GitRepositorySettingsModal extends PureComponent<{}, State> {
modal: ModalHandle | null = null;
_onSubmitEdits?: ((repo: Partial<GitRepository>) => any) | null;
state: State = {
export interface GitRepositorySettingsModalHandle {
show: (options: GitRepositorySettingsModalOptions) => void;
hide: () => void;
}
export const GitRepositorySettingsModal = forwardRef<GitRepositorySettingsModalHandle, ModalProps>((_, ref) => {
const modalRef = useRef<ModalHandle>(null);
const [state, setState] = useState<GitRepositorySettingsModalOptions>({
gitRepository: null,
};
onSubmitEdits: () => {},
});
_setModalRef(modal: ModalHandle) {
this.modal = modal;
}
useImperativeHandle(ref, () => ({
hide: () => {
modalRef.current?.hide();
},
show: options => {
setState(options);
modalRef.current?.show();
},
}), []);
show(options: {
gitRepository: GitRepository | null;
onSubmitEdits: (repo: Partial<GitRepository>) => any;
}) {
this._onSubmitEdits = options.onSubmitEdits;
const { gitRepository } = options;
this.setState({ gitRepository });
this.modal?.show();
}
hide() {
this.modal?.hide();
}
render() {
const _handleReset = async () => {
const { gitRepository } = this.state;
if (!gitRepository) {
// Nothing to do
return;
}
await deleteGitRepository(gitRepository);
this.hide();
};
const _handleSubmitEdit = async (patch: Partial<GitRepository>) => {
if (this._onSubmitEdits) {
this._onSubmitEdits({ ...this.state.gitRepository, ...patch });
}
this.hide();
};
return (
<Modal ref={this._setModalRef} {...this.props}>
<ModalForm
onSubmit={patch => _handleSubmitEdit(patch)}
onReset={_handleReset}
gitRepository={this.state.gitRepository}
/>
</Modal>
);
}
}
const { gitRepository, onSubmitEdits } = state;
return (
<Modal ref={modalRef}>
<ModalForm
onSubmit={patch => {
onSubmitEdits({ ...gitRepository, ...patch });
modalRef.current?.hide();
}}
onReset={() => {
gitRepository && deleteGitRepository(gitRepository);
modalRef.current?.hide();
}}
gitRepository={gitRepository}
/>
</Modal>
);
});
GitRepositorySettingsModal.displayName = 'GitRepositorySettingsModal';
interface Props {
gitRepository: GitRepository | null;
@@ -1,11 +1,10 @@
import { autoBindMethodsForReact } from 'class-autobind-decorator';
import classnames from 'classnames';
import path from 'path';
import React, { Fragment, PureComponent } from 'react';
import React, { forwardRef, useCallback, useImperativeHandle, useRef, useState } from 'react';
import { useMount } from 'react-use';
import YAML from 'yaml';
import { SegmentEvent, trackSegmentEvent, vcsSegmentEventProperties } from '../../../common/analytics';
import { AUTOBIND_CFG } from '../../../common/constants';
import { database as db } from '../../../common/database';
import { strings } from '../../../common/strings';
import * as models from '../../../models';
@@ -17,7 +16,7 @@ import { GIT_INSOMNIA_DIR, GIT_INSOMNIA_DIR_NAME, GitVCS } from '../../../sync/g
import parseGitPath from '../../../sync/git/parse-git-path';
import { getOauth2FormatName } from '../../../sync/git/utils';
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';
@@ -33,132 +32,41 @@ interface Item {
editable: boolean;
}
interface Props {
type Props = ModalProps & {
workspace: Workspace;
vcs: GitVCS;
gitRepository: GitRepository | null;
}
};
interface State {
export interface GitStagingModalOptions {
loading: boolean;
branch: string;
message: string;
items: Record<string, Item>;
onCommit: () => Promise<void>;
statusNames: Record<string, string>;
}
export interface GitStagingModalHandle {
show: (options: GitStagingModalOptions) => void;
hide: () => void;
}
export const GitStagingModal = forwardRef<GitStagingModalHandle, Props>(({ vcs, workspace, gitRepository }, ref) => {
const modalRef = useRef<ModalHandle>(null);
const [state, setState] = useState<GitStagingModalOptions>({
loading: false,
branch: '',
message: '',
items: {},
onCommit: async () => { },
statusNames: {},
});
const INITIAL_STATE: State = {
loading: false,
branch: '',
message: '',
items: {},
};
@autoBindMethodsForReact(AUTOBIND_CFG)
export class GitStagingModal extends PureComponent<Props, State> {
modal: ModalHandle | null = null;
statusNames?: Record<string, string>;
textarea: HTMLTextAreaElement | null = null;
onCommit: null | (() => void);
constructor(props: Props) {
super(props);
this.state = INITIAL_STATE;
this.onCommit = null;
}
_setModalRef(ref: ModalHandle) {
this.modal = ref;
}
_setTextareaRef(ref: HTMLTextAreaElement) {
this.textarea = ref;
}
async _handleMessageChange(event: React.SyntheticEvent<HTMLTextAreaElement>) {
this.setState({
message: event.currentTarget.value,
});
}
async _handleCommit() {
const { vcs, gitRepository } = this.props;
const { items, message } = this.state;
// Set the stage
for (const p of Object.keys(items)) {
const item = items[p];
if (!item.staged) {
continue;
}
if (item.status.includes('deleted')) {
await vcs.remove(item.path);
} else {
await vcs.add(item.path);
}
}
await vcs.commit(message);
const providerName = getOauth2FormatName(gitRepository?.credentials);
trackSegmentEvent(SegmentEvent.vcsAction, { ...vcsSegmentEventProperties('git', 'commit'), providerName });
this.modal?.hide();
if (typeof this.onCommit === 'function') {
this.onCommit();
}
}
_hideModal() {
this.modal?.hide();
}
async _toggleAll(items: Item[], forceAdd = false) {
const allStaged = items.every(i => i.staged);
const doStage = !allStaged;
const newItems = { ...this.state.items };
for (const { path: p } of items) {
if (!newItems[p].editable) {
continue;
}
newItems[p].staged = doStage || forceAdd;
}
const providerName = getOauth2FormatName(this.props.gitRepository?.credentials);
trackSegmentEvent(SegmentEvent.vcsAction, { ...vcsSegmentEventProperties('git', doStage ? 'stage_all' : 'unstage_all'), providerName });
this.setState({
items: newItems,
});
}
async _handleToggleOne(event: React.SyntheticEvent<HTMLInputElement>) {
const newItems = { ...this.state.items };
const gitPath = event.currentTarget.name;
if (!newItems[gitPath] || !newItems[gitPath].editable) {
return;
}
newItems[gitPath].staged = !newItems[gitPath].staged;
const providerName = getOauth2FormatName(this.props.gitRepository?.credentials);
trackSegmentEvent(SegmentEvent.vcsAction, { ...vcsSegmentEventProperties('git', newItems[gitPath].staged ? 'stage' : 'unstage'), providerName });
this.setState({
items: newItems,
});
}
async getAllPaths(): Promise<string[]> {
const { vcs } = this.props;
const getAllPaths = useCallback(async (): Promise<string[]> => {
// @ts-expect-error -- TSCONVERSION
const f = vcs.getFs().promises;
const fsPaths: string[] = [];
for (const type of await f.readdir(GIT_INSOMNIA_DIR)) {
const typeDir = path.join(GIT_INSOMNIA_DIR, type);
for (const name of await f.readdir(typeDir)) {
// NOTE: git paths don't start with '/' so we're omitting
// it here too.
@@ -166,85 +74,53 @@ export class GitStagingModal extends PureComponent<Props, State> {
fsPaths.push(path.join(gitPath));
}
}
// To get all possible paths, we need to combine the paths already in Git
// with the paths on the FS. This is required to cover the case where a
// file can be deleted from FS or from Git.
const gitPaths = await vcs.listFiles();
const uniquePaths = new Set([...fsPaths, ...gitPaths]);
return Array.from(uniquePaths).sort();
}
}, [vcs]);
async show(options: { onCommit?: () => void }) {
this.onCommit = options.onCommit || null;
this.modal?.show();
// Reset state
this.setState(INITIAL_STATE);
await this._refresh(() => {
this.textarea?.focus();
});
}
async _refresh(callback?: () => void) {
const { vcs, workspace } = this.props;
this.setState({
loading: true,
});
const refresh = useCallback(async () => {
setState(state => ({ ...state, loading: true }));
// Get and set branch name
const branch = await vcs.getBranch();
this.setState({
branch,
});
setState(state => ({ ...state, branch }));
// Cache status names
const docs = await db.withDescendants(workspace);
const allPaths = await this.getAllPaths();
this.statusNames = {};
const allPaths = await getAllPaths();
const statusNames: Record<string, string> = {};
for (const doc of docs) {
const name = (isApiSpec(doc) && doc.fileName) || doc.name || '';
this.statusNames[path.join(GIT_INSOMNIA_DIR_NAME, doc.type, `${doc._id}.json`)] = name;
this.statusNames[path.join(GIT_INSOMNIA_DIR_NAME, doc.type, `${doc._id}.yml`)] = name;
statusNames[path.join(GIT_INSOMNIA_DIR_NAME, doc.type, `${doc._id}.json`)] = name;
statusNames[path.join(GIT_INSOMNIA_DIR_NAME, doc.type, `${doc._id}.yml`)] = name;
}
// Create status items
const items: Record<string, Item> = {};
const log = (await vcs.log(1)) || [];
for (const gitPath of allPaths) {
const status = await vcs.status(gitPath);
if (status === 'unmodified') {
continue;
}
if (!this.statusNames[gitPath] && log.length > 0) {
if (!statusNames[gitPath] && log.length > 0) {
const docYML = await vcs.readObjFromTree(log[0].commit.tree, gitPath);
if (!docYML) {
continue;
}
try {
// @ts-expect-error -- TSCONVERSION
const doc = YAML.parse(docYML);
this.statusNames[gitPath] = doc.name || '';
} catch (err) {
// Nothing here
if (docYML) {
try {
statusNames[gitPath] = YAML.parse(docYML.toString()).name || '';
} catch (err) {}
}
}
// We know that type is in the path; extract it. If the model is not found, set to Unknown.
let { type } = parseGitPath(gitPath);
// @ts-expect-error -- TSCONVERSION
if (!models.types().includes(type)) {
type = 'Unknown';
}
const added = status.includes('added');
let staged = !added;
let editable = true;
// We want to enforce that workspace changes are always committed because otherwise
// others won't be able to clone from it. We also make fundamental migrations to the
// scope property which need to be committed.
@@ -253,7 +129,6 @@ export class GitStagingModal extends PureComponent<Props, State> {
editable = false;
staged = true;
}
items[gitPath] = {
type: type as any,
staged,
@@ -263,50 +138,62 @@ export class GitStagingModal extends PureComponent<Props, State> {
path: gitPath,
};
}
setState(state => ({
...state,
items,
loading: false,
statusNames,
}));
}, [getAllPaths, vcs, workspace]);
this.setState(
{
items,
useImperativeHandle(ref, () => ({
hide: () => {
modalRef.current?.hide();
},
show: async ({ onCommit }) => {
modalRef.current?.show();
// Reset state
setState({
loading: false,
},
callback,
);
}
renderOperation(item: Item) {
let child: JSX.Element | null = null;
let message = '';
let type = item.type;
if (item.status.includes('added')) {
child = <i className="fa fa-plus-circle success" />;
message = 'Added';
} else if (item.status.includes('modified')) {
child = <i className="fa fa-circle faded" />;
message = 'Modified';
} else if (item.status.includes('deleted')) {
child = <i className="fa fa-minus-circle danger" />;
message = 'Deleted';
} else {
child = <i className="fa fa-question-circle info" />;
message = 'Unknown';
branch: '',
message: '',
items: {},
statusNames: {},
onCommit,
});
refresh();
},
}), [refresh]);
useMount(() => {
refresh();
});
const toggleAll = async (items: Item[], forceAdd = false) => {
const allStaged = items.every(i => i.staged);
const doStage = !allStaged;
const newItems = { ...state.items };
for (const { path: p } of items) {
if (newItems[p].editable) {
newItems[p].staged = doStage || forceAdd;
}
}
const providerName = getOauth2FormatName(gitRepository?.credentials);
trackSegmentEvent(SegmentEvent.vcsAction, { ...vcsSegmentEventProperties('git', doStage ? 'stage_all' : 'unstage_all'), providerName });
setState(state => ({ ...state, items: newItems }));
};
if (type === models.workspace.type) {
type = strings.document.singular;
const handleToggleOne = async (event: React.SyntheticEvent<HTMLInputElement>) => {
const newItems = { ...state.items };
const gitPath = event.currentTarget.name;
if (!newItems[gitPath] || !newItems[gitPath].editable) {
return;
}
newItems[gitPath].staged = !newItems[gitPath].staged;
const providerName = getOauth2FormatName(gitRepository?.credentials);
trackSegmentEvent(SegmentEvent.vcsAction, { ...vcsSegmentEventProperties('git', newItems[gitPath].staged ? 'stage' : 'unstage'), providerName });
setState(state => ({ ...state, items: newItems }));
};
return (
<Fragment>
<Tooltip message={message}>
{child} {type}
</Tooltip>
</Fragment>
);
}
async _handleRollback(items: Item[]) {
const { vcs } = this.props;
const handleRollback = async (items: Item[]) => {
const files = items
.filter(i => i.editable) // only rollback if editable
.map(i => ({
@@ -314,158 +201,208 @@ export class GitStagingModal extends PureComponent<Props, State> {
status: i.status,
}));
await gitRollback(vcs, files);
await this._refresh();
}
refresh();
};
async _handleRollbackSingle(item: Item) {
await this._handleRollback([item]);
const providerName = getOauth2FormatName(this.props.gitRepository?.credentials);
const handleRollbackSingle = (item: Item) => {
handleRollback([item]);
const providerName = getOauth2FormatName(gitRepository?.credentials);
trackSegmentEvent(SegmentEvent.vcsAction, { ...vcsSegmentEventProperties('git', 'rollback'), providerName });
}
};
async _handleRollbackAll(items: Item[]) {
await this._handleRollback(items);
const providerName = getOauth2FormatName(this.props.gitRepository?.credentials);
const handleRollbackAll = (items: Item[]) => {
handleRollback(items);
const providerName = getOauth2FormatName(gitRepository?.credentials);
trackSegmentEvent(SegmentEvent.vcsAction, { ...vcsSegmentEventProperties('git', 'rollback_all'), providerName });
}
renderItem(item: Item) {
const { path: gitPath, staged, editable } = item;
// TODO: unsound non-null assertion
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const docName = this.statusNames![gitPath] || 'n/a';
return (
<tr key={gitPath} className="table--no-outline-row">
<td>
<label className="no-pad wide">
<input
disabled={!editable}
className="space-right"
type="checkbox"
checked={staged}
name={gitPath}
onChange={this._handleToggleOne}
/>{' '}
{docName}
</label>
</td>
<td className="text-right">
{item.editable && <Tooltip message={item.added ? 'Delete' : 'Rollback'}>
<button
className="btn btn--micro space-right"
onClick={() => this._handleRollbackSingle(item)}
>
<i className={classnames('fa', item.added ? 'fa-trash' : 'fa-undo')} />
</button>
</Tooltip>}
{this.renderOperation(item)}
</td>
</tr>
);
}
renderTable(title: string, items: Item[], rollbackLabel: string) {
if (items.length === 0) {
return null;
}
const allStaged = items.every(i => i.staged);
const allUnstaged = items.every(i => !i.staged);
return (
<div className="pad-top">
<strong>{title}</strong>
<PromptButton
className="btn pull-right btn--micro"
onClick={() => this._handleRollbackAll(items)}
>
{rollbackLabel}
</PromptButton>
<table className="table--fancy table--outlined margin-top-sm">
<thead>
<tr className="table--no-outline-row">
<th>
<label className="wide no-pad">
<span className="txt-md">
<IndeterminateCheckbox
className="space-right"
// @ts-expect-error -- TSCONVERSION
type="checkbox"
checked={allStaged}
onChange={() => this._toggleAll(items, !allStaged)}
indeterminate={!allStaged && !allUnstaged}
/>
</span>{' '}
name
</label>
</th>
<th className="text-right">Description</th>
</tr>
</thead>
<tbody>{items.map(this.renderItem)}</tbody>
</table>
</div>
);
}
_renderEmpty() {
const { loading } = this.state;
if (loading) {
return <>Loading...</>;
}
return <>No changes to commit.</>;
}
_renderItems(items: Item[]) {
const { message } = this.state;
const newItems = items.filter(i => i.status.includes('added'));
const existingItems = items.filter(i => !i.status.includes('added'));
return (
<>
<div className="form-control form-control--outlined">
<textarea
ref={this._setTextareaRef}
rows={3}
required
placeholder="A descriptive message to describe changes made"
defaultValue={message}
onChange={this._handleMessageChange}
};
const { items, branch, loading, statusNames, message, onCommit } = state;
const hasChanges = !!Object.values(items).length;
return (
<Modal ref={modalRef}>
<ModalHeader>Commit Changes</ModalHeader>
<ModalBody className="wide pad">
{hasChanges ? <>
<div className="form-control form-control--outlined">
<textarea
rows={3}
required
placeholder="A descriptive message to describe changes made"
defaultValue={state.message}
onChange={event => setState({ ...state, message: event.target.value })}
/>
</div>
<ChangesTable
title="Modified Objects"
rollbackLabel='Rollback all'
items={Object.values(items).filter(i => !i.status.includes('added'))}
statusNames={statusNames}
rollbackAll={handleRollbackAll}
rollbackOne={handleRollbackSingle}
toggleAll={toggleAll}
toggleOne={handleToggleOne}
/>
<ChangesTable
title="Unversioned Objects"
rollbackLabel='Delete all'
items={Object.values(items).filter(i => i.status.includes('added'))}
statusNames={statusNames}
rollbackAll={handleRollbackAll}
rollbackOne={handleRollbackSingle}
toggleAll={toggleAll}
toggleOne={handleToggleOne}
/>
</>
: state.loading ? <>Loading...</> : <>No changes to commit.</>}
</ModalBody>
<ModalFooter>
<div className="margin-left italic txt-sm">
<i className="fa fa-code-fork" /> {branch}{' '}
{loading && <i className="fa fa-refresh fa-spin" />}
</div>
{this.renderTable('Modified Objects', existingItems, 'Rollback all')}
{this.renderTable('Unversioned Objects', newItems, 'Delete all')}
</>
);
}
<div>
<button className="btn" onClick={() => modalRef.current?.hide()}>
Close
</button>
<button
className="btn"
onClick={async () => {
for (const item of Object.values(items)) {
if (item.staged) {
item.status.includes('deleted') ? await vcs.remove(item.path) : await vcs.add(item.path);
}
}
await vcs.commit(message);
const providerName = getOauth2FormatName(gitRepository?.credentials);
trackSegmentEvent(SegmentEvent.vcsAction, { ...vcsSegmentEventProperties('git', 'commit'), providerName });
modalRef.current?.hide();
if (typeof onCommit === 'function') {
onCommit();
}
}}
disabled={loading || !hasChanges}
>
Commit
</button>
</div>
</ModalFooter>
</Modal>
);
});
GitStagingModal.displayName = 'GitStagingModal';
render() {
const { items, branch, loading } = this.state;
const itemsList = Object.keys(items).map(k => items[k]);
const hasChanges = !!itemsList.length;
const OperationTooltip = ({ item }: { item: Item }) => {
const type = item.type === models.workspace.type ? strings.document.singular : item.type;
if (item.status.includes('added')) {
return (
<Modal ref={this._setModalRef}>
<ModalHeader>Commit Changes</ModalHeader>
<ModalBody className="wide pad">
{hasChanges ? this._renderItems(itemsList) : this._renderEmpty()}
</ModalBody>
<ModalFooter>
<div className="margin-left italic txt-sm">
<i className="fa fa-code-fork" /> {branch}{' '}
{loading && <i className="fa fa-refresh fa-spin" />}
</div>
<div>
<button className="btn" onClick={this._hideModal}>
Close
</button>
<button className="btn" onClick={this._handleCommit} disabled={loading || !hasChanges}>
Commit
</button>
</div>
</ModalFooter>
</Modal>
<Tooltip message="Added">
<i className="fa fa-plus-circle success" /> {type}
</Tooltip>
);
}
if (item.status.includes('modified')) {
return (
<Tooltip message="Modified">
<i className="fa fa-plus-circle faded" /> {type}
</Tooltip>
);
}
if (item.status.includes('deleted')) {
return (
<Tooltip message="Deleted">
<i className="fa fa-minus-circle danger" /> {type}
</Tooltip>
);
}
return (
<Tooltip message="Unknown">
<i className="fa fa-question-circle info" /> {type}
</Tooltip>
);
};
interface ChangeTableProps {
items: Item[];
title: string;
rollbackLabel: string;
statusNames: Record<string, string>;
rollbackAll: (items: Item[]) => void;
rollbackOne: (item: Item) => void;
toggleAll: (items: Item[], forceAdd: boolean) => void;
toggleOne: (event: React.SyntheticEvent<HTMLInputElement>) => void;
}
const ChangesTable = ({
items,
title,
rollbackLabel,
statusNames,
rollbackAll,
rollbackOne,
toggleAll,
toggleOne,
}: ChangeTableProps) => {
if (items.length === 0) {
return null;
}
const allStaged = items.every(i => i.staged);
const allUnstaged = items.every(i => !i.staged);
return (
<div className="pad-top">
<strong>{title}</strong>
<PromptButton
className="btn pull-right btn--micro"
onClick={() => rollbackAll(items)}
>
{rollbackLabel}
</PromptButton>
<table className="table--fancy table--outlined margin-top-sm">
<thead>
<tr className="table--no-outline-row">
<th>
<label className="wide no-pad">
<span className="txt-md">
<IndeterminateCheckbox
className="space-right"
// @ts-expect-error -- TSCONVERSION
type="checkbox"
checked={allStaged}
onChange={() => toggleAll(items, !allStaged)}
indeterminate={!allStaged && !allUnstaged}
/>
</span>{' '}
name
</label>
</th>
<th className="text-right">Description</th>
</tr>
</thead>
<tbody>{items.map(item => (
<tr key={item.path} className="table--no-outline-row">
<td>
<label className="no-pad wide">
<input
disabled={!item.editable}
className="space-right"
type="checkbox"
checked={item.staged}
name={item.path}
onChange={toggleOne}
/>{' '}
{statusNames?.[item.path] || 'n/a'}
</label>
</td>
<td className="text-right">
{item.editable && <Tooltip message={item.added ? 'Delete' : 'Rollback'}>
<button
className="btn btn--micro space-right"
onClick={() => rollbackOne(item)}
>
<i className={classnames('fa', item.added ? 'fa-trash' : 'fa-undo')} />
</button>
</Tooltip>}
<OperationTooltip item={item} />
</td>
</tr>
))}</tbody>
</table>
</div>
);
};
@@ -13,8 +13,7 @@ import { setActiveActivity } from '../redux/modules/global';
import { selectActiveActivity, selectActiveApiSpec, selectActiveGitRepository, selectActiveProject, selectActiveProjectName, selectActiveWorkspace, selectIsLoggedIn } from '../redux/selectors';
import { ActivityToggle } from './activity-toggle/activity-toggle';
import { AppHeader } from './app-header';
import { DropdownButton } from './base/dropdown/dropdown-button';
import GitSyncDropdown from './dropdowns/git-sync-dropdown';
import { GitSyncDropdown } from './dropdowns/git-sync-dropdown';
import { SyncDropdown } from './dropdowns/sync-dropdown';
import { WorkspaceDropdown } from './dropdowns/workspace-dropdown';
import { showModal } from './modals';
@@ -128,13 +127,7 @@ export const WorkspacePageHeader: FC = () => {
/> : isGitSync ? <GitSyncDropdown
className="margin-left"
workspace={activeWorkspace}
gitRepository={activeGitRepository}
vcs={gitVCS}
renderDropdownButton={children => (
<DropdownButton className="btn--clicky-small btn-sync">
{children}
</DropdownButton>
)}
/> : null}
/>
);
+4 -5
View File
@@ -208,17 +208,16 @@ const App = () => {
<EnvironmentEditModal ref={instance => registerModal(instance, 'EnvironmentEditModal')} />
<GitRepositorySettingsModal ref={registerModal} />
<GitRepositorySettingsModal ref={instance => registerModal(instance, 'GitRepositorySettingsModal')} />
{activeWorkspace && gitVCS ? (
<Fragment>
<GitStagingModal ref={registerModal} workspace={activeWorkspace} vcs={gitVCS} gitRepository={activeGitRepository} />
<GitLogModal ref={registerModal} vcs={gitVCS} />
<GitStagingModal ref={instance => registerModal(instance, 'GitStagingModal')} workspace={activeWorkspace} vcs={gitVCS} gitRepository={activeGitRepository} />
<GitLogModal ref={instance => registerModal(instance, 'GitLogModal')} vcs={gitVCS} />
{activeGitRepository !== null && (
<GitBranchesModal
ref={registerModal}
ref={instance => registerModal(instance, 'GitBranchesModal')}
vcs={gitVCS}
gitRepository={activeGitRepository}
/>
)}
</Fragment>