diff --git a/docs/react-component-classification.md b/docs/react-component-classification.md
index 75c3f97fd12..05a5929ec37 100644
--- a/docs/react-component-classification.md
+++ b/docs/react-component-classification.md
@@ -6,7 +6,7 @@ This is a high-level summary of the organization and implementation of our React
**Items** are intended to be used as top-level components within subtrees that are rendered into some [Portal](https://reactjs.org/docs/portals.html) and passed to the Atom API, like pane items, dock items, or tooltips. They are mostly responsible for implementing the [Atom "item" contract](https://github.com/atom/atom/blob/a3631f0dafac146185289ac5e37eaff17b8b0209/src/workspace.js#L29-L174).
-These live within [`lib/items/`](/lib/items), are tested within [`test/items/`](/test/items), and are named with an `Item` suffix. Examples: `PullRequestDetailItem`, `FilePatchItem`.
+These live within [`lib/items/`](/lib/items), are tested within [`test/items/`](/test/items), and are named with an `Item` suffix. Examples: `PullRequestDetailItem`, `ChangedFileItem`.
## Containers
diff --git a/keymaps/git.cson b/keymaps/git.cson
index 97029fd5611..2075d945599 100644
--- a/keymaps/git.cson
+++ b/keymaps/git.cson
@@ -26,6 +26,11 @@
'shift-tab': 'core:focus-previous'
'o': 'github:open-file'
'left': 'core:move-left'
+ 'cmd-left': 'core:move-left'
+
+'.github-CommitView button':
+ 'tab': 'core:focus-next'
+ 'shift-tab': 'core:focus-previous'
'.github-StagingView.unstaged-changes-focused':
'cmd-backspace': 'github:discard-changes-in-selected-files'
diff --git a/lib/atom/uri-pattern.js b/lib/atom/uri-pattern.js
index 0a213f50e97..558a54f95d7 100644
--- a/lib/atom/uri-pattern.js
+++ b/lib/atom/uri-pattern.js
@@ -246,7 +246,7 @@ function dashEscape(raw) {
* Reverse the escaping performed by `dashEscape` by un-doubling `-` characters.
*/
function dashUnescape(escaped) {
- return escaped.replace('--', '-');
+ return escaped.replace(/--/g, '-');
}
/**
diff --git a/lib/containers/file-patch-container.js b/lib/containers/changed-file-container.js
similarity index 78%
rename from lib/containers/file-patch-container.js
rename to lib/containers/changed-file-container.js
index 3b3865f08ce..6ee00e7df33 100644
--- a/lib/containers/file-patch-container.js
+++ b/lib/containers/changed-file-container.js
@@ -5,9 +5,9 @@ import yubikiri from 'yubikiri';
import {autobind} from '../helpers';
import ObserveModel from '../views/observe-model';
import LoadingView from '../views/loading-view';
-import FilePatchController from '../controllers/file-patch-controller';
+import MultiFilePatchController from '../controllers/multi-file-patch-controller';
-export default class FilePatchContainer extends React.Component {
+export default class ChangedFileContainer extends React.Component {
static propTypes = {
repository: PropTypes.object.isRequired,
stagingStatus: PropTypes.oneOf(['staged', 'unstaged']),
@@ -30,8 +30,10 @@ export default class FilePatchContainer extends React.Component {
}
fetchData(repository) {
+ const staged = this.props.stagingStatus === 'staged';
+
return yubikiri({
- filePatch: repository.getFilePatchForPath(this.props.relPath, {staged: this.props.stagingStatus === 'staged'}),
+ multiFilePatch: repository.getChangedFilePatch(this.props.relPath, {staged}),
isPartiallyStaged: repository.isPartiallyStaged(this.props.relPath),
hasUndoHistory: repository.hasDiscardHistory(this.props.relPath),
});
@@ -51,10 +53,11 @@ export default class FilePatchContainer extends React.Component {
}
return (
-
);
diff --git a/lib/containers/commit-preview-container.js b/lib/containers/commit-preview-container.js
new file mode 100644
index 00000000000..877fa76cfbe
--- /dev/null
+++ b/lib/containers/commit-preview-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 MultiFilePatchController from '../controllers/multi-file-patch-controller';
+
+export default class CommitPreviewContainer extends React.Component {
+ static propTypes = {
+ repository: PropTypes.object.isRequired,
+ }
+
+ fetchData = repository => {
+ return yubikiri({
+ multiFilePatch: repository.getStagedChangesPatch(),
+ });
+ }
+
+ render() {
+ return (
+
+ {this.renderResult}
+
+ );
+ }
+
+ renderResult = data => {
+ if (this.props.repository.isLoading() || data === null) {
+ return ;
+ }
+
+ return (
+
+ );
+ }
+}
diff --git a/lib/controllers/commit-controller.js b/lib/controllers/commit-controller.js
index c4d637c56b9..059a7282bb0 100644
--- a/lib/controllers/commit-controller.js
+++ b/lib/controllers/commit-controller.js
@@ -8,7 +8,9 @@ import fs from 'fs-extra';
import CommitView from '../views/commit-view';
import RefHolder from '../models/ref-holder';
+import CommitPreviewItem from '../items/commit-preview-item';
import {AuthorPropType, UserStorePropType} from '../prop-types';
+import {watchWorkspaceItem} from '../watch-workspace-item';
import {autobind} from '../helpers';
import {addEvent} from '../reporter-proxy';
@@ -43,7 +45,8 @@ export default class CommitController extends React.Component {
constructor(props, context) {
super(props, context);
- autobind(this, 'commit', 'handleMessageChange', 'toggleExpandedCommitMessageEditor', 'grammarAdded');
+ autobind(this, 'commit', 'handleMessageChange', 'toggleExpandedCommitMessageEditor', 'grammarAdded',
+ 'toggleCommitPreview');
this.subscriptions = new CompositeDisposable();
this.refCommitView = new RefHolder();
@@ -52,6 +55,14 @@ export default class CommitController extends React.Component {
this.subscriptions.add(
this.commitMessageBuffer.onDidChange(this.handleMessageChange),
);
+
+ this.previewWatcher = watchWorkspaceItem(
+ this.props.workspace,
+ CommitPreviewItem.buildURI(this.props.repository.getWorkingDirectoryPath()),
+ this,
+ 'commitPreviewOpen',
+ );
+ this.subscriptions.add(this.previewWatcher);
}
componentDidMount() {
@@ -110,6 +121,8 @@ export default class CommitController extends React.Component {
userStore={this.props.userStore}
selectedCoAuthors={this.props.selectedCoAuthors}
updateSelectedCoAuthors={this.props.updateSelectedCoAuthors}
+ toggleCommitPreview={this.toggleCommitPreview}
+ commitPreviewOpen={this.state.commitPreviewOpen}
/>
);
}
@@ -120,6 +133,12 @@ export default class CommitController extends React.Component {
} else {
this.commitMessageBuffer.setTextViaDiff(this.getCommitMessage());
}
+
+ if (prevProps.repository !== this.props.repository) {
+ this.previewWatcher.setPattern(
+ CommitPreviewItem.buildURI(this.props.repository.getWorkingDirectoryPath()),
+ );
+ }
}
componentWillUnmount() {
@@ -249,12 +268,22 @@ export default class CommitController extends React.Component {
return this.refCommitView.map(view => view.setFocus(focus)).getOr(false);
}
- hasFocus() {
- return this.refCommitView.map(view => view.hasFocus()).getOr(false);
+ advanceFocus(...args) {
+ return this.refCommitView.map(view => view.advanceFocus(...args)).getOr(false);
}
- hasFocusEditor() {
- return this.refCommitView.map(view => view.hasFocusEditor()).getOr(false);
+ retreatFocus(...args) {
+ return this.refCommitView.map(view => view.retreatFocus(...args)).getOr(false);
+ }
+
+ hasFocusAtBeginning() {
+ return this.refCommitView.map(view => view.hasFocusAtBeginning()).getOr(false);
+ }
+
+ toggleCommitPreview() {
+ return this.props.workspace.toggle(
+ CommitPreviewItem.buildURI(this.props.repository.getWorkingDirectoryPath()),
+ );
}
}
diff --git a/lib/controllers/file-patch-controller.js b/lib/controllers/file-patch-controller.js
index 14dc10db171..ebda46ca87c 100644
--- a/lib/controllers/file-patch-controller.js
+++ b/lib/controllers/file-patch-controller.js
@@ -4,7 +4,7 @@ import path from 'path';
import {autobind, equalSets} from '../helpers';
import {addEvent} from '../reporter-proxy';
-import FilePatchItem from '../items/file-patch-item';
+import ChangedFileItem from '../items/changed-file-item';
import FilePatchView from '../views/file-patch-view';
export default class FilePatchController extends React.Component {
@@ -13,7 +13,7 @@ export default class FilePatchController extends React.Component {
stagingStatus: PropTypes.oneOf(['staged', 'unstaged']),
relPath: PropTypes.string.isRequired,
filePatch: PropTypes.object.isRequired,
- hasUndoHistory: PropTypes.bool.isRequired,
+ hasUndoHistory: PropTypes.bool,
workspace: PropTypes.object.isRequired,
commands: PropTypes.object.isRequired,
@@ -22,9 +22,11 @@ export default class FilePatchController extends React.Component {
config: PropTypes.object.isRequired,
destroy: PropTypes.func.isRequired,
- discardLines: PropTypes.func.isRequired,
- undoLastDiscard: PropTypes.func.isRequired,
- surfaceFileAtPath: PropTypes.func.isRequired,
+ discardLines: PropTypes.func,
+ undoLastDiscard: PropTypes.func,
+ surfaceFileAtPath: PropTypes.func,
+ handleClick: PropTypes.func,
+ isActive: PropTypes.bool,
}
constructor(props) {
@@ -96,7 +98,7 @@ export default class FilePatchController extends React.Component {
diveIntoMirrorPatch() {
const mirrorStatus = this.withStagingStatus({staged: 'unstaged', unstaged: 'staged'});
const workingDirectory = this.props.repository.getWorkingDirectoryPath();
- const uri = FilePatchItem.buildURI(this.props.relPath, workingDirectory, mirrorStatus);
+ const uri = ChangedFileItem.buildURI(this.props.relPath, workingDirectory, mirrorStatus);
this.props.destroy();
return this.props.workspace.open(uri);
diff --git a/lib/controllers/github-tab-controller.js b/lib/controllers/github-tab-controller.js
index f8eeb45b51b..153b723d280 100644
--- a/lib/controllers/github-tab-controller.js
+++ b/lib/controllers/github-tab-controller.js
@@ -19,7 +19,7 @@ export default class GitHubTabController extends React.Component {
allRemotes: RemoteSetPropType.isRequired,
branches: BranchSetPropType.isRequired,
selectedRemoteName: PropTypes.string,
- aheadCount: PropTypes.number.isRequired,
+ aheadCount: PropTypes.number,
pushInProgress: PropTypes.bool.isRequired,
isLoading: PropTypes.bool.isRequired,
}
diff --git a/lib/controllers/multi-file-patch-controller.js b/lib/controllers/multi-file-patch-controller.js
new file mode 100644
index 00000000000..0b8f3ecede7
--- /dev/null
+++ b/lib/controllers/multi-file-patch-controller.js
@@ -0,0 +1,41 @@
+import React from 'react';
+
+import {MultiFilePatchPropType} from '../prop-types';
+import FilePatchController from '../controllers/file-patch-controller';
+import {autobind} from '../helpers';
+
+export default class MultiFilePatchController extends React.Component {
+ static propTypes = {
+ multiFilePatch: MultiFilePatchPropType.isRequired,
+ }
+
+ constructor(props) {
+ super(props);
+ autobind(this, 'handleMouseDown');
+ const firstFilePatch = this.props.multiFilePatch.getFilePatches()[0];
+
+ this.state = {activeFilePatch: firstFilePatch ? firstFilePatch.getPath() : null};
+ }
+
+ handleMouseDown(relPath) {
+ this.setState({activeFilePatch: relPath});
+ }
+
+ render() {
+ return this.props.multiFilePatch.getFilePatches().map(filePatch => {
+ const relPath = filePatch.getPath();
+ const isActive = this.state.activeFilePatch === relPath;
+ return (
+
+ );
+ });
+ }
+}
diff --git a/lib/controllers/root-controller.js b/lib/controllers/root-controller.js
index 3b4a6ad9f0f..5622941a053 100644
--- a/lib/controllers/root-controller.js
+++ b/lib/controllers/root-controller.js
@@ -15,8 +15,9 @@ 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 FilePatchItem from '../items/file-patch-item';
+import ChangedFileItem from '../items/changed-file-item';
import IssueishDetailItem from '../items/issueish-detail-item';
+import CommitPreviewItem from '../items/commit-preview-item';
import GitTabItem from '../items/git-tab-item';
import GitHubTabItem from '../items/github-tab-item';
import StatusBarTileController from './status-bar-tile-controller';
@@ -128,6 +129,15 @@ export default class RootController extends React.Component {
return (
{devMode && }
+ {devMode && (
+ {
+ const workdir = this.props.repository.getWorkingDirectoryPath();
+ this.props.workspace.toggle(CommitPreviewItem.buildURI(workdir));
+ }}
+ />
+ )}
@@ -316,9 +326,9 @@ export default class RootController extends React.Component {
+ uriPattern={ChangedFileItem.uriPattern}>
{({itemHolder, params}) => (
-
)}
+
+ {({itemHolder, params}) => (
+
+ )}
+
{({itemHolder, params}) => (
{
+ /* 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 preview';
+ }
+
+ getIconName() {
+ return 'git-commit';
+ }
+
+ getWorkingDirectory() {
+ return this.props.workingDirectory;
+ }
+
+ serialize() {
+ return {
+ deserializer: 'CommitPreviewStub',
+ uri: CommitPreviewItem.buildURI(this.props.workingDirectory),
+ };
+ }
+}
diff --git a/lib/models/patch/builder.js b/lib/models/patch/builder.js
index a773e3d9768..c2bef5fe72e 100644
--- a/lib/models/patch/builder.js
+++ b/lib/models/patch/builder.js
@@ -5,8 +5,9 @@ import File, {nullFile} from './file';
import Patch from './patch';
import {Unchanged, Addition, Deletion, NoNewline} from './region';
import FilePatch from './file-patch';
+import MultiFilePatch from './multi-file-patch';
-export default function buildFilePatch(diffs) {
+export function buildFilePatch(diffs) {
if (diffs.length === 0) {
return emptyDiffFilePatch();
} else if (diffs.length === 1) {
@@ -18,6 +19,42 @@ export default function buildFilePatch(diffs) {
}
}
+export function buildMultiFilePatch(diffs) {
+ const byPath = new Map();
+ const filePatches = [];
+
+ let index = 0;
+ for (const diff of diffs) {
+ const thePath = diff.oldPath || diff.newPath;
+
+ if (diff.status === 'added' || diff.status === 'deleted') {
+ // Potential paired diff. Either a symlink deletion + content addition or a symlink addition +
+ // content deletion.
+ const otherHalf = byPath.get(thePath);
+ if (otherHalf) {
+ // The second half. Complete the paired diff, or fail if they have unexpected statuses or modes.
+ const [otherDiff, otherIndex] = otherHalf;
+ filePatches[otherIndex] = dualDiffFilePatch(diff, otherDiff);
+ byPath.delete(thePath);
+ } else {
+ // The first half we've seen.
+ byPath.set(thePath, [diff, index]);
+ index++;
+ }
+ } else {
+ filePatches[index] = singleDiffFilePatch(diff);
+ index++;
+ }
+ }
+
+ // Populate unpaired diffs that looked like they could be part of a pair, but weren't.
+ for (const [unpairedDiff, originalIndex] of byPath.values()) {
+ filePatches[originalIndex] = singleDiffFilePatch(unpairedDiff);
+ }
+
+ return new MultiFilePatch(filePatches);
+}
+
function emptyDiffFilePatch() {
return FilePatch.createNull();
}
diff --git a/lib/models/patch/index.js b/lib/models/patch/index.js
index 596fcfde501..525043dbc41 100644
--- a/lib/models/patch/index.js
+++ b/lib/models/patch/index.js
@@ -1 +1 @@
-export {default as buildFilePatch} from './builder';
+export {buildFilePatch, buildMultiFilePatch} from './builder';
diff --git a/lib/models/patch/multi-file-patch.js b/lib/models/patch/multi-file-patch.js
new file mode 100644
index 00000000000..b47b90f5450
--- /dev/null
+++ b/lib/models/patch/multi-file-patch.js
@@ -0,0 +1,9 @@
+export default class MultiFilePatch {
+ constructor(filePatches) {
+ this.filePatches = filePatches;
+ }
+
+ getFilePatches() {
+ return this.filePatches;
+ }
+}
diff --git a/lib/models/repository-states/present.js b/lib/models/repository-states/present.js
index ed25c56e497..863c59a1e3d 100644
--- a/lib/models/repository-states/present.js
+++ b/lib/models/repository-states/present.js
@@ -6,7 +6,7 @@ import State from './state';
import {LargeRepoError} from '../../git-shell-out-strategy';
import {FOCUS} from '../workspace-change-observer';
-import {buildFilePatch} from '../patch';
+import {buildFilePatch, buildMultiFilePatch} from '../patch';
import DiscardHistory from '../discard-history';
import Branch, {nullBranch} from '../branch';
import Author from '../author';
@@ -14,6 +14,7 @@ import BranchSet from '../branch-set';
import Remote from '../remote';
import RemoteSet from '../remote-set';
import Commit from '../commit';
+import MultiFilePatch from '../patch/multi-file-patch';
import OperationStates from '../operation-states';
import {addEvent} from '../../reporter-proxy';
@@ -92,6 +93,7 @@ export default class Present extends State {
const includes = (...segments) => fullPath.includes(path.join(...segments));
if (endsWith('.git', 'index')) {
+ keys.add(Keys.stagedChanges);
keys.add(Keys.stagedChangesSinceParentCommit);
keys.add(Keys.filePatch.all);
keys.add(Keys.index.all);
@@ -231,6 +233,7 @@ export default class Present extends State {
...Keys.filePatch.eachWithOpts({staged: true}),
Keys.headDescription,
Keys.branches,
+ Keys.stagedChanges,
],
// eslint-disable-next-line no-shadow
() => this.executePipelineAction('COMMIT', async (message, options = {}) => {
@@ -276,6 +279,7 @@ export default class Present extends State {
return this.invalidate(
() => [
Keys.statusBundle,
+ Keys.stagedChanges,
Keys.stagedChangesSinceParentCommit,
Keys.filePatch.all,
Keys.index.all,
@@ -297,6 +301,7 @@ export default class Present extends State {
() => [
Keys.statusBundle,
Keys.stagedChangesSinceParentCommit,
+ Keys.stagedChanges,
...Keys.filePatch.eachWithFileOpts([filePath], [{staged: false}, {staged: true}]),
Keys.index.oneWith(filePath),
],
@@ -309,6 +314,7 @@ export default class Present extends State {
checkout(revision, options = {}) {
return this.invalidate(
() => [
+ Keys.stagedChanges,
Keys.stagedChangesSinceParentCommit,
Keys.lastCommit,
Keys.recentCommits,
@@ -330,6 +336,7 @@ export default class Present extends State {
return this.invalidate(
() => [
Keys.statusBundle,
+ Keys.stagedChanges,
Keys.stagedChangesSinceParentCommit,
...paths.map(fileName => Keys.index.oneWith(fileName)),
...Keys.filePatch.eachWithFileOpts(paths, [{staged: true}]),
@@ -619,6 +626,11 @@ export default class Present extends State {
return {stagedFiles, unstagedFiles, mergeConflictFiles};
}
+ // hack hack hack
+ async getChangedFilePatch(...args) {
+ return new MultiFilePatch([await this.getFilePatchForPath(...args)]);
+ }
+
getFilePatchForPath(filePath, {staged} = {staged: false}) {
return this.cache.getOrSet(Keys.filePatch.oneWith(filePath, {staged}), async () => {
const diffs = await this.git().getDiffsForFilePath(filePath, {staged});
@@ -626,6 +638,12 @@ export default class Present extends State {
});
}
+ getStagedChangesPatch() {
+ return this.cache.getOrSet(Keys.stagedChanges, () => {
+ return this.git().getStagedChangesPatch().then(buildMultiFilePatch);
+ });
+ }
+
readFileFromIndex(filePath) {
return this.cache.getOrSet(Keys.index.oneWith(filePath), () => {
return this.git().readFileFromIndex(filePath);
@@ -950,6 +968,8 @@ class GroupKey {
const Keys = {
statusBundle: new CacheKey('status-bundle'),
+ stagedChanges: new CacheKey('staged-changes'),
+
stagedChangesSinceParentCommit: new CacheKey('staged-changes-since-parent-commit'),
filePatch: {
@@ -1029,6 +1049,7 @@ const Keys = {
...Keys.workdirOperationKeys(fileNames),
...Keys.filePatch.eachWithFileOpts(fileNames, [{staged: true}]),
...fileNames.map(Keys.index.oneWith),
+ Keys.stagedChanges,
Keys.stagedChangesSinceParentCommit,
],
diff --git a/lib/models/repository-states/state.js b/lib/models/repository-states/state.js
index dc3b8e9bb4f..791b52174d9 100644
--- a/lib/models/repository-states/state.js
+++ b/lib/models/repository-states/state.js
@@ -3,6 +3,7 @@ import BranchSet from '../branch-set';
import RemoteSet from '../remote-set';
import {nullOperationStates} from '../operation-states';
import FilePatch from '../patch/file-patch';
+import MultiFilePatch from '../patch/multi-file-patch';
/**
* Map of registered subclasses to allow states to transition to one another without circular dependencies.
@@ -278,6 +279,14 @@ export default class State {
return Promise.resolve(FilePatch.createNull());
}
+ getChangedFilePatch() {
+ return Promise.resolve(new MultiFilePatch([]));
+ }
+
+ getStagedChangesPatch() {
+ return Promise.resolve(new MultiFilePatch([]));
+ }
+
readFileFromIndex(filePath) {
return Promise.reject(new Error(`fatal: Path ${filePath} does not exist (neither on disk nor in the index).`));
}
diff --git a/lib/models/repository.js b/lib/models/repository.js
index ab50db5f527..08970a981d6 100644
--- a/lib/models/repository.js
+++ b/lib/models/repository.js
@@ -326,7 +326,9 @@ const delegates = [
'getStatusBundle',
'getStatusesForChangedFiles',
'getFilePatchForPath',
+ 'getStagedChangesPatch',
'readFileFromIndex',
+ 'getChangedFilePatch',
'getLastCommit',
'getRecentCommits',
diff --git a/lib/prop-types.js b/lib/prop-types.js
index 13d0b4d394c..f00c709f8f3 100644
--- a/lib/prop-types.js
+++ b/lib/prop-types.js
@@ -130,6 +130,10 @@ export const FilePatchItemPropType = PropTypes.shape({
status: PropTypes.string.isRequired,
});
+export const MultiFilePatchPropType = PropTypes.shape({
+ getFilePatches: PropTypes.func.isRequired,
+});
+
const statusNames = [
'added',
'deleted',
diff --git a/lib/views/commit-view.js b/lib/views/commit-view.js
index 7690da29d95..69017069154 100644
--- a/lib/views/commit-view.js
+++ b/lib/views/commit-view.js
@@ -23,6 +23,7 @@ let FakeKeyDownEvent;
export default class CommitView extends React.Component {
static focus = {
+ COMMIT_PREVIEW_BUTTON: Symbol('commit-preview-button'),
EDITOR: Symbol('commit-editor'),
COAUTHOR_INPUT: Symbol('coauthor-input'),
ABORT_MERGE_BUTTON: Symbol('commit-abort-merge-button'),
@@ -41,6 +42,7 @@ export default class CommitView extends React.Component {
mergeConflictsExist: PropTypes.bool.isRequired,
stagedChangesExist: PropTypes.bool.isRequired,
isCommitting: PropTypes.bool.isRequired,
+ commitPreviewOpen: PropTypes.bool.isRequired,
deactivateCommitBox: PropTypes.bool.isRequired,
maximumCharacterLimit: PropTypes.number.isRequired,
messageBuffer: PropTypes.object.isRequired, // FIXME more specific proptype
@@ -51,6 +53,7 @@ export default class CommitView extends React.Component {
abortMerge: PropTypes.func.isRequired,
prepareToCommit: PropTypes.func.isRequired,
toggleExpandedCommitMessageEditor: PropTypes.func.isRequired,
+ toggleCommitPreview: PropTypes.func.isRequired,
};
constructor(props, context) {
@@ -73,6 +76,7 @@ export default class CommitView extends React.Component {
this.subscriptions = new CompositeDisposable();
this.refRoot = new RefHolder();
+ this.refCommitPreviewButton = new RefHolder();
this.refExpandButton = new RefHolder();
this.refCommitButton = new RefHolder();
this.refHardWrapButton = new RefHolder();
@@ -157,6 +161,15 @@ export default class CommitView extends React.Component {
+
+
+
}
@@ -551,26 +564,38 @@ export default class CommitView extends React.Component {
return this.refEditorComponent.map(editor => editor.contains(document.activeElement)).getOr(false);
}
- rememberFocus(event) {
- if (this.refEditorComponent.map(editor => editor.contains(event.target)).getOr(false)) {
+ hasFocusAtBeginning() {
+ return this.refCommitPreviewButton.map(button => button.contains(document.activeElement)).getOr(false);
+ }
+
+ getFocus(element = document.activeElement) {
+ if (this.refCommitPreviewButton.map(button => button.contains(element)).getOr(false)) {
+ return CommitView.focus.COMMIT_PREVIEW_BUTTON;
+ }
+
+ if (this.refEditorComponent.map(editor => editor.contains(element)).getOr(false)) {
return CommitView.focus.EDITOR;
}
- if (this.refAbortMergeButton.map(e => e.contains(event.target)).getOr(false)) {
+ if (this.refAbortMergeButton.map(e => e.contains(element)).getOr(false)) {
return CommitView.focus.ABORT_MERGE_BUTTON;
}
- if (this.refCommitButton.map(e => e.contains(event.target)).getOr(false)) {
+ if (this.refCommitButton.map(e => e.contains(element)).getOr(false)) {
return CommitView.focus.COMMIT_BUTTON;
}
- if (this.refCoAuthorSelect.map(c => c.wrapper && c.wrapper.contains(event.target)).getOr(false)) {
+ if (this.refCoAuthorSelect.map(c => c.wrapper && c.wrapper.contains(element)).getOr(false)) {
return CommitView.focus.COAUTHOR_INPUT;
}
return null;
}
+ rememberFocus(event) {
+ return this.getFocus(event.target);
+ }
+
setFocus(focus) {
let fallback = false;
const focusElement = element => {
@@ -578,6 +603,12 @@ export default class CommitView extends React.Component {
return true;
};
+ if (focus === CommitView.focus.COMMIT_PREVIEW_BUTTON) {
+ if (this.refCommitPreviewButton.map(focusElement).getOr(false)) {
+ return true;
+ }
+ }
+
if (focus === CommitView.focus.EDITOR) {
if (this.refEditorComponent.map(focusElement).getOr(false)) {
return true;
@@ -611,4 +642,78 @@ export default class CommitView extends React.Component {
return false;
}
+
+ advanceFocus(event) {
+ 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) {
+ case f.COMMIT_PREVIEW_BUTTON:
+ next = f.EDITOR;
+ break;
+ case f.COAUTHOR_INPUT:
+ next = this.props.isMerging ? f.ABORT_MERGE_BUTTON : f.COMMIT_BUTTON;
+ break;
+ case f.ABORT_MERGE_BUTTON:
+ next = f.COMMIT_BUTTON;
+ break;
+ case f.COMMIT_BUTTON:
+ // End of tab navigation. Prevent cycling.
+ event.stopPropagation();
+ return true;
+ }
+
+ if (next !== null) {
+ this.setFocus(next);
+ event.stopPropagation();
+
+ return true;
+ } else {
+ return false;
+ }
+ }
+
+ retreatFocus(event) {
+ const f = this.constructor.focus;
+ const current = this.getFocus();
+
+ let next = null;
+ switch (current) {
+ case f.COMMIT_BUTTON:
+ if (this.props.isMerging) {
+ next = f.ABORT_MERGE_BUTTON;
+ } else if (this.state.showCoAuthorInput) {
+ next = f.COAUTHOR_INPUT;
+ } else {
+ next = f.EDITOR;
+ }
+ break;
+ case f.ABORT_MERGE_BUTTON:
+ next = this.state.showCoAuthorInput ? f.COAUTHOR_INPUT : f.EDITOR;
+ break;
+ case f.COAUTHOR_INPUT:
+ next = f.EDITOR;
+ break;
+ case f.EDITOR:
+ next = f.COMMIT_PREVIEW_BUTTON;
+ break;
+ case f.COMMIT_PREVIEW_BUTTON:
+ // Allow the GitTabView to retreat focus back to the last StagingView list.
+ return false;
+ }
+
+ if (next !== null) {
+ this.setFocus(next);
+ event.stopPropagation();
+
+ return true;
+ } else {
+ return false;
+ }
+ }
}
diff --git a/lib/views/file-patch-view.js b/lib/views/file-patch-view.js
index 8736496e217..4cbf23538a3 100644
--- a/lib/views/file-patch-view.js
+++ b/lib/views/file-patch-view.js
@@ -35,6 +35,8 @@ export default class FilePatchView extends React.Component {
selectedRows: PropTypes.object.isRequired,
repository: PropTypes.object.isRequired,
hasUndoHistory: PropTypes.bool.isRequired,
+ useEditorAutoHeight: PropTypes.bool,
+ isActive: PropTypes.bool.isRequired,
workspace: PropTypes.object.isRequired,
commands: PropTypes.object.isRequired,
@@ -53,6 +55,11 @@ export default class FilePatchView extends React.Component {
toggleSymlinkChange: PropTypes.func.isRequired,
undoLastDiscard: PropTypes.func.isRequired,
discardRows: PropTypes.func.isRequired,
+ handleMouseDown: PropTypes.func,
+ }
+
+ static defaultProps = {
+ useEditorAutoHeight: false,
}
constructor(props) {
@@ -62,7 +69,7 @@ export default class FilePatchView extends React.Component {
'didMouseDownOnHeader', 'didMouseDownOnLineNumber', 'didMouseMoveOnLineNumber', 'didMouseUp',
'didConfirm', 'didToggleSelectionMode', 'selectNextHunk', 'selectPreviousHunk',
'didOpenFile', 'didAddSelection', 'didChangeSelectionRange', 'didDestroySelection',
- 'oldLineNumberLabel', 'newLineNumberLabel',
+ 'oldLineNumberLabel', 'newLineNumberLabel', 'handleMouseDown',
);
this.mouseSelectionInProgress = false;
@@ -147,16 +154,22 @@ export default class FilePatchView extends React.Component {
this.subs.dispose();
}
+ handleMouseDown() {
+ this.props.handleMouseDown(this.props.relPath);
+ }
+
render() {
const rootClass = cx(
'github-FilePatchView',
`github-FilePatchView--${this.props.stagingStatus}`,
{'github-FilePatchView--blank': !this.props.filePatch.isPresent()},
{'github-FilePatchView--hunkMode': this.props.selectionMode === 'hunk'},
+ {'github-FilePatchView--active': this.props.isActive},
+ {'github-FilePatchView--inactive': !this.props.isActive},
);
return (
-
+
{this.renderCommands()}
@@ -228,7 +241,7 @@ export default class FilePatchView extends React.Component {
buffer={this.props.filePatch.getBuffer()}
lineNumberGutterVisible={false}
autoWidth={false}
- autoHeight={false}
+ autoHeight={this.props.useEditorAutoHeight}
readOnly={true}
softWrapped={true}
@@ -429,7 +442,7 @@ export default class FilePatchView extends React.Component {
{this.props.filePatch.getHunks().map((hunk, index) => {
const containsSelection = this.props.selectionMode === 'line' && selectedHunks.has(hunk);
- const isSelected = this.props.selectionMode === 'hunk' && selectedHunks.has(hunk);
+ const isSelected = this.props.isActive && (this.props.selectionMode === 'hunk') && selectedHunks.has(hunk);
let buttonSuffix = '';
if (containsSelection) {
@@ -482,6 +495,9 @@ export default class FilePatchView extends React.Component {
if (ranges.length === 0) {
return null;
}
+ if (!this.props.isActive) {
+ return null;
+ }
const holder = refHolder || new RefHolder();
return (
diff --git a/lib/views/git-tab-view.js b/lib/views/git-tab-view.js
index d748e3bf71b..d89237e76d1 100644
--- a/lib/views/git-tab-view.js
+++ b/lib/views/git-tab-view.js
@@ -248,30 +248,38 @@ export default class GitTabView extends React.Component {
}
async advanceFocus(evt) {
- // The commit controller manages its own focus
- if (this.refCommitController.map(c => c.hasFocus()).getOr(false)) {
+ // Advance focus within the CommitView if it's there
+ if (this.refCommitController.map(c => c.advanceFocus(evt)).getOr(false)) {
return;
}
+ // 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();
- } else {
- if (this.refCommitController.map(c => c.setFocus(GitTabView.focus.EDITOR)).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();
}
}
async retreatFocus(evt) {
- if (this.refCommitController.map(c => c.hasFocus()).getOr(false)) {
- // if the commit editor is focused, focus the last staging view list
- if (this.refCommitController.map(c => c.hasFocusEditor()).getOr(false) &&
- await this.props.refStagingView.map(view => view.activateLastList()).getOr(null)
- ) {
+ // Retreat focus within the CommitView if it's there
+ if (this.refCommitController.map(c => c.retreatFocus(evt)).getOr(false)) {
+ return;
+ }
+
+ 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);
evt.stopPropagation();
}
} else if (await this.props.refStagingView.map(c => c.activatePreviousList()).getOr(null)) {
+ // Retreat focus within the StagingView
+ this.setFocus(GitTabView.focus.STAGING);
evt.stopPropagation();
}
}
diff --git a/lib/views/github-tab-view.js b/lib/views/github-tab-view.js
index cac4e26b2ca..4c8bc73a4be 100644
--- a/lib/views/github-tab-view.js
+++ b/lib/views/github-tab-view.js
@@ -22,7 +22,7 @@ export default class GitHubTabView extends React.Component {
remotes: RemoteSetPropType.isRequired,
currentRemote: RemotePropType.isRequired,
manyRemotesAvailable: PropTypes.bool.isRequired,
- aheadCount: PropTypes.number.isRequired,
+ aheadCount: PropTypes.number,
pushInProgress: PropTypes.bool.isRequired,
isLoading: PropTypes.bool.isRequired,
diff --git a/lib/views/staging-view.js b/lib/views/staging-view.js
index fae58d5cf04..23f35e96cc4 100644
--- a/lib/views/staging-view.js
+++ b/lib/views/staging-view.js
@@ -13,7 +13,7 @@ import MergeConflictListItemView from './merge-conflict-list-item-view';
import CompositeListSelection from '../models/composite-list-selection';
import ResolutionProgress from '../models/conflicts/resolution-progress';
import RefHolder from '../models/ref-holder';
-import FilePatchItem from '../items/file-patch-item';
+import ChangedFileItem from '../items/changed-file-item';
import Commands, {Command} from '../atom/commands';
import {autobind} from '../helpers';
import {addEvent} from '../reporter-proxy';
@@ -743,7 +743,7 @@ export default class StagingView extends React.Component {
const activePane = this.props.workspace.getCenter().getActivePane();
const activePendingItem = activePane.getPendingItem();
const activePaneHasPendingFilePatchItem = activePendingItem && activePendingItem.getRealItem &&
- activePendingItem.getRealItem() instanceof FilePatchItem;
+ activePendingItem.getRealItem() instanceof ChangedFileItem;
if (activePaneHasPendingFilePatchItem) {
await this.showFilePatchItem(selectedItem.filePath, this.state.selection.getActiveListKey(), {
activate: false,
@@ -762,7 +762,7 @@ export default class StagingView extends React.Component {
const pendingItem = pane.getPendingItem();
if (!pendingItem || !pendingItem.getRealItem) { return false; }
const realItem = pendingItem.getRealItem();
- const isDiffViewItem = realItem instanceof FilePatchItem;
+ const isDiffViewItem = realItem instanceof ChangedFileItem;
// We only want to update pending diff views for currently active repo
const isInActiveRepo = realItem.getWorkingDirectory() === this.props.workingDirectoryPath;
const isStale = !this.changedFileExists(realItem.getFilePath(), realItem.getStagingStatus());
@@ -777,19 +777,19 @@ export default class StagingView extends React.Component {
}
async showFilePatchItem(filePath, stagingStatus, {activate, pane} = {activate: false}) {
- const uri = FilePatchItem.buildURI(filePath, this.props.workingDirectoryPath, stagingStatus);
- const filePatchItem = await this.props.workspace.open(
+ const uri = ChangedFileItem.buildURI(filePath, this.props.workingDirectoryPath, stagingStatus);
+ const changedFileItem = await this.props.workspace.open(
uri, {pending: true, activatePane: activate, activateItem: activate, pane},
);
if (activate) {
- const itemRoot = filePatchItem.getElement();
+ const itemRoot = changedFileItem.getElement();
const focusRoot = itemRoot.querySelector('[tabIndex]');
if (focusRoot) {
focusRoot.focus();
}
} else {
// simply make item visible
- this.props.workspace.paneForItem(filePatchItem).activateItem(filePatchItem);
+ this.props.workspace.paneForItem(changedFileItem).activateItem(changedFileItem);
}
}
diff --git a/lib/watch-workspace-item.js b/lib/watch-workspace-item.js
new file mode 100644
index 00000000000..e55fdae9ba1
--- /dev/null
+++ b/lib/watch-workspace-item.js
@@ -0,0 +1,88 @@
+import {CompositeDisposable} from 'atom';
+
+import URIPattern from './atom/uri-pattern';
+
+class ItemWatcher {
+ constructor(workspace, pattern, component, stateKey) {
+ this.workspace = workspace;
+ this.pattern = pattern instanceof URIPattern ? pattern : new URIPattern(pattern);
+ this.component = component;
+ this.stateKey = stateKey;
+
+ this.itemCount = this.getItemCount();
+ this.subs = new CompositeDisposable();
+ }
+
+ setInitialState() {
+ if (!this.component.state) {
+ this.component.state = {};
+ }
+ this.component.state[this.stateKey] = this.itemCount > 0;
+ return this;
+ }
+
+ subscribeToWorkspace() {
+ this.subs.dispose();
+ this.subs = new CompositeDisposable(
+ this.workspace.onDidAddPaneItem(this.itemAdded),
+ this.workspace.onDidDestroyPaneItem(this.itemDestroyed),
+ );
+ return this;
+ }
+
+ setPattern(pattern) {
+ const wasTrue = this.itemCount > 0;
+
+ this.pattern = pattern instanceof URIPattern ? pattern : new URIPattern(pattern);
+
+ // Update the item count to match the new pattern
+ this.itemCount = this.getItemCount();
+
+ // Update the component's state if it's changed as a result
+ if (wasTrue && this.itemCount <= 0) {
+ return new Promise(resolve => this.component.setState({[this.stateKey]: false}, resolve));
+ } else if (!wasTrue && this.itemCount > 0) {
+ return new Promise(resolve => this.component.setState({[this.stateKey]: true}, resolve));
+ } else {
+ return Promise.resolve();
+ }
+ }
+
+ itemMatches = item => item.getURI && this.pattern.matches(item.getURI()).ok()
+
+ getItemCount() {
+ return this.workspace.getPaneItems().filter(this.itemMatches).length;
+ }
+
+ itemAdded = ({item}) => {
+ const hadOpen = this.itemCount > 0;
+ if (this.itemMatches(item)) {
+ this.itemCount++;
+
+ if (this.itemCount > 0 && !hadOpen) {
+ this.component.setState({[this.stateKey]: true});
+ }
+ }
+ }
+
+ itemDestroyed = ({item}) => {
+ const hadOpen = this.itemCount > 0;
+ if (this.itemMatches(item)) {
+ this.itemCount--;
+
+ if (this.itemCount <= 0 && hadOpen) {
+ this.component.setState({[this.stateKey]: false});
+ }
+ }
+ }
+
+ dispose() {
+ this.subs.dispose();
+ }
+}
+
+export function watchWorkspaceItem(workspace, pattern, component, stateKey) {
+ return new ItemWatcher(workspace, pattern, component, stateKey)
+ .setInitialState()
+ .subscribeToWorkspace();
+}
diff --git a/package.json b/package.json
index 3504ad6e5f3..3617fd9f9d6 100644
--- a/package.json
+++ b/package.json
@@ -195,6 +195,7 @@
"IssueishPaneItem": "createIssueishPaneItemStub",
"GitDockItem": "createDockItemStub",
"GithubDockItem": "createDockItemStub",
- "FilePatchControllerStub": "createFilePatchControllerStub"
+ "FilePatchControllerStub": "createFilePatchControllerStub",
+ "CommitPreviewStub": "createCommitPreviewStub"
}
}
diff --git a/styles/commit-preview-view.less b/styles/commit-preview-view.less
new file mode 100644
index 00000000000..a0fa33e21b7
--- /dev/null
+++ b/styles/commit-preview-view.less
@@ -0,0 +1,21 @@
+@import "variables";
+
+.github-CommitPreview-root {
+ overflow: auto;
+ z-index: 1; // Fixes scrollbar on macOS
+
+ .github-FilePatchView {
+ height: auto;
+ border-bottom: 1px solid @base-border-color;
+
+ &:last-child {
+ border-bottom: none;
+ }
+
+ & + .github-FilePatchView {
+ margin-top: @component-padding;
+ border-top: 1px solid @base-border-color;
+ }
+ }
+
+}
diff --git a/styles/commit-view.less b/styles/commit-view.less
index b97f49b6f7f..5151292b49c 100644
--- a/styles/commit-view.less
+++ b/styles/commit-view.less
@@ -79,6 +79,12 @@
}
}
+ &-buttonWrapper {
+ align-items: center;
+ display: flex;
+ margin-bottom: 10px;
+ }
+
&-coAuthorEditor {
position: relative;
margin-top: @component-padding / 2;
diff --git a/styles/file-patch-view.less b/styles/file-patch-view.less
index c04a3445b02..8fe131c6c03 100644
--- a/styles/file-patch-view.less
+++ b/styles/file-patch-view.less
@@ -163,7 +163,7 @@
.hunk-line-mixin(@bg;) {
background-color: fade(@bg, 18%);
- &.line.cursor-line {
+ .github-FilePatchView--active &.line.cursor-line {
background-color: fade(@bg, 28%);
}
}
@@ -225,4 +225,10 @@
}
}
}
+
+ // Inactive
+
+ &--inactive .highlights .highlight.selection {
+ display: none;
+ }
}
diff --git a/test/atom/uri-pattern.test.js b/test/atom/uri-pattern.test.js
index 4cb77a52ade..a36f332401c 100644
--- a/test/atom/uri-pattern.test.js
+++ b/test/atom/uri-pattern.test.js
@@ -38,6 +38,14 @@ describe('URIPattern', function() {
assert.isTrue(pattern.matches('proto://host/foo#exact').ok());
assert.isFalse(pattern.matches('proto://host/foo#nope').ok());
});
+
+ it('escapes and unescapes dashes', function() {
+ assert.isTrue(
+ new URIPattern('atom-github://with-many-dashes')
+ .matches('atom-github://with-many-dashes')
+ .ok(),
+ );
+ });
});
describe('parameter placeholders', function() {
diff --git a/test/containers/file-patch-container.test.js b/test/containers/changed-file-container.test.js
similarity index 94%
rename from test/containers/file-patch-container.test.js
rename to test/containers/changed-file-container.test.js
index a4e12a65f58..40d4c09b216 100644
--- a/test/containers/file-patch-container.test.js
+++ b/test/containers/changed-file-container.test.js
@@ -3,10 +3,10 @@ import fs from 'fs-extra';
import React from 'react';
import {mount} from 'enzyme';
-import FilePatchContainer from '../../lib/containers/file-patch-container';
+import ChangedFileContainer from '../../lib/containers/changed-file-container';
import {cloneRepository, buildRepository} from '../helpers';
-describe('FilePatchContainer', function() {
+describe('ChangedFileContainer', function() {
let atomEnv, repository;
beforeEach(async function() {
@@ -46,7 +46,7 @@ describe('FilePatchContainer', function() {
...overrideProps,
};
- return ;
+ return ;
}
it('renders a loading spinner before file patch data arrives', function() {
diff --git a/test/containers/commit-preview-container.test.js b/test/containers/commit-preview-container.test.js
new file mode 100644
index 00000000000..b33b11f169d
--- /dev/null
+++ b/test/containers/commit-preview-container.test.js
@@ -0,0 +1,59 @@
+import React from 'react';
+import {mount} from 'enzyme';
+
+import CommitPreviewContainer from '../../lib/containers/commit-preview-container';
+import {cloneRepository, buildRepository} from '../helpers';
+
+describe('CommitPreviewContainer', function() {
+ let atomEnv, repository;
+
+ beforeEach(async function() {
+ atomEnv = global.buildAtomEnvironment();
+
+ const workdir = await cloneRepository();
+ repository = await buildRepository(workdir);
+ });
+
+ afterEach(function() {
+ atomEnv.destroy();
+ });
+
+ function buildApp(override = {}) {
+ const props = {
+ repository,
+ ...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 patchPromise = repository.getStagedChangesPatch();
+ let resolveDelayedPromise = () => {};
+ const delayedPromise = new Promise(resolve => {
+ resolveDelayedPromise = resolve;
+ });
+ sinon.stub(repository, 'getStagedChangesPatch').returns(delayedPromise);
+
+ const wrapper = mount(buildApp());
+
+ assert.isTrue(wrapper.find('LoadingView').exists());
+ resolveDelayedPromise(patchPromise);
+ await assert.async.isFalse(wrapper.update().find('LoadingView').exists());
+ });
+
+ it('renders a MultiFilePatchController once the file patch is loaded', async function() {
+ await repository.getLoadPromise();
+ const patch = await repository.getStagedChangesPatch();
+
+ const wrapper = mount(buildApp());
+ await assert.async.isTrue(wrapper.update().find('MultiFilePatchController').exists());
+ assert.strictEqual(wrapper.find('MultiFilePatchController').prop('multiFilePatch'), patch);
+ });
+});
diff --git a/test/controllers/commit-controller.test.js b/test/controllers/commit-controller.test.js
index 3e716ea10da..8e450e80dc3 100644
--- a/test/controllers/commit-controller.test.js
+++ b/test/controllers/commit-controller.test.js
@@ -6,8 +6,10 @@ import {shallow, mount} from 'enzyme';
import Commit from '../../lib/models/commit';
import {nullBranch} from '../../lib/models/branch';
import UserStore from '../../lib/models/user-store';
+import URIPattern from '../../lib/atom/uri-pattern';
import CommitController, {COMMIT_GRAMMAR_SCOPE} from '../../lib/controllers/commit-controller';
+import CommitPreviewItem from '../../lib/items/commit-preview-item';
import {cloneRepository, buildRepository, buildRepositoryWithPipeline} from '../helpers';
import * as reporterProxy from '../../lib/reporter-proxy';
@@ -28,6 +30,16 @@ describe('CommitController', function() {
const noop = () => {};
const store = new UserStore({config});
+ // Ensure the Workspace doesn't mangle atom-github://... URIs
+ const pattern = new URIPattern(CommitPreviewItem.uriPattern);
+ workspace.addOpener(uri => {
+ if (pattern.matches(uri).ok()) {
+ return {getURI() { return uri; }};
+ } else {
+ return undefined;
+ }
+ });
+
app = (
item.getURI() === CommitPreviewItem.buildURI(workdir0)));
+ assert.isFalse(workspace.getPaneItems().some(item => item.getURI() === CommitPreviewItem.buildURI(workdir1)));
+ assert.isTrue(wrapper.find('CommitView').prop('commitPreviewOpen'));
+
+ wrapper.setProps({repository: repository1});
+ assert.isTrue(workspace.getPaneItems().some(item => item.getURI() === CommitPreviewItem.buildURI(workdir0)));
+ assert.isFalse(workspace.getPaneItems().some(item => item.getURI() === CommitPreviewItem.buildURI(workdir1)));
+ assert.isFalse(wrapper.find('CommitView').prop('commitPreviewOpen'));
+
+ await wrapper.find('CommitView').prop('toggleCommitPreview')();
+ assert.isTrue(workspace.getPaneItems().some(item => item.getURI() === CommitPreviewItem.buildURI(workdir0)));
+ assert.isTrue(workspace.getPaneItems().some(item => item.getURI() === CommitPreviewItem.buildURI(workdir1)));
+ assert.isTrue(wrapper.find('CommitView').prop('commitPreviewOpen'));
+
+ await wrapper.find('CommitView').prop('toggleCommitPreview')();
+ assert.isTrue(workspace.getPaneItems().some(item => item.getURI() === CommitPreviewItem.buildURI(workdir0)));
+ assert.isFalse(workspace.getPaneItems().some(item => item.getURI() === CommitPreviewItem.buildURI(workdir1)));
+ assert.isFalse(wrapper.find('CommitView').prop('commitPreviewOpen'));
});
});
});
diff --git a/test/controllers/git-tab-controller.test.js b/test/controllers/git-tab-controller.test.js
index 27838dd4274..3b2e059f87a 100644
--- a/test/controllers/git-tab-controller.test.js
+++ b/test/controllers/git-tab-controller.test.js
@@ -3,7 +3,6 @@ import path from 'path';
import React from 'react';
import {mount} from 'enzyme';
import dedent from 'dedent-js';
-import until from 'test-until';
import GitTabController from '../../lib/controllers/git-tab-controller';
import {gitTabControllerProps} from '../fixtures/props/git-tab-props';
@@ -277,165 +276,6 @@ describe('GitTabController', function() {
});
});
- describe('keyboard navigation commands', function() {
- let wrapper, rootElement, gitTab, stagingView, commitView, commitController, focusElement;
- const focuses = GitTabController.focus;
-
- const extractReferences = () => {
- rootElement = wrapper.instance().refRoot.get();
- gitTab = wrapper.instance().refView.get();
- stagingView = wrapper.instance().refStagingView.get();
- commitController = gitTab.refCommitController.get();
- commitView = commitController.refCommitView.get();
- focusElement = stagingView.element;
-
- const commitViewElements = [];
- commitView.refEditorComponent.map(e => commitViewElements.push(e));
- commitView.refAbortMergeButton.map(e => commitViewElements.push(e));
- commitView.refCommitButton.map(e => commitViewElements.push(e));
-
- const stubFocus = element => {
- sinon.stub(element, 'focus').callsFake(() => {
- focusElement = element;
- });
- };
- stubFocus(stagingView.refRoot.get());
- for (const e of commitViewElements) {
- stubFocus(e);
- }
-
- sinon.stub(commitController, 'hasFocus').callsFake(() => {
- return commitViewElements.includes(focusElement);
- });
- };
-
- const assertSelected = paths => {
- const selectionPaths = Array.from(stagingView.state.selection.getSelectedItems()).map(item => item.filePath);
- assert.deepEqual(selectionPaths, paths);
- };
-
- const assertAsyncSelected = paths => {
- return assert.async.deepEqual(
- Array.from(stagingView.state.selection.getSelectedItems()).map(item => item.filePath),
- paths,
- );
- };
-
- describe('with conflicts and staged files', function() {
- beforeEach(async function() {
- const workdirPath = await cloneRepository('each-staging-group');
- const repository = await buildRepository(workdirPath);
-
- // Merge with conflicts
- assert.isRejected(repository.git.merge('origin/branch'));
-
- fs.writeFileSync(path.join(workdirPath, 'unstaged-1.txt'), 'This is an unstaged file.');
- fs.writeFileSync(path.join(workdirPath, 'unstaged-2.txt'), 'This is an unstaged file.');
- fs.writeFileSync(path.join(workdirPath, 'unstaged-3.txt'), 'This is an unstaged file.');
-
- // Three staged files
- fs.writeFileSync(path.join(workdirPath, 'staged-1.txt'), 'This is a file with some changes staged for commit.');
- fs.writeFileSync(path.join(workdirPath, 'staged-2.txt'), 'This is another file staged for commit.');
- fs.writeFileSync(path.join(workdirPath, 'staged-3.txt'), 'This is a third file staged for commit.');
- await repository.stageFiles(['staged-1.txt', 'staged-2.txt', 'staged-3.txt']);
- repository.refresh();
-
- wrapper = mount(await buildApp(repository));
- await assert.async.lengthOf(wrapper.update().find('GitTabView').prop('unstagedChanges'), 3);
-
- extractReferences();
- });
-
- it('blurs on tool-panel:unfocus', function() {
- sinon.spy(workspace.getActivePane(), 'activate');
-
- commandRegistry.dispatch(wrapper.find('.github-Git').getDOMNode(), 'tool-panel:unfocus');
-
- assert.isTrue(workspace.getActivePane().activate.called);
- });
-
- it('advances focus through StagingView groups and CommitView, but does not cycle', async function() {
- assertSelected(['unstaged-1.txt']);
-
- commandRegistry.dispatch(rootElement, 'core:focus-next');
- assertSelected(['conflict-1.txt']);
-
- commandRegistry.dispatch(rootElement, 'core:focus-next');
- assertSelected(['staged-1.txt']);
-
- commandRegistry.dispatch(rootElement, 'core:focus-next');
- assertSelected(['staged-1.txt']);
- await assert.async.strictEqual(focusElement, wrapper.find('AtomTextEditor').instance());
-
- // This should be a no-op. (Actually, it'll insert a tab in the CommitView editor.)
- commandRegistry.dispatch(rootElement, 'core:focus-next');
- assertSelected(['staged-1.txt']);
- assert.strictEqual(focusElement, wrapper.find('AtomTextEditor').instance());
- });
-
- it('retreats focus from the CommitView through StagingView groups, but does not cycle', async function() {
- gitTab.setFocus(focuses.EDITOR);
- sinon.stub(commitView, 'hasFocusEditor').returns(true);
-
- commandRegistry.dispatch(rootElement, 'core:focus-previous');
- await assert.async.strictEqual(focusElement, stagingView.refRoot.get());
- assertSelected(['staged-1.txt']);
-
- commandRegistry.dispatch(rootElement, 'core:focus-previous');
- await assertAsyncSelected(['conflict-1.txt']);
-
- commandRegistry.dispatch(rootElement, 'core:focus-previous');
- await assertAsyncSelected(['unstaged-1.txt']);
-
- // This should be a no-op.
- commandRegistry.dispatch(rootElement, 'core:focus-previous');
- await assertAsyncSelected(['unstaged-1.txt']);
- });
- });
-
- describe('with staged changes', function() {
- let repository;
-
- beforeEach(async function() {
- const workdirPath = await cloneRepository('each-staging-group');
- repository = await buildRepository(workdirPath);
-
- // A staged file
- fs.writeFileSync(path.join(workdirPath, 'staged-1.txt'), 'This is a file with some changes staged for commit.');
- await repository.stageFiles(['staged-1.txt']);
- repository.refresh();
-
- const prepareToCommit = () => Promise.resolve(true);
- const ensureGitTab = () => Promise.resolve(false);
-
- wrapper = mount(await buildApp(repository, {ensureGitTab, prepareToCommit}));
-
- extractReferences();
- await assert.async.isTrue(commitView.props.stagedChangesExist);
- });
-
- it('focuses the CommitView on github:commit with an empty commit message', async function() {
- commitView.refEditorModel.map(e => e.setText(''));
- sinon.spy(wrapper.instance(), 'commit');
- wrapper.update();
-
- commandRegistry.dispatch(workspaceElement, 'github:commit');
-
- await assert.async.strictEqual(focusElement, wrapper.find('AtomTextEditor').instance());
- assert.isFalse(wrapper.instance().commit.called);
- });
-
- it('creates a commit on github:commit with a nonempty commit message', async function() {
- commitView.refEditorModel.map(e => e.setText('I fixed the things'));
- sinon.spy(repository, 'commit');
-
- commandRegistry.dispatch(workspaceElement, 'github:commit');
-
- await until('Commit method called', () => repository.commit.calledWith('I fixed the things'));
- });
- });
- });
-
describe('integration tests', function() {
it('can stage and unstage files and commit', async function() {
const workdirPath = await cloneRepository('three-files');
diff --git a/test/controllers/multi-file-patch-controller.test.js b/test/controllers/multi-file-patch-controller.test.js
new file mode 100644
index 00000000000..e1996c4c250
--- /dev/null
+++ b/test/controllers/multi-file-patch-controller.test.js
@@ -0,0 +1,76 @@
+import React from 'react';
+import {shallow} from 'enzyme';
+
+import MultiFilePatchController from '../../lib/controllers/multi-file-patch-controller';
+import {buildMultiFilePatch} from '../../lib/models/patch';
+
+describe('MultiFilePatchController', function() {
+ let multiFilePatch;
+
+ beforeEach(function() {
+ multiFilePatch = buildMultiFilePatch([
+ {
+ oldPath: 'first', oldMode: '100644', newPath: 'first', newMode: '100755', status: 'modified',
+ hunks: [
+ {
+ oldStartLine: 1, oldLineCount: 2, newStartLine: 1, newLineCount: 4,
+ lines: [' line-0', '+line-1', '+line-2', ' line-3'],
+ },
+ ],
+ },
+ {
+ oldPath: 'second', oldMode: '100644', newPath: 'second', newMode: '100644', status: 'modified',
+ hunks: [
+ {
+ oldStartLine: 5, oldLineCount: 3, newStartLine: 5, newLineCount: 3,
+ lines: [' line-5', '+line-6', '-line-7', ' line-8'],
+ },
+ ],
+ },
+ {
+ oldPath: 'third', oldMode: '100755', newPath: 'third', newMode: '100755', status: 'added',
+ hunks: [
+ {
+ oldStartLine: 1, oldLineCount: 0, newStartLine: 1, newLineCount: 3,
+ lines: ['+line-0', '+line-1', '+line-2'],
+ },
+ ],
+ },
+ ]);
+ });
+
+ function buildApp(override = {}) {
+ const props = {
+ multiFilePatch,
+ ...override,
+ };
+
+ return ;
+ }
+
+ it('renders a FilePatchController for each file patch', function() {
+ const wrapper = shallow(buildApp());
+
+ assert.lengthOf(wrapper.find('FilePatchController'), 3);
+
+ // O(n^2) doesn't matter when n is small :stars:
+ assert.isTrue(
+ multiFilePatch.getFilePatches().every(fp => {
+ return wrapper
+ .find('FilePatchController')
+ .someWhere(w => w.prop('filePatch') === fp);
+ }),
+ );
+ });
+
+ it('passes additional props to each controller', function() {
+ const extra = Symbol('hooray');
+ const wrapper = shallow(buildApp({extra}));
+
+ assert.isTrue(
+ wrapper
+ .find('FilePatchController')
+ .everyWhere(w => w.prop('extra') === extra),
+ );
+ });
+});
diff --git a/test/controllers/root-controller.test.js b/test/controllers/root-controller.test.js
index 4d94f594ea8..5cd8fe293de 100644
--- a/test/controllers/root-controller.test.js
+++ b/test/controllers/root-controller.test.js
@@ -15,6 +15,7 @@ import GitTabItem from '../../lib/items/git-tab-item';
import GitHubTabItem from '../../lib/items/github-tab-item';
import ResolutionProgress from '../../lib/models/conflicts/resolution-progress';
import IssueishDetailItem from '../../lib/items/issueish-detail-item';
+import CommitPreviewItem from '../../lib/items/commit-preview-item';
import * as reporterProxy from '../../lib/reporter-proxy';
import RootController from '../../lib/controllers/root-controller';
@@ -989,13 +990,13 @@ describe('RootController', function() {
editor.setCursorBufferPosition([7, 0]);
// TODO: too implementation-detail-y
- const filePatchItem = {
+ const changedFileItem = {
goToDiffLine: sinon.spy(),
focus: sinon.spy(),
getRealItemPromise: () => Promise.resolve(),
getFilePatchLoadedPromise: () => Promise.resolve(),
};
- sinon.stub(workspace, 'open').returns(filePatchItem);
+ sinon.stub(workspace, 'open').returns(changedFileItem);
await wrapper.instance().viewUnstagedChangesForCurrentFile();
await assert.async.equal(workspace.open.callCount, 1);
@@ -1003,9 +1004,9 @@ describe('RootController', function() {
`atom-github://file-patch/a.txt?workdir=${encodeURIComponent(workdirPath)}&stagingStatus=unstaged`,
{pending: true, activatePane: true, activateItem: true},
]);
- await assert.async.equal(filePatchItem.goToDiffLine.callCount, 1);
- assert.deepEqual(filePatchItem.goToDiffLine.args[0], [8]);
- assert.equal(filePatchItem.focus.callCount, 1);
+ await assert.async.equal(changedFileItem.goToDiffLine.callCount, 1);
+ assert.deepEqual(changedFileItem.goToDiffLine.args[0], [8]);
+ assert.equal(changedFileItem.focus.callCount, 1);
});
it('does nothing on an untitled buffer', async function() {
@@ -1034,13 +1035,13 @@ describe('RootController', function() {
editor.setCursorBufferPosition([7, 0]);
// TODO: too implementation-detail-y
- const filePatchItem = {
+ const changedFileItem = {
goToDiffLine: sinon.spy(),
focus: sinon.spy(),
getRealItemPromise: () => Promise.resolve(),
getFilePatchLoadedPromise: () => Promise.resolve(),
};
- sinon.stub(workspace, 'open').returns(filePatchItem);
+ sinon.stub(workspace, 'open').returns(changedFileItem);
await wrapper.instance().viewStagedChangesForCurrentFile();
await assert.async.equal(workspace.open.callCount, 1);
@@ -1048,9 +1049,9 @@ describe('RootController', function() {
`atom-github://file-patch/a.txt?workdir=${encodeURIComponent(workdirPath)}&stagingStatus=staged`,
{pending: true, activatePane: true, activateItem: true},
]);
- await assert.async.equal(filePatchItem.goToDiffLine.callCount, 1);
- assert.deepEqual(filePatchItem.goToDiffLine.args[0], [8]);
- assert.equal(filePatchItem.focus.callCount, 1);
+ await assert.async.equal(changedFileItem.goToDiffLine.callCount, 1);
+ assert.deepEqual(changedFileItem.goToDiffLine.args[0], [8]);
+ assert.equal(changedFileItem.focus.callCount, 1);
});
it('does nothing on an untitled buffer', async function() {
@@ -1088,6 +1089,20 @@ describe('RootController', function() {
});
});
+ describe('opening a CommitPreviewItem', function() {
+ it('registers an opener for CommitPreviewItems', async function() {
+ const workdir = await cloneRepository('three-files');
+ const repository = await buildRepository(workdir);
+ const wrapper = mount(React.cloneElement(app, {repository}));
+
+ const uri = CommitPreviewItem.buildURI(workdir);
+ const item = await atomEnv.workspace.open(uri);
+
+ assert.strictEqual(item.getTitle(), 'Commit preview');
+ assert.lengthOf(wrapper.update().find('CommitPreviewItem'), 1);
+ });
+ });
+
describe('context commands trigger event reporting', function() {
let wrapper;
diff --git a/test/git-strategies.test.js b/test/git-strategies.test.js
index 635b09dafd8..2c3bdcb7cc0 100644
--- a/test/git-strategies.test.js
+++ b/test/git-strategies.test.js
@@ -627,6 +627,32 @@ import * as reporterProxy from '../lib/reporter-proxy';
});
});
+ describe('getStagedChangesPatch', function() {
+ it('returns an empty patch if there are no staged files', async function() {
+ const workdir = await cloneRepository('three-files');
+ const git = createTestStrategy(workdir);
+ const mp = await git.getStagedChangesPatch();
+ assert.lengthOf(mp, 0);
+ });
+
+ it('returns a combined diff of all staged files', async function() {
+ const workdir = await cloneRepository('each-staging-group');
+ const git = createTestStrategy(workdir);
+
+ await assert.isRejected(git.merge('origin/branch'));
+ await fs.writeFile(path.join(workdir, 'unstaged-1.txt'), 'Unstaged file');
+ await fs.writeFile(path.join(workdir, 'unstaged-2.txt'), 'Unstaged file');
+
+ await fs.writeFile(path.join(workdir, 'staged-1.txt'), 'Staged file');
+ await fs.writeFile(path.join(workdir, 'staged-2.txt'), 'Staged file');
+ await fs.writeFile(path.join(workdir, 'staged-3.txt'), 'Staged file');
+ await git.stageFiles(['staged-1.txt', 'staged-2.txt', 'staged-3.txt']);
+
+ const diffs = await git.getStagedChangesPatch();
+ assert.deepEqual(diffs.map(diff => diff.newPath), ['staged-1.txt', 'staged-2.txt', 'staged-3.txt']);
+ });
+ });
+
describe('isMerging', function() {
it('returns true if `.git/MERGE_HEAD` exists', async function() {
const workingDirPath = await cloneRepository('merge-conflict');
diff --git a/test/integration/file-patch.test.js b/test/integration/file-patch.test.js
index ca2acba9a86..55bc811d629 100644
--- a/test/integration/file-patch.test.js
+++ b/test/integration/file-patch.test.js
@@ -75,15 +75,15 @@ describe('integration: file patches', function() {
listItem.simulate('mousedown', {button: 0, persist() {}});
window.dispatchEvent(new MouseEvent('mouseup'));
- const itemSelector = `FilePatchItem[relPath="${relativePath}"][stagingStatus="${stagingStatus}"]`;
+ const itemSelector = `ChangedFileItem[relPath="${relativePath}"][stagingStatus="${stagingStatus}"]`;
await until(
() => wrapper.update().find(itemSelector).find('.github-FilePatchView').exists(),
- `the FilePatchItem for ${relativePath} arrives and loads`,
+ `the ChangedFileItem for ${relativePath} arrives and loads`,
);
}
function getPatchItem(stagingStatus, relativePath) {
- return wrapper.update().find(`FilePatchItem[relPath="${relativePath}"][stagingStatus="${stagingStatus}"]`);
+ return wrapper.update().find(`ChangedFileItem[relPath="${relativePath}"][stagingStatus="${stagingStatus}"]`);
}
function getPatchEditor(stagingStatus, relativePath) {
diff --git a/test/items/file-patch-item.test.js b/test/items/changed-file-item.test.js
similarity index 87%
rename from test/items/file-patch-item.test.js
rename to test/items/changed-file-item.test.js
index 00467f1d2fc..59f8d131011 100644
--- a/test/items/file-patch-item.test.js
+++ b/test/items/changed-file-item.test.js
@@ -3,11 +3,11 @@ import React from 'react';
import {mount} from 'enzyme';
import PaneItem from '../../lib/atom/pane-item';
-import FilePatchItem from '../../lib/items/file-patch-item';
+import ChangedFileItem from '../../lib/items/changed-file-item';
import WorkdirContextPool from '../../lib/models/workdir-context-pool';
import {cloneRepository} from '../helpers';
-describe('FilePatchItem', function() {
+describe('ChangedFileItem', function() {
let atomEnv, repository, pool;
beforeEach(async function() {
@@ -41,10 +41,10 @@ describe('FilePatchItem', function() {
};
return (
-
+
{({itemHolder, params}) => {
return (
-
+ {({itemHolder, params}) => {
+ return (
+
+ );
+ }}
+
+ );
+ }
+
+ function open(wrapper, options = {}) {
+ const opts = {
+ workingDirectory: repository.getWorkingDirectoryPath(),
+ ...options,
+ };
+ const uri = CommitPreviewItem.buildURI(opts.workingDirectory);
+ 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('CommitPreviewItem').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('CommitPreviewContainer').prop('extra'), extra);
+ });
+
+ it('locates the repository from the context pool', async function() {
+ const wrapper = mount(buildPaneApp());
+ await open(wrapper);
+
+ assert.strictEqual(wrapper.update().find('CommitPreviewContainer').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('CommitPreviewContainer').prop('repository').isAbsent());
+ });
+
+ it('returns a fixed title and icon', async function() {
+ const wrapper = mount(buildPaneApp());
+ const item = await open(wrapper);
+
+ assert.strictEqual(item.getTitle(), 'Commit preview');
+ 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('serializes itself as a CommitPreviewStub', async function() {
+ const wrapper = mount(buildPaneApp());
+ const item0 = await open(wrapper, {workingDirectory: '/dir0'});
+ assert.deepEqual(item0.serialize(), {
+ deserializer: 'CommitPreviewStub',
+ uri: 'atom-github://commit-preview?workdir=%2Fdir0',
+ });
+
+ const item1 = await open(wrapper, {workingDirectory: '/dir1'});
+ assert.deepEqual(item1.serialize(), {
+ deserializer: 'CommitPreviewStub',
+ uri: 'atom-github://commit-preview?workdir=%2Fdir1',
+ });
+ });
+
+ it('has an item-level accessor for the current working directory', async function() {
+ const wrapper = mount(buildPaneApp());
+ const item = await open(wrapper, {workingDirectory: '/dir7'});
+ assert.strictEqual(item.getWorkingDirectory(), '/dir7');
+ });
+});
diff --git a/test/models/patch/builder.test.js b/test/models/patch/builder.test.js
index 6b2ce62401e..987194ef824 100644
--- a/test/models/patch/builder.test.js
+++ b/test/models/patch/builder.test.js
@@ -1,5 +1,5 @@
-import {buildFilePatch} from '../../../lib/models/patch';
-import {assertInPatch} from '../../helpers';
+import {buildFilePatch, buildMultiFilePatch} from '../../../lib/models/patch';
+import {assertInPatch, assertInFilePatch} from '../../helpers';
describe('buildFilePatch', function() {
it('returns a null patch for an empty diff list', function() {
@@ -501,6 +501,200 @@ describe('buildFilePatch', function() {
});
});
+ describe('with multiple diffs', function() {
+ it('creates a MultiFilePatch containing each', function() {
+ const mp = buildMultiFilePatch([
+ {
+ oldPath: 'first', oldMode: '100644', newPath: 'first', newMode: '100755', status: 'modified',
+ hunks: [
+ {
+ oldStartLine: 1, oldLineCount: 2, newStartLine: 1, newLineCount: 4,
+ lines: [
+ ' line-0',
+ '+line-1',
+ '+line-2',
+ ' line-3',
+ ],
+ },
+ {
+ oldStartLine: 10, oldLineCount: 3, newStartLine: 12, newLineCount: 2,
+ lines: [
+ ' line-4',
+ '-line-5',
+ ' line-6',
+ ],
+ },
+ ],
+ },
+ {
+ oldPath: 'second', oldMode: '100644', newPath: 'second', newMode: '100644', status: 'modified',
+ hunks: [
+ {
+ oldStartLine: 5, oldLineCount: 3, newStartLine: 5, newLineCount: 3,
+ lines: [
+ ' line-5',
+ '+line-6',
+ '-line-7',
+ ' line-8',
+ ],
+ },
+ ],
+ },
+ {
+ oldPath: 'third', oldMode: '100755', newPath: 'third', newMode: '100755', status: 'added',
+ hunks: [
+ {
+ oldStartLine: 1, oldLineCount: 0, newStartLine: 1, newLineCount: 3,
+ lines: [
+ '+line-0',
+ '+line-1',
+ '+line-2',
+ ],
+ },
+ ],
+ },
+ ]);
+
+ assert.lengthOf(mp.getFilePatches(), 3);
+ assert.strictEqual(mp.getFilePatches()[0].getOldPath(), 'first');
+ assertInFilePatch(mp.getFilePatches()[0]).hunks(
+ {
+ startRow: 0, endRow: 3, header: '@@ -1,2 +1,4 @@', regions: [
+ {kind: 'unchanged', string: ' line-0\n', range: [[0, 0], [0, 6]]},
+ {kind: 'addition', string: '+line-1\n+line-2\n', range: [[1, 0], [2, 6]]},
+ {kind: 'unchanged', string: ' line-3\n', range: [[3, 0], [3, 6]]},
+ ],
+ },
+ {
+ startRow: 4, endRow: 6, header: '@@ -10,3 +12,2 @@', regions: [
+ {kind: 'unchanged', string: ' line-4\n', range: [[4, 0], [4, 6]]},
+ {kind: 'deletion', string: '-line-5\n', range: [[5, 0], [5, 6]]},
+ {kind: 'unchanged', string: ' line-6\n', range: [[6, 0], [6, 6]]},
+ ],
+ },
+ );
+ assert.strictEqual(mp.getFilePatches()[1].getOldPath(), 'second');
+ assertInFilePatch(mp.getFilePatches()[1]).hunks(
+ {
+ startRow: 0, endRow: 3, header: '@@ -5,3 +5,3 @@', regions: [
+ {kind: 'unchanged', string: ' line-5\n', range: [[0, 0], [0, 6]]},
+ {kind: 'addition', string: '+line-6\n', range: [[1, 0], [1, 6]]},
+ {kind: 'deletion', string: '-line-7\n', range: [[2, 0], [2, 6]]},
+ {kind: 'unchanged', string: ' line-8\n', range: [[3, 0], [3, 6]]},
+ ],
+ },
+ );
+ assert.strictEqual(mp.getFilePatches()[2].getOldPath(), 'third');
+ assertInFilePatch(mp.getFilePatches()[2]).hunks(
+ {
+ startRow: 0, endRow: 2, header: '@@ -1,0 +1,3 @@', regions: [
+ {kind: 'addition', string: '+line-0\n+line-1\n+line-2\n', range: [[0, 0], [2, 6]]},
+ ],
+ },
+ );
+ });
+
+ it('identifies mode and content change pairs within the patch list', function() {
+ const mp = buildMultiFilePatch([
+ {
+ oldPath: 'first', oldMode: '100644', newPath: 'first', newMode: '100755', status: 'modified',
+ hunks: [
+ {
+ oldStartLine: 1, oldLineCount: 2, newStartLine: 1, newLineCount: 3,
+ lines: [
+ ' line-0',
+ '+line-1',
+ ' line-2',
+ ],
+ },
+ ],
+ },
+ {
+ oldPath: 'was-non-symlink', oldMode: '100644', newPath: 'was-non-symlink', newMode: '000000', status: 'deleted',
+ hunks: [
+ {
+ oldStartLine: 1, oldLineCount: 2, newStartLine: 1, newLineCount: 0,
+ lines: ['-line-0', '-line-1'],
+ },
+ ],
+ },
+ {
+ oldPath: 'was-symlink', oldMode: '000000', newPath: 'was-symlink', newMode: '100755', status: 'added',
+ hunks: [
+ {
+ oldStartLine: 1, oldLineCount: 0, newStartLine: 1, newLineCount: 2,
+ lines: ['+line-0', '+line-1'],
+ },
+ ],
+ },
+ {
+ oldMode: '100644', newPath: 'third', newMode: '100644', status: 'deleted',
+ hunks: [
+ {
+ oldStartLine: 1, oldLineCount: 3, newStartLine: 1, newLineCount: 0,
+ lines: ['-line-0', '-line-1', '-line-2'],
+ },
+ ],
+ },
+ {
+ oldPath: 'was-symlink', oldMode: '120000', newPath: 'was-non-symlink', newMode: '000000', status: 'deleted',
+ hunks: [
+ {
+ oldStartLine: 1, oldLineCount: 0, newStartLine: 0, newLineCount: 0,
+ lines: ['-was-symlink-destination'],
+ },
+ ],
+ },
+ {
+ oldPath: 'was-non-symlink', oldMode: '000000', newPath: 'was-non-symlink', newMode: '120000', status: 'added',
+ hunks: [
+ {
+ oldStartLine: 1, oldLineCount: 0, newStartLine: 1, newLineCount: 1,
+ lines: ['+was-non-symlink-destination'],
+ },
+ ],
+ },
+ ]);
+
+ assert.lengthOf(mp.getFilePatches(), 4);
+ const [fp0, fp1, fp2, fp3] = mp.getFilePatches();
+
+ assert.strictEqual(fp0.getOldPath(), 'first');
+ assertInFilePatch(fp0).hunks({
+ startRow: 0, endRow: 2, header: '@@ -1,2 +1,3 @@', regions: [
+ {kind: 'unchanged', string: ' line-0\n', range: [[0, 0], [0, 6]]},
+ {kind: 'addition', string: '+line-1\n', range: [[1, 0], [1, 6]]},
+ {kind: 'unchanged', string: ' line-2\n', range: [[2, 0], [2, 6]]},
+ ],
+ });
+
+ assert.strictEqual(fp1.getOldPath(), 'was-non-symlink');
+ assert.isTrue(fp1.hasTypechange());
+ assert.strictEqual(fp1.getNewSymlink(), 'was-non-symlink-destination');
+ assertInFilePatch(fp1).hunks({
+ startRow: 0, endRow: 1, header: '@@ -1,2 +1,0 @@', regions: [
+ {kind: 'deletion', string: '-line-0\n-line-1\n', range: [[0, 0], [1, 6]]},
+ ],
+ });
+
+ assert.strictEqual(fp2.getOldPath(), 'was-symlink');
+ assert.isTrue(fp2.hasTypechange());
+ assert.strictEqual(fp2.getOldSymlink(), 'was-symlink-destination');
+ assertInFilePatch(fp2).hunks({
+ startRow: 0, endRow: 1, header: '@@ -1,0 +1,2 @@', regions: [
+ {kind: 'addition', string: '+line-0\n+line-1\n', range: [[0, 0], [1, 6]]},
+ ],
+ });
+
+ assert.strictEqual(fp3.getNewPath(), 'third');
+ assertInFilePatch(fp3).hunks({
+ startRow: 0, endRow: 2, header: '@@ -1,3 +1,0 @@', regions: [
+ {kind: 'deletion', string: '-line-0\n-line-1\n-line-2\n', range: [[0, 0], [2, 6]]},
+ ],
+ });
+ });
+ });
+
it('throws an error with an unexpected number of diffs', function() {
assert.throws(() => buildFilePatch([1, 2, 3]), /Unexpected number of diffs: 3/);
});
diff --git a/test/models/patch/multi-file-patch.test.js b/test/models/patch/multi-file-patch.test.js
new file mode 100644
index 00000000000..3c0a38f5257
--- /dev/null
+++ b/test/models/patch/multi-file-patch.test.js
@@ -0,0 +1,65 @@
+import {TextBuffer} from 'atom';
+
+import MultiFilePatch from '../../../lib/models/patch/multi-file-patch';
+import FilePatch from '../../../lib/models/patch/file-patch';
+import File from '../../../lib/models/patch/file';
+import Patch from '../../../lib/models/patch/patch';
+import Hunk from '../../../lib/models/patch/hunk';
+import {Unchanged, Addition, Deletion} from '../../../lib/models/patch/region';
+
+describe('MultiFilePatch', function() {
+ it('has an accessor for its file patches', function() {
+ const filePatches = [buildFilePatchFixture(0), buildFilePatchFixture(1)];
+ const mp = new MultiFilePatch(filePatches);
+ assert.strictEqual(mp.getFilePatches(), filePatches);
+ });
+});
+
+function buildFilePatchFixture(index) {
+ const buffer = new TextBuffer();
+ for (let i = 0; i < 8; i++) {
+ buffer.append(`file-${index} line-${i}\n`);
+ }
+
+ const layers = {
+ hunk: buffer.addMarkerLayer(),
+ unchanged: buffer.addMarkerLayer(),
+ addition: buffer.addMarkerLayer(),
+ deletion: buffer.addMarkerLayer(),
+ noNewline: buffer.addMarkerLayer(),
+ };
+
+ const mark = (layer, start, end = start) => layer.markRange([[start, 0], [end, Infinity]]);
+
+ const hunks = [
+ new Hunk({
+ oldStartRow: 0, newStartRow: 0, oldRowCount: 3, newRowCount: 3,
+ sectionHeading: `file-${index} hunk-0`,
+ marker: mark(layers.hunk, 0, 3),
+ regions: [
+ new Unchanged(mark(layers.unchanged, 0)),
+ new Addition(mark(layers.addition, 1)),
+ new Deletion(mark(layers.deletion, 2)),
+ new Unchanged(mark(layers.unchanged, 3)),
+ ],
+ }),
+ new Hunk({
+ oldStartRow: 10, newStartRow: 10, oldRowCount: 3, newRowCount: 3,
+ sectionHeading: `file-${index} hunk-1`,
+ marker: mark(layers.hunk, 4, 7),
+ regions: [
+ new Unchanged(mark(layers.unchanged, 4)),
+ new Addition(mark(layers.addition, 5)),
+ new Deletion(mark(layers.deletion, 6)),
+ new Unchanged(mark(layers.unchanged, 7)),
+ ],
+ }),
+ ];
+
+ const patch = new Patch({status: 'modified', hunks, buffer, layers});
+
+ const oldFile = new File({path: `file-${index}.txt`, mode: '100644'});
+ const newFile = new File({path: `file-${index}.txt`, mode: '100644'});
+
+ return new FilePatch(oldFile, newFile, patch);
+}
diff --git a/test/models/repository.test.js b/test/models/repository.test.js
index d1ad3a84c4f..f0fb42c4e06 100644
--- a/test/models/repository.test.js
+++ b/test/models/repository.test.js
@@ -440,6 +440,25 @@ describe('Repository', function() {
});
});
+ describe('getStagedChangesPatch', function() {
+ it('computes a multi-file patch of the staged changes', async function() {
+ const workdir = await cloneRepository('each-staging-group');
+ const repo = new Repository(workdir);
+ await repo.getLoadPromise();
+
+ await fs.writeFile(path.join(workdir, 'unstaged-1.txt'), 'Unstaged file');
+
+ await fs.writeFile(path.join(workdir, 'staged-1.txt'), 'Staged file');
+ await fs.writeFile(path.join(workdir, 'staged-2.txt'), 'Staged file');
+ await repo.stageFiles(['staged-1.txt', 'staged-2.txt']);
+
+ const mp = await repo.getStagedChangesPatch();
+
+ assert.lengthOf(mp.getFilePatches(), 2);
+ assert.deepEqual(mp.getFilePatches().map(fp => fp.getPath()), ['staged-1.txt', 'staged-2.txt']);
+ });
+ });
+
describe('isPartiallyStaged(filePath)', function() {
it('returns true if specified file path is partially staged', async function() {
const workingDirPath = await cloneRepository('three-files');
@@ -1473,6 +1492,10 @@ describe('Repository', function() {
'getRemotes',
() => repository.getRemotes(),
);
+ calls.set(
+ 'getStagedChangesPatch',
+ () => repository.getStagedChangesPatch(),
+ );
const withFile = fileName => {
calls.set(
diff --git a/test/views/commit-view.test.js b/test/views/commit-view.test.js
index 7666fa22dc4..fd182e4e501 100644
--- a/test/views/commit-view.test.js
+++ b/test/views/commit-view.test.js
@@ -43,6 +43,7 @@ describe('CommitView', function() {
stagedChangesExist={false}
mergeConflictsExist={false}
isCommitting={false}
+ commitPreviewOpen={false}
deactivateCommitBox={false}
maximumCharacterLimit={72}
messageBuffer={messageBuffer}
@@ -359,6 +360,160 @@ describe('CommitView', function() {
assert.isFalse(wrapper.instance().hasFocusEditor());
});
+ describe('advancing focus', function() {
+ let wrapper, instance, event;
+
+ beforeEach(function() {
+ wrapper = mount(app);
+ instance = wrapper.instance();
+ event = {stopPropagation: sinon.spy()};
+
+ sinon.spy(instance, 'setFocus');
+ });
+
+ it('does nothing and returns false if the focus is not in the commit view', function() {
+ sinon.stub(instance, 'getFocus').returns(null);
+ assert.isFalse(instance.advanceFocus(event));
+ assert.isFalse(instance.setFocus.called);
+ assert.isFalse(event.stopPropagation.called);
+ });
+
+ it('moves focus to the commit editor if the commit preview button is focused', function() {
+ sinon.stub(instance, 'getFocus').returns(CommitView.focus.COMMIT_PREVIEW_BUTTON);
+
+ assert.isTrue(instance.advanceFocus(event));
+ assert.isTrue(instance.setFocus.calledWith(CommitView.focus.EDITOR));
+ assert.isTrue(event.stopPropagation.called);
+ });
+
+ it('inserts a tab if the commit editor is focused', function() {
+ sinon.stub(instance, 'getFocus').returns(CommitView.focus.EDITOR);
+
+ assert.isTrue(instance.advanceFocus(event));
+ assert.isFalse(event.stopPropagation.called);
+ });
+
+ it('moves focus to the commit button if the coauthor form is focused and no merge is in progress', function() {
+ sinon.stub(instance, 'getFocus').returns(CommitView.focus.COAUTHOR_INPUT);
+
+ assert.isTrue(instance.advanceFocus(event));
+ assert.isTrue(instance.setFocus.calledWith(CommitView.focus.COMMIT_BUTTON));
+ assert.isTrue(event.stopPropagation.called);
+ });
+
+ it('moves focus to the abort merge button if the coauthor form is focused and a merge is in progress', function() {
+ wrapper.setProps({isMerging: true});
+ sinon.stub(instance, 'getFocus').returns(CommitView.focus.COAUTHOR_INPUT);
+
+ assert.isTrue(instance.advanceFocus(event));
+ assert.isTrue(instance.setFocus.calledWith(CommitView.focus.ABORT_MERGE_BUTTON));
+ assert.isTrue(event.stopPropagation.called);
+ });
+
+ it('moves focus to the commit button if the abort merge button is focused', function() {
+ sinon.stub(instance, 'getFocus').returns(CommitView.focus.ABORT_MERGE_BUTTON);
+
+ assert.isTrue(instance.advanceFocus(event));
+ assert.isTrue(instance.setFocus.calledWith(CommitView.focus.COMMIT_BUTTON));
+ assert.isTrue(event.stopPropagation.called);
+ });
+
+ it('does nothing and returns true if the commit button is focused', function() {
+ sinon.stub(instance, 'getFocus').returns(CommitView.focus.COMMIT_BUTTON);
+
+ assert.isTrue(instance.advanceFocus(event));
+ assert.isFalse(instance.setFocus.called);
+ assert.isTrue(event.stopPropagation.called);
+ });
+ });
+
+ describe('retreating focus', function() {
+ let wrapper, instance, event;
+
+ beforeEach(function() {
+ wrapper = mount(app);
+ instance = wrapper.instance();
+ event = {stopPropagation: sinon.spy()};
+
+ sinon.spy(instance, 'setFocus');
+ });
+
+ it('does nothing and returns false if the focus is not in the commit view', function() {
+ sinon.stub(instance, 'getFocus').returns(null);
+
+ assert.isFalse(instance.retreatFocus(event));
+ assert.isFalse(instance.setFocus.called);
+ assert.isFalse(event.stopPropagation.called);
+ });
+
+ it('moves focus to the abort merge button if the commit button is focused and a merge is in progress', function() {
+ wrapper.setProps({isMerging: true});
+ sinon.stub(instance, 'getFocus').returns(CommitView.focus.COMMIT_BUTTON);
+
+ assert.isTrue(instance.retreatFocus(event));
+ assert.isTrue(instance.setFocus.calledWith(CommitView.focus.ABORT_MERGE_BUTTON));
+ assert.isTrue(event.stopPropagation.called);
+ });
+
+ it('moves focus to the editor if the commit button is focused and no merge is underway', function() {
+ sinon.stub(instance, 'getFocus').returns(CommitView.focus.COMMIT_BUTTON);
+
+ assert.isTrue(instance.retreatFocus(event));
+ assert.isTrue(instance.setFocus.calledWith(CommitView.focus.EDITOR));
+ assert.isTrue(event.stopPropagation.called);
+ });
+
+ it('moves focus to the co-author form if it is visible, the commit button is focused, and no merge', function() {
+ wrapper.setState({showCoAuthorInput: true});
+ sinon.stub(instance, 'getFocus').returns(CommitView.focus.COMMIT_BUTTON);
+
+ assert.isTrue(instance.retreatFocus(event));
+ assert.isTrue(instance.setFocus.calledWith(CommitView.focus.COAUTHOR_INPUT));
+ assert.isTrue(event.stopPropagation.called);
+ });
+
+ it('moves focus to the co-author form if it is visible and the abort merge button is in focus', function() {
+ wrapper.setState({showCoAuthorInput: true});
+ sinon.stub(instance, 'getFocus').returns(CommitView.focus.ABORT_MERGE_BUTTON);
+
+ assert.isTrue(instance.retreatFocus(event));
+ assert.isTrue(instance.setFocus.calledWith(CommitView.focus.COAUTHOR_INPUT));
+ assert.isTrue(event.stopPropagation.called);
+ });
+
+ it('moves focus to the commit editor if the abort merge button is in focus', function() {
+ sinon.stub(instance, 'getFocus').returns(CommitView.focus.ABORT_MERGE_BUTTON);
+
+ assert.isTrue(instance.retreatFocus(event));
+ assert.isTrue(instance.setFocus.calledWith(CommitView.focus.EDITOR));
+ assert.isTrue(event.stopPropagation.called);
+ });
+
+ it('moves focus to the commit editor if the co-author form is focused', function() {
+ sinon.stub(instance, 'getFocus').returns(CommitView.focus.COAUTHOR_INPUT);
+
+ assert.isTrue(instance.retreatFocus(event));
+ assert.isTrue(instance.setFocus.calledWith(CommitView.focus.EDITOR));
+ assert.isTrue(event.stopPropagation.called);
+ });
+
+ it('moves focus to the commit preview button if the commit editor is focused', function() {
+ sinon.stub(instance, 'getFocus').returns(CommitView.focus.EDITOR);
+
+ assert.isTrue(instance.retreatFocus(event));
+ assert.isTrue(instance.setFocus.calledWith(CommitView.focus.COMMIT_PREVIEW_BUTTON));
+ assert.isTrue(event.stopPropagation.called);
+ });
+
+ it('does nothing and returns false if the commit preview button is focused', function() {
+ sinon.stub(instance, 'getFocus').returns(CommitView.focus.COMMIT_PREVIEW_BUTTON);
+
+ assert.isFalse(instance.retreatFocus(event));
+ assert.isFalse(instance.setFocus.called);
+ assert.isFalse(event.stopPropagation.called);
+ });
+ });
+
it('remembers the current focus', function() {
const wrapper = mount(React.cloneElement(app, {isMerging: true}));
wrapper.instance().toggleCoAuthorInput();
@@ -369,6 +524,7 @@ describe('CommitView', function() {
['.github-CommitView-abortMerge', CommitView.focus.ABORT_MERGE_BUTTON],
['.github-CommitView-commit', CommitView.focus.COMMIT_BUTTON],
['.github-CommitView-coAuthorEditor input', CommitView.focus.COAUTHOR_INPUT],
+ ['.github-CommitView-commitPreview', CommitView.focus.COMMIT_PREVIEW_BUTTON],
];
for (const [selector, focus, subselector] of foci) {
let target = wrapper.find(selector).getDOMNode();
@@ -382,6 +538,7 @@ describe('CommitView', function() {
const holders = [
'refEditorComponent', 'refEditorModel', 'refAbortMergeButton', 'refCommitButton', 'refCoAuthorSelect',
+ 'refCommitPreviewButton',
].map(ivar => wrapper.instance()[ivar]);
for (const holder of holders) {
holder.setter(null);
@@ -390,6 +547,15 @@ describe('CommitView', function() {
});
describe('restoring focus', function() {
+ it('to the commit preview button', function() {
+ const wrapper = mount(app);
+ const element = wrapper.find('.github-CommitView-commitPreview').getDOMNode();
+ sinon.spy(element, 'focus');
+
+ assert.isTrue(wrapper.instance().setFocus(CommitView.focus.COMMIT_PREVIEW_BUTTON));
+ assert.isTrue(element.focus.called);
+ });
+
it('to the editor', function() {
const wrapper = mount(app);
const element = wrapper.find('AtomTextEditor').getDOMNode().querySelector('atom-text-editor');
@@ -448,6 +614,7 @@ describe('CommitView', function() {
// Simulate an unmounted component by clearing out RefHolders manually.
const holders = [
'refEditorComponent', 'refEditorModel', 'refAbortMergeButton', 'refCommitButton', 'refCoAuthorSelect',
+ 'refCommitPreviewButton',
].map(ivar => wrapper.instance()[ivar]);
for (const holder of holders) {
holder.setter(null);
@@ -458,4 +625,44 @@ describe('CommitView', function() {
}
});
});
+
+ describe('commit preview button', function() {
+ it('is enabled when there is staged changes', function() {
+ const wrapper = shallow(React.cloneElement(app, {
+ stagedChangesExist: true,
+ }));
+ assert.isFalse(wrapper.find('.github-CommitView-commitPreview').prop('disabled'));
+ });
+
+ it('is disabled when there\'s no staged changes', function() {
+ const wrapper = shallow(React.cloneElement(app, {
+ stagedChangesExist: false,
+ }));
+ assert.isTrue(wrapper.find('.github-CommitView-commitPreview').prop('disabled'));
+ });
+
+ it('calls a callback when the button is clicked', function() {
+ const toggleCommitPreview = sinon.spy();
+
+ const wrapper = shallow(React.cloneElement(app, {
+ toggleCommitPreview,
+ stagedChangesExist: true,
+ }));
+
+ wrapper.find('.github-CommitView-commitPreview').simulate('click');
+ assert.isTrue(toggleCommitPreview.called);
+ });
+
+ it('displays correct button text depending on prop value', function() {
+ const wrapper = shallow(app);
+
+ assert.strictEqual(wrapper.find('.github-CommitView-commitPreview').text(), 'Preview Commit');
+
+ wrapper.setProps({commitPreviewOpen: true});
+ assert.strictEqual(wrapper.find('.github-CommitView-commitPreview').text(), 'Close Commit Preview');
+
+ wrapper.setProps({commitPreviewOpen: false});
+ assert.strictEqual(wrapper.find('.github-CommitView-commitPreview').text(), 'Preview Commit');
+ });
+ });
});
diff --git a/test/views/file-patch-view.test.js b/test/views/file-patch-view.test.js
index f0c70afd89d..05a919011aa 100644
--- a/test/views/file-patch-view.test.js
+++ b/test/views/file-patch-view.test.js
@@ -53,6 +53,7 @@ describe('FilePatchView', function() {
selectionMode: 'line',
selectedRows: new Set(),
repository,
+ isActive: true,
workspace,
config: atomEnv.config,
@@ -99,6 +100,15 @@ describe('FilePatchView', function() {
assert.strictEqual(editor.instance().getModel().getText(), filePatch.getBuffer().getText());
});
+ it('enables autoHeight on the editor when requested', function() {
+ const wrapper = mount(buildApp({useEditorAutoHeight: true}));
+
+ assert.isTrue(wrapper.find('AtomTextEditor').prop('autoHeight'));
+
+ wrapper.setProps({useEditorAutoHeight: false});
+ assert.isFalse(wrapper.find('AtomTextEditor').prop('autoHeight'));
+ });
+
it('sets the root class when in hunk selection mode', function() {
const wrapper = shallow(buildApp({selectionMode: 'line'}));
assert.isFalse(wrapper.find('.github-FilePatchView--hunkMode').exists());
@@ -106,6 +116,15 @@ describe('FilePatchView', function() {
assert.isTrue(wrapper.find('.github-FilePatchView--hunkMode').exists());
});
+ it('sets the root class when active or inactive', function() {
+ const wrapper = shallow(buildApp({isActive: true}));
+ assert.isTrue(wrapper.find('.github-FilePatchView--active').exists());
+ assert.isFalse(wrapper.find('.github-FilePatchView--inactive').exists());
+ wrapper.setProps({isActive: false});
+ assert.isFalse(wrapper.find('.github-FilePatchView--active').exists());
+ assert.isTrue(wrapper.find('.github-FilePatchView--inactive').exists());
+ });
+
it('preserves the selection index when a new file patch arrives in line selection mode', function() {
const selectedRowsChanged = sinon.spy();
const wrapper = mount(buildApp({
diff --git a/test/views/git-tab-view.test.js b/test/views/git-tab-view.test.js
index ba684fc246c..efb57f37bec 100644
--- a/test/views/git-tab-view.test.js
+++ b/test/views/git-tab-view.test.js
@@ -83,17 +83,8 @@ describe('GitTabView', function() {
event = {stopPropagation: sinon.spy()};
});
- it('does nothing if the commit controller has focus', async function() {
- sinon.stub(commitController, 'hasFocus').returns(true);
- sinon.spy(stagingView, 'activateNextList');
-
- await wrapper.instance().advanceFocus(event);
-
- assert.isFalse(event.stopPropagation.called);
- assert.isFalse(stagingView.activateNextList.called);
- });
-
it('activates the next staging view list and stops', async function() {
+ sinon.stub(commitController, 'advanceFocus').returns(false);
sinon.stub(stagingView, 'activateNextList').resolves(true);
sinon.spy(commitController, 'setFocus');
@@ -104,16 +95,27 @@ describe('GitTabView', function() {
assert.isFalse(commitController.setFocus.called);
});
- it('moves focus to the commit message editor from the end of the staging view', async function() {
+ it('moves focus to the commit preview button from the end of the staging view', async function() {
+ sinon.stub(commitController, 'advanceFocus').returns(false);
sinon.stub(stagingView, 'activateNextList').resolves(false);
sinon.stub(commitController, 'setFocus').returns(true);
await wrapper.instance().advanceFocus(event);
- assert.isTrue(commitController.setFocus.calledWith(GitTabView.focus.EDITOR));
+ assert.isTrue(commitController.setFocus.calledWith(GitTabView.focus.COMMIT_PREVIEW_BUTTON));
assert.isTrue(event.stopPropagation.called);
});
+ it('advances focus within the commit view', async function() {
+ sinon.stub(commitController, 'advanceFocus').returns(true);
+ sinon.spy(stagingView, 'activateNextList');
+
+ await wrapper.instance().advanceFocus(event);
+
+ assert.isTrue(commitController.advanceFocus.called);
+ assert.isFalse(stagingView.activateNextList.called);
+ });
+
it('does nothing if refs are unavailable', async function() {
wrapper.instance().refCommitController.setter(null);
@@ -135,20 +137,8 @@ describe('GitTabView', function() {
event = {stopPropagation: sinon.spy()};
});
- it('focuses the last staging list if the commit editor has focus', async function() {
- sinon.stub(commitController, 'hasFocus').returns(true);
- sinon.stub(commitController, 'hasFocusEditor').returns(true);
- sinon.stub(stagingView, 'activateLastList').resolves(true);
-
- await wrapper.instance().retreatFocus(event);
-
- assert.isTrue(stagingView.activateLastList.called);
- assert.isTrue(event.stopPropagation.called);
- });
-
- it('does nothing if the commit controller has focus but not in its editor', async function() {
- sinon.stub(commitController, 'hasFocus').returns(true);
- sinon.stub(commitController, 'hasFocusEditor').returns(false);
+ it('does nothing if the commit controller has focus but not in the preview button', async function() {
+ sinon.stub(commitController, 'retreatFocus').returns(true);
sinon.spy(stagingView, 'activateLastList');
sinon.spy(stagingView, 'activatePreviousList');
@@ -159,8 +149,19 @@ describe('GitTabView', function() {
assert.isFalse(event.stopPropagation.called);
});
+ it('focuses the last staging list if the commit preview button has focus', async function() {
+ sinon.stub(commitController, 'retreatFocus').returns(false);
+ sinon.stub(commitController, 'hasFocusAtBeginning').returns(true);
+ sinon.stub(stagingView, 'activateLastList').resolves(true);
+
+ await wrapper.instance().retreatFocus(event);
+
+ assert.isTrue(stagingView.activateLastList.called);
+ assert.isTrue(event.stopPropagation.called);
+ });
+
it('activates the previous staging list and stops', async function() {
- sinon.stub(commitController, 'hasFocus').returns(false);
+ sinon.stub(commitController, 'retreatFocus').returns(false);
sinon.stub(stagingView, 'activatePreviousList').resolves(true);
await wrapper.instance().retreatFocus(event);
diff --git a/test/views/staging-view.test.js b/test/views/staging-view.test.js
index 8c9fb955031..3fb74bea3df 100644
--- a/test/views/staging-view.test.js
+++ b/test/views/staging-view.test.js
@@ -224,12 +224,12 @@ describe('StagingView', function() {
it('passes activation options and focuses the returned item if activate is true', async function() {
const wrapper = mount(app);
- const filePatchItem = {
- getElement: () => filePatchItem,
- querySelector: () => filePatchItem,
+ const changedFileItem = {
+ getElement: () => changedFileItem,
+ querySelector: () => changedFileItem,
focus: sinon.spy(),
};
- workspace.open.returns(filePatchItem);
+ workspace.open.returns(changedFileItem);
await wrapper.instance().showFilePatchItem('file.txt', 'staged', {activate: true});
@@ -238,15 +238,15 @@ describe('StagingView', function() {
`atom-github://file-patch/file.txt?workdir=${encodeURIComponent(workingDirectoryPath)}&stagingStatus=staged`,
{pending: true, activatePane: true, pane: undefined, activateItem: true},
]);
- assert.isTrue(filePatchItem.focus.called);
+ assert.isTrue(changedFileItem.focus.called);
});
it('makes the item visible if activate is false', async function() {
const wrapper = mount(app);
const focus = sinon.spy();
- const filePatchItem = {focus};
- workspace.open.returns(filePatchItem);
+ const changedFileItem = {focus};
+ workspace.open.returns(changedFileItem);
const activateItem = sinon.spy();
workspace.paneForItem.returns({activateItem});
@@ -259,7 +259,7 @@ describe('StagingView', function() {
]);
assert.isFalse(focus.called);
assert.equal(activateItem.callCount, 1);
- assert.equal(activateItem.args[0][0], filePatchItem);
+ assert.equal(activateItem.args[0][0], changedFileItem);
});
});
});
diff --git a/test/watch-workspace-item.test.js b/test/watch-workspace-item.test.js
new file mode 100644
index 00000000000..f5926855466
--- /dev/null
+++ b/test/watch-workspace-item.test.js
@@ -0,0 +1,159 @@
+import {watchWorkspaceItem} from '../lib/watch-workspace-item';
+import URIPattern from '../lib/atom/uri-pattern';
+
+describe('watchWorkspaceItem', function() {
+ let sub, atomEnv, workspace, component;
+
+ beforeEach(function() {
+ atomEnv = global.buildAtomEnvironment();
+ workspace = atomEnv.workspace;
+
+ component = {
+ state: {},
+ setState: sinon.stub().callsFake((updater, cb) => cb && cb()),
+ };
+
+ workspace.addOpener(uri => {
+ if (uri.startsWith('atom-github://')) {
+ return {
+ getURI() { return uri; },
+ };
+ } else {
+ return undefined;
+ }
+ });
+ });
+
+ afterEach(function() {
+ sub && sub.dispose();
+ atomEnv.destroy();
+ });
+
+ describe('initial state', function() {
+ it('creates component state if none is present', function() {
+ component.state = undefined;
+
+ sub = watchWorkspaceItem(workspace, 'atom-github://item', component, 'aKey');
+ assert.deepEqual(component.state, {aKey: false});
+ });
+
+ it('is false when the pane is not open', async function() {
+ await workspace.open('atom-github://nonmatching');
+
+ sub = watchWorkspaceItem(workspace, 'atom-github://item', component, 'someKey');
+ assert.isFalse(component.state.someKey);
+ });
+
+ it('is true when the pane is already open', async function() {
+ await workspace.open('atom-github://item/one');
+ await workspace.open('atom-github://item/two');
+
+ sub = watchWorkspaceItem(workspace, 'atom-github://item/one', component, 'theKey');
+
+ assert.isTrue(component.state.theKey);
+ });
+
+ it('is true when multiple panes matching the URI pattern are open', async function() {
+ await workspace.open('atom-github://item/one');
+ await workspace.open('atom-github://item/two');
+ await workspace.open('atom-github://nonmatch');
+
+ sub = watchWorkspaceItem(workspace, 'atom-github://item/{pattern}', component, 'theKey');
+
+ assert.isTrue(component.state.theKey);
+ });
+
+ it('accepts a preconstructed URIPattern', async function() {
+ await workspace.open('atom-github://item/one');
+ const u = new URIPattern('atom-github://item/{pattern}');
+
+ sub = watchWorkspaceItem(workspace, u, component, 'theKey');
+ assert.isTrue(component.state.theKey);
+ });
+ });
+
+ describe('workspace events', function() {
+ it('becomes true when the pane is opened', async function() {
+ sub = watchWorkspaceItem(workspace, 'atom-github://item/{pattern}', component, 'theKey');
+
+ assert.isFalse(component.state.theKey);
+
+ await workspace.open('atom-github://item/match');
+
+ assert.isTrue(component.setState.calledWith({theKey: true}));
+ });
+
+ it('remains true if another matching pane is opened', async function() {
+ await workspace.open('atom-github://item/match0');
+ sub = watchWorkspaceItem(workspace, 'atom-github://item/{pattern}', component, 'theKey');
+
+ assert.isTrue(component.state.theKey);
+
+ await workspace.open('atom-github://item/match1');
+
+ assert.isFalse(component.setState.called);
+ });
+
+ it('remains true if a matching pane is closed but another remains open', async function() {
+ await workspace.open('atom-github://item/match0');
+ await workspace.open('atom-github://item/match1');
+
+ sub = watchWorkspaceItem(workspace, 'atom-github://item/{pattern}', component, 'theKey');
+ assert.isTrue(component.state.theKey);
+
+ assert.isTrue(workspace.hide('atom-github://item/match1'));
+
+ assert.isFalse(component.setState.called);
+ });
+
+ it('becomes false if the last matching pane is closed', async function() {
+ await workspace.open('atom-github://item/match0');
+ await workspace.open('atom-github://item/match1');
+
+ sub = watchWorkspaceItem(workspace, 'atom-github://item/{pattern}', component, 'theKey');
+ assert.isTrue(component.state.theKey);
+
+ assert.isTrue(workspace.hide('atom-github://item/match1'));
+ assert.isTrue(workspace.hide('atom-github://item/match0'));
+
+ assert.isTrue(component.setState.calledWith({theKey: false}));
+ });
+ });
+
+ it('stops updating when disposed', async function() {
+ sub = watchWorkspaceItem(workspace, 'atom-github://item', component, 'theKey');
+ assert.isFalse(component.state.theKey);
+
+ sub.dispose();
+ await workspace.open('atom-github://item');
+ assert.isFalse(component.setState.called);
+
+ await workspace.hide('atom-github://item');
+ assert.isFalse(component.setState.called);
+ });
+
+ describe('setPattern', function() {
+ it('immediately updates the state based on the new pattern', async function() {
+ sub = watchWorkspaceItem(workspace, 'atom-github://item0/{pattern}', component, 'theKey');
+ assert.isFalse(component.state.theKey);
+
+ await workspace.open('atom-github://item1/match');
+ assert.isFalse(component.setState.called);
+
+ await sub.setPattern('atom-github://item1/{pattern}');
+ assert.isFalse(component.state.theKey);
+ assert.isTrue(component.setState.calledWith({theKey: true}));
+ });
+
+ it('uses the new pattern to keep state up to date', async function() {
+ sub = watchWorkspaceItem(workspace, 'atom-github://item0/{pattern}', component, 'theKey');
+ await sub.setPattern('atom-github://item1/{pattern}');
+
+ await workspace.open('atom-github://item0/match');
+ assert.isFalse(component.setState.called);
+
+ await workspace.open('atom-github://item1/match');
+ assert.isTrue(component.setState.calledWith({theKey: true}));
+ });
+ });
+});