';
+ },
+};
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);
- });
-});