diff --git a/keymaps/git.cson b/keymaps/git.cson
index f6ba953f13..014ba5453f 100644
--- a/keymaps/git.cson
+++ b/keymaps/git.cson
@@ -1,22 +1,23 @@
-'.platform-darwin':
- 'cmd-shift-c': 'github:toggle-git-panel'
-
-'.platform-win32, .platform-linux':
- 'alt-shift-c': 'github:toggle-git-panel'
+'.workspace':
+ 'ctrl-9': 'github:toggle-git-panel'
+ 'ctrl-(': 'github:toggle-git-panel-focus' # ctrl-shift-9
'.github-StagingView':
'left': 'github:focus-diff-view'
+ 'tab': 'core:focus-next'
+ 'shift-tab': 'core:focus-previous'
'.github-CommitView-editor atom-text-editor:not([mini])':
'cmd-enter': 'github:commit'
'ctrl-enter': 'github:commit'
+ 'shift-tab': 'core:focus-previous'
'.github-FilePatchView':
'/': 'github:toggle-patch-selection-mode'
'tab': 'github:select-next-hunk'
'shift-tab': 'github:select-previous-hunk'
- 'right': 'github:focus-git-panel'
+ 'right': 'core:move-right'
'.github-Prompt-input':
'enter': 'core:confirm'
- 'esc': 'core:cancel'
+ 'esc': 'tool-panel:unfocus'
diff --git a/lib/controllers/commit-view-controller.js b/lib/controllers/commit-view-controller.js
index 3265c4772b..155a7dfbe8 100644
--- a/lib/controllers/commit-view-controller.js
+++ b/lib/controllers/commit-view-controller.js
@@ -62,6 +62,7 @@ export default class CommitViewController {
isAmending={this.props.isAmending}
lastCommit={this.props.lastCommit}
onChangeMessage={this.handleMessageChange}
+ didMoveUpOnFirstLine={this.props.didMoveUpOnFirstLine}
/>
);
}
@@ -82,6 +83,14 @@ export default class CommitViewController {
etch.update(this);
}
+ focus() {
+ this.refs.commitView.focus();
+ }
+
+ isFocused() {
+ return this.refs.commitView.isFocused();
+ }
+
destroy() {
this.repoStateRegistry.save();
return etch.destroy(this);
diff --git a/lib/controllers/file-patch-controller.js b/lib/controllers/file-patch-controller.js
index 5de7bd57ce..0f7ed41ebe 100644
--- a/lib/controllers/file-patch-controller.js
+++ b/lib/controllers/file-patch-controller.js
@@ -15,6 +15,7 @@ export default class FilePatchController {
this.attemptHunkStageOperation = this.attemptHunkStageOperation.bind(this);
this.attemptLineStageOperation = this.attemptLineStageOperation.bind(this);
+ this.didSurfaceFile = this.didSurfaceFile.bind(this);
etch.initialize(this);
}
@@ -44,7 +45,9 @@ export default class FilePatchController {
@@ -152,9 +158,11 @@ export default class GitController extends React.Component {
{ this.filePatchController = c; }} reattachDomNode={false}>
@@ -190,6 +198,17 @@ export default class GitController extends React.Component {
}
}
+ async diveIntoFilePatchForPath(filePath, stagingStatus, {amending} = {}) {
+ await this.showFilePatchForPath(filePath, stagingStatus, {activate: true, amending});
+ this.focusFilePatchView();
+ }
+
+ surfaceFromFileAtPath(filePath, stagingStatus) {
+ if (this.gitPanelController) {
+ this.gitPanelController.getWrappedComponent().focusAndSelectStagingItem(filePath, stagingStatus);
+ }
+ }
+
onRepoRefresh() {
return this.showFilePatchForPath(this.state.filePath, this.state.stagingStatus, {amending: this.state.amending});
}
@@ -204,6 +223,10 @@ export default class GitController extends React.Component {
}
}
+ diveIntoMergeConflictFileForPath(relativeFilePath) {
+ return this.showMergeConflictFileForPath(relativeFilePath, {focus: true});
+ }
+
didChangeAmending(isAmending) {
this.setState({amending: isAmending});
return this.showFilePatchForPath(this.state.filePath, this.state.stagingStatus, {amending: isAmending});
@@ -213,23 +236,28 @@ export default class GitController extends React.Component {
this.setState(state => ({gitPanelActive: !state.gitPanelActive}));
}
- openAndFocusGitPanel() {
+ toggleGitPanelFocus() {
if (!this.state.gitPanelActive) {
- this.setState({gitPanelActive: true}, () => this.focusGitPanel());
+ this.setState({gitPanelActive: true}, () => this.toggleGitPanelFocus());
+ return;
+ }
+
+ if (this.gitPanelHasFocus()) {
+ this.props.workspace.getActivePane().activate();
} else {
this.focusGitPanel();
}
}
focusGitPanel() {
- if (this.gitPanelController) {
- this.gitPanelController.getWrappedComponent().focus();
- }
+ this.gitPanelController.getWrappedComponent().focus();
+ }
+
+ gitPanelHasFocus() {
+ return this.gitPanelController.getWrappedComponent().isFocused();
}
focusFilePatchView() {
- if (this.filePatchController) {
- this.filePatchController.getWrappedComponent().focus();
- }
+ this.filePatchController.getWrappedComponent().focus();
}
}
diff --git a/lib/controllers/git-panel-controller.js b/lib/controllers/git-panel-controller.js
index f7867cb3e1..4324c118b9 100644
--- a/lib/controllers/git-panel-controller.js
+++ b/lib/controllers/git-panel-controller.js
@@ -37,7 +37,9 @@ export default class GitPanelController {
commandRegistry={this.props.commandRegistry}
notificationManager={this.props.notificationManager}
didSelectFilePath={this.props.didSelectFilePath}
+ didDiveIntoFilePath={this.props.didDiveIntoFilePath}
didSelectMergeConflictFile={this.props.didSelectMergeConflictFile}
+ didDiveIntoMergeConflictPath={this.props.didDiveIntoMergeConflictPath}
focusFilePatchView={this.props.focusFilePatchView}
stageFilePatch={this.stageFilePatch}
unstageFilePatch={this.unstageFilePatch}
@@ -210,8 +212,14 @@ export default class GitPanelController {
}
focus() {
- if (this.refs.gitPanel) {
- this.refs.gitPanel.focus();
- }
+ this.refs.gitPanel.focus();
+ }
+
+ focusAndSelectStagingItem(filePath, stagingStatus) {
+ return this.refs.gitPanel.focusAndSelectStagingItem(filePath, stagingStatus);
+ }
+
+ isFocused() {
+ return this.refs.gitPanel.isFocused();
}
}
diff --git a/lib/views/commit-view.js b/lib/views/commit-view.js
index 5dc6de4317..d9d4712200 100644
--- a/lib/views/commit-view.js
+++ b/lib/views/commit-view.js
@@ -11,17 +11,22 @@ const COMMIT_GRAMMAR_SCOPE = 'text.git-commit';
export default class CommitView {
constructor(props) {
this.props = props;
+
+ this.commit = this.commit.bind(this);
this.abortMerge = this.abortMerge.bind(this);
this.handleAmendBoxClick = this.handleAmendBoxClick.bind(this);
this.commit = this.commit.bind(this);
this.abortMerge = this.abortMerge.bind(this);
etch.initialize(this);
+
this.editor = this.refs.editor;
+ // FIXME Use props-injected view registry instead of the Atom global
+ this.editorElement = atom.views.getView(this.editor);
this.editor.setText(this.props.message || '');
this.subscriptions = new CompositeDisposable(
this.editor.onDidChange(() => this.props.onChangeMessage && this.props.onChangeMessage(this.editor.getText())),
this.editor.onDidChangeCursorPosition(() => { etch.update(this); }),
- props.commandRegistry.add(this.element, {'github:commit': () => this.commit()}),
+ props.commandRegistry.add(this.element, {'github:commit': this.commit}),
);
const grammar = atom.grammars.grammarForScopeName(COMMIT_GRAMMAR_SCOPE);
@@ -146,4 +151,12 @@ export default class CommitView {
}
}
}
+
+ focus() {
+ this.editorElement.focus();
+ }
+
+ isFocused() {
+ return this.element === document.activeElement || this.element.contains(document.activeElement);
+ }
}
diff --git a/lib/views/composite-list-selection.js b/lib/views/composite-list-selection.js
index 37e3d7a312..fee9133f95 100644
--- a/lib/views/composite-list-selection.js
+++ b/lib/views/composite-list-selection.js
@@ -113,6 +113,16 @@ export default class CompositeListSelection {
return false;
}
+ activateLastSelection() {
+ for (let i = this.selections.length - 1; i >= 0; i--) {
+ if (this.selections[i].getItems().length > 0) {
+ this.activeSelectionIndex = i;
+ return true;
+ }
+ }
+ return false;
+ }
+
selectItem(item, preserveTail = false) {
const selection = this.selectionForItem(item);
if (!selection) { throw new Error(`No item found: ${item}`); }
@@ -160,11 +170,14 @@ export default class CompositeListSelection {
if (!preserveTail && this.getActiveSelection().getHeadItem() === this.getActiveSelection().getLastItem()) {
if (this.activateNextSelection()) {
this.getActiveSelection().selectFirstItem();
+ return true;
} else {
this.getActiveSelection().selectLastItem();
+ return false;
}
} else {
this.getActiveSelection().selectNextItem(preserveTail);
+ return true;
}
}
@@ -172,11 +185,26 @@ export default class CompositeListSelection {
if (!preserveTail && this.getActiveSelection().getHeadItem() === this.getActiveSelection().getItems()[0]) {
if (this.activatePreviousSelection()) {
this.getActiveSelection().selectLastItem();
+ return true;
} else {
this.getActiveSelection().selectFirstItem();
+ return false;
}
} else {
this.getActiveSelection().selectPreviousItem(preserveTail);
+ return true;
+ }
+ }
+
+ findItem(predicate) {
+ for (let i = 0; i < this.selections.length; i++) {
+ const selection = this.selections[i];
+ const key = this.keysBySelection.get(selection);
+ const found = selection.getItems().find(item => predicate(item, key));
+ if (found !== undefined) {
+ return found;
+ }
}
+ return null;
}
}
diff --git a/lib/views/file-patch-view.js b/lib/views/file-patch-view.js
index 3d57dcbc5e..5cfd8ebbd1 100644
--- a/lib/views/file-patch-view.js
+++ b/lib/views/file-patch-view.js
@@ -13,11 +13,12 @@ export default class FilePatchView {
this.props = props;
this.selection = new FilePatchSelection(this.props.hunks);
this.fontSize = atom.config.get('editor.fontSize');
-
this.mouseSelectionInProgress = false;
+
this.mousedownOnLine = this.mousedownOnLine.bind(this);
this.mousemoveOnLine = this.mousemoveOnLine.bind(this);
this.mouseup = this.mouseup.bind(this);
+
window.addEventListener('mouseup', this.mouseup);
this.disposables = new CompositeDisposable();
this.disposables.add(new Disposable(() => window.removeEventListener('mouseup', this.mouseup)));
@@ -27,11 +28,13 @@ export default class FilePatchView {
}));
etch.initialize(this);
- this.disposables.add(atom.commands.add(this.element, {
- 'github:toggle-patch-selection-mode': this.togglePatchSelectionMode.bind(this),
+
+ this.disposables.add(this.props.commandRegistry.add(this.element, {
+ 'github:toggle-patch-selection-mode': () => this.togglePatchSelectionMode(),
'core:confirm': () => this.didConfirm(),
'core:move-up': () => this.selectPrevious(),
'core:move-down': () => this.selectNext(),
+ 'core:move-right': () => this.didMoveRight(),
'core:move-to-top': () => this.selectFirst(),
'core:move-to-bottom': () => this.selectLast(),
'core:select-up': () => this.selectToPrevious(),
@@ -205,6 +208,12 @@ export default class FilePatchView {
return this.props.attemptLineStageOperation(this.selection.getSelectedLines());
}
+ didMoveRight() {
+ if (this.props.didSurfaceFile) {
+ this.props.didSurfaceFile();
+ }
+ }
+
focus() {
this.element.focus();
}
diff --git a/lib/views/git-panel-view.js b/lib/views/git-panel-view.js
index 755aaa2b09..64ec419be3 100644
--- a/lib/views/git-panel-view.js
+++ b/lib/views/git-panel-view.js
@@ -3,6 +3,7 @@
/* eslint react/no-unknown-property: "off" */
import etch from 'etch';
+import {Disposable, CompositeDisposable} from 'atom';
import StagingView from './staging-view';
import CommitViewController from '../controllers/commit-view-controller';
@@ -10,7 +11,23 @@ import CommitViewController from '../controllers/commit-view-controller';
export default class GitPanelView {
constructor(props) {
this.props = props;
+ this.blur = this.blur.bind(this);
+ this.rememberLastFocus = this.rememberLastFocus.bind(this);
+ this.advanceFocus = this.advanceFocus.bind(this);
+ this.retreatFocus = this.retreatFocus.bind(this);
+
etch.initialize(this);
+
+ this.element.addEventListener('focusin', this.rememberLastFocus);
+
+ this.subscriptions = new CompositeDisposable(
+ this.props.commandRegistry.add(this.element, {
+ 'tool-panel:unfocus': this.blur,
+ 'core:focus-next': this.advanceFocus,
+ 'core:focus-previous': this.retreatFocus,
+ }),
+ new Disposable(() => this.element.removeEventListener('focusin', this.rememberLastFocus)),
+ );
}
update(props) {
@@ -33,9 +50,10 @@ export default class GitPanelView {
);
} else {
return (
-
+
this.selectPrevious(),
'core:move-down': () => this.selectNext(),
+ 'core:move-left': () => this.diveIntoSelection(),
'core:select-up': () => this.selectPrevious(true),
'core:select-down': () => this.selectNext(true),
'core:select-all': () => this.selectAll(),
@@ -79,13 +85,36 @@ export default class StagingView {
}
activateNextList() {
- this.selection.activateNextSelection();
- return etch.update(this);
+ if (!this.selection.activateNextSelection()) {
+ return false;
+ }
+
+ this.selection.coalesce();
+ this.didChangeSelectedItems();
+ etch.update(this);
+ return true;
}
activatePreviousList() {
- this.selection.activatePreviousSelection();
- return etch.update(this);
+ if (!this.selection.activatePreviousSelection()) {
+ return false;
+ }
+
+ this.selection.coalesce();
+ this.didChangeSelectedItems();
+ etch.update(this);
+ return true;
+ }
+
+ activateLastList() {
+ if (!this.selection.activateLastSelection()) {
+ return false;
+ }
+
+ this.selection.coalesce();
+ this.didChangeSelectedItems();
+ etch.update(this);
+ return true;
}
confirmSelectedItems() {
@@ -131,11 +160,45 @@ export default class StagingView {
return etch.update(this);
}
+ diveIntoSelection() {
+ const selectedItems = this.selection.getSelectedItems();
+ if (selectedItems.size !== 1) {
+ return;
+ }
+
+ const selectedItem = selectedItems.values().next().value;
+ const stagingStatus = this.selection.getActiveListKey();
+
+ if (stagingStatus === 'conflicts') {
+ if (this.props.didDiveIntoMergeConflictPath) {
+ this.props.didDiveIntoMergeConflictPath(selectedItem.filePath);
+ }
+ } else {
+ if (this.props.didDiveIntoFilePath) {
+ const amending = this.props.isAmending && this.selection.getActiveListKey() === 'staged';
+ this.props.didDiveIntoFilePath(selectedItem.filePath, this.selection.getActiveListKey(), {amending});
+ }
+ }
+ }
+
writeAfterUpdate() {
const headItem = this.selection.getHeadItem();
if (headItem) { this.listElementsByItem.get(headItem).scrollIntoViewIfNeeded(); }
}
+ // Directly modify the selection to include only the item identified by the file path and stagingStatus tuple.
+ // Re-render the component, but don't notify didSelectSingleItem() or other callback functions. This is useful to
+ // avoid circular callback loops for actions originating in FilePatchView or TextEditors with merge conflicts.
+ quietlySelectItem(filePath, stagingStatus) {
+ const item = this.selection.findItem((each, key) => each.filePath === filePath && key === stagingStatus);
+ if (!item) {
+ return Promise.reject(new Error(`Unable to find item at path ${filePath} with staging status ${stagingStatus}`));
+ }
+
+ this.selection.selectItem(item);
+ return etch.update(this);
+ }
+
didChangeSelectedItems() {
const selectedItems = Array.from(this.selection.getSelectedItems());
if (this.isFocused() && selectedItems.length === 1) {
diff --git a/test/controllers/file-patch-controller.test.js b/test/controllers/file-patch-controller.test.js
index f062145159..ee03e742b0 100644
--- a/test/controllers/file-patch-controller.test.js
+++ b/test/controllers/file-patch-controller.test.js
@@ -10,9 +10,20 @@ import Hunk from '../../lib/models/hunk';
import HunkLine from '../../lib/models/hunk-line';
describe('FilePatchController', () => {
+ let atomEnv, commandRegistry;
+
+ beforeEach(() => {
+ atomEnv = global.buildAtomEnvironment();
+ commandRegistry = atomEnv.commands;
+ });
+
+ afterEach(() => {
+ atomEnv.destroy();
+ });
+
it('bases its tab title on the staging status', () => {
const filePatch1 = new FilePatch('a.txt', 'a.txt', 'modified', [new Hunk(1, 1, 1, 3, [])]);
- const controller = new FilePatchController({filePatch: filePatch1, stagingStatus: 'unstaged'});
+ const controller = new FilePatchController({commandRegistry, filePatch: filePatch1, stagingStatus: 'unstaged'});
assert.equal(controller.getTitle(), 'Unstaged Changes: a.txt');
const changeHandler = sinon.spy();
@@ -25,7 +36,7 @@ describe('FilePatchController', () => {
it('renders FilePatchView only if FilePatch has hunks', async () => {
const emptyFilePatch = new FilePatch('a.txt', 'a.txt', 'modified', []);
- const controller = new FilePatchController({filePatch: emptyFilePatch}); // eslint-disable-line no-new
+ const controller = new FilePatchController({commandRegistry, filePatch: emptyFilePatch}); // eslint-disable-line no-new
assert.isUndefined(controller.refs.filePatchView);
const hunk1 = new Hunk(0, 0, 1, 1, [new HunkLine('line-1', 'added', 1, 1)]);
@@ -39,7 +50,7 @@ describe('FilePatchController', () => {
const hunk2 = new Hunk(8, 8, 1, 1, [new HunkLine('line-5', 'deleted', 8, -1)]);
const hunkViewsByHunk = new Map();
const filePatch = new FilePatch('a.txt', 'a.txt', 'modified', [hunk1, hunk2]);
- const controller = new FilePatchController({filePatch, registerHunkView: (hunk, ctrl) => hunkViewsByHunk.set(hunk, ctrl)}); // eslint-disable-line no-new
+ const controller = new FilePatchController({commandRegistry, filePatch, registerHunkView: (hunk, ctrl) => hunkViewsByHunk.set(hunk, ctrl)}); // eslint-disable-line no-new
assert(hunkViewsByHunk.get(hunk1) != null);
assert(hunkViewsByHunk.get(hunk2) != null);
@@ -51,6 +62,15 @@ describe('FilePatchController', () => {
assert(hunkViewsByHunk.get(hunk3) != null);
});
+ it('invokes a didSurfaceFile callback with the current file path', () => {
+ const filePatch1 = new FilePatch('a.txt', 'a.txt', 'modified', [new Hunk(1, 1, 1, 3, [])]);
+ const didSurfaceFile = sinon.spy();
+ const controller = new FilePatchController({commandRegistry, filePatch: filePatch1, stagingStatus: 'unstaged', didSurfaceFile});
+
+ commandRegistry.dispatch(controller.refs.filePatchView.element, 'core:move-right');
+ assert.isTrue(didSurfaceFile.calledWith('a.txt', 'unstaged'));
+ });
+
describe('integration tests', () => {
it('stages and unstages hunks when the stage button is clicked on hunk views with no individual lines selected', async () => {
const workdirPath = await cloneRepository('multi-line-file');
@@ -70,7 +90,7 @@ describe('FilePatchController', () => {
const hunkViewsByHunk = new Map();
function registerHunkView(hunk, view) { hunkViewsByHunk.set(hunk, view); }
- const controller = new FilePatchController({filePatch: unstagedFilePatch, repository, stagingStatus: 'unstaged', registerHunkView});
+ const controller = new FilePatchController({commandRegistry, filePatch: unstagedFilePatch, repository, stagingStatus: 'unstaged', registerHunkView});
const view = controller.refs.filePatchView;
await view.selectNext();
const hunkToStage = hunkViewsByHunk.get(unstagedFilePatch.getHunks()[0]);
@@ -110,7 +130,7 @@ describe('FilePatchController', () => {
function registerHunkView(hunk, view) { hunkViewsByHunk.set(hunk, view); }
// stage a subset of lines from first hunk
- const controller = new FilePatchController({filePatch: unstagedFilePatch, repository, stagingStatus: 'unstaged', registerHunkView});
+ const controller = new FilePatchController({commandRegistry, filePatch: unstagedFilePatch, repository, stagingStatus: 'unstaged', registerHunkView});
const view = controller.refs.filePatchView;
let hunk = unstagedFilePatch.getHunks()[0];
let lines = hunk.getLines();
@@ -191,7 +211,7 @@ describe('FilePatchController', () => {
const hunkViewsByHunk = new Map();
function registerHunkView(hunk, view) { hunkViewsByHunk.set(hunk, view); }
- const controller = new FilePatchController({filePatch: unstagedFilePatch, repository, stagingStatus: 'unstaged', registerHunkView});
+ const controller = new FilePatchController({commandRegistry, filePatch: unstagedFilePatch, repository, stagingStatus: 'unstaged', registerHunkView});
const view = controller.refs.filePatchView;
let hunk = unstagedFilePatch.getHunks()[0];
let lines = hunk.getLines();
@@ -256,7 +276,7 @@ describe('FilePatchController', () => {
const hunkViewsByHunk = new Map();
function registerHunkView(hunk, view) { hunkViewsByHunk.set(hunk, view); }
- const controller = new FilePatchController({filePatch: unstagedFilePatch, repository, stagingStatus: 'unstaged', registerHunkView});
+ const controller = new FilePatchController({commandRegistry, filePatch: unstagedFilePatch, repository, stagingStatus: 'unstaged', registerHunkView});
let hunk = unstagedFilePatch.getHunks()[0];
let hunkView = hunkViewsByHunk.get(hunk);
diff --git a/test/controllers/git-controller.test.js b/test/controllers/git-controller.test.js
index 3da688d5e5..417a22e0cb 100644
--- a/test/controllers/git-controller.test.js
+++ b/test/controllers/git-controller.test.js
@@ -31,8 +31,8 @@ describe('GitController', () => {
atomEnv.destroy();
});
- describe('showMergeConflictFileForPath(filePath)', () => {
- it('opens the file as a pending pane item if it exsits', async () => {
+ describe('showMergeConflictFileForPath(relativeFilePath, {focus} = {})', () => {
+ it('opens the file as a pending pane item if it exists', async () => {
const workdirPath = await cloneRepository('merge-conflict');
const repository = await buildRepository(workdirPath);
sinon.spy(workspace, 'open');
@@ -64,6 +64,21 @@ describe('GitController', () => {
});
});
+ describe('diveIntoMergeConflictFileForPath(relativeFilePath)', () => {
+ it('opens the file and focuses the pane', async () => {
+ const workdirPath = await cloneRepository('merge-conflict');
+ const repository = await buildRepository(workdirPath);
+ sinon.spy(workspace, 'open');
+ app = React.cloneElement(app, {repository});
+ const wrapper = shallow(app);
+
+ await wrapper.instance().diveIntoMergeConflictFileForPath('added-to-both.txt');
+
+ assert.equal(workspace.open.callCount, 1);
+ assert.deepEqual(workspace.open.args[0], [path.join(workdirPath, 'added-to-both.txt'), {activatePane: true, pending: true}]);
+ });
+ });
+
describe('rendering a FilePatch', () => {
it('renders the FilePatchController based on state', async () => {
const workdirPath = await cloneRepository('three-files');
@@ -175,6 +190,34 @@ describe('GitController', () => {
});
});
+ describe('diveIntoFilePatchForPath(filePath, staged, {amending, activate})', () => {
+ it('reveals and focuses the file patch', async () => {
+ const workdirPath = await cloneRepository('three-files');
+ const repository = await buildRepository(workdirPath);
+
+ fs.writeFileSync(path.join(workdirPath, 'a.txt'), 'change', 'utf8');
+ await repository.refresh();
+
+ app = React.cloneElement(app, {repository});
+ const wrapper = shallow(app);
+
+ const focusFilePatch = sinon.spy();
+ wrapper.instance().filePatchController = {
+ getWrappedComponent: () => {
+ return {focus: focusFilePatch};
+ },
+ };
+
+ await wrapper.instance().diveIntoFilePatchForPath('a.txt', 'unstaged');
+
+ assert.equal(wrapper.state('filePath'), 'a.txt');
+ assert.equal(wrapper.state('filePatch').getPath(), 'a.txt');
+ assert.equal(wrapper.state('stagingStatus'), 'unstaged');
+
+ assert.isTrue(focusFilePatch.called);
+ });
+ });
+
describe('when amend mode is toggled in the staging panel while viewing a staged change', () => {
it('refetches the FilePatch with the amending flag toggled', async () => {
const workdirPath = await cloneRepository('multiple-commits');
@@ -226,22 +269,55 @@ describe('GitController', () => {
});
});
- describe('openAndFocusGitPanel()', () => {
- it('shows-and-focuses the git panel', async () => {
+ describe('toggleGitPanelFocus()', () => {
+ let wrapper;
+
+ beforeEach(async () => {
const workdirPath = await cloneRepository('multiple-commits');
const repository = await buildRepository(workdirPath);
app = React.cloneElement(app, {repository});
- const wrapper = shallow(app);
+ wrapper = shallow(app);
- sinon.spy(wrapper.instance(), 'focusGitPanel');
+ sinon.stub(wrapper.instance(), 'focusGitPanel');
+ sinon.spy(workspace.getActivePane(), 'activate');
+ });
+
+ it('opens and focuses the Git panel when it is initially closed', () => {
assert.isFalse(wrapper.find('Panel').prop('visible'));
- wrapper.instance().openAndFocusGitPanel();
+ sinon.stub(wrapper.instance(), 'gitPanelHasFocus').returns(false);
+
+ wrapper.instance().toggleGitPanelFocus();
+
+ assert.isTrue(wrapper.find('Panel').prop('visible'));
+ assert.equal(wrapper.instance().focusGitPanel.callCount, 1);
+ assert.isFalse(workspace.getActivePane().activate.called);
+ });
+
+ it('focuses the Git panel when it is already open, but blurred', () => {
+ wrapper.instance().toggleGitPanel();
+ sinon.stub(wrapper.instance(), 'gitPanelHasFocus').returns(false);
+
+ assert.isTrue(wrapper.find('Panel').prop('visible'));
+
+ wrapper.instance().toggleGitPanelFocus();
+
assert.isTrue(wrapper.find('Panel').prop('visible'));
- // TODO: remove this once we figure out the odd behavior that requires
- // a setTimeout in openAndFocusGitPanel's setState callbasck
- await new Promise(res => setTimeout(res, 250));
assert.equal(wrapper.instance().focusGitPanel.callCount, 1);
+ assert.isFalse(workspace.getActivePane().activate.called);
+ });
+
+ it('blurs the Git panel when it is already open and focused', () => {
+ wrapper.instance().toggleGitPanel();
+ sinon.stub(wrapper.instance(), 'gitPanelHasFocus').returns(true);
+
+ assert.isTrue(wrapper.find('Panel').prop('visible'));
+
+ wrapper.instance().toggleGitPanelFocus();
+
+ assert.isTrue(wrapper.find('Panel').prop('visible'));
+ assert.equal(wrapper.instance().focusGitPanel.callCount, 0);
+ assert.isTrue(workspace.getActivePane().activate.called);
});
});
diff --git a/test/controllers/git-panel-controller.test.js b/test/controllers/git-panel-controller.test.js
index 81c2eb063b..eafa62d29e 100644
--- a/test/controllers/git-panel-controller.test.js
+++ b/test/controllers/git-panel-controller.test.js
@@ -34,12 +34,14 @@ describe('GitPanelController', () => {
assert.equal(controller.getActiveRepository(), repository);
assert.isDefined(controller.refs.gitPanel.refs.repoLoadingMessage);
- assert.isUndefined(controller.refs.gitPanel.refs.repoInfo);
+ assert.isUndefined(controller.refs.gitPanel.refs.stagingView);
+ assert.isUndefined(controller.refs.gitPanel.refs.commitView);
await controller.getLastModelDataRefreshPromise();
assert.equal(controller.getActiveRepository(), repository);
assert.isUndefined(controller.refs.gitPanel.refs.repoLoadingMessage);
- assert.isDefined(controller.refs.gitPanel.refs.repoInfo);
+ assert.isDefined(controller.refs.gitPanel.refs.stagingView);
+ assert.isDefined(controller.refs.gitPanel.refs.commitViewController);
});
it('keeps the state of the GitPanelView in sync with the assigned repository', async () => {
@@ -165,6 +167,118 @@ describe('GitPanelController', () => {
});
});
+ it('selects an item by description', async () => {
+ const workdirPath = await cloneRepository('three-files');
+ const repository = await buildRepository(workdirPath);
+
+ 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.');
+ await repository.refresh();
+
+ const controller = new GitPanelController({workspace, commandRegistry, repository});
+ await controller.getLastModelDataRefreshPromise();
+
+ const gitPanel = controller.refs.gitPanel;
+ const stagingView = gitPanel.refs.stagingView;
+
+ sinon.spy(stagingView, 'focus');
+
+ await controller.focusAndSelectStagingItem('unstaged-2.txt', 'unstaged');
+
+ const selections = Array.from(stagingView.selection.getSelectedItems());
+ assert.equal(selections.length, 1);
+ assert.equal(selections[0].filePath, 'unstaged-2.txt');
+
+ assert.equal(stagingView.focus.callCount, 1);
+ });
+
+ describe('keyboard navigation commands', () => {
+ let controller, gitPanel, stagingView, commitView, commitViewController, focusElement;
+
+ beforeEach(async () => {
+ const workdirPath = await cloneRepository('each-staging-group');
+ const repository = await buildRepository(workdirPath);
+
+ // Merge with conflicts
+ assert.isRejected(repository.git.merge('origin/branch'));
+
+ // Three unstaged files
+ 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']);
+
+ await repository.refresh();
+
+ controller = new GitPanelController({workspace, commandRegistry, repository});
+ await controller.getLastModelDataRefreshPromise();
+
+ gitPanel = controller.refs.gitPanel;
+ stagingView = gitPanel.refs.stagingView;
+ commitViewController = gitPanel.refs.commitViewController;
+ commitView = commitViewController.refs.commitView;
+ focusElement = stagingView;
+
+ sinon.stub(commitView, 'focus', () => { focusElement = commitView; });
+ sinon.stub(commitView, 'isFocused', () => focusElement === commitView);
+ sinon.stub(stagingView, 'focus', () => { focusElement = stagingView; });
+ });
+
+ const assertSelected = paths => {
+ const selectionPaths = Array.from(stagingView.selection.getSelectedItems()).map(item => item.filePath);
+ assert.deepEqual(selectionPaths, paths);
+ };
+
+ it('blurs on tool-panel:unfocus', () => {
+ sinon.spy(workspace.getActivePane(), 'activate');
+
+ commandRegistry.dispatch(controller.element, 'tool-panel:unfocus');
+
+ assert.isTrue(workspace.getActivePane().activate.called);
+ });
+
+ it('advances focus through StagingView groups and CommitView, but does not cycle', () => {
+ assertSelected(['unstaged-1.txt']);
+
+ commandRegistry.dispatch(controller.element, 'core:focus-next');
+ assertSelected(['conflict-1.txt']);
+
+ commandRegistry.dispatch(controller.element, 'core:focus-next');
+ assertSelected(['staged-1.txt']);
+
+ commandRegistry.dispatch(controller.element, 'core:focus-next');
+ assertSelected(['staged-1.txt']);
+ assert.strictEqual(focusElement, commitView);
+
+ // This should be a no-op. (Actually, it'll insert a tab in the CommitView editor.)
+ commandRegistry.dispatch(controller.element, 'core:focus-next');
+ assertSelected(['staged-1.txt']);
+ assert.strictEqual(focusElement, commitView);
+ });
+
+ it('retreats focus from the CommitView through StagingView groups, but does not cycle', () => {
+ commitView.focus();
+
+ commandRegistry.dispatch(controller.element, 'core:focus-previous');
+ assertSelected(['staged-1.txt']);
+
+ commandRegistry.dispatch(controller.element, 'core:focus-previous');
+ assertSelected(['conflict-1.txt']);
+
+ commandRegistry.dispatch(controller.element, 'core:focus-previous');
+ assertSelected(['unstaged-1.txt']);
+
+ // This should be a no-op.
+ commandRegistry.dispatch(controller.element, 'core:focus-previous');
+ assertSelected(['unstaged-1.txt']);
+ });
+ });
describe('integration tests', () => {
it('can stage and unstage files and commit', async () => {
diff --git a/test/fixtures/repo-each-staging-group/conflict-1.txt b/test/fixtures/repo-each-staging-group/conflict-1.txt
new file mode 100644
index 0000000000..26581f9ba4
--- /dev/null
+++ b/test/fixtures/repo-each-staging-group/conflict-1.txt
@@ -0,0 +1,2 @@
+This is a file that will contain a merge conflict.
+This is a change on "master".
diff --git a/test/fixtures/repo-each-staging-group/conflict-2.txt b/test/fixtures/repo-each-staging-group/conflict-2.txt
new file mode 100644
index 0000000000..26581f9ba4
--- /dev/null
+++ b/test/fixtures/repo-each-staging-group/conflict-2.txt
@@ -0,0 +1,2 @@
+This is a file that will contain a merge conflict.
+This is a change on "master".
diff --git a/test/fixtures/repo-each-staging-group/conflict-3.txt b/test/fixtures/repo-each-staging-group/conflict-3.txt
new file mode 100644
index 0000000000..26581f9ba4
--- /dev/null
+++ b/test/fixtures/repo-each-staging-group/conflict-3.txt
@@ -0,0 +1,2 @@
+This is a file that will contain a merge conflict.
+This is a change on "master".
diff --git a/test/fixtures/repo-each-staging-group/dot-git/COMMIT_EDITMSG b/test/fixtures/repo-each-staging-group/dot-git/COMMIT_EDITMSG
new file mode 100644
index 0000000000..74deba40cc
--- /dev/null
+++ b/test/fixtures/repo-each-staging-group/dot-git/COMMIT_EDITMSG
@@ -0,0 +1 @@
+Change on "master"
diff --git a/test/fixtures/repo-each-staging-group/dot-git/HEAD b/test/fixtures/repo-each-staging-group/dot-git/HEAD
new file mode 100644
index 0000000000..cb089cd89a
--- /dev/null
+++ b/test/fixtures/repo-each-staging-group/dot-git/HEAD
@@ -0,0 +1 @@
+ref: refs/heads/master
diff --git a/test/fixtures/repo-each-staging-group/dot-git/config b/test/fixtures/repo-each-staging-group/dot-git/config
new file mode 100644
index 0000000000..6c9406b7d9
--- /dev/null
+++ b/test/fixtures/repo-each-staging-group/dot-git/config
@@ -0,0 +1,7 @@
+[core]
+ repositoryformatversion = 0
+ filemode = true
+ bare = false
+ logallrefupdates = true
+ ignorecase = true
+ precomposeunicode = true
diff --git a/test/fixtures/repo-each-staging-group/dot-git/description b/test/fixtures/repo-each-staging-group/dot-git/description
new file mode 100644
index 0000000000..498b267a8c
--- /dev/null
+++ b/test/fixtures/repo-each-staging-group/dot-git/description
@@ -0,0 +1 @@
+Unnamed repository; edit this file 'description' to name the repository.
diff --git a/test/fixtures/repo-each-staging-group/dot-git/hooks/applypatch-msg.sample b/test/fixtures/repo-each-staging-group/dot-git/hooks/applypatch-msg.sample
new file mode 100755
index 0000000000..a5d7b84a67
--- /dev/null
+++ b/test/fixtures/repo-each-staging-group/dot-git/hooks/applypatch-msg.sample
@@ -0,0 +1,15 @@
+#!/bin/sh
+#
+# An example hook script to check the commit log message taken by
+# applypatch from an e-mail message.
+#
+# The hook should exit with non-zero status after issuing an
+# appropriate message if it wants to stop the commit. The hook is
+# allowed to edit the commit message file.
+#
+# To enable this hook, rename this file to "applypatch-msg".
+
+. git-sh-setup
+commitmsg="$(git rev-parse --git-path hooks/commit-msg)"
+test -x "$commitmsg" && exec "$commitmsg" ${1+"$@"}
+:
diff --git a/test/fixtures/repo-each-staging-group/dot-git/hooks/commit-msg.sample b/test/fixtures/repo-each-staging-group/dot-git/hooks/commit-msg.sample
new file mode 100755
index 0000000000..b58d1184a9
--- /dev/null
+++ b/test/fixtures/repo-each-staging-group/dot-git/hooks/commit-msg.sample
@@ -0,0 +1,24 @@
+#!/bin/sh
+#
+# An example hook script to check the commit log message.
+# Called by "git commit" with one argument, the name of the file
+# that has the commit message. The hook should exit with non-zero
+# status after issuing an appropriate message if it wants to stop the
+# commit. The hook is allowed to edit the commit message file.
+#
+# To enable this hook, rename this file to "commit-msg".
+
+# Uncomment the below to add a Signed-off-by line to the message.
+# Doing this in a hook is a bad idea in general, but the prepare-commit-msg
+# hook is more suited to it.
+#
+# SOB=$(git var GIT_AUTHOR_IDENT | sed -n 's/^\(.*>\).*$/Signed-off-by: \1/p')
+# grep -qs "^$SOB" "$1" || echo "$SOB" >> "$1"
+
+# This example catches duplicate Signed-off-by lines.
+
+test "" = "$(grep '^Signed-off-by: ' "$1" |
+ sort | uniq -c | sed -e '/^[ ]*1[ ]/d')" || {
+ echo >&2 Duplicate Signed-off-by lines.
+ exit 1
+}
diff --git a/test/fixtures/repo-each-staging-group/dot-git/hooks/post-update.sample b/test/fixtures/repo-each-staging-group/dot-git/hooks/post-update.sample
new file mode 100755
index 0000000000..ec17ec1939
--- /dev/null
+++ b/test/fixtures/repo-each-staging-group/dot-git/hooks/post-update.sample
@@ -0,0 +1,8 @@
+#!/bin/sh
+#
+# An example hook script to prepare a packed repository for use over
+# dumb transports.
+#
+# To enable this hook, rename this file to "post-update".
+
+exec git update-server-info
diff --git a/test/fixtures/repo-each-staging-group/dot-git/hooks/pre-applypatch.sample b/test/fixtures/repo-each-staging-group/dot-git/hooks/pre-applypatch.sample
new file mode 100755
index 0000000000..4142082bcb
--- /dev/null
+++ b/test/fixtures/repo-each-staging-group/dot-git/hooks/pre-applypatch.sample
@@ -0,0 +1,14 @@
+#!/bin/sh
+#
+# An example hook script to verify what is about to be committed
+# by applypatch from an e-mail message.
+#
+# The hook should exit with non-zero status after issuing an
+# appropriate message if it wants to stop the commit.
+#
+# To enable this hook, rename this file to "pre-applypatch".
+
+. git-sh-setup
+precommit="$(git rev-parse --git-path hooks/pre-commit)"
+test -x "$precommit" && exec "$precommit" ${1+"$@"}
+:
diff --git a/test/fixtures/repo-each-staging-group/dot-git/hooks/pre-commit.sample b/test/fixtures/repo-each-staging-group/dot-git/hooks/pre-commit.sample
new file mode 100755
index 0000000000..68d62d5446
--- /dev/null
+++ b/test/fixtures/repo-each-staging-group/dot-git/hooks/pre-commit.sample
@@ -0,0 +1,49 @@
+#!/bin/sh
+#
+# An example hook script to verify what is about to be committed.
+# Called by "git commit" with no arguments. The hook should
+# exit with non-zero status after issuing an appropriate message if
+# it wants to stop the commit.
+#
+# To enable this hook, rename this file to "pre-commit".
+
+if git rev-parse --verify HEAD >/dev/null 2>&1
+then
+ against=HEAD
+else
+ # Initial commit: diff against an empty tree object
+ against=4b825dc642cb6eb9a060e54bf8d69288fbee4904
+fi
+
+# If you want to allow non-ASCII filenames set this variable to true.
+allownonascii=$(git config --bool hooks.allownonascii)
+
+# Redirect output to stderr.
+exec 1>&2
+
+# Cross platform projects tend to avoid non-ASCII filenames; prevent
+# them from being added to the repository. We exploit the fact that the
+# printable range starts at the space character and ends with tilde.
+if [ "$allownonascii" != "true" ] &&
+ # Note that the use of brackets around a tr range is ok here, (it's
+ # even required, for portability to Solaris 10's /usr/bin/tr), since
+ # the square bracket bytes happen to fall in the designated range.
+ test $(git diff --cached --name-only --diff-filter=A -z $against |
+ LC_ALL=C tr -d '[ -~]\0' | wc -c) != 0
+then
+ cat <<\EOF
+Error: Attempt to add a non-ASCII file name.
+
+This can cause problems if you want to work with people on other platforms.
+
+To be portable it is advisable to rename the file.
+
+If you know what you are doing you can disable this check using:
+
+ git config hooks.allownonascii true
+EOF
+ exit 1
+fi
+
+# If there are whitespace errors, print the offending file names and fail.
+exec git diff-index --check --cached $against --
diff --git a/test/fixtures/repo-each-staging-group/dot-git/hooks/pre-push.sample b/test/fixtures/repo-each-staging-group/dot-git/hooks/pre-push.sample
new file mode 100755
index 0000000000..6187dbf439
--- /dev/null
+++ b/test/fixtures/repo-each-staging-group/dot-git/hooks/pre-push.sample
@@ -0,0 +1,53 @@
+#!/bin/sh
+
+# An example hook script to verify what is about to be pushed. Called by "git
+# push" after it has checked the remote status, but before anything has been
+# pushed. If this script exits with a non-zero status nothing will be pushed.
+#
+# This hook is called with the following parameters:
+#
+# $1 -- Name of the remote to which the push is being done
+# $2 -- URL to which the push is being done
+#
+# If pushing without using a named remote those arguments will be equal.
+#
+# Information about the commits which are being pushed is supplied as lines to
+# the standard input in the form:
+#
+#
+#
+# This sample shows how to prevent push of commits where the log message starts
+# with "WIP" (work in progress).
+
+remote="$1"
+url="$2"
+
+z40=0000000000000000000000000000000000000000
+
+while read local_ref local_sha remote_ref remote_sha
+do
+ if [ "$local_sha" = $z40 ]
+ then
+ # Handle delete
+ :
+ else
+ if [ "$remote_sha" = $z40 ]
+ then
+ # New branch, examine all commits
+ range="$local_sha"
+ else
+ # Update to existing branch, examine new commits
+ range="$remote_sha..$local_sha"
+ fi
+
+ # Check for WIP commit
+ commit=`git rev-list -n 1 --grep '^WIP' "$range"`
+ if [ -n "$commit" ]
+ then
+ echo >&2 "Found WIP commit in $local_ref, not pushing"
+ exit 1
+ fi
+ fi
+done
+
+exit 0
diff --git a/test/fixtures/repo-each-staging-group/dot-git/hooks/pre-rebase.sample b/test/fixtures/repo-each-staging-group/dot-git/hooks/pre-rebase.sample
new file mode 100755
index 0000000000..9773ed4cb2
--- /dev/null
+++ b/test/fixtures/repo-each-staging-group/dot-git/hooks/pre-rebase.sample
@@ -0,0 +1,169 @@
+#!/bin/sh
+#
+# Copyright (c) 2006, 2008 Junio C Hamano
+#
+# The "pre-rebase" hook is run just before "git rebase" starts doing
+# its job, and can prevent the command from running by exiting with
+# non-zero status.
+#
+# The hook is called with the following parameters:
+#
+# $1 -- the upstream the series was forked from.
+# $2 -- the branch being rebased (or empty when rebasing the current branch).
+#
+# This sample shows how to prevent topic branches that are already
+# merged to 'next' branch from getting rebased, because allowing it
+# would result in rebasing already published history.
+
+publish=next
+basebranch="$1"
+if test "$#" = 2
+then
+ topic="refs/heads/$2"
+else
+ topic=`git symbolic-ref HEAD` ||
+ exit 0 ;# we do not interrupt rebasing detached HEAD
+fi
+
+case "$topic" in
+refs/heads/??/*)
+ ;;
+*)
+ exit 0 ;# we do not interrupt others.
+ ;;
+esac
+
+# Now we are dealing with a topic branch being rebased
+# on top of master. Is it OK to rebase it?
+
+# Does the topic really exist?
+git show-ref -q "$topic" || {
+ echo >&2 "No such branch $topic"
+ exit 1
+}
+
+# Is topic fully merged to master?
+not_in_master=`git rev-list --pretty=oneline ^master "$topic"`
+if test -z "$not_in_master"
+then
+ echo >&2 "$topic is fully merged to master; better remove it."
+ exit 1 ;# we could allow it, but there is no point.
+fi
+
+# Is topic ever merged to next? If so you should not be rebasing it.
+only_next_1=`git rev-list ^master "^$topic" ${publish} | sort`
+only_next_2=`git rev-list ^master ${publish} | sort`
+if test "$only_next_1" = "$only_next_2"
+then
+ not_in_topic=`git rev-list "^$topic" master`
+ if test -z "$not_in_topic"
+ then
+ echo >&2 "$topic is already up-to-date with master"
+ exit 1 ;# we could allow it, but there is no point.
+ else
+ exit 0
+ fi
+else
+ not_in_next=`git rev-list --pretty=oneline ^${publish} "$topic"`
+ /usr/bin/perl -e '
+ my $topic = $ARGV[0];
+ my $msg = "* $topic has commits already merged to public branch:\n";
+ my (%not_in_next) = map {
+ /^([0-9a-f]+) /;
+ ($1 => 1);
+ } split(/\n/, $ARGV[1]);
+ for my $elem (map {
+ /^([0-9a-f]+) (.*)$/;
+ [$1 => $2];
+ } split(/\n/, $ARGV[2])) {
+ if (!exists $not_in_next{$elem->[0]}) {
+ if ($msg) {
+ print STDERR $msg;
+ undef $msg;
+ }
+ print STDERR " $elem->[1]\n";
+ }
+ }
+ ' "$topic" "$not_in_next" "$not_in_master"
+ exit 1
+fi
+
+exit 0
+
+################################################################
+
+This sample hook safeguards topic branches that have been
+published from being rewound.
+
+The workflow assumed here is:
+
+ * Once a topic branch forks from "master", "master" is never
+ merged into it again (either directly or indirectly).
+
+ * Once a topic branch is fully cooked and merged into "master",
+ it is deleted. If you need to build on top of it to correct
+ earlier mistakes, a new topic branch is created by forking at
+ the tip of the "master". This is not strictly necessary, but
+ it makes it easier to keep your history simple.
+
+ * Whenever you need to test or publish your changes to topic
+ branches, merge them into "next" branch.
+
+The script, being an example, hardcodes the publish branch name
+to be "next", but it is trivial to make it configurable via
+$GIT_DIR/config mechanism.
+
+With this workflow, you would want to know:
+
+(1) ... if a topic branch has ever been merged to "next". Young
+ topic branches can have stupid mistakes you would rather
+ clean up before publishing, and things that have not been
+ merged into other branches can be easily rebased without
+ affecting other people. But once it is published, you would
+ not want to rewind it.
+
+(2) ... if a topic branch has been fully merged to "master".
+ Then you can delete it. More importantly, you should not
+ build on top of it -- other people may already want to
+ change things related to the topic as patches against your
+ "master", so if you need further changes, it is better to
+ fork the topic (perhaps with the same name) afresh from the
+ tip of "master".
+
+Let's look at this example:
+
+ o---o---o---o---o---o---o---o---o---o "next"
+ / / / /
+ / a---a---b A / /
+ / / / /
+ / / c---c---c---c B /
+ / / / \ /
+ / / / b---b C \ /
+ / / / / \ /
+ ---o---o---o---o---o---o---o---o---o---o---o "master"
+
+
+A, B and C are topic branches.
+
+ * A has one fix since it was merged up to "next".
+
+ * B has finished. It has been fully merged up to "master" and "next",
+ and is ready to be deleted.
+
+ * C has not merged to "next" at all.
+
+We would want to allow C to be rebased, refuse A, and encourage
+B to be deleted.
+
+To compute (1):
+
+ git rev-list ^master ^topic next
+ git rev-list ^master next
+
+ if these match, topic has not merged in next at all.
+
+To compute (2):
+
+ git rev-list master..topic
+
+ if this is empty, it is fully merged to "master".
diff --git a/test/fixtures/repo-each-staging-group/dot-git/hooks/pre-receive.sample b/test/fixtures/repo-each-staging-group/dot-git/hooks/pre-receive.sample
new file mode 100755
index 0000000000..a1fd29ec14
--- /dev/null
+++ b/test/fixtures/repo-each-staging-group/dot-git/hooks/pre-receive.sample
@@ -0,0 +1,24 @@
+#!/bin/sh
+#
+# An example hook script to make use of push options.
+# The example simply echoes all push options that start with 'echoback='
+# and rejects all pushes when the "reject" push option is used.
+#
+# To enable this hook, rename this file to "pre-receive".
+
+if test -n "$GIT_PUSH_OPTION_COUNT"
+then
+ i=0
+ while test "$i" -lt "$GIT_PUSH_OPTION_COUNT"
+ do
+ eval "value=\$GIT_PUSH_OPTION_$i"
+ case "$value" in
+ echoback=*)
+ echo "echo from the pre-receive-hook: ${value#*=}" >&2
+ ;;
+ reject)
+ exit 1
+ esac
+ i=$((i + 1))
+ done
+fi
diff --git a/test/fixtures/repo-each-staging-group/dot-git/hooks/prepare-commit-msg.sample b/test/fixtures/repo-each-staging-group/dot-git/hooks/prepare-commit-msg.sample
new file mode 100755
index 0000000000..f093a02ec4
--- /dev/null
+++ b/test/fixtures/repo-each-staging-group/dot-git/hooks/prepare-commit-msg.sample
@@ -0,0 +1,36 @@
+#!/bin/sh
+#
+# An example hook script to prepare the commit log message.
+# Called by "git commit" with the name of the file that has the
+# commit message, followed by the description of the commit
+# message's source. The hook's purpose is to edit the commit
+# message file. If the hook fails with a non-zero status,
+# the commit is aborted.
+#
+# To enable this hook, rename this file to "prepare-commit-msg".
+
+# This hook includes three examples. The first comments out the
+# "Conflicts:" part of a merge commit.
+#
+# The second includes the output of "git diff --name-status -r"
+# into the message, just before the "git status" output. It is
+# commented because it doesn't cope with --amend or with squashed
+# commits.
+#
+# The third example adds a Signed-off-by line to the message, that can
+# still be edited. This is rarely a good idea.
+
+case "$2,$3" in
+ merge,)
+ /usr/bin/perl -i.bak -ne 's/^/# /, s/^# #/#/ if /^Conflicts/ .. /#/; print' "$1" ;;
+
+# ,|template,)
+# /usr/bin/perl -i.bak -pe '
+# print "\n" . `git diff --cached --name-status -r`
+# if /^#/ && $first++ == 0' "$1" ;;
+
+ *) ;;
+esac
+
+# SOB=$(git var GIT_AUTHOR_IDENT | sed -n 's/^\(.*>\).*$/Signed-off-by: \1/p')
+# grep -qs "^$SOB" "$1" || echo "$SOB" >> "$1"
diff --git a/test/fixtures/repo-each-staging-group/dot-git/hooks/update.sample b/test/fixtures/repo-each-staging-group/dot-git/hooks/update.sample
new file mode 100755
index 0000000000..80ba94135c
--- /dev/null
+++ b/test/fixtures/repo-each-staging-group/dot-git/hooks/update.sample
@@ -0,0 +1,128 @@
+#!/bin/sh
+#
+# An example hook script to block unannotated tags from entering.
+# Called by "git receive-pack" with arguments: refname sha1-old sha1-new
+#
+# To enable this hook, rename this file to "update".
+#
+# Config
+# ------
+# hooks.allowunannotated
+# This boolean sets whether unannotated tags will be allowed into the
+# repository. By default they won't be.
+# hooks.allowdeletetag
+# This boolean sets whether deleting tags will be allowed in the
+# repository. By default they won't be.
+# hooks.allowmodifytag
+# This boolean sets whether a tag may be modified after creation. By default
+# it won't be.
+# hooks.allowdeletebranch
+# This boolean sets whether deleting branches will be allowed in the
+# repository. By default they won't be.
+# hooks.denycreatebranch
+# This boolean sets whether remotely creating branches will be denied
+# in the repository. By default this is allowed.
+#
+
+# --- Command line
+refname="$1"
+oldrev="$2"
+newrev="$3"
+
+# --- Safety check
+if [ -z "$GIT_DIR" ]; then
+ echo "Don't run this script from the command line." >&2
+ echo " (if you want, you could supply GIT_DIR then run" >&2
+ echo " $0 [ )" >&2
+ exit 1
+fi
+
+if [ -z "$refname" -o -z "$oldrev" -o -z "$newrev" ]; then
+ echo "usage: $0 ][ " >&2
+ exit 1
+fi
+
+# --- Config
+allowunannotated=$(git config --bool hooks.allowunannotated)
+allowdeletebranch=$(git config --bool hooks.allowdeletebranch)
+denycreatebranch=$(git config --bool hooks.denycreatebranch)
+allowdeletetag=$(git config --bool hooks.allowdeletetag)
+allowmodifytag=$(git config --bool hooks.allowmodifytag)
+
+# check for no description
+projectdesc=$(sed -e '1q' "$GIT_DIR/description")
+case "$projectdesc" in
+"Unnamed repository"* | "")
+ echo "*** Project description file hasn't been set" >&2
+ exit 1
+ ;;
+esac
+
+# --- Check types
+# if $newrev is 0000...0000, it's a commit to delete a ref.
+zero="0000000000000000000000000000000000000000"
+if [ "$newrev" = "$zero" ]; then
+ newrev_type=delete
+else
+ newrev_type=$(git cat-file -t $newrev)
+fi
+
+case "$refname","$newrev_type" in
+ refs/tags/*,commit)
+ # un-annotated tag
+ short_refname=${refname##refs/tags/}
+ if [ "$allowunannotated" != "true" ]; then
+ echo "*** The un-annotated tag, $short_refname, is not allowed in this repository" >&2
+ echo "*** Use 'git tag [ -a | -s ]' for tags you want to propagate." >&2
+ exit 1
+ fi
+ ;;
+ refs/tags/*,delete)
+ # delete tag
+ if [ "$allowdeletetag" != "true" ]; then
+ echo "*** Deleting a tag is not allowed in this repository" >&2
+ exit 1
+ fi
+ ;;
+ refs/tags/*,tag)
+ # annotated tag
+ if [ "$allowmodifytag" != "true" ] && git rev-parse $refname > /dev/null 2>&1
+ then
+ echo "*** Tag '$refname' already exists." >&2
+ echo "*** Modifying a tag is not allowed in this repository." >&2
+ exit 1
+ fi
+ ;;
+ refs/heads/*,commit)
+ # branch
+ if [ "$oldrev" = "$zero" -a "$denycreatebranch" = "true" ]; then
+ echo "*** Creating a branch is not allowed in this repository" >&2
+ exit 1
+ fi
+ ;;
+ refs/heads/*,delete)
+ # delete branch
+ if [ "$allowdeletebranch" != "true" ]; then
+ echo "*** Deleting a branch is not allowed in this repository" >&2
+ exit 1
+ fi
+ ;;
+ refs/remotes/*,commit)
+ # tracking branch
+ ;;
+ refs/remotes/*,delete)
+ # delete tracking branch
+ if [ "$allowdeletebranch" != "true" ]; then
+ echo "*** Deleting a tracking branch is not allowed in this repository" >&2
+ exit 1
+ fi
+ ;;
+ *)
+ # Anything else (is there anything else?)
+ echo "*** Update hook: unknown type of update to ref $refname of type $newrev_type" >&2
+ exit 1
+ ;;
+esac
+
+# --- Finished
+exit 0
diff --git a/test/fixtures/repo-each-staging-group/dot-git/index b/test/fixtures/repo-each-staging-group/dot-git/index
new file mode 100644
index 0000000000..7e9844990d
Binary files /dev/null and b/test/fixtures/repo-each-staging-group/dot-git/index differ
diff --git a/test/fixtures/repo-each-staging-group/dot-git/info/exclude b/test/fixtures/repo-each-staging-group/dot-git/info/exclude
new file mode 100644
index 0000000000..a5196d1be8
--- /dev/null
+++ b/test/fixtures/repo-each-staging-group/dot-git/info/exclude
@@ -0,0 +1,6 @@
+# git ls-files --others --exclude-from=.git/info/exclude
+# Lines that start with '#' are comments.
+# For a project mostly in C, the following would be a good set of
+# exclude patterns (uncomment them if you want to use them):
+# *.[oa]
+# *~
diff --git a/test/fixtures/repo-each-staging-group/dot-git/logs/HEAD b/test/fixtures/repo-each-staging-group/dot-git/logs/HEAD
new file mode 100644
index 0000000000..91a3a6b2e3
--- /dev/null
+++ b/test/fixtures/repo-each-staging-group/dot-git/logs/HEAD
@@ -0,0 +1,5 @@
+0000000000000000000000000000000000000000 d9bc5e55049ad4ef77cd4a4f86b192d37ca4392a Ash Wilson 1481904715 -0500 commit (initial): Initial commit
+d9bc5e55049ad4ef77cd4a4f86b192d37ca4392a d9bc5e55049ad4ef77cd4a4f86b192d37ca4392a Ash Wilson 1481904726 -0500 checkout: moving from master to branch
+d9bc5e55049ad4ef77cd4a4f86b192d37ca4392a de91ea7d16e0930634d71f2b0b159103b4f90706 Ash Wilson 1481904773 -0500 commit: Change on "branch"
+de91ea7d16e0930634d71f2b0b159103b4f90706 d9bc5e55049ad4ef77cd4a4f86b192d37ca4392a Ash Wilson 1481904776 -0500 checkout: moving from branch to master
+d9bc5e55049ad4ef77cd4a4f86b192d37ca4392a c610deedba642dc0f34080f98280a060bb8b9eb5 Ash Wilson 1481904800 -0500 commit: Change on "master"
diff --git a/test/fixtures/repo-each-staging-group/dot-git/logs/refs/heads/branch b/test/fixtures/repo-each-staging-group/dot-git/logs/refs/heads/branch
new file mode 100644
index 0000000000..ea52f63705
--- /dev/null
+++ b/test/fixtures/repo-each-staging-group/dot-git/logs/refs/heads/branch
@@ -0,0 +1,2 @@
+0000000000000000000000000000000000000000 d9bc5e55049ad4ef77cd4a4f86b192d37ca4392a Ash Wilson 1481904726 -0500 branch: Created from HEAD
+d9bc5e55049ad4ef77cd4a4f86b192d37ca4392a de91ea7d16e0930634d71f2b0b159103b4f90706 Ash Wilson 1481904773 -0500 commit: Change on "branch"
diff --git a/test/fixtures/repo-each-staging-group/dot-git/logs/refs/heads/master b/test/fixtures/repo-each-staging-group/dot-git/logs/refs/heads/master
new file mode 100644
index 0000000000..7ff0af6ff0
--- /dev/null
+++ b/test/fixtures/repo-each-staging-group/dot-git/logs/refs/heads/master
@@ -0,0 +1,2 @@
+0000000000000000000000000000000000000000 d9bc5e55049ad4ef77cd4a4f86b192d37ca4392a Ash Wilson 1481904715 -0500 commit (initial): Initial commit
+d9bc5e55049ad4ef77cd4a4f86b192d37ca4392a c610deedba642dc0f34080f98280a060bb8b9eb5 Ash Wilson 1481904800 -0500 commit: Change on "master"
diff --git a/test/fixtures/repo-each-staging-group/dot-git/objects/26/581f9ba4438d24c2a60024eaca2e4b1dd4d7bc b/test/fixtures/repo-each-staging-group/dot-git/objects/26/581f9ba4438d24c2a60024eaca2e4b1dd4d7bc
new file mode 100644
index 0000000000..735a9e2723
Binary files /dev/null and b/test/fixtures/repo-each-staging-group/dot-git/objects/26/581f9ba4438d24c2a60024eaca2e4b1dd4d7bc differ
diff --git a/test/fixtures/repo-each-staging-group/dot-git/objects/42/f7f1890fae1f6c121674916bab11ccaaa2976a b/test/fixtures/repo-each-staging-group/dot-git/objects/42/f7f1890fae1f6c121674916bab11ccaaa2976a
new file mode 100644
index 0000000000..71741327d1
Binary files /dev/null and b/test/fixtures/repo-each-staging-group/dot-git/objects/42/f7f1890fae1f6c121674916bab11ccaaa2976a differ
diff --git a/test/fixtures/repo-each-staging-group/dot-git/objects/5a/fc291bbe408f93cc841dd8e3501196d27a76bf b/test/fixtures/repo-each-staging-group/dot-git/objects/5a/fc291bbe408f93cc841dd8e3501196d27a76bf
new file mode 100644
index 0000000000..a3a3ae1f7b
Binary files /dev/null and b/test/fixtures/repo-each-staging-group/dot-git/objects/5a/fc291bbe408f93cc841dd8e3501196d27a76bf differ
diff --git a/test/fixtures/repo-each-staging-group/dot-git/objects/a4/0d809df9d9b486f172d4c708f7f2f1e83afbc6 b/test/fixtures/repo-each-staging-group/dot-git/objects/a4/0d809df9d9b486f172d4c708f7f2f1e83afbc6
new file mode 100644
index 0000000000..bb54a1f6a1
Binary files /dev/null and b/test/fixtures/repo-each-staging-group/dot-git/objects/a4/0d809df9d9b486f172d4c708f7f2f1e83afbc6 differ
diff --git a/test/fixtures/repo-each-staging-group/dot-git/objects/c1/3237e45bc6e3defa2891307f0bb6e08ae5551a b/test/fixtures/repo-each-staging-group/dot-git/objects/c1/3237e45bc6e3defa2891307f0bb6e08ae5551a
new file mode 100644
index 0000000000..e349450af5
Binary files /dev/null and b/test/fixtures/repo-each-staging-group/dot-git/objects/c1/3237e45bc6e3defa2891307f0bb6e08ae5551a differ
diff --git a/test/fixtures/repo-each-staging-group/dot-git/objects/c4/eeb2b401a860568405aede668bcb4efdaa3a21 b/test/fixtures/repo-each-staging-group/dot-git/objects/c4/eeb2b401a860568405aede668bcb4efdaa3a21
new file mode 100644
index 0000000000..81d80c1dda
Binary files /dev/null and b/test/fixtures/repo-each-staging-group/dot-git/objects/c4/eeb2b401a860568405aede668bcb4efdaa3a21 differ
diff --git a/test/fixtures/repo-each-staging-group/dot-git/objects/c6/10deedba642dc0f34080f98280a060bb8b9eb5 b/test/fixtures/repo-each-staging-group/dot-git/objects/c6/10deedba642dc0f34080f98280a060bb8b9eb5
new file mode 100644
index 0000000000..0c1e35a915
Binary files /dev/null and b/test/fixtures/repo-each-staging-group/dot-git/objects/c6/10deedba642dc0f34080f98280a060bb8b9eb5 differ
diff --git a/test/fixtures/repo-each-staging-group/dot-git/objects/ce/d523dade95b4c36b8fc8c0bbdb752adc9886db b/test/fixtures/repo-each-staging-group/dot-git/objects/ce/d523dade95b4c36b8fc8c0bbdb752adc9886db
new file mode 100644
index 0000000000..a42f6f9bdf
Binary files /dev/null and b/test/fixtures/repo-each-staging-group/dot-git/objects/ce/d523dade95b4c36b8fc8c0bbdb752adc9886db differ
diff --git a/test/fixtures/repo-each-staging-group/dot-git/objects/d9/bc5e55049ad4ef77cd4a4f86b192d37ca4392a b/test/fixtures/repo-each-staging-group/dot-git/objects/d9/bc5e55049ad4ef77cd4a4f86b192d37ca4392a
new file mode 100644
index 0000000000..c9e631182a
Binary files /dev/null and b/test/fixtures/repo-each-staging-group/dot-git/objects/d9/bc5e55049ad4ef77cd4a4f86b192d37ca4392a differ
diff --git a/test/fixtures/repo-each-staging-group/dot-git/objects/de/91ea7d16e0930634d71f2b0b159103b4f90706 b/test/fixtures/repo-each-staging-group/dot-git/objects/de/91ea7d16e0930634d71f2b0b159103b4f90706
new file mode 100644
index 0000000000..d3119378ef
Binary files /dev/null and b/test/fixtures/repo-each-staging-group/dot-git/objects/de/91ea7d16e0930634d71f2b0b159103b4f90706 differ
diff --git a/test/fixtures/repo-each-staging-group/dot-git/refs/heads/branch b/test/fixtures/repo-each-staging-group/dot-git/refs/heads/branch
new file mode 100644
index 0000000000..f9c25b51ab
--- /dev/null
+++ b/test/fixtures/repo-each-staging-group/dot-git/refs/heads/branch
@@ -0,0 +1 @@
+de91ea7d16e0930634d71f2b0b159103b4f90706
diff --git a/test/fixtures/repo-each-staging-group/dot-git/refs/heads/master b/test/fixtures/repo-each-staging-group/dot-git/refs/heads/master
new file mode 100644
index 0000000000..69247579f7
--- /dev/null
+++ b/test/fixtures/repo-each-staging-group/dot-git/refs/heads/master
@@ -0,0 +1 @@
+c610deedba642dc0f34080f98280a060bb8b9eb5
diff --git a/test/fixtures/repo-each-staging-group/staged-1.txt b/test/fixtures/repo-each-staging-group/staged-1.txt
new file mode 100644
index 0000000000..42f7f1890f
--- /dev/null
+++ b/test/fixtures/repo-each-staging-group/staged-1.txt
@@ -0,0 +1 @@
+This is a file that was already in the repository.
diff --git a/test/views/composite-list-selection.test.js b/test/views/composite-list-selection.test.js
index 881f544181..b04a8401ce 100644
--- a/test/views/composite-list-selection.test.js
+++ b/test/views/composite-list-selection.test.js
@@ -41,43 +41,43 @@ describe('CompositeListSelection', () => {
assert.equal(selection.getActiveListKey(), 'unstaged');
assertEqualSets(selection.getSelectedItems(), new Set(['a']));
- selection.selectNextItem();
+ assert.isTrue(selection.selectNextItem());
assert.equal(selection.getActiveListKey(), 'unstaged');
assertEqualSets(selection.getSelectedItems(), new Set(['b']));
- selection.selectNextItem();
+ assert.isTrue(selection.selectNextItem());
assert.equal(selection.getActiveListKey(), 'conflicts');
assertEqualSets(selection.getSelectedItems(), new Set(['c']));
- selection.selectNextItem();
+ assert.isTrue(selection.selectNextItem());
assert.equal(selection.getActiveListKey(), 'staged');
assertEqualSets(selection.getSelectedItems(), new Set(['d']));
- selection.selectNextItem();
+ assert.isTrue(selection.selectNextItem());
assert.equal(selection.getActiveListKey(), 'staged');
assertEqualSets(selection.getSelectedItems(), new Set(['e']));
- selection.selectNextItem();
+ assert.isFalse(selection.selectNextItem());
assert.equal(selection.getActiveListKey(), 'staged');
assertEqualSets(selection.getSelectedItems(), new Set(['e']));
- selection.selectPreviousItem();
+ assert.isTrue(selection.selectPreviousItem());
assert.equal(selection.getActiveListKey(), 'staged');
assertEqualSets(selection.getSelectedItems(), new Set(['d']));
- selection.selectPreviousItem();
+ assert.isTrue(selection.selectPreviousItem());
assert.equal(selection.getActiveListKey(), 'conflicts');
assertEqualSets(selection.getSelectedItems(), new Set(['c']));
- selection.selectPreviousItem();
+ assert.isTrue(selection.selectPreviousItem());
assert.equal(selection.getActiveListKey(), 'unstaged');
assertEqualSets(selection.getSelectedItems(), new Set(['b']));
- selection.selectPreviousItem();
+ assert.isTrue(selection.selectPreviousItem());
assert.equal(selection.getActiveListKey(), 'unstaged');
assertEqualSets(selection.getSelectedItems(), new Set(['a']));
- selection.selectPreviousItem();
+ assert.isFalse(selection.selectPreviousItem());
assert.equal(selection.getActiveListKey(), 'unstaged');
assertEqualSets(selection.getSelectedItems(), new Set(['a']));
});
@@ -155,7 +155,7 @@ describe('CompositeListSelection', () => {
assertEqualSets(selection.getSelectedItems(), new Set(['a']));
});
- it('allows selections to be added in the current active list, but updates the existing seleciton when activating a different list', () => {
+ it('allows selections to be added in the current active list, but updates the existing selection when activating a different list', () => {
const selection = new CompositeListSelection({
listsByKey: {
unstaged: ['a', 'b', 'c'],
@@ -209,6 +209,19 @@ describe('CompositeListSelection', () => {
selection.selectFirstItem(true);
assertEqualSets(selection.getSelectedItems(), new Set(['e', 'f', 'g']));
});
+
+ it('allows the last non-empty selection to be chosen', () => {
+ const selection = new CompositeListSelection({
+ listsByKey: {
+ unstaged: ['a', 'b', 'c'],
+ conflicts: ['e', 'f'],
+ staged: [],
+ },
+ });
+
+ assert.isTrue(selection.activateLastSelection());
+ assertEqualSets(selection.getSelectedItems(), new Set(['e']));
+ });
});
describe('updateLists(listsByKey)', () => {
diff --git a/test/views/file-patch-view.test.js b/test/views/file-patch-view.test.js
index bc986b1c0d..8430272911 100644
--- a/test/views/file-patch-view.test.js
+++ b/test/views/file-patch-view.test.js
@@ -1,6 +1,5 @@
/** @babel */
-
import FilePatchView from '../../lib/views/file-patch-view';
import Hunk from '../../lib/models/hunk';
import HunkLine from '../../lib/models/hunk-line';
@@ -8,6 +7,17 @@ import HunkLine from '../../lib/models/hunk-line';
import {assertEqualSets} from '../helpers';
describe('FilePatchView', () => {
+ let atomEnv, commandRegistry;
+
+ beforeEach(() => {
+ atomEnv = global.buildAtomEnvironment();
+ commandRegistry = atomEnv.commands;
+ });
+
+ afterEach(() => {
+ atomEnv.destroy();
+ });
+
it('allows lines and hunks to be selected via the mouse', async () => {
const hunks = [
new Hunk(1, 1, 2, 4, [
@@ -26,7 +36,7 @@ describe('FilePatchView', () => {
const hunkViews = new Map();
function registerHunkView(hunk, view) { hunkViews.set(hunk, view); }
- const filePatchView = new FilePatchView({hunks, registerHunkView});
+ const filePatchView = new FilePatchView({commandRegistry, hunks, registerHunkView});
const hunkView0 = hunkViews.get(hunks[0]);
const hunkView1 = hunkViews.get(hunks[1]);
@@ -107,7 +117,7 @@ describe('FilePatchView', () => {
new HunkLine('line-8', 'added', -1, 10),
]),
];
- const filePatchView = new FilePatchView({hunks});
+ const filePatchView = new FilePatchView({commandRegistry, hunks});
document.body.appendChild(filePatchView.element);
filePatchView.element.style.overflow = 'scroll';
filePatchView.element.style.height = '100px';
@@ -130,13 +140,13 @@ describe('FilePatchView', () => {
const hunk = new Hunk(1, 1, 1, 2, [new HunkLine('line-1', 'added', -1, 1)]);
let hunkView;
function registerHunkView(_hunk, view) { hunkView = view; }
- const view = new FilePatchView({hunks: [hunk], stagingStatus: 'unstaged', registerHunkView});
+ const view = new FilePatchView({commandRegistry, hunks: [hunk], stagingStatus: 'unstaged', registerHunkView});
assert.equal(hunkView.props.stageButtonLabel, 'Stage Hunk');
- await view.update({hunks: [hunk], stagingStatus: 'staged', registerHunkView});
+ await view.update({commandRegistry, hunks: [hunk], stagingStatus: 'staged', registerHunkView});
assert.equal(hunkView.props.stageButtonLabel, 'Unstage Hunk');
await view.togglePatchSelectionMode();
assert.equal(hunkView.props.stageButtonLabel, 'Unstage Selection');
- await view.update({hunks: [hunk], stagingStatus: 'unstaged', registerHunkView});
+ await view.update({commandRegistry, hunks: [hunk], stagingStatus: 'unstaged', registerHunkView});
assert.equal(hunkView.props.stageButtonLabel, 'Stage Selection');
});
@@ -164,10 +174,28 @@ describe('FilePatchView', () => {
]),
];
- const filePatchView = new FilePatchView({hunks, stagingStatus: 'unstaged', attemptHunkStageOperation: sinon.stub()});
+ const filePatchView = new FilePatchView({commandRegistry, hunks, stagingStatus: 'unstaged', attemptHunkStageOperation: sinon.stub()});
filePatchView.didClickStageButtonForHunk(hunks[2]);
await filePatchView.update({hunks: hunks.filter(h => h !== hunks[2])});
assertEqualSets(filePatchView.selection.getSelectedHunks(), new Set([hunks[1]]));
});
});
+
+ describe('keyboard navigation', () => {
+ it('invokes the didSurfaceFile callback on core:move-right', () => {
+ const hunks = [
+ new Hunk(1, 1, 2, 2, [
+ new HunkLine('line-1', 'unchanged', 1, 1),
+ new HunkLine('line-2', 'added', -1, 2),
+ ]),
+ ];
+ const didSurfaceFile = sinon.spy();
+
+ const filePatchView = new FilePatchView({commandRegistry, hunks, didSurfaceFile});
+
+ commandRegistry.dispatch(filePatchView.element, 'core:move-right');
+
+ assert.equal(didSurfaceFile.callCount, 1);
+ });
+ });
});
diff --git a/test/views/staging-view.test.js b/test/views/staging-view.test.js
index 9fbc6734e8..5ae8fc0d00 100644
--- a/test/views/staging-view.test.js
+++ b/test/views/staging-view.test.js
@@ -5,13 +5,24 @@ import StagingView from '../../lib/views/staging-view';
import {assertEqualSets} from '../helpers';
describe('StagingView', () => {
+ let atomEnv, commandRegistry;
+
+ beforeEach(() => {
+ atomEnv = global.buildAtomEnvironment();
+ commandRegistry = atomEnv.commands;
+ });
+
+ afterEach(() => {
+ atomEnv.destroy();
+ });
+
describe('staging and unstaging files', () => {
it('renders staged and unstaged files', async () => {
const filePatches = [
{filePath: 'a.txt', status: 'modified'},
{filePath: 'b.txt', status: 'deleted'},
];
- const view = new StagingView({unstagedChanges: filePatches, stagedChanges: []});
+ const view = new StagingView({commandRegistry, unstagedChanges: filePatches, stagedChanges: []});
const {refs} = view;
function textContentOfChildren(element) {
return Array.from(element.children).map(child => child.textContent);
@@ -32,7 +43,7 @@ describe('StagingView', () => {
{filePath: 'b.txt', status: 'deleted'},
];
const attemptFileStageOperation = sinon.spy();
- const view = new StagingView({unstagedChanges: filePatches, stagedChanges: [], attemptFileStageOperation});
+ const view = new StagingView({commandRegistry, unstagedChanges: filePatches, stagedChanges: [], attemptFileStageOperation});
view.mousedownOnItem({detail: 1}, filePatches[1]);
view.confirmSelectedItems();
@@ -49,7 +60,7 @@ describe('StagingView', () => {
describe('merge conflicts list', () => {
it('is visible only when conflicted paths are passed', async () => {
- const view = new StagingView({unstagedChanges: [], stagedChanges: []});
+ const view = new StagingView({commandRegistry, unstagedChanges: [], stagedChanges: []});
assert.isUndefined(view.refs.mergeConflicts);
@@ -90,7 +101,7 @@ describe('StagingView', () => {
const didSelectMergeConflictFile = sinon.spy();
const view = new StagingView({
- didSelectFilePath, didSelectMergeConflictFile,
+ commandRegistry, didSelectFilePath, didSelectMergeConflictFile,
unstagedChanges: filePatches, mergeConflicts, stagedChanges: [],
});
document.body.appendChild(view.element);
@@ -136,7 +147,7 @@ describe('StagingView', () => {
const didSelectMergeConflictFile = sinon.spy();
const view = new StagingView({
- didSelectFilePath, didSelectMergeConflictFile,
+ commandRegistry, didSelectFilePath, didSelectMergeConflictFile,
unstagedChanges: filePatches, mergeConflicts, stagedChanges: [],
});
document.body.appendChild(view.element);
@@ -173,7 +184,7 @@ describe('StagingView', () => {
{filePath: 'e.txt', status: 'modified'},
{filePath: 'f.txt', status: 'modified'},
];
- const view = new StagingView({unstagedChanges, stagedChanges: []});
+ const view = new StagingView({commandRegistry, unstagedChanges, stagedChanges: []});
// Actually loading the style sheet is complicated and prone to timing
// issues, so this applies some minimal styling to allow the unstaged
@@ -205,7 +216,7 @@ describe('StagingView', () => {
{filePath: 'c.txt', status: 'modified'},
];
const didSelectFilePath = sinon.stub();
- const view = new StagingView({unstagedChanges, stagedChanges: [], didSelectFilePath});
+ const view = new StagingView({commandRegistry, unstagedChanges, stagedChanges: [], didSelectFilePath});
view.isFocused = sinon.stub().returns(true);
document.body.appendChild(view.element);
@@ -216,4 +227,122 @@ describe('StagingView', () => {
assert.equal(view.props.didSelectFilePath.callCount, 0);
});
});
+
+ describe('when advancing and retreating activation', () => {
+ let view, stagedChanges;
+
+ beforeEach(() => {
+ const unstagedChanges = [
+ {filePath: 'unstaged-1.txt', status: 'modified'},
+ {filePath: 'unstaged-2.txt', status: 'modified'},
+ {filePath: 'unstaged-3.txt', status: 'modified'},
+ ];
+ const mergeConflicts = [
+ {filePath: 'conflict-1.txt', status: {file: 'modified', ours: 'deleted', theirs: 'modified'}},
+ {filePath: 'conflict-2.txt', status: {file: 'modified', ours: 'added', theirs: 'modified'}},
+ ];
+ stagedChanges = [
+ {filePath: 'staged-1.txt', status: 'staged'},
+ {filePath: 'staged-2.txt', status: 'staged'},
+ ];
+ view = new StagingView({commandRegistry, unstagedChanges, stagedChanges, mergeConflicts});
+ });
+
+ const assertSelected = expected => {
+ const actual = Array.from(view.selection.getSelectedItems()).map(item => item.filePath);
+ assert.deepEqual(actual, expected);
+ };
+
+ it("selects the next list, retaining that list's selection", () => {
+ assert.isTrue(view.activateNextList());
+ assertSelected(['conflict-1.txt']);
+
+ assert.isTrue(view.activateNextList());
+ assertSelected(['staged-1.txt']);
+
+ assert.isFalse(view.activateNextList());
+ assertSelected(['staged-1.txt']);
+ });
+
+ it("selects the previous list, retaining that list's selection", () => {
+ view.mousedownOnItem({detail: 1}, stagedChanges[1]);
+ view.mouseup();
+ assertSelected(['staged-2.txt']);
+
+ assert.isTrue(view.activatePreviousList());
+ assertSelected(['conflict-1.txt']);
+
+ assert.isTrue(view.activatePreviousList());
+ assertSelected(['unstaged-1.txt']);
+
+ assert.isFalse(view.activatePreviousList());
+ assertSelected(['unstaged-1.txt']);
+ });
+
+ it('selects the first item of the final list', () => {
+ assertSelected(['unstaged-1.txt']);
+
+ assert.isTrue(view.activateLastList());
+ assertSelected(['staged-1.txt']);
+ });
+ });
+
+ describe('when navigating with core:move-left', () => {
+ let view, didDiveIntoFilePath, didDiveIntoMergeConflictPath;
+
+ beforeEach(() => {
+ const unstagedChanges = [
+ {filePath: 'unstaged-1.txt', status: 'modified'},
+ {filePath: 'unstaged-2.txt', status: 'modified'},
+ ];
+ const mergeConflicts = [
+ {filePath: 'conflict-1.txt', status: {file: 'modified', ours: 'modified', theirs: 'modified'}},
+ {filePath: 'conflict-2.txt', status: {file: 'modified', ours: 'modified', theirs: 'modified'}},
+ ];
+
+ didDiveIntoFilePath = sinon.spy();
+ didDiveIntoMergeConflictPath = sinon.spy();
+
+ view = new StagingView({
+ commandRegistry, didDiveIntoFilePath, didDiveIntoMergeConflictPath,
+ unstagedChanges, stagedChanges: [], mergeConflicts,
+ });
+ });
+
+ it('invokes a callback with a single file selection', async () => {
+ await view.selectFirst();
+
+ commandRegistry.dispatch(view.element, 'core:move-left');
+
+ assert.isTrue(didDiveIntoFilePath.calledWith('unstaged-1.txt'), 'Callback invoked with unstaged-1.txt');
+ });
+
+ it('invokes a callback with a single merge conflict selection', async () => {
+ await view.activateNextList();
+ await view.selectFirst();
+
+ commandRegistry.dispatch(view.element, 'core:move-left');
+
+ assert.isTrue(didDiveIntoMergeConflictPath.calledWith('conflict-1.txt'), 'Callback invoked with conflict-1.txt');
+ });
+
+ it('does nothing with multiple files selections', async () => {
+ await view.selectAll();
+
+ commandRegistry.dispatch(view.element, 'core:move-left');
+
+ assert.equal(didDiveIntoFilePath.callCount, 0);
+ assert.equal(didDiveIntoMergeConflictPath.callCount, 0);
+ });
+
+ it('does nothing with multiple merge conflict selections', async () => {
+ await view.activateNextList();
+ await view.selectAll();
+
+ commandRegistry.dispatch(view.element, 'core:move-left');
+
+ assert.equal(didDiveIntoFilePath.callCount, 0);
+ assert.equal(didDiveIntoMergeConflictPath.callCount, 0);
+ });
+ });
});
]