diff --git a/keymaps/git.cson b/keymaps/git.cson index 038e4d942c6..d7b6d2c1d82 100644 --- a/keymaps/git.cson +++ b/keymaps/git.cson @@ -39,6 +39,7 @@ '.github-CommitView-editor atom-text-editor:not([mini])': 'cmd-enter': 'github:commit' 'ctrl-enter': 'github:commit' + 'tab': 'core:focus-next' 'shift-tab': 'core:focus-previous' '.github-CommitView-commitPreview': @@ -46,6 +47,15 @@ 'ctrl-left': 'github:dive' 'enter': 'native!' +'.github-RecentCommits': + 'enter': 'github:dive' + 'cmd-left': 'github:dive' + 'ctrl-left': 'github:dive' + +'.github-CommitDetailView': + 'cmd-right': 'github:surface' + 'ctrl-right': 'github:surface' + '.github-FilePatchView atom-text-editor:not([mini])': 'cmd-/': 'github:toggle-patch-selection-mode' 'ctrl-/': 'github:toggle-patch-selection-mode' diff --git a/lib/containers/__generated__/issueishDetailContainerQuery.graphql.js b/lib/containers/__generated__/issueishDetailContainerQuery.graphql.js index 73d262cf8c5..8e537f3d564 100644 --- a/lib/containers/__generated__/issueishDetailContainerQuery.graphql.js +++ b/lib/containers/__generated__/issueishDetailContainerQuery.graphql.js @@ -1,6 +1,6 @@ /** * @flow - * @relayHash fe501fb51956752e2d45ff061c36622e + * @relayHash d760438e6f0650bc4ba45ae430d56116 */ /* eslint-disable */ @@ -476,7 +476,8 @@ fragment prCommitView_item on Commit { } messageHeadline messageBody - abbreviatedOid + shortSha: abbreviatedOid + sha: oid url } */ @@ -997,7 +998,7 @@ return { "operationKind": "query", "name": "issueishDetailContainerQuery", "id": null, - "text": "query issueishDetailContainerQuery(\n $repoOwner: String!\n $repoName: String!\n $issueishNumber: Int!\n $timelineCount: Int!\n $timelineCursor: String\n $commitCount: Int!\n $commitCursor: String\n) {\n repository(owner: $repoOwner, name: $repoName) {\n ...issueishDetailController_repository_1mXVvq\n id\n }\n}\n\nfragment issueishDetailController_repository_1mXVvq on Repository {\n ...issueishDetailView_repository\n name\n owner {\n __typename\n login\n id\n }\n issueish: issueOrPullRequest(number: $issueishNumber) {\n __typename\n ... on Issue {\n title\n number\n ...issueishDetailView_issueish_4cAEh0\n }\n ... on PullRequest {\n title\n number\n headRefName\n headRepository {\n name\n owner {\n __typename\n login\n id\n }\n url\n sshUrl\n id\n }\n ...issueishDetailView_issueish_4cAEh0\n }\n ... on Node {\n id\n }\n }\n}\n\nfragment issueishDetailView_repository on Repository {\n id\n name\n owner {\n __typename\n login\n id\n }\n}\n\nfragment issueishDetailView_issueish_4cAEh0 on IssueOrPullRequest {\n __typename\n ... on Node {\n id\n }\n ... on Issue {\n state\n number\n title\n bodyHTML\n author {\n __typename\n login\n avatarUrl\n ... on User {\n url\n }\n ... on Bot {\n url\n }\n ... on Node {\n id\n }\n }\n ...issueTimelineController_issue_3D8CP9\n }\n ... on PullRequest {\n isCrossRepository\n changedFiles\n ...prCommitsView_pullRequest_38TpXw\n countedCommits: commits {\n totalCount\n }\n ...prStatusesView_pullRequest\n state\n number\n title\n bodyHTML\n baseRefName\n headRefName\n author {\n __typename\n login\n avatarUrl\n ... on User {\n url\n }\n ... on Bot {\n url\n }\n ... on Node {\n id\n }\n }\n ...prTimelineController_pullRequest_3D8CP9\n }\n ... on UniformResourceLocatable {\n url\n }\n ... on Reactable {\n reactionGroups {\n content\n users {\n totalCount\n }\n }\n }\n}\n\nfragment issueTimelineController_issue_3D8CP9 on Issue {\n url\n timeline(first: $timelineCount, after: $timelineCursor) {\n pageInfo {\n endCursor\n hasNextPage\n }\n edges {\n cursor\n node {\n __typename\n ...commitsView_nodes\n ...issueCommentView_item\n ...crossReferencedEventsView_nodes\n ... on Node {\n id\n }\n }\n }\n }\n}\n\nfragment prCommitsView_pullRequest_38TpXw on PullRequest {\n url\n commits(first: $commitCount, after: $commitCursor) {\n pageInfo {\n endCursor\n hasNextPage\n }\n edges {\n cursor\n node {\n commit {\n id\n ...prCommitView_item\n }\n id\n __typename\n }\n }\n }\n}\n\nfragment prStatusesView_pullRequest on PullRequest {\n id\n recentCommits: commits(last: 1) {\n edges {\n node {\n commit {\n status {\n state\n contexts {\n id\n state\n ...prStatusContextView_context\n }\n id\n }\n id\n }\n id\n }\n }\n }\n}\n\nfragment prTimelineController_pullRequest_3D8CP9 on PullRequest {\n url\n ...headRefForcePushedEventView_issueish\n timeline(first: $timelineCount, after: $timelineCursor) {\n pageInfo {\n endCursor\n hasNextPage\n }\n edges {\n cursor\n node {\n __typename\n ...commitsView_nodes\n ...issueCommentView_item\n ...mergedEventView_item\n ...headRefForcePushedEventView_item\n ...commitCommentThreadView_item\n ...crossReferencedEventsView_nodes\n ... on Node {\n id\n }\n }\n }\n }\n}\n\nfragment headRefForcePushedEventView_issueish on PullRequest {\n headRefName\n headRepositoryOwner {\n __typename\n login\n id\n }\n repository {\n owner {\n __typename\n login\n id\n }\n id\n }\n}\n\nfragment commitsView_nodes on Commit {\n id\n author {\n name\n user {\n login\n id\n }\n }\n ...commitView_item\n}\n\nfragment issueCommentView_item on IssueComment {\n author {\n __typename\n avatarUrl\n login\n ... on Node {\n id\n }\n }\n bodyHTML\n createdAt\n url\n}\n\nfragment mergedEventView_item on MergedEvent {\n actor {\n __typename\n avatarUrl\n login\n ... on Node {\n id\n }\n }\n commit {\n oid\n id\n }\n mergeRefName\n createdAt\n}\n\nfragment headRefForcePushedEventView_item on HeadRefForcePushedEvent {\n actor {\n __typename\n avatarUrl\n login\n ... on Node {\n id\n }\n }\n beforeCommit {\n oid\n id\n }\n afterCommit {\n oid\n id\n }\n createdAt\n}\n\nfragment commitCommentThreadView_item on CommitCommentThread {\n commit {\n oid\n id\n }\n comments(first: 100) {\n edges {\n node {\n id\n ...commitCommentView_item\n }\n }\n }\n}\n\nfragment crossReferencedEventsView_nodes on CrossReferencedEvent {\n id\n referencedAt\n isCrossRepository\n actor {\n __typename\n login\n avatarUrl\n ... on Node {\n id\n }\n }\n source {\n __typename\n ... on RepositoryNode {\n repository {\n name\n owner {\n __typename\n login\n id\n }\n id\n }\n }\n ... on Node {\n id\n }\n }\n ...crossReferencedEventView_item\n}\n\nfragment crossReferencedEventView_item on CrossReferencedEvent {\n id\n isCrossRepository\n source {\n __typename\n ... on Issue {\n number\n title\n url\n issueState: state\n }\n ... on PullRequest {\n number\n title\n url\n prState: state\n }\n ... on RepositoryNode {\n repository {\n name\n isPrivate\n owner {\n __typename\n login\n id\n }\n id\n }\n }\n ... on Node {\n id\n }\n }\n}\n\nfragment commitCommentView_item on CommitComment {\n author {\n __typename\n login\n avatarUrl\n ... on Node {\n id\n }\n }\n commit {\n oid\n id\n }\n bodyHTML\n createdAt\n path\n position\n}\n\nfragment commitView_item on Commit {\n author {\n name\n avatarUrl\n user {\n login\n id\n }\n }\n committer {\n name\n avatarUrl\n user {\n login\n id\n }\n }\n authoredByCommitter\n oid\n message\n messageHeadlineHTML\n commitUrl\n}\n\nfragment prStatusContextView_context on StatusContext {\n context\n description\n state\n targetUrl\n}\n\nfragment prCommitView_item on Commit {\n committer {\n avatarUrl\n name\n date\n }\n messageHeadline\n messageBody\n abbreviatedOid\n url\n}\n", + "text": "query issueishDetailContainerQuery(\n $repoOwner: String!\n $repoName: String!\n $issueishNumber: Int!\n $timelineCount: Int!\n $timelineCursor: String\n $commitCount: Int!\n $commitCursor: String\n) {\n repository(owner: $repoOwner, name: $repoName) {\n ...issueishDetailController_repository_1mXVvq\n id\n }\n}\n\nfragment issueishDetailController_repository_1mXVvq on Repository {\n ...issueishDetailView_repository\n name\n owner {\n __typename\n login\n id\n }\n issueish: issueOrPullRequest(number: $issueishNumber) {\n __typename\n ... on Issue {\n title\n number\n ...issueishDetailView_issueish_4cAEh0\n }\n ... on PullRequest {\n title\n number\n headRefName\n headRepository {\n name\n owner {\n __typename\n login\n id\n }\n url\n sshUrl\n id\n }\n ...issueishDetailView_issueish_4cAEh0\n }\n ... on Node {\n id\n }\n }\n}\n\nfragment issueishDetailView_repository on Repository {\n id\n name\n owner {\n __typename\n login\n id\n }\n}\n\nfragment issueishDetailView_issueish_4cAEh0 on IssueOrPullRequest {\n __typename\n ... on Node {\n id\n }\n ... on Issue {\n state\n number\n title\n bodyHTML\n author {\n __typename\n login\n avatarUrl\n ... on User {\n url\n }\n ... on Bot {\n url\n }\n ... on Node {\n id\n }\n }\n ...issueTimelineController_issue_3D8CP9\n }\n ... on PullRequest {\n isCrossRepository\n changedFiles\n ...prCommitsView_pullRequest_38TpXw\n countedCommits: commits {\n totalCount\n }\n ...prStatusesView_pullRequest\n state\n number\n title\n bodyHTML\n baseRefName\n headRefName\n author {\n __typename\n login\n avatarUrl\n ... on User {\n url\n }\n ... on Bot {\n url\n }\n ... on Node {\n id\n }\n }\n ...prTimelineController_pullRequest_3D8CP9\n }\n ... on UniformResourceLocatable {\n url\n }\n ... on Reactable {\n reactionGroups {\n content\n users {\n totalCount\n }\n }\n }\n}\n\nfragment issueTimelineController_issue_3D8CP9 on Issue {\n url\n timeline(first: $timelineCount, after: $timelineCursor) {\n pageInfo {\n endCursor\n hasNextPage\n }\n edges {\n cursor\n node {\n __typename\n ...commitsView_nodes\n ...issueCommentView_item\n ...crossReferencedEventsView_nodes\n ... on Node {\n id\n }\n }\n }\n }\n}\n\nfragment prCommitsView_pullRequest_38TpXw on PullRequest {\n url\n commits(first: $commitCount, after: $commitCursor) {\n pageInfo {\n endCursor\n hasNextPage\n }\n edges {\n cursor\n node {\n commit {\n id\n ...prCommitView_item\n }\n id\n __typename\n }\n }\n }\n}\n\nfragment prStatusesView_pullRequest on PullRequest {\n id\n recentCommits: commits(last: 1) {\n edges {\n node {\n commit {\n status {\n state\n contexts {\n id\n state\n ...prStatusContextView_context\n }\n id\n }\n id\n }\n id\n }\n }\n }\n}\n\nfragment prTimelineController_pullRequest_3D8CP9 on PullRequest {\n url\n ...headRefForcePushedEventView_issueish\n timeline(first: $timelineCount, after: $timelineCursor) {\n pageInfo {\n endCursor\n hasNextPage\n }\n edges {\n cursor\n node {\n __typename\n ...commitsView_nodes\n ...issueCommentView_item\n ...mergedEventView_item\n ...headRefForcePushedEventView_item\n ...commitCommentThreadView_item\n ...crossReferencedEventsView_nodes\n ... on Node {\n id\n }\n }\n }\n }\n}\n\nfragment headRefForcePushedEventView_issueish on PullRequest {\n headRefName\n headRepositoryOwner {\n __typename\n login\n id\n }\n repository {\n owner {\n __typename\n login\n id\n }\n id\n }\n}\n\nfragment commitsView_nodes on Commit {\n id\n author {\n name\n user {\n login\n id\n }\n }\n ...commitView_item\n}\n\nfragment issueCommentView_item on IssueComment {\n author {\n __typename\n avatarUrl\n login\n ... on Node {\n id\n }\n }\n bodyHTML\n createdAt\n url\n}\n\nfragment mergedEventView_item on MergedEvent {\n actor {\n __typename\n avatarUrl\n login\n ... on Node {\n id\n }\n }\n commit {\n oid\n id\n }\n mergeRefName\n createdAt\n}\n\nfragment headRefForcePushedEventView_item on HeadRefForcePushedEvent {\n actor {\n __typename\n avatarUrl\n login\n ... on Node {\n id\n }\n }\n beforeCommit {\n oid\n id\n }\n afterCommit {\n oid\n id\n }\n createdAt\n}\n\nfragment commitCommentThreadView_item on CommitCommentThread {\n commit {\n oid\n id\n }\n comments(first: 100) {\n edges {\n node {\n id\n ...commitCommentView_item\n }\n }\n }\n}\n\nfragment crossReferencedEventsView_nodes on CrossReferencedEvent {\n id\n referencedAt\n isCrossRepository\n actor {\n __typename\n login\n avatarUrl\n ... on Node {\n id\n }\n }\n source {\n __typename\n ... on RepositoryNode {\n repository {\n name\n owner {\n __typename\n login\n id\n }\n id\n }\n }\n ... on Node {\n id\n }\n }\n ...crossReferencedEventView_item\n}\n\nfragment crossReferencedEventView_item on CrossReferencedEvent {\n id\n isCrossRepository\n source {\n __typename\n ... on Issue {\n number\n title\n url\n issueState: state\n }\n ... on PullRequest {\n number\n title\n url\n prState: state\n }\n ... on RepositoryNode {\n repository {\n name\n isPrivate\n owner {\n __typename\n login\n id\n }\n id\n }\n }\n ... on Node {\n id\n }\n }\n}\n\nfragment commitCommentView_item on CommitComment {\n author {\n __typename\n login\n avatarUrl\n ... on Node {\n id\n }\n }\n commit {\n oid\n id\n }\n bodyHTML\n createdAt\n path\n position\n}\n\nfragment commitView_item on Commit {\n author {\n name\n avatarUrl\n user {\n login\n id\n }\n }\n committer {\n name\n avatarUrl\n user {\n login\n id\n }\n }\n authoredByCommitter\n oid\n message\n messageHeadlineHTML\n commitUrl\n}\n\nfragment prStatusContextView_context on StatusContext {\n context\n description\n state\n targetUrl\n}\n\nfragment prCommitView_item on Commit {\n committer {\n avatarUrl\n name\n date\n }\n messageHeadline\n messageBody\n shortSha: abbreviatedOid\n sha: oid\n url\n}\n", "metadata": {}, "fragment": { "kind": "Fragment", @@ -1169,11 +1170,18 @@ return { }, { "kind": "ScalarField", - "alias": null, + "alias": "shortSha", "name": "abbreviatedOid", "args": null, "storageKey": null }, + { + "kind": "ScalarField", + "alias": "sha", + "name": "oid", + "args": null, + "storageKey": null + }, v12 ] }, diff --git a/lib/containers/commit-detail-container.js b/lib/containers/commit-detail-container.js new file mode 100644 index 00000000000..c624d127f79 --- /dev/null +++ b/lib/containers/commit-detail-container.js @@ -0,0 +1,45 @@ +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, + itemType: PropTypes.func.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/containers/issueish-detail-container.js b/lib/containers/issueish-detail-container.js index b7022358e29..ff9e419b807 100644 --- a/lib/containers/issueish-detail-container.js +++ b/lib/containers/issueish-detail-container.js @@ -26,6 +26,8 @@ export default class IssueishDetailContainer extends React.Component { switchToIssueish: PropTypes.func.isRequired, onTitleChange: PropTypes.func.isRequired, + + workspace: PropTypes.object.isRequired, } constructor(props) { @@ -169,6 +171,8 @@ export default class IssueishDetailContainer extends React.Component { addRemote={repository.addRemote.bind(repository)} onTitleChange={this.props.onTitleChange} switchToIssueish={this.props.switchToIssueish} + workdirPath={repository.getWorkingDirectoryPath()} + workspace={this.props.workspace} /> ); } diff --git a/lib/controllers/commit-controller.js b/lib/controllers/commit-controller.js index b07be5d280a..7183eeb31a3 100644 --- a/lib/controllers/commit-controller.js +++ b/lib/controllers/commit-controller.js @@ -76,7 +76,7 @@ export default class CommitController extends React.Component { }), this.props.workspace.onDidDestroyPaneItem(async ({item}) => { if (this.props.repository.isPresent() && item.getPath && item.getPath() === this.getCommitMessagePath() && - this.getCommitMessageEditors().length === 0) { + this.getCommitMessageEditors().length === 0) { // we closed the last editor pointing to the commit message file try { this.commitMessageBuffer.setText(await fs.readFile(this.getCommitMessagePath(), {encoding: 'utf8'})); @@ -252,24 +252,20 @@ export default class CommitController extends React.Component { this.grammarSubscription.dispose(); } - rememberFocus(event) { - return this.refCommitView.map(view => view.rememberFocus(event)).getOr(null); + getFocus(element) { + return this.refCommitView.map(view => view.getFocus(element)).getOr(null); } setFocus(focus) { return this.refCommitView.map(view => view.setFocus(focus)).getOr(false); } - advanceFocus(...args) { - return this.refCommitView.map(view => view.advanceFocus(...args)).getOr(false); + advanceFocusFrom(...args) { + return this.refCommitView.map(view => view.advanceFocusFrom(...args)).getOr(false); } - retreatFocus(...args) { - return this.refCommitView.map(view => view.retreatFocus(...args)).getOr(false); - } - - hasFocusAtBeginning() { - return this.refCommitView.map(view => view.hasFocusAtBeginning()).getOr(false); + retreatFocusFrom(...args) { + return this.refCommitView.map(view => view.retreatFocusFrom(...args)).getOr(false); } toggleCommitPreview() { diff --git a/lib/controllers/commit-detail-controller.js b/lib/controllers/commit-detail-controller.js new file mode 100644 index 00000000000..3f6014b9c79 --- /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.drilledPropTypes, + + 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..f70da000dce 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; } @@ -327,7 +338,7 @@ export default class GitTabController extends React.Component { } rememberLastFocus(event) { - this.lastFocus = this.refView.map(view => view.rememberFocus(event)).getOr(null) || GitTabView.focus.STAGING; + this.lastFocus = this.refView.map(view => view.getFocus(event.target)).getOr(null) || GitTabView.focus.STAGING; } restoreFocus() { @@ -352,6 +363,10 @@ export default class GitTabController extends React.Component { return this.refView.map(view => view.focusAndSelectCommitPreviewButton()); } + focusAndSelectRecentCommit() { + return this.refView.map(view => view.focusAndSelectRecentCommit()); + } + quietlySelectItem(filePath, stagingStatus) { return this.refView.map(view => view.quietlySelectItem(filePath, stagingStatus)).getOr(null); } diff --git a/lib/controllers/issueish-detail-controller.js b/lib/controllers/issueish-detail-controller.js index 2db4fba4981..4267f3b46f2 100644 --- a/lib/controllers/issueish-detail-controller.js +++ b/lib/controllers/issueish-detail-controller.js @@ -6,7 +6,8 @@ import {BranchSetPropType, RemoteSetPropType} from '../prop-types'; import {GitError} from '../git-shell-out-strategy'; import EnableableOperation from '../models/enableable-operation'; import IssueishDetailView, {checkoutStates} from '../views/issueish-detail-view'; -import {incrementCounter} from '../reporter-proxy'; +import CommitDetailItem from '../items/commit-detail-item'; +import {incrementCounter, addEvent} from '../reporter-proxy'; export class BareIssueishDetailController extends React.Component { static propTypes = { @@ -16,6 +17,7 @@ export class BareIssueishDetailController extends React.Component { login: PropTypes.string.isRequired, }).isRequired, issueish: PropTypes.any, // FIXME from IssueishPaneItemContainer.propTypes + getWorkingDirectoryPath: PropTypes.func.isRequired, }), issueishNumber: PropTypes.number.isRequired, @@ -33,6 +35,9 @@ export class BareIssueishDetailController extends React.Component { addRemote: PropTypes.func.isRequired, onTitleChange: PropTypes.func.isRequired, switchToIssueish: PropTypes.func.isRequired, + + workspace: PropTypes.object.isRequired, + workdirPath: PropTypes.string.isRequired, } constructor(props) { @@ -85,6 +90,7 @@ export class BareIssueishDetailController extends React.Component { issueish={repository.issueish} checkoutOp={this.checkoutOp} switchToIssueish={this.props.switchToIssueish} + openCommit={this.openCommit} /> ); } @@ -201,6 +207,13 @@ export class BareIssueishDetailController extends React.Component { incrementCounter('checkout-pr'); } + + openCommit = ({sha}) => { + const uri = CommitDetailItem.buildURI(this.props.workdirPath, sha); + this.props.workspace.open(uri, {pending: true}).then(() => { + addEvent('open-commit-in-pane', {package: 'github', from: this.constructor.name}); + }); + } } export default createFragmentContainer(BareIssueishDetailController, { 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..2967983062d 100644 --- a/lib/controllers/recent-commits-controller.js +++ b/lib/controllers/recent-commits-controller.js @@ -1,22 +1,112 @@ import React from 'react'; import PropTypes from 'prop-types'; +import {addEvent} from '../reporter-proxy'; +import {CompositeDisposable} from 'event-kit'; +import CommitDetailItem from '../items/commit-detail-item'; +import URIPattern from '../atom/uri-pattern'; import RecentCommitsView from '../views/recent-commits-view'; +import RefHolder from '../models/ref-holder'; 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, + } + + static focus = RecentCommitsView.focus + + constructor(props, context) { + super(props, context); + + this.subscriptions = new CompositeDisposable( + this.props.workspace.onDidChangeActivePaneItem(this.updateSelectedCommit), + ); + + this.refView = new RefHolder(); + + 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() { return ( ); } + + openCommit = async ({sha, preserveFocus}) => { + const workdir = this.props.repository.getWorkingDirectoryPath(); + const uri = CommitDetailItem.buildURI(workdir, sha); + await this.props.workspace.open(uri, {pending: true}); + if (preserveFocus) { + this.setFocus(this.constructor.focus.RECENT_COMMIT); + } + addEvent('open-commit-in-pane', {package: 'github', from: this.constructor.name}); + } + + selectNextCommit = () => this.setSelectedCommitIndex(this.getSelectedCommitIndex() + 1); + + selectPreviousCommit = () => this.setSelectedCommitIndex(Math.max(this.getSelectedCommitIndex() - 1, 0)); + + getSelectedCommitIndex() { + return this.props.commits.findIndex(commit => commit.getSha() === this.state.selectedCommitSha); + } + + setSelectedCommitIndex(ind) { + const commit = this.props.commits[ind]; + if (commit) { + return new Promise(resolve => this.setState({selectedCommitSha: commit.getSha()}, resolve)); + } else { + return Promise.resolve(); + } + } + + getFocus(element) { + return this.refView.map(view => view.getFocus(element)).getOr(null); + } + + setFocus(focus) { + return this.refView.map(view => view.setFocus(focus)).getOr(false); + } + + advanceFocusFrom(focus) { + return this.refView.map(view => view.advanceFocusFrom(focus)).getOr(Promise.resolve(false)); + } + + retreatFocusFrom(focus) { + return this.refView.map(view => view.retreatFocusFrom(focus)).getOr(Promise.resolve(false)); + } } diff --git a/lib/controllers/root-controller.js b/lib/controllers/root-controller.js index ec5d22f536f..350c25d76ff 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'; @@ -98,7 +100,7 @@ export default class RootController extends React.Component { this.props.commandRegistry.onDidDispatch(event => { if (event.type && event.type.startsWith('github:') - && event.detail && event.detail[0] && event.detail[0].contextCommand) { + && event.detail && event.detail[0] && event.detail[0].contextCommand) { addEvent('context-menu-action', { package: 'github', command: event.type, @@ -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,27 @@ export default class RootController extends React.Component { /> )} + + {({itemHolder, params}) => ( + + )} + {({itemHolder, params}) => ( )} @@ -490,6 +533,10 @@ export default class RootController extends React.Component { this.setState({openIssueishDialogActive: true}); } + showOpenCommitDialog = () => { + this.setState({openCommitDialogActive: true}); + } + showWaterfallDiagnostics() { this.props.workspace.open(GitTimingsView.buildURI()); } @@ -548,6 +595,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: OpenCommitDialog.name}); + }); + } + + cancelOpenCommit = () => { + this.setState({openCommitDialogActive: false}); + } + surfaceFromFileAtPath = (filePath, stagingStatus) => { const gitTab = this.gitTabTracker.getComponent(); return gitTab && gitTab.focusAndSelectStagingItem(filePath, stagingStatus); @@ -558,6 +618,11 @@ export default class RootController extends React.Component { return gitTab && gitTab.focusAndSelectCommitPreviewButton(); } + surfaceToRecentCommit = () => { + const gitTab = this.gitTabTracker.getComponent(); + return gitTab && gitTab.focusAndSelectRecentCommit(); + } + destroyFilePatchPaneItems() { destroyFilePatchPaneItems({onlyStaged: false}, this.props.workspace); } 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..a7331b3cde8 --- /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.getPromise().then(focusable => focusable.focus()); + } +} diff --git a/lib/items/git-tab-item.js b/lib/items/git-tab-item.js index 254c9d717aa..e8de6d7ccad 100644 --- a/lib/items/git-tab-item.js +++ b/lib/items/git-tab-item.js @@ -90,4 +90,8 @@ export default class GitTabItem extends React.Component { quietlySelectItem(...args) { return this.refController.map(c => c.quietlySelectItem(...args)); } + + focusAndSelectRecentCommit() { + return this.refController.map(c => c.focusAndSelectRecentCommit()); + } } diff --git a/lib/items/issueish-detail-item.js b/lib/items/issueish-detail-item.js index 88b8242b71e..88c0bd78d3a 100644 --- a/lib/items/issueish-detail-item.js +++ b/lib/items/issueish-detail-item.js @@ -18,6 +18,8 @@ export default class IssueishDetailItem extends Component { workingDirectory: PropTypes.string.isRequired, workdirContextPool: WorkdirContextPoolPropType.isRequired, loginModel: GithubLoginModelPropType.isRequired, + + workspace: PropTypes.object.isRequired, } static uriPattern = 'atom-github://issueish/{host}/{owner}/{repo}/{issueishNumber}?workdir={workingDirectory}' @@ -66,6 +68,7 @@ export default class IssueishDetailItem extends Component { issueishNumber={this.state.issueishNumber} repository={this.state.repository} + workspace={this.props.workspace} loginModel={this.props.loginModel} onTitleChange={this.handleTitleChanged} 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/__generated__/issueishDetailViewRefetchQuery.graphql.js b/lib/views/__generated__/issueishDetailViewRefetchQuery.graphql.js index 3b5eeaf1e03..d300687d263 100644 --- a/lib/views/__generated__/issueishDetailViewRefetchQuery.graphql.js +++ b/lib/views/__generated__/issueishDetailViewRefetchQuery.graphql.js @@ -1,6 +1,6 @@ /** * @flow - * @relayHash 2918a11cbaa8f9b4f358a3913dab315c + * @relayHash 7a789201c89c0ffa32e9490d1bf7c181 */ /* eslint-disable */ @@ -446,7 +446,8 @@ fragment prCommitView_item on Commit { } messageHeadline messageBody - abbreviatedOid + shortSha: abbreviatedOid + sha: oid url } */ @@ -947,7 +948,7 @@ return { "operationKind": "query", "name": "issueishDetailViewRefetchQuery", "id": null, - "text": "query issueishDetailViewRefetchQuery(\n $repoId: ID!\n $issueishId: ID!\n $timelineCount: Int!\n $timelineCursor: String\n $commitCount: Int!\n $commitCursor: String\n) {\n repository: node(id: $repoId) {\n __typename\n ...issueishDetailView_repository_3D8CP9\n id\n }\n issueish: node(id: $issueishId) {\n __typename\n ...issueishDetailView_issueish_4cAEh0\n id\n }\n}\n\nfragment issueishDetailView_repository_3D8CP9 on Repository {\n id\n name\n owner {\n __typename\n login\n id\n }\n}\n\nfragment issueishDetailView_issueish_4cAEh0 on IssueOrPullRequest {\n __typename\n ... on Node {\n id\n }\n ... on Issue {\n state\n number\n title\n bodyHTML\n author {\n __typename\n login\n avatarUrl\n ... on User {\n url\n }\n ... on Bot {\n url\n }\n ... on Node {\n id\n }\n }\n ...issueTimelineController_issue_3D8CP9\n }\n ... on PullRequest {\n isCrossRepository\n changedFiles\n ...prCommitsView_pullRequest_38TpXw\n countedCommits: commits {\n totalCount\n }\n ...prStatusesView_pullRequest\n state\n number\n title\n bodyHTML\n baseRefName\n headRefName\n author {\n __typename\n login\n avatarUrl\n ... on User {\n url\n }\n ... on Bot {\n url\n }\n ... on Node {\n id\n }\n }\n ...prTimelineController_pullRequest_3D8CP9\n }\n ... on UniformResourceLocatable {\n url\n }\n ... on Reactable {\n reactionGroups {\n content\n users {\n totalCount\n }\n }\n }\n}\n\nfragment issueTimelineController_issue_3D8CP9 on Issue {\n url\n timeline(first: $timelineCount, after: $timelineCursor) {\n pageInfo {\n endCursor\n hasNextPage\n }\n edges {\n cursor\n node {\n __typename\n ...commitsView_nodes\n ...issueCommentView_item\n ...crossReferencedEventsView_nodes\n ... on Node {\n id\n }\n }\n }\n }\n}\n\nfragment prCommitsView_pullRequest_38TpXw on PullRequest {\n url\n commits(first: $commitCount, after: $commitCursor) {\n pageInfo {\n endCursor\n hasNextPage\n }\n edges {\n cursor\n node {\n commit {\n id\n ...prCommitView_item\n }\n id\n __typename\n }\n }\n }\n}\n\nfragment prStatusesView_pullRequest on PullRequest {\n id\n recentCommits: commits(last: 1) {\n edges {\n node {\n commit {\n status {\n state\n contexts {\n id\n state\n ...prStatusContextView_context\n }\n id\n }\n id\n }\n id\n }\n }\n }\n}\n\nfragment prTimelineController_pullRequest_3D8CP9 on PullRequest {\n url\n ...headRefForcePushedEventView_issueish\n timeline(first: $timelineCount, after: $timelineCursor) {\n pageInfo {\n endCursor\n hasNextPage\n }\n edges {\n cursor\n node {\n __typename\n ...commitsView_nodes\n ...issueCommentView_item\n ...mergedEventView_item\n ...headRefForcePushedEventView_item\n ...commitCommentThreadView_item\n ...crossReferencedEventsView_nodes\n ... on Node {\n id\n }\n }\n }\n }\n}\n\nfragment headRefForcePushedEventView_issueish on PullRequest {\n headRefName\n headRepositoryOwner {\n __typename\n login\n id\n }\n repository {\n owner {\n __typename\n login\n id\n }\n id\n }\n}\n\nfragment commitsView_nodes on Commit {\n id\n author {\n name\n user {\n login\n id\n }\n }\n ...commitView_item\n}\n\nfragment issueCommentView_item on IssueComment {\n author {\n __typename\n avatarUrl\n login\n ... on Node {\n id\n }\n }\n bodyHTML\n createdAt\n url\n}\n\nfragment mergedEventView_item on MergedEvent {\n actor {\n __typename\n avatarUrl\n login\n ... on Node {\n id\n }\n }\n commit {\n oid\n id\n }\n mergeRefName\n createdAt\n}\n\nfragment headRefForcePushedEventView_item on HeadRefForcePushedEvent {\n actor {\n __typename\n avatarUrl\n login\n ... on Node {\n id\n }\n }\n beforeCommit {\n oid\n id\n }\n afterCommit {\n oid\n id\n }\n createdAt\n}\n\nfragment commitCommentThreadView_item on CommitCommentThread {\n commit {\n oid\n id\n }\n comments(first: 100) {\n edges {\n node {\n id\n ...commitCommentView_item\n }\n }\n }\n}\n\nfragment crossReferencedEventsView_nodes on CrossReferencedEvent {\n id\n referencedAt\n isCrossRepository\n actor {\n __typename\n login\n avatarUrl\n ... on Node {\n id\n }\n }\n source {\n __typename\n ... on RepositoryNode {\n repository {\n name\n owner {\n __typename\n login\n id\n }\n id\n }\n }\n ... on Node {\n id\n }\n }\n ...crossReferencedEventView_item\n}\n\nfragment crossReferencedEventView_item on CrossReferencedEvent {\n id\n isCrossRepository\n source {\n __typename\n ... on Issue {\n number\n title\n url\n issueState: state\n }\n ... on PullRequest {\n number\n title\n url\n prState: state\n }\n ... on RepositoryNode {\n repository {\n name\n isPrivate\n owner {\n __typename\n login\n id\n }\n id\n }\n }\n ... on Node {\n id\n }\n }\n}\n\nfragment commitCommentView_item on CommitComment {\n author {\n __typename\n login\n avatarUrl\n ... on Node {\n id\n }\n }\n commit {\n oid\n id\n }\n bodyHTML\n createdAt\n path\n position\n}\n\nfragment commitView_item on Commit {\n author {\n name\n avatarUrl\n user {\n login\n id\n }\n }\n committer {\n name\n avatarUrl\n user {\n login\n id\n }\n }\n authoredByCommitter\n oid\n message\n messageHeadlineHTML\n commitUrl\n}\n\nfragment prStatusContextView_context on StatusContext {\n context\n description\n state\n targetUrl\n}\n\nfragment prCommitView_item on Commit {\n committer {\n avatarUrl\n name\n date\n }\n messageHeadline\n messageBody\n abbreviatedOid\n url\n}\n", + "text": "query issueishDetailViewRefetchQuery(\n $repoId: ID!\n $issueishId: ID!\n $timelineCount: Int!\n $timelineCursor: String\n $commitCount: Int!\n $commitCursor: String\n) {\n repository: node(id: $repoId) {\n __typename\n ...issueishDetailView_repository_3D8CP9\n id\n }\n issueish: node(id: $issueishId) {\n __typename\n ...issueishDetailView_issueish_4cAEh0\n id\n }\n}\n\nfragment issueishDetailView_repository_3D8CP9 on Repository {\n id\n name\n owner {\n __typename\n login\n id\n }\n}\n\nfragment issueishDetailView_issueish_4cAEh0 on IssueOrPullRequest {\n __typename\n ... on Node {\n id\n }\n ... on Issue {\n state\n number\n title\n bodyHTML\n author {\n __typename\n login\n avatarUrl\n ... on User {\n url\n }\n ... on Bot {\n url\n }\n ... on Node {\n id\n }\n }\n ...issueTimelineController_issue_3D8CP9\n }\n ... on PullRequest {\n isCrossRepository\n changedFiles\n ...prCommitsView_pullRequest_38TpXw\n countedCommits: commits {\n totalCount\n }\n ...prStatusesView_pullRequest\n state\n number\n title\n bodyHTML\n baseRefName\n headRefName\n author {\n __typename\n login\n avatarUrl\n ... on User {\n url\n }\n ... on Bot {\n url\n }\n ... on Node {\n id\n }\n }\n ...prTimelineController_pullRequest_3D8CP9\n }\n ... on UniformResourceLocatable {\n url\n }\n ... on Reactable {\n reactionGroups {\n content\n users {\n totalCount\n }\n }\n }\n}\n\nfragment issueTimelineController_issue_3D8CP9 on Issue {\n url\n timeline(first: $timelineCount, after: $timelineCursor) {\n pageInfo {\n endCursor\n hasNextPage\n }\n edges {\n cursor\n node {\n __typename\n ...commitsView_nodes\n ...issueCommentView_item\n ...crossReferencedEventsView_nodes\n ... on Node {\n id\n }\n }\n }\n }\n}\n\nfragment prCommitsView_pullRequest_38TpXw on PullRequest {\n url\n commits(first: $commitCount, after: $commitCursor) {\n pageInfo {\n endCursor\n hasNextPage\n }\n edges {\n cursor\n node {\n commit {\n id\n ...prCommitView_item\n }\n id\n __typename\n }\n }\n }\n}\n\nfragment prStatusesView_pullRequest on PullRequest {\n id\n recentCommits: commits(last: 1) {\n edges {\n node {\n commit {\n status {\n state\n contexts {\n id\n state\n ...prStatusContextView_context\n }\n id\n }\n id\n }\n id\n }\n }\n }\n}\n\nfragment prTimelineController_pullRequest_3D8CP9 on PullRequest {\n url\n ...headRefForcePushedEventView_issueish\n timeline(first: $timelineCount, after: $timelineCursor) {\n pageInfo {\n endCursor\n hasNextPage\n }\n edges {\n cursor\n node {\n __typename\n ...commitsView_nodes\n ...issueCommentView_item\n ...mergedEventView_item\n ...headRefForcePushedEventView_item\n ...commitCommentThreadView_item\n ...crossReferencedEventsView_nodes\n ... on Node {\n id\n }\n }\n }\n }\n}\n\nfragment headRefForcePushedEventView_issueish on PullRequest {\n headRefName\n headRepositoryOwner {\n __typename\n login\n id\n }\n repository {\n owner {\n __typename\n login\n id\n }\n id\n }\n}\n\nfragment commitsView_nodes on Commit {\n id\n author {\n name\n user {\n login\n id\n }\n }\n ...commitView_item\n}\n\nfragment issueCommentView_item on IssueComment {\n author {\n __typename\n avatarUrl\n login\n ... on Node {\n id\n }\n }\n bodyHTML\n createdAt\n url\n}\n\nfragment mergedEventView_item on MergedEvent {\n actor {\n __typename\n avatarUrl\n login\n ... on Node {\n id\n }\n }\n commit {\n oid\n id\n }\n mergeRefName\n createdAt\n}\n\nfragment headRefForcePushedEventView_item on HeadRefForcePushedEvent {\n actor {\n __typename\n avatarUrl\n login\n ... on Node {\n id\n }\n }\n beforeCommit {\n oid\n id\n }\n afterCommit {\n oid\n id\n }\n createdAt\n}\n\nfragment commitCommentThreadView_item on CommitCommentThread {\n commit {\n oid\n id\n }\n comments(first: 100) {\n edges {\n node {\n id\n ...commitCommentView_item\n }\n }\n }\n}\n\nfragment crossReferencedEventsView_nodes on CrossReferencedEvent {\n id\n referencedAt\n isCrossRepository\n actor {\n __typename\n login\n avatarUrl\n ... on Node {\n id\n }\n }\n source {\n __typename\n ... on RepositoryNode {\n repository {\n name\n owner {\n __typename\n login\n id\n }\n id\n }\n }\n ... on Node {\n id\n }\n }\n ...crossReferencedEventView_item\n}\n\nfragment crossReferencedEventView_item on CrossReferencedEvent {\n id\n isCrossRepository\n source {\n __typename\n ... on Issue {\n number\n title\n url\n issueState: state\n }\n ... on PullRequest {\n number\n title\n url\n prState: state\n }\n ... on RepositoryNode {\n repository {\n name\n isPrivate\n owner {\n __typename\n login\n id\n }\n id\n }\n }\n ... on Node {\n id\n }\n }\n}\n\nfragment commitCommentView_item on CommitComment {\n author {\n __typename\n login\n avatarUrl\n ... on Node {\n id\n }\n }\n commit {\n oid\n id\n }\n bodyHTML\n createdAt\n path\n position\n}\n\nfragment commitView_item on Commit {\n author {\n name\n avatarUrl\n user {\n login\n id\n }\n }\n committer {\n name\n avatarUrl\n user {\n login\n id\n }\n }\n authoredByCommitter\n oid\n message\n messageHeadlineHTML\n commitUrl\n}\n\nfragment prStatusContextView_context on StatusContext {\n context\n description\n state\n targetUrl\n}\n\nfragment prCommitView_item on Commit {\n committer {\n avatarUrl\n name\n date\n }\n messageHeadline\n messageBody\n shortSha: abbreviatedOid\n sha: oid\n url\n}\n", "metadata": {}, "fragment": { "kind": "Fragment", @@ -1155,11 +1156,18 @@ return { }, { "kind": "ScalarField", - "alias": null, + "alias": "shortSha", "name": "abbreviatedOid", "args": null, "storageKey": null }, + { + "kind": "ScalarField", + "alias": "sha", + "name": "oid", + "args": null, + "storageKey": null + }, v11 ] }, diff --git a/lib/views/__generated__/prCommitView_item.graphql.js b/lib/views/__generated__/prCommitView_item.graphql.js index c5d7709a260..2d1203207cb 100644 --- a/lib/views/__generated__/prCommitView_item.graphql.js +++ b/lib/views/__generated__/prCommitView_item.graphql.js @@ -18,7 +18,8 @@ export type prCommitView_item = {| |}, +messageHeadline: string, +messageBody: string, - +abbreviatedOid: string, + +shortSha: string, + +sha: any, +url: any, +$refType: prCommitView_item$ref, |}; @@ -80,11 +81,18 @@ const node/*: ConcreteFragment*/ = { }, { "kind": "ScalarField", - "alias": null, + "alias": "shortSha", "name": "abbreviatedOid", "args": null, "storageKey": null }, + { + "kind": "ScalarField", + "alias": "sha", + "name": "oid", + "args": null, + "storageKey": null + }, { "kind": "ScalarField", "alias": null, @@ -95,5 +103,5 @@ const node/*: ConcreteFragment*/ = { ] }; // prettier-ignore -(node/*: any*/).hash = 'c7c00b19a2fd2a18e4c1bab7f5f252ff'; +(node/*: any*/).hash = '2bd193bec5d758f465d9428ff3cd8a09'; module.exports = node; diff --git a/lib/views/__generated__/prCommitsViewQuery.graphql.js b/lib/views/__generated__/prCommitsViewQuery.graphql.js index f3c5e4ff134..7d20b34ac57 100644 --- a/lib/views/__generated__/prCommitsViewQuery.graphql.js +++ b/lib/views/__generated__/prCommitsViewQuery.graphql.js @@ -1,6 +1,6 @@ /** * @flow - * @relayHash 9ee1d26899a0ab5d2eeeee7dbd86fb3d + * @relayHash 86632ba0fe5f43bd343d798b397ad81b */ /* eslint-disable */ @@ -73,7 +73,8 @@ fragment prCommitView_item on Commit { } messageHeadline messageBody - abbreviatedOid + shortSha: abbreviatedOid + sha: oid url } */ @@ -147,7 +148,7 @@ return { "operationKind": "query", "name": "prCommitsViewQuery", "id": null, - "text": "query prCommitsViewQuery(\n $commitCount: Int!\n $commitCursor: String\n $url: URI!\n) {\n resource(url: $url) {\n __typename\n ... on PullRequest {\n ...prCommitsView_pullRequest_38TpXw\n }\n ... on Node {\n id\n }\n }\n}\n\nfragment prCommitsView_pullRequest_38TpXw on PullRequest {\n url\n commits(first: $commitCount, after: $commitCursor) {\n pageInfo {\n endCursor\n hasNextPage\n }\n edges {\n cursor\n node {\n commit {\n id\n ...prCommitView_item\n }\n id\n __typename\n }\n }\n }\n}\n\nfragment prCommitView_item on Commit {\n committer {\n avatarUrl\n name\n date\n }\n messageHeadline\n messageBody\n abbreviatedOid\n url\n}\n", + "text": "query prCommitsViewQuery(\n $commitCount: Int!\n $commitCursor: String\n $url: URI!\n) {\n resource(url: $url) {\n __typename\n ... on PullRequest {\n ...prCommitsView_pullRequest_38TpXw\n }\n ... on Node {\n id\n }\n }\n}\n\nfragment prCommitsView_pullRequest_38TpXw on PullRequest {\n url\n commits(first: $commitCount, after: $commitCursor) {\n pageInfo {\n endCursor\n hasNextPage\n }\n edges {\n cursor\n node {\n commit {\n id\n ...prCommitView_item\n }\n id\n __typename\n }\n }\n }\n}\n\nfragment prCommitView_item on Commit {\n committer {\n avatarUrl\n name\n date\n }\n messageHeadline\n messageBody\n shortSha: abbreviatedOid\n sha: oid\n url\n}\n", "metadata": {}, "fragment": { "kind": "Fragment", @@ -331,11 +332,18 @@ return { }, { "kind": "ScalarField", - "alias": null, + "alias": "shortSha", "name": "abbreviatedOid", "args": null, "storageKey": null }, + { + "kind": "ScalarField", + "alias": "sha", + "name": "oid", + "args": null, + "storageKey": null + }, v4 ] }, diff --git a/lib/views/commit-detail-view.js b/lib/views/commit-detail-view.js new file mode 100644 index 00000000000..af819f94428 --- /dev/null +++ b/lib/views/commit-detail-view.js @@ -0,0 +1,175 @@ +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 Commands, {Command} from '../atom/commands'; +import RefHolder from '../models/ref-holder'; + +export default class CommitDetailView extends React.Component { + static drilledPropTypes = { + // Model properties + repository: PropTypes.object.isRequired, + commit: PropTypes.object.isRequired, + currentRemote: PropTypes.object.isRequired, + currentBranch: PropTypes.object.isRequired, + isCommitPushed: PropTypes.bool.isRequired, + itemType: PropTypes.func.isRequired, + + // Atom environment + workspace: PropTypes.object.isRequired, + commands: PropTypes.object.isRequired, + keymaps: PropTypes.object.isRequired, + tooltips: PropTypes.object.isRequired, + config: PropTypes.object.isRequired, + + // Action functions + destroy: PropTypes.func.isRequired, + surfaceCommit: PropTypes.func.isRequired, + } + + static propTypes = { + ...CommitDetailView.drilledPropTypes, + + // Controller state + messageCollapsible: PropTypes.bool.isRequired, + messageOpen: PropTypes.bool.isRequired, + + // Action functions + toggleMessage: PropTypes.func.isRequired, + } + + constructor(props) { + super(props); + + this.refRoot = new RefHolder(); + } + + render() { + const commit = this.props.commit; + + return ( +
+ {this.renderCommands()} +
+
+

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

+
+ {/* TODO fix image src */} + {this.renderAuthors()} + + {this.getAuthorInfo()} committed {this.humanizeTimeSince(commit.getAuthorDate())} + +
+ {this.renderDotComLink()} +
+
+ {this.renderShowMoreButton()} + {this.renderCommitMessageBody()} +
+
+ +
+ ); + } + + renderCommands() { + return ( + + + + ); + } + + 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 commit = this.props.commit; + const coAuthorCount = commit.getCoAuthors().length; + if (coAuthorCount === 0) { + return commit.getAuthorEmail(); + } else if (coAuthorCount === 1) { + return `${commit.getAuthorEmail()} and ${commit.getCoAuthors()[0].email}`; + } else { + return `${commit.getAuthorEmail()} and ${coAuthorCount} others`; + } + } + + 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/commit-view.js b/lib/views/commit-view.js index 6013162cfaf..dbd7724ea9d 100644 --- a/lib/views/commit-view.js +++ b/lib/views/commit-view.js @@ -7,6 +7,8 @@ import Select from 'react-select'; import Tooltip from '../atom/tooltip'; import AtomTextEditor from '../atom/atom-text-editor'; import CoAuthorForm from './co-author-form'; +import RecentCommitsView from './recent-commits-view'; +import StagingView from './staging-view'; import Commands, {Command} from '../atom/commands'; import RefHolder from '../models/ref-holder'; import Author from '../models/author'; @@ -30,6 +32,10 @@ export default class CommitView extends React.Component { COMMIT_BUTTON: Symbol('commit-button'), }; + static firstFocus = CommitView.focus.COMMIT_PREVIEW_BUTTON; + + static lastFocus = CommitView.focus.COMMIT_BUTTON; + static propTypes = { workspace: PropTypes.object.isRequired, config: PropTypes.object.isRequired, @@ -569,15 +575,7 @@ export default class CommitView extends React.Component { return this.refRoot.map(element => element.contains(document.activeElement)).getOr(false); } - hasFocusEditor() { - return this.refEditorComponent.map(editor => editor.contains(document.activeElement)).getOr(false); - } - - hasFocusAtBeginning() { - return this.refCommitPreviewButton.map(button => button.contains(document.activeElement)).getOr(false); - } - - getFocus(element = document.activeElement) { + getFocus(element) { if (this.refCommitPreviewButton.map(button => button.contains(element)).getOr(false)) { return CommitView.focus.COMMIT_PREVIEW_BUTTON; } @@ -601,10 +599,6 @@ export default class CommitView extends React.Component { return null; } - rememberFocus(event) { - return this.getFocus(event.target); - } - setFocus(focus) { let fallback = false; const focusElement = element => { @@ -657,77 +651,73 @@ export default class CommitView extends React.Component { return false; } - advanceFocus(event) { + advanceFocusFrom(focus) { const f = this.constructor.focus; - const current = this.getFocus(); - if (current === f.EDITOR) { - // Let the editor handle it - return true; - } let next = null; - switch (current) { + switch (focus) { case f.COMMIT_PREVIEW_BUTTON: next = f.EDITOR; break; + case f.EDITOR: + if (this.state.showCoAuthorInput) { + next = f.COAUTHOR_INPUT; + } else if (this.props.isMerging) { + next = f.ABORT_MERGE_BUTTON; + } else if (this.commitIsEnabled(false)) { + next = f.COMMIT_BUTTON; + } else { + next = RecentCommitsView.firstFocus; + } + break; case f.COAUTHOR_INPUT: - next = this.props.isMerging ? f.ABORT_MERGE_BUTTON : f.COMMIT_BUTTON; + if (this.props.isMerging) { + next = f.ABORT_MERGE_BUTTON; + } else if (this.commitIsEnabled(false)) { + next = f.COMMIT_BUTTON; + } else { + next = RecentCommitsView.firstFocus; + } break; case f.ABORT_MERGE_BUTTON: - next = f.COMMIT_BUTTON; + next = this.commitIsEnabled(false) ? f.COMMIT_BUTTON : RecentCommitsView.firstFocus; break; case f.COMMIT_BUTTON: - // End of tab navigation. Prevent cycling. - event.stopPropagation(); - return true; + next = RecentCommitsView.firstFocus; + break; } - if (next !== null) { - this.setFocus(next); - event.stopPropagation(); - - return true; - } else { - return false; - } + return Promise.resolve(next); } - retreatFocus(event) { + retreatFocusFrom(focus) { const f = this.constructor.focus; - const current = this.getFocus(); - let next = null; - switch (current) { + let previous = null; + switch (focus) { case f.COMMIT_BUTTON: if (this.props.isMerging) { - next = f.ABORT_MERGE_BUTTON; + previous = f.ABORT_MERGE_BUTTON; } else if (this.state.showCoAuthorInput) { - next = f.COAUTHOR_INPUT; + previous = f.COAUTHOR_INPUT; } else { - next = f.EDITOR; + previous = f.EDITOR; } break; case f.ABORT_MERGE_BUTTON: - next = this.state.showCoAuthorInput ? f.COAUTHOR_INPUT : f.EDITOR; + previous = this.state.showCoAuthorInput ? f.COAUTHOR_INPUT : f.EDITOR; break; case f.COAUTHOR_INPUT: - next = f.EDITOR; + previous = f.EDITOR; break; case f.EDITOR: - next = f.COMMIT_PREVIEW_BUTTON; + previous = f.COMMIT_PREVIEW_BUTTON; break; case f.COMMIT_PREVIEW_BUTTON: - // Allow the GitTabView to retreat focus back to the last StagingView list. - return false; + previous = StagingView.lastFocus; + break; } - if (next !== null) { - this.setFocus(next); - event.stopPropagation(); - - return true; - } else { - return false; - } + return Promise.resolve(previous); } } 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..d39dbfcb60a 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,31 @@ export default class FilePatchMetaView extends React.Component { action: PropTypes.func.isRequired, children: PropTypes.element.isRequired, + itemType: PropTypes.oneOf([ChangedFileItem, CommitPreviewItem, CommitDetailItem]).isRequired, }; + renderMetaControls() { + 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..4ec74fde668 100644 --- a/lib/views/git-tab-view.js +++ b/lib/views/git-tab-view.js @@ -70,6 +70,7 @@ export default class GitTabView extends React.Component { this.subscriptions = new CompositeDisposable(); this.refCommitController = new RefHolder(); + this.refRecentCommitsController = new RefHolder(); } componentDidMount() { @@ -101,7 +102,7 @@ export default class GitTabView extends React.Component {
); } else if (this.props.repository.hasDirectory() && - !isValidWorkdir(this.props.repository.getWorkingDirectoryPath())) { + !isValidWorkdir(this.props.repository.getWorkingDirectoryPath())) { return (
@@ -193,9 +194,13 @@ export default class GitTabView extends React.Component { updateSelectedCoAuthors={this.props.updateSelectedCoAuthors} />
); @@ -219,27 +224,22 @@ export default class GitTabView extends React.Component { this.props.initializeRepo(initPath); } - rememberFocus(event) { - let currentFocus = null; - - currentFocus = this.props.refStagingView.map(view => view.rememberFocus(event)).getOr(null); - - if (!currentFocus) { - currentFocus = this.refCommitController.map(controller => controller.rememberFocus(event)).getOr(null); + getFocus(element) { + for (const ref of [this.props.refStagingView, this.refCommitController, this.refRecentCommitsController]) { + const focus = ref.map(sub => sub.getFocus(element)).getOr(null); + if (focus !== null) { + return focus; + } } - - return currentFocus; + return null; } setFocus(focus) { - if (this.props.refStagingView.map(view => view.setFocus(focus)).getOr(false)) { - return true; - } - - if (this.refCommitController.map(controller => controller.setFocus(focus)).getOr(false)) { - return true; + for (const ref of [this.props.refStagingView, this.refCommitController, this.refRecentCommitsController]) { + if (ref.map(sub => sub.setFocus(focus)).getOr(false)) { + return true; + } } - return false; } @@ -248,39 +248,34 @@ export default class GitTabView extends React.Component { } async advanceFocus(evt) { - // Advance focus within the CommitView if it's there - if (this.refCommitController.map(c => c.advanceFocus(evt)).getOr(false)) { - return; - } + const currentFocus = this.getFocus(document.activeElement); + let nextSeen = false; - // Advance focus to the next staging view list, if it's there - if (await this.props.refStagingView.map(view => view.activateNextList()).getOr(false)) { - evt.stopPropagation(); - return; - } - - // Advance focus from the staging view lists to the CommitView - if (this.refCommitController.map(c => c.setFocus(GitTabView.focus.COMMIT_PREVIEW_BUTTON)).getOr(false)) { - evt.stopPropagation(); + for (const subHolder of [this.props.refStagingView, this.refCommitController, this.refRecentCommitsController]) { + const next = await subHolder.map(sub => sub.advanceFocusFrom(currentFocus)).getOr(null); + if (next !== null && !nextSeen) { + nextSeen = true; + evt.stopPropagation(); + if (next !== currentFocus) { + this.setFocus(next); + } + } } } async retreatFocus(evt) { - // Retreat focus within the CommitView if it's there - if (this.refCommitController.map(c => c.retreatFocus(evt)).getOr(false)) { - return; - } + const currentFocus = this.getFocus(document.activeElement); + let previousSeen = false; - if (this.refCommitController.map(c => c.hasFocusAtBeginning()).getOr(false)) { - // Retreat focus from the beginning of the CommitView to the end of the StagingView - if (await this.props.refStagingView.map(view => view.activateLastList()).getOr(null)) { - this.setFocus(GitTabView.focus.STAGING); + for (const subHolder of [this.refRecentCommitsController, this.refCommitController, this.props.refStagingView]) { + const previous = await subHolder.map(sub => sub.retreatFocusFrom(currentFocus)).getOr(null); + if (previous !== null && !previousSeen) { + previousSeen = true; evt.stopPropagation(); + if (previous !== currentFocus) { + this.setFocus(previous); + } } - } else if (await this.props.refStagingView.map(c => c.activatePreviousList()).getOr(null)) { - // Retreat focus within the StagingView - this.setFocus(GitTabView.focus.STAGING); - evt.stopPropagation(); } } @@ -289,6 +284,10 @@ export default class GitTabView extends React.Component { this.setFocus(GitTabView.focus.STAGING); } + focusAndSelectRecentCommit() { + this.setFocus(RecentCommitsController.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/pr-commit-view.js b/lib/views/pr-commit-view.js index 88d879a3cbf..e1fa7302799 100644 --- a/lib/views/pr-commit-view.js +++ b/lib/views/pr-commit-view.js @@ -3,6 +3,7 @@ import PropTypes from 'prop-types'; import {emojify} from 'node-emoji'; import moment from 'moment'; import {graphql, createFragmentContainer} from 'react-relay'; +import cx from 'classnames'; import {autobind} from '../helpers'; @@ -18,9 +19,12 @@ export class PrCommitView extends React.Component { }).isRequired, messageBody: PropTypes.string, messageHeadline: PropTypes.string.isRequired, - abbreviatedOid: PropTypes.string.isRequired, + shortSha: PropTypes.string.isRequired, + sha: PropTypes.string.isRequired, url: PropTypes.string.isRequired, }).isRequired, + onBranch: PropTypes.bool.isRequired, + openCommit: PropTypes.func.isRequired, } constructor(props) { @@ -37,14 +41,21 @@ export class PrCommitView extends React.Component { return moment(date).fromNow(); } + openCommitDetailItem = () => { + return this.props.onBranch ? this.props.openCommit({sha: this.props.item.sha}) : null; + } + render() { - const {messageHeadline, messageBody, abbreviatedOid, url} = this.props.item; + const {messageHeadline, messageBody, shortSha, url} = this.props.item; const {avatarUrl, name, date} = this.props.item.committer; return (

- {emojify(messageHeadline)} + + {emojify(messageHeadline)} + {messageBody ?

@@ -86,7 +97,8 @@ export default createFragmentContainer(PrCommitView, { } messageHeadline messageBody - abbreviatedOid + shortSha: abbreviatedOid + sha: oid url }`, }); diff --git a/lib/views/pr-commits-view.js b/lib/views/pr-commits-view.js index 32ad87ce5cc..3e4bc0f0ecd 100644 --- a/lib/views/pr-commits-view.js +++ b/lib/views/pr-commits-view.js @@ -32,6 +32,8 @@ export class PrCommitsView extends React.Component { }), ), }), + onBranch: PropTypes.bool.isRequired, + openCommit: PropTypes.func.isRequired, } constructor(props) { @@ -71,6 +73,8 @@ export class PrCommitsView extends React.Component { ); }); } diff --git a/lib/views/recent-commits-view.js b/lib/views/recent-commits-view.js index 855fad6515a..ac64db3d404 100644 --- a/lib/views/recent-commits-view.js +++ b/lib/views/recent-commits-view.js @@ -4,6 +4,10 @@ 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 CommitView from './commit-view'; import Timeago from './timeago'; class RecentCommitView extends React.Component { @@ -11,14 +15,40 @@ 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, }; + constructor(props) { + super(props); + + this.refRoot = new RefHolder(); + } + + componentDidMount() { + if (this.props.isSelected) { + this.refRoot.map(root => root.scrollIntoViewIfNeeded(false)); + } + } + + componentDidUpdate(prevProps) { + if (this.props.isSelected && !prevProps.isSelected) { + this.refRoot.map(root => root.scrollIntoViewIfNeeded(false)); + } + } + render() { const authorMoment = moment(this.props.commit.getAuthorDate() * 1000); const fullMessage = this.props.commit.getFullMessage(); return ( -
  • +
  • {this.renderAuthors()} + onClick={this.undoLastCommit}> Undo )} @@ -73,18 +103,65 @@ class RecentCommitView extends React.Component { ); } + + undoLastCommit = event => { + event.stopPropagation(); + this.props.undoLastCommit(); + } } export default class RecentCommitsView extends React.Component { static propTypes = { + // Model state commits: PropTypes.arrayOf(PropTypes.object).isRequired, isLoading: PropTypes.bool.isRequired, + selectedCommitSha: PropTypes.string.isRequired, + + // Atom environment + commandRegistry: PropTypes.object.isRequired, + + // Action methods undoLastCommit: PropTypes.func.isRequired, + openCommit: PropTypes.func.isRequired, + selectNextCommit: PropTypes.func.isRequired, + selectPreviousCommit: PropTypes.func.isRequired, + }; + + static focus = { + RECENT_COMMIT: Symbol('recent_commit'), }; + static firstFocus = RecentCommitsView.focus.RECENT_COMMIT; + + static lastFocus = RecentCommitsView.focus.RECENT_COMMIT; + + constructor(props) { + super(props); + this.refRoot = new RefHolder(); + } + + setFocus(focus) { + if (focus === this.constructor.focus.RECENT_COMMIT) { + return this.refRoot.map(element => element.focus()).getOr(false); + } + + return false; + } + + getFocus(element) { + return this.refRoot.map(e => e.contains(element)).getOr(false) + ? this.constructor.focus.RECENT_COMMIT + : null; + } + render() { return ( -
    +
    + + + + + {this.renderCommits()}
    ); @@ -115,12 +192,31 @@ export default class RecentCommitsView extends React.Component { isMostRecent={i === 0} commit={commit} undoLastCommit={this.props.undoLastCommit} + openCommit={() => this.props.openCommit({sha: commit.getSha(), preserveFocus: true})} + isSelected={this.props.selectedCommitSha === commit.getSha()} /> ); })} ); } + } + + openSelectedCommit = () => this.props.openCommit({sha: this.props.selectedCommitSha, preserveFocus: false}) + + advanceFocusFrom(focus) { + if (focus === this.constructor.focus.RECENT_COMMIT) { + return Promise.resolve(this.constructor.focus.RECENT_COMMIT); + } + + return Promise.resolve(null); + } + + retreatFocusFrom(focus) { + if (focus === this.constructor.focus.RECENT_COMMIT) { + return Promise.resolve(CommitView.lastFocus); + } + return Promise.resolve(null); } } diff --git a/lib/views/staging-view.js b/lib/views/staging-view.js index 8629ff89534..474db1aaa0c 100644 --- a/lib/views/staging-view.js +++ b/lib/views/staging-view.js @@ -12,6 +12,7 @@ import ObserveModel from './observe-model'; import MergeConflictListItemView from './merge-conflict-list-item-view'; import CompositeListSelection from '../models/composite-list-selection'; import ResolutionProgress from '../models/conflicts/resolution-progress'; +import CommitView from './commit-view'; import RefHolder from '../models/ref-holder'; import ChangedFileItem from '../items/changed-file-item'; import Commands, {Command} from '../atom/commands'; @@ -43,7 +44,7 @@ function calculateTruncatedLists(lists) { }, {source: {}}); } -const noop = () => {}; +const noop = () => { }; const MAXIMUM_LISTED_ENTRIES = 1000; @@ -76,6 +77,10 @@ export default class StagingView extends React.Component { STAGING: Symbol('staging'), }; + static firstFocus = StagingView.focus.STAGING; + + static lastFocus = StagingView.focus.STAGING; + constructor(props) { super(props); autobind( @@ -226,12 +231,12 @@ export default class StagingView extends React.Component {
    {this.renderTruncatedMessage(this.props.unstagedChanges)}
  • - { this.renderMergeConflicts() } + {this.renderMergeConflicts()}
    - Staged Changes + Staged Changes