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())} + +
+
+
+ {/* TODO fix href */} + + {commit.getSha()} + +
+
+
+ +
+ ); + } + + 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 ( + {`${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/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.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/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');