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