diff --git a/lib/containers/commit-detail-container.js b/lib/containers/commit-detail-container.js
new file mode 100644
index 00000000000..911c73b5f8f
--- /dev/null
+++ b/lib/containers/commit-detail-container.js
@@ -0,0 +1,41 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import yubikiri from 'yubikiri';
+
+import ObserveModel from '../views/observe-model';
+import LoadingView from '../views/loading-view';
+import CommitDetailController from '../controllers/commit-detail-controller';
+
+export default class CommitDetailContainer extends React.Component {
+ static propTypes = {
+ repository: PropTypes.object.isRequired,
+ sha: PropTypes.string.isRequired,
+ }
+
+ fetchData = repository => {
+ return yubikiri({
+ commit: repository.getCommit(this.props.sha),
+ });
+ }
+
+ render() {
+ return (
+
+ {this.renderResult}
+
+ );
+ }
+
+ renderResult = data => {
+ if (this.props.repository.isLoading() || data === null || !data.commit.isPresent()) {
+ return ;
+ }
+
+ return (
+
+ );
+ }
+}
diff --git a/lib/controllers/commit-detail-controller.js b/lib/controllers/commit-detail-controller.js
new file mode 100644
index 00000000000..d73bf852f23
--- /dev/null
+++ b/lib/controllers/commit-detail-controller.js
@@ -0,0 +1,105 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import {emojify} from 'node-emoji';
+import moment from 'moment';
+
+import MultiFilePatchController from './multi-file-patch-controller';
+
+const avatarAltText = 'committer avatar';
+
+export default class CommitDetailController extends React.Component {
+ static propTypes = {
+ repository: PropTypes.object.isRequired,
+
+ workspace: PropTypes.object.isRequired,
+ commands: PropTypes.object.isRequired,
+ keymaps: PropTypes.object.isRequired,
+ tooltips: PropTypes.object.isRequired,
+ config: PropTypes.object.isRequired,
+
+ destroy: PropTypes.func.isRequired,
+ commit: PropTypes.object.isRequired,
+ }
+
+ render() {
+ const commit = this.props.commit;
+ // const {messageHeadline, messageBody, abbreviatedOid, url} = this.props.item;
+ // const {avatarUrl, name, date} = this.props.item.committer;
+
+ return (
+
+
+
+
+
+ {emojify(commit.getMessageSubject())}
+
+
+ {emojify(commit.getMessageBody())}
+
+ {/* TODO fix image src */}
+ {this.renderAuthors()}
+
+ {commit.getAuthorEmail()} committed {this.humanizeTimeSince(commit.getAuthorDate())}
+
+
+
+
+
+
+
+
+ );
+ }
+
+ humanizeTimeSince(date) {
+ return moment(date * 1000).fromNow();
+ }
+
+ getAuthorInfo() {
+ const coAuthorCount = this.props.commit.getCoAuthors().length;
+ return coAuthorCount ? this.props.commit.getAuthorEmail() : `${coAuthorCount + 1} people`;
+ }
+
+ renderAuthor(email) {
+ const match = email.match(/^(\d+)\+[^@]+@users.noreply.github.com$/);
+
+ let avatarUrl;
+ if (match) {
+ avatarUrl = 'https://avatars.githubusercontent.com/u/' + match[1] + '?s=32';
+ } else {
+ avatarUrl = 'https://avatars.githubusercontent.com/u/e?email=' + encodeURIComponent(email) + '&s=32';
+ }
+
+ return (
+
+ );
+ }
+
+ renderAuthors() {
+ const coAuthorEmails = this.props.commit.getCoAuthors().map(author => author.email);
+ const authorEmails = [this.props.commit.getAuthorEmail(), ...coAuthorEmails];
+
+ return (
+
+ {authorEmails.map(this.renderAuthor)}
+
+ );
+ }
+}
diff --git a/lib/controllers/commit-preview-controller.js b/lib/controllers/commit-preview-controller.js
index f1ce3c988c7..ff5f3cf72ba 100644
--- a/lib/controllers/commit-preview-controller.js
+++ b/lib/controllers/commit-preview-controller.js
@@ -23,6 +23,7 @@ export default class CommitPreviewController extends React.Component {
return (
);
diff --git a/lib/controllers/multi-file-patch-controller.js b/lib/controllers/multi-file-patch-controller.js
index 09944505c58..139d15a0a34 100644
--- a/lib/controllers/multi-file-patch-controller.js
+++ b/lib/controllers/multi-file-patch-controller.js
@@ -22,9 +22,11 @@ export default class MultiFilePatchController extends React.Component {
config: PropTypes.object.isRequired,
destroy: PropTypes.func.isRequired,
- discardLines: PropTypes.func.isRequired,
- undoLastDiscard: PropTypes.func.isRequired,
- surface: PropTypes.func.isRequired,
+ discardLines: PropTypes.func,
+ undoLastDiscard: PropTypes.func,
+ surface: PropTypes.func,
+ autoHeight: PropTypes.bool,
+ disableStageUnstage: PropTypes.bool,
}
constructor(props) {
diff --git a/lib/controllers/recent-commits-controller.js b/lib/controllers/recent-commits-controller.js
index 60ba927619b..2e72da64f97 100644
--- a/lib/controllers/recent-commits-controller.js
+++ b/lib/controllers/recent-commits-controller.js
@@ -1,13 +1,49 @@
import React from 'react';
import PropTypes from 'prop-types';
+import {autobind} from '../helpers';
+import {addEvent} from '../reporter-proxy';
+import CommitDetailItem from '../items/commit-detail-item';
+import URIPattern from '../atom/uri-pattern';
import RecentCommitsView from '../views/recent-commits-view';
+import {CompositeDisposable} from 'event-kit';
export default class RecentCommitsController extends React.Component {
static propTypes = {
commits: PropTypes.arrayOf(PropTypes.object).isRequired,
isLoading: PropTypes.bool.isRequired,
undoLastCommit: PropTypes.func.isRequired,
+ workspace: PropTypes.object.isRequired,
+ repository: PropTypes.object.isRequired,
+ }
+
+ constructor(props, context) {
+ super(props, context);
+ autobind(this, 'openCommit', 'updateSelectedCommit');
+
+ this.subscriptions = new CompositeDisposable(
+ this.props.workspace.onDidChangeActivePaneItem(this.updateSelectedCommit),
+ );
+ this.state = {selectedCommitSha: ''};
+ }
+
+ updateSelectedCommit() {
+ const activeItem = this.props.workspace.getActivePaneItem();
+
+ const pattern = new URIPattern(decodeURIComponent(
+ CommitDetailItem.buildURI(
+ this.props.repository.getWorkingDirectoryPath(),
+ '{sha}'),
+ ));
+
+ if (activeItem && activeItem.getURI) {
+ const match = pattern.matches(activeItem.getURI());
+ const {sha} = match.getParams();
+ if (match.ok() && sha && sha !== this.state.selectedCommitSha) {
+ return new Promise(resolve => this.setState({selectedCommitSha: sha}, resolve));
+ }
+ }
+ return Promise.resolve();
}
render() {
@@ -16,7 +52,17 @@ export default class RecentCommitsController extends React.Component {
commits={this.props.commits}
isLoading={this.props.isLoading}
undoLastCommit={this.props.undoLastCommit}
+ openCommit={this.openCommit}
+ selectedCommitSha={this.state.selectedCommitSha}
/>
);
}
+
+ openCommit({sha}) {
+ const workdir = this.props.repository.getWorkingDirectoryPath();
+ const uri = CommitDetailItem.buildURI(workdir, sha);
+ this.props.workspace.open(uri).then(() => {
+ addEvent('open-commit-in-pane', {package: 'github', from: 'recent commit'});
+ });
+ }
}
diff --git a/lib/controllers/root-controller.js b/lib/controllers/root-controller.js
index ec5d22f536f..c57c7a9d4f7 100644
--- a/lib/controllers/root-controller.js
+++ b/lib/controllers/root-controller.js
@@ -11,12 +11,14 @@ import Panel from '../atom/panel';
import PaneItem from '../atom/pane-item';
import CloneDialog from '../views/clone-dialog';
import OpenIssueishDialog from '../views/open-issueish-dialog';
+import OpenCommitDialog from '../views/open-commit-dialog';
import InitDialog from '../views/init-dialog';
import CredentialDialog from '../views/credential-dialog';
import Commands, {Command} from '../atom/commands';
import GitTimingsView from '../views/git-timings-view';
import ChangedFileItem from '../items/changed-file-item';
import IssueishDetailItem from '../items/issueish-detail-item';
+import CommitDetailItem from '../items/commit-detail-item';
import CommitPreviewItem from '../items/commit-preview-item';
import GitTabItem from '../items/git-tab-item';
import GitHubTabItem from '../items/github-tab-item';
@@ -139,6 +141,7 @@ export default class RootController extends React.Component {
+
);
}
@@ -244,6 +248,22 @@ export default class RootController extends React.Component {
);
}
+ renderOpenCommitDialog() {
+ if (!this.state.openCommitDialogActive) {
+ return null;
+ }
+
+ return (
+
+
+
+ );
+ }
+
renderCredentialDialog() {
if (this.state.credentialDialogQuery === null) {
return null;
@@ -362,6 +382,26 @@ export default class RootController extends React.Component {
/>
)}
+
+ {({itemHolder, params}) => (
+
+ )}
+
{({itemHolder, params}) => (
{
+ this.setState({openCommitDialogActive: true});
+ }
+
showWaterfallDiagnostics() {
this.props.workspace.open(GitTimingsView.buildURI());
}
@@ -548,6 +592,19 @@ export default class RootController extends React.Component {
this.setState({openIssueishDialogActive: false});
}
+ acceptOpenCommit = ({sha}) => {
+ const workdir = this.props.repository.getWorkingDirectoryPath();
+ const uri = CommitDetailItem.buildURI(workdir, sha);
+ this.setState({openCommitDialogActive: false});
+ this.props.workspace.open(uri).then(() => {
+ addEvent('open-commit-in-pane', {package: 'github', from: 'dialog'});
+ });
+ }
+
+ cancelOpenCommit = () => {
+ this.setState({openCommitDialogActive: false});
+ }
+
surfaceFromFileAtPath = (filePath, stagingStatus) => {
const gitTab = this.gitTabTracker.getComponent();
return gitTab && gitTab.focusAndSelectStagingItem(filePath, stagingStatus);
diff --git a/lib/git-shell-out-strategy.js b/lib/git-shell-out-strategy.js
index 0e8f303d4c3..1addc791b31 100644
--- a/lib/git-shell-out-strategy.js
+++ b/lib/git-shell-out-strategy.js
@@ -687,6 +687,14 @@ export default class GitShellOutStrategy {
return headCommit;
}
+ async getDiffsForCommit(sha) {
+ const output = await this.exec([
+ 'diff', '--no-prefix', '--no-ext-diff', '--no-renames', `${sha}~`, sha,
+ ]);
+
+ return parseDiff(output);
+ }
+
async getCommits(options = {}) {
const {max, ref, includeUnborn} = {max: 1, ref: 'HEAD', includeUnborn: false, ...options};
diff --git a/lib/github-package.js b/lib/github-package.js
index 0da56cd648a..a0ed4797100 100644
--- a/lib/github-package.js
+++ b/lib/github-package.js
@@ -389,6 +389,16 @@ export default class GithubPackage {
return item;
}
+ createCommitDetailStub({uri}) {
+ const item = StubItem.create('git-commit-detail', {
+ title: 'Commit',
+ }, uri);
+ if (this.controller) {
+ this.rerender();
+ }
+ return item;
+ }
+
destroyGitTabItem() {
if (this.gitTabStubItem) {
this.gitTabStubItem.destroy();
diff --git a/lib/items/commit-detail-item.js b/lib/items/commit-detail-item.js
new file mode 100644
index 00000000000..fdfc39e6382
--- /dev/null
+++ b/lib/items/commit-detail-item.js
@@ -0,0 +1,94 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import {Emitter} from 'event-kit';
+
+import {WorkdirContextPoolPropType} from '../prop-types';
+import CommitDetailContainer from '../containers/commit-detail-container';
+import RefHolder from '../models/ref-holder';
+
+export default class CommitDetailItem extends React.Component {
+ static propTypes = {
+ workdirContextPool: WorkdirContextPoolPropType.isRequired,
+ workingDirectory: PropTypes.string.isRequired,
+ sha: PropTypes.string.isRequired,
+ }
+
+ static uriPattern = 'atom-github://commit-detail?workdir={workingDirectory}&sha={sha}'
+
+ static buildURI(workingDirectory, sha) {
+ return `atom-github://commit-detail?workdir=${encodeURIComponent(workingDirectory)}&sha=${encodeURIComponent(sha)}`;
+ }
+
+ constructor(props) {
+ super(props);
+
+ this.emitter = new Emitter();
+ this.isDestroyed = false;
+ this.hasTerminatedPendingState = false;
+ this.refInitialFocus = new RefHolder();
+ }
+
+ terminatePendingState() {
+ if (!this.hasTerminatedPendingState) {
+ this.emitter.emit('did-terminate-pending-state');
+ this.hasTerminatedPendingState = true;
+ }
+ }
+
+ onDidTerminatePendingState(callback) {
+ return this.emitter.on('did-terminate-pending-state', callback);
+ }
+
+ destroy = () => {
+ /* istanbul ignore else */
+ if (!this.isDestroyed) {
+ this.emitter.emit('did-destroy');
+ this.isDestroyed = true;
+ }
+ }
+
+ onDidDestroy(callback) {
+ return this.emitter.on('did-destroy', callback);
+ }
+
+ render() {
+ const repository = this.props.workdirContextPool.getContext(this.props.workingDirectory).getRepository();
+
+ return (
+
+ );
+ }
+
+ getTitle() {
+ return `Commit: ${this.props.sha}`;
+ }
+
+ getIconName() {
+ return 'git-commit';
+ }
+
+ getWorkingDirectory() {
+ return this.props.workingDirectory;
+ }
+
+ getSha() {
+ return this.props.sha;
+ }
+
+ serialize() {
+ return {
+ deserializer: 'CommitDetailStub',
+ uri: CommitDetailItem.buildURI(this.props.workingDirectory, this.props.sha),
+ };
+ }
+
+ focus() {
+ this.refInitialFocus.map(focusable => focusable.focus());
+ }
+}
diff --git a/lib/models/commit.js b/lib/models/commit.js
index 6bfa738b8d3..f1fbd72a1c2 100644
--- a/lib/models/commit.js
+++ b/lib/models/commit.js
@@ -13,6 +13,7 @@ export default class Commit {
this.messageSubject = messageSubject;
this.messageBody = messageBody;
this.unbornRef = unbornRef === UNBORN;
+ this.multiFileDiff = null;
}
getSha() {
@@ -43,6 +44,14 @@ export default class Commit {
return `${this.getMessageSubject()}\n\n${this.getMessageBody()}`.trim();
}
+ setMultiFileDiff(multiFileDiff) {
+ this.multiFileDiff = multiFileDiff;
+ }
+
+ getMultiFileDiff() {
+ return this.multiFileDiff;
+ }
+
isUnbornRef() {
return this.unbornRef;
}
diff --git a/lib/models/repository-states/present.js b/lib/models/repository-states/present.js
index a9f9691a2ab..bc285890810 100644
--- a/lib/models/repository-states/present.js
+++ b/lib/models/repository-states/present.js
@@ -723,6 +723,17 @@ export default class Present extends State {
});
}
+ getCommit(sha) {
+ return this.cache.getOrSet(Keys.blob.oneWith(sha), async () => {
+ const [rawCommitMetadata] = await this.git().getCommits({max: 1, ref: sha});
+ const commit = new Commit(rawCommitMetadata);
+ // todo: check need for error handling in the case of 0 commit and 1 commit
+ const multiFileDiff = await this.git().getDiffsForCommit(sha).then(buildMultiFilePatch);
+ commit.setMultiFileDiff(multiFileDiff);
+ return commit;
+ });
+ }
+
getRecentCommits(options) {
return this.cache.getOrSet(Keys.recentCommits, async () => {
const commits = await this.git().getCommits({ref: 'HEAD', ...options});
@@ -1097,7 +1108,7 @@ const Keys = {
},
blob: {
- oneWith: sha => `blob:${sha}`,
+ oneWith: sha => new CacheKey(`blob:${sha}`, ['blob']),
},
// Common collections of keys and patterns for use with invalidate().
diff --git a/lib/models/repository-states/state.js b/lib/models/repository-states/state.js
index 85bb53e3a8f..92961a777ad 100644
--- a/lib/models/repository-states/state.js
+++ b/lib/models/repository-states/state.js
@@ -298,6 +298,10 @@ export default class State {
return Promise.resolve(nullCommit);
}
+ getCommit() {
+ return Promise.resolve(nullCommit);
+ }
+
getRecentCommits() {
return Promise.resolve([]);
}
diff --git a/lib/models/repository.js b/lib/models/repository.js
index 9c39d827eb1..68202d136e2 100644
--- a/lib/models/repository.js
+++ b/lib/models/repository.js
@@ -331,6 +331,7 @@ const delegates = [
'readFileFromIndex',
'getLastCommit',
+ 'getCommit',
'getRecentCommits',
'getAuthors',
diff --git a/lib/views/file-patch-header-view.js b/lib/views/file-patch-header-view.js
index 8f7db7d8e2b..5eb5476d08a 100644
--- a/lib/views/file-patch-header-view.js
+++ b/lib/views/file-patch-header-view.js
@@ -7,11 +7,12 @@ import cx from 'classnames';
import RefHolder from '../models/ref-holder';
import ChangedFileItem from '../items/changed-file-item';
import CommitPreviewItem from '../items/commit-preview-item';
+import CommitDetailItem from '../items/commit-detail-item';
export default class FilePatchHeaderView extends React.Component {
static propTypes = {
relPath: PropTypes.string.isRequired,
- stagingStatus: PropTypes.oneOf(['staged', 'unstaged']).isRequired,
+ stagingStatus: PropTypes.oneOf(['staged', 'unstaged']),
isPartiallyStaged: PropTypes.bool,
hasHunks: PropTypes.bool.isRequired,
hasUndoHistory: PropTypes.bool,
@@ -24,7 +25,8 @@ export default class FilePatchHeaderView extends React.Component {
openFile: PropTypes.func.isRequired,
toggleFile: PropTypes.func.isRequired,
- itemType: PropTypes.oneOf([ChangedFileItem, CommitPreviewItem]).isRequired,
+ itemType: PropTypes.oneOf([ChangedFileItem, CommitPreviewItem, CommitDetailItem]).isRequired,
+ disableStageUnstage: PropTypes.bool,
};
constructor(props) {
@@ -72,14 +74,18 @@ export default class FilePatchHeaderView extends React.Component {
}
renderButtonGroup() {
- return (
-
- {this.renderUndoDiscardButton()}
- {this.renderMirrorPatchButton()}
- {this.renderOpenFileButton()}
- {this.renderToggleFileButton()}
-
- );
+ if (this.props.disableStageUnstage) {
+ return null;
+ } else {
+ return (
+
+ {this.renderUndoDiscardButton()}
+ {this.renderMirrorPatchButton()}
+ {this.renderOpenFileButton()}
+ {this.renderToggleFileButton()}
+
+ );
+ }
}
renderUndoDiscardButton() {
diff --git a/lib/views/git-tab-view.js b/lib/views/git-tab-view.js
index 9899daca097..fb7339869db 100644
--- a/lib/views/git-tab-view.js
+++ b/lib/views/git-tab-view.js
@@ -196,6 +196,8 @@ export default class GitTabView extends React.Component {
commits={this.props.recentCommits}
isLoading={this.props.isLoading}
undoLastCommit={this.props.undoLastCommit}
+ workspace={this.props.workspace}
+ repository={this.props.repository}
/>
);
diff --git a/lib/views/hunk-header-view.js b/lib/views/hunk-header-view.js
index cb72feb36ac..0c4dd8516d9 100644
--- a/lib/views/hunk-header-view.js
+++ b/lib/views/hunk-header-view.js
@@ -17,22 +17,23 @@ export default class HunkHeaderView extends React.Component {
refTarget: RefHolderPropType.isRequired,
hunk: PropTypes.object.isRequired,
isSelected: PropTypes.bool.isRequired,
- stagingStatus: PropTypes.oneOf(['unstaged', 'staged']).isRequired,
+ stagingStatus: PropTypes.oneOf(['unstaged', 'staged']),
selectionMode: PropTypes.oneOf(['hunk', 'line']).isRequired,
- toggleSelectionLabel: PropTypes.string.isRequired,
- discardSelectionLabel: PropTypes.string.isRequired,
+ toggleSelectionLabel: PropTypes.string,
+ discardSelectionLabel: PropTypes.string,
tooltips: PropTypes.object.isRequired,
keymaps: PropTypes.object.isRequired,
- toggleSelection: PropTypes.func.isRequired,
- discardSelection: PropTypes.func.isRequired,
+ toggleSelection: PropTypes.func,
+ discardSelection: PropTypes.func,
mouseDown: PropTypes.func.isRequired,
+ disableStageUnstage: PropTypes.bool,
};
constructor(props) {
super(props);
- autobind(this, 'didMouseDown');
+ autobind(this, 'didMouseDown', 'renderButtons');
this.refDiscardButton = new RefHolder();
}
@@ -48,32 +49,44 @@ export default class HunkHeaderView extends React.Component {
{this.props.hunk.getHeader().trim()} {this.props.hunk.getSectionHeading().trim()}
-
- {this.props.stagingStatus === 'unstaged' && (
-
-
-
-
- )}
+ {this.renderButtons()}
);
}
+ renderButtons() {
+ if (this.props.disableStageUnstage) {
+ return null;
+ } else {
+ return (
+
+
+ {this.props.stagingStatus === 'unstaged' && (
+
+
+
+
+ )}
+
+ );
+ }
+ }
+
didMouseDown(event) {
return this.props.mouseDown(event, this.props.hunk);
}
diff --git a/lib/views/multi-file-patch-view.js b/lib/views/multi-file-patch-view.js
index 0ff63f97b39..a066c29bac4 100644
--- a/lib/views/multi-file-patch-view.js
+++ b/lib/views/multi-file-patch-view.js
@@ -18,6 +18,7 @@ import HunkHeaderView from './hunk-header-view';
import RefHolder from '../models/ref-holder';
import ChangedFileItem from '../items/changed-file-item';
import CommitPreviewItem from '../items/commit-preview-item';
+import CommitDetailItem from '../items/commit-detail-item';
import File from '../models/patch/file';
const executableText = {
@@ -31,7 +32,7 @@ const BLANK_LABEL = () => NBSP_CHARACTER;
export default class MultiFilePatchView extends React.Component {
static propTypes = {
- stagingStatus: PropTypes.oneOf(['staged', 'unstaged']).isRequired,
+ stagingStatus: PropTypes.oneOf(['staged', 'unstaged']),
isPartiallyStaged: PropTypes.bool,
multiFilePatch: MultiFilePatchPropType.isRequired,
selectionMode: PropTypes.oneOf(['hunk', 'line']).isRequired,
@@ -46,20 +47,21 @@ export default class MultiFilePatchView extends React.Component {
tooltips: PropTypes.object.isRequired,
config: PropTypes.object.isRequired,
- selectedRowsChanged: PropTypes.func.isRequired,
-
- diveIntoMirrorPatch: PropTypes.func.isRequired,
- surface: PropTypes.func.isRequired,
- openFile: PropTypes.func.isRequired,
- toggleFile: PropTypes.func.isRequired,
- toggleRows: PropTypes.func.isRequired,
- toggleModeChange: PropTypes.func.isRequired,
- toggleSymlinkChange: PropTypes.func.isRequired,
- undoLastDiscard: PropTypes.func.isRequired,
- discardRows: PropTypes.func.isRequired,
-
+ selectedRowsChanged: PropTypes.func,
+
+ diveIntoMirrorPatch: PropTypes.func,
+ surface: PropTypes.func,
+ openFile: PropTypes.func,
+ toggleFile: PropTypes.func,
+ toggleRows: PropTypes.func,
+ toggleModeChange: PropTypes.func,
+ toggleSymlinkChange: PropTypes.func,
+ undoLastDiscard: PropTypes.func,
+ discardRows: PropTypes.func,
+ autoHeight: PropTypes.bool,
+ disableStageUnstage: PropTypes.bool,
refInitialFocus: RefHolderPropType,
- itemType: PropTypes.oneOf([ChangedFileItem, CommitPreviewItem]).isRequired,
+ itemType: PropTypes.oneOf([ChangedFileItem, CommitPreviewItem, CommitDetailItem]).isRequired,
}
constructor(props) {
@@ -186,6 +188,15 @@ export default class MultiFilePatchView extends React.Component {
}
renderCommands() {
+ if (this.props.itemType === CommitDetailItem) {
+ return (
+
+
+
+
+ );
+ }
+
let stageModeCommand = null;
let stageSymlinkCommand = null;
@@ -205,11 +216,11 @@ export default class MultiFilePatchView extends React.Component {
return (
+
+
-
-
@@ -231,7 +242,7 @@ export default class MultiFilePatchView extends React.Component {
buffer={this.props.multiFilePatch.getBuffer()}
lineNumberGutterVisible={false}
autoWidth={false}
- autoHeight={false}
+ autoHeight={this.props.autoHeight}
readOnly={true}
softWrapped={true}
@@ -318,6 +329,7 @@ export default class MultiFilePatchView extends React.Component {
diveIntoMirrorPatch={() => this.props.diveIntoMirrorPatch(filePatch)}
openFile={() => this.didOpenFile({selectedFilePatch: filePatch})}
toggleFile={() => this.props.toggleFile(filePatch)}
+ disableStageUnstage={this.props.disableStageUnstage}
/>
{this.renderSymlinkChangeMeta(filePatch)}
{this.renderExecutableModeChangeMeta(filePatch)}
@@ -493,6 +505,8 @@ export default class MultiFilePatchView extends React.Component {
toggleSelection={() => this.toggleHunkSelection(hunk, containsSelection)}
discardSelection={() => this.discardHunkSelection(hunk, containsSelection)}
mouseDown={this.didMouseDownOnHeader}
+
+ disableStageUnstage={this.props.disableStageUnstage}
/>
diff --git a/lib/views/open-commit-dialog.js b/lib/views/open-commit-dialog.js
new file mode 100644
index 00000000000..47ab2d3ec21
--- /dev/null
+++ b/lib/views/open-commit-dialog.js
@@ -0,0 +1,138 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import {CompositeDisposable} from 'event-kit';
+
+import Commands, {Command} from '../atom/commands';
+import {autobind} from '../helpers';
+
+// const COMMIT_SHA_REGEX = /^(?:https?:\/\/)?github.com\/([^/]+)\/([^/]+)\/(?:issues|pull)\/(\d+)/;
+
+export default class OpenCommitDialog extends React.Component {
+ static propTypes = {
+ commandRegistry: PropTypes.object.isRequired,
+ didAccept: PropTypes.func,
+ didCancel: PropTypes.func,
+ }
+
+ static defaultProps = {
+ didAccept: () => {},
+ didCancel: () => {},
+ }
+
+ constructor(props, context) {
+ super(props, context);
+ autobind(this, 'accept', 'cancel', 'editorRefs', 'didChangeCommitSha');
+
+ this.state = {
+ cloneDisabled: false,
+ };
+
+ this.subs = new CompositeDisposable();
+ }
+
+ componentDidMount() {
+ if (this.commitShaElement) {
+ setTimeout(() => this.commitShaElement.focus());
+ }
+ }
+
+ render() {
+ return this.renderDialog();
+ }
+
+ renderDialog() {
+ return (
+
+
+
+
+
+
+
+ {this.state.error && {this.state.error}}
+
+
+
+
+
+
+ );
+ }
+
+ accept() {
+ if (this.getCommitSha().length === 0) {
+ return;
+ }
+
+ const parsed = this.parseSha();
+ if (!parsed) {
+ this.setState({
+ error: 'That is not a valid commit sha.',
+ });
+ return;
+ }
+ const {sha} = parsed;
+
+ this.props.didAccept({sha});
+ }
+
+ cancel() {
+ this.props.didCancel();
+ }
+
+ editorRefs(baseName) {
+ const elementName = `${baseName}Element`;
+ const modelName = `${baseName}Editor`;
+ const subName = `${baseName}Subs`;
+ const changeMethodName = `didChange${baseName[0].toUpperCase()}${baseName.substring(1)}`;
+
+ return element => {
+ if (!element) {
+ return;
+ }
+
+ this[elementName] = element;
+ const editor = element.getModel();
+ if (this[modelName] !== editor) {
+ this[modelName] = editor;
+
+ if (this[subName]) {
+ this[subName].dispose();
+ this.subs.remove(this[subName]);
+ }
+
+ this[subName] = editor.onDidChange(this[changeMethodName]);
+ this.subs.add(this[subName]);
+ }
+ };
+ }
+
+ didChangeCommitSha() {
+ this.setState({error: null});
+ }
+
+ parseSha() {
+ const sha = this.getCommitSha();
+ // const matches = url.match(ISSUEISH_URL_REGEX);
+ // if (!matches) {
+ // return false;
+ // }
+ // const [_full, repoOwner, repoName, issueishNumber] = matches; // eslint-disable-line no-unused-vars
+ return {sha};
+ }
+
+ getCommitSha() {
+ return this.commitShaEditor ? this.commitShaEditor.getText() : '';
+ }
+}
diff --git a/lib/views/pr-commit-view.js b/lib/views/pr-commit-view.js
index 88d879a3cbf..1d31e5947da 100644
--- a/lib/views/pr-commit-view.js
+++ b/lib/views/pr-commit-view.js
@@ -38,6 +38,7 @@ export class PrCommitView extends React.Component {
}
render() {
+ console.log('zzz');
const {messageHeadline, messageBody, abbreviatedOid, url} = this.props.item;
const {avatarUrl, name, date} = this.props.item.committer;
return (
diff --git a/lib/views/recent-commits-view.js b/lib/views/recent-commits-view.js
index 855fad6515a..fd0bea625c7 100644
--- a/lib/views/recent-commits-view.js
+++ b/lib/views/recent-commits-view.js
@@ -11,6 +11,8 @@ class RecentCommitView extends React.Component {
commit: PropTypes.object.isRequired,
undoLastCommit: PropTypes.func.isRequired,
isMostRecent: PropTypes.bool.isRequired,
+ openCommit: PropTypes.func.isRequired,
+ isSelected: PropTypes.bool.isRequired,
};
render() {
@@ -18,7 +20,12 @@ class RecentCommitView extends React.Component {
const fullMessage = this.props.commit.getFullMessage();
return (
-
+
{this.renderAuthors()}
+ onClick={this.undoLastCommit}>
Undo
)}
@@ -73,6 +80,11 @@ class RecentCommitView extends React.Component {
);
}
+
+ undoLastCommit = event => {
+ event.stopPropagation();
+ this.props.undoLastCommit();
+ }
}
export default class RecentCommitsView extends React.Component {
@@ -80,6 +92,8 @@ export default class RecentCommitsView extends React.Component {
commits: PropTypes.arrayOf(PropTypes.object).isRequired,
isLoading: PropTypes.bool.isRequired,
undoLastCommit: PropTypes.func.isRequired,
+ openCommit: PropTypes.func.isRequired,
+ selectedCommitSha: PropTypes.string.isRequired,
};
render() {
@@ -115,6 +129,8 @@ export default class RecentCommitsView extends React.Component {
isMostRecent={i === 0}
commit={commit}
undoLastCommit={this.props.undoLastCommit}
+ openCommit={() => this.props.openCommit({sha: commit.getSha()})}
+ isSelected={this.props.selectedCommitSha === commit.getSha()}
/>
);
})}
diff --git a/package.json b/package.json
index 94ea85116db..e8a5fed2e37 100644
--- a/package.json
+++ b/package.json
@@ -203,6 +203,7 @@
"GitDockItem": "createDockItemStub",
"GithubDockItem": "createDockItemStub",
"FilePatchControllerStub": "createFilePatchControllerStub",
- "CommitPreviewStub": "createCommitPreviewStub"
+ "CommitPreviewStub": "createCommitPreviewStub",
+ "CommitDetailStub": "createCommitDetailStub"
}
}
diff --git a/styles/commit-detail.less b/styles/commit-detail.less
new file mode 100644
index 00000000000..6f8c24f975a
--- /dev/null
+++ b/styles/commit-detail.less
@@ -0,0 +1,91 @@
+@import "variables";
+
+@default-padding: @component-padding;
+@avatar-dimensions: 16px;
+
+.github-CommitDetailView {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+
+ &-header {
+ flex: 0;
+ padding: @default-padding;
+ padding-bottom: 0;
+ background-color: @syntax-background-color;
+ }
+
+ &-commitContainer {
+ display: flex;
+ align-items: center;
+ padding: @default-padding;
+ border: 1px solid @base-border-color;
+ border-radius: @component-border-radius;
+ }
+
+ &-commit {
+ flex: 1;
+ }
+
+ &-title {
+ margin: 0 0 .25em 0;
+ font-size: 1.2em;
+ line-height: 1.4;
+ color: @text-color-highlight;
+ }
+
+ &-avatar {
+ border-radius: @component-border-radius;
+ height: @avatar-dimensions;
+ margin-right: .4em;
+ width: @avatar-dimensions;
+ }
+
+ &-metaText {
+ margin-left: @avatar-dimensions * 1.3; // leave some space for the avatars
+ line-height: @avatar-dimensions;
+ color: @text-color-subtle;
+ }
+
+ &-moreButton {
+ border: none;
+ margin-left: @default-padding/1.5;
+ padding: 0em .2em;
+ color: @text-color-subtle;
+ font-style: italic;
+ font-size: .8em;
+ border: 1px solid @base-border-color;
+ border-radius: @component-border-radius;
+ background-color: @button-background-color;
+
+ &:hover {
+ background-color: @button-background-color-hover
+ }
+ }
+
+ &-moreText {
+ padding: 0 0 @default-padding 0;
+ font-size: inherit;
+ font-family: var(--editor-font-family);
+ word-wrap: initial;
+ word-break: break-word;
+ white-space: initial;
+ background-color: transparent;
+ &:empty {
+ display: none;
+ }
+ }
+
+ &-sha {
+ flex: 0 0 7ch; // Limit to 7 characters
+ margin-left: @default-padding*2;
+ line-height: @avatar-dimensions;
+ color: @text-color-info;
+ font-family: var(--editor-font-family);
+ white-space: nowrap;
+ overflow: hidden;
+ a {
+ color: inherit;
+ }
+ }
+}
diff --git a/styles/file-patch-view.less b/styles/file-patch-view.less
index 3de411746a3..fcf9a7cb485 100644
--- a/styles/file-patch-view.less
+++ b/styles/file-patch-view.less
@@ -32,14 +32,6 @@
}
}
- // Editor overrides
-
- atom-text-editor {
- .selection .region {
- background-color: mix(@button-background-color-selected, @syntax-background-color, 24%);
- }
- }
-
&-header {
display: flex;
justify-content: space-between;
@@ -220,11 +212,6 @@
min-width: 6ch; // Fit up to 4 characters (+1 padding on each side)
opacity: 1;
padding: 0 1ch 0 0;
-
- &.github-FilePatchView-line--selected {
- color: contrast(@button-background-color-selected);
- background: @button-background-color-selected;
- }
}
&.icons .line-number {
@@ -249,11 +236,6 @@
&.github-FilePatchView-line--nonewline:before {
content: @no-newline;
}
-
- &.github-FilePatchView-line--selected {
- color: contrast(@button-background-color-selected);
- background: @button-background-color-selected;
- }
}
}
@@ -272,6 +254,49 @@
}
}
+
+// States
+
+// Selected
+.github-FilePatchView {
+ .gutter {
+ &.old .line-number,
+ &.new .line-number,
+ &.icons .line-number {
+ &.github-FilePatchView-line--selected {
+ color: @text-color-selected;
+ background: @background-color-selected;
+ }
+ }
+ }
+
+ atom-text-editor {
+ .selection .region {
+ background-color: transparent;
+ }
+ }
+}
+
+// Selected + focused
+.github-FilePatchView:focus-within {
+ .gutter {
+ &.old .line-number,
+ &.new .line-number,
+ &.icons .line-number {
+ &.github-FilePatchView-line--selected {
+ color: contrast(@button-background-color-selected);
+ background: @button-background-color-selected;
+ }
+ }
+ }
+
+ atom-text-editor {
+ .selection .region {
+ background-color: mix(@button-background-color-selected, @syntax-background-color, 24%);
+ }
+ }
+}
+
.gitub-FilePatchHeaderView-basename {
font-weight: bold;
}
diff --git a/styles/hunk-header-view.less b/styles/hunk-header-view.less
index 11cffdec766..5d4bfa61de0 100644
--- a/styles/hunk-header-view.less
+++ b/styles/hunk-header-view.less
@@ -68,9 +68,10 @@
}
}
+// Selected
.github-HunkHeaderView--isSelected {
- color: contrast(@button-background-color-selected);
- background-color: @button-background-color-selected;
+ color: @text-color-selected;
+ background-color: @background-color-selected;
border-color: transparent;
.github-HunkHeaderView-title {
color: inherit;
@@ -78,7 +79,22 @@
.github-HunkHeaderView-title,
.github-HunkHeaderView-stageButton,
.github-HunkHeaderView-discardButton {
- &:hover { background-color: lighten(@button-background-color-selected, 4%); }
- &:active { background-color: darken(@button-background-color-selected, 4%); }
+ &:hover { background-color: @background-color-highlight; }
+ &:active { background-color: @background-color-selected; }
+ }
+}
+
+
+// Selected + focused
+.github-FilePatchView:focus-within {
+ .github-HunkHeaderView--isSelected {
+ color: contrast(@button-background-color-selected);
+ background-color: @button-background-color-selected;
+ .github-HunkHeaderView-title,
+ .github-HunkHeaderView-stageButton,
+ .github-HunkHeaderView-discardButton {
+ &:hover { background-color: lighten(@button-background-color-selected, 4%); }
+ &:active { background-color: darken(@button-background-color-selected, 4%); }
+ }
}
}
diff --git a/styles/recent-commits.less b/styles/recent-commits.less
index 06a3bda3b56..f9ad97ef664 100644
--- a/styles/recent-commits.less
+++ b/styles/recent-commits.less
@@ -87,6 +87,17 @@
color: @text-color-subtle;
}
+ &:hover {
+ color: @text-color-highlight;
+ background: @background-color-highlight;
+ }
+
+ &.is-selected {
+ // is selected
+ color: @text-color-selected;
+ background: @background-color-selected;
+ }
+
}
diff --git a/test/containers/commit-detail-container.test.js b/test/containers/commit-detail-container.test.js
new file mode 100644
index 00000000000..e4dff8718fc
--- /dev/null
+++ b/test/containers/commit-detail-container.test.js
@@ -0,0 +1,74 @@
+import React from 'react';
+import {mount} from 'enzyme';
+
+import CommitDetailContainer from '../../lib/containers/commit-detail-container';
+import CommitDetailItem from '../../lib/items/commit-detail-item';
+import {cloneRepository, buildRepository} from '../helpers';
+
+const VALID_SHA = '18920c900bfa6e4844853e7e246607a31c3e2e8c';
+
+describe('CommitDetailContainer', function() {
+ let atomEnv, repository;
+
+ beforeEach(async function() {
+ atomEnv = global.buildAtomEnvironment();
+
+ const workdir = await cloneRepository('multiple-commits');
+ repository = await buildRepository(workdir);
+ });
+
+ afterEach(function() {
+ atomEnv.destroy();
+ });
+
+ function buildApp(override = {}) {
+
+ const props = {
+ repository,
+ sha: VALID_SHA,
+
+ itemType: CommitDetailItem,
+ workspace: atomEnv.workspace,
+ commands: atomEnv.commands,
+ keymaps: atomEnv.keymaps,
+ tooltips: atomEnv.tooltips,
+ config: atomEnv.config,
+
+ destroy: () => {},
+
+ ...override,
+ };
+
+ return ;
+ }
+
+ it('renders a loading spinner while the repository is loading', function() {
+ const wrapper = mount(buildApp());
+ assert.isTrue(wrapper.find('LoadingView').exists());
+ });
+
+ it('renders a loading spinner while the file patch is being loaded', async function() {
+ await repository.getLoadPromise();
+ const commitPromise = repository.getCommit(VALID_SHA);
+ let resolveDelayedPromise = () => {};
+ const delayedPromise = new Promise(resolve => {
+ resolveDelayedPromise = resolve;
+ });
+ sinon.stub(repository, 'getCommit').returns(delayedPromise);
+
+ const wrapper = mount(buildApp());
+
+ assert.isTrue(wrapper.find('LoadingView').exists());
+ resolveDelayedPromise(commitPromise);
+ await assert.async.isFalse(wrapper.update().find('LoadingView').exists());
+ });
+
+ it('renders a CommitDetailController once the commit is loaded', async function() {
+ await repository.getLoadPromise();
+ const commit = await repository.getCommit(VALID_SHA);
+
+ const wrapper = mount(buildApp());
+ await assert.async.isTrue(wrapper.update().find('CommitDetailController').exists());
+ assert.strictEqual(wrapper.find('CommitDetailController').prop('commit'), commit);
+ });
+});
diff --git a/test/controllers/commit-detail-controller.test.js b/test/controllers/commit-detail-controller.test.js
new file mode 100644
index 00000000000..d045d8b6d5f
--- /dev/null
+++ b/test/controllers/commit-detail-controller.test.js
@@ -0,0 +1,100 @@
+import React from 'react';
+import moment from 'moment';
+import {shallow, mount} from 'enzyme';
+
+import {cloneRepository, buildRepository} from '../helpers';
+import CommitDetailItem from '../../lib/items/commit-detail-item';
+import CommitDetailController from '../../lib/controllers/commit-detail-controller';
+import Commit from '../../lib/models/commit';
+import {multiFilePatchBuilder} from '../builder/patch';
+
+describe('CommitDetailController', function() {
+
+ let atomEnv, repository, commit;
+
+ beforeEach(async function() {
+ atomEnv = global.buildAtomEnvironment();
+ repository = await buildRepository(await cloneRepository('multiple-commits'));
+ commit = await repository.getCommit('18920c900bfa6e4844853e7e246607a31c3e2e8c');
+ });
+
+ afterEach(function() {
+ atomEnv.destroy();
+ });
+
+ function buildApp(override = {}) {
+ const props = {
+ repository,
+ commit,
+ itemType: CommitDetailItem,
+
+ workspace: atomEnv.workspace,
+ commands: atomEnv.commands,
+ keymaps: atomEnv.keymaps,
+ tooltips: atomEnv.tooltips,
+ config: atomEnv.config,
+ destroy: () => {},
+
+ ...override,
+ };
+
+ return ;
+ }
+
+ it('has a MultiFilePatchController that has `disableStageUnstage` flag set to true', function() {
+ const wrapper = mount(buildApp());
+ assert.isTrue(wrapper.find('MultiFilePatchController').exists());
+ assert.isTrue(wrapper.find('MultiFilePatchController').prop('disableStageUnstage'));
+ });
+
+ it('passes unrecognized props to a MultiFilePatchController', function() {
+ const extra = Symbol('extra');
+ const wrapper = shallow(buildApp({extra}));
+
+ assert.strictEqual(wrapper.find('MultiFilePatchController').prop('extra'), extra);
+ });
+
+ it('renders commit details properly', function() {
+ const newCommit = new Commit({
+ sha: '420',
+ authorEmail: 'very@nice.com',
+ authorDate: moment().subtract(2, 'days').unix(),
+ messageSubject: 'subject',
+ messageBody: 'messageBody',
+ });
+ const {multiFilePatch: mfp} = multiFilePatchBuilder().addFilePatch().build();
+ sinon.stub(newCommit, 'getMultiFileDiff').returns(mfp);
+ const wrapper = mount(buildApp({commit: newCommit}));
+
+ assert.strictEqual(wrapper.find('.github-CommitDetailView-title').text(), 'subject');
+ assert.strictEqual(wrapper.find('.github-CommitDetailView-moreText').text(), 'messageBody');
+ assert.strictEqual(wrapper.find('.github-CommitDetailView-metaText').text(), 'very@nice.com committed 2 days ago');
+ assert.strictEqual(wrapper.find('.github-CommitDetailView-sha').text(), '420');
+ /* TODO fix href test */
+ // assert.strictEqual(wrapper.find('.github-CommitDetailView-sha a').prop('href'), '420');
+ assert.strictEqual(wrapper.find('img.github-RecentCommit-avatar').prop('src'), 'https://avatars.githubusercontent.com/u/e?email=very%40nice.com&s=32');
+ });
+
+ it('renders multiple avatars for co-authored commit', function() {
+ const newCommit = new Commit({
+ sha: '420',
+ authorEmail: 'very@nice.com',
+ authorDate: moment().subtract(2, 'days').unix(),
+ messageSubject: 'subject',
+ messageBody: 'messageBody',
+ coAuthors: [{name: 'two', email: 'two@coauthor.com'}, {name: 'three', email: 'three@coauthor.com'}],
+ });
+ const {multiFilePatch: mfp} = multiFilePatchBuilder().addFilePatch().build();
+ sinon.stub(newCommit, 'getMultiFileDiff').returns(mfp);
+ const wrapper = mount(buildApp({commit: newCommit}));
+ assert.deepEqual(
+ wrapper.find('img.github-RecentCommit-avatar').map(w => w.prop('src')),
+ [
+ 'https://avatars.githubusercontent.com/u/e?email=very%40nice.com&s=32',
+ 'https://avatars.githubusercontent.com/u/e?email=two%40coauthor.com&s=32',
+ 'https://avatars.githubusercontent.com/u/e?email=three%40coauthor.com&s=32',
+ ],
+ );
+ });
+
+});
diff --git a/test/git-strategies.test.js b/test/git-strategies.test.js
index 34a24fb290c..574dceadd46 100644
--- a/test/git-strategies.test.js
+++ b/test/git-strategies.test.js
@@ -158,6 +158,36 @@ import * as reporterProxy from '../lib/reporter-proxy';
});
});
+ describe('getDiffsForCommit(sha)', function() {
+ it('returns the diff for the specified commit sha', async function() {
+ const workingDirPath = await cloneRepository('multiple-commits');
+ const git = createTestStrategy(workingDirPath);
+
+ const diffs = await git.getDiffForCommit('18920c90');
+
+ assertDeepPropertyVals(diffs, [{
+ oldPath: 'file.txt',
+ newPath: 'file.txt',
+ oldMode: '100644',
+ newMode: '100644',
+ hunks: [
+ {
+ oldStartLine: 1,
+ oldLineCount: 1,
+ newStartLine: 1,
+ newLineCount: 1,
+ heading: '',
+ lines: [
+ '-one',
+ '+two',
+ ],
+ },
+ ],
+ status: 'modified',
+ }]);
+ });
+ });
+
describe('getCommits()', function() {
describe('when no commits exist in the repository', function() {
it('returns an array with an unborn ref commit when the include unborn option is passed', async function() {
diff --git a/test/items/commit-detail-item.test.js b/test/items/commit-detail-item.test.js
new file mode 100644
index 00000000000..eecbc66cf6a
--- /dev/null
+++ b/test/items/commit-detail-item.test.js
@@ -0,0 +1,168 @@
+import React from 'react';
+import {mount} from 'enzyme';
+
+import CommitDetailItem from '../../lib/items/commit-detail-item';
+import PaneItem from '../../lib/atom/pane-item';
+import WorkdirContextPool from '../../lib/models/workdir-context-pool';
+import {cloneRepository} from '../helpers';
+
+describe('CommitDetailItem', function() {
+ let atomEnv, repository, pool;
+
+ beforeEach(async function() {
+ atomEnv = global.buildAtomEnvironment();
+ const workdir = await cloneRepository('multiple-commits');
+
+ pool = new WorkdirContextPool({
+ workspace: atomEnv.workspace,
+ });
+
+ repository = pool.add(workdir).getRepository();
+ });
+
+ afterEach(function() {
+ atomEnv.destroy();
+ pool.clear();
+ });
+
+ function buildPaneApp(override = {}) {
+ const props = {
+ workdirContextPool: pool,
+ workspace: atomEnv.workspace,
+ commands: atomEnv.commands,
+ keymaps: atomEnv.keymaps,
+ tooltips: atomEnv.tooltips,
+ config: atomEnv.config,
+ discardLines: () => {},
+ ...override,
+ };
+
+ return (
+
+ {({itemHolder, params}) => {
+ return (
+
+ );
+ }}
+
+ );
+ }
+
+ function open(wrapper, options = {}) {
+ const opts = {
+ workingDirectory: repository.getWorkingDirectoryPath(),
+ sha: '18920c900bfa6e4844853e7e246607a31c3e2e8c',
+ ...options,
+ };
+ const uri = CommitDetailItem.buildURI(opts.workingDirectory, opts.sha);
+ return atomEnv.workspace.open(uri);
+ }
+
+ it('constructs and opens the correct URI', async function() {
+ const wrapper = mount(buildPaneApp());
+ await open(wrapper);
+
+ assert.isTrue(wrapper.update().find('CommitDetailItem').exists());
+ });
+
+ it('passes extra props to its container', async function() {
+ const extra = Symbol('extra');
+ const wrapper = mount(buildPaneApp({extra}));
+ await open(wrapper);
+
+ assert.strictEqual(wrapper.update().find('CommitDetailItem').prop('extra'), extra);
+ });
+
+ it('serializes itself as a CommitDetailItem', async function() {
+ const wrapper = mount(buildPaneApp());
+ const item0 = await open(wrapper, {workingDirectory: '/dir0', sha: '420'});
+ assert.deepEqual(item0.serialize(), {
+ deserializer: 'CommitDetailStub',
+ uri: 'atom-github://commit-detail?workdir=%2Fdir0&sha=420',
+ });
+
+ const item1 = await open(wrapper, {workingDirectory: '/dir1', sha: '1337'});
+ assert.deepEqual(item1.serialize(), {
+ deserializer: 'CommitDetailStub',
+ uri: 'atom-github://commit-detail?workdir=%2Fdir1&sha=1337',
+ });
+ });
+
+ it('locates the repository from the context pool', async function() {
+ const wrapper = mount(buildPaneApp());
+ await open(wrapper);
+
+ assert.strictEqual(wrapper.update().find('CommitDetailContainer').prop('repository'), repository);
+ });
+
+ it('passes an absent repository if the working directory is unrecognized', async function() {
+ const wrapper = mount(buildPaneApp());
+ await open(wrapper, {workingDirectory: '/nah'});
+
+ assert.isTrue(wrapper.update().find('CommitDetailContainer').prop('repository').isAbsent());
+ });
+
+ it('returns a fixed title and icon', async function() {
+ const wrapper = mount(buildPaneApp());
+ const item = await open(wrapper, {sha: '1337'});
+
+ assert.strictEqual(item.getTitle(), 'Commit: 1337');
+ assert.strictEqual(item.getIconName(), 'git-commit');
+ });
+
+ it('terminates pending state', async function() {
+ const wrapper = mount(buildPaneApp());
+
+ const item = await open(wrapper);
+ const callback = sinon.spy();
+ const sub = item.onDidTerminatePendingState(callback);
+
+ assert.strictEqual(callback.callCount, 0);
+ item.terminatePendingState();
+ assert.strictEqual(callback.callCount, 1);
+ item.terminatePendingState();
+ assert.strictEqual(callback.callCount, 1);
+
+ sub.dispose();
+ });
+
+ it('may be destroyed once', async function() {
+ const wrapper = mount(buildPaneApp());
+
+ const item = await open(wrapper);
+ const callback = sinon.spy();
+ const sub = item.onDidDestroy(callback);
+
+ assert.strictEqual(callback.callCount, 0);
+ item.destroy();
+ assert.strictEqual(callback.callCount, 1);
+
+ sub.dispose();
+ });
+
+ it('has an item-level accessor for the current working directory & sha', async function() {
+ const wrapper = mount(buildPaneApp());
+ const item = await open(wrapper, {workingDirectory: '/dir7', sha: '420'});
+ assert.strictEqual(item.getWorkingDirectory(), '/dir7');
+ assert.strictEqual(item.getSha(), '420');
+ });
+
+ it('passes a focus() call to the component designated as its initial focus', async function() {
+ const wrapper = mount(buildPaneApp());
+ const item = await open(wrapper);
+ wrapper.update();
+
+ const refHolder = wrapper.find('CommitDetailContainer').prop('refInitialFocus');
+ const initialFocus = await refHolder.getPromise();
+ sinon.spy(initialFocus, 'focus');
+
+ item.focus();
+
+ assert.isTrue(initialFocus.focus.called);
+ });
+});
diff --git a/test/models/repository.test.js b/test/models/repository.test.js
index 834567e9ca8..2eaac205e5f 100644
--- a/test/models/repository.test.js
+++ b/test/models/repository.test.js
@@ -731,6 +731,17 @@ describe('Repository', function() {
});
});
+ describe('getCommit(sha)', function() {
+ it('returns the commit information for the provided sha', async function() {
+ const workingDirPath = await cloneRepository('multiple-commits');
+ const repo = new Repository(workingDirPath);
+ await repo.getLoadPromise();
+
+ console.log(await repo.getCommit('18920c90'));
+ // TODO ...
+ });
+ });
+
describe('undoLastCommit()', function() {
it('performs a soft reset', async function() {
const workingDirPath = await cloneRepository('multiple-commits');