diff --git a/lib/controllers/file-patch-controller.js b/lib/controllers/file-patch-controller.js index 1acf622703..5fe44fd3b1 100644 --- a/lib/controllers/file-patch-controller.js +++ b/lib/controllers/file-patch-controller.js @@ -1,41 +1,47 @@ -/** @jsx etch.dom */ -/* eslint react/no-unknown-property: "off" */ +import React from 'react'; -import {Emitter, Point} from 'atom'; -import etch from 'etch'; +import {Point, Emitter} from 'atom'; import {autobind} from 'core-decorators'; +import EventWatcher from '../event-watcher'; import FilePatchView from '../views/file-patch-view'; -export default class FilePatchController { - constructor(props) { - this.props = props; - this.emitter = new Emitter(); - this.stagingOperationInProgress = false; - etch.initialize(this); +export default class FilePatchController extends React.Component { + static propTypes = { + repository: React.PropTypes.object, + commandRegistry: React.PropTypes.object.isRequired, + filePatch: React.PropTypes.object.isRequired, + stagingStatus: React.PropTypes.oneOf(['unstaged', 'staged']).isRequired, + isPartiallyStaged: React.PropTypes.bool.isRequired, + isAmending: React.PropTypes.bool.isRequired, + discardLines: React.PropTypes.func.isRequired, + didSurfaceFile: React.PropTypes.func.isRequired, + didDiveIntoFilePath: React.PropTypes.func.isRequired, + quietlySelectItem: React.PropTypes.func.isRequired, + undoLastDiscard: React.PropTypes.func.isRequired, + openFiles: React.PropTypes.func.isRequired, + eventWatcher: React.PropTypes.instanceOf(EventWatcher), } - serialize() { - return null; + static defaultProps = { + eventWatcher: new EventWatcher(), } - update(props) { - // If we update the active repository to null and that gets passed to this - // component, we'll instead hold on to our current repository (since it will - // not change for a given file). - const repository = props.repository || this.props.repository; - this.props = {...this.props, ...props, repository}; - this.emitter.emit('did-change-title', this.getTitle()); - return etch.update(this); + constructor(props, context) { + super(props, context); + + this.stagingOperationInProgress = false; + this.emitter = new Emitter(); } - destroy() { - this.emitter.emit('did-destroy'); - return etch.destroy(this); + serialize() { + return null; } - getRepository() { - return this.props.repository; + componentWillReceiveProps(nextProps) { + if (this.getTitle(nextProps) !== this.getTitle()) { + this.emitter.emit('did-change-title', this.getTitle(nextProps)); + } } render() { @@ -53,17 +59,16 @@ export default class FilePatchController { return ( <div className="github-PaneView pane-item"> <FilePatchView - ref="filePatchView" commandRegistry={this.props.commandRegistry} - attemptLineStageOperation={this.attemptLineStageOperation} - didSurfaceFile={this.didSurfaceFile} - didDiveIntoCorrespondingFilePatch={this.diveIntoCorrespondingFilePatch} - attemptHunkStageOperation={this.attemptHunkStageOperation} hunks={hunks} filePath={filePath} stagingStatus={this.props.stagingStatus} isPartiallyStaged={this.props.isPartiallyStaged} - registerHunkView={this.props.registerHunkView} + attemptLineStageOperation={this.attemptLineStageOperation} + attemptHunkStageOperation={this.attemptHunkStageOperation} + didSurfaceFile={this.didSurfaceFile} + didDiveIntoCorrespondingFilePatch={this.diveIntoCorrespondingFilePatch} + eventWatcher={this.props.eventWatcher} openCurrentFile={this.openCurrentFile} discardLines={this.props.discardLines} undoLastDiscard={this.undoLastDiscard} @@ -74,16 +79,26 @@ export default class FilePatchController { } } - stageHunk(hunk) { - return this.props.repository.applyPatchToIndex( + onDidChangeTitle(callback) { + return this.emitter.on('did-change-title', callback); + } + + onDidDestroy(callback) { + return this.emitter.on('did-destroy', callback); + } + + async stageHunk(hunk) { + await this.props.repository.applyPatchToIndex( this.props.filePatch.getStagePatchForHunk(hunk), ); + this.props.eventWatcher.resolveStageOperationPromise(); } async unstageHunk(hunk) { await this.props.repository.applyPatchToIndex( this.props.filePatch.getUnstagePatchForHunk(hunk), ); + this.props.eventWatcher.resolveStageOperationPromise(); } stageOrUnstageHunk(hunk) { @@ -99,33 +114,31 @@ export default class FilePatchController { @autobind attemptHunkStageOperation(hunk) { if (this.stagingOperationInProgress) { - return { - stageOperationPromise: Promise.resolve(), - selectionUpdatePromise: Promise.resolve(), - }; + return; } this.stagingOperationInProgress = true; - - const hunkUpdatePromise = this.refs.filePatchView.getNextHunkUpdatePromise(); - const stageOperationPromise = this.stageOrUnstageHunk(hunk); - const selectionUpdatePromise = hunkUpdatePromise.then(() => { + this.props.eventWatcher.getPatchChangedPromise().then(() => { this.stagingOperationInProgress = false; }); - return {stageOperationPromise, selectionUpdatePromise}; + this.stageOrUnstageHunk(hunk); } - stageLines(lines) { - return this.props.repository.applyPatchToIndex( + async stageLines(lines) { + await this.props.repository.applyPatchToIndex( this.props.filePatch.getStagePatchForLines(lines), ); + + this.props.eventWatcher.resolveStageOperationPromise(); } async unstageLines(lines) { await this.props.repository.applyPatchToIndex( this.props.filePatch.getUnstagePatchForLines(lines), ); + + this.props.eventWatcher.resolveStageOperationPromise(); } stageOrUnstageLines(lines) { @@ -141,38 +154,24 @@ export default class FilePatchController { @autobind attemptLineStageOperation(lines) { if (this.stagingOperationInProgress) { - return { - stageOperationPromise: Promise.resolve(), - selectionUpdatePromise: Promise.resolve(), - }; + return; } this.stagingOperationInProgress = true; - - const hunkUpdatePromise = this.refs.filePatchView.getNextHunkUpdatePromise(); - const stageOperationPromise = this.stageOrUnstageLines(lines); - const selectionUpdatePromise = hunkUpdatePromise.then(() => { + this.props.eventWatcher.getPatchChangedPromise().then(() => { this.stagingOperationInProgress = false; }); - return {stageOperationPromise, selectionUpdatePromise}; + this.stageOrUnstageLines(lines); } - getTitle() { - let title = this.props.stagingStatus === 'staged' ? 'Staged' : 'Unstaged'; + getTitle(props = this.props) { + let title = props.stagingStatus === 'staged' ? 'Staged' : 'Unstaged'; title += ' Changes: '; - title += this.props.filePatch.getPath(); + title += props.filePatch.getPath(); return title; } - onDidChangeTitle(callback) { - return this.emitter.on('did-change-title', callback); - } - - onDidDestroy(callback) { - return this.emitter.on('did-destroy', callback); - } - @autobind didSurfaceFile() { if (this.props.didSurfaceFile) { @@ -188,15 +187,6 @@ export default class FilePatchController { return this.props.didDiveIntoFilePath(filePath, stagingStatus, {amending: this.props.isAmending}); } - didUpdateFilePatch() { - // FilePatch was mutated so all we need to do is re-render - return etch.update(this); - } - - didDestroyFilePatch() { - this.destroy(); - } - focus() { if (this.refs.filePatchView) { this.refs.filePatchView.focus(); @@ -221,4 +211,8 @@ export default class FilePatchController { hasUndoHistory() { return this.props.repository.hasDiscardHistory(this.props.filePatch.getPath()); } + + destroy() { + this.emitter.emit('did-destroy'); + } } diff --git a/lib/controllers/root-controller.js b/lib/controllers/root-controller.js index c1d5f029d9..7c4458e705 100644 --- a/lib/controllers/root-controller.js +++ b/lib/controllers/root-controller.js @@ -29,8 +29,8 @@ import {GitError} from '../git-shell-out-strategy'; const nullFilePatchState = { filePath: null, filePatch: null, - stagingStatus: null, - partiallyStaged: null, + stagingStatus: 'unstaged', + partiallyStaged: false, }; export default class RootController extends React.Component { @@ -195,26 +195,23 @@ export default class RootController extends React.Component { </Commands> <PaneItem workspace={this.props.workspace} - getItem={({subtree}) => subtree.getWrappedComponent()} ref={c => { this.filePatchControllerPane = c; }} onDidCloseItem={() => { this.setState({...nullFilePatchState}); }}> - <EtchWrapper ref={c => { this.filePatchController = c; }} reattachDomNode={false}> - <FilePatchController - repository={this.props.repository} - commandRegistry={this.props.commandRegistry} - filePatch={this.state.filePatch} - stagingStatus={this.state.stagingStatus} - isAmending={this.state.amending} - isPartiallyStaged={this.state.partiallyStaged} - onRepoRefresh={this.onRepoRefresh} - didSurfaceFile={this.surfaceFromFileAtPath} - didDiveIntoFilePath={this.diveIntoFilePatchForPath} - quietlySelectItem={this.quietlySelectItem} - openFiles={this.openFiles} - discardLines={this.discardLines} - undoLastDiscard={this.undoLastDiscard} - /> - </EtchWrapper> + <FilePatchController + repository={this.props.repository} + commandRegistry={this.props.commandRegistry} + filePatch={this.state.filePatch} + stagingStatus={this.state.stagingStatus} + isAmending={this.state.amending} + isPartiallyStaged={this.state.partiallyStaged} + onRepoRefresh={this.onRepoRefresh} + didSurfaceFile={this.surfaceFromFileAtPath} + didDiveIntoFilePath={this.diveIntoFilePatchForPath} + quietlySelectItem={this.quietlySelectItem} + openFiles={this.openFiles} + discardLines={this.discardLines} + undoLastDiscard={this.undoLastDiscard} + /> </PaneItem> </div> ); @@ -436,7 +433,9 @@ export default class RootController extends React.Component { @autobind focusFilePatchView() { - this.filePatchController.getWrappedComponent().focus(); + const item = this.filePatchControllerPane.getPaneItem(); + const viewElement = item.getElement().querySelector('[tabindex]'); + viewElement.focus(); } @autobind diff --git a/lib/event-watcher.js b/lib/event-watcher.js new file mode 100644 index 0000000000..70d6979430 --- /dev/null +++ b/lib/event-watcher.js @@ -0,0 +1,87 @@ +/* + * Construct Promises to wait for the next occurrence of specific events that occur throughout the data refresh + * and rendering cycle. Resolve those promises when the corresponding events have been observed. + */ +export default class EventWatcher { + constructor() { + this.promises = new Map(); + } + + /* + * Retrieve a Promise that will be resolved the next time a desired event is observed. + * + * In general, you should prefer the more specific `getXyzPromise()` methods instead to avoid a proliferation of + * "magic strings." + */ + getPromise(eventName) { + const existing = this.promises.get(eventName); + if (existing !== undefined) { + return existing.promise; + } + + let resolver, rejecter; + const created = new Promise((resolve, reject) => { + resolver = resolve; + rejecter = reject; + }); + this.promises.set(eventName, { + promise: created, + resolver, + rejecter, + }); + return created; + } + + /* + * Indicate that a named event has been observed, resolving any Promises that were created for this event. Optionally + * provide a payload. + * + * In general, you should prefer the more specific `resolveXyzPromise()` methods. + */ + resolvePromise(eventName, payload) { + const existing = this.promises.get(eventName); + if (existing !== undefined) { + this.promises.delete(eventName); + existing.resolver(payload); + } + } + + /* + * Indicate that a named event has had some kind of terrible problem. + */ + rejectPromise(eventName, error) { + const existing = this.promises.get(eventName); + if (existing !== undefined) { + this.promises.delete(eventName); + existing.rejecter(error); + } + } + + /* + * Notified when a hunk or line stage or unstage operation has completed. + */ + getStageOperationPromise() { + return this.getPromise('stage-operation'); + } + + /* + * Notified when an open FilePatchView's hunks have changed. + */ + getPatchChangedPromise() { + return this.getPromise('patch-changed'); + } + + /* + * A hunk or line stage or unstage operation has completed. + */ + resolveStageOperationPromise(payload) { + this.resolvePromise('stage-operation', payload); + } + + /* + * An open FilePatchView's hunks have changed. + */ + resolvePatchChangedPromise(payload) { + this.resolvePromise('patch-changed', payload); + } +} diff --git a/lib/views/file-patch-selection.js b/lib/views/file-patch-selection.js index 85fdff35a6..8ce76c8781 100644 --- a/lib/views/file-patch-selection.js +++ b/lib/views/file-patch-selection.js @@ -1,23 +1,125 @@ import ListSelection from './list-selection'; +const COPY = {}; + export default class FilePatchSelection { constructor(hunks) { - this.mode = 'hunk'; - this.hunksSelection = new ListSelection(); - this.linesSelection = new ListSelection({isItemSelectable: line => line.isChanged()}); - this.resolveNextUpdatePromise = () => {}; - this.updateHunks(hunks); + if (hunks._copy !== COPY) { + // Initialize a new selection + this.mode = 'hunk'; + + this.hunksByLine = new Map(); + const lines = []; + for (const hunk of hunks) { + for (const line of hunk.lines) { + lines.push(line); + this.hunksByLine.set(line, hunk); + } + } + + this.hunksSelection = new ListSelection({items: hunks}); + this.linesSelection = new ListSelection({items: lines, isItemSelectable: line => line.isChanged()}); + this.resolveNextUpdatePromise = () => {}; + } else { + // Copy from options. *Only* reachable from the copy() method because no other module has visibility to + // the COPY object without shenanigans. + const options = hunks; + + this.mode = options.mode; + this.hunksSelection = options.hunksSelection; + this.linesSelection = options.linesSelection; + this.resolveNextUpdatePromise = options.resolveNextUpdatePromise; + this.hunksByLine = options.hunksByLine; + } + } + + copy(options = {}) { + const mode = options.mode || this.mode; + const hunksSelection = options.hunksSelection || this.hunksSelection.copy(); + const linesSelection = options.linesSelection || this.linesSelection.copy(); + + let hunksByLine = null; + if (options.hunks) { + // Update hunks + const oldHunks = this.hunksSelection.getItems(); + const newHunks = options.hunks; + + let wasChanged = false; + if (newHunks.length !== oldHunks.length) { + wasChanged = true; + } else { + for (let i = 0; i < oldHunks.length; i++) { + if (oldHunks[i] !== newHunks[i]) { + wasChanged = true; + break; + } + } + } + + // Update hunks, preserving selection index + hunksSelection.setItems(newHunks); + + const oldLines = this.linesSelection.getItems(); + const newLines = []; + + hunksByLine = new Map(); + for (const hunk of newHunks) { + for (const line of hunk.lines) { + newLines.push(line); + hunksByLine.set(line, hunk); + } + } + + // Update lines, preserving selection index in *changed* lines + let newSelectedLine; + if (oldLines.length > 0 && newLines.length > 0) { + const oldSelectionStartIndex = this.linesSelection.getMostRecentSelectionStartIndex(); + let changedLineCount = 0; + for (let i = 0; i < oldSelectionStartIndex; i++) { + if (oldLines[i].isChanged()) { changedLineCount++; } + } + + for (let i = 0; i < newLines.length; i++) { + const line = newLines[i]; + if (line.isChanged()) { + newSelectedLine = line; + if (changedLineCount === 0) { break; } + changedLineCount--; + } + } + } + + linesSelection.setItems(newLines); + if (newSelectedLine) { linesSelection.selectItem(newSelectedLine); } + if (wasChanged) { this.resolveNextUpdatePromise(); } + } else { + // Hunks are unchanged. Don't recompute hunksByLine. + hunksByLine = this.hunksByLine; + } + + return new FilePatchSelection({ + _copy: COPY, + mode, + hunksSelection, + linesSelection, + hunksByLine, + resolveNextUpdatePromise: options.resolveNextUpdatePromise || this.resolveNextUpdatePromise, + }); } toggleMode() { if (this.mode === 'hunk') { const firstLineOfSelectedHunk = this.getHeadHunk().lines[0]; - this.selectLine(firstLineOfSelectedHunk); - if (!firstLineOfSelectedHunk.isChanged()) { this.selectNextLine(); } + const selection = this.selectLine(firstLineOfSelectedHunk); + if (!firstLineOfSelectedHunk.isChanged()) { + return selection.selectNextLine(); + } else { + return selection; + } } else { const selectedLine = this.getHeadLine(); const hunkContainingSelectedLine = this.hunksByLine.get(selectedLine); - this.selectHunk(hunkContainingSelectedLine); + return this.selectHunk(hunkContainingSelectedLine); } } @@ -27,93 +129,101 @@ export default class FilePatchSelection { selectNext(preserveTail = false) { if (this.mode === 'hunk') { - this.selectNextHunk(preserveTail); + return this.selectNextHunk(preserveTail); } else { - this.selectNextLine(preserveTail); + return this.selectNextLine(preserveTail); } } selectPrevious(preserveTail = false) { if (this.mode === 'hunk') { - this.selectPreviousHunk(preserveTail); + return this.selectPreviousHunk(preserveTail); } else { - this.selectPreviousLine(preserveTail); + return this.selectPreviousLine(preserveTail); } } selectAll() { if (this.mode === 'hunk') { - this.selectAllHunks(); + return this.selectAllHunks(); } else { - this.selectAllLines(); + return this.selectAllLines(); } } selectFirst(preserveTail) { if (this.mode === 'hunk') { - this.selectFirstHunk(preserveTail); + return this.selectFirstHunk(preserveTail); } else { - this.selectFirstLine(preserveTail); + return this.selectFirstLine(preserveTail); } } selectLast(preserveTail) { if (this.mode === 'hunk') { - this.selectLastHunk(preserveTail); + return this.selectLastHunk(preserveTail); } else { - this.selectLastLine(preserveTail); + return this.selectLastLine(preserveTail); } } selectHunk(hunk, preserveTail = false) { - this.mode = 'hunk'; - this.hunksSelection.selectItem(hunk, preserveTail); + const hunksSelection = this.hunksSelection.copy(); + hunksSelection.selectItem(hunk, preserveTail); + + return this.copy({mode: 'hunk', hunksSelection}); } addOrSubtractHunkSelection(hunk) { - this.mode = 'hunk'; - this.hunksSelection.addOrSubtractSelection(hunk); + const hunksSelection = this.hunksSelection.copy(); + hunksSelection.addOrSubtractSelection(hunk); + + return this.copy({mode: 'hunk', hunksSelection}); } selectAllHunks() { - this.mode = 'hunk'; - this.hunksSelection.selectAllItems(); + const hunksSelection = this.hunksSelection.copy(); + hunksSelection.selectAllItems(); + + return this.copy({mode: 'hunk', hunksSelection}); } selectFirstHunk(preserveTail) { - this.mode = 'hunk'; - this.hunksSelection.selectFirstItem(preserveTail); + const hunksSelection = this.hunksSelection.copy(); + hunksSelection.selectFirstItem(preserveTail); + + return this.copy({mode: 'hunk', hunksSelection}); } selectLastHunk(preserveTail) { - this.mode = 'hunk'; - this.hunksSelection.selectLastItem(preserveTail); + const hunksSelection = this.hunksSelection.copy(); + hunksSelection.selectLastItem(preserveTail); + + return this.copy({mode: 'hunk', hunksSelection}); } jumpToNextHunk() { - const mode = this.mode; - this.selectNextHunk(); - if (this.mode !== mode) { - this.toggleMode(); - } + const next = this.selectNextHunk(); + return next.getMode() !== this.mode ? next.toggleMode() : next; } jumpToPreviousHunk() { - const mode = this.mode; - this.selectPreviousHunk(); - if (this.mode !== mode) { - this.toggleMode(); - } + const next = this.selectPreviousHunk(); + return next.getMode() !== this.mode ? next.toggleMode() : next; } selectNextHunk(preserveTail) { - this.mode = 'hunk'; - this.hunksSelection.selectNextItem(preserveTail); + const hunksSelection = this.hunksSelection.copy(); + hunksSelection.selectNextItem(preserveTail); + + return this.copy({mode: 'hunk', hunksSelection}); } selectPreviousHunk(preserveTail) { - this.mode = 'hunk'; - this.hunksSelection.selectPreviousItem(preserveTail); + const hunksSelection = this.hunksSelection.copy(); + hunksSelection.selectPreviousItem(preserveTail); + + return this.copy({mode: 'hunk', hunksSelection}); } getSelectedHunks() { @@ -132,38 +242,45 @@ export default class FilePatchSelection { } selectLine(line, preserveTail = false) { - this.mode = 'line'; - this.linesSelection.selectItem(line, preserveTail); + const linesSelection = this.linesSelection.copy(); + linesSelection.selectItem(line, preserveTail); + return this.copy({mode: 'line', linesSelection}); } addOrSubtractLineSelection(line) { - this.mode = 'line'; - this.linesSelection.addOrSubtractSelection(line); + const linesSelection = this.linesSelection.copy(); + linesSelection.addOrSubtractSelection(line); + return this.copy({mode: 'line', linesSelection}); } selectAllLines(preserveTail) { - this.mode = 'line'; - this.linesSelection.selectAllItems(preserveTail); + const linesSelection = this.linesSelection.copy(); + linesSelection.selectAllItems(preserveTail); + return this.copy({mode: 'line', linesSelection}); } selectFirstLine(preserveTail) { - this.mode = 'line'; - this.linesSelection.selectFirstItem(preserveTail); + const linesSelection = this.linesSelection.copy(); + linesSelection.selectFirstItem(preserveTail); + return this.copy({mode: 'line', linesSelection}); } selectLastLine(preserveTail) { - this.mode = 'line'; - this.linesSelection.selectLastItem(preserveTail); + const linesSelection = this.linesSelection.copy(); + linesSelection.selectLastItem(preserveTail); + return this.copy({mode: 'line', linesSelection}); } selectNextLine(preserveTail = false) { - this.mode = 'line'; - this.linesSelection.selectNextItem(preserveTail); + const linesSelection = this.linesSelection.copy(); + linesSelection.selectNextItem(preserveTail); + return this.copy({mode: 'line', linesSelection}); } selectPreviousLine(preserveTail = false) { - this.mode = 'line'; - this.linesSelection.selectPreviousItem(preserveTail); + const linesSelection = this.linesSelection.copy(); + linesSelection.selectPreviousItem(preserveTail); + return this.copy({mode: 'line', linesSelection}); } getSelectedLines() { @@ -185,60 +302,17 @@ export default class FilePatchSelection { } updateHunks(newHunks) { - const oldHunks = this.hunksSelection.getItems(); - - let wasChanged = false; - if (newHunks.length !== oldHunks.length) { - wasChanged = true; - } else { - for (let i = 0; i < oldHunks.length; i++) { - if (oldHunks[i] !== newHunks[i]) { - wasChanged = true; - break; - } - } - } - - this.hunksByLine = new Map(); - const newLines = []; - for (const hunk of newHunks) { - for (const line of hunk.lines) { - newLines.push(line); - this.hunksByLine.set(line, hunk); - } - } - - // Update hunks, preserving selection index - this.hunksSelection.setItems(newHunks); - - // Update lines, preserving selection index in *changed* lines - const oldLines = this.linesSelection.getItems(); - let newSelectedLine; - if (oldLines.length > 0 && newLines.length > 0) { - const oldSelectionStartIndex = this.linesSelection.getMostRecentSelectionStartIndex(); - let changedLineCount = 0; - for (let i = 0; i < oldSelectionStartIndex; i++) { - if (oldLines[i].isChanged()) { changedLineCount++; } - } - - for (let i = 0; i < newLines.length; i++) { - const line = newLines[i]; - if (line.isChanged()) { - newSelectedLine = line; - if (changedLineCount === 0) { break; } - changedLineCount--; - } - } - } - this.linesSelection.setItems(newLines); - if (newSelectedLine) { this.linesSelection.selectItem(newSelectedLine); } - - if (wasChanged) { this.resolveNextUpdatePromise(); } + return this.copy({hunks: newHunks}); } coalesce() { - this.hunksSelection.coalesce(); - this.linesSelection.coalesce(); + const hunksSelection = this.hunksSelection.copy(); + const linesSelection = this.linesSelection.copy(); + + hunksSelection.coalesce(); + linesSelection.coalesce(); + + return this.copy({hunksSelection, linesSelection}); } getNextUpdatePromise() { diff --git a/lib/views/file-patch-view.js b/lib/views/file-patch-view.js index bce871a398..b60d17ffd0 100644 --- a/lib/views/file-patch-view.js +++ b/lib/views/file-patch-view.js @@ -1,76 +1,118 @@ -/** @jsx etch.dom */ -/* eslint react/no-unknown-property: "off" */ +import React from 'react'; import {CompositeDisposable, Disposable} from 'atom'; - -import etch from 'etch'; import cx from 'classnames'; import {autobind} from 'core-decorators'; import HunkView from './hunk-view'; +import Commands, {Command} from './commands'; import FilePatchSelection from './file-patch-selection'; +import EventWatcher from '../event-watcher'; + +export default class FilePatchView extends React.Component { + static propTypes = { + commandRegistry: React.PropTypes.object.isRequired, + filePath: React.PropTypes.string.isRequired, + hunks: React.PropTypes.arrayOf(React.PropTypes.object).isRequired, + stagingStatus: React.PropTypes.oneOf(['unstaged', 'staged']).isRequired, + isPartiallyStaged: React.PropTypes.bool.isRequired, + hasUndoHistory: React.PropTypes.bool.isRequired, + attemptLineStageOperation: React.PropTypes.func.isRequired, + attemptHunkStageOperation: React.PropTypes.func.isRequired, + discardLines: React.PropTypes.func.isRequired, + undoLastDiscard: React.PropTypes.func.isRequired, + openCurrentFile: React.PropTypes.func.isRequired, + didSurfaceFile: React.PropTypes.func.isRequired, + didDiveIntoCorrespondingFilePatch: React.PropTypes.func.isRequired, + eventWatcher: React.PropTypes.instanceOf(EventWatcher), + } + + static defaultProps = { + eventWatcher: new EventWatcher(), + } + + constructor(props, context) { + super(props, context); -export default class FilePatchView { - constructor(props) { - this.props = props; - this.selection = new FilePatchSelection(this.props.hunks); this.mouseSelectionInProgress = false; + this.disposables = new CompositeDisposable(); + this.state = { + selection: new FilePatchSelection(this.props.hunks), + }; + } + + componentDidMount() { window.addEventListener('mouseup', this.mouseup); - this.disposables = new CompositeDisposable(); this.disposables.add(new Disposable(() => window.removeEventListener('mouseup', this.mouseup))); + } - etch.initialize(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(), - 'core:select-down': () => this.selectToNext(), - 'core:select-to-top': () => this.selectToFirst(), - 'core:select-to-bottom': () => this.selectToLast(), - 'core:select-all': () => this.selectAll(), - 'github:select-next-hunk': () => this.selectNextHunk(), - 'github:select-previous-hunk': () => this.selectPreviousHunk(), - 'github:open-file': () => this.openFile(), - 'github:view-corresponding-diff': () => { - this.props.isPartiallyStaged && this.props.didDiveIntoCorrespondingFilePatch(); - }, - 'github:discard-selected-lines': () => this.discardSelection(), - 'core:undo': () => this.props.hasUndoHistory && this.props.undoLastDiscard(), - })); - this.disposables.add(this.props.commandRegistry.add('atom-workspace', { - 'github:undo-last-discard-in-diff-view': () => this.props.hasUndoHistory && this.props.undoLastDiscard(), - })); - } - - update(props) { - this.props = props; - this.selection.updateHunks(this.props.hunks); - return etch.update(this); - } - - destroy() { - this.disposables.dispose(); - return etch.destroy(this); + componentWillReceiveProps(nextProps) { + const hunksChanged = this.props.hunks.length !== nextProps.hunks.length || + this.props.hunks.some((hunk, index) => hunk !== nextProps.hunks[index]); + + if (hunksChanged) { + this.setState(prevState => { + return { + selection: prevState.selection.updateHunks(nextProps.hunks), + }; + }, () => { + nextProps.eventWatcher.resolvePatchChangedPromise(); + }); + } } render() { - const selectedHunks = this.selection.getSelectedHunks(); - const selectedLines = this.selection.getSelectedLines(); - const headHunk = this.selection.getHeadHunk(); - const headLine = this.selection.getHeadLine(); - const hunkSelectionMode = this.selection.getMode() === 'hunk'; + const selectedHunks = this.state.selection.getSelectedHunks(); + const selectedLines = this.state.selection.getSelectedLines(); + const headHunk = this.state.selection.getHeadHunk(); + const headLine = this.state.selection.getHeadLine(); + const hunkSelectionMode = this.state.selection.getMode() === 'hunk'; + const unstaged = this.props.stagingStatus === 'unstaged'; const stageButtonLabelPrefix = unstaged ? 'Stage' : 'Unstage'; + return ( - <div className={cx('github-FilePatchView', {'is-staged': !unstaged, 'is-unstaged': unstaged})} tabIndex="-1" - onmouseup={this.mouseup}> + <div + className={cx('github-FilePatchView', {'is-staged': !unstaged, 'is-unstaged': unstaged})} + tabIndex="-1" + onMouseUp={this.mouseup} + ref={e => { this.element = e; }}> + + <Commands registry={this.props.commandRegistry} target=".github-FilePatchView"> + <Command command="github:toggle-patch-selection-mode" callback={this.togglePatchSelectionMode} /> + <Command command="core:confirm" callback={this.didConfirm} /> + <Command command="core:move-up" callback={this.selectPrevious} /> + <Command command="core:move-down" callback={this.selectNext} /> + <Command command="core:move-right" callback={this.didMoveRight} /> + <Command command="core:move-to-top" callback={this.selectFirst} /> + <Command command="core:move-to-bottom" callback={this.selectLast} /> + <Command command="core:select-up" callback={this.selectToPrevious} /> + <Command command="core:select-down" callback={this.selectToNext} /> + <Command command="core:select-to-top" callback={this.selectToFirst} /> + <Command command="core:select-to-bottom" callback={this.selectToLast} /> + <Command command="core:select-all" callback={this.selectAll} /> + <Command command="github:select-next-hunk" callback={this.selectNextHunk} /> + <Command command="github:select-previous-hunk" callback={this.selectPreviousHunk} /> + <Command command="github:open-file" callback={this.openFile} /> + <Command + command="github:view-corresponding-diff" + callback={() => this.props.isPartiallyStaged && this.props.didDiveIntoCorrespondingFilePatch()} + /> + <Command command="github:discard-selected-lines" callback={this.discardSelection} /> + <Command + command="core:undo" + callback={() => this.props.hasUndoHistory && this.props.undoLastDiscard()} + /> + </Commands> + + <Commands registry={this.props.commandRegistry} target="atom-workspace"> + <Command + command="github:undo-last-discard-in-diff-view" + callback={() => this.props.hasUndoHistory && this.props.undoLastDiscard()} + /> + </Commands> + <header className="github-FilePatchView-header"> <span className="github-FilePatchView-title"> {unstaged ? 'Unstaged Changes for ' : 'Staged Changes for '} @@ -103,7 +145,6 @@ export default class FilePatchView { mousemoveOnLine={this.mousemoveOnLine} contextMenuOnItem={this.contextMenuOnItem} didClickStageButton={() => this.didClickStageButtonForHunk(hunk)} - registerView={this.props.registerHunkView} /> ); })} @@ -121,215 +162,252 @@ export default class FilePatchView { {this.props.hasUndoHistory && unstaged ? ( <button className="btn icon icon-history" - onclick={this.props.undoLastDiscard}> + onClick={this.props.undoLastDiscard}> Undo Discard </button> ) : null} {this.props.isPartiallyStaged ? ( <button className={cx('btn', 'icon', {'icon-tasklist': unstaged, 'icon-list-unordered': !unstaged})} - onclick={this.props.didDiveIntoCorrespondingFilePatch} + onClick={this.props.didDiveIntoCorrespondingFilePatch} /> ) : null} <button className="btn icon icon-code" - onclick={this.openFile} + onClick={this.openFile} /> <button className={cx('btn', 'icon', {'icon-move-down': unstaged, 'icon-move-up': !unstaged})} - onclick={this.stageOrUnstageAll}> + onClick={this.stageOrUnstageAll}> {unstaged ? 'Stage All' : 'Unstage All'} </button> </span> ); } + componentWillUnmount() { + this.disposables.dispose(); + } + @autobind - async contextMenuOnItem(event, hunk, line) { - const mode = this.selection.getMode(); - if (mode === 'hunk' && !this.selection.getSelectedHunks().has(hunk)) { - this.selection.selectHunk(hunk, event.shiftKey); - } else if (mode === 'line' && !this.selection.getSelectedLines().has(line)) { - this.selection.selectLine(line, event.shiftKey); - } else { - return; + contextMenuOnItem(event, hunk, line) { + const resend = () => { + event.stopPropagation(); + + const newEvent = new MouseEvent(event.type, event); + requestAnimationFrame(() => { + event.target.parentNode.dispatchEvent(newEvent); + }); + }; + + const mode = this.state.selection.getMode(); + if (mode === 'hunk' && !this.state.selection.getSelectedHunks().has(hunk)) { + this.setState(prevState => { + return {selection: prevState.selection.selectHunk(hunk, event.shiftKey)}; + }, resend); + } else if (mode === 'line' && !this.state.selection.getSelectedLines().has(line)) { + this.setState(prevState => { + return {selection: prevState.selection.selectLine(line, event.shiftKey)}; + }, resend); } - event.stopPropagation(); - await etch.update(this); - const newEvent = new MouseEvent(event.type, event); - requestAnimationFrame(() => { - event.target.parentNode.dispatchEvent(newEvent); - }); } - async mousedownOnHeader(event, hunk) { + mousedownOnHeader(event, hunk) { if (event.button !== 0) { return; } const windows = process.platform === 'win32'; if (event.ctrlKey && !windows) { return; } // simply open context menu - if (event.metaKey || (event.ctrlKey && windows)) { - if (this.selection.getMode() === 'hunk') { - this.selection.addOrSubtractHunkSelection(hunk); - } else { - // TODO: optimize - hunk.getLines().forEach(line => { - this.selection.addOrSubtractLineSelection(line); - this.selection.coalesce(); - }); - } - } else if (event.shiftKey) { - if (this.selection.getMode() === 'hunk') { - this.selection.selectHunk(hunk, true); - } else { - const hunkLines = hunk.getLines(); - const tailIndex = this.selection.getLineSelectionTailIndex(); - const selectedHunkAfterTail = tailIndex < hunkLines[0].diffLineNumber; - if (selectedHunkAfterTail) { - this.selection.selectLine(hunkLines[hunkLines.length - 1], true); + + this.mouseSelectionInProgress = true; + event.persist && event.persist(); + + this.setState(prevState => { + let selection = prevState.selection; + if (event.metaKey || (event.ctrlKey && windows)) { + if (selection.getMode() === 'hunk') { + selection = selection.addOrSubtractHunkSelection(hunk); } else { - this.selection.selectLine(hunkLines[0], true); + // TODO: optimize + selection = hunk.getLines().reduce( + (current, line) => current.addOrSubtractLineSelection(line).coalesce(), + selection, + ); } + } else if (event.shiftKey) { + if (selection.getMode() === 'hunk') { + selection = selection.selectHunk(hunk, true); + } else { + const hunkLines = hunk.getLines(); + const tailIndex = selection.getLineSelectionTailIndex(); + const selectedHunkAfterTail = tailIndex < hunkLines[0].diffLineNumber; + if (selectedHunkAfterTail) { + selection = selection.selectLine(hunkLines[hunkLines.length - 1], true); + } else { + selection = selection.selectLine(hunkLines[0], true); + } + } + } else { + selection = selection.selectHunk(hunk, false); } - } else { - this.selection.selectHunk(hunk, false); - } - this.mouseSelectionInProgress = true; - await etch.update(this); + + return {selection}; + }); } @autobind - async mousedownOnLine(event, hunk, line) { + mousedownOnLine(event, hunk, line) { if (event.button !== 0) { return; } const windows = process.platform === 'win32'; if (event.ctrlKey && !windows) { return; } // simply open context menu - if (event.metaKey || (event.ctrlKey && windows)) { - if (this.selection.getMode() === 'hunk') { - this.selection.addOrSubtractHunkSelection(hunk); - } else { - this.selection.addOrSubtractLineSelection(line); - } - } else if (event.shiftKey) { - if (this.selection.getMode() === 'hunk') { - this.selection.selectHunk(hunk, true); - } else { - this.selection.selectLine(line, true); - } - } else if (event.detail === 1) { - this.selection.selectLine(line, false); - } else if (event.detail === 2) { - this.selection.selectHunk(hunk, false); - } + this.mouseSelectionInProgress = true; - await etch.update(this); + event.persist && event.persist(); + + this.setState(prevState => { + let selection = prevState.selection; + + if (event.metaKey || (event.ctrlKey && windows)) { + if (selection.getMode() === 'hunk') { + selection = selection.addOrSubtractHunkSelection(hunk); + } else { + selection = selection.addOrSubtractLineSelection(line); + } + } else if (event.shiftKey) { + if (selection.getMode() === 'hunk') { + selection = selection.selectHunk(hunk, true); + } else { + selection = selection.selectLine(line, true); + } + } else if (event.detail === 1) { + selection = selection.selectLine(line, false); + } else if (event.detail === 2) { + selection = selection.selectHunk(hunk, false); + } + + return {selection}; + }); } @autobind - async mousemoveOnLine(event, hunk, line) { + mousemoveOnLine(event, hunk, line) { if (!this.mouseSelectionInProgress) { return; } - if (this.selection.getMode() === 'hunk') { - this.selection.selectHunk(hunk, true); - } else { - this.selection.selectLine(line, true); - } - await etch.update(this); + + this.setState(prevState => { + let selection = null; + if (prevState.selection.getMode() === 'hunk') { + selection = prevState.selection.selectHunk(hunk, true); + } else { + selection = prevState.selection.selectLine(line, true); + } + return {selection}; + }); } @autobind mouseup() { this.mouseSelectionInProgress = false; - this.selection.coalesce(); + this.setState(prevState => { + return {selection: prevState.selection.coalesce()}; + }); } + @autobind togglePatchSelectionMode() { - this.selection.toggleMode(); - return etch.update(this); + this.setState(prevState => ({selection: prevState.selection.toggleMode()})); } getPatchSelectionMode() { - return this.selection.getMode(); + return this.state.selection.getMode(); } getSelectedHunks() { - return this.selection.getSelectedHunks(); + return this.state.selection.getSelectedHunks(); } getSelectedLines() { - return this.selection.getSelectedLines(); + return this.state.selection.getSelectedLines(); } + @autobind selectNext() { - this.selection.selectNext(); - return etch.update(this); + this.setState(prevState => ({selection: prevState.selection.selectNext()})); } + @autobind selectNextHunk() { - this.selection.jumpToNextHunk(); - return etch.update(this); + this.setState(prevState => ({selection: prevState.selection.jumpToNextHunk()})); } + @autobind selectToNext() { - this.selection.selectNext(true); - this.selection.coalesce(); - return etch.update(this); + this.setState(prevState => { + return {selection: prevState.selection.selectNext(true).coalesce()}; + }); } + @autobind selectPrevious() { - this.selection.selectPrevious(); - return etch.update(this); + this.setState(prevState => ({selection: prevState.selection.selectPrevious()})); } + @autobind selectPreviousHunk() { - this.selection.jumpToPreviousHunk(); - return etch.update(this); + this.setState(prevState => ({selection: prevState.selection.jumpToPreviousHunk()})); } + @autobind selectToPrevious() { - this.selection.selectPrevious(true); - this.selection.coalesce(); - return etch.update(this); + this.setState(prevState => { + return {selection: prevState.selection.selectPrevious(true).coalesce()}; + }); } + @autobind selectFirst() { - this.selection.selectFirst(); - return etch.update(this); + this.setState(prevState => ({selection: prevState.selection.selectFirst()})); } + @autobind selectToFirst() { - this.selection.selectFirst(true); - return etch.update(this); + this.setState(prevState => ({selection: prevState.selection.selectFirst(true)})); } + @autobind selectLast() { - this.selection.selectLast(); - return etch.update(this); + this.setState(prevState => ({selection: prevState.selection.selectLast()})); } + @autobind selectToLast() { - this.selection.selectLast(true); - return etch.update(this); + this.setState(prevState => ({selection: prevState.selection.selectLast(true)})); } + @autobind selectAll() { - this.selection.selectAll(); - return etch.update(this); + return new Promise(resolve => { + this.setState(prevState => ({selection: prevState.selection.selectAll()}), resolve); + }); } getNextHunkUpdatePromise() { - return this.selection.getNextUpdatePromise(); + return this.state.selection.getNextUpdatePromise(); } didClickStageButtonForHunk(hunk) { - if (this.selection.getSelectedHunks().has(hunk)) { - return this.props.attemptLineStageOperation(this.selection.getSelectedLines()); + if (this.state.selection.getSelectedHunks().has(hunk)) { + this.props.attemptLineStageOperation(this.state.selection.getSelectedLines()); } else { - this.selection.selectHunk(hunk); - return this.props.attemptHunkStageOperation(hunk); + this.setState(prevState => ({selection: prevState.selection.selectHunk(hunk)}), () => { + this.props.attemptHunkStageOperation(hunk); + }); } } + @autobind didConfirm() { - return this.didClickStageButtonForHunk([...this.selection.getSelectedHunks()][0]); + return this.didClickStageButtonForHunk([...this.state.selection.getSelectedHunks()][0]); } + @autobind didMoveRight() { if (this.props.didSurfaceFile) { this.props.didSurfaceFile(); @@ -343,24 +421,25 @@ export default class FilePatchView { @autobind openFile() { let lineNumber = 0; - const firstSelectedLine = Array.from(this.selection.getSelectedLines())[0]; + const firstSelectedLine = Array.from(this.state.selection.getSelectedLines())[0]; if (firstSelectedLine && firstSelectedLine.newLineNumber > -1) { lineNumber = firstSelectedLine.newLineNumber; } else { - const firstSelectedHunk = Array.from(this.selection.getSelectedHunks())[0]; + const firstSelectedHunk = Array.from(this.state.selection.getSelectedHunks())[0]; lineNumber = firstSelectedHunk ? firstSelectedHunk.getNewStartRow() : 0; } return this.props.openCurrentFile({lineNumber}); } @autobind - stageOrUnstageAll() { - this.selectAll(); + async stageOrUnstageAll() { + await this.selectAll(); this.didConfirm(); } + @autobind discardSelection() { - const selectedLines = this.selection.getSelectedLines(); + const selectedLines = this.state.selection.getSelectedLines(); return selectedLines.size ? this.props.discardLines(selectedLines) : null; } } diff --git a/lib/views/hunk-view.js b/lib/views/hunk-view.js index f9f74bd9ce..bf34b60c17 100644 --- a/lib/views/hunk-view.js +++ b/lib/views/hunk-view.js @@ -1,56 +1,44 @@ -/** @jsx etch.dom */ -/* eslint react/no-unknown-property: "off" */ - -import etch from 'etch'; +import React from 'react'; import {autobind} from 'core-decorators'; -export default class HunkView { - constructor(props) { - this.lastMousemoveLine = null; - this.props = props; - if (props.registerView != null) { props.registerView(props.hunk, this); } // only for tests - this.lineElements = new WeakMap(); - this.registerLineElement = this.lineElements.set.bind(this.lineElements); - etch.initialize(this); - } - - destroy() { - return etch.destroy(this); +export default class HunkView extends React.Component { + static propTypes = { + hunk: React.PropTypes.object.isRequired, + headHunk: React.PropTypes.object, + headLine: React.PropTypes.object, + isSelected: React.PropTypes.bool.isRequired, + selectedLines: React.PropTypes.instanceOf(Set).isRequired, + hunkSelectionMode: React.PropTypes.bool.isRequired, + stageButtonLabel: React.PropTypes.string.isRequired, + mousedownOnHeader: React.PropTypes.func.isRequired, + mousedownOnLine: React.PropTypes.func.isRequired, + mousemoveOnLine: React.PropTypes.func.isRequired, + contextMenuOnItem: React.PropTypes.func.isRequired, + didClickStageButton: React.PropTypes.func.isRequired, } - @autobind - mousedownOnLine(event, line) { - this.props.mousedownOnLine(event, this.props.hunk, line); - } + constructor(props, context) { + super(props, context); - @autobind - mousemoveOnLine(event, line) { - if (line !== this.lastMousemoveLine) { - this.lastMousemoveLine = line; - this.props.mousemoveOnLine(event, this.props.hunk, line); - } - } - - update(props) { - this.props = props; - if (props.registerView != null) { props.registerView(props.hunk, this); } // only for tests - return etch.update(this); + this.lineElements = new WeakMap(); + this.lastMousemoveLine = null; } render() { const hunkSelectedClass = this.props.isSelected ? 'is-selected' : ''; const hunkModeClass = this.props.hunkSelectionMode ? 'is-hunkMode' : ''; + return ( - <div className={`github-HunkView ${hunkModeClass} ${hunkSelectedClass}`}> + <div className={`github-HunkView ${hunkModeClass} ${hunkSelectedClass}`} ref={e => { this.element = e; }}> <div className="github-HunkView-header" - onmousedown={e => this.props.mousedownOnHeader(e)}> - <span ref="header" className="github-HunkView-title"> + onMouseDown={e => this.props.mousedownOnHeader(e)}> + <span className="github-HunkView-title"> {this.props.hunk.getHeader().trim()} {this.props.hunk.getSectionHeading().trim()} </span> - <button ref="stageButton" + <button className="github-HunkView-stageButton" - onclick={this.props.didClickStageButton} - onmousedown={event => event.stopPropagation()}> + onClick={this.props.didClickStageButton} + onMouseDown={event => event.stopPropagation()}> {this.props.stageButtonLabel} </button> </div> @@ -69,11 +57,25 @@ export default class HunkView { ); } + @autobind + mousedownOnLine(event, line) { + this.props.mousedownOnLine(event, this.props.hunk, line); + } + + @autobind + mousemoveOnLine(event, line) { + if (line !== this.lastMousemoveLine) { + this.lastMousemoveLine = line; + this.props.mousemoveOnLine(event, this.props.hunk, line); + } + } + + @autobind registerLineElement(line, element) { this.lineElements.set(line, element); } - writeAfterUpdate() { + componentDidUpdate() { if (this.props.headHunk === this.props.hunk) { this.element.scrollIntoViewIfNeeded(); } else if (this.props.headLine && this.lineElements.has(this.props.headLine)) { @@ -82,16 +84,14 @@ export default class HunkView { } } -class LineView { - constructor(props) { - this.props = props; - etch.initialize(this); - this.props.registerLineElement(props.line, this.element); - } - - update(props) { - this.props = props; - return etch.update(this); +class LineView extends React.Component { + static propTypes = { + line: React.PropTypes.object.isRequired, + isSelected: React.PropTypes.bool.isRequired, + mousedown: React.PropTypes.func.isRequired, + mousemove: React.PropTypes.func.isRequired, + contextMenuOnItem: React.PropTypes.func.isRequired, + registerLineElement: React.PropTypes.func.isRequired, } render() { @@ -99,11 +99,14 @@ class LineView { const oldLineNumber = line.getOldLineNumber() === -1 ? ' ' : line.getOldLineNumber(); const newLineNumber = line.getNewLineNumber() === -1 ? ' ' : line.getNewLineNumber(); const lineSelectedClass = this.props.isSelected ? 'is-selected' : ''; + return ( - <div className={`github-HunkView-line ${lineSelectedClass} is-${line.getStatus()}`} - onmousedown={event => this.props.mousedown(event, line)} - onmousemove={event => this.props.mousemove(event, line)} - oncontextmenu={event => this.props.contextMenuOnItem(event, line)}> + <div + className={`github-HunkView-line ${lineSelectedClass} is-${line.getStatus()}`} + onMouseDown={event => this.props.mousedown(event, line)} + onMouseMove={event => this.props.mousemove(event, line)} + onContextMenu={event => this.props.contextMenuOnItem(event, line)} + ref={e => this.props.registerLineElement(line, e)}> <div className="github-HunkView-lineNumber is-old">{oldLineNumber}</div> <div className="github-HunkView-lineNumber is-new">{newLineNumber}</div> <div className="github-HunkView-lineContent"> diff --git a/lib/views/list-selection.js b/lib/views/list-selection.js index b5d70aff05..b394ca7a4f 100644 --- a/lib/views/list-selection.js +++ b/lib/views/list-selection.js @@ -1,11 +1,32 @@ import {autobind} from 'core-decorators'; +const COPY = {}; + export default class ListSelection { constructor(options = {}) { - this.options = { - isItemSelectable: options.isItemSelectable || (item => true), - }; - this.setItems(options.items || []); + if (options._copy !== COPY) { + this.options = { + isItemSelectable: options.isItemSelectable || (item => true), + }; + this.setItems(options.items || []); + } else { + this.options = { + isItemSelectable: options.isItemSelectable, + }; + this.items = options.items; + this.selections = options.selections; + } + } + + copy() { + // Deep-copy selections because it will be modified. + // (That's temporary, until ListSelection is changed to be immutable, too.) + return new ListSelection({ + _copy: COPY, + isItemSelectable: this.options.isItemSelectable, + items: this.items, + selections: this.selections.map(({head, tail, negate}) => ({head, tail, negate})), + }); } @autobind diff --git a/lib/views/pane-item.js b/lib/views/pane-item.js index 1d7a3d33b2..e408afc452 100644 --- a/lib/views/pane-item.js +++ b/lib/views/pane-item.js @@ -26,7 +26,7 @@ export default class PaneItem extends React.Component { } static defaultProps = { - getItem: ({portal, subtree}) => portal, + getItem: ({portal, subtree}) => portal.getView(), onDidCloseItem: paneItem => {}, } diff --git a/lib/views/portal.js b/lib/views/portal.js index b7b38ccff1..dd7e9cd796 100644 --- a/lib/views/portal.js +++ b/lib/views/portal.js @@ -76,4 +76,31 @@ export default class Portal extends React.Component { getElement() { return this.node; } + + getView() { + if (this.view) { + return this.view; + } + + const override = { + getPortal: () => this, + getInstance: () => this.subtree, + getElement: this.getElement.bind(this), + }; + + this.view = new Proxy(override, { + get(target, name) { + if (Reflect.has(target, name)) { + return target[name]; + } + + return target.getInstance()[name]; + }, + + set(target, name, value) { + target.getInstance()[name] = value; + }, + }); + return this.view; + } } diff --git a/test/controllers/file-patch-controller.test.js b/test/controllers/file-patch-controller.test.js index cff3ef3a8b..f228a9ee1d 100644 --- a/test/controllers/file-patch-controller.test.js +++ b/test/controllers/file-patch-controller.test.js @@ -1,21 +1,52 @@ +import React from 'react'; +import {shallow, mount} from 'enzyme'; + import fs from 'fs'; import path from 'path'; -import {Point} from 'atom'; - import {cloneRepository, buildRepository} from '../helpers'; import FilePatch from '../../lib/models/file-patch'; import FilePatchController from '../../lib/controllers/file-patch-controller'; import Hunk from '../../lib/models/hunk'; import HunkLine from '../../lib/models/hunk-line'; +import EventWatcher from '../../lib/event-watcher'; describe('FilePatchController', function() { - let atomEnv, commandRegistry, workspace; + let atomEnv, commandRegistry; + let component, eventWatcher; + let discardLines, didSurfaceFile, didDiveIntoFilePath, quietlySelectItem, undoLastDiscard, openFiles; beforeEach(function() { atomEnv = global.buildAtomEnvironment(); commandRegistry = atomEnv.commands; - workspace = atomEnv.workspace; + + eventWatcher = new EventWatcher(); + + discardLines = sinon.spy(); + didSurfaceFile = sinon.spy(); + didDiveIntoFilePath = sinon.spy(); + quietlySelectItem = sinon.spy(); + undoLastDiscard = sinon.spy(); + openFiles = sinon.spy(); + + const filePatch = new FilePatch('a.txt', 'a.txt', 'modified', [new Hunk(1, 1, 1, 3, '', [])]); + + component = ( + <FilePatchController + commandRegistry={commandRegistry} + filePatch={filePatch} + stagingStatus="unstaged" + isPartiallyStaged={false} + isAmending={false} + eventWatcher={eventWatcher} + discardLines={discardLines} + didSurfaceFile={didSurfaceFile} + didDiveIntoFilePath={didDiveIntoFilePath} + quietlySelectItem={quietlySelectItem} + undoLastDiscard={undoLastDiscard} + openFiles={openFiles} + /> + ); }); afterEach(function() { @@ -24,51 +55,72 @@ describe('FilePatchController', function() { it('bases its tab title on the staging status', function() { const filePatch1 = new FilePatch('a.txt', 'a.txt', 'modified', [new Hunk(1, 1, 1, 3, '', [])]); - const controller = new FilePatchController({commandRegistry, filePatch: filePatch1, stagingStatus: 'unstaged'}); - assert.equal(controller.getTitle(), 'Unstaged Changes: a.txt'); + + const wrapper = shallow(React.cloneElement(component, { + filePatch: filePatch1, + stagingStatus: 'unstaged', + })); + + assert.equal(wrapper.instance().getTitle(), 'Unstaged Changes: a.txt'); const changeHandler = sinon.spy(); - controller.onDidChangeTitle(changeHandler); + wrapper.instance().onDidChangeTitle(changeHandler); + + wrapper.setProps({stagingStatus: 'staged'}); - controller.update({filePatch: filePatch1, stagingStatus: 'staged'}); - assert.equal(controller.getTitle(), 'Staged Changes: a.txt'); - assert.deepEqual(changeHandler.args, [[controller.getTitle()]]); + const actualTitle = wrapper.instance().getTitle(); + assert.equal(actualTitle, 'Staged Changes: a.txt'); + assert.isTrue(changeHandler.calledWith(actualTitle)); }); - it('renders FilePatchView only if FilePatch has hunks', async function() { + it('renders FilePatchView only if FilePatch has hunks', function() { const emptyFilePatch = new FilePatch('a.txt', 'a.txt', 'modified', []); - const controller = new FilePatchController({commandRegistry, filePatch: emptyFilePatch}); // eslint-disable-line no-new - assert.isUndefined(controller.refs.filePatchView); + + const wrapper = mount(React.cloneElement(component, { + filePatch: emptyFilePatch, + })); + + assert.isFalse(wrapper.find('FilePatchView').exists()); const hunk1 = new Hunk(0, 0, 1, 1, '', [new HunkLine('line-1', 'added', 1, 1)]); const filePatch = new FilePatch('a.txt', 'a.txt', 'modified', [hunk1]); - await controller.update({filePatch}); - assert.isDefined(controller.refs.filePatchView); + + wrapper.setProps({filePatch}); + assert.isTrue(wrapper.find('FilePatchView').exists()); }); - it('updates when a new FilePatch is passed', async function() { + it('updates when a new FilePatch is passed', function() { const hunk1 = new Hunk(5, 5, 2, 1, '', [new HunkLine('line-1', 'added', -1, 5)]); 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({commandRegistry, filePatch, registerHunkView: (hunk, ctrl) => hunkViewsByHunk.set(hunk, ctrl)}); // eslint-disable-line no-new - assert(hunkViewsByHunk.get(hunk1) != null); - assert(hunkViewsByHunk.get(hunk2) != null); + const filePatch0 = new FilePatch('a.txt', 'a.txt', 'modified', [hunk1, hunk2]); + + const wrapper = shallow(React.cloneElement(component, { + filePatch: filePatch0, + })); + + const view0 = wrapper.find('FilePatchView').shallow(); + assert.isTrue(view0.find({hunk: hunk1}).exists()); + assert.isTrue(view0.find({hunk: hunk2}).exists()); - hunkViewsByHunk.clear(); const hunk3 = new Hunk(8, 8, 1, 1, '', [new HunkLine('line-10', 'modified', 10, 10)]); - await controller.update({filePatch: new FilePatch('a.txt', 'a.txt', 'modified', [hunk1, hunk3])}); - assert(hunkViewsByHunk.get(hunk1) != null); - assert(hunkViewsByHunk.get(hunk2) == null); - assert(hunkViewsByHunk.get(hunk3) != null); + const filePatch1 = new FilePatch('a.txt', 'a.txt', 'modified', [hunk1, hunk3]); + + wrapper.setProps({filePatch: filePatch1}); + + const view1 = wrapper.find('FilePatchView').shallow(); + assert.isTrue(view1.find({hunk: hunk1}).exists()); + assert.isTrue(view1.find({hunk: hunk3}).exists()); + assert.isFalse(view1.find({hunk: hunk2}).exists()); }); it('invokes a didSurfaceFile callback with the current file path', function() { - 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}); + const filePatch = new FilePatch('a.txt', 'a.txt', 'modified', [new Hunk(1, 1, 1, 3, '', [])]); + const wrapper = mount(React.cloneElement(component, { + filePatch, + stagingStatus: 'unstaged', + })); - commandRegistry.dispatch(controller.refs.filePatchView.element, 'core:move-right'); + commandRegistry.dispatch(wrapper.find('FilePatchView').getDOMNode(), 'core:move-right'); assert.isTrue(didSurfaceFile.calledWith('a.txt', 'unstaged')); }); @@ -76,10 +128,16 @@ describe('FilePatchController', function() { const workdirPath = await cloneRepository('multi-line-file'); const repository = await buildRepository(workdirPath); const filePatch = new FilePatch('a.txt', 'a.txt', 'modified', [new Hunk(1, 1, 1, 3, '', [])]); - const controller = new FilePatchController({commandRegistry, filePatch, repository, stagingStatus: 'unstaged'}); - assert.equal(controller.getRepository(), repository); - controller.update({repository: null}); - assert.equal(controller.getRepository(), repository); + + const wrapper = shallow(React.cloneElement(component, { + filePatch, + repository, + })); + + assert.equal(wrapper.instance().props.repository, repository); + // This is mostly ensuring that the render here doesn't throw. + wrapper.setProps({repository: null}); + assert.isNull(wrapper.instance().props.repository); }); describe('integration tests', function() { @@ -96,17 +154,25 @@ describe('FilePatchController', function() { ); unstagedLines.splice(11, 2, 'this is a modified line'); fs.writeFileSync(filePath, unstagedLines.join('\n')); + const unstagedFilePatch = await repository.getFilePatchForPath('sample.js'); - const hunkViewsByHunk = new Map(); - function registerHunkView(hunk, view) { hunkViewsByHunk.set(hunk, view); } + const wrapper = mount(React.cloneElement(component, { + filePatch: unstagedFilePatch, + stagingStatus: 'unstaged', + repository, + })); + + // selectNext() + commandRegistry.dispatch(wrapper.find('FilePatchView').getDOMNode(), 'core:move-down'); + + const hunkView0 = wrapper.find('HunkView').at(0); + assert.isFalse(hunkView0.prop('isSelected')); + + const opPromise0 = eventWatcher.getStageOperationPromise(); + hunkView0.find('button.github-HunkView-stageButton').simulate('click'); + await opPromise0; - 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]); - assert.notDeepEqual(view.selectedHunk, unstagedFilePatch.getHunks()[0]); - await hunkToStage.props.didClickStageButton(); const expectedStagedLines = originalLines.slice(); expectedStagedLines.splice(1, 1, 'this is a modified line', @@ -115,9 +181,19 @@ describe('FilePatchController', function() { ); assert.autocrlfEqual(await repository.readFileFromIndex('sample.js'), expectedStagedLines.join('\n')); + const updatePromise0 = eventWatcher.getPatchChangedPromise(); const stagedFilePatch = await repository.getFilePatchForPath('sample.js', {staged: true}); - await controller.update({filePatch: stagedFilePatch, repository, stagingStatus: 'staged', registerHunkView}); - await hunkViewsByHunk.get(stagedFilePatch.getHunks()[0]).props.didClickStageButton(); + wrapper.setProps({ + filePatch: stagedFilePatch, + stagingStatus: 'staged', + }); + await updatePromise0; + + const hunkView1 = wrapper.find('HunkView').at(0); + const opPromise1 = eventWatcher.getStageOperationPromise(); + hunkView1.find('button.github-HunkView-stageButton').simulate('click'); + await opPromise1; + assert.autocrlfEqual(await repository.readFileFromIndex('sample.js'), originalLines.join('\n')); }); @@ -136,74 +212,97 @@ describe('FilePatchController', function() { ); unstagedLines.splice(11, 2, 'this is a modified line'); fs.writeFileSync(filePath, unstagedLines.join('\n')); - let unstagedFilePatch = await repository.getFilePatchForPath('sample.js'); - const hunkViewsByHunk = new Map(); - function registerHunkView(hunk, view) { hunkViewsByHunk.set(hunk, view); } + const unstagedFilePatch0 = await repository.getFilePatchForPath('sample.js'); // stage a subset of lines from first hunk - 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(); - let hunkView = hunkViewsByHunk.get(hunk); - hunkView.props.mousedownOnLine({button: 0, detail: 1}, hunk, lines[1]); - hunkView.props.mousemoveOnLine({}, hunk, lines[3]); - view.mouseup(); - await hunkView.props.didClickStageButton(); + const wrapper = mount(React.cloneElement(component, { + filePatch: unstagedFilePatch0, + stagingStatus: 'unstaged', + repository, + })); + + const opPromise0 = eventWatcher.getStageOperationPromise(); + const hunkView0 = wrapper.find('HunkView').at(0); + hunkView0.find('LineView').at(1).simulate('mousedown', {button: 0, detail: 1}); + hunkView0.find('LineView').at(3).simulate('mousemove', {}); + window.dispatchEvent(new MouseEvent('mouseup')); + hunkView0.find('button.github-HunkView-stageButton').simulate('click'); + await opPromise0; + repository.refresh(); - let expectedLines = originalLines.slice(); - expectedLines.splice(1, 1, + const expectedLines0 = originalLines.slice(); + expectedLines0.splice(1, 1, 'this is a modified line', 'this is a new line', ); - assert.autocrlfEqual(await repository.readFileFromIndex('sample.js'), expectedLines.join('\n')); + assert.autocrlfEqual(await repository.readFileFromIndex('sample.js'), expectedLines0.join('\n')); // stage remaining lines in hunk - unstagedFilePatch = await repository.getFilePatchForPath('sample.js'); - await controller.update({filePatch: unstagedFilePatch}); - hunk = unstagedFilePatch.getHunks()[0]; - hunkView = hunkViewsByHunk.get(hunk); - await hunkView.props.didClickStageButton(); + const updatePromise1 = eventWatcher.getPatchChangedPromise(); + const unstagedFilePatch1 = await repository.getFilePatchForPath('sample.js'); + wrapper.setProps({filePatch: unstagedFilePatch1}); + await updatePromise1; + + const opPromise1 = eventWatcher.getStageOperationPromise(); + wrapper.find('HunkView').at(0).find('button.github-HunkView-stageButton').simulate('click'); + await opPromise1; + repository.refresh(); - expectedLines = originalLines.slice(); - expectedLines.splice(1, 1, + const expectedLines1 = originalLines.slice(); + expectedLines1.splice(1, 1, 'this is a modified line', 'this is a new line', 'this is another new line', ); - assert.autocrlfEqual(await repository.readFileFromIndex('sample.js'), expectedLines.join('\n')); + assert.autocrlfEqual(await repository.readFileFromIndex('sample.js'), expectedLines1.join('\n')); // unstage a subset of lines from the first hunk - let stagedFilePatch = await repository.getFilePatchForPath('sample.js', {staged: true}); - await controller.update({filePatch: stagedFilePatch, repository, stagingStatus: 'staged', registerHunkView}); - hunk = stagedFilePatch.getHunks()[0]; - lines = hunk.getLines(); - hunkView = hunkViewsByHunk.get(hunk); - hunkView.props.mousedownOnLine({button: 0, detail: 1}, hunk, lines[1]); - view.mouseup(); - hunkView.props.mousedownOnLine({button: 0, detail: 1, metaKey: true}, hunk, lines[2]); - view.mouseup(); - - await hunkView.props.didClickStageButton(); + const updatePromise2 = eventWatcher.getPatchChangedPromise(); + const stagedFilePatch2 = await repository.getFilePatchForPath('sample.js', {staged: true}); + wrapper.setProps({ + filePatch: stagedFilePatch2, + stagingStatus: 'staged', + }); + await updatePromise2; + + const hunkView2 = wrapper.find('HunkView').at(0); + hunkView2.find('LineView').at(1).simulate('mousedown', {button: 0, detail: 1}); + window.dispatchEvent(new MouseEvent('mouseup')); + hunkView2.find('LineView').at(2).simulate('mousedown', {button: 0, detail: 1, metaKey: true}); + window.dispatchEvent(new MouseEvent('mouseup')); + + const opPromise2 = eventWatcher.getStageOperationPromise(); + hunkView2.find('button.github-HunkView-stageButton').simulate('click'); + await opPromise2; + repository.refresh(); - expectedLines = originalLines.slice(); - expectedLines.splice(2, 0, + const expectedLines2 = originalLines.slice(); + expectedLines2.splice(2, 0, 'this is a new line', 'this is another new line', ); - assert.autocrlfEqual(await repository.readFileFromIndex('sample.js'), expectedLines.join('\n')); + assert.autocrlfEqual(await repository.readFileFromIndex('sample.js'), expectedLines2.join('\n')); // unstage the rest of the hunk - stagedFilePatch = await repository.getFilePatchForPath('sample.js', {staged: true}); - await controller.update({filePatch: stagedFilePatch}); - await view.togglePatchSelectionMode(); - await hunkView.props.didClickStageButton(); + const updatePromise3 = eventWatcher.getPatchChangedPromise(); + const stagedFilePatch3 = await repository.getFilePatchForPath('sample.js', {staged: true}); + wrapper.setProps({ + filePatch: stagedFilePatch3, + }); + await updatePromise3; + + commandRegistry.dispatch(wrapper.find('FilePatchView').getDOMNode(), 'github:toggle-patch-selection-mode'); + + const opPromise3 = eventWatcher.getStageOperationPromise(); + wrapper.find('HunkView').at(0).find('button.github-HunkView-stageButton').simulate('click'); + await opPromise3; + assert.autocrlfEqual(await repository.readFileFromIndex('sample.js'), originalLines.join('\n')); }); // https://github.com/atom/github/issues/417 describe('when unstaging the last lines/hunks from a file', function() { - it('removes added files from index when last hunk is unstaged', async () => { + it('removes added files from index when last hunk is unstaged', async function() { const workdirPath = await cloneRepository('three-files'); const repository = await buildRepository(workdirPath); const filePath = path.join(workdirPath, 'new-file.txt'); @@ -212,17 +311,21 @@ describe('FilePatchController', function() { await repository.stageFiles(['new-file.txt']); const stagedFilePatch = await repository.getFilePatchForPath('new-file.txt', {staged: true}); - const controller = new FilePatchController({commandRegistry, filePatch: stagedFilePatch, repository, stagingStatus: 'staged'}); - const view = controller.refs.filePatchView; - const hunk = stagedFilePatch.getHunks()[0]; + const wrapper = mount(React.cloneElement(component, { + filePatch: stagedFilePatch, + stagingStatus: 'staged', + repository, + })); + + const opPromise = eventWatcher.getStageOperationPromise(); + wrapper.find('HunkView').at(0).find('button.github-HunkView-stageButton').simulate('click'); + await opPromise; - const {stageOperationPromise} = view.didClickStageButtonForHunk(hunk); - await stageOperationPromise; const stagedChanges = await repository.getStagedChanges(); assert.equal(stagedChanges.length, 0); }); - it('removes added files from index when last lines are unstaged', async () => { + it('removes added files from index when last lines are unstaged', async function() { const workdirPath = await cloneRepository('three-files'); const repository = await buildRepository(workdirPath); const filePath = path.join(workdirPath, 'new-file.txt'); @@ -231,17 +334,22 @@ describe('FilePatchController', function() { await repository.stageFiles(['new-file.txt']); const stagedFilePatch = await repository.getFilePatchForPath('new-file.txt', {staged: true}); - const controller = new FilePatchController({commandRegistry, filePatch: stagedFilePatch, repository, stagingStatus: 'staged'}); - const view = controller.refs.filePatchView; - const hunk = stagedFilePatch.getHunks()[0]; + const wrapper = mount(React.cloneElement(component, { + filePatch: stagedFilePatch, + stagingStatus: 'staged', + repository, + })); - view.togglePatchSelectionMode(); - view.selectAll(); + const viewNode = wrapper.find('FilePatchView').getDOMNode(); + commandRegistry.dispatch(viewNode, 'github:toggle-patch-selection-mode'); + commandRegistry.dispatch(viewNode, 'core:select-all'); + + const opPromise = eventWatcher.getStageOperationPromise(); + wrapper.find('HunkView').at(0).find('button.github-HunkView-stageButton').simulate('click'); + await opPromise; - const {stageOperationPromise} = view.didClickStageButtonForHunk(hunk); - await stageOperationPromise; const stagedChanges = await repository.getStagedChanges(); - assert.equal(stagedChanges.length, 0); + assert.lengthOf(stagedChanges, 0); }); }); @@ -263,29 +371,27 @@ describe('FilePatchController', function() { unstagedLines.splice(11, 2, 'this is a modified line'); fs.writeFileSync(filePath, unstagedLines.join('\n')); const unstagedFilePatch = await repository.getFilePatchForPath('sample.js'); - const hunkViewsByHunk = new Map(); - function registerHunkView(hunk, view) { hunkViewsByHunk.set(hunk, view); } - 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(); - let hunkView = hunkViewsByHunk.get(hunk); - hunkView.props.mousedownOnLine({button: 0, detail: 1}, hunk, lines[1]); - view.mouseup(); + const wrapper = mount(React.cloneElement(component, { + filePatch: unstagedFilePatch, + stagingStatus: 'unstaged', + repository, + })); + + const hunkView0 = wrapper.find('HunkView').at(0); + hunkView0.find('LineView').at(1).simulate('mousedown', {button: 0, detail: 1}); + window.dispatchEvent(new MouseEvent('mouseup')); // stage lines in rapid succession // second stage action is a no-op since the first staging operation is in flight - const line1StagingPromises = hunkView.props.didClickStageButton(); - hunkView.props.didClickStageButton(); + const line1StagingPromise = eventWatcher.getStageOperationPromise(); + hunkView0.find('.github-HunkView-stageButton').simulate('click'); + hunkView0.find('.github-HunkView-stageButton').simulate('click'); + await line1StagingPromise; - await line1StagingPromises.stageOperationPromise; + // assert that only line 1 has been staged repository.refresh(); // clear the cached file patches const modifiedFilePatch = await repository.getFilePatchForPath('sample.js'); - await controller.update({filePatch: modifiedFilePatch, repository, stagingStatus: 'unstaged', registerHunkView}); - await line1StagingPromises.selectionUpdatePromise; - - // assert that only line 1 has been staged let expectedLines = originalLines.slice(); expectedLines.splice(1, 0, 'this is a modified line', @@ -293,14 +399,17 @@ describe('FilePatchController', function() { let actualLines = await repository.readFileFromIndex('sample.js'); assert.autocrlfEqual(actualLines, expectedLines.join('\n')); - hunk = modifiedFilePatch.getHunks()[0]; - lines = hunk.getLines(); - hunkView = hunkViewsByHunk.get(hunk); - hunkView.props.mousedownOnLine({button: 0, detail: 1}, hunk, lines[2]); - view.mouseup(); + const line1PatchPromise = eventWatcher.getPatchChangedPromise(); + wrapper.setProps({filePatch: modifiedFilePatch}); + await line1PatchPromise; - const line2StagingPromises = hunkView.props.didClickStageButton(); - await line2StagingPromises.stageOperationPromise; + const hunkView1 = wrapper.find('HunkView').at(0); + hunkView1.find('LineView').at(2).simulate('mousedown', {button: 0, detail: 1}); + window.dispatchEvent(new MouseEvent('mouseup')); + + const line2StagingPromise = eventWatcher.getStageOperationPromise(); + hunkView1.find('.github-HunkView-stageButton').simulate('click'); + await line2StagingPromise; // assert that line 2 has now been staged expectedLines = originalLines.slice(); @@ -328,23 +437,25 @@ describe('FilePatchController', function() { unstagedLines.splice(11, 2, 'this is a modified line'); fs.writeFileSync(filePath, unstagedLines.join('\n')); const unstagedFilePatch = await repository.getFilePatchForPath('sample.js'); - const hunkViewsByHunk = new Map(); - function registerHunkView(hunk, view) { hunkViewsByHunk.set(hunk, view); } - const controller = new FilePatchController({commandRegistry, filePatch: unstagedFilePatch, repository, stagingStatus: 'unstaged', registerHunkView}); - let hunk = unstagedFilePatch.getHunks()[0]; - let hunkView = hunkViewsByHunk.get(hunk); + const wrapper = mount(React.cloneElement(component, { + filePatch: unstagedFilePatch, + stagingStatus: 'unstaged', + repository, + })); // ensure staging the same hunk twice does not cause issues // second stage action is a no-op since the first staging operation is in flight - const hunk1StagingPromises = hunkView.props.didClickStageButton(); - hunkView.props.didClickStageButton(); + const hunk1StagingPromise = eventWatcher.getStageOperationPromise(); + wrapper.find('HunkView').at(0).find('.github-HunkView-stageButton').simulate('click'); + wrapper.find('HunkView').at(0).find('.github-HunkView-stageButton').simulate('click'); + await hunk1StagingPromise; - await hunk1StagingPromises.stageOperationPromise; + const patchPromise0 = eventWatcher.getPatchChangedPromise(); repository.refresh(); // clear the cached file patches const modifiedFilePatch = await repository.getFilePatchForPath('sample.js'); - await controller.update({filePatch: modifiedFilePatch, repository, stagingStatus: 'unstaged', registerHunkView}); - await hunk1StagingPromises.selectionUpdatePromise; + wrapper.setProps({filePatch: modifiedFilePatch}); + await patchPromise0; let expectedLines = originalLines.slice(); expectedLines.splice(1, 0, @@ -355,11 +466,9 @@ describe('FilePatchController', function() { let actualLines = await repository.readFileFromIndex('sample.js'); assert.autocrlfEqual(actualLines, expectedLines.join('\n')); - hunk = modifiedFilePatch.getHunks()[0]; - hunkView = hunkViewsByHunk.get(hunk); - - const hunk2StagingPromises = hunkView.props.didClickStageButton(); - await hunk2StagingPromises.stageOperationPromise; + const hunk2StagingPromise = eventWatcher.getStageOperationPromise(); + wrapper.find('HunkView').at(0).find('.github-HunkView-stageButton').simulate('click'); + await hunk2StagingPromise; expectedLines = originalLines.slice(); expectedLines.splice(1, 0, @@ -375,23 +484,45 @@ describe('FilePatchController', function() { }); describe('openCurrentFile({lineNumber})', () => { - it('sets the cursor on the correct line of the opened text editor', async () => { + it('sets the cursor on the correct line of the opened text editor', async function() { const workdirPath = await cloneRepository('multi-line-file'); const repository = await buildRepository(workdirPath); - const openFiles = filePaths => { - return Promise.all(filePaths.map(filePath => { - const absolutePath = path.join(repository.getWorkingDirectoryPath(), filePath); - return workspace.open(absolutePath, {pending: filePaths.length === 1}); - })); + const editorSpy = { + relativePath: null, + scrollToBufferPosition: sinon.spy(), + setCursorBufferPosition: sinon.spy(), }; - const hunk1 = new Hunk(5, 5, 2, 1, '', [new HunkLine('line-1', 'added', -1, 5)]); - const filePatch = new FilePatch('sample.js', 'sample.js', 'modified', [hunk1]); - const controller = new FilePatchController({commandRegistry, filePatch, openFiles}); // eslint-disable-line no-new + const openFilesStub = relativePaths => { + assert.lengthOf(relativePaths, 1); + editorSpy.relativePath = relativePaths[0]; + return Promise.resolve([editorSpy]); + }; + + const hunk = new Hunk(5, 5, 2, 1, '', [new HunkLine('line-1', 'added', -1, 5)]); + const filePatch = new FilePatch('sample.js', 'sample.js', 'modified', [hunk]); + + const wrapper = mount(React.cloneElement(component, { + filePatch, + repository, + openFiles: openFilesStub, + })); + + wrapper.find('LineView').simulate('mousedown', {button: 0, detail: 1}); + window.dispatchEvent(new MouseEvent('mouseup')); + commandRegistry.dispatch(wrapper.find('FilePatchView').getDOMNode(), 'github:open-file'); + + await assert.async.isTrue(editorSpy.setCursorBufferPosition.called); + + assert.isTrue(editorSpy.relativePath === 'sample.js'); + + const scrollCall = editorSpy.scrollToBufferPosition.firstCall; + assert.isTrue(scrollCall.args[0].isEqual([4, 0])); + assert.deepEqual(scrollCall.args[1], {center: true}); - const editor = await controller.openCurrentFile({lineNumber: 5}); - assert.deepEqual(editor.getCursorBufferPosition(), new Point(4, 0)); + const cursorCall = editorSpy.setCursorBufferPosition.firstCall; + assert.isTrue(cursorCall.args[0].isEqual([4, 0])); }); }); }); diff --git a/test/controllers/root-controller.test.js b/test/controllers/root-controller.test.js index 246c4071de..0b25ea1c5d 100644 --- a/test/controllers/root-controller.test.js +++ b/test/controllers/root-controller.test.js @@ -2,9 +2,10 @@ import path from 'path'; import fs from 'fs'; import React from 'react'; -import {shallow} from 'enzyme'; +import {shallow, mount} from 'enzyme'; import {cloneRepository, buildRepository} from '../helpers'; +import {writeFile} from '../../lib/helpers'; import {GitError} from '../../lib/git-shell-out-strategy'; import RootController from '../../lib/controllers/root-controller'; @@ -105,7 +106,7 @@ describe('RootController', function() { const state = { filePath: 'path', filePatch: {getPath: () => 'path.txt'}, - stagingStatus: 'stagingStatus', + stagingStatus: 'unstaged', }; wrapper.setState(state); assert.equal(wrapper.find('FilePatchController').length, 1); @@ -145,7 +146,6 @@ describe('RootController', function() { wrapper.find('PaneItem').prop('onDidCloseItem')(); assert.isNull(wrapper.state('filePath')); assert.isNull(wrapper.state('filePatch')); - assert.isNull(wrapper.state('stagingStatus')); const activate = sinon.stub(); wrapper.instance().filePatchControllerPane = {activate}; @@ -226,11 +226,16 @@ describe('RootController', function() { const wrapper = shallow(app); const focusFilePatch = sinon.spy(); - wrapper.instance().filePatchController = { - getWrappedComponent: () => { - return {focus: focusFilePatch}; - }, + const activate = sinon.spy(); + + const mockPane = { + getPaneItem: () => mockPane, + getElement: () => mockPane, + querySelector: () => mockPane, + focus: focusFilePatch, + activate, }; + wrapper.instance().filePatchControllerPane = mockPane; await wrapper.instance().diveIntoFilePatchForPath('a.txt', 'unstaged'); @@ -239,6 +244,7 @@ describe('RootController', function() { assert.equal(wrapper.state('stagingStatus'), 'unstaged'); assert.isTrue(focusFilePatch.called); + assert.isTrue(activate.called); }); }); @@ -995,4 +1001,20 @@ describe('RootController', function() { }); }); }); + + describe('integration tests', function() { + it('mounts the FilePatchController as a PaneItem', async function() { + const workdirPath = await cloneRepository('three-files'); + const repository = await buildRepository(workdirPath); + const wrapper = mount(React.cloneElement(app, {repository})); + + const filePath = path.join(workdirPath, 'a.txt'); + await writeFile(filePath, 'wut\n'); + await wrapper.instance().showFilePatchForPath('a.txt', 'unstaged'); + + const paneItem = workspace.getActivePaneItem(); + assert.isDefined(paneItem); + assert.equal(paneItem.getTitle(), 'Unstaged Changes: a.txt'); + }); + }); }); diff --git a/test/event-watcher.test.js b/test/event-watcher.test.js new file mode 100644 index 0000000000..fbd4a7fe16 --- /dev/null +++ b/test/event-watcher.test.js @@ -0,0 +1,115 @@ +import EventWatcher from '../lib/event-watcher'; + +describe('EventWatcher', function() { + let watcher; + + beforeEach(function() { + watcher = new EventWatcher(); + }); + + it('creates and resolves a Promise for an event', async function() { + const promise = watcher.getPromise('testing'); + + const payload = {}; + watcher.resolvePromise('testing', payload); + + const result = await promise; + assert.strictEqual(result, payload); + }); + + it('supports multiple consumers of the same Promise', async function() { + const promise0 = watcher.getPromise('testing'); + const promise1 = watcher.getPromise('testing'); + assert.strictEqual(promise0, promise1); + + const payload = {}; + watcher.resolvePromise('testing', payload); + + assert.strictEqual(await promise0, payload); + assert.strictEqual(await promise1, payload); + }); + + it('creates new Promises for repeated events', async function() { + const promise0 = watcher.getPromise('testing'); + + watcher.resolvePromise('testing', 0); + assert.equal(await promise0, 0); + + const promise1 = watcher.getPromise('testing'); + + watcher.resolvePromise('testing', 1); + assert.equal(await promise1, 1); + }); + + it('"resolves" an event that has no Promise', function() { + watcher.resolvePromise('anybody-there', {}); + }); + + it('rejects a Promise with an error', async function() { + const promise = watcher.getPromise('testing'); + + watcher.rejectPromise('testing', new Error('oh shit')); + await assert.isRejected(promise, /oh shit/); + }); + + describe('function pairs', function() { + const baseNames = Object.getOwnPropertyNames(EventWatcher.prototype) + .map(methodName => /^get(.+)Promise$/.exec(methodName)) + .filter(match => match !== null) + .map(match => match[1]); + let functionPairs; + + beforeEach(function() { + functionPairs = baseNames.map(baseName => { + return { + baseName, + getter: watcher[`get${baseName}Promise`].bind(watcher), + resolver: watcher[`resolve${baseName}Promise`].bind(watcher), + }; + }); + }); + + baseNames.forEach(baseName => { + it(`resolves the correct Promise for ${baseName}`, async function() { + const allPromises = []; + const positiveResults = []; + const negativeResults = []; + + let positiveResolver = null; + const negativeResolvers = []; + + for (let i = 0; i < functionPairs.length; i++) { + const functionPair = functionPairs[i]; + + if (functionPair.baseName === baseName) { + const positivePromise = functionPair.getter().then(payload => { + positiveResults.push(payload); + }); + allPromises.push(positivePromise); + + positiveResolver = functionPair.resolver; + } else { + const negativePromise = functionPair.getter().then(payload => { + negativeResults.push(payload); + }); + allPromises.push(negativePromise); + + negativeResolvers.push(functionPair.resolver); + } + } + + // Resolve positive resolvers with "yes" and negative resolvers with "no" + positiveResolver('yes'); + negativeResolvers.forEach(resolver => resolver('no')); + + await Promise.all(allPromises); + + assert.lengthOf(positiveResults, 1); + assert.isTrue(positiveResults.every(result => result === 'yes')); + + assert.lengthOf(negativeResults, baseNames.length - 1); + assert.isTrue(negativeResults.every(result => result === 'no')); + }); + }); + }); +}); diff --git a/test/views/file-patch-selection.test.js b/test/views/file-patch-selection.test.js index 0a5125e49f..5e3054b4d3 100644 --- a/test/views/file-patch-selection.test.js +++ b/test/views/file-patch-selection.test.js @@ -1,5 +1,3 @@ -import until from 'test-until'; - import FilePatchSelection from '../../lib/views/file-patch-selection'; import Hunk from '../../lib/models/hunk'; import HunkLine from '../../lib/models/hunk-line'; @@ -25,34 +23,34 @@ describe('FilePatchSelection', function() { new HunkLine('line-11', 'deleted', 9, -1), ]), ]; - const selection = new FilePatchSelection(hunks); + const selection0 = new FilePatchSelection(hunks); - selection.selectLine(hunks[0].lines[1]); - assertEqualSets(selection.getSelectedLines(), new Set([ + const selection1 = selection0.selectLine(hunks[0].lines[1]); + assertEqualSets(selection1.getSelectedLines(), new Set([ hunks[0].lines[1], ])); - selection.selectLine(hunks[1].lines[2], true); - assertEqualSets(selection.getSelectedLines(), new Set([ + const selection2 = selection1.selectLine(hunks[1].lines[2], true); + assertEqualSets(selection2.getSelectedLines(), new Set([ hunks[0].lines[1], hunks[1].lines[1], hunks[1].lines[2], ])); - selection.selectLine(hunks[1].lines[1], true); - assertEqualSets(selection.getSelectedLines(), new Set([ + const selection3 = selection2.selectLine(hunks[1].lines[1], true); + assertEqualSets(selection3.getSelectedLines(), new Set([ hunks[0].lines[1], hunks[1].lines[1], ])); - selection.selectLine(hunks[0].lines[0], true); - assertEqualSets(selection.getSelectedLines(), new Set([ + const selection4 = selection3.selectLine(hunks[0].lines[0], true); + assertEqualSets(selection4.getSelectedLines(), new Set([ hunks[0].lines[0], hunks[0].lines[1], ])); - selection.selectLine(hunks[1].lines[2]); - assertEqualSets(selection.getSelectedLines(), new Set([ + const selection5 = selection4.selectLine(hunks[1].lines[2]); + assertEqualSets(selection5.getSelectedLines(), new Set([ hunks[1].lines[2], ])); }); @@ -75,24 +73,21 @@ describe('FilePatchSelection', function() { new HunkLine('line-11', 'deleted', 9, -1), ]), ]; - const selection = new FilePatchSelection(hunks); - - selection.selectLine(hunks[0].lines[1]); - selection.selectLine(hunks[1].lines[1], true); - - selection.addOrSubtractLineSelection(hunks[1].lines[3]); - selection.selectLine(hunks[1].lines[4], true); + const selection0 = new FilePatchSelection(hunks) + .selectLine(hunks[0].lines[1]) + .selectLine(hunks[1].lines[1], true) + .addOrSubtractLineSelection(hunks[1].lines[3]) + .selectLine(hunks[1].lines[4], true); - assertEqualSets(selection.getSelectedLines(), new Set([ + assertEqualSets(selection0.getSelectedLines(), new Set([ hunks[0].lines[1], hunks[1].lines[1], hunks[1].lines[3], hunks[1].lines[4], ])); - selection.selectLine(hunks[0].lines[0], true); - - assertEqualSets(selection.getSelectedLines(), new Set([ + const selection1 = selection0.selectLine(hunks[0].lines[0], true); + assertEqualSets(selection1.getSelectedLines(), new Set([ hunks[0].lines[0], hunks[0].lines[1], hunks[1].lines[1], @@ -116,29 +111,29 @@ describe('FilePatchSelection', function() { new HunkLine('line-8', 'added', -1, 10), ]), ]; - const selection = new FilePatchSelection(hunks); + const selection0 = new FilePatchSelection(hunks) + .selectLine(hunks[0].lines[2]) + .selectLine(hunks[1].lines[2], true); - selection.selectLine(hunks[0].lines[2]); - selection.selectLine(hunks[1].lines[2], true); - assertEqualSets(selection.getSelectedLines(), new Set([ + assertEqualSets(selection0.getSelectedLines(), new Set([ hunks[0].lines[2], hunks[1].lines[1], hunks[1].lines[2], ])); - selection.addOrSubtractLineSelection(hunks[1].lines[1]); - assertEqualSets(selection.getSelectedLines(), new Set([ + const selection1 = selection0.addOrSubtractLineSelection(hunks[1].lines[1]); + assertEqualSets(selection1.getSelectedLines(), new Set([ hunks[0].lines[2], hunks[1].lines[2], ])); - selection.selectLine(hunks[1].lines[3], true); - assertEqualSets(selection.getSelectedLines(), new Set([ + const selection2 = selection1.selectLine(hunks[1].lines[3], true); + assertEqualSets(selection2.getSelectedLines(), new Set([ hunks[0].lines[2], ])); - selection.selectLine(hunks[0].lines[1], true); - assertEqualSets(selection.getSelectedLines(), new Set([ + const selection3 = selection2.selectLine(hunks[0].lines[1], true); + assertEqualSets(selection3.getSelectedLines(), new Set([ hunks[1].lines[2], ])); }); @@ -158,44 +153,46 @@ describe('FilePatchSelection', function() { new HunkLine('line-8', 'unchanged', 7, 10), ]), ]; - const selection = new FilePatchSelection(hunks); - - selection.selectLine(hunks[0].lines[1]); - selection.selectNextLine(); - assertEqualSets(selection.getSelectedLines(), new Set([ + const selection0 = new FilePatchSelection(hunks) + .selectLine(hunks[0].lines[1]) + .selectNextLine(); + assertEqualSets(selection0.getSelectedLines(), new Set([ hunks[0].lines[2], ])); - selection.selectNextLine(); - assertEqualSets(selection.getSelectedLines(), new Set([ + + const selection1 = selection0.selectNextLine(); + assertEqualSets(selection1.getSelectedLines(), new Set([ hunks[1].lines[2], ])); - selection.selectNextLine(); - assertEqualSets(selection.getSelectedLines(), new Set([ + + const selection2 = selection1.selectNextLine(); + assertEqualSets(selection2.getSelectedLines(), new Set([ hunks[1].lines[2], ])); - selection.selectPreviousLine(); - assertEqualSets(selection.getSelectedLines(), new Set([ + const selection3 = selection2.selectPreviousLine(); + assertEqualSets(selection3.getSelectedLines(), new Set([ hunks[0].lines[2], ])); - selection.selectPreviousLine(); - assertEqualSets(selection.getSelectedLines(), new Set([ + + const selection4 = selection3.selectPreviousLine(); + assertEqualSets(selection4.getSelectedLines(), new Set([ hunks[0].lines[1], ])); - selection.selectPreviousLine(); - assertEqualSets(selection.getSelectedLines(), new Set([ + + const selection5 = selection4.selectPreviousLine(); + assertEqualSets(selection5.getSelectedLines(), new Set([ hunks[0].lines[1], ])); - selection.selectNextLine(true); - assertEqualSets(selection.getSelectedLines(), new Set([ + const selection6 = selection5.selectNextLine(true); + assertEqualSets(selection6.getSelectedLines(), new Set([ hunks[0].lines[1], hunks[0].lines[2], ])); - selection.selectNextLine(); - selection.selectPreviousLine(true); - assertEqualSets(selection.getSelectedLines(), new Set([ + const selection7 = selection6.selectNextLine().selectPreviousLine(true); + assertEqualSets(selection7.getSelectedLines(), new Set([ hunks[0].lines[2], hunks[1].lines[2], ])); @@ -216,34 +213,38 @@ describe('FilePatchSelection', function() { new HunkLine('line-8', 'unchanged', 7, 10), ]), ]; - const selection = new FilePatchSelection(hunks); - selection.selectLastLine(); - assertEqualSets(selection.getSelectedLines(), new Set([ + const selection0 = new FilePatchSelection(hunks).selectLastLine(); + assertEqualSets(selection0.getSelectedLines(), new Set([ hunks[1].lines[2], ])); - selection.selectFirstLine(); - assertEqualSets(selection.getSelectedLines(), new Set([ + + const selection1 = selection0.selectFirstLine(); + assertEqualSets(selection1.getSelectedLines(), new Set([ hunks[0].lines[1], ])); - selection.selectLastLine(true); - assertEqualSets(selection.getSelectedLines(), new Set([ + + const selection2 = selection1.selectLastLine(true); + assertEqualSets(selection2.getSelectedLines(), new Set([ hunks[0].lines[1], hunks[0].lines[2], hunks[1].lines[2], ])); - selection.selectLastLine(); - assertEqualSets(selection.getSelectedLines(), new Set([ + + const selection3 = selection2.selectLastLine(); + assertEqualSets(selection3.getSelectedLines(), new Set([ hunks[1].lines[2], ])); - selection.selectFirstLine(true); - assertEqualSets(selection.getSelectedLines(), new Set([ + + const selection4 = selection3.selectFirstLine(true); + assertEqualSets(selection4.getSelectedLines(), new Set([ hunks[0].lines[1], hunks[0].lines[2], hunks[1].lines[2], ])); - selection.selectFirstLine(); - assertEqualSets(selection.getSelectedLines(), new Set([ + + const selection5 = selection4.selectFirstLine(); + assertEqualSets(selection5.getSelectedLines(), new Set([ hunks[0].lines[1], ])); }); @@ -263,9 +264,9 @@ describe('FilePatchSelection', function() { new HunkLine('line-8', 'unchanged', 7, 10), ]), ]; - const selection = new FilePatchSelection(hunks); - selection.selectAllLines(); - assertEqualSets(selection.getSelectedLines(), new Set([ + + const selection0 = new FilePatchSelection(hunks).selectAllLines(); + assertEqualSets(selection0.getSelectedLines(), new Set([ hunks[0].lines[1], hunks[0].lines[2], hunks[1].lines[2], @@ -281,22 +282,21 @@ describe('FilePatchSelection', function() { new HunkLine('line-4', 'unchanged', 2, 4), ]), ]; - const selection = new FilePatchSelection(hunks); - selection.selectLine(hunks[0].lines[1]); - selection.addOrSubtractLineSelection(hunks[0].lines[1]); - selection.coalesce(); - assertEqualSets(selection.getSelectedLines(), new Set()); + const selection0 = new FilePatchSelection(hunks) + .selectLine(hunks[0].lines[1]) + .addOrSubtractLineSelection(hunks[0].lines[1]) + .coalesce(); + assertEqualSets(selection0.getSelectedLines(), new Set()); - selection.selectNextLine(); - assertEqualSets(selection.getSelectedLines(), new Set([hunks[0].lines[1]])); + const selection1 = selection0.selectNextLine(); + assertEqualSets(selection1.getSelectedLines(), new Set([hunks[0].lines[1]])); - selection.addOrSubtractLineSelection(hunks[0].lines[1]); - selection.coalesce(); - assertEqualSets(selection.getSelectedLines(), new Set()); + const selection2 = selection1.addOrSubtractLineSelection(hunks[0].lines[1]).coalesce(); + assertEqualSets(selection2.getSelectedLines(), new Set()); - selection.selectPreviousLine(); - assertEqualSets(selection.getSelectedLines(), new Set([hunks[0].lines[2]])); + const selection3 = selection2.selectPreviousLine(); + assertEqualSets(selection3.getSelectedLines(), new Set([hunks[0].lines[2]])); }); it('collapses multiple selections down to one line when selecting next or previous', function() { @@ -314,32 +314,32 @@ describe('FilePatchSelection', function() { new HunkLine('line-8', 'unchanged', 7, 10), ]), ]; - const selection = new FilePatchSelection(hunks); - selection.selectLine(hunks[0].lines[1]); - selection.addOrSubtractLineSelection(hunks[0].lines[2]); - selection.selectNextLine(true); - assertEqualSets(selection.getSelectedLines(), new Set([ + const selection0 = new FilePatchSelection(hunks) + .selectLine(hunks[0].lines[1]) + .addOrSubtractLineSelection(hunks[0].lines[2]) + .selectNextLine(true); + assertEqualSets(selection0.getSelectedLines(), new Set([ hunks[0].lines[1], hunks[0].lines[2], hunks[1].lines[2], ])); - selection.selectNextLine(); - assertEqualSets(selection.getSelectedLines(), new Set([ + const selection1 = selection0.selectNextLine(); + assertEqualSets(selection1.getSelectedLines(), new Set([ hunks[1].lines[2], ])); - selection.selectLine(hunks[0].lines[1]); - selection.addOrSubtractLineSelection(hunks[0].lines[2]); - selection.selectPreviousLine(true); - assertEqualSets(selection.getSelectedLines(), new Set([ + const selection2 = selection1.selectLine(hunks[0].lines[1]) + .addOrSubtractLineSelection(hunks[0].lines[2]) + .selectPreviousLine(true); + assertEqualSets(selection2.getSelectedLines(), new Set([ hunks[0].lines[1], hunks[0].lines[2], ])); - selection.selectPreviousLine(); - assertEqualSets(selection.getSelectedLines(), new Set([ + const selection3 = selection2.selectPreviousLine(); + assertEqualSets(selection3.getSelectedLines(), new Set([ hunks[0].lines[1], ])); }); @@ -360,17 +360,15 @@ describe('FilePatchSelection', function() { new HunkLine('line-8', 'added', -1, 10), ]), ]; - const selection = new FilePatchSelection(hunks); - selection.selectLine(hunks[0].lines[2]); - selection.selectLine(hunks[1].lines[1], true); - - selection.addOrSubtractLineSelection(hunks[0].lines[0]); - selection.selectLine(hunks[1].lines[0], true); - selection.coalesce(); - - selection.selectPreviousLine(true); - assertEqualSets(selection.getSelectedLines(), new Set([ + const selection0 = new FilePatchSelection(hunks) + .selectLine(hunks[0].lines[2]) + .selectLine(hunks[1].lines[1], true) + .addOrSubtractLineSelection(hunks[0].lines[0]) + .selectLine(hunks[1].lines[0], true) + .coalesce() + .selectPreviousLine(true); + assertEqualSets(selection0.getSelectedLines(), new Set([ hunks[0].lines[0], hunks[0].lines[1], hunks[0].lines[2], @@ -378,12 +376,11 @@ describe('FilePatchSelection', function() { hunks[1].lines[0], ])); - selection.addOrSubtractLineSelection(hunks[1].lines[3]); - selection.selectLine(hunks[0].lines[3], true); - selection.coalesce(); - - selection.selectNextLine(true); - assertEqualSets(selection.getSelectedLines(), new Set([ + const selection1 = selection0.addOrSubtractLineSelection(hunks[1].lines[3]) + .selectLine(hunks[0].lines[3], true) + .coalesce() + .selectNextLine(true); + assertEqualSets(selection1.getSelectedLines(), new Set([ hunks[0].lines[1], hunks[0].lines[2], hunks[0].lines[3], @@ -409,27 +406,26 @@ describe('FilePatchSelection', function() { new HunkLine('line-8', 'added', -1, 10), ]), ]; - const selection = new FilePatchSelection(hunks); - selection.selectLine(hunks[0].lines[3]); - selection.selectLine(hunks[1].lines[1], true); - - selection.addOrSubtractLineSelection(hunks[0].lines[1]); - selection.selectLine(hunks[0].lines[2], true); - selection.coalesce(); - selection.selectPreviousLine(true); - assertEqualSets(selection.getSelectedLines(), new Set([ + const selection0 = new FilePatchSelection(hunks) + .selectLine(hunks[0].lines[3]) + .selectLine(hunks[1].lines[1], true) + .addOrSubtractLineSelection(hunks[0].lines[1]) + .selectLine(hunks[0].lines[2], true) + .coalesce() + .selectPreviousLine(true); + assertEqualSets(selection0.getSelectedLines(), new Set([ hunks[0].lines[1], hunks[0].lines[2], hunks[0].lines[3], hunks[1].lines[0], ])); - selection.addOrSubtractLineSelection(hunks[1].lines[2]); - selection.selectLine(hunks[1].lines[1], true); - selection.coalesce(); - selection.selectNextLine(true); - assertEqualSets(selection.getSelectedLines(), new Set([ + const selection1 = selection0.addOrSubtractLineSelection(hunks[1].lines[2]) + .selectLine(hunks[1].lines[1], true) + .coalesce() + .selectNextLine(true); + assertEqualSets(selection1.getSelectedLines(), new Set([ hunks[0].lines[2], hunks[0].lines[3], hunks[1].lines[0], @@ -453,17 +449,15 @@ describe('FilePatchSelection', function() { new HunkLine('line-8', 'added', -1, 10), ]), ]; - const selection = new FilePatchSelection(hunks); - - selection.selectLine(hunks[1].lines[3]); - selection.selectLine(hunks[1].lines[2], true); - selection.addOrSubtractLineSelection(hunks[0].lines[1]); - selection.selectLine(hunks[0].lines[0], true); - selection.coalesce(); - - selection.selectNext(true); - assertEqualSets(selection.getSelectedLines(), new Set([ + const selection0 = new FilePatchSelection(hunks) + .selectLine(hunks[1].lines[3]) + .selectLine(hunks[1].lines[2], true) + .addOrSubtractLineSelection(hunks[0].lines[1]) + .selectLine(hunks[0].lines[0], true) + .coalesce() + .selectNext(true); + assertEqualSets(selection0.getSelectedLines(), new Set([ hunks[0].lines[1], hunks[1].lines[2], hunks[1].lines[3], @@ -485,15 +479,15 @@ describe('FilePatchSelection', function() { new HunkLine('line-8', 'added', -1, 10), ]), ]; - const selection = new FilePatchSelection(hunks); - selection.selectLine(hunks[0].lines[0]); - selection.selectLine(hunks[1].lines[3], true); - - selection.addOrSubtractLineSelection(hunks[0].lines[3]); - selection.selectLine(hunks[1].lines[0], true); - selection.coalesce(); - selection.selectPrevious(true); - assertEqualSets(selection.getSelectedLines(), new Set([ + + const selection0 = new FilePatchSelection(hunks) + .selectLine(hunks[0].lines[0]) + .selectLine(hunks[1].lines[3], true) + .addOrSubtractLineSelection(hunks[0].lines[3]) + .selectLine(hunks[1].lines[0], true) + .coalesce() + .selectPrevious(true); + assertEqualSets(selection0.getSelectedLines(), new Set([ hunks[0].lines[0], hunks[0].lines[1], hunks[0].lines[2], @@ -508,12 +502,12 @@ describe('FilePatchSelection', function() { new HunkLine('line-1', 'added', -1, 1), ]), ]; - const selection = new FilePatchSelection(hunks); + const selection0 = new FilePatchSelection(hunks) + .selectLine(hunks[0].lines[0]) + .addOrSubtractLineSelection(hunks[0].lines[0]); + assertEqualSets(selection0.getSelectedLines(), new Set()); - selection.selectLine(hunks[0].lines[0]); - selection.addOrSubtractLineSelection(hunks[0].lines[0]); - assertEqualSets(selection.getSelectedLines(), new Set()); - selection.coalesce(); + selection0.coalesce(); }); }); }); @@ -528,8 +522,8 @@ describe('FilePatchSelection', function() { new HunkLine('line-2', 'added', -1, 6), ]), ]; - const selection = new FilePatchSelection(hunks); - assertEqualSets(selection.getSelectedHunks(), new Set([hunks[0]])); + const selection0 = new FilePatchSelection(hunks); + assertEqualSets(selection0.getSelectedHunks(), new Set([hunks[0]])); }); it('starts a new hunk selection with selectHunk and updates an existing selection when preserveTail is true', function() { @@ -547,16 +541,15 @@ describe('FilePatchSelection', function() { new HunkLine('line-4', 'added', -1, 18), ]), ]; - const selection = new FilePatchSelection(hunks); - - selection.selectHunk(hunks[1]); - assertEqualSets(selection.getSelectedHunks(), new Set([hunks[1]])); + const selection0 = new FilePatchSelection(hunks) + .selectHunk(hunks[1]); + assertEqualSets(selection0.getSelectedHunks(), new Set([hunks[1]])); - selection.selectHunk(hunks[3], true); - assertEqualSets(selection.getSelectedHunks(), new Set([hunks[1], hunks[2], hunks[3]])); + const selection1 = selection0.selectHunk(hunks[3], true); + assertEqualSets(selection1.getSelectedHunks(), new Set([hunks[1], hunks[2], hunks[3]])); - selection.selectHunk(hunks[0], true); - assertEqualSets(selection.getSelectedHunks(), new Set([hunks[0], hunks[1]])); + const selection2 = selection1.selectHunk(hunks[0], true); + assertEqualSets(selection2.getSelectedHunks(), new Set([hunks[0], hunks[1]])); }); it('adds a new hunk selection with addOrSubtractHunkSelection and always updates the head of the most recent hunk selection', function() { @@ -574,16 +567,15 @@ describe('FilePatchSelection', function() { new HunkLine('line-4', 'added', -1, 18), ]), ]; - const selection = new FilePatchSelection(hunks); + const selection0 = new FilePatchSelection(hunks) + .addOrSubtractHunkSelection(hunks[2]); + assertEqualSets(selection0.getSelectedHunks(), new Set([hunks[0], hunks[2]])); - selection.addOrSubtractHunkSelection(hunks[2]); - assertEqualSets(selection.getSelectedHunks(), new Set([hunks[0], hunks[2]])); + const selection1 = selection0.selectHunk(hunks[3], true); + assertEqualSets(selection1.getSelectedHunks(), new Set([hunks[0], hunks[2], hunks[3]])); - selection.selectHunk(hunks[3], true); - assertEqualSets(selection.getSelectedHunks(), new Set([hunks[0], hunks[2], hunks[3]])); - - selection.selectHunk(hunks[1], true); - assertEqualSets(selection.getSelectedHunks(), new Set([hunks[0], hunks[1], hunks[2]])); + const selection2 = selection1.selectHunk(hunks[1], true); + assertEqualSets(selection2.getSelectedHunks(), new Set([hunks[0], hunks[1], hunks[2]])); }); it('allows the next or previous hunk to be selected', function() { @@ -601,37 +593,37 @@ describe('FilePatchSelection', function() { new HunkLine('line-4', 'added', -1, 18), ]), ]; - const selection = new FilePatchSelection(hunks); - selection.selectNextHunk(); - assertEqualSets(selection.getSelectedHunks(), new Set([hunks[1]])); + const selection0 = new FilePatchSelection(hunks) + .selectNextHunk(); + assertEqualSets(selection0.getSelectedHunks(), new Set([hunks[1]])); - selection.selectNextHunk(); - assertEqualSets(selection.getSelectedHunks(), new Set([hunks[2]])); + const selection1 = selection0.selectNextHunk(); + assertEqualSets(selection1.getSelectedHunks(), new Set([hunks[2]])); - selection.selectNextHunk(); - selection.selectNextHunk(); - assertEqualSets(selection.getSelectedHunks(), new Set([hunks[3]])); + const selection2 = selection1.selectNextHunk() + .selectNextHunk(); + assertEqualSets(selection2.getSelectedHunks(), new Set([hunks[3]])); - selection.selectPreviousHunk(); - assertEqualSets(selection.getSelectedHunks(), new Set([hunks[2]])); + const selection3 = selection2.selectPreviousHunk(); + assertEqualSets(selection3.getSelectedHunks(), new Set([hunks[2]])); - selection.selectPreviousHunk(); - assertEqualSets(selection.getSelectedHunks(), new Set([hunks[1]])); + const selection4 = selection3.selectPreviousHunk(); + assertEqualSets(selection4.getSelectedHunks(), new Set([hunks[1]])); - selection.selectPreviousHunk(); - selection.selectPreviousHunk(); - assertEqualSets(selection.getSelectedHunks(), new Set([hunks[0]])); + const selection5 = selection4.selectPreviousHunk() + .selectPreviousHunk(); + assertEqualSets(selection5.getSelectedHunks(), new Set([hunks[0]])); - selection.selectNextHunk(); - selection.selectNextHunk(true); - assertEqualSets(selection.getSelectedHunks(), new Set([hunks[1], hunks[2]])); + const selection6 = selection5.selectNextHunk() + .selectNextHunk(true); + assertEqualSets(selection6.getSelectedHunks(), new Set([hunks[1], hunks[2]])); - selection.selectPreviousHunk(true); - assertEqualSets(selection.getSelectedHunks(), new Set([hunks[1]])); + const selection7 = selection6.selectPreviousHunk(true); + assertEqualSets(selection7.getSelectedHunks(), new Set([hunks[1]])); - selection.selectPreviousHunk(true); - assertEqualSets(selection.getSelectedHunks(), new Set([hunks[0], hunks[1]])); + const selection8 = selection7.selectPreviousHunk(true); + assertEqualSets(selection8.getSelectedHunks(), new Set([hunks[0], hunks[1]])); }); it('allows all hunks to be selected', function() { @@ -649,9 +641,10 @@ describe('FilePatchSelection', function() { new HunkLine('line-4', 'added', -1, 18), ]), ]; - const selection = new FilePatchSelection(hunks); - selection.selectAllHunks(); - assertEqualSets(selection.getSelectedHunks(), new Set([hunks[0], hunks[1], hunks[2], hunks[3]])); + + const selection0 = new FilePatchSelection(hunks) + .selectAllHunks(); + assertEqualSets(selection0.getSelectedHunks(), new Set([hunks[0], hunks[1], hunks[2], hunks[3]])); }); }); @@ -674,40 +667,40 @@ describe('FilePatchSelection', function() { new HunkLine('line-11', 'deleted', 9, -1), ]), ]; - const selection = new FilePatchSelection(hunks); - - assert.equal(selection.getMode(), 'hunk'); - assertEqualSets(selection.getSelectedHunks(), new Set([hunks[0]])); - assertEqualSets(selection.getSelectedLines(), getChangedLines(hunks[0])); - - selection.selectNext(); - assert.equal(selection.getMode(), 'hunk'); - assertEqualSets(selection.getSelectedHunks(), new Set([hunks[1]])); - assertEqualSets(selection.getSelectedLines(), getChangedLines(hunks[1])); - - selection.toggleMode(); - assert.equal(selection.getMode(), 'line'); - assertEqualSets(selection.getSelectedHunks(), new Set([hunks[1]])); - assertEqualSets(selection.getSelectedLines(), new Set([hunks[1].lines[1]])); - - selection.selectNext(); - assertEqualSets(selection.getSelectedHunks(), new Set([hunks[1]])); - assertEqualSets(selection.getSelectedLines(), new Set([hunks[1].lines[2]])); - - selection.toggleMode(); - assert.equal(selection.getMode(), 'hunk'); - assertEqualSets(selection.getSelectedHunks(), new Set([hunks[1]])); - assertEqualSets(selection.getSelectedLines(), getChangedLines(hunks[1])); - - selection.selectLine(hunks[0].lines[1]); - assert.equal(selection.getMode(), 'line'); - assertEqualSets(selection.getSelectedHunks(), new Set([hunks[0]])); - assertEqualSets(selection.getSelectedLines(), new Set([hunks[0].lines[1]])); - - selection.selectHunk(hunks[1]); - assert.equal(selection.getMode(), 'hunk'); - assertEqualSets(selection.getSelectedHunks(), new Set([hunks[1]])); - assertEqualSets(selection.getSelectedLines(), getChangedLines(hunks[1])); + const selection0 = new FilePatchSelection(hunks); + + assert.equal(selection0.getMode(), 'hunk'); + assertEqualSets(selection0.getSelectedHunks(), new Set([hunks[0]])); + assertEqualSets(selection0.getSelectedLines(), getChangedLines(hunks[0])); + + const selection1 = selection0.selectNext(); + assert.equal(selection1.getMode(), 'hunk'); + assertEqualSets(selection1.getSelectedHunks(), new Set([hunks[1]])); + assertEqualSets(selection1.getSelectedLines(), getChangedLines(hunks[1])); + + const selection2 = selection1.toggleMode(); + assert.equal(selection2.getMode(), 'line'); + assertEqualSets(selection2.getSelectedHunks(), new Set([hunks[1]])); + assertEqualSets(selection2.getSelectedLines(), new Set([hunks[1].lines[1]])); + + const selection3 = selection2.selectNext(); + assertEqualSets(selection3.getSelectedHunks(), new Set([hunks[1]])); + assertEqualSets(selection3.getSelectedLines(), new Set([hunks[1].lines[2]])); + + const selection4 = selection3.toggleMode(); + assert.equal(selection4.getMode(), 'hunk'); + assertEqualSets(selection4.getSelectedHunks(), new Set([hunks[1]])); + assertEqualSets(selection4.getSelectedLines(), getChangedLines(hunks[1])); + + const selection5 = selection4.selectLine(hunks[0].lines[1]); + assert.equal(selection5.getMode(), 'line'); + assertEqualSets(selection5.getSelectedHunks(), new Set([hunks[0]])); + assertEqualSets(selection5.getSelectedLines(), new Set([hunks[0].lines[1]])); + + const selection6 = selection5.selectHunk(hunks[1]); + assert.equal(selection6.getMode(), 'hunk'); + assertEqualSets(selection6.getSelectedHunks(), new Set([hunks[1]])); + assertEqualSets(selection6.getSelectedLines(), getChangedLines(hunks[1])); }); }); @@ -730,10 +723,9 @@ describe('FilePatchSelection', function() { new HunkLine('line-11', 'deleted', 9, -1), ]), ]; - const selection = new FilePatchSelection(oldHunks); - - selection.selectLine(oldHunks[1].lines[2]); - selection.selectLine(oldHunks[1].lines[4], true); + const selection0 = new FilePatchSelection(oldHunks) + .selectLine(oldHunks[1].lines[2]) + .selectLine(oldHunks[1].lines[4], true); const newHunks = [ new Hunk(1, 1, 1, 3, '', [ @@ -753,9 +745,9 @@ describe('FilePatchSelection', function() { new HunkLine('line-11', 'deleted', 11, -1), ]), ]; - selection.updateHunks(newHunks); + const selection1 = selection0.updateHunks(newHunks); - assertEqualSets(selection.getSelectedLines(), new Set([ + assertEqualSets(selection1.getSelectedLines(), new Set([ newHunks[2].lines[1], ])); }); @@ -769,8 +761,8 @@ describe('FilePatchSelection', function() { ]), ]; - const selection = new FilePatchSelection(oldHunks); - selection.selectLine(oldHunks[0].lines[1]); + const selection0 = new FilePatchSelection(oldHunks); + selection0.selectLine(oldHunks[0].lines[1]); const newHunks = [ new Hunk(1, 1, 1, 3, '', [ @@ -779,9 +771,9 @@ describe('FilePatchSelection', function() { new HunkLine('line-3', 'unchanged', 2, 3), ]), ]; - selection.updateHunks(newHunks); + const selection1 = selection0.updateHunks(newHunks); - assertEqualSets(selection.getSelectedLines(), new Set([ + assertEqualSets(selection1.getSelectedLines(), new Set([ newHunks[0].lines[0], ])); }); @@ -795,17 +787,17 @@ describe('FilePatchSelection', function() { new HunkLine('line-2', 'added', -1, 6), ]), ]; - const selection = new FilePatchSelection(oldHunks); - selection.selectHunk(oldHunks[1]); + const selection0 = new FilePatchSelection(oldHunks) + .selectHunk(oldHunks[1]); const newHunks = [ new Hunk(1, 1, 0, 1, '', [ new HunkLine('line-1', 'added', -1, 1), ]), ]; - selection.updateHunks(newHunks); + const selection1 = selection0.updateHunks(newHunks); - assertEqualSets(selection.getSelectedHunks(), new Set([newHunks[0]])); + assertEqualSets(selection1.getSelectedHunks(), new Set([newHunks[0]])); }); it('deselects if updating with an empty hunk array', function() { @@ -816,11 +808,10 @@ describe('FilePatchSelection', function() { ]), ]; - const selection = new FilePatchSelection(oldHunks); - selection.selectLine(oldHunks[0], oldHunks[0].lines[1]); - - selection.updateHunks([]); - assertEqualSets(selection.getSelectedLines(), new Set()); + const selection0 = new FilePatchSelection(oldHunks) + .selectLine(oldHunks[0], oldHunks[0].lines[1]) + .updateHunks([]); + assertEqualSets(selection0.getSelectedLines(), new Set()); }); it('resolves the getNextUpdatePromise the next time hunks are changed', async function() { @@ -834,13 +825,13 @@ describe('FilePatchSelection', function() { ]); const existingHunks = [hunk0, hunk1]; - const selection = new FilePatchSelection(existingHunks); + const selection0 = new FilePatchSelection(existingHunks); let wasResolved = false; - selection.getNextUpdatePromise().then(() => { wasResolved = true; }); + selection0.getNextUpdatePromise().then(() => { wasResolved = true; }); const unchangedHunks = [hunk0, hunk1]; - selection.updateHunks(unchangedHunks); + const selection1 = selection0.updateHunks(unchangedHunks); assert.isFalse(wasResolved); @@ -849,9 +840,9 @@ describe('FilePatchSelection', function() { new HunkLine('line-77', 'added', -1, 2), ]); const changedHunks = [hunk0, hunk2]; - selection.updateHunks(changedHunks); + selection1.updateHunks(changedHunks); - await until(() => wasResolved); + await assert.async.isTrue(wasResolved); }); }); @@ -875,40 +866,52 @@ describe('FilePatchSelection', function() { new HunkLine('line-11', 'deleted', 11, -1), ]), ]; - const selection = new FilePatchSelection(hunks); + const selection0 = new FilePatchSelection(hunks); // in hunk mode, selects the entire next/previous hunk - assert.equal(selection.getMode(), 'hunk'); - assertEqualSets(selection.getSelectedHunks(), new Set([hunks[0]])); - selection.jumpToNextHunk(); - assertEqualSets(selection.getSelectedHunks(), new Set([hunks[1]])); - selection.jumpToNextHunk(); - assertEqualSets(selection.getSelectedHunks(), new Set([hunks[2]])); - selection.jumpToNextHunk(); - assertEqualSets(selection.getSelectedHunks(), new Set([hunks[2]])); - selection.jumpToPreviousHunk(); - assertEqualSets(selection.getSelectedHunks(), new Set([hunks[1]])); - selection.jumpToPreviousHunk(); - assertEqualSets(selection.getSelectedHunks(), new Set([hunks[0]])); - selection.jumpToPreviousHunk(); - assertEqualSets(selection.getSelectedHunks(), new Set([hunks[0]])); + assert.equal(selection0.getMode(), 'hunk'); + assertEqualSets(selection0.getSelectedHunks(), new Set([hunks[0]])); + + const selection1 = selection0.jumpToNextHunk(); + assertEqualSets(selection1.getSelectedHunks(), new Set([hunks[1]])); + + const selection2 = selection1.jumpToNextHunk(); + assertEqualSets(selection2.getSelectedHunks(), new Set([hunks[2]])); + + const selection3 = selection2.jumpToNextHunk(); + assertEqualSets(selection3.getSelectedHunks(), new Set([hunks[2]])); + + const selection4 = selection3.jumpToPreviousHunk(); + assertEqualSets(selection4.getSelectedHunks(), new Set([hunks[1]])); + + const selection5 = selection4.jumpToPreviousHunk(); + assertEqualSets(selection5.getSelectedHunks(), new Set([hunks[0]])); + + const selection6 = selection5.jumpToPreviousHunk(); + assertEqualSets(selection6.getSelectedHunks(), new Set([hunks[0]])); // in line selection mode, the first changed line of the next/previous hunk is selected - selection.toggleMode(); - assert.equal(selection.getMode(), 'line'); - assertEqualSets(selection.getSelectedLines(), new Set([getFirstChangedLine(hunks[0])])); - selection.jumpToNextHunk(); - assertEqualSets(selection.getSelectedLines(), new Set([getFirstChangedLine(hunks[1])])); - selection.jumpToNextHunk(); - assertEqualSets(selection.getSelectedLines(), new Set([getFirstChangedLine(hunks[2])])); - selection.jumpToNextHunk(); - assertEqualSets(selection.getSelectedLines(), new Set([getFirstChangedLine(hunks[2])])); - selection.jumpToPreviousHunk(); - assertEqualSets(selection.getSelectedLines(), new Set([getFirstChangedLine(hunks[1])])); - selection.jumpToPreviousHunk(); - assertEqualSets(selection.getSelectedLines(), new Set([getFirstChangedLine(hunks[0])])); - selection.jumpToPreviousHunk(); - assertEqualSets(selection.getSelectedLines(), new Set([getFirstChangedLine(hunks[0])])); + const selection7 = selection6.toggleMode(); + assert.equal(selection7.getMode(), 'line'); + assertEqualSets(selection7.getSelectedLines(), new Set([getFirstChangedLine(hunks[0])])); + + const selection8 = selection7.jumpToNextHunk(); + assertEqualSets(selection8.getSelectedLines(), new Set([getFirstChangedLine(hunks[1])])); + + const selection9 = selection8.jumpToNextHunk(); + assertEqualSets(selection9.getSelectedLines(), new Set([getFirstChangedLine(hunks[2])])); + + const selection10 = selection9.jumpToNextHunk(); + assertEqualSets(selection10.getSelectedLines(), new Set([getFirstChangedLine(hunks[2])])); + + const selection11 = selection10.jumpToPreviousHunk(); + assertEqualSets(selection11.getSelectedLines(), new Set([getFirstChangedLine(hunks[1])])); + + const selection12 = selection11.jumpToPreviousHunk(); + assertEqualSets(selection12.getSelectedLines(), new Set([getFirstChangedLine(hunks[0])])); + + const selection13 = selection12.jumpToPreviousHunk(); + assertEqualSets(selection13.getSelectedLines(), new Set([getFirstChangedLine(hunks[0])])); }); }); }); diff --git a/test/views/file-patch-view.test.js b/test/views/file-patch-view.test.js index 891e5b2c07..cfb718fd5b 100644 --- a/test/views/file-patch-view.test.js +++ b/test/views/file-patch-view.test.js @@ -1,3 +1,6 @@ +import React from 'react'; +import {shallow, mount} from 'enzyme'; + import FilePatchView from '../../lib/views/file-patch-view'; import Hunk from '../../lib/models/hunk'; import HunkLine from '../../lib/models/hunk-line'; @@ -5,11 +8,39 @@ import HunkLine from '../../lib/models/hunk-line'; import {assertEqualSets} from '../helpers'; describe('FilePatchView', function() { - let atomEnv, commandRegistry; + let atomEnv, commandRegistry, component; + let attemptLineStageOperation, attemptHunkStageOperation, discardLines, undoLastDiscard, openCurrentFile; + let didSurfaceFile, didDiveIntoCorrespondingFilePatch; beforeEach(function() { atomEnv = global.buildAtomEnvironment(); commandRegistry = atomEnv.commands; + + attemptLineStageOperation = sinon.spy(); + attemptHunkStageOperation = sinon.spy(); + discardLines = sinon.spy(); + undoLastDiscard = sinon.spy(); + openCurrentFile = sinon.spy(); + didSurfaceFile = sinon.spy(); + didDiveIntoCorrespondingFilePatch = sinon.spy(); + + component = ( + <FilePatchView + commandRegistry={commandRegistry} + filePath="filename.js" + hunks={[]} + stagingStatus="unstaged" + isPartiallyStaged={false} + hasUndoHistory={false} + attemptLineStageOperation={attemptLineStageOperation} + attemptHunkStageOperation={attemptHunkStageOperation} + discardLines={discardLines} + undoLastDiscard={undoLastDiscard} + openCurrentFile={openCurrentFile} + didSurfaceFile={didSurfaceFile} + didDiveIntoCorrespondingFilePatch={didDiveIntoCorrespondingFilePatch} + /> + ); }); afterEach(function() { @@ -17,7 +48,7 @@ describe('FilePatchView', function() { }); describe('mouse selection', () => { - it('allows lines and hunks to be selected via mouse drag', async function() { + it('allows lines and hunks to be selected via mouse drag', function() { const hunks = [ new Hunk(1, 1, 2, 4, '', [ new HunkLine('line-1', 'unchanged', 1, 1), @@ -32,158 +63,175 @@ describe('FilePatchView', function() { new HunkLine('line-8', 'added', -1, 10), ]), ]; - const hunkViews = new Map(); - function registerHunkView(hunk, view) { hunkViews.set(hunk, view); } - const filePatchView = new FilePatchView({commandRegistry, hunks, registerHunkView}); - const hunkView0 = hunkViews.get(hunks[0]); - const hunkView1 = hunkViews.get(hunks[1]); + const wrapper = shallow(React.cloneElement(component, {hunks})); + const getHunkView = index => wrapper.find({hunk: hunks[index]}); // drag a selection - await hunkView0.props.mousedownOnLine({button: 0, detail: 1}, hunks[0], hunks[0].lines[2]); - await hunkViews.get(hunks[1]).props.mousemoveOnLine({}, hunks[1], hunks[1].lines[1]); - await filePatchView.mouseup(); - assert(hunkView0.props.isSelected); - assert(hunkView0.props.selectedLines.has(hunks[0].lines[2])); - assert(hunkView1.props.isSelected); - assert(hunkView1.props.selectedLines.has(hunks[1].lines[1])); + getHunkView(0).prop('mousedownOnLine')({button: 0, detail: 1}, hunks[0], hunks[0].lines[2]); + getHunkView(1).prop('mousemoveOnLine')({}, hunks[1], hunks[1].lines[1]); + wrapper.instance().mouseup(); + + assert.isTrue(getHunkView(0).prop('isSelected')); + assert.isTrue(getHunkView(0).prop('selectedLines').has(hunks[0].lines[2])); + assert.isTrue(getHunkView(1).prop('isSelected')); + assert.isTrue(getHunkView(1).prop('selectedLines').has(hunks[1].lines[1])); // start a new selection, drag it across an existing selection - await hunkView1.props.mousedownOnLine({button: 0, detail: 1, metaKey: true}, hunks[1], hunks[1].lines[3]); - await hunkView0.props.mousemoveOnLine({}, hunks[0], hunks[0].lines[0]); - assert(hunkView0.props.isSelected); - assert(hunkView0.props.selectedLines.has(hunks[0].lines[1])); - assert(hunkView0.props.selectedLines.has(hunks[0].lines[2])); - assert(hunkView1.props.isSelected); - assert(hunkView1.props.selectedLines.has(hunks[1].lines[1])); - assert(hunkView1.props.selectedLines.has(hunks[1].lines[2])); - assert(hunkView1.props.selectedLines.has(hunks[1].lines[3])); + getHunkView(1).prop('mousedownOnLine')({button: 0, detail: 1, metaKey: true}, hunks[1], hunks[1].lines[3]); + getHunkView(0).prop('mousemoveOnLine')({}, hunks[0], hunks[0].lines[0]); + + assert.isTrue(getHunkView(0).prop('isSelected')); + assert.isTrue(getHunkView(0).prop('selectedLines').has(hunks[0].lines[1])); + assert.isTrue(getHunkView(0).prop('selectedLines').has(hunks[0].lines[2])); + assert.isTrue(getHunkView(1).prop('isSelected')); + assert.isTrue(getHunkView(1).prop('selectedLines').has(hunks[1].lines[1])); + assert.isTrue(getHunkView(1).prop('selectedLines').has(hunks[1].lines[2])); + assert.isTrue(getHunkView(1).prop('selectedLines').has(hunks[1].lines[3])); // drag back down without releasing mouse; the other selection remains intact - await hunkView1.props.mousemoveOnLine({}, hunks[1], hunks[1].lines[3]); - assert(hunkView0.props.isSelected); - assert(!hunkView0.props.selectedLines.has(hunks[0].lines[1])); - assert(hunkView0.props.selectedLines.has(hunks[0].lines[2])); - assert(hunkView1.props.isSelected); - assert(hunkView1.props.selectedLines.has(hunks[1].lines[1])); - assert(!hunkView1.props.selectedLines.has(hunks[1].lines[2])); - assert(hunkView1.props.selectedLines.has(hunks[1].lines[3])); + getHunkView(1).prop('mousemoveOnLine')({}, hunks[1], hunks[1].lines[3]); + + assert.isTrue(getHunkView(0).prop('isSelected')); + assert.isFalse(getHunkView(0).prop('selectedLines').has(hunks[0].lines[1])); + assert.isTrue(getHunkView(0).prop('selectedLines').has(hunks[0].lines[2])); + assert.isTrue(getHunkView(1).prop('isSelected')); + assert.isTrue(getHunkView(1).prop('selectedLines').has(hunks[1].lines[1])); + assert.isFalse(getHunkView(1).prop('selectedLines').has(hunks[1].lines[2])); + assert.isTrue(getHunkView(1).prop('selectedLines').has(hunks[1].lines[3])); // drag back up so selections are adjacent, then release the mouse. selections should merge. - await hunkView1.props.mousemoveOnLine({}, hunks[1], hunks[1].lines[2]); - await filePatchView.mouseup(); - assert(hunkView0.props.isSelected); - assert(hunkView0.props.selectedLines.has(hunks[0].lines[2])); - assert(hunkView1.props.isSelected); - assert(hunkView1.props.selectedLines.has(hunks[1].lines[1])); - assert(hunkView1.props.selectedLines.has(hunks[1].lines[2])); - assert(hunkView1.props.selectedLines.has(hunks[1].lines[3])); + getHunkView(1).prop('mousemoveOnLine')({}, hunks[1], hunks[1].lines[2]); + wrapper.instance().mouseup(); + + assert.isTrue(getHunkView(0).prop('isSelected')); + assert.isTrue(getHunkView(0).prop('selectedLines').has(hunks[0].lines[2])); + assert.isTrue(getHunkView(1).prop('isSelected')); + assert.isTrue(getHunkView(1).prop('selectedLines').has(hunks[1].lines[1])); + assert.isTrue(getHunkView(1).prop('selectedLines').has(hunks[1].lines[2])); + assert.isTrue(getHunkView(1).prop('selectedLines').has(hunks[1].lines[3])); // we detect merged selections based on the head here - await filePatchView.selectToNext(); - assert(!hunkView0.props.isSelected); - assert(!hunkView0.props.selectedLines.has(hunks[0].lines[2])); + wrapper.instance().selectToNext(); + + assert.isFalse(getHunkView(0).prop('isSelected')); + assert.isFalse(getHunkView(0).prop('selectedLines').has(hunks[0].lines[2])); // double-clicking clears the existing selection and starts hunk-wise selection - await hunkView0.props.mousedownOnLine({button: 0, detail: 2}, hunks[0], hunks[0].lines[2]); - assert(hunkView0.props.isSelected); - assert(hunkView0.props.selectedLines.has(hunks[0].lines[1])); - assert(hunkView0.props.selectedLines.has(hunks[0].lines[2])); - assert(!hunkView1.props.isSelected); - - await hunkView1.props.mousemoveOnLine({}, hunks[1], hunks[1].lines[1]); - assert(hunkView0.props.isSelected); - assert(hunkView0.props.selectedLines.has(hunks[0].lines[1])); - assert(hunkView0.props.selectedLines.has(hunks[0].lines[2])); - assert(hunkView1.props.isSelected); - assert(hunkView1.props.selectedLines.has(hunks[1].lines[1])); - assert(hunkView1.props.selectedLines.has(hunks[1].lines[2])); - assert(hunkView1.props.selectedLines.has(hunks[1].lines[3])); + getHunkView(0).prop('mousedownOnLine')({button: 0, detail: 2}, hunks[0], hunks[0].lines[2]); + + assert.isTrue(getHunkView(0).prop('isSelected')); + assert.isTrue(getHunkView(0).prop('selectedLines').has(hunks[0].lines[1])); + assert.isTrue(getHunkView(0).prop('selectedLines').has(hunks[0].lines[2])); + assert.isFalse(getHunkView(1).prop('isSelected')); + + getHunkView(1).prop('mousemoveOnLine')({}, hunks[1], hunks[1].lines[1]); + + assert.isTrue(getHunkView(0).prop('isSelected')); + assert.isTrue(getHunkView(0).prop('selectedLines').has(hunks[0].lines[1])); + assert.isTrue(getHunkView(0).prop('selectedLines').has(hunks[0].lines[2])); + assert.isTrue(getHunkView(1).prop('isSelected')); + assert.isTrue(getHunkView(1).prop('selectedLines').has(hunks[1].lines[1])); + assert.isTrue(getHunkView(1).prop('selectedLines').has(hunks[1].lines[2])); + assert.isTrue(getHunkView(1).prop('selectedLines').has(hunks[1].lines[3])); // clicking the header clears the existing selection and starts hunk-wise selection - await hunkView0.props.mousedownOnHeader({button: 0, detail: 1}, hunks[0], hunks[0].lines[2]); - assert(hunkView0.props.isSelected); - assert(hunkView0.props.selectedLines.has(hunks[0].lines[1])); - assert(hunkView0.props.selectedLines.has(hunks[0].lines[2])); - assert(!hunkView1.props.isSelected); - - await hunkView1.props.mousemoveOnLine({}, hunks[1], hunks[1].lines[1]); - assert(hunkView0.props.isSelected); - assert(hunkView0.props.selectedLines.has(hunks[0].lines[1])); - assert(hunkView0.props.selectedLines.has(hunks[0].lines[2])); - assert(hunkView1.props.isSelected); - assert(hunkView1.props.selectedLines.has(hunks[1].lines[1])); - assert(hunkView1.props.selectedLines.has(hunks[1].lines[2])); - assert(hunkView1.props.selectedLines.has(hunks[1].lines[3])); + getHunkView(0).prop('mousedownOnHeader')({button: 0, detail: 1}, hunks[0], hunks[0].lines[2]); + + assert.isTrue(getHunkView(0).prop('isSelected')); + assert.isTrue(getHunkView(0).prop('selectedLines').has(hunks[0].lines[1])); + assert.isTrue(getHunkView(0).prop('selectedLines').has(hunks[0].lines[2])); + assert.isFalse(getHunkView(1).prop('isSelected')); + + getHunkView(1).prop('mousemoveOnLine')({}, hunks[1], hunks[1].lines[1]); + + assert.isTrue(getHunkView(0).prop('isSelected')); + assert.isTrue(getHunkView(0).prop('selectedLines').has(hunks[0].lines[1])); + assert.isTrue(getHunkView(0).prop('selectedLines').has(hunks[0].lines[2])); + assert.isTrue(getHunkView(1).prop('isSelected')); + assert.isTrue(getHunkView(1).prop('selectedLines').has(hunks[1].lines[1])); + assert.isTrue(getHunkView(1).prop('selectedLines').has(hunks[1].lines[2])); + assert.isTrue(getHunkView(1).prop('selectedLines').has(hunks[1].lines[3])); }); - it('allows lines and hunks to be selected via cmd-clicking', async () => { + it('allows lines and hunks to be selected via cmd-clicking', function() { const hunk0 = new Hunk(1, 1, 2, 4, '', [ - new HunkLine('line-1', 'added', -1, 1), - new HunkLine('line-2', 'added', -1, 2), - new HunkLine('line-3', 'added', -1, 3), + new HunkLine('line-0', 'added', -1, 1), + new HunkLine('line-1', 'added', -1, 2), + new HunkLine('line-2', 'added', -1, 3), ]); const hunk1 = new Hunk(5, 7, 1, 4, '', [ - new HunkLine('line-5', 'added', -1, 7), - new HunkLine('line-6', 'added', -1, 8), - new HunkLine('line-7', 'added', -1, 9), - new HunkLine('line-8', 'added', -1, 10), + new HunkLine('line-3', 'added', -1, 7), + new HunkLine('line-4', 'added', -1, 8), + new HunkLine('line-5', 'added', -1, 9), + new HunkLine('line-6', 'added', -1, 10), ]); - const hunkViews = new Map(); - function registerHunkView(hunk, view) { hunkViews.set(hunk, view); } - const filePatchView = new FilePatchView({commandRegistry, hunks: [hunk0, hunk1], registerHunkView}); - const hunkView0 = hunkViews.get(hunk0); - const hunkView1 = hunkViews.get(hunk1); + const wrapper = shallow(React.cloneElement(component, { + hunks: [hunk0, hunk1], + })); + const getHunkView = index => wrapper.find({hunk: [hunk0, hunk1][index]}); // in line selection mode, cmd-click line - await hunkView0.props.mousedownOnLine({button: 0, detail: 1}, hunk0, hunk0.lines[2]); - await filePatchView.mouseup(); - assert.equal(filePatchView.getPatchSelectionMode(), 'line'); - assertEqualSets(filePatchView.getSelectedHunks(), new Set([hunk0])); - assertEqualSets(filePatchView.getSelectedLines(), new Set([hunk0.lines[2]])); - await hunkView1.props.mousedownOnLine({button: 0, detail: 1, metaKey: true}, hunk1, hunk1.lines[2]); - await filePatchView.mouseup(); - assertEqualSets(filePatchView.getSelectedHunks(), new Set([hunk0, hunk1])); - assertEqualSets(filePatchView.getSelectedLines(), new Set([hunk0.lines[2], hunk1.lines[2]])); - await hunkView1.props.mousedownOnLine({button: 0, detail: 1, metaKey: true}, hunk1, hunk1.lines[2]); - await filePatchView.mouseup(); - assertEqualSets(filePatchView.getSelectedHunks(), new Set([hunk0])); - assertEqualSets(filePatchView.getSelectedLines(), new Set([hunk0.lines[2]])); + getHunkView(0).prop('mousedownOnLine')({button: 0, detail: 1}, hunk0, hunk0.lines[2]); + wrapper.instance().mouseup(); + + assert.equal(wrapper.instance().getPatchSelectionMode(), 'line'); + assertEqualSets(wrapper.instance().getSelectedHunks(), new Set([hunk0])); + assertEqualSets(wrapper.instance().getSelectedLines(), new Set([hunk0.lines[2]])); + + getHunkView(1).prop('mousedownOnLine')({button: 0, detail: 1, metaKey: true}, hunk1, hunk1.lines[2]); + wrapper.instance().mouseup(); + + assertEqualSets(wrapper.instance().getSelectedHunks(), new Set([hunk0, hunk1])); + assertEqualSets(wrapper.instance().getSelectedLines(), new Set([hunk0.lines[2], hunk1.lines[2]])); + + getHunkView(1).prop('mousedownOnLine')({button: 0, detail: 1, metaKey: true}, hunk1, hunk1.lines[2]); + wrapper.instance().mouseup(); + + assertEqualSets(wrapper.instance().getSelectedHunks(), new Set([hunk0])); + assertEqualSets(wrapper.instance().getSelectedLines(), new Set([hunk0.lines[2]])); // in line selection mode, cmd-click hunk header for separate hunk - await hunkView0.props.mousedownOnLine({button: 0, detail: 1}, hunk0, hunk0.lines[2]); - await filePatchView.mouseup(); - assert.equal(filePatchView.getPatchSelectionMode(), 'line'); - assertEqualSets(filePatchView.getSelectedHunks(), new Set([hunk0])); - assertEqualSets(filePatchView.getSelectedLines(), new Set([hunk0.lines[2]])); - await hunkView1.props.mousedownOnHeader({button: 0, metaKey: true}); - await filePatchView.mouseup(); - assertEqualSets(filePatchView.getSelectedHunks(), new Set([hunk0, hunk1])); - assertEqualSets(filePatchView.getSelectedLines(), new Set([hunk0.lines[2], ...hunk1.lines])); - await hunkView1.props.mousedownOnHeader({button: 0, metaKey: true}); - await filePatchView.mouseup(); - assertEqualSets(filePatchView.getSelectedHunks(), new Set([hunk0])); - assertEqualSets(filePatchView.getSelectedLines(), new Set([hunk0.lines[2]])); + getHunkView(0).prop('mousedownOnLine')({button: 0, detail: 1}, hunk0, hunk0.lines[2]); + wrapper.instance().mouseup(); + + assert.equal(wrapper.instance().getPatchSelectionMode(), 'line'); + assertEqualSets(wrapper.instance().getSelectedHunks(), new Set([hunk0])); + assertEqualSets(wrapper.instance().getSelectedLines(), new Set([hunk0.lines[2]])); + + getHunkView(1).prop('mousedownOnHeader')({button: 0, metaKey: true}); + wrapper.instance().mouseup(); + + assertEqualSets(wrapper.instance().getSelectedHunks(), new Set([hunk0, hunk1])); + assertEqualSets(wrapper.instance().getSelectedLines(), new Set([hunk0.lines[2], ...hunk1.lines])); + + getHunkView(1).prop('mousedownOnHeader')({button: 0, metaKey: true}); + wrapper.instance().mouseup(); + + assertEqualSets(wrapper.instance().getSelectedHunks(), new Set([hunk0])); + assertEqualSets(wrapper.instance().getSelectedLines(), new Set([hunk0.lines[2]])); // in hunk selection mode, cmd-click line for separate hunk - await hunkView0.props.mousedownOnLine({button: 0, detail: 1}, hunk0, hunk0.lines[2]); - await filePatchView.mouseup(); - filePatchView.togglePatchSelectionMode(); - assert.equal(filePatchView.getPatchSelectionMode(), 'hunk'); - assertEqualSets(filePatchView.getSelectedHunks(), new Set([hunk0])); - assertEqualSets(filePatchView.getSelectedLines(), new Set(hunk0.lines)); + getHunkView(0).prop('mousedownOnLine')({button: 0, detail: 1}, hunk0, hunk0.lines[2]); + wrapper.instance().mouseup(); + wrapper.instance().togglePatchSelectionMode(); + + assert.equal(wrapper.instance().getPatchSelectionMode(), 'hunk'); + assertEqualSets(wrapper.instance().getSelectedHunks(), new Set([hunk0])); + assertEqualSets(wrapper.instance().getSelectedLines(), new Set(hunk0.lines)); // in hunk selection mode, cmd-click hunk header for separate hunk - await hunkView0.props.mousedownOnLine({button: 0, detail: 1}, hunk0, hunk0.lines[2]); - await filePatchView.mouseup(); - filePatchView.togglePatchSelectionMode(); - assert.equal(filePatchView.getPatchSelectionMode(), 'hunk'); - assertEqualSets(filePatchView.getSelectedHunks(), new Set([hunk0])); - assertEqualSets(filePatchView.getSelectedLines(), new Set(hunk0.lines)); + getHunkView(0).prop('mousedownOnLine')({button: 0, detail: 1}, hunk0, hunk0.lines[2]); + wrapper.instance().mouseup(); + wrapper.instance().togglePatchSelectionMode(); + + assert.equal(wrapper.instance().getPatchSelectionMode(), 'hunk'); + assertEqualSets(wrapper.instance().getSelectedHunks(), new Set([hunk0])); + assertEqualSets(wrapper.instance().getSelectedLines(), new Set(hunk0.lines)); }); - it('allows lines and hunks to be selected via shift-clicking', async () => { + it('allows lines and hunks to be selected via shift-clicking', () => { const hunk0 = new Hunk(1, 1, 2, 4, '', [ new HunkLine('line-1', 'unchanged', 1, 1, 0), new HunkLine('line-2', 'added', -1, 2, 1), @@ -201,116 +249,135 @@ describe('FilePatchView', function() { new HunkLine('line-17', 'added', -1, 19, 9), new HunkLine('line-18', 'added', -1, 20, 10), ]); - const hunkViews = new Map(); - function registerHunkView(hunk, view) { hunkViews.set(hunk, view); } + const hunks = [hunk0, hunk1, hunk2]; - const filePatchView = new FilePatchView({commandRegistry, hunks: [hunk0, hunk1, hunk2], registerHunkView}); - const hunkView0 = hunkViews.get(hunk0); - const hunkView1 = hunkViews.get(hunk1); - const hunkView2 = hunkViews.get(hunk2); + const wrapper = shallow(React.cloneElement(component, {hunks})); + const getHunkView = index => wrapper.find({hunk: hunks[index]}); // in line selection mode, shift-click line in separate hunk that comes after selected line - await hunkView0.props.mousedownOnLine({button: 0, detail: 1}, hunk0, hunk0.lines[2]); - await filePatchView.mouseup(); - assertEqualSets(filePatchView.getSelectedHunks(), new Set([hunk0])); - assertEqualSets(filePatchView.getSelectedLines(), new Set([hunk0.lines[2]])); - await hunkView2.props.mousedownOnLine({button: 0, detail: 1, shiftKey: true}, hunk2, hunk2.lines[2]); - await filePatchView.mouseup(); - assertEqualSets(filePatchView.getSelectedHunks(), new Set([hunk0, hunk1, hunk2])); - assertEqualSets(filePatchView.getSelectedLines(), new Set([...hunk0.lines.slice(2), ...hunk1.lines, ...hunk2.lines.slice(0, 3)])); - await hunkView1.props.mousedownOnLine({button: 0, detail: 1, shiftKey: true}, hunk1, hunk1.lines[2]); - await filePatchView.mouseup(); - assertEqualSets(filePatchView.getSelectedHunks(), new Set([hunk0, hunk1])); - assertEqualSets(filePatchView.getSelectedLines(), new Set([...hunk0.lines.slice(2), ...hunk1.lines.slice(0, 3)])); + getHunkView(0).prop('mousedownOnLine')({button: 0, detail: 1}, hunk0, hunk0.lines[2]); + wrapper.instance().mouseup(); + + assertEqualSets(wrapper.instance().getSelectedHunks(), new Set([hunk0])); + assertEqualSets(wrapper.instance().getSelectedLines(), new Set([hunk0.lines[2]])); + + getHunkView(2).prop('mousedownOnLine')({button: 0, detail: 1, shiftKey: true}, hunk2, hunk2.lines[2]); + wrapper.instance().mouseup(); + + assertEqualSets(wrapper.instance().getSelectedHunks(), new Set([hunk0, hunk1, hunk2])); + assertEqualSets(wrapper.instance().getSelectedLines(), new Set([...hunk0.lines.slice(2), ...hunk1.lines, ...hunk2.lines.slice(0, 3)])); + + getHunkView(1).prop('mousedownOnLine')({button: 0, detail: 1, shiftKey: true}, hunk1, hunk1.lines[2]); + wrapper.instance().mouseup(); + + assertEqualSets(wrapper.instance().getSelectedHunks(), new Set([hunk0, hunk1])); + assertEqualSets(wrapper.instance().getSelectedLines(), new Set([...hunk0.lines.slice(2), ...hunk1.lines.slice(0, 3)])); // in line selection mode, shift-click hunk header for separate hunk that comes after selected line - await hunkView0.props.mousedownOnLine({button: 0, detail: 1}, hunk0, hunk0.lines[2]); - await filePatchView.mouseup(); - assertEqualSets(filePatchView.getSelectedHunks(), new Set([hunk0])); - assertEqualSets(filePatchView.getSelectedLines(), new Set([hunk0.lines[2]])); - await hunkView2.props.mousedownOnHeader({button: 0, shiftKey: true}, hunk2); - await filePatchView.mouseup(); - assertEqualSets(filePatchView.getSelectedHunks(), new Set([hunk0, hunk1, hunk2])); - assertEqualSets(filePatchView.getSelectedLines(), new Set([...hunk0.lines.slice(2), ...hunk1.lines, ...hunk2.lines])); - await hunkView1.props.mousedownOnHeader({button: 0, shiftKey: true}, hunk1); - await filePatchView.mouseup(); - assertEqualSets(filePatchView.getSelectedHunks(), new Set([hunk0, hunk1])); - assertEqualSets(filePatchView.getSelectedLines(), new Set([...hunk0.lines.slice(2), ...hunk1.lines])); + getHunkView(0).prop('mousedownOnLine')({button: 0, detail: 1}, hunk0, hunk0.lines[2]); + wrapper.instance().mouseup(); + + assertEqualSets(wrapper.instance().getSelectedHunks(), new Set([hunk0])); + assertEqualSets(wrapper.instance().getSelectedLines(), new Set([hunk0.lines[2]])); + + getHunkView(2).prop('mousedownOnHeader')({button: 0, shiftKey: true}, hunk2); + wrapper.instance().mouseup(); + + assertEqualSets(wrapper.instance().getSelectedHunks(), new Set([hunk0, hunk1, hunk2])); + assertEqualSets(wrapper.instance().getSelectedLines(), new Set([...hunk0.lines.slice(2), ...hunk1.lines, ...hunk2.lines])); + + getHunkView(1).prop('mousedownOnHeader')({button: 0, shiftKey: true}, hunk1); + wrapper.instance().mouseup(); + + assertEqualSets(wrapper.instance().getSelectedHunks(), new Set([hunk0, hunk1])); + assertEqualSets(wrapper.instance().getSelectedLines(), new Set([...hunk0.lines.slice(2), ...hunk1.lines])); // in line selection mode, shift-click hunk header for separate hunk that comes before selected line - await hunkView2.props.mousedownOnLine({button: 0, detail: 1}, hunk2, hunk2.lines[2]); - await filePatchView.mouseup(); - assertEqualSets(filePatchView.getSelectedHunks(), new Set([hunk2])); - assertEqualSets(filePatchView.getSelectedLines(), new Set([hunk2.lines[2]])); - await hunkView0.props.mousedownOnHeader({button: 0, shiftKey: true}, hunk0); - await filePatchView.mouseup(); - assertEqualSets(filePatchView.getSelectedHunks(), new Set([hunk0, hunk1, hunk2])); - assertEqualSets(filePatchView.getSelectedLines(), new Set([...hunk0.lines.slice(1), ...hunk1.lines, ...hunk2.lines.slice(0, 3)])); - await hunkView1.props.mousedownOnHeader({button: 0, shiftKey: true}, hunk1); - await filePatchView.mouseup(); - assertEqualSets(filePatchView.getSelectedHunks(), new Set([hunk1, hunk2])); - assertEqualSets(filePatchView.getSelectedLines(), new Set([...hunk1.lines, ...hunk2.lines.slice(0, 3)])); + getHunkView(2).prop('mousedownOnLine')({button: 0, detail: 1}, hunk2, hunk2.lines[2]); + wrapper.instance().mouseup(); + + assertEqualSets(wrapper.instance().getSelectedHunks(), new Set([hunk2])); + assertEqualSets(wrapper.instance().getSelectedLines(), new Set([hunk2.lines[2]])); + + getHunkView(0).prop('mousedownOnHeader')({button: 0, shiftKey: true}, hunk0); + wrapper.instance().mouseup(); + + assertEqualSets(wrapper.instance().getSelectedHunks(), new Set([hunk0, hunk1, hunk2])); + assertEqualSets(wrapper.instance().getSelectedLines(), new Set([...hunk0.lines.slice(1), ...hunk1.lines, ...hunk2.lines.slice(0, 3)])); + + getHunkView(1).prop('mousedownOnHeader')({button: 0, shiftKey: true}, hunk1); + wrapper.instance().mouseup(); + + assertEqualSets(wrapper.instance().getSelectedHunks(), new Set([hunk1, hunk2])); + assertEqualSets(wrapper.instance().getSelectedLines(), new Set([...hunk1.lines, ...hunk2.lines.slice(0, 3)])); // in hunk selection mode, shift-click hunk header for separate hunk that comes after selected line - await hunkView0.props.mousedownOnHeader({button: 0}, hunk0); - await filePatchView.mouseup(); - assertEqualSets(filePatchView.getSelectedHunks(), new Set([hunk0])); - assertEqualSets(filePatchView.getSelectedLines(), new Set(hunk0.lines.slice(1))); - await hunkView2.props.mousedownOnHeader({button: 0, shiftKey: true}, hunk2); - await filePatchView.mouseup(); - assertEqualSets(filePatchView.getSelectedHunks(), new Set([hunk0, hunk1, hunk2])); - assertEqualSets(filePatchView.getSelectedLines(), new Set([...hunk0.lines.slice(1), ...hunk1.lines, ...hunk2.lines])); - await hunkView1.props.mousedownOnHeader({button: 0, shiftKey: true}, hunk1); - await filePatchView.mouseup(); - assertEqualSets(filePatchView.getSelectedHunks(), new Set([hunk0, hunk1])); - assertEqualSets(filePatchView.getSelectedLines(), new Set([...hunk0.lines.slice(1), ...hunk1.lines])); + getHunkView(0).prop('mousedownOnHeader')({button: 0}, hunk0); + wrapper.instance().mouseup(); + + assertEqualSets(wrapper.instance().getSelectedHunks(), new Set([hunk0])); + assertEqualSets(wrapper.instance().getSelectedLines(), new Set(hunk0.lines.slice(1))); + + getHunkView(2).prop('mousedownOnHeader')({button: 0, shiftKey: true}, hunk2); + wrapper.instance().mouseup(); + + assertEqualSets(wrapper.instance().getSelectedHunks(), new Set([hunk0, hunk1, hunk2])); + assertEqualSets(wrapper.instance().getSelectedLines(), new Set([...hunk0.lines.slice(1), ...hunk1.lines, ...hunk2.lines])); + + getHunkView(1).prop('mousedownOnHeader')({button: 0, shiftKey: true}, hunk1); + wrapper.instance().mouseup(); + + assertEqualSets(wrapper.instance().getSelectedHunks(), new Set([hunk0, hunk1])); + assertEqualSets(wrapper.instance().getSelectedLines(), new Set([...hunk0.lines.slice(1), ...hunk1.lines])); // in hunk selection mode, shift-click hunk header for separate hunk that comes before selected line - await hunkView2.props.mousedownOnHeader({button: 0}, hunk2); - await filePatchView.mouseup(); - assertEqualSets(filePatchView.getSelectedHunks(), new Set([hunk2])); - assertEqualSets(filePatchView.getSelectedLines(), new Set(hunk2.lines)); - await hunkView0.props.mousedownOnHeader({button: 0, shiftKey: true}, hunk0); - await filePatchView.mouseup(); - assertEqualSets(filePatchView.getSelectedHunks(), new Set([hunk0, hunk1, hunk2])); - assertEqualSets(filePatchView.getSelectedLines(), new Set([...hunk0.lines.slice(1), ...hunk1.lines, ...hunk2.lines])); - await hunkView1.props.mousedownOnHeader({button: 0, shiftKey: true}, hunk1); - await filePatchView.mouseup(); - assertEqualSets(filePatchView.getSelectedHunks(), new Set([hunk1, hunk2])); - assertEqualSets(filePatchView.getSelectedLines(), new Set([...hunk1.lines, ...hunk2.lines])); + getHunkView(2).prop('mousedownOnHeader')({button: 0}, hunk2); + wrapper.instance().mouseup(); + + assertEqualSets(wrapper.instance().getSelectedHunks(), new Set([hunk2])); + assertEqualSets(wrapper.instance().getSelectedLines(), new Set(hunk2.lines)); + + getHunkView(0).prop('mousedownOnHeader')({button: 0, shiftKey: true}, hunk0); + wrapper.instance().mouseup(); + + assertEqualSets(wrapper.instance().getSelectedHunks(), new Set([hunk0, hunk1, hunk2])); + assertEqualSets(wrapper.instance().getSelectedLines(), new Set([...hunk0.lines.slice(1), ...hunk1.lines, ...hunk2.lines])); + + getHunkView(1).prop('mousedownOnHeader')({button: 0, shiftKey: true}, hunk1); + wrapper.instance().mouseup(); + + assertEqualSets(wrapper.instance().getSelectedHunks(), new Set([hunk1, hunk2])); + assertEqualSets(wrapper.instance().getSelectedLines(), new Set([...hunk1.lines, ...hunk2.lines])); }); if (process.platform !== 'win32') { // https://github.com/atom/github/issues/514 - describe('mousedownOnLine', () => { - it('does not select line or set selection to be in progress if ctrl-key is pressed and not on windows', async () => { + describe('mousedownOnLine', function() { + it('does not select line or set selection to be in progress if ctrl-key is pressed and not on windows', function() { const hunk0 = new Hunk(1, 1, 2, 4, '', [ new HunkLine('line-1', 'added', -1, 1), new HunkLine('line-2', 'added', -1, 2), new HunkLine('line-3', 'added', -1, 3), ]); - const hunkViews = new Map(); - function registerHunkView(hunk, view) { hunkViews.set(hunk, view); } - const filePatchView = new FilePatchView({commandRegistry, hunks: [hunk0], registerHunkView}); - const hunkView0 = hunkViews.get(hunk0); + const wrapper = shallow(React.cloneElement(component, {hunks: [hunk0]})); - filePatchView.togglePatchSelectionMode(); - assert.equal(filePatchView.getPatchSelectionMode(), 'line'); + wrapper.instance().togglePatchSelectionMode(); + assert.equal(wrapper.instance().getPatchSelectionMode(), 'line'); - sinon.spy(filePatchView.selection, 'addOrSubtractLineSelection'); - sinon.spy(filePatchView.selection, 'selectLine'); + sinon.spy(wrapper.state('selection'), 'addOrSubtractLineSelection'); + sinon.spy(wrapper.state('selection'), 'selectLine'); - await hunkView0.props.mousedownOnLine({button: 0, detail: 1, ctrlKey: true}, hunk0, hunk0.lines[2]); - assert.isFalse(filePatchView.selection.addOrSubtractLineSelection.called); - assert.isFalse(filePatchView.selection.selectLine.called); - assert.isFalse(filePatchView.mouseSelectionInProgress); + wrapper.find('HunkView').prop('mousedownOnLine')({button: 0, detail: 1, ctrlKey: true}, hunk0, hunk0.lines[2]); + assert.isFalse(wrapper.state('selection').addOrSubtractLineSelection.called); + assert.isFalse(wrapper.state('selection').selectLine.called); + assert.isFalse(wrapper.instance().mouseSelectionInProgress); }); }); // https://github.com/atom/github/issues/514 - describe('mousedownOnHeader', () => { - it('does not select line or set selection to be in progress if ctrl-key is pressed and not on windows', async () => { + describe('mousedownOnHeader', function() { + it('does not select line or set selection to be in progress if ctrl-key is pressed and not on windows', function() { const hunk0 = new Hunk(1, 1, 2, 4, '', [ new HunkLine('line-1', 'added', -1, 1), new HunkLine('line-2', 'added', -1, 2), @@ -322,23 +389,21 @@ describe('FilePatchView', function() { new HunkLine('line-7', 'added', -1, 9), new HunkLine('line-8', 'added', -1, 10), ]); - const hunkViews = new Map(); - function registerHunkView(hunk, view) { hunkViews.set(hunk, view); } - const filePatchView = new FilePatchView({commandRegistry, hunks: [hunk0, hunk1], registerHunkView}); - const hunkView0 = hunkViews.get(hunk0); + const wrapper = shallow(React.cloneElement(component, {hunks: [hunk0, hunk1]})); - filePatchView.togglePatchSelectionMode(); - assert.equal(filePatchView.getPatchSelectionMode(), 'line'); + wrapper.instance().togglePatchSelectionMode(); + assert.equal(wrapper.instance().getPatchSelectionMode(), 'line'); - sinon.spy(filePatchView.selection, 'addOrSubtractLineSelection'); - sinon.spy(filePatchView.selection, 'selectLine'); + sinon.spy(wrapper.state('selection'), 'addOrSubtractLineSelection'); + sinon.spy(wrapper.state('selection'), 'selectLine'); // ctrl-click hunk line - await hunkView0.props.mousedownOnHeader({button: 0, detail: 1, ctrlKey: true}, hunk0); - assert.isFalse(filePatchView.selection.addOrSubtractLineSelection.called); - assert.isFalse(filePatchView.selection.selectLine.called); - assert.isFalse(filePatchView.mouseSelectionInProgress); + wrapper.find({hunk: hunk0}).prop('mousedownOnHeader')({button: 0, detail: 1, ctrlKey: true}, hunk0); + + assert.isFalse(wrapper.state('selection').addOrSubtractLineSelection.called); + assert.isFalse(wrapper.state('selection').selectLine.called); + assert.isFalse(wrapper.instance().mouseSelectionInProgress); }); }); } @@ -359,42 +424,50 @@ describe('FilePatchView', function() { new HunkLine('line-8', 'added', -1, 10), ]), ]; - const filePatchView = new FilePatchView({commandRegistry, hunks}); - document.body.appendChild(filePatchView.element); - filePatchView.element.style.overflow = 'scroll'; - filePatchView.element.style.height = '100px'; - - filePatchView.togglePatchSelectionMode(); - filePatchView.selectNext(); - await new Promise(resolve => filePatchView.element.addEventListener('scroll', resolve)); - assert.isAbove(filePatchView.element.scrollTop, 0); - const initScrollTop = filePatchView.element.scrollTop; - - filePatchView.togglePatchSelectionMode(); - filePatchView.selectNext(); - await new Promise(resolve => filePatchView.element.addEventListener('scroll', resolve)); - assert.isAbove(filePatchView.element.scrollTop, initScrollTop); - - filePatchView.element.remove(); + + const root = document.createElement('div'); + root.style.overflow = 'scroll'; + root.style.height = '100px'; + document.body.appendChild(root); + + const wrapper = mount(React.cloneElement(component, {hunks}), {attachTo: root}); + + wrapper.instance().togglePatchSelectionMode(); + wrapper.instance().selectNext(); + await new Promise(resolve => root.addEventListener('scroll', resolve)); + assert.isAbove(root.scrollTop, 0); + const initScrollTop = root.scrollTop; + + wrapper.instance().togglePatchSelectionMode(); + wrapper.instance().selectNext(); + await new Promise(resolve => root.addEventListener('scroll', resolve)); + assert.isAbove(root.scrollTop, initScrollTop); + + root.remove(); }); - it('assigns the appropriate stage button label on hunks based on the stagingStatus and selection mode', async function() { + it('assigns the appropriate stage button label on hunks based on the stagingStatus and selection mode', function() { 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({commandRegistry, hunks: [hunk], stagingStatus: 'unstaged', registerHunkView}); - assert.equal(hunkView.props.stageButtonLabel, 'Stage Hunk'); - 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({commandRegistry, hunks: [hunk], stagingStatus: 'unstaged', registerHunkView}); - assert.equal(hunkView.props.stageButtonLabel, 'Stage Selection'); + + const wrapper = shallow(React.cloneElement(component, { + hunks: [hunk], + stagingStatus: 'unstaged', + })); + + assert.equal(wrapper.find('HunkView').prop('stageButtonLabel'), 'Stage Hunk'); + wrapper.setProps({stagingStatus: 'staged'}); + assert.equal(wrapper.find('HunkView').prop('stageButtonLabel'), 'Unstage Hunk'); + + wrapper.instance().togglePatchSelectionMode(); + + assert.equal(wrapper.find('HunkView').prop('stageButtonLabel'), 'Unstage Selection'); + wrapper.setProps({stagingStatus: 'unstaged'}); + assert.equal(wrapper.find('HunkView').prop('stageButtonLabel'), 'Stage Selection'); }); describe('didClickStageButtonForHunk', function() { // ref: https://github.com/atom/github/issues/339 - it('selects the next hunk after staging', async function() { + it('selects the next hunk after staging', function() { const hunks = [ new Hunk(1, 1, 2, 4, '', [ new HunkLine('line-1', 'unchanged', 1, 1), @@ -416,10 +489,15 @@ describe('FilePatchView', function() { ]), ]; - 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]])); + const wrapper = shallow(React.cloneElement(component, { + hunks, + stagingStatus: 'unstaged', + })); + + wrapper.find({hunk: hunks[2]}).prop('didClickStageButton')(); + wrapper.setProps({hunks: hunks.filter(h => h !== hunks[2])}); + + assertEqualSets(wrapper.state('selection').getSelectedHunks(), new Set([hunks[1]])); }); }); @@ -431,19 +509,17 @@ describe('FilePatchView', function() { 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'); + const wrapper = mount(React.cloneElement(component, {hunks})); + commandRegistry.dispatch(wrapper.getDOMNode(), 'core:move-right'); assert.equal(didSurfaceFile.callCount, 1); }); }); - describe('openFile', () => { - describe('when the selected line is an added line', () => { - it('calls this.props.openCurrentFile with the first selected line\'s new line number', async () => { + describe('openFile', function() { + describe('when the selected line is an added line', function() { + it('calls this.props.openCurrentFile with the first selected line\'s new line number', function() { const hunks = [ new Hunk(1, 1, 2, 4, '', [ new HunkLine('line-1', 'unchanged', 1, 1), @@ -454,19 +530,19 @@ describe('FilePatchView', function() { ]), ]; - const openCurrentFile = sinon.stub(); - const filePatchView = new FilePatchView({commandRegistry, hunks, openCurrentFile}); - filePatchView.mousedownOnLine({button: 0, detail: 1}, hunks[0], hunks[0].lines[2]); - await filePatchView.mousemoveOnLine({}, hunks[0], hunks[0].lines[3]); - await filePatchView.mouseup(); + const wrapper = shallow(React.cloneElement(component, {hunks})); + + wrapper.instance().mousedownOnLine({button: 0, detail: 1}, hunks[0], hunks[0].lines[2]); + wrapper.instance().mousemoveOnLine({}, hunks[0], hunks[0].lines[3]); + wrapper.instance().mouseup(); - filePatchView.openFile(); - assert.deepEqual(openCurrentFile.args[0], [{lineNumber: 3}]); + wrapper.instance().openFile(); + assert.isTrue(openCurrentFile.calledWith({lineNumber: 3})); }); }); - describe('when the selected line is a deleted line in a non-empty file', () => { - it('calls this.props.openCurrentFile with the new start row of the first selected hunk', async () => { + describe('when the selected line is a deleted line in a non-empty file', function() { + it('calls this.props.openCurrentFile with the new start row of the first selected hunk', function() { const hunks = [ new Hunk(1, 1, 2, 4, '', [ new HunkLine('line-1', 'unchanged', 1, 1), @@ -483,19 +559,19 @@ describe('FilePatchView', function() { ]), ]; - const openCurrentFile = sinon.stub(); - const filePatchView = new FilePatchView({commandRegistry, hunks, openCurrentFile}); - filePatchView.mousedownOnLine({button: 0, detail: 1}, hunks[1], hunks[1].lines[2]); - await filePatchView.mousemoveOnLine({}, hunks[1], hunks[1].lines[3]); - await filePatchView.mouseup(); + const wrapper = shallow(React.cloneElement(component, {hunks})); - filePatchView.openFile(); - assert.deepEqual(openCurrentFile.args[0], [{lineNumber: 17}]); + wrapper.instance().mousedownOnLine({button: 0, detail: 1}, hunks[1], hunks[1].lines[2]); + wrapper.instance().mousemoveOnLine({}, hunks[1], hunks[1].lines[3]); + wrapper.instance().mouseup(); + + wrapper.instance().openFile(); + assert.isTrue(openCurrentFile.calledWith({lineNumber: 17})); }); }); - describe('when the selected line is a deleted line in an empty file', () => { - it('calls this.props.openCurrentFile with a line number of 0', async () => { + describe('when the selected line is a deleted line in an empty file', function() { + it('calls this.props.openCurrentFile with a line number of 0', function() { const hunks = [ new Hunk(1, 0, 4, 0, '', [ new HunkLine('line-5', 'deleted', 1, -1), @@ -505,13 +581,13 @@ describe('FilePatchView', function() { ]), ]; - const openCurrentFile = sinon.stub(); - const filePatchView = new FilePatchView({commandRegistry, hunks, openCurrentFile}); - filePatchView.mousedownOnLine({button: 0, detail: 1}, hunks[0], hunks[0].lines[2]); - await filePatchView.mouseup(); + const wrapper = shallow(React.cloneElement(component, {hunks})); + + wrapper.instance().mousedownOnLine({button: 0, detail: 1}, hunks[0], hunks[0].lines[2]); + wrapper.instance().mouseup(); - filePatchView.openFile(); - assert.deepEqual(openCurrentFile.args[0], [{lineNumber: 0}]); + wrapper.instance().openFile(); + assert.isTrue(openCurrentFile.calledWith({lineNumber: 0})); }); }); }); diff --git a/test/views/hunk-view.test.js b/test/views/hunk-view.test.js index 8dbcdc2f0a..48fd43850a 100644 --- a/test/views/hunk-view.test.js +++ b/test/views/hunk-view.test.js @@ -1,89 +1,126 @@ +import React from 'react'; +import {shallow} from 'enzyme'; + import Hunk from '../../lib/models/hunk'; import HunkLine from '../../lib/models/hunk-line'; import HunkView from '../../lib/views/hunk-view'; describe('HunkView', function() { - it('renders the hunk header and its lines', async function() { - const hunk1 = new Hunk(5, 5, 2, 1, 'function fn {', [ + let component, mousedownOnHeader, mousedownOnLine, mousemoveOnLine, contextMenuOnItem, didClickStageButton; + + beforeEach(function() { + const onlyLine = new HunkLine('only', 'added', 1, 1); + const emptyHunk = new Hunk(1, 1, 1, 1, 'heading', [ + onlyLine, + ]); + + mousedownOnHeader = sinon.spy(); + mousedownOnLine = sinon.spy(); + mousemoveOnLine = sinon.spy(); + contextMenuOnItem = sinon.spy(); + didClickStageButton = sinon.spy(); + + component = ( + <HunkView + hunk={emptyHunk} + headHunk={emptyHunk} + headLine={onlyLine} + isSelected={false} + selectedLines={new Set()} + hunkSelectionMode={false} + stageButtonLabel="" + mousedownOnHeader={mousedownOnHeader} + mousedownOnLine={mousedownOnLine} + mousemoveOnLine={mousemoveOnLine} + contextMenuOnItem={contextMenuOnItem} + didClickStageButton={didClickStageButton} + /> + ); + }); + + it('renders the hunk header and its lines', function() { + const hunk0 = new Hunk(5, 5, 2, 1, 'function fn {', [ new HunkLine('line-1', 'unchanged', 5, 5), new HunkLine('line-2', 'deleted', 6, -1), new HunkLine('line-3', 'deleted', 7, -1), new HunkLine('line-4', 'added', -1, 6), ]); - const view = new HunkView({hunk: hunk1, selectedLines: new Set()}); - const element = view.element; - // eslint-disable-next-line prefer-const - let [line1, line2, line3, line4] = Array.from(element.querySelectorAll('.github-HunkView-line')); - assert.equal(view.refs.header.textContent.trim(), `${hunk1.getHeader().trim()} ${hunk1.getSectionHeading().trim()}`); + const wrapper = shallow(React.cloneElement(component, {hunk: hunk0})); + + assert.equal( + wrapper.find('.github-HunkView-header').render().text().trim(), + `${hunk0.getHeader().trim()} ${hunk0.getSectionHeading().trim()}`, + ); + + const lines0 = wrapper.find('LineView'); assertHunkLineElementEqual( - line1, + lines0.at(0), {oldLineNumber: '5', newLineNumber: '5', origin: ' ', content: 'line-1', isSelected: false}, ); assertHunkLineElementEqual( - line2, + lines0.at(1), {oldLineNumber: '6', newLineNumber: ' ', origin: '-', content: 'line-2', isSelected: false}, ); assertHunkLineElementEqual( - line3, + lines0.at(2), {oldLineNumber: '7', newLineNumber: ' ', origin: '-', content: 'line-3', isSelected: false}, ); assertHunkLineElementEqual( - line4, + lines0.at(3), {oldLineNumber: ' ', newLineNumber: '6', origin: '+', content: 'line-4', isSelected: false}, ); - const hunk2 = new Hunk(8, 8, 1, 1, 'function fn2 {', [ + const hunk1 = new Hunk(8, 8, 1, 1, 'function fn2 {', [ new HunkLine('line-1', 'deleted', 8, -1), new HunkLine('line-2', 'added', -1, 8), ]); - const lines = Array.from(element.querySelectorAll('.github-HunkView-line')); - line1 = lines[0]; - line2 = lines[1]; + wrapper.setProps({hunk: hunk1}); - await view.update({hunk: hunk2, selectedLines: new Set()}); + assert.equal( + wrapper.find('.github-HunkView-header').render().text().trim(), + `${hunk1.getHeader().trim()} ${hunk1.getSectionHeading().trim()}`, + ); - assert.equal(view.refs.header.textContent.trim(), `${hunk2.getHeader().trim()} ${hunk2.getSectionHeading().trim()}`); + const lines1 = wrapper.find('LineView'); assertHunkLineElementEqual( - line1, + lines1.at(0), {oldLineNumber: '8', newLineNumber: ' ', origin: '-', content: 'line-1', isSelected: false}, ); assertHunkLineElementEqual( - line2, + lines1.at(1), {oldLineNumber: ' ', newLineNumber: '8', origin: '+', content: 'line-2', isSelected: false}, ); - await view.update({hunk: hunk2, selectedLines: new Set([hunk2.getLines()[1]])}); + wrapper.setProps({ + selectedLines: new Set([hunk1.getLines()[1]]), + }); + + const lines2 = wrapper.find('LineView'); assertHunkLineElementEqual( - line1, + lines2.at(0), {oldLineNumber: '8', newLineNumber: ' ', origin: '-', content: 'line-1', isSelected: false}, ); assertHunkLineElementEqual( - line2, + lines2.at(1), {oldLineNumber: ' ', newLineNumber: '8', origin: '+', content: 'line-2', isSelected: true}, ); }); - it('adds the is-selected class based on the isSelected property', async function() { - const hunk = new Hunk(5, 5, 2, 1, '', []); - const view = new HunkView({hunk, selectedLines: new Set(), isSelected: true}); - assert(view.element.classList.contains('is-selected')); + it('adds the is-selected class based on the isSelected property', function() { + const wrapper = shallow(React.cloneElement(component, {isSelected: true})); + assert.isTrue(wrapper.find('.github-HunkView').hasClass('is-selected')); - await view.update({hunk, selectedLines: new Set(), isSelected: false}); - assert(!view.element.classList.contains('is-selected')); + wrapper.setProps({isSelected: false}); + + assert.isFalse(wrapper.find('.github-HunkView').hasClass('is-selected')); }); - it('calls the didClickStageButton handler when the staging button is clicked', async function() { - const hunk = new Hunk(5, 5, 2, 1, '', [new HunkLine('line-1', 'unchanged', 5, 5)]); - const didClickStageButton1 = sinon.spy(); - const view = new HunkView({hunk, selectedLines: new Set(), didClickStageButton: didClickStageButton1}); - view.refs.stageButton.dispatchEvent(new MouseEvent('click')); - assert(didClickStageButton1.calledOnce); - - const didClickStageButton2 = sinon.spy(); - await view.update({didClickStageButton: didClickStageButton2, hunk, selectedLines: new Set()}); - view.refs.stageButton.dispatchEvent(new MouseEvent('click')); - assert(didClickStageButton2.calledOnce); + it('calls the didClickStageButton handler when the staging button is clicked', function() { + const wrapper = shallow(component); + + wrapper.find('.github-HunkView-stageButton').simulate('click'); + assert.isTrue(didClickStageButton.called); }); describe('line selection', function() { @@ -96,33 +133,33 @@ describe('HunkView', function() { new HunkLine('line-5', 'deleted', 1234, 1234), ]); - const mousedownOnLine = sinon.spy(); - const mousemoveOnLine = sinon.spy(); // selectLine callback not called when selectionEnabled = false - const view = new HunkView({hunk, selectedLines: new Set(), mousedownOnLine, mousemoveOnLine, selectionEnabled: false}); - const element = view.element; - const lineElements = Array.from(element.querySelectorAll('.github-HunkView-line')); - const mousedownEvent = new MouseEvent('mousedown'); - lineElements[0].dispatchEvent(mousedownEvent); - assert.deepEqual(mousedownOnLine.args[0], [mousedownEvent, hunk, hunk.lines[0]]); + const wrapper = shallow(React.cloneElement(component, {hunk, selectionEnabled: false})); + const lineDivAt = index => wrapper.find('LineView').at(index).shallow().find('.github-HunkView-line'); - const mousemoveEvent = new MouseEvent('mousemove'); - lineElements[1].dispatchEvent(mousemoveEvent); - assert.deepEqual(mousemoveOnLine.args[0], [mousemoveEvent, hunk, hunk.lines[1]]); + const payload0 = {}; + lineDivAt(0).simulate('mousedown', payload0); + assert.isTrue(mousedownOnLine.calledWith(payload0, hunk, hunk.lines[0])); + + const payload1 = {}; + lineDivAt(1).simulate('mousemove', payload1); + assert.isTrue(mousemoveOnLine.calledWith(payload1, hunk, hunk.lines[1])); // we don't call handler with redundant events - assert.equal(mousemoveOnLine.args.length, 1); - lineElements[1].dispatchEvent(new MouseEvent('mousemove')); - assert.equal(mousemoveOnLine.args.length, 1); - lineElements[2].dispatchEvent(new MouseEvent('mousemove')); - assert.equal(mousemoveOnLine.args.length, 2); + assert.equal(mousemoveOnLine.callCount, 1); + lineDivAt(1).simulate('mousemove'); + assert.equal(mousemoveOnLine.callCount, 1); + lineDivAt(2).simulate('mousemove'); + assert.equal(mousemoveOnLine.callCount, 2); }); }); }); -function assertHunkLineElementEqual(lineElement, {oldLineNumber, newLineNumber, origin, content, isSelected}) { - assert.equal(lineElement.querySelector('.github-HunkView-lineNumber.is-old').textContent, oldLineNumber); - assert.equal(lineElement.querySelector('.github-HunkView-lineNumber.is-new').textContent, newLineNumber); - assert.equal(lineElement.querySelector('.github-HunkView-lineContent').textContent, origin + content); - assert.equal(lineElement.classList.contains('is-selected'), isSelected); +function assertHunkLineElementEqual(lineWrapper, {oldLineNumber, newLineNumber, origin, content, isSelected}) { + const subWrapper = lineWrapper.shallow(); + + assert.equal(subWrapper.find('.github-HunkView-lineNumber.is-old').render().text(), oldLineNumber); + assert.equal(subWrapper.find('.github-HunkView-lineNumber.is-new').render().text(), newLineNumber); + assert.equal(subWrapper.find('.github-HunkView-lineContent').render().text(), origin + content); + assert.equal(subWrapper.find('.github-HunkView-line').hasClass('is-selected'), isSelected); } diff --git a/test/views/portal.test.js b/test/views/portal.test.js index f288918413..841f931318 100644 --- a/test/views/portal.test.js +++ b/test/views/portal.test.js @@ -17,16 +17,33 @@ class Component extends React.Component { } describe('Portal', function() { + let renderer; + + beforeEach(function() { + renderer = createRenderer(); + }); + it('renders a subtree into a different dom node', function() { - const renderer = createRenderer(); renderer.render(<Portal><Component text="hello" /></Portal>); - assert.equal(renderer.instance.getElement().textContent, 'hello'); - assert.equal(renderer.instance.getRenderedSubtree().getText(), 'hello'); + assert.strictEqual(renderer.instance.getElement().textContent, 'hello'); + assert.strictEqual(renderer.instance.getRenderedSubtree().getText(), 'hello'); const oldSubtree = renderer.instance.getRenderedSubtree(); + renderer.render(<Portal><Component text="world" /></Portal>); - assert.equal(renderer.lastInstance, renderer.instance); - assert.equal(oldSubtree, renderer.instance.getRenderedSubtree()); - assert.equal(renderer.instance.getElement().textContent, 'world'); - assert.equal(renderer.instance.getRenderedSubtree().getText(), 'world'); + assert.strictEqual(renderer.lastInstance, renderer.instance); + assert.strictEqual(oldSubtree, renderer.instance.getRenderedSubtree()); + assert.strictEqual(renderer.instance.getElement().textContent, 'world'); + assert.strictEqual(renderer.instance.getRenderedSubtree().getText(), 'world'); + }); + + it('constructs a view facade that delegates methods to the root DOM node and component instance', function() { + renderer.render(<Portal><Component text="yo" /></Portal>); + + const view = renderer.instance.getView(); + + assert.strictEqual(view.getElement().textContent, 'yo'); + assert.strictEqual(view.getText(), 'yo'); + assert.strictEqual(view.getPortal(), renderer.instance); + assert.strictEqual(view.getInstance().getText(), 'yo'); }); });