diff --git a/lib/controllers/git-tab-controller.js b/lib/controllers/git-tab-controller.js index ac5710fb69..331cf44912 100644 --- a/lib/controllers/git-tab-controller.js +++ b/lib/controllers/git-tab-controller.js @@ -37,6 +37,7 @@ export default class GitTabController { workspace={this.props.workspace} commandRegistry={this.props.commandRegistry} notificationManager={this.props.notificationManager} + initializeRepo={this.props.initializeRepo} didSelectFilePath={this.props.didSelectFilePath} didDiveIntoFilePath={this.props.didDiveIntoFilePath} didSelectMergeConflictFile={this.props.didSelectMergeConflictFile} diff --git a/lib/controllers/root-controller.js b/lib/controllers/root-controller.js index f18fe6e471..88910f8307 100644 --- a/lib/controllers/root-controller.js +++ b/lib/controllers/root-controller.js @@ -38,6 +38,8 @@ export default class RootController extends React.Component { notificationManager: React.PropTypes.object.isRequired, tooltips: React.PropTypes.object.isRequired, confirm: React.PropTypes.func.isRequired, + activeProjectPath: React.PropTypes.string, + createRepositoryForProjectPath: React.PropTypes.func, repository: React.PropTypes.object, resolutionProgress: React.PropTypes.object, statusBar: React.PropTypes.object, @@ -151,6 +153,7 @@ export default class RootController extends React.Component { commandRegistry={this.props.commandRegistry} notificationManager={this.props.notificationManager} repository={this.props.repository} + initializeRepo={this.initializeRepo} resolutionProgress={this.props.resolutionProgress} isAmending={this.state.amending} didSelectFilePath={this.showFilePatchForPath} @@ -238,6 +241,11 @@ export default class RootController extends React.Component { } } + @autobind + initializeRepo() { + this.props.createRepositoryForProjectPath(this.props.activeProjectPath); + } + @autobind async showFilePatchForPath(filePath, stagingStatus, {activate, amending} = {}) { if (!filePath) { return null; } diff --git a/lib/git-shell-out-strategy.js b/lib/git-shell-out-strategy.js index ba71fbcd64..283896f63f 100644 --- a/lib/git-shell-out-strategy.js +++ b/lib/git-shell-out-strategy.js @@ -152,6 +152,10 @@ export default class GitShellOutStrategy { } } + init() { + return this.exec(['init', this.workingDir]); + } + /** * Staging/Unstaging files and patches and committing */ diff --git a/lib/github-package.js b/lib/github-package.js index 005d9656c5..2b7906b913 100644 --- a/lib/github-package.js +++ b/lib/github-package.js @@ -122,11 +122,13 @@ export default class GithubPackage { notificationManager={this.notificationManager} tooltips={this.tooltips} confirm={this.confirm} + activeProjectPath={this.activeProjectPath} repository={this.getActiveRepository()} resolutionProgress={this.getActiveResolutionProgress()} statusBar={this.statusBar} savedState={this.savedState.gitController} githubEnabled={this.config.get('github.githubEnabled')} + createRepositoryForProjectPath={this.createRepositoryForProjectPath} />, this.element, ); } @@ -155,6 +157,22 @@ export default class GithubPackage { await this.destroyModelsForPaths(Array.from(removed)); } + @autobind + async createRepositoryForProjectPath(projectPath) { + if (!projectPath) { + throw new Error('Must specify a project path to create a repository for'); + } + await Repository.init(projectPath); + this.modelsByProjectPath.delete(projectPath); + this.modelPromisesByProjectPath.delete(projectPath); + const promises = this.cacheModelsForPaths([projectPath]); + await Promise.all([ + promises.changeObserversStartedPromise, + promises.modelsPromise, + ]); + await this.updateActiveModels(); + } + @autobind didChangeActivePaneItem() { this.updateActiveModels(); @@ -168,6 +186,10 @@ export default class GithubPackage { return this.initModelsPromise; } + getActiveProjectPath() { + return this.activeProjectPath; + } + getActiveRepository() { return this.activeRepository; } @@ -178,12 +200,13 @@ export default class GithubPackage { async updateActiveModels() { await this.initModelsPromise; - let nextActiveRepository, nextActiveResolutionProgress; + let nextActiveRepository, nextActiveResolutionProgress, nextActiveProjectPath; const activeItem = this.workspace.getActivePaneItem(); if (activeItem && typeof activeItem.getPath === 'function') { const projectPath = this.projectPathForItemPath(activeItem.getPath()); if (projectPath) { + nextActiveProjectPath = projectPath; await Promise.all([ this.getRepositoryForWorkdirPath(projectPath).then(r => { nextActiveRepository = r; }), this.getResolutionProgressForWorkdirPath(projectPath).then(rp => { nextActiveResolutionProgress = rp; }), @@ -192,20 +215,24 @@ export default class GithubPackage { } else { nextActiveRepository = this.activeRepository; nextActiveResolutionProgress = this.activeResolutionProgress; + nextActiveProjectPath = this.activeProjectPath; } if (activeItem instanceof FilePatchController) { if (!activeItem.props.repository.isDestroyed()) { nextActiveRepository = activeItem.props.repository; nextActiveResolutionProgress = activeItem.props.resolutionProgress; + nextActiveProjectPath = nextActiveRepository.getWorkingDirectoryPath(); } } if (!activeItem && this.project.getPaths().length === 1) { - nextActiveRepository = await this.getRepositoryForWorkdirPath(this.project.getPaths()[0]); + nextActiveProjectPath = this.project.getPaths()[0]; + nextActiveRepository = await this.getRepositoryForWorkdirPath(nextActiveProjectPath); } if (nextActiveRepository && nextActiveRepository !== this.activeRepository) { + this.activeProjectPath = nextActiveProjectPath || null; this.setActiveModels(nextActiveRepository, nextActiveResolutionProgress); if (this.destroyedRepositorySubscription) { this.destroyedRepositorySubscription.dispose(); } @@ -215,9 +242,11 @@ export default class GithubPackage { } this.destroyedRepositorySubscription.dispose(); }); - } - - if (!nextActiveRepository) { + } else if (nextActiveRepository && this.activeProjectPath !== nextActiveProjectPath) { + this.activeProjectPath = nextActiveProjectPath || null; + this.rerender(); + } else if (!nextActiveRepository) { + this.activeProjectPath = nextActiveProjectPath || null; this.setActiveModels(null, null); } } diff --git a/lib/models/repository.js b/lib/models/repository.js index 3a3294b47d..ee84a3a51c 100644 --- a/lib/models/repository.js +++ b/lib/models/repository.js @@ -43,6 +43,12 @@ export default class Repository { } } + static async init(workingDirectoryPath, gitStrategy) { + const strategy = gitStrategy || CompositeGitStrategy.create(workingDirectoryPath); + await strategy.init(workingDirectoryPath); + return this.open(workingDirectoryPath, strategy); + } + static githubInfoFromRemote(remote) { // proto login domain owner repo const regex = /(?:.+:\/\/)?(?:.+@)?github\.com[:/]([^/]+)\/(.+)/; @@ -122,6 +128,10 @@ export default class Repository { /* eslint-enable no-console */ } + isGitRepository() { + return this.git.isGitRepository(); + } + fetchStatusesForChangedFiles() { return this.git.getStatusesForChangedFiles(); } diff --git a/lib/views/git-logo.js b/lib/views/git-logo.js new file mode 100644 index 0000000000..3af325c6f3 --- /dev/null +++ b/lib/views/git-logo.js @@ -0,0 +1,24 @@ +/** @jsx etch.dom */ +import etch from 'etch'; + +export default class GitLogo { + constructor() { + etch.initialize(this); + } + + update() {} + + render() { + /* eslint-disable */ + return ( + + + + + + + + ) + /* eslint-enable */ + } +} diff --git a/lib/views/git-tab-view.js b/lib/views/git-tab-view.js index 3005cf9e6c..607786c9ca 100644 --- a/lib/views/git-tab-view.js +++ b/lib/views/git-tab-view.js @@ -7,12 +7,14 @@ import etch from 'etch'; import {autobind} from 'core-decorators'; import StagingView from './staging-view'; +import GitLogo from './git-logo'; import CommitViewController from '../controllers/commit-view-controller'; export default class GitTabView { constructor(props) { this.props = props; etch.initialize(this); + this.repoCreationInProgress = false; this.element.addEventListener('focusin', this.rememberLastFocus); @@ -35,7 +37,15 @@ export default class GitTabView { if (!this.props.repository) { return (
-
No repository for active pane item
+
+
+ +
+
Initialize this project directory with a Git repository
+ +
); } else if (this.props.fetchInProgress) { @@ -99,6 +109,19 @@ export default class GitTabView { return etch.destroy(this); } + @autobind + async initializeRepo(event) { + event.preventDefault(); + this.repoCreationInProgress = true; + etch.update(this); + try { + await this.props.initializeRepo(); + } finally { + this.repoCreationInProgress = false; + etch.update(this); + } + } + @autobind blur() { this.props.workspace.getActivePane().activate(); diff --git a/styles/git-panel.less b/styles/git-panel.less index fc41ecd5ea..6c069df539 100644 --- a/styles/git-panel.less +++ b/styles/git-panel.less @@ -17,11 +17,27 @@ -webkit-user-select: none; cursor: default; - &.is-loading, - &.no-repository { + &.is-loading, &.no-repository { padding: @component-padding * 3; text-align: center; - color: @text-color-subtle; + } + + &.is-loading { font-style: italic; + color: @text-color-subtle; + } + + &.no-repository { + display: flex; + justify-content: center; + font-size: 1.25em; + + > * { + margin: @component-padding 0; + } + + .git-logo-path { + fill: mix(@base-background-color, @text-color, 66%); // 2/3 of bg color + } } } diff --git a/test/github-package.test.js b/test/github-package.test.js index ff20f4c19f..2fa5dac660 100644 --- a/test/github-package.test.js +++ b/test/github-package.test.js @@ -47,10 +47,10 @@ describe('GithubPackage', function() { }); describe('changing the project paths', function() { - it('updates the active repository', async function() { + it('updates the active repository and project path', async function() { const workdirPath1 = await cloneRepository('three-files'); const workdirPath2 = await cloneRepository('three-files'); - const nonRepositoryPath = temp.mkdirSync(); + const nonRepositoryPath = fs.realpathSync(temp.mkdirSync()); fs.writeFileSync(path.join(nonRepositoryPath, 'c.txt')); project.setPaths([workdirPath1, workdirPath2, nonRepositoryPath]); fs.writeFileSync(path.join(workdirPath1, 'a.txt'), 'change 1', 'utf8'); @@ -60,20 +60,24 @@ describe('GithubPackage', function() { await workspace.open(path.join(workdirPath1, 'a.txt')); const repository1 = await githubPackage.getRepositoryForWorkdirPath(workdirPath1); await assert.async.strictEqual(githubPackage.getActiveRepository(), repository1); + await assert.async.equal(githubPackage.getActiveProjectPath(), workdirPath1); await assert.async.equal(githubPackage.rerender.callCount, 1); // Remove repository for open file project.setPaths([workdirPath2, nonRepositoryPath]); + await assert.async.equal(githubPackage.getActiveProjectPath(), workdirPath1); await assert.async.isNull(githubPackage.getActiveRepository()); await assert.async.equal(githubPackage.rerender.callCount, 2); await workspace.open(path.join(workdirPath2, 'b.txt')); const repository2 = await githubPackage.getRepositoryForWorkdirPath(workdirPath2); await assert.async.strictEqual(githubPackage.getActiveRepository(), repository2); + await assert.async.equal(githubPackage.getActiveProjectPath(), workdirPath2); await assert.async.equal(githubPackage.rerender.callCount, 3); await workspace.open(path.join(nonRepositoryPath, 'c.txt')); await assert.async.isNull(githubPackage.getActiveRepository()); + await assert.async.equal(githubPackage.getActiveProjectPath(), nonRepositoryPath); await assert.async.equal(githubPackage.rerender.callCount, 4); }); @@ -130,7 +134,7 @@ describe('GithubPackage', function() { it('updates the active repository based on the active item, setting it to null when the active item is not in a project repository', async function() { const workdirPath1 = await cloneRepository('three-files'); const workdirPath2 = await cloneRepository('three-files'); - const nonRepositoryPath = temp.mkdirSync(); + const nonRepositoryPath = fs.realpathSync(temp.mkdirSync()); fs.writeFileSync(path.join(nonRepositoryPath, 'c.txt')); project.setPaths([workdirPath1, workdirPath2, nonRepositoryPath]); await githubPackage.activate(); @@ -140,34 +144,42 @@ describe('GithubPackage', function() { await githubPackage.updateActiveModels(); assert.isNotNull(githubPackage.getActiveRepository()); + assert.equal(githubPackage.getActiveProjectPath(), workdirPath2); assert.equal(githubPackage.getActiveRepository(), await githubPackage.getRepositoryForWorkdirPath(workdirPath2)); await workspace.open(path.join(nonRepositoryPath, 'c.txt')); await githubPackage.updateActiveModels(); + assert.equal(githubPackage.getActiveProjectPath(), nonRepositoryPath); assert.isNull(githubPackage.getActiveRepository()); await workspace.open(path.join(workdirPath1, 'a.txt')); await githubPackage.updateActiveModels(); + assert.equal(githubPackage.getActiveProjectPath(), workdirPath1); assert.equal(githubPackage.getActiveRepository(), await githubPackage.getRepositoryForWorkdirPath(workdirPath1)); workspace.getActivePane().activateItem({}); // such as when find & replace results pane is focused await githubPackage.updateActiveModels(); + assert.equal(githubPackage.getActiveProjectPath(), workdirPath1); assert.equal(githubPackage.getActiveRepository(), await githubPackage.getRepositoryForWorkdirPath(workdirPath1)); await workspace.open(path.join(workdirPath2, 'b.txt')); await githubPackage.updateActiveModels(); + assert.equal(githubPackage.getActiveProjectPath(), workdirPath2); assert.equal(githubPackage.getActiveRepository(), await githubPackage.getRepositoryForWorkdirPath(workdirPath2)); project.removePath(workdirPath2); await githubPackage.updateActiveModels(); + assert.isNull(githubPackage.getActiveProjectPath()); assert.isNull(githubPackage.getActiveRepository()); project.removePath(workdirPath1); await githubPackage.updateActiveModels(); + assert.isNull(githubPackage.getActiveProjectPath()); assert.isNull(githubPackage.getActiveRepository()); await workspace.open(path.join(workdirPath1, 'a.txt')); await githubPackage.updateActiveModels(); + assert.isNull(githubPackage.getActiveProjectPath()); assert.isNull(githubPackage.getActiveRepository()); }); @@ -186,7 +198,7 @@ describe('GithubPackage', function() { // Repository with a merge conflict, repository without a merge conflict, path without a repository const workdirMergeConflict = await cloneRepository('merge-conflict'); const workdirNoConflict = await cloneRepository('three-files'); - const nonRepositoryPath = temp.mkdirSync(); + const nonRepositoryPath = fs.realpathSync(temp.mkdirSync()); fs.writeFileSync(path.join(nonRepositoryPath, 'c.txt')); project.setPaths([workdirMergeConflict, workdirNoConflict, nonRepositoryPath]); @@ -230,7 +242,7 @@ describe('GithubPackage', function() { if (process.platform !== 'win32') { it('handles symlinked project paths', async () => { const workdirPath = await cloneRepository('three-files'); - const symlinkPath = temp.mkdirSync() + '-symlink'; + const symlinkPath = fs.realpathSync(temp.mkdirSync()) + '-symlink'; fs.symlinkSync(workdirPath, symlinkPath); project.setPaths([symlinkPath]); await githubPackage.activate(); @@ -296,6 +308,23 @@ describe('GithubPackage', function() { }); }); + describe('#createRepositoryForProjectPath', function() { + it('creates and sets a repository for the given project path', async function() { + const workdirPath1 = await cloneRepository('three-files'); + const workdirPath2 = await cloneRepository('three-files'); + const nonRepositoryPath = fs.realpathSync(temp.mkdirSync()); + fs.writeFileSync(path.join(nonRepositoryPath, 'c.txt')); + project.setPaths([workdirPath1, workdirPath2, nonRepositoryPath]); + await githubPackage.activate(); + + await workspace.open(path.join(nonRepositoryPath, 'c.txt')); + await assert.async.isNull(githubPackage.getActiveRepository()); + await githubPackage.createRepositoryForProjectPath(nonRepositoryPath); + assert.isOk(githubPackage.getActiveRepository()); + assert.equal(githubPackage.getActiveRepository(), await githubPackage.getRepositoryForWorkdirPath(nonRepositoryPath)); + }); + }); + describe('#projectPathForItemPath', function() { it('does not error when the path is falsy (e.g. new unsaved file)', function() { sinon.stub(project, 'getDirectories').returns([new Directory('path')]); diff --git a/test/models/repository.test.js b/test/models/repository.test.js index fb8dd50093..c10988f27a 100644 --- a/test/models/repository.test.js +++ b/test/models/repository.test.js @@ -1,6 +1,7 @@ import fs from 'fs'; import path from 'path'; import dedent from 'dedent-js'; +import temp from 'temp'; import Repository from '../../lib/models/repository'; @@ -39,6 +40,15 @@ describe('Repository', function() { }); }); + describe('init', function() { + it('creates a repository in the given dir and returns the repository', async function() { + const soonToBeRepositoryPath = fs.realpathSync(temp.mkdirSync()); + const repo = await Repository.init(soonToBeRepositoryPath); + assert.isTrue(await repo.isGitRepository()); + assert.equal(repo.getWorkingDirectoryPath(), soonToBeRepositoryPath); + }); + }); + describe('staging and unstaging files', function() { it('can stage and unstage modified files', async function() { const workingDirPath = await cloneRepository('three-files');