diff --git a/docs/focus-management.md b/docs/focus-management.md index 2b24b5ed39..738a511aa6 100644 --- a/docs/focus-management.md +++ b/docs/focus-management.md @@ -29,7 +29,7 @@ We move focus around by registering Atom commands. For example, in `GitTabView`: ``` - this.props.commandRegistry.add(this.refRoot, { + this.props.commands.add(this.refRoot, { 'tool-panel:unfocus': this.blur, 'core:focus-next': this.advanceFocus, 'core:focus-previous': this.retreatFocus, diff --git a/lib/atom/atom-text-editor.js b/lib/atom/atom-text-editor.js index 0cfea8bcee..6f1105dde1 100644 --- a/lib/atom/atom-text-editor.js +++ b/lib/atom/atom-text-editor.js @@ -36,6 +36,9 @@ export default class AtomTextEditor extends React.Component { didDestroySelection: PropTypes.func, hideEmptiness: PropTypes.bool, + preselect: PropTypes.bool, + className: PropTypes.string, + tabIndex: PropTypes.number, refModel: RefHolderPropType, @@ -49,6 +52,8 @@ export default class AtomTextEditor extends React.Component { didDestroySelection: () => {}, hideEmptiness: false, + preselect: false, + tabIndex: 0, } constructor(props) { @@ -77,6 +82,13 @@ export default class AtomTextEditor extends React.Component { this.refParent.map(element => { const editor = new TextEditor(modelProps); + editor.getElement().tabIndex = this.props.tabIndex; + if (this.props.className) { + editor.getElement().classList.add(this.props.className); + } + if (this.props.preselect) { + editor.selectAll(); + } element.appendChild(editor.getElement()); this.getRefModel().setter(editor); this.refElement.setter(editor.getElement()); diff --git a/lib/atom/panel.js b/lib/atom/panel.js index 9db1de1962..507744a0a5 100644 --- a/lib/atom/panel.js +++ b/lib/atom/panel.js @@ -22,14 +22,12 @@ export default class Panel extends React.Component { children: PropTypes.element.isRequired, options: PropTypes.object, onDidClosePanel: PropTypes.func, - visible: PropTypes.bool, itemHolder: RefHolderPropType, } static defaultProps = { options: {}, onDidClosePanel: panel => {}, - visible: true, } constructor(props) { @@ -46,21 +44,6 @@ export default class Panel extends React.Component { this.setupPanel(); } - shouldComponentUpdate(newProps) { - return this.props.visible !== newProps.visible; - } - - componentDidUpdate() { - if (this.didCloseItem) { - // eslint-disable-next-line no-console - console.error('Unexpected update in `Panel`: the contained panel has been destroyed'); - } - - if (this.panel) { - this.panel[this.props.visible ? 'show' : 'hide'](); - } - } - render() { return ReactDOM.createPortal( this.props.children, @@ -76,7 +59,7 @@ export default class Panel extends React.Component { const methodName = `add${location}Panel`; const item = createItem(this.domNode, this.props.itemHolder); - const options = {...this.props.options, visible: this.props.visible, item}; + const options = {...this.props.options, item}; this.panel = this.props.workspace[methodName](options); this.subscriptions.add( this.panel.onDidDestroy(() => { diff --git a/lib/autofocus.js b/lib/autofocus.js new file mode 100644 index 0000000000..0f9ee9027e --- /dev/null +++ b/lib/autofocus.js @@ -0,0 +1,75 @@ +/** + * When triggered, automatically focus the first element ref passed to this object. + * + * To unconditionally focus a single element: + * + * ``` + * class SomeComponent extends React.Component { + * constructor(props) { + * super(props); + * this.autofocus = new Autofocus(); + * } + * + * render() { + * return ( + *
+ * + * + *
+ * ); + * } + * + * componentDidMount() { + * this.autofocus.trigger(); + * } + * } + * ``` + * + * If multiple form elements are present, use `firstTarget` to create the ref instead. The rendered ref you assign the + * lowest numeric index will be focused on trigger: + * + * ``` + * class SomeComponent extends React.Component { + * constructor(props) { + * super(props); + * this.autofocus = new Autofocus(); + * } + * + * render() { + * return ( + *
+ * {this.props.someProp && } + * + * + *
+ * ); + * } + * + * componentDidMount() { + * this.autofocus.trigger(); + * } + * } + * ``` + * + */ +export default class AutoFocus { + constructor() { + this.index = Infinity; + this.captured = null; + } + + target = element => this.firstTarget(0)(element); + + firstTarget = index => element => { + if (index < this.index) { + this.index = index; + this.captured = element; + } + }; + + trigger() { + if (this.captured !== null) { + setTimeout(() => this.captured.focus(), 0); + } + } +} diff --git a/lib/controllers/commit-controller.js b/lib/controllers/commit-controller.js index 7183eeb31a..f8d64d6bef 100644 --- a/lib/controllers/commit-controller.js +++ b/lib/controllers/commit-controller.js @@ -24,7 +24,7 @@ export default class CommitController extends React.Component { static propTypes = { workspace: PropTypes.object.isRequired, grammars: PropTypes.object.isRequired, - commandRegistry: PropTypes.object.isRequired, + commands: PropTypes.object.isRequired, config: PropTypes.object.isRequired, tooltips: PropTypes.object.isRequired, @@ -104,7 +104,7 @@ export default class CommitController extends React.Component { prepareToCommit={this.props.prepareToCommit} commit={this.commit} abortMerge={this.props.abortMerge} - commandRegistry={this.props.commandRegistry} + commands={this.props.commands} maximumCharacterLimit={72} messageBuffer={this.commitMessageBuffer} isMerging={this.props.isMerging} diff --git a/lib/controllers/dialogs-controller.js b/lib/controllers/dialogs-controller.js new file mode 100644 index 0000000000..6c3a2f845f --- /dev/null +++ b/lib/controllers/dialogs-controller.js @@ -0,0 +1,154 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import InitDialog from '../views/init-dialog'; +import CloneDialog from '../views/clone-dialog'; +import CredentialDialog from '../views/credential-dialog'; +import OpenIssueishDialog from '../views/open-issueish-dialog'; +import OpenCommitDialog from '../views/open-commit-dialog'; + +const DIALOG_COMPONENTS = { + null: NullDialog, + init: InitDialog, + clone: CloneDialog, + credential: CredentialDialog, + issueish: OpenIssueishDialog, + commit: OpenCommitDialog, +}; + +export default class DialogsController extends React.Component { + static propTypes = { + // Model + request: PropTypes.shape({ + identifier: PropTypes.string.isRequired, + isProgressing: PropTypes.bool.isRequired, + }).isRequired, + + // Atom environment + workspace: PropTypes.object.isRequired, + commands: PropTypes.object.isRequired, + config: PropTypes.object.isRequired, + }; + + state = { + requestInProgress: null, + requestError: [null, null], + } + + render() { + const DialogComponent = DIALOG_COMPONENTS[this.props.request.identifier]; + return ; + } + + getCommonProps() { + const {request} = this.props; + const accept = request.isProgressing + ? async (...args) => { + this.setState({requestError: [null, null], requestInProgress: request}); + try { + const result = await request.accept(...args); + this.setState({requestInProgress: null}); + return result; + } catch (error) { + this.setState({requestError: [request, error], requestInProgress: null}); + return undefined; + } + } : (...args) => { + this.setState({requestError: [null, null]}); + try { + return request.accept(...args); + } catch (error) { + this.setState({requestError: [request, error]}); + return undefined; + } + }; + const wrapped = wrapDialogRequest(request, {accept}); + + return { + config: this.props.config, + commands: this.props.commands, + workspace: this.props.workspace, + inProgress: this.state.requestInProgress === request, + error: this.state.requestError[0] === request ? this.state.requestError[1] : null, + request: wrapped, + }; + } +} + +function NullDialog() { + return null; +} + +class DialogRequest { + constructor(identifier, params = {}) { + this.identifier = identifier; + this.params = params; + this.isProgressing = false; + this.accept = () => {}; + this.cancel = () => {}; + } + + onAccept(cb) { + this.accept = cb; + } + + onProgressingAccept(cb) { + this.isProgressing = true; + this.onAccept(cb); + } + + onCancel(cb) { + this.cancel = cb; + } + + getParams() { + return this.params; + } +} + +function wrapDialogRequest(original, {accept}) { + const dup = new DialogRequest(original.identifier, original.params); + dup.isProgressing = original.isProgressing; + dup.onAccept(accept); + dup.onCancel(original.cancel); + return dup; +} + +export const dialogRequests = { + null: { + identifier: 'null', + isProgressing: false, + params: {}, + accept: () => {}, + cancel: () => {}, + }, + + init({dirPath}) { + return new DialogRequest('init', {dirPath}); + }, + + clone(opts) { + return new DialogRequest('clone', { + sourceURL: '', + destPath: '', + ...opts, + }); + }, + + credential(opts) { + return new DialogRequest('credential', { + includeUsername: false, + includeRemember: false, + prompt: 'Please authenticate', + ...opts, + }); + }, + + issueish() { + return new DialogRequest('issueish'); + }, + + commit() { + return new DialogRequest('commit'); + }, +}; diff --git a/lib/controllers/editor-conflict-controller.js b/lib/controllers/editor-conflict-controller.js index 79f7309512..ad4c3c2848 100644 --- a/lib/controllers/editor-conflict-controller.js +++ b/lib/controllers/editor-conflict-controller.js @@ -15,7 +15,7 @@ import {autobind} from '../helpers'; export default class EditorConflictController extends React.Component { static propTypes = { editor: PropTypes.object.isRequired, - commandRegistry: PropTypes.object.isRequired, + commands: PropTypes.object.isRequired, resolutionProgress: PropTypes.object.isRequired, isRebase: PropTypes.bool.isRequired, refreshResolutionProgress: PropTypes.func.isRequired, @@ -56,7 +56,7 @@ export default class EditorConflictController extends React.Component { return (
{this.state.conflicts.size > 0 && ( - + diff --git a/lib/controllers/git-tab-controller.js b/lib/controllers/git-tab-controller.js index 647f8f9535..b75afe868d 100644 --- a/lib/controllers/git-tab-controller.js +++ b/lib/controllers/git-tab-controller.js @@ -35,7 +35,7 @@ export default class GitTabController extends React.Component { fetchInProgress: PropTypes.bool.isRequired, workspace: PropTypes.object.isRequired, - commandRegistry: PropTypes.object.isRequired, + commands: PropTypes.object.isRequired, grammars: PropTypes.object.isRequired, resolutionProgress: PropTypes.object.isRequired, notificationManager: PropTypes.object.isRequired, @@ -49,7 +49,7 @@ export default class GitTabController extends React.Component { undoLastDiscard: PropTypes.func.isRequired, discardWorkDirChangesForPaths: PropTypes.func.isRequired, openFiles: PropTypes.func.isRequired, - initializeRepo: PropTypes.func.isRequired, + openInitializeDialog: PropTypes.func.isRequired, controllerRef: RefHolderPropType, }; @@ -107,7 +107,7 @@ export default class GitTabController extends React.Component { resolutionProgress={this.props.resolutionProgress} workspace={this.props.workspace} - commandRegistry={this.props.commandRegistry} + commands={this.props.commands} grammars={this.props.grammars} tooltips={this.props.tooltips} notificationManager={this.props.notificationManager} @@ -115,7 +115,7 @@ export default class GitTabController extends React.Component { confirm={this.props.confirm} config={this.props.config} - initializeRepo={this.props.initializeRepo} + openInitializeDialog={this.props.openInitializeDialog} openFiles={this.props.openFiles} discardWorkDirChangesForPaths={this.props.discardWorkDirChangesForPaths} undoLastDiscard={this.props.undoLastDiscard} diff --git a/lib/controllers/recent-commits-controller.js b/lib/controllers/recent-commits-controller.js index adfd83d2d2..fd64391098 100644 --- a/lib/controllers/recent-commits-controller.js +++ b/lib/controllers/recent-commits-controller.js @@ -15,7 +15,7 @@ export default class RecentCommitsController extends React.Component { undoLastCommit: PropTypes.func.isRequired, workspace: PropTypes.object.isRequired, repository: PropTypes.object.isRequired, - commandRegistry: PropTypes.object.isRequired, + commands: PropTypes.object.isRequired, } static focus = RecentCommitsView.focus @@ -62,7 +62,7 @@ export default class RecentCommitsController extends React.Component { selectNextCommit={this.selectNextCommit} selectPreviousCommit={this.selectPreviousCommit} selectedCommitSha={this.state.selectedCommitSha} - commandRegistry={this.props.commandRegistry} + commands={this.props.commands} /> ); } diff --git a/lib/controllers/repository-conflict-controller.js b/lib/controllers/repository-conflict-controller.js index 68aabea1d6..e34b1a490a 100644 --- a/lib/controllers/repository-conflict-controller.js +++ b/lib/controllers/repository-conflict-controller.js @@ -19,7 +19,7 @@ const DEFAULT_REPO_DATA = { export default class RepositoryConflictController extends React.Component { static propTypes = { workspace: PropTypes.object.isRequired, - commandRegistry: PropTypes.object.isRequired, + commands: PropTypes.object.isRequired, config: PropTypes.object.isRequired, resolutionProgress: PropTypes.object.isRequired, repository: PropTypes.object.isRequired, @@ -78,7 +78,7 @@ export default class RepositoryConflictController extends React.Component { {conflictingEditors.map(editor => ( { + this.props.commands.onDidDispatch(event => { if (event.type && event.type.startsWith('github:') && event.detail && event.detail[0] && event.detail[0].contextCommand) { addEvent('context-menu-action', { @@ -132,19 +131,20 @@ export default class RootController extends React.Component { const devMode = global.atom && global.atom.inDevMode(); return ( - + {devMode && } - - - + this.openInitializeDialog()} /> + this.openCloneDialog()} /> + this.openIssueishDialog()} /> + this.openCommitDialog()} /> - {this.renderInitDialog()} - {this.renderCloneDialog()} - {this.renderCredentialDialog()} - {this.renderOpenIssueishDialog()} - {this.renderOpenCommitDialog()} - - ); - } - - renderInitDialog() { - if (!this.state.initDialogActive) { - return null; - } - - return ( - - - - ); - } - - renderCloneDialog() { - if (!this.state.cloneDialogActive) { - return null; - } - - return ( - - - - ); - } - - renderOpenIssueishDialog() { - if (!this.state.openIssueishDialogActive) { - return null; - } - - return ( - - - - ); - } - - renderOpenCommitDialog() { - if (!this.state.openCommitDialogActive) { - return null; - } - - return ( - - - - ); - } - - renderCredentialDialog() { - if (this.state.credentialDialogQuery === null) { - return null; - } - - return ( - - - + ); } @@ -286,7 +204,7 @@ export default class RootController extends React.Component { return ( ); } @@ -322,7 +240,7 @@ export default class RootController extends React.Component { @@ -566,120 +484,110 @@ export default class RootController extends React.Component { return this.props.loginModel.removeToken('https://api.github.com'); } - initializeRepo(initDialogPath) { - if (this.state.initDialogActive) { - return null; - } + closeDialog = () => new Promise(resolve => this.setState({dialogRequest: dialogRequests.null}, resolve)); - if (!initDialogPath) { - initDialogPath = this.props.repository.getWorkingDirectoryPath(); + openInitializeDialog = async dirPath => { + if (!dirPath) { + const activeEditor = this.props.workspace.getActiveTextEditor(); + if (activeEditor) { + const [projectPath] = this.props.project.relativizePath(activeEditor.getPath()); + if (projectPath) { + dirPath = projectPath; + } + } } - return new Promise(resolve => { - this.setState({initDialogActive: true, initDialogPath, initDialogResolve: resolve}); - }); - } + if (!dirPath) { + const directories = this.props.project.getDirectories(); + const withRepositories = await Promise.all( + directories.map(async d => [d, await this.props.project.repositoryForDirectory(d)]), + ); + const firstUninitialized = withRepositories.find(([d, r]) => !r); + if (firstUninitialized && firstUninitialized[0]) { + dirPath = firstUninitialized[0].getPath(); + } + } - toggleCommitPreviewItem = () => { - const workdir = this.props.repository.getWorkingDirectoryPath(); - return this.props.workspace.toggle(CommitPreviewItem.buildURI(workdir)); - } + if (!dirPath) { + dirPath = this.props.config.get('core.projectHome'); + } - showOpenIssueishDialog() { - this.setState({openIssueishDialogActive: true}); - } + const dialogRequest = dialogRequests.init({dirPath}); + dialogRequest.onProgressingAccept(async chosenPath => { + await this.props.initialize(chosenPath); + await this.closeDialog(); + }); + dialogRequest.onCancel(this.closeDialog); - showOpenCommitDialog = () => { - this.setState({openCommitDialogActive: true}); + return new Promise(resolve => this.setState({dialogRequest}, resolve)); } - showWaterfallDiagnostics() { - this.props.workspace.open(GitTimingsView.buildURI()); - } + openCloneDialog = opts => { + const dialogRequest = dialogRequests.clone(opts); + dialogRequest.onProgressingAccept(async (url, chosenPath) => { + await this.props.clone(url, chosenPath); + await this.closeDialog(); + }); + dialogRequest.onCancel(this.closeDialog); - showCacheDiagnostics() { - this.props.workspace.open(GitCacheView.buildURI()); + return new Promise(resolve => this.setState({dialogRequest}, resolve)); } - async acceptClone(remoteUrl, projectPath) { - this.setState({cloneDialogInProgress: true}); - try { - await this.props.cloneRepositoryForProjectPath(remoteUrl, projectPath); - addEvent('clone-repo', {package: 'github'}); - } catch (e) { - this.props.notificationManager.addError( - `Unable to clone ${remoteUrl}`, - {detail: e.stdErr, dismissable: true}, - ); - } finally { - this.setState({cloneDialogInProgress: false, cloneDialogActive: false}); - } - } + openCredentialsDialog = query => { + return new Promise((resolve, reject) => { + const dialogRequest = dialogRequests.credential(query); + dialogRequest.onProgressingAccept(async result => { + resolve(result); + await this.closeDialog(); + }); + dialogRequest.onCancel(async () => { + reject(); + await this.closeDialog(); + }); - cancelClone() { - this.setState({cloneDialogActive: false}); + this.setState({dialogRequest}); + }); } - async acceptInit(projectPath) { - try { - await this.props.createRepositoryForProjectPath(projectPath); - if (this.state.initDialogResolve) { this.state.initDialogResolve(projectPath); } - } catch (e) { - this.props.notificationManager.addError( - `Unable to initialize git repository in ${projectPath}`, - {detail: e.stdErr, dismissable: true}, - ); - } finally { - this.setState({initDialogActive: false, initDialogPath: null, initDialogResolve: null}); - } - } + openIssueishDialog = () => { + const dialogRequest = dialogRequests.issueish(); + dialogRequest.onProgressingAccept(async url => { + await openIssueishItem(url, { + workspace: this.props.workspace, + workdir: this.props.repository.getWorkingDirectoryPath(), + }); + await this.closeDialog(); + }); + dialogRequest.onCancel(this.closeDialog); - cancelInit() { - if (this.state.initDialogResolve) { this.state.initDialogResolve(false); } - this.setState({initDialogActive: false, initDialogPath: null, initDialogResolve: null}); + return new Promise(resolve => this.setState({dialogRequest}, resolve)); } - acceptOpenIssueish({repoOwner, repoName, issueishNumber}) { - const uri = IssueishDetailItem.buildURI({ - host: 'github.com', - owner: repoOwner, - repo: repoName, - number: issueishNumber, - }); - this.setState({openIssueishDialogActive: false}); - this.props.workspace.open(uri).then(() => { - addEvent('open-issueish-in-pane', {package: 'github', from: 'dialog'}); + openCommitDialog = () => { + const dialogRequest = dialogRequests.commit(); + dialogRequest.onProgressingAccept(async ref => { + await openCommitDetailItem(ref, { + workspace: this.props.workspace, + repository: this.props.repository, + }); + await this.closeDialog(); }); - } + dialogRequest.onCancel(this.closeDialog); - cancelOpenIssueish() { - this.setState({openIssueishDialogActive: false}); + return new Promise(resolve => this.setState({dialogRequest}, resolve)); } - isValidCommit = async ref => { - try { - await this.props.repository.getCommit(ref); - return true; - } catch (error) { - if (error instanceof GitError && error.code === 128) { - return false; - } else { - throw error; - } - } + toggleCommitPreviewItem = () => { + const workdir = this.props.repository.getWorkingDirectoryPath(); + return this.props.workspace.toggle(CommitPreviewItem.buildURI(workdir)); } - acceptOpenCommit = ({ref}) => { - const workdir = this.props.repository.getWorkingDirectoryPath(); - const uri = CommitDetailItem.buildURI(workdir, ref); - this.setState({openCommitDialogActive: false}); - this.props.workspace.open(uri).then(() => { - addEvent('open-commit-in-pane', {package: 'github', from: OpenCommitDialog.name}); - }); + showWaterfallDiagnostics() { + this.props.workspace.open(GitTimingsView.buildURI()); } - cancelOpenCommit = () => { - this.setState({openCommitDialogActive: false}); + showCacheDiagnostics() { + this.props.workspace.open(GitCacheView.buildURI()); } surfaceFromFileAtPath = (filePath, stagingStatus) => { @@ -705,10 +613,6 @@ export default class RootController extends React.Component { destroyEmptyFilePatchPaneItems(this.props.workspace); } - openCloneDialog() { - this.setState({cloneDialogActive: true}); - } - quietlySelectItem(filePath, stagingStatus) { const gitTab = this.gitTabTracker.getComponent(); return gitTab && gitTab.quietlySelectItem(filePath, stagingStatus); @@ -959,22 +863,6 @@ export default class RootController extends React.Component { }); }); } - - /* - * Display the credential entry dialog. Return a Promise that will resolve with the provided credentials on accept - * or reject on cancel. - */ - promptForCredentials(query) { - return new Promise((resolve, reject) => { - this.setState({ - credentialDialogQuery: { - ...query, - onSubmit: response => this.setState({credentialDialogQuery: null}, () => resolve(response)), - onCancel: () => this.setState({credentialDialogQuery: null}, reject), - }, - }); - }); - } } class TabTracker { diff --git a/lib/controllers/status-bar-tile-controller.js b/lib/controllers/status-bar-tile-controller.js index 07842aa630..80c0ca3994 100644 --- a/lib/controllers/status-bar-tile-controller.js +++ b/lib/controllers/status-bar-tile-controller.js @@ -16,7 +16,7 @@ export default class StatusBarTileController extends React.Component { static propTypes = { workspace: PropTypes.object.isRequired, notificationManager: PropTypes.object.isRequired, - commandRegistry: PropTypes.object.isRequired, + commands: PropTypes.object.isRequired, tooltips: PropTypes.object.isRequired, confirm: PropTypes.func.isRequired, repository: PropTypes.object.isRequired, @@ -111,7 +111,7 @@ export default class StatusBarTileController extends React.Component { return ( - + this.controller.promptForCredentials(query), + promptCallback: query => this.controller.openCredentialsDialog(query), pipelineManager: this.pipelineManager, }); @@ -273,7 +273,7 @@ export default class GithubPackage { ref={c => { this.controller = c; }} workspace={this.workspace} deserializers={this.deserializers} - commandRegistry={this.commandRegistry} + commands={this.commands} notificationManager={this.notificationManager} tooltips={this.tooltips} grammars={this.grammars} @@ -286,8 +286,8 @@ export default class GithubPackage { repository={this.getActiveRepository()} resolutionProgress={this.getActiveResolutionProgress()} statusBar={this.statusBar} - createRepositoryForProjectPath={this.createRepositoryForProjectPath} - cloneRepositoryForProjectPath={this.cloneRepositoryForProjectPath} + initialize={this.initialize} + clone={this.clone} switchboard={this.switchboard} startOpen={this.startOpen} startRevealed={this.startRevealed} @@ -425,7 +425,7 @@ export default class GithubPackage { } } - async createRepositoryForProjectPath(projectPath) { + initialize = async projectPath => { await fs.mkdirs(projectPath); const repository = this.contextPool.add(projectPath).getRepository(); @@ -436,10 +436,11 @@ export default class GithubPackage { this.project.addPath(projectPath); } + await this.refreshAtomGitRepository(projectPath); await this.scheduleActiveContextUpdate(); } - async cloneRepositoryForProjectPath(remoteUrl, projectPath) { + clone = async (remoteUrl, projectPath) => { const context = this.contextPool.getContext(projectPath); let repository; if (context.isPresent()) { @@ -585,11 +586,16 @@ export default class GithubPackage { this.setActiveContext(nextActiveContext); } - refreshAtomGitRepository(workdir) { - const atomGitRepo = this.project.getRepositories().find(repo => { - return repo && path.normalize(repo.getWorkingDirectory()) === workdir; - }); - return atomGitRepo ? atomGitRepo.refreshStatus() : Promise.resolve(); + async refreshAtomGitRepository(workdir) { + const directory = this.project.getDirectoryForProjectPath(workdir); + if (!directory) { + return; + } + + const atomGitRepo = await this.project.repositoryForDirectory(directory); + if (atomGitRepo) { + await atomGitRepo.refreshStatus(); + } } } diff --git a/lib/index.js b/lib/index.js index accea10ea8..814bca9773 100644 --- a/lib/index.js +++ b/lib/index.js @@ -6,7 +6,7 @@ const entry = { pack = new GithubPackage({ workspace: atom.workspace, project: atom.project, - commandRegistry: atom.commands, + commands: atom.commands, notificationManager: atom.notifications, tooltips: atom.tooltips, styles: atom.styles, diff --git a/lib/views/branch-menu-view.js b/lib/views/branch-menu-view.js index e81adf7254..85b0edcb0e 100644 --- a/lib/views/branch-menu-view.js +++ b/lib/views/branch-menu-view.js @@ -10,7 +10,7 @@ import {autobind} from '../helpers'; export default class BranchMenuView extends React.Component { static propTypes = { workspace: PropTypes.object.isRequired, - commandRegistry: PropTypes.object.isRequired, + commands: PropTypes.object.isRequired, notificationManager: PropTypes.object.isRequired, repository: PropTypes.object, branches: BranchSetPropType.isRequired, @@ -85,7 +85,7 @@ export default class BranchMenuView extends React.Component { return (
- + diff --git a/lib/views/clone-dialog.js b/lib/views/clone-dialog.js index 68d41854be..32bb97d8d1 100644 --- a/lib/views/clone-dialog.js +++ b/lib/views/clone-dialog.js @@ -1,172 +1,129 @@ import React from 'react'; import PropTypes from 'prop-types'; import {CompositeDisposable} from 'event-kit'; +import {TextBuffer} from 'atom'; import url from 'url'; import path from 'path'; -import Commands, {Command} from '../atom/commands'; -import {autobind} from '../helpers'; +import AtomTextEditor from '../atom/atom-text-editor'; +import AutoFocus from '../autofocus'; +import DialogView from './dialog-view'; export default class CloneDialog extends React.Component { static propTypes = { - config: PropTypes.object.isRequired, - commandRegistry: PropTypes.object.isRequired, + // Model + request: PropTypes.shape({ + getParams: PropTypes.func.isRequired, + accept: PropTypes.func.isRequired, + cancel: PropTypes.func.isRequired, + }).isRequired, inProgress: PropTypes.bool, - didAccept: PropTypes.func, - didCancel: PropTypes.func, - } + error: PropTypes.instanceOf(Error), - static defaultProps = { - inProgress: false, - didAccept: () => {}, - didCancel: () => {}, + // Atom environment + workspace: PropTypes.object.isRequired, + commands: PropTypes.object.isRequired, + config: PropTypes.object.isRequired, } - constructor(props, context) { - super(props, context); - autobind(this, 'clone', 'cancel', 'didChangeRemoteUrl', 'didChangeProjectPath', 'editorRefs'); + constructor(props) { + super(props); + + const params = this.props.request.getParams(); + this.sourceURL = new TextBuffer({text: params.sourceURL}); + this.destinationPath = new TextBuffer({ + text: params.destPath || this.props.config.get('core.projectHome'), + }); + this.destinationPathModified = false; this.state = { - cloneDisabled: false, + acceptEnabled: false, }; - this.projectHome = this.props.config.get('core.projectHome'); - this.subs = new CompositeDisposable(); - } - - componentDidMount() { - if (this.projectPathEditor) { - this.projectPathEditor.setText(this.props.config.get('core.projectHome')); - this.projectPathModified = false; - } + this.subs = new CompositeDisposable( + this.sourceURL.onDidChange(this.didChangeSourceUrl), + this.destinationPath.onDidChange(this.didChangeDestinationPath), + ); - if (this.remoteUrlElement) { - setTimeout(() => this.remoteUrlElement.focus()); - } + this.autofocus = new AutoFocus(); } render() { - if (!this.props.inProgress) { - return this.renderDialog(); - } else { - return this.renderSpinner(); - } - } - - renderDialog() { return ( -
- - - - -
- - -
-
- - -
-
+ + + + + + ); } - renderSpinner() { - return ( -
-
- - - Cloning {this.getRemoteUrl()} - -
-
- ); + componentDidMount() { + this.autofocus.trigger(); } - clone() { - if (this.getRemoteUrl().length === 0 || this.getProjectPath().length === 0) { - return; + accept = () => { + const sourceURL = this.sourceURL.getText(); + const destinationPath = this.destinationPath.getText(); + if (sourceURL === '' || destinationPath === '') { + return Promise.resolve(); } - this.props.didAccept(this.getRemoteUrl(), this.getProjectPath()); + return this.props.request.accept(sourceURL, destinationPath); } - cancel() { - this.props.didCancel(); - } - - didChangeRemoteUrl() { - if (!this.projectPathModified) { - const name = path.basename(url.parse(this.getRemoteUrl()).pathname, '.git') || ''; + didChangeSourceUrl = () => { + if (!this.destinationPathModified) { + const name = path.basename(url.parse(this.sourceURL.getText()).pathname, '.git') || ''; if (name.length > 0) { - const proposedPath = path.join(this.projectHome, name); - this.projectPathEditor.setText(proposedPath); - this.projectPathModified = false; + const proposedPath = path.join(this.props.config.get('core.projectHome'), name); + this.destinationPath.setText(proposedPath); + this.destinationPathModified = false; } } - this.setCloneEnablement(); - } - - didChangeProjectPath() { - this.projectPathModified = true; - this.setCloneEnablement(); - } - - editorRefs(baseName) { - const elementName = `${baseName}Element`; - const modelName = `${baseName}Editor`; - const subName = `${baseName}Subs`; - const changeMethodName = `didChange${baseName[0].toUpperCase()}${baseName.substring(1)}`; - - return element => { - if (!element) { - return; - } - - this[elementName] = element; - const editor = element.getModel(); - if (this[modelName] !== editor) { - this[modelName] = editor; - - if (this[subName]) { - this[subName].dispose(); - this.subs.remove(this[subName]); - } - - this[subName] = editor.onDidChange(this[changeMethodName]); - this.subs.add(this[subName]); - } - }; - } - - getProjectPath() { - return this.projectPathEditor ? this.projectPathEditor.getText() : ''; + this.setAcceptEnablement(); } - getRemoteUrl() { - return this.remoteUrlEditor ? this.remoteUrlEditor.getText() : ''; + didChangeDestinationPath = () => { + this.destinationPathModified = true; + this.setAcceptEnablement(); } - setCloneEnablement() { - const disabled = this.getRemoteUrl().length === 0 || this.getProjectPath().length === 0; - this.setState({cloneDisabled: disabled}); + setAcceptEnablement = () => { + const enabled = !this.sourceURL.isEmpty() && !this.destinationPath.isEmpty(); + if (enabled !== this.state.acceptEnabled) { + this.setState({acceptEnabled: enabled}); + } } } diff --git a/lib/views/co-author-form.js b/lib/views/co-author-form.js index 23d5f9f1ba..92efdd695c 100644 --- a/lib/views/co-author-form.js +++ b/lib/views/co-author-form.js @@ -7,7 +7,7 @@ import {autobind} from '../helpers'; export default class CoAuthorForm extends React.Component { static propTypes = { - commandRegistry: PropTypes.object.isRequired, + commands: PropTypes.object.isRequired, onSubmit: PropTypes.func, onCancel: PropTypes.func, name: PropTypes.string, @@ -36,7 +36,7 @@ export default class CoAuthorForm extends React.Component { render() { return (
- + diff --git a/lib/views/commit-view.js b/lib/views/commit-view.js index a12b50f0c9..0572af2f3a 100644 --- a/lib/views/commit-view.js +++ b/lib/views/commit-view.js @@ -40,7 +40,7 @@ export default class CommitView extends React.Component { workspace: PropTypes.object.isRequired, config: PropTypes.object.isRequired, tooltips: PropTypes.object.isRequired, - commandRegistry: PropTypes.object.isRequired, + commands: PropTypes.object.isRequired, lastCommit: PropTypes.object.isRequired, currentBranch: PropTypes.object.isRequired, @@ -147,14 +147,14 @@ export default class CommitView extends React.Component { return (
- + - + @@ -168,7 +168,7 @@ export default class CommitView extends React.Component { - +
@@ -358,7 +358,7 @@ export default class CommitView extends React.Component { return ( {}, - onCancel: () => {}, - } + constructor(props) { + super(props); - constructor(props, context) { - super(props, context); - autobind(this, 'confirm', 'cancel', 'onUsernameChange', 'onPasswordChange', 'onRememberChange', - 'focusFirstInput', 'toggleShowPassword'); + this.autofocus = new AutoFocus(); this.state = { username: '', @@ -34,101 +33,108 @@ export default class CredentialDialog extends React.Component { }; } - componentDidMount() { - setTimeout(this.focusFirstInput); - } - render() { + const request = this.props.request; + const params = request.getParams(); + return ( -
- - - - -
{this.props.prompt}
-
- {this.props.includeUsername ? ( - - ) : null} -
-
- {this.props.includeRemember ? ( - - ) : null} - - -
-
+ )} + + {params.includeRemember && ( + + )} + + ); } - confirm() { + componentDidMount() { + this.autofocus.trigger(); + } + + recaptureFocus = () => this.autofocus.trigger(); + + accept = () => { + if (!this.canSignIn()) { + return Promise.resolve(); + } + + const request = this.props.request; + const params = request.getParams(); + const payload = {password: this.state.password}; - if (this.props.includeUsername) { + if (params.includeUsername) { payload.username = this.state.username; } - if (this.props.includeRemember) { + if (params.includeRemember) { payload.remember = this.state.remember; } - this.props.onSubmit(payload); + return request.accept(payload); } - cancel() { - this.props.onCancel(); - } + didChangeUsername = e => this.setState({username: e.target.value}); - onUsernameChange(e) { - this.setState({username: e.target.value}); - } + didChangePassword = e => this.setState({password: e.target.value}); - onPasswordChange(e) { - this.setState({password: e.target.value}); - } + didChangeRemember = e => this.setState({remember: e.target.checked}); - onRememberChange(e) { - this.setState({remember: e.target.checked}); - } - - focusFirstInput() { - (this.usernameInput || this.passwordInput).focus(); - } + toggleShowPassword = () => this.setState({showPassword: !this.state.showPassword}); - toggleShowPassword() { - this.setState({showPassword: !this.state.showPassword}); + canSignIn() { + return !this.props.request.getParams().includeUsername || this.state.username.length > 0; } } diff --git a/lib/views/dialog-view.js b/lib/views/dialog-view.js new file mode 100644 index 0000000000..5c6fe497e9 --- /dev/null +++ b/lib/views/dialog-view.js @@ -0,0 +1,93 @@ +import React, {Fragment} from 'react'; +import PropTypes from 'prop-types'; +import cx from 'classnames'; + +import Commands, {Command} from '../atom/commands'; +import Panel from '../atom/panel'; + +export default class DialogView extends React.Component { + static propTypes = { + // Customization + prompt: PropTypes.string, + progressMessage: PropTypes.string, + acceptEnabled: PropTypes.bool, + acceptTabIndex: PropTypes.number, + acceptClassName: PropTypes.string, + acceptText: PropTypes.string, + cancelTabIndex: PropTypes.number, + + // Callbacks + accept: PropTypes.func.isRequired, + cancel: PropTypes.func.isRequired, + + // State + autofocus: PropTypes.shape({ + trigger: PropTypes.func.isRequired, + }), + inProgress: PropTypes.bool.isRequired, + error: PropTypes.instanceOf(Error), + + // Atom environment + workspace: PropTypes.object.isRequired, + commands: PropTypes.object.isRequired, + + // Form content + children: PropTypes.node.isRequired, + } + + static defaultProps = { + acceptTabIndex: 0, + acceptEnabled: true, + acceptText: 'Accept', + cancelTabIndex: 0, + } + + render() { + return ( + +
this.props.autofocus.trigger()}> + + + + + {this.props.prompt && ( +
{this.props.prompt}
+ )} +
+ {this.props.children} +
+
+
+ {this.props.progressMessage && this.props.inProgress && ( + + + {this.props.progressMessage} + + )} + {this.props.error && ( +
    +
  • {this.props.error.userMessage || this.props.error.message}
  • +
+ )} +
+
+ + +
+
+
+
+ ); + } +} diff --git a/lib/views/git-tab-view.js b/lib/views/git-tab-view.js index 20e54b9164..53c01408ce 100644 --- a/lib/views/git-tab-view.js +++ b/lib/views/git-tab-view.js @@ -40,7 +40,7 @@ export default class GitTabView extends React.Component { updateSelectedCoAuthors: PropTypes.func.isRequired, workspace: PropTypes.object.isRequired, - commandRegistry: PropTypes.object.isRequired, + commands: PropTypes.object.isRequired, grammars: PropTypes.object.isRequired, resolutionProgress: PropTypes.object.isRequired, notificationManager: PropTypes.object.isRequired, @@ -48,7 +48,7 @@ export default class GitTabView extends React.Component { project: PropTypes.object.isRequired, tooltips: PropTypes.object.isRequired, - initializeRepo: PropTypes.func.isRequired, + openInitializeDialog: PropTypes.func.isRequired, abortMerge: PropTypes.func.isRequired, commit: PropTypes.func.isRequired, undoLastCommit: PropTypes.func.isRequired, @@ -75,7 +75,7 @@ export default class GitTabView extends React.Component { componentDidMount() { this.props.refRoot.map(root => { return this.subscriptions.add( - this.props.commandRegistry.add(root, { + this.props.commands.add(root, { 'tool-panel:unfocus': this.blur, 'core:focus-next': this.advanceFocus, 'core:focus-previous': this.retreatFocus, @@ -143,7 +143,7 @@ export default class GitTabView extends React.Component { ref={this.props.refRoot.setter}> {}, - didCancel: () => {}, - } + constructor(props) { + super(props); - constructor(props, context) { - super(props, context); - autobind(this, 'init', 'cancel', 'editorRef', 'setInitEnablement'); + this.autofocus = new AutoFocus(); - this.state = { - initDisabled: false, - }; - - this.subs = new CompositeDisposable(); - } + this.destinationPath = new TextBuffer({ + text: this.props.request.getParams().dirPath, + }); - componentDidMount() { - if (this.projectPathEditor) { - this.projectPathEditor.setText(this.props.initPath || this.props.config.get('core.projectHome')); - this.projectPathModified = false; - } + this.sub = this.destinationPath.onDidChange(this.setAcceptEnablement); - if (this.projectPathElement) { - setTimeout(() => this.projectPathElement.focus()); - } + this.state = { + acceptEnabled: !this.destinationPath.isEmpty(), + }; } render() { return ( -
- - - - -
- -
-
- - -
-
+ + + + + ); } - init() { - if (this.getProjectPath().length === 0) { - return; - } - - this.props.didAccept(this.getProjectPath()); + componentDidMount() { + this.autofocus.trigger(); } - cancel() { - this.props.didCancel(); + componentWillUnmount() { + this.sub.dispose(); } - editorRef() { - return element => { - if (!element) { - return; - } - - this.projectPathElement = element; - const editor = element.getModel(); - if (this.projectPathEditor !== editor) { - this.projectPathEditor = editor; - - if (this.projectPathSubs) { - this.projectPathSubs.dispose(); - this.subs.remove(this.projectPathSubs); - } - - this.projectPathSubs = editor.onDidChange(this.setInitEnablement); - this.subs.add(this.projectPathSubs); - } - }; - } - - getProjectPath() { - return this.projectPathEditor ? this.projectPathEditor.getText() : ''; - } + accept = () => { + const destPath = this.destinationPath.getText(); + if (destPath.length === 0) { + return Promise.resolve(); + } - getRemoteUrl() { - return this.remoteUrlEditor ? this.remoteUrlEditor.getText() : ''; + return this.props.request.accept(destPath); } - setInitEnablement() { - this.setState({initDisabled: this.getProjectPath().length === 0}); + setAcceptEnablement = () => { + const enablement = !this.destinationPath.isEmpty(); + if (enablement !== this.state.acceptEnabled) { + this.setState({acceptEnabled: enablement}); + } } } diff --git a/lib/views/open-commit-dialog.js b/lib/views/open-commit-dialog.js index 6878e8bcae..e95106bd03 100644 --- a/lib/views/open-commit-dialog.js +++ b/lib/views/open-commit-dialog.js @@ -1,113 +1,106 @@ import React from 'react'; import PropTypes from 'prop-types'; -import {CompositeDisposable} from 'event-kit'; +import {TextBuffer} from 'atom'; -import Commands, {Command} from '../atom/commands'; +import AtomTextEditor from '../atom/atom-text-editor'; +import CommitDetailItem from '../items/commit-detail-item'; +import {GitError} from '../git-shell-out-strategy'; +import DialogView from './dialog-view'; +import AutoFocus from '../autofocus'; +import {addEvent} from '../reporter-proxy'; export default class OpenCommitDialog extends React.Component { static propTypes = { - commandRegistry: PropTypes.object.isRequired, - didAccept: PropTypes.func.isRequired, - didCancel: PropTypes.func.isRequired, - isValidEntry: PropTypes.func.isRequired, + // Model + request: PropTypes.shape({ + getParams: PropTypes.func.isRequired, + accept: PropTypes.func.isRequired, + cancel: PropTypes.func.isRequired, + }).isRequired, + inProgress: PropTypes.bool, + error: PropTypes.instanceOf(Error), + + // Atom environment + workspace: PropTypes.object.isRequired, + commands: PropTypes.object.isRequired, } - constructor(props, context) { - super(props, context); + constructor(props) { + super(props); + + this.ref = new TextBuffer(); + this.sub = this.ref.onDidChange(this.didChangeRef); this.state = { - error: null, + acceptEnabled: false, }; - this.subs = new CompositeDisposable(); - } - componentDidMount() { - setTimeout(() => this.commitRefElement.focus()); + this.autofocus = new AutoFocus(); } - componentWillUnmount() { - this.subs.dispose(); + render() { + return ( + + + + + + ); } - render() { - return this.renderDialog(); + componentDidMount() { + this.autofocus.trigger(); } - renderDialog() { - return ( -
- - - - -
- - {this.state.error && {this.state.error}} -
-
- - -
-
- ); + componentWillUnmount() { + this.sub.dispose(); } - accept = async () => { - const ref = this.getCommitRef(); - const valid = await this.props.isValidEntry(ref); - if (valid === true) { - this.props.didAccept({ref}); - } else { - this.setState({error: `There is no commit associated with "${ref}" in this repository`}); + accept = () => { + const ref = this.ref.getText(); + if (ref.length === 0) { + return Promise.resolve(); } + + return this.props.request.accept(ref); } - cancel = () => this.props.didCancel() - - editorRefs = baseName => { - const elementName = `${baseName}Element`; - const modelName = `${baseName}Editor`; - const subName = `${baseName}Subs`; - const changeMethodName = `didChange${baseName[0].toUpperCase()}${baseName.substring(1)}`; - - return element => { - if (!element) { - return; - } - - this[elementName] = element; - const editor = element.getModel(); - if (this[modelName] !== editor) { - this[modelName] = editor; - - /* istanbul ignore if */ - if (this[subName]) { - this[subName].dispose(); - this.subs.remove(this[subName]); - } - - this[subName] = editor.onDidChange(this[changeMethodName]); - this.subs.add(this[subName]); - } - }; + didChangeRef = () => { + const enabled = !this.ref.isEmpty(); + if (this.state.acceptEnabled !== enabled) { + this.setState({acceptEnabled: enabled}); + } } +} - didChangeCommitRef = () => new Promise(resolve => { - this.setState({error: null}, resolve); - }) +export async function openCommitDetailItem(ref, {workspace, repository}) { + try { + await repository.getCommit(ref); + } catch (error) { + if (error instanceof GitError && error.code === 128) { + error.userMessage = 'There is no commit associated with that reference.'; + } - getCommitRef() { - return this.commitRefEditor ? this.commitRefEditor.getText() : ''; + throw error; } + + const item = await workspace.open( + CommitDetailItem.buildURI(repository.getWorkingDirectoryPath(), ref), + {searchAllPanes: true}, + ); + addEvent('open-commit-in-pane', {package: 'github', from: OpenCommitDialog.name}); + return item; } diff --git a/lib/views/open-issueish-dialog.js b/lib/views/open-issueish-dialog.js index dbd21ff04d..39d6ac95c8 100644 --- a/lib/views/open-issueish-dialog.js +++ b/lib/views/open-issueish-dialog.js @@ -1,125 +1,88 @@ import React from 'react'; import PropTypes from 'prop-types'; -import {CompositeDisposable} from 'event-kit'; +import {TextBuffer} from 'atom'; -import Commands, {Command} from '../atom/commands'; -import {autobind} from '../helpers'; +import AtomTextEditor from '../atom/atom-text-editor'; +import IssueishDetailItem from '../items/issueish-detail-item'; +import AutoFocus from '../autofocus'; +import DialogView from './dialog-view'; +import {addEvent} from '../reporter-proxy'; -const ISSUEISH_URL_REGEX = /^(?:https?:\/\/)?github.com\/([^/]+)\/([^/]+)\/(?:issues|pull)\/(\d+)/; +const ISSUEISH_URL_REGEX = /^(?:https?:\/\/)?(github.com)\/([^/]+)\/([^/]+)\/(?:issues|pull)\/(\d+)/; export default class OpenIssueishDialog extends React.Component { static propTypes = { - commandRegistry: PropTypes.object.isRequired, - didAccept: PropTypes.func, - didCancel: PropTypes.func, + // Model + request: PropTypes.shape({ + getParams: PropTypes.func.isRequired, + accept: PropTypes.func.isRequired, + cancel: PropTypes.func.isRequired, + }).isRequired, + inProgress: PropTypes.bool, + error: PropTypes.instanceOf(Error), + + // Atom environment + workspace: PropTypes.object.isRequired, + commands: PropTypes.object.isRequired, } - static defaultProps = { - didAccept: () => {}, - didCancel: () => {}, - } + constructor(props) { + super(props); - constructor(props, context) { - super(props, context); - autobind(this, 'accept', 'cancel', 'editorRefs', 'didChangeIssueishUrl'); + this.url = new TextBuffer(); this.state = { - cloneDisabled: false, + acceptEnabled: false, }; - this.subs = new CompositeDisposable(); - } + this.sub = this.url.onDidChange(this.didChangeURL); - componentDidMount() { - if (this.issueishUrlElement) { - setTimeout(() => this.issueishUrlElement.focus()); - } + this.autofocus = new AutoFocus(); } render() { - return this.renderDialog(); - } - - renderDialog() { return ( -
- - - - -
- - {this.state.error && {this.state.error}} -
-
- - -
-
+ + + + + ); } - accept() { - if (this.getIssueishUrl().length === 0) { - return; - } - - const parsed = this.parseUrl(); - if (!parsed) { - this.setState({ - error: 'That is not a valid issue or pull request URL.', - }); - return; - } - const {repoOwner, repoName, issueishNumber} = parsed; - - this.props.didAccept({repoOwner, repoName, issueishNumber}); + componentDidMount() { + this.autofocus.trigger(); } - cancel() { - this.props.didCancel(); + componentWillUnmount() { + this.sub.dispose(); } - editorRefs(baseName) { - const elementName = `${baseName}Element`; - const modelName = `${baseName}Editor`; - const subName = `${baseName}Subs`; - const changeMethodName = `didChange${baseName[0].toUpperCase()}${baseName.substring(1)}`; - - return element => { - if (!element) { - return; - } - - this[elementName] = element; - const editor = element.getModel(); - if (this[modelName] !== editor) { - this[modelName] = editor; - - if (this[subName]) { - this[subName].dispose(); - this.subs.remove(this[subName]); - } - - this[subName] = editor.onDidChange(this[changeMethodName]); - this.subs.add(this[subName]); - } - }; - } + accept = () => { + const issueishURL = this.url.getText(); + if (issueishURL.length === 0) { + return Promise.resolve(); + } - didChangeIssueishUrl() { - this.setState({error: null}); + return this.props.request.accept(issueishURL); } parseUrl() { @@ -132,7 +95,22 @@ export default class OpenIssueishDialog extends React.Component { return {repoOwner, repoName, issueishNumber}; } - getIssueishUrl() { - return this.issueishUrlEditor ? this.issueishUrlEditor.getText() : ''; + didChangeURL = () => { + const enabled = !this.url.isEmpty(); + if (this.state.acceptEnabled !== enabled) { + this.setState({acceptEnabled: enabled}); + } + } +} + +export async function openIssueishItem(issueishURL, {workspace, workdir}) { + const matches = ISSUEISH_URL_REGEX.exec(issueishURL); + if (!matches) { + throw new Error('Not a valid issue or pull request URL'); } + const [, host, owner, repo, number] = matches; + const uri = IssueishDetailItem.buildURI({host, owner, repo, number, workdir}); + const item = await workspace.open(uri, {searchAllPanes: true}); + addEvent('open-issueish-in-pane', {package: 'github', from: 'dialog'}); + return item; } diff --git a/lib/views/recent-commits-view.js b/lib/views/recent-commits-view.js index aee9718f2c..0624bfb0db 100644 --- a/lib/views/recent-commits-view.js +++ b/lib/views/recent-commits-view.js @@ -118,7 +118,7 @@ export default class RecentCommitsView extends React.Component { selectedCommitSha: PropTypes.string.isRequired, // Atom environment - commandRegistry: PropTypes.object.isRequired, + commands: PropTypes.object.isRequired, // Action methods undoLastCommit: PropTypes.func.isRequired, @@ -160,7 +160,7 @@ export default class RecentCommitsView extends React.Component { render() { return (
- + diff --git a/lib/views/staging-view.js b/lib/views/staging-view.js index 474db1aaa0..fa73b425fc 100644 --- a/lib/views/staging-view.js +++ b/lib/views/staging-view.js @@ -56,7 +56,7 @@ export default class StagingView extends React.Component { workingDirectoryPath: PropTypes.string, resolutionProgress: PropTypes.object, hasUndoHistory: PropTypes.bool.isRequired, - commandRegistry: PropTypes.object.isRequired, + commands: PropTypes.object.isRequired, notificationManager: PropTypes.object.isRequired, workspace: PropTypes.object.isRequired, openFiles: PropTypes.func.isRequired, @@ -267,7 +267,7 @@ export default class StagingView extends React.Component { renderCommands() { return ( - + this.selectPrevious()} /> this.selectNext()} /> @@ -288,7 +288,7 @@ export default class StagingView extends React.Component { - + diff --git a/styles/dialog.less b/styles/dialog.less index ef9df6efbe..17c58524f8 100644 --- a/styles/dialog.less +++ b/styles/dialog.less @@ -3,14 +3,14 @@ @github-dialog-spacing: @component-padding / 2; .github-Dialog { - &Prompt { margin: @component-padding; text-align: center; font-size: 1.2em; + user-select: none; } - &Inputs { + &Form { display: flex; flex-direction: column; padding: @github-dialog-spacing; @@ -22,22 +22,48 @@ position: relative; line-height: 2; - &Button { - position: absolute; - background: transparent; - right: .3em; - bottom: 0; - border: none; - color: @text-color-subtle; - cursor: pointer; + &--horizontal { + display: flex; + flex-direction: row; + justify-content: flex-end; + align-items: center; + + input { + margin: 0 @component-padding; + } - &:hover { - color: @text-color-highlight; + input[type="text"] , input[type="password"] { + width: 85%; } } + + .github-AtomTextEditor-container { + margin-top: @github-dialog-spacing; + } + } + + &Info { + display: inline-block; + + li { + display: inline-block; + } + } + + &Footer { + display: flex; + flex-direction: row; + align-items: center; + } + + &Info { + flex-grow: 1; + margin: @github-dialog-spacing; + padding: @github-dialog-spacing; } &Buttons { + flex-grow: 0; display: flex; align-items: center; justify-content: flex-end; @@ -60,6 +86,26 @@ } } + &--insetButton { + position: absolute; + background: transparent; + right: 1em; + top: 0; + bottom: 0; + border: none; + color: @text-color-subtle; + cursor: pointer; + + &:hover { + color: @text-color-highlight; + } + + &:focus { + border: solid 1px @button-background-color-selected; + border-radius: 4px; + } + } + &Spinner { display: flex; align-items: center; @@ -73,5 +119,17 @@ atom-text-editor[mini].editor { margin: 0; + + &[readOnly] { + color: @text-color-subtle; + } + } + + // A trick to trap keyboard focus within this DOM element. + // In JavaScript, attach an event handler to the onTransitionEnd event and reassign focus to the initial element + // within the dialog. + &:not(:focus-within) { + padding-bottom: 1px; + transition: padding-bottom 0.01s; } } diff --git a/test/atom/atom-text-editor.test.js b/test/atom/atom-text-editor.test.js index ce2fc700cd..297260cb9b 100644 --- a/test/atom/atom-text-editor.test.js +++ b/test/atom/atom-text-editor.test.js @@ -85,6 +85,25 @@ describe('AtomTextEditor', function() { assert.strictEqual(editor.getText(), 'changed'); }); + it('mount with all text preselected on request', function() { + const buffer = new TextBuffer(); + buffer.setText('precreated\ntwo lines\n'); + + mount( + , + ); + + const editor = refModel.get(); + + assert.strictEqual(editor.getText(), 'precreated\ntwo lines\n'); + assert.strictEqual(editor.getSelectedText(), 'precreated\ntwo lines\n'); + }); + it('updates changed attributes on re-render', function() { const app = mount( + ); const wrapper = mount(app); - commandRegistry.dispatch(element, 'github:do-thing1'); + commands.dispatch(element, 'github:do-thing1'); assert.equal(callback1.callCount, 1); - commandRegistry.dispatch(element, 'github:do-thing2'); + commands.dispatch(element, 'github:do-thing2'); assert.equal(callback2.callCount, 1); await new Promise(resolve => { @@ -39,18 +39,18 @@ describe('Commands', function() { callback1.reset(); callback2.reset(); - commandRegistry.dispatch(element, 'github:do-thing1'); + commands.dispatch(element, 'github:do-thing1'); assert.equal(callback1.callCount, 1); - commandRegistry.dispatch(element, 'github:do-thing2'); + commands.dispatch(element, 'github:do-thing2'); assert.equal(callback2.callCount, 0); wrapper.unmount(); callback1.reset(); callback2.reset(); - commandRegistry.dispatch(element, 'github:do-thing1'); + commands.dispatch(element, 'github:do-thing1'); assert.equal(callback1.callCount, 0); - commandRegistry.dispatch(element, 'github:do-thing2'); + commands.dispatch(element, 'github:do-thing2'); assert.equal(callback2.callCount, 0); }); @@ -63,7 +63,7 @@ describe('Commands', function() { render() { return ( ; const wrapper = mount(app); - commandRegistry.dispatch(element, 'user:command1'); + commands.dispatch(element, 'user:command1'); assert.equal(callback1.callCount, 1); await new Promise(resolve => { @@ -83,10 +83,10 @@ describe('Commands', function() { }); callback1.reset(); - commandRegistry.dispatch(element, 'user:command1'); + commands.dispatch(element, 'user:command1'); assert.equal(callback1.callCount, 0); assert.equal(callback2.callCount, 0); - commandRegistry.dispatch(element, 'user:command2'); + commands.dispatch(element, 'user:command2'); assert.equal(callback1.callCount, 0); assert.equal(callback2.callCount, 1); }); @@ -95,17 +95,17 @@ describe('Commands', function() { const callback = sinon.spy(); const holder = new RefHolder(); mount( - + , ); const element = document.createElement('div'); - commandRegistry.dispatch(element, 'github:do-thing'); + commands.dispatch(element, 'github:do-thing'); assert.isFalse(callback.called); holder.setter(element); - commandRegistry.dispatch(element, 'github:do-thing'); + commands.dispatch(element, 'github:do-thing'); assert.isTrue(callback.called); }); }); diff --git a/test/atom/panel.test.js b/test/atom/panel.test.js index e8474f9e8a..afe0a8446c 100644 --- a/test/atom/panel.test.js +++ b/test/atom/panel.test.js @@ -51,7 +51,6 @@ describe('Panel', function() { assert.strictEqual(workspace.addLeftPanel.callCount, 1); const options = workspace.addLeftPanel.args[0][0]; assert.strictEqual(options.some, 'option'); - assert.isTrue(options.visible); assert.isDefined(options.item.getElement()); const panel = wrapper.instance().getPanel(); @@ -69,22 +68,4 @@ describe('Panel', function() { wrapper.instance().getPanel().destroy(); assert.strictEqual(onDidClosePanel.callCount, 1); }); - - describe('when updating the visible prop', function() { - it('shows or hides the panel', function() { - const wrapper = mount( - - - , - ); - - const panel = wrapper.instance().getPanel(); - - wrapper.setProps({visible: false}); - assert.strictEqual(panel.hide.callCount, 1); - - wrapper.setProps({visible: true}); - assert.strictEqual(panel.show.callCount, 1); - }); - }); }); diff --git a/test/autofocus.test.js b/test/autofocus.test.js new file mode 100644 index 0000000000..0a6626b20c --- /dev/null +++ b/test/autofocus.test.js @@ -0,0 +1,61 @@ +import AutoFocus from '../lib/autofocus'; + +describe('AutoFocus', function() { + let clock; + + beforeEach(function() { + clock = sinon.useFakeTimers(); + }); + + afterEach(function() { + clock.restore(); + }); + + it('captures an element and focuses it on trigger', function() { + const element = new MockElement(); + const autofocus = new AutoFocus(); + + autofocus.target(element); + autofocus.trigger(); + clock.next(); + + assert.isTrue(element.wasFocused()); + }); + + it('captures multiple elements by index and focuses the first on trigger', function() { + const element0 = new MockElement(); + const element1 = new MockElement(); + const element2 = new MockElement(); + + const autofocus = new AutoFocus(); + autofocus.firstTarget(0)(element0); + autofocus.firstTarget(1)(element1); + autofocus.firstTarget(2)(element2); + + autofocus.trigger(); + clock.next(); + + assert.isTrue(element0.wasFocused()); + assert.isFalse(element1.wasFocused()); + assert.isFalse(element2.wasFocused()); + }); + + it('does nothing on trigger when nothing is captured', function() { + const autofocus = new AutoFocus(); + autofocus.trigger(); + }); +}); + +class MockElement { + constructor() { + this.focused = false; + } + + focus() { + this.focused = true; + } + + wasFocused() { + return this.focused; + } +} diff --git a/test/controllers/commit-controller.test.js b/test/controllers/commit-controller.test.js index 2b7ba9f32a..e1121a27f6 100644 --- a/test/controllers/commit-controller.test.js +++ b/test/controllers/commit-controller.test.js @@ -13,13 +13,13 @@ import {cloneRepository, buildRepository, buildRepositoryWithPipeline, registerG import * as reporterProxy from '../../lib/reporter-proxy'; describe('CommitController', function() { - let atomEnvironment, workspace, commandRegistry, notificationManager, lastCommit, config, confirm, tooltips; + let atomEnvironment, workspace, commands, notificationManager, lastCommit, config, confirm, tooltips; let app; beforeEach(function() { atomEnvironment = global.buildAtomEnvironment(); workspace = atomEnvironment.workspace; - commandRegistry = atomEnvironment.commands; + commands = atomEnvironment.commands; notificationManager = atomEnvironment.notifications; config = atomEnvironment.config; tooltips = atomEnvironment.tooltips; @@ -35,7 +35,7 @@ describe('CommitController', function() { + ); + } + + it('renders nothing when a nullDialogRequest is provided', function() { + const wrapper = shallow(buildApp({ + request: dialogRequests.null, + })); + assert.isTrue(wrapper.exists('NullDialog')); + }); + + it('renders a chosen dialog when the appropriate DialogRequest is provided', function() { + const wrapper = shallow(buildApp({ + request: dialogRequests.init({dirPath: __dirname}), + })); + assert.isTrue(wrapper.exists('InitDialog')); + }); + + it('passes inProgress to the dialog when the accept callback is asynchronous', async function() { + let completeWork = () => {}; + const workPromise = new Promise(resolve => { + completeWork = resolve; + }); + const accept = sinon.stub().returns(workPromise); + + const request = dialogRequests.init({dirPath: '/not/home'}); + request.onProgressingAccept(accept); + + const wrapper = shallow(buildApp({request})); + assert.isFalse(wrapper.find('InitDialog').prop('inProgress')); + assert.isFalse(accept.called); + + const acceptPromise = wrapper.find('InitDialog').prop('request').accept('an-argument'); + assert.isTrue(wrapper.find('InitDialog').prop('inProgress')); + assert.isTrue(accept.calledWith('an-argument')); + + completeWork('some-result'); + assert.strictEqual(await acceptPromise, 'some-result'); + + wrapper.update(); + assert.isFalse(wrapper.find('InitDialog').prop('inProgress')); + }); + + describe('error handling', function() { + it('passes a raised error to the dialog when raised during a synchronous accept callback', function() { + const e = new Error('wtf'); + const request = dialogRequests.init({dirPath: __dirname}); + request.onAccept(() => { throw e; }); + + const wrapper = shallow(buildApp({request})); + wrapper.find('InitDialog').prop('request').accept(); + assert.strictEqual(wrapper.find('InitDialog').prop('error'), e); + }); + + it('passes a raised error to the dialog when raised during an asynchronous accept callback', async function() { + let breakWork = () => {}; + const workPromise = new Promise((_, reject) => { + breakWork = reject; + }); + const accept = sinon.stub().returns(workPromise); + + const request = dialogRequests.init({dirPath: '/not/home'}); + request.onProgressingAccept(accept); + + const wrapper = shallow(buildApp({request})); + const acceptPromise = wrapper.find('InitDialog').prop('request').accept('an-argument'); + assert.isTrue(wrapper.find('InitDialog').prop('inProgress')); + assert.isTrue(accept.calledWith('an-argument')); + + const e = new Error('ouch'); + breakWork(e); + await acceptPromise; + + wrapper.update(); + assert.strictEqual(wrapper.find('InitDialog').prop('error'), e); + }); + }); + + describe('specific dialogs', function() { + it('passes appropriate props to InitDialog', function() { + const accept = sinon.spy(); + const cancel = sinon.spy(); + const request = dialogRequests.init({dirPath: '/some/path'}); + request.onAccept(accept); + request.onCancel(cancel); + + const wrapper = shallow(buildApp({request})); + const dialog = wrapper.find('InitDialog'); + assert.strictEqual(dialog.prop('commands'), atomEnv.commands); + + const req = dialog.prop('request'); + + req.accept(); + assert.isTrue(accept.called); + + req.cancel(); + assert.isTrue(cancel.called); + + assert.strictEqual(req.getParams().dirPath, '/some/path'); + }); + + it('passes appropriate props to CloneDialog', function() { + const accept = sinon.spy(); + const cancel = sinon.spy(); + const request = dialogRequests.clone({sourceURL: 'git@github.com:atom/github.git', destPath: '/some/path'}); + request.onAccept(accept); + request.onCancel(cancel); + + const wrapper = shallow(buildApp({request})); + const dialog = wrapper.find('CloneDialog'); + assert.strictEqual(dialog.prop('config'), atomEnv.config); + assert.strictEqual(dialog.prop('commands'), atomEnv.commands); + + const req = dialog.prop('request'); + + req.accept(); + assert.isTrue(accept.called); + + req.cancel(); + assert.isTrue(cancel.called); + + assert.strictEqual(req.getParams().sourceURL, 'git@github.com:atom/github.git'); + assert.strictEqual(req.getParams().destPath, '/some/path'); + }); + + it('passes appropriate props to CredentialDialog', function() { + const accept = sinon.spy(); + const cancel = sinon.spy(); + const request = dialogRequests.credential({ + prompt: 'who the hell are you', + includeUsername: true, + includeRemember: true, + }); + request.onAccept(accept); + request.onCancel(cancel); + + const wrapper = shallow(buildApp({request})); + const dialog = wrapper.find('CredentialDialog'); + assert.strictEqual(dialog.prop('commands'), atomEnv.commands); + + const req = dialog.prop('request'); + + req.accept({username: 'me', password: 'whatever'}); + assert.isTrue(accept.calledWith({username: 'me', password: 'whatever'})); + + req.cancel(); + assert.isTrue(cancel.called); + + assert.strictEqual(req.getParams().prompt, 'who the hell are you'); + assert.isTrue(req.getParams().includeUsername); + assert.isTrue(req.getParams().includeRemember); + }); + + it('passes appropriate props to OpenIssueishDialog', function() { + const accept = sinon.spy(); + const cancel = sinon.spy(); + const request = dialogRequests.issueish(); + request.onAccept(accept); + request.onCancel(cancel); + + const wrapper = shallow(buildApp({request})); + const dialog = wrapper.find('OpenIssueishDialog'); + assert.strictEqual(dialog.prop('commands'), atomEnv.commands); + + const req = dialog.prop('request'); + + req.accept('https://github.com/atom/github/issue/123'); + assert.isTrue(accept.calledWith('https://github.com/atom/github/issue/123')); + + req.cancel(); + assert.isTrue(cancel.called); + }); + + it('passes appropriate props to OpenCommitDialog', function() { + const accept = sinon.spy(); + const cancel = sinon.spy(); + const request = dialogRequests.commit(); + request.onAccept(accept); + request.onCancel(cancel); + + const wrapper = shallow(buildApp({request})); + const dialog = wrapper.find('OpenCommitDialog'); + assert.strictEqual(dialog.prop('commands'), atomEnv.commands); + + const req = dialog.prop('request'); + + req.accept('abcd1234'); + assert.isTrue(accept.calledWith('abcd1234')); + + req.cancel(); + assert.isTrue(cancel.called); + }); + }); +}); diff --git a/test/controllers/editor-conflict-controller.test.js b/test/controllers/editor-conflict-controller.test.js index 872acb70f2..4de8ac326e 100644 --- a/test/controllers/editor-conflict-controller.test.js +++ b/test/controllers/editor-conflict-controller.test.js @@ -31,14 +31,14 @@ More of your changes Stuff at the very end.`; describe('EditorConflictController', function() { - let atomEnv, workspace, commandRegistry, app, wrapper, editor, editorView; + let atomEnv, workspace, commands, app, wrapper, editor, editorView; let resolutionProgress, refreshResolutionProgress; let fixtureFile; beforeEach(function() { atomEnv = global.buildAtomEnvironment(); workspace = atomEnv.workspace; - commandRegistry = atomEnv.commands; + commands = atomEnv.commands; refreshResolutionProgress = sinon.spy(); resolutionProgress = new ResolutionProgress(); @@ -61,7 +61,7 @@ describe('EditorConflictController', function() { app = ( e.setText(newMessage)); - commandRegistry.dispatch(workspaceElement, 'github:amend-last-commit'); + commands.dispatch(workspaceElement, 'github:amend-last-commit'); // verify that coAuthor was passed await assert.async.deepEqual( @@ -600,7 +600,7 @@ describe('GitTabController', function() { commitView.onSelectedCoAuthorsChanged([]); // amend again - commandRegistry.dispatch(workspaceElement, 'github:amend-last-commit'); + commands.dispatch(workspaceElement, 'github:amend-last-commit'); // verify that NO coAuthor was passed await assert.async.deepEqual( repository.commit.args[0][1], diff --git a/test/controllers/recent-commits-controller.test.js b/test/controllers/recent-commits-controller.test.js index 875c2fb3fa..949bf58ea8 100644 --- a/test/controllers/recent-commits-controller.test.js +++ b/test/controllers/recent-commits-controller.test.js @@ -22,7 +22,7 @@ describe('RecentCommitsController', function() { isLoading={false} undoLastCommit={() => { }} workspace={atomEnv.workspace} - commandRegistry={atomEnv.commands} + commands={atomEnv.commands} repository={repository} /> ); diff --git a/test/controllers/repository-conflict-controller.test.js b/test/controllers/repository-conflict-controller.test.js index af3963a05e..b1d7e84faa 100644 --- a/test/controllers/repository-conflict-controller.test.js +++ b/test/controllers/repository-conflict-controller.test.js @@ -14,9 +14,9 @@ describe('RepositoryConflictController', () => { atomEnv = global.buildAtomEnvironment(); atomEnv.config.set('github.graphicalConflictResolution', true); workspace = atomEnv.workspace; - const commandRegistry = atomEnv.commands; + const commands = atomEnv.commands; - app = ; + app = ; }); afterEach(() => atomEnv.destroy()); diff --git a/test/controllers/root-controller.test.js b/test/controllers/root-controller.test.js index 91295710de..ee56e91dc0 100644 --- a/test/controllers/root-controller.test.js +++ b/test/controllers/root-controller.test.js @@ -4,34 +4,35 @@ import fs from 'fs-extra'; import React from 'react'; import {shallow, mount} from 'enzyme'; import dedent from 'dedent-js'; +import temp from 'temp'; import {cloneRepository, buildRepository} from '../helpers'; import {multiFilePatchBuilder} from '../builder/patch'; -import {GitError} from '../../lib/git-shell-out-strategy'; import Repository from '../../lib/models/repository'; import WorkdirContextPool from '../../lib/models/workdir-context-pool'; +import ResolutionProgress from '../../lib/models/conflicts/resolution-progress'; +import RefHolder from '../../lib/models/ref-holder'; import GithubLoginModel from '../../lib/models/github-login-model'; import {InMemoryStrategy} from '../../lib/shared/keytar-strategy'; +import {dialogRequests} from '../../lib/controllers/dialogs-controller'; import GitTabItem from '../../lib/items/git-tab-item'; import GitHubTabItem from '../../lib/items/github-tab-item'; -import ResolutionProgress from '../../lib/models/conflicts/resolution-progress'; import IssueishDetailItem from '../../lib/items/issueish-detail-item'; import CommitPreviewItem from '../../lib/items/commit-preview-item'; import CommitDetailItem from '../../lib/items/commit-detail-item'; import * as reporterProxy from '../../lib/reporter-proxy'; import RootController from '../../lib/controllers/root-controller'; -import OpenCommitDialog from '../../lib/views/open-commit-dialog'; describe('RootController', function() { let atomEnv, app; - let workspace, commandRegistry, notificationManager, tooltips, config, confirm, deserializers, grammars, project; + let workspace, commands, notificationManager, tooltips, config, confirm, deserializers, grammars, project; let workdirContextPool; beforeEach(function() { atomEnv = global.buildAtomEnvironment(); workspace = atomEnv.workspace; - commandRegistry = atomEnv.commands; + commands = atomEnv.commands; deserializers = atomEnv.deserializers; grammars = atomEnv.grammars; notificationManager = atomEnv.notifications; @@ -49,7 +50,7 @@ describe('RootController', function() { app = ( {}} + clone={() => {}} + startOpen={false} startRevealed={false} - workdirContextPool={workdirContextPool} /> ); }); @@ -255,342 +261,297 @@ describe('RootController', function() { }); }); - describe('initializeRepo', function() { - let createRepositoryForProjectPath, resolveInit, rejectInit; + describe('initialize', function() { + let initialize; beforeEach(function() { - createRepositoryForProjectPath = sinon.stub().returns(new Promise((resolve, reject) => { - resolveInit = resolve; - rejectInit = reject; - })); + initialize = sinon.stub().resolves(); + app = React.cloneElement(app, {initialize}); }); - it('renders the modal init panel', function() { - app = React.cloneElement(app, {createRepositoryForProjectPath}); - const wrapper = shallow(app); + it('requests the init dialog with a command', async function() { + sinon.stub(config, 'get').returns(path.join('/home/me/src')); - wrapper.instance().initializeRepo(); - wrapper.update(); + const wrapper = shallow(app); - assert.lengthOf(wrapper.find('Panel').find({location: 'modal'}).find('InitDialog'), 1); + await wrapper.find('Command[command="github:initialize"]').prop('callback')(); + const req = wrapper.find('DialogsController').prop('request'); + assert.strictEqual(req.identifier, 'init'); + assert.strictEqual(req.getParams().dirPath, path.join('/home/me/src')); }); - it('triggers the init callback on accept', function() { - app = React.cloneElement(app, {createRepositoryForProjectPath}); - const wrapper = shallow(app); + it('defaults to the project directory containing the open file if there is one', async function() { + const noRepo0 = await new Promise((resolve, reject) => temp.mkdir({}, (err, p) => (err ? reject(err) : resolve(p)))); + const noRepo1 = await new Promise((resolve, reject) => temp.mkdir({}, (err, p) => (err ? reject(err) : resolve(p)))); + const filePath = path.join(noRepo1, 'file.txt'); + await fs.writeFile(filePath, 'stuff\n', {encoding: 'utf8'}); - wrapper.instance().initializeRepo(); - wrapper.update(); - const dialog = wrapper.find('InitDialog'); - dialog.prop('didAccept')('/a/path'); - resolveInit(); + project.setPaths([noRepo0, noRepo1]); + await workspace.open(filePath); - assert.isTrue(createRepositoryForProjectPath.calledWith('/a/path')); + const wrapper = shallow(app); + await wrapper.find('Command[command="github:initialize"]').prop('callback')(); + const req = wrapper.find('DialogsController').prop('request'); + assert.strictEqual(req.identifier, 'init'); + assert.strictEqual(req.getParams().dirPath, noRepo1); }); - it('dismisses the init callback on cancel', function() { - app = React.cloneElement(app, {createRepositoryForProjectPath}); - const wrapper = shallow(app); + it('defaults to the first project directory with no repository if one is present', async function() { + const withRepo = await cloneRepository(); + const noRepo0 = await new Promise((resolve, reject) => temp.mkdir({}, (err, p) => (err ? reject(err) : resolve(p)))); + const noRepo1 = await new Promise((resolve, reject) => temp.mkdir({}, (err, p) => (err ? reject(err) : resolve(p)))); - wrapper.instance().initializeRepo(); - wrapper.update(); - const dialog = wrapper.find('InitDialog'); - dialog.prop('didCancel')(); + project.setPaths([withRepo, noRepo0, noRepo1]); - wrapper.update(); - assert.isFalse(wrapper.find('InitDialog').exists()); + const wrapper = shallow(app); + await wrapper.find('Command[command="github:initialize"]').prop('callback')(); + const req = wrapper.find('DialogsController').prop('request'); + assert.strictEqual(req.identifier, 'init'); + assert.strictEqual(req.getParams().dirPath, noRepo0); }); - it('creates a notification if the init fails', async function() { - sinon.stub(notificationManager, 'addError'); - - app = React.cloneElement(app, {createRepositoryForProjectPath}); + it('requests the init dialog from the git tab', async function() { const wrapper = shallow(app); + const gitTabWrapper = wrapper + .find('PaneItem[className="github-Git-root"]') + .renderProp('children')({itemHolder: new RefHolder()}); - wrapper.instance().initializeRepo(); - wrapper.update(); - const dialog = wrapper.find('InitDialog'); - const acceptPromise = dialog.prop('didAccept')('/a/path'); - const err = new GitError('git init exited with status 1'); - err.stdErr = 'this is stderr'; - rejectInit(err); - await acceptPromise; + await gitTabWrapper.find('GitTabItem').prop('openInitializeDialog')(path.join('/some/workdir')); - wrapper.update(); - assert.isFalse(wrapper.find('InitDialog').exists()); - assert.isTrue(notificationManager.addError.calledWith( - 'Unable to initialize git repository in /a/path', - sinon.match({detail: sinon.match(/this is stderr/)}), - )); + const req = wrapper.find('DialogsController').prop('request'); + assert.strictEqual(req.identifier, 'init'); + assert.strictEqual(req.getParams().dirPath, path.join('/some/workdir')); }); - }); - - describe('github:open-commit', function() { - let workdirPath, wrapper, openCommitDetails, resolveOpenCommit, repository; - beforeEach(async function() { - openCommitDetails = sinon.stub(atomEnv.workspace, 'open').returns(new Promise(resolve => { - resolveOpenCommit = resolve; - })); + it('triggers the initialize callback on accept', async function() { + const wrapper = shallow(app); + await wrapper.find('Command[command="github:initialize"]').prop('callback')(); - workdirPath = await cloneRepository('multiple-commits'); - repository = await buildRepository(workdirPath); + const req0 = wrapper.find('DialogsController').prop('request'); + await req0.accept(path.join('/home/me/src')); + assert.isTrue(initialize.calledWith(path.join('/home/me/src'))); - app = React.cloneElement(app, {repository}); - wrapper = shallow(app); + const req1 = wrapper.find('DialogsController').prop('request'); + assert.strictEqual(req1, dialogRequests.null); }); - it('renders the modal open-commit panel', function() { - wrapper.instance().showOpenCommitDialog(); - wrapper.update(); + it('dismisses the dialog with its cancel callback', async function() { + const wrapper = shallow(app); + await wrapper.find('Command[command="github:initialize"]').prop('callback')(); - assert.lengthOf(wrapper.find('Panel').find({location: 'modal'}).find('OpenCommitDialog'), 1); - }); + const req0 = wrapper.find('DialogsController').prop('request'); + assert.notStrictEqual(req0, dialogRequests.null); + req0.cancel(); - it('triggers the open callback on accept and fires `open-commit-in-pane` event', async function() { - sinon.stub(reporterProxy, 'addEvent'); - wrapper.instance().showOpenCommitDialog(); - wrapper.update(); + const req1 = wrapper.update().find('DialogsController').prop('request'); + assert.strictEqual(req1, dialogRequests.null); + }); + }); - const dialog = wrapper.find('OpenCommitDialog'); - const ref = 'asdf1234'; + describe('openCloneDialog()', function() { + let clone; - const promise = dialog.prop('didAccept')({ref}); - resolveOpenCommit(); - await promise; + beforeEach(function() { + clone = sinon.stub().resolves(); + app = React.cloneElement(app, {clone}); + }); - const uri = CommitDetailItem.buildURI(workdirPath, ref); + it('requests the clone dialog with a command', function() { + sinon.stub(config, 'get').returns(path.join('/home/me/src')); - assert.isTrue(openCommitDetails.calledWith(uri)); + const wrapper = shallow(app); - await assert.isTrue(reporterProxy.addEvent.calledWith('open-commit-in-pane', {package: 'github', from: OpenCommitDialog.name})); + wrapper.find('Command[command="github:clone"]').prop('callback')(); + const req = wrapper.find('DialogsController').prop('request'); + assert.strictEqual(req.identifier, 'clone'); + assert.strictEqual(req.getParams().sourceURL, ''); + assert.strictEqual(req.getParams().destPath, ''); }); - it('dismisses the open-commit panel on cancel', function() { - wrapper.instance().showOpenCommitDialog(); - wrapper.update(); + it('triggers the clone callback on accept', async function() { + const wrapper = shallow(app); + wrapper.find('Command[command="github:clone"]').prop('callback')(); - const dialog = wrapper.find('OpenCommitDialog'); - dialog.prop('didCancel')(); + const req0 = wrapper.find('DialogsController').prop('request'); + await req0.accept('git@github.com:atom/atom.git', path.join('/home/me/src')); + assert.isTrue(clone.calledWith('git@github.com:atom/atom.git', path.join('/home/me/src'))); - wrapper.update(); - assert.lengthOf(wrapper.find('OpenCommitDialog'), 0); - assert.isFalse(openCommitDetails.called); - assert.isFalse(wrapper.state('openCommitDialogActive')); + const req1 = wrapper.find('DialogsController').prop('request'); + assert.strictEqual(req1, dialogRequests.null); }); - describe('isValidCommit', function() { - it('returns true if commit exists in repo, false if not', async function() { - assert.isTrue(await wrapper.instance().isValidCommit('HEAD')); - assert.isFalse(await wrapper.instance().isValidCommit('invalidCommitRef')); - }); + it('dismisses the dialog with its cancel callback', function() { + const wrapper = shallow(app); + wrapper.find('Command[command="github:clone"]').prop('callback')(); - it('re-throws exceptions encountered during validation check', async function() { - sinon.stub(repository, 'getCommit').throws(new Error('Oh shit')); - await assert.isRejected(wrapper.instance().isValidCommit('HEAD'), 'Oh shit'); - }); + const req0 = wrapper.find('DialogsController').prop('request'); + assert.notStrictEqual(req0, dialogRequests.null); + req0.cancel(); + + const req1 = wrapper.update().find('DialogsController').prop('request'); + assert.strictEqual(req1, dialogRequests.null); }); }); - describe('github:open-issue-or-pull-request', function() { - let workdirPath, wrapper, openIssueishDetails, resolveOpenIssueish; + describe('openIssueishDialog()', function() { + let repository, workdir; beforeEach(async function() { - openIssueishDetails = sinon.stub(atomEnv.workspace, 'open').returns(new Promise(resolve => { - resolveOpenIssueish = resolve; - })); - - workdirPath = await cloneRepository('multiple-commits'); - const repository = await buildRepository(workdirPath); - - app = React.cloneElement(app, {repository}); - wrapper = shallow(app); + workdir = await cloneRepository('multiple-commits'); + repository = await buildRepository(workdir); }); - it('renders the modal open-commit panel', function() { - wrapper.instance().showOpenIssueishDialog(); + it('renders the OpenIssueish dialog', function() { + const wrapper = shallow(app); + wrapper.find('Command[command="github:open-issue-or-pull-request"]').prop('callback')(); wrapper.update(); - assert.lengthOf(wrapper.find('Panel').find({location: 'modal'}).find('OpenIssueishDialog'), 1); + assert.strictEqual(wrapper.find('DialogsController').prop('request').identifier, 'issueish'); }); it('triggers the open callback on accept and fires `open-commit-in-pane` event', async function() { sinon.stub(reporterProxy, 'addEvent'); - wrapper.instance().showOpenIssueishDialog(); - wrapper.update(); - - const dialog = wrapper.find('OpenIssueishDialog'); - const repoOwner = 'owner'; - const repoName = 'name'; - const issueishNumber = 1234; - - const promise = dialog.prop('didAccept')({repoOwner, repoName, issueishNumber}); - resolveOpenIssueish(); - await promise; - - const uri = IssueishDetailItem.buildURI({ - host: 'github.com', - owner: repoOwner, - repo: repoName, - number: issueishNumber, - }); - - assert.isTrue(openIssueishDetails.calledWith(uri)); + sinon.stub(workspace, 'open').resolves(); + + const wrapper = shallow(React.cloneElement(app, {repository})); + wrapper.find('Command[command="github:open-issue-or-pull-request"]').prop('callback')(); + + const req0 = wrapper.find('DialogsController').prop('request'); + await req0.accept('https://github.com/atom/github/pull/123'); + + assert.isTrue(workspace.open.calledWith( + IssueishDetailItem.buildURI({ + host: 'github.com', + owner: 'atom', + repo: 'github', + number: 123, + workdir, + }), + {searchAllPanes: true}, + )); + assert.isTrue(reporterProxy.addEvent.calledWith( + 'open-issueish-in-pane', {package: 'github', from: 'dialog'}), + ); - await assert.isTrue(reporterProxy.addEvent.calledWith('open-issueish-in-pane', {package: 'github', from: 'dialog'})); + const req1 = wrapper.find('DialogsController').prop('request'); + assert.strictEqual(req1, dialogRequests.null); }); - it('dismisses the open-commit panel on cancel', function() { - wrapper.instance().showOpenIssueishDialog(); + it('dismisses the OpenIssueish dialog on cancel', function() { + const wrapper = shallow(app); + wrapper.find('Command[command="github:open-issue-or-pull-request"]').prop('callback')(); wrapper.update(); - const dialog = wrapper.find('OpenIssueishDialog'); - dialog.prop('didCancel')(); + const req0 = wrapper.find('DialogsController').prop('request'); + req0.cancel(); wrapper.update(); - assert.lengthOf(wrapper.find('OpenIssueishDialog'), 0); - assert.isFalse(openIssueishDetails.called); - assert.isFalse(wrapper.state('openIssueishDialogActive')); + const req1 = wrapper.find('DialogsController').prop('request'); + assert.strictEqual(req1, dialogRequests.null); }); }); - describe('github:clone', function() { - let wrapper, cloneRepositoryForProjectPath, resolveClone, rejectClone; - - beforeEach(function() { - cloneRepositoryForProjectPath = sinon.stub().returns(new Promise((resolve, reject) => { - resolveClone = resolve; - rejectClone = reject; - })); - - app = React.cloneElement(app, {cloneRepositoryForProjectPath}); - wrapper = shallow(app); - }); - - it('renders the modal clone panel', function() { - wrapper.instance().openCloneDialog(); - wrapper.update(); - - assert.lengthOf(wrapper.find('Panel').find({location: 'modal'}).find('CloneDialog'), 1); - }); + describe('openCommitDialog()', function() { + let workdirPath, repository; - it('triggers the clone callback on accept and fires `clone-repo` event', async function() { + beforeEach(async function() { sinon.stub(reporterProxy, 'addEvent'); - wrapper.instance().openCloneDialog(); - wrapper.update(); + sinon.stub(atomEnv.workspace, 'open').resolves('item'); - const dialog = wrapper.find('CloneDialog'); - const promise = dialog.prop('didAccept')('git@github.com:atom/github.git', '/home/me/github'); - resolveClone(); - await promise; + workdirPath = await cloneRepository('multiple-commits'); + repository = await buildRepository(workdirPath); + sinon.stub(repository, 'getCommit').callsFake(ref => { + return ref === 'abcd1234' ? Promise.resolve('ok') : Promise.reject(new Error('nah')); + }); - assert.isTrue(cloneRepositoryForProjectPath.calledWith('git@github.com:atom/github.git', '/home/me/github')); - await assert.isTrue(reporterProxy.addEvent.calledWith('clone-repo', {package: 'github'})); + app = React.cloneElement(app, {repository}); }); - it('marks the clone dialog as in progress during clone', async function() { - wrapper.instance().openCloneDialog(); - wrapper.update(); - - const dialog = wrapper.find('CloneDialog'); - assert.isFalse(dialog.prop('inProgress')); - - const acceptPromise = dialog.prop('didAccept')('git@github.com:atom/github.git', '/home/me/github'); - wrapper.update(); - - assert.isTrue(wrapper.find('CloneDialog').prop('inProgress')); - - resolveClone(); - await acceptPromise; + it('renders the OpenCommitDialog', function() { + const wrapper = shallow(app); - wrapper.update(); - assert.isFalse(wrapper.find('CloneDialog').exists()); + wrapper.find('Command[command="github:open-commit"]').prop('callback')(); + assert.strictEqual(wrapper.find('DialogsController').prop('request').identifier, 'commit'); }); - it('creates a notification if the clone fails and does not fire `clone-repo` event', async function() { - sinon.stub(notificationManager, 'addError'); - sinon.stub(reporterProxy, 'addEvent'); - - wrapper.instance().openCloneDialog(); - wrapper.update(); - - const dialog = wrapper.find('CloneDialog'); - assert.isFalse(dialog.prop('inProgress')); + it('triggers the open callback on accept', async function() { + const wrapper = shallow(app); + wrapper.find('Command[command="github:open-commit"]').prop('callback')(); - const acceptPromise = dialog.prop('didAccept')('git@github.com:nope/nope.git', '/home/me/github'); - const err = new GitError('git clone exited with status 1'); - err.stdErr = 'this is stderr'; - rejectClone(err); - await acceptPromise; + const req0 = wrapper.find('DialogsController').prop('request'); + await req0.accept('abcd1234'); - wrapper.update(); - assert.isFalse(wrapper.find('CloneDialog').exists()); - assert.isTrue(notificationManager.addError.calledWith( - 'Unable to clone git@github.com:nope/nope.git', - sinon.match({detail: sinon.match(/this is stderr/)}), + assert.isTrue(workspace.open.calledWith( + CommitDetailItem.buildURI(repository.getWorkingDirectoryPath(), 'abcd1234'), + {searchAllPanes: true}, )); - assert.isFalse(reporterProxy.addEvent.called); + assert.isTrue(reporterProxy.addEvent.called); + + const req1 = wrapper.find('DialogsController').prop('request'); + assert.strictEqual(req1, dialogRequests.null); }); - it('dismisses the clone panel on cancel', function() { - wrapper.instance().openCloneDialog(); - wrapper.update(); + it('dismisses the OpenCommitDialog on cancel', function() { + const wrapper = shallow(app); + wrapper.find('Command[command="github:open-commit"]').prop('callback')(); - const dialog = wrapper.find('CloneDialog'); - dialog.prop('didCancel')(); + const req0 = wrapper.find('DialogsController').prop('request'); + req0.cancel(); wrapper.update(); - assert.lengthOf(wrapper.find('CloneDialog'), 0); - assert.isFalse(cloneRepositoryForProjectPath.called); + const req1 = wrapper.find('DialogsController').prop('request'); + assert.strictEqual(req1, dialogRequests.null); }); }); - describe('promptForCredentials()', function() { - let wrapper; - - beforeEach(function() { - wrapper = shallow(app); - }); - + describe('openCredentialsDialog()', function() { it('renders the modal credentials dialog', function() { - wrapper.instance().promptForCredentials({ + const wrapper = shallow(app); + + wrapper.instance().openCredentialsDialog({ prompt: 'Password plz', includeUsername: true, }); wrapper.update(); - const dialog = wrapper.find('Panel').find({location: 'modal'}).find('CredentialDialog'); - assert.isTrue(dialog.exists()); - assert.equal(dialog.prop('prompt'), 'Password plz'); - assert.isTrue(dialog.prop('includeUsername')); + const req = wrapper.find('DialogsController').prop('request'); + assert.strictEqual(req.identifier, 'credential'); + assert.deepEqual(req.getParams(), { + prompt: 'Password plz', + includeUsername: true, + includeRemember: false, + }); }); it('resolves the promise with credentials on accept', async function() { - const credentialPromise = wrapper.instance().promptForCredentials({ + const wrapper = shallow(app); + const credentialPromise = wrapper.instance().openCredentialsDialog({ prompt: 'Speak "friend" and enter', includeUsername: false, }); - wrapper.update(); - wrapper.find('CredentialDialog').prop('onSubmit')({password: 'friend'}); + const req0 = wrapper.find('DialogsController').prop('request'); + await req0.accept({password: 'friend'}); assert.deepEqual(await credentialPromise, {password: 'friend'}); - wrapper.update(); - assert.isFalse(wrapper.find('CredentialDialog').exists()); + const req1 = wrapper.find('DialogsController').prop('request'); + assert.strictEqual(req1, dialogRequests.null); }); it('rejects the promise on cancel', async function() { - const credentialPromise = wrapper.instance().promptForCredentials({ + const wrapper = shallow(app); + const credentialPromise = wrapper.instance().openCredentialsDialog({ prompt: 'Enter the square root of 1244313452349528345', includeUsername: false, }); wrapper.update(); - wrapper.find('CredentialDialog').prop('onCancel')(); + const req0 = wrapper.find('DialogsController').prop('request'); + await req0.cancel(new Error('cancelled')); await assert.isRejected(credentialPromise); - wrapper.update(); - assert.isFalse(wrapper.find('CredentialDialog').exists()); + const req1 = wrapper.find('DialogsController').prop('request'); + assert.strictEqual(req1, dialogRequests.null); }); }); @@ -1245,16 +1206,6 @@ describe('RootController', function() { assert.strictEqual(item.getTitle(), 'owner/repo#123'); assert.lengthOf(wrapper.update().find('IssueishDetailItem'), 1); }); - - describe('acceptOpenIssueish', function() { - it('records an event', async function() { - const wrapper = mount(app); - sinon.stub(reporterProxy, 'addEvent'); - sinon.stub(workspace, 'open').returns(Promise.resolve()); - await wrapper.instance().acceptOpenIssueish({repoOwner: 'owner', repoName: 'repo', issueishNumber: 123}); - assert.isTrue(reporterProxy.addEvent.calledWith('open-issueish-in-pane', {package: 'github', from: 'dialog'})); - }); - }); }); describe('opening a CommitPreviewItem', function() { @@ -1297,7 +1248,7 @@ describe('RootController', function() { }); it('sends an event when a command is triggered via a context menu', function() { - commandRegistry.dispatch( + commands.dispatch( wrapper.find('CommitView').getDOMNode(), 'github:toggle-expanded-commit-message-editor', [{contextCommand: true}], @@ -1310,7 +1261,7 @@ describe('RootController', function() { }); it('does not send an event when a command is triggered in other ways', function() { - commandRegistry.dispatch( + commands.dispatch( wrapper.find('CommitView').getDOMNode(), 'github:toggle-expanded-commit-message-editor', ); @@ -1318,7 +1269,7 @@ describe('RootController', function() { }); it('does not send an event when a command not starting with github: is triggered via a context menu', function() { - commandRegistry.dispatch( + commands.dispatch( wrapper.find('CommitView').getDOMNode(), 'core:copy', [{contextCommand: true}], diff --git a/test/controllers/status-bar-tile-controller.test.js b/test/controllers/status-bar-tile-controller.test.js index f5d80c8daf..71c2694e4a 100644 --- a/test/controllers/status-bar-tile-controller.test.js +++ b/test/controllers/status-bar-tile-controller.test.js @@ -15,12 +15,12 @@ import GithubTileView from '../../lib/views/github-tile-view'; describe('StatusBarTileController', function() { let atomEnvironment; - let workspace, workspaceElement, commandRegistry, notificationManager, tooltips, confirm; + let workspace, workspaceElement, commands, notificationManager, tooltips, confirm; beforeEach(function() { atomEnvironment = global.buildAtomEnvironment(); workspace = atomEnvironment.workspace; - commandRegistry = atomEnvironment.commands; + commands = atomEnvironment.commands; notificationManager = atomEnvironment.notifications; tooltips = atomEnvironment.tooltips; confirm = sinon.stub(atomEnvironment, 'confirm'); @@ -36,7 +36,7 @@ describe('StatusBarTileController', function() { return ( {}, + openInitializeDialog: () => {}, abortMerge: () => {}, commit: () => {}, undoLastCommit: () => {}, diff --git a/test/github-package.test.js b/test/github-package.test.js index e7a97dc467..6e037704fa 100644 --- a/test/github-package.test.js +++ b/test/github-package.test.js @@ -8,7 +8,7 @@ import {fileExists, getTempDir} from '../lib/helpers'; import GithubPackage from '../lib/github-package'; describe('GithubPackage', function() { - let atomEnv, workspace, project, commandRegistry, notificationManager, grammars, config, keymaps; + let atomEnv, workspace, project, commands, notificationManager, grammars, config, keymaps; let confirm, tooltips, styles; let getLoadSettings, configDirPath, deserializers; let githubPackage, contextPool; @@ -19,7 +19,7 @@ describe('GithubPackage', function() { workspace = atomEnv.workspace; project = atomEnv.project; - commandRegistry = atomEnv.commands; + commands = atomEnv.commands; deserializers = atomEnv.deserializers; notificationManager = atomEnv.notifications; tooltips = atomEnv.tooltips; @@ -32,7 +32,7 @@ describe('GithubPackage', function() { configDirPath = path.join(__dirname, 'fixtures', 'atomenv-config'); githubPackage = new GithubPackage({ - workspace, project, commandRegistry, notificationManager, tooltips, styles, grammars, + workspace, project, commands, notificationManager, tooltips, styles, grammars, keymaps, config, deserializers, confirm, getLoadSettings, configDirPath, @@ -76,7 +76,7 @@ describe('GithubPackage', function() { const getLoadSettings1 = () => ({initialPaths}); githubPackage1 = new GithubPackage({ - workspace, project, commandRegistry, notificationManager, tooltips, styles, grammars, keymaps, + workspace, project, commands, notificationManager, tooltips, styles, grammars, keymaps, config, deserializers, confirm, getLoadSettings: getLoadSettings1, configDirPath, }); @@ -501,7 +501,7 @@ describe('GithubPackage', function() { project.setPaths([workdir1]); await workspace.open(path.join(workdir0, 'a.txt')); - commandRegistry.dispatch(atomEnv.views.getView(workspace), 'tree-view:toggle-focus'); + commands.dispatch(atomEnv.views.getView(workspace), 'tree-view:toggle-focus'); workspace.getLeftDock().activate(); await githubPackage.scheduleActiveContextUpdate(); @@ -682,7 +682,7 @@ describe('GithubPackage', function() { }); }); - describe('createRepositoryForProjectPath()', function() { + describe('initialize', function() { it('creates and sets a repository for the given project path', async function() { const nonRepositoryPath = await getTempDir(); project.setPaths([nonRepositoryPath]); @@ -693,7 +693,7 @@ describe('GithubPackage', function() { assert.isTrue(githubPackage.getActiveRepository().isEmpty()); assert.isFalse(githubPackage.getActiveRepository().isAbsent()); - await githubPackage.createRepositoryForProjectPath(nonRepositoryPath); + await githubPackage.initialize(nonRepositoryPath); assert.isTrue(githubPackage.getActiveRepository().isPresent()); assert.strictEqual( diff --git a/test/integration/helpers.js b/test/integration/helpers.js index 616d66f705..4bbce8fa8b 100644 --- a/test/integration/helpers.js +++ b/test/integration/helpers.js @@ -79,7 +79,7 @@ export async function setup(options = {}) { const githubPackage = new GithubPackage({ workspace: atomEnv.workspace, project: atomEnv.project, - commandRegistry: atomEnv.commands, + commands: atomEnv.commands, notificationManager: atomEnv.notifications, tooltips: atomEnv.tooltips, styles: atomEnv.styles, diff --git a/test/views/clone-dialog.test.js b/test/views/clone-dialog.test.js index fd3c9a1f39..42fe583154 100644 --- a/test/views/clone-dialog.test.js +++ b/test/views/clone-dialog.test.js @@ -1,123 +1,124 @@ import React from 'react'; -import {mount} from 'enzyme'; +import {shallow} from 'enzyme'; import path from 'path'; import CloneDialog from '../../lib/views/clone-dialog'; +import {dialogRequests} from '../../lib/controllers/dialogs-controller'; describe('CloneDialog', function() { - let atomEnv, config, commandRegistry; - let app, wrapper, didAccept, didCancel; + let atomEnv; beforeEach(function() { atomEnv = global.buildAtomEnvironment(); - config = atomEnv.config; - commandRegistry = atomEnv.commands; - sinon.stub(config, 'get').returns(path.join('home', 'me', 'codes')); - - didAccept = sinon.stub(); - didCancel = sinon.stub(); - - app = ( - - ); - wrapper = mount(app); }); afterEach(function() { atomEnv.destroy(); }); - const setTextIn = function(selector, text) { - wrapper.find(selector).getDOMNode().getModel().setText(text); - }; + function buildApp(overrides = {}) { + return ( + + ); + } describe('entering a remote URL', function() { it("updates the project path automatically if it hasn't been modified", function() { - setTextIn('.github-CloneUrl atom-text-editor', 'git@github.com:atom/github.git'); - - assert.equal(wrapper.instance().getProjectPath(), path.join('home', 'me', 'codes', 'github')); - }); + sinon.stub(atomEnv.config, 'get').returns(path.join('/home/me/src')); + const wrapper = shallow(buildApp()); - it('updates the project path for https URLs', function() { - setTextIn('.github-CloneUrl atom-text-editor', 'https://github.com/smashwilson/slack-emojinator.git'); + wrapper.find('.github-Clone-sourceURL').prop('buffer').setText('git@github.com:atom/github.git'); + wrapper.update(); + assert.strictEqual( + wrapper.find('.github-Clone-destinationPath').prop('buffer').getText(), + path.join('/home/me/src/github'), + ); - assert.equal(wrapper.instance().getProjectPath(), path.join('home', 'me', 'codes', 'slack-emojinator')); + wrapper.find('.github-Clone-sourceURL').prop('buffer') + .setText('https://github.com/smashwilson/slack-emojinator.git'); + wrapper.update(); + assert.strictEqual( + wrapper.find('.github-Clone-destinationPath').prop('buffer').getText(), + path.join('/home/me/src/slack-emojinator'), + ); }); it("doesn't update the project path if it has been modified", function() { - setTextIn('.github-ProjectPath atom-text-editor', path.join('somewhere', 'else')); - setTextIn('.github-CloneUrl atom-text-editor', 'git@github.com:atom/github.git'); - - assert.equal(wrapper.instance().getProjectPath(), path.join('somewhere', 'else')); - }); - - it('does update the project path if it was modified automatically', function() { - setTextIn('.github-CloneUrl atom-text-editor', 'git@github.com:atom/atom1.git'); - assert.equal(wrapper.instance().getProjectPath(), path.join('home', 'me', 'codes', 'atom1')); - - setTextIn('.github-CloneUrl atom-text-editor', 'git@github.com:atom/atom2.git'); - assert.equal(wrapper.instance().getProjectPath(), path.join('home', 'me', 'codes', 'atom2')); + const wrapper = shallow(buildApp()); + wrapper.find('.github-Clone-destinationPath').prop('buffer').setText(path.join('/somewhere/else')); + wrapper.find('.github-Clone-sourceURL').prop('buffer').setText('git@github.com:atom/github.git'); + assert.strictEqual( + wrapper.find('.github-Clone-destinationPath').prop('buffer').getText(), + path.join('/somewhere/else'), + ); }); }); describe('clone button enablement', function() { it('disables the clone button with no remote URL', function() { - setTextIn('.github-ProjectPath atom-text-editor', path.join('somewhere', 'else')); - setTextIn('.github-CloneUrl atom-text-editor', ''); + const wrapper = shallow(buildApp()); + wrapper.find('.github-Clone-destinationPath').prop('buffer').setText(path.join('/some/where')); + wrapper.find('.github-Clone-sourceURL').prop('buffer').setText(''); wrapper.update(); - assert.isTrue(wrapper.find('button.icon-repo-clone').prop('disabled')); + assert.isFalse(wrapper.find('DialogView').prop('acceptEnabled')); }); it('disables the clone button with no project path', function() { - setTextIn('.github-ProjectPath atom-text-editor', ''); - setTextIn('.github-CloneUrl atom-text-editor', 'git@github.com:atom/github.git'); + const wrapper = shallow(buildApp()); + wrapper.find('.github-Clone-destinationPath').prop('buffer').setText(''); + wrapper.find('.github-Clone-sourceURL').prop('buffer').setText('git@github.com:atom/github.git'); wrapper.update(); - assert.isTrue(wrapper.find('button.icon-repo-clone').prop('disabled')); + assert.isFalse(wrapper.find('DialogView').prop('acceptEnabled')); }); it('enables the clone button when both text boxes are populated', function() { - setTextIn('.github-ProjectPath atom-text-editor', path.join('somewhere', 'else')); - setTextIn('.github-CloneUrl atom-text-editor', 'git@github.com:atom/github.git'); + const wrapper = shallow(buildApp()); + wrapper.find('.github-Clone-destinationPath').prop('buffer').setText(path.join('/some/where')); + wrapper.find('.github-Clone-sourceURL').prop('buffer').setText('git@github.com:atom/github.git'); wrapper.update(); - assert.isFalse(wrapper.find('button.icon-repo-clone').prop('disabled')); + assert.isTrue(wrapper.find('DialogView').prop('acceptEnabled')); }); }); it('calls the acceptance callback', function() { - setTextIn('.github-ProjectPath atom-text-editor', '/somewhere/directory/'); - setTextIn('.github-CloneUrl atom-text-editor', 'git@github.com:atom/atom.git'); + const accept = sinon.spy(); + const request = dialogRequests.clone(); + request.onAccept(accept); + const wrapper = shallow(buildApp({request})); - wrapper.find('button.icon-repo-clone').simulate('click'); + wrapper.find('.github-Clone-destinationPath').prop('buffer').setText(path.join('/some/where')); + wrapper.find('.github-Clone-sourceURL').prop('buffer').setText('git@github.com:atom/github.git'); - assert.isTrue(didAccept.calledWith('git@github.com:atom/atom.git', '/somewhere/directory/')); + wrapper.find('DialogView').prop('accept')(); + assert.isTrue(accept.calledWith('git@github.com:atom/github.git', path.join('/some/where'))); }); it('calls the cancellation callback', function() { - wrapper.find('button.github-CancelButton').simulate('click'); - assert.isTrue(didCancel.called); + const cancel = sinon.spy(); + const request = dialogRequests.clone(); + request.onCancel(cancel); + const wrapper = shallow(buildApp({request})); + + wrapper.find('DialogView').prop('cancel')(); + assert.isTrue(cancel.called); }); describe('in progress', function() { - beforeEach(function() { - app = React.cloneElement(app, {inProgress: true}); - wrapper = mount(app); - }); - - it('conceals the text editors and buttons', function() { - assert.lengthOf(wrapper.find('atom-text-editor'), 0); - assert.lengthOf(wrapper.find('.btn'), 0); - }); + it('disables the text editors and buttons', function() { + const wrapper = shallow(buildApp({inProgress: true})); - it('displays the progress spinner', function() { - assert.lengthOf(wrapper.find('.loading'), 1); + assert.isTrue(wrapper.find('.github-Clone-sourceURL').prop('readOnly')); + assert.isTrue(wrapper.find('.github-Clone-destinationPath').prop('readOnly')); }); }); }); diff --git a/test/views/co-author-form.test.js b/test/views/co-author-form.test.js index b2980682ce..cf67e10990 100644 --- a/test/views/co-author-form.test.js +++ b/test/views/co-author-form.test.js @@ -16,7 +16,7 @@ describe('CoAuthorForm', function() { app = ( diff --git a/test/views/commit-view.test.js b/test/views/commit-view.test.js index fe239fceb0..fe3776b5f4 100644 --- a/test/views/commit-view.test.js +++ b/test/views/commit-view.test.js @@ -15,13 +15,13 @@ import StagingView from '../../lib/views/staging-view'; import * as reporterProxy from '../../lib/reporter-proxy'; describe('CommitView', function() { - let atomEnv, commandRegistry, tooltips, config, lastCommit; + let atomEnv, commands, tooltips, config, lastCommit; let messageBuffer; let app; beforeEach(function() { atomEnv = global.buildAtomEnvironment(); - commandRegistry = atomEnv.commands; + commands = atomEnv.commands; tooltips = atomEnv.tooltips; config = atomEnv.config; @@ -35,7 +35,7 @@ describe('CommitView', function() { app = ( - ); }); afterEach(function() { atomEnv.destroy(); }); - const setTextIn = function(selector, text) { - wrapper.find(selector).simulate('change', {target: {value: text}}); - }; + function buildApp(overrides = {}) { + return ( + + ); + } - describe('confirm', function() { + describe('accept', function() { it('reports the current username and password', function() { - wrapper = mount(app); - - setTextIn('.github-CredentialDialog-Username', 'someone'); - setTextIn('.github-CredentialDialog-Password', 'letmein'); + const accept = sinon.spy(); + const request = dialogRequests.credential({includeUsername: true}); + request.onAccept(accept); + const wrapper = shallow(buildApp({request})); - wrapper.find('.btn-primary').simulate('click'); + wrapper.find('.github-Credential-username').simulate('change', {target: {value: 'someone'}}); + wrapper.find('.github-Credential-password').simulate('change', {target: {value: 'letmein'}}); + wrapper.find('DialogView').prop('accept')(); - assert.deepEqual(didSubmit.firstCall.args[0], { + assert.isTrue(accept.calledWith({ username: 'someone', password: 'letmein', - }); + })); }); it('omits the username if includeUsername is false', function() { - wrapper = mount(React.cloneElement(app, {includeUsername: false})); + const accept = sinon.spy(); + const request = dialogRequests.credential({includeUsername: false}); + request.onAccept(accept); + const wrapper = shallow(buildApp({request})); - assert.isFalse(wrapper.find('.github-CredentialDialog-Username').exists()); - setTextIn('.github-CredentialDialog-Password', 'twowordsuppercase'); + assert.isFalse(wrapper.find('.github-Credential-username').exists()); + wrapper.find('.github-Credential-password').simulate('change', {target: {value: 'twowordsuppercase'}}); + wrapper.find('DialogView').prop('accept')(); - wrapper.find('.btn-primary').simulate('click'); - - assert.deepEqual(didSubmit.firstCall.args[0], { + assert.isTrue(accept.calledWith({ password: 'twowordsuppercase', - }); + })); }); it('includes a "remember me" checkbox', function() { - wrapper = mount(React.cloneElement(app, {includeRemember: true})); + const accept = sinon.spy(); + const request = dialogRequests.credential({includeUsername: true, includeRemember: true}); + request.onAccept(accept); + const wrapper = shallow(buildApp({request})); - const rememberBox = wrapper.find('.github-CredentialDialog-remember'); + const rememberBox = wrapper.find('.github-Credential-remember'); assert.isTrue(rememberBox.exists()); rememberBox.simulate('change', {target: {checked: true}}); - setTextIn('.github-CredentialDialog-Username', 'someone'); - setTextIn('.github-CredentialDialog-Password', 'letmein'); - - wrapper.find('.btn-primary').simulate('click'); + wrapper.find('.github-Credential-username').simulate('change', {target: {value: 'someone'}}); + wrapper.find('.github-Credential-password').simulate('change', {target: {value: 'letmein'}}); + wrapper.find('DialogView').prop('accept')(); - assert.deepEqual(didSubmit.firstCall.args[0], { + assert.isTrue(accept.calledWith({ username: 'someone', password: 'letmein', remember: true, - }); + })); }); it('omits the "remember me" checkbox', function() { - wrapper = mount(app); - assert.isFalse(wrapper.find('.github-CredentialDialog-remember').exists()); + const request = dialogRequests.credential({includeRemember: false}); + const wrapper = shallow(buildApp({request})); + assert.isFalse(wrapper.exists('.github-Credential-remember')); }); }); it('calls the cancel callback', function() { - wrapper = mount(app); - wrapper.find('.github-CancelButton').simulate('click'); - assert.isTrue(didCancel.called); + const cancel = sinon.spy(); + const request = dialogRequests.credential(); + request.onCancel(cancel); + const wrapper = shallow(buildApp({request})); + + wrapper.find('DialogView').prop('cancel')(); + assert.isTrue(cancel.called); }); describe('show password', function() { it('sets the passwords input type to "text" on the first click', function() { - wrapper = mount(app); - - wrapper.find('.github-DialogLabelButton').simulate('click'); + const wrapper = shallow(buildApp()); + wrapper.find('.github-Credential-visibility').simulate('click'); - const passwordInput = wrapper.find('.github-CredentialDialog-Password'); - assert.equal(passwordInput.prop('type'), 'text'); + const passwordInput = wrapper.find('.github-Credential-password'); + assert.strictEqual(passwordInput.prop('type'), 'text'); }); it('sets the passwords input type back to "password" on the second click', function() { - wrapper = mount(app); + const wrapper = shallow(buildApp()); + wrapper.find('.github-Credential-visibility').simulate('click').simulate('click'); + + const passwordInput = wrapper.find('.github-Credential-password'); + assert.strictEqual(passwordInput.prop('type'), 'password'); + }); + }); + + describe('sign in button enablement', function() { + it('is always enabled when includeUsername is false', function() { + const request = dialogRequests.credential({includeUsername: false}); + const wrapper = shallow(buildApp({request})); + + assert.isTrue(wrapper.find('DialogView').prop('acceptEnabled')); + }); + + it('is disabled if includeUsername is true and the username is empty', function() { + const request = dialogRequests.credential({includeUsername: true}); + const wrapper = shallow(buildApp({request})); + + assert.isFalse(wrapper.find('DialogView').prop('acceptEnabled')); + }); - wrapper.find('.github-DialogLabelButton').simulate('click').simulate('click'); + it('is enabled if includeUsername is true and the username is populated', function() { + const request = dialogRequests.credential({includeUsername: true}); + const wrapper = shallow(buildApp({request})); + wrapper.find('.github-Credential-username').simulate('change', {target: {value: 'nonempty'}}); - const passwordInput = wrapper.find('.github-CredentialDialog-Password'); - assert.equal(passwordInput.prop('type'), 'password'); + assert.isTrue(wrapper.find('DialogView').prop('acceptEnabled')); }); }); }); diff --git a/test/views/dialog-view.test.js b/test/views/dialog-view.test.js new file mode 100644 index 0000000000..e6ea829350 --- /dev/null +++ b/test/views/dialog-view.test.js @@ -0,0 +1,169 @@ +import React from 'react'; +import {shallow} from 'enzyme'; + +import DialogView from '../../lib/views/dialog-view'; +import AutoFocus from '../../lib/autofocus'; + +describe('DialogView', function() { + let atomEnv; + + beforeEach(function() { + atomEnv = global.buildAtomEnvironment(); + }); + + afterEach(function() { + atomEnv.destroy(); + }); + + function buildApp(overrides = {}) { + return ( + {}} + cancel={() => {}} + children={
} + {...overrides} + /> + ); + } + + it('includes common dialog elements', function() { + const wrapper = shallow(buildApp()); + + assert.isTrue(wrapper.exists('Panel[location="modal"]')); + assert.isTrue(wrapper.exists('.github-Dialog')); + assert.isTrue(wrapper.exists('Commands')); + assert.isTrue(wrapper.exists('main.github-DialogForm')); + assert.isTrue(wrapper.exists('footer.github-DialogFooter')); + assert.isTrue(wrapper.exists('.github-DialogInfo')); + assert.isTrue(wrapper.exists('.github-DialogButtons')); + assert.isTrue(wrapper.exists('.btn.github-Dialog-cancelButton')); + assert.isTrue(wrapper.exists('.btn.btn-primary')); + + assert.isFalse(wrapper.exists('header.github-DialogPrompt')); + assert.isFalse(wrapper.exists('.loading-spinner-small')); + assert.isFalse(wrapper.exists('.error-messages')); + }); + + describe('customization', function() { + it('includes a prompt banner if the prompt prop is provided', function() { + const wrapper = shallow(buildApp({prompt: 'some text'})); + assert.strictEqual(wrapper.find('header.github-DialogPrompt').text(), 'some text'); + }); + + it('inserts custom form contents', function() { + const wrapper = shallow(buildApp({ + children:
, + })); + assert.isTrue(wrapper.exists('main .custom')); + }); + + it('displays a spinner and custom message when in progress', function() { + const wrapper = shallow(buildApp({ + progressMessage: 'crunching numbers', + inProgress: true, + })); + assert.isTrue(wrapper.exists('.loading-spinner-small')); + assert.strictEqual(wrapper.find('.github-DialogProgress-message').text(), 'crunching numbers'); + }); + + it('omits the spinner when no progress message is provided', function() { + const wrapper = shallow(buildApp({ + inProgress: true, + })); + assert.isFalse(wrapper.exists('.loading-spinner-small')); + assert.isFalse(wrapper.exists('.github-DialogProgress-message')); + }); + + it('uses a custom classes and label for the accept button', function() { + const wrapper = shallow(buildApp({ + acceptClassName: 'icon icon-repo-clone', + acceptText: 'Engage', + })); + + const button = wrapper.find('.btn-primary'); + assert.isTrue(button.hasClass('icon')); + assert.isTrue(button.hasClass('icon-repo-clone')); + assert.strictEqual(button.text(), 'Engage'); + }); + }); + + describe('tabbing', function() { + it('defaults the tabIndex of the buttons to 0', function() { + const wrapper = shallow(buildApp()); + + assert.strictEqual(wrapper.find('.github-Dialog-cancelButton').prop('tabIndex'), 0); + assert.strictEqual(wrapper.find('.btn-primary').prop('tabIndex'), 0); + }); + + it('customizes the tabIndex of the standard buttons', function() { + const wrapper = shallow(buildApp({ + cancelTabIndex: 10, + acceptTabIndex: 20, + })); + + assert.strictEqual(wrapper.find('.github-Dialog-cancelButton').prop('tabIndex'), 10); + assert.strictEqual(wrapper.find('.btn-primary').prop('tabIndex'), 20); + }); + + it('recaptures focus after it leaves the dialog element', function() { + const autofocus = new AutoFocus(); + const wrapper = shallow(buildApp({autofocus})); + + sinon.spy(autofocus, 'trigger'); + wrapper.find('.github-Dialog').simulate('transitionEnd'); + assert.isTrue(autofocus.trigger.called); + }); + }); + + it('displays an error with a friendly explanation', function() { + const e = new Error('unfriendly'); + e.userMessage = 'friendly'; + + const wrapper = shallow(buildApp({error: e})); + assert.strictEqual(wrapper.find('.error-messages li').text(), 'friendly'); + }); + + it('falls back to presenting the regular error message', function() { + const e = new Error('other'); + + const wrapper = shallow(buildApp({error: e})); + assert.strictEqual(wrapper.find('.error-messages li').text(), 'other'); + }); + + it('calls the accept callback on core:confirm event', function() { + const accept = sinon.spy(); + const wrapper = shallow(buildApp({accept})); + + wrapper.find('Command[command="core:confirm"]').prop('callback')(); + assert.isTrue(accept.called); + }); + + it('calls the accept callback on an accept button click', function() { + const accept = sinon.spy(); + const wrapper = shallow(buildApp({accept})); + + wrapper.find('.btn-primary').simulate('click'); + assert.isTrue(accept.called); + }); + + it('calls the cancel callback on a core:cancel event', function() { + const cancel = sinon.spy(); + const wrapper = shallow(buildApp({cancel})); + + wrapper.find('Command[command="core:cancel"]').prop('callback')(); + assert.isTrue(cancel.called); + }); + + it('calls the cancel callback on a cancel button click', function() { + const cancel = sinon.spy(); + const wrapper = shallow(buildApp({cancel})); + + wrapper.find('.github-Dialog-cancelButton').simulate('click'); + assert.isTrue(cancel.called); + }); +}); diff --git a/test/views/init-dialog.test.js b/test/views/init-dialog.test.js index 974f7e5d8f..d8f11f9623 100644 --- a/test/views/init-dialog.test.js +++ b/test/views/init-dialog.test.js @@ -1,69 +1,70 @@ import React from 'react'; -import {mount} from 'enzyme'; +import {shallow} from 'enzyme'; import path from 'path'; import InitDialog from '../../lib/views/init-dialog'; +import {dialogRequests} from '../../lib/controllers/dialogs-controller'; describe('InitDialog', function() { - let atomEnv, config, commandRegistry; - let app, wrapper, didAccept, didCancel; + let atomEnv; beforeEach(function() { atomEnv = global.buildAtomEnvironment(); - config = atomEnv.config; - commandRegistry = atomEnv.commands; - sinon.stub(config, 'get').returns(path.join('home', 'me', 'codes')); - - didAccept = sinon.stub(); - didCancel = sinon.stub(); - - app = ( - - ); - wrapper = mount(app); }); afterEach(function() { atomEnv.destroy(); }); - const setTextIn = function(selector, text) { - wrapper.find(selector).getDOMNode().getModel().setText(text); - wrapper.update(); - }; + function buildApp(overrides = {}) { + return ( + + ); + } - it('defaults to your project home path', function() { - const text = wrapper.find('atom-text-editor').getDOMNode().getModel().getText(); - assert.equal(text, path.join('home', 'me', 'codes')); + it('defaults the destination directory to the dirPath parameter', function() { + const wrapper = shallow(buildApp({ + request: dialogRequests.init({dirPath: path.join('/home/me/src')}), + })); + assert.strictEqual(wrapper.find('AtomTextEditor').prop('buffer').getText(), path.join('/home/me/src')); }); - it('disables the initialize button with no project path', function() { - setTextIn('.github-ProjectPath atom-text-editor', ''); + it('disables the initialize button when the project path is empty', function() { + const wrapper = shallow(buildApp({})); - assert.isTrue(wrapper.find('button.icon-repo-create').prop('disabled')); + assert.isTrue(wrapper.find('DialogView').prop('acceptEnabled')); + wrapper.find('AtomTextEditor').prop('buffer').setText(''); + assert.isFalse(wrapper.find('DialogView').prop('acceptEnabled')); + wrapper.find('AtomTextEditor').prop('buffer').setText('/some/path'); + assert.isTrue(wrapper.find('DialogView').prop('acceptEnabled')); }); - it('enables the initialize button when the project path is populated', function() { - setTextIn('.github-ProjectPath atom-text-editor', path.join('somewhere', 'else')); + it('calls the request accept method with the chosen path', function() { + const accept = sinon.spy(); + const request = dialogRequests.init({dirPath: __dirname}); + request.onAccept(accept); - assert.isFalse(wrapper.find('button.icon-repo-create').prop('disabled')); - }); + const wrapper = shallow(buildApp({request})); + wrapper.find('AtomTextEditor').prop('buffer').setText('/some/path'); + wrapper.find('DialogView').prop('accept')(); - it('calls the acceptance callback', function() { - setTextIn('.github-ProjectPath atom-text-editor', '/somewhere/directory/'); + assert.isTrue(accept.calledWith('/some/path')); + }); - wrapper.find('button.icon-repo-create').simulate('click'); + it('calls the request cancel callback', function() { + const cancel = sinon.spy(); + const request = dialogRequests.init({dirPath: __dirname}); + request.onCancel(cancel); - assert.isTrue(didAccept.calledWith('/somewhere/directory/')); - }); + const wrapper = shallow(buildApp({request})); - it('calls the cancellation callback', function() { - wrapper.find('button.github-CancelButton').simulate('click'); - assert.isTrue(didCancel.called); + wrapper.find('DialogView').prop('cancel')(); + assert.isTrue(cancel.called); }); }); diff --git a/test/views/open-commit-dialog.test.js b/test/views/open-commit-dialog.test.js index c37f81ccd8..7a0b0d3cd2 100644 --- a/test/views/open-commit-dialog.test.js +++ b/test/views/open-commit-dialog.test.js @@ -1,97 +1,133 @@ import React from 'react'; -import {mount} from 'enzyme'; +import {shallow} from 'enzyme'; -import OpenCommitDialog from '../../lib/views/open-commit-dialog'; +import OpenCommitDialog, {openCommitDetailItem} from '../../lib/views/open-commit-dialog'; +import {dialogRequests} from '../../lib/controllers/dialogs-controller'; +import CommitDetailItem from '../../lib/items/commit-detail-item'; +import {GitError} from '../../lib/git-shell-out-strategy'; +import * as reporterProxy from '../../lib/reporter-proxy'; describe('OpenCommitDialog', function() { - let atomEnv, commandRegistry; - let app, wrapper, didAccept, didCancel, isValidEntry; + let atomEnv; beforeEach(function() { atomEnv = global.buildAtomEnvironment(); - commandRegistry = atomEnv.commands; - - didAccept = sinon.stub(); - didCancel = sinon.stub(); - isValidEntry = sinon.stub().returns(true); - - app = ( - - ); - wrapper = mount(app); }); afterEach(function() { atomEnv.destroy(); }); - const setTextIn = function(selector, text) { - wrapper.find(selector).getDOMNode().getModel().setText(text); - }; + function isValidRef(ref) { + return Promise.resolve(ref === 'abcd1234'); + } - describe('entering a commit sha', function() { - it("updates the commit ref automatically if it hasn't been modified", function() { - setTextIn('.github-CommitRef atom-text-editor', 'asdf1234'); + function buildApp(overrides = {}) { + const request = dialogRequests.commit(); - assert.equal(wrapper.instance().getCommitRef(), 'asdf1234'); + return ( + + ); + } + + describe('open button enablement', function() { + it('disables the open button with no commit ref', function() { + const wrapper = shallow(buildApp()); + + assert.isFalse(wrapper.find('DialogView').prop('acceptEnabled')); }); - it('does update the ref if it was modified automatically', function() { - setTextIn('.github-CommitRef atom-text-editor', 'asdf1234'); - assert.equal(wrapper.instance().getCommitRef(), 'asdf1234'); + it('enables the open button when commit sha box is populated', function() { + const wrapper = shallow(buildApp()); + wrapper.find('AtomTextEditor').prop('buffer').setText('abcd1234'); - setTextIn('.github-CommitRef atom-text-editor', 'zxcv5678'); - assert.equal(wrapper.instance().getCommitRef(), 'zxcv5678'); + assert.isTrue(wrapper.find('DialogView').prop('acceptEnabled')); }); }); - describe('open button enablement and error state', function() { - it('disables the open button with no commit ref', function() { - setTextIn('.github-CommitRef atom-text-editor', ''); - wrapper.update(); + it('calls the acceptance callback with the entered ref', function() { + const accept = sinon.spy(); + const request = dialogRequests.commit(); + request.onAccept(accept); - assert.isTrue(wrapper.find('button.icon-commit').prop('disabled')); - assert.isFalse(wrapper.find('.error').exists()); - }); + const wrapper = shallow(buildApp({request})); + wrapper.find('AtomTextEditor').prop('buffer').setText('abcd1234'); + wrapper.find('DialogView').prop('accept')(); - it('disables the open button when the commit does not exist in repo', async function() { - isValidEntry.returns(false); - const ref = 'abcd1234'; - setTextIn('.github-CommitRef atom-text-editor', ref); - wrapper.find('button.icon-commit').simulate('click'); + assert.isTrue(accept.calledWith('abcd1234')); + wrapper.unmount(); + }); - await assert.async.strictEqual(wrapper.update().find('.error').text(), `There is no commit associated with "${ref}" in this repository`); - assert.isTrue(wrapper.find('button.icon-commit').prop('disabled')); - }); + it('calls the cancellation callback', function() { + const cancel = sinon.spy(); + const request = dialogRequests.commit(); + request.onCancel(cancel); - it('enables the open button when commit sha box is populated with a valid sha', function() { - setTextIn('.github-CommitRef atom-text-editor', 'abcd1234'); - wrapper.update(); + const wrapper = shallow(buildApp({request})); - assert.isFalse(wrapper.find('button.icon-commit').prop('disabled')); - assert.isFalse(wrapper.find('.error').exists()); - }); + wrapper.find('DialogView').prop('cancel')(); + assert.isTrue(cancel.called); }); - it('calls the acceptance callback after validation', async function() { - isValidEntry.returns(true); - const ref = 'abcd1234'; - setTextIn('.github-CommitRef atom-text-editor', ref); + describe('openCommitDetailItem()', function() { + let repository; + + beforeEach(function() { + sinon.stub(atomEnv.workspace, 'open').resolves('item'); + sinon.stub(reporterProxy, 'addEvent'); + + repository = { + getWorkingDirectoryPath() { + return __dirname; + }, + getCommit(ref) { + if (ref === 'abcd1234') { + return Promise.resolve('ok'); + } + + if (ref === 'bad') { + const e = new GitError('bad ref'); + e.code = 128; + return Promise.reject(e); + } + + return Promise.reject(new GitError('other error')); + }, + }; + }); - wrapper.find('button.icon-commit').simulate('click'); + it('opens a CommitDetailItem with the chosen valid ref and records an event', async function() { + assert.strictEqual(await openCommitDetailItem('abcd1234', {workspace: atomEnv.workspace, repository}), 'item'); + assert.isTrue(atomEnv.workspace.open.calledWith( + CommitDetailItem.buildURI(__dirname, 'abcd1234'), + {searchAllPanes: true}, + )); + assert.isTrue(reporterProxy.addEvent.calledWith( + 'open-commit-in-pane', + {package: 'github', from: OpenCommitDialog.name}, + )); + }); - await assert.async.isTrue(didAccept.calledWith({ref})); - wrapper.unmount(); - }); + it('raises a friendly error if the ref is invalid', async function() { + const e = await openCommitDetailItem('bad', {workspace: atomEnv.workspace, repository}).then( + () => { throw new Error('unexpected success'); }, + error => error, + ); + assert.strictEqual(e.userMessage, 'There is no commit associated with that reference.'); + }); - it('calls the cancellation callback', function() { - wrapper.find('button.github-CancelButton').simulate('click'); - assert.isTrue(didCancel.called); - wrapper.unmount(); + it('passes other errors through directly', async function() { + const e = await openCommitDetailItem('nope', {workspace: atomEnv.workspace, repository}).then( + () => { throw new Error('unexpected success'); }, + error => error, + ); + assert.isUndefined(e.userMessage); + assert.strictEqual(e.message, 'other error'); + }); }); }); diff --git a/test/views/open-issueish-dialog.test.js b/test/views/open-issueish-dialog.test.js index b4caf29fe5..c338475f9f 100644 --- a/test/views/open-issueish-dialog.test.js +++ b/test/views/open-issueish-dialog.test.js @@ -1,95 +1,109 @@ import React from 'react'; -import {mount} from 'enzyme'; +import {shallow} from 'enzyme'; -import OpenIssueishDialog from '../../lib/views/open-issueish-dialog'; +import OpenIssueishDialog, {openIssueishItem} from '../../lib/views/open-issueish-dialog'; +import IssueishDetailItem from '../../lib/items/issueish-detail-item'; +import {dialogRequests} from '../../lib/controllers/dialogs-controller'; +import * as reporterProxy from '../../lib/reporter-proxy'; describe('OpenIssueishDialog', function() { - let atomEnv, commandRegistry; - let app, wrapper, didAccept, didCancel; + let atomEnv; beforeEach(function() { atomEnv = global.buildAtomEnvironment(); - commandRegistry = atomEnv.commands; - - didAccept = sinon.stub(); - didCancel = sinon.stub(); - - app = ( - - ); - wrapper = mount(app); + sinon.stub(reporterProxy, 'addEvent').returns(); }); afterEach(function() { atomEnv.destroy(); }); - const setTextIn = function(selector, text) { - wrapper.find(selector).getDOMNode().getModel().setText(text); - }; - - describe('entering a issueish url', function() { - it("updates the issue url automatically if it hasn't been modified", function() { - setTextIn('.github-IssueishUrl atom-text-editor', 'https://github.com/atom/github/pull/1807'); + function buildApp(overrides = {}) { + const request = dialogRequests.issueish(); - assert.equal(wrapper.instance().getIssueishUrl(), 'https://github.com/atom/github/pull/1807'); - }); - - it('does update the issue url if it was modified automatically', function() { - setTextIn('.github-IssueishUrl atom-text-editor', 'https://github.com/atom/github/pull/1807'); - assert.equal(wrapper.instance().getIssueishUrl(), 'https://github.com/atom/github/pull/1807'); - - setTextIn('.github-IssueishUrl atom-text-editor', 'https://github.com/atom/github/issues/1655'); - assert.equal(wrapper.instance().getIssueishUrl(), 'https://github.com/atom/github/issues/1655'); - }); - }); + return ( + + ); + } describe('open button enablement', function() { it('disables the open button with no issue url', function() { - setTextIn('.github-IssueishUrl atom-text-editor', ''); - wrapper.update(); + const wrapper = shallow(buildApp()); - assert.isTrue(wrapper.find('button.icon-git-pull-request').prop('disabled')); + wrapper.find('.github-OpenIssueish-url').prop('buffer').setText(''); + assert.isFalse(wrapper.find('DialogView').prop('acceptEnabled')); }); it('enables the open button when issue url box is populated', function() { - setTextIn('.github-IssueishUrl atom-text-editor', 'https://github.com/atom/github/pull/1807'); - wrapper.update(); + const wrapper = shallow(buildApp()); + wrapper.find('.github-OpenIssueish-url').prop('buffer').setText('https://github.com/atom/github/pull/1807'); - assert.isFalse(wrapper.find('button.icon-git-pull-request').prop('disabled')); + assert.isTrue(wrapper.find('DialogView').prop('acceptEnabled')); }); }); - describe('parseUrl', function() { - it('returns an object with repo owner, repo name, and issueish number', function() { - setTextIn('.github-IssueishUrl atom-text-editor', 'https://github.com/atom/github/pull/1807'); + it('calls the acceptance callback with the entered URL', function() { + const accept = sinon.spy(); + const request = dialogRequests.issueish(); + request.onAccept(accept); + const wrapper = shallow(buildApp({request})); + wrapper.find('.github-OpenIssueish-url').prop('buffer').setText('https://github.com/atom/github/pull/1807'); + wrapper.find('DialogView').prop('accept')(); - assert.deepEqual(wrapper.instance().parseUrl(), { - repoOwner: 'atom', - repoName: 'github', - issueishNumber: '1807', - }); - }); + assert.isTrue(accept.calledWith('https://github.com/atom/github/pull/1807')); }); - it('calls the acceptance callback', function() { - setTextIn('.github-IssueishUrl atom-text-editor', 'https://github.com/atom/github/pull/1807'); - - wrapper.find('button.icon-git-pull-request').simulate('click'); + it('calls the cancellation callback', function() { + const cancel = sinon.spy(); + const request = dialogRequests.issueish(); + request.onCancel(cancel); + const wrapper = shallow(buildApp({request})); + wrapper.find('DialogView').prop('cancel')(); - assert.isTrue(didAccept.calledWith({ - repoOwner: 'atom', - repoName: 'github', - issueishNumber: '1807', - })); + assert.isTrue(cancel.called); }); - it('calls the cancellation callback', function() { - wrapper.find('button.github-CancelButton').simulate('click'); - assert.isTrue(didCancel.called); + describe('openIssueishItem', function() { + it('opens an item for a valid issue URL', async function() { + sinon.stub(atomEnv.workspace, 'open').resolves('item'); + assert.strictEqual( + await openIssueishItem('https://github.com/atom/github/issues/2203', { + workspace: atomEnv.workspace, workdir: __dirname, + }), + 'item', + ); + assert.isTrue(atomEnv.workspace.open.calledWith( + IssueishDetailItem.buildURI({ + host: 'github.com', owner: 'atom', repo: 'github', number: 2203, workdir: __dirname, + }), + )); + }); + + it('opens an item for a valid PR URL', async function() { + sinon.stub(atomEnv.workspace, 'open').resolves('item'); + assert.strictEqual( + await openIssueishItem('https://github.com/smashwilson/az-coordinator/pull/10', { + workspace: atomEnv.workspace, workdir: __dirname, + }), + 'item', + ); + assert.isTrue(atomEnv.workspace.open.calledWith( + IssueishDetailItem.buildURI({ + host: 'github.com', owner: 'smashwilson', repo: 'az-coordinator', number: 10, workdir: __dirname, + }), + )); + }); + + it('rejects with an error for an invalid URL', async function() { + await assert.isRejected( + openIssueishItem('https://azurefire.net/not-an-issue', {workspace: atomEnv.workspace, workdir: __dirname}), + 'Not a valid issue or pull request URL', + ); + }); }); }); diff --git a/test/views/recent-commits-view.test.js b/test/views/recent-commits-view.test.js index 9f494283c7..b0eda50bba 100644 --- a/test/views/recent-commits-view.test.js +++ b/test/views/recent-commits-view.test.js @@ -16,7 +16,7 @@ describe('RecentCommitsView', function() { commits={[]} isLoading={false} selectedCommitSha="" - commandRegistry={atomEnv.commands} + commands={atomEnv.commands} undoLastCommit={() => { }} openCommit={() => { }} selectNextCommit={() => { }} diff --git a/test/views/staging-view.test.js b/test/views/staging-view.test.js index 996a4c6f6a..31f72f37bf 100644 --- a/test/views/staging-view.test.js +++ b/test/views/staging-view.test.js @@ -11,12 +11,12 @@ import {assertEqualSets} from '../helpers'; describe('StagingView', function() { const workingDirectoryPath = '/not/real/'; - let atomEnv, commandRegistry, workspace, notificationManager; + let atomEnv, commands, workspace, notificationManager; let app; beforeEach(function() { atomEnv = global.buildAtomEnvironment(); - commandRegistry = atomEnv.commands; + commands = atomEnv.commands; workspace = atomEnv.workspace; notificationManager = atomEnv.notifications; @@ -31,7 +31,7 @@ describe('StagingView', function() { stagedChanges={[]} workingDirectoryPath={workingDirectoryPath} hasUndoHistory={false} - commandRegistry={commandRegistry} + commands={commands} notificationManager={notificationManager} workspace={workspace} openFiles={noop} @@ -98,7 +98,7 @@ describe('StagingView', function() { .simulate('mousedown', {button: 0}); await wrapper.instance().mouseup(); - commandRegistry.dispatch(wrapper.getDOMNode(), 'core:confirm'); + commands.dispatch(wrapper.getDOMNode(), 'core:confirm'); await assert.async.isTrue(attemptFileStageOperation.calledWith(['b.txt'], 'unstaged')); }); @@ -113,7 +113,7 @@ describe('StagingView', function() { .simulate('mousedown', {button: 0}); await wrapper.instance().mouseup(); - commandRegistry.dispatch(wrapper.getDOMNode(), 'core:confirm'); + commands.dispatch(wrapper.getDOMNode(), 'core:confirm'); await assert.async.isTrue(attemptFileStageOperation.calledWith(['b.txt'], 'staged')); }); @@ -683,7 +683,7 @@ describe('StagingView', function() { it('invokes a callback only when a single file is selected', async function() { await wrapper.instance().selectFirst(); - commandRegistry.dispatch(wrapper.getDOMNode(), 'core:move-left'); + commands.dispatch(wrapper.getDOMNode(), 'core:move-left'); assert.isTrue(showFilePatchItem.calledWith('unstaged-1.txt'), 'Callback invoked with unstaged-1.txt'); @@ -693,7 +693,7 @@ describe('StagingView', function() { const selectedFilePaths = wrapper.instance().getSelectedItems().map(item => item.filePath).sort(); assert.deepEqual(selectedFilePaths, ['unstaged-1.txt', 'unstaged-2.txt']); - commandRegistry.dispatch(wrapper.getDOMNode(), 'core:move-left'); + commands.dispatch(wrapper.getDOMNode(), 'core:move-left'); assert.equal(showFilePatchItem.callCount, 0); }); @@ -702,7 +702,7 @@ describe('StagingView', function() { await wrapper.instance().activateNextList(); await wrapper.instance().selectFirst(); - commandRegistry.dispatch(wrapper.getDOMNode(), 'core:move-left'); + commands.dispatch(wrapper.getDOMNode(), 'core:move-left'); assert.isTrue(showMergeConflictFileForPath.calledWith('conflict-1.txt'), 'Callback invoked with conflict-1.txt'); @@ -711,7 +711,7 @@ describe('StagingView', function() { const selectedFilePaths = wrapper.instance().getSelectedItems().map(item => item.filePath).sort(); assert.deepEqual(selectedFilePaths, ['conflict-1.txt', 'conflict-2.txt']); - commandRegistry.dispatch(wrapper.getDOMNode(), 'core:move-left'); + commands.dispatch(wrapper.getDOMNode(), 'core:move-left'); assert.equal(showMergeConflictFileForPath.callCount, 0); });