diff --git a/img/unlock.svg b/img/unlock.svg new file mode 100644 index 0000000000..9e7927c140 --- /dev/null +++ b/img/unlock.svg @@ -0,0 +1,61 @@ + + + + + + image/svg+xml + + + + + + + + + diff --git a/lib/atom/octicon.js b/lib/atom/octicon.js index 1f306dda38..c4a3d14676 100644 --- a/lib/atom/octicon.js +++ b/lib/atom/octicon.js @@ -2,8 +2,32 @@ import React from 'react'; import PropTypes from 'prop-types'; import cx from 'classnames'; +/* eslint-disable max-len */ +const SVG = { + unlock: { + viewBox: '0 0 24 16', + element: ( + + ), + }, +}; +/* eslint-enable max-len */ + export default function Octicon({icon, ...others}) { const classes = cx('icon', `icon-${icon}`, others.className); + + const svgContent = SVG[icon]; + if (svgContent) { + return ( + + {svgContent.element} + + ); + } + return ; } diff --git a/lib/containers/github-tab-header-container.js b/lib/containers/github-tab-header-container.js index c421ef68b0..8bab9d4e8f 100644 --- a/lib/containers/github-tab-header-container.js +++ b/lib/containers/github-tab-header-container.js @@ -17,10 +17,12 @@ export default class GithubTabHeaderContainer extends React.Component { // Workspace currentWorkDir: PropTypes.string, + contextLocked: PropTypes.bool.isRequired, + changeWorkingDirectory: PropTypes.func.isRequired, + setContextLock: PropTypes.func.isRequired, getCurrentWorkDirs: PropTypes.func.isRequired, // Event Handlers - handleWorkDirSelect: PropTypes.func, onDidChangeWorkDirs: PropTypes.func, } @@ -59,12 +61,12 @@ export default class GithubTabHeaderContainer extends React.Component { environment={environment} variables={{}} query={query} - render={result => this.renderWithResult(result, token)} + render={result => this.renderWithResult(result)} /> ); } - renderWithResult({error, props, retry}, token) { + renderWithResult({error, props}) { if (error || props === null) { return this.renderNoResult(); } @@ -78,10 +80,12 @@ export default class GithubTabHeaderContainer extends React.Component { // Workspace currentWorkDir={this.props.currentWorkDir} + contextLocked={this.props.contextLocked} getCurrentWorkDirs={this.props.getCurrentWorkDirs} + changeWorkingDirectory={this.props.changeWorkingDirectory} + setContextLock={this.props.setContextLock} // Event Handlers - handleWorkDirSelect={this.props.handleWorkDirSelect} onDidChangeWorkDirs={this.props.onDidChangeWorkDirs} /> ); @@ -94,10 +98,12 @@ export default class GithubTabHeaderContainer extends React.Component { // Workspace currentWorkDir={this.props.currentWorkDir} + contextLocked={this.props.contextLocked} + changeWorkingDirectory={this.props.changeWorkingDirectory} + setContextLock={this.props.setContextLock} getCurrentWorkDirs={this.props.getCurrentWorkDirs} // Event Handlers - handleWorkDirSelect={this.props.handleWorkDirSelect} onDidChangeWorkDirs={this.props.onDidChangeWorkDirs} /> ); diff --git a/lib/controllers/git-tab-controller.js b/lib/controllers/git-tab-controller.js index 3b3a93e553..070abd08e5 100644 --- a/lib/controllers/git-tab-controller.js +++ b/lib/controllers/git-tab-controller.js @@ -51,7 +51,9 @@ export default class GitTabController extends React.Component { openFiles: PropTypes.func.isRequired, openInitializeDialog: PropTypes.func.isRequired, controllerRef: RefHolderPropType, + contextLocked: PropTypes.bool.isRequired, changeWorkingDirectory: PropTypes.func.isRequired, + setContextLock: PropTypes.func.isRequired, onDidChangeWorkDirs: PropTypes.func.isRequired, getCurrentWorkDirs: PropTypes.func.isRequired, }; @@ -122,7 +124,9 @@ export default class GitTabController extends React.Component { openFiles={this.props.openFiles} discardWorkDirChangesForPaths={this.props.discardWorkDirChangesForPaths} undoLastDiscard={this.props.undoLastDiscard} + contextLocked={this.props.contextLocked} changeWorkingDirectory={this.props.changeWorkingDirectory} + setContextLock={this.props.setContextLock} getCurrentWorkDirs={this.props.getCurrentWorkDirs} onDidChangeWorkDirs={this.props.onDidChangeWorkDirs} diff --git a/lib/controllers/git-tab-header-controller.js b/lib/controllers/git-tab-header-controller.js index b0a6831531..78d4b8f5b7 100644 --- a/lib/controllers/git-tab-header-controller.js +++ b/lib/controllers/git-tab-header-controller.js @@ -11,9 +11,11 @@ export default class GitTabHeaderController extends React.Component { // Workspace currentWorkDir: PropTypes.string, getCurrentWorkDirs: PropTypes.func.isRequired, + changeWorkingDirectory: PropTypes.func.isRequired, + contextLocked: PropTypes.bool.isRequired, + setContextLock: PropTypes.func.isRequired, // Event Handlers - handleWorkDirSelect: PropTypes.func.isRequired, onDidChangeWorkDirs: PropTypes.func.isRequired, onDidUpdateRepo: PropTypes.func.isRequired, } @@ -21,11 +23,16 @@ export default class GitTabHeaderController extends React.Component { constructor(props) { super(props); this._isMounted = false; - this.state = {currentWorkDirs: [], committer: nullAuthor}; + this.state = { + currentWorkDirs: [], + committer: nullAuthor, + changingLock: null, + changingWorkDir: null, + }; this.disposable = new CompositeDisposable(); } - static getDerivedStateFromProps(props, state) { + static getDerivedStateFromProps(props) { return { currentWorkDirs: props.getCurrentWorkDirs(), }; @@ -59,17 +66,49 @@ export default class GitTabHeaderController extends React.Component { committer={this.state.committer} // Workspace - workdir={this.props.currentWorkDir} + workdir={this.getWorkDir()} workdirs={this.state.currentWorkDirs} + contextLocked={this.getLocked()} + changingWorkDir={this.state.changingWorkDir !== null} + changingLock={this.state.changingLock !== null} // Event Handlers - handleWorkDirSelect={this.props.handleWorkDirSelect} + handleWorkDirSelect={this.handleWorkDirSelect} + handleLockToggle={this.handleLockToggle} /> ); } + handleLockToggle = async () => { + if (this.state.changingLock !== null) { + return; + } + + const nextLock = !this.props.contextLocked; + try { + this.setState({changingLock: nextLock}); + await this.props.setContextLock(this.getWorkDir(), nextLock); + } finally { + await new Promise(resolve => this.setState({changingLock: null}, resolve)); + } + } + + handleWorkDirSelect = async e => { + if (this.state.changingWorkDir !== null) { + return; + } + + const nextWorkDir = e.target.value; + try { + this.setState({changingWorkDir: nextWorkDir}); + await this.props.changeWorkingDirectory(nextWorkDir); + } finally { + await new Promise(resolve => this.setState({changingWorkDir: null}, resolve)); + } + } + resetWorkDirs = () => { - this.setState((state, props) => ({ + this.setState(() => ({ currentWorkDirs: [], })); } @@ -81,6 +120,14 @@ export default class GitTabHeaderController extends React.Component { } } + getWorkDir() { + return this.state.changingWorkDir !== null ? this.state.changingWorkDir : this.props.currentWorkDir; + } + + getLocked() { + return this.state.changingLock !== null ? this.state.changingLock : this.props.contextLocked; + } + componentWillUnmount() { this._isMounted = false; this.disposable.dispose(); diff --git a/lib/controllers/github-tab-controller.js b/lib/controllers/github-tab-controller.js index 320d48b328..ea63951897 100644 --- a/lib/controllers/github-tab-controller.js +++ b/lib/controllers/github-tab-controller.js @@ -24,6 +24,8 @@ export default class GitHubTabController extends React.Component { currentWorkDir: PropTypes.string, changeWorkingDirectory: PropTypes.func.isRequired, + setContextLock: PropTypes.func.isRequired, + contextLocked: PropTypes.bool.isRequired, onDidChangeWorkDirs: PropTypes.func.isRequired, getCurrentWorkDirs: PropTypes.func.isRequired, openCreateDialog: PropTypes.func.isRequired, @@ -54,6 +56,7 @@ export default class GitHubTabController extends React.Component { rootHolder={this.props.rootHolder} workingDirectory={this.props.workingDirectory || this.props.currentWorkDir} + contextLocked={this.props.contextLocked} repository={this.props.repository} branches={this.props.branches} currentBranch={currentBranch} @@ -67,6 +70,7 @@ export default class GitHubTabController extends React.Component { handlePushBranch={this.handlePushBranch} handleRemoteSelect={this.handleRemoteSelect} changeWorkingDirectory={this.props.changeWorkingDirectory} + setContextLock={this.props.setContextLock} getCurrentWorkDirs={this.props.getCurrentWorkDirs} onDidChangeWorkDirs={this.props.onDidChangeWorkDirs} openCreateDialog={this.props.openCreateDialog} diff --git a/lib/controllers/github-tab-header-controller.js b/lib/controllers/github-tab-header-controller.js index 78f4fba875..566a54cf3b 100644 --- a/lib/controllers/github-tab-header-controller.js +++ b/lib/controllers/github-tab-header-controller.js @@ -9,19 +9,26 @@ export default class GithubTabHeaderController extends React.Component { // Workspace currentWorkDir: PropTypes.string, + contextLocked: PropTypes.bool.isRequired, + changeWorkingDirectory: PropTypes.func.isRequired, + setContextLock: PropTypes.func.isRequired, getCurrentWorkDirs: PropTypes.func.isRequired, // Event Handlers - handleWorkDirSelect: PropTypes.func.isRequired, onDidChangeWorkDirs: PropTypes.func.isRequired, } constructor(props) { super(props); - this.state = {currentWorkDirs: []}; + + this.state = { + currentWorkDirs: [], + changingLock: null, + changingWorkDir: null, + }; } - static getDerivedStateFromProps(props, state) { + static getDerivedStateFromProps(props) { return { currentWorkDirs: props.getCurrentWorkDirs(), }; @@ -46,21 +53,60 @@ export default class GithubTabHeaderController extends React.Component { user={this.props.user} // Workspace - workdir={this.props.currentWorkDir} + workdir={this.getWorkDir()} workdirs={this.state.currentWorkDirs} + contextLocked={this.getContextLocked()} + changingWorkDir={this.state.changingWorkDir !== null} + changingLock={this.state.changingLock !== null} - // Event Handlers - handleWorkDirSelect={this.props.handleWorkDirSelect} + handleWorkDirChange={this.handleWorkDirChange} + handleLockToggle={this.handleLockToggle} /> ); } resetWorkDirs = () => { - this.setState((state, props) => ({ + this.setState(() => ({ currentWorkDirs: [], })); } + handleLockToggle = async () => { + if (this.state.changingLock !== null) { + return; + } + + const nextLock = !this.props.contextLocked; + try { + this.setState({changingLock: nextLock}); + await this.props.setContextLock(this.state.changingWorkDir || this.props.currentWorkDir, nextLock); + } finally { + await new Promise(resolve => this.setState({changingLock: null}, resolve)); + } + } + + handleWorkDirChange = async e => { + if (this.state.changingWorkDir !== null) { + return; + } + + const nextWorkDir = e.target.value; + try { + this.setState({changingWorkDir: nextWorkDir}); + await this.props.changeWorkingDirectory(nextWorkDir); + } finally { + await new Promise(resolve => this.setState({changingWorkDir: null}, resolve)); + } + } + + getWorkDir() { + return this.state.changingWorkDir !== null ? this.state.changingWorkDir : this.props.currentWorkDir; + } + + getContextLocked() { + return this.state.changingLock !== null ? this.state.changingLock : this.props.contextLocked; + } + componentWillUnmount() { this.disposable.dispose(); } diff --git a/lib/controllers/root-controller.js b/lib/controllers/root-controller.js index 708fdc3561..7ba6779ae3 100644 --- a/lib/controllers/root-controller.js +++ b/lib/controllers/root-controller.js @@ -67,7 +67,9 @@ export default class RootController extends React.Component { clone: PropTypes.func.isRequired, // Control + contextLocked: PropTypes.bool.isRequired, changeWorkingDirectory: PropTypes.func.isRequired, + setContextLock: PropTypes.func.isRequired, startOpen: PropTypes.bool, startRevealed: PropTypes.bool, } @@ -294,7 +296,9 @@ export default class RootController extends React.Component { currentWorkDir={this.props.currentWorkDir} getCurrentWorkDirs={getCurrentWorkDirs} onDidChangeWorkDirs={onDidChangeWorkDirs} + contextLocked={this.props.contextLocked} changeWorkingDirectory={this.props.changeWorkingDirectory} + setContextLock={this.props.setContextLock} /> )} @@ -311,7 +315,9 @@ export default class RootController extends React.Component { currentWorkDir={this.props.currentWorkDir} getCurrentWorkDirs={getCurrentWorkDirs} onDidChangeWorkDirs={onDidChangeWorkDirs} + contextLocked={this.props.contextLocked} changeWorkingDirectory={this.props.changeWorkingDirectory} + setContextLock={this.props.setContextLock} openCreateDialog={this.openCreateDialog} openPublishDialog={this.openPublishDialog} openCloneDialog={this.openCloneDialog} diff --git a/lib/github-package.js b/lib/github-package.js index f2fb23f72d..f0c88c6c99 100644 --- a/lib/github-package.js +++ b/lib/github-package.js @@ -26,6 +26,8 @@ import {reporterProxy} from './reporter-proxy'; const defaultState = { newProject: true, + activeRepositoryPath: null, + contextLocked: false, }; export default class GithubPackage { @@ -71,6 +73,7 @@ export default class GithubPackage { this.activeContextQueue = new AsyncQueue(); this.guessedContext = WorkdirContext.guess(criteria, this.pipelineManager); this.activeContext = this.guessedContext; + this.lockedContext = null; this.workdirCache = new WorkdirCache(); this.contextPool = new WorkdirContextPool({ window, @@ -159,10 +162,10 @@ export default class GithubPackage { } async activate(state = {}) { - this.savedState = {...defaultState, ...state}; + const savedState = {...defaultState, ...state}; const firstRun = !await fileExists(this.configPath); - const newProject = this.savedState.firstRun !== undefined ? this.savedState.firstRun : this.savedState.newProject; + const newProject = savedState.firstRun !== undefined ? savedState.firstRun : savedState.newProject; this.startOpen = firstRun || newProject; this.startRevealed = firstRun && !this.config.get('welcome.showOnStartup'); @@ -175,14 +178,9 @@ export default class GithubPackage { return !!event.target.closest('.github-FilePatchListView').querySelector('.is-selected'); }; - const handleProjectPathsChange = () => { - const activeRepository = this.getActiveRepository(); - const activeRepositoryPath = activeRepository ? activeRepository.getWorkingDirectoryPath() : null; - this.scheduleActiveContextUpdate({activeRepositoryPath}); - }; - this.subscriptions.add( - this.project.onDidChangePaths(handleProjectPathsChange), + this.workspace.getCenter().onDidChangeActivePaneItem(this.handleActivePaneItemChange), + this.project.onDidChangePaths(this.handleProjectPathsChange), this.styleCalculator.startWatching( 'github-package-styles', ['editor.fontSize', 'editor.fontFamily', 'editor.lineHeight', 'editor.tabLength'], @@ -243,16 +241,33 @@ export default class GithubPackage { ); this.activated = true; - this.scheduleActiveContextUpdate(this.savedState); + this.scheduleActiveContextUpdate({ + usePath: savedState.activeRepositoryPath, + lock: savedState.contextLocked, + }); this.rerender(); } - serialize() { - const activeRepository = this.getActiveRepository(); - const activeRepositoryPath = activeRepository ? activeRepository.getWorkingDirectoryPath() : null; + handleActivePaneItemChange = () => { + if (this.lockedContext) { + return; + } + const itemPath = pathForPaneItem(this.workspace.getCenter().getActivePaneItem()); + this.scheduleActiveContextUpdate({ + usePath: itemPath, + lock: false, + }); + } + + handleProjectPathsChange = () => { + this.scheduleActiveContextUpdate(); + } + + serialize() { return { - activeRepositoryPath, + activeRepositoryPath: this.getActiveWorkdir(), + contextLocked: Boolean(this.lockedContext), newProject: false, }; } @@ -275,7 +290,11 @@ export default class GithubPackage { } const changeWorkingDirectory = workingDirectory => { - this.scheduleActiveContextUpdate({activeRepositoryPath: workingDirectory}); + return this.scheduleActiveContextUpdate({usePath: workingDirectory}); + }; + + const setContextLock = (workingDirectory, lock) => { + return this.scheduleActiveContextUpdate({usePath: workingDirectory, lock}); }; this.renderFn( @@ -304,7 +323,9 @@ export default class GithubPackage { startRevealed={this.startRevealed} removeFilePatchItem={this.removeFilePatchItem} currentWorkDir={this.getActiveWorkdir()} + contextLocked={this.lockedContext !== null} changeWorkingDirectory={changeWorkingDirectory} + setContextLock={setContextLock} />, this.element, callback, ); } @@ -497,71 +518,146 @@ export default class GithubPackage { return this.switchboard; } - async scheduleActiveContextUpdate(savedState = {}) { + /** + * Enqueue a request to modify the active context. + * + * options: + * usePath - Path of the context to use as the next context, if it is present in the pool. + * lock - True or false to lock the ultimately chosen context. Omit to preserve the current lock state. + * + * This method returns a Promise that resolves when the requested context update has completed. Note that it's + * *possible* for the active context after resolution to differ from a requested `usePath`, if the workdir + * containing `usePath` is no longer a viable option, such as if it belongs to a project that is no longer present. + */ + async scheduleActiveContextUpdate(options = {}) { this.switchboard.didScheduleActiveContextUpdate(); - await this.activeContextQueue.push(this.updateActiveContext.bind(this, savedState), {parallel: false}); + await this.activeContextQueue.push(this.updateActiveContext.bind(this, options), {parallel: false}); } /** * Derive the git working directory context that should be used for the package's git operations based on the current * state of the Atom workspace. In priority, this prefers: * - * - The preferred git working directory set by the user (This is also the working directory that was active when the - * package was last serialized). - * - A git working directory corresponding to "first" Project, whether or not there is a single project or multiple. + * - When activating: the working directory that was active when the package was last serialized, if it still a viable + * option. (usePath) + * - The working directory chosen by the user from the context tile on the git or GitHub tabs. (usePath) + * - The working directory containing the path of the active pane item. + * - A git working directory corresponding to "first" project, if any projects are open. * - The current context, unchanged, which may be a `NullWorkdirContext`. * * First updates the pool of resident contexts to match all git working directories that correspond to open - * projects. + * projects and pane items. */ - async getNextContext(savedState) { + async getNextContext(usePath = null) { + // Identify paths that *could* contribute a git working directory to the pool. This is drawn from + // the roots of open projects, the currently locked context if one is present, and the path of the + // open workspace item. + const candidatePaths = new Set(this.project.getPaths()); + if (this.lockedContext) { + const lockedRepo = this.lockedContext.getRepository(); + /* istanbul ignore else */ + if (lockedRepo) { + candidatePaths.add(lockedRepo.getWorkingDirectoryPath()); + } + } + const activeItemPath = pathForPaneItem(this.workspace.getCenter().getActivePaneItem()); + if (activeItemPath) { + candidatePaths.add(activeItemPath); + } + + let activeItemWorkdir = null; + let firstProjectWorkdir = null; + + // Convert the candidate paths into the set of viable git working directories, by means of a cached + // `git rev-parse` call. Candidate paths that are not contained within a git working directory will + // be preserved as-is within the pool, to allow users to initialize them. const workdirs = new Set( await Promise.all( - this.project.getPaths().map(async projectPath => { - const workdir = await this.workdirCache.find(projectPath); - return workdir || projectPath; + Array.from(candidatePaths, async candidatePath => { + const workdir = (await this.workdirCache.find(candidatePath)) || candidatePath; + // Note the workdirs associated with the active pane item and the first open project so we can + // prefer them later. + if (candidatePath === activeItemPath) { + activeItemWorkdir = workdir; + } else if (candidatePath === this.project.getPaths()[0]) { + firstProjectWorkdir = workdir; + } + return workdir; }), ), ); - // Update pool with the open projects - this.contextPool.set(workdirs, savedState); + // Update pool with the identified projects. + this.contextPool.set(workdirs); - if (savedState.activeRepositoryPath) { - // Preferred git directory (the preferred directory or the last serialized directory). - const stateContext = this.contextPool.getContext(savedState.activeRepositoryPath); - // If the context exists chose it, else continue. + // 1 - Explicitly requested workdir. This is either selected by the user from a context tile or + // deserialized from package state. Choose this context only if it still exists in the pool. + if (usePath) { + const stateContext = this.contextPool.getContext(usePath); if (stateContext.isPresent()) { return stateContext; } } - const projectPaths = this.project.getPaths(); + // 2 - Use the currently locked context, if one is present. + if (this.lockedContext) { + return this.lockedContext; + } - if (projectPaths.length >= 1) { - // Single or multiple projects (just choose the first, the user can select after) - const projectPath = projectPaths[0]; - const activeWorkingDir = await this.workdirCache.find(projectPath); - return this.contextPool.getContext(activeWorkingDir || projectPath); + // 3 - Follow the active workspace pane item. + if (activeItemWorkdir) { + return this.contextPool.getContext(activeItemWorkdir); } - if (projectPaths.length === 0 && !this.activeContext.getRepository().isUndetermined()) { - // No projects. Revert to the absent context unless we've guessed that more projects are on the way. + // 4 - The first open project. + if (firstProjectWorkdir) { + return this.contextPool.getContext(firstProjectWorkdir); + } + + // No projects. Revert to the absent context unless we've guessed that more projects are on the way. + if (this.project.getPaths().length === 0 && !this.activeContext.getRepository().isUndetermined()) { return WorkdirContext.absent({pipelineManager: this.pipelineManager}); } - // It is only possible to reach here if there there was no preferred directory, there are no project paths and the - // the active context's repository is not undetermined. + // It is only possible to reach here if there there was no preferred directory, there are no project paths, and the + // the active context's repository is not undetermined. Preserve the existing active context. return this.activeContext; } - setActiveContext(nextActiveContext) { + /** + * Modify the active context and re-render the React tree. This should only be done as part of the + * context update queue; use scheduleActiveContextUpdate() to do this. + * + * nextActiveContext - The WorkdirContext to make active next, as derived from the current workspace + * state by getNextContext(). This may be absent or undetermined. + * lock - If true, also set this context as the "locked" one and engage the context lock if it isn't + * already. If false, clear any existing context lock. If null or undefined, leave the lock in its + * existing state. + */ + setActiveContext(nextActiveContext, lock) { if (nextActiveContext !== this.activeContext) { if (this.activeContext === this.guessedContext) { this.guessedContext.destroy(); this.guessedContext = null; } this.activeContext = nextActiveContext; + if (lock === true) { + this.lockedContext = this.activeContext; + } else if (lock === false) { + this.lockedContext = null; + } + + this.rerender(() => { + this.switchboard.didFinishContextChangeRender(); + this.switchboard.didFinishActiveContextUpdate(); + }); + } else if ((lock === true || lock === false) && lock !== (this.lockedContext !== null)) { + if (lock) { + this.lockedContext = this.activeContext; + } else { + this.lockedContext = null; + } + this.rerender(() => { this.switchboard.didFinishContextChangeRender(); this.switchboard.didFinishActiveContextUpdate(); @@ -571,15 +667,22 @@ export default class GithubPackage { } } - async updateActiveContext(savedState = {}) { + /** + * Derive the next active context with getNextContext(), then enact the context change with setActiveContext(). + * + * options: + * usePath - Path of the context to use as the next context, if it is present in the pool. + * lock - True or false to lock the ultimately chosen context. Omit to preserve the current lock state. + */ + async updateActiveContext(options) { if (this.workspace.isDestroyed()) { return; } this.switchboard.didBeginActiveContextUpdate(); - const nextActiveContext = await this.getNextContext(savedState); - this.setActiveContext(nextActiveContext); + const nextActiveContext = await this.getNextContext(options.usePath); + this.setActiveContext(nextActiveContext, options.lock); } async refreshAtomGitRepository(workdir) { @@ -594,3 +697,22 @@ export default class GithubPackage { } } } + +function pathForPaneItem(paneItem) { + if (!paneItem) { + return null; + } + + // Likely GitHub package provided pane item + if (typeof paneItem.getWorkingDirectory === 'function') { + return paneItem.getWorkingDirectory(); + } + + // TextEditor-like + if (typeof paneItem.getPath === 'function') { + return paneItem.getPath(); + } + + // Oh well + return null; +} diff --git a/lib/views/git-tab-header-view.js b/lib/views/git-tab-header-view.js index 78a71a990c..7189782236 100644 --- a/lib/views/git-tab-header-view.js +++ b/lib/views/git-tab-header-view.js @@ -1,7 +1,9 @@ import React from 'react'; import PropTypes from 'prop-types'; import path from 'path'; + import {AuthorPropType} from '../prop-types'; +import Octicon from '../atom/octicon'; export default class GitTabHeaderView extends React.Component { static propTypes = { @@ -10,20 +12,36 @@ export default class GitTabHeaderView extends React.Component { // Workspace workdir: PropTypes.string, workdirs: PropTypes.shape({[Symbol.iterator]: PropTypes.func.isRequired}).isRequired, + contextLocked: PropTypes.bool.isRequired, + changingWorkDir: PropTypes.bool.isRequired, + changingLock: PropTypes.bool.isRequired, // Event Handlers handleWorkDirSelect: PropTypes.func, + handleLockToggle: PropTypes.func, } render() { + const lockIcon = this.props.contextLocked ? 'lock' : 'unlock'; + const lockToggleTitle = this.props.contextLocked ? + 'Change repository with the dropdown' : + 'Follow the active pane item'; + return (
{this.renderCommitter()} +
); } diff --git a/lib/views/git-tab-view.js b/lib/views/git-tab-view.js index a5f10cc687..93c7fa46a0 100644 --- a/lib/views/git-tab-view.js +++ b/lib/views/git-tab-view.js @@ -61,7 +61,9 @@ export default class GitTabView extends React.Component { attemptFileStageOperation: PropTypes.func.isRequired, discardWorkDirChangesForPaths: PropTypes.func.isRequired, openFiles: PropTypes.func.isRequired, + contextLocked: PropTypes.bool.isRequired, changeWorkingDirectory: PropTypes.func.isRequired, + setContextLock: PropTypes.func.isRequired, onDidChangeWorkDirs: PropTypes.func.isRequired, getCurrentWorkDirs: PropTypes.func.isRequired, }; @@ -126,11 +128,13 @@ export default class GitTabView extends React.Component { // Workspace currentWorkDir={this.props.workingDirectoryPath} getCurrentWorkDirs={this.props.getCurrentWorkDirs} + contextLocked={this.props.contextLocked} + changeWorkingDirectory={this.props.changeWorkingDirectory} + setContextLock={this.props.setContextLock} // Event Handlers onDidChangeWorkDirs={this.props.onDidChangeWorkDirs} onDidUpdateRepo={repository.onDidUpdate.bind(repository)} - handleWorkDirSelect={e => this.props.changeWorkingDirectory(e.target.value)} /> ); } diff --git a/lib/views/github-tab-header-view.js b/lib/views/github-tab-header-view.js index fdcfa69b8a..bd3c6c1895 100644 --- a/lib/views/github-tab-header-view.js +++ b/lib/views/github-tab-header-view.js @@ -1,7 +1,9 @@ import React from 'react'; import PropTypes from 'prop-types'; import path from 'path'; + import {AuthorPropType} from '../prop-types'; +import Octicon from '../atom/octicon'; export default class GithubTabHeaderView extends React.Component { static propTypes = { @@ -10,20 +12,34 @@ export default class GithubTabHeaderView extends React.Component { // Workspace workdir: PropTypes.string, workdirs: PropTypes.shape({[Symbol.iterator]: PropTypes.func.isRequired}).isRequired, - - // Event Handlers - handleWorkDirSelect: PropTypes.func, + contextLocked: PropTypes.bool.isRequired, + changingWorkDir: PropTypes.bool.isRequired, + changingLock: PropTypes.bool.isRequired, + handleWorkDirChange: PropTypes.func.isRequired, + handleLockToggle: PropTypes.func.isRequired, } render() { + const lockIcon = this.props.contextLocked ? 'lock' : 'unlock'; + const lockToggleTitle = this.props.contextLocked ? + 'Change repository with the dropdown' : + 'Follow the active pane item'; + return (
{this.renderUser()} +
); } diff --git a/lib/views/github-tab-view.js b/lib/views/github-tab-view.js index a0add89b17..266bf4c93a 100644 --- a/lib/views/github-tab-view.js +++ b/lib/views/github-tab-view.js @@ -28,6 +28,8 @@ export default class GitHubTabView extends React.Component { workingDirectory: PropTypes.string, getCurrentWorkDirs: PropTypes.func.isRequired, changeWorkingDirectory: PropTypes.func.isRequired, + contextLocked: PropTypes.bool.isRequired, + setContextLock: PropTypes.func.isRequired, repository: PropTypes.object.isRequired, // Remotes @@ -40,7 +42,7 @@ export default class GitHubTabView extends React.Component { aheadCount: PropTypes.number, pushInProgress: PropTypes.bool.isRequired, - // Event Handelrs + // Event Handlers handleWorkDirSelect: PropTypes.func, handlePushBranch: PropTypes.func.isRequired, handleRemoteSelect: PropTypes.func.isRequired, @@ -137,10 +139,13 @@ export default class GitHubTabView extends React.Component { // Workspace currentWorkDir={this.props.workingDirectory} + contextLocked={this.props.contextLocked} + changeWorkingDirectory={this.props.changeWorkingDirectory} + setContextLock={this.props.setContextLock} getCurrentWorkDirs={this.props.getCurrentWorkDirs} // Event Handlers - handleWorkDirSelect={e => this.props.changeWorkingDirectory(e.target.value)} + // handleWorkDirSelect={e => this.props.changeWorkingDirectory(e.target.value)} onDidChangeWorkDirs={this.props.onDidChangeWorkDirs} /> ); @@ -151,10 +156,12 @@ export default class GitHubTabView extends React.Component { // Workspace currentWorkDir={this.props.workingDirectory} + contextLocked={this.props.contextLocked} + changeWorkingDirectory={this.props.changeWorkingDirectory} + setContextLock={this.props.setContextLock} getCurrentWorkDirs={this.props.getCurrentWorkDirs} // Event Handlers - handleWorkDirSelect={e => this.props.changeWorkingDirectory(e.target.value)} onDidChangeWorkDirs={this.props.onDidChangeWorkDirs} /> ); diff --git a/styles/project.less b/styles/project.less index e425cba4e6..143cd6a5a8 100644 --- a/styles/project.less +++ b/styles/project.less @@ -17,4 +17,39 @@ border-radius: @component-border-radius; } + &-lock.btn { + width: 40px; + height: 20px; + padding: 0; + display: flex; + flex-direction: row; + justify-content: center; + align-items: baseline; + + border: none; + background-color: transparent; + background-image: none; + + &:active, &:hover { + background-color: transparent; + background-image: none; + } + } + + .icon { + padding: 0; + margin: 0; + line-height: 1em; + fill: @text-color; + + &:hover { + fill: @text-color-highlight; + } + + &.icon-unlock { + width: 21px; + height: 17px; + padding-left: 1px; + } + } } diff --git a/test/atom/octicon.test.js b/test/atom/octicon.test.js new file mode 100644 index 0000000000..f7f373cc8a --- /dev/null +++ b/test/atom/octicon.test.js @@ -0,0 +1,17 @@ +import React from 'react'; +import {shallow} from 'enzyme'; + +import Octicon from '../../lib/atom/octicon'; + +describe('Octicon', function() { + it('defaults to rendering an octicon span', function() { + const wrapper = shallow(); + assert.isTrue(wrapper.exists('span.icon.icon-octoface')); + }); + + it('renders SVG overrides', function() { + const wrapper = shallow(); + + assert.strictEqual(wrapper.find('svg').prop('viewBox'), '0 0 24 16'); + }); +}); diff --git a/test/containers/github-tab-header-container.test.js b/test/containers/github-tab-header-container.test.js index 15eb6eeb9b..db68e86492 100644 --- a/test/containers/github-tab-header-container.test.js +++ b/test/containers/github-tab-header-container.test.js @@ -27,7 +27,12 @@ describe('GithubTabHeaderContainer', function() { null} + currentWorkDir={null} + contextLocked={false} + changeWorkingDirectory={() => {}} + setContextLock={() => {}} + getCurrentWorkDirs={() => new Set()} + onDidChangeWorkDirs={() => {}} {...overrideProps} /> ); diff --git a/test/controllers/git-tab-header-controller.test.js b/test/controllers/git-tab-header-controller.test.js index 5c66d545b9..d6619fedde 100644 --- a/test/controllers/git-tab-header-controller.test.js +++ b/test/controllers/git-tab-header-controller.test.js @@ -15,17 +15,16 @@ describe('GitTabHeaderController', function() { function buildApp(overrides) { const props = { getCommitter: () => nullAuthor, + currentWorkDir: null, getCurrentWorkDirs: () => createWorkdirs([]), - onDidUpdateRepo: () => new Disposable(), + changeWorkingDirectory: () => {}, + contextLocked: false, + setContextLock: () => {}, onDidChangeWorkDirs: () => new Disposable(), - handleWorkDirSelect: () => null, + onDidUpdateRepo: () => new Disposable(), ...overrides, }; - return ( - - ); + return ; } it('get currentWorkDirs initializes workdirs state', function() { @@ -112,6 +111,62 @@ describe('GitTabHeaderController', function() { assert.isTrue(getCommitter.calledOnce); }); + it('handles a lock toggle', async function() { + let resolveLockChange; + const setContextLock = sinon.stub().returns(new Promise(resolve => { + resolveLockChange = resolve; + })); + const wrapper = shallow(buildApp({currentWorkDir: 'the/workdir', contextLocked: false, setContextLock})); + + assert.isFalse(wrapper.find('GitTabHeaderView').prop('contextLocked')); + assert.isFalse(wrapper.find('GitTabHeaderView').prop('changingLock')); + + const handlerPromise = wrapper.find('GitTabHeaderView').prop('handleLockToggle')(); + wrapper.update(); + + assert.isTrue(wrapper.find('GitTabHeaderView').prop('contextLocked')); + assert.isTrue(wrapper.find('GitTabHeaderView').prop('changingLock')); + assert.isTrue(setContextLock.calledWith('the/workdir', true)); + + // Ignored while in-progress + wrapper.find('GitTabHeaderView').prop('handleLockToggle')(); + + resolveLockChange(); + await handlerPromise; + + assert.isFalse(wrapper.find('GitTabHeaderView').prop('changingLock')); + }); + + it('handles a workdir selection', async function() { + let resolveWorkdirChange; + const changeWorkingDirectory = sinon.stub().returns(new Promise(resolve => { + resolveWorkdirChange = resolve; + })); + const wrapper = shallow(buildApp({currentWorkDir: 'original', changeWorkingDirectory})); + + assert.strictEqual(wrapper.find('GitTabHeaderView').prop('workdir'), 'original'); + assert.isFalse(wrapper.find('GitTabHeaderView').prop('changingWorkDir')); + + const handlerPromise = wrapper.find('GitTabHeaderView').prop('handleWorkDirSelect')({ + target: {value: 'work/dir'}, + }); + wrapper.update(); + + assert.strictEqual(wrapper.find('GitTabHeaderView').prop('workdir'), 'work/dir'); + assert.isTrue(wrapper.find('GitTabHeaderView').prop('changingWorkDir')); + assert.isTrue(changeWorkingDirectory.calledWith('work/dir')); + + // Ignored while in-progress + wrapper.find('GitTabHeaderView').prop('handleWorkDirSelect')({ + target: {value: 'ig/nored'}, + }); + + resolveWorkdirChange(); + await handlerPromise; + + assert.isFalse(wrapper.find('GitTabHeaderView').prop('changingWorkDir')); + }); + it('unmounts without error', function() { const wrapper = shallow(buildApp()); wrapper.unmount(); diff --git a/test/controllers/github-tab-controller.test.js b/test/controllers/github-tab-controller.test.js index ad5e3c2fd2..c7be628195 100644 --- a/test/controllers/github-tab-controller.test.js +++ b/test/controllers/github-tab-controller.test.js @@ -45,6 +45,8 @@ describe('GitHubTabController', function() { currentWorkDir={repo.getWorkingDirectoryPath()} changeWorkingDirectory={() => {}} + setContextLock={() => {}} + contextLocked={false} onDidChangeWorkDirs={() => {}} getCurrentWorkDirs={() => []} openCreateDialog={() => {}} diff --git a/test/controllers/github-tab-header-controller.test.js b/test/controllers/github-tab-header-controller.test.js index 1e4f8529f1..994f1b8752 100644 --- a/test/controllers/github-tab-header-controller.test.js +++ b/test/controllers/github-tab-header-controller.test.js @@ -15,10 +15,12 @@ describe('GithubTabHeaderController', function() { function buildApp(overrides) { const props = { user: nullAuthor, + currentWorkDir: null, + contextLocked: false, + changeWorkingDirectory: () => {}, + setContextLock: () => {}, getCurrentWorkDirs: () => createWorkdirs([]), - onDidUpdateRepo: () => new Disposable(), onDidChangeWorkDirs: () => new Disposable(), - handleWorkDirSelect: () => null, ...overrides, }; return ( @@ -73,6 +75,62 @@ describe('GithubTabHeaderController', function() { assert.isTrue(getCurrentWorkDirs.calledTwice); }); + it('handles a lock toggle', async function() { + let resolveLockChange; + const setContextLock = sinon.stub().returns(new Promise(resolve => { + resolveLockChange = resolve; + })); + const wrapper = shallow(buildApp({currentWorkDir: 'the/workdir', contextLocked: false, setContextLock})); + + assert.isFalse(wrapper.find('GithubTabHeaderView').prop('contextLocked')); + assert.isFalse(wrapper.find('GithubTabHeaderView').prop('changingLock')); + + const handlerPromise = wrapper.find('GithubTabHeaderView').prop('handleLockToggle')(); + wrapper.update(); + + assert.isTrue(wrapper.find('GithubTabHeaderView').prop('contextLocked')); + assert.isTrue(wrapper.find('GithubTabHeaderView').prop('changingLock')); + assert.isTrue(setContextLock.calledWith('the/workdir', true)); + + // Ignored while in-progress + wrapper.find('GithubTabHeaderView').prop('handleLockToggle')(); + + resolveLockChange(); + await handlerPromise; + + assert.isFalse(wrapper.find('GithubTabHeaderView').prop('changingLock')); + }); + + it('handles a workdir selection', async function() { + let resolveWorkdirChange; + const changeWorkingDirectory = sinon.stub().returns(new Promise(resolve => { + resolveWorkdirChange = resolve; + })); + const wrapper = shallow(buildApp({currentWorkDir: 'original', changeWorkingDirectory})); + + assert.strictEqual(wrapper.find('GithubTabHeaderView').prop('workdir'), 'original'); + assert.isFalse(wrapper.find('GithubTabHeaderView').prop('changingWorkDir')); + + const handlerPromise = wrapper.find('GithubTabHeaderView').prop('handleWorkDirChange')({ + target: {value: 'work/dir'}, + }); + wrapper.update(); + + assert.strictEqual(wrapper.find('GithubTabHeaderView').prop('workdir'), 'work/dir'); + assert.isTrue(wrapper.find('GithubTabHeaderView').prop('changingWorkDir')); + assert.isTrue(changeWorkingDirectory.calledWith('work/dir')); + + // Ignored while in-progress + wrapper.find('GithubTabHeaderView').prop('handleWorkDirChange')({ + target: {value: 'ig/nored'}, + }); + + resolveWorkdirChange(); + await handlerPromise; + + assert.isFalse(wrapper.find('GithubTabHeaderView').prop('changingWorkDir')); + }); + it('disposes on unmount', function() { const disposeSpy = sinon.spy(); const onDidChangeWorkDirs = () => ({dispose: disposeSpy}); diff --git a/test/controllers/root-controller.test.js b/test/controllers/root-controller.test.js index c2eb72e890..9d61f815ca 100644 --- a/test/controllers/root-controller.test.js +++ b/test/controllers/root-controller.test.js @@ -58,13 +58,13 @@ describe('RootController', function() { workspace={workspace} commands={commands} deserializers={deserializers} - grammars={grammars} notificationManager={notificationManager} tooltips={tooltips} + keymaps={atomEnv.keymaps} + grammars={grammars} config={config} - confirm={confirm} project={project} - keymaps={atomEnv.keymaps} + confirm={confirm} currentWindow={atomEnv.getCurrentWindow()} loginModel={loginModel} @@ -72,9 +72,14 @@ describe('RootController', function() { repository={absentRepository} resolutionProgress={emptyResolutionProgress} + currentWorkDir={null} + initialize={() => {}} clone={() => {}} + contextLocked={false} + changeWorkingDirectory={() => {}} + setContextLock={() => {}} startOpen={false} startRevealed={false} /> diff --git a/test/fixtures/props/git-tab-props.js b/test/fixtures/props/git-tab-props.js index 5a8cf2d72b..8628000ecf 100644 --- a/test/fixtures/props/git-tab-props.js +++ b/test/fixtures/props/git-tab-props.js @@ -27,8 +27,10 @@ export function gitTabItemProps(atomEnv, repository, overrides = {}) { openFiles: noop, openInitializeDialog: noop, changeWorkingDirectory: noop, + contextLocked: false, + setContextLock: () => {}, onDidChangeWorkDirs: () => ({dispose: () => {}}), - getCurrentWorkDirs: () => [], + getCurrentWorkDirs: () => new Set(), ...overrides }; } @@ -102,9 +104,11 @@ export async function gitTabViewProps(atomEnv, repository, overrides = {}) { discardWorkDirChangesForPaths: () => {}, openFiles: () => {}, + contextLocked: false, changeWorkingDirectory: () => {}, + setContextLock: () => {}, onDidChangeWorkDirs: () => ({dispose: () => {}}), - getCurrentWorkDirs: () => [], + getCurrentWorkDirs: () => new Set(), onDidUpdateRepo: () => ({dispose: () => {}}), getCommitter: () => nullAuthor, diff --git a/test/github-package.test.js b/test/github-package.test.js index dfb0308011..67d50bcddd 100644 --- a/test/github-package.test.js +++ b/test/github-package.test.js @@ -412,12 +412,23 @@ describe('GithubPackage', function() { }); describe('when removing a project', function() { - beforeEach(async function() { + it('removes the project\'s context', async function() { await contextUpdateAfter(githubPackage, () => project.setPaths([workdirPath1])); - }); - it('removes the project\'s context', function() { assert.isFalse(contextPool.getContext(workdirPath2).isPresent()); + assert.strictEqual(githubPackage.getActiveWorkdir(), workdirPath1); + }); + + it('does nothing if the context is locked', async function() { + await githubPackage.scheduleActiveContextUpdate({ + usePath: workdirPath2, + lock: true, + }); + + await contextUpdateAfter(githubPackage, () => project.setPaths([workdirPath1])); + + assert.isTrue(contextPool.getContext(workdirPath2).isPresent()); + assert.strictEqual(githubPackage.getActiveWorkdir(), workdirPath2); }); }); @@ -434,6 +445,100 @@ describe('GithubPackage', function() { assert.isTrue(githubPackage.getActiveRepository().isAbsent()); }); }); + + describe('when changing the active pane item', function() { + it('follows the active pane item', async function() { + const itemPath2 = path.join(workdirPath2, 'b.txt'); + + assert.strictEqual(githubPackage.getActiveWorkdir(), workdirPath1); + await contextUpdateAfter(githubPackage, () => atomEnv.workspace.open(itemPath2)); + assert.strictEqual(githubPackage.getActiveWorkdir(), workdirPath2); + }); + + it('does nothing if the context is locked', async function() { + const itemPath2 = path.join(workdirPath2, 'c.txt'); + + await githubPackage.scheduleActiveContextUpdate({ + usePath: workdirPath1, + lock: true, + }); + + assert.strictEqual(githubPackage.getActiveWorkdir(), workdirPath1); + await contextUpdateAfter(githubPackage, () => atomEnv.workspace.open(itemPath2)); + assert.strictEqual(githubPackage.getActiveWorkdir(), workdirPath1); + }); + }); + + describe('with a locked context', function() { + it('preserves the locked context in the pool', async function() { + await githubPackage.scheduleActiveContextUpdate({ + usePath: workdirPath1, + lock: true, + }); + + await contextUpdateAfter(githubPackage, () => project.setPaths([workdirPath2])); + + assert.isTrue(contextPool.getContext(workdirPath1).isPresent()); + assert.isTrue(contextPool.getContext(workdirPath2).isPresent()); + + assert.strictEqual(githubPackage.getActiveWorkdir(), workdirPath1); + }); + + it('may be unlocked', async function() { + const itemPath1a = path.join(workdirPath1, 'a.txt'); + const itemPath1b = path.join(workdirPath2, 'b.txt'); + + await githubPackage.scheduleActiveContextUpdate({ + usePath: workdirPath2, + lock: true, + }); + + await contextUpdateAfter(githubPackage, () => atomEnv.workspace.open(itemPath1a)); + assert.strictEqual(githubPackage.getActiveWorkdir(), workdirPath2); + + await githubPackage.scheduleActiveContextUpdate({ + usePath: workdirPath1, + lock: false, + }); + + await contextUpdateAfter(githubPackage, () => atomEnv.workspace.open(itemPath1b)); + assert.strictEqual(githubPackage.getActiveWorkdir(), workdirPath1); + }); + + it('triggers a re-render when the context is unchanged', async function() { + sinon.stub(githubPackage, 'rerender'); + + assert.strictEqual(githubPackage.getActiveWorkdir(), workdirPath1); + await githubPackage.scheduleActiveContextUpdate({ + usePath: workdirPath1, + lock: true, + }); + + assert.strictEqual(githubPackage.getActiveWorkdir(), workdirPath1); + assert.isTrue(githubPackage.rerender.called); + githubPackage.rerender.resetHistory(); + + await githubPackage.scheduleActiveContextUpdate({ + usePath: workdirPath1, + lock: false, + }); + + assert.strictEqual(githubPackage.getActiveWorkdir(), workdirPath1); + assert.isTrue(githubPackage.rerender.called); + }); + }); + + it('does nothing when the workspace is destroyed', async function() { + sinon.stub(githubPackage, 'rerender'); + atomEnv.destroy(); + + await githubPackage.scheduleActiveContextUpdate({ + usePath: workdirPath2, + }); + + assert.isFalse(githubPackage.rerender.called); + assert.strictEqual(githubPackage.getActiveWorkdir(), workdirPath1); + }); }); describe('with non-repository, no-conflict, and in-progress merge-conflict projects', function() { @@ -455,7 +560,7 @@ describe('GithubPackage', function() { let resolutionMergeConflict; beforeEach(async function() { await githubPackage.scheduleActiveContextUpdate({ - activeRepositoryPath: workdirMergeConflict, + usePath: workdirMergeConflict, }); resolutionMergeConflict = contextPool.getContext(workdirMergeConflict).getResolutionProgress(); }); @@ -477,7 +582,7 @@ describe('GithubPackage', function() { let resolutionNoConflict; beforeEach(async function() { await githubPackage.scheduleActiveContextUpdate({ - activeRepositoryPath: workdirNoConflict, + usePath: workdirNoConflict, }); resolutionNoConflict = contextPool.getContext(workdirNoConflict).getResolutionProgress(); }); @@ -494,7 +599,7 @@ describe('GithubPackage', function() { describe('when opening a non-repository project', function() { beforeEach(async function() { await githubPackage.scheduleActiveContextUpdate({ - activeRepositoryPath: nonRepositoryPath, + usePath: nonRepositoryPath, }); }); @@ -515,7 +620,7 @@ describe('GithubPackage', function() { project.setPaths([workdirPath1, workdirPath2]); await githubPackage.scheduleActiveContextUpdate({ - activeRepositoryPath: workdirPath3, + usePath: workdirPath3, }); context1 = contextPool.getContext(workdirPath1); }); @@ -538,7 +643,7 @@ describe('GithubPackage', function() { project.setPaths([workdirPath1]); await githubPackage.scheduleActiveContextUpdate({ - activeRepositoryPath: workdirPath2, + usePath: workdirPath2, }); context1 = contextPool.getContext(workdirPath1); }); @@ -561,7 +666,7 @@ describe('GithubPackage', function() { project.setPaths([workdirPath1, workdirPath2]); await githubPackage.scheduleActiveContextUpdate({ - activeRepositoryPath: workdirPath2, + usePath: workdirPath2, }); }); diff --git a/test/views/git-tab-header-view.test.js b/test/views/git-tab-header-view.test.js index 5e0d9e8f7b..c1074551dd 100644 --- a/test/views/git-tab-header-view.test.js +++ b/test/views/git-tab-header-view.test.js @@ -15,7 +15,13 @@ describe('GitTabHeaderView', function() { function build(options = {}) { const props = { committer: nullAuthor, + workdir: null, workdirs: createWorkdirs([]), + contextLocked: false, + changingWorkDir: false, + changingLock: false, + handleWorkDirSelect: () => {}, + handleLockToggle: () => {}, ...options, }; return shallow(); @@ -29,7 +35,11 @@ describe('GitTabHeaderView', function() { beforeEach(function() { select = sinon.spy(); - wrapper = build({handleWorkDirSelect: select, workdirs: createWorkdirs(paths), workdir: path2}); + wrapper = build({ + handleWorkDirSelect: select, + workdirs: createWorkdirs(paths), + workdir: path2, + }); }); it('renders an option for all given working directories', function() { @@ -49,6 +59,42 @@ describe('GitTabHeaderView', function() { }); }); + describe('context lock control', function() { + it('renders locked when the lock is engaged', function() { + const wrapper = build({contextLocked: true}); + + assert.isTrue(wrapper.exists('Octicon[icon="lock"]')); + }); + + it('renders unlocked when the lock is disengaged', function() { + const wrapper = build({contextLocked: false}); + + assert.isTrue(wrapper.exists('Octicon[icon="unlock"]')); + }); + + it('calls handleLockToggle when the lock is clicked', function() { + const handleLockToggle = sinon.spy(); + const wrapper = build({handleLockToggle}); + + wrapper.find('button').simulate('click'); + assert.isTrue(handleLockToggle.called); + }); + }); + + describe('when changes are in progress', function() { + it('disables the workdir select while the workdir is changing', function() { + const wrapper = build({changingWorkDir: true}); + + assert.isTrue(wrapper.find('select').prop('disabled')); + }); + + it('disables the context lock toggle while the context lock is changing', function() { + const wrapper = build({changingLock: true}); + + assert.isTrue(wrapper.find('button').prop('disabled')); + }); + }); + describe('with falsish props', function() { let wrapper; diff --git a/test/views/git-tab-view.test.js b/test/views/git-tab-view.test.js index 05671f2f4b..1acc2c2732 100644 --- a/test/views/git-tab-view.test.js +++ b/test/views/git-tab-view.test.js @@ -286,7 +286,7 @@ describe('GitTabView', function() { it('calls changeWorkingDirectory when a project is selected', async function() { const changeWorkingDirectory = sinon.spy(); const wrapper = shallow(await buildApp({changeWorkingDirectory})); - wrapper.find('GitTabHeaderController').prop('handleWorkDirSelect')({target: {value: 'some-path'}}); + wrapper.find('GitTabHeaderController').prop('changeWorkingDirectory')('some-path'); assert.isTrue(changeWorkingDirectory.calledWith('some-path')); }); }); diff --git a/test/views/github-tab-header-view.test.js b/test/views/github-tab-header-view.test.js index 43cf57d060..882d92f43e 100644 --- a/test/views/github-tab-header-view.test.js +++ b/test/views/github-tab-header-view.test.js @@ -15,7 +15,13 @@ describe('GithubTabHeaderView', function() { function build(options = {}) { const props = { user: nullAuthor, + workdir: null, workdirs: createWorkdirs([]), + contextLocked: false, + changingWorkDir: false, + changingLock: false, + handleWorkDirChange: () => {}, + handleLockToggle: () => {}, ...options, }; return shallow(); @@ -29,7 +35,7 @@ describe('GithubTabHeaderView', function() { beforeEach(function() { select = sinon.spy(); - wrapper = build({handleWorkDirSelect: select, workdirs: createWorkdirs(paths), workdir: path2}); + wrapper = build({handleWorkDirChange: select, workdirs: createWorkdirs(paths), workdir: path2}); }); it('renders an option for all given working directories', function() { @@ -49,6 +55,42 @@ describe('GithubTabHeaderView', function() { }); }); + describe('context lock control', function() { + it('renders locked when the lock is engaged', function() { + const wrapper = build({contextLocked: true}); + + assert.isTrue(wrapper.exists('Octicon[icon="lock"]')); + }); + + it('renders unlocked when the lock is disengaged', function() { + const wrapper = build({contextLocked: false}); + + assert.isTrue(wrapper.exists('Octicon[icon="unlock"]')); + }); + + it('calls handleLockToggle when the lock is clicked', function() { + const handleLockToggle = sinon.spy(); + const wrapper = build({handleLockToggle}); + + wrapper.find('button').simulate('click'); + assert.isTrue(handleLockToggle.called); + }); + }); + + describe('when changes are in progress', function() { + it('disables the workdir select while the workdir is changing', function() { + const wrapper = build({changingWorkDir: true}); + + assert.isTrue(wrapper.find('select').prop('disabled')); + }); + + it('disables the context lock toggle while the context lock is changing', function() { + const wrapper = build({changingLock: true}); + + assert.isTrue(wrapper.find('button').prop('disabled')); + }); + }); + describe('with falsish props', function() { let wrapper; diff --git a/test/views/github-tab-view.test.js b/test/views/github-tab-view.test.js index d78ea5b44b..e507264c49 100644 --- a/test/views/github-tab-view.test.js +++ b/test/views/github-tab-view.test.js @@ -31,26 +31,31 @@ describe('GitHubTabView', function() { return ( []} + changeWorkingDirectory={() => {}} + contextLocked={false} + setContextLock={() => {}} repository={repo} - branches={new BranchSet()} - currentBranch={nullBranch} + remotes={new RemoteSet()} currentRemote={nullRemote} manyRemotesAvailable={false} - pushInProgress={false} isLoading={false} + branches={new BranchSet()} + currentBranch={nullBranch} + pushInProgress={false} + handleWorkDirSelect={() => {}} handlePushBranch={() => {}} handleRemoteSelect={() => {}} - changeWorkingDirectory={() => {}} onDidChangeWorkDirs={() => {}} - getCurrentWorkDirs={() => []} openCreateDialog={() => {}} openBoundPublishDialog={() => {}} openCloneDialog={() => {}} @@ -125,7 +130,7 @@ describe('GitHubTabView', function() { const currentRemote = new Remote('aaa', 'git@github.com:aaa/bbb.git'); const changeWorkingDirectory = sinon.spy(); const wrapper = shallow(buildApp({currentRemote, changeWorkingDirectory})); - wrapper.find('GithubTabHeaderContainer').prop('handleWorkDirSelect')({target: {value: 'some-path'}}); + wrapper.find('GithubTabHeaderContainer').prop('changeWorkingDirectory')('some-path'); assert.isTrue(changeWorkingDirectory.calledWith('some-path')); }); });