diff --git a/lib/controllers/file-patch-controller.js b/lib/controllers/file-patch-controller.js
index f034bde207..dd81237310 100644
--- a/lib/controllers/file-patch-controller.js
+++ b/lib/controllers/file-patch-controller.js
@@ -5,7 +5,6 @@ import {Emitter, CompositeDisposable} from 'atom'
import etch from 'etch'
import FilePatchView from '../views/file-patch-view'
-import ModelObserver from '../models/model-observer'
export default class FilePatchController {
constructor (props) {
@@ -15,23 +14,17 @@ export default class FilePatchController {
this.unstageHunk = this.unstageHunk.bind(this)
this.stageLines = this.stageLines.bind(this)
this.unstageLines = this.unstageLines.bind(this)
- this.repositoryObserver = new ModelObserver({
- didUpdate: () => this.props.onRepoRefresh && this.props.onRepoRefresh()
- })
- this.repositoryObserver.setActiveModel(props.repository)
etch.initialize(this)
}
async update (props) {
this.props = Object.assign({}, this.props, props)
this.emitter.emit('did-change-title', this.getTitle())
- await this.repositoryObserver.setActiveModel(props.repository)
return etch.update(this)
}
destroy () {
this.emitter.emit('did-destroy')
- this.repositoryObserver.destroy()
return etch.destroy(this)
}
@@ -105,4 +98,10 @@ export default class FilePatchController {
didDestroyFilePatch () {
this.destroy()
}
+
+ focus () {
+ if (this.refs.filePatchView) {
+ this.refs.filePatchView.focus()
+ }
+ }
}
diff --git a/lib/controllers/git-controller.js b/lib/controllers/git-controller.js
new file mode 100644
index 0000000000..d920f5eeb8
--- /dev/null
+++ b/lib/controllers/git-controller.js
@@ -0,0 +1,219 @@
+/** @babel */
+
+import path from 'path'
+
+import {CompositeDisposable, Disposable, File} from 'atom'
+
+import React from 'react'
+import ReactDom from 'react-dom'
+
+import EtchWrapper from '../views/etch-wrapper'
+import StatusBar from '../views/status-bar'
+import Panel from '../views/panel'
+import PaneItem from '../views/pane-item'
+import FilePatchController from './file-patch-controller'
+import GitPanelController from './git-panel-controller'
+import StatusBarTileController from './status-bar-tile-controller'
+import ModelObserver from '../models/model-observer'
+
+const nullFilePatchState = {
+ filePath: null,
+ filePatch: null,
+ stagingStatus: null,
+ amending: null,
+}
+
+export default class GitController extends React.Component {
+ static propTypes = {
+ workspace: React.PropTypes.object.isRequired,
+ commandRegistry: React.PropTypes.object.isRequired,
+ notificationManager: React.PropTypes.object.isRequired,
+ repository: React.PropTypes.object,
+ statusBar: React.PropTypes.object,
+ savedState: React.PropTypes.object,
+ }
+
+ static defaultProps = {
+ savedState: {},
+ }
+
+ serialize () {
+ return {
+ gitPanelActive: this.state.gitPanelActive
+ }
+ }
+
+ constructor (props, context) {
+ super(props, context)
+ this.state = {
+ ...nullFilePatchState,
+ gitPanelActive: !!props.savedState.gitPanelActive,
+ }
+
+ this.showFilePatchForPath = this.showFilePatchForPath.bind(this)
+ this.showMergeConflictFileForPath = this.showMergeConflictFileForPath.bind(this)
+ this.didChangeAmending = this.didChangeAmending.bind(this)
+ this.onRepoRefresh = this.onRepoRefresh.bind(this)
+ this.toggleGitPanel = this.toggleGitPanel.bind(this)
+ this.openAndFocusGitPanel = this.openAndFocusGitPanel.bind(this)
+ this.focusFilePatchView = this.focusFilePatchView.bind(this)
+ this.focusGitPanel = this.focusGitPanel.bind(this)
+ this.subscriptions = new CompositeDisposable()
+ this.subscriptions.add(
+ props.commandRegistry.add('atom-workspace', {
+ 'git:toggle-git-panel': this.toggleGitPanel.bind(this),
+ 'git:focus-git-panel': this.openAndFocusGitPanel.bind(this),
+ })
+ )
+
+ this.repositoryObserver = new ModelObserver({
+ didUpdate: () => this.onRepoRefresh()
+ })
+ this.repositoryObserver.setActiveModel(props.repository)
+ this.subscriptions.add(
+ new Disposable(() => this.repositoryObserver.destroy())
+ )
+ }
+
+ componentWillReceiveProps(newProps) {
+ this.repositoryObserver.setActiveModel(newProps.repository)
+ }
+
+ render () {
+ return (
+
+ {this.renderStatusBarTile()}
+ {this.renderGitPanel()}
+ {(this.state.filePath && this.state.filePatch) ? this.renderFilePatchController() : null}
+
+ )
+ }
+
+ renderStatusBarTile () {
+ return (
+ this.onConsumeStatusBar(sb)}>
+
+
+
+
+ )
+ }
+
+ renderGitPanel () {
+ return (
+ subtree.getWrappedComponent()}
+ onDidClosePanel={() => this.setState({gitPanelActive: false})}
+ visible={!!this.state.gitPanelActive}
+ >
+ this.gitPanelController = c} reattachDomNode={false}>
+
+
+
+ )
+ }
+
+ renderFilePatchController () {
+ return (
+ subtree.getWrappedComponent()}
+ ref={c => this.filePatchControllerPane = c}
+ onDidCloseItem={() => this.setState({...nullFilePatchState})}
+ >
+ this.filePatchController = c} reattachDomNode={false}>
+
+
+
+ )
+ }
+
+ componentWillUnmount () {
+ this.subscriptions.dispose()
+ }
+
+ onConsumeStatusBar (statusBar) {
+ if (statusBar.disableGitInfoTile) {
+ statusBar.disableGitInfoTile()
+ }
+ }
+
+ async showFilePatchForPath (filePath, stagingStatus, {activate, amending} = {}) {
+ if (!filePath) return
+ const repository = this.props.repository
+ if (!repository) return
+
+ const filePatch = await repository.getFilePatchForPath(filePath, {staged: stagingStatus === 'staged', amending})
+ if (filePatch) {
+ this.setState({ filePath, filePatch, stagingStatus, amending }, () => {
+ // TODO: can be better done w/ a prop?
+ if (activate && this.filePatchControllerPane) {
+ this.filePatchControllerPane.activate()
+ }
+ })
+ } else {
+ this.setState({...nullFilePatchState})
+ }
+ }
+
+ onRepoRefresh () {
+ return this.showFilePatchForPath(this.state.filePath, this.state.stagingStatus, {amending: this.state.amending})
+ }
+
+ async showMergeConflictFileForPath (relativeFilePath, {focus} = {}) {
+ const absolutePath = path.join(this.props.repository.getWorkingDirectoryPath(), relativeFilePath)
+ if (await new File(absolutePath).exists()) {
+ return this.props.workspace.open(absolutePath, {activatePane: Boolean(focus), pending: true})
+ } else {
+ this.props.notificationManager.addInfo('File has been deleted.')
+ }
+ }
+
+ didChangeAmending (isAmending) {
+ return this.showFilePatchForPath(this.state.filePath, this.state.stagingStatus, {amending: isAmending})
+ }
+
+ toggleGitPanel () {
+ this.setState(state => ({gitPanelActive: !state.gitPanelActive}))
+ }
+
+ openAndFocusGitPanel () {
+ if (!this.state.gitPanelActive) {
+ this.setState({gitPanelActive: true}, () => this.focusGitPanel())
+ } else {
+ this.focusGitPanel()
+ }
+ }
+
+ focusGitPanel () {
+ if (this.gitPanelController) {
+ this.gitPanelController.getWrappedComponent().focus()
+ }
+ }
+
+ focusFilePatchView () {
+ if (this.filePatchController) {
+ this.filePatchController.getWrappedComponent().focus()
+ }
+ }
+}
diff --git a/lib/controllers/git-panel-controller.js b/lib/controllers/git-panel-controller.js
index 2cb34cb928..4131dd21c6 100644
--- a/lib/controllers/git-panel-controller.js
+++ b/lib/controllers/git-panel-controller.js
@@ -66,8 +66,11 @@ export default class GitPanelController {
}
async update (props) {
+ const oldProps = this.props
this.props = Object.assign({}, this.props, props)
- await this.repositoryObserver.setActiveModel(props.repository)
+ if (this.props.repository !== oldProps.repository) {
+ await this.repositoryObserver.setActiveModel(props.repository)
+ }
return etch.update(this)
}
@@ -157,7 +160,7 @@ export default class GitPanelController {
}
setAmending (isAmending) {
- if (this.props.didChangeAmending) this.props.didChangeAmending()
+ if (this.props.didChangeAmending) this.props.didChangeAmending(isAmending)
this.isAmending = isAmending
return this.repositoryObserver.refreshModelData(this.getActiveRepository())
}
@@ -171,6 +174,8 @@ export default class GitPanelController {
}
focus () {
- if (this.refs.gitPanel) this.refs.gitPanel.focus()
+ if (this.refs.gitPanel) {
+ this.refs.gitPanel.focus()
+ }
}
}
diff --git a/lib/controllers/status-bar-tile-controller.js b/lib/controllers/status-bar-tile-controller.js
index 3518315c35..99ccfaacb4 100644
--- a/lib/controllers/status-bar-tile-controller.js
+++ b/lib/controllers/status-bar-tile-controller.js
@@ -191,9 +191,10 @@ export default class StatusBarTileController {
this.showingPushPullMenuView = true
}
- destroy () {
+ destroy (removeDomNode) {
this.branchTooltipDisposable && this.branchTooltipDisposable.dispose()
this.pushPullTooltipDisposable && this.pushPullTooltipDisposable.dispose()
this.repositoryObserver.destroy()
+ etch.destroy(this, removeDomNode)
}
}
diff --git a/lib/github-package.js b/lib/github-package.js
index d2018484ea..16b7805da3 100644
--- a/lib/github-package.js
+++ b/lib/github-package.js
@@ -1,15 +1,17 @@
/** @babel */
-import {CompositeDisposable, Disposable, File} from 'atom'
+import {CompositeDisposable, Disposable, Directory, File} from 'atom'
import Repository from './models/repository'
import FileSystemChangeObserver from './models/file-system-change-observer'
import WorkspaceChangeObserver from './models/workspace-change-observer'
-import GitPanelController from './controllers/git-panel-controller'
import FilePatchController from './controllers/file-patch-controller'
-import StatusBarTileController from './controllers/status-bar-tile-controller'
+import GitController from './controllers/git-controller'
import path from 'path'
+import React from 'react'
+import ReactDom from 'react-dom'
+
export default class GithubPackage {
constructor (workspace, project, commandRegistry, notificationManager) {
this.workspace = workspace
@@ -22,55 +24,73 @@ export default class GithubPackage {
this.activeRepository = null
this.repositoriesByProjectDirectory = new Map()
this.subscriptions = new CompositeDisposable()
- this.gitPanelController = new GitPanelController({
- repository: null,
- workspace,
- commandRegistry,
- notificationManager,
- didSelectFilePath: this.showFilePatchForPath.bind(this),
- didSelectMergeConflictFile: this.showMergeConflictFileForPath.bind(this),
- didChangeAmending: this.didChangeAmending.bind(this),
- focusFilePatchView: this.focusFilePatchView.bind(this)
- })
- this.statusBarTileController = new StatusBarTileController({
- workspace,
- repository: null,
- toggleGitPanel: this.toggleGitPanel.bind(this)
- })
- this.filePatchController = null
- }
-
- async activate () {
+ this.savedState = {}
+ }
+
+ async activate (state = {}) {
+ this.savedState = state
+ this.subscriptions = new CompositeDisposable()
await this.updateActiveRepository()
await this.changeObserver.start()
this.subscriptions.add(
+ new Disposable(() => this.changeObserver.stop()),
this.project.onDidChangePaths(this.didChangeProjectPaths.bind(this)),
this.workspace.onDidChangeActivePaneItem(this.didChangeActivePaneItem.bind(this)),
this.changeObserver.onDidChange(this.refreshActiveRepository.bind(this)),
- this.commandRegistry.add('atom-workspace', {'git:toggle-git-panel': this.toggleGitPanel.bind(this)}),
- this.commandRegistry.add('atom-workspace', {'git:focus-git-panel': this.focusGitPanel.bind(this)})
)
+ if (state.activeRepositoryDir) {
+ const dir = new Directory(state.activeRepositoryDir)
+ const repo = await this.repositoryForProjectDirectory(dir)
+ if (repo) {
+ await this.setActiveRepository(repo)
+ }
+ }
+ this.rerender()
}
- async deactivate () {
- await this.changeObserver.stop()
+ serialize () {
+ const activeRepository = this.getActiveRepository()
+ return {
+ activeRepositoryDir: activeRepository ? activeRepository.getWorkingDirectoryPath() : null,
+ gitController: this.controller.serialize(),
+ }
}
- // FIXME: Combine these 2 methods into a single consumeStatusBar method after
- // Atom 1.13 is on stable channel
- consumeStatusBar_1_0 (statusBar) {
- const tile = statusBar.addRightTile({item: this.statusBarTileController, priority: -50})
- this.subscriptions.add(new Disposable(() => tile.destroy()))
+ rerender () {
+ if (!this.element) {
+ this.element = document.createElement('div')
+ this.subscriptions.add(new Disposable(() => {
+ ReactDom.unmountComponentAtNode(this.element)
+ delete this.element
+ }))
+ }
+
+ ReactDom.render(
+ this.controller = c}
+ workspace={this.workspace}
+ commandRegistry={this.commandRegistry}
+ notificationManager={this.notificationManager}
+ repository={this.getActiveRepository()}
+ statusBar={this.statusBar}
+ savedState={this.savedState.gitController}
+ />, this.element
+ )
}
- consumeStatusBar_1_1 (statusBar) {
- statusBar.disableGitInfoTile()
- this.consumeStatusBar_1_0(statusBar)
+ async deactivate () {
+ this.subscriptions.dispose()
+ if (this.destroyedRepositorySubscription) this.destroyedRepositorySubscription.dispose()
+ }
+
+ consumeStatusBar (statusBar) {
+ this.statusBar = statusBar
+ this.rerender()
}
async didChangeProjectPaths () {
- await this.destroyRepositoriesForRemovedProjectFolders()
await this.updateActiveRepository()
+ this.destroyRepositoriesForRemovedProjectFolders()
}
async didChangeActivePaneItem () {
@@ -83,102 +103,41 @@ export default class GithubPackage {
}
}
- async showFilePatchForPath (filePath, stagingStatus, {activate, amending} = {}) {
- const repository = this.getActiveRepository()
- const filePatch = await repository.getFilePatchForPath(filePath, {staged: stagingStatus === 'staged', amending})
- if (filePatch) {
- let containingPane
- const onRepoRefresh = () => {
- this.showFilePatchForPath(filePath, stagingStatus, {amending})
- }
- if (this.filePatchController) {
- this.filePatchController.update({filePatch, repository, stagingStatus, onRepoRefresh})
- containingPane = this.workspace.paneForItem(this.filePatchController)
- } else {
- this.filePatchController = new FilePatchController({filePatch, repository, stagingStatus, onRepoRefresh})
- this.filePatchController.onDidDestroy(() => { this.filePatchController = null })
- containingPane = this.workspace.getActivePane()
- }
- if (activate) {
- containingPane.activateItem(this.filePatchController)
- }
- } else {
- if (this.filePatchController) {
- this.filePatchController.destroy()
- this.filePatchController = null
- }
- }
- }
-
- focusFilePatchView () {
- this.filePatchController.refs.filePatchView.element.focus()
- }
-
- async showMergeConflictFileForPath (relativeFilePath, {focus} = {}) {
- const absolutePath = path.join(this.getActiveRepository().getWorkingDirectoryPath(), relativeFilePath)
- if (await new File(absolutePath).exists()) {
- return this.workspace.open(absolutePath, {activatePane: Boolean(focus)})
- } else {
- this.notificationManager.addInfo('File has been deleted.')
- }
- }
-
- didChangeAmending () {
- if (this.filePatchController && this.filePatchController.props.stagingStatus === 'staged') {
- this.filePatchController.destroy()
- this.filePatchController = null
- }
- }
-
- toggleGitPanel () {
- if (this.workspaceGitPanel) {
- this.workspaceGitPanel.destroy()
- this.workspaceGitPanel = null
- } else {
- this.focusGitPanel()
- }
- }
-
- focusGitPanel () {
- this.workspaceGitPanel = this.workspace.addRightPanel({item: this.gitPanelController})
- this.gitPanelController.focus()
- }
-
getActiveRepository () {
return this.activeRepository
}
async updateActiveRepository () {
- let activeRepository
+ let nextActiveRepository
let activeItem = this.workspace.getActivePaneItem()
- if (activeItem) {
- if (typeof activeItem.getPath === 'function') {
- let projectDirectory = this.projectDirectoryForPath(activeItem.getPath())
- if (projectDirectory) {
- activeRepository = await this.repositoryForProjectDirectory(projectDirectory)
- }
- } else {
- activeRepository = this.activeRepository
+ if (activeItem && typeof activeItem.getPath === 'function') {
+ let projectDirectory = this.projectDirectoryForPath(activeItem.getPath())
+ if (projectDirectory) {
+ nextActiveRepository = await this.repositoryForProjectDirectory(projectDirectory)
}
+ } else {
+ nextActiveRepository = this.activeRepository
}
if (activeItem instanceof FilePatchController) {
if (!activeItem.props.repository.isDestroyed()) {
- activeRepository = activeItem.props.repository
+ nextActiveRepository = activeItem.props.repository
}
}
- if (activeRepository && activeRepository !== this.activeRepository) {
- await this.setActiveRepository(activeRepository)
+ if (nextActiveRepository && nextActiveRepository !== this.activeRepository) {
+ await this.setActiveRepository(nextActiveRepository)
if (this.destroyedRepositorySubscription) this.destroyedRepositorySubscription.dispose()
- this.destroyedRepositorySubscription = activeRepository.onDidDestroy(() => {
- this.setActiveRepository(null)
+ this.destroyedRepositorySubscription = nextActiveRepository.onDidDestroy(() => {
+ if (nextActiveRepository === this.activeRepository) {
+ this.setActiveRepository(null)
+ }
this.destroyedRepositorySubscription.dispose()
})
}
- if (!activeRepository) {
+ if (!nextActiveRepository) {
await this.setActiveRepository(null)
}
}
@@ -186,8 +145,7 @@ export default class GithubPackage {
async setActiveRepository (repository) {
this.activeRepository = repository
await this.changeObserver.setActiveRepository(repository)
- await this.statusBarTileController.update({repository})
- await this.gitPanelController.update({repository})
+ this.rerender()
}
projectDirectoryForPath (path) {
@@ -216,12 +174,4 @@ export default class GithubPackage {
}
}
}
-
- destroy () {
- this.subscriptions.dispose()
- if (this.destroyedRepositorySubscription) this.destroyedRepositorySubscription.dispose()
- this.gitPanelController.destroy()
- this.statusBarTileController.destroy()
- if (this.filePatchController) this.filePatchController.destroy()
- }
}
diff --git a/lib/models/repository.js b/lib/models/repository.js
index cc9b5b63c7..6ed98ca581 100644
--- a/lib/models/repository.js
+++ b/lib/models/repository.js
@@ -149,8 +149,8 @@ export default class Repository {
this.unstagedFilePatchesByPath = new Map()
}
- getCachedFilePatchForPath (filePath, {staged, amend} = {}) {
- if (staged && amend) {
+ getCachedFilePatchForPath (filePath, {staged, amending} = {}) {
+ if (staged && amending) {
return this.stagedFilePatchesSinceParentCommitByPath.get(filePath)
} else if (staged) {
return this.stagedFilePatchesByPath.get(filePath)
@@ -159,9 +159,9 @@ export default class Repository {
}
}
- cacheFilePatchForPath (filePath, filePatch, {staged, amend} = {}) {
+ cacheFilePatchForPath (filePath, filePatch, {staged, amending} = {}) {
let cachedFilePatch
- if (staged && amend) {
+ if (staged && amending) {
this.stagedFilePatchesSinceParentCommitByPath.set(filePath, filePatch)
} else if (staged) {
this.stagedFilePatchesByPath.set(filePath, filePatch)
diff --git a/lib/views/etch-wrapper.js b/lib/views/etch-wrapper.js
new file mode 100644
index 0000000000..6fcc805878
--- /dev/null
+++ b/lib/views/etch-wrapper.js
@@ -0,0 +1,129 @@
+/** @babel */
+
+import React from 'react'
+
+/**
+ * `EtchWrapper` is a React component that renders Etch components
+ * and correctly manages their lifecycles as the application progresses.
+ *
+ *
+ *
+ *
+ *
+ * The `type` property specifies the DOM node type to wrap around the
+ * Etch component's element, and defaults to 'div'. Any other props you
+ * pass to the wrapper component will be applied to the DOM node.
+ *
+ * `reattachDomNode` determines whether or not to place the wrapped component
+ * element back in the React component's DOM node if we find it's missing;
+ * this could happen due to changing the `type` property. If you pass the
+ * wrapped component element into a method that moves the element, you should
+ * specify `false` for this option.
+ *
+ * The component takes a single JSX child, which describes the type and props
+ * of the Etch component to render. Any time this changes, the wrapper will
+ * update (or destroy and recreate) the Etch component as necessary.
+ *
+ * Note that the component cleans up its own DOM node, and calls
+ * `component.destroy(false)` (if your component has a `destroy` method)
+ * and you should pass the `false` as the second argument to
+ * `etch.destroy(this)` (e.g. `etch.destroy(this, false)`) inside your
+ * component instance.
+ *
+ * The component instance is available at `this.getWrappedComponent` if you need
+ * to call methods on it from the outside (though you should really consider
+ * setting a prop instead. ;)
+ */
+export default class EtchWrapper extends React.Component {
+ static propTypes = {
+ children: React.PropTypes.element.isRequired,
+ type: React.PropTypes.string,
+ reattachDomNode: React.PropTypes.bool,
+ }
+
+ static defaultProps = {
+ type: 'div',
+ reattachDomNode: true,
+ }
+
+ componentDidMount () {
+ this.createComponent(this.getWrappedComponentDetails(this.props.children))
+ }
+
+ componentWillReceiveProps (newProps) {
+ const oldDetails = this.getWrappedComponentDetails(this.props.children)
+ const newDetails = this.getWrappedComponentDetails(newProps.children)
+ if (oldDetails.type !== newDetails.type) {
+ // The wrapped component type changed, so we need to destroy the old
+ // component and create a new one of the new type.
+ this.destroyComponent()
+ this.createComponent(newDetails)
+ }
+ }
+
+ async componentDidUpdate (prevProps) {
+ const oldDetails = this.getWrappedComponentDetails(prevProps.children)
+ const newDetails = this.getWrappedComponentDetails(this.props.children)
+
+ if (oldDetails.type === newDetails.type) {
+ // We didn't change the wrapped (Etch) component type,
+ // so we need to update the instance with the new props.
+ await this.updateComponent(this.getWrappedComponentDetails(this.props.children))
+ }
+
+ // If we just recreated our DOM node by changing the node type, we
+ // need to reattach the wrapped component's element.
+ if (this.props.reattachDomNode && !this.container.contains(this.component.element)) {
+ this.container.appendChild(this.component.element)
+ }
+ }
+
+ render () {
+ const Type = this.props.type
+ const {type, children, reattachDomNode, ...props} = this.props
+ return this.container = c} />
+ }
+
+ componentWillUnmount () {
+ this.destroyComponent()
+ }
+
+ getWrappedComponentDetails (ourChildren) {
+ // e.g. Hi
+ const etchElement = React.Children.toArray(ourChildren)[0]
+ // etchElement === {type: EtchChild, props: {prop: 1, other: 2, children: 'Hi'}}
+ const {type, props} = etchElement
+ // type === EtchChild, props === {prop: 1, other: 2, children: 'Hi'}
+ const {children, ...remainingProps} = props
+ // children === 'Hi', remainingProps === {prop: 1, other: 2}
+ return {type, children, props: remainingProps}
+ }
+
+ // For compatability with Atom's ViewProvider
+ getElement () {
+ return this.container
+ }
+
+ // Etch component interactions
+
+ getWrappedComponent () {
+ return this.component
+ }
+
+ createComponent ({type, props, children}) {
+ this.component = new type(props, children)
+ this.container.appendChild(this.component.element)
+ }
+
+ updateComponent ({props, children}) {
+ return this.component.update(props, children)
+ }
+
+ destroyComponent () {
+ if (this.container.contains(this.component.element)) {
+ this.container.removeChild(this.component.element)
+ }
+ this.component.destroy && this.component.destroy(false)
+ delete this.component
+ }
+}
diff --git a/lib/views/file-patch-view.js b/lib/views/file-patch-view.js
index a163169402..4ab3fc726c 100644
--- a/lib/views/file-patch-view.js
+++ b/lib/views/file-patch-view.js
@@ -205,4 +205,8 @@ export default class FilePatchView {
throw new Error(`Unknown stagingStatus: ${this.props.stagingStatus}`)
}
}
+
+ focus () {
+ this.element.focus()
+ }
}
diff --git a/lib/views/git-panel-view.js b/lib/views/git-panel-view.js
index 27e595a004..a9d97d92f2 100644
--- a/lib/views/git-panel-view.js
+++ b/lib/views/git-panel-view.js
@@ -77,6 +77,8 @@ export default class GitPanelView {
}
focus () {
- if (this.refs.stagingView) this.refs.stagingView.focus()
+ if (this.refs.stagingView) {
+ this.refs.stagingView.focus()
+ }
}
}
diff --git a/lib/views/pane-item.js b/lib/views/pane-item.js
new file mode 100644
index 0000000000..37d299f055
--- /dev/null
+++ b/lib/views/pane-item.js
@@ -0,0 +1,86 @@
+/** @babel */
+
+import React from 'react'
+
+import Portal from './portal'
+
+/**
+ * `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.
+ *
+ * You may pass a `getItem` function that takes an object with `portal` and
+ * `subtree` properties. `getItem` should return an item to be added to the
+ * Panel. `portal` is an instance of th Portal component, and `subtree` is the
+ * rendered subtree component built from the `children` prop. The default
+ * implementation simply returns the Portal instance, which contains a
+ * `getElement` method (to be compatible with Atom's view system).
+ *
+ * Unmounting the component when the item is open will close the item.
+ */
+export default class PaneItem extends React.Component {
+ static propTypes = {
+ workspace: React.PropTypes.object.isRequired,
+ getItem: React.PropTypes.func,
+ onDidCloseItem: React.PropTypes.func,
+ }
+
+ static defaultProps = {
+ getItem: ({portal, subtree}) => portal,
+ onDidCloseItem: (paneItem) => {},
+ }
+
+ componentDidMount () {
+ this.setupPaneItem()
+ }
+
+ componentWillReceiveProps () {
+ if (this.didCloseItem) {
+ console.error(
+ `Unexpected update in \`PaneItem\`: the contained item has been closed`
+ )
+ }
+ }
+
+ render () {
+ return this.portal = c}>{this.props.children}
+ }
+
+ setupPaneItem () {
+ if (this.paneItem) return
+
+ const item = this.props.getItem({portal: this.portal, subtree: this.portal.getRenderedSubtree()})
+ this.paneItem = this.props.workspace.getActivePane().addItem(item)
+ this.subscriptions = this.props.workspace.onDidDestroyPaneItem(({item}) => {
+ if (item === this.paneItem) {
+ this.didCloseItem = true
+ this.props.onDidCloseItem(this.paneItem)
+ }
+ })
+ }
+
+ getPaneItem () {
+ return this.paneItem
+ }
+
+ componentWillUnmount () {
+ this.subscriptions && this.subscriptions.dispose()
+ if (this.paneItem && !this.didCloseItem) {
+ this.paneItem.destroy()
+ }
+ }
+
+ activate () {
+ setTimeout(() => {
+ if (!this.paneItem) return
+
+ const pane = this.props.workspace.paneForItem(this.paneItem)
+ if (pane) {
+ pane.activateItem(this.paneItem)
+ } else {
+ throw new Error(`Cannot find pane for item in \`PaneItem#activate\``)
+ }
+ })
+ }
+}
diff --git a/lib/views/panel.js b/lib/views/panel.js
new file mode 100644
index 0000000000..4c357afcb8
--- /dev/null
+++ b/lib/views/panel.js
@@ -0,0 +1,87 @@
+/** @babel */
+
+import React from 'react'
+
+import Portal from './portal'
+
+/**
+ * `Panel` renders a React component into an Atom panel. Specify the
+ * location via the `location` prop, and any additional options to the
+ * `addXPanel` method in the `options` prop.
+ *
+ * You may pass a `getItem` function that takes an object with `portal` and
+ * `subtree` properties. `getItem` should return an item to be added to the
+ * Panel. `portal` is an instance of the Portal component, and `subtree` is the
+ * rendered subtree component built from the `children` prop. The default
+ * implementation simply returns the Portal instance, which contains a
+ * `getElement` method (to be compatible with Atom's view system).
+ *
+ * You can get the underlying Atom panel via `getPanel()`, but you should
+ * consider controlling the panel via React and the Panel component instead.
+ */
+export default class Panel extends React.Component {
+ static propTypes = {
+ workspace: React.PropTypes.object.isRequired,
+ location: React.PropTypes.oneOf([
+ 'top', 'bottom', 'left', 'right', 'header', 'footer', 'modal'
+ ]).isRequired,
+ getItem: React.PropTypes.func,
+ options: React.PropTypes.object,
+ onDidClosePanel: React.PropTypes.func,
+ visible: React.PropTypes.bool,
+ }
+
+ static defaultProps = {
+ options: {},
+ getItem: ({portal, subtree}) => portal,
+ onDidClosePanel: (panel) => {},
+ visible: true,
+ }
+
+ componentDidMount () {
+ this.setupPanel()
+ }
+
+ componentWillReceiveProps (newProps) {
+ if (this.didCloseItem) {
+ console.error(
+ `Unexpected update in \`Panel\`: the contained panel has been destroyed`
+ )
+ }
+
+ if (this.panel && this.props.visible !== newProps.visible) {
+ this.panel[newProps.visible ? 'show' : 'hide']()
+ }
+ }
+
+ render () {
+ return this.portal = c}>{this.props.children}
+ }
+
+ setupPanel () {
+ if (this.panel) return
+
+ // "left" => "Left"
+ const location = this.props.location.substr(0, 1).toUpperCase() + this.props.location.substr(1)
+ const methodName = `add${location}Panel`
+
+ const item = this.props.getItem({portal: this.portal, subtree: this.portal.getRenderedSubtree()})
+ const options = Object.assign({}, this.props.options, { visible: this.props.visible, item })
+ this.panel = this.props.workspace[methodName](options)
+ this.subscriptions = this.panel.onDidDestroy(() => {
+ this.didCloseItem = true
+ this.props.onDidClosePanel(this.panel)
+ })
+ }
+
+ componentWillUnmount () {
+ this.subscriptions && this.subscriptions.dispose()
+ if (this.panel) {
+ this.panel.destroy()
+ }
+ }
+
+ getPanel () {
+ return this.panel
+ }
+}
diff --git a/lib/views/portal.js b/lib/views/portal.js
new file mode 100644
index 0000000000..8d6f848e32
--- /dev/null
+++ b/lib/views/portal.js
@@ -0,0 +1,81 @@
+/** @babel */
+
+import React from 'react'
+import ReactDom from 'react-dom'
+
+/**
+ * `Portal` is a mechanism for rendering a React subtree at a different place
+ * in the DOM.
+ *
+ *
+ *
+ *
+ *
+ * Given the above example, there will be a span with the class "portal-class"
+ * created and appended to the document body, and then `` will be
+ * rendered into it. Note that this uses `unstable_renderSubtreeIntoContainer`
+ * to preserve context in the subtree.
+ *
+ * `getElement()` allows acccess to the React subtree container element.
+ * `getRenderedSubtree()` allows access to the rendered subtree instance
+ * (`Stuff` in the example above).
+ *
+ * Pass `false` (the default) to `appendNode` to skip adding the node to the
+ * DOM. `type` defaults to "div" and `className` defaults to
+ * "react-atom-portal".
+ */
+export default class Portal extends React.Component {
+ static propTypes = {
+ type: React.PropTypes.string,
+ className: React.PropTypes.string,
+ appendNode: React.PropTypes.bool,
+ }
+
+ static defaultProps = {
+ type: 'div',
+ className: 'react-atom-portal',
+ appendNode: false,
+ }
+
+ componentDidMount () {
+ this.node = document.createElement(this.props.type)
+ this.node.className = this.props.className
+ if (this.props.appendNode) {
+ document.body.appendChild(this.node)
+ }
+ this.renderPortal(this.props)
+ }
+
+ componentWillReceiveProps (newProps) {
+ this.renderPortal(newProps)
+ }
+
+ componentWillUnmount () {
+ ReactDom.unmountComponentAtNode(this.node)
+ if (this.props.appendNode) {
+ document.body.removeChild(this.node)
+ }
+ }
+
+ renderPortal (props) {
+ this.subtree = ReactDom.unstable_renderSubtreeIntoContainer(
+ this, props.children, this.node
+ )
+ }
+
+ shouldComponentUpdate () {
+ return false
+ }
+
+ render () {
+ return null
+ }
+
+ getRenderedSubtree () {
+ return this.subtree
+ }
+
+ getElement () {
+ return this.node
+ }
+}
diff --git a/lib/views/status-bar.js b/lib/views/status-bar.js
new file mode 100644
index 0000000000..893cb7f4eb
--- /dev/null
+++ b/lib/views/status-bar.js
@@ -0,0 +1,36 @@
+/** @babel */
+
+import React from 'react'
+
+// TODO: updates, unmount
+export default class StatusBar extends React.Component {
+ static propTypes = {
+ statusBar: React.PropTypes.object,
+ onConsumeStatusBar: React.PropTypes.func,
+ }
+
+ static defaultProps = {
+ onConsumeStatusBar: (statusBar) => {},
+ }
+
+ componentDidMount () {
+ this.consumeStatusBar()
+ }
+
+ componentDidUpdate () {
+ this.consumeStatusBar()
+ }
+
+ render () {
+ return this.container = c}>{this.props.children}
+ }
+
+ consumeStatusBar () {
+ if (this.tile) return
+ if (!this.props.statusBar) return
+
+ const componentElement = this.container.children[0]
+ this.tile = this.props.statusBar.addRightTile({item: componentElement, priority: -50})
+ this.props.onConsumeStatusBar(this.props.statusBar)
+ }
+}
diff --git a/package.json b/package.json
index 7de379378e..c09fcde25f 100644
--- a/package.json
+++ b/package.json
@@ -21,16 +21,19 @@
"multi-list-selection": "^0.1.1",
"ncp": "^2.0.0",
"nsfw": "1.0.12",
+ "react": "^15.4.1",
+ "react-dom": "^15.4.1",
"recursive-readdir": "^2.0.0",
"tree-kill": "^1.1.0",
"what-the-diff": "^0.2.0"
},
"devDependencies": {
- "atom-mocha-test-runner": "0.3.0",
+ "atom-mocha-test-runner": "0.3.1",
"babel-eslint": "^6.0.4",
"chai": "^3.5.0",
"chai-as-promised": "^5.3.0",
"dedent-js": "^1.0.1",
+ "enzyme": "^2.6.0",
"eslint": "2.10.2",
"eslint-config-standard": "^5.2.0",
"eslint-plugin-babel": "^3.2.0",
@@ -42,15 +45,15 @@
"eslint-plugin-standard": "^1.3.2",
"fs-extra": "^1.0.0",
"mkdirp": "^0.5.1",
+ "react-addons-test-utils": "^15.4.1",
"simulant": "^0.2.2",
- "sinon": "^1.17.4",
+ "sinon": "2.0.0-pre.4",
"temp": "^0.8.3"
},
"consumedServices": {
"status-bar": {
"versions": {
- "1.0.0": "consumeStatusBar_1_0",
- "1.1.0": "consumeStatusBar_1_1"
+ "^1.0.0": "consumeStatusBar"
}
}
},
diff --git a/test/controllers/git-controller.test.js b/test/controllers/git-controller.test.js
new file mode 100644
index 0000000000..7347f21e60
--- /dev/null
+++ b/test/controllers/git-controller.test.js
@@ -0,0 +1,250 @@
+/** @babel */
+
+import path from 'path'
+import fs from 'fs'
+
+import React from 'react'
+import ReactDom from 'react-dom'
+import ReactTestUtils from 'react-addons-test-utils'
+import sinon from 'sinon'
+import {shallow} from 'enzyme'
+
+import {cloneRepository, buildRepository, until} from '../helpers'
+
+import GitController from '../../lib/controllers/git-controller'
+
+import {expect} from 'chai'
+
+describe('GitController', () => {
+ let atomEnv, workspace, commandRegistry, notificationManager, app
+
+ beforeEach(async () => {
+ atomEnv = global.buildAtomEnvironment()
+ workspace = atomEnv.workspace
+ commandRegistry = atomEnv.commands
+ notificationManager = atomEnv.notifications
+ app =
+ })
+
+ afterEach(() => {
+ atomEnv.destroy()
+ })
+
+ describe('showMergeConflictFileForPath(filePath)', () => {
+ it('opens the file as a pending pane item if it exsits', async () => {
+ const workdirPath = await cloneRepository('merge-conflict')
+ const repository = await buildRepository(workdirPath)
+ sinon.spy(workspace, 'open')
+ app = React.cloneElement(app, {repository})
+ const wrapper = shallow(app)
+ await wrapper.instance().showMergeConflictFileForPath('added-to-both.txt')
+
+ assert.equal(workspace.open.callCount, 1)
+ assert.deepEqual(workspace.open.args[0], [path.join(workdirPath, 'added-to-both.txt'), {activatePane: false, pending: true}])
+ })
+
+ describe('when the file doesn\'t exist', () => {
+ it('shows an info notification and does not open the file', async () => {
+ const workdirPath = await cloneRepository('merge-conflict')
+ const repository = await buildRepository(workdirPath)
+ fs.unlinkSync(path.join(workdirPath, 'added-to-both.txt'))
+
+ sinon.spy(notificationManager, 'addInfo')
+ sinon.spy(workspace, 'open')
+ app = React.cloneElement(app, {repository})
+ const wrapper = shallow(app)
+
+ assert.equal(notificationManager.getNotifications().length, 0)
+ await wrapper.instance().showMergeConflictFileForPath('added-to-both.txt')
+ assert.equal(workspace.open.callCount, 0)
+ assert.equal(notificationManager.addInfo.callCount, 1)
+ assert.deepEqual(notificationManager.addInfo.args[0], ['File has been deleted.'])
+ })
+ })
+ })
+
+ describe('rendering a FilePatch', () => {
+ it('renders the FilePatchController based on state', async () => {
+ const workdirPath = await cloneRepository('three-files')
+ const repository = await buildRepository(workdirPath)
+ app = React.cloneElement(app, {repository})
+ const wrapper = shallow(app)
+
+ wrapper.setState({
+ filePath: null,
+ filePatch: null,
+ stagingStatus: null,
+ })
+ assert.equal(wrapper.find('FilePatchController').length, 0)
+
+ const state = {
+ filePath: 'path',
+ filePatch: Symbol('filePatch'),
+ stagingStatus: 'stagingStatus',
+ }
+ wrapper.setState(state)
+ assert.equal(wrapper.find('FilePatchController').length, 1)
+ assert.equal(wrapper.find('PaneItem').length, 1)
+ assert.equal(wrapper.find('PaneItem FilePatchController').length, 1)
+ assert.equal(wrapper.find('FilePatchController').prop('filePatch'), state.filePatch)
+ assert.equal(wrapper.find('FilePatchController').prop('stagingStatus'), state.stagingStatus)
+ assert.equal(wrapper.find('FilePatchController').prop('repository'), app.props.repository)
+ })
+ })
+
+ describe('showFilePatchForPath(filePath, staged, {amending, activate})', () => {
+ describe('when a file is selected in the staging panel', () => {
+ it('sets appropriate state', async () => {
+ const workdirPath = await cloneRepository('three-files')
+ const repository = await buildRepository(workdirPath)
+
+ fs.writeFileSync(path.join(workdirPath, 'a.txt'), 'change', 'utf8')
+ fs.writeFileSync(path.join(workdirPath, 'd.txt'), 'new-file', 'utf8')
+ await repository.stageFiles(['d.txt'])
+
+ app = React.cloneElement(app, {repository})
+ let wrapper = shallow(app)
+
+ await wrapper.instance().showFilePatchForPath('a.txt', 'unstaged')
+
+ assert.equal(wrapper.state('filePath'), 'a.txt')
+ assert.equal(wrapper.state('filePatch').getPath(), 'a.txt')
+ assert.equal(wrapper.state('stagingStatus'), 'unstaged')
+
+ await wrapper.instance().showFilePatchForPath('d.txt', 'staged')
+
+ assert.equal(wrapper.state('filePath'), 'd.txt')
+ assert.equal(wrapper.state('filePatch').getPath(), 'd.txt')
+ assert.equal(wrapper.state('stagingStatus'), 'staged')
+
+ wrapper.find('PaneItem').prop('onDidCloseItem')()
+ assert.isNull(wrapper.state('filePath'))
+ assert.isNull(wrapper.state('filePatch'))
+ assert.isNull(wrapper.state('stagingStatus'))
+
+ const activate = sinon.stub()
+ wrapper.instance().filePatchControllerPane = {activate}
+ await wrapper.instance().showFilePatchForPath('d.txt', 'staged', {activate: true})
+ assert.equal(activate.callCount, 1)
+ })
+ })
+
+ describe('when there is a change to the repo', () => {
+ it('calls onRepoRefresh', async () => {
+ const workdirPath = await cloneRepository('multiple-commits')
+ const repository = await buildRepository(workdirPath)
+
+ fs.writeFileSync(path.join(workdirPath, 'file.txt'), 'change', 'utf8')
+
+ app = React.cloneElement(app, {repository})
+ let wrapper = shallow(app)
+
+ sinon.spy(wrapper.instance(), 'onRepoRefresh')
+ await repository.refresh()
+ assert(wrapper.instance().onRepoRefresh.called)
+ })
+ })
+
+ describe('#onRepoRefresh', () => {
+ it('sets the correct FilePatch as state', async () => {
+ const workdirPath = await cloneRepository('multiple-commits')
+ const repository = await buildRepository(workdirPath)
+
+ fs.writeFileSync(path.join(workdirPath, 'file.txt'), 'change', 'utf8')
+
+ app = React.cloneElement(app, {repository})
+ let wrapper = shallow(app)
+
+ await wrapper.instance().showFilePatchForPath('file.txt', 'unstaged', {activate: true})
+
+ const originalFilePatch = wrapper.state('filePatch')
+ assert.equal(wrapper.state('filePath'), 'file.txt')
+ assert.equal(wrapper.state('filePatch').getPath(), 'file.txt')
+ assert.equal(wrapper.state('stagingStatus'), 'unstaged')
+
+ fs.writeFileSync(path.join(workdirPath, 'file.txt'), 'change\nand again!', 'utf8')
+ await repository.refresh()
+ await wrapper.instance().onRepoRefresh()
+
+ assert.equal(wrapper.state('filePath'), 'file.txt')
+ assert.equal(wrapper.state('filePatch').getPath(), 'file.txt')
+ assert.equal(wrapper.state('stagingStatus'), 'unstaged')
+ assert.notEqual(originalFilePatch, wrapper.state('filePatch'))
+ })
+ })
+ })
+
+ describe('when amend mode is toggled in the staging panel while viewing a staged change', () => {
+ it('refetches the FilePatch with the amending flag toggled', async () => {
+ const workdirPath = await cloneRepository('multiple-commits')
+ const repository = await buildRepository(workdirPath)
+
+ app = React.cloneElement(app, {repository})
+ let wrapper = shallow(app)
+
+ fs.writeFileSync(path.join(workdirPath, 'file.txt'), 'change', 'utf8')
+ await wrapper.instance().showFilePatchForPath('file.txt', 'unstaged', {amending: false})
+ const originalFilePatch = wrapper.state('filePatch')
+ assert.isOk(originalFilePatch)
+
+ sinon.spy(wrapper.instance(), 'showFilePatchForPath')
+ await wrapper.instance().didChangeAmending(true)
+ assert.isTrue(wrapper.instance().showFilePatchForPath.args[0][2].amending)
+ })
+ })
+
+ describe('when the StatusBarTileController calls toggleGitPanel', () => {
+ it('toggles the git panel', async () => {
+ const workdirPath = await cloneRepository('multiple-commits')
+ const repository = await buildRepository(workdirPath)
+
+ app = React.cloneElement(app, {repository})
+ let wrapper = shallow(app)
+
+ assert.isFalse(wrapper.find('Panel').prop('visible'))
+ wrapper.find('StatusBarTileController').prop('toggleGitPanel')()
+ assert.isTrue(wrapper.find('Panel').prop('visible'))
+ wrapper.find('StatusBarTileController').prop('toggleGitPanel')()
+ assert.isFalse(wrapper.find('Panel').prop('visible'))
+ })
+ })
+
+ describe('toggleGitPanel()', () => {
+ it('toggles the visibility of the Git panel', async () => {
+ const workdirPath = await cloneRepository('multiple-commits')
+ const repository = await buildRepository(workdirPath)
+
+ app = React.cloneElement(app, {repository})
+ let wrapper = shallow(app)
+
+ assert.isFalse(wrapper.find('Panel').prop('visible'))
+ wrapper.instance().toggleGitPanel()
+ assert.isTrue(wrapper.find('Panel').prop('visible'))
+ wrapper.instance().toggleGitPanel()
+ assert.isFalse(wrapper.find('Panel').prop('visible'))
+ })
+ })
+
+ describe('openAndFocusGitPanel()', () => {
+ it('shows-and-focuses the git panel', async () => {
+ const workdirPath = await cloneRepository('multiple-commits')
+ const repository = await buildRepository(workdirPath)
+
+ app = React.cloneElement(app, {repository})
+ let wrapper = shallow(app)
+
+ sinon.spy(wrapper.instance(), 'focusGitPanel')
+ assert.isFalse(wrapper.find('Panel').prop('visible'))
+ wrapper.instance().openAndFocusGitPanel()
+ assert.isTrue(wrapper.find('Panel').prop('visible'))
+ // TODO: remove this once we figure out the odd behavior that requires
+ // a setTimeout in openAndFocusGitPanel's setState callbasck
+ await new Promise(res => setTimeout(res, 250))
+ assert.equal(wrapper.instance().focusGitPanel.callCount, 1)
+ })
+ })
+})
diff --git a/test/github-package.test.js b/test/github-package.test.js
index 8df81a75e0..8ff270d985 100644
--- a/test/github-package.test.js
+++ b/test/github-package.test.js
@@ -5,7 +5,7 @@ import path from 'path'
import temp from 'temp'
import etch from 'etch'
import sinon from 'sinon'
-import {cloneRepository, buildRepository} from './helpers'
+import {cloneRepository, buildRepository, until} from './helpers'
import FilePatch from '../lib/models/file-patch'
import GithubPackage from '../lib/github-package'
@@ -37,8 +37,6 @@ describe('GithubPackage', () => {
await workspace.open(path.join(workdirPath1, 'a.txt'))
await githubPackage.activate()
assert.equal(githubPackage.getActiveRepository(), await githubPackage.repositoryForWorkdirPath(workdirPath1))
- assert.equal(githubPackage.gitPanelController.getActiveRepository(), githubPackage.getActiveRepository())
- assert.equal(githubPackage.statusBarTileController.getActiveRepository(), githubPackage.getActiveRepository())
})
})
@@ -51,34 +49,31 @@ describe('GithubPackage', () => {
project.setPaths([workdirPath1, workdirPath2, nonRepositoryPath])
fs.writeFileSync(path.join(workdirPath1, 'a.txt'), 'change 1', 'utf8')
+ sinon.spy(githubPackage, 'rerender')
await workspace.open(path.join(workdirPath1, 'a.txt'))
await githubPackage.didChangeProjectPaths()
assert.equal(githubPackage.getActiveRepository(), await githubPackage.repositoryForWorkdirPath(workdirPath1))
- assert.equal(githubPackage.gitPanelController.getActiveRepository(), githubPackage.getActiveRepository())
assert.equal(githubPackage.changeObserver.getActiveRepository(), githubPackage.getActiveRepository())
- assert.equal(githubPackage.statusBarTileController.getActiveRepository(), githubPackage.getActiveRepository())
+ assert.equal(githubPackage.rerender.callCount, 1)
// Remove repository for open file
project.setPaths([workdirPath2, nonRepositoryPath])
await githubPackage.didChangeProjectPaths()
assert.isNull(githubPackage.getActiveRepository())
assert.isNull(githubPackage.changeObserver.getActiveRepository())
- assert.isNull(githubPackage.gitPanelController.getActiveRepository())
- assert.isNull(githubPackage.statusBarTileController.getActiveRepository())
+ assert.equal(githubPackage.rerender.callCount, 2)
await workspace.open(path.join(workdirPath2, 'b.txt'))
await githubPackage.didChangeProjectPaths()
assert.equal(githubPackage.getActiveRepository(), await githubPackage.repositoryForWorkdirPath(workdirPath2))
assert.equal(githubPackage.changeObserver.getActiveRepository(), githubPackage.getActiveRepository())
- assert.equal(githubPackage.gitPanelController.getActiveRepository(), githubPackage.getActiveRepository())
- assert.equal(githubPackage.statusBarTileController.getActiveRepository(), githubPackage.getActiveRepository())
+ assert.equal(githubPackage.rerender.callCount, 3)
await workspace.open(path.join(nonRepositoryPath, 'c.txt'))
await githubPackage.didChangeProjectPaths()
assert.isNull(githubPackage.getActiveRepository())
assert.isNull(githubPackage.changeObserver.getActiveRepository())
- assert.isNull(githubPackage.gitPanelController.getActiveRepository())
- assert.isNull(githubPackage.statusBarTileController.getActiveRepository())
+ assert.equal(githubPackage.rerender.callCount, 4)
})
it('destroys all the repositories associated with the removed project folders', async () => {
@@ -96,7 +91,7 @@ describe('GithubPackage', () => {
project.removePath(workdirPath1)
project.removePath(workdirPath3)
- githubPackage.didChangeProjectPaths()
+ await githubPackage.didChangeProjectPaths()
assert.notEqual(await githubPackage.repositoryForProjectDirectory(repository1.getWorkingDirectory()), repository1)
assert.notEqual(await githubPackage.repositoryForProjectDirectory(repository3.getWorkingDirectory()), repository3)
@@ -115,14 +110,10 @@ describe('GithubPackage', () => {
await workspace.open(path.join(workdirPath1, 'a.txt'))
await githubPackage.didChangeActivePaneItem()
assert.equal(githubPackage.getActiveRepository(), await githubPackage.repositoryForWorkdirPath(workdirPath1))
- assert.equal(githubPackage.gitPanelController.getActiveRepository(), githubPackage.getActiveRepository())
- assert.equal(githubPackage.statusBarTileController.getActiveRepository(), githubPackage.getActiveRepository())
await workspace.open(path.join(workdirPath2, 'b.txt'))
await githubPackage.didChangeActivePaneItem()
assert.equal(githubPackage.getActiveRepository(), await githubPackage.repositoryForWorkdirPath(workdirPath2))
- assert.equal(githubPackage.gitPanelController.getActiveRepository(), githubPackage.getActiveRepository())
- assert.equal(githubPackage.statusBarTileController.getActiveRepository(), githubPackage.getActiveRepository())
})
})
@@ -168,234 +159,5 @@ describe('GithubPackage', () => {
await githubPackage.updateActiveRepository()
assert.isNull(githubPackage.getActiveRepository())
})
-
- describe('when the active item is a FilePatchController', () => {
- it('updates the active repository to be the one associated with the FilePatchController', async () => {
- const workdirPath = await cloneRepository('three-files')
- fs.writeFileSync(path.join(workdirPath, 'a.txt'), 'change', 'utf8')
-
- project.setPaths([workdirPath])
- const repository = await githubPackage.repositoryForWorkdirPath(workdirPath)
-
- await workspace.open(path.join(workdirPath, 'b.txt'))
- await githubPackage.updateActiveRepository()
- assert.equal(githubPackage.getActiveRepository(), repository)
-
- await githubPackage.gitPanelController.props.didSelectFilePath('a.txt', 'unstaged', {activate: true})
- assert.equal(workspace.getActivePaneItem(), githubPackage.filePatchController)
- assert.equal(githubPackage.filePatchController.props.repository, repository)
- await githubPackage.updateActiveRepository()
- assert.equal(githubPackage.getActiveRepository(), repository)
- })
- })
- })
-
- describe('showMergeConflictFileForPath(filePath)', () => {
- it('opens the file as a pane item if it exsits', async () => {
- const workdirPath = await cloneRepository('merge-conflict')
- const repository = await buildRepository(workdirPath)
- githubPackage.getActiveRepository = function () { return repository }
- await githubPackage.gitPanelController.props.didSelectMergeConflictFile('added-to-both.txt')
- assert.equal(workspace.getActivePaneItem().getPath(), path.join(workdirPath, 'added-to-both.txt'))
- })
-
- describe('when the file doesn\'t exist', () => {
- it('shows an info notification and does not open the file', async () => {
- const workdirPath = await cloneRepository('merge-conflict')
- const repository = await buildRepository(workdirPath)
- githubPackage.getActiveRepository = function () { return repository }
- fs.unlinkSync(path.join(workdirPath, 'added-to-both.txt'))
-
- assert.equal(notificationManager.getNotifications().length, 0)
- await githubPackage.gitPanelController.props.didSelectMergeConflictFile('added-to-both.txt')
- assert.isUndefined(workspace.getActivePaneItem())
- assert.equal(notificationManager.getNotifications().length, 1)
- })
- })
- })
-
- describe('showFilePatchForPath(filePath, staged, {amending, activate})', () => {
- describe('when a file is selected in the staging panel', () => {
- it('shows a FilePatchView for the selected file as a pane item, always updating the existing pane item', async () => {
- const workdirPath = await cloneRepository('three-files')
- const repository = await buildRepository(workdirPath)
-
- fs.writeFileSync(path.join(workdirPath, 'a.txt'), 'change', 'utf8')
- fs.writeFileSync(path.join(workdirPath, 'd.txt'), 'new-file', 'utf8')
- await repository.stageFiles(['d.txt'])
-
- githubPackage.getActiveRepository = function () { return repository }
-
- assert.isNull(githubPackage.filePatchController)
-
- await githubPackage.showFilePatchForPath('a.txt', 'unstaged', {activate: true})
- assert(githubPackage.filePatchController)
- assert.equal(githubPackage.filePatchController.props.filePatch.getPath(), 'a.txt')
- assert.equal(githubPackage.filePatchController.props.repository, repository)
- assert.equal(githubPackage.filePatchController.props.stagingStatus, 'unstaged')
- assert.equal(workspace.getActivePaneItem(), githubPackage.filePatchController)
-
- const existingFilePatchView = githubPackage.filePatchController
- const originalPane = workspace.getActivePane()
- originalPane.splitRight() // activate a different pane
- assert.isUndefined(workspace.getActivePaneItem())
-
- await githubPackage.showFilePatchForPath('d.txt', 'staged')
- assert.equal(githubPackage.filePatchController, existingFilePatchView)
- assert.equal(githubPackage.filePatchController.props.filePatch.getPath(), 'd.txt')
- assert.equal(githubPackage.filePatchController.props.repository, repository)
- assert.equal(githubPackage.filePatchController.props.stagingStatus, 'staged')
- assert.equal(originalPane.getActiveItem(), githubPackage.filePatchController)
-
- originalPane.getActiveItem().destroy()
- assert.isUndefined(workspace.getActivePaneItem())
- assert.isNull(githubPackage.filePatchController)
-
- await githubPackage.showFilePatchForPath('d.txt', 'staged', {activate: true})
- assert.notEqual(githubPackage.filePatchController, existingFilePatchView)
- assert.equal(githubPackage.filePatchController.props.filePatch.getPath(), 'd.txt')
- assert.equal(githubPackage.filePatchController.props.repository, repository)
- assert.equal(githubPackage.filePatchController.props.stagingStatus, 'staged')
- assert.equal(workspace.getActivePaneItem(), githubPackage.filePatchController)
- })
- })
-
- describe('when there is a change to the repo', () => {
- it('updates the FilePatchController with the correct filePatch', async () => {
- const workdirPath = await cloneRepository('multiple-commits')
- const repository = await buildRepository(workdirPath)
-
- githubPackage.getActiveRepository = function () { return repository }
- fs.writeFileSync(path.join(workdirPath, 'file.txt'), 'change', 'utf8')
- await githubPackage.showFilePatchForPath('file.txt', 'unstaged', {activate: true})
-
- let filePatchController = githubPackage.filePatchController
- sinon.spy(filePatchController, 'update')
-
- // udpate unstaged file patch
- fs.writeFileSync(path.join(workdirPath, 'file.txt'), 'change\nplus more changes', 'utf8')
- await repository.refresh()
- // repository refresh causes async actions so waiting for this is the best way to determine the update has finished
- await etch.getScheduler().getNextUpdatePromise()
- const unstagedFilePatch = await repository.getFilePatchForPath('file.txt')
- assert.deepEqual(filePatchController.update.lastCall.args[0].filePatch, unstagedFilePatch)
-
- // update staged file patch
- await repository.stageFiles(['file.txt'])
- await githubPackage.showFilePatchForPath('file.txt', 'staged', {activate: true})
- fs.writeFileSync(path.join(workdirPath, 'file.txt'), 'change\nplus more changes\nand more!', 'utf8')
- await repository.stageFiles(['file.txt'])
- await repository.refresh()
- // repository refresh causes async actions so waiting for this is the best way to determine the update has finished
- await etch.getScheduler().getNextUpdatePromise()
- const stagedFilePatch = await repository.getFilePatchForPath('file.txt', {staged: true})
- assert.deepEqual(filePatchController.update.lastCall.args[0].filePatch, stagedFilePatch)
-
- // update amended file patch
- // didChangeAmending destroys current filePatchController
- githubPackage.gitPanelController.props.didChangeAmending()
- await githubPackage.showFilePatchForPath('file.txt', 'staged', {activate: true, amending: true})
- filePatchController = githubPackage.filePatchController
- sinon.spy(filePatchController, 'update')
- fs.writeFileSync(path.join(workdirPath, 'file.txt'), 'change file being amended', 'utf8')
- await repository.stageFiles(['file.txt'])
- await repository.refresh()
- // repository refresh causes async actions so waiting for this is the best way to determine the update has finished
- await etch.getScheduler().getNextUpdatePromise()
- const amendedStagedFilePatch = await repository.getFilePatchForPath('file.txt', {staged: true, amending: true})
- assert.deepEqual(filePatchController.update.lastCall.args[0].filePatch, amendedStagedFilePatch)
- })
- })
- })
-
- describe('when amend mode is toggled in the staging panel while viewing a staged change', () => {
- it('closes the file patch pane item', async () => {
- const workdirPath = await cloneRepository('three-files')
- const repository = await buildRepository(workdirPath)
- fs.writeFileSync(path.join(workdirPath, 'a.txt'), 'change', 'utf8')
- await repository.stageFiles(['a.txt'])
-
- githubPackage.getActiveRepository = function () { return repository }
-
- await githubPackage.showFilePatchForPath('a.txt', 'staged', {activate: true})
- assert.isOk(githubPackage.filePatchController)
- assert.equal(workspace.getActivePaneItem(), githubPackage.filePatchController)
-
- githubPackage.gitPanelController.props.didChangeAmending()
- assert.isNull(githubPackage.filePatchController)
- assert.isUndefined(workspace.getActivePaneItem())
- })
- })
-
- describe('when the changed files label in the status bar is clicked', () => {
- it('toggles the git panel', async () => {
- const workdirPath = await cloneRepository('three-files')
- project.setPaths([workdirPath])
- await workspace.open(path.join(workdirPath, 'a.txt'))
- await githubPackage.updateActiveRepository()
-
- githubPackage.statusBarTileController.refs.changedFilesCountView.props.didClick()
- assert.equal(workspace.getRightPanels().length, 1)
-
- githubPackage.statusBarTileController.refs.changedFilesCountView.props.didClick()
- assert.equal(workspace.getRightPanels().length, 0)
-
- githubPackage.statusBarTileController.refs.changedFilesCountView.props.didClick()
- assert.equal(workspace.getRightPanels().length, 1)
- })
- })
-
- describe('toggleGitPanel()', () => {
- it('shows-and-focuses or hides the git panel', async () => {
- const workspaceElement = viewRegistry.getView(workspace)
- document.body.appendChild(workspaceElement)
- const workdirPath = await cloneRepository('three-files')
- project.setPaths([workdirPath])
- await workspace.open(path.join(workdirPath, 'a.txt'))
- await githubPackage.activate()
-
- assert.equal(workspace.getRightPanels().length, 0)
- githubPackage.toggleGitPanel()
- assert.equal(workspace.getRightPanels().length, 1)
- assert.equal(workspace.getRightPanels()[0].item, githubPackage.gitPanelController)
- assert(githubPackage.gitPanelController.refs.gitPanel.refs.stagingView.isFocused())
-
- githubPackage.toggleGitPanel()
- assert.equal(workspace.getRightPanels().length, 0)
-
- githubPackage.toggleGitPanel()
- assert.equal(workspace.getRightPanels().length, 1)
- assert.equal(workspace.getRightPanels()[0].item, githubPackage.gitPanelController)
- assert(githubPackage.gitPanelController.refs.gitPanel.refs.stagingView.isFocused())
-
- workspaceElement.remove()
- })
- })
-
- describe('focusGitPanel()', () => {
- it('shows-and-focuses the git panel', async () => {
- const workspaceElement = viewRegistry.getView(workspace)
- document.body.appendChild(workspaceElement)
- const workdirPath = await cloneRepository('three-files')
- project.setPaths([workdirPath])
- await workspace.open(path.join(workdirPath, 'a.txt'))
- await githubPackage.activate()
-
- assert.equal(workspace.getRightPanels().length, 0)
- githubPackage.focusGitPanel()
- assert.equal(workspace.getRightPanels().length, 1)
- assert.equal(workspace.getRightPanels()[0].item, githubPackage.gitPanelController)
- assert(githubPackage.gitPanelController.refs.gitPanel.refs.stagingView.isFocused())
-
- githubPackage.toggleGitPanel()
- assert.equal(workspace.getRightPanels().length, 0)
-
- githubPackage.focusGitPanel()
- assert.equal(workspace.getRightPanels().length, 1)
- assert.equal(workspace.getRightPanels()[0].item, githubPackage.gitPanelController)
- assert(githubPackage.gitPanelController.refs.gitPanel.refs.stagingView.isFocused())
-
- workspaceElement.remove()
- })
})
})
diff --git a/test/helpers.js b/test/helpers.js
index 65b5f74f10..0552824c8f 100644
--- a/test/helpers.js
+++ b/test/helpers.js
@@ -3,10 +3,13 @@
import fs from 'fs-extra'
import path from 'path'
import temp from 'temp'
+
import {Directory} from 'atom'
-import GitShellOutStrategy from '../lib/git-shell-out-strategy'
+import React from 'react'
+import ReactDom from 'react-dom'
import Repository from '../lib/models/repository'
+import GitShellOutStrategy from '../lib/git-shell-out-strategy'
assert.autocrlfEqual = (actual, expected, ...args) => {
actual = actual.replace(/\r\n/g, '\n')
@@ -95,3 +98,102 @@ export function assertEqualSortedArraysByKey (arr1, arr2, key) {
const sortFn = (a, b) => a[key] < b[key]
assert.deepEqual(arr1.sort(sortFn), arr2.sort(sortFn))
}
+
+// like `waitsFor` and `waitsForPromise` except you can `await` it
+// to suspend/block an `async` test, and you don't have to use `runs`.
+// Usage: `await until([description,] [timeout,] fn)`
+// Arguments can be supplied in any order
+//
+// E.g.:
+//
+// await until('a thing happens', () => didThingHappen())
+// expect(thing).toBe(something)
+export function until (...args) {
+ const start = new Date().getTime()
+
+ let latchFunction = null
+ let message = null
+ let timeout = null
+
+ if (args.length > 3) {
+ throw new Error('until only takes up to 3 args')
+ }
+
+ for (let arg of args) {
+ switch (typeof arg) {
+ case 'function':
+ latchFunction = arg
+ break
+ case 'string':
+ message = arg
+ break
+ case 'number':
+ timeout = arg
+ break
+ }
+ }
+
+ message = message || 'something happens'
+ timeout = timeout || 5000
+ const error = new Error(`timeout: timed out after ${timeout} msec waiting until ${message}`)
+
+ return new Promise((resolve, reject) => {
+ const checker = () => {
+ const result = latchFunction()
+ if (result) return resolve(result)
+
+ const now = new Date().getTime()
+ const delta = now - start
+ if (delta > timeout) {
+ return reject(error)
+ } else {
+ setTimeout(checker)
+ }
+ }
+ checker()
+ })
+}
+
+let activeRenderers = []
+export function createRenderer () {
+ let instance
+ let lastInstance
+ let node = document.createElement('div')
+ // ref function should be reference equal over renders to avoid React
+ // calling the "old" one with `null` and the "new" one with the instance
+ const setTopLevelRef = (c) => { instance = c }
+ const renderer = {
+ render (app) {
+ lastInstance = instance
+ app = React.cloneElement(app, { ref: setTopLevelRef })
+ ReactDom.render(app, node)
+ },
+
+ get instance () {
+ return instance
+ },
+
+ get lastInstance () {
+ return lastInstance
+ },
+
+ get node () {
+ return node
+ },
+
+ unmount () {
+ if (node) {
+ lastInstance = instance
+ ReactDom.unmountComponentAtNode(node)
+ node = null
+ }
+ }
+ }
+ activeRenderers.push(renderer)
+ return renderer
+}
+
+afterEach(() => {
+ activeRenderers.forEach(r => r.unmount())
+ activeRenderers = []
+})
diff --git a/test/views/etch-wrapper.test.js b/test/views/etch-wrapper.test.js
new file mode 100644
index 0000000000..3fba23409f
--- /dev/null
+++ b/test/views/etch-wrapper.test.js
@@ -0,0 +1,58 @@
+/** @babel */
+
+import React from 'react'
+import ReactDom from 'react-dom'
+import sinon from 'sinon'
+import {mount} from 'enzyme'
+import etch from 'etch'
+
+import EtchWrapper from '../../lib/views/etch-wrapper'
+
+
+class EtchComponent {
+ constructor (props) {
+ this.updated = 0
+ this.destroyed = 0
+ this.props = props
+ etch.initialize(this)
+ }
+
+ update (props) {
+ this.updated++
+ this.props = props
+ return etch.update(this)
+ }
+
+ destroy () {
+ this.destroyed++
+ etch.destroy(this)
+ }
+
+ render () {
+ return etch.dom('div', null, this.props.text)
+ }
+}
+
+describe('EtchWrapper', () => {
+ it('constructs a wrapped etch component with the given props', async () => {
+ let app = (
+
+
+
+ )
+
+ const wrapper = mount(app)
+ const instance = wrapper.instance().getWrappedComponent()
+ assert.equal(wrapper.text(), 'hello')
+ assert.equal(instance.props.other, 'world')
+
+ wrapper.setProps({children: })
+ await etch.getScheduler().getNextUpdatePromise()
+ assert.equal(wrapper.text(), 'world')
+ assert.equal(instance.props.other, 'more')
+ assert.equal(instance.updated, 1)
+
+ wrapper.unmount()
+ assert.equal(instance.destroyed, 1)
+ })
+})
diff --git a/test/views/pane-item.test.js b/test/views/pane-item.test.js
new file mode 100644
index 0000000000..acef32864c
--- /dev/null
+++ b/test/views/pane-item.test.js
@@ -0,0 +1,105 @@
+/** @babel */
+
+import React from 'react'
+import ReactDom from 'react-dom'
+import sinon from 'sinon'
+
+import PaneItem from '../../lib/views/pane-item'
+
+import {Emitter} from 'atom'
+
+import {createRenderer} from '../helpers'
+
+class Component extends React.Component {
+ render () {
+ return (
+ {this.props.text}
+ )
+ }
+
+ getText () {
+ return this.props.text
+ }
+}
+
+describe('PaneItem component', () => {
+ let renderer, emitter, workspace, activePane
+ beforeEach(() => {
+ renderer = createRenderer()
+ emitter = new Emitter()
+ const paneItem = {
+ destroy: sinon.spy(() => emitter.emit('destroy', {item: paneItem}))
+ }
+ activePane = {
+ addItem: sinon.spy((item) => {
+ return paneItem
+ }),
+ activateItem: sinon.stub()
+ }
+ workspace = {
+ getActivePane: sinon.stub().returns(activePane),
+ paneForItem: sinon.stub().returns(activePane),
+ onDidDestroyPaneItem: (cb) => emitter.on('destroy', cb),
+ }
+ })
+
+ afterEach(() => {
+ emitter.dispose()
+ })
+
+ it('renders a React component into an Atom pane item', () => {
+ let portal, subtree
+ const item = Symbol('item')
+ let app = (
+ {
+ portal = obj.portal
+ subtree = obj.subtree
+ return item
+ }}
+ >
+
+
+ )
+ renderer.render(app)
+ assert.equal(activePane.addItem.callCount, 1)
+ assert.deepEqual(activePane.addItem.args[0], [item])
+ assert.equal(portal.getElement().textContent, 'hello')
+ assert.equal(subtree.getText(), 'hello')
+
+ app = (
+ {
+ return item
+ }}
+ onDidCloseItem={() => { throw new Error('Expected onDidCloseItem not to be called') }}
+ >
+
+
+ )
+ renderer.render(app)
+ assert.equal(activePane.addItem.callCount, 1)
+ assert.equal(portal.getElement().textContent, 'world')
+ assert.equal(subtree.getText(), 'world')
+
+ renderer.unmount()
+ assert.equal(renderer.lastInstance.getPaneItem().destroy.callCount, 1)
+ })
+
+ it('calls props.onDidCloseItem when the pane item is destroyed unexpectedly', () => {
+ const onDidCloseItem = sinon.stub()
+ let app = (
+
+
+
+ )
+ renderer.render(app)
+ renderer.instance.getPaneItem().destroy()
+ assert.equal(onDidCloseItem.callCount, 1)
+ })
+})
diff --git a/test/views/panel.test.js b/test/views/panel.test.js
new file mode 100644
index 0000000000..dfa8d8592a
--- /dev/null
+++ b/test/views/panel.test.js
@@ -0,0 +1,144 @@
+/** @babel */
+
+import React from 'react'
+import ReactDom from 'react-dom'
+import sinon from 'sinon'
+
+import Panel from '../../lib/views/panel'
+
+import {Emitter} from 'atom'
+
+import {createRenderer} from '../helpers'
+
+class Component extends React.Component {
+ render () {
+ return (
+ {this.props.text}
+ )
+ }
+
+ getText () {
+ return this.props.text
+ }
+}
+
+describe('Panel component', () => {
+ let renderer, emitter, workspace
+ beforeEach(() => {
+ renderer = createRenderer()
+ emitter = new Emitter()
+ workspace = {
+ addLeftPanel: sinon.stub().returns({
+ destroy: sinon.spy(() => emitter.emit('destroy')),
+ onDidDestroy: (cb) => emitter.on('destroy', cb),
+ show: sinon.stub(),
+ hide: sinon.stub(),
+ })
+ }
+ })
+
+ afterEach(() => {
+ emitter.dispose()
+ })
+
+ it('renders a React component into an Atom panel', () => {
+ let portal, subtree
+ const item = Symbol('item')
+ let app = (
+ {
+ portal = obj.portal
+ subtree = obj.subtree
+ return item
+ }}
+ >
+
+
+ )
+ renderer.render(app)
+ assert.equal(workspace.addLeftPanel.callCount, 1)
+ assert.deepEqual(workspace.addLeftPanel.args[0], [{some: 'option', visible: true, item: item}])
+ assert.equal(portal.getElement().textContent, 'hello')
+ assert.equal(subtree.getText(), 'hello')
+
+ app = (
+ {
+ return item
+ }}
+ onDidClosePanel={() => { throw new Error('Expected onDidClosePanel not to be called') }}
+ >
+
+
+ )
+ renderer.render(app)
+ assert.equal(workspace.addLeftPanel.callCount, 1)
+ assert.equal(portal.getElement().textContent, 'world')
+ assert.equal(subtree.getText(), 'world')
+
+ renderer.unmount()
+ assert.equal(renderer.lastInstance.getPanel().destroy.callCount, 1)
+ })
+
+ it('calls props.onDidClosePanel when the panel is destroyed unexpectedly', () => {
+ const onDidClosePanel = sinon.stub()
+ let app = (
+
+
+
+ )
+ renderer.render(app)
+ renderer.instance.getPanel().destroy()
+ assert.equal(onDidClosePanel.callCount, 1)
+ })
+
+ describe('when updating the visible prop', () => {
+ it('shows or hides the panel', () => {
+ let app = (
+
+
+
+ )
+ renderer.render(app)
+
+ const panel = renderer.instance.getPanel()
+ app = (
+
+
+
+ )
+ renderer.render(app)
+ assert.equal(panel.hide.callCount, 1)
+
+ app = (
+
+
+
+ )
+ renderer.render(app)
+ assert.equal(panel.show.callCount, 1)
+ })
+ })
+})
diff --git a/test/views/portal.test.js b/test/views/portal.test.js
new file mode 100644
index 0000000000..8ee6f736c8
--- /dev/null
+++ b/test/views/portal.test.js
@@ -0,0 +1,35 @@
+/** @babel */
+
+import React from 'react'
+import ReactDom from 'react-dom'
+
+import Portal from '../../lib/views/portal'
+
+import {createRenderer} from '../helpers'
+
+class Component extends React.Component {
+ render () {
+ return (
+ {this.props.text}
+ )
+ }
+
+ getText () {
+ return this.props.text
+ }
+}
+
+describe('Portal', () => {
+ it('renders a subtree into a different dom node', () => {
+ const renderer = createRenderer()
+ renderer.render()
+ assert.equal(renderer.instance.getElement().textContent, 'hello')
+ assert.equal(renderer.instance.getRenderedSubtree().getText(), 'hello')
+ const oldSubtree = renderer.instance.getRenderedSubtree()
+ renderer.render()
+ assert.equal(renderer.lastInstance, renderer.instance)
+ assert.equal(oldSubtree, renderer.instance.getRenderedSubtree())
+ assert.equal(renderer.instance.getElement().textContent, 'world')
+ assert.equal(renderer.instance.getRenderedSubtree().getText(), 'world')
+ })
+})