diff --git a/lib/atom-items/issueish-pane-item.js b/lib/atom-items/issueish-pane-item.js index 9b1cc4aeff..f3e6a3d4bd 100644 --- a/lib/atom-items/issueish-pane-item.js +++ b/lib/atom-items/issueish-pane-item.js @@ -1,85 +1,67 @@ import {Emitter} from 'event-kit'; -import url from 'url'; - -import React from 'react'; -import ReactDom from 'react-dom'; +import React, {Component} from 'react'; +import PropTypes from 'prop-types'; import {autobind} from 'core-decorators'; import IssueishPaneItemController from '../controllers/issueish-pane-item-controller'; import GithubLoginModel from '../models/github-login-model'; -function getPropsFromUri(uri) { - // atom-github://issueish/https://github-host.tld/owner/repo/issueishNumber - const {protocol, hostname, pathname} = url.parse(uri); - if (protocol === 'atom-github:' && hostname === 'issueish') { - const [scheme, host, owner, repo, issueishNum] = pathname.split('/').filter(s => s); - if (!scheme || !host || !owner || !repo || !issueishNum) { return null; } - const issueishNumber = parseInt(issueishNum, 10); - if (isNaN(issueishNumber)) { return null; } - return {owner, repo, issueishNumber, host: `${scheme}//${host}`}; +export default class IssueishPaneItem extends Component { + static propTypes = { + uri: PropTypes.string.isRequired, + host: PropTypes.string.isRequired, + owner: PropTypes.string.isRequired, + repo: PropTypes.string.isRequired, + issueishNumber: PropTypes.number.isRequired, } - return null; -} -export default class IssueishPaneItem { - static opener(uri) { - const props = getPropsFromUri(uri); - if (props) { - return IssueishPaneItem.create(props, uri); - } else { - return null; - } - } + static uriPattern = 'atom-github://issueish/{host}/{owner}/{repo}/{number}' - static create({owner, repo, issueishNumber, host}, uri) { - return new IssueishPaneItem({owner, repo, issueishNumber, host}, uri); + static buildURI(host, owner, repo, number) { + return 'atom-github://issueish/' + + encodeURIComponent(host) + '/' + + encodeURIComponent(owner) + '/' + + encodeURIComponent(repo) + '/' + + encodeURIComponent(number); } - constructor({owner, repo, issueishNumber, host}, uri) { - this.emitter = new Emitter(); + constructor(props) { + super(props); - this.owner = owner; - this.repo = repo; - this.issueishNumber = issueishNumber; - this.host = host; - this.uri = uri; - this.title = `${this.owner}/${this.repo}#${this.issueishNumber}`; + this.emitter = new Emitter(); + this.title = `${this.props.owner}/${this.props.repo}#${this.props.issueishNumber}`; + this.loginModel = GithubLoginModel.get(); - this.createComponent(); + this.state = {}; } - createComponent() { - this.element = document.createElement('div'); - const loginModel = GithubLoginModel.get(); - this.props = { - owner: this.owner, - repo: this.repo, - issueishNumber: this.issueishNumber, - host: this.host, - loginModel, + static getDerivedStateFromProps(nextProps, prevState) { + return { + host: nextProps.host, + owner: nextProps.owner, + repo: nextProps.repo, + issueishNumber: nextProps.issueishNumber, }; - this.render(); } render() { - this.component = ReactDom.render( + return ( , - this.element, + /> ); } @autobind switchToIssueish(owner, repo, issueishNumber) { - this.owner = owner; - this.repo = repo; - this.issueishNumber = issueishNumber; - this.props = {...this.props, owner, repo, issueishNumber}; - this.render(); + this.setState({owner, repo, issueishNumber}); } @autobind @@ -94,27 +76,18 @@ export default class IssueishPaneItem { return this.emitter.on('did-change-title', cb); } - getElement() { - return this.element; - } - serialize() { return { - uri: this.uri, + uri: this.props.uri, deserializer: 'IssueishPaneItem', }; } getURI() { - return this.uri; + return this.props.uri; } getTitle() { return this.title; } - - destroy() { - this.emitter.dispose(); - ReactDom.unmountComponentAtNode(this.getElement()); - } } diff --git a/lib/atom/pane-item.js b/lib/atom/pane-item.js new file mode 100644 index 0000000000..53692969c9 --- /dev/null +++ b/lib/atom/pane-item.js @@ -0,0 +1,155 @@ +import React, {Fragment} from 'react'; +import ReactDOM from 'react-dom'; +import PropTypes from 'prop-types'; +import {CompositeDisposable} from 'event-kit'; + +import URIPattern, {nonURIMatch} from './uri-pattern'; +import RefHolder from '../models/ref-holder'; +import {createItem} from '../helpers'; + +/** + * PaneItem registers an opener with the current Atom workspace as long as this component is mounted. The opener will + * trigger on URIs that match a specified pattern and render a subtree returned by a render prop. + * + * The render prop can receive three arguments: + * + * * itemHolder: A RefHolder. If used as the target for a ref, the referenced component will be used as the "item" of + * the pane item - its `getTitle()`, `getIconName()`, and other methods will be used by the pane. + * * params: An object containing the named parameters captured by the URI pattern. + * * uri: The exact, matched URI used to launch this item. + * + * render() { + * return ( + * + * {({itemHolder, params}) => ( + * + * )} + * + * ); + * } + */ +export default class PaneItem extends React.Component { + static propTypes = { + workspace: PropTypes.object.isRequired, + children: PropTypes.func.isRequired, + uriPattern: PropTypes.string.isRequired, + } + + constructor(props) { + super(props); + + const uriPattern = new URIPattern(this.props.uriPattern); + const currentlyOpen = this.props.workspace.getPaneItems() + .map(item => { + const element = item.getElement ? item.getElement() : null; + const match = item.getURI ? uriPattern.matches(item.getURI()) : nonURIMatch; + const stub = item.setRealItem ? item : null; + return {element, match, stub}; + }) + .filter(each => each.element && each.match.ok()) + .map(each => new OpenItem(each.match, each.element, each.stub)); + + this.subs = new CompositeDisposable(); + this.state = {uriPattern, currentlyOpen}; + } + + static getDerivedStateFromProps(nextProps, prevState) { + if (prevState.uriPattern.getOriginal() === nextProps.uriPattern) { + return null; + } + + return { + uriPattern: new URIPattern(nextProps.uriPattern), + }; + } + + componentDidMount() { + this.subs.add(this.props.workspace.addOpener(this.opener)); + + for (const openItem of this.state.currentlyOpen) { + openItem.hydrateStub(); + } + } + + render() { + return this.state.currentlyOpen.map(item => { + return ( + + {item.renderPortal(this.props.children)} + + ); + }); + } + + componentWillUnmount() { + this.subs.dispose(); + } + + opener = uri => { + const m = this.state.uriPattern.matches(uri); + if (!m.ok()) { + return undefined; + } + + const openItem = new OpenItem(m); + + return new Promise(resolve => { + this.setState(prevState => ({ + currentlyOpen: [...prevState.currentlyOpen, openItem], + }), () => { + const paneItem = openItem.create(); + + this.subs.add( + this.props.workspace.onDidDestroyPaneItem(({item}) => { + if (item === paneItem) { + this.setState(prevState => ({ + currentlyOpen: prevState.currentlyOpen.filter(each => each !== openItem), + })); + } + }), + ); + + resolve(paneItem); + }); + }); + } +} + +/** + * A subtree rendered through a portal onto a detached DOM node for use as the root as a PaneItem. + */ +class OpenItem { + constructor(match, element = null, stub = null) { + this.domNode = element || document.createElement('div'); + this.stubItem = stub; + this.match = match; + this.itemHolder = new RefHolder(); + } + + getURI() { + return this.match.getURI(); + } + + create() { + const h = this.itemHolder.isEmpty() ? null : this.itemHolder; + return createItem(this.domNode, h, this.match.getURI()); + } + + hydrateStub() { + if (this.stubItem) { + this.stubItem.setRealItem(this.create()); + this.stubItem = null; + } + } + + renderPortal(renderProp) { + return ReactDOM.createPortal( + renderProp({ + itemHolder: this.itemHolder, + params: this.match.getParams(), + uri: this.match.getURI(), + }), + this.domNode, + ); + } +} diff --git a/lib/atom/uri-pattern.js b/lib/atom/uri-pattern.js new file mode 100644 index 0000000000..09f5dec48b --- /dev/null +++ b/lib/atom/uri-pattern.js @@ -0,0 +1,366 @@ +import url from 'url'; + +/** + * Match and capture parts of a URI, like a specialized dialect of regular expression. This is used by PaneItem to + * describe URIs that should launch specific panes. + * + * URI patterns used `{name}` placeholders to match any non-empty path segment or URI part (host, protocol) and capture + * it as a parameter called "name". Any segment that is not recognized as a parameter will match exactly. + * + * Examples: + * + * `atom-github://hostname/exact/path?p0=value&p1=value` contains no parameters, so it will match _only_ that exact URL, + * including query parameters and their values. Extra query parameters or a fragment (`#`) will cause the match to fail. + * + * `atom-github://hostname/path/{name}/fragment` will match and capture any second path segment. + * * `atom-github://hostname/path/one/fragment` will match with `{name: 'one'}` + * * `atom-github://hostname/path/two/fragment` will match with `{name: 'two'}` + * * `atom-github://hostname/path/fragment` will not. + * + * `atom-github://hostname/root/{segments...}` will capture any number of path segments as an array. For example, + * * `atom-github://hostname/root/foo/bar/baz/` will match with `{segments: ['foo', 'bar', 'baz']}`. + * * `atom-github://hostname/root/single` will match with `{segments: ['single']}`; even a single segment will be + * matched as an array. + * * `atom-github://hostname/root/` will match with `{segments: []}`. + * + * Query parameters and their values may be captured. Given: `atom-github://hostname?q={value}` + * * `atom-github://hostname?q=foo` will match with `{value: 'foo'}`. + * * `atom-github://hostname?q=one&q=two` will _not_ match. + * + * To match multiple query parameters, use a splat parameter. Given: `atom-github://hostname?q={value...}` + * * `atom-github://hostname?q=one&q=two` will match with `{value: ['one', 'two']}`. + * * `atom-github://hostname?q=single` will match with `{value: ['single']}`. + * * `atom-github://hostname` will match with `{value: []}`. + * + * Protocol, username, password, or hostname may also contain capture expressions: `{p}://hostname`, + * `foo://me:{password}@hostname`. + */ +export default class URIPattern { + constructor(string) { + this.original = string; + + const parsed = url.parse(dashEscape(string), true); + this.parts = { + protocol: asPart(parsed.protocol, '', ':'), + auth: splitAuth(parsed.auth, asPart), + hostname: asPart(parsed.hostname), + port: asPart(parsed.port), + pathname: (parsed.pathname || '').split('/').slice(1).map(segment => asPart(segment)), + query: Object.keys(parsed.query).reduce( + (acc, current) => { + acc[current] = asPart(parsed.query[current]); + return acc; + }, + {}, + ), + hash: asPart(parsed.hash, '#', ''), + }; + } + + matches(string) { + const other = url.parse(string, true); + const params = {}; + + // direct matches + for (const attr of ['protocol', 'hostname', 'port', 'hash']) { + if (!this.parts[attr].matchesIn(params, other[attr])) { + return nonURIMatch; + } + } + + // auth + const auth = splitAuth(other.auth); + if (!this.parts.auth.username.matchesIn(params, auth.username)) { + return nonURIMatch; + } + if (!this.parts.auth.password.matchesIn(params, auth.password)) { + return nonURIMatch; + } + + // pathname + const pathParts = (other.pathname || '').split('/').slice(1); + let mineInd = 0; + let yoursInd = 0; + while (mineInd < this.parts.pathname.length && yoursInd < pathParts.length) { + const mine = this.parts.pathname[mineInd]; + const yours = pathParts[yoursInd]; + + if (mine.isSplat()) { + if (mine.matchesIn(params, yours)) { + yoursInd++; + } else { + mineInd++; + yoursInd++; + } + } else { + if (!mine.matchesIn(params, yours)) { + return nonURIMatch; + } + mineInd++; + yoursInd++; + } + } + + while (mineInd < this.parts.pathname.length) { + const part = this.parts.pathname[mineInd]; + if (!part.matchesEmptyIn(params)) { + return nonURIMatch; + } + mineInd++; + } + + if (yoursInd !== pathParts.length) { + return nonURIMatch; + } + + // query string + const remaining = new Set(Object.keys(this.parts.query)); + for (const k in other.query) { + const yours = other.query[k]; + remaining.delete(k); + + const mine = this.parts.query[k]; + if (mine === undefined) { + return nonURIMatch; + } + + const allYours = yours instanceof Array ? yours : [yours]; + + for (const each of allYours) { + if (!mine.matchesIn(params, each)) { + return nonURIMatch; + } + } + } + + for (const k of remaining) { + const part = this.parts.query[k]; + if (!part.matchesEmptyIn(params)) { + return nonURIMatch; + } + } + + return new URIMatch(string, params); + } + + // Access the original string used to create this pattern. + getOriginal() { + return this.original; + } + + toString() { + return ``; + } +} + +/** + * Null object returned by an unsuccessful. + */ +export const nullURIPattern = { + matches() { + return null; + }, + + getOriginal() { + return ''; + }, + + toString() { + return ''; + }, +}; + +/** + * Pattern component that matches its corresponding segment exactly. + */ +class ExactPart { + constructor(string) { + this.string = string; + } + + matchesIn(params, other) { + return other === this.string; + } + + isSplat() { + return false; + } +} + +/** + * Pattern component that matches and captures any non-empty corresponding segment within a URI. + */ +class CapturePart { + constructor(name, splat, prefix, suffix) { + this.name = name; + this.splat = splat; + this.prefix = prefix; + this.suffix = suffix; + } + + matchesIn(params, other) { + if (this.prefix.length > 0 && other.startsWith(this.prefix)) { + other = other.slice(this.prefix.length); + } + if (this.suffix.length > 0 && other.endsWith(this.suffix)) { + other = other.slice(0, -this.suffix.length); + } + if (other.length === 0) { + return false; + } + + other = decodeURIComponent(other); + + if (this.name.length > 0) { + if (this.splat) { + if (params[this.name] === undefined) { + params[this.name] = [other]; + } else { + params[this.name].push(other); + } + } else { + if (params[this.name] !== undefined) { + return false; + } + params[this.name] = other; + } + } + return true; + } + + matchesEmptyIn(params) { + if (this.splat) { + if (params[this.name] === undefined) { + params[this.name] = []; + } + return true; + } + + return false; + } + + isSplat() { + return this.splat; + } +} + +/** + * Including `{}` characters in certain URI components (hostname, protocol) cause `url.parse()` to lump everything into + * the `pathname`. Escape brackets from a pattern with `-a` and `-z`, and literal dashes with `--`. + */ +function dashEscape(raw) { + return raw.replace(/[{}-]/g, ch => { + if (ch === '{') { + return '-a'; + } else if (ch === '}') { + return '-z'; + } else { + return '--'; + } + }); +} + +/** + * Reverse the escaping performed by `dashEscape` by un-doubling `-` characters. + */ +function dashUnescape(escaped) { + return escaped.replace('--', '-'); +} + +/** + * Parse a URI pattern component as either an `ExactPart` or a `CapturePart`. Recognize captures ending with `...` as + * splat captures that can consume zero to many components. + */ +function asPart(patternSegment, prefix = '', suffix = '') { + if (patternSegment === null) { + return new ExactPart(null); + } + + let subPattern = patternSegment; + if (prefix.length > 0 && subPattern.startsWith(prefix)) { + subPattern = subPattern.slice(prefix.length); + } + if (suffix.length > 0 && subPattern.endsWith(suffix)) { + subPattern = subPattern.slice(0, -suffix.length); + } + + if (subPattern.startsWith('-a') && subPattern.endsWith('-z')) { + const splat = subPattern.endsWith('...-z'); + if (splat) { + subPattern = subPattern.slice(2, -5); + } else { + subPattern = subPattern.slice(2, -2); + } + + return new CapturePart(dashUnescape(subPattern), splat, prefix, suffix); + } else { + return new ExactPart(dashUnescape(patternSegment)); + } +} + +/** + * Split the `.auth` field into username and password subcomponent. + */ +function splitAuth(auth, fn = x => x) { + if (auth === null) { + return {username: fn(null), password: fn(null)}; + } + + const ind = auth.indexOf(':'); + return ind !== -1 + ? {username: fn(auth.slice(0, ind)), password: fn(auth.slice(ind + 1))} + : {username: fn(auth), password: fn(null)}; +} + +/** + * Memorialize a successful match between a URI and a URIPattern, including any parameters that have been captured. + */ +class URIMatch { + constructor(uri, params = {}) { + this.uri = uri; + this.params = params; + } + + ok() { + return true; + } + + getURI() { + return this.uri; + } + + getParams() { + return this.params; + } + + toString() { + let s = ''; + }, +}; diff --git a/lib/containers/pr-info-container.js b/lib/containers/pr-info-container.js index ef1dde3cb7..faf83350ac 100644 --- a/lib/containers/pr-info-container.js +++ b/lib/containers/pr-info-container.js @@ -178,7 +178,7 @@ export class PrInfo extends React.Component { event.nativeEvent.stopPropagation(); const pr = this.props.pullRequest; const repo = pr.repository; - atom.workspace.open(`atom-github://issueish/https://api.github.com/${repo.owner.login}/${repo.name}/${pr.number}`); + atom.workspace.open(`atom-github://issueish/https%3A%2F%2Fapi.github.com/${repo.owner.login}/${repo.name}/${pr.number}`); } @autobind diff --git a/lib/controllers/file-patch-controller.js b/lib/controllers/file-patch-controller.js index f8ccbc7904..1dc52b5cd4 100644 --- a/lib/controllers/file-patch-controller.js +++ b/lib/controllers/file-patch-controller.js @@ -20,7 +20,6 @@ export default class FilePatchController extends React.Component { deserializers: PropTypes.object.isRequired, tooltips: PropTypes.object.isRequired, filePath: PropTypes.string.isRequired, - uri: PropTypes.string.isRequired, lineNumber: PropTypes.number, initialStagingStatus: PropTypes.oneOf(['unstaged', 'staged']).isRequired, discardLines: PropTypes.func.isRequired, @@ -36,6 +35,15 @@ export default class FilePatchController extends React.Component { switchboard: new Switchboard(), } + static uriPattern = 'atom-github://file-patch/{relpath...}?workdir={workdir}&stagingStatus={stagingStatus}' + + static buildURI(relPath, workdir, stagingStatus) { + return 'atom-github://file-patch/' + + relPath + + `?workdir=${encodeURIComponent(workdir)}` + + `&stagingStatus=${encodeURIComponent(stagingStatus)}`; + } + static confirmedLargeFilePatches = new Set() static resetConfirmedLargeFilePatches() { @@ -94,10 +102,6 @@ export default class FilePatchController extends React.Component { return title; } - getURI() { - return this.props.uri; - } - serialize() { return { deserializer: 'FilePatchControllerStub', diff --git a/lib/controllers/root-controller.js b/lib/controllers/root-controller.js index eba57e1967..50a1fb0923 100644 --- a/lib/controllers/root-controller.js +++ b/lib/controllers/root-controller.js @@ -1,23 +1,24 @@ import fs from 'fs-extra'; import path from 'path'; -import url from 'url'; -import React from 'react'; +import React, {Fragment} from 'react'; import PropTypes from 'prop-types'; import {autobind} from 'core-decorators'; import {CompositeDisposable} from 'event-kit'; import StatusBar from '../views/status-bar'; import Panel from '../views/panel'; -import PaneItem from '../views/pane-item'; +import PaneItem from '../atom/pane-item'; import DockItem from '../views/dock-item'; import CloneDialog from '../views/clone-dialog'; import OpenIssueishDialog from '../views/open-issueish-dialog'; import InitDialog from '../views/init-dialog'; import CredentialDialog from '../views/credential-dialog'; import Commands, {Command} from '../views/commands'; +import GitTimingsView from '../views/git-timings-view'; import GithubTabController from './github-tab-controller'; import FilePatchController from './file-patch-controller'; +import IssueishPaneItem from '../atom-items/issueish-pane-item'; import GitTabController from './git-tab-controller'; import StatusBarTileController from './status-bar-tile-controller'; import RepositoryConflictController from './repository-conflict-controller'; @@ -25,24 +26,9 @@ import GithubLoginModel from '../models/github-login-model'; import Conflict from '../models/conflicts/conflict'; import RefHolder from '../models/ref-holder'; import Switchboard from '../switchboard'; -import {toNativePathSep, destroyFilePatchPaneItems, destroyEmptyFilePatchPaneItems} from '../helpers'; +import {destroyFilePatchPaneItems, destroyEmptyFilePatchPaneItems} from '../helpers'; import {GitError} from '../git-shell-out-strategy'; -function getPropsFromUri(uri) { - // atom-github://file-patch/file.txt?workdir=/foo/bar/baz&stagingStatus=staged - const {protocol, hostname, pathname, query} = url.parse(uri, true); - if (protocol === 'atom-github:' && hostname === 'file-patch') { - const filePath = toNativePathSep(pathname.slice(1)); - const {stagingStatus, workdir} = query; - return { - filePath: decodeURIComponent(filePath), - workdir: decodeURIComponent(workdir), - stagingStatus, - }; - } - return null; -} - export default class RootController extends React.Component { static propTypes = { workspace: PropTypes.object.isRequired, @@ -66,8 +52,6 @@ export default class RootController extends React.Component { githubTabStubItem: PropTypes.object, destroyGitTabItem: PropTypes.func.isRequired, destroyGithubTabItem: PropTypes.func.isRequired, - filePatchItems: PropTypes.array, - removeFilePatchItem: PropTypes.func.isRequired, pipelineManager: PropTypes.object, } @@ -92,7 +76,6 @@ export default class RootController extends React.Component { this.refGitTabController = new RefHolder(); this.refGitHubTabController = new RefHolder(); - this.filePatchRefs = new Map(); this.gitTabTracker = new TabTracker('git', { uri: 'atom-github://dock-item/git', @@ -112,46 +95,49 @@ export default class RootController extends React.Component { } render() { + return ( + + {this.renderCommands()} + {this.renderStatusBarTile()} + {this.renderDockItems()} + {this.renderPaneItems()} + {this.renderDialogs()} + {this.renderConflictResolver()} + + ); + } + + renderCommands() { const devMode = global.atom && global.atom.inDevMode(); return ( -
- - {devMode && } - - - - - - - - - - - - - - {this.renderStatusBarTile()} - {this.renderPanels()} - {this.renderInitDialog()} - {this.renderCloneDialog()} - {this.renderCredentialDialog()} - {this.renderOpenIssueishDialog()} - {this.renderRepositoryConflictController()} - {this.renderFilePatches()} -
+ + {devMode && } + + + + + + + + + + + + + ); } @@ -176,51 +162,63 @@ export default class RootController extends React.Component { ); } - renderPanels() { - const gitTab = this.props.gitTabStubItem && ( - - - + renderDockItems() { + return ( + + {this.props.gitTabStubItem && ( + + + + )} + {this.props.githubTabStubItem && ( + + + + )} + ); + } - const githubTab = this.props.githubTabStubItem && ( - - - + renderDialogs() { + return ( + + {this.renderInitDialog()} + {this.renderCloneDialog()} + {this.renderCredentialDialog()} + {this.renderOpenIssueishDialog()} + ); - - return
{gitTab}{githubTab}
; } renderInitDialog() { @@ -287,7 +285,7 @@ export default class RootController extends React.Component { ); } - renderRepositoryConflictController() { + renderConflictResolver() { if (!this.props.repository) { return null; } @@ -304,44 +302,49 @@ export default class RootController extends React.Component { ); } - renderFilePatches() { - return this.props.filePatchItems.map(item => { - const {filePath, stagingStatus, workdir} = getPropsFromUri(item.uri); - - let refHolder = this.filePatchRefs.get(item.uri); - if (!refHolder) { - refHolder = new RefHolder(); - this.filePatchRefs.set(refHolder); - } - - return ( + renderPaneItems() { + return ( + - + uriPattern={FilePatchController.uriPattern}> + {({itemHolder, params}) => ( + + )} - ); - }); + + {({itemHolder, params, uri}) => ( + + )} + + + {({itemHolder}) => } + + + ); } @autobind @@ -358,11 +361,6 @@ export default class RootController extends React.Component { return this.props.getRepositoryForWorkdir(workdir); } - @autobind - removeFilePatchItem(item) { - return this.props.removeFilePatchItem(item); - } - componentWillUnmount() { this.subscription.dispose(); } @@ -372,14 +370,6 @@ export default class RootController extends React.Component { this.subscription = new CompositeDisposable( this.props.repository.onMergeError(() => this.gitTabTracker.ensureVisible()), ); - - const existingURIs = new Set(this.props.filePatchItems.map(item => item.uri)); - const retainedURIs = [...this.filePatchRefs.keys()]; - for (const uri of retainedURIs) { - if (!existingURIs.has(uri)) { - this.filePatchRefs.delete(uri); - } - } } onConsumeStatusBar(statusBar) { @@ -411,7 +401,7 @@ export default class RootController extends React.Component { @autobind showWaterfallDiagnostics() { - this.props.workspace.open('atom-github://debug/timings'); + this.props.workspace.open(GitTimingsView.buildURI()); } @autobind @@ -457,7 +447,7 @@ export default class RootController extends React.Component { @autobind acceptOpenIssueish({repoOwner, repoName, issueishNumber}) { - const uri = `atom-github://issueish/https://api.github.com/${repoOwner}/${repoName}/${issueishNumber}`; + const uri = `atom-github://issueish/https%3A%2F%2Fapi.github.com/${repoOwner}/${repoName}/${issueishNumber}`; this.setState({openIssueishDialogActive: false}); this.props.workspace.open(uri); } @@ -536,7 +526,7 @@ export default class RootController extends React.Component { } const lineNum = editor.getCursorBufferPosition().row + 1; const filePatchItem = await this.props.workspace.open( - `atom-github://file-patch/${filePath}?workdir=${repoPath}&stagingStatus=${stagingStatus}`, + FilePatchController.buildURI(filePath, repoPath, stagingStatus), {pending: true, activatePane: true, activateItem: true}, ); await filePatchItem.getRealItemPromise(); diff --git a/lib/github-package.js b/lib/github-package.js index 59f56ee447..3a4815d408 100644 --- a/lib/github-package.js +++ b/lib/github-package.js @@ -14,7 +14,6 @@ import WorkdirContextPool from './models/workdir-context-pool'; import Repository from './models/repository'; import StyleCalculator from './models/style-calculator'; import RootController from './controllers/root-controller'; -import IssueishPaneItem from './atom-items/issueish-pane-item'; import StubItem from './atom-items/stub-item'; import Switchboard from './switchboard'; import yardstick from './yardstick'; @@ -83,8 +82,6 @@ export default class GithubPackage { ); this.setupYardstick(); - - this.filePatchItems = []; } setupYardstick() { @@ -172,23 +169,6 @@ export default class GithubPackage { } `, ), - this.workspace.addOpener(uri => { - if (uri === 'atom-github://debug/timings') { - return this.createGitTimingsView(); - } else { - return null; - } - }), - this.workspace.addOpener(IssueishPaneItem.opener), - this.workspace.addOpener((uri, ...args) => { - if (uri.startsWith('atom-github://file-patch/')) { - const item = this.createFilePatchControllerStub({uri}); - this.rerender(); - return item; - } else { - return null; - } - }), this.workspace.addOpener(uri => { if (uri.startsWith('atom-github://dock-item/')) { const item = this.createDockItemStub({uri}); @@ -301,7 +281,6 @@ export default class GithubPackage { githubTabStubItem={this.githubTabStubItem} destroyGitTabItem={this.destroyGitTabItem} destroyGithubTabItem={this.destroyGithubTabItem} - filePatchItems={this.filePatchItems} removeFilePatchItem={this.removeFilePatchItem} getRepositoryForWorkdir={this.getRepositoryForWorkdir} />, this.element, callback, @@ -327,12 +306,16 @@ export default class GithubPackage { @autobind createGitTimingsView() { - return GitTimingsView.createPaneItem(); + return StubItem.create('git-timings-view', { + title: 'GitHub Package Timings View', + }, GitTimingsView.buildURI()); } @autobind - createIssueishPaneItem({uri}) { - return IssueishPaneItem.opener(uri); + createIssueishPaneItemStub({uri}) { + return StubItem.create('issueish-pane-item', { + title: 'Issueish', + }, uri); } @autobind @@ -377,7 +360,6 @@ export default class GithubPackage { const item = StubItem.create('git-file-patch-controller', { title: 'Diff', }, uri); - this.filePatchItems.push(item); if (this.controller) { this.rerender(); } @@ -406,14 +388,6 @@ export default class GithubPackage { } } - @autobind - removeFilePatchItem(itemToRemove) { - this.filePatchItems = this.filePatchItems.filter(item => item !== itemToRemove); - if (this.controller) { - this.rerender(); - } - } - @autobind async createRepositoryForProjectPath(projectPath) { await fs.mkdirs(projectPath); diff --git a/lib/helpers.js b/lib/helpers.js index 33da0b6d21..124f884e4a 100644 --- a/lib/helpers.js +++ b/lib/helpers.js @@ -332,13 +332,17 @@ export function extractCoAuthorsAndRawCommitMessage(commitMessage) { return {message: rawMessage, coAuthors}; } -export function createItem(node, componentHolder) { +export function createItem(node, componentHolder = null, uri = null) { const override = { getElement: () => node, getRealItem: () => componentHolder.get(), }; + if (uri) { + override.getURI = () => uri; + } + if (componentHolder) { return new Proxy(override, { get(target, name) { diff --git a/lib/views/git-timings-view.js b/lib/views/git-timings-view.js index 77f3dd20d2..a289757547 100644 --- a/lib/views/git-timings-view.js +++ b/lib/views/git-timings-view.js @@ -344,31 +344,14 @@ let lastMarkerTime = null; let updateTimer = null; export default class GitTimingsView extends React.Component { - static propTypes = { - container: PropTypes.any.isRequired, - } - static emitter = new Emitter(); + static uriPattern = 'atom-github://debug/timings'; - static createPaneItem() { - let element; - return { - serialize() { return {deserializer: 'GitTimingsView'}; }, - getURI() { return 'atom-github://debug/markers'; }, - getTitle() { return 'GitHub Package Timings View'; }, - get element() { - if (!element) { - element = document.createElement('div'); - ReactDom.render(, element); - } - return element; - }, - }; + static buildURI() { + return this.uriPattern; } - static deserialize() { - return this.createPaneItem(); - } + static emitter = new Emitter(); static generateMarker(label) { const marker = new Marker(label, () => { @@ -412,12 +395,6 @@ export default class GitTimingsView extends React.Component { componentDidMount() { this.subscriptions = new CompositeDisposable( GitTimingsView.onDidUpdate(() => this.forceUpdate()), - atom.workspace.onDidDestroyPaneItem(({item}) => { - if (item.element === this.props.container) { - // we just got closed - ReactDom.unmountComponentAtNode(this.props.container); - } - }), ); } @@ -456,4 +433,18 @@ export default class GitTimingsView extends React.Component { } }); } + + serialize() { + return { + deserializer: 'GitTimingsView', + }; + } + + getURI() { + return this.constructor.buildURI(); + } + + getTitle() { + return 'GitHub Package Timings View'; + } } diff --git a/lib/views/pane-item.js b/lib/views/pane-item.js deleted file mode 100644 index 9b054da8f6..0000000000 --- a/lib/views/pane-item.js +++ /dev/null @@ -1,107 +0,0 @@ -import React from 'react'; -import ReactDOM from 'react-dom'; -import PropTypes from 'prop-types'; -import {CompositeDisposable} from 'event-kit'; - -import {createItem} from '../helpers'; -import {RefHolderPropType} from '../prop-types'; - -/** - * `PaneItem` adds its child to the current Atom pane when rendered. When the pane is closed, the component's - * `onDidCloseItem` is called. You should use this callback to set state so that the `PaneItem` is no longer rendered; - * you will get an error in your console if you forget. - * - * Unmounting the component when the item is open will close the item. - */ -export default class PaneItem extends React.Component { - static propTypes = { - workspace: PropTypes.object.isRequired, - children: PropTypes.element.isRequired, - onDidCloseItem: PropTypes.func, - stubItem: PropTypes.object, - itemHolder: RefHolderPropType, - } - - static defaultProps = { - onDidCloseItem: () => {}, - } - - constructor(props) { - super(props); - - this.subscriptions = new CompositeDisposable(); - this.paneItem = null; - this.didCloseItem = false; - - if (this.props.stubItem) { - this.domNode = this.props.stubItem.getElement(); - } else { - this.domNode = document.createElement('div'); - this.domNode.className = 'react-atom-pane-item'; - } - } - - componentDidMount() { - this.setupPaneItem(); - } - - componentDidUpdate() { - if (this.didCloseItem) { - // eslint-disable-next-line no-console - console.error('Unexpected update in `PaneItem`: the contained item has been closed'); - } - } - - render() { - return ReactDOM.createPortal( - this.props.children, - this.domNode, - ); - } - - setupPaneItem() { - if (this.paneItem) { return; } - - const itemToAdd = createItem(this.domNode, this.props.itemHolder); - if (itemToAdd.wasActivated) { - this.subscriptions.add( - this.props.workspace.onDidChangeActivePaneItem(activeItem => { - if (activeItem === this.paneItem) { - itemToAdd.wasActivated(() => { - return this.props.workspace.getActivePaneItem() === this.paneItem; - }); - } - }), - ); - } - - const stub = this.props.stubItem; - if (stub) { - stub.setRealItem(itemToAdd); - this.paneItem = stub; - } else { - const paneContainer = this.props.workspace; - this.paneItem = paneContainer.getActivePane().addItem(itemToAdd); - } - - this.subscriptions.add( - this.props.workspace.onDidDestroyPaneItem(({item}) => { - if (item === this.paneItem) { - this.didCloseItem = true; - this.props.onDidCloseItem(this.paneItem); - } - }), - ); - } - - getPaneItem() { - return this.paneItem; - } - - componentWillUnmount() { - this.subscriptions.dispose(); - if (this.paneItem && !this.didCloseItem) { - this.paneItem.destroy(); - } - } -} diff --git a/lib/views/panel.js b/lib/views/panel.js index cba5ba3708..9db1de1962 100644 --- a/lib/views/panel.js +++ b/lib/views/panel.js @@ -4,7 +4,7 @@ import PropTypes from 'prop-types'; import {CompositeDisposable} from 'event-kit'; import {createItem} from '../helpers'; -import RefHolderPropType from '../prop-types'; +import {RefHolderPropType} from '../prop-types'; /** * `Panel` renders a React component into an Atom panel. Specify the location via the `location` prop, and any diff --git a/lib/views/staging-view.js b/lib/views/staging-view.js index da7effb588..45b92c0274 100644 --- a/lib/views/staging-view.js +++ b/lib/views/staging-view.js @@ -451,11 +451,9 @@ export default class StagingView { } async showFilePatchItem(filePath, stagingStatus, {activate, pane} = {activate: false}) { - const encodedFilePath = encodeURIComponent(filePath); - const encodedWorkdir = encodeURIComponent(this.props.workingDirectoryPath); + const uri = FilePatchController.buildURI(filePath, this.props.workingDirectoryPath, stagingStatus); const filePatchItem = await this.props.workspace.open( - `atom-github://file-patch/${encodedFilePath}?workdir=${encodedWorkdir}&stagingStatus=${stagingStatus}`, - {pending: true, activatePane: activate, activateItem: activate, pane}, + uri, {pending: true, activatePane: activate, activateItem: activate, pane}, ); if (activate) { const itemRoot = filePatchItem.getElement(); diff --git a/package.json b/package.json index d6e0abc5a9..edc062ec3e 100644 --- a/package.json +++ b/package.json @@ -176,7 +176,7 @@ }, "deserializers": { "GitTimingsView": "createGitTimingsView", - "IssueishPaneItem": "createIssueishPaneItem", + "IssueishPaneItem": "createIssueishPaneItemStub", "GitDockItem": "createDockItemStub", "GithubDockItem": "createDockItemStub", "FilePatchControllerStub": "createFilePatchControllerStub" diff --git a/test/atom/pane-item.test.js b/test/atom/pane-item.test.js new file mode 100644 index 0000000000..29c79ffe51 --- /dev/null +++ b/test/atom/pane-item.test.js @@ -0,0 +1,230 @@ +import React from 'react'; +import {shallow, mount} from 'enzyme'; +import PropTypes from 'prop-types'; + +import PaneItem from '../../lib/atom/pane-item'; +import StubItem from '../../lib/atom-items/stub-item'; + +class Component extends React.Component { + static propTypes = { + text: PropTypes.string.isRequired, + } + + render() { + return ( +
{this.props.text}
+ ); + } + + getTitle() { + return `Component with: ${this.props.text}`; + } + + getText() { + return this.props.text; + } +} + +describe('PaneItem', function() { + let atomEnv, workspace; + + beforeEach(function() { + atomEnv = global.buildAtomEnvironment(); + workspace = atomEnv.workspace; + }); + + afterEach(function() { + atomEnv.destroy(); + }); + + describe('opener', function() { + it('registers an opener on the workspace', function() { + sinon.spy(workspace, 'addOpener'); + + shallow( + + {() => } + , + ); + + assert.isTrue(workspace.addOpener.called); + }); + + it('disposes the opener on unmount', function() { + const sub = { + dispose: sinon.spy(), + }; + sinon.stub(workspace, 'addOpener').returns(sub); + + const wrapper = shallow( + + {() => } + , + ); + wrapper.unmount(); + + assert.isTrue(sub.dispose.called); + }); + + it('renders no children', function() { + const wrapper = shallow( + + {() => } + , + ); + + assert.lengthOf(wrapper.find('Component'), 0); + }); + }); + + describe('when opened with a matching URI', function() { + it('calls its render prop', async function() { + let called = false; + shallow( + + {() => { + called = true; + return ; + }} + , + ); + + assert.isFalse(called); + await workspace.open('atom-github://pattern'); + assert.isTrue(called); + }); + + it('uses the child component as the workspace item', async function() { + mount( + + {({itemHolder}) => } + , + ); + + const item = await workspace.open('atom-github://pattern'); + assert.strictEqual(item.getTitle(), 'Component with: a prop'); + }); + + it('renders a child item', async function() { + const wrapper = mount( + + {() => } + , + ); + await workspace.open('atom-github://pattern'); + assert.lengthOf(wrapper.update().find('Component'), 1); + }); + + it('renders a different child item for each matching URI', async function() { + const wrapper = mount( + + {() => } + , + ); + await workspace.open('atom-github://pattern/1'); + await workspace.open('atom-github://pattern/2'); + + assert.lengthOf(wrapper.update().find('Component'), 2); + }); + + it('passes matched parameters to its render prop', async function() { + let calledWith = null; + mount( + + {({params}) => { + calledWith = params; + return ; + }} + , + ); + + assert.isNull(calledWith); + await workspace.open('atom-github://pattern/123'); + assert.deepEqual(calledWith, {id: '123'}); + + calledWith = null; + await workspace.open('atom-github://pattern/456'); + assert.deepEqual(calledWith, {id: '456'}); + }); + + it('passes the URI itself', async function() { + let calledWith = null; + mount( + + {({uri}) => { + calledWith = uri; + return ; + }} + , + ); + + assert.isNull(calledWith); + await workspace.open('atom-github://pattern/123'); + assert.strictEqual(calledWith, 'atom-github://pattern/123'); + + calledWith = null; + await workspace.open('atom-github://pattern/456'); + assert.strictEqual(calledWith, 'atom-github://pattern/456'); + }); + + it('removes a child when its pane is destroyed', async function() { + const wrapper = mount( + + {() => } + , + ); + + await workspace.open('atom-github://pattern/0'); + const item1 = await workspace.open('atom-github://pattern/1'); + + assert.lengthOf(wrapper.update().find('Component'), 2); + + assert.isTrue(await workspace.paneForItem(item1).destroyItem(item1)); + + assert.lengthOf(wrapper.update().find('Component'), 1); + }); + }); + + describe('when StubItems are present', function() { + it('renders children into the stubs', function() { + const stub0 = StubItem.create( + 'some-component', + {title: 'Component'}, + 'atom-github://pattern/root/10', + ); + workspace.getActivePane().addItem(stub0); + const stub1 = StubItem.create( + 'other-component', + {title: 'Other Component'}, + 'atom-github://other/pattern', + ); + workspace.getActivePane().addItem(stub1); + + const wrapper = mount( + + {({params}) => } + , + ); + + assert.lengthOf(wrapper.find('Component'), 1); + assert.strictEqual(wrapper.find('Component').prop('text'), '10'); + }); + + it('adopts the real item into the stub', function() { + const stub = StubItem.create( + 'some-component', + {title: 'Component'}, + 'atom-github://pattern/root/10', + ); + workspace.getActivePane().addItem(stub); + + mount( + + {({params, itemHolder}) => } + , + ); + + assert.strictEqual(stub.getText(), '10'); + }); + }); +}); diff --git a/test/atom/uri-pattern.test.js b/test/atom/uri-pattern.test.js new file mode 100644 index 0000000000..04bbae54bc --- /dev/null +++ b/test/atom/uri-pattern.test.js @@ -0,0 +1,132 @@ +import URIPattern from '../../lib/atom/uri-pattern'; + +describe('URIPattern', function() { + describe('exact', function() { + let exact; + + beforeEach(function() { + exact = new URIPattern('atom-github://exact'); + }); + + it('matches exact URIs', function() { + assert.isTrue(exact.matches('atom-github://exact').ok()); + }); + + it('does not match any other URIs', function() { + assert.isFalse(exact.matches('atom-github://exactbutnot').ok()); + assert.isFalse(exact.matches('https://exact').ok()); + assert.isFalse(exact.matches('atom-github://exact/but/not').ok()); + assert.isFalse(exact.matches('atom-github://exact?no=no').ok()); + }); + }); + + describe('parameter placeholders', function() { + it('matches a protocol placeholder', function() { + const pattern = new URIPattern('{proto}://host/some/path'); + + const m = pattern.matches('something://host/some/path'); + assert.isTrue(m.ok()); + assert.deepEqual(m.getParams(), {proto: 'something'}); + }); + + it('matches an auth username placeholder', function() { + const pattern = new URIPattern('proto://{user}@host/some/path'); + + const m = pattern.matches('proto://me@host/some/path'); + assert.isTrue(m.ok()); + assert.deepEqual(m.getParams(), {user: 'me'}); + }); + + it('matches an auth password placeholder', function() { + const pattern = new URIPattern('proto://me:{password}@host/some/path'); + + const m = pattern.matches('proto://me:swordfish@host/some/path'); + assert.isTrue(m.ok()); + assert.deepEqual(m.getParams(), {password: 'swordfish'}); + }); + + it('matches a hostname placeholder', function() { + const pattern = new URIPattern('proto://{host}/some/path'); + + const m = pattern.matches('proto://somewhere.com/some/path'); + assert.isTrue(m.ok()); + assert.deepEqual(m.getParams(), {host: 'somewhere.com'}); + }); + + it('matches each path placeholder to one path segment', function() { + const pattern = new URIPattern('atom-github://base/exact/{id}'); + + const m0 = pattern.matches('atom-github://base/exact/0'); + assert.isTrue(m0.ok()); + assert.deepEqual(m0.getParams(), {id: '0'}); + + const m1 = pattern.matches('atom-github://base/exact/1'); + assert.isTrue(m1.ok()); + assert.deepEqual(m1.getParams(), {id: '1'}); + + assert.isFalse(pattern.matches('atom-github://base/exact/0/more').ok()); + }); + + it('does not match if the expected path segment is absent', function() { + const pattern = new URIPattern('atom-github://base/exact/{id}'); + assert.isFalse(pattern.matches('atom-github://base/exact/').ok()); + }); + + it('matches multiple path segments with a splat', function() { + const pattern = new URIPattern('proto://host/root/{rest...}'); + + const m0 = pattern.matches('proto://host/root'); + assert.isTrue(m0.ok()); + assert.deepEqual(m0.getParams(), {rest: []}); + + const m1 = pattern.matches('proto://host/root/a'); + assert.isTrue(m1.ok()); + assert.deepEqual(m1.getParams(), {rest: ['a']}); + + const m2 = pattern.matches('proto://host/root/a/b/c'); + assert.isTrue(m2.ok()); + assert.deepEqual(m2.getParams(), {rest: ['a', 'b', 'c']}); + }); + + it('matches a query string placeholder', function() { + const pattern = new URIPattern('proto://host?p0={zero}&p1={one}'); + + const m0 = pattern.matches('proto://host?p0=aaa&p1=bbb'); + assert.isTrue(m0.ok()); + assert.deepEqual(m0.getParams(), {zero: 'aaa', one: 'bbb'}); + + const m1 = pattern.matches('proto://host?p1=no&p0=yes'); + assert.isTrue(m1.ok()); + assert.deepEqual(m1.getParams(), {zero: 'yes', one: 'no'}); + }); + + it('does not match a single query string parameter against multiple occurrences', function() { + const pattern = new URIPattern('proto://host?p={single}'); + assert.isFalse(pattern.matches('proto://host?p=0&p=1&p=2').ok()); + }); + + it('matches multiple query string parameters with a splat', function() { + const pattern = new URIPattern('proto://host?ps={multi...}'); + + const m0 = pattern.matches('proto://host'); + assert.isTrue(m0.ok()); + assert.deepEqual(m0.getParams(), {multi: []}); + + const m1 = pattern.matches('proto://host?ps=0'); + assert.isTrue(m1.ok()); + assert.deepEqual(m1.getParams(), {multi: ['0']}); + + const m2 = pattern.matches('proto://host?ps=0&ps=1&ps=2'); + assert.isTrue(m2.ok()); + assert.deepEqual(m2.getParams(), {multi: ['0', '1', '2']}); + }); + + it('URI-decodes matched parameters', function() { + const pattern = new URIPattern('proto://host/root/{child}?q={search}'); + const m = pattern.matches('proto://host/root/hooray%3E%20for%3C%20encodings?q=%3F%26%3F!'); + + assert.isTrue(m.ok()); + assert.deepEqual(m.getParams(), {child: 'hooray> for< encodings', search: '?&?!'}); + }); + }); +}); diff --git a/test/controllers/root-controller.test.js b/test/controllers/root-controller.test.js index e31971a1de..cc91a6a482 100644 --- a/test/controllers/root-controller.test.js +++ b/test/controllers/root-controller.test.js @@ -113,46 +113,6 @@ describe('RootController', function() { }); }); - describe('rendering a FilePatch', function() { - it('renders the FilePatchController based on props.filePatchItems', async function() { - const workdirPath = await cloneRepository('three-files'); - const repository = await buildRepository(workdirPath); - app = React.cloneElement(app, {repository, filePatchItems: []}); - let wrapper = shallow(app); - - assert.equal(wrapper.find('FilePatchController').length, 0); - - app = React.cloneElement(app, {repository, filePatchItems: [ - {key: 1, uri: 'atom-github://file-patch/a.txt?workdir=/foo/bar/baz&stagingStatus=unstaged'}, - {key: 2, uri: 'atom-github://file-patch/b.txt?workdir=/foo/bar/baz&stagingStatus=unstaged'}, - {key: 3, uri: 'atom-github://file-patch/b.txt?workdir=/foo/bar/baz&stagingStatus=staged'}, - {key: 4, uri: 'atom-github://file-patch/c.txt?workdir=/foo/bar/baz&stagingStatus=staged'}, - ]}); - wrapper = shallow(app); - - assert.equal(wrapper.find('FilePatchController').length, 4); - assert.isTrue(wrapper.find({filePath: 'a.txt', initialStagingStatus: 'unstaged'}).exists()); - assert.isTrue(wrapper.find({filePath: 'b.txt', initialStagingStatus: 'unstaged'}).exists()); - assert.isTrue(wrapper.find({filePath: 'b.txt', initialStagingStatus: 'staged'}).exists()); - assert.isTrue(wrapper.find({filePath: 'c.txt', initialStagingStatus: 'staged'}).exists()); - }); - - if (path.sep !== '/') { - it('reconstitutes paths from the pane URI with the correct path separator', async function() { - const workdirPath = await cloneRepository('three-files'); - const repository = await buildRepository(workdirPath); - - app = React.cloneElement(app, {repository, filePatchItems: [ - {key: 1, uri: 'atom-github://file-patch/foo/bar/baz/filename.txt?workdir=/foo/bar/baz&stagingStatus=unstaged'}, - ]}); - const wrapper = shallow(app); - - assert.equal(wrapper.find('FilePatchController').length, 1); - assert.isTrue(wrapper.find({filePath: path.join('foo', 'bar', 'baz', 'filename.txt')}).exists()); - }); - } - }); - ['git', 'github'].forEach(function(tabName) { describe(`${tabName} tab tracker`, function() { let wrapper, tabTracker, mockDockItem; @@ -1045,7 +1005,7 @@ describe('RootController', function() { await assert.async.equal(workspace.open.callCount, 1); assert.deepEqual(workspace.open.args[0], [ - `atom-github://file-patch/a.txt?workdir=${workdirPath}&stagingStatus=unstaged`, + `atom-github://file-patch/a.txt?workdir=${encodeURIComponent(workdirPath)}&stagingStatus=unstaged`, {pending: true, activatePane: true, activateItem: true}, ]); await assert.async.equal(filePatchItem.goToDiffLine.callCount, 1); @@ -1090,7 +1050,7 @@ describe('RootController', function() { await assert.async.equal(workspace.open.callCount, 1); assert.deepEqual(workspace.open.args[0], [ - `atom-github://file-patch/a.txt?workdir=${workdirPath}&stagingStatus=staged`, + `atom-github://file-patch/a.txt?workdir=${encodeURIComponent(workdirPath)}&stagingStatus=staged`, {pending: true, activatePane: true, activateItem: true}, ]); await assert.async.equal(filePatchItem.goToDiffLine.callCount, 1); diff --git a/test/pane-items/issueish-pane-item.test.js b/test/pane-items/issueish-pane-item.test.js deleted file mode 100644 index e86e4b49c9..0000000000 --- a/test/pane-items/issueish-pane-item.test.js +++ /dev/null @@ -1,33 +0,0 @@ -import IssueishPaneItem from '../../lib/atom-items/issueish-pane-item'; - -describe('IssueishPaneItem', function() { - describe('opener', function() { - const paneItem = Symbol('paneItem'); - beforeEach(function() { - sinon.stub(IssueishPaneItem, 'create').returns(paneItem); - }); - - it('returns an item given a valid PR URL', function() { - const item = IssueishPaneItem.opener('atom-github://issueish/https://api.github.com/atom/github/123'); - assert.deepEqual(IssueishPaneItem.create.getCall(0).args[0], { - host: 'https://api.github.com', - owner: 'atom', - repo: 'github', - issueishNumber: 123, - }); - assert.equal(item, paneItem); - }); - - [ - ['returns null if a segment is missing', 'atom-github://issueish/https://api.github.com/atom/123'], - ['returns null if the PR number is not a number', 'atom-github://issueish/https://api.github.com/atom/github/asdf'], - ['returns null if the host is not issueish', 'atom-github://pr/https://api.github.com/atom/github/123'], - ['returns null if the protocol is not atom-github', 'github://issueish/https://api.github.com/atom/github/123'], - ].forEach(([description, uri]) => { - it(description, function() { - const item = IssueishPaneItem.opener(uri); - assert.isNull(item); - }); - }); - }); -}); diff --git a/test/views/pane-item.test.js b/test/views/pane-item.test.js deleted file mode 100644 index 2445270396..0000000000 --- a/test/views/pane-item.test.js +++ /dev/null @@ -1,82 +0,0 @@ -import React from 'react'; -import {mount} from 'enzyme'; -import PropTypes from 'prop-types'; - -import PaneItem from '../../lib/views/pane-item'; - -import {Emitter} from 'event-kit'; - -class Component extends React.Component { - static propTypes = { - text: PropTypes.string.isRequired, - } - - render() { - return ( -
{this.props.text}
- ); - } - - getText() { - return this.props.text; - } -} - -describe('PaneItem', function() { - let emitter, workspace, activePane; - - beforeEach(function() { - emitter = new Emitter(); - - const paneItem = { - destroy: sinon.stub().callsFake(() => emitter.emit('destroy', {item: paneItem})), - }; - - activePane = { - addItem: sinon.stub().callsFake(() => paneItem), - activateItem: sinon.stub(), - }; - - workspace = { - getActivePane: sinon.stub().returns(activePane), - paneForItem: sinon.stub().returns(activePane), - onDidDestroyPaneItem: cb => emitter.on('destroy', cb), - }; - }); - - afterEach(function() { - emitter.dispose(); - }); - - it('renders a React component into an Atom pane item', function() { - const wrapper = mount( - - - , - ); - - const paneItem = wrapper.instance().getPaneItem(); - - assert.strictEqual(activePane.addItem.callCount, 1); - assert.deepEqual( - activePane.addItem.args[0][0].getElement().children[0], - wrapper.find('Component').getDOMNode(), - ); - - wrapper.unmount(); - - assert.strictEqual(paneItem.destroy.callCount, 1); - }); - - it('calls props.onDidCloseItem when the pane item is destroyed unexpectedly', function() { - const onDidCloseItem = sinon.stub(); - const wrapper = mount( - - - , - ); - - wrapper.instance().getPaneItem().destroy(); - assert.strictEqual(onDidCloseItem.callCount, 1); - }); -});