diff --git a/jupyterlab_git/git.py b/jupyterlab_git/git.py index b615c0c1c..99ea14aa9 100644 --- a/jupyterlab_git/git.py +++ b/jupyterlab_git/git.py @@ -606,12 +606,13 @@ def reset_to_commit(self, commit_id, top_repo_path): ) return my_output - def checkout_new_branch(self, branchname, current_path): + def checkout_new_branch(self, branchname, startpoint, current_path): """ Execute git checkout command & return the result. """ + cmd = ["git", "checkout", "-b", branchname, startpoint] p = Popen( - ["git", "checkout", "-b", branchname], + cmd, stdout=PIPE, stderr=PIPE, cwd=os.path.join(self.root_dir, current_path), diff --git a/jupyterlab_git/handlers.py b/jupyterlab_git/handlers.py index 3dc07944a..f021c0cac 100644 --- a/jupyterlab_git/handlers.py +++ b/jupyterlab_git/handlers.py @@ -320,7 +320,7 @@ def post(self): if data["checkout_branch"]: if data["new_check"]: my_output = self.git.checkout_new_branch( - data["branchname"], top_repo_path + data["branchname"], data["startpoint"], top_repo_path ) else: my_output = self.git.checkout_branch(data["branchname"], top_repo_path) diff --git a/package.json b/package.json index a1ded01e8..763c678d8 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,8 @@ "@jupyterlab/services": "^4.1.0", "@jupyterlab/terminal": "^1.1.0", "@jupyterlab/ui-components": "^1.1.0", + "@material-ui/core": "^4.8.2", + "@material-ui/icons": "^4.5.1", "@phosphor/widgets": "^1.8.0", "diff-match-patch": "^1.0.4", "nbdime": "~5.0.1", diff --git a/src/components/BranchHeader.tsx b/src/components/BranchHeader.tsx deleted file mode 100644 index 8139267ef..000000000 --- a/src/components/BranchHeader.tsx +++ /dev/null @@ -1,213 +0,0 @@ -import { showErrorMessage } from '@jupyterlab/apputils'; -import * as React from 'react'; -import { classes } from 'typestyle'; -import { - branchDropdownButtonStyle, - branchHeaderCenterContent, - branchLabelStyle, - branchListItemStyle, - branchStyle, - branchTrackingLabelStyle, - expandedBranchStyle, - headerButtonDisabledStyle, - historyLabelStyle, - newBranchButtonStyle, - openHistorySideBarButtonStyle, - selectedHeaderStyle, - unSelectedHeaderStyle -} from '../style/BranchHeaderStyle'; -import { Git, IGitExtension } from '../tokens'; -import { NewBranchBox } from './NewBranchBox'; - -const CHANGES_ERR_MSG = - 'You have files with changes in current branch. Please commit or discard changed files before'; - -export interface IBranchHeaderState { - dropdownOpen: boolean; - showNewBranchBox: boolean; -} - -export interface IBranchHeaderProps { - model: IGitExtension; - currentBranch: string; - upstreamBranch: string; - stagedFiles: Git.IStatusFileResult[]; - data: Git.IBranch[]; - refresh: () => Promise; - disabled: boolean; - toggleSidebar: () => void; - sideBarExpanded: boolean; -} - -export class BranchHeader extends React.Component< - IBranchHeaderProps, - IBranchHeaderState -> { - constructor(props: IBranchHeaderProps) { - super(props); - this.state = { - dropdownOpen: false, - showNewBranchBox: false - }; - } - - /** Switch current working branch */ - async switchBranch(branchName: string) { - const result = await this.props.model.checkout({ branchname: branchName }); - if (result.code !== 0) { - showErrorMessage('Error switching branch', result.message); - } - - this.toggleSelect(); - } - - createNewBranch = async (branchName: string) => { - const result = await this.props.model.checkout({ - newBranch: true, - branchname: branchName - }); - if (result.code !== 0) { - showErrorMessage('Error creating new branch', result.message); - } - - this.toggleNewBranchBox(); - }; - - toggleSelect() { - this.props.refresh(); - if (!this.props.disabled) { - this.setState({ - dropdownOpen: !this.state.dropdownOpen - }); - } else { - showErrorMessage( - 'Switching branch disabled', - CHANGES_ERR_MSG + 'switching to another branch.' - ); - } - } - - getBranchStyle() { - if (this.state.dropdownOpen) { - return classes(branchStyle, expandedBranchStyle); - } else { - return branchStyle; - } - } - - toggleNewBranchBox = (): void => { - this.props.refresh(); - if (!this.props.disabled) { - this.setState({ - showNewBranchBox: !this.state.showNewBranchBox, - dropdownOpen: false - }); - } else { - showErrorMessage( - 'Creating new branch disabled', - CHANGES_ERR_MSG + 'creating a new branch.' - ); - } - }; - - getHistoryHeaderStyle() { - if (this.props.sideBarExpanded) { - return classes(openHistorySideBarButtonStyle, selectedHeaderStyle); - } - return classes(unSelectedHeaderStyle, openHistorySideBarButtonStyle); - } - - getBranchHeaderStyle() { - if (this.props.sideBarExpanded) { - return classes(branchHeaderCenterContent, unSelectedHeaderStyle); - } - return classes(selectedHeaderStyle, branchHeaderCenterContent); - } - - render() { - return ( -
-
-
this.props.toggleSidebar() - : null - } - > -

{this.props.currentBranch}

-
this.toggleSelect()} - /> - {!this.state.showNewBranchBox && ( -
this.toggleNewBranchBox()} - /> - )} - {this.state.showNewBranchBox && ( - - )} - {this.props.upstreamBranch != null && - this.props.upstreamBranch !== '' && ( -

- {this.props.upstreamBranch} -

- )} -
-
this.props.toggleSidebar() - } - title={'Show commit history'} - > -

History

