import path from 'path'; import fs from 'fs-extra'; import React from 'react'; import {shallow} from 'enzyme'; import MultiFilePatchController from '../../lib/controllers/multi-file-patch-controller'; import MultiFilePatch from '../../lib/models/patch/multi-file-patch'; import * as reporterProxy from '../../lib/reporter-proxy'; import {multiFilePatchBuilder} from '../builder/patch'; import {cloneRepository, buildRepository} from '../helpers'; import CommitPreviewItem from '../../lib/items/commit-preview-item'; describe('MultiFilePatchController', function() { let atomEnv, repository, multiFilePatch, filePatch; beforeEach(async function() { atomEnv = global.buildAtomEnvironment(); const workdirPath = await cloneRepository(); repository = await buildRepository(workdirPath); // a.txt: unstaged changes const filePath = 'a.txt'; await fs.writeFile(path.join(workdirPath, filePath), '00\n01\n02\n03\n04\n05\n06'); multiFilePatch = await repository.getFilePatchForPath(filePath); [filePatch] = multiFilePatch.getFilePatches(); }); afterEach(function() { atomEnv.destroy(); }); function buildApp(overrideProps = {}) { const props = { repository, stagingStatus: 'unstaged', multiFilePatch, hasUndoHistory: false, workspace: atomEnv.workspace, commands: atomEnv.commands, keymaps: atomEnv.keymaps, tooltips: atomEnv.tooltips, config: atomEnv.config, destroy: () => {}, discardLines: () => {}, undoLastDiscard: () => {}, surface: () => {}, itemType: CommitPreviewItem, ...overrideProps, }; return <MultiFilePatchController {...props} />; } it('passes extra props to the FilePatchView', function() { const extra = Symbol('extra'); const wrapper = shallow(buildApp({extra})); assert.strictEqual(wrapper.find('MultiFilePatchView').prop('extra'), extra); }); it('calls undoLastDiscard through with set arguments', function() { const undoLastDiscard = sinon.spy(); const wrapper = shallow(buildApp({undoLastDiscard, stagingStatus: 'staged'})); wrapper.find('MultiFilePatchView').prop('undoLastDiscard')(filePatch); assert.isTrue(undoLastDiscard.calledWith(filePatch.getPath(), repository)); }); describe('diveIntoMirrorPatch()', function() { it('destroys the current pane and opens the staged changes', async function() { const destroy = sinon.spy(); sinon.stub(atomEnv.workspace, 'open').resolves(); const wrapper = shallow(buildApp({stagingStatus: 'unstaged', destroy})); await wrapper.find('MultiFilePatchView').prop('diveIntoMirrorPatch')(filePatch); assert.isTrue(destroy.called); assert.isTrue(atomEnv.workspace.open.calledWith( `atom-github://file-patch/${filePatch.getPath()}` + `?workdir=${encodeURIComponent(repository.getWorkingDirectoryPath())}&stagingStatus=staged`, )); }); it('destroys the current pane and opens the unstaged changes', async function() { const destroy = sinon.spy(); sinon.stub(atomEnv.workspace, 'open').resolves(); const wrapper = shallow(buildApp({stagingStatus: 'staged', destroy})); await wrapper.find('MultiFilePatchView').prop('diveIntoMirrorPatch')(filePatch); assert.isTrue(destroy.called); assert.isTrue(atomEnv.workspace.open.calledWith( `atom-github://file-patch/${filePatch.getPath()}` + `?workdir=${encodeURIComponent(repository.getWorkingDirectoryPath())}&stagingStatus=unstaged`, )); }); }); describe('openFile()', function() { it('opens an editor on the current file', async function() { const wrapper = shallow(buildApp({stagingStatus: 'unstaged'})); const editor = await wrapper.find('MultiFilePatchView').prop('openFile')(filePatch, []); assert.strictEqual(editor.getPath(), path.join(repository.getWorkingDirectoryPath(), filePatch.getPath())); }); it('sets the cursor to a single position', async function() { const wrapper = shallow(buildApp({relPath: 'a.txt', stagingStatus: 'unstaged'})); const editor = await wrapper.find('MultiFilePatchView').prop('openFile')(filePatch, [[1, 1]]); assert.deepEqual(editor.getCursorBufferPositions().map(p => p.serialize()), [[1, 1]]); }); it('adds cursors at a set of positions', async function() { const wrapper = shallow(buildApp({stagingStatus: 'unstaged'})); const editor = await wrapper.find('MultiFilePatchView').prop('openFile')(filePatch, [[1, 1], [3, 1], [5, 0]]); assert.deepEqual(editor.getCursorBufferPositions().map(p => p.serialize()), [[1, 1], [3, 1], [5, 0]]); }); }); describe('toggleFile()', function() { it('stages the current file if unstaged', async function() { sinon.spy(repository, 'stageFiles'); const wrapper = shallow(buildApp({stagingStatus: 'unstaged'})); await wrapper.find('MultiFilePatchView').prop('toggleFile')(filePatch); assert.isTrue(repository.stageFiles.calledWith([filePatch.getPath()])); }); it('unstages the current file if staged', async function() { sinon.spy(repository, 'unstageFiles'); const wrapper = shallow(buildApp({stagingStatus: 'staged'})); await wrapper.find('MultiFilePatchView').prop('toggleFile')(filePatch); assert.isTrue(repository.unstageFiles.calledWith([filePatch.getPath()])); }); it('is a no-op if a staging operation is already in progress', async function() { sinon.stub(repository, 'stageFiles').resolves('staged'); sinon.stub(repository, 'unstageFiles').resolves('unstaged'); const wrapper = shallow(buildApp({stagingStatus: 'unstaged'})); assert.strictEqual(await wrapper.find('MultiFilePatchView').prop('toggleFile')(filePatch), 'staged'); // No-op assert.isNull(await wrapper.find('MultiFilePatchView').prop('toggleFile')(filePatch)); // Simulate an identical patch arriving too soon wrapper.setProps({multiFilePatch: multiFilePatch.clone()}); // Still a no-op assert.isNull(await wrapper.find('MultiFilePatchView').prop('toggleFile')(filePatch)); // Simulate updated patch arrival const promise = wrapper.instance().patchChangePromise; wrapper.setProps({multiFilePatch: MultiFilePatch.createNull()}); await promise; await wrapper.instance().forceUpdate(); // Performs an operation again assert.strictEqual(await wrapper.find('MultiFilePatchView').prop('toggleFile')(filePatch), 'staged'); }); }); describe('selected row and selection mode tracking', function() { it('captures the selected row set', function() { const wrapper = shallow(buildApp()); assert.sameMembers(Array.from(wrapper.find('MultiFilePatchView').prop('selectedRows')), []); assert.strictEqual(wrapper.find('MultiFilePatchView').prop('selectionMode'), 'hunk'); assert.isFalse(wrapper.find('MultiFilePatchView').prop('hasMultipleFileSelections')); wrapper.find('MultiFilePatchView').prop('selectedRowsChanged')(new Set([1, 2]), 'line', true); assert.sameMembers(Array.from(wrapper.find('MultiFilePatchView').prop('selectedRows')), [1, 2]); assert.strictEqual(wrapper.find('MultiFilePatchView').prop('selectionMode'), 'line'); assert.isTrue(wrapper.find('MultiFilePatchView').prop('hasMultipleFileSelections')); }); it('does not re-render if the row set, selection mode, and file spanning are unchanged', function() { const wrapper = shallow(buildApp()); assert.sameMembers(Array.from(wrapper.find('MultiFilePatchView').prop('selectedRows')), []); assert.strictEqual(wrapper.find('MultiFilePatchView').prop('selectionMode'), 'hunk'); assert.isFalse(wrapper.find('MultiFilePatchView').prop('hasMultipleFileSelections')); sinon.spy(wrapper.instance(), 'render'); // All changed wrapper.find('MultiFilePatchView').prop('selectedRowsChanged')(new Set([1, 2]), 'line', true); assert.isTrue(wrapper.instance().render.called); assert.sameMembers(Array.from(wrapper.find('MultiFilePatchView').prop('selectedRows')), [1, 2]); assert.strictEqual(wrapper.find('MultiFilePatchView').prop('selectionMode'), 'line'); assert.isTrue(wrapper.find('MultiFilePatchView').prop('hasMultipleFileSelections')); // Nothing changed wrapper.instance().render.resetHistory(); wrapper.find('MultiFilePatchView').prop('selectedRowsChanged')(new Set([2, 1]), 'line', true); assert.sameMembers(Array.from(wrapper.find('MultiFilePatchView').prop('selectedRows')), [1, 2]); assert.strictEqual(wrapper.find('MultiFilePatchView').prop('selectionMode'), 'line'); assert.isTrue(wrapper.find('MultiFilePatchView').prop('hasMultipleFileSelections')); assert.isFalse(wrapper.instance().render.called); // Selection mode changed wrapper.instance().render.resetHistory(); wrapper.find('MultiFilePatchView').prop('selectedRowsChanged')(new Set([1, 2]), 'hunk', true); assert.sameMembers(Array.from(wrapper.find('MultiFilePatchView').prop('selectedRows')), [1, 2]); assert.strictEqual(wrapper.find('MultiFilePatchView').prop('selectionMode'), 'hunk'); assert.isTrue(wrapper.find('MultiFilePatchView').prop('hasMultipleFileSelections')); assert.isTrue(wrapper.instance().render.called); // Selection file spanning changed wrapper.instance().render.resetHistory(); wrapper.find('MultiFilePatchView').prop('selectedRowsChanged')(new Set([1, 2]), 'hunk', false); assert.sameMembers(Array.from(wrapper.find('MultiFilePatchView').prop('selectedRows')), [1, 2]); assert.strictEqual(wrapper.find('MultiFilePatchView').prop('selectionMode'), 'hunk'); assert.isFalse(wrapper.find('MultiFilePatchView').prop('hasMultipleFileSelections')); assert.isTrue(wrapper.instance().render.called); }); describe('discardRows()', function() { it('records an event', async function() { const wrapper = shallow(buildApp()); sinon.stub(reporterProxy, 'addEvent'); await wrapper.find('MultiFilePatchView').prop('discardRows')(new Set([1, 2]), 'hunk'); assert.isTrue(reporterProxy.addEvent.calledWith('discard-unstaged-changes', { package: 'github', component: 'MultiFilePatchController', lineCount: 2, eventSource: undefined, })); }); it('is a no-op when multiple patches are present', async function() { const {multiFilePatch: mfp} = multiFilePatchBuilder() .addFilePatch() .addFilePatch() .build(); const discardLines = sinon.spy(); const wrapper = shallow(buildApp({discardLines, multiFilePatch: mfp})); sinon.stub(reporterProxy, 'addEvent'); await wrapper.find('MultiFilePatchView').prop('discardRows')(new Set([1, 2])); assert.isFalse(reporterProxy.addEvent.called); assert.isFalse(discardLines.called); }); }); describe('undoLastDiscard()', function() { it('records an event', function() { const wrapper = shallow(buildApp()); sinon.stub(reporterProxy, 'addEvent'); wrapper.find('MultiFilePatchView').prop('undoLastDiscard')(filePatch); assert.isTrue(reporterProxy.addEvent.calledWith('undo-last-discard', { package: 'github', component: 'MultiFilePatchController', eventSource: undefined, })); }); }); }); describe('toggleRows()', function() { it('is a no-op with no selected rows', async function() { const wrapper = shallow(buildApp()); sinon.spy(repository, 'applyPatchToIndex'); await wrapper.find('MultiFilePatchView').prop('toggleRows')(); assert.isFalse(repository.applyPatchToIndex.called); }); it('applies a stage patch to the index', async function() { const wrapper = shallow(buildApp()); wrapper.find('MultiFilePatchView').prop('selectedRowsChanged')(new Set([1]), 'hunk', false); sinon.spy(multiFilePatch, 'getStagePatchForLines'); sinon.spy(repository, 'applyPatchToIndex'); await wrapper.find('MultiFilePatchView').prop('toggleRows')(); assert.sameMembers(Array.from(multiFilePatch.getStagePatchForLines.lastCall.args[0]), [1]); assert.isTrue(repository.applyPatchToIndex.calledWith(multiFilePatch.getStagePatchForLines.returnValues[0])); }); it('toggles a different row set if provided', async function() { const wrapper = shallow(buildApp()); wrapper.find('MultiFilePatchView').prop('selectedRowsChanged')(new Set([1]), 'line', false); sinon.spy(multiFilePatch, 'getStagePatchForLines'); sinon.spy(repository, 'applyPatchToIndex'); await wrapper.find('MultiFilePatchView').prop('toggleRows')(new Set([2]), 'hunk'); assert.sameMembers(Array.from(multiFilePatch.getStagePatchForLines.lastCall.args[0]), [2]); assert.isTrue(repository.applyPatchToIndex.calledWith(multiFilePatch.getStagePatchForLines.returnValues[0])); assert.sameMembers(Array.from(wrapper.find('MultiFilePatchView').prop('selectedRows')), [2]); assert.strictEqual(wrapper.find('MultiFilePatchView').prop('selectionMode'), 'hunk'); }); it('applies an unstage patch to the index', async function() { await repository.stageFiles(['a.txt']); const otherPatch = await repository.getFilePatchForPath('a.txt', {staged: true}); const wrapper = shallow(buildApp({multiFilePatch: otherPatch, stagingStatus: 'staged'})); wrapper.find('MultiFilePatchView').prop('selectedRowsChanged')(new Set([2]), 'hunk', false); sinon.spy(otherPatch, 'getUnstagePatchForLines'); sinon.spy(repository, 'applyPatchToIndex'); await wrapper.find('MultiFilePatchView').prop('toggleRows')(new Set([2]), 'hunk'); assert.sameMembers(Array.from(otherPatch.getUnstagePatchForLines.lastCall.args[0]), [2]); assert.isTrue(repository.applyPatchToIndex.calledWith(otherPatch.getUnstagePatchForLines.returnValues[0])); }); }); if (process.platform !== 'win32') { describe('toggleModeChange()', function() { it("it stages an unstaged file's new mode", async function() { const p = path.join(repository.getWorkingDirectoryPath(), 'a.txt'); await fs.chmod(p, 0o755); repository.refresh(); const newMultiFilePatch = await repository.getFilePatchForPath('a.txt', {staged: false}); const wrapper = shallow(buildApp({filePatch: newMultiFilePatch, stagingStatus: 'unstaged'})); const [newFilePatch] = newMultiFilePatch.getFilePatches(); sinon.spy(repository, 'stageFileModeChange'); await wrapper.find('MultiFilePatchView').prop('toggleModeChange')(newFilePatch); assert.isTrue(repository.stageFileModeChange.calledWith('a.txt', '100755')); }); it("it stages a staged file's old mode", async function() { const p = path.join(repository.getWorkingDirectoryPath(), 'a.txt'); await fs.chmod(p, 0o755); await repository.stageFiles(['a.txt']); repository.refresh(); const newMultiFilePatch = await repository.getFilePatchForPath('a.txt', {staged: true}); const [newFilePatch] = newMultiFilePatch.getFilePatches(); const wrapper = shallow(buildApp({filePatch: newMultiFilePatch, stagingStatus: 'staged'})); sinon.spy(repository, 'stageFileModeChange'); await wrapper.find('MultiFilePatchView').prop('toggleModeChange')(newFilePatch); assert.isTrue(repository.stageFileModeChange.calledWith('a.txt', '100644')); }); }); describe('toggleSymlinkChange', function() { it('handles an addition and typechange with a special repository method', async function() { if (process.env.ATOM_GITHUB_SKIP_SYMLINKS) { this.skip(); return; } const p = path.join(repository.getWorkingDirectoryPath(), 'waslink.txt'); const dest = path.join(repository.getWorkingDirectoryPath(), 'destination'); await fs.writeFile(dest, 'asdf\n', 'utf8'); await fs.symlink(dest, p); await repository.stageFiles(['waslink.txt', 'destination']); await repository.commit('zero'); await fs.unlink(p); await fs.writeFile(p, 'fdsa\n', 'utf8'); repository.refresh(); const symlinkMultiPatch = await repository.getFilePatchForPath('waslink.txt', {staged: false}); const wrapper = shallow(buildApp({filePatch: symlinkMultiPatch, relPath: 'waslink.txt', stagingStatus: 'unstaged'})); const [symlinkPatch] = symlinkMultiPatch.getFilePatches(); sinon.spy(repository, 'stageFileSymlinkChange'); await wrapper.find('MultiFilePatchView').prop('toggleSymlinkChange')(symlinkPatch); assert.isTrue(repository.stageFileSymlinkChange.calledWith('waslink.txt')); }); it('stages non-addition typechanges normally', async function() { if (process.env.ATOM_GITHUB_SKIP_SYMLINKS) { this.skip(); return; } const p = path.join(repository.getWorkingDirectoryPath(), 'waslink.txt'); const dest = path.join(repository.getWorkingDirectoryPath(), 'destination'); await fs.writeFile(dest, 'asdf\n', 'utf8'); await fs.symlink(dest, p); await repository.stageFiles(['waslink.txt', 'destination']); await repository.commit('zero'); await fs.unlink(p); repository.refresh(); const symlinkMultiPatch = await repository.getFilePatchForPath('waslink.txt', {staged: false}); const wrapper = shallow(buildApp({filePatch: symlinkMultiPatch, relPath: 'waslink.txt', stagingStatus: 'unstaged'})); sinon.spy(repository, 'stageFiles'); const [symlinkPatch] = symlinkMultiPatch.getFilePatches(); await wrapper.find('MultiFilePatchView').prop('toggleSymlinkChange')(symlinkPatch); assert.isTrue(repository.stageFiles.calledWith(['waslink.txt'])); }); it('handles a deletion and typechange with a special repository method', async function() { const p = path.join(repository.getWorkingDirectoryPath(), 'waslink.txt'); const dest = path.join(repository.getWorkingDirectoryPath(), 'destination'); await fs.writeFile(dest, 'asdf\n', 'utf8'); await fs.writeFile(p, 'fdsa\n', 'utf8'); await repository.stageFiles(['waslink.txt', 'destination']); await repository.commit('zero'); await fs.unlink(p); await fs.symlink(dest, p); await repository.stageFiles(['waslink.txt']); repository.refresh(); const symlinkMultiPatch = await repository.getFilePatchForPath('waslink.txt', {staged: true}); const wrapper = shallow(buildApp({filePatch: symlinkMultiPatch, relPath: 'waslink.txt', stagingStatus: 'staged'})); sinon.spy(repository, 'stageFileSymlinkChange'); const [symlinkPatch] = symlinkMultiPatch.getFilePatches(); await wrapper.find('MultiFilePatchView').prop('toggleSymlinkChange')(symlinkPatch); assert.isTrue(repository.stageFileSymlinkChange.calledWith('waslink.txt')); }); it('unstages non-deletion typechanges normally', async function() { const p = path.join(repository.getWorkingDirectoryPath(), 'waslink.txt'); const dest = path.join(repository.getWorkingDirectoryPath(), 'destination'); await fs.writeFile(dest, 'asdf\n', 'utf8'); await fs.symlink(dest, p); await repository.stageFiles(['waslink.txt', 'destination']); await repository.commit('zero'); await fs.unlink(p); await repository.stageFiles(['waslink.txt']); repository.refresh(); const symlinkMultiPatch = await repository.getFilePatchForPath('waslink.txt', {staged: true}); const wrapper = shallow(buildApp({multiFilePatch: symlinkMultiPatch, relPath: 'waslink.txt', stagingStatus: 'staged'})); sinon.spy(repository, 'unstageFiles'); const [symlinkPatch] = symlinkMultiPatch.getFilePatches(); await wrapper.find('MultiFilePatchView').prop('toggleSymlinkChange')(symlinkPatch); assert.isTrue(repository.unstageFiles.calledWith(['waslink.txt'])); }); }); } it('calls discardLines with selected rows', async function() { const discardLines = sinon.spy(); const wrapper = shallow(buildApp({discardLines})); wrapper.find('MultiFilePatchView').prop('selectedRowsChanged')(new Set([1, 2]), 'hunk', false); await wrapper.find('MultiFilePatchView').prop('discardRows')(); const lastArgs = discardLines.lastCall.args; assert.strictEqual(lastArgs[0], multiFilePatch); assert.sameMembers(Array.from(lastArgs[1]), [1, 2]); assert.strictEqual(lastArgs[2], repository); }); it('calls discardLines with explicitly provided rows', async function() { const discardLines = sinon.spy(); const wrapper = shallow(buildApp({discardLines})); wrapper.find('MultiFilePatchView').prop('selectedRowsChanged')(new Set([1, 2]), 'hunk', false); await wrapper.find('MultiFilePatchView').prop('discardRows')(new Set([4, 5]), 'hunk'); const lastArgs = discardLines.lastCall.args; assert.strictEqual(lastArgs[0], multiFilePatch); assert.sameMembers(Array.from(lastArgs[1]), [4, 5]); assert.strictEqual(lastArgs[2], repository); assert.sameMembers(Array.from(wrapper.find('MultiFilePatchView').prop('selectedRows')), [4, 5]); assert.strictEqual(wrapper.find('MultiFilePatchView').prop('selectionMode'), 'hunk'); }); });