From 1bf7b7bc2b3077ed86a0839c6950ea69a7144604 Mon Sep 17 00:00:00 2001 From: Jack Kavanagh Date: Mon, 24 Oct 2022 16:39:14 +0100 Subject: [PATCH] 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 --- .../dropdowns/git-sync-dropdown.tsx | 475 +++++-------- .../components/modals/git-branches-modal.tsx | 469 ++++++------- .../ui/components/modals/git-log-modal.tsx | 161 +++-- .../git-repository-settings-modal.tsx | 103 ++- .../components/modals/git-staging-modal.tsx | 623 ++++++++---------- .../ui/components/workspace-page-header.tsx | 9 +- packages/insomnia/src/ui/routes/root.tsx | 9 +- 7 files changed, 782 insertions(+), 1067 deletions(-) diff --git a/packages/insomnia/src/ui/components/dropdowns/git-sync-dropdown.tsx b/packages/insomnia/src/ui/components/dropdowns/git-sync-dropdown.tsx index ecf818083d..967feb5daf 100644 --- a/packages/insomnia/src/ui/components/dropdowns/git-sync-dropdown.tsx +++ b/packages/insomnia/src/ui/components/dropdowns/git-sync-dropdown.tsx @@ -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 & { +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 { - _dropdown: DropdownHandle | null = null; - - state: State = { - initializing: false, +export const GitSyncDropdown: FC = ({ workspace, vcs, className }) => { + const [state, setState] = useState({ loadingPush: false, loadingPull: false, log: [], branch: '', branches: [], - }; + }); + const dispatch = useDispatch(); + const gitRepository = useSelector(selectActiveGitRepository); + const dropdownRef = useRef(null); - _setDropdownRef(dropdown: DropdownHandle) { - this._dropdown = dropdown; - } + useMount(() => { + refreshState(); + }); - async _refreshState(otherState?: Record) { - 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 { 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 { 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 ( +
+ + {vcs.isInitialized() ? + ( + {iconClassName && } +
{branch}
+ +
) : + ( + + Setup Git Sync + )} - async _handleCommit() { - const { gitRepository } = this.props; - showModal(GitStagingModal, { - onCommit: this._refreshState, - gitRepository, - }); - } + + Git Sync + + Sync and collaborate with Git{' '} + + +
+ Documentation +
+ +
+
- _handleManageBranches() { - showModal(GitBranchesModal, { - onHide: this._refreshState, - }); - } + { + if (gitRepository) { + dispatch(gitActions.updateGitRepository({ + gitRepository, + })); + } else { + dispatch(gitActions.setupGitRepository({ + workspace, + createFsClient: MemClient.createClient, + })); + } + }} + > + Repository Settings + - async _handleCheckoutBranch(branch: string) { - const { vcs, handleInitializeEntities } = this.props; - const bufferId = await db.bufferChanges(); + {vcs.isInitialized() && ( + + showModal(GitBranchesModal, { onCheckout: refreshState })}> + Branches + + + )} - 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 => ( - - {children} - - )); - - if (!vcs.isInitialized()) { - return renderBtn( - - - Setup Git Sync - , - ); - } - - const initializing = false; - const iconClassName = this._getProviderIconClassName(); - return renderBtn( - - {iconClassName && } -
{initializing ? 'Initializing...' : branch}
- -
, - ); - } - - renderBranch(branch: string) { - const { branch: currentBranch } = this.state; - const icon = - branch === currentBranch ? : ; - const isCurrentBranch = branch === currentBranch; - return ( - this._handleCheckoutBranch(branch)} - className={classnames({ - bold: isCurrentBranch, - })} - title={isCurrentBranch ? '' : `Switch to "${branch}"`} - > - {icon} - {branch} - - ); - } - - render() { - const { className, vcs } = this.props; - const { log, branches, branch, loadingPull, loadingPush } = this.state; - return ( -
- - {this.renderButton()} - - - Git Sync - - Sync and collaborate with Git{' '} - - -
- Documentation -
- -
-
- - - Repository Settings - - - {vcs.isInitialized() && ( - - - Branches - - - )} - - {vcs.isInitialized() && ( - - Branches - {branches.map(this.renderBranch)} - - {branch} - - - Commit - - {log.length > 0 && ( - - {' '} - Push + {vcs.isInitialized() && ( + + Branches + {branches.map(branch => { + const { branch: currentBranch } = state; + const icon = branch === currentBranch ? : ; + const isCurrentBranch = branch === currentBranch; + return ( + { + 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} - )} - + ); + })} + + {branch} + + showModal(GitStagingModal, { + onCommit: refreshState, + gitRepository, + })} + > + Commit + + {log.length > 0 && ( + handlePush({ force: false })} stayOpenAfterClick> {' '} - Pull + Push - - History ({log.length}) - - - )} -
-
- ); - } -} - -function mapDispatchToProps(dispatch: Dispatch) { - const boundGitActions = bindActionCreators({ ...gitActions, initializeEntities }, dispatch); - return { - setupGitRepository: boundGitActions.setupGitRepository, - updateGitRepository: boundGitActions.updateGitRepository, - handleInitializeEntities: boundGitActions.initializeEntities, - }; -} - -export default connect(null, mapDispatchToProps)(GitSyncDropdown); + )} + { + 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 + > + {' '} + Pull + + showModal(GitLogModal)} disabled={log.length === 0}> + History ({log.length}) + + + )} +
+
+ ); +}; diff --git a/packages/insomnia/src/ui/components/modals/git-branches-modal.tsx b/packages/insomnia/src/ui/components/modals/git-branches-modal.tsx index c908cb2d8e..46325b8e9d 100644 --- a/packages/insomnia/src/ui/components/modals/git-branches-modal.tsx +++ b/packages/insomnia/src/ui/components/modals/git-branches-modal.tsx @@ -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 & { +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 { - 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(({ vcs }, ref) => { + const modalRef = useRef(null); + const [state, setState] = useState({ 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) { - 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) { + const handleCheckout = async (branch: string) => { try { - return await cb(); - } catch (err) { - this.setState({ - error: err.message, - }); - } - } - - async _handleCreate(event: React.SyntheticEvent) { - 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) { - 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 ( - - Local Branches - - {error && ( -

- - {error} -

- )} -
-
-
- -
-
- -
+ 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 ( + + Local Branches + + {error && ( +

+ + {error} +

+ )} + { + 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 })); + } + }} + > +
+
+
- +
+ +
+
+ +
+ + + + + + + + + {branches.map(branch => ( + + + + + ))} + +
Branches 
+ + {branch} + + {branch === currentBranch ? ( + (current) + ) : null} + + {branch !== currentBranch && ( + <> + { + 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 + + { + 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 + + + + )} +
+
+ {remoteOnlyBranches.length > 0 && (
- + - {branches.map(name => ( - - + {remoteOnlyBranches.map(branch => ( + + ))}
BranchesRemote Branches  
- - {name} - - {name === currentBranch ? ( - (current) - ) : null} -
{branch} - {name !== currentBranch && ( - - this._handleMerge(name)} - > - Merge - - this._handleDelete(name)} - > - Delete - - - - )} +
- {remoteOnlyBranches.length > 0 && ( -
- - - - - - - - - {remoteOnlyBranches.map(name => ( - - - - - ))} - -
Remote Branches 
{name} - -
-
- )} -
- -
- {currentBranch} -
-
- -
-
-
- ); - } -} -function mapDispatchToProps(dispatch: Dispatch) { - const boundGitActions = bindActionCreators({ initializeEntities }, dispatch); - return { - handleInitializeEntities: boundGitActions.initializeEntities, - }; -} - -export const GitBranchesModal = connect(null, mapDispatchToProps, null, { forwardRef: true })(GitBranchesModalClass); + )} + + +
+ {currentBranch} +
+
+ +
+
+ + ); +}); +GitBranchesModal.displayName = 'GitBranchesModal'; diff --git a/packages/insomnia/src/ui/components/modals/git-log-modal.tsx b/packages/insomnia/src/ui/components/modals/git-log-modal.tsx index f81486600e..8ef480f9ac 100644 --- a/packages/insomnia/src/ui/components/modals/git-log-modal.tsx +++ b/packages/insomnia/src/ui/components/modals/git-log-modal.tsx @@ -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 { - modal: ModalHandle | null = null; - - state: State = { +export interface GitLogModalHandle { + show: () => void; + hide: () => void; +} +export const GitLogModal = forwardRef(({ vcs }, ref) => { + const modalRef = useRef(null); + const [state, setState] = useState({ 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 ( - - {message} - - - - - `} delay={800}> - {author.name} - - - - ); - } - - render() { - const { logs, branch } = this.state; - return ( - - Git History ({logs.length}) - - - - - - - + const { logs, branch } = state; + return ( + + Git History ({logs.length}) + +
MessageWhenAuthor
+ + + + + + + + {logs.map(entry => { + const { + commit: { author, message }, + oid, + } = entry; + return ( + + + + - - {logs.map(this.renderLogEntryRow)} -
MessageWhenAuthor
{message} + + + `} delay={800}> + {author.name} + +
-
- -
- {branch} -
-
- -
-
-
- ); - } -} + ); + })} + + + +
+ {branch} +
+
+ +
+
+ + ); +}); +GitLogModal.displayName = 'GitLogModal'; diff --git a/packages/insomnia/src/ui/components/modals/git-repository-settings-modal/git-repository-settings-modal.tsx b/packages/insomnia/src/ui/components/modals/git-repository-settings-modal/git-repository-settings-modal.tsx index 384eaae38e..2be28de1be 100644 --- a/packages/insomnia/src/ui/components/modals/git-repository-settings-modal/git-repository-settings-modal.tsx +++ b/packages/insomnia/src/ui/components/modals/git-repository-settings-modal/git-repository-settings-modal.tsx @@ -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) => void; } - -@autoBindMethodsForReact(AUTOBIND_CFG) -export class GitRepositorySettingsModal extends PureComponent<{}, State> { - modal: ModalHandle | null = null; - _onSubmitEdits?: ((repo: Partial) => any) | null; - - state: State = { +export interface GitRepositorySettingsModalHandle { + show: (options: GitRepositorySettingsModalOptions) => void; + hide: () => void; +} +export const GitRepositorySettingsModal = forwardRef((_, ref) => { + const modalRef = useRef(null); + const [state, setState] = useState({ 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) => 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) => { - if (this._onSubmitEdits) { - this._onSubmitEdits({ ...this.state.gitRepository, ...patch }); - } - - this.hide(); - }; - - return ( - - _handleSubmitEdit(patch)} - onReset={_handleReset} - gitRepository={this.state.gitRepository} - /> - - ); - } -} + const { gitRepository, onSubmitEdits } = state; + return ( + + { + onSubmitEdits({ ...gitRepository, ...patch }); + modalRef.current?.hide(); + }} + onReset={() => { + gitRepository && deleteGitRepository(gitRepository); + modalRef.current?.hide(); + }} + gitRepository={gitRepository} + /> + + ); +}); +GitRepositorySettingsModal.displayName = 'GitRepositorySettingsModal'; interface Props { gitRepository: GitRepository | null; diff --git a/packages/insomnia/src/ui/components/modals/git-staging-modal.tsx b/packages/insomnia/src/ui/components/modals/git-staging-modal.tsx index a0d3d78e69..cddee81132 100644 --- a/packages/insomnia/src/ui/components/modals/git-staging-modal.tsx +++ b/packages/insomnia/src/ui/components/modals/git-staging-modal.tsx @@ -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; + onCommit: () => Promise; + statusNames: Record; } +export interface GitStagingModalHandle { + show: (options: GitStagingModalOptions) => void; + hide: () => void; +} +export const GitStagingModal = forwardRef(({ vcs, workspace, gitRepository }, ref) => { + const modalRef = useRef(null); + const [state, setState] = useState({ + 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 { - modal: ModalHandle | null = null; - statusNames?: Record; - 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) { - 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) { - 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 { - const { vcs } = this.props; + const getAllPaths = useCallback(async (): Promise => { // @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 { 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 = {}; 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 = {}; 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 { editable = false; staged = true; } - items[gitPath] = { type: type as any, staged, @@ -263,50 +138,62 @@ export class GitStagingModal extends PureComponent { 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 = ; - message = 'Added'; - } else if (item.status.includes('modified')) { - child = ; - message = 'Modified'; - } else if (item.status.includes('deleted')) { - child = ; - message = 'Deleted'; - } else { - child = ; - 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) => { + 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 ( - - - {child} {type} - - - ); - } - - 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 { 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 ( - - - - - - {item.editable && - - } - {this.renderOperation(item)} - - - ); - } - - 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 ( -
- {title} - this._handleRollbackAll(items)} - > - {rollbackLabel} - - - - - - - - - {items.map(this.renderItem)} -
- - Description
-
- ); - } - - _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 ( - <> -
-