-
-
- {!this.props.sideBarExpanded && ( - - {this.state.dropdownOpen && ( -
- {this.props.data.map( - (branch: Git.IBranch, branchIndex: number) => ( -
  • this.switchBranch(branch.name)} - > - {branch.name} -
  • - ) - )} -
    - )} - {this.state.showNewBranchBox && ( -
    Branching from {this.props.currentBranch}
    - )} -
    - )} -
    - ); - } -} diff --git a/src/components/BranchMenu.tsx b/src/components/BranchMenu.tsx new file mode 100644 index 000000000..fb0d35893 --- /dev/null +++ b/src/components/BranchMenu.tsx @@ -0,0 +1,320 @@ +import * as React from 'react'; +import { classes } from 'typestyle'; +import List from '@material-ui/core/List'; +import ListItem from '@material-ui/core/ListItem'; +import ClearIcon from '@material-ui/icons/Clear'; +import { showErrorMessage } from '@jupyterlab/apputils'; +import { Git, IGitExtension } from '../tokens'; +import { + activeListItemClass, + filterClass, + filterClearClass, + filterInputClass, + filterWrapperClass, + listItemClass, + listItemIconClass, + listWrapperClass, + newBranchButtonClass, + wrapperClass +} from '../style/BranchMenu'; +import { NewBranchDialog } from './NewBranchDialog'; + +const CHANGES_ERR_MSG = + 'The current branch contains files with uncommitted changes. Please commit or discard these changes before switching to or creating another branch.'; + +/** + * Interface describing component properties. + */ +export interface IBranchMenuProps { + /** + * Git extension data model. + */ + model: IGitExtension; + + /** + * Boolean indicating whether branching is disabled. + */ + branching: boolean; +} + +/** + * Interface describing component state. + */ +export interface IBranchMenuState { + /** + * Menu filter. + */ + filter: string; + + /** + * Boolean indicating whether to show a dialog to create a new branch. + */ + branchDialog: boolean; + + /** + * Current branch name. + */ + current: string; + + /** + * Current list of branches. + */ + branches: Git.IBranch[]; +} + +/** + * React component for rendering a branch menu. + */ +export class BranchMenu extends React.Component< + IBranchMenuProps, + IBranchMenuState +> { + /** + * Returns a React component for rendering a branch menu. + * + * @param props - component properties + * @returns React component + */ + constructor(props: IBranchMenuProps) { + super(props); + + const repo = this.props.model.pathRepository; + + this.state = { + filter: '', + branchDialog: false, + current: repo ? this.props.model.currentBranch.name : '', + branches: repo ? this.props.model.branches : [] + }; + } + + /** + * Callback invoked immediately after mounting a component (i.e., inserting into a tree). + */ + componentDidMount(): void { + this._addListeners(); + } + + /** + * Callback invoked when a component will no longer be mounted. + */ + componentWillUnmount(): void { + this._removeListeners(); + } + + /** + * Renders the component. + * + * @returns React element + */ + render(): React.ReactElement { + return ( +
    +
    +
    + + {this.state.filter ? ( + + ) : null} +
    + +
    +
    + {this._renderItems()} +
    + +
    + ); + } + + /** + * Renders menu items. + * + * @returns array of React elements + */ + private _renderItems(): React.ReactElement[] { + return this.state.branches.map(this._renderItem, this); + } + + /** + * Renders a menu item. + * + * @param branch - branch + * @param idx - item index + * @returns React element + */ + private _renderItem( + branch: Git.IBranch, + idx: number + ): React.ReactElement | null { + // Perform a "simple" filter... (TODO: consider implementing fuzzy filtering) + if (this.state.filter && !branch.name.includes(this.state.filter)) { + return null; + } + return ( + + + {branch.name} + + ); + } + + /** + * Adds model listeners. + */ + private _addListeners(): void { + // When the HEAD changes, decent probability that we've switched branches: + this.props.model.headChanged.connect(this._syncState, this); + + // When the status changes, we may have checked out a new branch (e.g., via the command-line and not via the extension) or changed repositories: + this.props.model.statusChanged.connect(this._syncState, this); + } + + /** + * Removes model listeners. + */ + private _removeListeners(): void { + this.props.model.headChanged.disconnect(this._syncState, this); + this.props.model.statusChanged.disconnect(this._syncState, this); + } + + /** + * Syncs the component state with the underlying model. + */ + private _syncState(): void { + const repo = this.props.model.pathRepository; + this.setState({ + current: repo ? this.props.model.currentBranch.name : '', + branches: repo ? this.props.model.branches : [] + }); + } + + /** + * Callback invoked upon a change to the menu filter. + * + * @param event - event object + */ + private _onFilterChange = (event: any): void => { + this.setState({ + filter: event.target.value + }); + }; + + /** + * Callback invoked to reset the menu filter. + */ + private _resetFilter = (): void => { + this.setState({ + filter: '' + }); + }; + + /** + * Callback invoked upon clicking a button to create a new branch. + * + * @param event - event object + */ + private _onNewBranchClick = (): void => { + if (!this.props.branching) { + showErrorMessage('Creating a new branch is disabled', CHANGES_ERR_MSG); + return; + } + this.setState({ + branchDialog: true + }); + }; + + /** + * Callback invoked upon closing a dialog to create a new branch. + */ + private _onNewBranchDialogClose = (): void => { + this.setState({ + branchDialog: false + }); + }; + + /** + * Returns a callback which is invoked upon clicking a branch name. + * + * @param branch - branch name + * @returns callback + */ + private _onBranchClickFactory(branch: string) { + const self = this; + return onClick; + + /** + * Callback invoked upon clicking a branch name. + * + * @private + * @param event - event object + */ + function onClick(): void { + if (!self.props.branching) { + showErrorMessage('Switching branches is disabled', CHANGES_ERR_MSG); + return; + } + const opts = { + branchname: branch + }; + self.props.model + .checkout(opts) + .then(onResolve) + .catch(onError); + } + + /** + * Callback invoked upon promise resolution. + * + * @private + * @param result - result + */ + function onResolve(result: any): void { + if (result.code !== 0) { + showErrorMessage('Error switching branch', result.message); + } + } + + /** + * Callback invoked upon encountering an error. + * + * @private + * @param err - error + */ + function onError(err: any): void { + showErrorMessage('Error switching branch', err.message); + } + } +} diff --git a/src/components/CommitBox.tsx b/src/components/CommitBox.tsx index 79921c2c6..ffdc6b131 100644 --- a/src/components/CommitBox.tsx +++ b/src/components/CommitBox.tsx @@ -66,9 +66,9 @@ export class CommitBox extends React.Component< /** * Renders the component. * - * @returns fragment + * @returns React element */ - render() { + render(): React.ReactElement { const disabled = !(this.props.hasFiles && this.state.summary); return (
    @@ -109,7 +109,7 @@ export class CommitBox extends React.Component< * * @param event - event object */ - private _onCommitClick = () => { + private _onCommitClick = (): void => { const msg = this.state.summary + '\n' + this.state.description + '\n'; this.props.onCommit(msg); @@ -157,10 +157,10 @@ export class CommitBox extends React.Component< /** * Resets component state (e.g., in order to re-initialize the commit message input box). */ - private _reset = (): void => { + private _reset(): void { this.setState({ summary: '', description: '' }); - }; + } } diff --git a/src/components/FileList.tsx b/src/components/FileList.tsx index 9ad832951..3be4bec20 100644 --- a/src/components/FileList.tsx +++ b/src/components/FileList.tsx @@ -5,6 +5,7 @@ import { Menu } from '@phosphor/widgets'; import * as React from 'react'; import { GitExtension } from '../model'; import { + fileListWrapperClass, moveFileDownButtonSelectedStyle, moveFileDownButtonStyle, moveFileUpButtonSelectedStyle, @@ -482,7 +483,7 @@ export class FileList extends React.Component { if (this.props.settings.composite['simpleStaging']) { return ( -
    +
    {
    ); - } else { - return ( -
    event.preventDefault()}> -
    - - - -
    -
    - ); } + return ( +
    event.preventDefault()} + > +
    + + + +
    +
    + ); } } diff --git a/src/components/GitPanel.tsx b/src/components/GitPanel.tsx index 6cfee14c3..9a053f4c0 100644 --- a/src/components/GitPanel.tsx +++ b/src/components/GitPanel.tsx @@ -1,21 +1,26 @@ import * as React from 'react'; +import Tabs from '@material-ui/core/Tabs'; +import Tab from '@material-ui/core/Tab'; import { showErrorMessage, showDialog } from '@jupyterlab/apputils'; import { ISettingRegistry } from '@jupyterlab/coreutils'; import { IRenderMimeRegistry } from '@jupyterlab/rendermime'; import { JSONObject } from '@phosphor/coreutils'; import { GitExtension } from '../model'; import { - findRepoButtonStyle, - panelContainerStyle, - panelWarningStyle -} from '../style/GitPanelStyle'; + panelWrapperClass, + repoButtonClass, + selectedTabClass, + tabClass, + tabsClass, + tabIndicatorClass, + warningWrapperClass +} from '../style/GitPanel'; import { Git } from '../tokens'; import { decodeStage } from '../utils'; import { GitAuthorForm } from '../widgets/AuthorBox'; -import { BranchHeader } from './BranchHeader'; import { FileList } from './FileList'; import { HistorySideBar } from './HistorySideBar'; -import { PathHeader } from './PathHeader'; +import { Toolbar } from './Toolbar'; import { CommitBox } from './CommitBox'; /** Interface for GitPanel component state */ @@ -24,7 +29,6 @@ export interface IGitSessionNodeState { branches: Git.IBranch[]; currentBranch: string; - upstreamBranch: string; pastCommits: Git.ISingleCommitInfo[]; @@ -32,7 +36,7 @@ export interface IGitSessionNodeState { unstagedFiles: Git.IStatusFileResult[]; untrackedFiles: Git.IStatusFileResult[]; - isHistoryVisible: boolean; + tab: number; } /** Interface for GitPanel component props */ @@ -53,12 +57,11 @@ export class GitPanel extends React.Component< inGitRepository: false, branches: [], currentBranch: '', - upstreamBranch: '', pastCommits: [], stagedFiles: [], unstagedFiles: [], untrackedFiles: [], - isHistoryVisible: false + tab: 0 }; props.model.repositoryChanged.connect((_, args) => { @@ -72,7 +75,7 @@ export class GitPanel extends React.Component< }, this); props.model.headChanged.connect(async () => { await this.refreshBranch(); - if (this.state.isHistoryVisible) { + if (this.state.tab === 1) { this.refreshHistory(); } else { this.refreshStatus(); @@ -88,8 +91,7 @@ export class GitPanel extends React.Component< this.setState({ branches: this.props.model.branches, - currentBranch: currentBranch ? currentBranch.name : 'master', - upstreamBranch: currentBranch ? currentBranch.upstream : '' + currentBranch: currentBranch ? currentBranch.name : 'master' }); }; @@ -157,13 +159,6 @@ export class GitPanel extends React.Component< } }; - toggleSidebar = (): void => { - if (!this.state.isHistoryVisible) { - this.refreshHistory(); - } - this.setState({ isHistoryVisible: !this.state.isHistoryVisible }); - }; - /** * Commits all marked files. * @@ -197,24 +192,104 @@ export class GitPanel extends React.Component< } }; - render() { - let filelist: React.ReactElement; - let main: React.ReactElement; - let sub: React.ReactElement; - let msg: React.ReactElement; - - if (this.state.isHistoryVisible) { - sub = ( - + /** + * Renders the component. + * + * @returns React element + */ + render(): React.ReactElement { + return ( +
    + {this._renderToolbar()} + {this._renderMain()} +
    + ); + } + + /** + * Renders a toolbar. + * + * @returns React element + */ + private _renderToolbar(): React.ReactElement { + const disableBranching = Boolean( + this.props.settings.composite['disableBranchWithChanges'] && + ((this.state.unstagedFiles && this.state.unstagedFiles.length) || + (this.state.stagedFiles && this.state.stagedFiles.length)) + ); + return ( + + ); + } + + /** + * Renders the main panel. + * + * @returns React element + */ + private _renderMain(): React.ReactElement { + if (this.state.inGitRepository) { + return ( + + {this._renderTabs()} + {this.state.tab === 1 ? this._renderHistory() : this._renderChanges()} + ); - } else { - filelist = ( + } + return this._renderWarning(); + } + + /** + * Renders panel tabs. + * + * @returns React element + */ + private _renderTabs(): React.ReactElement { + return ( + + + + + ); + } + + /** + * Renders a panel for viewing and committing file changes. + * + * @returns React element + */ + private _renderChanges(): React.ReactElement { + return ( + - ); - if (this.props.settings.composite['simpleStaging']) { - msg = ( + {this.props.settings.composite['simpleStaging'] ? ( 0} onCommit={this.commitMarkedFiles} /> - ); - } else { - msg = ( + ) : ( 0} onCommit={this.commitStagedFiles} /> - ); - } - sub = ( - - {filelist} - {msg} - - ); - } - - if (this.state.inGitRepository) { - const disableBranchOps = Boolean( - this.props.settings.composite['disableBranchWithChanges'] && - ((this.state.unstagedFiles && this.state.unstagedFiles.length) || - (this.state.stagedFiles && this.state.stagedFiles.length)) - ); + )} + + ); + } - main = ( - - - {sub} - - ); - } else { - main = ( -
    -
    You aren’t in a git repository.
    - -
    - ); - } + /** + * Renders a panel for viewing commit history. + * + * @returns React element + */ + private _renderHistory(): React.ReactElement { + return ( + + ); + } + /** + * Renders a panel for prompting a user to find a Git repository. + * + * @returns React element + */ + private _renderWarning(): React.ReactElement { return ( -
    - { - await this.refreshBranch(); - if (this.state.isHistoryVisible) { - this.refreshHistory(); - } else { - this.refreshStatus(); - } - }} - /> - {main} +
    +
    Unable to detect a Git repository.
    +
    ); } + /** + * Callback invoked upon changing the active panel tab. + * + * @param event - event object + * @param tab - tab number + */ + private _onTabChange = (event: any, tab: number): void => { + if (tab === 1) { + this.refreshHistory(); + } + this.setState({ + tab: tab + }); + }; + + /** + * Callback invoked upon refreshing a repository. + * + * @returns promise which refreshes a repository + */ + private _onRefresh = async () => { + await this.refreshBranch(); + if (this.state.tab === 1) { + this.refreshHistory(); + } else { + this.refreshStatus(); + } + }; + /** * List of modified files (both staged and unstaged). */ @@ -367,7 +443,6 @@ export class GitPanel extends React.Component< throw new Error('Failed to set your identity. ' + error.message); } } - return Promise.resolve(true); } diff --git a/src/components/NewBranchBox.tsx b/src/components/NewBranchBox.tsx deleted file mode 100644 index adab23386..000000000 --- a/src/components/NewBranchBox.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import * as React from 'react'; -import { classes } from 'typestyle'; -import { - buttonStyle, - cancelNewBranchButtonStyle, - newBranchBoxStyle, - newBranchButtonStyle, - newBranchInputAreaStyle -} from '../style/NewBranchBoxStyle'; - -export interface ICommitBoxProps { - createNewBranch: (branchName: string) => Promise; - toggleNewBranchBox: () => void; -} - -export interface ICommitBoxState { - value: string; -} - -export class NewBranchBox extends React.Component< - ICommitBoxProps, - ICommitBoxState -> { - constructor(props: ICommitBoxProps) { - super(props); - this.state = { - value: '' - }; - } - - /** Prevent enter key triggered 'submit' action during input */ - onKeyPress(event: any): void { - if (event.which === 13) { - event.preventDefault(); - this.setState({ value: this.state.value + '\n' }); - } - } - - /** Handle input inside commit message box */ - handleChange = (event: any): void => { - this.setState({ - value: event.target.value - }); - }; - - render() { - return ( -
    this.onKeyPress(event)} - > - - this.props.createNewBranch(this.state.value)} - /> - this.props.toggleNewBranchBox()} - /> -
    - ); - } -} diff --git a/src/components/NewBranchDialog.tsx b/src/components/NewBranchDialog.tsx new file mode 100644 index 000000000..c6580937b --- /dev/null +++ b/src/components/NewBranchDialog.tsx @@ -0,0 +1,445 @@ +import * as React from 'react'; +import { classes } from 'typestyle'; +import List from '@material-ui/core/List'; +import ListItem from '@material-ui/core/ListItem'; +import Dialog from '@material-ui/core/Dialog'; +import DialogActions from '@material-ui/core/DialogActions'; +import ClearIcon from '@material-ui/icons/Clear'; +import { showErrorMessage } from '@jupyterlab/apputils'; +import { Git, IGitExtension } from '../tokens'; +import { + actionsWrapperClass, + activeListItemClass, + branchDialogClass, + buttonClass, + cancelButtonClass, + closeButtonClass, + contentWrapperClass, + createButtonClass, + filterClass, + filterClearClass, + filterInputClass, + filterWrapperClass, + listItemBoldTitleClass, + listItemClass, + listItemContentClass, + listItemDescClass, + listItemIconClass, + listItemTitleClass, + listWrapperClass, + nameInputClass, + titleClass, + titleWrapperClass +} from '../style/NewBranchDialog'; + +const BRANCH_DESC = { + current: + 'The current branch. Pick this if you want to build on work done in this branch.', + default: + 'The default branch. Pick this if you want to start fresh from the default branch.' +}; + +/** + * Interface describing component properties. + */ +export interface INewBranchDialogProps { + /** + * Git extension data model. + */ + model: IGitExtension; + + /** + * Boolean indicating whether to show the dialog. + */ + open: boolean; + + /** + * Callback to invoke upon closing the dialog. + */ + onClose: () => void; +} + +/** + * Interface describing component state. + */ +export interface INewBranchDialogState { + /** + * Branch name. + */ + name: string; + + /** + * Base branch. + */ + base: string; + + /** + * Menu filter. + */ + filter: string; + + /** + * Current branch name. + */ + current: string; + + /** + * Current list of branches. + */ + branches: Git.IBranch[]; +} + +/** + * React component for rendering a dialog to create a new branch. + */ +export class NewBranchDialog extends React.Component< + INewBranchDialogProps, + INewBranchDialogState +> { + /** + * Returns a React component for rendering a branch menu. + * + * @param props - component properties + * @returns React component + */ + constructor(props: INewBranchDialogProps) { + super(props); + + const repo = this.props.model.pathRepository; + + this.state = { + name: '', + base: repo ? this.props.model.currentBranch.name : '', + filter: '', + current: repo ? this.props.model.currentBranch.name : '', + branches: repo ? this.props.model.branches : [] + }; + } + + /** + * Callback invoked immediately after mounting a component (i.e., inserting into a tree). + */ + componentDidMount(): void { + this._addListeners(); + } + + /** + * Callback invoked when a component will no longer be mounted. + */ + componentWillUnmount(): void { + this._removeListeners(); + } + + /** + * Renders the component. + * + * @returns React element + */ + render(): React.ReactElement { + return ( + +
    +

    Create a Branch

    + +
    +
    +

    Name

    + +

    Create branch based on...

    +
    +
    + + {this.state.filter ? ( + + ) : null} +
    +
    +
    + {this._renderItems()} +
    +
    + + + + +
    + ); + } + + /** + * Renders branch menu items. + * + * @returns array of React elements + */ + private _renderItems(): React.ReactElement[] { + const current = this.props.model.currentBranch.name; + return this.state.branches + .slice() + .sort(comparator) + .map(this._renderItem, this); + + /** + * Comparator function for sorting branches. + * + * @private + * @param a - first branch + * @param b - second branch + * @returns integer indicating sort order + */ + function comparator(a: Git.IBranch, b: Git.IBranch): number { + if (a.name === current) { + return -1; + } + return 0; + } + } + + /** + * Renders a branch menu item. + * + * @param branch - branch + * @param idx - item index + * @returns React element + */ + private _renderItem( + branch: Git.IBranch, + idx: number + ): React.ReactElement | null { + // Perform a "simple" filter... (TODO: consider implementing fuzzy filtering) + if (this.state.filter && !branch.name.includes(this.state.filter)) { + return null; + } + const isBase = branch.name === this.state.base; + const isCurr = branch.name === this.state.current; + + let isBold; + let desc; + if (isCurr) { + isBold = true; + desc = BRANCH_DESC['current']; + } + return ( + + +
    +

    + {branch.name} +

    + {desc ?

    {desc}

    : null} +
    +
    + ); + } + + /** + * Adds model listeners. + */ + private _addListeners(): void { + // When the HEAD changes, decent probability that we've switched branches: + this.props.model.headChanged.connect(this._syncState, this); + + // When the status changes, we may have checked out a new branch (e.g., via the command-line and not via the extension) or changed repositories: + this.props.model.statusChanged.connect(this._syncState, this); + } + + /** + * Removes model listeners. + */ + private _removeListeners(): void { + this.props.model.headChanged.disconnect(this._syncState, this); + this.props.model.statusChanged.disconnect(this._syncState, this); + } + + /** + * Syncs the component state with the underlying model. + */ + private _syncState(): void { + const repo = this.props.model.pathRepository; + this.setState({ + base: repo ? this.state.base : '', + current: repo ? this.props.model.currentBranch.name : '', + branches: repo ? this.props.model.branches : [] + }); + } + + /** + * Callback invoked upon closing the dialog. + * + * @param event - event object + */ + private _onClose = (): void => { + this.props.onClose(); + this.setState({ + name: '', + filter: '' + }); + }; + + /** + * Callback invoked upon a change to the menu filter. + * + * @param event - event object + */ + private _onFilterChange = (event: any): void => { + this.setState({ + filter: event.target.value + }); + }; + + /** + * Callback invoked to reset the menu filter. + */ + private _resetFilter = (): void => { + this.setState({ + filter: '' + }); + }; + + /** + * Returns a callback which is invoked upon clicking a branch name. + * + * @param branch - branch name + * @returns callback + */ + private _onBranchClickFactory(branch: string) { + const self = this; + return onClick; + + /** + * Callback invoked upon clicking a branch name. + * + * @private + * @param event - event object + */ + function onClick(): void { + self.setState({ + base: branch + }); + } + } + + /** + * Callback invoked upon a change to the branch name input element. + * + * @param event - event object + */ + private _onNameChange = (event: any): void => { + this.setState({ + name: event.target.value + }); + }; + + /** + * Callback invoked upon clicking a button to create a new branch. + * + * @param event - event object + */ + private _onCreate = (): void => { + const branch = this.state.name; + + // Close the branch dialog: + this.props.onClose(); + + // Reset the branch name and filter: + this.setState({ + name: '', + filter: '' + }); + + // Create the branch: + this._createBranch(branch); + }; + + /** + * Creates a new branch. + * + * @param branch - branch name + */ + private _createBranch(branch: string): void { + const opts = { + newBranch: true, + branchname: branch + }; + this.props.model + .checkout(opts) + .then(onResolve) + .catch(onError); + + /** + * Callback invoked upon promise resolution. + * + * @private + * @param result - result + */ + function onResolve(result: any): void { + if (result.code !== 0) { + showErrorMessage('Error creating branch', result.message); + } + } + + /** + * Callback invoked upon encountering an error. + * + * @private + * @param err - error + */ + function onError(err: any): void { + showErrorMessage('Error creating branch', err.message); + } + } +} diff --git a/src/components/PathHeader.tsx b/src/components/PathHeader.tsx deleted file mode 100644 index 1451b194e..000000000 --- a/src/components/PathHeader.tsx +++ /dev/null @@ -1,117 +0,0 @@ -import { Dialog, showDialog, UseSignal } from '@jupyterlab/apputils'; -import { PathExt } from '@jupyterlab/coreutils'; -import * as React from 'react'; -import { classes } from 'typestyle'; -import { - gitPullStyle, - gitPushStyle, - repoPathStyle, - repoRefreshStyle, - repoStyle -} from '../style/PathHeaderStyle'; -import { GitCredentialsForm } from '../widgets/CredentialsBox'; -import { GitPullPushDialog, Operation } from '../widgets/gitPushPull'; -import { IGitExtension } from '../tokens'; - -export interface IPathHeaderProps { - model: IGitExtension; - refresh: () => Promise; -} - -export class PathHeader extends React.Component { - constructor(props: IPathHeaderProps) { - super(props); - } - - render() { - return ( -
    - - {(_, change) => ( - - {PathExt.basename(change.newValue || '')} - - )} - -
    - ); - } - - /** - * Displays the error dialog when the Git Push/Pull operation fails. - * @param title the title of the error dialog - * @param body the message to be shown in the body of the modal. - */ - private async showGitPushPullDialog( - model: IGitExtension, - operation: Operation - ): Promise { - let result = await showDialog({ - title: `Git ${operation}`, - body: new GitPullPushDialog(model, operation), - buttons: [Dialog.okButton({ label: 'DISMISS' })] - }); - let retry = false; - while (!result.button.accept) { - retry = true; - - let response = await showDialog({ - title: 'Git credentials required', - body: new GitCredentialsForm( - 'Enter credentials for remote repository', - retry ? 'Incorrect username or password.' : '' - ), - buttons: [Dialog.cancelButton(), Dialog.okButton({ label: 'OK' })] - }); - - if (response.button.accept) { - // user accepted attempt to login - result = await showDialog({ - title: `Git ${operation}`, - body: new GitPullPushDialog(model, operation, response.value), - buttons: [Dialog.okButton({ label: 'DISMISS' })] - }); - } else { - break; - } - } - } -} diff --git a/src/components/Toolbar.tsx b/src/components/Toolbar.tsx new file mode 100644 index 000000000..cf761d892 --- /dev/null +++ b/src/components/Toolbar.tsx @@ -0,0 +1,379 @@ +import * as React from 'react'; +import { classes } from 'typestyle'; +import { Dialog, showDialog } from '@jupyterlab/apputils'; +import { PathExt } from '@jupyterlab/coreutils'; + +import { + // NOTE: keep in alphabetical order + branchIconClass, + closeMenuIconClass, + openMenuIconClass, + pullButtonClass, + pushButtonClass, + refreshButtonClass, + repoIconClass, + toolbarButtonClass, + toolbarClass, + toolbarMenuButtonClass, + toolbarMenuButtonEnabledClass, + toolbarMenuButtonIconClass, + toolbarMenuButtonSubtitleClass, + toolbarMenuButtonTitleClass, + toolbarMenuButtonTitleWrapperClass, + toolbarMenuWrapperClass, + toolbarNavClass +} from '../style/Toolbar'; +import { GitCredentialsForm } from '../widgets/CredentialsBox'; +import { GitPullPushDialog, Operation } from '../widgets/gitPushPull'; +import { IGitExtension } from '../tokens'; +import { BranchMenu } from './BranchMenu'; + +/** + * Displays an error dialog when a Git operation fails. + * + * @private + * @param model - Git extension model + * @param operation - Git operation name + * @returns Promise for displaying a dialog + */ +async function showGitOperationDialog( + model: IGitExtension, + operation: Operation +): Promise { + const title = `Git ${operation}`; + let result = await showDialog({ + title: title, + body: new GitPullPushDialog(model, operation), + buttons: [Dialog.okButton({ label: 'DISMISS' })] + }); + let retry = false; + while (!result.button.accept) { + retry = true; + const credentials = await showDialog({ + title: 'Git credentials required', + body: new GitCredentialsForm( + 'Enter credentials for remote repository', + retry ? 'Incorrect username or password.' : '' + ), + buttons: [Dialog.cancelButton(), Dialog.okButton({ label: 'OK' })] + }); + if (!credentials.button.accept) { + break; + } + result = await showDialog({ + title: title, + body: new GitPullPushDialog(model, operation, credentials.value), + buttons: [Dialog.okButton({ label: 'DISMISS' })] + }); + } +} + +/** + * Interface describing component properties. + */ +export interface IToolbarProps { + /** + * Git extension data model. + */ + model: IGitExtension; + + /** + * Boolean indicating whether branching is disabled. + */ + branching: boolean; + + /** + * Callback to invoke in order to refresh a repository. + * + * @returns promise which refreshes a repository + */ + refresh: () => Promise; +} + +/** + * Interface describing component state. + */ +export interface IToolbarState { + /** + * Boolean indicating whether a branch menu is shown. + */ + branchMenu: boolean; + + /** + * Boolean indicating whether a repository menu is shown. + */ + repoMenu: boolean; + + /** + * Current repository. + */ + repository: string; + + /** + * Current branch name. + */ + branch: string; +} + +/** + * React component for rendering a panel toolbar. + */ +export class Toolbar extends React.Component { + /** + * Returns a React component for rendering a panel toolbar. + * + * @param props - component properties + * @returns React component + */ + constructor(props: IToolbarProps) { + super(props); + + const repo = this.props.model.pathRepository; + + this.state = { + branchMenu: false, + repoMenu: false, + repository: repo || '', + branch: repo ? this.props.model.currentBranch.name : '' + }; + } + + /** + * Callback invoked immediately after mounting a component (i.e., inserting into a tree). + */ + componentDidMount(): void { + this._addListeners(); + } + + /** + * Callback invoked when a component will no longer be mounted. + */ + componentWillUnmount(): void { + this._removeListeners(); + } + + /** + * Renders the component. + * + * @returns React element + */ + render(): React.ReactElement { + return ( +
    + {this._renderTopNav()} + {this._renderRepoMenu()} + {this._renderBranchMenu()} +
    + ); + } + + /** + * Renders the top navigation. + * + * @returns React element + */ + private _renderTopNav(): React.ReactElement { + return ( +
    +
    + ); + } + + /** + * Renders a repository menu. + * + * @returns React element + */ + private _renderRepoMenu(): React.ReactElement { + return ( +
    + + {this.state.repoMenu ? null : null} +
    + ); + } + + /** + * Renders a branch menu. + * + * @returns React element + */ + private _renderBranchMenu(): React.ReactElement | null { + if (!this.state.repository) { + return null; + } + return ( +
    + + {this.state.branchMenu ? ( + + ) : null} +
    + ); + } + + /** + * Adds model listeners. + */ + private _addListeners(): void { + // When the HEAD changes, decent probability that we've switched branches: + this.props.model.headChanged.connect(this._syncState, this); + + // When the status changes, we may have checked out a new branch (e.g., via the command-line and not via the extension) or changed repositories: + this.props.model.statusChanged.connect(this._syncState, this); + } + + /** + * Removes model listeners. + */ + private _removeListeners(): void { + this.props.model.headChanged.disconnect(this._syncState, this); + this.props.model.statusChanged.disconnect(this._syncState, this); + } + + /** + * Syncs the component state with the underlying model. + */ + private _syncState(): void { + const repo = this.props.model.pathRepository; + this.setState({ + repository: repo || '', + branch: repo ? this.props.model.currentBranch.name : '' + }); + } + + /** + * Callback invoked upon clicking a button to pull the latest changes. + * + * @param event - event object + */ + private _onPullClick = (): void => { + showGitOperationDialog(this.props.model, Operation.Pull).catch(reason => { + console.error( + `Encountered an error when pulling changes. Error: ${reason}` + ); + }); + }; + + /** + * Callback invoked upon clicking a button to push the latest changes. + * + * @param event - event object + */ + private _onPushClick = (): void => { + showGitOperationDialog(this.props.model, Operation.Push).catch(reason => { + console.error( + `Encountered an error when pushing changes. Error: ${reason}` + ); + }); + }; + + /** + * Callback invoked upon clicking a button to change the current repository. + * + * @param event - event object + */ + private _onRepositoryClick = (): void => { + // Toggle the repository menu: + this.setState({ + repoMenu: !this.state.repoMenu + }); + }; + + /** + * Callback invoked upon clicking a button to change the current branch. + * + * @param event - event object + */ + private _onBranchClick = (): void => { + // Toggle the branch menu: + this.setState({ + branchMenu: !this.state.branchMenu + }); + }; + + /** + * Callback invoked upon clicking a button to refresh a repository. + * + * @param event - event object + */ + private _onRefreshClick = (): void => { + this.props.refresh(); + }; +} diff --git a/src/model.ts b/src/model.ts index e07725ea5..0f0bcae7e 100644 --- a/src/model.ts +++ b/src/model.ts @@ -41,7 +41,7 @@ export class GitExtension implements IGitExtension { interval = DEFAULT_REFRESH_INTERVAL; } const poll = new Poll({ - factory: () => model._refreshStatus(), + factory: () => model.refresh(), frequency: { interval: interval, backoff: true, @@ -387,6 +387,7 @@ export class GitExtension implements IGitExtension { checkout_branch: false, new_check: false, branchname: '', + startpoint: '', checkout_all: true, filename: '', top_repo_path: path @@ -397,6 +398,9 @@ export class GitExtension implements IGitExtension { body.branchname = options.branchname; body.checkout_branch = true; body.new_check = options.newBranch === true; + if (options.newBranch) { + body.startpoint = options.startpoint || this._currentBranch.name; + } } else if (options.filename) { body.filename = options.filename; body.checkout_all = false; @@ -787,8 +791,31 @@ export class GitExtension implements IGitExtension { * Request git status refresh */ async refreshStatus(): Promise { - await this._poll.refresh(); - await this._poll.tick; + await this.ready; + const path = this.pathRepository; + + if (path === null) { + this._setStatus([]); + return Promise.resolve(); + } + + try { + let response = await httpGitRequest('/git/status', 'POST', { + current_path: path + }); + const data = await response.json(); + if (response.status !== 200) { + console.error(data.message); + // TODO should we notify the user + this._setStatus([]); + } + + this._setStatus((data as Git.IStatusResult).files); + } catch (err) { + console.error(err); + // TODO should we notify the user + this._setStatus([]); + } } /** @@ -936,35 +963,6 @@ export class GitExtension implements IGitExtension { } } - /** Refresh the git repository status */ - protected async _refreshStatus(): Promise { - await this.ready; - const path = this.pathRepository; - - if (path === null) { - this._setStatus([]); - return Promise.resolve(); - } - - try { - let response = await httpGitRequest('/git/status', 'POST', { - current_path: path - }); - const data = await response.json(); - if (response.status !== 200) { - console.error(data.message); - // TODO should we notify the user - this._setStatus([]); - } - - this._setStatus((data as Git.IStatusResult).files); - } catch (err) { - console.error(err); - // TODO should we notify the user - this._setStatus([]); - } - } - /** * Set repository status * diff --git a/src/style/BranchHeaderStyle.ts b/src/style/BranchHeaderStyle.ts deleted file mode 100644 index 752aa01e4..000000000 --- a/src/style/BranchHeaderStyle.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { style } from 'typestyle'; - -export const branchStyle = style({ - zIndex: 1, - textAlign: 'center', - overflowY: 'auto', - minHeight: 29 -}); - -export const selectedHeaderStyle = style({ - borderTop: 'var(--jp-border-width) solid var(--jp-border-color2)', - paddingBottom: 'var(--jp-border-width)' -}); - -export const unSelectedHeaderStyle = style({ - backgroundColor: 'var(--jp-layout-color2)', - borderBottom: 'var(--jp-border-width) solid var(--jp-border-color2)', - paddingTop: 'var(--jp-border-width)' -}); - -export const expandedBranchStyle = style({ - height: '500px' -}); - -export const openHistorySideBarButtonStyle = style({ - width: '50px', - flex: 'initial', - paddingLeft: '10px', - paddingRight: '10px', - borderRight: 'var(--jp-border-width) solid var(--jp-border-color2)' -}); - -export const historyLabelStyle = style({ - fontSize: 'var(--jp-ui-font-size1)', - marginTop: '5px', - marginBottom: '5px', - display: 'inline-block', - fontWeight: 'normal' -}); - -export const branchLabelStyle = style({ - fontSize: 'var(--jp-ui-font-size1)', - marginTop: '5px', - marginBottom: '5px', - display: 'inline-block' -}); - -export const branchTrackingLabelStyle = style({ - fontSize: 'var(--jp-ui-font-size1)', - marginTop: '5px', - marginBottom: '5px', - display: 'inline-block', - color: 'var(--jp-ui-font-color2)', - fontWeight: 'normal' -}); - -export const branchDropdownButtonStyle = style({ - backgroundImage: 'var(--jp-icon-arrow-down)', - backgroundSize: '100%', - backgroundRepeat: 'no-repeat', - backgroundPosition: 'center', - height: '18px', - width: '18px', - display: 'inline-block', - verticalAlign: 'middle' -}); - -export const newBranchButtonStyle = style({ - backgroundImage: 'var(--jp-icon-plus)', - backgroundSize: '100%', - backgroundRepeat: 'no-repeat', - backgroundPosition: 'center', - height: '18px', - width: '18px', - display: 'inline-block', - verticalAlign: 'middle' -}); - -export const headerButtonDisabledStyle = style({ - opacity: 0.5 -}); - -export const branchListItemStyle = style({ - listStyle: 'none', - textAlign: 'left', - marginLeft: '1em', - color: 'var(--jp-ui-font-color1)' -}); - -export const branchHeaderCenterContent = style({ - paddingLeft: '5px', - paddingRight: '5px', - flex: 'auto' -}); diff --git a/src/style/BranchMenu.ts b/src/style/BranchMenu.ts new file mode 100644 index 000000000..73105fcfc --- /dev/null +++ b/src/style/BranchMenu.ts @@ -0,0 +1,129 @@ +import { style } from 'typestyle'; + +export const wrapperClass = style({ + marginTop: '6px', + marginBottom: '0' +}); + +export const filterWrapperClass = style({ + padding: '4px 11px 4px' +}); + +export const filterClass = style({ + boxSizing: 'border-box', + display: 'inline-block', + position: 'relative', + + width: 'calc(100% - 7.7em - 11px)', // full_width - button_width - right_margin + + marginRight: '11px', + + fontSize: 'var(--jp-ui-font-size1)' +}); + +export const filterInputClass = style({ + boxSizing: 'border-box', + + width: '100%', + height: '2em', + + /* top | right | bottom | left */ + padding: '1px 18px 2px 7px', + + color: 'var(--jp-ui-font-color0)', + fontSize: 'var(--jp-ui-font-size1)', + fontWeight: 300, + + backgroundColor: 'var(--jp-layout-color1)', + + border: 'var(--jp-border-width) solid var(--jp-border-color2)', + borderRadius: '3px', + + $nest: { + '&:active': { + border: 'var(--jp-border-width) solid var(--jp-brand-color1)' + }, + '&:focus': { + border: 'var(--jp-border-width) solid var(--jp-brand-color1)' + } + } +}); + +export const filterClearClass = style({ + position: 'absolute', + right: '5px', + top: '0.6em', + + height: '1.1em', + width: '1.1em', + + padding: 0, + + backgroundColor: 'var(--jp-inverse-layout-color4)', + + border: 'none', + borderRadius: '50%', + + $nest: { + svg: { + width: '0.5em!important', + height: '0.5em!important', + + fill: 'var(--jp-ui-inverse-font-color0)' + }, + '&:hover': { + backgroundColor: 'var(--jp-inverse-layout-color3)' + }, + '&:active': { + backgroundColor: 'var(--jp-inverse-layout-color2)' + } + } +}); + +export const newBranchButtonClass = style({ + boxSizing: 'border-box', + + width: '7.7em', + height: '2em', + + color: 'white', + fontSize: 'var(--jp-ui-font-size1)', + + backgroundColor: 'var(--jp-brand-color1)', + border: '0', + borderRadius: '3px' +}); + +export const listWrapperClass = style({ + display: 'block', + width: '100%', + minHeight: '150px', + maxHeight: '400px', + + overflow: 'hidden', + overflowY: 'scroll' +}); + +export const listItemClass = style({ + paddingTop: '4px!important', + paddingBottom: '4px!important', + paddingLeft: '11px!important' +}); + +export const activeListItemClass = style({ + color: 'white!important', + + backgroundColor: 'var(--jp-brand-color1)!important' +}); + +export const listItemIconClass = style({ + width: '16px', + height: '16px', + + marginRight: '4px', + + backgroundImage: 'var(--jp-icon-git-branch)', + backgroundSize: '16px', + backgroundRepeat: 'no-repeat', + backgroundPosition: 'center' +}); diff --git a/src/style/CommitBox.ts b/src/style/CommitBox.ts index d6843fdf0..d2ae482c9 100644 --- a/src/style/CommitBox.ts +++ b/src/style/CommitBox.ts @@ -30,7 +30,16 @@ export const commitSummaryClass = style({ backgroundColor: 'var(--jp-layout-color1)', border: 'var(--jp-border-width) solid var(--jp-border-color2)', - borderRadius: '3px' + borderRadius: '3px', + + $nest: { + '&:active': { + border: 'var(--jp-border-width) solid var(--jp-brand-color1)' + }, + '&:focus': { + border: 'var(--jp-border-width) solid var(--jp-brand-color1)' + } + } }); export const commitDescriptionClass = style({ @@ -53,7 +62,12 @@ export const commitDescriptionClass = style({ $nest: { '&:focus': { - outline: 'none' + outline: 'none', + border: 'var(--jp-border-width) solid var(--jp-brand-color1)' + }, + '&:active': { + outline: 'none', + border: 'var(--jp-border-width) solid var(--jp-brand-color1)' }, '&::placeholder': { color: 'var(--jp-ui-font-color3)' diff --git a/src/style/FileListStyle.ts b/src/style/FileListStyle.ts index b8cb4b837..cb4e4427e 100644 --- a/src/style/FileListStyle.ts +++ b/src/style/FileListStyle.ts @@ -1,5 +1,15 @@ import { style } from 'typestyle'; +export const fileListWrapperClass = style({ + height: 'auto', + minHeight: '150px', + maxHeight: '400px', + paddingBottom: '40px', + + overflow: 'hidden', + overflowY: 'scroll' +}); + export const moveFileUpButtonStyle = style({ backgroundImage: 'var(--jp-icon-move-file-up)' }); diff --git a/src/style/GitPanel.ts b/src/style/GitPanel.ts new file mode 100644 index 000000000..685cd836f --- /dev/null +++ b/src/style/GitPanel.ts @@ -0,0 +1,59 @@ +import { style } from 'typestyle'; + +export const panelWrapperClass = style({ + display: 'flex', + flexDirection: 'column', + height: '100%', + overflowY: 'auto' +}); + +export const warningWrapperClass = style({ + marginTop: '9px', + textAlign: 'center' +}); + +export const repoButtonClass = style({ + marginTop: '5px', + color: 'white', + backgroundColor: 'var(--jp-brand-color1)' +}); + +export const tabsClass = style({ + minHeight: '36px!important', + + $nest: { + 'button:last-of-type': { + borderRight: 'none' + } + } +}); + +export const tabClass = style({ + width: '50%', + minWidth: '0!important', + maxWidth: '50%!important', + minHeight: '36px!important', + + backgroundColor: 'var(--jp-layout-color2)!important', + + borderBottom: + 'var(--jp-border-width) solid var(--jp-border-color2)!important', + borderRight: 'var(--jp-border-width) solid var(--jp-border-color2)!important', + + $nest: { + span: { + textTransform: 'none' + } + } +}); + +export const selectedTabClass = style({ + backgroundColor: 'var(--jp-layout-color1)!important' +}); + +export const tabIndicatorClass = style({ + height: '3px!important', + + backgroundColor: 'var(--jp-brand-color1)!important', + transition: 'none!important' +}); diff --git a/src/style/GitPanelStyle.ts b/src/style/GitPanelStyle.ts deleted file mode 100644 index 1043ef8de..000000000 --- a/src/style/GitPanelStyle.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { style } from 'typestyle'; - -export const panelContainerStyle = style({ - display: 'flex', - flexDirection: 'column', - overflowY: 'auto', - height: '100%' -}); - -export const panelWarningStyle = style({ - textAlign: 'center', - marginTop: '9px' -}); - -export const findRepoButtonStyle = style({ - color: 'white', - backgroundColor: 'var(--jp-brand-color1)', - marginTop: '5px' -}); diff --git a/src/style/HistorySideBarStyle.ts b/src/style/HistorySideBarStyle.ts index f0423ad68..9f6c5f146 100644 --- a/src/style/HistorySideBarStyle.ts +++ b/src/style/HistorySideBarStyle.ts @@ -3,7 +3,12 @@ import { style } from 'typestyle'; export const historySideBarStyle = style({ display: 'flex', flexDirection: 'column', + + minHeight: '400px', + + marginBlockStart: 0, + marginBlockEnd: 0, paddingLeft: 0, - overflowY: 'scroll', - marginBlockEnd: 0 + + overflowY: 'scroll' }); diff --git a/src/style/NewBranchBoxStyle.ts b/src/style/NewBranchBoxStyle.ts deleted file mode 100644 index 88324df5c..000000000 --- a/src/style/NewBranchBoxStyle.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { style, keyframes } from 'typestyle'; - -export const newBranchInputAreaStyle = style({ - verticalAlign: 'middle' -}); - -export const slideAnimation = keyframes({ - from: { - width: '0', - left: '0' - }, - to: { - width: '84px', - left: '0' - } -}); - -export const newBranchBoxStyle = style({ - width: '84px', - height: '17px', - boxSizing: 'border-box', - margin: '0', - padding: '2px', - verticalAlign: 'middle', - animationDuration: '0.5s', - animationTimingFunction: 'ease-out', - animationName: slideAnimation, - outline: 'none', - - $nest: { - '&:focus': { - border: '1px var(--jp-brand-color2) solid' - } - } -}); - -export const buttonStyle = style({ - backgroundSize: '100%', - backgroundRepeat: 'no-repeat', - backgroundPosition: 'center', - width: '17px', - height: '17px', - verticalAlign: 'middle', - outline: 'none', - border: 'none' -}); - -export const newBranchButtonStyle = style({ - backgroundImage: 'var(--jp-icon-plus-white)', - backgroundColor: 'var(--jp-brand-color1)' -}); - -export const cancelNewBranchButtonStyle = style({ - backgroundImage: 'var(--jp-icon-clear-white)', - backgroundColor: 'var(--jp-layout-color4)' -}); diff --git a/src/style/NewBranchDialog.ts b/src/style/NewBranchDialog.ts new file mode 100644 index 000000000..6d9cd4e26 --- /dev/null +++ b/src/style/NewBranchDialog.ts @@ -0,0 +1,261 @@ +import { style } from 'typestyle'; + +export const branchDialogClass = style({ + height: '460px', + width: '400px', + + color: 'var(--jp-ui-font-color1)!important', + + borderRadius: '3px!important', + + backgroundColor: 'var(--jp-layout-color1)!important' +}); + +export const closeButtonClass = style({ + position: 'absolute', + top: '10px', + right: '12px', + + height: '30px', + width: '30px', + + padding: 0, + + border: 'none', + borderRadius: '50%', + + backgroundColor: 'var(--jp-layout-color1)', + + $nest: { + svg: { + fill: 'var(--jp-ui-font-color1)' + }, + '&:hover': { + backgroundColor: 'var(--jp-border-color2)' + }, + '&:active': { + backgroundColor: 'var(--jp-border-color2)' + } + } +}); + +export const titleWrapperClass = style({ + boxSizing: 'border-box', + position: 'relative', + + padding: '15px', + + borderBottom: 'var(--jp-border-width) solid var(--jp-border-color2)' +}); + +export const titleClass = style({ + fontWeight: 700 +}); + +export const contentWrapperClass = style({ + padding: '15px', + + $nest: { + '> p': { + marginBottom: '7px' + } + } +}); + +export const nameInputClass = style({ + boxSizing: 'border-box', + + width: '100%', + height: '2em', + + marginBottom: '16px', + + /* top | right | bottom | left */ + padding: '1px 18px 2px 7px', + + color: 'var(--jp-ui-font-color0)', + fontSize: 'var(--jp-ui-font-size1)', + fontWeight: 300, + + backgroundColor: 'var(--jp-layout-color1)', + + border: 'var(--jp-border-width) solid var(--jp-border-color2)', + borderRadius: '3px', + + $nest: { + '&:active': { + border: 'var(--jp-border-width) solid var(--jp-brand-color1)' + }, + '&:focus': { + border: 'var(--jp-border-width) solid var(--jp-brand-color1)' + } + } +}); + +export const filterWrapperClass = style({ + padding: 0, + paddingBottom: '4px' +}); + +export const filterClass = style({ + boxSizing: 'border-box', + display: 'inline-block', + position: 'relative', + + width: '100%', + + marginRight: '11px', + + fontSize: 'var(--jp-ui-font-size1)' +}); + +export const filterInputClass = style({ + boxSizing: 'border-box', + + width: '100%', + height: '2em', + + /* top | right | bottom | left */ + padding: '1px 18px 2px 7px', + + color: 'var(--jp-ui-font-color0)', + fontSize: 'var(--jp-ui-font-size1)', + fontWeight: 300, + + backgroundColor: 'var(--jp-layout-color1)', + + border: 'var(--jp-border-width) solid var(--jp-border-color2)', + borderRadius: '3px', + + $nest: { + '&:active': { + border: 'var(--jp-border-width) solid var(--jp-brand-color1)' + }, + '&:focus': { + border: 'var(--jp-border-width) solid var(--jp-brand-color1)' + } + } +}); + +export const filterClearClass = style({ + position: 'absolute', + right: '5px', + top: '0.6em', + + height: '1.1em', + width: '1.1em', + + padding: 0, + + backgroundColor: 'var(--jp-inverse-layout-color4)', + + border: 'none', + borderRadius: '50%', + + $nest: { + svg: { + width: '0.5em!important', + height: '0.5em!important', + + fill: 'var(--jp-ui-inverse-font-color0)' + }, + '&:hover': { + backgroundColor: 'var(--jp-inverse-layout-color3)' + }, + '&:active': { + backgroundColor: 'var(--jp-inverse-layout-color2)' + } + } +}); + +export const listWrapperClass = style({ + boxSizing: 'border-box', + display: 'block', + + width: '100%', + height: '200px', + + border: 'var(--jp-border-width) solid var(--jp-border-color2)', + borderRadius: '3px', + + overflow: 'hidden', + overflowY: 'scroll' +}); + +export const listItemClass = style({ + flexDirection: 'row', + flexWrap: 'wrap', + + width: '100%', + + /* top | right | bottom | left */ + padding: '4px 11px 4px 11px!important', + + fontSize: 'var(--jp-ui-font-size1)', + lineHeight: '1.5em' +}); + +export const activeListItemClass = style({ + color: 'white!important', + + backgroundColor: 'var(--jp-brand-color1)!important' +}); + +export const listItemContentClass = style({ + flexBasis: 0, + flexGrow: 1, + + marginTop: 'auto', + marginBottom: 'auto', + marginRight: 'auto' +}); + +export const listItemDescClass = style({ + marginBottom: 'auto' +}); + +export const listItemIconClass = style({ + width: '16px', + height: '16px', + + /* top | right | bottom | left */ + margin: 'auto 8px auto 0', + + backgroundImage: 'var(--jp-icon-git-branch)', + backgroundSize: '16px', + backgroundRepeat: 'no-repeat', + backgroundPosition: 'center' +}); + +export const listItemTitleClass = style({}); + +export const listItemBoldTitleClass = style({ + fontWeight: 700 +}); + +export const actionsWrapperClass = style({ + padding: '15px!important', + + borderTop: 'var(--jp-border-width) solid var(--jp-border-color2)' +}); + +export const buttonClass = style({ + boxSizing: 'border-box', + + width: '9em', + height: '2em', + + color: 'white', + fontSize: 'var(--jp-ui-font-size1)', + + border: '0', + borderRadius: '3px' +}); + +export const cancelButtonClass = style({ + backgroundColor: '#757575' +}); + +export const createButtonClass = style({ + backgroundColor: 'var(--jp-brand-color1)' +}); diff --git a/src/style/PathHeaderStyle.ts b/src/style/PathHeaderStyle.ts deleted file mode 100644 index 347548e3c..000000000 --- a/src/style/PathHeaderStyle.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { style } from 'typestyle'; - -export const repoStyle = style({ - display: 'flex', - flexDirection: 'row', - backgroundColor: 'var(--jp-layout-color1)', - lineHeight: 'var(--jp-private-running-item-height)', - minHeight: '35px' -}); - -export const repoPathStyle = style({ - fontSize: 'var(--jp-ui-font-size1)', - marginRight: '4px', - paddingLeft: '4px', - textOverflow: 'ellipsis', - overflow: 'hidden', - whiteSpace: 'nowrap', - verticalAlign: 'middle', - lineHeight: '33px' -}); - -export const repoRefreshStyle = style({ - width: 'var(--jp-private-running-button-width)', - background: 'var(--jp-layout-color1)', - border: 'none', - backgroundImage: 'var(--jp-icon-refresh)', - backgroundSize: '16px', - backgroundRepeat: 'no-repeat', - backgroundPosition: 'center', - boxSizing: 'border-box', - outline: 'none', - padding: '0px 6px', - margin: 'auto 5px auto 5px', - height: '24px', - - $nest: { - '&:hover': { - backgroundColor: 'var(--jp-layout-color2)' - }, - '&:active': { - backgroundColor: 'var(--jp-layout-color3)' - } - } -}); - -export const gitPushStyle = style({ - width: 'var(--jp-private-running-button-width)', - background: 'var(--jp-layout-color1)', - border: 'none', - backgroundImage: 'var(--jp-icon-git-push)', - backgroundSize: '16px', - backgroundRepeat: 'no-repeat', - backgroundPosition: 'center', - boxSizing: 'border-box', - outline: 'none', - padding: '0px 6px', - margin: 'auto 5px auto 5px', - height: '24px', - - $nest: { - '&:hover': { - backgroundColor: 'var(--jp-layout-color2)' - }, - '&:active': { - backgroundColor: 'var(--jp-layout-color3)' - } - } -}); - -export const gitPullStyle = style({ - width: 'var(--jp-private-running-button-width)', - background: 'var(--jp-layout-color1)', - border: 'none', - backgroundImage: 'var(--jp-icon-git-pull)', - backgroundSize: '16px', - backgroundRepeat: 'no-repeat', - backgroundPosition: 'center', - boxSizing: 'border-box', - outline: 'none', - padding: '0px 6px', - margin: 'auto 5px auto auto', - height: '24px', - - $nest: { - '&:hover': { - backgroundColor: 'var(--jp-layout-color2)' - }, - '&:active': { - backgroundColor: 'var(--jp-layout-color3)' - } - } -}); diff --git a/src/style/Toolbar.ts b/src/style/Toolbar.ts new file mode 100644 index 000000000..60bc46357 --- /dev/null +++ b/src/style/Toolbar.ts @@ -0,0 +1,167 @@ +import { style } from 'typestyle'; + +export const toolbarClass = style({ + display: 'flex', + flexDirection: 'column', + + backgroundColor: 'var(--jp-layout-color1)' +}); + +export const toolbarNavClass = style({ + display: 'flex', + flexDirection: 'row', + flexWrap: 'wrap', + + minHeight: '35px', + lineHeight: 'var(--jp-private-running-item-height)', + + backgroundColor: 'var(--jp-layout-color1)', + + borderBottomStyle: 'solid', + borderBottomWidth: 'var(--jp-border-width)', + borderBottomColor: 'var(--jp-border-color2)' +}); + +export const toolbarMenuWrapperClass = style({ + background: 'var(--jp-layout-color1)', + + borderBottomStyle: 'solid', + borderBottomWidth: 'var(--jp-border-width)', + borderBottomColor: 'var(--jp-border-color2)' +}); + +export const toolbarMenuButtonClass = style({ + boxSizing: 'border-box', + display: 'flex', + flexDirection: 'row', + flexWrap: 'wrap', + + width: '100%', + minHeight: '50px', + + /* top | right | bottom | left */ + padding: '4px 11px 4px 11px', + + fontSize: 'var(--jp-ui-font-size1)', + lineHeight: '1.5em', + color: 'var(--jp-ui-font-color0)', + textAlign: 'left', + + border: 'none', + borderRadius: 0, + + background: 'var(--jp-layout-color1)' +}); + +export const toolbarMenuButtonEnabledClass = style({ + $nest: { + '&:hover': { + backgroundColor: 'var(--jp-layout-color2)' + }, + '&:active': { + backgroundColor: 'var(--jp-layout-color3)' + } + } +}); + +export const toolbarMenuButtonIconClass = style({ + width: '16px', + height: '16px', + + /* top | right | bottom | left */ + margin: 'auto 8px auto 0' +}); + +export const toolbarMenuButtonTitleWrapperClass = style({ + flexBasis: 0, + flexGrow: 1, + + marginTop: 'auto', + marginBottom: 'auto', + marginRight: 'auto' +}); + +export const toolbarMenuButtonTitleClass = style({}); + +export const toolbarMenuButtonSubtitleClass = style({ + marginBottom: 'auto', + + fontWeight: 700 +}); + +export const toolbarButtonClass = style({ + boxSizing: 'border-box', + height: '24px', + width: 'var(--jp-private-running-button-width)', + + margin: 'auto 0 auto 0', + padding: '0px 6px', + + border: 'none', + outline: 'none', + + $nest: { + '&:hover': { + backgroundColor: 'var(--jp-layout-color2)' + }, + '&:active': { + backgroundColor: 'var(--jp-layout-color3)' + } + } +}); + +export const pullButtonClass = style({ + marginLeft: 'auto', + + background: 'var(--jp-layout-color1)', + backgroundImage: 'var(--jp-icon-git-pull)', + backgroundSize: '16px', + backgroundRepeat: 'no-repeat', + backgroundPosition: 'center' +}); + +export const pushButtonClass = style({ + background: 'var(--jp-layout-color1)', + backgroundImage: 'var(--jp-icon-git-push)', + backgroundSize: '16px', + backgroundRepeat: 'no-repeat', + backgroundPosition: 'center' +}); + +export const refreshButtonClass = style({ + marginRight: '4px', + + background: 'var(--jp-layout-color1)', + backgroundImage: 'var(--jp-icon-refresh)', + backgroundSize: '16px', + backgroundRepeat: 'no-repeat', + backgroundPosition: 'center' +}); + +export const repoIconClass = style({ + backgroundImage: 'var(--jp-icon-git-repo)', + backgroundSize: '16px', + backgroundRepeat: 'no-repeat', + backgroundPosition: 'center' +}); + +export const branchIconClass = style({ + backgroundImage: 'var(--jp-icon-git-branch)', + backgroundSize: '16px', + backgroundRepeat: 'no-repeat', + backgroundPosition: 'center' +}); + +export const openMenuIconClass = style({ + backgroundImage: 'var(--jp-icon-caretdown)', + backgroundSize: '20px', + backgroundRepeat: 'no-repeat', + backgroundPosition: 'center' +}); + +export const closeMenuIconClass = style({ + backgroundImage: 'var(--jp-icon-caretup)', + backgroundSize: '20px', + backgroundRepeat: 'no-repeat', + backgroundPosition: 'center' +}); diff --git a/src/tokens.ts b/src/tokens.ts index a318b4a06..608867f6f 100644 --- a/src/tokens.ts +++ b/src/tokens.ts @@ -319,6 +319,10 @@ export namespace Git { * Is it a new branch? */ newBranch?: boolean; + /** + * The commit (branch name, tag, or commit id) to which a new branch HEAD will point. + */ + startpoint?: string; /** * Filename */ diff --git a/style/images/desktop-white.svg b/style/images/desktop-white.svg new file mode 100644 index 000000000..7dace5079 --- /dev/null +++ b/style/images/desktop-white.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/style/images/desktop.svg b/style/images/desktop.svg new file mode 100644 index 000000000..fd943060e --- /dev/null +++ b/style/images/desktop.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/style/images/git-branch-white.svg b/style/images/git-branch-white.svg new file mode 100644 index 000000000..9bc29e5aa --- /dev/null +++ b/style/images/git-branch-white.svg @@ -0,0 +1,3 @@ + + + diff --git a/style/images/git-branch.svg b/style/images/git-branch.svg new file mode 100644 index 000000000..386a5fb87 --- /dev/null +++ b/style/images/git-branch.svg @@ -0,0 +1,3 @@ + + + diff --git a/style/variables.css b/style/variables.css index 1b7d3fef0..460eb97d7 100644 --- a/style/variables.css +++ b/style/variables.css @@ -4,38 +4,42 @@ |----------------------------------------------------------------------------*/ :root { - --jp-icon-discard-file-selected: url('./images/discard-selected.svg'); - --jp-icon-move-file-down-hover: url('./images/move-file-down-hover.svg'); - --jp-icon-move-file-up-hover: url('./images/move-file-up-hover.svg'); --jp-checkmark: url('./images/checkmark-icon.svg'); - --jp-icon-clear-white: url('./images/clear-white.svg'); - --jp-icon-git-clone: url('./images/git-clone-icon.svg'); - --jp-git-diff-deleted-color: rgba(255, 0, 0, 0.2); --jp-git-diff-added-color: rgba(155, 185, 85, 0.2); - --jp-git-diff-output-color: rgba(0, 141, 255, 0.3); + --jp-git-diff-deleted-color: rgba(255, 0, 0, 0.2); --jp-git-diff-output-border-color: rgba(0, 141, 255, 0.7); + --jp-git-diff-output-color: rgba(0, 141, 255, 0.3); + --jp-icon-clear-white: url('./images/clear-white.svg'); + --jp-icon-discard-file-selected: url('./images/discard-selected.svg'); + --jp-icon-git-clone: url('./images/git-clone-icon.svg'); + --jp-icon-move-file-down-hover: url('./images/move-file-down-hover.svg'); + --jp-icon-move-file-up-hover: url('./images/move-file-up-hover.svg'); } [data-jp-theme-light='true'] { --jp-icon-arrow-down: url('./images/arrow-down.svg'); + --jp-icon-diff: url('./images/diff-hover.svg'); --jp-icon-discard-file: url('./images/discard.svg'); + --jp-icon-git-branch: url('./images/git-branch.svg'); --jp-icon-git-pull: url('./images/git-pull.svg'); --jp-icon-git-push: url('./images/git-push.svg'); + --jp-icon-git-repo: url('./images/desktop.svg'); --jp-icon-move-file-down: url('./images/move-file-down.svg'); --jp-icon-move-file-up: url('./images/move-file-up.svg'); --jp-icon-plus: url('./images/plus.svg'); --jp-icon-rewind: url('./images/rewind.svg'); - --jp-icon-diff: url('./images/diff-hover.svg'); } [data-jp-theme-light='false'] { --jp-icon-arrow-down: url('./images/arrow-down-white.svg'); + --jp-icon-diff: url('./images/diff-hover-dk.svg'); --jp-icon-discard-file: url('./images/discard-selected.svg'); + --jp-icon-git-branch: url('./images/git-branch-white.svg'); --jp-icon-git-pull: url('./images/git-pull-white.svg'); --jp-icon-git-push: url('./images/git-push-white.svg'); + --jp-icon-git-repo: url('./images/desktop-white.svg'); --jp-icon-move-file-down: url('./images/move-file-down-hover.svg'); --jp-icon-move-file-up: url('./images/move-file-up-hover.svg'); --jp-icon-plus: url('./images/plus-white.svg'); --jp-icon-rewind: url('./images/rewind-white.svg'); - --jp-icon-diff: url('./images/diff-hover-dk.svg'); } diff --git a/tests/test-components/BranchHeader.spec.tsx b/tests/test-components/BranchHeader.spec.tsx deleted file mode 100644 index 348ef3b65..000000000 --- a/tests/test-components/BranchHeader.spec.tsx +++ /dev/null @@ -1,130 +0,0 @@ -import 'jest'; -import { - BranchHeader, - IBranchHeaderProps -} from '../../src/components/BranchHeader'; -import { branchStyle } from '../../src/style/BranchHeaderStyle'; -import { GitExtension } from '../../src/model'; -import * as git from '../../src/git'; - -jest.mock('../../src/git'); - -describe('BranchHeader', () => { - const props: IBranchHeaderProps = { - model: null, - currentBranch: 'master', - sideBarExpanded: false, - upstreamBranch: 'origin/master', - stagedFiles: [ - { x: '', y: '', to: '', from: 'test-1' }, - { x: '', y: '', to: '', from: 'test-2' } - ], - data: [ - { - is_current_branch: true, - is_remote_branch: false, - name: 'master', - upstream: '', - top_commit: '', - tag: '' - }, - { - is_current_branch: true, - is_remote_branch: false, - name: 'feature-1', - upstream: '', - top_commit: '', - tag: '' - }, - { - is_current_branch: true, - is_remote_branch: false, - name: 'feature-2', - upstream: '', - top_commit: '', - tag: '' - }, - { - is_current_branch: true, - is_remote_branch: false, - name: 'patch-007', - upstream: '', - top_commit: '', - tag: '' - } - ], - refresh: () => Promise.resolve(), - disabled: false, - toggleSidebar: function() { - return true; - } - }; - - describe('#constructor()', () => { - let branchHeader: BranchHeader = null; - - beforeEach(async () => { - const fakeRoot = '/foo'; - const mockGit = git as jest.Mocked; - mockGit.httpGitRequest.mockImplementation((url, method, request) => { - if (url === '/git/server_root') { - return Promise.resolve( - new Response( - JSON.stringify({ - server_root: fakeRoot - }) - ) - ); - } - }); - props.model = new GitExtension(); - - branchHeader = new BranchHeader(props); - }); - - it('should construct a new branch header', () => { - expect(branchHeader).toBeInstanceOf(BranchHeader); - }); - - it('should set default values correctly', () => { - expect(branchHeader.state.dropdownOpen).toEqual(false); - expect(branchHeader.state.showNewBranchBox).toEqual(false); - }); - }); - - describe('#switchBranch()', () => { - const branchHeader = new BranchHeader(props); - it('should switch to specified branch', () => { - const spy = jest.spyOn(GitExtension.prototype, 'checkout'); - branchHeader.switchBranch('new-feature'); - expect(spy).toHaveBeenCalledTimes(1); - expect(spy).toHaveBeenCalledWith({ - branchname: 'new-feature' // Branch name - }); - spy.mockRestore(); - }); - }); - - describe('#createNewBranch()', () => { - const branchHeader = new BranchHeader(props); - it('should create and checkout new branch', () => { - const spy = jest.spyOn(GitExtension.prototype, 'checkout'); - branchHeader.createNewBranch('new-feature'); - expect(spy).toHaveBeenCalledTimes(1); - expect(spy).toHaveBeenCalledWith({ - newBranch: true, // Create new branch if it doesn't exist - branchname: 'new-feature' // Branch name - }); - spy.mockRestore(); - }); - }); - - describe('#getBranchStyle()', () => { - it('should return correct branch style without drop down state', () => { - const branchHeader = new BranchHeader(props); - let actual = branchHeader.getBranchStyle(); - let expected = branchStyle; - expect(actual).toEqual(expected); - }); - }); -}); diff --git a/tests/test-components/BranchMenu.spec.tsx b/tests/test-components/BranchMenu.spec.tsx new file mode 100644 index 000000000..7145b2129 --- /dev/null +++ b/tests/test-components/BranchMenu.spec.tsx @@ -0,0 +1,357 @@ +import * as React from 'react'; +import 'jest'; +import { shallow } from 'enzyme'; +import { GitExtension } from '../../src/model'; +import * as git from '../../src/git'; +import { listItemClass } from '../../src/style/BranchMenu'; +import { BranchMenu } from '../../src/components/BranchMenu'; + +jest.mock('../../src/git'); + +const BRANCHES = [ + { + is_current_branch: true, + is_remote_branch: false, + name: 'master', + upstream: '', + top_commit: '', + tag: '' + }, + { + is_current_branch: true, + is_remote_branch: false, + name: 'feature-1', + upstream: '', + top_commit: '', + tag: '' + }, + { + is_current_branch: true, + is_remote_branch: false, + name: 'feature-2', + upstream: '', + top_commit: '', + tag: '' + }, + { + is_current_branch: true, + is_remote_branch: false, + name: 'patch-007', + upstream: '', + top_commit: '', + tag: '' + } +]; + +function request(url: string, method: string, request: Object | null) { + let response: Response; + switch (url) { + case '/git/show_top_level': + response = new Response( + JSON.stringify({ + code: 0, + top_repo_path: (request as any)['current_path'] + }) + ); + break; + case '/git/server_root': + response = new Response( + JSON.stringify({ + server_root: '/foo' + }) + ); + break; + default: + response = new Response( + `{"message": "No mock implementation for ${url}."}`, + { status: 404 } + ); + } + return Promise.resolve(response); +} + +async function createModel() { + const model = new GitExtension(); + + jest.spyOn(model, 'branches', 'get').mockReturnValue(BRANCHES); + jest.spyOn(model, 'currentBranch', 'get').mockReturnValue(BRANCHES[0]); + model.pathRepository = '/path/to/repo'; + + await model.ready; + return model; +} + +describe('BranchMenu', () => { + describe('constructor', () => { + let model: GitExtension; + + beforeEach(async () => { + const mock = git as jest.Mocked; + mock.httpGitRequest.mockImplementation(request); + + model = await createModel(); + }); + + it('should return a new instance', () => { + const props = { + model: model, + branching: false + }; + const menu = new BranchMenu(props); + expect(menu).toBeInstanceOf(BranchMenu); + }); + + it('should set the default menu filter to an empty string', () => { + const props = { + model: model, + branching: false + }; + const menu = new BranchMenu(props); + expect(menu.state.filter).toEqual(''); + }); + + it('should set the default flag indicating whether to show a dialog to create a new branch to `false`', () => { + const props = { + model: model, + branching: false + }; + const menu = new BranchMenu(props); + expect(menu.state.branchDialog).toEqual(false); + }); + }); + + describe('render', () => { + let model: GitExtension; + + beforeEach(async () => { + const mock = git as jest.Mocked; + mock.httpGitRequest.mockImplementation(request); + + model = await createModel(); + }); + + it('should display placeholder text for the menu filter', () => { + const props = { + model: model, + branching: false + }; + const component = shallow(); + const node = component.find('input[type="text"]').first(); + expect(node.prop('placeholder')).toEqual('Filter'); + }); + + it('should set a `title` attribute on the input element to filter a branch menu', () => { + const props = { + model: model, + branching: false + }; + const component = shallow(); + const node = component.find('input[type="text"]').first(); + expect(node.prop('title').length > 0).toEqual(true); + }); + + it('should display a button to clear the menu filter once a filter is provided', () => { + const props = { + model: model, + branching: false + }; + const component = shallow(); + component.setState({ + filter: 'foo' + }); + const nodes = component.find('ClearIcon'); + expect(nodes.length).toEqual(1); + }); + + it('should set a `title` on the button to clear the menu filter', () => { + const props = { + model: model, + branching: false + }; + const component = shallow(); + component.setState({ + filter: 'foo' + }); + const html = component + .find('ClearIcon') + .first() + .html(); + expect(html.includes('')).toEqual(true); + }); + + it('should display a button to create a new branch', () => { + const props = { + model: model, + branching: false + }; + const component = shallow(<BranchMenu {...props} />); + const node = component.find('input[type="button"]').first(); + expect(node.prop('value')).toEqual('New Branch'); + }); + + it('should set a `title` attribute on the button to create a new branch', () => { + const props = { + model: model, + branching: false + }; + const component = shallow(<BranchMenu {...props} />); + const node = component.find('input[type="button"]').first(); + expect(node.prop('title').length > 0).toEqual(true); + }); + + it('should display a list of branches', () => { + const props = { + model: model, + branching: false + }; + const component = shallow(<BranchMenu {...props} />); + const nodes = component.find(`.${listItemClass}`); + + const branches = model.branches; + expect(nodes.length).toEqual(branches.length); + + // Should contain the branch names... + for (let i = 0; i < branches.length; i++) { + expect( + nodes + .at(i) + .text() + .includes(branches[i].name) + ).toEqual(true); + } + }); + + it('should set a `title` attribute for each displayed branch', () => { + const props = { + model: model, + branching: false + }; + const component = shallow(<BranchMenu {...props} />); + const nodes = component.find(`.${listItemClass}`); + + const branches = model.branches; + expect(nodes.length).toEqual(branches.length); + + for (let i = 0; i < branches.length; i++) { + expect(nodes.at(i).prop('title').length > 0).toEqual(true); + } + }); + + it('should not, by default, show a dialog to create a new branch', () => { + const props = { + model: model, + branching: false + }; + const component = shallow(<BranchMenu {...props} />); + const node = component.find('NewBranchDialog').first(); + expect(node.prop('open')).toEqual(false); + }); + + it('should show a dialog to create a new branch when the flag indicating whether to show the dialog is `true`', () => { + const props = { + model: model, + branching: false + }; + const component = shallow(<BranchMenu {...props} />); + component.setState({ + branchDialog: true + }); + const node = component.find('NewBranchDialog').first(); + expect(node.prop('open')).toEqual(true); + }); + }); + + describe('switch branch', () => { + let model: GitExtension; + + beforeEach(async () => { + const mock = git as jest.Mocked<typeof git>; + mock.httpGitRequest.mockImplementation(request); + + model = await createModel(); + }); + + it('should not switch to a specified branch upon clicking its corresponding element when branching is disabled', () => { + const spy = jest.spyOn(GitExtension.prototype, 'checkout'); + + const props = { + model: model, + branching: false + }; + const component = shallow(<BranchMenu {...props} />); + const nodes = component.find(`.${listItemClass}`); + + const node = nodes.at(1); + node.simulate('click'); + + expect(spy).toHaveBeenCalledTimes(0); + spy.mockRestore(); + }); + + it('should not switch to a specified branch upon clicking its corresponding element when branching is enabled', () => { + const spy = jest.spyOn(GitExtension.prototype, 'checkout'); + + const props = { + model: model, + branching: true + }; + const component = shallow(<BranchMenu {...props} />); + const nodes = component.find(`.${listItemClass}`); + + const node = nodes.at(1); + node.simulate('click'); + + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledWith({ + branchname: BRANCHES[1].name + }); + + spy.mockRestore(); + }); + }); + + describe('create branch', () => { + let model: GitExtension; + + beforeEach(async () => { + const mock = git as jest.Mocked<typeof git>; + mock.httpGitRequest.mockImplementation(request); + + model = await createModel(); + }); + + it('should not allow creating a new branch when branching is disabled', () => { + const spy = jest.spyOn(GitExtension.prototype, 'checkout'); + + const props = { + model: model, + branching: false + }; + const component = shallow(<BranchMenu {...props} />); + + const node = component.find('input[type="button"]').first(); + node.simulate('click'); + + expect(component.state('branchDialog')).toEqual(false); + expect(spy).toHaveBeenCalledTimes(0); + spy.mockRestore(); + }); + + it('should display a dialog to create a new branch when branching is enabled and the new branch button is clicked', () => { + const spy = jest.spyOn(GitExtension.prototype, 'checkout'); + + const props = { + model: model, + branching: true + }; + const component = shallow(<BranchMenu {...props} />); + + const node = component.find('input[type="button"]').first(); + node.simulate('click'); + + expect(component.state('branchDialog')).toEqual(true); + expect(spy).toHaveBeenCalledTimes(0); + spy.mockRestore(); + }); + }); +}); diff --git a/tests/test-components/CommitBox.spec.tsx b/tests/test-components/CommitBox.spec.tsx index b0016aab8..5bb2b5e59 100644 --- a/tests/test-components/CommitBox.spec.tsx +++ b/tests/test-components/CommitBox.spec.tsx @@ -42,6 +42,16 @@ describe('CommitBox', () => { expect(node.prop('placeholder')).toEqual('Summary (required)'); }); + it('should set a `title` attribute on the input element to provide a commit message summary', () => { + const props = { + onCommit: async () => {}, + hasFiles: false + }; + const component = shallow(<CommitBox {...props} />); + const node = component.find('input[type="text"]').first(); + expect(node.prop('title').length > 0).toEqual(true); + }); + it('should display placeholder text for the commit message description', () => { const props = { onCommit: async () => {}, @@ -52,6 +62,16 @@ describe('CommitBox', () => { expect(node.prop('placeholder')).toEqual('Description'); }); + it('should set a `title` attribute on the input element to provide a commit message description', () => { + const props = { + onCommit: async () => {}, + hasFiles: false + }; + const component = shallow(<CommitBox {...props} />); + const node = component.find('TextareaAutosize').first(); + expect(node.prop('title').length > 0).toEqual(true); + }); + it('should display a button to commit changes', () => { const props = { onCommit: async () => {}, @@ -62,6 +82,16 @@ describe('CommitBox', () => { expect(node.prop('value')).toEqual('Commit'); }); + it('should set a `title` attribute on the button to commit changes', () => { + const props = { + onCommit: async () => {}, + hasFiles: false + }; + const component = shallow(<CommitBox {...props} />); + const node = component.find('input[type="button"]').first(); + expect(node.prop('title').length > 0).toEqual(true); + }); + it('should apply a class to disable the commit button when no files have changes to commit', () => { const props = { onCommit: async () => {}, diff --git a/tests/test-components/PathHeader.spec.tsx b/tests/test-components/PathHeader.spec.tsx deleted file mode 100644 index 550e94c41..000000000 --- a/tests/test-components/PathHeader.spec.tsx +++ /dev/null @@ -1,96 +0,0 @@ -import * as React from 'react'; -import { shallow } from 'enzyme'; -import { IPathHeaderProps, PathHeader } from '../../src/components/PathHeader'; -import { - gitPullStyle, - gitPushStyle, - repoRefreshStyle -} from '../../src/style/PathHeaderStyle'; -import 'jest'; -import { GitExtension } from '../../src/model'; -import * as git from '../../src/git'; - -jest.mock('../../src/git'); - -describe('PathHeader', function() { - let props: IPathHeaderProps; - - beforeEach(async () => { - const fakePath = '/path/to/repo'; - const fakeRoot = '/foo'; - const mockGit = git as jest.Mocked<typeof git>; - mockGit.httpGitRequest.mockImplementation((url, method, request) => { - let response: Response; - switch (url) { - case '/git/show_top_level': - response = new Response( - JSON.stringify({ - code: 0, - top_repo_path: (request as any)['current_path'] - }) - ); - break; - case '/git/server_root': - response = new Response( - JSON.stringify({ - server_root: fakeRoot - }) - ); - break; - default: - response = new Response( - `{"message": "No mock implementation for ${url}."}`, - { status: 404 } - ); - } - return Promise.resolve(response); - }); - - const model = new GitExtension(); - model.pathRepository = fakePath; - await model.ready; - - props = { - model: model, - refresh: async () => {} - }; - }); - - it('should have all buttons', function() { - // When - const node = shallow(<PathHeader {...props} />); - - // Then - const buttons = node.find('button'); - expect(buttons).toHaveLength(3); - expect(buttons.find(`.${gitPullStyle}`)).toHaveLength(1); - expect(buttons.find(`.${gitPullStyle}`).prop('title')).toEqual( - 'Pull latest changes' - ); - expect(buttons.find(`.${gitPushStyle}`)).toHaveLength(1); - expect(buttons.find(`.${gitPushStyle}`).prop('title')).toEqual( - 'Push committed changes' - ); - expect(buttons.find(`.${repoRefreshStyle}`)).toHaveLength(1); - }); - - it('should call API on button click', function() { - // Given - const spyPull = jest.spyOn(GitExtension.prototype, 'pull'); - const spyPush = jest.spyOn(GitExtension.prototype, 'push'); - - // When - const node = shallow(<PathHeader {...props} />); - - // Then - const buttons = node.find('button'); - - buttons.find(`.${gitPullStyle}`).simulate('click'); - expect(spyPull).toHaveBeenCalledTimes(1); - expect(spyPull).toHaveBeenCalledWith(undefined); - - buttons.find(`.${gitPushStyle}`).simulate('click'); - expect(spyPush).toHaveBeenCalledTimes(1); - expect(spyPush).toHaveBeenCalledWith(undefined); - }); -}); diff --git a/tests/test-components/Toolbar.spec.tsx b/tests/test-components/Toolbar.spec.tsx new file mode 100644 index 000000000..8e35b7910 --- /dev/null +++ b/tests/test-components/Toolbar.spec.tsx @@ -0,0 +1,361 @@ +import * as React from 'react'; +import 'jest'; +import { shallow } from 'enzyme'; +import { GitExtension } from '../../src/model'; +import * as git from '../../src/git'; +import { Toolbar } from '../../src/components/Toolbar'; +import { + pullButtonClass, + pushButtonClass, + refreshButtonClass, + toolbarMenuButtonClass +} from '../../src/style/Toolbar'; + +jest.mock('../../src/git'); + +async function createModel() { + const model = new GitExtension(); + + jest.spyOn(model, 'currentBranch', 'get').mockReturnValue({ + is_current_branch: true, + is_remote_branch: false, + name: 'master', + upstream: '', + top_commit: '', + tag: '' + }); + model.pathRepository = '/path/to/repo'; + + await model.ready; + return model; +} + +function request(url: string, method: string, request: Object | null) { + let response: Response; + switch (url) { + case '/git/show_top_level': + response = new Response( + JSON.stringify({ + code: 0, + top_repo_path: (request as any)['current_path'] + }) + ); + break; + case '/git/server_root': + response = new Response( + JSON.stringify({ + server_root: '/foo' + }) + ); + break; + default: + response = new Response( + `{"message": "No mock implementation for ${url}."}`, + { status: 404 } + ); + } + return Promise.resolve(response); +} + +describe('Toolbar', () => { + describe('constructor', () => { + let model: GitExtension; + + beforeEach(async () => { + const mock = git as jest.Mocked<typeof git>; + mock.httpGitRequest.mockImplementation(request); + + model = await createModel(); + }); + + it('should return a new instance', () => { + const props = { + model: model, + branching: false, + refresh: async () => {} + }; + const el = new Toolbar(props); + expect(el).toBeInstanceOf(Toolbar); + }); + + it('should set the default flag indicating whether to show a branch menu to `false`', () => { + const props = { + model: model, + branching: false, + refresh: async () => {} + }; + const el = new Toolbar(props); + expect(el.state.branchMenu).toEqual(false); + }); + + it('should set the default flag indicating whether to show a repository menu to `false`', () => { + const props = { + model: model, + branching: false, + refresh: async () => {} + }; + const el = new Toolbar(props); + expect(el.state.repoMenu).toEqual(false); + }); + }); + + describe('render', () => { + let model: GitExtension; + + beforeEach(async () => { + const mock = git as jest.Mocked<typeof git>; + mock.httpGitRequest.mockImplementation(request); + + model = await createModel(); + }); + + it('should display a button to pull the latest changes', () => { + const props = { + model: model, + branching: false, + refresh: async () => {} + }; + const node = shallow(<Toolbar {...props} />); + const nodes = node.find(`.${pullButtonClass}`); + + expect(nodes.length).toEqual(1); + }); + + it('should set the `title` attribute on the button to pull the latest changes', () => { + const props = { + model: model, + branching: false, + refresh: async () => {} + }; + const node = shallow(<Toolbar {...props} />); + const button = node.find(`.${pullButtonClass}`).first(); + + expect(button.prop('title')).toEqual('Pull latest changes'); + }); + + it('should display a button to push the latest changes', () => { + const props = { + model: model, + branching: false, + refresh: async () => {} + }; + const node = shallow(<Toolbar {...props} />); + const nodes = node.find(`.${pushButtonClass}`); + + expect(nodes.length).toEqual(1); + }); + + it('should set the `title` attribute on the button to push the latest changes', () => { + const props = { + model: model, + branching: false, + refresh: async () => {} + }; + const node = shallow(<Toolbar {...props} />); + const button = node.find(`.${pushButtonClass}`).first(); + + expect(button.prop('title')).toEqual('Push committed changes'); + }); + + it('should display a button to refresh the current repository', () => { + const props = { + model: model, + branching: false, + refresh: async () => {} + }; + const node = shallow(<Toolbar {...props} />); + const nodes = node.find(`.${refreshButtonClass}`); + + expect(nodes.length).toEqual(1); + }); + + it('should set the `title` attribute on the button to refresh the current repository', () => { + const props = { + model: model, + branching: false, + refresh: async () => {} + }; + const node = shallow(<Toolbar {...props} />); + const button = node.find(`.${refreshButtonClass}`).first(); + + expect(button.prop('title')).toEqual( + 'Refresh the repository to detect local and remote changes' + ); + }); + + it('should display a button to toggle a repository menu', () => { + const props = { + model: model, + branching: false, + refresh: async () => {} + }; + const node = shallow(<Toolbar {...props} />); + const button = node.find(`.${toolbarMenuButtonClass}`).first(); + + const text = button.text(); + expect(text.includes('Current Repository')).toEqual(true); + }); + + it('should set the `title` attribute on the button to toggle a repository menu', () => { + const props = { + model: model, + branching: false, + refresh: async () => {} + }; + const node = shallow(<Toolbar {...props} />); + const button = node.find(`.${toolbarMenuButtonClass}`).first(); + + const bool = button.prop('title').includes('Current repository: '); + expect(bool).toEqual(true); + }); + + it('should display a button to toggle a branch menu', () => { + const props = { + model: model, + branching: false, + refresh: async () => {} + }; + const node = shallow(<Toolbar {...props} />); + const button = node.find(`.${toolbarMenuButtonClass}`).at(1); + + const text = button.text(); + expect(text.includes('Current Branch')).toEqual(true); + }); + + it('should set the `title` attribute on the button to toggle a branch menu', () => { + const props = { + model: model, + branching: false, + refresh: async () => {} + }; + const node = shallow(<Toolbar {...props} />); + const button = node.find(`.${toolbarMenuButtonClass}`).at(1); + + expect(button.prop('title')).toEqual( + `Change the current branch: ${model.currentBranch.name}` + ); + }); + }); + + describe('branch menu', () => { + let model: GitExtension; + + beforeEach(async () => { + const mock = git as jest.Mocked<typeof git>; + mock.httpGitRequest.mockImplementation(request); + + model = await createModel(); + }); + + it('should not, by default, display a branch menu', () => { + const props = { + model: model, + branching: false, + refresh: async () => {} + }; + const node = shallow(<Toolbar {...props} />); + const nodes = node.find('BranchMenu'); + + expect(nodes.length).toEqual(0); + }); + + it('should display a branch menu when the button to display a branch menu is clicked', () => { + const props = { + model: model, + branching: false, + refresh: async () => {} + }; + const node = shallow(<Toolbar {...props} />); + const button = node.find(`.${toolbarMenuButtonClass}`).at(1); + + button.simulate('click'); + expect(node.find('BranchMenu').length).toEqual(1); + }); + }); + + describe('pull changes', () => { + let model: GitExtension; + + beforeEach(async () => { + const mock = git as jest.Mocked<typeof git>; + mock.httpGitRequest.mockImplementation(request); + + model = await createModel(); + }); + + it('should pull changes when the button to pull the latest changes is clicked', () => { + const spy = jest.spyOn(GitExtension.prototype, 'pull'); + + const props = { + model: model, + branching: false, + refresh: async () => {} + }; + const node = shallow(<Toolbar {...props} />); + const button = node.find(`.${pullButtonClass}`); + + button.simulate('click'); + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledWith(undefined); + + spy.mockRestore(); + }); + }); + + describe('push changes', () => { + let model: GitExtension; + + beforeEach(async () => { + const mock = git as jest.Mocked<typeof git>; + mock.httpGitRequest.mockImplementation(request); + + model = await createModel(); + }); + + it('should push changes when the button to push the latest changes is clicked', () => { + const spy = jest.spyOn(GitExtension.prototype, 'push'); + + const props = { + model: model, + branching: false, + refresh: async () => {} + }; + const node = shallow(<Toolbar {...props} />); + const button = node.find(`.${pushButtonClass}`); + + button.simulate('click'); + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledWith(undefined); + + spy.mockRestore(); + }); + }); + + describe('refresh repository', () => { + let model: GitExtension; + + beforeEach(async () => { + const mock = git as jest.Mocked<typeof git>; + mock.httpGitRequest.mockImplementation(request); + + model = await createModel(); + }); + + it('should refresh the repository when the button to refresh the repository is clicked', () => { + const spy = jest.fn(async () => {}); + + const props = { + model: model, + branching: false, + refresh: spy + }; + const node = shallow(<Toolbar {...props} />); + const button = node.find(`.${refreshButtonClass}`); + + button.simulate('click'); + expect(spy).toHaveBeenCalledTimes(1); + + spy.mockRestore(); + }); + }); +}); diff --git a/yarn.lock b/yarn.lock index 145eeffa2..31ca249ae 100644 --- a/yarn.lock +++ b/yarn.lock @@ -631,6 +631,13 @@ dependencies: regenerator-runtime "^0.13.2" +"@babel/runtime@^7.3.1", "@babel/runtime@^7.4.4", "@babel/runtime@^7.5.5", "@babel/runtime@^7.6.2", "@babel/runtime@^7.6.3": + version "7.7.7" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.7.7.tgz#194769ca8d6d7790ec23605af9ee3e42a0aa79cf" + integrity sha512-uCnC2JEVAu8AKB5do1WRIsvrdJ0flYx/A/9f/6chdacnEZ7LmavjdsDXr5ksYBegxtuTPR5Va9/+13QF/kFkCA== + dependencies: + regenerator-runtime "^0.13.2" + "@babel/template@^7.4.0", "@babel/template@^7.7.0": version "7.7.0" resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.7.0.tgz#4fadc1b8e734d97f56de39c77de76f2562e597d0" @@ -706,6 +713,11 @@ exec-sh "^0.3.2" minimist "^1.2.0" +"@emotion/hash@^0.7.4": + version "0.7.4" + resolved "https://registry.yarnpkg.com/@emotion/hash/-/hash-0.7.4.tgz#f14932887422c9056b15a8d222a9074a7dfa2831" + integrity sha512-fxfMSBMX3tlIbKUdtGKxqB1fyrH6gVrX39Gsv3y8lRYKUqlgDt3UMqQyGnR1bQMa2B8aGnhLZokZgg8vT0Le+A== + "@jest/console@^24.7.1", "@jest/console@^24.9.0": version "24.9.0" resolved "https://registry.yarnpkg.com/@jest/console/-/console-24.9.0.tgz#79b1bc06fb74a8cfb01cbdedf945584b1b9707f0" @@ -1247,6 +1259,80 @@ react "~16.8.4" typestyle "^2.0.1" +"@material-ui/core@^4.8.2": + version "4.8.2" + resolved "https://registry.yarnpkg.com/@material-ui/core/-/core-4.8.2.tgz#883a53985a0e27d4ef73775767597e498a0162bc" + integrity sha512-4dILME6TVCTyi9enavqbYLU8HueaX5YQxfn2IiCiGwHpqp4pIhJCVUVlBf0ADG6lL2K1tWrsawGs/hePpHxAYw== + dependencies: + "@babel/runtime" "^7.4.4" + "@material-ui/styles" "^4.8.2" + "@material-ui/system" "^4.7.1" + "@material-ui/types" "^4.1.1" + "@material-ui/utils" "^4.7.1" + "@types/react-transition-group" "^4.2.0" + clsx "^1.0.2" + convert-css-length "^2.0.1" + hoist-non-react-statics "^3.2.1" + normalize-scroll-left "^0.2.0" + popper.js "^1.14.1" + prop-types "^15.7.2" + react-is "^16.8.0" + react-transition-group "^4.3.0" + +"@material-ui/icons@^4.5.1": + version "4.5.1" + resolved "https://registry.yarnpkg.com/@material-ui/icons/-/icons-4.5.1.tgz#6963bad139e938702ece85ca43067688018f04f8" + integrity sha512-YZ/BgJbXX4a0gOuKWb30mBaHaoXRqPanlePam83JQPZ/y4kl+3aW0Wv9tlR70hB5EGAkEJGW5m4ktJwMgxQAeA== + dependencies: + "@babel/runtime" "^7.4.4" + +"@material-ui/styles@^4.8.2": + version "4.8.2" + resolved "https://registry.yarnpkg.com/@material-ui/styles/-/styles-4.8.2.tgz#841acbc4314accbe82a45cb1feb758d47448c802" + integrity sha512-r5U+93pkpwQOmHTmwyn2sqTio6PHd873xvSHiKP6fdybAXXX6CZgVvh3W8saZNbYr/QXsS8OHmFv7sYJLt5Yfg== + dependencies: + "@babel/runtime" "^7.4.4" + "@emotion/hash" "^0.7.4" + "@material-ui/types" "^4.1.1" + "@material-ui/utils" "^4.7.1" + clsx "^1.0.2" + csstype "^2.5.2" + hoist-non-react-statics "^3.2.1" + jss "^10.0.0" + jss-plugin-camel-case "^10.0.0" + jss-plugin-default-unit "^10.0.0" + jss-plugin-global "^10.0.0" + jss-plugin-nested "^10.0.0" + jss-plugin-props-sort "^10.0.0" + jss-plugin-rule-value-function "^10.0.0" + jss-plugin-vendor-prefixer "^10.0.0" + prop-types "^15.7.2" + +"@material-ui/system@^4.7.1": + version "4.7.1" + resolved "https://registry.yarnpkg.com/@material-ui/system/-/system-4.7.1.tgz#d928dacc0eeae6bea569ff3ee079f409efb3517d" + integrity sha512-zH02p+FOimXLSKOW/OT2laYkl9bB3dD1AvnZqsHYoseUaq0aVrpbl2BGjQi+vJ5lg8w73uYlt9zOWzb3+1UdMQ== + dependencies: + "@babel/runtime" "^7.4.4" + "@material-ui/utils" "^4.7.1" + prop-types "^15.7.2" + +"@material-ui/types@^4.1.1": + version "4.1.1" + resolved "https://registry.yarnpkg.com/@material-ui/types/-/types-4.1.1.tgz#b65e002d926089970a3271213a3ad7a21b17f02b" + integrity sha512-AN+GZNXytX9yxGi0JOfxHrRTbhFybjUJ05rnsBVjcB+16e466Z0Xe5IxawuOayVZgTBNDxmPKo5j4V6OnMtaSQ== + dependencies: + "@types/react" "*" + +"@material-ui/utils@^4.7.1": + version "4.7.1" + resolved "https://registry.yarnpkg.com/@material-ui/utils/-/utils-4.7.1.tgz#dc16c7f0d2cd02fbcdd5cfe601fd6863ae3cc652" + integrity sha512-+ux0SlLdlehvzCk2zdQ3KiS3/ylWvuo/JwAGhvb8dFVvwR21K28z0PU9OQW2PGogrMEdvX3miEI5tGxTwwWiwQ== + dependencies: + "@babel/runtime" "^7.4.4" + prop-types "^15.7.2" + react-is "^16.8.0" + "@phosphor/algorithm@^1.1.2", "@phosphor/algorithm@^1.2.0": version "1.2.0" resolved "https://registry.yarnpkg.com/@phosphor/algorithm/-/algorithm-1.2.0.tgz#4a19aa59261b7270be696672dc3f0663f7bef152" @@ -1483,6 +1569,13 @@ dependencies: "@types/react" "*" +"@types/react-transition-group@^4.2.0": + version "4.2.3" + resolved "https://registry.yarnpkg.com/@types/react-transition-group/-/react-transition-group-4.2.3.tgz#4924133f7268694058e415bf7aea2d4c21131470" + integrity sha512-Hk8jiuT7iLOHrcjKP/ZVSyCNXK73wJAUz60xm0mVhiRujrdiI++j4duLiL282VGxwAgxetHQFfqA29LgEeSkFA== + dependencies: + "@types/react" "*" + "@types/react@*", "@types/react@~16.8.13", "@types/react@~16.8.18", "@types/react@~16.8.4": version "16.8.25" resolved "https://registry.yarnpkg.com/@types/react/-/react-16.8.25.tgz#0247613ab58b1b11ba10fed662e1947c5f2bb89c" @@ -2043,6 +2136,11 @@ cliui@^5.0.0: strip-ansi "^5.2.0" wrap-ansi "^5.1.0" +clsx@^1.0.2: + version "1.0.4" + resolved "https://registry.yarnpkg.com/clsx/-/clsx-1.0.4.tgz#0c0171f6d5cb2fe83848463c15fcc26b4df8c2ec" + integrity sha512-1mQ557MIZTrL/140j+JVdRM6e31/OA4vTYxXgqIIZlndyfjHpyawKZia1Im05Vp9BWmImkcNrNtFYQMyFcgJDg== + co@^4.6.0: version "4.6.0" resolved "https://registry.yarnpkg.com/co/-/co-4.6.0.tgz#6ea6bdf3d853ae54ccb8e47bfa0bf3f9031fb184" @@ -2115,6 +2213,11 @@ console-control-strings@^1.0.0, console-control-strings@~1.1.0: resolved "https://registry.yarnpkg.com/console-control-strings/-/console-control-strings-1.1.0.tgz#3d7cf4464db6446ea644bf4b39507f9851008e8e" integrity sha1-PXz0Rk22RG6mRL9LOVB/mFEAjo4= +convert-css-length@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/convert-css-length/-/convert-css-length-2.0.1.tgz#90a76bde5bfd24d72881a5b45d02249b2c1d257c" + integrity sha512-iGpbcvhLPRKUbBc0Quxx7w/bV14AC3ItuBEGMahA5WTYqB8lq9jH0kTXFheCBASsYnqeMFZhiTruNxr1N59Axg== + convert-source-map@^1.4.0, convert-source-map@^1.7.0: version "1.7.0" resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.7.0.tgz#17a2cb882d7f77d3490585e2ce6c524424a3a442" @@ -2187,6 +2290,14 @@ css-select@~1.2.0: domutils "1.5.1" nth-check "~1.0.1" +css-vendor@^2.0.7: + version "2.0.7" + resolved "https://registry.yarnpkg.com/css-vendor/-/css-vendor-2.0.7.tgz#4e6d53d953c187981576d6a542acc9fb57174bda" + integrity sha512-VS9Rjt79+p7M0WkPqcAza4Yq1ZHrsHrwf7hPL/bjQB+c1lwmAI+1FXxYTYt818D/50fFVflw0XKleiBN5RITkg== + dependencies: + "@babel/runtime" "^7.6.2" + is-in-browser "^1.0.2" + css-what@2.1: version "2.1.3" resolved "https://registry.yarnpkg.com/css-what/-/css-what-2.1.3.tgz#a6d7604573365fe74686c3f311c56513d88285f2" @@ -2209,6 +2320,11 @@ csstype@^2.2.0, csstype@^2.4.0: resolved "https://registry.yarnpkg.com/csstype/-/csstype-2.6.7.tgz#20b0024c20b6718f4eda3853a1f5a1cce7f5e4a5" integrity sha512-9Mcn9sFbGBAdmimWb2gLVDtFJzeKtDGIr76TUqmjZrw9LFXBMSU70lcs+C0/7fyCd6iBDqmksUcCOUIkisPHsQ== +csstype@^2.5.2, csstype@^2.6.5, csstype@^2.6.7: + version "2.6.8" + resolved "https://registry.yarnpkg.com/csstype/-/csstype-2.6.8.tgz#0fb6fc2417ffd2816a418c9336da74d7f07db431" + integrity sha512-msVS9qTuMT5zwAGCVm4mxfrZ18BNc6Csd0oJAtiFMZ1FAx1CCvy2+5MDmYoix63LM/6NDbNtodCiGYGmFgO0dA== + dashdash@^1.12.0: version "1.14.1" resolved "https://registry.yarnpkg.com/dashdash/-/dashdash-1.14.1.tgz#853cfa0f7cbe2fed5de20326b8dd581035f6e2f0" @@ -2364,6 +2480,14 @@ dom-helpers@^3.4.0: dependencies: "@babel/runtime" "^7.1.2" +dom-helpers@^5.0.1: + version "5.1.3" + resolved "https://registry.yarnpkg.com/dom-helpers/-/dom-helpers-5.1.3.tgz#7233248eb3a2d1f74aafca31e52c5299cc8ce821" + integrity sha512-nZD1OtwfWGRBWlpANxacBEZrEuLa16o1nh7YopFWeoF68Zt8GGEmzHu6Xv4F3XaFIC+YXtTLrzgqKxFgLEe4jw== + dependencies: + "@babel/runtime" "^7.6.3" + csstype "^2.6.7" + dom-serializer@0: version "0.2.2" resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-0.2.2.tgz#1afb81f533717175d478655debc5e332d9f9bb51" @@ -3061,6 +3185,13 @@ has@^1.0.1, has@^1.0.3: dependencies: function-bind "^1.1.1" +hoist-non-react-statics@^3.2.1: + version "3.3.1" + resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-3.3.1.tgz#101685d3aff3b23ea213163f6e8e12f4f111e19f" + integrity sha512-wbg3bpgA/ZqWrZuMOeJi8+SKMhr7X9TesL/rXMjTzh0p0JUBo3II8DHboYbuIXWRlttrUFxwcu/5kygrCw8fJw== + dependencies: + react-is "^16.7.0" + hosted-git-info@^2.1.4: version "2.8.5" resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.8.5.tgz#759cfcf2c4d156ade59b0b2dfabddc42a6b9c70c" @@ -3118,6 +3249,11 @@ husky@1.3.1: run-node "^1.0.0" slash "^2.0.0" +hyphenate-style-name@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/hyphenate-style-name/-/hyphenate-style-name-1.0.3.tgz#097bb7fa0b8f1a9cf0bd5c734cf95899981a9b48" + integrity sha512-EcuixamT82oplpoJ2XU4pDtKGWQ7b00CD9f1ug9IaQ3p1bkHMiKCZ9ut9QDI6qsa6cpUuB+A/I+zLtdNK4n2DQ== + iconv-lite@0.4.24, iconv-lite@^0.4.4: version "0.4.24" resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b" @@ -3319,6 +3455,11 @@ is-glob@^4.0.0: dependencies: is-extglob "^2.1.1" +is-in-browser@^1.0.2, is-in-browser@^1.1.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/is-in-browser/-/is-in-browser-1.1.3.tgz#56ff4db683a078c6082eb95dad7dc62e1d04f835" + integrity sha1-Vv9NtoOgeMYILrldrX3GLh0E+DU= + is-number-object@^1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/is-number-object/-/is-number-object-1.0.3.tgz#f265ab89a9f445034ef6aff15a8f00b00f551799" @@ -3988,6 +4129,75 @@ jsprim@^1.2.2: json-schema "0.2.3" verror "1.10.0" +jss-plugin-camel-case@^10.0.0: + version "10.0.3" + resolved "https://registry.yarnpkg.com/jss-plugin-camel-case/-/jss-plugin-camel-case-10.0.3.tgz#ce25f3cdb7f2b80724558361351fe6b644ca9e4f" + integrity sha512-rild/oFKFkmRP7AoiX9D6bdDAUfmJv8c7sEBvFoi+JP31dn2W8nw4txMKGnV1LJKlFkYprdZt1X99Uvztl1hug== + dependencies: + "@babel/runtime" "^7.3.1" + hyphenate-style-name "^1.0.3" + jss "^10.0.3" + +jss-plugin-default-unit@^10.0.0: + version "10.0.3" + resolved "https://registry.yarnpkg.com/jss-plugin-default-unit/-/jss-plugin-default-unit-10.0.3.tgz#c4b97b7b18c6cf9e9809e05b8525045decc298d3" + integrity sha512-n+XfVLPF9Qh7IOTdQ8M4oRpjpg6egjr/r0NNytubbCafMgCILJYIVrMTGgOTydH+uvak8onQY3f/F9hasPUx6g== + dependencies: + "@babel/runtime" "^7.3.1" + jss "^10.0.3" + +jss-plugin-global@^10.0.0: + version "10.0.3" + resolved "https://registry.yarnpkg.com/jss-plugin-global/-/jss-plugin-global-10.0.3.tgz#82bc95aa7f2c7171adc3ea47ec7717aca76a2389" + integrity sha512-kNotkAciJIXpIGYnmueaIifBne9rdq31O8Xq1nF7KMfKlskNRANTcEX5rVnsGKl2yubTMYfjKBFCeDgcQn6+gA== + dependencies: + "@babel/runtime" "^7.3.1" + jss "^10.0.3" + +jss-plugin-nested@^10.0.0: + version "10.0.3" + resolved "https://registry.yarnpkg.com/jss-plugin-nested/-/jss-plugin-nested-10.0.3.tgz#1ff39383154a710008788dbc9f73e6dec77b2852" + integrity sha512-OMucRs9YLvWlZ3Ew+VhdgNVMwSS2zZy/2vy+s/etvopnPUzDHgCnJwdY2Wx/SlhLGERJeKKufyih2seH+ui0iw== + dependencies: + "@babel/runtime" "^7.3.1" + jss "^10.0.3" + tiny-warning "^1.0.2" + +jss-plugin-props-sort@^10.0.0: + version "10.0.3" + resolved "https://registry.yarnpkg.com/jss-plugin-props-sort/-/jss-plugin-props-sort-10.0.3.tgz#8bc9f2a670fbd603f110486d28c526eb9efcbdc4" + integrity sha512-ufhvdCMnRcDa0tNHoZ12OcVNQQyE10yLMohxo/UIMarLV245rM6n9D19A12epjldRgyiS13SoSyLFCJEobprYg== + dependencies: + "@babel/runtime" "^7.3.1" + jss "^10.0.3" + +jss-plugin-rule-value-function@^10.0.0: + version "10.0.3" + resolved "https://registry.yarnpkg.com/jss-plugin-rule-value-function/-/jss-plugin-rule-value-function-10.0.3.tgz#1103240cf686bde5baee16cd7b15b0daf79d1103" + integrity sha512-RWwIT2UBAIwf3f6DQtt5gyjxHMRJoeO9TQku+ueR8dBMakqSSe8vFwQNfjXEoe0W+Tez5HZCTkZKNMulv3Z+9A== + dependencies: + "@babel/runtime" "^7.3.1" + jss "^10.0.3" + +jss-plugin-vendor-prefixer@^10.0.0: + version "10.0.3" + resolved "https://registry.yarnpkg.com/jss-plugin-vendor-prefixer/-/jss-plugin-vendor-prefixer-10.0.3.tgz#cfdf2ac1263e190ee9a0d874cdcc6092df452012" + integrity sha512-zVs6e5z4tFRK/fJ5kuTLzXlTFQbLeFTVwk7lTZiYNufmZwKT0kSmnOJDUukcSe7JLGSRztjWhnHB/6voP174gw== + dependencies: + "@babel/runtime" "^7.3.1" + css-vendor "^2.0.7" + jss "^10.0.3" + +jss@^10.0.0, jss@^10.0.3: + version "10.0.3" + resolved "https://registry.yarnpkg.com/jss/-/jss-10.0.3.tgz#5c160f96aa8ce8b9f851ee0b33505dcd37f490a4" + integrity sha512-AcDvFdOk16If9qvC9KN3oFXsrkHWM9+TaPMpVB9orm3z+nq1Xw3ofHyflRe/mkSucRZnaQtlhZs1hdP3DR9uRw== + dependencies: + "@babel/runtime" "^7.3.1" + csstype "^2.6.5" + is-in-browser "^1.1.3" + tiny-warning "^1.0.2" + kind-of@^3.0.2, kind-of@^3.0.3, kind-of@^3.2.0: version "3.2.2" resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-3.2.2.tgz#31ea21a734bab9bbb0f32466d893aea51e4a3c64" @@ -4542,6 +4752,11 @@ normalize-path@^2.1.1: dependencies: remove-trailing-separator "^1.0.1" +normalize-scroll-left@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/normalize-scroll-left/-/normalize-scroll-left-0.2.0.tgz#9445d74275f303cc661e113329aefa492f58114c" + integrity sha512-t5oCENZJl8TGusJKoCJm7+asaSsPuNmK6+iEjrZ5TyBj2f02brCRsd4c83hwtu+e5d4LCSBZ0uoDlMjBo+A8yA== + normalize.css@^8.0.1: version "8.0.1" resolved "https://registry.yarnpkg.com/normalize.css/-/normalize.css-8.0.1.tgz#9b98a208738b9cc2634caacbc42d131c97487bf3" @@ -4937,7 +5152,7 @@ pn@^1.1.0: resolved "https://registry.yarnpkg.com/pn/-/pn-1.1.0.tgz#e2f4cef0e219f463c179ab37463e4e1ecdccbafb" integrity sha512-2qHaIQr2VLRFoxe2nASzsV6ef4yOOH+Fi9FBOVH6cqeSgUnoyySPZkxzLuzd+RYOQTRpROA0ztTMqxROKSb/nA== -popper.js@^1.14.4, popper.js@^1.15.0: +popper.js@^1.14.1, popper.js@^1.14.4, popper.js@^1.15.0: version "1.16.0" resolved "https://registry.yarnpkg.com/popper.js/-/popper.js-1.16.0.tgz#2e1816bcbbaa518ea6c2e15a466f4cb9c6e2fbb3" integrity sha512-+G+EkOPoE5S/zChTpmBSSDYmhXJ5PsW8eMhH8cP/CQHMFPBG/kC9Y5IIw6qNYgdJ+/COf0ddY2li28iHaZRSjw== @@ -5129,6 +5344,11 @@ react-is@^16.6.1, react-is@^16.8.1, react-is@^16.8.4, react-is@^16.8.6, react-is resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.11.0.tgz#b85dfecd48ad1ce469ff558a882ca8e8313928fa" integrity sha512-gbBVYR2p8mnriqAwWx9LbuUrShnAuSCNnuPGyc7GJrMVQtPDAh8iLpv7FRuMPFb56KkaVZIYSz1PrjI9q0QPCw== +react-is@^16.7.0, react-is@^16.8.0: + version "16.12.0" + resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.12.0.tgz#2cc0fe0fba742d97fd527c42a13bec4eeb06241c" + integrity sha512-rPCkf/mWBtKc97aLL9/txD8DZdemK0vkA3JMLShjlJB3Pj3s+lpf1KaBzMfQrAmhMQB0n1cU/SUGgKKBCe837Q== + react-lifecycles-compat@^3.0.4: version "3.0.4" resolved "https://registry.yarnpkg.com/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz#4f1a273afdfc8f3488a8c516bfda78f872352362" @@ -5174,6 +5394,16 @@ react-transition-group@^2.9.0: prop-types "^15.6.2" react-lifecycles-compat "^3.0.4" +react-transition-group@^4.3.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/react-transition-group/-/react-transition-group-4.3.0.tgz#fea832e386cf8796c58b61874a3319704f5ce683" + integrity sha512-1qRV1ZuVSdxPlPf4O8t7inxUGpdyO5zG9IoNfJxSO0ImU2A1YWkEQvFPuIPZmMLkg5hYs7vv5mMOyfgSkvAwvw== + dependencies: + "@babel/runtime" "^7.5.5" + dom-helpers "^5.0.1" + loose-envify "^1.4.0" + prop-types "^15.6.2" + react@~16.8.4: version "16.8.6" resolved "https://registry.yarnpkg.com/react/-/react-16.8.6.tgz#ad6c3a9614fd3a4e9ef51117f54d888da01f2bbe" @@ -5944,6 +6174,11 @@ throat@^4.0.0: resolved "https://registry.yarnpkg.com/throat/-/throat-4.1.0.tgz#89037cbc92c56ab18926e6ba4cbb200e15672a6a" integrity sha1-iQN8vJLFarGJJua6TLsgDhVnKmo= +tiny-warning@^1.0.2: + version "1.0.3" + resolved "https://registry.yarnpkg.com/tiny-warning/-/tiny-warning-1.0.3.tgz#94a30db453df4c643d0fd566060d60a875d84754" + integrity sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA== + tmpl@1.0.x: version "1.0.4" resolved "https://registry.yarnpkg.com/tmpl/-/tmpl-1.0.4.tgz#23640dd7b42d00433911140820e5cf440e521dd1"