diff --git a/docs/feature-requests/007-remote-management.md b/docs/feature-requests/007-remote-management.md new file mode 100644 index 0000000000..f322ff3f87 --- /dev/null +++ b/docs/feature-requests/007-remote-management.md @@ -0,0 +1,63 @@ + + +**_Part 1 - Required information_** + +# Remote Management + +## :memo: Summary + +This feature should allow users to manage git remotes within Atom. + +## :checkered_flag: Motivation + +For full support, the minimum features should be as follows. + - Be able to add or delete git remotes. (High Priority) + - Be able to choose a remote to view GitHub PR's from. (High Priority) + +This supports many use cases where people view, commit, pull, or push across multiple forks. It also supports viewing all pull request reviews where their are multiple remotes. Currently, it is only support to see PR's from one remote. + +The expected outcome is to move a lot of remote management from the terminal to Atom. + +## 🤯 Explanation + +Explain the proposal as if it was already implemented in the GitHub package and you were describing it to an Atom user. That generally means: + +- Introducing new named concepts. +- Explaining the feature largely in terms of examples. +- Explaining any changes to existing workflows. +- Design mock-ups or diagrams depicting any new UI that will be introduced. + + +**_Part 2 - Additional information_** + +## :anchor: Drawbacks + +Why should we *not* do this? + +## :thinking: Rationale and alternatives + +- Why is this approach the best in the space of possible approaches? +- What other approaches have been considered and what is the rationale for not choosing them? +- What is the impact of not doing this? + +## :question: Unresolved questions + +- What unresolved questions do you expect to resolve through the Feature Request process before this gets merged? +- What unresolved questions do you expect to resolve through the implementation of this feature before it is released in a new version of the package? + +## :warning: Out of Scope + +- What related issues do you consider out of scope for this Feature Request that could be addressed in the future independently of the solution that comes out of this Feature Request? + +## :construction: Implementation phases + +- Can this functionality be introduced in multiple, distinct, self-contained pull requests? +- A specification for when the feature is considered "done." + +## :white_check_mark: Feature description for Atom release blog post + +- When this feature is shipped, what would we like to say or show in our Atom release blog post (example: http://blog.atom.io/2018/07/31/atom-1-29.html) +- Feel free to drop ideas and gifs here during development +- Once development is complete, write a blurb for the release coordinator to copy/paste into the Atom release blog diff --git a/lib/atom/marker.js b/lib/atom/marker.js index 0fed0f7e01..8f3358142f 100644 --- a/lib/atom/marker.js +++ b/lib/atom/marker.js @@ -25,7 +25,7 @@ export const DecorableContext = React.createContext(); class BareMarker extends React.Component { static propTypes = { ...markerProps, - id: PropTypes.string, + id: PropTypes.number, bufferRange: RangePropType, markableHolder: RefHolderPropType, children: PropTypes.node, diff --git a/lib/containers/__generated__/githubHeaderContainerQuery.graphql.js b/lib/containers/__generated__/githubHeaderContainerQuery.graphql.js new file mode 100644 index 0000000000..b9132a0aca --- /dev/null +++ b/lib/containers/__generated__/githubHeaderContainerQuery.graphql.js @@ -0,0 +1,102 @@ +/** + * @flow + * @relayHash 1fc294f177f0be857503309c013873a8 + */ + +/* eslint-disable */ + +'use strict'; + +/*:: +import type { ConcreteRequest } from 'relay-runtime'; +export type githubHeaderContainerQueryVariables = {||}; +export type githubHeaderContainerQueryResponse = {| + +viewer: {| + +avatarUrl: any + |} +|}; +export type githubHeaderContainerQuery = {| + variables: githubHeaderContainerQueryVariables, + response: githubHeaderContainerQueryResponse, +|}; +*/ + + +/* +query githubHeaderContainerQuery { + viewer { + avatarUrl + id + } +} +*/ + +const node/*: ConcreteRequest*/ = (function(){ +var v0 = { + "kind": "ScalarField", + "alias": null, + "name": "avatarUrl", + "args": null, + "storageKey": null +}; +return { + "kind": "Request", + "fragment": { + "kind": "Fragment", + "name": "githubHeaderContainerQuery", + "type": "Query", + "metadata": null, + "argumentDefinitions": [], + "selections": [ + { + "kind": "LinkedField", + "alias": null, + "name": "viewer", + "storageKey": null, + "args": null, + "concreteType": "User", + "plural": false, + "selections": [ + (v0/*: any*/) + ] + } + ] + }, + "operation": { + "kind": "Operation", + "name": "githubHeaderContainerQuery", + "argumentDefinitions": [], + "selections": [ + { + "kind": "LinkedField", + "alias": null, + "name": "viewer", + "storageKey": null, + "args": null, + "concreteType": "User", + "plural": false, + "selections": [ + (v0/*: any*/), + { + "kind": "ScalarField", + "alias": null, + "name": "id", + "args": null, + "storageKey": null + } + ] + } + ] + }, + "params": { + "operationKind": "query", + "name": "githubHeaderContainerQuery", + "id": null, + "text": "query githubHeaderContainerQuery {\n viewer {\n avatarUrl\n id\n }\n}\n", + "metadata": {} + } +}; +})(); +// prettier-ignore +(node/*: any*/).hash = '8f64904c4645e6e5b69683dd100f6a6e'; +module.exports = node; diff --git a/lib/containers/current-pull-request-container.js b/lib/containers/current-pull-request-container.js index 95cba9103d..705f7ca965 100644 --- a/lib/containers/current-pull-request-container.js +++ b/lib/containers/current-pull-request-container.js @@ -37,8 +37,6 @@ export default class CurrentPullRequestContainer extends React.Component { aheadCount: PropTypes.number, pushInProgress: PropTypes.bool.isRequired, - workspace: PropTypes.object.isRequired, - // Actions onOpenIssueish: PropTypes.func.isRequired, onOpenReviews: PropTypes.func.isRequired, @@ -164,7 +162,6 @@ export default class CurrentPullRequestContainer extends React.Component { total={associatedPullRequests.totalCount} results={associatedPullRequests.nodes} isLoading={false} - workspace={this.props.workspace} endpoint={this.props.endpoint} resultFilter={issueish => issueish.getHeadRepositoryID() === this.props.repository.id} {...this.controllerProps()} diff --git a/lib/containers/github-header-container.js b/lib/containers/github-header-container.js new file mode 100644 index 0000000000..0a5baa04a7 --- /dev/null +++ b/lib/containers/github-header-container.js @@ -0,0 +1,104 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import {QueryRenderer, graphql} from 'react-relay'; + +import {incrementCounter} from '../reporter-proxy'; +import {EndpointPropType, RemoteSetPropType} from '../prop-types'; +import RelayNetworkLayerManager from '../relay-network-layer-manager'; +import {UNAUTHENTICATED, INSUFFICIENT} from '../shared/keytar-strategy'; +import ObserveModel from '../views/observe-model'; +import QueryErrorView from '../views/query-error-view'; +import GithubHeaderView from '../views/github-header-view'; + +export default class GithubHeaderContainer extends React.Component { + static propTypes = { + // Connection + loginModel: PropTypes.object.isRequired, + endpoint: EndpointPropType.isRequired, + + avatarUrl: PropTypes.string, + remotes: RemoteSetPropType.isRequired, + currentRemoteName: PropTypes.string.isRequired, + + handleRemoteSelect: PropTypes.func.isRequired, + } + + fetchToken = loginModel => { + return loginModel.getToken(this.props.endpoint.getLoginAccount()); + } + + render() { + return ( + + {this.renderWithToken} + + ); + } + + renderWithToken = token => { + if (token === null) { + return this.renderWithBlankAvatar(); + } + + if (token instanceof Error) { + return ( + + ); + } + + if (token === UNAUTHENTICATED || token === INSUFFICIENT) { + return this.renderWithBlankAvatar(); + } + + const environment = RelayNetworkLayerManager.getEnvironmentForHost(this.props.endpoint, token); + const query = graphql` + query githubHeaderContainerQuery { + viewer { + avatarUrl + } + } + `; + + return ( + this.renderWithResult(result, token)} + /> + ); + } + + renderWithResult({error, props, retry}, token) { + const avatarUrl = !error && props !== null ? props.viewer.avatarUrl : null; + + return ( + this.props.handleRemoteSelect(e, this.props.remotes.withName(e.target.value))} + /> + ); + } + + renderWithBlankAvatar = () => this.renderWithResult({props: null}); + + handleLogin = token => { + incrementCounter('github-login'); + this.props.loginModel.setToken(this.props.endpoint.getLoginAccount(), token); + } + + handleLogout = () => { + incrementCounter('github-logout'); + this.props.loginModel.removeToken(this.props.endpoint.getLoginAccount()); + } + + handleTokenRetry = () => this.props.loginModel.didUpdate(); +} diff --git a/lib/controllers/editor-comment-decorations-controller.js b/lib/controllers/editor-comment-decorations-controller.js index 1cfe855a6f..61e548e85c 100644 --- a/lib/controllers/editor-comment-decorations-controller.js +++ b/lib/controllers/editor-comment-decorations-controller.js @@ -20,7 +20,10 @@ export default class EditorCommentDecorationsController extends React.Component workspace: PropTypes.object.isRequired, editor: PropTypes.object.isRequired, threadsForPath: PropTypes.arrayOf(PropTypes.shape({ - rootCommentID: PropTypes.string.isRequired, + rootCommentID: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.number, + ]).isRequired, position: PropTypes.number, threadID: PropTypes.string.isRequired, })).isRequired, diff --git a/lib/controllers/git-tab-controller.js b/lib/controllers/git-tab-controller.js index b75afe868d..55c0bcf65e 100644 --- a/lib/controllers/git-tab-controller.js +++ b/lib/controllers/git-tab-controller.js @@ -51,6 +51,7 @@ export default class GitTabController extends React.Component { openFiles: PropTypes.func.isRequired, openInitializeDialog: PropTypes.func.isRequired, controllerRef: RefHolderPropType, + changeProjectWorkingDirectory: PropTypes.func.isRequired, }; constructor(props, context) { @@ -119,6 +120,7 @@ export default class GitTabController extends React.Component { openFiles={this.props.openFiles} discardWorkDirChangesForPaths={this.props.discardWorkDirChangesForPaths} undoLastDiscard={this.props.undoLastDiscard} + changeProjectWorkingDirectory={this.props.changeProjectWorkingDirectory} attemptFileStageOperation={this.attemptFileStageOperation} attemptStageAllOperation={this.attemptStageAllOperation} diff --git a/lib/controllers/github-tab-controller.js b/lib/controllers/github-tab-controller.js index dc850c2e7f..ec336fbb61 100644 --- a/lib/controllers/github-tab-controller.js +++ b/lib/controllers/github-tab-controller.js @@ -8,6 +8,7 @@ import GitHubTabView from '../views/github-tab-view'; export default class GitHubTabController extends React.Component { static propTypes = { + project: PropTypes.object.isRequired, workspace: PropTypes.object.isRequired, repository: PropTypes.object.isRequired, remoteOperationObserver: OperationStateObserverPropType.isRequired, @@ -21,6 +22,8 @@ export default class GitHubTabController extends React.Component { aheadCount: PropTypes.number, pushInProgress: PropTypes.bool.isRequired, isLoading: PropTypes.bool.isRequired, + + changeProjectWorkingDirectory: PropTypes.func.isRequired, } render() { @@ -28,15 +31,16 @@ export default class GitHubTabController extends React.Component { const currentBranch = this.props.branches.getHeadBranch(); let currentRemote = gitHubRemotes.withName(this.props.selectedRemoteName); - let manyRemotesAvailable = false; + let isSelectingRemote = false; if (!currentRemote.isPresent() && gitHubRemotes.size() === 1) { currentRemote = Array.from(gitHubRemotes)[0]; } else if (!currentRemote.isPresent() && gitHubRemotes.size() > 1) { - manyRemotesAvailable = true; + isSelectingRemote = true; } return ( ); } diff --git a/lib/controllers/issueish-list-controller.js b/lib/controllers/issueish-list-controller.js index 8c2f405c2c..94635a6cc6 100644 --- a/lib/controllers/issueish-list-controller.js +++ b/lib/controllers/issueish-list-controller.js @@ -56,7 +56,6 @@ export class BareIssueishListController extends React.Component { onOpenMore: PropTypes.func, emptyComponent: PropTypes.func, - workspace: PropTypes.object, endpoint: EndpointPropType, needReviewsButton: PropTypes.bool, }; diff --git a/lib/controllers/root-controller.js b/lib/controllers/root-controller.js index 53ee82a659..40a8db36ee 100644 --- a/lib/controllers/root-controller.js +++ b/lib/controllers/root-controller.js @@ -65,6 +65,7 @@ export default class RootController extends React.Component { clone: PropTypes.func.isRequired, // Control + changeProjectWorkingDirectory: PropTypes.func.isRequired, startOpen: PropTypes.bool, startRevealed: PropTypes.bool, } @@ -284,6 +285,7 @@ export default class RootController extends React.Component { discardWorkDirChangesForPaths={this.discardWorkDirChangesForPaths} undoLastDiscard={this.undoLastDiscard} refreshResolutionProgress={this.refreshResolutionProgress} + changeProjectWorkingDirectory={this.props.changeProjectWorkingDirectory} /> )} @@ -296,7 +298,9 @@ export default class RootController extends React.Component { ref={itemHolder.setter} repository={this.props.repository} loginModel={this.props.loginModel} + project={this.props.project} workspace={this.props.workspace} + changeProjectWorkingDirectory={this.props.changeProjectWorkingDirectory} /> )} diff --git a/lib/error-boundary.js b/lib/error-boundary.js new file mode 100644 index 0000000000..be06024949 --- /dev/null +++ b/lib/error-boundary.js @@ -0,0 +1,36 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +export default class ErrorBoundary extends React.Component { + static propTypes = { + children: PropTypes.any, + }; + + constructor(props) { + super(props); + this.state = {hasError: false, error: null, errorInfo: null}; + } + + static getDerivedStateFromError(error) { + // Update state so the next render will show the fallback UI. + return {hasError: true}; + } + + componentDidCatch(error, errorInfo) { + this.setState({ + error, + errorInfo, + }); + // You can also log the error to an error reporting service + // console.log({error, errorInfo}); + } + + render() { + if (this.state.hasError) { + // You can render any custom fallback UI + return null; + } + + return this.props.children; + } +} diff --git a/lib/github-package.js b/lib/github-package.js index 3da08abf44..38f3bfed6d 100644 --- a/lib/github-package.js +++ b/lib/github-package.js @@ -175,9 +175,14 @@ export default class GithubPackage { return !!event.target.closest('.github-FilePatchListView').querySelector('.is-selected'); }; + const handleProjectPathsChange = projectPaths => { + const activeRepository = this.getActiveRepository(); + const activeRepositoryPath = activeRepository ? activeRepository.getWorkingDirectoryPath() : null; + this.scheduleActiveContextUpdate({activeRepositoryPath}, {projectPaths}); + }; + this.subscriptions.add( - this.project.onDidChangePaths(this.scheduleActiveContextUpdate), - this.workspace.getCenter().onDidChangeActivePaneItem(this.scheduleActiveContextUpdate), + this.project.onDidChangePaths(handleProjectPathsChange), this.styleCalculator.startWatching( 'github-package-styles', ['editor.fontSize', 'editor.fontFamily', 'editor.lineHeight', 'editor.tabLength'], @@ -269,6 +274,10 @@ export default class GithubPackage { })); } + const changeProjectWorkingDirectory = projectWorkingDirectory => { + this.scheduleActiveContextUpdate({activeRepositoryPath: projectWorkingDirectory}); + }; + this.renderFn( { this.controller = c; }} @@ -294,6 +303,7 @@ export default class GithubPackage { startOpen={this.startOpen} startRevealed={this.startRevealed} removeFilePatchItem={this.removeFilePatchItem} + changeProjectWorkingDirectory={changeProjectWorkingDirectory} />, this.element, callback, ); } @@ -486,25 +496,29 @@ export default class GithubPackage { return this.switchboard; } - async scheduleActiveContextUpdate(savedState = {}) { + /** + * Extras can be responses from subscribing to events + * For example, in `handleProjectPathsChange` extras contains the projectPaths changes, + * and in `handleActivePaneChange` extras contains the item changes. + */ + async scheduleActiveContextUpdate(savedState = {}, extras = null) { this.switchboard.didScheduleActiveContextUpdate(); - await this.activeContextQueue.push(this.updateActiveContext.bind(this, savedState), {parallel: false}); + await this.activeContextQueue.push(this.updateActiveContext.bind(this, savedState, extras), {parallel: false}); } /** * Derive the git working directory context that should be used for the package's git operations based on the current * state of the Atom workspace. In priority, this prefers: * - * - A git working directory that contains the active pane item in the workspace's center. - * - A git working directory corresponding to a single Project. - * - When initially activating the package, the working directory that was active when the package was last - * serialized. + * - The preferred git working directory set by the user (This is also the working directory that was active when the + * package was last serialized). + * - A git working directory corresponding to "first" Project, whether or not there is a single project or multiple. * - The current context, unchanged, which may be a `NullWorkdirContext`. * * First updates the pool of resident contexts to match all git working directories that correspond to open - * projects and pane items. + * projects. */ - async getNextContext(savedState) { + async getNextContext(savedState, extras) { const workdirs = new Set( await Promise.all( this.project.getPaths().map(async projectPath => { @@ -514,55 +528,40 @@ export default class GithubPackage { ), ); - const fromPaneItem = async maybeItem => { - const itemPath = pathForPaneItem(maybeItem); - - if (!itemPath) { - return {}; - } - - const itemWorkdir = await this.workdirCache.find(itemPath); - - if (itemWorkdir && !this.project.contains(itemPath)) { - workdirs.add(itemWorkdir); - } - - return {itemPath, itemWorkdir}; - }; - - const active = await fromPaneItem(this.workspace.getCenter().getActivePaneItem()); - + // Update pool with the open projects this.contextPool.set(workdirs, savedState); - if (active.itemPath) { - // Prefer an active item - return this.contextPool.getContext(active.itemWorkdir || active.itemPath); + if (savedState.activeRepositoryPath) { + // Preferred git directory (the preferred directory or the last serialized directory). + const stateContext = this.contextPool.getContext(savedState.activeRepositoryPath); + // If the context exists chose it, else continue. + if (stateContext.isPresent()) { + return stateContext; + } } - if (this.project.getPaths().length === 1) { - // Single project - const projectPath = this.project.getPaths()[0]; + const projectPaths = this.project.getPaths(); + + if (projectPaths.length >= 1) { + // Single or multiple projects (just choose the first, the user can select after) + const projectPath = projectPaths[0]; const activeWorkingDir = await this.workdirCache.find(projectPath); return this.contextPool.getContext(activeWorkingDir || projectPath); } - if (this.project.getPaths().length === 0 && !this.activeContext.getRepository().isUndetermined()) { + if (projectPaths.length === 0 && !this.activeContext.getRepository().isUndetermined()) { // No projects. Revert to the absent context unless we've guessed that more projects are on the way. return WorkdirContext.absent({pipelineManager: this.pipelineManager}); } - // Restore models from saved state. Will return a NullWorkdirContext if this path is not presently - // resident in the pool. - const savedWorkingDir = savedState.activeRepositoryPath; - if (savedWorkingDir) { - return this.contextPool.getContext(savedWorkingDir); - } - + // It is only possible to reach here if there there was no preferred directory, there are no project paths and the + // the active context's repository is undetermined. return this.activeContext; } - setActiveContext(nextActiveContext) { - if (nextActiveContext !== this.activeContext) { + setActiveContext(nextActiveContext, extras) { + // Always update rendering when paths change. + if (nextActiveContext !== this.activeContext || (extras && extras.projectPaths)) { if (this.activeContext === this.guessedContext) { this.guessedContext.destroy(); this.guessedContext = null; @@ -577,15 +576,15 @@ export default class GithubPackage { } } - async updateActiveContext(savedState = {}) { + async updateActiveContext(savedState = {}, extras = null) { if (this.workspace.isDestroyed()) { return; } this.switchboard.didBeginActiveContextUpdate(); - const nextActiveContext = await this.getNextContext(savedState); - this.setActiveContext(nextActiveContext); + const nextActiveContext = await this.getNextContext(savedState, extras); + this.setActiveContext(nextActiveContext, extras); } async refreshAtomGitRepository(workdir) { @@ -601,6 +600,7 @@ export default class GithubPackage { } } +// eslint-disable-next-line no-unused-vars function pathForPaneItem(paneItem) { if (!paneItem) { return null; diff --git a/lib/views/git-tab-view.js b/lib/views/git-tab-view.js index 53c01408ce..fb4c75374f 100644 --- a/lib/views/git-tab-view.js +++ b/lib/views/git-tab-view.js @@ -1,9 +1,10 @@ -import React from 'react'; +import React, {Fragment} from 'react'; import PropTypes from 'prop-types'; import cx from 'classnames'; import {CompositeDisposable} from 'atom'; import StagingView from './staging-view'; +import HeaderView from './header-view'; import CommitController from '../controllers/commit-controller'; import RecentCommitsController from '../controllers/recent-commits-controller'; import RefHolder from '../models/ref-holder'; @@ -60,6 +61,7 @@ export default class GitTabView extends React.Component { attemptFileStageOperation: PropTypes.func.isRequired, discardWorkDirChangesForPaths: PropTypes.func.isRequired, openFiles: PropTypes.func.isRequired, + changeProjectWorkingDirectory: PropTypes.func.isRequired, }; constructor(props, context) { @@ -85,62 +87,95 @@ export default class GitTabView extends React.Component { } render() { + let type = 'default'; + let isEmpty = false; + let isLoading = false; if (this.props.repository.isTooLarge()) { + type = 'is-too-large'; + isEmpty = true; + } else if (this.props.repository.hasDirectory() && + !isValidWorkdir(this.props.repository.getWorkingDirectoryPath())) { + type = 'unsupported-dir'; + isEmpty = true; + } else if (this.props.repository.showGitTabInit()) { + type = 'no-repo'; + isEmpty = true; + } else if (this.props.isLoading || this.props.repository.showGitTabLoading()) { + isLoading = true; + } + + return ( +
+ {this.renderHeader()} + {this.renderBody(type)} +
+ ); + } + + renderHeader() { + return ( + this.props.changeProjectWorkingDirectory(e.target.value)} + currentProject={this.props.workingDirectoryPath} + projectPaths={this.props.project.getPaths()} + /> + ); + } + + renderBody(type) { + switch (type) { + case 'is-too-large': return ( -
-
-
-

Too many changes

-
- The repository at {this.props.workingDirectoryPath} has too many changed files - to display in Atom. Ensure that you have set up an appropriate .gitignore file. -
+
+
+

Too many changes

+
+ The repository at {this.props.workingDirectoryPath} has too many changed files + to display in Atom. Ensure that you have set up an appropriate .gitignore file.
); - } else if (this.props.repository.hasDirectory() && - !isValidWorkdir(this.props.repository.getWorkingDirectoryPath())) { + case 'unsupported-dir': return ( -
-
-
-

Unsupported directory

-
- Atom does not support managing Git repositories in your home or root directories. -
+
+
+

Unsupported directory

+
+ Atom does not support managing Git repositories in your home or root directories.
); - } else if (this.props.repository.showGitTabInit()) { - const inProgress = this.props.repository.showGitTabInitInProgress(); - const message = this.props.repository.hasDirectory() - ? - ( - Initialize {this.props.workingDirectoryPath} with a - Git repository - ) - : Initialize a new project directory with a Git repository; - + case 'no-repo': return ( -
-
-
-

Create Repository

-
{message}
- +
+
+

Create Repository

+
+ { + this.props.repository.hasDirectory() + ? + ( + Initialize {this.props.workingDirectoryPath} with a + Git repository + ) + : Initialize a new project directory with a Git repository + }
+
); - } else { - const isLoading = this.props.isLoading || this.props.repository.showGitTabLoading(); - + default: return ( -
+ -
+ ); } } diff --git a/lib/views/github-header-view.js b/lib/views/github-header-view.js new file mode 100644 index 0000000000..4838cd5d2a --- /dev/null +++ b/lib/views/github-header-view.js @@ -0,0 +1,31 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import {RemoteSetPropType} from '../prop-types'; +import RemoteSelector from './remote-selector'; + +export default class GithubHeaderView extends React.Component { + static propTypes = { + avatarUrl: PropTypes.string, + remotes: RemoteSetPropType.isRequired, + currentRemoteName: PropTypes.string.isRequired, + + handleRemoteSelect: PropTypes.func.isRequired, + } + + render() { + const {avatarUrl} = this.props; + return ( +
+ Avatar + + null} /> +
+ ); + } +} diff --git a/lib/views/github-tab-view.js b/lib/views/github-tab-view.js index 6ce0dbddd2..c1cc3fb9e1 100644 --- a/lib/views/github-tab-view.js +++ b/lib/views/github-tab-view.js @@ -7,10 +7,14 @@ import { } from '../prop-types'; import LoadingView from './loading-view'; import RemoteSelectorView from './remote-selector-view'; +import HeaderView from './header-view'; import RemoteContainer from '../containers/remote-container'; +import GithubHeaderContainer from '../containers/github-header-container'; + export default class GitHubTabView extends React.Component { static propTypes = { + project: PropTypes.object.isRequired, workspace: PropTypes.object.isRequired, remoteOperationObserver: OperationStateObserverPropType.isRequired, loginModel: GithubLoginModelPropType.isRequired, @@ -21,18 +25,20 @@ export default class GitHubTabView extends React.Component { currentBranch: BranchPropType.isRequired, remotes: RemoteSetPropType.isRequired, currentRemote: RemotePropType.isRequired, - manyRemotesAvailable: PropTypes.bool.isRequired, + isSelectingRemote: PropTypes.bool.isRequired, aheadCount: PropTypes.number, pushInProgress: PropTypes.bool.isRequired, isLoading: PropTypes.bool.isRequired, handlePushBranch: PropTypes.func.isRequired, handleRemoteSelect: PropTypes.func.isRequired, + changeProjectWorkingDirectory: PropTypes.func.isRequired, } render() { return (
+ {this.renderHeader()}
{this.renderRemote()}
@@ -40,6 +46,23 @@ export default class GitHubTabView extends React.Component { ); } + renderHeader() { + if (this.props.isLoading || !this.props.currentRemote.isPresent()) { return null; } + + const {currentRemote} = this.props; + const currentRemoteName = currentRemote && currentRemote.name ? currentRemote.name : ' '; + return ( + + ); + } + renderRemote() { if (this.props.isLoading) { return ; @@ -66,7 +89,7 @@ export default class GitHubTabView extends React.Component { ); } - if (this.props.manyRemotesAvailable) { + if (this.props.isSelectingRemote) { // No chosen remote, multiple remotes hosted on GitHub instances return ( ); } + + renderHeader() { + return ( + this.props.changeProjectWorkingDirectory(e.target.value)} + currentProject={this.props.workingDirectory} + projectPaths={this.props.project.getPaths()} + /> + ); + } } diff --git a/lib/views/header-view.js b/lib/views/header-view.js new file mode 100644 index 0000000000..b6347c8995 --- /dev/null +++ b/lib/views/header-view.js @@ -0,0 +1,32 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import path from 'path'; + +export default class HeaderView extends React.Component { + static propTypes = { + currentProject: PropTypes.string, + projectPaths: PropTypes.arrayOf(PropTypes.string), + + handleProjectSelect: PropTypes.func.isRequired, + } + + render() { + return ( +
+ +
+ ); + } + + renderProjects = () => { + const projects = []; + for (const projectPath of this.props.projectPaths) { + projects.push(); + } + return projects; + }; +} diff --git a/lib/views/remote-selector.js b/lib/views/remote-selector.js new file mode 100644 index 0000000000..809651010e --- /dev/null +++ b/lib/views/remote-selector.js @@ -0,0 +1,32 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import {RemoteSetPropType} from '../prop-types'; + +export default class RemoteSelector extends React.Component { + static propTypes = { + remotes: RemoteSetPropType.isRequired, + currentRemoteName: PropTypes.string.isRequired, + + handleRemoteSelect: PropTypes.func.isRequired, + } + + render() { + return ( + + ); + } + + renderRemotes = () => { + const remoteOptions = []; + for (const remote of this.props.remotes) { + remoteOptions.push(); + } + return remoteOptions; + }; +} diff --git a/styles/project.less b/styles/project.less new file mode 100644 index 0000000000..6dde98f812 --- /dev/null +++ b/styles/project.less @@ -0,0 +1,13 @@ +@import "variables"; + +.github-Project { + display: flex; + align-items: center; + padding: @component-padding/2 @component-padding; + border-bottom: 1px solid @base-border-color; + + &-path { + flex: 1; + } + +} diff --git a/test/atom/decoration.test.js b/test/atom/decoration.test.js index b31b040b5e..93d9ca244f 100644 --- a/test/atom/decoration.test.js +++ b/test/atom/decoration.test.js @@ -7,6 +7,7 @@ import Decoration from '../../lib/atom/decoration'; import AtomTextEditor from '../../lib/atom/atom-text-editor'; import Marker from '../../lib/atom/marker'; import MarkerLayer from '../../lib/atom/marker-layer'; +import ErrorBoundary from '../../lib/error-boundary'; describe('Decoration', function() { let atomEnv, workspace, editor, marker; @@ -128,15 +129,40 @@ describe('Decoration', function() { assert.isNull(editor.gutterWithName(gutterName)); }); - it('throws an error if `gutterName` prop is not supplied for gutter decorations', function() { - const app = ( - -
- This is a subtree -
-
- ); - assert.throws(() => mount(app), 'You are trying to decorate a gutter but did not supply gutterName prop.'); + describe('throws an error', function() { + let errors; + + // This consumes the error rather than printing it to console. + const onError = function(e) { + if (e.message === 'Uncaught Error: You are trying to decorate a gutter but did not supply gutterName prop.') { + errors.push(e.error); + e.preventDefault(); + } + }; + + beforeEach(function() { + errors = []; + window.addEventListener('error', onError); + }); + + afterEach(function() { + errors = []; + window.removeEventListener('error', onError); + }); + + it('if `gutterName` prop is not supplied for gutter decorations', function() { + const app = ( + + +
+ This is a subtree +
+
+
+ ); + mount(app); + assert(errors[0], 'You are trying to decorate a gutter but did not supply gutterName prop.'); + }); }); }); @@ -254,7 +280,7 @@ describe('Decoration', function() { it('decorates a parent Marker on a prop-provided TextEditor', function() { mount( - + , ); @@ -266,7 +292,7 @@ describe('Decoration', function() { let layerID = null; mount( { layerID = id; }}> - + , ); diff --git a/test/atom/marker.test.js b/test/atom/marker.test.js index 297ce3333a..0fb62032f3 100644 --- a/test/atom/marker.test.js +++ b/test/atom/marker.test.js @@ -6,6 +6,7 @@ import Marker from '../../lib/atom/marker'; import AtomTextEditor from '../../lib/atom/atom-text-editor'; import RefHolder from '../../lib/models/ref-holder'; import MarkerLayer from '../../lib/atom/marker-layer'; +import ErrorBoundary from '../../lib/error-boundary'; describe('Marker', function() { let atomEnv, workspace, editor, marker, markerID; @@ -135,8 +136,31 @@ describe('Marker', function() { assert.strictEqual(instance.markerHolder.get(), external); }); - it('fails on construction if its ID is invalid', function() { - assert.throws(() => mount(), /Invalid marker ID: 67/); + describe('fails on construction', function() { + let errors; + + // This consumes the error rather than printing it to console. + const onError = function(e) { + if (e.message === 'Uncaught Error: Invalid marker ID: 67') { + errors.push(e.error); + e.preventDefault(); + } + }; + + beforeEach(function() { + errors = []; + window.addEventListener('error', onError); + }); + + afterEach(function() { + errors = []; + window.removeEventListener('error', onError); + }); + + it('if its ID is invalid', function() { + mount(); + assert(errors[0], 'Error: Invalid marker ID: 67'); + }); }); it('does not destroy its marker on unmount', function() { diff --git a/test/containers/comment-decorations-container.test.js b/test/containers/comment-decorations-container.test.js index 6ee4eb3213..a00f9bb1e3 100644 --- a/test/containers/comment-decorations-container.test.js +++ b/test/containers/comment-decorations-container.test.js @@ -184,8 +184,10 @@ describe('CommentDecorationsContainer', function() { it('renders nothing if query errors', function() { const tokenWrapper = localRepoWrapper.find(ObserveModel).renderProp('children')('1234'); + const stub = sinon.stub(console, 'warn'); const resultWrapper = tokenWrapper.find(QueryRenderer).renderProp('render')({ error: 'oh noes', props: null, retry: () => {}}); + stub.restore(); assert.isTrue(resultWrapper.isEmptyRender()); }); @@ -254,12 +256,15 @@ describe('CommentDecorationsContainer', function() { const resultWrapper = tokenWrapper.find(QueryRenderer).renderProp('render')({ error: null, props, retry: () => {}, }); + const stub = sinon.stub(console, 'warn'); const reviewsWrapper = resultWrapper.find(AggregatedReviewsContainer).renderProp('children')({ errors: [new Error('ahhhh')], summaries: [], commentThreads: [], }); + stub.restore(); + assert.isTrue(reviewsWrapper.isEmptyRender()); }); @@ -341,9 +346,11 @@ describe('CommentDecorationsContainer', function() { {thread: {id: 'thread0'}, comments: [{id: 'comment0', path: 'a.txt'}, {id: 'comment1', path: 'a.txt'}]}, ], }); + const stub = sinon.stub(console, 'warn'); const patchWrapper = reviewsWrapper.find(PullRequestPatchContainer).renderProp('children')( new Error('oops'), null, ); + stub.restore(); assert.isTrue(patchWrapper.isEmptyRender()); }); diff --git a/test/containers/current-pull-request-container.test.js b/test/containers/current-pull-request-container.test.js index 7d37e017c2..8370c82e44 100644 --- a/test/containers/current-pull-request-container.test.js +++ b/test/containers/current-pull-request-container.test.js @@ -45,6 +45,7 @@ describe('CurrentPullRequestContainer', function() { pushInProgress={false} onOpenIssueish={() => {}} + onOpenReviews={() => {}} onCreatePr={() => {}} {...overrideProps} diff --git a/test/containers/issueish-detail-container.test.js b/test/containers/issueish-detail-container.test.js index 417f65fc70..cc726c58d1 100644 --- a/test/containers/issueish-detail-container.test.js +++ b/test/containers/issueish-detail-container.test.js @@ -54,6 +54,7 @@ describe('IssueishDetailContainer', function() { switchToIssueish: () => {}, onTitleChange: () => {}, destroy: () => {}, + reportRelayError: () => {}, itemType: IssueishDetailItem, refEditor: new RefHolder(), diff --git a/test/containers/issueish-search-container.test.js b/test/containers/issueish-search-container.test.js index e9119f1e5d..f3763b17d5 100644 --- a/test/containers/issueish-search-container.test.js +++ b/test/containers/issueish-search-container.test.js @@ -26,6 +26,7 @@ describe('IssueishSearchContainer', function() { onOpenIssueish={() => {}} onOpenSearch={() => {}} + onOpenReviews={() => {}} {...overrideProps} /> @@ -86,31 +87,49 @@ describe('IssueishSearchContainer', function() { await promise; }); - it('passes an empty result list and an error prop to the controller when errored', async function() { - expectRelayQuery({ - name: 'issueishSearchContainerQuery', - variables: { - query: 'type:pr', - first: 20, - checkSuiteCount: CHECK_SUITE_PAGE_SIZE, - checkSuiteCursor: null, - checkRunCount: CHECK_RUN_PAGE_SIZE, - checkRunCursor: null, - }, - }, op => { - return relayResponseBuilder(op) - .addError('uh oh') - .build(); - }).resolve(); + describe('when the query errors', function() { + let stub; + // Consumes the failing Relay Query console error + before(function() { + stub = sinon.stub(console, 'error'); + // eslint-disable-next-line no-console + console.error.withArgs( + 'Error encountered in subquery', + sinon.match.defined.and(sinon.match.hasNested('errors[0].message', sinon.match('uh oh'))), + ).callsFake(() => {}); + // eslint-disable-next-line no-console + console.error.callThrough(); + }); - const wrapper = mount(buildApp({})); + it('passes an empty result list and an error prop to the controller', async function() { + expectRelayQuery({ + name: 'issueishSearchContainerQuery', + variables: { + query: 'type:pr', + first: 20, + checkSuiteCount: CHECK_SUITE_PAGE_SIZE, + checkSuiteCursor: null, + checkRunCount: CHECK_RUN_PAGE_SIZE, + checkRunCursor: null, + }, + }, op => { + return relayResponseBuilder(op) + .addError('uh oh') + .build(); + }).resolve(); + + const wrapper = mount(buildApp({})); + await assert.async.isTrue( + wrapper.update().find('BareIssueishListController').filterWhere(n => !n.prop('isLoading')).exists(), + ); + const controller = wrapper.find('BareIssueishListController'); + assert.deepEqual(controller.prop('error').errors, [{message: 'uh oh'}]); + assert.lengthOf(controller.prop('results'), 0); + }); - await assert.async.isTrue( - wrapper.update().find('BareIssueishListController').filterWhere(n => !n.prop('isLoading')).exists(), - ); - const controller = wrapper.find('BareIssueishListController'); - assert.deepEqual(controller.prop('error').errors, [{message: 'uh oh'}]); - assert.lengthOf(controller.prop('results'), 0); + after(function() { + stub.restore(); + }); }); it('passes results to the controller', async function() { diff --git a/test/containers/reviews-container.test.js b/test/containers/reviews-container.test.js index 903922ec00..49e43678a6 100644 --- a/test/containers/reviews-container.test.js +++ b/test/containers/reviews-container.test.js @@ -39,7 +39,9 @@ describe('ReviewsContainer', function() { queryData = { repository: { - pullRequest: {}, + pullRequest: { + headRefOid: '0000000000000000000000000000000000000000', + }, }, }; }); diff --git a/test/controllers/changed-file-controller.test.js b/test/controllers/changed-file-controller.test.js index 6247764cdf..ab5b808173 100644 --- a/test/controllers/changed-file-controller.test.js +++ b/test/controllers/changed-file-controller.test.js @@ -27,6 +27,9 @@ describe('ChangedFileController', function() { keymaps: atomEnv.keymaps, tooltips: atomEnv.tooltips, config: atomEnv.config, + multiFilePatch: { + getFilePatches: () => {}, + }, destroy: () => {}, undoLastDiscard: () => {}, diff --git a/test/controllers/github-tab-controller.test.js b/test/controllers/github-tab-controller.test.js index 5843176be0..151781d564 100644 --- a/test/controllers/github-tab-controller.test.js +++ b/test/controllers/github-tab-controller.test.js @@ -57,21 +57,21 @@ describe('GitHubTabController', function() { const allRemotes = new RemoteSet([dotcom0, dotcom1, nonDotcom]); const wrapper = shallow(buildApp({allRemotes, selectedRemoteName: 'yes1'})); assert.strictEqual(wrapper.find('GitHubTabView').prop('currentRemote'), dotcom1); - assert.isFalse(wrapper.find('GitHubTabView').prop('manyRemotesAvailable')); + assert.isFalse(wrapper.find('GitHubTabView').prop('isSelectingRemote')); }); it('uses a single GitHub-hosted remote', function() { const allRemotes = new RemoteSet([dotcom0, nonDotcom]); const wrapper = shallow(buildApp({allRemotes})); assert.strictEqual(wrapper.find('GitHubTabView').prop('currentRemote'), dotcom0); - assert.isFalse(wrapper.find('GitHubTabView').prop('manyRemotesAvailable')); + assert.isFalse(wrapper.find('GitHubTabView').prop('isSelectingRemote')); }); it('indicates when multiple remotes are available', function() { const allRemotes = new RemoteSet([dotcom0, dotcom1]); const wrapper = shallow(buildApp({allRemotes})); assert.isFalse(wrapper.find('GitHubTabView').prop('currentRemote').isPresent()); - assert.isTrue(wrapper.find('GitHubTabView').prop('manyRemotesAvailable')); + assert.isTrue(wrapper.find('GitHubTabView').prop('isSelectingRemote')); }); }); diff --git a/test/fixtures/props/git-tab-props.js b/test/fixtures/props/git-tab-props.js index fd3dfa2e65..dc26fe4732 100644 --- a/test/fixtures/props/git-tab-props.js +++ b/test/fixtures/props/git-tab-props.js @@ -25,6 +25,7 @@ export function gitTabItemProps(atomEnv, repository, overrides = {}) { discardWorkDirChangesForPaths: noop, openFiles: noop, openInitializeDialog: noop, + changeProjectWorkingDirectory: noop, ...overrides }; } diff --git a/test/fixtures/props/github-tab-props.js b/test/fixtures/props/github-tab-props.js index d5ae646f10..de4ccf1695 100644 --- a/test/fixtures/props/github-tab-props.js +++ b/test/fixtures/props/github-tab-props.js @@ -12,6 +12,10 @@ export function gitHubTabItemProps(atomEnv, repository, overrides = {}) { workspace: atomEnv.workspace, repository, loginModel: new GithubLoginModel(InMemoryStrategy), + project: { + getPaths: () => [], + }, + changeProjectWorkingDirectory: () => {}, ...overrides, }; } @@ -39,6 +43,7 @@ export function gitHubTabControllerProps(atomEnv, repository, overrides = {}) { export function gitHubTabViewProps(atomEnv, repository, overrides = {}) { return { + project: atomEnv.project, workspace: atomEnv.workspace, remoteOperationObserver: new OperationStateObserver(repository, PUSH, PULL, FETCH), loginModel: new GithubLoginModel(InMemoryStrategy), @@ -49,12 +54,13 @@ export function gitHubTabViewProps(atomEnv, repository, overrides = {}) { currentBranch: nullBranch, remotes: new RemoteSet(), currentRemote: nullRemote, - manyRemotesAvailable: false, + isSelectingRemote: false, aheadCount: 0, pushInProgress: false, handlePushBranch: () => {}, handleRemoteSelect: () => {}, + changeProjectWorkingDirectory: () => {}, ...overrides, }; diff --git a/test/github-package.test.js b/test/github-package.test.js index 496c52550b..dfb0308011 100644 --- a/test/github-package.test.js +++ b/test/github-package.test.js @@ -141,14 +141,12 @@ describe('GithubPackage', function() { describe('activate()', function() { let atomEnv, githubPackage; - let workspace, project, config; - let configDirPath, contextPool; + let project, config, configDirPath, contextPool; beforeEach(async function() { ({ atomEnv, githubPackage, - workspace, project, - config, configDirPath, contextPool, + project, config, configDirPath, contextPool, } = await buildAtomEnvironmentAndGithubPackage(global.buildAtomEnvironmentAndGithubPackage)); }); @@ -158,7 +156,7 @@ describe('GithubPackage', function() { atomEnv.destroy(); }); - describe('with no project, state, or active pane', function() { + describe('with no projects or state', function() { beforeEach(async function() { await contextUpdateAfter(githubPackage, () => githubPackage.activate()); }); @@ -187,7 +185,7 @@ describe('GithubPackage', function() { }); describe('with only projects', function() { - let workdirPath1, workdirPath2, nonRepositoryPath; + let workdirPath1, workdirPath2, nonRepositoryPath, context1; beforeEach(async function() { ([workdirPath1, workdirPath2, nonRepositoryPath] = await Promise.all([ cloneRepository('three-files'), @@ -197,10 +195,15 @@ describe('GithubPackage', function() { project.setPaths([workdirPath1, workdirPath2, nonRepositoryPath]); await contextUpdateAfter(githubPackage, () => githubPackage.activate()); + + context1 = contextPool.getContext(workdirPath1); }); - it('uses an undetermined repository context', function() { - assert.isTrue(githubPackage.getActiveRepository().isUndetermined()); + it('uses the first project\'s context', function() { + assert.isTrue(context1.isPresent()); + assert.strictEqual(githubPackage.getActiveWorkdir(), workdirPath1); + assert.strictEqual(context1.getRepository(), githubPackage.getActiveRepository()); + assert.strictEqual(context1.getResolutionProgress(), githubPackage.getActiveResolutionProgress()); }); it('creates contexts from preexisting projects', function() { @@ -210,28 +213,6 @@ describe('GithubPackage', function() { }); }); - describe('with projects and an active pane', function() { - let workdirPath1, workdirPath2, context2; - beforeEach(async function() { - ([workdirPath1, workdirPath2] = await Promise.all([ - cloneRepository('three-files'), - cloneRepository('three-files'), - ])); - project.setPaths([workdirPath1, workdirPath2]); - await workspace.open(path.join(workdirPath2, 'a.txt')); - - await contextUpdateAfter(githubPackage, () => githubPackage.activate()); - context2 = contextPool.getContext(workdirPath2); - }); - - it('uses the active pane\'s context', function() { - assert.isTrue(context2.isPresent()); - assert.strictEqual(context2.getRepository(), githubPackage.getActiveRepository()); - assert.strictEqual(context2.getResolutionProgress(), githubPackage.getActiveResolutionProgress()); - assert.strictEqual(githubPackage.getActiveWorkdir(), workdirPath2); - }); - }); - describe('with projects and state', function() { let workdirPath1, workdirPath2, workdirPath3; beforeEach(async function() { @@ -256,31 +237,7 @@ describe('GithubPackage', function() { }); }); - describe('with projects, state, and an active pane', function() { - let workdirPath1, workdirPath2, context2; - beforeEach(async function() { - ([workdirPath1, workdirPath2] = await Promise.all([ - cloneRepository('three-files'), - cloneRepository('three-files'), - ])); - project.setPaths([workdirPath1, workdirPath2]); - await workspace.open(path.join(workdirPath2, 'b.txt')); - - await contextUpdateAfter(githubPackage, () => githubPackage.activate({ - activeRepositoryPath: workdirPath1, - })); - context2 = contextPool.getContext(workdirPath2); - }); - - it('uses the active pane\'s context', function() { - assert.isTrue(context2.isPresent()); - assert.strictEqual(context2.getRepository(), githubPackage.getActiveRepository()); - assert.strictEqual(context2.getResolutionProgress(), githubPackage.getActiveResolutionProgress()); - assert.strictEqual(githubPackage.getActiveWorkdir(), workdirPath2); - }); - }); - - describe('with 1 project and state', function() { + describe('with 1 project and absent state', function() { let workdirPath1, workdirPath2, context1; beforeEach(async function() { ([workdirPath1, workdirPath2] = await Promise.all([ @@ -394,14 +351,12 @@ describe('GithubPackage', function() { describe('scheduleActiveContextUpdate()', function() { let atomEnv, githubPackage; - let workspace, project, commands; - let contextPool; + let project, contextPool; beforeEach(async function() { ({ atomEnv, githubPackage, - workspace, project, commands, - contextPool, + project, contextPool, } = await buildAtomEnvironmentAndGithubPackage(global.buildAtomEnvironmentAndGithubPackage)); }); @@ -434,8 +389,12 @@ describe('GithubPackage', function() { await contextUpdateAfter(githubPackage, () => githubPackage.activate()); }); - it('uses an absent context', function() { - assert.isTrue(githubPackage.getActiveRepository().isUndetermined()); + it('uses the first project\'s context', function() { + const context1 = contextPool.getContext(workdirPath1); + assert.isTrue(context1.isPresent()); + assert.strictEqual(githubPackage.getActiveWorkdir(), workdirPath1); + assert.strictEqual(context1.getRepository(), githubPackage.getActiveRepository()); + assert.strictEqual(context1.getResolutionProgress(), githubPackage.getActiveResolutionProgress()); }); it('has no contexts for projects that are not open', function() { @@ -471,19 +430,8 @@ describe('GithubPackage', function() { assert.isFalse(contextPool.getContext(workdirPath1).isPresent()); }); - it('use an absent guess repo', function() { - assert.isTrue(githubPackage.getActiveRepository().isAbsentGuess()); - }); - }); - - describe('when an active pane is opened', function() { - beforeEach(async function() { - await contextUpdateAfter(githubPackage, () => workspace.open(path.join(workdirPath2, 'b.txt'))); - }); - - it('uses the new active pane\'s context', function() { - const repository2 = contextPool.getContext(workdirPath2).getRepository(); - assert.strictEqual(githubPackage.getActiveRepository(), repository2); + it('uses an absent repo', function() { + assert.isTrue(githubPackage.getActiveRepository().isAbsent()); }); }); }); @@ -503,11 +451,12 @@ describe('GithubPackage', function() { resolutionMergeConflict.reportMarkerCount('modified-on-both-ours.txt', remainingMarkerCount); }); - describe('when opening an in-progress merge-conflict project', function() { + describe('when selecting an in-progress merge-conflict project', function() { let resolutionMergeConflict; beforeEach(async function() { - await workspace.open(path.join(workdirMergeConflict, 'modified-on-both-ours.txt')); - await githubPackage.scheduleActiveContextUpdate(); + await githubPackage.scheduleActiveContextUpdate({ + activeRepositoryPath: workdirMergeConflict, + }); resolutionMergeConflict = contextPool.getContext(workdirMergeConflict).getResolutionProgress(); }); @@ -527,8 +476,9 @@ describe('GithubPackage', function() { describe('when opening a no-conflict repository project', function() { let resolutionNoConflict; beforeEach(async function() { - await workspace.open(path.join(workdirNoConflict, 'b.txt')); - await githubPackage.scheduleActiveContextUpdate(); + await githubPackage.scheduleActiveContextUpdate({ + activeRepositoryPath: workdirNoConflict, + }); resolutionNoConflict = contextPool.getContext(workdirNoConflict).getResolutionProgress(); }); @@ -543,8 +493,9 @@ describe('GithubPackage', function() { describe('when opening a non-repository project', function() { beforeEach(async function() { - await workspace.open(path.join(nonRepositoryPath, 'c.txt')); - await githubPackage.scheduleActiveContextUpdate(); + await githubPackage.scheduleActiveContextUpdate({ + activeRepositoryPath: nonRepositoryPath, + }); }); it('has no active resolution progress', function() { @@ -553,28 +504,27 @@ describe('GithubPackage', function() { }); }); - describe('with projects, state, and an active pane', function() { - let workdirPath1, workdirPath2, workdirPath3, context2; + describe('with projects and absent state', function() { + let workdirPath1, workdirPath2, workdirPath3, context1; beforeEach(async function() { ([workdirPath1, workdirPath2, workdirPath3] = await Promise.all([ cloneRepository('three-files'), cloneRepository('three-files'), cloneRepository('three-files'), ])); - project.setPaths([workdirPath1]); - await workspace.open(path.join(workdirPath2, 'a.txt')); + project.setPaths([workdirPath1, workdirPath2]); await githubPackage.scheduleActiveContextUpdate({ activeRepositoryPath: workdirPath3, }); - context2 = contextPool.getContext(workdirPath2); + context1 = contextPool.getContext(workdirPath1); }); - it('uses the active pane\'s context', function() { - assert.isTrue(context2.isPresent()); - assert.strictEqual(context2.getRepository(), githubPackage.getActiveRepository()); - assert.strictEqual(context2.getResolutionProgress(), githubPackage.getActiveResolutionProgress()); - assert.strictEqual(githubPackage.getActiveWorkdir(), workdirPath2); + it('uses the first project\'s context', function() { + assert.isTrue(context1.isPresent()); + assert.strictEqual(context1.getRepository(), githubPackage.getActiveRepository()); + assert.strictEqual(context1.getResolutionProgress(), githubPackage.getActiveResolutionProgress()); + assert.strictEqual(githubPackage.getActiveWorkdir(), workdirPath1); }); }); @@ -647,69 +597,6 @@ describe('GithubPackage', function() { }); }); - describe('with an active pane in a non-repository project', function() { - beforeEach(async function() { - const nonRepositoryPath = await fs.realpath(temp.mkdirSync()); - const workdir = await cloneRepository('three-files'); - project.setPaths([nonRepositoryPath, workdir]); - await fs.writeFile(path.join(nonRepositoryPath, 'a.txt'), 'stuff', {encoding: 'utf8'}); - - await workspace.open(path.join(nonRepositoryPath, 'a.txt')); - - await githubPackage.scheduleActiveContextUpdate(); - }); - - it('uses and absent context', function() { - assert.isTrue(githubPackage.getActiveRepository().isAbsent()); - }); - }); - - describe('with multiple pane items', function() { - let workdirPath1, workdirPath2, context1; - - beforeEach(async function() { - ([workdirPath1, workdirPath2] = await Promise.all([ - cloneRepository('three-files'), - cloneRepository('three-files'), - ])); - project.setPaths([workdirPath2]); - - await workspace.open(path.join(workdirPath1, 'a.txt')); - commands.dispatch(atomEnv.views.getView(workspace), 'tree-view:toggle-focus'); - workspace.getLeftDock().activate(); - - await githubPackage.scheduleActiveContextUpdate(); - context1 = contextPool.getContext(workdirPath1); - }); - - it('uses the active pane\'s context', function() { - assert.isTrue(context1.isPresent()); - assert.strictEqual(context1.getRepository(), githubPackage.getActiveRepository()); - assert.strictEqual(context1.getResolutionProgress(), githubPackage.getActiveResolutionProgress()); - assert.strictEqual(githubPackage.getActiveWorkdir(), workdirPath1); - }); - }); - - describe('with an active context', function() { - let workdirPath1, workdirPath2; - beforeEach(async function() { - ([workdirPath1, workdirPath2] = await Promise.all([ - cloneRepository('three-files'), - cloneRepository('three-files'), - ])); - project.setPaths([workdirPath1, workdirPath2]); - - contextPool.set([workdirPath1, workdirPath2]); - githubPackage.setActiveContext(contextPool.getContext(workdirPath1)); - - await githubPackage.scheduleActiveContextUpdate(); - }); - - it('uses the active context', function() { - assert.strictEqual(githubPackage.getActiveWorkdir(), workdirPath1); - }); - }); - describe('with a repository project\'s subdirectory', function() { let workdirPath; beforeEach(async function() { @@ -759,7 +646,6 @@ describe('GithubPackage', function() { const symlinkPath = (await fs.realpath(temp.mkdirSync())) + '-symlink'; fs.symlinkSync(workdirPath, symlinkPath); project.setPaths([symlinkPath]); - await workspace.open(path.join(symlinkPath, 'a.txt')); await githubPackage.scheduleActiveContextUpdate(); }); diff --git a/test/views/git-tab-view.test.js b/test/views/git-tab-view.test.js index 93710af685..4ad44b2f71 100644 --- a/test/views/git-tab-view.test.js +++ b/test/views/git-tab-view.test.js @@ -282,4 +282,13 @@ describe('GitTabView', function() { wrapper.instance().focusAndSelectRecentCommit(); assert.isTrue(setFocus.calledWith(GitTabView.focus.RECENT_COMMIT)); }); + + it('calls changeProjectWorkingDirectory when a project is selected', async function() { + const select = sinon.spy(); + const path = 'test/path'; + const wrapper = mount(await buildApp({changeProjectWorkingDirectory: select})); + wrapper.find('.github-Project-path.input-select').simulate('change', {target: {value: path}}); + assert.isTrue(select.calledWith(path)); + wrapper.unmount(); + }); }); diff --git a/test/views/github-tab-view.test.js b/test/views/github-tab-view.test.js index 37b494df63..2bfe3a0a41 100644 --- a/test/views/github-tab-view.test.js +++ b/test/views/github-tab-view.test.js @@ -1,5 +1,5 @@ import React from 'react'; -import {shallow} from 'enzyme'; +import {shallow, mount} from 'enzyme'; import {gitHubTabViewProps} from '../fixtures/props/github-tab-props'; import Repository from '../../lib/models/repository'; @@ -48,7 +48,7 @@ describe('GitHubTabView', function() { const wrapper = shallow(buildApp({ remotes, currentRemote: nullRemote, - manyRemotesAvailable: true, + isSelectingRemote: true, handleRemoteSelect, })); @@ -60,7 +60,16 @@ describe('GitHubTabView', function() { }); it('renders a static message when no remotes are available', function() { - const wrapper = shallow(buildApp({currentRemote: nullRemote, manyRemotesAvailable: false})); + const wrapper = shallow(buildApp({currentRemote: nullRemote, isSelectingRemote: false})); assert.isTrue(wrapper.find('.github-GitHub-noRemotes').exists()); }); + + it('calls changeProjectWorkingDirectory when a project is selected', function() { + const select = sinon.spy(); + const path = 'test/path'; + const wrapper = mount(buildApp({changeProjectWorkingDirectory: select})); + wrapper.find('.github-Project-path.input-select').simulate('change', {target: {value: path}}); + assert.isTrue(select.calledWith(path)); + wrapper.unmount(); + }); }); diff --git a/test/views/header-view.test.js b/test/views/header-view.test.js new file mode 100644 index 0000000000..77d9868202 --- /dev/null +++ b/test/views/header-view.test.js @@ -0,0 +1,33 @@ +import React from 'react'; +import {shallow} from 'enzyme'; +import path from 'path'; + +import HeaderView from '../../lib/views/header-view'; + +describe('HeaderView', function() { + let wrapper, select; + const path1 = 'test/path/project1'; + const path2 = '2nd-test/path/project2'; + const paths = [path1, path2]; + + beforeEach(function() { + select = sinon.spy(); + wrapper = shallow(); + }); + + it('renders an option for all given project paths', function() { + wrapper.find('option').forEach(function(node, index) { + assert.strictEqual(node.props().value, paths[index]); + assert.strictEqual(node.children().text(), path.basename(paths[index])); + }); + }); + + it('selects the current project\'s path', function() { + assert.strictEqual(wrapper.find('select').props().value, path2); + }); + + it('calls handleProjectSelect on select', function() { + wrapper.find('select').simulate('change', {target: {value: path1}}); + assert.isTrue(select.calledWith({target: {value: path1}})); + }); +});