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');
   });
 });