diff --git a/.nycrc.json b/.nycrc.json index b9380f0e5c..7135cec850 100644 --- a/.nycrc.json +++ b/.nycrc.json @@ -6,6 +6,7 @@ ], "exclude": [ "lib/views/git-cache-view.js", - "lib/views/git-timings-view.js" + "lib/views/git-timings-view.js", + "lib/relay-network-layer-manager.js" ] } diff --git a/docs/react-component-atlas.md b/docs/react-component-atlas.md index a585c9aea3..383e3b78eb 100644 --- a/docs/react-component-atlas.md +++ b/docs/react-component-atlas.md @@ -121,6 +121,13 @@ This is a high-level overview of the structure of the React component tree that > > > [``](/lib/views/pr-commit-view.js) > > > > > > Enumerate the commits associated with a pull request. +> > +> > > [``](/lib/containers/pr-changed-files-container.js) +> > > +> > > Show all the changes, separated by files, introduced in a pull request. +> > > +> > > > [``](/lib/controllers/multi-file-patch-controller.js) +> > > > [``](/lib/views/multi-file-patch-view.js) > > > [``](/lib/views/init-dialog.js) > > [``](/lib/views/clone-dialog.js) diff --git a/lib/containers/current-pull-request-container.js b/lib/containers/current-pull-request-container.js index 1f012df810..9930f52180 100644 --- a/lib/containers/current-pull-request-container.js +++ b/lib/containers/current-pull-request-container.js @@ -4,13 +4,16 @@ import {QueryRenderer, graphql} from 'react-relay'; import {Disposable} from 'event-kit'; import {autobind} from '../helpers'; -import {RemotePropType, RemoteSetPropType, BranchSetPropType, OperationStateObserverPropType} from '../prop-types'; +import { + RemotePropType, RemoteSetPropType, BranchSetPropType, OperationStateObserverPropType, EndpointPropType, +} from '../prop-types'; import IssueishListController, {BareIssueishListController} from '../controllers/issueish-list-controller'; import CreatePullRequestTile from '../views/create-pull-request-tile'; import RelayNetworkLayerManager from '../relay-network-layer-manager'; export default class CurrentPullRequestContainer extends React.Component { static propTypes = { + // Relay payload repository: PropTypes.shape({ id: PropTypes.string.isRequired, defaultBranchRef: PropTypes.shape({ @@ -19,9 +22,14 @@ export default class CurrentPullRequestContainer extends React.Component { }), }).isRequired, + // Connection + endpoint: EndpointPropType.isRequired, token: PropTypes.string.isRequired, - host: PropTypes.string.isRequired, + + // Search constraints limit: PropTypes.number, + + // Repository model attributes remoteOperationObserver: OperationStateObserverPropType.isRequired, remote: RemotePropType.isRequired, remotes: RemoteSetPropType.isRequired, @@ -29,6 +37,7 @@ export default class CurrentPullRequestContainer extends React.Component { aheadCount: PropTypes.number, pushInProgress: PropTypes.bool.isRequired, + // Actions onOpenIssueish: PropTypes.func.isRequired, onCreatePr: PropTypes.func.isRequired, } @@ -45,7 +54,7 @@ export default class CurrentPullRequestContainer extends React.Component { } render() { - const environment = RelayNetworkLayerManager.getEnvironmentForHost(this.props.host, this.props.token); + const environment = RelayNetworkLayerManager.getEnvironmentForHost(this.props.endpoint, this.props.token); const head = this.props.branches.getHeadBranch(); if (!head.isPresent()) { diff --git a/lib/containers/issueish-detail-container.js b/lib/containers/issueish-detail-container.js index ff9e419b80..c428b9fe47 100644 --- a/lib/containers/issueish-detail-container.js +++ b/lib/containers/issueish-detail-container.js @@ -3,31 +3,45 @@ import PropTypes from 'prop-types'; import yubikiri from 'yubikiri'; import {QueryRenderer, graphql} from 'react-relay'; +import {autobind} from '../helpers'; import RelayNetworkLayerManager from '../relay-network-layer-manager'; -import {GithubLoginModelPropType} from '../prop-types'; +import {GithubLoginModelPropType, ItemTypePropType, EndpointPropType} from '../prop-types'; import {UNAUTHENTICATED, INSUFFICIENT} from '../shared/keytar-strategy'; import GithubLoginView from '../views/github-login-view'; import LoadingView from '../views/loading-view'; import QueryErrorView from '../views/query-error-view'; import ObserveModel from '../views/observe-model'; -import IssueishDetailController from '../controllers/issueish-detail-controller'; import RelayEnvironment from '../views/relay-environment'; -import {autobind} from '../helpers'; +import IssueishDetailController from '../controllers/issueish-detail-controller'; export default class IssueishDetailContainer extends React.Component { static propTypes = { - host: PropTypes.string, + // Connection + endpoint: EndpointPropType.isRequired, + + // Issueish selection criteria owner: PropTypes.string.isRequired, repo: PropTypes.string.isRequired, issueishNumber: PropTypes.number.isRequired, + // Package models repository: PropTypes.object.isRequired, loginModel: GithubLoginModelPropType.isRequired, + // Atom environment + workspace: PropTypes.object.isRequired, + commands: PropTypes.object.isRequired, + keymaps: PropTypes.object.isRequired, + tooltips: PropTypes.object.isRequired, + config: PropTypes.object.isRequired, + + // Action methods switchToIssueish: PropTypes.func.isRequired, onTitleChange: PropTypes.func.isRequired, + destroy: PropTypes.func.isRequired, - workspace: PropTypes.object.isRequired, + // Item context + itemType: ItemTypePropType.isRequired, } constructor(props) { @@ -42,7 +56,7 @@ export default class IssueishDetailContainer extends React.Component { fetchToken(loginModel) { return yubikiri({ - token: loginModel.getToken(this.props.host), + token: loginModel.getToken(this.props.endpoint.getLoginAccount()), }); } @@ -97,7 +111,7 @@ export default class IssueishDetailContainer extends React.Component { return ; } - const environment = RelayNetworkLayerManager.getEnvironmentForHost(this.props.host, token); + const environment = RelayNetworkLayerManager.getEnvironmentForHost(this.props.endpoint, token); const query = graphql` query issueishDetailContainerQuery ( @@ -136,13 +150,13 @@ export default class IssueishDetailContainer extends React.Component { environment={environment} query={query} variables={variables} - render={queryResult => this.renderWithResult(queryResult, repoData)} + render={queryResult => this.renderWithResult(queryResult, repoData, token)} /> ); } - renderWithResult({error, props, retry}, repoData) { + renderWithResult({error, props, retry}, repoData, token) { if (error) { return ( ); } handleLogin(token) { - return this.props.loginModel.setToken(this.props.host, token); + return this.props.loginModel.setToken(this.props.endpoint.getLoginAccount(), token); } handleLogout() { - return this.props.loginModel.removeToken(this.props.host); + return this.props.loginModel.removeToken(this.props.endpoint.getLoginAccount()); } } diff --git a/lib/containers/issueish-search-container.js b/lib/containers/issueish-search-container.js index a937113658..4566ccec3e 100644 --- a/lib/containers/issueish-search-container.js +++ b/lib/containers/issueish-search-container.js @@ -4,18 +4,22 @@ import {QueryRenderer, graphql} from 'react-relay'; import {Disposable} from 'event-kit'; import {autobind} from '../helpers'; -import {SearchPropType, OperationStateObserverPropType} from '../prop-types'; +import {SearchPropType, OperationStateObserverPropType, EndpointPropType} from '../prop-types'; import IssueishListController, {BareIssueishListController} from '../controllers/issueish-list-controller'; import RelayNetworkLayerManager from '../relay-network-layer-manager'; export default class IssueishSearchContainer extends React.Component { static propTypes = { + // Connection information + endpoint: EndpointPropType.isRequired, token: PropTypes.string.isRequired, - host: PropTypes.string.isRequired, + + // Search model limit: PropTypes.number, search: SearchPropType.isRequired, remoteOperationObserver: OperationStateObserverPropType.isRequired, + // Action methods onOpenIssueish: PropTypes.func.isRequired, onOpenSearch: PropTypes.func.isRequired, } @@ -32,7 +36,7 @@ export default class IssueishSearchContainer extends React.Component { } render() { - const environment = RelayNetworkLayerManager.getEnvironmentForHost(this.props.host, this.props.token); + const environment = RelayNetworkLayerManager.getEnvironmentForHost(this.props.endpoint, this.props.token); if (this.props.search.isNull()) { return ( diff --git a/lib/containers/pr-changed-files-container.js b/lib/containers/pr-changed-files-container.js new file mode 100644 index 0000000000..ece3bf4a82 --- /dev/null +++ b/lib/containers/pr-changed-files-container.js @@ -0,0 +1,118 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import {parse as parseDiff} from 'what-the-diff'; + +import {ItemTypePropType, EndpointPropType} from '../prop-types'; +import MultiFilePatchController from '../controllers/multi-file-patch-controller'; +import LoadingView from '../views/loading-view'; +import ErrorView from '../views/error-view'; +import {buildMultiFilePatch} from '../models/patch'; + +export default class PullRequestChangedFilesContainer extends React.Component { + static propTypes = { + // Pull request properties + owner: PropTypes.string.isRequired, + repo: PropTypes.string.isRequired, + number: PropTypes.number.isRequired, + + // Connection properties + endpoint: EndpointPropType.isRequired, + token: PropTypes.string.isRequired, + + // Item context + itemType: ItemTypePropType.isRequired, + + // action methods + destroy: PropTypes.func.isRequired, + + // Atom environment + workspace: PropTypes.object.isRequired, + commands: PropTypes.object.isRequired, + keymaps: PropTypes.object.isRequired, + tooltips: PropTypes.object.isRequired, + config: PropTypes.object.isRequired, + + // local repo as opposed to pull request repo + localRepository: PropTypes.object.isRequired, + + // refetch diff on refresh + shouldRefetch: PropTypes.bool.isRequired, + } + + constructor(props) { + super(props); + this.state = {isLoading: true, error: null}; + this.fetchDiff(); + } + + componentDidUpdate(prevProps) { + if (this.props.shouldRefetch && !prevProps.shouldRefetch) { + this.setState({isLoading: true, error: null}); + this.fetchDiff(); + } + } + + // Generate a v3 GitHub API REST URL for the pull request resource. + // Example: https://api.github.com/repos/atom/github/pulls/1829 + getDiffURL() { + return this.props.endpoint.getRestURI('repos', this.props.owner, this.props.repo, 'pulls', this.props.number); + } + + buildPatch(rawDiff) { + const diffs = parseDiff(rawDiff); + return buildMultiFilePatch(diffs); + } + + async fetchDiff() { + const diffError = (message, err = null) => new Promise(resolve => { + if (err) { + // eslint-disable-next-line no-console + console.error(err); + } + this.setState({isLoading: false, error: message}, resolve); + }); + const url = this.getDiffURL(); + + const response = await fetch(url, { + headers: { + Accept: 'application/vnd.github.v3.diff', + Authorization: `bearer ${this.props.token}`, + }, + // eslint-disable-next-line handle-callback-err + }).catch(err => { + diffError(`Network error encountered at fetching ${url}`, err); + }); + if (this.state.error) { + return; + } + try { + if (response && response.ok) { + const rawDiff = await response.text(); + const multiFilePatch = this.buildPatch(rawDiff); + await new Promise(resolve => this.setState({isLoading: false, multiFilePatch}, resolve)); + } else { + diffError(`Unable to fetch diff for this pull request${response ? ': ' + response.statusText : ''}.`); + } + } catch (err) { + diffError('Unable to parse diff for this pull request.', err); + } + } + + render() { + if (this.state.isLoading) { + return ; + } + + if (this.state.error) { + return ; + } + + return ( + + ); + } +} diff --git a/lib/containers/remote-container.js b/lib/containers/remote-container.js index fc68447a1f..e4aec30e9a 100644 --- a/lib/containers/remote-container.js +++ b/lib/containers/remote-container.js @@ -4,7 +4,9 @@ import {QueryRenderer, graphql} from 'react-relay'; import {incrementCounter} from '../reporter-proxy'; import {autobind} from '../helpers'; -import {RemotePropType, RemoteSetPropType, BranchSetPropType, OperationStateObserverPropType} from '../prop-types'; +import { + RemotePropType, RemoteSetPropType, BranchSetPropType, OperationStateObserverPropType, EndpointPropType, +} from '../prop-types'; import RelayNetworkLayerManager from '../relay-network-layer-manager'; import {UNAUTHENTICATED, INSUFFICIENT} from '../shared/keytar-strategy'; import RemoteController from '../controllers/remote-controller'; @@ -15,20 +17,21 @@ import GithubLoginView from '../views/github-login-view'; export default class RemoteContainer extends React.Component { static propTypes = { + // Connection loginModel: PropTypes.object.isRequired, + endpoint: EndpointPropType.isRequired, - host: PropTypes.string.isRequired, - + // Repository attributes remoteOperationObserver: OperationStateObserverPropType.isRequired, + pushInProgress: PropTypes.bool.isRequired, workingDirectory: PropTypes.string.isRequired, workspace: PropTypes.object.isRequired, remote: RemotePropType.isRequired, remotes: RemoteSetPropType.isRequired, branches: BranchSetPropType.isRequired, - aheadCount: PropTypes.number, - pushInProgress: PropTypes.bool.isRequired, + // Action methods onPushBranch: PropTypes.func.isRequired, } @@ -39,7 +42,7 @@ export default class RemoteContainer extends React.Component { } fetchToken(loginModel) { - return loginModel.getToken(this.props.host); + return loginModel.getToken(this.props.endpoint.getLoginAccount()); } render() { @@ -69,7 +72,7 @@ export default class RemoteContainer extends React.Component { ); } - const environment = RelayNetworkLayerManager.getEnvironmentForHost(this.props.host, token); + const environment = RelayNetworkLayerManager.getEnvironmentForHost(this.props.endpoint, token); const query = graphql` query remoteContainerQuery($owner: String!, $name: String!) { repository(owner: $owner, name: $name) { @@ -114,7 +117,7 @@ export default class RemoteContainer extends React.Component { return ( ); } else { @@ -246,6 +277,11 @@ export class BareIssueishDetailController extends React.Component { } openCommit = async ({sha}) => { + /* istanbul ignore if */ + if (!this.props.workdirPath) { + return; + } + const uri = CommitDetailItem.buildURI(this.props.workdirPath, sha); await this.props.workspace.open(uri, {pending: true}); addEvent('open-commit-in-pane', {package: 'github', from: this.constructor.name}); diff --git a/lib/controllers/issueish-searches-controller.js b/lib/controllers/issueish-searches-controller.js index 02ea32e5a4..65b07b7f42 100644 --- a/lib/controllers/issueish-searches-controller.js +++ b/lib/controllers/issueish-searches-controller.js @@ -3,7 +3,9 @@ import PropTypes from 'prop-types'; import {shell} from 'electron'; import {autobind} from '../helpers'; -import {RemotePropType, RemoteSetPropType, BranchSetPropType, OperationStateObserverPropType} from '../prop-types'; +import { + RemotePropType, RemoteSetPropType, BranchSetPropType, OperationStateObserverPropType, EndpointPropType, +} from '../prop-types'; import Search from '../models/search'; import IssueishSearchContainer from '../containers/issueish-search-container'; import CurrentPullRequestContainer from '../containers/current-pull-request-container'; @@ -12,10 +14,7 @@ import {addEvent} from '../reporter-proxy'; export default class IssueishSearchesController extends React.Component { static propTypes = { - host: PropTypes.string.isRequired, - token: PropTypes.string.isRequired, - workspace: PropTypes.object.isRequired, - + // Relay payload repository: PropTypes.shape({ id: PropTypes.string.isRequired, defaultBranchRef: PropTypes.shape({ @@ -24,6 +23,14 @@ export default class IssueishSearchesController extends React.Component { }), }), + // Connection + endpoint: EndpointPropType.isRequired, + token: PropTypes.string.isRequired, + + // Atom environment + workspace: PropTypes.object.isRequired, + + // Repository model attributes remoteOperationObserver: OperationStateObserverPropType.isRequired, workingDirectory: PropTypes.string.isRequired, remote: RemotePropType.isRequired, @@ -32,6 +39,7 @@ export default class IssueishSearchesController extends React.Component { aheadCount: PropTypes.number, pushInProgress: PropTypes.bool.isRequired, + // Actions onCreatePr: PropTypes.func.isRequired, } @@ -56,7 +64,7 @@ export default class IssueishSearchesController extends React.Component { )} @@ -584,7 +588,7 @@ export default class RootController extends React.Component { } acceptOpenIssueish({repoOwner, repoName, issueishNumber}) { - const uri = IssueishDetailItem.buildURI('https://api.github.com', repoOwner, repoName, issueishNumber); + const uri = IssueishDetailItem.buildURI('github.com', repoOwner, repoName, issueishNumber); this.setState({openIssueishDialogActive: false}); this.props.workspace.open(uri).then(() => { addEvent('open-issueish-in-pane', {package: 'github', from: 'dialog'}); diff --git a/lib/github-package.js b/lib/github-package.js index a0ed479710..092c8eb6fa 100644 --- a/lib/github-package.js +++ b/lib/github-package.js @@ -181,12 +181,7 @@ export default class GithubPackage { 'github-package-styles', ['editor.fontSize', 'editor.fontFamily', 'editor.lineHeight', 'editor.tabLength'], config => ` - .github-FilePatchView { - font-size: 1.1em; - } - .github-HunkView-line { - font-size: ${config.get('editor.fontSize')}px; font-family: ${config.get('editor.fontFamily')}; line-height: ${config.get('editor.lineHeight')}; tab-size: ${config.get('editor.tabLength')} diff --git a/lib/items/issueish-detail-item.js b/lib/items/issueish-detail-item.js index 88c0bd78d3..a7e4fd695f 100644 --- a/lib/items/issueish-detail-item.js +++ b/lib/items/issueish-detail-item.js @@ -4,22 +4,31 @@ import {Emitter} from 'event-kit'; import {autobind} from '../helpers'; import {GithubLoginModelPropType, WorkdirContextPoolPropType} from '../prop-types'; +import {addEvent} from '../reporter-proxy'; import Repository from '../models/repository'; +import {getEndpoint} from '../models/endpoint'; import IssueishDetailContainer from '../containers/issueish-detail-container'; -import {addEvent} from '../reporter-proxy'; export default class IssueishDetailItem extends Component { static propTypes = { + // Issueish selection criteria + // Parsed from item URI host: PropTypes.string.isRequired, owner: PropTypes.string.isRequired, repo: PropTypes.string.isRequired, issueishNumber: PropTypes.number.isRequired, - workingDirectory: PropTypes.string.isRequired, + + // Package models workdirContextPool: WorkdirContextPoolPropType.isRequired, loginModel: GithubLoginModelPropType.isRequired, + // Atom environment workspace: PropTypes.object.isRequired, + commands: PropTypes.object.isRequired, + keymaps: PropTypes.object.isRequired, + tooltips: PropTypes.object.isRequired, + config: PropTypes.object.isRequired, } static uriPattern = 'atom-github://issueish/{host}/{owner}/{repo}/{issueishNumber}?workdir={workingDirectory}' @@ -62,7 +71,7 @@ export default class IssueishDetailItem extends Component { render() { return ( ); } @@ -138,6 +154,14 @@ export default class IssueishDetailItem extends Component { return this.emitter.on('did-terminate-pending-state', callback); } + destroy = () => { + /* istanbul ignore else */ + if (!this.isDestroyed) { + this.emitter.emit('did-destroy'); + this.isDestroyed = true; + } + } + onDidDestroy(callback) { return this.emitter.on('did-destroy', callback); } diff --git a/lib/models/enableable-operation.js b/lib/models/enableable-operation.js index bd651cb5e3..4a69c8dce8 100644 --- a/lib/models/enableable-operation.js +++ b/lib/models/enableable-operation.js @@ -3,7 +3,7 @@ const ENABLED = Symbol('enabled'); const NO_REASON = Symbol('no-reason'); // Track an operation that may be either enabled or disabled with a message and a reason. EnableableOperation instances -// are immutable to aid passing them as React comopnent props; call `.enable()` or `.disable()` to derive a new +// are immutable to aid passing them as React component props; call `.enable()` or `.disable()` to derive a new // operation instance with the same callback. export default class EnableableOperation { constructor(op, options = {}) { diff --git a/lib/models/endpoint.js b/lib/models/endpoint.js new file mode 100644 index 0000000000..3fc5fd2fef --- /dev/null +++ b/lib/models/endpoint.js @@ -0,0 +1,41 @@ +// API endpoint for a GitHub instance, either dotcom or an Enterprise installation. +class Endpoint { + constructor(host, apiHost, apiRouteParts) { + this.host = host; + this.apiHost = apiHost; + this.apiRoute = apiRouteParts.map(encodeURIComponent).join('/'); + } + + getRestURI(...parts) { + const sep = parts.length > 0 ? '/' : ''; + return this.getRestRoot() + sep + parts.map(encodeURIComponent).join('/'); + } + + getGraphQLRoot() { + return this.getRestURI('graphql'); + } + + getRestRoot() { + const sep = this.apiRoute !== '' ? '/' : ''; + return `https://${this.apiHost}${sep}${this.apiRoute}`; + } + + getHost() { + return this.host; + } + + getLoginAccount() { + return `https://${this.apiHost}`; + } +} + +// API endpoint for GitHub.com +const dotcomEndpoint = new Endpoint('github.com', 'api.github.com', []); + +export function getEndpoint(host) { + if (host === 'github.com') { + return dotcomEndpoint; + } else { + return new Endpoint(host, host, ['api', 'v3']); + } +} diff --git a/lib/models/remote.js b/lib/models/remote.js index d9a6248131..a5a441b8db 100644 --- a/lib/models/remote.js +++ b/lib/models/remote.js @@ -1,3 +1,5 @@ +import {getEndpoint} from './endpoint'; + export default class Remote { constructor(name, url) { this.name = name; @@ -39,7 +41,7 @@ export default class Remote { return this.repo; } - getNameOr(fallback) { + getNameOr() { return this.getName(); } @@ -51,6 +53,10 @@ export default class Remote { return `${this.owner}/${this.repo}`; } + getEndpoint() { + return this.domain === null ? null : getEndpoint(this.domain); + } + isPresent() { return true; } @@ -125,6 +131,10 @@ export const nullRemote = { return null; }, + getEndpoint() { + return null; + }, + isPresent() { return false; }, diff --git a/lib/models/user-store.js b/lib/models/user-store.js index 90ffd5acb6..ae11ba48a2 100644 --- a/lib/models/user-store.js +++ b/lib/models/user-store.js @@ -113,6 +113,17 @@ export default class UserStore { ); } + async getToken(loginModel, loginAccount) { + if (!loginModel) { + return null; + } + const token = await loginModel.getToken(loginAccount); + if (token === UNAUTHENTICATED || token === INSUFFICIENT) { + return null; + } + return token; + } + async loadMentionableUsers(remote) { const cached = this.cache.get(remote); if (cached !== null) { @@ -120,17 +131,13 @@ export default class UserStore { return; } - const loginModel = this.loginObserver.getActiveModel(); - if (!loginModel) { - return; - } - - const token = await loginModel.getToken('https://api.github.com'); - if (token === UNAUTHENTICATED || token === INSUFFICIENT) { + const endpoint = remote.getEndpoint(); + const token = await this.getToken(this.loginObserver.getActiveModel(), endpoint.getLoginAccount()); + if (!token) { return; } - const fetchQuery = RelayNetworkLayerManager.getFetchQuery('https://api.github.com/graphql', token); + const fetchQuery = RelayNetworkLayerManager.getFetchQuery(endpoint, token); let hasMore = true; let cursor = null; diff --git a/lib/prop-types.js b/lib/prop-types.js index f00c709f8f..78fd797320 100644 --- a/lib/prop-types.js +++ b/lib/prop-types.js @@ -28,6 +28,13 @@ export const RemotePropType = PropTypes.shape({ isGithubRepo: PropTypes.func.isRequired, getOwner: PropTypes.func.isRequired, getRepo: PropTypes.func.isRequired, + getEndpoint: PropTypes.func.isRequired, +}); + +export const EndpointPropType = PropTypes.shape({ + getGraphQLRoot: PropTypes.func.isRequired, + getRestRoot: PropTypes.func.isRequired, + getRestURI: PropTypes.func.isRequired, }); export const BranchPropType = PropTypes.shape({ @@ -155,3 +162,42 @@ export const UserStorePropType = PropTypes.shape({ getUsers: PropTypes.func.isRequired, onDidUpdate: PropTypes.func.isRequired, }); + +// Require item classes lazily to prevent circular imports +let lazyItemConstructors = null; +function createItemTypePropType(required) { + return function(props, propName, componentName) { + if (lazyItemConstructors === null) { + lazyItemConstructors = new Set(); + for (const itemPath of [ + './items/changed-file-item', + './items/commit-preview-item', + './items/commit-detail-item', + './items/issueish-detail-item', + ]) { + lazyItemConstructors.add(require(itemPath).default); + } + } + + if (props[propName] === undefined || props[propName] === null) { + /* istanbul ignore else */ + if (required) { + return new Error(`Missing required prop ${propName} on component ${componentName}.`); + } else { + return undefined; + } + } + + /* istanbul ignore if */ + if (!lazyItemConstructors.has(props[propName])) { + const choices = Array.from(lazyItemConstructors, each => each.name).join(', '); + return new Error( + `Invalid prop "${propName}" supplied to ${componentName}. Must be one of ${choices}.`); + } + + return undefined; + }; +} + +export const ItemTypePropType = createItemTypePropType(false); +ItemTypePropType.isRequired = createItemTypePropType(true); diff --git a/lib/relay-network-layer-manager.js b/lib/relay-network-layer-manager.js index 00be606c43..fd6abf2861 100644 --- a/lib/relay-network-layer-manager.js +++ b/lib/relay-network-layer-manager.js @@ -2,7 +2,11 @@ import util from 'util'; import {Environment, Network, RecordSource, Store} from 'relay-runtime'; import moment from 'moment'; -const relayEnvironmentPerGithubHost = new Map(); +const relayEnvironmentPerURL = new Map(); +const tokenPerURL = new Map(); +const fetchPerURL = new Map(); + +const responsesByQuery = new Map(); function logRatelimitApi(headers) { const remaining = headers.get('x-ratelimit-remaining'); @@ -14,8 +18,6 @@ function logRatelimitApi(headers) { console.debug(`GitHub API Rate Limit: ${remaining}/${total} — resets ${resetsIn}`); } -const responsesByQuery = new Map(); - export function expectRelayQuery(operationPattern, response) { let resolve, reject; const promise = new Promise((resolve0, reject0) => { @@ -41,12 +43,9 @@ export function clearRelayExpectations() { responsesByQuery.clear(); } -const tokenPerURL = new Map(); -const fetchPerURL = new Map(); - function createFetchQuery(url) { if (atom.inSpecMode()) { - return function specFetchQuery(operation, variables, cacheConfig, uploadables) { + return function specFetchQuery(operation, variables, _cacheConfig, _uploadables) { const expectations = responsesByQuery.get(operation.name) || []; const match = expectations.find(expectation => { if (Object.keys(expectation.variables).length !== Object.keys(variables).length) { @@ -89,7 +88,7 @@ function createFetchQuery(url) { }; } - return async function fetchQuery(operation, variables, cacheConfig, uploadables) { + return async function fetchQuery(operation, variables, _cacheConfig, _uploadables) { const currentToken = tokenPerURL.get(url); const response = await fetch(url, { @@ -132,23 +131,23 @@ function createFetchQuery(url) { } export default class RelayNetworkLayerManager { - static getEnvironmentForHost(host, token) { - host = host === 'github.com' ? 'https://api.github.com' : host; - const url = host === 'https://api.github.com' ? `${host}/graphql` : `${host}/api/v3/graphql`; - let {environment, network} = relayEnvironmentPerGithubHost.get(host) || {}; + static getEnvironmentForHost(endpoint, token) { + const url = endpoint.getGraphQLRoot(); + let {environment, network} = relayEnvironmentPerURL.get(url) || {}; tokenPerURL.set(url, token); if (!environment) { const source = new RecordSource(); const store = new Store(source); - network = Network.create(this.getFetchQuery(url, token)); + network = Network.create(this.getFetchQuery(endpoint, token)); environment = new Environment({network, store}); - relayEnvironmentPerGithubHost.set(host, {environment, network}); + relayEnvironmentPerURL.set(url, {environment, network}); } return environment; } - static getFetchQuery(url, token) { + static getFetchQuery(endpoint, token) { + const url = endpoint.getGraphQLRoot(); tokenPerURL.set(url, token); let fetch = fetchPerURL.get(url); if (!fetch) { diff --git a/lib/views/error-view.js b/lib/views/error-view.js index a8d8067997..6d1228bdf6 100644 --- a/lib/views/error-view.js +++ b/lib/views/error-view.js @@ -35,7 +35,9 @@ export default class ErrorView extends React.Component { {this.props.retry && ( )} - + {this.props.logout && ( + + )} diff --git a/lib/views/file-patch-header-view.js b/lib/views/file-patch-header-view.js index 51131689f0..eba8278094 100644 --- a/lib/views/file-patch-header-view.js +++ b/lib/views/file-patch-header-view.js @@ -5,9 +5,10 @@ import PropTypes from 'prop-types'; import cx from 'classnames'; import RefHolder from '../models/ref-holder'; +import IssueishDetailItem from '../items/issueish-detail-item'; import ChangedFileItem from '../items/changed-file-item'; -import CommitPreviewItem from '../items/commit-preview-item'; import CommitDetailItem from '../items/commit-detail-item'; +import {ItemTypePropType} from '../prop-types'; export default class FilePatchHeaderView extends React.Component { static propTypes = { @@ -25,7 +26,7 @@ export default class FilePatchHeaderView extends React.Component { openFile: PropTypes.func.isRequired, toggleFile: PropTypes.func.isRequired, - itemType: PropTypes.oneOf([ChangedFileItem, CommitPreviewItem, CommitDetailItem]).isRequired, + itemType: ItemTypePropType.isRequired, }; constructor(props) { @@ -73,7 +74,7 @@ export default class FilePatchHeaderView extends React.Component { } renderButtonGroup() { - if (this.props.itemType === CommitDetailItem) { + if (this.props.itemType === CommitDetailItem || this.props.itemType === IssueishDetailItem) { return null; } else { return ( diff --git a/lib/views/file-patch-meta-view.js b/lib/views/file-patch-meta-view.js index d39dbfcb60..16b072440a 100644 --- a/lib/views/file-patch-meta-view.js +++ b/lib/views/file-patch-meta-view.js @@ -1,9 +1,10 @@ import React from 'react'; import PropTypes from 'prop-types'; import cx from 'classnames'; + import CommitDetailItem from '../items/commit-detail-item'; -import ChangedFileItem from '../items/changed-file-item'; -import CommitPreviewItem from '../items/commit-preview-item'; +import IssueishDetailItem from '../items/issueish-detail-item'; +import {ItemTypePropType} from '../prop-types'; export default class FilePatchMetaView extends React.Component { static propTypes = { @@ -14,11 +15,11 @@ export default class FilePatchMetaView extends React.Component { action: PropTypes.func.isRequired, children: PropTypes.element.isRequired, - itemType: PropTypes.oneOf([ChangedFileItem, CommitPreviewItem, CommitDetailItem]).isRequired, + itemType: ItemTypePropType.isRequired, }; renderMetaControls() { - if (this.props.itemType === CommitDetailItem) { + if (this.props.itemType === CommitDetailItem || this.props.itemType === IssueishDetailItem) { return null; } return ( diff --git a/lib/views/github-tab-view.js b/lib/views/github-tab-view.js index 4c8bc73a4b..cef66f9779 100644 --- a/lib/views/github-tab-view.js +++ b/lib/views/github-tab-view.js @@ -47,20 +47,21 @@ export default class GitHubTabView extends React.Component { if (this.props.currentRemote.isPresent()) { // Single, chosen or unambiguous remote - // only supporting GH.com for now, hardcoded values return ( this.props.handlePushBranch(this.props.currentBranch, this.props.currentRemote)} remote={this.props.currentRemote} remotes={this.props.remotes} branches={this.props.branches} aheadCount={this.props.aheadCount} - pushInProgress={this.props.pushInProgress} + + onPushBranch={() => this.props.handlePushBranch(this.props.currentBranch, this.props.currentRemote)} /> ); } diff --git a/lib/views/hunk-header-view.js b/lib/views/hunk-header-view.js index a2ed357015..89243a27c9 100644 --- a/lib/views/hunk-header-view.js +++ b/lib/views/hunk-header-view.js @@ -3,13 +3,12 @@ import PropTypes from 'prop-types'; import cx from 'classnames'; import {autobind} from '../helpers'; -import {RefHolderPropType} from '../prop-types'; +import {RefHolderPropType, ItemTypePropType} from '../prop-types'; import RefHolder from '../models/ref-holder'; import Tooltip from '../atom/tooltip'; import Keystroke from '../atom/keystroke'; -import ChangedFileItem from '../items/changed-file-item'; -import CommitPreviewItem from '../items/commit-preview-item'; import CommitDetailItem from '../items/commit-detail-item'; +import IssueishDetailItem from '../items/issueish-detail-item'; function theBuckStopsHere(event) { event.stopPropagation(); @@ -31,7 +30,7 @@ export default class HunkHeaderView extends React.Component { toggleSelection: PropTypes.func, discardSelection: PropTypes.func, mouseDown: PropTypes.func.isRequired, - itemType: PropTypes.oneOf([ChangedFileItem, CommitPreviewItem, CommitDetailItem]).isRequired, + itemType: ItemTypePropType.isRequired, }; constructor(props) { @@ -58,7 +57,7 @@ export default class HunkHeaderView extends React.Component { } renderButtons() { - if (this.props.itemType === CommitDetailItem) { + if (this.props.itemType === CommitDetailItem || this.props.itemType === IssueishDetailItem) { return null; } else { return ( diff --git a/lib/views/issue-detail-view.js b/lib/views/issue-detail-view.js index 36842041fe..54d07e74cb 100644 --- a/lib/views/issue-detail-view.js +++ b/lib/views/issue-detail-view.js @@ -1,4 +1,4 @@ -import React, {Fragment} from 'react'; +import React from 'react'; import {graphql, createRefetchContainer} from 'react-relay'; import PropTypes from 'prop-types'; import cx from 'classnames'; @@ -71,7 +71,7 @@ export class BareIssueDetailView extends React.Component { renderIssueBody(issue) { return ( - +
No description provided.'} switchToIssueish={this.props.switchToIssueish} @@ -81,7 +81,7 @@ export class BareIssueDetailView extends React.Component { issue={issue} switchToIssueish={this.props.switchToIssueish} /> - +
); } @@ -93,31 +93,30 @@ export class BareIssueDetailView extends React.Component {
-
+ {this.renderIssueBody(issue)}