diff --git a/keymaps/git.cson b/keymaps/git.cson index 038e4d942c6..f6236b9a07c 100644 --- a/keymaps/git.cson +++ b/keymaps/git.cson @@ -32,6 +32,11 @@ 'tab': 'core:focus-next' 'shift-tab': 'core:focus-previous' +'.github-RecentCommitsView': + 'up': 'github:recent-commit-up' + 'down': 'github:recent-commit-down' + 'enter': 'github:open-recent-commit' + '.github-StagingView.unstaged-changes-focused': 'cmd-backspace': 'github:discard-changes-in-selected-files' 'ctrl-backspace': 'github:discard-changes-in-selected-files' diff --git a/lib/containers/commit-detail-container.js b/lib/containers/commit-detail-container.js new file mode 100644 index 00000000000..275d82ce353 --- /dev/null +++ b/lib/containers/commit-detail-container.js @@ -0,0 +1,44 @@ +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), + currentBranch: repository.getCurrentBranch(), + currentRemote: async query => repository.getRemoteForBranch((await query.currentBranch).getName()), + isCommitPushed: repository.isCommitPushed(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..6e0df0c146c --- /dev/null +++ b/lib/controllers/commit-detail-controller.js @@ -0,0 +1,38 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import CommitDetailView from '../views/commit-detail-view'; + +export default class CommitDetailController extends React.Component { + static propTypes = { + ...CommitDetailView.propTypes, + + commit: PropTypes.object.isRequired, + } + + constructor(props) { + super(props); + + this.state = { + messageCollapsible: this.props.commit.isBodyLong(), + messageOpen: !this.props.commit.isBodyLong(), + }; + } + + render() { + return ( + + ); + } + + toggleMessage = () => { + return new Promise(resolve => { + this.setState(prevState => ({messageOpen: !prevState.messageOpen}), resolve); + }); + } +} diff --git a/lib/controllers/git-tab-controller.js b/lib/controllers/git-tab-controller.js index 57087c7170f..f3322f4c797 100644 --- a/lib/controllers/git-tab-controller.js +++ b/lib/controllers/git-tab-controller.js @@ -11,6 +11,7 @@ import { CommitPropType, BranchPropType, FilePatchItemPropType, MergeConflictItemPropType, RefHolderPropType, } from '../prop-types'; import {autobind} from '../helpers'; +import CommitDetailItem from '../items/commit-detail-item'; export default class GitTabController extends React.Component { static focus = { @@ -277,6 +278,16 @@ export default class GitTabController extends React.Component { new Author(author.email, author.name)); this.updateSelectedCoAuthors(coAuthors); + + // close corresponding commit item pane, if opened + const uri = CommitDetailItem.buildURI(this.props.repository.getWorkingDirectoryPath(), lastCommit.getSha()); + const pane = this.props.workspace.paneForURI(uri); + if (pane) { + const item = pane.itemForURI(uri); + if (item) { + await pane.destroyItem(item); + } + } return null; } diff --git a/lib/controllers/multi-file-patch-controller.js b/lib/controllers/multi-file-patch-controller.js index 09944505c58..cdba88def62 100644 --- a/lib/controllers/multi-file-patch-controller.js +++ b/lib/controllers/multi-file-patch-controller.js @@ -22,9 +22,9 @@ 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, } constructor(props) { diff --git a/lib/controllers/recent-commits-controller.js b/lib/controllers/recent-commits-controller.js index 60ba927619b..e97b079192c 100644 --- a/lib/controllers/recent-commits-controller.js +++ b/lib/controllers/recent-commits-controller.js @@ -1,13 +1,50 @@ 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, + commandRegistry: 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 +53,18 @@ 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} + commandRegistry={this.props.commandRegistry} /> ); } + + 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..ea7bbad6063 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}; @@ -926,6 +934,16 @@ export default class GitShellOutStrategy { }); } + async getBranchesWithCommit(sha, option = {}) { + const args = ['branch', '--format=%(refname)', '--contains', sha]; + if (option.showLocal && option.showRemote) { + args.splice(1, 0, '--all'); + } else if (option.showRemote) { + args.splice(1, 0, '--remotes'); + } + return (await this.exec(args)).trim().split(LINE_ENDING_REGEX); + } + checkoutFiles(paths, revision) { if (paths.length === 0) { return null; } const args = ['checkout']; 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..4f69679c19d 100644 --- a/lib/models/commit.js +++ b/lib/models/commit.js @@ -1,6 +1,15 @@ const UNBORN = Symbol('unborn'); +// Truncation elipsis styles +const WORD_ELIPSES = '...'; +const NEWLINE_ELIPSES = '\n...'; +const PARAGRAPH_ELIPSES = '\n\n...'; + export default class Commit { + static LONG_MESSAGE_THRESHOLD = 500; + + static NEWLINE_THRESHOLD = 9; + static createUnborn() { return new Commit({unbornRef: UNBORN}); } @@ -13,6 +22,7 @@ export default class Commit { this.messageSubject = messageSubject; this.messageBody = messageBody; this.unbornRef = unbornRef === UNBORN; + this.multiFileDiff = null; } getSha() { @@ -39,10 +49,83 @@ export default class Commit { return this.messageBody; } + isBodyLong() { + if (this.getMessageBody().length > this.constructor.LONG_MESSAGE_THRESHOLD) { + return true; + } + + if ((this.getMessageBody().match(/\r?\n/g) || []).length > this.constructor.NEWLINE_THRESHOLD) { + return true; + } + + return false; + } + getFullMessage() { return `${this.getMessageSubject()}\n\n${this.getMessageBody()}`.trim(); } + /* + * Return the messageBody, truncated before the character at LONG_MESSAGE_THRESHOLD. If a paragraph boundary is + * found within BOUNDARY_SEARCH_THRESHOLD characters before that position, the message will be truncated at the + * end of the previous paragraph. If there is no paragraph boundary found, but a word boundary is found within + * that range, the text is truncated at that word boundary and an elipsis (...) is added. If neither are found, + * the text is truncated hard at LONG_MESSAGE_THRESHOLD - 3 characters and an elipsis (...) is added. + */ + abbreviatedBody() { + if (!this.isBodyLong()) { + return this.getMessageBody(); + } + + const {LONG_MESSAGE_THRESHOLD, NEWLINE_THRESHOLD} = this.constructor; + + let lastNewlineCutoff = null; + let lastParagraphCutoff = null; + let lastWordCutoff = null; + + const searchText = this.getMessageBody().substring(0, LONG_MESSAGE_THRESHOLD); + const boundaryRx = /\s+/g; + let result; + let lineCount = 0; + while ((result = boundaryRx.exec(searchText)) !== null) { + const newlineCount = (result[0].match(/\r?\n/g) || []).length; + + lineCount += newlineCount; + if (lineCount > NEWLINE_THRESHOLD) { + lastNewlineCutoff = result.index; + break; + } + + if (newlineCount < 2 && result.index <= LONG_MESSAGE_THRESHOLD - WORD_ELIPSES.length) { + lastWordCutoff = result.index; + } else if (result.index < LONG_MESSAGE_THRESHOLD - PARAGRAPH_ELIPSES.length) { + lastParagraphCutoff = result.index; + } + } + + let elipses = WORD_ELIPSES; + let cutoffIndex = LONG_MESSAGE_THRESHOLD - WORD_ELIPSES.length; + if (lastNewlineCutoff !== null) { + elipses = NEWLINE_ELIPSES; + cutoffIndex = lastNewlineCutoff; + } else if (lastParagraphCutoff !== null) { + elipses = PARAGRAPH_ELIPSES; + cutoffIndex = lastParagraphCutoff; + } else if (lastWordCutoff !== null) { + cutoffIndex = lastWordCutoff; + } + + return this.getMessageBody().substring(0, cutoffIndex) + elipses; + } + + setMultiFileDiff(multiFileDiff) { + this.multiFileDiff = multiFileDiff; + } + + getMultiFileDiff() { + return this.multiFileDiff; + } + isUnbornRef() { return this.unbornRef; } @@ -68,4 +151,8 @@ export const nullCommit = { isPresent() { return false; }, + + isBodyLong() { + return false; + }, }; diff --git a/lib/models/repository-states/present.js b/lib/models/repository-states/present.js index a9f9691a2ab..5e323fc4e25 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}); @@ -730,6 +741,12 @@ export default class Present extends State { }); } + async isCommitPushed(sha) { + const remoteBranchesWithCommit = await this.git().getBranchesWithCommit(sha, {showLocal: false, showRemote: true}); + const currentRemote = (await this.repository.getCurrentBranch()).getUpstream(); + return remoteBranchesWithCommit.includes(currentRemote.getFullRef()); + } + // Author information getAuthors(options) { @@ -1097,7 +1114,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..747b7b3ac54 100644 --- a/lib/models/repository-states/state.js +++ b/lib/models/repository-states/state.js @@ -298,10 +298,18 @@ export default class State { return Promise.resolve(nullCommit); } + getCommit() { + return Promise.resolve(nullCommit); + } + getRecentCommits() { return Promise.resolve([]); } + isCommitPushed(sha) { + return false; + } + // Author information getAuthors() { diff --git a/lib/models/repository.js b/lib/models/repository.js index 9c39d827eb1..e4960aed2af 100644 --- a/lib/models/repository.js +++ b/lib/models/repository.js @@ -5,7 +5,6 @@ import fs from 'fs-extra'; import {getNullActionPipelineManager} from '../action-pipeline'; import CompositeGitStrategy from '../composite-git-strategy'; -import Remote, {nullRemote} from './remote'; import Author, {nullAuthor} from './author'; import Branch from './branch'; import {Loading, Absent, LoadingGuess, AbsentGuess} from './repository-states'; @@ -214,11 +213,7 @@ export default class Repository { async getRemoteForBranch(branchName) { const name = await this.getConfig(`branch.${branchName}.remote`); - if (name === null) { - return nullRemote; - } else { - return new Remote(name); - } + return (await this.getRemotes()).withName(name); } async saveDiscardHistory() { @@ -331,7 +326,9 @@ const delegates = [ 'readFileFromIndex', 'getLastCommit', + 'getCommit', 'getRecentCommits', + 'isCommitPushed', 'getAuthors', diff --git a/lib/views/commit-detail-view.js b/lib/views/commit-detail-view.js new file mode 100644 index 00000000000..e76b70ec22c --- /dev/null +++ b/lib/views/commit-detail-view.js @@ -0,0 +1,141 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import {emojify} from 'node-emoji'; +import moment from 'moment'; + +import MultiFilePatchController from '../controllers/multi-file-patch-controller'; +import CommitDetailItem from '../items/commit-detail-item'; + +export default class CommitDetailView extends React.Component { + static propTypes = { + repository: PropTypes.object.isRequired, + commit: PropTypes.object.isRequired, + itemType: PropTypes.oneOf([CommitDetailItem]).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, + + messageCollapsible: PropTypes.bool.isRequired, + messageOpen: PropTypes.bool.isRequired, + toggleMessage: PropTypes.func.isRequired, + + currentRemote: PropTypes.object.isRequired, + currentBranch: PropTypes.object.isRequired, + isCommitPushed: PropTypes.bool.isRequired, + } + + render() { + const commit = this.props.commit; + + return ( +
+
+
+

+ {emojify(commit.getMessageSubject())} +

+
+ {/* TODO fix image src */} + {this.renderAuthors()} + + {commit.getAuthorEmail()} committed {this.humanizeTimeSince(commit.getAuthorDate())} + +
+ {this.renderDotComLink()} +
+
+ {this.renderShowMoreButton()} + {this.renderCommitMessageBody()} +
+
+ +
+ ); + } + + renderCommitMessageBody() { + const collapsed = this.props.messageCollapsible && !this.props.messageOpen; + + return ( +
+        {collapsed ? this.props.commit.abbreviatedBody() : this.props.commit.getMessageBody()}
+      
+ ); + } + + renderShowMoreButton() { + if (!this.props.messageCollapsible) { + return null; + } + + const buttonText = this.props.messageOpen ? 'Show Less' : 'Show More'; + return ( + + ); + } + + humanizeTimeSince(date) { + return moment(date * 1000).fromNow(); + } + + renderDotComLink() { + const remote = this.props.currentRemote; + const sha = this.props.commit.getSha(); + if (remote && remote.isGithubRepo() && this.props.isCommitPushed) { + const repoUrl = `https://www.github.com/${this.props.currentRemote.getOwner()}/${this.props.currentRemote.getRepo()}`; + return ( + + {sha} + + ); + } else { + return ({sha}); + } + } + + 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 ( + {`${email}'s + ); + } + + 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/views/file-patch-header-view.js b/lib/views/file-patch-header-view.js index 8f7db7d8e2b..51131689f0f 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,7 @@ 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, }; constructor(props) { @@ -72,14 +73,18 @@ export default class FilePatchHeaderView extends React.Component { } renderButtonGroup() { - return ( - - {this.renderUndoDiscardButton()} - {this.renderMirrorPatchButton()} - {this.renderOpenFileButton()} - {this.renderToggleFileButton()} - - ); + if (this.props.itemType === CommitDetailItem) { + return null; + } else { + return ( + + {this.renderUndoDiscardButton()} + {this.renderMirrorPatchButton()} + {this.renderOpenFileButton()} + {this.renderToggleFileButton()} + + ); + } } renderUndoDiscardButton() { diff --git a/lib/views/file-patch-meta-view.js b/lib/views/file-patch-meta-view.js index bbefd913f6d..fadc45edd88 100644 --- a/lib/views/file-patch-meta-view.js +++ b/lib/views/file-patch-meta-view.js @@ -1,6 +1,9 @@ import React from 'react'; import PropTypes from 'prop-types'; import cx from 'classnames'; +import CommitDetailItem from '../items/commit-detail-item'; +import ChangedFileItem from '../items/changed-file-item'; +import CommitPreviewItem from '../items/commit-preview-item'; export default class FilePatchMetaView extends React.Component { static propTypes = { @@ -11,21 +14,32 @@ export default class FilePatchMetaView extends React.Component { action: PropTypes.func.isRequired, children: PropTypes.element.isRequired, + itemType: PropTypes.oneOf([ChangedFileItem, CommitPreviewItem, CommitDetailItem]).isRequired, }; + renderMetaControls() { + console.log(this.props); + if (this.props.itemType === CommitDetailItem) { + return null; + } + return ( +
+ +
+ ); + } + render() { return (

{this.props.title}

-
- -
+ {this.renderMetaControls()}
{this.props.children} diff --git a/lib/views/git-tab-view.js b/lib/views/git-tab-view.js index 9899daca097..322cc3ad97d 100644 --- a/lib/views/git-tab-view.js +++ b/lib/views/git-tab-view.js @@ -7,6 +7,7 @@ import StagingView from './staging-view'; import GitLogo from './git-logo'; import CommitController from '../controllers/commit-controller'; import RecentCommitsController from '../controllers/recent-commits-controller'; +import RecentCommitsView from '../views/recent-commits-view'; import RefHolder from '../models/ref-holder'; import {isValidWorkdir, autobind} from '../helpers'; import {AuthorPropType, UserStorePropType, RefHolderPropType} from '../prop-types'; @@ -16,6 +17,7 @@ export default class GitTabView extends React.Component { ...StagingView.focus, ...CommitController.focus, ...RecentCommitsController.focus, + ...RecentCommitsView.focus, }; static propTypes = { @@ -193,9 +195,12 @@ export default class GitTabView extends React.Component { updateSelectedCoAuthors={this.props.updateSelectedCoAuthors} />
); @@ -289,6 +294,10 @@ export default class GitTabView extends React.Component { this.setFocus(GitTabView.focus.STAGING); } + focusAndSelectRecentCommit() { + this.setFocus(RecentCommitsView.focus.RECENT_COMMIT); + } + focusAndSelectCommitPreviewButton() { this.setFocus(GitTabView.focus.COMMIT_PREVIEW_BUTTON); } diff --git a/lib/views/hunk-header-view.js b/lib/views/hunk-header-view.js index cb72feb36ac..a2ed357015f 100644 --- a/lib/views/hunk-header-view.js +++ b/lib/views/hunk-header-view.js @@ -7,6 +7,9 @@ import {RefHolderPropType} from '../prop-types'; import RefHolder from '../models/ref-holder'; import Tooltip from '../atom/tooltip'; import Keystroke from '../atom/keystroke'; +import ChangedFileItem from '../items/changed-file-item'; +import CommitPreviewItem from '../items/commit-preview-item'; +import CommitDetailItem from '../items/commit-detail-item'; function theBuckStopsHere(event) { event.stopPropagation(); @@ -17,22 +20,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, + itemType: PropTypes.oneOf([ChangedFileItem, CommitPreviewItem, CommitDetailItem]).isRequired, }; constructor(props) { super(props); - autobind(this, 'didMouseDown'); + autobind(this, 'didMouseDown', 'renderButtons'); this.refDiscardButton = new RefHolder(); } @@ -48,32 +52,44 @@ export default class HunkHeaderView extends React.Component { {this.props.hunk.getHeader().trim()} {this.props.hunk.getSectionHeading().trim()} - - {this.props.stagingStatus === 'unstaged' && ( - -
); } + renderButtons() { + if (this.props.itemType === CommitDetailItem) { + return null; + } else { + return ( + + + {this.props.stagingStatus === 'unstaged' && ( + + + +
+ + ); + } + + 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/recent-commits-view.js b/lib/views/recent-commits-view.js index 855fad6515a..9faffd716df 100644 --- a/lib/views/recent-commits-view.js +++ b/lib/views/recent-commits-view.js @@ -4,6 +4,9 @@ import moment from 'moment'; import cx from 'classnames'; import {emojify} from 'node-emoji'; +import Commands, {Command} from '../atom/commands'; +import RefHolder from '../models/ref-holder'; + import Timeago from './timeago'; class RecentCommitView extends React.Component { @@ -11,6 +14,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 +23,12 @@ class RecentCommitView extends React.Component { const fullMessage = this.props.commit.getFullMessage(); return ( -
  • +
  • {this.renderAuthors()} + onClick={this.undoLastCommit}> Undo )} @@ -73,6 +83,11 @@ class RecentCommitView extends React.Component { ); } + + undoLastCommit = event => { + event.stopPropagation(); + this.props.undoLastCommit(); + } } export default class RecentCommitsView extends React.Component { @@ -80,11 +95,39 @@ 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, + commandRegistry: PropTypes.object.isRequired, }; + static focus = { + RECENT_COMMIT: Symbol('recent_commit'), + }; + + constructor(props) { + super(props); + this.refRecentCommits = new RefHolder(); + } + + setFocus(focus) { + return this.refRecentCommits.map(view => view.setFocus(focus)).getOr(false); + } + + rememberFocus(event) { + return this.refRecentCommits.map(view => view.rememberFocus(event)).getOr(null); + } + + selectNextCommit() { + // okay, we should probably move the state of the selected commit into this component + // instead of using the sha, so we can more easily move to next / previous. + } + render() { return (
    + + + {this.renderCommits()}
    ); @@ -115,6 +158,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..9f9bcc47620 --- /dev/null +++ b/styles/commit-detail.less @@ -0,0 +1,94 @@ +@import "variables"; + +@default-padding: @component-padding; +@avatar-dimensions: 16px; + +.github-CommitDetailView { + display: flex; + flex-direction: column; + height: 100%; + + &-header { + flex: 0; + border-bottom: 1px solid @base-border-color; + background-color: @syntax-background-color; + } + + &-commit { + padding: @default-padding*2; + padding-bottom: 0; + } + + &-title { + margin: 0 0 .25em 0; + font-size: 1.4em; + line-height: 1.3; + color: @text-color-highlight; + } + + &-avatar { + border-radius: @component-border-radius; + height: @avatar-dimensions; + margin-right: .4em; + width: @avatar-dimensions; + } + + &-meta { + display: flex; + align-items: center; + margin: @default-padding/2 0 @default-padding*2 0; + } + + &-metaText { + flex: 1; + margin-left: @avatar-dimensions * 1.3; // leave some space for the avatars + line-height: @avatar-dimensions; + color: @text-color-subtle; + } + + &-moreButton { + position: absolute; + left: 50%; + transform: translate(-50%, -50%); + padding: 0em .4em; + color: @text-color-subtle; + font-style: italic; + 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: @default-padding*2 0; + font-size: inherit; + font-family: var(--editor-font-family); + word-wrap: initial; + word-break: break-word; + white-space: pre-wrap; + border-top: 1px solid @base-border-color; + background-color: transparent; + // in the case of loonnng commit message bodies, we want to cap the height so that + // the content beneath will remain visible / scrollable. + max-height: 55vh; + &:empty { + display: none; + } + } + + &-sha { + flex: 0 0 7ch; // Limit to 7 characters + margin-left: @default-padding*2; + line-height: @avatar-dimensions; + color: @text-color-subtle; + font-family: var(--editor-font-family); + white-space: nowrap; + overflow: hidden; + a { + color: @text-color-info; + } + } +} diff --git a/styles/file-patch-view.less b/styles/file-patch-view.less index 3de411746a3..830fadc1e44 100644 --- a/styles/file-patch-view.less +++ b/styles/file-patch-view.less @@ -24,7 +24,7 @@ } .github-FilePatchView-controlBlock { - padding: @component-padding*2 @component-padding @component-padding 0; + padding: @component-padding*4 @component-padding @component-padding 0; background-color: @syntax-background-color; & + .github-FilePatchView-controlBlock { @@ -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/builder/commit.js b/test/builder/commit.js new file mode 100644 index 00000000000..d9dd960bb77 --- /dev/null +++ b/test/builder/commit.js @@ -0,0 +1,75 @@ +import moment from 'moment'; + +import Commit from '../../lib/models/commit'; +import {multiFilePatchBuilder} from './patch'; + +class CommitBuilder { + constructor() { + this._sha = '0123456789abcdefghij0123456789abcdefghij'; + this._authorEmail = 'default@email.com'; + this._authorDate = moment('2018-11-28T12:00:00', moment.ISO_8601).unix(); + this._coAuthors = []; + this._messageSubject = 'subject'; + this._messageBody = 'body'; + + this._multiFileDiff = null; + } + + sha(newSha) { + this._sha = newSha; + return this; + } + + authorEmail(newEmail) { + this._authorEmail = newEmail; + return this; + } + + authorDate(timestamp) { + this._authorDate = timestamp; + return this; + } + + messageSubject(subject) { + this._messageSubject = subject; + return this; + } + + messageBody(body) { + this._messageBody = body; + return this; + } + + setMultiFileDiff(block = () => {}) { + const builder = multiFilePatchBuilder(); + block(builder); + this._multiFileDiff = builder.build().multiFilePatch; + return this; + } + + addCoAuthor(name, email) { + this._coAuthors.push({name, email}); + return this; + } + + build() { + const commit = new Commit({ + sha: this._sha, + authorEmail: this._authorEmail, + authorDate: this._authorDate, + coAuthors: this._coAuthors, + messageSubject: this._messageSubject, + messageBody: this._messageBody, + }); + + if (this._multiFileDiff !== null) { + commit.setMultiFileDiff(this._multiFileDiff); + } + + return commit; + } +} + +export function commitBuilder() { + return new CommitBuilder(); +} 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..2231af47da2 --- /dev/null +++ b/test/controllers/commit-detail-controller.test.js @@ -0,0 +1,107 @@ +import React from 'react'; +import {shallow} from 'enzyme'; +import dedent from 'dedent-js'; + +import {cloneRepository, buildRepository} from '../helpers'; +import CommitDetailItem from '../../lib/items/commit-detail-item'; +import CommitDetailController from '../../lib/controllers/commit-detail-controller'; + +const VALID_SHA = '18920c900bfa6e4844853e7e246607a31c3e2e8c'; + +describe('CommitDetailController', function() { + let atomEnv, repository, commit; + + beforeEach(async function() { + atomEnv = global.buildAtomEnvironment(); + repository = await buildRepository(await cloneRepository('multiple-commits')); + commit = await repository.getCommit(VALID_SHA); + }); + + 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('forwards props to its CommitDetailView', function() { + const wrapper = shallow(buildApp()); + const view = wrapper.find('CommitDetailView'); + + assert.strictEqual(view.prop('repository'), repository); + assert.strictEqual(view.prop('commit'), commit); + assert.strictEqual(view.prop('itemType'), CommitDetailItem); + }); + + it('passes unrecognized props to its CommitDetailView', function() { + const extra = Symbol('extra'); + const wrapper = shallow(buildApp({extra})); + assert.strictEqual(wrapper.find('CommitDetailView').prop('extra'), extra); + }); + + describe('commit body collapsing', function() { + const LONG_MESSAGE = dedent` + Lorem ipsum dolor sit amet, et his justo deleniti, omnium fastidii adversarium at has. Mazim alterum sea ea, + essent malorum persius ne mei. Nam ea tempor qualisque, modus doming te has. Affert dolore albucius te vis, eam + tantas nullam corrumpit ad, in oratio luptatum eleifend vim. + + Ea salutatus contentiones eos. Eam in veniam facete volutpat, solum appetere adversarium ut quo. Vel cu appetere + urbanitas, usu ut aperiri mediocritatem, alia molestie urbanitas cu qui. Velit antiopam erroribus no eum, scripta + iudicabit ne nam, in duis clita commodo sit. + + Assum sensibus oportere te vel, vis semper evertitur definiebas in. Tamquam feugiat comprehensam ut his, et eum + voluptua ullamcorper, ex mei debitis inciderint. Sit discere pertinax te, an mei liber putant. Ad doctus tractatos + ius, duo ad civibus alienum, nominati voluptaria sed an. Libris essent philosophia et vix. Nusquam reprehendunt et + mea. Ea eius omnes voluptua sit. + + No cum illud verear efficiantur. Id altera imperdiet nec. Noster audiam accusamus mei at, no zril libris nemore + duo, ius ne rebum doctus fuisset. Legimus epicurei in sit, esse purto suscipit eu qui, oporteat deserunt + delicatissimi sea in. Est id putent accusata convenire, no tibique molestie accommodare quo, cu est fuisset + offendit evertitur. + `; + + it('is uncollapsible if the commit message is short', function() { + sinon.stub(commit, 'getMessageBody').returns('short'); + const wrapper = shallow(buildApp()); + const view = wrapper.find('CommitDetailView'); + assert.isFalse(view.prop('messageCollapsible')); + assert.isTrue(view.prop('messageOpen')); + }); + + it('is collapsible and begins collapsed if the commit message is long', function() { + sinon.stub(commit, 'getMessageBody').returns(LONG_MESSAGE); + + const wrapper = shallow(buildApp()); + const view = wrapper.find('CommitDetailView'); + assert.isTrue(view.prop('messageCollapsible')); + assert.isFalse(view.prop('messageOpen')); + }); + + it('toggles collapsed state', async function() { + sinon.stub(commit, 'getMessageBody').returns(LONG_MESSAGE); + + const wrapper = shallow(buildApp()); + assert.isFalse(wrapper.find('CommitDetailView').prop('messageOpen')); + + await wrapper.find('CommitDetailView').prop('toggleMessage')(); + + assert.isTrue(wrapper.find('CommitDetailView').prop('messageOpen')); + }); + }); +}); 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/commit.test.js b/test/models/commit.test.js new file mode 100644 index 00000000000..4b4d6aa08d8 --- /dev/null +++ b/test/models/commit.test.js @@ -0,0 +1,151 @@ +import dedent from 'dedent-js'; + +import {nullCommit} from '../../lib/models/commit'; +import {commitBuilder} from '../builder/commit'; + +describe('Commit', function() { + describe('isBodyLong()', function() { + it('returns false if the commit message body is short', function() { + const commit = commitBuilder().messageBody('short').build(); + assert.isFalse(commit.isBodyLong()); + }); + + it('returns true if the commit message body is long', function() { + const messageBody = dedent` + Lorem ipsum dolor sit amet, et his justo deleniti, omnium fastidii adversarium at has. Mazim alterum sea ea, + essent malorum persius ne mei. Nam ea tempor qualisque, modus doming te has. Affert dolore albucius te vis, eam + tantas nullam corrumpit ad, in oratio luptatum eleifend vim. + + Ea salutatus contentiones eos. Eam in veniam facete volutpat, solum appetere adversarium ut quo. Vel cu appetere + urbanitas, usu ut aperiri mediocritatem, alia molestie urbanitas cu qui. Velit antiopam erroribus no eum, + scripta iudicabit ne nam, in duis clita commodo sit. + + Assum sensibus oportere te vel, vis semper evertitur definiebas in. Tamquam feugiat comprehensam ut his, et eum + voluptua ullamcorper, ex mei debitis inciderint. Sit discere pertinax te, an mei liber putant. Ad doctus + tractatos ius, duo ad civibus alienum, nominati voluptaria sed an. Libris essent philosophia et vix. Nusquam + reprehendunt et mea. Ea eius omnes voluptua sit. + + No cum illud verear efficiantur. Id altera imperdiet nec. Noster audiam accusamus mei at, no zril libris nemore + duo, ius ne rebum doctus fuisset. Legimus epicurei in sit, esse purto suscipit eu qui, oporteat deserunt + delicatissimi sea in. Est id putent accusata convenire, no tibique molestie accommodare quo, cu est fuisset + offendit evertitur. + `; + const commit = commitBuilder().messageBody(messageBody).build(); + assert.isTrue(commit.isBodyLong()); + }); + + it('returns true if the commit message body contains too many newlines', function() { + let messageBody = 'a\n'; + for (let i = 0; i < 50; i++) { + messageBody += 'a\n'; + } + const commit = commitBuilder().messageBody(messageBody).build(); + assert.isTrue(commit.isBodyLong()); + }); + + it('returns false for a null commit', function() { + assert.isFalse(nullCommit.isBodyLong()); + }); + }); + + describe('abbreviatedBody()', function() { + it('returns the message body as-is when the body is short', function() { + const commit = commitBuilder().messageBody('short').build(); + assert.strictEqual(commit.abbreviatedBody(), 'short'); + }); + + it('truncates the message body at the last paragraph boundary before the cutoff if one is present', function() { + const body = dedent` + Lorem ipsum dolor sit amet, et his justo deleniti, omnium fastidii adversarium at has. Mazim alterum sea ea, + essent malorum persius ne mei. + + Nam ea tempor qualisque, modus doming te has. Affert dolore albucius te vis, eam + tantas nullam corrumpit ad, in oratio luptatum eleifend vim. Ea salutatus contentiones eos. Eam in veniam facete + volutpat, solum appetere adversarium ut quo. Vel cu appetere urbanitas, usu ut aperiri mediocritatem, alia + molestie urbanitas cu qui. + + Velit antiopam erroribus no eu|m, scripta iudicabit ne nam, in duis clita commodo + sit. Assum sensibus oportere te vel, vis semper evertitur definiebas in. Tamquam feugiat comprehensam ut his, et + eum voluptua ullamcorper, ex mei debitis inciderint. Sit discere pertinax te, an mei liber putant. Ad doctus + tractatos ius, duo ad civibus alienum, nominati voluptaria sed an. Libris essent philosophia et vix. Nusquam + reprehendunt et mea. Ea eius omnes voluptua sit. + `; + + const commit = commitBuilder().messageBody(body).build(); + assert.strictEqual(commit.abbreviatedBody(), dedent` + Lorem ipsum dolor sit amet, et his justo deleniti, omnium fastidii adversarium at has. Mazim alterum sea ea, + essent malorum persius ne mei. + + Nam ea tempor qualisque, modus doming te has. Affert dolore albucius te vis, eam + tantas nullam corrumpit ad, in oratio luptatum eleifend vim. Ea salutatus contentiones eos. Eam in veniam facete + volutpat, solum appetere adversarium ut quo. Vel cu appetere urbanitas, usu ut aperiri mediocritatem, alia + molestie urbanitas cu qui. + + ... + `); + }); + + it('truncates the message body at the nearest word boundary before the cutoff if one is present', function() { + // The | is at the 500-character mark. + const body = dedent` + Lorem ipsum dolor sit amet, et his justo deleniti, omnium fastidii adversarium at has. Mazim alterum sea ea, + essent malorum persius ne mei. Nam ea tempor qualisque, modus doming te has. Affert dolore albucius te vis, eam + tantas nullam corrumpit ad, in oratio luptatum eleifend vim. Ea salutatus contentiones eos. Eam in veniam facete + volutpat, solum appetere adversarium ut quo. Vel cu appetere urbanitas, usu ut aperiri mediocritatem, alia + molestie urbanitas cu qui. Velit antiopam erroribus no eum,| scripta iudicabit ne nam, in duis clita commodo + sit. Assum sensibus oportere te vel, vis semper evertitur definiebas in. Tamquam feugiat comprehensam ut his, et + eum voluptua ullamcorper, ex mei debitis inciderint. Sit discere pertinax te, an mei liber putant. Ad doctus + tractatos ius, duo ad civibus alienum, nominati voluptaria sed an. Libris essent philosophia et vix. Nusquam + reprehendunt et mea. Ea eius omnes voluptua sit. No cum illud verear efficiantur. Id altera imperdiet nec. + Noster audia|m accusamus mei at, no zril libris nemore duo, ius ne rebum doctus fuisset. Legimus epicurei in + sit, esse purto suscipit eu qui, oporteat deserunt delicatissimi sea in. Est id putent accusata convenire, no + tibique molestie accommodare quo, cu est fuisset offendit evertitur. + `; + + const commit = commitBuilder().messageBody(body).build(); + assert.strictEqual(commit.abbreviatedBody(), dedent` + Lorem ipsum dolor sit amet, et his justo deleniti, omnium fastidii adversarium at has. Mazim alterum sea ea, + essent malorum persius ne mei. Nam ea tempor qualisque, modus doming te has. Affert dolore albucius te vis, eam + tantas nullam corrumpit ad, in oratio luptatum eleifend vim. Ea salutatus contentiones eos. Eam in veniam facete + volutpat, solum appetere adversarium ut quo. Vel cu appetere urbanitas, usu ut aperiri mediocritatem, alia + molestie urbanitas cu qui. Velit antiopam erroribus no... + `); + }); + + it('truncates the message body at the character cutoff if no word or paragraph boundaries can be found', function() { + // The | is at the 500-character mark. + const body = 'Loremipsumdolorsitamet,ethisjustodeleniti,omniumfastidiiadversariumathas.' + + 'Mazimalterumseaea,essentmalorumpersiusnemei.Nameatemporqualisque,modusdomingtehas.Affertdolore' + + 'albuciustevis,eamtantasnullamcorrumpitad,inoratioluptatumeleifendvim.Easalutatuscontentioneseos.' + + 'Eaminveniamfacetevolutpat,solumappetereadversariumutquo.Velcuappetereurbanitas,usuutaperiri' + + 'mediocritatem,aliamolestieurbanitascuqui.Velitantiopamerroribusnoeum,scriptaiudicabitnenam,in' + + 'duisclitacommodosit.Assumsensibusoporteretevel,vissem|perevertiturdefiniebasin.Tamquamfeugiat' + + 'comprehensamuthis,eteumvoluptuaullamcorper,exmeidebitisinciderint.Sitdiscerepertinaxte,anmei' + + 'liberputant.Addoctustractatosius,duoadcivibusalienum,nominativoluptariasedan.Librisessent' + + 'philosophiaetvix.Nusquamreprehenduntetmea.Eaeiusomnesvoluptuasit.Nocumilludverearefficiantur.Id' + + 'alteraimperdietnec.Nosteraudiamaccusamusmeiat,nozrillibrisnemoreduo,iusnerebumdoctusfuisset.' + + 'Legimusepicureiinsit,essepurtosuscipiteuqui,oporteatdeseruntdelicatissimiseain.Estidputent' + + 'accusataconvenire,notibiquemolestieaccommodarequo,cuestfuissetoffenditevertitur.'; + + const commit = commitBuilder().messageBody(body).build(); + assert.strictEqual( + commit.abbreviatedBody(), + 'Loremipsumdolorsitamet,ethisjustodeleniti,omniumfastidiiadversariumathas.' + + 'Mazimalterumseaea,essentmalorumpersiusnemei.Nameatemporqualisque,modusdomingtehas.Affertdolore' + + 'albuciustevis,eamtantasnullamcorrumpitad,inoratioluptatumeleifendvim.Easalutatuscontentioneseos.' + + 'Eaminveniamfacetevolutpat,solumappetereadversariumutquo.Velcuappetereurbanitas,usuutaperiri' + + 'mediocritatem,aliamolestieurbanitascuqui.Velitantiopamerroribusnoeum,scriptaiudicabitnenam,in' + + 'duisclitacommodosit.Assumsensibusoporteretevel,vis...', + ); + }); + + it('truncates the message body when it contains too many newlines', function() { + let messageBody = ''; + for (let i = 0; i < 50; i++) { + messageBody += `${i}\n`; + } + const commit = commitBuilder().messageBody(messageBody).build(); + assert.strictEqual(commit.abbreviatedBody(), '0\n1\n2\n3\n4\n5\n6\n7\n8\n9\n...'); + }); + }); +}); diff --git a/test/models/repository.test.js b/test/models/repository.test.js index 834567e9ca8..99a3ec27467 100644 --- a/test/models/repository.test.js +++ b/test/models/repository.test.js @@ -64,7 +64,7 @@ describe('Repository', function() { for (const method of [ 'isLoadingGuess', 'isAbsentGuess', 'isAbsent', 'isLoading', 'isEmpty', 'isPresent', 'isTooLarge', 'isUndetermined', 'showGitTabInit', 'showGitTabInitInProgress', 'showGitTabLoading', 'showStatusBarTiles', - 'hasDiscardHistory', 'isMerging', 'isRebasing', + 'hasDiscardHistory', 'isMerging', 'isRebasing', 'isCommitPushed', ]) { assert.isFalse(await repository[method]()); } @@ -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'); diff --git a/test/views/commit-detail-view.test.js b/test/views/commit-detail-view.test.js new file mode 100644 index 00000000000..acca9a093d8 --- /dev/null +++ b/test/views/commit-detail-view.test.js @@ -0,0 +1,174 @@ +import React from 'react'; +import {shallow} from 'enzyme'; +import moment from 'moment'; +import dedent from 'dedent-js'; + +import CommitDetailView from '../../lib/views/commit-detail-view'; +import CommitDetailItem from '../../lib/items/commit-detail-item'; +import Commit from '../../lib/models/commit'; +import {cloneRepository, buildRepository} from '../helpers'; +import {commitBuilder} from '../builder/commit'; + +describe('CommitDetailView', function() { + let repository, atomEnv; + + beforeEach(async function() { + atomEnv = global.buildAtomEnvironment(); + repository = await buildRepository(await cloneRepository('multiple-commits')); + }); + + afterEach(function() { + atomEnv.destroy(); + }); + + function buildApp(override = {}) { + const props = { + repository, + commit: commitBuilder().build(), + messageCollapsible: false, + messageOpen: true, + itemType: CommitDetailItem, + + workspace: atomEnv.workspace, + commands: atomEnv.commands, + keymaps: atomEnv.keymaps, + tooltips: atomEnv.tooltips, + config: atomEnv.config, + + destroy: () => {}, + toggleMessage: () => {}, + ...override, + }; + + return ; + } + + it('has a MultiFilePatchController that its itemType set', function() { + const wrapper = shallow(buildApp({itemType: CommitDetailItem})); + assert.strictEqual(wrapper.find('MultiFilePatchController').prop('itemType'), CommitDetailItem); + }); + + 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 commit = commitBuilder() + .sha('420') + .authorEmail('very@nice.com') + .authorDate(moment().subtract(2, 'days').unix()) + .messageSubject('subject') + .messageBody('body') + .setMultiFileDiff() + .build(); + const wrapper = shallow(buildApp({commit})); + + assert.strictEqual(wrapper.find('.github-CommitDetailView-title').text(), 'subject'); + assert.strictEqual(wrapper.find('.github-CommitDetailView-moreText').text(), 'body'); + assert.strictEqual(wrapper.find('.github-CommitDetailView-metaText').text(), 'very@nice.com committed 2 days ago'); + assert.strictEqual(wrapper.find('.github-CommitDetailView-sha').text(), '420'); + // 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 commit = commitBuilder() + .authorEmail('blaze@it.com') + .addCoAuthor('two', 'two@coauthor.com') + .addCoAuthor('three', 'three@coauthor.com') + .build(); + const wrapper = shallow(buildApp({commit})); + assert.deepEqual( + wrapper.find('img.github-RecentCommit-avatar').map(w => w.prop('src')), + [ + 'https://avatars.githubusercontent.com/u/e?email=blaze%40it.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', + ], + ); + }); + + describe('commit message collapsibility', function() { + let wrapper, shortMessage, longMessage; + + beforeEach(function() { + shortMessage = dedent` + if every pork chop was perfect... + + we wouldn't have hot dogs! + 🌭🌭🌭🌭🌭🌭🌭 + `; + + longMessage = 'this message is really really really\n'; + while (longMessage.length < Commit.LONG_MESSAGE_THRESHOLD) { + longMessage += 'really really really really really really\n'; + } + longMessage += 'really really long.'; + }); + + describe('when messageCollapsible is false', function() { + beforeEach(function() { + const commit = commitBuilder().messageBody(shortMessage).build(); + wrapper = shallow(buildApp({commit, messageCollapsible: false})); + }); + + it('renders the full message body', function() { + assert.strictEqual(wrapper.find('.github-CommitDetailView-moreText').text(), shortMessage); + }); + + it('does not render a button', function() { + assert.isFalse(wrapper.find('.github-CommitDetailView-moreButton').exists()); + }); + }); + + describe('when messageCollapsible is true and messageOpen is false', function() { + beforeEach(function() { + const commit = commitBuilder().messageBody(longMessage).build(); + wrapper = shallow(buildApp({commit, messageCollapsible: true, messageOpen: false})); + }); + + it('renders an abbreviated commit message', function() { + const messageText = wrapper.find('.github-CommitDetailView-moreText').text(); + assert.notStrictEqual(messageText, longMessage); + assert.isAtMost(messageText.length, Commit.LONG_MESSAGE_THRESHOLD); + }); + + it('renders a button to reveal the rest of the message', function() { + const button = wrapper.find('.github-CommitDetailView-moreButton'); + assert.lengthOf(button, 1); + assert.strictEqual(button.text(), 'Show More'); + }); + }); + + describe('when messageCollapsible is true and messageOpen is true', function() { + let toggleMessage; + + beforeEach(function() { + toggleMessage = sinon.spy(); + const commit = commitBuilder().messageBody(longMessage).build(); + wrapper = shallow(buildApp({commit, messageCollapsible: true, messageOpen: true, toggleMessage})); + }); + + it('renders the full message', function() { + assert.strictEqual(wrapper.find('.github-CommitDetailView-moreText').text(), longMessage); + }); + + it('renders a button to collapse the message text', function() { + const button = wrapper.find('.github-CommitDetailView-moreButton'); + assert.lengthOf(button, 1); + assert.strictEqual(button.text(), 'Show Less'); + }); + + it('the button calls toggleMessage when clicked', function() { + const button = wrapper.find('.github-CommitDetailView-moreButton'); + button.simulate('click'); + assert.isTrue(toggleMessage.called); + }); + }); + }); +}); diff --git a/test/views/file-patch-header-view.test.js b/test/views/file-patch-header-view.test.js index 2d4e0acb651..cf778723cb9 100644 --- a/test/views/file-patch-header-view.test.js +++ b/test/views/file-patch-header-view.test.js @@ -5,6 +5,7 @@ import path from 'path'; import FilePatchHeaderView from '../../lib/views/file-patch-header-view'; import ChangedFileItem from '../../lib/items/changed-file-item'; import CommitPreviewItem from '../../lib/items/commit-preview-item'; +import CommitDetailItem from '../../lib/items/commit-detail-item'; describe('FilePatchHeaderView', function() { const relPath = path.join('dir', 'a.txt'); @@ -178,5 +179,10 @@ describe('FilePatchHeaderView', function() { buttonClass: 'icon-move-up', oppositeButtonClass: 'icon-move-down', })); + + it('does not render buttons when in a CommitDetailItem', function() { + const wrapper = shallow(buildApp({itemType: CommitDetailItem})); + assert.isFalse(wrapper.find('.btn-group').exists()); + }); }); }); diff --git a/test/views/git-tab-view.test.js b/test/views/git-tab-view.test.js index c21f152e91c..5164c65ecb3 100644 --- a/test/views/git-tab-view.test.js +++ b/test/views/git-tab-view.test.js @@ -234,4 +234,13 @@ describe('GitTabView', function() { assert.isTrue(setFocus.called); assert.isTrue(setFocus.lastCall.returnValue); }); + + it('imperatively focuses the recent commits view', async function() { + const wrapper = mount(await buildApp()); + + const setFocus = sinon.spy(wrapper.find('RecentCommitsView').instance(), 'setFocus'); + wrapper.instance().focusAndSelectRecentCommit(); + assert.isTrue(setFocus.called); + assert.isTrue(setFocus.lastCall.returnValue); + }); }); diff --git a/test/views/hunk-header-view.test.js b/test/views/hunk-header-view.test.js index ae1b80fefe6..78c263ae813 100644 --- a/test/views/hunk-header-view.test.js +++ b/test/views/hunk-header-view.test.js @@ -4,6 +4,8 @@ import {shallow} from 'enzyme'; import HunkHeaderView from '../../lib/views/hunk-header-view'; import RefHolder from '../../lib/models/ref-holder'; import Hunk from '../../lib/models/patch/hunk'; +import CommitDetailItem from '../../lib/items/commit-detail-item'; +import CommitPreviewItem from '../../lib/items/commit-preview-item'; describe('HunkHeaderView', function() { let atomEnv, hunk; @@ -117,4 +119,14 @@ describe('HunkHeaderView', function() { assert.isFalse(mouseDown.called); assert.isTrue(evt.stopPropagation.called); }); + + it('does not render extra buttons when in a CommitPreviewItem or a CommitDetailItem', function() { + let wrapper = shallow(buildApp({itemType: CommitPreviewItem})); + assert.isFalse(wrapper.find('.github-HunkHeaderView-stageButton').exists()); + assert.isFalse(wrapper.find('.github-HunkHeaderView-discardButton').exists()); + + wrapper = shallow(buildApp({itemType: CommitDetailItem})); + assert.isFalse(wrapper.find('.github-HunkHeaderView-stageButton').exists()); + assert.isFalse(wrapper.find('.github-HunkHeaderView-discardButton').exists()); + }) });